avatar
hodumaru

Swagger 응답 커스텀을 통한 코드 간결화 + 프론트엔드 요구사항 해결

Swagger
Sep 25
·
10 min read

시작하며

‘포티(Photi)’는 개발 중간에 투입되어 진행 중인 프로젝트로, Swagger를 활용해 API 명세서를 작성하여 프론트엔드 개발자와 소통하고 있습니다. 이 글에서는 기존 코드에서 발생한 Swagger 적용 문제와 그 해결 방안을 공유하려고 합니다.

기존 코드의 문제점

class DefaultSingleResponse(
    code: String,
    message: String,
    val data: Any,
) : DefaultResponse(code, message) {

    companion object {
        fun toResponseEntity(successCode: SuccessCode, data: Any): ResponseEntity<DefaultSingleResponse> {
            return ResponseEntity.status(successCode.httpStatus)
                .body(DefaultSingleResponse(successCode.name, successCode.message, data))
        }

        fun toResponseEntity(headers: HttpHeaders, successCode: SuccessCode, data: Any):
                ResponseEntity<DefaultSingleResponse> {
            return ResponseEntity.status(successCode.httpStatus)
                .headers(headers)
                .body(DefaultSingleResponse(successCode.name, successCode.message, data))
        }
    }
}

기존 코드에서는 DefaultSingleResponse라는 공통 클래스를 만들어 놓고, 이를 컨트롤러에서 활용하는 방법을 사용하고 있었습니다.

@PostMapping("/api/users/register")
@Operation(summary = "회원가입")
@ApiResponses(
    value = [
        ApiResponse(responseCode = "201", description = "회원가입 성공"),
        ApiResponse(
            responseCode = "400",
            description = """
            1. 이메일 인증을 먼저 해주세요.
            2. 비밀번호와 비밀번호 재입력이 동일하지 않습니다.
            """
        ),
        ApiResponse(
            responseCode = "409",
            description = """
            1. 해당 이메일로 이미 가입된 회원이 있습니다.
            2. 사용 불가능한 아이디입니다.
            3. 이미 사용중인 아이디입니다.
            """
        ),
    ]
)
fun registerUser(@RequestBody @Valid request: UserRegisterRequest): ResponseEntity<DefaultSingleResponse> {
    val response = authService.registerUser(request.toServiceDto())
    val headers = jwtProvider.createToken(response.userId)

    return DefaultSingleResponse.toResponseEntity(headers, USER_REGISTERED, response)
}

이 코드에서는 ResponseEntity를 반환할 때 미리 정의해 둔 코드만 사용하면 되므로 구현하기 편리하다는 장점이 있었습니다.

하지만,,,

  1. 프론트엔드 개발자가 Swagger에서 응답 schema를 확인할 수 없는 문제가 있었습니다.

    • 프론트엔드 개발자 > “data 필드 안의 객체가 어떤 이름과 어떤 데이터 타입인지 확인하고 싶은데, 직접 API 요청을 하는 방법 대신, Swagger에서 바로 확인하는 방법은 없나요?”

      until-1502
  2. Swagger 응답 예시 문서화를 위해 코드가 너무 길어진다는 문제가 있었습니다.

    • 문서화를 위해 약 20줄의 코드 추가

      @PostMapping("/api/users/register")
      @Operation(summary = "회원가입")
      @ApiResponses(
          value = [
              ApiResponse(responseCode = "201", description = "회원가입 성공"),
              ApiResponse(
                  responseCode = "400",
                  description = """
                  1. 이메일 인증을 먼저 해주세요.
                  2. 비밀번호와 비밀번호 재입력이 동일하지 않습니다.
                  """
              ),
              ApiResponse(
                  responseCode = "409",
                  description = """
                  1. 해당 이메일로 이미 가입된 회원이 있습니다.
                  2. 사용 불가능한 아이디입니다.
                  3. 이미 사용중인 아이디입니다.
                  """
              ),
          ]
      )

개선 과정

1. Swagger 구조 분석

우선 Swagger 공식문서를 참고하여 response의 구조를 파악하였습니다.

  1. operation - 해당 경로에 액세스하는데 사용하는 HTTP 메소드 정의

  2. response - ‘상태 코드 + Response body에서 반환된 데이터’의 구조

  3. content - ‘Media type + 응답 페이로드’ 구조

  4. schema - 인라인으로 정의하거나, $ref로 참조 가능. Response body를 설명하는데 사용

paths:
  /users/{id}:
    get:
      tags:
        - Users
      summary: Gets a user by ID.
      responses:
        '200': # Response
          description: A user object.
          content: # Response body
            application/json: # Media type
              schema: # Schema
                $ref: '#/components/schemas/User'
                
components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          description: The user ID.
        username:
          type: string
          description: The user name.

구조를 확인해봤을 때, schema 부분을 수정하면 성공 응답을 커스텀 할 수 있겠다는 생각이 들었습니다.

에러 응답의 경우도 똑같이 하면 될까 생각했지만, key와 일치하는 여러 value가 있을 경우 가장 최근에 저장된 value로 덮어쓰는 Map의 특징 때문에, 1개의 상태코드에 여러 개의 상태 설명을 갖고 있는 현재 구조에는 적용할 수 없다는 판단을 했습니다.

responses:
  '200':
    description: A user object.
    content:
      application/json:
        schema:
          $ref: '#/components/schemas/User'
        examples: # Examples
          Jessica:
            value:
              id: 10
              name: Jessica Smith
          Ron:
            value:
              id: 20
              name: Ron Stewart

따라서 샘플 값을 생성하는 examples를 이용하여 에러 응답 커스텀을 해결하고자 하였습니다.

2. OperationCustomizer

이후 커스텀하는 방법을 공식 문서에서 찾으려고 했지만, 제가 못찾은건지 이 부분은 찾을 수 없었습니다. 😭

그래서 한 블로그를 통해 OperationCustomizer 로 Swagger를 커스텀 할 수 있다는 것을 알게되었습니다.

customize : OperationHandlerMethod를 파라미터로 받아, Operation 객체를 직접 수정하고 정의할 수 있는 메소드

@FunctionalInterface
public interface OperationCustomizer {
    Operation customize(Operation operation, HandlerMethod handlerMethod);
}

3. 성공, 에러 응답 커스텀

  1. 성공 응답 커스텀

    • 2XX 상태코드를 갖는 response를 필터링하여 (상태코드, response)를 key, value쌍으로 저장합니다.

    • 반복문으로 돌면서 schema를 수정합니다.

    • (프론트엔드 개발자와 상의한 결과 code, message, data 구조로 성공 응답을 반환하도록 하였습니다.)

    private fun addResponseBodySchemaExample(operation: Operation) {
        val filterKeys = operation.responses.filterKeys { it.startsWith("2") }
    
        filterKeys.forEach { (code, response) ->
            response.content.forEach { (_, mediaType) ->
                val data = mediaType.schema
                val schema = Schema<String>().apply {
                    addProperty("code", Schema<String>().example(code))
                    addProperty("message", Schema<String>().example("성공"))
                    addProperty("data", data)
                }
                mediaType.schema = schema
            }
        }
    }
  2. 에러 응답 커스텀

    @Target(AnnotationTarget.FUNCTION)
    @Retention(AnnotationRetention.RUNTIME)
    @ApiResponses(value = [])
    annotation class ApiErrorResponses(val exceptionCodes: Array<ExceptionCode>)
    • 컨트롤러에서 코드를 간결화하기 위해 에러 코드 배열을 파라미터로 갖는 ApiErrorResponses 어노테이션을 생성하였습니다.

    private fun addApiErrorResponses(operation: Operation, handlerMethod: HandlerMethod) {
        val apiErrorResponses = handlerMethod.method.getAnnotation(ApiErrorResponses::class.java)
    
        apiErrorResponses?.exceptionCodes?.forEach { exceptionCode ->
            val example = Example().apply {
                summary = exceptionCode.name
                value = mapOf("code" to exceptionCode.name, "message" to exceptionCode.message)
                description = exceptionCode.description
            }
            val apiResponse = operation.responses[exceptionCode.httpStatus.value().toString()]
            val mediaType = apiResponse?.content?.get("application/json") ?: MediaType()
            val examples = mediaType.examples?.toMutableMap() ?: mutableMapOf()
    
            examples[exceptionCode.name] = example
    
            operation.responses.addApiResponse(
                exceptionCode.httpStatus.value().toString(),
                ApiResponse().content(
                    Content().addMediaType("application/json", MediaType().examples(examples))
                )
            )
        }
    }
    • ApiErrorResponses 어노테이션을 가진 메소드만 적용됩니다.

    • exceptionCode의 반복문을 돌면서 상태코드와 메시지를 받아와 example에 저장하고, examples에 example을 추가합니다.

    • (프론트엔드 개발자와 상의한 결과 code, message 구조로 에러 응답을 반환하도록 하였습니다.)

  3. Configuration이 정의된 클래스에 Bean으로 등록

    • 스프링에서 해당 Bean 관리

    @Bean
    fun operationCustomizer(): OperationCustomizer {
        return OperationCustomizer { operation, handlerMethod ->
            addResponseBodySchemaExample(operation)
            addApiErrorResponses(operation, handlerMethod)
            operation
        }
    }

결과

  1. 컨트롤러 코드

    • 문서화를 위해 코드 약 20줄 → 코드 4줄로 감소

    @PostMapping("/api/users/register")
    @Operation(summary = "회원가입")
    @ApiResponse(responseCode = "201")
    @ApiErrorResponses([EMAIL_VALIDATION_INVALID, PASSWORD_MATCH_INVALID, EXISTING_USER, UNAVAILABLE_USERNAME, EXISTING_USERNAME])
    fun registerUser(@RequestBody @Valid request: UserRegisterRequest): ResponseEntity<UserRegisterResponse> {
        val response = authService.registerUser(request.toServiceDto())
        val headers = jwtProvider.createToken(response.userId)
    
        return ResponseEntity.status(CREATED).headers(headers).body(response)
    }
  2. 성공 응답

    • 프론트엔드 개발자의 요구사항 해결

      until-1501
  3. 에러 응답

    • 프론트엔드 개발자의 요구사항 해결

      until-1499

마치며

Swagger 구조를 분석하여 성공, 실패 응답 커스텀하고, 문서화를 진행하기 위해 길어졌던 컨트롤러 코드도 간결화 할 수 있었습니다.

구현하고 보니까 코틀린 언어의 장점을 잘 사용하지 못한 코드인 것 같고,, code, message 등 하드 코딩된 부분이 많아서 나중에 시간이 된다면 수정해보면 좋을 것 같습니다.

References


- 컬렉션 아티클