2024년12월 Swagger 입문기
2줄 요약
프런트엔드 개발자와 협업을 위해 Swagger 적용
각 어노테이션별 특징 및 사용법 정리
개발환경
Spring boot : 3.3.2
Swagger(=OpenAPI): 2.6.0
Java: 17
배경설명
한동안 신규 프로젝트의 기능 개발에만 집중한다고 쿼리와 아키텍처 구성, 테이블 설계에만 빠져 살았습니다. 개발단계가 어느 정도 수면 위로 올라오기 시작했고, 마침 주간 스프린트에서 Swagger 적용 건의가 있어 신규 프로젝트에 적용해 보기로 했습니다. 여러 블로그와 문서를 찾아보면서 Spring boot 3.0 이상 버전부터 Swagger의 많은 변화가 있어 포스팅을 통해 정리해보고자 합니다.
Swagger? OAS? Spring Rest docs? 용어 정리부터
Open API Specification(OAS): 다른 사용자들이 Restful API 를 쉽게 이해하고 사용할 수 있도록 하기 위한 국제 표준 문서로, json이나 yaml 형식으로 문서를 작성한다. 아래는 토스페이먼츠에서 예시로 작성한 OAS 입니다.
openapi: 3.0.0
info:
title: Sample API
description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML.
version: 0.1.9
servers:
- url: http://api.example.com/v1
description: Optional server description, e.g. Main (production) server
- url: http://staging-api.example.com
description: Optional server description, e.g. Internal staging server for testing
paths:
/users:
get:
summary: Returns a list of users.
description: Optional extended description in CommonMark or HTML.
responses:
'200': # status code
description: A JSON array of user names
content:
application/json:
schema:
type: array
items:
type: string
Swagger: API의 메타 데이터를 통해 OpenAPI Specification을 만들고 해당 문서를 통해 개발자에게 API 설명 및 테스트 인터페이스를 제공합니다. Spring boot에서 간편히 사용할 수 있도록 라이브러리를 지원하고, Spring 메타데이터를 이용해 end point와 필요 파라미터 등을 바로 적용해 주지만, Swagger의 Annotation을 통해 자세한 설명과 세부 정보를 제공할 수 있습니다. 하지만 어노테이션을 직접 서비스코드에 추가하기에 코드 가독성이 떨어지고, 서비스 로직을 침범하는 것에 부정적으로 생각하는 개발자분들도 있습니다.
Spring Rest docs: 테스트코드 작성을 통해 API 명세 인터페이스를 제공해 주는 라이브러리입니다. Swagger와 달리 테스트는 불가하고, 대부분의 개발자들이 인터페이스 UI/UX가 Swagger와 달리 유저 친화적이지 않고, 결론적으로 예쁘지 않습니다. 하지만 Swagger와 달리 서비스 코드에 직접적으로 침범하지 않는 점에서 많은 개발자분들이 장점으로 생각하는 것 같습니다.
이번에 Swagger을 조사하면서 Spring docs와 Swagger를 복합적으로 사용하는 방법을 알게 되었습니다. 테스트 코드를 작성하고 Spring docs를 이용 OAS를 추출 후 이를 Swagger에서 읽어드려 서비스 코드에 침범하지 않고도 가독성이 높은 Swagger 인터페이스를 이용하여 두 라이브러리의 장점을 모두 취할 수 있는 괜찮은 방법 같아요. 이번 포스팅 이후에 Spring docs도 정리하여 작성해 보려는데 해당 내용도 같이 정리해 볼 예정입니다.
1. 간단한 CRUD API 개발
1) 게시글 작성 및 조회 API 작성
- 게시글 저장 API : 게시글 제목과 내용을 받아 저장하고, Id를 반환
- 게시글 조회 API : Id를 받아 조회 후, 게시글 제목과 내용을 반환
- 회원가입 API : 이메일과 비밀번호를 받아 저장하고 Id를 반환
- 로그인 API : 이메일과 비밀번호를 받아와 저장된 회원정보와 비교 후 "로그인 성공" or "로그인 실패"를 반환
* 게시글 API
* BoardRequest
package com.example.boards;
record BoardRequest(String title, String contents) {
public static Boards of(BoardRequest boardRequest) {
return new Boards(boardRequest.title(), boardRequest.contents);
}
}
* BoardResponse
package com.example.boards;
public record BoardResponse(Long savedId, String title, String contents) {
public static BoardResponse of(Long savedId,Boards boards) {
return new BoardResponse(savedId, boards.getTitle(), boards.getContents());
}
}
* BoardsController
package com.example.boards;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/v1/boards")
@RequiredArgsConstructor
@RestController
public class BoardsController {
private final BoardsService boardsService;
@PostMapping
public ResponseEntity<?> createBoards(@RequestBody BoardRequest boardReqeust) {
BoardResponse boardResponse = boardsService.saveBoards(boardReqeust);
return ResponseEntity.ok(boardResponse);
}
@GetMapping
public ResponseEntity<?> getBoard(@RequestParam("Id") Long id) {
BoardResponse boardResponse = boardsService.getBoard(id);
return ResponseEntity.ok(boardResponse);
}
}
* BoardsService
package com.example.boards;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class BoardsService {
private static final AtomicLong autoIncrement = new AtomicLong();
private static final Map<Long, Boards> boardsInMemory = new HashMap<>();
public BoardResponse saveBoards(BoardRequest boardRequest) {
Boards board = BoardRequest.of(boardRequest);
Long savedId = autoIncrement.incrementAndGet();
boardsInMemory.put(savedId, board);
return BoardResponse.of(savedId, board);
}
public BoardResponse getBoard(Long id) {
Boards boards = boardsInMemory.get(id);
return BoardResponse.of(boards);
}
}
* Boards
package com.example.boards;
import lombok.Getter;
@Getter
public class Boards {
private String title;
private String contents;
public Boards(String title, String contents) {
this.title = title;
this.contents = contents;
}
}
* User API
* RegisterRequest
package com.example.users;
public record RegisterRequest(String email, String password) {
public static Users of(RegisterRequest registerRequest) {
return Users.builder()
.email(registerRequest.email)
.password(registerRequest.password)
.build();
}
}
* LoginRequest
package com.example.users;
public record LoginRequest(String email, String password) {
public static Users of(LoginRequest loginRequest) {
return Users.builder()
.email(loginRequest.email)
.password(loginRequest.password)
.build();
}
}
* UserController
package com.example.users;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RequestMapping("/api/v1/user")
@RequiredArgsConstructor
@RestController
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<?> register(RegisterRequest registerRequest) {
Long savedId = userService.register(registerRequest);
return ResponseEntity.ok(Map.of("유저 ID", savedId));
}
@GetMapping
public ResponseEntity<?> login(LoginRequest loginRequest) {
String result = userService.login(loginRequest);
return ResponseEntity.ok(result);
}
}
* UserService
package com.example.users;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class UserService {
private static final AtomicLong autoIncrement = new AtomicLong();
private static final Map<Long, Users> usersInMemory = new HashMap<>();
public Long register(RegisterRequest registerRequest) {
Users user = RegisterRequest.of(registerRequest);
long newId = autoIncrement.incrementAndGet();
usersInMemory.put(newId, user);
return newId;
}
public String login(LoginRequest loginRequest) {
Users loginUser = LoginRequest.of(loginRequest);
for (Users savedUser : usersInMemory.values()) {
if (savedUser.equals(loginUser)) {
return "로그인 성공";
}
}
return "로그인 실패";
}
}
* Users
package com.example.users;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@Getter
@EqualsAndHashCode
public class Users {
private String email;
private String password;
@Builder
private Users(String email, String password) {
this.email = email;
this.password = password;
}
}
2. Swagger 적용해 보기
1) 의존성 추가 및 Swagger 인터페이스 접속
build.gradle에 Swagger 의존성을 추가해 주고, 애플리케이션을 실행 후 아래 url로 접근하면 Swagger 인터페이스에 접근할 수 있어요!
http://localhost:8080/swagger-ui/index.html#/
Spring Security를 사용 중이라면 Swagger 접근 URL을 열어줘야 접근 가능해요
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
/**
... 추가 설정 ...
**/
@Bean
protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
/**
... 부가 설정 ...
**/
http.authorizeHttpRequests(
authorize -> authorize
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger UI 접근 허용
.anyRequest().authenticated() // 나머지 모든 요청은 인증 필요
);
return http.build();
}
}
별도 설정을 하지 않았음에도 Spring 메타데이터를 통해 Controller에 정의된 Http-request와 엔드포인트 정보 및 필요 파라미터도 확인할 수 있습니다. 이제 세부적인 설정과 어노테이션을 적용하면 어떻게 바뀌는지 확인해 볼게요.
2) SwaggerConfig 설정
본 글에서는 SwaggerConfig를 이용합니다. 세부 설정은 하기 공식 사이트를 참고해 주세요!
(물론 application.properties or. yml로도 설정이 가능하지만 저는 @Configuration으로 설정하는 게 편해서... ㅎㅎ..)
SwaggerConfig 설정 코드
앞서 정의한 API 중 유저와 게시판 API 2개로 분리해 Swagger 설정을 해주었습니다. 각 설정이 Swagger 인터페이스 어느 부분에 적용되는지 간단히 정리해 보았습니다.
- API 명세서 제목 및 내용 정의
- API 그룹 명세(JWT 미사용)
- API 그룹 명세(JWT 사용)
- JWT 토큰 사용을 위한 Authorize 아이콘 활성화 및 관련 기능 활성화
※ SwaggerConfig.class 코드
package com.example.global;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@OpenAPIDefinition(
info = @Info(
title = "게시판 API 명세서",
description = "게시판 API 명세서",
version = "v1.0.0"
)
)
@Configuration
public class SwaggerConfig {
private static final String SECURITY_SCHEME_NAME = "Authorization";
// JWT 토큰이 필요한 API
@Bean
public GroupedOpenApi boardApi() {
String[] paths = {"/api/v1/boards/**"};
return GroupedOpenApi.builder()
.group("보안 인증이 필요한 API")
.pathsToMatch(paths)
.pathsToExclude("/api/v1/users/**")
.addOperationCustomizer((operation, handlerMethod) ->
operation.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)))
.build();
}
// 보안이 필요하지 않은 API
@Bean
public GroupedOpenApi userApi() {
String[] paths = {"/api/v1/users/**"};
return GroupedOpenApi.builder()
.group("유저 API")
.pathsToMatch(paths)
.build();
}
// JWT 보안 설정
@Bean
public OpenAPI boardApiWithSecurity() {
// Security Scheme 정의
SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(SECURITY_SCHEME_NAME);
// OpenAPI 정의에 Security Scheme 추가
return new OpenAPI().components(
new Components().addSecuritySchemes(SECURITY_SCHEME_NAME, securityScheme));
}
}
3. Swagger 적용한 Presentation Layer 코드
API 명세 세부 설정을 위해서는 Controller(Presentation Layer)에 Swagger 관련 @Annotation을 추가해주어야 합니다.
또한 Request, Response Dto에도 @Annotation을 정의가 필요합니다. 아래는 예시 코드를 통해 참고하시면 됩니다!
※ Controller.class 코드 예시
* BoardsController.class
package com.example.boards;
import io.swagger.v3.oas.annotations.Operation;
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.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(
name = "boards-controller",
description = "게시글 저장 및 조회를 위한 API 입니다."
)
@RequestMapping("/api/v1/boards")
@RequiredArgsConstructor
@RestController
public class BoardsController {
private final BoardsService boardsService;
@Operation(summary = "게시글 저장 API", description = "게시글을 저장하는 API 입니다.")
@ApiResponse(
responseCode = "200",
description = "게시글 저장 성공",
content = @Content(
schema = @Schema(implementation = BoardResponse.class)
)
)
@PostMapping
public ResponseEntity<?> createBoards(@RequestBody BoardRequest boardReqeust) {
BoardResponse boardResponse = boardsService.saveBoards(boardReqeust);
return ResponseEntity.ok(boardResponse);
}
@Operation(summary = "게시글 조회 API", description = "게시글을 조회하는 API 입니다.")
@ApiResponse(responseCode = "200", description = "OK",
content = @Content(
schema = @Schema(implementation = BoardResponse.class)
))
@GetMapping
public ResponseEntity<?> getBoard(@RequestParam("Id") Long id) {
BoardResponse boardResponse = boardsService.getBoard(id);
return ResponseEntity.ok(boardResponse);
}
}
* UsersController
package com.example.users;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Tag(
name = "users-controller",
description = "유저 등록 및 로그인을 위한 API 입니다."
)
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@RestController
public class UserController {
private final UserService userService;
@Operation(summary = "유저 등록 API", description = "유저를 등록하는 API 입니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "OK",
content = @Content(
schema = @Schema(implementation = Long.class)
)),
@ApiResponse(responseCode = "400", description = "BAD REQUEST"),
@ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR")
})
@PostMapping
public ResponseEntity<?> register(RegisterRequest registerRequest) {
Long savedId = userService.register(registerRequest);
return ResponseEntity.ok(Map.of("유저 ID", savedId));
}
@Operation(summary = "유저 로그인 API", description = "유저를 로그인하는 API 입니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "OK",
content = @Content(
schema = @Schema(implementation = String.class),
examples = @ExampleObject(value = "로그인 성공", summary = "로그인 성공", name = "로그인 성공")
)),
@ApiResponse(responseCode = "400", description = "BAD REQUEST",
content = @Content(
schema = @Schema(implementation = String.class),
examples = @ExampleObject(value = "로그인 실패", summary = "로그인 실패", name = "로그인 실패")
)),
@ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR")
})
@GetMapping
public ResponseEntity<?> login(LoginRequest loginRequest) {
Boolean result = userService.login(loginRequest);
if (!result) {
return ResponseEntity.badRequest().body("로그인 실패");
}
return ResponseEntity.ok("로그인 성공");
}
}
※ ResponseDto.class 코드 예시
* BoardResponse.class
package com.example.boards;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "게시글 응답DTO")
public record BoardResponse(
@Schema(description = "게시글 ID", example = "1")
Long savedId,
@Schema(description = "게시글 제목", example = "너드커넥션 노래중 최애곡은?")
String title,
@Schema(description = "게시글 내용", example = "좋은밤 좋은꿈!")
String contents
) {
public static BoardResponse of(Long savedId,Boards boards) {
return new BoardResponse(savedId, boards.getTitle(), boards.getContents());
}
}
* BoardRequest
package com.example.boards;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "게시글 저장 요청")
record BoardRequest(
@Schema(description = "게시글 제목", example = "너드커넥션 노래중 최애곡은?")
String title,
@Schema(description = "게시글 내용", example = "좋은밤 좋은꿈!")
String contents
) {
public static Boards of(BoardRequest boardRequest) {
return new Boards(boardRequest.title(), boardRequest.contents);
}
}
* LoginRequest.class
package com.example.users;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "유저 로그인 요청")
public record LoginRequest(
@Schema(description = "이메일", example = "example@email.com")
String email,
@Schema(description = "비밀번호", example = "password")
String password)
{
public static Users of(LoginRequest loginRequest) {
return Users.builder()
.email(loginRequest.email)
.password(loginRequest.password)
.build();
}
}
* RegisterRequest.class
package com.example.users;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "유저 등록 요청")
public record RegisterRequest(
@Schema(description = "이메일", example = "example@email.com")
String email,
@Schema(description = "비밀번호", example = "password")
String password
) {
public static Users of(RegisterRequest registerRequest) {
return Users.builder()
.email(registerRequest.email)
.password(registerRequest.password)
.build();
}
}
@Annotation 설명
- @Tag() & @Operaion
- @ApiResponses & @ApiResponse
- @Schema
마무리
애플리케이션에 Swagger 적용하는 것 자체는 어렵지 않았습니다. 사용하는 @Annotation이 몇 개 되지 않고, 한번 정의한 클래스를 참고해서 내용만 수정하면 되어 러닝커브가 높지 않다고 느꼈습니다. 다만 SwaggerConfig 설정이 어느 정도 이해가 필요하기에 상기에 공유한 공식 문서를 통해 설정하는 것을 권장드립니다.
Swagger 사용 경험은 유저 친화적이고, 한눈에 파악하기 좋은 UI를 제공해 주어서 많은 개발자분들이 Swagger UI를 선호하는지 이유를 알게 되었습니다.
다만 애플리케이션 코드에 Swagger 어노테이션을 정의하는 게 과연 적절한가에 대해서는 저도 회의적인 의견입니다. 추후 Swagger 버전이 변경된다면 @Annotation 또한 변경될 것이고, 그때마다 코드에 작성한 Swagger 관련 내용을 수정해야 하는 번거로움과 Swagger가 아닌 다른 API 명세 라이브러리를 사용한다면... 많은 시간이 필요하다고 생각이 들었어요.
이러한 이유에서 오히려 Spring Rest Docs를 이용해 테스트 코드 기반 API 명세를 한다면 신규 테스트 코드만 작성하면 작업 시간을 줄일 수 있을 것이고, Swagger 정보를 수집하면서 알게 된 Spring Rest Docs와 Swagger를 하이브리드 형태는 현 개발환경에서 가장 최선의 선택이라 생각이 들었습니다.
긴 글 읽어주셔서 감사합니다!!
소스코드를 확인하고 싶으신 분들은 하기 URL에서 확인 가능합니다!
https://github.com/gomshiki/swagger-practice
이상 전달 끝!