返回 Skill 列表
extension
分类: 开发与工程无需 API Key

controller-expert

REST Controller 专家。Thin Controller 模式,CQRS Command/Query Controller 分离,ResponseEntity<ApiResponse<T>> 包装,@Valid 验证是必须的。禁止使用 DELETE(应使用 PATCH)。必须使用 TestRestTemplate(禁止使用 MockMvc)。依赖 UseCase。Mapper DI。

person作者: jakexiaohubgithub

Controller Expert (REST API 전문가)

목적 (Purpose)

REST API Layer에서 HTTP 요청/응답을 처리하는 컴포넌트를 규칙에 맞게 생성합니다. Thin Controller 패턴과 CQRS 분리 원칙을 준수합니다.

활성화 조건

  • /impl rest-api {feature} 명령 실행 시
  • /plan 실행 후 REST API Layer 작업 시
  • controller, rest, dto, request, response, api 키워드 언급 시

산출물 (Output)

| 컴포넌트 | 파일명 패턴 | 위치 | |----------|-------------|------| | CommandController | {Bc}CommandController.java | adapter-in/rest-api/{bc}/controller/ | | QueryController | {Bc}QueryController.java | adapter-in/rest-api/{bc}/controller/ | | Command DTO | {Action}{Bc}ApiRequest.java | adapter-in/rest-api/{bc}/dto/command/ | | Query DTO | {Bc}SearchApiRequest.java | adapter-in/rest-api/{bc}/dto/query/ | | Response DTO | {Bc}ApiResponse.java | adapter-in/rest-api/{bc}/dto/response/ | | ApiMapper | {Bc}ApiMapper.java | adapter-in/rest-api/{bc}/mapper/ | | ErrorMapper | {Bc}ApiErrorMapper.java | adapter-in/rest-api/{bc}/error/ |

완료 기준 (Acceptance Criteria)

  • [ ] CQRS 분리: CommandController (POST, PATCH) / QueryController (GET)
  • [ ] ResponseEntity<ApiResponse<T>> 래핑 (두 가지 모두 필수)
  • [ ] @Valid 검증: 모든 Request DTO에 필수
  • [ ] UseCase 직접 의존 (Service 금지)
  • [ ] Mapper DI: @Component Bean (Static 금지)
  • [ ] DELETE 금지: 소프트 삭제는 PATCH /{id}/delete
  • [ ] Lombok 금지
  • [ ] DTO는 Java 21 Record
  • [ ] ArchUnit 테스트 통과

Thin Controller 패턴

HTTP Request
    ↓
@Valid 검증 (Request DTO)
    ↓
Mapper 변환 (API DTO → UseCase DTO)
    ↓
UseCase 실행
    ↓
Mapper 변환 (UseCase DTO → API DTO)
    ↓
ResponseEntity<ApiResponse<T>> 래핑
    ↓
HTTP Response

핵심: Controller는 HTTP 요청/응답 처리만. 비즈니스 로직 절대 금지.


코드 템플릿

1. CommandController (POST, PATCH)

package com.ryuqq.adapter.in.rest.order.controller;

import com.ryuqq.adapter.in.rest.common.dto.ApiResponse;
import com.ryuqq.adapter.in.rest.order.dto.command.CreateOrderApiRequest;
import com.ryuqq.adapter.in.rest.order.dto.command.CancelOrderApiRequest;
import com.ryuqq.adapter.in.rest.order.dto.response.OrderApiResponse;
import com.ryuqq.adapter.in.rest.order.mapper.OrderApiMapper;
import com.ryuqq.application.order.port.in.CreateOrderUseCase;
import com.ryuqq.application.order.port.in.CancelOrderUseCase;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

/**
 * Order Command Controller
 *
 * <p>Order 도메인의 상태 변경 API를 제공합니다.</p>
 *
 * <p>제공하는 API:</p>
 * <ul>
 *   <li>POST /api/v1/orders - 주문 생성</li>
 *   <li>PATCH /api/v1/orders/{id}/cancel - 주문 취소</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@RestController
@RequestMapping("${api.endpoints.base-v1}/orders")
@Validated
public class OrderCommandController {

    private final CreateOrderUseCase createOrderUseCase;
    private final CancelOrderUseCase cancelOrderUseCase;
    private final OrderApiMapper orderApiMapper;

    public OrderCommandController(
            CreateOrderUseCase createOrderUseCase,
            CancelOrderUseCase cancelOrderUseCase,
            OrderApiMapper orderApiMapper) {
        this.createOrderUseCase = createOrderUseCase;
        this.cancelOrderUseCase = cancelOrderUseCase;
        this.orderApiMapper = orderApiMapper;
    }

    /**
     * 주문 생성
     *
     * @param request 주문 생성 요청 DTO
     * @return 주문 생성 결과 (201 Created)
     */
    @PostMapping
    public ResponseEntity<ApiResponse<OrderApiResponse>> createOrder(
            @RequestBody @Valid CreateOrderApiRequest request) {

        // 1. API Request → UseCase Command 변환 (Mapper)
        var command = orderApiMapper.toCommand(request);

        // 2. UseCase 실행 (비즈니스 로직)
        var useCaseResponse = createOrderUseCase.execute(command);

        // 3. UseCase Response → API Response 변환 (Mapper)
        var apiResponse = orderApiMapper.toApiResponse(useCaseResponse);

        // 4. ResponseEntity<ApiResponse<T>> 래핑
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(ApiResponse.ofSuccess(apiResponse));
    }

    /**
     * 주문 취소 (소프트 삭제 패턴)
     *
     * @param id 주문 ID
     * @param request 취소 요청 DTO
     * @return 취소 결과 (200 OK)
     */
    @PatchMapping("/{id}/cancel")
    public ResponseEntity<ApiResponse<Void>> cancelOrder(
            @PathVariable Long id,
            @RequestBody @Valid CancelOrderApiRequest request) {

        var command = orderApiMapper.toCancelCommand(id, request);
        cancelOrderUseCase.execute(command);

        return ResponseEntity.ok(ApiResponse.ofSuccess());
    }
}

핵심 규칙:

  • POST: 201 Created 반환
  • PATCH: 200 OK 반환
  • DELETE 금지 → PATCH /{id}/delete 또는 PATCH /{id}/cancel
  • @Validated 클래스 레벨 (PathVariable/RequestParam 검증용)
  • @Valid 파라미터 레벨 (RequestBody 검증용)

2. QueryController (GET)

package com.ryuqq.adapter.in.rest.order.controller;

import com.ryuqq.adapter.in.rest.common.dto.ApiResponse;
import com.ryuqq.adapter.in.rest.common.dto.SliceApiResponse;
import com.ryuqq.adapter.in.rest.order.dto.query.OrderSearchApiRequest;
import com.ryuqq.adapter.in.rest.order.dto.response.OrderApiResponse;
import com.ryuqq.adapter.in.rest.order.dto.response.OrderDetailApiResponse;
import com.ryuqq.adapter.in.rest.order.mapper.OrderApiMapper;
import com.ryuqq.application.order.port.in.GetOrderQueryService;
import com.ryuqq.application.order.port.in.SearchOrderQueryService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

/**
 * Order Query Controller
 *
 * <p>Order 도메인의 조회 API를 제공합니다.</p>
 *
 * <p>제공하는 API:</p>
 * <ul>
 *   <li>GET /api/v1/orders/{id} - 주문 단건 조회</li>
 *   <li>GET /api/v1/orders - 주문 검색</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@RestController
@RequestMapping("${api.endpoints.base-v1}/orders")
@Validated
public class OrderQueryController {

    private final GetOrderQueryService getOrderQueryService;
    private final SearchOrderQueryService searchOrderQueryService;
    private final OrderApiMapper orderApiMapper;

    public OrderQueryController(
            GetOrderQueryService getOrderQueryService,
            SearchOrderQueryService searchOrderQueryService,
            OrderApiMapper orderApiMapper) {
        this.getOrderQueryService = getOrderQueryService;
        this.searchOrderQueryService = searchOrderQueryService;
        this.orderApiMapper = orderApiMapper;
    }

    /**
     * 주문 단건 조회
     *
     * @param id 주문 ID (양수)
     * @return 주문 상세 정보 (200 OK)
     */
    @GetMapping("/{id}")
    public ResponseEntity<ApiResponse<OrderDetailApiResponse>> getOrder(
            @PathVariable @Positive Long id) {

        // 1. ID → UseCase Query 변환 (Mapper)
        var query = orderApiMapper.toGetQuery(id);

        // 2. UseCase 실행 (조회 로직)
        var useCaseResponse = getOrderQueryService.getById(query);

        // 3. UseCase Response → API Response 변환 (Mapper)
        var apiResponse = orderApiMapper.toDetailApiResponse(useCaseResponse);

        // 4. ResponseEntity<ApiResponse<T>> 래핑
        return ResponseEntity.ok(ApiResponse.ofSuccess(apiResponse));
    }

    /**
     * 주문 검색 (Cursor 기반)
     *
     * @param request 검색 조건
     * @return 주문 검색 결과 (200 OK)
     */
    @GetMapping
    public ResponseEntity<ApiResponse<SliceApiResponse<OrderApiResponse>>> searchOrders(
            @Valid @ModelAttribute OrderSearchApiRequest request) {

        var query = orderApiMapper.toSearchQuery(request);
        var useCaseResponse = searchOrderQueryService.search(query);
        var apiResponse = SliceApiResponse.from(
            useCaseResponse,
            orderApiMapper::toApiResponse
        );

        return ResponseEntity.ok(ApiResponse.ofSuccess(apiResponse));
    }
}

핵심 규칙:

  • GET: 200 OK 반환
  • @ModelAttribute로 Query Parameter 바인딩
  • @Positive로 PathVariable 검증
  • SliceApiResponse (Cursor) vs PageApiResponse (Offset) 선택

3. Command DTO (Request)

package com.ryuqq.adapter.in.rest.order.dto.command;

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;

/**
 * 주문 생성 요청
 *
 * @param customerId 고객 ID
 * @param items 주문 항목 목록
 * @param shippingAddress 배송 주소
 * @author development-team
 * @since 1.0.0
 */
public record CreateOrderApiRequest(
    @NotNull(message = "고객 ID는 필수입니다")
    Long customerId,

    @NotEmpty(message = "주문 항목은 필수입니다")
    @Valid
    List<OrderItemRequest> items,

    @NotNull(message = "배송 주소는 필수입니다")
    @Valid
    AddressRequest shippingAddress
) {
    // Compact Constructor (불변 리스트, 추가 검증)
    public CreateOrderApiRequest {
        if (customerId != null && customerId <= 0) {
            throw new IllegalArgumentException("유효하지 않은 고객 ID: " + customerId);
        }
        items = items == null ? List.of() : List.copyOf(items);
    }

    /**
     * 주문 항목 요청
     */
    public record OrderItemRequest(
        @NotNull(message = "상품 ID는 필수입니다")
        Long productId,

        @NotNull(message = "수량은 필수입니다")
        @Min(value = 1, message = "수량은 1 이상이어야 합니다")
        Integer quantity,

        @NotNull(message = "가격은 필수입니다")
        @Min(value = 0, message = "가격은 0 이상이어야 합니다")
        Long price
    ) {}

    /**
     * 배송 주소 요청
     */
    public record AddressRequest(
        @NotBlank(message = "우편번호는 필수입니다")
        String zipCode,

        @NotBlank(message = "주소는 필수입니다")
        String address,

        String addressDetail
    ) {}
}

Command DTO 네이밍 규칙:

| HTTP 메서드 | 접두사 | 예시 | |-------------|--------|------| | POST | Create, Register, Place | CreateOrderApiRequest | | PATCH | Update, Modify, Cancel | CancelOrderApiRequest |

4. Query DTO (Request)

package com.ryuqq.adapter.in.rest.order.dto.query;

import jakarta.validation.constraints.*;
import java.time.LocalDate;

/**
 * 주문 검색 요청
 *
 * @param customerId 고객 ID (Optional)
 * @param status 주문 상태 (Optional)
 * @param startDate 시작 날짜 (Optional)
 * @param endDate 종료 날짜 (Optional)
 * @param cursor 다음 페이지 커서 (Optional)
 * @param size 페이지 크기
 * @author development-team
 * @since 1.0.0
 */
public record OrderSearchApiRequest(
    // Optional 필드 (null 허용)
    Long customerId,

    @Pattern(regexp = "PLACED|CONFIRMED|SHIPPED|DELIVERED|CANCELLED",
             message = "유효하지 않은 상태입니다")
    String status,

    LocalDate startDate,
    LocalDate endDate,

    // Cursor 기반 페이징
    String cursor,

    // 페이징 필수
    @Min(value = 1, message = "페이지 크기는 1 이상")
    @Max(value = 100, message = "페이지 크기는 100 이하")
    Integer size
) {
    // Compact Constructor (기본값 설정)
    public OrderSearchApiRequest {
        size = size == null ? 20 : size;

        // 날짜 범위 검증
        if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
            throw new IllegalArgumentException("시작 날짜는 종료 날짜보다 이전이어야 합니다");
        }
    }
}

Query DTO 특징:

  • 대부분 필드 Optional (null 허용)
  • 페이징 조건 필수 (무한 조회 방지)
  • Compact Constructor로 기본값 설정

5. Response DTO

package com.ryuqq.adapter.in.rest.order.dto.response;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 주문 응답
 *
 * @param orderId 주문 ID
 * @param customerId 고객 ID
 * @param status 주문 상태
 * @param totalAmount 총 금액
 * @param items 주문 항목 목록
 * @param createdAt 생성 일시
 * @author development-team
 * @since 1.0.0
 */
public record OrderApiResponse(
    Long orderId,
    Long customerId,
    String status,
    Long totalAmount,
    List<OrderItemResponse> items,
    LocalDateTime createdAt
) {
    // Compact Constructor (불변 컬렉션)
    public OrderApiResponse {
        items = items == null ? List.of() : List.copyOf(items);
    }

    /**
     * Application Layer Response → REST API Response
     */
    public static OrderApiResponse from(OrderResponse appResponse) {
        return new OrderApiResponse(
            appResponse.orderId(),
            appResponse.customerId(),
            appResponse.status(),
            appResponse.totalAmount(),
            appResponse.items().stream()
                .map(OrderItemResponse::from)
                .toList(),
            appResponse.createdAt()
        );
    }

    /**
     * 주문 항목 응답
     */
    public record OrderItemResponse(
        Long productId,
        String productName,
        Integer quantity,
        Long price
    ) {
        public static OrderItemResponse from(OrderItemDto dto) {
            return new OrderItemResponse(
                dto.productId(),
                dto.productName(),
                dto.quantity(),
                dto.price()
            );
        }
    }
}

Response DTO 특징:

  • from() 정적 팩토리 메서드로 변환
  • 불변 컬렉션 (List.copyOf)
  • Nested Record로 복잡한 구조 표현

6. ApiMapper (@Component)

package com.ryuqq.adapter.in.rest.order.mapper;

import com.ryuqq.adapter.in.rest.order.dto.command.CreateOrderApiRequest;
import com.ryuqq.adapter.in.rest.order.dto.command.CancelOrderApiRequest;
import com.ryuqq.adapter.in.rest.order.dto.query.OrderSearchApiRequest;
import com.ryuqq.adapter.in.rest.order.dto.response.OrderApiResponse;
import com.ryuqq.adapter.in.rest.order.dto.response.OrderDetailApiResponse;
import com.ryuqq.application.order.dto.command.CreateOrderCommand;
import com.ryuqq.application.order.dto.command.CancelOrderCommand;
import com.ryuqq.application.order.dto.query.GetOrderQuery;
import com.ryuqq.application.order.dto.query.SearchOrdersQuery;
import com.ryuqq.application.order.dto.response.OrderResponse;
import com.ryuqq.application.order.dto.response.OrderDetailResponse;
import org.springframework.stereotype.Component;
import java.util.List;

/**
 * OrderApiMapper - Order REST API ↔ Application Layer 변환
 *
 * <p>REST API Layer와 Application Layer 간의 DTO 변환을 담당합니다.</p>
 *
 * @author development-team
 * @since 1.0.0
 */
@Component
public class OrderApiMapper {

    /**
     * CreateOrderApiRequest → CreateOrderCommand 변환
     */
    public CreateOrderCommand toCommand(CreateOrderApiRequest request) {
        List<CreateOrderCommand.OrderItem> items = request.items().stream()
            .map(item -> CreateOrderCommand.OrderItem.of(
                item.productId(),
                item.quantity(),
                item.price()
            ))
            .toList();

        return CreateOrderCommand.of(
            request.customerId(),
            items,
            toAddressCommand(request.shippingAddress())
        );
    }

    /**
     * CancelOrderApiRequest → CancelOrderCommand 변환
     */
    public CancelOrderCommand toCancelCommand(Long id, CancelOrderApiRequest request) {
        return CancelOrderCommand.of(id, request.reason());
    }

    /**
     * ID → GetOrderQuery 변환
     */
    public GetOrderQuery toGetQuery(Long id) {
        return GetOrderQuery.of(id);
    }

    /**
     * OrderSearchApiRequest → SearchOrdersQuery 변환
     */
    public SearchOrdersQuery toSearchQuery(OrderSearchApiRequest request) {
        boolean isCursor = request.cursor() != null && !request.cursor().isBlank();

        if (isCursor) {
            return SearchOrdersQuery.ofCursor(
                request.customerId(),
                request.status(),
                request.startDate(),
                request.endDate(),
                request.cursor(),
                request.size()
            );
        } else {
            return SearchOrdersQuery.ofOffset(
                request.customerId(),
                request.status(),
                request.startDate(),
                request.endDate(),
                0,  // page default
                request.size()
            );
        }
    }

    /**
     * OrderResponse → OrderApiResponse 변환
     */
    public OrderApiResponse toApiResponse(OrderResponse appResponse) {
        return OrderApiResponse.from(appResponse);
    }

    /**
     * OrderDetailResponse → OrderDetailApiResponse 변환
     */
    public OrderDetailApiResponse toDetailApiResponse(OrderDetailResponse appResponse) {
        return OrderDetailApiResponse.from(appResponse);
    }

    // ========== Private Helper ==========

    private CreateOrderCommand.Address toAddressCommand(
            CreateOrderApiRequest.AddressRequest address) {
        return CreateOrderCommand.Address.of(
            address.zipCode(),
            address.address(),
            address.addressDetail()
        );
    }
}

ApiMapper 규칙:

  • @Component Bean 등록 (Static 금지)
  • 필드 매핑만 수행 (비즈니스 로직 금지)
  • 기본값 설정/검증 금지 (Controller/Bean Validation 책임)

HTTP 상태 코드 규칙

| 메서드 | HTTP 상태 | 용도 | |--------|-----------|------| | POST | 201 Created | 리소스 생성 성공 | | GET | 200 OK | 조회 성공 | | PATCH | 200 OK | 부분 수정 성공 | | PUT | 200 OK | 전체 수정 성공 | | DELETE | ❌ 지원 안 함 | 소프트 삭제는 PATCH로 |


Zero-Tolerance 규칙

✅ MANDATORY (필수)

| 규칙 | 설명 | |------|------| | ResponseEntity<ApiResponse<T>> | 두 가지 래핑 모두 필수 | | @Valid | 모든 Request DTO에 필수 | | @Validated | Controller 클래스 레벨 | | UseCase 의존 | Service 직접 의존 금지 | | Mapper DI | @Component Bean (Static 금지) | | Record DTO | Java 21 Record 사용 | | RESTful URI | 명사 복수형, 동사 금지 |

❌ PROHIBITED (금지)

| 항목 | 이유 | |------|------| | DELETE 메서드 | 소프트 삭제는 PATCH 사용 | | 비즈니스 로직 | UseCase/Domain 책임 | | @Transactional | UseCase 책임 | | try-catch | GlobalExceptionHandler 위임 | | Domain 직접 노출 | Response DTO 사용 | | Lombok | Plain Java 사용 | | Jackson 어노테이션 | @JsonFormat, @JsonProperty 금지 | | MockMvc 테스트 | TestRestTemplate 사용 |


페이징 패턴

Slice vs Page 선택

| 구분 | SliceApiResponse | PageApiResponse | |------|------------------|-----------------| | 사용 사례 | 무한 스크롤 (일반 사용자) | 페이지 번호 (관리자) | | 성능 | 빠름 (COUNT 불필요) | 느림 (COUNT 필수) | | 제공 정보 | hasNext, nextCursor | totalElements, totalPages | | 적합한 UI | 모바일, SNS 피드 | 관리자 테이블 |

// Slice (일반 사용자)
@GetMapping
public ResponseEntity<ApiResponse<SliceApiResponse<OrderApiResponse>>> searchOrders(...) {
    // hasNext, nextCursor 반환
}

// Page (관리자)
@GetMapping("/admin")
public ResponseEntity<ApiResponse<PageApiResponse<OrderApiResponse>>> searchOrdersForAdmin(...) {
    // totalElements, totalPages 반환
}

에러 처리 (RFC 7807)

GlobalExceptionHandler

  • @RestControllerAdvice로 전역 예외 처리
  • application/problem+json Content-Type
  • x-error-code 응답 헤더 추가
  • 로깅 레벨: 5xx → ERROR, 404 → DEBUG, 4xx → WARN

ErrorMapper 패턴

@Component
public class OrderApiErrorMapper implements ErrorMapper {

    @Override
    public boolean supports(DomainException ex) {
        return ex instanceof OrderException;
    }

    @Override
    public MappedError map(DomainException ex, Locale locale) {
        return switch (ex.code()) {
            case "ORDER_NOT_FOUND" -> new MappedError(
                HttpStatus.NOT_FOUND,
                "Not Found",
                "주문을 찾을 수 없습니다",
                URI.create("/errors/order/not-found")
            );
            // ...
        };
    }
}

패키지 구조

adapter-in/rest-api/
├── common/
│   ├── controller/
│   │   └── GlobalExceptionHandler.java
│   ├── dto/
│   │   ├── ApiResponse.java
│   │   ├── SliceApiResponse.java
│   │   └── PageApiResponse.java
│   ├── error/
│   │   └── ErrorMapperRegistry.java
│   └── mapper/
│       └── ErrorMapper.java
│
└── {bc}/
    ├── controller/
    │   ├── {Bc}CommandController.java
    │   └── {Bc}QueryController.java
    ├── dto/
    │   ├── command/
    │   │   └── {Action}{Bc}ApiRequest.java
    │   ├── query/
    │   │   └── {Bc}SearchApiRequest.java
    │   └── response/
    │       └── {Bc}ApiResponse.java
    ├── mapper/
    │   └── {Bc}ApiMapper.java
    └── error/
        └── {Bc}ApiErrorMapper.java

체크리스트 (Output Checklist)

Controller

  • [ ] @RestController, @RequestMapping 어노테이션
  • [ ] @Validated 클래스 레벨
  • [ ] UseCase, Mapper 생성자 주입 (Lombok 없음)
  • [ ] ResponseEntity<ApiResponse<T>> 반환
  • [ ] HTTP 상태 코드 올바르게 설정 (POST → 201)
  • [ ] @Valid 모든 Request DTO에 적용
  • [ ] DELETE 미사용 (PATCH로 대체)
  • [ ] 비즈니스 로직 없음
  • [ ] Javadoc 작성

Command DTO

  • [ ] public record 키워드
  • [ ] {Action}{Bc}ApiRequest 네이밍
  • [ ] Bean Validation 어노테이션
  • [ ] Compact Constructor (불변 리스트, 추가 검증)
  • [ ] Nested Record (복잡한 구조)

Query DTO

  • [ ] public record 키워드
  • [ ] {Bc}SearchApiRequest 네이밍
  • [ ] Optional 필드 (대부분 null 허용)
  • [ ] 페이징 조건 필수 (size)
  • [ ] 기본값 설정 (Compact Constructor)

Response DTO

  • [ ] public record 키워드
  • [ ] {Bc}ApiResponse 네이밍
  • [ ] from() 정적 팩토리 메서드
  • [ ] 불변 컬렉션 (List.copyOf)

ApiMapper

  • [ ] @Component 어노테이션
  • [ ] 생성자 주입 (필요 시)
  • [ ] 필드 매핑만 수행
  • [ ] 비즈니스 로직 없음
  • [ ] Static 메서드 없음

테스트 체크리스트

Controller 통합 테스트

  • [ ] @SpringBootTest(webEnvironment = RANDOM_PORT)
  • [ ] TestRestTemplate 사용 (MockMvc 금지)
  • [ ] @Tag("integration"), @Tag("adapter-rest")
  • [ ] 성공/실패 시나리오 테스트
  • [ ] Validation 실패 테스트

REST Docs 테스트

  • [ ] RestDocsTestSupport 상속
  • [ ] document() 메서드로 Snippet 생성
  • [ ] requestFields(), responseFields() 문서화
  • [ ] pathParameters(), queryParameters() 문서화

참조 문서

  • Controller Guide: docs/coding_convention/01-adapter-in-layer/rest-api/controller/controller-guide.md
  • Command DTO Guide: docs/coding_convention/01-adapter-in-layer/rest-api/dto/command/command-dto-guide.md
  • Query DTO Guide: docs/coding_convention/01-adapter-in-layer/rest-api/dto/query/query-dto-guide.md
  • Response DTO Guide: docs/coding_convention/01-adapter-in-layer/rest-api/dto/response/response-dto-guide.md
  • Mapper Guide: docs/coding_convention/01-adapter-in-layer/rest-api/mapper/mapper-guide.md
  • Error Guide: docs/coding_convention/01-adapter-in-layer/rest-api/error/error-guide.md
  • REST Docs Guide: docs/coding_convention/01-adapter-in-layer/rest-api/controller/controller-test-restdocs-guide.md