Apply OpenAPI and SwaggerUI to Phoenix Web

posted by donghyun

12 min read

태그

cover image

Documentation matters

문서화는 개발자에게 있어서 애증의 대상입니다. 문서화를 수행하는 개발자 입장에서는 문서화는 코드 작성보다 더 시간을 잡아먹기도 하는 업무거리이며, 자신한테는 단기간에 어떠한 보상을 주지도 않으며 누군가(보통은 매니저가) 문서화에 대한 독려 혹은 강요를 하지 않는 이상 스스로 동기부여를 할 수 밖에 없는 작업입니다. 다른 한편, 진행중인 프로젝트에 투입되어 새롭게 많은 것을 파악해야 한다거나, 갑자기 다른 사람의 작업을 인수인계 받거나 해서 유지보수를 해야하는 입장의 개발자는 잘 되어있는 문서화는 마치 사막의 오아시스 같은 느낌일 것입니다. 특히 서로 다른 영역 간에 소통을 할 땐 문서화는 필수적이라 할 수 있죠. 예를 들어 Mobile Client 개발자가 Backend Server 개발자와 협업을 하게 되면 주로 API 문서를 기준으로 개발하는 식입니다.

Docs as Code

이렇듯 중요한 문서화는 앞에서도 말했듯이 작성하는 사람 입장에서는 딱히 동기부여가 되지 않으면 성실히 수행하기가 어렵습니다. 심지어 그렇게 쉬운 작업도 아닙니다. 엉망으로 작성된 문서는 오히려 독자를 혼란스럽게 만들기도 합니다.

구글에서는 이렇게 중요한 문서화를 개선하기 위해 “문서자료를 코드처럼 취급”하여 엔지니어링 워크플로에 통합하는 시도가 가장 효과적이었다고 말합니다. 문서를 작성하고 관리하는 것을 코드를 작성하고 유지보수 하는 것과 동일하게 취급한 것입니다. 이로 인해 개발 과정에 문서화 작업이 자연스럽게 녹아들어갈 수 있었습니다.

이렇게 문서화를 코드처럼 관리하는 방법 중의 하나로, 코드에 주석을 달면 해당 주석 내용을 자동으로 문서화 시키는 방식이 있습니다. 주석이 달린 모듈, 함수 단위로 유지보수를 하면서 자연스럽게 주석을 수정하면 개발 과정에 문서화를 자연스럽게 녹여낼 수 있는 것이죠. 하지만 이 경우에도 만약 함수는 수정했는데 함수의 주석 내용을 수정하지 않거나 혹은 잘못된 문서화를 한 경우 이를 알아차리지 못한다면 효과가 크게 떨어지겠죠. 이를 방지하기 위해 문서화에도 테스트를 결합하는 방법들이 존재합니다. 예를 들어 Elixir의 문서화 도구인 ex_doc은 함수 주석의 예제 코드를 doctest 라는 매크로를 통해 테스트시 검증할 수 있는 기능을 제공합니다.

이렇게 코드와 문서화를 결합하는것이 잘 어울리는 영역이 또 HTTP API 문서화라 할 수 있죠. 문서화의 형식이 어느정도 정해져 있고, 코드와 중복되는 부분들이 많기 때문인데 덕분에 관련된 도구들도 많이 나와 있습니다. 그 중 가장 유명한 도구로 (구) Swagger, (현) OpenAPI Specification(OAS)이 있습니다.

OpenAPI Specification

OpenAPI 또는 OpenAPI Specification(OAS)는 2015년에 Swagger가 OpenAPI Initiative로 넘어가면서 바뀐 이름입니다. Swagger 2.0이 OpenAPI 3.0 으로 이어졌다고 보면 되는데, Swagger 2.0의 여러 문제점들을 해소하면서 API 문서화에 매우 편리한 도구 역할을 하고 있죠. 또한 OAS는 API 문서화 규약이라면 SwaggerUI 같은 도구를 통해 이를 웹 문서로 보여주며 즉석에서 API 테스트 요청 같은 기능까지 제공할 수 있습니다.

Web-Framework-Specific OAS Library

원래 OAS는 json이나 yaml파일 형식으로 스펙을 명시하게 되는데, 언어마다 이것을 특정 웹 프레임워크와 결합하여 코드와 스펙을 자연스럽게 결합하는 라이브러리들이 있습니다. 이렇게 코드와 API 문서화가 결합되었을 때의 장점은, 문서 관리가 코드 관리와 함께 진행될 수 있다는 것입니다. API 로직을 수정하는 브랜치, 커밋에서 문서화도 같이 수정할 수 있다는 뜻입니다.

이런 라이브러리는 예를 들어 스프링에는 SpringDoc이 있습니다. 다음과 같이 annotation을 이용해 기존 컨트롤러 메소드들에 스펙을 결합하는 형태를 볼 수 있습니다. 보통 라이브러리는 이러한 결합된 스펙을 json 형식 스펙으로 변환 해주는 방식입니다. 변환된 스펙만 있다면 swagger-ui로 API 문서를 보여주는것은 매우 간단하기 때문이죠.

@RestController
@RequestMapping("/api/auth")
@Tag(name = "auth", description = "the authentication api")
public class AuthController {

    ...

    @Operation(summary = "login", description = "login by id password")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "successful operation", 
                  content = @Content(schema = @Schema(implementation = LoginResponse.class))),
        @ApiResponse(responseCode = "400", description = "bad request operation", 
                  content = @Content(schema = @Schema(implementation = LoginResponse.class)))
    })
    @PostMapping(value = "login", produces = { "application/json" })
    public ResponseEntity<LoginResponse> login(LoginRequest loginRequest) {
        LoginResponse loginResponse = new LoginResponse("accessTokenValue", "refreshTokenValue");
        return ResponseEntity.ok().body(loginResponse);
    }
}

open_api_spex library of Elixir

Elixir에도 이러한 라이브러리가 있는데, 바로 open_api_spex 입니다. Elixir의 대표적 웹 프레임워크 Phoenix와 결합되는 라이브러리죠. 실제로 저희 팀에 제가 도입해 사용중인 라이브러리입니다. 해당 라이브러리에서는 같은 컨트롤러가 다음과 같이 표현될 수 있습니다.

defmodule MyAppWeb.AuthController do
  use MyAppWeb, :controller
  use OpenApiSpex.ControllerSpec

  alias MyAppWeb.Schemas.{LoginRequest, LoginResponse, ErrorResponse}

  tags ["auth"]

  operation :login,
    summary: "login",
    description: "login by id password",
    request_body: {"login params", "application/json", LoginRequest},
    responses: [
      ok: {"successful operation", "application/json", LoginResponse},
      bad_request: {"bad request operation", "application/json", ErrorResponse}
    ]

  def login(conn, _params) do
    ...
    render(conn, login_response)
  end
end

Elixir의 open_api_spex 라이브러리에서는 매크로를 이용해 컨트롤러와 스펙을 결합했습니다. tagsoperation 매크로는 모두 기존 피닉스 프로젝트에는 없던 문법으로, OpenAPI Spec을 나타내기 위해 라이브러리에서 정의한 문법입니다.

라이브러리의 사용법은 문서에서 자세히 살펴볼 수 있습니다. 매크로 이름이나 각 용어들은 대부분 OAS 공식 문서를 따르고 있습니다.

open_api_spex has two special abilities

open_api_spex는 이렇게 코드에 문서화를 결합하는 기능 뿐 아니라, 2가지 특별한 기능을 더 제공하고 있습니다. 한 가지는 스펙으로 정의된 request 파라미터에 대한 validation을 자동으로 수행하는 기능, 다른 하나는 테스트에 response 검증을 추가해 문서에 명시된 스펙이 실제 로직 결과와 일치하는지 검증하는 기능입니다. 이 두가지 기능을 자세히 살펴보겠습니다.

Ability of CastAndValidate plug

먼저 파라미터에 대한 validation 기능입니다. 앞에서 HTTP API 문서화는 코드와 결합하기 아주 좋다고 했습니다. 이것은 파라미터 검증에서 잘 드러나는데, OAS 형식으로 파라미터의 스펙을 정의하면, 이것이 swagger-ui 문서에 나타나는 동시에 실제 웹 요청을 처리할 때에 같은 조건을 가지고 validation을 수행할 수가 있습니다. 예를 들어 어떤 파라미터가 필수적인지, integer 파라미터의 범위는 어디서부터 어디인지, string 파라미터의 길이는 최대 얼마인지 등을 스펙에 명시하면, 그것이 곧 컨트롤러 action의 validation 체크 조건이 되는 식입니다. open_api_spex는 이러한 validation, 그리고 elixir같은 dynamic language에서 매우 유용한 casting 기능까지 제공하고 있습니다. casting은 쉽게 말해 각 파라미터 필드의 string, integer, boolean 등의 타입을 식별해서 변환해주는 것이죠. 여기에 map, array도 식별이 가능하고, custom한 casting을 넣는것도 가능하기에 더 강력하다고 할 수 있습니다.

한 번 예시를 보겠습니다. open_api_spex가 없는 상태에서 피닉스 프로젝트에는 보통 다음과 같이 각 컨트롤러의 로직 일부로서 파라미터 검증을 수행하곤 했습니다. 물론 공통적으로 쓰이는 함수들은 유틸 모듈에 모아놓고 재활용하는게 일반적입니다.

defmodule MyAppWeb.UsersController do
  use MyAppWeb, :controller

  def show(conn, %{"id" => id}) do
    with {:ok, id} <- validate_integer(id),
      ... do
      render(conn, %{user: user})
    else 
      err ->
        render_error(conn, err)
    end
  end
  
  # if `id` is a query parameter, this validation of missing parameters is needed
  def show(conn, _params) do
    render_error(conn, {:missing_parameter, "id is missing"})
  end

  defp validate_integer(id) do
    id = String.to_integer(id)
    if id > 0 do
      {:ok, id}
    else
      {:invalid_parameter, "#{id} must greater than 0"}
    end
  rescue 
    ArgumentError -> {:invalid_parameter, "#{id} is not valid integer"}
  end
end

이러한 방식이 간단한 파라미터 검증에는 딱히 불편하진 않지만, 복잡한 파라미터 검증을 수행하려면 점점 로직이 복잡해지고 관리가 어려워질 수 있습니다. 특히 API 문서와 정확히 일치하지 않게 될 가능성이 있습니다.

open_api_spexCastAndValidate plug를 사용하면 컨트롤러 action이 호출되기 전에 선언된 스펙을 가지고 파라미터 검증을 수행하고 만약 검증에 실패하는 경우 주어진 에러 핸들러를 호출하도록 할 수 있습니다. 예를 들면 다음과 같이 됩니다.

defmodule MyAppWeb.UsersController do
  use MyAppWeb, :controller
  use OpenApiSpex.ControllerSpec 

  alias MyAppWeb.Schemas.Commons.ID
  alias MyAppWeb.Schemas.UserResponse

  plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true

  operation :show,
    summary: "get a user",
    parameters: [
      id: [in: :path, description: "user ID", schema: ID]
    ],
    responses: %{
      200 => {"User", "application/json", UserResponse},
      400 -> OpenApiSpex.JsonErrorResponse.response()
    }

  def show(conn, %{id: id}) do
    with {:ok, user} <- ... do
      render(conn, %{user: user})
    else
      err ->
        render_error(conn, err)
    end
  end
end

공통으로 쓰이는 ID 타입 파라미터에 대해 스키마 컴포넌트로 정의했습니다. 이때 스키마에 명시된 스펙이 CastAndValidate plug에서 검증됩니다. 다음과 같이 스키마를 선언하면 복잡한 json body도 역시 명시된 스펙대로 검증이 가능합니다.

defmodule MyAppWeb.Schemas.UpdateUserParams do
  require OpenApiSpex
  alias OpenApiSpex.Schema

  OpenApiSpex.schema(%{
    title: "UpdateUserParams",
    required: [:name, :email],
    properties: %{
      id: %Schema{type: :integer}
      name: %Schema{type: :string, pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
      email: %Schema{type: :string, format: :email}
    }
  })
end

Ability of validation of response Spec

파라미터에 대한 검증을 봤으니 이제 API 응답에 대한 검증을 봅시다. 파라미터에 대한 검증과 달리, 응답값에 대한 검증은 실제 운영중인 환경에서 이뤄지지 않습니다. 각 API 요청마다 응답 스키마에 검증이 이뤄지는 것 자체가 불필요하다고 볼 수 있겠습니다. 대신 응답 스키마는 유닛 테스트에서 검증할 수 있습니다. 보통 피닉스에서 컨트롤러의 유닛 테스트를 작성하는 부분에, 다음과 같이 라이브러리에서 제공하는 스키마 검증 도구를 이용해 API의 응답이 스키마와 일치하는지 검증할 수 있습니다.

use MyAppWeb.ConnCase
import OpenApiSpex.TestAssertions

test "UserController produces a UsersResponse", %{conn: conn} do
  json =
    conn
    |> get(user_path(conn, :index))
    |> json_response(200)


  api_spec = MyAppWeb.ApiSpec.spec()
  assert_schema(json, "UsersResponse", api_spec)
end

이렇게 응답을 검증하는 테스트를 추가함으로써, 추후에 API의 변경이 일어난 경우에 스키마에 해당 변경사항을 누락한 경우 테스트에서 실패하게 함으로써 API 스펙 문서를 항상 최신으로 유지할 수 있도록 할 수 있습니다. 이것이 앞에서 말한 문서화와 테스트를 결합하여 최신의 문서화를 유지할 수 있도록 하는 방법의 실제 예시입니다.

위 두가지 특별한 능력이 open_api_spex가 매우 유용한 도구인 이유입니다. 파라미터 검증 기능을 통해 클라이언트에게 선언한 스펙을 문서를 통해 알려주는 동시에 잘못된 인자에 대해서는 자동으로 에러 응답을 내려주어 컨트롤러의 로직을 단순하게 만들어줍니다. 이것은 또한 실용주의 프로그래머의 ‘DRY’ 원칙과도 일맥상통합니다. 실용주의 프로그래머 책에서는 DRY 원칙을 다음과 같이 말하고 있습니다.

모든 지식은 시스템 내에서 단 한 번만, 애매하지 않고, 권위 있게 표현되어야 한다.

실용주의 프로그래머는 개정판에서 이 원칙에 대해 부연설명하는데, DRY는 단순히 코드의 중복만을 말하는것이 아니라고 합니다. 만약 코드의 어떤 측면 하나를 바꾸는데 여러 곳을 바꾸고 있다면 그것은 DRY하지 않다고 합니다. open_api_spex를 이용하면 API 스펙이라는 하나의 지식을 한 곳에 표현하고 이를 통해 해당 지식과 연관된 부분들을 한꺼번에 통제할 수 있게 됩니다.

Customize open_api_spex: schema definition

open_api_spex는 이렇게 DRY 원칙을 지킬 수 있는 훌륭한 도구입니다만, 오픈소스 라이브러리인 만큼 개선될 여지가 아직 많이 남아있습니다. 특히 문법이 마냥 편하지는 않다고 생각하는데, 라이브러리에서 스펙을 정의하는 몇가지 문법을 선택적으로 제공하고 있지만 좀 더 쉽고 직관적으로 쓰면 좋겠다는 생각을 계속 했습니다.

Elixir의 ectoabsinthe 는 각각 데이터베이스의 스키마, graphql의 스키마를 표현할때 유사한 문법을 제공하는데, 매우 직관적인 문법 표현을 가지고 있습니다. ecto의 스키마 표현은 다음과 같습니다.

defmodule User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :age, :integer
  end
end

이러한 문법을 비슷하게 쓸 수 있으면 좋겠다는 생각이 들어 직접 매크로를 통해 문법을 정의해보았습니다. 예를 들어 위에서 표현된 다음과 같은 스키마는,

defmodule MyAppWeb.Schemas.UpdateUserParams do
  require OpenApiSpex
  alias OpenApiSpex.Schema

  OpenApiSpex.schema(%{
    title: "UpdateUserParams",
    required: [:name, :email],
    properties: %{
      id: %Schema{type: :integer}
      name: %Schema{type: :string, pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
      email: %Schema{type: :string, format: :email}
    }
  })
end

다음과 같이 쓸 수 있도록 했습니다.

defmodule MyAppWeb.Schemas.UpdateUserParams do
  use OpenApiSpex.Schemax

  @required [:name, :email]
  schema "UpdateUserParams" do
    property :id, :integer
    property :name, :string, pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/
    property :email, :string, format: :email
  end
end

사실 보기에 따라 별 차이 없다고 느낄수도 있지만, 기존의 다른 라이브러리들의 스키마 문법들과 비슷하게 보여지게 함으로써 이질감을 없애고 가독성을 향상시키고자 한 목적에는 어느 정도 부합했다고 생각하고, 실제로 저희 팀에서 제가 정의한 문법대로 사용중인데 더 보기 좋다는 의견도 있었습니다.

이 밖에도 매크로를 이용해 몇 가지 저만의 사용법을 정의해 제가 속한 팀에 가이드하고 있습니다. 예를 들어 컨트롤러에 operation을 같이 보여주는것이 적절하게 로직이 분산되지 못한 레거시 컨트롤러들에서는 너무 긴 코드를 만들게 되어서, 가독성을 위해 컨트롤러와 operation 로직을 따로 나누게 하는 등의 조치를 취했습니다. 엘릭서의 장점은 이런 매크로를 적절히 사용하면 라이브러리의 독단적인 문법들을 얼마든지 원하는대로 바꿔서 사용할 수 있다는 점이죠.

Conclusion

이번 글에서는 개발자의 애증의 대상인 문서화를 도와주는 방법 중 하나인 Docs as Code와 그 예로 API 문서화를 코드에 결합하는 방법에 대해서 소개했습니다. OpenApi Spec같은 정형화된 스펙 도구와, 거기에 스키마 검증을 해주는 특별한 기능을 더한 open_api_spex 를 통해 개발자는 ‘DRY’ 하지 않고 효율적으로 API 개발을 할 수 있습니다. 또한 엘릭서의 편리한 매크로를 통해 라이브러리의 문법을 다른 스키마 문법들과 유사하도록 커스터마이징하여 사용성을 더욱 개선하였습니다.

open_api_spex는 실제로 제가 신중히 골라 팀에 도입한 라이브러리이고, 그 사용성을 더 개선하기 위해 여러가지 매크로를 고안하는 등 노력했습니다. 지금은 팀원들이 그 편리함과 유용함을 점점 인정하고 있는 분위기인데요, 오랫동안 고민하여 도입한 만큼 더 잘 사용하고 싶은 마음이 커서 앞으로도 이런저런 시도들을 해보고자 합니다.

‘구글 엔지니어는 이렇게 일한다’ 책에 따르면 책이 쓰여진 2010년대 후반까지의 문서화 실태는 마치 1980년대 후반의 소프트웨어 테스트의 상황과 비슷하다고 합니다.

문서자료 개선에 더 힘써야 한다는 사실은 모두가 알지만 조직 차원에서는 문서자료가 선물하는 핵심 이점이 무엇인지 이해하지 못하고 있는 것입니다.

전 문서화가 개발 프로세스에 포함되며 개발과 동시에 효율적으로 이루어질 수 있는 최적의 방법들이 점점 개발되고 있다고 믿고 있습니다. 이 글에서 다룬것은 그 중 하나이며 이는 실용주의 프로그래머가 되어가는 과정입니다.