- 배송지 도메인에서 클라이언트의 요청(Request)을 받고 응답(Response)을 내려주기 위해 작성한 DTO 구조를 리뷰해보겠습니다.
💻 실제 코드 (Request & Response DTO)
요청 DTO: ReqCreateAddressDtoV1.java
@Getter
@Schema(description = "배송지 등록 요청 객체")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ReqCreateAddressDtoV1 {
@Schema(description = "별칭", example = "우리집")
private String alias;
@Schema(description = "주소", example = "서울특별시 종로구 세종대로 172")
@NotBlank(message = "배송지 주소는 필수 입력 사항입니다.")
private String address;
// ... (detail, zipCode 등 생략)
public AddressEntity toEntity(UUID userId) {
return AddressEntity.builder()
.userId(userId)
.alias(this.alias)
.address(this.address)
.detail(this.detail)
.zipCode(this.zipCode)
.isDefault(this.isDefault != null ? this.isDefault : false)
// isDeleted는 부모(BaseSoftDeleteEntity)가 자동으로 false로 설정.
.build();
}
}
응답 DTO: ResGetAddressDtoV1.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ResGetAddressDtoV1 {
private UUID addressId;
private UUID userId; // 유저 ID 필드 추가
private String alias;
private String address;
// ... 생략 ...
private String message;
// 메시지 없이 변환할 때 (기본 호출)
public static ResGetAddressDtoV1 from(AddressEntity entity) {
return from(entity, null); // 중복 제거를 위해 오버로딩 활용
}
// 메시지를 포함하여 변환할 때
public static ResGetAddressDtoV1 from(AddressEntity entity, String message) {
return ResGetAddressDtoV1.builder()
.addressId(entity.getId())
.userId(entity.getUserId())
.alias(entity.getAlias())
.address(entity.getAddress())
// ... 생략 ...
.message(message)
.build();
}
}
🤔 왜 이렇게 짰는가? (설계 의도와 이유)
왜 Entity를 바로 쓰지 않고 DTO를 따로 만들었을까?
- 가장 중요한 아키텍처 원칙 중 하나입니다. 만약 클라이언트에게 AddressEntity를 그대로 반환하다면,
- Entitiy에 비밀번호나 숨겨야 할 데이터가 추가되면 API 응답으로 노출될 수 있습니다.
- 클라이언트가 화면에 그리기 위해 필요한 데이터(예: 성공 message)를 엔티티에 억지로 추가해야 합니다.
- 따라서 뷰(View) 로직과 DB 로직을 철저히 분리하기 위해 Request/Response 용도의 DTO를 분리하여 만들어야 합니다.
@NotBlank 어노테이션의 역할 (Validation)
- 요청 DTO에 @NotBlank(message = "배송지 주소는 필수 입력 사항입니다.")가 있습니다.
- 이 코드는 컨트롤러에 도달하기 전, 스프링의 스프링의 Validation (유효성 검증) 매커니즘을 통해 클라이언트가 빈 값을 보냈는지 미리 차단해 줍니다. 서비스 로직 안에서 일일이 if(address == null) 체크를 하는 수고를 덜어주는 핵심 문법입니다.
toEntity()와 from() 메서드를 DTO 안에 둔 이유
- toEntity(): 클라이언트로부터 받은 DTO를 DB에 저장할 엔티티로 변환합니다.
- from(): DB에서 꺼낸 엔티티를 클라이언트에게 줄 응답 DTO로 변환합니다. (Factory Method 패턴)
이 변환 로직을 Service 계층에 길게 늘어놓으면 서비스 코드가 너무 지저분해집니다. 변환 책임을 DTO 스스로에게 부여(객체 지향적)하여 서비스 로직의 가독성을 높였습니다.
💡 정리
- 엄격한 계층 분리: API 스펙과 DB 스키마가 서로 결합(Coupling)되는 것을 막기 위해 Entity와 DTO를 엄격히 분리했습니다. 이를 통해 Entity가 변경되어도 API 응답 규격이 깨지지 않도록 설계했습니다.
- 객체 지향적 변환 로직: 엔티티와 DTO 간의 변환 책임을 Service가 아닌 DTO의 팩토리 메서드(from, toEntity)에 위임하여 코드의 응집도를 높이고 Service 코드를 깔끔하게 유지했습니다.
- 안전한 입력 검증: "jakarta.validation(@NotBlank)을 활용하여 컨트롤러 진입 전 방어 로직을 구축, 불필요한 서비스 계층 실행을 차단했습니다.