왜 무중단 배포를 구현했나

기존에는 배포 시 서버를 재시작하는 중단 배포 방식으로 배포를 진행했다.
이 방식은 구현이 간단하지만 배포 시 서비스 중단 발생, 이로 인한 사용자 경험 저하와 같은 문제가 발생한다.
특히 현재 개발하고 있는 실시간 채팅 서비스의 경우 연결이 끊기는 것이 치명적이었다.
내가 사용자라고 생각해봤을 때 배포할때마다 서비스가 중단된다면 다른 서비스를 찾아볼 것 같다 생각했다. 그래서 '다운타임 없이 배포하는 무중단 배포'를 우선적으로 구현해보기로 했다.
로드밸런싱을 적용한 이유
메시지 브로커 기반 아키텍처에서는 다수의 서버 인스턴스가 메시지를 처리할 때, 각 인스턴스가 메시지를 경쟁적으로 소비할지(경쟁 소비 모델), 또는 메시지를 분산 처리할지(팬아웃 모델)에 대한 설계가 중요하다. 하지만 현재의 단일 서버 환경에서는 이러한 메시지 처리 전략을 실험하거나 확장성 있는 구조로 발전시키는 데 한계가 있었기 때문에, 서버를 수평 확장하고 로드밸런서를 도입하여 트래픽을 분산시키는 구조로 개선하였다. 이를 통해 메시지 처리의 확장성과 안정성을 확보하고자 했다.
또한 뒤에 나오겠지만 롤링 배포 방식을 위해서는 최소 2개 이상의 서버가 필요하기 때문에 이를 위함도 있다.
향후 부하 테스트를 통해 수평 확장이 실제로 처리량과 응답 시간에 어떤 영향을 미치는지 검증하고, 시스템의 성능 개선 효과를 정량적으로 확인하고자 한다.
목표
이번 배포 구조의 목표는 다음과 같다.
- Docker Compose 환경에서 구현 가능할 것
- Jenkins로 자동화할 수 있을 것
- 자동 롤백 기능
쿠버네티스 없이도 구현 가능한 현실적인 무중단 배포를 지향했다.
그 이유는 점진적인 확장을 경험해보고 싶었기 때문이다.
쿠버네티스는 서버가 도커 컴포즈로 관리하기 어려울 정도로 확장되었을 때 사용하는 도구다.
따라서 현재 프로젝트 규모에서는 불필요하다고 판단했다.
그렇다면 어떤 전략을 사용할까? 3가지 무중단 배포 전략이 있다.
Rolling 배포
트래픽을 점진적으로 구버전에서 신버전으로 옮기는 과정이다. 여기엔 2가지 방식이 있다.
surge 방식

급증, 밀려듬을 뜻하는 단어로 배포 시 순간적으로 서버가 하나 추가된다. 인스턴스를 하나 추가하고 새로운 버전을 실행한 뒤 로드밸런서에서 라우팅한 뒤, 구버전 인스턴스 하나를 라우팅하지 않도록 연결을 끊는다. 이를 반복하여 점진적으로 모든 인스턴스에 새 버전의 애플리케이션을 배포한다.
non-surge 방식

서비스 중인 인스턴스 하나를 로드밸런서에서 라우팅하지 않도록 한 뒤, 새 버전을 적용하여 다시 라우팅하도록 한다. 이를 반복하여 모든 인스턴스에 새 버전의 애플리케이션을 배포한다.
이 둘의 차이점은 배포 중 실행되는 인스턴스 수의 차이에 있다. surge 방식의 경우 실행되는 서버의 수 n개 + 1개가 실행되고, non-surge 방식의 경우 실행되는 서버 n개가 실행된다. surge 방식은 서버 하나만큼의 리소스가 추가로 필요하지만 트래픽 분산을 유지할 수 있다는 특징이 있고, non-surge 방식은 배포 중 필요 리소스는 그대로지만 트래픽이 나머지 실행중인 서버에 몰린다는 특징이 있다.
장점
롤링 배포 방식은 k8s, elastic beanstalk과 같은 많은 오케스트레이션 도구에서 지원하여 간편하다고 한다. 또한, 많은 서버자원을 확보하지 않아도 무중단 배포가 가능하다. 인스턴스마다 차례로 배포를 진행하기 때문에 상황에 따라 손쉽게 롤백이 가능한 장점이 있다.
단점
구버전과 새로운 버전의 애플리케이션이 동시에 서비스되기 때문에 호환성 문제가 발생할 수 있다.
Canary 배포

과거 광부들이 가스 누출을 탐지하기 위해 가스에 민감한 카나리아 새를 이용했던 것에서 유래된 이름으로 소수인원들에게 새로운 버전을 배포하고 오류가 발생하는지 테스트해보는 배포 방식이다.
일부에게만 새로운 버전을 배포하고 새로운 방식에 문제가 없다고 판단되었을 때 모든 트래픽을 신규 버전으로 옮긴다.
장점
새로운 버전에 대한 위험을 조기에 잡아낼 수 있다.
단점
롤링 배포와 마찬가지로 구버전과 신버전 간의 호환성 문제가 발생할 수 있다.
Blue/Green 배포

트래픽을 한번에 구버전에서 신버전으로 옮기는 방법이다. Blue/Green 배포 전략에서는 현재 운영중인 환경을 Blue라 부르고, 새롭게 배포할 환경을 Green이라 부른다. Blue가 실행되고 있는 상태에서 Green을 실행시킨 뒤 배포 시점에 로드밸런서가 트래픽을 Blue에서 Green으로 일제히 전환시킨다. Green 버전 배포가 성공적이라면 Blue 버전은 제거하거나 롤백을 위해 남겨둔다.
장점
구버전과 신버전이 동시에 서비스되는 호환성 문제가 발생하지 않는다.
단점
실제 운영에 필요한 서버 리소스 대비 2배의 리소스가 요구된다. 필요한 리소스를 유동적으로 조정할 수 있는 클라우드 환경에서 유리한 방식이다.
Rolling 배포를 선택한 이유
서버 환경이 미니pc다보니 리소스가 유동적이지 못하고 고정된 리소스를 사용하다보니 blue/green 처럼 많은 리소스가 필요한 배포 방식은 어렵다고 판단했다. 그에 따라 구버전과 신버전이 공존하는 호환성 문제는 안고가야할 숙제지만 현실적인 환경 문제를 더 고려해서 이렇게 선택하게 되었다. 그리고 surge 방식과 non-surge 방식 중에는 surge 방식을 선택했다. 서버를 확장한다고 가정하면 서버 하나 늘어나는 것은 큰 문제가 되지 않는다고 생각했고 트래픽을 고려해서 서버를 확장한 것일 텐데 서버를 줄이면서 나머지 서버가 트래픽을 부담하게 되는 것을 피하고 싶었기 때문이다.
전체 구조
구성은 크게 3개로 나뉜다.
Infra
여기엔 서버 외 DB 같은 항상 유지해야하는 컨테이너들을 담는다. 도커 컴포즈로 컨테이너를 묶어뒀고, docker-compose.infra.yml로 이름 지었다.
Rolling 슬롯
백엔드 서버, 프론트엔드 서버 여러개를 띄우는 역할을 담당한다. docker-compose.rolling.yml로 이름 지었다.
Router
로드밸런싱과 라우팅을 담당한다. docker-compose.router.yml로 이름 지었다.
Nginx를 이용한 트래픽 라우팅
http {
// ...
resolver 127.0.0.11 ipv6=off valid=10s;
# upstream은 http 컨텍스트에서만 허용됨
include /etc/nginx/conf.d/upstream.conf;
include /etc/nginx/conf.d/default.conf;
}
Nginx.conf
resolver 127.0.0.11: Docker embedded DNS를 써서 컨테이너 이름을 해석한다.
Nginx 메인 설정에서 upstream.conf와 default.conf를 include한다.
upstream funchat_web_upstream {
zone funchat_web_upstream 64k;
include /etc/nginx/conf.d/upstream.web.servers.conf;
keepalive 32;
}
upstream funchat_app_upstream {
zone funchat_app_upstream 64k;
include /etc/nginx/conf.d/upstream.app.servers.conf;
keepalive 32;
}
upstream.conf
upstream funchat_web_upstream: 로드밸런싱 대상 서버 그룹 이름 지정
- proxy_pass http://funchat_web_upstream 이런식으로 사용
zone funchat_web_upstream 64k: upstream 상태를 공유 메모리에 저장하여 여러 worker 프로세스가 상태를 공유한다.
- 헬스 상태, 연결 상태, 로드밸런싱 정보를 멀티프로세스 환경에서 일관성을 유지하기 위해 공유한다.
upstream.app.servers.conf, upstream.web.servers.conf 는 배포 스크립트가 생성/갱신한다.
location = /health {
// ...
proxy_pass http://funchat_app_upstream;
}
location /api/ {
// ...
proxy_pass http://funchat_app_upstream;
}
location /ws {
// ...
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://funchat_app_upstream;
}
location / {
// ...
proxy_pass http://funchat_web_upstream;
}
default.conf
경로별로 upstream을 선택한다.
/health, /api/, /ws 는 백엔드 upstream
/ 는 프론트 upstream
배포 스크립트 분석
docker_login_with_credentials
mkdir -p nginx
# 1) infra는 항상 유지한다. 최초 실행 시 네트워크와 데이터 저장소를 만든다.
docker compose --env-file "$ENV_FILE" -f docker-compose.infra.yml up -d
# 2) 새 이미지 pull
rolling_compose pull
# 3) 롤링 슬롯이 없거나 부족하면 먼저 현재 이미지로 전체 슬롯을 준비한다.
for slot in $(slot_range); do
ensure_slot_started "$slot"
done
# 4) 라우터는 재생성하지 않고 유지한다. 없으면 최초 1회만 기동한다.
write_upstream_servers "" ""
router_compose up -d
test_nginx_config
reload_nginx
run_smoke_tests
stop_unused_slots
echo "Surge rolling deploy uses steady replicas=${APP_REPLICAS}, surge slot=${SURGE_SLOT}."
# 5) backend app을 한 슬롯씩 교체한다.
APP_SURGE_STARTED=0
CURRENT_APP_SLOT=""
trap 'cleanup_app_surge; cleanup_deploy_runtime' EXIT
start_app_surge
APP_SURGE_STARTED=1
for slot in $(slot_range); do
echo "Rolling update backend app slot ${slot}/${APP_REPLICAS}..."
CURRENT_APP_SLOT="$slot"
update_app_slot "$slot"
CURRENT_APP_SLOT=""
done
stop_app_surge
APP_SURGE_STARTED=0
trap cleanup_deploy_runtime EXIT
# 6) frontend web을 한 슬롯씩 교체한다.
WEB_SURGE_STARTED=0
CURRENT_WEB_SLOT=""
trap 'cleanup_web_surge; cleanup_deploy_runtime' EXIT
start_web_surge
WEB_SURGE_STARTED=1
for slot in $(slot_range); do
echo "Rolling update frontend web slot ${slot}/${APP_REPLICAS}..."
CURRENT_WEB_SLOT="$slot"
update_web_slot "$slot"
CURRENT_WEB_SLOT=""
done
stop_web_surge
WEB_SURGE_STARTED=0
trap cleanup_deploy_runtime EXIT
docker_logout_if_needed
docker image prune -f
deploy.sh
1. Docker hub에 로그인
2. 최초 실행 시 Infra 생성(항상 유지)
3. rolling 배포를 위해 새로운 이미지 pull
4. 롤링 슬롯이 없거나 부족하면 슬롯을 채워준다.
5. 라우터도 최초 실행 시 생성하고 유지한다.
6. 백엔드, 프론트엔드 애플리케이션을 한 슬롯씩 교체한다. (surge 슬롯 방식)
7. 도커 허브 로그아웃
8. 사용하지 않는 이미지 삭제
슬롯 교체 과정은 surge 기동 → (upstream 투입 → 기존 슬롯 제외/재생성 → upstream 원복) 반복 → surge 정리 순으로 동작한다.
백엔드(app) 교체 과정 핵심 함수들만 살펴보자.
update_app_slot 함수
update_app_slot() {
local slot="$1"
local surge_slot="$SURGE_SLOT"
apply_upstream "$slot" "" "$surge_slot" ""
rolling_compose stop "app-${slot}" >/dev/null 2>&1 || true
rolling_compose up -d --no-deps --force-recreate "app-${slot}"
wait_app_health "$slot"
apply_upstream "" "" "$surge_slot" ""
run_smoke_tests
}
1. 기존 슬롯을 로드밸런싱 대상에서 제외하고 surge 슬롯은 upstream에 포함된 상태를 유지한다.
- upstream이란 로드밸런서가 트래픽을 분산해서 보내는 대상 서버 풀을 의미한다.
2. 기존 슬롯 컨테이너를 종료한다.
3. 기존 슬롯을 재생성한다.
4. 도커 컨테이너 헬스체크
5. 재생성된 기존 슬롯을 다시 upstream에 포함시키고, 가용성을 위해 surge 슬롯을 그대로 포함시킨다.
6. nginx를 통해 요청이 응답까지 성공적으로 동작하는지 스모크테스트한다. (스모크테스트는 GET /health으로 구현하여 서버가 정상적으로 동작한다면 200 OK를 반환한다)
- 여기서 도커 컨테이너 헬스체크와 스모크 테스트로 분리해서 체크한 이유는 슬롯 생성 시에는 슬롯에 대해서만 동작 검증하면 되지만, 슬롯을 로드밸런서에 라우팅하게되면 변경 영역이 슬롯뿐만 아니라 nginx 등 배포영역으로 확장되기 때문에 배포 경로 전체를 동작 검증 가능한 스모크 테스트를 사용했다.
apply_upstream 함수
1,2번째 파라미터는 로드밸런서에서 제외할 슬롯, 3,4번째 파라미터는 로드밸런서에 포함시킬 슬롯이다.
apply_upstream() {
local exclude_app_slot="${1:-}"
local exclude_web_slot="${2:-}"
local include_app_surge_slot="${3:-}"
local include_web_surge_slot="${4:-}"
write_upstream_servers "$exclude_app_slot" "$exclude_web_slot" "$include_app_surge_slot" "$include_web_surge_slot"
test_nginx_config
reload_nginx
}
write_upstream_servers 함수
교체 대상 슬롯을 upstream에서 제외하고, 교체가 끝나면 전체 슬롯을 다시 upstream에 포함시킨다.
write_upstream_servers() {
local exclude_app_slot="${1:-}"
local exclude_web_slot="${2:-}"
local include_app_surge_slot="${3:-}"
local include_web_surge_slot="${4:-}"
local slot
local app_count=0
local web_count=0
{
for slot in $(slot_range); do
if [[ "$slot" == "$exclude_web_slot" ]]; then
continue
fi
echo "server funchat-web-${slot}:80 resolve;"
web_count=$((web_count + 1))
done
if [[ -n "$include_web_surge_slot" ]]; then
echo "server funchat-web-${include_web_surge_slot}:80 resolve;"
web_count=$((web_count + 1))
fi
} > nginx/upstream.web.servers.conf.tmp
{
for slot in $(slot_range); do
if [[ "$slot" == "$exclude_app_slot" ]]; then
continue
fi
echo "server funchat-app-${slot}:8081 resolve;"
app_count=$((app_count + 1))
done
if [[ -n "$include_app_surge_slot" ]]; then
echo "server funchat-app-${include_app_surge_slot}:8081 resolve;"
app_count=$((app_count + 1))
fi
} > nginx/upstream.app.servers.conf.tmp
if [[ "$app_count" -lt 1 || "$web_count" -lt 1 ]]; then
rm -f nginx/upstream.web.servers.conf.tmp nginx/upstream.app.servers.conf.tmp
echo "Nginx upstream에는 app/web 서버가 각각 1개 이상 필요합니다."
return 1
fi
cat nginx/upstream.web.servers.conf.tmp > nginx/upstream.web.servers.conf
cat nginx/upstream.app.servers.conf.tmp > nginx/upstream.app.servers.conf
rm -f nginx/upstream.web.servers.conf.tmp nginx/upstream.app.servers.conf.tmp
}
프론트(web) 교체 차이점
대부분 백엔드 교체 과정과 같으나 정적 컨텐츠 제공이 주 역할이라 running 여부로 헬스체크를 한다.
스모크 테스트 백엔드 코드
HealthController
@GetMapping("/health")
public ResponseEntity<ResponseDto> health() {
HealthCheckResponse response = healthService.check();
HttpStatus status = response.healthy() ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE;
String message = response.healthy() ? "성공" : "서비스 준비 상태가 아닙니다.";
return ResponseEntity.status(status)
.body(new ResponseDto(status.value(), message, response));
}
HealthService
public HealthCheckResponse check() {
Map<String, String> checks = new LinkedHashMap<>();
checks.put("database", checkDatabase());
checks.put("redis", checkRedis());
checks.put("mongodb", checkMongo());
boolean healthy = checks.values().stream().allMatch(UP::equals);
return new HealthCheckResponse(healthy ? UP : DOWN, checks);
}
private String checkDatabase() {
try (Connection connection = dataSource.getConnection()) {
return connection.isValid(1) ? UP : DOWN;
} catch (Exception e) {
log.warn("Database health check failed: {}", e.getMessage());
return DOWN;
}
}
private String checkRedis() {
try (RedisConnection connection = redisConnectionFactory.getConnection()) {
String response = connection.ping();
return "PONG".equalsIgnoreCase(response) ? UP : DOWN;
} catch (Exception e) {
log.warn("Redis health check failed: {}", e.getMessage());
return DOWN;
}
}
private String checkMongo() {
try {
mongoTemplate.executeCommand(new Document("ping", 1));
return UP;
} catch (Exception e) {
log.warn("MongoDB health check failed: {}", e.getMessage());
return DOWN;
}
}
스모크 테스트는 소프트웨어의 핵심 기능이 정상적으로 작동하는지 빠르게 확인하는 예비 테스트다.
요청이 정상적으로 서버에 도착하는지, 각 데이터베이스와의 연결이 정상적으로 이루어졌는지, 응답이 정상적으로 라우터를 통해 클라이언트에게 도착하는지 확인한다.
'Infra' 카테고리의 다른 글
| 배포 환경 변수 주입은 어떻게 풀어낼까? (0) | 2026.04.30 |
|---|---|
| SonarQube + Jenkins + GitHub CI/CD 연동기 (0) | 2026.02.25 |
| Jenkins와 Docker Compose를 이용한 Spring Boot CI/CD 구축기(feat. 홈서버) (0) | 2026.01.29 |
| N8N 홈서버 구축 회고(2) - 도커 설정 및 트러블 슈팅 (0) | 2026.01.27 |