Skip to main content

· 14 min read
BE_로지

안녕하세요, 집사의고민 백엔드 개발자 로지입니다. 이번 글에서는 저희 팀이 선택한 API 문서화 방법을 소개하고 어떻게 적용했는지 알려드리려고해요.


웹 애플리케이션 API를 구현하고 관리하게 되면 문서화에 어떤 기술을 활용할지 고민하게되는데요, 스프링 진영에는 다음 기술들을 주로 고려하는 것 같습니다.

  • Spring Restdocs
  • Swagger Generator

결론적으로, 이 둘 중 하나를 써도 되지만 저희 팀은 두 기술을 모두 활용해보기로 했습니다.

저희 팀은 먼저, 실제로 호출되는 API와 문서가 동기화되는 restdocs를 사용하기로 결정했습니다. 하지만 기존에 restdocs를 사용해본 경험이 있는 크루들은 restdocs의 asscidoc 이 swagger ui에 비해 화면/기능에 있어 실용성이 부족하다고 생각했습니다.

이 부분을 절충하기 위해, swagger generator에서 ui를 생성하는 기능만을 사용해 restdocs와 함께 사용해보는 것으로 결정하게 되었습니다.

필요한 도구들

  • restassured (인수테스트 도구): 저희 팀은 인수테스트를 수행해 restdocs를 발행합니다. 인수테스트에 restassured라는 http 클라이언트 테스트 도구를 사용합니다.
  • restdocs (문서화도구): restassured 테스트를 진행하며 API의 정보를 adoc으로 발행합니다.
  • restdocs-api-spec: restdocs에서 만들어준 adoc으로는 바로 swagger UI를 만들 수 없습니다. swagger ui를 발행할 수 있도록, adoc을 open api spec으로 변환해주는 도구입니다.
  • gradle-swagger-generator-plugin : adoc에서 변환된 open api spec으로부터 swagger ui를 만드는데 사용할 도구입니다.

눈치채셨겠지만, 테스트부터 ui를 만드는 데까지 사용하는 순서대로 도구를 소개해드렸어요.

팀 개발 환경

저희 팀은 다음 환경에서 작업을 진행했습니다!

  • java 17
  • Spring boot 3
  • gradle 8.2.1
  • intellij idea

흐름 보기

자세한 설정까지는 아니더라도, 코드로 흐름을 본다면 이 글을 읽기가 더 수월할 것 같아 먼저 코드 조각들을 보여드리려고 합니다.

RestAssured로 테스트하는 코드

@Test
void 파라미터에_이름을_넣고_요청한다() {
// given
var 요청_준비 = given(spec)
.contentType(JSON)
.filter(성공_응답_문서_만들기("hello-rosie-world-성공"));

// when
var 응답 = 요청_준비.when()
.pathParam("name", "김크루")
.get("/rosie/{name}");

// then
응답.then()
.assertThat().statusCode(OK.value());
}

RestAssured 의 given, when, then 메서드를 사용해서 테스트를 진행합니다. 요청_준비 변수를 만드는 마지막줄에 filter 메서드에 RestDocs가 문서를 만들수 있도록 다음과 같이 명시해줍니다.

private RestDocumentationFilter 성공_응답_문서_만들기(String 제목) {
return document(제목,
API_정보.responseSchema(성공_응답_형식),
pathParameters(parameterWithName("name").description("놀러와주신 분의 성함")),
responseFields(
fieldWithPath("messages").description("환영의 단어들"),
fieldWithPath("ps").description("로지가 추가로 전하는 말").optional()
));
}

이 테스트를 실행하면, restdocs가 adoc을 만들어줍니다.

open api 스펙으로 변환하기

만들어진 adoc 파일은 다음과 같은데요, swagger UI에 활용할수 있도록 openapi 형식으로 변환해줍니다. openapi3 태스크를 실행하면 open api 3.0 스펙으로 생성돼요.

이렇게 만든 문서는 다음 화면에 대응될 예정입니다 :D

build.gradle 설정

제가 사용한 그래들 스크립트입니다.

플러그인

plugins {
// 2. restdocs-api-spec 플러그인 추가
id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"

// 3. swagger-generator 플러그인 추가
id 'org.hidetake.swagger.generator' version '2.18.2'
}

dependencies

dependencies {
testImplementation 'io.rest-assured:rest-assured'
testImplementation 'org.springframework.restdocs:spring-restdocs-restassured'
testImplementation "com.epages:restdocs-api-spec-restassured:${restdocsApiSpecVersion}"
testImplementation "com.epages:restdocs-api-spec-mockmvc:${restdocsApiSpecVersion}"

swaggerUI 'org.webjars:swagger-ui:4.11.1'
}

플러그인/태스크 설정

  • openapi3 설정
  • 스웨거 ui 생성
  • bootJar 설정

openapi3

우선 restdocs-api-spec 의 설정을 해줄거예요.

openapi3 { // 4. open api 설정
setServer("http://localhost:8080") // 4-1. 요청 보낼 서버의 baseUrl
title = "로지 API Docs" // 4-2. 제목
description = "로지네 API 명세서" // 4-3. 설명
version = "0.0.1" // 4-4. api 문서의 버전
format = "yaml" // 4-5. openapi 형식을 저장할 형식 (yaml/json)
}

코드와 대응하는 화면을 보여드릴게요.

GenerateSwaggerUI 설정

그리고 나서, swaggerUI 를 만드는 태스크를 정의해줍니다. 자세한 설명은 주석을 참고해주세요.

tasks.withType(GenerateSwaggerUI) { // 5. swaggerUI 를 생성하는 task들은 전부
dependsOn 'openapi3' // openapi3 이 실행된 이후에 실행되도록 설정
}

swaggerSources { // 6. swagger ui 생성에 필요한 source 설정.
rosieProject { // ⭐️ rosieProject(이름은 프로젝트에 맞도록 변경) 라는 스웨거 ui의
setInputFile(file("build/api-spec/openapi3.yaml")) // 소스 위치를 설정. (현재는 default 위치)
}
}

// 7. rosieProject 에 대한 swagger ui를 생성하는 task.
generateSwaggerUIRosieProject {
doLast { // ui 파일들이 생성되고 난 뒤
copy { // 정적 리소스 디렉토리로 복사
from outputDir.toPath()
into "build/resources/main/static/docs" // jar 파일에 포한되려면 build/ 내의 위치로 설정해야합니다!
}
}
}

[2023.08.05 수정]

이전에 제가 실수로 src 하위에 (src/main/resources/static/docs) 파일을 복사하도록 했는데, 이렇게하면 jar 패키징에 생성한 ui파일들이 포함되지 않습니다~

bootJar 태스크 설정

마지막으로, jar 패키징에 swagger ui가 포함될 수 있도록 설정해줍니다.

bootJar {
dependsOn generateSwaggerUI
}

만약 위 스크립트로 안되면

bootJar {
dependsOn tasks.withType(GenerateSwaggerUI)
}

이렇게 해보시면 됩니다.

테스트 코드 작성

RequestSpecification 설정

RestAssured에서 Restdocs를 사용하기 위해서, 저는 다음과 같이 추상클래스에서 RequestSpecification 필드를 관리하게 되었습니다.

@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class AcceptanceTest {

protected RequestSpecification spec;

@LocalServerPort
private int port;

@BeforeEach
void setUp() {
RestAssured.port = port;
}

@BeforeEach
void setUpRestDocs(RestDocumentationContextProvider provider) {
RestAssuredOperationPreprocessorsConfigurer filter = documentationConfiguration(provider)
.operationPreprocessors();

this.spec = new RequestSpecBuilder()
.setPort(port)
.addFilter(filter)
.build();
}

}

restdocs가 설정된 RequestSpecification 사용하기

restAssured 사용시, specification을 명시하지 않는 given() 을 이용하면 다음과 같이 매번 새로운 Specification을 사용합니다.

우리가 위에서 설정해준 spec 변수를 이용하기 위해서는 다음 방법으로 restAsscured를 사용해야합니다.

(1) given(spec)
(2) given().spec(spec)
(3) given을 사용하지 않는경우: when().spec(spec)

테스트 별로 생성할 문서 정의하기

보통 restdocs를 asciidoc 으로 문서를 생성할 때는, 다음과 같이RestAssuredRestDocumentation.document 를 사용하는데요,

이 메서드를 restdocs-api-spec이 제공하는 wrapper 클래스의 메서드로 바꿔 사용합니다. (구현을 보면 많이 다를 건 없지만, 깃허브를 보면 openapi spec으로 변환할 때 편리함을 위해 제공한다고 합니다.)

private ResourceSnippetDetails API_정보 = resourceDetails()
.summary("로지월드 들어가기 API")
.description("로지 월드에 와서 환영을 받습니다.");

@Test
void 파라미터에_이름을_넣고_요청한다() {
// given
var 요청_준비 = given(spec)
.contentType(JSON)
.filter( // 여기입니다.
RestAssuredRestDocumentationWrapper.document("hello-rosie-world-성공", API_정보)
);

// when
var 응답 = 요청_준비.when()
.pathParam("name", "김크루")
.get("/rosie/{name}");

// then
응답.then()
.assertThat().statusCode(OK.value());
}

같은 API에 대한 여러 요청(응답)

같은 API에 대해서도, 여러 응답을 문서화해야하는데요. 이 작업은 별도의 작업이 필요하진 않습니다. 테스트별 document 메서드의 ResourceSnippetDetails 인자를 동일하게 설정해주면, 하나의 요청으로 간주하여 다음과같이 응답을 합쳐줍니다.

document() 사용법

restdocs-api-spec 모듈은 코틀린으로 작성되어서.. 참고하시며 작업하시길 바라구요. 어쨌든, document() 메서드에는 많은 인자들이 들어갈 수 있는데요. 각 인자가 어떤 요소에 대응되는지 정리해봤습니다.

identifier: rest docs 생성 파일 디렉토리명

document() 메서드의 첫번째 인자인데요, 이 아이는 restdocs에서 코드를 생성할 때 만들어지는 디렉토리의 이름이됩니다.

given(spec)
.contentType(JSON)
.filter(document("hello-rosie-world-성공", API_정보));

이렇게 설정하고, 테스트를 통과한다면 설정한 이름의 디렉토리에 adoc 파일들이 생성된 것을 확인할 수 있어요.

resourceDetails : API 정보

API 정보는 ResourceSnippetDetails 클래스 로 정의합니다.

ResourceSnippetDefails API_정보 = resourceDetails()
.summary("로지월드 들어가기 API")
.description("로지 월드에 와서 환영을 받습니다.")

given(spec)
.contentType(JSON)
.filter(document("hello-rosie-world-성공", API_정보));

이 코드는 다음 화면에 대응돼요.

requestProcessor , responseProcessor: 요청/응답에 적용할 프로세서

OperationProcessor 객체를 인자로 받습니다. OperationProcessor는 요청 또는 응답이 문서화되기 전에 적용하고 싶은 것을 정의할 수 있어요.

+) 프로세서는 다음과 같은 것들이 있어요: prettyPrint(), removeHeaders() 등..

snippeetFilters

Function<List<Snippet>, List<Snippet>> 인데요, 변수명과 자료형을 보아 스니펫 리스트를 필터링하는데 사용하는 것 같습니다.

그러나 정확히 어떤 목적을 위한 변수인지는 모르겠습니다! 아시는 분은 알려주세요~ 저는 쓸 필요를 못느껴서 Pass~

snippets : 요청/응답 예시 등 API 문서 조각에 쓰이는 모든 것

org.springframework.restdocs.snippet 에 정의돼있는 Snippet 객체를 가변 인자로 받아요.

저는 보통 다음과같이 요청 파라미터, 요청/응답 바디 Snippet을 활용하는 편입니다.

document("hello-rosie-world-성공",
API_정보.responseSchema(성공_응답_형식),
// 파라미터에 대한 snippet 생성
pathParameters(parameterWithName("name").description("놀러와주신 분의 성함")),
// 응답 바디에 대한 snippet 생성
responseFields(
fieldWithPath("messages").description("환영의 단어들"),
fieldWithPath("ps").description("로지가 추가로 전하는 말").optional()
));

이외에도 Snippet은 여러가지 종류가 있는데요, 스니펫을 만들기 위해서는 지정된 정적 팩토리 메서드를 사용해야해서, 문서화가 필요한 대상의 키워드(ex. 쿼리파라미터, 헤더)로 restdocs 공식문서에서 검색해보시면 쉽게 찾을 수 있습니다 :D

전체 코드

전체 코드는 깃허브에 올려뒀습니다. 글에서 조각으로 보면 복잡한 것 같기도한데, 한번에 보면 괜찮은 것 같아요.

https://github.com/kyY00n/restassured-restdocs-swaggerui


꽤 긴 튜토리얼이었는데요, 처음부터 꼼꼼히 읽기 보다는 초반에 소개드렸던 적용 흐름을 파악하시고, 디테일한 부분들은 공식문서와 함께 보는 것을 추천합니다 :D

여튼 읽어 주셔서 감사합니다. 올바르지 않거나 부족한 설명이 있다면 알려주세요! 여러분의 피드백을 격하게 환영합니다 :D

References

· 5 min read
BE_무민

안녕하세요, 집사의고민 백엔드 개발자 무민입니다. 이번 글에서는 저희 팀이 선택한 Merge 전략에 대해 소개하고 어떻게 적용했는지 알려드리려고 합니다.


이번에 집사의고민 팀에서 브랜치를 정하고 어떻게 병합할지에 대해 얘기가 나왔다. 스쿼시 어쩌구저쩌구 리베이스 어쩌구저쩌구... 이런 용어가 나오길래 아 아직도 깃허브에 대해 모르는 게 많구나 다시 한번 느끼고 Merge 전략에 대해 정리하고 가고자 한다.


Merge 전략

Merge Commit

Merge Commit은 일반적인 브랜치 병합 전략으로 두 개의 브랜치를 병합할 때 새로운 커밋을 생성한다. 그림으로 보면 다음과 같다.

  • Merge 된 커밋(#4)으로부터 뒤로 돌아가면서 부모를 모두 찾아 브랜치를 구성
  • #4는 부모로 #3와 main을 가짐
  • #3은 #2를, #2는 #1을, #1은 main을 부모로 가져
  • main -> #1 -> #2 -> #3 -> #4의 구조가 히스토리로 남게 됨

Merge Commit은 불필요한 commit message가 생기고 merge 순서와 commit 순서가 별도로 기록되어 history 관리가 어렵다는 단점이 있으나 머지 기록을 남긴다는 게 오히려 어떤 관리 포인트가(ex) n차 스프린트) 될 수도 있지 않을까 한다.

Squash and Merge

Squash and Merge 전략은 여러 개의 커밋을 하나로 압축하여 병합하는 전략으로 브랜치의 모든 커밋이 단일 커밋으로 압축되어 기존 브랜치에 병합된다. 그림으로 보면 다음과 같다.

  • 커밋 #1, #2, #3는 main을 부모로 가진 단일 커밋
  • 병합 후 작업한 브랜치의 커밋들은 메인 브랜치와 연관을 가지지 않는다.

main에선 기능별로 합쳐진 깔끔한 history를 가져 히스토리 관리는 쉬우나 rollback이 어렵다는 단점이 있다.

Rebase and Merge

Rebase and Merge는 현재 브랜치의 변경 내용을 다른 브랜치의 최신 상태에 병합하는 전략으로 Merge Commit과 달리 새로운 커밋을 생성하지 않는다. 그림으로 보면 다음과 같다.

  • Base를 main의 최신 커밋(#5, New Base)으로 다시 설정
  • 커밋 a, b, c의 관계를 그대로 유지한 채 메인 브랜치에 그대로 추가

Commit 순서가 아닌 Merge 순서대로 기록되어 다른 PR의 커밋 메시지와 섞이지 않아 rollback이 용이하며 commit 단위의 히스토리가 남겨지게 된다. 하지만, rebase에 익숙하지 않은 경우 어려움이 발생할 수 있다.

그래서 어떤 방식을 선택?

우선 우리팀은 main, develop, feature, hotfix 브랜치가 있는데 다음과 같은 병합 전략을 선택하기로 했다.

feature -> develop

  • Squash and Merge 방식
  • 지저분한 커밋 내역을 하나의 커밋으로 묶어어 develop으로 병합하면서 기능 단위로 커밋

develop -> main

  • 롤백할일이 생길 수도 있으니 Squash and Merge 방식은 제외
  • 기본적으로 Merge Commit 방식
    • 매번 스프린트마다 완성되면 main으로 머지해 배포할 계획이니 N차 스프린트 기록을 남기기 위해 Merge commit
  • 일반적으로 넣는 경우 말고 추가적으로 넣어야 될 때 따로 기록할 필요 없으니 Rebase and Merge 방식

References