Skip to content

[문제해결] 무중단 배포 설정하기 #103

@wlgns12370

Description

@wlgns12370

현재 인프라 구성

[ALB]
  ├─► EC2 App 1 (Spring Boot)
  └─► EC2 App 2 (Spring Boot)
            │
            ├─► EC2 Redis (공유, 단일 인스턴스)
            └─► RDS (공유, 단일 인스턴스)
  • App 서버 2대가 Redis와 RDS를 공유
  • /health 엔드포인트로 헬스체크 가능

지금까지 구현을 고려했을 때 발생할 수 있는 문제

Warmup Runner 변경: 재시작 직후 Redis가 DB 기반으로 초기화되기 전에 요청이 들어오면 score 0으로 응답

선택 전략: 롤링 배포

EC2 2대 환경에서 Blue-Green은 동일한 스펙의 인스턴스 2대를 추가해야 해서 비용 부담이 있다.
대신 ALB의 연결 드레이닝(Connection Draining) 기능을 활용한 롤링 배포로 무중단을 달성한다.

초기:    ALB → [App1 v1] [App2 v1]

1단계:   ALB → [App1 드레이닝] [App2 v1]   ← App1 신규 요청 차단
2단계:   ALB → [App1 v2 기동] [App2 v1]   ← App1 /health 확인 후 재등록
3단계:   ALB → [App1 v2] [App2 드레이닝]  ← App2 신규 요청 차단
완료:    ALB → [App1 v2] [App2 v2]

배포 전 선행 작업: DB 마이그레이션 분리

신버전 배포 전에 마이그레이션을 먼저 적용해야 구버전 코드와 공존이 가능하다.

-- V{N}__add_like_count_to_booth.sql
ALTER TABLE booth ADD COLUMN like_count INT NOT NULL DEFAULT 0;
  • DEFAULT 0으로 추가하면 구버전 코드는 해당 컬럼을 무시하고 계속 동작
  • 마이그레이션 적용 완료 확인 후 앱 배포 진행

배포 절차 (단계별)

Phase 1 — DB 마이그레이션

1. RDS 스냅샷(백업) 생성
2. Flyway 마이그레이션 스크립트 실행 (like_count 컬럼 추가)
3. 구버전 앱 2대 정상 동작 확인

Phase 2 — App1 교체

1. ALB Target Group에서 App1 deregister
   → 드레이닝 대기 (기존 연결 처리 완료, 예: 30초)
2. App1에 신버전 jar 배포 후 재기동
   → Warmup Runner 실행: DB → Redis 초기화 (like_count 복원)
3. GET /health → 200 확인
4. ALB Target Group에 App1 재등록
5. ALB가 App1으로 트래픽 보내는지 확인 (1~2분 모니터링)

Phase 3 — App2 교체

6. ALB Target Group에서 App2 deregister
7. App2에 신버전 jar 배포 후 재기동
   → Warmup Runner: Redis가 이미 App1이 초기화해둔 상태
     → ADD NX 옵션 또는 기존 값이 있으면 스킵하도록 처리 권장
8. GET /health → 200 확인
9. ALB Target Group에 App2 재등록

Warmup Runner: App2 재기동 시 Redis 덮어쓰기 문제

App1이 DB 값으로 Redis를 초기화한 뒤, App2가 재기동하면 다시 Redis를 초기화한다.
이 사이에 사용자 좋아요 요청이 들어와 Redis score가 올라간 상태라면 App2의 Warmup이 덮어써버릴 수 있다.

해결책: Warmup 시 현재 Redis 값보다 DB 값이 클 때만 적용

@Override
public void run(ApplicationArguments args) {
    List<Booth> booths = boothRepository.findAll();

    booths.forEach(booth -> {
        String key = "like:ranking";
        String member = String.valueOf(booth.getId());

        Double currentScore = redisTemplate.opsForZSet().score(key, member);

        // Redis에 값이 없거나 DB 값이 더 크면 DB 값으로 초기화
        if (currentScore == null || currentScore < booth.getLikeCount()) {
            redisTemplate.opsForZSet().add(key, member, booth.getLikeCount());
        }
    });
}

스케줄러 중복 실행 처리

2대가 동시에 syncLikeCountToDB()를 실행하면 RDS에 동시 업데이트가 발생한다.
Redis 값을 그대로 쓰는 멱등 연산이므로 정합성 문제는 없지만, 불필요한 DB 부하를 줄이려면 ShedLock을 추가한다.

<!-- build.gradle -->
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.x.x'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.x.x'
-- Flyway로 함께 추가
CREATE TABLE shedlock (
    name       VARCHAR(64)  NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at  TIMESTAMP(3) NOT NULL,
    locked_by  VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);
@Scheduled(fixedDelay = 5 * 60 * 1000)
@SchedulerLock(name = "boothLikeSync", lockAtMostFor = "4m", lockAtLeastFor = "1m")
public void syncLikeCountToDB() {
    // 2대 중 lock을 획득한 1대만 실행
}

롤백 계획

상황 롤백 방법
App1 기동 실패 ALB에 App1 재등록 안 하면 됨 — App2가 트래픽 전담, 무중단
App2 기동 실패 App1이 이미 정상 운영 중 — App2만 구버전으로 롤백 후 재기동
마이그레이션 오류 RDS 스냅샷으로 복원 (배포 전 백업 필수)
Redis 데이터 유실 Warmup Runner 재실행으로 DB → Redis 복원 가능

논의 필요 사항

  • ALB 드레이닝 시간: 현재 설정값 확인 필요 — 짧으면 처리 중 요청이 끊길 수 있음 (권장: 30초)
  • 스케줄러 주기: flush 주기 = 최대 데이터 손실 허용 시간 — 4.md 논의 사항과 연계
  • ShedLock 도입 여부: 부하가 크지 않다면 생략 가능, 중복 실행 자체는 정합성에 무해

Metadata

Metadata

Assignees

No one assigned

    Labels

    기능구현This will not be worked on

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions