
본 포스팅의 코드는 java, spring data jpa 기반으로 작성되었습니다.
DTO (Data Transfer Object)
API를 개발하다 보면 서비스 계층에서 처리한 결과를 클라이언트에 반환해야 하는 상황이 생긴다. 이때 JPA 엔티티를 그대로 반환하면 여러 문제가 발생한다.
- 엔티티에는 클라이언트에 노출해서는 안 되는 필드가 포함될 수 있다 (예: 원가, 내부 상태값 등).
- JPA 엔티티를 직렬화하면 프록시 객체 문제, 지연 로딩 예외 등이 발생할 수 있다.
- API 응답 구조(계약)와 DB 구조(엔티티)가 결합되면, 둘 중 하나를 변경할 때 서로 영향을 준다.
이 문제를 해결하기 위해 사용하는 것이 DTO(Data Transfer Object)다. DTO는 계층 간 데이터 전달을 위한 전용 객체로, 꼭 필요한 필드만 담아서 전달하는 역할을 한다.
Java record 로 DTO 만들기
DTO를 일반 class로 만들면 반복적인 코드가 많다.
public class ProductResponseDto {
private final Long id;
private final String name;
public ProductResponseDto(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
// equals, hashCode, toString도 직접 구현해야 한다
}
DTO는 한 번 생성된 뒤 변경될 필요가 없는 객체다. 불변 데이터 묶음 선언에 특화된 Java 16의 record를 사용하면 위 코드를 한 줄로 줄일 수 있다.
public record ProductResponseDto(Long id, String name) {}
// 생성자, getter, equals, hashCode, toString이 자동으로 만들어진다
그런데 record를 그대로 사용하다 보면 또 다른 문제가 생긴다.
생성자 직접 호출의 한계
record는 선언한 필드 전체를 받는 생성자를 자동으로 만든다. 그런데 같은 DTO를 상황에 따라 다르게 채워야 하는 경우, 생성자를 직접 호출하면 의도가 코드에 드러나지 않는다.
결제 응답이 성공/실패에 따라 포함 필드가 다른 경우를 예로 들면 다음과 같다.
// 결제 성공: 영수증 URL 있음, 실패 사유 없음
return new PaymentResponseDto("ORD-001", receiptUrl, null);
// 결제 실패: 영수증 URL 없음, 실패 사유 있음
return new PaymentResponseDto("ORD-001", null, failReason);
이 코드에는 세 가지 문제가 있다.
null이 어떤 의도로 들어가는지 코드만 봐서는 알기 어렵다.- 어떤 필드에
null을 넣어야 하는지를 호출하는 쪽에서 직접 알고 있어야 한다. - 필드가 추가되면 이 생성자를 호출하는 모든 곳을 수정해야 한다.
정적 팩토리 메서드로 해결하기
이 문제는 정적 팩토리 메서드(Static Factory Method) 패턴으로 해결할 수 있다. 객체 생성을 담당하는 static 메서드를 별도로 두고, 상황에 맞는 이름을 붙이는 방식이다.
Effective Java(Joshua Bloch) Item 1에서도 이 이유를 명확히 설명한다.
생성자는 이름을 가질 수 없지만, 정적 팩토리 메서드는 이름으로 의도를 전달할 수 있다.
앞서 본 결제 응답 DTO에 정적 팩토리를 적용하면 다음과 같다.
public record PaymentResponseDto(
String orderId,
String receiptUrl, // 성공 시에만 존재
String failReason // 실패 시에만 존재
) {
// 결제 성공: 영수증 URL 있음, 실패 사유 없음
public static PaymentResponseDto ofSuccess(String orderId, String receiptUrl) {
return new PaymentResponseDto(orderId, receiptUrl, null);
}
// 결제 실패: 영수증 URL 없음, 실패 사유 있음
public static PaymentResponseDto ofFailure(String orderId, String failReason) {
return new PaymentResponseDto(orderId, null, failReason);
}
}
null 처리 로직이 DTO 내부로 들어왔기 때문에, 서비스 계층의 코드가 훨씬 명확해진다.
// 팩토리 없이 서비스에서 직접 분기
if (paymentSuccess) {
return new PaymentResponseDto(orderId, receiptUrl, null);
} else {
return new PaymentResponseDto(orderId, null, failReason);
}
// 팩토리 사용 시
return paymentSuccess
? PaymentResponseDto.ofSuccess(orderId, receiptUrl)
: PaymentResponseDto.ofFailure(orderId, failReason);
팩토리 메서드 명명 규칙
| 이름 | 의미 | 사용 시점 |
|---|---|---|
of(...) |
단순 변환 — 인수를 그대로 감싸거나 조합 | 필드 조합 로직이 단순할 때 |
ofXxx(...) |
특수 케이스 — 목적에 따라 다른 초기값 적용 | 같은 타입의 객체를 상황에 따라 다르게 만들 때 |
(참고)@JsonInclude(NON_NULL) 과의 연결
그런데 팩토리 내부에서 null을 주입하더라도, 직렬화 시 null 키가 클라이언트에 그대로 노출되는 문제가 남는다.
{"orderId": "ORD-001", "receiptUrl": null, "failReason": "잔액 부족"}
@JsonInclude(JsonInclude.Include.NON_NULL)을 선언하면 null인 필드는 JSON 키 자체가 제거된다.
@JsonInclude(JsonInclude.Include.NON_NULL)
public record PaymentResponseDto(
String orderId,
String receiptUrl,
String failReason
) { ... }
{"orderId": "ORD-001", "failReason": "잔액 부족"}
정적 팩토리 패턴이 null 주입 방식을 DTO 내부에 캡슐화해두었기 때문에, 어노테이션 하나로 모든 케이스의 응답이 일관되게 처리된다. 만약 서비스 계층에서 null을 직접 관리했다면, 이런 일관성을 보장하기 어렵다.
추가 예시
단순 메시지 응답
입력값을 받아 메시지 문자열을 조합하는 로직도 팩토리 내부에 캡슐화할 수 있다.
public record NotificationResponseDto(String message) {
public static NotificationResponseDto of(String recipient) {
return new NotificationResponseDto(
recipient + " 님께 알림이 발송되었습니다."
);
}
}
호출하는 쪽에서는 NotificationResponseDto.of(recipient)만 호출하면 된다. 메시지 포맷이 바뀌어도 팩토리 내부만 수정하면 된다.
단순 래핑 — 확장을 고려한 팩토리
현재는 단순히 필드를 감싸기만 하더라도, 팩토리 메서드를 통해 생성하도록 해두면 향후 필드가 추가될 때 호출부를 수정할 필요가 없다.
public record UserSummaryResponseDto(Long id, String name, String email) {
public static UserSummaryResponseDto of(Long id, String name, String email) {
return new UserSummaryResponseDto(id, name, email);
}
}
record + 정적 팩토리 조합
public record ProductResponseDto(String name, int price) {
// 1. 컴팩트 생성자는 검증용으로만 사용
public ProductResponseDto {
Objects.requireNonNull(name, "name must not be null");
if (price < 0) throw new IllegalArgumentException("price must be non-negative");
}
// 2. 단순 변환
public static ProductResponseDto of(String name, int price) {
return new ProductResponseDto(name, price);
}
// 3. 특수 케이스 — 품절 상품은 가격을 0으로 표시
public static ProductResponseDto ofSoldOut(String name) {
return new ProductResponseDto(name, 0);
}
}
- 외부에서는
new ProductResponseDto(...)대신 항상ProductResponseDto.of(...)형태로 생성한다. - 팩토리 메서드 이름:
of(단순 변환),ofXxx(특수 케이스). record의 전체 필드 생성자는 암묵적으로 생성되므로 Lombok이 불필요하다.
'DEV > 개발 방법론' 카테고리의 다른 글
| [개발 방법론] 마이크로 서비스(MSA)와 모놀리틱 아키텍처의 차이점 (1) | 2023.11.29 |
|---|---|
| [개발 방법론] 디자인 패턴과 MVC 패턴의 설명과 차이 (0) | 2023.11.15 |
| [개발방법론] 객체지향 프로그래밍, SOLID 원칙에 대한 설명 (0) | 2023.11.05 |