지난 글에서는 Spring Rest Docs를 활용해 Api 문서를 자동화해보았다.
하지만 Rest Docs에 비해 상대적으로 오래전부터 Api 문서화에 쓰였던 툴이 있으니 바로 Swagger이다.
이번 글에서는 Swagger로 Api문서를 작성해보면서
어떤 점이 Rest Docs에 비해 좋았고, 또 아쉬웠는지 적어보고자 한다.
먼저 Swagger에 대해 이해하기 위해서는 OpenAPI에 대해 이해할 필요가 있다.
- OpenApI란?
: RESTful API의 표준 명세 작성 방식
: RESTful API가 API 그 자체의 표준 규정이라면,
: OpenApI Specification(OAS)는 그 API를 어떻게 문서로 명세해야하는지 표준 구조를 명시
OpenAPI를 지켜 문서를 작성한다면
- 코드 사용자가 코드 없이도 API 구조 파악이 쉬워진다.
- 개발 언어에 구애받지 않고 API 명세가 가능하다
ex) 다음과 같은 형식이다.
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
ref) OAS(Open API Specification) | 토스페이먼츠 개발자센터 (tosspayments.com)
- Swagger란?
: OAS 규정을 준수한 API 문서화를 도와주는 라이브러리
- Swagger 이전의 백엔드와 프론트 엔드의 소통
: url, request, response를 일일히 문서화
문제 1) 휴먼 에러 => 최신화된 정확한 API 정보가 필요하다
문제 2) 비일관성 => 일관적이면 좋겠다
문제 3) 테스트 불가 => 프론트 엔드가 API를 테스트하고 싶다
=> API 최신성을 반영하고 + 일관적이며 + 테스트 가능한 API 명세 라이브러리는 없을까?
=> Swagger 의 등장
- SwaggerFox vs SpringDoc
1) springfox
- mvnrepository기준으로 2020년 7월 이후로 업데이트가 되지 않았음
- 스프링 버전 3.x.x와 호환되지 않는다.
2) springdoc
- webflux라는 논 블로킹 비동기 방식의 웹 개발을 지원
- yml 파일 설정을 통해 그룹 간 API 정렬이 가능하다.(abc 순 등)
스프링 3.x.x 버전과의 호환 가능성을 생각했을 때, spring doc을 추천
의존성 추가
dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' // springdoc-openapi 의존성 추가
implementation('org.springdoc:springdoc-openapi-ui:1.7.0')
}
.properties 파일 설정
springdoc.swagger-ui.tags-sorter=alpha // tags 정렬 기준 : 알파벳 순
springdoc.swagger-ui.operations-sorter=alpha // 오퍼레이션 정렬 기준 : 알파벳 순
springdoc.api-docs.path=/api-docs // docs를 json format으로 볼 수 있는 경로
springdoc.swagger-ui.path=/swagger.html // docs를 html로 볼 수 있는 경로
springdoc.default-consumes-media-type=application/json;charset=UTF-8 //default request media-type
springdoc.default-produces-media-type=application/json;charset=UTF-8 // default response mdeia-type
그외 설정은 다음 링크를 참조하자
https://springdoc.org/#springdoc-openapi-core-properties
OpenApiConfig 설정
package org.example.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
Info info = new Info()
.title("member 관련 API")
.version("1.0")
.description("api 관련 설명입니다.");
return new OpenAPI()
.components(new Components())
.info(info);
}
}
INFO
- title : API docs 이름
- description : docs 관련 추가 설명
- version : API 명세 버전
여기까지 설정한 이후, 어플리케이션을 실행하고
다음 링크에 접속하게 되면 위의 화면이 보인다.
아직 명세한 내용이 없지만 info 부분에 대한 설정이 잘 표시되는 것을 확인할 수 있다.
Swagger OpenAPI Annotation
- @Tag : API 그룹 설정함
- name : 태그 이름
- description : 태그 관련 상세 기술
@Tag(name = "member", description = "member 관련 API")
@RestController
public class MemberController {...}
=> RestController API를 member라는 이름의 태그 그룹으로 분류
- @Operation: API 동작 명세 작성
- summary: API에 대한 간략 설명
- description : API에 대한 상세 설명
- requestBody : post/put/patch 등 request에 해당하는 정보 서술
@GetMapping("/member")
@Operation(summary = "전체 멤버 조회", description = "전체 멤버 정보 반환")
ResponseEntity<MemberResponses> getAllMembers() {
MemberResponses all = memberService.findAll();
return ResponseEntity.ok(all);
}
=> 전체 멤버를 조회하는 API 동작을 명세함
- @Schema : model에 대한 정보 작성
- 응답/요청이 객체 형태일때 model에 대한 상세한 설명이 가능
- description : 설명
- example
- defaultValue
@Schema(name="member", description = "멤버")
public record MemberResponse (
@Schema(name="id", description = "멤버 아이디", example = "1")
long id,
@Schema(name = "name", description = "멤버 이름", example = "memberName")
String name,
@Schema(name = "age", description = "멤버의 나이", example = "10")
int age
){
}
@Schema(name = "신규 멤버 request", description = "신규 멤버 관련 정보입니다.")
@ParameterObject
public record MemberRequest(
@Schema(description = "신규 멤버의 이름입니다.", example = "신규 멤버 이름", type = "string")
@NotNull @Length(min = 1, max = 50) String name,
@Schema(description = "신규 멤버 나이", example = "10", type="integer")
@Positive int age
) {
}
-@Parameter : API parameter정보 작성
- `name` : 파라미터 이름
- description : 파라미터 설명
- in : 파라미터 위치(query, header, path, cookie
- required : 필수 여부
- @Parameters 안에 복수개를 한번에 작성 가능
@GetMapping("/member-name")
ResponseEntity<MemberResponse> getMemberByName(
@Parameter(description = "조회할 멤버 이름", required = true)
@RequestParam(value = "name") String name
) {
MemberResponse member = memberService.findByName(name);
return ResponseEntity.ok(member);
}
-- @Schema vs @Parameter
: 두 애너테이션의 의미와 목적을 구분하는 것이 중요하다
: 스키마는 모델에 대한 설명 vs 파라미터는 인수에 대한 설명이다.
: dto 각 필드에 대한 설명은 @Schema를 통해 명세한다
: path variable 혹은 query string을 통한 파라미터는 @Parameter를 통해 명세한다
- @ApiResponse : API 응답 명세 작성
- responseCode : 응답 코드
- description : 응답 세부 설명
- content : 응답에 관한 특정 미디어 타입/ 응답 바디의 스키마 정보 정의
- @Content : 미디어 타입이나 스키마 정보 정의
- @ApiResposes를 통해 가능한 성공/실패 시나리오 명세 가능
@GetMapping("/member/{id}")
@Operation(summary = "아이디로 멤버 조회", description = "일치하는 멤버 반환")
@ApiResponses(value = {
@ApiResponse(responseCode ="200", content = @Content(schema = @Schema(implementation = MemberResponse.class))),
@ApiResponse(responseCode = "404", description = "id와 일치하는 멤버가 없을 때")
})
ResponseEntity<MemberResponse> findById(
@Parameter(description = "조회할 멤버 아이디", required = true)
@PathVariable(name = "id") Long id) {
MemberResponse member = memberService.findById(id);
return ResponseEntity.ok(member);
}
최종 controller 코드
위의 swagger 명세를 적용한 코드는 다음과 같다.
package org.example;
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.parameters.RequestBody;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Tag(name = "member", description = "member 관련 API")
@RestController
public class MemberController {
@Autowired
MemberService memberService;
@PostMapping("/member")
@Operation(
summary = "회원 등록",
description = "신규 회원 정보 반환",
requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = MemberRequest.class))))
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "등록한 신규 회원 정보",
content = {@Content(schema = @Schema(implementation = MemberResponses.class))}),
@ApiResponse(responseCode = "404", description = "요청 정보가 잘못되었을 때")
}
)
ResponseEntity<MemberResponse> saveMember(@org.springframework.web.bind.annotation.RequestBody MemberRequest request) {
MemberResponse memberResponse = memberService.save(request);
return ResponseEntity.ok(memberResponse);
}
@GetMapping("/member")
@Operation(summary = "전체 멤버 조회", description = "전체 멤버 정보 반환")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "전체 회원 정보입니다.",
content = @Content(schema = @Schema(implementation = MemberResponses.class)))
})
ResponseEntity<MemberResponses> getAllMembers() {
MemberResponses all = memberService.findAll();
return ResponseEntity.ok(all);
}
@GetMapping("/member-name")
@Operation(summary = "이름으로 멤버 조회", description = "일치하는 멤버 반환")
@ApiResponses(value = {
@ApiResponse(responseCode ="200", content = @Content(schema = @Schema(implementation = MemberResponse.class))),
@ApiResponse(responseCode = "404", description = "이름과 일치하는 멤버가 없을 때")
})
ResponseEntity<MemberResponse> getMemberByName(
@Parameter(description = "조회할 멤버 이름", required = true)
@RequestParam(value = "name") String name
) {
MemberResponse member = memberService.findByName(name);
return ResponseEntity.ok(member);
}
@GetMapping("/member/{id}")
@Operation(summary = "아이디로 멤버 조회", description = "일치하는 멤버 반환")
@ApiResponses(value = {
@ApiResponse(responseCode ="200", content = @Content(schema = @Schema(implementation = MemberResponse.class))),
@ApiResponse(responseCode = "404", description = "id와 일치하는 멤버가 없을 때")
})
ResponseEntity<MemberResponse> findById(
@Parameter(description = "조회할 멤버 아이디", required = true)
@PathVariable(name = "id") Long id) {
MemberResponse member = memberService.findById(id);
return ResponseEntity.ok(member);
}
}
다시 링크에 접속해보면 다음과 같은 api docs를 확인할 수 있다
swagger를 통한 docs는 어떤 기능을 제공할까
기능1) OAS 형식대로 API 명세
- 요청에 대한 설명
- 응답 값과 시나리오별 응답코드
기능2) API 테스트
: 코드 사용자가 직접 파라미터를 넣고 예상 응답값을 확인할 수도 있다
=> 지금은 service에서 무조건 testName을 가진 회원을 반환하도록 코드를 짜놓았다.
=> 코드 작동 결과 예시를 받아볼 수 있다는 것이 코드 사용자 입장에서 정말 큰 장점인 것 같다
기능3) 스키마 구조 명세
- 요청과 응답 전달 과정에 쓰이는 객체들의 스키마 구조도 확인할 수 있다
Swagger - 느낀 점
장점
- 설정이 쉽고 간단하다
- 코드 사용자가 직접 api 테스트를 해볼 수 있다
- ui가 상대적으로 깔끔하다
단점
- 프로덕션 코드가 매우 더러워진다
- rest docs에 비해 api 동작이 보장되지 않는다 by 로직 테스트의 부재
커스텀 애너테이션으로 프로덕션 코드에서 swagger 코드 걷어내기
그럼 swagger의 단점인 프로덕션 코드의 침해를 보완할 수는 없을까?
커스텀 애너테이션을 만들어 어느정도는 가독성을 챙길 수 있다.
예를 들어 200 ok를 반환하는 코드를 다음과 같이 만들어주고
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Operation()
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MemberResponse.class)))
public @interface SwaggerOk {
String summary() default "";
String description() default "";
}
공통된 부분에 다음과 같이 애너테이션을 활용할 수 있다
@GetMapping("/member-name")
@SwaggerOk(summary = "이름으로 멤버 조회", description = "일치하는 멤버 반환")
@ApiResponses(value = {
@ApiResponse(responseCode = "404", description = "이름과 일치하는 멤버가 없을 때")
})
ResponseEntity<MemberResponse> getMemberByName(
@Parameter(description = "조회할 멤버 이름", required = true)
@RequestParam(value = "name") String name
) {
MemberResponse member = memberService.findByName(name);
return ResponseEntity.ok(member);
}
이제 공통된 부분들을 크로스 커팅하는 애너테이션들을 적용해보자
package org.example;
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.parameters.RequestBody;
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 org.example.customannotation.SwaggerBadRequest;
import org.example.customannotation.SwaggerOk;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Tag(name = "member", description = "member 관련 API")
@RestController
public class MemberController {
@Autowired
MemberService memberService;
@PostMapping("/member")
@Operation(requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = MemberRequest.class))))
@SwaggerOk(summary = "회원 등록", description = "신규 회원 정보 반환")
@SwaggerBadRequest(description = "요청 정보가 잘못되었을 때")
ResponseEntity<MemberResponse> saveMember(@org.springframework.web.bind.annotation.RequestBody MemberRequest request) {
MemberResponse memberResponse = memberService.save(request);
return ResponseEntity.ok(memberResponse);
}
@GetMapping("/member")
@Operation(summary = "전체 멤버 조회", description = "전체 멤버 정보 반환")
@ApiResponse(responseCode = "200", description = "전체 회원 정보입니다.",
content = @Content(schema = @Schema(implementation = MemberResponses.class)))
ResponseEntity<MemberResponses> getAllMembers() {
MemberResponses all = memberService.findAll();
return ResponseEntity.ok(all);
}
@GetMapping("/member-name")
@SwaggerOk(summary = "이름으로 멤버 조회", description = "일치하는 멤버 반환")
@SwaggerBadRequest(description = "이름과 일치하는 멤버가 없을 때")
ResponseEntity<MemberResponse> getMemberByName(
@Parameter(description = "조회할 멤버 이름", required = true)
@RequestParam(value = "name") String name
) {
MemberResponse member = memberService.findByName(name);
return ResponseEntity.ok(member);
}
@GetMapping("/member/{id}")
@SwaggerOk(summary="아이디로 멤버 조회", description = "일치하는 멤버 반환")
@SwaggerBadRequest(description = "id와 일치하는 멤버가 없을 때")
ResponseEntity<MemberResponse> findById(
@Parameter(description = "조회할 멤버 아이디", required = true)
@PathVariable(name = "id") Long id) {
MemberResponse member = memberService.findById(id);
return ResponseEntity.ok(member);
}
}
그래도 조금은 가독성이 나아진 모습을 볼 수 있다.
하지만, 애플리케이션 규모가 커지고, 설정 값의 공통 부분이 많지 않으면 각각의 상황을 애너테이션으로 만들기에는 한계가 있는 방안이라는 생각이 들었다.
Rest Docs vs Swagger
그래서 나는 무엇을 더 선호하게 되었을까?
Rest Docs를 쓰면서 가장 좋았던 점은 `안전성과 깔끔함`이다.
테스트를 통해 api 작동이 안정적으로 보장되어 있고, api 문서를 붙여준다는 느낌이 강했다.
특히, 이미 환경설정이 되어있고 테스트가 작성되어 있다면 시간이 오래걸릴 것 같지 않았다.
무엇보다 프로덕션코드에 영향을 주지 않는다는 점이 너무 마음에 들었다.
문서를 위한 코드가 격리되어 있다는 것은 수정 포인트가 응집되어 있다는 것을 의미한다.
또한, 유지보수를 강제한다는 점에서도 시간이 걸리더라도 API 최신성 관리가 가능하다는 것을 의미한다.
Swagger를 쓰면서 좋았던 점은 '편의성'이다.
상대적으로 환경설정이 쉬웠고 애너테이션을 통한 문서화가 간단했다.
무엇보다 클라이언트 입장에서는 더 좋아할 것 같았다. 스키마 구조도 볼 수 있고 테스트도 할 수 있다.
그러나, 프로덕션 코드에 너무 많은 docs 로직이 침투해 지저분해 보이는 건 어쩔 수 없어졌다.
결론적으로 나는 케바케로 문서화 도구를 선택할 것 같다.
애자일하게 빨리 개발해야 하는 상황에서는 변화에 빠르게 대응할 수 있는 swagger가 좋을 것 같다.
그러나, 시간적 여유가 있고 오류 하나에도 치명적 결과가 일어나는 운영서버가 있는 개발환경에서는
Rest Docs가 더 좋을 것 같았다.
ref)
예제 코드)
https://github.com/coli-geonwoo/blog_hey_bro/tree/master/swagger
'우테코' 카테고리의 다른 글
우테코 미션으로 찍먹한 QueryDsl : 동적쿼리 (0) | 2024.06.21 |
---|---|
[우테코- Lv3] 아이디어 기획본1 (0) | 2024.06.19 |
[Rest Docs vs Swagger] 1편 : Rest Docs로 API 문서 자동화해보기 (0) | 2024.06.13 |
[Spring] 유효성 검사 (0) | 2024.05.11 |
[Spring] 웹 요청 - 응답 과정 (2) | 2024.04.24 |