목차
이 시리즈를 열었던 전제, “API는 되돌리기 어려운 계약이다”를 정면으로 회수할 자리다.
v2를 냈는데 v1을 못 내린다
응답 구조를 크게 바꿔야 할 일이 생겼다. 기존 클라이언트를 깨지 않으려고 새 버전을 하나 판다.
GET /v1/orders ← 기존
GET /v2/orders ← 새 구조계획은 이랬다. v2를 내고, 클라이언트들이 옮겨오면, v1을 내린다.
반년 뒤 상황은 이렇다. 웹은 v2로 옮겼다. 모바일 앱은 최신 버전만 옮겼고 구버전 앱이 아직 남아 있다. 파트너사 한 곳은 “다음 분기에 하겠다”고 했고 그 다음 분기에도 같은 답을 했다. 그리고 아무도 기억 못 하는 배치 하나가 여전히 v1을 부르고 있다.
그래서 v1을 못 내린다. 그동안 코드는 두 벌이다. 주문 로직을 고칠 때마다 v1 컨트롤러와 v2 컨트롤러를 둘 다 봐야 하고, 한쪽만 고치면 그쪽에서만 버그가 난다. 그리고 이 상태에서 v3가 필요해진다.
버전을 가르는 건 문제를 해결한 게 아니라 미룬 것이었다. 그래서 이 글의 순서는 버저닝이 아니라 그 앞에서 시작한다. 어떻게 하면 버전을 안 갈라도 되는가.
깨는 변경과 안 깨는 변경
기준은 처음부터 하나였다. 옛 클라이언트가 그대로 계속 돌아가는가.
몇 개는 헷갈리니 짚어둔다.
필수 파라미터를 추가하는 건 깨는 변경이다. 필드 추가는 안전한데 왜 다르냐면, 방향이 반대이기 때문이다. 응답 필드는 옛 클라이언트가 무시하면 그만이지만, 새로 필수가 된 요청 파라미터는 옛 클라이언트가 보낼 방법이 없다. 그래서 새 파라미터는 기본값을 가진 선택 파라미터로 넣어야 한다.
에러 코드를 추가하는 건 안전하지만, 조건이 붙는다. 상태 코드와 에러 응답에서 본 대로 클라이언트가 모르는 코드를 만났을 때의 기본 동작이 있어야 한다. 모르는 코드에서 죽는 클라이언트가 있다면, 새 코드 하나가 장애가 된다.
응답 필드의 의미를 바꾸는 것도 깨는 변경이다. 이름과 타입이 그대로여도 그렇다. amount가 부가세 포함이었다가 별도가 되면 파싱은 성공하고 값만 틀린다. 형태보다 이쪽이 더 위험하다. 아무도 에러를 못 보기 때문이다.
안 깨고 바꾸는 기본기: 더하고, 안 지운다
깨는 변경의 목록을 보면 대부분이 제거와 변경이다. 그래서 기본 전략이 나온다. 더하기만 한다.
응답에 새 표현이 필요하면 옛 필드를 두고 새 필드를 더한다.
{
"amount": 12000, // 옛 필드: 그대로 둔다
"amountDetail": { // 새 필드: 부가세를 나눠서
"supply": 10909,
"vat": 1091,
"total": 12000
}
}옛 클라이언트는 amount를 계속 읽고, 새 클라이언트는 amountDetail을 읽는다. 둘 다 돌아간다.
요청 쪽도 같다. 새 방식을 받고 싶으면 선택 파라미터로 열고, 안 보내면 기존 동작을 유지한다.
GET /orders ← 기존 동작 그대로
GET /orders?include=amountDetail ← 새 동작을 원할 때만이 전략의 효율은 처음에 얼마나 안 내보냈느냐에 달려 있다. 내보낸 게 적으면 더할 자리가 많고, 처음부터 다 내보냈으면 바꿀 방법이 제거밖에 안 남는다. Endpoint 설계 편에서 목록 응답을 배열이 아니라 객체로 감싸라고 한 것도 같은 이야기였다. 나중에 더할 자리를 미리 만들어두는 것.
물론 더하기만 하면 응답에 낡은 필드가 쌓인다. 그걸 정리하는 게 다음 이야기다.
필드를 바꿔야 할 때: 늘렸다 줄인다
옛 필드를 언젠가는 없애야 한다. 그런데 없애는 순간이 곧 깨지는 순간이다. 그래서 한 번에 바꾸지 않고 단계를 나눈다.
userName을 name으로 바꾸고 싶었던 상황을 이 절차로 하면 이렇게 된다.
늘리기. 응답에 name을 더한다. userName도 그대로 내려준다. 두 필드가 같은 값을 갖는 기간이 생긴다. 이 시점에는 아무도 안 깨진다.
옮기기. 새로 짜는 코드는 name을 쓰게 하고, 기존 클라이언트에 이동을 요청한다. 문서에도 userName이 없어질 예정임을 적는다.
재기. 여기가 핵심이다. 짐작하지 않고 실제로 센다. 응답을 만들 때 userName을 읽어가는지는 서버가 알 수 없으니, 대신 클라이언트 버전이나 호출 주체를 기준으로 센다. 이 부분이 실제로는 만만치 않아서, 뒤에서 따로 본다.
줄이기. 사용량이 충분히 오래 0이면 제거한다.
이 절차의 값은 각 단계에서 되돌릴 수 있다는 것이다. 마지막 단계에서 문제가 생겨도 옛 필드를 다시 내려주면 복구된다. 반면 한 번에 바꾸면 되돌릴 방법이 배포 롤백밖에 없다.
중간 단계가 영원히 안 끝나는 게 이 방식의 실패 모드다. 두 필드를 같이 내려주는 상태로 몇 년이 지나면, 응답에 정체를 모르는 필드가 쌓이고 어느 게 정본인지 아무도 모르게 된다. 늘릴 때 줄일 날짜를 같이 정해두는 것이 그나마 아는 방어다.
그래도 버전을 갈라야 할 때
더하기로 안 되는 변경이 있다. 자원의 구조 자체가 달라지거나, 인증 방식이 바뀌거나, 한 자원이 둘로 쪼개지는 경우다. 필드 몇 개를 더해서 될 일이 아니다.
이때 비로소 버전을 가른다. 다만 가르기 전에 두 가지를 계산하는 게 좋다.
두 벌을 얼마나 오래 유지해야 하나. 소비자가 사내 서비스 둘이면 몇 주다. 앱과 파트너가 섞여 있으면 몇 년일 수 있다. 이 기간이 곧 코드를 두 벌 유지하는 기간이다.
정말 전부를 갈라야 하나. 바뀌는 건 주문 응답 하나인데 API 전체를 v2로 올리면, 아무것도 안 바뀐 엔드포인트까지 두 벌이 된다. 그래서 바뀐 자원만 새 엔드포인트로 여는 방법도 선택지다. 앞 글들에서 본 대로 새 엔드포인트를 추가하는 건 안 깨는 변경이다.
POST /orders ← 그대로
POST /order-drafts ← 구조가 다른 새 개념을 새 이름으로버전 번호를 안 붙이고 새 이름을 붙이는 것인데, 이름이 뜻을 담으면 이쪽이 읽기도 낫다.
버전을 어디에 적나
버전을 가르기로 했다면 그걸 어디에 표시할지 정해야 한다. 흔한 방식이 셋이다.
| 방식 | 모양 | 성격 |
|---|---|---|
| URL 경로 | /v1/orders | 눈에 보인다. 브라우저에서 바로 열리고 로그에서 구분된다 |
| 헤더 | Accept: application/vnd.myapi.v2+json | URL이 자원만 가리킨다. 대신 안 보인다 |
| 쿼리 파라미터 | /orders?version=2 | 붙이기 쉽다. 캐시 키·기본값 처리가 애매해질 수 있다 |
실무에서 가장 흔한 건 URL 경로 방식이다. 이유는 대체로 실용적인 쪽이다. 문서에 URL만 적으면 되고, 로그를 보면 어느 버전이 얼마나 불리는지 바로 보이고, 게이트웨이에서 경로로 라우팅하기 쉽다. 클라이언트 개발자가 헤더를 빠뜨려서 엉뚱한 버전을 받는 일도 없다.
헤더 방식의 논리는 이렇다. /v1/orders와 /v2/orders는 같은 주문을 가리키는데 URL이 다르다. URL은 자원의 이름이니 표현 방식이 바뀐다고 이름이 바뀌는 건 이상하다는 것이다. REST 글에서 형식을 URL이 아니라 Accept 헤더로 정한다고 한 것과 같은 계열의 주장이다.
원칙으로는 헤더 쪽이 일관되지만, 눈에 안 보이는 것의 운영 비용이 실제로 든다. 어느 쪽을 골라도 크게 잘못되지는 않는다는 게 대체적인 평가이고, 둘 사이 논쟁에 시간을 많이 쓸 값은 크지 않아 보인다. 다만 한 API 안에서 두 방식을 섞지 않는 것은 분명하다.
버전은 API 단위로 매긴다
/v1/orders와 /v3/users가 공존하는 상태를 만들면 어떻게 될까. 자원마다 버전이 따로 올라가면 조합이 폭발한다. 클라이언트는 자원마다 지금 몇 버전인지를 외워야 하고, 문서는 자원별 버전 표가 된다.
그래서 버전은 API 전체에 하나로 매기는 편이 대체로 단순하다. 무언가를 갈라야 하면 전체가 v2로 올라가고, 안 바뀐 엔드포인트는 v2에서 v1과 같은 동작을 하면 된다.
다만 이건 절대적인 규칙은 아니고, 조직이 크고 팀별로 API를 따로 소유하는 구조라면 다르게 가는 경우도 있다. 판단 기준은 “클라이언트 개발자가 지금 무슨 버전을 쓰는지 한 문장으로 답할 수 있는가” 정도로 두면 무난하다.
라이브러리 버전에 쓰는 시맨틱 버저닝(2.3.1)을 API에 그대로 가져오려는 시도가 있는데, 실제로는 잘 안 맞는다. URL에 들어가는 버전은 깨는 변경의 세대만 세면 충분하기 때문이다. 안 깨는 변경은 애초에 버전을 안 올리고 그냥 배포한다. 그래서 API 버전은 대개 v1, v2 같은 정수 하나로 끝난다.
없앨 거라고 미리 말한다
옛 버전을 내리려면 쓰는 쪽이 먼저 옮겨야 하고, 옮기려면 없어진다는 걸 알아야 한다. 그런데 문서 구석에 적어두는 것만으로는 대개 전달되지 않는다. 사람들은 잘 돌아가는 코드의 문서를 다시 읽지 않는다.
그래서 알림을 여러 겹으로 둔다.
- 응답 헤더로 알린다. 폐기 예정 엔드포인트의 응답에 표시를 붙이면, 개발자도구나 로그에서 보인다.
Deprecation과Sunset헤더가 이 용도로 정의돼 있다. - 사용 중인 곳에 직접 연락한다. 로그에서 호출 주체를 추려 담당자에게 알린다. 공개 API라면 등록된 연락처로 공지한다.
- 문서에 예정일을 적는다. “곧 없어짐”이 아니라 날짜를 적는다. 날짜가 없으면 아무도 안 옮긴다.
연락할 대상을 알려면 누가 부르는지 알 수 있어야 한다. 그래서 API를 열 때부터 호출 주체를 식별할 수단을 두는 게 좋다. 클라이언트가 자기 이름과 버전을 User-Agent에 넣게 하거나, 발급한 키로 구분하는 식이다. 이게 없으면 나중에 “누구에게 연락해야 하나”에서 막힌다. 여기까지 와서야 아쉬워지는 것들이라 미리 챙길 값이 있다.
내리는 절차
앞의 것들을 순서로 묶으면 이렇게 된다.
4번이 자주 빠지는데 값이 크다. 예고된 시간에 잠깐만 옛 버전을 끊어보면, 계측에 안 잡히던 호출자가 그때 나타난다. 아무도 모르던 배치가 여기서 드러나는 일이 있다. 진짜로 내린 뒤에 발견하는 것보다 훨씬 낫다.
3번에서 사용량이 안 줄면 2번으로 돌아간다. 여기서 “기한이니까 내린다”로 밀어붙이면 장애가 되고, “안 줄어드니 그냥 두자”로 물러서면 처음의 v1처럼 영원히 남는다. 이 자리가 실제로 어려운 지점이고, 기술보다 조율의 문제에 가깝다.
그래서 실무에선 어떻게 쓰이나
소비자가 누구냐에 따라 전략의 무게가 완전히 다르다. 이게 가장 중요한 조건이다.
| 소비자 | 옛 버전 유지 기간 | 전략 |
|---|---|---|
| 우리 팀 웹 프런트 | 배포 한 번 | 같이 배포하면 되니 크게 신경 안 써도 된다 |
| 옆 팀 서비스 | 며칠에서 몇 주 | 미리 알리고 같이 옮긴다 |
| 모바일 앱 | 길다 | 구버전 앱이 남는다. 강제 업데이트 정책이 있어야 그나마 짧아진다 |
| 외부 파트너 | 아주 길 수 있다 | 계약과 공지 절차가 따로 필요하다 |
모바일이 특히 까다롭다. 스토어에 새 버전을 올려도 사용자가 업데이트를 해야 반영되고, 최소 지원 버전을 강제하는 장치가 앱에 없으면 옛 버전이 무한정 남는다. API 버저닝 전략이 앱의 업데이트 정책에 묶여 있다는 걸 늦게 깨닫는 경우가 많다.
서버 안에서 두 버전을 어떻게 유지하나. 컨트롤러를 통째로 복사하면 로직까지 두 벌이 되어 유지가 어려워진다. 대체로 바깥 층만 가르고 안쪽은 공유하는 방향이 낫다.
@RestController
@RequestMapping("/v1/orders")
class OrderV1Controller {
@GetMapping("/{id}")
public OrderV1Response get(@PathVariable Long id) {
return OrderV1Response.from(orderService.find(id)); // 응답 모양만 v1
}
}
@RestController
@RequestMapping("/v2/orders")
class OrderV2Controller {
@GetMapping("/{id}")
public OrderV2Response get(@PathVariable Long id) {
return OrderV2Response.from(orderService.find(id)); // 서비스는 하나
}
}버전마다 다른 건 응답 DTO이고, 서비스는 하나다. 이 구조가 되려면 서비스가 이미 웹 응답 모양에서 자유로워야 한다. DTO 글의 변환은 어디서 하나 대목이 그 경계를 다룬다. 버저닝이 감당 가능한지가 그 경계를 그어뒀는지에 달려 있다.
애초에 사내 API라면 버전을 안 가르는 선택도 정당하다. 소비자를 전부 알고 배포를 조율할 수 있다면, 앞의 “늘렸다 줄이기”만으로 대부분 처리된다. 버저닝은 조율이 불가능할 때 쓰는 도구다. 조율이 되는데 버전부터 가르면 유지비만 든다.
정리
| 먼저 할 것 | 버전을 가르는 게 아니라 안 깨고 바꾸는 것 |
| 판단 기준 | 옛 클라이언트가 그대로 돌아가는가 |
| 안 깨는 변경 | 응답 필드 추가 · 선택 파라미터 추가 · 새 엔드포인트 · 새 에러 코드 |
| 깨는 변경 | 제거 · 이름 변경 · 타입 변경 · 필수 파라미터 추가 · 의미 변경 |
| 바꾸는 절차 | 늘리기 → 옮기기 → 재기 → 줄이기. 각 단계에서 되돌릴 수 있다 |
| 버전 위치 | URL 경로가 흔하고 운영이 쉽다. 헤더가 원칙적이지만 안 보인다 |
| 버전 단위 | 자원마다가 아니라 API 전체에 하나가 대체로 단순하다 |
| 내리기 | 계측 → 공지 → 감시 → 짧은 차단 → 종료. 짐작으로 내리지 않는다 |
| 소비자 | 앱과 파트너가 섞이면 유지 기간이 길어진다. 전략의 무게를 여기가 정한다 |
이 시리즈를 연 문장으로 돌아가면 이렇다. API는 코드가 아니라 계약이다. 그래서 URL 이름도, 상태 코드도, JSON 필드도, 스키마의 필드 하나도 전부 “나중에 되돌리기 어렵다”는 조건 아래 골랐다.
그 조건을 없앨 수는 없다. 대신 두 가지를 할 수 있다. 처음에 적게 약속하는 것, 그리고 약속을 바꿔야 할 때 한 번에 끊지 않고 겹쳐서 옮기는 것. 지금까지 본 결정들이 결국 이 둘로 모인다.