
난이도 ⭐️
작성 날짜 2025.07.13
마감 날짜가 정해져 있어 MVP를 빠르게 개발해야 했기 때문에,
백엔드에서는 기능이 개발되는 대로 바로바로 서버에 배포하였다.
이 배포 과정에서 잠깐의 중단이 발생하는데,
프론트 팀원들에게 미리 공지하지 않으면 갑작스러운 에러 발생으로 당황해할 수 있다.
CI/CD 파이프라인으로 배포하고 있어서 Deploy Step이 언제 진행될지 몰라 미리 서버의 중단을 10분 정도로 공지하고, 배포하고 있었다.

프론트 개발자님과의 슬랙 내용 (새벽 1시 51분)
새벽에도 열심히 개발하는 우리 팀원들... 덕분에 새벽 배포도 어렵다ㅠ
문제는 Deploy나 Dockerfile을 잘못 만지거나 의존성에서 문제가 생기거나 하는 경우에는 기존 서버가 stop & rm된 상태로 새로운 서버가 올라가지 않는 경우도 있다.
이러면 기존에 10분으로 공지했던 내용을 20분, 30분... 이상으로 연기시켜야 했던 아찔한 경우가 있었다.
나아가 개발 마감 기한이 가까워져서 PM님과 디자이너님도 함께 QA에 들어가게 되면 팀 전체가 서버의 배포만을 기다리게 되는 끔찍한 경우가 생길지도 모른다..!
🤔 일이 끔찍해지기 전에 막아보자
이왕 이렇게 된거 무중단 배포의 개념을 찾아 정리해보았다.

개념
전체 서버(또는 컨테이너)를 한 번에 교체하지 않고, 일부 인스턴스부터 새로운 버전을 점진적으로 교체하는 방식.
트래픽은 점차 새로운 버전으로 분산되며, 모든 인스턴스가 교체되면 배포가 완료됨.
장점
단점

개념
Blue(현재 운영 중인 버전)과 Green(새 버전) 두 개의 동일한 환경을 준비해두고, 트래픽을 한 번에 Blue → Green으로 전환하는 방식.
문제가 생기면 다시 Blue로 트래픽을 전환하여 빠르게 롤백 가능.
장점
단점

개념
새로운 버전을 일부 사용자(또는 트래픽)에게만 먼저 배포하고, 이상이 없으면 점차 확대하는 방식.
"카나리 새"에서 유래: 광산에 카나리를 먼저 들여보내 위험 여부를 확인했던 것처럼, 새로운 버전을 일부에 먼저 시험 적용.
롤링 배포와 다른 점은 카나리는 검증을 위한 배포 방식이라는 점이다.
일부 사용자에게만 고정적으로 신버전을 제공하여 점차 신버전 사용자의 비율을 늘려가는 방식이다.
장점
단점
상황에 따라 다르겠지만, 사실 내 경우에는 어떠한 전략도 선택할 수 없다.
개념을 엄밀하게 따지자면, 무중단 배포는 독립된 두 물리적인 자원 (인스턴스)가 필요하다.
우리 팀은 운영 비용을 최소화하기 위해 서버를 프리티어 인스턴스 위에서 돌리고 있다.
그리고 AWS는 EC2 인스턴스 한 대까지만 프리티어를 지원하기 때문에, 새로운 인스턴스를 생성하고 운용하는 것은 추가적인 비용에 대한 부담이 있었다.
그래서 위에서 언급한 배포 전략은 모두 적용하기 어렵다.
그러나 배포 전략의 아이디어만은 가져올 수 있다.
하나의 인스턴스에서 여러 개의 도커 컨테이너를 띄우면, 버전에 따른 논리적인 서버 분리가 이루어지는 것은 아닐까?
카나리 배포는 트래픽 분산을 위한 기술이 필요하며, 단순히 개발 과정에서의 편의를 위해 도입하기에는 과하다고 판단했다.
롤링 배포도 매력적이지만, 프리티어 인스턴스에서 메모리 관리를 위해 컨테이너 수를 최소화 할 수 있는 블루-그린 배포가 적절하다고 생각했다.
블루-그린 배포의 개념을 활용하면 단 두 개의 실행 중인 서버로도 무중단 배포를 할 수 있기 때문이다!
우선 기존의 인스턴스에 새로운 도커 컨테이너를 띄워보자.
기존 스프링 컨테이너는 8080으로, 새로운 컨테이너는 8081에 띄워보자.
이 과정에서 살짝 고생했던 경험을 공유하자면,
현재 서버가 docker-compose를 이용해 스프링 서버의 컨테이너와 Redis 컨테이너를 같이 띄우고 있다.
그럼 새로운 컨테이너는 같은 compose로 실행된 컨테이너가 아니기 때문에 직접 Redis 컨테이너에 접근할 수 없다.
그럼 docker-compose를 사용하지 말고 레디스를 따로 컨테이너 띄우면 되지 않나?
라고 생각했지만 CI/CD의 코드를 통해 레디스의 설정을 관리할 수 있는 장점을 포기하고 싶지 않아서 패스.
그래서 우선 인스턴스에 접속해서 도커 네트워크를 생성해주었다.
docker network create network-name
그리고 docker-compose에서 컨테이너를 띄울 때
networks:
- network-name
...
networks:
network-name:
external: true
이런 식으로 설정하여 compose 외부의 네트워크 위에서 컨테이너가 돌아가게 하였다.
새롭게 띄우는 컨테이너도 해당 네트워크를 이용하도록 설정하면 끝!
물리적으로 인스턴스를 분리하는 경우에는 AWS ELB와 같은 로드밸런서를 활용한다.
왜냐하면 사용자의 요청을 어느 인스턴스의 서버로 보낼지 결정해주는 가이드가 필요하기 때문이다.
나의 경우, 하나의 인스턴스에서 이를 실현하기 위해 nginx를 사용하였다.

server {
listen 80;
server_name api.server.name.kr;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name api.server.name.kr;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 이하 내용 생략
}
여기서 http://localhost:8080 부분을 8081로 바꾸거나 반대로 바꾸어주면,
api.server.name.kr로 요청이 들어왔을 때 새롭게 띄운 컨테어너로 연결이 가능하다.
스크립트를 이용해 배포 시 자동으로 nginx 설정까지 바꿔주는 방법도 있긴 하지만, 어차피 롤백과 같은 상황에서 관리하려면 nginx 설정을 직접 건드릴 필요가 있고 수정할 부분도 조금이라 해당 방법을 사용하지는 않았다.
sudo nginx -t
sudo systemctl reload nginx
이제 이 명령으로 nginx를 업데이트~!
한 가지 궁금했던 점은
- 현재 서버 도메인은 블루 컨테이너로 연결되어 있음
- 사용자가 도메인으로 API 요청을 보냄
- 요청이 완전히 처리되기 전 nginx의 설정을 변경하여 도메인의 연결을 그린 컨테이너로 변경
- 블루 컨테이너의 API 요청이 처리
이 경우에 사용자는 API 요청에 대한 response를 받을 수 있을까?
정답은 Yes이다.
nginx는 설정을 reload하더라도 기존 연결을 끊지 않고 유지한다. (restart와 다르다!)
reload 명령 이후에 새롭게 들어온 요청만 새로운 설정 값으로 처리한다고 한다.
이 방식을 적용하여 테스트 하던 중,
Redis Stream을 사용하는 로직을 가진 API에 요청을 보내면, SSE 응답이 오지 않는 문제를 발견했다.
문제의 발생 순서는 다음과 같다.
1. 클라이언트가 SSE 연결 API 요청
2. SSE Emitter는 블루 컨테이너 서버의 HashMap에 저장
3. 클라이언트가 Redis Stream을 이용하는 API 요청
4. 블루 컨테이너가 Redis Stream에 메시지를 publish
5. 그린 컨테이너의 Consumer가 이를 소비
6. 그린 컨테이너에는 해당 클라이언트의 SSE Emitter가 존재하지 않으니 오류 처리
해결
문제는 동일한 서버에서 생성된 메시지를 동일한 서버에서 처리해야 하는데 그렇지 않아서 발생한다.
기존에 하드코딩 해두었던 Stream Key와 Consumer Group을
yml을 통해 환경 변수로 주입받도록 변경한다.
data:
redis:
port: 6379
host: redis
stream-key: ${REDIS_STREAM_KEY}
consumer-group: ${REDIS_CONSUMER_GROUP}
repositories:
enabled: false
이 부분은 docker-compose(deploy.yml)에서 설정할 수 있으며, 블루 컨테이너와 그린 컨테이너 각각 다른 값을 넣어준다.
environment:
- TZ=Asia/Seoul
- REDIS_STREAM_KEY=${{ secrets.REDIS_STREAM_KEY }}
- REDIS_CONSUMER_GROUP=${{ secrets.REDIS_CONSUMER_GROUP }}
Producer(publisher)의 코드는 다음과 같다.
RecordId id = ops.add(MapRecord.create(streamKey, map));
주입받은 streamKey를 payload와 함께 발행한다.
그리고 컨슈머의 코드를 보면
redisTemplate.opsForStream().createGroup(streamKey, ReadOffset.from("0"), consumerGroup);
해당 stream key에 해당하는 메시지만 Consume하도록 설정되어 있기 때문에 문제를 해결할 수 있다.
이와 별개로 SSE Emitter가 유실되는 문제는... 현재의 구조에서 완벽하게 해결하긴 어려운 것으로 결정지었다.
SSE Emitter의 타임아웃을 1분 정도로 짧게 지정해서, 재연결을 요청하는 경우나 새롭게 연결을 요청하는 경우 새로운 서버에서 Emitter를 생성할 수 있다.
1분 정도의 유실이 생기는 부분은 아쉽지만 구조 확장이 없이는 다른 방법이 크게 떠오르지 않는 것 같아서 마무리..!
이를 통해 다음과 같은 결과를 얻을 수 있었다.

런칭 이후인 지금은 그린 컨테이너를 테스트 서버로 사용하고 있다.
Redis 메시지는 Key와 Consumer를 분리해두어 간섭이 없고, DB도 분리해두었기 때문에 Prod 서버와는 문제 없이 병행 운용할 수 있다.
그리고 테스트가 완료되면 그린 컨테이너(테스트 서버)의 설정을 바꾼 후 nginx의 포트를 교차시키는 방식으로 무중단 배포를 활용하고 있다.
결론
더 나은 방법도 찾아보면 있을지도 모르겠지만, 비용을 최소화하면서 원하는 결과를 얻을 수 있어서 보람찼다!!