Claude-skill-registry controller-expert
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/controller-expert" ~/.claude/skills/majiayu000-claude-skill-registry-controller-expert && rm -rf "$T"
manifest:
skills/data/controller-expert/SKILL.mdsource content
Controller Expert (REST API 전문가)
목적 (Purpose)
REST API Layer에서 HTTP 요청/응답을 처리하는 컴포넌트를 규칙에 맞게 생성합니다. Thin Controller 패턴과 CQRS 분리 원칙을 준수합니다.
활성화 조건
명령 실행 시/impl rest-api {feature}
실행 후 REST API Layer 작업 시/plan- controller, rest, dto, request, response, api 키워드 언급 시
산출물 (Output)
| 컴포넌트 | 파일명 패턴 | 위치 |
|---|---|---|
| CommandController | | |
| QueryController | | |
| Command DTO | | |
| Query DTO | | |
| Response DTO | | |
| ApiMapper | | |
| ErrorMapper | | |
완료 기준 (Acceptance Criteria)
- CQRS 분리: CommandController (POST, PATCH) / QueryController (GET)
-
래핑 (두 가지 모두 필수)ResponseEntity<ApiResponse<T>> -
검증: 모든 Request DTO에 필수@Valid - UseCase 직접 의존 (Service 금지)
- Mapper DI:
Bean (Static 금지)@Component - 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}/deletePATCH /{id}/cancel
클래스 레벨 (PathVariable/RequestParam 검증용)@Validated
파라미터 레벨 (RequestBody 검증용)@Valid
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 반환
로 Query Parameter 바인딩@ModelAttribute
로 PathVariable 검증@Positive- 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 | |
| PATCH | Update, Modify, Cancel | |
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 규칙:
Bean 등록 (Static 금지)@Component- 필드 매핑만 수행 (비즈니스 로직 금지)
- 기본값 설정/검증 금지 (Controller/Bean Validation 책임)
HTTP 상태 코드 규칙
| 메서드 | HTTP 상태 | 용도 |
|---|---|---|
| POST | 201 Created | 리소스 생성 성공 |
| GET | 200 OK | 조회 성공 |
| PATCH | 200 OK | 부분 수정 성공 |
| PUT | 200 OK | 전체 수정 성공 |
| DELETE | ❌ 지원 안 함 | 소프트 삭제는 PATCH로 |
Zero-Tolerance 규칙
✅ MANDATORY (필수)
| 규칙 | 설명 |
|---|---|
| 두 가지 래핑 모두 필수 |
| 모든 Request DTO에 필수 |
| Controller 클래스 레벨 |
| UseCase 의존 | Service 직접 의존 금지 |
| Mapper DI | Bean (Static 금지) |
| Record DTO | Java 21 Record 사용 |
| RESTful URI | 명사 복수형, 동사 금지 |
❌ PROHIBITED (금지)
| 항목 | 이유 |
|---|---|
| DELETE 메서드 | 소프트 삭제는 PATCH 사용 |
| 비즈니스 로직 | UseCase/Domain 책임 |
| UseCase 책임 |
| try-catch | GlobalExceptionHandler 위임 |
| Domain 직접 노출 | Response DTO 사용 |
| Lombok | Plain Java 사용 |
| Jackson 어노테이션 | , 금지 |
| 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
Content-Typeapplication/problem+json
응답 헤더 추가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)
-
모든 Request DTO에 적용@Valid - 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) -
사용 (MockMvc 금지)TestRestTemplate -
,@Tag("integration")@Tag("adapter-rest") - 성공/실패 시나리오 테스트
- Validation 실패 테스트
REST Docs 테스트
-
상속RestDocsTestSupport -
메서드로 Snippet 생성document() -
,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