Springdoc(Swagger)에서 커스텀 어노테이션으로 API 에러 코드 자동화하기

mseo39·2025년 10월 19일
0

TIL

목록 보기
10/10
post-thumbnail

왜 커스텀 어노테이션이 필요했나?

API 문서를 작성하는 도구로 Spring REST Docs와 Swagger를 고려했습니다.

  • Spring REST Docs: 테스트 코드를 기반으로 문서를 생성하기 때문에 문서의 신뢰도가 매우 높다는 장점이 있습니다. 하지만 당시 프로젝트는 한 달 이내의 빠른 개발과 공유가 필요했습니다. REST Docs는 별도의 테스트 코드와 문서 스니펫을 작성해야 하므로 저희의 빠른 개발 사이클에 맞지 않다고 판단했습니다.
  • Swagger (springdoc-openapi): 어노테이션 기반으로 코드와 함께 문서를 관리할 수 있고, 대부분의 응답을 자동으로 생성해 주어 속도가 빠릅니다.

그래서 Swagger를 선택했지만 문제가 있었습니다. 저희는 도메인별로 Enum을 만들어 에러 코드를 관리하고 있었는데 Swagger는 이 Enum에 정의된 수많은 에러 응답들을 자동으로 문서에 반영해주지 못했습니다.

물론 @ApiResponse 어노테이션을 컨트롤러 메서드마다 일일이 붙여 수동으로 작성할 수도 있었지만 이는 보일러플레이트 코드를 양산하고, 에러 코드가 추가/변경될 때마다 문서도 함께 수정해야 하는 유지보수의 어려움을 만들었습니다.

@ApiResponse 예시

@Operation(summary = "템플릿 승인 요청", description = "특정 템플릿에 대해 승인 요청을 보낸다.")
@ApiResponses(value = {
    @ApiResponse(
        responseCode = "200", 
        description = "템플릿 승인 요청 성공",
        content = @Content(
            mediaType = "application/json",
            schema = @Schema(implementation = ApiResult.class),
            examples = @ExampleObject(
                name = "승인 요청 성공",
                value = """
                        {
                            "success": true,
                            "data": {
                                "templateId": 1,
                                "status": "PENDING",
                                "requestedBy": "user_123"
                            },
                            "error": null
                        }
                        """
            )
        )
    ),
    @ApiResponse(
        responseCode = "403", 
        description = "권한 없음",
        content = @Content(
            mediaType = "application/json",
            schema = @Schema(implementation = ApiResult.class),
            examples = @ExampleObject(
                name = "승인 권한 없음 (TEMPLATE_403_001)",
                value = """
                        {
                            "success": false,
                            "data": null,
                            "error": {
                                "code": "FORBIDDEN_TEMPLATE",
                                "message": "권한이 없는 템플릿입니다."
                            }
                        }
                        """
            )
        )
    ),
    @ApiResponse(
        responseCode = "404", 
        description = "템플릿을 찾을 수 없음",
        content = @Content(
            mediaType = "application/json",
            schema = @Schema(implementation = ApiResult.class),
            examples = @ExampleObject(
                name = "템플릿 없음 (TEMPLATE_404_001)",
                value = """
                        {
                            "success": false,
                            "data": null,
                            "error": {
                                "code": "TEMPLATE_NOT_FOUND",
                                "message": "템플릿을 찾을 수 없습니다."
                            }
                        }
                        """
            )
        )
    ),
    // ... TemplateErrorCode에 정의된 다른 모든 에러 ...
})
@PostMapping("/{id}/approve-request")
public ApiResult<TemplateApproveResponse> approveTemplate(
        @PathVariable Long id,
        @AuthenticationPrincipal CustomUserPrincipal principal
) {
    System.out.println(principal.getId());
    TemplateApproveResponse response = templateService.approveTemplate(id, principal.getId());
    return ApiResult.ok(response);
}

저의 목표는 다음과 같았습니다.

  1. Enum에 정의된 에러 코드가 그대로 API 문서에 반영되어야 한다.
  2. 컨트롤러에 간단한 어노테이션 하나만 붙이면, 해당 API에서 발생 가능한 모든 에러가 자동으로 문서화되어야 한다.
  3. 팀원들이 API 명세를 보고 명확하게 에러 케이스를 인지할 수 있어야 한다.

이 문제를 해결하기 위해 OperationCustomizer를 활용한 커스텀 어노테이션을 만들기로 했습니다.


구현 과정

구현은 크게 두 단계로 나뉩니다.

  1. 컨트롤러 메서드에 적용할 커스텀 어노테이션 @ApiErrorCodeExample 정의
  2. 해당 어노테이션을 감지하여 Swagger 문서를 동적으로 커스터마이징하는 SwaggerConfig 작성

1. @ApiErrorCodeExample 어노테이션 정의

먼저, 컨트롤러의 각 메서드가 어떤 에러 코드 Enum을 참조해야 하는지 알려주는 어노테이션을 만듭니다.

ApiErrorCodeExample.java

package com.example.final_projects.config.swagger;

import com.example.final_projects.exception.code.BaseErrorCode;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Swagger 문서에 API에서 발생 가능한 에러 코드 예시를 동적으로 추가하기 위한
 * 커스텀 어노테이션입니다.
 *
 * BaseErrorCode를 구현한 Enum 클래스를 값으로 받습니다.
 */
@Target(ElementType.METHOD) // 메서드에 적용
@Retention(RetentionPolicy.RUNTIME) // 런타임에 이 어노테이션 정보를 참조할 수 있도록 설정
public @interface ApiErrorCodeExample {
    Class<? extends BaseErrorCode> value(); // BaseErrorCode를 상속받는 Enum 클래스 타입을 받음
}
  • @Target(ElementType.METHOD): 이 어노테이션을 메서드에만 붙일 수 있도록 지정합니다.
  • @Retention(RetentionPolicy.RUNTIME): Springdoc이 런타임에 어노테이션을 읽고 문서를 동적으로 생성해야 하므로 RUNTIME으로 설정합니다.
  • value(): TemplateErrorCode.class 처럼 에러 코드가 정의된 Enum 클래스를 넘겨받기 위해 사용합니다. (이때 BaseErrorCode라는 공통 인터페이스를 구현하도록 강제했습니다.)

2. SwaggerConfig 및 OperationCustomizer 설정

다음으로, Springdoc이 Swagger 문서를 생성하는 과정에 개입하여 @ApiErrorCodeExample 어노테이션을 처리하는 로직을 추가합니다. springdoc-core가 제공하는 OperationCustomizerBean으로 등록하면 이 작업을 수행할 수 있습니다.

SwaggerConfig.java

@Configuration
public class SwaggerConfig {

    // ... (OpenAPI 기본 설정 Bean: Jober API 타이틀, JWT 인증 설정 등) ...
  

    /**
     * @ApiErrorCodeExample 어노테이션을 처리하여
     * Swagger 문서에 동적으로 Error Response 예시를 추가하는 OperationCustomizer
     */
    @Bean
    public OperationCustomizer errorCodeCustomizer() {
        return (operation, handlerMethod) -> {
            // 1. 핸들러 메서드(컨트롤러)에서 @ApiErrorCodeExample 어노테이션 찾기
            ApiErrorCodeExample annotation = handlerMethod.getMethodAnnotation(ApiErrorCodeExample.class);
            
            if (annotation != null) {
                // 2. 어노테이션이 존재하면, value()로 지정된 Enum 클래스를 가져옴
                Class<? extends BaseErrorCode> errorCodeClass = annotation.value();
                // 3. 해당 Enum의 상수들(에러 코드)을 가져와 Swagger 문서에 예시 추가
                addErrorCodeExamples(operation.getResponses(), errorCodeClass);
            }
            return operation;
        };
    }

    /**
     * BaseErrorCode Enum 클래스를 바탕으로 ApiResponses에 에러 응답 예시(Example)를 추가
     */
    private void addErrorCodeExamples(ApiResponses responses, Class<? extends BaseErrorCode> errorCodeClass) {
        // 1. Enum에 정의된 모든 에러 코드를 가져옴
        BaseErrorCode[] errorCodes = errorCodeClass.getEnumConstants();

        // 2. 에러 코드들을 HTTP Status Code별로 그룹화
        //    (e.g., 400 - [BAD_REQUEST_001, BAD_REQUEST_002], 404 - [NOT_FOUND_001])
        Map<Integer, List<BaseErrorCode>> groupedByStatus = Arrays.stream(errorCodes)
                .collect(Collectors.groupingBy(ec -> ec.getErrorReason().getStatus()));

        // 3. 그룹화된 맵을 순회하며 HTTP Status별로 ApiResponse 생성
        groupedByStatus.forEach((status, list) -> {
            Content content = new Content();
            MediaType mediaType = new MediaType();
            ApiResponse apiResponse = new ApiResponse().content(content.addMediaType("application/json", mediaType));

            // 4. 같은 HTTP Status를 가진 에러 코드들을 Example로 추가
            for (BaseErrorCode ec : list) {
                Example example = new Example();
                example.setSummary(ec.getErrorReason().getMessage()); // 예시 이름 (Summary)
                // 예시 값 (JSON). ApiResult는 공통 응답 포맷
                example.setValue(ApiResult.error(
                        ec.getErrorReason().getCode(),
                        ec.getErrorReason().getMessage()
                ));
                
                // (e.g., "USER_400_001")
                mediaType.addExamples(ec.getErrorReason().getCode(), example);
            }
            
            // 5. 생성된 ApiResponse를 Operation의 ApiResponses에 추가
            responses.addApiResponse(String.valueOf(status), apiResponse);
        });
    }
}

핵심 로직:

  1. errorCodeCustomizer Bean이 Springdoc에 의해 등록됩니다.
  2. Springdoc이 각 컨트롤러 메서드(Operation)를 스캔할 때마다 errorCodeCustomizer가 동작합니다.
  3. handlerMethod.getMethodAnnotation(ApiErrorCodeExample.class)를 통해 메서드에 @ApiErrorCodeExample이 붙어있는지 확인합니다.
  4. 어노테이션이 있다면, annotation.value()TemplateErrorCode.class 같은 Enum 클래스 정보를 가져옵니다.
  5. addErrorCodeExamples 헬퍼 메서드를 호출합니다.
  6. addErrorCodeExamples에서는:
    • Enum의 모든 상수(getEnumConstants())를 가져옵니다.
    • 각 에러 코드가 가진 HTTP 상태 코드(e.g., 400, 404)를 기준으로 groupingBy를 사용해 맵으로 만듭니다. (Swagger는 HTTP Status 기준으로 응답을 묶어 보여주기 때문입니다.)
    • 각 HTTP Status(400, 404...)별로 ApiResponse를 생성합니다.
    • 해당 Status에 속하는 모든 에러 코드(BaseErrorCode ec)에 대해 Example 객체를 만듭니다.
    • example.setSummary(): 예시의 요약 (e.g., "입력값이 유효하지 않습니다.")
    • example.setValue(): 실제 반환될 JSON 응답 본문 (ApiResult.error(...))
    • mediaType.addExamples(): 생성된 예시를 MediaType에 추가합니다. 이때 **키(Key)로 에러 코드 문자열(e.g., "INVALID_INPUT")**을 사용하면, Swagger UI에서 드롭다운 형태로 여러 예시를 선택해 볼 수 있습니다.
    • 최종적으로 이 ApiResponseresponses.addApiResponse(String.valueOf(status), ...)를 통해 Swagger 문서에 추가합니다.

적용 결과

이제 컨트롤러에서 이 어노테이션을 사용하기만 하면 됩니다.

컨트롤러 예시:

Swagger UI 결과:

결론

@ApiErrorCodeExample 커스텀 어노테이션과 OperationCustomizer를 도입함으로써 다음을 달성할 수 있었습니다.

  1. 에러 코드 Enum이 곧 문서가 되었습니다. Enum만 수정하면 별도 작업 없이 Swagger 문서가 자동으로 갱신됩니다.
  2. 컨트롤러에서 어노테이션 한 줄만 추가하면 되므로, REST Docs나 수동 @ApiResponse 작성에 비해 문서화에 드는 공수가 줄었습니다.
profile
하루하루 성실하게

0개의 댓글