DAO·DTO·VO 한 페이지 — 역할로 가르고 예제로 굳히기

부제: 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는 ‘도메인 안에 눌러앉아
값으로 존재하는 불변 개념’으로 갈린다.


왜 자꾸 섞이나 — 이름의 함정

셋이 뒤섞이는 데는 이유가 있다.

  1. DTO와 VO의 혼용 — 자바·한국 엔터프라이즈 관행에서 "VO"를 ‘값을
    담는 객체’라는 느슨한 뜻으로 써 온 역사가 있어, 실제로는 DTO인데
    VO로 이름 붙은 코드가 흔하다. 그래서 VO라는 이름을 만나면 어느 쪽인지 먼저 확인해야 한다.
    (자세한 구분은 아래 3절에서 다룬다.)
  2. 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의 본질은 ‘구조체’라는 형태보다 다음 성질들에 있다.

  1. 식별자(ID)가 없다 — 값으로 동등성을 판단한다.
    엔티티는 ID로 구별된다(회원 A와 B는 이름이 같아도 다른 사람).
    VO는 반대로 값이 같으면 같은 것으로 본다. Money(1000, "KRW")
    두 개는 서로 구별할 이유가 없다.
  2. 불변(immutable)이다.
    값을 바꾸는 게 아니라, 바뀐 값을 가진 새 VO를 만든다.
    1,000원1,100원으로 바꾸는 게 아니라 새 Money를 반환한다.
  3. 스스로 유효성을 지킨다.
    생성 시점에 규칙을 검증해, 잘못된 값의 VO는 아예 존재하지 못하게
    한다(예: 음수 금액 금지, 우편번호 형식 검사).
  4. 엔티티의 속성으로 합성된다.
    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로 굳힌다. 데이터가 시스템을 흐르는 경로가 곧 세 객체의 순서다.

참고

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다