본문으로 건너뛰기

클라우드 아키텍처 (CA)

2026-08-17 기록. 현재 문서와 내용이 다를 수 있다. 아래 내용은 당시 상태를 보존하며 수정하지 않는다.

이것을 실제 환경에 어떻게 배포할 것인가?

가장 아래층이자 가장 구체적인 문서다. 그대로 만들 수 있어야 한다 — 읽고 나서 무엇을 몇 개 띄워야 할지 모르면 덜 쓴 것이다.

  • 집중하는 것 — Deployment / Infrastructure
  • 담는 것 — VPC, ALB, ECS, RDS, Redis, S3, NAT, AZ, IAM 등
  • 빼는 것 — UI 기능이나 Domain 구조
  • 구체성 — 가장 구체적

IaC 를 두지 않는다. 형상 확인은 SSM 으로 접속해 CLI 로 한다. 그래서 이 문서가 형상의 정본이고, 관리 대상을 줄이는 선택이 그만큼 더 중요해진다.

주소를 적지 않는다. 층은 public / private app / private data 로, AZ 는 A / B 로만 구분한다. 실제 CIDR 은 콘솔에 입력할 때 필요한 값이지 구조를 읽는 데 필요한 값이 아니다.

그림답하는 질문
§1무엇이 AWS 안이고 무엇이 밖인가
§2어느 서브넷에 무엇이 있고 어디로 나가나
§3몇 개가 뜨고 무엇으로 늘어나나
§4저장소가 어디 있고 누가 어떤 길로 붙나
§5무엇이 새어 나가고 무엇이 막나

1. 전체 — 무엇이 어디에 있나

10초 안에 읽혀야 하는 것 — 층이 셋이고, 저장소는 인터넷으로 못 나가며, 벤더는 전부 VPC 밖이라는 것.

그림이 말하는 것무엇
들어오는 길이 둘이다REST 는 Cloudflare 프록시를 지나고 실시간 호스트는 안 지난다
data 층에서 나가는 선이 없다라우팅 테이블에 기본 라우트가 없다. 문장이 아니라 그것이 강제 장치다
VPC 밖에 저장소가 없다회의록 간 관계까지 heymoa 안의 테이블이다. 밖으로 나가는 것은 벤더 호출뿐이다

2. 네트워크 — VPC · AZ · 서브넷

AZ 를 나란히 놓고 층을 쌓는다. 이중화가 전제이므로 같은 것을 두 번 그린다 — "AZ 2개"라고 글로만 적으면 무엇이 두 벌이고 무엇이 한 벌인지 안 보인다. 이 그림만 개수를 그림으로 말한다.

그림이 말하는 것무엇
두 벌인 것NAT GW · app 층 · RDS 인스턴스 · Redis 노드
한 벌인 것ALB · IGW · Gateway Endpoint · ECS 클러스터
한쪽 AZ 는 항상 cross-AZ 다읽기 복제본을 두지 않기로 했으므로 양쪽 app 층이 같은 writer 에 쓴다. 장애 전환하면 그 한쪽이 바뀐다
S3 만 NAT 를 안 지난다나머지는 리전 서비스든 벤더든 전부 NAT 로 나간다

라우팅 테이블을 AZ 마다 나누는 것이 NAT 이중화의 실체다. 하나로 합치면 한쪽 NAT 가 죽을 때 양쪽 AZ 가 같이 죽고 평소에도 AZ 간 전송료가 붙는다.

무엇이 어느 경계에 속하나

"AZ 밖에 있는 것"과 "AZ 를 걸치는 것"과 "AZ 안에 있는 것"은 다르다. 이걸 섞으면 이중화를 잘못 센다. 우리가 정한 규칙이 아니라 지켜야 하는 규칙이다.

경계무엇이 여기 사나
리전 · VPC 밖S3 · SQS · ECR · Secrets Manager · CloudWatch Logs · SES
VPC 전역 · AZ 없음IGW · 라우팅 테이블 · 보안그룹 · S3 Gateway Endpoint · ECS 클러스터
AZ 를 걸친다ALB — 각 public 서브넷에 노드가 하나씩 있다는 뜻이다
서브넷 = AZ 하나NAT GW(+EIP) · ECS 태스크 · RDS 인스턴스 · Redis 노드
AZ 목록만 갖는 것DB 서브넷 그룹 · 캐시 서브넷 그룹. 리소스가 아니라 명단이라 그림에 상자로 안 그린다

RDS 는 AZ 밖에 있지 않다. 인스턴스는 data 서브넷 하나 = AZ 하나에 산다. Multi-AZ 는 다른 AZ 의 서브넷에 standby 를 하나 더 두는 것이다.


3. 컴퓨트 — ALB · ECS

그림이 말하는 것무엇
타깃 그룹이 둘뿐이다ai 는 ALB 뒤에 없다. 밖에서 도달할 길이 없다는 뜻이고, 그래서 서로를 어떻게 찾을지가 열려 있다
ECS 서비스가 셋이다조립은 ECS 에 없다. 뾰족하고 짧은 일이라 상시 태스크를 띄울 이유가 없다
서비스마다 확장 기준이 다르다api 는 CPU, realtime 은 활성 회의, ai 는 동시 스트림. 조립 함수는 Lambda 가 알아서 늘리고 우리는 상한만 정한다
realtime 만 축소를 끈다ECS 는 어느 태스크를 죽일지 우리가 고르게 해주지 않는다. 축소가 곧 회의 강제 종료다
드레이닝이 둘로 갈린다api 는 짧게, realtime 은 길게. 배포가 회의를 덮지 않게 하려는 것이 실행 단위를 가른 목적이다

realtime 은 CPU 도 요청 수도 확장 기준으로 안 맞는다 — 오디오 중계는 CPU 를 거의 안 쓰고, 2시간 회의도 요청으로는 1건이다.

배포는 회의를 덮을 수 없다

드레이닝을 아무리 늘려도 회의가 그보다 길면 반드시 끊긴다. 회의 상한이 5시간이라 확실하다.

그래서 배포 전략은 "끊지 않는다"가 될 수 없고 "끊겨도 사용자가 모르게 복구한다"가 된다.

계획된 종료에서는 오디오 유실이 0이다. 드레이닝 안에서 끝나는 짧은 회의는 애초에 안 끊기지만 거기에 의존하지 않는다.

DB 마이그레이션

  • expand-then-contract. 파괴적 변경은 앱 배포와 분리해 두 번에 나눠 올린다
  • 엔티티와 스키마가 어긋나면 컨테이너가 아예 안 뜬다. 마이그레이션 적용 뒤 옛 이미지를 올리는 경로가 막혀 있다
  • 되돌아가는 것은 앱뿐이고 마이그레이션은 적용된 채 남는다. 앞으로 고치는 것이 기본이다

4. 데이터 — 저장소와 가는 길

그림이 말하는 것무엇
RDS 하나에 데이터베이스가 둘이다인스턴스는 하나인데 heymoaheymoa_ai 가 산다. cross-database 쿼리 금지
S3 로 가는 길만 공짜다Gateway Endpoint 는 라우팅 테이블 항목이라 요금이 없다. 안 붙이면 같은 트래픽이 NAT 로 흘러 조용히 돈이 샌다
조립 함수만 VPC 밖에 있다RDS 를 안 부르니 VPC 에 넣을 이유가 없다. ENI·NAT·커넥션 풀이 이 함수에서 전부 빠진다
조립 함수는 Redis 에도 벤더에도 안 붙는다없다고 확인한 것이다
큐는 NAT 를 지난다SQS 는 리전 서비스이지만 VPC 밖이라 인터페이스 엔드포인트를 안 붙이면 NAT 로 나간다. 물량이 작아 지금은 NAT 로 시작한다

⚠️ app 라우팅 테이블 둘 다에 Gateway Endpoint 를 붙여야 한다. 하나만 붙이면 반쪽 트래픽이 조용히 NAT 로 나간다. 빠져도 에러가 안 나고 청구서에만 나타난다.

S3 수명 주기

규칙
오디오 세그먼트합본이 만들어지면 삭제 — 유예 기간 미정
Opus 압축본보존 — 기간은 법률 확인 후

「원본 PCM 7일」 규칙이 없어졌다. 브라우저부터 Opus 라 되돌릴 원본이 애초에 없다. 잃은 것을 명시한다 — 원본 품질로 재전사할 수 없다. 「미완료 멀티파트 Abort」도 없어졌다 — 멀티파트를 안 쓰므로 미완료 업로드가 생기지 않는다.

버킷 하나에 프리픽스로 나눈다 — 오브젝트 키가 회의 식별자로 시작해서 회의 삭제가 프리픽스 삭제 하나로 끝난다.


5. 관측과 보안

그림이 말하는 것무엇
운영자가 인바운드 포트로 안 들어온다태스크가 밖으로 연결을 연다. 조립 함수가 아예 VPC 밖인 것과 같은 원리다
막는 것이 둘이다IAM 은 무엇을 할 수 있나, 보안그룹은 누구와 말할 수 있나. 둘을 섞으면 한쪽에 구멍이 난다
관측이 NAT 로 나간다트레이스 전량이 인터넷으로 나가므로 물량이 요금에 잡힌다

앱이 OTLP 로 Grafana Cloud 에 직접 보낸다. Collector 를 두지 않는다. 다만 부팅 실패와 크래시만 CloudWatch 에 짧게 남긴다 — 앱이 뜨기 전에 죽으면 OTLP 로 아무것도 안 나간다.

깨워야 하는 것은 "값이 없는 것"이다. 분석이 열 시간 0건인데 아무도 모르고, 관측 토큰이 만료됐는데 앱은 멀쩡히 돌고, 계측 수십 종이 열흘간 한 건도 도달하지 않는 일이 실제로 있었다. 신호가 끊긴 것을 감지하는 알람이 없으면 관측이 죽은 것을 관측할 방법이 없다.

⚠️ 죽은 잡을 보는 알람이 없다. 아무도 안 보면 없는 것과 같고 회의 하나가 영영 처리되지 않는다.


6. 연결 관계

인바운드 — 밖에서 안으로

#구간무엇이 허용하나
사용자 → Cloudflare → VercelCloudflare 프록시
사용자 → Cloudflare(프록시) → ALB → apialb-sg ← 인터넷 · api-sgalb-sg
사용자 → ALB 직접 → realtime〃 · rt-sgalb-sg
pyannote → ALB → api · 서명 검증alb-sg · api-sg
운영자 → SSM · ECS Exec인바운드 포트 없음. 태스크가 밖으로 연결을 연다

인바운드는 이 다섯뿐이고, 공개 경로는 ⓑ·ⓒ·ⓓ 셋이며, 전부 ALB 하나를 지난다.

⚠️ ⓓ(pyannote webhook) 가 api 로 온다. 이제 제출도 api 가 하므로 보내는 쪽과 받는 쪽이 같아졌다.

⚠️ ALB 의 인바운드를 Cloudflare 대역으로 좁힐 수 없다. ⓒ 가 DNS only 라 브라우저가 ALB 에 직접 붙는다. 오디오를 Cloudflare 로 보내지 않기로 한 결정의 대가다.

아웃바운드 — 안에서 밖으로

#구간나가는 길물량
realtime → S3 · 세그먼트 쓰기Gateway Endpoint — NAT 를 안 지난다월 ≈ 330 GB
realtime → SonioxNAT (피할 수 없다)월 ≈ 330 GB
api → pyannote · 제출NAT회의당 한 번. 바이트는 안 지난다
ai → OpenAI · Linear · GitHubNAT토큰 물량 미정
조립 함수 → S3 · SQSVPC 를 안 지난다회의당 합본 하나
api → SES · Google OAuth2NAT작다
전 서비스 → Grafana Cloud · LangfuseNAT트레이스 전량
태스크 기동 → ECR · Secrets · CloudWatch LogsNAT배포·확장마다
SQS 발행과 소비NAT작다

⚠️ ⓟ 가 ⓞ 와 같은 크기인데 무료가 아니다. 같은 바이트를 S3 와 Soniox 양쪽에 보내는데 Soniox 는 인터넷 밖이라 Gateway Endpoint 로 뺄 수 없다. 다만 그 크기가 12배 줄었다 — PCM 384 kbps → Opus 32 kbps. 월 3.9 TB 였던 것이 ≈ 330 GB 다. ⚠️ ⓥ 가 NAT 를 지난다는 것은 "NAT 가 죽으면 새 태스크가 못 뜬다"는 뜻이다. NAT 를 AZ 마다 둔 이유가 이것이기도 하다.

무엇이 무엇을 막나

전부 보안그룹 참조로 잇는다. 태스크 주소가 배포마다 바뀌므로 CIDR 로 쓴 규칙은 유지될 수 없다.

인터넷 ────> alb-sg ────> api-sg ────> ai-sg
└───> rt-sg ────┘
ai-sg ────> api-sg (콜백)

api-sg ────> rds-sg (heymoa)
rt-sg ────> rds-sg (heymoa)
조립 함수는 VPC 밖이라 SG 가 없다
ai-sg ────> rds-sg (heymoa_ai)

api-sg ────> redis-sg
rt-sg ────> redis-sg
ai-sg 는 Redis를 안 쓴다
규칙무엇이 강제하나
조립 함수가 DB 에 못 닿는다VPC 밖에 있어서 rds-sg 에 넣을 소스 자체가 없다. 문장이 아니라 이것이 강제 장치다
브라우저가 ai 에 도달할 수 없다ai-sgapi-sg·rt-sg 만 받는다. 타깃 그룹도 없다
data 층이 밖으로 못 나간다data 라우팅 테이블에 기본 라우트가 없다

⚠️ ⓛ 을 빠뜨리면 ai 가 기동하자마자 죽는다. heymoa-ai 는 같은 인스턴스의 다른 데이터베이스에 직접 붙는다.

권한이 곧 경계다

태스크 역할을 서비스마다 나눈다. 문장으로 쓴 격리 규칙을 실제로 강제하는 것이 이것이다.

역할줘야 하는 것
api잡 발행·수신 · SES · Secrets · S3 GetObject 서명만(합본 presigned URL)
realtimeS3 PutObject(세그먼트) · Secrets
함수 실행 역할S3 읽기·쓰기 · 잡 수신·발행 · CloudWatch Logs. 그게 전부다. Secrets 가 없다
aiSecrets · 잡 수신

⚠️ S3 에 실제 바이트로 닿는 것은 realtime(쓰기)과 조립 함수(읽고 쓰기)뿐이다. api 에 GetObject 가 새로 생겼다 — 예전에는 worker 가 pyannote 에 제출해서 api 는 S3 권한이 0개였는데, 제출이 api 로 옮겨 오면서 presigned URL 을 서명할 권한이 필요해졌다. 바이트는 여전히 안 지난다. 함수 역할에 Secrets 가 없다 — 벤더 키를 안 갖는 것이 이 격리의 요점이다.


7. 기존 형상에서 넘겨받는 것

전부를 새로 만드는 게 아니다.

지금어떻게 되나
VPC · 서브넷 넷재사용한다. 기존 private 둘을 app 층으로 올리고 data 층을 새로 판다
EC2 인스턴스와 그 안의 nginx없앤다. ECS 서비스와 ALB 가 대신한다
Single-AZ RDS제자리에서 Multi-AZ 로 올린다
붙어 있지 않은 S3 Gateway Endpoint새 라우팅 테이블에 붙인다
운영 접속SSH 인바운드를 두지 않는다. SSM Session Manager 로만 들어간다
Secrets Manager · SES · Cloudflare · Vercel그대로

8. 비용

전부 추정이다. 실제 청구 단가는 확인하지 않았다.

항목근거
STT egress (ⓟ)≈ $42오디오 330 GB × $0.126/GB
NAT 데이터 처리 — Soniox 몫 (ⓟ)≈ $15같은 330 GB × $0.045/GB. Gateway Endpoint 로 못 뺀다
조립 함수 호출작다회의 1,000건/일 × 수십 초
NAT 데이터 처리 — S3 몫 (ⓞ)$0 또는 $175Gateway Endpoint 를 붙였나 안 붙였나
S3 (원본 7일 + Opus 누적)12개월 뒤 ≈ $90원본을 영구 보관하면 47 TB · $1,070
ALB · NAT 시간요금 · Fargate · RDS · Redis미정단가 미확인
인터페이스 엔드포인트$0두지 않고 시작한다
LLM 토큰미정 — 계산 항목 자체가 없다이 규모에서 가장 클 수 있다

총액을 만들지 않는다. 근거 없는 숫자를 더하면 문서 전체가 무효가 된다.


9. 원칙 넷

원칙
무엇을 어디에 두나정본은 RDS · 오디오는 S3 · 티켓과 팬아웃은 Redis · 회의록 간 관계는 RDS 안의 테이블 · 프로세스 경계를 넘는 잡은 , 같은 프로세스 안은 DB 행
관리 대상을 늘리지 않는다IaC 가 없다. 그래서 Lambda · 로그 사이드카 · NAT 인스턴스 · 인터페이스 엔드포인트를 지금은 전부 물렸다
되돌릴 수 없는 쪽을 피한다VPC 재사용도, expand-then-contract 마이그레이션도 같은 이유다
지금 안 만드는 것worker 서비스(영구히 — 조립은 함수다) · 그래프 전용 DB(영구히) · 인터페이스 엔드포인트 · 읽기 복제본

10. 아직 안 정한 것

  • api ↔ ai 도달 수단 — 안 정하면 배포마다 깨진다
  • VPC 재사용 · 서브넷 · 라우팅 · 보안그룹 · 엔드포인트
  • LLM 비용 — 계산 항목이 없다
  • 인스턴스 클래스 · 태스크 사양 · 캐시 노드 타입
  • 조립 함수의 예약 동시성 · 메모리 · 타임아웃
  • realtime 컨테이너 포트 · ALB 헬스체크 경로
  • SQS 재시도 횟수 · 가시성 타임아웃 · DLQ 경보 임계
  • 드레이닝 최댓값 · 종료 유예 상한
  • S3 보존 기간 (법률 확인 후)