메랜샵이 출시 한 달 만에 회원 1만명을 돌파했습니다. 정말 기쁜 일이었지만, 동시에 예상치 못한 문제들이 연달아 발생했습니다.
아래 코드, 엔티티명, API 경로, 데이터 값은 모두 임의의 예시이며 실제 서비스와 무관합니다.
가장 큰 문제는 배포였습니다.
기존에는 새로운 기능을 배포할 때마다 1-2분간 서비스가 완전히 중단되었습니다. 사용자들이 거래를 진행하던 중에 갑자기 서버가 멈추는 상황이 발생하니 최대한 사용자들이 적은 새벽 시간대에 배포를 했죠.
더 심각했던 것은 트래픽이 순간적으로 몰릴 때였습니다. 홍보로 인해 특정 시간대에 사용자들이 몰려들면 서버가 버티지 못하고 느려지는 현상이 발생했습니다.
"거래 등록하려고 하는데 계속 로딩만 돌아요"
"새벽에 가끔 서비스가 안 되네요"
실제 사용자들의 피드백이었습니다.
문제의 근본 원인은 단일 EC2 인스턴스에 모든 것을 몰아넣은 구조에 있었습니다.
[단일 EC2 인스턴스]
├── Nginx (React 프론트엔드 서빙, 리버스 프록시)
├── Docker (Spring Boot 백엔드)
└── Docker (MySQL 데이터베이스)
만명이 넘는 회원을 가진 서비스라기에는 명백히 부족한 구조였습니다.
문제를 근본적으로 해결하기 위해 전체 시스템을 재설계하기로 결정했습니다.
[프론트엔드]
S3 + CloudFront (정적 호스팅)[백엔드]
ALB →
[Blue Environment][Green Environment]
├── ASG (Auto Scaling Group)
├── EC2 Instances (n개)
└── Target Group[데이터베이스]
RDS (MySQL)[CI/CD]
GitHub Actions → ECR → CodeDeploy
이제 각 단계별로 실제 구현 과정과 마주했던 문제들을 살펴보겠습니다.
첫 번째로 프론트엔드를 백엔드에서 완전히 분리했습니다.
nginx# EC2 내 nginx.conf
server {
listen 80;
# React 빌드 파일 서빙
location / {
root /var/www/html;
try_files $uri $uri/ /index.html;
}
# API 요청 프록시
location /api {
proxy_pass http://localhost:8080;
}
}
이 구조에서는 프론트엔드 업데이트만 해도 전체 서버를 재배포해야 했습니다.
S3 정적 웹사이트 호스팅 설정:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::mashop/*"
}
]
}
프론트엔드 분리로 이제 백엔드에만 집중할 수 있게 되었습니다.
이제 본격적으로 무중단 배포를 구현해야 했습니다. 이 과정이 전체 프로젝트에서 가장 복잡하고 어려웠던 부분이었습니다.
가장 먼저 AWS 콘솔에서 두 개의 Target Group을 생성했습니다.
Target Group 설정:
- Name: mashop-blue-tg / mashop-green-tg
- Protocol: HTTP
- Port: 8080
- VPC: 기존 VPC 선택
- Health Check Path: /health
핵심 설정:
- Health Check: /health 엔드포인트로 인스턴스 상태 확인
- Healthy Threshold: 2회 연속 성공 시 healthy 판정
- Unhealthy Threshold: 3회 연속 실패 시 unhealthy 판정
ALB에서 EC2로만 트래픽이 흐르도록 보안 그룹을 설정했습니다.
ALB 보안 그룹 (mashop-alb-sg):
Inbound:
- HTTP (80) - 0.0.0.0/0 (전체 인터넷)
- HTTPS (443) - 0.0.0.0/0 (전체 인터넷)
Outbound: All traffic
EC2 보안 그룹 (mashop-ec2-sg):
Inbound:
- HTTP (8080) - Source: mashop-alb-sg (ALB 보안그룹만 허용)
- SSH (22) - My IP (관리용)
Outbound: All traffic
이렇게 설정하면 외부에서 EC2에 직접 접근이 불가능하고, 반드시 ALB를 거쳐야만 백엔드에 접근할 수 있게 됩니다.
# appspec.yml
version: 0.0
os: linux
hooks:
BeforeInstall:
- location: scripts/stop_application.sh
timeout: 120
runas: root
ApplicationStart:
- location: scripts/start_application.sh
timeout: 300
runas: root
ValidateService:
- location: scripts/validate_service.sh
timeout: 50
runas: root
AfterAllowTraffic:
- location: scripts/smoke_alb.sh
timeout: 150
runas: root
가장 복잡했던 IAM 권한 설정.. 😂
CodeDeploy가 ASG와 ALB를 조작할 수 있도록 권한을 설정하는 것이 가장 까다로웠습니다.
배포 과정에서 스모크 테스트가 계속 실패하는 문제가 발생했습니다.
CodeDeploy의 블루-그린 배포 과정:
핵심은 이 모든 과정이 자동으로 진행되며, 문제가 생기면 자동으로 롤백된다는 점이었습니다.
블루-그린 배포를 구현했지만, 여전히 한 가지 불안 요소가 남아있었습니다. 예전에 홍보로 인해 사용자가 순간적으로 몰려서 서버가 느려졌던 경험이 있었기 때문입니다.
"거래 등록하려고 하는데 계속 로딩만 돌아요"
이런 상황이 다시 발생하지 않도록 Auto Scaling Group(ASG)을 통한 자동 확장 시스템을 구축했습니다.
먼저 인스턴스 타입을 기존 t3.micro
에서 t3.small
로 업그레이드했습니다.
더 안정적인 성능을 위한 선택이었죠.
Auto Scaling Group 설정:
- 최소 인스턴스: 1개
- 원하는 용량: 1개
- 최대 인스턴스: 3개
- 인스턴스 타입: t3.small
- 가용 영역: ap-northeast-2a, ap-northeast-2c
가장 중요한 부분은 언제 스케일링을 할 것인가를 결정하는 정책이었습니다.
스케일 아웃 정책 (Scale Out)
조건: CPU 사용률이 70% 이상
기간: 2분간 연속 유지
액션: 인스턴스 1개 추가
쿨다운: 5분
CPU 70%를 기준으로 설정한 이유는 CloudWatch 지표에 몇 분간의 지연이 있기 때문입니다. 80-90%로 설정하면 실제 부하가 임계점에 도달했을 때 이미 늦은 상황이 될 수 있어서, 여유있게 70%로 설정했습니다.
조건: CPU 사용률이 30% 미만
기간: 15분간 연속 유지
액션: 인스턴스 1개 제거
쿨다운: 10분
스케일 인은 좀 더 신중하게 접근했습니다. 트래픽이 일시적으로 줄어들었다가 다시 늘어날 수 있기 때문에, 15분간 안정적으로 낮은 상태를 유지해야만 인스턴스를 제거하도록 설정했습니다.
설정이 올바르게 동작하는지 확인하기 위해 직접 부하 테스트를 진행했습니다.
# EC2 인스턴스에서 부하 테스트
sudo yum install -y stress
stress -c 2 -t 900s # 2개 CPU 코어에 15분간 부하
처음에는 CPU만 모니터링했는데, 실제 운영해보니 다른 지표도 함께 봐야 한다는 것을 깨달았습니다.
추가 모니터링 지표:
- NetworkIn/NetworkOut: API 호출 급증 감지
- TargetResponseTime: 응답 시간 지연 감지
- RequestCountPerTarget: 요청 분산 상태 확인
특히 TargetResponseTime이 중요했습니다. CPU는 여유로워도 데이터베이스 쿼리가 복잡해서 응답이 지연되는 경우가 있었거든요.
물론 완벽하지는 않았습니다.
이미 인스턴스 사양을 올렸기에 다행히 ASG 설정 이후 실제로 스케일링이 필요할 정도의 대규모 트래픽은 아직 경험하지 않았습니다. 하지만 심리적 안정감이 컸죠. 이제는 예상치 못한 상황에 대한 자동 대응 체계가 갖춰진 상태입니다.
회원 수가 1만 명을 넘어서면서 단일 EC2 내의 MySQL Docker 컨테이너로는 한계가 명확했습니다. 관리형 데이터베이스 RDS로 마이그레이션하기로 결정했습니다.
가장 큰 고민은 언제 마이그레이션을 할 것인가였습니다. 메랜샵은 메이플랜드 자리 거래 서비스라 메이플랜드 서버 점검시간을 활용하기로 했습니다.
# 1. 기존 DB 덤프 생성
mysqldump -u root -p ma_db > backup.sql
# 2. RDS 인스턴스 생성 및 설정
# - db.t3.micro (추후 모니터링 후 확장 예정)
# - 다중 AZ 배치로 가용성 확보
# 3. 데이터 복구
mysql -h rds-endpoint -u admin -p ma_db < backup.sql
# 4. 애플리케이션 설정 변경
# application.yml의 datasource URL 변경
메이플랜드 점검시간을 활용했기 때문에 사용자들에게는 거의 영향을 주지 않았습니다.
기존에는 EC2 인스턴스가 새로 생성될 때마다 초기 설정 스크립트를 실행해야 했습니다:
# 기존 user-data.sh
#!/bin/bash
set -euxo pipefail
# 1. 2GB 가상 메모리(Swap) 설정
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
# 재부팅 시에도 활성화되도록 /etc/fstab에 추가
echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab
# 2. Docker 설치 및 설정
yum install -y docker
systemctl start docker
systemctl enable docker
# ec2-user가 sudo 없이 docker 명령어를 사용하도록 권한 설정
usermod -aG docker ec2-user
echo ">>> Docker 설치 및 설정 완료"
# 3. CodeDeploy 에이전트 설치
yum install -y ruby wget
wget https://aws-codedeploy-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/latest/install
chmod +x ./install
./install auto
echo ">>> CodeDeploy 에이전트 설치 완료"
# 4. API 디렉토리 생성
mkdir -p /home/ec2-user/api
chown -R ec2-user:ec2-user /home/ec2-user/api
echo ">>> API 디렉토리 생성 완료"
# ... 기타 설정들
CI/CD 파이프라인의 빌드 시간도 최적화했습니다.
# .github/workflows/deploy.yml (핵심 부분)
# --- 2. Java 및 Gradle 설정 ---
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
# ... 기타 설정들
# --- 4. Docker 이미지 빌드 및 ECR 푸시 (캐싱 적용) ---
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# 캐싱을 위한 빌더를 사용하도록 설정
driver-opts: image=moby/buildkit:v0.14.0
- name: Build, tag, and push image to Amazon ECR with cache
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker buildx build \
--platform linux/arm64 \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
--tag $ECR_REGISTRY/$ECR_REPOSITORY:latest \
--push \
--cache-from type=registry,ref=$ECR_REGISTRY/$ECR_REPOSITORY:cache \
--cache-to type=registry,ref=$ECR_REGISTRY/$ECR_REPOSITORY:cache,mode=max,image-manifest=true,oci-mediatypes=true .
Docker 이미지 레이어를 ECR에 캐시로 저장해두고, 다음 빌드 시 변경되지 않은 레이어는 재사용하여 빌드 속도를 획기적으로 높이는 원리입니다
기존 아키텍처:
[단일 EC2]
├── Nginx (React 서빙)
├── Docker (Spring Boot)
└── Docker (MySQL)
문제점:
- 배포 시 1-2분 서비스 중단
- 트래픽 급증 시 수동 대응
- 단일 장애점 존재
새로운 아키텍처:
[Frontend] S3 + CloudFront
[Backend] ALB → ASG (1~3 인스턴스)
[Database] RDS MySQL
[CI/CD] GitHub Actions → ECR → CodeDeploy
달성한 것:
- 완전 무중단 배포
- 자동 확장/축소 (CPU 70% 기준)
- 장애 격리 및 복구 자동화
- 배포 시간 대폭 단축
항목 | 기존 | 개선 후 |
---|---|---|
배포 다운타임 | 1-2분 | 0초 |
CI/CD 빌드 시간 | 1분 21초 | 31초 |
인스턴스 확장 | 수동 | 자동 (8-9분) |
트래픽 대응 | 단일 서버 | 최대 3배 확장 |
드디어 끝마친 이번 아키텍처 개선을 통해 10만 명 그 이상까지도 안정적으로 서비스할 수 있는 기반을 마련했습니다.
단일 EC2 인스턴스 비용에서 ALB, RDS, 데이터 전송 비용 등이 추가되었지만, 이는 유연한 운영과 안정성을 위한 투자라고 생각했습니다. 또한 S3의 저렴한 비용 덕분에 트래픽 대비 비용 효율은 오히려 개선되었습니다.
무엇보다 이번 개선을 통해 사용자들은 더 이상 예기치 못한 서비스 중단 없이 안정적으로 서비스를 이용할 수 있어 뿌듯했습니다. (동시에, 새벽 배포로 인한 스트레스가 사라진 것이 가장 큰 성과라고 생각합니다. 😊)