
VO, DTO, DAO, 엔티티란 무엇인가?
스프링을 다루는 개발자라면 VO, DTO, DAO, 엔티티 같은 용어를 한 번쯤은 들어봤을 것이다. 어쩌면 이미 각 용어에 대해 나름의 정의를 가지고 있을 수도 있다. 그렇다면 그 정의는 무엇일까?
- VO(Value Object: 값 객체)란 어떤 객체일까?
- DTO(Data Transfer Object: 데이터 전송 객체)란 어떤 객체일까?
- DAO(Data Access Object: 데이터 접근 객체)란 어떤 객체일까?
- 엔티티(Entity: 개체)란 어떤 객체일까?
실제로 이런 질문을 저연차 개발자에게 던져 보면 대체로 비슷한 답변이 돌아온다. VO는 Value Object이며, 쓰기 작업이 불가능한 읽기 전용 객체, DTO는 계층 간 데이터 교환에 사용되는 객체이고, 대표적으로 데이터를 저장하거나 조회할 때 사용하는 객체, DAO는 데이터베이스에 접근하는 데 사용되는 객체, 엔티티는 JPA의 `@Entity`이며, 테이블과 1:1로 대응되고, 각각을 구분할 수 있는 식별자를 가진다고 설명한다.
하지만 이런 답변들 중 상당수는 충분한 설명이라고 보기 어렵다. 어떤 것은 핵심이 빠져 있고, 어떤 것은 아예 잘못된 이해에 가깝다. 그렇다면 어떤 부분이 잘못됐고, 어떤 내용이 충분하지 않은 설명인지 알아보자.
VO(Value Object: 값 객체)
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) {
throw new IllegalArgumentException("RGB should be 0 to 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 false;
}
final Color color = (Color) o;
return r == color.r &&
g == color.g &&
b == color.b;
}
@Override
public int hashCode() {
return Objects.hash(r, g, b);
}
}
위 코드의 `Color` 클래스는 값 객체, 즉 VO(Value Object)다. 그렇다면 이 객체는 일반적인 객체와 무엇이 다를까? 그리고 VO라는 말은 정확히 무엇을 의미할까?
`Color` 클래스가 VO라는 말은, `Color` 객체를 단순한 참조 대상이 아니라 하나의 값처럼 다룰 수 있다는 뜻이다. 숫자 1, 2, 3, 4를 값으로 다루듯이, `Color(255, 0, 0)` 역시 하나의 값으로 볼 수 있다는 의미다. 즉, `Color`는 객체이지만 동시에 값이다. 그래서 값 객체, 즉 VO라고 부른다.
그렇다면 여기서 말하는 값이란 무엇일까? 그리고 숫자 1 같은 것을 왜 값이라고 부르는 걸까? 사전적인 정의를 가져와 설명할 수도 있겠지만, 우리가 정말 궁금한 것은 값이라는 말의 국어적 뜻이 아니다. 소프트웨어 관점에서 값을 어떻게 이해해야 하는가에 더 가깝다. 그래서 먼저 값이 어떤 특징을 가지는지부터 살펴볼 필요가 있다.
소프트웨어 관점에서 값은 보통 불변성, 동등성, 자가 검증이라는 특징으로 설명할 수 있다. 그리고 어떤 객체가 이런 특징을 가질 때, 그 객체를 VO라고 부른다.
| 특징 | 설명 |
| 불변성 | 값은 변하지 않는다. 예를 들어 숫자 1은 언제나 숫자 1이다. |
| 동등성 | 값은 위치나 시점이 달라도 같으면 같은 값이다. 예를 들어 모든 숫자 1은 어디에 적혀 있든 같은 1이다. |
| 자가 검증 | 값은 그 자체로 올바른 상태를 가져야 한다. 예를 들어 숫자 1은 별도의 검증 없이도 온전한 값이다. |
각 특징을 더 깊게 살펴보자.
불변성
불변성(immutability)이란 말 그대로 변하지 않는다는 뜻이다. 값은 변하지 않는다. 숫자 1은 언제나 1이고, 3,000년 전에도 1이었으며 1만 년 뒤에도 여전히 1일 것이다. 값 자체가 달라지지 않기 때문이다. 이런 점에서 VO 역시 불변성을 중요한 특징으로 가진다.
왜 이런 특징이 중요할까? 소프트웨어는 본질적으로 불확실성이 많은 환경에서 동작한다. 코드 자체는 정상이어도 사용자의 네트워크 환경에 따라 프로그램이 예상과 다르게 동작할 수 있다. 일부 로직이 병렬로 수행되면 실행 결과를 쉽게 예측하기 어려워지기도 한다. 특히 같은 객체를 여러 곳에서 참조하고 있고, 그 객체의 상태가 바뀔 수 있다면 어느 시점에 어떤 값이 들어 있는지 확신하기 어려워진다.
그래서 소프트웨어에서는 믿을 수 있는 영역을 최대한 많이 만드는 것이 중요하다. 여기서 믿을 수 있는 코드란, 외부 상황에 따라 쉽게 흔들리지 않고 항상 같은 값과 같은 결과를 돌려주는 코드를 뜻한다. 그리고 불변 객체는 바로 그런 안정성을 만드는 데 큰 도움을 준다. 값이 바뀌지 않는다면 적어도 그 객체에 대해서는 상태 변화를 의심하지 않아도 되기 때문이다.
그렇다면 자바에서 이런 불변성을 어떻게 표현할 수 있을까? 자바에서는 값을 다시 대입하지 못하게 만들기 위해 `final`이라는 예약어를 제공한다. 그래서 VO를 설계할 때는 멤버 변수를 함부로 변경할 수 없도록 만드는 것이 기본이 된다.
VO는 불변해야 한다. 객체가 생성된 이후 그 안에 담긴 값이 바뀌어서는 안 된다. 그래서 VO의 멤버 변수는 보통 final로 선언되고, 외부에서 상태를 변경할 수 있는 setter 역시 두지 않는 경우가 많다.
하지만 여기서 “모든 멤버 변수가 `final`이면 곧 VO다”라고 이해하면 그것 역시 충분한 설명은 아니다. 왜냐하면 모든 멤버 변수를 `final`로 선언하더라도, 그 안에 들어 있는 참조 객체가 가변 객체라면 전체 객체의 불변성이 깨질 수 있기 때문이다. 즉, 불변 객체 안에 변경 가능한 참조가 들어 있다면 그 객체를 완전히 불변하다고 보기는 어렵다.
예를 들어 멤버 변수 자체는 다시 다른 객체를 가리키지 못하더라도, 그 참조가 가리키는 내부 상태가 계속 바뀔 수 있다면 결국 객체의 값도 변하는 것과 다르지 않다. 그래서 불변성을 이야기할 때는 단순히 `final` 키워드가 붙어 있는지만 볼 것이 아니라, 객체가 실제로 상태 변화를 허용하는 구조인지까지 함께 봐야 한다.
또 하나 중요한 점은, 불변성은 단순히 변수 선언 방식만으로 완성되지 않는다는 것이다. 객체가 외부에 내부 상태를 그대로 노출하거나, 메서드를 통해 자신의 상태를 바꿀 수 있게 두면 `final`이 있더라도 불변 객체라고 보기 어렵다. 결국 중요한 것은 문법 자체보다, 객체가 생성된 이후에도 같은 상태를 계속 유지할 수 있도록 설계되어 있는가에 있다.
동등성
값은 같으면 같은 값이다. 이 말은 너무 당연해 보여서 별 의미 없어 보일 수도 있다. 하지만 객체를 다루는 순간 이 당연한 사실은 더 이상 당연하지 않게 된다. 왜냐하면 객체 세계에는 참조 동일성과 값의 동등성이 서로 다를 수 있기 때문이다.
예를 들어 `new Color(255, 0, 0)`으로 만든 객체가 두 개 있다고 해보자. 이 둘은 서로 다른 메모리 주소를 가지므로 참조 관점에서는 다른 객체다. 하지만 값의 관점에서는 둘 다 같은 빨간색을 의미한다. VO에서 중요한 것은 바로 이 지점이다. VO는 참조가 아니라 값으로 비교되어야 한다.
그래서 VO는 동등성(equality)을 중요한 특징으로 가진다. 같은 값을 가지는 두 VO는 같은 것으로 다뤄져야 한다. `Color` 클래스에서 `equals()`와 `hashCode()`를 재정의한 이유도 여기에 있다. 만약 이 메서드들을 정의하지 않는다면 자바는 기본적으로 참조 기준으로 객체를 비교하게 된다. 그렇게 되면 값은 같지만 다른 객체를 서로 다른 것으로 판단하게 되고, VO의 의미와 어긋나게 된다.
즉, VO에서 중요한 것은 “이 객체가 정확히 어느 인스턴스인가”가 아니다. 더 중요한 것은 “이 객체가 어떤 값을 표현하고 있는가”이다. 같은 빨간색이라면 같은 값이어야 하고, 같은 금액이라면 같은 값이어야 하며, 같은 좌표라면 같은 값이어야 한다. VO는 바로 이런 식으로 동일성을 식별자가 아니라 값으로 판단하는 객체다.
그래서 엔티티와 VO는 이 지점에서 분명하게 갈린다. 엔티티는 같은 속성을 가지고 있더라도 식별자가 다르면 다른 객체로 본다. 반면 VO는 식별자가 아니라 값 자체가 본질이므로, 값이 같다면 같은 객체로 본다. 결국 동등성이란 VO를 VO답게 만드는 핵심 특징 중 하나라고 할 수 있다.
자가 검증
값은 그 자체로 올바른 상태여야 한다. 이것이 자가 검증의 핵심이다. 숫자 1은 누군가가 따로 검증해주지 않아도 이미 1이라는 온전한 값이다. 마찬가지로 VO 역시 생성된 순간부터 이미 유효한 상태여야 한다.
왜 이런 특징이 중요할까? 값 객체가 외부의 검증에 의존하기 시작하면, 그 순간부터 그 객체는 언제든 잘못된 상태로 존재할 수 있게 된다. 예를 들어 `Color` 객체가 `r = -1 `, ` g = 300 ` , ` b = 999 ` 같은 값을 허용한다고 해보자. 이런 객체는 `Color`라는 이름을 가지고는 있지만, 실제로는 유효한 색상을 표현하지 못한다. 즉, 존재는 하지만 의미는 없는 객체가 되어버린다.
그래서 VO는 보통 생성 시점에 자기 스스로를 검증한다. `Color` 클래스가 생성자에서 RGB 값의 범위를 확인하는 것도 이 때문이다. 객체를 만든 뒤 외부에서 “이 값이 올바른가?”를 계속 확인하는 것이 아니라, 애초에 잘못된 값으로는 객체를 만들 수 없게 막는 것이다. 이런 방식은 객체를 사용하는 쪽의 부담도 줄여 준다. 일단 생성된 Color라면 유효하다는 사실을 믿고 사용할 수 있기 때문이다.
이 점은 객체지향적인 설계와도 맞닿아 있다. 객체는 단순히 데이터를 담아두는 통이 아니라, 자기 상태를 스스로 책임지는 존재여야 한다. VO 역시 마찬가지다. 값 객체는 “내가 올바른 값인지 아닌지”를 외부에게 맡기지 않는다. 자신의 생성 규칙을 스스로 알고, 잘못된 값은 애초에 허용하지 않는다.
정리하면 VO는 불변성, 동등성, 자가 검증이라는 특징을 가진다. 불변성은 값이 바뀌지 않는다는 뜻이고, 동등성은 값이 같으면 같은 것으로 본다는 뜻이며, 자가 검증은 값이 스스로 올바른 상태를 보장해야 한다는 뜻이다. 그래서 VO를 단순히 읽기 전용 객체라고만 설명하는 것은 충분하지 않다. VO의 핵심은 읽기 전용 여부 자체가 아니라, 객체를 값처럼 다룰 수 있게 만드는 성질에 있다.
DTO(Data Transfer Object: 데이터 전송 객체)
그렇다면 DTO는 무엇일까? DTO는 말 그대로 데이터를 전달하기 위한 객체다. 더 구체적으로 말하면, 여러 데이터를 하나하나 따로 전달하는 것이 번거롭기 때문에 그것들을 하나로 묶어서 전달하기 위해 만들어진 객체다.
예를 들어 이름, 나이, 주소, 전화번호를 어떤 메서드에 전달해야 한다고 해보자. 이 데이터를 매번 각각의 파라미터로 나열해서 넘길 수도 있다. 하지만 전달해야 할 값이 많아질수록 메서드 시그니처는 길어지고, 호출하는 쪽도 불편해진다. 이럴 때 관련된 데이터를 하나로 묶어서 전달하면 훨씬 다루기 쉬워진다. DTO는 바로 이런 목적에서 나온 객체다.
그래서 DTO를 설명할 때 “계층 간 데이터 이동에 사용되는 객체”라고 말하는 경우가 많다. 물론 틀린 말은 아니다. 실제로 DTO는 컨트롤러와 서비스 사이, 서비스와 외부 시스템 사이, 또는 애플리케이션 내부의 여러 경계에서 자주 사용된다. 하지만 이것만으로 DTO를 정의하면 설명이 너무 좁아진다. DTO의 핵심은 계층이라는 구조 자체가 아니라, 데이터를 전달하기 위해 여러 값을 하나로 묶는다는 데 있다. 즉, DTO는 계층 간 이동에만 쓰이는 객체가 아니라, 데이터 전달이 필요한 모든 맥락에서 사용할 수 있는 객체다.
또 DTO를 이야기할 때 흔히 따라붙는 오해가 하나 더 있다. DTO는 getter와 setter를 가지고 있다는 설명이다. 하지만 이것 역시 DTO의 본질은 아니다. getter와 setter는 데이터를 다루기 위한 하나의 구현 방식일 뿐이다. DTO는 getter와 setter를 가질 수도 있고, 생성자를 통해 초기화할 수도 있으며, 불변 객체로 설계할 수도 있다. 자바의 record 역시 DTO처럼 사용할 수 있다. 중요한 것은 어떻게 구현했느냐가 아니라, 이 객체가 데이터를 전달하기 위해 존재하느냐이다.
DTO를 데이터베이스 저장용 객체라고 설명하는 경우도 많다. 예를 들어 “DTO는 데이터를 저장하거나 조회할 때 사용하는 객체다” 같은 설명이다. 하지만 이 역시 DTO의 정의를 지나치게 데이터베이스 중심으로 좁혀 버린다. DTO는 데이터베이스 저장을 위해서만 존재하는 객체가 아니다. 외부 API 요청과 응답을 표현할 수도 있고, 화면에 필요한 데이터를 묶어서 반환할 수도 있으며, 특정 유스케이스에 필요한 입력과 출력을 담는 객체가 될 수도 있다. 즉, DTO의 본질은 저장이 아니라 전달이다.
결국 DTO는 데이터를 전송하기 위한 목적의 객체다. 데이터가 여러 개 흩어져 있을 때 그것을 하나로 묶어 전달하고, 그 전달 과정을 더 명확하고 편리하게 만들기 위해 사용된다. 그래서 DTO를 이해할 때는 “getter와 setter가 있는가”, “DB와 관련 있는가”, “계층 간 이동에 쓰이는가”보다 먼저, 이 객체가 전달을 위해 존재하는가를 보는 것이 중요하다.
DAO(Data Access Object: 데이터 접근 객체)
DAO는 말 그대로 데이터에 접근하기 위해 만들어진 객체다. 이름 자체가 이미 역할을 설명하고 있다. 그렇다면 DAO는 왜 필요할까?
핵심은 관심사의 분리에 있다. 애플리케이션에는 비즈니스 규칙을 다루는 로직이 있고, 데이터를 저장하고 조회하는 로직이 있다. 이 둘을 한곳에 섞어 두기 시작하면 도메인 로직은 데이터베이스 세부 구현에 쉽게 오염된다. 쿼리를 어떻게 작성할지, 커넥션을 어떻게 다룰지, 어떤 저장소를 사용할지 같은 문제들이 본래의 비즈니스 규칙과 뒤엉키게 된다. DAO는 바로 이런 문제를 줄이기 위해 등장했다.
즉, DAO가 만들어진 목적은 도메인 로직과 데이터베이스 연결 로직을 분리하는 데 있다. 도메인 로직은 “무엇을 해야 하는가”에 집중하고, DAO는 “데이터를 어떻게 읽고 쓸 것인가”에 집중한다. 이 둘을 나누면 코드의 역할이 분명해지고, 변경의 영향도 줄일 수 있다.
그래서 DAO는 흔히 데이터베이스 접근 객체라고 설명된다. 실무에서도 대부분은 그렇게 쓰인다. 하지만 조금 더 넓게 보면 DAO의 핵심은 단순히 데이터베이스라는 기술 그 자체에 있지 않다. 더 본질적인 의미는 애플리케이션이 데이터를 저장소로부터 읽고 쓰는 책임을 별도의 객체로 분리했다는 데 있다. 저장소가 관계형 데이터베이스일 수도 있고, 파일 시스템일 수도 있고, 다른 형태의 영속 저장소일 수도 있다. 중요한 것은 데이터 접근이라는 책임을 분리했다는 점이다.
물론 오늘날 스프링 생태계에서는 전통적인 DAO라는 표현보다 Repository라는 표현이 더 자주 쓰인다. 특히 JPA를 사용할 때는 `JpaRepository` 같은 추상화가 널리 사용되기 때문에 DAO라는 용어가 예전보다 덜 직접적으로 등장하기도 한다. 하지만 이름이 달라졌다고 해서 본질이 달라지는 것은 아니다. 데이터 접근 책임을 도메인 로직과 분리한다는 생각 자체는 여전히 같다.
정리하면 DAO는 데이터를 읽고 쓰는 책임을 맡는 객체다. 그리고 DAO가 중요한 이유는 단순히 DB에 접근하기 때문이 아니라, 데이터 접근 로직을 비즈니스 로직으로부터 분리해 각자의 책임을 분명하게 만들기 때문이다.
엔티티(Entity: 개체)
엔티티는 JPA에서 만들어진 용어가 아니다. 이 점은 생각보다 중요하다. 실무에서는 종종 엔티티를 곧바로 JPA의 `@Entity`와 같은 의미로 받아들이지만, 그것은 엔티티라는 개념을 지나치게 좁게 이해한 것이다. 엔티티라는 개념은 JPA가 등장하기 훨씬 전부터 존재해 왔고, JPA의 엔티티는 그 개념을 자바와 ORM 환경에서 표현하는 여러 방법 중 하나일 뿐이다. 그래서 엔티티를 곧바로 JPA와 동일시하는 것은 옳지 않다.
그렇다면 엔티티란 무엇일까? 이를 설명하려면 먼저 헷갈리기 쉬운 세 가지 엔티티를 구분해 볼 필요가 있다.
- 도메인 엔티티
- DB 엔티티
- JPA 엔티티
먼저 도메인 엔티티는 도메인 모델 관점에서의 엔티티다. 도메인 엔티티는 식별자를 가지고, 시간의 흐름 속에서도 동일한 대상으로 추적할 수 있는 객체다. 예를 들어 회원, 주문, 가맹점 같은 객체를 생각해보자. 이름이나 상태가 바뀔 수는 있어도, 우리는 그것을 여전히 같은 회원, 같은 주문, 같은 가맹점으로 인식한다. 도메인 엔티티에서 중요한 것은 속성값이 아니라 정체성이다. 값이 조금 바뀌더라도 같은 존재로 이어질 수 있다는 점에서 VO와 구분된다.
다음으로 DB 엔티티는 데이터베이스 설계나 모델링 관점에서 이야기하는 엔티티다. 보통은 테이블로 표현되거나, 적어도 테이블 설계의 대상이 되는 개체를 가리킨다. 여기서는 저장 구조와 식별자, 컬럼 구성, 관계 같은 요소가 더 중요하게 다뤄진다. 즉, DB 엔티티는 데이터베이스에 어떻게 표현되고 저장되는가에 더 가까운 개념이다.
마지막으로 JPA 엔티티는 JPA가 객체를 데이터베이스 테이블과 매핑하기 위해 제공하는 구현 수단이다. `@Entity`가 붙은 클래스는 자바 객체를 관계형 데이터베이스의 테이블과 연결해 주는 역할을 한다. 그래서 JPA 엔티티는 분명 엔티티를 표현하는 한 방식이지만, 엔티티라는 개념 전체를 대표하는 말은 아니다.
문제는 이 세 가지가 실무에서 자주 뒤섞인다는 점이다. 도메인 엔티티를 그대로 JPA 엔티티로 쓰기도 하고, JPA 엔티티를 곧 도메인 그 자체처럼 다루기도 하며, 심지어는 테이블 구조만 보고 엔티티를 이해하기도 한다. 물론 작은 규모의 애플리케이션에서는 이 셋이 크게 분리되지 않을 수도 있다. 하지만 개념적으로는 분명히 구분해 둘 필요가 있다. 그래야 지금 이야기하고 있는 것이 도메인 모델의 엔티티인지, 데이터베이스 설계상의 엔티티인지, 아니면 JPA 구현체인 엔티티인지 헷갈리지 않기 때문이다.
결국 엔티티의 핵심은 식별자와 동일성에 있다. 값이 조금 달라져도 같은 객체로 추적할 수 있어야 하고, 시간의 흐름 속에서도 같은 존재로 이어질 수 있어야 한다. 그래서 엔티티를 이해할 때는 먼저 JPA 어노테이션을 떠올리기보다, “이 객체는 값이 아니라 정체성으로 구분되는가?”를 먼저 질문하는 편이 더 본질에 가깝다.
'📚 Book' 카테고리의 다른 글
| 컨텍스트 엔지니어링으로 구축하는 AI 에이전트 (0) | 2026.03.02 |
|---|---|
| 료의 생각 없는 생각 (0) | 2025.11.08 |
| 실패를 통과하는 일 (1) | 2025.10.25 |
| 함께 자라기 (0) | 2025.10.09 |
| 사람을 안다는 것 (0) | 2025.10.08 |