Kubernetes에 PostgreSQL 올리기 — CloudNativePG + PostGIS
서비스에 데이터베이스를 붙였습니다. 시작하기 전에 원칙부터 정했습니다.
원칙: DB는 저장소일 뿐
| 위치 | 역할 |
|---|---|
| 애플리케이션 (api) | 도메인 규칙, 검증, 트랜잭션 경계 |
| PostgreSQL | 저장과 조회만. 저장 프로시저나 트리거에 업무 규칙을 넣지 않습니다 |
| 스키마 | 앱 저장소의 db/schema.sql (Atlas 선언형). 앱은 스키마를 바꾸지 않습니다 |
| 프론트(web) | DB에 직접 붙지 않고 api만 거칩니다 |
이렇게 해 두면 DB는 언제든 바꿀 수 있는 부품이 됩니다. 나중에 클라우드 관리형 DB로 옮길 때 접속 정보만 바꾸면 됩니다.
왜 PostgreSQL인가
단순 저장·조회만 보면 MySQL도 충분합니다. 그래도 PostgreSQL을 고른 이유는 앞으로 붙일 기능 때문입니다.
- Kubernetes Operator가 가장 성숙합니다. CloudNativePG(CNCF)는 장애 조치, 백업, 시점 복구, 업그레이드를 YAML 선언으로 처리합니다.
- PostGIS. 위치 기반 기능을 붙일 계획이라 공간 쿼리가 필요합니다.
- 조회 도구가 많습니다. 부분 인덱스,
JSONB+ GIN, 전문 검색. 업무 규칙이 아니라 조회 성능 도구라서 원칙과도 맞습니다. - 이식성. 어느 클라우드에서나 관리형으로 제공됩니다.
대량 트렌드 데이터는 ClickHouse, 벡터는 전용 벡터 DB로 따로 둘 계획입니다. 서비스 DB에 다 넣지 않습니다.
구성
platform/database CloudNativePG Operator (Helm) — 클러스터에 한 번
envs/dev, envs/prd Cluster "pg" — PostgreSQL 18 + PostGIS, 인스턴스 1, 5Gi
Operator는 클라우드로 치면 "DB 엔진"이고, 각 환경은 Cluster 리소스 하나만 선언합니다.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgis:18.6-3.6.4-standard-trixie
storage:
size: 5Gi
postgresql:
parameters:
shared_buffers: 96MB
timezone: Asia/Seoul
log_min_duration_statement: "500"
bootstrap:
initdb:
database: claudharmony
owner: app
postInitApplicationSQL:
- CREATE EXTENSION IF NOT EXISTS postgis;
managed:
roles:
- name: readonly
login: true
connectionLimit: 5
inRoles: [pg_read_all_data]
passwordSecret:
name: pg-readonly
실제로는 이 YAML을 Terraform 모듈로 감싸서 환경별로 메모리와 스토리지만 다르게 넘깁니다.
만들어지는 것
| 종류 | 이름 | 용도 |
|---|---|---|
| Service | pg-rw |
읽기·쓰기 — 앱은 여기로 |
| Service | pg-ro |
읽기 전용 복제본 (인스턴스 2개 이상일 때) |
| Secret | pg-app |
앱 계정. Operator가 자동 생성 |
| Secret | pg-readonly |
조회 계정. Terraform이 랜덤 생성 |
계정은 두 개로 나눴습니다.
app— DB 소유자. api와 스키마 적용(Atlas)이 씁니다.readonly—pg_read_all_data역할만. DB 툴, 어드민 콘솔, 분석용입니다.CREATE TABLE을 시도하면permission denied가 나는 것까지 확인했습니다.
앱에 연결
서비스 목록(JSON)에 한 줄만 넣으면 됩니다.
"api": { "port": 8080, "database": true }
그러면 Deployment에 아래 환경변수가 들어갑니다. 비밀번호는 Secret을 참조하므로 코드나 저장소에 남지 않습니다.
SPRING_DATASOURCE_URL jdbc:postgresql://pg-rw.<env>.svc.cluster.local:5432/claudharmony
SPRING_DATASOURCE_USERNAME ← Secret pg-app
SPRING_DATASOURCE_PASSWORD ← Secret pg-app
Spring Boot는 JDBC 의존성이 없으면 이 값을 무시합니다. 그래서 인프라를 먼저 깔아 두고, 앱이 JDBC를 붙이는 순간 연결되게 했습니다.
처음에는 앱이 기동할 때 Flyway로 테이블을 만들었습니다. 그런데 운영에서는 "배포 = 스키마 변경"이 되는 게 위험해서, 같은 날 Atlas 선언형으로 바꿨습니다. dev는 배포 전에 자동으로 적용하고, prd는 계획을 확인한 뒤 수동으로 적용합니다. 앱은 기동할 때 필요한 테이블과 컬럼이 있는지만 확인합니다.
DB 툴로 들여다보기
클러스터 안 DB는 외부에 열지 않았습니다. 대신 스크립트 하나로 port-forward를 엽니다.
scripts/db.sh dev # 기본은 readonly
scripts/db.sh dev app # 쓰기가 필요할 때만
접속 정보를 보여주고, 비밀번호는 화면에 찍지 않고 클립보드로 복사합니다. TablePlus나 DBeaver에 붙여넣으면 끝입니다.
막혔던 곳
initdb SQL에서 managed role에 권한 주기
처음에는 postInitApplicationSQL에 GRANT pg_read_all_data TO readonly;를 넣었습니다. 그런데 initdb 시점에는 managed role이 아직 만들어지기 전일 수 있습니다. 권한은 role 선언의 inRoles로 옮겼습니다.
버전 고르기
PostGIS 이미지 태그가 1,000개가 넘어서 레지스트리 API가 한 번에 다 주지 않습니다. 페이지를 넘겨 가며 PostgreSQL 18 태그를 찾았습니다. 처음 결과만 보면 17이 최신처럼 보입니다.
남은 것
- 백업. 지금 데이터는 로컬 클러스터 노드 안에 있습니다. 노드 재시작은 괜찮지만 클러스터를 다시 만들면 사라집니다. 다음 단계로 오브젝트 스토리지에 base backup과 WAL을 보내고, 재생성 시 복구되게 하겠습니다.
- 그 다음은 Redis, 벡터 DB, 분석 DB 순서입니다.