구축 → 완전 이전
하루 10/12 · Terraform 리소스 92개 → 데이터 복원 → 자동 배포 → 컷오버
2026-10-06 계획 → 10-08 재수립(부하 테스트 9차 뒤) → 10-12 구축·완전 이전 → 10-13~14 AWS 부하 테스트 · AWS 서울 리전 · 운영 10-12 ~ 10-27
로컬 집 서버의 docker compose로 돌던 백엔드를 AWS 서울 리전의 쿠버네티스(EKS)와 관리형 DB(RDS)로 옮겼습니다. 계획은 요구사항 → 설계 기준 → 구조·선택 이유 → 자원·확장 → 배포·백업 순서로 세웠고, 10월 12일 하루에 인프라 구축부터 데이터 이전·자동 배포·프론트 연결·완전 이전(컷오버)까지 끝냈습니다. 지금 수집 크론은 전부 AWS에서 돌고, 로컬은 개발 전용입니다. 옮긴 뒤 같은 시나리오로 부하를 다시 재서, DB를 한 단계 키운 구성이 1,543 RPS(동접 4,500명)를 p95 51ms로 받는 것을 확인했습니다.
이 페이지는 공개용 요약입니다. 계정·접속 허용 범위, 비밀값 이름과 교체 주기, CI 신뢰 조건, 관리자 IP 판정 방식, 보안 사고 대응 기록, 서버 이전 비용은 비공개 문서에 따로 둡니다.
하루 10/12 · Terraform 리소스 92개 → 데이터 복원 → 자동 배포 → 컷오버
72초 컷오버 복원 시간 · 표 54개 행 수 로컬과 일치
3회 10/12 · Argo CD · DB 마이그레이션 4개 모두 PreSync로 적용
아키텍처 그림보다 먼저 숫자를 정했습니다. 아래 숫자가 바뀌면 뒤의 선택도 바뀝니다.
| 항목 | 값 | 근거 |
|---|---|---|
| 동시접속 | 평시 피크 47명, 스파이크 470명(성공 시나리오 950명) | 연간 개업 2.8만 건 깔때기, 피크 1시간 15% 집중, 스파이크 ×10 |
| 요청 성격 | 읽기가 대부분(지도·판정·프로필 조회). 쓰기는 리포트 저장·관리자 기록·밤 수집뿐 | 프론트 API 호출 목록 |
| 특수 요청 | AI 리포트 SSE(수 초 연결 유지), 행정동 경계 GeoJSON(첫 화면) | |
| 배포 빈도 | 백엔드 8/24~10/6에 99번 — 하루 약 3번 | 버전 로그 실측 |
| 처리량 기준 | 로컬 실측 천장 약 900 RPS·응답 기준 지킨 동접 약 2,900명 → AWS 실측 1,543 RPS 이상(§10) | 부하 테스트 |
| 대상 | 목표 | 이유 |
|---|---|---|
| 조회 API (지도·판정·프로필·자금 계획) | 99.5% (월 약 3.6시간) | 결제·예약이 없는 정보 서비스. 99.9%는 비용이 크게 늘어 공개 후 판단 |
| AI 리포트 | 99% (최선 노력) | 외부 LLM 계정 상태에 묶여 있다. 대신 실패해도 조회 API에 번지지 않게 한다 |
| 수집 크론 | 하루 안에 재실행되면 정상 | 공공데이터는 일 단위 갱신 |
| 분류 | 예 | 잃으면? |
|---|---|---|
| A. 재수집 가능한 공공데이터 | 상권·매출·점포·인구·지원사업·뉴스 (DB 3.2GB 대부분) | 원천 API에서 다시 적재 — 시간만 든다 |
| B. 다시 만들 수 없는 운영 데이터 | 분석 리포트, LLM 사용량, 관리자 기록 (수 MB) | 복구 불가 — 백업만이 답 |
| C. 파생 데이터 | 지표·판정·검색 벡터(8,905청크) | A에서 재계산 |
→ 복구 시점 목표(RPO)는 B가 정합니다. 그래서 DB는 시점 복구(PITR)가 되는 관리형으로 둡니다.
| 장애 | 복구 시간(RTO) | 잃는 데이터(RPO) | 방법 |
|---|---|---|---|
| 파드 하나 죽음 | 1분 (자동) | 0 | 쿠버네티스가 다시 띄운다 |
| 노드 하나 죽음 | 2분 (자동) | 0 | 다른 가용 영역 파드가 받고 노드 보충 |
| 잘못된 배포 | 10분 | 0 | 이전 이미지로 되돌리기 |
| 데이터 손상 (잘못된 마이그레이션·삭제) | 1시간 | 5분 | RDS 시점 복구 |
| 서울 리전 전체 장애 | 24시간 | 24시간 | Terraform으로 다른 리전에 재구축 — 알고 수용하는 위험 |
| 순위 | 기준 | 이 기준 때문에 내린 결정 |
|---|---|---|
| 1 | 운영 복잡도 최소화 — 전담 운영자 없는 소규모 팀이 밤에 깨지 않고 돌릴 수 있어야 한다 | 상태가 있는 것은 관리형으로(RDS·ALB·Secrets Manager). 측정으로 필요가 확인되기 전에는 서비스를 늘리지 않는다 |
| 2 | 장애 격리 — LLM·수집기·관리자 기능이 실패해도 지도·판정 조회는 산다 | 크론은 API와 별도 파드, 파드별 자원 상한, LLM 실패 시 폴백 |
| 3 | 비용 예측 가능성 | 상시 GPU 없음, 노드·파드 최대 수 상한 |
| 4 | 가용성은 99.5%에서 멈춘다 | NAT 1개·DB 단일 AZ로 시작, 증거가 필요한 기간에만 Multi-AZ |
개발 저장소의 원칙 “확인된 요구 없이 복잡도를 늘리지 않는다”를 인프라에 그대로 적용한 것입니다.
쿠버네티스는 “파드는 언제든 죽고, 여러 개가 동시에 뜬다”를 전제로 합니다. 이 전제와 부딪히는 곳을 이전 전에 로컬에서 먼저 고쳤습니다.
| 지금 코드 | 쿠버네티스에서 생기는 문제 | 해결 |
|---|---|---|
| 분석 대기 장부가 프로세스 메모리 | 파드가 2개 이상이면 리포트 요청과 스트림 구독이 다른 파드로 가서 404 | Postgres 테이블로(30분 만료). Redis는 새 장애 지점·비용이 늘어 측정으로 필요할 때만 |
| 행정동 경계 5.4MB를 요청마다 직렬화 | 파드 CPU를 가장 먼저 먹는다 | 미리 직렬화·압축해 요청당 CPU 0 |
| DB 연결 풀이 고정값 | 파드 수 × 풀이 DB 최대 연결을 넘을 수 있다 | 풀 크기를 환경변수로 |
/health 하나 | 롤링 배포 중 DB에 못 붙는 파드로 요청이 간다 | 생존(liveness)과 준비(readiness, DB 확인) 분리 |
| 크론이 로그 파일에 씀 | 파드 로그는 표준 출력으로만 모인다 | 셸 스크립트 없이 python -m … 직접 실행 |
| 비밀값이 파일 하나에 | 저장소가 공개라 이미지·매니페스트에 들어가면 안 된다 | AWS Secrets Manager → 쿠버네티스 Secret |
| 컨테이너가 root로 실행 | 보안·이미지 크기 | 비루트 사용자, 테스트·캐시 제외, 태그는 git SHA |
| SSE 스트림 | 로드밸런서 유휴 타임아웃에 끊길 수 있다 | 유휴 타임아웃 300초. keep-alive는 안 넣기로 — 실측 이벤트 간격 최대 3.6초, 최악 약 30초로 300초에 한참 못 미친다 |
브라우저
├─ www.beyondfacade.cloud ──▶ Vercel (Next.js) beyondfacade.cloud → www 리디렉트
└─ api.beyondfacade.cloud ──▶ ALB (HTTPS 인증서, HTTP→HTTPS, 유휴 300초, 파드 IP로 직접)
│
┌──────────────── EKS (서울 리전, 가용 영역 2곳) ─────────────────┐
│ 노드: c7g.large(Graviton arm64) × 2, 최대 3 — Cluster Autoscaler │
│ api Deployment (HPA 2~10개, CPU 60%) · 수집 CronJob 9개 │
│ Argo CD — 저장소 main의 배포 선언을 클러스터에 맞춘다 │
│ 애드온: LB Controller · External Secrets · metrics-server · │
│ Cluster Autoscaler │
└──────────────┬──────────────────────────────┬──────────────────┘
▼ ▼ NAT Gateway 1개
RDS PostgreSQL 17 + pgvector 외부: Gemini → Opus(폴백),
db.m7g.large · 백업 7일 공공데이터 API, 브이월드, SGIS
Secrets Manager · ECR(이미지 태그 = git SHA) · S3(Terraform 상태·덤프) · CloudWatch(알람·감사 로그)
노드와 DB는 private 서브넷에 있고, DB는 클러스터에서만 접근할 수 있습니다. 인프라는 Terraform, 앱 배포 선언은 Kustomize로 코드에 남겨 다시 만들 수 있는 인프라로 관리합니다.
| 영역 | 선택 | 버린 대안 | 이유 |
|---|---|---|---|
| 쿠버네티스 | EKS | EC2 + docker compose, ECS Fargate, k3s | 하루 3번 배포·스파이크 ×10·자가 치유가 필요하다. compose는 배포마다 끊기고, k3s는 컨트롤 플레인 장애·업그레이드를 우리가 진다. 대신 애드온 수 제한·Terraform 코드화로 복잡도를 관리한다 |
| DB | RDS PostgreSQL + pgvector | 쿠버네티스 안 StatefulSet, Aurora | 다시 만들 수 없는 데이터(B)가 있고 전담 DBA가 없다 — 백업·시점 복구·장애 조치를 관리형에 맡긴다. Aurora는 3.2GB 규모엔 비싸다 |
| 입구 | ALB 1개 | NLB, 터널 | 요청이 HTTP 조회와 SSE뿐이라 경로 헬스체크·요청 단위 분배·인증서가 필요하다. NLB는 TCP·고정 IP용 |
| 프론트 | Vercel | S3+CloudFront, 쿠버네티스에 Next 파드 | Next.js SSR을 가장 손 안 대고 돌리고, CDN이 정적 자원을 받아 클러스터 부하와 무관하다 |
| 노드 CPU | Graviton(arm64) | x86 | 같은 성능에 약 20% 싸다. CI에서 arm64로 바로 빌드 |
| 배포 | Argo CD(GitOps) | CI에서 helm upgrade | CI에 클러스터 권한을 주지 않는다. 클러스터 안의 Argo CD가 저장소를 읽어 맞춘다 |
| 모니터링 | CloudWatch | Prometheus + Grafana 자체 운영 | 운영 대상이 하나 준다 |
“왜 썼나”만큼 “왜 안 썼나”도 설명할 수 있어야 합니다. 아래는 지금 넣지 않고, 이런 신호가 측정되면 넣습니다.
| 서비스 | 지금 안 쓰는 이유 | 도입 신호 |
|---|---|---|
| ElastiCache (Redis) | 분석 대기 장부는 Postgres로 충분, 무거운 응답은 미리 압축 | 같은 조회 반복으로 DB CPU가 병목이고 인덱스로 안 풀릴 때 |
| MSK (Kafka) · SQS | 실시간으로 여러 소비자가 받는 흐름이 없다. 수집은 일 배치, 리포트는 요청 안 SSE | 분석 대기가 1분을 넘어 줄 세우기가 필요할 때(SQS 먼저) |
| OpenSearch | 벡터 검색은 pgvector로 Hit@5 1.00(평가셋 실측), 8,905청크 규모 | 청크가 수백만 단위가 되거나 형태소 전문 검색이 요구될 때 |
| GPU 인스턴스 | 상시 GPU는 평시 수십 명 규모에 과하다. 임베딩은 요청의 약 1.3%, 처리량과 무관 | 로컬 폴백 모델이 API 폴백보다 확실히 좋을 때 |
| Route53 | 기존 DNS로 충분 | 헬스체크 기반 장애 조치 DNS·다중 리전이 필요할 때 |
| 영역 | 계획 | 실제 | 이유 |
|---|---|---|---|
| 시작일 | 10/14 | 10/12 | 팀 합의로 이틀 앞당김 |
| DB 가용성 | Multi-AZ 상시 | 단일 AZ, 10/16~10/22만 Multi-AZ | 장애 조치 실측이 필요한 기간에만 켠다 |
| 노드 확장 | Karpenter | Cluster Autoscaler | 애드온 수를 줄인다 |
| 배포 | CI에서 helm upgrade --atomic | Argo CD + Kustomize | 클러스터 API를 CI에 열지 않기 위해 — CI 권한을 이미지 푸시로만 줄였다 |
| LLM 폴백 | Gemini → 집 서버 로컬 모델 | Gemini → Opus, 집 서버 연결 안 함 | 운영이 개인 장비·회선에 의존하지 않게. 지원사업 검색은 임베딩 없이 규칙 순서로 |
| 비용 감시 | AWS Budgets·Cost Explorer | 어림 스크립트(켜진 리소스 × 공시 단가, 매일 1회) | 계정 정책상 비용 조회 API를 쓸 수 없다 |
| 알람 | ALB·DB·LLM·비용 | DB CPU·여유 스토리지 → 메일부터 | ALB 알람은 남은 일 |
| 수집 크론 | 8개 | 9개 | 지역 이벤트 수집기 추가 |
| SSE keep-alive | 넣기 | 안 넣음 | 실측 간격이 타임아웃보다 훨씬 짧다 |
| 층 | 늘리는 방법 | 트리거 | 반응 시간 |
|---|---|---|---|
| 화면·정적 자원 | Vercel CDN | 자동 | 즉시. 서버에서 빼는 것이 가장 싼 확장 |
| API 파드 | HPA (2~10개, 부하 테스트 때만 20개) | CPU 60% | 노드에 자리가 있으면 30초 안팎 (재시작 → 응답까지 1.5초 실측) |
| 노드 | Cluster Autoscaler (평소 최대 3대, 테스트 때만 상향) | 자리가 없어 대기 중인 파드 | 1~2분 |
| DB | 인덱스 → 연결 정리 → 캐시 → 인스턴스 키우기 → 읽기 복제본 순서 | 사람이 판단 | 분 단위. 가로로 쉽게 안 늘어난다 |
| LLM | 늘릴 수 없음 (계정 한도) | — | 동시 분석 상한으로 받아낼 양을 제한 |
| 설정 | 값 | 이유 |
|---|---|---|
| 대상 유형 | 파드 IP | 노드를 거치지 않고 파드로 바로, 파드 단위 헬스체크 |
| 분배 방식 | 처리 중 요청이 가장 적은 파드로 | 수 ms 조회와 수 초 SSE가 섞여 있어 순서대로 나누면 SSE가 몰린 파드만 느려진다 |
| 헬스체크 | 준비(readiness) 엔드포인트 | DB에 못 붙는 파드에는 안 보낸다 |
| 유휴 타임아웃 | 300초 (기본 60초) | SSE 리포트가 중간에 안 끊기게 |
| 등록 해제 지연 | 약 70초 (기본 300초) | 진행 중 요청을 마칠 시간. 기본값이면 배포·축소가 5분씩 늘어진다 |
| 고정 세션 | 끔 | 파드에 상태가 없다. 켜면 늘린 파드로 부하가 안 넘어간다 |
| 설정 | 값 | 이유 |
|---|---|---|
| 종료 전 대기(preStop) | 10초 | 로드밸런서가 대상을 빼기 전에 파드가 먼저 닫혀 502가 나는 것을 막는다 |
| 종료 유예 | 90초 | 대기 10초 + SSE + 여유 |
| 롤링 업데이트 | 동시 중단 0, 추가 1 | 새 파드가 준비를 통과한 뒤에야 옛 파드를 내린다 |
| PodDisruptionBudget | 최소 1개 | 노드 교체 때도 파드 하나는 늘 살아 있다 |
늘려도 안 빨라지는 곳: 파드는 늘릴 수 있지만 같이 쓰는 자원은 안 늘어납니다 — DB 연결 수(파드 수 × 풀 ≤ 최대 연결의 80%), DB CPU, LLM 계정 한도. 이 계산이 HPA 최대 파드 수를 정합니다.
main 푸시
├─ 백엔드 변경 → GitHub Actions
│ arm64 네이티브 빌드 → ECR :<git SHA>
│ 배포 선언(kustomization)의 이미지 태그를 그 SHA로 커밋
│ ▼
│ Argo CD (약 3분마다 main 확인)
│ PreSync: DB 마이그레이션 — 실패하면 여기서 멈추고 이미지는 그대로
│ → API 롤링 교체 + CronJob 9개 같은 이미지로
│ 새벽 수집 시간대는 동기화 금지 — 수집 중 마이그레이션 방지
└─ 프론트 변경 → Vercel
| 대상 | 방법 | 보관 |
|---|---|---|
| RDS 전체 | 자동 백업 + 트랜잭션 로그(시점 복구, RPO 약 5분) | 7일 |
| RDS 변경 전 | 위험한 변경 직전 수동 스냅샷 | 30일 |
| 이미지 | ECR, 태그 = git SHA (태그 덮어쓰기 금지) | 롤백 범위 |
| 인프라 | Terraform 코드(git) + 상태 파일(S3) | 무기한 |
장애 확인 순서: 증상 범위(전부 / 특정 API / AI 리포트만) → 최근 변경(있으면 원인 분석보다 롤백 먼저) → 로드밸런서 정상 대상 수 → 파드 → DB → 외부(LLM·공공 API) → 복구 확인 → 사후 기록. 사후 기록의 재발 방지 칸은 “조심한다”가 아니라 알람·테스트·코드 가드 중 하나여야 합니다.
오전
쿠버네티스 준비 브랜치를 main에 다시 얹고 Terraform·매니페스트·배포 워크플로 작성
정오 무렵
terraform apply — 리소스 92개. 애드온 설치 순서 문제를 고쳐 다시 적용
12:56
1차 DB 복원 69초 — 표 49개 행 수·벡터 표본 일치
13:07
첫 자동 배포 — ALB·HTTPS·API 확인
15:00
Argo CD(GitOps)로 전환 — CI의 클러스터 접근 제거, 첫 자동 배포 성공
15:10
프론트 Vercel 연결, www·최상위 도메인 DNS
15:28
컷오버 — 로컬 DB 최신본 재복원 72초(표 54개 일치), 15:35 로컬 수집 크론 9개 끔 → 수집은 AWS만
| 문제 | 원인 | 수정 | 재발 방지 |
|---|---|---|---|
| 애드온 설치 실패 | LB Controller 웹훅이 뜨기 전에 다른 차트가 Service를 만듦 | 재설치 | Terraform 의존 순서로 LB Controller 먼저 |
| 비용 알람 생성 실패 | 계정 정책이 비용 API를 막음 | 켜진 리소스 × 공시 단가 어림 스크립트 | 매일 1회 실행 |
| 배포가 “성공”인데 반영 안 됨 | CI의 클러스터 접속 실패를 “첫 배포”로 잘못 처리 | 접속 오류는 실패로 | Argo CD로 전환해 CI 접속 자체를 없앰 |
| 프론트가 가짜 데이터 사용 | NEXT_PUBLIC_ 값은 빌드 때 박히는데 첫 빌드 때 환경변수가 없었음 | 환경변수 확인 후 재배포 | 번들에 API 주소가 들어갔는지 확인 |
일부 기기에서 www가 안 열림 | 레코드를 넣기 전 조회한 “없음” 응답이 길게 캐시됨 | DNS 캐시 비우기 | — |
| 컷오버 직전 로컬 DB 스키마가 운영 코드보다 앞섬 | 팀원 기능이 로컬에만 먼저 적용 | 그 커밋을 먼저 배포해 버전을 맞춘 뒤 복원 | 복원 전 마이그레이션 버전 비교 |
로컬에서 10차까지 잰 시나리오를 그대로 운영 구성(ALB → EKS → RDS)에 걸었습니다. k6는 같은 리전의 별도 EC2에서 돌렸고, AI 리포트는 고정 지연을 주는 가짜 LLM으로 바꿔 서버 구조의 한계만 쟀습니다. 측정 전 DB 스냅샷을 떴고, 끝난 뒤 테스트 데이터를 지우고 구성을 원래대로 되돌렸습니다.
| 단계 | 구성 | 결과 | 먼저 찬 곳 |
|---|---|---|---|
| 11-1 | 처음 구성 그대로 (DB 2 vCPU, 파드 최대 10, 노드 최대 3) | 기준 지킨 천장 약 685 RPS(동접 2,000명), 약 900 RPS에서 포화 | DB CPU — DB 시간의 약 92%가 자금 계획 프리필의 서울 점포당 매출 중앙값 쿼리 하나 |
| 11-2 | DB 4 vCPU, 파드 최대 20, 노드 최대 8 | 1,033 RPS(동접 3,000명)까지 p95 38ms, 포화 안 함 | — (같은 850 RPS에서 DB CPU 82% → 36%) |
| 11-3 | 11-2와 같음, 계단만 4,500명까지 | 1,543 RPS, p95 51ms, 약 189만 요청 중 에러 3건 | 아직 포화 전 — 파드가 상한 20개에 닿음, DB CPU 67% |
| 날짜 | 할 일 |
|---|---|
| 첫 밤 AWS 수집 CronJob 확인 — 멈춘 두 곳을 고침 | |
| AWS 부하 테스트 11차(§10) — 준비가 일찍 끝나 앞당겼다. 측정 동안만 DB Multi-AZ를 켰다 | |
| 10/16~10/20 | LLM 차단 상태에서 폴백 리포트 확인, E2E 11단계 |
| 10/21~10/22 | Multi-AZ 다시 켜기 → 장애 조치·시점 복구·재구축 리허설로 RTO 실측 → Multi-AZ 끄기 |
| 10/23~10/26 | 실측 결과 정리, 발표 자료 |
| 10/27 밤 | 발표 뒤 서비스 내리기 |
| 10/28~10/31 | 증거·DB 덤프 내려받기 → terraform destroy → 남은 과금 0 확인 |
종료 순서가 중요한 이유: ALB는 Terraform이 아니라 쿠버네티스 Ingress가 만듭니다. 클러스터를 먼저 지우면 ALB가 고아로 남아 계속 과금되므로 앱(Argo CD 앱 → Ingress → ALB)부터 지우고 그다음 terraform destroy를 합니다.
| 위험 | 영향 | 대응 |
|---|---|---|
| NAT 1개 (한 가용 영역) | 그 영역이 죽으면 외부 호출(LLM·수집)이 멈춘다. 지도·판정 조회는 DB만 써서 계속 된다 | 평가 때 “알고 있는 단일 장애점”으로 설명 |
| DB 단일 AZ 기간 | DB 장애 시 스냅샷 복원 30~60분 | 부하 테스트(10/13~10/14)·리허설(10/21~10/22) 기간에 Multi-AZ |
| 서울 리전 전체 장애 | 24시간 | Terraform 재구축 — 다중 리전 상시 운영은 이 규모에 과하다 |
| 사양 산정이 틀릴 수 있다 | 11차로 확인 — 요청당 API CPU는 로컬의 약 2배(어림)였지만, 먼저 막힌 곳은 DB였고 DB 한 단계로 1,500 RPS를 받았다(§10) | |
| LLM 계정 한도·크레딧 | 스파이크 때 리포트 실패 (10/6 실제 발생) | 다른 제공자 폴백(Opus), 동시 분석 상한 |