ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java/Spring] DTO: 데이터 전송 객체
    Spring 2025. 12. 12. 14:58

    이번 포스팅에서는 DTO가 무엇인지 알아보겠습니다.

    1. DTO(Data Transfer Object)란 무엇인가?

    DTO의 정의

    DTO(Data Transfer Object)는 말 그대로 '데이터 전송을 위한 객체'입니다. 소프트웨어공학적인 관점에서 보았을 때, 프로세스 간(Process) 혹은 계층 간(Layer) 데이터 교환을 위해 사용하는 객체를 의미합니다.

    스프링 프레임워크 기반의 웹 애플리케이션에서는 주로 클라이언트(View/Frontend)와 서버(Controller), 혹은 서비스(Service)와 영속성 계층(Repository) 사이에서 데이터를 주고받을 때 사용됩니다.

    DTO의 가장 큰 특징은 비즈니스 로직을 가지지 않는 순수한 데이터 객체(Java Bean)라는 점입니다. 오직 데이터의 저장(Setter)과 조회(Getter) 메서드만을 가집니다. (단, 데이터 포맷팅이나 직렬화 로직 등은 포함할 수 있습니다.)

    2. DTO를 사용하는 목적과 다른 개념과의 차이

    우리는 이미 프로젝트에서 데이터베이스 테이블과 매핑되는 Entity(엔티티) 클래스를 사용하고 있습니다. 그런데 왜 굳이 DTO라는 별도의 클래스를 만들어야 할까요? DTO의 존재 이유를 Entity 그리고 자주 혼동되는 VO(Value Object)와 비교하여 알아보겠습니다.

    1) DTO vs Entity: 왜 분리해야 하는가?

    가장 주된 이유는 계층 간의 분리입니다.

    • Entity의 캡슐화 보호 및 결합도 낮추기
      • Entity는 데이터베이스의 스키마와 직결되는 핵심 클래스입니다. 만약 Entity를 컨트롤러에서 직접 반환하여 화면에 노출시킨다면, Entity의 구조가 변경될 때 화면(View) 로직까지 영향을 받게 됩니다.
      • DTO를 사용하면 DB 내부 구조가 바뀌어도 API 스펙(DTO)은 그대로 유지할 수 있어 유연한 설계를 가능하게 합니다.
    • 보안 및 정보 은닉
      • Entity에는 사용자 비밀번호, 주민등록번호, 수정 이력 등 외부로 노출되어서는 안 될 민감한 정보가 포함될 수 있습니다.
      • DTO를 사용하면 클라이언트에게 보여줄 데이터만 선별해서 담을 수 있습니다.
    • 순환 참조 방지
      • JPA를 사용할 경우, 양방향 연관 관계가 걸린 Entity를 그대로 JSON으로 직렬화하면 무한 루프(순환 참조)에 빠질 수 있습니다. DTO를 사용하면 필요한 데이터만 뽑아서 전달하므로 이 문제를 원천적으로 차단할 수 있습니다.

    2) DTO vs VO(Value Object): 무엇이 다른가?

    DTO와 VO는 둘 다 '데이터를 담는 객체'라는 점에서 비슷해 보이지만, 목적과 특징에서 결정적인 차이가 있습니다.

    • 목적의 차이 (전송 vs 값 그 자체)
      • DTO는 이름 그대로 '데이터 전송'이 목적입니다. 계층 간(Layer)에 데이터를 단순히 운반하는 '배달 상자'와 같습니다. 따라서 로직을 갖지 않고 오직 Getter/Setter(혹은 불변 객체라면 생성자)만을 가집니다.
      • VO는 '값(Value) 그 자체'를 표현하는 것이 목적입니다. 금액(Money), 주소(Address)처럼 도메인의 고유한 값을 의미하며, 내부에 값의 유효성을 검증하는 비즈니스 로직을 포함할 수 있습니다.
    • 불변성과 동등성(Equality)
      • VO는 반드시 불변(Immutable)해야 하며, 서로 다른 인스턴스라도 내부의 값이 같으면 같은 객체로 취급해야 합니다(동등성 보장, equals() & hashCode() 오버라이딩 필수).
      • DTO는 일회성으로 데이터를 전달하고 버려지는 객체이기 때문에 동등성 비교가 중요하지 않습니다. 또한, 과거에는 Setter를 열어두어 가변 객체로 만드는 경우가 많았으나, 최근에는 데이터 무결성을 위해 DTO도 불변으로 설계하는 추세입니다(Java Record 활용 등).

     

    3. DTO에 대한 오해와 진실

    DTO는 워낙 흔하게 사용되는 패턴이다 보니, 우리가 무심코 지나치거나 잘못 알고 있는 사실들이 있습니다. 단순히 '데이터를 담는 그릇'을 넘어, DTO를 더 정확하게 이해하기 위해 대표적인 오해 세 가지를 짚어보겠습니다.

    오해 1: DTO는 프로세스나 계층 간 이동에만 사용된다?

    흔히 DTO를 Controller와 Service, 혹은 Service와 Repository 사이의 경계(Layer Boundary)에서만 사용하는 객체로 한정 짓곤 합니다. 물론 API 통신이나 DB 통신이 가장 대표적인 사용처인 것은 맞습니다.

    하지만 DTO의 본질은 '데이터 전송(Data Transfer)' 그 자체에 있습니다. 데이터를 A에서 B로 전달해야 하는 상황이라면 그곳이 어디든 DTO를 사용할 수 있습니다.

    • 메서드 호출 시 매개변수(Parameter)가 너무 많아 이를 하나로 묶어 전달하고 싶을 때
    • 여러 번 호출해야 할 메서드를 한 번의 호출로 처리하기 위해 데이터를 뭉칠 때

    이처럼 데이터 전송이 필요한 모든 곳이 DTO의 활동 무대입니다. (단, 무작정 매개변수를 객체로 감싸는 것은 메서드가 실제로 어떤 데이터에 의존하는지 숨길 수 있으므로 주의가 필요합니다.)

    오해 2: DTO는 반드시 Getter/Setter를 가져야 한다?

    "자바 빈(Java Bean) 규약에 따라 멤버 변수는 private으로 하고, Getter/Setter를 만들어야 한다." 우리가 자바를 배우면서 귀에 딱지가 앉도록 들은 말입니다. 하지만 이것이 DTO에서도 절대적인 진리일까요?

    다음 코드를 한번 살펴보겠습니다.

     
    package com.example.proceduresample;
    
    import lombok.Getter;
    import lombok.Setter;
    
    @Getter
    @Setter
    public class Dto {
        private String username;
        private int age;
        private String address;
        private String email;
    }
    

    언뜻 보면 아주 모범적인 DTO처럼 보입니다. 하지만 냉정하게 생각해보면 의문이 듭니다. "멤버 변수는 private으로 감췄는데, Setter를 통해 외부에서 마음대로 값을 바꿀 수 있다면 public 변수와 다를 게 무엇인가?"

    캡슐화(Encapsulation)는 데이터에 직접 접근하는 것을 막고 메서드를 통해서만 접근하게 하여 객체를 보호하는 것입니다. 하지만 위와 같이 기계적으로 Getter/Setter를 모두 열어두는 것은 캡슐화의 이점을 전혀 살리지 못하는, 사실상 public 변수 선언과 다름없는 코드입니다.

    오히려 다음과 같이 작성하는 것이 DTO의 목적(데이터 전달)에는 더 편하고 명확할 수 있습니다. 

    package com.example.proceduresample;
    
    public class Dto {
        public final String username;
        public final int age;
        public final String address;
        public final String email;
    
        public Dto(String username, int age, String address, String email) {
            this.username = username;
            this.age = age;
            this.address = address;
            this.email = email;
        }
    }
    

     

    그렇다고 멤버 변수를 public으로 지정하라고 하는 것은 압니다. 말하고 싶은 점은 다음과 같습니다.

    Getter/Setter는 데이터를 전달하는 하나의 방법일 뿐, 필수 조건이 아닙니다. 중요한 건 '데이터를 목적지까지 전달하는 것'입니다.

    물론, user.email (속성에 직접 접근)과 user.getEmail() (행위를 통한 접근)은 의미론적으로 다릅니다. 정보 은닉과 유연한 변경을 위해서는 모든 멤버 변수를 private으로 하되, 무분별한 Getter/Setter는 지양하고 필요한 곳에만 Getter/Setter를 제공하는 방향으로 가는 것이 가장 이상적일 것입니다.

    오해 3: DTO는 DB에 저장되는 데이터만을 의미한다?

    세 번째 오해는 DTO를 '데이터베이스 테이블에 들어가는 데이터'와 동일시하는 것입니다. 앞서 말했듯 DTO의 D(Data)는 컴퓨터 공학에서 말하는 범용적인 의미의 데이터입니다.

    • 클라이언트가 보낸 API 요청 본문(Request Body)
    • 서버가 응답하는 JSON 데이터(Response Body)
    • 객체와 객체 사이에 주고받는 임시 데이터

    이 모든 것이 DTO입니다. DB와 매핑되는 Entity와는 명확히 구분되어야 하며, DTO는 오직 '데이터를 나르는 역할'에 충실하면 됩니다.

    4. DTO 구현 예시: Class vs Record

    자바에서 DTO를 구현하는 방법은 크게 두 가지가 있습니다. 전통적으로 많이 사용해 온 Class와 Lombok 라이브러리를 조합하는 방식과, Java 16부터 정식 도입된 Record 타입을 사용하는 방식입니다. 두 방식의 코드를 비교해 보겠습니다.

    1) 전통적인 방식: Class + Lombok 활용

    가장 보편적으로 사용되어 온 방식입니다. 자바의 기본적인 Class 문법에 Lombok 어노테이션을 사용하여 Getter, 생성자 등의 보일러플레이트(반복) 코드를 줄입니다.

     
    package com.blog.dto;
    
    import com.blog.domain.Member;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import lombok.AllArgsConstructor;
    
    @Getter
    @NoArgsConstructor // 역직렬화(JSON -> Object)를 위해 기본 생성자 필요
    @AllArgsConstructor
    public class MemberRequestDto {
    
        private String email;
        private String password;
        private String name;
    
        // DTO -> Entity 변환 메서드
        public Member toEntity() {
            return Member.builder()
                    .email(this.email)
                    .password(this.password) // 실무에선 암호화 처리 필요
                    .name(this.name)
                    .build();
        }
    }
    
    • 설명:
      • @Getter: 데이터를 조회하기 위해 필수적입니다.
      • @NoArgsConstructor: Jackson 같은 JSON 파싱 라이브러리가 객체를 생성할 때 기본 생성자를 필요로 하기 때문에 자주 사용됩니다.
      • 특징: 필드가 기본적으로 가변(mutable) 상태일 수 있어, 불변성을 보장하려면 필드에 final을 붙이고 @Value 등을 사용해야 하는 번거로움이 있습니다.

    Lombok @Data 사용 시 주의사항

    Lombok을 사용할 때 가장 많이 사용하는 어노테이션 중 하나가 바로 @Data입니다. @Data 하나만 붙이면 @Getter, @Setter, @RequiredArgsConstructor, @ToString, @EqualsAndHashCode를 한방에 해결해 주기 때문에 사용하는 사람이 많습니다.

     
    @Data // 편리해 보이지만 DTO에서는 신중해야 합니다.
    public class MemberRequestDto {
        private String email;
        private String password;
        private String name;
    }
    

    하지만 DTO를 설계할 때 @Data를 무분별하게 사용하는 것은 몇 가지 치명적인 단점이 있어 주의가 필요합니다.

    1. 불필요한 Setter 개방 (불변성 위반)
      • 앞서 언급했듯, 데이터 전송 객체는 데이터가 생성된 이후에 변하지 않는 불변(Immutable) 상태를 유지하는 것이 좋습니다.
      • 하지만 @Data는 자동으로 @Setter를 생성합니다. 이는 DTO가 어디서든 수정될 수 있다는 뜻이며, 데이터의 무결성을 보장하기 어렵게 만듭니다.
    2. ToString으로 인한 보안 이슈
      • @Data는 @ToString을 자동으로 생성합니다.
      • 만약 DTO 안에 password 같은 민감한 정보가 포함되어 있다면, 로그를 찍을 때 memberDto.toString()이 호출되면서 비밀번호가 평문으로 로그에 남게 되는 보안 사고로 이어질 수 있습니다. (물론 @ToString.Exclude로 제외할 수 있지만, 실수할 여지를 남깁니다.)
    3. 무거운 메서드 생성
      • 단순히 데이터를 옮기는 DTO에는 객체의 동등성을 비교하는 equals()나 hashCode()가 필요 없는 경우가 대부분입니다. @Data는 사용하지도 않을 무거운 메서드들을 모두 생성하여 불필요한 코드를 늘립니다.

    권장 방식: 따라서 DTO를 만들 때는 @Data를 통째로 쓰기보다는, 필요한 어노테이션만 골라 쓰는 것이 좋습니다.

    추천 조합: @Getter, @NoArgsConstructor, @AllArgsConstructor, (필요시 @Builder)

    2) 모던한 방식: Java Record 활용 (Java 16+)

    DTO는 '데이터를 운반'하는 것이 목적이므로, 데이터의 불변성을 보장하고 코드를 간결하게 유지하는 것이 좋습니다. 이를 위해 탄생한 것이 바로 Record입니다.

     
    package com.blog.dto;
    
    import com.blog.domain.Member;
    
    // class 대신 record 키워드 사용
    public record MemberRequestDto(
        String email,
        String password,
        String name
    ) {
        // 1. 모든 필드는 private final로 선언됩니다. (불변성 보장)
        // 2. 생성자, Getter, equals, hashCode, toString이 자동 생성됩니다.
        
        // 컴팩트 생성자 (Compact Constructor): 유효성 검증 로직 추가 가능
        public MemberRequestDto {
            if (email == null || email.isBlank()) {
                throw new IllegalArgumentException("이메일은 필수값입니다.");
            }
        }
    
        // DTO -> Entity 변환 메서드
        public Member toEntity() {
            return Member.builder()
                    .email(this.email)
                    .password(this.password)
                    .name(this.name)
                    .build();
        }
    }
    
    • 설명:
      • 간결함: 어노테이션 없이 필드 선언만으로 DTO의 역할을 완벽하게 수행합니다.
      • 불변성(Immutable): 생성된 객체의 데이터는 변경할 수 없습니다. 데이터 전송 도중 값이 변조될 위험이 사라집니다.
      • Getter 네이밍: getEmail()이 아니라 email() 처럼 필드명 그대로 메서드가 생성됩니다. (대부분의 JSON 라이브러리는 이를 자동으로 인식하여 처리합니다.)

     

     

    'Spring' 카테고리의 다른 글

    [스프링] 안티 패턴: 스마트 UI  (0) 2025.12.16
    [Java/Spring] Entity: 개체  (0) 2025.12.12
    [Java/Spring] VO(Value Object)란 무엇인가?  (0) 2025.12.11
    [Spring] Spring AI란?  (0) 2025.07.08
    스프링(Spring)이란 무엇인가?  (1) 2025.06.18
Designed by MSJ.