# 개요

Spring Boot 앱을 GCP Cloud Run에 배포하고, Cloud SQL(PostgreSQL)과 연결하는 전체 과정을 명령어 단위로 정리한다. Cloud Scheduler로 크론잡 트리거까지 세팅하는 것까지 포함한다.


# 1. GCP 프로젝트 설정

gcloud config set project PROJECT_ID

gcloud projects list로 프로젝트 ID를 먼저 확인한다. 콘솔 URL(console.cloud.google.com/...?project=PROJECT_ID)에서도 확인 가능하다.

프로젝트 ID vs 프로젝트 이름

GCP 콘솔에서 보이는 이름(예: "Dignify")과 실제 프로젝트 ID(예: dignify-501004)는 다를 수 있다. gcloud 명령어에는 항상 프로젝트 ID를 써야 한다.


# 2. 필요한 API 활성화

gcloud services enable \
  run.googleapis.com \
  sqladmin.googleapis.com \
  artifactregistry.googleapis.com \
  cloudscheduler.googleapis.com

GCP는 서비스별로 API를 명시적으로 켜야 사용할 수 있다. 활성화 자체는 무료다.

API 용도
run.googleapis.com Cloud Run — 컨테이너 실행
sqladmin.googleapis.com Cloud SQL — PostgreSQL 관리
artifactregistry.googleapis.com Docker 이미지 저장소
cloudscheduler.googleapis.com 크론잡 스케줄러

# 3. Artifact Registry — Docker 저장소 생성

gcloud artifacts repositories create REPO_NAME \
  --repository-format=docker \
  --location=REGION

Docker 이미지를 올려둘 저장소다. ECR(AWS), Docker Hub와 동일한 역할.

  • --location: Cloud Run과 같은 리전으로 설정해야 한다. 리전이 다르면 이미지를 pull할 때 네트워크 비용이 발생하고 속도도 느리다.
  • 비용: $0.10/GB/월. 이미지 1개 기준 약 $0.05/월 수준.

# Docker 인증 설정

gcloud auth configure-docker REGION-docker.pkg.dev

로컬 Docker가 Artifact Registry에 push/pull할 수 있도록 인증을 연결한다. 처음 한 번만 실행하면 된다.


# 4. Cloud SQL 인스턴스 + DB + 유저 생성

# 인스턴스 생성 (3~5분 소요)
gcloud sql instances create INSTANCE_NAME \
  --database-version=POSTGRES_16 \
  --tier=db-f1-micro \
  --region=REGION \
  --edition=ENTERPRISE
  • --tier=db-f1-micro: 가장 저렴한 스펙 (공유 vCPU, RAM 0.6GB). 토이 프로젝트 수준이면 충분하다.
  • --edition=ENTERPRISE: GCP Cloud SQL은 현재 Enterprise / Enterprise Plus 두 에디션이 있다. db-f1-micro 같은 소형 머신은 Enterprise에서만 지원된다. --edition을 명시하지 않으면 GCP가 Enterprise Plus로 생성하려 하고, 소형 머신 티어가 지원되지 않아 에러가 난다.
  • 비용: db-f1-micro Enterprise 기준 약 $7.65/월. 인스턴스가 켜져 있는 동안 요청 유무와 관계없이 과금된다.

Cloud SQL은 항상 켜져있는 비용이다

Cloud Run은 요청이 없으면 과금이 없지만, Cloud SQL은 인스턴스가 살아있는 동안 계속 과금된다. 개발/테스트가 끝나면 인스턴스를 중지하거나 삭제하는 걸 잊지 말자.

# DB 생성
gcloud sql databases create DB_NAME --instance=INSTANCE_NAME

# 유저 생성
gcloud sql users create DB_USER \
  --instance=INSTANCE_NAME \
  --password=DB_PASSWORD

# 5. Docker 이미지 빌드 & 푸시

IMAGE=REGION-docker.pkg.dev/PROJECT_ID/REPO_NAME/APP_NAME

docker build --platform linux/amd64 -t $IMAGE .
docker push $IMAGE
  • --platform linux/amd64: M1/M2 Mac은 기본적으로 arm64 아키텍처로 빌드된다. Cloud Run은 linux/amd64 환경이므로 이 옵션을 반드시 명시해야 한다. 없으면 Cloud Run에서 실행 불가.

# 6. Cloud Run 배포

# 인스턴스 연결 이름(connectionName) 조회

INSTANCE_CONNECTION_NAME=$(gcloud sql instances describe INSTANCE_NAME \
  --format='value(connectionName)')

echo $INSTANCE_CONNECTION_NAME
# 출력 예: dignify-501004:us-central1:dignify

connectionNamePROJECT_ID:REGION:INSTANCE_NAME 형태의 문자열이다. Cloud Run이 Cloud SQL 소켓을 마운트할 때 이 값으로 인스턴스를 식별한다.

  • --format='value(connectionName)': gcloud describe 결과에서 connectionName 필드만 추출하는 옵션. 전체 JSON 출력 대신 값만 꺼내서 셸 변수에 바로 담는다.

# Cloud Run 서비스 배포

gcloud run deploy APP_NAME \
  --image=REGION-docker.pkg.dev/PROJECT_ID/REPO_NAME/APP_NAME \
  --region=REGION \
  --platform=managed \
  --allow-unauthenticated \
  --add-cloudsql-instances=$INSTANCE_CONNECTION_NAME \
  --set-env-vars="\
DB_URL=jdbc:postgresql:///DB_NAME?cloudSqlInstance=${INSTANCE_CONNECTION_NAME}&socketFactory=com.google.cloud.sql.postgres.SocketFactory,\
DB_USERNAME=DB_USER,\
DB_PASSWORD=DB_PASSWORD,\
JWT_SECRET=JWT_SECRET_VALUE,\
CRON_SECRET=CRON_SECRET_VALUE"

배포 옵션:

  • --platform=managed: GCP가 인프라를 완전 관리하는 모드. 서버 관리 불필요.
  • --allow-unauthenticated: iOS 클라이언트 등 외부에서 API를 인증 없이 호출할 수 있도록 공개 접근 허용.
  • --add-cloudsql-instances=$INSTANCE_CONNECTION_NAME: Cloud Run 컨테이너 안에 /cloudsql/... 유닉스 소켓을 마운트하고 빌트인 프록시를 띄운다. 단, 이 문서는 아래처럼 소켓 팩토리 라이브러리(socketFactory=)로 붙기 때문에 마운트된 이 소켓은 실제로 쓰이지 않는다 — 플래그가 있어도 무해하지만 이 구성에선 중복이다. (자세한 관계는 아래 '설정' 절 참고)

환경변수(--set-env-vars):

변수 설명
DB_URL jdbc:postgresql:///DB_NAME?cloudSqlInstance=...&socketFactory=... 소켓 팩토리 방식 JDBC URL. application.properties에서 이 값이 있으면 TCP fallback을 무시하고 이 URL을 사용한다.
DB_USERNAME Cloud SQL 유저명 gcloud sql users create로 만든 유저
DB_PASSWORD Cloud SQL 비밀번호 유저 생성 시 설정한 비밀번호
JWT_SECRET 임의의 시크릿 문자열 access/refresh token 서명용 HS256 키
CRON_SECRET 임의의 시크릿 문자열 /internal/cron/collect 엔드포인트 인증 헤더 값

DB_URL의 `///` (슬래시 3개)

jdbc:postgresql:///DB_NAME — 호스트 부분이 비어있다(// 다음 바로 /DB_NAME). TCP 방식이라면 //localhost:5432/DB_NAME처럼 호스트를 명시하지만, 소켓 팩토리 방식은 접속 대상을 호스트:포트가 아니라 socketFactory·cloudSqlInstance 파라미터가 정하므로 호스트를 생략한다. 소켓 팩토리가 cloudSqlInstance 값으로 인스턴스를 식별해 직접 mTLS 연결을 연다(마운트된 /cloudsql 소켓을 쓰는 게 아니다).

  • 비용: 요청이 없으면 과금 없음. 요청 수 + CPU 사용 시간 기준 과금. 트래픽이 거의 없는 수준이면 $0~1/월.

# Cloud SQL 접속 방식

Cloud Run에서 Cloud SQL에 붙는 방식은 두 개의 독립된 축으로 갈린다. 헷갈리기 쉬운데, 이 둘은 서로 별개이며 조합해서 쓴다.

  • ① 인터페이스 — 앱이 무엇에 대고 붙느냐
  • ② 경로 — 트래픽이 어느 IP로 나가느냐

# ① 인터페이스

방식 실제 동작 설정
소켓 팩토리 라이브러리 (이 문서) 자바 라이브러리가 Admin API로 인증서를 받아 자체 mTLS 연결을 연다. OS 유닉스 소켓이 아니다. 의존성 추가 + JDBC URL 변경
유닉스 소켓 (/cloudsql/...) --add-cloudsql-instances가 마운트한 소켓 파일에 붙는다. 빌트인 프록시가 뒤에서 중계. URL에 host=/cloudsql/...
TCP 직결 인스턴스 IP:5432로 직접 TCP 접속 IP·인증 직접 관리

"socket factory"는 유닉스 소켓이 아니다

이름 때문에 오해하기 쉽다. 여기서 SocketFactory는 자바에서 Socket(TCP 연결) 객체를 만드는 표준 인터페이스 javax.net.SocketFactory의 이름일 뿐, OS의 유닉스 도메인 소켓과 무관하다. com.google.cloud.sql.postgres.SocketFactory는 마운트된 /cloudsql/... 소켓을 쓰지 않고, Cloud SQL Admin API로 인스턴스 주소·임시 인증서를 받아 직접 mTLS/TCP 연결을 연다.

# ② 경로

기본값 조건
공인 IP ✅ 소켓 팩토리 기본값 별도 설정 없음
사설 IP (VPC) 옵션 ipTypes=PRIVATE + 인스턴스 사설 IP + VPC 연결

이 문서 구성은 소켓 팩토리 + 공인 IP다. VPC는 켜지 않았다.

# 실제 통신 흐름 — 로컬 유닉스 소켓과 헷갈리지 말 것

로컬 개발의 유닉스 소켓과 Cloud Run의 소켓 팩토리는 겉보기만 비슷하고 실체가 다르다.

로컬 Postgres (진짜 유닉스 소켓) — 같은 머신, 네트워크 안 탐:

앱 → /var/run/postgresql/.s.PGSQL.5432 → postgres (DB 본체)

소켓 파일 반대편에 DB 본체가 직접 있어 네트워크를 거치지 않는다. TCP보다 빠르고 파일 권한으로 접근을 통제한다. 이 설명은 로컬에서만 참이다.

Cloud Run 소켓 팩토리 — Cloud SQL은 원격 머신이라 네트워크를 반드시 건넌다:

앱 → 소켓 팩토리(라이브러리) → mTLS/TCP → Cloud SQL (원격, 기본 공인 IP)

Cloud Run에서는 "네트워크를 안 탄다"가 성립하지 않는다. 보안 이점은 공인 IP가 없어서가 아니라, 라이브러리가 mTLS 암호화 + IAM 인증을 걸어주기 때문이다. 공인 IP로 나가더라도 인증된 클라이언트만 붙을 수 있다. 공인 IP 노출 자체를 없애려면 위 ②에서 사설 IP + VPC로 경로를 바꾼다.

mTLS (mutual TLS)

일반 TLS(HTTPS)는 서버만 인증서로 신원을 증명하고 클라이언트는 익명이다. mTLS는 여기에 더해 클라이언트도 인증서를 제시해 서로(mutual) 신원을 검증한다. 즉 "서버가 진짜 그 Cloud SQL인지" + "클라이언트가 허가된 앱인지"를 양방향으로 확인하고 통신을 암호화한다. 소켓 팩토리가 Admin API에서 받아온 임시 인증서로 이 과정을 자동 처리하므로, 공인 IP로 나가도 아무나 붙을 수 없다.

# 설정

소켓 팩토리를 쓰려면 build.gradle에 의존성을 추가하고, JDBC URL을 소켓 팩토리 형식으로 작성한다.

implementation 'com.google.cloud.sql:postgres-socket-factory:1.14.0'
# Cloud Run용 DB URL
jdbc:postgresql:///DB_NAME?cloudSqlInstance=PROJECT:REGION:INSTANCE&socketFactory=com.google.cloud.sql.postgres.SocketFactory

jdbc:postgresql:///DB_NAME에서 호스트가 비어 있는 이유는, 접속 대상을 호스트:포트가 아니라 socketFactory·cloudSqlInstance 파라미터가 정하기 때문이다. 소켓 팩토리가 cloudSqlInstance 값으로 인스턴스를 식별해 mTLS 연결을 연다.

로컬에서는 기존 TCP URL을 그대로 쓰고, Cloud Run에서만 이 URL을 환경변수로 주입한다.

# application.properties
# DB_URL이 있으면 그 값 사용, 없으면 TCP fallback
spring.datasource.url=${DB_URL:jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:dignify}}

`--add-cloudsql-instances`와의 관계

이 플래그는 /cloudsql/... 유닉스 소켓을 마운트하는 별개 방식이다. 위처럼 URL에 socketFactory=를 쓰면 라이브러리가 자체 연결을 열기 때문에 마운트된 소켓은 사용되지 않는다(플래그가 있어도 무해하지만 이 구성에선 중복). 반대로 소켓 팩토리 라이브러리 없이 붙으려면, 이 플래그로 마운트한 뒤 URL에 host=/cloudsql/PROJECT:REGION:INSTANCE를 지정하면 된다.

# 세 가지 연결 방식을 하나로 — "앞단만 다르고 뒷단은 같다"

여기까지 등장한 연결 방식이 사실 세 가지다. 로컬 크론잡(Cloud SQL Auth Proxy 문서)까지 합치면 전부 "프록시를 어디에 두고 앱이 그 앞단에 뭘로 붙느냐"만 다르고, 프록시 뒷단(→ Cloud SQL)은 셋 다 똑같이 TCP mTLS:3307이다.

방식 프록시 위치 앱이 붙는 곳(앞단) 뒷단(→ Cloud SQL)
크론잡 (로컬) 독립 cloud-sql-proxy 프로세스 (스크립트가 띄움) localhost:5433 평문 TCP TCP mTLS:3307
Cloud Run (이 문서) 소켓 팩토리 라이브러리 (앱 JVM 내장) 없음 — 앱이 직결 TCP mTLS:3307
유닉스 소켓 마운트 Cloud Run 관리 프록시 (같은 인스턴스 사이드카) /cloudsql/... 소켓 파일 TCP mTLS:3307
  • 앞단이 다른 이유: 프록시를 프로세스로 띄우면 로컬 포트(TCP)로, 소켓을 마운트하면 소켓 파일(IPC)로, 라이브러리로 흡수하면 앞단 자체가 없다.
  • 뒷단이 같은 이유: 프록시는 어차피 다른 인스턴스인 Cloud SQL로 나가야 하고, 네트워크 경계를 넘는 방법은 TCP mTLS 하나뿐이라 선택지가 없다.
  • 소켓 팩토리 방식은 "프록시가 없다"기보다 프록시가 하던 일을 앱 JVM 안 라이브러리가 흡수해 홉을 하나 줄인 형태로 보면 된다.

"유닉스 소켓 마운트 = IPC로 Cloud SQL과 통신"은 오해

유닉스 도메인 소켓(IPC)은 같은 커널을 공유하는 프로세스끼리만 가능한 로컬 통신이다. Cloud Run과 Cloud SQL은 물리적으로 다른 인스턴스이므로 소켓 파일로 직접 붙을 수 없다. 그래서 IPC 구간은 앱 ↔ 같은 인스턴스 안의 관리 프록시까지이고, 그 프록시가 인스턴스 경계를 넘는 순간 네트워크 TCP mTLS로 바뀐다. 즉 소켓 마운트 방식도 "끝까지 IPC"가 아니라 로컬 IPC(앞단) + 네트워크 TCP mTLS(뒷단) 의 조합이다.

# Admin API의 역할 vs 실제 암호화 주체

mTLS를 얘기할 때 가장 헷갈리는 지점이 "Cloud SQL Admin API가 암호화를 해준다"는 오해다. Admin API는 암호화를 하지 않는다 — 재료만 내주는 창구다.

담당 하는 일
Cloud SQL Admin API 재료 조달 ① 인스턴스 IP/메타데이터 ② 짧은 수명의 클라이언트 인증서 발급. 연결 데이터 경로엔 끼지 않고, 연결 전에 호출된다
프록시 / 소켓 팩토리 실제 암호화 그 인증서로 mTLS 핸드셰이크를 수행하고, 소켓을 유지하며 트래픽을 암호화한다

Admin API = 인증서 발급소, 실제 mTLS 암호화 = 프록시(또는 라이브러리). 프록시가 Cloud SQL에 붙기 직전 Admin API에 IP/메타데이터와 임시 클라이언트 인증서를 요청하고, 그 인증서로 양방향 인증을 수행한다. IP만으론 mTLS가 성립하지 않는다 — 클라이언트 인증서가 있어야 "나는 허가된 접속자"임을 증명할 수 있다.

TCP 핸드셰이크 vs TLS 핸드셰이크는 다른 층위다

"TCP도 3-way 핸드셰이크가 있는데 TLS랑 같은 거냐"는 흔한 질문. 둘은 경쟁이 아니라 순차로 겹쳐 일어난다.

1. TCP 3-way    : SYN → SYN-ACK → ACK      ← L4, "관을 연다"
2. TLS 핸드셰이크 : 인증서 교환·키 협상        ← TCP 위에서, "관을 암호화한다"
3. 실제 데이터   : 암호화된 트래픽

TCP는 밑바닥 연결(관)을 뚫는 전송 계층이고, TLS/mTLS는 그 관 위에 얹혀 암호화를 협상하는 상위 절차다. TCP 핸드셰이크가 끝나야 TLS 핸드셰이크를 시작할 수 있다. "프록시 → Cloud SQL의 TCP mTLS:3307"은 곧 3307로 TCP 연결(1) → 그 위에서 mTLS 핸드셰이크(2) → 암호화 통신(3) 이라는 뜻이다.


# 7. Cloud Scheduler — 크론잡 트리거

CLOUD_RUN_URL=$(gcloud run services describe APP_NAME \
  --region=REGION \
  --format='value(status.url)')

gcloud scheduler jobs create http JOB_NAME \
  --location=REGION \
  --schedule="0 2 * * *" \
  --time-zone="America/Phoenix" \
  --uri="${CLOUD_RUN_URL}/internal/cron/collect" \
  --http-method=POST \
  --headers="X-Cron-Secret=CRON_SECRET_VALUE"
  • --schedule="0 2 * * *": 매일 새벽 2시 (cron 표현식)
  • --time-zone: 기준 시간대. 생략하면 UTC 기준.
  • --headers: 크론잡 엔드포인트의 인증용 시크릿 헤더.
  • 비용: 월 3개 job까지 무료.

# 수동 실행으로 테스트

gcloud scheduler jobs run JOB_NAME --location=REGION

# 8. 배포 후 로그 확인

gcloud run services logs tail APP_NAME --region=REGION

# 월 예상 비용 요약

서비스 비용
Cloud SQL db-f1-micro ~$7.65/월
Cloud Run ~$0~1/월
Artifact Registry ~$0.05/월
Cloud Scheduler 무료 (3개 이하)
합계 ~$8~9/월

예산 알림은 GCP 콘솔 → 결제 → 예산 및 알림에서 설정할 수 있다. 단, GCP는 예산 초과 시 서비스를 자동으로 중단하지 않고 이메일 알림만 보낸다. 강제 차단이 필요하면 별도 자동화가 필요하다.