병원 마케팅 SaaS 플랫폼의 인프라를 코드로 관리한 이야기
"이거 왜 콘솔에서 직접 만들었어?"
혼자서 프로젝트를 시작할 때는 AWS 콘솔을 클릭하는 게 빠르다. 그런데 서비스가 하나씩 늘어나면서 문제가 생겼다. API 서버, 크롤러, 프론트엔드 2개까지 총 4개의 서브 프로젝트. 거기에 dev/prod 두 환경. 어떤 보안그룹이 어디에 붙어 있는지, 환경변수가 정확히 뭐였는지 기억이 나지 않기 시작했다.
그래서 Terraform으로 전환했다. 이 글은 그 과정에서 내린 기술적 결정들과, 지금 운영 중인 인프라의 전체 구조를 정리한 기록이다.
병원 마케팅을 자동화하는 SaaS 플랫폼이다. 네이버 키워드 순위를 추적하고, 플레이스 분석을 돌리고, 블로그 노출 현황까지 추적한다. 이 플랫폼은 역할에 따라 4개의 독립적인 프로젝트로 나뉜다.
project-root/
├── api/ # Spring Boot REST API
├── crawler/ # Playwright 기반 크롤링 워커
├── front/ # Next.js 관리자 대시보드
├── site/ # Next.js 공식 마케팅 사이트
├── terraform/ # 인프라 코드
└── docker-compose.yml # 로컬 개발 환경
| 프로젝트 | 기술 스택 | 배포 대상 | 한 줄 요약 |
|---|---|---|---|
| API | Java 21, Spring Boot 3.5 | ECS Fargate | 모든 비즈니스 로직의 중심 |
| Crawler | Java 21, Playwright | ECS Fargate | 네이버 스크래핑 워커 |
| Front | Next.js 16, React 19 | Vercel | 관리자가 매일 보는 대시보드 |
| Site | Next.js 16 | Vercel | 서비스 소개 공식 사이트 |
각각이 존재하는 이유가 있다. 다음 섹션에서 하나씩 살펴보겠다.
모든 클라이언트가 이 서버를 통해 데이터를 주고받는다. 인증, 병원 관리, 크롤링 요청, 결과 조회, 파일 업로드까지 전부 여기서 처리한다.
api/
├── auth/ # JWT 로그인 & 토큰 리프레시
├── user/ # 시스템 사용자 (BCrypt 암호화)
├── hospital/ # 병원 CRUD
├── crawl/ # 크롤링 잡 생성 & 상태 추적
├── result/ # 결과 데이터 (키워드, 플레이스, 블로그, 방문자)
├── adlog/ # 외부 광고 분석 API 연동
├── article/ # 공식 사이트용 아티클 CMS
├── asset/ # S3 Presigned URL 생성
├── dashboard/ # 통계 집계
├── traffic/ # 광고비 데이터 (엑셀 업로드)
└── viral/ # 바이럴 콘텐츠 관리
기술 선택:
여기서 가장 중요한 설계 결정은 크롤링을 비동기로 처리한 것이다. 클라이언트가 크롤링을 요청하면, API는 DB에 PENDING 상태의 잡을 만들고 바로 202 Accepted를 돌려준다. 실제 크롤링은 Crawler가 담당한다. 이렇게 하면 API 서버가 무거운 브라우저 작업에 묶이지 않는다.
이 서비스의 존재 이유는 단 하나, 네이버에서 데이터를 긁어오는 것이다. 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 알림
여기서 신경 쓴 부분이 안정성이다. 크롤러가 도중에 죽으면 어떻게 될까?
리소스는 API의 2배를 할당했다 (CPU 1024, Memory 2048MB). Chromium 하나 띄우는 데 꽤 많은 메모리가 필요하다. Docker 이미지도 시스템 폰트와 Chromium을 포함해 약 800MB 정도 된다.
병원 운영자와 내부 관리자가 매일 사용하는 웹 대시보드다. 크롤링 결과를 차트로 보여주고, 트래픽을 분석하고, 바이럴 콘텐츠를 관리한다.
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/
└── 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│◄─────────────┘
└──────────┘
정리하면:
이제 이 서비스들이 어디서, 어떻게 돌아가는지 보자.
┌──────────────┐
│ 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를 통해서만 접근 가능하게 했다.
인프라를 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 터널 호스트 (선택적)
| 모듈 | 주요 리소스 | 한 줄 설명 |
|---|---|---|
vpc | VPC, Subnet, IGW, NAT GW | 2개 AZ에 걸친 public/private 네트워크 |
security-group | SG x 3 | ALB → ECS → RDS 체인 형태의 접근 제어 |
alb | ALB, Target Group, Listener | HTTPS 종단 + 헬스체크 |
ecs | Cluster, Task Def, Service | API + Crawler 컨테이너 오케스트레이션 |
rds | DB Instance | PostgreSQL 15, 자동 스토리지 스케일링 |
ecr | Repository x 2 | 컨테이너 이미지 저장 + 오래된 이미지 자동 정리 |
dns | Hosted Zone, ACM Cert | 와일드카드 SSL 인증서, DNS 검증 |
assets | S3, CloudFront, OAC | 정적 자산 업로드 + CDN 배포 |
secrets | Secrets Manager | DB 비밀번호, API 키 등 민감정보 관리 |
bastion | EC2, Key Pair | 개발 시 RDS 직접 접근용 (필요할 때만 생성) |
dns (Route 53 + ACM)
│
┌─────────┼──────────┐
▼ ▼ ▼
alb assets (Vercel)
│ (S3+CF)
│
▼
vpc ──► security-group ──► ecs ──► rds
│
ecr
│
secrets
이렇게 모듈화하면 좋은 점은, staging 환경이 필요해지면 staging/main.tf 하나만 추가하면 된다는 것이다. 모듈은 이미 검증되어 있으니 변수만 바꿔주면 된다.
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 변수로 필요할 때만 띄우고, 평소에는 꺼둔다.
Terraform 모듈을 활용해 dev와 prod를 완전히 분리했다.
| 항목 | Dev | Prod |
|---|---|---|
| VPC CIDR | 10.0.0.0/16 | 10.1.0.0/16 |
| 컨테이너 이미지 태그 | dev-latest | prod-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-latest와 prod-latest 태그로 이미지를 구분한다. 저장소를 환경마다 따로 만들면 관리 포인트만 늘어난다.
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를 치면 상태 파일이 깨질 수 있기 때문이다.
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::" },
# ...
]
}])
}
포인트는 environment와 secrets의 분리다. 일반 설정은 environment로, DB 비밀번호나 API 키 같은 민감정보는 secrets로 Secrets Manager에서 직접 가져온다. 컨테이너가 시작될 때 ECS가 자동으로 주입해준다.
크롤러는 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 브라우저는 빌드 시점에 미리 설치해서, 컨테이너 시작 시 다운로드 시간을 없앴다.
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) 원칙을 따르기로 했다.
# 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에 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에 추가해서 커밋되지 않게 했다.
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 승인이 필요하도록 설정해서, 실수로 프로덕션에 배포되는 일을 방지한다.
코드 푸시
→ Vercel이 자동 빌드 & 배포
→ PR마다 Preview URL 생성
→ main 머지 시 프로덕션 배포
ECS 태스크의 로그는 CloudWatch Logs로 자동 수집된다 (14일 보존). 크롤러에서 잡 실패나 좀비 잡 복구가 발생하면 Slack Webhook으로 실시간 알림이 온다.
소규모 SaaS에서 AWS 비용은 민감한 문제다. 적용한 절감 전략들:
.env 파일로 시크릿을 관리하던 시절이 있었는데, Secrets Manager로 바꾸니 "이 환경변수 값이 뭐였지?" 하고 찾아 헤매는 일이 없어졌다.terraform plan으로만 검증한다. Terratest 같은 도구로 "이 모듈을 apply하면 실제로 원하는 리소스가 만들어지는가"를 자동 테스트하고 싶다.Terraform으로 인프라를 코드화하면 초기에는 시간이 더 걸린다. 콘솔에서 클릭 몇 번이면 될 걸, 왜 굳이 HCL을 쓰나 싶을 수도 있다.
그런데 서비스가 2개, 3개로 늘어나고, dev/prod 환경을 분리하고, 누군가 "이 보안그룹 규칙 언제 바꿨어?" 하고 물어볼 때, git log 한 줄로 대답할 수 있다는 건 꽤 큰 차이다.
이 글이 비슷한 규모의 SaaS 인프라를 고민하는 분들에게 조금이라도 참고가 되길 바란다.