[Numble 챌린지 개발일지] 3주차 REST API에 Swagger 입히기
기본적인 JSON 응답 형식 등은 프론트와 협의가 필요하다고 생각되어 질문 드렸으나 뎁스가 깊지 않았으면 좋겠고, 편한대로 하면 다 맞추겠다고 말씀하셨습니다. 또한 화면마다 필요한 데이터가 얼추 회의록에 기록은 되어있으나 디자이너분께서 회의에 미참석했기 때문에 정확하게 설정되지 않은 상태에서 설계를 해야했습니다.
초기 API 설계는 급한대로 노션에 일단 작성한 다음, API 문서화 툴을 사용해보기로 했는데요!(두근두근) DB 및 API 초기설계를 각자 해온 후 합치는 식으로 작업했습니다.
Swagger 채택 이유
자바 API 문서 자동화 툴로는 Swagger를 사용하기로 했습니다. 그밖의 선택지로 Spring Rest Docs도 있지만, Spring Rest Docs는 성공적인 테스트 코드를 작성해야 하고, Swagger보다 다소 적용하기 어렵다고 합니다.
팀원들의 역량 및 6주라는 짧은 개발 기간을 고려하면 테스트 코드까지 작성하기는 어려울 것이라는 판단 하에, 간단하게 적용할 수 있으며 테스트 화면도 제공되는 Swagger를 선택했습니다.
Swagger-UI 적용 방법
springdoc이라는 Open API를 사용해서 Swagger3을 적용해볼겁니다. 공식문서를 참고하여 설정합니다.
개인적으로 다음 링크의 포스팅이 많은 도움이 되었습니다 👍
- https://jeonyoungho.github.io/posts/Open-API-3.0-Swagger-v3/
- https://wildeveloperetrain.tistory.com/156
build.gradle 설정
dependencies {
//swagger
implementation 'org.springdoc:springdoc-openapi-ui:1.6.12'
implementation 'org.springdoc:springdoc-openapi-security:1.6.12'
...
application.properties 설정
# Swagger
springdoc.version=openapi_3_0
springdoc.packages-to-scan=com.example.backend
springdoc.api-docs.path=/api-docs/json
springdoc.api-docs.resolve-schema-properties=true
springdoc.swagger-ui.path=/api-docs
springdoc.swagger-ui.tags-sorter=alpha
springdoc.swagger-ui.operations-sorter=alpha
springdoc.writer-with-default-pretty-printer=true
springdoc.default-consumes-media-type=application/json;charset=UTF-8
springdoc.default-produces-media-type=application/json;charset=UTF-8
springdoc.cache.disabled=true
- .version : OpenAPI bean 등록시 사용할 springdoc 버전
- .packages-to-scan : swagger 적용을 위해 스캔할 패키지 지정
- .api-docs.path : swagger-ui 커스텀 시 json format 경로도 변경해야 함
- .api-docs.resolve-schema-properties : @Schema(이름, 제목 및 설명)에서 속성 확인자를 활성화(default: false)
- .swagger-ui.path :swagger HTML 문서 경로(dfeault: http://localhost:{port}/swagger-ui/)
- .swagger-ui.tags-sorter : 태그 정렬 기준
- .swagger-ui.operations-sorter : 컨트롤러 정렬 기준(default:컨트롤러 내에서 정의한 메서드 순)
- alpha(알파벳 오름차순), method(Http 메소드 순)
- .default-consumes-media-type : consumes 타입(default: application/json), 한글 인코딩을 위해서 ;charset=UTF-8를 덧붙임
- .default-produces-media-type : produces 타입 (default: /), 한글 인코딩을 위해서 ;charset=UTF-8를 덧붙임
consumes/produces 타입은 이 포스팅을 참고하면 좋을 것 같습니다
- .cache.disabled : open api의 캐시 비활성화 여부 (default: false), 서버 배포 후 API 변경 시 캐시 때문에 반영되지 않을 수 있어서 비활성화함
- .writer-with-default-pretty-printer : UI를 예쁘게 출력
+) 프로젝트 당시에는 공식문서에서 발견을 못해서 설정하지 않았지만... 추가했더라면 좋았을 옵션 👀
Config 클래스 설정
OpenAPI bean 을 이용하여 swagger-ui를 구성하는 Bean을 등록합니다.
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI(@Value("${springdoc.version}") String appVersion) {
return new OpenAPI()
.components(new Components())
.info(new Info()
.title("넘블 3팀 API")
.version(appVersion)
.description("넘블 프로젝트 API 명세서"));
}
}
Spring Security 설정
앞서 application.properties 에서 /api-docs로 swagger-ui 접속 경로를 설정해뒀습니다.이 경로로 접속하면 swagger-ui HTML페이지로 리다이렉트 되며 도착지 주소는 /swagger-ui/index.html 이 됩니다.
하지만 spring security가 적용된 상태이면 /api-docs로 접속하더라도 권한이 없어서 진입이 거부됩니다.
따라서 다음과 같이 SecurityConfig 클래스에서 .authorizeRequests().antMatchers().permitAll() 함수의 파라미터에 /swagger-ui/** 와 /api-docs/** 경로를 추가해야 합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private String REFRESH_COOKIE = "refreshToken";
private final CustomLogoutHandler customLogoutHandler;
private final AuthTokenProvider authTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors()//기본 cors 설정
.and()
.csrf().disable()
.formLogin().disable() //formLogin 인증 비활성화
.httpBasic().disable() //httpBasic 인증 비활성화
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests().antMatchers(
"/oauth2/**", "/", "/login/**", "/users", "/refresh", "/swagger-ui/**", "/api-docs/**")
.permitAll()
.anyRequest().authenticated();
...
return http.build();
}
}
그리고 WebMvcConfig 클래스를 생성해서 클라이언트가 접속할 수 있도록 설정해줍니다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
//모든 경로에 대해
registry.addMapping("/**")
//클라이언트 포트 설정
.allowedOrigins("http://localhost:3000")
//메서드 허용
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*") //모든 헤더 허용
.allowCredentials(true) // 모든 인증 정보 허용
.maxAge(MAX_AGE_SECS);
}
}
이제 컨트롤러를 작성해 본격적으로 API를 명세해보겠습니다 ..!
구글에는 swagger2의 자료가 많은데, swagger3으로 버전업되면서 변경된 부분들은 다음과 같으니 참고하면 좋을 것 같습니다.
회원가입 컨트롤러 예시코드
@Tag(name = "user", description = "회원 API")
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "회원가입",
description = "unique field 중복 시 errorCode -101(이메일), -102(닉네임), -103(이메일,닉네임)이 반환됩니다.")
@PostMapping("/join")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "CREATED"),
@ApiResponse(responseCode = "400", description = "BAD REQUEST")
})
public ResponseDTO<?> join(@RequestBody @Valid final UserDefaultJoinRequestDTO userDefaultJoinRequestDTO){
userService.createDefaultUser(userDefaultJoinRequestDTO);
return ResponseDTO.builder().success(true).message("회원가입 처리되었습니다.").build();
}
}
회원가입 Request DTO
@Data
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Schema
@NotNull
public class UserDefaultJoinRequestDTO {
@Schema(description = "이메일(아이디)", defaultValue = "hello@numble.com")
@NotBlank
private String email; // 아이디
@Schema(description = "비밀번호", defaultValue = "testPw")
@NotBlank
private String password;
...
아래에서 설명할 response DTO를 구현했다고 치고.. 주소창에 http://localhost:{port}/api-docs/ 를 입력하면 다음 화면으로 리다이렉트 됩니다.
이후에 캡쳐한 거라 comment api가 먼저 뜨네용
※ 주의
다음과 같이 @Schema를 붙인 필드에 List를 둘 경우 defaultVaule를 설정하면 string 샘플 값이 나와버리므로 꼭 빼줘야 합니다!
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema
public class SocialCreateRequestDTO {
...
@Schema(description = "소분류 태그 1~3개")
@NotNull
private List<TagDTO> tags;
@Schema(description = "배경 이미지 리스트 1~3개")
@NotNull
private List<PostImageDTO> images;
}
이렇게 나와야 정상적인 결과입니다
Response 형식
이제 Response 형식을 정할 차례인데요, 다들 rest api는 처음 구현해봐서 다소 당황스러웠지만...
저는 'React.js, 스프링 부트, AWS로 배우는 웹 개발 101' 라는 책으로 학습했을 때 써본 적이 있어서, 이 책을 참고하여 다음과 같이 구현했었습니다.
public class ResponseDTO<T> {
private boolean success;
private String message;
private int code;
private List<T> data;
}
사실 서버랜더링으로 뷰템플릿을 호출하는 컨트롤러만 계속 작성해왔어서, REST API 형식의 컨트롤러는 어떻게 작성해야하는 건지 감이 안왔습니다..
@Tag(name = "user", description = "회원 API")
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@Operation(summary = "회원가입")
@PostMapping("/join")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "CREATED"),
@ApiResponse(responseCode = "400", description = "BAD REQUEST"),
@ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR")
})
public ResponseEntity<ResponseDTO> join(@RequestBody final UserJoinRequestDTO userJoinRequestDTO){
//TODO validation 처리
userService.create(userJoinRequestDTO);
return new ResponseEntity(ResponseDTO.builder().success(true).message("회원가입 처리되었습니다.").build(), HttpStatus.OK);
}
일단 이렇게 작성했더니.. Unchecked assignment 경고문구가 떴습니다.
에러 이유: https://dwenn.tistory.com/91
그래서 이렇게 변경했습니다...!
@Tag(name = "user", description = "회원 API")
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "회원가입")
@PostMapping("/join")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "CREATED"),
@ApiResponse(responseCode = "400", description = "BAD REQUEST"),
@ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR")
})
public ResponseDTO<?> join(@RequestBody final UserJoinRequestDTO userJoinRequestDTO){
//TODO validation 처리
userService.create(userJoinRequestDTO);
return ResponseDTO.builder().success(true).message("회원가입 처리되었습니다.").build();
}
에러는 해결했지만, 위의 Response 형식대로 응답을 보내니 data가 단건이더라도 배열 [ ]로 감싸진 후 그 안에 또 데이터가 { } 있어서 이 부분이 마음에 들지 않았습니다.
그러다가 이 포스팅(https://hyeonic.tistory.com/197)을 보고 다음과 같이 바꿔서 해결했습니다!
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResponseDTO<T> {
private boolean success;
private String message;
private T data;
public ResponseDTO(T data, String message){
success = true;
this.data = data;
this.message = message;
}
}
코드도 더욱 간결해져서 일타 쌍피의 효과...(주석친 게 원래 코드입니다)
응답이 객체 한 개일때 List로 안감싸고 그냥 생성자에 넣으면 됩니다!
JSON 결과는
data: [ { 객체 한 개 } ] 이렇게 오던게
data: { 객체 한 개 } 이렇게 변경되었습니다.
에러 응답 DTO도 따로 분리했습니다.
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ErrorDTO {
int errorCode;
String errorMessage;
}
에러코드는 로그인 -100, 피드 -200, 소셜 -300, 댓글 -400번대 이런식으로 나누었습니다.
Exception Controller
예외처리를 Swagger에 맞출 것인지... 고민을 꽤 했는데요. 😂
처음에는 팀원들이 각자 구현하는 기능에 맞춰서 에러도 각자 다루는게 편할 것 같아서, UserException, SocialException 이런식으로, 엔티티별로 나누고 그냥 한 메서드에서 한꺼번에 처리하려 했지만 (사실 이게 속편하고 몸도 편합니다...)
Open API 공식 문서에 따르면 응답 결과를 Swagger API 문서에 나타내려면 에러 처리를 하는 메서드에 @ResponsStatus를 붙여줘야합니다.
한 메서드로 처리해버리면 UserExpceiotn에 들어오는 다양한 Status를 구분할 수 없기 때문에..!
Swagger 연동을 하려면 status별로 메서드를 글로벌 처리해줘야했습니다.
그래서 우선은 각 status별로 예외를 처리하는 메서드와 예외 클래스를 하나씩 만들고, 나중에 필요하면 클래스를 추가해서 @ExceptionHandler 에 설정해주기로 했습니다.
패키지 구조는 이렇고,
@RestControllerAdvice
@Slf4j
public class CustomErrorController implements ErrorController {
...
@ResponseStatus(HttpStatus.BAD_REQUEST) //400
@ExceptionHandler(value = {UserInvalidInputException.class, CommentInvalidInputException.class,
SocialInvalidInputException.class, FeedInvalidInputException.class})
public ErrorDTO handleBadRequest(CustomException ex){
return ErrorDTO.builder()
.errorCode(ex.getExceptionType().getErrorCode())
.errorMessage(ex.getExceptionType().getMessage())
.build();
}
@ResponseStatus(HttpStatus.UNAUTHORIZED) //401
@ExceptionHandler(UnAuthorizedException.class)
public ErrorDTO handleUnAuthorized(UnAuthorizedException ex){
return ErrorDTO.builder()
.errorCode(ex.getExceptionType().getErrorCode())
.errorMessage(ex.getExceptionType().getMessage())
.build();
}
@ResponseStatus(HttpStatus.FORBIDDEN) //403
@ExceptionHandler(ForbiddenException.class) //TODO
public ErrorDTO handleForbidden(ForbiddenException ex){
return ErrorDTO.builder()
.errorCode(ex.getExceptionType().getErrorCode())
.errorMessage(ex.getExceptionType().getMessage())
.build();
}
@ResponseStatus(HttpStatus.NOT_FOUND) //404
@ExceptionHandler(EntityNotExistsException.class)
public ErrorDTO handleNotFound(EntityNotExistsException ex){
return ErrorDTO.builder()
.errorCode(ex.getExceptionType().getErrorCode())
.errorMessage(ex.getExceptionType().getMessage())
.build();
}
//그 외에 놓친 예외들
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public ErrorDTO handleEtc(Exception ex){
log.error("Exception: {}", ex);
return ErrorDTO.builder()
.errorCode(500)
.errorMessage(ex.getMessage()).build();
}
}
할 수 있는 한 확장성 있는 설계를 하려고... 이렇게 설정했봤습니다.
Spring @Valid 오류 메시지 사용자 정의하기
DTO validation의 description을 설정해도 그대로 에러메시지로 출력되지 않고 default 오류메시지가 출력되더라구요.
bindingRessut에서 fieldError를 따로 받아서 출력하는 식으로 해결했습니다.
@RestControllerAdvice
@Slf4j
public class CustomErrorController implements ErrorController {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ErrorDTO handleBadRequest(MethodArgumentNotValidException ex){
List<FieldError> errors = ex.getBindingResult().getFieldErrors();
StringBuilder sb = new StringBuilder();
for(FieldError error : errors){
sb.append("'");
sb.append(error.getField());
sb.append("' ");
}
sb.append("validation 에러 입니다.");
return ErrorDTO.builder()
.errorCode(-1)
.errorMessage(sb.toString())
.build();
}
...
}
테스트 결과
음.. 시간이 촉박하기도 하고.. 일단 이렇게 진행하기는 했는데.. 다음에는 어떻게 전달해야 프론트에서 분리를 잘 할 수 있는지 협의해보고 진행하면 더 좋을 것 같습니다.
참고한 포스팅
@Builder 관련 에러
Builder 사용 시 @NoArgsConstructor 를 같이 불일 때, 전체 멤버변수를 갖는 생성자가 없을 경우 에러가 발생하기 때문에 @AllArgsConstructor도 같이 붙여주거나 직접 생성자를 작성해야합니다.
@Builder 사용 시, 생성한 생성자가 없다면 @AllArgsConstructor(access = AccessLevel.PACKAGE) 가 암묵적으로 적용된다고 하는데, 빈 생성자나 일부 멤버변수를 갖는 생성자가 존재할 경우에는 그렇지 않나봅니다...
(이 부분은 추후에 더 찾아보겠습니다)
이렇게 바꾸면 됩니다. 그런데 Reuqest DTO의 경우 @AllArgsConstructor가 있으면 변경될 우려가 있기 때문에 이때는 @Builder의 access = AccessLevel.PRIVATE 옵션으로 외부에서 접근못하게 막는게 좋습니다.
참고한 포스팅