서버 이전 계획 및 실행 — 집 서버에서 AWS EKS로

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개 → 데이터 복원 → 자동 배포 → 컷오버

DB 이전

72초 컷오버 복원 시간 · 표 54개 행 수 로컬과 일치

자동 배포

3회 10/12 · Argo CD · DB 마이그레이션 4개 모두 PreSync로 적용

1. 무엇을 만족해야 하나 — 요구사항

아키텍처 그림보다 먼저 숫자를 정했습니다. 아래 숫자가 바뀌면 뒤의 선택도 바뀝니다.

항목 값 근거
동시접속 평시 피크 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으로 다른 리전에 재구축 — 알고 수용하는 위험

2. 설계 기준 — 선택이 갈리면 위가 이긴다

순위 기준 이 기준 때문에 내린 결정
1 운영 복잡도 최소화 — 전담 운영자 없는 소규모 팀이 밤에 깨지 않고 돌릴 수 있어야 한다 상태가 있는 것은 관리형으로(RDS·ALB·Secrets Manager). 측정으로 필요가 확인되기 전에는 서비스를 늘리지 않는다
2 장애 격리 — LLM·수집기·관리자 기능이 실패해도 지도·판정 조회는 산다 크론은 API와 별도 파드, 파드별 자원 상한, LLM 실패 시 폴백
3 비용 예측 가능성 상시 GPU 없음, 노드·파드 최대 수 상한
4 가용성은 99.5%에서 멈춘다 NAT 1개·DB 단일 AZ로 시작, 증거가 필요한 기간에만 Multi-AZ

개발 저장소의 원칙 “확인된 요구 없이 복잡도를 늘리지 않는다”를 인프라에 그대로 적용한 것입니다.

3. 이전 전에 코드에서 고친 것

쿠버네티스는 “파드는 언제든 죽고, 여러 개가 동시에 뜬다”를 전제로 합니다. 이 전제와 부딪히는 곳을 이전 전에 로컬에서 먼저 고쳤습니다.

지금 코드 쿠버네티스에서 생기는 문제 해결
분석 대기 장부가 프로세스 메모리 파드가 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초에 한참 못 미친다

4. 지금 운영 중인 구성

브라우저
 ├─ 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·다중 리전이 필요할 때

5. 계획과 실제 — 무엇이 달라졌나

영역 계획 실제 이유
시작일 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 넣기 안 넣음 실측 간격이 타임아웃보다 훨씬 짧다

6. 확장과 로드밸런싱

층별 확장 — 무엇을, 무엇을 보고, 얼마나 빨리

층 늘리는 방법 트리거 반응 시간
화면·정적 자원 Vercel CDN 자동 즉시. 서버에서 빼는 것이 가장 싼 확장
API 파드 HPA (2~10개, 부하 테스트 때만 20개) CPU 60% 노드에 자리가 있으면 30초 안팎 (재시작 → 응답까지 1.5초 실측)
노드 Cluster Autoscaler (평소 최대 3대, 테스트 때만 상향) 자리가 없어 대기 중인 파드 1~2분
DB 인덱스 → 연결 정리 → 캐시 → 인스턴스 키우기 → 읽기 복제본 순서 사람이 판단 분 단위. 가로로 쉽게 안 늘어난다
LLM 늘릴 수 없음 (계정 한도) — 동시 분석 상한으로 받아낼 양을 제한

ALB 설정 — 기본값에서 바꾼 것

설정 값 이유
대상 유형 파드 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 최대 파드 수를 정합니다.

7. 배포 흐름

main 푸시
 ├─ 백엔드 변경 → GitHub Actions
 │    arm64 네이티브 빌드 → ECR :<git SHA>
 │    배포 선언(kustomization)의 이미지 태그를 그 SHA로 커밋
 │         ▼
 │    Argo CD (약 3분마다 main 확인)
 │      PreSync: DB 마이그레이션 — 실패하면 여기서 멈추고 이미지는 그대로
 │      → API 롤링 교체 + CronJob 9개 같은 이미지로
 │      새벽 수집 시간대는 동기화 금지 — 수집 중 마이그레이션 방지
 └─ 프론트 변경 → Vercel
  • 클러스터를 손으로 바꾸면 Argo CD가 저장소 상태로 되돌립니다. 파드 수만은 HPA 몫이라 비교에서 뺐습니다.
  • 롤백은 이미지 태그를 이전 SHA로 커밋하면 됩니다. DB 스키마는 되돌리지 않는 것을 원칙으로 확장 → 전환 → 정리 순서로 바꿔, 앱만 롤백해도 DB와 맞습니다.
  • 10/12에 자동 배포 3회, 마이그레이션 4개가 PreSync로 모두 적용됐습니다.

8. 백업·복구·모니터링

대상 방법 보관
RDS 전체 자동 백업 + 트랜잭션 로그(시점 복구, RPO 약 5분) 7일
RDS 변경 전 위험한 변경 직전 수동 스냅샷 30일
이미지 ECR, 태그 = git SHA (태그 덮어쓰기 금지) 롤백 범위
인프라 Terraform 코드(git) + 상태 파일(S3) 무기한

장애 확인 순서: 증상 범위(전부 / 특정 API / AI 리포트만) → 최근 변경(있으면 원인 분석보다 롤백 먼저) → 로드밸런서 정상 대상 수 → 파드 → DB → 외부(LLM·공공 API) → 복구 확인 → 사후 기록. 사후 기록의 재발 방지 칸은 “조심한다”가 아니라 알람·테스트·코드 가드 중 하나여야 합니다.

9. 10월 12일 하루 기록

오전

쿠버네티스 준비 브랜치를 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. AWS에서 다시 잰 부하 — 11차 (10/13~10/14)

로컬에서 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%
  • TPS 1,500 눈금의 103%, 로컬 최고(9차 약 925 RPS)의 약 1.7배
  • 로컬에서는 API CPU가 먼저 찼지만 AWS에서는 DB가 먼저 찼다. API는 파드를 늘려 옆으로 넓어지지만 DB는 한 대이기 때문이다(§6의 “DB는 가로로 쉽게 안 늘어난다”가 실측으로 확인됐다)
  • 11-3 구성에서 DB 다음 단계(8 vCPU)는 쓰지 않았다. 11-2에서 DB CPU가 44%라 필요하지 않았다
  • 원래 크기로 되돌렸다: DB 2 vCPU·단일 AZ, 파드 최대 10, 가짜 LLM 끔, k6 EC2 삭제. 큰 트래픽이 예상되면 11-2·11-3 구성이 근거가 된다
  • 계단별 표와 로컬과의 비교는 부하 테스트 §4에 있다

11. 남은 일정과 종료 절차

날짜 할 일
10/13 완료 첫 밤 AWS 수집 CronJob 확인 — 멈춘 두 곳을 고침
10/17~10/20 10/13~10/14 완료 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를 합니다.

12. 알고 수용한 위험

위험 영향 대응
NAT 1개 (한 가용 영역) 그 영역이 죽으면 외부 호출(LLM·수집)이 멈춘다. 지도·판정 조회는 DB만 써서 계속 된다 평가 때 “알고 있는 단일 장애점”으로 설명
DB 단일 AZ 기간 DB 장애 시 스냅샷 복원 30~60분 부하 테스트(10/13~10/14)·리허설(10/21~10/22) 기간에 Multi-AZ
서울 리전 전체 장애 24시간 Terraform 재구축 — 다중 리전 상시 운영은 이 규모에 과하다
로컬 → AWS 성능 환산 계수(×1.8) 미검증 사양 산정이 틀릴 수 있다 11차로 확인 — 요청당 API CPU는 로컬의 약 2배(어림)였지만, 먼저 막힌 곳은 DB였고 DB 한 단계로 1,500 RPS를 받았다(§10)
LLM 계정 한도·크레딧 스파이크 때 리포트 실패 (10/6 실제 발생) 다른 제공자 폴백(Opus), 동시 분석 상한

팀 Beyond Facade — Metabole 창업 경고 서비스 개발 문서. 경고 없음은 성공·안전·추천을 보장하지 않습니다.

창업 경고의 근거와 구현 기록 전체 문서 검증 결과 최종 산출물