CJ푸드빌처럼 매장·키오스크·모바일 앱·웹·파트너사 연동을 동시에 운영하는 도메인에서 REST API는 여러 세대의 클라이언트가 동시에 살아 있는 상태를 전제로 한다. 백엔드는 일주일에 두세 번 배포할 수 있지만, iOS/Android 앱은 그렇지 않다. 앱스토어 심사, 사용자 강제 업데이트 동의, 구버전 OS 잔존, 사내 매장 단말의 펌웨어 라이프사이클까...
CJ푸드빌처럼 매장·키오스크·모바일 앱·웹·파트너사 연동을 동시에 운영하는 도메인에서 REST API는 여러 세대의 클라이언트가 동시에 살아 있는 상태를 전제로 한다. 백엔드는 일주일에 두세 번 배포할 수 있지만, iOS/Android 앱은 그렇지 않다. 앱스토어 심사, 사용자 강제 업데이트 동의, 구버전 OS 잔존, 사내 매장 단말의 펌웨어 라이프사이클까지 고려하면 "옛날 클라이언트가 오늘도 호출한다"는 사실이 운영의 기본값이다.
이 상태에서 API를 그냥 바꾸면 앱이 흰 화면을 띄우고, 키오스크가 결제 직전에 멈추고, 파트너사 정산 배치가 깨진다. 면접에서 "모바일 앱이 강제 업데이트가 안 되는 환경에서 결제 응답 스키마를 바꿔야 한다면 어떻게 하시겠습니까"라는 질문이 나오면, 단순히 "v2로 올린다"는 대답으로는 시니어 후보로 인식되지 않는다. 버저닝 전략, 호환성 룰, 폐기 절차, 롤백 안전망, 검증 수단까지 한 줄기로 설명할 수 있어야 한다.
이 문서는 그 한 줄기를 정리한다. 큰 분류 체계나 RFC 인용을 목표로 하지 않고, 실제 백엔드 엔지니어가 모바일 앱 호환성 사고를 줄이기 위해 무엇을 안 바꾸고, 무엇은 어떻게 바꾸고, 어떻게 측정하는지를 다룬다.
API 버저닝의 본질은 "버전 번호를 어디 박을 것인가"가 아니라 "이 변경이 기존 클라이언트를 깨뜨리는가"를 정확히 분류하는 일이다. 클라이언트가 자유롭게 업그레이드할 수 있는 환경(서버-서버, 사내 콘솔)과 그렇지 않은 환경(앱스토어를 거치는 모바일 앱, 매장 단말)을 같은 규칙으로 다루면 안 된다.
다음 변경은 기본적으로 깨지는 변경으로 본다. 모바일 앱처럼 강제 업데이트가 어려운 채널에서는 이 목록을 더 보수적으로 적용한다.
int → string, string → object)amount가 KRW였는데 USD로 바뀜)다음은 일반적으로 안전한 additive change로 다룬다. 단, 클라이언트가 strict한 JSON 디시리얼라이저를 쓰면 추가 필드도 사고가 될 수 있으니 클라이언트 파서 정책을 사전에 합의하는 것이 전제다.
모바일 채널을 책임지는 백엔드는 클라이언트에게 **"우리가 모르는 필드와 enum이 와도 죽지 마라"**는 약속을 받아두어야 한다. 이걸 보통 tolerant reader라고 한다. iOS의 Codable, Android의 Moshi/Gson, Kotlin Serialization 모두 unknown key 무시 옵션을 제공한다. 백엔드 단독으로 결정할 수 있는 일은 아니지만, 버저닝 정책을 만들 때 모바일팀과 합의해야 할 0순위 항목이다.
면접에서 "왜 강제 업데이트가 어렵나요"를 물으면, 실제 운영 경험 없이도 다음 정도는 정리해서 말할 수 있어야 한다.
따라서 백엔드가 잡아야 할 가정은 단순하다. "오늘 배포한 API는 6개월~1년 뒤에도 같은 의미로 호출될 가능성이 있다."
이 가정을 받아들이면 v1을 v2로 한 번에 갈아엎는 시나리오는 거의 비현실적이라는 것이 자연스럽게 나온다.
크게 두 가지가 실무에서 살아남는다.
/v1/orders장점은 단순하고, 캐시 키가 자연스럽게 분리되며, 게이트웨이 라우팅이 쉽다는 것이다. 단점은 버전을 올리면 모든 경로가 새 트리로 분기해 코드 중복이 늘어난다는 점이다.
운영 관점에서는 메이저 버전만 URI에 박는다. 마이너 변경(필드 추가, 새 enum 추가)을 위해 v1.1, v1.2 같은 경로를 만드는 순간 라우팅과 문서가 폭발한다.
Accept: application/vnd.cjfoodville.v1+json 또는 X-API-Version: 2026-04-01날짜 기반 버저닝(Stripe 스타일)은 마이크로 버저닝이 가능해 모바일 앱에 매우 잘 맞는다. 클라이언트는 빌드 시점의 날짜를 박고, 서버는 그 날짜에 맞춘 응답을 만든다. 단점은 게이트웨이/캐시/관측 도구가 헤더를 인지하도록 추가 설정이 필요하고, 문서화가 URI보다 어렵다는 것이다.
모바일 앱 중심 도메인에서는 메이저는 URI, 마이너는 헤더의 하이브리드가 자주 보인다.
/v1/orders 경로 자체는 5~10년 단위로 유지X-API-Version: 2026-04-01 같은 날짜 헤더로 분기response에 loyaltyPoint를 새로 넣는다고 하자. 깨지지 않으려면 다음을 지킨다.
null = 미적립 대상, 0 = 적립 대상이지만 0점).{
"orderId": "ORD-2026-0001",
"amount": 18900,
"currency": "KRW",
"loyaltyPoint": null
}기존 클라이언트는 새 필드를 못 보낸다. 따라서 새 필드는 항상 optional이고, 서버는 누락 시 합리적인 기본값을 정의해야 한다. 기본값을 "에러"로 두면 사실상 breaking change다.
POST /v1/orders
{
"menuId": "MENU-001",
"quantity": 2,
"couponCode": "WELCOME" // 신규 필드. 누락 시 쿠폰 미적용으로 처리
}enum은 "추가는 안전하다"고 흔히 말하지만, 클라이언트가 unknown enum을 안전하게 처리하지 못하면 그 자리에서 크래시가 난다. 면접에서 매우 잘 나오는 지점이다.
문제 시나리오: OrderStatus에 기존 CREATED, PAID, CANCELED만 있었는데 REFUND_PENDING을 추가했다. iOS 앱이 Codable로 strict하게 디코딩하면 unknown enum에서 throw가 나서 주문 상세 화면 자체가 깨진다.
대응 원칙:
UNKNOWN으로 폴백하도록 설계하고, 화면에는 "처리 중"처럼 안전한 기본 메시지를 띄운다.REFUND_PENDING은 1.5.0 이전 클라이언트에서는 CANCELED로 다루어도 무방하다."기존에 항상 채워주던 discountAmount를 어느 시점부터 null이 올 수 있게 바꾸면, 옛날 클라이언트는 null.intValue()에서 NPE가 난다. "항상 있는 필드"를 nullable로 만드는 것은 사실상 breaking change다.
해결: 새 의미는 새 필드(discountDetail)로 추가하고, 기존 discountAmount는 가능한 한 의미를 유지한다.
GET /v1/orders/ORD-2026-0001
// before
{
"orderId": "ORD-2026-0001",
"amount": 18900,
"status": "PAID"
}
// after — 한 번에 변경
{
"orderId": "ORD-2026-0001",
"payment": { // amount, status가 사라지고
"totalAmount": 18900, // 안으로 들어가버림
"state": "PAID"
}
}옛날 앱은 amount/status를 그대로 읽으니 주문 상세 화면이 빈 값으로 뜬다. 문제의 본질은 "필드 위치를 옮긴 것"이 아니라 기존 필드를 제거한 것이다.
// 1단계 — additive
{
"orderId": "ORD-2026-0001",
"amount": 18900, // 유지
"status": "PAID", // 유지
"payment": { // 신규, 동일 정보 미러링
"totalAmount": 18900,
"state": "PAID"
}
}이 상태로 6개월 ~ 1년 운영하면서 신규 클라이언트는 payment를 읽도록 옮긴다. 사용 메트릭으로 옛 필드 호출이 충분히 줄었다는 근거가 모이면, 그때 deprecation 절차를 시작한다.
GET /v1/orders/ORD-2026-0001
{ "status": "REFUND_PENDING" } // 신규 enum, 기존 앱은 디코딩 실패GET /v1/orders/ORD-2026-0001
X-API-Version: 2026-04-01
{ "status": "REFUND_PENDING", "legacyStatus": "CANCELED" }GET /v1/orders/ORD-2026-0001
// 헤더 없음 → 과거 동작
{ "status": "CANCELED" }옛 클라이언트에는 안전한 의미로 매핑한 값을 주고, 새 클라이언트에는 정확한 상태를 준다. 요점은 서버가 클라이언트 능력에 맞춰 응답을 줄인다는 것이다.
게이트웨이(예: Spring Cloud Gateway, Kong, AWS API Gateway)는 버저닝의 1차 방어선이다. 게이트웨이가 다음을 책임지면 백엔드 코드가 단순해진다.
/v1/orders → orders-service v1.x, /v2/orders → orders-service v2.x)X-Client-Version, X-Platform)Deprecation, Sunset 헤더 부착 (RFC 8594/9745)여기서 중요한 운영 디테일은 **"버전별 트래픽을 측정 가능하게 만들어 두는 것"**이다. 측정이 없으면 폐기 결정을 감으로 하게 된다.
지속 가능한 정책은 다음 정도면 충분하다.
Deprecation: true, Sunset: Wed, 31 Dec 2026 23:59:59 GMT, Link: </docs/migration-v2>; rel="deprecation" 헤더를 부착한다.요점은 "절차를 미리 합의해 두는 것"이지, 정확한 일자 자체가 아니다. 면접에서 이 5단계를 자기 언어로 말할 수 있으면 충분히 시니어로 보인다.
문서와 리뷰만으로는 호환성이 깨지지 않는다는 것을 보장하기 어렵다. **consumer-driven contract test **(CDC)가 이 자리를 메운다.
핵심 아이디어:
대표 도구: Pact. JVM 백엔드는 pact-jvm-provider로 검증하고, 모바일은 각 플랫폼 SDK로 계약을 등록한다. 사내에 Pact Broker를 띄우면 contract 버전을 관리할 수 있다.
면접 답변용 한 줄: "OpenAPI 스키마 비교만으로는 unknown enum 처리, nullable 의미, 헤더 기반 분기 같은 동작 계약을 잡지 못해서, 모바일 채널 핵심 API에는 Pact 기반 CDC를 두고 백엔드 빌드에서 검증하도록 했습니다."
버저닝과 짝이 되는 안전장치는 즉시 되돌릴 수 있는 배포다.
호환성 시나리오는 머릿속으로만 돌리면 감이 잘 잡히지 않는다. 작은 환경을 만들어 직접 깨뜨려 보는 게 가장 빠르다.
권장 스택:
@RestController
@RequestMapping("/v1/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderResponse get(
@PathVariable String id,
@RequestHeader(value = "X-API-Version", required = false) String apiVersion) {
Order order = orderService.findById(id);
boolean isNew = ApiVersion.isAtLeast(apiVersion, "2026-04-01");
return OrderResponse.builder()
.orderId(order.getId())
.amount(order.getAmount())
.status(isNew ? order.getStatus().name() : LegacyStatus.map(order.getStatus()))
.legacyStatus(isNew ? LegacyStatus.map(order.getStatus()) : null)
.build();
}
}ApiVersion.isAtLeast는 null을 가장 보수적인 과거 버전으로 해석한다. 이 한 줄짜리 규칙이 "모르면 옛 동작"이라는 정책을 코드로 표현한다.
@Component
public class DeprecationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
chain.doFilter(req, res);
if (req.getRequestURI().startsWith("/v1/orders/legacy-summary")) {
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", "Wed, 31 Dec 2026 23:59:59 GMT");
res.setHeader("Link", "</v2/orders/summary>; rel=\"successor-version\"");
}
}
}@Provider("orders-service")
@PactBroker(host = "pact-broker.internal", port = "9292")
class OrderProviderContractTest {
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verify(PactVerificationContext ctx) {
ctx.verifyInteraction();
}
@State("주문 ORD-2026-0001 이 PAID 상태로 존재한다")
void seedPaidOrder() {
orderRepository.save(Order.paid("ORD-2026-0001", 18900));
}
}@JsonCreator
public static OrderStatus from(@JsonProperty("status") String raw) {
try {
return OrderStatus.valueOf(raw);
} catch (IllegalArgumentException e) {
return OrderStatus.UNKNOWN;
}
}서버 사이에서도, 외부 파트너 응답을 수신할 때 같은 패턴이 필요하다. 백엔드 자신이 클라이언트가 되는 순간이 있기 때문이다.
@Test
void v1_response_must_keep_amount_and_status_fields() {
String body = mvc.perform(get("/v1/orders/ORD-2026-0001"))
.andReturn().getResponse().getContentAsString();
DocumentContext json = JsonPath.parse(body);
assertThat(json.read("$.amount", Integer.class)).isNotNull();
assertThat(json.read("$.status", String.class)).isNotNull();
}이 테스트는 누군가가 v1 응답에서 amount/status를 제거하려고 할 때 빌드를 빨갛게 만든다. 호환성 보호의 가장 싼 방어선이다.
/v1.1/orders, /v1.2/orders가 늘어나면 라우팅이 폭발한다. 마이너는 헤더로 받는 편이 운영이 단순하다.면접에서는 길게 풀지 말고, 다음 줄기로 60~90초 안에 정리하면 시니어 후보로 자연스럽게 들린다.
X-API-Version 같은 날짜 헤더로 처리합니다. 헤더가 없으면 가장 보수적인 과거 동작으로 폴백합니다."질문이 더 좁게 들어오면 그 한 줄기만 깊게 풀면 된다. 예: "Deprecation 절차를 어떻게 합의하시겠습니까"라는 질문에는 5단계(공지·계측·권고·강제·삭제)와 임계값(80~90% 자연 업데이트)을 풀어 말하는 식이다.