[REST API] 22. API Document

문정준's avatar
May 16, 2025
[REST API] 22. API Document

1. Swagger

  • Annotation을 이용해서 코드를 문서화시킬 수 있는 라이브러리
 
  • build.gradle
    • commons-lang3 버전도 호환될 수 있도록 일치
dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0' }
 
  • UserRequest
    • Schema (컬럼 설명) 추가
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 } }
 
  • UserResponse
    • Schema(컬럼 설명) 추가
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
    • Tag : 해당 컨트롤러 기능 설명
    • Operation : 해당 메서드 기능 설명
    • APIResponse : 리턴되는 값 매핑 및 구조 설명 (Resp에 묶여있어서 자동 매핑이 불가)
@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); } }
 

결과 확인

  • localhost:8080/swagger-ui/index.html 접속
notion image
 
  • 해당 기능 설명 및 Request, Response 정보 추가
notion image
 

장점

  • 간편하게 설정 가능
  • 별도의 추가 설정이 필요 없음
 

단점

  • 코드의 가독성을 많이 해침
  • example로 값을 적기 때문에 예시 값의 신뢰성이 떨어짐
 
 

2. REST Docs

  • 테스트 코드에 @AutoConfigureRestDocs 어노테이션을 추가하여 문서를 만들 수 있는 라이브러리
 
  • build.gradle
    • commons-lang3 버전도 호환될 수 있도록 일치
    • resource 폴더 내에 static/docs 폴더 생성 후 adoc 파일 하나 만들어둘 것 (html 변환용)
plugins { id 'org.asciidoctor.jvm.convert' version '3.3.2' } dependencies { testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0' } ext { snippetsDir = file('build/generated-snippets') } test { useJUnitPlatform() outputs.dir snippetsDir systemProperty 'file.encoding', 'UTF-8' } asciidoctor { inputs.dir snippetsDir dependsOn test outputDir = file("${buildDir}/docs/asciidoc") } task copyDocs(type: Copy) { dependsOn asciidoctor from("${asciidoctor.outputDir}") into("src/main/resources/static/docs") }
 
  • api.adoc
    • resource/static/docs 안에 미리 만들어둘 것
    • :별칭: → 폴더명
    • include::{snippets}/{별칭}/메서드명/http-request.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[]
 
  • MyRestDoc : 문서화용 추상 메서드
    • Autowired를 통해 기존의 필터를 사용 가능
    • AutoConfigureRestDocs : mvc에서 uri 정보를 추가하여 REST Docs 관련 설정을 자동 구성
@AutoConfigureRestDocs(uriScheme = "http", uriHost = "localhost", uriPort = 8080) @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())); } }
 
  • Test 클래스 : 문서화 기능 추가
    • 중복되는 Annotation 삭제
    • action.andDo 메서드로 문서화 기능 추가 (*.adoc 파일 생성)
// 컨트롤러 통합 테스트 @Transactional @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) // MOCK -> 가짜 환경을 만들어 필요한 의존관계를 다 메모리에 올려서 테스트 public class UserControllerTest extends MyRestDoc { @Autowired private ObjectMapper om; // json <-> java Object 변환 해주는 객체. IoC에 objectMapper가 이미 떠있음 private String accessToken; @BeforeEach public void setUp() { // 테스트 시작 전에 실행할 코드 System.out.println("setUp"); User ssar = User.builder() .id(1) .username("ssar") .build(); accessToken = JwtUtil.create(ssar); } @AfterEach public void tearDown() { // 끝나고 나서 마무리 함수 // 테스트 후 정리할 코드 System.out.println("tearDown"); } @Test public void join_username_uk_fail_test() throws Exception { // 이 메서드를 호출한 주체에게 예외 위임 -> 지금은 jvm 이다 // given -> 가짜 데이터 UserRequest.JoinDTO reqDTO = new UserRequest.JoinDTO(); reqDTO.setEmail("ssar@nate.com"); reqDTO.setPassword("1234"); reqDTO.setUsername("ssar"); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // {"username":"haha","password":"1234","email":"haha@nate.com"} // when -> 테스트 실행 ResultActions actions = mvc.perform( // 주소가 틀리면 터지고, json 아닌거 넣으면 터지고, 타입이 달라도 터지고. 따라서 미리 터진다고 알려줌 MockMvcRequestBuilders .post("/join") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) ); // eye -> 결과 눈으로 검증 String responseBody = actions.andReturn().getResponse().getContentAsString(); //System.out.println(responseBody); // {"status":200,"msg":"성공","body":{"id":4,"username":"haha","email":"haha@nate.com","createdAt":"2025-05-13 11:45:23.604577"}} // then -> 결과를 코드로 검증 // json의 최상위 객체를 $ 표기한다 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(400)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("중복된 유저네임이 존재합니다")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body").value(Matchers.nullValue())); actions.andDo(MockMvcResultHandlers.print()).andDo(document); } @Test public void join_test() throws Exception { // 이 메서드를 호출한 주체에게 예외 위임 -> 지금은 jvm 이다 // given -> 가짜 데이터 UserRequest.JoinDTO reqDTO = new UserRequest.JoinDTO(); reqDTO.setEmail("haha@nate.com"); reqDTO.setPassword("1234"); reqDTO.setUsername("haha"); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // {"username":"haha","password":"1234","email":"haha@nate.com"} // when -> 테스트 실행 ResultActions actions = mvc.perform( // 주소가 틀리면 터지고, json 아닌거 넣으면 터지고, 타입이 달라도 터지고. 따라서 미리 터진다고 알려줌 MockMvcRequestBuilders .post("/join") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) ); // eye -> 결과 눈으로 검증 String responseBody = actions.andReturn().getResponse().getContentAsString(); //System.out.println(responseBody); // {"status":200,"msg":"성공","body":{"id":4,"username":"haha","email":"haha@nate.com","createdAt":"2025-05-13 11:45:23.604577"}} // then -> 결과를 코드로 검증 // json의 최상위 객체를 $ 표기한다 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(4)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.username").value("haha")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.email").value("haha@nate.com")); actions.andDo(MockMvcResultHandlers.print()).andDo(document); } @Test public void login_test() throws Exception { // given UserRequest.LoginDTO reqDTO = new UserRequest.LoginDTO(); reqDTO.setUsername("ssar"); reqDTO.setPassword("1234"); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .post("/login") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then (jwt 길이만 검증) -> 길이 변환 가능. 패턴만 확인 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.accessToken", matchesPattern("^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+$"))); actions.andDo(MockMvcResultHandlers.print()).andDo(document); } @Test public void update_test() throws Exception { // given UserRequest.UpdateDTO reqDTO = new UserRequest.UpdateDTO(); reqDTO.setEmail("ssar@gmail.com"); reqDTO.setPassword("1234"); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .put("/s/api/user") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then 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.username").value("ssar")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.email").value("ssar@gmail.com")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); actions.andDo(MockMvcResultHandlers.print()).andDo(document); } @Test public void check_username_available_test() throws Exception { // given String username = "ssar"; // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .get("/api/check-username-available/{username}", username) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.available").value(false)); actions.andDo(MockMvcResultHandlers.print()).andDo(document); } }
 

결과 확인

  • 테스팅을 통해 빌드를 수행하면 api 문서가 생성됨
테스트를 통해 만들어진 snippets
테스트를 통해 만들어진 snippets
 
snippets를 모아 생성된 api.html
snippets를 모아 생성된 api.html
  • api.html
    • 일부만 작성됨
 
  • 서버 실행 후 /docs/api.html로 접속하면 문서가 화면에 출력됨
notion image
 

장점

  • 기존 코드를 수정하지 않음
  • URI, Body, Header 등의 요구 사항을 자세하게 작성 가능
  • 통합 테스트를 통해 통과된 코드만 사용하므로 예시 값도 신뢰할 수 있음
 

단점

  • 통합 테스트 코드가 있어야 사용 가능
  • 초기 설정이 많아 복잡함
Share article

sxias