Swagger에서 공통 응답 예쁘게 버무리기

민찬기·2025년 4월 9일

Spring

목록 보기
3/3
post-thumbnail

최근 공식앱을 만들면서 이루고 싶은 개인적 목표가 있습니다.

클라이언트가 API 문서에 대한 질문을 하지 않을 수 있도록 상세하고 예쁘게 만들기

그간 회사 업무에서든, 사이드 프로젝트에서든 API 문서의 디테일 부족으로 클라이언트와 불필요한 의사소통이 많았습니다. 이번에는 불필요한 의사소통이 없도록 문서를 최대한 꼼꼼하고 자세히 쓰는 것을 목표로 했습니다.

다만, 문서화를 하면서 불필요하게 엑셀이나 노션 및 컨플루언스에 문서화를 하는 일은 피하고자 했습니다. 귀찮음이 x2000이 될 뿐만 아니라, 장기적으로 지속되지 못할 것이라는 생각이 들었습니다.

그래서 최대한 Swagger에서 모든 내용을 전달할 수 있는 방법을 찾기 시작했습니다.

프로젝트 전역에서 사용되는 공통 에러

현재 서버에서는 절대 다수의 API에서 공통적으로 내려줄 수 있는 몇 가지 에러가 있습니다. 가령, 토큰이 만료되었다든가, 서버에서 예상치 못한 에러가 발생하여 500 응답이 나가야 하는 경우입니다.

이런 공통 에러들도 클라이언트에서 핸들링 하기 위해선 공통 에러로 문서에 포함이 되어야 합니다.

하지만 공통 에러를 모든 API에 어노테이션으로 복붙할 수는 없습니다. 크게 두 가지 문제가 있습니다.

  1. 응답 케이스 하나 설정하는데도 아래와 같이 긴 코드가 작성되는데, 공통 에러를 모든 API에 복붙할 수는 없는 노릇이다.
  2. 그러다가 공통 에러를 수정해야 하는 상황이 오면... 재앙이다. ☠️

일단 구조를 살펴보자

공식 문서에 따르면 Swagger 문서 커스터마이징을 위해선 OpenApiCustomizer를 이용하라고 안내되어 있습니다. 이에 따라 OpenApiCustomizer를 이용하여 공통 에러 처리를 해보겠습니다.

OpenApiCustomizer의 구조

우선 저희는 응답을 커스터마이징 해야 하므로, OpenApiCustomizer에서 응답을 추출하기 위한 구조만 살펴보겠습니다.

public interface OpenApiCustomizer {
    void customise(OpenAPI openApi);
}

public class OpenAPI {
    private Paths paths = null; 
}

Paths

public class Paths extends LinkedHashMap<String, PathItem>

Paths는 같은 URL로 구성된 API들을 묶어줍니다.
가령 게시물을 등록하는 API의 엔드포인트가 [POST] /posts, 게시물 목록을 조회하는 API의 엔드포인트가 [GET] /posts라면 두 API는 같은 Paths에 묶입니다.

PathItem

public class PathItem {
	private Operation get = null;
    private Operation put = null;
    private Operation post = null;
    // ...
}

PathItem은 같은 URL 안에서, HTTP Method별로 분리됩니다.
PathItem의 멤버 변수가 각 Method로 선언되어 있는 것을 보면 쉽게 이해할 수 있을 거 같습니다.

Operation

public class Operation {
	private ApiResponses responses = null;
}

Operation은 각 API의 정보를 담고 있습니다. 여기에는 응답 정보를 담고있는 ApiResponses 객체가 있습니다. 스웨거로 치면, 각 색상 블럭을 의미한다고 보면 될 거 같습니다.

결과적으로 공통 응답을 예쁘게 잡아 넣기 위해서는 ApiResponses 객체를 커스터마이징 해야 합니다.

ApiResponses 구조 살펴보기

우리가 커스터마이징 해야할 ApiResponses 객체의 구조를 살펴보겠습니다.

ApiResponses

public class ApiResponses extends LinkedHashMap<String, ApiResponse>

ApiResponses는 코드별로 응답을 묶어놓은 객체입니다.
아래 사진 기준으로는 200이 key가 되고, 옆에 있는 응답에 대한 명세가 ApiResponse 입니다.

ApiResponse

public class ApiResponse {
	private Content content = null;
}

다른 필드들도 보면 재밌는 내용이 많지만, 응답에 대한 필드만 살펴보겠습니다.
ApiResponse는 각 응답 코드에 대한 명세입니다.
여기서는 Content 필드를 담고 있습니다.

Content

public class Content extends LinkedHashMap<String, MediaType>

Content에서는 MediaType별 응답을 노출합니다.
특별한 경우가 아니면 application/json 형태를 반환하므로, 단일 key가 됩니다.

MediaType

public class MediaType {
    private Map<String, Example> examples = null;
    private Object example = null;
}

특별한 경우가 아니면 MediaType은 applicati/on/json 형태이므로, 사실상 '하나의 응답 코드에 대해 하나 혹은 여러개의 Example 객체를 가지는 구조'가 됩니다.

우리의 공통 응답은 각 응답 코드별로 Example 객체가 되어 추가됩니다.

커스터마이징 하기

이제 구조는 다 살펴봤으니, 진짜 커스터마이징을 해보겠습니다.
실제 코드는 다음을 참조하세요.

SwaggerConfig.kt - Swagger를 설정해요
SwaggerUtils.kt - 공통에러 처리를 위해 OpenApiCustomizer를 처리해요
SwaggerCommonResponses.kt - 공통 에러를 선언해뒀어요

ApiResponses 순회

val customizeResponses: OpenApiCustomizer =
    OpenApiCustomizer { openApi ->
        openApi.paths.values.forEach { item ->
            item.readOperations().forEach { operation ->
            	operation.responses.addCommonResponse()
	        }
        }
    }
    
private fun ApiResponses.addCommonResponse()

우선, 각 단계를 차례로 순환하며, operation 내부의 ApiResponses까지 내려갑니다.
ApiResponses는 확장 함수로 선언된 addCommonResponse()를 호출하여, 커스터마이징 작업을 진행합니다.

케이스 분리

addCommonResponse()에서는 HTTP Status 응답별로 몇 가지 케이스로 나누어 처리를 합니다.

API 응답 / 공통 에러공통 에러 O공통 에러 X
API 응답 O12
API 응답 X3X

가령, 1번의 경우 API Swagger 설정에 404 응답에 대한 정의가 있는데, 공통 에러에도 404 응답에 대한 정의가 있는 것이죠.

반면 3번의 경우 API 설정에는 401 응답에 대한 정의가 없는데, 공통 에러에만 401 응답에 대한 정의가 있는 경우입니다.

각 경우에 대해 적절한 처리가 필요합니다.

private fun ApiResponses.addCommonResponse() {
    for ((status, response) in this) {
        if (response.content == null) {
            continue
        }

        val commonResponse = SwaggerCommonResponses.v[status]
        when (commonResponse == null) {
            true -> 2false -> 1}
    }

    for ((status, response) in SwaggerCommonResponses.v) {
        if (this[status] == null) {
            3}
    }
}

1번 케이스

기존에 존재하는 API 응답에 공통 에러를 추가해주어야 합니다.

이때 주의할 점이 있는데, MediaType에는 examplesexample이 존재합니다. 어떤 기준에 따라 나뉘는지 파악은 못했으나, 둘 중 어떤 변수에 저장되어 있다 하더라도 잘 처리될 수 있도록 만들어야 합니다.

또한, API 명세에 MediaType을 application/json으로 명시해놓지 않은 경우도 있을 수 있으니, 이를 정확하게 명시한 Content()를 새로 만들어 ApiResponse의 기존 Content를 대체합니다.

private fun ApiResponses.addCommonResponse() {
    for ((status, response) in this) {
        val commonResponse = SwaggerCommonResponses.v[status]
        when (commonResponse == null) {
            true -> 2// 1번 케이스
            false -> response.combineContent(commonResponse)
        }
        
        // ...
    }
}

private fun ApiResponse.combineContent(response: ApiResponse): ApiResponse {
    val examples = mutableMapOf<String, Example>()
    this.content.values.forEach { value ->
        if (value.examples != null) {
            examples.putAll(value.examples)
        }
    }
    response.content.values.forEach {
        if (it.examples != null) {
            examples.putAll(it.examples)
        }
    }

    this.content = Content().apply {
        addMediaType(
            "application/json",
            MediaType().apply { setExamples(examples) }
        )
    }
    
    return this
}

2번 케이스

API 응답에는 존재하는데 공통 에러는 존재하지 않는 경우입니다.
이 경우, MediaType만 application/json으로 노출될 수 있도록 변경하는 작업을 진행해줍니다.
로직은 1번에서 examplesexample를 처리한 것과 동일합니다.

private fun ApiResponses.addCommonResponse() {
    for ((status, response) in this) {
		// ...

        val commonResponse = SwaggerCommonResponses.v[status]
        when (commonResponse == null) {
            true -> {
                val examples = mutableMapOf<String, Example>().apply {
                    response.content.values.forEach { mediaType ->
                        mediaType.examples?.let { putAll(it) }
                        mediaType.example?.let {
                            put("성공", Example().apply { value = it })
                        }
                    }
                }

                val content = Content().apply {
                    addMediaType(
                        "application/json",
                        MediaType().apply { setExamples(examples) }
                    )
                }

                response.content = content
            }
            false -> response.combineContent(commonResponse)
        }
    }
}

3번 케이스

이 경우는 ApiResponse를 새로 만들어야 합니다.
기존에 공통 에러를 ApiResponse 객체로 만들어 놓았기 때문에, 간단히 추가 작업만 진행하면 됩니다.

private fun ApiResponses.addCommonResponse() {
    //...

    for ((status, response) in SwaggerCommonResponses.v) {
        if (this[status] == null) {
            this.addApiResponse(status, response)
        }
    }
}

커스텀 된 응답을 추가하기

그룹화 된 명세 페이지를 제공하는지 여부에 따라 추가하는 방식이 다릅니다.
단일 명세 페이지를 사용하는 경우 OpenApiCustomizer를 빈으로 등록하면 됩니다.
그룹 명세 페이지를 사용하는 경우, 각 GroupedOpenApiCustomizer를 등록해야 합니다.

다만, 그룹 명세 페이지의 경우, 페이지별로 추가할 공통 응답을 달리 설정할 수도 있습니다.

단일 명세 페이지

@Bean
fun customiseResponses(): OpenApiCustomizer =
    OpenApiCustomizer { openApi ->
        openApi.paths.values.forEach { item ->
            item.readOperations().forEach { operation ->
                val responses = operation.responses
                SwaggerCommonResponses.values.forEach { value ->
                    responses.addApiResponse(value.code, value.apiResponse)
                }
            }
        }
    }

그룹화 된 명세 페이지

@Bean
fun appApi(): GroupedOpenApi =
    GroupedOpenApi
        .builder()
        .group("App API")
        .pathsToExclude("/admin/**")
        .addOpenApiCustomizer(customizeResponses)
        .build()

결과

위와 같은 설정을 마치고 나면, 모든 API에서 아래와 같은 응답이 고정적으로 노출됩니다.
다만, 조금 더 디테일한 설정을 위해서는 경로별로, 메서드별로 다른 조건이 추가될 수 있을 거 같습니다.

명세를 잘 쓰려다보니 Swagger 라이브러리까지 까보게 되었는데, 조금 더 잘 써서 더 많은 내용 공유할 수 있도록 해보겠습니다.

profile
https://github.com/devmizz

0개의 댓글