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> 공통 봉투로 감싼다. 클라이언트가 성공/실패를 매번 다른 모양으로 파싱하지 않도록, 응답 구조 자체를 하나로 고정하기 위해서다.
@Getter
public class HttpApiResponse<T> {
private static final int SUCCESS_CODE = 200000;
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> success(T data) {
return new HttpApiResponse<>(true, 200, SUCCESS_CODE, "OK", data, List.of());
}
public static <T> HttpApiResponse<T> success(int status, T data) {
return new HttpApiResponse<>(true, status, SUCCESS_CODE, "OK", 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 OrderService orderService;
@PostMapping("/orders")
public HttpApiResponse<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
ServiceResult<OrderDomain> result = orderService.createOrder(request.toCommand());
return HttpApiResponse.success(OrderResponse.from(result));
}
}
HttpApiResponse도 변환 책임 원칙을 그대로 따른다. Controller 계층의 타입이므로 Controller 메서드가 직접 조립해도 되고, 실패 응답은 @ExceptionHandler에서 HttpApiResponse.error(...)를 반환하는 식으로 통일한다.
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 곳곳에서 읽히므로 이름이 호출부까지 따라가야 하기 때문이다. 반면 XxxRequest는 필드명이 곧 JSON 키라서 @Nullable 어노테이션을 쓴다 — 자세한 이유는 계층별 검증 표준에서 다룬다.
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 / ServicePagingResult
Service의 반환 타입은 매번 새 클래스를 만들지 않고, 공통 제네릭 래퍼 세 가지로 통일한다. 기본값은 항상 T = Domain이고, 응답을 가공하거나 여러 Domain을 조합해야 하는 실제 이유가 생겼을 때만 예외적으로 다른 타입을 넣는다.
@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 ServicePagingResult<T> {
private final List<T> data;
private final int page;
private final int size;
private final long totalCount;
private ServicePagingResult(List<T> data, int page, int size, long totalCount) {
this.data = data;
this.page = page;
this.size = size;
this.totalCount = totalCount;
}
public static <T> ServicePagingResult<T> of(List<T> data, int page, int size, long totalCount) {
return new ServicePagingResult<>(data, page, size, totalCount);
}
public boolean hasNext() {
return (long) (page + 1) * size < totalCount;
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
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 ServicePagingResult<OrderDomain> findOrders(int page, int size) {
return orderRepository.findAll(page, size);
}
}
OrderRepository는 Service가 정의하는 인터페이스이고, 반환 타입은 항상 Domain이다. 구현체가 JPA든 MyBatis든 Service는 신경 쓰지 않는다.
public interface OrderRepository {
OrderDomain save(OrderDomain order);
Optional<OrderDomain> findById(Long id);
ServicePagingResult<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()
);
}
}
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) {
OrderEntity saved = jpaEntityRepository.save(mapper.toEntity(order));
return mapper.toDomain(saved);
}
@Override
public Optional<OrderDomain> findById(Long id) {
return jpaEntityRepository.findById(id).map(mapper::toDomain);
}
@Override
public ServicePagingResult<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 ServicePagingResult.of(data, page, size, result.getTotalElements());
}
}
Entity를 그대로 쓰지 못하는 집계/조인 조회는 Entity가 아니라 별도 XxxProjection으로 이름을 분리한다 (예: OrderSummaryProjection).
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는 어느 쪽이 꽂혀 있는지 전혀 알 수 없다.
변환 책임: 누가, 왜
| 변환 | 담당 | 이유 |
|---|---|---|
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 |
| Service 출력 (단건) | ServiceResult<T> |
ServiceResult<OrderDomain> |
| Service 출력 (리스트) | ServiceResults<T> |
ServiceResults<OrderDomain> |
| Service 출력 (페이징) | ServicePagingResult<T> |
ServicePagingResult<OrderDomain> |
| JPA 영속 객체 | XxxEntity |
OrderEntity |
| JPA 집계/조인 조회 | XxxProjection |
OrderSummaryProjection |
| MyBatis 입력 파라미터 | XxxMapperParam |
OrderMapperParam |
| MyBatis 조회 결과 | XxxMapperResult |
OrderMapperResult |
이 구조에 도달하기까지
처음 생각은 단순했다. “계층마다 DTO 이름을 다르게 하면 의존성이 줄어들지 않을까.” Controller는 Request/Response, Service는 Query/Command, JPA는 Entity, MyBatis는 MapperDto. 여기까지는 금방 정했는데, 막상 세부 규칙을 정하려니 질문이 하나씩 따라붙었다.
Service 출력부터 막혔다. 단건이든 리스트든 매번 XxxResult 클래스를 새로 만들면 결국 Entity, Response와 똑같은 클래스가 하나 더 늘어나는 셈이었다. 그래서 제네릭 Result<T>로 감싸는 쪽으로 방향을 잡았고, 이름은 ServiceResult로 확정했다. 리스트와 페이징까지 같은 타입으로 억지로 우겨넣으면 나중에 페이징 메타데이터(totalCount, hasNext)를 넣을 자리가 없어질 게 뻔해서, ServiceResults와 ServicePagingResult로 아예 나눴다.
그다음은 “누가 변환하는가”였다. 처음엔 각 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/ServicePagingResult세 가지로 통일한다. 가공이 필요할 때만 예외를 만든다 - 변환 책임은 항상 바깥쪽 DTO에 있다. Entity와 MapperResult는 절대 자기 자신을 변환하지 않는다
- Repository 인터페이스는 Service가 정의하고
Domain을 반환한다. JPA/MyBatis는 구현체일 뿐이다 - Controller는
XxxResponse를 그대로 반환하지 않는다. 모든 응답은HttpApiResponse<T>로 감싼다
이 표준은 Null-free 객체 설계와 생성 철학, 상속 vs 합성, 팩토리 메소드로 VO 생성하기에서 정리한 원칙과 같은 선상에 있다 — 객체는 생성 시점에 완전한 상태를 가지고, 자기 책임 밖의 일은 하지 않는다.
AI 코드 어시스턴트에 바로 적용하기
위 규칙을 매번 설명하지 않아도 되도록, Claude Code와 GitHub Copilot에 그대로 붙여넣을 수 있는 형태로 정리했다.
Claude Code — .claude/skills/dto-layer-standard/SKILL.md
---
name: dto-layer-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`이다.
- Service 출력은 `ServiceResult<T>`(단건), `ServiceResults<T>`(리스트), `ServicePagingResult<T>`(페이징) 중 하나로 감싼다. 기본값은 `T = Domain`이며, 가공이 필요한 실제 이유가 있을 때만 다른 타입을 쓴다.
- JPA 영속 객체는 `XxxEntity`, 집계/조인 등 복합 조회는 `XxxProjection`으로 만든다.
- MyBatis 입력은 `XxxMapperParam`, 출력은 `XxxMapperResult`로 만든다.
- Controller 메서드의 반환 타입은 항상 `HttpApiResponse<T>`로 감싼다 (`T`는 `XxxResponse`). `XxxResponse`를 그대로 반환하지 않는다.
## 변환 책임
- `Request`/`Response`는 스스로 변환 메서드(`toCommand()`, `from()`)를 가질 수 있다.
- `Entity`, `MapperResult`, `Domain`은 서로를 변환하는 메서드를 갖지 않는다. 변환은 반드시 별도의 `XxxEntityMapper` / MyBatis용 Mapper 클래스가 담당한다.
- `Domain`은 JPA나 MyBatis 관련 타입을 import하지 않는다.
## 경계
- Repository 인터페이스는 Service 쪽에서 정의하고 `Domain`을 반환한다. JPA/MyBatis 구현체는 이 인터페이스 뒤에 숨긴다.
- 위 규칙과 다른 패턴(Entity를 Controller까지 직접 전달, DTO 하나를 여러 계층에서 재사용 등)을 발견하면 그대로 따르지 말고 리팩터링을 제안한다.
GitHub Copilot — .github/instructions/dto-layer-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>`로 감싼다.
- Service의 페이징 반환은 `ServicePagingResult<T>`로 감싼다.
- JPA 엔티티는 `XxxEntity`로 만들고 변환 메서드를 갖지 않는다.
- JPA 집계/조인 조회 결과는 `XxxProjection`으로 만든다.
- MyBatis 입력 파라미터는 `XxxMapperParam`으로 만든다.
- MyBatis 조회 결과는 `XxxMapperResult`로 만든다.
- Entity와 MapperResult를 Domain으로 변환할 때는 별도 Mapper 클래스(`XxxEntityMapper` 등)를 사용한다.
- Repository 인터페이스는 Domain 타입을 반환하도록 선언한다.
- Controller 메서드는 `XxxResponse`를 그대로 반환하지 말고 `HttpApiResponse<T>`로 감싸서 반환한다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기