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);
}
}
한 줄씩 보면, @Entity는 이 클래스가 DB 테이블과 짝이라는 표시이고, @Id·@GeneratedValue(@Id·@GeneratedValue 참고)는 어느 필드가 기본 키이고 그 값을 자동으로 채울지 정한다. @RestController는 이 클래스가 요청을 받는 Controller라는 표시이고, @RequiredArgsConstructor는 final 필드(orderService)를 받는 생성자를 자동으로 만들어 준다(@RequiredArgsConstructor 참고). @PostMapping("/orders")는 “HTTP POST 방식으로 /orders에 온 요청을 이 메서드가 처리한다”는 뜻이고(요청 매핑 애너테이션 참고), @RequestBody는 요청 본문(JSON)을 OrderDto 객체로 바꿔서 받으라는 표시다(@RequestBody 참고).
- ❌
@Id,@GeneratedValue같은 영속성 관심사가 API 스펙에 그대로 노출된다 - ❌ Entity 필드를 바꾸면 Request/Response 스펙이 함께 깨진다
- ❌ Service가 JPA Entity 타입에 직접 의존해서, MyBatis로 바꾸려면 Service 코드까지 손대야 한다
- ❌ Controller에서 받은 값 그대로 DB에 저장되므로, 계층별 검증 지점이 사라진다
계층 경계마다 타입을 분리하면 이 문제가 사라진다. 대신 경계를 넘을 때 변환이 필요한데, “누가 그 변환을 책임지는가”까지 정해야 규칙이 흔들리지 않는다.
전체 흐름
가운데 있는 Domain이 핵심이다. Service는 이 타입 하나만 알면 되고, 그 뒤에 JPA가 있는지 MyBatis가 있는지는 몰라도 된다. 그림의 점선 화살표에 적힌 Repository는 Domain을 실제 DB와 연결해 주는 창구이고, 이 글 3번 이후에서 계속 나온다.
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()
);
}
}
한 줄씩 보면, @Getter는(@Getter 참고) 필드마다 getXxx() 메서드를 자동으로 만들어 준다. @JsonCreator·@JsonProperty는(@JsonCreator·@JsonProperty 참고) JSON 키(productName, quantity)를 이 생성자의 인자와 연결해, 요청 JSON이 이 생성자를 거쳐 객체로 만들어지게 한다. toCommand()는 CreateOrderRequest를 Service가 받는 CreateOrderCommand로 바꿔 돌려주고, OrderResponse.from()은 Service가 돌려준 ServiceResult<OrderDomain>에서 Domain을 꺼내 응답 모양으로 다시 조립한다.
DTO를 record가 아니라 class로 만들고 생성 경로를 하나로 고정하는 규칙은 DTO 생성 표준에서 자세히 다룬다.
Controller 메서드는 XxxResponse를 그대로 반환하지 않는다. 모든 응답을 HttpApiResponse<T>(<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;
// package-private — 같은 패키지의 헬퍼 HttpApiResponses만 성공 봉투를 만든다
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 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);
}
}
}
한 줄씩 보면, 이 클래스에는 성공 응답을 만드는 메서드가 없다. 생성자에 접근 제어자가 없어서(package-private) 같은 패키지에 둔 헬퍼 HttpApiResponses만 success가 true인 봉투를 만들 수 있다. Controller는 그 헬퍼의 ok·created·accepted로 성공 응답을 돌려준다 — 왜 성공 쪽만 헬퍼로 옮겼는지는 성공 응답 상태 표준 원칙 13에 있다. 여기 남은 정적 메서드 error()와 validationError()는 실패용이다. success를 false로 두고 ErrorCode에서 상태·코드·메시지를 꺼내 채운다.
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);
URI location = URI.create("/orders/" + data.getOrderId());
return HttpApiResponses.created(location, data);
}
}
한 줄씩 보면, OrderCommandUseCase는(UseCase 참고) “주문을 만든다”는 일 하나를 나타내는 인터페이스이고, Controller는 이 인터페이스만 보고 실제 처리는 그 뒤의 Service에 맡긴다. ResponseEntity<...>는(ResponseEntity 참고) 응답 본문과 상태 번호·헤더를 함께 담는 상자다. 이 메서드 안을 계층마다 다른 타입이 차례로 지나간다 — CreateOrderRequest(Controller 입력) → toCommand()로 만든 Command(Service 입력) → ServiceResult<OrderDomain>(Service 출력) → OrderCreatedResponse(Controller 출력). 마지막 줄의 HttpApiResponses.created(location, data)는 그 응답을 봉투에 담고, 상태 번호 201과 Location 헤더(새로 만든 주문의 주소)까지 채운 상자를 돌려준다.
HttpApiResponse도 Controller 계층의 본문 타입이다. 실제 HTTP 상태와 헤더를 채운 ResponseEntity는 Controller가 손으로 만들지 않고 헬퍼 HttpApiResponses가 만들어 돌려준다. 실패 응답은 @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; }
}
한 줄씩 보면, 필드가 전부 final이라 한 번 만들어지면 값이 바뀌지 않는다(불변 객체 참고). 생성자는 private이고 밖에서는 of()(정적 팩토리 메서드, 즉 new 대신 이 메서드를 거쳐야만 객체를 만들 수 있게 막아 둔 것)로만 객체를 만들 수 있다. Objects.requireNonNull(productName, "productName")은 productName이 null이면 그 자리에서 바로 NullPointerException을 던지는 코드다 — 괄호 안 두 번째 글자열("productName")은 예외 메시지에 “어느 필드가 null이었는지”를 남기기 위한 이름표다. getTotalPrice()는 저장된 값이 아니라 unitPrice와 quantity를 그 자리에서 곱해 계산한 값을 돌려준다.
Domain이 어느 Entity/MapperResult도 import하지 않는다는 점이 이 구조 전체의 전제다. 여기서 규칙이 깨지면 밑에서 아무리 Mapper를 잘 짜도 의미가 없다.
4. Service 출력: ServiceResult / ServiceResults / PagingResult
반증: 25행을 Page 로 읽을 때 목록 조회마다 COUNT 가 나가는가 — Boot 3.5.16 (가)size50-첫페이지 COUNT없음/문장1/행25 (나)size10-마지막페이지 COUNT없음/문장1/행5 (다)size10-중간페이지 COUNT나감/문장2/행10 · Boot 4.1.1 (가)size50-첫페이지 COUNT없음/문장1/행25 (나)size10-마지막페이지 COUNT없음/문장1/행5 (다)size10-중간페이지 COUNT나감/문장2/행10 — 증명
이 반증 줄의 말을 풀면, 목록을 25행씩 볼 때 “첫 페이지”나 “마지막 페이지”처럼 전체 중 어디 있는지 이미 계산으로 알 수 있는 경우에는 전체 개수를 구하는 COUNT 쿼리를 DB에 보내지 않고, “중간 페이지”처럼 그 계산만으로는 전체 개수를 알 수 없을 때만 COUNT 쿼리가 추가로 나간다는 뜻이다.
Service의 반환 타입은 매번 새 클래스를 만들지 않고, 공통 제네릭 래퍼 세 가지로 통일한다. 기본값은 항상 T = Domain이고, 응답을 가공하거나 여러 Domain을 조합해야 하는 실제 이유가 생겼을 때만 예외적으로 다른 타입을 넣는다.
세 개 중 하나는 이름이 다르다. PagingResult에만 Service 접두사가 없는데, 실수가 아니라 경계를 표시한 것이다. ServiceResult와 ServiceResults는 Service만 만들고 Service만 반환한다. 반면 PagingResult는 domain의 Repository 인터페이스가 반환하고 infra의 구현체가 만든다 — 페이징 메타는 조회 그 자체에서만 나오기 때문에 Repository가 그걸 실어 올릴 수밖에 없다. totalCount가 COUNT 쿼리에서 나오는 것이 그 사례다. 다만 「매 조회마다 COUNT를 친다」는 것은 사실이 아니었다 — 첫 페이지가 통째로 들어오거나 마지막 페이지면 Spring Data가 COUNT를 생략하고 총계를 offset과 받은 행 수로 복원한다(두 Boot 버전 동일). 중간 페이지에서만 나간다. 페이징만 필연적으로 계층을 넘는 것이고, 그래서 이름에서 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;
}
}
map()의 content.stream().map(mapper).toList()는(스트림 참고) content 목록의 원소 하나하나에 mapper(변환 함수)를 적용해 새 목록을 만든다는 뜻이다. hasNext()의 (page + 1) * size < totalCount는 “다음 페이지 번호(page + 1)까지 채웠을 때의 개수가 전체 개수(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());
return HttpApiResponses.ok(result.map(OrderResponse::from));
}
@Valid는(Bean Validation 참고) OrderSearchRequest에 붙은 검증 규칙을 요청이 들어오자마자 검사하라는 표시이고, @ModelAttribute는 쿼리 파라미터(?page=1&size=10처럼 주소에 붙는 값)를 OrderSearchRequest 객체의 필드에 자동으로 채워 넣으라는 표시다. result.map(OrderResponse::from)의 OrderResponse::from은 메서드 참조다 — order -> OrderResponse.from(order)라는 람다를 짧게 줄여 쓴 것과 같은 뜻이고, PagingResult.map()이 목록의 원소 하나하나에 이 변환을 적용한다.
즉 페이징 응답 본문의 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);
}
Optional은 “값이 있을 수도, 없을 수도 있다”를 타입으로 드러내는 상자다. findById가 그냥 OrderDomain을 돌려주면 그 주문이 없을 때 null이 오는지 코드만 보고는 알 수 없는데, Optional<OrderDomain>으로 감싸면 호출하는 쪽이 “없을 수도 있구나”를 바로 알고 값이 없을 때 할 일을 적게 된다. 그 “할 일”을 적는 자리가 .orElseThrow(...)다 — 값이 있으면 그 값을 꺼내고, 없으면 괄호 안에 적은 예외를 던진다.
5. JPA: Entity + EntityMapper
Entity는 패키지 밖으로 나가지 않는다. 그리고 Entity는 스스로 Domain으로 변환하는 메서드(toDomain())를 갖지 않는다. 의존 방향 때문은 아니다 — infra의 Entity가 domain의 Domain을 import하는 것은 패키지 구조 표준 원칙 2가 허용한 방향(infra → domain)이다. 변환을 Entity에 두든 별도 EntityMapper에 두든 동작은 같다. 그래도 EntityMapper에 두는 것은 통일하려는 약속이고, 아래 EntityMapper 코드를 본 뒤에 다룬다.
이 “패키지 밖으로 나가지 않는다”는 패키지 구조 표준에서 package-private으로 강제한다. 규율이 아니라 컴파일 에러로 막힌다.
@Entity
@Table(name = "orders")
class OrderEntity { // public이 아니다
@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; }
}
각 필드 위의 @Column(nullable = false)는 이 컬럼에 NOT NULL 제약을 건다는 뜻이다 — 이 값을 비운 채로는 DB가 행 저장을 거절한다.
protected OrderEntity() {}는 매개변수가 없는 생성자다. 이 글의 create(...) 같은 의미 있는 이름의 정적 메서드로만 OrderEntity를 만들게 하면서, 동시에 JPA가 요구하는 조건도 맞춰야 한다 — JPA는 DB에서 값을 읽어와 객체로 만들 때 이 매개변수 없는 생성자를 리플렉션으로 직접 호출해 빈 객체를 만든 뒤 필드를 채운다. private으로 막으면 JPA가 이 생성자를 못 써서 에러가 나므로, 외부에는 안 보이되 JPA는 쓸 수 있는 protected로 남겨 둔다.
Entity ↔ Domain 변환은 별도 EntityMapper가 맡는다.
@Component
class OrderEntityMapper { // public이 아니다
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이 모르는 필드는 아예 언급하지 않는다
}
}
applyTo가 부르는 entity.changeQuantity(...)와 entity.changeUnitPrice(...)는 OrderEntity에 getQuantity()·getUnitPrice()와 나란히 두는 수정용 메서드다. 위 OrderEntity 코드에는 조회용 getter만 적었지만, 기존 값을 바꿔야 하는 이 경로를 위해 각 필드마다 값을 바꿔 쓰는(set) 메서드를 하나씩 더 둔다. changeQuantity는 quantity 필드를, changeUnitPrice는 unitPrice 필드를 넘겨받은 값으로 바꿔 쓴다.
Mapper가 셋을 갖는 이유는 저장 경로 때문이다. toEntity는 id 없이 새 Entity를 만드는 신규 INSERT용이고, 기존 수정은 영속 Entity를 꺼내 applyTo로 덮는다. id를 가진 분리(detached) Entity — id는 있지만 JPA가 추적하지 않는 Entity. DB에서 꺼낸 뒤 트랜잭션이 끝났거나, id를 채워 new로 새로 만든 것이 그렇다 — 를 save()에 넘기면 merge가 일어난다. merge는 넘긴 객체를 그대로 쓰지 않고 같은 ID를 가진 영속 객체를 찾아와 분리 객체의 필드를 전부 복사한다. 그래서 분리 객체에서 비어 있던(= Domain이 모르는) 필드는 null 그대로 복사되어 DB 값이 null로 덮인다. Domain 영속화 표준에서 다룬다.
약속: 반대로 정해도 동작은 같다. 통일이 목적이다.
변환을 Entity 안에 두든 EntityMapper에 두든 동작은 같다. 위 세 메서드를 OrderEntity 안으로 옮겨도 하는 일은 그대로다. 그래도 EntityMapper로 정한 이유는 하나다 — 변환하는 자리를 한 곳으로 정해 두면 모든 도메인에서 변환 코드를 같은 이름의 같은 클래스(XxxEntityMapper)에서 찾는다. 그래서 Entity에 toDomain()을 두지 않는다.
applyTo가 createdAt·version을 일부러 언급하지 않는 것은 Mapper가 하는 일의 설명이지, Mapper를 따로 두는 이유는 아니다. 수정 경로에서 applyTo 대신 id까지 채운 Entity를 새로 만들어 save()에 넘기면 merge가 되어 createdAt이 null로 덮이는데(이 글의 toEntity는 id를 옮기지 않으므로, 수정 경로에 그대로 쓰면 merge가 아니라 새 행이 INSERT된다 — 이것도 틀린 결과다), 이 사고는 Repository 구현체가 두 메서드 중 무엇을 고르느냐에서 생긴다. 그 메서드가 Entity에 있어도 Mapper에 있어도 똑같이 잘못 고를 수 있다. 사고의 경위는 Domain 영속화 표준 원칙 1에 있다. 처음에는 이 자리에 다른 이유를 댔다가 거두었고, 그 경위는 「이 구조에 도달하기까지」에 있다.
OrderRepository의 JPA 구현체는 Spring Data JPA를 감싸고, Mapper로 변환한 뒤에만 값을 돌려준다.
@Repository
@RequiredArgsConstructor
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를 직접 고친다
jpaEntityRepository.flush(); // 예외를 구현체 안에서 받는다
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());
}
}
한 줄씩 보면, save()는 id가 없으면(신규) toEntity로 새 Entity를 만들어 저장하고, id가 있으면(수정) 기존 Entity를 찾아 applyTo로 값만 덮어쓴다. jpaEntityRepository.flush()는(flush 참고) 이 변경을 그 자리에서 바로 DB로 내보내 예외가 있으면 이 메서드 안에서 바로 받게 한다. findAll(int page, int size)의 PageRequest.of(page, size)와 Page<OrderEntity>는(Page·PageRequest 참고) “몇 번째 페이지를 몇 개씩” 조회할지 조건을 만들고 그 결과를 돌려받는 Spring Data의 타입이다.
Entity를 그대로 쓰지 못하는 조인 조회는 Entity가 아니라 별도 XxxProjection으로 이름을 분리한다 (예: OrderSummaryProjection). 이건 JPA 네이티브 쿼리 전용 이름이고 클래스가 아니라 인터페이스로 선언한다 — 이유는 패키지 구조 표준에서 다룬다. MyBatis로 조인하면 결과 타입은 XxxMapperResult다.
6. MyBatis: MapperParam / MapperResult + Mapper
반증: setter 없는 타입으로 같은 조회를 받을 수 있는가 — Boot 3.5.16 MyBatis 3.5.17 (대조군)기본생성자+setter 채워짐(1,alpha,700) (가)final필드+전인자생성자만 채워짐(1,alpha,700) (나)기본생성자+setter없음 채워짐(1,alpha,700) — 증명
실측(supported): SELECT 순서만 id,code,name 으로 바꾸면 생성자가 무엇을 받는가 — MyBatis 3.5.17 (가)class·final 생성자(1,C-7,alpha) getter(1,alpha,C-7) · (나)record 생성자(1,C-7,alpha) getter(1,C-7,alpha) · (다)이름기준설정=true 생성자(1,alpha,C-7) getter(1,alpha,C-7) · (라)private기본생성자 생성자(noarg) getter(1,alpha,C-7) — 증명
두 반증 줄의 (1,alpha,700)이나 (1,C-7,alpha) 같은 괄호는 테스트에 넣은 값 하나가 실제로 어디에 채워졌는지를 순서대로(“id 값, code 값, name 값”) 적은 표기다. 예를 들어 생성자(1,C-7,alpha) getter(1,alpha,C-7)는 “생성자에는 id, code, name 순서로 값이 들어왔는데, getter로 꺼내 보면 id, name, code 순서로 나온다”는 뜻이다. 즉 값이 엉뚱한 자리에 들어갔다는 걸 보여주는 표기다.
MyBatis도 JPA와 동일한 규칙을 따른다. 입력과 출력의 이름을 명시적으로 분리하고, MapperResult도 패키지 밖으로 나가지 않는다.
@Getter
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가 채우므로 private 기본 생성자만 예외적으로 허용한다. setter는 두지 않는다
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class OrderMapperResult {
private Long id;
private String productName;
private int quantity;
private BigDecimal unitPrice;
}
@NoArgsConstructor(access = AccessLevel.PRIVATE)는(@NoArgsConstructor 참고) 아무 값도 안 받는 생성자를 만들되 클래스 밖에서는 그 생성자를 못 쓰게 막는다는 뜻이다. MyBatis는 내부에서 이 생성자를 쓸 수 있어 동작하지만, 우리 코드에서 new OrderMapperResult()처럼 직접 쓰는 것은 막힌다.
MapperParam은 우리 코드가 만들므로 DTO 생성 표준을 그대로 따르고, MapperResult만 MyBatis가 채우기 때문에 예외가 된다. 이 예외가 허용되는 이유는 MapperResult가 패키지 밖으로 나가지 않아 수명이 짧기 때문이다.
단 「MyBatis라서 어쩔 수 없다」는 부분은 사실이 아니었다. 실측에서 final 필드에 전 인자 생성자만 둔 타입도 설정 없이 채워졌다. 하지만 그 생성자 매핑은 컬럼 이름이 아니라 순서를 봐서, SELECT 순서가 바뀌면 같은 타입 값이 예외 없이 뒤바뀐다. 기본 생성자를 두면 MyBatis가 이름으로 필드를 찾아 setter 없이 채운다. 그래서 예외를 없애지 않고 setter만 뺐다. 안 비교는 DTO 생성 표준 원칙 6에 있다.
<select id="findById" parameterType="OrderMapperParam" resultType="OrderMapperResult">
SELECT id, product_name AS productName, quantity, unit_price AS unitPrice
FROM orders
WHERE id = #{id}
</select>
이 XML은 findById라는 이름의 SQL을 정의한다. parameterType은 입력으로 받는 타입, resultType은 결과를 담을 타입이다. SQL 안의 AS productName은 DB 컬럼 이름(product_name)을 자바 필드 이름(productName)에 맞게 바꿔 주는 별칭이고, #{id}는 OrderMapperParam의 id 값을 그 자리에 끼워 넣으라는 표시다.
@Component
class OrderMyBatisMapper {
public OrderDomain toDomain(OrderMapperResult result) {
return OrderDomain.of(
result.getId(),
result.getProductName(),
result.getQuantity(),
result.getUnitPrice()
);
}
}
@Repository
@RequiredArgsConstructor
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에 둬도 같다. 변환 코드를 모든 도메인에서 같은 이름의 같은 클래스에서 찾게 한다 |
MapperResult ↔ Domain |
별도 Mapper | 같은 약속 — JPA든 MyBatis든 변환은 별도 Mapper 클래스에 있다 |
Request·Response가 자기 변환을 하는 것은 api가 application 타입을 import해도 되는 방향이라서다 — 이건 그대로 맞다. Entity와 MapperResult는 방향으로 갈리지 않는다. 둘이 Domain을 import하는 것도 패키지 구조 표준 원칙 2가 허용한 infra → domain 방향이라, 방향만 보면 자기 변환을 해도 된다. 그리고 변환을 자기 안에 두든 별도 Mapper에 두든 동작도 같다. 그래서 별도 Mapper에 두는 것은 위 5. JPA에서 밝힌 대로 약속이다 — 변환하는 자리를 한 곳으로 정해 모든 도메인에서 같은 이름의 같은 클래스에서 찾게 한다. MapperResult도 같은 약속으로 묶는다. 처음에는 이 자리를 「방향이 뒤집힌다」로, 다음에는 「저장 규칙과 조립을 한 곳이 맡는다」로 설명했는데 둘 다 Entity와 Mapper를 가르지 못했다. 바뀐 경위는 「이 구조에 도달하기까지」에 있다.
지역변수에 var를 쓰지 않아 계층마다 다른 타입이 코드에 보이게 한다
이 글은 계층마다 타입 이름을 갈랐다. 그런데 그 이름이 코드를 읽는 화면에서 사라지는 길이 하나 있다. 자바 10부터 지역변수를 var로 선언하면 컴파일러가 오른쪽 값을 보고 타입을 알아서 정한다.
// 이렇게 하지 않는다 — result가 Service 출력인지 이미 바꾼 Response인지 이 줄만으로는 모른다
var result = orderQueryUseCase.findOrder(query);
// 이렇게 한다 — 어느 계층의 타입을 들고 있는지 줄에 적혀 있다
ServiceResult<OrderDomain> result = orderQueryUseCase.findOrder(query);
한 줄씩 따라가기. 두 줄은 컴파일하면 똑같이 동작한다. 차이는 읽는 사람에게만 있다. 위 줄은 findOrder가 무엇을 돌려주는지 메서드 선언을 찾아가야 알 수 있고, 아래 줄은 ServiceResult<OrderDomain>이라고 적혀 있어 이 값이 Service가 돌려준 출력이고 아직 OrderResponse로 바뀌기 전이라는 것이 바로 보인다.
그래서 var는 자바 코드 전체에서 쓰지 않는다. 테스트 코드도 같다(2026-10-21). 이 줄을 정하게 된 경위와 이유 셋(주니어 독자, 타입 힌트가 없는 PR 화면, 예외를 두면 규칙만 는다)은 HTTP 성공 응답 표준 원칙 14에 있다. 규칙 줄은 이 글의 규칙 파일(에이전트가 코드를 쓸 때 읽는 coding-standards/.claude/rules/layered-dto-naming-standard.md)에 두었다. 이 파일이 모든 자바 파일에 걸리고, var가 가리는 것이 바로 이 글이 갈라 둔 계층별 타입이라서다.
대가는 기계가 이 줄을 지켜 주지 않는다는 것이다. var는 컴파일 결과물에 흔적이 남지 않아 ArchUnit으로 못 잡고, 소스를 읽는 검사 도구는 아직 정하지 않았다. 당분간 규칙 문장과 리뷰로 지킨다.
판단 기준 정리
| 상황 | 타입 | 예시 |
|---|---|---|
| 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는 안쪽 계층이라 스스로 변환하면 안 된다. 이 기준은 뒤에 틀린 것으로 드러났다(아래 2026-10-20 문단).
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처럼 봉투가 자기 자신을 조립하게(HttpApiResponse.ok(...)) 두었는데, 2026-10-21에 성공 봉투를 만드는 일은 헬퍼 HttpApiResponses로 옮겼다(성공 응답 상태 표준 원칙 13).
2026-10-20에 Entity가 자기 변환을 하지 않는 이유를 바꿨다. 결론은 그대로다. 위 「누가 변환하는가」 문단의 첫 예시는 Entity가 toResult()로 Service 타입을 import하는 경우였다. 그건 infra → application이다. 패키지 구조 표준이 적은 의존 방향(api → application → domain, infra → domain)에는 없는 방향이라 그 예시에서 「방향에 어긋난다」는 맞았다. 다만 그 표준의 ArchUnit 규칙이 막는 것은 api → infra와 domain → api/application/infra뿐이라, infra → application이 규칙으로 막혀 있지는 않다. 그런데 변환 대상을 Domain으로 바꾸면서 그 논거를 그대로 가져왔다. infra → domain은 패키지 구조 표준 원칙 2가 「핵심」이라고 한 허용 방향이다. 두 글이 「방향」을 다른 기준으로 매기고 있었다 — 이 글은 「누가 변환하는가」 문단에서 import를 말하면서도, 계층의 안팎은 실행 중 호출 순서(Service → Repository → Entity, 뒤에 불릴수록 안쪽)로 매겼다. 그래서 Entity가 「안쪽」이 됐다. 패키지 구조 글은 방향을 처음부터 끝까지 import 방향(컴파일 의존)으로 매기고, 그 기준에서는 infra가 domain을 import하는 것이 허용 방향이다. 이 저장소의 공식 뜻은 후자다. 게다가 그 논거로는 Entity와 Mapper를 가를 수도 없었다 — EntityMapper도 같은 infra 안에서 Domain을 안다. 그래서 「Mapper를 따로 두는 결론까지 틀렸나」를 다시 따졌다.
안 A — 결론(변환은 EntityMapper가 하고 Entity에 toDomain()을 두지 않는다)은 두고, 이유만 둘로 바꾼다. 새 이유는 Domain이 모르는 컬럼을 건드리지 않는 저장을 한 곳이 맡는 것, 루트와 자식 Entity를 Domain 하나로 조립하는 일을 한 곳이 맡는 것이다. 얻는 것은 코드를 하나도 옮기지 않고 틀린 논거만 걷어 낸다는 것이다.
안 B — Entity의 자기 변환(toDomain())을 허용하고 Mapper 의무를 푼다. 방향 규칙과는 맞고, Entity마다 붙던 Mapper 파일이 하나씩 준다. 버린 이유는 applyTo 같은 저장 규칙이 Entity마다 흩어진다는 것이다. 그러면 Domain이 모르는 컬럼을 덮는 사고를 막는 자리가 사라지고, 한 Entity의 실수가 데이터를 조용히 지운다.
안 C — 「방향」을 import가 아니라 「무엇이 핵심인가」로 다시 정의해 원래 문장을 살린다. 「Domain이 핵심이니 Entity는 Domain을 몰라야 한다」로 읽으면 이 글의 문장을 하나도 안 고쳐도 된다. 버린 이유는 그 정의대로면 Domain의 enum을 필드로 쓰는 Entity가 위반이 된다는 것이다. 그리고 이 정의는 Entity에만 거는 말이라, 같은 infra 안에서 Domain을 아는 EntityMapper는 막을 근거도 허용할 근거도 주지 않는다.
골랐다 — 안 A. 안 B가 잃는 저장 규칙의 자리를 지키고, 안 C처럼 이미 쓰는 코드를 위반으로 만들지 않는다. 대가는 Entity마다 Mapper 파일이 하나씩 붙는 것인데, 이건 원래도 내던 값이다.
같은 날 이 두 이유도 Entity와 Mapper를 가르지 못한다는 것이 드러났다. 주니어 독자 검토를 두 번 돌렸는데 두 번 다 같은 곳을 짚었다. 첫째, createdAt이 null이 되는 사고는 Repository 구현체가 수정 경로에서 toEntity를 고르느냐 applyTo를 고르느냐에서 생긴다. 그 메서드가 Entity에 있든 Mapper에 있든 똑같이 잘못 고를 수 있다. 안 B를 버린 이유도 이것이었으니 함께 무너졌다. 둘째, 루트 Entity(애그리거트 루트, 예컨대 주문)는 @OneToMany(하나가 여럿을 거느리는 관계를 JPA에 알리는 애노테이션)로 자식 Entity(예컨대 주문 항목)를 들고 있어서 Entity 혼자서도 Domain 하나로 조립할 수 있다. 결국 변환을 Entity에 두든 Mapper에 두든 동작은 같다. 그래서 다시 골랐다.
안 D — 두 이유를 그대로 둔다. 방금 고친 글을 또 고치지 않아도 된다. 버린 이유는 이유가 결론을 받치지 못하는 글이 남는다는 것이다. 6개월 뒤에 「Entity에 둬도 똑같지 않나」라고 물으면 그 글은 답하지 못한다.
안 B를 다시 — Entity의 자기 변환을 허용한다. 동작이 같다면 Mapper 파일이 하나씩 주는 쪽을 다시 볼 만했다. 버린 이유는 한 프로젝트 안에서 변환 자리가 두 가지로 섞인다는 것이다. 이 표준을 적용하는 실제 프로젝트의 socialprovider는 Entity 3개가 이미 toDomain()을 갖고 있다. 허용하면 이 섞임이 규칙이 된다.
골랐다 — 결론은 두고 「약속」으로 표시한다(2026-10-20). 동작이 같으니 동작에서 이유를 찾지 않고, 5. JPA에서 Mapper를 따로 두는 이유를 적는 자리(EntityMapper 코드와 저장 경로 설명 바로 아래) 첫 줄에 「> 약속: 반대로 정해도 동작은 같다. 통일이 목적이다」를 단다. 이유는 통일 하나다 — 변환하는 자리를 한 곳으로 정해 두면 모든 도메인에서 변환 코드를 같은 이름의 같은 클래스에서 찾는다. 안 D가 답하지 못하는 「Entity에 둬도 똑같지 않나」에 「똑같다, 그래서 약속이다」로 답하고, 안 B처럼 변환 자리가 둘로 섞이지 않는다. MapperResult도 같은 약속으로 묶었다. 대가는 Entity마다 Mapper 파일이 하나씩 붙는 것과, socialprovider의 toDomain() 셋을 Mapper로 옮겨야 한다는 것이다.
2026-10-21에 var 금지 줄이 이 글로 왔다. Controller 메서드 몸통을 지역변수로 풀어 쓰기로 정하면서(HTTP 성공 응답 표준 원칙 14) 그 지역변수에 var를 쓸지를 먼저 정해야 했고, 자바 전체에서 쓰지 않기로 했다. 그 줄은 규칙 파일(에이전트가 코드를 쓸 때 읽는 coding-standards/.claude/rules/ 아래 파일)에 들어가야 한다. 이 글의 규칙 파일은 자바 파일 전체(**/*.java)에 걸리고, var가 숨기는 것이 바로 이 글이 계층마다 갈라 둔 타입 이름이라 여기에 두었다.
정리
- 계층이 다르면 타입도 다르다. 하나의 DTO를 Controller부터 Entity까지 관통시키지 않는다
- 그 타입은 코드에 적는다. 지역변수에
var를 쓰지 않는다. 테스트 코드도 같다 - Domain은 영속성 기술을 모른다. JPA/MyBatis 어느 쪽으로 구현이 바뀌어도 Service는 그대로다
- Service 출력은
ServiceResult/ServiceResults/PagingResult세 가지로 통일한다. 가공이 필요할 때만 예외를 만든다 - 단
XxxProvider의 반환은 감싸지 않는다. 그 봉투는api경계용이고 도메인 간 호출은 그 경계가 아니다 - Entity와 MapperResult는 자기 자신을 변환하지 않는다. 변환은 Mapper 한 곳이 맡는다. 동작은 Entity에 둬도 같다. 모든 도메인에서 변환 코드를 같은 이름의 같은 클래스에서 찾게 하려는 약속이다
- 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 생성하기에서 정리한 원칙과 같은 선상에 있다 — 객체는 생성 시점에 완전한 상태를 가지고, 자기 책임 밖의 일은 하지 않는다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기