Backend/Spring Boot & JPA

[Spring Boot] Swagger의 운영 코드 침투를 막아라 (feat. Springdoc)

나죽못고나강뿐 2024. 8. 15. 19:32
📕 목차

1. Intoduction
2. Interface 분리하기
3. 공통 예외 응답
4. Custom Error Code Parser  

1. Introduction

 

📌 개요

같은 백엔드 개발자끼리는 인터페이스 설명을 위해 Java Docs같은 언어에서 제공하는 주석을 사용한다.

하지만 클라이언트 측 개발자에게 명세를 표현하기 위해선, 별도의 API 명세서가 필요한데 가장 많이 사용하는 것이 Swagger.

(본문하고는 관련 없지만 Swagger를 API 문서화 도구라고 설명하는 글들이 많던데, 문서화는 Swagger의 여러 기능 중 하나일 뿐이다.)

 

Spring Boot에서 API 문서를 제공하고자 할 땐 Springdoc이 대표적인 라이브러리다.

https://springdoc.org/#general-overview

적용 방법은 여기서 설명하지 않는다.

springdoc 의존성을 gradle에 설정하기만 해도, Swagger가 알아서 @Controller 어노테이션이 붙은 클래스의 메서드를 기반으로 문서를 생성해준다.

 

하지만 이 정도로는 명세서라고 할 수 없다.

API를 사용할 때 주의 사항, 예외 응답, 내부적으로 어떤 보안 정책을 사용하고 있으며 클라이언트가 이를 주의해야 하는 등의 설명까지 자동으로 생성해주지는 못하기 때문이다.

 

이를 문서에 추가하고 싶으면 Swagger가 문서를 생성할 때 hint를 제공해야 하는데, 여기서부터 본격적으로 Swagger가 Product code에 침투하기 시작한다.

 

📌 Swagger는 운영 코드에 침투적이다.

@Operation(summary = "[2] 일반 회원가입 인증번호 검증", description = "인증번호를 검증합니다. 미인증 사용자만 가능합니다.")
@ApiResponses({
        @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = {
                @ExampleObject(name = "검증 성공 - 기존에 등록한 소셜 계정 없음 - [3-1]로 진행", value = """
                        {
                            "code": "2000",
                            "data": {
                                "sms": {
                                    "code": true,
                                    "oauth": false
                                }
                            }
                        }
                        """),
                @ExampleObject(name = "검증 성공 - 기존에 등록한 소셜 계정 있음 - [3-2]로 진행", value = """
                        {
                            "code": "2000",
                            "data": {
                                "sms": {
                                    "code": true,
                                    "oauth": true,
                                    "username": "pennyway"
                                }
                            }
                        }
                        """)
        })),
        @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = {
                @ExampleObject(name = "검증 실패", value = """
                        {
                            "code": "4010",
                            "message": "인증번호가 일치하지 않습니다."
                        }
                        """)
        })),
        @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = {
                @ExampleObject(name = "검증 실패 - 인증번호 만료", value = """
                        {
                            "code": "4042",
                             "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다."
                        }
                        """)
        })),
        @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = {
                @ExampleObject(name = "일반 회원가입 계정이 이미 존재함", value = """
                        {
                            "code": "4091",
                            "message": "이미 회원가입한 유저입니다."
                        }
                        """)
        }))
})
@PostMapping("/phone/verification")
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) {
    return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.verifyCode(request)));
}

 

인증코드를 검증하는 컨트롤러 하나에 명세를 표현하기 위한 어노테이션이 50라인 이상을 차지하고 있다.

 

여기서 문서를 위한 코드는 분명 필요하지만, 제품 코드는 아니다.

제품 코드도 아닌 것이 무분별하게 침투하기 시작하면, 개발자는 혼란에 빠질 수밖에 없다.

그렇다고 명세의 퀄리티를 낮추자니, 그건 또 부적절한 대안이다.

 

따라서 Swagger를 제품 코드로부터 분리하고, 우아하게 적용하는 방법을 모색해보며 점진적으로 개선해보았다.

 

📌 Swagger vs. Spring REST Docs

Swagger의 제품 코드 침투가 싫다면, Spring REST Docs를 사용하는 것도 좋은 생각이다.

Spring REST Docs는 작성된 테스트 코드를 기반으로 문서를 작성하는데, 이는 양날의 검이다.

 

테스트를 강제하기 때문에 API에 대한 신뢰감을 제공하며, 변경 사항 또한 즉각적으로 반영할 수 있다.

하지만 이미 어느정도 개발이 진행된 프로젝트(테스트 코드는 없는)나, 테스트 코드 작성이 미숙한 팀(보통은 학생 프로젝트), 혹은 데드라인이 너무 촉박해서 현실적으로 모든 테스트 케이스를 작성하긴 어려운 경우(원칙 상 안 되지만, 세상엔 언제나 예외가 존재한다.)엔 정작 가장 중요한 구현을 지연시키는 원인이 된다.  

 

또한 Swagger와 달리 Spring REST Docs로 생성된 문서는 API 테스트를 해볼 수 없다.

 

 

Spring Rest Docs로 OpenAPI (Swagger) 문서를 만들어 Swagger UI로 호출하여 보기

이 글의 내용은 Spring Rest Docs를 이미 사용하고 있는 상황에서 OpenAPI 문서를 만들기 위한 내용을 담고 있습니다. OpenAPI Specification 소개 OpenAPI Specification은 예전엔 Swagger Specification으로 알려졌었다.

luvstudy.tistory.com

만약 Spring REST Docs에 Swagger를 얹는 걸 해보고 싶다면, 위 블로그가 상당히 도움이 된다. 

 

우리 프로젝트에서도 Spring REST Docs를 도입하는 것을 고려해보지 않은 것은 아니다.

하지만 팀원들이 JUnit으로 테스트 코드를 작성하는 것을 어려워하기도 했고, 이미 어느정도 개발이 진행된데다, 런칭이 자꾸 지연되고 있는 현재 시점에서 적용하기엔 무리가 있었다.

 

그래서 Springdoc을 개선하는 방향으로 진행하기로 결정했다.

 

📌 요구 스펙
  • 기존과 동일한 Swagger 문서를 제공할 수 있어야 한다. (가장 중요)
  • Swagger를 제품 코드로부터 분리해야 한다.
  • 문자열에 의존하는 에러 코드 응답을 상수값을 기반으로 자동 생성할 수 있도록 수정해야 한다.

 


2. Interface 분리하기

 

📌 Interface에 Swagger 어노테이션을 달아도 가능할까?

최우선적으로 저 놈의 어노테이션을 제품 코드에서 떼어낼 수 있는 방법이 무엇일지 고민을 해봤다.

커스텀 어노테이션을 만들어서 축약을 할 수는 있겠지만, 어디까지나 차선책일 뿐 여전히 Swagger가 제품 코드에 침투하고 있는 건 마찬가지기 때문.

 

그러다 팀원이 한 가지 아이디어를 제공해줬는데, Controller의 인터페이스를 만들어서 거기에 어노테이션을 달면 어떻냐는 의견이었다.

 

'왜 이 생각을 못 했지?'랑 '진짜 될까?'가 반반정도 섞인 상태에서 일단 테스트를 해보기로 했다.

 

📌 Interface 분리
@Tag(name = "[인증 API]")
public interface AuthApi {
    @Operation(summary = "[2] 일반 회원가입 인증번호 검증", description = "인증번호를 검증합니다. 미인증 사용자만 가능합니다.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = {
                    @ExampleObject(name = "검증 성공 - 기존에 등록한 소셜 계정 없음 - [3-1]로 진행", value = """
                            {
                                "code": "2000",
                                "data": {
                                    "sms": {
                                        "code": true,
                                        "oauth": false
                                    }
                                }
                            }
                            """),
                    @ExampleObject(name = "검증 성공 - 기존에 등록한 소셜 계정 있음 - [3-2]로 진행", value = """
                            {
                                "code": "2000",
                                "data": {
                                    "sms": {
                                        "code": true,
                                        "oauth": true,
                                        "username": "pennyway"
                                    }
                                }
                            }
                            """)
            })),
            @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = {
                    @ExampleObject(name = "검증 실패", value = """
                            {
                                "code": "4010",
                                "message": "인증번호가 일치하지 않습니다."
                            }
                            """)
            })),
            @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = {
                    @ExampleObject(name = "검증 실패 - 인증번호 만료", value = """
                            {
                                "code": "4042",
                                "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다."
                            }
                            """)
            })),
            @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = {
                    @ExampleObject(name = "일반 회원가입 계정이 이미 존재함", value = """
                            {
                                "code": "4091",
                                "message": "이미 회원가입한 유저입니다."
                            }
                            """)
            }))
    })
    ResponseEntity<?> verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request);
}
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/auth")
public class AuthController implements AuthApi {
    private final AuthUseCase authUseCase;

    @Override
    @PostMapping("/phone/verification")
    @PreAuthorize("isAnonymous()")
    public ResponseEntity<?> verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) {
        return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.verifyCode(request)));
    }
}

정말 똑같이 동작한다 ㅋㅋ.

가장 고민 거리였던 부분이 가장 허무하게 풀렸는데, 기분은 좋았다.

 

앞으로 Swagger 설정을 확인하고 싶다면 인터페이스에서 확인하고, 제품 코드는 구현체인 Controller에서 관리하면 된다.

관리할 클래스가 2배가 된다는 단점이 있지만, 적어도 20~50라인은 가볍게 차지하는 Swagger 스니펫이 제품 코드에 지속적으로 침투하는 것보다 훨씬 낫다.

 


3. 공통 예외 응답

 

📌 공통 예외

예외는 2가지 종류로 나뉘는데, 특정 API에서만 발생하는 특수한 예외가 있고, 특정 그룹에서는 공통적으로 발생하는 예외가 존재한다.

 

전자의 경우엔 API마다 명시를 해주어야 하지만, 후자의 경우엔 번거롭기 그지 없는 작업이 된다.

심지어 개발자가 빠트릴 수도 있고, 공통적으로 발생하는 예외가 많으면 많을 수록 상황은 악화된다.

 

그래서 보통 ErrorResponse 클래스를 하나 만들어두고, 전역 예외 처리에서 다음과 같이 사용한다.

@Getter
@Schema(title = "API 응답 - 실패 및 에러")
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ErrorResponse {
    @Schema(description = "응답 코드", example = "4자리 정수형 문자열 (상태 코드(3자리) + 에러 코드(1자리))", pattern = "\\d{4}")
    private String code;
    @Schema(description = "응답 메시지", example = "에러 이유")
    private String message;
    @Schema(description = "에러 상세", example = "{\"field\":\"reason\"}")
    private Object fieldErrors;
}
/**
 * Controller 하위 계층에서 발생하는 전역 예외를 처리하는 클래스
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * API 호출 시 인가 관련 예외를 처리하는 메서드
     *
     * @see AccessDeniedException
     */
    @ResponseStatus(HttpStatus.FORBIDDEN)
    @ExceptionHandler(AccessDeniedException.class)
    @ApiResponse(responseCode = "403", description = "FORBIDDEN", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
    protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) {
        log.warn("handleAccessDeniedException : {}", e.getMessage());
        CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN);

        return ErrorResponse.of(causedBy.getCode(), causedBy.getReason());
    }
}

Swagger는 @Contoller 뿐만 아니라, @ControllerAdvice가 선언된 클래스를 기반으로도 응답을 생성한다.

 

이를 이용하여 전역 예외를 처리하는 곳에서 @ApiResponse를 정의해두면, 모든 API에서 공통 예외에 대한 문서를 추가할 수 있다.

 

📌 fieldError가 왜 나와?

422 Unprocessed Entity 에러처럼 사용자가 request payload의 데이터를 누락한 경우엔, 어떤 필드에서 예외가 발생했는지 알려주도록 만드는 경우가 많다.

그러나 Error Response를 기반으로 예외 문서를 만드는 Swagger가 그런 까다로운 조건을 알리는 만무하니, 그냥 모든 예외 응답에 죄다 존재하지도 않는 fieldError 필드를 삽입해두는 문제가 있었다.

 

여기서 문제 접근을 위한 질문은 다음과 같다.

"어떻게 하면 Swagger가 선택적으로 프로퍼티를 결정할 수 있도록 할 수 있을까?"

 

📌 @JsonView
 

Spring Boot, Jackson, @JsonView로 멀티 뷰 구성하기

@JsonView 정의 public class View { public static class Consumer { } public static class Repository { } } @JsonView의 장점은 동일한 POJO 오브젝트에 대해서 선택적으로 서로 다른 프라퍼티가 조합된 JSON 문자열을 만들 수

jsonobject.tistory.com

jackson 라이브러리에서 제공하는 @JsonView는 동일한 POJO에 대해 선택적으로 서로 다른 property 조합을 만들 수 있다.

 

위 블로그에서 예시를 너무 잘 작성해주어서, 별도의 설명은 생략.

여튼 이걸 사용한다면 422 에러에 대해서만 fieldError가 보이도록 조정할 수 있지 않을까??

 

📌 CustomJsonView
public class CustomJsonView {
    public static class Common {
    }

    public static class Hidden extends Common {
    }
}
@Getter
@Schema(title = "API 응답 - 실패 및 에러")
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ErrorResponse {
    @Schema(description = "응답 코드", example = "4자리 정수형 문자열 (상태 코드(3자리) + 에러 코드(1자리))", pattern = "\\d{4}")
    @JsonView(CustomJsonView.Common.class)
    private String code;
    @Schema(description = "응답 메시지", example = "에러 이유")
    @JsonView(CustomJsonView.Common.class)
    private String message;
    @Schema(description = "에러 상세", example = "{\"field\":\"reason\"}")
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @JsonView(CustomJsonView.Hidden.class)
    private Object fieldErrors;
}

저 당시에 이름을 왜 저따구로 지었을까..기억이 안 난다.

code, message는 모든 에러 응답에 포함되므로 Common 속성을 적용하고, fieldError까지 포함하는 경우엔 Common을 상속한 Hidden 속성을 적용했다.

 

Hidden이 Common을 상속하지 않으면, 나중에 @JsonView({CustomJsonView.Common.class, CustomJsonView.Hidden.class})처럼 적어야 하는데

어차피 code, message는 공통 필드이므로 상속 처리해주는 게 간편하다.

 

📌 GlobalExceptionHandler 수정
/**
 * API 호출 시 인가 관련 예외를 처리하는 메서드
 *
 * @see AccessDeniedException
 */
@ResponseStatus(HttpStatus.FORBIDDEN)
@ExceptionHandler(AccessDeniedException.class)
@ApiResponse(responseCode = "403", description = "FORBIDDEN", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@JsonView(CustomJsonView.Common.class) // 추가
protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) {
    log.warn("handleAccessDeniedException : {}", e.getMessage());
    CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN);

    return ErrorResponse.of(causedBy.getCode(), causedBy.getReason());
}

/**
 * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우
 *
 * @see MethodArgumentNotValidException
 */
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ExceptionHandler(MethodArgumentNotValidException.class)
@JsonView(CustomJsonView.Hidden.class)
protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    log.warn("handleMethodArgumentNotValidException: {}", e.getMessage());
    BindingResult bindingResult = e.getBindingResult();

    return ErrorResponse.failure(bindingResult, ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY);
}

아까 전 예외 처리를 해주던 메서드에서 JsonView 어노테이션을 추가해주자.

fieldError가 보이지 않는 곳에선 Common을 적용하고, 보여져야 할 곳에는 Hidden을 적용해준다.

 

📌 결과

이렇게 하면 성공적으로 공통 예외 응답의 fieldErrors 필드를 선택적으로 반영할 수 있다.

 


4. Custom Error Code Parser

 

📌 Custom Error Code와 Error Response의 문제점
 

[Spring Boot] 프로젝트 멀티 모듈화 고찰

💡 해당 포스트는 필자의 빈약한 이해 지식을 기반으로 한 프로젝트 멀티 모듈화입니다.개발이 진행됨에 따라 추후 지속적으로 내용이 변경될 수 있으며, 혹시나 본인의 프로젝트에도 반영을

jaeseo0519.tistory.com

우리 서비스는 서비스에서 발생하는 예외 포맷을 통일하기 위해, 위 포스트의 [4. Refactoring]처럼 에러 상수를 정의하고 있다. 

여기까진 좋았는데, 문제는 이걸 무슨 수로 Swagger 문서에 명시할 것인지가 관건이었다.

 

@Getter
@RequiredArgsConstructor
public enum PhoneVerificationErrorCode implements BaseErrorCode {
    // 400 Bad Request
    INVALID_VERIFICATION_TYPE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "유효하지 않은 인증 타입입니다."),
    PROVIDER_IS_REQUIRED(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "type이 OAUTH인 경우 provider는 필수입니다."),

    // 401 Unauthorized
    IS_NOT_VALID_CODE(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "인증코드가 일치하지 않습니다."),

    // 404 Not Found
    EXPIRED_OR_INVALID_PHONE(StatusCode.NOT_FOUND, ReasonCode.RESOURCE_DELETED_OR_MOVED, "만료되었거나 등록되지 않은 휴대폰 정보입니다."),
    ;

    private final StatusCode statusCode;
    private final ReasonCode reasonCode;
    private final String message;

    @Override
    public CausedBy causedBy() {
        return CausedBy.of(statusCode, reasonCode);
    }

    @Override
    public String getExplainError() throws NoSuchFieldError {
        return message;
    }
}

에러 상수는 위와 같이 정의되어 있다.

하지만 에러 응답 포맷은 ErrorResponse를 기반으로 생성해야 한다.

ErrorResponse에 들어갈 예외는 런타임 시점에 동적으로 결정되므로, @Schema를 이용해 ErrorResponse에 예시를 작성해둘 수도 없는 노릇이다.

 

결국 이 문제를 임시 방편으로 처리해두기 위해 다음과 같이 작성하고 있다.

미치고 환장할 노릇

최악의 방법이다.

우선 Swagger를 위한 문서를 작성하는 난이도와 번거로움이 급격하게 상승하며,

예외 code 혹은 message 정보가 수정되었는데 개발자가 swagger 수정을 깜빡한다면, 우리 팀의 헬스 체크 담당인 iOS 팀에서 연락이 올지도 모른다.

(서버가 죽으면 Gmail 알림보다도 빠르게 연락이 날아온다. 무섭다.)

 

프로젝트 초기엔 이 문제를 그냥 방치했다.

아니, 그야 swagger 문서는 컴파일 시점에 생성된다고 생각했던 터라 어떻게 해결할 지도 착잡했고

swagger 문서 편하게 작성해보겠다고 여기에 시간 쏟는 건 또 팀원들의 눈치가 보였기에 나중에 수정해야겠다 하고 미루고 미루던 작업이었다.

 

그런데 SwaggerConfig를 수정하던 도중 문득 의문이 들었다.

해당 Config는 Swagger 문서를 생성하기 위해 필요한 정보들이 들어 있는데, Spring Boot의 Bean Context 이후에 정보를 등록을 하려면 컴파일 단계에서 문서를 완성할 수가 없다는 점이었다.

 

그럼 문서는 대체 언제 생성되는 거지?

 

📌 SpringDoc 동작 과정

스웨거 페이지를 렌더링하면 서버에는 "Init duration for springdoc-openapi"라는 로그가 찍힌다.

로그가 찍히는 지점은 AbstractOpenApiResource이니, 친히 찾아가주었다.

 

/**
 * The type Abstract open api resource.
 *
 * @author bnasslahsen
 * @author kevinraddatz
 * @author hyeonisism
 * @author doljae
 */
public abstract class AbstractOpenApiResource extends SpecFilter {
    ...
 	/**
	 * Gets open api.
	 *
	 * @param locale the locale
	 * @return the open api
	 */
	protected OpenAPI getOpenApi(Locale locale) {
		this.reentrantLock.lock();
        try {
			final OpenAPI openAPI;
			final Locale finalLocale = locale == null ? Locale.getDefault() : locale;
			if (openAPIService.getCachedOpenAPI(finalLocale) == null || springDocConfigProperties.isCacheDisabled()) {
                ...
				openAPIService.setCachedOpenAPI(openAPI, finalLocale);

				LOGGER.info("Init duration for springdoc-openapi is: {} ms",
						Duration.between(start, Instant.now()).toMillis());
			}
            ...
        }
        ....
    }
    ...
}

내가 생각한 것과는 전혀 다르게, SpringDoc은 런타임 시점에 Swagger 문서를 생성하고 있었다.

(한 번 생성한 문서는 캐싱해두고, 다음 요청 시엔 캐싱된 정보를 반환한다.)

 

어라, 그렇다면 ErrorResponse의 값 또한 동적으로 바인딩할 수 있는 거 아닌가? 🤔

 

📌 OperationCustomer
 

Programmatic customization of the OpenAPI, problem with GroupedOpenApi?! · Issue #366 · springdoc/springdoc-openapi

The docs state that programmatic customization of the OpenAPI object is supported: @Bean public OpenAPI customOpenAPI(@Value("${springdoc.version}") String appVersion) { return new OpenAPI() .compo...

github.com

 

아니나 다를까, springdoc에선 객체 지향 프로그래밍 방식의 사용자 정의를 지원하기 위해 두 가지 함수형 인터페이스를 제공하고 있다.

 

여기서 실수했던 점이 정작 개발할 때는 다른 이슈를 보고 OperationCustomizer를 사용해서 기능을 구현했는데, 

예외 포맷을 정해주기 위한 건 group에 상관없이 전역적으로 수행되어야 하는 작업이라 OpenApiCustomer를 써서 구현하는 게 더 적절하지 않았을까 싶다. (확실하진 않음)

 

내가 하려는 작업은 문서를 생성할 때, 특정 어노테이션이 선언된 메서드들을 적절하게 파싱해주는 작업을 수행해주는 것인데, 여기서 그 역할을 하려면 Components를 살펴보면 구현 가능할 듯.

 

하지만 나는 OperationCustomizer를 사용하기로 했으므로, 해당 빈을 사용해서 처리할 것이다.

 

📌 annotation
@ApiResponses({
    @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = {
        @ExampleObject(name = "검증 실패", value = """
            {
                "code": "4010",
                "message": "인증번호가 일치하지 않습니다."
            }
        """)
    })),
    @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = {
        @ExampleObject(name = "검증 실패 - 인증번호 만료", value = """
            {
                "code": "4042",
                "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다."
            }
        """)
    })),
    @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = {
        @ExampleObject(name = "일반 회원가입 계정이 이미 존재함", value = """
            {
                "code": "4091",
                "message": "이미 회원가입한 유저입니다."
            }
        """)
    }))
})

위 어노테이션을 대체하려면, @ApiResponses와 @ApiResponse 역할을 수행할 커스텀 어노테이션이 필요하다.

 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiResponseExplanations {
    ApiExceptionExplanation[] errors() default {};
}

일단 @ApiResponses 역할을 대체할 어노테이션을 만들어주자.

만약, 성공 응답도 커스텀하게 처리하고 싶다면, @ApiSuccessExplanation 같은 어노테이션 만들어서 success() 필드를 추가해주면 된다.

하지만 현재로써 성공 응답 포맷을 만드는 건 그렇게까지 정확성 문제에서 어긋날 일은 없고, 오히려 작업 시간만 연장시킬 거 같아서 에러 응답에 대해서만 처리했다.

 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiExceptionExplanation {
    Class<? extends BaseErrorCode> value();

    /**
     * BaseErrorCode를 구현한 Enum 클래스의 상수명
     */
    String constant();

    String name() default "";

    String mediaType() default "application/json";

    String summary() default "";

    String description() default "";
}

그 다음 @ApiExceptionExplanation 어노테이션을 작성했는데, value와 constant를 제외하고는 모두 옵션값이다.

 

서비스에서 커스텀하게 처리하는 예외는 모두 BaseErrorCode 인터페이스를 구현하고 있으므로, Class<? extends BaseErrorCode> 타입으로 받았다.

문제는 범용성은 확보했지만, 어노테이션에 enum 상수값을 넘길 수가 없게 되었다.

 

일단 Annotation의 필드로는 클래스, 인터페이스, 기본/래퍼 자료형 타입은 허용되지만 enum 타입은 허용되지 않는다. 

그럼 어쩔 수 없이 Class 타입으로 받아야 하는데, UserErrorCode.class는 되지만 UserErrorCode.NOT_FOUND는 클래스가 아닌 인스턴스이므로 넣을 수가 없다.

 

그렇다고 code와 message를 직접 넣는 방식을 선택하자니,

Annotation과 Switch는 JVM은 컴파일 시점에 값이 결정되어야 하는 대표적인 요소들이다.

그 말은 @ApiExceptionExplanation(code=UserErrorCode.NOT_FOUND.getCode())처럼 사용할 수가 없다는 이야기다.

 

그래서 결정한 방법.. UserErrorCode.NOT_FOUND를 표현하고 싶다면,

value에 UserErrorCode.class를 넣고, constant로 "NOT_FOUND"를 넣어주도록 했다.

 

🤔 결국 문자열에 의존하는 거 아닌가요?

물론 위 방법이 우아하지 않다는 의견에는 동의하지만, 적어도 이전과는 상황이 다르다.
기존 방법은 문서가 잘못 작성되어도 오류를 찾아내려면, 서비스 로직을 뒤져가며 모든 에러 메시지와 문자열들을 비교해봤어야 했다.
하지만 위 방법은 문자열을 이용해 상수값을 찾다가 실패하면, 예외를 발생시킴으로써 훨씬 빠르고 쉽게 오류를 검수할 수 있다.
또한 이전과는 달리, 어떤 enum 클래스의 어떤 상수값에서 데이터를 가져오는지 확인할 수 있으므로 서비스 로직을 모두 뒤져야 하는 불상사를 피할 수 있다.

 

📌 ApiExceptionExplainParser
public final class ApiExceptionExplainParser {
    public static void parse(Operation operation, HandlerMethod handlerMethod) {
        ApiResponseExplanations annotation = handlerMethod.getMethodAnnotation(ApiResponseExplanations.class);

        if (annotation != null) {
            generateExceptionResponseDocs(operation, annotation.errors());
        }
    }
    
    ...
}

OperationCustomer를 사용하면, Operation과 HandlerMethod 정보를 알 수 있다.

우선 handlerMethod에 내가 정의한 ApiResponseExplanations 어노테이션이 존재하는 지 확인하고, 있다면 파싱을 시작하면 된다.

 

private static void generateExceptionResponseDocs(Operation operation, ApiExceptionExplanation[] exceptions) {
    ApiResponses responses = operation.getResponses();

    Map<Integer, List<ExampleHolder>> holders = Arrays.stream(exceptions)
            .map(ExampleHolder::from)
            .collect(Collectors.groupingBy(ExampleHolder::httpStatus));

    addExamplesToResponses(responses, holders);
}

복수개일 수 있는 error 정보를 우선 ExampleHolder에 담아주었다.

그 후엔 동일한 httpStatus를 갖는 에러들을 묶어주어 Map으로 만들어주면 된다.

(이걸 왜 하는지는 Swagger 쓰면서 대차게 굴러봐야 아는 거라..예를 들어, 404에러도 여러가지 원인일 수 있는데 그걸 묶어주는 작업이다.)

 

public final class ApiExceptionExplainParser {
    ...
    
    @Builder(access = AccessLevel.PRIVATE)
    private record ExampleHolder(int httpStatus, String name, String mediaType, String description, Example holder) {
        static ExampleHolder from(ApiExceptionExplanation annotation) {
            BaseErrorCode errorCode = getErrorCode(annotation);

            return ExampleHolder.builder()
                    .httpStatus(errorCode.causedBy().statusCode().getCode())
                    .name(StringUtils.hasText(annotation.name()) ? annotation.name() : errorCode.getExplainError())
                    .mediaType(annotation.mediaType())
                    .description(annotation.description())
                    .holder(createExample(errorCode, annotation.summary(), annotation.description()))
                    .build();
        }
        
        @SuppressWarnings("unchecked")
        public static <E extends Enum<E> & BaseErrorCode> E getErrorCode(ApiExceptionExplanation annotation) {
            Class<E> enumClass = (Class<E>) annotation.value();
            return Enum.valueOf(enumClass, annotation.constant());
        }

        private static Example createExample(BaseErrorCode errorCode, String summary, String description) {
            ErrorResponse response = ErrorResponse.of(errorCode.causedBy().getCode(), errorCode.getExplainError());

            Example example = new Example();
            example.setValue(response);
            example.setSummary(summary);
            example.setDescription(description);

            return example;
        }
    }
}

ExampleHolder는 어노테이션에 정의된 데이터를 기반으로 정보를 뽑아내기 위함인데, 내부적으로 두 가지 메서드를 갖는다.

  • getErrorCode
    • value에서 알려준 클래스 정보에서 constant 상수 값을 찾아서 반환한다.
    • value를 강제로 형변환 하면 경고가 뜨는데, 어차피 커스텀 예외는 언제나 BaseErrorCode를 상속받은 enum 타입이라는 것이 명확하므로 경고를 꺼버렸다. (이게 불편하다면 instanceof로 대체해도 괜찮을 듯.)
    • 만약 존재하지 않는 상수를 찾으려하면, valueOf()에 의해 InvalidArgumentException이 발생한다.
  • createExample
    • 응답 포맷을 결정하기 위한 메서드
    • ErrorResponse를 사용해서 실제로 반환할 인스턴스를 생성하고, Example 오브젝트에 넣어준다.

 

private static void addExamplesToResponses(ApiResponses responses, Map<Integer, List<ExampleHolder>> holders) {
    holders.forEach((httpStatus, exampleHolders) -> {
        Content content = new Content();
        MediaType mediaType = new MediaType();
        ApiResponse response = new ApiResponse();

        exampleHolders.forEach(holder -> mediaType.addExamples(holder.name(), holder.holder()));
        content.addMediaType("application/json", mediaType);
        response.setContent(content);

        responses.addApiResponse(String.valueOf(httpStatus), response);
    });
}

마지막으로 완성된 포맷들을 기반으로 ApiResponses에 값을 넣어주기만 하면 된다.

exampleHolers로 name과 응답 정보(response)를 mediaType에 추가해주고, content의 mediaType을 결정한다.

그리고 완성된 content를 ApiResponse에 담아서, ApiResponses에 추가해주면 끝난다.

 

📌 SwaggerConfig
public class SwaggerConfig {
    ...
    
    @Bean
    public OperationCustomizer customizer() {
        return (Operation operation, HandlerMethod handlerMethod) -> {
            ApiExceptionExplainParser.parse(operation, handlerMethod);
            return operation;
        };
    }
    
}

Swagger 설정을 해주고 있는 Config에서 OperationCustomizer 빈을 등록만 해주면 끝나는데,

만약 나처럼 GroupedOpenApi를 적용하고 있다면, 하나하나 .addOperationCustomizer()를 설정해주어야 한다.

 

@Bean
public GroupedOpenApi authApi() {
    String[] targets = {"..."};

    return GroupedOpenApi.builder()
            .packagesToScan(targets)
            .group("사용자 인증")
            .addOperationCustomizer(customizer())
            .build();
}

난 이걸 안 해서 왜 안 됨???? 이러고 한참을 고생했다..ㅎㅎ

 

📌 사용 방법
@ApiResponseExplanations(
    errors = {
        @ApiExceptionExplanation(name = "검증 실패", description = "인증코드 정보가 일치하지 않습니다.", value = PhoneVerificationErrorCode.class, constant = "IS_NOT_VALID_CODE"),
        @ApiExceptionExplanation(name = "검증 실패 - 인증번호 만료", value = PhoneVerificationErrorCode.class, constant = "EXPIRED_OR_INVALID_PHONE"),
        @ApiExceptionExplanation(name = "일반 회원가입 계정이 이미 존재함", value = UserErrorCode.class, constant = "ALREADY_SIGNUP")
    }
)

완벽하진 않지만, 적어도 기존 방법과 비교했을 때 훨씬 안정적이고, 문제가 발생해도 빠르게 찾아낼 수 있게 되었다.

 

하지만 에러로 사용하다가 더 이상 사용하지 않게 되었거나, 예외를 추가했을 때 이 변경 사항을 누락했는지에 대한 정보는 알 수 없는데 이건 Swagger로 처리할 수 있는 범위를 넘어선다.

그렇게 까지 하고 싶다면 Spring REST Docs를 사용하자.