프로그래밍/Python

🔀 API 버전 관리 부족: 기존 클라이언트와 호환성 문제

Tiboong 2026. 2. 14. 06:36

🚨 이런 상황, 겪어보셨나요?

"상품 API 응답 구조를 바꿨더니 모바일 앱이 전부 크래시 났습니다."

"필드 이름 하나 변경했을 뿐인데, 프론트엔드가 먹통이 됐어요."

"구버전 앱 사용자한테 강제 업데이트 공지를 띄워야 하는데, 이미 별점 테러가 시작됐습니다."

"외부 파트너사가 우리 API를 쓰고 있는데, 사전 공지 없이 응답 형식을 바꿔버려서 항의 전화가 왔어요."

 

Django REST Framework(DRF)로 API를 운영하다 보면, 처음에는 버전 관리를 생각하지 않습니다. "우리 앱이랑 우리 프론트만 쓰니까 맞추면 되지" 하고 넘어가죠.

 

그런데 서비스가 성장하면서 모바일 앱, 웹 프론트엔드, 외부 파트너사, 서드파티 연동이 동시에 같은 API를 호출하게 되면, 필드 하나 바꾸는 것이 전쟁의 시작이 됩니다.


🔥 실제 상황: 필드 하나가 서비스를 멈추다

한 스타트업에서 배달 서비스 API를 운영하고 있었습니다. 서비스 초기에는 모바일 앱 하나만 이 API를 사용했습니다.

어느 날, 백엔드 개발자가 주문 API의 응답을 "개선"하기로 했습니다.

// 기존 응답
{
    "order_id": 123,
    "price": 15000,
    "status": "delivered"
}

// "개선된" 응답
{
    "order_id": 123,
    "total_price": {          // price → total_price로 변경, 구조도 변경
        "amount": 15000,
        "currency": "KRW"
    },
    "delivery_status": {      // status → delivery_status로 변경, 구조도 변경
        "code": "DELIVERED",
        "label": "배달 완료"
    }
}

개발자는 "더 명확한 구조"를 위한 합리적인 개선이라고 생각했습니다. 문제는 이 변경을 버전 관리 없이 기존 엔드포인트에 그대로 덮어씌웠다는 것입니다.

 

배포 직후 벌어진 일:

  1. iOS 앱 크래시율 94% — response["price"]로 접근하던 코드에서 키 에러 발생. 주문 화면 자체가 열리지 않음
  2. Android 앱 결제 금액 표시 오류 — price가 숫자일 거라고 가정하고 연산하던 코드에서 딕셔너리를 만나 "0원"으로 표시
  3. 파트너사 정산 시스템 중단 — 외부 파트너사가 매일 밤 status 필드를 기준으로 정산을 돌리고 있었는데, 필드가 사라지면서 정산 배치가 전부 실패
  4. 실시간 대시보드 먹통 — 웹 프론트엔드가 status === "delivered"로 필터링하고 있었는데, 갑자기 조건에 맞는 주문이 0건으로 표시

하나의 API 변경이 4개의 클라이언트를 동시에 무너뜨렸습니다. 긴급 롤백까지 걸린 시간 45분. 그 사이 누적된 주문 오류 2,300건. 파트너사와의 신뢰 관계에 금이 갔습니다.


🎯 API 버전 관리가 뭔데, 왜 이렇게 중요할까?

도로 비유로 이해하기

API 버전 관리를 이해하려면 도시의 도로 체계를 떠올려보세요.

도시가 성장하면서 기존 2차선 도로를 4차선으로 확장해야 한다고 합시다. 버전 관리 없이 API를 변경하는 것은 어느 날 갑자기 도로의 차선 방향과 신호 체계를 전부 바꿔버리는 것과 같습니다. 기존 내비게이션은 엉뚱한 곳으로 안내하고, 운전자들은 혼란에 빠지고, 사고가 연쇄적으로 발생합니다.

반면, 버전 관리가 있는 API 변경은 새로운 4차선 도로를 기존 도로 옆에 나란히 만드는 것입니다. 새 내비게이션을 가진 차는 새 도로를 이용하고, 구형 내비게이션을 가진 차는 기존 도로를 계속 사용합니다. 충분한 전환 기간이 지나면 구 도로를 폐쇄하는 것이죠.

이것이 바로 API 버전 관리의 핵심입니다. 기존 클라이언트를 깨뜨리지 않으면서, 새로운 기능과 구조를 도입하는 것.

Breaking Change vs Non-breaking Change

모든 API 변경이 문제를 일으키는 것은 아닙니다. 핵심은 **Breaking Change(호환성을 깨는 변경)**와 **Non-breaking Change(호환성을 유지하는 변경)**를 구분하는 것입니다.

 

Breaking Change (반드시 버전 관리 필요)

  • 기존 필드 삭제 또는 이름 변경
  • 필드의 데이터 타입 변경 (숫자 → 객체, 문자열 → 배열)
  • 필수 파라미터 추가
  • 응답 구조 변경 (평면 → 중첩)
  • URL 경로 변경
  • 인증 방식 변경
  • HTTP 상태 코드 의미 변경

Non-breaking Change (버전 관리 없이 가능)

  • 새로운 선택적 필드 추가
  • 새로운 엔드포인트 추가
  • 선택적 쿼리 파라미터 추가
  • 응답에 새로운 값 추가 (기존 값 유지)
  • 버그 수정 (기존 스펙과 실제 동작 일치시키기)

이 구분을 명확히 하는 것만으로도 불필요한 버전 변경을 줄이고, 정말 필요한 시점에만 새 버전을 만들 수 있습니다.


📊 비즈니스 영향: 버전 관리 부재가 가져오는 실질적 피해

1. 모바일 앱의 "구버전 지옥"

웹은 배포하면 즉시 모든 사용자에게 반영되지만, 모바일 앱은 사용자가 직접 업데이트해야 합니다. 실제로 앱 업데이트율은 놀라울 정도로 느립니다.

앱 업데이트 배포 후 새 버전 사용 비율을 보면, 1일 후에는 약 15%, 1주일 후에야 50%, 1개월 후에도 75% 수준입니다. 즉, API를 변경한 뒤 한 달이 지나도 25%의 사용자는 여전히 구버전 앱을 사용하고 있다는 뜻입니다.

버전 관리 없이 API를 변경하면, 이 25%의 사용자는 앱이 작동하지 않는 상태로 방치됩니다. 결과는 앱 스토어 별점 1점 리뷰와 고객 이탈입니다.

2. 외부 파트너 신뢰도 붕괴

API를 외부에 제공하고 있다면, 예고 없는 변경은 계약 위반에 가까운 문제입니다. 파트너사는 여러분의 API 스펙을 기준으로 시스템을 구축합니다. 사전 공지 없이 필드 하나가 사라지면 파트너사의 서비스도 같이 멈춥니다.

파트너사가 10곳이라면, API 변경 한 번에 11개의 서비스가 동시에 장애를 겪는 셈입니다.

3. 개발팀 내부 혼란

프론트엔드 개발자가 API 문서를 보고 개발했는데, 어느 날 응답이 달라져 있다면? "이게 언제 바뀐 거야?"라는 질문이 슬랙에 올라오고, 백엔드 개발자는 "아 그거 지난주에 바꿨는데"라고 답하고, 프론트엔드 개발자는 급하게 코드를 수정합니다.

이런 일이 반복되면 팀 간 신뢰가 무너지고, 모든 API 변경에 대해 과도한 커뮤니케이션 비용이 발생합니다.


🔍 DRF에서 API 버전 관리하는 4가지 방법

Django REST Framework는 네 가지 버전 관리 방식을 기본 제공합니다. 각각의 특성이 다르므로 서비스 상황에 맞는 방식을 선택해야 합니다.

1. URL Path Versioning — "주소에 버전을 넣는 방식"

가장 직관적이고 널리 사용되는 방식입니다. URL 자체에 버전 번호가 포함됩니다.

GET /api/v1/orders/
GET /api/v2/orders/
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
    'DEFAULT_VERSION': 'v1',
    'ALLOWED_VERSIONS': ['v1', 'v2'],
}
# urls.py
from django.urls import path, include

urlpatterns = [
    path('api/<version>/', include('orders.urls')),
]

 

장점: 어떤 버전을 호출하는지 URL만 보면 바로 알 수 있습니다. 브라우저에서 직접 테스트하기 쉽고, 로그에서도 버전별 트래픽을 쉽게 구분할 수 있습니다. API 문서화도 직관적입니다.

 

단점: URL이 길어지고, 새 버전을 추가할 때마다 URL 라우팅 설정이 필요합니다. 또한 "URL은 리소스를 식별해야 하지 버전을 포함해선 안 된다"는 REST 원칙과 충돌한다는 의견도 있습니다.

 

적합한 경우: 외부 파트너에게 API를 제공하는 경우, 명확한 버전 구분이 필요한 공개 API.


2. Namespace Versioning — "Django 앱을 버전별로 분리"

Django의 URL namespace를 활용하는 방식입니다. URL은 Path Versioning과 비슷하지만, 내부 구조가 다릅니다.

# urls.py
urlpatterns = [
    path('api/v1/', include('orders.urls_v1', namespace='v1')),
    path('api/v2/', include('orders.urls_v2', namespace='v2')),
]
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
}

 

장점: 버전별로 URL 설정 파일을 완전히 분리할 수 있어서, 버전 간 라우팅이 명확하게 독립됩니다.

 

단점: 버전이 늘어날수록 URL 파일이 계속 늘어나서 관리가 복잡해집니다. 실무에서는 Path Versioning에 비해 잘 사용되지 않습니다.

 

적합한 경우: 버전 간 엔드포인트 구조 자체가 크게 다른 경우.


3. Header Versioning — "헤더에 버전을 실어 보내는 방식"

HTTP 헤더에 버전 정보를 포함시킵니다. URL은 깔끔하게 유지됩니다.

GET /api/orders/
Accept: application/json; version=2
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
    'DEFAULT_VERSION': 'v1',
    'ALLOWED_VERSIONS': ['v1', 'v2'],
}

 

장점: URL이 깔끔합니다. REST 원칙에 더 부합하고, 같은 리소스 URL로 여러 버전의 표현을 받을 수 있습니다.

 

단점: 브라우저에서 직접 테스트하기 어렵습니다. 헤더를 설정하려면 Postman이나 curl 같은 도구가 필요합니다. 또한 API를 호출하는 클라이언트가 헤더 설정을 빠뜨리는 실수가 발생하기 쉽습니다.

 

적합한 경우: 내부 서비스 간 통신(MSA), URL 구조를 변경하고 싶지 않은 경우.


4. Query Parameter Versioning — "쿼리 파라미터로 버전 지정"

가장 간단한 방식입니다. URL 뒤에 쿼리 파라미터로 버전을 지정합니다.

GET /api/orders/?version=v2
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.QueryParameterVersioning',
    'DEFAULT_VERSION': 'v1',
    'ALLOWED_VERSIONS': ['v1', 'v2'],
}

 

장점: 기존 URL 구조를 전혀 변경하지 않고도 버전 관리를 도입할 수 있습니다. 구현이 가장 간단합니다.

 

단점: 쿼리 파라미터를 빼먹으면 기본 버전으로 동작해서, 클라이언트가 자신이 어떤 버전을 사용하는지 인식하지 못할 수 있습니다. URL 캐싱 정책과 충돌할 수도 있습니다.

 

적합한 경우: 이미 운영 중인 API에 버전 관리를 뒤늦게 도입하는 경우, 빠른 프로토타이핑.


⚡ 방식별 비교

항목 URL Path Namespace Header Query Param

직관성 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐
URL 깔끔함 ⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐
브라우저 테스트 쉬움 쉬움 어려움 쉬움
캐싱 호환 좋음 좋음 보통 나쁨
기존 API 도입 URL 변경 필요 URL 변경 필요 변경 없음 변경 없음
업계 채택 가장 많음 적음 중간 적음

 

실무에서 가장 많이 사용되는 방식은 URL Path Versioning입니다. GitHub, Twitter, Stripe 등 대부분의 유명 API가 이 방식을 채택하고 있습니다. 특별한 이유가 없다면 URL Path 방식을 추천합니다.


🛠️ 실전 적용: 버전별 분기 처리 전략

버전 관리 방식을 선택했다면, 이제 실제로 뷰에서 버전별로 다른 응답을 반환하는 방법을 구현해야 합니다.

전략 1: 뷰에서 직접 분기 (소규모 변경)

변경 사항이 적을 때 가장 간단한 방법입니다.

# views.py
from rest_framework.generics import RetrieveAPIView

class OrderDetailView(RetrieveAPIView):
    queryset = Order.objects.all()

    def get_serializer_class(self):
        if self.request.version == 'v2':
            return OrderV2Serializer
        return OrderV1Serializer
# serializers.py
class OrderV1Serializer(serializers.ModelSerializer):
    """v1: 기존 평면 구조 유지"""
    class Meta:
        model = Order
        fields = ['order_id', 'price', 'status']

class OrderV2Serializer(serializers.ModelSerializer):
    """v2: 구조화된 응답"""
    total_price = serializers.SerializerMethodField()
    delivery_status = serializers.SerializerMethodField()

    class Meta:
        model = Order
        fields = ['order_id', 'total_price', 'delivery_status']

    def get_total_price(self, obj):
        return {
            'amount': obj.price,
            'currency': 'KRW'
        }

    def get_delivery_status(self, obj):
        return {
            'code': obj.status.upper(),
            'label': obj.get_status_display()
        }

 

이 방식의 핵심은 v1 Serializer는 절대 수정하지 않는 것입니다. 기존 클라이언트가 기대하는 응답 형식을 그대로 유지하면서, 새로운 형식은 v2에서만 제공합니다.


전략 2: URL 라우팅 분리 (대규모 변경)

버전 간 차이가 클 때는 뷰 자체를 분리하는 것이 관리하기 편합니다.

# urls.py
from django.urls import path, include

urlpatterns = [
    path('api/v1/', include('orders.urls_v1')),
    path('api/v2/', include('orders.urls_v2')),
]
# orders/urls_v1.py
from . import views_v1

urlpatterns = [
    path('orders/', views_v1.OrderListView.as_view()),
    path('orders/<int:pk>/', views_v1.OrderDetailView.as_view()),
]

# orders/urls_v2.py
from . import views_v2

urlpatterns = [
    path('orders/', views_v2.OrderListView.as_view()),
    path('orders/<int:pk>/', views_v2.OrderDetailView.as_view()),
]

 

이 방식은 코드 중복이 생기지만, 버전 간 완전한 독립성이 보장됩니다. v1을 수정해야 할 일이 생겨도 v2에 영향을 주지 않고, 반대도 마찬가지입니다.


전략 3: 버전 미들웨어로 공통 처리 (하이브리드)

실무에서 가장 많이 쓰이는 패턴입니다. 공통 로직은 공유하되, 버전별 차이점만 분기합니다.

# versioning.py
class VersionedAPIMixin:
    """버전별 Serializer 자동 선택 Mixin"""

    versioned_serializers = {}  # 하위 클래스에서 정의

    def get_serializer_class(self):
        version = self.request.version or 'v1'
        if version in self.versioned_serializers:
            return self.versioned_serializers[version]
        # 정의되지 않은 버전이면 가장 낮은 버전 사용
        return self.versioned_serializers.get(
            'v1', super().get_serializer_class()
        )
# views.py
class OrderListView(VersionedAPIMixin, ListAPIView):
    queryset = Order.objects.select_related('customer', 'restaurant')

    versioned_serializers = {
        'v1': OrderV1Serializer,
        'v2': OrderV2Serializer,
    }

 

이 Mixin을 만들어두면, 새로운 뷰에서 versioned_serializers 딕셔너리만 정의하면 버전별 분기가 자동으로 처리됩니다. 공통 쿼리 최적화(select_related 등)는 한 곳에서 관리하면서, Serializer만 버전별로 다르게 적용하는 깔끔한 패턴입니다.


🐛 자주 발생하는 실수와 디버깅

실수 1: 버전 기본값을 설정하지 않는 경우

클라이언트가 버전을 명시하지 않았을 때 에러가 나는 경우입니다.

# ❌ DEFAULT_VERSION 없이 설정
REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
    'ALLOWED_VERSIONS': ['v1', 'v2'],
    # DEFAULT_VERSION이 없으면 버전 없는 요청에서 에러 발생!
}
# ✅ 기본 버전을 반드시 설정
REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
    'DEFAULT_VERSION': 'v1',
    'ALLOWED_VERSIONS': ['v1', 'v2'],
}

 

DEFAULT_VERSION을 설정하지 않으면, 버전을 명시하지 않는 기존 클라이언트들이 전부 에러를 만나게 됩니다. 기존 API에 버전 관리를 도입할 때 특히 주의해야 합니다.


실수 2: v1을 수정하는 실수

v2를 만들고 나서, "이왕이면 v1도 같이 개선하자"고 v1의 응답을 변경하는 경우입니다.

# ❌ v1 Serializer를 "개선"하는 실수
class OrderV1Serializer(serializers.ModelSerializer):
    # 원래 'price' 필드였는데 "이름이 별로라서" 변경
    total_price = serializers.IntegerField(source='price')  # Breaking Change!

    class Meta:
        model = Order
        fields = ['order_id', 'total_price', 'status']

 

v1은 "동결(freeze)" 상태여야 합니다. v1의 응답 형식은 해당 버전이 폐기될 때까지 절대 변경하지 않는 것이 원칙입니다. 버그 수정이 아닌 이상 건드리지 마세요.


실수 3: 버전 폐기 절차 없이 갑자기 삭제

"v1 사용자가 거의 없으니까 그냥 꺼도 되겠지?"라는 판단으로 갑자기 v1을 삭제하면, 남아 있던 소수의 사용자 또는 오래된 자동화 스크립트가 전부 깨집니다.

# ✅ 단계적 버전 폐기 절차

# 1단계: Deprecation 경고 헤더 추가
class DeprecatedVersionMixin:
    deprecated_versions = ['v1']

    def finalize_response(self, request, response, *args, **kwargs):
        response = super().finalize_response(
            request, response, *args, **kwargs
        )
        if request.version in self.deprecated_versions:
            response['Sunset'] = 'Sat, 01 Jun 2026 00:00:00 GMT'
            response['Deprecation'] = 'true'
            response['Link'] = (
                '<https://api.example.com/docs/migration-v2>; '
                'rel="successor-version"'
            )
        return response

# 2단계: 사용자에게 마이그레이션 가이드 제공
# 3단계: 충분한 전환 기간 (최소 3~6개월) 부여
# 4단계: v1 트래픽이 전체의 1% 미만일 때 비로소 폐기

 

Sunset 헤더와 Deprecation 헤더는 **API 폐기 표준(RFC 8594)**에서 정의된 방식입니다. 이 헤더를 보고 클라이언트 측에서 마이그레이션을 준비할 수 있습니다.


실수 4: 모든 변경에 새 버전을 만드는 과잉 버전 관리

작은 변경에도 매번 새 버전을 만들면, v1, v2, v3, v4... 관리할 버전이 끝없이 늘어납니다.

새 버전은 Breaking Change가 있을 때만 만드세요. 새로운 선택적 필드를 추가하거나, 새 엔드포인트를 만드는 것은 기존 버전 안에서 처리할 수 있습니다.

# 새 필드 추가는 Breaking Change가 아님 → v1에서 처리 가능
class OrderV1Serializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ['order_id', 'price', 'status', 'estimated_delivery']  # 필드 추가 OK

 

기존 클라이언트는 새로 추가된 estimated_delivery 필드를 모르면 그냥 무시합니다. 이건 Breaking Change가 아니므로 버전을 올릴 필요가 없습니다.


✅ 베스트 프랙티스 체크리스트

설계 단계

  • [ ] API 버전 관리 방식을 선택했는가? (추천: URL Path Versioning)
  • [ ] DEFAULT_VERSION과 ALLOWED_VERSIONS를 설정했는가?
  • [ ] Breaking Change와 Non-breaking Change의 기준을 팀 내에서 정의했는가?
  • [ ] API 변경 시 버전 정책 문서가 있는가?

개발 단계

  • [ ] 구버전 Serializer는 "동결" 상태를 유지하고 있는가?
  • [ ] 버전별 Serializer 분기가 테스트 코드로 검증되고 있는가?
  • [ ] 새 버전 도입 시 마이그레이션 가이드를 작성했는가?
  • [ ] 비공개 API라도 프론트엔드 팀과 변경 사항을 공유했는가?

운영 단계

  • [ ] 버전별 트래픽을 모니터링하고 있는가? (v1: 30%, v2: 70% 등)
  • [ ] 폐기 예정 버전에 Sunset 헤더를 포함했는가?
  • [ ] 버전 폐기 전 최소 3~6개월의 전환 기간을 확보했는가?
  • [ ] 외부 파트너에게 API 변경을 사전 공지하는 프로세스가 있는가?
  • [ ] API 변경 이력(Changelog)을 관리하고 있는가?

🎯 결론: API 버전 관리는 사용자와의 약속입니다

API 버전 관리 부재는 개발자의 편의를 위해 사용자의 안정성을 희생하는 것입니다. "귀찮으니까 나중에 하자"는 생각이 서비스가 성장한 후에 엄청난 기술 부채로 돌아옵니다.

기억해야 할 핵심 원칙

  1. Breaking Change를 명확히 구분하세요: 모든 변경이 새 버전을 필요로 하는 것은 아닙니다. 기존 응답 구조를 깨는 변경만 새 버전으로 관리하세요.
  2. 구버전은 동결(freeze)하세요: v1을 만들고 v2를 만들었다면, v1은 버그 수정 외에는 건드리지 마세요. 그것이 구버전 사용자와의 약속입니다.
  3. URL Path Versioning으로 시작하세요: 특별한 이유가 없다면 가장 직관적이고 널리 검증된 방식으로 시작하세요. 나중에 필요하면 전환할 수 있습니다.
  4. 단계적으로 폐기하세요: 갑작스러운 폐기는 사용자를 적으로 만듭니다. Sunset 헤더, 마이그레이션 가이드, 충분한 전환 기간을 제공하세요.

서비스 규모별 추천

서비스 규모 추천 전략

MVP/초기 스타트업 URL Path + 뷰 내부 분기 (전략 1)
성장기 (모바일 앱 운영) URL Path + Mixin 패턴 (전략 3)
중대형 (외부 API 제공) URL Path + 라우팅 분리 (전략 2) + Sunset 헤더

💼 Django API 설계 컨설팅

API 버전 관리 전략부터 호환성 유지 아키텍처까지, 잘 설계된 API는 서비스의 성장을 뒷받침합니다.

이런 고민이 있으시다면:

  • 이미 운영 중인 API에 버전 관리를 도입하고 싶은데 어디서부터 시작할지 모르겠다면
  • API 변경할 때마다 프론트엔드/모바일과 충돌이 반복된다면
  • 외부 파트너에게 안정적인 API를 제공해야 하는데 체계가 없다면
  • 마이크로서비스 전환을 준비하면서 API 설계를 재정비하고 싶다면

크몽에서 Django 전문 컨설팅을 제공합니다. 실제 운영 경험을 바탕으로, 여러분의 서비스에 맞는 API 버전 관리 전략을 함께 설계해 드립니다.

👉 [크몽에서 Django 컨설팅 문의하기]