AWS 다시 써보니 떠난 이유 더 명확함 — 3개월 회고

목차

AWS는 클라우드 시장 점유율 30%대를 유지하는 사실상의 표준이다. 새 회사 인프라가 AWS 기반이라 2년 만에 다시 만졌다. RDS PostgreSQL, ECS Fargate, S3, CloudFront, EventBridge, IAM — 익숙한 이름들이 전부 그대로 있었다. 다만 그 익숙함이 반갑기보다 답답했다.

그러나, 2024년 초 개인 프로젝트 비용이 한 달에 60만 원을 넘긴 뒤 AWS를 떠났다. GCP Cloud Run과 Cloudflare Workers 조합으로 2년 가까이 운영해왔다. 그러다 이직한 회사가 AWS 표준 환경이라 3개월 동안 다시 깊이 만질 일이 생겼다. 그 3개월의 기록이다.

다시 AWS로 돌아온 배경

새 프로젝트는 B2B SaaS 백엔드 리뉴얼이었다. 트래픽이 큰 편은 아니다. 평균 RPS 80, 피크 RPS 400 정도. 데이터는 좀 무거웠다. PostgreSQL 단일 인스턴스 기준 약 600GB가 쌓여 있었고, 매일 새로 들어오는 데이터가 3~5GB 수준이다.

따라서, 기존 인프라는 EC2 위에서 도커 컴포즈로 돌아가던 모놀리식 구조였다. 운영팀이 2년 넘게 쌓아둔 IAM 정책, VPC 구성, CloudFormation 템플릿이 있었다. 처음부터 다시 만든다는 선택지는 의미가 없었다. 기존 자산을 살리면서 컨테이너 기반으로 옮기는 게 합리적이었다.

실제로, 기술 스택은 ECS Fargate + RDS PostgreSQL 15 + CloudFront + S3로 정했다. EKS는 운영 인력이 부족해서 뺐다. Cloud Run을 GCP에서 잘 써본 경험이 있어서 비슷한 추상화 수준인 Fargate가 자연스러웠다.

리전은 ap-northeast-2(서울). 한국 트래픽이 대부분이라 다른 선택지는 없었다. 백업용 DR 리전은 ap-northeast-1(도쿄)로 잡았다. 여기까지는 별로 고민할 게 없었다.

첫 주 — 콘솔 UX는 2년 전 그대로였다

가장 먼저 든 생각이다. AWS 콘솔은 2년 전과 거의 같았다. 좌측 검색, 우상단 리전 선택, 서비스마다 따로 노는 UI. RDS에서 파라미터 그룹 만들고 ECS 콘솔로 갔다가 다시 CloudWatch 로그 그룹 보러 가는 동선이 여전히 길었다.

GCP 콘솔은 2년 동안 검색 기반 UI로 많이 정리됐다. 명령어 한 번이면 어디로든 갈 수 있다. AWS는 여전히 "RDS → 데이터베이스 → 인스턴스 → 모니터링" 같은 트리 구조다. 새 동료가 "Fargate 태스크 로그 어디서 봐요?"라고 물었을 때 "ECS 콘솔 → 클러스터 → 서비스 → 태스크 → Logs 탭" 다섯 번 클릭이라고 답하면서 한숨이 나왔다.

물론 익숙한 사람한테는 이게 강점이다. 5년 전에 익혀둔 동선이 그대로 통한다. 매뉴얼이 안 바뀌니까. 새로 들어온 주니어한테 가르치기 어려울 뿐.

CLI는 v2가 안정됐다. aws ecs update-service --force-new-deployment 같은 명령은 여전히 잘 동작하고, profile 관리도 익숙하다. 이 부분은 만족도가 높았다.

둘째 주 — IAM에서 막힌 4시간

또한, ECS 태스크에서 S3 버킷에 파일 업로드하는 코드를 짰다. 로컬에서는 잘 동작했다. Fargate에 배포하니 AccessDenied 가 떴다.

botocore.exceptions.ClientError: An error occurred (AccessDenied) 
when calling the PutObject operation: User: 
arn:aws:sts::123456789012:assumed-role/ecsTaskExecutionRole/xxx 
is not authorized to perform: s3:PutObject on resource: 
"arn:aws:s3:::my-bucket/uploads/file.pdf" because no identity-based 
policy allows the s3:PutObject action

이처럼, 문제는 ecsTaskExecutionRoletaskRoleArn을 헷갈렸다는 것이다. ECS Task Definition에는 역할이 두 개 들어간다.

  • executionRoleArn: 컨테이너를 실행하기 위해 ECR에서 이미지를 가져오고 CloudWatch에 로그를 쓰는 권한
  • taskRoleArn: 컨테이너 안에서 동작하는 애플리케이션이 AWS API를 호출할 때 쓰는 권한

S3 권한은 taskRoleArn에 붙여야 한다. 처음에 executionRoleArn에 정책을 붙이고 한참을 헤맸다. 2년 전에 분명히 알았던 내용인데 깡그리 까먹었다.

{
  "family": "api-server",
  "executionRoleArn": "arn:aws:iam::xxx:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::xxx:role/apiServerTaskRole",
  "containerDefinitions": [
    {
      "name": "api",
      "image": "xxx.dkr.ecr.ap-northeast-2.amazonaws.com/api:v1.2.3",
      "essential": true
    }
  ]
}

GCP에서는 서비스 계정 하나에 권한을 다 붙이면 끝난다. AWS의 이 분리는 보안적으로는 합리적이다. 컨테이너 런타임 권한과 애플리케이션 권한이 섞이면 위험할 수 있다. 합리적이지만 매번 헷갈리는 종류의 합리성이다.

그래서, 이걸 알아내는 데 4시간을 썼다. 공식 Task IAM Role 문서를 처음부터 다시 읽었다. 한 줄 차이인데 그 한 줄을 찾는 게 일이다.

한 달차 — 첫 청구서가 도착했을 때

그래서, 비용 청구서가 처음 떴을 때 표정이 굳었다. 예상보다 47% 비쌌다.

항목 예상 ($) 실제 ($) 차이
RDS PostgreSQL (db.r6g.large) 280 280 0%
ECS Fargate (4 vCPU 평균) 320 340 +6%
S3 (스토리지 + 요청) 80 95 +19%
Data Transfer Out 50 210 +320%
NAT Gateway 30 145 +383%
CloudWatch Logs 20 78 +290%
합계 780 1,148 +47%

NAT Gateway가 진짜 폭탄이었다

ECS 태스크가 프라이빗 서브넷에서 외부 API를 호출할 때마다 NAT Gateway를 통한다. 시간당 요금 + 처리량당 요금이 둘 다 붙는다. 서울 리전 기준 시간당 $0.059, GB당 $0.062 (2026년 5월 기준, 공식 가격표). 외부 결제 API 두 곳, 외부 이메일 발송, 외부 푸시 알림 — 이 트래픽이 전부 NAT Gateway로 흘렀다.

반면, 가장 어이없는 건 S3로 가는 트래픽도 NAT를 통하고 있었다는 점이다. VPC Endpoint를 안 만들어둬서 그렇다. Gateway Endpoint는 무료인데 깜빡한 거다.

CloudWatch Logs 보관 정책

그런데, CloudWatch Logs도 예상보다 비쌌다. 컨테이너 stdout을 전부 CloudWatch로 보냈는데, Ingestion 요금이 GB당 $0.50이다. 디버그 로그를 켜두고 며칠을 둔 결과였다. 로그 레벨을 INFO로 낮추고, 보관 기간을 90일에서 14일로 줄였다.

resource "aws_cloudwatch_log_group" "api" {
  name              = "/ecs/api-server"
  retention_in_days = 14  # 기본값 never expire에서 14일로
}

Terraform으로 모듈화해서 모든 로그 그룹에 일괄 적용했다. 이 한 줄로 다음 달 청구서에서 약 $50가 빠졌다.

Data Transfer Out 캐시 미스

Data Transfer Out은 CloudFront 캐시 미스 비율이 예상보다 높았던 게 원인이었다. 캐시 정책을 재검토했더니 쿼리 스트링 처리가 잘못돼서 캐시 키가 자꾸 갈라지고 있었다. 정렬 파라미터까지 캐시 키에 포함되어 있어서 같은 페이지인데도 다른 캐시로 잡혔다. 이건 정책 한 곳 고쳐서 해결.

2년 전에 떠난 이유가 정확히 이거다. AWS는 "예상 비용"을 미리 계산하기가 어렵다. 각 항목마다 별도 가격 페이지, 별도 단위, 별도 무료 티어가 있다. GCP나 Cloudflare는 가격표가 한 페이지에 거의 다 들어간다.

두 달차 — 빛났던 순간

비판만 쓰면 균형이 안 맞으니 강점도 짚고 가자. RDS Performance Insights는 정말 잘 만들었다.

결국, 운영 중에 갑자기 DB CPU가 80%를 찍었다. Performance Insights 들어가서 "Top SQL"을 봤더니 한 쿼리가 전체 부하의 60%를 먹고 있었다. 어떤 쿼리인지, 어떤 시간대에 몰리는지, 어떤 wait event에서 막히는지 한 화면에 다 보였다.

-- 문제가 됐던 쿼리
SELECT * FROM events 
WHERE created_at >= NOW() - INTERVAL '24 hours'
  AND user_id IN (
    SELECT id FROM users WHERE org_id = $1
  )
ORDER BY created_at DESC;

그래서, 서브쿼리가 매번 풀스캔을 돌고 있었다. (org_id, created_at) 복합 인덱스를 만들고 JOIN으로 다시 짰다. 진단부터 수정까지 5분이 안 걸렸다.

즉, 같은 진단을 GCP Cloud SQL에서 했다면 Query Insights를 켜고, pg_stat_statements를 직접 보고, slow query log를 활성화하고, EXPLAIN ANALYZE를 돌리는 흐름이었을 것 같다. 30분은 더 걸렸을 것이다.

즉, ECR도 마찬가지다. Vulnerability Scanning이 기본 내장돼 있고, ECS와의 연동이 매끄럽다. 푸시 직후 자동으로 스캔이 돌고 critical 취약점이 있으면 콘솔에서 바로 보인다. CI 파이프라인에 직접 보안 스캔을 넣을 필요가 없다.

실제로, 인프라 팀이 잘 갖춰진 조직에서 AWS의 강점은 이런 데서 나온다. 자잘한 통합이 매끄럽고, 운영 도구가 잘 만들어져 있고, 사례가 많다. 혼자 개인 프로젝트 돌릴 때는 절대 못 느끼는 부분이다.

세 달차 — Fargate와 Cloud Run의 체감 차이

그런데, 3개월 정도 굴려보니 비교가 가능해졌다. 같은 종류의 워크로드를 두 플랫폼에서 다 돌려본 사람으로서 솔직한 체감이다.

항목 ECS Fargate Cloud Run
초기 설정 시간 2~4시간 (Task Def, ALB, IAM 등) 약 15분 (yaml 한 장)
콜드 스타트 약 30~60초 (이미지 풀 포함) 약 2~5초
단일 컨테이너 비용 (월) 약 $50 (always-on 가정) 약 $15 (요청 기반)
Scale to zero 불가 가능
VPC 통합 네이티브 추가 설정 필요
로깅 비용 CloudWatch 별도 과금 관대한 무료 티어
이미지 레지스트리 ECR (통합 매끄러움) Artifact Registry (괜찮음)

결국, 요점은 "어느 게 더 좋다"가 아니다. 워크로드 성격에 따라 다르다. Always-on이 필요하고, VPC 통합이 중요하고, 인프라 팀이 있다면 Fargate가 자연스럽다. 트래픽 변동이 크고, 빠르게 띄우고 내려야 하고, 운영 인력이 적다면 Cloud Run이 편하다.

한편, 회사 워크로드는 always-on이 맞다. 트래픽이 0으로 떨어지는 일이 거의 없다. Fargate 선택은 잘했다고 본다. 개인 프로젝트에서는 절대 Fargate를 쓰지 않을 것 같다.

결국 어떻게 됐나

3개월 끝에 시스템은 안정적으로 돌고 있다. 마이그레이션은 큰 사고 없이 끝났다. 누적 다운타임은 약 7분, 데이터 손실은 없었다.

비용은 두 달째부터 안정됐다. NAT Gateway 트래픽 일부를 VPC Endpoint로 우회시켰고, CloudWatch Logs 보관 기간을 줄였고, CloudFront 캐시 설정을 손봤다. 한 달에 약 $900 수준으로 떨어졌다. 첫 청구서 대비 22% 감소.

그래서, GCP+Cloudflare 시절 비슷한 규모의 워크로드를 굴렸을 때는 한 달에 약 $400이 들었다. 같은 회사 워크로드를 GCP로 옮긴다면 절반은 절약될 것으로 보인다. 그러려면 인프라를 처음부터 다시 짜야 하고, 운영팀 학습 비용이 추가된다. 그 비용이 절약분보다 클 수도, 작을 수도 있다. 조직마다 답이 갈린다.

게다가, 남은 숙제:

  • Cost Explorer 자동화: 매주 카테고리별 비용 알림을 EventBridge + Lambda로 보내는 구성. 아직 적용 안 됨.
  • Reserved Instance vs Savings Plans: 1년 약정으로 RDS와 Fargate를 합쳐 약 30% 절감 가능. 사용 패턴이 안정되면 적용 예정.
  • 멀티 AZ 비용: 현재 단일 AZ로 운영 중. 가용성 SLA 합의 후 결정.

다음 결정을 위한 메모

회고 끝나고 손에 쥔 판단 기준이다. 같은 상황이라면 지금 당장 할 수 있는 액션이다.

  1. NAT Gateway 트래픽 점검: VPC Flow Logs를 14일만 켜서 어떤 트래픽이 NAT를 통과하는지 본다. S3와 DynamoDB는 Gateway Endpoint로 우회 가능하고 무료다. 다른 AWS 서비스는 Interface Endpoint를 검토할 수 있다. NAT 비용의 50% 이상이 깎이는 경우가 흔하다.
  2. CloudWatch Logs 보관 기간 점검: 모든 로그 그룹의 retention 기본값을 한 번에 본다. Terraform 모듈로 14일 강제 적용하면 한 줄로 끝난다.
  3. Cost Anomaly Detection 활성화: 무료다. AWS 콘솔 → Billing → Cost Anomaly Detection. 한 달에 $50 이상 비정상 증가가 감지되면 메일이 온다. 청구서 보고 놀라는 일을 줄여준다.

특히, 새 프로젝트를 시작한다면 클라우드 선택 기준은 이렇게 단순화됐다.

  • 인프라 팀이 있고 운영 표준이 AWS다 → AWS
  • 작은 팀이고 빠르게 띄우고 싶다 → GCP + Cloudflare
  • 정적/엣지 워크로드 비중이 크다 → Cloudflare
  • 데이터 분석이 핵심이다 → GCP (BigQuery)

특히, AWS가 나쁜 도구라는 말은 아니다. 강력하고 완성도 높고 생태계가 크다. 그 강력함을 다 활용하려면 전담 인력이 필요하다. 혼자서 절반만 써야 한다면 비용 대비 효율이 떨어진다. 이게 2년 전 떠난 이유였고, 다시 만져보니 그 판단이 더 명확해졌다.

예를 들어, 다만 인프라 표준이 정해진 큰 조직에서 AWS가 가장 합리적인 선택인지는 1년쯤 더 굴려봐야 답이 나올 것 같다.

관련 글