본문 바로가기
서비스 웹 서비스 개발 앱 개발 AI 알고리즘 개발 AI 컨설팅 AI 마케팅 프로젝트 인사이트 회사소개 고객게시판
API 설계 10 MIN READ

API를 바꿔도 서비스는 멈추지 않게: 하위 호환성과 스키마 진화

서버를 업데이트해도 모바일 앱과 외부 고객은 즉시 바뀌지 않습니다. 안전한 필드 추가·삭제, 이벤트 스키마, 소비자 계약 테스트, 버전 전략과 Expand–Migrate–Contract 배포 순서를 정리합니다.

Category
API 설계
Published
Read
10분
Author
Nature Fractal
API를 바꿔도 서비스는 멈추지 않게: 하위 호환성과 스키마 진화 대표 이미지

서버 API의 name 필드를 displayName으로 바꾸는 일은 코드 한 줄처럼 보입니다. 서버와 웹 프런트엔드를 동시에 배포하면 개발 환경에서는 잘 동작합니다. 하지만 업데이트하지 않은 모바일 앱, 며칠 전 배포된 다른 서비스, 문서를 보고 연동한 외부 고객은 여전히 name을 기대합니다. 작은 이름 변경이 여러 시스템의 장애가 됩니다.

분산된 시스템에서는 생산자와 소비자를 완전히 같은 순간에 바꾸기 어렵습니다. 모바일 앱은 사용자가 업데이트해야 하고, 메시지 큐에는 이전 형식의 이벤트가 남아 있으며, 외부 파트너는 몇 달의 전환 기간이 필요할 수 있습니다. 따라서 API 변경의 핵심은 새 형식을 만드는 것이 아니라 서로 다른 버전이 함께 존재하는 시간을 안전하게 통과하는 것입니다.

이번 글에서는 무엇이 호환성을 깨뜨리는지, REST·GraphQL·이벤트 스키마에서 어떤 차이가 있는지, 계약 테스트와 버전 관리, 데이터베이스 변경을 어떤 순서로 배포해야 하는지 살펴보겠습니다.

호환성은 누구의 관점인지부터 정한다

하위 호환성이라는 말은 맥락에 따라 다르게 쓰입니다. 새 서버가 오래된 클라이언트 요청을 처리할 수 있는가, 새 클라이언트가 오래된 서버 응답을 처리할 수 있는가, 새 이벤트 소비자가 과거 이벤트를 읽을 수 있는가를 구분해야 합니다.

  • Backward compatibility: 새 구현이 이전 형식의 데이터를 읽거나 이전 소비자의 기대를 계속 만족합니다.
  • Forward compatibility: 이전 구현이 미래 형식에서 알 수 없는 부분을 무시하며 가능한 범위에서 처리합니다.
  • Full compatibility: 일정 범위 안에서 두 방향이 모두 가능합니다.

HTTP API에서는 보통 새 서버가 구버전 앱을 계속 지원하는지가 중요합니다. 이벤트 시스템에서는 생산자와 소비자가 독립 배포되므로 과거·미래 스키마를 읽는 두 방향을 모두 고려할 때가 많습니다. “호환된다”는 선언만 하지 말고 어떤 버전의 생산자와 소비자 조합을 지원하는지 표로 정리해야 합니다.

필드 추가는 대체로 안전하지만 항상 그런 것은 아니다

JSON 응답에 선택 필드를 추가하는 것은 일반적으로 안전한 변경입니다. 알 수 없는 필드를 무시하는 클라이언트라면 기존 동작을 유지합니다. 하지만 엄격한 역직렬화 설정이 미지의 필드를 오류로 처리하거나, 응답 전체를 서명·해시로 비교하거나, 화면이 모든 키를 자동 표시한다면 깨질 수 있습니다.

요청에 새 필드를 추가할 때는 더 조심해야 합니다. 서버가 이를 선택 사항으로 받고 합리적인 기본 동작을 제공하면 구버전 클라이언트도 계속 요청할 수 있습니다. 반대로 새 필드를 필수로 만들면 구버전은 값을 보낼 방법이 없어 즉시 호환성이 깨집니다.

기존 구조에 선택 필드를 추가하는 안전한 변경과 필드 제거·형식 변경으로 연결이 깨지는 변경 비교
선택 필드를 더하는 변경은 기존 연결을 유지할 수 있지만, 이름·타입·필수 여부를 한 번에 바꾸면 구버전 소비자가 연결되지 못합니다.

필드 삭제, 이름 변경, 타입 변경, nullable에서 required로의 전환은 대표적인 파괴적 변경입니다. 배열의 정렬 순서를 바꾸거나 기본 페이지 크기를 크게 변경하고, 이전에 나오던 상태 코드를 다른 의미로 사용하는 것도 스키마에는 보이지 않지만 행동 계약을 깨뜨릴 수 있습니다.

이름을 바꾸는 대신 새 이름으로 이동하는 기간을 만든다

name을 displayName으로 바꾸려면 서버가 한동안 두 필드를 모두 제공해야 합니다. 쓰기 요청도 두 이름을 받아들이되 둘 다 들어왔을 때의 우선순위와 값 불일치 정책을 정합니다. 새 클라이언트가 충분히 전환된 뒤 이전 필드 사용량을 측정하고 폐기 일정을 공지합니다.

이 기간에는 하나의 내부 원본에서 두 표현을 만들어야 합니다. 두 필드를 별도 DB 컬럼에 독립적으로 저장하면 값이 어긋날 수 있으므로 경계 계층의 어댑터에서 변환하는 편이 낫습니다. 응답 문서에는 이전 필드가 deprecated이며 대체 필드와 종료 예정일이 무엇인지 표시합니다.

필드 제거는 코드 검색만으로 결정할 수 없습니다. 외부 소비자와 오래된 모바일 버전은 저장소 밖에 있습니다. 실제 요청과 응답 사용량, SDK 버전, 파트너 확인을 통해 남은 소비자를 파악해야 합니다.

값의 의미가 바뀌는 것도 스키마 변경이다

필드 타입이 문자열로 그대로여도 의미가 바뀌면 호환되지 않을 수 있습니다. status = "completed"가 예전에는 결제 완료를 뜻했는데 이제 배송 완료를 뜻한다면 기존 소비자의 업무 로직이 잘못됩니다. 단위가 원에서 소수 통화 단위로 바뀌거나 타임존 없는 날짜가 UTC로 바뀌는 경우도 같습니다.

열거형에 새 값을 추가하는 것은 생산자 입장에서는 확장처럼 보이지만, 클라이언트가 모든 enum 값을 exhaustive switch로 처리하면 예외가 발생할 수 있습니다. 소비자는 알 수 없는 값을 안전한 기본 상태로 처리하고 원본 값을 보존하는 전략이 필요합니다. 생산자는 새 값을 보내기 전에 소비자의 준비 상태를 확인해야 합니다.

오류 응답도 계약입니다. HTTP 상태 코드, 오류 코드, 재시도 가능 여부와 필드별 검증 오류의 구조를 안정적으로 유지해야 합니다. 사람이 읽는 메시지 문자열을 프로그램 분기 조건으로 사용하지 않도록 기계용 코드를 제공합니다.

REST 버전은 URL만의 문제가 아니다

REST API 버전은 /v1/orders처럼 경로에 넣거나 헤더와 미디어 타입으로 협상할 수 있습니다. 경로 버전은 발견과 라우팅이 명확하지만 여러 버전 코드가 장기간 복제되기 쉽습니다. 헤더 버전은 URL이 깨끗하지만 브라우저와 운영 도구에서 보기가 어렵고 캐시 키 설정에 주의해야 합니다.

중요한 것은 버전 위치보다 언제 새 주요 버전을 만드는지입니다. 선택 필드 추가처럼 기존 계약 안에서 진화할 수 있는 변경마다 v2를 만들면 소비자가 끝없이 이주해야 합니다. 의미를 유지할 수 없는 큰 변화에서만 새 주요 버전을 만들고, 한 버전 안에서는 호환 가능한 방식으로 발전시키는 편이 좋습니다.

새 버전은 복사한 코드를 별도로 유지하기보다 공통 도메인 모델 위에 버전별 입력·출력 어댑터를 두는 구조가 관리하기 쉽습니다. 다만 새 버전의 의미가 완전히 달라졌다면 억지로 내부 모델을 공유하는 것이 더 복잡할 수 있습니다.

GraphQL도 자동으로 호환성을 보장하지 않는다

GraphQL은 클라이언트가 필요한 필드만 요청하고 필드별 deprecation 정보를 제공하므로 점진적 진화에 유리합니다. nullable 필드를 추가하는 것은 대체로 안전하고, 사용 중인 필드를 관측한 뒤 폐기할 수 있습니다.

그러나 nullable 필드를 non-null로 바꾸거나 인자에 필수값을 추가하고, enum 값을 변경하거나 기존 필드의 의미를 바꾸면 소비자를 깨뜨릴 수 있습니다. non-null 필드는 하위 resolver 오류가 상위 객체까지 전파되는 방식에도 영향을 줍니다.

스키마가 컴파일된다고 운영 쿼리가 모두 안전한 것은 아닙니다. 저장된 쿼리와 실제 필드 사용량을 수집하고, 제거 전에 모든 활성 클라이언트 버전이 대체 필드로 이동했는지 확인해야 합니다.

이벤트 스키마는 이미 발행된 데이터를 잊지 않는다

HTTP 응답은 요청 순간 생성되지만 이벤트는 브로커와 데이터 레이크, 재처리 저장소에 오래 남습니다. 새 소비자는 몇 년 전 형식의 이벤트를 읽을 수 있고, 장애로 지연된 구버전 생산자가 나중에 이벤트를 보낼 수도 있습니다.

이벤트에는 schema version, event type, 발생 시각과 안정적인 식별자를 명시합니다. 스키마 레지스트리를 사용하면 Avro, Protobuf, JSON Schema 등의 호환성 규칙을 등록 시 검사할 수 있습니다. 하지만 도구는 필드의 업무 의미 변화까지 알 수 없으므로 리뷰를 대신하지는 못합니다.

Protobuf에서는 제거한 필드 번호를 reserved로 남겨 다시 사용하지 않아야 합니다. 과거 데이터의 같은 번호가 새 의미로 해석될 수 있기 때문입니다. 이벤트 이름도 “현재 상태 전체”인지 “발생한 사실”인지 명확히 하고, 이미 발행된 이벤트를 수정하기보다 보정 이벤트를 추가하는 방식이 감사와 재처리에 유리합니다.

관대한 독자와 엄격한 생산자 원칙을 현실적으로 적용한다

생산자는 문서에 정의된 안정적인 형식만 보내고, 소비자는 알 수 없는 추가 필드와 enum 값에 가능한 한 관대하게 대응하는 원칙이 진화를 돕습니다. 하지만 모든 잘못된 값을 조용히 받아들이라는 뜻은 아닙니다.

금액이 음수이거나 필수 식별자가 없는 것처럼 업무 불변식을 깨는 입력은 즉시 거절해야 합니다. 알 수 없는 선택 필드는 무시할 수 있지만 타입이 잘못된 필드를 임의 변환하면 데이터 오류를 숨길 수 있습니다. 허용 범위와 오류 범위를 계약에 구체적으로 적습니다.

소비자가 응답 DTO를 그대로 데이터베이스에 저장하거나 전체 객체 동등성을 검사하면 작은 추가에도 깨질 가능성이 커집니다. 실제로 필요한 필드만 내부 모델로 변환해 외부 스키마 변화의 영향을 경계에서 차단합니다.

계약 테스트는 생산자와 소비자의 가정을 만난다

생산자 단위 테스트는 새 응답이 명세에 맞는지 확인하지만 실제 소비자가 어떤 필드와 행동에 의존하는지는 모릅니다. 소비자 주도 계약 테스트는 소비자가 필요한 요청과 기대 응답의 최소 계약을 정의하고 생산자의 CI에서 검증합니다.

여러 소비자의 계약을 모으면 변경이 누구에게 영향을 주는지 배포 전에 알 수 있습니다. 다만 테스트에 모든 응답 필드를 고정하면 사소한 필드 추가도 실패해 오히려 진화를 막습니다. 소비자가 실제로 의존하는 필드와 의미만 검증해야 합니다.

OpenAPI나 GraphQL 스키마 diff로 필드 삭제와 타입 변경을 자동 감지하고, 이벤트 스키마의 호환성 검사를 CI에 넣을 수 있습니다. 정적 diff와 계약 테스트, 운영 사용량 관측을 함께 사용해야 숨은 소비자까지 다룰 수 있습니다.

데이터베이스 변경은 Expand–Migrate–Contract로 나눈다

API 필드를 바꾸면서 DB 컬럼을 한 번에 rename하면 새 코드와 구버전 인스턴스가 함께 실행되는 롤링 배포 중 하나가 실패합니다. 무중단 변경은 확장, 이주, 축소 단계를 분리합니다.

기존 구조를 유지한 채 새 구조를 추가하고 데이터를 이주·검증한 뒤 이전 구조를 제거하는 과정
새 구조를 먼저 추가하고 구·신 버전이 함께 동작하는 기간을 거친 뒤, 전환과 검증이 끝나면 이전 구조를 제거합니다.
  1. Expand: nullable 새 컬럼이나 새 테이블을 추가합니다. 기존 코드가 계속 동작해야 합니다.
  2. Dual compatibility: 새 코드가 구·신 구조를 읽거나 함께 기록하도록 배포합니다.
  3. Migrate: 기존 데이터를 작은 배치로 백필하고 불일치와 부하를 관측합니다.
  4. Switch: 읽기 경로를 새 구조로 점진 전환하고 결과를 비교합니다.
  5. Contract: 모든 소비자 전환이 확인된 뒤 이전 필드와 코드, DB 구조를 제거합니다.

대용량 테이블의 기본값 컬럼 추가, 인덱스 생성과 제약 검증은 긴 잠금을 만들 수 있습니다. 데이터베이스의 온라인·동시 생성 기능을 검토하고 변경을 작은 단계로 나눕니다. 롤백도 새 컬럼을 즉시 삭제하는 방식보다 이전 읽기 경로로 되돌릴 수 있게 준비합니다.

Dual Write에는 불일치 복구가 필요하다

구·신 저장소에 함께 기록하는 동안 한쪽만 성공할 수 있습니다. 단순히 코드 두 줄로 저장하면 트랜잭션 경계가 다를 때 데이터가 어긋납니다. 같은 데이터베이스라면 하나의 트랜잭션을 사용할 수 있고, 서로 다른 시스템이라면 Outbox와 재처리, 대사 작업이 필요합니다.

읽기 전환 전에는 두 경로의 결과를 shadow read로 비교할 수 있습니다. 사용자 응답은 기존 경로에서 주되 새 경로도 읽어 차이를 메트릭으로 기록합니다. 개인정보가 로그에 노출되지 않도록 해시나 차이 유형만 남깁니다.

백필 작업은 멱등적으로 만들고 진행 위치와 처리 건수, 실패 목록을 기록합니다. 온라인 쓰기와 경쟁할 때 오래된 백필 값이 최신 변경을 덮지 않도록 version이나 조건부 갱신을 사용합니다.

폐기 공지만으로는 제거할 수 없다

문서에 “곧 종료”라고 적어도 실제 사용자가 보지 않을 수 있습니다. 응답 헤더, 개발자 포털, 이메일과 SDK 경고 등 소비자가 접하는 채널로 폐기 일정을 전달합니다. 내부 API라면 소유 팀과 마이그레이션 티켓을 연결합니다.

구버전 호출 수, 클라이언트 버전과 소비자 식별자를 관측해 사용량이 실제로 0에 가까워졌는지 확인합니다. 로그의 user-agent만으로 소비자를 완전히 식별하기 어려우므로 API key나 service identity 같은 안정적인 주체 정보가 필요합니다.

종료일에는 갑자기 삭제하기보다 테스트 환경에서 먼저 차단하고, 운영에서 경고 응답과 제한된 거절을 단계적으로 적용할 수 있습니다. 예외 연장 기준과 최종 책임자도 미리 정해야 끝없는 유지로 이어지지 않습니다.

모바일 앱은 가장 느린 소비자라고 가정한다

웹 프런트엔드는 서버와 함께 배포할 수 있지만 모바일 앱은 심사와 사용자 업데이트가 필요합니다. 몇 년간 업데이트하지 않은 기기가 접속할 수도 있습니다. 서버는 최소 지원 앱 버전과 지원 기간을 명확히 정하고, 오래된 앱이 안전하게 업데이트 안내를 받을 수 있는 최소 API를 유지해야 합니다.

앱이 알 수 없는 JSON 필드와 enum 값을 처리하는지 출시 전 테스트하고, 서버 주도 UI나 원격 설정도 구버전이 모르는 값에 안전한 기본 동작을 갖게 합니다. 최소 버전을 올릴 때는 사용자의 중요한 로컬 데이터가 사라지지 않는지도 고려합니다.

호환성 사고를 줄이는 배포 체크리스트

  • 생산자와 모든 소비자의 지원 버전 조합이 정의되어 있는가?
  • 새 요청 필드는 선택 사항이며 구버전용 기본 동작이 있는가?
  • 알 수 없는 응답 필드와 enum 값에 소비자가 안전한가?
  • 이름·타입·의미·정렬·오류 행동의 변화를 모두 검토했는가?
  • OpenAPI·GraphQL·이벤트 스키마 diff가 CI에서 실행되는가?
  • 핵심 소비자의 계약 테스트가 생산자 변경을 검증하는가?
  • DB 변경이 Expand–Migrate–Contract 단계로 분리되었는가?
  • Dual Write와 백필 실패를 대사하고 재처리할 수 있는가?
  • 구버전 사용량과 폐기 대상 소비자를 관측할 수 있는가?
  • 롤링 배포와 롤백 중 구·신 인스턴스가 함께 동작하는가?

좋은 API는 바뀌지 않는 API가 아니라 안전하게 바뀌는 API다

제품이 성장하면 API와 데이터 모델은 반드시 변합니다. 모든 미래 요구를 처음부터 예측해 고정된 스키마를 만드는 것은 불가능합니다. 중요한 것은 변경 속도를 멈추는 대신 소비자가 이동할 시간과 관측 가능한 전환 경로를 제공하는 것입니다.

핵심 원칙을 요약하면 이렇습니다. 먼저 추가하고, 구·신 버전을 함께 지원하며, 실제 사용량과 데이터를 검증한 뒤 마지막에 제거하라. 이 순서를 계약 테스트와 운영 지표로 자동화하면 API 변화는 장애를 부르는 일회성 교체가 아니라 반복 가능한 진화 과정이 됩니다.

#API설계 #하위호환성 #스키마진화 #APIVersioning #계약테스트 #RESTAPI #GraphQL #이벤트스키마 #무중단배포 #ExpandContract #백엔드개발 #분산시스템 #데이터마이그레이션

GraphQL PostgreSQL REST API

/ 다음 단계

이런 문제, 직접 겪고 계신가요?

글로 정리한 방법을 실제 환경에 맞춰 적용하는 일까지 함께 하겠습니다.

영업일 기준 3일 이내 회신 · 상담 및 범위 견적 무료