본문 바로가기
Framework/Spring Boot

[Spring Boot] 무중단 배포 전략(Blue-Green)

새벽 3시, 개발자가 버그를 고치고 새로운 스프링 부트(Spring Boot) 버전(V2)을 배포(Deploy)하기 위해 서버에 접속합니다. 기존에 돌고 있던 V1 서버 프로세스를 kill 명령어로 죽이고, 새로운 V2 .jar 파일을 java -jar 명령어로 기동 시킵니다.
스프링의 거대한 프레임워크가 톰캣(Tomcat)을 띄우고 메모리에 빈(Bean)들을 올리는 데 약 10~15초가 소요됩니다.
문제는 이 15초 동안 사이트에 들어온 사용자들은 끔찍한 "502 Bad Gateway" 또는 "연결할 수 없음" 에러 페이지를 마주하게 된다는 것입니다. (다운타임 발생)

 

과거에는 고객들이 잠든 새벽에 서버를 내리고 작업(점검 시간)하는 것이 당연했지만, 24시간 글로벌로 돌아가는 현대의 서비스(넷플릭스, 쿠팡 등)에서 단 1초의 점검 시간이나 끊김(Downtime)은 곧 수억 원의 매출 증발을 의미합니다.

Nginx(엔진엑스) 리버스 프록시를 활용한 블루/그린(Blue/Green) 무중단 배포 아키텍처에 대하여 다뤄보겠습니다.

1. 무중단 배포의 핵심 원리: 문지기와 2개의 방

원리는 아주 간단합니다. 유저가 들어오는 문 앞에 빠릿빠릿한 문지기(Nginx)를 세워두고, 방을 2개(포트 8080, 8081) 준비하는 것입니다.

  1. 현재는 8080 포트(Blue)에서 구버전 V1 서버가 팽팽 잘 돌아가고 있습니다. 문지기(Nginx)는 모든 유저를 8080 포트로 안내합니다.
  2. 개발자가 신버전 V2를 배포하려고 합니다. 기존 8080을 죽이지 않고, 비어있는 8081 포트(Green)에 V2 서버를 몰래 조용히 구동(Run)시킵니다. (이 15초 동안에도 유저들은 8080 포트에서 정상적으로 서비스를 이용합니다.)
  3. 8081 포트의 V2 서버가 완전히 켜져서 /actuator/health 헬스 체크 응답을 "UP"으로 내려주면 준비가 완료된 것입니다.
  4. (핵심 ) 문지기(Nginx)에게 "지금부터 들어오는 유저들은 8080 말고 8081(V2)로 안내해!"라고 명령(Reload)합니다. Nginx의 방향 전환은 0.1초도 걸리지 않습니다.
  5. 이제 유저들은 완벽한 V2 서버를 이용하게 되며, 역할이 끝난 8080 포트의 V1 서버는 조용히 죽여버립니다(Kill).

 

2. Nginx 리버스 프록시(Reverse Proxy) 세팅의 마법

Nginx는 C언어로 만들어진 미친 듯이 가볍고 빠른 웹 서버입니다. 앞서 말한 '방향 전환(스위칭)'을 하려면 Nginx의 설정 파일(nginx.conf)을 건드려야 합니다.

# /etc/nginx/conf.d/service-url.inc
# 현재 바라보고 있는 포트를 저장하는 변수 (스크립트로 이 파일을 8080 <-> 8081로 수정할 것입니다)
set $service_url http://127.0.0.1:8080;
# /etc/nginx/nginx.conf
server {
    listen 80; # 유저는 무조건 80(기본 웹 포트)로 들어옵니다.
    server_name my-awesome-domain.com;

    # 위에서 만든 변수 파일을 쏙 끼워 넣습니다.
    include /etc/nginx/conf.d/service-url.inc;

    location / {
        # 유저가 들어오면 문지기(Nginx)가 $service_url(8080 또는 8081)로 트래픽을 던져줍니다! (리버스 프록시)
        proxy_pass $service_url;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_addrs;
        proxy_set_header Host $http_host;
    }
}

 

3. 쉘 스크립트(Shell Script)를 통한 100% 자동화

실무에서는 이 배포 과정을 젠킨스(Jenkins)나 Github Actions가 실행하는 리눅스 쉘 스크립트(deploy.sh)로 완벽하게 자동화합니다.

  • 1. 현재 작동 중인 포트 확인: Nginx가 8080을 바라보고 있다면, 새 버전은 8081에 띄우기로 결정합니다.
  • 2. 새 버전 구동: nohup java -jar -Dserver.port=8081 app-v2.jar & 명령어로 8081 포트에 서버를 띄웁니다.
  • 3. 헬스 체크 루프: curl http://127.0.0.1:8081/actuator/health를 1초마다 찔러보며 응답이 올 때까지 대기합니다.
  • 4. Nginx 리로드(Switching): 서버가 완벽히 뜨면, 아까 만든 service-url.inc의 숫자를 8080에서 8081로 바꾸고, sudo service nginx reload 명령어를 날려 Nginx가 끊김 없이 방향을 틀게 만듭니다. (Restart가 아니라 Reload 임에 주의하세요! Reload는 기존 연결을 끊지 않고 부드럽게 설정만 새로 덮어씁니다.)

 

4. 마무리 및 주요 발전 방향

지금까지 서버 업데이트 시 필연적으로 발생하던 "502 Bad Gateway" 에러 화면을 역사 속으로 지워버리고, Nginx의 리버스 프록시와 두 개의 포트를 활용하여 단 0.1초의 끊김도 없이 부드럽게 신규 버전을 서비스하는 '무중단 블루/그린 아키텍처'에 대해 다루어 보았습니다. 이제 여러분은 24시간 언제 배포 버튼을 눌러도 유저의 컴플레인을 받지 않는 자유를 얻었습니다.

자, 드디어 대망의 최종 파트인 '최신 인프라와 트러블슈팅' 영역으로 넘어갑니다. 지금까지 우리는 .jar 파일을 만들어서 EC2 리눅스 서버에 직접 자바(Java)를 깔고 배포했습니다. 그런데 개발자의 로컬은 Windows고 운영 서버는 Linux라서, "내 PC에선 잘 도는데 서버에선 안 돌아요!"라는 고전적인 변명이 발생하기 시작합니다. 이 지긋지긋한 '환경 불일치'의 고통을 찢어버리고 "소스코드뿐만 아니라 자바, OS 환경까지 통째로 압축해서 캡슐처럼 배포하자!"는 가상화 혁명이 불어닥쳤습니다. 본 설명에서는 "클라우드 네이티브의 절대 표준!" Docker(도커)를 활용해 Spring Boot 애플리케이션을 완벽하게 컨테이너화하는 최적의 Dockerfile 작성 비법에 대해 아주 심도 있게 살펴보겠습니다.


여러분이 윈도우(Windows) 노트북에서 Java 17을 설치하고 스프링 부트(Spring Boot) 서버를 완벽하게 개발했습니다. "완벽해! 이제 배포하자!"라며 리눅스(Linux) 운영 서버에 .jar 파일을 올리고 실행했는데, 갑자기 "Unsupported Class Version Error"가 뜨며 서버가 뻗어버립니다. 알고 보니 리눅스 서버에는 Java 11이 깔려있었던 것입니다. 부랴부랴 Java 17로 업데이트했더니 이번엔 파일 경로(Path) 문제로 이미지가 엑스박스가 뜹니다. 개발자의 로컬 환경, 테스트 서버 환경, 라이브 운영 서버 환경이 조금씩 달라서 생기는 이 지긋지긋한 '환경 불일치 문제'는 지난 수십 년간 수많은 개발자의 밤을 지새우게 만들었습니다. 이 고통을 완벽하게 끝내기 위해 "애플리케이션(jar)뿐만 아니라, 자바 버전, 운영체제(OS), 라이브러리 세팅까지 통째로 하나의 박스(Container)에 꽉꽉 압축해서 배포하자!"는 혁명적인 기술이 탄생했습니다. 바로 Docker(도커)입니다.

이번에는 "클라우드 네이티브의 절대 표준!" Spring Boot 애플리케이션을 도커라이징(Dockerizing)하는 최적의 Dockerfile 작성 비법에 대하여 아주 치밀하게 딥다이브 해보겠습니다.

1. Dockerfile: 컨테이너를 찍어내는 붕어빵 틀

도커의 핵심은 Dockerfile이라는 텍스트 문서입니다. 이 문서에 "어떤 OS를 깔고, 어떤 자바를 깔고, 내 코드를 어디에 복사할지"를 적어두면, 도커 엔진이 이 문서를 읽고 '도커 이미지(Image)'라는 압축 캡슐을 만들어냅니다. 스프링 부트를 띄우기 위한 가장 스탠다드한 Dockerfile을 .

# 1. Base Image: 이 컨테이너의 바탕이 될 운영체제와 자바 버전을 지정합니다.
#  실무 꿀팁: 무거운 우분투(Ubuntu) 대신, 초경량화된 리눅스인 alpine 버전을 써야 이미지 용량이 획기적으로 줄어듭니다!
FROM openjdk:17-jdk-alpine

# 2. 컨테이너 내부에서 우리가 작업할 폴더 위치를 지정합니다.
WORKDIR /app

# 3. 프로젝트를 빌드해서 나온 결과물(.jar)을 컨테이너 안의 /app 폴더로 복사(COPY)합니다.
# build/libs/*.jar 는 보통 gradle build를 하면 나오는 파일 경로입니다.
COPY build/libs/*-SNAPSHOT.jar app.jar

# 4. 컨테이너가 켜졌을 때 스프링 부트를 기동시키는 실행 명령어입니다.
# -Djava.security.egd 옵션은 리눅스 환경에서 톰캣 기동 속도가 느려지는 버그를 막아줍니다.
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

2. 실무 고도화: 레이어드 빌드(Layered Build) 최적화

위의 코드는 작동은 잘하지만, 실무 관점에서는 약간의 아쉬움이 있습니다. 스프링 부트의 .jar 파일은 보통 50MB~100MB 정도인데, 이 중 99%는 변하지 않는 라이브러리(Spring, Hibernate 등)이고, 개발자가 짠 순수 코드는 1MB도 안 됩니다. 그런데 소스 코드 한 줄 고칠 때마다 매번 저 100MB짜리를 통째로 도커 이미지로 다시 구워내야(Build) 하니 빌드 시간도 오래 걸리고 네트워크 트래픽도 낭비됩니다.

스프링 부트 2.3부터는 레이어드(Layered) 빌드라는 엄청난 기능을 지원합니다. .jar 파일을 압축 해제해서 [라이브러리 계층], [스프링 부트 로더 계층], [내가 짠 소스코드 계층]으로 쪼개서 도커에 담습니다. 이렇게 하면 라이브러리는 안 변했으니 도커가 캐시(Cache)를 재사용하고, 내가 수정한 1MB짜리 소스코드 계층만 휙! 하고 갈아 끼워서 빌드 속도를 수십 배 단축시킬 수 있습니다!

#  스프링 공식 문서에서 권장하는 최적화된 Multi-stage 빌드 Dockerfile
FROM openjdk:17-jdk-alpine as builder
WORKDIR /app
COPY build/libs/*.jar app.jar
# .jar 파일을 분해(layertools)하여 레이어별로 쪼갭니다.
RUN java -Djarmode=layertools -jar app.jar extract

FROM openjdk:17-jdk-alpine
WORKDIR /app
# 쪼개진 레이어들을 순서대로 복사합니다. (잘 안 변하는 라이브러리부터 복사하는 것이 캐싱의 핵심!)
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

# .jar 통짜 실행이 아니라, org.springframework.boot.loader.JarLauncher 클래스를 직접 기동시킵니다.
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

3. 마무리 및 주요 발전 방향

지금까지 "내 로컬에선 잘 되는데 서버에선 안 돼요!"라는 변명을 영원히 종식시키고, 무거운 우분투 대신 Alpine 리눅스를 사용하며, Layered 분해를 통해 빌드 속도를 극한으로 깎아내는 완벽한 실무용 Dockerfile 튜닝 비법에 대해 아주 치밀하게 다루어 보았습니다. 이제 여러분이 만든 도커 이미지는 어떤 클라우드(AWS EC2, Kubernetes)에 던져놔도 100% 동일하게 작동합니다.

서비스를 도커로 띄워서 한 달 정도 잘 운영하고 있었는데, 어느 날 새벽에 알람이 미친 듯이 울립니다.
서버의 메모리(RAM) 사용량이 99%를 찍더니 서버가 죽어버렸습니다(OOM, OutOfMemory Error). 서버를 껐다 켜면 다시 돌아오지만, 일주일 뒤에 또 메모리가 차오르며 죽어버립니다. 코드 어딘가에서 메모리가 줄줄 새고 있는(Memory Leak) 것입니다! 눈으로 수만 줄의 코드를 읽으며 범인을 찾는 것은 불가능합니다.

다음 설명에서는 "서버가 죽기 에 대해 아주 심도 있게 살펴보겠습니다.