REST API를 만들고 나면 문서가 항상 뒤로 밀린다. 코드는 계속 바뀌는데 위키나 노션에 손으로 적어둔 파라미터 표는 금세 낡는다. 저는 이럴 때 문서를 사람이 쓰지 않고 코드에서 뽑아내는 방향으로 정리한다. Spring Boot 3라면 springdoc-openapi 한 줄로 시작할 수 있다.
결론부터 말하자면, springdoc-openapi-starter-webmvc-ui 의존성 하나를 넣으면 컨트롤러를 스캔해 OpenAPI 3 스펙(/v3/api-docs)과 Swagger UI(/swagger-ui.html)를 자동으로 만들어준다. 여기에 어노테이션으로 예시와 JWT 인증을 덧붙이고, 그룹을 나눠 문서를 쪼갠 뒤, 그 스펙을 Postman으로 가져와 newman으로 테스트까지 돌리는 순서로 진행한다.
OpenAPI와 springdoc-openapi가 무엇인가
OpenAPI는 REST API의 엔드포인트, 파라미터, 응답 구조, 인증 방식을 기계가 읽을 수 있는 JSON 또는 YAML로 기술하는 표준 규격이다. 예전 이름이 Swagger라서 지금도 두 단어가 섞여 쓰인다. 규격이 OpenAPI, 그 스펙을 화면으로 그려주는 도구가 Swagger UI라고 보면 된다.
springdoc-openapi는 스프링 컨트롤러와 DTO를 런타임에 훑어 이 OpenAPI 스펙을 만들어주는 라이브러리다. 과거 springfox를 쓰던 프로젝트라면 이제 springdoc으로 넘어와야 한다. springfox는 Spring Boot 3와 Jakarta 네임스페이스를 지원하지 않는다.
버전 선택: Spring Boot 3는 springdoc 2.x
Spring Boot 3는 javax.*에서 jakarta.*로 넘어갔기 때문에 springdoc도 2.x 계열을 써야 한다. 1.x는 Spring Boot 2 전용이다. 현재 2.x 최신은 2.8 계열이고 Java 17 이상을 요구한다. 아티팩트 이름도 1.x와 달라졌으니 주의한다.
Maven이면 이렇게 넣는다. 버전 번호는 릴리스 상태에 따라 다를 수 있으니 최신 2.x를 확인하고 고정한다.
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
Gradle이면 한 줄이다.
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
WebFlux 프로젝트라면 -webmvc-ui 대신 springdoc-openapi-starter-webflux-ui를 쓴다. 여기서는 가장 흔한 Spring MVC 기준으로 설명한다.
의존성만 넣고 바로 확인하기
추가 설정 없이 앱을 띄우면 두 경로가 열린다. 스펙 JSON과 Swagger UI다.
$ curl -s http://localhost:8080/v3/api-docs | head -c 200
{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{...}}
브라우저로는 http://localhost:8080/swagger-ui.html에 접속한다. 이 주소는 실제로 /swagger-ui/index.html로 리다이렉트된다. 여기서 한 번 막히기 쉬운데, 컨텍스트 패스(server.servlet.context-path)를 설정했다면 그 앞에 컨텍스트 패스가 붙는다. 404가 나면 이 경로부터 확인한다.
YAML로 받고 싶으면 /v3/api-docs.yaml을 호출한다. 뒤에서 Postman으로 가져올 때는 JSON 쪽이 편하다.
경로를 바꾸거나 운영에서 끄기
application.yml에서 경로와 노출 여부를 조정한다. 운영 환경에서 문서를 닫고 싶으면 enabled를 false로 둔다.
springdoc:
api-docs:
path: /v3/api-docs # 스펙 JSON 경로
enabled: true
swagger-ui:
path: /swagger-ui.html # UI 진입 경로
enabled: true
tags-sorter: alpha
operations-sorter: alpha
어노테이션으로 문서에 살 붙이기
자동 생성만으로는 설명이 비어 있다. 컨트롤러와 DTO에 어노테이션을 달아 제목, 설명, 예시, 응답 코드를 채운다. 핵심 어노테이션은 몇 개 안 된다.
@Tag: 컨트롤러 단위로 문서를 묶는 그룹 이름@Operation: 엔드포인트 하나의 요약과 설명@Parameter: 쿼리, 패스 파라미터 설명과 예시@ApiResponse: HTTP 상태 코드별 응답 문서화@Schema: DTO 필드의 설명, 예시, 필수 여부@Hidden: 문서에서 제외
컨트롤러 예시다. 각 응답 코드에 대표 예시(JSON 문자열)를 붙였다.
@Tag(name = "members", description = "회원 조회와 가입")
@RestController
@RequestMapping("/api/members")
public class MemberController {
@Operation(summary = "회원 단건 조회", description = "ID로 회원 한 명을 조회한다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = MemberResponse.class),
examples = @ExampleObject(value = """
{\"id\": 1, \"email\": \"kim@sarc.io\", \"nickname\": \"kim\"}"""))),
@ApiResponse(responseCode = "404", description = "회원 없음",
content = @Content(examples = @ExampleObject(value = """
{\"code\": \"MEMBER_NOT_FOUND\", \"message\": \"회원을 찾을 수 없다\"}""")))
})
@GetMapping("/{id}")
public MemberResponse getMember(
@Parameter(description = "회원 ID", example = "1")
@PathVariable Long id) {
return memberService.findById(id);
}
}
DTO에는 @Schema로 필드 의미와 예시를 적는다. 이걸 채워두면 Swagger UI의 Example Value와 Schema 탭이 그럴듯하게 그려진다.
public record MemberResponse(
@Schema(description = "회원 ID", example = "1")
Long id,
@Schema(description = "이메일", example = "kim@sarc.io")
String email,
@Schema(description = "닉네임", example = "kim", requiredMode = Schema.RequiredMode.REQUIRED)
String nickname
) {}
여기서 자주 걸리는 함정 하나. Bean Validation 어노테이션(@NotNull, @Size 등)을 DTO에 달아두면 springdoc이 그 제약을 스펙의 required와 maxLength로 자동 반영한다. 그래서 @Schema에 필수 여부를 중복으로 적을 필요가 없는 경우가 많다. 두 곳이 어긋나면 검증 어노테이션 쪽이 실제 동작이니 그쪽을 기준으로 맞춘다.
JWT 인증을 문서에 표기하기
Bearer 토큰을 쓰는 API라면 Swagger UI에서 Authorize 버튼으로 토큰을 넣고 바로 호출해볼 수 있게 만든다. 보안 스킴을 한 번 정의해두는 방식이다. 설정 클래스에서 OpenAPI 빈을 등록한다.
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI sarcOpenAPI() {
final String schemeName = "bearer-jwt";
return new OpenAPI()
.info(new Info().title("sarc API").version("v1")
.description("sarc.io 백엔드 REST API"))
.addSecurityItem(new SecurityRequirement().addList(schemeName))
.components(new Components()
.addSecuritySchemes(schemeName, new SecurityScheme()
.name(schemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
addSecurityItem을 전역에 걸면 모든 엔드포인트에 자물쇠가 붙는다. 로그인이나 회원가입처럼 토큰이 필요 없는 엔드포인트는 전역 요구를 빼고, 보호가 필요한 컨트롤러에만 @SecurityRequirement(name = "bearer-jwt")를 다는 방식이 깔끔하다.
@SecurityRequirement(name = "bearer-jwt")
@Operation(summary = "내 프로필 조회")
@GetMapping("/me")
public MemberResponse me() { ... }
이렇게 하면 Swagger UI 상단 Authorize 창에 토큰을 한 번 붙여넣고, 이후 호출마다 Authorization: Bearer ... 헤더가 자동으로 실린다. bearerFormat은 표시용 힌트라서 실제 검증에 영향을 주지는 않는다.
그룹으로 문서 쪼개기
엔드포인트가 많아지면 하나의 스펙에 다 몰아넣기가 부담스럽다. 공개 API와 관리자 API를 따로 보여주고 싶을 때 GroupedOpenApi로 그룹을 나눈다. 경로 패턴이나 패키지 기준으로 묶을 수 있다.
@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/api/**")
.pathsToExclude("/api/admin/**")
.build();
}
@Bean
public GroupedOpenApi adminApi() {
return GroupedOpenApi.builder()
.group("admin")
.pathsToMatch("/api/admin/**")
.build();
}
그룹을 정의하면 스펙 경로가 그룹별로 갈라진다. Swagger UI 우측 상단 드롭다운에서 그룹을 골라 볼 수 있다.
$ curl -s http://localhost:8080/v3/api-docs/public | head -c 80
{"openapi":"3.1.0","info":{...},"paths":{"/api/members/{id}":{...
$ curl -s http://localhost:8080/v3/api-docs/admin | head -c 80
{"openapi":"3.1.0","info":{...},"paths":{"/api/admin/members":{...
뒤에서 newman으로 테스트할 때 이 그룹별 스펙 경로가 요긴하다. 공개 API만 골라 테스트 컬렉션을 만들 수 있다.
스펙을 Postman으로 가져오기
이제 문서를 테스트로 바꾼다. Postman은 OpenAPI 스펙을 임포트하면 엔드포인트마다 요청이 채워진 컬렉션을 자동으로 만들어준다. 먼저 스펙 파일을 내려받는다.
$ curl -s http://localhost:8080/v3/api-docs/public -o openapi-public.json
Postman 앱에서 Import로 이 파일을 넣고, 임포트 옵션에서 "Generate collection from imported APIs"를 켜면 컬렉션이 생성된다. 각 요청에는 @Example로 넣어둔 예시 값이 바디에 채워진다. 여기에 로그인 후 토큰을 넣는 사전 스크립트나 응답 검증 테스트를 붙인 뒤, 컬렉션과 환경을 파일로 Export한다. 이 두 파일을 CI에서 돌릴 것이다.
sarc-collection.json # 익스포트한 컬렉션(v2.1 포맷) sarc-local.postman_environment.json # baseUrl, token 등 변수
컬렉션을 익스포트할 때 포맷 버전에 주의한다. newman은 Collection v2.1 포맷을 읽는다. Postman 최신 버전에서 도입된 v3 포맷 컬렉션은 newman과 호환되지 않으니, 익스포트 시 v2.1을 선택한다.
newman으로 테스트 자동화
newman은 Postman 컬렉션을 GUI 없이 커맨드라인에서 실행하는 러너다. CI 파이프라인에 넣기 좋다. Node.js가 있으면 npm으로 설치한다.
$ npm install -g newman $ newman --version 6.2.1
컬렉션과 환경 파일을 넘겨 실행한다. 로컬 앱이 떠 있는 상태에서 돌린다.
$ newman run sarc-collection.json \
-e sarc-local.postman_environment.json
newman
sarc API
→ members / get member
GET http://localhost:8080/api/members/1 [200 OK, 512B, 41ms]
✓ status is 200
✓ body has email field
→ members / get member (404)
GET http://localhost:8080/api/members/99999 [404 Not Found, 320B, 12ms]
✓ status is 404
┌─────────────────────────┬──────────┐
│ │ executed │ failed │
├─────────────────────────┼──────────┤
│ assertions │ 3 │ 0 │
└─────────────────────────┴──────────┘
테스트가 하나라도 실패하면 newman은 종료 코드 1로 끝난다. 그래서 CI에서 별도 판정 로직 없이 그대로 게이트로 쓸 수 있다. 응답 시간은 환경에 따라 다르지만 로컬 단순 조회면 대략 수십 밀리초 수준이다.
리포트와 CI 연결
리포터를 바꿔 JSON이나 JUnit XML로 결과를 남긴다. CI 대시보드에 테스트 결과를 표시할 때 JUnit 형식이 유용하다.
$ newman run sarc-collection.json \
-e sarc-ci.postman_environment.json \
-r cli,junit \
--reporter-junit-export newman-report.xml
GitHub Actions라면 앱을 백그라운드로 띄우고, 헬스체크가 통과한 뒤 위 명령을 실행하는 스텝을 넣으면 된다. 스펙이 바뀌면 Postman에서 컬렉션을 다시 임포트해 갱신한다. 코드에서 문서가 나오고, 그 문서가 다시 테스트가 되는 셈이라 한 곳만 관리하면 된다.
정리
Spring Boot 3에서는 springdoc-openapi 2.x 스타터 하나로 Swagger UI와 OpenAPI 스펙을 자동 생성한다. 어노테이션으로 예시와 JWT 인증을 표기하고, GroupedOpenApi로 공개와 관리자 문서를 나눈 뒤, 그 스펙을 Postman으로 임포트해 newman으로 CI에서 돌리면 문서와 테스트를 한 소스에서 유지할 수 있다. 시작은 의존성 한 줄, 확인은 /swagger-ui.html 접속부터 하면 된다.