목차
- 결론부터
- 실패 패턴 1: DB 스키마 불일치
- 증상
- 왜 발생하는가
- 방지 방법
- 실패 패턴 2: 세션/캐시 불일치
- 증상
- 왜 발생하는가
- 방지 방법
- 실패 패턴 3: 롤백 시 데이터 정합성 깨짐
- 증상
- 왜 발생하는가
- 방지 방법
- 실패 패턴 4: 헬스체크 통과했지만 실제론 장애
- 증상
- 왜 발생하는가
- 방지 방법
- 실패 패턴 5: 트래픽 전환 순간의 커넥션 드레이닝 누락
- 증상
- 왜 발생하는가
- 방지 방법
- Blue-Green 배포 안전 체크리스트
- 배포 전
- 전환 시
- 전환 후
- 요약
결론부터
Blue-Green 배포는 구조 자체는 단순하다. 두 환경을 두고 트래픽을 전환하면 끝이니까. 그런데 실제로 운영하면 전환 순간에 장애가 터진다. 개념이 틀린 게 아니라, 전환 전후로 발생하는 상태 불일치를 놓치기 때문이다.
이 글은 Blue-Green 배포의 기초를 설명하지 않는다. 이미 도입했거나 도입 중인 팀이 겪는 실패 패턴 5가지와, 각각의 방지 방법을 정리한다.
실패 패턴 1: DB 스키마 불일치
증상
Green 환경에 새 버전을 배포하고 트래픽을 전환했다. 잘 되는 것 같았는데, 롤백이 필요해서 Blue로 되돌리는 순간 500 에러가 쏟아진다. Blue의 코드가 Green에서 변경된 DB 스키마를 읽지 못하는 것이다.
왜 발생하는가
Blue와 Green은 DB를 공유한다. Green 배포 시 마이그레이션을 실행하면 테이블 구조가 바뀌는데, Blue의 코드는 이전 스키마를 기대한다. 전환은 애플리케이션만 바꾸지, DB는 되돌릴 수 없다.
흔한 시나리오:
- Green에서 컬럼명을
user_name->username으로 변경 - Blue 코드는 여전히
user_name을 SELECT - 롤백하면 Blue가 존재하지 않는 컬럼을 조회
방지 방법
Expand-Contract 패턴을 적용한다. 스키마 변경을 두 단계로 나눠서 실행한다.
-- 1단계: Expand (Green 배포 전)
-- 새 컬럼 추가, 기존 컬럼 유지
ALTER TABLE users ADD COLUMN username VARCHAR(100);
UPDATE users SET username = user_name;
-- 애플리케이션은 두 컬럼 모두 읽을 수 있게 작성
-- Blue도 Green도 정상 동작
-- 2단계: Contract (Blue-Green 양쪽 모두 새 코드 확인 후)
-- 구 컬럼 삭제
ALTER TABLE users DROP COLUMN user_name;
핵심 원칙: 마이그레이션은 항상 하위 호환성을 유지해야 한다. 컬럼 삭제나 이름 변경은 양쪽 환경이 모두 새 스키마를 사용하는 것이 확인된 후에 실행한다.
실패 패턴 2: 세션/캐시 불일치
증상
트래픽을 Green으로 전환한 순간, 로그인한 사용자 전원이 로그아웃된다. 장바구니가 비어 있다는 CS가 몰린다. Green 서버에는 Blue에서 생성된 세션 데이터가 없다.
왜 발생하는가
세션을 애플리케이션 서버의 로컬 메모리에 저장하는 경우다. Blue 서버 프로세스의 메모리에 있는 세션은 Green 서버에서 접근할 수 없다. 로컬 파일 기반 캐시도 마찬가지다.
[사용자] → [Blue: 세션 존재] → 전환 → [Green: 세션 없음] → 로그아웃
방지 방법
세션과 캐시를 외부 저장소로 분리한다.
# Spring Boot 예시 - Redis 세션 저장소
spring:
session:
store-type: redis
redis:
host: session-redis.internal
port: 6379
# Nginx에서 sticky session을 쓰고 있다면 주의
# Blue-Green 전환 시 upstream이 통째로 바뀌므로
# sticky session은 의미가 없어진다
# 잘못된 설정
upstream backend {
sticky cookie srv_id;
server blue-1:8080;
server blue-2:8080;
}
# 올바른 접근: 외부 세션 저장소 + stateless 서버
upstream backend {
server green-1:8080;
server green-2:8080;
}
체크 포인트:
- 세션: Redis, Memcached 등 외부 저장소 사용
- 캐시: 로컬 캐시 의존도 확인, 캐시 워밍업 스크립트 준비
- 파일 업로드: 로컬 디스크가 아닌 S3 등 공유 스토리지 사용
실패 패턴 3: 롤백 시 데이터 정합성 깨짐
증상
Green으로 전환 후 30분간 운영했다. 문제가 발견돼서 Blue로 롤백했다. 그런데 Green에서 30분간 쌓인 주문 데이터의 형식이 Blue 코드와 맞지 않아서, 주문 조회가 깨진다.
왜 발생하는가
Green 버전이 데이터를 새로운 형식으로 저장했기 때문이다. 예를 들어:
- Green: 주문 상태를
ENUM('PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED')으로 확장 - Green 운영 중
DELIVERED상태 주문이 100건 생성됨 - Blue로 롤백하면 Blue 코드는
DELIVERED상태를 모름 → 예외 발생
DB 스키마가 아니라 데이터 값 수준의 불일치라서, 마이그레이션 롤백으로도 해결이 안 된다.
방지 방법
1) 데이터 호환성 레이어를 둔다.
// Blue 코드에서 알 수 없는 상태를 graceful하게 처리
public String getDisplayStatus(String status) {
return switch (status) {
case "PENDING" -> "주문 접수";
case "CONFIRMED" -> "확인됨";
case "SHIPPED" -> "배송중";
default -> "처리중"; // 알 수 없는 상태 → 안전한 기본값
};
}
2) Feature Flag로 데이터 형식 변경을 제어한다.
// 새 형식은 Feature Flag가 켜진 후에만 저장
if (featureFlags.isEnabled("order-v2-status")) {
order.setStatus("DELIVERED");
} else {
order.setStatus("SHIPPED"); // 기존 호환 상태 유지
}
3) 롤백 시간 제한 정책을 정한다. Green 전환 후 일정 시간이 지나면 롤백 대신 핫픽스를 선택하는 기준을 미리 합의해 둔다. 30분 이상 운영된 Green을 롤백하면 데이터 정합성 리스크가 급격히 올라간다.
실패 패턴 4: 헬스체크 통과했지만 실제론 장애
증상
Green 환경 배포 완료. 헬스체크 엔드포인트가 200을 반환한다. 트래픽을 전환한다. 그런데 실제 API 호출에서 타임아웃이 발생한다. 헬스체크는 통과했는데 서비스는 죽어 있는 상황이다.
왜 발생하는가
헬스체크가 너무 단순하기 때문이다.
// 이런 헬스체크는 아무것도 검증하지 못한다
@GetMapping("/health")
public ResponseEntity<String> health() {
return ResponseEntity.ok("OK");
}
서버 프로세스가 살아 있으면 200을 반환한다. 하지만:
- DB 커넥션 풀이 고갈되었거나
- 외부 API 연동이 끊겼거나
- JVM이 Full GC 중이거나
- 스레드 풀이 포화 상태이면
애플리케이션은 실질적으로 응답 불능인데 헬스체크만 통과하는 상황이 된다.
방지 방법
Deep Health Check를 구현한다.
@GetMapping("/health/ready")
public ResponseEntity<Map<String, Object>> readiness() {
Map<String, Object> checks = new LinkedHashMap<>();
boolean allHealthy = true;
// DB 연결 확인
try {
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
checks.put("database", "UP");
} catch (Exception e) {
checks.put("database", "DOWN: " + e.getMessage());
allHealthy = false;
}
// Redis 연결 확인
try {
redisTemplate.opsForValue().get("health-check-key");
checks.put("redis", "UP");
} catch (Exception e) {
checks.put("redis", "DOWN: " + e.getMessage());
allHealthy = false;
}
// 커넥션 풀 여유 확인
HikariPoolMXBean pool = hikariDataSource.getHikariPoolMXBean();
int idle = pool.getIdleConnections();
if (idle < 2) {
checks.put("db_pool", "WARN: idle=" + idle);
allHealthy = false;
} else {
checks.put("db_pool", "OK: idle=" + idle);
}
HttpStatus status = allHealthy ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE;
return ResponseEntity.status(status).body(checks);
}
ALB 또는 Nginx에서 이 엔드포인트를 확인한다.
# Nginx 헬스체크 설정
upstream green {
server green-1:8080;
server green-2:8080;
}
server {
location /internal/health {
proxy_pass http://green/health/ready;
proxy_connect_timeout 3s;
proxy_read_timeout 3s;
}
}
추가로 트래픽 전환 전에 실제 시나리오 테스트를 자동화한다.
#!/bin/bash
# 전환 전 스모크 테스트
GREEN_HOST="green.internal:8080"
echo "=== Smoke Test 시작 ==="
# 헬스체크
curl -sf "$GREEN_HOST/health/ready" || { echo "FAIL: health check"; exit 1; }
# 핵심 API 호출
curl -sf -o /dev/null -w "%{http_code}" "$GREEN_HOST/api/products?limit=1" | grep -q "200" \
|| { echo "FAIL: products API"; exit 1; }
# 응답 시간 확인 (500ms 초과 시 실패)
RESP_TIME=$(curl -sf -o /dev/null -w "%{time_total}" "$GREEN_HOST/api/products?limit=1")
if (( $(echo "$RESP_TIME > 0.5" | bc -l) )); then
echo "FAIL: response time ${RESP_TIME}s > 0.5s"
exit 1
fi
echo "=== Smoke Test 통과 ==="
실패 패턴 5: 트래픽 전환 순간의 커넥션 드레이닝 누락
증상
새벽 3시에 배포했다. 트래픽을 Green으로 전환했다. 그런데 Blue에서 처리 중이던 결제 요청 12건이 응답 없이 끊겼다. 사용자에게는 결제가 된 건지 안 된 건지 알 수 없는 상태가 됐다. PG사에는 승인이 갔는데 우리 DB에는 기록이 없다.
왜 발생하는가
트래픽 전환 시 Blue로 향하는 기존 커넥션을 즉시 끊어버렸기 때문이다. 진행 중인 요청이 완료될 시간을 주지 않았다.
시간순서:
1. 사용자 A가 Blue에 결제 요청 (처리 중, 약 3초 소요)
2. 1.5초 시점에 트래픽을 Green으로 전환
3. Blue 서버 즉시 종료 또는 연결 차단
4. 사용자 A의 요청이 중간에 끊김
5. PG사에는 승인 완료, 우리 DB에는 미기록
방지 방법
Connection Draining(Graceful Shutdown) 을 설정한다.
# AWS ALB - Target Group 설정
# 등록 취소 지연: 기존 연결이 완료될 때까지 대기하는 시간
deregistration_delay: 30 # 30초 동안 기존 요청 완료 대기
# Spring Boot - Graceful Shutdown
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
# Nginx 전환 스크립트 - 점진적 전환
#!/bin/bash
# 1단계: 새 요청만 Green으로
cat > /etc/nginx/conf.d/upstream.conf << 'EOF'
upstream backend {
server green-1:8080;
server green-2:8080;
server blue-1:8080 down; # 새 요청 차단
server blue-2:8080 down;
}
EOF
nginx -s reload
# 2단계: 기존 요청 완료 대기
echo "기존 커넥션 드레이닝 대기 (30초)..."
sleep 30
# 3단계: Blue 서버에 남은 커넥션 확인
REMAINING=$(curl -s blue-1:8080/actuator/metrics/tomcat.threads.busy | jq '.measurements[0].value')
if [ "$REMAINING" -gt 0 ]; then
echo "경고: Blue에 아직 $REMAINING 개 활성 요청 존재. 추가 대기..."
sleep 30
fi
echo "전환 완료"
핵심: 트래픽 전환은 즉시 전환이 아니라 단계적 전환이어야 한다. 새 요청을 Green으로 보내되, Blue의 기존 요청이 끝날 때까지 기다린다.
Blue-Green 배포 안전 체크리스트
배포 전환 전에 아래 항목을 확인한다.
### 배포 전
- [ ] DB 마이그레이션이 하위 호환성을 유지하는가? (Expand-Contract)
- [ ] 세션/캐시가 외부 저장소에 있는가?
- [ ] 새 데이터 형식을 이전 코드가 처리할 수 있는가?
- [ ] Deep Health Check가 구현되어 있는가?
- [ ] 스모크 테스트가 Green에서 통과했는가?
### 전환 시
- [ ] Connection Draining 시간이 설정되어 있는가?
- [ ] Blue의 활성 요청 수를 모니터링하고 있는가?
- [ ] 롤백 절차가 문서화되어 있고 1분 내 실행 가능한가?
### 전환 후
- [ ] 에러율 모니터링 대시보드를 확인하고 있는가?
- [ ] 롤백 판단 기준(에러율 N% 초과 등)이 합의되어 있는가?
- [ ] Blue 환경을 바로 내리지 않고 대기 상태로 유지하는가?
요약
- DB 스키마 불일치: Expand-Contract 패턴으로 마이그레이션을 두 단계로 나눠서 항상 하위 호환성을 유지한다
- 세션/캐시 불일치: 세션과 캐시를 Redis 등 외부 저장소로 분리해서 어느 환경에서든 접근 가능하게 한다
- 롤백 시 데이터 정합성: 알 수 없는 값에 대한 기본값 처리와 Feature Flag로 데이터 형식 변경을 제어한다
- 헬스체크 위양성: 단순 200 응답이 아닌 DB, Redis, 커넥션 풀까지 확인하는 Deep Health Check를 구현한다
- 커넥션 드레이닝 누락: 트래픽 전환 시 기존 요청이 완료될 때까지 대기하는 Graceful Shutdown을 반드시 설정한다
관련글:
- Blue-Green 무중단 배포 구축기 — 기본 구조와 설정
'AI 코딩 에이전트' 카테고리의 다른 글
| Claude Code Buddy 리롤하는 법: buddy-pick으로 원하는 컴패니언 직접 고르기 (0) | 2026.04.07 |
|---|---|
| AI 코딩 에이전트에게 백엔드 리팩터링을 맡기면 어디서 위험해지나 (0) | 2026.04.04 |
| Kafka 동기화 트랜잭션에서 실제로 자주 터지는 장애 5가지 (0) | 2026.04.04 |
| AI 코딩 에이전트 보안 사고는 어떻게 터지는가: 권한, 토큰, 쉘의 삼중주 (0) | 2026.04.04 |
| MCP를 붙이면 코딩 에이전트는 어디까지 달라지나: 연결 레이어의 설계 해부 (0) | 2026.04.04 |
