V2 ECS 기반 전체 아키텍처 설계 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki

V2 ECS 기반 전체 아키텍처 설계

  • V2는 V1의 Prod·Dev EC2 2대 + Docker Compose 구조를 ECS on EC2 다중 클러스터 구조로 전환한다. 클러스터는 스테이지(dev / stg / prod) 단위로, ECS Service는 애플리케이션(rest / websocket / frontend / fastapi) 단위로 나눈다.
  • 네트워크는 V1 VPC를 공유하고 V2 전용 서브넷만 추가한다. 인터넷 진입점인 ALB와 데이터 서브넷만 2개 AZ에 두고, 애플리케이션 워크로드는 AZ-a 단일 배치로 시작한다.
  • ALB는 Prod 전용 ALB와 dev·stg가 공유하는 Non-Prod ALB 두 개로 나눈다. Non-Prod ALB는 서브도메인 host-header(dev.moyeota.com, stg.moyeota.com)로만 스테이지를 구분한다.
  • 서비스 간 용량·장애 경계는 클러스터가 아니라 같은 클러스터 안의 서비스별 ASG(Capacity Provider) 로 만든다. REST·WebSocket의 기준 용량은 On-Demand에 고정하고, Spot은 REST의 출퇴근·행사 추가분에만 쓴다.
  • REST와 WebSocket을 분리하면서 생긴 태스크 간 채팅 전달 문제는 Redis Pub/Sub로 해결한다. 채팅의 원본은 계속 MySQL이며, Prod DB는 RDS for MySQL Single-AZ로 전환한다.

클러스터 분리는 격리의 "경계"를 만드는 것이고, 실제 권한·설정 접근 차단은 스테이지별 IAM Role, SSM 경로, 보안 그룹으로 완성된다. 같은 스테이지 안에서도 DB에 닿을 수 있는 것은 rest·websocket 태스크뿐이다.

image

V2 아키텍처 전체


1. V1 구조의 한계와 V2의 요구사항

V1은 Prod·Dev 각 1대의 EC2 위에 Next.js, Spring Boot, FastAPI, MySQL 컨테이너를 Docker Compose로 띄운 구조다. 빠른 출시와 최소 비용에는 맞았지만, V2 성장 가정(6개 업무지구, DAU 36,000)에서는 아래 한계가 드러난다.

V1 한계 원인 V2 대응
설정·자격 증명이 환경 간에 섞일 위험 같은 계열의 인스턴스 프로파일과 env 파일을 Prod·Dev가 공유 스테이지별 클러스터·IAM Role·SSM 경로 분리
운영과 같은 조건의 사전 검증 단계 부재 Dev에서 Prod로 바로 배포 Stg 스테이지 신설, Stg에서 검증된 이미지 digest만 Prod로 승격
REST 지연이 WebSocket 연결 수에 끌려감 한 JVM에서 CPU·heap·GC·커넥션 풀 공유 rest·websocket을 별도 ECS Service와 별도 ASG로 분리
피크 외 시간의 유휴 용량 출퇴근 피크에 맞춘 호스트를 24시간 유지 On-Demand 기준 용량 + REST Spot 추가 용량 + 예약 스케일링
호스트 장애 = 서비스 장애 모든 컨테이너가 EC2 1대에 존재 서비스·태스크 단위 재배치, ALB 헬스체크 기반 교체

V1 혼합 부하에서 WebSocket 1,642개 연결과 REST 조회를 함께 걸었을 때 조회 p99는 33.4ms에서 914.8ms로 약 27배, GC pause p95는 3.1ms에서 257ms로 80배 이상 증가했다. 이 측정이 REST와 WebSocket을 분리하는 직접 근거다. 측정 조건은 V2 백엔드 애플리케이션 인프라 설계를 참고한다.

용량 산정은 V2 서비스 확장 가정 및 V1의 한계 지점의 가정을 따른다. DAU 36,000명, 하루 2세션 기준으로 출퇴근 피크 순간 부하는 91 RPS, 피크 외 20시간의 평시 평균은 3.9 RPS로 약 23배 차이가 난다. 행사 트래픽이 겹치면 225.5 RPS와 WebSocket 동시 연결 1,800개를 기준으로 잡는다.

이에 따라 V2의 요구사항을 다음과 같이 정리했다.

  • 스테이지별 보안·IAM 권한 경계 분리
  • 스테이지별 환경 변수·시크릿 접근 분리
  • 스테이지별 배포 전략(CD) 분리
  • 운영과 같은 구성(사양만 축소)의 스테이징 환경
  • REST·WebSocket 자원 격리와 서로 다른 지표 기반의 확장

2. 네트워크 설계

2.1 VPC와 서브넷

V2는 V1 VPC(10.20.0.0/16)와 Internet Gateway를 새로 만들지 않고 참조만 한다. V1 리소스를 V2 Terraform이 교체하거나 소유하지 않게 하기 위해서다. V2는 자기 서브넷과 라우트 테이블만 만들고, CIDR은 V1 서브넷(10.20.10.0/24, 10.20.20.0/24)과 겹치지 않게 잡았다.

서브넷 CIDR AZ 계층 경로 올라가는 것
public-a 10.20.30.0/24 2a public IGW Prod ALB, Non-Prod ALB, NAT Gateway
public-b 10.20.31.0/24 2c public IGW Prod ALB, Non-Prod ALB
app-a 10.20.40.0/24 2a private-app NAT prod ECS 인스턴스·태스크
dev-a 10.20.41.0/24 2a private-dev NAT dev ECS, dev EC2(MySQL·Redis), 현재 stg ECS
stg-a (예정) 10.20.42.0/24 2a private-app NAT stg 전용
data-a 10.20.100.0/24 2a private-data VPC 로컬만 RDS, ElastiCache primary
data-b 10.20.101.0/24 2c private-data VPC 로컬만 서브넷 그룹 2번째 AZ, ElastiCache replica

public / private-app / private-data 3계층을 유지한다. 데이터 서브넷의 라우트 테이블에는 인터넷 경로가 없어, DB와 Redis는 VPC 내부에서만 닿는다.

stg는 지금 dev 서브넷을 함께 쓰고 있다. 그러나 stg를 "Prod와 같은 인프라 구성, 사양만 축소"로 두기로 한 결정과 맞지 않고, dev 실험이 IP를 소진하거나 경로를 바꾸면 stg가 함께 영향을 받는다. 그래서 stg 전용 stg-a 서브넷을 신설하기로 했다.

prod app-a의 IP 예산은 최대 기준 약 41개(인스턴스 ENI 13 + 태스크 ENI 14 + 롤링 배포 여유 14)로, /24 서브넷으로 충분하다.

2.2 AZ 전략

ALB는 2개 AZ의 public 서브넷이 필수이므로 public-a·b에 걸쳐 두고, 교차 AZ 로드밸런싱을 켠다. 반면 애플리케이션 대상(태스크)은 AZ-a에만 둔다. 워크로드를 2개 AZ로 나누면 호스트·NAT·데이터 전송 비용이 함께 늘어나기 때문이다.

이 구성은 AZ-a 장애 시 앱이 중단되는 위험을 수용한 초기 절충안이다. Multi-AZ 전환을 위한 app-c(10.20.50.0/24), stg-c(10.20.52.0/24) 대역은 미리 예약해 두었다.

2.3 외부 출구

private 서브넷의 외부 통신(ECR·SSM API, 외부 AI·교통 API)은 AZ-a의 NAT Gateway 1개로 나간다. ECR 이미지 레이어가 S3에서 내려오므로, NAT 데이터 처리 비용을 줄이기 위해 무료인 S3 Gateway Endpoint를 추가할 계획이다. Interface Endpoint는 시간당 비용이 있어 보류했다.


3. 보안 그룹 설계

ECS 태스크 네트워크 모드는 awsvpc로 한다. 태스크마다 ENI와 보안 그룹을 따로 가지므로, 같은 호스트에 올라간 태스크끼리도 서비스 단위로 접근을 나눌 수 있다. bridge 모드처럼 호스트의 동적 포트 범위(32768–65535)를 통째로 열 필요도 없다.

보안 그룹 붙는 곳 인바운드 목적
Prod ALB SG Prod ALB 80·443 ← 인터넷 (CloudFront 도입 후 CloudFront prefix list) 서비스 진입점
Non-Prod ALB SG Non-Prod ALB 80·443 ← 운영자·개발자·QA IP dev·stg는 특정 그룹만 접속
인스턴스 SG 스테이지의 모든 ECS 인스턴스 없음 앱 트래픽은 태스크 SG로, 운영 접근은 SSM
rest·websocket 태스크 SG rest·websocket 태스크 8080·8090 ← 해당 ALB SG 앱 포트 + Actuator 관리 포트
frontend 태스크 SG Next.js 태스크 3000 ← 해당 ALB SG
fastapi 태스크 SG FastAPI 태스크 8000 ← 해당 ALB SG
Redis SG 스테이지 ElastiCache 6379 ← 같은 스테이지 rest·websocket 태스크 SG
dev EC2 SG dev MySQL·Redis 호스트 3306·6379 ← dev rest·websocket 태스크 SG

보안 그룹을 출발지로 참조하면 태스크 IP가 바뀌어도 규칙을 고칠 필요가 없다. 그리고 스테이지를 넘는 규칙은 만들지 않는다. stg 태스크 SG는 prod 데이터 SG의 출발지에 절대 들어가지 않는다. frontend·fastapi 태스크에는 데이터 계층 경로가 없으므로, frontend가 뚫려도 DB에 접근할 수 없다.

EC2 기반 ECS에서는 같은 호스트의 태스크가 IMDS로 인스턴스 Role 자격 증명을 얻어 다른 태스크의 자격 증명까지 가져갈 수 있다(2025년 공개된 ECScape 연구). 이를 막기 위해 모든 인스턴스에 ECS_AWSVPC_BLOCK_IMDS=true와 IMDSv2 필수·hop limit 1을 적용한다.


4. 진입점: DNS · 인증서 · ALB

4.1 도메인과 인증서

스테이지 도메인 ALB
prod moyeota.com Prod ALB
stg stg.moyeota.com Non-Prod ALB
dev dev.moyeota.com Non-Prod ALB

dev·stg는 서브도메인 DNS로 구분하고, 두 호스트를 담은 ACM 인증서를 Route 53 DNS 검증으로 발급해 Non-Prod ALB의 HTTPS 리스너에 붙인다. V1에서 이미 dev.moyeota.com에 API 서브도메인과 ACM 인증서를 등록해 두었기 때문에, V2 dev는 DNS를 당분간 V1에 두고 V1·V2를 병행한 뒤 전환한다.

4.2 Prod ALB와 Non-Prod ALB 분리

dev·stg·prod를 ALB 하나에 몰면 비용은 줄지만, dev·stg 작업으로 리스너 규칙이나 보안 그룹을 바꿀 때마다 Prod 진입점이 함께 영향을 받는다. 반대로 스테이지마다 ALB를 두면 시간당 비용이 세 배가 된다. 그래서 Prod는 전용 ALB, dev·stg는 Non-Prod ALB 하나를 공유하는 절충안을 선택했다.

항목 Prod ALB Non-Prod ALB
대상 스테이지 prod dev, stg
스테이지 구분 없음 (Prod 전용) host-header (dev. / stg.)
인바운드 인터넷 (CloudFront 도입 후 prefix list) 운영자·개발자·QA IP만
기본 동작 frontend Target Group 404 고정 응답
idle timeout 300초 300초

Non-Prod ALB의 기본 동작을 404로 둔 이유는 host를 잘못 넣은 요청이 다른 스테이지로 새지 않게 하기 위해서다. 헤더(X-Moyeota-Stage)로 스테이지를 고르는 대체 경로는 두지 않았다. Prod에 host 조건을 두지 않은 이유는, 앞단에 CloudFront를 둘 때 Host 헤더 전달 설정에 라우팅이 의존하지 않게 하기 위해서다.

idle timeout은 WebSocket 장기 연결을 위해 300초로 늘렸다. 대신 서버 측 STOMP heart-beat가 꺼져 있으면 유휴 연결이 정확히 5분 만에 끊기므로, 서버 heart-beat를 함께 켠다.

4.3 Listener Rule과 Target Group

ECS Service마다 전용 ip 타입 Target Group을 하나씩 두고, 서비스끼리 Target Group을 공유하지 않는다. 서비스별 요청 수·지연·헬스 상태를 따로 볼 수 있어야 스파이크 상황에서 어느 서비스가 병목인지 구분할 수 있기 때문이다.

규칙 우선순위는 "스테이지 기준값 + 서비스 순서"로 정한다. 스테이지나 서비스가 늘어도 번호가 충돌하지 않는다.

순서 경로 서비스 prod stg dev
0 /api/wss websocket 1 (컷오버 전 100) 200 300
1 /api/* rest 2 (101) 201 301
2 /ocr/* fastapi 3 (102) 202 302
3 /* frontend 4 (103) 203 303

/api/wss는 /api/*보다 먼저 평가해야 한다. 순서가 바뀌면 WebSocket Upgrade 요청이 rest로 간다. SockJS를 쓰지 않아 엔드포인트가 하나뿐이므로 와일드카드도 붙이지 않는다.

Prod는 기존 Compose 호스트에서 ECS로 한 번에 넘어가야 하므로 컷오버 단계를 둔다. 컷오버 전에는 기존 Compose Target Group 규칙(우선순위 10–30)이 실트래픽을 받고, ECS 규칙(100–103)은 X-Moyeota-Stage: prod 헤더가 있는 스모크 테스트 요청만 받는다. 컷오버 시 ECS 규칙을 1–4로 올려 기존 규칙보다 앞서게 하고, 이후 Compose 호스트를 제거한다.

헬스체크 rest · websocket frontend fastapi
포트 8090 (Actuator 관리 포트) 3000 8000
경로 /actuator/health/readiness / /health
정상 코드 200-399 200-399 200-399
deregistration delay rest 30초 / websocket 120초 30초 30초

Spring 서비스는 Actuator를 앱 포트(8080)와 분리된 관리 포트(8090)에서만 노출한다. 8080의 /actuator/health를 보고 정상 코드를 4xx까지 넓히면 404도 healthy가 되어, 헬스체크가 "프로세스가 떠 있는가"만 보고 기동 완료·DB 연결 같은 readiness는 보지 못한다. 그러면 롤링 배포 중 준비되지 않은 태스크로 트래픽이 들어가고, circuit breaker도 실패를 감지하지 못한다. 그래서 8090의 readiness 경로와 200-399를 유지한다.

deregistration delay는 REST가 Spot 회수 알림(2분) 안에 정리되도록 30초, WebSocket은 연결 정리 시간을 확보하도록 120초로 둔다.


5. 컴퓨팅 설계: ECS 클러스터 · ASG · Capacity Provider

5.1 클러스터는 스테이지, 서비스는 애플리케이션

계층 역할 V2 단위
Cluster 태스크·서비스를 묶는 논리 그룹이자 용량 제공 단위 스테이지당 1개 (moyeota-v2-dev / stg / prod)
Capacity Provider · ASG 같은 스테이지 안에서 서비스별 호스트 풀 역할 × 구매 유형별 1개
Service desired 태스크 수 유지, 배포, ALB 연동, 오토스케일링 rest / websocket / frontend / fastapi
Task Definition 이미지, CPU·메모리, 포트, 환경 변수, IAM Role 서비스당 1개 family

서비스 단위 트래픽 격리를 클러스터 분리로 해결하지 않은 이유는, 클러스터를 서비스마다 나누면 스테이지 × 서비스만큼 클러스터·IAM·네임스페이스가 늘어나는데 정작 자원 경합은 호스트 단위에서 생기기 때문이다. EC2 타입에서는 같은 클러스터 안에서 서비스별 Capacity Provider(별도 ASG) 로 호스트를 나누는 것이 자원 격리의 실제 수단이다.

5.2 ASG를 나누는 기준

1 ASG = 1 Launch Template = 1 Capacity Provider = 1 Cluster로 둔다. 같은 스테이지 안에서 아래 중 하나라도 다르면 ASG를 분리한다.

  • 장애 영향 범위 (WebSocket 호스트가 죽으면 연결이 끊기고, REST 호스트는 요청만 재시도된다)
  • 구매 유형 (On-Demand / Spot)
  • 스케일링 신호 (REST는 CPU·RPS, WebSocket은 연결 수)
  • scale-in 민감도 (WebSocket은 자동 scale-in 금지)
  • 태스크 형상 (rest 1 vCPU / 1 GiB, frontend 0.5 vCPU / 768 MiB 등)

Launch Template의 user data에 스테이지와 역할을 새겨 둔다. ECS_CLUSTER가 스테이지 경계를, ECS_INSTANCE_ATTRIBUTES={"role","purchase"}가 서비스 경계를 만들고, 서비스는 memberOf(attribute:role == <role>) 제약으로 자기 역할의 호스트에만 배치된다.

5.3 스테이지별 ASG 구성

스테이지 ASG 구매 타입 min / max 올라가는 서비스
prod rest-od On-Demand t3.small 2 / 3 rest 기준 용량
prod rest-spot Spot 속성 기반 (vCPU 2–4, 2–8 GiB) 0 / 4 rest 출퇴근·행사 추가분
prod ws-od On-Demand t3.small 3 / 4 websocket
prod general-od On-Demand t3.small 1 / 2 frontend + fastapi
stg prod와 같은 4개 〃 〃 1/2 · 0/2 · 1/3 · 1/2 대수만 축소
dev shared-spot Spot vCPU 2–4, 4–8 GiB 0 / 2 4개 서비스 공유

Spot은 REST 추가 용량에만 쓴다. Spot을 확보하지 못해도 On-Demand 기준 용량만으로 핵심 처리량을 유지해야 하므로, rest-spot은 SLO 기준 용량에 넣지 않는다. WebSocket은 Spot 회수 때 연결이 한꺼번에 끊기므로 Spot을 쓰지 않는다. Spot은 price-capacity-optimized와 Capacity Rebalance로 회수 위험이 낮은 풀을 고르고, 10개 이상 인스턴스 타입을 후보로 둔다.

각 ASG의 max가 min보다 1대 큰 이유는 롤링 배포 때 새 태스크를 놓을 자리가 필요해서다. t3.small·medium은 ENI가 3개라 awsvpc 태스크를 호스트당 2개까지만 올릴 수 있다. general-od에 frontend·fastapi 2개를 올리면 호스트가 꽉 차므로 max를 2로 두었고, stg websocket은 2태스크를 서로 다른 호스트에 두고 롤링 여유 1대를 더해 ws-od max를 3으로 두었다. dev는 4개 서비스가 shared-spot 2대(호스트당 2태스크)를 꽉 채우므로 롤링 여유가 없어, 기존 태스크를 내리고 새로 띄우는 방식(min 0% / max 100%)으로 배포한다.

5.4 서비스 태스크 스펙

서비스 Task CPU / Memory 컨테이너 hard limit 포트
rest 1 vCPU / 1 GiB 960 MiB (-Xmx512m) 8080, 8090
websocket 0.5 vCPU / 1 GiB 960 MiB (-Xmx512m) 8080, 8090
frontend 0.5 vCPU / 768 MiB 704 MiB 3000
fastapi 0.5 vCPU / 768 MiB 704 MiB 8000

rest와 websocket은 같은 BE 이미지를 쓰고 APP_ROLE 환경 변수로 실행 역할을 구분한다. ECS Service만 두 개로 나누면 두 태스크 모두에 REST와 WebSocket 기능이 그대로 뜨기 때문에, BE가 이 값으로 REST 컨트롤러·STOMP endpoint·Redis listener를 조건부로 등록해야 실제 자원이 분리된다. frontend·fastapi 스펙은 측정값이 없어 general-od 호스트당 2태스크가 들어가는 잠정값이다.

5.5 배치 전략과 오토스케일링

스케일링은 2단계로 한다. Service Auto Scaling이 태스크 수를 정하고, Capacity Provider의 managed scaling이 그 태스크를 올릴 인스턴스 수를 맞춘다. ASG를 직접 스케일링하지 않으므로, 태스크가 있는 인스턴스는 managed termination protection과 managed draining으로 보호된다.

서비스 (prod) 배치 태스크 min / max 스케일링 정책
rest rest-od base 2, weight 0 + rest-spot weight 1 · 호스트당 1태스크 2 / 6 CPU 60% + 평일 06:50·16:50 min 3 → 09:10·19:10 min 2
websocket ws-od base 3 · 호스트당 1태스크 3 / 4 CPU 60%, scale-in 금지
frontend · fastapi general-od · binpack(memory) 1 / 2 CPU 70%

REST의 기준 2태스크는 항상 On-Demand에 두고 3번째 태스크부터 Spot에 뜨도록 weight 0 전략을 쓴다. 출퇴근 피크는 시간이 정해져 있으므로 CPU가 오르기를 기다리지 않고 피크 10분 전에 min을 3으로 올리고, 피크 10분 뒤 되돌린다. stg에서는 rest desired를 1에서 2로 올렸을 때 두 번째 태스크가 rest-spot에만 뜨는지 먼저 검증한다.

WebSocket은 자동 scale-in으로 태스크가 내려가면 그 태스크에 붙은 연결이 모두 끊기므로 scale-in을 막는다. 동적 지표는 태스크당 STOMP 세션 수가 맞지만, 앱이 이 지표를 발행하기 전까지는 CPU로 scale-out만 한다. rest max 6은 "태스크 수 × HikariCP 풀 크기"가 DB 연결 예산 안에 들어오는지로 다시 확정해야 한다.

모든 서비스는 롤링 배포(min 100% / max 200%)와 deployment circuit breaker 자동 롤백을 쓴다. Stg·Prod는 Blue/Green으로 바꾸는 것이 목표다.


6. 데이터 계층 설계

스테이지 MySQL Redis (채팅 fan-out)
prod RDS for MySQL Single-AZ ElastiCache primary + replica, 자동 장애 조치
stg 마이그레이션·QA 기간에만 RDS ElastiCache 단일 노드, QA 기간에만
dev dev EC2 MySQL 컨테이너 (DDL 스키마만) dev EC2 Redis 컨테이너

Prod DB를 RDS Single-AZ로 정한 근거는 V2 DB 인프라 설계를 따른다. 관리형 백업·PITR과 DB 생명주기 분리는 얻되, Multi-AZ의 상시 standby 비용은 보류한 단계적 선택이다. Prod 배포 전에 Prod DB 무중단 마이그레이션(MySQL 컨테이너 → RDS)이 먼저 끝나야 한다.

dev는 Prod RDS 비용이 개발 환경에 중복되지 않도록 컨테이너를 유지한다. 앱이 MySQL과 Redis에 의존하므로 dev에도 두 의존성이 떠 있어야 하고, dev EC2는 비용을 줄이기 위해 매일 09:00–24:00(KST)에만 켜 둔다. 운영 시간 밖에는 dev 태스크가 DB·Redis에 연결하지 못한다.

6.1 Redis Pub/Sub fan-out

REST와 WebSocket을 분리하면 채팅 이벤트를 만든 프로세스와 수신자의 WebSocket 연결을 가진 프로세스가 달라진다. Spring Simple Broker는 구독 정보를 각 프로세스 메모리에 두므로, websocket 태스크가 2개 이상이면 같은 방의 일부 참여자가 메시지를 받지 못하고, rest가 만든 시스템 메시지는 태스크 수와 상관없이 아무에게도 전달되지 않는다. dev에서 Non-Prod ALB로 rest·websocket을 분기해 검증하면서 이 문제를 확인했다.

REST / WebSocket 태스크 ── MySQL 커밋 후 publish ──▶ Redis 채널 moyeota:<stage>:chat:fanout:v1
                                                       │ (모든 websocket 태스크가 구독)
                                                       ▼
                         각 websocket 태스크의 로컬 Simple Broker /sub/chat/{roomId} → 클라이언트

Redis는 실시간 전달용(at-most-once)이고, 원본과 누락 복구는 MySQL 이력 API가 맡는다. 그래서 스냅샷을 두지 않는다. 다만 prod Redis가 멈추면 모든 WebSocket 태스크의 실시간 전달이 함께 멈추므로, 단일 장애점을 두지 않도록 replica와 자동 장애 조치를 켠다. 상세 설계는 V2 WebSocket 메시지 버스와 Redis 도입 설계를 참고한다.

Redis는 단일 스레드이고, fan-out 송신량은 "발행량 × 구독 태스크 수"로 늘어난다. 그래서 엔진 CPU, 메모리(구독자 출력 버퍼), 네트워크 송신 대역폭 초과를 노드별 알람으로 본다.


7. IAM 설계

스테이지 경계는 Role 이름과 리소스 경로의 <stage>로, 서비스 경계는 Role을 서비스마다 나누는 것으로 만든다.

Role 단위 권한 범위
컨테이너 인스턴스 Role 스테이지 ECS agent 액션을 자기 클러스터로만 제한 + SSM. 이미지·로그·설정 권한 없음
Task Execution Role 스테이지 ECR pull, 로그 전송, 자기 스테이지 SSM 경로만 읽기
Task Role 스테이지 × 서비스 ECS Exec 채널만. 앱 권한은 필요가 확인될 때 추가
Deploy Role 스테이지 GitHub OIDC로만 assume, 자기 클러스터 서비스만 갱신, 자기 스테이지 Role만 PassRole

인스턴스 Role을 관리형 정책 대신 자기 클러스터로 좁힌 이유는, dev Launch Template에 prod 클러스터 이름이 잘못 들어가도 등록 자체가 거부되게 하기 위해서다. Deploy Role은 앱 레포의 GitHub Environment(dev / staging / production)를 OIDC 토큰의 sub 조건으로 확인하므로, dev 배포 워크플로가 prod 서비스를 갱신할 수 없다.

Task Execution Role은 아직 스테이지당 1개다. 같은 스테이지의 frontend가 DB 비밀번호를 주입받을 수 없도록 서비스별로 나누는 것을 다음 단계로 둔다.


8. 환경 변수 · 시크릿 관리

V1은 CI 이미지 빌드용 값을 GitHub vars/secret에, 런타임 값을 EC2 env 파일에 나눠 두었다. 저장소가 분산되어 있어 값이 빠져도 배포 후에야 오류가 보였다. V2는 빌드용 값과 런타임 값을 모두 SSM Parameter Store로 모은다.

구분 경로 관리 방식 읽는 주체
앱 런타임 /moyeota/v2/<stage>/<backend·frontend·ai>/<KEY> Terraform은 리소스만 생성(더미값 + 값 변경 무시), 실제 값은 콘솔·CLI Task Execution Role → 태스크 secrets
채팅 브로커 /moyeota/v2/<stage>/broker/* 인프라가 만드는 값이라 Terraform이 값까지 관리 rest·websocket 태스크
FE 빌드 /moyeota/v2/build/frontend/NEXT_PUBLIC_* 콘솔·CLI FE CI Role

경로에 버전(v2)을 넣은 이유는 V3까지 버전이 이어질 예정이라, 버전 간 Role 접근을 경로로 막기 위해서다. 실제 값을 Terraform state에 남기지 않으므로 state가 노출돼도 시크릿은 노출되지 않는다. FE 빌드 값에 스테이지가 없는 이유는, 한 번 빌드한 이미지 digest를 Stg → Prod로 그대로 승격하므로 번들에 박히는 값이 스테이지마다 다를 수 없기 때문이다.


9. CI/CD 설계

dev(BE) / develop(FE) ─▶ CI: dev-<sha>         ─▶ Dev CD   ─▶ moyeota-v2-dev
release/vX.Y.Z        ─▶ CI: rc-vX.Y.Z-<sha>   ─▶ Stg CD   ─▶ moyeota-v2-stg  ─▶ stg-vX.Y.Z-<sha> 태그
release/* ─PR─▶ main  ─▶ Prod CD: stg- 태그 확인 ─▶ production 승인 ─▶ moyeota-v2-prod ─▶ prod-vX.Y.Z-<sha> 태그

Dev·Prod는 기존 브랜치 정책을 유지하고, 그 사이에 release/* → Stg를 새로 넣었다. Prod는 Stg 배포가 완료된 digest만 재빌드 없이 승격하고 dev → main 직행은 거부한다. 태그는 다시 붙일 수 있지만 digest는 이미지 내용 그 자체이므로, Stg에서 검증한 이미지와 Prod 이미지가 같다는 것을 digest로 보장한다.

항목 Dev Stg Prod
목적 속도·자율성 운영과의 일관성 검증 안정성
트리거 dev / develop 머지 release/* push main 머지 + 수동 승인
이미지 빌드 산출물 그대로 rc 이미지 digest Stg와 같은 digest
인증 OIDC → dev Deploy Role OIDC → stg Deploy Role OIDC → prod Deploy Role + 승인자

배포 액션은 digest로 고정된 이미지만 받아, 서비스의 최신 Task Definition에서 이미지만 바꾼 새 revision을 등록하고 서비스 배포가 완료될 때까지 기다린다. circuit breaker 롤백이나 시간 초과는 모두 실패로 끝낸다. BE 이미지는 rest·websocket 두 서비스에, FE 이미지는 frontend에 배포한다.

dev는 dev.moyeota.com DNS가 V1을 가리키는 동안 V1 dev CD를 지우지 않고 V2 dev CD를 함께 돌린다. 같은 CI 산출물을 V1 EC2와 V2 ECS에 각각 배포하고, DNS를 Non-Prod ALB로 넘길 때 V1 dev CD를 제거한다.


10. 운영 · 관측 · 장애 대응 검증

항목 구성
로그 서비스별 CloudWatch 로그 그룹 (보존 dev 7일 / stg 14일 / prod 30일)
Container Insights dev 끔 / stg 켬 / prod enhanced
운영 접근 SSH 없음. 인스턴스는 SSM Session Manager, 태스크는 ECS Exec (prod는 운영자 그룹만)
비용 스케줄 dev EC2 09:00–24:00 자동 시작·중지, stg는 QA 기간에만 기동
장애 실험 (stg) AWS FIS로 websocket 태스크 종료, rest Spot 회수, 컨테이너 인스턴스 drain

Stg의 장애 실험은 아래를 확인하기 위한 것이다.

  • websocket 태스크가 죽었을 때 대체 태스크가 준비(ALB healthy)되기까지의 시간과, 그 사이 발행된 메시지의 유실 범위
  • rest Spot 인스턴스가 회수될 때 2분 안에 drain되고 다른 인스턴스에 대체 태스크가 뜨는지
  • 인스턴스 절반이 drain될 때 배치 제약(호스트당 1태스크, 역할별 호스트) 아래에서 Capacity Provider가 인스턴스를 늘리는지

중앙 집중식 모니터링 구축은 다음 마일스톤에서 다룬다.


최종 구성

V2의 전체 구조는 Route 53 → (CloudFront) → Prod ALB / Non-Prod ALB → 스테이지별 ECS 클러스터 → 서비스별 ASG 위의 태스크 → RDS · ElastiCache로 정리된다.

영역 최종 결정
실행 환경 ECS on EC2, 스테이지별 클러스터(dev / stg / prod), 서비스별 ECS Service
네트워크 V1 VPC 공유, public / private-app / private-data 3계층, 워크로드 AZ-a 단일, NAT 1개
진입점 Prod ALB(prod 전용) + Non-Prod ALB(dev·stg host 기반), 서비스별 ip Target Group
격리 스테이지: 클러스터·IAM·SSM 경로·서브넷 / 서비스: ASG·태스크 SG
용량 기준 용량 On-Demand, REST 추가분만 Spot, 출퇴근 예약 스케일링, WebSocket scale-in 금지
데이터 Prod RDS Single-AZ, Redis Pub/Sub fan-out (prod replica 포함), dev는 컨테이너
배포 digest 기반 승격 (dev → stg → 승인 → prod), 스테이지별 OIDC Deploy Role

이 구조가 감수하는 위험은 다음과 같다.

  • 워크로드가 AZ-a에만 있어 AZ 장애 시 서비스가 중단된다. 99.9% 가용성은 단일 AZ 장애를 포함한 보장치가 아니다.
  • Prod RDS가 Single-AZ라 AZ 장애 시 자동 standby 전환이 없다. 자동 백업·PITR과 복구 리허설로 대응한다.
  • Redis Pub/Sub는 전달을 보장하지 않으므로, 장애 조치 중 발행된 메시지는 MySQL 이력 API로 복구해야 한다.
  • 단일 AWS 계정이므로 계정 수준의 Prod / Non-Prod 분리는 V3에서 검토한다.

남은 결정과 다음 단계

항목 현재 다음 단계
배포 일정 dev 클러스터에 rest·websocket만 배포 10/16 stg 배포 → 10/17 QA → 10/19 prod 배포
stg 서브넷 dev 서브넷 공유 stg-a 신설
Prod DB Compose MySQL 컨테이너 RDS 생성과 무중단 마이그레이션 시나리오 확정
Prod 진입점 HTTP, 인터넷 전체 허용 ACM 인증서 연결, CloudFront 도입 후 prefix list로 제한
Prod 컷오버 Compose 호스트가 실트래픽 처리 ECS 규칙 전환 후 Compose 호스트 제거
Task Execution Role 스테이지당 1개 서비스별 분리
WebSocket 스케일링 지표 CPU STOMP 세션 수 커스텀 지표
배포 방식 롤링 Stg·Prod Blue/Green
fastapi 외부 노출 /ocr/* ALB 규칙 있음 담당자와 논의 후 결정 (내부 전용이면 Service Connect만)
ALB 2-AZ 대상 AZ-a에만 대상 AZ-c에 최소 대상을 둘지 결정
⚠️ **GitHub.com Fallback** ⚠️