DTO 하나를 Controller부터 JPA Entity까지 그대로 끌고 다니는 구조는 편하다. 필드도 한 번만 정의하면 되고, 변환 코드도 안 짜도 된다. 문제는 편한 만큼 계층 사이의 의존성이 전부 한 클래스에 뭉친다는 것이다. Entity에 컬럼 하나를 추가하면 API 응답이 바뀌고, API 요청 필드를 바꾸면 쿼리 매핑이 깨진다.
이 글은 “계층이 다르면 타입도 다르다”는 전제에서 출발한다. Controller, Service, JPA, MyBatis가 각자 자기 타입만 알고, 그 경계를 넘을 때는 반드시 변환을 거치도록 강제한다. 아래에 계층별 네이밍과 변환 책임을 정리한다.
문제: 하나의 DTO로 전 계층을 관통시킬 때
// ❌ Request이자 Entity이자 Response인 하나의 클래스
@Entity
public class OrderDto {
@Id
@GeneratedValue
private Long id;
private String productName;
private int quantity;
private BigDecimal unitPrice;
}
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public OrderDto createOrder(@RequestBody OrderDto dto) {
// Request로 받아서, 그대로 Entity로 저장하고, 그대로 Response로 내보낸다
return orderService.create(dto);
}
}
- ❌
@Id,@GeneratedValue같은 영속성 관심사가 API 스펙에 그대로 노출된다 - ❌ Entity 필드를 바꾸면 Request/Response 스펙이 함께 깨진다
- ❌ Service가 JPA Entity 타입에 직접 의존해서, MyBatis로 바꾸려면 Service 코드까지 손대야 한다
- ❌ Controller에서 받은 값 그대로 DB에 저장되므로, 계층별 검증 지점이 사라진다
계층 경계마다 타입을 분리하면 이 문제가 사라진다. 대신 경계를 넘을 때 변환이 필요한데, “누가 그 변환을 책임지는가”까지 정해야 규칙이 흔들리지 않는다.
전체 흐름
가운데 있는 Domain이 핵심이다. Service는 이 타입 하나만 알면 되고, 그 뒤에 JPA가 있는지 MyBatis가 있는지는 몰라도 된다.
1. Controller: Request / Response
Controller의 DTO는 HTTP 스펙을 표현하는 것이 유일한 역할이다. Service 타입(Command, ServiceResult)을 알아도 되는 유일한 계층이기도 하다 — 그래서 변환 책임도 Controller DTO 자신이 진다.
@Getter
public class CreateOrderRequest {
private final String productName;
private final Integer quantity;
@JsonCreator
public CreateOrderRequest(
@JsonProperty("productName") String productName,
@JsonProperty("quantity") Integer quantity
) {
this.productName = productName;
this.quantity = quantity;
}
public CreateOrderCommand toCommand() {
return CreateOrderCommand.of(productName, quantity);
}
}
@Getter
public class OrderResponse {
private final Long orderId;
private final String productName;
private final int quantity;
private final BigDecimal totalPrice;
private OrderResponse(Long orderId, String productName, int quantity, BigDecimal totalPrice) {
this.orderId = orderId;
this.productName = productName;
this.quantity = quantity;
this.totalPrice = totalPrice;
}
public static OrderResponse from(ServiceResult<OrderDomain> result) {
OrderDomain order = result.getData();
return new OrderResponse(
order.getId(),
order.getProductName(),
order.getQuantity(),
order.getTotalPrice()
);
}
}
DTO를 record가 아니라 class로 만들고 생성 경로를 하나로 고정하는 규칙은 DTO 생성 표준에서 자세히 다룬다.
Controller 메서드는 XxxResponse를 그대로 반환하지 않는다. 모든 응답을 HttpApiResponse<T> 공통 봉투로 감싼다. 클라이언트가 성공/실패를 매번 다른 모양으로 파싱하지 않도록, 응답 구조 자체를 하나로 고정하기 위해서다.
실제로 나가는 응답 중 딱 하나는 본문이 없다. 클라이언트가 If-None-Match를 보내 서버가 304로 답하는 경우다(성공 응답 상태 표준 원칙 12). 다만 이건 Controller가 아니라 그 앞의 필터가 만드는 응답이라, 여기서 정한 반환 타입은 바뀌지 않는다.
@Getter
public class HttpApiResponse<T> {
private final boolean success;
private final int status;
private final int code;
private final String message;
private final T data;
private final List<FieldError> errors;
private HttpApiResponse(
boolean success, int status, int code, String message, T data, List<FieldError> errors) {
this.success = success;
this.status = status;
this.code = code;
this.message = message;
this.data = data;
this.errors = errors;
}
public static <T> HttpApiResponse<T> ok(T data) {
return success(200, "OK", data);
}
public static HttpApiResponse<Void> ok() {
return success(200, "OK", null);
}
public static <T> HttpApiResponse<T> created(T data) {
return success(201, "Created", data);
}
public static <T> HttpApiResponse<T> accepted(T data) {
return success(202, "Accepted", data);
}
private static <T> HttpApiResponse<T> success(int status, String message, T data) {
int code = status * 1_000_000;
return new HttpApiResponse<>(true, status, code, message, data, List.of());
}
public static HttpApiResponse<Void> error(ErrorCode errorCode, String message) {
return new HttpApiResponse<>(
false, errorCode.getStatus(), errorCode.getCode(), message, null, List.of());
}
public static HttpApiResponse<Void> validationError(List<FieldError> errors) {
ErrorCode errorCode = ErrorCode.INVALID_REQUEST;
return new HttpApiResponse<>(
false, errorCode.getStatus(), errorCode.getCode(), errorCode.getDefaultMessage(), null, errors);
}
@Getter
public static class FieldError {
private final String field;
private final String message;
private FieldError(String field, String message) {
this.field = field;
this.message = message;
}
public static FieldError of(String field, String message) {
return new FieldError(field, message);
}
}
}
errors는 검증 실패 시 어느 필드가 왜 틀렸는지를 담는 자리다. 자세한 사용 규칙은 계층별 검증 표준에서 다룬다.
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderCommandUseCase orderCommandUseCase;
@PostMapping("/orders")
public ResponseEntity<HttpApiResponse<OrderCreatedResponse>> createOrder(
@RequestBody CreateOrderRequest request) {
ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand());
OrderCreatedResponse data = OrderCreatedResponse.from(result);
HttpApiResponse<OrderCreatedResponse> response = HttpApiResponse.created(data);
URI location = URI.create("/orders/" + data.getOrderId());
return ResponseEntity.status(response.getStatus()).location(location).body(response);
}
}
HttpApiResponse도 변환 책임 원칙을 그대로 따른다. Controller 계층의 본문 타입이며, 실제 HTTP 상태와 헤더는 Controller가 ResponseEntity로 조립한다. 실패 응답은 @ExceptionHandler가 같은 방식으로 ResponseEntity<HttpApiResponse<Void>>를 반환한다. 상태별 상세 규칙은 성공 응답 상태 표준을 따른다.
2. Service: Command / Query 입력
Service로 들어가는 입력은 쓰기는 Command, 읽기는 Query로 나눈다. CQRS를 엄격히 적용하는 건 아니고, 이름만으로 “이 메서드가 상태를 바꾸는지”를 구분하려는 목적이다.
@Getter
public class CreateOrderCommand {
private final String productName;
private final Integer quantity;
private CreateOrderCommand(String productName, Integer quantity) {
this.productName = productName;
this.quantity = quantity;
}
public static CreateOrderCommand of(String productName, Integer quantity) {
return new CreateOrderCommand(productName, quantity);
}
}
@Getter
public class FindOrderQuery {
private final Long orderId;
private FindOrderQuery(Long orderId) {
this.orderId = orderId;
}
public static FindOrderQuery of(Long orderId) {
return new FindOrderQuery(orderId);
}
}
null을 허용하는 필드는 Null-free 객체 설계의 규칙대로 orNull 접미사로 표기한다. 이 타입들은 Service 곳곳에서 읽히므로 이름이 호출부까지 따라가야 하기 때문이다. 다른 도메인이 읽는 XxxSnapshot도 같은 규칙이다. 반면 JSON 키가 계약인 XxxRequest와 XxxResponse는 @Nullable 어노테이션을 쓴다. 전체 기준과 Response의 null 출력 방식은 경계 DTO의 null 표기에서 다룬다.
3. Domain: 영속성을 모르는 내부 객체
Domain은 Service가 실제로 다루는 비즈니스 객체다. JPA Entity도, MyBatis MapperResult도 몰라야 한다. 그래야 영속성 기술을 JPA에서 MyBatis로 바꿔도 Service 코드는 한 줄도 바뀌지 않는다.
public final class OrderDomain {
private final Long id;
private final String productName;
private final int quantity;
private final BigDecimal unitPrice;
private OrderDomain(Long id, String productName, int quantity, BigDecimal unitPrice) {
this.id = id;
this.productName = Objects.requireNonNull(productName, "productName");
this.quantity = quantity;
this.unitPrice = Objects.requireNonNull(unitPrice, "unitPrice");
}
public static OrderDomain of(Long id, String productName, int quantity, BigDecimal unitPrice) {
return new OrderDomain(id, productName, quantity, unitPrice);
}
public BigDecimal getTotalPrice() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
public Long getId() { return id; }
public String getProductName() { return productName; }
public int getQuantity() { return quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
}
Domain이 어느 Entity/MapperResult도 import하지 않는다는 점이 이 구조 전체의 전제다. 여기서 규칙이 깨지면 밑에서 아무리 Mapper를 잘 짜도 의미가 없다.
4. Service 출력: ServiceResult / ServiceResults / PagingResult
Service의 반환 타입은 매번 새 클래스를 만들지 않고, 공통 제네릭 래퍼 세 가지로 통일한다. 기본값은 항상 T = Domain이고, 응답을 가공하거나 여러 Domain을 조합해야 하는 실제 이유가 생겼을 때만 예외적으로 다른 타입을 넣는다.
세 개 중 하나는 이름이 다르다. PagingResult에만 Service 접두사가 없는데, 실수가 아니라 경계를 표시한 것이다. ServiceResult와 ServiceResults는 Service만 만들고 Service만 반환한다. 반면 PagingResult는 domain의 Repository 인터페이스가 반환하고 infra의 구현체가 만든다 — 페이징 메타는 조회 그 자체에서만 나오기 때문에 Repository가 그걸 실어 올릴 수밖에 없다. totalCount가 COUNT 쿼리에서 나오는 것이 그 사례다. 페이징만 필연적으로 계층을 넘는 것이고, 그래서 이름에서 Service를 뗐다. 접두사가 있으면 Service 전용, 없으면 공유로 읽으면 된다.
이 근거를 처음에는 totalCount로 적었다. 그런데 페이징 방식 표준에서 totalCount가 없는 CursorResult가 생기자 그 문장으로는 얘는 왜 접두사가 없는지를 설명할 수 없었다. 결론은 같고 근거만 어긋난 경우라, 근거를 한 단계 위로 올렸다.
@Getter
public class ServiceResult<T> {
private final T data;
private ServiceResult(T data) {
this.data = data;
}
public static <T> ServiceResult<T> of(T data) {
return new ServiceResult<>(data);
}
}
@Getter
public class ServiceResults<T> {
private final List<T> data;
private ServiceResults(List<T> data) {
this.data = data;
}
public static <T> ServiceResults<T> of(List<T> data) {
return new ServiceResults<>(data);
}
}
@Getter
public class PagingResult<T> {
private final List<T> content;
private final int page;
private final int size;
private final long totalCount;
private PagingResult(List<T> content, int page, int size, long totalCount) {
this.content = content;
this.page = page;
this.size = size;
this.totalCount = totalCount;
}
public static <T> PagingResult<T> of(List<T> content, int page, int size, long totalCount) {
return new PagingResult<>(content, page, size, totalCount);
}
/** Domain 목록을 Response 목록으로 갈아 끼운다. 페이징 메타는 그대로 옮긴다. */
public <R> PagingResult<R> map(Function<T, R> mapper) {
return new PagingResult<>(content.stream().map(mapper).toList(), page, size, totalCount);
}
public boolean hasNext() {
return (long) (page + 1) * size < totalCount;
}
}
totalCount를 필드로 둔 대가가 있다. 이 필드가 있다는 건 매 목록 조회에 COUNT 쿼리를 한 번 친다는 뜻이고, 타입이 그걸 요구하므로 안 칠 수가 없다. 즉 이 타입은 offset 페이징을 계약으로 박은 것이다. 무한 스크롤이나 커서 페이징은 totalCount가 없어야 하므로 이 타입으로 표현할 수 없고, 그래서 나중에 페이징 방식 표준이 CursorResult<T>를 따로 만들었다. 어느 쪽을 쓸지는 “임의 페이지로 점프할 수 있어야 하는가”로 가른다.
data가 아니라 content인 것에 이유가 있다. PagingResult는 그대로 api 경계까지 올라가는데, HttpApiResponse의 data 안에 또 data가 들어가면 클라이언트가 response.data.data로 접근하게 된다. 세 봉투 중 이것만 필드명이 다른 셈인데, 이것만 계층을 넘기 때문이라 예외가 아니라 같은 이유의 연장이다.
map()은 Controller가 PagingResult<OrderDomain>을 PagingResult<OrderResponse>로 바꿀 때 쓴다. 이게 없으면 목록 API마다 리스트를 풀어서 다시 감싸는 코드가 반복된다.
// order/api/OrderAdminController.java
@GetMapping("/admin/orders")
public ResponseEntity<HttpApiResponse<PagingResult<OrderResponse>>> searchOrders(
@Valid @ModelAttribute OrderSearchRequest request) {
PagingResult<OrderDomain> result = orderQueryUseCase.searchOrders(request.toQuery());
HttpApiResponse<PagingResult<OrderResponse>> response =
HttpApiResponse.ok(result.map(OrderResponse::from));
return ResponseEntity.status(response.getStatus()).body(response);
}
즉 페이징 응답 본문의 T는 XxxResponse가 아니라 PagingResult<XxxResponse>다. “응답 본문은 HttpApiResponse<T>이고 T는 XxxResponse“라는 규칙의 유일한 예외이고, 목록 API의 응답 모양이 전역에서 같아지는 대가로 받아들였다.
봉투를 씌우지 않는 자리가 하나 더 있다. 이 글을 쓸 때는 “Service 출력은 언제나 세 봉투 중 하나”로 정했는데, 나중에 UseCase 인터페이스 표준에서 도메인 간 창구인 XxxProvider를 만들면서 이 규칙이 걸렸다. ServiceResult 계열은 api 경계로 나가는 응답을 위한 봉투인데, 도메인 간 호출은 그 경계가 아니다. 그래서 OrderProvider.getOrder()는 ServiceResult<OrderSnapshot>이 아니라 OrderSnapshot을 그대로 반환한다. 같은 XxxService가 구현하더라도 어느 경계로 나가는지에 따라 봉투가 갈린다.
@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase, OrderQueryUseCase {
private final OrderRepository orderRepository;
public ServiceResult<OrderDomain> createOrder(CreateOrderCommand command) {
OrderDomain order = OrderDomain.of(null, command.getProductName(), command.getQuantity(), lookupUnitPrice(command));
OrderDomain saved = orderRepository.save(order);
return ServiceResult.of(saved);
}
public PagingResult<OrderDomain> findOrders(int page, int size) {
return orderRepository.findAll(page, size);
}
}
OrderRepository는 Service가 정의하는 인터페이스이고, 반환 타입은 항상 Domain이다 — 단건은 그대로, 목록은 PagingResult<Domain>으로 감싸서. 구현체가 JPA든 MyBatis든 Service는 신경 쓰지 않는다.
public interface OrderRepository {
OrderDomain save(OrderDomain order);
Optional<OrderDomain> findById(Long id);
PagingResult<OrderDomain> findAll(int page, int size);
}
5. JPA: Entity + EntityMapper
Entity는 패키지 밖으로 나가지 않는다. Entity가 스스로 Domain으로 변환하는 메서드를 갖지 않는 것도 같은 이유다 — Entity가 Domain 타입을 알아버리면 영속성 계층이 상위 계층에 의존하게 된다.
이 “패키지 밖으로 나가지 않는다”는 패키지 구조 표준에서 package-private으로 강제한다. 규율이 아니라 컴파일 에러로 막힌다.
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false)
private String productName;
@Column(nullable = false)
private int quantity;
@Column(nullable = false)
private BigDecimal unitPrice;
protected OrderEntity() {}
public static OrderEntity create(String productName, int quantity, BigDecimal unitPrice) {
OrderEntity entity = new OrderEntity();
entity.productName = productName;
entity.quantity = quantity;
entity.unitPrice = unitPrice;
return entity;
}
public Long getId() { return id; }
public String getProductName() { return productName; }
public int getQuantity() { return quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
}
Entity ↔ Domain 변환은 별도 EntityMapper가 맡는다.
@Component
public class OrderEntityMapper {
public OrderDomain toDomain(OrderEntity entity) {
return OrderDomain.of(
entity.getId(),
entity.getProductName(),
entity.getQuantity(),
entity.getUnitPrice()
);
}
public OrderEntity toEntity(OrderDomain domain) { // 신규 생성용
return OrderEntity.create(
domain.getProductName(),
domain.getQuantity(),
domain.getUnitPrice()
);
}
public void applyTo(OrderEntity entity, OrderDomain domain) { // 기존 수정용
entity.changeQuantity(domain.getQuantity());
entity.changeUnitPrice(domain.getUnitPrice());
// createdAt, version처럼 Domain이 모르는 필드는 아예 언급하지 않는다
}
}
Mapper가 셋을 갖는 이유는 저장 경로 때문이다. toEntity는 신규 INSERT용이고, 기존 수정은 영속 Entity를 꺼내 applyTo로 덮는다. 분리 Entity를 save()에 넘기면 merge가 되어 Domain이 모르는 컬럼이 null로 덮이기 때문이다. Domain 영속화 표준에서 다룬다.
OrderRepository의 JPA 구현체는 Spring Data JPA를 감싸고, Mapper로 변환한 뒤에만 값을 돌려준다.
@Repository
@RequiredArgsConstructor
public class OrderJpaRepository implements OrderRepository {
private final OrderJpaEntityRepository jpaEntityRepository; // Spring Data JPA
private final OrderEntityMapper mapper;
@Override
public OrderDomain save(OrderDomain order) {
if (order.getId() == null) {
return mapper.toDomain(jpaEntityRepository.save(mapper.toEntity(order)));
}
OrderEntity entity = jpaEntityRepository.findById(order.getId())
.orElseThrow(() -> ErrorCodeException.of(ErrorCode.ORDER_NOT_FOUND));
mapper.applyTo(entity, order); // 수정은 영속 Entity를 직접 고친다
return mapper.toDomain(entity);
}
@Override
public Optional<OrderDomain> findById(Long id) {
return jpaEntityRepository.findById(id).map(mapper::toDomain);
}
@Override
public PagingResult<OrderDomain> findAll(int page, int size) {
Page<OrderEntity> result = jpaEntityRepository.findAll(PageRequest.of(page, size));
List<OrderDomain> data = result.getContent().stream().map(mapper::toDomain).toList();
return PagingResult.of(data, page, size, result.getTotalElements());
}
}
Entity를 그대로 쓰지 못하는 조인 조회는 Entity가 아니라 별도 XxxProjection으로 이름을 분리한다 (예: OrderSummaryProjection). 이건 JPA 네이티브 쿼리 전용 이름이고 클래스가 아니라 인터페이스로 선언한다 — 이유는 패키지 구조 표준에서 다룬다. MyBatis로 조인하면 결과 타입은 XxxMapperResult다.
6. MyBatis: MapperParam / MapperResult + Mapper
MyBatis도 JPA와 동일한 규칙을 따른다. 입력과 출력의 이름을 명시적으로 분리하고, MapperResult도 패키지 밖으로 나가지 않는다.
@Getter
public class OrderMapperParam {
private final Long id;
private OrderMapperParam(Long id) {
this.id = id;
}
public static OrderMapperParam of(Long id) {
return new OrderMapperParam(id);
}
}
// MapperResult는 MyBatis가 채우므로 기본 생성자와 setter를 예외적으로 허용한다
@Getter
@Setter
@NoArgsConstructor
public class OrderMapperResult {
private Long id;
private String productName;
private int quantity;
private BigDecimal unitPrice;
}
MapperParam은 우리 코드가 만들므로 DTO 생성 표준을 그대로 따르고, MapperResult만 MyBatis가 채우기 때문에 예외가 된다. 이 예외가 허용되는 이유는 MapperResult가 패키지 밖으로 나가지 않아 수명이 짧기 때문이다.
<select id="findById" parameterType="OrderMapperParam" resultType="OrderMapperResult">
SELECT id, product_name AS productName, quantity, unit_price AS unitPrice
FROM orders
WHERE id = #{id}
</select>
@Component
public class OrderMyBatisMapper {
public OrderDomain toDomain(OrderMapperResult result) {
return OrderDomain.of(
result.getId(),
result.getProductName(),
result.getQuantity(),
result.getUnitPrice()
);
}
}
@Repository
@RequiredArgsConstructor
public class OrderMyBatisRepository implements OrderRepository {
private final OrderSqlMapper sqlMapper; // MyBatis 매퍼 인터페이스
private final OrderMyBatisMapper mapper;
@Override
public Optional<OrderDomain> findById(Long id) {
OrderMapperResult result = sqlMapper.findById(OrderMapperParam.of(id));
return Optional.ofNullable(result).map(mapper::toDomain);
}
}
JPA와 MyBatis 두 구현체 모두 같은 OrderRepository 인터페이스를 구현하므로, Service는 어느 쪽이 꽂혀 있는지 전혀 알 수 없다.
범위를 좁히는 각주. 여기서 JPA와 MyBatis는 같은 인터페이스를 놓고 기술을 통째로 갈아 끼우는 관계다 — 마이그레이션하듯 하나를 다른 하나로 바꾸는 상황을 그린다. 그런데 조건이 여러 개 조합되는 동적 검색이 필요한 애그리거트는 얘기가 다르다. 쓰기/단건 조회(JPA)와 동적 검색(MyBatis)이 한 애그리거트 안에 영구히 공존하며, 인터페이스도
OrderRepository/OrderSearchRepository로 나뉜다. 이 경우의 기준은 Repository 설계 표준에서 다룬다.
변환 책임: 누가, 왜
| 변환 | 담당 | 이유 |
|---|---|---|
Request → Command |
Request 자신 (toCommand()) |
Controller는 Service 타입을 알아도 되는 계층 |
ServiceResult → Response |
Response 자신 (from()) |
Controller는 Service 타입을 알아도 되는 계층 |
Entity ↔ Domain |
별도 EntityMapper |
Entity와 Domain은 서로의 존재를 몰라야 함 |
MapperResult ↔ Domain |
별도 Mapper | MapperResult와 Domain은 서로의 존재를 몰라야 함 |
기준은 하나다. 의존성 방향으로 변환이 가능하면 DTO 자신이 하고, 반대 방향이면 별도 클래스가 한다. Controller는 원래 Service를 의존해도 되는 바깥 계층이라 자기 변환이 가능하지만, Entity/MapperResult가 자기 변환을 하면 안쪽 계층이 바깥쪽(Domain)을 의존하게 되어 방향이 뒤집힌다.
판단 기준 정리
| 상황 | 타입 | 예시 |
|---|---|---|
| HTTP 요청 바디 | XxxRequest |
CreateOrderRequest |
| HTTP 응답 바디 | XxxResponse |
OrderResponse |
| HTTP 응답 전체 봉투 | HttpApiResponse<T> |
HttpApiResponse<OrderResponse> |
| Service 입력 (쓰기) | XxxCommand |
CreateOrderCommand |
| Service 입력 (읽기) | XxxQuery |
FindOrderQuery |
| Service 내부 / Repository 반환 | Domain |
OrderDomain |
| Repository 입력 (조회 조건) | XxxSearchCondition |
OrderSearchCondition |
| Service 출력 (단건) | ServiceResult<T> |
ServiceResult<OrderDomain> |
| Service 출력 (리스트) | ServiceResults<T> |
ServiceResults<OrderDomain> |
| 페이징 봉투 (Repository~api 공유) | PagingResult<T> |
PagingResult<OrderDomain> |
| JPA 영속 객체 | XxxEntity |
OrderEntity |
| JPA 조인 조회 (인터페이스) | XxxProjection |
OrderSummaryProjection |
| 애그리거트에 담기지 않는 조회 결과 | XxxView |
OrderSummaryView |
| MyBatis 입력 파라미터 | XxxMapperParam |
OrderMapperParam |
| MyBatis 조회 결과 | XxxMapperResult |
OrderMapperResult |
| 다른 도메인에 넘기는 값 | XxxSnapshot |
OrderSnapshot |
| 대량 연산 전용 Mapper | XxxBulkMapper |
OrderBulkMapper |
| Spring Batch 컴포넌트 | XxxBulk{의도}Writer |
OrderBulkStatusWriter |
| 대량 연산 입력 | XxxBulkMapperParam |
OrderBulkMapperParam |
| 대량 연산 조회 결과 | XxxBulkMapperResult |
OrderBulkMapperResult |
XxxQuery와 XxxSearchCondition이 따로 있는 이유가 있다. XxxQuery는 application에 있고 Repository 인터페이스는 domain에 있는데, 패키지 구조 표준이 정한 대로 domain은 application을 참조할 수 없다. 그러니 XxxQuery를 Repository 파라미터로 쓰면 의존 방향이 뒤집힌다. domain에 조회 조건 타입을 따로 두고 Service가 변환해 넘긴다 — Request → Command와 정확히 같은 구조다. 조건이 조합되는 검색은 Repository 설계 표준에서 다룬다.
벌크 계열 네 줄에 Repository가 붙지 않은 건 의도적이다. Repository는 “이걸 쓰면 애그리거트 규칙이 보장된다”는 약속인데, 대량 연산은 애초에 그 약속을 깨는 물건이다.
MapperParam과 BulkMapperParam을 따로 두는 것도 의도적이다. 벌크는 {도메인}/bulk에 있고 MapperParam은 infra에 package-private으로 있어서 애초에 참조가 안 된다. 그걸 public으로 열면 application까지 MyBatis 타입을 보게 되므로, 배치가 자기 타입을 갖는 쪽을 택했다. 자세한 내용은 Repository 설계 표준에서 다룬다.
이 구조에 도달하기까지
처음 생각은 단순했다. “계층마다 DTO 이름을 다르게 하면 의존성이 줄어들지 않을까.” Controller는 Request/Response, Service는 Query/Command, JPA는 Entity, MyBatis는 MapperDto. 여기까지는 금방 정했는데, 막상 세부 규칙을 정하려니 질문이 하나씩 따라붙었다.
Service 출력부터 막혔다. 단건이든 리스트든 매번 XxxResult 클래스를 새로 만들면 결국 Entity, Response와 똑같은 클래스가 하나 더 늘어나는 셈이었다. 그래서 제네릭 Result<T>로 감싸는 쪽으로 방향을 잡았고, 이름은 ServiceResult로 확정했다. 리스트와 페이징까지 같은 타입으로 억지로 우겨넣으면 나중에 페이징 메타데이터(totalCount, hasNext)를 넣을 자리가 없어질 게 뻔해서, ServiceResults와 PagingResult로 아예 나눴다.
그다음은 “누가 변환하는가”였다. 처음엔 각 DTO가 정적 팩토리로 스스로를 변환하면 될 것 같았다(Response.from(result)처럼). 그런데 이 규칙을 Entity에도 그대로 적용하면 문제가 생긴다. Entity가 toResult() 같은 메서드를 가지려면 Entity가 Service 계층 타입을 import해야 하는데, 그러면 영속성 계층이 상위 계층을 아는 구조가 되어버린다. 정리해보니 기준은 하나였다 — 의존성 방향으로 변환이 가능하면 DTO 자신이 하고, 반대 방향이면 별도 클래스가 한다. Controller는 원래 Service를 알아도 되는 바깥 계층이니 자기 변환이 가능하지만, Entity는 안쪽 계층이라 스스로 변환하면 안 된다.
Entity 변환을 막고 나니 자연스럽게 다음 질문이 나왔다. “그럼 Service는 Entity를 안 쓰고 뭘 쓰지?” MyBatis로 바꾸면 Service 코드까지 손대야 하는 상황을 피하고 싶었다. 그래서 JPA인지 MyBatis인지 전혀 모르는 Domain을 두고, Repository 인터페이스가 항상 Domain을 반환하도록 했다. Entity와 MapperResult는 각각 EntityMapper, MyBatis Mapper를 통해서만 Domain으로 바뀐다.
Mapper 클래스 대신 JPQL 생성자 표현식이나 MyBatis resultMap으로 바로 Domain을 만드는 방법도 검토했다. 조회 성능은 그쪽이 조금 더 유리하지만, 매핑 로직이 쿼리 문자열과 XML에 흩어지면 나중에 리팩터링할 때 추적하기가 더 번거로워진다. 관리 편의성을 우선해서 Mapper 클래스로 정리했다.
마지막 걱정은 성능이었다. 계층마다 객체를 새로 만들면 GC 부담이 크지 않을까 싶었는데, 따져보면 전부 짧게 살고 죽는 작은 객체라 JVM 영 세대 GC가 가장 잘 처리하는 케이스다. 실제 병목은 대부분 DB 쿼리나 네트워크 I/O 쪽이지 이런 매핑 코드가 아니다. 다만 수백만 건을 순회하는 배치 작업처럼 진짜 핫패스가 있다면, 그 구간만큼은 이 구조를 다 거치지 않고 우회하는 것도 고려할 부분이다.
응답 형식도 한 번 더 통일했다. Controller가 매번 XxxResponse를 그대로 반환하면, 성공 응답과 실패 응답의 모양이 endpoint마다 제각각이 될 수 있다. 그래서 모든 Controller 메서드가 HttpApiResponse<T>라는 공통 봉투에 담아 반환하도록 규칙을 하나 더 추가했다. Response와 마찬가지로 Controller 계층의 타입이라, 자기 자신을 조립하는 게 자연스럽다.
정리
- 계층이 다르면 타입도 다르다. 하나의 DTO를 Controller부터 Entity까지 관통시키지 않는다
- Domain은 영속성 기술을 모른다. JPA/MyBatis 어느 쪽으로 구현이 바뀌어도 Service는 그대로다
- Service 출력은
ServiceResult/ServiceResults/PagingResult세 가지로 통일한다. 가공이 필요할 때만 예외를 만든다 - 단
XxxProvider의 반환은 감싸지 않는다. 그 봉투는api경계용이고 도메인 간 호출은 그 경계가 아니다 - 변환 책임은 항상 바깥쪽 DTO에 있다. Entity와 MapperResult는 절대 자기 자신을 변환하지 않는다
- Repository 인터페이스는 Service가 정의하고
Domain을 반환한다. JPA/MyBatis는 구현체일 뿐이다 - Controller는
XxxResponse를 그대로 반환하지 않는다. 모든 응답은HttpApiResponse<T>로 감싼다 (본문이 없는304는 필터가 만든다) - nullable은 JSON 계약인 Request·Response에서
@Nullable, Java 계약인 Command·Query·Domain·Snapshot에서orNull로 표기한다. Response 필드는 null이어도 키를 생략하지 않는다
이 표준은 Null-free 객체 설계와 생성 철학, 상속 vs 합성, 팩토리 메소드로 VO 생성하기에서 정리한 원칙과 같은 선상에 있다 — 객체는 생성 시점에 완전한 상태를 가지고, 자기 책임 밖의 일은 하지 않는다.
AI 코드 어시스턴트에 바로 적용하기
위 규칙을 매번 설명하지 않아도 되도록, Claude Code와 GitHub Copilot에 그대로 붙여넣을 수 있는 형태로 정리했다.
Claude Code — .claude/skills/layered-dto-naming-standard/SKILL.md
---
name: layered-dto-naming-standard
description: Controller/Service/JPA/MyBatis 계층별 DTO 네이밍과 변환 책임 규칙. DTO, Request, Response, Command, Query, Domain, Entity, Mapper 관련 코드를 생성하거나 리뷰할 때 반드시 적용한다.
---
# 계층별 DTO 네이밍 표준
이 프로젝트는 계층마다 서로 다른 DTO 타입을 사용해 의존성을 차단한다. 아래 규칙을 벗어나는 코드는 생성하지 않는다.
## 네이밍
- Controller 입력은 `XxxRequest`, 출력은 `XxxResponse`로 만든다.
- Service 입력은 쓰기 작업이면 `XxxCommand`, 읽기 작업이면 `XxxQuery`로 만든다.
- Service 내부와 Repository 반환 타입은 항상 `XxxDomain`이다. 목록은 `PagingResult<XxxDomain>` 또는 `CursorResult<XxxDomain>`으로 감싼다. 둘 중 무엇을 쓰는지는 paging-strategy-standard를 따른다.
- Repository의 조회 조건 입력은 `XxxSearchCondition`으로 만들고 `domain`에 `public`으로 둔다. `XxxQuery`를 Repository 파라미터로 쓰지 않는다 — `XxxQuery`는 `application`에 있고 `domain`은 `application`을 참조할 수 없다.
- Service가 `XxxQuery`를 `XxxSearchCondition`으로 변환해 Repository에 넘긴다. `Request` → `Command`와 같은 구조다.
- Service 출력은 `ServiceResult<T>`(단건), `ServiceResults<T>`(리스트), `PagingResult<T>`(offset 페이징), `CursorResult<T>`(커서 페이징) 중 하나로 감싼다. 기본값은 `T = Domain`이며, 가공이 필요한 실제 이유가 있을 때만 다른 타입을 쓴다.
- 단 `XxxProvider`의 반환은 감싸지 않는다. `ServiceResult` 계열은 `api` 경계로 나가는 응답을 위한 봉투이고, 도메인 간 호출은 그 경계가 아니다. usecase-interface-standard를 따른다.
- `ServiceResult`와 `ServiceResults`는 Service만 만들고 Service만 반환한다. `PagingResult`와 `CursorResult`는 `domain`의 Repository 인터페이스도 반환한다. 페이징 메타는 조회 그 자체에서만 나오기 때문이다.
- 그래서 페이징 봉투에는 `Service` 접두사를 붙이지 않는다. 접두사가 있으면 Service 전용, 없으면 계층 간 공유로 읽는다.
- JPA 영속 객체는 `XxxEntity`로 만든다.
- 여러 도메인을 조인하는 조회는 **JPA 네이티브 쿼리 + 인터페이스 프로젝션**으로 하고 이름은 `XxxProjection`으로 한다. 클래스가 아니라 인터페이스로 선언하고 getter만 둔다.
- MyBatis로 조인 조회를 하면 결과 타입은 `XxxProjection`이 아니라 `XxxMapperResult`다. `XxxProjection`은 JPA 전용 이름이다.
- `XxxProjection`과 `XxxMapperResult`는 `infra` 밖으로 나가지 않는다. 애그리거트 하나에 담기지 않는 조회 결과는 `domain`의 `XxxView`로 변환해 내보낸다. read-model-standard를 따른다.
- `XxxView`는 `{조회 이름}View`로 짓고 같은 조회의 `XxxProjection`·`XxxMapperResult`·`XxxResponse`와 접두사를 맞춘다.
- MyBatis 입력은 `XxxMapperParam`, 출력은 `XxxMapperResult`로 만든다.
- 다른 도메인에 넘기는 타입은 `XxxSnapshot`으로 만들고 `application`에 둔다. `XxxDomain`을 도메인 밖으로 내보내지 않는다. usecase-interface-standard를 따른다.
- nullable은 `XxxRequest`·`XxxResponse`에서 `@Nullable`, `XxxCommand`·`XxxQuery`·`XxxDomain`·`XxxSnapshot`에서 `orNull`로 표시한다. Response의 null 필드는 JSON 키를 생략하지 않는다. boundary-dto-null-standard를 따른다.
- 대량 연산 전용 MyBatis Mapper는 `XxxBulkMapper`, Spring Batch 컴포넌트는 `XxxBulk{의도}Writer` / `XxxBulk{의도}Reader`로 만든다(`OrderBulkStatusWriter` 등). 여기에 `Repository` 접미사를 붙이지 않는다. `Repository`는 애그리거트 규칙이 보장된다는 약속이고, 대량 연산은 그 약속을 깨는 물건이다.
- 대량 연산의 입력은 `XxxBulkMapperParam`, 출력은 `XxxBulkMapperResult`로 만들고 `{도메인}/bulk`에 둔다. `infra`의 `XxxMapperParam`을 벌크에서 재사용하지 않는다.
- Controller 메서드의 반환 타입은 항상 `ResponseEntity<HttpApiResponse<T>>`다 (`T`는 `XxxResponse`). `XxxResponse`를 그대로 반환하지 않는다.
- 페이징 응답만 예외로 `T`가 `PagingResult<XxxResponse>` 또는 `CursorResult<XxxResponse>`다. Controller에서 `result.map(XxxResponse::from)`으로 변환한다.
- `PagingResult`와 `CursorResult`의 목록 필드 이름은 `data`가 아니라 `content`다. `HttpApiResponse.data` 안에서 `data`가 겹치지 않게 하기 위해서다.
## 변환 책임
- `Request`/`Response`는 스스로 변환 메서드(`toCommand()`, `from()`)를 가질 수 있다.
- 인증 주체가 필요하면 `toCommand(LoginUser)` / `toQuery(LoginUser)`처럼 인자로 받는다. `XxxRequest`에 `userId` 필드를 두지 않는다. authenticated-user-standard를 따른다.
- `Entity`, `MapperResult`, `Domain`은 서로를 변환하는 메서드를 갖지 않는다. 변환은 반드시 별도의 `XxxEntityMapper` / MyBatis용 Mapper 클래스가 담당한다.
- `XxxEntityMapper`는 `toEntity`(신규 생성), `applyTo`(기존 수정), `toDomain`(조회) 셋을 갖는다. domain-persistence-standard를 따른다.
- `Domain`은 JPA나 MyBatis 관련 타입을 import하지 않는다.
## 경계
- Repository 인터페이스는 Service 쪽에서 정의하고 `Domain`을 반환한다. JPA/MyBatis 구현체는 이 인터페이스 뒤에 숨긴다.
- 한 애그리거트의 Repository 인터페이스는 둘 이상일 수 있다. 저장과 단건 조회는 `XxxRepository`(JPA), 조건이 2개 이상 선택적으로 조합되는 동적 검색은 `XxxSearchRepository`(MyBatis)로 나누고 하나로 합치지 않는다.
- 즉 JPA와 MyBatis는 같은 인터페이스를 갈아 끼우는 대체 구현체이기도 하고, 서로 다른 인터페이스로 한 애그리거트 안에 공존하기도 한다. 어느 쪽인지는 조회 조건의 성격으로 가른다.
- 위 규칙과 다른 패턴(Entity를 Controller까지 직접 전달, DTO 하나를 여러 계층에서 재사용 등)을 발견하면 그대로 따르지 말고 리팩터링을 제안한다.
GitHub Copilot — .github/instructions/layered-dto-naming-standard.instructions.md
---
description: 계층별 DTO 네이밍과 변환 책임 규칙
applyTo: "**/*.java"
---
- Controller의 입력 DTO는 `XxxRequest`로 만든다.
- Controller의 출력 DTO는 `XxxResponse`로 만든다.
- Service의 쓰기 입력은 `XxxCommand`로 만든다.
- Service의 읽기 입력은 `XxxQuery`로 만든다.
- Service 내부 타입과 Repository 반환 타입은 `XxxDomain`으로 만든다.
- Domain 클래스는 JPA나 MyBatis 타입을 참조하지 않는다.
- Service의 단건 반환은 `ServiceResult<T>`로 감싼다.
- Service의 리스트 반환은 `ServiceResults<T>`로 감싼다.
- `XxxProvider`의 반환은 `ServiceResult` 계열로 감싸지 않는다. 그 봉투는 `api` 경계용이다.
- 페이징 반환은 `PagingResult<T>` 또는 `CursorResult<T>`로 감싼다. 두 타입은 `domain`의 Repository 인터페이스도 반환하므로 `Service` 접두사를 붙이지 않는다.
- Repository의 조회 조건 입력은 `XxxSearchCondition`으로 만들고 `domain`에 둔다. `XxxQuery`를 Repository 파라미터로 쓰지 않는다.
- Service가 `XxxQuery`를 `XxxSearchCondition`으로 변환해 Repository에 넘긴다.
- JPA 엔티티는 `XxxEntity`로 만들고 변환 메서드를 갖지 않는다.
- 여러 도메인을 조인하는 JPA 네이티브 조회 결과는 `XxxProjection` **인터페이스**로 만들고 getter만 둔다.
- MyBatis로 조인 조회를 하면 결과 타입은 `XxxMapperResult`로 만든다. `XxxProjection`은 JPA 전용 이름이다.
- `XxxProjection`과 `XxxMapperResult`를 `infra` 밖으로 내보내지 않는다. 애그리거트에 담기지 않는 조회 결과는 `domain`의 `XxxView`로 변환해 반환한다.
- `XxxView`는 `{조회 이름}View`로 짓고 같은 조회의 다른 층 타입과 접두사를 맞춘다.
- MyBatis 입력 파라미터는 `XxxMapperParam`으로 만든다.
- MyBatis 조회 결과는 `XxxMapperResult`로 만든다.
- 다른 도메인에 넘기는 타입은 `XxxSnapshot`으로 만들고 `application`에 둔다. `XxxDomain`을 도메인 밖으로 내보내지 않는다.
- nullable은 `XxxRequest`·`XxxResponse`에서 `@Nullable`, `XxxCommand`·`XxxQuery`·`XxxDomain`·`XxxSnapshot`에서 `orNull`로 표시한다. Response의 null 필드는 JSON 키를 생략하지 않는다.
- 대량 연산 전용 MyBatis Mapper는 `XxxBulkMapper`, Spring Batch 컴포넌트는 `XxxBulk{의도}Writer` / `XxxBulk{의도}Reader`로 만들고 `Repository` 접미사를 붙이지 않는다.
- 대량 연산의 입력은 `XxxBulkMapperParam`, 출력은 `XxxBulkMapperResult`로 만들고 `{도메인}/bulk`에 둔다.
- Entity와 MapperResult를 Domain으로 변환할 때는 별도 Mapper 클래스(`XxxEntityMapper` 등)를 사용한다.
- `XxxEntityMapper`는 `toEntity`(신규 생성), `applyTo`(기존 수정), `toDomain`(조회) 셋을 갖는다.
- Repository 인터페이스는 Domain 타입을 반환하도록 선언한다.
- 저장과 단건 조회는 `XxxRepository`(JPA)에, 조건이 2개 이상 선택적으로 조합되는 동적 검색은 `XxxSearchRepository`(MyBatis)에 두고 하나의 인터페이스로 합치지 않는다.
- 인증 주체가 필요하면 `toCommand(LoginUser)` / `toQuery(LoginUser)`로 받고 `XxxRequest`에 `userId` 필드를 두지 않는다.
- Controller 메서드는 `XxxResponse`를 그대로 반환하지 말고 `HttpApiResponse<T>`로 감싸서 반환한다.
- 페이징 응답은 `ResponseEntity<HttpApiResponse<PagingResult<XxxResponse>>>` 또는 `ResponseEntity<HttpApiResponse<CursorResult<XxxResponse>>>`로 반환하고 `result.map(XxxResponse::from)`으로 변환한다.
- `PagingResult`와 `CursorResult`의 목록 필드 이름은 `content`로 한다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기