Kubernetes에 PostgreSQL 올리기 — CloudNativePG + PostGIS

DB는 저장과 조회만, 규칙은 애플리케이션에. 그 원칙으로 PostgreSQL 18 + PostGIS를 Operator로 띄우고 앱에 연결했습니다.

서비스에 데이터베이스를 붙였습니다. 시작하기 전에 원칙부터 정했습니다.

원칙: 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 순서입니다.
← 기술블로그 목록