PostgreSQL 과 MariaDB — 결제 도메인 선택
결제·정산을 직접 운영하는 HEDVION이 MariaDB에서 PostgreSQL로 전환한 실전 이유 — 금액 정밀도, 워커 중복 처리, JSONB 인덱싱, 타임존 마이그레이션까지 구체적 수치와 시나리오로 정리했다.
결제 도메인에서 DB 선택이 취향 문제가 아닌 이유
결제와 정산을 직접 운영해본 팀이라면 DB 선택이 얼마나 조용히, 그러나 확실하게 문제를 만들어내는지 안다. 장애 로그를 뒤지다 보면 DB 레이어의 트레이드오프가 아주 구체적인 숫자로 나타난다. 0.01원 단위 오차가 한 달에 수만 건 쌓이면 정산 불일치가 되고, 타임존 처리 실수 하나가 전날 거래를 다음날 집계에 올려버리는 일이 생긴다.
HEDVION은 결제 처리, 정산 자동화, 외부 PG사 연동을 소규모 팀이 직접 운영한다. 자원이 제한된 환경에서 DB 레이어의 실수는 애플리케이션 레이어에서 보정하기 매우 어렵다. 그래서 정산 파이프라인과 결제 이벤트 저장 레이어를 재설계하는 타이밍에 "지금 다시 고른다면?"이라는 질문을 DB 수준에서 진지하게 검토했다. 기존 MariaDB 운영 경험이 더 길었음에도 불구하고, 이 검토는 결제 도메인의 세 가지 핵심 요구사항 — 금액 정밀도, 다중 워커 동시성 제어, JSON 응답 저장 및 인덱싱 — 을 기준으로 진행됐다.
NUMERIC 정밀도 — 0.01원이 왜 허용 불가인가
금액을 다루는 코드에서 부동소수점을 쓰면 안 된다는 원칙은 잘 알려져 있다. 덜 알려진 것은 DB 레이어에서 같은 문제가 조용히 발생할 수 있다는 점이다.
MariaDB의 DECIMAL 타입은 명목상 고정 소수점이지만, 집계 함수나 특정 산술 연산 과정에서 내부적으로 부동소수점 연산을 거치는 케이스가 있다. 우리가 실제로 겪은 사례는 SUM() 결과를 중간 집계 테이블에 INSERT ... SELECT로 적재하는 정산 배치였다. 수백만 건 단위 집계에서 소수점 셋째 자리 오차가 누적됐고, 건별로는 무시할 수준이었지만 월간 집계 기준으로 수십 원에서 수백 원의 불일치가 발생했다. PG사 정산 금액과 내부 집계를 대조하는 작업에서 이 오차는 "원인을 알 수 없는 차이"로 수 시간의 디버깅을 소비하게 만든다.
PostgreSQL의 NUMERIC 타입은 가변 정밀도 고정 소수점으로 설계됐고, SUM을 포함한 집계 연산에서도 중간 캐스팅 없이 NUMERIC을 유지한다. NUMERIC(19, 4)처럼 명시적 정밀도를 지정하면 어떤 경로의 연산에서도 선언한 정밀도 범위 안에서만 동작한다는 것을 보장받는다. DB가 "정밀도는 내가 보장한다"는 계약을 지켜주는 것과 그렇지 않은 것의 차이는 곧 애플리케이션 레이어에 보정 로직을 추가해야 하는지의 여부로 직결된다. 결제·정산 도메인에서 이 보정 로직은 단순한 기술 부채가 아니라 감사 추적과 정산 책임 범위에 영향을 준다.
SELECT FOR UPDATE SKIP LOCKED — 결제 큐의 핵심 패턴
결제 처리 자동화에서 DB 테이블을 작업 큐로 쓰는 패턴은 간단하고 트랜잭션 경계와 잘 맞는다는 장점이 있다. 문제는 다중 워커 환경에서 같은 레코드를 두 개의 워커가 동시에 집어드는 것을 어떻게 막느냐다. Redis 분산 락을 도입하면 복잡도가 올라가고, 상태 컬럼 + 주기적 폴링 방식은 락 해제 타이밍을 잘못 설계하면 중복 처리 위험이 남는다.
PostgreSQL의 SELECT FOR UPDATE SKIP LOCKED는 이 문제를 DB 레이어에서 해결한다. 이미 락이 걸린 행은 건너뛰고 다음 가용 레코드를 반환하기 때문에, 워커 N개가 동시에 같은 쿼리를 실행해도 각각 서로 다른 레코드를 가져간다. 우리 결제 재시도 큐의 실제 구조는 이렇다: BEGIN → SELECT id, payload FROM payment_queue WHERE status = 'pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED → 외부 API 호출 및 처리 → 성공 시 UPDATE status = 'done', 실패 시 UPDATE status = 'failed', retry_count = retry_count + 1 → COMMIT. 워커 수를 4개에서 8개로 늘렸을 때 처리량이 거의 선형적으로 증가했고 중복 처리는 발생하지 않았다. MariaDB는 10.6부터 SKIP LOCKED를 지원하지만, 동작 방식의 엣지 케이스와 실제 운영 사례가 PostgreSQL 대비 얇아서 신뢰 기반을 쌓기까지 시간이 더 필요하다고 판단했다.
JSONB와 GIN 인덱스 — PG사 응답 저장의 실용적 선택
외부 PG사와 연동하면 응답 스키마가 PG사마다 다르고 시간이 지나면서 필드가 추가되거나 바뀐다. 이 원본 응답을 그대로 저장해두고 나중에 특정 필드 기준으로 조회해야 하는 경우가 생긴다. 예를 들어 "승인번호 A1234B로 처리된 건 전체 조회"나 "특정 카드사 응답 코드가 포함된 건 집계" 같은 요구다. 처음에는 자주 쓰는 필드만 정규 컬럼으로 빼는 방식을 택했지만, PG사가 응답 구조를 바꿀 때마다 스키마를 변경해야 하는 문제가 생겼다.
PostgreSQL의 jsonb 타입은 JSON을 파싱된 이진 형태로 저장하며, GIN 인덱스를 붙이면 JSON 내부 필드에 대한 조회가 일반 컬럼 수준의 성능으로 올라간다. 약 200만 건의 PG 응답 로그에 jsonb + GIN 인덱스를 적용했을 때, 특정 JSON 키-값 필터 조회가 인덱스 없는 전체 스캔 대비 약 40배 빠른 실행 계획을 보였다(EXPLAIN ANALYZE 기준 8,200ms → 200ms). MariaDB의 JSON 타입도 함수 인덱스를 생성할 수 있지만 복합 조건이나 중첩 구조에서 GIN 인덱스만큼의 유연성을 제공하지 않는다. 정산 배치에서 이 조회가 포함되면 실행 시간 차이는 배치 전체 SLA에 직접 영향을 준다.
마이그레이션에서 진짜 시간을 잡아먹은 것들
MariaDB → PostgreSQL 마이그레이션을 단순한 스키마 변환으로 보면 반드시 중간에 막힌다. 우리가 가장 많은 시간을 소비한 부분은 타임존 처리였다.
MariaDB의 DATETIME은 타임존 정보를 저장하지 않는다. 애플리케이션이 Asia/Seoul 기준으로 동작하면서 DATETIME에 값을 넣으면 DB에는 KST 시각이 그냥 들어간다. 이 데이터를 PostgreSQL의 TIMESTAMPTZ로 마이그레이션하면 PostgreSQL은 들어온 값을 UTC로 변환해 저장한다. 문제는 기존 데이터가 "KST 기준으로 09:00"을 의도했는지 "UTC 기준으로 09:00"을 의도했는지 메타데이터만으로는 알 수 없다는 것이다. 우리 케이스에서는 이 애매함을 해소하는 데 이틀이 소요됐다. 마이그레이션 스크립트에서 기존 DATETIME 값을 AT TIME ZONE 'Asia/Seoul'로 명시적으로 변환해 INSERT하는 방식으로 해결했지만, 놓쳤다면 과거 결제 이력 조회에서 9시간 어긋난 데이터를 보게 됐을 것이다. 정산 이력에서 날짜 경계가 틀어지면 월간 정산 금액 자체가 달라진다. 그 외 실질적으로 영향을 준 차이: AUTO_INCREMENT → SEQUENCE 전환(레거시 코드 참조가 많으면 수작업이 많아짐), TINYINT(1) → BOOLEAN 타입 명시, 스토어드 프로시저 문법 차이(우리는 이 기회에 프로시저 로직을 전부 애플리케이션 레이어로 올렸다).
MariaDB를 유지하는 것이 맞는 경우
공정하게 이야기하면 PostgreSQL이 항상 정답은 아니다. 팀의 운영 경험이 MariaDB에 집중되어 있고 전환 비용(DBA 학습, 인프라 재구성, 마이그레이션 검증 공수)이 기대 이득을 상회하는 경우라면 유지가 낫다. 결제·정산이 아닌 단순 CRUD 위주의 서비스라면 두 DB의 차이가 실질적으로 거의 느껴지지 않는다. MySQL 호환성이 중요한 레거시 연동 맥락에서는 MariaDB의 생태계 호환성이 실용적 강점이 된다. HEDVION도 비결제 서비스 일부는 여전히 MariaDB를 유지하고 있다. DB 선택은 전사 통일 원칙으로 가져가기보다 서비스별 도메인 요구사항에 맞게 결정하는 것이 맞다.
지금 당장 써먹을 수 있는 체크리스트
결제·정산 도메인에서 DB를 선택하거나 전환을 검토하는 팀을 위한 다섯 가지 구체적 행동이다.
1. 금액 집계 정밀도 검증부터 시작하라. 현재 DB에서 SUM() + INSERT ... SELECT 경로를 거친 집계 결과가 건별 합산과 일치하는지 실데이터로 확인하라. 불일치가 발견된다면 DB 레이어 정밀도 문제일 가능성이 높다.
2. 결제 큐 중복 처리 방지 방식을 점검하라. Redis 락이나 상태 컬럼 + 폴링으로 관리하고 있다면 SELECT FOR UPDATE SKIP LOCKED 패턴으로 단순화할 수 있는지 검토하라. DB 트랜잭션 범위 안으로 가져올수록 원자성 보장이 쉬워진다.
3. DATETIME 컬럼에 타임존 기준을 지금 문서화하라. 마이그레이션 계획이 없더라도, 모든 날짜 컬럼 주석에 "저장 기준 타임존"을 지금 명시하라. 이 정보가 없으면 나중에 정산 이력 재처리 시 비용이 크게 올라간다.
4. PG사 JSON 응답 저장 컬럼에 인덱스가 붙어 있는지 확인하라. 인덱스 없이 JSON_EXTRACT 조건으로 수십만 건을 조회하면 풀 스캔이 발생하고 정산 배치 실행 시간이 늘어난다. 조회에 자주 쓰이는 JSON 키를 파악해 인덱싱 대상을 우선순위화하라.
5. 마이그레이션을 결정했다면 타임존 변환 스크립트를 첫 번째로 작성하라. 스키마 변환보다 데이터 변환이 더 위험하다. DATETIME → TIMESTAMPTZ 변환 스크립트를 먼저 작성하고 변환 전후 결과를 건별 샘플링으로 검증하는 단계를 반드시 포함하라.
DB는 한 번 선택하면 바꾸는 비용이 크다. "나중에 문제 생기면 그때 고치자"는 접근이 통하지 않는 레이어다. 결제·정산을 운영하는 팀이라면 이 선택을 미루지 않는 것이 낫다.
* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.