Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/reference/api-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# API 버전 정책

> 관련 이슈: #44

## 결정 — 헤더로 협상한다

| 방식 | 판단 |
|------|------|
| 경로 (`/api/v1/facilities`) | 채택하지 않음 |
| **헤더 (`X-API-Version: 1`)** | **채택** |

경로 방식을 쓰지 않는 이유:

- 프런트가 부르는 모든 경로와 `SecurityConfig` 의 인가 규칙이 버전 없는 경로로 짜여 있다.
경로에 버전을 넣으면 인가 규칙을 두 벌로 유지해야 하고, 한쪽만 고치면 그대로 구멍이 된다.
- 버전이 갈리는 건 보통 일부 API 뿐이다. 헤더 방식은 바뀐 API 만 새 버전 처리를 두면 된다.

예전 `BaseController` 의 `@RequestMapping("/api/v1")` 은 하위 컨트롤러가 전부 덮어써서 어떤 경로에도
적용된 적이 없다. 오해만 낳아 지웠다.

## 규칙

| 요청 | 처리 | 응답 헤더 |
|------|------|-----------|
| 헤더 없음 | 현재 버전(1) | `X-API-Version: 1` |
| `X-API-Version: 1` 또는 `v1` | 1 | `X-API-Version: 1` |
| 지원하지 않는 값 (`2`, `abc` …) | **400** `API_VERSION_UNSUPPORTED` | `X-API-Version: 1` |

- **헤더가 없으면 현재 버전**이다. 지금 클라이언트는 아무것도 바꾸지 않아도 된다.
- 모르는 버전을 400 으로 거절하는 이유: 다른 버전을 기대한 클라이언트에게 조용히 현재 버전을 주면
필드가 어긋나도 알아채지 못한다. 이 프로젝트에서 실제로 여러 번 겪은 실패 방식이다.
- 구현: `core/web/ApiVersionFilter`. CORS 허용·노출 헤더에 포함돼 브라우저에서도 읽을 수 있다.
- Swagger 의 모든 API 에 선택 헤더로 표시된다.

## 호환되지 않는 변경을 할 때

필드 삭제·이름 변경·타입 변경·필수값 추가가 여기에 해당한다. 필드 **추가**는 호환 변경이다.

1. `ApiVersionFilter.SUPPORTED` 에 새 버전을 추가한다 (`CURRENT` 는 아직 올리지 않는다).
2. 바뀌는 API 에서 요청 속성 `ApiVersionFilter.REQUEST_ATTRIBUTE` 로 버전을 보고 응답을 가른다.
3. 프런트가 새 버전 헤더를 보내도록 배포한다.
4. 옛 버전 응답에 `Deprecation`·`Sunset` 헤더를 붙여 종료일을 알린다.
5. 종료일이 지나면 옛 버전을 `SUPPORTED` 에서 빼고 `CURRENT` 를 올린다.

가능하면 1~5 대신 **새 필드를 추가하고 옛 필드를 유지**하는 호환 변경을 먼저 검토한다.
(예: 정책 검색 응답의 `totalCount` 는 `totalElements` 를 추가한 뒤에도 남겨 두었다.)
17 changes: 15 additions & 2 deletions src/main/java/com/carecode/core/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("CareCode API")
.description("육아 지원 플랫폼 맘편한의 REST API 문서")
.version("1.0.0")
.description("육아 지원 플랫폼 맘편한의 REST API 문서. "
+ "API 버전은 `X-API-Version` 요청 헤더로 지정합니다. 생략하면 현재 버전(1)이며, "
+ "응답의 `X-API-Version` 헤더가 실제로 처리한 버전입니다.")
.version("1")
.contact(new Contact()
.name("CareCode Team")
.email("dhxogns920@naver.com")
Expand Down Expand Up @@ -59,6 +61,17 @@ public OpenAPI customOpenAPI() {
.description("기본 인증 정보를 입력하세요")));
}

/** 모든 API 에 선택 헤더 X-API-Version 을 문서로 보여 준다. */
@Bean
public org.springdoc.core.customizers.OperationCustomizer apiVersionHeader() {
return (operation, handlerMethod) -> operation.addParametersItem(
new io.swagger.v3.oas.models.parameters.HeaderParameter()
.name(com.carecode.core.web.ApiVersionFilter.HEADER)
.required(false)
.description("API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400")
.schema(new io.swagger.v3.oas.models.media.StringSchema()._default("1")));
}

// 환경별 서버 목록 생성
private List<Server> createServerList() {
List<Server> servers = new ArrayList<>();
Expand Down
11 changes: 7 additions & 4 deletions src/main/java/com/carecode/core/controller/BaseController.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.carecode.core.controller;

import org.springframework.web.bind.annotation.RequestMapping;

/** 모든 컨트롤러의 기본 클래스 공통 URL 경로와 기본 설정을 제공 */
@RequestMapping("/api/v1")
/**
* 컨트롤러 공통 상수.
*
* <p>예전에는 여기에 {@code @RequestMapping("/api/v1")} 이 있었지만, 하위 컨트롤러가 전부 자기
* {@code @RequestMapping} 으로 덮어써서 어떤 경로에도 적용된 적이 없다. "경로 버전을 쓰고 있다" 는
* 오해만 낳아 지웠다. API 버전은 {@link com.carecode.core.web.ApiVersionFilter} 가 헤더로 다룬다.
*/
public abstract class BaseController {

// 공통 응답 메시지 상수
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/carecode/core/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true);
configuration.setAllowedOriginPatterns(allowedOrigins);
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "Accept"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "Accept", "X-API-Version"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setExposedHeaders(List.of("Authorization", "X-Refresh-Token"));
configuration.setExposedHeaders(List.of("Authorization", "X-Refresh-Token", "X-API-Version", "X-Request-Id"));
return configuration;
})) // CORS 활성화
.csrf(AbstractHttpConfigurer::disable)
Expand Down
88 changes: 88 additions & 0 deletions src/main/java/com/carecode/core/web/ApiVersionFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.carecode.core.web;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Set;

/**
* API 버전 협상. 버전은 경로가 아니라 {@code X-API-Version} 헤더로 주고받는다.
*
* <p>경로 버전({@code /api/v1/...})을 쓰지 않는 이유:
* <ul>
* <li>프런트가 부르는 모든 경로와 SecurityConfig 의 인가 규칙이 버전 없는 경로로 짜여 있다.
* 경로를 바꾸면 인가 규칙을 전부 두 벌로 유지해야 하고, 한쪽만 고치면 그대로 구멍이 된다.</li>
* <li>버전이 갈리는 건 일부 API 뿐이다. 헤더 방식은 바뀐 API 만 새 버전 처리를 두면 된다.</li>
* </ul>
*
* <p>규칙 ({@code docs/reference/api-versioning.md}):
* <ul>
* <li>헤더가 없으면 현재 버전({@value #CURRENT})으로 처리한다. 기존 클라이언트는 아무것도 바꾸지 않아도 된다.</li>
* <li>{@code 1}, {@code v1} 모두 받는다. 지원하지 않는 버전은 400 으로 거절한다 — 다른 버전을 기대한
* 클라이언트에게 조용히 현재 버전 응답을 주면 필드가 어긋나도 알아채지 못한다.</li>
* <li>응답에는 항상 실제로 처리한 버전을 {@code X-API-Version} 으로 돌려준다.</li>
* </ul>
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class ApiVersionFilter extends OncePerRequestFilter {

public static final String HEADER = "X-API-Version";
public static final int CURRENT = 1;
public static final Set<Integer> SUPPORTED = Set.of(1);

/** 컨트롤러에서 처리 중인 버전을 꺼낼 때 쓴다. 새 버전이 생겨 응답이 갈릴 때 필요하다. */
public static final String REQUEST_ATTRIBUTE = ApiVersionFilter.class.getName() + ".version";

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String raw = request.getHeader(HEADER);
Integer version = parse(raw);

if (version == null || !SUPPORTED.contains(version)) {
reject(response);
return;
}

request.setAttribute(REQUEST_ATTRIBUTE, version);
response.setHeader(HEADER, String.valueOf(version));
filterChain.doFilter(request, response);
}

/** 헤더가 없으면 현재 버전. 형식이 틀리면 null. */
static Integer parse(String raw) {
if (raw == null || raw.isBlank()) {
return CURRENT;
}
String value = raw.trim().toLowerCase(Locale.ROOT);
if (value.startsWith("v")) {
value = value.substring(1);
}
if (!value.matches("[0-9]{1,3}")) {
return null;
}
return Integer.parseInt(value);
}

private void reject(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setHeader(HEADER, String.valueOf(CURRENT));
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
// 헤더 값은 응답에 되돌려 쓰지 않는다. 그대로 넣으면 JSON 을 깨뜨리거나 주입에 쓰일 수 있다.
response.getWriter().write("{\"code\":\"API_VERSION_UNSUPPORTED\","
+ "\"message\":\"지원하지 않는 API 버전입니다. X-API-Version 헤더를 빼거나 지원 버전을 지정하세요.\","
+ "\"supportedVersions\":" + SUPPORTED.stream().sorted().toList() + "}");
}
}
2 changes: 1 addition & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ server:

app:
api:
base-url: /api/v1
# 버전은 경로가 아니라 X-API-Version 헤더로 협상한다 (docs/reference/api-versioning.md).
version: v1
title: 맘편한 API
description: 육아 지원 플랫폼 맘편한의 REST API
Expand Down
61 changes: 61 additions & 0 deletions src/test/java/com/carecode/core/web/ApiVersionFilterTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.carecode.core.web;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("API 버전 헤더")
class ApiVersionFilterTest {

private final ApiVersionFilter filter = new ApiVersionFilter();

@Test
@DisplayName("헤더가 없으면 현재 버전으로 처리하고 응답에 버전을 알린다 — 기존 클라이언트는 그대로 동작")
void missingHeaderMeansCurrent() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/facilities");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();

filter.doFilter(request, response, chain);

assertThat(chain.getRequest()).as("다음 필터로 넘어간다").isNotNull();
assertThat(response.getHeader(ApiVersionFilter.HEADER)).isEqualTo("1");
assertThat(request.getAttribute(ApiVersionFilter.REQUEST_ATTRIBUTE)).isEqualTo(1);
}

@ParameterizedTest
@ValueSource(strings = {"1", "v1", "V1", " 1 "})
void acceptsSupportedForms(String header) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/facilities");
request.addHeader(ApiVersionFilter.HEADER, header);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();

filter.doFilter(request, response, chain);

assertThat(chain.getRequest()).isNotNull();
assertThat(response.getStatus()).isEqualTo(200);
}

@ParameterizedTest
@ValueSource(strings = {"2", "v9", "abc", "1.0", "<script>"})
@DisplayName("지원하지 않거나 형식이 틀린 버전은 400 이고 컨트롤러까지 가지 않는다")
void rejectsUnsupported(String header) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/facilities");
request.addHeader(ApiVersionFilter.HEADER, header);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();

filter.doFilter(request, response, chain);

assertThat(chain.getRequest()).as("다음 필터로 넘어가지 않는다").isNull();
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.getContentAsString()).contains("API_VERSION_UNSUPPORTED").doesNotContain(header.trim());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,17 @@ void publicDataReadStaysOpen(String path) throws Exception {
.isNotIn(401, 403);
}

/** 버전 필터가 실제 필터 체인에 걸려 있는지. 단위 테스트만으로는 등록 누락을 모른다. */
@Test
@DisplayName("응답에 API 버전 헤더가 붙고, 모르는 버전은 400 이다")
void apiVersionHeader() throws Exception {
MvcResult ok = mockMvc.perform(get("/facilities")).andReturn();
assertThat(ok.getResponse().getHeader("X-API-Version")).isEqualTo("1");

MvcResult unsupported = mockMvc.perform(get("/facilities").header("X-API-Version", "9")).andReturn();
assertThat(unsupported.getResponse().getStatus()).isEqualTo(400);
}

/** 사용자 목록·검색은 전체 회원 개인정보다. 로그인만 했다고 열리면 안 된다. */
@ParameterizedTest(name = "{0} 은 일반 회원에게 403 이다")
@ValueSource(strings = {
Expand Down
Loading