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. 오류 발생

✅ 오류 해결
원인
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에 적혀 있는 값이 적힘


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

✅ 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 응답 확인

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 파일을 만든다.

이후 코드 작성
- 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
이후에 파일들이 제대로 생성 되었는지 확인


4️⃣ RestDocs 문서

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