보통 Spring 에서 문서화를 할 때, Swagger
와 Restdocs
를 사용하게 됩니다. Swagger는 마치 Postman처럼(직접 요청하듯이) API를 테스트해 볼 수 있는 화면을 제공하여 동작 테스트하는 용도에 조금 더 특화되어있습니다.
그렇다면 Swagger는 문서화도 되고 테스트도 가능하니 더 좋은 것이 아닌가라고 생각할 수 있습니다. 하지만 Swagger를 사용할 경우 명확한 단점이 존재합니다.
로직에 애노테이션을 통해 명세를 작성하게 되는데 지속적으로 사용하게 된다면 명세를 위한 코드들이 많이 붙게되어 전체적으로 가독성이 떨어진다.
테스트 기반이 아니기에 문서가 100% 정확하다고 확신할 수 없다.
모든 오류에 대한 여러 가지 응답을 문서화할 수 없다.
아래 코드는 swagger를 적용한 코드인데 해당 애노테이션들이 계속 붙게되면서 코드가 가독성이 매우 떨어집니다
// swagger 예시
public class SignupForm {
@ApiModelProperty(value = "카카오 id", required = true, example = "1")
private Long id;
@ApiModelProperty(value = "카카오 image url", required = true, example = "\"http://k.kakaocdn.net\"")
private String imageFileUrl;
}
반면에 REST Docs를 사용하면 다음과 같은 이점이 있습니다.
테스트 기반으로 문서가 작성되어 테스트 코드가 일치하지 않으면 테스트 빌드가 실패하게 되기 때문에 문서를 신뢰할 수 있다.
테스트 코드에서 명세를 작성하기 때문에 비즈니스 로직의 가독성에 영향을 미치지 않는다.
build.gradle
implementation 'io.springfox:springfox-boot-starter:3.0.0'
Swagger 설정을 정의한 코드입니다.
- .consume()과 .produces()는 각각 Request Content-Type, Response Content-Type에 대한 설정입니다.(선택)
- .apiInfo()는 Swagger API 문서에 대한 설명을 표기하는 메소드입니다. (선택)
- .apis()는 Swagger API 문서로 만들기 원하는 basePackage 경로입니다. (필수)
- .path()는 URL 경로를 지정하여 해당 URL에 해당하는 요청만 Swagger API 문서로 만듭니다.(필수)
config아래에 SwaggerConfig를 생성
package com.example.Belog.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
@Configuration
public class SwaggerConfig {
@Bean
public Docket api() {
return new Docket(DocumentationType.OAS_30)
// ApiSelectorBuilder 생성
.select()
// api 문서화를 할 패키지 경로
.apis(RequestHandlerSelectors.basePackage("com.example.Belog.controller"))
// 어떤 API URL에 대해서 문서화할지
// PathSelectors.any() : 모든 API에 대한 URL을 문서화
// PathSelectors.ant("/member/**") : 특정 URL을 지정해서 문서화
.paths(PathSelectors.any())
.build();
}
}
.select()
를 들어가면 아래와 같이 나온다.
이대로 실행하면 에러가 발생하는데 Spring Boot 2.6 버전 이후에 spring.mvc.pathmatch.matching-strategy 값이 ant_path_matcher
에서 path_pattern_parser
로 변경되면서 몇몇 라이브러리에 오류가 발생할 수 있다.
그래서 properties나 yml에 spring.mvc.pathmatch.matching-strategy=ant_path_matcher
설정을 해주면 오류가 발생하지 않습니다.
이제 들어가보자!
Swagger 문서 경로에 들어가면 아래와 같은 창이 뜹니다.
이 부분도 수정할 수 있습니다.
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("API 문서")
.description("API에 대해서 설명해주는 문서입니다.")
.version("1.0")
.build();
}
이렇게 private로 작성한 다음 Docket api()에 추가 해줍니다.
.apiInfo(apiInfo())
이렇게 변하는 것을 볼 수 있습니다.
그리고 예를들어, user-controller에 들어가면
이렇게 뜨는데 기본적으로 제공되는 기능입니다. 이것을 끌 수 있습니다.
Docket api() 메소드에 추가해줍니다.
.useDefaultResponseMessages(false);
없어지는 것을 볼 수 있습니다.
swagger 장점인 문서화 페이지에서 실행할 수 있습니다.
Try it out 클릭하면
이 버튼이 뜨는데
결과 값이 나오는 것을 볼 수 있습니다.
만약 직접 입력받는 것을 끄려고한다면
이렇게 하면 입력받는 창이 사라집니다.
여기는 security에 관련되어 보입니다.
설정
- Spring boot 2.6.4
- jjwt 0.11.5
- Swagger 3.0.0
swagger에서 request header에 인증 토큰을 넣고 싶은 경우
Swagger에서는 기본적으로 request header값을 설정하는 부분이 없다. @configuration이 달린 Swagger 설정 클래스에서 이를 설정해주어야 한다.
// 인증 방식 설정
private SecurityContext securityContext(){
return SecurityContext.builder()
.securityReferences(defaultAuth())
.build();
}
private List<SecurityReference> defaultAuth(){
AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
return Arrays.asList(new SecurityReference("Authorization", authorizationScopes));
}
// 버튼 클릭 시 입력 값 설정
private ApiKey apiKey(){
return new ApiKey("Authorization", "Bearer", "header");
}
Docket api() 메소드에 추가해줍니다.
.securityContexts(Arrays.asList(securityContext()))
.securitySchemes(Arrays.asList(apiKey()))
여기까지 해주면
자물쇠표시가 생겼습니다.
이제 클릭을 하면
이런 창이 나오는데 이 부분에 토큰을 설정을 해서 보내줄 수 있습니다.
Swagger 전역에서 jwt 토큰 값을 설정할 때 Bearer jwt토큰값과 같이 토큰 값 앞에 Bearer를 붙여줘야 하는 점도 주의해야 합니다.
제대로 치고 들어가면
정보가 나옵니다.
본격적으로 swagger를 꾸미겠습니다.
package com.example.Belog.controller;
import com.example.Belog.domain.UserDTO;
import com.example.Belog.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.List;
import java.util.Map;
import java.util.Optional;
// API 그룹 설정
// name : 태그의 이름
// description : API 그룹에 대한 설명
@Tag(name = "user check", description = "API 상태 체크")
@RestController
@Log4j2
@AllArgsConstructor
@RequestMapping("/user")
public class UserController {
private UserService userService;
// 모든 회원 정보를 가져오는 API
@GetMapping("/")
@Tag(name = "user check")
@Operation(summary = "전체 불러오기 API", description = "모든 유저들을 불러오는 API입니다.")
public ResponseEntity<List<UserDTO>> getAllUser() {
List<UserDTO> userDTOList = userService.getAllUser();
return ResponseEntity.status(HttpStatus.OK).body(userDTOList);
}
// 회원 정보를 가져오는 API
@GetMapping("/{userEmail}")
@Tag(name = "user check")
@Operation(summary = "불러오기 API", description = "특정 유저들을 불러오는 API입니다.")
public ResponseEntity<?> getUser(@PathVariable String userEmail) {
Optional<UserDTO> userDTO = userService.getUser(userEmail);
if (userDTO.isPresent()) {
return ResponseEntity.status(HttpStatus.NO_CONTENT).body(userDTO);
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
}
/**
* 회원 가입 API
*
* @return ResponseEntity<UserResponse> 201 Created, 가입된 회원의 정보
*/
@PostMapping("/")
@Tag(name = "user check")
@Operation(summary = "회원가입 API", description = "회원가입하는 API입니다.")
public ResponseEntity<String> signUp(@Validated @RequestBody UserDTO userDTO, Errors errors, HttpServletResponse resp) {
// post요청시 넘어온 user 입력값에서 Validation에 걸리는 경우
if (errors.hasErrors()) {
// 유효성 통과 못한 필드와 메시지를 핸들링
// 회원가입 실패시 message 값들을 모델에 매핑해서 View로 전달
Map<String, String> validatorResult = userService.validateHandling(errors);
// map.keySet() -> 모든 key값을 갖고온다.
// 그 갖고온 키로 반복문을 통해 키와 에러 메세지로 매핑
for (String key : validatorResult.keySet()
) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(validatorResult.get(key));
}
}
if (userService.signUp(userDTO)) {
log.info("result : " + userDTO.getUserId());
log.info("result : " + userDTO.getUserEmail());
Cookie cookie = new Cookie("userEmail", userDTO.getUserEmail());
// 30분
cookie.setMaxAge(1800);
resp.addCookie(cookie);
}
return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공했습니다.");
}
// 로그인
@PostMapping("/loginUser")
@Tag(name = "user check")
@Operation(summary = "로그인 API", description = "로그인하는 API입니다.")
public String login(@RequestBody UserDTO userDTO, HttpSession session) {
UserDTO loginUser = userService.login(userDTO.getUserEmail(), userDTO.getUserPw());
if (loginUser != null) {
session.setAttribute("userId", loginUser.getUserId());
session.setAttribute("userEmail", loginUser.getUserEmail());
return "로그인에 성공했습니다.";
}
return "아이디가 없습니다.";
}
@GetMapping("/logOut")
@Tag(name = "user check")
@Operation(summary = "로그아웃 API", description = "로그아웃 하는 API입니다.")
public String logOut(HttpServletRequest req) {
req.getSession().invalidate();
return "로그아웃 하셨습니다";
}
// 회원 정보 수정
@PutMapping("/")
@Tag(name = "user check")
@Operation(summary = "수정 API", description = "유저 정보를 수정하는 API입니다.")
public ResponseEntity<?> update(@RequestBody UserDTO userDTO, HttpSession session) {
userService.update(userDTO);
return ResponseEntity.status(HttpStatus.OK).body(userDTO);
}
// 회원 탈퇴(삭제) API
// 204 : NO_CONTENT
@DeleteMapping("/{userId}")
@Tag(name = "user check")
@Operation(summary = "삭제 API", description = "유저를 삭제하는 API입니다.")
public ResponseEntity<Object> delete(@PathVariable Long userId) {
userService.delete(userId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).body(null);
}
// 중복체크
@PostMapping("/user/email-check")
@Tag(name = "user check")
@Operation(summary = "중복체크 API", description = "userEmail이 중복인지 체크하는 API입니다.")
// ajax를 쓸 때는 반드시 @ResponseBody를 써야한다.
public @ResponseBody int emailCheck(@RequestParam("userEmail") String userEmail) {
log.info("userEmail : " + userEmail);
return userService.emailCheck(userEmail);
}
}
@Schema에 대해 알아 보겠습니다. example은 예시 값이고 required는 필수 여부 입니다. description은 필드에 대한 설명입니다. @Schema는 각 항목들에 대한 설명 또는 한글항목명이나 body에 설정될 기본 값, 허용가능한 입력값 enum 등을 정의할 수 있습니다. description에는 한글항목명을 쓰면 되고, Swagger상에 기본적으로 보여줄 Example Value의 기본값은 defaultValue에 정의합니다. 만일, 특정 값들만 허용하는것을 Swagger문서상으로 보여주고 싶다면 allowableValues에 설정해주면 됩니다.
@Schema(description = "사용자")
@Getter @Setter
public class UserValue {
@Pattern(regexp = "[0-2]")
@Schema(description = "유형", defaultValue = "0", allowableValues = {"0", "1", "2"})
private String type;
@Email
@Schema(description = "이메일", nullable = false, example = "abc@jiniworld.me")
private String email;
@Schema(description = "이름")
private String name;
@Pattern(regexp = "[1-2]")
@Schema(description = "성별", defaultValue = "1", allowableValues = {"1", "2"})
private String sex;
@DateTimeFormat(pattern = "yyMMdd")
@Schema(description = "생년월일", example = "yyMMdd", maxLength = 6)
private String birthDate;
@Schema(description = "전화번호")
private String phoneNumber;
@Schema(description = "비밀번호")
private String password;
}
package com.example.Belog.domain;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
@Getter
@ToString
@NoArgsConstructor
public class UserDTO {
@Schema(description = "유저 번호")
private Long userId;
@Schema(description = "이메일", nullable = false, example = "abc@jiniworld.me", required = true)
@NotBlank(message = "이메일은 필수 입력사항 입니다.")
// 이메일 형식이여야 함
@Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
@Email(message = "이메일 형식에 맞지 않습니다.")
private String userEmail;
@Schema(description = "비밀번호", required = true, nullable = false)
@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
// @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\\\d)(?=.*[~!@#$%^&*()+|=])[A-Za-z\\\\d~!@#$%^&*()+|=]{8,16}$\\n",
// message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 16자의 비밀번호여야 합니다.")
private String userPw;
@Schema(description = "이름", nullable = false, required = true)
@NotBlank(message = "이름은 필수 입력사항 입니다.")
private String userName;
@Schema(description = "주소", nullable = false, required = true)
@NotBlank(message = "주소는 필수 입력사항 입니다.")
private String userAddr;
@Schema(description = "상세 주소", nullable = false, required = true)
@NotBlank
private String userAddrDetail;
@Schema(description = "그 외 주소", nullable = false, required = true)
@NotBlank
private String userAddrEtc;
@Builder
public UserDTO(
String userEmail,
String userPw,
String userName,
String userAddr,
String userAddrDetail,
String userAddrEtc
) {
this.userEmail = userEmail;
this.userPw = userPw;
this.userName = userName;
this.userAddr = userAddr;
this.userAddrDetail = userAddrDetail;
this.userAddrEtc = userAddrEtc;
}
}
description에 한글명을 작성하고, defaultValue를 통해 기본값을 제공할 수 있습니다. allowableValues 설정을 하면 Schema 정보에서 리스트 형태로 들어갈 수 있는 데이터 정보를 볼 수 있습니다.
- schema : payload에서 이용하는 Schema
- hidden : Schema 숨김여부
- implementation : Schema 대상 클래스
@ApiResponse를 설정하지 않으면 Swagger UI에서는 상태코드 200과 비어있는 response body를 보여줍니다. 무늬뿐인 response 구조가 아닌, api 조회 성공 및 실패시 발생될 상태코드 및 Response 구조를 설정하고자 한다면 @ApiResponse를 설정하면 됩니다.
예를들어, 회원 조회 API에서는 200, 404 상태코드가 설정되어 있습니다. 조회가 성공되었을 시, 200 상태코드와 CommonResponse<User> reseponseBody
가 반환되고
조회 실패시엔, 404 상태코드와 ErrorResponse responseBody
가 반환됩니다.
여러개를 사용하고자 할 때는 다음과 같이 합니다.
@GetMapping("/{id}")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "회원 조회 성공", content = @Content(schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "404", description = "존재하지 않는 리소스 접근", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) })
@Operation(summary = "회원 조회", description = "id를 이용하여 user 레코드를 조회합니다.")
public ResponseEntity<? extends BasicResponse> select(
@PathVariable("id") long id) {
...
}
- query : query string 방식으로 전달하는 경우
@ApiResponse 와 마찬가지로 @Parameters 애너테이션에 @Parameter 리스트를 담아 api 메서드에 설정할 수 있고, @Operation 애너테이션의 parameters 요소에 설정할 수 있습니다. 그리고, 파라미터의 경우, api method의 인자값에 붙여 명시적으로 설정할 수도 있습니다.
@GetMapping("/posts/{id}")
public PostsResponseDto findById(@Parameter(name = "id", description = "posts 의 id", in = ParameterIn.PATH)
@PathVariable Long id) {
return postsService.findById(id);
}
path로부터 들어올 파라미터인 id에 대한 설정을 추가하였다. @PathVariable 설정 앞에 @Parameter 어노테이션 설정을 추가했는데, 메서드의 인자앞에 직접 설정할 경우에는 name을 생략할 수 있다. 만일 @Parameters 나 @Operations에 파라미터를 설정할 경우에는 어떤 파라미터에 대한 설정인지 알 수 없기 때문에 반드시 name을 설정해 줘야 합니다.
@Parameter의 Target은 ANNOTATION_TYPE, FIELD, METHOD, PARAMETER이렇게 4가지로 명시되어 있지만, PARAMETER에 작성하면 name 설정을 해줄 필요가 없어 주로 PARAMETER에 작성하여 명시적으로 설정하는 것을 선호하는 것 같았습니다.
public PostResponseDto findById(
@Parameter(description = "게시글 의 id", in = ParameterIn.PATH)
@PathVariable Long id) {
테스트 코드 기반으로 Restful API 문서를 돕는 도구입니다.
Asciidoctor를 이용해서 HTML 등등 다양한 포맷으로 문서를 자동으로 출력할 수 있습니다.
RestDocs의 가장 큰 장점은 테스트 코드 기반으로 문서를 작성한다는 점입니다.
API Spec과 문서화를 위한 테스트 코드가 일치하지 않으면 테스트 빌드를 실패하게 되어 테스트 코드로 검증된 문서를 보장할 수 있습니다.
테스트 코드가 강제