TLDR
- 쿠버네티스에서 Pod를 점진 교체하는 무중단 배포는 배포 전략(RollingUpdate / Blue-Green / Canary) 하나로 완성되지 않습니다. 애플리케이션 코드의 백워드 호환성이 함께 지켜져야 진짜 무중단이 됩니다.
- Argo Rollouts의 3가지 배포 전략을 실제 설정과 함께 정리하고, 각 전략에서 구버전·신버전 Pod가 공존하는 시간이 왜 장애의 씨앗이 되는지 짚어보겠습니다.
해당 글의 코드는 모두 예시 코드이며, 사내 코드와는 무관함을 밝힙니다.
왜 무중단 배포를 공부하게 됐나
대부분의 B2C 서비스는 쿠버네티스 위에서 Pod를 교체하는 방식으로 배포됩니다. 무중단 배포는 24시간 트래픽이 들어오는 서비스에서 배포 순간에도 요청이 끊기면 안 되므로, 사실상 가장 중요한 설정입니다.
"새벽에 배포하면 되지 않나요?"라는 생각은 엣지 케이스를 무시하는, 건강하지 않은 개발자의 마인드라고 생각합니다. 누군가는 새벽에도 주문을 할 수 있을 뿐만 아니라, 글로벌 서비스처럼 시차가 존재하는 경우 우리의 새벽이 누군가에게는 가장 활발한 구매 시간인 점심시간일 수도 있습니다.
이렇게 중요함에도 불구하고, 저에게 "무중단 배포 방식" 은 늘 뭉뚱그려져 있었습니다.
과거의 나: "DevOps 팀이 알아서 잘 해주시겠지…?" (혼나야 마땅합니다.)
그래서 회사를 다니며 배포가 실제로 어떻게 구성돼 있는지 뜯어봤고, 그 과정에서 오히려 더 신경 쓰이는 문제를 하나 발견했습니다.
무중단 배포는 대부분 구버전 Pod와 신버전 Pod가 잠시 동시에 떠 있는 구간을 만듭니다. 그렇다면 제가 백엔드에서 backward compatible하지 않은 코드를 배포하면, 그 공존 구간에서 장애가 날 수 있는 것 아닐까요? 이것은 가정이 아니라 실제로 관측된 문제였습니다.
Backward Compatible한 코드: API 소비자에게 신 버전의 코드와 이전 버전의 코드 모두를 호환시킬 수 있는 코드
공교롭게도 무중단 배포 방식을 공부하던 시기에, 팀에서 "Backward Compatible" 이슈로 에러가 발생했습니다(매출 지장 0%). 이 일은 이 주제를 더 깊이 공부해야겠다고 마음먹은 계기가 되었습니다.
간단한 ENUM 추가가 500 에러를 만든다
이 문제는 ENUM 값을 추가한 배포에서, 이전 Pod가 새로 올라간 Pod가 만든 ENUM 값을 이해하지 못해 역직렬화에 실패한 것이 원인이었습니다. 그 결과 배포가 진행되는 동안 일시적으로 Error Rate가 튀었습니다. 배포 전략 자체는 정상이었지만, 코드가 호환되지 않아서 생긴 장애였습니다.
주문 상태를 나타내는 ENUM이 있다고 하겠습니다.
v1에는 세 개의 값만 있습니다.
// v1 (기존 Pod)
enum class OrderStatus {
CREATED, PAID, SHIPPED
}
v2에서 환불 기능이 추가되며 값이 하나 늘어납니다.
// v2 (새 Pod)
enum class OrderStatus {
CREATED, PAID, SHIPPED,
REFUNDED // 새로 추가된 값
}
롤링 배포 중에는 v1 Pod와 v2 Pod가 함께 떠 있습니다. v2 Pod가 REFUNDED 상태의 주문을 만들어 캐시나 메시지 큐, DB에 흘려보내면, 그 값을 v1 Pod가 읽어 역직렬화하는 순간 문제가 터집니다. Jackson은 기본적으로 모르는 ENUM 문자열을 만나면 예외를 던지기 때문입니다.
// v1 Pod에서 실행되는 역직렬화
val mapper = jacksonObjectMapper()
val order: Order = mapper.readValue("""{"status":"REFUNDED"}""")
// com.fasterxml.jackson.databind.exc.InvalidFormatException:
// Cannot deserialize value of type `OrderStatus` from String "REFUNDED":
// not one of the values accepted for Enum class: [CREATED, PAID, SHIPPED]
값 하나를 추가했을 뿐인데, 배포가 끝날 때까지 v1 Pod로 들어간 요청 일부가 500으로 떨어집니다. 배포 전략이 아무리 매끄러워도 코드가 이 공존 구간을 견디지 못하면 무중단은 깨집니다. 이 문제를 어떻게 막는지는 뒤에서 다시 다루겠습니다.
먼저 쿠버네티스에서 어떤 전략으로 배포하는지부터 정리해 보겠습니다.
Argo Rollouts 배포 방식
쿠버네티스 기본 Deployment는 배포 전략으로 RollingUpdate와 Recreate 두 가지만 지원합니다. Canary, Blue-Green, 트래픽 가중치 조절, 메트릭 기반 자동 롤백 같은 고급 배포는 Deployment만으로는 불가능합니다.
Argo Rollouts는 Deployment를 대체하는 CRD(kind: Rollout)로, 위 전략을 모두 제공합니다. 특히 ArgoCD(GitOps)로 매니페스트를 동기화하고, 워크로드는 Argo Rollouts의 Rollout 리소스로 배포합니다. 배포 전략은 Helm 차트 값에서 결정됩니다.
전형적인 k8s 기반의 아키텍처입니다.
workload:
type: rollout # rollout(=Argo Rollouts) | statefulset
rollout:
strategy:
type: rollingUpdate # rollingUpdate | canary | blueGreen ← 여기서 전략 선택

1. RollingUpdate
"조금씩 갈아끼우기"
구버전 Pod를 조금씩 새 버전으로 교체하는 방식입니다. 가장 단순하고 자원 효율이 좋아 하위 환경과 다수 서비스의 기본 전략으로 쓰입니다.

단순하고 추가 자원이 적은 대신, 새 버전이 점진적으로 전체 사용자에게 노출됩니다. 버그가 있으면 일부 사용자가 즉시 영향을 받고, 무엇보다 구·신 버전이 공존하는 구간이 존재합니다. 앞서 본 ENUM 문제가 가장 잘 드러나는 전략이 바로 이것입니다.
2. Blue-Green
"통째로 갈아끼우기"
새 버전(Green)을 기존(Blue) 옆에 100% 규모로 통째로 띄워 두고, 준비가 끝나면 트래픽을 한 번에 Green으로 스위치합니다. 문제가 생기면 다시 Blue로 즉시 되돌립니다.

전환이 순간적이라 두 버전이 트래픽을 동시에 받는 시간이 거의 없고, 롤백이 트래픽 스위치만으로 즉시 가능합니다. 대신 배포 중 Blue와 Green을 동시에 띄우므로 약 2배의 자원이 필요합니다.
주의할 점은 Blue-Green이 공존 문제를 완전히 없애지는 않는다는 것입니다. 트래픽 관점의 공존은 사라지지만, DB·캐시·메시지 큐처럼 공유 상태를 통해 v2가 만든 데이터를 (아직 살아 있는) v1이 읽는 경로는 여전히 남습니다. scaleDownDelaySeconds 동안 Blue가 살아 있는 것도 같은 이유에서 잠재적 창구가 됩니다.
3. Canary
"트래픽을 조금씩 흘려보내기"
새 버전에 트래픽을 10% → 30% → … → 100%로 조금씩 흘리며, 각 단계에서 멈춰(pause) 안전을 확인합니다. 가장 안전하지만 가장 느립니다. 결제처럼 장애 반경이 큰 서비스에 적합합니다.

단계별 수동 검증 설정도 가능합니다.
운영자가 Argo Rollouts UI나 CLI에서 직접 승인해야 다음 단계로 넘어가도록 하여, 사람 개입 없이 다음 단계로의 배포가 진행되는 것을차단할 수 있습니다.
예를 들어 APM 지표로 에러율을 측정해, 임계치를 넘으면 자동으로 이전 버전으로 되돌립니다.
위험을 가장 잘게 쪼개 노출하고 자동 롤백까지 가능한 대신, 가장 느리며 트래픽 분배용 인프라(예: Istio HTTPRoute)와 분석 설정이 필요합니다. 그리고 Canary는 의도적으로 두 버전을 오래 공존시키는 전략이므로, 백워드 호환성이 지켜지지 않으면 오히려 공존 구간이 길어 문제가 더 오래 노출될 수 있습니다.
세 전략 비교
항목 RollingUpdate Blue-Green Canary
| 방식 | Pod를 조금씩 교체 | 통째로 띄우고 한 번에 전환 | 트래픽을 단계적(%)으로 이동 |
| 두 버전 공존 시간 | 짧게 있음 | (트래픽 기준) 거의 없음 | 길게 있음 (의도적) |
| 추가 자원 | 적음 (maxSurge 만큼) | 많음 (약 2배) | 중간 |
| 롤백 속도 | 보통 (다시 롤링) | 매우 빠름 (스위치) | 빠름 (가중치 0%) |
| 위험 노출 제어 | 약함 | 중간 (preview 검증) | 강함 (%로 세밀) |
| 자동 분석/롤백 | 없음 | 없음 | 가능 |
어떤 서비스에 어떤 전략을 쓰나
관찰된 패턴은 명확했습니다.
- 하위 환경(dev/qa/stg)은 단순하고 인프라 자원 효율이 좋은 RollingUpdate로 통일합니다.
- 운영(prd)은 서비스의 장애 반경에 따라 전략을 격상합니다. (아래 서비스 분류는 익명화한 것입니다.)
환경 일반적인 전략 대상
| dev / qa / stg | RollingUpdate | 전 서비스 공통 |
| prd — 일반 화면/조회성 | RollingUpdate | 웹 프론트, 백오피스, 조회 워커 등 |
| prd — 도메인 핵심 | Blue-Green | 회원 · 포인트 · 멤버십 · 인증 · 알림 · 정산 어댑터 · 리뷰 · 외부 연동 등 |
| prd — 최고 위험 | Canary (+자동 분석) | 결제, API 게이트웨이 |

- 데이터 정합성과 도메인 신뢰성이 중요한 서비스는 Blue-Green으로 "통째 전환 + 즉시 롤백" 안전망을 둡니다.
- 결제·게이트웨이처럼 장애 반경이 가장 큰 서비스는 Canary로 트래픽을 잘게 흘리며 자동 분석까지 붙입니다.
- 그 외 화면·조회성 서비스는 자원 효율이 좋은 RollingUpdate를 유지합니다.
전략별로 감수하는 비용을 한 줄로 요약하면 다음과 같습니다.
RollingUpdate는 자원 오버헤드가 거의 없는 대신 새 버전이 점진적으로 전원에게 노출됩니다.
Blue-Green은 공존 시간을 없애는 대가로 배포 중 자원이 약 2배 듭니다.
Canary는 위험을 가장 잘게 쪼개는 대신 가장 느리고 트래픽 분배 인프라가 필요합니다.
상황별로 보는 전략 선택
기준만으로는 감이 잘 오지 않으므로, "이 배포를 한다고 치면 어떻게 흘러가는가"를 실제 설정과 함께 짚어보겠습니다.
상황 1 — 조회성 API에 응답 필드 하나 추가 (RollingUpdate)
무상태 조회 서비스에 응답 필드를 하나 추가하는 배포입니다. FE와 응답 형태가 달라 타입 에러가 발생하더라도 특정 화면 일부만 일시적으로 깨지며(FE가 strict로 타입을 관리하더라도 nullable로 오류가 나지 않도록 일시적으로 처리하면 됩니다), 스키마·데이터 변경은 없습니다.
2배 자원(Blue-Green)이나 느린 단계 검증(Canary)을 감수할 이유가 없는 경우입니다.
다만 새 버전이 점진적으로 전원에게 노출됩니다. 만약 이 배포에 DB 마이그레이션이 끼어 있고 구·신 버전이 잠깐 공존하면 문제가 생길 수 있는데, 그런 배포라면 애초에 이 전략의 후보에서 빠집니다(→ 상황 2).
상황 2 — 도메인 핵심 서비스에 컬럼 추가 + 로직 변경 (Blue-Green)
서비스에서 등급 계산 로직을 바꾸고 신규 컬럼을 읽기 시작하는 배포입니다. 구버전과 신버전이 동시에 같은 회원 데이터를 다르게 처리하면 정합성이 깨질 수 있습니다.
Green(신버전)을 100% 규모로 옆에 띄우되 트래픽은 아직 Blue(구버전)가 전부 받습니다. preview endpoint로 Green을 검증한 뒤, 60초 후 트래픽을 한 번에 Green으로 스위치하고, Blue는 120초 더 살려두다 제거합니다.
여기서 Canary가 아니라 Blue-Green을 쓰는 이유가 핵심입니다. Canary는 구·신 버전이 트래픽을 나눠 받으며 오래 공존하므로, 회원 같은 상태 서비스에서는 그 공존 시간 동안 "어떤 요청은 구버전이, 어떤 요청은 신버전이 처리"해 데이터가 뒤섞일 위험이 있습니다. Blue-Green은 전환이 순간적이라 공존이 거의 없고, 문제 시 트래픽만 Blue로 되돌리면 즉시 롤백됩니다. 그 대가로 배포 중 Blue+Green이 동시에 떠 자원이 약 2배 듭니다.
상황 3-A — 게이트웨이 라우팅 규칙 변경 (Canary 수동)
API 게이트웨이의 라우팅·필터 로직을 바꾸는 배포입니다. 게이트웨이는 모든 트래픽의 진입점이라 잘못되면 전 서비스가 영향을 받습니다. 다만 지표로 자동 판정하기에는 애매한 변경이라 사람이 단계마다 확인합니다.
rollout:
strategy:
type: canary
canary:
steps:
- setWeight: 10
- pause: {} # 무기한 정지 → 운영자가 대시보드 보고 수동 승인
- setWeight: 30
- pause: {}
- setWeight: 60
- pause: {}
- setWeight: 100
트래픽 10%만 신버전으로 흘린 뒤 pause: {}에서 무기한 멈춥니다.
운영자가 로그·지표를 확인하고 Argo UI에서 승인하면 30%, 60%로 단계적으로 올립니다. 이상하면 그 자리에서 abort해 가중치 0%로 즉시 복귀합니다.
장애 반경이 최대이므로, 전원에게 노출하기 전에 소량(10%) 트래픽으로 실제 프로덕션에서 먼저 검증하는 것입니다. 대신 가장 느리고 트래픽 분배 인프라(Istio HTTPRoute)가 필요합니다.
상황 3-B — 결제 서비스 배포 (Canary 자동 분석)
결제 서비스는 돈이 오가고, 성공률·에러율이라는 명확한 SLI가 있어 사람 대신 지표가 판정합니다.
rollout:
strategy:
type: canary
analysis:
enabled: true
templates:
- metrics:
- name: error-rate
interval: 60s
initialDelay: 120s
successCondition: result < 2 # 5xx 에러율 2% 미만이어야 통과
failureLimit: 3 # 3번 실패하면 자동 롤백
provider:
datadog:
formula: "(errors / (total + 0.001)) * 100"
queries:
errors: sum:trace.servlet.request{http.status_code:5*, service:{{ args.service-name }}}.as_rate()
total: sum:trace.servlet.request{service:{{ args.service-name }}}.as_rate()
- name: success-rate
successCondition: result >= 98 # 성공률 98% 이상
failureLimit: 3
트래픽 일부를 신버전으로 흘린 뒤, 120초가 지나면 60초 간격으로 에러율·성공률을 측정합니다.
조건을 통과하면 다음 단계로 가고, failureLimit만큼 조건을 어기면 사람 개입 없이 자동으로 v1으로 복귀합니다. 결제 장애는 초 단위로 손실이 나므로, 새벽에 사람이 대시보드를 보지 못해도 시스템이 스스로 롤백해야 합니다. 명확한 지표가 있으니 그 판정을 자동화한 것입니다.
신규 서비스 전략 결정 순서
이런 각 무중단 배포 방식을 고려하여 다음과 같은 논리 구조로 배포 방식을 선정하면 됩니다.
공존을 피해야 하면 Blue-Green, 공존을 활용해 잘게 검증해야 하면 Canary, 둘 다 신경 쓸 필요 없으면 RollingUpdate입니다. 그리고 이 결정 트리 전체를 관통하는 전제는 앞서 강조한 백워드 호환성입니다. 어떤 칸에 떨어지든, 코드가 공존 구간을 견디지 못하면 선택한 전략과 무관하게 장애가 납니다.

백워드 호환성 — 전략이 못 막는 장애
정리하면, RollingUpdate와 Canary는 구조상 두 버전을 공존시키고, Blue-Green도 공유 상태를 통한 공존 창구가 남습니다. 즉 어떤 전략을 써도 v1과 v2가 동시에 데이터를 다루는 순간이 존재합니다. 이 구간을 견디는 것은 배포 전략이 아니라 코드의 몫입니다.
앞의 ENUM 문제로 돌아가 보겠습니다. 해결의 핵심은 모르는 값을 만나도 죽지 않게 만드는 것입니다. Jackson에서는 기본값 ENUM을 두고, 알 수 없는 값을 그 기본값으로 흡수하도록 설정할 수 있습니다.
enum class OrderStatus {
CREATED, PAID, SHIPPED,
@JsonEnumDefaultValue
UNKNOWN // 모르는 값을 흡수할 기본값
}
val mapper = jacksonObjectMapper()
.configure(
DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE,
true
)
// 이제 v1 Pod가 "REFUNDED"를 받아도 예외 대신 UNKNOWN으로 매핑됩니다
val order: Order = mapper.readValue("""{"status":"REFUNDED"}""")
// order.status == OrderStatus.UNKNOWN (예외 없음)
이렇게 하면 v1 Pod는 자신이 모르는 값을 만나도 500을 던지지 않고 UNKNOWN으로 안전하게 처리합니다.
물론 이것은 방어일 뿐, 진짜 원칙은 배포 순서를 나누는 것입니다. 흔히 expand-and-contract(또는 reader-first)라고 부르는 방식입니다.
- Expand: 새 값을 읽을 수 있는 코드(reader)를 먼저 배포합니다. 아직 아무도 그 값을 만들지 않습니다.
- 모든 Pod가 새 값을 이해하게 된 뒤에야, 그 값을 만드는 코드(writer)를 배포합니다.
- Contract: 구버전 경로가 완전히 사라진 뒤 정리합니다.
핵심은 "쓰는 쪽보다 읽는 쪽을 먼저 배포한다"는 순서입니다. ENUM 추가, 필드 추가/삭제, DB 컬럼 변경, 메시지 스키마 변경이 모두 같은 원리를 따릅니다. 새 데이터를 만들어내기 전에, 그 데이터를 만날 모든 소비자가 먼저 그것을 견딜 수 있어야 합니다.
결국 무중단 배포는 두 축으로 이루어집니다.
인프라 축은 Argo Rollouts의 전략과 readiness/preStop/PDB 같은 장치가 담당하고, 코드 축은 백워드 호환성이 담당합니다. 전략만 잘 골라도 배포는 매끄러워 보이지만, 코드가 공존 구간을 견디지 못하면 무중단은 그 지점에서 조용히 깨집니다.
마무리
무중단 배포 방식에 대한 공부는, 백엔드 개발을 하면서 Backward Compatible한 개발을 위해 반드시 알아야 할 지식입니다.
뿐만 아니라 본인의 서비스가 어떻게 배포되고 어떻게 관리되는지 아는 것은, 제가 작업하는 서비스에 대한 오너십을 기르기 위해 반드시 가져야 할 자세라고 생각합니다.
참고
용어
- Argo Rollouts: k8s Deployment를 대체하는 CRD. Canary/Blue-Green 등 고급 배포 전략을 제공합니다.
- maxSurge / maxUnavailable: 교체 중 추가로 띄울 수 있는 Pod 수 / 동시에 줄어들어도 되는 Pod 수. maxUnavailable: 0이면 용량 손실이 없습니다.
- setWeight / pause: Canary에서 새 버전으로 보낼 트래픽 비중(%)과 단계 사이의 정지. pause: {}는 무기한(수동 승인)입니다.
- autoPromotion / scaleDownDelay: Blue-Green에서 자동 전환 여부와, 전환 후 구버전을 유지하는 시간(즉시 롤백 대비)입니다.
- PDB (PodDisruptionBudget): 노드 드레인 등 자발적 중단 시 최소 가용 Pod 수를 보장하는 정책입니다.
- expand-and-contract: 스키마/값 변경을 읽기(reader)-쓰기(writer)-정리(contract) 순으로 나눠, 공존 구간에서도 호환을 유지하는 배포 패턴입니다.
'🍀 올리브영 개발자 🍀 > 백엔드 직무전환 프로젝트' 카테고리의 다른 글
| [데이터베이스] Reader Writer 분리 (1) | 2026.06.11 |
|---|