
CI/CD 구성 이후 애플리케이션 배포 구조를 운영 관점에서 다시 한번 정리했다.
다음과 같은 목적으로 작업을 진행하려고 했다.
- 외부 접근 경로를 단순하게 만들고
- 배포 흐름을 더 일관되게 정리하고
- dev/prod를 명확하게 분리하는 것
이번 글에서는 Nginx 리버스 프록시 도입부터 ECR 기반 배포 전환까지를 단계별로 정리할 예정이다.
경로 기반 라우팅이 필요해진 이유
초기에는 dev/prod 컨테이너를 포트로 분리해서 접근했다. 검증 단계에서는 빠르고 단순했지만, 운영 관점에서는 불편했다.
- 어떤 포트가 어떤 환경인지 계속 기억해야 하고
- 외부에 앱 포트를 직접 열어두는 구조가 되며
- 라우팅 규칙이 애플리케이션 밖으로 분산된다
그래서 외부 진입점은 80 포트 하나로 두고, 내부 라우팅은 Nginx가 맡는 구조로 바꿨다.
Nginx를 붙인 이유
Nginx는 웹 서버이면서 리버스 프록시 역할을 할 수 있는 도구다. 여기서 리버스 프록시란, 사용자의 요청을 먼저 받고 내부 서비스로 전달해주는 앞단 게이트웨이 역할에 가깝다.
이번 구조에서 Nginx를 둔 이유는 명확했다.
- 외부 노출 포트를 80 하나로 통일할 수 있고
- /는 prod, /dev/는 dev처럼 경로 기반 분기가 쉬우며
- 앱 컨테이너를 외부에서 직접 접근하지 않도록 감쌀 수 있다
Nginx를 포함한 컨테이너 실행 구조
먼저 compose에서 dev/prod 앱과 nginx를 함께 올리는 구조를 만들었다.
services:
app-dev:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: moongchijang-dev
env_file:
- ../.env.dev
restart: unless-stopped
app-prod:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: moongchijang-prod
env_file:
- ../.env.prod
restart: unless-stopped
nginx:
image: nginx:1.29-alpine
container_name: moongchijang-nginx
depends_on:
- app-dev
- app-prod
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
restart: unless-stopped
외부 요청은 nginx로만 받는다는 원칙으로 구성했다.
depends_on은 컨테이너 시작 순서만 보장하고 애플리케이션 준비 완료(readiness)까지 보장하지는 않기 때문에, 운영 환경에서는 healthcheck나 재시도 전략도 함께 고려해야 한다.
Nginx 라우팅에서 실제로 신경 쓴 부분
경로 기반 라우팅은 단순해 보여도 /dev와 /dev/ 처리 차이, 프록시 헤더 전달이 꽤 중요했다.
events {}
http {
upstream app_dev {
server app-dev:8080;
}
upstream app_prod {
server app-prod:8080;
}
server {
listen 80;
server_name _;
# dev 경로 슬래시 정규화
location = /dev {
return 301 $scheme://$http_host$uri/$is_args$args;
}
# dev 경로 요청을 dev 컨테이너로 전달
location /dev/ {
proxy_pass http://app_dev/;
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;
proxy_set_header X-Forwarded-Prefix /dev;
}
# 기본 경로 요청을 prod 컨테이너로 전달
location / {
proxy_pass http://app_prod/;
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;
}
}
}
적용 포인트는 두 가지였다.
- /dev 접속 시 /dev/로 리다이렉트
- X-Forwarded-Prefix 전달로 프록시 뒤 경로 인식 보완
보안 그룹도 라우팅 구조에 맞춰 변경
구조를 바꿨으면 네트워크 정책도 같이 맞춰야 했다. 그래서 HTTP 80 포트를 열고, 앱 포트 직접 노출은 정리했다.
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
외부 접근 경로가 한 곳으로 모이면서 운영 포인트도 단순해졌다.
ECR을 도입한 이유
Nginx로 트래픽 경로를 정리한 뒤, 다음은 배포 단위를 정리할 차례였다.
여기서 ECR(Elastic Container Registry)은 Docker 이미지를 저장하는 AWS 레지스트리다.
ECR 기반으로 바꾸면 장점이 분명하다.
- 서버에서 매번 빌드하지 않고 이미지를 pull해서 실행 가능
- CI에서 빌드한 결과물을 그대로 배포해 환경 차이를 줄임
- 태그(develop, main)로 배포 대상을 명확히 관리 가능
즉 배포 파이프라인에서 CI는 build/push를 담당하고, EC2는 pull/run을 담당하는 구조로 역할이 분리된다.
compose를 이미지 기반으로 전환
services:
app-dev:
# develop 태그 이미지를 사용하는 dev 애플리케이션
profiles: ["dev"]
image: ${ECR_REPOSITORY_URL}:develop
container_name: moongchijang-dev
env_file:
- ../.env.dev
restart: unless-stopped
app-prod:
# main 태그 이미지를 사용하는 prod 애플리케이션
profiles: ["prod"]
image: ${ECR_REPOSITORY_URL}:main
container_name: moongchijang-prod
env_file:
- ../.env.prod
restart: unless-stopped
nginx:
# 공용 리버스 프록시. prod는 /, dev는 /dev/ 경로로 라우팅
image: nginx:1.29-alpine
container_name: moongchijang-nginx
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
restart: unless-stopped
기존에도 서비스 단위(app-dev/app-prod) 실행은 가능했지만, profiles를 도입하면서 dev/prod 실행 의도를 Compose 설정 자체에 명시적으로 표현할 수 있게 됐다.
인프라 측면에서 함께 추가한 것
이미지 배포로 전환하면서 ECR 리포지토리, lifecycle policy, EC2 pull 권한도 같이 맞췄다.
resource "aws_ecr_repository" "app" {
name = "${var.project_name}-be"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_lifecycle_policy" "app" {
repository = aws_ecr_repository.app.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Keep last 10 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
action = {
type = "expire"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "ec2_ecr_readonly" {
role = aws_iam_role.ec2_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
이미지 정리 정책까지 넣어두면, 시간이 지나도 운영 비용과 저장소 관리가 훨씬 안정적이다.
워크플로우 실행 방식
배포 워크플로우는 환경별로 분리하고, 실행 서비스도 profile 기준으로 제한했다.
# dev
docker compose --profile dev pull app-dev
docker compose --profile dev up -d nginx app-dev
# prod
docker compose --profile prod pull app-prod
docker compose --profile prod up -d nginx app-prod
이 방식의 장점은 배포 동작이 추측이 아니라 규칙으로 남는다는 점이다.
어떤 브랜치에서 만든 이미지를 어떤 태그로 가져와 어떤 서비스만 갱신했는지가 실행 명령 자체에 드러나기 때문에, 장애 대응이나 배포 이력 확인 시에도 흐름을 훨씬 빠르게 따라갈 수 있다.
마무리
이번 작업은 기능 추가보다는 운영 방식 정리에 가까웠다.
Nginx로 외부 접근 경로를 단순화하고, ECR로 배포 단위를 이미지 중심으로 바꾸면서 dev/prod 운영 흐름을 훨씬 명확하게 가져갈 수 있었다.
'Backend' 카테고리의 다른 글
| 배포 시간 14분에서 53초로: 빌드 구조 개선과 ARM64 정합성 해결 (0) | 2026.05.31 |
|---|---|
| 서버 접속 배포에서 GitHub Actions 자동 배포까지, CI/CD 구성하기 (0) | 2026.05.02 |
| AWS 인프라, 콘솔 대신 Terraform으로 관리해보자 (0) | 2026.04.16 |
| 콤마 하나 때문에 구글 로그인이 실패했던 이유 (3) | 2026.03.14 |