[RestAPI 개발기] 12. 문서 만들기

편준민's avatar
May 16, 2025
[RestAPI 개발기] 12. 문서 만들기

1️⃣ Swagger

💡
REST API 문서를 자동으로 생성하고 테스트할 수 있게 해주는 도구이다.

장점

  • 자동 문서화가 간편함 : 애노테이션만 추가하면 API 문서가 자동으로 생성되어, 설정이 간단하고 빠르게 사용할 수 있습니다.
  • UI 기반 테스트 지원 : Swagger UI를 통해 웹 브라우저에서 API를 직접 실행하고 응답을 확인할 수 있어, 별도로 Postman 같은 도구 없이도 테스트가 가능합니다.

단점

  • 코드 가독성 저하 : API 문서를 만들기 위한 애노테이션이나 설정 코드가 실제 비즈니스 로직에 함께 작성되기 때문에, 소스 코드가 복잡해지고 가독성이 떨어질 수 있습니다.
  • 의존성 충돌 위험 : 다양한 Swagger 관련 라이브러리가 존재하며, 일부는 Spring Boot 버전과 호환되지 않아 의존성 충돌 문제가 발생할 수 있습니다.
  • 실제 동작과 문서 불일치 가능성 : 문서가 수동으로 작성되거나 자동 갱신되지 않으면, API 동작과 문서 내용이 달라질 위험이 있습니다.

1. 라이브러리 추가

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0

2. UserRequset

💡
나의 request 코드를 GPT에게 Schema를 추가 해달라고 요청
 
package shop.mtcoding.blog.user; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Data; public class UserRequest { @Data public static class UpdateDTO { @Schema(description = "비밀번호 (4~20자)", example = "1234") @Size(min = 4, max = 20) private String password; @Schema(description = "이메일 주소", example = "user@example.com") @Pattern(regexp = "^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", message = "이메일 형식으로 적어주세요") private String email; } @Data public static class JoinDTO { @Schema(description = "유저네임 (2~20자, 특수문자/한글 불가)", example = "metacoding") @Pattern(regexp = "^[a-zA-Z0-9]{2,20}$", message = "유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다") private String username; @Schema(description = "비밀번호 (4~20자)", example = "1234") @Size(min = 4, max = 20) private String password; @Schema(description = "이메일 주소", example = "user@example.com") @Pattern(regexp = "^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", message = "이메일 형식으로 적어주세요") private String email; public User toEntity() { return User.builder() .username(username) .password(password) .email(email) .build(); } } @Data public static class LoginDTO { @Schema(description = "유저네임 (2~20자)", example = "metacoding") @Pattern(regexp = "^[a-zA-Z0-9]{2,20}$", message = "유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다") private String username; @Schema(description = "비밀번호 (4~20자)", example = "1234") @Size(min = 4, max = 20) private String password; @Schema(description = "자동 로그인 여부 (체크시 'on')", example = "on", nullable = true) private String rememberMe; // check되면 on, 안되면 null } }

3. UserController

💡
UserController도 마찬가지로 Operation 추가 해달라고 요청
package shop.mtcoding.blog.user; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.*; import shop.mtcoding.blog._core.util.Resp; import java.util.Map; @Slf4j @Tag(name = "User API", description = "회원가입, 로그인, 회원정보 수정 등 사용자 관련 API") @RequiredArgsConstructor @RestController // json만 리턴!! public class UserController { private final UserService userService; private final HttpSession session; @Operation(summary = "회원정보 수정", description = "로그인한 사용자의 비밀번호와 이메일을 수정합니다.") @PutMapping("/s/api/user") public ResponseEntity<?> update(@Valid @RequestBody UserRequest.UpdateDTO reqDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); UserResponse.DTO respDTO = userService.회원정보수정(reqDTO, sessionUser.getId()); return Resp.ok(respDTO); } @Operation(summary = "유저네임 중복체크", description = "해당 유저네임이 이미 사용 중인지 확인합니다.") @GetMapping("/api/check-username-available/{username}") public ResponseEntity<?> checkUsernameAvailable( @Parameter(description = "확인할 유저네임", example = "metacoding") @PathVariable("username") String username) { Map<String, Object> respDTO = userService.유저네임중복체크(username); return Resp.ok(respDTO); } @Operation(summary = "회원가입", description = "유저네임, 비밀번호, 이메일을 받아 회원가입을 진행합니다.") @PostMapping("/join") public ResponseEntity<?> join( @Valid @RequestBody UserRequest.JoinDTO reqDTO, Errors errors, HttpServletResponse response, HttpServletRequest request) { log.debug(reqDTO.toString()); log.trace("트레이스ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); log.debug("디버그---------"); log.info("인포ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); log.warn("워닝ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); log.error("에러ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); String hello = request.getHeader("X-Key"); System.out.println("X-good : " + hello); response.setHeader("Authorization", "jooho"); UserResponse.DTO respDTO = userService.회원가입(reqDTO); return Resp.ok(respDTO); } @Operation(summary = "로그인", description = "유저네임과 비밀번호를 이용하여 로그인합니다.") @PostMapping("/login") public ResponseEntity<?> login( @Valid @RequestBody UserRequest.LoginDTO loginDTO, Errors errors, HttpServletResponse response) { UserResponse.TokenDTO respDTO = userService.로그인(loginDTO); return Resp.ok(respDTO); } // AccessToken만으로는 Logout 을 할 수 없다. }

4. 오류 발생

notion image

✅ 오류 해결

💡
원인
NumberUtils.isCreatable(String) 메서드는 Apache Commons Lang 3.5 이상에 포함되어 있습니다.
이 에러가 발생하는 일반적인 이유는:
  • 프로젝트에서 사용하는 Apache Commons Lang의 버전이 너무 낮아서 isCreatable() 메서드가 포함되어 있지 않다.
  • 의존성 충돌(Dependency Conflict) 로 인해 구버전이 사용되고 있다 (예: 다른 라이브러리에서 lang3의 구버전을 의존하고 있을 경우).

해결방법 Apache Commons Lang 버전 3.0에서 3.12.0으로 변경
implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0'

2️⃣ Swagger 문서

💡
  • summary에 적혀 있는 값이 적힘
notion image
 
notion image

1. 문서 확인 중 Response 응답이 안보임

notion image

✅ Response 응답 설정

📌
UserResponse
  • UserResponse에도 Schema 추가
package shop.mtcoding.blog.user; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; public class UserResponse { @Data public static class TokenDTO { @Schema(description = "엑세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI...") private String accessToken; @Schema(description = "리프레시 토큰", example = "dGhpc0lzUmVmcmVzaFRva2Vu") private String refreshToken; @Builder public TokenDTO(String accessToken, String refreshToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; } } @Data public static class DTO { @Schema(description = "유저 ID", example = "1") private Integer id; @Schema(description = "유저 이름", example = "cos") private String username; @Schema(description = "이메일 주소", example = "cos@nate.com") private String email; @Schema(description = "생성일시", example = "2024-05-16T10:00:00") private String createdAt; public DTO(User user) { this.id = user.getId(); this.username = user.getUsername(); this.email = user.getEmail(); this.createdAt = user.getCreatedAt().toString(); } } }
UserController
  • UserController에서 아래 코드를 각 함수마다 입력. GPT에게 요청 시 일괄 수정 가능
@ApiResponse(responseCode = "200", description = "회원정보 수정 성공", content = @Content(schema = @Schema(implementation = UserResponse.DTO.class)))
package shop.mtcoding.blog.user; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.*; import shop.mtcoding.blog._core.util.Resp; import java.util.Map; @Slf4j @Tag(name = "User API", description = "회원가입, 로그인, 회원정보 수정 등 사용자 관련 API") @RequiredArgsConstructor @RestController public class UserController { private final UserService userService; private final HttpSession session; @Operation(summary = "회원정보 수정", description = "로그인한 사용자의 비밀번호와 이메일을 수정합니다.") @ApiResponse(responseCode = "200", description = "회원정보 수정 성공", content = @Content(schema = @Schema(implementation = UserResponse.DTO.class))) @PutMapping("/s/api/user") public ResponseEntity<?> update(@Valid @RequestBody UserRequest.UpdateDTO reqDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); UserResponse.DTO respDTO = userService.회원정보수정(reqDTO, sessionUser.getId()); return Resp.ok(respDTO); } @Operation(summary = "유저네임 중복체크", description = "해당 유저네임이 이미 사용 중인지 확인합니다.") @ApiResponse(responseCode = "200", description = "중복 여부 반환", content = @Content(schema = @Schema(implementation = Map.class))) @GetMapping("/api/check-username-available/{username}") public ResponseEntity<?> checkUsernameAvailable( @Parameter(description = "확인할 유저네임", example = "metacoding") @PathVariable("username") String username) { Map<String, Object> respDTO = userService.유저네임중복체크(username); return Resp.ok(respDTO); } @Operation(summary = "회원가입", description = "유저네임, 비밀번호, 이메일을 받아 회원가입을 진행합니다.") @ApiResponse(responseCode = "200", description = "회원가입 성공", content = @Content(schema = @Schema(implementation = UserResponse.DTO.class))) @PostMapping("/join") public ResponseEntity<?> join( @Valid @RequestBody UserRequest.JoinDTO reqDTO, Errors errors, HttpServletResponse response, HttpServletRequest request) { log.debug(reqDTO.toString()); log.trace("트레이스ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); log.debug("디버그---------"); log.info("인포ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); log.warn("워닝ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); log.error("에러ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ"); String hello = request.getHeader("X-Key"); System.out.println("X-good : " + hello); response.setHeader("Authorization", "jooho"); UserResponse.DTO respDTO = userService.회원가입(reqDTO); return Resp.ok(respDTO); } @Operation(summary = "로그인", description = "유저네임과 비밀번호를 이용하여 로그인합니다.") @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = UserResponse.TokenDTO.class))) @PostMapping("/login") public ResponseEntity<?> login( @Valid @RequestBody UserRequest.LoginDTO loginDTO, Errors errors, HttpServletResponse response) { UserResponse.TokenDTO respDTO = userService.로그인(loginDTO); return Resp.ok(respDTO); } }
Response 응답 확인
notion image

3️⃣ RestDocs

💡

장점

  • 신뢰성 있는 문서화 : 테스트 결과를 기반으로 하기 때문에 실제 응답과 일치하는 문서 생성이 가능.
  • 소스 코드 분리 : 문서화 코드가 테스트 코드에 작성되므로, 기존 비즈니스 로직 코드의 가독성을 해치지 않음.

단점

  • 통합 테스트 코드 작성 필수 : 문서 생성을 위해 반드시 통합 테스트 코드를 작성해야 함.
  • 테스트 기반 문서화 학습 필요 : 처음 사용하는 경우에는 테스트 방식에 대한 이해가 필요함.

1. 라이브러리 추가

testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

2. plugin 추가

id 'org.asciidoctor.jvm.convert' version '3.3.2'

3. Dependency 추가

💡
기존에 있는 tasks.named('test') 지우고 아래 코드 붙여넣기
ext { set('snippetsDir', file("build/generated-snippets")) } tasks.named('test') { outputs.dir snippetsDir useJUnitPlatform() } tasks.named('asciidoctor') { inputs.dir snippetsDir dependsOn test } bootJar { dependsOn asciidoctor copy { // src/docs/asciidoc == from 경로 from "${asciidoctor.outputDir}" into 'src/main/resources/static/docs' // /static/docs로 복사! } }

4. 추상 클래스 생성

💡
Test에 추상클래스 MyRestDoc 생성
package shop.mtcoding.blog; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.operation.preprocess.Preprocessors; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; // 4. 문서만들기 @AutoConfigureRestDocs(uriScheme = "http", uriHost = "localhost", uriPort = 8080) // build폴더에 adoc파일 생성 @AutoConfigureMockMvc @ExtendWith({SpringExtension.class, RestDocumentationExtension.class}) public abstract class MyRestDoc { @Autowired protected MockMvc mvc; protected RestDocumentationResultHandler document; @BeforeEach public void documentSetUp() { this.document = MockMvcRestDocumentation.document("{class-name}/{method-name}", Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), Preprocessors.preprocessResponse(Preprocessors.prettyPrint())); } }

5. 통합테스트 수정

💡
  • uriScheme : 서버 프로토콜 - https 서버면 “https” 로 변경
  • uriHost : 실제 Host 주소 - 네이버일 경우 “naver” 로 변경
  • uriPort : 서버 Port 번호
    • # 상속하기 public class UserControllerTest extends MyRestDoc { # 추상클래스에서 mvc를 추가 하여 통합테스트에서 mvc 삭제 @Autowired private MockMvc mvc; # 통합테스트에 코드를 검증한 것을 HTML 파일로 출력하기 위해 코드 추가 # 가장 아래에 actions.andDo 추가 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.title").value("제1")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.content").value("내1")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isPublic").value(true)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.userId").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); // 화면에 출력하지말고 document에 새로운 파일을 만들어서 그 안에 넣어라 actions.andDo(MockMvcResultHandlers.print()).andDo(document);

6. 폴더 만들기

💡
src 폴더 아래에 docs/asciidoc/api.adoc 파일을 만든다.
notion image
이후 코드 작성
  • api.adoc에 넣기
ifndef::snippets[] :snippets: ./build/generated-snippets endif::[] :user: user-controller-test :board: board-controller-test :reply: reply-controller-test :love: love-controller-test = REST API :toc: left :toclevels: 2 :source-highlighter: highlightjs == 회원 === 1. 회원가입 유저네임 중복 실패 ===== Request Example include::{snippets}/{user}/join_username_uk_fail_test/http-request.adoc[] ===== Response Example include::{snippets}/{user}/join_username_uk_fail_test/http-response.adoc[] === 2. 회원가입 ===== Request Example include::{snippets}/{user}/join_test/http-request.adoc[] ===== Response Example include::{snippets}/{user}/join_test/http-response.adoc[] === 3. 로그인 ===== Request Example include::{snippets}/{user}/login_test/http-request.adoc[] ===== Response Example include::{snippets}/{user}/login_test/http-response.adoc[] === 4. 회원정보수정 ===== Request Example include::{snippets}/{user}/update_test/http-request.adoc[] ===== Response Example include::{snippets}/{user}/update_test/http-response.adoc[] === 4. 회원정보수정 ===== Request Example include::{snippets}/{user}/update_test/http-request.adoc[] ===== Response Example include::{snippets}/{user}/update_test/http-response.adoc[] === 5. 유저네임중복확인 ===== Request Example include::{snippets}/{user}/check_username_available_test/http-request.adoc[] ===== Response Example include::{snippets}/{user}/check_username_available_test/http-response.adoc[] == 게시글 === 1. 게시글등록 ===== Request Example include::{snippets}/{board}/list_test/http-request.adoc[] ===== Response Example include::{snippets}/{board}/list_test/http-response.adoc[]

7. 제대로 문서가 만들어지는 실행!

💡
제대로 문서가 만들어지는 Build를 하기 전에 코드를 수정하면서 통합테스트가 먼저 잘 실행되는지 확인해야한다.
  • Build방법
/gradlew clean build
이후에 파일들이 제대로 생성 되었는지 확인
notion image
notion image

4️⃣ RestDocs 문서

notion image

Dependency 설정

💡
Dependency 설정이 복잡해 보여서 GPT와 대화한 내용
ext { set('snippetsDir', file("build/generated-snippets")) } tasks.named('test') { outputs.dir snippetsDir useJUnitPlatform() } tasks.named('asciidoctor') { inputs.dir snippetsDir dependsOn test } bootJar { dependsOn asciidoctor copy { // src/docs/asciidoc == from 경로 from "${asciidoctor.outputDir}" into 'src/main/resources/static/docs' // /static/docs로 복사! } }
build를 하면 test코드가 실행되는데 test코드가 실행된 결과를 build/generated-snippets 폴더에 파일로 저장하고 그 결과들을 static/docs 폴더에 html로 만들어줘 이 설정하는 거자나? 근데 왤케 복잡하게 되어있어?!
GPT
응 정확히 이해했어! 너가 요약한 게 딱 맞아: test를 실행하면 build/generated-snippets 폴더에 REST Docs 스니펫(.adoc 조각들)이 생기고, asciidoctor가 이걸 기반으로 HTML 문서를 생성하고, bootJar 시점에 이 HTML 결과물을 src/main/resources/static/docs로 복사해서 최종적으로 http://localhost:8080/docs/index.html 같은 경로에서 접근 가능하게 만드는 흐름이야. 근데 너 말대로 복잡해 보여도 이유는 있어. 요건 "빌드 프로세스 자동화"를 위해 필요한 의식 같은 거야 😅
요소
역할
snippetsDir
테스트에서 생성될 스니펫의 경로 정의
test.outputs.dir
test 태스크가 이 디렉토리를 출력물로 생성함을 명시
asciidoctor.inputs.dir
AsciiDoctor가 어떤 파일을 기반으로 HTML을 생성할지를 정의
bootJar.copy
HTML 결과물을 JAR에 포함시키기 위한 복사 작업
그럼 이걸 더 단순하게 만들 수 없냐? 가능은 해. 예를 들면, 아래처럼 Gradle Kotlin DSL로 쓰면 좀 더 명시적이면서 깔끔해:
val snippetsDir by extra { file("build/generated-snippets") } tasks.test { outputs.dir(snippetsDir) useJUnitPlatform() } tasks.asciidoctor { inputs.dir(snippetsDir) dependsOn(tasks.test) } tasks.bootJar { dependsOn(tasks.asciidoctor) doLast { copy { from("build/docs/asciidoc") into("src/main/resources/static/docs") } } }
결론 ✅ 네 말이 맞아. 결국 "test → snippets → asciidoctor → 복사" 이게 핵심인데, DSL 구조랑 자동화를 위한 규칙들 때문에 좀 장황하게 보이는 거야. 하지만 한 번 설정해두면, 문서 자동생성은 진짜 강력한 무기지. 필요하면 내가 이거 zip으로 자동 설정되게 구성해서 보내줄 수도 있어. 말만 해!
그리고 bootJar 시점도 별로야!! 그냥 서버실행하면 이자나
GPT
맞아 ㅋㅋ 너 말 진짜 핵심 찔렀다. bootJar 시점에 복사하는 건 "배포용 jar 만들 때 문서 포함시키려고" 그런 건데, 우린 그냥 서버 실행(bootRun)만 해도 문서 나오면 되잖아? 서버 실행할 때 docs/index.html 바로 확인 가능한 게 더 현실적이고 빠르지. 굳이 jar 만들 때까지 기다릴 이유가 없어. ✅ 그러면 실시간 미리보기 + 서버실행에 맞는 구조는? 1. 테스트 → snippets 생성 2. asciidoctor → HTML 생성 3. HTML을 static/docs로 바로 복사 (서버 실행하면 바로 열람 가능) ✨ 이렇게 바꿔 쓰면 돼 (Gradle 기준)
ext { snippetsDir = file('build/generated-snippets') docsOutputDir = file('src/main/resources/static/docs') // 여기에 바로 복사 } // 테스트하면 스니펫 생성 tasks.named('test') { outputs.dir snippetsDir useJUnitPlatform() } // 문서 빌드 시 스니펫 의존 tasks.named('asciidoctor') { inputs.dir snippetsDir dependsOn test } // 문서 결과물을 바로 static/docs로 복사 tasks.register('copyDocs', Copy) { dependsOn tasks.named('asciidoctor') from file("build/docs/asciidoc") into docsOutputDir } // bootRun 전에 문서 복사 tasks.named('bootRun') { dependsOn tasks.named('copyDocs') }
📌 효과 ./gradlew bootRun만 하면: 테스트 돌고, 문서 뽑고, static/docs로 복사되고, 서버 켜지면 /docs/index.html 바로 열람 가능
Share article

YunSeolAn