GCP 프로젝트·결제·인증부터 Terraform과 CI의 역할 분담까지 — 이 프로젝트의 인프라 전체를 신입 눈높이로 설명한다. 아직 리소스도 Terraform 코드도 없는 지금이 배우기 가장 좋은 시점이다: 무엇이 왜 그 순서로 생기는지 처음부터 볼 수 있다.
infra/가 생긴다.docs/spec/deploy.md에 손으로 기록해 재현성을 확보한다.이 문서의 시제 표기 규칙 — 지금 있음 은 레포·GCP에 실재하는 것, M6 예정 은 계획만 확정된 것. 신입이 가장 흔히 하는 오해가 "계획 문서에 있으니 이미 있겠지"다.
vertical-news-portal을 공유한다(리전으로 분리).일본어 매거진용 기사 생성 워커. 30분마다 Cloud Run Job이 떠서 수집→작성→검수 큐 적재를 1사이클 돌고 종료한다. 상주 서버가 없어 월 비용이 약 $15~25 수준. 리소스는 runbook(gcloud 수기 명령)으로 만든다.
테크월드(epnc.co.kr) 1소스로 시작하는 수집 엔진 MVP. 상주 서비스 3개 + 잡 1개 + 큐·버킷·DB로 구성이 훨씬 크고, 서비스 계정을 쪼개 최소 권한을 준다. 이 트랙의 리소스 생성이 Terraform 도입 시점이다.
.github/workflows/ci.yml(테스트 CI 초안),
루트 Dockerfile, packages/(공용 패키지)가 실재하고 — infra/ 디렉토리는 아직 없다.
개발 머신 쪽은 gcloud 로그인 · 기본 프로젝트 · ADC까지 연동 완료(2026-08-09 실측 — §10 체크리스트).
GCP의 권한과 비용은 계층을 따라 흐른다: 조직 → 프로젝트 → 리소스. 이 프로젝트의 현재 위치(2026-08-09 실측):
| 축 | 상태 | 의미 |
|---|---|---|
| 조직 소속 | 콘캣 조직 하위 확인됨 | 회사 조직의 정책·권한 상속 아래 — 개인 프로젝트가 아니다 |
| 결제 연결 | 연결됨 · Blaze 확인됨 | 리소스를 만들면 과금이 시작될 수 있는 상태. 지금은 리소스 0개라 청구 ~$0 |
| 활성 API | 44종 확인됨 | 요구 6종 + Firebase 생성 기본분 + 자동 의존. 엔진 전용 3종(cloudtasks 등)은 아직 꺼짐 — 승인 완료(08-09)로 이제 켤 수 있다(M6 첫 작업) |
| 실리소스 | 0개 | Cloud SQL·Cloud Run·버킷 전부 미생성 — "선언할 것"이 아직 없는 상태(§08과 연결) |
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 |
"로그인"이 하나가 아니라는 것이 GCP 인증의 첫 관문이다. 터미널에서 gcloud 명령을 칠 때의 자격과, 같은 머신에서 Terraform이나 파이썬 라이브러리가 API를 부를 때의 자격 (ADC)은 별개 파일이다. 그리고 CI와 배포된 서비스는 아예 다른 방식 — 키 없는 토큰 교환(WIF)과 서비스 계정(SA) — 을 쓴다.
gcloud auth login 했는데 Terraform이 인증 오류를 낸다" — 정상이다.
사용자 인증(①)과 ADC(②)는 별개 자격이라 둘 다 해야 한다.
반대로 이 두 자격 파일 중 무엇도 CI에 올리지 않는다 — 그 자리를 위해 WIF가 존재한다.
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) 다음 점검 때 드러난다.
신입이 가장 먼저 부딪히는 혼란이 이것이다. 배포 자동화(CI)가 이미 있는데 Terraform은 뭘 더 해주나? 원 설계 문서의 표현을 그대로 빌리면 — "대체재가 아니라 계층입니다. CI가 Terraform을 실행합니다." 건물 비유로: 시설팀(Terraform)과 이삿짐 운송(CI)은 둘 다 "건물에서 일하는" 주체지만 하는 일이 겹치지 않고, 이사 트럭이 들어오려면 시설팀이 만들어 둔 출입구와 권한이 먼저 있어야 한다.
ci.yml(pytest + 경로 분기)로 씨앗이 이미 심겨 있고, 배포 스텝이 붙으면 완성된다.
아래 그림이 이 문서의 중심이다. 박스의 테두리 색이 "껍데기를 누가 만드나"를, 박스 안의 색 칩이 "내용물을 누가 채우나"를 나타낸다. 왼쪽 JP 트랙은 runbook(수기 gcloud)으로 만들고, 오른쪽 엔진 트랙은 M6부터 Terraform이 만든다 — 한 프로젝트 안에 두 관리 방식이 나란히 놓인, 이 레포만의 과도기 그림이다.
| 대상 | 관리 주체 | 이유 |
|---|---|---|
| Cloud Run 존재 · 설정 · 권한 | Terraform | 재현성과 감사 — 리뷰를 거친 코드가 곧 현재 상태 |
| 컨테이너 이미지 태그 | CI | Terraform이 쥐면 코드 배포 때마다 apply가 필요해진다 |
| 언론사 목록 (sources 행) | 앱 데이터 | 운영자가 언론사를 추가할 때 배포가 필요해지면 안 된다 |
| 시크릿 값 | 콘솔/CLI | Terraform이 관리하면 state 파일에 평문으로 남는다 |
관리 주체는 위 표의 넷(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 역할 |
앞의 표가 GCP가 파는 제품이었다면, 이 넷은 우리가 만든 프로그램이다. AWS 대응이 없는 것이 당연하다 — 클라우드를 옮겨도 이름은 그대로고 밑에 깔리는 런타임만 바뀐다. 그림 ①에서 파란 테두리 + 초록 칩으로 그려진 박스들이 정확히 이 넷이다: 껍데기(Cloud Run 서비스·잡이 존재한다는 사실과 권한)는 Terraform, 그 안에 들어가는 내용물(이미지 태그)은 CI가 관리한다. 넷 다 엔진 트랙(서울)이고 M6 생성 예정이다.
기사 1건이 작업 단위다. Scheduler가 /fetch-feed를 때리면 RSS 목록을 읽어
기사 URL마다 태스크를 큐에 등록하고, 큐가 초당 1건씩 /parse-article로 되밀어 준다.
worker는 원본 HTML을 받아 파싱하기 전 상태 그대로 GCS에 넣은 뒤 본문을 추출해 DB에 적재한다.
넷 중 유일하게 외부 HTML을 직접 다뤄 공격 표면이 가장 크다 — 서비스 계정을 쪼개는 이유의 절반이 이 하나다.
사양 1GiB · 동시성 2 · min 0
권한 GCS 쓰기 · DB 쓰기 · Secret 읽기
쌓인 기사를 외부 서비스가 가져가는 통로. 소비 쪽은
GET /articles?updated_since=<커서>로 바뀐 것만 당겨간다(Pull 방식).
정정·삭제 접수로 내려간 기사를 응답에서 빼는 것도 여기 책임이라, "내렸다"가 실제로 통하는지는
이 API의 응답으로 확인한다. 넷 중 유일하게 min 1로 한 대를 늘 띄워 둔다 —
첫 요청이 콜드스타트를 만나지 않게 하기 위해서다(그만큼 상시 비용이 붙는다).
사양 512MiB · 동시성 80 · min 1
권한 DB 읽기·쓰기 (목표 구성에서 Pub/Sub 발행 추가)
셀렉터를 고쳤을 때 이미 수집한 기사를 다시 만드는 잡. 언론사에 다시 요청하지 않고
GCS에 남겨 둔 원본을 읽어 같은 행을 갱신한다 — worker가 원본을 파싱 전 상태로 보관하는 이유가 이것이다.
파서 코드는 worker와 공유한다(packages/parsers). 갈라지는 순간
신규 수집분과 재파싱분의 본문이 달라져, 같은 기사인데 언제 들어왔느냐로 내용이 갈린다.
사양 병렬 5~10(Cloud SQL 커넥션 제약) · 타임아웃 최대 168시간
권한 GCS 읽기 · DB 쓰기
언론사의 정정·삭제 요청을 접수하는 운영 화면. 계약이 요구하는 "즉시 반영"을 사람이 실행할 수 있게 하는 자리이고, 그래서 기술적으로 작아도 빠질 수 없다. DB를 직접 만지지 않고 api를 호출한다 — 그 결과 넷 중 GCP 권한이 가장 적다. 정적 SPA(Firebase Hosting)로 갈 수도 있었지만 Cloud Run SSR로 정해졌다.
사양 Cloud Run Service (SSR)
권한 사실상 없음 — api 호출만
5번이 좋은 연습문제다. 언론사별로 큐를 만들면 "언론사 추가 = 인프라 배포"가 돼버린다. 그래서 큐는 속도 등급별 공용으로 두고, 언론사 추가는 DB 행 하나로 끝나게 설계했다 — 무엇을 Terraform에 넣지 않을지가 넣을지만큼 중요하다.
lifecycle 설정 한 줄을 빼먹으면,
인프라를 배포할 때마다 앱이 옛 버전으로 롤백되는 사고가 난다.
경계가 "Terraform=껍데기 / CI=내용물"로 나뉘면 필연적으로 충돌 지점이 하나 생긴다.
Terraform으로 Cloud Run을 만들려면 image 필드를 채워야 하는데,
바로 그 필드를 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]
}
}
ignore_changes = [template[0].containers[0].image] 한 줄을 빼먹으면
인프라 배포마다 앱이 옛 버전으로 롤백되는 사고가 난다.
M6 계획은 전 서비스에 이 설정을 필수로 못박아 뒀다. 인프라 PR을 리뷰할 일이 생기면
이것부터 확인하라 — plan 출력에 ~ image: 줄이 보이면 무언가 잘못된 것이다.
"좋은 도구면 처음부터 쓰지, 왜 미뤘나?"는 정당한 질문이다. 팀의 답(ADR-0007 Decision 8): 현재 배포 산출물이 0인 단계에서 Terraform을 들이면 얻는 것 없이 state·backend 관리 비용만 추가된다. 도구는 문제가 생기는 시점에 들인다 — 단, "그 시점"을 감으로 정하지 않도록 트리거를 문서로 박아뒀다. 하나라도 충족되면 도입한다:
서비스 3~4개 + 큐 + 버킷 + DB + SA/IAM — 수기로 관리하기엔 리소스 그래프가 크다. 킥오프(2026-08-07)가 "M6 계획이 이걸 포함하므로 (a) 발동"을 기록했다.
두 사람이 각자 콘솔을 만지기 시작하면 "누가 뭘 바꿨는지"를 코드와 state가 대신 기억해야 한다.
같은 구성을 환경 두 벌로 복제해야 하는 순간, 선언형 코드의 재사용 가치가 커진다. 아직 미발동.
docs/spec/deploy.md가 runbook이 되어 메운다:
인프라 명령을 실행하면 그 명령과 결과를 문서에 그대로 기록한다. "6종→7종" 사건이 기록으로 남은 것도
이 규칙 덕분이다. 다만 runbook은 사후 수기라 drift를 스스로 감지하지 못한다 —
그 한계가 바로 트리거가 겨냥하는 지점이다.
ignore_changes·state 원격 저장이
설계에 포함됐고, ADR-0007이 "Terraform 보류 + 도입 트리거 3개"로 확정.infra/ 신설 + 엔진 리소스 실생성.
여기서부터 이 문서의 파란 박스들이 현실이 된다.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 키 파일을 레포나 시크릿에
두지 않는다.
| 단계 | 명령 · 대상 | 확인 방법 |
|---|---|---|
| 1. gcloud 설치 | brew install --cask google-cloud-sdk | gcloud --version |
| 2. 사용자 로그인 | gcloud auth login (브라우저) | gcloud auth list |
| 3. 기본 프로젝트 | gcloud config set project vertical-news-portal | gcloud projects describe vertical-news-portal |
| 4. ADC | gcloud auth application-default login | 자격 파일 생성 + quota project가 이 프로젝트인지 |
| 5. 로컬 DB | podman pgvector 컨테이너(:7230) + 마이그레이션 적용 | 매거진 테이블 + engine 스키마 4테이블 |
| 6. .env | .env.example 복사 후 값 채움 | GEMINI_API_KEY · DATABASE_URL · ENGINE_DATABASE_URL 등 |
연동이 제대로 됐는지 30초 검증 — 셋 다 통과해야 한다:
gcloud services list --enabled | wc -l → 44 (runbook 기록과 일치)gcloud sql instances list → 0건 (리소스는 아직 없어야 정상 — 있으면 누가 만들었는지부터 확인)engine.sources에 epnc 시드 1행, enabled=falsegcloud config set run/region … 같은 전역 기본 리전을 잡아두지 않는다 —
이 프로젝트는 2리전(JP 도쿄 · 엔진 서울)이라, 기본값이 있으면 명령이
조용히 엉뚱한 리전에 떨어진다. 리전은 명령마다 명시한다.
참고 — 리소스를 실제로 만드는 명령은 킥오프 승인(2026-08-09) 완료로 이제 가능하다.
실행하면 docs/spec/deploy.md runbook에 기록하는 것이 팀 규칙이다.
ADR-0005가 검토하고 기각했다. 근거 4개:
infra/ 코드가 아직 없다"는 사실뿐이고,
M6 착수가 곧 그것을 만드는 일이다. 참고로 M6의 인프라 레인 설계
(plan → PR 코멘트 → environment 승인 → apply)에 있는 승인은 킥오프 승인과 다른 것이다 —
매 apply마다 도는 상시 장치라, 킥오프 승인이 났다고 사라지지 않는다.
vertical-news-portal은 실제로 Firebase 콘솔 경로로 생성됐다.
"Blaze 요금제"는 결제 계정이 연결돼 있다는 뜻일 뿐이다. 이 레포는 Firebase 제품을 쓰지 않기로
결정했으므로(admin-web = Cloud Run SSR — Firebase 신규 의존 회피), 초대의 실질 의미는
GCP 리소스 접근 권한이다. 연동은 firebase CLI가 아니라 gcloud로 한다 — §10 체크리스트.
각 상황을 읽고 4분면 중 하나를 고른 뒤 펼쳐서 확인.
~ image: 줄이 없는지도 함께 본다.~ image: v7 → v1이 보인다conket-tfstate에 원격 저장(잠금 지원)할
계획 — 로컬 state는 2명 이상이 만지는 순간 충돌한다.infra/modules/에 둘 계획이다.[template[0].containers[0].image]를 반드시 무시시킨다.engine-normal 큐가
설정으로 "외부 사이트에 초당 1요청"을 강제한다 — 예의(정중한 수집)가 인프라 설정으로 명문화된 예.infra/** 변경은 인프라 레인으로, 앱 코드 변경은 앱 레인으로 보낸다.
현재 ci.yml에 초안이 있다.docs/spec/deploy.md가 그 역할이다 — 명령을 실행하면 문서에 기록한다.gcloud auth login은 "사람"의 자격이며
gcloud 명령에만 쓰인다 — 코드가 부르는 API에는 적용되지 않는다.gcloud auth application-default login으로 발급하며, 사용자 인증과 별개라
둘 다 해야 한다. 과금·쿼터가 잡히는 quota project도 이 파일에 기록된다.