Spring REST Docs 와 Swagger 를 사용하여 API 명세서 작성 자동화하기

jjunhwan.kim·2023년 10월 21일
2

스프링

목록 보기
5/10
post-thumbnail

개요

지난 두 글에서 각각 REST Docs와 Swagger를 사용하여 API 명세서를 만들어보았습니다. 각각의 기술은 둘 다 장단점이 있었습니다.

  • REST Docs
    • 컨트롤러 테스트 코드 작성을 통해 API 명세서를 작성합니다.
    • 장점으로는 테스트 코드를 작성하므로, 비즈니스 로직 코드와 명확하게 분리된다는 것 입니다.
    • 단점으로는 자동으로 생성해주는 adoc 파일 외에 사용자가 수동으로 만들어줘야 하는 adoc 문서가 있었습니다. 테스트 코드는 해당 API에 대해서만 adoc 문서를 생성해주므로 그 외에 목차라던가 전체적인 문서의 구조는 사용자가 직접 작성해야합니다.
    • 정적인 문서만 생성해주므로 Swagger 처럼 API를 호출할 수 있는 웹 기반 UI는 제공되지 않습니다.
  • Swagger
    • 컨트롤러 코드에 Swagger 어노테이션 코드를 추가하여 API 명세서를 작성합니다.
    • 장점으로는 API를 호출 할 수 있는 웹 기반 UI가 제공됩니다.
    • 컨트롤러 코드에 Swagger 어노테이션 코드가 추가되어 애플리케이션 코드와 API 명세를 위한 코드가 동시에 존재합니다.

이번 글에서는 각각의 장점을 결합할 수 있도록 REST Docs와 Swagger를 통합하여 사용해보겠습니다.

이전 글에서 Swagger의 의존성을 추가할 때 springdoc-openapi 를 추가하였습니다. 이 라이브러리가 애플리케이션을 분석하여 OpenAPI 3로 변환하고 Swagger UI가 이를 기반으로 UI를 구성한다고 설명하였습니다.

비슷한 방법으로 REST Docs의 테스트 코드의 결과를 OpenAPI 3로 변환하고 Swagger UI를 사용하면 REST Docs와 Swagger를 같이 사용할 수 있습니다.

프로젝트 생성

먼저 스프링 프로젝트를 생성합니다. https://start.spring.io 에서 아래와 같이 Spring Web, Lombok 의존성을 추가하여 생성합니다.
저번 예제와 동일하게 이번 예제에서도 API만 구현하므로 데이터베이스 관련 의존성은 추가하지 않았습니다.

restdocs-api-spec 의존성 추가

개요에서 말한 REST Docs의 테스트 코드의 결과를 OpenAPI 3로 변환하는 과정은 restdocs-api-spec 라이브러리를 사용하여 수행합니다.

깃허브의 README.md를 참고하였습니다.
https://github.com/ePages-de/restdocs-api-spec

Spring REST Docs의 출력 결과는 AsciiDoc 문서입니다. 이 라이브러리는 해당 출력 결과를 API specifications으로 변환합니다. API specifications 중에서 OpenAPI 3.0.1 json, yaml 포맷을 지원합니다.

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.1.5'
	id 'io.spring.dependency-management' version '1.1.3'
	id 'com.epages.restdocs-api-spec' version '0.18.4' // 1
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // 2
	testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.4' // 3
}

tasks.named('test') {
	useJUnitPlatform()
}

// 4
openapi3 {
	servers = [
			{ url = 'http://localhost:8080' },
			{ url = 'http://production-api-server-url.com' }
	]
	title = 'Post Service API'
	description = 'Post Service API description'
	version = '1.0.0'
	format = 'json'
}
  1. restdocs-api-spec Gradle 플러그인을 추가합니다.
  2. Spring REST Docs 의존성을 추가합니다.
  3. restdocs-api-spec-mockmvc 의존성을 추가합니다.
  4. 1에서 추가한 Gradle 플러그인을 설정합니다. 서버 주소, 제목, 설명, 버전, 출력 포맷등을 설정합니다.

API 작성

이전 Spring REST Docs 게시글에서 사용한 예제를 그대로 사용합니다.

공통 응답 클래스입니다.

응답 결과 열거형입니다.

게시글 생성, 조회, 업데이트, 삭제 API 입니다.

비즈니스 로직입니다. 일단 null을 리턴하도록 작성하였습니다.

API 테스트 코드 작성

이전 Spring REST Docs 게시글에서 사용한 테스트 코드를 그대로 사용합니다. 기존에 작성한 테스트 코드는 아래와 같습니다.

기존에 작성된 Spring REST Docs 테스트 코드를 사용할 때 주의해야 할 점이 있습니다. 바로 document 메서드 입니다. 기존에 사용된 MockMvcRestDocumentation.document 메서드를 MockMvcRestDocumentationWrapper.document 메서드로 교체해야합니다.

현재 코드 상단의 import static 구문을 보면 import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document 구문이 있습니다. 이 라인을 import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document 구문으로 교체해줍니다.

OpenAPI 3.0.1 Specification 생성

아래 명령어를 사용하여 openapi13 태스크를 실행시킵니다.

./gradlew openapi3

태스크가 성공적으로 수행되면 build/api-spec/openapi13.json 파일이 생성됩니다. 아래와 같이 생성됩니다.

{
  "openapi" : "3.0.1",
  "info" : {
    "title" : "Post Service API",
    "description" : "Post Service API description",
    "version" : "1.0.0"
  },
  "servers" : [ {
    "url" : "http://localhost:8080"
  }, {
    "url" : "http://production-api-server-url.com"
  } ],
  "tags" : [ ],
  "paths" : {
    "/posts" : {
      "get" : {
        "tags" : [ "posts" ],
        "operationId" : "get-posts",
        "parameters" : [ {
          "name" : "page",
          "in" : "query",
          "description" : "페이지 번호",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "size",
          "in" : "query",
          "description" : "한 페이지의 데이터 개수",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "sort",
          "in" : "query",
          "description" : "정렬 파라미터,오름차순 또는 내림차순 +\nex) +\ncreatedDate,asc(작성일 오름차순) +\ncreatedDate,desc(작성일 내림차순)",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        } ],
        "responses" : {
          "200" : {
            "description" : "200",
            "content" : {
              "application/json" : {
                "schema" : {
                  "$ref" : "#/components/schemas/posts-1056458637"
                },
                "examples" : {
                  "get-posts" : {
                    "value" : "{\n  \"code\" : 0,\n  \"message\" : \"성공\",\n  \"data\" : {\n    \"totalPages\" : 1,\n    \"pageNumber\" : 1,\n    \"pageSize\" : 10,\n    \"totalElements\" : 1,\n    \"posts\" : [ {\n      \"id\" : 1,\n      \"title\" : \"title1\",\n      \"author\" : \"author1\",\n      \"createdTime\" : \"2023-10-09 12:00:00\"\n    } ]\n  }\n}"
                  }
                }
              }
            }
          }
        }
      },
      "post" : {
        "tags" : [ "posts" ],
        "operationId" : "create-posts",
        "parameters" : [ {
          "name" : "Authorization",
          "in" : "header",
          "description" : "AccessToken",
          "required" : true,
          "schema" : {
            "type" : "string"
          },
          "example" : "Bearer {AccessToken}"
        } ],
        "requestBody" : {
          "content" : {
            "application/json" : {
              "schema" : {
                "$ref" : "#/components/schemas/posts-id27737830"
              },
              "examples" : {
                "create-posts" : {
                  "value" : "{\n  \"title\" : \"title\",\n  \"content\" : \"content\"\n}"
                }
              }
            }
          }
        },
        "responses" : {
          "200" : {
            "description" : "200",
            "content" : {
              "application/json" : {
                "schema" : {
                  "$ref" : "#/components/schemas/posts-1349496598"
                },
                "examples" : {
                  "create-posts" : {
                    "value" : "{\n  \"code\" : 0,\n  \"message\" : \"성공\",\n  \"data\" : {\n    \"id\" : 1\n  }\n}"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/posts/{id}" : {
      "get" : {
        "tags" : [ "posts" ],
        "operationId" : "get-post",
        "parameters" : [ {
          "name" : "id",
          "in" : "path",
          "description" : "게시글 ID",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        } ],
        "responses" : {
          "200" : {
            "description" : "200",
            "content" : {
              "application/json" : {
                "schema" : {
                  "$ref" : "#/components/schemas/posts-id1070811564"
                },
                "examples" : {
                  "get-post" : {
                    "value" : "{\n  \"code\" : 0,\n  \"message\" : \"성공\",\n  \"data\" : {\n    \"id\" : 1,\n    \"title\" : \"title\",\n    \"content\" : \"content\",\n    \"author\" : \"author\",\n    \"createdTime\" : \"2023-10-09 12:00:00\"\n  }\n}"
                  }
                }
              }
            }
          }
        }
      },
      "put" : {
        "tags" : [ "posts" ],
        "operationId" : "update-posts",
        "parameters" : [ {
          "name" : "id",
          "in" : "path",
          "description" : "게시글 ID",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "Authorization",
          "in" : "header",
          "description" : "AccessToken",
          "required" : true,
          "schema" : {
            "type" : "string"
          },
          "example" : "Bearer {AccessToken}"
        } ],
        "requestBody" : {
          "content" : {
            "application/json" : {
              "schema" : {
                "$ref" : "#/components/schemas/posts-id27737830"
              },
              "examples" : {
                "update-posts" : {
                  "value" : "{\n  \"title\" : \"title\",\n  \"content\" : \"content\"\n}"
                }
              }
            }
          }
        },
        "responses" : {
          "200" : {
            "description" : "200",
            "content" : {
              "application/json" : {
                "schema" : {
                  "$ref" : "#/components/schemas/posts-id1650436776"
                },
                "examples" : {
                  "update-posts" : {
                    "value" : "{\n  \"code\" : 0,\n  \"message\" : \"성공\",\n  \"data\" : null\n}"
                  }
                }
              }
            }
          }
        }
      },
      "delete" : {
        "tags" : [ "posts" ],
        "operationId" : "delete-posts",
        "parameters" : [ {
          "name" : "id",
          "in" : "path",
          "description" : "게시글 ID",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "Authorization",
          "in" : "header",
          "description" : "AccessToken",
          "required" : true,
          "schema" : {
            "type" : "string"
          },
          "example" : "Bearer {AccessToken}"
        } ],
        "responses" : {
          "200" : {
            "description" : "200",
            "content" : {
              "application/json" : {
                "schema" : {
                  "$ref" : "#/components/schemas/posts-id1650436776"
                },
                "examples" : {
                  "delete-posts" : {
                    "value" : "{\n  \"code\" : 0,\n  \"message\" : \"성공\",\n  \"data\" : null\n}"
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components" : {
    "schemas" : {
      "posts-id27737830" : {
        "required" : [ "content", "title" ],
        "type" : "object",
        "properties" : {
          "title" : {
            "type" : "string",
            "description" : "게시글 제목"
          },
          "content" : {
            "type" : "string",
            "description" : "게시글 내용"
          }
        }
      },
      "posts-1056458637" : {
        "required" : [ "code", "message" ],
        "type" : "object",
        "properties" : {
          "code" : {
            "type" : "number",
            "description" : "상태 코드"
          },
          "data" : {
            "required" : [ "pageNumber", "pageSize", "posts", "totalElements", "totalPages" ],
            "type" : "object",
            "properties" : {
              "pageNumber" : {
                "type" : "number",
                "description" : "현재 페이지 번호"
              },
              "totalPages" : {
                "type" : "number",
                "description" : "검색 페이지 수"
              },
              "pageSize" : {
                "type" : "number",
                "description" : "한 페이지의 데이터 개수"
              },
              "posts" : {
                "type" : "array",
                "description" : "게시글 목록",
                "items" : {
                  "required" : [ "author", "createdTime", "id", "title" ],
                  "type" : "object",
                  "properties" : {
                    "author" : {
                      "type" : "string",
                      "description" : "게시글 작성자"
                    },
                    "createdTime" : {
                      "type" : "string",
                      "description" : "게시글 생성일"
                    },
                    "id" : {
                      "type" : "number",
                      "description" : "게시글 ID"
                    },
                    "title" : {
                      "type" : "string",
                      "description" : "게시글 제목"
                    }
                  }
                }
              },
              "totalElements" : {
                "type" : "number",
                "description" : "검색 데이터 개수"
              }
            }
          },
          "message" : {
            "type" : "string",
            "description" : "상태 메세지"
          }
        }
      },
      "posts-id1650436776" : {
        "required" : [ "code", "data", "message" ],
        "type" : "object",
        "properties" : {
          "code" : {
            "type" : "number",
            "description" : "상태 코드"
          },
          "message" : {
            "type" : "string",
            "description" : "상태 메세지"
          }
        }
      },
      "posts-1349496598" : {
        "required" : [ "code", "message" ],
        "type" : "object",
        "properties" : {
          "code" : {
            "type" : "number",
            "description" : "상태 코드"
          },
          "data" : {
            "required" : [ "id" ],
            "type" : "object",
            "properties" : {
              "id" : {
                "type" : "number",
                "description" : "생성된 게시글 ID"
              }
            }
          },
          "message" : {
            "type" : "string",
            "description" : "상태 메세지"
          }
        }
      },
      "posts-id1070811564" : {
        "required" : [ "code", "message" ],
        "type" : "object",
        "properties" : {
          "code" : {
            "type" : "number",
            "description" : "상태 코드"
          },
          "data" : {
            "required" : [ "author", "content", "createdTime", "id", "title" ],
            "type" : "object",
            "properties" : {
              "author" : {
                "type" : "string",
                "description" : "게시글 작성자"
              },
              "createdTime" : {
                "type" : "string",
                "description" : "게시글 생성일"
              },
              "id" : {
                "type" : "number",
                "description" : "게시글 ID"
              },
              "title" : {
                "type" : "string",
                "description" : "게시글 제목"
              },
              "content" : {
                "type" : "string",
                "description" : "게시글 내용"
              }
            }
          },
          "message" : {
            "type" : "string",
            "description" : "상태 메세지"
          }
        }
      }
    }
  }
}

Swagger UI 연동

이번에는 Swagger UI를 스프링 애플리케이션에 통합하지 않고 도커를 통해 스프링 서버와 분리하여 실행해보겠습니다. 실제 서비스를 한다고 가정하면 Swagger UI는 따로 실행되는게 맞는 것 같습니다.

먼저 Swagger UI 서버는 따로 실행되므로 스프링 서버에 API 요청시 CORS 오류가 발생하므로 스프링 애플리케이션 코드에 CORS 설정을 추가합니다.

위에서 생성한 openapi13.json 파일을 적절한 디렉토리에 복사합니다.

아래 도커 명령어로 Swagger UI 컨테이너를 실행합니다.

docker run -d -p 80:8080 --name swagger -e SWAGGER_JSON=/tmp/openapi3.json -v {openapi13.json 파일이 위치한 디렉토리 경로}:/tmp swaggerapi/swagger-ui

http://localhost 에 접속하면 아래와 같이 Swagger UI가 출력됩니다.

Try it out 버튼을 클릭하여 실제 서버에 API 요청이 가능합니다. 아래처럼 응답을 확인할 수 있습니다.

예제 프로젝트

전체 코드는 https://github.com/nefertirii/apidoc 에서 확인하실 수 있습니다.

0개의 댓글