프로젝트/장기 프로젝트

Swagger OpenAPI 3.0 에서 allOf 으로 확장해서 공통 응답 적용하기

Chipmunks 2024. 2. 4. 09:37
728x90

 

Node.js + Express + TypeScript 로 이뤄진 프로젝트에서 OpenAPI 3.0 버전의 Swagger 패키지를 쓰고 있다.

Swagger OpenAPI 3.0 은 다음 용도로 사용하고 있다.

 

  1. 클라이언트 OpenAPI Generator 을 통한 Type 코드 생성 ( 문서를 기준으로 Typescript 타입 객체가 생긴다..! )
  2. 클라이언트를 위한 API 문서 용도
  3. 서버 테스트용(Postman 대체)

개발적으로 1번을 위한 목적이 제일 크다.

OpenAPI 문서 파일을 공유하면 프론트엔드에 타입 파일을 만들어준다.

 

서버 문서를 보고 클라이언트에서 대응 가능한 타입을 일일이 만들어야 한다는 건... 여간 고통스러운 일이 아니다.

특히 타입스크립트는 컴파일을 위해 억지로라도(?) 타입을 만들어줘야 하기에, 그러한 작업은 필수에 가깝다.

급하다보면 당장 사용하지 않는 건 any 로 때울 수 있긴 하지만... 리팩터링 기술부채만 늘어난다.

훗날 실제로 사용해야 해서 컴파일러 통과하려면 울며겨자먹기로 타입을 만들어줘야 한다. 🤣

 

프론트엔드는 내 영역이 아니라 잘 모르지만, OpenAPI 에서 가져오는 건 아래 블로그를 참고하면 좋을 것 같다.

https://hmos.dev/how-to-use-oas-generator

package.json 에서 아래 스크립트 명령으로 사용하고 있다.

"scripts": {
  "openapi": "openapi-generator-cli generate -i ./api-doc.yaml -g typescript-axios -o ./openapi -c ./openapitools.json --skip-validate-spec",
}

 

결과물은 다음과 같다.

// 1. successResponse.ts

/**
 * Generated by orval v6.24.0 🍺
 * Do not edit manually.
 * Boospatch API Docs
 * This is a simple CRUD API application made with Express and documented with Swagger
 * OpenAPI spec version: 1.0.0
 */

export interface SuccessResponse {
  /** 성공 코드 */
  code?: string;
  /** 성공 메시지 */
  message?: string;
  /** 상태 코드 */
  status?: number;
}


// 2. JWTToken.ts

/**
 * Generated by orval v6.24.0 🍺
 * Do not edit manually.
 * Boospatch API Docs
 * This is a simple CRUD API application made with Express and documented with Swagger
 * OpenAPI spec version: 1.0.0
 */

export interface JWTToken {
  acessToken?: string;
  refreshToken?: string;
}

// 3. getLoginNaverCallback200.ts

/**
 * Generated by orval v6.24.0 🍺
 * Do not edit manually.
 * Boospatch API Docs
 * This is a simple CRUD API application made with Express and documented with Swagger
 * OpenAPI spec version: 1.0.0
 */
import type { SuccessResponse } from './successResponse';
import type { GetLoginNaverCallback200AllOf } from './getLoginNaverCallback200AllOf';

export type GetLoginNaverCallback200 = SuccessResponse &
  GetLoginNaverCallback200AllOf;
  
// 4. getLoginNaverCallback200AllOf.ts

/**
 * Generated by orval v6.24.0 🍺
 * Do not edit manually.
 * Boospatch API Docs
 * This is a simple CRUD API application made with Express and documented with Swagger
 * OpenAPI spec version: 1.0.0
 */
import type { JWTToken } from './jWTToken';

export type GetLoginNaverCallback200AllOf = {
  /** 발급한 서버 통신용 토큰 */
  data?: JWTToken;
};

 

위에서 눈 여겨 보아야할 점은 공통 응답인 successResponse.ts 과 getLoginNaverCallback200.ts 파일이다.

getLoginNaverCallback200.ts 에서 공통 응답과 데이터를 합친 것을 볼 수 있다.

이를 OpenAPI 3.0 에서 어떻게 표현하는지 간단하게 정리하고자 한다.

 

allOf 를 활용한 공통 응답 설정

OpenAPI 3.0 스펙 문서를 처음 보기도 하고, 뭐가 많아서 굉장히 어지러웠다.

며칠 간 졸음을 이기고, 삽질을 반복한 결과 어느정도는 이해해냈다.

베타 테스트를 하는 데 당장 급한 건 아니라, 우선순위가 낮았지만 그래도 언젠가 해야할 거라

서버 작업을 마치고 프론트 작업 남은 거 다 할 때 까지 보기로 했다.

금방 끝날 줄 알았는데, 며칠이나 걸릴 줄이야...

 

내가 OpenAPI 3.0 스펙을 보면서 알아낸 건 다음과 같다.

  • components 에서 schemas 설정으로 타입을 정의할 수 있다. 이는 어디서든 꺼내쓸 수 있다.
  • 루트 문서에도 작성할 수 있고, 여러 파일을 나눠서 작성할 수 있다.
  • $ref 으로 같은/다른 파일에서 스키마를 가져올 수 있다.
  • type : Object 일 때, 동일 레벨에서 properties 으로 Object 속성을 기입할 수 있다.
  • 여러 스키마를 적용하려면, allOf 으로 동일 레벨에서 여러 개로 묶어야 한다.

공통 응답을 만들 때의 핵심은 allOf 와 $ref 이다.

루트 문서의 컴포넌트에 공통 응답을 다음과 같이 만든다.

루트 문서는 openapi.yaml 으로 설정했고, 다른 파일은 login.yaml, logout.yaml, user.yaml 등으로 같은 폴더에 설정했다.

 

만들 공통 응답은 JSON 으로 아래와 같다.

{
  // Common Area
  "status": number,
  "code": string,
  "message": string,

  // Custom Area
  "data": Object
}

여기서 공통 응답 부분은 Common Area 다.

status, code, message 이다.

나중에 따로 각 API 에서 추가해야할 부분은 Custom Area 의 data 다.

 

루트 문서에 다음과 같이 공통 응답 구조를 만들었다.

// openapi.yaml

openapi: "3.0.0"
...
components:
  ...
  schemas:
    ...
    SuccessResponse:
      type: object
      properties:
        status:
          type: integer
          description: 상태 코드
        code:
          type: string
          description: 성공 코드
        message:
          type: string
          description: 성공 메시지
    ...
paths:
    ...

 

처음엔 data 까지 공통 응답으로 만들어서, 뭔가 이 필드와 매핑할 수 있는 방법이 있지 않을까 싶었으나... 그런 방법은 없더라.

properties 가 그 역할을 하는 앤가 싶었는데, 그냥 type 이 Object 일 때 속성들 만들어주는 애였다.

공통 응답 친구를 다른 파일에서 allOf 으로 확장해보자.

 

login.yaml 에서 먼저 로그인 API 에서 사용할 응답을 컴포넌트로 만든다.

// login.yaml

components:
  schemas:
    ...
    JWTToken:
      type: object
      properties:
        acessToken:
          type: string
        refreshToken:
          type: string
    ...

 

// login.yaml

/login/naver/callback:
  get:
    tags:
      - 인증
    summary: 네이버 토큰 인증 후 JWT 토큰 발행
    consumes:
      - application/json
    responses:
      "200":
        description: 토큰 발행
        content:
          application/json:
            schema:
              allOf:
                - $ref: "./openapi.yaml#/components/schemas/SuccessResponse"
                - type: object
                  properties:
                    data:
                      type: object
                      description: 발급한 서버 통신용 토큰
                      $ref: "#/components/schemas/JWTToken"
            example:
              {
                status: 200,
                code: "GET_TOKENS",
                message: "토큰을 발급합니다.",
                data:
                  {
                    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDY5NTUMjEsImRhdGEiOnsiaWQiOjR9LCJpYXQiOjE3MDY5NTIxMjF9.qtrIbD8OVc525dgYYcPlrzwrJ8zXCJv31m7Oo1eZdN",
                    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDcwMzg1MjEsImlhdC6MTcwNjk1MjEyMX0.KfKs-JgUCrziCaQZAQTR6pItuTp75VvLG1g7NWd_PP",
                  },
              }
              ...

 

공통 응답 구조를 확장하는 부분은 allOf 부터다.

allOf 으로 두 개의 컴포넌트를 하나로 합쳤다.

하나는 루트 문서에서 만든 SuccessResponse 를 $ref 으로 가져왔고,

하나는 type: Object 으로 data 필드에, 같은 파일에서 만든 JWTToken 컴포넌트를 가져왔다.

allOf 가 처음엔 배열 표시와 관련된 건 줄 알았는데, 스키마를 여러 개로 합칠 수 있게 해주는 역할이었다.

처음부터 간단할 것 같았고, 실제로 생각보다 간단했지만, 이를 알아내는 과정이... 압박;

 

swagger-cli 으로 문서 양식을 확인하고 잘 생성되는지 확인하자!

  ...
  "scripts": {
    ...
    "docs": "swagger-cli bundle ./src/swagger/openapi.yaml --outfile src/swagger.yaml --type yaml",
    "predev": "npm run docs"
  },
  ...
  
  
$ yarn docs
yarn run v1.22.21
$ swagger-cli bundle ./src/swagger/openapi.yaml --outfile src/swagger.yaml --type yaml
Created src/swagger.yaml from ./src/swagger/openapi.yaml
✨  Done in 0.22s.

 

참고로 predev 으로 개발 환경 실행하기 전에 동기화 되도록 설정했다.

 

실제 문서에선 다음과 같이 보인다.

후기

Swagger OpenAPI 3.0 문서를 직접 이렇게 다뤄보는 건 이번이 처음이다.

( 예전에 한 번 해봤긴 한데, 공부하고 딥하게 쓰지 않아서 유의미하게 기억나진 않는다. )

그 동안 문서 파일을 만들어 주는 건 프레임워크의 라이브러리단에서 해줬기 때문인데...

Spring 프레임워크와 NestJS 프레임워크, Python 계열 웹 프레임워크는 언어단의 데코레이터로 설정하면 자동으로 생성해준다.

 

잠깐... 가만 생각해보니 Node.js Express 할 때만 장인 정신으로 스펙 문서를 만든 것 같은데?

심지어 Ruby On Rails, Laravel 도 코드단으로 설정해준다. 🤔

path '/blogs/{id}' do

    get 'Retrieves a blog' do
      tags 'Blogs', 'Another Tag'
      produces 'application/json', 'application/xml'
      parameter name: :id, in: :path, type: :string
      request_body_example value: { some_field: 'Foo' }, name: 'basic', summary: 'Request example description'

      response '200', 'blog found' do
        schema type: :object,
          properties: {
            id: { type: :integer },
            title: { type: :string },
            content: { type: :string }
          },
          required: [ 'id', 'title', 'content' ]

        let(:id) { Blog.create(title: 'foo', content: 'bar').id }
        run_test!
      end

      response '404', 'blog not found' do
        let(:id) { 'invalid' }
        run_test!
      end

      response '406', 'unsupported accept header' do
        let(:'Accept') { 'application/foo' }
        run_test!
      end
    end
  end
end 

/**
* @OA\Info(
*     title="Example", version="0.1", description="Example API Documentation",
*     @OA\Contact(
*         email="example@test.com",
*         name="Example"
*     )
* )
*/
class Controller extends BaseController
{
    use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}

 

확실히 코드단에 많이 침투하는 게 흠이긴 하다.

그런 면에서 문서를 따로 만드는 것도 나쁘지 않은듯?

물론 러닝 커브가 꽤 있어서 겁나 귀찮긴 하다...

 

스프링에선 테스트 코드로 작성하면 adoc 문서를 주는 Rest Docs 같은 친구가 있긴 하지만

그 친구도 러닝커브와 드는 비용이 만만치가 않아보이더라.

OpenAPI 문서는 yaml 로만 작성하면 되는데... Spring Rest Docs 는 테스트도 챙기랴, 문서도 챙기랴 아주 정신이 없다.

문서를 위한 코드는 비즈니스 로직과 분리되긴 하지만 어째 더 길어지는 것 같은데!?

        // 테스트 코드 중 일부 문서 동기화 코드
        // 출처 : https://techblog.woowahan.com/2597/
        
        result.andExpect(status().isOk())
                .andDo(document("persons-update", // (4)
                        getDocumentRequest(),
                        getDocumentResponse(),
                        pathParameters(
                                parameterWithName("id").description("아이디")
                        ),
                        requestFields(
                                fieldWithPath("firstName").type(JsonFieldType.STRING).description("이름"),
                                fieldWithPath("lastName").type(JsonFieldType.STRING).description("성"),
                                fieldWithPath("birthDate").type(JsonFieldType.STRING).description("생년월일"),
                                fieldWithPath("hobby").type(JsonFieldType.STRING).description("취미")
                        ),
                        responseFields(
                                fieldWithPath("code").type(JsonFieldType.STRING).description("결과코드"),
                                fieldWithPath("message").type(JsonFieldType.STRING).description("결과메시지"),
                                fieldWithPath("data.person.id").type(JsonFieldType.NUMBER).description("아이디"),
                                fieldWithPath("data.person.firstName").type(JsonFieldType.STRING).description("이름"),
                                fieldWithPath("data.person.lastName").type(JsonFieldType.STRING).description("성"),
                                fieldWithPath("data.person.age").type(JsonFieldType.NUMBER).description("나이"),
                                fieldWithPath("data.person.birthDate").type(JsonFieldType.STRING).description("생년월일"),
                                fieldWithPath("data.person.gender").type(JsonFieldType.STRING).description("성별"),
                                fieldWithPath("data.person.hobby").type(JsonFieldType.STRING).description("취미")
                        )
                ));

 

나중에는 컴파일러 같은게 나와서, 데코레이터나 문서 작성안해도 정적 분석으로 알아서 만들어주면 좋겠다.

(동적 응답을 주는 API는..? 유감)

설명 같은 건 편집기로 편집하면 되니깐, 굳이 코드로 안적어도 되지 않을까 하는 생각도 있다.

지금까지 써본 Swagger 는 스프링이 그나마 쓰기 편한 것 같다. ( 간단한 설정 / DTO 인식 / 직관적인 어노테이션 )