날씨 정보를 불러오는 중...

Oracle에서 PostgreSQL로 갈아탄 이야기 - 홈서버 메모리를 절반 되찾은 DB 전환기

2026-09-26 19:46 · IT기술 · 조회 7
Oracle to PostgreSQL

Oracle에서 PostgreSQL로 갈아탄 이야기 - 홈서버 메모리를 절반 되찾은 DB 전환기

집에서 돌리는 쿠버네티스(k3s) 서버는 노드가 딱 한 대이고, 메모리는 14GB 남짓입니다. 이 블로그가 돌아가는 포털도 여기서 돌아가는데, 어느 날 모니터링 대시보드를 보다가 메모리 사용률이 70%를 넘어 있는 걸 보고 원인을 찾아보니 범인은 하나였습니다. 바로 Oracle 데이터베이스. 방문자 기록 몇만 건, 글 몇십 개, 계정 몇 개를 담는 DB가 서버 메모리의 3분의 1을 쥐고 있었던 거죠. 결국 PostgreSQL로 옮겼고, 결과는 4.4GB → 약 66MB. 오늘은 왜 옮겼는지, 코드에서 무엇을 바꿨는지, 데이터는 어떻게 옮겼는지, 그리고 실제로 부딪힌 함정들을 정리합니다.

왜 옮겼나: 작은 서비스에 너무 큰 DB

처음 Oracle을 고른 이유는 단순했습니다. 회사에서 오래 써서 손에 익었고, 무료판인 Oracle Database 23ai Free에 벡터 검색 같은 최신 기능도 들어 있어서 "나중에 AI 기능 붙이면 쓰겠지" 싶었거든요. 그런데 몇 달 운영해 보니 현실은 이랬습니다.

  1. 메모리 - 컨테이너 하나가 SGA/PGA 예약만으로 4~8GB를 잡고 있었습니다. 실측 사용량 약 4.4GiB.
  2. 기동 시간 - 파드가 재시작되면 DB가 열릴 때까지 몇 분씩 걸려서, 헬스체크(startupProbe)를 아주 길게 잡아야 했습니다.
  3. 실제로 쓰는 기능 - PL/SQL, 시퀀스, 트리거, MERGE, 벡터 검색… 하나도 안 쓰고 있었습니다. 평범한 테이블 CRUD뿐이었죠.
  4. 이미지 크기 - 수 GB짜리 이미지라 디스크도 빠듯한 단일 노드에 부담이었습니다.

"쓰지도 않는 기능 때문에 서버 자원의 3분의 1을 내주고 있다"는 결론이 나오니 고민할 이유가 없었습니다. 표준 SQL을 잘 지키면서 가볍고, 쿠버네티스에서 운영 사례도 가장 많은 PostgreSQL이 자연스러운 선택이었습니다.

Oracle과 PostgreSQL, 한눈에 비교

mem 메모리 - Oracle은 인스턴스가 뜰 때 공유 메모리(SGA)를 크게 예약합니다. PostgreSQL은 shared_buffers 기본값이 작고, 쓰는 만큼 늘어나서 소규모 서비스에선 수십 MB로 충분합니다.

db 문법 호환성 - 둘 다 SQL이지만 날짜 함수(SYSTIMESTAMP ↔ now()), NULL 처리(NVL ↔ COALESCE), 바인드 변수 표기(:name ↔ %(name)s) 같은 방언 차이가 꽤 있습니다.

tx 트랜잭션 동작 - PostgreSQL은 트랜잭션 안에서 에러가 한 번 나면 ROLLBACK 전까지 이후 모든 명령을 거부합니다. Oracle은 실패한 문장만 취소하고 계속 진행할 수 있죠. 이 차이가 이번 전환에서 가장 까다로웠습니다.

key 라이선스·배포 - Oracle Free는 무료지만 리소스 상한과 라이선스 조건이 있고, PostgreSQL은 완전한 오픈소스라 공식 이미지를 그대로 가져다 쓰면 됩니다.

1단계: PostgreSQL 올리기

쿠버네티스에 StatefulSet 하나로 올렸습니다. 데이터는 로컬 디스크 PVC(5Gi)에 저장하고, 접속 정보는 Secret으로 분리했습니다. 앱은 같은 네임스페이스의 ClusterIP 서비스(postgres-svc)로 접속합니다.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: portal
spec:
  serviceName: postgres-headless
  replicas: 1
  selector:
    matchLabels: { app: postgres }
  template:
    metadata:
      labels: { app: postgres }
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          envFrom:
            - secretRef: { name: postgres-secret }   # POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB
          ports: [{ containerPort: 5432 }]
          resources:
            requests: { memory: 128Mi }
            limits:   { memory: 256Mi }
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        resources: { requests: { storage: 5Gi } }

작은 팁 하나: 앱 설정에서 비밀번호 환경변수 이름을 Secret의 키 이름(POSTGRES_PASSWORD)과 똑같이 맞췄습니다. 이름이 다르면 같은 비밀번호를 다른 키로 한 번 더 복사해야 하는데, 비밀번호를 여기저기 복제하지 않는 게 관리 면에서 훨씬 안전합니다.

2단계: 애플리케이션 코드 바꾸기

포털은 Python(Flask)이고 ORM 없이 SQL을 직접 쓰고 있어서, 드라이버 교체와 SQL 방언 수정이 필요했습니다. 실제로 바꾼 것들을 정리하면 이렇습니다.

code 드라이버와 커넥션 풀

# 전: python-oracledb
_pool = oracledb.create_pool(user=..., password=..., dsn=..., min=2, max=10)
conn = _pool.acquire()      # 반납은 conn.close()

# 후: psycopg2
_pool = psycopg2.pool.ThreadedConnectionPool(minconn=2, maxconn=10,
            host=..., port=5432, dbname=..., user=..., password=...)
conn = _pool.getconn()      # 반납은 _pool.putconn(conn)

Oracle에서는 CLOB 컬럼(블로그 본문 등)을 읽으면 LOB 객체가 와서 매번 .read()를 해주거나 출력 타입 핸들러를 달아야 했는데, PostgreSQL의 TEXT는 그냥 문자열로 옵니다. 이 핸들러 코드는 통째로 지웠습니다.

db SQL 방언 바꾸기

  1. 바인드 변수 - WHERE id = :id → WHERE id = %(id)s
  2. 현재 시각 - SYSTIMESTAMP → now()
  3. 기간 표기 - INTERVAL '9' HOUR → INTERVAL '9 hours'
  4. 날짜만 자르기 - TRUNC(시각) → 시각::date
  5. NULL 대체 - NVL(a, b) → COALESCE(a, b)
  6. 자료형 - VARCHAR2 → VARCHAR, NUMBER → INTEGER, CLOB → TEXT
  7. 새로 만든 행의 ID 받기 - OUT 변수를 따로 만들던 RETURNING id INTO :out_id가 RETURNING id 한 줄로 끝나고, 결과는 fetchone()으로 받습니다.

ok "이미 있으면 넘어가기"가 훨씬 쉬워짐

Oracle에는 CREATE TABLE IF NOT EXISTS가 없어서, 테이블을 만들어 보고 "이미 존재함(ORA-00955)" 에러만 잡아서 무시하는 식으로 짜 두었습니다. 컬럼 추가도 ORA-01430을 잡아야 했고요. PostgreSQL은 표준 문법을 지원해서 이 예외 처리 코드가 전부 사라졌습니다.

CREATE TABLE IF NOT EXISTS site_visits (
    id          INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    visit_date  DATE NOT NULL,
    visitor_key VARCHAR(64) NOT NULL,
    CONSTRAINT site_visits_uq UNIQUE (visit_date, visitor_key)
);
ALTER TABLE site_visits ADD COLUMN IF NOT EXISTS ip_address VARCHAR(45);

가장 크게 데인 함정: 트랜잭션이 통째로 멈춘다

warn 에러를 잡아서 무시했는데 다음 쿼리가 전부 실패

뉴스 RSS를 모아서 저장하는 코드가 있었는데, Oracle 시절에는 이렇게 짜여 있었습니다. "한 건씩 INSERT 하다가 중복이면(유니크 제약 위반) 에러를 잡고 다음 건으로 넘어간다." Oracle에서는 실패한 문장만 취소되니 문제없이 돌아갔습니다.

PostgreSQL로 옮기자 이 코드가 첫 번째 중복에서 멈췄습니다. 에러를 잡아서 넘어갔는데도, 그 뒤 모든 INSERT가 current transaction is aborted, commands ignored until end of transaction block로 실패한 거죠. PostgreSQL은 트랜잭션 안에서 에러가 한 번이라도 나면 ROLLBACK 하기 전까지 그 트랜잭션을 "망가진" 상태로 봅니다.

해결책은 두 가지였습니다.

  1. 중복은 SQL에서 처리하기 - 에러를 내고 잡는 대신, 애초에 에러가 안 나게 INSERT … ON CONFLICT DO NOTHING을 씁니다.
  2. 에러가 나면 반드시 ROLLBACK 후 반납 - 커넥션을 풀에 돌려주기 전에 rollback을 해 두지 않으면, 다음에 그 커넥션을 빌려 간 요청이 영문 모를 에러를 보게 됩니다. 커넥션 헬퍼에 한 번만 넣어 두면 됩니다.
-- 중복이면 조용히 건너뛰기
INSERT INTO news_cache (title, link, source)
VALUES (%(title)s, %(link)s, %(source)s)
ON CONFLICT (link) DO NOTHING;
@contextlib.contextmanager
def get_cursor(commit=False):
    conn = _pool.getconn()
    try:
        cur = conn.cursor()
        yield cur
        if commit:
            conn.commit()
    except Exception:
        conn.rollback()          # 망가진 트랜잭션을 정리한 뒤에
        raise
    finally:
        _pool.putconn(conn)      # 풀에 돌려준다

덤으로 얻은 것도 있습니다. ON CONFLICT … DO UPDATE(흔히 UPSERT라고 부르는 문법)가 있으니 "없으면 넣고, 있으면 횟수만 올리기" 같은 로직도 쿼리 한 줄로 끝납니다. Oracle의 MERGE보다 훨씬 간결합니다.

함정 2: 시간대(타임존)

clock DB 서버 시계는 UTC

공식 컨테이너 이미지는 Oracle이든 PostgreSQL이든 서버 시간대가 UTC입니다. 그래서 now()를 그대로 저장하면 한국 시각보다 9시간 느리게 찍히고, "오늘 방문자"를 셀 때 날짜 경계도 오전 9시에 바뀌어 버립니다. Oracle 때도 같은 이유로 9시간을 더하고 있었는데, 전환하면서 표기만 바뀌었습니다.

-- 한국 시각 기준 '오늘'로 저장 (한국은 서머타임이 없어 고정 +9시간이면 충분)
INSERT INTO site_visits (visit_date, visitor_key)
VALUES ((now() + INTERVAL '9 hours')::date, %(vid)s);

다만 로그인 잠금 시간처럼 now()와 비교하는 값은 UTC 그대로 둬야 합니다. "화면에 보여줄 시각"과 "로직에서 비교할 시각"을 구분하는 게 핵심이었습니다.

3단계: 데이터 옮기기

move 양쪽 DB에 동시에 붙는 1회용 파드

두 DB 모두 클러스터 안에서만 접속되는 서비스라 외부에서 덤프를 떠서 옮기기가 번거로웠습니다. 그래서 두 DB에 모두 접근할 수 있는 네임스페이스에 1회용 Python 파드를 띄우고, 그 안에서 oracledb와 psycopg2를 설치한 뒤 테이블별로 읽어서 넣는 스크립트를 돌렸습니다. 스크립트는 ConfigMap으로 넣어서 이미지를 따로 만들 필요도 없었습니다.

# 테이블 하나 옮기기 (요지)
ora.execute("SELECT id, username, email, password_hash, created_at FROM users")
rows = ora.fetchall()
pg.executemany(
    "INSERT INTO users (id, username, email, password_hash, created_at) "
    "OVERRIDING SYSTEM VALUE VALUES (%s, %s, %s, %s, %s)", rows)
# IDENTITY 컬럼에 기존 ID를 그대로 넣었으면 시퀀스를 최댓값 뒤로 맞춰 준다
pg.execute("SELECT setval(pg_get_serial_sequence('users','id'), (SELECT MAX(id) FROM users))")

기존 ID를 그대로 옮겨야 글 주소(/blog/…/글번호)와 댓글·좋아요 관계가 유지됩니다. IDENTITY 컬럼에 기존 값을 넣으려면 OVERRIDING SYSTEM VALUE가 필요하고, 다 넣은 뒤에는 시퀀스를 최댓값 뒤로 맞춰 줘야 합니다. 이걸 빼먹으면 다음 새 글을 쓸 때 이미 있는 ID와 충돌합니다.

옮긴 데이터는 사용자 4명, 글 14개, 좋아요 13개, 카테고리 3개, 방문 기록 32,696건. 뉴스 캐시 테이블은 10분이면 RSS에서 다시 채워지는 데이터라 일부러 옮기지 않았습니다. 옮길 필요 없는 데이터를 구분하는 것도 작업량을 줄이는 방법입니다.

key 비밀번호가 담긴 Secret은 네임스페이스를 못 넘는다

이 파드는 두 DB의 접속 정보가 모두 필요했는데, 쿠버네티스 Secret은 같은 네임스페이스의 파드만 참조할 수 있습니다. Oracle 쪽 Secret을 작업 네임스페이스로 잠깐 복사해 쓰고, 작업이 끝나자마자 지웠습니다. 복사할 때는 값이 화면에 찍히지 않도록 kubectl get secret -o json을 가공해서 곧바로 kubectl apply로 넘기는 한 줄 명령을 썼습니다.

4단계: 전환과 뒷정리

check 검증 - 새 이미지를 운영에 바로 올리기 전에, 같은 이미지로 임시 파드를 하나 더 띄워 새 DB에 붙여 보고 주요 페이지를 호출해 상태 코드를 확인하는 방식이 안전합니다. 테이블 생성 코드가 "이미 있으면 넘어가기"라 두 번 실행돼도 문제가 없습니다.

keep Oracle은 지우지 않고 재워 두기 - StatefulSet을 replicas: 0으로만 줄였습니다. 데이터 볼륨과 Secret은 그대로 있어서, 가끔 Oracle을 테스트해 보고 싶을 때 1로 올리면 다시 살아납니다. 되돌릴 길을 남겨 두는 건 전환 작업의 기본입니다.

결과: 숫자로 보면

  1. DB 메모리 - 약 4.4GiB → 약 66MiB (지금 이 글을 쓰는 시점의 실측값)
  2. 노드 전체 메모리 사용률 - 약 73% → 50%대
  3. DB 기동 - 수 분 → 몇 초
  4. 코드 - Oracle 에러코드 예외 처리와 LOB 핸들러가 빠지면서 오히려 짧아짐

여유가 생긴 메모리 덕분에 그 뒤로 메일 서버, 백업(Velero+MinIO), 모니터링, 게임 서버까지 같은 노드에 편하게 올릴 수 있었습니다. 체감상 "서버를 한 대 더 산 것" 같은 효과였습니다.

마무리: 이런 경우라면 옮겨 볼 만합니다

  1. 개인 프로젝트나 작은 서비스인데 DB가 서버 자원을 과하게 쓰고 있다
  2. PL/SQL, 패키지, 파티셔닝 같은 Oracle 전용 기능을 거의 안 쓴다
  3. ORM 없이 SQL을 직접 쓰더라도 쿼리 수가 관리 가능한 규모다

반대로 업무 로직이 PL/SQL 프로시저에 잔뜩 들어 있는 시스템이라면 이야기가 완전히 달라집니다. 그런 경우는 전용 변환 도구와 긴 검증 기간이 필요합니다. 제 경우엔 평범한 CRUD 서비스여서 코드 수정 자체는 많지 않았고, 가장 시간이 걸린 건 코드가 아니라 트랜잭션 에러 동작 차이를 이해하는 일이었습니다. 옮기실 계획이 있다면 그 부분부터 확인해 보시길 권합니다.

참고: PostgreSQL 공식 문서, psycopg2 문서. 버전이나 기본 설정값은 시간이 지나며 바뀔 수 있으니 적용 전에 최신 문서를 확인해 주세요.


댓글 0

로그인 후 댓글을 작성할 수 있습니다.