Saas 서비스 개발 회고록

Yono·3일 전

4개의 서브 프로젝트를 Terraform + AWS로 운영하기까지

병원 마케팅 SaaS 플랫폼의 인프라를 코드로 관리한 이야기


들어가며

"이거 왜 콘솔에서 직접 만들었어?"

혼자서 프로젝트를 시작할 때는 AWS 콘솔을 클릭하는 게 빠르다. 그런데 서비스가 하나씩 늘어나면서 문제가 생겼다. API 서버, 크롤러, 프론트엔드 2개까지 총 4개의 서브 프로젝트. 거기에 dev/prod 두 환경. 어떤 보안그룹이 어디에 붙어 있는지, 환경변수가 정확히 뭐였는지 기억이 나지 않기 시작했다.

그래서 Terraform으로 전환했다. 이 글은 그 과정에서 내린 기술적 결정들과, 지금 운영 중인 인프라의 전체 구조를 정리한 기록이다.


목차

  1. 무엇을 만들고 있나
  2. 서비스별 구조 뜯어보기
  3. 인프라 전체 아키텍처
  4. Terraform 모듈 설계
  5. 네트워크와 보안
  6. 환경 분리 전략
  7. ECS 서비스 구성
  8. 정적 자산과 시크릿
  9. 배포 파이프라인
  10. 비용 최적화
  11. 회고

1. 무엇을 만들고 있나

병원 마케팅을 자동화하는 SaaS 플랫폼이다. 네이버 키워드 순위를 추적하고, 플레이스 분석을 돌리고, 블로그 노출 현황까지 추적한다. 이 플랫폼은 역할에 따라 4개의 독립적인 프로젝트로 나뉜다.

project-root/
├── api/                # Spring Boot REST API
├── crawler/            # Playwright 기반 크롤링 워커
├── front/              # Next.js 관리자 대시보드
├── site/               # Next.js 공식 마케팅 사이트
├── terraform/          # 인프라 코드
└── docker-compose.yml  # 로컬 개발 환경
프로젝트기술 스택배포 대상한 줄 요약
APIJava 21, Spring Boot 3.5ECS Fargate모든 비즈니스 로직의 중심
CrawlerJava 21, PlaywrightECS Fargate네이버 스크래핑 워커
FrontNext.js 16, React 19Vercel관리자가 매일 보는 대시보드
SiteNext.js 16Vercel서비스 소개 공식 사이트

각각이 존재하는 이유가 있다. 다음 섹션에서 하나씩 살펴보겠다.


2. 서비스별 구조 뜯어보기

API 서버 — 플랫폼의 심장

모든 클라이언트가 이 서버를 통해 데이터를 주고받는다. 인증, 병원 관리, 크롤링 요청, 결과 조회, 파일 업로드까지 전부 여기서 처리한다.

api/
├── auth/          # JWT 로그인 & 토큰 리프레시
├── user/          # 시스템 사용자 (BCrypt 암호화)
├── hospital/      # 병원 CRUD
├── crawl/         # 크롤링 잡 생성 & 상태 추적
├── result/        # 결과 데이터 (키워드, 플레이스, 블로그, 방문자)
├── adlog/         # 외부 광고 분석 API 연동
├── article/       # 공식 사이트용 아티클 CMS
├── asset/         # S3 Presigned URL 생성
├── dashboard/     # 통계 집계
├── traffic/       # 광고비 데이터 (엑셀 업로드)
└── viral/         # 바이럴 콘텐츠 관리

기술 선택:

  • Java 21 + Spring Boot 3.5 + Spring Security
  • PostgreSQL 15 + JWT 인증 + Bucket4j Rate Limiting
  • AWS S3 SDK v2 + Flyway 마이그레이션

여기서 가장 중요한 설계 결정은 크롤링을 비동기로 처리한 것이다. 클라이언트가 크롤링을 요청하면, API는 DB에 PENDING 상태의 잡을 만들고 바로 202 Accepted를 돌려준다. 실제 크롤링은 Crawler가 담당한다. 이렇게 하면 API 서버가 무거운 브라우저 작업에 묶이지 않는다.


Crawler — Headless 브라우저를 돌리는 워커

이 서비스의 존재 이유는 단 하나, 네이버에서 데이터를 긁어오는 것이다. Playwright로 Headless Chromium을 띄우고, 키워드 순위 · 플레이스 정보 · 블로그 노출 여부 등을 수집한다.

crawler/
├── poller/
│   └── JobPoller              # 5초마다 DB에서 PENDING 잡 확인
├── executor/
│   └── JobExecutor            # 잡 실행 & 결과 저장
├── scheduler/
│   ├── HeartbeatScheduler     # RUNNING 잡의 생존 신호 갱신
│   └── StaleJobRecovery       # 좀비 잡 자동 복구
├── scraper/
│   ├── HiddenKeywordScraper   # 키워드 순위 추적
│   ├── PlaceAnalyzer          # 플레이스 상세 분석
│   ├── BlogAnalyzer           # 블로그 노출 확인
│   └── PlaceRankComparisonScraper  # 경쟁사 순위 비교
└── ai/
    └── ReviewSentimentAnalyzer # OpenAI로 부정 리뷰 자동 감지

Crawler의 동작 흐름을 그림으로 보면 이렇다:

JobPoller (5초 간격으로 DB 폴링)
    │
    ▼
PENDING 잡 있나? ──No──► 대기
    │ Yes
    ▼
상태를 RUNNING으로 변경 + 하트비트 시작
    │
    ▼
Playwright로 스크래핑 실행
    ├── 키워드 순위
    ├── 플레이스 정보
    ├── 블로그 노출
    └── 리뷰 감성 분석 (OpenAI)
    │
    ▼
결과 DB 저장 + 상태 COMPLETED
    │
    ▼
실패 시 Slack 알림

여기서 신경 쓴 부분이 안정성이다. 크롤러가 도중에 죽으면 어떻게 될까?

  • 하트비트: RUNNING 상태의 잡은 주기적으로 "나 살아있어" 신호를 보낸다.
  • 좀비 잡 복구: 10분 이상 하트비트가 없으면 "이 잡은 죽었다"고 판단하고 PENDING으로 되돌린다. 그러면 다음 폴링에서 다시 실행된다.
  • Rate Limiting: Bucket4j로 요청 속도를 제한해서, 크롤링 대상에 부담을 주지 않도록 했다.

리소스는 API의 2배를 할당했다 (CPU 1024, Memory 2048MB). Chromium 하나 띄우는 데 꽤 많은 메모리가 필요하다. Docker 이미지도 시스템 폰트와 Chromium을 포함해 약 800MB 정도 된다.


Front — 관리자 대시보드

병원 운영자와 내부 관리자가 매일 사용하는 웹 대시보드다. 크롤링 결과를 차트로 보여주고, 트래픽을 분석하고, 바이럴 콘텐츠를 관리한다.

front/
├── app/
│   ├── (auth)/login/              # 로그인
│   ├── (dashboard)/               # 일반 사용자 영역
│   │   ├── blog/                  #   블로그 관리
│   │   ├── hospital/              #   병원 관리
│   │   ├── traffic/               #   트래픽 통계 (Recharts)
│   │   └── viral/                 #   바이럴 콘텐츠
│   ├── (admin)/admin/             # 관리자 전용 영역
│   │   ├── accounts/              #   계정 관리
│   │   ├── crawling/              #   크롤링 현황
│   │   └── hospitals/             #   병원 관리 (어드민)
│   ├── (pdf)/pdf-preview/         # PDF 리포트 출력
│   └── api/                       # Route Handler (API 프록시)
└── components/

기술 선택: Next.js 16 (App Router) + TanStack Query v5 + Recharts + Tailwind CSS v4

여기서 눈여겨볼 점은 Route Group으로 역할을 분리한 것이다. (dashboard)는 일반 사용자, (admin)은 관리자 전용이다. Next.js의 괄호 라우팅 덕분에 URL에는 영향을 주지 않으면서 레이아웃과 권한을 깔끔하게 나눌 수 있었다.

또한 클라이언트가 백엔드 API를 직접 호출하지 않고, Next.js Route Handler를 프록시로 사용한다. 이렇게 하면 API 서버의 실제 주소가 브라우저에 노출되지 않고, 서버 사이드에서 토큰 갱신 같은 처리도 할 수 있다.


Site — 공식 마케팅 사이트

서비스를 소개하는 공식 웹사이트. 가장 단순한 프로젝트다.

site/
└── src/app/
    ├── page.tsx          # 랜딩 페이지
    ├── about/            # 회사 소개
    ├── magazine/         # 블로그/아티클
    ├── ranking/          # 순위 정보
    └── contact/          # 문의하기

Next.js 16 + Tailwind CSS v4로 만들었고, 커스텀 디자인 시스템(Pretendard 폰트, 4px 단위 스페이싱, Blue primary 컬러)을 자체 구축했다. UI 라이브러리 없이 전부 Tailwind로 구현했는데, 페이지 수가 적어서 이 정도면 충분했다.


서비스 간 관계

4개 서비스가 어떻게 연결되는지 한눈에 보면:

                    ┌──────────┐
                    │   Site   │  (독립적, 아티클만 API에서 조회)
                    └──────────┘

┌──────────┐        ┌──────────┐        ┌──────────┐
│  Front   │──API──►│   API    │──잡──►│ Crawler  │
│ 대시보드  │◄─결과──│  Server  │◄─결과──│  워커    │
└──────────┘        └────┬─────┘        └────┬─────┘
                         │                    │
                    ┌────▼─────┐              │
                    │PostgreSQL│◄─────────────┘
                    └──────────┘

정리하면:

  • Front ↔ API: 모든 데이터를 API를 통해 주고받음
  • API → Crawler: DB를 매개로 한 비동기 잡 패턴
  • Site: 독립적, 아티클 조회 시에만 API 호출

3. 인프라 전체 아키텍처

이제 이 서비스들이 어디서, 어떻게 돌아가는지 보자.

                        ┌──────────────┐
                        │   Route 53   │
                        │  (DNS 관리)   │
                        └──────┬───────┘
                               │
                 ┌─────────────┼─────────────┐
                 │             │             │
                 ▼             ▼             ▼
          ┌──────────┐  ┌──────────┐  ┌──────────┐
          │   ALB    │  │CloudFront│  │  Vercel  │
          │ (HTTPS)  │  │  (CDN)   │  │(Frontend)│
          └────┬─────┘  └────┬─────┘  └──────────┘
               │             │
               │             ▼
               │        ┌─────────┐
               │        │   S3    │
               │        │ (Assets)│
               │        └─────────┘
               │
    ┌──────────┴──────────────────────────┐
    │              VPC                     │
    │                                      │
    │   ┌─ Public Subnets (2 AZ) ──────┐  │
    │   │  ALB  +  NAT Gateway          │  │
    │   └──────────┬────────────────────┘  │
    │              │                       │
    │   ┌─ Private Subnets (2 AZ) ─────┐  │
    │   │                               │  │
    │   │   ┌───────────────────────┐   │  │
    │   │   │     ECS Fargate       │   │  │
    │   │   │                       │   │  │
    │   │   │  ┌─────┐  ┌────────┐ │   │  │
    │   │   │  │ API │  │Crawler │ │   │  │
    │   │   │  │:8080│  │ :8081  │ │   │  │
    │   │   │  └─────┘  └────────┘ │   │  │
    │   │   └───────────┬───────────┘   │  │
    │   │               │               │  │
    │   │          ┌────▼─────┐         │  │
    │   │          │   RDS    │         │  │
    │   │          │PostgreSQL│         │  │
    │   │          └──────────┘         │  │
    │   └───────────────────────────────┘  │
    └──────────────────────────────────────┘

    ┌──────────┐  ┌────────────┐  ┌───────────┐
    │   ECR    │  │  Secrets   │  │CloudWatch │
    │(Registry)│  │  Manager   │  │  (Logs)   │
    └──────────┘  └────────────┘  └───────────┘

왜 이런 구조인가?

ECS Fargate를 택한 이유. EC2를 직접 관리하고 싶지 않았다. Fargate는 태스크 단위로 CPU/Memory를 지정할 수 있어서, API(512 CPU / 1GB)와 Crawler(1024 CPU / 2GB)에 각각 다른 스펙을 줄 수 있었다. 서버 패치, 스케일링 정책 같은 건 신경 쓰지 않아도 된다.

프론트엔드를 Vercel에 분리한 이유. Next.js 앱 2개를 ECS에 올릴 수도 있었지만, Vercel의 자동 배포와 Edge Network가 너무 편했다. git push 하나로 Preview URL이 생기고, 머지하면 프로덕션에 반영된다. 이걸 ECS로 하려면 Dockerfile, Task Definition, 서비스 업데이트까지 직접 해야 한다.

CloudFront + S3 조합. 이미지 등 정적 자산은 S3에 넣고 CloudFront로 배포한다. OAC(Origin Access Control)로 S3 직접 접근은 차단하고, CloudFront를 통해서만 접근 가능하게 했다.


4. Terraform 모듈 설계

인프라를 10개의 재사용 가능한 모듈로 나눴다. 환경(dev/prod)별로 모듈을 호출하는 구조다.

terraform/
├── dev/
│   ├── main.tf          # 모듈 호출 & 환경별 변수
│   ├── provider.tf      # AWS 프로바이더
│   ├── variables.tf     # 입력 변수
│   └── outputs.tf       # 출력값
├── prod/
│   └── (동일 구조)
└── modules/
    ├── vpc/             # VPC, 서브넷, IGW, NAT, 라우팅
    ├── security-group/  # ALB/ECS/RDS 보안그룹
    ├── alb/             # 로드밸런서, 타겟그룹, 리스너
    ├── ecs/             # Fargate 클러스터, 태스크, 서비스
    ├── rds/             # PostgreSQL 인스턴스
    ├── ecr/             # 컨테이너 레지스트리
    ├── dns/             # Route 53, ACM 인증서
    ├── assets/          # S3 + CloudFront
    ├── secrets/         # Secrets Manager
    └── bastion/         # SSH 터널 호스트 (선택적)

각 모듈이 하는 일

모듈주요 리소스한 줄 설명
vpcVPC, Subnet, IGW, NAT GW2개 AZ에 걸친 public/private 네트워크
security-groupSG x 3ALB → ECS → RDS 체인 형태의 접근 제어
albALB, Target Group, ListenerHTTPS 종단 + 헬스체크
ecsCluster, Task Def, ServiceAPI + Crawler 컨테이너 오케스트레이션
rdsDB InstancePostgreSQL 15, 자동 스토리지 스케일링
ecrRepository x 2컨테이너 이미지 저장 + 오래된 이미지 자동 정리
dnsHosted Zone, ACM Cert와일드카드 SSL 인증서, DNS 검증
assetsS3, CloudFront, OAC정적 자산 업로드 + CDN 배포
secretsSecrets ManagerDB 비밀번호, API 키 등 민감정보 관리
bastionEC2, Key Pair개발 시 RDS 직접 접근용 (필요할 때만 생성)

모듈 간 의존 관계

         dns (Route 53 + ACM)
              │
    ┌─────────┼──────────┐
    ▼         ▼          ▼
   alb     assets      (Vercel)
    │     (S3+CF)
    │
    ▼
   vpc ──► security-group ──► ecs ──► rds
                                │
                              ecr
                                │
                            secrets

이렇게 모듈화하면 좋은 점은, staging 환경이 필요해지면 staging/main.tf 하나만 추가하면 된다는 것이다. 모듈은 이미 검증되어 있으니 변수만 바꿔주면 된다.


5. 네트워크와 보안

VPC 구조

2개 가용영역(AZ)에 걸쳐 public/private 서브넷을 구성했다.

VPC (10.x.0.0/16)
│
├── Public Subnet AZ-a  (10.x.1.0/24)  ── ALB, NAT Gateway
├── Public Subnet AZ-c  (10.x.2.0/24)  ── ALB (고가용성)
├── Private Subnet AZ-a (10.x.3.0/24)  ── ECS, RDS
└── Private Subnet AZ-c (10.x.4.0/24)  ── ECS, RDS (고가용성)

NAT Gateway는 비용 절감을 위해 하나만 배치했다. AZ-a가 죽으면 Private Subnet의 아웃바운드 트래픽이 막히는 트레이드오프가 있지만, 현재 트래픽 규모에서는 감수할 만하다고 판단했다.

보안그룹 체인

트래픽이 한 방향으로만 흐르도록 보안그룹을 체인으로 구성했다.

인터넷 (0.0.0.0/0)
    │ HTTP/HTTPS
    ▼
┌─────────┐
│   ALB   │ ◄── 80, 443만 허용
└────┬────┘
     │ :8080
     ▼
┌─────────┐
│   ECS   │ ◄── ALB 보안그룹에서만 접근 허용
└────┬────┘
     │ :5432
     ▼
┌─────────┐
│   RDS   │ ◄── ECS + Bastion 보안그룹에서만 접근 허용
└─────────┘

핵심은 RDS가 절대 인터넷에 노출되지 않는다는 것이다. Private Subnet에 있고, ECS 또는 Bastion에서만 접근 가능하다.

개발 중 DB에 직접 접근해야 할 때는 Bastion 호스트를 통한 SSH 터널을 쓴다:

ssh -i key.pem -L 5432:rds-endpoint:5432 ec2-user@bastion-ip

Bastion은 create_bastion = true 변수로 필요할 때만 띄우고, 평소에는 꺼둔다.


6. 환경 분리 전략 (Dev / Prod)

Terraform 모듈을 활용해 dev와 prod를 완전히 분리했다.

항목DevProd
VPC CIDR10.0.0.0/1610.1.0.0/16
컨테이너 이미지 태그dev-latestprod-latest
RDS Multi-AZ비활성활성 가능
RDS 백업 보존7일14일
API 리소스512 CPU / 1024MB환경에 맞게 조절
Crawler 리소스1024 CPU / 2048MB환경에 맞게 조절

모든 걸 따로 만들지는 않았다

ECR(컨테이너 레지스트리)과 Route 53 호스팅 존은 dev에서 한 번만 만들고, prod에서는 참조만 한다.

# prod/main.tf
data "aws_ecr_repository" "api" {
  name = "project/api"
}

같은 ECR 저장소에 dev-latestprod-latest 태그로 이미지를 구분한다. 저장소를 환경마다 따로 만들면 관리 포인트만 늘어난다.

Terraform 상태 관리

terraform {
  backend "s3" {
    bucket         = "project-terraform-state"
    key            = "dev/terraform.tfstate"   # prod는 "prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "project-terraform-lock"
    encrypt        = true
  }
}

S3에 상태 파일을 저장하고, DynamoDB로 동시 수정을 방지한다. 혼자 작업해도 이건 꼭 설정해두는 게 좋다. 실수로 두 터미널에서 동시에 terraform apply를 치면 상태 파일이 깨질 수 있기 때문이다.


7. ECS 서비스 구성

Task Definition 예시 (API)

resource "aws_ecs_task_definition" "api" {
  family                   = "${var.project}-api"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = var.api_cpu      # 512
  memory                   = var.api_memory   # 1024

  container_definitions = jsonencode([{
    name  = "api"
    image = "${var.ecr_url}/project/api:${var.image_tag}"
    portMappings = [{ containerPort = 8080 }]

    healthCheck = {
      command = ["CMD-SHELL",
        "curl -f http://localhost:8080/actuator/health || exit 1"]
    }

    environment = [
      { name = "SPRING_DATASOURCE_URL",
        value = "jdbc:postgresql://${var.db_host}:5432/${var.db_name}" },
      { name = "S3_BUCKET_NAME", value = var.s3_bucket },
      # ...
    ]

    secrets = [
      { name = "DB_PASSWORD",
        valueFrom = "${var.secret_arn}:DB_PASSWORD::" },
      { name = "JWT_SECRET",
        valueFrom = "${var.secret_arn}:JWT_SECRET::" },
      # ...
    ]
  }])
}

포인트는 environmentsecrets의 분리다. 일반 설정은 environment로, DB 비밀번호나 API 키 같은 민감정보는 secrets로 Secrets Manager에서 직접 가져온다. 컨테이너가 시작될 때 ECS가 자동으로 주입해준다.

Crawler Dockerfile

크롤러는 Chromium을 포함해야 해서 이미지 빌드가 좀 특이하다:

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./gradlew bootJar

FROM eclipse-temurin:21-jdk
# Playwright가 필요로 하는 시스템 라이브러리
RUN apt-get update && apt-get install -y \
    libglib2.0-0 libnss3 libatk1.0-0 ...
# 빌드 시점에 Chromium 설치 (런타임에 다운로드하지 않음)
RUN npx playwright install chromium
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

멀티 스테이지 빌드로 Gradle 빌드와 런타임 이미지를 분리했다. Playwright 브라우저는 빌드 시점에 미리 설치해서, 컨테이너 시작 시 다운로드 시간을 없앴다.

워커 패턴: 왜 SQS 대신 DB 폴링인가

API와 Crawler 사이에 SQS 같은 메시지 큐를 넣을 수도 있었다. 하지만 현재 규모(잡이 분당 수십 건 이하)에서는 오버엔지니어링이라고 판단했다.

┌──────┐         ┌────────────────┐         ┌─────────┐
│Client│──POST──►│   API Server   │──INSERT─►│   DB    │
│      │◄─202──  │   (ECS:8080)   │ PENDING  │(RDS PG) │
└──────┘         └────────────────┘         └────┬────┘
                                                  │ poll (5초)
                                            ┌────▼────┐
                                            │ Crawler │
                                            │(ECS:8081)│
                                            └────┬────┘
                                                  │
                                    ┌─────────────┼──────────────┐
                                    ▼             ▼              ▼
                              Playwright     결과 저장        Slack 알림
                              스크래핑       (DB UPDATE)

DB 폴링의 장점은 상태를 한곳에서 관리할 수 있다는 것이다. 잡의 생성, 실행 상태, 결과, 실패 이력이 전부 같은 테이블에 있으니 디버깅이 편하다. SQS를 쓰면 메시지가 사라진 뒤에 "이 잡이 왜 실행 안 됐지?" 하고 추적하기가 어려워진다.

물론 규모가 커지면 SQS로 전환할 수 있다. 하지만 KISS(Keep It Simple, Stupid) 원칙을 따르기로 했다.


8. 정적 자산과 시크릿 관리

S3 + CloudFront

# S3: 퍼블릭 접근 완전 차단
resource "aws_s3_bucket_public_access_block" "assets" {
  bucket                  = aws_s3_bucket.assets.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# CloudFront OAC: S3를 CloudFront를 통해서만 접근 가능하게
resource "aws_cloudfront_origin_access_control" "assets" {
  name                              = "${var.project}-assets-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

파일 업로드는 Presigned URL 방식이다. 클라이언트가 API에 업로드 URL을 요청하면, API가 S3 Presigned URL을 생성해서 돌려준다. 클라이언트는 그 URL로 S3에 직접 업로드한다. API 서버가 파일 데이터를 중계하지 않으니 부하가 크게 줄어든다.

Client ──► API (Presigned URL 요청)
              │
              ▼
         S3 Presigned URL 반환
              │
Client ──────► S3 (직접 업로드)
              │
              ▼
         CloudFront URL로 접근

Secrets Manager

민감정보는 코드에 절대 넣지 않는다. Secrets Manager에 JSON으로 저장하고, ECS 태스크가 시작될 때 자동 주입된다.

resource "aws_secretsmanager_secret_version" "app" {
  secret_id = aws_secretsmanager_secret.app.id
  secret_string = jsonencode({
    DB_PASSWORD     = var.db_password
    JWT_SECRET      = var.jwt_secret
    OPENAI_API_KEY  = var.openai_api_key
    SLACK_WEBHOOK_URL = var.slack_webhook_url
  })
}

실제 값은 terraform.tfvars에서 주입하고, 이 파일은 .gitignore에 추가해서 커밋되지 않게 했다.


9. 배포 파이프라인

백엔드 (API / Crawler → ECS)

GitHub Actions로 자동화되어 있다. API와 Crawler 각각 dev/prod 워크플로우가 있고, Manual Dispatch로 트리거한다.

워크플로우 수동 트리거 (GitHub Actions)
  → JDK 21 + Gradle 빌드 (테스트 제외)
    → Docker 이미지 빌드
      → ECR 푸시 (커밋 SHA 태그 + dev-latest/prod-latest)
        → ECS Task Definition 업데이트
          → ECS 서비스 롤링 배포
            → 서비스 안정화 대기
              → Slack 알림 (성공/실패, 소요 시간, 커밋 정보)

prod 배포는 GitHub Environment 승인이 필요하도록 설정해서, 실수로 프로덕션에 배포되는 일을 방지한다.

프론트엔드 (Front / Site → Vercel)

코드 푸시
  → Vercel이 자동 빌드 & 배포
    → PR마다 Preview URL 생성
      → main 머지 시 프로덕션 배포

모니터링

ECS 태스크의 로그는 CloudWatch Logs로 자동 수집된다 (14일 보존). 크롤러에서 잡 실패나 좀비 잡 복구가 발생하면 Slack Webhook으로 실시간 알림이 온다.


10. 비용 최적화

소규모 SaaS에서 AWS 비용은 민감한 문제다. 적용한 절감 전략들:

  1. NAT Gateway 단일 AZ — 월 ~$45 절약. 고가용성을 포기하는 트레이드오프
  2. RDS 단일 AZ (Dev) — 개발 환경에 Multi-AZ는 과하다
  3. Bastion 필요 시만 생성 — 상시 운영하면 월 ~$10 낭비
  4. CloudWatch 로그 14일 보존 — 오래된 로그는 자동 삭제
  5. ECR Lifecycle Policy — 최근 N개만 남기고 오래된 이미지 자동 정리
  6. RDS 자동 스토리지 스케일링 — 20GB로 시작, 최대 100GB까지 필요한 만큼만 확장
  7. Fargate Spot 검토 중 — 크롤러처럼 중단 가능한 워크로드에 적합

11. 회고 & 배운 점

잘한 것

  • Terraform 모듈화. 10개로 나누니 환경 추가가 쉬웠다. 처음엔 모듈 만드는 데 시간이 걸렸지만, 두 번째 환경부터는 변수만 바꿔서 복제할 수 있었다.
  • DB 폴링 워커. SQS 없이도 충분했다. 인프라가 단순해지니 디버깅도 쉬웠다. 규모가 커지면 그때 바꿔도 늦지 않다.
  • Secrets Manager. .env 파일로 시크릿을 관리하던 시절이 있었는데, Secrets Manager로 바꾸니 "이 환경변수 값이 뭐였지?" 하고 찾아 헤매는 일이 없어졌다.
  • 프론트엔드 Vercel 분리. ECS에 올리는 것보다 배포가 압도적으로 편하다. git push 하나로 끝.
  • GitHub Actions로 백엔드 배포 자동화. ECR 푸시 → ECS 업데이트 → Slack 알림까지 한 번에 돌아간다. prod는 Environment 승인을 걸어서 안전장치도 챙겼다.

아쉬운 것, 다음에 할 것

  • 모니터링 고도화. CloudWatch Logs만으로는 한계가 있다. CloudWatch Alarm + 대시보드를 구성해서 CPU/Memory 사용률, 에러율, 응답 시간을 한눈에 보고 싶다.
  • IaC 테스트. 지금은 terraform plan으로만 검증한다. Terratest 같은 도구로 "이 모듈을 apply하면 실제로 원하는 리소스가 만들어지는가"를 자동 테스트하고 싶다.

마무리

Terraform으로 인프라를 코드화하면 초기에는 시간이 더 걸린다. 콘솔에서 클릭 몇 번이면 될 걸, 왜 굳이 HCL을 쓰나 싶을 수도 있다.

그런데 서비스가 2개, 3개로 늘어나고, dev/prod 환경을 분리하고, 누군가 "이 보안그룹 규칙 언제 바꿨어?" 하고 물어볼 때, git log 한 줄로 대답할 수 있다는 건 꽤 큰 차이다.

이 글이 비슷한 규모의 SaaS 인프라를 고민하는 분들에게 조금이라도 참고가 되길 바란다.


profile
Java,Spring,JavaScript

0개의 댓글