트랜잭셔널 CRUD 앱을 위한 PostgreSQL vs MariaDB
PostgreSQL과 MariaDB 비교: 인덱싱, 마이그레이션, JSON, 쿼리 기능 등 CRUD 앱이 프로토타입을 넘어설 때 실제로 중요한 요소들을 실무 관점에서 살펴봅니다.

프로토타입을 넘어선 CRUD 앱일 때
프로토타입 CRUD 앱은 보통 데이터가 적고 팀도 작고 트래픽이 예측 가능해서 빠르게 느껴집니다. 간단한 쿼리, 몇 개의 인덱스, 수동 스키마 조정으로 버틸 수 있죠. 그런데 실제 사용자가 늘고 워크플로우가 복잡해지며 마감이 생기면 상황이 바뀝니다.
성장은 워크로드를 바꿉니다. 리스트와 대시보드는 하루 종일 열립니다. 같은 레코드를 여러 사람이 편집합니다. 백그라운드 작업이 배치로 쓰기 시작합니다. 그때가 바로 “어제는 괜찮았는데”가 느린 페이지, 무작위 타임아웃, 피크 시간대의 락 대기(lock waits)로 바뀌는 순간입니다.
다음과 같은 현상이 보이면 한계를 넘었을 가능성이 큽니다: 페이지 20 이후부터 느려지는 목록 페이지, 릴리스에 데이터 백필이 포함되는 경우(단순 컬럼 추가가 아님), 메타데이터나 통합 페이로드를 위한 ‘플렉스 필드’ 증가, 혹은 바쁠 때 저장이 오래 걸린다는 지원 티켓 등.
그때가 되면 PostgreSQL과 MariaDB의 비교는 단순한 브랜드 선호를 넘어 현실적인 질문이 됩니다. 트랜잭셔널 CRUD 워크로드에서는 인덱싱 옵션(쿼리가 복잡해질 때), 테이블이 클 때의 마이그레이션 안전성, JSON 저장 및 쿼리, 그리고 애플리케이션 쪽 작업을 줄여주는 쿼리 기능들이 결과를 좌우합니다.
이 글은 그런 데이터베이스 동작성에 초점을 맞춥니다. 서버 사이징, 클라우드 요금, 공급업체 계약 같은 주제에는 깊게 들어가지 않습니다. 물론 중요하지만, 제품이 의존하는 스키마와 쿼리 스타일을 바꾸는 것보다 나중에 바꾸기 쉬운 경우가 많습니다.
데이터베이스 브랜드가 아닌 앱 요구사항에서 시작하세요
출발점은 “PostgreSQL 대 MariaDB”가 아니라 앱의 일상적 동작입니다: 레코드 생성, 일부 필드 업데이트, 필터된 결과 목록, 많은 사용자가 동시에 클릭할 때의 일관성 유지.
가장 바쁜 화면들이 무엇을 하는지 적어보세요. 읽기가 쓰기에 비해 얼마나 많은지? 스파이크는 언제 발생하는지(예: 아침 로그인, 월말 리포트, 대규모 임포트)? 나중에 인덱스 설계와 쿼리 패턴을 좌우할 정확한 필터와 정렬을 캡처하세요.
그다음 비타협적 요구사항을 정의하세요. 많은 팀에게 그것은 금전이나 재고에 대한 엄격한 일관성, 누가 무엇을 변경했는지 기록하는 감사 추적, 스키마가 진화해도 망가지지 않는 리포트 쿼리 등입니다.
운영 현실도 기능만큼 중요합니다. 관리형 데이터베이스를 운영할지 자체 호스팅할지, 백업 복구 속도 요구치는 얼마인지, 유지보수 창을 어느 정도 허용할지 결정하세요.
마지막으로 "충분히 빠른"의 정의를 몇 가지 목표로 정하세요. 예: 정상 부하에서 p95 API 지연 200~400ms, 피크 동시성에서 p95(정상 대비 2배 등), 업데이트 시 최대 허용 락 대기 100ms 미만, 백업·복원 시간 한계 등.
CRUD 속도를 좌우하는 인덱싱 기본
대부분의 CRUD 앱은 수백만 행에 달하고 모든 화면이 ‘정렬이 있는 필터 목록’이 되기 전까지는 빠르게 느껴집니다. 그 지점에서는 인덱싱이 50ms 쿼리와 5초 타임아웃의 차이를 만듭니다.
B-트리 인덱스는 PostgreSQL과 MariaDB 모두에서 기본 작업 말단입니다. 컬럼으로 필터할 때, 키로 조인할 때, 그리고 ORDER BY가 인덱스 순서와 맞을 때 도움이 됩니다. 실제 성능 차이는 보통 선택성(몇 행이 매칭되는가)과 인덱스가 필터와 정렬을 추가 스캔 없이 만족시키는지에 달려 있습니다.
앱이 성숙해질수록 단일 컬럼 인덱스보다 복합 인덱스가 더 중요해집니다. 흔한 패턴은 멀티테넌트 필터 + 상태 + 시간 정렬, 예: (tenant_id, status, created_at)입니다. 가장 일관된 필터(보통 tenant_id)를 앞에 두고, 그 다음 필터들, 마지막에 정렬 컬럼을 두세요. 별도 인덱스를 옵티마이저가 효율적으로 결합하지 못하는 경우보다 이 방식이 더 낫습니다.
차이는 “더 똑똑한” 인덱스에서 드러납니다. PostgreSQL은 부분 인덱스(partial)와 표현식 인덱스(expression index)를 지원해 특정 화면(예: “open인 티켓만 인덱싱”)에 유리합니다. 강력하지만 쿼리가 정확히 조건과 일치하지 않으면 놀랄 수 있습니다.
인덱스는 공짜가 아닙니다. 모든 삽입과 업데이트는 각 인덱스를 갱신해야 하므로, 어떤 화면을 빠르게 만들기 위해 인덱스를 추가하면 쓰기 성능을 조용히 느리게 만들기 쉽습니다.
간단한 규칙:
- 실제 쿼리 경로(이름을 댈 수 있는 화면이나 API 호출)를 위한 경우에만 인덱스를 추가하세요.
- 여러 겹치는 인덱스보다는 하나의 잘 설계된 복합 인덱스를 선호하세요.
- 기능 변경 후 인덱스를 재점검하고 쓸모없는 인덱스는 제거하세요.
- 유지보수를 계획하세요: PostgreSQL은 정기적인 vacuum/analyze가 필요하고, MariaDB도 좋은 통계와 가끔의 정리가 필요합니다.
- 직관을 믿지 말고 측정하세요(변경 전후 측정).
실제 화면을 위한 인덱싱: 목록, 검색, 페이지네이션
대부분의 CRUD 앱은 몇 가지 화면에 시간을 씁니다: 필터가 있는 목록, 검색 박스, 상세 페이지. 데이터베이스 선택은 인덱스가 해당 화면과 맞느냐만큼 중요하지 않지만, 테이블이 커지면 두 엔진은 서로 다른 도구를 제공합니다.
목록 페이지는 이 순서를 생각하세요: 필터 → 정렬 → 페이지네이션. 흔한 패턴은 "계정 X의 모든 티켓, status가 (open, pending)인 것, 최신순"입니다. 필터 컬럼으로 시작하고 마지막에 정렬 컬럼을 둔 복합 인덱스가 보통 이깁니다.
페이지네이션은 특별히 신경 써야 합니다. offset 페이지네이션(OFFSET 380처럼)은 스크롤할수록 느려집니다. 키셋 페이지네이션은 안정적입니다: 마지막으로 본 값(예: created_at과 id)을 전달해 다음을 요청하면 성능이 일정하고, 새로운 행이 중간에 생겨도 중복과 누락이 줄어듭니다.
PostgreSQL은 목록 화면에 유용한 옵션이 있습니다: INCLUDE를 사용한 "커버링" 인덱스는 visibility map이 허용하면 인덱스 온리 스캔(index-only scan)을 가능하게 합니다. MariaDB도 커버링 읽기를 지원하지만 보통 필요한 컬럼을 인덱스 정의에 직접 넣어야 해서 인덱스가 더 넓어지고 유지비용이 커질 수 있습니다.
다음과 같은 상황이 보이면 인덱스를 개선해야 합니다: 테이블이 커졌는데도 목록 엔드포인트가 느려진다(20~50개 행만 반환함에도), ORDER BY를 제거해야만 정렬이 빨라진다, 단순 필터에서 I/O가 급증한다. 긴 쿼리는 피크 시 락 대기를 늘립니다.
예: customer_id와 status로 필터하고 created_at으로 정렬하는 주문 화면은 보통 (customer_id, status, created_at)으로 시작하는 인덱스가 이롭습니다. 나중에 "주문 번호로 검색"을 추가하면 보통 별도의 인덱스를 만듭니다.
마이그레이션: 데이터가 커질 때 릴리스를 안전하게 유지하기
마이그레이션은 곧바로 "테이블 변경"이 아니라 복잡합니다. 실제 사용자와 누적 데이터가 있으면 데이터 백필, 제약 강화, 오래된 데이터 형태 정리 등을 하면서 앱을 깨뜨리지 않아야 합니다.
안전한 기본은 expand, backfill, contract입니다. 기존 코드를 방해하지 않는 방식으로 필요한 것을 추가하고, 작은 단계로 데이터를 복사 또는 계산하며, 이후에 제약(NOT NULL, 외래키, 고유 규칙 등)으로 검증한 뒤 오래된 컬럼을 제거하세요.
실제로는 새 nullable 컬럼이나 테이블을 추가하고, 쓰기를 일관성 있게 유지하면서 배치로 백필을 진행하고, 추후 제약으로 검증한 뒤 오래된 컬럼·인덱스·코드 경로를 제거하는 패턴이 일반적입니다.
스키마 변경마다 위험도가 다릅니다. 컬럼 추가는 보통 낮은 위험입니다. 큰 테이블에 인덱스 추가는 여전히 비용이 크므로 트래픽이 적은 시점에 계획하고 측정하세요. 컬럼 타입 변경은 데이터 재작성이나 쓰기 블록을 유발할 수 있어 위험합니다. 안전한 패턴은 새 타입으로 새 컬럼을 만들고 백필한 뒤 읽기/쓰기를 전환하는 것입니다.
롤백도 규모에 따라 의미가 달라집니다. 스키마 롤백은 때로 쉬울 수 있지만 데이터 롤백은 종종 불가능합니다. 특히 파괴적 삭제나 손실성 변환이 포함된 마이그레이션은 되돌릴 수 있는 범위를 명확히 하세요.
JSON 지원: 유연한 필드를 미래의 고통 없이 사용하기
JSON 필드는 추가 폼 필드, 통합 페이로드, 사용자 설정, 외부 시스템의 노트를 스키마 변경 없이 저장할 수 있어 매력적입니다. 문제는 무엇을 JSON에 두고 무엇을 실제 컬럼으로 둘지 결정하는 것입니다.
PostgreSQL과 MariaDB 모두에서 JSON은 거의 필터링하지 않고 표시용으로 쓰이거나, 디버깅 용도, 사용자별·테넌트별 설정 블롭, 작고 선택적인 속성에 적합합니다.
JSON 인덱싱은 팀이 놀라는 지점입니다. JSON 키를 한 번 쿼리하는 건 쉽습니다. 하지만 대규모 테이블에서 그 키로 필터링하거나 정렬하면 성능이 무너질 수 있습니다. PostgreSQL은 JSON 경로 인덱싱 옵션이 강력하지만 여전히 규율이 필요합니다: 진짜로 필터할 몇몇 키만 인덱스하고 나머지는 인덱싱하지 않은 페이로드로 유지하세요. MariaDB도 JSON 쿼리를 지원하지만 복잡한 내부 검색 패턴은 취약해지고 빠르게 유지하기 어려워질 수 있습니다.
JSON은 제약을 약화시킵니다. 비구조화된 블롭 내부에서 “항상 하나의 값이어야 한다”거나 “항상 존재해야 한다” 같은 규칙을 강제하기 어렵고, 리포팅 도구는 보통 타입화된 컬럼을 선호합니다.
확장 가능한 규칙: 모르는 필드는 일단 JSON에 넣되, (1) 필터나 정렬에 사용되면, (2) 제약이 필요하면, (3) 대시보드에 매주 등장하면 컬럼이나 자식 테이블로 정규화하세요. 예: 주문의 전체 배송 API 응답을 JSON으로 보관하는 것은 괜찮지만, delivery_status나 carrier 같은 필드는 지원과 리포팅에 의존하게 되면 실제 컬럼으로 옮기는 것이 좋습니다.
성숙한 앱에서 드러나는 쿼리 기능들
초기에는 대부분의 CRUD 앱이 단순한 SELECT, INSERT, UPDATE, DELETE로 돌아갑니다. 이후 활동 피드, 감사 뷰, 관리자 리포트, 즉각적인 체감이 필요한 검색 기능을 추가합니다. 이때 선택이 기능적 트레이드오프로 보이기 시작합니다.
CTE와 서브쿼리는 복잡한 쿼리를 읽기 쉽게 유지하는 데 도움이 됩니다. 결과를 단계별로 만들 때 유용하지만, 가독성이 비용을 숨길 수 있습니다. 쿼리가 느려지면 CTE를 서브쿼리나 조인으로 재작성하고 실행 계획을 다시 확인해야 할 때가 있습니다.
윈도우 함수는 누군가가 "지출 기준 고객 순위", "누적 합계 표시", "티켓별 최신 상태"를 요청할 때 처음으로 중요해집니다. 애플리케이션 루프를 대체하고 쿼리 수를 줄여줍니다.
재시도 가능한 쓰기(idempotent writes)는 성장한 요구사항입니다. 재시도가 있을 때(모바일 네트워크, 백그라운드 작업) 중복 생성 없이 안전하게 쓰려면 업서트가 필요합니다:
- PostgreSQL:
INSERT ... ON CONFLICT - MariaDB:
INSERT ... ON DUPLICATE KEY UPDATE
검색은 팀을 몰래 따라옵니다. 내장 풀텍스트 검색은 제품 카탈로그나 지식 기반, 지원 노트에 유용합니다. 트라이그램 같은 검색은 자동완성과 오타 허용에 좋습니다. 검색이 핵심 기능(복잡한 랭킹, 많은 필터, 높은 트래픽)이 되면 외부 검색 도구를 도입하는 것이 유지비용 대비 가치가 있을 수 있습니다.
예: 주문 포털이 처음에는 “주문 목록”으로 시작했지만 1년 뒤에는 "각 고객의 최신 주문 표시, 월별 지출 순위, 잘못 입력된 이름으로 검색"이 필요해질 수 있습니다. 이런 요구는 UI 작업만이 아니라 데이터베이스 기능과 직접 연결됩니다.
트랜잭션, 락, 동시성: 부하가 걸렸을 때
트래픽이 적을 때는 대부분의 데이터베이스가 괜찮아 보입니다. 부하가 걸릴 때 차이는 종종 동일한 데이터를 동시에 변경할 때 어떻게 처리하느냐에 달려 있습니다. PostgreSQL과 MariaDB 모두 트랜잭셔널 CRUD 워크로드를 처리할 수 있지만, 경합(contention)을 대비해 설계해야 합니다.
격리 수준을 평이하게 설명하면
트랜잭션은 함께 성공해야 하는 일련의 단계입니다. 격리(isolation)는 그 단계가 실행되는 동안 다른 세션이 무엇을 볼 수 있는지를 제어합니다. 높은 격리는 놀라운 읽기를 줄여주지만 대기 시간을 늘릴 수 있습니다. 많은 앱은 기본값으로 시작해서 결제 처리처럼 진짜로 필요한 흐름에만 격리를 높입니다.
실제로 락 문제를 일으키는 것들
CRUD 앱의 락 문제는 보통 몇 가지 반복되는 원인에서 옵니다: 모두가 업데이트하는 핫 로우, 모든 동작에서 바뀌는 카운터, 여러 워커가 같은 ‘다음 작업’을 잡으려 하는 잡 큐, 그리고 다른 작업(또는 사용자 시간)을 대기하는 동안 락을 잡고 있는 긴 트랜잭션.
경합을 줄이려면 트랜잭션을 짧게 유지하고, 필요한 컬럼만 업데이트하며, 트랜잭션 안에서 네트워크 호출을 피하세요.
도움이 되는 습관은 충돌 시 재시도입니다. 두 명의 지원 담당자가 동시에 같은 티켓을 저장하면 조용히 실패시키지 말고 충돌을 감지해 최신 행을 다시 로드하고 사용자에게 변경을 재적용하도록 안내하세요.
문제를 조기에 발견하려면 데드락, 장기 실행 트랜잭션, 실행 대신 대기에 시간을 쓰는 쿼리를 관찰하세요. 릴리스 후에는 특히 느린 쿼리 로그를 루틴에 포함하세요.
출시 후 중요해지는 운영 이슈
출시 이후에는 단순히 쿼리 속도만 최적화하는 것이 아닙니다. 복구, 안전한 변경, 예측 가능한 성능을 최적화해야 합니다.
일반적인 다음 단계는 리플리카 추가입니다. 프라이머리는 쓰기를 처리하고 리플리카는 대시보드나 리포트 같은 읽기 중심 페이지를 제공할 수 있습니다. 이때 새로 생각해야 할 것은 신선도(freshness)입니다: 일부 읽기는 수 초의 지연을 허용할 수 있으므로 어떤 화면은 프라이머리에서 읽어야 하는지(예: 방금 주문한 화면), 어떤 화면은 약간 오래된 데이터를 허용하는지(예: 주간 요약)를 앱이 알아야 합니다.
백업은 일의 절반에 불과합니다. 중요한 것은 얼마나 빠르고 정확하게 복원할 수 있는가입니다. 별도의 환경에 정기적으로 복원 테스트를 스케줄하고 기본 사항을 검증하세요: 앱 접속, 주요 테이블 존재 여부, 핵심 쿼리의 정상 반환. 많은 팀이 너무 늦게야 잘못된 대상을 백업했거나 복원 시간이 다운타임 한계를 훨씬 넘는다는 것을 발견합니다.
업그레이드도 더 이상 "클릭하고 기대"할 수 없습니다. 유지보수 창을 계획하고 호환성 노트를 읽고 프로덕션 데이터 복사본으로 업그레이드 경로를 테스트하세요. 사소한 버전 변경도 쿼리 플랜이나 인덱스·JSON 함수 동작을 바꿀 수 있습니다.
간단한 관찰성은 일찍 도움이 됩니다. 느린 쿼리 로그와 총 시간 기준 상위 쿼리, 연결 포화도, 복제 지연, 캐시 히트 비율과 I/O 압력, 락 대기와 데드락 이벤트부터 시작하세요.
선택 방법: 실용적 평가 프로세스
막혔다면 기능 목록을 읽는 것을 멈추고 실제 워크로드로 작은 실험을 해보세요. 목표는 완벽한 벤치마크가 아니라 테이블이 수백만 행에 달하고 릴리스 주기가 빨라졌을 때의 놀라움을 피하는 것입니다.
1) 프로덕션을 닮은 미니 테스트를 만들어라
실제 고통을 대표하는 앱의 조각을 선택하세요: 핵심 테이블 1~2개, 몇 개의 화면, 그 뒤의 쓰기 경로. 목록 페이지, 상세 페이지, 백그라운드 작업 등 상위 쿼리를 모으세요. 현실적인 행 수(프로토타입 데이터의 최소 100배, 유사한 모양)를 로드하고, 필요하다고 생각되는 인덱스를 추가한 뒤 같은 쿼리를 같은 필터·정렬로 실행해 타이밍을 캡처하세요. 쓰기가 동시에 일어나는 상황(간단한 삽입/업데이트 스크립트면 충분)을 반복하세요.
예: 상태로 필터하고 이름으로 검색하고 마지막 활동으로 정렬하며 페이지네이션하는 “고객” 목록은 인덱싱과 플래너 동작이 오래가느냐를 빠르게 드러냅니다.
2) 실제 릴리스처럼 마이그레이션을 연습하라
스테이징 데이터셋 복사본을 만들어 곧 올 변경사항(컬럼 추가, 타입 변경, 데이터 백필, 인덱스 추가)을 연습하세요. 걸리는 시간, 쓰기 차단 여부, 데이터가 이미 바뀐 상태에서 롤백이 실제로 무슨 의미인지 측정하세요.
3) 단순 점수표를 사용하라
테스트 후에는 성능(실제 쿼리), 정확성 및 안전성(제약, 트랜잭션, 엣지 케이스), 마이그레이션 위험(락, 다운타임, 복구 옵션), 운영 노력(백업/복원, 복제, 모니터링), 팀 숙련도를 기준으로 점수를 매기세요.
다음 12개월의 위험을 줄여주는 데이터베이스를 선택하세요. 한 번의 마이크로 테스트에서 이긴 쪽이 아니라요.
흔한 실수와 함정
가장 비용이 큰 데이터베이스 문제는 종종 "빠른 해결"에서 시작됩니다. 두 데이터베이스 모두 트랜잭셔널 CRUD 앱을 운용할 수 있지만, 잘못된 관행은 트래픽과 데이터가 커지면 어느 쪽이든 문제를 일으킵니다.
흔한 함정은 JSON을 모든 것의 지름길로 보는 것입니다. 유연한 "extras" 필드는 진짜 선택적 데이터에 괜찮지만 상태, 타임스탬프, 외래키 같은 핵심 필드는 실제 컬럼으로 남겨두세요. 그렇지 않으면 느린 필터, 어색한 검증, 리포팅이 중요해질 때 고통스러운 리팩터가 생깁니다.
인덱싱의 함정은 화면에서 본 모든 필터마다 인덱스를 추가하는 것입니다. 인덱스는 읽기를 빠르게 하지만 삽입과 업데이트를 느리게 하고 마이그레이션을 무겁게 만듭니다. 실제로 사용하는 인덱스만 추가하고 부하를 측정하세요.
마이그레이션은 테이블을 잠그며 물어뜯을 수 있습니다. 큰 컬럼을 재작성하거나 NOT NULL을 기본값과 함께 추가하거나 대형 인덱스를 만드는 일괄 변경은 쓰기를 몇 분간 차단할 수 있습니다. 위험한 변경은 단계로 나누고 앱이 한가할 때 스케줄하세요.
또한 ORM 기본값에 영원히 의존하지 마세요. 목록 뷰가 1,000행에서 1,000만행으로 가면 쿼리 플랜을 읽고 누락된 인덱스를 찾아 느린 조인을 고쳐야 합니다.
빠른 경고 신호: 주요 필터나 정렬에 JSON을 사용, 측정 없이 급증하는 인덱스 수, 한 번의 배포로 큰 테이블을 재작성하는 마이그레이션, 안정적인 정렬 없이 하는 페이지네이션(누락·중복 발생).
결정하기 전에 빠른 체크리스트
선택하기 전에 가장 바쁜 화면과 릴리스 프로세스를 기준으로 현실 점검을 하세요.
- 최다 화면이 피크 부하에서 충분히 빠른가? 가장 느린 목록 페이지를 실제 필터·정렬·페이지네이션으로 테스트하고 인덱스가 정확히 그 쿼리와 맞는지 확인하세요.
- 안전하게 스키마 변경을 배포할 수 있는가? 다음 중대한 변경에 대해 expand-backfill-contract 계획을 적어보세요.
- JSON과 컬럼에 대한 명확한 규칙이 있는가? 어떤 JSON 키를 검색·정렬해야 하는지, 어떤 키가 진짜로 유연한지 결정하세요.
- 특정 쿼리 기능에 의존하는가? 업서트 동작, 윈도우 함수, CTE 동작, 함수형 또는 부분 인덱스가 필요한지 확인하세요.
- 출시 후 운영할 수 있는가? 백업에서 복원할 수 있음을 증명하고, 느린 쿼리를 측정하며 지연과 락 대기를 기준선으로 잡으세요.
예시: 단순 주문 추적에서 바쁜 고객 포털로
고객 포털을 상상해보세요. 처음엔 간단합니다: 고객이 로그인하고 주문을 보고, 송장을 내려받고, 지원 티켓을 엽니다. 첫 주엔 거의 모든 트랜잭셔널 데이터베이스가 괜찮게 느껴질 것입니다. 페이지가 빠르고 스키마도 작습니다.
몇 달 뒤 성장의 순간이 나타납니다. 고객이 "지난 30일 내 배송된 주문 중 카드결제, 부분 환불된 것" 같은 필터를 원합니다. 지원팀은 주간 검토용 CSV 내보내기를 빠르게 원합니다. 재무팀은 누가 언제 송장 상태를 무엇에서 무엇으로 바꿨는지 감사 추적을 원합니다. 쿼리 패턴은 원래 화면보다 훨씬 넓어집니다.
그때 결정은 특정 기능과 실제 부하에서의 동작에 관한 문제가 됩니다.
유연한 필드(배송 지시사항, 커스텀 속성, 티켓 메타데이터)를 추가하면 JSON 지원이 중요합니다. 팀이 JSON 경로를 인덱싱하고, 형태를 검증하고, JSON이 커져도 성능을 예측 가능하게 유지할지 솔직해야 합니다.
리포팅은 또 다른 압박점입니다. 주문, 송장, 결제, 티켓을 많은 필터와 함께 조인하는 순간부터 복합 인덱스, 쿼리 플래닝, 다운타임 없이 인덱스를 진화시키는 용이성에 신경 쓰게 됩니다. 마이그레이션도 더 이상 "금요일에 스크립트 실행"이 아니라 모든 릴리스의 일부가 됩니다.
실용적 전진 방법은 앞으로 6개월 내에 예상되는 다섯 개의 실제 화면과 내보내기를 적고, 감사 히스토리 테이블을 일찍 포함시키고, 가장 느린 쿼리로 현실적인 데이터 크기로 벤치마킹하며(hello-world CRUD가 아님), JSON 사용·인덱싱·마이그레이션에 대한 팀 규칙을 문서화하는 것입니다.
마지막으로, 빠르게 진행하면서 모든 레이어를 수작업으로 만들고 싶지 않다면 AppMaster (appmaster.io)는 시각적 모델에서 프로덕션 준비 백엔드, 웹 앱, 네이티브 모바일 앱을 생성할 수 있습니다. 또한 화면, 필터, 비즈니스 프로세스를 초기에 실제 쿼리 워크로드로 다루도록 유도해 인덱싱과 마이그레이션 위험을 프로덕션 전에 발견하도록 도와줍니다.
자주 묻는 질문
먼저 실제 워크로드(가장 바쁜 목록 화면, 필터, 정렬 방식, 피크 시의 쓰기 경로)를 적어보세요. 둘 다 CRUD를 잘 처리할 수 있지만, 더 안전한 선택은 익숙한 이름이 아니라 향후 1년 동안 어떻게 인덱싱하고 마이그레이션하며 쿼리할지에 맞는 쪽입니다.
다음과 같은 징후가 보이면 프로토타입 설정이 한계를 넘은 것입니다: 페이지를 깊게 넘길수록 목록 페이지가 느려진다(OFFSET 스캔 비용), 바쁜 시간에 저장이 멈춘다(락 경합 또는 긴 트랜잭션), 릴리스에 백필이나 큰 인덱스가 포함된다(마이그레이션이 신뢰성 문제로 변함).
중요한 화면마다 하나의 복합 인덱스를 기본으로 하세요. 가장 일관된 필터를 먼저 두고 정렬 컬럼은 마지막에 둡니다. 예: 멀티테넌트 목록이라면 (tenant_id, status, created_at)처럼 구성하면 필터링과 정렬을 추가 스캔 없이 지원하기 쉽습니다.
OFFSET 페이지네이션은 페이지를 뒤로 갈수록 느려집니다. 데이터베이스가 앞쪽 행들을 지나쳐야 하기 때문입니다. 대신 키셋 페이지네이션을 사용하세요: 마지막으로 본 값(예: created_at과 id)을 전달해 ‘다음 20건’처럼 요청하면 성능이 안정적이고, 중간에 새 행이 생겨도 중복이나 누락이 줄어듭니다.
인덱스는 실제로 필요할 때만 추가하세요. 정확히 어떤 화면이나 API 호출이 그 인덱스를 필요로 하는지 이름을 댈 수 있어야 합니다. 겹치는 인덱스가 많아지면 삽입과 업데이트가 느려져 피크 쓰기 시점에 앱이 불규칙하게 느려질 수 있습니다.
안전한 패턴은 expand, backfill, contract입니다. 호환되는 방식으로 새 구조를 추가하고(예: nullable 컬럼), 소규모 배치로 백필을 진행한 뒤 제약을 걸어 검증하고, 읽기/쓰기 전환이 끝난 뒤에 오래된 경로를 제거하세요. 큰 테이블에서 일괄 변경은 쓰기를 블록할 수 있으니 계획해야 합니다.
JSON은 주로 표시용이거나 디버깅·설정용 블롭, 혹은 자주 필터링되지 않는 선택적 속성에 적합합니다. 정렬·필터링·보고서에 자주 쓰일 필드는 컬럼이나 자식 테이블로 정규화하세요. 그래야 성능과 제약(예: 필수값 검사)이 쉬워집니다.
재시도가 보통일 때는 업서트가 필수입니다. 중복 생성되지 않도록 고유 키를 잘 정의하세요. PostgreSQL은 INSERT ... ON CONFLICT, MariaDB는 INSERT ... ON DUPLICATE KEY UPDATE를 사용합니다.
트랜잭션을 짧게 유지하고, 트랜잭션 안에서 네트워크 호출을 피하며, 모두가 업데이트하는 ‘핫 로우’(공유 카운터 등)를 줄이세요. 충돌이 발생하면 재시도하거나 사용자에게 충돌을 알려 변경을 재적용하도록 하세요. 또한 데드락과 긴 트랜잭션을 모니터링하는 것이 중요합니다.
읽기 부하가 많고 약간의 지연을 허용할 수 있다면 리플리카를 추가하세요. 중요한 ‘방금 변경한’ 읽기는 주(primary)에서 하도록 하고, 리플리카 지연(replication lag)을 모니터링해 너무 오래된 데이터를 보여주지 않도록 주의하세요.


