부제: DAO는 동사, DTO는 상자, VO는 값 그 자체
OrderDAO, OrderDTO, OrderVO — 셋이 한 프로젝트에 나란히 있으면
누가 무엇인지 헷갈리기 쉽다. 세 글자가 비슷하게 생겨서다. 그러나 이
셋은 서로 다른 질문에 답하는 서로 다른 물건이다. 이름이 아니라
역할로 보면 단번에 갈라진다.
- DAO는 "이 데이터를 어떻게 가져오는가"에 답한다 → 동작을 가진 객체.
- DTO는 "이 데이터를 어떻게 실어 나르는가"에 답한다 → 운반용 그릇.
- VO는 "이 값은 무엇인가"에 답한다 → 값 그 자체인 개념.
한 장 비교표
| 축 | DAO | DTO | VO |
|---|---|---|---|
| 정식 명칭 | Data Access Object | Data Transfer Object | Value Object |
| 핵심 질문 | 어떻게 가져오나 | 어떻게 나르나 | 무엇인가(값) |
| 성격 | 동작(behavior) | 데이터 그릇 | 개념(값) |
| 정체성(ID) | — (서비스 객체) | 없음 | 없음, 값으로 동등 |
| 가변성 | — | 보통 가변 | 불변(immutable) |
| 행위(로직) | 있음(CRUD) | 없음 | 값 관련 최소 로직 |
| 주로 사는 곳 | 데이터 접근 계층 | 계층·경계 사이 | 도메인 안(엔티티 속성) |
| 대표 예 | UserDAO.findById() |
UserResponse |
Money, Address |
세로로 훑으면 DAO만 일하는 객체(메서드 호출로 데이터를 가져온다)이고,
DTO와 VO는 데이터를 담는 객체다. 그리고 그 둘 사이에서 DTO는
‘운반 중에만 잠깐 존재하는 가변 그릇’, VO는 ‘도메인 안에 눌러앉아
값으로 존재하는 불변 개념’으로 갈린다.
왜 자꾸 섞이나 — 이름의 함정
셋이 뒤섞이는 데는 이유가 있다.
- DTO와 VO의 혼용 — 자바·한국 엔터프라이즈 관행에서 "VO"를 ‘값을
담는 객체’라는 느슨한 뜻으로 써 온 역사가 있어, 실제로는 DTO인데
VO로 이름 붙은 코드가 흔하다. 그래서 VO라는 이름을 만나면 어느 쪽인지 먼저 확인해야 한다.
(자세한 구분은 아래 3절에서 다룬다.) - DAO와 Repository의 혼용 — DAO는
Repository·Active Record와
같은 ‘데이터 접근’ 계열이라 역할이 겹쳐 보인다. 하지만 DAO는 보통
테이블/데이터소스 중심의 저수준 CRUD이고, Repository는 도메인 컬렉션
중심의 고수준 추상이다. (아래 1절에서 비교한다.)
다만 이 구분에 너무 엄격할 필요는 없다고 나는 생각한다. 사실 위에서
정리한 것도 ‘절대적인 정의’라기보다 내가 이해한 바의 개념에
가깝고, 이 구분이 모든 언어·모든 코드베이스에서 언제나 맞는 것도 아니다.
언어란 본래 서로를 이해하려고 단어를 배우는 도구이니, 다른 사람이
DAO·DTO·VO를 엄밀히 가르지 않고 말하더라도 그 용어를 굳이 바로잡기보다
그가 가리키는 바를 속으로 번역해 이해하는 편이 대화에는 더 매끄럽다고
본다. 결국 중요한 건 이름표가 아니라, 그 객체가 지금 어떤 역할을 하고
있는지다.
1. DAO — 데이터 접근을 한곳에 가두는 객체
먼저 셋 중 유일하게 일하는 객체부터.
부제: SQL을 한 방에 몰아넣고, 나머지 코드는 저장 방식을 모르게
애플리케이션 곳곳에 SELECT ... WHERE id = ?가 흩어져 있으면, DB
스키마가 한 번 바뀔 때 온 코드를 뒤져야 한다. DAO(Data Access
Object)는 이 문제를 정면으로 다룬다. 데이터 접근 로직을 한 객체에
가두고, 나머지 코드에는 "가져와 줘 / 저장해 줘" 같은 메서드만
노출한다.
형태 — 인터페이스와 구현
DAO의 전형적인 모양은 ‘무엇을 할 수 있는가’를 정의한 인터페이스와,
‘그걸 실제로 어떻게 하는가’를 담은 구현의 짝이다.
// 계약 — 나머지 코드는 이 인터페이스에만 의존한다
interface UserDao {
User findById(long id);
List<User> findByStatus(String status);
void save(User user);
void deleteById(long id);
}
// 구현 — SQL/쿼리는 오직 여기에만 존재한다
class JdbcUserDao implements UserDao {
public User findById(long id) {
// SELECT * FROM users WHERE id = ?
...
}
public void save(User user) {
// INSERT ... ON CONFLICT UPDATE ...
...
}
// ...
}
핵심은 경계다. 서비스 계층은 UserDao라는 인터페이스만 알고,
그것이 JDBC인지 MyBatis인지 JPA인지는 모른다. 저장 기술을 갈아 끼워도
구현체 하나만 바꾸면 된다.
DAO가 하는 일 세 가지
- CRUD 캡슐화 — 생성·조회·수정·삭제 쿼리를 메서드 뒤로 숨긴다.
- 매핑 — DB의 행(row)을 객체로, 객체를 행으로 옮긴다.
- 기술 격리 — 커넥션·트랜잭션·드라이버 같은 세부를 위층에서 감춘다.
즉 DAO는 앞서
아키텍처 종합 편에서
말한 ‘쿼리 실행 레이어’ 그 자체다. 폴더로 치면 repositories/
자리에 앉아, 도메인과 DB 사이의 통역을 맡는다.
DAO vs Repository — 가장 흔한 혼동
DAO와 Repository는 둘 다 ‘데이터 접근’이라 자주 같은 것으로 취급되지만,
바라보는 높이가 다르다.
| 축 | DAO | Repository |
|---|---|---|
| 중심 개념 | 테이블·데이터소스 | 도메인 컬렉션(애그리거트) |
| 추상 수준 | 저수준 (CRUD 매핑) | 고수준 (도메인 언어) |
| 단위 | 대개 1 테이블 ≈ 1 DAO | 1 애그리거트 ≈ 1 Repository |
| 말투 | insert, selectByPk |
save, findActiveMembers |
| 관심 | "어떻게 저장/조회하나" | "이 도메인 객체를 어떻게 얻나" |
| 출신 | Java EE / 전통 3-tier | DDD(Eric Evans) |
거칠게 말하면 Repository가 DAO를 품는 경우가 많다. Repository는
도메인의 언어로 "활성 회원을 다오"라고 요청받고, 그 요청을 이루기 위해
내부에서 한두 개의 DAO(또는 ORM)를 호출해 쿼리를 실행한다. 그래서
작은 CRUD 서비스에서는 DAO만으로 충분하고, 도메인이 복잡해질수록
Repository라는 한 겹을 더 얹어 도메인 언어를 지키는 편이 낫다.
언제 DAO가 잘 맞나
- 테이블 구조가 명확하고 CRUD 중심인 서비스.
- ORM을 얇게 쓰거나, 성능을 위해 직접 짠 쿼리를 다뤄야 할 때.
- 저장 기술을 나중에 바꿀 여지를 남기고 싶을 때(인터페이스 경계).
반대로 도메인 규칙이 두껍고 여러 테이블을 한 개념으로 묶어야 한다면,
DAO 위에 Repository를 얹어 도메인은 DB를 모르게 만드는 쪽이 오래
버틴다.
2. DTO — 계층을 넘나드는 데이터 운반 그릇
DAO로 가져온 데이터는 어딘가로 나가야 한다.
부제: 로직은 빼고, 받는 쪽에 맞춰 모양을 짜는 상자
DTO(Data Transfer Object)는 이름 그대로 데이터를 옮기기 위한
객체다. 계층에서 계층으로(서비스 → 컨트롤러), 서버에서 클라이언트로
(API 응답) 데이터가 경계를 넘을 때, 그 데이터를 담아 나르는 그릇
역할만 한다. 비즈니스 로직은 갖지 않는다.
DTO는 왜 필요한가 — 엔티티를 그대로 내보내면 생기는 일
가장 흔한 안티패턴은 도메인 엔티티를 API 응답으로 그대로 노출하는
것이다. 편해 보이지만 대가가 크다.
- 과다 노출 —
passwordHash, 내부 상태 플래그까지 딸려 나간다. - 결합 — DB 컬럼명이 그대로 API 계약이 되어, 스키마를 못 바꾼다.
- 모양 불일치 — 화면이 원하는 형태(합쳐진 이름, 계산된 값)와
엔티티 구조가 어긋난다.
DTO는 이 사이에 완충 계층을 둔다. 내부 도메인 모델은 자유롭게
바꾸되, 바깥과의 계약은 DTO로 따로 고정한다.
형태 — 요청 DTO와 응답 DTO
DTO는 대개 받는 쪽에 맞춰 모양이 갈린다. 들어올 때와 나갈 때의
필요가 다르기 때문이다.
// 요청 DTO — 클라이언트가 보내는 것만 받는다 (id·생성일은 서버가 정함)
record CreateUserRequest(String email, String rawPassword, String name) {}
// 응답 DTO — 내보내도 되는 것만, 화면이 원하는 모양으로
record UserResponse(long id, String name, String maskedEmail) {}
응답 DTO에는 rawPassword가 아예 없고, maskedEmail처럼 가공된
값이 들어간다. 엔티티와 1:1이 아니라, 소비자가 원하는 계약이
DTO의 모양을 결정한다.
엔티티 ↔ DTO 매핑
경계에서는 둘을 서로 옮겨 주는 매핑이 필요하다. 이 변환을 한곳에
모아 두면 도메인과 계약이 깔끔하게 분리된다.
UserResponse toResponse(User u) {
return new UserResponse(
u.getId(),
u.getName(),
mask(u.getEmail()) // 도메인 값을 표현용으로 가공
);
}
작은 프로젝트는 손으로 매핑하고, 규모가 커지면 MapStruct 같은 도구로
자동화하기도 한다. 어느 쪽이든 원칙은 하나 — 매핑을 도메인 안이 아닌
경계에 둔다.
DTO를 다룰 때의 규칙
| 하라 | 하지 마라 |
|---|---|
| 데이터만 담기 | 비즈니스 로직 넣기 |
| 받는 쪽 모양으로 설계 | 엔티티를 그대로 재사용 |
| 요청·응답 DTO 분리 | 하나의 DTO를 입출력 겸용 |
| 경계에서만 쓰기 | 도메인 계층까지 끌고 다니기 |
DTO는 ‘잠깐 존재하는 운반 상자’라는 정체성을 지킬 때 가장 쓸모 있다.
로직이 붙기 시작하면 그건 이미 DTO가 아니라 어정쩡한 도메인 객체다.
값 자체가 의미를 갖는 개념이라면, DTO가 아니라
VO(Value Object)로 굳히는 것이 맞다.
3. VO — 값 그 자체가 정체성인 객체, 그리고 흔한 오해
마지막은 도메인 안에 눌러앉는 쪽, 그리고 셋 중 이름 때문에 가장 자주 오해받는 개념이다.
부제: Address는 왜 ‘엔티티’가 아니라 ‘값’인가
먼저 결론부터. VO를 ‘Address처럼 구조체(struct)로 만들어 다른 객체
안에서 가져다 쓰는 객체’로 이해하는 관점은 정확하다. 이것이 바로
DDD(도메인 주도 설계)에서 말하는 Value Object다. 여기에 몇 가지
정의를 더 얹으면 개념이 또렷해진다.
VO를 VO로 만드는 네 가지 성질
VO의 본질은 ‘구조체’라는 형태보다 다음 성질들에 있다.
- 식별자(ID)가 없다 — 값으로 동등성을 판단한다.
엔티티는 ID로 구별된다(회원 A와 B는 이름이 같아도 다른 사람).
VO는 반대로 값이 같으면 같은 것으로 본다.Money(1000, "KRW")
두 개는 서로 구별할 이유가 없다. - 불변(immutable)이다.
값을 바꾸는 게 아니라, 바뀐 값을 가진 새 VO를 만든다.
1,000원을1,100원으로 바꾸는 게 아니라 새Money를 반환한다. - 스스로 유효성을 지킨다.
생성 시점에 규칙을 검증해, 잘못된 값의 VO는 아예 존재하지 못하게
한다(예: 음수 금액 금지, 우편번호 형식 검사). - 엔티티의 속성으로 합성된다.
VO는 홀로 저장되기보다, 엔티티 안에 값처럼 박혀 쓰인다 —
"다른 객체 안에서 값처럼 쓰이는 객체"라는 감각이 정확히 이 지점이다.
// 값으로 동등, 불변, 자기 검증 — 전형적인 VO
record Money(long amount, String currency) {
Money {
if (amount < 0) throw new IllegalArgumentException("음수 금액 불가");
}
Money plus(Money other) { // 바꾸지 않고 새로 만든다
return new Money(this.amount + other.amount, currency);
}
}
// Address 도 마찬가지 — Order 안에 값으로 박혀 쓰인다
record Address(String zipcode, String street) { }
class Order {
private Address shippingAddress; // 합성: 엔티티의 속성으로
}
Money, Address, DateRange, 좌표(Lat/Lng)가 교과서적 VO다.
공통점은 전부 "무엇인지가 값으로 다 설명되는" 개념이라는 것이다.
흔한 오해 — "VO = DTO"라는 이름의 함정
여기서 반드시 짚어야 할 혼란이 있다. "VO"라는 약자가 두 가지 다른
뜻으로 쓰인다.
| 구분 | ① DDD의 Value Object | ② 관행적 "VO" |
|---|---|---|
| 의미 | 값으로 동등한 불변 도메인 개념 | ‘값 담는 객체’ ≈ 사실상 DTO |
| 불변성 | 불변 | 보통 가변(setter 있음) |
| 정체성 | 값으로 동등 | 그냥 데이터 그릇 |
| 출신 | Eric Evans, DDD | 자바·한국 엔터프라이즈 관행 |
| 예 | Money, Address |
게시판VO, 회원VO |
자바·한국 실무에서는 오래전부터 "값을 담는 객체"를 뭉뚱그려 VO라고
불러 왔다. 그래서 코드에서 SomethingVO를 만나면, 그것이 정말 DDD의
Value Object인지, 아니면 이름만 VO인 DTO인지
먼저 확인해야 한다. 앞에서 든 Address 예시는 명백히 ①에 해당한다 —
값으로 동등하고, 불변이며, 다른 객체 안에서 값으로 쓰이므로.
VO · DTO · 엔티티 — 한 줄로 가르기
셋을 나란히 두면 경계가 분명해진다.
| 엔티티 | VO | DTO | |
|---|---|---|---|
| 정체성 | ID로 구별 | 값으로 동등 | 없음(그릇) |
| 가변성 | 가변 | 불변 | 보통 가변 |
| 사는 곳 | 도메인 | 도메인(엔티티 속성) | 경계(계층 사이) |
| 존재 이유 | 식별되는 무엇 | 값인 무엇 | 나르는 무엇 |
한 문장으로 압축하면 — 엔티티는 ‘누구/무엇’이고, VO는 ‘얼마/어디/
언제’ 같은 값이며, DTO는 그 값들을 잠깐 담아 옮기는 상자다.
VO를 쓰면 좋은 순간
- 원시 타입(
long amount,String zip)이 의미를 잃고 떠다닐 때 —
Money,Zipcode로 감싸면 규칙이 값에 붙는다. - 같은 검증이 여러 곳에 중복될 때 — VO 생성자로 한 번에 모은다.
- "같은 값이면 같다"는 비교가 필요할 때 — 값 동등성이 공짜로 온다.
원시 타입을 VO로 감싸는 이 습관을 흔히 "원시 타입 집착(Primitive
Obsession)에서 벗어난다"고 부른다. 작은 값 하나를 개념으로 승격시키는
것 — VO의 값어치는 대개 거기서 나온다.
세 편을 한 문장으로
세 객체를 관통하는 한 문장은 이것이다. DAO로 가져와서, DTO에 담아 나르고, 그 안의 의미 있는 값은 VO로 굳힌다. 데이터가 시스템을 흐르는 경로가 곧 세 객체의 순서다.
참고
- Martin Fowler, Patterns of Enterprise Application Architecture — Data Mapper·Repository·Data Transfer Object·Value Object 항목
- Eric Evans, Domain-Driven Design — Value Object / Repository
- Architecture 카테고리 전체 보기
답글 남기기