Chapter 2. 객체의 종류
- VO(Value Object)란 어떤 객체인가?
- DTO(Data Transfer Object)란 어떤 객체인가?
- DAO(Data Access Objec)란 어떤 객체인가?
- 엔티티(Entity)란 어떤 객체인가?
흔히 검색하면 나오는 개념으로는 다음과 같다.
- VO는 Value Object로 값 객체라는 뜻이다. 쓰기 작업이 불가한 읽기 전용 객체를 말한다.
- DTO는 계층 간 데이터 교환에 사용되는 객체이다. 대표적인 예로 DB에 데이터를 넣을 때나 DB에서 데이터를 불러올 때 DTO를 사용한다.
- DAO는 데이터베이스에 접근하는 데 사용되는 객체이다.
- 엔티티는 JPA @Entity이며, 테이블에 1:1로 대응되고, 각각을 구별할 수 있는 식별자를 가지고 있다.
하지만 결코 충분하지 못하며, 어떤 답변은 틀리다.
@Getter
public class UserInfoVO {
private final long id;
private final String username;
private final String password;
private final String email;
}
이 글을 다 읽고 확인해 보면 될 질문이다.
- VO를 설명하기 위한 키워드로 '읽기 전용'이라는 특징이 전부인가?
- VO를 쓰는 이유는 무엇이고, 왜 읽기 전용으로 만드는 것일까?
- UserInfoVO는 VO인가? VO라면 왜 그렇고, 아니라면 왜 아닌가?
- DTO를 설명해 달라고 하면 대부분 데이터베이스와 연관된 사례를 예시로 든다. 데이터베이스를 예시로 들지 않고 설명할 수 있나?
- DTO는 DAO를 이용해 데이터베이스에 저장하기 위한 객체라고 봐도 되나?
- DTO와 DAO는 긴밀하게 관련된 객체인가?
- 엔티티는 JPA의 엔티니(@Entity)와 1:1로 대응되는 개념인가?
- 엔티티는 JPA의 엔티티라면 JPA가 없던 시절에 엔티티라는 개념은 존재하지 않았나?
- 도메인 엔티티라는 용어와 DB 엔티티라는 용어가 있는데 둘의 차이는?
2.1 VO (Value Objec: 값 객체)
public final class Color {
public final int r;
public final int g;
public final int b;
public Color(int r, int g, int b) {
if (r<0 || r >255 ||
g<0 || g >255 ||
b<0 || b >255) {
this.r = r;
this.g = g;
this.b = b;
}
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return true;
}
fianl Color color = (Color) o;
return r == color.r && g == color.g && b == color.b;
}
@Override
public int hasCode() {
return Objects.hash(r, g, b);
}
}
해당 코드는 VO(Value Object)이다. (엄밀히 말하면 클래스는 객체가 아니므로 Color 클래스가 VO는 아니고, Color 클래스로 만들어진 객체가 VO이다. 하지만 편의상 클래스를 이렇게 표현했다.) Color 클래스가 VO라는 것은 Color 클래스로 만들어진 객체를 숫자 1,2,3과 같은 값으로 볼 수 있다는 의미다.
값에는 어떤 특징들이 있는지 소프트웨어 설계자 입장에서 본다면, 값은 불변성, 동등성, 자가 검증이라는 특징이 있다. 이 세 가지 특징을 만족할 때 VO라고 한다. 세 가지 특징은 불변성, 동등성, 자가 검증이다.
2.1.1 불변성
불변성이란 '변하지 않는다'는 의미이다. 예를 들어, 숫자 1은 영원히 1이다. 3000년 전에도, 만년 뒤에도 1은 변하지 않는다. VO는 이와 같이 한 번 생성되면 내부 상태가 절대 바뀌지 않아야 한다.
불변성이 중요한 이유
불변성은 시스템의 복잡도를 줄이고, 코드의 예측 가능성과 신뢰성을 높인다. 소프트웨어는 불확실성으로 가득한 복잡계이다.
- 네트워크 호출이나 DB에서 데이터를 가져오는 동작, 병렬 실행 등으로 인해 코드의 동작이 예측하기 어려운 경우가 많다.
- 병렬로 동작할 경우 같은 객체라도 내부에 변수가 있다면, 그 객체를 참조하는 다른 코드의 결과도 달라질 수 있다.
이처럼 소프트웨어에는 불확실한 요소가 너무 많아서 믿을 수 있고 확실한 영역을 최대한 많이 만드는 것이 중요하다. 여기서 믿을 수 있는 코드란 항상 변하지 않고 똑같은 결과와 똑같은 값만 돌려주는 코드를 의미한다. 따라서, 불변성을 가진 객체는 한 번 생성된 이후 값이 바뀌지 않아 항상 같은 결과를 보장한다. 이로 인해 시스템의 신뢰성이 높아지고, 다른 객체와 협력할 때 예측 가능해진다.
자바에서 불변 객체를 만드는 방법
자바에서는 final 예약어를 활용해 멤버 변수를 불변으로 선언한다. 예를 들어, Color 클래스에서 r, g, b 멤버 변수는 final로 선언되어 한 번 할당된 후 변경되지 않는다. 물론 VO는 단순히 모든 멤버 변수를 final로 선언한다고 해서 완벽한 불변성을 갖는 것은 아니다.
- final로 선언한다 해도 만약 VO 내에 참조 타입의 객체가 있고, 그 객체가 불변하지 않다면 전체 불변성이 깨질 수 있다.
- 또한, VO 내의 모든 메서드는 입력이 같을 때 항상 같은 결과를 반환하는 순수 함수여야 한다.
- VO 클래스 자체도 final로 선언하여 상속을 방지해야 불변성이 유지된다.
즉, "모든 멤버 변수가 final이면 VO다"라는 단순한 명제는 옳지 않으며, 불변성은 변수뿐만 아니라 메서드의 동작, 클래스 상속 구조 등 전체 설계에 걸쳐 고려되어야 한다.
불변성을 통한 시스템 신뢰성 향상
위에서 신뢰성이 높아지고 다른 객체와 협력할 때 예측 가능해진다고 했다. 그렇다면 예측할 수 없다는 것은 무엇일까? 불변성을 가진 객체는 내부 상태가 변경되지 않기 때문에, 여러 스레드가 동시에 접근하더라도 항상 일관된 결과를 반환한다. 따라서 신뢰성이 높아지고, 다른 객체와 협력할 때 예측이 가능해진다.
그렇다면 예측할 수 없다는 것은 무슨 의미일까? 예를 들어, 아래의 AccountInfo 클래스는 불변 객체가 아니라면 멀티스레드 환경에서 getLevel() 호출 결과가 시점에 따라 달라질 수 있다.
@Setter
public class AccountInfo {
private long mileage;
public AccountInfo getLevel() {
if (mileage > 100_000) return AccountLevel.DIAMOND;
else if (mileage > 50_000) return AccountLevel.GOLD;
else if (mileage > 30_000) return AccountLevel.SILVER;
else if (mileage > 10_000) return AccountLevel.BRONZE;
else return AccountLevel.NONE;
}
}
하지만, 다음과 같이 불변 객체로 수정하면, 멀티스레드 환경에서도 항상 일관된 결과를 보장할 수 있다.
- 멤버 변수를 final로 선언하고, @AllArgsConstructor 어노테이션을 지정했다.
- 상태 변경이 필요한 경우 새로운 객체를 반환하도록 withMileage() 메서드를 추가했다.
@AllArgsConstructor
public class AccountInfo {
private long mileage;
public AccountInfo getLevel() {
if (mileage > 100_000) return AccountLevel.DIAMOND;
else if (mileage > 50_000) return AccountLevel.GOLD;
else if (mileage > 30_000) return AccountLevel.SILVER;
else if (mileage > 10_000) return AccountLevel.BRONZE;
else return AccountLevel.NONE;
}
public AccountInfo withMileage(long mileage) {
return new AccountInfo(this.id, mileage);
}
}
이와 같이 불변 객체를 사용하면, 한 번 생성된 객체의 상태가 바뀌지 않아 예측 가능한 결과를 보장하게 된다. 불변성을 추구하는 것은 시스템 내에서 확실한 부분(예측 가능하고 신뢰할 수 있는 코드)을 늘리는 행위로, 결국 시스템의 신뢰성을 높이는 데 기여한다.
이처럼 불변 객체를 사용하면 프로그램의 신뢰성이 높아진다. 불확실성을 제거함으로써 예측 가능하도록 바뀌기 때문이다. 그래서 불변성을 추구하는 것은 시스템에서 확실한 부분을 늘려가는 행위이므로 신뢰성 있는 시스템을 만들어나가는데 도움이 된다. 따라서 불변성을 추구하고 불확실성을 제거하는 편이 좋다. 하지만 이 말을 오해해서는 안된다. 모든 객체와 함수를 이렇게 만들라는 말이 아니다. 애초에 소프트웨어는 개발 자체가 불확실성을 전제로 이뤄지며, 대부분이 상태 변화에 따라 다르게 동작하도록 만들어지기 때문에 아예 없애는 것은 불가능하다. 그러니 소프트웨어가 상태에 따라 다르게 동작하고 동작을 다르게 하기 위해 상태를 바꾸는 것은 너무나 자연스러운 일이다.
불확실성을 없애는 것은 불가능하다. 하지만 최대한 줄일 수는 있다. 그래서 불확실성을 제거할 수 있는 부분과 안고 가야하는 부분을 나누는 것을 시작으로 시스템에서 확실한 부분을 최대한 늘려야 한다. 그것이 불변성이 추구하는 목적이다.
2.1.2 동등성
VO예시에서 Color는 equals와 hasCode 메서드를 오버라이딩 하고 있다. 이것은 동등성(equality) 때문이다. 그렇다면 동등성은 무엇이고 왜 추구해야 할까?
Color g1 = new Color(0, 1, 0);
Color g2 = new Color(0, 1, 0);
System.out.println(g1 == g2); // false 반환
만약 두 메서드가 없었다면 g1==g2는 false를 반환한다. 하지만 이때 g1==g2는 false를 반환해야 할까? 아님 true를 반환해야 할까? 의미적으로는 초록이라는 색상은 같기 때문에 같다고 대답할 것이다. 하지만 누군가는 다른 참조 값을 갖는 객체이니 다르다 볼 것이다. 둘 다 틀린 말은 아니다. 결과적으로 g1==g2를 예측할 수 없게 됐다. 따라서 이로 인해 또 다른 불확실성이 생긴 것이다. 한편 VO는 이러한 불확실성을 해결하려고 탄생한 개념이다. 그렇다면 불확실성 문제에 어떻게 답할까?
어떤 객체가 값이고 상태가 모두 같다면 같은 객체로 봐야 한다.
즉, 값은 내재된 의미가 같다면 같은 것이다. 숫자 1은 어디서나 숫자 1인 것과 같은 의미이다. 이처럼 값의 가치 판단 기준은 내적 상태에 있다. 따라서 VO를 만들기 위해 자바에서는 객체 간 비교에 사용되는 equals나 hashCode를 오버라이딩할 필요가 있다. 이 둘을 오버라이딩 하지 않는다면 equals와 hashCode 메서드는 객체의 참조값, 즉 메모리 상의 주솟값을 이용해 비교한다. 이는 VO의 설계 의도와 일치하지 않는다. 따라서 두 메서드를 오버라이딩해서 상태를 비교하는 코드로 변경해야 한다.
하지만 아쉽게 많은 프로젝트에서 VO를 만들려고 노력하지만 VO는 동등성을 지켜야 한다는 가치는 반영하지 않는 경우가 많다. 귀찮아서 반영하지 않는 경우도 있겠지만 더 근본적으로는 프로그램을 개발하면서 객체를 비교해야 하는 상황이 많지 않기 때문이다. 심지어 equals와 hasCode를 굳이 오버라이딩하는 것은 번거롭고 잘 읽히지도 않는 지저분한 코드를 만드는 일로 여기기도 한다. 그래서 이렇게까지 해야 하나?라고 원칙의 실효성에 의문이 생기는 것이다. 그리고 이 또한 공감되는 말이다. 결국 VO가 동등성을 추구하는 것은 소프트웨어의 또 다른 예측 불가능성을 해결하기 위함이다. 그러므로 신뢰할 수 있는 객체를 만들 수만 있다면 굳이 불필요하게 느껴지는 작업은 하지 않아도 된다. 이렇게 생각한다면 롬복의 @Value 어노테이션을 추천한다.(스프링의 Value 어노테이션 아님) 이 어노테이션은 값 객체를 만들 때 유용하게 활용할 수 있다.
- equals와 hashCode 메서드가 객체의 상태에 따라 비교하는 메서드로 자동 생성된다.
- 멤버 변수가 final로 선언된다.
- 클래스가 final로 선언된다.
더불어 해당 어노테이션을 사용하면 명시적으로 VO를 나타낼 수 있다는 것도 유용하다.
동등성 vs 식별자
VO에는 식별자를 넣어서는 안된다. VO는 id 같은 식별자 필드를 멤버 변수로 가지고 있어서는 안 된다. 그래서 UserInfoVO는 VO라고 보기 어렵다. 왜냐하면 식별자의 정의와 VO의 동등성 개념이 서로 충돌하기 때문이다. 예를 들어 계정 정보를 알려주는 AccountInfo에 fianl로 id 필드가 들어가게 됐다고 가정해 보자.
AccountInfo a1 = new AccountInfo(1, 20_000);
AccountInfo a2 = a1.withMileage(70_000);
System.out.println(a1 == a2); // false 반환
둘은 id 값이 1로 같은 객체라고 봐야 하지만 VO정의에 따르면 두 객체는 상태가 다르기 때문에(20000과 70000) 다른 객체라고 봐야 한다. 이처럼 식별자가 존재하는 순간 이 객체에 존재하지 않았던 예측 불가능성이 다시 생긴다. 이러한 문제가 발생한 이유는 단순하다. VO로 적합하지 않은 객체를 VO로 만들려 했기 때문이다. 동등성과 식별자는 의미상 충돌이 생길 수밖에 없다. 따라서 식별자를 갖고 있는 객체는 VO가 될 수 없다.
2.1.3 자가 검증
자가 검증(self validation)이란 말 그대로 클래스 스스로 상태가 유효한지 검증할 수 있음을 의미한다. 즉 유효하지 않은 상태의 객체가 만들어질 수 없다는 것을 의미한다.
한번 생성된 VO의 멤버 변수에는 이상한 값이 들어 있을 수 없다.
VO의 목표가 신뢰할 수 있고, 예측 가능한 객체를 만드는 것이라는 점을 알고 있다면 이 특징이 중요한 것을 알 수 있다. 객체가 아무리 불변이고 동등성을 보장한다 해도 값 자체에 잘못된 값이 들어갈 경우 해당 객체는 신뢰할 수 없다. 자가 검증이 완벽한 객체라면 외부에서 아무리 이 객체를 사용할 때 이상한 값이 들어 있는지는 않을지 걱정하지 않아도 된다. 즉, 상태 검증을 위해 if-else, try-catch문을 사용하지 않아도 된다는 것이다. 따라서 VO의 생성자에는 반드시 유효한 상태 값이 들어오는지 검증하는 코드가 있어야 한다.
이러한 자가 검증이란 특징은 VO에서만 사용할 수 있는 것은 아니다. VO로 선언한 객체든 아니든 자가 검증이 완료된 객체는 사용하기가 매우 편하다.
VO의 목적은 신뢰할 수 있고 예측 가능한 객체를 만드는 것이다. 따라서 자가 검증이라는 특징 역시 VO를 정의하는 데 필요한 조건이다. 자가 검증하는 코드가 없다면 우리는 여전히 VO를 믿지 못한다.
실제 개발할 때 중요한 것은 이 객체가 VO냐 아니냐 보다 VO의 목적을 고민해 보는 과정이다. 신뢰할 수 있는 객체를 어떻게 만들지, 이런 값을 불변으로 만들지, 어디까지 값을 보장해야 할지 등 고민하는 과정이 개발에 더 도움 된다. VO를 추구하기보다 불변성, 동등성, 자가 검증, 신뢰할 수 있는 객체를 추구하는 것이 좋다.
2.2 DTO (Data Transfer Object: 데이터 전송 객체)
데이터 전송에 사용되는 객체를 의미한다. 다음은 대표적인 DTO의 예시이다.
public class UserCreatReq {
public String username;
public String password;
public String email;
}
실제 이렇게 만들어진 클래스는 userService.create(userCreateReq); 이렇게 사용될 것이다. 즉, UserCreatReq 클래스는 메서드를 호출할 때 데이터를 전송할 목적으로 만들어진 클래스로서, 이 클래스로부터 만들어진 userCreateReq 객체는 DTO라 할 수 있다.
왜 DTO를 사용하는가?
다른 객체의 메서드를 호출하거나 시스템을 호출할 때 매개변수를 일일이 모두 나열하는 것이 불편하기 때문이다. 즉, DTO는 다른 객체나 시스템에 데이터를 구조적으로 만들어 전달하기 위한 객체이다. 따라서 DTO를 객체라고 보기엔 애매하다. 이름부터 '데이터 덩어리'이기 때문이다. DTO는 오직 데이터를 효과적으로 전달하는 데만 집중하고, 그 밖의 능동적인 역할이나 책임을 가지고 있지 않는다. 따라서 DTO에는 데이터를 읽고 쓰는 것 외에 다른 비즈니스 로직이 들어가서는 안된다.
즉, DTO는 그저 데이터 하나하나를 일일이 나열해서 전달하는 게 불편해서 데이터를 하나로 묶어서 보내려고 만들어진 객체이다.
DTO에 관한 오해
- DTO는 프로세스, 계층 간 데이터 이동에 사용된다.
- DTO는 게터, 세터를 갖고 있다.
- DTO는 데이터베이스에 데이터를 저장하기 위해 사용되는 객체이다.
1. DTO를 프로세스나 계층 간 데이터 이동에만 사용되는 객체로 인식하는 것은 일부 맞는 설명이긴 하지만 불충분하다. 이 설명에 의하면 DTO는 API 통신이나 데이터베이스 통신 같은 곳에서 사용하는 객체를 의미한다. 분명히 둘 다 사용할 수 있지만 그것이 DTO의 목적은 아니다. DTO는 좀 더 단순하고 범용적인 개념이다. DTO의 목적은 데이터를 전달하는 것이다. 그러므로 데이터를 전달하고 싶은 상황이라면 어디서든 사용될 수 있다. 메서드를 호출하는 데 필요한 데이터를 전달할 때 사용할 수도 있고, 원래 여러 번 호출해야 하는 메서드를 한 번의 메서드 호출로 바꾸기 위해서 사용할 수도 있다. 따라서 DTO가 어디서 사용되는지는 중요하지 않다. 데이터 전송이 필요한 모든 곳에서 사용할 수 있다. (하지만 매개변수가 너무 많아서 객체로 한 번 감싸는 것은 추천하진 않는다. 매서드에 필요한 변수가 무엇인지에 관한 의존성을 감추기 때문이다.)
2. 저장한 데이터를 읽고 쓰기 위해서는 게터, 세터가 필요하다 생각해서 이러한 오해가 생긴 것 같다. 하지만 게터, 세터는 내부 데이터를 전달하기 위한 방법 중 하나일 뿐이다. 하지만 멤버 변수를 public로 선언해도 게터, 세터 없이 내부 데이터를 전달할 수 있다.
멤버 변수를 public으로 선언해도 괜찮을까?
다들 객체지향이 추구하는 캡슐화의 가치를 지키기 위해 public으로 선언하지 못한다. 즉, 어떤 객체의 속성 값을 모두 감춤으로써 직접 접근을 막고 메서드를 통한 간접 접근으로 안정성과 유연성을 확보하려 하는 것이다. 하지만 결국 private으로 멤버 변수 선언을 하고 Getter, Setter를 설정해 놓는다면 이 행동이 의미 있을지도 생각해봐야 한다. 게터, 세터가 남발되면 이를 이용해 모든 데이터에 접근하고 수정할 수 있다. 따라서 캡슐화 관점에서 큰 차이가 없을 수도 있다. 물론 public을 권장하거나 남발하라는 의미는 더더욱 아니다. 필요에 따라 public도 선택지가 될 수 있다는 것이다. (물론 user.getEmail() 메서드 호출하는 것과 user.email로 변수에 직접 접근하는 것은 분명히 다르다. 각각 의존하는 행동이 다르기 때문이다. 더불어 캡슐화의 주요 목표 중 하나인 정보 은닉을 위해서 private로 선언하되 일부 Getter만 제공하는 방향이 유리하다.)
3. Data를 데이터베이스로 착각해서 발생할 것이다. 하지만 DTO는 단순히 데이터를 전송하기 위한 객체일 뿐 그 이상도 이하도 아니다. API 통신에 사용되는 요청 본문(request body), 응답 본문(response body)을 받는 데 사용되는 객체도 DTO이고, 데이터베이스에서 데이터를 불러오고 저장하는 데 사용되는 객체도 DTO이다. 그리고 객체 간에 데이터를 주고받기 위한 목적으로 만들어진 객체도 DTO이다.
2.3 DAO (Data Access Object: 데이터 접근 객체)
DAO는 데이터베이스 접근과 관련된 역할을 지닌 객체를 의미한다. 따라서 다음과 같은 역할을 담당한다.
- 데이터베이스와의 연결을 관리
- 데이터베이스에 연결해 데이터에 대한 CRUD 연산을 수행
- 보안 취약성을 고려한 쿼리 작성
DAO는 데이터에 접근하기 위해 만들어진 객체이다. 복잡하고 번거로운 데이터베이스 접근 관련 로직을 전문적으로 처리하기 위해 만들어진 객체로서 스프링 개발자에게 친숙한 Repository와 같은 개념이라고 보면 된다. 이외에도 DAO는 다양한 역할을 가질 수 있지만, DAO 역할보다는 만들어진 목적을 생각하는 것이 더 바람직하다.
DAO가 만들어진 목적
도메인 로직과 데이터베이스 연결 로직을 분리하기 위함이다. 스프링을 다룬다면 높은 확률로 DB 관련 로직을 작성해야 할 때가 있다. 여기서 DB 연결하고 쿼리를 작성하고 응답 결과를 객체에 매핑한다. 이러한 과정은 서비스를 구현하는 데 필요한 부분이기에 피할 수 없다. 하지만 이 작업이 애플리케이션의 핵심은 아니다. 데이터베이스와 상호작용은 데이터를 저장하고 검색하는 기술에 불과한다. 따라서 실제 애플리케이션의 핵심은 요구사항을 해결하는 비즈니스 로직이고 도메인이다.
비즈니스 로직과 데이터베이스 관련 로직이 섞여있다면 비즈니스 로직이 눈에 들어오지 않을 것이다. 이러한 이유로 개발자들은 비즈니스 로직과 데이터베이스 관련 로직을 분리하고 싶어 했고, 그래서 DAO가 만들어졌다.
자가 점검 리스트
- 서비스 컴포넌트(@Service 지정된 컴포넌트)에서 SQL 쿼리를 만든다.
- 서비스 컴포넌트에서 LIKE 검색을 위해 "%" 문자열을 앞뒤로 붙인다.
- 서비스 컴포넌트에서 EntityManger을 활용해 어떤 로직을 처리한다.
- 서비스 컴포넌트에서 JPA 관련 클래스나 인터페이스를 import 한다.
- 주 데이터베이스로 관계형 데이터베이스를 사용하고 있을 경우 서비스 컴포넌트의 코드를 변경해야만 도큐먼트 베이스로 전환할 수 있다. (없다면 체크)
하나라도 해당이 된다면 비즈니스 로직과 데이터베이스 관련 로직을 제대로 분리하지 못하고 있다는 의미이다.
2.4 엔티티 (Entity: 개체)
JPA의 @Entity를 엔티티라고 오해해서는 안된다. 엔티티는 JPA 개념이 만들어지기 전의 용어이다.
Entity에는 크게 세 가지 엔티티가 있다.
- 도메인 엔티티
- DB 엔티티
- JPA 엔티티
엔티티라는 것은 보편적인 개념이다. 이 개념이 어디에 사용되느냐에 따라 저렇게 분리되는 것이다. 쓰임새는 문맥에 따라 다르지만 기본적 의미는 비슷하다.
2.4.1 도메인 엔티티
도메인이 무엇인지부터 이해해야 한다. 도메인은 비즈니스 영역(domain) 정도로 이해하면 된다. 예를 들어 은행 SW를 만든다면 Account, Transaction, Money와 같은 개념이 사용될 수 있다. 이 개념들을 클래스로 만들 수 있고, 이렇게 만들어진 개념 모델들을 도메인 모델이라고 부른다. 즉, 도메인 모델을 어떤 도메인 문제를 해결하고자 만들어진 클래스 모델이다.
이러한 도메인 모델 중 Account, Transaction은 Money와 다르게 식별자가 존재할 수 있고, 도메인 모델에 걸맞은 조금은 특화된 비즈니스 로직을 가질 수 있다. 나아가 Lifecycle을 가질 수도 있다. 그래서 도메인 모델 중에서도 이렇게 특별한 기능을 가지고 있는 모델들을 도메인 엔티티라고 한다.
도메인 엔티티 특징
- 식별 가능한 식별자를 갖는다.
- 비즈니스 로직을 갖는다.
도메인 엔티티는 식별가능하고 비즈니스 로직을 가지고 있으며, 조금 특별하게 관리되는 클래스로 만들어진 객체이다. 소프트웨어를 개발한다는 것 자체가 어떤 비즈니스 영역의 문제를 해결하고자 하는 것이기 때문에 소프트웨어 개발 분야에서 말하는 엔티티는 보통 도메인 엔티티를 의미한다. 소프트웨어를 만드는 이유는 어떤 도메인에 존재하는 문제를 해결하기 위함이고, 그래서 소프트웨어를 개발할 때 모델링한다는 도메인을 모델링한다를 뜻한다. (도메인 모델링의 주산물은 도메인 모델이고, 여기엔 다양한 객체가 포함되어 있다. 도메인 엔티티, 도메인 VO, 도메인 DTO, 도메인 DAO...)
2.4.2 DB 엔티티
도메인 엔티티의 개념과 상관없이 원래 관계형 데이터베이스 분야에서 어떤 유무형의 객체를 표현하는 데 사용했던 용어이다. 즉, 데이터베이스 분야에서 개체 또는 엔티티라고 하는 것은 데이터베이스에 표현하려고 하는 유형, 무형의 객체로써 서로 구별되는 것을 뜻한다. (여기서 객체는 객체지향의 객체와는 조금 다르나 데이터 모델에 가까운 개념으로 이해하면 된다.)
2.4.3 JPA 엔티티
JPA 엔티티는 관계형 데이터베이스에 있는 데이터를 객체로 매핑하는 데 사용하는 클래스를 말한다. 이때 클래스에 @Entity라는 어노테이션을 지정한다. JPA 엔티티는 관계형 데이터베이스에 뿌리를 두고 있기 때문에 도메인 엔티티나 DB 엔티티 중에서 DB 엔티티에 좀 더 가까운 개념이다. 실제로 JPA에서는 관계형 데이터베이스에서 사용하는 용어를 그대로 가져와서 사용하고 있다.
엔티티 == JPA 엔티티
그래서 엔티티는 JPA 엔티티라고 생각하는 건 틀린 말이다. 보통 소프트웨어 개발 분야에서 말하는 엔티티는 도메인 엔티티이기 때문이다. 또한 JPA 엔티티가 DB 엔티티에 뿌리를 내리고 있지만 JPA 엔티티 == DB 엔티티도 틀린 말이다. 이렇게 인식할 경우 관계형 데이터베이스에 종속된 프로그램을 만들 확률이 높다.
2.4.4 엔티티란?
위의 세 가지 엔티티는 모두 "엔티티"라는 큰 카테고리의 하위 개념에 불과하다. 결국 엔티티란, 프로그래밍 언어나 데이터베이스에서 유무형의 자산을 데이터로 표현하는 방식을 뜻한다.
초기 프로그래밍 언어나 데이터베이스 연구자들은 "어떤 유무형의 자산을 데이터로 어떻게 표현할 것인가"에 대해 고민하며, 사용자(User) 정보(id, email, 이름)와 같은 자산 정보를 표현하기 위한 용어로 "엔티티(entity)"를 도입했다.
객체지향 진영에서는 엔티티를 클래스로, 데이터베이스 진영에서는 테이블로 표현한다. 두 방식 모두 데이터 저장을 위한 공간(멤버 변수 vs 칼럼)과 이를 정형화하여 하나로 묶는 집합(클래스 vs 테이블)을 가진다는 점에서는 유사하지만, 객체지향은 온전한 객체와 객체 간 협력, 데이터베이스는 정합성과 중복 제거에 중점을 둔다. 따라서 엔티티란, 표현하고자 하는 유무형의 자산 자체를 의미하며, 이를 어떻게 표현할지는 각 분야의 목적에 따라 달라진다.
서비스가 발전하면서 도메인 모델(객체)과 데이터베이스 테이블 간의 1:1 매핑은 어려워졌다. 이에 따라 개발자는 DB에서 데이터를 읽어와 도메인 모델로 옮기는 번거로운 작업을 수행해야 했다. 이러한 문제를 해결하기 위해 MyBatis와 같은 라이브러리가 등장해 개발자가 직접 SQL 쿼리를 작성하고, 결과를 도메인 모델에 매핑하도록 도왔다.
하지만 매핑 작업의 반복성과 번거로움이 점차 문제가 되자, ORM(Object-Relational Mapping) 솔루션이 등장했다. ORM은 관계형 데이터베이스의 데이터를 객체에 자동으로 매핑해 주는 방식으로, 자바에서는 대표적으로 JPA(Java Persistence API)와 하이버네이트(Hibernate)가 사용된다.
JPA와 하이버네이트
JPA는 단순히 기술 명세일 뿐이다. JPA라는 껍데기에 하이버네이트가 실제 구현체로 사용된 것이다. 하이버네이트는 JPA에서 정의한 @Id, @Column, @Entity 같은 어노테이션을 참조해서 이를 기준으로 쿼리를 생성하고 데이터를 가져오는 역할을 한다. 이러한 기술 명세와 구현체가 따로 있는 이유는 언제든 구현체를 바꿀 수 있게 하기 위해서이다. 필요에 따라 구현체를 바꿈으로 변화에 유연하게 대처하기 위함이다. 유연하고 의연하게 대처할 수 있는 이유는 기술 명세와 구현체를 따로 분리했기 때문이다. 즉, 역할과 구현을 분리해서 구현체에 종속되는 상황을 피한 것이다. 객체지향뿐만 아니라 시스템 설계에서도 '역할과 구현의 분리', '책임을 위임'과 같은 원리들이 적용되는 덕목이다.
JPA 엔티티란?
객체지향 프로그램에서 사용되니까 객체인 것 같으면서도 관계형 데이터베이스에 뿌리를 둔 엔티티라는 용어를 사용하고 있다.
- JPA의 엔티티는 관계형 데이터베이스의 엔티티를 지칭하는 것이다.
- JPA의 @Entity 어노테이션이 적용된 객체는 영속성 객체(PO: Persistent Object)이다.
- JPA의 @Entity 어노테이션은 영속성 객체를 만들기 위한 도구일 뿐이다.
사실 JPA 엔티티를 엔티티라고 설명하는 것보다 영속성 객체라고 설명하는 것이 더 맞다. JPA 이름 자체도 자바 영속성 API라는 의미이고, 영속성(persistence)란 말 자체로 데이터가 영원히 이어지도록 어딘가에 저장하고 불러오는 것을 의미한다. 그리고 JPA @Entity 어노테이션이 지정된 클래스는 완벽하게 이 같은 역할을 수행한다. JPA 엔티티는 영속성 객체를 만들기 위한 도구일 뿐이다. 그래서 이를 소프트웨어 개발 분야의 엔티티라고 볼 수 없다.
소프트웨어 개발 분야의 엔티티는 도메인 엔티티이다. 그리고 도메인 엔티티는 기능적으로 분류되어 엔티티로서의 역할이 할당되는 것이 아니다. 엔티티는 역할과 책임에 의해 결정되는 것뿐이다. 지금까지 내용을 다음과 같이 정리할 수 있다.
- 엔티티는 데이터로 표현하려는 유무형의 대상이다.
- DB 엔티티는 데이터베이스 분야에서 데이터로 표현하려는 대상이다.
- 소프트웨어 개발 분야에서 말하는 엔티티는 도메인 엔티티다.
- 도메인 엔티티는 도메인 모델 중에서도 식별 가능하고, 비즈니스 로직을 갖고 있으며, 조금 특별하게 관리되는 객체이다.
- JPA의 엔티티는 관계형 데이터베이스의 엔티티를 지칭하는 것이다.
- JPA의 @Entity 어노테이션이 적용된 객체는 영속성 객체이다.
- JPA의 @Entity 어노테이션은 영속성 객체를 만들기 위한 도구일 뿐이다.
- 도메인 엔티티와 DB엔티티는 다르다.
MongoDB를 쓰는 상황이라면?
최근 관계형 데이터베이스의 복잡도와 용량 한계를 극복하고자 나온 NoSQL 중 하나로, 도큐먼트 지향 아키텍처를 통해 데이터를 저장하는 MongoDB를 많이 사용하고 있다. JSON과 유사한 BSON(Binary JSON) 형식으로 데이터를 다루며, 고성능, 확장성을 추구하는 데이터베이스이다. 쉽게 말해, JSON 하나하나를 Document로 보고 이를 저장하고 불러오는 DB이다. 따라서 이러한 데이터베이스를 도큐먼트 데이터베이스라 부른다. 관계형 데이터베이스의 문제를 극복하고자 만들어진 솔루션이다 보니 그 행보가 관계형 데이터베이스와 다르고, 사용하는 용어 측면에서도 차이를 보인다. Entity는 Document에 해당하고, 테이블 대신 컬렉션(Collection)이 표현된다. 따라서 MongoDB를 활용하려면 Entity != JPA Entity라는 것을 더 잘 알고 있어야 할 것이다.
2.5 객체의 다양한 종류
PO(Persistent Object: 영속성 객체)
객체지향에서 자주 활용되는 객체이며, 데이터베이스와의 연동을 추상화하고, 데이터 액세스를 담당함으로써 애플리케이션의 비즈니스 로직과 영속성 로직을 분리하는 목적으로 사용되곤 한다.
SO(Service Object: 서비스 객체)
SO는 DAO 같은 영속성 객체를 통해 도메인을 불러와 도메인에 업무를 지시하기도 하고, 비즈니스 로직이라 불리는 애플리케이션의 코어 로직을 처리하는 객체를 의미한다. 스프링의 @Service 컴포넌트와 같은 개념이다. 사실 스프링에 존재하는 대부분의 컴포넌트 역시 객체지향 언어에서 특수한 역할을 가지고 태어난 객체일 뿐이다.
개발 방향성
VO, DTO, DAO, PO, SO 등 많은 타입의 객체를 활용하고 있다. 하지만 이러한 이름과 정의를 외우고, 용어에 따라 객체를 분류하려고 시도하는 것은 큰 의미가 없다. 이미 잘 사용되는 UserService를 UserSO로 바꿔서 부르는 것이 의미가 없는 것처럼 말이다. 더불어 역할을 칼같이 구분하는 것도 엄청 바람직한 자세는 아니다. VO는 DTO가 아니니까 데이터 전송에 사용될 수 없으며, PO는 불변성이라는 특징을 가지면 안 되고, 지금껏 VO는 자가 검증이 되지 않았는데 VO라 할 수 없는 것.. 모두 다 아니다.
개념을 외우고 엄격한 기준을 적용하는 것보다는 각 개념이 만들어진 이유와 목적을 생각하는 것이 바람직하다. 개념에 프로젝트를 끼워 맞추고, 이름을 짓는 것도 의미 없다. 개념은 그저 개념일 뿐! 집중해야 하는 것은 그 안에 소프트웨어 설계가 추구하는 가치이다.
- 불변성
- 예측 가능성
- 역할의 분리
- 항상성
각 개념을 이해하고 적용하는 과정에서 소프트웨어 개발 역량이 향상된다. 따라서 위 가치를 프로젝트에 지속적으로 적용하는 것이 좋다.
Java의 record
record는 Java 14 프리뷰로 시작해 16부터 정식으로 사용할 수 있는 키워드이다. record는 데이터를 담는 간단한 클래스를 만들 때 사용할 수 있는 키워드로서 이 키워드를 사용해서 만들어진 객체는 VO의 특징을 가진다. 레코드를 이용해 만들어진 객체의 변수는 final 선언을 하지 않아도 final 선언한 것과 같이 동작하며, equals, hashCode, toString 같은 메서드가 자동으로 만들어진다. VO를 만들 때 이 키워드를 사용하면 반복적인 코드 작성을 줄일 수 있다. 더불어 레코드 객체의 변수들은 public Getter 메서드가 자동으로 만들어진다.
Chapter2를 마치며
배운 내용을 간단하게 정의하면 다음과 같을 것이다.
- VO (Value Object): 값을 나타내며 불변성, 동등성, 자가 검증 같은 특징을 가져야 한다.
- DTO (Data Transfer Object): 계층이나 시스템 간 데이터를 간결하게 전달하기 위한 객체로, 데이터 전송 그 자체에 집중한다.
- DAO (Data Access Object): 데이터베이스 접근 로직을 캡슐화하여, 비즈니스 로직과 데이터 접근 로직을 분리하는 역할을 한다.
- 엔티티 (Entity): 프로그래밍 언어나 데이터베이스에서 유무형의 자산을 표현하는 개념으로, 도메인 엔티티, DB 엔티티로 크게 분리된다. 도메인 엔티티는 식별가능하고 비즈니스 로직을 가지고 있으며, 조금 특별하게 관리되는 클래스로 만들어진 객체이다.
내가 이 책을 읽게 된 이유 중 하나는 초반 프로젝트 설계를 진행하며 DDD와 아키텍처 설계에 대해 깊이 고민했기 때문이다. 어찌 보면 나도 이 책에 나오는 Entity, Domain Entity, DAO, DTO, VO와 같은 다양한 개념에 사로잡혀 있던 사람이었다. 사실, 처음에는 이런 개념들을 딱딱 잘 구분해서 지키며 좋은 소프트웨어를 개발하고 싶었지만, 다행히도 이 책을 통해 단순히 개념을 외우고 분류하는 것보다, 각 객체가 스스로 어떤 책임을 지고 그 역할을 수행하며 협력하는지가 훨씬 더 중요하다는 것을 알게 돼서 참 다행이다. (몰랐으면 너무 각각의 개념을 딱 정확하게 분류하기 위해 애먹을 뻔했다..)
VO, DTO, DAO, 엔티티라는 분류는 결국 데이터를 전달하거나 저장하기 위한 도구에 불과하다. 진정 중요한 것은 이들이 시스템 전반에 걸쳐 불변성, 예측 가능성, 책임 분리, 그리고 협력을 통해 신뢰할 수 있는 구조를 만든다는 점이다. 또한, 이런 전반적인 개념들을 이해함으로써 너무 강박에 빠지지 않는, 유연하면서도 좋은 소프트웨어 구조를 설계할 수 있다는 점이 다행이다. 물론 이 책에서 나온 것만으로는 완벽한 좋은 소프트웨어를 만들 수는 없겠지만,, 앞으로 이 책에서 배운 개념들을 실제 설계와 구현에 어떻게 반영할지, 왜 이러한 설계 원칙들이 필요한지를 꾸준히 고민하며 공부해 봐야겠다.
'책책책 책을 읽어요 > 자바,스프링 개발자를 위한 실용주의 프로그래밍' 카테고리의 다른 글
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 1. 절차지향과 비교하기 (2) | 2025.03.15 |
---|