ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

목차

  • 결론부터
  • 실패 패턴 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을 반드시 설정한다

관련글:

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.