인프라 전체 가이드 — GCP · Terraform · CI
INFRA GUIDE · vertical-news-portal

인프라 전체 가이드 — GCP·Terraform·CI

GCP 프로젝트·결제·인증부터 Terraform과 CI의 역할 분담까지 — 이 프로젝트의 인프라 전체를 신입 눈높이로 설명한다. 아직 리소스도 Terraform 코드도 없는 지금이 배우기 가장 좋은 시점이다: 무엇이 왜 그 순서로 생기는지 처음부터 볼 수 있다.

대상 — GCP·Terraform·IaC를 처음 보는 개발자 정독 약 20분 · 훑기 3분(각 섹션 첫 줄 요약만) 기준일 2026-08-09 · 근거 문서는 맨 아래
00

네 줄 요약

  1. Terraform은 "무엇이 존재하고 누가 권한을 갖는가"를 코드로 선언하는 도구다. CI(GitHub Actions)는 "그 위에 어떤 코드가 돌고 있는가"를 담당한다. 경계 문장: Terraform = 리소스 존재·권한 / CI = 코드 배포. 대체재가 아니라 계층이다.
  2. 지금 이 프로젝트의 GCP 리소스는 0개다(API 활성화만 완료). 관리할 것이 없으니 Terraform도 아직 없다 — 대신 도입 트리거 3개를 정해뒀고, 엔진(뉴스 수집) 리소스를 실제로 만드는 M6에서 트리거가 발동infra/가 생긴다.
  3. GCP 쪽 기본 세팅은 끝나 있다(2026-08-09 실측) — 조직 소속·결제 연결(Blaze)·API 44종, 그리고 개발 머신의 gcloud 로그인·ADC까지(§02·§03·§10). 없는 것은 리소스와 Terraform 코드뿐이다. 킥오프 승인도 2026-08-09 완료 — M6는 착수 가능 상태다.
  4. 그전까지는 runbook 방식이다 — 인프라 명령을 실행하면 docs/spec/deploy.md에 손으로 기록해 재현성을 확보한다.

이 문서의 시제 표기 규칙 — 지금 있음 은 레포·GCP에 실재하는 것, M6 예정 은 계획만 확정된 것. 신입이 가장 흔히 하는 오해가 "계획 문서에 있으니 이미 있겠지"다.

01

프로젝트 지도 — 한 레포, 두 트랙, 한 GCP 프로젝트

요약레포 하나에 성격이 다른 두 트랙이 있고, 둘 다 GCP 프로젝트 vertical-news-portal을 공유한다(리전으로 분리).
TRACK A · 도쿄 asia-northeast1

JP 매거진 파이프라인 가동 준비 중

일본어 매거진용 기사 생성 워커. 30분마다 Cloud Run Job이 떠서 수집→작성→검수 큐 적재를 1사이클 돌고 종료한다. 상주 서버가 없어 월 비용이 약 $15~25 수준. 리소스는 runbook(gcloud 수기 명령)으로 만든다.

TRACK B · 서울 asia-northeast3

뉴스 수집 엔진 M6 생성 예정

테크월드(epnc.co.kr) 1소스로 시작하는 수집 엔진 MVP. 상주 서비스 3개 + 잡 1개 + 큐·버킷·DB로 구성이 훨씬 크고, 서비스 계정을 쪼개 최소 권한을 준다. 이 트랙의 리소스 생성이 Terraform 도입 시점이다.

지금 이 순간의 실제 상태 GCP에 만들어진 리소스: 0개. 완료된 것은 프로젝트 확보 + API 6종 활성화이고, 킥오프 승인(2026-08-09)으로 리소스 실생성 게이트는 열렸다 — 남은 선행은 엔진 전용 API 활성화와 Actions용 WIF/SA뿐이다. 레포 쪽에는 .github/workflows/ci.yml(테스트 CI 초안), 루트 Dockerfile, packages/(공용 패키지)가 실재하고 — infra/ 디렉토리는 아직 없다. 개발 머신 쪽은 gcloud 로그인 · 기본 프로젝트 · ADC까지 연동 완료(2026-08-09 실측 — §10 체크리스트).
02

GCP 기초 — 프로젝트 · 결제(Blaze) · API · 비용

요약Firebase 프로젝트와 GCP 프로젝트는 같은 것이다. Blaze = 결제 계정이 연결됐다는 뜻이고, API 활성화는 무과금 — 과금은 리소스를 만든 시점부터다.

GCP의 권한과 비용은 계층을 따라 흐른다: 조직 → 프로젝트 → 리소스. 이 프로젝트의 현재 위치(2026-08-09 실측):

상태의미
조직 소속콘캣 조직 하위 확인됨회사 조직의 정책·권한 상속 아래 — 개인 프로젝트가 아니다
결제 연결연결됨 · Blaze 확인됨리소스를 만들면 과금이 시작될 수 있는 상태. 지금은 리소스 0개라 청구 ~$0
활성 API44종 확인됨요구 6종 + Firebase 생성 기본분 + 자동 의존. 엔진 전용 3종(cloudtasks 등)은 아직 꺼짐 — 승인 완료(08-09)로 이제 켤 수 있다(M6 첫 작업)
실리소스0개Cloud SQL·Cloud Run·버킷 전부 미생성 — "선언할 것"이 아직 없는 상태(§08과 연결)
Firebase 초대장을 받았다면 "Firebase 프로젝트 vertical-news-portal에 초대"는 이 GCP 프로젝트에 초대된 것이다. Firebase 프로젝트는 GCP 프로젝트의 다른 얼굴(1:1 동일체)이고, 이 프로젝트는 실제로 Firebase 콘솔 경로로 생성됐다(그래서 기본 API 37종이 켜져 있었다). 단, 이 레포는 Firebase 제품은 쓰지 않기로 결정돼 있다 — admin-web도 Firebase Hosting이 아니라 Cloud Run SSR이다. 초대의 실질 의미는 Cloud Run·Cloud SQL 같은 GCP 리소스 접근 권한이며, 연동 도구도 firebase CLI가 아니라 gcloud다.

비용 지도 — 어디서 돈이 나가나

트랙주요 항목월 추산
JP 매거진 (도쿄)Cloud Run Job $1~5 · Cloud SQL $10~15 · Scheduler/Secret/Registry ~$0 · LLM ~$5~$15~25
엔진 (서울) M6 예정별도 Cloud SQL 인스턴스 + 상주 api(min 1) 등+$25~60
현재리소스 0 — API 활성화는 무과금·가역~$0
03

인증 지도 — 넷이 서로 다르게 자신을 증명한다

요약사람 · 로컬 코드 · CI · 배포된 서비스는 GCP에 자신을 증명하는 방법이 각각 다르다. 앞의 둘은 연동 완료, 뒤의 둘은 M6에서 만들어진다.

"로그인"이 하나가 아니라는 것이 GCP 인증의 첫 관문이다. 터미널에서 gcloud 명령을 칠 때의 자격과, 같은 머신에서 Terraform이나 파이썬 라이브러리가 API를 부를 때의 자격 (ADC)은 별개 파일이다. 그리고 CI와 배포된 서비스는 아예 다른 방식 — 키 없는 토큰 교환(WIF)과 서비스 계정(SA) — 을 쓴다.

GCP vertical-news-portal 개발자 (터미널) 사람이 명령을 칠 때 사용자 인증 — gcloud auth login 연동 완료 · 2026-08-09 gcloud 명령 로컬 코드 · Terraform 라이브러리가 실행될 때 ADC — application-default login 연동 완료 · 자격 파일은 이 머신에 코드의 API 호출 GitHub Actions CI가 실행될 때 WIF — 키 파일 없는 토큰 교환 M6 예정 · 유출될 키가 없다 plan·apply / 배포 Cloud Run 서비스 worker·api… 런타임 SA — 최소 권한 서비스 계정 M6 예정 · 서비스마다 분리 런타임 권한
그림 C — 같은 GCP를 향해 넷이 서로 다른 자격으로 들어간다. ①②는 자격 파일이 이 머신에 있고, ③④는 키 파일이 아예 없다는 점이 요지 — 아래로 갈수록 "유출될 키를 만들지 않는" 방향이다.
흔한 오해 "gcloud auth login 했는데 Terraform이 인증 오류를 낸다" — 정상이다. 사용자 인증(①)과 ADC(②)는 별개 자격이라 둘 다 해야 한다. 반대로 이 두 자격 파일 중 무엇도 CI에 올리지 않는다 — 그 자리를 위해 WIF가 존재한다.
04

Terraform이 없던 날 — 6종을 켰는데 7종이 켜졌다

요약수동 명령은 부수 효과를 남기는데 그걸 추적하는 장부가 없다. IaC는 "원하는 최종 상태"를 코드로 적고, 도구가 현실을 거기에 맞추게 한다.

2026-08-07, 이 프로젝트에서 실제로 있었던 일이다. ADR-0005가 요구하는 GCP API 6종(run·sqladmin·cloudscheduler·secretmanager·artifactregistry·aiplatform)을 gcloud services enable로 켰다. 결과를 세어 보니 활성 API가 37종에서 44종이 됐다 — 요청은 6개인데 7개가 늘었다. containerregistry가 다른 API의 자동 의존으로 함께 켜진 것이다. 우리가 요청한 적 없는 변화가 프로젝트에 남았고, 이 사실은 담당자가 눈치채고 문서에 적어뒀기 때문에 아는 것이지, 시스템 어디에도 "의도한 상태"와 "실제 상태"의 대조표는 없다.

이것이 수동 인프라 관리의 본질적 한계다. 콘솔 클릭과 CLI 명령은 절차만 남기고 결과의 전모는 남기지 않는다. 지금 이 프로젝트에 정확히 뭐가 켜져 있는지 아는 방법은 콘솔을 열어 눈으로 확인하는 것뿐이다.

IaC(Infrastructure as Code)는 이 문제를 뒤집는다. "API 6종이 켜져 있어야 한다", "큐가 1개 있고 초당 1건만 내보내야 한다"처럼 원하는 최종 상태를 코드 파일로 선언하면, 도구(Terraform)가 현실과 비교해서 모자란 것은 만들고 남는 것은 알려준다. 코드니까 리뷰를 거치고, 이력이 남고, 다시 실행하면 같은 결과가 나온다(멱등성).

건물 관리에 비유하면

Terraform은 건물·전기·출입권한을 관리하는 시설팀이다. "이 건물에 사무실이 4칸 있고, 택배기사는 로비까지만 들어올 수 있다"는 설계도(.tf 코드)를 유지한다. 입주자가 들여놓는 가구(애플리케이션 코드)는 시설팀 소관이 아니다 — 그건 CI가 나른다. 그리고 시설팀에는 자산 대장이 있어서, 대장에 없는 물건이 건물에 나타나면(drift) 다음 점검 때 드러난다.

코드 (.tf) 원하는 최종 상태 · 리뷰 대상 state 파일 자산 대장 — 내가 만든 것의 기록 실제 GCP 현실 plan 셋을 비교해 "무엇이 바뀔지" 미리보기 apply 현실을 코드에 맞춤 (성공 시 대장도 갱신) 어긋나면 = drift 콘솔 수동 변경 · 자동 의존 활성(6종→7종)
그림 A — Terraform의 세 축. plan은 코드·대장·현실 셋을 비교해 변경 계획을 미리 보여주고, apply가 현실을 코드에 맞춘다. "6종을 켰는데 7종이 켜진" 사건은 현실만 몰래 바뀌는 drift의 전형 — Terraform이 있었다면 다음 plan에서 잡혔을 변화다.
05

CI와 Terraform — 대체재가 아니라 계층

요약"CI가 있는데 왜 Terraform이 또 필요하지?"의 답: 다루는 대상이 다르다. 그리고 CI가 Terraform을 실행한다.

신입이 가장 먼저 부딪히는 혼란이 이것이다. 배포 자동화(CI)가 이미 있는데 Terraform은 뭘 더 해주나? 원 설계 문서의 표현을 그대로 빌리면 — "대체재가 아니라 계층입니다. CI가 Terraform을 실행합니다." 건물 비유로: 시설팀(Terraform)과 이삿짐 운송(CI)은 둘 다 "건물에서 일하는" 주체지만 하는 일이 겹치지 않고, 이사 트럭이 들어오려면 시설팀이 만들어 둔 출입구와 권한이 먼저 있어야 한다.

TerraformCI (GitHub Actions)
패러다임선언형 — 최종 상태를 기술명령형 — 실행 절차를 기술
상태state 파일로 현실을 추적상태 없음
멱등성몇 번 apply해도 결과 동일스크립트가 스스로 보장해야 함
실행 빈도드물게 (주 1회 이하)자주 (하루 수 회)
다루는 대상리소스 존재 여부 · 권한 · 설정값테스트 · 빌드 · 이미지 push · 배포
되돌리기이전 .tf로 apply이전 이미지 태그로 재배포
GitHub Actions — 두 레인 모두 이 안에서 돈다 PR / push 코드 변경 paths-filter 바뀐 경로로 분기 인프라 레인 — infra/** 변경 · 주 1회 이하 M6 예정 terraform plan 변경 미리보기 PR 코멘트 plan 결과를 리뷰 승인 environment 게이트 apply 리소스·권한 변경 앱 레인 — 앱 코드 변경 · 하루 수 회 지금 있음: ci.yml 초안 pytest 테스트 이미지 빌드 Dockerfile Registry push Artifact Registry gcloud run deploy 새 리비전으로 전환
그림 ② — 한 지붕(GitHub Actions) 아래 두 레인. 인프라 레인의 핵심은 apply 앞의 사람 승인 게이트다 — "리소스 변경은 사람이 확인 후"라는 팀 원칙을 기계 게이트로 옮긴 형태 — 킥오프 승인(2026-08-09 완료)과 별개로, 이 게이트는 매 apply의 상시 장치다. 앱 레인은 ci.yml(pytest + 경로 분기)로 씨앗이 이미 심겨 있고, 배포 스텝이 붙으면 완성된다.
06

전체 인프라 구성도 — 누가 무엇을 관리하나

요약같은 Cloud Run 하나에서도 껍데기(존재·설정·권한)는 Terraform, 내용물(이미지 태그)은 CI, 데이터(sources 행)는 앱, 시크릿 값은 콘솔이 관리한다.

아래 그림이 이 문서의 중심이다. 박스의 테두리 색이 "껍데기를 누가 만드나"를, 박스 안의 색 칩이 "내용물을 누가 채우나"를 나타낸다. 왼쪽 JP 트랙은 runbook(수기 gcloud)으로 만들고, 오른쪽 엔진 트랙은 M6부터 Terraform이 만든다 — 한 프로젝트 안에 두 관리 방식이 나란히 놓인, 이 레포만의 과도기 그림이다.

GitHub Actions — CI 앱 레인(배포) + 인프라 레인(terraform) 앱 레인: 이미지 push·배포 인프라 레인 (M6): terraform apply → 존재·권한 GCP 프로젝트 · vertical-news-portal JP 매거진 트랙 · asia-northeast1 도쿄 가동 준비 중 Cloud Scheduler 30분 주기 트리거 Cloud Run Job 워커 · 1vCPU·1GB · 1사이클 후 종료 image: CI 읽기·쓰기 임베딩 호출 Cloud SQL (도쿄) pgvector sources 행: 앱 데이터 Vertex AI 임베딩 API 호출 대상 — 리소스 아님 Secret Manager 시크릿 값: 콘솔 Artifact Registry 이미지 저장소 push: CI 이 트랙의 리소스 생성 = runbook(gcloud 수기) — deploy.md §2에 명령 기록 엔진 트랙 · asia-northeast3 서울 M6 생성 예정 Cloud Scheduler fetch-feed · 10분 주기 OIDC worker (Service) FastAPI · 1GiB · 동시성 2 image: CI 기사 태스크 등록 push · 1req/s Cloud Tasks 큐 engine-normal 초당 1건을 큐가 강제 api (Service) 512MiB · 동시성 80 · min 1 backfill (Job) GCS 재파싱 · 병렬 5~10 admin-web (SSR) 정정·삭제 접수 화면 호출 조회 Cloud SQL (서울) 별도 인스턴스 · worker가 insert sources 행: 앱 데이터 재파싱 갱신 GCS 원본 버킷 비공개 · lifecycle 미설정 원본 저장(파싱 전) SA 4개 분리(최소 권한): worker · api · backfill · admin-web — 목표는 5개(+dispatcher) SA 5개 분리(목표): dispatcher · worker · api · backfill · admin-web dispatcher (Service) — MVP 보류 Pub/Sub + DLQ — 보류(Pull API만) Vercel — 매거진 프론트 web/ · GCP 밖 published 기사 (ISR) 테크월드 epnc.co.kr 수집 대상 언론사 · 외부 RSS·기사 수집 · 1req/s
그림 ① — 전체 인프라와 관리 주체. 파란 테두리 박스가 모두 "Terraform 관리 대상"인데, 파란 박스 안에도 초록·호박 칩이 있다는 점이 이 그림의 핵심이다 — 껍데기와 내용물의 관리자가 다르다. 수치·주기는 킥오프에서 확정된 MVP 사양 기준.

관리 주체 4분면 — 표로 다시 보기

대상관리 주체이유
Cloud Run 존재 · 설정 · 권한Terraform재현성과 감사 — 리뷰를 거친 코드가 곧 현재 상태
컨테이너 이미지 태그CITerraform이 쥐면 코드 배포 때마다 apply가 필요해진다
언론사 목록 (sources 행)앱 데이터운영자가 언론사를 추가할 때 배포가 필요해지면 안 된다
시크릿 값콘솔/CLITerraform이 관리하면 state 파일에 평문으로 남는다

GCP 프로젝트 안에는 무엇이 있나 — 정의 · 역할 · AWS 대응

관리 주체는 위 표의 넷(Terraform · CI · 앱 데이터 · 콘솔)이 전부고, 여기서는 그 관리 대상 — 그림 ①의 GCP 프로젝트 프레임 안에 있는 것들 — 을 하나씩 본다. 각 이름이 무엇이고 이 구성에서 무슨 일을 하는지, 그리고 AWS를 먼저 배운 사람을 위해 같은 자리를 메우는 AWS 제품을 함께 적는다.

GCP한 줄 정의이 구성에서의 역할AWS 대응
GCP 프로젝트 리소스·결제·권한이 함께 묶이는 최상위 격리 단위 (리소스 계층의 가운데 칸) vertical-news-portal 하나를 두 트랙이 리전으로 나눠 쓴다 — 프로젝트를 쪼개지 않았다 AWS 계정. AWS에는 프로젝트 계층이 없어, 같은 크기의 격리를 하려면 계정을 나누고 Organizations로 묶는다
Cloud Run (Service) 컨테이너를 HTTP 요청 단위로 띄우는 서버리스 런타임 (Service vs Job) worker · api · admin-web. 요청이 없으면 0으로 줄고, api만 min 1로 상주시킨다 App Runner 또는 ECS on Fargate. Lambda가 아니다 — 코드가 아니라 이미지를 그대로 굴린다
Cloud Run (Job) 같은 Cloud Run이되 한 번 실행하고 종료하는 실행 형태 JP 워커의 30분 1사이클, 엔진의 backfill 재파싱 AWS Batch 또는 ECS RunTask(Fargate)
Cloud Scheduler 관리형 cron — 정해진 시각에 HTTP 엔드포인트나 잡을 호출한다 JP 30분 · 엔진 10분 주기 트리거. 호출할 때 OIDC 토큰으로 자신을 증명한다 EventBridge Scheduler (구 CloudWatch Events 규칙)
Cloud Tasks 태스크를 큐에 넣고 정해진 속도로 밀어서 보내는(push) 큐 (용어집) engine-normal 큐 설정이 "외부 사이트에 초당 1건"을 강제한다 — 예의가 인프라에 박혀 있다 SQS가 가장 가깝지만 SQS는 소비자가 당겨오는(pull) 구조라, 속도 제한은 큐가 아니라 소비자 쪽(Lambda 동시성 등)에서 따로 눌러야 한다
Pub/Sub 보류 발행–구독 메시지 버스 + 실패분을 모으는 DLQ MVP에서는 쓰지 않는다(그림의 점선 고스트) — 큐 하나로 충분해서다 SNS + SQS 조합, 또는 EventBridge
Cloud SQL 관리형 관계형 DB — 백업·패치·복제를 GCP가 맡는다 트랙별로 인스턴스를 따로 둔다(도쿄·서울). JP 쪽은 pgvector 확장으로 임베딩까지 담는다 RDS (Aurora PostgreSQL 포함)
Cloud Storage (GCS) 객체 스토리지 — 파일을 그대로 넣고 꺼낸다 수집 원본을 파싱하기 전 상태로 보관한다. backfill이 나중에 다시 읽는 근거가 여기 있다 S3
Secret Manager 시크릿의 저장·버전·접근 제어 (용어집) DB 접속 문자열·API 키. 껍데기는 Terraform, 값은 콘솔이 넣는다 AWS Secrets Manager (단순한 설정값이면 SSM Parameter Store)
Artifact Registry 빌드된 컨테이너 이미지를 보관하는 저장소 (용어집) 저장소 자체는 Terraform이 만들고, 이미지 push는 CI가 한다 ECR
Vertex AI 관리형 AI 플랫폼. 이 레포는 그중 임베딩 API만 호출한다 그림에서 점선인 이유 — 우리가 만드는 리소스가 아니라 호출 대상이다. 관리 범위 밖 Bedrock(모델을 API로 부르는 자리). 직접 학습·호스팅까지 가면 SageMaker
서비스 계정 (SA) 사람이 아닌 워크로드의 신원 (SA · IAM) 서비스마다 쪼개 최소 권한만 준다 — 외부 HTML을 파싱하는 worker가 공격 표면이 가장 크다 IAM 역할(Role). 방향이 반대다 — AWS는 워크로드가 역할을 빌리고(assume), GCP는 계정을 리소스에 붙인다
WIF 외부 IdP의 토큰을 GCP 토큰으로 바꿔주는 키 없는 인증 (용어집) GitHub Actions가 SA 키 파일 없이 apply·배포 권한을 얻는 통로 같은 구조가 그대로 있다 — GitHub OIDC → AssumeRoleWithWebIdentity로 IAM 역할
AWS 대응을 읽는 법 오른쪽 열은 "같은 자리를 메우는 제품"이지 기능 동치가 아니다. 어긋나는 지점이 셋 있고, AWS 경험자가 이 그림을 오독하는 것도 대개 이 셋이다 — ①프로젝트 ↔ 계정(격리 단위의 크기가 다르다), ②Cloud Tasks ↔ SQS(밀어주느냐 당겨가느냐 · 속도 제한을 큐가 쥐느냐), ③Cloud Run ↔ Fargate(요청이 없으면 0으로 줄어드는 과금 모델).

그 위에서 도는 프로그램 넷 — worker · api · backfill · admin-web

앞의 표가 GCP가 파는 제품이었다면, 이 넷은 우리가 만든 프로그램이다. AWS 대응이 없는 것이 당연하다 — 클라우드를 옮겨도 이름은 그대로고 밑에 깔리는 런타임만 바뀐다. 그림 ①에서 파란 테두리 + 초록 칩으로 그려진 박스들이 정확히 이 넷이다: 껍데기(Cloud Run 서비스·잡이 존재한다는 사실과 권한)는 Terraform, 그 안에 들어가는 내용물(이미지 태그)은 CI가 관리한다. 넷 다 엔진 트랙(서울)이고 M6 생성 예정이다.

CLOUD RUN · SERVICE · 트리거: CLOUD TASKS(PUSH)

worker — 수집·파싱 실행부

기사 1건이 작업 단위다. Scheduler가 /fetch-feed를 때리면 RSS 목록을 읽어 기사 URL마다 태스크를 큐에 등록하고, 큐가 초당 1건씩 /parse-article로 되밀어 준다. worker는 원본 HTML을 받아 파싱하기 전 상태 그대로 GCS에 넣은 뒤 본문을 추출해 DB에 적재한다. 넷 중 유일하게 외부 HTML을 직접 다뤄 공격 표면이 가장 크다 — 서비스 계정을 쪼개는 이유의 절반이 이 하나다.

사양 1GiB · 동시성 2 · min 0
권한 GCS 쓰기 · DB 쓰기 · Secret 읽기

CLOUD RUN · SERVICE · 상주(MIN 1)

api — 밖으로 나가는 유일한 창구

쌓인 기사를 외부 서비스가 가져가는 통로. 소비 쪽은 GET /articles?updated_since=<커서>바뀐 것만 당겨간다(Pull 방식). 정정·삭제 접수로 내려간 기사를 응답에서 빼는 것도 여기 책임이라, "내렸다"가 실제로 통하는지는 이 API의 응답으로 확인한다. 넷 중 유일하게 min 1로 한 대를 늘 띄워 둔다 — 첫 요청이 콜드스타트를 만나지 않게 하기 위해서다(그만큼 상시 비용이 붙는다).

사양 512MiB · 동시성 80 · min 1
권한 DB 읽기·쓰기 (목표 구성에서 Pub/Sub 발행 추가)

CLOUD RUN · JOB · 온디맨드

backfill — 과거분 재파싱

셀렉터를 고쳤을 때 이미 수집한 기사를 다시 만드는 잡. 언론사에 다시 요청하지 않고 GCS에 남겨 둔 원본을 읽어 같은 행을 갱신한다 — worker가 원본을 파싱 전 상태로 보관하는 이유가 이것이다. 파서 코드는 worker와 공유한다(packages/parsers). 갈라지는 순간 신규 수집분과 재파싱분의 본문이 달라져, 같은 기사인데 언제 들어왔느냐로 내용이 갈린다.

사양 병렬 5~10(Cloud SQL 커넥션 제약) · 타임아웃 최대 168시간
권한 GCS 읽기 · DB 쓰기

CLOUD RUN · SERVICE · SSR

admin-web — 사람이 쓰는 화면

언론사의 정정·삭제 요청을 접수하는 운영 화면. 계약이 요구하는 "즉시 반영"을 사람이 실행할 수 있게 하는 자리이고, 그래서 기술적으로 작아도 빠질 수 없다. DB를 직접 만지지 않고 api를 호출한다 — 그 결과 넷 중 GCP 권한이 가장 적다. 정적 SPA(Firebase Hosting)로 갈 수도 있었지만 Cloud Run SSR로 정해졌다.

사양 Cloud Run Service (SSR)
권한 사실상 없음 — api 호출만

넷을 쪼개 둔 이유 한 서비스에 다 넣어도 돌아간다. 그런데 권한 줄이 서로 다르다 — worker만 GCS에 쓰고, backfill은 GCS를 읽기만 하고, admin-web은 GCP 권한이 거의 없다. 합치는 순간 가장 위험한 것(외부 HTML 파싱)이 가장 센 권한(DB 쓰기·Secret 읽기)과 한 프로세스에 놓인다. SA를 넷으로 나눈 것은 조직도가 아니라 사고 반경을 나눈 것이다.
Terraform이 관리하게 될 것 — 9항목 전체 목록
  1. Cloud Run 서비스 + 잡 — 설정값 포함, 이미지 태그 제외
  2. 서비스 계정(SA)과 IAM 바인딩
  3. Cloud SQL 인스턴스 · DB · 사용자
  4. GCS 버킷 + lifecycle 규칙
  5. Cloud Tasks 큐 — 속도 등급별 공용 큐(fast/normal/slow), 언론사별 큐 금지
  6. Pub/Sub 토픽 · 구독 · DLQ (MVP에서는 보류)
  7. Cloud Scheduler 작업
  8. Secret Manager — 시크릿 껍데기까지만 (값은 콘솔)
  9. Artifact Registry 저장소

5번이 좋은 연습문제다. 언론사별로 큐를 만들면 "언론사 추가 = 인프라 배포"가 돼버린다. 그래서 큐는 속도 등급별 공용으로 두고, 언론사 추가는 DB 행 하나로 끝나게 설계했다 — 무엇을 Terraform에 넣지 않을지가 넣을지만큼 중요하다.

07

한 줄이 지키는 안전 — ignore_changes 함정

요약lifecycle 설정 한 줄을 빼먹으면, 인프라를 배포할 때마다 앱이 옛 버전으로 롤백되는 사고가 난다.

경계가 "Terraform=껍데기 / CI=내용물"로 나뉘면 필연적으로 충돌 지점이 하나 생긴다. Terraform으로 Cloud Run을 만들려면 image 필드를 채워야 하는데, 바로 그 필드를 CI가 배포 때마다 계속 바꾼다. 그러면 다음 plan은 "코드에 적힌 값과 다르네?" 하며 이미지를 옛 값으로 되돌리려 든다. 건물 비유로: 시설팀이 도면대로 원상복구를 하다가 입주자가 새로 들인 가구를 옛 가구로 바꿔놓는 사고다.

① Terraform이 생성 worker image: v1 state 대장: v1 현실 = 대장 = 코드 ✓ ② CI가 여섯 번 배포 worker image: v7 (현실) state 대장: 여전히 v1 현실 ≠ 대장·코드 ③ 메모리만 바꾸는 infra PR $ terraform plan ~ memory: 1Gi→2Gi ~ image: v7 → v1 ! 되돌림이 계획에 끼어든다 ④-a 그대로 apply 앱이 v1로 롤백 — 사고 인프라 배포가 옛 코드를 되살린다 ④-b ignore_changes 있음 image는 계획에서 제외 — 안전 메모리만 2Gi로 바뀌고 v7은 그대로 돈다
그림 B — 사고의 시간 순서. ②에서 이미 현실과 대장이 어긋나 있고(정상 — 이미지는 CI 몫이니까), ③의 plan이 그 어긋남을 "고치겠다"고 나서는 순간이 위험 지점이다.

해법은 "이 필드는 Terraform이 못 본 척해라"를 선언하는 것이다. 이 문서에서 유일한 코드 조각:

resource "google_cloud_run_v2_service" "worker" {
  name     = "worker"
  location = var.region

  template {
    containers {
      image = "asia-northeast3-docker.pkg.dev/${var.project}/news/worker:bootstrap"
    }
  }

  lifecycle {
    ignore_changes = [template[0].containers[0].image]
  }
}
함정 — 이 프로젝트의 1급 규칙 ignore_changes = [template[0].containers[0].image] 한 줄을 빼먹으면 인프라 배포마다 앱이 옛 버전으로 롤백되는 사고가 난다. M6 계획은 전 서비스에 이 설정을 필수로 못박아 뒀다. 인프라 PR을 리뷰할 일이 생기면 이것부터 확인하라 — plan 출력에 ~ image: 줄이 보이면 무언가 잘못된 것이다.
08

그런데 왜 아직 도입하지 않았나 — 트리거와 결정 이력

요약관리할 리소스가 0개인 단계에서는 선언할 것이 없다. 대신 도입 트리거 3개를 정해뒀고, 엔진 리소스를 실제로 만드는 M6에서 트리거 (a)가 발동한다.

"좋은 도구면 처음부터 쓰지, 왜 미뤘나?"는 정당한 질문이다. 팀의 답(ADR-0007 Decision 8): 현재 배포 산출물이 0인 단계에서 Terraform을 들이면 얻는 것 없이 state·backend 관리 비용만 추가된다. 도구는 문제가 생기는 시점에 들인다 — 단, "그 시점"을 감으로 정하지 않도록 트리거를 문서로 박아뒀다. 하나라도 충족되면 도입한다:

TRIGGER (a) — 발동 예고됨

엔진 리소스 실생성 착수

서비스 3~4개 + 큐 + 버킷 + DB + SA/IAM — 수기로 관리하기엔 리소스 그래프가 크다. 킥오프(2026-08-07)가 "M6 계획이 이걸 포함하므로 (a) 발동"을 기록했다.

TRIGGER (b)

인프라 변경 주체 2인 이상

두 사람이 각자 콘솔을 만지기 시작하면 "누가 뭘 바꿨는지"를 코드와 state가 대신 기억해야 한다.

TRIGGER (c)

dev / prod 분리 필요

같은 구성을 환경 두 벌로 복제해야 하는 순간, 선언형 코드의 재사용 가치가 커진다. 아직 미발동.

공백기의 대책 — runbook Terraform이 없는 동안의 재현성은 docs/spec/deploy.md가 runbook이 되어 메운다: 인프라 명령을 실행하면 그 명령과 결과를 문서에 그대로 기록한다. "6종→7종" 사건이 기록으로 남은 것도 이 규칙 덕분이다. 다만 runbook은 사후 수기라 drift를 스스로 감지하지 못한다 — 그 한계가 바로 트리거가 겨냥하는 지점이다.

결정 이력 — 어떻게 여기까지 왔나

2026-07-24
사내 인프라 자문으로 GCP + Cloud Run Jobs 확정(ADR-0005). JP 매거진 트랙의 배포 형태가 정해졌다 — 이때는 Terraform 논의 없음.
2026-08-05
뉴스 엔진 외부 설계 반입. Terraform/CI 경계·ignore_changes·state 원격 저장이 설계에 포함됐고, ADR-0007이 "Terraform 보류 + 도입 트리거 3개"로 확정.
2026-08-07
킥오프 — 엔진 MVP 확정과 함께 "트리거 (a) 발동 예고" 기록. 같은 날 API 6종 활성화(37→44종 — 7종이 켜진 그 사건).
2026-08-09
곽팀장 킥오프 승인 — 게이트 해제. 리소스 실생성·엔진 API 활성화·계약 발신이 가능해졌다. 같은 날 스프린트 패스트트랙(한시) 발효 — PR 사람 리뷰를 생략하고 CI 가드 그린을 유일한 머지 게이트로 쓴다(⛔ 불변 원칙·기계 가드·실운영 발행 전량 검수는 완화 대상 아님 — team-assignment §6).
M6 (착수 가능)
M6 예정 infra/ 신설 + 엔진 리소스 실생성. 여기서부터 이 문서의 파란 박스들이 현실이 된다.
09

도입되면 이렇게 된다 — M6 로드맵

요약infra/modules/ + infra/envs/prod/ 구조로 시작하고, 운영은 1단계 "GitHub Actions + GCS backend"로 — plan을 PR 코멘트로 붙이면 GUI 없이 충분하다.

M6에서 레포에 생기는 것:

  • infra/modules/ — 반복되는 리소스 묶음(module)
  • infra/envs/prod/ — 환경별 값. 지금은 prod 한 벌뿐(트리거 (c) 미발동이므로 dev를 미리 만들지 않는다)
  • .github/workflows/engine-infra.yml — 인프라 레인(plan → PR 코멘트 → 승인 → apply)
  • .github/workflows/deploy.yml — 앱 레인(빌드 → push → deploy)

state는 GCS 버킷 conket-tfstate에 원격 저장한다(backend). 이유는 한 문장이다 — "로컬 state는 2명 이상이 만지는 순간 충돌한다." GitHub Actions가 GCP 권한을 얻는 방식은 WIF로, SA 키 파일을 레포나 시크릿에 두지 않는다.

운영 도구는 왜 "GitHub Actions + GCS backend"인가 — 3단 로드맵
  1. 1단계 (권장): GitHub Actions + GCS backend — 무료. plan 결과를 PR 코멘트로 붙이면 리뷰·승인 흐름이 곧 UI가 된다.
  2. 2단계: GCP Infrastructure Manager — 콘솔에서 배포 미리보기·drift 조회가 필요해지면. state 관리가 필요 없어지는 대신 Cloud Build 비용이 든다.
  3. SaaS(HCP Terraform·Scalr·Spacelift 등): 현 단계 불필요 — 이 프로젝트의 리소스 규모는 20~30개라 전용 관리 서비스가 해결해 줄 문제가 아직 없다.
10

로컬 개발 환경 — 온보딩 체크리스트

요약팀 표준 로컬 셋업은 GCP 연동 4단계 + 로컬 DB + .env다. 기준 머신에서는 전 항목 완료 상태(2026-08-09 실측).
단계명령 · 대상확인 방법
1. gcloud 설치brew install --cask google-cloud-sdkgcloud --version
2. 사용자 로그인gcloud auth login (브라우저)gcloud auth list
3. 기본 프로젝트gcloud config set project vertical-news-portalgcloud projects describe vertical-news-portal
4. ADCgcloud auth application-default login자격 파일 생성 + quota project가 이 프로젝트인지
5. 로컬 DBpodman pgvector 컨테이너(:7230) + 마이그레이션 적용매거진 테이블 + engine 스키마 4테이블
6. .env.env.example 복사 후 값 채움GEMINI_API_KEY · DATABASE_URL · ENGINE_DATABASE_URL

연동이 제대로 됐는지 30초 검증 — 셋 다 통과해야 한다:

  • gcloud services list --enabled | wc -l44 (runbook 기록과 일치)
  • gcloud sql instances list0건 (리소스는 아직 없어야 정상 — 있으면 누가 만들었는지부터 확인)
  • 로컬 DB의 engine.sources에 epnc 시드 1행, enabled=false
기본 리전을 설정하지 말 것 gcloud config set run/region … 같은 전역 기본 리전을 잡아두지 않는다 — 이 프로젝트는 2리전(JP 도쿄 · 엔진 서울)이라, 기본값이 있으면 명령이 조용히 엉뚱한 리전에 떨어진다. 리전은 명령마다 명시한다.

참고 — 리소스를 실제로 만드는 명령은 킥오프 승인(2026-08-09) 완료로 이제 가능하다. 실행하면 docs/spec/deploy.md runbook에 기록하는 것이 팀 규칙이다.

11

FAQ · 흔한 오해

요약실제로 나올 법한 질문 6개로 경계를 다시 확인하고, 퀴즈로 마무리.
Q1. CI가 있는데 왜 Terraform이 또 필요한가요?
다루는 대상이 다르다. CI는 "테스트 → 빌드 → 배포"라는 절차를 자동화하고, Terraform은 "무엇이 존재하고 누가 권한을 갖는가"라는 상태를 관리한다. CI 스크립트로 리소스를 만들 수는 있지만, 그 스크립트는 상태를 기억하지 못해서 두 번 돌리면 두 개가 생기거나 에러가 난다. 그리고 실제 구성에서는 CI(GitHub Actions)가 Terraform 명령을 실행한다 — 경쟁 관계가 아니라 계층 관계다.
Q2. GitHub Actions의 cron으로 30분 주기 워커를 돌리면 GCP가 필요 없지 않나요?

ADR-0005가 검토하고 기각했다. 근거 4개:

  • 비용이 결정적 — 사이클당 3~5분 × 월 ~1,440회 = 월 4,320~7,200 러너-분. 유료 러너 단가로 월 ~$35~58. Cloud Run Jobs는 ~$1~5다. 10배 이상 차이나고, 방향도 반대다.
  • 공유 러너 큐 지연 — 30분 주기가 45분이 되는 것을 우리가 통제할 수 없다.
  • 러너 IP가 매 실행 바뀌어 Cloud SQL의 인증된 네트워크를 쓸 수 없다.
  • 시크릿이 GitHub Secrets로 복제된다.
흔한 오해 주의 기각된 것은 Actions를 사이클 실행 주체(스케줄러)로 쓰는 안이다. 배포(코드 푸시 → 빌드 → Cloud Run 반영)에는 Actions가 채택돼 있다. 둘은 다른 일이고, 이 레포는 둘 다 쓴다.
Q3. 언론사를 추가하면 Terraform 코드를 고쳐야 하나요?
아니다. 큐는 속도 등급별 공용(fast/normal/slow)이고 언론사는 등급에 배정될 뿐이라, 언론사 추가 = DB의 sources 행 하나다. 애초에 그렇게 되도록 "언론사별 큐 금지"를 설계 규칙으로 박아뒀다 — 운영 데이터가 인프라 코드에 새어 들어가면 운영자가 인프라 배포 권한을 요구하게 된다.
Q4. 시크릿 값도 코드로 관리하면 안 되나요? 어차피 리뷰도 되고 좋을 것 같은데요.
Terraform이 값을 알게 되는 순간 state 파일에 평문으로 남는다. state는 GCS에 저장되는 일반 파일이라 읽기 권한이 있는 누구나 값을 볼 수 있게 된다. 그래서 Terraform은 시크릿의 껍데기(존재와 권한)까지만 만들고, 값은 콘솔/CLI로 사람이 직접 넣는다.
Q5. 지금 바로 Terraform 코드를 쓰고 apply해도 되나요?
절차상으로는 이제 된다 — 리소스 실생성을 막던 킥오프 승인이 2026-08-09에 났다. 지금 apply를 막는 것은 절차가 아니라 "선언할 infra/ 코드가 아직 없다"는 사실뿐이고, M6 착수가 곧 그것을 만드는 일이다. 참고로 M6의 인프라 레인 설계 (plan → PR 코멘트 → environment 승인 → apply)에 있는 승인은 킥오프 승인과 다른 것이다 — 매 apply마다 도는 상시 장치라, 킥오프 승인이 났다고 사라지지 않는다.
Q6. Terraform으로 만든 리소스를 급해서 콘솔에서 고치면 어떻게 되나요?
drift가 된다. 다음 plan이 "코드와 다르다"며 되돌리는 변경을 제안할 것이고, 리뷰어는 콘솔 수정이 의도였는지 사고였는지 판단해야 한다. 의도라면 코드에 반영하는 것이 정석이다. 콘솔 직접 수정이 정상인 영역은 처음부터 정해져 있다 — 시크릿 값이 그것이다.
Q7. Firebase 프로젝트에 초대받았어요 — 이 프로젝트와 무슨 관계인가요?
같은 프로젝트다. Firebase 프로젝트는 GCP 프로젝트의 다른 얼굴(1:1 동일체)이고, vertical-news-portal은 실제로 Firebase 콘솔 경로로 생성됐다. "Blaze 요금제"는 결제 계정이 연결돼 있다는 뜻일 뿐이다. 이 레포는 Firebase 제품을 쓰지 않기로 결정했으므로(admin-web = Cloud Run SSR — Firebase 신규 의존 회피), 초대의 실질 의미는 GCP 리소스 접근 권한이다. 연동은 firebase CLI가 아니라 gcloud로 한다 — §10 체크리스트.

셀프체크 — 이 변경은 누구 소관인가?

각 상황을 읽고 4분면 중 하나를 고른 뒤 펼쳐서 확인.

1. worker 메모리를 1GiB에서 2GiB로 올리고 싶다
Terraform. 리소스의 설정값이다. infra PR → plan 리뷰 → 승인 → apply. 이때 plan에 ~ image: 줄이 없는지도 함께 본다.
2. 파서 버그를 고쳐서 새 버전을 내보내고 싶다
CI. 코드 배포다. 앱 레인이 테스트 → 빌드 → push → deploy를 수행하고, Terraform은 아무것도 몰라도 된다(ignore_changes가 그렇게 만들어 준다).
3. 두 번째 언론사와 계약이 성사돼 수집을 시작해야 한다
앱 데이터. sources 테이블에 행 하나를 넣는다. 배포도 apply도 없다 — 설계가 애초에 그렇게 돼 있다.
4. LLM API 키가 유출 의심돼서 교체해야 한다
콘솔/CLI. Secret Manager에서 새 버전을 넣는다. Terraform state에 값이 남으면 안 되므로 이 영역은 처음부터 코드 밖이다.
5. 인프라 PR의 plan 출력에 ~ image: v7 → v1이 보인다
멈추고 확인. ignore_changes 누락 신호다. 그대로 apply하면 앱이 v1으로 롤백된다 — 이 문서에서 가장 중요한 함정이었다.
12

용어집

요약본문의 점선 밑줄 용어가 여기로 연결된다. 핵심 표시 10개만 알아도 대화가 된다.
A · TERRAFORM 코어
IaC (Infrastructure as Code) 핵심
서버·DB·권한 같은 인프라를 콘솔 클릭이나 수기 명령이 아니라 코드 파일로 선언하고, 도구가 그 코드대로 현실을 만들게 하는 방식. 코드이므로 리뷰·이력·재현이 가능하다.
선언형 / 명령형 핵심
선언형은 "최종 상태"(큐가 1개 있고 초당 1건이어야 한다)를 적고, 명령형은 "절차"(큐 생성 명령을 실행하라)를 적는다. Terraform은 선언형, CI 스크립트는 명령형.
HCL
Terraform 설정 파일(.tf)을 쓰는 전용 언어. 이 문서의 ignore_changes 예제가 HCL이다.
provider
Terraform이 특정 클라우드(GCP 등)의 API를 다루게 해주는 플러그인. 이 레포는 google provider를 쓰게 된다.
state 핵심
Terraform이 "내가 만든 리소스가 현재 이렇다"라고 기억하는 장부 파일. 코드·state·실제 클라우드 3자를 비교해 변경분을 계산한다. 비유하면 시설팀의 자산 대장.
backend
state 파일의 저장 위치 설정. 이 레포는 GCS 버킷 conket-tfstate에 원격 저장(잠금 지원)할 계획 — 로컬 state는 2명 이상이 만지는 순간 충돌한다.
plan / apply 핵심
plan은 "코드대로 맞추려면 무엇이 바뀌는지" 미리보기, apply는 실제 반영. 이 레포 계획은 plan 결과를 PR 코멘트로 붙이고 승인 후 apply한다.
drift 핵심
실제 클라우드가 코드·state와 어긋난 상태. 콘솔 수동 변경이나 "6종을 켰는데 7종이 켜진" 것 같은 자동 의존 활성 같은 부수 효과로 생긴다.
멱등성
같은 작업을 몇 번 반복해도 결과가 같은 성질. apply를 두 번 해도 리소스가 두 개 생기지 않는다.
module
반복되는 리소스 묶음(예: Cloud Run 서비스 + SA + IAM)을 재사용 단위로 포장한 것. infra/modules/에 둘 계획이다.
lifecycle / ignore_changes 핵심
특정 필드는 Terraform이 되돌리지 않게 지정하는 설정. 이미지 태그는 CI가 관리하므로 [template[0].containers[0].image]를 반드시 무시시킨다.
B · GCP 리소스
Cloud Run: Service vs Job 핵심
Service는 요청을 기다리는 상주형(worker·api·admin-web), Job은 한 번 실행하고 종료 (JP 매거진의 30분 사이클, backfill). 같은 Cloud Run이라도 실행 모델이 다르다.
Cloud Tasks
작업을 큐에 넣고 정해진 속도로 꺼내 보내는 GCP 큐. engine-normal 큐가 설정으로 "외부 사이트에 초당 1요청"을 강제한다 — 예의(정중한 수집)가 인프라 설정으로 명문화된 예.
SA (서비스 계정) 핵심
사람이 아닌 프로그램이 쓰는 GCP 계정. 서비스마다 분리해 최소 권한만 준다 — MVP 4개(worker·api·backfill·admin-web), 목표 5개(+dispatcher). 외부 HTML을 파싱하는 worker가 공격 표면이 가장 커서 분리가 필수다.
IAM 핵심
"누가(SA) 무엇을(버킷 쓰기 등) 할 수 있나"를 정하는 GCP 권한 체계. Terraform 관리 대상이다 — 권한이 코드 리뷰를 거치게 하는 것이 도입의 핵심 이유 중 하나.
Artifact Registry
빌드된 컨테이너 이미지를 보관하는 GCP 저장소. 저장소 자체는 Terraform이, 이미지 push는 CI가 담당한다.
Secret Manager
시크릿 저장소. Terraform은 "껍데기"(시크릿이 존재한다는 사실과 접근 권한)까지만 만들고 값은 콘솔/CLI로 넣는다 — state에 평문으로 남기 때문.
C · CI / GITHUB
OIDC
토큰 기반 신원 증명 표준. Scheduler가 worker를 호출할 때 OIDC 토큰으로 자신을 증명한다 — 아무나 worker 엔드포인트를 때릴 수 없게.
WIF (Workload Identity Federation)
GitHub Actions가 SA 키 파일 없이 GCP 권한을 얻는 방식. 키 파일이 없으니 유출될 키도 없다.
paths-filter
모노레포에서 바뀐 경로를 감지해 필요한 워크플로만 돌리는 GitHub Actions 기법. infra/** 변경은 인프라 레인으로, 앱 코드 변경은 앱 레인으로 보낸다. 현재 ci.yml에 초안이 있다.
environment 승인
특정 environment에 배포하기 전 지정 리뷰어의 수동 승인을 요구하는 GitHub Actions 장치. apply 앞의 사람 게이트로 쓴다 — "리소스 실생성은 승인 후"라는 팀 규칙의 기계화.
runbook 핵심
운영 작업을 사람 손으로 재현할 수 있게 적어둔 절차 문서. Terraform 도입 전까지 docs/spec/deploy.md가 그 역할이다 — 명령을 실행하면 문서에 기록한다.
D · 계정 · 인증
gcloud CLI / 사용자 인증
터미널에서 GCP를 다루는 공식 CLI. gcloud auth login은 "사람"의 자격이며 gcloud 명령에만 쓰인다 — 코드가 부르는 API에는 적용되지 않는다.
ADC (Application Default Credentials) 핵심
라이브러리·Terraform이 자동으로 찾는 로컬 자격 파일. gcloud auth application-default login으로 발급하며, 사용자 인증과 별개라 둘 다 해야 한다. 과금·쿼터가 잡히는 quota project도 이 파일에 기록된다.
Blaze / 결제 계정
Firebase 쪽에서 부르는 종량제 요금제 이름 = GCP 결제 계정이 프로젝트에 연결된 상태. 연결 자체로는 과금되지 않고, 리소스를 만든 시점부터 과금된다.
리소스 계층
조직 → 프로젝트 → 리소스. IAM 권한은 위에서 아래로 상속되고 비용은 프로젝트 단위로 집계된다(트랙 구분은 리전·라벨로). 이 프로젝트는 콘캣 조직 하위다.