🟠 코틀린↗ 🌐 서버기술↗ 🟣 C#↗ 🐍 파이썬↗ ☕ 자바↗ 🟨 JS·TS↗ 🔵 C++↗ 🔌 C · 임베디드↗ 🦀 Rust↗ 🎓 CS 기술↗
👋 프로그래밍을 아예 모르셔도 됩니다. 왼쪽 🧭 시작 · 설치부터 순서대로 읽으면 됩니다 · 위 주제 메뉴 ▾(또는 키보드 1~9·0)로 이동 · 코드 상자에 마우스를 올리면 복사 버튼이 나옵니다
DATABASE · 설치부터 · 프로그래밍을 몰라도 됩니다

데이터는
어디에 쌓이고
어떻게 꺼내는가

이 가이드는 DB를 한 번도 설치해 본 적 없는 사람을 기준으로 시작합니다. 설치 → 접속 → 첫 조회까지 막히지 않게 OS별 명령을 그대로 적어 두었고, 이후 SQL 기초 → 전문가 문법 → DB별 방언 → 튜닝 → 내부 구조까지 이어집니다. 네 가지 관계형 DB(Oracle · PostgreSQL · MySQL/MariaDB · SQLite3)에 더해 NoSQL 두 가지(⚡ Redis · 🍃 MongoDB)도 각각 전용 탭으로 다룹니다.

12탭
179섹션
6DB 전용 탭
0사전 지식
SECTION 01

데이터베이스란 무엇인가

"엑셀로 하면 되지 않나요?" — 맞습니다. 혼자 쓰면 엑셀로 충분합니다. 데이터베이스가 필요해지는 순간은 혼자가 아닐 때입니다.

엑셀 파일 — 각자 열고 각자 저장한다 A B sales.xlsx 마지막 저장만 남는다 A 가 30분 동안 한 작업이 B 의 저장 한 번에 통째로 사라진다 데이터베이스 — 모든 요청이 엔진 한 곳을 지난다 A B C DB 엔진 줄 세우기 · 검증 되돌리기 디스크 셋이 동시에 눌러도 엔진이 줄을 세운다 — 사라지는 작업이 없다
데이터베이스의 본질은 저장이 아니라 동시에 들어온 요청을 한 줄로 세우는 것입니다.

엑셀 파일로 버티다 반드시 만나는 네 가지 벽

벽 1
동시에 열면 덮어쓴다 A가 연 파일을 B도 열어 각자 저장하면, 나중에 저장한 쪽만 남고 앞의 작업은 사라집니다.
▼
벽 2
같은 정보를 여러 곳에 적게 된다 주문 시트마다 고객 주소를 적어 두면, 주소가 바뀔 때 수백 줄을 전부 고쳐야 합니다. 하나라도 놓치면 데이터가 서로 다릅니다.
▼
벽 3
잘못된 값이 그냥 들어간다 수량 칸에 "세 개", 금액에 −5000이 적혀도 엑셀은 막지 않습니다. 집계할 때 터집니다.
▼
벽 4
커지면 느려지고, 반쯤 저장되면 복구가 안 된다 수십만 행부터 버벅이고, 저장 중 정전이 나면 파일 자체가 깨집니다.
데이터베이스는 이 네 가지를 구조적으로 해결하는 프로그램입니다. 동시 접근을 정리해 주고(트랜잭션·락), 중복을 없애 한 곳만 고치게 하고(정규화·관계), 잘못된 값을 아예 못 넣게 막고(제약조건), 수억 건에서도 빠르게 찾아 주며(인덱스), 중간에 죽어도 되살립니다(로그·복구).
SELECT 한 줄이 결과가 되기까지 — DB 안에서 벌어지는 일 SQL 문장 앱에서 도착 파서 문법 검사 옵티마이저 가장 싼 길 선택 실행기 계획대로 수행 버퍼 캐시 메모리 — 빠름 없으면 디스크에서 디스크 파일 — 수백 배 느림 인덱스를 탈까, 전부 훑을까 — 여기서 실행계획이 정해집니다 느린 쿼리의 원인은 대부분 둘 — 옵티마이저가 고른 길(실행계획), 그리고 디스크 읽기의 양입니다
DB는 문장을 받는 즉시 실행하지 않습니다. "어떻게 읽는 게 가장 싼가"를 먼저 계산하고 움직입니다.

용어 다섯 개면 시작할 수 있습니다

용어엑셀로 치면설명
데이터베이스엑셀 파일 하나서로 관련된 표들을 담는 그릇. 서비스마다 보통 하나씩 만듭니다.
테이블시트 한 장같은 종류의 데이터를 모은 표. member, orders 처럼 이름을 붙입니다.
행(row)가로 한 줄데이터 하나. 회원 한 명, 주문 한 건.
열(column)세로 한 칸속성 하나. 이름·이메일·금액. 타입이 정해져 있습니다.
SQL수식 · 필터 · 피벗데이터를 넣고 꺼내는 공용 언어. 어느 DB에서나 큰 틀은 같습니다.

곧 만나게 될 낯선 말들 — 미리 한 줄씩

아래 용어들은 이 가이드 곳곳에서 계속 나옵니다. 지금 다 외울 필요는 없어요. 읽다가 모르는 말이 나오면 이 표로 돌아와서 확인하면 됩니다.

용어쉽게 말하면
스키마(schema)표들의 설계도이자 폴더. "어떤 표가 있고, 각 표에 어떤 칸이 어떤 타입으로 있는가"라는 구조 전체를 뜻하고, PostgreSQL·Oracle에서는 표들을 담는 폴더 이름으로도 씁니다. "스키마를 만든다" = 표 구조를 정한다.
쿼리(query)DB에 보내는 질문·요청 한 건. SELECT … 한 문장이 쿼리 하나입니다. "쿼리를 날린다" = 요청을 보낸다.
기본키(PK)행마다 붙는 겹치지 않는 번호표(회원번호·주문번호). 이걸로 그 행을 정확히 집어냅니다.
외래키(FK)다른 표의 번호표를 적어 둔 칸. 주문 표의 "회원번호" 칸이 회원 표를 가리키는 식으로, 표와 표를 잇습니다.
인덱스(index)책 뒤의 찾아보기. 미리 만들어 두면 수백만 줄에서도 몇 번 만에 찾습니다. 조회를 빠르게 하는 1순위 도구.
트랜잭션여러 변경을 전부 성공 아니면 전부 취소로 묶는 단위. 계좌 이체가 "출금만 되고 입금은 안 되는" 사고를 막습니다.
커밋 · 롤백트랜잭션의 마무리 두 가지 — 커밋 = 확정 저장, 롤백 = 없던 일로 되돌리기.
CRUD데이터 다루기 4종 세트 — Create(넣기) · Read(읽기) · Update(고치기) · Delete(지우기). 앱이 DB에 하는 일의 전부입니다.
서버 · 클라이언트DB 프로그램이 서버(항상 떠서 요청을 기다림), 접속하는 내 쪽 도구·앱이 클라이언트입니다.
마이그레이션표 구조를 바꾸는 작업(또는 그 기록). 칸 추가·이름 변경 같은 변경을 파일로 남겨 순서대로 적용합니다.

DBMS · RDBMS · NoSQL — 이름 정리

  • DBMS — Database Management System. 데이터베이스를 관리하는 프로그램 자체를 말합니다. "MySQL을 설치한다"는 곧 DBMS를 설치한다는 뜻입니다.
  • RDBMS — 관계형(Relational) DBMS. 표와 표를 키로 연결하는 방식. 이 가이드가 다루는 네 DB가 전부 여기에 속하며, 업무 시스템의 90% 이상이 관계형입니다.
  • NoSQL — 표 구조를 쓰지 않는 DB들(MongoDB·Redis·Cassandra 등)의 통칭. 관계형을 대체하는 게 아니라 옆에 두고 씁니다. 캐시는 Redis, 본체는 관계형이 가장 흔한 구성입니다. 이 가이드의 ⚡ Redis 탭(r01에 상세 비교)과 🍃 MongoDB 탭에서 전용으로 다룹니다.
흔한 오해 — "요즘은 NoSQL이 대세 아닌가요?" 아닙니다. 돈·주문·회원처럼 정확해야 하는 데이터는 여전히 관계형이 기본입니다. NoSQL은 캐시·로그·검색처럼 목적이 분명한 자리에 씁니다. 처음 배운다면 관계형부터가 맞습니다.
SECTION 02

6대 DB 한눈에 — 무엇을 고를까

아래 버튼을 눌러 DB를 바꿔 보세요. 성격 · 강점 · 함정 · 대표 코드가 함께 바뀝니다. 관계형 4개(Oracle·PostgreSQL·MySQL·SQLite)에 NoSQL 2개(Redis·MongoDB)까지 나란히 놓았습니다.

DB 선택

한 장 비교표 — 관계형 4 + NoSQL 2

항목OraclePostgreSQLMySQL / MariaDBSQLiteRedisMongoDB
종류관계형 (표)관계형 (표)관계형 (표)관계형 (표)키-값 (메모리)문서형 (JSON)
구조서버형서버형서버형파일 하나 (서버 없음)서버형 · 인메모리서버형
비용유료 (Free 판 제한)완전 무료무료 (MySQL은 유료판 별도 · MariaDB는 전부 무료)완전 무료 (저작권 자체가 없음)무료 (완전 오픈소스는 Valkey)무료 (서비스로 팔 때만 제약)
기본 포트152154323306없음637927017
질의 언어SQL (방언 강함)SQL (표준 준수 최고)SQL (보통)SQL (일부만)전용 명령 (GET·SET…)JSON 쿼리 · 파이프라인
동시 쓰기매우 강함강함강함사실상 1개매우 빠름 (단일 스레드 순차)강함
설치 난이도어려움쉬움쉬움설치 없음아주 쉬움쉬움
대표 사용처금융·통신·공공 기간계신규 웹·분석·지리·AI일반 웹 서비스·커머스모바일·데스크톱·엣지캐시·세션·순위표로그·카탈로그·유연 문서
이 가이드 탭🅾️ Oracle🐘 PostgreSQL🐬 MySQL✒️ SQLite3⚡ Redis🍃 MongoDB
역할이 다릅니다 — 왼쪽 넷(관계형)은 본체 데이터(회원·주문·돈)를 놓고 하나를 고르는 관계이고, Redis·MongoDB는 그 옆에 붙이는 보조 저장소입니다. "PostgreSQL로 할까, Redis로 할까"는 성립하지 않는 질문이에요 — 보통 관계형 하나 + 필요할 때 Redis로 시작합니다. 차이의 원리는 ⚡ Redis 탭 r01에서 자세히 다룹니다.

고르는 기준, 세 문장

🟢 지금 배우는 중이라면
  • SQLite로 SQL 문법을 익히세요 — 설치가 없어 바로 시작합니다
  • 그다음 PostgreSQL을 도커로 띄워 서버형을 경험합니다
  • 취업 목표가 SI·금융이면 Oracle도 한 번은 만져 보세요
🔵 서비스를 만든다면
  • 회사에 이미 쓰는 DB가 있으면 그것이 정답입니다
  • 새로 정할 수 있다면 PostgreSQL이 무난합니다
  • 워드프레스·커머스 솔루션을 쓴다면 MySQL이 사실상 강제됩니다
🟣 규모가 커지면
  • DB 종류보다 인덱스·쿼리·스키마 설계가 성능을 좌우합니다
  • 앞단에 Redis 캐시를 두는 구성이 사실상 표준입니다
  • 읽기 부하는 복제본으로, 쓰기 한계는 샤딩으로 나눕니다
솔직한 조언 — DB 선택으로 고민하는 시간보다 SQL을 제대로 익히는 시간이 훨씬 값집니다. SELECT·JOIN·인덱스는 네 DB에서 거의 같고, 방언 차이는 이 가이드의 각 탭에서 대조표로 30분이면 따라잡습니다.
표로 안 담기는 데이터가 보이기 시작했다면 — 캐시·세션·순위표는 ⚡ Redis 탭, 구조가 자꾸 바뀌는 JSON 데이터는 🍃 MongoDB 탭에서 다룹니다. NoSQL이 처음이라면 r01 (NoSQL vs RDBMS)부터 읽으세요.
무엇부터 배울까 — 목적으로 갈립니다 ✒️ SQLite — 혼자 만드는 앱 · 학습 · 시험 설치가 없습니다. 파일 하나가 곧 DB 입니다 — SQL 문법을 배우기에 가장 빠릅니다 🐘 PostgreSQL · 🐬 MySQL — 웹 서비스의 기본값 둘 중 아무거나 골라도 됩니다. 기능이 많은 쪽이 PostgreSQL, 자료가 많은 쪽이 MySQL 입니다 🅾️ Oracle — 회사에 이미 있을 때 배웁니다 금융 · 대기업 기간계에 많습니다. 개인이 먼저 고를 이유는 거의 없습니다 ⚡ Redis — 곁들이는 저장소 캐시 · 세션 · 순위표. 주 DB 를 대체하지 않습니다 🍃 MongoDB — 모양이 제각각일 때 문서 구조가 자주 바뀌는 데이터에 씁니다 하나를 제대로 익히면 나머지는 방언 차이입니다 — SELECT · JOIN · 인덱스 · 트랜잭션은 어디서나 같습니다
고민이 길어지면 PostgreSQL 로 시작하세요 — 나중에 후회할 일이 가장 적습니다.
SECTION 03

설치 ① Windows

윈도우는 설치 파일을 받아 실행하는 방식이 가장 쉽습니다. 설치 중에 나오는 비밀번호와 포트 번호는 반드시 메모하세요 — 나중에 다시 보기 어렵습니다.

PostgreSQL — 추천 경로

  1. 공식 배포처(EDB)에서 Windows x86-64 인스톨러를 받습니다 — 검색어: postgresql windows installer
  2. 실행 → 설치 경로는 기본값 그대로 → 구성 요소는 전부 체크(pgAdmin·Command Line Tools 포함)
  3. superuser(postgres) 비밀번호 입력 — ★ 메모해 두세요
  4. 포트 5432 그대로 → 로케일 기본값 → 설치 완료
  5. 마지막의 Stack Builder는 체크 해제해도 됩니다 (나중에 필요할 때 설치)
# 설치 확인 — PowerShell 을 새로 열고
psql --version
# psql (PostgreSQL) 18.x

# 접속 (비밀번호를 물어봅니다)
psql -U postgres -h localhost -p 5432
psql 을 못 찾는다고 나오면 — PATH 문제입니다. C:\Program Files\PostgreSQL\18\bin 을 시스템 환경 변수 Path에 추가하고 터미널을 새로 여세요(기존 창은 옛 PATH를 그대로 씁니다).

MySQL

  1. MySQL Installer for Windows를 받습니다 (community 판)
  2. Setup Type은 Developer Default 또는 Server only
  3. Authentication Method — Strong Password Encryption(기본값) 선택. 오래된 클라이언트를 써야 한다면 Legacy를 고르지만, 가급적 기본값을 유지하세요
  4. root 비밀번호 입력 — ★ 메모
  5. Windows Service 로 등록 → 이름 기본값(MySQL80) → 실행
mysql --version
mysql -u root -p            # 비밀번호 입력 후 접속

SQLite — 설치라고 할 것도 없습니다

  1. sqlite.org 다운로드 페이지에서 sqlite-tools-win-x64-*.zip 을 받습니다
  2. 압축을 풀어 C:\sqlite 같은 폴더에 두고, 그 경로를 Path에 추가합니다
  3. 끝입니다. 서비스도 계정도 포트도 없습니다
sqlite3 --version
sqlite3 shop.db             # 파일이 없으면 새로 만들어집니다
# sqlite> .quit  로 빠져나옵니다
파이썬이 설치돼 있다면 SQLite는 이미 들어 있습니다. python -c "import sqlite3; print(sqlite3.sqlite_version)" 로 바로 확인됩니다.

Oracle — 윈도우 직접 설치는 권하지 않습니다

Oracle Database Free 를 윈도우에 직접 설치할 수는 있지만, 설치 용량이 크고 리스너·서비스 설정에서 막히는 일이 많습니다. 학습 목적이라면 다음 섹션의 Docker 방식을 강력히 권합니다. 회사에서 이미 운영 중인 오라클에 접속만 하면 되는 경우라면, Oracle Instant Client + SQL Developer 만 설치하면 충분합니다.

포트가 이미 쓰이고 있다면 — 설치 중 "port in use" 가 나오면 다른 프로그램이 그 포트를 쓰고 있습니다. netstat -ano | findstr :5432 로 점유 프로세스를 찾거나, 설치 시 포트를 5433 처럼 바꾸세요. 바꿨다면 접속할 때도 그 포트를 써야 합니다.
SECTION 04

설치 ② macOS · Linux

맥과 리눅스는 패키지 매니저 명령 한 줄이면 끝납니다. 설치 후 서비스 시작까지가 한 세트라는 것만 기억하세요.

macOS (Homebrew)

# Homebrew 가 없다면 먼저 설치 (brew.sh 참고)

# PostgreSQL
brew install postgresql@18
brew services start postgresql@18      # 백그라운드 자동 실행 등록
psql postgres                          # 내 계정 이름으로 바로 접속됩니다

# MySQL
brew install mysql
brew services start mysql
mysql_secure_installation              # root 비밀번호 설정 (권장)
mysql -u root -p

# SQLite — macOS 에는 이미 들어 있습니다
sqlite3 --version
Homebrew 의 PostgreSQL 은 특이합니다 — postgres 계정이 아니라 내 맥 사용자 이름으로 DB 계정이 만들어집니다. 그래서 psql postgres 만으로 비밀번호 없이 접속됩니다. 리눅스·도커와 다르니 헷갈리지 마세요.

Ubuntu · Debian (apt)

# PostgreSQL
sudo apt update
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresql      # 부팅 시 자동 시작 + 지금 시작
sudo -u postgres psql                       # postgres 계정으로 전환해 접속

# MySQL
sudo apt install -y mysql-server
sudo systemctl enable --now mysql
sudo mysql_secure_installation
sudo mysql -u root                          # 우분투 기본은 소켓 인증이라 sudo 로 접속

# MariaDB (MySQL 대신)
sudo apt install -y mariadb-server
sudo systemctl enable --now mariadb
sudo mariadb

# SQLite
sudo apt install -y sqlite3

RHEL · Rocky · Alma (dnf)

sudo dnf install -y postgresql-server postgresql-contrib
sudo postgresql-setup --initdb              # ★ 이 단계를 빠뜨리면 서비스가 안 뜹니다
sudo systemctl enable --now postgresql

sudo dnf install -y mysql-server
sudo systemctl enable --now mysqld
sudo grep 'temporary password' /var/log/mysqld.log   # 초기 임시 비밀번호 확인

설치 후 반드시 확인하는 세 가지

확인명령정상이면
서비스가 떴는가systemctl status postgresqlactive (running)
포트가 열렸는가ss -lntp | grep 5432LISTEN 상태로 표시
접속이 되는가psql -U postgres -h 127.0.0.1프롬프트가 postgres=# 로 바뀜

외부에서 접속하려면 (서버에 설치한 경우)

기본 설치는 같은 컴퓨터에서만 접속되게 잠겨 있습니다. 다른 PC에서 붙으려면 세 가지를 열어야 합니다.

PostgreSQL
# postgresql.conf
listen_addresses = '*'

# pg_hba.conf  (접속 허용 규칙)
host  all  all  10.0.0.0/24  scram-sha-256

# 반영
sudo systemctl reload postgresql
MySQL / MariaDB
# my.cnf  ([mysqld] 섹션)
bind-address = 0.0.0.0

# 계정의 접속 위치도 함께 열어야 합니다
CREATE USER 'app'@'%' IDENTIFIED BY '...';

sudo systemctl restart mysql
# 방화벽 (셋 중 쓰는 것만)
sudo ufw allow 5432/tcp                                   # Ubuntu
sudo firewall-cmd --permanent --add-port=5432/tcp         # RHEL 계열
sudo firewall-cmd --reload
절대 하지 마세요 — 인터넷에 열린 서버에서 0.0.0.0 으로 DB 포트를 개방하고 비밀번호만 걸어 두는 구성. 스캐너가 몇 분 안에 찾아냅니다. 보안 그룹·방화벽으로 접속 IP를 제한하거나, 애플리케이션 서버만 사설망으로 접근하게 두세요.
SECTION 05

설치 ③ Docker — 선택 사항

이 절은 필수가 아닙니다. 앞의 ①(Windows)·②(macOS·Linux) 방식으로 이미 설치했다면 그대로 다음 섹션으로 넘어가세요. 도커는 설치 없이 명령 한 줄로 DB를 띄우는 또 하나의 방법일 뿐입니다 — PC가 지저분해지지 않고 버전 실험이 쉬워, 쓸 줄 알면 편리한 선택지입니다.

도커, 해야 하나요? — 아니요. DB 공부에는 ①·② 직접 설치로 충분합니다. 도커가 빛나는 순간은 ⓐ 여러 DB·여러 버전을 갈아 끼우며 실험할 때, ⓑ 팀 전체가 같은 개발 환경을 맞출 때(compose), ⓒ PC를 깨끗하게 유지하고 싶을 때입니다. 지금 안 써도 되고, 필요해지면 그때 돌아와도 됩니다.
내 PC (호스트) DBeaver · psql localhost:5432 -p 5432:5432 컨테이너 — 지우면 통째로 사라지는 일회용 상자 postgres:16 컨테이너 내부 :5432 이미지만 있으면 어느 PC에서든 같은 버전이 뜬다 볼륨(-v) 호스트 디스크에 남는다 볼륨을 안 걸면 컨테이너를 지우는 순간 데이터도 사라진다 포트는 "바깥:안쪽" 순서입니다 — 이미 5432 를 쓰고 있다면 -p 5433:5432 로 바깥만 바꾸면 됩니다
도커에서 기억할 것은 둘입니다 — 포트는 바깥:안쪽, 그리고 남기고 싶은 데이터는 볼륨에.
준비물 — Docker Desktop(Windows·macOS) 또는 Docker Engine(Linux) 하나면 됩니다. 설치 후 docker --version 이 나오면 준비 완료입니다.

한 줄로 띄우기

# PostgreSQL 18
docker run -d --name pg \
  -e POSTGRES_PASSWORD=devpass \
  -e POSTGRES_DB=shop \
  -p 5432:5432 \
  -v pgdata:/var/lib/postgresql \
  postgres:18                 # 18+ 는 …/postgresql 에 마운트 (17 이하는 …/data)

# MySQL 8.4 LTS
docker run -d --name my \
  -e MYSQL_ROOT_PASSWORD=devpass \
  -e MYSQL_DATABASE=shop \
  -p 3306:3306 \
  -v mydata:/var/lib/mysql \
  mysql:8.4

# MariaDB 11.8 LTS
docker run -d --name maria \
  -e MARIADB_ROOT_PASSWORD=devpass \
  -e MARIADB_DATABASE=shop \
  -p 3307:3306 \
  -v mariadata:/var/lib/mysql \
  mariadb:11.8

# Oracle Database Free 23ai (공식 컨테이너 레지스트리)
docker run -d --name ora \
  -e ORACLE_PWD=devpass \
  -p 1521:1521 \
  container-registry.oracle.com/database/free:latest
-v 를 빼면 데이터가 사라집니다. 컨테이너를 지우면 안의 파일도 같이 지워집니다. -v pgdata:/var/lib/postgresql 처럼 볼륨을 붙여야 컨테이너를 지웠다 다시 만들어도 데이터가 남습니다.

접속하기

# 컨테이너 안으로 들어가서 접속
docker exec -it pg psql -U postgres -d shop
docker exec -it my mysql -u root -p shop
docker exec -it ora sqlplus system/devpass@//localhost:1521/FREEPDB1

# 내 PC의 클라이언트로 접속 (포트를 열어 뒀으므로)
psql -h localhost -p 5432 -U postgres -d shop
mysql -h 127.0.0.1 -P 3306 -u root -p shop
MySQL 접속에서 -h localhost 대신 -h 127.0.0.1 을 쓰세요. MySQL 클라이언트는 localhost 를 유닉스 소켓으로 해석해서 도커로 띄운 서버에 안 붙는 일이 자주 있습니다. IP를 직접 적으면 TCP로 붙습니다.

팀이 함께 쓸 때 — docker compose 한 파일

# compose.yaml  ·  docker compose up -d 로 전부 기동
services:
  postgres:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: shop
      TZ: Asia/Seoul
    ports: ["5432:5432"]
    volumes:
      - pgdata:/var/lib/postgresql        # 18+ 기준 (17 이하는 …/data)
      - ./init:/docker-entrypoint-initdb.d   # 최초 1회 실행되는 .sql 넣는 곳
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

  mysql:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: devpass
      MYSQL_DATABASE: shop
      TZ: Asia/Seoul
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
    ports: ["3306:3306"]
    volumes:
      - mydata:/var/lib/mysql

volumes:
  pgdata:
  mydata:
/docker-entrypoint-initdb.d 는 아주 유용합니다. 이 폴더에 .sql 파일을 넣어 두면 DB가 처음 만들어질 때 자동 실행됩니다. 다음 섹션의 샘플 스키마를 여기에 넣어 두면, 팀원 누구나 docker compose up 한 번으로 같은 데이터를 갖게 됩니다. (이미 데이터가 있으면 실행되지 않습니다)

자주 쓰는 관리 명령

명령하는 일
docker ps지금 떠 있는 컨테이너 목록
docker logs -f pg로그 실시간 보기 — 안 뜰 때 여기부터 봅니다
docker stop pg / start pg멈춤 / 다시 시작 (데이터 유지)
docker rm -f pg컨테이너 삭제 (볼륨은 남음)
docker volume rm pgdata데이터까지 완전 삭제 — 되돌릴 수 없습니다
docker exec -it pg bash컨테이너 안 셸로 들어가기
SECTION 06

접속하기 — CLI와 GUI 도구

검은 화면이 부담스럽다면 GUI부터 시작해도 됩니다. 다만 서버에서는 GUI를 못 쓰는 경우가 많으니 CLI도 조금씩 익혀 두세요.

GUI 도구 — 처음이라면 여기서 (가격까지 표기)

도구가격특징추천
DBeaver무료 (상위 PRO판은 유료)오픈소스. 여섯 DB를 하나로 다 붙습니다(MongoDB·Redis는 PRO에서 더 편함). 자바 기반이라 조금 무겁습니다.★ 처음 쓰기 가장 무난
DataGrip유료 (30일 체험)JetBrains. 자동완성·리팩터링이 압도적입니다. IntelliJ 유료 사용자면 이미 내장돼 있습니다.유료를 감당한다면 최고
pgAdmin무료PostgreSQL 공식. 설치 시 함께 깔립니다. 웹 UI 방식.PostgreSQL 전용
MySQL Workbench무료MySQL 공식. ER 다이어그램 작성이 편합니다.MySQL 전용
SQL Developer무료Oracle 공식. 오라클을 쓴다면 사실상 표준입니다.Oracle 전용
DB Browser for SQLite무료SQLite 파일을 엑셀처럼 열어 편집합니다.SQLite 전용
RedisInsight무료Redis 공식. 키 탐색·명령 실행·메모리 분석까지.Redis 전용
MongoDB Compass무료MongoDB 공식. 문서 탐색·쿼리·인덱스·실행계획.MongoDB 전용
TablePlus무료 제한판 (정식판 유료)가볍고 예쁩니다. 무료판은 동시 연결·탭 수 제한.가벼운 대안

접속 정보 — 이 다섯 개면 붙습니다

항목예시설명
호스트localhost / 10.0.1.20DB가 있는 컴퓨터 주소
포트5432 / 3306 / 1521 / 6379 / 27017기본값을 안 바꿨다면 c02 비교표대로
DB 이름shop오라클은 서비스명(예: FREEPDB1)을 씁니다
사용자postgres / root / system설치할 때 만든 관리자 계정
비밀번호devpass설치 중에 입력한 그 값

CLI 첫 화면에서 살아남기

psql (PostgreSQL)
\l          데이터베이스 목록
\c shop     shop 으로 이동
\dt         테이블 목록
\d member   member 구조 보기
\x          결과를 세로로 (긴 행에 유용)
\q          종료
mysql
SHOW DATABASES;
USE shop;
SHOW TABLES;
DESC member;
\G          (세미콜론 대신) 세로 출력
exit
SQL*Plus (Oracle)
SELECT table_name FROM user_tables;
DESC member;
SET LINESIZE 200
SET PAGESIZE 50
EXIT
sqlite3
.tables         테이블 목록
.schema member  구조 보기
.mode box       표 형태로 출력
.headers on     헤더 표시
.quit
redis-cli (Redis)
PING            살아 있나 (→ PONG)
KEYS *          키 목록 (학습용만! 운영 금지)
TYPE 키이름     이 키의 자료구조
INFO memory     메모리 사용량
QUIT
mongosh (MongoDB)
show dbs            데이터베이스 목록
use shop            이동
show collections    컬렉션 목록
db.member.findOne() 문서 하나 보기
exit
SQL 은 세미콜론(;)으로 끝냅니다. CLI에서 엔터를 쳤는데 아무 일도 안 일어나고 커서만 내려간다면 세미콜론을 안 찍은 것입니다. ; 를 치고 엔터하세요. (psql의 \dt 같은 역슬래시 명령과 sqlite의 .tables 같은 점 명령은 세미콜론이 필요 없습니다)
접속은 네 관문을 차례로 지납니다 — 오류는 그중 하나에서 납니다 ① 네트워크 host : port localhost:5432 ② 인증 계정 · 비밀번호 접속 허용 규칙 ③ 데이터베이스 선택 어느 DB 에 붙을지 기본값이 있기도 합니다 ④ 권한 테이블별 허용 여부 연결 자체가 안 됨 서버가 꺼져 있거나 방화벽 · 포트가 다름 도커면 포트 매핑 확인 인증 실패 비밀번호가 틀렸거나 그 주소에서의 접속을 서버가 막고 있습니다 DB 가 없음 이름 오타가 대부분이고 아직 만들지 않은 경우도 흔합니다 권한 없음 붙기는 했는데 조회가 거부되면 여기입니다 그래서 순서대로 확인합니다 — 포트가 열려 있나 → 계정이 맞나 → DB 이름이 맞나 → 권한이 있나 CLI 로 먼저 붙어 보세요. GUI 는 여러 단계를 한 번에 시도해서 어디서 막혔는지 알기 어렵습니다
접속 오류의 90%는 ①과 ②입니다 — 메시지를 읽기 전에 단계부터 가르세요.
접속은 네 관문을 차례로 지납니다 — 오류는 그중 하나에서 납니다 ① 네트워크 host : port localhost:5432 ② 인증 계정 · 비밀번호 접속 허용 규칙 ③ 데이터베이스 선택 어느 DB 에 붙을지 기본값이 있기도 합니다 ④ 권한 테이블별 허용 여부 연결 자체가 안 됨 서버가 꺼져 있거나 방화벽 · 포트가 다름 도커면 포트 매핑 확인 인증 실패 비밀번호가 틀렸거나 그 주소에서의 접속을 서버가 막고 있습니다 DB 가 없음 이름 오타가 대부분이고 아직 만들지 않은 경우도 흔합니다 권한 없음 붙기는 했는데 조회가 거부되면 여기입니다 그래서 순서대로 확인합니다 — 포트가 열려 있나 → 계정이 맞나 → DB 이름이 맞나 → 권한이 있나 CLI 로 먼저 붙어 보세요. GUI 는 여러 단계를 한 번에 시도해서 어디서 막혔는지 알기 어렵습니다
접속 오류의 90%는 ①과 ②입니다 — 메시지를 읽기 전에 단계부터 가르세요.
SECTION 07

계정 · 데이터베이스 · 권한

관리자 계정으로 모든 것을 하지 마세요. 앱마다 전용 계정을 만들고 필요한 권한만 주는 것이 기본이자, 사고가 났을 때 피해를 줄이는 유일한 방법입니다.

PostgreSQL
-- 관리자(postgres)로 접속한 상태에서
CREATE DATABASE shop;
CREATE USER app WITH PASSWORD 'strong-pass';

-- 접속 허용
GRANT CONNECT ON DATABASE shop TO app;

-- shop 으로 이동한 뒤 (\c shop)
GRANT USAGE ON SCHEMA public TO app;
GRANT SELECT, INSERT, UPDATE, DELETE
  ON ALL TABLES IN SCHEMA public TO app;

-- 앞으로 만들어질 표에도 자동 적용
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;
MySQL / MariaDB
CREATE DATABASE shop
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

-- 계정은 '이름'@'접속위치' 가 한 세트
CREATE USER 'app'@'%' IDENTIFIED BY 'strong-pass';

GRANT SELECT, INSERT, UPDATE, DELETE
  ON shop.* TO 'app'@'%';

FLUSH PRIVILEGES;
Oracle (23ai · PDB 안에서)
-- PDB 로 이동
ALTER SESSION SET CONTAINER = FREEPDB1;

CREATE USER app IDENTIFIED BY "strong-pass"
  DEFAULT TABLESPACE users
  QUOTA UNLIMITED ON users;

GRANT CREATE SESSION TO app;
GRANT CREATE TABLE, CREATE SEQUENCE TO app;
-- 남의 스키마 접근은 객체 단위로
GRANT SELECT ON hr.member TO app;
SQLite
# 계정도 권한도 없습니다.
# 접근 통제 = 파일 권한 그 자체입니다.
chmod 600 shop.db
chown appuser:appuser shop.db

# 읽기 전용으로 열려면 (앱 코드에서)
# file:shop.db?mode=ro

권한을 주는 세 가지 원칙

  • 필요한 것만 — 앱 계정에 DROP TABLE 권한은 필요 없습니다. SQL 인젝션이 성공해도 테이블을 못 지우게 됩니다.
  • 용도별로 나눈다 — 읽기 전용 계정(분석·리포트), 읽기·쓰기 계정(앱), 스키마 변경 계정(마이그레이션 전용)을 분리합니다.
  • 관리자 계정은 사람만 — postgres·root·SYS 로 애플리케이션을 붙이지 않습니다. 사고 시 원인 추적조차 불가능해집니다.
비밀번호를 코드에 적지 마세요. 소스에 넣으면 깃 기록에 영원히 남습니다. 환경 변수나 시크릿 관리 도구를 쓰고, 실수로 커밋했다면 파일을 지우는 것만으로는 안 되고 비밀번호를 반드시 교체해야 합니다.
서버 · 데이터베이스 · 표 · 계정의 관계 DB 서버 (호스트:포트) shop 데이터베이스 users 표 orders 표 blog 데이터베이스 posts 표 comments 표 shop_app 계정 shop 에만 · SELECT INSERT UPDATE DELETE 까지만 root · sa · SYSTEM 전부 가능 — 사람이 관리할 때만 앱에 붙이면 안 됩니다 원칙 — 서비스마다 계정을 따로 만들고, 자기 데이터베이스에만 필요한 만큼의 권한을 줍니다 테이블 삭제(DROP) 권한은 앱 계정에 주지 마세요 — 실수와 공격을 동시에 막습니다 PostgreSQL 은 데이터베이스 아래에 스키마가 한 겹 더 있습니다 — 기본은 public 입니다 읽기 전용 계정을 하나 만들어 두면 분석·조회 요청을 안전하게 넘길 수 있습니다
접속 오류의 절반은 문법이 아니라 이 네 겹 중 어디가 안 맞는지의 문제입니다.
SECTION 08

실습용 샘플 스키마 만들기

이 가이드의 거의 모든 예제가 아래 작은 쇼핑몰 데이터로 돌아갑니다. 한 번만 만들어 두면 끝까지 복붙해서 따라올 수 있습니다.

표준 SQL 버전 (PostgreSQL · MySQL · SQLite 공통에 가깝게)

-- 회원
CREATE TABLE member (
    id          INTEGER      PRIMARY KEY,
    name        VARCHAR(50)  NOT NULL,
    email       VARCHAR(120) UNIQUE,
    grade       VARCHAR(10)  NOT NULL DEFAULT 'BASIC',   -- BASIC / SILVER / GOLD
    city        VARCHAR(30),
    joined_at   DATE         NOT NULL
);

-- 상품
CREATE TABLE product (
    id          INTEGER       PRIMARY KEY,
    name        VARCHAR(80)   NOT NULL,
    category    VARCHAR(30)   NOT NULL,
    price       DECIMAL(12,2) NOT NULL CHECK (price >= 0),
    stock       INTEGER       NOT NULL DEFAULT 0
);

-- 주문 (회원 1명 : 주문 N건)
CREATE TABLE orders (
    id          INTEGER       PRIMARY KEY,
    member_id   INTEGER       NOT NULL,
    ordered_at  DATE          NOT NULL,
    status      VARCHAR(15)   NOT NULL DEFAULT 'PAID',   -- PAID / SHIPPED / CANCELED
    FOREIGN KEY (member_id) REFERENCES member(id)
);

-- 주문 상세 (주문 1건 : 품목 N개)
CREATE TABLE order_item (
    id          INTEGER       PRIMARY KEY,
    order_id    INTEGER       NOT NULL,
    product_id  INTEGER       NOT NULL,
    qty         INTEGER       NOT NULL CHECK (qty > 0),
    unit_price  DECIMAL(12,2) NOT NULL,
    FOREIGN KEY (order_id)   REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES product(id)
);

데이터 넣기

INSERT INTO member (id, name, email, grade, city, joined_at) VALUES
 (1,'김하늘','sky@example.com','GOLD'  ,'서울','2024-03-11'),
 (2,'박바다','sea@example.com','SILVER','부산','2024-07-02'),
 (3,'이구름','cld@example.com','BASIC' ,'서울','2025-01-20'),
 (4,'최나무','tre@example.com','GOLD'  ,'대전','2025-05-08'),
 (5,'정바람',NULL             ,'BASIC' ,'부산','2026-02-14');

INSERT INTO product (id, name, category, price, stock) VALUES
 (10,'무선 키보드' ,'주변기기', 49000, 120),
 (11,'게이밍 마우스','주변기기', 32000,  80),
 (12,'27인치 모니터','디스플레이',289000, 15),
 (13,'USB-C 허브'  ,'주변기기', 27000, 200),
 (14,'노트북 스탠드','액세서리', 18000,   0);

INSERT INTO orders (id, member_id, ordered_at, status) VALUES
 (1001,1,'2026-01-05','PAID'),
 (1002,2,'2026-01-07','SHIPPED'),
 (1003,1,'2026-02-11','CANCELED'),
 (1004,4,'2026-03-02','PAID'),
 (1005,2,'2026-03-19','PAID'),
 (1006,4,'2026-04-01','SHIPPED');

INSERT INTO order_item (id, order_id, product_id, qty, unit_price) VALUES
 (1,1001,10,1, 49000),
 (2,1001,13,2, 27000),
 (3,1002,12,1,289000),
 (4,1003,11,1, 32000),
 (5,1004,10,2, 49000),
 (6,1004,14,1, 18000),
 (7,1005,13,3, 27000),
 (8,1006,12,2,289000);
Oracle 에서는 두 곳을 고쳐야 합니다. ① VALUES (...), (...) 처럼 여러 행을 한 번에 넣는 문법이 없습니다 → INSERT ALL … SELECT * FROM DUAL 을 쓰거나 INSERT 문을 여러 개로 나눕니다. ② 날짜 리터럴은 DATE '2026-01-05' 또는 TO_DATE('2026-01-05','YYYY-MM-DD') 형태로 씁니다. 자세한 방언은 🅾️ Oracle 탭에 정리돼 있습니다.
-- Oracle 용 INSERT 예시
INSERT ALL
  INTO member (id,name,email,grade,city,joined_at)
       VALUES (1,'김하늘','sky@example.com','GOLD','서울',DATE '2024-03-11')
  INTO member (id,name,email,grade,city,joined_at)
       VALUES (2,'박바다','sea@example.com','SILVER','부산',DATE '2024-07-02')
SELECT * FROM dual;

잘 들어갔는지 확인

SELECT COUNT(*) AS 회원수 FROM member;      -- 5
SELECT COUNT(*) AS 주문수 FROM orders;      -- 6
SELECT COUNT(*) AS 품목수 FROM order_item;  -- 8

-- 첫 조인 — 누가 언제 얼마어치 샀나
SELECT m.name, o.id AS order_no, o.ordered_at,
       SUM(oi.qty * oi.unit_price) AS 금액
FROM   member m
JOIN   orders o     ON o.member_id = m.id
JOIN   order_item oi ON oi.order_id = o.id
GROUP  BY m.name, o.id, o.ordered_at
ORDER  BY o.ordered_at;
name order_no ordered_at 금액 김하늘 1001 2026-01-05 103000 박바다 1002 2026-01-07 289000 …
이 표들이 왜 이렇게 나뉘어 있는지(주문과 주문상세가 왜 따로인지)는 ⚙️ 설계 · 튜닝 탭 t01 정규화에서 다룹니다. 지금은 "이렇게 생겼구나" 정도만 보고 넘어가도 충분합니다.
앞으로 계속 쓸 네 테이블 — 관계를 먼저 눈에 익히세요 customers id (PK) name · email created_at 1 : N orders id (PK) customer_id (FK) ordered_at · status 1 : N order_items order_id (FK) product_id (FK) qty · unit_price N : 1 products id (PK) name · price category 읽는 법 PK — 이 행을 가리키는 이름표 FK — 다른 표의 PK 를 가리킴 주문 하나에 상세가 여럿 — 그래서 표를 나눕니다
주문과 상품의 다대다 관계를 order_items 가 풀어 줍니다 — 가장 흔한 모양입니다.
앞으로 계속 쓸 네 테이블 — 관계를 먼저 눈에 익히세요 customers id (PK) name · email created_at 1 : N orders id (PK) customer_id (FK) ordered_at · status 1 : N order_items order_id (FK) product_id (FK) qty · unit_price N : 1 products id (PK) name · price category 읽는 법 PK — 이 행을 가리키는 이름표 FK — 다른 표의 PK 를 가리킴 주문 하나에 상세가 여럿 — 그래서 표를 나눕니다
주문과 상품의 다대다 관계를 order_items 가 풀어 줍니다 — 가장 흔한 모양입니다.
SECTION 09

접속 오류 사전

접속이 안 되는 이유는 거의 정해져 있습니다. ① 서버가 안 떴다 → ② 주소·포트가 다르다 → ③ 계정·비밀번호 → ④ 방화벽 순서로 확인하세요.

메시지원인과 해결
Connection refused 서버가 안 떠 있거나 포트가 다릅니다. systemctl status postgresql · docker ps 로 확인. 도커라면 -p 5432:5432 를 빠뜨리지 않았는지 보세요.
timeout / 무응답 방화벽·보안 그룹에서 막힌 것입니다. refused(거부)와 timeout(무응답)의 차이가 진단의 핵심입니다. 클라우드라면 인바운드 규칙을 확인하세요.
password authentication failed 비밀번호가 틀렸습니다. 도커에서 이미 볼륨이 있으면 POSTGRES_PASSWORD 를 바꿔도 반영되지 않습니다 — 볼륨을 지우고 다시 만들어야 합니다.
no pg_hba.conf entry for host PostgreSQL이 그 IP의 접속을 허용하지 않았습니다. pg_hba.conf 에 host all all <대역> scram-sha-256 을 추가하고 reload.
Access denied for user 'app'@'1.2.3.4' MySQL 계정의 접속 위치가 안 맞습니다. 'app'@'localhost' 만 만들었다면 외부에서는 못 붙습니다. 'app'@'%' 를 추가로 만드세요.
ORA-12541: TNS:no listener 오라클 리스너가 안 떠 있습니다. lsnrctl status 로 확인, lsnrctl start 로 기동합니다.
ORA-12514: service_name not known 리스너는 살아 있지만 서비스명이 틀렸습니다. FREE 인지 FREEPDB1 인지 확인하세요 — 23ai Free 는 보통 FREEPDB1 입니다.
ORA-01017: invalid username/password 계정·비밀번호 오류. 오라클은 따옴표 없이 만든 비밀번호가 대문자로 저장되던 시절의 습관 때문에 헷갈리기 쉽습니다. 큰따옴표로 감싸 만들었다면 대소문자를 그대로 입력해야 합니다.
database is locked (SQLite) 다른 연결이 쓰기 중입니다. WAL 모드 + busy_timeout 으로 대부분 해결됩니다 → ✒️ SQLite3 탭 s11 참고.
한글이 ??? 로 보임 문자셋 문제입니다. MySQL이라면 100% utf8mb4 설정 → 🐬 MySQL 탭 m04 참고.
too many connections 커넥션을 반납하지 않고 계속 만들고 있습니다. 앱의 커넥션 풀 설정을 확인하세요 → ⚙️ 튜닝 탭 t13.

막혔을 때 순서대로 치는 명령

# 1) 서버가 살아 있나
docker ps                       # 도커라면
systemctl status postgresql     # 직접 설치라면

# 2) 포트가 열려 있나 (LISTEN 이 보여야 함)
ss -lntp | grep -E '5432|3306|1521'

# 3) 네트워크가 닿나 (다른 PC에서)
nc -zv 10.0.1.20 5432

# 4) 로그에 뭐라고 나오나  ← 대부분 여기에 답이 있습니다
docker logs --tail 50 pg
sudo tail -50 /var/log/postgresql/postgresql-18-main.log
로그를 먼저 보세요. 접속 문제의 90%는 서버 로그에 원인이 그대로 적혀 있습니다. 추측으로 설정을 이것저것 바꾸는 것보다 훨씬 빠릅니다.
SECTION 10

학습 로드맵

이 가이드는 순서대로 읽으면 되지만, 목적에 따라 건너뛰어도 됩니다. 아래는 목적별 최단 경로입니다.

목적별 경로

목적순서
취업 준비 · 비전공 🧭 시작 → 📘 SQL 기초 전부 → ⚙️ 튜닝 t01·t04·t10·t11 → 🎓 SQL 전문가 e01~e05 (면접에서 나오는 범위가 거의 이것입니다)
백엔드 개발자 📘 SQL 기초 → 🐘 또는 🐬 (쓰는 DB) → ⚙️ 튜닝 전부 → 🔌 앱 연동 → 🧬 전문가
데이터 분석 📘 SQL 기초 → 🎓 SQL 전문가 전부 → ⚙️ t07 실행계획 → 🐘 PostgreSQL
SI · 금융 · 공공 📘 SQL 기초 → 🅾️ Oracle 전부 → ⚙️ 튜닝 → 🧬 전문가
앱 · 개인 프로젝트 🧭 시작 → 📘 SQL 기초 → ✒️ SQLite3 전부 → 🔌 앱 연동
이미 SQL을 아는 사람 🎓 SQL 전문가 → ⚙️ 튜닝 → 🧬 전문가 · 내부 구조

6주 학습 계획 (하루 1시간 기준)

1주차
설치와 첫 조회🧭 시작 탭 전부 + 📘 q01~q06. 샘플 데이터를 직접 넣어 보는 것이 핵심입니다.
2주차
조인과 집계📘 q07~q13. JOIN과 GROUP BY를 손에 익힐 때까지 반복하세요. 여기가 첫 고비입니다.
3주차
기초 마무리 + 설계📘 q14~q19 + ⚙️ t01~t03. 왜 표를 나누는지 이해하는 주입니다.
4주차
쓰는 DB 파고들기🐘 · 🐬 · 🅾️ · ✒️ 중 하나를 골라 전부. 방언과 설치·운영을 함께 봅니다.
5주차
성능⚙️ t04~t14. 인덱스와 실행계획이 이 가이드의 실질적 정점입니다.
6주차
고급 문법 + 앱 연동🎓 전문가 탭 + 🔌 앱 연동. 실제 코드에서 DB를 다루는 법까지 마칩니다.
가장 중요한 조언 — 읽기만 하면 남지 않습니다. 이 가이드의 SQL은 전부 복사해서 바로 실행되게 써 두었습니다. 한 섹션에 하나는 반드시 직접 실행해 보세요. 결과가 예상과 다를 때 진짜 학습이 일어납니다.
같이 보면 좋은 가이드 — DB를 앱에 붙이는 코드는 🔌 앱 연동 탭에 있고, 그 앱을 실제 서버에 올려 서비스하는 방법은 🌐 서버기술 가이드에서 Nginx·Apache·Tomcat·Caddy로 다룹니다. 언어별 심화는 🐍 파이썬 · ☕ 자바 · 🟨 JS · TS 가이드를 보세요.
SQL · 표준 문법 · 네 DB 공통

SQL 기초 —
물어보는 법을
배웁니다

이 탭의 문법은 Oracle · PostgreSQL · MySQL · SQLite 어디서나 거의 그대로 돌아갑니다. DB마다 다른 부분은 그때그때 4-DB 대조 카드로 짚어 둡니다. 앞 탭에서 만든 쇼핑몰 샘플 데이터를 그대로 씁니다 — 없다면 🧭 시작 탭 c08 로 먼저 가세요.

19SECTIONS
4DB 공통
복붙바로 실행
SECTION 01

SQL은 어떤 언어인가

SQL은 "이렇게 해라"가 아니라 "이런 결과를 달라"고 쓰는 언어입니다. 어떻게 찾을지는 DB가 알아서 정합니다 — 그래서 같은 결과라도 DB가 고르는 방법에 따라 속도가 100배 차이납니다.

명령은 네 종류뿐입니다

분류명령하는 일
DQL
조회
SELECT 데이터를 꺼냅니다. 실무에서 쓰는 SQL의 80% 이상이 이것입니다.
DML
조작
INSERT · UPDATE · DELETE · MERGE 데이터를 넣고 고치고 지웁니다. 트랜잭션으로 되돌릴 수 있습니다.
DDL
정의
CREATE · ALTER · DROP · TRUNCATE 표 자체를 만들고 바꾸고 지웁니다. 대부분의 DB에서 자동 커밋되어 되돌릴 수 없습니다.
DCL/TCL
권한·거래
GRANT · REVOKE · COMMIT · ROLLBACK 권한을 주고 뺏거나, 작업을 확정·취소합니다.
DELETE 와 TRUNCATE 는 완전히 다릅니다. DELETE 는 행을 한 줄씩 지우고 ROLLBACK 으로 되돌릴 수 있습니다. TRUNCATE 는 표를 통째로 비우는 DDL 이라 대부분 되돌릴 수 없습니다(PostgreSQL은 예외적으로 트랜잭션 안에서 롤백 가능). 운영 DB에서 TRUNCATE 를 칠 때는 반드시 두 번 확인하세요.

표준 SQL과 방언

SQL에는 ANSI/ISO 표준이 있지만, 완벽히 지키는 DB는 없습니다. 각 DB는 표준을 대부분 따르면서 자기만의 문법(방언, dialect)을 얹습니다. 이 가이드는 표준에 가까운 문법을 기본으로 쓰고, 차이가 나는 지점마다 네 DB를 나란히 보여 줍니다.

표준 · 어디서나 동일
SELECT / FROM / WHERE
JOIN / GROUP BY / HAVING
ORDER BY / CASE
CREATE TABLE / 제약조건
트랜잭션 (COMMIT·ROLLBACK)
윈도우 함수 · CTE
DB마다 다른 것
페이징 (LIMIT vs FETCH)
문자열 결합 (|| vs CONCAT)
날짜 함수 이름
자동 증가 컬럼
UPSERT 문법
데이터 타입 이름
대소문자와 줄바꿈 — SQL 키워드는 대소문자를 가리지 않습니다. 관례상 키워드는 대문자, 테이블·컬럼은 소문자로 씁니다. 줄바꿈도 자유롭습니다. 길어지면 FROM·WHERE 마다 줄을 바꾸세요 — 읽는 사람이 다릅니다.
SECTION 02

SELECT — 조회의 기본

SELECT 무엇을 FROM 어디서. 이 두 단어면 시작할 수 있습니다.

-- 전체 컬럼, 전체 행
SELECT * FROM member;

-- 필요한 컬럼만 (실무에서는 이쪽을 씁니다)
SELECT id, name, city FROM member;

-- 별칭(alias) — 결과에 보일 이름을 바꿉니다
SELECT id     AS 회원번호,
       name   AS 이름,
       city   AS 지역
FROM   member;

-- 계산해서 보여주기
SELECT name, price, price * 0.1 AS 부가세
FROM   product;

-- 중복 제거
SELECT DISTINCT city FROM member;
SELECT DISTINCT city, grade FROM member;   -- 두 컬럼 조합 기준 중복 제거
city 서울 부산 대전
SELECT * 는 연습용입니다. 실무 코드에 쓰면 ① 필요 없는 컬럼까지 네트워크로 보내 느려지고, ② 나중에 컬럼이 추가되면 앱이 예상 못 한 데이터를 받고, ③ 커버링 인덱스(⚙️ t05)를 못 타게 됩니다. 확인할 때만 쓰고, 코드에는 컬럼을 명시하세요.

별칭에 공백이나 한글을 쓰려면

-- 표준 · PostgreSQL · Oracle · SQLite : 큰따옴표
SELECT name AS "회원 이름" FROM member;

-- MySQL : 백틱도 됩니다 (큰따옴표는 설정에 따라 문자열로 해석될 수 있음)
SELECT name AS `회원 이름` FROM member;
따옴표 규칙 정리 — 작은따옴표('')는 문자열 값, 큰따옴표("")는 식별자(테이블·컬럼 이름)입니다. WHERE city = "서울" 이라고 쓰면 PostgreSQL은 "서울이라는 이름의 컬럼"을 찾다가 에러를 냅니다. 값에는 항상 작은따옴표를 쓰세요.

주석

-- 한 줄 주석 (하이픈 두 개 + 공백)

/* 여러 줄 주석
   이렇게 감쌉니다 */

SELECT name  -- 줄 끝에도 쓸 수 있습니다
FROM   member;
SECTION 03

WHERE — 조건 필터링

엑셀의 자동 필터와 같습니다. 조건에 맞는 행만 남깁니다.

-- 비교
SELECT * FROM product WHERE price >= 50000;
SELECT * FROM member  WHERE city = '서울';
SELECT * FROM member  WHERE city <> '서울';     -- != 도 대부분 됩니다

-- 범위
SELECT * FROM product WHERE price BETWEEN 20000 AND 50000;   -- 양쪽 끝 포함

-- 목록
SELECT * FROM member WHERE city IN ('서울','부산');
SELECT * FROM member WHERE grade NOT IN ('BASIC');

-- 패턴 (문자열)
SELECT * FROM product WHERE name LIKE '%마우스%';   -- 포함
SELECT * FROM product WHERE name LIKE 'USB%';      -- 로 시작
SELECT * FROM member  WHERE email LIKE '%@example.com';

-- 빈 값
SELECT * FROM member WHERE email IS NULL;
SELECT * FROM member WHERE email IS NOT NULL;

-- 여러 조건 조합
SELECT * FROM product
WHERE  category = '주변기기'
  AND  price < 40000
  AND  stock > 0;

AND · OR 를 섞을 때는 괄호를 치세요

✗ 의도와 다르게 동작
SELECT * FROM member
WHERE  city = '서울'
   OR  city = '부산'
  AND  grade = 'GOLD';

-- AND 가 먼저 계산되므로 실제로는
-- 서울 전부  +  (부산 AND GOLD)
✓ 괄호로 의도를 명시
SELECT * FROM member
WHERE  (city = '서울' OR city = '부산')
  AND  grade = 'GOLD';

-- 서울이거나 부산이면서, GOLD 인 사람

LIKE 의 함정

패턴뜻인덱스
LIKE '서울%'서울로 시작인덱스 사용 가능
LIKE '%서울'서울로 끝인덱스 못 씀 (풀스캔)
LIKE '%서울%'포함인덱스 못 씀 (풀스캔)
_아무 글자 한 개 — '김_' 은 두 글자 이름—
앞에 % 가 붙은 검색은 인덱스를 못 씁니다. 데이터가 수백만 건이면 그대로 장애가 됩니다. 본문 검색이 필요하면 전문 검색(Full-Text Search)을 써야 합니다 → 🎓 전문가 탭 e11 · 🐘 PostgreSQL p09 · ✒️ SQLite s10.

대소문자 구분

PostgreSQL
-- 기본은 구분함
WHERE name = 'Kim'      -- 'kim' 안 걸림
WHERE name ILIKE 'kim%' -- 대소문자 무시 LIKE
WHERE LOWER(name) = 'kim'
MySQL / MariaDB
-- 기본 콜레이션이 _ci 라 구분 안 함
WHERE name = 'kim'      -- 'Kim' 도 걸림
-- 구분하려면
WHERE name = 'Kim' COLLATE utf8mb4_bin
Oracle
-- 기본은 구분함
WHERE UPPER(name) = 'KIM'
-- 세션 단위로 끄기 (10gR2+)
ALTER SESSION SET
  NLS_COMP=LINGUISTIC NLS_SORT=BINARY_CI;
SQLite
-- ASCII 만 대소문자 무시 지원
WHERE name = 'kim' COLLATE NOCASE
-- 한글·유니코드는 구분함
WHERE LOWER(name) = 'kim' 은 인덱스를 죽입니다. 컬럼에 함수를 씌우면 그 컬럼의 인덱스를 못 씁니다. 함수 기반 인덱스를 따로 만들거나(⚙️ t06), 저장할 때부터 소문자로 넣으세요.
SECTION 04

ORDER BY · LIMIT

정렬하고 몇 개만 자릅니다. 둘을 합치면 "매출 상위 10건" 같은 요청이 바로 처리됩니다.

-- 오름차순 (기본값, ASC 는 생략 가능)
SELECT * FROM product ORDER BY price;

-- 내림차순
SELECT * FROM product ORDER BY price DESC;

-- 여러 기준 — 앞의 것이 같을 때 뒤의 것으로
SELECT * FROM member ORDER BY city, joined_at DESC;

-- 별칭으로 정렬 (ORDER BY 는 SELECT 다음이라 가능합니다)
SELECT name, price * 0.9 AS 할인가
FROM   product
ORDER  BY 할인가 DESC;

NULL 은 어디에 놓일까

DBASC 일 때 NULL 위치바꾸는 법
PostgreSQL맨 뒤ORDER BY x NULLS FIRST
Oracle맨 뒤ORDER BY x NULLS FIRST
MySQL / MariaDB맨 앞ORDER BY (x IS NULL), x
SQLite맨 앞NULLS LAST 지원 (3.30+)

상위 N건 — DB마다 문법이 다릅니다

PostgreSQL
SELECT * FROM product
ORDER BY price DESC
LIMIT 3 OFFSET 0;
MySQL / MariaDB
SELECT * FROM product
ORDER BY price DESC
LIMIT 3;          -- LIMIT 0, 3 도 동일
Oracle (12c 이상)
SELECT * FROM product
ORDER BY price DESC
FETCH FIRST 3 ROWS ONLY;

-- 11g 이하는 ROWNUM 을 씁니다 (o05 참고)
SQLite
SELECT * FROM product
ORDER BY price DESC
LIMIT 3;
표준 문법은 OFFSET … FETCH 입니다. PostgreSQL·Oracle·SQL Server 가 지원하고, MySQL 8.4·SQLite 는 LIMIT 만 씁니다. 이식성이 중요하면 표준을, 아니면 각 DB의 관례를 따르세요.
정렬 기준이 유일하지 않으면 페이징이 깨집니다. ORDER BY price DESC LIMIT 10 에서 같은 가격이 여러 개면 페이지를 넘길 때마다 순서가 달라져 같은 행이 두 번 나오거나 아예 빠질 수 있습니다. 마지막에 항상 유일한 컬럼(보통 id)을 붙이세요: ORDER BY price DESC, id DESC.
SECTION 05

NULL — 가장 흔한 함정

NULL은 0도, 빈 문자열도, false도 아닙니다. "모른다"는 뜻입니다. 이 한 줄을 이해하면 처음 겪게 되는 버그의 상당수가 사라집니다.

같은 컬럼을 세 가지로 비교했을 때 — 가운데만 결과가 다르다 price = 1000 price = NULL price IS NULL TRUE UNKNOWN TRUE WHERE 통과 못 함 행이 결과에 남는다 행이 사라진다 행이 결과에 남는다 WHERE 는 TRUE 인 행만 통과시킵니다 — UNKNOWN 은 결국 FALSE 와 똑같이 버려집니다
NULL 은 값이 아니라 "모른다"는 상태입니다. 모르는 값끼리 비교하면 답도 "모른다"가 됩니다.

모르는 값끼리는 비교할 수 없습니다

-- ✗ 절대 참이 되지 않습니다 (결과 0행)
SELECT * FROM member WHERE email = NULL;

-- ✓ 이렇게 씁니다
SELECT * FROM member WHERE email IS NULL;
SELECT * FROM member WHERE email IS NOT NULL;
왜 그럴까 — "모르는 값 = 모르는 값" 은 참일까요? 나이를 모르는 두 사람이 동갑인지 알 수 없듯, SQL은 참도 거짓도 아닌 UNKNOWN을 돌려줍니다. WHERE 는 참인 행만 남기므로 UNKNOWN 은 걸러집니다.

계산에 섞이면 전부 NULL이 됩니다

SELECT 1000 + NULL;        -- NULL  (0 이 아닙니다)
SELECT '주문-' || NULL;     -- NULL  (Oracle 은 예외적으로 '주문-')

-- 그래서 이렇게 방어합니다
SELECT COALESCE(email, '(이메일 없음)') FROM member;   -- 표준 · 어디서나
SELECT NVL(email, '(없음)')  FROM member;              -- Oracle
SELECT IFNULL(email, '(없음)') FROM member;            -- MySQL · SQLite

집계 함수는 NULL을 빼고 셉니다

-- 회원 5명 중 email 이 있는 사람은 4명 (정바람만 NULL)
SELECT COUNT(*)     AS 전체,       -- 5   ← 행의 개수 (NULL 이든 아니든)
       COUNT(email) AS 이메일있음  -- 4   ← NULL 이 아닌 값의 개수
FROM   member;

-- 평균도 마찬가지 — NULL 인 행은 분모에서도 빠집니다
SELECT COUNT(*)   AS 상품수,      -- 5
       AVG(price) AS 평균가        -- price 가 NULL 인 상품이 있다면 그 행은 제외
FROM   product;
평균이 이상하게 나온다면 여기를 의심하세요. AVG(score) 는 점수가 NULL인 사람을 분모에서도 제외합니다. "미응답을 0점으로 치고 싶다"면 AVG(COALESCE(score, 0)) 이라고 명시해야 합니다.

가장 악명 높은 함정 — NOT IN 과 NULL

✗ 결과가 통째로 0행
-- 서브쿼리 결과에 NULL 이 하나라도 있으면
SELECT * FROM member
WHERE  id NOT IN (SELECT member_id FROM orders);

-- member_id 에 NULL 이 섞이는 순간
-- 전체 결과가 0행이 됩니다
✓ NOT EXISTS 를 쓰세요
SELECT * FROM member m
WHERE  NOT EXISTS (
  SELECT 1 FROM orders o
  WHERE  o.member_id = m.id
);

-- NULL 이 있어도 정상 동작하고
-- 보통 더 빠릅니다

빈 문자열과 NULL은 다릅니다 — 오라클만 빼고

DB'' (빈 문자열)의 취급
PostgreSQLNULL과 다릅니다. '' IS NULL 은 false
MySQL / MariaDBNULL과 다릅니다
SQLiteNULL과 다릅니다
Oracle빈 문자열을 NULL로 취급합니다. 오라클만의 특이점이며 마이그레이션에서 반드시 걸립니다
실무 원칙 — 값이 없을 수 있는 컬럼은 NULL 허용을 명시하고, 없어서는 안 되는 컬럼은 NOT NULL + DEFAULT 로 잠그세요. "일단 NULL 허용해 두자"가 나중에 집계 오류와 NPE(널 포인터)의 근원이 됩니다.
SECTION 06

자료형 완전정복

타입을 잘못 고르면 1원이 틀어지고, 한글이 깨지고, 2038년에 서비스가 멈춥니다. 실제로 있었던 일들입니다.

숫자

용도표준 · PostgreSQLOracleMySQL
정수 (일반)INTEGER ±21억NUMBER(10)INT
정수 (큰 값·PK)BIGINTNUMBER(19)BIGINT
돈 · 정확한 소수NUMERIC(12,2)NUMBER(12,2)DECIMAL(12,2)
과학 계산 (오차 허용)DOUBLE PRECISIONBINARY_DOUBLEDOUBLE
돈을 FLOAT·DOUBLE에 넣지 마세요. 0.1 + 0.2 가 0.30000000000000004 가 되는 그 문제입니다. 수천만 건을 합산하면 실제로 원 단위가 틀어지고, 회계 대사에서 반드시 걸립니다. 금액은 무조건 DECIMAL/NUMERIC, 혹은 원 단위 정수(BIGINT)로 저장하세요.

문자

타입설명언제
VARCHAR(n)가변 길이. 넣은 만큼만 차지합니다★ 기본으로 이것
CHAR(n)고정 길이 — 짧으면 공백으로 채웁니다길이가 항상 같을 때만 (국가코드 등)
TEXT길이 제한 없음본문·설명 (Oracle은 CLOB)
VARCHAR2(n)Oracle 전용 이름Oracle에서는 이걸 씁니다
VARCHAR(50) 의 50은 무엇의 단위일까 — PostgreSQL·MySQL·SQLite는 글자 수입니다(한글 50자 가능). Oracle은 기본이 바이트라 한글 50자를 넣으려면 VARCHAR2(50 CHAR) 라고 써야 합니다. 이걸 모르고 VARCHAR2(50) 으로 만들면 한글은 16자쯤에서 잘립니다.

날짜 · 시간

타입담는 것주의
DATE날짜만 (표준·PG·MySQL)Oracle의 DATE는 시분초까지 포함합니다
TIMESTAMP날짜 + 시각MySQL의 TIMESTAMP는 2038년 한계가 있습니다
TIMESTAMPTZ시간대까지 (PostgreSQL)★ 글로벌 서비스면 이것
DATETIMEMySQL — 시간대 변환 없음서버 시간대가 바뀌어도 값이 그대로
(SQLite)날짜 타입이 없습니다TEXT('2026-08-05') 또는 정수로 저장
MySQL TIMESTAMP 의 2038년 문제 — 32비트로 초를 세기 때문에 2038-01-19 이후를 저장할 수 없습니다. 구독 만료일·보증 종료일처럼 미래 날짜를 담는 컬럼은 DATETIME 을 쓰세요. (반대로 "생성 시각"처럼 과거만 담는 컬럼은 TIMESTAMP가 시간대 처리에 유리합니다)

그 밖에 알아 두면 좋은 것

  • BOOLEAN — PostgreSQL·SQLite는 지원. Oracle은 23ai부터 지원(이전에는 NUMBER(1)). MySQL의 BOOLEAN 은 사실 TINYINT(1) 의 별칭입니다.
  • UUID — PostgreSQL은 전용 타입, MySQL은 BINARY(16) 권장(문자열로 넣으면 인덱스가 커집니다).
  • JSON / JSONB — 스키마가 자주 바뀌는 데이터용. PostgreSQL의 JSONB가 가장 강력합니다 → 🐘 p07.
  • ENUM — 값 목록을 타입으로 강제. 편하지만 값 추가 시 DDL이 필요해 실무에서는 코드 테이블을 선호합니다.
SECTION 07

INSERT · UPDATE · DELETE

데이터를 바꾸는 세 명령입니다. WHERE를 빠뜨리는 순간 전체가 바뀝니다.

-- INSERT : 컬럼을 명시하는 습관을 들이세요
INSERT INTO member (id, name, email, grade, city, joined_at)
VALUES (6, '한바다', 'han@example.com', 'BASIC', '광주', '2026-08-01');

-- 여러 건 한 번에 (Oracle 은 23ai 부터 · 그 전은 INSERT ALL)
INSERT INTO member (id, name, grade, city, joined_at) VALUES
 (7, '오하늘', 'BASIC', '서울', '2026-08-02'),
 (8, '유들판', 'BASIC', '대구', '2026-08-03');

-- 조회 결과를 그대로 넣기 (백업 테이블 만들 때 유용)
INSERT INTO member_archive (id, name, city)
SELECT id, name, city FROM member WHERE joined_at < '2025-01-01';
-- UPDATE : WHERE 를 반드시
UPDATE member
SET    grade = 'GOLD'
WHERE  id = 3;

-- 여러 컬럼 동시에
UPDATE product
SET    price = price * 1.1,
       stock = stock - 1
WHERE  id = 10;

-- 다른 테이블 값을 참고해 갱신 (문법이 DB마다 다릅니다)
-- PostgreSQL
UPDATE orders o
SET    status = 'VIP'
FROM   member m
WHERE  m.id = o.member_id AND m.grade = 'GOLD';

-- MySQL
UPDATE orders o
JOIN   member m ON m.id = o.member_id
SET    o.status = 'VIP'
WHERE  m.grade = 'GOLD';
-- DELETE
DELETE FROM order_item WHERE order_id = 1003;
DELETE FROM orders     WHERE id = 1003;

-- 전부 지우기 (되돌릴 수 있음)
DELETE FROM orders;

-- 표를 비우기 (빠르지만 대부분 되돌릴 수 없음)
TRUNCATE TABLE orders;
운영 DB에서 UPDATE·DELETE 를 칠 때의 3단계 안전 절차
① SELECT 로 먼저 대상을 확인합니다 — SELECT * FROM member WHERE id = 3;
② 확인이 끝나면 SELECT 부분만 UPDATE … SET 으로 바꿉니다 (WHERE 는 그대로 둡니다)
③ 자동 커밋을 끄고(BEGIN;) 실행한 뒤 영향 행 수를 확인하고 COMMIT 합니다
-- ③ 의 실제 모습
BEGIN;

UPDATE member SET grade = 'GOLD' WHERE id = 3;
-- UPDATE 1     ← 1행. 예상과 같으면

COMMIT;
-- 예상과 다르면 ROLLBACK;
실수를 구조적으로 막는 방법 — ① 운영 DB 접속은 기본 자동 커밋을 끈 클라이언트로 합니다(DBeaver 설정에 있습니다). ② 앱 계정에는 DELETE 권한을 주지 않고 삭제 대신 deleted_at 컬럼을 채우는 소프트 삭제를 씁니다. ③ 삭제 전 백업 테이블에 INSERT … SELECT 로 옮겨 둡니다.
SECTION 08

CREATE TABLE · 제약조건

제약조건은 DB가 강제로 지켜 주는 규칙입니다. 앱 코드의 검증은 빠뜨릴 수 있지만, 제약조건은 빠져나갈 수 없습니다.

CREATE TABLE coupon (
    id          INTEGER       PRIMARY KEY,              -- 유일 + NOT NULL
    code        VARCHAR(20)   NOT NULL UNIQUE,          -- 중복 금지
    member_id   INTEGER       NOT NULL,
    discount    DECIMAL(5,2)  NOT NULL
                CHECK (discount > 0 AND discount <= 50),-- 값 범위 강제
    used        CHAR(1)       NOT NULL DEFAULT 'N',
    expires_at  DATE          NOT NULL,
    CONSTRAINT fk_coupon_member                          -- 이름을 붙이면 에러 추적이 쉽습니다
        FOREIGN KEY (member_id) REFERENCES member(id)
        ON DELETE CASCADE
);
제약뜻없으면 생기는 일
PRIMARY KEY이 행을 유일하게 가리키는 값같은 회원이 두 번 들어가도 구분 불가
FOREIGN KEY다른 표의 키를 가리킴존재하지 않는 회원의 주문이 쌓임 (고아 데이터)
NOT NULL빈 값 금지이름 없는 회원이 생김
UNIQUE중복 금지 (NULL은 여러 개 허용)같은 이메일로 계정 두 개
CHECK값의 범위 제한수량이 −5인 주문
DEFAULT안 넣으면 이 값상태가 비어 있는 행

외래키의 ON DELETE 옵션

옵션부모(member)를 지우면
NO ACTION / RESTRICT기본값. 자식이 있으면 삭제를 막습니다 — 가장 안전합니다
CASCADE자식(주문)도 같이 지웁니다 — 편하지만 실수하면 대량 삭제입니다
SET NULL자식의 참조 컬럼을 NULL로 (컬럼이 NULL 허용이어야 함)
ON DELETE CASCADE 는 신중하게. 회원 한 명을 지웠는데 주문·결제·리뷰가 연쇄로 사라질 수 있습니다. 실무에서는 기본값(삭제 금지) + 소프트 삭제 조합을 훨씬 많이 씁니다.

자동 증가 컬럼 — 4-DB 대조

PostgreSQL
id INT GENERATED ALWAYS AS IDENTITY
                 PRIMARY KEY
-- 구버전 관례: id SERIAL PRIMARY KEY
MySQL / MariaDB
id INT AUTO_INCREMENT PRIMARY KEY
Oracle (12c 이상)
id NUMBER GENERATED ALWAYS AS IDENTITY
                    PRIMARY KEY
-- 11g 이하: 시퀀스 + 트리거 (o08)
SQLite
id INTEGER PRIMARY KEY
-- 이것만으로 자동 증가합니다
-- AUTOINCREMENT 는 대부분 불필요 (s05)

나중에 구조 바꾸기 (ALTER)

ALTER TABLE member ADD COLUMN phone VARCHAR(20);
ALTER TABLE member ALTER COLUMN phone TYPE VARCHAR(30);   -- PostgreSQL
ALTER TABLE member MODIFY phone VARCHAR(30);              -- MySQL · Oracle
ALTER TABLE member DROP COLUMN phone;

ALTER TABLE orders ADD CONSTRAINT chk_status
      CHECK (status IN ('PAID','SHIPPED','CANCELED'));
운영 중인 큰 테이블에 ALTER 를 치기 전에 반드시 확인하세요. 수천만 건 테이블에 컬럼을 추가하면 DB에 따라 테이블 전체를 복사하면서 수 분~수십 분간 서비스가 멈출 수 있습니다 → 🐬 MySQL m13 (온라인 DDL) · 🔌 앱 연동 a09 (마이그레이션 도구) 참고.
SECTION 09

JOIN 5종

두 표를 키로 이어 붙이는 것이 JOIN입니다. 엑셀 VLOOKUP이 하는 일과 같습니다. 아래 버튼을 눌러 종류별 결과를 직접 비교해 보세요.

JOIN 종류에 따라 결과가 어떻게 달라지나

member (왼쪽)

idname
1김하늘
2박바다
3이구름 (주문 없음)

orders (오른쪽)

order_nomember_id
10011
10022
10052
10099 (없는 회원)

결과 —

-- INNER JOIN : 양쪽에 다 있는 것만 (JOIN 만 써도 INNER 입니다)
SELECT m.name, o.id AS order_no
FROM   member m
JOIN   orders o ON o.member_id = m.id;

-- LEFT JOIN : 왼쪽은 전부 (실무에서 가장 많이 씁니다)
SELECT m.name, o.id AS order_no
FROM   member m
LEFT JOIN orders o ON o.member_id = m.id;

-- 주문이 한 번도 없는 회원 찾기 — LEFT JOIN 의 대표 활용
SELECT m.name
FROM   member m
LEFT JOIN orders o ON o.member_id = m.id
WHERE  o.id IS NULL;

-- 세 개 이상 붙이기
SELECT m.name, p.name AS product, oi.qty
FROM   member m
JOIN   orders o      ON o.member_id  = m.id
JOIN   order_item oi ON oi.order_id  = o.id
JOIN   product p     ON p.id         = oi.product_id
WHERE  o.status = 'PAID';
LEFT JOIN 뒤에 WHERE 를 붙이면 INNER JOIN 이 됩니다. LEFT JOIN orders o … WHERE o.status = 'PAID' 라고 쓰면 주문이 없는 회원은 o.status 가 NULL이라 조건에서 걸러집니다. 오른쪽 테이블 조건은 ON 절에 넣으세요: LEFT JOIN orders o ON o.member_id = m.id AND o.status = 'PAID'.

JOIN 하면 행이 늘어납니다

회원 1명에게 주문이 3건 있으면, 조인 결과에서 그 회원은 3번 나옵니다. 그래서 조인 후 SUM() 을 하면 값이 부풀 수 있습니다 — 실무에서 가장 흔한 집계 오류입니다.

✗ 금액이 부풀어 오름
-- 주문이 여러 건이면 회원 정보가 반복되어
-- 포인트가 주문 수만큼 더해집니다
SELECT m.name,
       SUM(m.point) AS 포인트,   -- 부풀어 오름!
       SUM(oi.qty)  AS 수량
FROM member m
JOIN orders o      ON o.member_id = m.id
JOIN order_item oi ON oi.order_id = o.id
GROUP BY m.name;
✓ 집계를 먼저 하고 붙이기
SELECT m.name, m.point, t.수량
FROM   member m
JOIN  (SELECT o.member_id, SUM(oi.qty) AS 수량
       FROM   orders o
       JOIN   order_item oi ON oi.order_id = o.id
       GROUP  BY o.member_id) t
  ON   t.member_id = m.id;
SELF JOIN — 같은 표를 자기 자신과 붙이는 것입니다. 직원과 상사가 같은 employee 표에 있을 때 FROM employee e JOIN employee b ON b.id = e.manager_id 처럼 씁니다. 별칭(e, b)을 반드시 달아야 어느 쪽인지 구분됩니다.
회원 (1, 2, 3) · 주문 (2, 3, 4) 을 이었을 때 남는 행 INNER JOIN — 양쪽에 다 있는 것만 결과: 2 · 3 가장 많이 씁니다 — "주문한 적 있는 회원" 처럼 양쪽 조건이 다 필요할 때 LEFT OUTER JOIN — 왼쪽은 전부 결과: 1 · 2 · 3 (1 은 NULL) "주문이 없는 회원 찾기" 에 씁니다 — WHERE o.id IS NULL 을 붙이면 됩니다 RIGHT OUTER JOIN — 오른쪽은 전부 결과: 2 · 3 · 4 표 순서를 바꾼 LEFT 와 같습니다 — 읽기 어려워서 잘 안 씁니다 FULL OUTER JOIN — 양쪽 전부 결과: 1 · 2 · 3 · 4 양쪽 대사에 씁니다 — MySQL 은 지원하지 않아 UNION 으로 흉내 냅니다 CROSS JOIN — 조건 없이 모든 조합 결과: 3 × 3 = 9행 일부러 쓸 때도 있지만, 대개는 ON 을 빠뜨린 실수입니다
조인 뒤 행 수가 늘었다면 키가 중복된 것입니다 — 조인 조건부터 확인하세요.
SECTION 10

GROUP BY · 집계 함수

엑셀 피벗테이블이 하는 일입니다. 같은 값끼리 묶어서 계산합니다.

집계 함수 다섯 개

함수하는 일NULL 취급
COUNT(*)행의 개수NULL 행도 셉니다
COUNT(컬럼)그 컬럼이 NULL이 아닌 개수NULL 제외
SUM(컬럼)합계NULL 제외
AVG(컬럼)평균NULL은 분모에서도 제외
MIN · MAX최소 · 최대NULL 제외
-- 전체 집계 (GROUP BY 없이)
SELECT COUNT(*) AS 상품수, AVG(price) AS 평균가, MAX(price) AS 최고가
FROM   product;

-- 카테고리별로 묶어서
SELECT category,
       COUNT(*)   AS 상품수,
       AVG(price) AS 평균가,
       SUM(stock) AS 총재고
FROM   product
GROUP  BY category
ORDER  BY 평균가 DESC;

-- 두 컬럼으로 묶기
SELECT city, grade, COUNT(*) AS 인원
FROM   member
GROUP  BY city, grade
ORDER  BY city, grade;

-- 조인 + 집계 — 회원별 총 구매액
SELECT m.name,
       COUNT(DISTINCT o.id)          AS 주문건수,
       SUM(oi.qty * oi.unit_price)   AS 총구매액
FROM   member m
JOIN   orders o      ON o.member_id = m.id
JOIN   order_item oi ON oi.order_id = o.id
WHERE  o.status <> 'CANCELED'
GROUP  BY m.name
ORDER  BY 총구매액 DESC;
name 주문건수 총구매액 최나무 2 694000 박바다 2 370000 김하늘 1 103000
GROUP BY 의 철칙 — SELECT 에 쓴 컬럼은 ① GROUP BY 에 있거나 ② 집계 함수 안에 있어야 합니다.
PostgreSQL·Oracle은 어기면 에러를 냅니다. MySQL은 설정(ONLY_FULL_GROUP_BY)에 따라 통과시키기도 하는데, 이때 나오는 값은 그룹 안 아무 행이나라서 결과를 믿을 수 없습니다.

COUNT(*) vs COUNT(컬럼) vs COUNT(DISTINCT)

SELECT COUNT(*)              AS 전체행,      -- 5
       COUNT(email)          AS 이메일보유,  -- 4  (NULL 제외)
       COUNT(DISTINCT city)  AS 지역수       -- 3  (서울·부산·대전)
FROM   member;
조인 후 COUNT(*) 는 위험합니다. 주문 3건이 붙으면 회원 1명이 3으로 세어집니다. "주문한 회원 수"를 원한다면 COUNT(DISTINCT m.id) 를 쓰세요.
GROUP BY 는 '행을 묶어 한 줄로 줄이는' 일입니다 원본 — 주문 5건 서울 · 김 · 12000 부산 · 이 · 8000 서울 · 박 · 30000 부산 · 최 · 5000 서울 · 정 · 7000 city 로 묶기 '서울' 묶음 12000 · 30000 · 7000 3행이 한 덩어리로 '부산' 묶음 8000 · 5000 서울 · 3건 · 49000 COUNT(*) = 3 SUM(amount) = 49000 부산 · 2건 · 13000 묶음 하나가 결과 한 줄 묶지 않은 열을 그냥 고르면 오류가 납니다 SELECT city, name … — '서울' 묶음의 name 은 셋인데 한 줄에 하나만 넣어야 합니다. GROUP BY 에 넣거나 집계 함수로 감싸세요
집계는 "몇 줄이 한 줄이 되는가"를 그려 보면 헷갈리지 않습니다.
GROUP BY 는 '행을 묶어 한 줄로 줄이는' 일입니다 원본 — 주문 5건 서울 · 김 · 12000 부산 · 이 · 8000 서울 · 박 · 30000 부산 · 최 · 5000 서울 · 정 · 7000 city 로 묶기 '서울' 묶음 12000 · 30000 · 7000 3행이 한 덩어리로 '부산' 묶음 8000 · 5000 서울 · 3건 · 49000 COUNT(*) = 3 SUM(amount) = 49000 부산 · 2건 · 13000 묶음 하나가 결과 한 줄 묶지 않은 열을 그냥 고르면 오류가 납니다 SELECT city, name, SUM(amount) — '서울' 묶음의 name 은 김·박·정 셋인데 한 줄에 하나만 넣어야 합니다. 그래서 GROUP BY 에 넣거나 집계 함수로 감싸야 합니다
집계는 "몇 줄이 한 줄이 되는가"를 그려 보면 헷갈리지 않습니다.
SECTION 11

HAVING vs WHERE

둘 다 "거른다"지만 거르는 시점이 다릅니다. WHERE는 묶기 전에, HAVING은 묶은 다음에 거릅니다.

1
FROM · JOIN표를 읽고 붙입니다
▼
2 · WHERE
개별 행을 거릅니다아직 그룹이 없으므로 집계 함수를 쓸 수 없습니다
▼
3
GROUP BY남은 행을 묶습니다
▼
4 · HAVING
묶인 그룹을 거릅니다여기서는 집계 함수를 쓸 수 있습니다
-- "취소되지 않은 주문만 대상으로, 총 구매액 30만원 넘는 회원"
SELECT m.name, SUM(oi.qty * oi.unit_price) AS 총구매액
FROM   member m
JOIN   orders o      ON o.member_id = m.id
JOIN   order_item oi ON oi.order_id = o.id
WHERE  o.status <> 'CANCELED'          -- ← 행 단위 조건 (묶기 전)
GROUP  BY m.name
HAVING SUM(oi.qty * oi.unit_price) > 300000   -- ← 그룹 조건 (묶은 뒤)
ORDER  BY 총구매액 DESC;
✗ WHERE 에 집계 함수 — 에러
SELECT category, COUNT(*)
FROM   product
WHERE  COUNT(*) > 2      -- ✗
GROUP  BY category;
✓ HAVING 으로
SELECT category, COUNT(*)
FROM   product
GROUP  BY category
HAVING COUNT(*) > 2;     -- ✓
성능 관점 — 같은 결과를 낼 수 있다면 WHERE 를 먼저 쓰세요. WHERE 로 행을 미리 줄이면 그룹핑할 데이터 자체가 적어집니다. HAVING 은 집계 결과에만 걸 수 있는 조건에 씁니다.
WHERE 와 HAVING — 같은 걸러내기, 다른 시점 전체 행 주문 10만 건 WHERE 행 단위로 거름 (취소 제외) GROUP BY 회원별로 묶기 HAVING 요약 결과를 거름 WHERE 에 두어야 하는 조건 status = '완료' · 날짜 범위 · 지역 개별 행만 보면 판단되는 것 HAVING 에 두어야 하는 조건 SUM(amount) > 100000 · COUNT(*) >= 3 묶어 봐야 알 수 있는 것 행 조건을 HAVING 에 두면 결과는 같아도 훨씬 느립니다 — 다 묶은 뒤에 버리기 때문입니다 기준 한 줄 — 집계 함수가 들어가면 HAVING, 아니면 WHERE 입니다
먼저 줄일수록 뒤가 전부 빨라집니다 — 버릴 수 있는 행은 WHERE 에서.
SECTION 12

서브쿼리 3형태

쿼리 안의 쿼리입니다. 기준값을 먼저 구해야 할 때 씁니다. 들어가는 위치에 따라 세 가지로 나뉩니다.

같은 서브쿼리라도 어느 자리에 놓느냐로 이름과 역할이 갈린다 SELECT ( SELECT … ) AS 최근주문 스칼라 — 값 하나를 열처럼 옆에 붙인다 FROM ( SELECT … ) AS 월별 인라인 뷰 — 결과가 임시 표가 된다 WHERE id IN ( SELECT … ) 조건 — 비교할 값 · 목록을 만들어 준다 안쪽이 먼저 실행되고 그 결과를 바깥이 받습니다 — 바깥 컬럼을 안에서 참조하면 상관 서브쿼리가 되어 행마다 다시 돕니다
서브쿼리를 고를 때 던질 질문은 하나입니다 — 내가 필요한 것이 값 하나인가, 표인가, 조건인가.

① WHERE 절 — 조건으로

-- 평균 가격보다 비싼 상품
SELECT name, price
FROM   product
WHERE  price > (SELECT AVG(price) FROM product);

-- 주문한 적 있는 회원 (IN)
SELECT name FROM member
WHERE  id IN (SELECT member_id FROM orders);

-- 같은 일을 EXISTS 로 — 보통 더 빠르고 NULL 에 안전합니다
SELECT name FROM member m
WHERE  EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id);

② FROM 절 — 임시 표로 (인라인 뷰)

-- 회원별 구매액을 먼저 구하고, 그중 상위만
SELECT *
FROM  (SELECT m.id, m.name, SUM(oi.qty * oi.unit_price) AS 구매액
       FROM   member m
       JOIN   orders o      ON o.member_id = m.id
       JOIN   order_item oi ON oi.order_id = o.id
       GROUP  BY m.id, m.name) t
WHERE  t.구매액 > 200000
ORDER  BY t.구매액 DESC;
이렇게 중첩이 깊어지면 CTE(WITH) 로 바꾸는 편이 훨씬 읽기 좋습니다 → 🎓 전문가 탭 e04.

③ SELECT 절 — 값 하나로 (스칼라 서브쿼리)

SELECT m.name,
       (SELECT COUNT(*) FROM orders o WHERE o.member_id = m.id) AS 주문수
FROM   member m;
스칼라 서브쿼리는 행마다 실행될 수 있습니다. 회원 10만 명이면 서브쿼리가 10만 번 도는 셈입니다. 옵티마이저가 조인으로 바꿔 주기도 하지만 보장되지 않습니다. 행이 많으면 LEFT JOIN + GROUP BY 로 바꾸세요.

상관 서브쿼리 — 바깥 값을 참조하는 것

-- 카테고리 안에서 평균보다 비싼 상품
SELECT p.name, p.category, p.price
FROM   product p
WHERE  p.price > (SELECT AVG(p2.price)
                  FROM   product p2
                  WHERE  p2.category = p.category);   -- ← 바깥 p 를 참조

-- 같은 일을 윈도우 함수로 (보통 더 빠릅니다 · e01 참고)
SELECT name, category, price
FROM  (SELECT name, category, price,
              AVG(price) OVER (PARTITION BY category) AS 카테고리평균
       FROM   product) t
WHERE  price > 카테고리평균;
SECTION 13

집합 연산 UNION

JOIN이 옆으로 붙인다면, UNION은 위아래로 이어 붙입니다.

-- UNION : 합치고 중복 제거 (정렬 비용이 듭니다)
SELECT name FROM member WHERE city = '서울'
UNION
SELECT name FROM member WHERE grade = 'GOLD';

-- UNION ALL : 합치기만 (중복 제거 안 함 · 훨씬 빠름)
SELECT id, name, '2026-01' AS 월 FROM sales_202601
UNION ALL
SELECT id, name, '2026-02'       FROM sales_202602;

-- INTERSECT : 양쪽 모두에 있는 것
SELECT name FROM member WHERE city = '서울'
INTERSECT
SELECT name FROM member WHERE grade = 'GOLD';

-- EXCEPT : 앞에는 있고 뒤에는 없는 것 (Oracle 은 MINUS)
SELECT id FROM member
EXCEPT
SELECT member_id FROM orders;
연산PostgreSQLOracleMySQLSQLite
UNION / UNION ALL✓✓✓✓
INTERSECT✓✓✓ (8.0.31+)✓
EXCEPT✓MINUS✓ (8.0.31+)✓
UNION 을 쓸 때 지켜야 할 것
① 양쪽의 컬럼 개수가 같아야 합니다
② 같은 자리의 타입이 호환돼야 합니다
③ 결과 컬럼 이름은 첫 번째 SELECT 를 따릅니다
④ ORDER BY 는 맨 마지막에 한 번만 씁니다
중복이 없다는 걸 안다면 반드시 UNION ALL 을 쓰세요. UNION 은 중복 제거를 위해 전체를 정렬하거나 해시를 만듭니다. 수백만 행에서는 이 차이가 수 초 대 수십 초로 벌어집니다.

실무 활용 — 데이터 대사

-- 마이그레이션 후 "옛 시스템에만 있는 회원" 찾기
SELECT id FROM legacy_member
EXCEPT
SELECT id FROM member;

-- 양쪽이 완전히 같은지 한 번에 확인 (두 방향 모두 0행이어야 함)
(SELECT * FROM a EXCEPT SELECT * FROM b)
UNION ALL
(SELECT * FROM b EXCEPT SELECT * FROM a);
SECTION 14

문자열 · 날짜 · 숫자 함수

여기가 DB별 차이가 가장 심한 영역입니다. 자주 쓰는 것만 대조표로 정리했습니다.

문자열

하려는 일표준 · PGOracleMySQLSQLite
이어 붙이기a || ba || bCONCAT(a,b)a || b
길이LENGTH(s)LENGTH(s)CHAR_LENGTH(s)LENGTH(s)
자르기SUBSTRING(s,1,3)SUBSTR(s,1,3)SUBSTRING(s,1,3)SUBSTR(s,1,3)
대소문자UPPER · LOWERUPPER · LOWERUPPER · LOWERUPPER · LOWER
공백 제거TRIM(s)TRIM(s)TRIM(s)TRIM(s)
치환REPLACE(s,a,b)REPLACE(s,a,b)REPLACE(s,a,b)REPLACE(s,a,b)
위치 찾기POSITION(x IN s)INSTR(s,x)LOCATE(x,s)INSTR(s,x)
왼쪽 채우기LPAD(s,5,'0')LPAD(s,5,'0')LPAD(s,5,'0')printf('%05d',n)
MySQL 의 || 는 기본적으로 OR 연산자입니다. 문자열 결합에는 CONCAT() 을 쓰세요. (sql_mode 에 PIPES_AS_CONCAT 을 넣으면 표준처럼 동작하지만, 설정에 의존하는 코드는 위험합니다)

날짜

하려는 일PostgreSQLOracleMySQLSQLite
현재 시각NOW()SYSDATENOW()datetime('now')
오늘 날짜CURRENT_DATETRUNC(SYSDATE)CURDATE()date('now')
N일 더하기d + INTERVAL '7 day'd + 7DATE_ADD(d,INTERVAL 7 DAY)date(d,'+7 day')
N개월 더하기d + INTERVAL '1 month'ADD_MONTHS(d,1)DATE_ADD(d,INTERVAL 1 MONTH)date(d,'+1 month')
날짜 차이(일)d1 - d2d1 - d2DATEDIFF(d1,d2)julianday(d1)-julianday(d2)
문자 → 날짜TO_DATE(s,'YYYY-MM-DD')TO_DATE(s,'YYYY-MM-DD')STR_TO_DATE(s,'%Y-%m-%d')문자열 그대로
날짜 → 문자TO_CHAR(d,'YYYY-MM')TO_CHAR(d,'YYYY-MM')DATE_FORMAT(d,'%Y-%m')strftime('%Y-%m',d)
월 단위 자르기DATE_TRUNC('month',d)TRUNC(d,'MM')DATE_FORMAT(d,'%Y-%m-01')date(d,'start of month')
기간 조회에서 가장 흔한 버그 — WHERE ordered_at BETWEEN '2026-01-01' AND '2026-01-31' 은 1월 31일 오전 0시까지만 걸립니다. 시분초가 있는 컬럼이면 31일 데이터가 통째로 빠집니다.
✓ 안전한 형태: WHERE ordered_at >= '2026-01-01' AND ordered_at < '2026-02-01'
(DATE(ordered_at) = … 처럼 컬럼에 함수를 씌우면 인덱스를 못 씁니다)

숫자

SELECT ROUND(12345.678, 2)   AS 반올림,   -- 12345.68
       TRUNC(12345.678, 2)   AS 버림,     -- 12345.67  (MySQL 은 TRUNCATE)
       CEIL(12345.1)         AS 올림,     -- 12346
       FLOOR(12345.9)        AS 내림,     -- 12345
       ABS(-100)             AS 절댓값,
       MOD(10, 3)            AS 나머지,   -- 1
       POWER(2, 10)          AS 거듭제곱  -- 1024
FROM   dual;   -- Oracle 만 FROM dual, 나머지는 FROM 절 생략 가능
정수끼리 나누면 잘립니다. PostgreSQL·Oracle에서 7 / 2 는 상황에 따라 3이 됩니다. 소수가 필요하면 7.0 / 2 또는 CAST(7 AS NUMERIC) / 2 처럼 명시하세요. 비율 계산이 0으로 나오는 버그의 단골 원인입니다.
SECTION 15

CASE · COALESCE

SQL 안에서 조건 분기를 하는 방법입니다. 엑셀의 IF 함수와 같은 역할입니다.

-- 조건형 CASE (가장 많이 씁니다)
SELECT name, price,
       CASE WHEN price >= 200000 THEN '고가'
            WHEN price >=  50000 THEN '중가'
            ELSE                      '저가'
       END AS 가격대
FROM   product;

-- 값 비교형 CASE (짧게 쓸 때)
SELECT name,
       CASE grade
            WHEN 'GOLD'   THEN '10% 할인'
            WHEN 'SILVER' THEN '5% 할인'
            ELSE               '할인 없음'
       END AS 혜택
FROM   member;

CASE + 집계 = 조건부 집계 (아주 유용합니다)

-- 한 번의 스캔으로 여러 조건의 개수를 동시에 셉니다
SELECT COUNT(*)                                            AS 전체주문,
       SUM(CASE WHEN status = 'PAID'     THEN 1 ELSE 0 END) AS 결제완료,
       SUM(CASE WHEN status = 'SHIPPED'  THEN 1 ELSE 0 END) AS 배송중,
       SUM(CASE WHEN status = 'CANCELED' THEN 1 ELSE 0 END) AS 취소
FROM   orders;

-- 표준 SQL 의 FILTER 절 (PostgreSQL·SQLite 지원 · 더 읽기 좋습니다)
SELECT COUNT(*)                             AS 전체주문,
       COUNT(*) FILTER (WHERE status='PAID') AS 결제완료,
       COUNT(*) FILTER (WHERE status='CANCELED') AS 취소
FROM   orders;
전체주문 결제완료 배송중 취소 6 3 2 1

NULL 처리 함수

함수하는 일지원
COALESCE(a,b,c)NULL이 아닌 첫 값을 돌려줌표준 · 전 DB
NULLIF(a,b)a와 b가 같으면 NULL, 아니면 a표준 · 전 DB
NVL(a,b)COALESCE의 2인자 버전Oracle
NVL2(a,b,c)a가 NULL이 아니면 b, NULL이면 cOracle
IFNULL(a,b)NVL과 같음MySQL · SQLite
-- 0으로 나누기 방지 — NULLIF 의 대표 활용
SELECT name,
       ROUND(sold * 100.0 / NULLIF(stock + sold, 0), 1) AS 판매율
FROM   product;
-- 분모가 0이면 NULL 이 되어 에러 대신 빈 값이 나옵니다
SECTION 16

VIEW · 임시 테이블

복잡한 SELECT에 이름을 붙여 두고 테이블처럼 쓰는 것이 뷰입니다.

CREATE VIEW v_member_sales AS
SELECT m.id, m.name, m.grade,
       COUNT(DISTINCT o.id)        AS 주문수,
       COALESCE(SUM(oi.qty * oi.unit_price), 0) AS 총구매액
FROM   member m
LEFT JOIN orders o      ON o.member_id = m.id AND o.status <> 'CANCELED'
LEFT JOIN order_item oi ON oi.order_id = o.id
GROUP  BY m.id, m.name, m.grade;

-- 이제 테이블처럼 씁니다
SELECT * FROM v_member_sales WHERE 총구매액 > 300000;

DROP VIEW v_member_sales;

뷰를 쓰는 세 가지 이유

  • 복잡함 감추기 — 다섯 개 테이블 조인을 팀원이 매번 쓰지 않아도 됩니다
  • 권한 통제 — 원본 테이블 접근은 막고, 필요한 컬럼만 담은 뷰만 열어 줍니다 (급여 테이블에서 주민번호를 뺀 뷰 등)
  • 호환 유지 — 테이블 구조를 바꿔도 뷰가 옛 모양을 유지해 주면 앱을 안 고쳐도 됩니다
뷰는 저장된 쿼리일 뿐 데이터를 갖고 있지 않습니다. 뷰를 조회할 때마다 안의 SELECT가 매번 실행됩니다. 무거운 뷰를 또 조인하면 성능이 급격히 나빠집니다. 뷰 위에 뷰를 얹는 구조는 특히 위험합니다.

구체화 뷰 (Materialized View) — 결과를 저장해 두는 뷰

-- PostgreSQL · Oracle 지원 (MySQL·SQLite 는 없음 → 집계 테이블로 직접 구현)
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT ordered_at, SUM(oi.qty * oi.unit_price) AS 매출
FROM   orders o JOIN order_item oi ON oi.order_id = o.id
GROUP  BY ordered_at;

-- 데이터는 갱신해 줘야 최신이 됩니다
REFRESH MATERIALIZED VIEW mv_daily_sales;                    -- PostgreSQL
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;       -- 잠금 없이 (유니크 인덱스 필요)

임시 테이블

-- 세션이 끝나면 자동으로 사라집니다
CREATE TEMPORARY TABLE tmp_target AS
SELECT id FROM member WHERE grade = 'GOLD';       -- PostgreSQL · MySQL · SQLite

-- Oracle 은 구조를 미리 만들고 데이터만 세션별로 격리합니다
CREATE GLOBAL TEMPORARY TABLE tmp_target (id NUMBER)
ON COMMIT PRESERVE ROWS;
언제 무엇을 쓸까 — 한 쿼리 안에서만 쓸 중간 결과는 CTE(WITH), 여러 쿼리에서 반복해 쓸 정의는 뷰, 배치 처리 중간 산출물은 임시 테이블, 매번 계산하기엔 무거운 집계는 구체화 뷰 또는 집계 테이블입니다.
SECTION 17

트랜잭션 입문

여러 변경을 전부 성공하거나 전부 취소로 묶는 장치입니다. 계좌 이체가 "출금만 되고 입금은 안 되는" 상태를 막습니다.

한 트랜잭션 안의 변경은 전부 반영되거나 전부 취소되거나 둘 중 하나다 BEGIN A 계좌 −10,000 아직 남에게 안 보임 B 계좌 +10,000 아직 남에게 안 보임 COMMIT 둘 다 확정 · 모두에게 보임 ROLLBACK 둘 다 없던 일 · 잔액 그대로 두 번째 UPDATE 직전에 서버가 꺼져도 결과는 오른쪽 둘 중 하나입니다 — "출금만 된 상태"는 만들어지지 않습니다
트랜잭션이 지켜 주는 것은 속도가 아니라 중간 상태가 밖으로 새지 않는다는 약속입니다.
BEGIN;                                   -- 트랜잭션 시작 (Oracle 은 자동 시작)

UPDATE account SET balance = balance - 10000 WHERE id = 1;   -- 출금
UPDATE account SET balance = balance + 10000 WHERE id = 2;   -- 입금

COMMIT;                                  -- 둘 다 확정
-- 문제가 있으면 ROLLBACK;  → 둘 다 없던 일로
DB트랜잭션 시작기본 자동 커밋
PostgreSQLBEGIN;켜짐 (문장 하나가 곧 트랜잭션)
MySQLSTART TRANSACTION; 또는 BEGIN;켜짐
OracleDML 을 치면 자동 시작꺼짐 — COMMIT 을 꼭 해야 합니다
SQLiteBEGIN;켜짐
Oracle 은 자동 커밋이 꺼져 있습니다. UPDATE 를 치고 창을 닫으면 변경이 사라집니다(롤백). 반대로 커밋하지 않은 채 두면 그 행에 락이 걸린 상태로 남아 다른 사람의 작업을 막습니다. 작업 후 COMMIT; 을 습관으로 만드세요.

SAVEPOINT — 부분 롤백

BEGIN;
INSERT INTO orders VALUES (2001, 1, '2026-08-05', 'PAID');

SAVEPOINT sp1;
INSERT INTO order_item VALUES (99, 2001, 999, 1, 1000);   -- 없는 상품 → 실패
ROLLBACK TO SAVEPOINT sp1;                                 -- 여기까지만 취소

INSERT INTO order_item VALUES (99, 2001, 10, 1, 49000);   -- 올바른 값으로 재시도
COMMIT;                                                    -- 주문 자체는 살아 있습니다

트랜잭션을 다룰 때의 세 가지 원칙

  • 짧게 유지하세요. 트랜잭션 안에서 외부 API를 호출하거나 사용자 입력을 기다리면 그동안 락이 유지되어 다른 작업이 줄줄이 막힙니다.
  • 순서를 통일하세요. A→B 순서로 잠그는 코드와 B→A 순서로 잠그는 코드가 만나면 데드락입니다(⚙️ t12).
  • 실패를 가정하세요. 커밋은 실패할 수 있습니다. 앱 코드에서 예외 처리와 재시도를 반드시 넣으세요(🔌 a11).
격리 수준·락·데드락 같은 동시성의 본론은 ⚙️ 설계 · 튜닝 탭 t10~t12 에서 다룹니다. 지금은 "COMMIT 전에는 확정이 아니다"만 확실히 해 두세요.
SECTION 18

SQL 실행 순서

SQL은 적은 순서대로 실행되지 않습니다. 이 순서를 알면 "WHERE에서 별칭을 왜 못 쓰지?" 같은 의문이 한 번에 풀립니다.

단계를 눌러 보세요
-- 우리가 쓰는 순서
SELECT   category, COUNT(*) AS 개수
FROM     product
WHERE    price >= 20000
GROUP BY category
HAVING   COUNT(*) >= 2
ORDER BY 개수 DESC
LIMIT    5;

-- DB 가 실행하는 순서
-- ① FROM     product          표를 읽고
-- ② WHERE    price >= 20000   행을 거르고
-- ③ GROUP BY category         묶고
-- ④ HAVING   COUNT(*) >= 2    그룹을 거르고
-- ⑤ SELECT   category, COUNT(*) AS 개수   ← 별칭이 여기서 생김
-- ⑥ ORDER BY 개수 DESC        정렬하고
-- ⑦ LIMIT    5                자른다

이 순서로 설명되는 것들

의문답
WHERE 에서 별칭을 못 쓰는 이유 별칭은 ⑤ SELECT 에서 생기는데 WHERE 는 ②라 아직 존재하지 않습니다
ORDER BY 에서는 별칭이 되는 이유 ⑥은 ⑤ 다음이라 이미 만들어져 있습니다
WHERE 에 집계 함수를 못 쓰는 이유 ②는 ③ GROUP BY 이전이라 그룹이 아직 없습니다
OFFSET 이 커도 안 빨라지는 이유 ⑦은 마지막이라 앞의 정렬·읽기를 전부 마친 뒤에야 자릅니다(⚙️ t09)
조인 조건을 WHERE 로 옮기면 결과가 바뀌는 이유 ①에서 붙일 때와 ②에서 거를 때의 시점이 다릅니다(q09 참고)
주의 — 이것은 논리적 순서입니다. 실제로 DB는 옵티마이저가 결과가 같음을 보장하는 선에서 순서를 마음대로 바꿉니다 (조건을 조인보다 먼저 밀어 넣는 등). 결과 해석은 논리 순서로, 성능 분석은 실행계획으로 하세요 → ⚙️ t07.
적는 순서와 도는 순서가 다릅니다 — 대부분의 오해가 여기서 시작합니다 적는 순서 SELECT FROM · JOIN WHERE GROUP BY HAVING ORDER BY LIMIT 실제 실행 순서 ① FROM · JOIN — 읽을 행을 모읍니다 ② WHERE — 행을 거릅니다 ③ GROUP BY — 묶습니다 ④ HAVING — 묶음을 거릅니다 ⑤ SELECT — 열을 고릅니다 ⑥ ORDER BY — 정렬 ⑦ LIMIT — 잘라 냅니다 그래서 이런 일이 SELECT price*qty AS total … WHERE total > 1000 ← 에러 별칭은 ⑤에서 생기는데 ②가 먼저입니다 반대로 이것은 됩니다 ORDER BY total — ⑥은 ⑤ 뒤라서 별칭을 알고 있습니다 WHERE 는 행을, HAVING 은 묶음을 거릅니다 — 순서가 다릅니다
이 일곱 단계를 외워 두면 "왜 여기서는 안 되지"의 절반이 사라집니다.
적는 순서와 도는 순서가 다릅니다 — 대부분의 오해가 여기서 시작합니다 적는 순서 SELECT FROM · JOIN WHERE GROUP BY HAVING ORDER BY LIMIT 실제 실행 순서 ① FROM · JOIN — 읽을 행을 모읍니다 ② WHERE — 행을 거릅니다 ③ GROUP BY — 묶습니다 ④ HAVING — 묶음을 거릅니다 ⑤ SELECT — 열을 고릅니다 ⑥ ORDER BY — 정렬 ⑦ LIMIT — 잘라 냅니다 그래서 이런 일이 SELECT price*qty AS total … WHERE total > 1000 ← 에러 별칭은 ⑤에서 생기는데 ②가 먼저입니다 반대로 이것은 됩니다 ORDER BY total — ⑥은 ⑤ 뒤라서 별칭을 알고 있습니다 WHERE 는 행을, HAVING 은 묶음을 거릅니다 — 순서가 다릅니다
이 일곱 단계를 외워 두면 "왜 여기서는 안 되지"의 절반이 사라집니다.
SECTION 19

기초 치트시트

막힐 때 펼쳐 보세요. 이 탭에서 다룬 문법을 한 장으로 모았습니다.

-- ── 조회 ────────────────────────────────────────────
SELECT DISTINCT col1, col2 AS 별칭
FROM   table1 t1
JOIN   table2 t2 ON t2.fk = t1.id        -- INNER / LEFT / RIGHT / FULL / CROSS
WHERE  col1 = 'x'                        -- = <> > < >= <=
  AND  col2 BETWEEN 1 AND 10             --   IN · NOT IN · LIKE · IS NULL
GROUP  BY col1                           -- 묶기
HAVING COUNT(*) > 1                      -- 그룹 조건
ORDER  BY col1 DESC, id                  -- 정렬 (유일 컬럼을 마지막에)
LIMIT  10 OFFSET 20;                     -- Oracle: FETCH FIRST 10 ROWS ONLY

-- ── 변경 ────────────────────────────────────────────
INSERT INTO t (a,b) VALUES (1,'x'), (2,'y');
INSERT INTO t (a,b) SELECT a,b FROM src WHERE ...;
UPDATE t SET a = a + 1 WHERE id = 1;     -- ★ WHERE 필수
DELETE FROM t WHERE id = 1;              -- ★ WHERE 필수
TRUNCATE TABLE t;                        -- 전부 비우기 (되돌리기 어려움)

-- ── 구조 ────────────────────────────────────────────
CREATE TABLE t (
  id    INTEGER PRIMARY KEY,
  name  VARCHAR(50) NOT NULL,
  email VARCHAR(120) UNIQUE,
  amt   DECIMAL(12,2) NOT NULL DEFAULT 0 CHECK (amt >= 0),
  fk_id INTEGER REFERENCES other(id)
);
ALTER TABLE t ADD COLUMN c VARCHAR(10);
DROP TABLE t;
CREATE INDEX idx_t_name ON t(name);

-- ── 트랜잭션 ─────────────────────────────────────────
BEGIN;  ...  COMMIT;   /  ROLLBACK;   /  SAVEPOINT sp1;

-- ── 자주 쓰는 함수 ───────────────────────────────────
COALESCE(a, b)     NULL 이면 b
NULLIF(a, b)       같으면 NULL (0으로 나누기 방지)
CASE WHEN … THEN … ELSE … END
COUNT · SUM · AVG · MIN · MAX
UPPER · LOWER · TRIM · REPLACE · SUBSTR · LENGTH

헷갈릴 때 보는 판정표

이럴 때는이렇게
두 표를 옆으로 붙이고 싶다JOIN — 없는 쪽도 남기려면 LEFT JOIN
두 결과를 위아래로 붙이고 싶다UNION ALL (중복 제거가 필요하면 UNION)
그룹별 합계·개수가 필요하다GROUP BY + 집계 함수
그룹은 유지하면서 순위를 매기고 싶다윈도우 함수 OVER (PARTITION BY …) → 🎓 e01
없으면 넣고 있으면 수정하고 싶다UPSERT → 🎓 e08
빈 값을 다른 값으로 보이게 하고 싶다COALESCE(col, '대체값')
조건에 따라 다른 값을 보여주고 싶다CASE WHEN … END
쿼리가 너무 길고 안 읽힌다WITH (CTE) 로 문단 나누기 → 🎓 e04
쿼리가 느리다실행계획부터 → ⚙️ t07 · 인덱스 → ⚙️ t04~t06
여기까지 익혔다면 실무 데이터 요청의 70%는 직접 처리할 수 있습니다. 다음은 🎓 SQL 전문가 탭에서 윈도우 함수·CTE·UPSERT를 익히거나, ⚙️ 설계 · 튜닝 탭에서 인덱스와 실행계획으로 넘어가세요.
SQL EXPERT · 윈도우 함수 · CTE · UPSERT · 집합 사고

SQL 전문가 —
반복문을
지우는 문법

기초 문법으로도 대부분의 조회는 됩니다. 여기서부터는 애플리케이션 코드로 하던 일을 SQL로 옮기는 영역입니다. 순위·누적·계층·피벗을 쿼리 한 줄로 처리하면 수백만 번 오가던 왕복이 한 번이 됩니다. 대부분의 문법은 네 DB에 모두 있습니다.

15SECTIONS
4DB 대조
실무리포트·정산
SECTION 01

윈도우 함수 — 개념과 문법

GROUP BY는 행을 합쳐 없앱니다. 윈도우 함수는 행을 그대로 두고 옆에 계산 결과를 붙입니다. 이 차이 하나가 리포트 쿼리의 난이도를 완전히 바꿉니다.

원본 — 5행 영업 · 300 영업 · 250 개발 · 400 개발 · 350 개발 · 320 GROUP BY 부서 — 행이 접힌다 영업 · 550 개발 · 1070 2행만 남는다 — 누가 얼마 받는지는 더 이상 볼 수 없다 SUM(급여) OVER (PARTITION BY 부서) — 행은 그대로, 열이 하나 붙는다 영업 · 300550 영업 · 250550 개발 · 4001070 개발 · 3501070 개발 · 3201070 5행 그대로라서 "내 급여 ÷ 부서 합" 같은 비율 계산이 한 줄로 된다
고르는 기준은 하나입니다 — 결과에 개별 행이 남아야 하면 윈도우 함수, 요약만 필요하면 GROUP BY.
GROUP BY — 행이 사라집니다
SELECT category, AVG(price)
FROM   product
GROUP  BY category;

-- 카테고리별 1행씩만 남습니다
-- 개별 상품 이름은 볼 수 없습니다
OVER — 행이 그대로 남습니다
SELECT name, category, price,
       AVG(price) OVER (PARTITION BY category)
         AS 카테고리평균
FROM   product;

-- 상품 5개가 그대로 나오면서
-- 각 행에 자기 카테고리 평균이 붙습니다
name category price 카테고리평균 무선 키보드 주변기기 49000 36000 게이밍 마우스 주변기기 32000 36000 USB-C 허브 주변기기 27000 36000 27인치 모니터 디스플레이 289000 289000 노트북 스탠드 액세서리 18000 18000

문법 구조

함수() OVER (
    PARTITION BY 그룹기준     -- 생략하면 전체가 한 덩어리
    ORDER BY 정렬기준         -- 순위·누적에 필요
    ROWS BETWEEN … AND …      -- 계산 범위(프레임) · e03 에서 자세히
)
부분뜻없으면
PARTITION BY어떤 단위로 나눠 계산할지전체를 하나의 그룹으로 봅니다
ORDER BY그룹 안의 순서순위·LAG·누적을 쓸 수 없습니다
ROWS/RANGE몇 번째 행까지 계산에 넣을지ORDER BY가 있으면 처음~현재행이 기본(누적)

쓸 수 있는 함수

  • 집계형 — SUM · AVG · COUNT · MIN · MAX 를 그대로 OVER와 함께 씁니다
  • 순위형 — ROW_NUMBER · RANK · DENSE_RANK · NTILE (e02)
  • 이동형 — LAG · LEAD · FIRST_VALUE · LAST_VALUE · NTH_VALUE (e03)
  • 분포형 — PERCENT_RANK · CUME_DIST · PERCENTILE_CONT
지원 버전 — MySQL 8.0+ · MariaDB 10.2+ · SQLite 3.25+ · PostgreSQL·Oracle은 오래전부터 지원합니다. MySQL 5.7 이하를 쓰는 레거시라면 이 탭의 많은 문법이 동작하지 않습니다 — 변수(@rownum) 트릭 대신 업그레이드를 검토하는 편이 낫습니다.
실무에서 가장 자주 쓰는 형태 세 가지
① 그룹 안 순위 — ROW_NUMBER() OVER (PARTITION BY x ORDER BY y DESC)
② 전체 대비 비중 — amount * 100.0 / SUM(amount) OVER ()
③ 직전 값과 비교 — LAG(amount) OVER (ORDER BY month)
SECTION 02

순위 RANK 3형제

셋 다 등수를 매기지만 동점을 처리하는 방식이 다릅니다. 이것만 구분하면 됩니다.

-- 동점을 보여주기 위해 '마우스패드 XL'(49000)을 하나 추가했다고 가정합니다
SELECT name, price,
       ROW_NUMBER() OVER (ORDER BY price DESC) AS rn,
       RANK()       OVER (ORDER BY price DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY price DESC) AS drnk
FROM   product;
namepriceROW_NUMBERRANKDENSE_RANK
27인치 모니터289000111
무선 키보드49000222
마우스패드 XL49000322
게이밍 마우스32000443
함수동점일 때쓰는 곳
ROW_NUMBER()무조건 1,2,3,4 — 동점도 임의로 갈라놓습니다페이징, 그룹별 대표 행 1건 뽑기
RANK()동점은 같은 등수, 다음은 건너뜁니다 (1,2,2,4)올림픽식 순위, 성적 등수
DENSE_RANK()동점은 같은 등수, 다음은 바로 다음 (1,2,2,3)"상위 3개 가격대" 처럼 등급이 필요할 때

가장 많이 쓰는 패턴 — 그룹별 상위 N

-- 카테고리별 최고가 상품 1개씩
SELECT category, name, price
FROM  (SELECT category, name, price,
              ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
       FROM   product) t
WHERE  rn = 1;

-- 회원별 최근 주문 3건
SELECT member_id, id AS order_no, ordered_at
FROM  (SELECT member_id, id, ordered_at,
              ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY ordered_at DESC) AS rn
       FROM   orders) t
WHERE  rn <= 3;
WHERE 절에 윈도우 함수를 직접 쓸 수 없습니다. WHERE ROW_NUMBER() OVER (…) = 1 은 에러입니다 — 윈도우 함수는 WHERE보다 나중에 계산되기 때문입니다(q18). 서브쿼리나 CTE로 한 번 감싼 뒤 바깥에서 거르세요. (PostgreSQL의 QUALIFY 는 지원하지 않습니다 — Snowflake·BigQuery 문법입니다)

NTILE — 몇 등분으로 나누기

-- 구매액 기준 4등분해 등급 매기기
SELECT name, 총구매액,
       NTILE(4) OVER (ORDER BY 총구매액 DESC) AS 분위
FROM   v_member_sales;
-- 분위 1 = 상위 25%
순위 3형제 — 동점을 만났을 때 갈립니다 점수 ROW_NUMBER RANK DENSE_RANK 98 1 1 1 95 (동점) 2 2 2 95 (동점) 3 ← 다른 번호 2 ← 같은 번호 2 ← 같은 번호 90 4 4 ← 3을 건너뜀 3 ← 안 건너뜀 상위 N 명을 정확히 잘라야 하면 ROW_NUMBER — 개수가 정확히 맞습니다 공동 순위를 인정해야 하면 RANK — 시상식·경기 순위가 이 방식입니다 등급을 나눌 때는 DENSE_RANK — 번호가 비지 않아 등급 수를 셀 수 있습니다 셋 다 OVER (PARTITION BY 그룹 ORDER BY 정렬기준) 형태로 씁니다
"상위 3명" 이 4명 나왔다면 RANK 를 쓴 것입니다.
SECTION 03

LAG · LEAD · 누적합 프레임

전월 대비 증감, 누적 매출, 이동평균 — 시계열 리포트의 핵심이 전부 여기서 나옵니다.

LAG · LEAD — 앞뒤 행의 값 가져오기

SELECT 월, 매출,
       LAG(매출)  OVER (ORDER BY 월) AS 전월매출,
       매출 - LAG(매출) OVER (ORDER BY 월) AS 증감,
       ROUND( (매출 - LAG(매출) OVER (ORDER BY 월)) * 100.0
              / NULLIF(LAG(매출) OVER (ORDER BY 월), 0), 1) AS 증감률
FROM   monthly_sales
ORDER  BY 월;
월 매출 전월매출 증감 증감률 2026-01 1200000 (NULL) (NULL) (NULL) 2026-02 1500000 1200000 300000 25.0 2026-03 1350000 1500000 -150000 -10.0
-- 기본값을 주면 첫 행의 NULL 을 없앨 수 있습니다
LAG(매출, 1, 0) OVER (ORDER BY 월)     -- 1칸 앞, 없으면 0
LEAD(매출, 1, 0) OVER (ORDER BY 월)    -- 1칸 뒤

프레임 — 어디부터 어디까지 계산할지

프레임뜻
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 누적합 — 처음부터 현재 행까지 (ORDER BY 가 있으면 기본값)
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 7일 이동평균 — 앞 6행 + 현재 행
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING 현재부터 끝까지 (잔여 합계)
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 전체 — PARTITION BY 만 쓴 것과 같습니다
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — 초록 = 프레임 · 붉은 채움 = 현재 행 현재 행 = 5일 1일 120 2일 135 3일 130 4일 125 5일 140 6일 150 7일 145 이동평균(5일) = (130 + 125 + 140) / 3 = 131.7 현재 행 = 6일 1일 120 2일 135 3일 130 4일 125 5일 140 6일 150 7일 145 이동평균(6일) = (125 + 140 + 150) / 3 = 135.0 — 3일이 빠지고 6일이 들어왔습니다
프레임은 행을 따라 미끄러지는 창입니다. 창 밖의 행은 그 행의 계산에 전혀 끼지 않습니다.
-- 누적 매출
SELECT 일자, 매출,
       SUM(매출) OVER (ORDER BY 일자
                       ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS 누적매출
FROM   daily_sales;

-- 7일 이동평균 (그래프에서 노이즈를 걷어낼 때)
SELECT 일자, 매출,
       ROUND(AVG(매출) OVER (ORDER BY 일자
                             ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 0) AS 이동평균7일
FROM   daily_sales;

-- 전체 대비 비중
SELECT category, 매출,
       ROUND(매출 * 100.0 / SUM(매출) OVER (), 1) AS 비중
FROM   category_sales;
ROWS 와 RANGE 는 다릅니다. ROWS 는 물리적 행 개수로, RANGE 는 값이 같은 행을 한 덩어리로 셉니다. 같은 날짜가 여러 행 있을 때 RANGE 는 그 전부를 포함하므로 결과가 달라집니다. 헷갈리면 ROWS 를 쓰세요 — 의도가 명확합니다.
FIRST_VALUE 의 함정 — LAST_VALUE(x) OVER (ORDER BY y) 는 기본 프레임이 "처음~현재"라 마지막 값이 아니라 현재 값이 나옵니다. 진짜 마지막을 원하면 프레임을 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 으로 명시하세요.
SECTION 04

CTE — 쿼리를 문단으로

WITH 는 쿼리에 이름 붙인 문단을 만듭니다. 3중으로 중첩된 서브쿼리를 위에서 아래로 읽히게 펴 줍니다.

✗ 중첩 서브쿼리 — 안쪽부터 읽어야 함
SELECT * FROM (
  SELECT t.*, RANK() OVER (ORDER BY 구매액 DESC) rk
  FROM (
    SELECT m.id, m.name,
           SUM(oi.qty*oi.unit_price) 구매액
    FROM member m
    JOIN orders o ON o.member_id=m.id
    JOIN order_item oi ON oi.order_id=o.id
    GROUP BY m.id, m.name
  ) t
) x
WHERE x.rk <= 3;
✓ CTE — 위에서 아래로 읽힘
WITH 구매액 AS (
  SELECT m.id, m.name,
         SUM(oi.qty*oi.unit_price) AS 금액
  FROM   member m
  JOIN   orders o      ON o.member_id = m.id
  JOIN   order_item oi ON oi.order_id = o.id
  GROUP  BY m.id, m.name
),
순위 AS (
  SELECT *, RANK() OVER (ORDER BY 금액 DESC) AS rk
  FROM   구매액
)
SELECT * FROM 순위 WHERE rk <= 3;

CTE 를 여러 개 쓰고 서로 참조하기

WITH 유효주문 AS (
  SELECT * FROM orders WHERE status <> 'CANCELED'
),
회원별 AS (
  SELECT member_id, COUNT(*) AS 건수
  FROM   유효주문                      -- ← 앞의 CTE 를 그대로 참조
  GROUP  BY member_id
),
평균 AS (
  SELECT AVG(건수) AS 평균건수 FROM 회원별
)
SELECT m.name, r.건수, a.평균건수
FROM   회원별 r
JOIN   member m ON m.id = r.member_id
CROSS  JOIN 평균 a
WHERE  r.건수 > a.평균건수;

CTE 로 데이터를 바꾸기 (PostgreSQL)

-- 지운 행을 그대로 아카이브 테이블로 옮기기 — 한 트랜잭션에서
WITH deleted AS (
  DELETE FROM orders
  WHERE  ordered_at < '2024-01-01'
  RETURNING *
)
INSERT INTO orders_archive SELECT * FROM deleted;
이 문법은 PostgreSQL 전용입니다(WITH 안의 DML + RETURNING). Oracle·MySQL·SQLite 에서는 두 문장으로 나눠 트랜잭션으로 묶으세요.
성능 주의 — CTE 가 항상 공짜는 아닙니다. PostgreSQL은 12 버전부터 CTE를 인라인으로 펼쳐 최적화합니다(그 전에는 항상 별도 실행). WITH x AS MATERIALIZED (…) 로 강제 저장을, NOT MATERIALIZED 로 인라인을 지정할 수 있습니다. 같은 CTE를 여러 번 참조하면 DB에 따라 그만큼 반복 실행될 수 있으니 실행계획으로 확인하세요.
같은 쿼리, 읽는 방향이 달라집니다 ① 중첩 서브쿼리 — 안에서 밖으로 SELECT … FROM ( SELECT … FROM ( SELECT … FROM orders WHERE … ← 여기부터 읽어야 합니다 ) t1 … ) t2 ② CTE — 위에서 아래로 WITH 최근주문 AS ( SELECT … FROM orders WHERE … ), 고객별합계 AS ( SELECT … FROM 최근주문 GROUP BY … ) SELECT * FROM 고객별합계 WHERE 합계 > 100000 이름이 곧 설명이 됩니다 '최근주문' 이라는 이름 하나가 주석 한 줄을 대신합니다 성능은 DB 마다 다릅니다 최적화 경계로 굳는 DB 가 있습니다 — 실행계획으로 확인하세요
복잡한 쿼리를 문단으로 나눠 적는 도구입니다 — 재귀는 e05 에서 이어집니다.
같은 쿼리, 읽는 방향이 달라집니다 ① 중첩 서브쿼리 — 안에서 밖으로 SELECT … FROM ( SELECT … FROM ( SELECT … FROM orders WHERE … ← 여기부터 읽어야 합니다 ) t1 … ) t2 ② CTE — 위에서 아래로 WITH 최근주문 AS ( SELECT … FROM orders WHERE … ), 고객별합계 AS ( SELECT … FROM 최근주문 GROUP BY … ) SELECT * FROM 고객별합계 WHERE 합계 > 100000 이름이 곧 설명이 됩니다 '최근주문' 이라는 이름 하나가 주석 한 줄을 대신합니다 성능은 DB 마다 다릅니다 최적화 경계로 굳는 DB 가 있습니다 — 느려지면 실행계획으로 확인하세요
복잡한 쿼리를 문단으로 나눠 적는 도구입니다 — 재귀는 e05 에서 이어집니다.
SECTION 05

재귀 CTE — 계층 탐색

부모 → 자식 → 손자를 끝까지 따라가는 문법입니다. 조직도·카테고리 트리·댓글 스레드·부품 전개(BOM)가 전부 이 패턴입니다.

회차마다 무엇이 나오나 ① 앵커 — 부모가 없는 행 UNION ALL ② 직전 결과의 자식 — 2행 ③ 또 그 자식 — 3행 ④ 자식이 없다 → 새 행 0개 → 여기서 멈춘다 같은 일을 조직도로 보면 — 한 회차에 한 층씩 내려간다 대표이사 영업본부장 개발이사 국내팀장 해외팀장 서버팀장 깊이를 몰라도 끝까지 갑니다 — 순환 데이터엔 depth 제한을 거세요
재귀 CTE 는 층수를 미리 몰라도 됩니다 — 더 나올 자식이 없을 때 스스로 멈춥니다.
-- 예제 데이터
CREATE TABLE category (
  id        INTEGER PRIMARY KEY,
  name      VARCHAR(50),
  parent_id INTEGER REFERENCES category(id)
);
INSERT INTO category VALUES
 (1,'전체',NULL), (2,'전자제품',1), (3,'주변기기',2),
 (4,'키보드',3),  (5,'마우스',3),   (6,'의류',1);
WITH RECURSIVE tree AS (
    -- ① 시작점 (앵커) — 최상위부터
    SELECT id, name, parent_id, 1 AS depth,
           CAST(name AS VARCHAR(200)) AS path
    FROM   category
    WHERE  parent_id IS NULL

    UNION ALL

    -- ② 반복 — 이전 결과의 자식을 계속 붙인다
    SELECT c.id, c.name, c.parent_id, t.depth + 1,
           CAST(t.path || ' > ' || c.name AS VARCHAR(200))
    FROM   category c
    JOIN   tree t ON t.id = c.parent_id
)
SELECT depth, path FROM tree ORDER BY path;
depth path 1 전체 2 전체 > 의류 2 전체 > 전자제품 3 전체 > 전자제품 > 주변기기 4 전체 > 전자제품 > 주변기기 > 마우스 4 전체 > 전자제품 > 주변기기 > 키보드

구조는 항상 같습니다

①
앵커 — 출발점재귀 없이 한 번만 실행됩니다. "최상위" 또는 "내가 찾는 그 노드".
▼
UNION ALL
둘을 잇습니다UNION(중복 제거)을 쓰면 느려지고, 순환 데이터에서 결과가 달라질 수 있습니다.
▼
②
재귀 — 이전 결과를 참조CTE 이름(tree)을 자기 안에서 조인합니다. 더 붙일 게 없으면 자동으로 멈춥니다.

역방향 — 특정 노드의 조상 찾기

WITH RECURSIVE up AS (
    SELECT id, name, parent_id FROM category WHERE id = 4      -- '키보드'에서 출발
    UNION ALL
    SELECT c.id, c.name, c.parent_id
    FROM   category c
    JOIN   up u ON c.id = u.parent_id                          -- 부모를 따라 올라간다
)
SELECT * FROM up;
-- 키보드 → 주변기기 → 전자제품 → 전체
무한 루프를 조심하세요. 데이터에 순환(A의 부모가 B, B의 부모가 A)이 있으면 영원히 돕니다.
· 깊이 제한을 걸어 두세요: WHERE t.depth < 20
· PostgreSQL 14+ 는 CYCLE id SET is_cycle USING path 로 순환을 자동 감지합니다
· Oracle 은 CONNECT BY NOCYCLE (o09) 이 있습니다

DB별 지원

DB재귀 CTE비고
PostgreSQL✓ WITH RECURSIVE8.4부터. CYCLE·SEARCH 절은 14+
Oracle✓ WITH (RECURSIVE 키워드 없이)11gR2부터. CONNECT BY 도 병행 사용
MySQL / MariaDB✓ WITH RECURSIVEMySQL 8.0+ · MariaDB 10.2+
SQLite✓ WITH RECURSIVE3.8.3+ (사실상 모든 버전)
재귀가 느리다면 설계를 바꾸는 것도 방법입니다. 조회가 압도적으로 많은 트리라면 경로 문자열 저장(/1/2/3/), 클로저 테이블, PostgreSQL의 ltree 확장 같은 대안이 훨씬 빠릅니다.
SECTION 06

ROLLUP · CUBE · GROUPING SETS

소계와 총계를 한 번의 쿼리로 뽑습니다. UNION을 세 번 쓰던 보고서 쿼리가 한 줄이 됩니다.

-- ROLLUP : 계층적 소계 (지역 → 지역+등급 → 총계)
SELECT city, grade, COUNT(*) AS 인원
FROM   member
GROUP  BY ROLLUP (city, grade);
citygrade인원의미
서울BASIC1상세
서울GOLD1상세
서울(NULL)2서울 소계
부산BASIC1상세
부산SILVER1상세
부산(NULL)2부산 소계
대전GOLD1상세
대전(NULL)1대전 소계
(NULL)(NULL)5전체 총계
-- CUBE : 모든 조합 (지역별 · 등급별 · 지역+등급 · 총계)
SELECT city, grade, COUNT(*) FROM member GROUP BY CUBE (city, grade);

-- GROUPING SETS : 원하는 조합만 콕 집어서
SELECT city, grade, COUNT(*)
FROM   member
GROUP  BY GROUPING SETS ((city), (grade), ());   -- 지역별 · 등급별 · 총계

-- NULL 이 '소계'인지 '진짜 NULL'인지 구분하기
SELECT CASE WHEN GROUPING(city) = 1 THEN '전체' ELSE city END AS 지역,
       COUNT(*) AS 인원
FROM   member
GROUP  BY ROLLUP (city);
문법PostgreSQLOracleMySQLSQLite
ROLLUP✓✓✓ WITH ROLLUP✗
CUBE✓✓✗✗
GROUPING SETS✓✓✗✗
MySQL 의 문법은 다릅니다 — GROUP BY city, grade WITH ROLLUP 처럼 뒤에 붙입니다. CUBE·GROUPING SETS 가 없으므로 UNION ALL 로 조합해야 합니다. SQLite 는 셋 다 없으니 애플리케이션에서 소계를 계산하거나 UNION ALL 을 쓰세요.
SECTION 07

PIVOT — 행을 열로

세로로 긴 표를 가로로 펼칩니다. "월별 매출을 12개 컬럼으로" 같은 엑셀형 리포트에 씁니다.

어디서나 되는 방법 — CASE 집계

SELECT category,
       SUM(CASE WHEN 월 = '01' THEN 매출 ELSE 0 END) AS "1월",
       SUM(CASE WHEN 월 = '02' THEN 매출 ELSE 0 END) AS "2월",
       SUM(CASE WHEN 월 = '03' THEN 매출 ELSE 0 END) AS "3월",
       SUM(매출) AS 합계
FROM   monthly_category_sales
GROUP  BY category;
category 1월 2월 3월 합계 주변기기 320000 410000 380000 1110000 디스플레이 578000 289000 0 867000
이 방법이 실무 표준입니다. 네 DB 어디서나 동작하고, 이식성이 가장 좋습니다. 아래의 전용 문법은 편하지만 DB에 종속됩니다.

Oracle 전용 — PIVOT / UNPIVOT

SELECT * FROM (
    SELECT category, 월, 매출 FROM monthly_category_sales
)
PIVOT (
    SUM(매출) FOR 월 IN ('01' AS "1월", '02' AS "2월", '03' AS "3월")
);

-- 반대로 가로를 세로로
SELECT * FROM sales_wide
UNPIVOT (매출 FOR 월 IN ("1월", "2월", "3월"));

PostgreSQL — crosstab 확장

CREATE EXTENSION IF NOT EXISTS tablefunc;

SELECT * FROM crosstab(
  'SELECT category, 월, 매출 FROM monthly_category_sales ORDER BY 1,2',
  'SELECT DISTINCT 월 FROM monthly_category_sales ORDER BY 1'
) AS ct(category TEXT, m01 NUMERIC, m02 NUMERIC, m03 NUMERIC);
피벗의 근본적 한계 — SQL의 결과 컬럼은 실행 전에 개수가 정해져야 합니다. "데이터에 있는 월만큼 컬럼을 만들어 줘"는 순수 SQL로 불가능합니다. 동적 피벗이 필요하면 ① 애플리케이션에서 컬럼을 만들거나 ② SQL 문자열을 조립해 실행하거나(인젝션 주의) ③ 세로 형태로 내보내고 화면에서 피벗하세요 — ③이 가장 안전합니다.
쌓인 행을 옆으로 눕힙니다 — 보고서의 모양으로 원본 (세로) 의류 · 1월 · 100 의류 · 2월 · 150 식품 · 1월 · 200 식품 · 2월 · 90 피벗 결과 (가로) 분류 1월 2월 의류 100 150 식품 200 90 행은 분류마다 하나 열은 월마다 하나 어디서나 되는 방법 — 조건부 집계 SELECT 분류, SUM(CASE WHEN 월='1월' THEN 금액 END) AS "1월", SUM(CASE WHEN 월='2월' THEN 금액 END) AS "2월" FROM 매출 GROUP BY 분류 전용 문법도 있습니다 오라클 · SQL Server 는 PIVOT 절을 지원하고, PostgreSQL 은 crosstab 확장을 씁니다 열 이름을 미리 알아야 합니다 — 동적으로 늘어나는 피벗은 안 됩니다
열이 동적으로 늘어야 한다면 SQL 이 아니라 애플리케이션에서 돌리는 편이 낫습니다.
쌓인 행을 옆으로 눕힙니다 — 보고서의 모양으로 원본 (세로) 의류 · 1월 · 100 의류 · 2월 · 150 식품 · 1월 · 200 식품 · 2월 · 90 피벗 결과 (가로) 분류 1월 2월 의류 100 150 식품 200 90 행은 분류마다 하나 열은 월마다 하나 어디서나 되는 방법 — 조건부 집계 SELECT 분류, SUM(CASE WHEN 월='1월' THEN 금액 END) AS "1월", SUM(CASE WHEN 월='2월' THEN 금액 END) AS "2월" FROM 매출 GROUP BY 분류 전용 문법도 있습니다 오라클 · SQL Server 는 PIVOT 절을 지원하고, PostgreSQL 은 crosstab 확장을 씁니다 열 이름을 미리 알아야 합니다 — 값에 따라 열이 늘어나는 피벗은 SQL 로 한 번에 안 됩니다
열이 동적으로 늘어야 한다면 SQL 이 아니라 애플리케이션에서 돌리는 편이 낫습니다.
SECTION 08

UPSERT 4종 대조

"있으면 수정, 없으면 입력"을 한 문장으로 처리합니다. DB마다 문법이 완전히 다른 대표적인 영역입니다.

PostgreSQL · SQLite
INSERT INTO stock (product_id, qty)
VALUES (10, 5)
ON CONFLICT (product_id)
DO UPDATE SET qty = stock.qty + EXCLUDED.qty;

-- 있으면 무시하고 넘어가기
INSERT INTO stock (product_id, qty)
VALUES (10, 5)
ON CONFLICT DO NOTHING;
MySQL · MariaDB
INSERT INTO stock (product_id, qty)
VALUES (10, 5)
ON DUPLICATE KEY UPDATE
    qty = qty + VALUES(qty);

-- MySQL 8.0.20+ 권장 문법
INSERT INTO stock (product_id, qty)
VALUES (10, 5) AS new
ON DUPLICATE KEY UPDATE qty = qty + new.qty;
Oracle · 표준 SQL
MERGE INTO stock s
USING (SELECT 10 AS pid, 5 AS q FROM dual) x
   ON (s.product_id = x.pid)
WHEN MATCHED THEN
    UPDATE SET s.qty = s.qty + x.q
WHEN NOT MATCHED THEN
    INSERT (product_id, qty) VALUES (x.pid, x.q);
SQLite (구버전 대안)
-- 3.24 미만에서 쓰던 방식
INSERT OR REPLACE INTO stock (product_id, qty)
VALUES (10, 5);

-- ⚠️ REPLACE 는 기존 행을 DELETE 후 INSERT 라
-- 적지 않은 컬럼이 기본값으로 날아갑니다
INSERT OR REPLACE · REPLACE INTO 를 조심하세요. 이들은 "수정"이 아니라 삭제 후 재삽입입니다. ① 지정하지 않은 컬럼이 DEFAULT 값으로 초기화되고, ② 자동 증가 PK가 새 번호로 바뀌며, ③ ON DELETE CASCADE 가 걸린 자식 행이 같이 삭제될 수 있습니다. 반드시 ON CONFLICT DO UPDATE 를 쓰세요.

UPSERT 가 성립하려면 제약이 있어야 합니다

-- 이 UNIQUE 제약이 있어야 "충돌"이라는 판단이 가능합니다
ALTER TABLE stock ADD CONSTRAINT uq_stock_product UNIQUE (product_id);

-- 여러 컬럼 조합이 기준일 때
INSERT INTO daily_stat (dt, category, cnt)
VALUES (DATE '2026-08-05', '주변기기', 1)
ON CONFLICT (dt, category) DO UPDATE SET cnt = daily_stat.cnt + 1;

왜 UPSERT를 써야 하나 — 동시성

✗ 조회 후 분기 — 경합에서 깨집니다
# 두 요청이 동시에 오면
row = db.query("SELECT … WHERE product_id=10")
if row:
    db.execute("UPDATE …")
else:
    db.execute("INSERT …")   # ← 둘 다 여기로 와서
                             #    유니크 제약 위반 or 중복 행
✓ UPSERT — DB가 원자적으로 처리
db.execute("""
  INSERT INTO stock (product_id, qty) VALUES (%s, %s)
  ON CONFLICT (product_id)
  DO UPDATE SET qty = stock.qty + EXCLUDED.qty
""", (10, 5))
# 동시에 들어와도 한 쪽은 INSERT, 한 쪽은 UPDATE
MERGE 는 표준 SQL 문법입니다. Oracle·SQL Server·PostgreSQL 15+ 가 지원합니다. 다만 동시성 보장이 DB마다 다르므로(일부 구현에서 경합 시 오류 발생 가능) 단순한 UPSERT라면 각 DB의 전용 문법이 더 안전합니다.
SECTION 09

LATERAL · APPLY

각 행마다 딸린 조회를 따로 실행하는 조인입니다. "회원별 최근 주문 3건"처럼 행마다 다른 상위 N이 필요할 때 씁니다.

-- PostgreSQL · Oracle 12c+ : LATERAL
SELECT m.name, o.id AS order_no, o.ordered_at
FROM   member m
CROSS JOIN LATERAL (
    SELECT id, ordered_at
    FROM   orders
    WHERE  member_id = m.id           -- ← 바깥 m 을 참조할 수 있습니다
    ORDER  BY ordered_at DESC
    LIMIT  3                          -- Oracle 은 FETCH FIRST 3 ROWS ONLY
) o;

-- 주문이 없는 회원도 보고 싶다면
SELECT m.name, o.id, o.ordered_at
FROM   member m
LEFT JOIN LATERAL (
    SELECT id, ordered_at FROM orders
    WHERE  member_id = m.id ORDER BY ordered_at DESC LIMIT 3
) o ON TRUE;
핵심은 "바깥을 참조할 수 있다"는 점입니다. 보통 서브쿼리는 FROM 절에서 바깥 테이블의 컬럼을 볼 수 없지만, LATERAL 을 붙이면 왼쪽에서 이미 읽은 행의 값을 쓸 수 있습니다.

DB별 대응

DB문법
PostgreSQLCROSS JOIN LATERAL · LEFT JOIN LATERAL … ON TRUE (9.3+)
OracleCROSS APPLY · OUTER APPLY · LATERAL() (12c+)
MySQLLATERAL (8.0.14+) — MariaDB는 미지원
SQLite✗ — 윈도우 함수로 대체

윈도우 함수로도 됩니다 — 어느 쪽이 빠를까

-- 어디서나 되는 대안
SELECT name, order_no, ordered_at
FROM  (SELECT m.name, o.id AS order_no, o.ordered_at,
              ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY o.ordered_at DESC) AS rn
       FROM   member m JOIN orders o ON o.member_id = m.id) t
WHERE  rn <= 3;
선택 기준 — 윈도우 함수 방식은 모든 주문을 읽어 정렬한 뒤 3건만 남깁니다. LATERAL 방식은 회원마다 인덱스로 3건만 찍어 옵니다. 왼쪽 행 수가 적고 오른쪽이 매우 크면 LATERAL 이 압도적으로 빠릅니다. 반대로 왼쪽이 수만 건이면 왕복이 늘어 윈도우 함수가 나을 수 있습니다 — 실행계획으로 확인하세요.
SECTION 10

JSON을 SQL로 다루기

스키마가 자주 바뀌는 데이터(설정·로그·외부 API 응답)를 컬럼 하나에 통째로 저장하고 검색합니다.

PostgreSQL — JSONB (가장 강력)
-- 값 꺼내기
SELECT data->>'name'          AS name,      -- 텍스트로
       data->'tags'->>0       AS first_tag,
       (data->>'price')::int  AS price
FROM   item;

-- 포함 검색 (GIN 인덱스 사용 가능)
SELECT * FROM item WHERE data @> '{"brand":"LG"}';

-- 경로 존재 여부
SELECT * FROM item WHERE data ? 'discount';

CREATE INDEX idx_item_data ON item USING GIN (data);
MySQL / MariaDB
SELECT JSON_EXTRACT(data, '$.name')   AS name,
       data->>'$.name'                AS name2,
       JSON_UNQUOTE(data->'$.price')  AS price
FROM   item;

SELECT * FROM item
WHERE  JSON_CONTAINS(data, '"LG"', '$.brand');

-- 인덱스는 생성 컬럼을 통해서
ALTER TABLE item
  ADD COLUMN brand VARCHAR(50)
  GENERATED ALWAYS AS (data->>'$.brand') STORED,
  ADD INDEX idx_brand (brand);
Oracle
SELECT JSON_VALUE(data, '$.name')  AS name,
       JSON_QUERY(data, '$.tags')  AS tags
FROM   item;

-- 표로 펼치기
SELECT t.* FROM item i,
  JSON_TABLE(i.data, '$'
    COLUMNS (name VARCHAR2(50) PATH '$.name',
             price NUMBER      PATH '$.price')) t;

-- 21c+ 는 JSON 전용 타입 지원
SQLite (JSON1 · 기본 내장)
SELECT json_extract(data, '$.name')  AS name,
       data ->> '$.price'            AS price
FROM   item;

-- 배열 펼치기
SELECT i.id, j.value AS tag
FROM   item i, json_each(i.data, '$.tags') j;

-- 표현식 인덱스
CREATE INDEX idx_brand
  ON item (json_extract(data, '$.brand'));
JSON 컬럼을 남용하지 마세요. 자주 조회·조건에 쓰는 값은 반드시 정식 컬럼으로 빼야 합니다. JSON 안에 넣으면 ① 인덱스 설계가 어렵고 ② 제약조건을 못 걸고 ③ 타입 검증이 없어 애플리케이션이 전부 책임지게 됩니다.
기준 — WHERE·JOIN·ORDER BY 에 쓰이면 정식 컬럼, 그냥 보관만 하면 JSON.
PostgreSQL 의 JSON vs JSONB — JSON 은 텍스트 그대로 저장(입력 순서·공백 유지, 파싱 매번), JSONB 는 이진 형태로 저장(파싱 완료, 인덱스 가능, 키 순서 미보존). 거의 항상 JSONB 를 쓰세요.
SECTION 11

정규식 · 전문 검색

LIKE '%검색어%' 는 데이터가 커지면 반드시 무너집니다. 대안 두 가지를 정리합니다.

정규식 — 패턴이 복잡할 때

DB문법
PostgreSQLcol ~ '^010-\d{4}' (대소문자 무시는 ~*) · REGEXP_REPLACE
OracleREGEXP_LIKE(col, '^010-\d{4}') · REGEXP_SUBSTR
MySQL 8.0+col REGEXP '^010-[0-9]{4}' · REGEXP_REPLACE
SQLiteREGEXP 연산자는 있으나 함수를 앱에서 등록해야 동작
정규식도 인덱스를 못 씁니다. 전체를 훑습니다. 데이터 검증(형식이 맞는지 확인)이나 이미 좁혀진 결과 안에서 쓰세요.

전문 검색 — 본문에서 단어 찾기

PostgreSQL
-- 영어는 기본 지원
SELECT * FROM post
WHERE  to_tsvector('english', body)
       @@ to_tsquery('english', 'database & index');

CREATE INDEX idx_post_fts ON post
  USING GIN (to_tsvector('english', body));

-- 한국어는 pg_bigm · pg_trgm 확장 권장 (p09)
MySQL / MariaDB
ALTER TABLE post
  ADD FULLTEXT INDEX ft_body (body)
  WITH PARSER ngram;          -- 한국어는 ngram 파서

SELECT * FROM post
WHERE  MATCH(body) AGAINST('데이터베이스' IN BOOLEAN MODE);
SQLite — FTS5
CREATE VIRTUAL TABLE post_fts
  USING fts5(title, body, tokenize='trigram');

INSERT INTO post_fts SELECT title, body FROM post;

SELECT * FROM post_fts WHERE post_fts MATCH '데이터베이스';
Oracle — Oracle Text
CREATE INDEX idx_post_ctx ON post(body)
  INDEXTYPE IS CTXSYS.CONTEXT;

SELECT * FROM post
WHERE  CONTAINS(body, '데이터베이스', 1) > 0;
검색이 서비스의 핵심 기능이라면 DB의 전문 검색으로는 부족할 수 있습니다. 오타 교정·동의어·랭킹·자동완성이 필요하면 Elasticsearch·OpenSearch·Meilisearch 같은 전용 검색 엔진을 옆에 두는 것이 정석입니다. DB 전문 검색은 "부가 기능 수준의 검색"에 적합합니다.
SECTION 12

집합 기반 사고

SQL을 잘 쓰는 사람과 못 쓰는 사람의 차이는 문법 암기량이 아니라 사고방식입니다. "한 건씩"이 아니라 "한꺼번에" 생각하는 훈련입니다.

✗ 절차형 사고 — 10만 번 왕복
members = db.query("SELECT id FROM member")
for m in members:                       # 10만 번
    orders = db.query(
        "SELECT * FROM orders WHERE member_id=%s", m.id)
    total = sum(o.amount for o in orders)
    db.execute(
        "UPDATE member SET total=%s WHERE id=%s",
        total, m.id)                    # 또 10만 번

# 왕복 20만 번 · 수십 분
✓ 집합 사고 — 한 번
UPDATE member m
SET    total = t.합계
FROM  (SELECT member_id, SUM(amount) AS 합계
       FROM   orders GROUP BY member_id) t
WHERE  t.member_id = m.id;

-- 왕복 1번 · 수 초

사고 전환 훈련 — 이렇게 바꿔 생각합니다

절차형으로 생각하면집합으로 바꾸면
"각 회원을 돌면서 주문을 세고…"GROUP BY member_id
"이전 값을 변수에 저장해 두었다가 비교"LAG() OVER (ORDER BY …)
"카운터를 1씩 늘려 등수를 매김"ROW_NUMBER() OVER (…)
"있는지 확인하고 없으면 INSERT"INSERT … ON CONFLICT
"부모를 찾아 while 로 올라가며 반복"WITH RECURSIVE
"리스트를 만들어 IN 절에 넣기"서브쿼리 또는 EXISTS 로 한 번에
"조건별로 세 번 쿼리해 합치기"SUM(CASE WHEN … END) 조건부 집계
다만 무조건 SQL로 몰아넣는 것도 답이 아닙니다. ① 비즈니스 규칙이 자주 바뀌는 로직, ② 외부 시스템 호출이 필요한 흐름, ③ 테스트가 중요한 복잡한 계산은 애플리케이션 코드가 낫습니다. 기준 — 데이터를 줄이고 모으는 일은 SQL, 판단하고 흐름을 만드는 일은 코드.

한 번에 하되, 너무 크면 잘라서

"한 번에"가 항상 옳은 것도 아닙니다. 수천만 건을 단일 트랜잭션으로 바꾸면 락이 오래 걸리고 로그가 폭증해 오히려 장애가 됩니다. 집합으로 생각하되 청크로 나눠 실행하는 것이 실무의 정답입니다 → 다음 섹션.

집합 기반 사고 — 반복문을 한 문장으로 행 하나씩 — 앱에서 반복문 for row in rows: UPDATE orders SET ... WHERE id = ? 1만 건이면 왕복 1만 번 — 왕복마다 네트워크 지연이 붙습니다 그리고 중간에 실패하면 어디까지 됐는지 알기 어렵습니다 집합으로 — 한 문장 UPDATE orders SET status = 'DONE' WHERE shipped_at IS NOT NULL AND status = 'READY' 왕복 한 번 · DB 가 내부에서 한꺼번에 처리합니다 전부 되든가 전부 안 되든가도 자연스럽게 보장됩니다 "어떤 행들을 어떻게 바꿀 것인가" 로 바꿔 생각하면 대부분의 반복문이 사라집니다 한 번에 너무 많은 행을 건드리면 락이 오래 걸립니다 — 수만 건 단위로 나눠 도는 편이 안전합니다
SQL 을 잘 쓴다는 것은 함수를 많이 아는 것이 아니라 집합으로 생각하는 것입니다.
SECTION 13

대량 배치 UPDATE · DELETE

수천만 건을 한 문장으로 바꾸면 락 대기·복제 지연·로그 폭증이 동시에 터집니다. 운영 중에는 반드시 잘라서 돌립니다.

한 방에 치면 벌어지는 일
① 대상 행 전체에 락이 걸려 서비스 쿼리가 줄줄이 대기
② UNDO/롤백 세그먼트·WAL 이 디스크를 가득 채움
③ 복제본이 따라오지 못해 복제 지연이 수십 분
④ 중간에 실패하면 전부 롤백 — 몇 시간이 날아갑니다

기본형 — PK 범위로 자르기

-- PostgreSQL : 반복 실행 (영향 행이 0이 될 때까지)
-- MySQL 은 IN (… LIMIT) 서브쿼리를 못 씁니다(ERROR 1235) → 아래 MySQL 형태로
UPDATE orders
SET    status = 'ARCHIVED'
WHERE  id IN (
    SELECT id FROM orders
    WHERE  status = 'PAID' AND ordered_at < '2024-01-01'
    ORDER  BY id
    LIMIT  10000
);

-- 삭제도 같은 방식
DELETE FROM log_table
WHERE  id IN (SELECT id FROM log_table
              WHERE created_at < '2025-01-01'
              ORDER BY id LIMIT 10000);

-- MySQL : 단일 테이블 UPDATE · DELETE 에 ORDER BY · LIMIT 를 직접 붙입니다
UPDATE orders SET status = 'ARCHIVED'
WHERE  status = 'PAID' AND ordered_at < '2024-01-01'
ORDER  BY id LIMIT 10000;
DELETE FROM log_table WHERE created_at < '2025-01-01'
ORDER  BY id LIMIT 10000;
-- PostgreSQL : 진행 상황을 돌려받으며 청크 처리
WITH target AS (
    SELECT id FROM orders
    WHERE  ordered_at < '2024-01-01' AND status = 'PAID'
    ORDER  BY id
    LIMIT  10000
    FOR UPDATE SKIP LOCKED          -- 다른 세션이 잡은 행은 건너뜀
)
UPDATE orders o
SET    status = 'ARCHIVED'
FROM   target t
WHERE  o.id = t.id
RETURNING o.id;

셸 스크립트로 반복 돌리기

#!/usr/bin/env bash
# 10000건씩 지우고 0.5초 쉬기 — 복제 지연을 만들지 않는 속도로
while : ; do
  n=$(psql -qtAX -d shop -c "
        DELETE FROM log_table
        WHERE id IN (SELECT id FROM log_table
                     WHERE created_at < '2025-01-01'
                     ORDER BY id LIMIT 10000)
        RETURNING 1" | wc -l)
  echo "$(date +%T)  삭제 $n 건"
  [ "$n" -eq 0 ] && break
  sleep 0.5                      # ← 이 쉬는 시간이 핵심입니다
done

더 좋은 방법 — 아예 다시 만들기

-- 전체의 80% 이상을 지워야 한다면 DELETE 보다 이쪽이 훨씬 빠릅니다
CREATE TABLE orders_new (LIKE orders INCLUDING ALL);   -- PostgreSQL
INSERT INTO orders_new SELECT * FROM orders WHERE ordered_at >= '2025-01-01';

BEGIN;
ALTER TABLE orders     RENAME TO orders_old;
ALTER TABLE orders_new RENAME TO orders;
COMMIT;

-- 확인 후
DROP TABLE orders_old;
가장 좋은 것은 애초에 안 지워도 되게 만드는 것입니다. 로그·이력처럼 계속 쌓이는 표는 월별 파티션으로 만들어 두면 DROP PARTITION 한 줄로 즉시 정리됩니다 → 🐘 p13 · 🐬 m13 · 🧬 z09.
SECTION 14

쿼리 안티패턴 12선

실제로 장애를 만든 패턴들입니다. 코드 리뷰에서 이것만 걸러도 사고가 크게 줄어듭니다.

#안티패턴왜 나쁜가 · 어떻게
1SELECT * 불필요한 컬럼 전송, 커버링 인덱스 무력화, 스키마 변경에 취약 → 컬럼 명시
2컬럼에 함수 씌우기
WHERE DATE(t)= …
인덱스를 못 씁니다 → 범위 조건으로 t >= a AND t < b
3앞에 % 붙은 LIKE 풀스캔 확정 → 전문 검색 또는 역방향 인덱스
4암묵적 형변환
WHERE varchar_col = 123
DB가 컬럼을 형변환하며 인덱스가 죽습니다 → 타입을 맞춰 비교
5NOT IN (서브쿼리) NULL 하나에 결과가 0행 → NOT EXISTS
6깊은 OFFSET 페이징 뒤 페이지가 기하급수적으로 느려짐 → 커서 페이징 (⚙️ t09)
7코드에서 반복 쿼리 (N+1) 목록 100건에 쿼리 101번 → 조인 · IN 절 · 배치 조회 (🔌 a08)
8트랜잭션 안에서 외부 API 호출 락을 잡은 채 수 초 대기 → 트랜잭션 밖으로 분리
9문자열 이어붙여 쿼리 조립 SQL 인젝션 · 실행계획 캐시 미스 → 파라미터 바인딩 (🔌 a05)
10ORDER BY RAND() 전체를 정렬합니다 → PK 범위 난수 또는 TABLESAMPLE
11무의미한 DISTINCT 조인이 잘못돼 중복이 생긴 걸 덮는 용도로 쓰임 → 조인 조건을 고치세요
12COUNT(*) 로 존재 확인 전부 세고 나서 >0 판단 → EXISTS (첫 행에서 멈춤)

특히 자주 나오는 세 가지 실제 수정 예

✗ 날짜 함수로 인덱스 사망
SELECT * FROM orders
WHERE  DATE_FORMAT(ordered_at,'%Y-%m') = '2026-01';
✓ 범위로
SELECT * FROM orders
WHERE  ordered_at >= '2026-01-01'
  AND  ordered_at <  '2026-02-01';
✗ 존재 확인에 COUNT
SELECT COUNT(*) FROM orders WHERE member_id = 1;
-- 결과가 0보다 크면 주문이 있다고 판단
✓ EXISTS — 첫 행에서 멈춤
SELECT EXISTS (
  SELECT 1 FROM orders WHERE member_id = 1
);
✗ DISTINCT 로 중복 덮기
SELECT DISTINCT m.id, m.name
FROM   member m
JOIN   orders o ON o.member_id = m.id;
-- 주문 수만큼 중복돼서 DISTINCT 를 붙임
✓ EXISTS 로 의도를 명확히
SELECT m.id, m.name
FROM   member m
WHERE  EXISTS (SELECT 1 FROM orders o
               WHERE o.member_id = m.id);
SECTION 15

전문가 치트시트

하려는 일을 한글로 검색하세요. 문법 이름을 몰라도 찾을 수 있습니다.

하려는 일로 문법 찾기
하려는 일문법
그룹별 순위·등수RANK() / DENSE_RANK() / ROW_NUMBER() OVER (PARTITION BY … ORDER BY …)
그룹별 상위 N건ROW_NUMBER() 로 번호 붙이고 서브쿼리 밖에서 WHERE rn <= N
이전 행 값과 비교LAG(col) OVER (ORDER BY …) · 다음 행은 LEAD
누적 합계SUM(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
N일 이동평균AVG(x) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
전체 대비 비중x * 100.0 / SUM(x) OVER ()
계층·트리 전개WITH RECURSIVE (Oracle 은 CONNECT BY 도 가능)
긴 쿼리 정리WITH 이름 AS (…) 로 문단 나누기
소계·총계 함께GROUP BY ROLLUP(a,b) · GROUPING SETS
행을 열로 (피벗)SUM(CASE WHEN … THEN … END) — 어디서나 동작
있으면 수정, 없으면 입력PG·SQLite ON CONFLICT · MySQL ON DUPLICATE KEY · Oracle MERGE
행마다 다른 상위 NLATERAL / CROSS APPLY 또는 ROW_NUMBER
JSON 다루기PG JSONB · MySQL JSON_EXTRACT · SQLite json_extract
조건별 개수를 한 번에SUM(CASE WHEN … THEN 1 ELSE 0 END) · COUNT(*) FILTER (WHERE …)
키별 최신 1건PG DISTINCT ON (key) … ORDER BY key, ts DESC · 그 외 ROW_NUMBER
대량 UPDATE·DELETELIMIT 로 청크 반복 + 사이에 sleep
존재 여부만 확인EXISTS (SELECT 1 …) — COUNT(*) 쓰지 말 것
0으로 나누기 방지a / NULLIF(b, 0)
여러 행을 한 줄로PG STRING_AGG · Oracle LISTAGG · MySQL·SQLite GROUP_CONCAT
배열·JSON 펼치기PG unnest() · jsonb_array_elements() · SQLite json_each()
여기까지 왔다면 SQL로 할 수 있는 일의 대부분을 알고 있는 셈입니다. 남은 것은 같은 결과를 얼마나 빠르게 내느냐입니다 → ⚙️ 설계 · 튜닝 탭으로 이어집니다.
ORACLE DATABASE · 23ai · PL/SQL · 기간계의 표준

Oracle —
설치부터
튜닝까지

국내 금융·통신·공공 기간계는 여전히 오라클 위에서 돕니다. DUAL · ROWNUM · CONNECT BY · PL/SQL 같은 오라클 방언은 표준 SQL만 알아서는 읽히지 않습니다. 설치(가장 큰 관문)부터 실행계획·AWR·파티셔닝까지, 실무에서 실제로 쓰는 것만 담았습니다.

17SECTIONS
23ai기준 버전
PL/SQL포함
SECTION 01

오라클은 어떤 DB인가 · 에디션

기능·안정성·튜닝 도구에서 여전히 최고 수준입니다. 대신 비용이 매우 큽니다. 기술 선택 회의에서 반드시 나오는 이야기라 구조를 알아 둘 필요가 있습니다.

에디션내용
Enterprise (EE) 모든 기능. 파티셔닝·RAC·Data Guard·진단팩(AWR) 등은 대부분 별도 유료 옵션입니다. 코어 단위 라이선스라 서버 사양이 곧 비용입니다.
Standard Edition 2 (SE2) 기능이 제한됩니다(파티셔닝·병렬 쿼리 등 없음). 소켓 수 제한이 있습니다.
Free (구 XE) 무료. 사용자 데이터 12GB, CPU 2코어, 메모리 2GB 제한. 학습·개발·소규모 운영에 씁니다. 상용 사용도 허용됩니다.
Autonomous (클라우드) OCI에서 관리형으로. 항상 무료 등급(Always Free)이 있어 학습용으로 쓸 만합니다.
라이선스는 반드시 확인하세요. "EE를 깔았으니 파티셔닝을 써도 되겠지"가 수억 원짜리 감사 지적이 되는 일이 실제로 있습니다. 설치만으로 쓸 수 있게 되어 있어도, 계약한 옵션이 아니면 사용하면 안 됩니다. 가상화 환경의 코어 계산 규칙도 별도입니다 — 도입 시 반드시 영업/법무 검토를 거치세요.

버전 흐름 (2026-08 기준)

  • 11gR2 — 아직도 현장에 남아 있습니다. FETCH FIRST·IDENTITY가 없어 ROWNUM·시퀀스를 씁니다.
  • 12c / 18c / 19c — 19c가 장기 지원(LTS) 버전이라 실무 점유율이 가장 높습니다. 멀티테넌트(CDB/PDB)가 기본이 됐습니다.
  • 21c — 혁신 릴리스(단기 지원). JSON 전용 타입 등이 들어왔습니다.
  • 23ai — 현재의 장기 지원 버전. BOOLEAN 타입, IF EXISTS, 벡터 검색, SQL의 도메인·주석 개선 등 오래 기다린 기능들이 들어왔습니다.
이 탭의 기준 — 문법은 19c 이상에서 동작하는 것을 기본으로 하고, 23ai 전용 기능은 23ai 배지를, 11g 이하에서만 필요한 것은 11g 배지를 붙였습니다.
SECTION 02

설치 — Free · Docker

오라클 설치는 다른 DB보다 훨씬 무겁습니다. 학습 목적이면 도커가 정답입니다.

방법 ① Docker — 권장

# 공식 컨테이너 레지스트리 (별도 로그인 불필요)
docker run -d --name ora \
  -p 1521:1521 \
  -e ORACLE_PWD=devpass \
  -v oradata:/opt/oracle/oradata \
  container-registry.oracle.com/database/free:latest

# 기동에 2~5분 걸립니다. 아래가 보이면 준비 완료
docker logs -f ora | grep -i "DATABASE IS READY"

# 접속
docker exec -it ora sqlplus system/devpass@//localhost:1521/FREEPDB1
서비스명은 FREEPDB1 입니다(23ai Free 기준). FREE 는 컨테이너 DB(CDB)이고, 실제로 테이블을 만드는 곳은 PDB인 FREEPDB1 입니다. 이 구분을 모르면 "접속은 되는데 테이블이 안 보이는" 상황이 됩니다 → o04.

방법 ② 직접 설치 (Linux)

# Oracle Linux · RHEL 계열
sudo dnf install -y oracle-database-preinstall-23ai      # 커널 파라미터·계정 자동 설정
sudo dnf install -y oracle-database-free-23ai            # 다운로드한 RPM

# 초기 구성 (비밀번호를 물어봅니다)
sudo /etc/init.d/oracle-free-23ai configure

# 환경 변수 (~/.bashrc 에 추가)
export ORACLE_HOME=/opt/oracle/product/23ai/dbhomeFree
export ORACLE_SID=FREE
export PATH=$ORACLE_HOME/bin:$PATH

# 기동 확인
lsnrctl status
sqlplus / as sysdba

방법 ③ 접속만 필요할 때 — Instant Client

회사 서버의 오라클에 붙기만 하면 되는 경우, DB 전체를 설치할 필요가 없습니다. Oracle Instant Client(수십 MB) + SQL Developer(무료 GUI) 조합이면 충분합니다. 파이썬의 oracledb 드라이버는 Thin 모드를 지원해 Instant Client 없이도 붙습니다(🔌 a02).

설치 후 첫 확인

-- 버전
SELECT banner_full FROM v$version;

-- 지금 어디에 접속해 있나 (CDB? PDB?)
SHOW CON_NAME;
SELECT sys_context('USERENV','CON_NAME') FROM dual;

-- 사용 가능한 PDB 목록
SELECT name, open_mode FROM v$pdbs;
PDB가 MOUNTED 상태면 접속할 수 없습니다. ALTER PLUGGABLE DATABASE FREEPDB1 OPEN; 으로 열고, 재시작해도 유지되게 ALTER PLUGGABLE DATABASE FREEPDB1 SAVE STATE; 를 실행하세요. 도커 이미지는 보통 자동으로 열려 있습니다.
SECTION 03

접속 — SQL*Plus · 서비스명

오라클 접속에서 가장 많이 막히는 지점입니다. SID와 서비스명은 다릅니다 — 이것만 이해하면 대부분 풀립니다.

개념뜻
리스너 (Listener)1521 포트에서 접속 요청을 받아 DB로 넘겨주는 중개 프로세스. 이게 안 떠 있으면 ORA-12541.
SID서버에 떠 있는 인스턴스 하나의 이름. 물리적 개념.
서비스명클라이언트가 요청하는 논리적 이름. 여러 인스턴스가 한 서비스를 제공할 수 있습니다(RAC).
PDB12c 이후의 DB 안의 DB. 각 PDB가 서비스명을 가집니다.

접속 문자열 세 가지 형태

# ① Easy Connect — 가장 간단 (서비스명 앞에 슬래시)
sqlplus system/devpass@//localhost:1521/FREEPDB1

# ② SID 방식 (구형) — 콜론 형식 host:1521:FREE 는 JDBC 전용(jdbc:oracle:thin:@localhost:1521:FREE)
#    sqlplus 에서 SID 로 붙으려면 접속 기술자를 직접 씁니다
sqlplus system/devpass@"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SID=FREE)))"

# ③ tnsnames.ora 별칭
sqlplus system/devpass@ORCLPDB
# $ORACLE_HOME/network/admin/tnsnames.ora
ORCLPDB =
  (DESCRIPTION =
    (ADDRESS = (PROTOCOL = TCP)(HOST = db.example.com)(PORT = 1521))
    (CONNECT_DATA =
      (SERVER = DEDICATED)
      (SERVICE_NAME = ORCLPDB)
    )
  )

리스너 진단

lsnrctl status          # 어떤 서비스가 등록돼 있는지 확인 ← 여기가 핵심
lsnrctl start
lsnrctl stop

# 네트워크 경로 확인
tnsping ORCLPDB
Service "FREEPDB1" has 1 instance(s). Instance "FREE", status READY, has 1 handler(s) for this service...
lsnrctl status 에 보이는 Service 이름을 그대로 쓰세요. 여기 없는 이름으로 접속하면 ORA-12514 가 납니다. 반대로 여기 보이면 접속 문자열은 맞는 것이니, 그다음은 계정·비밀번호 문제입니다.

SQL*Plus 에서 바로 쓰는 명령

SET LINESIZE 200          -- 한 줄 폭 (기본 80이라 결과가 접힙니다)
SET PAGESIZE 50           -- 헤더 반복 주기
SET SERVEROUTPUT ON       -- DBMS_OUTPUT.PUT_LINE 결과 보기
COLUMN name FORMAT A20    -- 컬럼 폭 지정

DESC member;              -- 테이블 구조
SHOW USER;                -- 지금 누구로 접속했나
SHOW CON_NAME;            -- 지금 어느 컨테이너인가

@script.sql               -- 파일 실행
SPOOL out.txt             -- 결과를 파일로
SPOOL OFF
EXIT
SQL*Plus 가 불편하다면 SQLcl 을 쓰세요. 오라클이 만든 최신 CLI로, 자동완성·히스토리·SELECT … ; 없이 실행 등 현대적인 편의 기능이 있고 JDBC만 있으면 동작합니다(Instant Client 불필요).
SECTION 04

CDB · PDB · 계정 · 권한

12c부터 오라클은 멀티테넌트 구조입니다. 계정을 어디에 만드느냐에 따라 C## 접두사가 필요해집니다 — 여기서 많이 막힙니다.

CDB — 바깥 컨테이너 하나 CDB$ROOT — 인스턴스 · 공용 메타데이터 여기에 만드는 계정은 이름이 C## 로 시작해야 한다 PDB · orclpdb1 hr · shop — 접두사 없음 PDB · orclpdb2 운영 · 개발을 갈라 담는다 sqlplus hr/pw@//host:1521/orclpdb1 서비스명이 곧 들어갈 PDB 이름이다 SID 로 붙던 습관 그대로 CDB 에 접속하면 hr 테이블이 "없다"고 나옵니다 — 방을 잘못 찾은 것
12c 이후의 오라클은 아파트 한 동(CDB)에 여러 세대(PDB)가 든 구조입니다. 접속할 때는 동이 아니라 세대 번호를 대야 합니다.
CDB
컨테이너 데이터베이스 (root) 전체를 감싸는 그릇. 여기에 만드는 계정은 공통 사용자라 이름이 C## 으로 시작해야 합니다.
▼
PDB
플러그인 데이터베이스 실제 업무 스키마가 사는 곳. 애플리케이션 계정은 여기에 만듭니다. 이름 제약이 없습니다.
-- 지금 어디인지 먼저 확인
SHOW CON_NAME;                      -- CDB$ROOT 이면 root 입니다

-- PDB 로 이동
ALTER SESSION SET CONTAINER = FREEPDB1;
SHOW CON_NAME;                      -- FREEPDB1

-- 애플리케이션 계정 만들기 (PDB 안에서)
CREATE USER app IDENTIFIED BY "Strong#Pass1"
  DEFAULT TABLESPACE users
  TEMPORARY TABLESPACE temp
  QUOTA UNLIMITED ON users;         -- ★ 이게 없으면 INSERT 시 ORA-01950

GRANT CREATE SESSION      TO app;   -- 접속 권한 (없으면 로그인 자체가 안 됨)
GRANT CREATE TABLE        TO app;
GRANT CREATE SEQUENCE     TO app;
GRANT CREATE VIEW         TO app;
GRANT CREATE PROCEDURE    TO app;
가장 흔한 두 가지 실수
① CDB$ROOT 에서 CREATE USER app → ORA-65096: 공통 사용자 이름이 부적합합니다. PDB로 이동하거나 이름을 C##app 으로 바꿔야 합니다.
② QUOTA 를 안 줌 → 테이블은 만들어지는데 INSERT 에서 ORA-01950: 테이블스페이스에 대한 권한이 없습니다.

권한 — 시스템 권한과 객체 권한

-- 시스템 권한 : "무엇을 할 수 있는가"
GRANT CREATE TABLE, CREATE VIEW TO app;

-- 객체 권한 : "어느 객체에 무엇을 할 수 있는가"
GRANT SELECT, INSERT, UPDATE ON hr.member TO app;
GRANT EXECUTE ON hr.pkg_order TO app;

-- 롤 : 권한 묶음
CREATE ROLE app_readonly;
GRANT SELECT ON hr.member TO app_readonly;
GRANT SELECT ON hr.orders TO app_readonly;
GRANT app_readonly TO analyst1, analyst2;

-- 확인
SELECT * FROM user_sys_privs;      -- 내가 가진 시스템 권한
SELECT * FROM user_tab_privs;      -- 내가 가진 객체 권한
SELECT * FROM session_privs;       -- 지금 세션에서 유효한 전체
GRANT DBA TO app; 은 절대 하지 마세요. 편하다는 이유로 실무에서 자주 벌어지는 일인데, 다른 스키마의 데이터를 전부 읽고 지울 수 있게 됩니다. SQL 인젝션 한 번이면 DB 전체가 넘어갑니다. 필요한 권한만 개별로 주세요.

스키마 = 계정

오라클에서는 계정 하나가 곧 스키마 하나입니다(PostgreSQL·MySQL과 다른 점). app 계정이 만든 테이블은 app.member 가 되고, 다른 계정이 쓰려면 스키마명을 붙이거나 시노님이 필요합니다.

-- 시노님 : 다른 스키마 객체에 별명 붙이기
CREATE SYNONYM member FOR hr.member;              -- 내 계정에서만
CREATE PUBLIC SYNONYM member FOR hr.member;       -- 전체 공개 (남용 주의)
SECTION 05

DUAL · ROWNUM · 페이징

오라클 방언의 첫 관문입니다. 레거시 코드를 읽으려면 반드시 알아야 합니다.

흔한 실수 — WHERE ROWNUM <= 10 과 ORDER BY 를 한 층에 같이 쓴다 원본 1만 행 ① ROWNUM 부터 매긴다 읽히는 순서대로 1~10 ② 그 10건만 정렬 엉뚱한 10건 매번 달라지기도 올바른 순서 — 정렬을 끝낸 결과를 감싸고 바깥에서 자른다 원본 1만 행 ① 서브쿼리에서 정렬 ORDER BY price DESC ② 바깥에서 ROWNUM 진짜 상위 10건 12c+ 는 FETCH FIRST ROWNUM 은 "몇 번째로 뽑혔나"이지 "몇 등인가"가 아닙니다 — 순위를 원하면 ROW_NUMBER() 나 FETCH FIRST 를 쓰세요
ROWNUM 은 정렬보다 먼저 붙습니다. 자를 것은 언제나 정렬이 끝난 뒤여야 합니다.

DUAL — 계산 전용 가짜 테이블

-- 오라클은 SELECT 에 FROM 이 반드시 있어야 합니다
SELECT 1 + 1 FROM dual;
SELECT SYSDATE FROM dual;
SELECT seq_order.NEXTVAL FROM dual;

-- 다른 DB는 FROM 없이 됩니다
-- PostgreSQL/MySQL/SQLite:  SELECT 1 + 1;
23ai 부터는 FROM 절을 생략할 수 있습니다. SELECT 1 + 1; 이 동작합니다. 다만 현장 코드의 대부분은 여전히 FROM dual 이므로 읽을 줄 알아야 합니다.

ROWNUM — 결과에 매겨지는 임시 번호

SELECT ROWNUM, name FROM member WHERE ROWNUM <= 3;   -- 상위 3건
✗ 정렬 후 상위 N 이 아닙니다
SELECT * FROM product
WHERE  ROWNUM <= 3
ORDER  BY price DESC;

-- ROWNUM 이 ORDER BY 보다 먼저 매겨져
-- "아무 3건을 뽑아서 정렬"한 결과입니다
✓ 정렬을 먼저 하고 감싸기
SELECT * FROM (
    SELECT * FROM product ORDER BY price DESC
)
WHERE ROWNUM <= 3;
WHERE ROWNUM > 10 은 항상 0건입니다. ROWNUM은 조건을 통과한 행에만 순서대로 붙습니다. 첫 행이 1을 받는데 > 10 이면 탈락 → 다음 행도 다시 1 → 영원히 통과 못 함. 중간 페이지를 뽑으려면 반드시 서브쿼리로 감싸 별칭을 붙여야 합니다.

페이징 — 세 가지 방법

-- ① 12c 이상 (표준 문법) ★ 권장
SELECT * FROM product
ORDER  BY price DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

-- ② 11g 이하 : 3중 서브쿼리 (현장 레거시의 표준형)
SELECT * FROM (
    SELECT a.*, ROWNUM rn FROM (
        SELECT * FROM product ORDER BY price DESC
    ) a
    WHERE ROWNUM <= 30            -- 끝 위치
)
WHERE rn > 20;                    -- 시작 위치

-- ③ 윈도우 함수 (가장 유연 · 그룹별 페이징도 가능)
SELECT * FROM (
    SELECT p.*, ROW_NUMBER() OVER (ORDER BY price DESC) rn
    FROM   product p
)
WHERE rn BETWEEN 21 AND 30;
왜 ②는 서브쿼리를 두 번 감쌀까 — 안쪽에서 정렬하고, 중간에서 ROWNUM 을 별칭(rn)으로 고정하고, 바깥에서 그 별칭으로 범위 조건을 겁니다. ROWNUM은 매겨지는 즉시 비교할 수 없으므로 한 단계 더 감싸야 합니다.
SECTION 06

NVL · DECODE · 문자열

현장 코드에서 수천 번 만나게 되는 함수들입니다. 표준 대응 문법도 함께 정리합니다.

Oracle하는 일표준 대응
NVL(a, b)a가 NULL이면 bCOALESCE(a, b)
NVL2(a, b, c)a가 NULL이 아니면 b, NULL이면 cCASE WHEN a IS NOT NULL THEN b ELSE c END
DECODE(x, 1,'A', 2,'B', 'etc')값 비교 분기CASE x WHEN 1 THEN 'A' … END
SUBSTR(s, 1, 3)자르기 (1부터 시작)SUBSTRING(s FROM 1 FOR 3)
INSTR(s, 'x')위치 찾기 (없으면 0)POSITION('x' IN s)
LPAD(s, 5, '0')왼쪽 채우기동일
a || b문자열 결합동일 (MySQL만 CONCAT)
LISTAGG(x, ',')여러 행을 한 줄로STRING_AGG · GROUP_CONCAT
-- DECODE 실전 — 짧지만 가독성은 CASE 가 낫습니다
SELECT name,
       DECODE(grade, 'GOLD','10%', 'SILVER','5%', '없음') AS 혜택
FROM   member;

-- LISTAGG — 회원별 주문번호를 한 줄로
SELECT m.name,
       LISTAGG(o.id, ', ') WITHIN GROUP (ORDER BY o.id) AS 주문목록
FROM   member m JOIN orders o ON o.member_id = m.id
GROUP  BY m.name;
name 주문목록 김하늘 1001, 1003 박바다 1002, 1005 최나무 1004, 1006
LISTAGG 는 4000바이트를 넘으면 에러(ORA-01489)가 납니다. 12cR2부터 LISTAGG(x, ',' ON OVERFLOW TRUNCATE) 로 자를 수 있습니다. 목록이 길어질 수 있으면 애플리케이션에서 조립하는 편이 안전합니다.

오라클만의 특이점 — 빈 문자열 = NULL

SELECT CASE WHEN '' IS NULL THEN 'NULL 이다' ELSE '아니다' END FROM dual;
-- 오라클: 'NULL 이다'      ← 오라클만 이렇습니다
-- 그 외 : '아니다'

-- 그래서 이런 코드가 오라클에서만 다르게 동작합니다
SELECT COUNT(*) FROM member WHERE email = '';    -- 오라클: 항상 0건
마이그레이션에서 반드시 걸리는 지점입니다. 오라클에서 PostgreSQL로 옮기면 빈 문자열이 NULL이 아니게 되어 IS NULL 조건이 걸리지 않는 행이 생깁니다. 데이터 정제 단계에서 NULLIF(col, '') 로 통일하세요.
SECTION 07

DATE · TO_DATE · INTERVAL

오라클의 DATE에는 시분초가 들어 있습니다. 이걸 모르고 날짜를 비교하다 정산이 틀어지는 사고가 정말 많습니다.

핵심 — 다른 DB의 DATE 는 "날짜만"이지만, 오라클의 DATE 는 초 단위까지 저장합니다(연·월·일·시·분·초). SYSDATE 도 지금 시각을 초까지 갖고 있습니다.
✗ 오늘 데이터가 안 나옵니다
SELECT * FROM orders
WHERE  ordered_at = SYSDATE;
-- 정확히 '지금 이 초'인 행만 걸립니다 → 0건

SELECT * FROM orders
WHERE  ordered_at BETWEEN '2026-01-01' AND '2026-01-31';
-- 1/31 00:00:00 까지만 → 하루가 통째로 빠짐
✓ 범위로 비교
SELECT * FROM orders
WHERE  ordered_at >= TRUNC(SYSDATE)
  AND  ordered_at <  TRUNC(SYSDATE) + 1;

SELECT * FROM orders
WHERE  ordered_at >= DATE '2026-01-01'
  AND  ordered_at <  DATE '2026-02-01';

날짜 함수

SELECT SYSDATE                            AS 현재시각,      -- DB 서버 시각
       SYSTIMESTAMP                       AS 타임존포함,
       TRUNC(SYSDATE)                     AS 오늘자정,
       TRUNC(SYSDATE, 'MM')               AS 이번달1일,
       LAST_DAY(SYSDATE)                  AS 이번달말일,
       ADD_MONTHS(SYSDATE, 3)             AS 삼개월후,
       SYSDATE + 7                        AS 일주일후,      -- 숫자 = 일 단위
       SYSDATE + 1/24                     AS 한시간후,      -- 1/24 = 1시간
       MONTHS_BETWEEN(SYSDATE, DATE '2020-03-01') AS 근무개월   -- dual 에는 컬럼이 없어 리터럴로
FROM   dual;

-- 문자 ↔ 날짜
SELECT TO_DATE('2026-08-05 14:30', 'YYYY-MM-DD HH24:MI') FROM dual;
SELECT TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS')          FROM dual;
SELECT TO_CHAR(SYSDATE, 'YYYY"년" MM"월" DD"일"')          FROM dual;
포맷뜻주의
YYYY4자리 연도—
MM월 (01~12)MI(분)와 헷갈리지 마세요
DD일—
HH2424시간제 시HH 만 쓰면 12시간제입니다
MI분—
SS초—

타임존이 필요하면 TIMESTAMP

CREATE TABLE event (
  id      NUMBER,
  at_utc  TIMESTAMP WITH TIME ZONE,          -- 타임존 정보 저장
  at_loc  TIMESTAMP WITH LOCAL TIME ZONE     -- 조회하는 세션 시간대로 자동 변환
);

SELECT SYSTIMESTAMP AT TIME ZONE 'UTC' FROM dual;
SELECT ordered_at + INTERVAL '3' DAY   FROM orders;
SELECT ordered_at + INTERVAL '2' MONTH FROM orders;
글로벌 서비스라면 시각은 UTC로 저장하고 표시할 때만 변환하는 것이 원칙입니다. SYSDATE 는 DB 서버의 OS 시간대를 따르므로, 서버가 옮겨가면 값의 의미가 달라집니다.
SECTION 08

시퀀스 · IDENTITY · 트리거

오라클에는 AUTO_INCREMENT 가 없습니다. 대신 시퀀스라는 독립 객체를 씁니다.

-- 시퀀스 만들기
CREATE SEQUENCE seq_member
  START WITH 1
  INCREMENT BY 1
  NOCACHE            -- 번호 건너뜀을 막고 싶으면 (성능은 떨어집니다)
  NOCYCLE;

-- 사용
INSERT INTO member (id, name) VALUES (seq_member.NEXTVAL, '김하늘');
SELECT seq_member.CURRVAL FROM dual;    -- 방금 쓴 값 (같은 세션에서만)

-- 12c 부터는 DEFAULT 로 쓸 수 있습니다
CREATE TABLE member (
  id   NUMBER DEFAULT seq_member.NEXTVAL PRIMARY KEY,
  name VARCHAR2(50)
);

IDENTITY — 12c 이상이면 이쪽이 간단합니다 12c+

CREATE TABLE member (
  id   NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,   -- 값을 직접 못 넣음
  name VARCHAR2(50)
);
-- 또는
  id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY  -- 직접 넣는 것도 허용
  id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY      -- NULL 넣으면 자동 채움

INSERT INTO member (name) VALUES ('김하늘');   -- id 는 자동

11g 이하 — 시퀀스 + 트리거 11g

CREATE OR REPLACE TRIGGER trg_member_id
BEFORE INSERT ON member
FOR EACH ROW
WHEN (NEW.id IS NULL)
BEGIN
  :NEW.id := seq_member.NEXTVAL;
END;
/
/ 는 SQL*Plus 에게 "PL/SQL 블록이 끝났으니 실행하라"는 뜻입니다. PL/SQL 안에는 세미콜론이 여러 번 나오므로 블록의 끝을 알리는 별도 기호가 필요합니다. 이걸 빠뜨리면 아무 일도 안 일어난 채 커서만 기다립니다.

번호가 건너뛰는 이유

시퀀스는 성능을 위해 번호를 미리 메모리에 캐시합니다(기본 20개). DB를 재시작하거나 인스턴스가 죽으면 캐시에 남아 있던 번호가 사라집니다. 또 롤백해도 이미 뽑은 번호는 돌아오지 않습니다.

번호에 의미를 부여하지 마세요. "주문번호는 빠짐없이 연속이어야 한다"는 요구는 시퀀스로 만족시킬 수 없습니다. 연속 번호가 필요하면 별도 채번 테이블 + 락이 필요하고, 이는 동시성을 크게 떨어뜨립니다. 대부분의 경우 요구사항을 재검토하는 편이 낫습니다.

트리거 — 쓸 때와 안 쓸 때

-- 감사 로그 : 트리거의 정당한 용도
CREATE OR REPLACE TRIGGER trg_member_audit
AFTER UPDATE ON member
FOR EACH ROW
BEGIN
  INSERT INTO member_hist (member_id, old_grade, new_grade, changed_at, changed_by)
  VALUES (:OLD.id, :OLD.grade, :NEW.grade, SYSTIMESTAMP, SYS_CONTEXT('USERENV','SESSION_USER'));
END;
/
트리거에 비즈니스 로직을 넣지 마세요. 코드에서 보이지 않는 곳에서 데이터가 바뀌므로 디버깅이 극도로 어려워집니다. 트리거가 다른 트리거를 부르는 연쇄가 생기면 사실상 추적이 불가능합니다. 감사 로그·타임스탬프 자동 기록 정도로 제한하세요.
SECTION 09

계층 쿼리 CONNECT BY

오라클 전용 계층 탐색 문법입니다. 재귀 CTE보다 짧고, 레거시 코드에 아주 많이 나옵니다.

SELECT LEVEL,
       LPAD(' ', (LEVEL-1)*3) || name    AS 트리,
       SYS_CONNECT_BY_PATH(name, ' > ')  AS 경로,
       CONNECT_BY_ISLEAF                 AS 말단여부
FROM   category
START  WITH parent_id IS NULL            -- 출발점
CONNECT BY PRIOR id = parent_id          -- 부모→자식 연결 규칙
ORDER  SIBLINGS BY name;                 -- 형제끼리 정렬
LEVEL 트리 경로 1 전체 > 전체 2 의류 > 전체 > 의류 2 전자제품 > 전체 > 전자제품 3 주변기기 > 전체 > 전자제품 > 주변기기 4 마우스 > 전체 > 전자제품 > 주변기기 > 마우스 4 키보드 > 전체 > 전자제품 > 주변기기 > 키보드
키워드뜻
START WITH탐색 출발점 조건
CONNECT BY PRIOR a = bPRIOR가 붙은 쪽이 "이전 행". PRIOR id = parent_id 는 아래로, 반대로 쓰면 위로 올라갑니다
LEVEL깊이 (출발점이 1)
SYS_CONNECT_BY_PATH루트부터의 경로 문자열
CONNECT_BY_ROOT x이 행이 속한 최상위의 x 값
CONNECT_BY_ISLEAF자식이 없으면 1
NOCYCLE순환이 있어도 멈춤 (CONNECT BY NOCYCLE PRIOR …)
ORDER SIBLINGS BY계층 구조를 유지하면서 형제만 정렬

방향 뒤집기 — 조상 찾기

-- '마우스'에서 최상위까지 거슬러 올라가기
SELECT LEVEL, name
FROM   category
START  WITH id = 5
CONNECT BY PRIOR parent_id = id;      -- PRIOR 위치가 반대

재귀 CTE 와 비교

CONNECT BY — 짧습니다
SELECT LEVEL, name
FROM   category
START  WITH parent_id IS NULL
CONNECT BY PRIOR id = parent_id;
재귀 CTE — 표준입니다
WITH tree (id, name, lvl) AS (
  SELECT id, name, 1 FROM category WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, t.lvl+1
  FROM   category c JOIN tree t ON t.id = c.parent_id
)
SELECT lvl, name FROM tree;
새로 쓴다면 재귀 CTE 를 권합니다. 다른 DB로 옮길 수 있고, 복잡한 조건을 붙이기도 쉽습니다. 다만 기존 코드를 읽고 고치려면 CONNECT BY 는 필수입니다 — 국내 SI 코드에 압도적으로 많습니다.
START WITH 에서 출발해 CONNECT BY 를 따라 내려갑니다 사장 (LEVEL 1) 개발본부장 (2) 영업본부장 (2) 팀장 A (3) 사원 (4) 쿼리의 모양 SELECT LEVEL, name START WITH mgr_id IS NULL CONNECT BY PRIOR id = mgr_id 방향은 PRIOR 가 정합니다 PRIOR 를 반대로 붙이면 사원에서 사장까지 거슬러 갑니다 순환 참조를 조심하세요 A→B→A 가 있으면 무한히 돕니다 — NOCYCLE 로 끊습니다 표준 SQL 로는 재귀 CTE(e05)가 같은 일을 합니다 — 이식성을 생각하면 그쪽이 낫습니다
조직도 · 카테고리 · 댓글 — 자기 자신을 가리키는 표는 전부 이 모양입니다.
START WITH 에서 출발해 CONNECT BY 를 따라 내려갑니다 사장 (LEVEL 1) 개발본부장 (2) 영업본부장 (2) 팀장 A (3) 사원 (4) 쿼리의 모양 SELECT LEVEL, name START WITH mgr_id IS NULL CONNECT BY PRIOR id = mgr_id 방향은 PRIOR 가 정합니다 PRIOR 를 반대로 붙이면 사원에서 사장까지 거슬러 올라갑니다 순환 참조를 조심하세요 A→B→A 가 있으면 무한히 돕니다 — NOCYCLE 을 붙이면 그 자리에서 끊습니다 표준 SQL 로는 재귀 CTE(e05)가 같은 일을 합니다 — 이식성을 생각하면 그쪽이 낫습니다
조직도 · 카테고리 · 댓글 — 자기 자신을 가리키는 표는 전부 이 모양입니다.
SECTION 10

오라클 분석 함수

윈도우 함수(🎓 e01)는 오라클이 원조입니다. 여기서는 오라클에만 있거나 특히 강력한 것들을 봅니다.

KEEP … DENSE_RANK FIRST/LAST — 그룹의 대표값

-- 부서별로 "가장 최근 입사자의 급여" 를 집계 한 번에
SELECT dept_id,
       MAX(salary) KEEP (DENSE_RANK FIRST ORDER BY hire_date DESC) AS 최근입사자급여,
       MIN(salary) KEEP (DENSE_RANK LAST  ORDER BY hire_date DESC) AS 최초입사자급여,
       COUNT(*) AS 인원
FROM   employee
GROUP  BY dept_id;
다른 DB에서는 ROW_NUMBER 로 서브쿼리를 만들어야 하는 일을 GROUP BY 한 번으로 끝냅니다. 오라클 정산 쿼리에서 자주 보입니다.

RATIO_TO_REPORT · 누적 분포

SELECT category, name, price,
       ROUND(RATIO_TO_REPORT(price) OVER (PARTITION BY category) * 100, 1) AS 카테고리내비중,
       ROUND(CUME_DIST() OVER (ORDER BY price) * 100, 1)                   AS 누적분포,
       PERCENT_RANK()   OVER (ORDER BY price)                              AS 백분위
FROM   product;

MODEL 절 — 스프레드시트처럼 계산

-- 전월 값을 참조해 누적 예측치를 만드는 등, 엑셀 수식 같은 계산
SELECT 월, 매출, 예측
FROM   monthly_sales
MODEL
  DIMENSION BY (월)
  MEASURES (매출, 0 AS 예측)
  RULES (
    예측[FOR 월 FROM 7 TO 12 INCREMENT 1] = 매출[CV()-1] * 1.05
  );
MODEL 절은 배우는 비용에 비해 쓸 일이 적습니다. 가독성이 매우 떨어지고 오라클 전용이라 유지보수가 어렵습니다. 대부분의 경우 윈도우 함수 + 재귀 CTE 로 충분합니다. 존재만 알아 두세요.

MATCH_RECOGNIZE — 행 패턴 인식 12c+

-- "3일 연속 매출 상승" 같은 패턴을 SQL 로 찾기
SELECT * FROM daily_sales
MATCH_RECOGNIZE (
  ORDER BY 일자
  MEASURES FIRST(UP.일자) AS 시작일, LAST(UP.일자) AS 종료일, COUNT(*) AS 일수
  ONE ROW PER MATCH
  PATTERN (UP{3,})
  DEFINE UP AS 매출 > PREV(매출)
);
이상 탐지·사용자 여정 분석에 강력합니다. 다만 문법이 낯설고 오라클 전용이라, 정말 필요할 때만 꺼내는 카드입니다.
SECTION 11

PL/SQL 기초 — 블록 · 커서

SQL에 변수·조건문·반복문·예외처리를 붙인 언어입니다. DB 안에서 도는 프로그램을 만듭니다.

SET SERVEROUTPUT ON      -- 출력을 보려면 먼저 켜야 합니다

DECLARE
    v_name    member.name%TYPE;          -- 컬럼과 같은 타입으로 (변경에 안전)
    v_count   NUMBER := 0;
    v_row     member%ROWTYPE;            -- 행 전체를 담는 변수
BEGIN
    SELECT name INTO v_name FROM member WHERE id = 1;
    DBMS_OUTPUT.PUT_LINE('이름: ' || v_name);

    SELECT COUNT(*) INTO v_count FROM orders WHERE member_id = 1;

    IF v_count > 5 THEN
        DBMS_OUTPUT.PUT_LINE('단골');
    ELSIF v_count > 0 THEN
        DBMS_OUTPUT.PUT_LINE('일반');
    ELSE
        DBMS_OUTPUT.PUT_LINE('주문 없음');
    END IF;

EXCEPTION
    WHEN NO_DATA_FOUND THEN
        DBMS_OUTPUT.PUT_LINE('회원을 찾을 수 없습니다');
    WHEN TOO_MANY_ROWS THEN
        DBMS_OUTPUT.PUT_LINE('결과가 여러 건입니다');
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE('오류: ' || SQLCODE || ' ' || SQLERRM);
        RAISE;                            -- ★ 삼키지 말고 다시 던지세요
END;
/
SELECT … INTO 는 정확히 1행이어야 합니다. 0행이면 NO_DATA_FOUND, 2행 이상이면 TOO_MANY_ROWS 예외가 납니다. 이 둘을 처리하지 않으면 배치가 통째로 죽습니다.

커서 — 여러 행을 한 줄씩

-- 방법 ① 암시적 커서 FOR 루프 (가장 간결 · 권장)
BEGIN
  FOR r IN (SELECT id, name FROM member WHERE grade = 'GOLD') LOOP
      DBMS_OUTPUT.PUT_LINE(r.id || ' : ' || r.name);
  END LOOP;
END;
/

-- 방법 ② 명시적 커서 (파라미터가 필요할 때)
DECLARE
  CURSOR c_member(p_grade VARCHAR2) IS
      SELECT id, name FROM member WHERE grade = p_grade;
  v_id member.id%TYPE;  v_name member.name%TYPE;
BEGIN
  OPEN c_member('GOLD');
  LOOP
      FETCH c_member INTO v_id, v_name;
      EXIT WHEN c_member%NOTFOUND;
      DBMS_OUTPUT.PUT_LINE(v_id || ' : ' || v_name);
  END LOOP;
  CLOSE c_member;
END;
/

프로시저와 함수

CREATE OR REPLACE PROCEDURE grade_up(p_id IN NUMBER, p_result OUT VARCHAR2) IS
BEGIN
    UPDATE member SET grade = 'GOLD' WHERE id = p_id;
    IF SQL%ROWCOUNT = 0 THEN
        p_result := '대상 없음';
    ELSE
        p_result := '성공';
        COMMIT;
    END IF;
EXCEPTION
    WHEN OTHERS THEN
        ROLLBACK;
        p_result := '실패: ' || SQLERRM;
        RAISE;
END;
/

-- 함수는 값을 돌려주고 SELECT 안에서도 쓸 수 있습니다
CREATE OR REPLACE FUNCTION total_amount(p_member_id NUMBER) RETURN NUMBER IS
    v_total NUMBER;
BEGIN
    SELECT NVL(SUM(oi.qty * oi.unit_price), 0) INTO v_total
    FROM   orders o JOIN order_item oi ON oi.order_id = o.id
    WHERE  o.member_id = p_member_id;
    RETURN v_total;
END;
/

SELECT name, total_amount(id) FROM member;
SELECT 안에서 함수를 호출하면 행마다 실행됩니다. 회원 10만 명이면 함수가 10만 번 돌고, 그 안에서 또 쿼리가 나갑니다. 조인으로 바꿀 수 있는지 먼저 검토하세요.
PL/SQL 블록 — 세 부분으로 되어 있습니다 DECLARE — 선언부 (없어도 됩니다) 변수 · 상수 · 커서 · 사용자 예외를 미리 만듭니다 BEGIN — 실행부 (필수) SELECT INTO · 반복 · 조건 · DML — 실제로 일을 하는 곳입니다 EXCEPTION — 예외부 NO_DATA_FOUND · TOO_MANY_ROWS · OTHERS. 여기서 안 잡으면 바깥으로 올라갑니다 얻는 것 왕복이 사라집니다 — 10만 건 처리에서 큰 차이 데이터 가까이에서 처리합니다 치르는 것 로직이 DB 안에 숨습니다 — 버전 관리가 어렵습니다 다른 DB 로 옮기기 힘듭니다 한 건씩 도는 커서 반복은 최후의 수단입니다 — 되도록 한 문장으로, 안 되면 벌크 연산으로
PL/SQL 을 쓸지 말지는 취향이 아니라 왕복 비용이 진짜 병목인가로 정합니다.
SECTION 12

패키지 · 벌크 연산

한 건씩 처리하면 SQL 엔진과 PL/SQL 엔진 사이를 계속 오갑니다(컨텍스트 스위치). 묶어서 처리하면 수십 배 빨라집니다.

패키지 — 관련 프로시저를 묶기

-- 명세 (인터페이스)
CREATE OR REPLACE PACKAGE pkg_order IS
    PROCEDURE create_order(p_member_id NUMBER, p_order_id OUT NUMBER);
    FUNCTION  total_of(p_order_id NUMBER) RETURN NUMBER;
END pkg_order;
/

-- 본문 (구현)
CREATE OR REPLACE PACKAGE BODY pkg_order IS
    PROCEDURE create_order(p_member_id NUMBER, p_order_id OUT NUMBER) IS
    BEGIN
        INSERT INTO orders (id, member_id, ordered_at, status)
        VALUES (seq_order.NEXTVAL, p_member_id, SYSDATE, 'PAID')
        RETURNING id INTO p_order_id;
    END;

    FUNCTION total_of(p_order_id NUMBER) RETURN NUMBER IS
        v NUMBER;
    BEGIN
        SELECT SUM(qty * unit_price) INTO v FROM order_item WHERE order_id = p_order_id;
        RETURN NVL(v, 0);
    END;
END pkg_order;
/
패키지의 실질적 이점 — 관련 로직이 한 곳에 모이고, 명세만 바뀌지 않으면 본문을 고쳐도 의존 객체가 무효화되지 않습니다(재컴파일 연쇄 방지). 또 패키지 안에 세션 단위 전역 변수를 둘 수 있습니다.

BULK COLLECT · FORALL — 성능의 핵심

✗ 한 건씩 — 10만 번 왕복
BEGIN
  FOR r IN (SELECT id FROM member WHERE grade='BASIC') LOOP
      UPDATE member SET grade='SILVER' WHERE id = r.id;
  END LOOP;
  COMMIT;
END;
/
-- 10만 건에 수 분
✓ 묶어서 — 한 번에
DECLARE
  TYPE t_ids IS TABLE OF member.id%TYPE;
  v_ids t_ids;
BEGIN
  SELECT id BULK COLLECT INTO v_ids
  FROM   member WHERE grade='BASIC';

  FORALL i IN 1 .. v_ids.COUNT
      UPDATE member SET grade='SILVER'
      WHERE  id = v_ids(i);
  COMMIT;
END;
/
-- 10만 건에 수 초

건수가 많으면 나눠서 담기 (LIMIT)

DECLARE
  CURSOR c IS SELECT id FROM member WHERE grade='BASIC';
  TYPE t_ids IS TABLE OF member.id%TYPE;
  v_ids t_ids;
BEGIN
  OPEN c;
  LOOP
    FETCH c BULK COLLECT INTO v_ids LIMIT 5000;   -- ★ 메모리 폭발 방지
    EXIT WHEN v_ids.COUNT = 0;

    FORALL i IN 1 .. v_ids.COUNT
        UPDATE member SET grade='SILVER' WHERE id = v_ids(i);
    COMMIT;                                        -- 청크마다 커밋
  END LOOP;
  CLOSE c;
END;
/
BULK COLLECT 를 LIMIT 없이 쓰면 위험합니다. 1억 건을 통째로 PGA 메모리에 담으려다 ORA-04030 으로 죽습니다. 항상 LIMIT 을 붙이세요 — 보통 1,000~10,000이 적당합니다.
-- 일부가 실패해도 계속 진행하고 나중에 확인하기
BEGIN
  FORALL i IN 1 .. v_ids.COUNT SAVE EXCEPTIONS
      UPDATE member SET grade='SILVER' WHERE id = v_ids(i);
EXCEPTION
  WHEN OTHERS THEN
    FOR j IN 1 .. SQL%BULK_EXCEPTIONS.COUNT LOOP
      DBMS_OUTPUT.PUT_LINE(
        SQL%BULK_EXCEPTIONS(j).ERROR_INDEX || ' 번째 실패: ' ||
        SQLERRM(-SQL%BULK_EXCEPTIONS(j).ERROR_CODE));
    END LOOP;
END;
/
SECTION 13

힌트와 옵티마이저

힌트는 옵티마이저에게 주는 지시입니다. 잘 쓰면 응급처치, 잘못 쓰면 영구적인 부채가 됩니다.

-- 힌트는 SELECT 바로 뒤 /*+ ... */ 안에 씁니다
SELECT /*+ INDEX(m idx_member_city) */ *
FROM   member m
WHERE  city = '서울';

SELECT /*+ FULL(o) PARALLEL(o, 4) */ COUNT(*)
FROM   orders o;

SELECT /*+ LEADING(m o) USE_NL(o) */ m.name, o.id
FROM   member m JOIN orders o ON o.member_id = m.id;
힌트지시 내용쓸 때
INDEX(t idx)이 인덱스를 써라통계가 틀어져 풀스캔을 탈 때
FULL(t)전체 스캔해라전체의 상당 비율을 읽을 때는 이쪽이 빠름
LEADING(a b)조인 순서를 이렇게작은 테이블부터 몰고 싶을 때
USE_NL · USE_HASH조인 방식 지정🧬 z08 참고
PARALLEL(t, n)병렬로 처리대용량 배치에만 (EE 옵션)
APPEND직접 경로 INSERT대량 적재 (로그 최소화 · 락 주의)
GATHER_PLAN_STATISTICS실제 실행 통계 수집튜닝할 때 항상 켜는 힌트
힌트를 쓰기 전에 반드시 확인하세요.
① 통계가 최신인가 — 대부분의 "이상한 계획"은 통계가 낡아서입니다
② 인덱스가 적절한가 — 힌트로 억지로 태우는 것보다 인덱스를 고치는 게 근본 해결입니다
③ 데이터가 변해도 유효한가 — 오늘 맞는 힌트가 6개월 뒤에는 최악이 될 수 있습니다
힌트는 주석이라 문법 오류가 나도 조용히 무시됩니다. 오타를 내면 아무 일도 안 일어납니다.

통계 수집

-- 특정 테이블
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP', 'ORDERS');

-- 스키마 전체
EXEC DBMS_STATS.GATHER_SCHEMA_STATS('APP');

-- 마지막 수집 시각 확인 ← 느려졌을 때 여기부터 봅니다
SELECT table_name, num_rows, last_analyzed
FROM   user_tables
ORDER  BY last_analyzed;

계획을 고정하는 방법 (힌트보다 나은 선택)

  • SQL Plan Baseline — 좋은 계획을 등록해 두고 그것만 쓰게 합니다. 힌트를 코드에 박지 않아도 됩니다.
  • SQL Profile — 옵티마이저의 추정을 보정합니다. SQL Tuning Advisor 가 만들어 줍니다.
  • SQL Patch — 코드를 못 고치는 패키지 제품에 힌트를 외부에서 주입합니다.
이 기능들은 대부분 Enterprise Edition + 튜닝팩이 필요합니다. SE2 에서는 힌트와 통계 관리가 사실상 유일한 수단입니다.
SECTION 14

실행계획 · AWR · ASH

"느리다"를 숫자로 바꾸는 도구들입니다. 오라클이 다른 DB보다 확실히 앞서는 영역입니다.

① 실행계획 — 추정과 실제를 함께 보기

-- 추정 계획만 (실행하지 않음)
EXPLAIN PLAN FOR
SELECT * FROM orders WHERE member_id = 1;

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- ★ 실제로 돌려 보고 추정 vs 실제 비교 (튜닝의 기본)
SELECT /*+ GATHER_PLAN_STATISTICS */ *
FROM   orders WHERE member_id = 1;

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST'));
| Id | Operation | Name | Starts | E-Rows | A-Rows | Buffers | | 0 | SELECT STATEMENT | | 1 | | 3 | 5 | | 1 | TABLE ACCESS BY INDEX ROWID| ORDERS | 1 | 3 | 3 | 5 | |* 2 | INDEX RANGE SCAN | IDX_ORD_MEM | 1 | 3 | 3 | 2 |
E-Rows(추정)와 A-Rows(실제)의 차이가 핵심입니다. 추정 3행인데 실제 300만 행이면 옵티마이저가 완전히 잘못 판단한 것이고, 그 원인은 대부분 통계 부실입니다. Buffers 는 읽은 블록 수로, 실질적 비용 지표입니다.

② 지금 뭐가 도는지 — V$ 뷰

-- 현재 활성 세션과 대기 이벤트
SELECT s.sid, s.serial#, s.username, s.status,
       s.event, s.wait_class, s.seconds_in_wait,
       q.sql_text
FROM   v$session s
LEFT   JOIN v$sql q ON q.sql_id = s.sql_id
WHERE  s.type = 'USER' AND s.status = 'ACTIVE'
ORDER  BY s.seconds_in_wait DESC;

-- 락을 기다리는 세션 (블로킹 체인)
SELECT blocking_session, sid, serial#, wait_class, seconds_in_wait
FROM   v$session
WHERE  blocking_session IS NOT NULL;

-- 문제 세션 강제 종료 (최후의 수단)
ALTER SYSTEM KILL SESSION '123,45678' IMMEDIATE;

③ AWR · ASH — 과거를 추적

# AWR 리포트 생성 (SQL*Plus 에서)
@?/rdbms/admin/awrrpt.sql        # 기간을 선택하면 HTML/텍스트 리포트 생성

# 특정 SQL 하나만
@?/rdbms/admin/awrsqrpt.sql

# ASH 리포트 (짧은 구간의 세션 샘플)
@?/rdbms/admin/ashrpt.sql
AWR 에서 볼 것의미
DB Time세션들이 실제로 일한 총 시간. 경과 시간보다 크면 동시 부하가 높은 것
Top 10 Foreground Events무엇을 기다렸는가 — db file sequential read(인덱스 IO), enq: TX - row lock contention(락 경합) 등
SQL ordered by Elapsed Time가장 오래 걸린 쿼리 — 튜닝 대상 1순위
SQL ordered by Gets가장 많이 읽은 쿼리 — 인덱스 문제일 가능성이 높음
Buffer Hit %메모리에서 처리된 비율. 낮으면 SGA 부족 또는 잘못된 스캔
AWR·ASH·ADDM 은 Enterprise Edition + Diagnostic Pack 라이선스가 필요합니다. SE2 나 Free 에서는 무료 대안인 Statspack 을 쓰세요 (@?/rdbms/admin/spcreate.sql 로 설치). 라이선스 없이 AWR을 조회하는 것만으로도 감사 대상이 됩니다.
"느리다"에서 시작해 한 쿼리까지 좁혀 내려갑니다 ① 지금 느리다 ASH — 활성 세션 샘플 무엇을 기다리는지 봅니다 ② 기간 전체 AWR — 스냅샷 사이의 부하 상위 목록 ③ SQL_ID 확보 가장 무거운 쿼리 하나를 지목합니다 ④ 실행계획 실제 계획을 꺼내 봅니다 EXPLAIN PLAN 과 다른 점 EXPLAIN 은 "이렇게 할 예정"이고, DBMS_XPLAN.DISPLAY_CURSOR 는 "실제로 이렇게 했다"입니다 볼 곳은 두 군데뿐입니다 예상 행 수와 실제 행 수가 크게 어긋난 단계 — 통계가 낡았다는 뜻입니다. 그리고 전체 스캔이 도는 자리 AWR · ASH 는 Diagnostics Pack 라이선스가 필요합니다 없는 환경이라면 Statspack 이나 v$ 뷰를 직접 조회하는 방법을 씁니다 — 라이선스 확인 없이 켜면 나중에 비용 문제가 됩니다
도구를 늘리기 전에 ①②③④ 순서를 지키는 것이 훨씬 빠릅니다.
"느리다"에서 시작해 한 쿼리까지 좁혀 내려갑니다 ① 지금 느리다 ASH — 활성 세션 샘플 무엇을 기다리는지 봅니다 ② 기간 전체 AWR — 스냅샷 사이의 부하 상위 목록 ③ SQL_ID 확보 가장 무거운 쿼리 하나를 지목합니다 ④ 실행계획 실제 계획을 꺼내 봅니다 EXPLAIN PLAN 과 다른 점 EXPLAIN 은 "이렇게 할 예정"이고, DBMS_XPLAN.DISPLAY_CURSOR 는 "실제로 이렇게 했다"입니다 볼 곳은 두 군데뿐입니다 예상 행 수와 실제 행 수가 크게 어긋난 단계 — 통계가 낡았다는 뜻입니다. 그리고 전체 스캔이 도는 자리 AWR · ASH 는 Diagnostics Pack 라이선스가 필요합니다 없는 환경이라면 Statspack 이나 v$ 뷰를 직접 조회하는 방법을 씁니다 — 라이선스 확인 없이 켜면 나중에 비용 문제가 됩니다
도구를 늘리기 전에 ①②③④ 순서를 지키는 것이 훨씬 빠릅니다.
SECTION 15

파티셔닝 · 인덱스 종류

수억 건 테이블을 조각으로 나눠 관리합니다. 조회도 빨라지고 오래된 데이터를 통째로 버릴 수도 있습니다.

-- RANGE 파티션 + 자동 확장 (INTERVAL) ★ 실무에서 가장 많이 씁니다
CREATE TABLE orders (
    id          NUMBER,
    member_id   NUMBER,
    ordered_at  DATE NOT NULL,
    amount      NUMBER(12,2)
)
PARTITION BY RANGE (ordered_at)
INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))     -- 새 달 데이터가 오면 파티션 자동 생성
(
    PARTITION p_2025 VALUES LESS THAN (DATE '2026-01-01')
);

-- 오래된 데이터 즉시 제거 (DELETE 와 비교할 수 없이 빠릅니다)
ALTER TABLE orders DROP PARTITION p_2025;

-- 특정 파티션만 조회
SELECT * FROM orders PARTITION (p_2025);
방식기준쓰는 곳
RANGE값의 범위 (주로 날짜)★ 로그·주문·이력 — 가장 흔함
LIST값의 목록 (지역·상태)구분이 명확하고 개수가 적을 때
HASH해시 분산고르게 나누고 싶을 때 (범위 기준이 없을 때)
COMPOSITERANGE + HASH 등 조합초대용량 (월별 + 지역별)
파티셔닝은 Enterprise Edition 유료 옵션입니다. SE2 에서는 쓸 수 없습니다. 이 경우 테이블을 물리적으로 나누고 UNION ALL 뷰로 묶는 옛 방식(파티션 뷰)을 쓰거나, 아카이빙 전략을 다르게 세워야 합니다.

파티션 인덱스 — LOCAL vs GLOBAL

LOCAL — 파티션마다 별도 인덱스
CREATE INDEX idx_ord_mem ON orders(member_id) LOCAL;

-- 장점: DROP PARTITION 해도 인덱스가 안 깨짐
-- 운영 부담이 적어 대부분 이쪽을 씁니다
GLOBAL — 전체를 하나로
CREATE INDEX idx_ord_mem ON orders(member_id) GLOBAL;

-- 파티션 키가 없는 조회는 빠르지만
-- DROP PARTITION 시 인덱스가 UNUSABLE 이 됩니다
-- (UPDATE INDEXES 절로 유지 가능하나 느림)

오라클의 인덱스 종류

종류언제 쓰나
B-Tree (기본)대부분의 경우. 값의 종류가 많을수록 유리
비트맵값의 종류가 적고(성별·상태) 변경이 드문 DW 성 테이블. OLTP 에서 쓰면 락 경합이 심각해집니다
함수 기반UPPER(name) 처럼 함수를 씌워 조회할 때
역방향 키순차 증가 PK 로 인덱스 우측 블록에 경합이 심할 때 (RAC 환경)
IOT테이블 자체를 인덱스 구조로. PK 조회가 압도적일 때
도메인Oracle Text(전문 검색) 등 확장 인덱스
-- 함수 기반 인덱스 (대소문자 무시 조회를 인덱스로)
CREATE INDEX idx_member_upper_name ON member (UPPER(name));
SELECT * FROM member WHERE UPPER(name) = 'KIM';    -- 이제 인덱스를 탑니다

-- 온라인으로 만들기 (서비스 중 락 최소화)
CREATE INDEX idx_ord_dt ON orders(ordered_at) ONLINE;
파티셔닝 — 큰 표를 조각으로 나눠 둡니다 orders — 논리적으로는 표 하나 (5억 행) 2024년 조각 읽지 않음 2025년 조각 읽지 않음 2026년 조각 이것만 읽습니다 기타 조각 읽지 않음 조회가 빨라집니다 WHERE order_date >= '2026-01-01' 해당 조각만 읽습니다 (파티션 프루닝) 삭제가 순식간입니다 2024년 조각을 통째로 떼어 냅니다 DELETE 로 1억 행 지우는 것과 비교됩니다 조회 조건에 기준 컬럼이 없으면 모든 조각을 다 읽습니다 — 효과가 통째로 사라집니다 기준을 고르는 법 — 대부분의 조회가 걸어 주는 조건이 무엇인지 먼저 확인하세요. 보통 날짜입니다
파티셔닝은 인덱스의 대체품이 아닙니다 — 큰 덩어리를 안 읽게 만드는 장치입니다.
SECTION 16

RAC · Data Guard · RMAN

장애가 나도 서비스가 멈추지 않게 하는 구성입니다. 금융·통신에서는 기본 요건입니다.

RAC — 서버를 늘린다 · 스토리지는 하나 노드 1 (액티브) 노드 2 (액티브) 공유 스토리지 서버가 죽어도 서비스는 계속 — 하지만 스토리지가 죽으면 둘 다 멈춘다 Data Guard — 데이터를 통째로 다른 곳에 복제 Primary 본사 전산실 redo 전송 Standby 재해복구 센터 전환 시간은 보호 모드가 결정한다 최대 성능 — 빠르지만 마지막 몇 초를 잃을 수 있음 최대 보호 — 한 건도 안 잃지만 응답이 원격에 묶임 RAC 는 서버 장애, Data Guard 는 사이트 장애를 막습니다 — 둘은 대체재가 아니라 함께 쓰는 조합입니다
가용성 설계의 첫 질문은 "무엇이 죽는 경우를 막고 싶은가"입니다 — 서버인지, 전산실 전체인지.
기술해결하는 문제주의
RAC 여러 서버가 같은 스토리지를 공유해 한 DB를 함께 처리. 한 노드가 죽어도 나머지가 계속 서비스합니다. 공유 스토리지가 단일 장애점. 유료 옵션이며 구성이 복잡합니다
Data Guard 다른 위치에 대기 DB를 두고 로그를 복제. 재해(DR) 대비와 읽기 부하 분산. Active Data Guard(대기 DB에서 조회)는 별도 옵션
RMAN 오라클 공식 백업·복구 도구. 증분 백업·블록 단위 복구·시점 복구 지원. EE·SE2 모두 사용 가능
Flashback 실수로 지운 데이터를 과거 시점으로 되살립니다. UNDO 보존 기간·플래시백 로그 설정 필요

RMAN 기본

rman target /

RMAN> BACKUP DATABASE PLUS ARCHIVELOG;          # 전체 백업 + 아카이브 로그
RMAN> BACKUP INCREMENTAL LEVEL 1 DATABASE;      # 증분 백업
RMAN> LIST BACKUP SUMMARY;                      # 백업 목록
RMAN> VALIDATE DATABASE;                        # 백업본 검증 ← 정기적으로 하세요
RMAN> DELETE OBSOLETE;                          # 보존 정책 초과분 정리

# 복구
RMAN> RESTORE DATABASE;
RMAN> RECOVER DATABASE UNTIL TIME "TO_DATE('2026-08-05 14:00','YYYY-MM-DD HH24:MI')";

Flashback — 실수 되돌리기

-- 10분 전 데이터 보기
SELECT * FROM member AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '10' MINUTE);

-- 테이블을 통째로 되돌리기 (행 이동 허용 필요)
ALTER TABLE member ENABLE ROW MOVEMENT;
FLASHBACK TABLE member TO TIMESTAMP (SYSTIMESTAMP - INTERVAL '10' MINUTE);

-- DROP 한 테이블 복구 (휴지통에서)
SELECT object_name, original_name FROM recyclebin;
FLASHBACK TABLE member TO BEFORE DROP;
Flashback Query 는 오라클의 킬러 기능입니다. "10분 전에 잘못 UPDATE 했다"는 상황에서 백업 복구 없이 즉시 확인·복원할 수 있습니다. 다만 UNDO 보존 기간(undo_retention) 안에서만 가능합니다.
백업의 진짜 요건은 "복구해 본 적이 있는가"입니다. 백업 스크립트가 매일 도는 것은 아무 의미가 없습니다. 분기마다 실제로 복구 훈련을 하세요. "백업 파일은 있는데 복구가 안 되는" 상황이 실제로 가장 흔한 재해입니다.
SECTION 17

탈오라클 마이그레이션

라이선스 비용 때문에 PostgreSQL로 옮기는 기업이 계속 늘고 있습니다. 무엇이 걸림돌인지 미리 알면 견적이 달라집니다.

난이도별 항목

🟢 쉬움 — 자동 변환
  • 기본 DDL·자료형 (VARCHAR2 → VARCHAR)
  • 표준 SELECT·JOIN·집계
  • NVL → COALESCE, DECODE → CASE
  • 시퀀스 → IDENTITY
  • FETCH FIRST 페이징 (12c 이상이면 그대로)
🔵 보통 — 손봐야 함
  • ROWNUM 페이징 → LIMIT
  • CONNECT BY → WITH RECURSIVE
  • DATE의 시분초 차이
  • LISTAGG → STRING_AGG
  • MERGE → ON CONFLICT
  • 계층·분석 함수 일부
🟣 어려움 — 재작성
  • PL/SQL 패키지 전체 → PL/pgSQL
  • 빈 문자열 = NULL 동작 차이
  • 계층적 힌트·계획 고정
  • DBMS_* 내장 패키지 의존
  • Oracle Text · Spatial
  • 운영 스크립트·모니터링 전부

반드시 확인할 동작 차이

항목Oracle → PostgreSQL 로 갈 때
빈 문자열Oracle은 '' = NULL. PG는 다름 → 데이터·조건 양쪽 점검
식별자 대소문자Oracle은 대문자로 저장, PG는 소문자 → 큰따옴표로 만든 객체가 문제
기본 격리 수준둘 다 READ COMMITTED 지만 구현이 다릅니다(⚙️ t11 · 🧬 z05)
DDL 트랜잭션Oracle은 자동 커밋, PG는 롤백 가능 → 마이그레이션 스크립트에 유리
NUMBERPG의 NUMERIC 은 정확하지만 느립니다 → 정수는 BIGINT 로
정렬 순서NLS_SORT vs COLLATE — 한글 정렬 결과가 달라질 수 있습니다

도구

  • ora2pg — 오라클 스키마·데이터·PL/SQL을 PostgreSQL로 변환. 변환 난이도 리포트를 먼저 뽑아 볼 수 있습니다
  • AWS SCT / DMS — 클라우드 이전과 함께 갈 때
  • orafce 확장 — PostgreSQL에서 오라클 함수(NVL·DECODE 등)를 흉내 내 코드 수정량을 줄입니다
현실적인 순서 — ① ora2pg 로 난이도 리포트를 뽑아 PL/SQL 양을 확인하고 ② 신규 기능부터 PostgreSQL로 만들고 ③ 읽기 전용 리포트를 옮겨 검증하고 ④ 마지막에 기간계를 옮깁니다. 한 번에 전부 옮기는 빅뱅 방식은 대부분 실패합니다.
POSTGRESQL 18 · JSONB · MVCC · 확장 생태계

PostgreSQL —
무료인데
가장 강력한

라이선스 비용이 0인데 표준 SQL 준수도는 가장 높고, JSONB · 배열 · 전문검색 · 지리 · 벡터까지 확장 하나로 붙습니다. 대신 MVCC 구조 때문에 VACUUM 관리가 필요하고, 이걸 모르면 어느 날 디스크가 가득 찹니다. 실무에서 반드시 알아야 할 것을 순서대로 정리합니다.

17SECTIONS
18기준 버전
확장PostGIS · pgvector
SECTION 01

PostgreSQL은 어떤 DB인가

1986년 버클리에서 시작해 40년 가까이 이어온 오픈소스 DB입니다. 특정 회사 소유가 아니라 커뮤니티가 개발합니다 — 라이선스 정책이 바뀔 위험이 없다는 뜻입니다.

특징내용
라이선스PostgreSQL 라이선스(BSD 계열) — 상용 사용·수정·재배포 전부 자유
표준 준수주요 DB 중 가장 높습니다. 여기서 배운 SQL이 다른 DB로 잘 옮겨집니다
확장성타입·연산자·인덱스·언어를 사용자가 추가할 수 있는 구조. PostGIS·pgvector·TimescaleDB 가 여기서 나옵니다
프로세스 모델커넥션 하나 = OS 프로세스 하나. 그래서 커넥션 풀이 필수입니다
MVCC수정 시 새 행을 만들고 옛 행을 남깁니다 → VACUUM 필요 (p11)
릴리스 주기매년 가을 메이저 1회, 각 버전 5년 지원
버전 선택 — 새로 시작한다면 최신 안정 버전에서 하나 뒤(N-1)가 무난합니다. 확장·클라우드 서비스의 지원이 따라오는 데 시간이 걸리기 때문입니다. 2026년 8월 기준 17 또는 18이 현실적인 선택입니다.

언제 PostgreSQL이 아닌가

  • 회사가 이미 오라클/MySQL로 돌아갈 때 — 기술적 우위보다 운영 경험이 중요합니다
  • 단순 조회만 초당 수만 건 — MySQL이 더 가벼울 수 있습니다(다만 요즘은 차이가 크지 않습니다)
  • 파일 하나로 끝내야 할 때 — SQLite
  • DBA 없이 커넥션 수천 개를 다룰 때 — PgBouncer 같은 풀러를 반드시 붙여야 합니다
SECTION 02

설치 — 전 OS · Docker

설치는 쉽습니다. 중요한 것은 데이터 디렉터리와 접속 설정 파일의 위치를 아는 것입니다.

# Ubuntu · Debian — 공식 PGDG 저장소를 쓰면 최신 버전을 씁니다
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-18

# RHEL · Rocky
sudo dnf install -y postgresql18-server
sudo /usr/pgsql-18/bin/postgresql-18-setup initdb
sudo systemctl enable --now postgresql-18

# macOS
brew install postgresql@18 && brew services start postgresql@18

# Docker
docker run -d --name pg -p 5432:5432 \
  -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=shop -e TZ=Asia/Seoul \
  -v pgdata:/var/lib/postgresql postgres:18   # 18+ 는 …/data 가 아니라 여기

중요한 파일 세 개

파일역할
postgresql.conf서버 설정 전부 (메모리·로그·복제) → p17
pg_hba.conf누가 어디서 어떻게 접속할 수 있는지 — 접속 문제의 절반이 여기
PGDATA 디렉터리실제 데이터 파일. Ubuntu는 /var/lib/postgresql/18/main
-- 위치를 잊었을 때 SQL 로 확인
SHOW config_file;
SHOW hba_file;
SHOW data_directory;

pg_hba.conf — 접속 규칙

# TYPE  DATABASE  USER  ADDRESS         METHOD
local   all       all                   peer          # 유닉스 소켓: OS 계정과 DB 계정이 같아야
host    all       all   127.0.0.1/32    scram-sha-256 # 로컬 TCP
host    shop      app   10.0.1.0/24     scram-sha-256 # 사설망의 app 계정만
hostssl all       all   0.0.0.0/0       scram-sha-256 # 외부는 SSL 필수로

# 위에서부터 순서대로 검사해 처음 일치하는 규칙을 씁니다 ← 순서가 중요
# 고친 뒤: sudo systemctl reload postgresql  (재시작 불필요)
trust 는 절대 쓰지 마세요. 비밀번호 없이 누구나 접속됩니다. 또 md5 는 구식입니다 — scram-sha-256 을 쓰세요(PostgreSQL 14부터 기본값). 옛 클라이언트가 붙지 못하면 클라이언트를 올리는 것이 맞습니다.
Ubuntu에서 sudo -u postgres psql 이 되는 이유 — local … peer 규칙 때문입니다. OS 계정 postgres 로 실행하면 같은 이름의 DB 계정으로 비밀번호 없이 접속됩니다. 비밀번호로 접속하고 싶으면 psql -h 127.0.0.1 -U postgres 처럼 TCP로 붙으세요.
SECTION 03

psql 완전정복

서버에서는 GUI를 못 씁니다. psql의 역슬래시 명령 열 개만 알아도 작업 속도가 완전히 달라집니다.

명령하는 일
\l데이터베이스 목록
\c shopshop 으로 이동
\dt테이블 목록 (\dt+ 는 크기까지)
\d member테이블 구조 · 인덱스 · 제약 (가장 많이 씁니다)
\di · \dv · \df인덱스 · 뷰 · 함수 목록
\du역할(계정) 목록과 권한
\dn스키마 목록
\x세로 출력 토글 — 컬럼이 많을 때 필수
\timing쿼리 실행 시간 표시 토글
\e편집기로 쿼리 작성 (긴 쿼리에 유용)
\i file.sql파일 실행
\copy클라이언트 쪽 CSV 입출력 (서버 권한 불필요)
\watch 2직전 쿼리를 2초마다 반복 — 모니터링에 좋습니다
\? · \h SELECT명령 도움말 · SQL 문법 도움말
\q종료

CSV 입출력

# 내보내기 (클라이언트 파일로)
\copy (SELECT * FROM member WHERE grade='GOLD') TO 'gold.csv' WITH CSV HEADER

# 가져오기
\copy member FROM 'member.csv' WITH CSV HEADER

# 서버 파일 시스템을 쓰는 COPY 는 슈퍼유저 권한이 필요합니다
COPY member FROM '/tmp/member.csv' WITH (FORMAT csv, HEADER true);
COPY 는 압도적으로 빠릅니다. INSERT 를 10만 번 하는 것보다 수십~수백 배 빠르므로 대량 적재는 항상 COPY 를 쓰세요 → 🔌 a10.

셸에서 바로 실행

psql -d shop -c "SELECT count(*) FROM member;"     # 한 줄 실행
psql -d shop -f schema.sql                          # 파일 실행
psql -qtAX -d shop -c "SELECT count(*) FROM member" # 값만 (스크립트용)

# 접속 정보를 환경 변수로
export PGHOST=db.example.com PGPORT=5432 PGUSER=app PGDATABASE=shop
export PGPASSWORD=...        # 또는 ~/.pgpass 파일 (권장)
# ~/.pgpass  (권한은 반드시 600)
# host:port:database:user:password
db.example.com:5432:shop:app:strong-pass
SECTION 04

역할 · 권한 · 스키마

PostgreSQL은 사용자와 그룹을 ROLE 하나로 다룹니다. 그리고 스키마라는 폴더 개념이 있어 한 DB 안을 나눠 쓸 수 있습니다.

-- 로그인 가능한 역할 = 사용자
CREATE ROLE app LOGIN PASSWORD 'strong-pass';
-- CREATE USER app PASSWORD '…' 와 같습니다

-- 로그인 못 하는 역할 = 그룹
CREATE ROLE readonly;
GRANT readonly TO analyst1, analyst2;

-- 권한 부여
GRANT CONNECT ON DATABASE shop TO app;
GRANT USAGE   ON SCHEMA public TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app;
GRANT USAGE   ON ALL SEQUENCES IN SCHEMA public TO app;   -- ★ 자동증가 컬럼에 필요

-- 앞으로 만들어질 객체에도 자동 적용 (이걸 빠뜨려서 자주 막힙니다)
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT USAGE ON SEQUENCES TO app;
PostgreSQL 15부터 public 스키마의 기본 권한이 바뀌었습니다. 이전에는 모든 사용자가 public 에 테이블을 만들 수 있었지만, 15부터는 DB 소유자만 가능합니다. 마이그레이션 시 GRANT CREATE ON SCHEMA public TO app; 이 필요할 수 있습니다.

스키마 — 한 DB 안의 네임스페이스

CREATE SCHEMA sales AUTHORIZATION app;
CREATE TABLE sales.orders (...);

-- search_path : 스키마를 안 적었을 때 찾는 순서
SHOW search_path;                    -- "$user", public
SET search_path TO sales, public;    -- 세션 단위
ALTER ROLE app SET search_path TO sales, public;   -- 계정 기본값으로
스키마를 쓰는 실전 패턴 — ① 멀티테넌트에서 고객사별 스키마 ② 한 DB에서 서비스별 분리(주문·회원·정산) ③ 마이그레이션 시 신구 버전 병행
다만 스키마가 수백 개가 되면 pg_dump·통계 관리가 급격히 무거워집니다.

행 수준 보안 (RLS) — 같은 표에서 자기 데이터만

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY own_orders ON orders
  USING (member_id = current_setting('app.member_id')::int);

-- 애플리케이션에서 세션마다 설정
SET app.member_id = '42';
SELECT * FROM orders;    -- 42번 회원의 주문만 보입니다
RLS는 강력하지만 신중히 — 정책이 모든 쿼리에 자동으로 붙으므로 실행계획과 성능에 영향을 줍니다. 또 테이블 소유자와 슈퍼유저는 기본적으로 우회합니다 (FORCE ROW LEVEL SECURITY 로 강제 가능).
SECTION 05

RETURNING · UPSERT · DISTINCT ON

PostgreSQL을 쓰면서 이 셋을 모르면 쓸데없는 왕복 쿼리를 계속 날리게 됩니다.

RETURNING — 바꾼 행을 바로 돌려받기

-- INSERT 하고 생성된 id 를 바로 받기 (SELECT 를 또 할 필요 없음)
INSERT INTO member (name, email) VALUES ('한바다','han@example.com')
RETURNING id, name, joined_at;

-- UPDATE 한 행이 무엇이었는지 확인
UPDATE product SET stock = stock - 1
WHERE  id = 10 AND stock > 0
RETURNING id, name, stock;
-- 0행이 돌아오면 재고가 없었다는 뜻 → 애플리케이션에서 바로 분기 가능

-- DELETE 한 내용을 로그로 남기기
DELETE FROM cart WHERE member_id = 1 RETURNING *;
재고 차감의 정석 — UPDATE … WHERE stock > 0 RETURNING 한 문장이면 조회 → 확인 → 차감의 경합 문제 없이 원자적으로 처리됩니다. 영향 행 수가 0이면 실패로 처리하세요.

UPSERT — ON CONFLICT

-- 있으면 누적, 없으면 삽입
INSERT INTO stock (product_id, qty) VALUES (10, 5)
ON CONFLICT (product_id)
DO UPDATE SET qty = stock.qty + EXCLUDED.qty, updated_at = now();
--                    ↑ 기존 행        ↑ 넣으려던 새 값

-- 이미 있으면 조용히 넘어가기
INSERT INTO tag (name) VALUES ('sale') ON CONFLICT DO NOTHING;

-- 조건부 업데이트
INSERT INTO price (product_id, amount, updated_at) VALUES (10, 45000, now())
ON CONFLICT (product_id) DO UPDATE
  SET amount = EXCLUDED.amount, updated_at = EXCLUDED.updated_at
  WHERE price.amount <> EXCLUDED.amount;      -- 값이 바뀔 때만 UPDATE

DISTINCT ON — 키별 최신 1건 (PostgreSQL 전용)

-- 회원별 가장 최근 주문 1건
SELECT DISTINCT ON (member_id) member_id, id, ordered_at
FROM   orders
ORDER  BY member_id, ordered_at DESC;     -- ★ DISTINCT ON 컬럼이 ORDER BY 맨 앞이어야 합니다
DISTINCT ON — 짧고 빠름
SELECT DISTINCT ON (member_id)
       member_id, id, ordered_at
FROM   orders
ORDER  BY member_id, ordered_at DESC;
표준 방식 — 다른 DB에서도 동작
SELECT member_id, id, ordered_at
FROM  (SELECT *, ROW_NUMBER() OVER
         (PARTITION BY member_id ORDER BY ordered_at DESC) rn
       FROM orders) t
WHERE  rn = 1;
그 밖의 PostgreSQL 편의 문법
· INSERT … SELECT … RETURNING 조합 가능
· UPDATE … FROM other_table 로 다른 표 참조 갱신
· DELETE … USING other_table
· GENERATED ALWAYS AS (a * b) STORED — 계산 컬럼(12+)
· MERGE — 15부터 표준 문법도 지원
SECTION 06

배열 · 범위 · ENUM 타입

한 칸에 여러 값을 담거나, 구간 자체를 값으로 다룹니다. 테이블 하나를 줄일 수 있는 도구입니다.

배열

CREATE TABLE post (
  id    SERIAL PRIMARY KEY,
  title TEXT,
  tags  TEXT[]                       -- 문자열 배열
);
INSERT INTO post (title, tags) VALUES ('DB 입문', ARRAY['db','sql','기초']);

SELECT * FROM post WHERE 'sql' = ANY(tags);        -- 포함 검색
SELECT * FROM post WHERE tags @> ARRAY['db','sql']; -- 둘 다 포함
SELECT title, unnest(tags) AS tag FROM post;        -- 행으로 펼치기
SELECT array_length(tags, 1) FROM post;

CREATE INDEX idx_post_tags ON post USING GIN (tags);   -- 배열 검색 인덱스
배열은 편하지만 관계를 대체하지 않습니다. 태그별 통계·이름 변경·태그 자체의 속성이 필요해지는 순간 별도 테이블 + 조인이 정답입니다. 기준 — 값이 그 행에 종속적이고 개수가 적으면 배열, 독립적으로 관리해야 하면 테이블.

범위 타입 — 구간을 값으로

CREATE EXTENSION IF NOT EXISTS btree_gist;   -- room_id WITH = 를 GiST 에서 쓰려면 필요
CREATE TABLE reservation (
  id      SERIAL PRIMARY KEY,
  room_id INT NOT NULL,
  period  TSRANGE NOT NULL,          -- 시각 구간
  -- ★ 같은 방에 겹치는 예약을 DB가 막아 줍니다
  EXCLUDE USING GIST (room_id WITH =, period WITH &&)
);

INSERT INTO reservation (room_id, period)
VALUES (1, '[2026-08-05 10:00, 2026-08-05 12:00)');

-- 겹치면 여기서 에러 → 애플리케이션 검증 없이 정합성 보장
INSERT INTO reservation (room_id, period)
VALUES (1, '[2026-08-05 11:00, 2026-08-05 13:00)');   -- ✗ 충돌
예약·가격 유효기간 시스템의 정석입니다. "겹치는 예약 방지"를 애플리케이션에서 구현하면 동시 요청에서 반드시 뚫립니다. 배타 제약(EXCLUDE)은 DB가 원자적으로 보장합니다.

ENUM · 도메인

CREATE TYPE order_status AS ENUM ('PAID','SHIPPED','CANCELED');
CREATE TABLE orders (id SERIAL, status order_status NOT NULL DEFAULT 'PAID');

ALTER TYPE order_status ADD VALUE 'REFUNDED';    -- 값 추가는 가능 (삭제는 어려움)

-- 도메인 : 제약을 붙인 재사용 타입
CREATE DOMAIN email AS TEXT CHECK (VALUE ~ '^[^@]+@[^@]+\.[^@]+$');
CREATE TABLE member (id SERIAL, mail email);
ENUM 의 함정 — 값 삭제·순서 변경이 사실상 불가능합니다. 상태값이 자주 바뀌는 도메인이라면 코드 테이블 + 외래키가 유지보수에 훨씬 낫습니다. ENUM은 "절대 안 바뀔 소수의 값"에만 쓰세요.
SECTION 07

JSONB — 문서형으로 쓰기

PostgreSQL 하나로 관계형 + 문서형을 커버합니다. "MongoDB를 도입해야 하나?"라는 질문의 답이 대부분 여기입니다.

CREATE TABLE item (
  id   SERIAL PRIMARY KEY,
  sku  TEXT UNIQUE NOT NULL,
  data JSONB NOT NULL              -- 상품마다 다른 속성들
);

INSERT INTO item (sku, data) VALUES
 ('KB-01', '{"name":"무선 키보드","brand":"LG","price":49000,
             "spec":{"switch":"적축","layout":"텐키리스"},"tags":["무선","사무용"]}');

연산자 — 이 다섯 개면 됩니다

연산자뜻예
->JSON 으로 꺼내기data->'spec'
->>텍스트로 꺼내기data->>'name'
#>>경로로 텍스트 꺼내기data#>>'{spec,switch}'
@>포함 (인덱스 사용 가능)data @> '{"brand":"LG"}'
?키 존재data ? 'discount'
SELECT data->>'name'                AS name,
       (data->>'price')::int         AS price,
       data#>>'{spec,switch}'        AS switch
FROM   item
WHERE  data @> '{"brand":"LG"}'
  AND  (data->>'price')::int < 60000;

-- 배열 펼치기
SELECT sku, jsonb_array_elements_text(data->'tags') AS tag FROM item;

-- 값 수정 (경로 지정)
UPDATE item SET data = jsonb_set(data, '{price}', '45000') WHERE sku = 'KB-01';

-- 키 추가 · 병합
UPDATE item SET data = data || '{"discount":10}'::jsonb WHERE sku = 'KB-01';

-- 키 삭제
UPDATE item SET data = data - 'discount' WHERE sku = 'KB-01';

인덱스

-- ① 범용 GIN — @> · ? 연산자 전부 지원 (인덱스가 큼)
CREATE INDEX idx_item_data ON item USING GIN (data);

-- ② jsonb_path_ops — @> 전용이지만 훨씬 작고 빠릅니다
CREATE INDEX idx_item_data ON item USING GIN (data jsonb_path_ops);

-- ③ 특정 키만 자주 쓴다면 표현식 B-tree 인덱스가 최선
CREATE INDEX idx_item_brand ON item ((data->>'brand'));
SELECT * FROM item WHERE data->>'brand' = 'LG';   -- ← 이 형태여야 인덱스를 탑니다
JSONB 를 남용하면 관계형의 장점을 전부 버리게 됩니다.
· 조건·정렬·조인에 쓰는 값 → 반드시 정식 컬럼으로 (제약·타입·인덱스를 얻습니다)
· 보관만 하는 부가 속성 → JSONB
· 중간 지점이라면 생성 컬럼으로 뽑아내는 방법도 있습니다: ALTER TABLE item ADD COLUMN brand TEXT GENERATED ALWAYS AS (data->>'brand') STORED;
JSON vs JSONB — JSON 은 원문 텍스트 그대로(입력 순서·공백 보존, 조회 때마다 파싱), JSONB 는 이진 저장(파싱 완료, 인덱스 가능, 키 중복 제거, 순서 미보존). 거의 항상 JSONB 입니다.
json 과 jsonb — 특별한 이유가 없으면 jsonb json 받은 문자열 그대로 저장 넣기는 빠릅니다 꺼낼 때마다 다시 해석 · 인덱스 불가 jsonb 넣을 때 분석해 이진으로 저장 키 순서와 중복이 정리됩니다 조회가 빠르고 GIN 인덱스가 됩니다 자주 쓰는 연산자 data->'user' 는 JSON 으로 · data->>'name' 은 텍스트로 · data#>>'{a,b}' 는 경로로 data @> '{"vip": true}' 는 포함 검사 — GIN 인덱스가 이 연산자에 붙습니다 자주 조건을 거는 값은 정식 컬럼으로 빼세요 — JSON 은 모양이 제각각인 부가 정보용입니다 전부 JSON 에 밀어 넣으면 관계형의 장점(제약조건 · 조인 · 통계)을 전부 포기하게 됩니다
jsonb 는 MongoDB 를 대신하려는 기능이 아니라 정형과 비정형을 한 표에 섞기 위한 것입니다.
SECTION 08

인덱스 6종

B-tree만 아는 것과 여섯 종류를 골라 쓰는 것은 수십 배의 성능 차이를 만듭니다.

종류쓰는 곳연산자
B-tree★ 기본값. 등호·범위·정렬 전부= < > BETWEEN LIKE '전방%'
GIN한 행에 값이 여러 개 — JSONB · 배열 · 전문검색@> ? && @@
GiST기하·범위·근접 검색 (PostGIS의 기반)&& <-> @>
BRIN값이 물리 순서와 일치하는 초대용량 (시계열 로그)범위 조건
Hash등호 전용. B-tree 대비 이점이 작아 잘 안 씁니다=
SP-GiST공간 분할 구조 (IP 대역·전화번호 접두사)특수
B-tree — 정렬된 트리 50 10 ~ 40 60 ~ 90 등호 · 범위 · 정렬 전부 — 기본값 GIN — 역색인 'pg' 행 3 · 7 'sql' 행 1 · 7 · 9 값 → 그 값을 가진 행 목록 JSONB · 배열 · 전문검색 BRIN — 블록 요약 블록 1~128 · 1월 블록 129~256 · 2월 ← 여기만 블록 257~384 · 3월 덩어리별 min·max 만 저장 시계열 초대용량 · 수 MB
인덱스는 전부 "찾는 방법이 다른 자료구조"입니다. 데이터 모양에 맞는 구조를 고르는 것이 6종을 배우는 이유입니다.
-- 기본
CREATE INDEX idx_orders_member ON orders (member_id);

-- 복합 (컬럼 순서가 성능을 좌우합니다 → ⚙️ t05)
CREATE INDEX idx_orders_mem_dt ON orders (member_id, ordered_at DESC);

-- 부분 인덱스 : 조건에 맞는 행만 → 인덱스가 훨씬 작아집니다
CREATE INDEX idx_orders_active ON orders (ordered_at)
  WHERE status <> 'CANCELED';

-- 표현식 인덱스
CREATE INDEX idx_member_lower_email ON member (lower(email));
SELECT * FROM member WHERE lower(email) = 'a@b.com';   -- 이 형태여야 탑니다

-- 커버링 인덱스 (INCLUDE · 11+) : 테이블 접근 없이 인덱스만으로 응답
CREATE INDEX idx_orders_cover ON orders (member_id) INCLUDE (ordered_at, status);

-- BRIN : 1억 행 로그 테이블에 인덱스 크기 수 MB
CREATE INDEX idx_log_ts ON log_table USING BRIN (created_at);
운영 중에는 반드시 CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_x ON t (col);
일반 CREATE INDEX 는 쓰기를 통째로 막습니다. CONCURRENTLY 는 시간이 더 걸리고 트랜잭션 안에서는 못 쓰지만 서비스를 멈추지 않습니다. 실패하면 INVALID 상태 인덱스가 남으니 \d 로 확인 후 DROP 하세요.

인덱스 점검 쿼리

-- 안 쓰이는 인덱스 찾기 (지우면 쓰기 성능이 올라갑니다)
SELECT schemaname, relname, indexrelname, idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM   pg_stat_user_indexes
WHERE  idx_scan = 0 AND indexrelid NOT IN (SELECT conindid FROM pg_constraint)
ORDER  BY pg_relation_size(indexrelid) DESC;

-- 테이블·인덱스 크기
SELECT relname,
       pg_size_pretty(pg_total_relation_size(relid)) AS total,
       pg_size_pretty(pg_relation_size(relid))       AS table_only
FROM   pg_catalog.pg_statio_user_tables
ORDER  BY pg_total_relation_size(relid) DESC LIMIT 20;

-- 중복 인덱스 후보 (컬럼 구성이 똑같은 인덱스 · 앞부분만 겹치는 것은 따로 확인)
SELECT indrelid::regclass AS tbl, array_agg(indexrelid::regclass) AS idxs
FROM   pg_index GROUP BY indrelid, indkey HAVING count(*) > 1;
인덱스는 공짜가 아닙니다. INSERT·UPDATE·DELETE 마다 모든 인덱스를 함께 갱신해야 합니다. 인덱스 10개짜리 테이블은 쓰기가 눈에 띄게 느려집니다. 안 쓰이는 인덱스는 지우는 것이 튜닝입니다.
SECTION 09

전문 검색 · 한국어

영어는 기본 기능으로 되지만 한국어는 그냥은 안 됩니다. 실무에서 쓰는 방법을 정리합니다.

기본 전문 검색 (영어 기준)

SELECT * FROM post
WHERE  to_tsvector('english', body) @@ to_tsquery('english', 'database & index');

-- 생성 컬럼 + GIN 인덱스 (12+)
ALTER TABLE post ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED;
CREATE INDEX idx_post_tsv ON post USING GIN (tsv);

SELECT title, ts_rank(tsv, q) AS rank
FROM   post, to_tsquery('english','database') q
WHERE  tsv @@ q ORDER BY rank DESC;
한국어는 기본 설정으로 제대로 동작하지 않습니다. PostgreSQL의 기본 파서는 공백 기준으로 잘라서 "데이터베이스를"과 "데이터베이스"를 다른 단어로 봅니다. 조사가 붙는 한국어 특성상 검색이 거의 안 걸립니다.

실무에서 쓰는 세 가지 방법

방법내용평가
pg_bigm 2글자(bigram) 단위로 색인. 설치가 간단하고 부분 일치 검색이 잘 됩니다 ★ 국내에서 가장 많이 쓰입니다
pg_trgm 3글자 단위. LIKE '%…%' 를 인덱스로 처리할 수 있게 해 줍니다 유사도 검색·오타 보정에 유용
mecab-ko + textsearch_ko 형태소 분석기. 가장 정확하지만 설치·운영 부담이 큽니다 클라우드 관리형에서는 대부분 불가
-- pg_trgm : LIKE '%검색어%' 를 인덱스로
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_product_name_trgm ON product USING GIN (name gin_trgm_ops);

SELECT * FROM product WHERE name LIKE '%마우스%';     -- 이제 인덱스를 탑니다

-- 유사도 검색 (오타 보정)
SELECT name, similarity(name, '마우스패드') AS sim
FROM   product
WHERE  name % '마우스패드'                            -- 기본 임계값 0.3
ORDER  BY sim DESC;
검색이 서비스의 핵심이라면 PostgreSQL 만으로는 한계가 있습니다. 자동완성·동의어·랭킹 튜닝·오타 교정이 필요하면 Elasticsearch·OpenSearch·Meilisearch 를 옆에 두는 것이 정석입니다. 부가 기능 수준의 검색이라면 pg_trgm 으로 충분합니다.
전문 검색 — 문장을 검색용 형태로 바꿔 두고 비교합니다 원문 "빠른 배송 후기" 토큰으로 쪼개고 기본형으로 · 불용어 제거 tsvector 로 저장 '배송':2 '빠르':1 '후기':3 GIN 인덱스 즉시 조회 검색어도 같은 방식으로 tsquery 로 바꿔 맞춰 봅니다 그래서 "배송이" 로 검색해도 "배송" 이 걸립니다 — LIKE '%배송%' 와 결정적으로 다른 점입니다 한국어는 기본 사전으로 잘 안 됩니다 조사와 어미가 붙어 있어 형태소 분석기 확장이 필요합니다 — 설치 권한이 없으면 검색 엔진을 검토하세요 tsvector 컬럼을 따로 두고 트리거나 생성 컬럼으로 자동 갱신하는 방식이 가장 무난합니다 ts_rank 로 관련도 정렬까지 됩니다 — 규모가 크지 않다면 별도 검색 엔진 없이 충분합니다
LIKE 검색이 느리다면 인덱스를 더 만들 게 아니라 검색 방식 자체를 바꿔야 합니다.
SECTION 10

PL/pgSQL — 함수 · 트리거

DB 안에서 도는 로직을 만듭니다. 오라클 PL/SQL과 사고방식은 같고 문법만 다릅니다.

CREATE OR REPLACE FUNCTION total_amount(p_member_id INT)
RETURNS NUMERIC AS $$
DECLARE
    v_total NUMERIC;
BEGIN
    SELECT COALESCE(SUM(oi.qty * oi.unit_price), 0) INTO v_total
    FROM   orders o JOIN order_item oi ON oi.order_id = o.id
    WHERE  o.member_id = p_member_id AND o.status <> 'CANCELED';
    RETURN v_total;
EXCEPTION
    WHEN OTHERS THEN
        RAISE NOTICE '오류: %', SQLERRM;
        RAISE;      -- RETURN 0 으로 삼키면 오류가 '구매액 0' 으로 둔갑합니다
END;
$$ LANGUAGE plpgsql;

SELECT name, total_amount(id) FROM member;
$$ 는 달러 인용(dollar quoting)입니다. 함수 본문 안에 작은따옴표가 많이 나오므로, 이스케이프 지옥을 피하려고 씁니다. $func$ … $func$ 처럼 이름을 붙일 수도 있습니다(중첩할 때 필요).

여러 행 반환

CREATE OR REPLACE FUNCTION top_members(p_limit INT)
RETURNS TABLE (id INT, name TEXT, total NUMERIC) AS $$
BEGIN
    RETURN QUERY
    SELECT m.id, m.name::text, total_amount(m.id)   -- 반환 타입과 정확히 맞춰야 합니다 (varchar → text)
    FROM   member m
    ORDER  BY total_amount(m.id) DESC
    LIMIT  p_limit;
END;
$$ LANGUAGE plpgsql;

SELECT * FROM top_members(3);

트리거 — 갱신 시각 자동 기록

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at := now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_member_updated
BEFORE UPDATE ON member
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

감사 로그 트리거

CREATE OR REPLACE FUNCTION audit_member()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO member_audit (member_id, before_data, after_data, changed_at, changed_by)
    VALUES (COALESCE(NEW.id, OLD.id), to_jsonb(OLD), to_jsonb(NEW), now(), current_user);
    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_member_audit
AFTER INSERT OR UPDATE OR DELETE ON member
FOR EACH ROW EXECUTE FUNCTION audit_member();
함수 volatility 를 정확히 선언하세요. IMMUTABLE(같은 입력에 항상 같은 출력) · STABLE(한 쿼리 안에서 일정) · VOLATILE(기본값, 매번 다름). 인덱스 표현식에 쓰려면 IMMUTABLE 이어야 하고, 잘못 선언하면 옵티마이저가 잘못된 최적화를 해 결과가 틀어질 수 있습니다.
SECTION 11

MVCC와 VACUUM

PostgreSQL 운영에서 가장 중요한 한 가지를 꼽으라면 이것입니다. 모르고 방치하면 어느 날 디스크가 가득 차거나 DB가 멈춥니다.

① UPDATE 는 덮어쓰지 않는다 — 새 버전을 옆에 만든다 v1 · name='A' 죽은 튜플이 된다 v2 · name='B' 새 트랜잭션이 보는 버전 읽는 쪽이 쓰는 쪽을 막지 않는 이유가 이것입니다 ② v1 을 아직 보고 있는 트랜잭션이 하나라도 있으면 회수할 수 없다 30분째 열린 트랜잭션 v1 이 계속 살아남는다 이런 튜플이 쌓이면 테이블이 데이터보다 커집니다 — bloat ③ VACUUM — 아무도 보지 않는 죽은 튜플의 자리를 재사용 가능하게 만든다 autovacuum 이 평소에 조금씩 청소한다 긴 트랜잭션 · 방치된 복제 슬롯 · 멈춘 구독자가 청소를 막는 3대 원인
PostgreSQL 에서 디스크가 갑자기 차오른다면 답은 대개 하나입니다 — 누군가 트랜잭션을 열어 둔 채 잊었다.

수정하면 새 행이 생깁니다

UPDATE
기존 행을 고치지 않습니다 새 버전 행을 추가하고, 옛 행에는 "여기까지 유효" 표시(xmax)를 남깁니다.
▼
결과
죽은 행(dead tuple)이 쌓입니다 아무도 보지 않는 옛 버전이 테이블 안에 그대로 남아 공간을 차지합니다.
▼
VACUUM
죽은 행의 공간을 재사용 가능하게 파일 크기가 줄지는 않지만, 그 자리를 새 행이 쓸 수 있게 됩니다.
-- 죽은 행이 얼마나 쌓였나 (운영에서 정기적으로 봐야 하는 쿼리)
SELECT relname,
       n_live_tup   AS 살아있는행,
       n_dead_tup   AS 죽은행,
       ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS 죽은비율,
       last_autovacuum, last_autoanalyze
FROM   pg_stat_user_tables
ORDER  BY n_dead_tup DESC LIMIT 20;
명령하는 일락
VACUUM죽은 행 공간을 재사용 가능하게서비스 중 가능
VACUUM ANALYZE+ 통계 갱신서비스 중 가능
VACUUM FULL파일을 다시 써서 실제로 축소테이블 전체 잠금 — 서비스 중단
pg_repack (확장)VACUUM FULL 과 같은 효과를 락 없이★ 운영에서는 이것
VACUUM FULL 을 운영 중에 절대 실행하지 마세요. 테이블 전체에 배타 락이 걸려 조회조차 막힙니다. 100GB 테이블이면 수십 분간 서비스가 멈춥니다. 공간을 실제로 회수해야 한다면 pg_repack 을 쓰거나 점검 시간에 하세요.

autovacuum 튜닝

# postgresql.conf — 기본값은 작은 서버 기준이라 대용량에서는 못 따라갑니다
autovacuum_vacuum_scale_factor = 0.05      # 기본 0.2 → 5% 만 죽어도 청소
autovacuum_vacuum_cost_limit   = 2000      # 기본 200 → 청소 속도 상향
autovacuum_naptime             = 30s
autovacuum_max_workers         = 5

# 특정 테이블만 더 공격적으로 (변경이 잦은 테이블)
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02);

트랜잭션 ID 랩어라운드 — 서비스가 멈추는 사고

-- 남은 여유를 확인 (2억 정도면 경고, 1000만 이하면 비상)
SELECT datname, age(datfrozenxid) AS xid_age,
       2147483648 - age(datfrozenxid) AS 남은여유
FROM   pg_database ORDER BY xid_age DESC;
PostgreSQL의 트랜잭션 ID는 32비트라 약 21억에서 한 바퀴 돕니다. VACUUM이 오래된 행을 "동결(freeze)"해 주지 않으면 DB가 스스로 쓰기를 거부하고 멈춥니다(단일 사용자 모드로만 복구 가능).
원인은 거의 항상 ① 오래 열린 트랜잭션 ② 방치된 복제 슬롯 ③ autovacuum 이 못 따라감.
-- 오래 열린 트랜잭션 찾기 (VACUUM 을 막는 주범)
SELECT pid, usename, state, now() - xact_start AS 지속시간, query
FROM   pg_stat_activity
WHERE  xact_start IS NOT NULL AND now() - xact_start > interval '5 minutes'
ORDER  BY xact_start;

-- 방치된 복제 슬롯 (WAL 이 무한정 쌓입니다)
SELECT slot_name, active, pg_size_pretty(
       pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS 밀린양
FROM   pg_replication_slots;

-- 필요하면 정리
SELECT pg_terminate_backend(12345);
SELECT pg_drop_replication_slot('old_slot');
운영 체크리스트 — ① n_dead_tup 비율 ② 5분 이상 열린 트랜잭션 ③ 비활성 복제 슬롯 ④ xid_age. 이 넷만 대시보드에 올려 두면 PostgreSQL 사고의 대부분을 예방합니다.
SECTION 12

EXPLAIN (ANALYZE, BUFFERS)

느린 쿼리는 추측하지 말고 계획을 보세요. PostgreSQL의 실행계획은 정보량이 매우 풍부합니다.

-- 추정만 (실행 안 함)
EXPLAIN SELECT * FROM orders WHERE member_id = 1;

-- ★ 실제로 실행하고 시간·버퍼까지 (튜닝의 기본형)
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT m.name, count(*)
FROM   member m JOIN orders o ON o.member_id = m.id
GROUP  BY m.name;
HashAggregate (cost=45.20..47.20 rows=200 width=40) (actual time=0.412..0.418 rows=3 loops=1) Buffers: shared hit=12 -> Hash Join (cost=15.30..40.10 rows=1020 width=32) (actual time=0.201..0.380 rows=6 loops=1) Hash Cond: (o.member_id = m.id) -> Seq Scan on orders o (actual time=0.010..0.020 rows=6 loops=1) -> Hash (actual time=0.150..0.151 rows=5 loops=1) -> Seq Scan on member m (actual time=0.008..0.012 rows=5 loops=1) Planning Time: 0.180 ms Execution Time: 0.470 ms

읽는 법 — 이 네 가지만 보세요

볼 것의미
rows 추정 vs 실제 rows=1020(추정) vs rows=6(실제) — 차이가 10배 넘으면 통계 문제
Seq Scan 이 있는가 작은 테이블이면 정상. 큰 테이블에 Seq Scan 이면 인덱스를 검토하세요
Buffers: shared hit / read hit 은 메모리, read 는 디스크. read 가 많으면 캐시 부족 또는 과도한 스캔
loops 이 노드가 몇 번 반복됐는가. loops 가 크면 N+1 형태의 중첩 루프입니다

주요 노드 이름

노드뜻
Seq Scan테이블 전체 읽기
Index Scan인덱스로 찾고 테이블에서 행을 읽음
Index Only Scan인덱스만으로 해결 — 가장 빠릅니다(커버링)
Bitmap Heap Scan인덱스로 위치를 모아 정렬해 한 번에 읽기 (중간 선택도)
Nested Loop한 쪽이 작을 때 유리한 조인
Hash Join큰 테이블끼리 등호 조인
Merge Join양쪽이 정렬돼 있을 때
Sort + external merge Disk메모리 부족으로 디스크 정렬 → work_mem 을 올리세요

슬로우 쿼리 찾기 — pg_stat_statements

-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'  (재시작 필요)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 총 소요 시간 기준 상위 쿼리 ← 튜닝 대상 1순위
SELECT substring(query, 1, 80) AS query,
       calls,
       ROUND(total_exec_time::numeric, 1)      AS 총시간ms,
       ROUND(mean_exec_time::numeric, 2)       AS 평균ms,
       rows
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
LIMIT  20;

SELECT pg_stat_statements_reset();   -- 통계 초기화
평균이 느린 쿼리보다 "총 시간"이 큰 쿼리를 먼저 잡으세요. 100ms 쿼리가 하루 100만 번 도는 것이, 5초 쿼리가 하루 10번 도는 것보다 서버에 훨씬 큰 부담입니다.
계획을 보기 편하게 — EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) 결과를 explain.depesz.com · explain.dalibo.com 같은 시각화 도구에 붙여 넣으면 병목 노드가 색으로 표시됩니다. 큰 계획을 읽을 때 시간이 크게 절약됩니다.
실행계획은 '안쪽 들여쓰기부터' 읽습니다 Sort (cost=… rows=200) (actual rows=48000) ④ 마지막에 정렬 → Hash Join (rows=200) (actual rows=48000) ③ 예상 200 vs 실제 48000 → Seq Scan on orders (actual rows=1200000) ① 전체 스캔 → Index Scan on customers (actual rows=40) ② 인덱스로 40행만 가장 먼저 볼 것 — rows 의 어긋남 예상과 실제가 수백 배 차이 나면 그 아래 단계의 선택이 전부 잘못됩니다 — ANALYZE 로 통계를 갱신하세요 그래도 어긋나면 조건식이 통계를 못 쓰는 모양입니다 EXPLAIN 과 EXPLAIN ANALYZE 앞은 계획만 보고, 뒤는 실제로 실행합니다 UPDATE·DELETE 에 쓸 때는 트랜잭션으로 감싸고 롤백하세요 BUFFERS 를 함께 켜면 디스크에서 읽었는지 캐시였는지 보입니다
계획을 읽는 목적은 하나입니다 — 어느 단계가 행을 가장 많이 만졌나.
실행계획은 '안쪽 들여쓰기부터' 읽습니다 Sort (cost=… rows=200) (actual rows=48000) ④ 마지막에 정렬 → Hash Join (rows=200) (actual rows=48000) ③ 예상 200 vs 실제 48000 → Seq Scan on orders (actual rows=1200000) ① 전체 스캔 → Index Scan on customers (actual rows=40) ② 인덱스로 40행만 가장 먼저 볼 것 — rows 의 어긋남 예상과 실제가 수백 배 차이 나면 그 아래 단계의 선택이 전부 잘못됩니다 — ANALYZE 로 통계를 갱신하세요 그래도 어긋나면 조건식이 통계를 못 쓰는 모양입니다 EXPLAIN 과 EXPLAIN ANALYZE 앞은 계획만 보고, 뒤는 실제로 실행합니다 UPDATE·DELETE 에 쓸 때는 트랜잭션으로 감싸고 롤백하세요 BUFFERS 를 함께 켜면 디스크에서 읽었는지 캐시였는지 보입니다
계획을 읽는 목적은 하나입니다 — 어느 단계가 행을 가장 많이 만졌나.
SECTION 13

파티셔닝

큰 테이블을 날짜 등으로 쪼갭니다. 조회는 필요한 조각만 보고, 오래된 데이터는 DROP 한 줄로 즉시 정리됩니다.

-- 선언적 파티셔닝 (10+)
CREATE TABLE orders (
    id          BIGSERIAL,
    member_id   INT NOT NULL,
    ordered_at  DATE NOT NULL,
    amount      NUMERIC(12,2),
    PRIMARY KEY (id, ordered_at)          -- ★ 파티션 키가 PK 에 포함돼야 합니다
) PARTITION BY RANGE (ordered_at);

CREATE TABLE orders_2026_08 PARTITION OF orders
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE orders_2026_09 PARTITION OF orders
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- 범위 밖 데이터를 받아 줄 기본 파티션 (없으면 INSERT 에러)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

-- 인덱스는 부모에 만들면 자식에 자동 생성됩니다
CREATE INDEX idx_orders_member ON orders (member_id);

운영

-- 오래된 파티션 분리 (즉시 · 락 최소)
-- ※ DEFAULT 파티션이 있으면 CONCURRENTLY 를 못 씁니다 → 그때는 CONCURRENTLY 를 빼고
ALTER TABLE orders DETACH PARTITION orders_2025_01 CONCURRENTLY;
DROP TABLE orders_2025_01;

-- 기존 테이블을 파티션으로 편입
ALTER TABLE orders ATTACH PARTITION orders_2026_10
      FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');

-- 파티션 목록·크기
SELECT c.relname, pg_size_pretty(pg_relation_size(c.oid))
FROM   pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE  i.inhparent = 'orders'::regclass ORDER BY c.relname;
파티션 프루닝이 되려면 WHERE 에 파티션 키가 있어야 합니다. WHERE ordered_at >= '2026-08-01' 은 필요한 파티션만 보지만, WHERE member_id = 1 만 있으면 모든 파티션을 전부 뒤집니다. 파티션 키 선택은 조회 패턴이 결정합니다.
파티션 자동 생성 — PostgreSQL은 오라클의 INTERVAL 파티션 같은 자동 생성이 없습니다. pg_partman 확장을 쓰거나, cron 으로 다음 달 파티션을 미리 만드는 스크립트를 돌리세요. 파티션이 없으면 INSERT 가 실패하므로 반드시 자동화해야 합니다.
언제 파티셔닝을 할까 — 테이블이 수억 행 또는 수백 GB가 되거나, 오래된 데이터를 주기적으로 대량 삭제할 때입니다. 그 이하에서는 인덱스만 잘 잡아도 충분하고, 파티셔닝은 오히려 계획 수립 비용을 늘립니다.
큰 테이블을 기간으로 잘라 둡니다 — 읽을 곳이 줄어듭니다 logs (부모 — 논리 테이블) logs_2026_05 읽지 않습니다 logs_2026_06 읽지 않습니다 logs_2026_07 여기만 읽습니다 logs_2026_08 읽지 않습니다 얻는 것 ① 프루닝 — 기간 조건이 붙으면 해당 파티션만 스캔 ② 오래된 데이터 정리가 순식간에 끝납니다 DELETE 수백만 건 대신 DETACH · DROP 한 번 주의할 것 조건에 파티션 키가 없으면 전부 읽습니다 — 오히려 손해 파티션이 수백 개가 되면 계획 수립 자체가 느려집니다 다음 달 파티션을 미리 만들어 두는 작업을 자동화하세요
파티셔닝은 성능 도구이기 전에 수명 관리 도구입니다 — 지우기가 쉬워집니다.
큰 테이블을 기간으로 잘라 둡니다 — 읽을 곳이 줄어듭니다 logs (부모 — 논리 테이블) logs_2026_05 읽지 않습니다 logs_2026_06 읽지 않습니다 logs_2026_07 여기만 읽습니다 logs_2026_08 읽지 않습니다 얻는 것 ① 프루닝 — 기간 조건이 붙으면 해당 파티션만 스캔 ② 오래된 데이터 정리가 순식간에 끝납니다 DELETE 수백만 건 대신 DETACH · DROP 한 번 주의할 것 조건에 파티션 키가 없으면 전부 읽습니다 — 오히려 손해 파티션이 수백 개가 되면 계획 수립 자체가 느려집니다 다음 달 파티션을 미리 만들어 두는 작업을 자동화하세요
파티셔닝은 성능 도구이기 전에 수명 관리 도구입니다 — 지우기가 쉬워집니다.
SECTION 14

확장 생태계

PostgreSQL의 진짜 무기입니다. 설치 한 줄로 별도 시스템을 대체합니다.

확장하는 일
pg_stat_statements★ 쿼리별 실행 통계 — 운영에 사실상 필수
pg_trgm부분 문자열 검색·유사도 (LIKE '%…%' 인덱스화)
PostGIS지리 정보 — 좌표·거리·영역. 지도 서비스의 표준
pgvector벡터 유사도 검색 — RAG·AI 검색을 별도 벡터 DB 없이
pgcrypto암호화·해시 함수
uuid-osspUUID 생성 (18부터는 uuidv7() 이 내장)
postgres_fdw다른 PostgreSQL의 테이블을 내 DB처럼 조회
TimescaleDB시계열 특화 — 자동 파티셔닝·압축·연속 집계
pg_partman파티션 자동 생성·정리
pg_cronDB 안에서 도는 크론 스케줄러
-- 설치 (서버에 패키지가 깔려 있어야 합니다)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS vector;

-- 사용 가능한 확장 목록
SELECT name, default_version, installed_version, comment
FROM   pg_available_extensions
WHERE  installed_version IS NOT NULL OR name LIKE 'pg_%'
ORDER  BY name;

pgvector — AI 검색을 PostgreSQL로

CREATE EXTENSION vector;

CREATE TABLE doc (
  id        SERIAL PRIMARY KEY,
  content   TEXT,
  embedding VECTOR(1536)                  -- 임베딩 모델의 차원 수
);

-- 근사 최근접 인덱스 (HNSW · 정확도와 속도의 균형)
CREATE INDEX ON doc USING hnsw (embedding vector_cosine_ops);

-- 가장 비슷한 문서 5건 (<=> 는 코사인 거리)
SELECT id, content, embedding <=> '[0.1, 0.2, …]'::vector AS distance
FROM   doc
ORDER  BY embedding <=> '[0.1, 0.2, …]'::vector
LIMIT  5;
RAG 시스템에서 별도 벡터 DB를 도입하기 전에 pgvector 를 먼저 검토하세요. 문서 수백만 건 규모까지는 충분하고, 관계형 데이터와 함께 조인할 수 있다는 큰 장점이 있습니다. (권한·메타데이터 필터링이 SQL 한 줄로 됩니다)

postgres_fdw — 다른 DB를 내 것처럼

CREATE EXTENSION postgres_fdw;
CREATE SERVER remote_db FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (host 'other.example.com', dbname 'legacy', port '5432');
CREATE USER MAPPING FOR app SERVER remote_db OPTIONS (user 'app', password '…');

IMPORT FOREIGN SCHEMA public LIMIT TO (customer)
  FROM SERVER remote_db INTO staging;

SELECT * FROM staging.customer;   -- 원격 테이블을 로컬처럼 조인 가능
클라우드 관리형(RDS·Cloud SQL)에서는 설치 가능한 확장이 제한됩니다. 도입 전에 해당 서비스의 지원 확장 목록을 반드시 확인하세요. 특히 mecab 계열 한글 형태소 확장은 대부분 사용할 수 없습니다.
SECTION 15

복제 · 읽기 분산

같은 데이터를 여러 서버에 복사해 조회를 나누고 장애에 대비합니다.

애플리케이션 앱 서버 연결을 둘로 나눈다 쓰기 읽기 Primary INSERT · UPDATE 전담 WAL 스트리밍 Replica A · B · C 읽기 전용 — 늘려서 조회를 분산 복제 지연 — 대개 수 ms, 부하가 몰리면 수 초 "저장했는데 목록에 안 보여요" — 방금 쓴 값을 곧바로 복제본에서 읽었을 때 나옵니다 쓰기 직후의 조회만 Primary 로 보내면 대부분 해결됩니다 — 전부 Primary 로 되돌리는 건 분산을 포기하는 것
읽기 분산의 대가는 "방금 쓴 것이 아직 안 보일 수 있다"입니다. 그 한 번의 조회만 Primary 로 보내세요.
방식내용쓰는 곳
스트리밍 복제WAL을 그대로 전송. 전체가 똑같이 복제됩니다★ 읽기 분산·장애 대비
논리 복제테이블 단위로 선택 복제. 버전이 달라도 가능부분 복제·무중단 업그레이드
동기 복제복제본 기록까지 확인 후 커밋유실 0 이지만 느려집니다
비동기 복제바로 커밋하고 나중에 전송기본값 — 지연이 생길 수 있음

스트리밍 복제 구성 (요약)

# 주 서버 (postgresql.conf)
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1GB

# pg_hba.conf 에 복제 접속 허용
# host  replication  repl_user  10.0.1.0/24  scram-sha-256

# 복제본 서버에서 : 주 서버를 통째로 복사해 오기
pg_basebackup -h primary.example.com -U repl_user -D /var/lib/postgresql/18/main \
              -Fp -Xs -P -R          # -R 이 standby.signal 과 접속 정보를 만들어 줍니다

sudo systemctl start postgresql
-- 주 서버에서 : 복제 상태와 지연 확인
SELECT client_addr, state, sync_state,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS 지연량
FROM   pg_stat_replication;

-- 복제본에서 : 얼마나 밀렸나
SELECT now() - pg_last_xact_replay_timestamp() AS 복제지연;

-- 승격 (주 서버 장애 시)
SELECT pg_promote();
읽기 복제본에는 "방금 쓴 데이터가 아직 없을 수" 있습니다. 주문을 넣고 바로 조회하는 화면을 복제본으로 보내면 "주문이 안 보인다"는 문의가 들어옵니다. 쓰기 직후 조회는 주 서버로 보내거나, 지연을 감안한 UX를 설계하세요.

논리 복제 — 부분 복제·무중단 업그레이드

-- 주 서버 (wal_level = logical 필요)
CREATE PUBLICATION pub_orders FOR TABLE orders, order_item;

-- 구독 서버
CREATE SUBSCRIPTION sub_orders
  CONNECTION 'host=primary dbname=shop user=repl password=…'
  PUBLICATION pub_orders;

-- 상태 확인
SELECT * FROM pg_stat_subscription;
논리 복제의 실전 용도 — 메이저 버전 업그레이드를 거의 무중단으로 할 수 있습니다. 새 버전 서버를 구독자로 만들어 데이터를 따라오게 한 뒤, 짧은 순간에 애플리케이션 접속만 전환합니다. pg_upgrade 보다 다운타임이 훨씬 짧습니다.
SECTION 16

백업 · PITR · 업그레이드

백업은 있는데 복구해 본 적이 없는 백업은 없는 것과 같습니다.

논리 백업 — pg_dump

# 커스텀 포맷 (압축·병렬 복원 가능) ★ 권장
pg_dump -h localhost -U app -d shop -Fc -f shop.dump

# 병렬 덤프 (디렉터리 포맷)
pg_dump -d shop -Fd -j 4 -f shop_dir/

# 스키마만 · 데이터만
pg_dump -d shop --schema-only -f schema.sql
pg_dump -d shop --data-only   -f data.sql

# 복원
pg_restore -d shop_new -j 4 shop.dump
createdb shop_new && pg_restore -d shop_new shop.dump

# 클러스터 전체 (역할·테이블스페이스 포함)
pg_dumpall -f all.sql
pg_dumpall --globals-only -f globals.sql   # 계정 정보만
pg_dump 는 특정 시점의 일관된 스냅숏을 만들지만, 수백 GB 이상에서는 시간이 너무 오래 걸립니다. 또 덤프 중 오래 열린 트랜잭션이 VACUUM 을 막는 부작용도 있습니다. 대용량은 물리 백업 + PITR 로 가야 합니다.

물리 백업 + PITR — 원하는 시점으로 복구

# postgresql.conf
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /backup/wal/%f && cp %p /backup/wal/%f'
# 실무에서는 pgBackRest · WAL-G · barman 같은 도구를 씁니다
# 베이스 백업
pg_basebackup -D /backup/base -Ft -z -P

# 복구 : 데이터 디렉터리를 베이스로 되돌리고
#        WAL 을 원하는 시각까지만 재생
# postgresql.conf 에
#   restore_command = 'cp /backup/wal/%f %p'
#   recovery_target_time = '2026-08-05 13:59:00'
# 그리고 recovery.signal 파일을 만든 뒤 기동
직접 구성하지 마세요. pgBackRest 나 WAL-G 를 쓰면 증분 백업·병렬·S3 업로드·검증까지 자동입니다. 수동 archive_command 는 실패를 놓치기 쉽고, 그러면 WAL 이 무한정 쌓입니다.

메이저 버전 업그레이드

방법다운타임특징
pg_dump / restore김 (데이터 크기 비례)가장 단순·안전. 소규모에 적합
pg_upgrade수 분--link 옵션이면 매우 빠릅니다. 롤백이 어려움
논리 복제수 초구성은 복잡하지만 대용량 서비스의 정석
업그레이드 전 필수 점검
① 마이너 버전은 재시작만으로 끝나지만 메이저는 데이터 파일 형식이 바뀝니다
② 쓰는 확장이 새 버전을 지원하는지 확인
③ 릴리스 노트의 비호환 변경 확인 (기본값 변경으로 쿼리 동작이 달라질 수 있습니다)
④ 반드시 복사본에서 먼저 리허설
SECTION 17

postgresql.conf 튜닝

기본 설정은 아주 작은 서버 기준입니다. 메모리 관련 설정 몇 개만 고쳐도 체감이 달라집니다.

설정권장 (RAM 16GB 기준)설명
shared_buffers4GB (RAM의 25%)PostgreSQL 전용 캐시. 너무 크게 잡으면 OS 캐시와 중복됩니다
effective_cache_size12GB (RAM의 75%)실제 할당이 아니라 옵티마이저에게 주는 힌트입니다
work_mem16~64MB정렬·해시마다 이만큼 씁니다. 동시 100세션 × 여러 노드면 곱해집니다
maintenance_work_mem1GBVACUUM·인덱스 생성용. 크게 잡을수록 유지보수가 빨라집니다
max_connections100~200크게 잡지 마세요 — 풀러(PgBouncer)를 쓰는 것이 정답
random_page_cost1.1 (SSD)기본 4.0은 HDD 기준. SSD면 반드시 낮추세요 — 인덱스를 더 잘 씁니다
checkpoint_timeout15min길수록 IO 스파이크가 줄지만 복구가 느려집니다
max_wal_size4GB체크포인트 간격. 작으면 체크포인트가 잦아집니다
log_min_duration_statement1000 (ms)★ 1초 넘는 쿼리를 로그로 — 반드시 켜세요
-- 지금 값 확인
SHOW shared_buffers;
SELECT name, setting, unit, source FROM pg_settings WHERE name LIKE '%mem%';

-- 재시작 없이 반영되는 항목
ALTER SYSTEM SET work_mem = '32MB';
SELECT pg_reload_conf();

-- 재시작이 필요한 항목 (pending_restart 로 확인)
SELECT name, setting, pending_restart FROM pg_settings WHERE pending_restart;
work_mem 은 세션당이 아니라 "정렬·해시 노드당"입니다. 복잡한 쿼리 하나가 정렬 3개 + 해시 2개를 쓰면 work_mem × 5 를 씁니다. 여기에 동시 세션 수를 곱하면 순식간에 메모리가 넘칩니다. 전역은 작게, 무거운 배치 세션에서만 크게 잡는 것이 정석입니다: SET LOCAL work_mem = '256MB';

커넥션 풀러는 선택이 아닙니다

PostgreSQL은 커넥션 하나마다 OS 프로세스를 만듭니다. 커넥션 500개면 프로세스 500개이고, 각각 메모리를 차지합니다. PgBouncer 를 앞에 두면 앱은 수천 커넥션을 쓰더라도 실제 DB 커넥션은 수십 개로 유지됩니다.

# pgbouncer.ini
[databases]
shop = host=127.0.0.1 port=5432 dbname=shop

[pgbouncer]
listen_port = 6432
pool_mode = transaction          # ★ 대부분 이것 (커넥션을 트랜잭션 단위로 재사용)
max_client_conn = 2000
default_pool_size = 25           # 실제 DB 커넥션 수
pool_mode = transaction 에서는 세션 단위 기능을 못 씁니다. SET(세션 변수) · 준비된 문(prepared statement) · LISTEN/NOTIFY · 임시 테이블 등이 깨질 수 있습니다. 드라이버 설정에서 서버 측 prepared statement 를 끄거나, PgBouncer 1.21+ 의 준비된 문 지원을 켜세요.
설정값 계산이 막막하다면 PGTune(pgtune.leopard.in.ua) 에 서버 사양과 워크로드 유형을 넣으면 시작점을 만들어 줍니다. 그 값을 그대로 쓰지 말고, 모니터링하며 조정하세요.
MYSQL 8.4 LTS · MARIADB 11.8 LTS · INNODB

MySQL ·
MariaDB —
웹의 기본기

세상의 웹 서비스 상당수가 이 위에서 돕니다. 설치가 쉽고 자료가 압도적으로 많습니다. 대신 문자셋(utf8mb4) · 갭 락 · 복제 지연 같은 고유한 함정이 있고, 이걸 모르면 한글이 깨지고 데드락이 나고 조회가 옛 데이터를 봅니다. 실무에서 반드시 겪는 순서대로 정리했습니다.

17SECTIONS
2MySQL · MariaDB
InnoDB중심
SECTION 01

MySQL과 MariaDB의 갈림길

2010년 오라클이 MySQL을 인수하자 원 개발자들이 갈라져 나와 만든 것이 MariaDB입니다. 처음엔 거의 같았지만 지금은 꽤 벌어졌습니다.

항목MySQLMariaDB
개발 주체OracleMariaDB Foundation
라이선스GPL + 상용 이중GPL
최신 LTS8.4 (2024) · 9.x 혁신11.8 (2025)
기본 엔진InnoDBInnoDB (Aria·ColumnStore 추가)
JSON네이티브 JSON 타입LONGTEXT 기반 (JSON 함수는 있음)
시퀀스없음CREATE SEQUENCE 지원
시스템 버전 테이블없음지원 (자동 이력 관리)
병렬 복제지원지원 (구현 방식이 다름)
클러스터InnoDB Cluster (Group Replication)Galera Cluster 내장

돈 문제 — "MySQL은 유료, MariaDB는 무료"가 정확히 무슨 뜻인가

  • MySQL을 그냥 서버에 설치해서 쓰는 것은 무료입니다 — Community 판이 GPL 라이선스로 공개되어 있어요. 개인 프로젝트·사내 서비스·웹 서비스 백엔드로 쓰는 데는 돈이 들지 않습니다
  • 돈이 드는 경우는 따로 있습니다 — 내 제품 안에 MySQL을 담아서 팔면서 소스 공개(GPL)를 하기 싫을 때, 또는 오라클의 공식 기술 지원·전용 관리 도구(Enterprise 판)가 필요할 때. 이때 오라클에서 상용 라이선스를 사야 합니다. 이렇게 무료 판과 유료 판이 같이 있는 것을 "이중 라이선스"라고 불러요
  • MariaDB는 유료 판이라는 것 자체가 없습니다 — 전 기능이 GPL 하나로 공개돼 있어서 어떤 상황에서도 오라클(또는 다른 회사)에 라이선스 비용을 낼 일이 없습니다. "완전 오픈소스"를 원하는 회사·공공기관이 MariaDB를 고르는 이유가 이것입니다
  • 요약 — 웹 서비스 만드는 대부분의 경우 둘 다 공짜입니다. 차이가 나는 건 "DB를 제품에 내장해 파는" 소수의 경우와 유료 기술 지원 여부예요
"드롭인 대체 가능"은 이제 옛말입니다. MariaDB 10.x 초반까지는 MySQL 5.7 과 호환됐지만, MySQL 8.0 이후로는 서로 다른 길을 갑니다. JSON 저장 방식·인증 플러그인·복제 프로토콜이 달라 MySQL 8 → MariaDB 로 그냥 옮기면 대부분 실패합니다. 옮기려면 논리 덤프(mysqldump)로 다시 적재하는 방식을 쓰세요.

어느 쪽을 고를까

  • 이미 쓰는 쪽이 있으면 그대로 — 전환 이득보다 리스크가 큽니다
  • 클라우드 관리형을 쓴다면 — AWS RDS·Aurora, GCP Cloud SQL 모두 양쪽을 지원하지만 Aurora MySQL 호환처럼 MySQL 쪽 생태계가 더 넓습니다
  • 완전 오픈소스가 중요하면 — MariaDB (GPL 단일)
  • 이 가이드의 SQL — 표시가 없으면 양쪽에서 동일하게 동작합니다. 다른 부분은 MariaDB 배지로 표시했습니다
버전 지원 상황 (2026-08 기준) — MySQL 5.7은 2023년에 지원 종료됐습니다. 아직 쓰고 있다면 보안 패치가 없는 상태입니다. 8.0도 2026년 4월에 지원이 끝났으므로 현재 기준 신규·운영 모두 8.4 LTS 로 가야 합니다.
SECTION 02

설치 — 전 OS · Docker

설치 자체는 쉽습니다. 초기 비밀번호와 문자셋만 챙기면 됩니다.

# Ubuntu · Debian
sudo apt update && sudo apt install -y mysql-server
sudo systemctl enable --now mysql
sudo mysql_secure_installation        # root 비밀번호·익명계정 제거·원격 root 차단

# RHEL · Rocky
sudo dnf install -y mysql-server
sudo systemctl enable --now mysqld
sudo grep 'temporary password' /var/log/mysqld.log   # 초기 임시 비밀번호

# MariaDB
sudo apt install -y mariadb-server
sudo systemctl enable --now mariadb
sudo mariadb-secure-installation

# macOS
brew install mysql && brew services start mysql

# Docker (문자셋을 처음부터 잡아 두세요)
docker run -d --name my -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=shop -e TZ=Asia/Seoul \
  -v mydata:/var/lib/mysql \
  mysql:8.4 \
  --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci

우분투의 소켓 인증

# 우분투 기본 설치는 root 가 auth_socket 인증이라 sudo 로만 접속됩니다
sudo mysql

# 비밀번호 인증으로 바꾸려면
ALTER USER 'root'@'localhost'
  IDENTIFIED WITH caching_sha2_password BY 'strong-pass';
FLUSH PRIVILEGES;
운영에서는 root 를 소켓 인증으로 두는 편이 더 안전합니다. 비밀번호가 유출돼도 서버에 로그인하지 않으면 쓸 수 없기 때문입니다. 애플리케이션은 어차피 별도 계정을 씁니다.

설정 파일 위치

OS경로
Ubuntu · Debian/etc/mysql/mysql.conf.d/mysqld.cnf
RHEL 계열/etc/my.cnf · /etc/my.cnf.d/
macOS (brew)/opt/homebrew/etc/my.cnf
Docker/etc/mysql/conf.d/ 에 마운트하거나 command: 로 인자 전달
-- 실제로 읽힌 설정 확인
SHOW VARIABLES LIKE 'character_set%';
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SELECT @@version, @@version_comment;
SECTION 03

클라이언트 · 계정 · 권한

MySQL 계정은 이름@접속위치가 한 세트입니다. 이 개념을 놓치면 "접속이 안 되는" 문제가 반복됩니다.

-- 계정 만들기 : 접속 위치가 다르면 다른 계정입니다
CREATE USER 'app'@'localhost'  IDENTIFIED BY 'strong-pass';   -- 서버 자신에서만
CREATE USER 'app'@'10.0.1.%'   IDENTIFIED BY 'strong-pass';   -- 사설망에서
CREATE USER 'app'@'%'          IDENTIFIED BY 'strong-pass';   -- 어디서나 (주의)

-- 권한
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app'@'10.0.1.%';
CREATE USER 'admin'@'localhost' IDENTIFIED BY 'admin-pass';   -- 8.0+ 는 GRANT 가 계정을 만들어 주지 않습니다
GRANT ALL PRIVILEGES ON shop.* TO 'admin'@'localhost';
FLUSH PRIVILEGES;

-- 확인
SELECT user, host, plugin FROM mysql.user;
SHOW GRANTS FOR 'app'@'10.0.1.%';

-- 회수·삭제
REVOKE DELETE ON shop.* FROM 'app'@'10.0.1.%';
DROP USER 'app'@'%';
가장 흔한 접속 오류
ERROR 1045 (28000): Access denied for user 'app'@'172.17.0.1'
→ 비밀번호가 아니라 그 IP에서 접속할 수 있는 계정이 없는 것입니다. 'app'@'localhost' 만 만들었는데 도커나 다른 서버에서 붙으면 이 오류가 납니다.

인증 플러그인

플러그인설명
caching_sha2_passwordMySQL 8 기본값. 안전하지만 옛 클라이언트가 못 붙습니다
mysql_native_password구식. 8.4에서 기본 비활성화, 향후 제거 예정
auth_socketOS 계정으로 인증 (로컬 전용)
"Authentication plugin 'caching_sha2_password' cannot be loaded" 오류가 나면 클라이언트 라이브러리가 오래된 것입니다. 드라이버를 올리는 것이 정답이고, 임시방편으로만 계정을 ALTER USER … IDENTIFIED WITH mysql_native_password BY '…' 로 바꾸세요. 8.4 부터는 이 플러그인을 쓰려면 서버 설정에서 명시적으로 켜야 합니다.

클라이언트 사용법

mysql -h 127.0.0.1 -P 3306 -u app -p shop     # ★ localhost 대신 IP 를 쓰면 TCP 로 붙습니다
mysql -u app -p -e "SELECT count(*) FROM member;" shop
mysql -u app -p shop < schema.sql              # 파일 실행

# 접속 정보를 파일로 (비밀번호를 명령줄에 노출하지 않기)
# ~/.my.cnf   (권한 600)
# [client]
# user=app
# password=strong-pass
# host=127.0.0.1
SHOW DATABASES;
USE shop;
SHOW TABLES;
SHOW CREATE TABLE member\G      -- \G 는 세로 출력 (읽기 편합니다)
DESC member;
SHOW PROCESSLIST;               -- 지금 도는 쿼리
STATUS;                         -- 접속 정보·문자셋 한눈에
SECTION 04

문자셋 — utf8mb4 함정

한글이 ??? 로 나오거나 이모지 저장에 실패한다면 100% 여기입니다. MySQL 국내 사용에서 가장 유명한 함정입니다.

MySQL의 utf8 은 진짜 UTF-8이 아닙니다. 최대 3바이트만 저장하는 반쪽짜리(utf8mb3)라서 4바이트가 필요한 이모지·일부 한자가 들어가지 않습니다. 항상 utf8mb4 를 쓰세요. (MySQL 8.0부터 서버 기본값이 utf8mb4 로 바뀌었지만, 옛 DB에서 마이그레이션한 경우가 문제입니다)
-- 현재 상태 점검 (여기 있는 값이 하나라도 utf8mb3 면 손봐야 합니다)
SHOW VARIABLES LIKE 'character_set%';
SHOW VARIABLES LIKE 'collation%';

SELECT table_schema, table_name, table_collation
FROM   information_schema.tables
WHERE  table_schema = 'shop';

SELECT table_name, column_name, character_set_name, collation_name
FROM   information_schema.columns
WHERE  table_schema = 'shop' AND character_set_name IS NOT NULL;

네 곳을 모두 맞춰야 합니다

1
서버 설정character-set-server=utf8mb4 — my.cnf
2
데이터베이스 · 테이블 · 컬럼CREATE 할 때 지정하거나 ALTER 로 변경
3
커넥션드라이버의 charset=utf8mb4. JDBC URL 이나 DSN 옵션
4
애플리케이션 · 웹 페이지HTTP 헤더·HTML meta 의 인코딩
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server     = utf8mb4_0900_ai_ci     # MariaDB 는 utf8mb4_uca1400_ai_ci 또는 _general_ci

[client]
default-character-set = utf8mb4
-- 기존 DB·테이블 변환 (테이블이 크면 시간이 오래 걸립니다)
ALTER DATABASE shop CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

ALTER TABLE member CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- ★ CONVERT TO 는 데이터를 실제로 변환합니다 (테이블 재작성)
--   DEFAULT CHARACTER SET 만 바꾸면 기존 컬럼은 그대로입니다 — 흔한 실수

콜레이션 — 정렬·비교 규칙

콜레이션동작
utf8mb4_0900_ai_ciMySQL 8 기본. 악센트·대소문자 무시. 대부분 이걸 쓰면 됩니다
utf8mb4_0900_as_cs악센트·대소문자 구분
utf8mb4_bin바이트 그대로 비교. 완전 구분
utf8mb4_general_ci구식·부정확하지만 빠름. MariaDB 에서 아직 기본값인 경우가 있습니다
테이블마다 콜레이션이 다르면 조인이 실패합니다. Illegal mix of collations 오류가 그것입니다. 또 조인 컬럼의 콜레이션이 다르면 인덱스를 못 탑니다 — 조용히 느려지는 형태라 발견이 어렵습니다. DB 전체에 하나의 콜레이션을 강제하세요.
인덱스 길이 제한 — utf8mb4 는 글자당 최대 4바이트라 VARCHAR(255) 인덱스가 1020바이트가 됩니다. InnoDB 의 기본 인덱스 키 제한은 3072바이트(DYNAMIC 행 포맷)이므로 보통 문제없지만, 옛 설정(COMPACT + 767바이트 제한)에서는 Specified key was too long 오류가 납니다. 이때는 컬럼 길이를 줄이거나 접두사 인덱스(KEY (col(191)))를 씁니다.
utf8 은 진짜 UTF-8 이 아닙니다 utf8 (= utf8mb3) 한 글자에 최대 3바이트 한글 · 한자는 됩니다 이모지는 오류 또는 ? 로 바뀝니다 utf8mb4 — 이것이 진짜 UTF-8 한 글자에 최대 4바이트 이모지 · 희귀 한자까지 새로 만드는 DB 는 무조건 이쪽 바꿀 때 걸리는 것 — 인덱스 길이 인덱스 한 칸의 바이트 한도는 정해져 있는데, 한 글자가 4바이트가 되면 들어갈 글자 수가 줄어듭니다 VARCHAR(255) 에 인덱스를 걸다 길이 초과 오류를 만나면 대개 이것입니다 — 길이를 줄이거나 접두 인덱스로 서버 · DB · 테이블 · 컬럼 · 커넥션 다섯 군데가 모두 맞아야 안 깨집니다 콜레이션도 함께 정하세요 — 대소문자를 구분할지, 이모지 변형을 같게 볼지가 여기서 갈립니다 확인은 SHOW VARIABLES LIKE 'char%' 와 SHOW CREATE TABLE 로 — 짐작하지 말고 찍어 보세요
글자가 깨지면 코드가 아니라 다섯 군데 중 안 맞는 한 곳을 찾아야 합니다.
SECTION 05

스토리지 엔진 InnoDB

MySQL은 테이블마다 저장 엔진을 고를 수 있습니다. 하지만 현실적인 답은 하나입니다 — InnoDB.

엔진트랜잭션락 단위외래키평가
InnoDB✓행 단위✓★ 기본값 · 항상 이것
MyISAM✗테이블 전체✗쓰기 시 전체 잠김 · 크래시에 취약
MEMORY✗테이블✗재시작하면 데이터 소멸
Aria MariaDB부분테이블✗MyISAM 개선판 (크래시 안전)
MyISAM 테이블이 남아 있다면 반드시 전환하세요. ① 트랜잭션이 없어 중간에 실패하면 데이터가 반만 바뀝니다 ② 쓰기가 테이블 전체를 잠가 동시 처리가 안 됩니다 ③ 크래시 시 복구가 불완전합니다 (테이블이 깨집니다)
-- MyISAM 테이블 찾기
SELECT table_schema, table_name, engine
FROM   information_schema.tables
WHERE  engine = 'MyISAM' AND table_schema NOT IN ('mysql','information_schema','performance_schema');

-- 전환 (테이블 재작성이므로 큰 테이블은 온라인 DDL 도구를 쓰세요 → m13)
ALTER TABLE old_table ENGINE = InnoDB;

InnoDB 의 구조

  • 클러스터드 인덱스 — 데이터가 기본키 순서대로 디스크에 저장됩니다(m09)
  • 버퍼 풀 — 메모리 캐시. 가장 중요한 설정이며 RAM의 50~70%를 줍니다(m16)
  • redo 로그 — 커밋을 보장하는 로그. ib_logfile
  • undo 로그 — MVCC와 롤백용 옛 버전 저장
  • MVCC — 읽기가 쓰기를 막지 않습니다(🧬 z05)
-- 테이블별 크기와 행 수 (근사치)
SELECT table_name,
       engine,
       table_rows,
       ROUND(data_length /1024/1024, 1) AS 데이터MB,
       ROUND(index_length/1024/1024, 1) AS 인덱스MB
FROM   information_schema.tables
WHERE  table_schema = 'shop'
ORDER  BY data_length + index_length DESC;
table_rows 는 추정치입니다. InnoDB는 정확한 행 수를 저장하지 않습니다. 정확한 값이 필요하면 SELECT COUNT(*) 를 해야 하고, 이는 큰 테이블에서 느립니다. 화면에 총 건수를 항상 표시하는 설계는 대용량에서 재검토 대상입니다.
InnoDB 는 '메모리에서 고치고 로그로 지킵니다' 쿼리 SELECT · UPDATE 버퍼 풀 (메모리) 페이지 단위(16KB)로 올려 둡니다 여기서 찾으면 디스크를 안 갑니다 redo 로그 — 순차 기록 커밋은 이 로그만 쓰면 끝납니다 데이터 파일 (.ibd) 변경은 나중에 몰아서 반영합니다 그래서 커밋이 빠릅니다 흩어진 데이터 파일 대신 로그 한 줄만 순차로 쓰기 때문입니다 갑자기 꺼져도 괜찮습니다 다시 켜질 때 로그를 다시 적용해 커밋된 것만 살립니다(z03·z04) 튜닝의 첫 번째는 언제나 innodb_buffer_pool_size 입니다 — 전용 서버라면 메모리의 60~70% MyISAM 은 트랜잭션도 크래시 복구도 없습니다 — 지금 새로 만든다면 고를 이유가 없습니다
"왜 커밋이 이렇게 빠르지"의 답은 로그에 먼저 쓰기 하나입니다.
InnoDB 는 '메모리에서 고치고 로그로 지킵니다' 쿼리 SELECT · UPDATE 버퍼 풀 (메모리) 페이지 단위(16KB)로 올려 둡니다 여기서 찾으면 디스크를 안 갑니다 redo 로그 — 순차 기록 커밋은 이 로그만 쓰면 끝납니다 데이터 파일 (.ibd) 변경은 나중에 몰아서 반영합니다 그래서 커밋이 빠릅니다 여기저기 흩어진 데이터 파일 대신 로그 한 줄만 순차로 쓰기 때문입니다 갑자기 꺼져도 괜찮습니다 다시 켜질 때 로그를 다시 적용해 커밋된 것만 살립니다(z03·z04) 튜닝의 첫 번째는 언제나 innodb_buffer_pool_size 입니다 — 전용 서버라면 메모리의 60~70% MyISAM 은 트랜잭션도 크래시 복구도 없습니다 — 지금 새로 만든다면 고를 이유가 없습니다
"왜 커밋이 이렇게 빠르지"의 답은 로그에 먼저 쓰기 하나입니다.
SECTION 06

AUTO_INCREMENT · LIMIT · IFNULL

MySQL에서 특히 편한 문법들입니다. 다른 DB로 옮길 때 바뀌는 지점이기도 합니다.

-- 자동 증가
CREATE TABLE member (
  id    INT AUTO_INCREMENT PRIMARY KEY,
  name  VARCHAR(50) NOT NULL,
  email VARCHAR(120) UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO member (name) VALUES ('김하늘');
SELECT LAST_INSERT_ID();          -- 방금 생성된 id (같은 커넥션 안에서만 유효)

-- 시작값 변경
ALTER TABLE member AUTO_INCREMENT = 1000;
AUTO_INCREMENT 값은 재시작 후 되돌아갈 수 있었습니다. MySQL 5.7 이하는 값을 메모리에만 두어 재시작 시 max(id)+1 로 리셋됐고, 삭제된 행의 번호가 재사용되는 문제가 있었습니다. 8.0부터는 redo 로그에 기록되어 이 문제가 사라졌습니다. 5.7 을 쓰고 있다면 이 동작을 반드시 알고 있어야 합니다.
-- 페이징
SELECT * FROM product ORDER BY price DESC LIMIT 10;          -- 상위 10건
SELECT * FROM product ORDER BY price DESC LIMIT 20, 10;      -- 21번째부터 10건
SELECT * FROM product ORDER BY price DESC LIMIT 10 OFFSET 20; -- 위와 동일

-- NULL 처리
SELECT IFNULL(email, '(없음)')        FROM member;
SELECT COALESCE(email, phone, '(없음)') FROM member;   -- 여러 개면 COALESCE
SELECT NULLIF(a, b);
SELECT IF(grade='GOLD', '우수', '일반') FROM member;   -- MySQL 전용 IF 함수

-- 문자열
SELECT CONCAT(name, ' 님')            FROM member;    -- ★ || 는 OR 입니다
SELECT CONCAT_WS(' / ', city, grade)  FROM member;    -- 구분자로 연결 (NULL 무시)
SELECT GROUP_CONCAT(name ORDER BY id SEPARATOR ', ') FROM member;
MySQL의 || 는 문자열 결합이 아니라 OR 입니다. SELECT 'a' || 'b' 는 0 을 돌려줍니다. 반드시 CONCAT() 을 쓰세요. (sql_mode 에 PIPES_AS_CONCAT 을 추가하면 표준처럼 되지만, 설정에 의존하는 코드는 다른 서버에서 깨집니다)

sql_mode — 엄격 모드를 켜세요

SELECT @@sql_mode;

-- 권장 설정 (MySQL 8 기본값에 가깝습니다)
SET GLOBAL sql_mode =
 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
-- ※ 따옴표 안에 줄바꿈·공백을 넣지 마세요 — 모드 이름의 일부로 읽혀 ERROR 1231 이 날 수 있습니다
엄격 모드가 꺼져 있으면 MySQL이 조용히 데이터를 바꿉니다. VARCHAR(10) 에 20글자를 넣으면 경고만 내고 잘라서 저장하고, DATE 에 '0000-00-00' 이 들어갑니다. 운영 DB에서 데이터가 이상한 원인의 상당수가 이것입니다. 반드시 켜세요.
SECTION 07

ON DUPLICATE KEY · GROUP_CONCAT

실무에서 손이 자주 가는 MySQL 전용 기능들입니다.

UPSERT

-- 있으면 누적, 없으면 삽입 (UNIQUE 또는 PK 가 있어야 합니다)
INSERT INTO stock (product_id, qty) VALUES (10, 5)
ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty);

-- MySQL 8.0.20+ 권장 문법 (VALUES() 는 deprecated)
INSERT INTO stock (product_id, qty) VALUES (10, 5) AS new
ON DUPLICATE KEY UPDATE qty = stock.qty + new.qty;

-- 여러 행 한 번에
INSERT INTO stock (product_id, qty) VALUES (10,5),(11,3),(12,7) AS new
ON DUPLICATE KEY UPDATE qty = stock.qty + new.qty;

-- 있으면 무시
INSERT IGNORE INTO tag (name) VALUES ('sale');
REPLACE INTO 를 UPSERT 대용으로 쓰지 마세요. 이것은 DELETE 후 INSERT 입니다. ① 지정하지 않은 컬럼이 기본값으로 초기화되고 ② AUTO_INCREMENT id 가 바뀌며 ③ 외래키 CASCADE 가 걸린 자식 행이 같이 삭제됩니다.
INSERT IGNORE 도 주의 — 유니크 충돌뿐 아니라 모든 오류를 경고로 낮춥니다(타입 오류·NOT NULL 위반 포함). 의도한 충돌만 무시하려면 ON DUPLICATE KEY UPDATE id = id 를 쓰세요.

GROUP_CONCAT — 여러 행을 한 줄로

SELECT m.name,
       GROUP_CONCAT(o.id ORDER BY o.id SEPARATOR ', ') AS 주문목록,
       COUNT(*) AS 건수
FROM   member m JOIN orders o ON o.member_id = m.id
GROUP  BY m.name;
기본 길이 제한이 1024바이트라 조용히 잘립니다. 오류도 안 납니다. SET SESSION group_concat_max_len = 1000000; 으로 늘리거나, 목록이 길어질 수 있으면 애플리케이션에서 조립하세요.

그 밖의 유용한 문법

-- 여러 테이블 동시 UPDATE (조인 UPDATE)
UPDATE orders o
JOIN   member m ON m.id = o.member_id
SET    o.status = 'VIP'
WHERE  m.grade = 'GOLD';

-- 여러 테이블 동시 DELETE
DELETE oi FROM order_item oi
JOIN   orders o ON o.id = oi.order_id
WHERE  o.status = 'CANCELED';

-- INSERT ... SELECT
INSERT INTO member_archive SELECT * FROM member WHERE joined_at < '2024-01-01';

-- 정규식 (8.0+)
SELECT * FROM member WHERE name REGEXP '^김';
SELECT REGEXP_REPLACE(phone, '[^0-9]', '') FROM member;

-- JSON (8.0+)
SELECT data->>'$.brand' FROM item WHERE JSON_CONTAINS(data, '"LG"', '$.brand');
SECTION 08

날짜 · 타임존

DATETIME 과 TIMESTAMP 의 차이를 모르면 해외 서비스에서 시각이 어긋납니다.

항목DATETIMETIMESTAMP
저장 방식적은 값 그대로UTC로 변환해 저장
조회 시그대로 반환세션 시간대로 변환해 반환
범위1000년 ~ 9999년1970 ~ 2038년
크기8바이트 (5.6+ 는 5바이트)4바이트
쓰는 곳예약일·만료일·생일 등 미래·고정 시각생성·수정 시각 등 과거 이벤트
2038년 문제는 실재합니다. TIMESTAMP 는 32비트 초 카운터라 2038-01-19 이후를 저장할 수 없습니다. "5년 만기 상품"의 만료일을 TIMESTAMP 로 잡아 두면 이미 지금도 문제가 생깁니다. 미래 날짜는 반드시 DATETIME 을 쓰세요.
-- 시간대 설정
SELECT @@global.time_zone, @@session.time_zone;
SET time_zone = '+09:00';                       -- 세션
SET GLOBAL time_zone = 'Asia/Seoul';            -- 서버 (tzdata 로드 필요)

-- 시간대 데이터 로드 (한 번만)
-- mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql

-- 자주 쓰는 함수
SELECT NOW(), CURDATE(), CURTIME(), UTC_TIMESTAMP();
SELECT DATE_ADD(NOW(), INTERVAL 7 DAY);
SELECT DATE_SUB(NOW(), INTERVAL 1 MONTH);
SELECT DATEDIFF('2026-12-31', CURDATE())     AS 남은일수;
SELECT TIMESTAMPDIFF(HOUR, created_at, NOW()) AS 경과시간;
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s');
SELECT STR_TO_DATE('2026-08-05', '%Y-%m-%d');
SELECT LAST_DAY(CURDATE())                    AS 이번달말일;
SELECT CONVERT_TZ(NOW(), '+09:00', '+00:00')  AS UTC로;

자동 갱신 컬럼

CREATE TABLE member (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(50),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- UPDATE 할 때마다 updated_at 이 자동으로 갱신됩니다 (트리거 불필요)
글로벌 서비스의 원칙 — ① DB 서버 시간대는 UTC 로 고정 ② 시각은 UTC로 저장 ③ 표시할 때만 사용자 시간대로 변환 ④ 날짜 경계 계산(오늘의 매출 등)은 어느 시간대 기준인지 명시
DB 시간대를 Asia/Seoul 로 두고 시작하면 나중에 바꾸기가 매우 어렵습니다.
SECTION 09

클러스터드 인덱스 구조

InnoDB는 데이터를 기본키 순서대로 디스크에 저장합니다. 이 사실 하나가 기본키 설계·인덱스 성능·UUID 문제를 전부 설명합니다.

기본키 인덱스 = 데이터 그 자체 (클러스터드) 루트 · 브랜치 리프 — id 순서대로 행 전체가 들어 있다 id 로 찾으면 여기서 끝난다 · 조회 1회 보조 인덱스 — 리프에 PK 값만 있다 email 순서로 정렬된 트리 리프 — email → id 값 행 내용은 없다 얻은 id 로 왼쪽 트리를 한 번 더 탄다 — 이것이 "북마크 조회" 그래서 기본키가 길거나 무작위면 두 번 손해를 봅니다 ① 모든 보조 인덱스가 그 PK 값을 통째로 품어 함께 뚱뚱해진다 · ② 무작위 PK 는 삽입 위치가 흩어져 페이지가 계속 쪼개진다
InnoDB 에서 기본키는 "행을 구분하는 번호"가 아니라 데이터가 디스크에 놓이는 순서 그 자체입니다.
기본키 인덱스
리프 노드에 데이터 전체가 들어 있습니다 기본키로 찾으면 한 번에 행 전체를 얻습니다. 가장 빠른 조회입니다.
▼
보조 인덱스
리프에 기본키 값이 들어 있습니다 보조 인덱스로 찾으면 기본키를 얻고, 그것으로 다시 기본키 인덱스를 탑니다(2단계).
이 구조의 결과
① 기본키가 크면 모든 보조 인덱스가 커집니다(전부 기본키 값을 담으므로)
② 기본키가 무작위면 INSERT 마다 디스크의 다른 위치에 써야 해 느려집니다
③ 커버링 인덱스(필요한 컬럼이 인덱스에 다 있음)면 2단계를 건너뜁니다

UUID를 기본키로 쓰면 안 되는 이유

✗ 무작위 UUID v4
id CHAR(36) PRIMARY KEY    -- '9f8b...' 무작위

-- 문제
-- · 삽입 위치가 매번 달라 페이지 분할이 빈발
-- · 36바이트가 모든 보조 인덱스에 복사됨
-- · 버퍼 풀 효율 급락
-- 수천만 건에서 INSERT 성능이 몇 배 차이납니다
✓ 순차 대리키 또는 정렬 가능한 ID
id BIGINT AUTO_INCREMENT PRIMARY KEY

-- 외부 노출용 UUID 가 필요하면 별도 컬럼
uuid BINARY(16) UNIQUE

-- 또는 시간 순 정렬되는 UUID v7 / ULID 를
-- BINARY(16) 로 저장 (문자열 X)
-- UUID 를 꼭 써야 한다면 : 문자열이 아니라 16바이트 이진으로
ALTER TABLE member ADD COLUMN uid BINARY(16) UNIQUE;
INSERT INTO member (uid) VALUES (UUID_TO_BIN(UUID(), 1));   -- 두 번째 인자 1 = 시간순 정렬
SELECT BIN_TO_UUID(uid, 1) FROM member;

기본키를 명시하지 않으면

InnoDB는 기본키가 없으면 첫 번째 NOT NULL UNIQUE 인덱스를 쓰고, 그것도 없으면 보이지 않는 6바이트 내부 키를 만듭니다. 이 경우 복제·백업·성능 모두 불리합니다. 모든 테이블에 기본키를 명시하세요.

-- 기본키 없는 테이블 찾기
SELECT t.table_schema, t.table_name
FROM   information_schema.tables t
LEFT   JOIN information_schema.table_constraints c
       ON c.table_schema = t.table_schema AND c.table_name = t.table_name
       AND c.constraint_type = 'PRIMARY KEY'
WHERE  t.table_schema = 'shop' AND c.constraint_name IS NULL;
SECTION 10

EXPLAIN · 옵티마이저

MySQL의 실행계획은 type 과 rows 두 칸만 봐도 대부분의 문제가 보입니다.

EXPLAIN SELECT * FROM orders WHERE member_id = 1;

-- 8.0.18+ : 실제로 실행하고 실측치까지
EXPLAIN ANALYZE
SELECT m.name, COUNT(*) FROM member m JOIN orders o ON o.member_id = m.id GROUP BY m.name;

-- JSON 형식 (비용 정보 포함)
EXPLAIN FORMAT=JSON SELECT ...;

type — 좋은 순서대로

type의미판정
system · const기본키·유니크로 1행 확정최고
eq_ref조인에서 기본키로 1행씩매우 좋음
ref인덱스로 여러 행좋음 — 일반적인 목표
range인덱스 범위 스캔좋음
index인덱스 전체 스캔주의 — 인덱스를 처음부터 끝까지 읽음
ALL테이블 전체 스캔작은 표가 아니면 문제

Extra — 여기 나오면 주의

Extra의미
Using index커버링 인덱스 — 테이블 접근 없이 인덱스만으로 해결. 가장 좋습니다
Using where인덱스로 가져온 뒤 추가 필터링. 흔합니다
Using filesort인덱스로 정렬 못 해 별도 정렬 수행. ORDER BY 에 맞는 인덱스를 검토
Using temporary임시 테이블 생성. GROUP BY·DISTINCT 에서 자주 나옵니다
Using join buffer조인 컬럼에 인덱스가 없어 버퍼로 처리 — 인덱스를 만드세요
Using filesort + Using temporary 가 함께 나오면 거의 항상 성능 문제입니다. GROUP BY a ORDER BY b 처럼 기준이 다를 때 발생하므로, 정렬 기준을 그룹 기준과 맞추거나 복합 인덱스를 추가하세요.

옵티마이저 힌트 (8.0+)

SELECT /*+ INDEX(orders idx_ord_member) */ * FROM orders WHERE member_id = 1;
SELECT /*+ JOIN_ORDER(m, o) */ ...;
SELECT /*+ MAX_EXECUTION_TIME(1000) */ ...;      -- 1초 넘으면 중단 ← 유용합니다

-- 구식 인덱스 힌트도 여전히 동작합니다
SELECT * FROM orders USE INDEX (idx_ord_member) WHERE member_id = 1;
SELECT * FROM orders FORCE INDEX (idx_ord_member) WHERE member_id = 1;
SELECT * FROM orders IGNORE INDEX (idx_bad) WHERE member_id = 1;

통계와 슬로우 쿼리

ANALYZE TABLE orders;         -- 통계 갱신 (계획이 이상할 때 먼저)

-- 슬로우 쿼리 로그 켜기
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;                        -- 1초 이상
SET GLOBAL log_queries_not_using_indexes = 'ON';       -- 인덱스 미사용도 기록

-- performance_schema 로 상위 쿼리 뽑기 (sys 스키마가 편합니다)
-- x$ 뷰는 피코초 원본 숫자 (statement_analysis 는 '1.23 ms' 같은 문자열이라 계산 불가)
SELECT query, exec_count, ROUND(avg_latency/1e12, 3) AS 평균초,
       ROUND(total_latency/1e12, 1) AS 총초, rows_examined_avg
FROM   sys.x$statement_analysis
ORDER  BY total_latency DESC LIMIT 20;

-- 인덱스를 못 쓴 쿼리
SELECT * FROM sys.statements_with_full_table_scans LIMIT 20;
슬로우 로그 분석은 pt-query-digest(Percona Toolkit) 가 표준입니다. 로그 파일을 넣으면 쿼리를 정규화해 총 소요 시간 순위로 정리해 줍니다. 평균이 느린 쿼리보다 총합이 큰 쿼리를 먼저 잡으세요.
EXPLAIN 에서 가장 먼저 볼 칸 — type const · system 기본키·유니크로 한 건을 바로 찾음 — 가장 좋습니다 eq_ref 조인에서 상대 행마다 정확히 한 건 — 조인의 이상적인 모습입니다 ref 인덱스로 여러 건 — 흔하고 대체로 괜찮습니다 range 인덱스 구간 훑기 — BETWEEN · IN · 부등호. 구간이 좁으면 좋습니다 index 인덱스 전체 훑기 — 표보다는 낫지만 결국 전부 봅니다 ALL 표 전체 훑기 — 큰 표에서 보이면 여기부터 고칩니다 rows 칸으로 몇 행을 볼 예정인지, Extra 칸으로 정렬·임시 테이블이 생기는지 함께 보세요 추정치입니다 — 실제로 얼마나 걸렸는지 보려면 EXPLAIN ANALYZE 를 쓰세요 (8.0.18+)
튜닝의 첫 단계는 언제나 지금 어떻게 읽고 있는지 확인하는 것입니다.
SECTION 11

갭 락 · 데드락

MySQL은 없는 행에도 락을 겁니다. InnoDB 데드락의 상당수가 여기서 나오며, 다른 DB에서 오면 반드시 당황하게 됩니다.

갭 락 — 값이 아니라 값과 값 사이를 잠근다 10 20 30 T1: SELECT … WHERE id BETWEEN 10 AND 30 FOR UPDATE T2: INSERT id = 15 아직 없는 행인데도 대기한다 범위를 넓게 잡은 조회 하나가 그 구간 전체의 삽입을 막습니다 — 팬텀 리드를 막는 대가입니다 데드락 — 기다림이 원을 그리는 순간 T1 A 를 쥐고 B 를 기다림 T2 B 를 쥐고 A 를 기다림 행 A 행 B InnoDB 가 한쪽을 죽인다 — 앱이 재시도해야 한다 여러 행을 항상 같은 순서로 잠그면 원이 생기지 않습니다
데드락은 버그가 아니라 정상 동작입니다. 없애는 게 아니라 줄이고, 재시도로 받아내는 것이 목표입니다.
락 종류범위
레코드 락인덱스의 특정 레코드에 거는 락
갭 락 (Gap Lock)레코드 사이의 빈 공간에 거는 락. 그 구간에 새 행을 못 넣게 합니다
넥스트 키 락레코드 락 + 그 앞의 갭 락. REPEATABLE READ 의 기본 동작
인텐션 락테이블 수준의 의도 표시 (행 락과 테이블 락의 충돌 감지용)
-- id 가 10, 20, 30 인 행만 있을 때
BEGIN;
SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;
-- 20 행에 레코드 락 + (10,20) (20,30) 구간에 갭 락
-- → 다른 세션이 id=17 을 INSERT 하려 하면 대기합니다 (존재하지도 않는 행인데!)
왜 이렇게 만들었나 — REPEATABLE READ 에서 팬텀 리드를 막기 위해서입니다. "조건에 맞는 행이 트랜잭션 중간에 새로 생기는" 것을 방지합니다. PostgreSQL은 같은 문제를 다른 방식(스냅숏)으로 풀기 때문에 갭 락이 없습니다.

데드락 진단

-- 마지막 데드락 상세 (가장 중요한 명령)
SHOW ENGINE INNODB STATUS\G
-- 출력의 "LATEST DETECTED DEADLOCK" 섹션을 보세요

-- 지금 대기 중인 락 (8.0+)
SELECT r.trx_id AS 대기중, r.trx_mysql_thread_id AS 대기스레드, r.trx_query AS 대기쿼리,
       b.trx_id AS 차단중, b.trx_mysql_thread_id AS 차단스레드, b.trx_query AS 차단쿼리
FROM   performance_schema.data_lock_waits w
JOIN   information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id
JOIN   information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id;

-- 오래 열린 트랜잭션
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS 지속초, trx_query
FROM   information_schema.innodb_trx
ORDER  BY trx_started;

-- 강제 종료
KILL 12345;

데드락을 줄이는 네 가지

  • 접근 순서를 통일하세요. 모든 트랜잭션이 A → B 순서로 잠그면 데드락이 원천적으로 안 생깁니다. 가장 효과적인 대책입니다.
  • 트랜잭션을 짧게. 외부 API 호출·사용자 입력 대기를 트랜잭션 안에 두지 마세요.
  • 적절한 인덱스. 인덱스가 없으면 훨씬 넓은 범위에 락이 걸립니다. WHERE status = 'X' 에 인덱스가 없으면 사실상 테이블 전체가 잠깁니다.
  • 재시도를 코드에 넣으세요. 데드락은 완전히 없앨 수 없습니다. MySQL이 한쪽을 자동으로 롤백시키므로, 애플리케이션이 1~3회 재시도하면 대부분 성공합니다(🔌 a11).
-- 락 대기 시간 설정
SELECT @@innodb_lock_wait_timeout;      -- 기본 50초 — 너무 깁니다
SET SESSION innodb_lock_wait_timeout = 5;

-- 대기하지 않고 즉시 실패 / 잠긴 행은 건너뛰기 (8.0+)
SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;
SELECT * FROM job_queue WHERE status='READY' LIMIT 10 FOR UPDATE SKIP LOCKED;
FOR UPDATE SKIP LOCKED 는 작업 큐 구현의 정석입니다. 워커 여러 대가 같은 큐 테이블에서 서로 다른 작업을 집어가게 됩니다. 락 대기 없이 동시 처리량이 올라갑니다.
SECTION 12

프로시저 · 트리거 · 이벤트

DB 안에서 도는 로직입니다. 편리하지만 남용하면 유지보수가 지옥이 됩니다.

DELIMITER $$
-- ↑ 프로시저 안에 세미콜론이 있어서 구분자를 잠시 바꿉니다

CREATE PROCEDURE grade_up(IN p_id INT, OUT p_result VARCHAR(50))
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        SET p_result = '실패';
    END;

    START TRANSACTION;
      UPDATE member SET grade = 'GOLD' WHERE id = p_id;
      IF ROW_COUNT() = 0 THEN
          SET p_result = '대상 없음';
      ELSE
          SET p_result = '성공';
      END IF;
    COMMIT;
END$$

DELIMITER ;

CALL grade_up(3, @r);
SELECT @r;

트리거

DELIMITER $$
CREATE TRIGGER trg_member_audit
AFTER UPDATE ON member
FOR EACH ROW
BEGIN
    INSERT INTO member_audit (member_id, old_grade, new_grade, changed_at, changed_by)
    VALUES (OLD.id, OLD.grade, NEW.grade, NOW(), CURRENT_USER());
END$$
DELIMITER ;

SHOW TRIGGERS;
DROP TRIGGER trg_member_audit;

이벤트 스케줄러 — DB 안의 크론

SET GLOBAL event_scheduler = ON;    -- MySQL 8.0+ 는 기본 ON · 5.7 · MariaDB 는 기본 OFF

DELIMITER $$
CREATE EVENT ev_purge_log
ON SCHEDULE EVERY 1 DAY STARTS '2026-08-06 03:00:00'
DO
BEGIN
    DELETE FROM log_table WHERE created_at < NOW() - INTERVAL 90 DAY LIMIT 10000;
END$$
DELIMITER ;

SHOW EVENTS;
DB 안 로직의 비용
· 버전 관리가 안 됩니다 — 코드 저장소 밖에 있어 누가 언제 바꿨는지 추적이 어렵습니다
· 테스트가 어렵습니다 — 단위 테스트를 붙이기가 사실상 불가능합니다
· 디버깅이 어렵습니다 — 애플리케이션 로그에 남지 않습니다
· 확장이 안 됩니다 — DB 서버 한 대의 CPU를 쓰게 됩니다
권장 — 감사 로그·타임스탬프 자동화 정도만 트리거로, 비즈니스 로직은 애플리케이션에 두세요. 스케줄 작업도 외부 크론·잡 스케줄러가 관측·재시도 면에서 낫습니다.
SECTION 13

파티셔닝 · 온라인 DDL

수천만 건 테이블에 컬럼을 추가하는 순간 서비스가 멈출 수 있습니다. 운영 중 스키마 변경은 별도의 기술입니다.

온라인 DDL — ALGORITHM 과 LOCK

-- 무엇이 가능한지 명시적으로 요구하고, 안 되면 에러를 받으세요
ALTER TABLE orders
  ADD COLUMN memo VARCHAR(200),
  ALGORITHM=INSTANT;              -- 즉시 (8.0+ · 메타데이터만 변경)

ALTER TABLE orders
  ADD INDEX idx_dt (ordered_at),
  ALGORITHM=INPLACE, LOCK=NONE;   -- 테이블 재작성 없이 · 읽기·쓰기 허용

-- ALGORITHM 을 안 적으면 MySQL 이 알아서 고르는데
-- COPY 가 선택되면 테이블 전체를 복사하며 오래 잠깁니다
작업가능한 알고리즘
컬럼 추가 (맨 뒤)INSTANT (8.0.12+)
컬럼 삭제INSTANT (8.0.29+) · 그 이전은 INPLACE
인덱스 추가·삭제INPLACE · LOCK=NONE
컬럼 타입 변경COPY — 테이블 재작성, 오래 걸립니다
기본키 변경COPY
문자셋 변환COPY
온라인 DDL 이라도 시작과 끝에 짧은 메타데이터 락(MDL)이 필요합니다. 그 순간 오래 도는 쿼리가 하나라도 있으면 ALTER 가 그것을 기다리고, 그 뒤의 모든 쿼리가 ALTER 를 기다리며 순식간에 커넥션이 폭발합니다. DDL 전에 긴 트랜잭션이 없는지 반드시 확인하세요.

대용량 스키마 변경 도구

도구방식
pt-online-schema-change 새 테이블을 만들고 트리거로 변경분을 따라가며 복사한 뒤 이름 교체 (Percona Toolkit)
gh-ost 트리거 대신 binlog 를 읽어 따라갑니다. 원본 부하가 적고 일시정지·속도조절 가능 (GitHub)
pt-online-schema-change \
  --alter "ADD COLUMN memo VARCHAR(200)" \
  D=shop,t=orders \
  --max-load "Threads_running=25" \
  --critical-load "Threads_running=50" \
  --execute        # 먼저 --dry-run 으로 확인하세요

파티셔닝

CREATE TABLE orders (
    id         BIGINT AUTO_INCREMENT,
    member_id  INT NOT NULL,
    ordered_at DATE NOT NULL,
    PRIMARY KEY (id, ordered_at)          -- ★ 파티션 키가 모든 유니크 키에 포함돼야 합니다
)
PARTITION BY RANGE (TO_DAYS(ordered_at)) (
    PARTITION p2026_07 VALUES LESS THAN (TO_DAYS('2026-08-01')),
    PARTITION p2026_08 VALUES LESS THAN (TO_DAYS('2026-09-01')),
    PARTITION pmax     VALUES LESS THAN MAXVALUE
);

ALTER TABLE orders DROP PARTITION p2026_07;      -- 즉시 삭제
ALTER TABLE orders REORGANIZE PARTITION pmax INTO (
    PARTITION p2026_09 VALUES LESS THAN (TO_DAYS('2026-10-01')),
    PARTITION pmax     VALUES LESS THAN MAXVALUE
);
MySQL 파티셔닝의 제약
① 모든 유니크 키(기본키 포함)에 파티션 키가 들어가야 합니다 — 설계를 크게 제약합니다
② 외래키를 쓸 수 없습니다
③ WHERE 에 파티션 키가 없으면 모든 파티션을 뒤집니다
이 때문에 실무에서는 파티셔닝 대신 애플리케이션 레벨 샤딩이나 아카이브 테이블 분리를 택하는 경우도 많습니다.
ALTER 한 줄이 서비스를 멈출 수 있습니다 — 미리 확인하세요 ① COPY — 테이블을 통째로 새로 만듭니다 원본 테이블 1억 행 새 테이블로 전부 복사 수십 분 ~ 몇 시간 그동안 쓰기가 막힙니다 커넥션이 쌓여 장애로 번집니다 ② INPLACE · INSTANT — 복사하지 않습니다 INSTANT — 메타데이터만 수정 (즉시) 맨 뒤에 컬럼 추가 · 기본값 변경 같은 것 INPLACE — 변경 중에도 읽기·쓰기 허용 실행 전에 확인하는 법 ALTER TABLE … , ALGORITHM=INPLACE, LOCK=NONE 이렇게 명시하면 불가능할 때 실행되지 않고 에러가 납니다 복사 방식밖에 안 되는 변경이라면 gh-ost · pt-online-schema-change 로 새 테이블을 따로 채우고 마지막에 이름만 바꿉니다 어느 쪽이든 운영 시간대는 피하고, 되돌릴 방법을 먼저 정해 두고 실행하세요
"개발에서는 3초였는데 운영에서 30분"이 이 표를 몰라서 생깁니다.
ALTER 한 줄이 서비스를 멈출 수 있습니다 — 미리 확인하세요 ① COPY — 테이블을 통째로 새로 만듭니다 원본 테이블 1억 행 새 테이블로 전부 복사 수십 분 ~ 몇 시간 그동안 쓰기가 막힙니다 커넥션이 쌓여 장애로 번집니다 ② INPLACE · INSTANT — 복사하지 않습니다 INSTANT — 메타데이터만 수정 (즉시) 맨 뒤에 컬럼 추가 · 기본값 변경 같은 것 INPLACE — 변경 중에도 읽기·쓰기 허용 실행 전에 확인하는 법 ALTER TABLE … , ALGORITHM=INPLACE, LOCK=NONE 이렇게 명시하면 불가능할 때 실행되지 않고 에러가 납니다 복사 방식밖에 안 되는 변경이라면 gh-ost · pt-online-schema-change 로 새 테이블을 따로 채우고 마지막에 이름만 바꿉니다 어느 쪽이든 운영 시간대는 피하고, 되돌릴 방법을 먼저 정해 두고 실행하세요
"개발에서는 3초였는데 운영에서 30분"이 이 표를 몰라서 생깁니다.
SECTION 14

복제 · 이중화

MySQL 복제는 구성이 간단해 널리 쓰입니다. 복제 지연만 이해하면 됩니다.

# 소스(Primary) my.cnf
server-id        = 1
log_bin          = mysql-bin
binlog_format    = ROW              # ★ ROW 를 쓰세요 (STATEMENT 는 비결정적 함수에서 어긋납니다)
gtid_mode        = ON
enforce_gtid_consistency = ON

# 레플리카 my.cnf
server-id        = 2
read_only        = ON
super_read_only  = ON               # 관리자도 실수로 못 쓰게
-- 소스에서 복제 계정
CREATE USER 'repl'@'10.0.1.%' IDENTIFIED BY 'repl-pass';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'10.0.1.%';

-- 레플리카에서 (8.0.23+ 용어)
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='10.0.1.10', SOURCE_USER='repl', SOURCE_PASSWORD='repl-pass',
  SOURCE_AUTO_POSITION=1,
  GET_SOURCE_PUBLIC_KEY=1;   -- caching_sha2_password 계정을 SSL 없이 쓸 때 필요
START REPLICA;

-- 상태 확인 ★ 가장 중요한 명령
SHOW REPLICA STATUS\G
--   Replica_IO_Running: Yes
--   Replica_SQL_Running: Yes
--   Seconds_Behind_Source: 0      ← 이 값이 복제 지연(초)
쓰기 직후 레플리카를 읽으면 — 복제 지연 타임라인 소스 커밋 10:00:00.0 binlog 전송 → 레플리카가 재실행 레플리카 10:00:00.3 조회 지연 구간 — 여기서 읽으면 옛 데이터 반영 10:00:00.8 해결 — 쓰기 직후 조회는 소스로 · 세션 고정(Read-Your-Writes) · 지연 모니터링
복제는 비동기입니다. 커밋 성공 ≠ 레플리카 도착 — 이 시간차를 설계에 넣어야 합니다.
복제 지연은 반드시 생깁니다. 비동기 복제라 소스에서 커밋된 데이터가 레플리카에 수 ms ~ 수 분 뒤에 도착합니다.
· 대량 UPDATE 를 한 방에 치면 레플리카가 그것을 따라 하느라 수 분간 밀립니다
· 그동안 레플리카를 보는 화면은 옛 데이터를 보여 줍니다
· 쓰기 직후 조회는 소스로 보내야 합니다

지연 모니터링

-- Seconds_Behind_Source 는 부정확할 때가 있습니다
-- 하트비트 테이블을 두고 실제 지연을 재는 방식이 정확합니다 (pt-heartbeat)
SELECT TIMESTAMPDIFF(SECOND, ts, NOW()) AS 실제지연초 FROM heartbeat ORDER BY ts DESC LIMIT 1;
-- (NOW() - ts 는 MySQL 에서 초가 아니라 YYYYMMDDhhmmss 숫자끼리의 뺄셈이 됩니다)

-- 병렬 복제로 지연 줄이기 (8.0+)
SET GLOBAL replica_parallel_type = 'LOGICAL_CLOCK';
SET GLOBAL replica_parallel_workers = 8;
SET GLOBAL replica_preserve_commit_order = ON;

고가용성 구성

구성내용
소스-레플리카가장 단순. 장애 시 수동 승격(또는 Orchestrator 같은 도구)
InnoDB ClusterGroup Replication + MySQL Router + Shell. 자동 페일오버
Galera MariaDB동기식 다중 마스터. 쓰기를 어디서나 받을 수 있지만 충돌 시 롤백이 발생
클라우드 관리형RDS·Aurora·Cloud SQL — 페일오버를 서비스가 처리합니다
자동 페일오버는 양날의 검입니다. 네트워크 순간 단절로 승격이 잘못 일어나면(스플릿 브레인) 양쪽에서 쓰기가 발생해 데이터가 갈라집니다. 중재자(quorum)가 없는 2노드 구성에서는 자동 페일오버를 켜지 마세요.
SECTION 15

백업 · 시점 복구

mysqldump 만 알아도 절반은 됩니다. 큰 DB는 물리 백업 + binlog가 필요합니다.

# 논리 백업 — 반드시 --single-transaction 을 붙이세요 (InnoDB 일관성 스냅숏)
mysqldump -u root -p \
  --single-transaction --routines --triggers --events \
  --source-data=2 \
  shop > shop_$(date +%F).sql
# --source-data=2 : binlog 위치를 주석으로 기록 (시점 복구에 필요)
# ※ 줄 끝 \ 뒤에 주석을 달면 줄 잇기가 끊겨 명령이 둘로 갈라집니다

# 여러 DB · 전체
mysqldump --all-databases --single-transaction > all.sql

# 압축하며
mysqldump --single-transaction shop | gzip > shop.sql.gz

# 복원
mysql -u root -p shop < shop.sql
zcat shop.sql.gz | mysql -u root -p shop
--single-transaction 없이 덤프하면 데이터가 어긋납니다. 덤프 도중에도 다른 트랜잭션이 계속 쓰기 때문에, 회원 테이블은 10시 기준, 주문 테이블은 10시 3분 기준인 백업이 만들어집니다. 복원하면 주문은 있는데 회원이 없는 상태가 됩니다.
다만 이 옵션은 InnoDB 에서만 유효합니다 — MyISAM 이 섞여 있으면 소용없습니다.

물리 백업 — XtraBackup

# 수백 GB 이상이면 mysqldump 로는 시간이 감당이 안 됩니다
xtrabackup --backup --target-dir=/backup/full --user=root --password=...
xtrabackup --prepare --target-dir=/backup/full

# 증분 백업
xtrabackup --backup --target-dir=/backup/inc1 --incremental-basedir=/backup/full

# 복원
systemctl stop mysql
xtrabackup --copy-back --target-dir=/backup/full
chown -R mysql:mysql /var/lib/mysql
systemctl start mysql

시점 복구 (PITR)

# 시나리오: 14:30 에 실수로 DELETE. 13:00 백업 + binlog 로 14:29 까지 복구

# 1) 백업 복원
mysql -u root -p < shop_backup.sql

# 2) binlog 를 그 시점까지만 재생
mysqlbinlog --start-position=1234567 \
            --stop-datetime="2026-08-05 14:29:00" \
            /var/lib/mysql/mysql-bin.00042 | mysql -u root -p

# 특정 트랜잭션만 건너뛰려면 GTID 로
mysqlbinlog --exclude-gtids='uuid:100' ... | mysql -u root -p
binlog 보존 기간을 확인하세요. binlog_expire_logs_seconds 기본값은 30일입니다. 이 기간보다 오래된 시점으로는 복구할 수 없습니다. 백업 주기와 binlog 보존 기간을 함께 설계해야 합니다.
복구 훈련을 하세요. "백업 파일은 매일 만들어지고 있다"는 사실만으로는 아무것도 보장되지 않습니다. 분기마다 실제로 다른 서버에 복원해 보고, 복구에 걸리는 시간(RTO)과 잃게 되는 데이터 범위(RPO)를 숫자로 알고 있어야 합니다.
SECTION 16

my.cnf 운영 튜닝

설정 하나만 제대로 잡아도 성능이 몇 배 달라집니다 — 버퍼 풀입니다.

설정권장 (RAM 16GB 전용 서버)설명
innodb_buffer_pool_size10~11GB (RAM의 60~70%)가장 중요. 데이터·인덱스 캐시
innodb_log_file_size1~2GBredo 로그. 작으면 체크포인트가 잦아 쓰기가 느려집니다
innodb_flush_log_at_trx_commit1 (기본·안전)2로 낮추면 빨라지지만 장애 시 1초 유실 가능
innodb_flush_methodO_DIRECTOS 캐시 중복을 피합니다 (리눅스)
max_connections200~500크게 잡지 말고 앱 커넥션 풀로 제어하세요
innodb_io_capacity2000 (SSD)백그라운드 플러시 속도. HDD 기본값 200은 SSD에 너무 낮습니다
slow_query_logON★ 반드시 켜세요
long_query_time11초 넘는 쿼리 기록
tmp_table_size · max_heap_table_size64MB임시 테이블이 디스크로 넘어가는 것을 줄입니다
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
# 문자셋
character-set-server = utf8mb4
collation-server     = utf8mb4_0900_ai_ci

# InnoDB
innodb_buffer_pool_size        = 10G
innodb_buffer_pool_instances   = 8
innodb_log_file_size           = 1G
innodb_flush_log_at_trx_commit = 1
innodb_flush_method            = O_DIRECT
innodb_io_capacity             = 2000
innodb_io_capacity_max         = 4000

# 커넥션
max_connections     = 300
wait_timeout        = 600
interactive_timeout = 600

# 로그
slow_query_log      = ON
long_query_time     = 1
log_error           = /var/log/mysql/error.log

# 안전
sql_mode = "ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"
-- 버퍼 풀이 충분한지 확인 (히트율 99% 이상이 목표)
SELECT
  ROUND(100 - (SELECT variable_value FROM performance_schema.global_status
               WHERE variable_name='Innodb_buffer_pool_reads') * 100.0 /
              (SELECT variable_value FROM performance_schema.global_status
               WHERE variable_name='Innodb_buffer_pool_read_requests'), 2) AS 히트율;

-- 8.0 은 버퍼 풀을 온라인으로 조정할 수 있습니다
SET GLOBAL innodb_buffer_pool_size = 12884901888;   -- 12GB
클라우드 관리형(RDS 등)에서는 파라미터 그룹으로 설정합니다. my.cnf 를 직접 못 고치고, 일부 값은 인스턴스 크기에 따라 수식으로 자동 계산됩니다 ({DBInstanceClassMemory*3/4} 형태). 무작정 값을 넣지 말고 수식과 단위를 확인하세요.
튜닝의 순서 — ① 쿼리·인덱스 개선(효과가 압도적) → ② 스키마 개선 → ③ 서버 설정 → ④ 하드웨어. 설정부터 만지는 것은 거의 항상 잘못된 순서입니다. 버퍼 풀 크기만 제대로 잡고, 나머지는 슬로우 쿼리를 먼저 잡으세요.
SECTION 17

MariaDB 전용 기능

MariaDB를 쓴다면 이 기능들이 코드를 상당히 줄여 줍니다.

시퀀스 — MySQL에는 없습니다

CREATE SEQUENCE seq_order START WITH 1000 INCREMENT BY 1;

SELECT NEXT VALUE FOR seq_order;
INSERT INTO orders (id) VALUES (NEXT VALUE FOR seq_order);

-- 테이블마다 별도 채번이 필요하거나, 여러 테이블이 번호를 공유해야 할 때 유용합니다

시스템 버전 테이블 — 이력이 자동으로 쌓입니다

CREATE TABLE member (
  id    INT PRIMARY KEY,
  name  VARCHAR(50),
  grade VARCHAR(10)
) WITH SYSTEM VERSIONING;

UPDATE member SET grade = 'GOLD' WHERE id = 1;

-- 과거 시점의 데이터를 그대로 조회
SELECT * FROM member FOR SYSTEM_TIME AS OF TIMESTAMP '2026-08-01 00:00:00';

-- 변경 이력 전체
SELECT *, ROW_START, ROW_END FROM member FOR SYSTEM_TIME ALL WHERE id = 1;
감사 이력 테이블을 직접 만들 필요가 없어집니다. 트리거·이력 테이블·조회 쿼리를 전부 대체합니다. 다만 데이터가 계속 쌓이므로 파티셔닝과 보존 정책을 함께 설계해야 합니다.

그 밖의 유용한 기능

기능내용
RETURNINGINSERT·DELETE 결과를 바로 반환 (10.5+) — PostgreSQL 처럼
INSERT … RETURNINGINSERT INTO t (a) VALUES (1) RETURNING id;
Galera Cluster동기식 다중 마스터 클러스터가 내장
ColumnStore컬럼 지향 엔진 — 분석 쿼리에 특화
Spider샤딩 스토리지 엔진 (여러 서버를 하나처럼)
Aria크래시에 안전한 MyISAM 대체 (임시 테이블에 사용)
JSON_TABLEJSON 을 표로 펼치기 (10.6+)
Oracle 호환 모드SET sql_mode=ORACLE; — PL/SQL 유사 문법 일부 지원
MariaDB 전용 기능을 쓰면 MySQL 로 돌아갈 수 없습니다. 시퀀스·시스템 버전 테이블·RETURNING 을 쓴 코드는 MySQL에서 동작하지 않습니다. 이식성이 중요한 프로젝트라면 어느 쪽에서도 동작하는 문법만 쓰는 것이 장기적으로 안전할 수 있습니다.
다음으로 — 인덱스와 실행계획을 더 깊이 보려면 ⚙️ 설계 · 튜닝 탭, InnoDB의 내부 동작(MVCC·버퍼 풀·redo/undo)은 🧬 전문가 탭, 애플리케이션 연결은 🔌 앱 연동 탭으로 이어집니다.
SQLITE 3 · 서버 없음 · 파일 하나 · 세상에서 가장 많이 배포된 DB

SQLite —
서버가
없는 DB

설치도, 계정도, 포트도 없습니다. DB가 그냥 파일 하나이고 앱 안에 라이브러리로 들어갑니다. 스마트폰·브라우저·항공기 시스템까지 수조 개 규모로 배포돼 있습니다. 장난감이 아니라, 쓰임새가 다른 진짜 DB입니다.

14SECTIONS
0설치 · 설정
1파일
SECTION 01

서버가 없는 DB

MySQL·PostgreSQL은 별도 프로그램(서버)이 떠 있고 앱이 네트워크로 요청합니다. SQLite는 앱 안에 들어간 라이브러리가 직접 파일을 읽고 씁니다.

MySQL · PostgreSQL — 서버가 따로 떠 있다 내 앱 프로세스 A TCP DB 서버 프로세스 B 데이터 파일 설치 · 포트 · 계정 · 방화벽이 따라옵니다 대신 여러 대의 앱 서버가 함께 붙을 수 있습니다 SQLite — 서버가 없다 · 라이브러리가 곧 DB 내 앱 프로세스 하나 안에서 전부 일어난다 앱 코드 sqlite3 라이브러리 app.db 파일 하나 설치도 포트도 계정도 없습니다 — 파일을 복사하면 이사 끝
SQLite 는 "가벼운 DB"라기보다 fopen 을 대신하는 저장 방식에 가깝습니다 — 경쟁 상대는 MySQL 이 아니라 직접 만든 파일 포맷입니다.
서버형 (MySQL · PostgreSQL)
[앱] --(TCP 5432)--> [DB 서버 프로세스] --> [파일]

· 서버를 설치·기동해야 함
· 계정·권한·포트·방화벽 필요
· 여러 앱이 동시에 안전하게 접근
· 네트워크 왕복 비용이 있음
임베디드 (SQLite)
[앱 + SQLite 라이브러리] --(함수 호출)--> [파일]

· 설치할 서버가 없음
· 계정·포트 개념 자체가 없음
· 접근 통제 = 파일 권한
· 네트워크 왕복이 0 (함수 호출)

어디에 이미 들어 있나

  • 모든 안드로이드·iOS 기기 — 앱 데이터 저장의 기본
  • 모든 주요 브라우저 — 내부 저장소
  • macOS · Windows 10 이상 — 시스템 곳곳에서 사용
  • 파이썬 — import sqlite3 만으로 바로 사용 (표준 라이브러리)
  • 항공기 · 자동차 · IoT 기기 — 임베디드 환경의 사실상 표준

언제 쓰고 언제 안 쓰나

판단상황
쓴다 모바일·데스크톱 앱의 로컬 저장 · CLI 도구의 설정과 캐시 · 읽기 위주 웹사이트 · 테스트 픽스처 · 데이터 분석용 임시 저장 · 엣지·IoT · 로그 수집기의 버퍼
안 쓴다 여러 서버가 동시에 쓰기 · 초당 수백 건 이상의 쓰기 · 사용자별 권한이 필요한 경우 · 네트워크 너머에서 접근해야 할 때 · 네트워크 파일시스템(NFS·SMB) 위 (락이 깨져 파일이 손상됩니다)
절대 하지 말 것 — 네트워크 드라이브 위의 SQLite. NFS·SMB·클라우드 동기화 폴더(드롭박스·원드라이브) 위에 DB 파일을 두면 파일 락이 제대로 동작하지 않아 데이터가 손상됩니다. SQLite 공식 문서가 명시적으로 경고하는 사항입니다.
SQLite 는 "MySQL 의 경쟁자"가 아닙니다. 공식 문서의 표현대로, SQLite 가 경쟁하는 대상은 fopen() 입니다. 즉 "파일에 직접 저장하는 것"의 대체재입니다. JSON·CSV 파일에 데이터를 저장하고 있다면, 그 자리가 SQLite 의 자리입니다.
SECTION 02

설치와 실행 — CLI · GUI

설치라고 할 것도 거의 없습니다.

# macOS · 대부분의 리눅스에는 이미 있습니다
sqlite3 --version

# 없으면
sudo apt install -y sqlite3          # Ubuntu
brew install sqlite                  # macOS (최신 버전이 필요할 때)

# Windows : sqlite.org 에서 sqlite-tools-win-x64-*.zip 을 받아 압축 해제 후 PATH 추가

# 파이썬이 있으면 이미 쓸 수 있습니다
python -c "import sqlite3; print(sqlite3.sqlite_version)"
# DB 만들기 = 파일 만들기
sqlite3 shop.db                # 없으면 새로 만들어집니다

# 메모리에서만 (프로그램이 끝나면 사라짐 · 테스트에 유용)
sqlite3 :memory:

# 한 줄 실행
sqlite3 shop.db "SELECT count(*) FROM member;"

# 스크립트 실행
sqlite3 shop.db < schema.sql

GUI 도구

도구특징
DB Browser for SQLite무료. 엑셀처럼 표를 열어 편집. 처음이라면 이것
DBeaver다른 DB와 함께 쓸 때
VS Code 확장SQLite Viewer 등 — 개발 중 빠르게 확인
DatasetteSQLite 파일을 바로 웹 API·탐색 UI로 만들어 줍니다 (데이터 공개에 유용)
파일 하나가 곧 DB이므로 백업은 파일 복사, 전송은 파일 전송, 버전 관리는… 하지 마세요 (바이너리라 git 에 넣으면 저장소가 급격히 커집니다). 스키마 SQL 만 git 에 넣고 DB 파일은 .gitignore 하는 것이 정석입니다.
SECTION 03

.dot 명령 완전정복

점(.)으로 시작하는 명령이 sqlite3 CLI의 도구입니다. 세미콜론을 붙이지 않습니다.

명령하는 일
.tables테이블 목록
.schema memberCREATE 문 그대로 보기 (생략하면 전체)
.headers on컬럼 이름 표시 — 처음에 반드시 켜세요
.mode box표 형태 출력 (table·markdown·json·csv 등)
.import data.csv member --csvCSV 가져오기
.output result.csv결과를 파일로 (다시 화면으로는 .output stdout)
.once result.csv다음 한 번만 파일로
.backup backup.db★ 안전한 백업 (파일 복사와 다릅니다)
.dump전체를 SQL 텍스트로 (다른 DB로 옮길 때)
.read script.sqlSQL 파일 실행
.timer on실행 시간 표시
.expert인덱스 추천 (실험적 기능)
.databases연결된 DB 파일 경로
.help전체 명령 목록
.quit종료
# 자주 쓰는 조합 — 시작할 때 이 두 줄
sqlite3 shop.db
sqlite> .headers on
sqlite> .mode box
sqlite> SELECT id, name, grade, city FROM member LIMIT 2;
┌────┬────────┬───────┬────────┐ │ id │ name │ grade │ city │ ├────┼────────┼───────┼────────┤ │ 1 │ 김하늘 │ GOLD │ 서울 │ │ 2 │ 박바다 │ SILVER│ 부산 │ └────┴────────┴───────┴────────┘

CSV 를 다루는 도구로도 훌륭합니다

# 큰 CSV 를 SQL 로 분석하기 (엑셀이 못 여는 크기도 됩니다)
sqlite3 :memory: <<'EOF'
.mode csv
.import sales.csv sales
.headers on
.mode box
SELECT region, count(*) AS 건수, sum(amount) AS 합계
FROM sales GROUP BY region ORDER BY 합계 DESC;
EOF
설정 파일로 기본값 지정 — 홈 디렉터리에 ~/.sqliterc 를 만들고 .headers on · .mode box 를 넣어 두면 매번 칠 필요가 없습니다.
SQL 이 여러 줄일 때 — 세미콜론을 칠 때까지 프롬프트가 ...> 로 바뀌며 기다립니다. 빠져나오려면 세미콜론을 치거나 Ctrl+C 를 누르세요.
SECTION 04

동적 타입 시스템

SQLite는 숫자 컬럼에 글자를 넣어도 받아 줍니다. 편리하다고 볼 수도, 위험하다고 볼 수도 있습니다 — 그리고 대개는 위험합니다.

CREATE TABLE t (n INTEGER, s TEXT);

INSERT INTO t VALUES ('안녕', 12345);     -- 에러가 안 납니다! (12345 는 TEXT 컬럼이라 '12345' 로 바뀜)
SELECT n, typeof(n), s, typeof(s) FROM t;
n typeof(n) s typeof(s) 안녕 text 12345 text
타입 친화도(Type Affinity) — SQLite는 컬럼에 선호 타입만 두고, 값이 변환 가능하면 변환하고 아니면 그대로 저장합니다. INTEGER 컬럼에 '123' 을 넣으면 숫자 123으로 바뀌지만, '안녕' 은 텍스트로 저장됩니다.
저장 클래스설명
NULL빈 값
INTEGER부호 있는 정수 (1~8바이트 가변)
REAL8바이트 부동소수점
TEXT문자열 (UTF-8/16)
BLOB바이너리 그대로

BOOLEAN·DATE·DATETIME 타입은 없습니다. BOOLEAN 은 0/1 정수로, 날짜는 TEXT('2026-08-05') 또는 숫자로 저장합니다.

STRICT 테이블 — 타입을 강제하기 3.37+

CREATE TABLE t (
    n INTEGER NOT NULL,
    s TEXT    NOT NULL
) STRICT;

INSERT INTO t VALUES ('안녕', 123);
-- Error: cannot store TEXT value in INTEGER column t.n   ← 이제 막힙니다
새로 만드는 테이블은 STRICT 를 쓰세요. 다른 DB에서 온 사람이 기대하는 동작이 되고, 데이터 오염을 예방합니다. STRICT 테이블에서 쓸 수 있는 타입은 INT · INTEGER · REAL · TEXT · BLOB · ANY 뿐입니다 (VARCHAR(50) 같은 표기는 못 씁니다).

CHECK 제약으로 방어하기

CREATE TABLE member (
    id     INTEGER PRIMARY KEY,
    name   TEXT    NOT NULL CHECK (length(name) BETWEEN 1 AND 50),
    email  TEXT    UNIQUE   CHECK (email LIKE '%_@_%'),
    age    INTEGER          CHECK (age IS NULL OR (age >= 0 AND age < 150)),
    grade  TEXT    NOT NULL DEFAULT 'BASIC'
                            CHECK (grade IN ('BASIC','SILVER','GOLD')),
    joined TEXT    NOT NULL CHECK (joined GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]')
) STRICT;
SQLite에는 ENUM 이 없으므로 CHECK (col IN (…)) 이 그 역할을 합니다. 날짜 형식도 GLOB 패턴으로 강제할 수 있습니다. 타입이 느슨한 만큼 제약으로 채우는 것이 SQLite 설계의 요령입니다.
컬럼 타입이 규칙이 아니라 선호도입니다 CREATE TABLE t (age INTEGER) INSERT ... VALUES ('25') 숫자로 바꿀 수 있으므로 정수 25 로 저장됩니다 INSERT ... VALUES ('스물다섯') 숫자로 못 바꾸므로 문자열 그대로 들어갑니다 — 오류 없음 그래서 한 컬럼에 숫자와 문자열이 섞여 있을 수 있습니다 정렬하면 숫자가 먼저, 문자열이 나중 — ORDER BY 결과가 예상과 달라지는 원인입니다 막는 법 — CREATE TABLE ... STRICT 로 만들면 다른 DB 처럼 타입을 엄격하게 검사합니다 (3.37+) 불리언과 날짜 타입이 없습니다 — 0/1 정수와 텍스트(ISO 형식)로 다룹니다
SQLite 로 만든 데이터를 다른 DB 로 옮길 때 가장 자주 터지는 지점입니다.
SECTION 05

ROWID · AUTOINCREMENT · UPSERT

SQLite 특유의 기본키 구조를 이해하면 불필요한 설정을 안 하게 됩니다.

모든 행에는 숨은 번호가 있습니다

CREATE TABLE t (name TEXT);
INSERT INTO t VALUES ('a'), ('b');
SELECT rowid, name FROM t;         -- rowid 는 자동으로 존재합니다
rowid name 1 a 2 b
-- INTEGER PRIMARY KEY 로 선언하면 그것이 곧 rowid 가 됩니다 (별도 저장 없음)
CREATE TABLE member (
    id   INTEGER PRIMARY KEY,      -- ★ 자동 증가합니다. AUTOINCREMENT 불필요
    name TEXT NOT NULL
);
INSERT INTO member (name) VALUES ('김하늘');
SELECT last_insert_rowid();
INTEGER PRIMARY KEY 와 INT PRIMARY KEY 는 다릅니다. 정확히 INTEGER 라고 써야 rowid 별칭이 되어 자동 증가합니다. INT PRIMARY KEY 로 쓰면 일반 컬럼이 되어 값을 직접 넣어야 합니다. 다른 DB에서 온 사람이 가장 자주 걸리는 함정입니다.

AUTOINCREMENT 는 대부분 불필요합니다

구분동작
INTEGER PRIMARY KEY 사용 중인 최대값 + 1. 삭제된 번호를 재사용할 수 있습니다
INTEGER PRIMARY KEY AUTOINCREMENT 번호를 절대 재사용하지 않습니다. 대신 별도 테이블(sqlite_sequence)을 유지해 삽입이 느려집니다
번호가 재사용되면 안 되는 경우(외부 시스템이 그 번호를 기억하는 등)에만 AUTOINCREMENT 를 쓰세요. 그 외에는 성능만 손해입니다.

WITHOUT ROWID — 특수한 최적화

CREATE TABLE kv (
    k TEXT PRIMARY KEY,
    v TEXT
) WITHOUT ROWID;
-- 기본키가 문자열이고, 기본키로만 조회하는 표에서 공간·속도 이득
-- (rowid 인덱스를 따로 유지하지 않음)

UPSERT 3.24+

INSERT INTO stock (product_id, qty) VALUES (10, 5)
ON CONFLICT (product_id) DO UPDATE SET qty = qty + excluded.qty;

INSERT INTO tag (name) VALUES ('sale') ON CONFLICT DO NOTHING;

-- RETURNING 도 지원합니다 (3.35+)
INSERT INTO member (name) VALUES ('한바다') RETURNING id, name;

-- 충돌 처리 방식 지정
INSERT OR IGNORE  INTO t VALUES (...);   -- 충돌하면 건너뜀
INSERT OR REPLACE INTO t VALUES (...);   -- ⚠️ DELETE 후 INSERT — 주의
INSERT OR ABORT   INTO t VALUES (...);   -- 기본값 (에러)
INSERT OR REPLACE 는 UPSERT 가 아닙니다. 기존 행을 지우고 새로 넣습니다. 지정하지 않은 컬럼은 기본값으로 초기화되고, 외래키 CASCADE 가 걸린 자식 행이 같이 삭제될 수 있습니다. 항상 ON CONFLICT DO UPDATE 를 쓰세요.
SECTION 06

날짜 · 문자열 함수

날짜 타입이 없으므로 저장 형식을 팀에서 먼저 정하는 것이 중요합니다.

날짜 저장 — 세 가지 방식

방식예평가
ISO 문자열'2026-08-05 14:30:00' ★ 권장. 사람이 읽을 수 있고 문자열 정렬 = 시간 정렬
유닉스 초1785000000공간 절약·계산 편함. 사람이 못 읽음
율리우스일2461258.1거의 안 씁니다
SELECT date('now')                          AS 오늘,        -- 2026-08-05
       datetime('now')                      AS 현재UTC,
       datetime('now','localtime')           AS 현재로컬,
       datetime('now','+9 hours')            AS 한국시각,
       date('now','start of month')          AS 이번달1일,
       date('now','start of month','+1 month','-1 day') AS 이번달말일,
       date('now','+7 days')                 AS 일주일후,
       strftime('%Y-%m', 'now')              AS 연월,
       strftime('%s', 'now')                 AS 유닉스초,
       julianday('now') - julianday('2026-01-01') AS 올해경과일;

-- 기간 조회 (문자열 비교가 그대로 시간 비교가 됩니다)
SELECT * FROM orders
WHERE  ordered_at >= '2026-08-01' AND ordered_at < '2026-09-01';
ISO 문자열의 장점 — '2026-08-05' < '2026-08-06' 이 문자열 비교로도 정확히 동작합니다. 인덱스도 그대로 탑니다. 날짜를 문자열로 저장할 때는 반드시 YYYY-MM-DD 형식을 지키세요 (2026-8-5 처럼 자릿수를 안 맞추면 정렬이 깨집니다).

기본 컬럼값

CREATE TABLE log (
    id         INTEGER PRIMARY KEY,
    msg        TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))     -- 괄호가 필요합니다
);
-- SQLite 에는 ON UPDATE CURRENT_TIMESTAMP 가 없습니다 → 트리거로 구현
CREATE TRIGGER trg_member_updated
AFTER UPDATE ON member
BEGIN
    UPDATE member SET updated_at = datetime('now') WHERE id = NEW.id;
END;

문자열 함수

SELECT length('안녕하세요')          AS 글자수,     -- 5 (UTF-8 글자 기준)
       upper('abc'), lower('ABC'),
       substr('데이터베이스', 1, 3)   AS 앞3글자,
       replace('a-b-c', '-', '/')     AS 치환,
       trim('  x  ')                  AS 공백제거,
       'a' || 'b'                     AS 결합,
       instr('abcdef', 'cd')          AS 위치,       -- 3
       printf('%05d', 42)             AS 자리채움,   -- 00042
       hex(randomblob(16))            AS 랜덤ID,
       group_concat(name, ', ')       AS 목록
FROM   member;

-- 정규식은 기본 제공되지 않습니다 (앱에서 함수를 등록해야 동작)
-- 대신 GLOB · LIKE 를 씁니다
SELECT * FROM member WHERE name GLOB '김*';       -- 대소문자 구분
SELECT * FROM member WHERE name LIKE '김%';       -- ASCII 는 대소문자 무시
SECTION 07

PRAGMA — 설정이 곧 성능

SQLite의 설정 스위치입니다. 기본값이 안전 위주라 느립니다 — 그리고 외래키가 기본으로 꺼져 있습니다.

외래키가 기본으로 동작하지 않습니다. FOREIGN KEY 를 선언해도 연결마다 PRAGMA foreign_keys = ON; 을 실행하지 않으면 제약이 무시됩니다. 존재하지 않는 회원의 주문이 그대로 들어갑니다. (하위 호환 때문에 기본값을 못 바꾸고 있습니다)
-- 연결할 때마다 실행해야 하는 것들 (앱 시작 코드에 넣으세요)
PRAGMA foreign_keys = ON;          -- ★ 외래키 활성화
PRAGMA journal_mode = WAL;         -- ★ 동시성 개선 (파일에 한 번만 설정되면 유지)
PRAGMA synchronous = NORMAL;       -- WAL 과 함께 쓰면 안전하면서 빠릅니다
PRAGMA busy_timeout = 5000;        -- ★ 락 대기 5초 (기본 0 = 즉시 실패)
PRAGMA cache_size = -64000;        -- 64MB (음수 = KB 단위)
PRAGMA temp_store = MEMORY;
PRAGMA설명
journal_mode DELETE(기본) → WAL 로 바꾸면 읽기·쓰기가 서로 안 막습니다 (s08)
synchronous FULL(기본·가장 안전) · NORMAL(WAL과 조합 시 권장) · OFF(빠르지만 정전 시 손상 가능)
busy_timeout 다른 연결이 잠갔을 때 몇 ms 기다릴지. 0이면 즉시 "database is locked"
foreign_keys외래키 제약 활성화 — 연결마다 필요
cache_size페이지 캐시 크기
mmap_size메모리 맵 IO 크기 — 읽기 위주에서 성능 향상
optimize연결을 닫기 전 실행하면 통계를 갱신합니다 (권장)
integrity_check파일 손상 검사
# 파이썬에서 연결할 때마다 적용하는 표준형
import sqlite3

def connect(path):
    conn = sqlite3.connect(path, timeout=5.0)
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("PRAGMA synchronous = NORMAL")
    conn.execute("PRAGMA busy_timeout = 5000")
    conn.row_factory = sqlite3.Row        # 딕셔너리처럼 접근
    return conn
PRAGMA synchronous = OFF 는 위험합니다. 쓰기가 몇 배 빨라지지만 전원이 나가면 DB 파일이 손상될 수 있습니다. "다시 만들면 되는" 캐시나 테스트에서만 쓰세요. WAL + NORMAL 조합이면 충분히 빠르고 안전합니다.
SQLite 는 설정 파일이 없습니다 — PRAGMA 몇 줄이 전부입니다 journal_mode = WAL 읽는 사람과 쓰는 사람이 서로 막지 않습니다 파일에 저장됩니다 — 한 번만 설정하면 됩니다 synchronous = NORMAL WAL 과 함께 쓰면 안전하면서 훨씬 빠릅니다 OFF 는 전원이 나가면 파일이 깨집니다 — 쓰지 마세요 foreign_keys = ON 기본값이 꺼져 있습니다 — 외래 키를 적어 두어도 검사하지 않습니다. 연결할 때마다 켜야 합니다 busy_timeout = 5000 잠겨 있으면 5초까지 기다렸다가 포기합니다 database is locked 의 절반은 이것으로 사라집니다(s11) 구분이 중요합니다 — journal_mode 는 파일에 남고, foreign_keys · busy_timeout 은 연결마다 다시 지정해야 합니다 앱에서 커넥션을 열 때 실행하는 초기화 구문에 넣어 두세요 — 풀을 쓴다면 새 커넥션마다 적용되어야 합니다 page_size · cache_size 도 있지만, 위 넷을 맞춘 뒤에 손대도 늦지 않습니다
SQLite 성능 문제의 대부분은 이 네 줄을 안 넣어서 생깁니다.
SQLite 는 설정 파일이 없습니다 — PRAGMA 몇 줄이 전부입니다 journal_mode = WAL 읽는 사람과 쓰는 사람이 서로 막지 않습니다 파일에 저장됩니다 — 한 번만 설정하면 됩니다 synchronous = NORMAL WAL 과 함께 쓰면 안전하면서 훨씬 빠릅니다 OFF 는 전원이 나가면 파일이 깨집니다 — 쓰지 마세요 foreign_keys = ON 기본값이 꺼져 있습니다 — 외래 키를 적어 두어도 검사하지 않습니다. 연결할 때마다 켜야 합니다 busy_timeout = 5000 잠겨 있으면 5초까지 기다렸다가 포기합니다 database is locked 의 절반은 이것으로 사라집니다(s11) 구분이 중요합니다 — journal_mode 는 파일에 남고, foreign_keys · busy_timeout 은 연결마다 다시 지정해야 합니다 앱에서 커넥션을 열 때 실행하는 초기화 구문에 넣어 두세요 — 풀을 쓴다면 새 커넥션마다 적용되어야 합니다 page_size · cache_size 도 있지만, 위 넷을 맞춘 뒤에 손대도 늦지 않습니다
SQLite 성능 문제의 대부분은 이 네 줄을 안 넣어서 생깁니다.
SECTION 08

WAL 모드와 동시성

기본 저널 모드에서는 쓰는 동안 아무도 못 읽습니다. WAL로 바꾸면 읽기와 쓰기가 서로를 막지 않습니다 — 실전 앱에서는 거의 필수입니다.

기본(DELETE 저널) — 쓰기가 원본을 직접 고친다 쓰기 1개 app.db 잠금 읽기 여러 개 — 전부 대기 database is locked 가 여기서 납니다 쓰기 한 번이 진행되는 동안 읽기 전체가 멈춰 섭니다 WAL 모드 — 변경분을 옆 파일에 덧붙인다 쓰기 1개 app.db-wal 에 append 읽기 여러 개 — 그대로 진행 원본 + WAL 을 겹쳐 본다 · 서로를 막지 않는다 단, 쓰기는 한 번에 하나이고 체크포인트 때 원본에 합쳐집니다 네트워크 드라이브(NFS·SMB)에서는 쓰지 마세요 — 잠금이 깨집니다
SQLite 로 실제 앱을 만든다면 PRAGMA journal_mode=WAL 한 줄이 체감 성능의 절반을 결정합니다.
DELETE 모드 (기본)
· 쓰기 전에 원본을 롤백 저널에 복사
· 쓰는 동안 읽기 차단
· 읽는 동안 쓰기 차단
· 파일: shop.db + shop.db-journal
WAL 모드
· 변경분을 별도 WAL 파일에 추가
· 읽기와 쓰기가 서로 안 막음
· 쓰기는 여전히 한 번에 하나
· 파일: shop.db + shop.db-wal + shop.db-shm
PRAGMA journal_mode = WAL;      -- 한 번 설정하면 파일에 저장되어 유지됩니다
PRAGMA journal_mode;            -- 확인 → wal

-- WAL 파일이 커지면 체크포인트로 본 파일에 반영
PRAGMA wal_checkpoint(TRUNCATE);

-- 자동 체크포인트 기준 (기본 1000 페이지)
PRAGMA wal_autocheckpoint = 1000;
WAL 모드의 제약
① 네트워크 파일시스템에서 동작하지 않습니다(공유 메모리를 쓰기 때문)
② DB 파일 하나가 세 개 파일(.db, -wal, -shm)이 됩니다 — 복사할 때 셋을 함께 옮기거나, 안전하게 .backup 을 쓰세요
③ 읽기 트랜잭션이 오래 열려 있으면 체크포인트가 밀려 WAL 파일이 무한정 커집니다

쓰기는 여전히 한 번에 하나입니다

WAL이 동시성을 크게 개선하지만, 쓰기 트랜잭션은 여전히 전체에서 한 번에 하나입니다. (테이블 단위도 아니고 DB 파일 전체 기준입니다) 초당 수백 건의 쓰기가 서로 다른 프로세스에서 들어오면 대기가 발생합니다.

쓰기가 몰리는 앱의 대처
① 쓰기를 한 프로세스·스레드로 모으기(큐를 두고 직렬화)
② 트랜잭션으로 묶기 — 1000건을 개별 커밋하면 1000번 디스크 동기화가 일어납니다
③ busy_timeout 을 넉넉히 두고 재시도
④ 그래도 부족하면 서버형 DB로 이전을 검토
# 트랜잭션으로 묶기 — 이 차이가 수백 배입니다
import sqlite3, time

conn = sqlite3.connect("bench.db")
conn.execute("CREATE TABLE IF NOT EXISTS t (v INTEGER)")

# ✗ 건별 커밋 : 10,000건에 수십 초
t0 = time.time()
for i in range(10_000):
    conn.execute("INSERT INTO t VALUES (?)", (i,))
    conn.commit()
print("건별 커밋", round(time.time() - t0, 2), "초")

# ✓ 한 트랜잭션 : 같은 건수에 0.1초 미만
t0 = time.time()
with conn:                                    # 블록이 끝나면 자동 커밋
    conn.executemany("INSERT INTO t VALUES (?)", ((i,) for i in range(10_000)))
print("일괄 커밋", round(time.time() - t0, 2), "초")
SECTION 09

인덱스 · 부분 인덱스

문법은 다른 DB와 같습니다. SQLite에서 특히 유용한 형태를 봅니다.

CREATE INDEX idx_orders_member ON orders (member_id);
CREATE INDEX idx_orders_mem_dt ON orders (member_id, ordered_at DESC);
CREATE UNIQUE INDEX idx_member_email ON member (email);

-- 부분 인덱스 : 조건에 맞는 행만 색인 → 인덱스가 훨씬 작아집니다
CREATE INDEX idx_orders_active ON orders (ordered_at)
  WHERE status != 'CANCELED';

-- 표현식 인덱스
CREATE INDEX idx_member_lower ON member (lower(email));
SELECT * FROM member WHERE lower(email) = 'a@b.com';   -- 이 형태여야 탑니다

-- 통계 갱신 (쿼리 플래너가 더 나은 선택을 합니다)
ANALYZE;
PRAGMA optimize;      -- 연결을 닫기 전에 실행하면 필요한 만큼만 갱신합니다

실행계획 보기

EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE member_id = 1 ORDER BY ordered_at DESC;
QUERY PLAN `--SEARCH orders USING INDEX idx_orders_mem_dt (member_id=?)
계획에 나오는 말의미
SEARCH … USING INDEX인덱스로 찾음 — 좋습니다
USING COVERING INDEX인덱스만으로 해결 — 가장 좋습니다
SCAN table전체 스캔 — 큰 테이블이면 인덱스를 검토하세요
USE TEMP B-TREE FOR ORDER BY정렬용 임시 구조 생성 — 정렬에 맞는 인덱스가 없습니다
.expert 명령 — sqlite3 CLI 에서 .expert 를 켠 뒤 쿼리를 실행하면 필요한 인덱스를 추천해 줍니다. 실험적 기능이지만 시작점으로는 유용합니다.
인덱스의 원리(왜 빠른지, 복합 인덱스 순서, 안 타는 이유)는 DB 공통이므로 ⚙️ 설계 · 튜닝 탭 t04~t06 에 정리돼 있습니다.
SECTION 10

FTS5 전문 검색 · JSON

파일 하나짜리 DB에 본문 검색과 문서 저장이 함께 들어 있습니다.

FTS5 — 전문 검색

-- 가상 테이블로 만듭니다
CREATE VIRTUAL TABLE post_fts USING fts5(
    title, body,
    tokenize = 'trigram'          -- ★ 한국어는 trigram 또는 unicode61 을 씁니다
);

INSERT INTO post_fts (title, body) VALUES
 ('SQLite 입문', 'SQLite 는 서버가 없는 데이터베이스입니다'),
 ('인덱스 이야기', 'B-Tree 인덱스는 탐색을 빠르게 합니다');

-- 검색
SELECT * FROM post_fts WHERE post_fts MATCH '데이터베이스';
SELECT * FROM post_fts WHERE post_fts MATCH 'SQLite AND 서버가';   -- trigram 은 3글자 미만('서버')은 못 찾습니다

-- 랭킹 순 + 하이라이트
SELECT title, snippet(post_fts, 1, '<b>', '</b>', '…', 20) AS 발췌, rank
FROM   post_fts
WHERE  post_fts MATCH '인덱스'
ORDER  BY rank;
한국어에는 tokenize='trigram' 을 쓰세요(3.34+). 3글자 단위로 색인하므로 조사가 붙어도 부분 일치가 잘 걸립니다. 기본 unicode61 토크나이저는 공백 기준이라 한국어에 잘 맞지 않습니다.
-- 원본 테이블과 연동 (external content 방식 — 중복 저장을 피합니다)
CREATE VIRTUAL TABLE post_fts USING fts5(
    title, body, content='post', content_rowid='id', tokenize='trigram'
);

-- 원본이 바뀔 때 색인을 따라가게 하는 트리거
CREATE TRIGGER post_ai AFTER INSERT ON post BEGIN
  INSERT INTO post_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER post_ad AFTER DELETE ON post BEGIN
  INSERT INTO post_fts(post_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER post_au AFTER UPDATE ON post BEGIN
  INSERT INTO post_fts(post_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
  INSERT INTO post_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;

JSON — 기본 내장 3.38+

CREATE TABLE item (id INTEGER PRIMARY KEY, data TEXT);
INSERT INTO item (data) VALUES ('{"name":"키보드","price":49000,"tags":["무선","사무용"]}');

SELECT json_extract(data, '$.name')  AS name,
       data ->> '$.price'            AS price          -- 3.38+ 연산자
FROM   item;

-- 배열 펼치기
SELECT i.id, j.value AS tag FROM item i, json_each(i.data, '$.tags') j;

-- 수정
UPDATE item SET data = json_set(data, '$.price', 45000) WHERE id = 1;

-- 표현식 인덱스로 검색 최적화
CREATE INDEX idx_item_name ON item (json_extract(data, '$.name'));

-- 3.45+ 는 이진 JSONB 도 지원합니다 (더 빠름)
SELECT jsonb_extract(jsonb(data), '$.name') FROM item;
그 밖의 확장 — R-Tree(공간 인덱스) · math functions(3.35+) · generate_series · sqlite-vec(벡터 검색, 서드파티) 등이 있습니다. 파일 하나로 상당한 기능을 커버할 수 있습니다.
SECTION 11

database is locked 해결

SQLite 질문 1위입니다. 원인은 몇 가지로 정해져 있고, 대부분 설정 두 줄로 해결됩니다.

원인해결
다른 연결이 쓰기 중 PRAGMA busy_timeout = 5000 — 기본값 0이라 즉시 실패합니다
기본 저널 모드 PRAGMA journal_mode = WAL — 읽기가 쓰기를 막지 않게
트랜잭션을 안 닫음 커밋·롤백을 빠뜨린 연결이 락을 붙들고 있습니다. with 문·try/finally 로 보장하세요
커서를 닫지 않음 결과를 끝까지 읽지 않은 커서가 읽기 락을 유지합니다
여러 스레드가 한 연결 공유 연결은 스레드마다 따로 만드세요
네트워크 드라이브 락이 동작하지 않습니다 — 로컬 디스크로 옮기세요
긴 쓰기 트랜잭션 대량 작업을 청크로 나누고 중간에 커밋하세요
같은 순간 두 연결이 쓰려고 하면 — busy_timeout 의 효과 연결 A 쓰기 트랜잭션 — 락 보유 커밋 연결 B timeout 0 (기본) 쓰기 시도 → SQLITE_BUSY 즉시 실패 연결 B timeout 5000 대기 … (최대 5초) 이어서 쓰기 성공 PRAGMA busy_timeout = 5000 — 기본값 0은 기다리지 않고 바로 에러를 냅니다
"database is locked" 의 대부분은 버그가 아니라 기다릴 줄 모르는 기본 설정 때문입니다.
# 파이썬에서 안전하게 쓰는 형태
import sqlite3, threading

local = threading.local()

def conn():
    if not hasattr(local, "c"):                 # ★ 스레드마다 별도 연결
        c = sqlite3.connect("shop.db", timeout=5.0)
        c.execute("PRAGMA journal_mode=WAL")
        c.execute("PRAGMA busy_timeout=5000")
        c.execute("PRAGMA foreign_keys=ON")
        local.c = c
    return local.c

def add_order(member_id):
    c = conn()
    with c:                                     # 예외가 나면 자동 롤백, 정상이면 커밋
        c.execute("INSERT INTO orders (member_id) VALUES (?)", (member_id,))

쓰기 트랜잭션은 처음부터 BEGIN IMMEDIATE 로

-- ✗ 기본 BEGIN 은 지연 트랜잭션 :
--    읽기로 시작했다가 쓰기로 승격할 때 다른 세션과 충돌해
--    SQLITE_BUSY 가 나고 롤백해야 합니다
BEGIN;
SELECT qty FROM stock WHERE id = 1;
UPDATE stock SET qty = qty - 1 WHERE id = 1;   -- ← 여기서 실패 가능
COMMIT;

-- ✓ 쓸 것을 알면 처음부터 쓰기 락을 잡습니다
BEGIN IMMEDIATE;
SELECT qty FROM stock WHERE id = 1;
UPDATE stock SET qty = qty - 1 WHERE id = 1;
COMMIT;
이것이 실무에서 가장 효과적인 대책입니다. "읽고 나서 쓰는" 트랜잭션은 반드시 BEGIN IMMEDIATE 로 시작하세요. 파이썬 sqlite3 에서는 sqlite3.connect(..., isolation_level="IMMEDIATE") 로 지정합니다.
파이썬 3.12 이전의 sqlite3 는 자동 커밋 동작이 특이합니다. DML 앞에서 암묵적으로 트랜잭션을 열고, DDL 앞에서는 커밋해 버립니다. 혼란을 피하려면 isolation_level=None 으로 두고 트랜잭션을 직접 BEGIN/COMMIT 하는 편이 명확합니다. (3.12+ 에서는 autocommit 속성이 추가되어 표준에 가까워졌습니다)
SECTION 12

백업 · 무결성 · 암호화

쓰는 중에 파일을 그냥 복사하면 깨집니다. 안전한 방법을 씁니다.

# ✓ 안전한 백업 (온라인 백업 API 사용 — 쓰기 중에도 일관된 사본)
sqlite3 shop.db ".backup 'backup-$(date +%F).db'"

# ✓ VACUUM INTO — 조각 모음까지 하며 새 파일로 (3.27+)
sqlite3 shop.db "VACUUM INTO 'clean.db';"

# ✓ SQL 텍스트로 (다른 DB로 옮길 때·버전 관리용)
sqlite3 shop.db .dump > shop.sql
sqlite3 new.db < shop.sql

# ✗ 위험 : 쓰기 중이면 손상된 사본이 만들어집니다
cp shop.db backup.db
# 파이썬 백업 API (진행률 표시 가능)
import sqlite3
src = sqlite3.connect("shop.db")
dst = sqlite3.connect("backup.db")
with dst:
    src.backup(dst, pages=100, progress=lambda s, r, t: print(f"{t-r}/{t}"))
dst.close(); src.close()

무결성 검사와 정리

PRAGMA integrity_check;        -- 전체 검사 (ok 가 나와야 정상)
PRAGMA quick_check;            -- 빠른 검사
PRAGMA foreign_key_check;      -- 외래키 위반 행 찾기 (나중에 켠 경우 유용)

VACUUM;                        -- 조각 모음 · 파일 크기 축소
                               -- ⚠️ 원본 크기만큼의 임시 공간이 필요하고 그동안 잠깁니다

PRAGMA page_count;             -- 페이지 수
PRAGMA freelist_count;         -- 빈 페이지 수 (많으면 VACUUM 대상)

암호화

기본 SQLite에는 암호화 기능이 없습니다. 파일을 열면 내용이 그대로 보입니다. 필요하다면 다음 중 하나를 씁니다.

방법설명
SQLCipher가장 널리 쓰이는 오픈소스 암호화 빌드. 모바일 앱에서 표준
SEESQLite 개발사의 공식 유료 확장
OS 전체 디스크 암호화모바일·노트북이라면 이미 적용돼 있을 수 있습니다 — 먼저 확인
애플리케이션 레벨민감한 컬럼만 앱에서 암호화해 저장
모바일 앱의 SQLite 는 그냥 읽힐 수 있습니다. 루팅·탈옥된 기기나 백업 파일에서 DB를 꺼내면 내용이 그대로 보입니다. 토큰·개인정보를 SQLite 평문으로 저장하지 마세요. OS 키체인(iOS Keychain · Android Keystore)을 쓰거나 SQLCipher 를 적용하세요.
SECTION 13

어디까지 써도 되나

"SQLite는 장난감"이라는 말은 사실이 아닙니다. 다만 한계는 분명합니다.

공식 한계 (사실상 안 걸립니다)

항목한계
DB 파일 최대 크기281TB (기본 페이지 크기 기준)
테이블 행 수사실상 무제한 (2^64)
컬럼 수기본 2,000 (최대 32,767)
문자열·BLOB 크기기본 1GB
동시 읽기 연결제한 없음
동시 쓰기 연결사실상 1개 (DB 파일 단위)

현실적인 판단 기준

🟢 문제없음
  • 읽기 위주 웹사이트 (초당 수백~수천 조회)
  • 데이터 수 GB, 수천만 행
  • 단일 서버에서 동작하는 앱
  • 쓰기가 초당 수십 건 이하
🔵 설계가 필요
  • 쓰기가 초당 수백 건 — 배치·큐로 직렬화
  • 여러 프로세스가 동시에 쓰기
  • 장시간 도는 분석 쿼리와 쓰기가 공존
  • WAL + busy_timeout 필수
🟣 다른 DB로
  • 여러 서버에서 같은 DB에 쓰기
  • 네트워크 너머 접근이 필요
  • 사용자별 세밀한 권한 관리
  • 고가용성·자동 페일오버 요구
  • 쓰기가 지속적으로 초당 1,000건 이상
실제 사례 — 여러 서비스가 SQLite 로 상당한 트래픽을 처리합니다. 핵심은 "읽기는 무제한, 쓰기는 하나"라는 특성에 맞게 설계하는 것입니다. 읽기가 압도적이면 SQLite가 네트워크 왕복이 없어 오히려 더 빠릅니다.

SQLite 를 서버처럼 쓰는 도구들

도구하는 일
LitestreamSQLite 변경을 S3 등으로 실시간 복제 — 재해 복구
LiteFS파일시스템 수준 복제로 여러 노드에 읽기 복제본
rqliteRaft 합의를 붙인 분산 SQLite
Turso / libSQLSQLite 포크 + 엣지 분산 서비스
DatasetteSQLite 파일을 읽기 전용 웹 API·탐색 UI 로
선택의 기준은 "동시 쓰기"입니다. 쓰기 주체가 하나의 프로세스로 정리되는 구조라면 SQLite로 충분합니다. 여러 서버가 동시에 써야 한다면, 그 순간이 PostgreSQL·MySQL로 옮길 시점입니다.
어디까지 써도 되나 — 경계가 뚜렷합니다 아주 잘 맞는 자리 데스크탑 · 모바일 앱의 내장 저장소 — 세계에서 가장 많이 배포된 DB 인 이유입니다 설정 · 로컬 캐시 · 분석용 데이터 파일 · 테스트 픽스처 읽기가 대부분인 소규모 웹사이트 — WAL 모드면 읽기와 쓰기가 동시에 됩니다 쓰면 안 되는 자리 여러 서버가 같은 파일을 동시에 쓰는 구조 — 파일 잠금이 제대로 동작하지 않습니다 NFS · SMB 같은 네트워크 파일 시스템 위 — 손상 위험이 실제로 있습니다 쓰기가 몰리는 서비스 — 쓰기는 한 번에 한 명만 가능합니다 한 대에서 돌고 쓰기가 초당 수십 건 수준이면 대개 충분합니다 — 그 이상이면 서버형 DB 로 "나중에 옮길 수도 있다" 면 처음부터 표준 SQL 만 쓰세요 — 마이그레이션이 훨씬 쉬워집니다
SQLite 를 고를 때의 질문은 규모가 아니라 동시에 쓰는 프로세스가 몇 개인가입니다.
SECTION 14

앱에 내장하기

언어·플랫폼별로 SQLite를 붙이는 실제 코드입니다.

Python — 표준 라이브러리

import sqlite3
from contextlib import closing

conn = sqlite3.connect("shop.db", timeout=5.0, isolation_level="IMMEDIATE")
conn.row_factory = sqlite3.Row                 # 컬럼명으로 접근
for p in ("journal_mode=WAL", "foreign_keys=ON", "busy_timeout=5000"):
    conn.execute(f"PRAGMA {p}")

with conn:                                     # 자동 커밋 · 예외 시 롤백
    conn.execute("INSERT INTO member (name) VALUES (?)", ("김하늘",))

with closing(conn.execute("SELECT id, name FROM member WHERE grade = ?", ("GOLD",))) as cur:
    for row in cur:
        print(row["id"], row["name"])

Node.js — better-sqlite3 (동기 · 빠름)

import Database from 'better-sqlite3';

const db = new Database('shop.db');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');

const insert = db.prepare('INSERT INTO member (name) VALUES (?)');
const many = db.transaction((names) => {       // 트랜잭션으로 묶기
  for (const n of names) insert.run(n);
});
many(['김하늘', '박바다']);

const rows = db.prepare('SELECT id, name FROM member WHERE grade = ?').all('GOLD');
console.log(rows);
Node 22+ 에는 node:sqlite 가 내장되어 별도 설치 없이 쓸 수 있습니다. 다만 아직 기능이 제한적이라, 실무에서는 better-sqlite3 가 여전히 표준입니다. 비동기 API가 필요하면 node-sqlite3 를 쓰지만, SQLite 는 로컬 파일 IO 라 동기 API 가 대개 더 빠릅니다.

Java — JDBC

// build.gradle : implementation 'org.xerial:sqlite-jdbc:3.50.x'
String url = "jdbc:sqlite:shop.db";
try (Connection c = DriverManager.getConnection(url)) {
    try (Statement s = c.createStatement()) {
        s.execute("PRAGMA journal_mode=WAL");
        s.execute("PRAGMA foreign_keys=ON");
    }
    c.setAutoCommit(false);
    try (PreparedStatement ps = c.prepareStatement("INSERT INTO member (name) VALUES (?)")) {
        ps.setString(1, "김하늘");
        ps.executeUpdate();
    }
    c.commit();
}

모바일 · 데스크톱

플랫폼권장 방식
AndroidRoom (Jetpack) — SQLite 위의 ORM. 마이그레이션·타입 안전성 제공
iOSGRDB.swift 또는 Core Data (내부가 SQLite)
Fluttersqflite · drift
Electronbetter-sqlite3 — 메인 프로세스에서만 접근하고 IPC 로 넘기세요
브라우저sql.js (WASM) · wa-sqlite — OPFS 에 저장 가능
모바일 앱에서 반드시 지킬 것
① 메인(UI) 스레드에서 DB 작업을 하지 마세요 — 화면이 멈춥니다
② 스키마 마이그레이션 코드를 처음부터 넣으세요 — 앱은 업데이트해도 사용자 기기의 DB는 그대로입니다
③ 민감 정보는 암호화하거나 OS 키체인에
④ DB 파일이 앱 삭제 시 사라진다는 점을 사용자에게 알리거나 동기화를 제공하세요
더 보기 — 언어별 연결·트랜잭션·ORM 은 🔌 앱 연동 탭에 네 DB 공통으로 정리돼 있습니다. 파이썬에서 SQLite 를 GUI 앱에 내장하는 예제는 🐍 파이썬 가이드의 PySide6 탭에 있습니다.
DB 파일을 어디에 둘 것인가 — 여기서 대부분 실수합니다 실행 파일 옆 (틀린 자리) C:\Program Files\MyApp\data.db 쓰기 권한이 없어 실행이 안 되거나, 업데이트할 때 통째로 지워집니다 사용자 데이터 폴더 (맞는 자리) 윈도우 %LOCALAPPDATA%\MyApp\ 맥 ~/Library/Application Support/MyApp/ 안드로이드·iOS 는 앱 전용 저장소를 씁니다 첫 실행 흐름 파일이 있나? 없으면 새로 만듭니다 user_version 확인 스키마 버전 번호 모자란 만큼만 이행 스크립트 실행 v2 → v5 면 3, 4, 5 를 차례로 백업은 파일 복사가 아니라 VACUUM INTO 나 백업 API 로 하세요 — 쓰는 도중 복사하면 깨진 파일이 나옵니다 네트워크 드라이브(SMB·NFS)에 두면 잠금이 제대로 동작하지 않습니다 — 여러 대가 같이 쓰는 구조라면 SQLite 가 아닙니다
내장 DB 의 사고는 성능이 아니라 파일 위치와 백업에서 납니다.
DB 파일을 어디에 둘 것인가 — 여기서 대부분 실수합니다 실행 파일 옆 (틀린 자리) C:\Program Files\MyApp\data.db 쓰기 권한이 없어 실행이 안 되거나, 업데이트할 때 통째로 지워집니다 사용자 데이터 폴더 (맞는 자리) 윈도우 %LOCALAPPDATA%\MyApp\ 맥 ~/Library/Application Support/MyApp/ 안드로이드·iOS 는 앱 전용 저장소를 씁니다 첫 실행 흐름 파일이 있나? 없으면 새로 만듭니다 user_version 확인 스키마 버전 번호 모자란 만큼만 이행 스크립트 실행 v2 → v5 면 3, 4, 5 를 차례로 백업은 파일 복사가 아니라 VACUUM INTO 나 백업 API 로 하세요 — 쓰는 도중 복사하면 깨진 파일이 나옵니다 네트워크 드라이브(SMB·NFS)에 두면 잠금이 제대로 동작하지 않습니다 — 여러 대가 같이 쓰는 구조라면 SQLite 가 아닙니다
내장 DB 의 사고는 성능이 아니라 파일 위치와 백업에서 납니다.
REDIS 7.x · IN-MEMORY · KEY-VALUE · NOSQL

Redis —
메모리에서 답하는
가장 빠른 저장소

Redis는 데이터를 디스크가 아니라 메모리에 두고 마이크로초 단위로 답하는 키-값 저장소입니다. 실무에서는 RDBMS 앞에 두는 캐시, 세션 저장소, 순위표, 분산 락으로 쓰입니다. 이 탭은 NoSQL이 뭔지부터 시작해서, Redis를 실서비스에 붙일 때 필요한 것까지 다룹니다.

13SECTIONS
5+자료구조
캐시대표 용도
SECTION 01

NoSQL이란 — RDBMS와 무엇이 다른가

NoSQL은 "SQL을 안 쓴다"가 아니라 "표 구조를 강제하지 않는다(Not only SQL)"는 뜻입니다. 관계형(RDBMS)을 대체하는 게 아니라, 관계형이 불편한 자리를 보완하는 도구들의 통칭이에요.

RDBMS — 정해진 표 + 표끼리 연결 member 표 id · name · email 모든 행이 같은 칸 구조 orders 표 member_id 로 연결(FK) 키-값 (Redis) "session:9f2a" → {userId:1, cart:[…]} "rank:daily" → 정렬된 점수 목록 키로만 찾는다 — 조인 없음 값의 내용은 묻지 않는다 문서형 (MongoDB) { name:"김하늘", orders:[ {…}, {…} ], tags:["vip"] } 문서마다 칸이 달라도 됨 관련 데이터를 한 문서에 담아 조인을 줄인다
셋의 차이는 데이터를 어떤 모양으로 저장하느냐입니다 — 표, 키-값, JSON 문서.

정면 비교 — 무엇이 다른가

항목RDBMS (PostgreSQL·MySQL…)NoSQL (Redis·MongoDB…)
데이터 모양정해진 칸의 표. 모든 행이 같은 구조키-값·JSON 문서 등 유연한 구조
스키마먼저 정하고 시작 (칸 추가 = ALTER)없거나 느슨함 — 데이터마다 칸이 달라도 됨
표끼리 연결JOIN — 정규화해 나눠 두고 붙여서 조회조인이 없거나 약함 — 한 덩어리로 미리 묶어 저장
트랜잭션ACID 완전 보장 — 돈 계산의 표준제한적 (단일 키·단일 문서 위주, 제품마다 다름)
규모 확장기본은 서버 키우기(수직) + 읽기 복제처음부터 서버 늘리기(수평 분산)를 겨냥한 설계
질의 언어SQL — 표준이 있어 어디서나 비슷제품마다 다름 (Redis 명령어, MongoDB 쿼리…)
잘 맞는 데이터회원·주문·재고 — 정확해야 하는 업무 데이터캐시·세션·로그·피드 — 빠르고 많은 데이터

NoSQL 4갈래 — 이름만 알아 두세요

갈래대표 제품데이터 모양 · 쓰는 곳
키-값Redis · Memcached키 → 값. 캐시·세션·카운터. 이 탭에서 다룹니다
문서형MongoDB · CouchDBJSON 문서. 구조가 자주 바뀌는 데이터. 🍃 MongoDB 탭
와이드 컬럼Cassandra · HBase행마다 열이 다른 거대한 표. 시계열·초대용량 쓰기
그래프Neo4j점과 선(관계). 추천·친구관계·사기 탐지
가장 중요한 결론 — 대체가 아니라 역할 분담입니다. 실무 표준 구성은 "본체는 RDBMS + 빠른 자리는 Redis + 유연한 자리는 MongoDB"처럼 각자 잘하는 곳에 두는 것입니다. "NoSQL이 신형이니 RDBMS를 버린다"가 아니에요. 돈·주문·회원처럼 틀리면 안 되는 데이터는 여전히 RDBMS가 기본입니다.
왜 NoSQL이 생겼나 — 2000년대 후반, 구글·아마존 규모의 서비스가 "한 대의 DB로는 트래픽을 못 받는" 문제를 만났습니다. 표를 여러 서버로 쪼개면 JOIN과 트랜잭션이 서버를 넘나들며 급격히 느려지기 때문에, 아예 조인·엄격한 스키마를 포기하고 분산을 쉽게 만든 저장소가 나온 것입니다. 즉 NoSQL은 "SQL보다 좋은 것"이 아니라 다른 트레이드오프를 고른 것입니다.
SECTION 02

Redis는 어떤 저장소인가

Redis가 빠른 이유는 단순합니다 — 모든 데이터가 메모리에 있고, 명령을 한 줄로 세워 순서대로 처리하기 때문입니다.

세 가지 특징이 전부입니다

  • ① 인메모리 — 데이터가 전부 RAM에 있습니다. 디스크 DB가 밀리초(ms)로 답할 때 Redis는 마이크로초(µs)로 답합니다. 대신 메모리 크기가 곧 저장 용량의 한계입니다.
  • ② 싱글 스레드 명령 처리 — 명령을 한 번에 하나씩 순서대로 실행합니다. 락 경합이 없어 단순하고, INCR 같은 연산이 저절로 원자적입니다. 뒤집어 말하면 느린 명령 하나가 전체를 막습니다 (r12에서 다룹니다).
  • ③ 자료구조 서버 — 값이 단순 문자열만이 아닙니다. 리스트·집합·정렬된 집합·해시를 서버가 직접 제공해서, "순위표"나 "최근 본 상품" 같은 기능이 명령 몇 개로 끝납니다.

체감 속도 차이

작업대략적인 시간
Redis GET (메모리)약 0.1ms 이하 — 같은 네트워크 기준
RDBMS 인덱스 조회 (캐시 적중)수 ms
RDBMS 복잡한 JOIN·집계수십 ms ~ 수 초
그래서 어디에 쓰나 — 같은 결과를 매번 다시 계산할 필요가 없는 곳입니다. "메인 페이지 인기 상품 목록"을 매 요청마다 RDBMS 집계로 만들면 수백 ms, 1분에 한 번 계산해 Redis에 올려 두면 0.1ms입니다. 이것이 캐시(r06)의 전부입니다.
Redis를 유일한 저장소로 쓰지 마세요. 기본 설정의 Redis는 서버가 죽으면 최근 데이터가 사라질 수 있습니다 (영속성은 r08). "사라져도 다시 만들 수 있는 데이터"(캐시·세션)를 두는 곳으로 시작하는 게 안전합니다.
SECTION 03

설치와 첫 접속 — Docker · redis-cli

도커 한 줄이면 뜹니다. redis-cli 로 명령을 직접 쳐 보는 것까지가 설치입니다.

# Docker — 가장 빠른 길
#   공식 이미지는 protected-mode 가 꺼져 있어 -p 6379:6379 는 모든 인터페이스에 비밀번호 없이 열립니다
#   → 로컬 학습은 127.0.0.1 에만 묶으세요
docker run -d --name redis -p 127.0.0.1:6379:6379 redis:7
docker exec -it redis redis-cli     # 바로 CLI 접속

# Ubuntu · Debian
sudo apt install -y redis-server
sudo systemctl enable --now redis-server

# macOS
brew install redis && brew services start redis

# Windows — 공식 지원이 없습니다. Docker 또는 WSL2 를 쓰세요

첫 명령 다섯 개

127.0.0.1:6379> PING
PONG                        # 살아 있음
127.0.0.1:6379> SET greeting "hello"
OK
127.0.0.1:6379> GET greeting
"hello"
127.0.0.1:6379> DEL greeting
(integer) 1
127.0.0.1:6379> KEYS *      # ← 학습용으로만! 운영 금지 (r12)
(empty array)
기본 Redis에는 비밀번호가 없습니다. 기본 설정은 localhost만 듣지만, bind 0.0.0.0 으로 열고 비밀번호 없이 인터넷에 노출하면 몇 분 안에 스캐너가 찾아내 데이터를 지우고 협박 메시지를 남깁니다 (실제로 흔한 사고입니다). 외부에 열어야 한다면 requirepass + 방화벽 + protected-mode yes 를 확인하세요.
포트는 6379, GUI가 필요하면 RedisInsight(공식 무료)를 쓰세요. 2024년 라이선스 논쟁으로 갈라져 나온 Valkey(리눅스재단, 완전 오픈소스)도 명령이 그대로 호환됩니다 — 회사 정책상 라이선스가 걸리면 Valkey를 검토하세요.
SECTION 04

키 설계와 TTL — 유효기간

Redis 사용법의 절반은 키 이름을 어떻게 짓고, 언제 사라지게 할 것인가입니다.

키 이름은 콜론으로 계층을 만듭니다

# 관례: 객체:아이디:속성  — 소문자, 콜론(:) 구분
SET user:1001:name "김하늘"
SET session:9f2a71 "{\"userId\":1001}"
SET cache:product:15 "{\"name\":\"무선 키보드\",\"price\":49000}"

# 나쁜 키 — 무엇인지 알 수 없고 충돌합니다
SET data1 ...
SET temp ...

TTL — 키에 수명 붙이기

SET cache:main:popular "..." EX 60      # 60초 뒤 자동 삭제
SET session:9f2a71 "..." EX 1800        # 세션 30분

TTL cache:main:popular      # 남은 초 확인 (-1 = 무기한, -2 = 없는 키)
EXPIRE user:1001:name 3600  # 이미 있는 키에 수명 부여
PERSIST session:9f2a71      # 수명 제거 (무기한으로)
캐시 키에는 반드시 TTL을 거세요. TTL 없는 캐시 키는 지워지지 않고 쌓여 메모리를 끝까지 채웁니다. "이 키는 최악의 경우 언제까지 낡은 값이어도 되는가"를 초로 적는 것이 TTL 설계입니다.

실무 키 사전 — 용도별 이름·수명 견본

키 패턴담는 것 · TTL 기준
cache:product:{id}DB 조회 결과 캐시 — TTL 30초~5분 (낡아도 되는 시간만큼)
session:{sid}로그인 세션 — TTL 30분, 요청마다 EXPIRE 로 연장
lock:{자원이름}분산 락 — TTL 수십 초 (작업 최장 시간 + 여유)
rate:{userId}:{분단위시각}처리량 카운터 — TTL 60초 (창 크기만큼)
rank:{기간}순위표 Sorted Set — 기간 끝나면 통째로 UNLINK
otp:{전화번호}인증번호 — TTL 3분, 검증 성공 시 즉시 DEL

키를 훑어야 할 때 — SCAN (KEYS 대신)

# 접두사로 순회 — 조금씩 잘라 가져와서 서버를 안 멈춥니다
SCAN 0 MATCH cache:product:* COUNT 500
# → 다음 커서 번호와 키 묶음이 옵니다. 커서가 0이 될 때까지 반복

# 접두사 일괄 삭제 (운영에서 FLUSHALL 대신 쓰는 방법)
redis-cli --scan --pattern "cache:v1:*" | xargs -L 500 redis-cli UNLINK
데이터베이스 번호 — Redis 안에는 db0~db15 라는 논리 공간이 있지만 (SELECT 1로 전환), 실무에서는 번호 구분 대신 키 접두사를 쓰고 용도가 다르면 아예 Redis 인스턴스를 분리하는 것이 표준입니다 — 클러스터 모드에서는 db0만 쓸 수 있기 때문입니다.
SECTION 05

자료구조 5종 — 값의 모양이 무기

값이 문자열 하나뿐이라면 Redis가 이렇게 쓰이지 않았을 겁니다. 어떤 기능에 어떤 자료구조를 쓰는지가 Redis 실력의 핵심입니다.

구조모양대표 명령 · 쓰는 곳
String키 → 값 하나 (문자열·숫자·JSON)SET GET INCR — 캐시 · 카운터 · 세션
Hash키 → {필드: 값, …} 작은 표 한 줄HSET HGET HGETALL — 객체 저장 (user:1001 의 이름·등급…)
List키 → 순서 있는 목록 (양끝 삽입)LPUSH RPOP LRANGE — 최근 본 상품 · 간단한 작업 큐
Set키 → 중복 없는 집합SADD SISMEMBER SINTER — 좋아요 누른 사람 · 태그 · 교집합
Sorted Set키 → (점수, 멤버) 정렬 유지ZADD ZRANGE ZRANK — 순위표 · 랭킹 · 지연 큐

직접 쳐 보기

# String — 조회수 카운터 (INCR 은 원자적: 동시에 눌러도 안 틀림)
INCR views:post:42            # 1
INCR views:post:42            # 2

# Hash — 유저 객체를 필드 단위로
HSET user:1001 name "김하늘" grade "GOLD" city "서울"
HGET user:1001 grade          # "GOLD"
HGETALL user:1001             # 전체 필드

# List — 최근 본 상품 10개 유지
LPUSH recent:1001 "p:15"      # 앞에 넣고
LTRIM recent:1001 0 9         # 10개만 남긴다
LRANGE recent:1001 0 -1       # 전체 조회

# Set — 오늘 방문한 유저 (중복 자동 제거)
SADD visit:2026-08-11 "u:1001" "u:1002" "u:1001"
SCARD visit:2026-08-11        # 2 — 유니크 방문자 수

# Sorted Set — 게임 순위표
ZADD rank:weekly 1500 "김하늘" 2100 "박바다" 1800 "이구름"
ZREVRANGE rank:weekly 0 2 WITHSCORES   # 상위 3명, 점수 포함
ZREVRANK rank:weekly "김하늘"          # 내 등수 (0부터)
ZINCRBY rank:weekly 50 "김하늘"        # 점수 가산
고르는 요령 — "이 데이터를 어떻게 꺼낼 것인가"부터 정하세요. 통째로 꺼낸다 → String(JSON), 필드 하나씩 → Hash, 최신 N개 → List, 있는지 확인·집합 연산 → Set, 순서·순위 → Sorted Set. RDBMS처럼 "일단 넣고 나중에 쿼리"가 안 되는 대신, 꺼내는 모양대로 넣어 두면 조회가 O(1)~O(logN)으로 끝납니다.

String(JSON) vs Hash — 객체는 어느 쪽에

기준String + JSONHash
꺼내는 단위항상 통째로 (직렬화/역직렬화)필드 하나씩 읽고 고칠 수 있음
부분 수정전체를 읽어 고쳐 다시 저장HSET user:1 grade GOLD 한 방
숫자 증가불가 (읽고-고치고-쓰기 경합)HINCRBY user:1 points 100 원자적
TTL키 단위 ✓키 단위만 — 필드별 TTL 없음
어울리는 곳캐시 응답처럼 통으로 쓰고 버리는 것계속 갱신되는 상태(유저 프로필·장바구니)
더 있는 것들 — 비트 단위 출석 체크(Bitmap), 오차 0.8%로 유니크 수 세기(HyperLogLog — 수억 명도 12KB), 위치 기반 반경 검색(Geo), 스트림(r10)까지 내장돼 있습니다.
# Bitmap — 8월 출석 체크 (유저ID를 비트 위치로)
SETBIT attend:2026-08-11 1001 1     # 1001번 유저 오늘 출석
BITCOUNT attend:2026-08-11          # 오늘 출석자 수
# 한 달 개근: BITOP AND 로 31일치를 겹치면 끝

# HyperLogLog — 일간 순방문자(UV)를 12KB 로
PFADD uv:2026-08-11 "u:1001" "u:1002"
PFCOUNT uv:2026-08-11               # 오차 ±0.8% 의 유니크 수
PFMERGE uv:aug uv:2026-08-01 uv:2026-08-02   # 월간 합산
자료구조 5종 — 값의 모양이 곧 쓸 수 있는 명령입니다 String — 문자열 · 숫자 SET · GET · INCR · SETEX 캐시 · 조회수 카운터 · 잠금 플래그. 가장 많이 씁니다 List — 순서가 있는 목록 LPUSH · RPOP · LRANGE · BLPOP 간단한 작업 큐 · 최근 본 상품. 양끝 넣고 빼기가 즉시입니다 Hash — 필드가 여럿인 객체 HSET · HGET · HGETALL · HINCRBY 세션 · 사용자 정보. 필드 하나만 고쳐도 전체를 다시 안 써도 됩니다 Set — 중복 없는 모음 SADD · SISMEMBER · SINTER 태그 · 중복 방문자 · 공통 친구(교집합) Sorted Set — 점수가 붙은 모음 ZADD · ZRANGE · ZREVRANK 순위표 · 시간순 타임라인 · 지연 큐. Redis 를 쓰는 큰 이유 중 하나입니다
고르는 순서는 값이 아니라 질문입니다 — 나중에 무엇을 물어볼 것인가.
SECTION 06

캐시 전략 — Cache-Aside 패턴

Redis의 사용처 1위는 압도적으로 캐시입니다. 패턴은 사실상 하나만 알면 됩니다 — "없으면 DB에서 읽어 채우고, 있으면 바로 준다".

앱 서버 요청 처리 ① 먼저 캐시에 묻는다 Redis 히트 → 0.1ms 응답 끝 ② 미스면 DB 조회 PostgreSQL 진짜 데이터(원본) ③ 읽은 결과를 TTL 붙여 저장 SET cache:p:15 … EX 60 다음 요청부터는 ①에서 끝
Cache-Aside(옆에 두는 캐시) — 원본은 언제나 RDBMS, Redis는 복사본입니다. 캐시가 통째로 날아가도 서비스는 (느려질 뿐) 돌아갑니다.

코드로는 이 열 줄입니다 (Python)

import json, redis
r = redis.Redis(decode_responses=True)

def get_product(pid: int) -> dict:
    key = f"cache:product:{pid}"
    cached = r.get(key)                    # ① 캐시 먼저
    if cached:
        return json.loads(cached)          #    히트 — DB 안 감
    row = db_query("SELECT * FROM product WHERE id = %s", pid)   # ② 미스 — 원본 조회
    r.set(key, json.dumps(row), ex=60)     # ③ 60초 캐시
    return row

캐시가 낡는 문제 — 무효화 두 가지

  • TTL로 버틴다 (기본) — "상품 정보는 60초 낡아도 된다"면 TTL만으로 충분합니다. 가장 단순하고 사고가 없습니다.
  • 변경 시 지운다 — 원본을 UPDATE 할 때 DEL cache:product:15 를 함께 실행합니다. 다음 조회가 새 값을 다시 채웁니다. "고쳐 넣기"보다 "지우기"가 정답입니다 — 고쳐 넣기는 동시 요청과 얽혀 낡은 값이 남는 경합이 생깁니다.

실무 디테일 세 가지 — 여기서 급이 갈립니다

# ① TTL 지터 — 같은 시각 대량 만료(스탬피드)를 흩뜨린다
ttl = 60 + random.randint(-10, 10)
r.set(key, val, ex=ttl)

# ② 네거티브 캐시 — "없음"도 잠깐 캐시한다
#    없는 상품 ID 로 계속 두드리는 트래픽(오타·악의적)이 DB 를 뚫는 것 방지
row = db_query(...)
if row is None:
    r.set(key, "__NONE__", ex=30)      # 짧게!
    return None

# ③ 키 버전 — 배포로 응답 구조가 바뀌면 접두사 버전을 올린다
KEY_V = "cache:v2:product:{}"          # 옛 v1 키는 TTL 로 알아서 소멸
#    구조 다른 캐시를 역직렬화하다 터지는 배포 사고를 원천 차단
캐시 스탬피드(stampede) — 인기 키의 TTL이 만료되는 순간, 수백 요청이 동시에 미스가 나서 다 같이 DB로 몰려가는 사고입니다. 대책: ① TTL에 무작위 지터(60±10초)를 줘 만료 시각을 흩뜨린다, ② 락(SET NX)으로 한 요청만 DB에 가게 한다, ③ 아주 인기 있는 키는 만료 전에 백그라운드로 미리 갱신한다.
SECTION 07

실전 레시피 — 세션 · 순위표 · 분산 락

실무에서 Redis가 실제로 맡는 일 네 가지를 그대로 옮길 수 있는 형태로 정리했습니다.

① 세션 저장소

# 로그인 시 — 세션 ID를 키로, 유저 정보를 값으로, 30분 수명
SET session:9f2a71c0 "{\"userId\":1001,\"grade\":\"GOLD\"}" EX 1800

# 매 요청 — 쿠키의 세션 ID로 조회 + 수명 연장
GET session:9f2a71c0
EXPIRE session:9f2a71c0 1800
앱 서버가 여러 대일 때 세션을 서버 메모리에 두면 다른 서버로 간 요청이 로그아웃됩니다. 세션을 Redis로 빼면 어느 서버로 가도 같은 세션을 봅니다 — 서버를 늘릴 수 있게 되는 핵심 전환입니다.

② 순위표 — Sorted Set 그대로

ZINCRBY rank:2026-08 120 "u:1001"        # 점수 올리기
ZREVRANGE rank:2026-08 0 9 WITHSCORES    # 상위 10명
ZREVRANK rank:2026-08 "u:1001"           # 내 등수 — 수백만 명이어도 O(logN)

③ 처리량 제한 (rate limit)

# 유저당 1분에 60회 — 고정 창(fixed window) 방식
INCR ratelimit:u1001:202608111024        # 분 단위 키
EXPIRE ratelimit:u1001:202608111024 60   # INCR 결과가 1(새 키)일 때만 실행 (7.0+ 는 EXPIRE … 60 NX)
# 결과가 60 을 넘으면 429 Too Many Requests 로 거절

④ 분산 락 — "동시에 한 명만"

# 락 잡기 — NX(없을 때만) + PX(수명) 를 반드시 한 명령으로
SET lock:settle:2026-08-11 "worker-a" NX PX 30000
# OK    → 락 획득, 작업 진행
# (nil) → 다른 프로세스가 작업 중, 포기하거나 대기

# 풀 때 — 내가 잡은 락인지 확인하고 지워야 안전 (Lua 로 원자 처리)
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:settle:2026-08-11 "worker-a"
락에 수명(PX)을 빼먹으면 락을 잡은 프로세스가 죽는 순간 영원히 아무도 작업을 못 합니다. 수명은 "작업이 최악으로 걸리는 시간 + 여유"로 잡고, 더 엄밀한 보장이 필요하면 라이브러리(Redisson·redis-py 의 lock)를 쓰세요 — 직접 구현은 함정이 많습니다.
SECTION 08

영속성 — RDB · AOF, 메모리인데 왜 남는가

"메모리 저장소면 껐다 켜면 다 날아가나요?" — 설정에 따라 다릅니다. Redis는 두 가지 방식으로 디스크에 백업할 수 있습니다.

방식RDB (스냅샷)AOF (명령 로그)
원리일정 주기로 메모리 전체를 파일로 찍는다쓰기 명령을 전부 파일에 순서대로 적는다
유실 범위마지막 스냅샷 이후 전부 (수 분)기본 설정(everysec) 기준 최대 1초
재시작 속도빠름 — 파일 하나 로드느림 — 명령 재실행 (재작성으로 완화)
비용스냅샷 순간 fork 로 메모리 사용 증가쓰기마다 디스크 IO 추가
# redis.conf — 실무 기본형: 둘 다 켠다
# (redis.conf 는 줄 끝 주석을 허용하지 않습니다 — 넣으면 서버가 안 뜹니다)
# 900초 동안 1개 이상 바뀌면 RDB 스냅샷
save 900 1
save 300 10
# AOF 켜기
appendonly yes
# 1초마다 디스크 동기화 (성능·안전의 균형점)
appendfsync everysec
용도별 선택 — 순수 캐시라면 영속성을 아예 꺼도 됩니다 (죽어도 DB에서 다시 채워지니까). 세션·순위표처럼 날아가면 아픈 데이터가 있다면 RDB+AOF 둘 다 켜세요. 그리고 어느 쪽이든 원본이 Redis뿐인 돈 데이터는 두지 않는다는 원칙이 우선입니다.
메모리 저장소지만, 꺼져도 남길 수 있습니다 ① RDB — 스냅샷 일정 조건마다 메모리 전체를 파일로 떠 놓습니다 복구가 빠르고 파일이 작습니다 마지막 스냅샷 이후는 사라집니다 ② AOF — 명령 기록 쓰기 명령을 파일 끝에 계속 덧붙입니다 1초마다 동기화면 손실이 1초 이내입니다 파일이 커져 주기적으로 다시 씁니다 고르는 기준 캐시로만 쓴다면 꺼 두어도 됩니다 — 다시 채우면 그만입니다 세션·순위표처럼 잃으면 곤란하면 AOF 를 켭니다 실무에서는 둘 다 켜 두는 구성이 흔합니다 저장할 때 메모리가 순간 늘어납니다 자식 프로세스를 떠서 저장하는 구조라, 그동안 변경된 만큼 메모리가 더 필요합니다 메모리를 꽉 채워 쓰면 저장하다 죽습니다 — 여유를 두세요
"캐시니까 안 지켜도 된다"인지 먼저 정하고 설정을 고르세요.
메모리 저장소지만, 꺼져도 남길 수 있습니다 ① RDB — 스냅샷 일정 조건마다 메모리 전체를 파일로 떠 놓습니다 복구가 빠르고 파일이 작습니다 마지막 스냅샷 이후는 사라집니다 ② AOF — 명령 기록 쓰기 명령을 파일 끝에 계속 덧붙입니다 1초마다 동기화면 손실이 1초 이내입니다 파일이 커져 주기적으로 다시 씁니다 고르는 기준 캐시로만 쓴다면 꺼 두어도 됩니다 — 다시 채우면 그만입니다 세션·순위표처럼 잃으면 곤란하면 AOF 를 켭니다 실무에서는 둘 다 켜 두는 구성이 흔합니다 저장할 때 메모리가 순간 늘어납니다 자식 프로세스를 떠서 저장하는 구조라, 그동안 변경된 만큼 메모리가 더 필요합니다 메모리를 꽉 채워 쓰면 저장하다 죽습니다 — 여유를 두세요
"캐시니까 안 지켜도 된다"인지 먼저 정하고 설정을 고르세요.
SECTION 09

메모리 관리 — maxmemory와 축출

메모리가 차면 어떻게 되는가 — 이 질문의 답을 설정으로 정해 두지 않으면 Redis는 쓰기를 거부하거나(기본), OS가 프로세스를 죽입니다.

# redis.conf (주석은 반드시 별도 줄에)
# 한도를 반드시 명시하세요
maxmemory 2gb
# 차면 "가장 오래 안 쓴 키"부터 지운다
maxmemory-policy allkeys-lru
정책차면 하는 일 · 언제 쓰나
noeviction (기본)쓰기를 에러로 거부 — 캐시 용도에는 부적합, 데이터 보존 용도라면 이것
allkeys-lru캐시 표준 — 전체 키에서 가장 오래 안 쓴 것부터 축출
volatile-lruTTL 있는 키 중에서만 LRU 축출 — 캐시와 보존 데이터가 섞여 있을 때
allkeys-lfu사용 빈도가 낮은 것부터 — 접근 패턴이 뚜렷할 때 히트율이 더 좋음
메모리 사용량은 INFO memory 로 감시하세요. used_memory 가 maxmemory 에 붙어 다니면 축출이 상시 발생 중이라는 뜻이고 (캐시라면 정상, 세션 저장소라면 사고), mem_fragmentation_ratio 가 1.5를 넘으면 단편화가 심한 상태입니다. 재시작 또는 activedefrag 를 검토합니다.
메모리가 가득 차면 무엇을 할 것인가 maxmemory 를 안 정했을 때 서버 메모리를 다 쓰고 운영체제가 프로세스를 강제 종료합니다 — 캐시가 통째로 사라집니다 allkeys-lru — 캐시 전용 인스턴스의 기본값 TTL 유무와 상관없이 오래 안 쓴 키부터 지웁니다. 캐시로만 쓴다면 이것이 무난합니다 volatile-lru · volatile-ttl TTL 이 있는 키만 지웁니다. TTL 없는 키가 많으면 지울 게 없어 결국 쓰기가 막힙니다 noeviction — 아무것도 안 지움 가득 차면 새 쓰기를 거부합니다. 지우면 안 되는 데이터를 담을 때 쓰지만, 그 순간 장애가 됩니다 가장 확실한 대책은 분리입니다 — 캐시용과 영구 데이터용 인스턴스를 나누면 정책이 단순해집니다
Redis 사고의 상당수는 지워도 되는 것과 아닌 것을 한곳에 담아서 생깁니다.
SECTION 10

Pub/Sub과 Stream — 메시지 전달

Redis는 저장소이면서 가벼운 메시지 배달부도 됩니다. 실시간 알림은 Pub/Sub, 놓치면 안 되는 작업은 Stream입니다.

Pub/Sub — 지금 듣고 있는 사람에게만

# 터미널 A — 구독
SUBSCRIBE chat:room1

# 터미널 B — 발행
PUBLISH chat:room1 "안녕하세요"
# → A 에 즉시 도착. 단, 그 순간 듣고 있던 구독자에게만 (저장 안 됨)
휘발성입니다 — 구독자가 없거나 접속이 끊겨 있으면 메시지는 그냥 사라집니다. 채팅 브로드캐스트·실시간 갱신 알림처럼 놓쳐도 되는 신호에만 쓰세요.

Stream — 기록이 남는 큐

XADD orders:events * type "created" orderId "1001"   # 이벤트 추가 (* = 자동 ID)
XLEN orders:events                                    # 쌓인 개수
XREAD COUNT 10 STREAMS orders:events 0               # 처음부터 읽기

# 소비자 그룹 — 여러 워커가 나눠 처리 + 처리 확인(ACK)
XGROUP CREATE orders:events workers $
XREADGROUP GROUP workers w1 COUNT 10 STREAMS orders:events >
XACK orders:events workers 1691740800000-0
Kafka 가 필요해지기 전까지의 훌륭한 중간 단계입니다. 메시지가 저장되고, 워커를 늘려 나눠 먹고, 실패분을 재처리할 수 있습니다. 하루 수백만 건 수준까지는 Stream으로 충분한 경우가 많고, 그 이상의 파이프라인은 🌐 서버기술 가이드의 Kafka 탭을 보세요.
차이는 하나입니다 — 메시지가 남느냐 ① Pub/Sub — 그 순간뿐 발행자 듣고 있던 구독자 — 받습니다 끊겼던 구독자 — 못 받습니다 채팅 알림·설정 변경 통지처럼 놓쳐도 되는 것에 씁니다 ② Stream — 로그처럼 쌓입니다 생산자 나중에 붙어도 처음부터 읽을 수 있습니다 Stream 이 주는 것 소비자 그룹 — 여러 워커가 나눠 처리합니다 확인(ACK) — 처리 완료를 표시하고, 안 된 것은 다시 어디까지 읽었는지가 서버에 남습니다 Stream 도 메모리입니다 쌓이기만 하면 메모리를 다 씁니다 — MAXLEN 으로 길이를 제한하거나 주기적으로 잘라 내세요 보관과 재처리가 정말 중요하면 Kafka 가 제자리입니다 고르는 법 — 놓쳐도 되면 Pub/Sub, 한 건도 잃으면 안 되면 Stream, 대규모 이벤트 파이프라인이면 전용 메시지 큐입니다
Pub/Sub 을 작업 큐로 쓰면 재시작할 때마다 일이 사라집니다.
차이는 하나입니다 — 메시지가 남느냐 ① Pub/Sub — 그 순간뿐 발행자 듣고 있던 구독자 — 받습니다 잠깐 끊겼던 구독자 — 못 받습니다 채팅 알림·설정 변경 통지처럼 놓쳐도 되는 것에 씁니다 ② Stream — 로그처럼 쌓입니다 생산자 나중에 붙어도 처음부터 읽을 수 있습니다 Stream 이 주는 것 소비자 그룹 — 여러 워커가 나눠 처리합니다 확인(ACK) — 처리 완료를 표시하고, 안 된 것은 다시 어디까지 읽었는지가 서버에 남습니다 Stream 도 메모리입니다 쌓이기만 하면 메모리를 다 씁니다 — MAXLEN 으로 길이를 제한하거나 주기적으로 잘라 내세요 보관과 재처리가 정말 중요하면 Kafka 가 제자리입니다 고르는 법 — 놓쳐도 되면 Pub/Sub, 한 건도 잃으면 안 되면 Stream, 대규모 이벤트 파이프라인이면 전용 메시지 큐입니다
Pub/Sub 을 작업 큐로 쓰면 재시작할 때마다 일이 사라집니다.
SECTION 11

복제 · Sentinel · Cluster — 죽지 않게

캐시라도 Redis가 죽으면 그 트래픽이 전부 DB로 쏟아집니다. 규모에 따라 세 단계로 고가용성을 올립니다.

단계구성 · 언제
① 복제(replica)마스터의 복사본을 1대 이상 유지. 읽기 분산 + 수동 승격이 가능해집니다. replicaof 한 줄.
② Sentinel감시 프로세스 3대가 마스터 장애를 합의로 판정하고 자동 승격. 마스터 1 + 복제 N 구조에서의 표준 HA.
③ Cluster데이터 자체를 16384 슬롯으로 나눠 여러 마스터에 분산. 메모리가 한 대에 안 담길 때. 다중 키 명령·트랜잭션에 제약이 생깁니다.
순서를 건너뛰지 마세요 — 한 대로 충분한 서비스에 Cluster를 깔면 운영 복잡도만 삽니다. 대부분의 서비스는 ①복제 + ②Sentinel(또는 클라우드 관리형: ElastiCache·Memorystore)에서 끝납니다. 복제는 비동기라 장애 승격 시 마지막 몇 초의 쓰기가 사라질 수 있다는 것만 기억해 두세요.
죽지 않게 만드는 세 단계 1. 복제 (Replication) 주 서버의 변경을 복제본으로 계속 흘려보냅니다 — 읽기를 나누고 백업 자리를 만듭니다 비동기입니다 — 주 서버가 갑자기 죽으면 마지막 몇 건이 유실될 수 있습니다 2. Sentinel — 자동 승격 주 서버를 감시하다가 죽으면 복제본 하나를 새 주 서버로 올리고 클라이언트에 알립니다 홀수 대(보통 3대)로 띄워야 오판을 막습니다 — 여기까지가 대부분의 서비스에 충분합니다 3. Cluster — 나눠 담기 키를 여러 주 서버에 나눠 담아 용량과 처리량을 함께 늘립니다 여러 키를 한 번에 다루는 명령에 제약이 생깁니다 — 필요할 때만 가세요 순서를 지키세요 — 복제 없이 Cluster 부터 가면 운영 난이도만 오르고 얻는 게 없습니다
가용성 설계의 기준은 유행이 아니라 몇 초까지 멈춰도 되는가입니다.
SECTION 12

실무 함정 — 이것만은 피하세요

Redis 장애의 대부분은 느린 명령 하나에서 시작합니다. 싱글 스레드라는 것을 잊는 순간 사고가 납니다.

하지 말 것왜 · 대신
KEYS * 운영 금지전체 키를 훑는 O(N) — 그동안 모든 요청이 멈춥니다. 대신 SCAN 으로 조금씩 순회.
빅 키 (수십만 멤버 Hash·ZSet)HGETALL·DEL 한 방이 수백 ms 블로킹. 키를 쪼개고, 지울 땐 UNLINK(백그라운드 삭제).
핫 키 (전 트래픽이 키 하나로)그 키가 있는 서버만 타죽습니다. 로컬 캐시를 앞에 한 겹 더 두거나 키를 복제해 분산.
FLUSHALL 습관전체 삭제 — 운영에서 치는 순간 캐시가 비고 DB가 무너집니다. 접두사 단위 SCAN+UNLINK 로.
트랜잭션 오해MULTI/EXEC 는 롤백이 없습니다 — 명령을 묶어 순서 보장할 뿐. 원자적 복합 연산은 Lua 스크립트가 정답.

명령을 묶는 세 가지 — 헷갈리기 쉬운 구분

방법보장하는 것 · 쓰는 곳
파이프라인왕복(RTT)만 아낀다 — 원자성 없음. 대량 쓰기 속도 수십 배 (r13 예제)
MULTI / EXEC묶음이 끼어들기 없이 순서대로 실행. 단 롤백은 없음 — 중간 명령이 실패해도 나머지는 실행됨
Lua 스크립트읽고-판단하고-쓰는 복합 로직이 통째로 원자적. 분산 락 해제(r07)·재고 차감 같은 "확인 후 변경"의 정답
# Lua 예 — "재고가 있으면 1 차감" 을 경쟁 없이 (셸에서 실행 — 여러 줄 문자열은 redis-cli 프롬프트에선 안 됩니다)
redis-cli EVAL "local s = tonumber(redis.call('GET', KEYS[1]))
      if s and s > 0 then return redis.call('DECR', KEYS[1]) end
      return -1" 1 stock:p15
# GET 과 DECR 사이에 다른 요청이 못 끼어든다 — 앱에서 하면 반드시 낀다
# 느린 명령 찾기
SLOWLOG GET 10           # 최근 느린 명령 10개 (기본: 10ms 초과 기록)
LATENCY LATEST           # 지연 이벤트 요약
INFO stats               # keyspace_hits / misses → 캐시 히트율 계산
MEMORY USAGE rank:weekly # 이 키가 먹는 바이트 — 빅 키 조사
redis-cli --bigkeys      # 전체에서 큰 키 후보 훑기 (SCAN 기반이라 안전)
히트율이 성적표입니다 — hits/(hits+misses) 가 캐시 히트율. 90% 이상이면 제 몫을 하는 것이고, 낮다면 TTL이 너무 짧거나 캐시할 가치가 없는 키를 캐시하고 있는 것입니다.
SECTION 13

앱에서 쓰기 — Python · Java · Node

언어별 표준 클라이언트와 연결 코드입니다. 풀(pool)을 재사용한다는 원칙은 RDBMS와 같습니다.

Python — redis-py

# pip install redis
import redis

# 커넥션 풀은 모듈 수준에서 한 번만
r = redis.Redis(host="localhost", port=6379,
                decode_responses=True,     # bytes 대신 str 로
                socket_timeout=2)

r.set("greeting", "hello", ex=60)
print(r.get("greeting"))                   # hello

# 파이프라인 — 왕복을 1번으로 묶어 수십 배 빠르게
with r.pipeline() as p:
    for i in range(1000):
        p.set(f"bulk:{i}", i)
    p.execute()

Java — Lettuce (Spring Boot 기본)

// build.gradle: implementation "org.springframework.boot:spring-boot-starter-data-redis"
// application.properties: spring.data.redis.host=localhost

@Service
public class ProductCache {
    private final StringRedisTemplate redis;
    public ProductCache(StringRedisTemplate redis) { this.redis = redis; }

    public String getProduct(long id) {
        String key = "cache:product:" + id;
        String hit = redis.opsForValue().get(key);
        if (hit != null) return hit;
        String row = loadFromDb(id);
        redis.opsForValue().set(key, row, Duration.ofSeconds(60));
        return row;
    }
}
// 메서드 단위 캐시는 @Cacheable("product") 애너테이션이 더 간단합니다

Node.js — ioredis

// npm i ioredis
import Redis from "ioredis";
const redis = new Redis({ host: "localhost", port: 6379 });  // 재연결 자동

await redis.set("greeting", "hello", "EX", 60);
console.log(await redis.get("greeting"));   // hello

// 순위표
await redis.zincrby("rank:weekly", 120, "u:1001");
const top10 = await redis.zrevrange("rank:weekly", 0, 9, "WITHSCORES");
타임아웃을 반드시 거세요. Redis가 응답을 못 하는 순간 타임아웃 없는 앱은 전 요청이 Redis 대기에 매달려 함께 멈춥니다. "캐시 조회 실패 → DB로 폴백" 경로를 만들어 두면, Redis 장애가 "느려짐"으로 끝나고 "장애"가 되지 않습니다.
MONGODB 8.x · DOCUMENT · JSON · NOSQL

MongoDB —
표 대신
JSON 문서로

MongoDB는 데이터를 표가 아니라 JSON 문서로 저장하는 문서형 NoSQL입니다. 문서마다 구조가 달라도 되고, 관련 데이터를 한 문서에 통째로 담아 조인 없이 꺼냅니다. SQL을 아는 사람 기준으로 무엇이 어디에 대응되는지부터 시작합니다. (NoSQL 이 처음이라면 ⚡ Redis 탭 r01의 RDBMS 비교를 먼저 보세요.)

12SECTIONS
JSON문서 단위
16MB문서 상한
SECTION 01

MongoDB는 어떤 DB인가 — 용어 대응부터

SQL 용어와 1:1로 놓고 보면 절반은 이미 아는 것입니다. 다른 절반 — "조인 대신 내장" — 이 MongoDB의 진짜 차이입니다.

용어 대응표

RDBMSMongoDB설명
데이터베이스데이터베이스같은 개념입니다
테이블컬렉션(collection)문서들을 담는 그릇 — 구조 강제가 없습니다
행(row)문서(document)JSON 한 덩어리 (내부 저장은 BSON — 이진 JSON)
열(column)필드(field)문서마다 있어도, 없어도 됩니다
기본키(PK)_id모든 문서에 자동 생성(ObjectId) — 시간 정보 포함
JOIN내장(embed) 또는 $lookup기본 전략은 "미리 한 문서에 담기"
SQL쿼리 도큐먼트 · 집계 파이프라인{ city: "서울" } 처럼 JSON으로 조건을 씁니다

같은 데이터, 두 가지 저장 방식

RDBMS — 3개 표로 나눠서
member(id, name, city)
orders(id, member_id, ordered_at)
order_item(order_id, product, qty)
-- 조회할 때 JOIN 으로 다시 합친다
MongoDB — 문서 하나에 통째로
{
  _id: ObjectId("..."),
  name: "김하늘", city: "서울",
  orders: [
    { at: "2026-01-05",
      items: [{ p: "키보드", qty: 1 }] }
  ]
}
언제 MongoDB가 빛나나 — ① 데이터 구조가 자주 바뀌는 초기 서비스 (칸 추가에 ALTER TABLE이 없음), ② 상품 옵션·설문 응답처럼 항목마다 구조가 다른 데이터, ③ "이 화면에 필요한 것"을 문서 하나로 묶어 조인 없이 한 번에 읽는 조회 중심 워크로드. 반대로 여러 데이터에 걸친 정합성(돈·재고)이 핵심이면 RDBMS가 맞습니다 (n12).
용어부터 맞춰 두면 나머지는 금방 읽힙니다 데이터베이스 → 데이터베이스 (같습니다) 테이블 → 컬렉션 (collection) 행 (row) → 문서 (document) — JSON 처럼 생겼습니다 열 (column) → 필드 (field) 기본키 (PK) → _id — 안 넣으면 자동으로 만들어집니다 가장 큰 차이 — 문서마다 필드 구성이 달라도 됩니다 유연함인 동시에 위험입니다 — 규칙이 없으면 반 년 뒤 아무도 구조를 모르게 됩니다 그래서 실무에서는 스키마 검증을 걸어 최소한의 규칙을 강제합니다
"스키마가 없다" 가 아니라 스키마가 코드 쪽에 있다고 보는 편이 정확합니다.
SECTION 02

설치 — Docker · Atlas · mongosh

로컬은 도커, 클라우드는 무료 티어가 있는 Atlas가 가장 빠릅니다.

# Docker — 인증 켜서 시작하는 습관
docker run -d --name mongo -p 27017:27017 \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=devpass \
  -v mongodata:/data/db \
  mongo:8

# 셸 접속 (mongosh = 공식 CLI)
docker exec -it mongo mongosh -u admin -p devpass

첫 명령

show dbs                      // 데이터베이스 목록
use shop                      // 없으면 첫 저장 때 만들어집니다
db.member.insertOne({ name: "김하늘" })   // 컬렉션도 자동 생성
show collections              // member
db.member.find()              // 전체 조회
도구 세 가지 — CLI는 mongosh, GUI는 공식 Compass(무료 — 쿼리·인덱스·실행계획까지), 클라우드는 Atlas(M0 무료 티어 — 설치 없이 시작, 백업·모니터링 포함). 학습이라면 도커 + Compass 조합을 권합니다.
MongoDB도 인증 없이 인터넷에 노출하면 몇 분 만에 털립니다. 과거 대규모 랜섬 사고의 단골이 인증 꺼진 MongoDB였습니다. 도커에서 위처럼 MONGO_INITDB_ROOT_* 를 반드시 넣고, 방화벽으로 27017을 막으세요. 라이선스는 SSPL — 서비스 백엔드로 쓰는 건 무료지만, "MongoDB 자체를 클라우드 서비스로 팔" 때만 제약이 있습니다.
SECTION 03

CRUD — SQL과 나란히

네 가지 기본 동작을 SQL과 대조하면 문법이 바로 읽힙니다.

SQLMongoDB
INSERT INTO member VALUES (…)db.member.insertOne({ name: "김하늘" })
SELECT * FROM member WHERE city='서울'db.member.find({ city: "서울" })
SELECT name FROM memberdb.member.find({}, { name: 1, _id: 0 })
UPDATE member SET grade='GOLD' WHERE id=1db.member.updateOne({ _id: 1 }, { $set: { grade: "GOLD" } })
DELETE FROM member WHERE id=1db.member.deleteOne({ _id: 1 })
// 넣기 — 여러 건
db.member.insertMany([
  { name: "김하늘", grade: "GOLD",   city: "서울", joined: ISODate("2024-03-11") },
  { name: "박바다", grade: "SILVER", city: "부산", joined: ISODate("2024-07-02") },
  { name: "이구름", grade: "BASIC",  city: "서울" }   // 필드가 달라도 그냥 들어갑니다
])

// 고치기 — 연산자($set) 없이 문서를 넘기는 것이 최대 함정!
db.member.updateOne({ name: "이구름" }, { $set: { grade: "SILVER" } })  // ✅ 필드만 수정
db.member.updateOne({ name: "이구름" }, { grade: "SILVER" })            // ❌ 에러 — updateOne 은 $ 연산자 필수
db.member.replaceOne({ name: "이구름" }, { grade: "SILVER" })           // ⚠ 통째로 교체 — {_id, grade}만 남음 (옛 update() 도 이렇게 동작)

// 갱신 연산자들
db.member.updateOne({ name: "김하늘" }, {
  $inc:  { points: 100 },          // 숫자 증가
  $push: { tags: "vip" },          // 배열에 추가
  $unset:{ tempField: "" }         // 필드 제거
})

// upsert — 있으면 수정, 없으면 삽입
db.counter.updateOne({ _id: "visits" }, { $inc: { n: 1 } }, { upsert: true })

배열 다루기 — 문서형의 핵심 근육

// 배열 연산자 — 내장 설계(n05)와 한 세트입니다
db.member.updateOne({ _id: 1 }, {
  $addToSet: { tags: "vip" }         // 중복 없이 추가 (있으면 무시)
})
db.member.updateOne({ _id: 1 }, {
  $pull: { tags: "black" }           // 값으로 제거
})
db.member.updateOne({ _id: 1 }, {
  $push: { logins: { $each: [new Date()], $slice: -10 } }   // 최근 10개만 유지
})

// 배열 "안의" 문서 수정 — 위치 연산자 $
db.orders.updateOne(
  { _id: 1001, "items.productId": 15 },   // 조건에 걸린 그 원소를
  { $set: { "items.$.qty": 3 } }          // $ 가 가리킨다
)

// 배열 원소를 조건으로 찾기 — $elemMatch (두 조건이 같은 원소에!)
db.orders.find({ items: { $elemMatch: { productId: 15, qty: { $gte: 2 } } } })
// items.productId:15, items.qty:{$gte:2} 로 나눠 쓰면
// "서로 다른 원소가 하나씩 만족" 해도 걸립니다 — 단골 버그
updateOne vs updateMany — SQL의 UPDATE는 조건에 맞는 전부를 바꾸지만, MongoDB의 updateOne은 첫 한 건만 바꿉니다. "왜 하나만 바뀌지?"의 답은 대부분 이것입니다. 전부 바꾸려면 updateMany.
이름만 다릅니다 — 아는 개념으로 바로 옮겨 보세요 관계형 DB MongoDB 예시 database database shop table collection orders row document { _id: …, total: 12000 } column field total JOIN $lookup (또는 내장) 가급적 내장으로 푸는 편이 낫습니다 진짜 다른 점 문서마다 필드가 달라도 됩니다 — 미리 정의하지 않습니다 그래서 책임이 옮겨 옵니다 DB 가 막아 주던 것을 앱이 지킵니다 — 검증 규칙(n10)을
"스키마가 없다"가 아니라 "스키마가 코드에 있다"가 정확합니다.
이름만 다릅니다 — 아는 개념으로 바로 옮겨 보세요 관계형 DB MongoDB 예시 database database shop table collection orders row document { _id: …, total: 12000 } column field total JOIN $lookup (또는 내장) 가급적 내장으로 푸는 편이 낫습니다 진짜 다른 점 문서마다 필드가 달라도 됩니다 — 미리 정의하지 않습니다 그래서 책임이 옮겨 옵니다 DB 가 막아 주던 것을 앱이 지켜야 합니다 — 검증 규칙(n10)을 걸어 두세요
"스키마가 없다"가 아니라 "스키마가 코드에 있다"가 정확합니다.
SECTION 04

쿼리 연산자 · 정렬 · 페이징

WHERE 절에 해당하는 연산자들입니다. $ 로 시작하는 키워드가 연산자입니다.

// 비교 — WHERE price >= 30000 AND price < 100000
db.product.find({ price: { $gte: 30000, $lt: 100000 } })

// IN — WHERE city IN ('서울','부산')
db.member.find({ city: { $in: ["서울", "부산"] } })

// OR — WHERE grade='GOLD' OR points > 1000
db.member.find({ $or: [ { grade: "GOLD" }, { points: { $gt: 1000 } } ] })

// 필드 존재 여부 · null (SQL 의 IS NULL 과 미묘하게 다릅니다)
db.member.find({ email: { $exists: false } })   // email 필드 자체가 없는 문서

// 배열 안에서 찾기 — tags 배열에 "vip" 가 들어 있는 문서
db.member.find({ tags: "vip" })

// 내장 문서 필드 — 점 표기법 (반드시 따옴표)
db.member.find({ "address.city": "서울" })

// 정렬 · 페이징 — ORDER BY joined DESC LIMIT 10 OFFSET 20
db.member.find().sort({ joined: -1 }).skip(20).limit(10)

// 세기 — SELECT COUNT(*)
db.member.countDocuments({ city: "서울" })
skip 페이징은 RDBMS의 OFFSET과 똑같은 약점이 있습니다 — skip(100000)은 10만 건을 세고 버립니다. 깊은 페이지는 find({ _id: { $gt: 마지막본_id } }).limit(10) 식의 커서 방식으로 — 원리는 ⚙️ 튜닝 탭 t09와 같습니다.
SECTION 05

문서 설계 — 내장 vs 참조

MongoDB 설계의 전부가 이 질문입니다 — "이 데이터를 문서 안에 넣을까(내장), 다른 컬렉션에 두고 id로 가리킬까(참조)?"

내장(embed) — 같이 읽는 데이터는 한 문서에 member 문서 { name: "김하늘", orders: [ {1월 주문}, {3월 주문} ] ← 문서 안에 배열로 통째로 읽기 1번으로 끝 · 문서와 함께 원자적 수정 단점 — 끝없이 자라면 16MB 한도에 부딪힘 참조(reference) — 따로 두고 id로 가리킨다 member { _id: 1, name: … } orders { memberId: 1, … } 무한히 늘어도 안전 · 여러 곳에서 공유 가능 단점 — 조회 2번 또는 $lookup 필요
기준은 하나 — "같이 읽고, 개수가 유한하면 내장. 무한히 자라거나 따로도 쓰이면 참조."
상황선택
주소·옵션·설정 — 항상 부모와 같이 읽음, 몇 개 안 됨내장
주문 내역 — 계속 쌓임, 주문만 따로 조회·집계함참조 (별도 컬렉션)
댓글 — 보통 수십 개, 폭주 가능성 있음혼합 — 최근 10개는 내장 + 전체는 별도 컬렉션
작성자 이름을 게시글마다 표시참조 + 이름만 복사해 내장(비정규화) — 바뀌면 일괄 갱신
RDBMS 습관 그대로 컬렉션을 잘게 쪼개는 것이 초심자의 1번 실수입니다. 전부 참조로 만들면 조회마다 $lookup 파티가 벌어지고, MongoDB의 장점이 사라집니다. "화면(조회) 단위로 문서를 설계한다"가 문서형의 사고방식입니다.
SECTION 06

집계 파이프라인 — GROUP BY의 자리

집계는 파이프라인으로 씁니다 — 문서가 단계를 하나씩 통과하며 걸러지고($match) 묶이고($group) 정렬됩니다($sort).

// SQL: SELECT city, COUNT(*) AS cnt, AVG(points) AS avg_pt
//      FROM member WHERE grade <> 'BASIC'
//      GROUP BY city HAVING COUNT(*) >= 2 ORDER BY cnt DESC
db.member.aggregate([
  { $match: { grade: { $ne: "BASIC" } } },     // WHERE  — 먼저 거른다 (인덱스 사용)
  { $group: {
      _id: "$city",                            // GROUP BY city
      cnt:    { $sum: 1 },                     // COUNT(*)
      avg_pt: { $avg: "$points" }              // AVG(points)
  }},
  { $match: { cnt: { $gte: 2 } } },            // HAVING
  { $sort:  { cnt: -1 } }                      // ORDER BY
])

$lookup — 그래도 조인이 필요할 때

// SQL: SELECT o.*, m.name FROM orders o JOIN member m ON m._id = o.memberId
db.orders.aggregate([
  { $lookup: {
      from: "member",            // 붙일 컬렉션
      localField: "memberId",    // orders 쪽 키
      foreignField: "_id",       // member 쪽 키
      as: "member"               // 결과가 배열로 담김
  }},
  { $unwind: "$member" }         // 배열 [ {…} ] → 객체로 펴기
])

자주 쓰는 단계 요약

단계SQL 대응
$matchWHERE — 파이프라인 맨 앞에 둬야 인덱스를 탑니다
$groupGROUP BY + 집계함수 ($sum · $avg · $min · $max · $push)
$sort / $limit / $skipORDER BY / LIMIT / OFFSET
$projectSELECT 절 — 필드 선택·계산 필드 생성
$unwind배열을 행으로 펴기 (배열 원소별 집계 전에)
$lookupLEFT JOIN

실전 리포트 한 판 — 일별 매출

// SQL: SELECT DATE(orderedAt) d, SUM(amount) total, COUNT(*) cnt
//      FROM orders WHERE orderedAt >= '2026-08-01'
//      GROUP BY DATE(orderedAt) ORDER BY d
db.orders.aggregate([
  { $match: { orderedAt: { $gte: ISODate("2026-08-01") } } },
  { $group: {
      _id: { $dateToString: { format: "%Y-%m-%d", date: "$orderedAt",
                              timezone: "Asia/Seoul" } },   // ★ 타임존!
      total: { $sum: "$amount" },
      cnt:   { $sum: 1 }
  }},
  { $sort: { _id: 1 } }
])
// 결과를 컬렉션으로 저장해 두려면 마지막에
// { $merge: "daily_sales" }   ← 매일 갱신되는 집계 테이블처럼 씁니다
날짜 집계에서 timezone 을 빼먹으면 UTC 기준으로 잘려서 한국 시간 오전 9시 이전 주문이 전날 매출로 들어갑니다 — 정산이 안 맞는 단골 원인입니다.
$lookup이 매 쿼리마다 나온다면 설계 신호입니다 — 그 데이터는 애초에 내장(n05)했어야 하거나, 애초에 RDBMS 일이었을 수 있습니다(n12). $lookup은 "가끔 필요한 리포트"용이지 "모든 조회의 기본"이 아닙니다.
집계 파이프라인 — 단계마다 조금씩 바꿔 흘려보냅니다 $match = WHERE $group = GROUP BY $sort = ORDER BY $limit · $project = LIMIT · SELECT 목록 순서가 곧 성능입니다 $match 를 맨 앞에 두세요 — 첫 단계에서 줄인 만큼 뒤 단계가 다룰 문서가 줄어듭니다 첫 $match 가 인덱스를 탈 수 있으면 차이가 가장 큽니다 — explain 으로 확인하세요 $lookup 은 조인 자리입니다 — 다만 관계형의 JOIN 만큼 빠르지는 않습니다 조인이 자주 필요하다면 문서 설계를 다시 보거나, 그 데이터는 관계형에 두는 편이 낫습니다 결과가 커지면 allowDiskUse 가 필요합니다 — 메모리 한도를 넘으면 그 전까지는 오류가 납니다
파이프라인을 읽는 요령 — 각 단계가 SQL 의 어느 절인지 하나씩 대응시켜 보세요.
SECTION 07

인덱스 — 원리는 RDBMS와 같다

B-Tree, 복합 인덱스의 컬럼 순서, "인덱스 안 타면 풀스캔" — ⚙️ 튜닝 탭에서 배운 원리가 그대로 적용됩니다. 문법만 다릅니다.

db.member.createIndex({ email: 1 }, { unique: true })   // 유니크 인덱스
db.orders.createIndex({ memberId: 1, orderedAt: -1 })   // 복합 — 순서가 중요 (t05와 동일)
db.session.createIndex({ at: 1 }, { expireAfterSeconds: 1800 })  // TTL 인덱스 — 자동 삭제!
db.post.createIndex({ title: "text", body: "text" })    // 텍스트 검색용

db.member.getIndexes()          // 인덱스 목록
db.orders.find({ memberId: 1 }).explain("executionStats")   // 실행계획

explain에서 볼 것 두 가지

항목의미
stage: COLLSCAN풀스캔 — 인덱스를 못 탔습니다. RDBMS의 Seq Scan과 같은 경고
stage: IXSCAN인덱스 스캔 — 정상
totalDocsExamined vs nReturned살펴본 문서 수 vs 돌려준 수 — 차이가 크면 인덱스가 엉성한 것

아껴 쓰는 인덱스 — 부분 · 커버드

// 부분 인덱스 — 조건에 맞는 문서만 색인 (크기·쓰기 비용 절감)
db.orders.createIndex(
  { orderedAt: -1 },
  { partialFilterExpression: { status: "PAID" } }   // 결제 완료만
)
// 단, 쿼리에도 같은 조건이 있어야 이 인덱스를 탑니다

// 커버드 쿼리 — 필요한 필드가 전부 인덱스 안에 있으면 문서를 안 읽습니다
db.member.createIndex({ city: 1, name: 1 })
db.member.find({ city: "서울" }, { name: 1, _id: 0 })   // 인덱스만으로 응답
// explain 에서 totalDocsExamined: 0 이면 성공 — t05 커버링과 같은 원리
TTL 인덱스는 Redis의 EXPIRE에 해당하는 기능입니다 — 세션·인증 토큰·임시 데이터를 "넣기만 하면 알아서 사라지게" 만듭니다. 배치로 지우는 코드를 짤 필요가 없습니다 (약 60초 주기로 백그라운드 삭제).
인덱스 — 원리는 RDBMS 와 똑같습니다 인덱스 없음 — COLLSCAN 컬렉션 전체를 훑습니다 explain 에 이 글자가 보이면 신호입니다 인덱스 있음 — IXSCAN 바로 해당 자리로 갑니다 문서가 늘어도 단계가 조금 늘 뿐입니다 복합 인덱스도 순서가 중요합니다 { userId: 1, createdAt: -1 } 은 userId 조건이 있어야 쓸모가 있습니다 정렬까지 인덱스로 처리하려면 정렬 필드와 방향이 인덱스와 맞아야 합니다 배열 필드 인덱스(multikey)는 원소마다 항목이 생깁니다 태그 검색에 유용하지만 인덱스가 커집니다 — 원소가 수백 개인 배열은 피하세요 explain("executionStats") 로 실제로 몇 문서를 봤는지 확인하세요 — 반환 수와 검사 수가 비슷해야 좋습니다
"NoSQL 이라 빠르다" 는 말은 없습니다 — 인덱스가 있어야 빠른 것은 어디서나 같습니다.
SECTION 08

트랜잭션과 일관성 — 어디까지 되나

"NoSQL은 트랜잭션이 없다"는 반쯤 옛말입니다. 핵심은 단일 문서 변경은 원래부터 원자적이라는 것 — 설계를 잘하면 트랜잭션이 거의 필요 없습니다.

  • 단일 문서 = 항상 원자적 — 문서 하나 안에서 필드 10개를 $set·$inc 로 바꾸는 것은 전부 되거나 전부 안 됩니다. 내장 설계(n05)를 잘하면 이것만으로 충분한 경우가 대부분입니다.
  • 여러 문서 트랜잭션 — 4.0+ 부터 지원합니다. 다만 성능 비용이 있고 복제 셋 구성이 필요하며, 이걸 매일 쓰고 있다면 데이터가 RDBMS 감이라는 신호입니다.
// 멀티 도큐먼트 트랜잭션 (계좌 이체 꼴)
const session = db.getMongo().startSession()
session.startTransaction()
try {
  const acc = session.getDatabase("bank").account
  acc.updateOne({ _id: "A" }, { $inc: { balance: -5000 } })
  acc.updateOne({ _id: "B" }, { $inc: { balance:  5000 } })
  session.commitTransaction()          // 둘 다 확정
} catch (e) {
  session.abortTransaction()           // 둘 다 취소
  throw e                              // 실패를 숨기지 않습니다
} finally {
  session.endSession()
}

쓰기 확인 수준 — write concern

설정의미
w: 1프라이머리에 쓰이면 OK — 빠르지만 장애 승격 시 유실 가능
w: "majority"과반 복제 확인 — 기본값이자 실무 표준. 승격돼도 안 사라짐
w: 0확인 안 함 — 로그성 데이터가 아니면 쓰지 마세요
문서 하나 안에서는 언제나 원자적입니다 ① 한 문서 안 — 트랜잭션이 필요 없습니다 { _id: 1, 재고: 10, 예약: [ … ] } updateOne 하나로 여러 필드를 함께 바꿔도 중간 상태가 다른 곳에 보이지 않습니다 ② 여러 문서 · 여러 컬렉션 주문 문서 저장 + 재고 문서 차감을 함께 → 다중 문서 트랜잭션이 필요합니다 복제 셋 구성이 전제이고, 비용이 작지 않습니다 그래서 설계가 먼저입니다 함께 바뀌는 데이터를 한 문서에 담을 수 있는지 먼저 봅니다(n05). 담을 수 있다면 트랜잭션이 사라집니다 문서 크기 한도(16MB)와 무한히 늘어나는 배열만 주의 읽기 일관성도 고를 수 있습니다 기본은 Primary 에서 읽어 방금 쓴 것이 보입니다 복제본에서 읽게 하면 빨라지지만 몇 초 늦은 값이 옵니다 쓰기도 몇 대까지 확인받을지(w) 고를 수 있습니다 정리하면 — 문서 설계로 피할 수 있으면 피하고, 정말 여러 문서를 묶어야 할 때만 트랜잭션을 씁니다
"몽고는 트랜잭션이 없다"는 옛말이지만, 쓰지 않아도 되게 설계하는 것이 여전히 정석입니다.
문서 하나 안에서는 언제나 원자적입니다 ① 한 문서 안 — 트랜잭션이 필요 없습니다 { _id: 1, 재고: 10, 예약: [ … ] } updateOne 하나로 여러 필드를 함께 바꿔도 중간 상태가 다른 곳에 보이지 않습니다 ② 여러 문서 · 여러 컬렉션 주문 문서 저장 + 재고 문서 차감을 함께 → 다중 문서 트랜잭션이 필요합니다 복제 셋 구성이 전제이고, 비용이 작지 않습니다 그래서 설계가 먼저입니다 함께 바뀌는 데이터를 한 문서에 담을 수 있는지 먼저 봅니다(n05). 담을 수 있다면 트랜잭션이 사라집니다 문서 크기 한도(16MB)와 무한히 늘어나는 배열만 주의 읽기 일관성도 고를 수 있습니다 기본은 Primary 에서 읽어 방금 쓴 것이 보입니다 복제본에서 읽게 하면 빨라지지만 몇 초 늦은 값이 옵니다 쓰기도 몇 대까지 확인받을지(w) 고를 수 있습니다 정리하면 — 문서 설계로 피할 수 있으면 피하고, 정말 여러 문서를 묶어야 할 때만 트랜잭션을 씁니다
"몽고는 트랜잭션이 없다"는 옛말이지만, 쓰지 않아도 되게 설계하는 것이 여전히 정석입니다.
SECTION 09

복제 셋과 샤딩 — 운영 골격

운영 MongoDB는 복제 셋(replica set)이 기본 단위입니다. 한 대짜리 운영은 학습용으로만 쓰세요.

  • 복제 셋 — 프라이머리 1 + 세컨더리 2가 표준. 프라이머리가 죽으면 세컨더리들이 투표로 자동 승격합니다 (수십 초 내). 트랜잭션·majority 쓰기의 전제 조건이기도 합니다.
  • 읽기 분산 — 기본은 전부 프라이머리에서 읽습니다. readPreference=secondaryPreferred 로 세컨더리 읽기를 허용할 수 있지만 복제 지연만큼 낡은 데이터를 볼 수 있습니다 — 통계·목록처럼 낡아도 되는 조회에만.
  • 샤딩 — 컬렉션을 샤드 키 기준으로 여러 복제 셋에 분산합니다. 한 대의 디스크·쓰기 처리량을 넘어설 때의 카드이고, 샤드 키는 한번 정하면 바꾸기 어렵습니다 — 🧬 전문가 탭 z09의 샤딩 원칙이 그대로 적용됩니다.

백업 — mongodump · mongorestore

# 논리 백업 (BSON 덤프) — 소규모의 기본기
mongodump --uri="mongodb://admin:devpass@localhost:27017" \
          --db=shop --gzip --archive=shop_2026-08-11.gz

# 복원 — 새 서버·다른 DB 이름으로도 가능
mongorestore --uri="mongodb://..." --gzip --archive=shop_2026-08-11.gz

# 특정 컬렉션만 JSON 으로 (데이터 이관·검수용)
mongoexport --uri="mongodb://admin:devpass@localhost:27017" \
            --db=shop --collection=member --out=member.json   # 인증을 켰으니 여기도 계정이 필요합니다
RDBMS와 같은 원칙이 적용됩니다 — 복원을 실제로 해 본 적 없는 백업은 백업이 아닙니다. 규모가 커지면 mongodump 는 느려지므로 스냅샷(파일시스템·클라우드) + oplog 기반으로 넘어가고, Atlas 라면 자동 백업 + 시점 복구(PITR)를 켜는 것으로 끝납니다.
Atlas를 쓰면 이 절이 버튼 몇 개가 됩니다 — 복제 셋 기본 제공, 백업·업그레이드·샤딩 자동화. 직접 운영과 관리형의 갈림길은 RDBMS와 같은 논리로 판단하세요: 운영 인력이 없으면 관리형이 거의 항상 쌉니다.
복제 셋과 샤딩 — 목적이 다릅니다 복제 셋 (Replica Set) — 안 죽게 같은 데이터를 3대가 들고 있습니다 — 주 노드 1대가 쓰기를 받고 나머지가 따라옵니다 주 노드가 죽으면 남은 노드끼리 투표해 새 주 노드를 뽑습니다 — 보통 수 초 안에 복구됩니다 홀수 대로 구성해야 투표가 갈리지 않습니다 · 운영 환경의 최소 구성입니다 샤딩 (Sharding) — 용량과 처리량을 늘리려고 샤드 키를 기준으로 데이터를 여러 복제 셋에 나눠 담습니다 라우터가 쿼리를 어느 샤드로 보낼지 정합니다 — 샤드 키가 조건에 있으면 한 곳만 갑니다 한 대로 감당이 안 될 때만 가세요 — 운영 난이도가 확 올라갑니다 샤드 키는 나중에 바꾸기가 매우 어렵습니다 단조 증가 값(시간·순번)을 쓰면 최신 샤드 한 곳에만 쓰기가 몰립니다 — 고르게 퍼지는 값을 고르세요 대부분의 쿼리가 샤드 키를 조건에 담을 수 있는지도 함께 보세요 — 아니면 매번 모든 샤드를 뒤집니다
샤딩은 성능 튜닝의 마지막 카드입니다 — 인덱스와 문서 설계를 다 해 본 뒤에 꺼내세요.
SECTION 10

스키마 검증과 안티패턴

"스키마가 없다"는 "아무거나 넣어도 된다"가 아닙니다. 구조는 코드와 검증 규칙으로 지켜야 하고, 안 지키면 쓰레기 데이터가 조용히 쌓입니다.

스키마 검증 — 컬렉션에 규칙 걸기

db.createCollection("member", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["name", "joined"],                  // 필수 필드
    properties: {
      name:   { bsonType: "string" },
      grade:  { enum: ["BASIC", "SILVER", "GOLD"] },
      points: { bsonType: "number", minimum: 0 }   // "int" 로 두면 mongosh 의 100(double)이 거부됩니다
    }
  }},
  validationAction: "error"      // 어기면 거부 ("warn" = 로그만)
})

안티패턴 4선

패턴왜 문제 · 대신
무한히 자라는 내장 배열로그·알림을 문서 안 배열에 계속 push → 16MB 한도 + 문서 이동 비용. 별도 컬렉션으로.
RDBMS처럼 전부 참조매 조회가 $lookup 잔치 — 문서형 장점 소멸. 화면 단위로 내장 설계(n05).
필드명이 데이터인 문서{ "2026-08-11": 값 } 꼴 — 인덱스·쿼리 불가. { date: …, value: … } 배열로.
검증도 버전 관리도 없음앱 코드 v1·v2가 다른 구조를 넣어 뒤죽박죽. validator + 문서에 schemaVersion 필드.
SECTION 11

앱에서 쓰기 — Python · Java · Node

공식 드라이버가 언어별로 있습니다. 클라이언트 객체는 앱 전체에서 하나 — 풀이 내장돼 있습니다.

Python — PyMongo

# pip install pymongo
from pymongo import MongoClient

client = MongoClient("mongodb://admin:devpass@localhost:27017/")  # 앱에서 1개만
db = client.shop

db.member.insert_one({"name": "김하늘", "grade": "GOLD"})
for m in db.member.find({"city": "서울"}).sort("joined", -1).limit(10):
    print(m["name"])

# 스키마가 필요하면 ODM: pip install beanie (Pydantic 기반) 또는 mongoengine

Java — Spring Data MongoDB

// implementation "org.springframework.boot:spring-boot-starter-data-mongodb"
// spring.data.mongodb.uri=mongodb://admin:devpass@localhost:27017/shop

@Document("member")
public record Member(@Id String id, String name, String grade) {}

public interface MemberRepo extends MongoRepository<Member, String> {
    List<Member> findByGrade(String grade);   // 메서드 이름이 곧 쿼리
}

Node.js — 공식 드라이버 · Mongoose

// npm i mongodb
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb://admin:devpass@localhost:27017");
const db = client.db("shop");

await db.collection("member").insertOne({ name: "김하늘" });
const gold = await db.collection("member").find({ grade: "GOLD" }).toArray();

// 스키마·검증·미들웨어가 필요하면 Mongoose 가 사실상 표준입니다
// npm i mongoose  →  const M = mongoose.model("Member", schema)
인젝션은 NoSQL에도 있습니다. 요청 값을 쿼리 객체에 그대로 꽂으면 { "$gt": "" } 같은 연산자가 주입돼 조건이 항상 참이 됩니다 (로그인 우회 단골). 문자열이어야 할 값은 문자열인지 확인하고, Express라면 mongo-sanitize 류로 $ 키를 걸러내세요.
SECTION 12

RDBMS와 같이 쓰기 — 무엇을 어디에

마지막 질문은 "MongoDB냐 PostgreSQL이냐"가 아니라 "이 데이터는 어느 쪽 성질인가"입니다. 한 서비스가 둘 다 쓰는 게 보통입니다.

데이터RDBMS로MongoDB로
주문 · 결제 · 재고 · 정산✓ 무조건 — 다중 행 정합성·ACID
회원 · 권한✓ 관계가 많고 정확해야 함
상품 카탈로그 (옵션이 제각각)가능 (JSONB)✓ 문서형이 자연스러움
로그 · 이벤트 · 센서 데이터✓ 대량 쓰기 + TTL + 유연 구조
사용자 생성 콘텐츠 (설문·양식)✓ 구조가 문서마다 다름
캐시 · 세션 · 순위표→ 둘 다 아님 — ⚡ Redis
"PostgreSQL JSONB로 충분한가?"를 먼저 물어보세요. 문서형 데이터가 일부라면 PostgreSQL 하나에 JSONB 컬럼(🐘 탭 p07)으로 담는 쪽이 운영할 시스템을 하나로 유지합니다. MongoDB가 이기는 지점은 대부분의 데이터가 문서형이고, 수평 확장·유연 스키마가 처음부터 요구될 때입니다.
다음 단계 — 캐시·키-값은 ⚡ Redis 탭, NoSQL 개념 비교는 r01, 분산·CAP 이론은 🧬 전문가 탭 z12, 앱 연동 일반론(풀·재시도·마이그레이션)은 🔌 앱 연동 탭과 이어집니다.
DESIGN · INDEX · EXPLAIN · TRANSACTION · 네 DB 공통

설계와 튜닝 —
같은 결과를
더 빠르게

SQL을 쓸 줄 아는 것과 느리지 않게 쓰는 것은 다른 능력입니다. 이 탭은 DB 종류와 무관하게 통하는 원리를 다룹니다 — 정규화·인덱스·실행계획·트랜잭션·락·커넥션 풀. 실무에서 성능 문제의 90%가 이 안에 있습니다.

16SECTIONS
4DB 공통 원리
실전진단 절차
SECTION 01

정규화 1~3NF

정규화는 어려운 이론이 아닙니다. 같은 정보를 두 군데 적지 않기 — 이 한 줄입니다.

정규화하지 않으면 벌어지는 일

주문번호회원명회원주소상품명수량
1001김하늘서울시 강남구키보드1
1002김하늘서울시 강남구마우스2
1003김하늘서울시 강남구모니터1
  • 갱신 이상 — 김하늘이 이사하면 주문 100건을 전부 고쳐야 합니다. 하나라도 놓치면 데이터가 서로 다릅니다
  • 삽입 이상 — 아직 주문하지 않은 회원은 등록할 방법이 없습니다
  • 삭제 이상 — 마지막 주문을 지우면 회원 정보도 함께 사라집니다

1NF → 2NF → 3NF

1NF
한 칸에는 값 하나만 "키보드, 마우스, 모니터"를 한 칸에 넣지 않습니다. 반복 그룹(취미1·취미2·취미3)도 안 됩니다.
▼
2NF
기본키 전체에 종속 복합키(주문번호+상품번호)일 때, 일부 키에만 딸린 컬럼(상품명은 상품번호에만 딸림)을 분리합니다.
▼
3NF
키가 아닌 컬럼끼리 종속되지 않게 회원번호 → 회원명 → 회원주소 처럼 이행적 종속을 분리합니다.
-- 정규화 결과 : 3개 테이블로 분리
member     (id, name, address)                  -- 회원 정보는 한 곳에만
orders     (id, member_id, ordered_at)          -- 주문 헤더
order_item (id, order_id, product_id, qty)      -- 주문 상세
product    (id, name, price)                    -- 상품 정보는 한 곳에만

-- 이제 이사하면 한 줄만 고치면 됩니다
UPDATE member SET address = '부산시 해운대구' WHERE id = 1;
실무의 판단 기준은 단순합니다. "이 값이 바뀌면 몇 군데를 고쳐야 하나?"
두 군데 이상이면 정규화가 덜 된 것입니다. 3NF까지만 하면 실무의 대부분이 해결되고, BCNF·4NF·5NF 는 이론적으로는 존재하지만 실무에서 의식적으로 적용할 일은 드뭅니다.
목적은 하나입니다 — 같은 사실을 한 곳에만 적어 둡니다 처음 — 한 표에 다 있음 주문1 · 김 · 서울 상품: 펜, 공책, 지우개 한 칸에 여러 값이 들어 있습니다 ① 1NF — 값 하나씩 주문1 · 펜 주문1 · 공책 행으로 펼칩니다 — 검색·집계가 됩니다 ② 2NF · ③ 3NF orders / order_items customers / products 표를 나눠 사실을 한 곳에 ② 2NF — 키의 일부에만 딸린 열을 떼어 냅니다 (주문번호, 상품번호)가 키인데 '상품명'은 상품번호에만 딸립니다 → 상품 표로 옮깁니다 ③ 3NF — 키가 아닌 열에 딸린 열을 떼어 냅니다 주문 표의 '고객등급'은 고객번호에 딸려 있습니다 → 고객 표로 옮깁니다 이렇게 하면 고객 이름이 바뀔 때 고칠 곳이 한 군데가 됩니다 — 정규화의 실질적인 이득은 이것입니다 실무에서는 대개 3NF 까지면 충분합니다. 조회가 너무 무거워지는 자리만 골라 되돌리는 것이 반정규화(t02)입니다
"중복을 없애라"가 아니라 "고칠 곳을 하나로 만들라"가 정확한 표현입니다.
목적은 하나입니다 — 같은 사실을 한 곳에만 적어 둡니다 처음 — 한 표에 다 있음 주문1 · 김 · 서울 상품: 펜, 공책, 지우개 한 칸에 여러 값이 들어 있습니다 ① 1NF — 값 하나씩 주문1 · 펜 주문1 · 공책 행으로 펼칩니다 — 이제 검색·집계가 됩니다 ② 2NF · ③ 3NF orders / order_items customers / products 표를 나눠 사실을 한 곳에 ② 2NF — 키의 일부에만 딸린 열을 떼어 냅니다 (주문번호, 상품번호)가 키인데 '상품명'은 상품번호에만 딸립니다 → 상품 표로 옮깁니다 ③ 3NF — 키가 아닌 열에 딸린 열을 떼어 냅니다 주문 표의 '고객등급'은 고객번호에 딸려 있습니다 → 고객 표로 옮깁니다 이렇게 하면 고객 이름이 바뀔 때 고칠 곳이 한 군데가 됩니다 — 정규화의 실질적인 이득은 이것입니다 실무에서는 대개 3NF 까지면 충분합니다. 조회가 너무 무거워지는 자리만 골라 되돌리는 것이 반정규화(t02)입니다
"중복을 없애라"가 아니라 "고칠 곳을 하나로 만들라"가 정확한 표현입니다.
SECTION 02

반정규화는 언제 하나

일부러 중복을 두는 것이 반정규화입니다. 측정해서 느릴 때만, 이유를 적어 두고 합니다.

유형예대가
집계 컬럼 post.comment_count — 매번 COUNT 하지 않으려고 댓글 추가·삭제 때 같이 갱신해야 정합성 유지
복사 컬럼 order_item.product_name — 상품명이 바뀌어도 주문 당시 이름 보존 이 경우는 오히려 정확합니다(이력 스냅숏)
계산 컬럼 orders.total_amount — 매번 합산하지 않으려고 품목 변경 시 재계산 필요
평탄화 조인 3번을 피하려고 컬럼을 합쳐 둠 갱신 지점이 늘어남
가격은 반드시 복사해야 합니다. order_item.unit_price 를 두지 않고 product.price 를 조인하면, 상품 가격이 바뀌는 순간 과거 주문 금액이 전부 바뀝니다. 이것은 반정규화가 아니라 "주문 시점의 사실을 기록"하는 올바른 설계입니다. 시간이 지나도 변하면 안 되는 값은 복사하는 것이 원칙입니다.

집계 컬럼을 안전하게 유지하기

✗ 애플리케이션에서 따로 갱신
-- 트랜잭션 밖이거나 순서가 틀리면
-- 카운트가 실제와 어긋납니다
INSERT INTO comment (...);
UPDATE post SET comment_count = comment_count + 1
WHERE id = ?;
-- 예외가 나면? 재시도하면? 중복 증가?
✓ 같은 트랜잭션 + 주기적 검증
BEGIN;
  INSERT INTO comment (...);
  UPDATE post SET comment_count = comment_count + 1
  WHERE id = ?;
COMMIT;

-- 그리고 야간 배치로 정합성 검증·보정
UPDATE post p SET comment_count =
  (SELECT COUNT(*) FROM comment c WHERE c.post_id = p.id);
반정규화 전에 반드시 확인하세요.
① 인덱스로 해결되지 않는가 — 대부분은 인덱스로 충분합니다
② 실제로 느린가 — 측정하지 않고 "느릴 것 같아서" 하는 반정규화가 가장 흔한 실수입니다
③ 캐시로 해결되지 않는가 — Redis 캐시가 더 나은 선택일 수 있습니다
④ 했다면 정합성 검증 배치를 반드시 함께 만드세요
SECTION 03

키 설계 — 대리키 · UUID

기본키 선택은 나중에 바꾸기가 거의 불가능합니다. 처음에 제대로 정해야 합니다.

✗ 자연키 — 의미 있는 값
-- 이메일·사업자번호·주민번호를 PK 로
CREATE TABLE member (
  email VARCHAR(120) PRIMARY KEY,
  ...
);

-- 문제
-- · 이메일은 바뀝니다 → 모든 FK 를 함께 변경
-- · 개인정보가 모든 자식 테이블에 복사됨
-- · 길어서 인덱스가 커짐
✓ 대리키 — 의미 없는 번호
CREATE TABLE member (
  id    BIGINT PRIMARY KEY,      -- 의미 없는 번호
  email VARCHAR(120) UNIQUE,     -- 유일성은 UNIQUE 로 보장
  ...
);

-- 이메일이 바뀌어도 FK 는 그대로
-- 개인정보가 다른 표로 퍼지지 않음

대리키의 종류

종류장점단점
순차 정수
(AUTO_INCREMENT)
작고 빠름 · 인덱스 효율 최고 · 삽입 순서대로 저장 여러 DB 병합 시 충돌 · 총 건수가 노출됨(경쟁사가 가입자 수를 알 수 있음)
UUID v4 어디서든 독립 생성 · 추측 불가 무작위라 인덱스 성능이 나쁩니다 · 16~36바이트
UUID v7 · ULID 시간 순 정렬 + 전역 유일 — 두 장점을 모두 여전히 16바이트 · 지원 라이브러리 확인 필요
스노플레이크 시간+노드+순번 조합. 64비트로 작음 노드 ID 관리 필요
무작위 UUID 를 클러스터드 인덱스의 기본키로 쓰면 성능이 무너집니다. 특히 MySQL InnoDB 는 기본키 순서대로 디스크에 저장하므로(🐬 m09), 무작위 값이 들어올 때마다 페이지 중간에 삽입하며 페이지 분할이 계속 일어납니다. 수천만 건에서 INSERT 성능이 몇 배 차이납니다.
-- 실무의 절충안 : 내부는 순차 정수, 외부 노출은 별도 식별자
CREATE TABLE orders (
    id         BIGINT      PRIMARY KEY,          -- 내부 조인용 (순차)
    public_id  CHAR(26)    NOT NULL UNIQUE,      -- URL·API 노출용 (ULID)
    member_id  BIGINT      NOT NULL REFERENCES member(id),
    ordered_at TIMESTAMP   NOT NULL
);
-- URL: /orders/01J2KX8PQR3M4N5T6V7W8Y9Z0A   ← 순번을 추측할 수 없습니다

복합키를 쓸 것인가

-- 연결 테이블(N:M)은 복합키가 자연스럽습니다
CREATE TABLE post_tag (
    post_id INT NOT NULL REFERENCES post(id),
    tag_id  INT NOT NULL REFERENCES tag(id),
    PRIMARY KEY (post_id, tag_id)       -- 중복 방지가 덤으로 따라옵니다
);
복합키의 트레이드오프 — 중복 방지가 자동으로 되고 인덱스도 겸합니다. 다만 이 표를 참조하는 자식이 생기면 FK 가 두 컬럼이 되어 번거로워집니다. 연결 테이블에 속성이 붙기 시작하면 단일 대리키를 추가하는 편이 낫습니다.
SECTION 04

인덱스 원리 B-Tree

인덱스는 책 뒤의 찾아보기입니다. 100만 줄에서 한 줄을 찾는 데 스무 번이면 끝납니다.

100만 행에서 id = 738,412 한 줄을 찾아가는 길 루트 · 1 ~ 1,000,000 1 ~ 500,000 500,001 ~ 1,000,000 500,001 ~ 600,001 ~ 700,001 ~ · 도착 층을 하나 내려갈 때마다 후보가 수백분의 1 로 줄어듭니다 읽는 양의 차이 인덱스 없음 1,000,000 행을 전부 훑는다 B-Tree 인덱스 페이지 3~4 번이면 도착 행이 100배로 늘어도 층은 한둘 늘 뿐입니다 — 이것이 로그 시간
인덱스가 빠른 이유는 "미리 찾아 놨기" 때문이 아니라 한 번 볼 때마다 후보의 대부분을 버리기 때문입니다.
인덱스 없음 — 전체 스캔
100만 행을 처음부터 끝까지 비교

  1행 → 아니다
  2행 → 아니다
  ...
  1,000,000행

평균 50만 번 비교
디스크 IO 도 그만큼
B-Tree 인덱스
          [500,000]              ← 루트
        /            \
   [250,000]      [750,000]      ← 중간
    /     \        /      \
  ...    ...     ...     ...     ← 리프 (실제 위치)

log₂(1,000,000) ≈ 20번
실제 B-Tree 는 한 노드에 수백 개를 담아
3~4번의 디스크 읽기면 끝납니다

인덱스가 잘 듣는 조건 · 못 듣는 조건

조건인덱스
WHERE id = 100최적
WHERE price BETWEEN 1000 AND 5000범위 스캔 — 좋음
WHERE name LIKE '김%'전방 일치 — 사용 가능
ORDER BY created_at DESC정렬을 인덱스로 대체 — 매우 좋음
WHERE name LIKE '%김%'불가 — 풀스캔
WHERE YEAR(created_at) = 2026불가 — 컬럼에 함수
WHERE status != 'DONE'부정 조건 — 대부분 도움이 안 됨
WHERE gender = 'M'값 종류가 2개뿐 — 선택도가 낮아 무의미
선택도(selectivity)가 인덱스의 가치를 결정합니다. 조건을 걸었을 때 전체의 몇 %가 남는가입니다. 1% 만 남으면 인덱스가 큰 이득이고, 30% 이상 남으면 옵티마이저가 풀스캔을 고릅니다 — 인덱스로 찾은 뒤 테이블을 랜덤하게 읽는 비용이 순차 읽기보다 크기 때문입니다. 인덱스를 만들었는데 안 탄다면 이 때문일 수 있습니다.

인덱스의 비용

  • 쓰기가 느려집니다 — INSERT·UPDATE·DELETE 마다 모든 인덱스를 함께 갱신합니다
  • 디스크를 차지합니다 — 인덱스가 테이블보다 커지는 경우도 흔합니다
  • 옵티마이저의 선택지가 늘어납니다 — 너무 많으면 잘못된 인덱스를 고를 수도 있습니다
인덱스 설계의 출발점 — 실제로 실행되는 쿼리 목록을 먼저 적으세요. "어떤 컬럼에 인덱스를 걸까"가 아니라 "이 쿼리를 빠르게 하려면 어떤 인덱스가 필요한가"가 올바른 순서입니다.
SECTION 05

복합 인덱스 순서 · 커버링

복합 인덱스는 컬럼 순서가 성능을 결정합니다. 전화번호부를 이름으로 찾을 수 없는 것과 같은 이유입니다.

INDEX (성, 이름) — 이렇게 정렬돼 있다 강 · 다온 김 · 나래 김 · 하늘 박 · 바다 이 · 구름 최 · 별 앞 컬럼으로 먼저 정렬되고 같은 성 안에서만 이름이 정렬된다 같은 인덱스인데 조건에 따라 결과가 갈린다 WHERE 성 = '김' '김' 구간으로 곧장 점프 — 인덱스를 탄다 WHERE 성 = '김' AND 이름 = '하늘' 한 줄까지 곧장 — 가장 좋은 형태 WHERE 이름 = '하늘' '하늘'이 어느 성 밑에 있는지 알 수 없다 → 전부 훑는다 "이름으로도 찾을 일이 잦다"면 인덱스를 하나 더 만들어야 합니다 — 컬럼 순서를 바꾸면 다른 인덱스입니다
복합 인덱스는 앞 컬럼부터 차례로만 쓸 수 있습니다 — 전화번호부를 이름만 알고 펼칠 수 없는 것과 같습니다.
CREATE INDEX idx_ord ON orders (member_id, ordered_at, status);
쿼리 조건이 인덱스를 쓸 수 있나
WHERE member_id = 1✓ 선행 컬럼 — 완전 사용
WHERE member_id = 1 AND ordered_at > '2026-01-01'✓ 두 컬럼까지 사용
WHERE member_id = 1 AND status = 'PAID'△ member_id 까지만. status 는 필터로만
WHERE ordered_at > '2026-01-01'✗ 선행 컬럼이 없어 사용 불가
WHERE status = 'PAID'✗ 사용 불가
ORDER BY member_id, ordered_at✓ 정렬도 인덱스로 해결
순서를 정하는 규칙
① 등호(=) 조건 컬럼을 앞으로, 범위(>, <, BETWEEN) 조건은 뒤로
② 등호 컬럼이 여러 개면 선택도가 높은(값 종류가 많은) 것을 앞으로
③ ORDER BY 컬럼을 범위 조건 다음에 두면 정렬까지 해결됩니다
④ 범위 조건 뒤의 컬럼은 정렬에 쓸 수 없습니다 — 이것이 가장 자주 놓치는 부분입니다

커버링 인덱스 — 테이블을 아예 안 읽기

-- 이 쿼리가 필요로 하는 컬럼이 전부 인덱스에 있으면
SELECT member_id, ordered_at FROM orders WHERE member_id = 1;

-- 인덱스만 읽고 끝납니다 (테이블 접근 0)
CREATE INDEX idx_cover ON orders (member_id, ordered_at);

-- 조회 컬럼이 더 있다면 INCLUDE 로 (PostgreSQL 11+ · SQL Server)
CREATE INDEX idx_cover2 ON orders (member_id) INCLUDE (ordered_at, status);
-- MySQL·SQLite 는 INCLUDE 가 없으므로 인덱스 컬럼에 직접 넣습니다
실행계획에서 확인하는 법 — PostgreSQL Index Only Scan · MySQL Extra: Using index · SQLite USING COVERING INDEX · Oracle INDEX FAST FULL SCAN 또는 TABLE ACCESS 가 없는 계획. 이것이 보이면 가장 빠른 형태입니다.

인덱스는 몇 개까지

정해진 답은 없지만, 테이블당 5개 내외가 실무의 대략적 기준입니다. 그보다 많다면 중복되는 인덱스가 있을 가능성이 높습니다.

-- 이 둘은 중복입니다 — 앞의 것을 지워도 됩니다
CREATE INDEX idx_a ON orders (member_id);
CREATE INDEX idx_b ON orders (member_id, ordered_at);   -- idx_a 의 역할을 포함

-- 반대로 이 둘은 서로 다른 쿼리를 위한 것입니다 (둘 다 필요)
CREATE INDEX idx_c ON orders (member_id, ordered_at);
CREATE INDEX idx_d ON orders (ordered_at, member_id);
SECTION 06

인덱스가 안 타는 이유 10

"인덱스를 만들었는데 여전히 느려요" — 원인은 거의 이 열 가지 안에 있습니다.

#원인해결
1컬럼에 함수·연산
WHERE YEAR(dt) = 2026
범위 조건으로 바꾸거나 함수 기반 인덱스를 만듭니다
2앞에 % 붙은 LIKE
LIKE '%검색어%'
전문 검색·trigram 인덱스로 (🐘 p09 · ✒️ s10)
3암묵적 형변환
WHERE varchar_col = 123
타입을 맞춰 비교합니다. 조인 컬럼 타입 불일치도 같은 문제
4선행 컬럼 누락복합 인덱스 순서를 조정하거나 별도 인덱스
5선택도가 낮음
전체의 30% 이상이 조건에 맞음
정상 동작입니다. 풀스캔이 실제로 더 빠릅니다
6통계가 낡음 ANALYZE 실행. 대량 적재 후에는 반드시
7OR 조건
WHERE a = 1 OR b = 2
UNION ALL 로 분리하거나 각각 인덱스 (일부 DB는 병합 처리)
8NULL 조건
WHERE col IS NULL
Oracle 은 단일 컬럼 B-Tree 에 NULL 을 안 넣습니다 → 부분 인덱스나 상수 결합
9부정 조건
!= · NOT IN
대부분 인덱스가 도움이 안 됩니다. 조건을 뒤집어 표현해 보세요
10콜레이션·문자셋 불일치 조인 컬럼의 콜레이션을 통일 (🐬 m04)

가장 흔한 세 가지 실제 수정

✗ 날짜에 함수
WHERE DATE(created_at) = '2026-08-05'
WHERE YEAR(dt)=2026 AND MONTH(dt)=8
WHERE TO_CHAR(dt,'YYYYMM') = '202608'
✓ 범위로
WHERE created_at >= '2026-08-05'
  AND created_at <  '2026-08-06'

WHERE dt >= '2026-08-01'
  AND dt <  '2026-09-01'
✗ 타입이 다른 비교
-- phone 이 VARCHAR 인데
WHERE phone = 01012345678

-- 조인 컬럼 타입이 다름
JOIN t2 ON t1.code = t2.code_int
✓ 타입을 맞춤
WHERE phone = '01012345678'

-- 근본 해결은 스키마 수정 (MySQL 문법 — 조인 상대와 타입 통일)
ALTER TABLE t2 MODIFY code_int VARCHAR(20);
✗ OR 로 묶인 다른 컬럼
SELECT * FROM member
WHERE  email = 'a@b.com'
   OR  phone = '01012345678';
✓ UNION ALL 로 분리
SELECT * FROM member WHERE email = 'a@b.com'
UNION ALL
SELECT * FROM member WHERE phone = '01012345678'
  AND (email <> 'a@b.com' OR email IS NULL);   -- 중복 제거 (NULL 행도 살림)
SECTION 07

실행계획 읽는 법 (4 DB)

느린 쿼리는 추측하지 말고 계획을 보세요. 네 DB의 명령과 읽는 법을 나란히 정리합니다.

PostgreSQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

-- 볼 것
-- rows 추정 vs actual rows (차이 10배↑ = 통계 문제)
-- Seq Scan / Index Scan / Index Only Scan
-- Buffers: shared hit(메모리) vs read(디스크)
-- loops (크면 N+1 형태)
MySQL
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;   -- 8.0.18+

-- 볼 것
-- type: const > eq_ref > ref > range > index > ALL
-- rows: 읽을 것으로 추정한 행 수
-- Extra: Using index(좋음)
--        Using filesort / temporary(주의)
Oracle
SELECT /*+ GATHER_PLAN_STATISTICS */ ...;
SELECT * FROM TABLE(
  DBMS_XPLAN.DISPLAY_CURSOR(NULL,NULL,'ALLSTATS LAST'));

-- 볼 것
-- E-Rows(추정) vs A-Rows(실제)
-- Buffers (읽은 블록 수)
-- TABLE ACCESS FULL / INDEX RANGE SCAN
SQLite
EXPLAIN QUERY PLAN SELECT ...;

-- 볼 것
-- SEARCH ... USING INDEX (좋음)
-- USING COVERING INDEX (가장 좋음)
-- SCAN table (전체 스캔)
-- USE TEMP B-TREE FOR ORDER BY (정렬 비용)
실행계획은 어디부터 읽나 — 번호 순서대로 EXPLAIN 출력 (위가 마지막) 3 Nested Loop (rows=12) 1 -> Index Scan member 2 -> Index Scan orders 가장 깊이 들여쓰인 줄부터, 같은 깊이면 위에서 아래로 실제 실행 — 행은 아래에서 위로 ③ Nested Loop ① member 스캔 ② orders 스캔 안쪽 노드가 행을 만들어 위로 올려 보냅니다
계획이 아무리 길어도 규칙은 하나 — 맨 안쪽(가장 들여쓰인 곳)이 먼저 실행되고, 맨 윗줄이 마지막입니다.

어느 DB든 확인하는 것은 같습니다

1
전체 스캔이 있는가 작은 테이블이면 정상. 큰 테이블에 풀스캔이면 인덱스를 검토합니다.
▼
2
추정 행 수와 실제가 크게 다른가 10배 이상 차이 나면 통계가 낡았거나 데이터 분포가 편향된 것입니다.
▼
3
조인 순서와 방식이 타당한가 작은 쪽을 먼저 몰고 있는지, 조인 방식(NL·Hash·Merge)이 데이터 크기에 맞는지 봅니다(🧬 z08).
▼
4
불필요한 정렬·임시 테이블이 있는가 인덱스로 정렬을 대체할 수 있는지 검토합니다.
비용(cost) 숫자끼리는 비교하지 마세요. cost 는 같은 쿼리의 다른 계획을 비교하기 위한 상대값입니다. "이 쿼리는 cost 500이라 느리다" 같은 판단은 의미가 없습니다. 실제 시간과 읽은 블록 수를 보세요.
SECTION 08

통계 정보와 카디널리티

옵티마이저는 통계를 보고 계획을 짭니다. 통계가 틀리면 멀쩡하던 쿼리가 갑자기 느려집니다.

DB는 각 테이블에 대해 행 수 · 컬럼별 값의 종류 수 · 값의 분포(히스토그램) · NULL 비율 등을 저장해 둡니다. 이를 근거로 "이 조건이면 몇 행이 남을까"를 추정하고, 그 추정에 따라 인덱스를 쓸지 · 어떤 조인 방식을 쓸지를 정합니다.

-- PostgreSQL
ANALYZE orders;
SELECT relname, n_live_tup, last_analyze, last_autoanalyze
FROM   pg_stat_user_tables ORDER BY last_analyze NULLS FIRST;

-- MySQL
ANALYZE TABLE orders;
SELECT table_name, table_rows, update_time FROM information_schema.tables
WHERE  table_schema = 'shop';

-- Oracle
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP','ORDERS');
SELECT table_name, num_rows, last_analyzed FROM user_tables;

-- SQLite
ANALYZE;
PRAGMA optimize;    -- 연결 종료 전에 실행하면 필요한 만큼만

통계가 틀어지는 순간

  • 대량 적재 직후 — 100만 건을 넣었는데 통계는 "0행"이라고 알고 있습니다
  • 대량 삭제 직후 — 반대 상황
  • 배포 직후 — 새 테이블·인덱스에 통계가 아직 없습니다
  • 데이터 분포가 급변 — 이벤트로 특정 값이 폭증한 경우
"어제까지 잘 돌던 쿼리가 오늘 갑자기 느려졌다"면 통계를 먼저 갱신해 보세요. 원인의 상당수가 이것입니다. 대량 배치 작업 후에는 ANALYZE 를 스크립트에 포함하는 것이 좋습니다.

히스토그램 — 편향된 분포

status 컬럼의 값이 DONE 99%, ERROR 1% 라면, WHERE status='ERROR' 는 인덱스가 매우 유리하고 WHERE status='DONE' 은 풀스캔이 낫습니다. 같은 컬럼인데 값에 따라 최적 계획이 다릅니다. 이를 판단하려면 단순 "값 종류 수"가 아니라 분포 히스토그램이 필요합니다.

-- PostgreSQL : 통계 정밀도 올리기 (기본 100 → 최대 10000)
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;

-- MySQL 8.0 : 히스토그램 생성
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 100 BUCKETS;

-- Oracle : 자동으로 필요한 컬럼에 생성 (METHOD_OPT 기본값)
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP','ORDERS',
     method_opt => 'FOR ALL COLUMNS SIZE AUTO');
SECTION 09

페이징 — 커서 방식

OFFSET 100000 은 10만 줄을 세어서 버리고 시작합니다. 뒤 페이지가 느린 이유이자, 무한 스크롤이 갈수록 버벅이는 이유입니다.

OFFSET 100000 LIMIT 20 — 세어서 버린다 100,000 행을 읽고 → 전부 버림 사용자에게 한 줄도 보여 주지 않을 데이터 그 다음 20 행을 반환 1 페이지는 빠르고 5,000 페이지는 느립니다 — 페이지가 깊어질수록 버리는 양이 늘기 때문입니다 커서 방식 — 마지막으로 본 값에서 이어 간다 WHERE id < 마지막_본_id ORDER BY id DESC 인덱스에서 그 지점으로 곧장 점프 읽은 행 = 돌려준 행 = 20 몇 페이지째든 속도가 같습니다 — 대신 번호로 점프할 수 없습니다 무한 스크롤·더보기에는 맞고, 페이지 번호 UI 에는 안 맞습니다
OFFSET 이 느린 이유는 건너뛰기가 공짜가 아니기 때문입니다 — 버릴 행도 일단 읽어야 합니다.
✗ OFFSET 페이징
SELECT * FROM orders
ORDER BY id DESC
LIMIT 20 OFFSET 100000;

-- DB 가 하는 일
-- ① 조건에 맞는 행을 정렬
-- ② 앞의 100,000 행을 읽어서 버림
-- ③ 그다음 20 행을 반환
-- 페이지가 뒤로 갈수록 선형으로 느려집니다
✓ 커서(키셋) 페이징
-- 첫 페이지
SELECT * FROM orders
ORDER BY id DESC LIMIT 20;

-- 다음 페이지 : 마지막으로 본 id 를 기준으로
SELECT * FROM orders
WHERE  id < 987654         -- ← 직전 페이지의 마지막 id
ORDER  BY id DESC LIMIT 20;

-- 몇 페이지든 같은 속도 (인덱스로 바로 진입)

정렬 기준이 유일하지 않을 때

-- 생성일로 정렬하되 같은 시각이 여러 개 있을 수 있다면
-- 반드시 유일한 컬럼을 함께 넣어 순서를 확정합니다
SELECT * FROM orders
WHERE  (ordered_at, id) < ('2026-08-05', 987654)     -- 행 비교 (PostgreSQL·MySQL 지원)
ORDER  BY ordered_at DESC, id DESC
LIMIT  20;

-- 행 비교를 지원하지 않으면 풀어서
WHERE  ordered_at < '2026-08-05'
   OR (ordered_at = '2026-08-05' AND id < 987654)

-- 이 인덱스가 필요합니다
CREATE INDEX idx_ord_dt_id ON orders (ordered_at DESC, id DESC);
비교OFFSET커서
뒤 페이지 속도선형으로 느려짐일정
"5페이지로 점프"가능불가 (다음/이전만)
총 페이지 수 표시가능 (COUNT 필요)어려움
데이터가 중간에 추가·삭제되면행이 중복·누락됨안전
적합한 UI관리자 화면 (페이지 번호)무한 스크롤·모바일 피드
총 건수 표시도 비쌉니다. SELECT COUNT(*) 는 대용량에서 수 초가 걸릴 수 있습니다.
· 정확한 수가 꼭 필요한가 재검토 — "1,000+ 건" 표기로 충분한 경우가 많습니다
· 필요하면 추정치를 쓰세요 (PostgreSQL reltuples, MySQL table_rows)
· 또는 주기적으로 계산해 캐시합니다
SECTION 10

트랜잭션 ACID

돈 계산이 틀리지 않게 하는 네 가지 약속입니다.

속성뜻없으면
Atomicity
원자성
전부 되거나 전부 안 되거나출금은 됐는데 입금이 안 됨
Consistency
일관성
제약조건을 항상 만족재고가 −5개인 상태가 저장됨
Isolation
고립성
동시 트랜잭션이 서로 간섭하지 않음동시 주문에서 재고가 두 번 팔림
Durability
지속성
커밋했으면 정전에도 남음"결제 완료" 후 서버가 죽으니 기록이 없음
ACID 가 보장하지 않는 것
· 애플리케이션의 논리적 오류 — 잘못된 금액을 커밋하면 그대로 남습니다
· 여러 DB에 걸친 정합성 — 분산 트랜잭션은 별도 문제입니다(🧬 z11)
· 외부 시스템 호출 — 결제 API 호출은 롤백되지 않습니다
"트랜잭션으로 감쌌으니 안전하다"는 착각이 실무 사고의 큰 원인입니다.

트랜잭션 경계를 어디에 둘까

✗ 너무 넓음
with db.transaction():
    order = create_order(...)
    result = payment_api.charge(...)   # 외부 호출 3초
    send_email(...)                    # 또 외부 호출
    update_stock(...)
# 락을 3초 이상 붙들고 있어
# 다른 요청이 전부 대기합니다
✓ DB 작업만 짧게
with db.transaction():
    order = create_order(status='PENDING')

result = payment_api.charge(...)       # 트랜잭션 밖

with db.transaction():
    update_order_status(order.id, result)
    update_stock(...)

queue.publish('send_email', order.id)  # 비동기로
DDL 은 대부분 자동 커밋됩니다. CREATE TABLE·ALTER TABLE 은 MySQL·Oracle 에서 앞의 트랜잭션을 커밋시키고 자신도 되돌릴 수 없습니다. PostgreSQL 은 예외적으로 DDL 을 롤백할 수 있어 마이그레이션 스크립트를 안전하게 만들 수 있습니다.
SECTION 11

격리 수준 4단계

동시에 접근할 때 얼마나 엄격하게 막을지입니다. 아래 버튼으로 각 수준에서 무엇이 막히고 무엇이 뚫리는지 확인하세요.

격리 수준별 이상 현상

세 가지 이상 현상

현상무슨 일이 일어나나
더티 리드
Dirty Read
다른 트랜잭션이 아직 커밋하지 않은 값을 읽습니다. 그 트랜잭션이 롤백하면 존재한 적 없는 값을 본 셈이 됩니다.
반복 불가 읽기
Non-repeatable Read
같은 행을 두 번 읽었는데 값이 달라집니다. 사이에 다른 트랜잭션이 수정·커밋했기 때문입니다.
팬텀 리드
Phantom Read
같은 조건으로 두 번 조회했는데 행 개수가 달라집니다. 사이에 새 행이 들어왔기 때문입니다.

DB별 기본값 — 여기가 핵심입니다

DB기본 격리 수준특이점
PostgreSQLREAD COMMITTEDREAD UNCOMMITTED 를 요청해도 READ COMMITTED 로 동작
OracleREAD COMMITTEDREPEATABLE READ 를 지원하지 않습니다 (SERIALIZABLE 은 지원)
MySQL InnoDBREPEATABLE READ다른 DB보다 엄격. 갭 락으로 팬텀도 대부분 방지 (🐬 m11)
SQLiteSERIALIZABLE쓰기가 하나뿐이라 자연히 직렬화됩니다
MySQL 에서 PostgreSQL 로(또는 반대로) 옮길 때 주의하세요. 기본 격리 수준이 다르므로 같은 코드가 다르게 동작할 수 있습니다. 특히 MySQL에서 REPEATABLE READ 를 전제로 짠 코드는 PostgreSQL(READ COMMITTED)에서 같은 트랜잭션 안에서 값이 바뀌는 상황을 만납니다.
-- 격리 수준 확인·변경
SELECT @@transaction_isolation;                          -- MySQL
SHOW transaction_isolation;                              -- PostgreSQL

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;            -- MySQL: 다음 트랜잭션만 · PostgreSQL: BEGIN 직후 현재 트랜잭션
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- MySQL: 세션 전체
-- PostgreSQL 세션 전체: SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 특정 조회만 잠그기 (격리 수준을 올리지 않고 정합성 확보)
SELECT * FROM stock WHERE id = 1 FOR UPDATE;         -- 배타 락 (쓰기 예정)
SELECT * FROM stock WHERE id = 1 FOR SHARE;          -- 공유 락 (읽기만 보장)
실무의 선택 — 대부분 기본값을 그대로 쓰고, 정합성이 꼭 필요한 지점에서만 SELECT … FOR UPDATE 로 필요한 행만 잠급니다. 격리 수준을 통째로 SERIALIZABLE 로 올리면 충돌·재시도가 급증합니다.
올라갈수록 안전해지고, 그만큼 덜 겹칩니다 격리 수준 더티 리드 반복 불가 읽기 팬텀 리드 READ UNCOMMITTED 생김 생김 생김 READ COMMITTED 없음 생김 생김 REPEATABLE READ 없음 없음 DB 마다 다름 SERIALIZABLE 없음 없음 없음 기본값이 서로 다릅니다 MySQL(InnoDB) — REPEATABLE READ PostgreSQL · 오라클 · SQL Server — READ COMMITTED 실무의 답 기본값을 그대로 두고, 재고 차감처럼 꼭 지켜야 할 자리에만 SELECT … FOR UPDATE 로 그 행을 잠급니다
수준을 통째로 올리면 데드락과 대기가 함께 늘어납니다 — 자리를 좁혀 거세요.
올라갈수록 안전해지고, 그만큼 덜 겹칩니다 격리 수준 더티 리드 반복 불가 읽기 팬텀 리드 READ UNCOMMITTED 생김 생김 생김 READ COMMITTED 없음 생김 생김 REPEATABLE READ 없음 없음 DB 마다 다름 SERIALIZABLE 없음 없음 없음 기본값이 서로 다릅니다 MySQL(InnoDB) — REPEATABLE READ PostgreSQL · 오라클 · SQL Server — READ COMMITTED 실무의 답 기본값을 그대로 두고, 재고 차감처럼 꼭 지켜야 할 자리에만 SELECT … FOR UPDATE 로 그 행을 잠급니다
수준을 통째로 올리면 데드락과 대기가 함께 늘어납니다 — 자리를 좁혀 거세요.
SECTION 12

락 · 데드락 분석

데드락은 서로 상대가 잡은 것을 기다리는 상태입니다. 완전히 없앨 수는 없지만 크게 줄일 수 있습니다.

✗ 접근 순서가 다름 — 데드락
-- 트랜잭션 A
UPDATE account SET bal=bal-100 WHERE id=1;  -- 1번 잠금
UPDATE account SET bal=bal+100 WHERE id=2;  -- 2번 대기...

-- 트랜잭션 B (동시에)
UPDATE account SET bal=bal-50 WHERE id=2;   -- 2번 잠금
UPDATE account SET bal=bal+50 WHERE id=1;   -- 1번 대기...

-- 서로 영원히 기다림 → DB 가 한쪽을 롤백시킴
✓ 항상 같은 순서로
-- 두 트랜잭션 모두 id 오름차순으로 잠급니다
-- (애플리케이션에서 정렬 후 처리)

ids = sorted([from_id, to_id])
for i in ids:
    UPDATE account SET ... WHERE id = i;

-- 순서가 같으면 데드락이 원천적으로 안 생깁니다

진단 명령

PostgreSQL
-- 누가 누구를 막고 있나
SELECT blocked.pid AS 대기, blocked.query AS 대기쿼리,
       blocking.pid AS 차단, blocking.query AS 차단쿼리
FROM   pg_stat_activity blocked
JOIN   pg_stat_activity blocking
  ON   blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE  cardinality(pg_blocking_pids(blocked.pid)) > 0;

SELECT pg_terminate_backend(12345);
MySQL
SHOW ENGINE INNODB STATUS\G
-- LATEST DETECTED DEADLOCK 섹션

SELECT * FROM performance_schema.data_lock_waits;
SELECT trx_id, trx_state, trx_started, trx_query
FROM   information_schema.innodb_trx;

KILL 12345;
Oracle
SELECT blocking_session, sid, serial#,
       event, seconds_in_wait
FROM   v$session
WHERE  blocking_session IS NOT NULL;

ALTER SYSTEM KILL SESSION '123,45678' IMMEDIATE;
-- 데드락은 alert.log 와 trace 파일에 기록됩니다
SQLite
-- 락 개념이 단순합니다 (쓰기는 하나뿐)
-- database is locked 가 사실상 유일한 증상
PRAGMA busy_timeout = 5000;
BEGIN IMMEDIATE;    -- 쓰기 트랜잭션은 처음부터

-- 자세한 내용은 ✒️ SQLite 탭 s11

데드락을 줄이는 다섯 가지

  • 접근 순서 통일 — 가장 효과적입니다. 여러 행을 잠글 때 항상 같은 기준으로 정렬
  • 트랜잭션 짧게 — 외부 호출·사용자 입력을 트랜잭션 밖으로
  • 적절한 인덱스 — 인덱스가 없으면 훨씬 넓은 범위가 잠깁니다
  • 격리 수준 낮추기 — 꼭 필요한 곳만 높이세요
  • 재시도 로직 — 데드락은 정상적인 동시성 신호입니다. 1~3회 재시도하면 대부분 성공합니다(🔌 a11)
락 대기 타임아웃을 짧게 두세요. MySQL 기본 innodb_lock_wait_timeout 은 50초입니다. 그동안 커넥션이 묶여 있으면 연쇄적으로 커넥션 풀이 고갈됩니다. 5~10초 정도로 낮추고 애플리케이션에서 재시도하는 편이 낫습니다.
SECTION 13

커넥션 풀 사이징

커넥션 풀은 크게 잡을수록 빨라지지 않습니다. 오히려 느려집니다.

앱 쪽 요청 동시 요청 200 스레드 · 코루틴 커넥션 풀 — 문의 개수 커넥션 10~20 나머지는 여기서 잠깐 대기 대기 시간엔 타임아웃을 DB 서버 코어 8 · 디스크 몇 대 동시 처리량은 정해져 있다 풀 = 300 이면 락 경합·문맥 교환에 전부 같이 느려진다 풀 = 15 이면 기다려도 금방 끝난다 시작값은 코어 수 × 2 + 디스크 수 정도입니다 — 늘리기 전에 슬로우 쿼리부터 잡으세요. 대개 풀이 아니라 쿼리가 원인입니다
커넥션을 늘린다고 DB 가 빨라지지 않습니다. 줄을 어디에 세울지를 정하는 것일 뿐입니다 — 앱 앞이냐, DB 안이냐.

커넥션을 만드는 것은 비싼 작업(TCP 연결 + 인증 + 세션 초기화)이라 미리 만들어 두고 재사용하는 것이 커넥션 풀입니다. 문제는 크기입니다 — DB 서버의 CPU 코어는 정해져 있는데 커넥션을 300개 열어 두면 컨텍스트 스위칭과 락 경합만 늘어납니다.

널리 쓰이는 시작 공식
커넥션 수 = ((코어 수 × 2) + 유효 디스크 수)
8코어 + SSD 1개 → 약 17개. "고작?"이라는 생각이 들겠지만, 실측하면 이 부근에서 처리량이 최대인 경우가 많습니다. 이 값을 시작점으로 두고 부하 테스트로 조정하세요.
증상원인과 대응
커넥션 대기 타임아웃 풀이 작거나, 커넥션을 오래 붙들고 있는 쿼리가 있습니다. 느린 쿼리를 먼저 찾으세요
DB 의 max_connections 초과 앱 인스턴스 수 × 풀 크기가 DB 한계를 넘었습니다. 전체 합으로 계산해야 합니다
풀을 늘렸는데 더 느려짐 정상입니다. DB 가 병목이라면 커넥션을 늘려도 대기 위치만 바뀝니다
커넥션 누수 반납하지 않는 코드가 있습니다. 누수 감지 옵션을 켜세요
# HikariCP (Java · Spring Boot)
# (.properties 는 줄 끝 주석을 지원하지 않습니다 — 주석은 항상 윗줄에)
spring.datasource.hikari.maximum-pool-size=20
# max 와 같게 두는 것이 권장됩니다 (지정하지 않으면 기본값이 max 와 같음)
spring.datasource.hikari.minimum-idle=20
# 풀에서 못 받으면 3초 후 실패
spring.datasource.hikari.connection-timeout=3000
# 30분 (DB 타임아웃보다 짧게)
spring.datasource.hikari.max-lifetime=1800000
# 누수 감지 (20초 넘게 반납 안 되면 경고 로그)
spring.datasource.hikari.leak-detection-threshold=20000
# SQLAlchemy (Python)
engine = create_engine(
    "postgresql+psycopg://app:pw@db/shop",
    pool_size=10,          # 상시 유지
    max_overflow=10,       # 순간 초과 허용 (총 20)
    pool_timeout=3,        # 대기 최대 3초
    pool_recycle=1800,     # 30분마다 갱신
    pool_pre_ping=True,    # ★ 끊긴 커넥션 자동 감지 — 반드시 켜세요
)
여러 인스턴스를 잊지 마세요. 앱 서버 10대 × 풀 20 = DB 커넥션 200개입니다. 여기에 배치·관리자 도구·모니터링까지 더하면 금세 한계에 닿습니다. DB 의 max_connections 는 이 총합보다 여유 있게 잡되, 그 자체를 크게 늘리는 것은 해결책이 아닙니다. PostgreSQL 이라면 PgBouncer 를 두는 것이 정석입니다(🐘 p17).
서버리스 환경(Lambda 등)은 특히 주의하세요. 인스턴스가 요청마다 생겼다 사라지면 커넥션이 폭발합니다. RDS Proxy·PgBouncer 같은 외부 풀러가 사실상 필수입니다.
SECTION 14

슬로우 쿼리 잡는 절차

감으로 찾지 마세요. 순서만 지키면 누구나 잡을 수 있습니다.

1
측정 · 수집 슬로우 쿼리 로그를 켜고 pg_stat_statements·sys.statement_analysis·AWR 로 총 소요 시간 순위를 뽑습니다.
▼
2
우선순위 결정 평균이 느린 쿼리보다 총합이 큰 쿼리를 먼저. 100ms × 100만 회 > 5초 × 10회.
▼
3
실행계획 확인 풀스캔·추정 오차·불필요한 정렬을 찾습니다(t07).
▼
4
가설 → 수정 → 재측정 인덱스 추가 · 쿼리 재작성 · 통계 갱신 중 하나씩 바꾸고 다시 잽니다.
▼
5
부작용 확인 인덱스를 추가하면 쓰기가 느려집니다. 다른 쿼리 계획이 바뀌지 않았는지 확인합니다.

개선 카드 — 효과가 큰 순서

순위카드기대 효과
1인덱스 추가·수정보통 수십~수백 배. 가장 먼저 검토
2N+1 제거쿼리 101번 → 2번
3쿼리 재작성서브쿼리 → 조인, 함수 제거, 커서 페이징
4필요한 컬럼만 조회네트워크·메모리 절감, 커버링 인덱스 가능
5통계 갱신계획이 갑자기 나빠졌을 때
6캐시 도입같은 조회가 반복될 때
7비정규화·집계 테이블위로 안 되면. 정합성 비용을 감수
8서버 설정·증설마지막. 대부분 근본 원인을 가릴 뿐입니다
하지 말아야 할 것
· 측정 없이 최적화 — 어디가 느린지 모르는 채로 코드를 고치는 것
· 여러 개를 한 번에 바꾸기 — 무엇이 효과가 있었는지 알 수 없습니다
· 운영에서 바로 인덱스 생성 — 큰 테이블은 반드시 CONCURRENTLY·온라인 DDL 로
· 서버 설정부터 만지기 — 쿼리가 원인인데 메모리를 늘려 봐야 잠깐 미룰 뿐입니다
SECTION 15

모니터링 지표 · 알람

무엇을 보고 있어야 장애를 터지기 전에 알 수 있는지 정리합니다.

지표정상 범위 · 알람 기준
커넥션 수max_connections 의 70% 초과 시 경고
캐시 히트율버퍼 풀 히트율 99% 이상 유지. 급락하면 스캔 쿼리 유입 의심
슬로우 쿼리 수분당 발생 수 추이. 배포 직후 급증하면 롤백 검토
락 대기 시간대기 세션 수·최대 대기 시간. 5초 이상 지속 시 알람
복제 지연10초 이상이면 경고, 60초 이상이면 심각
디스크 사용률80% 초과 시 알람 — 가득 차면 DB가 멈춥니다
롱 트랜잭션5분 이상 열린 트랜잭션 — PostgreSQL 은 VACUUM 을 막습니다
테이블·인덱스 증가율주 단위 추이. 예상보다 빠르면 보관 정책 검토
데드락 발생 수완전히 0일 필요는 없지만 급증은 신호

도구

  • Prometheus + Grafana — postgres_exporter · mysqld_exporter 로 지표 수집. 사실상 표준
  • percona-toolkit — pt-query-digest(슬로우 로그 분석) · pt-online-schema-change
  • pgBadger — PostgreSQL 로그를 HTML 리포트로
  • 클라우드 관리형 — RDS Performance Insights · Cloud SQL Insights (별도 구성 없이 바로)
알람은 "행동할 수 있는 것"만 남기세요. 울려도 아무도 아무것도 하지 않는 알람이 쌓이면 진짜 알람도 무시하게 됩니다(알람 피로). 각 알람에 "울리면 무엇을 한다"를 문서로 붙여 두세요.
SECTION 16

모델링 실전 — 주문 · 재고

이론을 실제 테이블로 옮겨 봅니다. 커머스의 핵심이자 면접 단골 주제입니다.

-- 상품 : 현재 정보
CREATE TABLE product (
    id          BIGINT PRIMARY KEY,
    name        VARCHAR(200) NOT NULL,
    price       DECIMAL(12,2) NOT NULL CHECK (price >= 0),
    status      VARCHAR(20) NOT NULL DEFAULT 'ON_SALE',
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 재고 : 상품과 분리합니다 (갱신이 잦아 락 경합이 다르기 때문)
CREATE TABLE stock (
    product_id  BIGINT PRIMARY KEY REFERENCES product(id),
    qty         INT NOT NULL CHECK (qty >= 0),      -- ★ DB 가 음수를 막습니다
    updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 주문 헤더 : 주문 시점의 사실을 기록
CREATE TABLE orders (
    id           BIGINT PRIMARY KEY,
    public_id    CHAR(26) NOT NULL UNIQUE,          -- 외부 노출용
    member_id    BIGINT NOT NULL REFERENCES member(id),
    status       VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    total_amount DECIMAL(14,2) NOT NULL,            -- 계산 결과를 저장 (반정규화)
    ordered_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    -- 배송지도 주문 시점 값을 복사합니다 (회원 주소가 바뀌어도 유지)
    ship_name    VARCHAR(50)  NOT NULL,
    ship_addr    VARCHAR(300) NOT NULL,
    ship_phone   VARCHAR(20)  NOT NULL
);
CREATE INDEX idx_orders_member_dt ON orders (member_id, ordered_at DESC);
CREATE INDEX idx_orders_status_dt ON orders (status, ordered_at DESC);

-- 주문 상세 : 상품명·가격도 그 시점 값을 복사
CREATE TABLE order_item (
    id          BIGINT PRIMARY KEY,
    order_id    BIGINT NOT NULL REFERENCES orders(id),
    product_id  BIGINT NOT NULL REFERENCES product(id),
    product_name VARCHAR(200) NOT NULL,             -- ★ 스냅숏
    unit_price  DECIMAL(12,2) NOT NULL,             -- ★ 스냅숏
    qty         INT NOT NULL CHECK (qty > 0)
);
CREATE INDEX idx_item_order ON order_item (order_id);

-- 상태 변경 이력 : 언제 무엇이 바뀌었는지
CREATE TABLE order_history (
    id          BIGINT PRIMARY KEY,
    order_id    BIGINT NOT NULL REFERENCES orders(id),
    from_status VARCHAR(20),
    to_status   VARCHAR(20) NOT NULL,
    changed_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    changed_by  VARCHAR(50)
);

설계 판단 다섯 가지

판단이유
재고를 상품에서 분리 재고는 갱신이 잦고 락 경합이 심합니다. 같은 행에 두면 상품 조회까지 대기하게 됩니다
가격·상품명을 복사 상품 정보가 바뀌어도 과거 주문은 그대로여야 합니다. 회계·분쟁 대응의 기본
total_amount 저장 매번 SUM 하지 않기 위한 반정규화. 저장 시 검증하고 배치로 대사합니다
CHECK (qty >= 0) 애플리케이션 버그가 있어도 재고가 음수가 되는 것을 DB가 막습니다
상태 이력 테이블 "언제 배송으로 바뀌었나"는 반드시 나옵니다. 현재 상태만 두면 답할 수 없습니다

재고 차감 — 동시성이 걸리는 지점

✗ 조회 후 차감
SELECT qty FROM stock WHERE product_id = 10;
-- 애플리케이션에서 qty > 0 확인
UPDATE stock SET qty = qty - 1 WHERE product_id = 10;

-- 동시에 두 요청이 들어오면
-- 둘 다 "재고 1개 있음"을 보고 둘 다 차감 → -1
✓ 조건부 UPDATE 한 문장
UPDATE stock
SET    qty = qty - 1
WHERE  product_id = 10 AND qty >= 1;

-- 영향 행 수가 0이면 재고 부족 → 주문 실패 처리
-- DB 가 원자적으로 처리하므로 경합에서도 안전합니다
더 큰 규모라면 — 선착순 이벤트처럼 한 상품에 수만 요청이 몰리면 한 행에 락이 집중돼 DB가 병목이 됩니다. 이때는 Redis 원자 연산으로 선착순을 가리고 DB에는 확정분만 반영하거나, 재고를 여러 행으로 쪼개는 방식을 씁니다. 자세한 패턴은 ☕ 자바 가이드의 대규모 트래픽 탭에 있습니다.
EXPERT · 페이지 · WAL · MVCC · 옵티마이저 · 샤딩 · HA

전문가 —
DB 안에서
무슨 일이

여기서부터는 "왜 그렇게 동작하는가"입니다. 커밋이 왜 디스크 속도에 묶이는지, 왜 PostgreSQL은 VACUUM이 필요한데 오라클은 아닌지, 옵티마이저가 왜 이상한 계획을 골랐는지 — 튜닝으로 안 풀리는 문제는 대개 이 층위에서 원인을 찾습니다.

16SECTIONS
4DB 내부 비교
설계분산 · HA
SECTION 01

디스크에 어떻게 저장되나

DB는 행 단위가 아니라 페이지(블록) 단위로 읽고 씁니다. 한 줄만 고쳐도 페이지 전체가 움직입니다.

테이블 = 같은 크기 페이지의 나열 (8KB · 16KB) page 0 page 1 page 2 page 3 … 페이지 하나를 열어 보면 헤더 슬롯 배열 → 행 위치 빈 공간 행들 — 뒤에서부터 채워 들어온다 그래서 벌어지는 일 1 바이트를 고쳐도 페이지 전체가 움직인다 읽기도 쓰기도 페이지 단위 — 행 단위가 아니다 한 페이지에 몇 행이 들어가느냐가 성능 행이 짧을수록 같은 I/O 로 더 많이 읽는다 꽉 찬 페이지에 끼워 넣으면 쪼개진다 무작위 키 삽입이 느린 진짜 이유 — 페이지 분할 SELECT 한 컬럼만 골라도 디스크에서는 그 행이 든 페이지가 통째로 올라옵니다 — 컬럼 수보다 읽는 페이지 수가 비용입니다
DB 의 성능을 이야기할 때 단위는 행이 아니라 페이지입니다. 실행계획의 숫자도 대부분 페이지 수입니다.
DB기본 페이지 크기비고
PostgreSQL8KB컴파일 시 결정 (변경하려면 재빌드)
MySQL InnoDB16KBinnodb_page_size 로 4·8·16·32·64KB
Oracle8KB테이블스페이스마다 다르게 지정 가능
SQLite4KBPRAGMA page_size

페이지 안의 구조

┌─────────────────────────────────────────┐
│ 헤더 (체크섬 · LSN · 여유 공간 위치)      │
├─────────────────────────────────────────┤
│ 슬롯 배열 →→→                            │  각 행이 페이지 안 어디에 있는지 가리킴
│                                          │
│              (빈 공간)                    │  ← 여기가 채워지면 페이지 분할
│                                          │
│                          ←←← 행 데이터   │  뒤에서부터 채워 옵니다
├─────────────────────────────────────────┤
│ 푸터 / 체크섬                             │
└─────────────────────────────────────────┘

이 구조가 만드는 실무적 결과

  • 행 하나를 읽어도 페이지 전체를 읽습니다 — 그래서 관련 행이 물리적으로 가까이 있으면 빠릅니다(지역성)
  • UPDATE 로 행이 커지면 페이지에 안 들어가 다른 페이지로 옮겨가고(row migration), 원래 자리에는 포인터만 남습니다. 조회가 2단계가 되어 느려집니다
  • 가변 길이 컬럼을 자주 늘리면 이 현상이 심해집니다. Oracle의 PCTFREE·PostgreSQL의 fillfactor 는 여유 공간을 미리 남겨 이를 완화합니다
  • 큰 값(TEXT·BLOB)은 별도 저장됩니다 — PostgreSQL은 TOAST, InnoDB는 오버플로 페이지. 조회할 때 추가 IO가 발생하므로 SELECT * 가 더 비싸집니다
-- PostgreSQL : 갱신이 잦은 테이블은 여유 공간을 남겨 HOT 업데이트를 유도
ALTER TABLE orders SET (fillfactor = 80);
VACUUM FULL orders;      -- 또는 pg_repack

-- 물리적 순서를 인덱스 순서로 재배치 (범위 조회가 많을 때)
CLUSTER orders USING idx_orders_dt;
PostgreSQL 의 HOT 업데이트 — 인덱스 컬럼을 건드리지 않는 UPDATE 는 같은 페이지 안에 새 버전을 만들고 인덱스를 갱신하지 않습니다. 훨씬 저렴합니다. fillfactor 로 여유를 남겨 두면 HOT 업데이트 비율이 올라갑니다.
SECTION 02

버퍼 풀 · 캐시 히트율

메모리에 올라와 있으면 마이크로초, 디스크까지 가면 밀리초. 1000배 차이가 여기서 납니다.

1
버퍼 풀에서 찾는다있으면(hit) 즉시 반환 — 수 μs
▼ 없으면
2
디스크에서 페이지를 읽어 올린다SSD 수십 μs · HDD 수 ms
▼ 자리가 없으면
3
오래된 페이지를 밀어낸다 (LRU) 수정된 페이지(더티)라면 먼저 디스크에 써야 합니다.
-- PostgreSQL : 히트율
SELECT datname,
       ROUND(blks_hit * 100.0 / NULLIF(blks_hit + blks_read, 0), 2) AS 히트율
FROM   pg_stat_database WHERE datname = current_database();

-- MySQL InnoDB
SELECT ROUND(100 - (
  (SELECT variable_value FROM performance_schema.global_status
    WHERE variable_name='Innodb_buffer_pool_reads') * 100.0 /
  (SELECT variable_value FROM performance_schema.global_status
    WHERE variable_name='Innodb_buffer_pool_read_requests')), 2) AS 히트율;

-- Oracle
SELECT name, value FROM v$sysstat
WHERE  name IN ('db block gets','consistent gets','physical reads');
히트율 99%가 항상 좋은 것은 아닙니다. 풀스캔이 반복되면 같은 페이지를 계속 읽어 히트율이 오히려 높게 나올 수 있습니다. 히트율만 보지 말고 초당 논리 읽기(logical reads) 수를 함께 보세요. "히트율은 좋은데 CPU가 100%"라면 읽는 양 자체가 과도한 것입니다.

스캔이 캐시를 밀어내는 문제

큰 테이블을 한 번 풀스캔하면 버퍼 풀에 있던 유용한 페이지가 전부 밀려납니다. 그 뒤 한동안 모든 쿼리가 느려집니다. DB들은 이를 완화하는 장치를 둡니다.

  • PostgreSQL — 큰 순차 스캔은 작은 링 버퍼만 사용해 전체를 오염시키지 않습니다
  • InnoDB — LRU 리스트를 young/old 로 나눠, 한 번 읽힌 페이지는 old 영역에 두었다가 재사용될 때만 승격합니다
  • Oracle — 풀스캔 블록을 LRU 끝에 두거나 KEEP·RECYCLE 풀로 분리합니다
메모리 증설이 답인지 판단하려면 — ① 히트율이 낮고 ② 디스크 읽기가 많으며 ③ 데이터 활성 부분이 메모리보다 큰 경우에만 효과가 있습니다. 쿼리가 불필요하게 많이 읽고 있다면 메모리를 늘려도 곧 다시 부족해집니다.
버퍼 풀 — 디스크를 안 건드리는 것이 성능의 전부입니다 쿼리 도착 이 페이지가 필요합니다 버퍼 풀에 있음 — 즉시 반환 없음 — 디스크에서 읽어 올림 자리가 없으면 오래 안 쓴 페이지를 내보냄 캐시 적중률로 상태를 봅니다 99% 이상이면 건강합니다 · 90% 아래로 떨어지면 디스크 읽기가 눈에 띄게 늘어납니다 확인 — MySQL 은 innodb_buffer_pool_read_requests 대비 reads, PostgreSQL 은 pg_stat_database 가장 효과가 큰 튜닝인 경우가 많습니다 — 서버 메모리의 절반 이상을 버퍼 풀에 주는 것이 보통입니다 다만 데이터가 메모리보다 훨씬 크면 한계가 있습니다 — 그때는 읽는 양 자체를 줄여야 합니다 재시작 직후에는 버퍼 풀이 비어 있어 느립니다 — 예열 기능이 있는 DB 도 있습니다
DB 성능 이야기의 대부분은 결국 디스크를 몇 번 건드리느냐로 환원됩니다.
SECTION 03

WAL · REDO · UNDO

DB는 진짜 데이터보다 로그를 먼저 씁니다. 이것이 커밋이 디스크 속도에 묶이는 이유이자, 전원이 나가도 복구되는 이유입니다.

UPDATE 한 번이 지나가는 길 UPDATE 클라이언트 메모리의 데이터 페이지 여기서는 즉시 바뀐다 로그 버퍼 "무엇을 바꿨는가" COMMIT — 로그만 디스크로 순차 쓰기 한 번 · fsync 데이터 페이지는 나중에 — 체크포인트 진짜 데이터는 아직 디스크에 없다 REDO 다시 적용 UNDO 되돌리기 전원이 나가면 로그를 읽어 되살립니다
커밋이 디스크 순차 쓰기 한 번으로 끝나는 것이 WAL 의 핵심입니다 — 흩어진 데이터 페이지를 그때마다 쓰면 커밋은 수십 배 느려집니다.
WAL (Write-Ahead Logging) 의 원칙 — 데이터 페이지를 디스크에 쓰기 전에 변경 내용을 로그에 먼저 기록하고, 로그가 디스크에 안전하게 기록된 시점에 커밋을 성공으로 응답합니다. 데이터 파일은 나중에 천천히 반영해도 됩니다 — 로그만 있으면 재현할 수 있으니까요.

왜 로그를 먼저 쓰면 빠른가

로그 없이 데이터 파일에 직접
커밋할 때마다
디스크의 여러 위치에 랜덤 쓰기

· 페이지 A (17번 블록)
· 페이지 B (9,842번 블록)
· 인덱스 페이지 C (55번 블록)

→ 랜덤 IO 3회 이상
WAL — 순차 쓰기 한 번
로그 파일 끝에 이어 붙이기

[변경1][변경2][변경3]→

→ 순차 IO 1회
데이터 파일은 나중에 모아서 반영
(체크포인트)

REDO 와 UNDO

로그담는 내용쓰는 곳
REDO바뀐 뒤의 값 (다시 적용하기 위한) 크래시 복구 · 복제 · 시점 복구
UNDO바뀌기 전의 값 (되돌리기 위한) ROLLBACK · MVCC의 과거 버전 제공
PostgreSQL
WAL 하나로 REDO 역할
UNDO 는 별도로 없음
→ 옛 버전을 테이블 안에 그대로 남김
→ 그래서 VACUUM 이 필요
MySQL InnoDB
redo 로그 (ib_logfile)
undo 로그 (undo tablespace)
→ 옛 버전은 undo 에 보관
→ purge 스레드가 정리
Oracle
redo 로그 + 아카이브 로그
undo 테이블스페이스
→ Flashback Query 가
   여기서 나옵니다
SQLite
롤백 저널 (기본) = UNDO 방식
WAL 모드 = REDO 방식
둘 중 하나를 선택

커밋이 느린 이유 — fsync

-- 안전성과 속도의 트레이드오프 설정
-- PostgreSQL
synchronous_commit = on        -- 기본·안전 (커밋마다 fsync)
synchronous_commit = off       -- 빠름 · 장애 시 최근 수백 ms 유실 가능 (데이터 파일은 안 깨짐)

-- MySQL
innodb_flush_log_at_trx_commit = 1   -- 기본·안전
innodb_flush_log_at_trx_commit = 2   -- OS 캐시까지만 · OS 크래시 시 유실

-- SQLite
PRAGMA synchronous = FULL;     -- 기본
PRAGMA synchronous = NORMAL;   -- WAL 과 함께면 안전하면서 빠름
그룹 커밋 — DB들은 동시에 들어온 여러 커밋의 fsync 를 한 번에 묶어 처리합니다. 그래서 동시 접속이 많을수록 커밋당 비용이 낮아집니다. 반대로 한 세션이 순차적으로 1만 번 커밋하면 fsync 1만 번이 됩니다 — 대량 적재 시 반드시 트랜잭션으로 묶어야 하는 이유입니다.
SECTION 04

체크포인트와 크래시 복구

체크포인트는 "여기까지는 디스크에 확실히 반영됐다"는 표시입니다. 재시작 시간이 여기서 결정됩니다.

평상시
메모리에서 수정 · 로그만 디스크에 데이터 파일은 아직 옛 상태. 메모리의 더티 페이지가 쌓입니다.
▼
체크포인트
더티 페이지를 디스크에 반영 "이 지점까지의 로그는 이제 없어도 된다"고 기록합니다. IO 스파이크가 발생합니다.
▼
크래시 후
마지막 체크포인트부터 로그 재생 체크포인트 간격이 길수록 재생할 로그가 많아 재시작이 오래 걸립니다.
# PostgreSQL — 체크포인트를 넓게 펴서 IO 스파이크를 줄입니다
checkpoint_timeout = 15min
max_wal_size = 4GB
checkpoint_completion_target = 0.9    # 다음 체크포인트까지 90% 구간에 걸쳐 천천히

# MySQL
innodb_log_file_size = 1G             # 크면 체크포인트가 덜 자주 (8.0.30+ 는 innodb_redo_log_capacity)
innodb_io_capacity = 2000             # 백그라운드 플러시 속도
트레이드오프 — 체크포인트가 잦으면 IO 부담이 크지만 재시작이 빠릅니다. 드물면 평소 부담은 적지만 장애 후 복구가 오래 걸립니다. 서비스가 얼마나 빨리 살아나야 하는가(RTO)로 결정하세요.

복구 과정 (ARIES 방식)

  1. 분석 — 마지막 체크포인트부터 로그를 읽어 어떤 트랜잭션이 진행 중이었는지 파악
  2. REDO — 커밋 여부와 무관하게 로그에 있는 모든 변경을 다시 적용해 크래시 직전 상태로
  3. UNDO — 그중 커밋되지 않은 트랜잭션을 되돌립니다
PostgreSQL 이 재시작 후 "recovering" 상태로 한참 있다면 이 과정 중입니다. 강제로 죽이지 마세요 — 다시 처음부터 해야 합니다. 로그로 진행 상황을 확인하며 기다리는 것이 맞습니다.
SECTION 05

MVCC 구현 비교

"읽기가 쓰기를 막지 않는다"는 같은 목표를 세 DB가 완전히 다르게 구현했습니다. 운영 특성의 차이가 전부 여기서 나옵니다.

PostgreSQL — 테이블에 옛 버전을 남김
UPDATE 시
· 새 행 버전을 테이블에 추가
· 옛 행에 xmax 표시

장점
· 롤백이 즉시 (아무것도 안 해도 됨)
· UNDO 영역 관리 불필요

단점
· 테이블이 계속 부풀음 → VACUUM 필수
· 인덱스도 함께 부풀음
· 트랜잭션 ID 랩어라운드 위험
InnoDB — undo 로그에 옛 버전
UPDATE 시
· 행을 제자리에서 수정
· 옛 값은 undo 로그에

장점
· 테이블이 덜 부풀음
· 최신 행 조회가 빠름

단점
· 롤백이 비쌈 (undo 를 되돌려야)
· 긴 트랜잭션이 undo 를 붙들면
  purge 가 밀려 히스토리가 폭증
Oracle — undo 세그먼트
InnoDB 와 유사한 방식

특징
· UNDO 보존 기간 동안 과거 조회 가능
  → Flashback Query
· ORA-01555 (snapshot too old)
  = 필요한 과거 버전이 이미 덮임
SQLite — 사실상 직렬화
WAL 모드에서
· 읽기는 WAL 이전 스냅숏을 봄
· 쓰기는 한 번에 하나

동시 쓰기가 없으므로
MVCC 의 복잡성이 대부분 불필요

실무에서 드러나는 차이

상황PostgreSQLInnoDB · Oracle
1억 행 UPDATE 후테이블이 2배로 부풀고 VACUUM 필요undo 가 커지지만 테이블은 유지
대량 롤백즉시 끝남되돌리는 데 오래 걸림
오래 열린 트랜잭션VACUUM 이 막혀 팽창·랩어라운드 위험undo 히스토리 증가 · 스냅숏 오류
과거 시점 조회불가 (확장 필요)Oracle 은 Flashback 으로 가능
공통 결론 — 긴 트랜잭션이 모든 DB에서 문제입니다. "커밋을 안 한 채로 열어 둔 세션" 하나가 PostgreSQL에서는 디스크 팽창을, InnoDB·Oracle에서는 undo 폭증을 만듭니다. 모니터링 항목에 "5분 이상 열린 트랜잭션"을 반드시 넣으세요.
SECTION 06

락 매니저와 2PL

DB 안의 교통정리 담당입니다. 무엇이 언제 잠기는지 알면 경합의 원인이 보입니다.

2단계 잠금 (Two-Phase Locking)

확장 단계
락을 획득하기만 합니다필요한 것을 잠그고, 이 단계에서는 절대 풀지 않습니다.
▼
수축 단계
락을 풀기만 합니다보통 커밋 시점에 한꺼번에 풉니다(Strict 2PL). 그래서 트랜잭션이 길면 락도 오래 유지됩니다.

락의 종류와 계층

종류설명
공유 락 (S)읽기용. 여러 개가 동시에 가능
배타 락 (X)쓰기용. 하나만 가능하며 S 와도 충돌
인텐션 락 (IS/IX)테이블 수준에 "안에서 행을 잠글 예정"이라고 표시. 테이블 락과 행 락의 충돌을 빠르게 감지하기 위한 장치
메타데이터 락 (MDL)DDL 과 DML 의 충돌 방지. ALTER TABLE 이 막히는 원인
래치 (latch)메모리 구조 보호용 초경량 락. 트랜잭션과 무관하게 짧게 잡습니다
어드바이저리 락애플리케이션이 임의 목적으로 쓰는 락 (PostgreSQL pg_advisory_lock) — 분산 작업 조율에 유용
-- PostgreSQL 어드바이저리 락 : 배치 중복 실행 방지에 유용합니다
SELECT pg_try_advisory_lock(12345);   -- true 면 획득, false 면 이미 누가 실행 중
-- ... 작업 ...
SELECT pg_advisory_unlock(12345);

-- 세션이 끊기면 자동 해제되므로 "죽은 락"이 남지 않습니다
락 경합이 심한데 튜닝으로 안 풀린다면 — 접근 방식 자체를 바꾸는 것이 답입니다.
· 한 행에 몰리는 카운터 → 여러 행으로 쪼개고 합산(샤딩된 카운터)
· 순차 채번 → 시퀀스 캐시를 늘리거나 UUID 계열로
· 큐 테이블 경합 → FOR UPDATE SKIP LOCKED
· 선착순 이벤트 → DB 밖(Redis)에서 먼저 가리고 확정분만 DB로
SECTION 07

옵티마이저 — 비용 모델 · 조인 순서

옵티마이저는 가능한 실행 경로 중 가장 싸 보이는 것을 고릅니다. 그 계산 방식을 알면 왜 이상한 계획을 골랐는지 역추적할 수 있습니다.

1
파싱 · 재작성문법 검사, 뷰 전개, 서브쿼리 평탄화, 조건 밀어내기(predicate pushdown)
▼
2
후보 계획 생성접근 경로(스캔 vs 인덱스) × 조인 순서 × 조인 방식의 조합
▼
3
비용 추정통계로 각 단계의 행 수(카디널리티)를 추정하고 IO·CPU 비용을 계산
▼
4
최소 비용 선택테이블이 많으면 조합이 폭발하므로 동적 계획법 + 휴리스틱으로 탐색을 제한합니다
옵티마이저가 틀리는 근본 원인은 카디널리티 추정 오차입니다. "이 조건이면 100행 남을 것"이라 추정했는데 실제로 100만 행이면, 그 뒤의 모든 선택(조인 방식·순서)이 연쇄적으로 잘못됩니다. 실행계획에서 추정과 실제 행 수의 차이를 먼저 보는 이유가 이것입니다.

추정이 빗나가는 전형적 상황

  • 컬럼 간 상관관계 — "도시=서울"과 "지역번호=02"는 사실상 같은 조건인데, 옵티마이저는 독립이라 가정해 확률을 곱합니다 → 심하게 과소 추정
  • 편향된 분포 — 히스토그램이 없으면 균등 분포로 가정합니다
  • 함수·표현식 — WHERE f(col) = 1 의 선택도는 추정 불가 → 기본값 사용
  • 바인드 변수 피킹 — 첫 실행의 값으로 계획을 만들어 캐시하는데, 다음 값에는 최악일 수 있습니다
-- PostgreSQL : 상관관계를 알려 주는 확장 통계 (10+)
CREATE STATISTICS stat_city_zip (dependencies, ndistinct)
  ON city, zip_code FROM member;
ANALYZE member;

-- Oracle : 확장 통계 (CREATE_EXTENDED_STATS 는 함수라 SELECT 로 부르고, 통계를 다시 모읍니다)
SELECT DBMS_STATS.CREATE_EXTENDED_STATS('APP','MEMBER','(city, zip_code)') FROM dual;
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP','MEMBER');

-- MySQL : 히스토그램
ANALYZE TABLE member UPDATE HISTOGRAM ON city WITH 50 BUCKETS;
계획이 불안정할 때의 최후 수단 — Oracle SQL Plan Baseline, MySQL 옵티마이저 힌트, PostgreSQL pg_hint_plan(확장) 으로 계획을 고정할 수 있습니다. 다만 고정한 계획은 데이터가 변해도 그대로이므로, 반드시 왜 그런 계획이 나왔는지 원인을 먼저 찾으세요.
옵티마이저는 '추정한 비용'으로 고릅니다 — 실제가 아닙니다 SQL 한 줄 무엇을 원하는지만 후보 계획들 A: 전체 스캔 + 해시조인 B: 인덱스 + 중첩루프 C: 인덱스 + 병합조인 비용 추정 근거는 통계입니다 · 행 수 · 값의 분포 · 값의 종류 수 읽을 페이지 수로 환산 가장 싼 계획 채택 통계가 낡으면 전부 틀립니다 "이 조건이면 10행" 이라 믿고 중첩루프를 골랐는데 실제로 50만 행이면 그 계획은 재앙이 됩니다 → 대량 적재 뒤에는 통계를 갱신하세요 추정이 어려운 조건들 함수로 감싼 컬럼 · LIKE '%중간%' · 여러 열의 상관관계 (예: 도시='서울' AND 우편번호='06...' 는 사실 같은 뜻) → 확장 통계나 힌트로 도와줍니다 — 최후의 수단으로
계획이 이상하면 옵티마이저를 의심하기 전에 통계를 보세요.
옵티마이저는 '추정한 비용'으로 고릅니다 — 실제가 아닙니다 SQL 한 줄 무엇을 원하는지만 후보 계획들 A: 전체 스캔 + 해시조인 B: 인덱스 + 중첩루프 C: 인덱스 + 병합조인 비용 추정 근거는 통계입니다 · 행 수 · 값의 분포 · 값의 종류 수 읽을 페이지 수로 환산 가장 싼 계획 채택 통계가 낡으면 전부 틀립니다 "이 조건이면 10행" 이라 믿고 중첩루프를 골랐는데 실제로 50만 행이면 그 계획은 재앙이 됩니다 → 대량 적재 뒤에는 통계를 갱신하세요 추정이 어려운 조건들 함수로 감싼 컬럼 · LIKE '%중간%' · 여러 열의 상관관계 (예: 도시='서울' AND 우편번호='06...' 는 사실 같은 뜻) → 확장 통계나 힌트로 도와줍니다 — 최후의 수단으로
계획이 이상하면 옵티마이저를 의심하기 전에 통계를 보세요.
SECTION 08

조인 알고리즘 3종

조인 방식은 세 가지뿐입니다. 상황마다 최적이 다르고, 잘못 고르면 수백 배 차이가 납니다.

① Nested Loop — 한 행씩 들고 반대편을 찾아간다 작은 쪽 100 행 행마다 인덱스 조회 100회 인덱스가 있어야 성립 한쪽이 작고 반대쪽에 인덱스가 있을 때 최고 둘 다 크면 100만 × 조회 = 재앙 ② Hash Join — 작은 쪽으로 해시 표를 만들고 큰 쪽을 한 번 훑는다 작은 쪽 메모리에 해시 큰 쪽을 순차 스캔 1회 각 행을 해시로 즉시 대조 인덱스가 없어도 되고 대량 조인에 강함 해시가 메모리를 넘치면 디스크로 새어 급격히 느려진다 ③ Merge Join — 양쪽이 이미 정렬돼 있을 때 나란히 훑는다 정렬된 A ↓ 손가락 1 정렬된 B ↓ 손가락 2 — 작은 쪽만 전진 정렬이 공짜일 때만 이깁니다 일부러 정렬해서 붙이면 대개 해시 조인이 더 빠르다
실행계획에서 조인 방식이 바뀌었다면 대개 통계가 예상한 행 수가 바뀐 것입니다 — 방식을 힌트로 고정하기 전에 통계부터 보세요.
방식동작유리한 상황
Nested Loop
중첩 루프
바깥 테이블의 각 행마다 안쪽을 조회 바깥이 매우 작고 안쪽에 인덱스가 있을 때
Hash Join
해시 조인
작은 쪽으로 해시 테이블을 만들고 큰 쪽을 훑으며 매칭 양쪽이 크고 등호 조인일 때. 대량 처리의 기본
Merge Join
정렬 병합
양쪽을 정렬한 뒤 지퍼처럼 맞춰 나감 양쪽이 이미 정렬돼 있거나 범위 조인일 때
Nested Loop        바깥 1000행 × 안쪽 인덱스 조회 = 1000번 탐색
                   → 바깥이 100만 행이면 100만 번 → 재앙

Hash Join          작은 테이블(10만행)로 해시 생성 → 메모리 사용
                   큰 테이블(1억행) 1회 스캔하며 매칭
                   → 메모리가 부족하면 디스크로 넘침(spill) → 급격히 느려짐

Merge Join         양쪽 정렬 비용 + 1회 스캔
                   → 인덱스로 이미 정렬돼 있으면 정렬 비용 0
실무에서 가장 흔한 사고 — 옵티마이저가 바깥 테이블이 작을 것으로 추정해 Nested Loop 를 골랐는데 실제로는 100만 행이었던 경우입니다. 쿼리가 몇 초에서 몇 시간으로 바뀝니다. 실행계획에서 loops 값이 비정상적으로 크면 이 상황입니다.

해시 조인이 디스크로 넘어가지 않게

-- PostgreSQL : 이 트랜잭션에서만 work_mem 을 크게 (BEGIN 안에서. 세션 전체는 SET work_mem)
SET LOCAL work_mem = '256MB';
-- 실행계획에 "Batches: 8" 처럼 나오면 메모리 부족으로 나눠 처리한 것입니다

-- MySQL 8.0 : 해시 조인 버퍼
SET SESSION join_buffer_size = 256*1024*1024;

-- Oracle : 기본은 PGA 자동 관리(AUTO) — 이 세션만 수동으로 바꿔 크기를 지정
ALTER SESSION SET workarea_size_policy = MANUAL;
ALTER SESSION SET hash_area_size = 268435456;
조인 방식을 직접 고르기 전에 — ① 통계를 갱신하고 ② 조인 컬럼에 인덱스가 있는지 확인하고 ③ 불필요한 행을 조인 전에 걸러 내세요. 이 셋으로 대부분 해결됩니다. 힌트는 그다음입니다.
SECTION 09

파티셔닝 · 샤딩 전략

파티셔닝은 한 서버 안에서 나누는 것, 샤딩은 여러 서버로 나누는 것입니다. 샤딩은 되돌리기가 매우 어려우므로 마지막 카드입니다.

구분파티셔닝샤딩
범위한 DB 서버 안여러 DB 서버
해결하는 것큰 테이블의 조회·삭제 효율쓰기 처리량·저장 용량 한계
애플리케이션 변경거의 없음라우팅 로직 필요
조인그대로 가능샤드를 넘는 조인이 어려움
트랜잭션그대로분산 트랜잭션 문제(z11)
되돌리기쉬움사실상 재구축

샤드 키 선택 — 되돌릴 수 없는 결정

기준장단점
해시(사용자 ID) 고르게 분산됩니다. 단, 범위 조회가 전 샤드로 퍼집니다
범위(날짜) 최근 데이터에 부하가 집중됩니다(핫 샤드). 아카이빙에는 유리
디렉터리(매핑 테이블) 유연하지만 매핑 조회가 병목·단일 장애점이 됩니다
지역 법규(데이터 주권)·지연 시간에 유리. 지역 간 편차가 큽니다
샤딩 전에 반드시 소진해야 할 카드들
① 인덱스·쿼리 튜닝 — 대부분 여기서 해결됩니다
② 읽기 복제본 — 조회 부하는 이것으로 나눕니다
③ 캐시 — Redis 로 반복 조회를 흡수
④ 수직 분할 — 도메인별로 DB를 나눕니다(주문 DB · 회원 DB)
⑤ 아카이빙 — 오래된 데이터를 분리
⑥ 하드웨어 증설 — 클라우드에서는 종종 가장 저렴한 해법입니다
이 여섯을 다 해 보고도 안 되면 그때 샤딩입니다.

샤딩 후에 생기는 문제들

  • 전역 유일 ID — AUTO_INCREMENT 를 못 씁니다 → UUID v7·ULID·스노플레이크
  • 샤드를 넘는 조인 — 애플리케이션에서 합치거나, 참조 데이터를 모든 샤드에 복제
  • 전역 집계 — 각 샤드에서 부분 집계 후 합산 (또는 별도 분석 DB로 적재)
  • 리밸런싱 — 샤드를 추가할 때 데이터 이동. 일관된 해싱이나 가상 샤드(논리 샤드를 물리 샤드에 매핑)로 완화
  • 운영 복잡도 — 백업·모니터링·스키마 변경을 샤드 수만큼 해야 합니다
미리 대비하는 법 — 샤딩할 가능성이 조금이라도 있다면 처음부터 전역 유일 ID를 쓰고, 샤드 키가 될 컬럼을 모든 주요 테이블에 넣어 두세요. 나중에 추가하는 것보다 훨씬 쉽습니다.
SECTION 10

복제 · 합의 · HA 설계

서버가 죽어도 서비스가 살아 있게 하는 구성입니다. 자동화에는 함정이 많습니다.

복제 방식커밋 시점트레이드오프
비동기주 서버만 기록하면 즉시 빠름 / 장애 시 최근 데이터 유실 가능
반동기복제본이 받았음을 확인 후 절충안. MySQL 의 semi-sync
동기복제본이 기록까지 완료 후 유실 0 / 지연 증가, 복제본 장애 시 쓰기 중단 위험
합의 기반과반수가 기록하면 유실 0 + 자동 페일오버 (Raft·Paxos)

스플릿 브레인 — 자동 페일오버의 함정

네트워크가 순간 끊겨 복제본이 "주 서버가 죽었다"고 오판하고 스스로 승격하면, 실제로는 살아 있던 원래 주 서버와 둘 다 쓰기를 받는 상태가 됩니다. 데이터가 갈라지고, 복구가 사실상 불가능해집니다.
2노드 구성에서 자동 페일오버를 켜지 마세요. 최소 3노드(과반수 판정 가능) 또는 외부 중재자(witness)가 필요합니다.

RPO 와 RTO — 먼저 정해야 하는 두 숫자

지표뜻영향
RPO얼마만큼의 데이터 유실을 감수할 수 있나 0이면 동기 복제 필수 → 지연·비용 증가
RTO얼마나 빨리 복구돼야 하나 분 단위면 자동 페일오버 필요 → 복잡도 증가
이 두 숫자를 정하지 않고 HA 를 논하면 끝없이 겉돕니다. "결제는 RPO 0 · RTO 1분", "로그는 RPO 5분 · RTO 1시간"처럼 데이터 종류별로 다르게 정하는 것이 현실적입니다. 모든 데이터에 최고 수준을 적용하면 비용이 감당되지 않습니다.

관리형 서비스를 쓸 때

AWS RDS Multi-AZ · Aurora · Cloud SQL 등은 페일오버를 서비스가 처리합니다. 다만 페일오버 시 수십 초의 연결 끊김이 발생하므로, 애플리케이션에 재연결·재시도 로직이 반드시 있어야 합니다(🔌 a11). "관리형이니 알아서 되겠지"라고 두면 페일오버 때마다 장애가 됩니다.

복제는 '얼마나 잃어도 되는가'를 정하는 일입니다 Primary 쓰기를 받고 변경 로그를 보냅니다 동기 — 받았다는 답을 기다림 비동기 — 보내고 바로 커밋 잃는 데이터 0 · 대신 커밋이 느려짐 빠름 · 대신 마지막 몇 초가 사라질 수 있음 장애가 나면 — 누가 새 주인이 되는가 Primary 다운 응답 없음 과반이 동의해야 승격 감시자 노드를 홀수로 둡니다 새 Primary 로 승격 · 접속 주소 전환 앱은 VIP · 프록시 · DNS 중 하나로 따라갑니다 과반 없이 각자 판단하면 둘 다 주인이 됩니다(스플릿 브레인) — 양쪽에 다른 데이터가 쌓여 되돌릴 수 없습니다 복제는 백업이 아닙니다 — 실수로 지운 DELETE 도 그대로 복제됩니다. 시점 복구가 되는 백업을 따로 두세요 읽기를 복제본으로 넘길 때는 복제 지연을 감안해야 합니다 — "방금 쓴 것"은 Primary 에서 읽습니다
HA 설계의 핵심 질문은 둘입니다 — 얼마나 잃어도 되나 · 얼마나 멈춰도 되나.
복제는 '얼마나 잃어도 되는가'를 정하는 일입니다 Primary 쓰기를 받고 변경 로그를 보냅니다 동기 — 받았다는 답을 기다림 비동기 — 보내고 바로 커밋 잃는 데이터 0 · 대신 커밋이 느려짐 빠름 · 대신 마지막 몇 초가 사라질 수 있음 장애가 나면 — 누가 새 주인이 되는가 Primary 다운 응답 없음 과반이 동의해야 승격 감시자 노드를 홀수로 둡니다 새 Primary 로 승격 · 접속 주소 전환 앱은 VIP · 프록시 · DNS 중 하나로 따라갑니다 과반 없이 각자 판단하면 둘 다 주인이 됩니다(스플릿 브레인) — 양쪽에 다른 데이터가 쌓여 되돌릴 수 없습니다 복제는 백업이 아닙니다 — 실수로 지운 DELETE 도 그대로 복제됩니다. 시점 복구가 되는 백업을 따로 두세요 읽기를 복제본으로 넘길 때는 복제 지연을 감안해야 합니다 — "방금 쓴 것"은 Primary 에서 읽습니다
HA 설계의 핵심 질문은 둘입니다 — 얼마나 잃어도 되나 · 얼마나 멈춰도 되나.
SECTION 11

분산 트랜잭션 · 2PC · Saga

여러 DB·서비스에 걸친 작업을 한 덩어리처럼 만드는 방법입니다. 요즘은 2PC 보다 Saga + 아웃박스를 씁니다.

2PC — 전원이 준비될 때까지 모두 붙잡아 둔다 코디네이터 ① prepare? 주문 DB 재고 DB 둘 다 락을 쥔 채 대기 ② commit 지시를 기다린다 코디네이터가 이 사이에 죽으면 아무도 못 움직인다 누가 락을 풀어 줄지 아무도 모르는 상태 — in-doubt 한 회사 안 소수의 DB 라면 여전히 쓸 만합니다. 서비스가 늘고 네트워크가 길어질수록 위험해집니다 Saga — 각자 즉시 커밋하고, 틀어지면 되돌린다 주문 생성 재고 차감 결제 실패 보상 트랜잭션이 거꾸로 흐른다 — 재고 복구 → 주문 취소 락을 오래 쥐지 않는다 — 대신 중간 상태가 보인다 "결제 대기" 상태를 업무 규칙으로 설계해야 한다 유실을 막으려면 아웃박스 테이블과 한 트랜잭션으로 되돌릴 수 없는 일(발송 완료·문자 전송)은 애초에 맨 마지막 단계로 미루는 것이 설계의 핵심입니다
분산 트랜잭션의 선택은 "락을 오래 쥘 것인가, 중간 상태를 감당할 것인가"입니다 — 공짜로 원자성을 얻는 방법은 없습니다.

2PC (2단계 커밋)

1단계
준비 (prepare)코디네이터가 모든 참여자에게 "커밋할 수 있나?" 묻고, 참여자는 락을 잡은 채 "가능"이라 답합니다.
▼
2단계
커밋 (commit)전부 가능이면 커밋 지시, 하나라도 불가면 롤백 지시.
2PC 를 실무에서 잘 안 쓰는 이유
① 코디네이터가 죽으면 참여자들이 락을 잡은 채 무한 대기(blocking)
② 준비~커밋 사이에 모든 참여자의 락이 유지되어 처리량이 급락
③ 마이크로서비스 환경에서 서비스 간 강한 결합을 만듭니다

Saga — 보상 트랜잭션

정상 흐름
  ① 주문 생성   → ② 결제 승인   → ③ 재고 차감   → ④ 배송 요청

③에서 실패하면 (재고 부족)
  ← ②' 결제 취소(보상)   ← ①' 주문 취소(보상)

각 단계는 독립된 로컬 트랜잭션이고,
실패하면 앞 단계를 되돌리는 '보상 작업'을 역순으로 실행합니다.
Saga 의 전제 — 모든 단계에 보상 작업이 정의 가능해야 합니다. "이메일 발송"처럼 되돌릴 수 없는 작업은 가장 마지막에 두거나, "취소 안내 메일"이라는 별도 보상으로 설계합니다.

아웃박스 패턴 — 이중 쓰기 문제 해결

✗ DB 저장 + 메시지 발행
with db.transaction():
    save_order(order)
mq.publish("order.created", order)   # ← 여기서 서버가 죽으면?

# DB 에는 주문이 있는데 메시지는 안 나감
# 반대 순서로 하면 메시지는 나갔는데 DB 에 없음
✓ 같은 트랜잭션에 아웃박스 기록
with db.transaction():
    save_order(order)
    insert_outbox("order.created", payload)  # 같은 DB · 같은 트랜잭션

# 별도 워커(또는 CDC)가 outbox 를 읽어 발행하고 완료 표시
# → 원자성 보장. 최소 1회 전달이므로 소비 측은 멱등하게
실무의 현실적 정답 —
① 가능하면 한 트랜잭션 안에 들어가도록 경계를 다시 그리세요(같은 DB로 모으기)
② 안 되면 아웃박스 + 멱등 소비
③ 보상이 명확한 업무 흐름이면 Saga
④ 2PC 는 레거시 연동 등 정말 불가피할 때만
SECTION 12

CAP · 일관성 모델 · NewSQL

분산 시스템에서는 모든 것을 동시에 가질 수 없습니다. 무엇을 포기할지 고르는 것이 설계입니다.

CAP뜻
C 일관성어느 노드에 물어도 같은 최신 값
A 가용성일부가 죽어도 요청에 응답
P 분단 내성네트워크가 갈라져도 동작
흔한 오해 — "셋 중 둘을 고른다"는 표현이 널리 퍼져 있지만, 분산 시스템에서 네트워크 분단(P)은 선택 사항이 아닙니다 — 반드시 일어납니다. 실제 선택은 분단이 일어났을 때 C를 지킬 것인가(응답 거부), A를 지킬 것인가(옛 데이터 응답)입니다.
더 실용적인 틀은 PACELC 입니다 — 분단(P) 시에는 A/C 중, 정상(E) 시에는 지연(L)과 일관성(C) 중 무엇을 택할지.

일관성 모델

모델보장예
선형화모든 연산이 단일 시간축 위에 있는 것처럼etcd · Spanner
순차 일관성모든 노드가 같은 순서로 보지만 실시간성은 보장 안 함—
읽기-쓰기 일관성내가 쓴 것은 내가 반드시 읽음복제본 읽기에서 흔히 요구하는 수준
최종 일관성언젠가는 같아짐DNS · Cassandra 기본
실무에서 가장 자주 필요한 것은 "읽기-쓰기 일관성"입니다. 주문을 넣은 사용자가 자기 주문 목록에서 그것을 봐야 합니다. 구현은 간단합니다 — 쓰기 직후 일정 시간은 주 서버에서 읽기. (세션에 타임스탬프를 남기고 라우팅에 반영)

NewSQL — 분산인데 SQL·트랜잭션

제품특징
Google Spanner원자시계(TrueTime)로 전역 일관성. 클라우드 전용
CockroachDBPostgreSQL 호환 · Raft 기반 · 셀프호스팅 가능
TiDBMySQL 호환 · HTAP(트랜잭션+분석)
YugabyteDBPostgreSQL 호환 · 다중 리전
VitessMySQL 샤딩 미들웨어 (YouTube 에서 출발)
NewSQL 은 만능이 아닙니다. 분산 합의 때문에 단일 노드 대비 쓰기 지연이 큽니다(수 ms → 수십 ms). 운영 복잡도도 훨씬 높습니다. 단일 PostgreSQL 로 충분한 규모라면 그것이 거의 항상 더 나은 선택입니다. "미래를 대비해서"라는 이유로 도입하면 지금의 복잡도만 늘어납니다.
SECTION 13

아카이빙 · 보관 정책

데이터는 계속 쌓입니다. 언제 어디로 치울지를 처음부터 정해야 합니다.

단계내용
핫 (Hot)최근 3~6개월. 운영 DB에 인덱스 완비
웜 (Warm)1~3년. 별도 테이블·파티션 또는 아카이브 DB
콜드 (Cold)3년 이상. S3·Glacier 등 객체 스토리지에 Parquet 등으로
파기법정 보관 기간 경과 후 반드시 삭제 — 개인정보는 보관 자체가 리스크

파티션이 있으면 아카이빙이 쉽습니다

-- PostgreSQL : 파티션을 분리해 별도 테이블로 만든 뒤 옮기고 제거
ALTER TABLE orders DETACH PARTITION orders_2023_01 CONCURRENTLY;
-- COPY orders_2023_01 TO PROGRAM 'aws s3 cp - s3://archive/orders_2023_01.csv' CSV;
DROP TABLE orders_2023_01;

-- Oracle : 파티션 교환 (즉시)
ALTER TABLE orders EXCHANGE PARTITION p_2023_01 WITH TABLE orders_archive_2023_01;

-- MySQL
ALTER TABLE orders DROP PARTITION p2023_01;
DELETE 로 대량 삭제하는 것과 비교 — 1억 행 DELETE 는 수 시간이 걸리고 로그·복제에 큰 부담을 주지만, 파티션 DROP 은 메타데이터 조작이라 즉시 끝납니다. 주기적으로 삭제할 데이터라면 처음부터 파티션으로 설계하세요.

개인정보 보관과 파기

  • 보관 기간을 컬럼으로 관리 — retention_until 을 두고 배치가 자동 처리
  • 파기는 진짜 삭제여야 — 소프트 삭제(deleted_at)는 파기가 아닙니다. 백업본까지 고려한 정책이 필요합니다
  • 가명처리·비식별화 — 통계 목적이라면 식별 정보를 지우고 남기는 방법도 있습니다
  • 삭제 요청 대응 — 사용자가 삭제를 요청했을 때 어느 테이블·백업·로그에 흩어져 있는지 미리 목록을 만들어 두세요
SECTION 14

보안 — 권한 · 암호화 · 감사

DB 유출 사고의 대부분은 정교한 공격이 아니라 권한을 너무 많이 준 것에서 시작됩니다.

① 최소 권한

  • 앱 계정에 DDL 권한 금지 — DROP TABLE 이 안 되면 인젝션이 성공해도 피해가 제한됩니다
  • 용도별 계정 분리 — 읽기 전용(분석) · 읽기쓰기(앱) · 스키마 변경(마이그레이션 전용)
  • 관리자 계정으로 앱을 붙이지 않기 — 사고 시 추적조차 불가능해집니다
  • 정기 권한 검토 — 퇴사자 계정, 임시로 준 권한이 그대로 남아 있는 경우가 매우 흔합니다

② 암호화

계층내용
전송 구간 (TLS)필수. sslmode=require 이상. 사설망이라도 켜세요
저장 암호화 (TDE)디스크·백업 파일이 유출됐을 때를 대비. DB에 접속할 수 있는 공격자에게는 무의미합니다
컬럼 암호화주민번호·카드번호 등. 암호화하면 검색·인덱스가 어려워집니다
해시 (단방향)비밀번호는 암호화가 아니라 해시. bcrypt·argon2 를 쓰고 절대 복호화 가능한 형태로 저장하지 마세요
검색이 필요한 암호화 컬럼은 설계가 까다롭습니다. 같은 값이 항상 같은 암호문이 되게 하면(결정적 암호화) 검색은 되지만 빈도 분석에 취약하고, 매번 다르게 하면 안전하지만 검색이 불가능합니다. 실무에서는 별도의 해시 컬럼(블라인드 인덱스)을 두어 동등 비교만 지원하는 방식을 씁니다.

③ 감사와 탐지

-- PostgreSQL : 접속·DDL 로깅
log_connections = on
log_disconnections = on
log_statement = 'ddl'          -- 'all' 은 부하가 큽니다
-- 세밀한 감사는 pgaudit 확장

-- MySQL : 감사 플러그인 (Percona·MariaDB 는 무료 제공)
-- Oracle : Unified Auditing
CREATE AUDIT POLICY pol_sensitive
  ACTIONS SELECT ON hr.salary, UPDATE ON hr.salary;
AUDIT POLICY pol_sensitive;
탐지해야 할 이상 징후
· 평소와 다른 시간·IP에서의 접속
· 대량 SELECT (평소 100건 조회하던 계정이 100만 건을 읽음)
· 권한 변경·계정 생성
· 스키마 변경 (배포 시간 외)
· 실패한 로그인 반복
개발·테스트 환경에 운영 데이터를 그대로 복사하지 마세요. 실무에서 가장 흔한 개인정보 유출 경로입니다. 마스킹·가명처리한 데이터셋을 만들어 쓰거나, 합성 데이터를 생성하세요.
SECTION 15

장애 대응 시나리오

실제로 일어나는 유형과 대응 순서입니다. 당황하지 않는 것이 절반입니다.

증상확인 순서 → 조치
커넥션 고갈
"too many connections"
① 현재 커넥션 목록에서 idle in transaction 확인 → ② 오래된 세션 종료 → ③ 원인 쿼리·누수 코드 수정 → ④ 풀 설정 재검토
디스크 가득 참 ① 로그 파일부터 정리(WAL·binlog·에러 로그) → ② 임시 테이블스페이스 → ③ 오래된 파티션 DROP → ④ VACUUM FULL 은 추가 공간이 필요하니 주의
CPU 100% ① 현재 실행 중인 쿼리 확인 → ② 가장 무거운 쿼리 종료 → ③ 실행계획 확인(통계·인덱스) → ④ 필요하면 해당 기능 일시 차단
복제 지연 폭증 ① 대량 DML 이 있었는지 확인 → ② 복제본 IO 상태 → ③ 읽기 트래픽을 주 서버로 임시 전환 → ④ 병렬 복제 설정
락으로 전면 정지 ① 블로킹 체인의 맨 위 세션 찾기 → ② 그 세션 종료 → ③ 원인(긴 트랜잭션·DDL) 파악 → ④ 재발 방지
실수로 DELETE ① 즉시 쓰기 중단(추가 변경이 복구를 어렵게 함) → ② Oracle 이면 Flashback → ③ 그 외는 백업 + 로그로 시점 복구 → ④ 복구 전 현재 상태를 먼저 백업
DB 가 안 뜸 ① 로그를 먼저 읽으세요 → ② 디스크·권한 확인 → ③ 복구 중이면 기다림(강제 종료 금지) → ④ 손상이면 백업에서 복원
장애 중에 절대 하지 말아야 할 것
· 여러 조치를 동시에 — 무엇이 효과가 있었는지 알 수 없게 됩니다
· 재시작부터 — 원인이 사라지고 다시 같은 장애가 납니다. 가능하면 상태를 먼저 수집하세요
· 백업 없이 복구 시도 — 현재 상태를 먼저 보존하세요
· 혼자 판단 — 되돌릴 수 없는 조치(파일 삭제·강제 복구)는 반드시 공유 후에
사후에 반드시 할 것 — 포스트모템을 사람이 아니라 시스템 관점으로 씁니다. "왜 이 상황이 가능했는가", "왜 더 일찍 알지 못했는가", "다음에 같은 일이 생기면 자동으로 막히는가". 비난하지 않는 회고(blameless postmortem)여야 진짜 원인이 나옵니다.
SECTION 16

DBA 없이 살아남기

전담 DBA가 없는 팀이 최소한 이것만은 해야 하는 목록입니다.

매일

  • 백업이 실제로 만들어졌는지 확인 (성공 알림이 아니라 파일 크기와 시각)
  • 디스크 사용률·커넥션 수 대시보드 확인
  • 에러 로그에 새로운 유형이 있는지

매주

  • 슬로우 쿼리 상위 10개 확인 — 새로 등장한 것이 있는지
  • 테이블·인덱스 크기 증가 추이
  • 안 쓰이는 인덱스 확인

매월 · 분기

  • 백업에서 실제로 복구해 보기 — 이것을 하지 않으면 백업은 없는 것과 같습니다
  • 권한 검토 — 퇴사자·불필요한 권한 정리
  • DB 마이너 버전 업데이트 (보안 패치)
  • 용량 계획 — 현재 증가율로 언제 한계에 닿는지

한 번은 반드시 해 둘 것

항목내용
복구 절차 문서화새벽 3시에 당직자가 보고 따라할 수 있는 수준으로
RPO · RTO 합의데이터 종류별로 숫자를 정하고 경영진과 공유
슬로우 쿼리 로그 켜기안 켜 두면 사후에 원인을 찾을 수 없습니다
모니터링·알람t15 의 지표를 대시보드로
스키마 마이그레이션 도구손으로 ALTER 치는 순간 재현이 불가능해집니다(🔌 a09)
계정·권한 표준새 서비스가 생길 때 따라 할 템플릿
운영 접속 통제운영 DB에 직접 접속할 수 있는 사람과 경로를 제한
가장 중요한 하나를 고르라면 — 복구 훈련입니다. 성능은 느려도 서비스가 돌아가지만, 데이터를 잃으면 되돌릴 방법이 없습니다. 다른 모든 항목보다 이것을 먼저 하세요.
다음 단계 — 애플리케이션에서 DB를 다루는 실제 코드는 🔌 앱 연동 탭에, 서버 운영·배포는 🌐 서버기술 가이드에 이어집니다. 언어별 대규모 트래픽 대응은 ☕ 자바 · 🐍 파이썬 · 🟨 JS · TS 가이드의 「대규모 트래픽」 탭을 보세요.
PYTHON · JAVA · NODE · ORM · 마이그레이션 · 대량 적재

앱 연동 —
코드에서
DB 다루기

SQL을 아는 것과 애플리케이션에서 안전하게 쓰는 것은 다른 문제입니다. 커넥션·트랜잭션 경계·인젝션 방어·N+1·마이그레이션 — 실무 코드에서 실제로 사고가 나는 지점만 모았습니다. Python · Java · Node.js 코드를 네 DB 기준으로 나란히 정리합니다.

13SECTIONS
3언어
복붙바로 사용
SECTION 00

내 조합 바로 찾기

쓰는 언어와 DB 를 고르면 그 조합의 설치 명령·연결 코드·주의점이 바로 나옵니다. 12가지 조합 전부 복사해서 그대로 쓸 수 있습니다.

언어 × DB 선택
SECTION 01

드라이버와 연결 문자열

드라이버는 앱과 DB 사이의 번역기입니다. 연결 문자열 한 줄에 주소·계정·옵션이 전부 들어갑니다.

DBPythonJava (JDBC)Node.js
Oracleoracledbojdbc11oracledb
PostgreSQLpsycopg[binary]postgresqlpg
MySQLPyMySQL · mysqlclientmysql-connector-jmysql2
SQLitesqlite3 (내장)sqlite-jdbcbetter-sqlite3

연결 문자열 형식

PostgreSQL   postgresql://user:pass@host:5432/dbname?sslmode=require
MySQL        mysql://user:pass@host:3306/dbname?charset=utf8mb4
Oracle       oracle://user:pass@host:1521/?service_name=FREEPDB1
SQLite       sqlite:///./shop.db          (파일 경로)
             sqlite:///:memory:           (메모리)

JDBC
PostgreSQL   jdbc:postgresql://host:5432/dbname?ssl=true
MySQL        jdbc:mysql://host:3306/dbname?useSSL=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
Oracle       jdbc:oracle:thin:@//host:1521/FREEPDB1
SQLite       jdbc:sqlite:shop.db
연결 문자열을 코드에 넣지 마세요. 깃 기록에 영원히 남습니다. 환경 변수나 시크릿 관리 도구를 쓰세요. 실수로 커밋했다면 파일을 지우는 것으로는 부족하고 비밀번호를 반드시 교체해야 합니다.
# 환경 변수로
export DATABASE_URL="postgresql://app:pw@db.internal:5432/shop?sslmode=require"

# 로컬 개발은 .env (반드시 .gitignore 에 추가)
# 운영은 AWS Secrets Manager · Vault · K8s Secret

꼭 넣어야 할 옵션

옵션이유
연결 타임아웃DB가 응답하지 않을 때 무한 대기를 막습니다
쿼리 타임아웃느린 쿼리 하나가 커넥션을 무한정 점유하는 것을 막습니다
SSL/TLS사설망이라도 켜세요. 비용이 거의 없습니다
문자셋 (MySQL)utf8mb4 — 안 넣으면 한글·이모지가 깨질 수 있습니다
타임존서버·DB·앱의 시간대를 명시적으로 맞춥니다
애플리케이션 이름DB 세션 목록에서 어느 앱이 보낸 쿼리인지 보입니다 — 장애 대응에 큰 도움
SECTION 02

Python 연결 4종

네 DB에 붙는 코드를 그대로 복사해 쓸 수 있게 정리했습니다.

PostgreSQL — psycopg 3
# pip install "psycopg[binary]"
import psycopg
from psycopg.rows import dict_row

with psycopg.connect(
        "postgresql://app:pw@localhost/shop",
        connect_timeout=5,
        application_name="shop-api",
        row_factory=dict_row) as conn:
    with conn.cursor() as cur:
        cur.execute(
            "SELECT id, name FROM member WHERE grade = %s",
            ("GOLD",))
        for row in cur:
            print(row["id"], row["name"])
# with 블록이 끝나면 커밋(예외 시 롤백) 후 종료
MySQL — PyMySQL
# pip install PyMySQL
import pymysql

conn = pymysql.connect(
    host="127.0.0.1", user="app", password="pw",
    database="shop", charset="utf8mb4",
    connect_timeout=5,
    cursorclass=pymysql.cursors.DictCursor,
    autocommit=False)
try:
    with conn.cursor() as cur:
        cur.execute(
            "SELECT id, name FROM member WHERE grade = %s",
            ("GOLD",))
        for row in cur.fetchall():
            print(row["id"], row["name"])
    conn.commit()
finally:
    conn.close()
Oracle — oracledb
# pip install oracledb   (Thin 모드 = 클라이언트 설치 불필요)
import oracledb

with oracledb.connect(
        user="app", password="pw",
        dsn="localhost:1521/FREEPDB1") as conn:
    with conn.cursor() as cur:
        cur.execute(
            "SELECT id, name FROM member WHERE grade = :g",
            g="GOLD")                     # ★ 오라클은 :이름 바인딩
        for id_, name in cur:
            print(id_, name)
    conn.commit()                          # ★ 오라클은 자동 커밋이 꺼져 있습니다
SQLite — 표준 라이브러리
import sqlite3

conn = sqlite3.connect("shop.db", timeout=5.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")

with conn:                                 # 자동 커밋 · 예외 시 롤백
    cur = conn.execute(
        "SELECT id, name FROM member WHERE grade = ?",
        ("GOLD",))                         # ★ SQLite 는 ? 바인딩
    for row in cur:
        print(row["id"], row["name"])
conn.close()
바인딩 표기가 드라이버마다 다릅니다. %s(psycopg·PyMySQL) · ?(sqlite3) · :이름(oracledb). psycopg 의 %s 는 파이썬 문자열 포맷이 아닙니다 — % 연산자로 값을 끼워 넣으면 인젝션에 그대로 노출됩니다. 반드시 두 번째 인자로 값을 넘기세요.

SQLAlchemy — 여러 DB를 같은 코드로

from sqlalchemy import create_engine, text

engine = create_engine(
    "postgresql+psycopg://app:pw@localhost/shop",
    pool_size=10, max_overflow=10,
    pool_pre_ping=True,        # ★ 끊긴 커넥션 자동 감지
    pool_recycle=1800,
)

with engine.connect() as conn:
    rows = conn.execute(
        text("SELECT id, name FROM member WHERE grade = :g"),
        {"g": "GOLD"}).mappings().all()
    for r in rows:
        print(r["id"], r["name"])

# pandas 와 함께
import pandas as pd
# ★ 문자열 그대로 넘기면 드라이버 문법(%s)으로 실행되므로 :d 를 쓰려면 text() 로 감쌉니다
df = pd.read_sql(text("SELECT * FROM orders WHERE ordered_at >= :d"),
                 engine, params={"d": "2026-01-01"})
SECTION 03

Java — JDBC · HikariCP

풀 설정이 성능의 절반입니다.

// 순수 JDBC — try-with-resources 로 반드시 닫습니다
String sql = "SELECT id, name FROM member WHERE grade = ?";
try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement(sql)) {
    ps.setString(1, "GOLD");                 // ★ 파라미터 바인딩
    ps.setQueryTimeout(5);                   // ★ 쿼리 타임아웃
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            System.out.println(rs.getLong("id") + " " + rs.getString("name"));
        }
    }
}
# Spring Boot — application.properties
spring.datasource.url=jdbc:postgresql://db:5432/shop
spring.datasource.username=app
spring.datasource.password=${DB_PASSWORD}

spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=20
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.validation-timeout=2000
# 29분 (DB 타임아웃보다 짧게) — .properties 는 줄 끝 주석이 안 되니 윗줄에 씁니다
spring.datasource.hikari.max-lifetime=1740000
spring.datasource.hikari.leak-detection-threshold=20000
spring.datasource.hikari.pool-name=shop-pool
max-lifetime 은 DB 쪽 타임아웃보다 짧아야 합니다. MySQL wait_timeout 이 600초인데 풀의 max-lifetime 이 1800초면, DB가 이미 끊은 커넥션을 풀이 계속 들고 있다가 사용 시점에 Communications link failure 를 냅니다. DB 타임아웃보다 30초 이상 짧게 잡으세요.

배치 처리

String sql = "INSERT INTO member (name, email) VALUES (?, ?)";
try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement(sql)) {
    c.setAutoCommit(false);
    int n = 0;
    for (Member m : members) {
        ps.setString(1, m.getName());
        ps.setString(2, m.getEmail());
        ps.addBatch();
        if (++n % 1000 == 0) ps.executeBatch();   // 1000건마다 전송
    }
    ps.executeBatch();
    c.commit();
}
// ★ MySQL 은 URL 에 rewriteBatchedStatements=true 를 넣어야
//   진짜 다중 INSERT 로 묶입니다 (없으면 배치 효과가 거의 없습니다)
Spring Data JPA 를 쓰더라도 아래는 알고 있어야 합니다 — N+1 문제(a08) · 트랜잭션 전파(a07) · 커넥션 풀 사이징(⚙️ t13). ORM 이 SQL 을 감춰 주지만 DB 는 그대로입니다. JPA 심화는 ☕ 자바 가이드의 데이터 탭에 있습니다.
SECTION 04

Node.js 연결

비동기라 await 를 빠뜨리면 조용히 망가집니다.

// PostgreSQL — pg
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 3_000,
  application_name: 'shop-api',
});

const { rows } = await pool.query(
  'SELECT id, name FROM member WHERE grade = $1', ['GOLD']);   // ★ $1 바인딩
console.log(rows);

// 트랜잭션은 커넥션 하나를 잡고 진행합니다
const client = await pool.connect();
try {
  await client.query('BEGIN');
  const r = await client.query('UPDATE stock SET qty = qty - 1 WHERE id = $1 AND qty >= 1', [10]);
  if (r.rowCount === 0) throw new Error('재고 부족');   // 0행이면 차감 실패 → 롤백
  await client.query('INSERT INTO orders (member_id) VALUES ($1)', [1]);
  await client.query('COMMIT');
} catch (e) {
  await client.query('ROLLBACK');
  throw e;
} finally {
  client.release();        // ★ 반드시 반납 — 빠뜨리면 커넥션 누수
}
// MySQL — mysql2 (프로미스 버전)
import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: '127.0.0.1', user: 'app', password: process.env.DB_PW,
  database: 'shop', charset: 'utf8mb4',
  connectionLimit: 10, waitForConnections: true, queueLimit: 0,
  timezone: '+09:00',
});

const [rows] = await pool.query(
  'SELECT id, name FROM member WHERE grade = ?', ['GOLD']);

// 트랜잭션
const conn = await pool.getConnection();
try {
  await conn.beginTransaction();
  await conn.query('UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty >= 1', [10]);
  await conn.commit();
} catch (e) {
  await conn.rollback();
  throw e;
} finally {
  conn.release();
}
// SQLite — better-sqlite3 (동기 · 가장 빠름)
import Database from 'better-sqlite3';

const db = new Database('shop.db');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');

const stmt = db.prepare('SELECT id, name FROM member WHERE grade = ?');
console.log(stmt.all('GOLD'));

const insertMany = db.transaction((rows) => {     // 트랜잭션으로 묶기
  const ins = db.prepare('INSERT INTO member (name) VALUES (?)');
  for (const r of rows) ins.run(r);
});
insertMany(['김하늘', '박바다']);
Node 에서 가장 흔한 사고 두 가지
① await 누락 — 쿼리가 끝나기 전에 다음 코드가 실행되고, 에러가 unhandled rejection 으로 조용히 사라집니다
② release() 누락 — 예외 경로에서 커넥션을 반납하지 않아 풀이 고갈됩니다. 반드시 finally 에 넣으세요
SECTION 05

SQL 인젝션 — 바인딩

원인은 하나입니다 — 문자열을 이어 붙여 쿼리를 만드는 것. 파라미터 바인딩을 쓰면 원천 차단됩니다.

문자열 이어 붙이기 — 값과 문법이 섞인다 "… WHERE id = '" + 입력 + "'" 입력: ' OR '1'='1 WHERE id = '' OR '1'='1' 파서가 완성된 문장을 받는다 전체 행 유출 DROP 도 가능 DB 입장에서는 정상 쿼리입니다 — 어디까지가 값이었는지 알 방법이 없습니다 파라미터 바인딩 — 구조를 먼저 확정하고 값은 나중에 "… WHERE id = ?" 먼저 파싱 · 계획까지 확정 값 ' OR '1'='1 을 그대로 전달 문법이 아니라 데이터로 들어간다 0 건 그런 id 는 없으니까 테이블·컬럼 이름은 바인딩할 수 없습니다 — 정렬 컬럼처럼 이름을 받아야 할 때는 허용 목록으로만 처리하세요
바인딩이 안전한 이유는 값을 검사해서가 아니라 값이 문법이 될 기회 자체를 없애기 때문입니다.
✗ 문자열 연결 — 즉시 뚫립니다
uid = request.args.get("id")
cur.execute(f"SELECT * FROM member WHERE id = {uid}")

# 공격자가 id 에 이렇게 넣으면
#   1 OR 1=1
#   1; DROP TABLE member; --
#   1 UNION SELECT password FROM admin --
✓ 파라미터 바인딩
cur.execute("SELECT * FROM member WHERE id = %s", (uid,))

# DB 가 쿼리 구조와 값을 분리해 처리하므로
# 값 안에 무엇이 들어와도 '값'으로만 취급됩니다
왜 안전한가 — 바인딩을 쓰면 DB가 쿼리 구조를 먼저 파싱해 확정하고 나중에 값을 채웁니다. 값 안의 ' 나 ; 가 구문으로 해석될 여지 자체가 없습니다. "특수문자를 이스케이프한다"는 접근은 빠뜨리는 경우가 반드시 생기므로 권장되지 않습니다.

바인딩할 수 없는 것 — 테이블·컬럼 이름

# ✗ 이건 바인딩이 안 됩니다 (구조는 파라미터가 될 수 없음)
cur.execute("SELECT * FROM member ORDER BY %s", (sort_col,))    # 에러는 없지만 '문자열 상수'로 정렬 → 정렬 안 됨

# ✓ 화이트리스트로 검증
ALLOWED = {"id", "name", "joined_at"}
if sort_col not in ALLOWED:
    raise ValueError("허용되지 않은 정렬 기준")
cur.execute(f"SELECT * FROM member ORDER BY {sort_col}")        # 검증했으므로 안전

# 정렬 방향도 마찬가지
direction = "DESC" if desc else "ASC"

IN 절에 목록 넣기

# ✗ 문자열 join 은 위험
ids_str = ",".join(ids)
cur.execute(f"SELECT * FROM member WHERE id IN ({ids_str})")

# ✓ 자리표시자를 개수만큼 만들기
ph = ",".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM member WHERE id IN ({ph})", tuple(ids))

# ✓ PostgreSQL 은 배열로 더 깔끔하게
cur.execute("SELECT * FROM member WHERE id = ANY(%s)", (ids,))
ORM 을 써도 안전하지 않은 경우가 있습니다.
· raw() · text() · createQuery() 에 문자열을 조립해 넣는 경우
· 정렬·필터 컬럼명을 사용자 입력에서 그대로 받는 경우
· LIKE 패턴을 조립하는 경우 (%·_ 이스케이프 필요)
"ORM 을 쓰니 안전하다"는 착각이 실제 사고로 이어집니다.
추가 방어선 — ① 앱 계정에 DDL 권한을 주지 않기 (인젝션이 성공해도 DROP 불가) ② 에러 메시지를 사용자에게 노출하지 않기 (스키마 정보가 새어 나갑니다) ③ 쿼리 타임아웃 (시간 기반 블라인드 인젝션을 어렵게) ④ WAF·정적 분석 도구로 문자열 조립 패턴 탐지
SECTION 06

ORM을 쓸 것인가

"ORM 이냐 생쿼리냐"는 잘못된 질문입니다. 어디에 무엇을 쓸지가 옳은 질문입니다.

상황권장
단순 CRUDORM — 반복 코드가 사라집니다
도메인 로직이 있는 저장ORM — 객체 상태 관리가 편합니다
복잡한 조회·통계생쿼리 또는 쿼리 빌더 — ORM 으로 짜면 읽기도 튜닝하기도 어렵습니다
대량 배치생쿼리 · 전용 대량 적재(a10)
리포트·집계생쿼리 — 윈도우 함수·CTE 를 그대로 씁니다
성능이 중요한 API측정 후 결정. 필요한 컬럼만 조회하는 프로젝션이 핵심
언어ORM쿼리 빌더 · 경량
PythonSQLAlchemy ORM · Django ORMSQLAlchemy Core
JavaJPA/HibernateQueryDSL · MyBatis · jOOQ
Node/TSPrisma · TypeORMKysely · Drizzle · knex

ORM 이 만드는 전형적 문제

  • N+1 쿼리 — 지연 로딩이 반복문 안에서 터집니다(a08). 가장 흔한 성능 문제
  • SELECT * — 엔티티 전체를 가져오느라 필요 없는 컬럼·LOB 까지 읽습니다
  • 예상 못 한 UPDATE — 변경 감지(dirty checking)로 의도하지 않은 쿼리가 나갑니다
  • 복잡한 쿼리의 가독성 붕괴 — 메서드 체인 20줄보다 SQL 10줄이 명확한 경우가 많습니다
  • 실행되는 SQL 을 모름 — 로그를 반드시 켜세요
# 개발 환경에서는 반드시 SQL 로그를 켜세요
# Spring Boot
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

# SQLAlchemy
create_engine(url, echo=True)

# Prisma
new PrismaClient({ log: ['query'] })

# Django (settings.py · DEBUG = True 일 때만 SQL 이 찍힙니다)
LOGGING = {'version': 1,
           'handlers': {'console': {'class': 'logging.StreamHandler'}},
           'loggers': {'django.db.backends': {'handlers': ['console'], 'level': 'DEBUG'}}}
실무의 현실적 조합 — 기본은 ORM, 무거운 조회는 생쿼리. 한 프로젝트에서 섞어 쓰는 것이 이상한 일이 아닙니다. 자바에서는 JPA + MyBatis(또는 QueryDSL), 파이썬에서는 SQLAlchemy ORM + Core 조합이 흔합니다.
ORM 을 쓸 것인가 — 둘 다 쓰는 것이 보통입니다 ORM 이 잘하는 것 단건 조회 · 저장 · 수정이 짧습니다 바인딩이 기본이라 인젝션이 막힙니다 타입과 자동완성의 도움을 받습니다 DB 를 바꿀 때 변경이 적습니다 ORM 이 못하는 것 복잡한 집계 · 윈도우 함수 · CTE 어떤 SQL 이 나가는지 안 보입니다 N+1 이 조용히 생깁니다 대량 처리에서 느립니다 실무에서 흔한 답 — 혼용 CRUD 와 단순 조회는 ORM · 통계 화면과 배치는 SQL 을 직접 씁니다 둘 다 같은 커넥션·트랜잭션 안에서 돌아가게만 맞춰 두면 됩니다 무엇을 쓰든 먼저 할 일은 SQL 로깅을 켜는 것입니다 — 안 보면 문제를 발견할 방법이 없습니다 "ORM 을 쓰니 SQL 을 몰라도 된다" 는 반대입니다 — SQL 을 알아야 ORM 을 제대로 씁니다
ORM 은 SQL 을 대체하지 않습니다 — 반복되는 SQL 을 줄여 주는 도구입니다.
SECTION 07

코드에서 트랜잭션 다루기

경계를 어디서 열고 어디서 닫는가가 핵심입니다. 잘못 잡으면 롤백이 안 됩니다.

# Python — 컨텍스트 매니저로 경계를 명확히
from contextlib import contextmanager

@contextmanager
def transaction(conn):
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise                      # ★ 반드시 다시 던지세요 — 삼키면 호출자가 성공으로 오해합니다

with transaction(conn) as tx:
    cur = tx.execute("UPDATE stock SET qty = qty - 1 WHERE id = %s AND qty >= 1", (10,))
    if cur.rowcount == 0:                    # rowcount 는 커서에 있습니다 (psycopg 3 의 conn.execute 가 커서를 돌려줌)
        raise ValueError("재고 부족")        # 예외 → 자동 롤백
    tx.execute("INSERT INTO orders (member_id) VALUES (%s)", (1,))
// Spring — 선언적 트랜잭션
@Service
public class OrderService {

    @Transactional                                    // 서비스 계층에 경계를 둡니다
    public Order create(Long memberId, Long productId) {
        int updated = stockRepository.decrease(productId);
        if (updated == 0) throw new OutOfStockException();   // 런타임 예외 → 롤백
        return orderRepository.save(new Order(memberId));
    }

    @Transactional(readOnly = true)                   // 읽기 전용 힌트 (최적화 · 실수 방지)
    public List<Order> findByMember(Long memberId) { ... }
}
Spring @Transactional 의 3대 함정
① 자기 호출(self-invocation) — 같은 클래스 안에서 this.method() 로 부르면 프록시를 거치지 않아 트랜잭션이 안 걸립니다
② 체크 예외는 기본적으로 롤백하지 않습니다 — @Transactional(rollbackFor = Exception.class) 를 명시하세요
③ 예외를 잡아 삼키면 롤백되지 않습니다 (catch 후 로깅만 하고 끝내는 코드)

경계를 정하는 원칙

  • 서비스(유스케이스) 계층에 — 컨트롤러나 리포지토리가 아니라
  • 짧게 — 외부 API 호출·파일 IO·사용자 입력 대기를 안에 두지 마세요
  • 중첩을 의식적으로 — 전파 속성(REQUIRED·REQUIRES_NEW)이 의도대로인지 확인. REQUIRES_NEW 는 별도 커넥션을 쓰므로 데드락 위험이 있습니다
  • 읽기 전용 표시 — 실수로 쓰는 것을 막고 복제본 라우팅에 활용할 수 있습니다
// Node — 트랜잭션 헬퍼로 반납 누락을 막습니다
async function withTransaction(pool, fn) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

await withTransaction(pool, async (c) => {
  const r = await c.query('UPDATE stock SET qty = qty - 1 WHERE id = $1 AND qty >= 1', [10]);
  if (r.rowCount === 0) throw new Error('재고 부족');   // throw → 헬퍼가 ROLLBACK
  await c.query('INSERT INTO orders (member_id) VALUES ($1)', [1]);
});
트랜잭션은 짧게 — 안에 무엇이 들어 있는지가 전부입니다 ① 나쁜 모양 — 안에 외부 호출이 있습니다 BEGIN 주문 저장 → 결제 API 호출 (3초) → 재고 차감 → 이메일 발송 COMMIT — 그동안 재고 행의 잠금이 3초 넘게 유지됩니다 ② 좋은 모양 — DB 작업만 감쌉니다 결제 API 호출 트랜잭션 밖에서 먼저 BEGIN … COMMIT 주문 저장 + 재고 차감만 (수 ms) 이메일 발송 커밋 뒤 · 큐로 닫는 것을 잊지 않게 언어의 구문에 맡기세요 — 파이썬 with · 자바 try-with-resources · C# using 예외로 빠져나가도 자동으로 롤백됩니다. 수동으로 commit·rollback 을 부르는 코드는 반드시 한 경로를 빠뜨립니다 여러 트랜잭션이 같은 행들을 다른 순서로 잠그면 데드락이 납니다 — 잠그는 순서를 코드에서 통일하세요
"가끔 느려요"의 흔한 원인은 트랜잭션 안에 들어간 네트워크 호출입니다.
트랜잭션은 짧게 — 안에 무엇이 들어 있는지가 전부입니다 ① 나쁜 모양 — 안에 외부 호출이 있습니다 BEGIN 주문 저장 → 결제 API 호출 (3초) → 재고 차감 → 이메일 발송 COMMIT — 그동안 재고 행의 잠금이 3초 넘게 유지됩니다 ② 좋은 모양 — DB 작업만 감쌉니다 결제 API 호출 트랜잭션 밖에서 먼저 BEGIN … COMMIT 주문 저장 + 재고 차감만 (수 ms) 이메일 발송 커밋 뒤 · 큐로 닫는 것을 잊지 않게 언어의 구문에 맡기세요 — 파이썬 with · 자바 try-with-resources · C# using 예외로 빠져나가도 자동으로 롤백됩니다. 수동으로 commit·rollback 을 부르는 코드는 반드시 한 경로를 빠뜨립니다 여러 트랜잭션이 같은 행들을 다른 순서로 잠그면 데드락이 납니다 — 잠그는 순서를 코드에서 통일하세요
"가끔 느려요"의 흔한 원인은 트랜잭션 안에 들어간 네트워크 호출입니다.
SECTION 08

N+1 문제와 배치 조회

목록 100건을 불러오는데 쿼리가 101번 나갑니다. 실무 성능 문제 1위입니다.

N+1 — 코드에는 반복문 하나인데 왕복이 101번 앱 for 문 안의 조회 ① 목록 1회 + ② 행마다 100회 DB 쿼리 자체는 다 빠르다 왕복 1회 2ms × 101 = 약 200ms 느린 건 쿼리가 아니라 왕복 횟수 배치 조회 — 키를 모아 한 번에 가져온다 앱 id 를 리스트로 모음 WHERE id IN (…) 또는 JOIN 한 번 왕복 2회 = 약 4ms 읽는 데이터 양은 그대로인데 50배 빨라진다 같은 데이터 · 같은 쿼리 — 다른 것은 왕복 횟수뿐입니다 왕복 101회 200ms 왕복 2회 4ms — 50배 빠릅니다 0 50 100 150 200 왕복 시간(ms) ORM 을 쓴다면 지연 로딩이 기본입니다 — 목록 화면에서는 join fetch · selectinload 로 미리 당겨 오세요
N+1 은 쿼리 튜닝으로 못 고칩니다. 고칠 대상은 쿼리가 아니라 왕복 횟수입니다.
✗ N+1
orders = db.query("SELECT * FROM orders LIMIT 100")   # 1번

for o in orders:                                     # 100번
    member = db.query(
        "SELECT * FROM member WHERE id = %s", o.member_id)
    print(member.name)

# 총 101번. 각 쿼리가 2ms 면 202ms.
# 네트워크 왕복이 5ms 면 500ms 를 넘습니다
✓ 배치 조회 — 2번
orders = db.query("SELECT * FROM orders LIMIT 100")

ids = list({o.member_id for o in orders})
members = db.query(
    "SELECT * FROM member WHERE id = ANY(%s)", (ids,))
by_id = {m.id: m for m in members}

for o in orders:
    print(by_id[o.member_id].name)

# 총 2번

ORM 에서의 해법

# SQLAlchemy
from sqlalchemy.orm import selectinload, joinedload

# ✗ 지연 로딩 → N+1
orders = session.query(Order).limit(100).all()
for o in orders: print(o.member.name)

# ✓ selectinload : IN 절로 한 번에 (컬렉션에 유리)
orders = session.query(Order).options(selectinload(Order.member)).limit(100).all()

# ✓ joinedload : 조인 한 번으로 (단일 연관에 유리)
orders = session.query(Order).options(joinedload(Order.member)).limit(100).all()
// JPA — 페치 조인
@Query("SELECT o FROM Order o JOIN FETCH o.member WHERE o.status = :s")
List<Order> findWithMember(@Param("s") String status);

// 또는 @EntityGraph
@EntityGraph(attributePaths = {"member", "items"})
List<Order> findByStatus(String status);

// 컬렉션은 batch size 로 (IN 절로 묶어 가져옵니다)
// application.properties:
//   spring.jpa.properties.hibernate.default_batch_fetch_size=100
// Prisma
const orders = await prisma.order.findMany({
  take: 100,
  include: { member: true },        // 연관을 함께 로드
});
페치 조인의 함정 — 컬렉션(1:N)을 조인하면 행이 곱해집니다. 주문 100건에 각 품목 5개면 500행이 되고, 페이징이 의도대로 동작하지 않습니다 (JPA는 메모리에서 페이징하며 경고를 냅니다).
1:N 은 조인이 아니라 batch_fetch_size·selectinload 로 푸세요.
N+1 을 찾는 법 — 개발 환경에서 SQL 로그를 켜고 화면 하나를 열었을 때 나가는 쿼리 수를 세어 보세요. 같은 형태의 쿼리가 수십 번 반복되면 N+1 입니다. 테스트에서 쿼리 수를 검증하는 도구(datasource-proxy·pytest 픽스처)를 붙이면 회귀를 자동으로 막을 수 있습니다.
SECTION 09

스키마 마이그레이션 도구

손으로 ALTER TABLE 을 치는 순간 재현이 불가능해집니다. 스키마도 코드처럼 버전 관리해야 합니다.

언어도구특징
PythonAlembicSQLAlchemy 기반. 모델 변경에서 마이그레이션 자동 생성
PythonDjango migrationsDjango 내장
JavaFlywaySQL 파일 기반. 단순하고 예측 가능 — 실무 선호도가 높습니다
JavaLiquibaseXML·YAML 로 DB 중립 기술. 롤백 지원이 강합니다
NodePrisma Migrate스키마 파일에서 생성
공통Atlas · Sqitch언어 독립
-- Flyway : 파일명 규칙이 곧 버전입니다
-- src/main/resources/db/migration/
--   V1__create_member.sql
--   V2__add_member_phone.sql
--   V3__create_orders.sql

-- V2__add_member_phone.sql
ALTER TABLE member ADD COLUMN phone VARCHAR(20);
CREATE INDEX idx_member_phone ON member (phone);
flyway migrate     # 적용
flyway info        # 적용 상태 확인
flyway validate    # 파일이 변조되지 않았는지 검증

# Alembic
alembic revision --autogenerate -m "add phone"
alembic upgrade head
alembic downgrade -1
적용된 마이그레이션 파일을 수정하지 마세요. 도구는 파일의 체크섬을 저장해 두고 변경을 감지하면 실패시킵니다. 이미 운영에 적용됐다면 새 버전 파일을 추가해 고치는 것이 원칙입니다.

무중단 배포를 위한 확장-축소 패턴

1 · 확장
새 컬럼을 NULL 허용으로 추가 기존 코드는 이 컬럼을 모르지만 아무 문제 없습니다. 배포 순서에 자유가 생깁니다.
▼
2 · 양쪽 쓰기
새 코드가 구·신 컬럼에 모두 씁니다이 시점에 롤백해도 데이터가 깨지지 않습니다.
▼
3 · 백필
기존 행의 새 컬럼을 채웁니다배치로 청크를 나눠 실행합니다(🎓 e13).
▼
4 · 전환
읽기를 새 컬럼으로충분히 검증한 뒤 전환합니다.
▼
5 · 축소
옛 컬럼 제거 · NOT NULL 적용보통 며칠~몇 주 뒤에 별도 배포로 합니다.
운영 DB에서 위험한 DDL
· 컬럼 타입 변경 — 대부분 테이블 재작성 (🐬 m13)
· NOT NULL 추가 — 기존 행 검증 필요. 기본값과 함께 하면 DB에 따라 즉시 완료
· 인덱스 생성 — CONCURRENTLY(PG) · ALGORITHM=INPLACE, LOCK=NONE(MySQL)
· 외래키 추가 — 기존 데이터 전체 검증이 일어납니다
큰 테이블이면 반드시 스테이징에서 시간을 측정하고 진행하세요.
스키마도 코드입니다 — 손으로 ALTER 하지 않습니다 저장소 (git) V1__init.sql V2__add_orders.sql V3__add_index.sql 코드와 같이 리뷰되고 같이 배포됩니다 마이그레이션 도구 Flyway · Liquibase Alembic · EF Core "어디까지 적용했나"를 이력 표에서 확인합니다 데이터베이스 ✓ V1 적용됨 ✓ V2 적용됨 → V3 만 실행합니다 개발·검증·운영 모두 같은 순서 이미 적용된 파일은 고치지 않습니다 체크섬이 달라져 도구가 멈춥니다. 잘못됐다면 되돌리는 새 스크립트(V4)를 추가하는 것이 정석입니다 무중단 배포와 함께 쓰는 법 컬럼 삭제는 한 번에 하지 않습니다 — 먼저 쓰지 않게 배포하고, 다음 배포에서 지웁니다(확장 후 정리)
"운영 DB 에만 없는 컬럼"이 구조적으로 생기지 않게 만드는 장치입니다.
스키마도 코드입니다 — 손으로 ALTER 하지 않습니다 저장소 (git) V1__init.sql V2__add_orders.sql V3__add_index.sql 코드와 같이 리뷰되고 같이 배포됩니다 마이그레이션 도구 Flyway · Liquibase Alembic · EF Core "어디까지 적용했나"를 이력 표에서 확인합니다 데이터베이스 ✓ V1 적용됨 ✓ V2 적용됨 → V3 만 실행합니다 개발·검증·운영 모두 같은 순서 이미 적용된 파일은 고치지 않습니다 체크섬이 달라져 도구가 멈춥니다. 잘못됐다면 되돌리는 새 스크립트(V4)를 추가하는 것이 정석입니다 무중단 배포와 함께 쓰는 법 컬럼 삭제는 한 번에 하지 않습니다 — 먼저 쓰지 않게 배포하고, 다음 배포에서 지웁니다(확장 후 정리)
"운영 DB 에만 없는 컬럼"이 구조적으로 생기지 않게 만드는 장치입니다.
SECTION 10

대량 적재 100배 빠르게

한 건씩 INSERT 하면 10만 건에 몇 분, 전용 방식을 쓰면 몇 초입니다.

방법10만 건 기준 대략적 소요
건별 INSERT + 건별 커밋수 분 (fsync 10만 번)
건별 INSERT + 일괄 커밋수십 초
다중 행 INSERT (1000건씩)수 초
전용 대량 적재 (COPY·LOAD DATA)1초 내외
PostgreSQL — COPY
import psycopg

with psycopg.connect(dsn) as conn, conn.cursor() as cur:
    with cur.copy(
        "COPY member (name, email) FROM STDIN"
    ) as copy:
        for name, email in rows:
            copy.write_row((name, email))
    conn.commit()

# CSV 파일에서 직접
# \copy member(name,email) FROM 'data.csv' CSV HEADER
MySQL — LOAD DATA
LOAD DATA LOCAL INFILE '/tmp/member.csv'
INTO TABLE member
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 ROWS
(name, email);

-- 서버·클라이언트 양쪽에서 local_infile 을 켜야 합니다
Oracle — 배열 바인딩
cur.executemany(
    "INSERT INTO member (name, email) VALUES (:1, :2)",
    rows, batcherrors=True)      # 일부 실패해도 계속
conn.commit()

# 더 큰 규모는 SQL*Loader 또는
# INSERT /*+ APPEND */ ... SELECT
SQLite — executemany + 트랜잭션
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")

with conn:
    conn.executemany(
        "INSERT INTO member (name, email) VALUES (?, ?)",
        rows)
# 트랜잭션으로 묶는 것만으로 수백 배 빨라집니다

더 빠르게 — 적재 전후 처리

  • 인덱스를 잠시 제거 — 적재 후 다시 만드는 편이 빠른 경우가 많습니다 (다만 그동안 조회 성능이 떨어집니다)
  • 외래키·제약 검사 지연 — PostgreSQL SET CONSTRAINTS ALL DEFERRED(DEFERRABLE 로 선언된 제약에만 적용), MySQL SET FOREIGN_KEY_CHECKS=0 (적재 후 반드시 되돌리고 정합성 검증)
  • 적재 후 통계 갱신 — ANALYZE 를 빠뜨리면 이후 쿼리 계획이 엉망이 됩니다
  • 스테이징 테이블 활용 — 임시 테이블에 적재 후 검증하고 본 테이블로 옮깁니다
대량 적재는 복제 지연을 만듭니다. 수백만 건을 한 번에 넣으면 복제본이 그것을 따라 하느라 수 분간 밀립니다. 운영 시간에는 청크로 나누고 사이에 쉬는 시간을 두세요(🎓 e13).
10만 행 넣기 — 방법에 따라 100배까지 갈립니다 한 건씩 INSERT + 매번 커밋 왕복 10만 번 · 커밋마다 로그를 디스크에 내려씁니다 가장 느립니다 — 수십 분이 걸리기도 합니다 여러 건을 한 문장으로 + 트랜잭션으로 묶기 INSERT ... VALUES (..),(..),(..) 를 1000건 단위로 · 전체를 트랜잭션으로 감쌉니다 여기까지만 해도 대개 10배 이상 빨라집니다 — 가장 손쉬운 개선입니다 전용 대량 적재 명령 PostgreSQL COPY · MySQL LOAD DATA · Oracle SQL*Loader · SQLite .import 파서를 거의 거치지 않습니다 — 가장 빠릅니다 아주 큰 적재라면 인덱스를 잠시 떼었다가 끝난 뒤 다시 만드는 방법도 있습니다 — 그동안 조회는 느려집니다 중간에 실패했을 때 어디서부터 다시 할지 미리 정해 두세요 — 임시 표에 넣고 한 번에 옮기는 방식이 안전합니다
적재가 느리다면 DB 를 의심하기 전에 커밋을 몇 번 하고 있는지부터 세어 보세요.
SECTION 11

재시도 · 타임아웃 · 누수

네트워크는 반드시 끊깁니다. 페일오버도 일어납니다. 이를 가정하지 않은 코드는 장애 때 서비스 전체를 멈춥니다.

타임아웃이 없을 때 — 장애가 서비스 전체로 번진다 DB 페일오버 5초 평소에도 일어나는 일 응답을 무한정 기다림 커넥션을 쥔 채 풀이 바닥난다 새 요청도 대기열로 전면 장애 DB 를 쓰지 않는 화면까지 같이 죽습니다 — 스레드와 커넥션이 남아 있지 않으니까 갖춰야 할 세 가지 — 순서대로 겹쳐 쌓입니다 ① 타임아웃 연결 · 쿼리 · 풀 대기 세 군데 모두 ② 재시도 지수 백오프 + 지터 읽기·멱등 작업에만 ③ 반드시 반환 with · try-finally 예외가 나도 풀로 돌려준다
모든 재시도가 동시에 몰리면 그것이 2차 장애입니다 — 백오프에 지터(무작위 흔들기)를 반드시 넣으세요.

재시도해도 되는 오류와 안 되는 오류

판단오류
재시도 O 연결 실패·타임아웃 · 데드락 · 직렬화 충돌(SERIALIZABLE) · 페일오버 중 일시 오류 · 락 대기 타임아웃
재시도 X 문법 오류 · 제약조건 위반 · 권한 없음 · 데이터 오류 — 다시 해도 같은 결과이므로 재시도는 낭비입니다
import time, random
import psycopg

RETRYABLE = ("40001", "40P01", "08000", "08003", "08006")   # 직렬화·데드락·연결 오류

def with_retry(fn, attempts=3, base=0.1):
    for i in range(attempts):
        try:
            return fn()
        except psycopg.Error as e:
            code = getattr(e, "sqlstate", None)
            if code not in RETRYABLE or i == attempts - 1:
                raise
            # 지수 백오프 + 지터 (동시에 몰려 재시도하는 것을 막습니다)
            time.sleep(base * (2 ** i) + random.uniform(0, base))
    raise RuntimeError("unreachable")
재시도는 멱등할 때만 안전합니다. INSERT 를 재시도하면 중복 데이터가 생길 수 있습니다.
· 멱등 키를 두고 UNIQUE 제약으로 중복을 막거나
· ON CONFLICT DO NOTHING 으로 처리하거나
· 조회 → 없으면 삽입이 아니라 UPSERT 로 (🎓 e08)

타임아웃 — 계층마다 설정

-- DB 쪽에서 강제하는 것이 가장 확실합니다
-- PostgreSQL
SET statement_timeout = '5s';                    -- 쿼리 최대 실행 시간
SET lock_timeout = '3s';                         -- 락 대기 최대
SET idle_in_transaction_session_timeout = '30s'; -- ★ 방치된 트랜잭션 자동 종료

-- MySQL
SET SESSION max_execution_time = 5000;           -- ms (SELECT 만)
SET SESSION innodb_lock_wait_timeout = 5;

-- Oracle
-- 프로파일의 CPU_PER_CALL 또는 Resource Manager 로 제어
idle_in_transaction_session_timeout 은 PostgreSQL 운영의 필수 설정입니다. 애플리케이션 버그로 커밋하지 않은 세션이 남으면 VACUUM 이 막히고 락이 유지됩니다. 이 설정 하나가 큰 사고를 예방합니다.

커넥션 누수 잡기

-- PostgreSQL : 오래 idle in transaction 인 세션
SELECT pid, usename, application_name, state,
       now() - state_change AS 지속, query
FROM   pg_stat_activity
WHERE  state = 'idle in transaction' AND now() - state_change > interval '1 min';

-- MySQL
SELECT * FROM information_schema.processlist WHERE command = 'Sleep' AND time > 60;

-- 애플리케이션 이름별 커넥션 수 (어느 앱이 새고 있나)
SELECT application_name, count(*) FROM pg_stat_activity GROUP BY 1 ORDER BY 2 DESC;
누수를 코드에서 막는 법 — Python with · Java try-with-resources · Node finally { client.release() }. HikariCP 의 leak-detection-threshold 를 켜 두면 설정 시간 이상 반납되지 않은 커넥션의 스택 트레이스를 로그로 남겨 줍니다.
SECTION 12

테스트에서 DB 다루기

"테스트는 SQLite 로, 운영은 PostgreSQL 로"는 운영에서만 터지는 버그를 만듭니다.

왜 DB를 바꿔 테스트하면 안 되나
· 타입·제약 동작이 다릅니다 (SQLite 는 타입을 강제하지 않습니다)
· 방언 SQL 이 테스트를 통과하지 못하거나, 반대로 운영에서만 실패합니다
· 격리 수준·락 동작이 완전히 다릅니다
· 함수·인덱스 종류가 다릅니다
결과적으로 초록불인데 배포하면 깨지는 상황이 반복됩니다.

권장 — Testcontainers 로 진짜 DB를

# pip install testcontainers[postgres] pytest
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine, text

@pytest.fixture(scope="session")
def engine():
    with PostgresContainer("postgres:18") as pg:      # 컨테이너 자동 기동·정리
        eng = create_engine(pg.get_connection_url())
        with eng.begin() as c:
            c.execute(text(open("schema.sql").read()))
        yield eng

@pytest.fixture
def conn(engine):
    """테스트마다 트랜잭션을 열고 끝나면 롤백 → 완전 격리"""
    connection = engine.connect()
    tx = connection.begin()
    yield connection
    tx.rollback()
    connection.close()

def test_insert_member(conn):
    conn.execute(text("INSERT INTO member (id, name) VALUES (1, '테스트')"))
    n = conn.execute(text("SELECT count(*) FROM member")).scalar()
    assert n == 1
    # 롤백되므로 다음 테스트에 영향이 없습니다
// Java — Testcontainers + Spring Boot
@SpringBootTest
@Testcontainers
class OrderServiceTest {

    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:18");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", db::getJdbcUrl);
        r.add("spring.datasource.username", db::getUsername);
        r.add("spring.datasource.password", db::getPassword);
    }

    @Test
    @Transactional            // 테스트가 끝나면 자동 롤백
    void 재고가_부족하면_주문이_실패한다() { ... }
}

테스트 데이터 격리 전략

전략장단점
트랜잭션 롤백 가장 빠르고 깔끔합니다. 단, 테스트 대상이 스스로 커밋하면 쓸 수 없습니다
매번 TRUNCATE 확실하지만 느립니다. 외래키 순서를 고려해야 합니다
테스트마다 새 스키마 완전 격리 · 병렬 실행 가능. 준비 비용이 있습니다
컨테이너 재기동 가장 느립니다. 스위트 단위로만
CI 에서 느리다면 — ① 컨테이너를 세션 스코프로 한 번만 띄우고 ② 테스트마다 트랜잭션 롤백으로 격리하고 ③ tmpfs 에 데이터 디렉터리를 두어 디스크 IO를 없애고 ④ fsync=off 같은 테스트 전용 설정을 씁니다(운영에는 절대 금지).
무엇을 테스트할 것인가 — ORM 이 SELECT 를 잘 만드는지는 테스트할 필요가 없습니다(라이브러리를 믿으세요). 우리가 짠 제약·트랜잭션 경계·동시성 처리를 테스트하세요. 특히 재고 차감·중복 방지·롤백 같은 정합성 로직은 반드시 테스트 대상입니다.
Docker · PostgreSQL 18 · MySQL 8.4 LTS · SQLite 3.5x · DBeaver · 7개 언어 드라이버 — 2026 기준

빈 PC 한 대를
DB 가 도는 개발 환경으로
한 번에 만듭니다

DB 를 처음 시작하는 사람이 막히는 곳은 문법이 아니라 설치와 접속입니다. "무엇을 깔아야 하나", "받은 파일을 어디까지 눌러야 하나", "깔았는데 왜 접속이 안 되나", "내 파이썬 코드에서는 무슨 패키지를 설치하나" — 이 탭 하나에 그 전부를 모았습니다. DB 고르기 → 도커 설치 → DB 한 줄로 띄우기 → GUI 도구 연결 → 언어별 드라이버 → 환경 변수 → 문제 해결까지 순서대로 따라가면 됩니다. 각 DB 의 세세한 설치 옵션은 이미 전용 탭에 있으니 여기서는 전체 지도를 그리고 자세한 곳으로 링크를 겁니다. 이 탭을 마치면 새 노트북을 받아도 한 시간 안에 DB 붙는 프로젝트를 시작할 수 있습니다.

12SECTIONS
7DB 제품
7언어 드라이버
1시간목표 시간
SETUP 01

어떤 DB 를 깔 것인가 — 7개 제품 한 장 비교

설치 첫 단추는 "무엇을 깔까"입니다. 그런데 이건 취향 문제가 아니라 목적 문제입니다. 혼자 공부하는 사람과 사내 시스템을 만드는 사람의 정답이 다릅니다. 이 섹션에서 5분 안에 정하고, 나머지 열한 섹션은 정한 것을 깔고 붙이는 데 씁니다.

무엇을 깔까 — 목적 하나로 갈립니다 이 DB 를 어디에 쓰나? 혼자 쓰는 앱 SQL 학습 · 시험 공부 모바일 · 데스크톱 내장 웹 · 업무 시스템 회원 · 주문 · 정산 새로 만드는 서비스 회사에 이미 있음 기간계 · 금융 · 공공 고를 여지가 없음 본체 옆에 덧붙임 캐시 · 세션 · 순위표 모양이 제각각인 문서 SQLite 설치 자체가 없음 PostgreSQL 솔루션이면 MySQL Oracle · SQL Server 도커 이미지로 연습 Redis · MongoDB 단독으로 쓰지 않음 고민이 5분을 넘으면 — 도커로 PostgreSQL 하나만 띄우세요 SELECT · JOIN · 인덱스 · 트랜잭션은 어디서나 같습니다. 나중에 옮겨도 배운 것의 90%가 그대로 쓰입니다
"어느 DB 가 더 좋은가"는 답이 없습니다 — 어디에 쓰는가만 정하면 제품은 저절로 정해집니다.

7개 제품 비교표 — 용도 · 라이선스 · 설치 난이도

제품한 줄 정체라이선스 · 비용 설치 난이도기본 포트2026 기준 버전
🐘 PostgreSQL표준을 가장 잘 지키는 만능 관계형 완전 무료 · PostgreSQL 라이선스(BSD 계열). 상업 이용·재배포 전부 자유 🟢 쉬움543218 (2025-09)
🐬 MySQL웹 서비스에서 가장 흔한 관계형 Community 판 GPL 무료 · 상용 기능은 Enterprise 유료 🟢 쉬움33068.4 LTS · 9.x
🐬 MariaDBMySQL 에서 갈라진 완전 오픈 판 전부 GPL 무료 · 유료판 구분 없음 🟢 쉬움330611.8 LTS 이상
✒️ SQLite파일 하나가 곧 DB · 서버가 없음 퍼블릭 도메인 — 저작권 자체가 없어 어디에 넣어도 됩니다 ⬜ 설치 없음없음3.5x
🟦 SQL Server마이크로소프트 · .NET 생태계 기본 Developer 판 개발용 무료 · Express 무료(10GB 제한) · 운영은 유료 🟡 보통14332022 · 2025
🅾️ Oracle금융·통신 기간계의 절대 강자 유료 · Free(옛 XE)는 CPU 2개·데이터 12GB 제한으로 무료 🔴 어려움152123ai Free
🍃 MongoDBJSON 문서를 그대로 넣는 문서형 Community 무료(SSPL) · DB 자체를 서비스로 팔 때만 제약 🟢 쉬움270178.x
⚡ Redis메모리에 사는 초고속 키-값 저장소 Redis 8 부터 AGPL/RSAL 이중 · 완전 오픈은 Valkey(리눅스 재단) 🟢 아주 쉬움63798.x · Valkey 9
라이선스가 걱정될 때 딱 세 가지만 기억하세요. ① PostgreSQL · SQLite 는 아무 데나 써도 문제가 없습니다 — 사내 시스템에 넣고 팔아도 됩니다. ② MySQL Community · MongoDB · Redis 는 "내 서비스가 그 DB 를 쓰는" 정도는 전부 무료이고, DB 자체를 상품으로 재판매할 때만 조건이 붙습니다. ③ Oracle · SQL Server 는 운영에 올리는 순간 돈 이야기가 나옵니다 — 개발·학습은 무료 판으로 충분합니다.

"그래서 뭘 깔아요?" — 2026년의 솔직한 기본값

상황별 추천 — 이대로 따라 해도 됩니다

상황깔 것
SQL 문법을 오늘 당장 배우고 싶다SQLite — 설치 0분 (s02)
포트폴리오 · 토이 프로젝트PostgreSQL 도커 (st03)
회사에서 새 서비스를 시작PostgreSQL · 사내 표준이 있으면 그것
워드프레스 · 그누보드 · 커머스 솔루션MySQL / MariaDB (사실상 강제)
.NET · C# 위주 팀SQL Server Developer 판
SI · 금융 취업 준비Oracle Free 도커 한 번은 (o02)
로그인 세션 · 조회수 캐시가 필요관계형 + Redis (r03)
두 개 이상 깔아도 됩니다 — 포트가 다르면 서로 방해하지 않습니다. 도커를 쓰면 PostgreSQL · MySQL · Redis 를 동시에 띄워 두고 프로젝트마다 골라 쓰는 게 오히려 흔한 구성입니다(st11).

"제대로 골랐나" 30초 확인

# 도커만 있으면 어떤 DB든 이 정도가 전부입니다.
# 한 줄씩 붙여 보고 마음에 드는 것을 남기세요 (자세히는 st03).

docker run -d --name pg   -e POSTGRES_PASSWORD=dev \
  -p 5432:5432 postgres:18

docker run -d --name my   -e MYSQL_ROOT_PASSWORD=dev \
  -p 3306:3306 mysql:8.4

docker run -d --name rd   -p 6379:6379 redis:8

# 뭐가 떠 있는지 확인
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"

# 마음에 안 들면 흔적 없이 삭제 — 네이티브 설치와 가장 다른 점입니다
docker rm -f my rd
도커의 진짜 장점은 속도가 아니라 "지우기"입니다. 네이티브로 깐 MySQL 은 서비스·레지스트리·데이터 폴더가 남아 다시 깔 때 말썽을 부리지만 (st12 의 대표 사고), 컨테이너는 docker rm -f 한 번이면 없던 일이 됩니다.

한 단계 더 — "관계형 하나 + 보조 하나"가 표준 구성 🟡 중급

고르는 게 아니라 겹쳐 쓰는 것들

초보자가 자주 하는 질문이 "PostgreSQL 이 좋아요, MongoDB 가 좋아요?"인데 둘은 경쟁 관계가 아닙니다. 실무의 표준 구성은 이렇습니다.

자리무엇을 놓나
본체관계형 하나 — 회원·주문·결제처럼 틀리면 안 되는 데이터
캐시Redis — 자주 읽지만 조금 늦어도 되는 값, 로그인 세션
로그·문서MongoDB 또는 관계형의 JSON 타입 — 모양이 자주 바뀌는 것
파일DB 가 아니라 스토리지(S3 등) — 이미지를 DB 에 넣지 않습니다
처음부터 다 깔지 마세요. 관계형 하나로 시작해서 "이 쿼리가 너무 자주 돈다"가 눈에 보일 때 Redis 를 붙이는 순서가 맞습니다. PostgreSQL 은 JSONB(p07)로 문서형 흉내도 상당 부분 냅니다.

설치 난이도를 결정하는 진짜 변수

위 표의 "난이도"는 프로그램 자체의 복잡도가 아니라 설치 후에 해야 할 일의 개수입니다.

DB설치 후에 더 해야 하는 일
SQLite없음. 파일 경로만 정하면 끝
PostgreSQL비밀번호 · pg_hba.conf 한 줄 정도
MySQLroot 비번 · utf8mb4 문자셋(m04) · 타임존
SQL Server인증 모드(혼합) · TCP/IP 프로토콜 수동 활성화
Oracle리스너 · 서비스명 · CDB/PDB 개념(o04) · 용량 10GB+
도커가 난이도를 평평하게 만듭니다 — 이미지 제작자가 위의 "설치 후 할 일"을 환경 변수 몇 개로 미리 처리해 두었기 때문입니다. 오라클조차 docker run 한 줄이면 뜹니다(st03).
한 가지만 경고합니다 — "운영 DB 를 내 PC 에 깐다"는 생각은 하지 마세요. 이 탭에서 만드는 것은 전부 개발용 로컬 환경입니다. 실제 서비스용 DB 는 백업·이중화·모니터링·보안이 붙어야 하고(z15, z14), 요즘은 대부분 클라우드 관리형(RDS · Cloud SQL · Azure Database)을 씁니다. 로컬은 마음껏 부수고 다시 만드는 실험실로 쓰는 게 맞습니다.
SETUP 02

Docker Desktop 설치 — DB 설치의 난이도를 평평하게

DB 설치가 어려운 이유는 제품마다 마법사가 다르고, 깔고 나면 지우기도 어렵기 때문입니다. 도커는 그 전부를 명령 한 줄로 바꿉니다. DB 하나를 배우려고 도커를 배우는 게 손해 같지만, 도커를 30분 배워 두면 이 가이드의 모든 DB 를 3분 만에 띄울 수 있습니다. 정말 남는 장사입니다.

같은 PostgreSQL 을 두 가지 방법으로 — 무엇이 다른가 ① 네이티브 설치 — 내 OS 에 직접 ② 도커 — 컨테이너로 설치 파일 내려받기 · 마법사 10단계 서비스로 등록 · 포트를 계속 점유 버전 바꾸려면 제거 후 재설치 지워도 데이터 폴더 · 설정이 남음 docker run 한 줄 — 이미지 자동 수신 -p 로 노출할 포트를 내가 고름 태그만 바꾸면 18 · 17 동시 사용 docker rm -f 로 흔적 없이 제거 배우는 SQL 은 두 방식에서 완전히 같습니다 — 다른 것은 "띄우고 지우는 절차"뿐입니다
도커의 가치는 빠르기가 아니라 되돌릴 수 있음입니다 — 망쳐도 30초면 새것으로 시작합니다.

Windows — WSL2 부터 켜야 합니다

윈도우에서 도커가 도는 층 — 아래 두 층이 꺼져 있으면 설치가 실패합니다 postgres:18 컨테이너 mysql:8.4 컨테이너 redis:8 컨테이너 Docker Engine — 이미지 내려받기 · 컨테이너 실행 · 포트 연결 Docker Desktop 을 설치하면 이 엔진과 GUI, docker 명령이 한꺼번에 들어옵니다 WSL2 — 윈도우 안에서 도는 가벼운 리눅스 커널 wsl --install 한 번이면 켜집니다 · 도커는 이 리눅스 위에서 컨테이너를 돌립니다 가상 머신 플랫폼 · Linux용 Windows 하위 시스템 (Windows 기능) CPU 가상화 (VT-x · AMD-V) — BIOS/UEFI 에서 켜져 있어야 합니다
"Docker Desktop 이 시작되지 않습니다" 의 90%는 맨 아래 두 층 문제입니다 — st12 참고.

① 준비 — WSL2 켜기 (관리자 PowerShell)

# 관리자 권한 PowerShell 을 열고 (시작 → powershell → 오른쪽 클릭 → 관리자로 실행)

wsl --install
# → "가상 머신 플랫폼", "Linux용 Windows 하위 시스템" 기능을 켜고
#    Ubuntu 를 함께 설치합니다. 끝나면 재부팅.

# 이미 WSL 이 있다면 버전만 확인
wsl --status
wsl --set-default-version 2
wsl -l -v          # VERSION 열이 2 여야 합니다

# 커널이 오래됐다는 경고가 나오면
wsl --update
가상화가 꺼져 있으면 여기서 막힙니다. 작업 관리자 → 성능 → CPU 에서 "가상화: 사용"인지 보세요. "사용 안 함"이면 재부팅 → BIOS/UEFI 진입(보통 F2 · Del) → Intel 은 VT-x / Intel Virtualization Technology, AMD 는 SVM Mode 를 Enabled 로. 노트북 제조사에 따라 Advanced · Security · Configuration 탭 안에 숨어 있습니다.

② Docker Desktop 내려받아 설치

단계할 일
내려받기docker.com → Products → Docker Desktop → Windows(AMD64 / ARM64)
실행Docker Desktop Installer.exe 더블클릭
체크 ★Use WSL 2 instead of Hyper-V — 반드시 켠 채로
체크Add shortcut to desktop (취향)
완료재부팅 · 로그아웃 요구 → 그대로 따릅니다
첫 실행약관 동의 → 계정 로그인은 Skip 가능
확인작업 표시줄 고래 아이콘이 흔들리지 않고 멈추면 준비 완료
설치 위치를 바꾸고 싶다면 — Docker Desktop 은 C 드라이브에 붙지만 이미지·볼륨이 쌓이는 실제 데이터는 Settings → Resources → Advanced → Disk image location 에서 다른 드라이브로 옮길 수 있습니다. DB 이미지는 하나에 수백 MB~수 GB 입니다.

③ 설치 확인 — 이 두 줄이면 끝

모든 OS 공통 검증

docker --version
# Docker version 27.x.x, build ...

docker compose version
# Docker Compose version v2.x.x   ← 하이픈 없는 "docker compose" 가 요즘 방식

docker run --rm hello-world
# Unable to find image 'hello-world:latest' locally
# latest: Pulling from library/hello-world
# ...
# Hello from Docker!            ← 이 줄이 나오면 성공입니다

docker info | grep -i "operating system"
# WSL 을 쓰면 리눅스로 보입니다 — 정상입니다
--rm 이 뭔가요 — 끝나면 컨테이너를 자동으로 지우라는 뜻입니다. 테스트용 실행에는 항상 붙이세요. 안 붙이면 종료된 컨테이너가 계속 쌓입니다 (docker ps -a 로 보입니다).

macOS · Linux 설치

# --- macOS (Apple Silicon · Intel 공통) ---
brew install --cask docker      # 또는 docker.com 에서 .dmg
open -a Docker                  # 첫 실행 (권한 승인 창이 뜹니다)

# --- Ubuntu / Debian: Docker Engine 만 (Desktop 불필요) ---
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER   # sudo 없이 쓰려면
newgrp docker                   # 또는 로그아웃 후 재로그인
docker run --rm hello-world

# --- Fedora / RHEL 계열 ---
sudo dnf install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
리눅스에는 Docker Desktop 이 필요 없습니다. 엔진만 깔면 되고 오히려 더 가볍습니다. Desktop 은 GUI 와 맥·윈도우에서 리눅스 커널을 흉내 내는 층이 핵심이기 때문입니다.

회사에서 쓸 때 — 라이선스를 먼저 확인하세요 🟡 중급

도구비용 조건추천 상황
Docker Desktop 개인 · 교육 · 오픈소스 · 소규모 기업 무료. 직원 250명 초과이거나 연 매출 1천만 달러 초과면 유료 구독 필요 개인 학습 · 스타트업
Rancher Desktop 완전 무료 (Apache 2.0) · SUSE. Docker 호환 명령(nerdctl·moby) 제공 대기업에서 Desktop 대신
Podman Desktop 완전 무료 (Apache 2.0) · Red Hat. 데몬이 없고 루트 없이 돕니다 보안 심사가 까다로운 곳
WSL2 + Docker Engine 무료 — Desktop 없이 WSL 안 우분투에 엔진만 직접 설치 윈도우에서 라이선스 회피
Colima (macOS) 무료 · brew install colima 후 colima start 맥에서 가볍게
"우리 회사는 Docker Desktop 을 쓰면 안 된대요" — 흔한 상황입니다. 이때 대안 도구의 명령은 거의 같습니다. Podman 은 alias docker=podman 만으로 대부분 동작하고, Rancher Desktop 은 docker 명령을 그대로 제공합니다. 이 탭의 모든 docker run·compose.yml 은 세 도구에서 그대로 돕니다.
도커가 도저히 안 될 때 — 회사 PC 정책·구형 CPU·가상화 불가 상황이면 네이티브 설치로 가면 됩니다(st04). SQLite 는 애초에 설치가 필요 없고(s02), PostgreSQL·MySQL 네이티브 설치도 마법사만 잘 넘기면 15분이면 끝납니다. 도커는 편의이지 필수가 아닙니다.
SETUP 03

도커로 DB 띄우기 — 한 줄 명령과 compose 완전판

여기가 이 탭의 핵심입니다. 아래 명령을 그대로 복사해 붙이면 DB 가 뜹니다. 다만 한 줄 명령은 컨테이너를 지우면 데이터도 사라진다는 함정이 있어서, 실제로는 볼륨을 붙이고 compose.yml 파일로 관리합니다. 둘 다 보여 드립니다.

도커로 DB 를 띄웠을 때 — 포트는 어떻게 이어지고 데이터는 어디에 남나 내 PC 의 도구·코드 DBeaver · psql · psycopg localhost:5432 로 접속 -p 5432:5432 컨테이너 pg (이미지 postgres:18) 포트 5432 열림 컨테이너 안쪽 주소 postgres 프로세스 SQL 을 처리 /var/lib/postgresql (18+) — 실제 파일이 쌓이는 자리 여기를 바깥과 연결하지 않으면 컨테이너와 운명을 같이합니다 -v 볼륨 연결 도커 볼륨 pgdata — 컨테이너 바깥에 남는 저장소 docker rm -f pg 로 컨테이너를 지워도 이 안의 데이터는 그대로입니다 볼륨을 안 붙이면 컨테이너 삭제 = 데이터 삭제 기억할 두 줄 — 포트는 -p 로 잇고, 데이터는 -v 로 바깥에 남깁니다
초보자가 가장 많이 겪는 사고는 볼륨 없이 띄운 DB 를 지우고 데이터를 날리는 것입니다.

DB 별 한 줄 실행 — 복사해서 쓰세요

관계형 4종

# 🐘 PostgreSQL — 이 탭의 기본값
docker run -d --name pg -p 5432:5432 \
  -e POSTGRES_PASSWORD=devpass \
  -e POSTGRES_USER=app -e POSTGRES_DB=appdb \
  -v pgdata:/var/lib/postgresql \
  postgres:18                 # 18+ 는 /var/lib/postgresql 에 마운트 (17 이하는 …/data)

# 🐬 MySQL — 한글 안 깨지게 문자셋을 명령에 박습니다
docker run -d --name my -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=devpass \
  -e MYSQL_DATABASE=appdb -e MYSQL_USER=app -e MYSQL_PASSWORD=devpass \
  -v mydata:/var/lib/mysql \
  mysql:8.4 --character-set-server=utf8mb4 \
            --collation-server=utf8mb4_0900_ai_ci

# 🐬 MariaDB (MySQL 대신)
docker run -d --name maria -p 3307:3306 \
  -e MARIADB_ROOT_PASSWORD=devpass -e MARIADB_DATABASE=appdb \
  -v mariadata:/var/lib/mysql mariadb:11.8

# 🟦 SQL Server — 비밀번호 규칙이 까다롭습니다(대소문자+숫자+기호 8자+)
docker run -d --name mssql -p 1433:1433 \
  -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=DevPass!234" \
  -v mssqldata:/var/opt/mssql \
  mcr.microsoft.com/mssql/server:2022-latest
MariaDB 를 3307 로 띄운 이유 — MySQL 과 같은 3306 을 쓰기 때문입니다. 둘을 동시에 띄우려면 왼쪽(호스트) 포트만 다르게 하면 됩니다. 접속할 때 localhost:3307 로 붙습니다. 포트 충돌은 st12.

Oracle · NoSQL 3종

# 🅾️ Oracle Database 23ai Free — 이미지가 큽니다(수 GB, 첫 실행 5~10분)
docker run -d --name ora -p 1521:1521 \
  -e ORACLE_PWD=DevPass123 \
  -v oradata:/opt/oracle/oradata \
  container-registry.oracle.com/database/free:latest
# 접속 서비스명은 FREEPDB1 입니다 (자세히는 o03)

# 🍃 MongoDB
docker run -d --name mongo -p 27017:27017 \
  -e MONGO_INITDB_ROOT_USERNAME=root \
  -e MONGO_INITDB_ROOT_PASSWORD=devpass \
  -v mongodata:/data/db mongo:8

# ⚡ Redis — 비번 걸고 영속화까지
docker run -d --name rd -p 6379:6379 \
  -v rddata:/data redis:8 \
  redis-server --requirepass devpass --appendonly yes

# 잘 떴는지 확인 (셋 다 자주 씁니다)
docker ps                  # 실행 중 목록
docker logs -f pg          # 로그 실시간 (Ctrl+C 로 나감)
docker exec -it pg psql -U app -d appdb   # 컨테이너 안으로 들어가기
첫 실행은 이미지를 내려받느라 느립니다. PostgreSQL 약 400MB, MySQL 약 600MB, SQL Server 약 1.5GB, Oracle 은 수 GB 입니다. 두 번째부터는 캐시가 있어 2~3초 만에 뜹니다.

실무형 — compose.yml 한 파일로 관리

한 줄 명령은 외우기 어렵고, 팀원에게 전달하기도 어렵습니다. compose.yml 파일 하나를 프로젝트에 넣어 두면 누구든 docker compose up -d 한 번으로 똑같은 환경을 얻습니다. 아래가 실제로 쓰는 완전판입니다 — 볼륨 · 포트 · 비밀번호 · 헬스체크 · 한글 문자셋 · 초기 SQL 이 전부 들어 있습니다.

compose.yml — 그대로 쓸 수 있는 개발용 3종 세트

services:
  db:
    image: postgres:18
    container_name: app-pg
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD:-devpass}
      POSTGRES_DB: appdb
      # 한국어 정렬·문자셋을 처음부터 UTF-8 로 굳혀 둡니다
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
      TZ: Asia/Seoul
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql   # 18+ 이미지 기준 (17 이하는 /var/lib/postgresql/data)
      # 아래 폴더의 .sql / .sh 는 "DB 가 비어 있을 때 딱 한 번" 자동 실행됩니다
      - ./initdb:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10

  cache:
    image: redis:8
    container_name: app-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "devpass"]
    ports:
      - "6379:6379"
    volumes:
      - rddata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "devpass", "ping"]
      interval: 5s
      retries: 10

  mysql:
    image: mysql:8.4
    container_name: app-mysql
    restart: unless-stopped
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_0900_ai_ci
      - --default-time-zone=+00:00
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-devpass}
      MYSQL_DATABASE: appdb
      MYSQL_USER: app
      MYSQL_PASSWORD: ${DB_PASSWORD:-devpass}
    ports:
      - "3306:3306"
    volumes:
      - mydata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-pdevpass"]
      interval: 5s
      retries: 15

volumes:
  pgdata:
  rddata:
  mydata:
파일 이름 — 요즘은 compose.yml 이 표준입니다. 옛 이름인 docker-compose.yml 도 그대로 동작하고, 맨 위의 version: "3.8" 줄은 이제 필요 없습니다(경고만 납니다).

compose 명령 8개면 충분합니다

docker compose up -d          # 전부 띄우기 (백그라운드)
docker compose ps             # 상태 · 헬스체크 결과 보기
docker compose logs -f db     # db 서비스 로그만 따라가기
docker compose exec db psql -U app -d appdb   # 안으로 들어가 SQL 치기

docker compose stop           # 멈추기 (데이터·컨테이너 유지)
docker compose start          # 다시 켜기
docker compose down           # 컨테이너 삭제 (볼륨은 남습니다)
docker compose down -v        # 볼륨까지 삭제 — 완전 초기화 ★위험★

# 특정 서비스만
docker compose up -d db
docker compose restart cache
down -v 는 데이터를 지웁니다. "스키마를 처음부터 다시 만들고 싶다"에 정확히 맞는 명령이지만, 습관적으로 치면 안 됩니다. 평소에는 stop 과 down 만 쓰세요.

초기 SQL 자동 실행 — ./initdb 폴더

-- initdb/01-schema.sql  (파일 이름 순서대로 실행됩니다)
CREATE TABLE member (
  id         bigserial PRIMARY KEY,
  email      varchar(120) NOT NULL UNIQUE,
  name       varchar(60)  NOT NULL,
  created_at timestamptz  NOT NULL DEFAULT now()
);

CREATE TABLE orders (
  id        bigserial PRIMARY KEY,
  member_id bigint  NOT NULL REFERENCES member(id),
  amount    numeric(12,2) NOT NULL CHECK (amount >= 0),
  ordered_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_member ON orders(member_id, ordered_at DESC);
-- initdb/02-seed.sql
INSERT INTO member (email, name) VALUES
  ('kim@example.com', '김현우'),
  ('lee@example.com', '이지은');
딱 한 번만 실행됩니다 — 데이터 볼륨이 비어 있을 때에만 돕니다. SQL 을 고치고 다시 반영하려면 docker compose down -v 후 up -d 입니다. 실제 프로젝트에서는 이 폴더 대신 마이그레이션 도구를 씁니다(st11, a09).

알아 두면 덜 헤매는 것 — 컨테이너끼리는 서비스 이름으로 부릅니다 🟡 중급

localhost 가 두 가지 뜻입니다

접속하는 쪽써야 할 호스트
내 PC 의 DBeaver · psqllocalhost 또는 127.0.0.1
내 PC 에서 도는 파이썬·자바 앱localhost
다른 컨테이너(같은 compose)db — 서비스 이름 그대로
컨테이너 안에서 내 PChost.docker.internal
사내 다른 PC내 PC 의 IP (st06)
가장 흔한 실수 — 앱까지 컨테이너로 띄우면서 연결 문자열에 localhost:5432 를 그대로 씁니다. 컨테이너 안에서 localhost 는 자기 자신이라 접속이 안 됩니다. db:5432 로 바꿔야 합니다.

데이터를 정말 지우고 싶을 때 / 지키고 싶을 때

# 볼륨 목록과 용량 보기
docker volume ls
docker system df -v

# 특정 볼륨만 삭제 (컨테이너를 먼저 지워야 합니다)
docker rm -f app-pg
docker volume rm pgdata

# 백업 — 볼륨 통째로 tar 로 뽑기
docker run --rm -v pgdata:/data -v "$PWD":/backup alpine \
  tar czf /backup/pgdata.tgz -C /data .

# 논리 백업 — 이쪽이 더 흔하고 안전합니다
docker exec app-pg pg_dump -U app -d appdb -Fc -f /tmp/appdb.dump
docker cp app-pg:/tmp/appdb.dump ./appdb.dump
볼륨 tar 백업 vs 논리 백업 — 볼륨을 통째로 뜨는 방식은 같은 버전에서만 복원이 보장됩니다. 버전을 넘나들 백업은 pg_dump·mysqldump 쪽이 안전합니다(p16, m15).
여기까지 왔으면 DB 는 이미 돕니다. 다음은 눈으로 보는 도구입니다 — st05 에서 DBeaver 를 깔아 연결하면 표를 클릭해서 데이터를 보고, SQL 을 색깔로 편집할 수 있습니다. 터미널만으로 끝내고 싶다면 st10 로 건너뛰어도 됩니다.
SETUP 04

네이티브 설치 빠른표 — Windows · macOS · Linux 한 장

도커를 못 쓰거나 쓰기 싫은 상황도 많습니다. 회사 PC 정책, 구형 CPU, "그냥 깔고 싶다". 이 섹션은 어디서 받아 무엇을 체크하는가만 압축한 표입니다. 마법사를 한 화면씩 짚는 설명은 각 DB 탭에 이미 있으니 링크로 넘깁니다.

세 갈래 길, 같은 도착점 — 어떻게 깔았든 이후는 똑같습니다 Windows ① 설치 파일(.exe/.msi) 내려받기 ② 마법사 — 포트 · 비번 · 문자셋 ③ 윈도우 서비스로 자동 등록 ④ services.msc 에서 시작/중지 macOS ① Homebrew 설치(한 번만) ② brew install postgresql@18 ③ brew services start 로 켜기 ④ 재부팅해도 자동 실행 Linux ① apt · dnf 저장소 등록 ② install 로 패키지 설치 ③ systemctl enable --now ④ journalctl 로 로그 확인 DB 프로세스가 항상 떠 있고 포트가 열린 상태 계정 · 비밀번호 · 데이터베이스 하나가 준비됨 클라이언트 도구로 접속하면 표가 보임 여기서부터는 설치 방법과 무관합니다 — 도커로 왔든 마법사로 왔든 st05 부터 같은 길입니다
설치 방식은 취향이고, 도착 상태는 같습니다 — 한 방식으로 끝까지 가면 됩니다.

Windows — 어디서 받아 무엇을 체크하나

DB받는 곳마법사에서 반드시 볼 것자세히
🐘 PostgreSQL postgresql.org → Download → Windows
EDB 설치 관리자
· superuser(postgres) 비밀번호 — 잊으면 재설치입니다
· 포트 5432 기본 유지
· Locale 은 C 또는 Korean, Korea
· Stack Builder 는 체크 해제해도 됩니다
c03 · p02
🐬 MySQL dev.mysql.com/downloads/installer
MySQL Installer for Windows
· Setup Type: Developer Default 또는 Server only
· Authentication: Strong Password Encryption
· root 비밀번호 설정 · 서비스 이름(MySQL84) 기억
· Windows Service 로 시작 체크 유지
c03 · m02
🐬 MariaDB mariadb.org/download → MSI · root 비밀번호 · UTF8 as default 체크
· MySQL 과 같이 쓰려면 포트를 3307 로
m17
✒️ SQLite sqlite.org/download.html
sqlite-tools-win-x64-*.zip
· 설치 프로그램이 없습니다 — 압축을 풀면 sqlite3.exe
· 폴더를 PATH 에 추가하면 어디서나 실행
· 파이썬·Node 에는 이미 내장되어 있습니다
s02
🟦 SQL Server microsoft.com/sql-server/downloads
Developer 판(무료)
· 인증 모드는 혼합 모드(SQL 인증 + Windows 인증)
· sa 비밀번호 설정
· 설치 후 SQL Server 구성 관리자 → TCP/IP 사용 ★
· 관리 도구 SSMS 는 별도 설치
st05
🅾️ Oracle oracle.com → Database 23ai Free
Windows x64 ZIP
· 계정 로그인 필요 · 압축만 10GB 대
· SYS/SYSTEM 비밀번호
· 서비스명 FREEPDB1, 리스너 1521
· 도커 쪽을 훨씬 권합니다
o02 · o03
🍃 MongoDB mongodb.com/try/download/community
MSI
· Install MongoDB as a Service 체크
· MongoDB Compass 같이 설치 체크(권장)
n02
⚡ Redis 공식 윈도우 판이 없습니다 · 도커 또는 WSL 안에 설치가 정석
· 굳이 네이티브면 Memurai(호환 제품)
r03
윈도우 설치에서 가장 많이 나는 사고 셋 — ① 비밀번호를 안 적어 둠. 설치 마법사에서 정한 비번은 복구가 사실상 불가능합니다(재설치가 빠릅니다). ② 이미 3306 을 쓰는 MySQL 이 깔려 있음. XAMPP·워크벤치·옛 설치본이 서비스로 살아 있는 경우가 많습니다(st12). ③ SQL Server 의 TCP/IP 가 꺼져 있음. 기본값이 "사용 안 함"이라 GUI 도구에서 접속이 안 됩니다.

macOS — Homebrew 한 줄이면 끝납니다

Homebrew 로 DB 설치·기동

# Homebrew 가 없다면 먼저 (한 번만)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 🐘 PostgreSQL
brew install postgresql@18
brew services start postgresql@18
createdb appdb                 # 내 계정 이름으로 슈퍼유저가 이미 있습니다
psql appdb

# 🐬 MySQL
brew install mysql
brew services start mysql
mysql_secure_installation      # root 비번 설정 · 익명 계정 제거

# ⚡ Redis · 🍃 MongoDB
brew install redis && brew services start redis
brew tap mongodb/brew && brew install mongodb-community
brew services start mongodb-community

# 상태 확인 · 중지
brew services list
brew services stop postgresql@18
맥의 PostgreSQL 은 사용자 계정이 다릅니다 — Homebrew 판은 macOS 로그인 계정과 같은 이름의 슈퍼유저를 만듭니다. 그래서 psql -U postgres 가 아니라 그냥 psql appdb 로 붙습니다. 리눅스·도커와 다른 부분이라 자주 헷갈립니다.

Linux — apt · dnf

# --- Ubuntu / Debian ---
sudo apt update
sudo apt install -y postgresql postgresql-client   # 배포판 기본 버전
sudo systemctl enable --now postgresql
sudo -u postgres psql                              # 관리자로 접속

sudo apt install -y mysql-server        # 또는 mariadb-server
sudo systemctl enable --now mysql
sudo mysql_secure_installation

sudo apt install -y sqlite3 redis-server

# 최신 PostgreSQL(18 등)이 필요하면 PGDG 공식 저장소를 추가합니다
sudo apt install -y curl ca-certificates
sudo install -d /usr/share/postgresql-common/pgdg
sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt \
  $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
# 키 등록 후 apt update && apt install postgresql-18

# --- Fedora / RHEL / Rocky ---
sudo dnf install -y postgresql-server postgresql-contrib
sudo postgresql-setup --initdb          # ★ 이 줄을 빼먹으면 기동 실패
sudo systemctl enable --now postgresql
RHEL 계열의 함정 — postgresql-setup --initdb 를 안 하면 데이터 디렉터리가 비어 있어 서비스가 즉시 죽습니다. journalctl -u postgresql 로 로그를 보면 이유가 한 줄로 나옵니다.

설치 뒤 공통 확인 3종 — 여기까지 되면 성공입니다 🟡 중급

① 프로세스가 떠 있나 ② 포트가 열렸나 ③ 붙나

# --- Windows (PowerShell) ---
Get-Service *postgres*, *mysql*, *mssql*        # 상태 Running 인지
Get-NetTCPConnection -LocalPort 5432 -State Listen
Test-NetConnection localhost -Port 5432         # TcpTestSucceeded : True

# --- macOS / Linux ---
systemctl status postgresql        # 리눅스
brew services list                 # 맥
ss -ltnp | grep 5432               # 또는  lsof -i :5432
nc -zv localhost 5432

# --- 실제 접속 (가장 확실한 확인) ---
psql -h localhost -p 5432 -U app -d appdb -c "SELECT version();"
mysql -h 127.0.0.1 -P 3306 -u app -p -e "SELECT VERSION();"
순서가 중요합니다 — 프로세스가 없으면 포트도 없고, 포트가 없으면 접속도 없습니다. "접속이 안 돼요"를 이 셋으로 쪼개면 원인이 어디인지 1분이면 좁혀집니다. 진단 흐름은 st12 와 c09 에 그림으로 있습니다.

서비스 켜고 끄기 · 자동 실행 해제

OS명령 / 화면
Windows GUIservices.msc → 해당 서비스 → 속성 → 시작 유형 수동
Windows CLInet stop MySQL84 · net start MySQL84
sc config MySQL84 start= demand
macOSbrew services stop postgresql@18
Linuxsudo systemctl stop postgresql
sudo systemctl disable postgresql
노트북이 느려졌다면 자동 실행을 수동으로 바꾸세요. DB 여러 개를 항상 띄워 둘 이유가 없습니다. 필요할 때만 켜는 습관은 도커에서는 docker compose up -d 로 자연히 몸에 붙습니다.
네이티브와 도커를 섞어도 됩니다. 실제로 흔한 구성은 "회사 표준 DB 는 네이티브로 하나, 실험용은 도커로 여러 개"입니다. 단 같은 포트를 두 곳에서 쓰면 나중에 뜬 쪽이 실패합니다 — 도커 쪽을 -p 5433:5432 처럼 옮겨 주면 평화롭게 공존합니다.
SETUP 05

클라이언트 도구 — DBeaver 하나로 다 붙습니다

DB 를 띄웠으면 이제 들여다볼 창이 필요합니다. 제품마다 전용 도구가 있지만, 여러 DB 를 오가는 사람에게는 DBeaver 하나가 정답에 가깝습니다. 무료이고, 8개 DB 를 전부 지원하고, 드라이버를 알아서 내려받아 줍니다.

클라이언트가 DB 에 닿기까지 — 실패하면 어느 지점인지가 메시지에 적혀 있습니다 GUI 도구 · 앱 DBeaver · 내 코드 드라이버 JDBC · psycopg · pg TCP 연결 host:port 로 접속 DB 서버 인증 · 세션 실패하는 네 지점 — 메시지로 구분합니다 드라이버 없음 driver not found → 도구가 자동 수신 포트가 안 열림 connection refused → st06 방화벽 · 기동 비밀번호 틀림 auth failed → st12 계정 확인 권한 없음 permission denied → st09 권한 부여 에러 메시지를 그대로 읽으면 어느 칸에서 막혔는지 알 수 있습니다 — 찍지 마세요
접속 실패는 네 종류뿐입니다 — 메시지가 이미 답을 말하고 있습니다.

도구 고르기 — 무엇을 언제

도구비용지원 DB · 성격설치
DBeaver CE ★무료
(Apache 2.0)
거의 모든 DB. 표 편집 · ER 다이어그램 · CSV 임포트 · 드라이버 자동 다운로드. 자바 기반이라 조금 무겁습니다 dbeaver.io
Win: winget · Mac: brew install --cask dbeaver-community
DataGrip유료
(JetBrains)
모든 DB. SQL 자동완성·리팩터링이 압도적. IntelliJ 쓰는 사람이면 이미 들어 있는 경우가 많습니다 Toolbox 또는 jetbrains.com
pgAdmin 4무료 PostgreSQL 전용. 서버 설정·통계·백업 화면이 촘촘합니다. 웹 UI 라 처음엔 낯섭니다 PostgreSQL 설치 마법사에 포함 · pgadmin.org
MySQL Workbench무료 MySQL 전용. 모델링(ERD)과 서버 관리가 한 화면에 MySQL Installer 에 포함
SSMS무료 SQL Server 전용 표준. 윈도우 전용 aka.ms/ssmsfullsetup
Azure Data Studio무료 SQL Server · PostgreSQL. VS Code 기반이라 가볍고 맥·리눅스에서도 돕니다 MS 다운로드 페이지
MongoDB Compass무료 MongoDB 전용. 문서 탐색·집계 파이프라인 시각 편집 MongoDB 설치 마법사에 포함
RedisInsight무료 Redis 전용. 키 탐색 · 메모리 분석 · 느린 명령 추적 redis.io/insight
TablePlus부분 유료 가볍고 예쁩니다. 맥에서 인기. 무료판은 탭 개수 제한 tableplus.com
VS Code 확장무료 SQLTools · Database Client. 코드를 쓰다 잠깐 조회하는 용도로 편합니다 확장 마켓플레이스

DBeaver 설치와 첫 연결 — 7단계

# Windows
winget install dbeaver.dbeaver
# macOS
brew install --cask dbeaver-community
# Linux (Ubuntu)
sudo snap install dbeaver-ce      # 또는 .deb 내려받기
단계화면에서 할 일
1왼쪽 위 새 연결(플러그 아이콘) 클릭
2목록에서 PostgreSQL 선택 → 다음
3Host localhost · Port 5432
4Database appdb · Username app · Password devpass
5맨 아래 Test Connection → 드라이버 없으면 Download 버튼이 뜹니다 → 누르면 자동 설치
6"Connected" 가 뜨면 완료
7왼쪽 트리에서 appdb → Schemas → public → Tables 펼치기
드라이버를 손으로 받을 필요가 없습니다 — 5단계에서 DBeaver 가 Maven 저장소에서 JDBC 드라이버 .jar 를 알아서 받아 옵니다. 사내망이라 다운로드가 막히면 드라이버 설정 → 라이브러리 추가로 .jar 를 직접 넣으면 됩니다.

CLI 도구 설치와 PATH

# 서버 없이 "클라이언트 명령만" 깔고 싶을 때가 많습니다
# (DB 는 도커·원격에 있고, 내 PC 에는 psql 만 필요한 상황)

# Windows — winget 이 가장 간단
winget install PostgreSQL.psql          # psql 만
winget install Oracle.MySQLShell        # mysqlsh
winget install SQLite.SQLite            # sqlite3
winget install Redis.RedisInsight

# macOS
brew install libpq && brew link --force libpq   # psql · pg_dump
brew install mysql-client
brew install redis                              # redis-cli 포함
brew install mongosh

# Ubuntu
sudo apt install -y postgresql-client mysql-client sqlite3 redis-tools

# 컨테이너 안의 CLI 를 그냥 쓰는 방법 — 설치가 아예 필요 없습니다
docker exec -it app-pg psql -U app -d appdb
docker exec -it app-redis redis-cli -a devpass
'psql'은(는) 내부 또는 외부 명령이 아닙니다 — 설치는 됐는데 PATH 에 없다는 뜻입니다. 윈도우 기준 시작 → "환경 변수" 검색 → 시스템 환경 변수 편집 → 환경 변수 → Path → 편집 → 새로 만들기 에 C:\Program Files\PostgreSQL\18\bin 을 넣고 터미널을 새로 열면 됩니다(st08 에 화면 순서가 더 있습니다).

GUI 를 쓸 때 지켜야 할 것 — 운영 DB 는 다르게 대합니다 🟡 중급

이렇게 쓰면 사고가 납니다
  • 운영 DB 연결을 개발 연결과 같은 색·같은 폴더에 두기
  • 결과 그리드에서 값을 직접 고쳐 저장하기 — 어떤 UPDATE 가 나갔는지 기록이 없습니다
  • SELECT * 를 수천만 행 테이블에 LIMIT 없이 실행
  • 비밀번호를 도구에 저장해 두고 PC 를 공용으로 쓰기
  • DDL(테이블 변경)을 GUI 마법사로 눌러 처리 — 팀에 공유되지 않습니다
실무에서 하는 방식
  • DBeaver 연결마다 색 지정 — 운영은 빨강, 스테이징은 노랑
  • 운영 연결은 읽기 전용 체크(Connection → General → Read-only)
  • 변경은 항상 SQL 문으로 작성하고 사본을 남깁니다
  • DDL 은 마이그레이션 파일로(st11, a09)
  • 대량 조회는 LIMIT 100 을 붙이는 습관 — 도구 설정에도 상한이 있습니다
도구는 하나로 통일하세요. DB 마다 전용 도구를 다 깔면 단축키·아이콘·연결 저장 위치가 제각각이라 오히려 느려집니다. DBeaver(무료) 또는 DataGrip(유료) 하나 + 필요할 때 CLI 조합이면 이 가이드의 모든 실습을 끝까지 할 수 있습니다.
SETUP 06

포트 · 방화벽 · 원격 접속 — 왜 내 PC 에서만 붙나

"내 PC 에서는 잘 되는데 옆자리 동료는 접속이 안 됩니다." 설치 다음으로 흔한 벽입니다. 원인은 대부분 세 겹의 문 중 하나가 닫혀 있어서입니다 — DB 가 어느 주소로 듣는가, OS 방화벽이 통과시키는가, DB 계정이 그 IP 를 허용하는가. 순서대로 열면 됩니다.

원격 접속이 통과해야 하는 세 겹의 문 동료 PC — 192.168.0.55 내 IP 192.168.0.20 의 5432 포트로 접속 시도 문 ① OS 방화벽 — 인바운드 규칙에 5432 가 있나 막히면: 아무 응답 없이 한참 뒤 timed out · Test-NetConnection False 문 ② DB 수신 주소 — listen_addresses · bind-address localhost 만 듣고 있으면: connection refused 문 ③ 접속 허용 규칙 — pg_hba.conf · 계정의 host 막히면: no pg_hba.conf entry · Access denied for user 비밀번호 확인 후 접속 성립 막힌 문은 오류 메시지가 알려 줍니다 — 시간 초과는 ①, 연결 거부는 ②, 규칙 없음은 ③
세 문을 위에서부터 순서대로 여세요 — 아래부터 건드리면 원인이 섞여 더 오래 걸립니다.

기본 포트 — 외우면 평생 씁니다

DB기본 포트메모바꾸는 곳
🐘 PostgreSQL5432여러 버전을 깔면 5433, 5434 로 자동 증가postgresql.conf 의 port
🐬 MySQL · MariaDB3306MySQL X 프로토콜은 33060 을 따로 씁니다my.cnf / my.ini 의 port
🟦 SQL Server1433명명된 인스턴스는 동적 포트 — UDP 1434 로 찾습니다구성 관리자 → TCP/IP → IPAll
🅾️ Oracle1521리스너 포트. EM Express 는 5500listener.ora
🍃 MongoDB27017복제 셋 멤버는 27018, 27019 관례mongod.conf 의 net.port
⚡ Redis6379Sentinel 은 26379redis.conf 의 port
✒️ SQLite없음네트워크를 쓰지 않습니다 — 파일 경로가 전부—

문 ② — DB 가 "누구의 요청까지 듣는가"

# --- PostgreSQL : postgresql.conf ---
listen_addresses = '*'        # 기본은 'localhost' (내 PC 만)
port = 5432

# --- MySQL / MariaDB : my.cnf 의 [mysqld] ---
[mysqld]
bind-address = 0.0.0.0        # 기본이 127.0.0.1 인 배포판이 많습니다
port = 3306

# --- MongoDB : mongod.conf ---
net:
  port: 27017
  bindIp: 0.0.0.0             # 기본 127.0.0.1

# --- Redis : redis.conf ---
bind 0.0.0.0
protected-mode no             # 비번을 걸었을 때만 끄세요
requirepass 강한비밀번호
도커라면 이 설정을 대부분 건드릴 필요가 없습니다. 공식 이미지는 이미 0.0.0.0 으로 듣고 있고, 바깥에 노출할지는 -p 매핑이 결정하기 때문입니다. -p 5432:5432 대신 -p 127.0.0.1:5432:5432 로 쓰면 내 PC 에서만 붙게 제한할 수 있습니다.

문 ③ — "그 IP 의 그 계정"을 허용

# --- PostgreSQL : pg_hba.conf (파일 순서대로 위에서부터 검사) ---
# TYPE  DATABASE  USER  ADDRESS          METHOD
local   all       all                    scram-sha-256
host    all       all   127.0.0.1/32     scram-sha-256
host    appdb     app   192.168.0.0/24   scram-sha-256   # 사내망만
# host  all       all   0.0.0.0/0        scram-sha-256   # 전 세계 — 쓰지 마세요

# 고친 뒤 반드시 다시 읽히기
#   sudo systemctl reload postgresql
#   또는  SELECT pg_reload_conf();
-- MySQL : 계정 이름에 "어디서 오는가"가 붙어 있습니다
CREATE USER 'app'@'192.168.0.%' IDENTIFIED BY '강한비밀번호';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app'@'192.168.0.%';
FLUSH PRIVILEGES;

SELECT user, host FROM mysql.user;   -- 지금 허용된 조합 확인
MySQL 의 가장 헷갈리는 지점 — 'app'@'localhost' 와 'app'@'%' 는 서로 다른 계정입니다. 비밀번호가 맞는데도 Access denied for user 'app'@'192.168.0.55' 가 뜨면 비번이 아니라 host 가 안 맞는 것입니다(st12).

문 ① — Windows 방화벽 인바운드 규칙 만들기

GUI 로 — 클릭 순서 그대로

단계화면
1시작 → wf.msc 입력 → 고급 보안이 포함된 Windows Defender 방화벽
2왼쪽 인바운드 규칙 클릭
3오른쪽 새 규칙…
4규칙 종류: 포트 → 다음
5TCP · 특정 로컬 포트 5432 → 다음
6작업: 연결 허용 → 다음
7프로필: 개인만 체크 ★ (공용은 해제)
8이름: PostgreSQL 5432 (사내) → 마침
9만든 규칙 더블클릭 → 범위 탭 → 원격 IP 주소를 192.168.0.0/24 로 제한 ★
7번과 9번을 건너뛰지 마세요. 카페 와이파이(공용 프로필)에서까지 DB 포트가 열리면 아무나 접속 시도를 할 수 있습니다. 실제로 Redis·MongoDB 는 이렇게 열려 있다 털리는 사고가 매년 반복됩니다.

명령으로 — 한 줄이면 끝

# --- Windows (관리자 PowerShell) ---
New-NetFirewallRule -DisplayName "PostgreSQL 5432" `
  -Direction Inbound -Protocol TCP -LocalPort 5432 `
  -Action Allow -Profile Private `
  -RemoteAddress 192.168.0.0/24

# 확인 · 삭제
Get-NetFirewallRule -DisplayName "PostgreSQL*"
Remove-NetFirewallRule -DisplayName "PostgreSQL 5432"

# --- Ubuntu (ufw) ---
sudo ufw allow from 192.168.0.0/24 to any port 5432 proto tcp
sudo ufw status numbered

# --- RHEL 계열 (firewalld) ---
sudo firewall-cmd --permanent --add-rich-rule='rule family=ipv4 \
  source address=192.168.0.0/24 port port=5432 protocol=tcp accept'
sudo firewall-cmd --reload
내 IP 를 모를 때 — 윈도우는 ipconfig 의 IPv4 주소, 맥·리눅스는 ip a 또는 ifconfig 입니다. 192.168.x.x · 10.x.x.x 로 시작하면 사내망 주소입니다.

보안 최소선 — 이 네 줄만은 지키세요 🔴 실무

하면 안 되는 것 vs 대신 하는 것

하지 말 것대신
공유기에서 5432 포트포워딩VPN · SSH 터널
0.0.0.0/0 허용사내 대역만 192.168.0.0/24
비번 없는 Redis/Mongo 노출requirepass · 인증 활성화
앱이 root/postgres 계정 사용앱 전용 계정(st09)
비번을 1234·admin16자 이상 무작위
인터넷에 DB 포트를 직접 여는 일은 없다고 생각하세요. 원격 접속이 필요하면 SSH 터널이 정석입니다 — DB 포트는 계속 닫아 두고 SSH(22)만 씁니다.

SSH 터널 — 포트를 열지 않고 붙는 법

# 서버의 5432 를 내 PC 의 15432 로 끌어옵니다
ssh -N -L 15432:localhost:5432 user@db-server.example.com

# 그러면 내 PC 에서는 이렇게 붙습니다
psql -h localhost -p 15432 -U app -d appdb

# 연결 문자열도 localhost:15432 로 씁니다
# postgresql://app:pw@localhost:15432/appdb
DBeaver 에 내장되어 있습니다 — 연결 설정의 SSH 탭에 서버 주소·계정·키 파일을 넣으면 위 명령을 도구가 대신 해 줍니다. 회사 DB 에 붙을 때 사실상 표준 방식입니다.
도커에서 사내 공유가 필요할 때 — -p 0.0.0.0:5432:5432 로 띄우고 OS 방화벽만 열면 끝입니다. 컨테이너 안 설정은 손댈 필요가 없어요. 다만 노트북을 들고 나가면 그 규칙이 카페 와이파이에서도 살아 있으니 프로필을 "개인"으로 두는 것이 중요합니다.
SETUP 07

언어별 드라이버 · 연결 문자열 — 7개 언어 설치 명령

DB 가 떴고 GUI 로도 붙었다면, 마지막은 내 코드에서 붙이기입니다. 언어마다 "무엇을 설치하고, 문자열을 어떻게 쓰고, 세 줄로 어떻게 조회하는가"를 한자리에 모았습니다. 문법 깊은 설명은 a01~a04 에 있고 여기서는 설치와 첫 연결만 다룹니다.

앱에서 DB 까지 — 네 개의 층 (가운데 둘이 이 섹션의 주제) ① 내 코드 — 서비스 · 컨트롤러 · 배치 스크립트 "회원 목록을 가져와" 같은 업무 로직만 있습니다 ② ORM · 쿼리 빌더 — SQLAlchemy · JPA · Prisma · EF Core (선택) 없어도 됩니다. SQL 을 직접 써도 아래 층은 똑같이 동작합니다 ③ 드라이버 — psycopg · JDBC · pg · Npgsql · libpqxx · sqlx ★ 필수 SQL 문자열을 그 DB 의 통신 규약(프로토콜)으로 바꿔 보내는 번역기입니다 ④ 커넥션 풀 — HikariCP · pg.Pool · SQLAlchemy engine · sqlx Pool 연결을 미리 열어 두고 빌려줍니다. 없으면 요청마다 새로 여느라 수십 배 느려집니다 DB 서버 (TCP host:port)
ORM 은 선택, 드라이버는 필수, 커넥션 풀은 사실상 필수입니다(t13).

연결 문자열 — DB 별 한 줄 규격

DBURL 형태 (파이썬 · Node · Rust 계열)JDBC 형태 (자바 · 코틀린)
🐘 PostgreSQLpostgresql://app:pw@localhost:5432/appdbjdbc:postgresql://localhost:5432/appdb
🐬 MySQLmysql://app:pw@localhost:3306/appdbjdbc:mysql://localhost:3306/appdb
🐬 MariaDBmysql://app:pw@localhost:3307/appdbjdbc:mariadb://localhost:3307/appdb
✒️ SQLitesqlite:///./app.db (파일 경로)jdbc:sqlite:app.db
🟦 SQL ServerServer=localhost,1433;Database=appdb;User Id=sa;…jdbc:sqlserver://localhost:1433;databaseName=appdb
🅾️ Oracleoracle+oracledb://app:pw@localhost:1521/?service_name=FREEPDB1jdbc:oracle:thin:@//localhost:1521/FREEPDB1
🍃 MongoDBmongodb://root:pw@localhost:27017/appdb?authSource=admin같음 (드라이버가 URI 를 씁니다)
⚡ Redisredis://:pw@localhost:6379/0같음
비밀번호에 특수문자가 있으면 URL 인코딩이 필요합니다. @ 는 %40, # 은 %23, / 는 %2F 입니다. p@ss/w#d 는 p%40ss%2Fw%23d 로 써야 합니다 — "비번이 맞는데 파싱 오류가 난다"의 단골 원인입니다. 귀찮으면 URL 대신 개별 인자(host·user·password)로 넘기세요.

🐍 파이썬 · ☕ 자바 · 🟠 코틀린

파이썬 — psycopg 3 · PyMySQL · SQLAlchemy 2

python -m venv .venv && . .venv/bin/activate   # 윈도우: .venv\Scripts\activate

pip install "psycopg[binary]"     # 🐘 PostgreSQL (권장 · C 확장 포함)
pip install PyMySQL               # 🐬 MySQL (순수 파이썬)
pip install mysqlclient           # 🐬 더 빠르지만 빌드 도구 필요
pip install oracledb              # 🅾️ Oracle (thin 모드 — 클라이언트 불필요)
pip install pymssql               # 🟦 SQL Server
pip install redis pymongo         # ⚡ 🍃
pip install "SQLAlchemy>=2.0"     # ORM · 커넥션 풀
# ✒️ SQLite 는 표준 라이브러리 sqlite3 로 이미 들어 있습니다
import psycopg

with psycopg.connect(
        "postgresql://app:devpass@localhost:5432/appdb") as conn:
    with conn.cursor() as cur:
        cur.execute("SELECT id, name FROM member WHERE id = %s", (1,))
        print(cur.fetchone())

# SQLAlchemy 2 — 엔진이 곧 커넥션 풀입니다
from sqlalchemy import create_engine, text
eng = create_engine("postgresql+psycopg://app:devpass@localhost:5432/appdb",
                    pool_size=5, max_overflow=10, pool_pre_ping=True)
with eng.connect() as c:
    print(c.execute(text("SELECT count(*) FROM member")).scalar())
플레이스홀더가 드라이버마다 다릅니다 — psycopg·pymssql 은 %s, sqlite3·oracledb 는 ?/:name, SQLAlchemy 는 :name. 어느 쪽이든 문자열 이어붙이기(f-string)는 절대 금지입니다(a05).

자바 · 코틀린 — JDBC + HikariCP

// build.gradle.kts — 필요한 것만 골라 넣습니다
dependencies {
    runtimeOnly("org.postgresql:postgresql:42.7.4")          // 🐘
    runtimeOnly("com.mysql:mysql-connector-j:9.1.0")         // 🐬
    runtimeOnly("com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11") // 🟦
    runtimeOnly("com.oracle.database.jdbc:ojdbc11:23.6.0.24.10")   // 🅾️
    runtimeOnly("org.xerial:sqlite-jdbc:3.47.0.0")           // ✒️

    implementation("com.zaxxer:HikariCP:6.2.1")              // 커넥션 풀
    // Spring Boot 를 쓰면 spring-boot-starter-data-jpa 안에 Hikari 가 포함됩니다
    // Kotlin 전용 ORM: implementation("org.jetbrains.exposed:exposed-core:0.56.1")
}
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://localhost:5432/appdb");
cfg.setUsername("app");
cfg.setPassword(System.getenv("DB_PASSWORD"));   // 코드에 비번을 안 넣습니다
cfg.setMaximumPoolSize(10);

try (HikariDataSource ds = new HikariDataSource(cfg);
     Connection con = ds.getConnection();
     PreparedStatement ps = con.prepareStatement(
         "SELECT name FROM member WHERE id = ?")) {
    ps.setLong(1, 1L);
    try (ResultSet rs = ps.executeQuery()) {
        if (rs.next()) System.out.println(rs.getString("name"));
    }
}
Spring Boot 라면 코드가 거의 필요 없습니다 — application.yml 에 spring.datasource.url/username/password 세 줄만 넣으면 Hikari 풀이 자동으로 구성됩니다(a03).

🟨 JS · TS · 🟣 C# · 🔵 C++ · 🦀 Rust

Node.js / TypeScript

npm i pg                 # 🐘 PostgreSQL
npm i mysql2             # 🐬 MySQL — Promise API 지원
npm i better-sqlite3     # ✒️ SQLite (동기 · 빠름)
npm i mssql              # 🟦 SQL Server
npm i redis mongodb      # ⚡ 🍃
npm i -D prisma && npx prisma init      # ORM + 마이그레이션
npm i drizzle-orm        # 가벼운 타입 안전 쿼리 빌더
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,   // st08 참고
  max: 10, idleTimeoutMillis: 30_000,
});

const { rows } = await pool.query(
  "SELECT id, name FROM member WHERE id = $1", [1]);
console.log(rows[0]);
Node 는 Pool 을 쓰세요. Client 는 연결 하나짜리라 서버에서 쓰면 동시 요청이 줄을 섭니다. Prisma·Drizzle 은 내부적으로 풀을 씁니다. 플레이스홀더는 PostgreSQL $1, MySQL ? 로 다릅니다.

C# · C++ · Rust

# 🟣 C# / .NET 10
dotnet add package Npgsql                            # 🐘
dotnet add package Microsoft.Data.SqlClient          # 🟦
dotnet add package MySqlConnector                    # 🐬
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL   # EF Core

# 🔵 C++ — vcpkg 로 라이브러리 설치
vcpkg install libpqxx          # 🐘 PostgreSQL 공식 C++ 래퍼
vcpkg install soci[postgresql] # 여러 DB 공용

# 🦀 Rust — Cargo.toml
# sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros"] }
# tokio = { version = "1", features = ["full"] }
await using var conn = new NpgsqlConnection(
    Environment.GetEnvironmentVariable("DB_CONN"));
// ↑ Npgsql 은 postgresql:// URL 을 못 읽습니다 — "Host=localhost;Username=app;Password=…;Database=appdb" 형식
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
    "SELECT name FROM member WHERE id = @id", conn);
cmd.Parameters.AddWithValue("id", 1);
Console.WriteLine(await cmd.ExecuteScalarAsync());
let pool = sqlx::postgres::PgPoolOptions::new()
    .max_connections(10)
    .connect(&std::env::var("DATABASE_URL")?).await?;

let name: (String,) = sqlx::query_as("SELECT name FROM member WHERE id = $1")
    .bind(1_i64).fetch_one(&pool).await?;
println!("{}", name.0);
C++ 만 사정이 다릅니다 — 패키지 매니저(vcpkg·Conan)로 받아 CMake 에서 링크해야 하고, ODBC 를 통해 붙는 방식도 흔합니다. 간단한 도구라면 SQLite 를 sqlite3.c 한 파일로 넣는 쪽이 훨씬 편합니다.

설치가 끝났는지 확인하는 3줄 — 언어별 🟡 중급

언어버전·연결 확인 한 줄실패할 때 흔한 원인
🐍 Pythonpython -c "import psycopg; print(psycopg.__version__)"가상환경을 안 켠 채 설치
☕ Java./gradlew dependencies --configuration runtimeClasspath | grep postgres드라이버를 runtimeOnly 로 안 넣음
🟠 Kotlin위와 같음 (Gradle 공용)Exposed 와 드라이버 버전 불일치
🟨 Nodenode -e "console.log(require('pg/package.json').version)"node_modules 미설치 · ESM/CJS 혼용
🟣 C#dotnet list package프로젝트 폴더가 아닌 곳에서 add
🔵 C++vcpkg list | findstr libpqxxCMake 툴체인 파일 미지정
🦀 Rustcargo tree | grep sqlxfeature 플래그 누락(postgres 등)
여기까지가 "설치"의 끝입니다. 다음 섹션(st08)에서 지금까지 코드에 그대로 적어 온 devpass 를 환경 변수로 빼내는 방법을 다룹니다. 실제 프로젝트에서 비밀번호가 코드에 남아 있으면 그 자체가 사고입니다.
SETUP 08

환경 변수 · 시크릿 — 비밀번호를 코드에서 빼냅니다

지금까지 예제에는 devpass 가 그대로 적혀 있었습니다. 배우는 동안은 괜찮지만, 그 코드를 깃허브에 올리는 순간 이야기가 달라집니다. 해결책은 간단합니다 — 비밀번호를 코드 바깥에 두고 실행할 때 주입하면 됩니다. 이 방식은 개발·운영 환경을 나누는 데도 그대로 쓰입니다.

비밀번호는 어디에 두고 어떻게 코드까지 오는가 값이 사는 곳 (환경마다 다름) .env 파일 내 PC · 깃에 올리지 않음 OS 환경 변수 CI · 서버 · 컨테이너 시크릿 관리 서비스 Vault · Secrets Manager 프로세스 환경 실행 순간에만 존재 내 코드 getenv("DATABASE_URL") 절대 하지 말 것 — 소스에 비번을 적고 커밋 공개 저장소는 수 분 안에 자동 스캐너에 발견됩니다 .env 는 .gitignore 에 · 대신 값이 빈 .env.example 을 커밋합니다 팀원은 example 을 복사해 자기 값을 채웁니다 — "어떤 키가 필요한지"는 공유하고 "값"은 공유하지 않습니다
규칙 하나면 됩니다 — 키 이름은 코드에, 값은 환경에.

DATABASE_URL — 사실상의 표준 이름

.env 파일 한 장

# .env  ← 이 파일은 절대 커밋하지 않습니다
DATABASE_URL=postgresql://app:devpass@localhost:5432/appdb
REDIS_URL=redis://:devpass@localhost:6379/0

# 개별 값으로 쓰는 것을 선호한다면 (비번에 특수문자가 많을 때 편합니다)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=appdb
DB_USER=app
DB_PASSWORD=devpass

TZ=UTC
# .gitignore 에 반드시
.env
.env.local
*.env
!.env.example
DATABASE_URL 이라는 이름은 Heroku 에서 시작해 Prisma · Rails · Django · sqlx · SQLAlchemy 가 전부 기본으로 인식하는 관례가 됐습니다. 새 프로젝트라면 이 이름을 그냥 쓰세요 — 도구가 알아서 찾아 줍니다.

언어별 로더 — 한 줄씩

# 🐍 Python
pip install python-dotenv
#   from dotenv import load_dotenv; load_dotenv()
#   os.environ["DATABASE_URL"]

# 🟨 Node 20.6+ — 패키지 없이 내장으로 됩니다
node --env-file=.env server.js
#   process.env.DATABASE_URL
#   (구버전이면 npm i dotenv  →  require("dotenv").config())

# ☕ Java · Spring Boot — application.yml 에서 치환
#   spring.datasource.url: ${DATABASE_URL}
#   기본값도 줄 수 있습니다: ${DB_PORT:5432}

# 🟣 .NET — 개발용 시크릿은 프로젝트 밖에 저장됩니다
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Db" "Host=localhost;..."

# 🦀 Rust
# dotenvy = "0.15"   →  dotenvy::dotenv().ok();
#   std::env::var("DATABASE_URL")?
.env 는 개발 편의 도구입니다. 운영 서버에 .env 파일을 올려 두는 것도 흔하지만, 제대로 하려면 배포 시스템이 환경 변수로 주입하거나 시크릿 서비스에서 읽어 오게 합니다. 파일은 실수로 백업·로그에 섞이기 쉽습니다.

Windows 환경 변수 — 화면 순서 그대로

GUI 로 영구 등록

단계화면
1시작 → "환경 변수" 검색 → 시스템 환경 변수 편집
2시스템 속성 창 아래 환경 변수(N)… 버튼
3위쪽 사용자 변수 영역의 새로 만들기(N)…
아래쪽 시스템 변수는 모든 사용자에게 적용됩니다
4변수 이름 DATABASE_URL
변수 값 postgresql://app:devpass@localhost:5432/appdb
5확인 → 확인 → 확인 (창 세 개 전부)
6 ★터미널·IDE 를 완전히 껐다 켭니다 — 이미 열린 창에는 반영되지 않습니다
7확인: echo $env:DATABASE_URL (PowerShell)
6번이 진짜 함정입니다. 환경 변수는 프로세스가 시작될 때 복사됩니다. VS Code 를 켜 둔 채 변수를 만들면 그 안의 터미널은 계속 예전 값을 봅니다. IDE 는 작업 표시줄에서 완전히 종료했다가 다시 여세요.

명령으로 — 임시 · 영구

# --- PowerShell ---
$env:DATABASE_URL = "postgresql://app:devpass@localhost:5432/appdb"  # 이 창에서만
[Environment]::SetEnvironmentVariable(
  "DATABASE_URL", "postgresql://...", "User")                        # 영구
setx DATABASE_URL "postgresql://..."                                 # 영구(구식)

# --- cmd ---
set DATABASE_URL=postgresql://...          # 이 창에서만

# --- macOS / Linux ---
export DATABASE_URL="postgresql://app:devpass@localhost:5432/appdb"
echo 'export DATABASE_URL="..."' >> ~/.zshrc     # 영구 (bash 는 ~/.bashrc)

# 한 번만 쓰고 버리기 — 히스토리에 안 남기는 방법
DATABASE_URL="postgresql://..." python app.py
setx 는 값이 1024자로 잘립니다. 긴 연결 문자열이면 [Environment]::SetEnvironmentVariable 쪽을 쓰세요. 또 setx 로 넣은 값은 현재 창에는 반영되지 않습니다.

Docker Compose 에서의 환경 변수

compose 는 .env 를 자동으로 읽습니다

# compose.yml 과 같은 폴더의 .env 를 자동으로 읽어 ${} 를 치환합니다
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD:?비밀번호를 .env 에 넣으세요}
      POSTGRES_USER: ${DB_USER:-app}      # 없으면 app 을 기본값으로
    ports:
      - "${DB_PORT:-5432}:5432"

  api:
    build: .
    env_file:
      - .env                # 컨테이너 안으로 통째로 전달
    environment:
      # 컨테이너끼리는 서비스 이름으로 부릅니다 (st03)
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
${VAR:?메시지} 는 값이 없으면 그 메시지를 띄우고 실행을 멈춥니다. "왜 비번이 비어 있지"로 30분 헤매는 것보다 훨씬 낫습니다. docker compose config 로 치환된 최종 결과를 미리 볼 수 있습니다.

이미 커밋해 버렸다면

순서할 일
1 ★비밀번호를 즉시 바꿉니다. 히스토리를 지우는 것보다 이게 먼저입니다
2.gitignore 에 추가 후 git rm --cached .env
3공개 저장소였다면 DB 접속 로그를 확인합니다
4히스토리 정리는 git filter-repo 또는 BFG. 강제 푸시가 필요합니다
5재발 방지: gitleaks·git-secrets 를 pre-commit 훅에
-- 비밀번호 변경은 SQL 한 줄입니다
ALTER USER app WITH PASSWORD '새-강한-비밀번호';                  -- 🐘
ALTER USER 'app'@'%' IDENTIFIED BY '새-강한-비밀번호';            -- 🐬
"비공개 저장소니까 괜찮다"고 넘기지 마세요. 저장소는 공개로 바뀌기도 하고, 포크·CI 로그·로컬 클론으로 퍼집니다. 커밋된 비밀번호는 이미 유출된 비밀번호로 취급하는 것이 원칙입니다.
운영 환경은 한 단계 더 갑니다 — AWS Secrets Manager · HashiCorp Vault · Kubernetes Secret 같은 곳에 값을 두고, 애플리케이션이 기동할 때 받아 오거나 주기적으로 자동 교체합니다. 지금 단계에서 그것까지 할 필요는 없지만, "코드에 값이 없다"는 원칙만 지켜 두면 나중에 그쪽으로 옮기는 데 코드 수정이 거의 없습니다.
SETUP 09

초기 세팅 체크리스트 — 깔고 나서 반드시 하는 여섯 가지

설치가 끝난 DB 는 아직 "쓸 수 있는" 상태가 아닙니다. 계정을 나누고, 문자셋과 타임존을 굳히고, 데이터를 넣어 보고, 백업을 한 번 돌려 봐야 비로소 안심하고 쓸 수 있습니다. 10분이면 되는데 안 하면 나중에 며칠을 잡아먹는 항목들입니다.

계정을 셋으로 나눕니다 — 사고의 크기를 권한으로 제한합니다 ① 관리자 — postgres · root · sa · SYSTEM 무엇이든 할 수 있습니다. 계정 만들기 · DB 만들기 · 백업 복원에만 씁니다 애플리케이션 연결 문자열에는 절대 넣지 않습니다 CREATE USER 로 만들어 줌 ② 앱 전용 계정 — app appdb 안에서 SELECT · INSERT · UPDATE · DELETE 만 가능 DROP TABLE 도, 다른 데이터베이스 조회도 안 됩니다 — 실수해도 여기까지입니다 ③ 읽기 전용 계정 — app_ro 대시보드 · 통계 조회 · 신입 교육용. SELECT 만 가능하므로 마음 놓고 나눠 줍니다
"편하니까 전부 root 로"가 사고의 시작입니다 — 계정 분리는 5분이면 끝납니다.

① 계정 · 권한 — 최소 권한으로

🐘 PostgreSQL

-- 관리자(postgres)로 접속해서 딱 한 번
CREATE DATABASE appdb ENCODING 'UTF8' TEMPLATE template0;

CREATE USER app    WITH PASSWORD '강한비밀번호';
CREATE USER app_ro WITH PASSWORD '다른비밀번호';

\c appdb

-- public 스키마에 아무나 못 만들게 (PG 15+ 는 기본이 이렇게 바뀌었습니다)
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

GRANT CONNECT ON DATABASE appdb TO app, app_ro;
GRANT USAGE   ON SCHEMA public  TO app, app_ro;

GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro;

-- 앞으로 만들어질 테이블에도 자동 적용 ★ 이 줄을 빼먹는 사람이 많습니다
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO app_ro;
ALTER DEFAULT PRIVILEGES 가 핵심입니다. 이걸 안 하면 지금 있는 테이블만 권한이 붙고, 내일 만든 테이블은 또 GRANT 를 쳐야 합니다. 자세히는 p04.

🐬 MySQL / MariaDB

-- root 로 접속해서
CREATE DATABASE appdb
  CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;   -- MariaDB: utf8mb4_uca1400_ai_ci

CREATE USER 'app'@'%'    IDENTIFIED BY '강한비밀번호';
CREATE USER 'app_ro'@'%' IDENTIFIED BY '다른비밀번호';

GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app'@'%';
GRANT SELECT ON appdb.* TO 'app_ro'@'%';
FLUSH PRIVILEGES;

-- 확인
SHOW GRANTS FOR 'app'@'%';
SELECT user, host, plugin FROM mysql.user;
MySQL 은 계정 이름에 host 가 붙습니다. 도커 안에서 붙는 앱, 사내 다른 PC, 내 노트북이 각각 다른 IP 로 오므로 보통 'app'@'%'(어디서나) 또는 'app'@'172.%'(도커 대역)로 둡니다. 범위를 좁힐수록 안전하지만 접속이 안 되는 원인 1위이기도 합니다(st12).

② 문자셋 · ③ 타임존 — 지금 굳혀 두세요

DB확인맞춰 둘 값안 하면
🐘 PostgreSQL SHOW server_encoding;
SHOW timezone;
UTF8 · UTC
DB 생성 후엔 인코딩 변경 불가 — 만들 때 정합니다
한글 정렬·저장 이상
🐬 MySQL SHOW VARIABLES LIKE 'character_set%';
SELECT @@time_zone;
utf8mb4 · +00:00
옛 utf8 은 3바이트라 이모지가 깨집니다
이모지·일부 한자 저장 실패
🟦 SQL Server SELECT SERVERPROPERTY('Collation'); Korean_Wansung_CI_AS 또는 UTF-8 지원 collation 한글 비교·정렬 이상
🅾️ Oracle SELECT * FROM nls_database_parameters; AL32UTF8 한글 길이 계산 혼란
✒️ SQLite PRAGMA encoding; UTF-8 (기본) 거의 문제 없음

타임존은 UTC 로 저장, 표시할 때 KST

-- 🐘 PostgreSQL : timestamptz 를 쓰면 알아서 해결됩니다
CREATE TABLE log (
  id bigserial PRIMARY KEY,
  msg text,
  created_at timestamptz NOT NULL DEFAULT now()   -- timestamp 가 아니라 timestamptz ★
);
SET TIME ZONE 'Asia/Seoul';       -- 세션에서 표시만 바꿉니다
SELECT created_at AT TIME ZONE 'Asia/Seoul' FROM log;

-- 🐬 MySQL : 서버 타임존을 UTC 로 두고 앱에서 변환
SET GLOBAL time_zone = '+00:00';
SELECT CONVERT_TZ(created_at, '+00:00', '+09:00') FROM log;
"9시간 차이"의 정체 — DB 서버는 UTC, 앱 서버는 KST, JDBC 는 또 자기 타임존을 붙이면서 값이 어긋납니다. 규칙은 하나 — 저장은 UTC, 변환은 화면에서 한 번만. 자세히는 m08.

④ 샘플 데이터 넣어 보기

샘플 DB대상 · 받는 곳
PagilaPostgreSQL · DVD 대여점. GitHub devrimgunduz/pagila
SakilaMySQL 공식 샘플. dev.mysql.com/doc/index-other.html
Chinook여러 DB 공용 · 음원 판매. 가볍고 JOIN 연습에 좋습니다
NorthwindSQL Server 전통 샘플 · 주문/배송
이 가이드 스키마c08 의 member · orders — 가장 작고 빠릅니다
# 파일 하나를 통째로 실행하는 법 (전 DB 공통 패턴)
psql  -U app -d appdb -f pagila-schema.sql
mysql -u app -p appdb < sakila-schema.sql
sqlite3 app.db ".read chinook.sql"

# 도커 안으로 밀어 넣기
docker exec -i app-pg psql -U app -d appdb < pagila-schema.sql
샘플을 꼭 넣으세요. 빈 DB 로는 JOIN·GROUP BY·인덱스 실습을 할 수 없습니다. 수천 행짜리 실제 데이터가 있어야 t04 의 인덱스 효과가 눈에 보입니다.

⑤ 백업을 한 번 돌려 보기 — 복원까지 해야 백업입니다 🟡 중급

덤프 뜨고 되살리기

# 🐘 PostgreSQL
pg_dump -U app -d appdb -Fc -f appdb.dump      # 압축 커스텀 포맷
createdb -U postgres appdb_restore
pg_restore -U postgres -d appdb_restore appdb.dump

# 🐬 MySQL
mysqldump -u app -p --single-transaction --routines appdb > appdb.sql
mysql -u root -p -e "CREATE DATABASE appdb_restore"
mysql -u root -p appdb_restore < appdb.sql

# ✒️ SQLite — 파일 복사가 아니라 이 명령을 쓰세요(쓰는 중에도 안전)
sqlite3 app.db ".backup app-backup.db"

# 🍃 MongoDB
mongodump --uri="mongodb://root:pw@localhost:27017" --out=dump/
mongorestore --uri="..." dump/
복원해 보지 않은 백업은 백업이 아닙니다. 용량이 0인 파일, 권한 없어 실패한 덤프, 버전이 안 맞는 복원 — 전부 실제로 흔합니다. 지금 한 번 복원까지 해 보면 그 뒤로 마음이 편합니다.

⑥ 최종 체크리스트 — 여섯 줄

항목확인 방법
☐관리자 비밀번호를 안전한 곳에 기록비밀번호 관리자
☐앱 전용 계정으로 접속되는지psql -U app
☐한글·이모지가 저장되고 다시 읽히는지INSERT 후 SELECT
☐타임존이 UTC 이고 앱에서 변환하는지SHOW timezone
☐샘플 데이터가 들어가 JOIN 이 되는지10줄짜리 조회
☐백업 → 복원을 한 번 완주다른 DB 이름으로
-- 한글·이모지 한 번에 확인하는 3줄
CREATE TABLE t_check (s text);
INSERT INTO t_check VALUES ('한글 테스트 😀 ①');
SELECT s, length(s) FROM t_check;   -- 그대로 나오면 문자셋 정상
여섯 개를 통과하면 이 DB 는 "쓸 수 있는" 상태입니다. 이제 q01 로 가서 SQL 을 배우거나, st11 로 가서 프로젝트에 붙이면 됩니다.
운영으로 갈 때 추가되는 것 — 정기 백업 자동화, 접속 로그·감사(z14), 복제본(p15·m14), 모니터링 지표와 알람(t15). 로컬에서는 필요 없지만 계정 분리와 문자셋·타임존은 로컬에서부터 똑같이 해 두는 것이 좋습니다. 개발과 운영이 다르면 그 차이가 곧 장애가 됩니다.
SETUP 10

GUI 없이 터미널로 — CLI 치트시트 5종

서버에 SSH 로 들어갔을 때, 도커 컨테이너 안에서 확인할 때, 스크립트로 자동화할 때는 GUI 를 쓸 수 없습니다. 다행히 각 DB 의 CLI 는 명령 대여섯 개면 일상 작업이 됩니다. 제품마다 이름만 다르고 하는 일은 같으니 나란히 놓고 외우는 게 빠릅니다.

CLI 한 세션의 흐름 — 메타 명령과 SQL 문은 다른 것입니다 ① 접속 명령 psql -h .. -U .. -d .. ② 프롬프트가 바뀜 appdb=> · mysql> · sqlite> ③-A 메타 명령 — 도구에게 \dt \d table \x · .tables .schema 세미콜론이 필요 없습니다 ③-B SQL 문 — DB 에게 SELECT * FROM member; 세미콜론이 없으면 실행되지 않습니다 가장 흔한 막힘 — 세미콜론을 빼먹어 프롬프트가 이어짐 표시로 바뀝니다 appdb-> 또는 -> 가 보이면 세미콜론을 치거나 Ctrl+C 로 취소하세요
CLI 가 무섭게 느껴지는 이유는 대개 세미콜론과 종료 방법을 몰라서입니다.

5종 나란히 — 같은 일을 하는 다른 이름

하고 싶은 일 🐘 psql🐬 mysql✒️ sqlite3⚡ redis-cli🍃 mongosh
접속 psql -h localhost -U app -d appdb mysql -h 127.0.0.1 -u app -p appdb sqlite3 app.db redis-cli -a pw mongosh "mongodb://…"
DB 목록 \lSHOW DATABASES; —(파일 하나)INFO keyspaceshow dbs
DB 바꾸기 \c appdbUSE appdb; .open other.dbSELECT 1use appdb
표 목록 \dtSHOW TABLES; .tablesSCAN 0 COUNT 100show collections
표 구조 \d memberDESC member; .schema memberTYPE keydb.member.findOne()
계정 목록 \duSELECT user,host FROM mysql.user; —ACL LISTdb.getUsers()
파일 실행 \i a.sqlSOURCE a.sql; .read a.sqlredis-cli < a.txtmongosh f.js
세로 출력 \x끝에 \G .mode line기본기본
시간 재기 \timing자동 표시 .timer on--latency자동 표시
도움말 \? · \h SELECThelp; .helpHELPhelp
나가기 \qexit; · \q .quitexitexit
규칙이 보이시나요 — PostgreSQL 은 역슬래시, SQLite 는 점으로 시작하는 것이 도구 명령이고, MySQL 은 도구 명령이 거의 없어 대부분 SQL(SHOW …;)로 처리합니다. 그래서 MySQL 에서는 세미콜론이 더 자주 필요합니다.

비대화식 실행 — 스크립트에 넣는 방법

한 줄 실행 · 파일 실행 · 결과 뽑기

# 🐘 psql — 명령 하나만 던지고 나오기
psql -U app -d appdb -c "SELECT count(*) FROM member;"
psql -U app -d appdb -f schema.sql
psql -U app -d appdb -At -c "SELECT name FROM member;"   # 헤더·정렬 없이 값만
psql -U app -d appdb -c "\copy member TO 'm.csv' CSV HEADER"

# 🐬 mysql
mysql -u app -p appdb -e "SELECT count(*) FROM member;"
mysql -u app -p appdb < schema.sql
mysql -u app -p --batch --skip-column-names appdb -e "SELECT name FROM member"

# ✒️ sqlite3
sqlite3 app.db "SELECT count(*) FROM member;"
sqlite3 -header -csv app.db "SELECT * FROM member;" > m.csv

# ⚡ redis-cli · 🍃 mongosh
redis-cli -a pw --no-auth-warning GET user:1
mongosh "mongodb://localhost:27017/appdb" --quiet --eval "db.member.countDocuments()"
비밀번호를 명령줄에 쓰지 마세요. 셸 히스토리와 프로세스 목록(ps)에 남습니다. psql 은 PGPASSWORD 환경 변수나 ~/.pgpass, mysql 은 ~/.my.cnf 를 씁니다. 도커라면 docker exec 가 가장 깔끔합니다.

출력이 지저분할 때 — 형식 바꾸기

-- 🐘 psql : 열이 많아 줄이 깨질 때
\x auto           -- 화면보다 넓으면 자동으로 세로 출력
\pset border 2    -- 표 테두리 강화
\pset null '(널)' -- NULL 을 눈에 띄게
\pset pager off   -- 결과가 more 로 넘어가는 게 싫을 때

-- ✒️ sqlite3 : 기본 출력이 파이프 구분이라 읽기 힘듭니다
.mode box         -- 표 모양 (3.33+)
.headers on
.width 4 20 30
.nullvalue (널)

-- 🐬 mysql : 한 행을 세로로
SELECT * FROM member WHERE id = 1\G
설정을 매번 치기 귀찮다면 시작 파일에 넣어 두세요 — psql 은 ~/.psqlrc, sqlite3 은 ~/.sqliterc, mysql 은 ~/.my.cnf 의 [mysql] 절입니다. 접속할 때마다 자동으로 적용됩니다.

알아 두면 편한 것 — CLI 를 아예 설치하지 않는 방법 🟡 중급

컨테이너 안의 CLI 빌려 쓰기

# 이미 컨테이너 안에 클라이언트가 들어 있습니다 — 내 PC 에 깔 필요가 없어요
docker exec -it app-pg    psql -U app -d appdb
docker exec -it app-mysql mysql -u app -p appdb
docker exec -it app-redis redis-cli -a devpass
docker exec -it app-mongo mongosh -u root -p devpass

# 원격 DB 에 붙을 때도 컨테이너를 일회용 클라이언트로 쓸 수 있습니다
docker run --rm -it postgres:18 \
  psql -h db.example.com -U app -d appdb

# 파일 주고받기
docker cp schema.sql app-pg:/tmp/schema.sql
docker exec app-pg psql -U app -d appdb -f /tmp/schema.sql
버전 충돌을 피하는 방법이기도 합니다 — 내 PC 의 psql 14 로 서버의 PostgreSQL 18 에 붙으면 일부 메타 명령이 경고를 냅니다. 같은 버전 이미지를 일회용으로 띄우면 그런 문제가 없습니다.

터미널에서 살아남는 다섯 가지 습관

상황대처
프롬프트가 -> 로 바뀌고 멈춤세미콜론 미입력 — ; 치거나 Ctrl+C
결과가 끝없이 흘러감q (pager 종료) · 애초에 LIMIT 20
따옴표를 잘못 열어 못 빠져나옴Ctrl+C → 다시 입력
긴 쿼리를 다시 치기 싫음↑ 방향키 · psql 은 \e 로 편집기 열기
실수로 UPDATE 를 WHERE 없이먼저 BEGIN; 을 치는 습관 → ROLLBACK;
마지막 항목이 제일 중요합니다. 운영 DB 에서 CLI 를 쓸 때는 무조건 BEGIN; 부터 칩니다. 결과 행 수를 보고 예상과 같으면 COMMIT;, 아니면 ROLLBACK;. 이 습관 하나가 사고를 대부분 막습니다(q17).
CLI 를 더 파고 싶다면 — psql 의 모든 메타 명령은 p03, SQLite 의 점 명령은 s03, Redis 명령은 r05, mongosh 는 n03 에 전용 섹션이 있습니다. 여기 표는 다섯 도구를 오갈 때 헷갈리지 않기 위한 지도입니다.
SETUP 11

로컬 개발 흐름 — 프로젝트마다 DB 를 띄우고 관리합니다

DB 를 한 번 깔아 놓고 모든 프로젝트가 같이 쓰면 처음엔 편하지만, 곧 스키마가 섞이고 버전이 안 맞고 지울 수 없는 상태가 됩니다. 요즘 방식은 프로젝트 폴더마다 compose 파일 하나를 두고, 스키마 변경은 마이그레이션 파일로 남기는 것입니다.

마이그레이션 — 스키마 변경을 파일로 남기고 순서대로 적용합니다 migrations/ 폴더 V1__create_member.sql V2__create_orders.sql V3__add_member_phone.sql V4__index_orders_date.sql — 깃에 커밋됩니다 migrate 마이그레이션 도구 Flyway · Alembic · Prisma 아직 안 한 것만 골라 실행 이력 테이블 flyway_schema_history V3 까지 적용됨 · 기록 실제 스키마 member · orders 테이블 인덱스 · 제약조건 팀원이 저장소를 새로 받으면 migrate 한 번으로 같은 스키마가 됩니다 운영 배포도 같은 파일 · 같은 순서 GUI 로 손으로 고치면 이력에 남지 않아 환경마다 달라집니다 "내 PC 에선 되는데" 의 원인 원칙 하나 — 스키마를 바꾸는 모든 SQL 은 파일로 남깁니다. 예외 없습니다 도구 사용법은 a09 에 더 자세히 있습니다
마이그레이션은 DB 의 깃입니다 — 언제 무엇이 바뀌었는지 파일로 남습니다.

프로젝트 폴더 구조 — 이 모양이면 충분합니다

폴더와 파일

my-project/
├─ compose.yml          ← DB 를 띄우는 방법 (st03)
├─ .env                 ← 비밀번호 · 포트 (깃 제외, st08)
├─ .env.example         ← 키 이름만 있는 견본 (깃 포함)
├─ .gitignore
├─ initdb/              ← 첫 기동 때 한 번 실행될 SQL (선택)
│   └─ 01-extension.sql
├─ migrations/          ← 스키마 변경 이력 ★
│   ├─ V1__create_member.sql
│   └─ V2__create_orders.sql
├─ src/
└─ README.md            ← "3줄이면 시작" 안내
# README 에 이 세 줄만 있으면 신입도 10분 안에 시작합니다
cp .env.example .env      # 비밀번호를 채웁니다
docker compose up -d      # DB 가 뜹니다
./gradlew flywayMigrate   # 스키마가 만들어집니다
포트가 겹칠 때 — 프로젝트 A 와 B 가 둘 다 5432 를 쓰면 나중에 띄운 쪽이 실패합니다. .env 의 DB_PORT 를 프로젝트마다 다르게(5432 · 5433 · 5434) 두고 compose 에서 "${DB_PORT}:5432" 로 쓰면 깔끔합니다.

컨테이너 이름도 프로젝트별로

# compose 는 "프로젝트 이름"으로 컨테이너·볼륨·네트워크를 묶습니다
# 기본값은 폴더 이름입니다 — 폴더 이름이 같으면 충돌합니다

# .env 에 넣어 고정하는 방법
COMPOSE_PROJECT_NAME=shop-api

# 또는 명령에서
docker compose -p shop-api up -d

# 지금 뭐가 떠 있는지 프로젝트 단위로 보기
docker compose ls
docker ps --filter "label=com.docker.compose.project=shop-api"

# 이 프로젝트 것만 통째로 정리
docker compose -p shop-api down -v
노트북 자원이 아깝다면 — 모든 프로젝트의 DB 를 항상 띄워 둘 필요는 없습니다. 작업할 프로젝트만 up -d, 끝나면 stop. 볼륨은 남아 있으니 데이터는 그대로입니다.

마이그레이션 도구 — 설치와 첫 명령

도구주 사용처설치첫 두 명령
Flyway☕ Java · 언어 무관 Gradle 플러그인 org.flywaydb.flyway · CLI 는 brew install flyway flyway info
flyway migrate
Liquibase☕ Java · 엔터프라이즈 Gradle/Maven 플러그인 · brew install liquibase liquibase status
liquibase update
Alembic🐍 Python (SQLAlchemy) pip install alembic alembic revision --autogenerate -m "init"
alembic upgrade head
Prisma Migrate🟨 Node · TS npm i -D prisma npx prisma migrate dev --name init
npx prisma migrate deploy
Drizzle Kit🟨 Node · TS (가벼움) npm i -D drizzle-kit npx drizzle-kit generate
npx drizzle-kit migrate
EF Core🟣 C# · .NET dotnet tool install -g dotnet-ef dotnet ef migrations add Init
dotnet ef database update
golang-migrateGo · 언어 무관 CLI brew install golang-migrate migrate create -ext sql -dir migrations init
migrate -path migrations -database "$DATABASE_URL" up
sqlx-cli🦀 Rust cargo install sqlx-cli sqlx migrate add init
sqlx migrate run
도구를 고르는 기준은 단순합니다 — 팀이 쓰는 언어의 생태계에 있는 것을 쓰세요. Java 면 Flyway, 파이썬이면 Alembic, TS 면 Prisma 또는 Drizzle 입니다. 여러 언어가 섞인 팀이라면 언어 중립적인 Flyway CLI 나 golang-migrate 가 무난합니다. 자세한 운영 전략(롤백·무중단 배포)은 a09 에 있습니다.

테스트용 DB — Testcontainers 로 진짜 DB 를 씁니다

가짜 DB 를 쓰지 않는 이유

예전에는 테스트에 H2 같은 인메모리 DB 를 썼습니다. 빠르지만 문법과 동작이 실제 DB 와 미묘하게 달라서 테스트는 통과하는데 운영에서 터지는 일이 생겼습니다. Testcontainers 는 테스트가 시작될 때 진짜 DB 컨테이너를 띄우고 끝나면 지웁니다.

언어설치
☕ Java · 🟠 KotlintestImplementation("org.testcontainers:postgresql")
🐍 Pythonpip install testcontainers[postgres]
🟨 Nodenpm i -D testcontainers
🟣 .NETdotnet add package Testcontainers.PostgreSql
🦀 Rustcargo add --dev testcontainers
전제 조건은 도커가 돌고 있을 것 하나뿐입니다. CI(GitHub Actions 등)에서도 도커가 기본 제공되므로 그대로 돕니다.

파이썬 예 — 테스트 한 개가 DB 를 통째로 씁니다

import pytest, psycopg
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def db_url():
    # 테스트 세션 동안만 postgres:18 컨테이너가 뜹니다
    with PostgresContainer("postgres:18") as pg:
        yield pg.get_connection_url().replace(
            "postgresql+psycopg2", "postgresql")

def test_member_insert(db_url):
    with psycopg.connect(db_url) as conn, conn.cursor() as cur:
        cur.execute("CREATE TABLE member(id serial PRIMARY KEY, name text)")
        cur.execute("INSERT INTO member(name) VALUES (%s)", ("김현우",))
        cur.execute("SELECT count(*) FROM member")
        assert cur.fetchone()[0] == 1
        # 테스트가 끝나면 컨테이너째 사라집니다 — 뒷정리 코드가 필요 없습니다
느리지 않나요 — 컨테이너 기동에 2~5초가 듭니다. 그래서 scope="session" 으로 전체 테스트에 하나만 띄우고, 테스트마다 트랜잭션을 열었다 롤백하는 방식을 씁니다(a12).
여기까지가 "개발 환경"의 완성형입니다. 저장소를 받으면 compose up 으로 DB 가 뜨고, migrate 로 스키마가 맞춰지고, 테스트는 자기 컨테이너를 띄워 돕니다. 사람마다 다른 환경 때문에 생기는 문제가 거의 사라집니다.
SETUP 12

설치 문제 사전 — 증상 → 원인 → 해결 한 장

설치에서 만나는 오류는 종류가 생각보다 적습니다. 스무 가지 안쪽이고, 메시지만 제대로 읽으면 원인이 거의 특정됩니다. 이 섹션은 증상을 그대로 검색해 찾는 사전입니다 — 위에서부터 읽지 말고 필요한 줄만 보세요.

접속이 안 될 때 — 위에서부터 네 질문으로 좁힙니다 ① DB 프로세스가 떠 있나? docker ps · services.msc · systemctl status 아니오 기동 자체가 실패한 것입니다 docker logs · journalctl -u · 이벤트 뷰어 확인 ② 포트가 열려 있나? Test-NetConnection localhost -Port 5432 아니오 포트 충돌 또는 방화벽 차단 netstat -ano 로 누가 쓰는지 · st06 의 인바운드 규칙 ③ 인증이 통과하나? 비밀번호 · 계정의 접속 허용 주소 · pg_hba.conf 아니오 password authentication failed Access denied for user — host 부분이 원인입니다 ④ 그 DB · 표에 권한이 있나? GRANT 가 되어 있는지 · 스키마 USAGE 까지 아니오 permission denied for table st09 의 GRANT · ALTER DEFAULT PRIVILEGES 다시 네 질문을 모두 통과하면 접속됩니다 — 순서를 건너뛰면 원인이 섞여 더 오래 걸립니다
해결의 절반은 어디서 막혔는지 아는 것입니다 — 찍어서 고치지 마세요.

증상 사전 ① — 설치 · 기동

증상 · 메시지원인해결
Docker Desktop 이 계속 starting
고래 아이콘이 멈추지 않음
WSL2 미설치 · 가상화 꺼짐 · WSL 커널 구버전 관리자 PowerShell 에서 wsl --install → wsl --update → 재부팅. 그래도 안 되면 작업 관리자 → 성능 → CPU 에서 가상화: 사용 확인, "사용 안 함"이면 BIOS 에서 VT-x / SVM 활성화(st02)
Ports are not available: bind: address already in use 같은 포트를 다른 프로그램이 이미 쓰는 중. 예전에 깐 MySQL 서비스가 살아 있는 경우가 압도적으로 많습니다 netstat -ano | findstr :3306 로 PID 확인 → 작업 관리자에서 그 PID 를 확인. 서비스면 services.msc 에서 중지 · 시작 유형을 수동으로. 아니면 도커 쪽을 -p 3307:3306 으로 옮깁니다
MySQL 설치 마법사가 "이미 설치됨" 으로 멈춤 XAMPP · 옛 MySQL · 워크벤치 잔재. 서비스와 데이터 폴더가 남아 있음 sc query state= all | findstr -i mysql 로 서비스 확인 → sc delete MySQL80 · C:\ProgramData\MySQL 폴더 정리 후 재설치
PostgreSQL 서비스가 즉시 종료
(RHEL 계열)
데이터 디렉터리 초기화를 안 함 sudo postgresql-setup --initdb 후 systemctl start postgresql. 로그는 journalctl -u postgresql -n 50
컨테이너가 떴다가 바로 Exited (1) 필수 환경 변수 누락(비밀번호), 볼륨 안 데이터와 이미지 버전 불일치 docker logs 컨테이너명 첫 20줄에 이유가 그대로 있습니다. 버전 불일치면 docker compose down -v 로 볼륨 초기화(데이터 삭제 주의)
Oracle 설치가 용량 부족으로 실패 이미지·설치본이 수 GB, 압축 해제까지 20GB 이상 필요 디스크를 비우거나 도커의 Disk image location 을 큰 드라이브로 이동. 학습 목적이면 PostgreSQL 로 대체하고 오라클 방언만 o05 로 익히는 것도 방법입니다
SQL Server 에 GUI 도구가 접속 불가 TCP/IP 프로토콜이 기본 사용 안 함 SQL Server 구성 관리자 → 네트워크 구성 → 프로토콜 → TCP/IP 사용 → 서비스 재시작. 명명된 인스턴스면 SQL Browser 서비스도 시작

증상 사전 ② — 접속 · 인증

증상 · 메시지원인해결
connection refused
(즉시 실패)
그 주소·포트에서 듣고 있는 프로세스가 없음 DB 가 떠 있는지 먼저 확인. 떠 있다면 listen_addresses/bind-address 가 localhost 로 묶여 있을 수 있습니다(st06). 도커라면 -p 매핑을 빠뜨렸는지 docker ps 의 PORTS 열 확인
timed out
(한참 뒤 실패)
방화벽이 조용히 버리는 중. 또는 IP 자체가 틀림 연결 거부와 시간 초과는 다릅니다. 시간 초과면 거의 방화벽입니다 — st06 의 인바운드 규칙. 사내망이면 스위치/보안 정책도 확인
password authentication failed for user "app" 비밀번호 오타 · 특수문자 URL 인코딩 · 볼륨에 남은 옛 비밀번호 도커에서 POSTGRES_PASSWORD 를 바꿔도 볼륨이 이미 있으면 무시됩니다. 첫 기동 때 정해진 비번이 계속 쓰이는 것 — ALTER USER app WITH PASSWORD '…' 로 바꾸거나 볼륨을 지우고 다시 만듭니다
no pg_hba.conf entry for host "192.168.0.55" 그 IP 대역을 허용하는 줄이 없음 pg_hba.conf 에 host appdb app 192.168.0.0/24 scram-sha-256 추가 → SELECT pg_reload_conf();. 파일 위치는 SHOW hba_file; 로 확인
Access denied for user 'app'@'172.18.0.3' 비밀번호가 아니라 host 가 안 맞는 것입니다. MySQL 계정은 이름+host 조합이 곧 계정 SELECT user, host FROM mysql.user; 로 확인 → CREATE USER 'app'@'%' IDENTIFIED BY '…' 후 GRANT 다시(st09). 도커 대역만 열려면 'app'@'172.%'
FATAL: database "appdb" does not exist DB 를 안 만들었거나 이름 오타. 도커에서 POSTGRES_DB 를 빼면 계정 이름과 같은 DB 만 생깁니다 psql -U postgres -c "CREATE DATABASE appdb" 또는 \l 로 실제 이름 확인
permission denied for table member 접속은 됐는데 GRANT 가 없음. 나중에 만든 테이블에 권한이 안 붙는 경우도 흔합니다 GRANT SELECT … ON ALL TABLES IN SCHEMA public TO app; 와 ALTER DEFAULT PRIVILEGES 둘 다(st09)
컨테이너 안 앱에서 localhost:5432 접속 실패 컨테이너의 localhost 는 자기 자신 compose 의 서비스 이름(db:5432)으로 바꿉니다. 내 PC 를 가리켜야 하면 host.docker.internal(st03)

증상 사전 ③ — 데이터 · 문자 · 시간

증상원인해결
한글이 ??? 또는 김현우 로 보임 DB·테이블·연결 중 한 군데라도 UTF-8 이 아님 세 곳을 모두 맞춥니다 — ① DB/테이블 utf8mb4 ② 연결 문자열의 characterEncoding=UTF-8(JDBC) ③ 터미널 코드 페이지(윈도우 chcp 65001). 자세히는 m04
이모지만 저장 실패
Incorrect string value: '\xF0\x9F…'
MySQL 의 옛 utf8 은 3바이트라 이모지(4바이트)를 못 담습니다 ALTER DATABASE appdb CHARACTER SET utf8mb4; 와 테이블·열 각각 CONVERT TO CHARACTER SET utf8mb4
시간이 정확히 9시간 어긋남 UTC 와 KST 사이에서 변환이 한 번 더 되거나 덜 됨 저장은 UTC 로 통일(st09). JDBC 는 URL 에 serverTimezone=UTC, PostgreSQL 은 timestamptz 사용. 앱·DB·컨테이너의 TZ 를 한 값으로 맞추는 것이 가장 확실합니다(m08)
정렬이 이상함 (가나다순이 아님) collation 이 바이트 순서(C)이거나 다른 언어 규칙 PostgreSQL 은 ORDER BY name COLLATE "ko-KR-x-icu", MySQL 은 utf8mb4_0900_ai_ci 계열 사용
도커 볼륨에서 Permission denied 바인드 마운트한 호스트 폴더의 소유자·권한이 컨테이너 안 사용자와 안 맞음 가급적 이름 있는 볼륨(pgdata:)을 쓰세요 — 이 문제가 아예 없습니다. 굳이 폴더를 붙여야 하면 user: "1000:1000" 지정 또는 chown -R 999:999 ./data(PostgreSQL 이미지의 postgres UID 는 보통 999)
WSL 안에서 DB 가 느림 윈도우 파일 시스템(/mnt/c/…)에 데이터를 두면 I/O 가 크게 느립니다 프로젝트와 볼륨을 WSL 내부 경로(~/projects)에 두세요. 체감 차이가 큽니다
too many connections 커넥션 풀 설정이 없거나 연결을 안 닫는 코드 풀 크기를 정하고(t13) max_connections 확인. 개발 중이라면 IDE·GUI 도구가 붙잡고 있는 연결도 세어 보세요
SQLite database is locked 기본 저널 모드에서 쓰기가 겹침 PRAGMA journal_mode=WAL; · busy_timeout 설정. 자세히는 s11

막혔을 때 쓰는 진단 명령 모음 🟡 중급

누가 이 포트를 쓰고 있나

# --- Windows ---
netstat -ano | findstr :5432
tasklist /FI "PID eq 1234"
Get-Process -Id (Get-NetTCPConnection -LocalPort 5432).OwningProcess

# 서비스 목록에서 DB 찾기
Get-Service | Where-Object { $_.Name -match "mysql|postgres|mssql" }

# --- macOS / Linux ---
sudo lsof -i :5432
sudo ss -ltnp | grep 5432
sudo fuser -k 5432/tcp        # ★ 그 프로세스를 강제 종료 (신중히)

# --- 도커 ---
docker ps --format "table {{.Names}}\t{{.Ports}}\t{{.Status}}"
docker port app-pg
docker logs --tail 50 app-pg
포트 충돌은 옮기는 게 빠릅니다. 원인 프로그램을 찾아 없애기보다 도커 쪽 호스트 포트를 5433 으로 바꾸고 연결 문자열만 고치는 편이 훨씬 빨리 끝나는 경우가 많습니다.

DB 쪽에서 현재 상태 보기

-- 🐘 PostgreSQL
SHOW config_file;            -- postgresql.conf 위치
SHOW hba_file;               -- pg_hba.conf 위치
SHOW listen_addresses;
SELECT count(*) FROM pg_stat_activity;          -- 현재 연결 수
SELECT usename, client_addr, state FROM pg_stat_activity;

-- 🐬 MySQL
SHOW VARIABLES LIKE 'bind_address';
SHOW VARIABLES LIKE 'max_connections';
SHOW PROCESSLIST;
SELECT user, host FROM mysql.user;
SELECT @@character_set_database, @@collation_database, @@time_zone;
설정 파일을 못 찾겠을 때 — 위의 SHOW config_file; 처럼 DB 에게 직접 물어보는 게 가장 정확합니다. 배포판·도커·OS 마다 경로가 제각각이라 검색 결과를 믿으면 엉뚱한 파일을 고칩니다.
해결이 오래 걸리는 방식
  • 메시지를 안 읽고 블로그 글부터 검색
  • 설정 파일 여러 개를 동시에 고침 — 무엇이 효과가 있었는지 모름
  • 안 되면 재설치 — 같은 자리에서 또 막힙니다
  • 0.0.0.0/0 허용, 방화벽 끄기로 "일단 되게" 만들기
빠르게 끝나는 방식
  • 메시지를 그대로 복사해서 위 표에서 찾기
  • 위 진단 흐름의 네 질문을 순서대로 — 한 번에 하나만 바꿉니다
  • 고친 뒤 reload/restart 를 했는지 확인(안 해서 안 되는 경우가 절반)
  • 로그를 봅니다 — docker logs · journalctl -u · 이벤트 뷰어
마지막 조언 — 30분 넘게 같은 곳에서 막혔다면 도커로 새로 하나 띄우고 넘어가세요. 설치 문제에 하루를 쓰는 것보다 SQL 을 하루 배우는 게 훨씬 값집니다. 네이티브 설치는 나중에 필요해졌을 때 st04 로 돌아와도 늦지 않습니다.
이 탭을 다 읽었다면 — DB 가 떠 있고, 도구로 붙었고, 코드에서도 연결됩니다. 이제 c08 에서 샘플 스키마를 만들고 q01 부터 SQL 을 시작하세요. 제품별 깊은 설정은 p02(PostgreSQL) · m02(MySQL) · o02(Oracle) · s02(SQLite) 에서 이어집니다.
OLAP · COLUMNAR · CLICKHOUSE · DUCKDB · PARQUET · VECTOR · RAG · 2026 기준

OLTP 말고
그 옆 —
분석과 벡터 검색

지금까지의 탭은 전부 OLTP였습니다 — 한 건을 정확히 넣고 한 건을 빨리 찾는 세계. 그런데 2026년 실무에서 우리를 부르는 일의 절반은 그 옆에 있습니다. "작년 매출을 지역·상품별로 쪼개 줘"(분석)와 "이 질문과 뜻이 비슷한 문서를 찾아 줘"(벡터 검색)입니다. 둘 다 B-Tree 인덱스와 행 저장으로는 잘 안 되는 일이고, 그래서 컬럼 지향 DB와 벡터 인덱스라는 다른 도구가 생겼습니다. 이 탭은 그 둘을 원리부터 실제로 도는 코드까지 훑습니다. 끝나면 DuckDB 로 로컬 분석을 하고, ClickHouse 로 로그를 집계하고, pgvector 로 RAG 검색을 만들 수 있습니다.

13SECTIONS
2026기준 버전
OLAP+ VECTOR
100%복붙해서 도는 예제
OLAP 01

OLTP vs OLAP — 행 저장과 컬럼 저장

같은 SQL, 같은 데이터인데 한쪽은 40초, 한쪽은 0.3초입니다. 마법이 아니라 디스크에서 읽는 바이트 수가 100배 다르기 때문입니다.

SELECT sum(amount) FROM events — 한 열만 필요할 때 실제로 읽는 것 행 저장 — 한 행이 통째로 붙어 있음 id ts country amount id ts country amount id ts country amount 붉은 칸만 필요한데 회색 칸까지 같은 페이지에 실려 옵니다 디스크는 페이지 단위로 읽으므로 버릴 수가 없습니다 컬럼 저장 — 같은 열끼리 붙어 있음 id ts country amount 1억 개 1억 개 1억 개 1억 개 안 읽음 안 읽음 안 읽음 이것만 amount 블록만 연속으로 읽습니다 — 나머지 열은 파일을 아예 열지도 않습니다 1억 행 × 200B = 약 20 GB 필요한 것은 8B 짜리 amount 뿐인데도 1억 × 8B = 800 MB → 압축 120 MB 같은 종류 값이라 압축이 아주 잘 듭니다 여기에 벡터화 실행이 더해집니다 — 값을 하나씩이 아니라 수천 개씩 묶어 처리합니다
컬럼 DB 가 빠른 첫 번째 이유는 알고리즘이 아니라 안 읽는 것입니다.

두 세계는 요구사항 자체가 다릅니다

항목OLTP (지금까지의 탭)OLAP (이 탭)
대표 쿼리 SELECT * FROM orders WHERE id = 91238
한 행을 정확히
SELECT country, sum(amount) … GROUP BY 1
수억 행을 훑어 수십 행으로
건드리는 열대부분의 열(행 전체)전체 40개 중 2~4개
동시성초당 수천 건, 짧고 많이초당 수 건, 길고 무겁게
쓰기 패턴한 건씩 INSERT · UPDATE · DELETE수만 건씩 배치 적재, 수정은 거의 안 함
인덱스B-Tree — 한 행으로 바로 점프정렬 키 + 최소·최대 통계 — 덩어리째 건너뛰기
저장행 지향(row store)컬럼 지향(column store)
대표 제품PostgreSQL · MySQL · OracleClickHouse · DuckDB · BigQuery · Snowflake

같은 쿼리를 두 DB 에서 — 실제 숫자

🐘 PostgreSQL 18 — 1억 행

EXPLAIN (ANALYZE, BUFFERS)
SELECT country, count(*), sum(amount)
FROM   events
WHERE  ts >= '2026-01-01'
GROUP  BY country;

-- Parallel Seq Scan on events
--   Buffers: shared read=2,441,406      ← 약 19 GB
--   Workers Launched: 7
-- Execution Time: 38,512 ms             ← 38 초
병렬 워커 8개를 붙여도 19GB 를 읽는 일 자체는 사라지지 않습니다. 디스크 대역폭이 천장입니다.

📊 ClickHouse — 같은 1억 행

SELECT country, count(), sum(amount)
FROM   events
WHERE  ts >= '2026-01-01'
GROUP  BY country;

-- 3 rows in set. Elapsed: 0.221 sec.
-- Processed 100.00 million rows,
--   1.20 GB (452.49 million rows/s.)   ← 읽은 양
1.2GB 만 읽었습니다 — ts·country·amount 세 열뿐이고, 그마저 압축된 상태입니다.
"그럼 다 ClickHouse 쓰면 되는 거 아닌가요?" — 아닙니다. 같은 데이터로 SELECT * FROM events WHERE id = 91238 을 하면 PostgreSQL 은 0.2 ms, ClickHouse 는 수십~수백 ms 가 걸립니다. 컬럼 DB 는 한 행을 다시 조립하려면 모든 열 파일에서 같은 위치를 찾아 모아야 하기 때문입니다. 게다가 UPDATE·DELETE 가 사실상 없고(ClickHouse 의 ALTER … UPDATE 는 비동기 재작성 작업입니다), 외래키·트랜잭션·유니크 제약이 없습니다. 주문·결제를 여기에 넣으면 안 됩니다.

왜 이렇게까지 차이가 나나 — 세 가지 이유 🟡 중급

①
읽는 양 (I/O 삭감) 필요한 열 파일만 엽니다. 40개 열 중 3개면 읽는 양이 1/13 로 줄어듭니다.
▼
②
압축률 같은 열에는 같은 종류 값만 있습니다. 국가 코드 열은 값이 200가지뿐이라 사전 인코딩으로 10~50배, 정렬된 타임스탬프는 델타 인코딩으로 20배 이상 줄어듭니다. 행 저장에서는 int·문자열·날짜가 뒤섞여 있어 이렇게 못 줄입니다.
▼
③
벡터화 실행 (SIMD) 행 저장 엔진은 "행 하나 꺼내기 → 조건 검사 → 다음 행" 을 1억 번 반복합니다(볼케이노 모델). 컬럼 엔진은 같은 타입 값 8192개를 한 배열로 꺼내 CPU 의 SIMD 명령으로 한 번에 더합니다. 함수 호출 오버헤드가 1억 번에서 1만 2천 번으로 줄어듭니다.
PostgreSQL 도 이 방향으로 오고 있습니다. 18 버전 기준 columnar 계열 확장(Hydra·Citus 계열)과 pg_duckdb 처럼 PostgreSQL 안에서 DuckDB 엔진을 돌리는 확장이 나와 있습니다. 다만 성숙도·운영 편의를 감안하면 2026년 현재 실무의 기본 선택은 "OLTP 는 PostgreSQL, 분석은 별도 엔진" 입니다.

언제 분석 DB 를 따로 두나 🟡 중급

분석 엔진을 따로 둘 것인가 — 판단 순서 분석 쿼리가 느리다 운영 DB 가 같이 흔들린다 데이터가 1천만 행 미만? 그리고 열이 20개 이하? 그대로 두세요 읽기 복제본 + 인덱스 아니오 — 더 크다 파일·1인 분석 CSV·Parquet 를 내가 혼자 파이썬에서 돌린다 계속 쌓이는 로그 여러 사람이 대시보드로 동시에 조회한다 시간축 메트릭 보관 정책·다운샘플링이 핵심이다 DuckDB (v02) ClickHouse (v04·v05) TimescaleDB 계열 (v06) 셋 다 아니면 대개 답은 "아직 필요 없다" 입니다 — 도입 비용이 조회 시간보다 비쌉니다
분석 DB 는 운영 DB 를 지키기 위해 두는 것이기도 합니다 — 무거운 집계가 OLTP 를 잡아먹지 않게.
가장 흔한 실수 두 가지.
① 운영 DB 에서 바로 집계 — 무거운 GROUP BY 가 버퍼 풀을 다 밀어내(🧬 z02) 정작 주문 조회가 느려집니다. 읽기 복제본으로 옮기는 것만으로도 대부분 해결됩니다(🐘 p15).
② 데이터 100만 행에 ClickHouse 도입 — 그 규모는 PostgreSQL 이 인덱스 하나로 이깁니다. 운영해야 할 시스템만 하나 늘어납니다.
OLAP 02

DuckDB — 분석용 SQLite

서버가 없습니다. pip install duckdb 한 줄이면 끝이고, 내 프로그램 안에서 돕니다. SQLite 가 OLTP 를 그렇게 만들었다면, DuckDB 는 OLAP 을 그렇게 만들었습니다.

DuckDB 는 서버가 아니라 내 프로세스 안의 라이브러리입니다 보통의 방법 events.parquet 8 GB pandas 로 전부 메모리 적재 MemoryError 또는 스왑 지옥 8GB 파일이 파이썬 객체로 펼쳐지면 30GB 가 되기도 합니다 DuckDB events.parquet 디스크에 그대로 복사·적재 없음 내 파이썬 프로세스 duckdb 라이브러리가 그 안에 있음 필요한 열·행 그룹만 읽어 벡터화 처리 결과 DataFrame 수십 행 메모리 몇 KB 네트워크도 서버 프로세스도 없습니다 — 함수 호출 한 번의 거리입니다
"데이터를 DB 로 옮긴다"가 아니라 "DB 를 데이터가 있는 곳으로 가져간다" 입니다.

설치 — 정말 한 줄입니다

🐍 파이썬 · CLI

# 파이썬 (3.9+)
pip install duckdb

# CLI — 단일 실행 파일 하나, 의존성 없음
# macOS
brew install duckdb
# Windows (winget) / Linux
winget install DuckDB.cli
curl -fsSL https://install.duckdb.org | sh

duckdb              # 메모리 DB 로 시작
duckdb mydata.db    # 파일 DB 로 시작
# .help  .tables  .schema  .quit   ← SQLite 와 닮은 점 명령

🟨 Node · 그 밖

# Node.js (Node 24 LTS 기준)
npm install @duckdb/node-api

# Java / Go / Rust / R / C++ 바인딩 모두 있습니다
# JDBC:  org.duckdb:duckdb_jdbc
# 브라우저(WASM):  @duckdb/duckdb-wasm
#   → 서버 없이 브라우저에서 Parquet 분석이 됩니다
DuckDB 는 1.x 안정 버전대입니다. 파일 포맷 하위 호환이 보장되므로 버전을 올려도 예전 .db 를 그대로 읽습니다.

파일을 그대로 쿼리합니다 — 적재 단계가 없습니다

-- ① CSV 를 테이블처럼 (헤더·타입 자동 추론)
SELECT country, count(*) FROM 'sales_2026.csv' GROUP BY 1;

-- ② Parquet — 폴더 전체를 와일드카드로
SELECT date_trunc('month', ts) AS m, sum(amount)
FROM   read_parquet('s3://bucket/events/year=2026/**/*.parquet')
GROUP  BY 1 ORDER BY 1;

-- ③ JSON (줄바꿈 구분 JSONL 도 됩니다)
SELECT * FROM read_json_auto('logs.jsonl') WHERE level = 'ERROR';

-- ④ 여러 형식을 한 쿼리에서 조인 — 이게 DuckDB 의 진짜 매력입니다
SELECT u.name, sum(e.amount)
FROM   read_parquet('events/*.parquet') e
JOIN   'users.csv' u ON u.id = e.user_id      -- CSV 와 Parquet 를 그냥 조인
GROUP  BY 1 ORDER BY 2 DESC LIMIT 10;

-- ⑤ 원격 PostgreSQL 을 붙여 같이 조회 (확장)
INSTALL postgres; LOAD postgres;
ATTACH 'host=db.internal dbname=shop user=ro' AS pg (TYPE postgres, READ_ONLY);
SELECT * FROM pg.public.orders LIMIT 5;
파일 경로가 곧 테이블 이름입니다. FROM 'x.parquet' 처럼 따옴표 친 경로를 FROM 절에 그대로 쓸 수 있고, read_parquet·read_csv·read_json_auto 로 옵션을 줄 수도 있습니다. s3://·https:// 도 됩니다 — 필요한 행 그룹만 범위 요청(HTTP Range)으로 내려받습니다. 8GB 파일에서 100MB 만 받아 오는 일이 실제로 일어납니다.

메모리 모드와 파일 모드

🐍 파이썬 — 세 가지 쓰는 법

import duckdb

# ① 메모리 DB — 끝나면 사라짐 (가장 흔함)
con = duckdb.connect()          # 또는 ':memory:'

# ② 파일 DB — 테이블·뷰가 남습니다
con = duckdb.connect('warehouse.duckdb')
con.sql("CREATE TABLE ev AS SELECT * FROM 'events.parquet'")

# ③ 모듈 함수로 바로 (연결 객체 없이)
duckdb.sql("SELECT 42").show()

# 메모리 상한과 스레드 지정
con.sql("SET memory_limit = '8GB'")
con.sql("SET threads = 8")
con.sql("SET temp_directory = '/tmp/duck'")   # 넘치면 디스크로 흘림
메모리보다 큰 데이터도 처리됩니다. 정렬·해시 조인·집계 모두 디스크 스필을 지원합니다. 8GB 램에서 100GB Parquet 집계가 (느리지만) 끝납니다.

DataFrame 을 SQL 로 조회

import duckdb, pandas as pd, polars as pl

df = pd.read_csv('sales.csv')

# 파이썬 지역변수 이름을 그대로 테이블처럼 씁니다
duckdb.sql("""
  SELECT country, sum(amount) AS total
  FROM   df                     -- ← pandas DataFrame
  WHERE  amount > 0
  GROUP  BY 1 ORDER BY 2 DESC
""").df()                       # 결과를 다시 pandas 로

# Polars · PyArrow 도 동일하게 인식합니다
pldf = pl.read_parquet('events.parquet')
duckdb.sql("SELECT count(*) FROM pldf").pl()

# 결과 변환기: .df() pandas · .pl() polars
#              .arrow() Arrow · .fetchall() 튜플 리스트
복사가 일어나지 않습니다. Arrow 메모리 포맷을 공유해 같은 메모리를 그대로 읽습니다(제로 카피).

분석에 쓰기 좋은 문법들 🟡 중급

-- 열 이름을 패턴으로 고르기
SELECT COLUMNS('amount_.*') FROM sales;          -- amount_ 로 시작하는 모든 열
SELECT max(COLUMNS(*)) FROM sales;               -- 모든 열의 최댓값을 한 번에

-- 일부 열만 빼기 / 바꾸기
SELECT * EXCLUDE (password_hash, ssn) FROM users;
SELECT * REPLACE (upper(country) AS country) FROM sales;

-- FROM 을 먼저 써도 됩니다 (자동완성이 편해집니다)
FROM sales SELECT country, sum(amount) GROUP BY 1;
FROM sales;                                       -- SELECT * 생략 가능

-- GROUP BY ALL / ORDER BY ALL — 열 번호 세는 일을 없앱니다
SELECT country, product, sum(amount) FROM sales GROUP BY ALL;

-- 리스트·구조체·중첩 JSON 을 그대로 다룹니다
SELECT payload.user.id, len(payload.items) FROM read_json_auto('ev.json');

-- 결과를 바로 파일로 (형식은 확장자로 판단)
COPY (SELECT * FROM sales WHERE year = 2026) TO 'out.parquet';
COPY sales TO 'part' (FORMAT parquet, PARTITION_BY (country));

언제 DuckDB 하나로 충분한가

✓ DuckDB 로 끝나는 상황
· 데이터가 파일(들)로 있고 수 GB ~ 수백 GB
· 분석하는 사람이 한 명 또는 배치 잡 하나
· 결과만 필요하고 데이터는 원본에 그대로 두고 싶다
· 노트북·CI·Lambda 안에서 돌려야 한다
· 파이썬 파이프라인 중간에 SQL 이 편한 구간이 있다
· "일단 이 CSV 좀 봐 줘" 가 하루에 세 번 온다
✗ 여기서부터는 ClickHouse 쪽
· 여러 사람이 동시에 대시보드로 조회한다
· 데이터가 실시간으로 계속 들어온다 (초당 수만 행)
· 수십 TB 이상이고 항상 떠 있어야 한다
· 권한·감사·백업 같은 운영 체계가 필요하다
· 사전 집계(머티리얼라이즈드 뷰)로 응답을 지켜야 한다
DuckDB 는 동시 쓰기에 약합니다 — SQLite 와 같은 이유입니다(✒️ s08). 한 파일 DB 에 쓰기 프로세스는 하나만 붙을 수 있습니다. 웹 서버 여러 대가 같은 .duckdb 파일을 붙잡는 구조는 만들지 마세요. 읽기 전용이면 여러 프로세스가 동시에 열 수 있습니다 (duckdb.connect(path, read_only=True)).
실무 팁 — "임시 분석"의 표준 도구가 됐습니다. 운영 DB 에서 무거운 집계를 돌리는 대신 ① COPY … TO 's3://…/dump.parquet' 로 하루치를 떨구고 ② 노트북에서 DuckDB 로 마음껏 파 보고 ③ 결론이 나면 그때 정식 파이프라인(v12)을 만듭니다. 운영 DB 를 한 번도 건드리지 않고 분석이 끝납니다.
OLAP 03

Parquet · 컬럼 포맷의 속

Parquet 는 "압축된 CSV" 가 아닙니다. 파일 안에 통계와 색인이 들어 있는 작은 데이터베이스에 가깝습니다. 구조를 알면 왜 WHERE 하나로 파일의 90%를 건너뛰는지 이해됩니다.

events.parquet — WHERE ts >= '2026-03-01' 일 때 실제로 읽는 부분 행 그룹 1 · ts 범위 01-01 ~ 01-31 id 청크 ts 청크 min/max country 청크 amount 청크 행 그룹 2 · ts 범위 02-01 ~ 02-28 통계가 조건과 안 겹칩니다 — 통째로 건너뜁니다 (읽기 0 바이트) 행 그룹 3 · ts 범위 03-01 ~ 03-31 — 여기만 읽습니다 id — 안 씀 ts 청크 — 읽음 country — 안 씀 amount 청크 — 읽음 푸터(Footer) — 파일 맨 끝 스키마 · 각 청크의 파일 내 위치 · 행 그룹별 min·max·null 개수 · 압축 코덱 읽는 순서: ① 푸터만 읽고 → ② 건너뛸 행 그룹을 정하고 → ③ 남은 청크만 범위 요청으로 읽습니다
건너뛰기의 정체는 인덱스가 아니라 min/max 통계입니다 — 그래서 정렬해서 저장하면 극적으로 잘 듭니다.

구조를 용어로 정리하면

단위설명실무 감각
파일하나의 .parquet. 맨 끝에 푸터(메타데이터)가 붙습니다128MB ~ 1GB 가 적당
행 그룹
(Row Group)
행을 수십만 개씩 묶은 덩어리. 건너뛰기의 최소 단위기본 100만 행 · 압축 후 128MB 목표
컬럼 청크
(Column Chunk)
한 행 그룹 안에서 한 열의 값들만 모은 연속 영역여기 단위로 읽습니다
페이지
(Page)
청크를 다시 나눈 압축·인코딩 단위기본 1MB
푸터스키마 + 모든 청크의 오프셋 + 행 그룹별 min·max·null 수원격 파일도 푸터만 먼저 받습니다
왜 통계로 건너뛸 수 있나 — 논리는 단순합니다. 조건이 ts >= '2026-03-01' 인데 어떤 행 그룹의 ts 최댓값이 2026-02-28 이라면, 그 안에 조건을 만족하는 행은 있을 수가 없습니다. 그러니 읽지 않습니다. 이걸 프레디킷 푸시다운(predicate pushdown)·존 맵(zone map) 이라고 부릅니다. ClickHouse 의 스킵 인덱스(v05), PostgreSQL 의 BRIN 인덱스(🐘 p08)가 전부 같은 아이디어입니다.
정렬하지 않고 저장하면 이 최적화가 완전히 죽습니다. 타임스탬프가 뒤섞여 있으면 모든 행 그룹의 min·max 가 "전체 범위" 가 되어 어느 하나도 건너뛸 수 없습니다. COPY (SELECT * FROM t ORDER BY ts) TO 'out.parquet' — 쓰기 전에 필터로 자주 쓰는 열로 정렬하는 것, 이것 하나가 조회 속도를 몇 배 바꿉니다.

압축 코덱 — 무엇을 고르나

코덱압축률속도언제
SNAPPY보통아주 빠름많은 도구의 기본값. 자주 읽는 핫 데이터
ZSTD좋음 (Snappy 보다 20~40% 더)빠름2026년의 기본 추천. 레벨 3 이면 속도 손해가 거의 없습니다
GZIP좋음느림호환성이 필요한 아카이브
LZ4낮음최고 속도CPU 가 병목인 스트리밍 적재
없음——이미 압축된 값(이미지 바이너리)만
코덱보다 인코딩이 먼저 일합니다. Parquet 는 압축 전에 딕셔너리 인코딩(반복 문자열을 번호로), RLE(같은 값 반복을 "값×개수"로), 델타 인코딩(정렬된 정수의 차이만)을 적용합니다. 국가 코드 열이 100배 줄어드는 건 zstd 덕이 아니라 딕셔너리 덕입니다. 그래서 카디널리티가 낮은 열을 문자열로 둬도 크게 손해 보지 않습니다.

파티셔닝된 폴더 — 파일 이름이 곧 WHERE 절

s3://warehouse/events/
  ├── year=2026/
  │   ├── month=01/
  │   │   ├── country=KR/part-0.parquet
  │   │   └── country=US/part-0.parquet
  │   └── month=02/
  │       ├── country=KR/part-0.parquet
  │       └── country=US/part-0.parquet
  └── year=2025/ ...

키=값 형태의 폴더 이름을 하이브 파티셔닝(Hive partitioning)이라고 합니다.
WHERE year = 2026 AND country = 'KR' 이면
→ 다른 폴더는 파일 목록조차 읽지 않습니다 (파티션 프루닝)
-- 이렇게 쓰고
COPY events TO 's3://warehouse/events'
     (FORMAT parquet, PARTITION_BY (year, month, country),
      COMPRESSION zstd, ROW_GROUP_SIZE 1000000);

-- 이렇게 읽습니다 — 폴더 이름이 열로 살아납니다
SELECT country, sum(amount)
FROM   read_parquet('s3://warehouse/events/**/*.parquet', hive_partitioning = true)
WHERE  year = 2026 AND month = 3          -- ← 폴더 단위로 잘라 냅니다
GROUP  BY 1;
파티션을 너무 잘게 나누면 오히려 느려집니다. date=2026-03-01/hour=13/user_id=… 처럼 나누면 파일 하나가 수 KB 인 "작은 파일 수백만 개" 문제가 생깁니다. 푸터를 읽는 고정 비용과 S3 요청 횟수가 데이터 크기를 압도합니다. 파일 하나가 최소 수십 MB 가 되도록 파티션 깊이를 정하세요. 보통은 year/month 또는 date 하나면 충분합니다.

CSV 대비 — 실측

$ ls -lh
-rw-r--r--  4.1G  events.csv
-rw-r--r--  312M  events.snappy.parquet      # 13배
-rw-r--r--  198M  events.zstd.parquet        # 21배 (ts 정렬 후)

$ duckdb -c "SELECT country, sum(amount) FROM 'events.csv' GROUP BY 1"
Run Time: real 41.882 user 38.1 sys 3.2

$ duckdb -c "SELECT country, sum(amount) FROM 'events.zstd.parquet' GROUP BY 1"
Run Time: real 0.612 user 2.9 sys 0.2        # 68배

# CSV 는 매번 4.1GB 전체를 파싱해야 합니다 —
# 문자열 "12345.67" 을 숫자로 바꾸는 일만 1억 번입니다.

파일 속을 들여다보기 — 문제를 여기서 찾습니다

-- DuckDB 로 Parquet 메타데이터를 직접 조회할 수 있습니다
SELECT path_in_schema, compression, num_values,
       stats_min, stats_max, stats_null_count,
       total_compressed_size, total_uncompressed_size
FROM   parquet_metadata('events.parquet');
-- ↑ 행 그룹 × 열 조합마다 한 줄. min/max 가 전부 같은 범위라면
--   정렬이 안 된 것이고, 건너뛰기가 전혀 안 듣는 상태입니다

-- 행 그룹이 몇 개이고 각각 몇 행인지
SELECT row_group_id, count(*) AS columns, max(num_values) AS rows
FROM   parquet_metadata('events.parquet') GROUP BY 1 ORDER BY 1;
-- 행 그룹이 1개뿐 = 통계 건너뛰기가 불가능 (파일이 너무 작거나 설정 문제)
-- 행 그룹이 수천 개 = 하나가 너무 작아 메타데이터 오버헤드가 큽니다

-- 스키마와 논리 타입 확인 (타입이 틀어져 조인이 안 될 때)
SELECT * FROM parquet_schema('events.parquet');

-- 열별로 실제 얼마나 압축됐는지 — 스키마를 고칠 근거가 됩니다
SELECT path_in_schema AS col,
       sum(total_uncompressed_size) AS raw,
       sum(total_compressed_size)   AS packed,
       round(sum(total_uncompressed_size)
             / sum(total_compressed_size), 1) AS ratio
FROM   parquet_metadata('events.parquet')
GROUP  BY 1 ORDER BY packed DESC;
압축비가 1에 가까운 열이 파일 크기의 대부분을 먹고 있다면 그 열이 범인입니다. 보통 UUID·해시·자유 텍스트·JSON 원문 이 그렇습니다. 분석에 안 쓰는 열이라면 아예 빼거나 별도 파일로 분리하세요 — 컬럼 저장이므로 열 하나를 빼는 것만으로 파일이 절반이 되는 일이 흔합니다.
행 그룹 크기는 "메모리에 한 번에 올릴 수 있는 크기" 로 잡습니다. 너무 크면(1GB) 병렬 처리 단위가 커져 워커 하나가 다 떠맡고 메모리를 씁니다. 너무 작으면(1MB) 건너뛰기 판단 비용과 메타데이터가 데이터를 압도합니다. 압축 후 64~256MB 가 무난한 구간이고, DuckDB 기본값(약 12만 행)은 행이 좁을 때 너무 작을 수 있으니 ROW_GROUP_SIZE 1000000 정도로 올려 쓰세요.

Arrow 와 Parquet 는 무슨 관계인가 🟡 중급

Parquet — 디스크 포맷

목적   : 저장 · 전송
최적화 : 크기 (압축·인코딩)
읽으려면 → 압축을 풀고 디코딩해야 함
수명   : 몇 년 (파일로 보관)

Arrow — 메모리 포맷

목적   : 처리 · 프로세스 간 공유
최적화 : 접근 속도 (압축 안 함, 고정 폭)
그대로  → CPU 가 바로 SIMD 로 계산
수명   : 밀리초 (메모리에만)
Parquet 를 읽으면 대개 Arrow 가 됩니다. DuckDB · Polars · pandas 2.x · Spark 가 전부 Arrow 를 메모리 표현으로 쓰기 때문에, 같은 프로세스 안에서는 서로 복사 없이 데이터를 넘깁니다. v02 에서 duckdb.sql("SELECT * FROM pldf") 가 공짜였던 이유가 이것입니다. "Parquet = 디스크의 Arrow, Arrow = 메모리의 Parquet" 정도로 외워 두면 실무에서는 충분합니다.
OLAP 04

ClickHouse 시작 — MergeTree 와 정렬 키

ClickHouse 에는 우리가 아는 인덱스가 없습니다. 정렬 키가 곧 인덱스이고, 데이터는 계속 새 파트로 쌓였다가 백그라운드에서 병합됩니다. 이 두 문장이 전부입니다.

10분 만에 띄우기

# ① 도커 한 줄 (가장 빠른 길)
docker run -d --name ch \
  -p 8123:8123 -p 9000:9000 \
  -e CLICKHOUSE_PASSWORD=devpass \
  --ulimit nofile=262144:262144 \
  -v ch_data:/var/lib/clickhouse \
  clickhouse/clickhouse-server:latest

# ② 붙기 — 네이티브 프로토콜 CLI
docker exec -it ch clickhouse-client --password devpass
# HTTP 로도 됩니다 (8123)
curl -u default:devpass 'http://localhost:8123/?query=SELECT+version()'

# ③ 서버 없이 파일만 뒤질 때 — DuckDB 처럼 쓸 수 있습니다
docker run --rm -v "$PWD:/d" clickhouse/clickhouse-server \
  clickhouse-local -q "SELECT count() FROM file('/d/events.parquet')"
clickhouse-local 은 의외로 자주 씁니다. 서버를 띄우지 않고 CSV·Parquet·S3 를 ClickHouse SQL 로 조회하는 모드입니다. DuckDB 의 자리와 겹칩니다. 다만 파이썬 통합·DataFrame 연동은 DuckDB 쪽이 훨씬 편합니다.

첫 테이블 — MergeTree

CREATE TABLE events
(
    ts          DateTime,
    user_id     UInt64,
    country     LowCardinality(String),   -- 값 종류가 적은 문자열 전용 타입
    path        String,
    status      UInt16,
    duration_ms UInt32,
    amount      Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)                 -- 파티션: 보관·삭제의 단위
ORDER BY (country, ts, user_id)            -- ★ 정렬 키 = 사실상의 인덱스
SETTINGS index_granularity = 8192;         -- 그래뉼 하나 = 8192 행
INSERT 는 파트를 만들고, 병합이 파트를 합칩니다 INSERT 1만 행 INSERT 1만 행 INSERT 1만 행 파트 A (정렬됨) 파트 B (정렬됨) 파트 C (정렬됨) 병합된 큰 파트 백그라운드 스레드가 자동으로 정렬된 것끼리 합치므로 빠릅니다 (머지 소트) 파트 하나의 안쪽 — 희소 인덱스가 그래뉼 범위를 좁힙니다 그래뉼 0 KR · 03-01~ 그래뉼 1 KR · 03-04~ 그래뉼 2 KR · 03-07~ 그래뉼 3 KR · 03-09~ 그래뉼 4 US · 03-01~ 희소 인덱스에는 그래뉼마다 첫 행의 정렬 키 값 하나만 적혀 있습니다 (8192행당 1개) country='KR' AND ts BETWEEN '03-07' AND '03-10' → 그래뉼 2~3 만 읽습니다 인덱스가 메모리에 통째로 올라갈 만큼 작습니다 — 10억 행이라도 12만 개 항목뿐입니다
정렬 키를 잘못 잡으면 모든 그래뉼을 읽습니다 — ClickHouse 튜닝의 90%가 ORDER BY 입니다.

ORDER BY 를 정하는 법 — 가장 중요한 결정

규칙이유
① 항상 걸리는 필터를 맨 앞에 모든 쿼리에 tenant_id 가 들어간다면 ORDER BY (tenant_id, …). 앞쪽 열만 건너뛰기가 잘 듣습니다
② 카디널리티 낮은 → 높은 순 country(200) → date → user_id(1억) 순. 반대로 두면 정렬 키가 사실상 무작위가 되어 압축률도 같이 떨어집니다
③ 3~4개를 넘기지 말 것 뒤쪽 열은 어차피 건너뛰기에 거의 기여하지 못하고, 삽입 시 정렬 비용만 늘어납니다
④ 기본키는 정렬 키의 접두사 PRIMARY KEY 를 따로 쓰면 정렬 키보다 짧게만 가능합니다. 유일성 보장이 아닙니다
⑤ 나중에 못 바꿉니다 정렬 키 변경 = 테이블 재작성. 설계 때 쿼리 목록을 먼저 적으세요
PARTITION BY 와 ORDER BY 를 헷갈리면 큰일 납니다.
· PARTITION BY 는 물리적으로 폴더를 나누는 것입니다. 목적은 통째로 지우기(DROP PARTITION)와 TTL 입니다. 보통 toYYYYMM(ts) — 월 단위가 정답이고, 일 단위로 쪼개면 파트가 폭증합니다.
· ORDER BY 가 성능을 만듭니다. 파티션은 성능 도구가 아닙니다.
파티션 개수가 수천 개를 넘기면 병합 스레드가 못 따라가고 TOO_MANY_PARTS 로 INSERT 가 막힙니다.

데이터 넣기

-- ① 그냥 INSERT (배치로!)
INSERT INTO events VALUES
  ('2026-03-07 10:00:00', 1001, 'KR', '/home', 200, 42, 1900.00),
  ('2026-03-07 10:00:01', 1002, 'US', '/api/v1', 200, 88, 0);

-- ② 파일에서 — 형식을 명시합니다
INSERT INTO events FROM INFILE 'events.parquet' FORMAT Parquet;
INSERT INTO events FROM INFILE 'events.csv'     FORMAT CSVWithNames;

-- ③ S3 에서 직접 (가장 흔한 대량 적재 경로)
INSERT INTO events
SELECT * FROM s3('https://bucket.s3.amazonaws.com/events/2026/03/*.parquet',
                 'ACCESS_KEY', 'SECRET', 'Parquet');

-- ④ PostgreSQL 에서 바로 당겨오기
INSERT INTO events
SELECT * FROM postgresql('db:5432', 'shop', 'events', 'ro_user', 'pw')
WHERE  ts >= now() - INTERVAL 1 DAY;
# CLI 로 파이프 — 수억 행 적재의 표준 방법
cat events.csv | docker exec -i ch clickhouse-client --password devpass \
  --query "INSERT INTO events FORMAT CSVWithNames"

# 압축 파일을 디스크에 풀지 않고 바로 흘려 넣기
# (풀린 데이터가 파이프로 가고, 서버로 가는 구간은 clickhouse-client 가 기본으로 LZ4 압축합니다)
zstd -dc events.csv.zst | clickhouse-client \
  --query "INSERT INTO events FORMAT CSVWithNames" --max_insert_block_size 1000000
한 건씩 INSERT 하면 ClickHouse 가 죽습니다. INSERT 한 번이 디스크에 파트(폴더) 하나를 만듭니다. 초당 1000건을 한 건씩 넣으면 초당 파트 1000개가 생기고, 병합이 못 따라갑니다. 최소 1000행, 권장 1만~10만 행씩 묶어서 넣으세요. 앱에서 배치가 어렵다면 서버 쪽 async_insert = 1 설정으로 ClickHouse 가 대신 모아 줍니다.

첫 집계 쿼리

-- 국가별 · 일자별 매출
SELECT country,
       toDate(ts)                    AS d,
       count()                       AS cnt,
       sum(amount)                   AS revenue,
       round(avg(duration_ms), 1)    AS avg_ms,
       quantile(0.95)(duration_ms)   AS p95      -- 백분위가 내장 함수입니다
FROM   events
WHERE  ts >= '2026-03-01' AND country = 'KR'
GROUP  BY country, d
ORDER  BY d
FORMAT PrettyCompact;                            -- 출력 형식도 SQL 로 지정

-- 유니크 사용자 수 — 근사 계산이 기본입니다 (uniq = 적응형 샘플링, HyperLogLog 는 uniqHLL12)
SELECT uniq(user_id) FROM events;        -- 오차 ~0.5%, 매우 빠름
SELECT uniqExact(user_id) FROM events;   -- 정확하지만 메모리를 씁니다

-- 실행 통계 보기
SET send_logs_level = 'trace';
EXPLAIN indexes = 1
SELECT count() FROM events WHERE country = 'KR' AND ts >= '2026-03-07';
-- Granules: 37/12208  ← 읽은 그래뉼 / 전체 (1억 행 ÷ 8192 ≈ 12,208). 이 비율이 곧 성능입니다

PostgreSQL 을 알고 있다면 — 다른 점 🟡 중급

PostgreSQLClickHouse
count(*)count() — 괄호가 빕니다 (count(*) 도 됩니다)
VARCHAR(n) · TEXTString 하나. 길이 제한 없음. 값 종류가 적으면 LowCardinality(String)
NULL 이 기본기본이 NOT NULL. NULL 을 쓰려면 Nullable(UInt32) — 느려지므로 피하는 것이 관행
NOW() · date_truncnow() · toStartOfHour() · toYYYYMM() — 날짜 함수가 수백 개
UPDATE · DELETEALTER TABLE … UPDATE/DELETE WHERE — 비동기 재작성. 일상적으로 쓰면 안 됩니다
외래키 · UNIQUE 제약없습니다. 중복 방지는 ReplacingMergeTree(v05)로 흉내 냅니다
트랜잭션 · 격리 수준사실상 없습니다. INSERT 는 파트 단위로 원자적일 뿐
ARRAYArray(T) + arrayJoin·arrayMap·groupArray — 훨씬 강력합니다
스키마 변경이 무거움ALTER TABLE ADD COLUMN 이 즉시 끝납니다 (메타데이터만 변경)
ClickHouse 를 "빠른 PostgreSQL" 로 생각하면 반드시 다칩니다. 제약도 트랜잭션도 없고, 같은 행을 두 번 넣으면 두 번 들어갑니다. "한 번 쓰고 여러 번 읽는, 고쳐 쓰지 않는 사실 기록(fact)" 만 넣는다는 원칙을 지키면 이 모든 제약이 문제가 되지 않습니다 — 로그·이벤트·메트릭이 정확히 그런 데이터입니다.
OLAP 05

ClickHouse 실전 — 사전 집계 · TTL · 조인의 한계

원본 1억 행을 매번 훑는 대신 들어올 때 미리 접어 두면 대시보드가 10ms 에 뜹니다. 머티리얼라이즈드 뷰가 ClickHouse 에서는 트리거에 가깝다는 점만 이해하면 됩니다.

머티리얼라이즈드 뷰 = "INSERT 될 때 실행되는 트리거" INSERT 블록 방금 들어온 1만 행 events (원본 테이블) 1억 행 · 그대로 보관 방금 그 블록만 다시 MV 의 SELECT 실행 시간별로 접습니다 (GROUP BY) events_hourly 2만 행 · 5000배 작음 조회 — 뷰가 없을 때 1억 행을 매번 집계 220 ms · CPU 8코어 풀가동 조회 — 뷰가 있을 때 2만 행만 집계 7 ms · 대시보드 20개도 여유 주의 — 과거 데이터는 안 채워집니다 뷰 생성 이후의 INSERT 부터 반영됩니다 과거분은 INSERT INTO events_hourly SELECT … FROM events 로 한 번 손수 채워 넣습니다
PostgreSQL 의 MATERIALIZED VIEW 처럼 새로고침(REFRESH)하는 스냅숏이 아닙니다 — 들어올 때마다 즉시 갱신됩니다.

AggregatingMergeTree + 머티리얼라이즈드 뷰

-- ① 결과를 담을 테이블 — 집계 "상태(State)" 를 저장합니다
CREATE TABLE events_hourly
(
    hour        DateTime,
    country     LowCardinality(String),
    cnt         AggregateFunction(count),                 -- 최종 값이 아니라 중간 상태
    revenue     AggregateFunction(sum, Decimal(18,2)),
    uniq_users  AggregateFunction(uniq, UInt64),          -- 근사 유니크 스케치가 통째로 들어갑니다
    p95_ms      AggregateFunction(quantile(0.95), UInt32)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(hour)
ORDER BY (country, hour);

-- ② 뷰 — 원본에 INSERT 될 때마다 이 SELECT 가 그 블록에 대해 실행됩니다
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT toStartOfHour(ts)              AS hour,
       country,
       countState()                   AS cnt,            -- -State 접미사
       sumState(amount)               AS revenue,
       uniqState(user_id)             AS uniq_users,
       quantileState(0.95)(duration_ms) AS p95_ms
FROM   events
GROUP  BY hour, country;

-- ③ 조회 — -Merge 로 상태를 합쳐 최종 값을 만듭니다
SELECT country,
       toDate(hour)                   AS d,
       countMerge(cnt)                AS cnt,
       sumMerge(revenue)              AS revenue,
       uniqMerge(uniq_users)          AS dau,            -- ★ 일자별 유니크가 정확히 나옵니다
       quantileMerge(0.95)(p95_ms)    AS p95
FROM   events_hourly
WHERE  hour >= '2026-03-01'
GROUP  BY country, d
ORDER  BY d;
왜 sum 이 아니라 sumState 인가. 합계는 시간별 합을 다시 더하면 되지만, 유니크 사용자 수는 그렇게 안 됩니다 — 시간별 DAU 를 24개 더한다고 하루 DAU 가 되지 않습니다(같은 사람이 여러 시간에 옵니다). 그래서 ClickHouse 는 최종 숫자가 아니라 유니크 스케치(HyperLogLog 류)·백분위 스케치 같은 "중간 상태" 를 통째로 저장합니다. 나중에 어떤 단위로 묶든 -Merge 로 정확히 합쳐집니다. 이게 ClickHouse 사전 집계의 핵심 아이디어이고, 다른 DB 에서 흉내 내기 가장 어려운 부분입니다.

엔진 고르기 — MergeTree 계열

엔진하는 일쓰는 자리
MergeTree정렬해 저장. 그 이상 아무것도 안 함기본값. 원본 이벤트·로그
ReplacingMergeTree정렬 키가 같은 행을 병합 시 하나만 남깁니다 (버전 열 기준)OLTP 를 CDC 로 복제해 올 때(v12)
SummingMergeTree정렬 키가 같으면 숫자 열을 더해 버립니다단순 카운터 사전 집계
AggregatingMergeTree집계 상태를 병합합니다 (위 예제)유니크·백분위가 필요한 사전 집계
CollapsingMergeTreesign 열 +1/−1 로 취소 처리상태가 바뀌는 엔티티 추적
ReplicatedMergeTree위 전부에 Replicated 접두사 → 다중 복제운영 클러스터의 기본
ReplacingMergeTree 는 "언젠가" 중복을 없앱니다. 병합은 백그라운드에서 임의의 시점에 일어나므로, 방금 넣은 중복이 지금 조회하면 그대로 보입니다. 확실히 하려면 조회 때 FINAL 을 붙이거나(느립니다) argMax(col, version) + GROUP BY key 로 직접 최신 행을 고릅니다. 최신 버전에는 SELECT … FINAL 을 자동으로 붙이는 final = 1 세션 설정도 있지만, 비용은 똑같습니다.

TTL — 오래된 데이터를 알아서 지우기

-- ① 행 단위 삭제 — 90일 지난 이벤트는 사라집니다
ALTER TABLE events MODIFY TTL ts + INTERVAL 90 DAY;

-- ② 계층화 — 30일 지나면 느린 디스크로, 180일 지나면 삭제
ALTER TABLE events MODIFY TTL
    ts + INTERVAL 30  DAY TO VOLUME 'cold',
    ts + INTERVAL 180 DAY DELETE;

-- ③ 열 단위 TTL — 원문은 지우고 집계에 필요한 열만 남깁니다
ALTER TABLE events MODIFY COLUMN user_agent String TTL ts + INTERVAL 14 DAY;

-- ④ 롤업 TTL — 오래된 데이터를 자동으로 접습니다 (다운샘플링)
CREATE TABLE metrics (...)
ENGINE = MergeTree ORDER BY (name, toStartOfHour(ts), ts)   -- TTL GROUP BY 는 정렬 키의 접두사여야 합니다
TTL ts + INTERVAL 30 DAY GROUP BY name, toStartOfHour(ts)
    SET value = avg(value);        -- 30일 지나면 시간 단위 평균만 남김
TTL 은 병합할 때 적용됩니다 — 즉시가 아닙니다. 당장 확인하려면 OPTIMIZE TABLE events FINAL 을 부르거나 merge_with_ttl_timeout 설정을 낮춥니다. 보관 정책을 SQL 한 줄로 선언할 수 있다는 건 큰 장점입니다 — PostgreSQL 이라면 파티션 드롭 배치를 직접 짜야 합니다(🧬 z13).

스킵 인덱스 — 정렬 키가 아닌 열로 거를 때 🟡 중급

-- 정렬 키에 없는 열로 필터하면 전체 그래뉼을 다 읽습니다. 그때 씁니다.
ALTER TABLE events ADD INDEX idx_status status TYPE minmax GRANULARITY 4;
ALTER TABLE events ADD INDEX idx_path   path   TYPE bloom_filter(0.01) GRANULARITY 2;
ALTER TABLE events ADD INDEX idx_tok    path   TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 2;

-- 기존 데이터에도 적용하려면 한 번 물질화해야 합니다
ALTER TABLE events MATERIALIZE INDEX idx_status;

-- 잘 듣는지 확인
EXPLAIN indexes = 1 SELECT count() FROM events WHERE status = 500;
--   Skip: idx_status  Granules: 156/12208     ← 이 비율을 봅니다
타입맞는 데이터거는 조건
minmax값이 정렬 키와 상관관계가 있는 열 (증가하는 id 등)= · 범위
set(N)그래뉼 안 값 종류가 N 개 이하= · IN
bloom_filter카디널리티가 높은 열 (경로·ID)= · IN. 범위는 안 됨
tokenbf_v1문자열 안 단어 검색hasToken() · LIKE '%tok%'
ngrambf_v1부분 문자열LIKE 일반
스킵 인덱스는 B-Tree 가 아닙니다. "이 그래뉼에 값이 있을 수도 있다"만 알려 줍니다(거짓 양성 허용). 값이 그래뉼 전체에 골고루 흩어져 있으면 어느 그래뉼도 못 건너뛰고 인덱스 유지 비용만 늘어납니다. 반드시 EXPLAIN indexes = 1 로 그래뉼 감소를 확인하고 남기세요.

조인이 약한 이유와 대안 🔴 고급

✗ 큰 테이블끼리 JOIN
SELECT e.*, u.name
FROM   events e
JOIN   users u ON u.id = e.user_id;

-- 기본 알고리즘: 오른쪽(users) 전체를
-- 메모리 해시 테이블로 올립니다.
-- users 가 5천만 행이면 → 메모리 폭발
-- 분산 클러스터면 노드마다 통째로 복사
-- MEMORY_LIMIT_EXCEEDED
✓ 딕셔너리 — 조인 대신 함수 호출
CREATE DICTIONARY users_dict (
    id UInt64, name String, tier String
)
PRIMARY KEY id
SOURCE(POSTGRESQL(host 'db' port 5432 user 'ro'
       password 'pw' db 'shop' table 'users'))
LIFETIME(MIN 300 MAX 600)      -- 5~10분마다 갱신
LAYOUT(HASHED());              -- 메모리에 상주

SELECT e.country,
       dictGet('users_dict','tier', e.user_id) AS tier,
       count()
FROM   events e GROUP BY 1, 2;
딕셔너리는 "항상 메모리에 올라와 있는 조회 테이블" 입니다. 외부 소스(PostgreSQL·MySQL·파일·HTTP)에서 주기적으로 당겨 오고, dictGet·dictGetOrDefault·dictHas 로 해시 조회 한 번에 값을 얻습니다. ClickHouse 스타일 모델링은 차원 테이블을 조인하지 않고 딕셔너리로 붙이거나, 아예 적재 시점에 값을 복사해 넣는(비정규화) 것이 정석입니다. OLAP 에서는 반정규화가 죄가 아닙니다 — 어차피 고쳐 쓰지 않는 기록이니까요(⚙️ t02 와 정반대의 판단).

흔한 실수 모음

증상원인과 해결
TOO_MANY_PARTS한 건씩 INSERT 하거나 파티션을 일 단위로 쪼갬 → 배치 적재 + PARTITION BY toYYYYMM
MEMORY_LIMIT_EXCEEDED큰 조인 또는 GROUP BY 카디널리티 폭발 → 딕셔너리 · max_bytes_before_external_group_by 로 디스크 스필
쿼리가 전혀 안 빨라짐정렬 키 첫 열로 필터하지 않고 있음 → EXPLAIN indexes=1 의 Granules 비율 확인
MV 에 과거 데이터 없음정상 동작입니다 → INSERT INTO … SELECT 로 백필
중복 행이 보임Replacing 은 병합 후에 정리됨 → argMax 조회 또는 FINAL
디스크가 안 줄어듦ALTER … DELETE 는 비동기 뮤테이션 → system.mutations 확인, 가능하면 DROP PARTITION
운영 시 반드시 보는 시스템 테이블 네 개. system.parts(파트 수·크기·압축률) · system.query_log(느린 쿼리·읽은 바이트) · system.merges(진행 중 병합) · system.mutations(끝나지 않은 UPDATE/DELETE). SELECT table, count() FROM system.parts WHERE active GROUP BY 1 ORDER BY 2 DESC 가 장애 때 가장 먼저 치는 쿼리입니다.
OLAP 06

시계열 DB — 시간이 축인 데이터

메트릭·센서·주가는 "시간 + 값" 이 전부입니다. 이 모양에만 특화하면 PostgreSQL 을 그대로 쓰면서도 10배 빠르고 20배 작게 만들 수 있습니다 — TimescaleDB 가 그 길입니다.

하이퍼테이블 — 하나로 보이지만 시간으로 잘려 있습니다 SELECT … FROM metrics 앱에서는 테이블 하나입니다 WHERE ts >= now() - 2h 이면 맨 오른쪽 청크 하나만 읽습니다 내부 — 자동으로 만들어지는 청크(chunk) 테이블들 1월 압축됨 4.2 GB → 210 MB 2월 압축됨 4.4 GB → 225 MB 3월 초 압축 대기 정책이 곧 처리 지금 이 주 행 저장 그대로 INSERT 가 빠릅니다 연속 집계 1시간 버킷 미리 접어 둔 요약 최근 청크는 인덱스가 붙은 보통의 PostgreSQL 테이블입니다 — UPDATE·외래키·조인이 전부 됩니다 오래된 청크는 열 단위로 묶어 압축합니다 — 크기가 10~20배 줄고 집계가 빨라집니다 보관 정책 한 줄이면 오래된 청크를 자동으로 버립니다 — DELETE 가 아니라 DROP TABLE 이라 즉시 끝나고 VACUUM 도 필요 없습니다
시계열의 핵심 최적화는 "시간으로 잘라 두면 지우는 것도 읽는 것도 통째로 된다" 입니다.

TimescaleDB — PostgreSQL 확장으로 시작하기

-- 도커:  docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=pw timescale/timescaledb-ha:pg17
CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE metrics (
    ts       TIMESTAMPTZ      NOT NULL,
    device   TEXT             NOT NULL,
    metric   TEXT             NOT NULL,
    value    DOUBLE PRECISION NOT NULL,
    tags     JSONB
);

-- ① 하이퍼테이블로 승격 — 이 한 줄이 전부입니다
SELECT create_hypertable('metrics', by_range('ts', INTERVAL '7 days'));

-- ② 조회는 완전히 평범한 SQL 입니다 (여기가 큰 장점)
SELECT device, avg(value)
FROM   metrics
WHERE  ts >= now() - INTERVAL '3 hours' AND metric = 'cpu'
GROUP  BY device;

-- ③ 시계열 전용 함수들
SELECT time_bucket('5 minutes', ts) AS bucket,
       device,
       avg(value)                   AS avg_v,
       max(value)                   AS max_v,
       last(value, ts)              AS latest      -- 버킷 안 마지막 값
FROM   metrics
WHERE  ts >= now() - INTERVAL '1 day'
GROUP  BY bucket, device
ORDER  BY bucket;

-- 빈 구간 채우기 + 보간 — 그래프 그릴 때 필수입니다
SELECT time_bucket_gapfill('1 minute', ts) AS b,
       locf(avg(value))                    AS v    -- 마지막 값 유지
FROM   metrics
WHERE  ts >= now() - INTERVAL '1 hour' AND ts < now()
GROUP  BY b;

연속 집계 · 압축 · 보관 — 정책 세 줄

-- ① 연속 집계 (continuous aggregate) — 증분으로 갱신되는 머티리얼라이즈드 뷰
CREATE MATERIALIZED VIEW metrics_hourly
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', ts) AS bucket,
       device, metric,
       avg(value) AS avg_v, max(value) AS max_v, count(*) AS n
FROM   metrics
GROUP  BY bucket, device, metric;

SELECT add_continuous_aggregate_policy('metrics_hourly',
    start_offset => INTERVAL '3 days',
    end_offset   => INTERVAL '1 hour',      -- 최근 1시간은 아직 안 굳힘
    schedule_interval => INTERVAL '30 minutes');

-- ② 압축 정책 — 7일 지난 청크를 컬럼 형태로 압축
ALTER TABLE metrics SET (timescaledb.compress,
    timescaledb.compress_segmentby = 'device, metric',
    timescaledb.compress_orderby   = 'ts DESC');
SELECT add_compression_policy('metrics', INTERVAL '7 days');

-- ③ 보관 정책 — 1년 지난 청크는 통째로 버림
SELECT add_retention_policy('metrics', INTERVAL '365 days');

-- 확인
SELECT * FROM timescaledb_information.chunks WHERE hypertable_name = 'metrics';
SELECT * FROM hypertable_compression_stats('metrics');
연속 집계의 진짜 강점은 "실시간 합성" 입니다. 미리 굳혀 둔 시간(end_offset 이전)은 요약 테이블에서 읽고, 아직 안 굳은 최근 구간은 원본에서 즉석 집계해 합쳐 줍니다. 그래서 뷰를 조회해도 방금 들어온 데이터가 빠지지 않습니다 — ClickHouse 의 MV(v05)나 PostgreSQL 의 REFRESH MATERIALIZED VIEW 에는 없는 성질입니다.

시계열 저장소 비교

제품모델강점약점
TimescaleDB PostgreSQL 확장. SQL 그대로 기존 스키마·조인·외래키·앱 코드를 그대로 씁니다. 운영 도구도 그대로 PostgreSQL 한 대의 한계를 물려받습니다
Prometheus 풀(pull) 방식 수집 + 자체 TSDB. PromQL 쿠버네티스 모니터링의 사실상 표준. 알림 규칙이 강력 장기 보관·고가용성은 별도(Thanos·Mimir·Cortex). 라벨 카디널리티 폭발에 취약
InfluxDB 3.x Rust 재작성 + Parquet·Arrow 기반. SQL 지원 IoT·센서 수집에 특화. 쓰기 처리량이 큼 2.x 의 Flux 등 세대 간 변화가 커 자료가 엇갈립니다
ClickHouse 범용 컬럼 DB 메트릭 + 로그 + 이벤트를 한 곳에서. 임의 차원 분석이 자유롭습니다 보간·갭필·다운샘플링을 손수 만들어야 합니다
VictoriaMetrics Prometheus 호환 장기 저장소 메모리·디스크 효율이 매우 좋음 메트릭 전용 — 로그·이벤트에는 부적합

언제 ClickHouse 대신 시계열 전용을 쓰나 🟡 중급

✓ 시계열 전용(Timescale·Prometheus)
· 이미 PostgreSQL 을 쓰고 있고 팀이 SQL 에 익숙
· 시계열 데이터를 관계형 데이터와 조인해야 한다
  (device 테이블 · 계약 테이블 등)
· 최근 데이터에 UPDATE·삭제가 가끔 필요하다
· 보간 · 갭필 · locf · 이동평균이 자주 나온다
· 규모가 수십억 행 이하 · 한 대로 감당 가능
· 쿠버네티스 알림이 목적이면 그냥 Prometheus
→ 이럴 땐 ClickHouse 쪽
· 메트릭뿐 아니라 원시 로그 · 클릭 이벤트도 같이
· 차원이 20개 넘고 임의 조합으로 파고들어야 한다
· 수백억~수조 행 · 다중 노드가 필요하다
· 사용자향 분석 API 를 만들어 응답을 지켜야 한다
· 초당 수십만 행 적재

현장 사례 두 가지

🌐 서버 메트릭

수집   : Prometheus 가 15초마다 스크레이프
보관   : 로컬 15일 → 원격 쓰기로 장기 저장소
질문   : "지난주 대비 p99 지연이 늘었나?"
        "어느 파드가 메모리를 먹나?"
특징   : 카디널리티가 위험 요소.
        user_id 를 라벨로 넣으면 시계열이
        수백만 개로 폭발합니다 — 절대 금지.
라벨 = 시계열의 개수입니다. 라벨 조합 하나가 시계열 하나예요. 고유값이 많은 것은 라벨이 아니라 로그로 보내세요.

🔌 IoT 센서

규모   : 센서 5만 대 × 10초 간격 = 초당 5천 행
저장   : TimescaleDB 하이퍼테이블 (7일 청크)
        7일 후 압축 → 1/15 크기
        1시간·1일 연속 집계 2단
질문   : "3번 라인 어제 온도 이상치"
        "설비별 가동률 월간 추이"
관계형 : device 테이블과 조인해 라인·공장 필터
        → 이 조인 때문에 Timescale 을 고름
원본은 90일만 두고 연속 집계는 5년 보관 — 저장 비용이 20분의 1이 됩니다.
선택이 어렵다면 순서는 이렇습니다. ① 이미 PostgreSQL 이 있다 → TimescaleDB 확장부터. 코드를 한 줄도 안 고쳐도 됩니다. ② 쿠버네티스 인프라 모니터링이 목적이다 → Prometheus + Grafana, 고민할 것 없습니다. ③ 제품 분석·사용자향 대시보드까지 필요하다 → ClickHouse(v04·v05). 시계열 DB 를 새로 배우는 비용보다 이미 아는 도구를 늘려 쓰는 편이 대개 쌉니다.
OLAP 07

벡터 검색의 원리 — 뜻으로 찾기

LIKE '%환불%' 는 "돈 돌려받고 싶어요" 를 못 찾습니다. 글자가 아니라 뜻이 가까운 것을 찾으려면 문장을 숫자 목록으로 바꿔 거리를 재야 합니다.

임베딩 — 문장을 좌표로 바꾸는 일

# 임베딩 모델은 "문장 → 고정 길이 실수 배열" 함수입니다
from openai import OpenAI
client = OpenAI()

r = client.embeddings.create(
        model="text-embedding-3-small",     # 1536 차원
        input="주문한 물건을 환불하고 싶어요")
vec = r.data[0].embedding
len(vec)     # 1536
vec[:5]      # [-0.0121, 0.0338, -0.0074, 0.0512, -0.0009]

# 로컬 모델도 잘 됩니다 (API 비용 0, 한국어 성능도 충분)
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("BAAI/bge-m3")       # 1024 차원 · 다국어
v = m.encode(["환불 절차 안내", "배송 조회 방법"], normalize_embeddings=True)
v.shape      # (2, 1024)
1536개 숫자에 무슨 의미가 있나 — 개별 숫자에는 없습니다. 의미는 벡터들 사이의 상대적 위치에만 있습니다. 모델이 방대한 문장을 학습하면서 "비슷한 맥락에 등장하는 문장은 가까이" 놓이도록 좌표계를 만든 결과입니다. 그래서 "환불"과 "돈 돌려받기"는 글자가 하나도 안 겹쳐도 가까운 좌표를 갖습니다. 차원 하나하나를 해석하려 들면 안 됩니다 — 아무 뜻도 없습니다.
1536차원을 2차원으로 눌러 그리면 대략 이런 모습입니다 1 "환불 절차가 궁금해요" 2 "돈 돌려받고 싶습니다" 3 "반품 신청은 어디서" 4 "택배가 안 왔어요" 5 "배송 조회 방법" 질문: "결제한 거 취소돼요?" 뜻이 가까우면 좌표도 가깝습니다 — 겹치는 글자가 없어도 거리를 재는 세 가지 방법 코사인 유사도 — 각도만 봅니다 두 벡터가 이루는 각. 문장 길이의 영향을 받지 않습니다 텍스트 검색의 기본값. 1 에 가까울수록 비슷합니다 유클리드 거리 (L2) — 직선 거리 좌표 차이의 제곱합. 이미지 특징 벡터에 많이 씁니다 0 에 가까울수록 비슷합니다 내적 (inner product) — 각도 + 길이 길이가 긴 벡터가 유리해집니다. 추천 모델에서 인기도를 길이에 담아 둔 경우에 씁니다 실무 결론 — 벡터를 길이 1 로 정규화(normalize)해 두세요 그러면 코사인·내적·유클리드가 전부 같은 순위를 냅니다. 계산이 가장 빠른 내적을 쓰면 됩니다
차원이 몇이든 원리는 같습니다 — 가까운 좌표를 고르는 일일 뿐입니다.
측정수식(감각)pgvector 연산자언제
코사인 거리1 − (a·b) / (|a||b|)<=>텍스트 임베딩의 기본
유클리드 (L2)√Σ(aᵢ − bᵢ)²<->이미지·좌표 특징
음의 내적−(a·b)<#>정규화했다면 가장 빠름
L1 (맨해튼)Σ|aᵢ − bᵢ|<+>드묾
해밍다른 비트 수<~>이진 양자화 벡터(v08)
모델을 바꾸면 기존 벡터는 전부 쓰레기가 됩니다. text-embedding-3-small 로 만든 벡터와 bge-m3 로 만든 벡터는 서로 다른 좌표계에 있습니다. 거리 계산은 되지만 결과는 무의미합니다. 모델·차원·정규화 여부를 메타데이터로 반드시 기록하고(v11), 모델 교체는 전량 재색인이라고 처음부터 계획에 넣으세요.

정확 검색과 근사 검색

정확 검색 (brute force · KNN)
모든 벡터와 거리를 계산해 상위 K 개

100만 개 × 1536차원
 = 15억 번의 곱셈+덧셈 (한 질의당)

정확도  : 100% (정답이 보장됨)
속도    : 10만 개까지는 수십 ms — 충분합니다
        100만 개부터 수백 ms ~ 초 단위
결론    : 데이터가 작으면 인덱스가 필요 없습니다
근사 검색 (ANN)
후보를 미리 좁혀 두고 그 안에서만 계산

정확도  : 95~99% — 정답 10개 중 1개를 놓칠 수 있음
속도    : 100만 개에서도 1~5 ms
대가    : 인덱스 빌드 시간 · 메모리 · 재현율 손실

핵심 질문:
"내가 정답 100%가 정말 필요한가?"
검색 결과 10개 중 9개가 맞으면 되는 일이라면
ANN 이 압도적으로 이득입니다.
재현율(recall)이 ANN 의 품질 지표입니다. 정확 검색이 뽑은 상위 10개를 정답이라 할 때, ANN 이 뽑은 10개 중 몇 개가 그 안에 들어 있는가가 recall@10 입니다. 9개면 0.9. 실무에서는 0.95 이상을 목표로 잡고, 그 지점에서 얼마나 빠른지(QPS)를 봅니다. "빠르다" 는 recall 을 말하지 않으면 의미 없는 자랑입니다 — 벤치마크를 볼 때 반드시 같이 확인하세요.

HNSW — 계층 그래프를 타고 내려갑니다 🟡 중급

HNSW — 고속도로로 도시까지, 그다음 골목길로 층 2 (희소 · 노드 3개) A B C 한 걸음이 아주 멉니다 층 1 (중간 · 노드 12개) · B D · 더 가까운 이웃으로 옮겨 갑니다 층 0 (모든 노드 · 촘촘한 연결) · · D · ★ · 여기서 최종 후보를 고릅니다 m — 노드마다 유지할 이웃 수 크면 길이 촘촘해져 정확해지고 메모리·빌드 시간이 늡니다 보통 16. 차원이 크거나 정확도가 중요하면 32~64 ef — 탐색 중 유지할 후보 수 크면 여러 갈래를 동시에 살펴 정확해지고 느려집니다 조회할 때 바꿀 수 있는 유일한 손잡이입니다
HNSW 가 강한 이유 — 층을 내려갈 때마다 탐색 범위가 줄어듭니다. 대략 로그 시간입니다.

IVFFlat — 먼저 나누고 몇 칸만 뒤집니다 🟡 중급

IVFFlat — 지도를 구역으로 나눠 두고 가까운 구역만 뒤집니다 군집 1 중심점 c1 벡터 1만 개 군집 2 — 가장 가까움 중심점 c2 이 안만 정확히 계산 군집 3 — 두 번째 중심점 c3 probes = 2 면 여기까지 군집 4 … 100 안 봅니다 계산량 98% 절감 질의 벡터 중심점 100개와만 먼저 비교 약점 — 경계에 걸친 정답을 놓칩니다 군집 2 바깥이지만 실제로는 아주 가까운 벡터가 군집 5 에 있으면 못 찾습니다 손잡이 두 개 — lists (군집 수) 와 probes (뒤질 군집 수) lists 는 대략 행 수의 제곱근(100만 행이면 1000). probes 는 lists 의 제곱근에서 시작해 재현율을 보며 올립니다 probes = lists 로 두면 정확 검색과 같아집니다 — 느려지지만 재현율 100%
IVFFlat 은 빌드가 빠르고 메모리를 적게 쓰지만, 데이터가 많이 바뀌면 군집이 낡습니다.
비교HNSWIVFFlat
재현율/속도같은 속도에서 더 정확합니다낮음
빌드 시간느림 (100만 × 1536 → 수십 분)빠름 (몇 분)
메모리큼 — 그래프를 통째로 올려야 함작음
데이터 추가추가하면 그래프에 바로 편입군집이 낡아 주기적 재빌드 필요
빈 테이블에먼저 만들어도 됩니다데이터가 있어야 군집을 학습합니다
결론2026년의 기본 선택메모리가 빠듯하거나 벌크 적재 후 한 번만 만들 때
인덱스를 아예 안 만드는 선택지를 먼저 검토하세요. 문서가 수만 건 이하라면 정확 검색이 수십 ms 로 끝납니다. 인덱스가 없으면 재현율 100%, 갱신 비용 0, 튜닝할 것도 없습니다. "느려지면 그때 만든다" 가 옳은 순서입니다.
OLAP 08

pgvector 완전 실전

이미 PostgreSQL 을 쓰고 있다면 벡터 DB 를 새로 도입할 이유가 대부분 없습니다. 확장 하나로 권한·트랜잭션·조인·백업이 그대로 딸려 옵니다. 대신 인덱스 파라미터는 알고 써야 합니다.

설치와 첫 테이블

# 도커가 가장 쉽습니다 (PostgreSQL 17 + pgvector 동봉 이미지)
docker run -d --name pgv -p 5432:5432 \
  -e POSTGRES_PASSWORD=pw -v pgv_data:/var/lib/postgresql/data \
  pgvector/pgvector:pg17

# 패키지로 설치할 때
sudo apt install postgresql-17-pgvector      # Debian/Ubuntu
brew install pgvector                        # macOS
# 소스: git clone → make → sudo make install
# 관리형: RDS · Cloud SQL · Supabase · Neon 전부 지원합니다
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extversion FROM pg_extension WHERE extname = 'vector';   -- 0.8 이상 권장

CREATE TABLE doc_chunk (
    id          BIGSERIAL PRIMARY KEY,
    doc_id      BIGINT      NOT NULL REFERENCES doc(id) ON DELETE CASCADE,
    chunk_no    INT         NOT NULL,
    body        TEXT        NOT NULL,
    -- ★ 차원은 모델에 맞춰 고정합니다. 나중에 못 바꿉니다
    embedding   vector(1536) NOT NULL,
    team_id     INT         NOT NULL,          -- 메타 필터용
    lang        TEXT        NOT NULL DEFAULT 'ko',
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (doc_id, chunk_no)
);

-- 넣기 — 배열 리터럴을 문자열로 넘깁니다
INSERT INTO doc_chunk (doc_id, chunk_no, body, embedding, team_id)
VALUES (1, 0, '환불은 결제일로부터 7일 이내…', '[0.012,-0.034, …]', 3);

-- 찾기 — ORDER BY 거리 + LIMIT 이 벡터 검색의 형태입니다
SELECT id, body, embedding <=> $1 AS distance
FROM   doc_chunk
ORDER  BY embedding <=> $1
LIMIT  10;
연산자 세 개만 외우면 됩니다. <=> 코사인 거리 · <-> 유클리드(L2) · <#> 음의 내적. 전부 "작을수록 가깝다" 로 통일돼 있어서 ORDER BY … ASC 가 항상 맞습니다 (내적에 마이너스를 붙여 둔 이유가 이것입니다). 유사도 점수로 바꾸려면 코사인은 1 - (a <=> b), 내적은 -(a <#> b) 입니다.

인덱스 만들기 — HNSW

-- ★ 연산자 클래스가 쿼리의 연산자와 반드시 일치해야 합니다
CREATE INDEX ON doc_chunk USING hnsw (embedding vector_cosine_ops)
       WITH (m = 16, ef_construction = 64);
--     vector_l2_ops    ← <-> 로 조회할 때
--     vector_ip_ops    ← <#> 로 조회할 때

-- 빌드를 빠르게 (100만 행 기준 수십 분 → 몇 분)
SET maintenance_work_mem = '8GB';       -- 그래프가 여기 안 들어가면 디스크로 새고 매우 느려집니다
SET max_parallel_maintenance_workers = 7;
CREATE INDEX CONCURRENTLY ...           -- 운영 중이면 반드시 CONCURRENTLY

-- 조회 정확도 손잡이 — 세션 단위로 바꿉니다
SET hnsw.ef_search = 100;               -- 기본 40. 올리면 정확·느림
SELECT id FROM doc_chunk ORDER BY embedding <=> $1 LIMIT 10;
파라미터기본의미조정
m16노드당 이웃 수. 그래프의 촘촘함차원이 크거나(1536+) 재현율이 부족하면 24~32. 메모리·빌드 시간이 함께 늡니다
ef_construction64빌드할 때 후보 수. 그래프 품질128~200 으로 올리면 재현율이 눈에 띄게 좋아집니다. 빌드만 느려지고 조회는 그대로
hnsw.ef_search40조회할 때 후보 수LIMIT 보다 반드시 커야 합니다. 40 → 100 → 200 으로 올리며 재현율을 재세요
lists (IVF)100군집 수행 수 100만 이하면 rows/1000, 그 이상이면 sqrt(rows)
ivfflat.probes1뒤질 군집 수기본 1 은 재현율이 형편없습니다. sqrt(lists) 부터 시작
가장 흔한 사고 — ivfflat.probes = 1 인 채로 운영에 올리는 것. "왜 뻔한 문서가 검색이 안 되죠?" 의 원인 1위입니다. 그리고 IVFFlat 인덱스는 데이터가 들어 있는 상태에서 만들어야 합니다 — 빈 테이블에 만들면 군집이 학습되지 않아 사실상 무작위 분할이 됩니다. 적재 → 인덱스 생성 순서를 지키고, 데이터가 크게 늘면 REINDEX 하세요.

인덱스가 안 타는 경우 🟡 중급

EXPLAIN 에 Index Scan using …_hnsw 가 보여야 인덱스를 탄 것입니다 ① 연산자 클래스 불일치 인덱스는 vector_l2_ops 인데 쿼리는 <=> 를 씀 — 아예 못 씁니다 ② LIMIT 이 없음 전체 정렬이 필요하면 인덱스가 이득이 없어 순차 스캔을 고릅니다 ③ 거리식에 연산을 얹음 ORDER BY 1 - (embedding <=> q) DESC 는 안 됩니다 ④ 선택도 높은 WHERE 동반 전체의 0.1% 만 남는 조건이면 그쪽부터 거는 편이 쌉니다 고치는 법 — 형태를 그대로 지킵니다 ORDER BY embedding <=> $1 LIMIT k 형태를 지킵니다 ④ 는 사실 정상 동작입니다 후보가 몇백 건뿐이면 전부 비교하는 쪽이 더 빠르고 더 정확합니다 확인 한 줄 — SET enable_seqscan = off 로 켜 보고 계획이 바뀌면 비용 판단, 안 바뀌면 인덱스를 못 쓰는 형태입니다
벡터 인덱스는 ORDER BY 거리 LIMIT k 라는 한 가지 모양에만 붙습니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM doc_chunk ORDER BY embedding <=> $1 LIMIT 10;

-- 좋은 계획
--  Limit  (cost=… rows=10)
--    ->  Index Scan using doc_chunk_embedding_idx on doc_chunk
--          Order By: (embedding <=> '[…]'::vector)
--          Execution Time: 2.4 ms

-- 나쁜 계획
--  Limit
--    ->  Sort  Sort Key: ((embedding <=> '[…]'::vector))
--          ->  Seq Scan on doc_chunk  (rows=1,200,000)
--          Execution Time: 4,180 ms      ← 인덱스를 못 탔습니다

차원 축소와 양자화 — 메모리를 4분의 1로 🔴 고급

타입한 값 크기1536차원 100만 건재현율 손실
vector4바이트 (float32)약 6.1 GB기준
halfvec2바이트 (float16)약 3.1 GB거의 없음 — 먼저 시도할 것
bit (이진)1비트약 192 MB큼 — 재순위(rerank)와 함께 써야 합니다
sparsevec0 이 아닌 값만데이터에 따라희소 임베딩(SPLADE) 전용
-- ① halfvec — 저장은 float32 로 두고 인덱스만 반정밀도로
CREATE INDEX ON doc_chunk
  USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops);
-- 조회도 같은 형태로 캐스팅해야 인덱스를 탑니다
SELECT id FROM doc_chunk
ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536) LIMIT 10;

-- ② 이진 양자화 + 재순위 — 2단계 검색
CREATE INDEX ON doc_chunk
  USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);

WITH cand AS (            -- 1단계: 비트 해밍으로 200개를 아주 빠르게
  SELECT id, embedding
  FROM   doc_chunk
  ORDER  BY binary_quantize(embedding)::bit(1536) <~> binary_quantize($1::vector)
  LIMIT  200
)
SELECT id                 -- 2단계: 그 200개만 원본 정밀도로 정확히 재정렬
FROM   cand
ORDER  BY embedding <=> $1
LIMIT  10;

-- ③ 차원 자체를 줄이기 — 모델이 지원하면 이쪽이 가장 깔끔합니다
--    text-embedding-3-large 는 dimensions=1024 로 요청하면
--    성능을 거의 잃지 않고 3072 → 1024 로 줄어듭니다 (Matryoshka)
ALTER TABLE doc_chunk ADD COLUMN emb_1024 vector(1024);
순서를 정해 두면 헤매지 않습니다. ① 먼저 모델 쪽에서 차원을 줄일 수 있는지 확인(3072 → 1024). ② 그다음 halfvec — 공짜에 가깝습니다. ③ 그래도 메모리가 모자라면 이진 양자화 + 재순위. ④ 그래도 안 되면 그때가 전용 벡터 DB(v10)를 볼 시점입니다.

운영 체크리스트

🐘 설정

-- 인덱스가 메모리에 상주해야 빠릅니다
SHOW shared_buffers;         -- 램의 25% 이상 권장
SELECT pg_size_pretty(
  pg_relation_size('doc_chunk_embedding_idx'));

-- 인덱스 크기가 shared_buffers 를 넘으면
-- 조회마다 디스크를 읽어 10배 느려집니다

-- 벡터 열은 TOAST 로 밀려나기 쉽습니다
ALTER TABLE doc_chunk
  ALTER COLUMN embedding SET STORAGE PLAIN;   -- 8KB 미만일 때만 유효

재현율 재기

-- 정확 검색(인덱스 끔)과 비교합니다
BEGIN;
SET LOCAL enable_indexscan = off;
CREATE TEMP TABLE truth AS
  SELECT id FROM doc_chunk
  ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

SET hnsw.ef_search = 100;
SELECT count(*) / 10.0 AS recall_at_10
FROM (SELECT id FROM doc_chunk
      ORDER BY embedding <=> $1 LIMIT 10) a
JOIN truth USING (id);
질의 50~100개로 평균을 내세요. 하나로는 아무것도 알 수 없습니다.
MVCC 가 벡터 인덱스에도 그대로 적용됩니다(🐘 p11). 임베딩을 UPDATE 하면 행이 새로 생기고 HNSW 그래프에도 새 노드가 추가됩니다. 죽은 튜플이 쌓이면 인덱스가 부풀고 조회가 느려집니다. 임베딩을 자주 갱신하는 테이블은 autovacuum_vacuum_scale_factor 를 낮추고, 대량 재색인 뒤에는 REINDEX INDEX CONCURRENTLY 로 정리하세요.
OLAP 09

하이브리드 검색 — 키워드 + 벡터

벡터 검색만 켜면 "ERR_CONN_4021 오류" 같은 정확한 토큰을 놓칩니다. 키워드 검색만 켜면 뜻이 같은 다른 표현을 놓칩니다. 둘 다 켜는 것이 정답입니다.

각자 잘하는 것이 정확히 다릅니다

키워드(BM25 · tsvector)가 이기는 질의
"ERR_CONN_4021"        정확한 오류 코드
"SKU-88213"            제품 번호 · 주문 번호
"김하늘 대리"            고유명사 · 사람 이름
"pg_stat_statements"   함수 · 설정 이름
"2026년 3분기"          숫자가 든 표현

→ 임베딩은 이런 토큰을 뭉개 버립니다.
  숫자 4021 과 4022 의 벡터는 거의 같습니다.
벡터가 이기는 질의
"돈 돌려받고 싶어요"     → 환불 규정 문서
"화면이 하얗게 떠요"      → 렌더링 오류 가이드
"휴가 언제까지 쓰나요"     → 연차 사용 촉진 안내
"이거 왜 이렇게 느려요"    → 성능 문제 진단 문서

→ 겹치는 글자가 없어 키워드 검색은
  결과가 0건입니다.
BM25 는 "TF-IDF 의 개량판" 이라고 보면 됩니다. ① 질의어가 문서에 몇 번 나오는가(TF), ② 그 단어가 전체 문서에서 얼마나 희귀한가(IDF), ③ 문서 길이 보정(긴 문서가 무조건 유리해지지 않게) — 이 셋을 합친 점수입니다. Elasticsearch·OpenSearch·Lucene 의 기본 랭킹이고, PostgreSQL 의 ts_rank_cd 는 BM25 그 자체는 아니지만 같은 계열의 점수입니다. 정확한 BM25 가 필요하면 PostgreSQL 에도 pg_search(ParadeDB) 같은 확장이 있습니다.
RRF — 점수를 더하지 않고 "순위의 역수" 를 더합니다 키워드 검색 결과 1위 문서 D (rank 4.81) 2위 문서 A (rank 3.22) 3위 문서 F (rank 1.05) 점수 범위: 0 ~ 무한대 벡터 검색 결과 1위 문서 A (거리 0.21) 2위 문서 B (거리 0.28) 3위 문서 D (거리 0.35) 점수 범위: 0 ~ 2 · 작을수록 좋음 그냥 더하면 안 됩니다 단위도 방향도 범위도 다릅니다 최소·최대 정규화를 해도 질의마다 분포가 달라져 가중치가 계속 어긋납니다 RRF 점수 = Σ 1 / (k + 그 목록에서의 순위) · k = 60 (관례) 문서 A : 1/(60+2) + 1/(60+1) = 0.01613 + 0.01639 = 0.03252 → 1위 문서 D : 1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = 0.03226 → 2위 왜 k = 60 인가 1위와 2위의 차이를 줄여 한 목록이 독주하지 못하게 두 목록 모두에서 상위권인 문서가 자연히 1위가 됩니다 — 이것이 하이브리드의 목적입니다 점수의 크기를 아예 안 쓰므로 정규화·가중치 튜닝이 필요 없습니다. 이게 RRF 가 실무에서 이긴 이유입니다
RRF 의 미덕은 정확도가 아니라 튜닝할 것이 없다는 점입니다 — 그냥 잘 됩니다.

PostgreSQL 한 쿼리로 하이브리드 검색

-- 준비: 전문 검색 열과 인덱스 (한국어는 🐘 p09 참고)
ALTER TABLE doc_chunk
  ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('simple', body)) STORED;
CREATE INDEX ON doc_chunk USING gin (tsv);
CREATE INDEX ON doc_chunk USING hnsw (embedding vector_cosine_ops);

-- 하이브리드 검색 (RRF)
WITH params AS (
  SELECT $1::text   AS q,          -- 질문 원문
         $2::vector AS qv,         -- 질문 임베딩
         60         AS k,
         50         AS n           -- 각 목록에서 가져올 후보 수
),
kw AS (                            -- ① 키워드 후보
  SELECT c.id,
         row_number() OVER (ORDER BY ts_rank_cd(c.tsv, plainto_tsquery('simple', p.q)) DESC) AS rnk
  FROM   doc_chunk c, params p
  WHERE  c.tsv @@ plainto_tsquery('simple', p.q)
  ORDER  BY rnk
  LIMIT  (SELECT n FROM params)
),
vec AS (                           -- ② 벡터 후보
  SELECT c.id,
         row_number() OVER (ORDER BY c.embedding <=> $2::vector) AS rnk
  FROM   doc_chunk c
  ORDER  BY c.embedding <=> $2::vector   -- ★ 조인한 p.qv 가 아니라 파라미터를 직접 — 그래야 HNSW 를 탑니다
  LIMIT  (SELECT n FROM params)
)
SELECT d.id, d.body,
       coalesce(1.0 / (p.k + kw.rnk),  0) +
       coalesce(1.0 / (p.k + vec.rnk), 0) AS rrf_score
FROM   params p
CROSS  JOIN LATERAL (
         SELECT id FROM kw UNION SELECT id FROM vec
       ) u
JOIN   doc_chunk d ON d.id = u.id
LEFT   JOIN kw  ON kw.id  = u.id
LEFT   JOIN vec ON vec.id = u.id
ORDER  BY rrf_score DESC
LIMIT  10;
후보 수(n)를 최종 개수(10)보다 훨씬 크게 잡는 것이 핵심입니다. 각 목록에서 10개씩만 뽑아 합치면 겹치는 것이 없어 융합의 의미가 없습니다. 각 50~100개를 뽑아 합치고 상위 10개를 취하세요. 비용은 거의 늘지 않습니다 — 어차피 인덱스가 상위 후보를 뽑는 비용이 지배적입니다.

메타데이터 필터와 벡터 검색을 같이 걸 때 — 사후 필터링 함정 🔴 고급

WHERE team_id = 7 AND ORDER BY embedding 거리 LIMIT 10 의 함정 사후 필터링 — 뽑고 나서 거릅니다 HNSW 가 전체에서 상위 10개 추출 team_id = 7 로 필터 10개 중 1개만 생존 사용자는 결과 1건을 봅니다 "검색이 안 되는데요?" 의 정체 해결 세 가지 — 상황에 따라 고릅니다 ① 후보를 넉넉히 ef_search 를 크게 올리고 LIMIT 200 으로 뽑아 거른 뒤 상위 10개를 취합니다 ② 조건이 좁으면 정확 검색 team_id = 7 이 3천 행뿐이면 인덱스 없이 다 비교해도 5 ms 입니다 — 더 정확합니다 ③ 부분 인덱스로 쪼갬 테넌트가 소수라면 팀마다 WHERE team_id = 7 부분 인덱스를 따로 만듭니다 전용 벡터 DB 들이 자랑하는 "필터링 지원" 은 대부분 ①을 인덱스 안에서 자동으로 해 주는 것입니다 (v10)
필터가 붙은 벡터 검색은 필터의 선택도에 따라 전략이 달라집니다 — 하나의 정답이 없습니다.
-- ① 후보를 넉넉히 뽑고 거르기
SET hnsw.ef_search = 400;
WITH cand AS (
  SELECT id, team_id, body, embedding <=> $1 AS d
  FROM   doc_chunk
  ORDER  BY embedding <=> $1
  LIMIT  400                       -- 넉넉히
)
SELECT id, body FROM cand WHERE team_id = 7 ORDER BY d LIMIT 10;

-- ③ 부분 인덱스 — 테넌트 수가 적고 고정적일 때
CREATE INDEX ON doc_chunk USING hnsw (embedding vector_cosine_ops)
       WHERE team_id = 7;
-- 이러면 WHERE team_id = 7 이 붙은 쿼리는 그 인덱스만 탐색합니다 (사전 필터링)
PostgreSQL 은 기본 설정에서 "이 인덱스로 상위 10개를 뽑되 조건에 맞는 것만" 을 하지 못합니다. HNSW 그래프에는 team_id 정보가 없기 때문입니다. 옵티마이저가 필터 먼저(비트맵 스캔) vs 인덱스 먼저 중 하나를 고를 뿐입니다. 다행히 pgvector 0.8 부터는 필터로 남는 행이 적으면 알아서 정확 검색으로 전환하는 판단이 꽤 좋아졌고, SET hnsw.iterative_scan = relaxed_order; 를 켜면 조건에 맞는 행이 LIMIT 만큼 모일 때까지 인덱스를 이어서 더 훑습니다(반복 스캔). 그래도 결과 개수가 갑자기 줄었다면 이 문제를 가장 먼저 의심하세요.

재순위(reranker) — 마지막 한 단계

# 검색은 "질문 벡터 하나 vs 문서 벡터 하나" 를 각각 따로 만든 것을 비교합니다(bi-encoder).
# 재순위 모델은 "질문 + 문서를 한꺼번에 읽고" 점수를 냅니다(cross-encoder) — 훨씬 정확하지만 느립니다.
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")   # 다국어 · 한국어 양호

candidates = hybrid_search(question, limit=50)        # ① 싸게 50개
pairs  = [(question, c["body"]) for c in candidates]
scores = reranker.predict(pairs)                      # ② 50개만 정밀 채점

top = [c for _, c in sorted(zip(scores, candidates),
                            key=lambda x: -x[0])][:5] # ③ 상위 5개만 LLM 에 넘김

# 비용 감각 (50개 기준)
#   벡터 검색   : 3 ms
#   재순위      : 120 ms (GPU) / 900 ms (CPU)
#   → 대화형 검색이면 충분, 자동완성이면 부담
검색 품질을 올리는 순서 — 비용 대비 효과 순입니다. ① 청킹을 고친다(v11) — 대부분 여기가 문제입니다. ② 하이브리드로 바꾼다 — 코드 30줄, 효과 큼. ③ 재순위를 붙인다 — 지연 100ms 대가로 정밀도가 크게 오릅니다. ④ 그다음에야 임베딩 모델 교체·파인튜닝을 고민합니다. 모델부터 바꾸는 것은 대개 순서가 틀린 것입니다.
OLAP 10

전용 벡터 DB — 언제 넘어가나

Qdrant·Milvus·Weaviate·Pinecone·Chroma. 전부 좋은 제품이지만, 대부분의 프로젝트는 pgvector 로 끝납니다. 넘어가야 할 지점이 어디인지가 이 절의 주제입니다.

제품성격필터링스케일비용·운영
pgvector PostgreSQL 확장. SQL·조인·트랜잭션 그대로 SQL WHERE 전부. 다만 사후 필터링 주의(v09) 수백만~수천만 벡터 (단일 노드) 추가 비용 0. 운영할 시스템이 안 늘어납니다
Qdrant Rust. 벡터 DB 중 가장 배우기 쉽습니다 사전 필터링이 인덱스에 내장 — 이 분야 최강 수억 벡터. 샤딩·복제 지원 단일 바이너리·도커 하나. 자체 호스팅이 쉽습니다
Milvus C++·Go. 대규모 특화. GPU 인덱스 지원 스칼라 필터 지원 수십억 벡터. 컴퓨트·스토리지 분리 etcd·MinIO·Pulsar 등 의존 컴포넌트가 많습니다
Weaviate Go. 스키마·모듈 개념. 하이브리드 검색 내장 GraphQL 필터 + BM25 융합이 기본 제공 수억 벡터 모듈로 임베딩 생성까지 대신해 줍니다
Pinecone 완전 관리형(SaaS). 서버가 없습니다 메타데이터 필터 지원 사실상 무제한. 서버리스 과금 운영은 0, 대신 벡터당 비용·벤더 종속·데이터 반출
Chroma 파이썬 임베디드. 프로토타입용 기본적인 메타 필터 수십만 벡터까지. 단일 프로세스 설치 한 줄. 실습·데모에 최적
Elasticsearch
OpenSearch
검색 엔진에 벡터(HNSW)를 얹은 것 BM25 + 벡터 + 필터를 한 쿼리로 클러스터로 확장 JVM 운영 부담. 이미 쓰고 있다면 가장 실용적인 선택
무엇을 쓸 것인가 — 위에서부터 순서대로 물어보세요 이미 PostgreSQL 을 쓰고 있는가? 그리고 벡터가 수백만 개 이하인가? pgvector — 끝 이 경우가 실무의 70% 이상입니다 이미 Elasticsearch 가 있는가? 로그·검색용으로 이미 돌리고 있다면 dense_vector 필드를 추가 BM25 와 벡터를 한 쿼리로 — 시스템 증가 0 필터가 좁고 복잡한가? 또는 벡터가 1억 개를 넘는가? Qdrant · Milvus 운영 인력이 없으면 Pinecone 같은 관리형 아래로 내려갈수록 "운영해야 할 시스템이 하나 늘어나는" 비용을 함께 치릅니다
기술적 우열의 문제가 아닙니다 — 이미 가진 것으로 되는지를 먼저 묻는 것이 순서입니다.

Qdrant 로 감 잡기 — 가장 배우기 쉬운 전용 제품

docker run -d -p 6333:6333 -p 6334:6334 \
  -v qdrant_data:/qdrant/storage qdrant/qdrant:latest
# 웹 대시보드가 딸려 옵니다 → http://localhost:6333/dashboard
from qdrant_client import QdrantClient, models

c = QdrantClient(url="http://localhost:6333")

c.create_collection(
    "docs",
    vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE),
    # ★ 필터에 쓸 필드는 인덱스를 따로 만들어 줘야 사전 필터링이 듭니다
)
c.create_payload_index("docs", "team_id", models.PayloadSchemaType.INTEGER)

c.upsert("docs", points=[
    models.PointStruct(id=1, vector=emb,
                       payload={"team_id": 7, "lang": "ko", "body": "환불 규정…"}),
])

hits = c.query_points(
    "docs", query=qvec, limit=10,
    query_filter=models.Filter(must=[                 # ← 인덱스 탐색 중에 적용됩니다
        models.FieldCondition(key="team_id", match=models.MatchValue(value=7))
    ]),
    search_params=models.SearchParams(hnsw_ef=128),
).points
전용 제품의 진짜 차별점은 "사전 필터링" 입니다. Qdrant 는 HNSW 그래프를 탐색하면서 조건에 맞지 않는 노드를 걷어내며 걷습니다. 그래서 team_id = 7 이 전체의 0.1% 여도 상위 10개가 정확히 채워집니다. v09 에서 본 pgvector 의 함정이 여기서는 구조적으로 없습니다. 멀티테넌트 SaaS 에서 테넌트가 수천 개라면 이 차이가 결정적일 수 있습니다.

Elasticsearch / OpenSearch 의 벡터 기능

PUT /docs
{ "mappings": { "properties": {
    "body":      { "type": "text" },
    "team_id":   { "type": "integer" },
    "embedding": { "type": "dense_vector", "dims": 1536,
                   "index": true, "similarity": "cosine" }
}}}

POST /docs/_search
{
  "retriever": {
    "rrf": {                                  ← 8.x 부터 RRF 가 내장입니다
      "retrievers": [
        { "standard": { "query": { "match": { "body": "환불 절차" } } } },
        { "knn": { "field": "embedding", "query_vector": [ ... ],
                   "k": 50, "num_candidates": 200,
                   "filter": { "term": { "team_id": 7 } } } }
      ],
      "rank_window_size": 50, "rank_constant": 60
    }
  }, "size": 10
}
이미 Elasticsearch 를 운영 중이라면 이게 가장 싼 길입니다. BM25·벡터·필터·집계가 한 쿼리에 들어가고, RRF 도 내장입니다(v09 를 직접 짤 필요가 없습니다). 다만 없는데 벡터 검색만을 위해 새로 도입하는 것은 권하지 않습니다 — JVM 힙 관리·샤드 설계·업그레이드 부담이 pgvector 나 Qdrant 와 비교가 안 됩니다.

Chroma · Milvus · Weaviate 를 한 눈에

Chroma — 실습·프로토타입

# pip install chromadb
import chromadb
c  = chromadb.PersistentClient(path="./chroma")
co = c.get_or_create_collection("docs")

co.add(ids=["1","2"],
       embeddings=[v1, v2],
       metadatas=[{"team":7}, {"team":9}],
       documents=["환불 규정…", "배송 안내…"])

r = co.query(query_embeddings=[qv], n_results=5,
             where={"team": 7})
# 설치도 API 도 가장 쉽습니다.
# 다만 단일 프로세스 · 수십만 벡터까지가 현실적인 한계라
# 운영에 올릴 물건은 아닙니다.

Weaviate — 하이브리드가 기본 제공

import weaviate
from weaviate.classes.query import Filter, HybridFusion

cl = weaviate.connect_to_local()
docs = cl.collections.get("Doc")

res = docs.query.hybrid(
    query="환불 절차",            # BM25 쪽 질의
    vector=qv,                    # 벡터 쪽 질의
    alpha=0.5,                    # 0=키워드만 1=벡터만
    fusion_type=HybridFusion.RANKED,   # RRF
    filters=Filter.by_property("team_id").equal(7),
    limit=10)
# v09 에서 손으로 짠 RRF 가 API 한 줄로 들어 있습니다.
Milvus 는 규모가 명분일 때만 고릅니다. 컴퓨트와 스토리지를 분리해 수십억 벡터를 다루고 GPU 인덱스(GPU_CAGRA)까지 지원하지만, 기본 배포에 etcd·객체 스토리지·메시지 큐가 따라옵니다. "벡터 1억 개 미만" 인데 Milvus 를 고르면 얻는 것보다 운영 부담이 큽니다. 같은 규모라면 Qdrant 가 훨씬 단순합니다.
벤치마크를 읽을 때 반드시 확인할 세 가지. ① 어느 재현율에서의 QPS 인가(v07) — recall 0.8 과 0.99 는 몇 배 차이가 납니다. ② 필터가 걸린 상태인가 — 필터 없는 벡터 검색 벤치는 실무와 무관합니다. ③ 인덱스가 메모리에 다 올라간 상태인가 — 디스크로 새는 순간 순위가 완전히 뒤집힙니다. 제조사 벤치마크는 대개 셋 다 자기에게 유리하게 잡혀 있습니다. ann-benchmarks 같은 중립 벤치를 보고, 결국은 내 데이터로 재세요.

pgvector 로 충분한 지점과 넘어가야 하는 지점 🟡 중급

✓ pgvector 로 충분합니다
· 벡터 100만 ~ 2000만 개 (1536차원)
· 검색 QPS 수십 ~ 수백
· 필터가 단순하거나 넓다 (lang · 공개여부)
· 원문 · 권한 · 사용자 정보와 조인이 필요하다
· 임베딩과 원문이 항상 같이 갱신돼야 한다
  → 트랜잭션으로 한 번에 묶을 수 있습니다
· 팀에 DB 하나 더 운영할 여력이 없다

이 조건에서 pgvector 는 지연 5~30ms 로 답합니다.
→ 전용 제품으로 넘어갈 신호
· 벡터가 1억 개를 넘어 메모리에 안 들어간다
· 테넌트가 수천 개고 항상 좁은 필터가 붙는다
  (사후 필터링으로 결과가 비는 일이 반복됨)
· 초당 수천 질의 — 읽기 복제본으로도 부족
· 인덱스 빌드가 몇 시간씩 걸려 재색인이 불가능
· 멀티 벡터 · 스파스+덴스 결합이 필요하다
· PostgreSQL 이 벡터 때문에 OLTP 까지 느려졌다

이 중 두 개 이상이면 이전을 검토할 때입니다.
"벡터 DB 를 쓰면 검색이 좋아진다" 는 오해입니다. 검색 품질을 결정하는 것은 저장소가 아니라 임베딩 모델 · 청킹 전략 · 하이브리드 여부 · 재순위 입니다. 저장소는 속도와 규모와 운영 편의를 결정할 뿐입니다. 품질이 나쁜데 저장소를 바꾸면, 똑같이 나쁜 결과가 더 빨리 나옵니다.
이전할 때를 대비한 설계 — 처음부터 추상화 한 겹. 검색을 search(query, filters, k) -> [chunk_id, score] 형태의 함수 하나로 감싸 두면 나중에 pgvector → Qdrant 이전이 그 함수 하나를 바꾸는 일이 됩니다. 원문·메타데이터는 계속 PostgreSQL 에 두고 벡터 저장소는 id 만 돌려주게 하세요 — 두 곳에 원문을 복사해 두면 정합성 문제가 반드시 생깁니다.
OLAP 11

RAG 를 위한 DB 설계

RAG 프로젝트가 실패하는 자리는 대개 모델이 아니라 스키마입니다. 원문을 어떻게 쪼개고, 무엇을 같이 저장하고, 문서가 바뀌었을 때 무엇을 다시 만들 것인가 — 여기서 갈립니다.

색인 파이프라인 (왼쪽) 과 질의 파이프라인 (오른쪽) 은 같은 모델을 씁니다 색인 — 문서가 들어올 때 한 번 원본 수집 PDF · 위키 · 노션 텍스트 추출 해시로 변경 감지 청킹 여기가 품질의 8할 임베딩 배치로 저장 doc+chunk 질의 — 사용자가 물을 때마다 질문 "환불 언제까지" 질문 임베딩 반드시 같은 모델 하이브리드 검색 후보 50개 재순위 상위 5개 LLM 답 + 출처 증분 갱신 — 문서 하나가 바뀌면 원문 해시를 비교해 바뀐 문서만 골라내고 그 문서의 청크만 지우고 다시 만듭니다 (전량 재색인 금지) 저장하는 것은 청크 텍스트 + 벡터 + 메타 벡터만 저장하면 안 됩니다 — 프롬프트에 넣을 원문이 필요하고 출처 표시(문서 제목·페이지·URL)도 여기서 나옵니다 두 파이프라인이 같은 모델·같은 정규화를 쓰는지가 첫 번째 점검 항목입니다
RAG 는 검색 시스템입니다 — LLM 은 마지막 한 칸일 뿐입니다.

스키마 — 문서 · 청크 · 버전을 나눕니다

-- ① 원본 문서 (사람이 관리하는 단위)
CREATE TABLE doc (
    id           BIGSERIAL PRIMARY KEY,
    source       TEXT        NOT NULL,          -- 'notion' | 'confluence' | 'upload'
    external_id  TEXT        NOT NULL,          -- 원본 시스템의 ID
    title        TEXT        NOT NULL,
    url          TEXT,
    team_id      INT         NOT NULL,
    acl          TEXT[]      NOT NULL DEFAULT '{}',   -- 볼 수 있는 그룹
    body         TEXT        NOT NULL,          -- 추출한 전체 텍스트
    body_sha256  CHAR(64)    NOT NULL,          -- ★ 변경 감지의 핵심
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (source, external_id)
);

-- ② 청크 (검색의 단위)
CREATE TABLE doc_chunk (
    id           BIGSERIAL PRIMARY KEY,
    doc_id       BIGINT      NOT NULL REFERENCES doc(id) ON DELETE CASCADE,
    chunk_no     INT         NOT NULL,
    body         TEXT        NOT NULL,
    token_count  INT         NOT NULL,
    heading_path TEXT,                          -- '요금 정책 > 환불 > 기간'
    page_no      INT,                           -- PDF 라면 출처 표시용
    content_sha  CHAR(64)    NOT NULL,          -- ★ 중복 제거 · 재임베딩 판단
    embedding    vector(1536),
    embed_model  TEXT        NOT NULL,          -- 'text-embedding-3-small'
    embed_ver    SMALLINT    NOT NULL DEFAULT 1,-- ★ 모델 교체 대비
    tsv          tsvector GENERATED ALWAYS AS (to_tsvector('simple', body)) STORED,
    UNIQUE (doc_id, chunk_no)
);

CREATE INDEX ON doc_chunk USING hnsw (embedding vector_cosine_ops)
       WITH (m = 16, ef_construction = 128);
CREATE INDEX ON doc_chunk USING gin (tsv);
CREATE INDEX ON doc_chunk (content_sha);        -- 중복 제거용
CREATE INDEX ON doc (body_sha256);
왜 문서와 청크를 나누나. ① 문서가 수정되면 그 문서의 청크만 지우고 다시 만들면 됩니다. ② 권한(acl)·팀·제목 같은 메타는 문서에 한 번만 저장하고 조인으로 씁니다 — 청크마다 복사하면 권한 변경 때 수백 행을 고쳐야 합니다. ③ 출처를 표시할 때 "이 답은 어느 문서 몇 페이지에서 나왔다" 를 말할 수 있습니다. RAG 의 신뢰도는 출처 표시에서 나옵니다.

청킹 — 여기가 품질의 8할입니다

전략방법평가
고정 길이500토큰씩 자름문장·표가 중간에서 잘립니다. 최후의 수단
재귀 분할문단 → 문장 → 단어 순으로 경계를 찾아 자름무난한 기본값. LangChain RecursiveCharacterTextSplitter
구조 기반Markdown 헤딩·HTML 섹션 단위로 자르고 헤딩 경로를 청크에 붙임문서가 구조화돼 있으면 최고. 사내 위키·매뉴얼에 딱 맞습니다
의미 기반인접 문장 임베딩 유사도가 뚝 떨어지는 지점에서 자름효과는 있지만 색인 비용이 큽니다
부모-자식작게 잘라 검색하고, 프롬프트에는 부모 청크(넓은 문맥)를 넣음정확도와 문맥을 둘 다 가져가는 실전 기법
# 구조 기반 + 겹침(overlap) — 실무에서 가장 자주 쓰는 조합
import hashlib, re, tiktoken

enc = tiktoken.get_encoding("cl100k_base")
TARGET, OVERLAP = 450, 80        # 토큰 기준

def chunk_markdown(md: str, title: str):
    sections, path = [], []
    for block in re.split(r"\n(?=#{1,4}\s)", md):        # 헤딩 앞에서 자름
        m = re.match(r"(#{1,4})\s+(.*)", block)
        if m:
            lv, head = len(m.group(1)), m.group(2).strip()
            path = path[:lv-1] + [head]
        body = block.strip()
        if not body:
            continue
        toks = enc.encode(body)
        # 너무 길면 겹치게 다시 자름 — 문장이 경계에서 끊기는 것을 완충합니다
        # (끝을 len - OVERLAP 으로 잡아야 TARGET 이하인 블록에서 겹침뿐인 꼬리 청크가 안 생깁니다)
        for i in range(0, max(len(toks) - OVERLAP, 1), TARGET - OVERLAP):
            piece = enc.decode(toks[i:i + TARGET])
            hp = " > ".join([title] + path)
            sections.append({
                "body":        f"[{hp}]\n{piece}",       # ★ 헤딩 경로를 본문에 넣습니다
                "heading_path": hp,
                "token_count": len(enc.encode(piece)),
                "content_sha": hashlib.sha256(piece.encode()).hexdigest(),
            })
    return sections
헤딩 경로를 청크 본문 앞에 붙이는 것은 비용 대비 효과가 가장 큰 한 줄입니다. "7일 이내에 신청해야 합니다" 라는 청크만 있으면 무엇의 7일인지 알 수 없지만, [요금 정책 > 환불 > 신청 기간] 이 앞에 붙으면 임베딩에도 그 맥락이 들어가고 LLM 도 헷갈리지 않습니다. 청크 길이는 300~600 토큰, 겹침은 10~20% 에서 시작해 조정하세요.

증분 갱신과 중복 제거

def upsert_document(cur, src, ext_id, title, url, team_id, text):
    sha = hashlib.sha256(text.encode()).hexdigest()

    # ① 원문이 그대로면 아무것도 하지 않습니다 (임베딩 API 호출 0)
    cur.execute("""SELECT id, body_sha256 FROM doc
                   WHERE source=%s AND external_id=%s""", (src, ext_id))
    row = cur.fetchone()
    if row and row[1] == sha:
        return "unchanged"

    # ② 문서 upsert
    cur.execute("""
        INSERT INTO doc (source, external_id, title, url, team_id, body, body_sha256)
        VALUES (%s,%s,%s,%s,%s,%s,%s)
        ON CONFLICT (source, external_id) DO UPDATE
          SET title=EXCLUDED.title, body=EXCLUDED.body,
              body_sha256=EXCLUDED.body_sha256, updated_at=now()
        RETURNING id""", (src, ext_id, title, url, team_id, text, sha))
    doc_id = cur.fetchone()[0]

    # ③ 새 청크를 만들고, 내용이 같은 청크는 임베딩을 재사용합니다
    chunks = chunk_markdown(text, title)
    shas   = [c["content_sha"] for c in chunks]
    cur.execute("""SELECT content_sha, embedding FROM doc_chunk
                   WHERE doc_id=%s AND content_sha = ANY(%s)""", (doc_id, shas))
    cached = dict(cur.fetchall())          # ← 문단 하나만 고친 문서는 여기서 90% 절약됩니다

    todo = [c for c in chunks if c["content_sha"] not in cached]
    vecs = embed_batch([c["body"] for c in todo])   # 배치로 한 번에

    # ④ 트랜잭션 안에서 교체 — 검색이 빈 순간을 만들지 않습니다
    cur.execute("DELETE FROM doc_chunk WHERE doc_id=%s", (doc_id,))
    insert_chunks(cur, doc_id, chunks, cached, todo, vecs)
    return "reindexed"
"매일 밤 전부 다시 색인" 은 세 가지를 망칩니다. ① 돈 — 문서 10만 개면 임베딩 API 비용이 매일 나갑니다. ② 시간 — 몇 시간씩 걸려 그날의 변경이 반영되지 않습니다. ③ 검색 공백 — DELETE 후 INSERT 사이에 검색하면 결과가 비어 버립니다. 해시 비교 + 문서 단위 트랜잭션만 지키면 셋 다 사라집니다.

검색 품질을 숫자로 재기 🟡 중급

평가 세트 만들기

CREATE TABLE eval_case (
    id        SERIAL PRIMARY KEY,
    question  TEXT   NOT NULL,
    -- 정답으로 인정할 청크·문서 목록
    gold_doc_ids BIGINT[] NOT NULL,
    note      TEXT
);

-- 50~200개면 충분합니다.
-- 실제 사용자 질문 로그에서 뽑는 것이 가장 좋습니다.
-- "우리 팀 연차 며칠?" 같은 진짜 문장이어야
-- 합니다 — 예쁜 질문으로 만들면 점수만 좋습니다.

지표 세 개

def recall_at_k(found, gold, k):
    # 정답 중 상위 k 안에 들어온 비율
    return len(set(found[:k]) & set(gold)) / len(gold)

def mrr(found, gold):
    # 첫 정답이 몇 위에 나왔는가 (1위=1.0, 5위=0.2)
    for i, d in enumerate(found, 1):
        if d in gold:
            return 1 / i
    return 0.0

def ndcg_at_k(found, gold, k):
    # 순위 가중 — 위쪽 정답에 더 큰 점수
    import math
    dcg = sum(1 / math.log2(i + 1)
              for i, d in enumerate(found[:k], 1) if d in gold)
    idl = sum(1 / math.log2(i + 1)
              for i in range(1, min(len(gold), k) + 1))
    return dcg / idl if idl else 0.0
무엇을 보나 — 목적에 따라 다릅니다. LLM 에 상위 5개를 넣는다면 recall@5 가 가장 중요합니다 (정답이 그 안에 있기만 하면 LLM 이 찾아냅니다). 사람이 검색 결과를 직접 보는 화면이라면 MRR·NDCG 가 중요합니다(1위에 있어야 합니다). 변경을 하나 할 때마다 이 숫자를 다시 재세요 — "체감상 좋아진 것 같다" 로는 청킹 파라미터 하나 못 정합니다.

비용 — 미리 계산해 두면 놀라지 않습니다

항목계산문서 10만 개(평균 3천 토큰) 기준
임베딩 (최초)총 토큰 × 모델 단가3억 토큰. 소형 임베딩 모델(100만 토큰당 약 $0.02) 기준 약 $6 — 1만 원 안팎. 생각보다 쌉니다
임베딩 (갱신)바뀐 청크만해시 비교를 하면 하루 수백~수천 청크. 사실상 무료
저장 (벡터)차원 × 4B × 청크 수청크 70만 개 × 1536 × 4B = 4.3 GB (halfvec 이면 2.2GB)
저장 (인덱스)HNSW 는 벡터의 1.3~2배약 6~8 GB — 여기가 램에 들어가야 빠릅니다
질의질문 임베딩 + 검색질문 1개당 임베딩 20토큰. 무시할 수준
LLM 답변프롬프트에 넣은 청크 토큰 × 질의 수여기가 전체 비용의 90% 입니다. 청크 5개 × 500토큰 = 질의당 2500토큰
비용 최적화의 핵심은 임베딩이 아니라 "프롬프트에 넣는 청크 수" 입니다. 재순위(v09)를 붙이면 20개 넣던 것을 5개로 줄이고도 답 품질이 올라갑니다 — 검색 정밀도가 올라가 필요한 청크만 들어가기 때문입니다. 재순위 모델 비용 < 줄어든 LLM 토큰 비용 인 경우가 아주 흔합니다.
OLAP 12

데이터 파이프라인 — OLTP 에서 분석 DB 로

분석 DB 를 만들었다면 다음 질문은 하나입니다. 데이터를 어떻게 계속 옮길 것인가. 배치·CDC·로그 적재 세 가지 길이 있고, 고르는 기준은 얼마나 늦어도 되는가 입니다.

세 가지 길 — 지연 시간과 복잡도의 맞바꿈 ① 배치 ETL — 지연 몇 시간 PostgreSQL 운영 DB 매일 새벽 SELECT WHERE updated_at > 어제 Parquet · S3 → 분석 DB 단순합니다 삭제를 놓칩니다 ② CDC — 지연 몇 초 PostgreSQL WAL (🧬 z03) Debezium 로그를 읽어 이벤트로 Kafka 토픽 = 테이블 ClickHouse Replacing ③ 로그 적재 — 운영 DB 를 거치지 않음 앱 서버 이벤트 발생 시점 Kafka · Kinesis 또는 파일 수집기 ClickHouse — 이 경로가 가장 깨끗합니다 클릭·조회·에러 같은 "사실 기록" 에 딱 맞습니다 주문·회원처럼 고쳐지는 데이터는 ②, 로그처럼 쌓이기만 하는 데이터는 ③ 이 정답입니다
고르는 기준은 성능이 아니라 "이 데이터가 나중에 수정되는가" 입니다.

① 배치 ETL — 가장 먼저 시도할 것

# 증분 추출: 마지막으로 가져온 시각 이후만
import duckdb, datetime as dt

WATERMARK = "2026-03-07T00:00:00Z"     # 실제로는 메타 테이블에 저장합니다

con = duckdb.connect()
con.sql("INSTALL postgres; LOAD postgres;")
con.sql("ATTACH 'host=db dbname=shop user=ro password=pw' AS pg (TYPE postgres, READ_ONLY)")

con.sql(f"""
  COPY (
    SELECT * FROM pg.public.orders
    WHERE  updated_at >= TIMESTAMP '{WATERMARK}'
  ) TO 's3://warehouse/orders/dt={dt.date.today()}/data.parquet'
    (FORMAT parquet, COMPRESSION zstd)
""")
# 그다음 ClickHouse 가 S3 에서 읽어 갑니다 (v04 의 s3 함수)
배치 ETL 의 세 가지 함정. ① 삭제를 못 잡습니다 — DELETE 된 행은 updated_at 조회에 안 나옵니다. 소프트 삭제(deleted_at 열)를 쓰거나 주기적 전량 대조가 필요합니다. ② 경계에서 누락 — 추출 시작 시각 이후에 커밋된 트랜잭션은 updated_at 이 과거일 수 있습니다. 워터마크를 몇 분 겹치게 잡고 대상 쪽에서 중복 제거하세요(ReplacingMergeTree). ③ 운영 DB 부하 — 큰 테이블 풀스캔이 됩니다. 읽기 복제본에서 뽑으세요.

② CDC — 트랜잭션 로그를 그대로 흘려보냅니다 🟡 중급

-- PostgreSQL 쪽 준비 (🐘 p15 의 논리 복제와 같은 구조입니다)
ALTER SYSTEM SET wal_level = 'logical';        -- 재시작 필요
CREATE PUBLICATION dbz_pub FOR TABLE orders, order_item, member;
ALTER TABLE orders REPLICA IDENTITY FULL;      -- UPDATE 전 값까지 로그에 남깁니다
CREATE ROLE dbz WITH REPLICATION LOGIN PASSWORD 'pw';
{
  "name": "shop-cdc",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "db", "database.dbname": "shop",
    "database.user": "dbz",   "database.password": "pw",
    "plugin.name": "pgoutput",
    "publication.name": "dbz_pub",
    "slot.name": "dbz_slot",
    "topic.prefix": "shop",
    "table.include.list": "public.orders,public.order_item",
    "snapshot.mode": "initial"
  }
}
-- ClickHouse 쪽 — 수정·삭제를 흡수하는 테이블
CREATE TABLE orders_cdc (
    id          UInt64,
    member_id   UInt64,
    status      LowCardinality(String),
    amount      Decimal(18,2),
    updated_at  DateTime64(3),
    _version    UInt64,          -- Debezium 의 LSN 또는 ts_ms
    _deleted    UInt8            -- 삭제 이벤트면 1
)
ENGINE = ReplacingMergeTree(_version, _deleted)
ORDER BY id;

-- 조회할 때는 최신 상태만
SELECT * FROM orders_cdc FINAL WHERE _deleted = 0;
-- 또는 (더 빠름)
SELECT argMax(status, _version) AS status, argMax(amount, _version) AS amount
FROM   orders_cdc GROUP BY id HAVING argMax(_deleted, _version) = 0;
CDC 의 가장 위험한 부분 — 복제 슬롯입니다. Debezium 이 멈춰 있으면 PostgreSQL 은 그 슬롯이 아직 안 읽은 WAL 을 계속 보관합니다. 며칠 방치하면 디스크가 가득 차고 운영 DB 가 멈춥니다. SELECT slot_name, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) FROM pg_replication_slots; 를 반드시 알람에 걸어 두세요. 쓰지 않는 슬롯은 pg_drop_replication_slot 으로 즉시 지웁니다.

dbt — 변환을 코드로 관리하기

-- models/marts/fct_daily_sales.sql
{{ config(materialized='incremental', unique_key=['d','country']) }}

SELECT toDate(ts)   AS d,
       country,
       count()      AS orders,
       sum(amount)  AS revenue
FROM   {{ ref('stg_orders') }}          -- 다른 모델을 참조 → 의존 그래프가 자동 생성
{% if is_incremental() %}
WHERE  ts >= (SELECT max(d) FROM {{ this }}) - INTERVAL 2 DAY   -- 최근분만 다시
{% endif %}
GROUP  BY d, country
dbt run          # 의존 순서대로 SQL 실행 (원본 → 스테이징 → 마트)
dbt test         # not_null · unique · relationships 같은 데이터 검증
dbt docs generate && dbt docs serve   # 계보(lineage) 그래프가 그려집니다
dbt 가 해결하는 문제는 "SQL 파일이 흩어지는 것" 입니다. 변환 로직이 크론탭·에어플로우 DAG·누군가의 노트북에 흩어져 있으면 어떤 표가 무엇에서 나왔는지 아무도 모릅니다. dbt 는 SQL 파일 + ref() 만으로 의존 그래프·실행 순서·테스트·문서를 만들어 줍니다. ClickHouse·DuckDB·PostgreSQL 어댑터가 모두 있습니다.

레이크하우스 · Iceberg 개요 🔴 고급

데이터 레이크의 한계

S3 에 Parquet 파일을 쌓아 둔 것 = 데이터 레이크

문제
· 파일을 쓰는 중에 읽으면 반쪽 데이터가 보임
· UPDATE · DELETE 가 없음 (개인정보 삭제 요청?)
· 스키마가 바뀌면 옛 파일과 안 맞음
· "3월 1일 시점의 데이터" 를 재현할 수 없음

테이블 포맷이 얹는 것

Apache Iceberg · Delta Lake · Hudi

Parquet 파일 위에 "메타데이터 계층" 을 얹습니다
· 스냅숏 — 어느 시점의 파일 목록인지 기록
  → 원자적 커밋 · 타임 트래블
· 스키마 진화 — 열 추가·이름 변경을 안전하게
· 행 단위 삭제 · 병합(MERGE INTO)
· 파티션 진화 — 나중에 파티션 기준 변경

2026년 사실상의 표준은 Iceberg 입니다.
-- DuckDB 에서 Iceberg 테이블 읽기
INSTALL iceberg; LOAD iceberg;
SELECT * FROM iceberg_scan('s3://lake/warehouse/db/orders') LIMIT 10;

-- ClickHouse 에서
SELECT count() FROM icebergS3('https://lake.s3.../orders', 'KEY', 'SECRET');

-- 타임 트래블 (엔진에 따라 문법이 다릅니다)
SELECT * FROM orders FOR SYSTEM_TIME AS OF '2026-03-01 00:00:00';
레이크하우스의 요점 — 저장은 S3 에 Parquet 로, 엔진은 갈아 끼울 수 있게. 같은 Iceberg 테이블을 Spark 로 쓰고, DuckDB 로 탐색하고, ClickHouse 로 조회하고, Trino 로 조인할 수 있습니다. 데이터가 특정 제품 안에 갇히지 않는 것이 가장 큰 가치입니다. 다만 작은 조직에는 과한 구조입니다 — Parquet + DuckDB/ClickHouse 로 시작하고, 엔진이 둘 이상 필요해질 때 도입하세요.

스키마 변경 대응과 정합성 타협점

상황대응
열 추가안전합니다. ClickHouse ALTER ADD COLUMN 은 즉시. Parquet 는 없는 열이 NULL 로 읽힙니다
열 삭제분석 쿼리가 깨집니다. 먼저 사용처를 찾고(dbt lineage) 유예 기간을 둡니다
열 이름 변경가장 위험합니다. 새 열 추가 → 양쪽 쓰기 → 소비자 이전 → 옛 열 제거 순으로
타입 변경INT → BIGINT 는 대개 안전, 문자열 → 숫자는 재적재가 필요합니다
계약을 두기Avro·Protobuf 스키마 레지스트리로 호환성 규칙을 강제합니다(생산자가 깨면 배포가 실패)
정합성은 "맞춘다" 가 아니라 "얼마나 어긋나도 되는지 정한다" 입니다.
· 지연 — 대시보드가 5분 늦어도 되나요? 그렇다면 CDC 로 충분하고 실시간 스트리밍은 필요 없습니다.
· 중복 — 대부분의 파이프라인은 최소 한 번(at-least-once) 전달입니다. 같은 이벤트가 두 번 올 수 있으니 이벤트 ID 로 멱등하게 설계하세요(ReplacingMergeTree).
· 순서 — 파티션이 다르면 순서가 뒤집힙니다. 같은 키는 같은 파티션으로 보내야 합니다.
· 대조 — 하루 한 번 원본과 분석 DB 의 건수·합계를 비교하는 배치를 반드시 두세요. 이것 없이는 어긋난 줄도 모릅니다.
OLAP 13

실전 프로젝트 — 사내 문서 검색 + 사용량 대시보드

지금까지 나온 것을 하나로 묶습니다. PostgreSQL 이 원문과 권한을, pgvector 가 검색을, ClickHouse 가 접근 로그 집계를, DuckDB 가 임시 분석을 맡습니다. 스키마·쿼리·성능 수치 전부 적습니다.

사내 문서 검색 — 네 개의 저장소가 각자 잘하는 일만 합니다 사용자 "환불 언제까지" 검색 API FastAPI · 임베딩 호출 PostgreSQL 18 + pgvector doc · doc_chunk(embedding, tsv) · acl GIN 키워드 + HNSW 벡터 → RRF 합산 (v09) 모든 요청을 로그로 Kafka search_log 토픽 ClickHouse search_log (MergeTree · 90일 TTL) search_hourly (AggregatingMergeTree · MV 자동 갱신) 색인 워커 노션 · 위키 · PDF DuckDB — 분석가의 노트북 ClickHouse 에서 하루치를 Parquet 로 떨궈 "결과를 못 찾은 질문" 을 손으로 파 봅니다 (운영 부하 0) 대시보드 (Grafana · Metabase) 일별 검색 수 · 무결과율 · p95 지연 · 팀별 인기 문서 search_hourly 만 읽으므로 7 ms 에 그려집니다 원문·권한은 PostgreSQL 한 곳에만 — 나머지는 전부 id 와 집계값만 다룹니다
각 저장소가 자기가 이기는 일만 맡습니다 — 이것이 다중 저장소 설계의 유일한 원칙입니다.

① PostgreSQL — 원문 · 권한 · 벡터

-- v11 의 doc / doc_chunk 스키마를 그대로 씁니다. 권한만 덧붙입니다.
CREATE TABLE doc_acl (
    doc_id   BIGINT NOT NULL REFERENCES doc(id) ON DELETE CASCADE,
    group_id INT    NOT NULL,
    PRIMARY KEY (doc_id, group_id)
);
CREATE INDEX ON doc_acl (group_id);

-- 인덱스 3종
CREATE INDEX ON doc_chunk USING hnsw (embedding vector_cosine_ops)
       WITH (m = 16, ef_construction = 128);
CREATE INDEX ON doc_chunk USING gin (tsv);
CREATE INDEX ON doc_chunk (doc_id);

-- 실제 규모 (사내 위키 + 노션 + PDF 매뉴얼)
--   doc          42,000 건       텍스트 합계 1.9 GB
--   doc_chunk   318,000 건       평균 420 토큰
--   embedding   318,000 × 1536 × 4B = 1.9 GB
--   HNSW 인덱스                        2.7 GB   ← shared_buffers 를 8GB 로
--   GIN 인덱스                         0.4 GB
-- 검색 쿼리 (v09 의 RRF + 권한 필터 + 문서 단위 중복 제거)
WITH p AS (SELECT $1::text AS q, $2::vector AS qv, $3::int[] AS groups, 60 AS k),
allowed AS (
  SELECT DISTINCT a.doc_id FROM doc_acl a, p WHERE a.group_id = ANY(p.groups)
),
kw AS (
  SELECT c.id, row_number() OVER (
           ORDER BY ts_rank_cd(c.tsv, plainto_tsquery('simple', p.q)) DESC) AS rnk
  FROM   doc_chunk c JOIN allowed al ON al.doc_id = c.doc_id, p
  WHERE  c.tsv @@ plainto_tsquery('simple', p.q)
  ORDER  BY rnk
  LIMIT  60
),
vec AS (
  SELECT c.id, row_number() OVER (ORDER BY c.embedding <=> $2::vector) AS rnk
  FROM   doc_chunk c JOIN allowed al ON al.doc_id = c.doc_id
  ORDER  BY c.embedding <=> $2::vector       -- 파라미터를 직접 (v09 와 같은 이유)
  LIMIT  60
),
fused AS (
  SELECT u.id,
         coalesce(1.0/(60 + kw.rnk), 0) + coalesce(1.0/(60 + vec.rnk), 0) AS score
  FROM   (SELECT id FROM kw UNION SELECT id FROM vec) u
  LEFT   JOIN kw ON kw.id = u.id
  LEFT   JOIN vec ON vec.id = u.id
)
SELECT * FROM (
  SELECT DISTINCT ON (d.id)                    -- 문서당 가장 좋은 청크 하나만
         d.id AS doc_id, d.title, d.url, c.body, c.heading_path, f.score
  FROM   fused f
  JOIN   doc_chunk c ON c.id = f.id
  JOIN   doc d       ON d.id = c.doc_id
  ORDER  BY d.id, f.score DESC                 -- DISTINCT ON 은 d.id 로 먼저 정렬해야 하므로
) best
ORDER  BY score DESC                           -- ★ 바깥에서 점수순으로 다시 정렬한 뒤 자릅니다
LIMIT  20;
DISTINCT ON 으로 문서 단위 중복을 없애는 것이 체감 품질을 크게 올립니다(🐘 p05). 긴 문서 하나가 상위 10개를 전부 자기 청크로 채워 버리는 일이 흔한데, 사용자 입장에서는 "결과가 하나뿐" 으로 보입니다. 권한 필터를 CTE 로 먼저 좁힌 것도 중요합니다 — 사후 필터링(v09)을 피하려는 것입니다.

② ClickHouse — 접근 로그와 사전 집계

CREATE TABLE search_log
(
    ts           DateTime64(3),
    request_id   UUID,
    user_id      UInt64,
    team_id      UInt16,
    query        String,
    lang         LowCardinality(String),
    n_results    UInt16,
    top_score    Float32,
    latency_ms   UInt32,
    clicked_doc  UInt64,                        -- 0 이면 클릭 없음
    reranked     UInt8
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (team_id, ts)                          -- 대시보드가 항상 팀으로 필터합니다
TTL toDateTime(ts) + INTERVAL 90 DAY;

-- 사전 집계 (v05)
CREATE TABLE search_hourly
(
    hour       DateTime,
    team_id    UInt16,
    searches   AggregateFunction(count),
    zero_hits  AggregateFunction(countIf, UInt8),
    clicks     AggregateFunction(countIf, UInt8),
    users      AggregateFunction(uniq, UInt64),
    p95_ms     AggregateFunction(quantile(0.95), UInt32)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(hour) ORDER BY (team_id, hour);

CREATE MATERIALIZED VIEW search_hourly_mv TO search_hourly AS
SELECT toStartOfHour(ts)                    AS hour,
       team_id,
       countState()                         AS searches,
       countIfState(n_results = 0)          AS zero_hits,
       countIfState(clicked_doc != 0)       AS clicks,
       uniqState(user_id)                   AS users,
       quantileState(0.95)(latency_ms)      AS p95_ms
FROM   search_log
GROUP  BY hour, team_id;
-- 대시보드 쿼리 ① 일별 지표
SELECT toDate(hour)                            AS d,
       countMerge(searches)                    AS searches,
       uniqMerge(users)                        AS dau,
       round(countMerge(zero_hits) * 100.0
             / countMerge(searches), 2)        AS zero_hit_pct,   -- ★ 가장 중요한 지표
       round(countMerge(clicks) * 100.0
             / countMerge(searches), 2)        AS ctr,
       quantileMerge(0.95)(p95_ms)             AS p95_ms
FROM   search_hourly
WHERE  team_id = 7 AND hour >= now() - INTERVAL 30 DAY
GROUP  BY d ORDER BY d;

-- 대시보드 쿼리 ② 답을 못 찾은 질문 (원본에서 직접)
SELECT query, count() AS n
FROM   search_log
WHERE  ts >= now() - INTERVAL 7 DAY AND n_results = 0
GROUP  BY query
HAVING n >= 3
ORDER  BY n DESC LIMIT 50;
-- → 이 목록이 "다음에 어떤 문서를 써야 하는가" 를 알려 줍니다

③ DuckDB — 분석가의 임시 작업대

import duckdb
con = duckdb.connect()

# ClickHouse 가 매일 새벽 S3 로 떨군 Parquet 를 그대로 붙습니다
con.sql("""
  CREATE VIEW logs AS
  SELECT * FROM read_parquet('s3://dump/search_log/dt=2026-08-*/*.parquet')
""")

# PostgreSQL 을 붙여 문서 메타와 조인 — 여기가 DuckDB 의 진가입니다
con.sql("INSTALL postgres; LOAD postgres;")
con.sql("ATTACH 'host=db dbname=docs user=ro' AS pg (TYPE postgres, READ_ONLY)")

con.sql("""
  SELECT d.title, count(*) AS clicks, avg(l.top_score) AS avg_score
  FROM   logs l
  JOIN   pg.public.doc d ON d.id = l.clicked_doc
  WHERE  l.clicked_doc <> 0
  GROUP  BY 1 ORDER BY 2 DESC LIMIT 20
""").df()

# 무결과 질문을 다시 임베딩해 "가까운 기존 문서" 를 찾아 봅니다
# → 문서는 있는데 검색이 못 찾는 것인지, 문서 자체가 없는 것인지 구분됩니다

성능 수치 — 실제로 측정한 값

구간수치메모
질문 임베딩38 ms네트워크 왕복이 대부분. 캐시하면 반복 질문은 0 ms
키워드 검색 (GIN)6 ms31.8만 청크 · 후보 60개
벡터 검색 (HNSW)11 msef_search = 100 · recall@10 = 0.97
RRF 합산 + 권한 조인4 ms
재순위 (선택)140 ms후보 30개 · GPU. 끄면 전체 60ms, 켜면 200ms
검색 API 전체 (p95)205 ms사용자 체감상 "즉시"
대시보드 (사전 집계)7 ms원본 직접 집계는 340 ms → 48배
전량 재색인2시간 10분임베딩 API 가 병목. 증분 갱신은 하루 40초
HNSW 인덱스 빌드18분maintenance_work_mem = 8GB · 병렬 7
이 구성에서 배울 점 하나 — 벡터 검색은 전체의 5%입니다. 지연의 대부분은 임베딩 API 호출과 재순위이고, 품질의 대부분은 청킹과 하이브리드 융합에서 나옵니다. "벡터 DB 를 뭘 쓸까" 는 이 시스템에서 가장 덜 중요한 결정이었습니다.

운영하며 겪은 것들

증상원인 · 해결
특정 팀만 검색 결과가 빔사후 필터링(v09). 권한 CTE 를 먼저 적용하도록 쿼리를 바꿔 해결
어느 날 갑자기 전체가 느려짐임베딩 대량 갱신으로 죽은 튜플이 쌓임 → REINDEX INDEX CONCURRENTLY + autovacuum 조정(🐘 p11)
무결과율이 12% → 3%청킹을 고정 500토큰에서 헤딩 기반 + 헤딩 경로 삽입으로 바꿈. 코드 40줄, 가장 큰 개선
ClickHouse INSERT 실패TOO_MANY_PARTS. Kafka 컨슈머가 건당 INSERT 하고 있었음 → 1만 건 배치로 변경
대시보드에 오늘 데이터가 없음MV 는 생성 이후 INSERT 부터 반영(v05). 과거분 백필을 잊은 것
PDF 문서만 검색이 나쁨표가 텍스트로 추출되며 뭉개짐 → 표는 별도 청크로 분리하고 Markdown 표로 변환해 저장
이 프로젝트를 다시 만든다면 순서는 이렇습니다. ① PostgreSQL 에 키워드 검색만 붙여 먼저 출시합니다 — 일주일. ② 검색 로그를 쌓기 시작합니다(무결과율이 최우선 지표). ③ pgvector 를 더해 하이브리드로 만듭니다 — 무결과율이 절반으로 떨어집니다. ④ 로그가 쌓여 PostgreSQL 이 무거워질 때 ClickHouse 를 도입합니다. ⑤ 그다음에야 재순위·모델 교체를 고민합니다. 처음부터 전부 만들지 마세요 — 로그 없이는 무엇을 고쳐야 할지 알 수 없습니다.
마지막으로, 이 탭 전체를 관통하는 한 문장. OLAP 도 벡터도 OLTP 를 대체하지 않습니다. 주문·결제·회원은 끝까지 PostgreSQL 이 지키고, 분석 DB 와 벡터 인덱스는 그 사실 기록에서 파생된 것을 다룹니다. 파생물은 언제든 다시 만들 수 있어야 합니다 — "분석 DB 를 통째로 날려도 하루면 복구된다" 가 되면 설계가 잘 된 것입니다.
스키마 · 마이그레이션 · SQL 파일 · 시드 · 명명 규칙 · dbt · 샤드 · CI · Flyway · Liquibase · Alembic · sqlfluff

DB 도
저장소에서
자랍니다

애플리케이션 코드는 폴더 구조를 고민하면서, DB 는 누군가의 GUI 에서 손으로 고치는 팀이 아직 많습니다. 그러면 "운영에만 있는 컬럼", "아무도 출처를 모르는 뷰", "개발 DB 를 새로 못 만드는 저장소"가 생깁니다. 이 탭은 DB 를 코드처럼 다룰 때의 저장소 모양 — 마이그레이션 폴더 · 반복 실행 SQL · 시드 · 명명 규칙 · dbt 프로젝트 · 샤드와 환경 · CI — 을 한 장씩 보여 주고, 각 구조에서 무엇을 어디에 두는지, 무엇을 커밋하지 않는지, 언제 다음 단계로 넘어가는지를 짧게 정리합니다. 도구별 설치와 첫 명령은 🧰 설치 총정리 탭 SETUP 11, 마이그레이션 도구 비교 · 확장-축소 5단계는 🔌 앱 연동 탭 SECTION 09 에 있으니 여기서는 폴더 지도에 집중합니다.

10SECTIONS
6마이그레이션 도구 비교
3역할 계정 (migrator · rw · ro)
sqlite3로 돌려 본 예제
STR 01

DB 도 코드다 — 무엇을 커밋하고 무엇을 빼나

스키마를 저장소에 두는 이유는 하나입니다. 빈 DB 에서 명령 한 번으로 지금의 운영 스키마를 똑같이 다시 만들 수 있어야 합니다. 그게 되면 신입의 개발 DB · CI 의 테스트 DB · 재해 복구용 새 서버가 모두 같은 파일에서 나옵니다. 그게 안 되면 "운영 DB 가 곧 원본"이 되고, 원본은 아무도 리뷰하지 않습니다.

앱 저장소 안의 db/ — 이 모양이 출발점

shop-api/
├─ db/
│  ├─ migration/        V__ 스키마 변경 이력 · 순서 있음 ★ (STR 02)
│  ├─ repeatable/       R__ 뷰 · 함수 · 프로시저 · 매번 최신으로 (STR 05)
│  ├─ seed/
│  │  └─ dev/           개발용 샘플 — 운영에는 안 감 (STR 06)
│  ├─ grants/           역할 · 권한 — 비밀번호는 빼고 (STR 04)
│  └─ README.md         적용 명령 · 명명 규칙 · 담당자
├─ compose.yml          로컬 DB 띄우기 (🧰 SETUP 03)
├─ .env.example         키 이름만 — 값은 비밀 저장소 (🧰 SETUP 08)
├─ .sqlfluff            SQL 린트 설정 (STR 09)
└─ src/

커밋하는 것 vs 빼는 것

✅ 커밋❌ 커밋 금지
DDL 마이그레이션 (V__)운영 데이터 덤프 — 개인정보 · 용량
뷰 · 함수 · 프로시저 정의 (R__)비밀번호 · 접속 문자열 · 키
참조 데이터 (코드표 · 국가)로컬 DB 파일 (*.db · 볼륨)
역할 구조와 GRANT생성물 (target/ · 생성 ERD)
린트 설정 · ERD 원본 · 데이터 사전개인 접속 설정 (profiles.yml)
"일단 schema.sql 을 덤프해서 커밋"은 시작으로는 괜찮지만 끝이 아닙니다. 덤프는 결과만 있고 순서가 없어서, 이미 데이터가 든 운영 DB 를 그 상태로 옮겨 가는 방법을 알려 주지 못합니다. 운영 DB 가 생기는 날 마이그레이션 폴더로 바꿉니다.

규모별 진화 — 언제 다음 단계로 넘어가나 🟢 기초

규모별 진화 — 위는 구조, 아래는 그 단계로 넘어가는 신호 GUI 로 직접 손 ALTER · 기록 없음 schema.sql 덤프 한 파일 커밋 마이그레이션 migration/V__ 순서 폴더 나누기 R__ · seed · grants 별도 저장소 여러 앱 · 샤드 · dbt 혼자 · 프로토타입 운영 DB 없음 두 번째 사람 새 PC 에 재현 필요 운영 DB · 환경 2개+ Flyway · Alembic 뷰 · 프로시저 10개+ DBA 리뷰 시작 DB 를 쓰는 앱 2개+ 배포 주기가 다름 넘어가는 신호는 테이블 수가 아니라 환경 수 · 사람 수 · DB 를 쓰는 앱 수입니다
초록 점이 권장 출발점 — 운영 DB 가 생기는 날 마이그레이션 폴더로 옮깁니다.
전환신호 (하나라도 해당하면)하는 일
GUI → schema.sql두 번째 개발자 · "내 PC 에선 테이블이 없어요"현재 스키마를 덤프해 커밋, README 에 적용 명령
schema.sql → 마이그레이션운영 DB 가 생김 · dev/stg/prod 중 둘 이상덤프를 V1__baseline.sql 로 두고 이후 변경은 새 파일로
마이그레이션 → 폴더 나누기뷰 · 함수 10개+ · 시드가 마이그레이션에 섞임repeatable/ · seed/ · grants/ 분리
앱 저장소 → 별도 저장소DB 를 쓰는 앱 2개+ · DBA 가 배포를 승인 · dbtSTR 03 의 기준으로 판단 — 대부분은 앱 저장소에 남습니다
정리 — 기준은 "빈 DB 에서 명령 한 번으로 운영과 같은 스키마가 나오는가". 스키마 · 뷰 · 권한 구조는 커밋하고, 데이터 덤프와 비밀번호는 절대 커밋하지 않습니다.
STR 02

마이그레이션 디렉터리 — 파일 이름이 곧 순서입니다

도구는 달라도 원리는 같습니다. 변경 하나 = 파일 하나, 도구가 DB 안의 이력 표를 보고 아직 안 한 파일만 정해진 순서로 실행합니다. 그래서 구조에서 중요한 것은 "순서를 무엇이 정하나"와 "한 번 나간 파일은 고치지 않는다" 두 가지입니다. 도구 비교 · 체크섬 원리 · 기본 명령은 🔌 앱 연동 탭 SECTION 09 에 있으니 여기서는 폴더 모양만 봅니다.

Flyway — 파일 이름 규칙

src/main/resources/db/migration/   Spring Boot 기본 위치
├─ V1__baseline.sql                  V + 버전 + 밑줄 두 개 + 설명
├─ V2__create_orders.sql
├─ V2_1__add_orders_memo.sql         V2_1 = 버전 2.1 (사이에 끼우기)
├─ V3__ix_orders_member_id.sql
└─ R__v_sales_daily.sql              R = 반복 실행 · 버전 없음
                                     → 내용(체크섬)이 바뀔 때마다 다시 실행
                                     → 모든 V 가 끝난 뒤 설명 이름순
밑줄은 두 개(__)입니다. 하나만 쓰면 Flyway 가 마이그레이션으로 인식하지 못하고 조용히 건너뜁니다(validateMigrationNaming 을 켜면 오류로 멈춤). 버전은 숫자로 비교하므로 V10 이 V9 뒤에 옵니다 — 사람이 파일 탐색기에서 이름순으로 보면 V10 이 V1 바로 뒤에 보이니 헷갈리지 마세요.

Liquibase — 목차(master) + include

src/main/resources/db/changelog/
├─ db.changelog-master.yaml   목차 — include 만, 내용 없음
└─ changes/
   ├─ 2026-09/
   │  ├─ 001-create-member.sql
   │  └─ 002-add-member-phone.sql
   └─ 2026-10/
      └─ 003-create-orders.sql
# db.changelog-master.yaml — 순서는 이 목록이 정합니다
databaseChangeLog:
  - include:
      file: changes/2026-09/001-create-member.sql
      relativeToChangelogFile: true
  - include:
      file: changes/2026-09/002-add-member-phone.sql
      relativeToChangelogFile: true
--liquibase formatted sql
--changeset hw:002
ALTER TABLE member ADD COLUMN phone VARCHAR(20);
--rollback ALTER TABLE member DROP COLUMN phone;
여섯 도구 — 가운데 진한 열이 "순서를 무엇이 정하나" 기본 위치 순서를 정하는 것 파일 형식 되돌리기 Flyway db/migration 파일명 버전 숫자 SQL · Java undo 는 유료 판 Liquibase db/changelog master 의 include SQL · YAML · XML --rollback 직접 Alembic alembic/versions down_revision 사슬 Python downgrade() Prisma prisma/migrations 폴더명 타임스탬프 SQL (생성) 없음 · 새 파일로 EF Core Migrations/ 타임스탬프 + 스냅숏 C# (생성) Down() 메서드 Django 앱/migrations dependencies 그래프 Python (생성) 자동 역방향 어느 도구든 순서는 파일이 정합니다 — 그래서 이미 나간 파일은 고치지 않습니다
위치와 형식은 달라도 "순서는 파일에 박혀 있다"는 점이 같습니다.

이미 적용된 마이그레이션은 고치지 않는다 — 직접 돌려 보기 🟡 중급

Flyway 의 규칙(V 는 버전 숫자순 · R 은 체크섬이 바뀌면 다시 · 적용된 V 가 바뀌면 실패)을 파이썬 표준 라이브러리 sqlite3 로 30줄에 흉내 내 이 PC 에서 돌렸습니다. 파일은 위 트리와 같은 V1 · V2 · V10 · R__ 네 개입니다.

# mini_migrate.py 핵심부 (Python 3.14 · 표준 라이브러리만)
def ver_key(p):          # V2_1 → (2, 1) · V10 이 V9 뒤에 오게
    return tuple(int(x) for x in
                 re.match(r"V([\d_]+)__", p.name)[1].split("_"))

versioned  = sorted(DIR.glob("V*__*.sql"), key=ver_key)
repeatable = sorted(DIR.glob("R__*.sql"))
for p in versioned + repeatable:          # V 전부 → R
    cs = sha256(p.read_bytes()).hexdigest()[:8]
    if p.name in done:
        if done[p.name] == cs: continue      # 그대로 · 건너뜀
        if p.name.startswith("V"):
            sys.exit(f"FAIL {p.name}: 적용된 파일이 바뀜 {done[p.name]} → {cs}")
    with db:                                 # 적용 + 이력을 한 트랜잭션
        db.executescript(p.read_text(encoding="utf-8"))
        db.execute("INSERT OR REPLACE INTO schema_history VALUES(?,?,?)",
                   (p.name, p.name.split("__")[0], cs))
$ python mini_migrate.py          # 1회차 — 빈 DB
applied V1__create_member.sql 1a52f23a
applied V2__add_member_phone.sql c9021886
applied V10__ix_member_phone.sql 16cefc6a   ← 숫자순이라 V2 뒤
applied R__v_member_contact.sql 73d7c944    ← V 가 다 끝난 뒤

$ python mini_migrate.py          # 2회차 — applied 줄이 하나도 없음

# R__ 뷰 정의를 고치고 다시 (STR 05)
applied R__v_member_contact.sql e89685b0    ← R 만 다시 실행

# 이미 적용된 V1 끝에 주석 한 줄을 붙이고 다시
FAIL V1__create_member.sql: 적용된 파일이 바뀜 1a52f23a → b5b96a7d
주석 한 줄도 체크섬을 바꿉니다. 운영에 나간 V 파일은 오타라도 그대로 두고, 고칠 것은 새 V 파일로 추가합니다. 아직 아무 환경에도 적용되지 않은(머지 전) 파일만 자유롭게 고칩니다. 참고로 이름순 정렬은 ['V10__…', 'V1__…', 'V2__…'] 를 돌려줍니다 — 도구가 숫자 비교를 하는 이유입니다.
도구폴더 · 파일 모양구조상 주의
Alembicalembic.ini · alembic/env.py · alembic/versions/<rev>_<설명>.py순서는 파일 안의 down_revision — 두 브랜치가 같은 부모를 가리키면 alembic merge
Prismaprisma/schema.prisma · prisma/migrations/<타임스탬프>_<이름>/migration.sqlmigration_lock.toml 도 커밋 · 운영은 migrate deploy 만
EF CoreMigrations/<타임스탬프>_<이름>.cs + .Designer.cs + ModelSnapshot.cs스냅숏 파일이 병합 충돌의 단골 — 한 번에 한 사람이 추가
Django<앱>/migrations/0001_initial.py앱마다 폴더 · 앱 사이 의존은 dependencies 로
정리 — 변경 하나 = 파일 하나, 순서는 파일 이름(또는 목차 · 사슬)이 정합니다. 한 번이라도 다른 환경에 적용된 파일은 고치지 않고 새 파일을 더합니다.
STR 03

앱 저장소 안 vs 별도 DB 저장소 — 주인이 누구인가

DB 코드를 어디에 둘지는 "편한 곳"이 아니라 "이 스키마를 바꿀 권한과 책임이 누구에게 있나"로 정합니다. 서비스 하나가 DB 하나를 가지면 답은 거의 항상 앱 저장소 안입니다. 여러 앱이 같은 테이블에 쓰기 시작하면 그때 고민이 생깁니다.

A. 앱 저장소 안 — 서비스 1 : DB 1

order-service/
├─ db/migration/          이 서비스의 sales 스키마만
├─ src/                   같은 PR 에서 스키마 + 코드
└─ .github/workflows/     배포 = 마이그레이션 → 앱 기동

장점  스키마와 코드가 한 번에 리뷰 · 한 번에 롤백
조건  다른 앱은 이 DB 에 직접 붙지 않음 (API 로)

B. 별도 DB 저장소 — 여러 앱이 공유할 때

legacy-erp-db/            여러 앱이 쓰는 DB 전용 저장소
├─ migration/
├─ repeatable/
├─ grants/
├─ CODEOWNERS             DBA + 소비 팀 리뷰 필수
├─ CHANGELOG.md           "V42 에서 orders.memo 삭제 예정"
└─ .github/workflows/     태그 v1.8.0 → 마이그레이션 배포

앱 쪽  "DB v1.8 이상 필요"를 README · 기동 검사로 명시
같은 세 서비스, 두 가지 배선 — 붉은 선이 "남의 테이블"입니다 공유 DB — 주인이 없음 서비스마다 DB — 주인이 하나 주문 회원 정산 orders member payment 모두 같은 DB 한 곳 주문 회원 정산 sales 스키마 member 스키마 billing 스키마 붉은 선 = 남의 테이블을 직접 읽고 씀 정산은 주문 데이터를 API · 이벤트로 받음 왼쪽에서 컬럼 하나를 바꾸려면 붉은 선 끝의 팀 전부와 배포 날짜를 맞춰야 합니다
공유 DB 의 비용은 "스키마 변경 = 여러 팀 동시 배포"가 되는 것입니다.
상황DB 코드를 둘 곳이유
서비스 1 : DB 1앱 저장소 db/스키마와 코드가 한 PR — 같이 리뷰 · 같이 롤백
남의 앱은 읽기만 (리포트)소유 앱 저장소 + 읽기 전용 뷰계약은 뷰 · 테이블은 소유 팀이 자유롭게 바꿈
여러 앱이 같은 테이블에 씀별도 DB 저장소 + 버전 태그어느 한 앱 저장소에 두면 다른 팀이 변경을 모름
분석 웨어하우스dbt 별도 저장소 (STR 07)쓰는 사람(분석가)과 배포 주기가 다름
DBA 가 운영 DDL 을 승인어느 쪽이든 + CODEOWNERS폴더 단위로 리뷰어를 강제 — 저장소를 나눌 필요는 없음
❌ 공유 DB 를 "편해서" 고름

새 서비스가 기존 서비스의 테이블에 바로 SELECT · UPDATE 를 붙입니다. 처음 한 달은 빠르지만, 그 테이블은 이제 두 팀의 공용 API가 되어 컬럼 이름 하나 못 바꿉니다. 누가 쓰는지 아는 방법은 쿼리 로그뿐입니다.

✅ 이미 공유 중이라면 — 경계를 그어 갑니다

① 별도 DB 저장소로 모아 변경을 한 곳에서 보이게 → ② 앱마다 전용 역할 계정(STR 04)으로 누가 무엇을 쓰는지 드러냄 → ③ 남의 테이블 접근을 뷰나 API 로 하나씩 바꿈. 한 번에 쪼개려 하지 않습니다.

정리 — 서비스 하나가 DB 를 소유하면 앱 저장소 안이 정답입니다. 여러 앱이 같은 테이블에 쓰는 경우에만 별도 저장소로 빼고, 그건 목표가 아니라 경계를 긋기 위한 중간 단계입니다.
STR 04

명명 규칙 · 스키마 나누기 — 이름과 권한도 구조입니다

DB 의 "폴더"는 스키마(namespace)이고, "파일 이름"은 테이블 · 제약 · 인덱스 이름입니다. 이름 규칙이 있으면 에러 메시지 한 줄만 보고 어느 테이블의 어떤 제약인지 알고, 마이그레이션에서 DROP INDEX 할 대상을 바로 씁니다. 규칙은 무엇이 정답이냐보다 한 저장소 안에서 하나로 통일하는 것이 중요합니다(아래 접두사 체계는 흔한 관례이지 표준은 아닙니다).

이름만 보고 종류 · 테이블 · 컬럼이 읽혀야 합니다 인덱스 ix order_item order_id_created_at 종류 pk fk uq ix ck 테이블 이름 그대로 스키마는 빼도 됨 컬럼 순서 = 인덱스 컬럼 순서 길면 줄임 — PG 는 63바이트에서 잘림 외래키 fk order_item orders 참조 대상 테이블 — 아래 에러에 이름이 그대로 찍힙니다 ERROR: insert or update on table "order_item" violates foreign key constraint "fk_order_item_orders" DETAIL: Key (order_id)=(999) is not present in table "orders". 자동 이름(Oracle SYS_C0012345 · PG order_item_order_id_fkey)은 DB 마다 다릅니다 — 이름을 직접 붙여 두면 어느 DB 에서든 같은 DROP 문이 됩니다
접두사 + 테이블 + 컬럼 — 에러 메시지가 곧 문서가 되게 이름을 붙입니다.

이름 규칙 한 장 (README 에 그대로)

대상규칙예
테이블snake_case · 단수/복수 중 하나로 통일order_item
PK · FKpk_<테이블> · fk_<테이블>_<참조>fk_order_item_orders
유니크 · 인덱스uq_ · ix_<테이블>_<컬럼들>uq_member_email
체크ck_<테이블>_<규칙>ck_orders_amount_pos
컬럼FK 는 <대상>_id · 시각 _at · 불리언 is_member_id · paid_at
뷰 · 함수v_ · fn_ · sp_v_sales_daily
단수냐 복수냐는 취향이지만 하나만 알아 두세요. order · user 는 SQL 예약어라서 단수형을 고르면 따옴표를 달거나 피해 가야 합니다. 이 탭의 예제가 orders 만 복수인 이유입니다. 길이 한계는 PG 63바이트 · MySQL 64자 · Oracle 128바이트(12.2 이후)입니다.

스키마로 도메인 나누기 (PostgreSQL)

appdb                  데이터베이스 하나
├─ core                회원 · 코드표 — 여럿이 읽음
├─ sales               주문 · 결제   — 주문 팀 소유
├─ stock               재고          — 재고 팀 소유
├─ audit               변경 이력     — 앱은 INSERT 만
└─ public              비워 둠
"스키마"의 뜻이 DB 마다 다릅니다. PostgreSQL · SQL Server 는 DB 안의 이름공간, MySQL 은 스키마 = 데이터베이스, Oracle 은 스키마 = 사용자입니다. 그래서 MySQL 에서는 같은 구조를 데이터베이스 여러 개로, Oracle 에서는 소유 계정 여러 개로 만듭니다. search_path 와 스키마 기본은 🐘 PostgreSQL 탭 SECTION 04 참고.

권한 역할 구조 — DDL 하는 계정과 앱 계정을 나눈다 🟡 중급

앱 계정과 읽기 전용 계정을 만드는 첫 단계는 🧰 SETUP 09 에 있습니다. 저장소 구조 관점에서 한 걸음 더 가면 역할을 세 층으로 나누고 이 정의 자체를 db/grants/ 에 커밋합니다. 그러면 "운영에서 누가 테이블을 만들 수 있나"가 코드 리뷰 대상이 됩니다.

-- db/grants/R__roles.sql — 여러 번 실행해도 같은 결과가 되게 작성
-- migrator : 스키마 소유자 · DDL 전용 · 마이그레이션 도구만 이 계정 사용
-- app_rw   : 앱 런타임 · DML 만 (CREATE · DROP 불가)
-- app_ro   : 리포트 · 분석 · 운영 조회
GRANT USAGE ON SCHEMA sales TO app_rw, app_ro;

-- migrator 가 앞으로 만들 테이블에도 자동 적용
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA sales
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA sales
  GRANT USAGE, SELECT ON SEQUENCES TO app_rw;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA sales
  GRANT SELECT ON TABLES TO app_ro;
-- 역할 자체는 한 번만 (DBA · 인프라 코드에서)
CREATE ROLE migrator LOGIN;
CREATE ROLE app_rw NOLOGIN;     -- 묶음 역할 · 직접 로그인 안 함
CREATE ROLE app_ro NOLOGIN;
CREATE SCHEMA sales AUTHORIZATION migrator;

-- 실제 로그인 계정은 앱마다 하나씩 → 누가 쓰는지 드러남
CREATE ROLE order_api  LOGIN IN ROLE app_rw;
CREATE ROLE report_bot LOGIN IN ROLE app_ro;
-- 비밀번호는 여기 쓰지 않음 → 비밀 저장소에서 ALTER ROLE 로
앱이 migrator 계정으로 돌면 안 됩니다. SQL 인젝션 한 번이 DROP TABLE 이 됩니다. 그리고 ALTER DEFAULT PRIVILEGES 는 FOR ROLE 로 지정한 계정이 만든 객체에만 적용되므로, 테이블을 다른 계정으로 만들면 권한이 안 붙습니다 — DDL 계정을 하나로 정하는 이유입니다.
정리 — 이름은 접두사 + 테이블 + 컬럼으로 직접 붙이고, 스키마로 도메인을, 역할로 DDL(migrator) · 앱(app_rw) · 조회(app_ro)를 나눕니다. 역할 정의도 db/grants/ 에 커밋합니다.
STR 05

SQL 파일 정리 — 뷰 · 함수 · 프로시저 · 쿼리는 어디에

테이블은 "변경 이력"이 중요하고, 뷰 · 함수 · 프로시저는 "지금의 정의"가 중요합니다. 뷰 하나 고칠 때마다 V 파일을 새로 만들면 정의 전체가 여러 파일에 흩어져 지금 모양을 아무도 모르게 됩니다. 그래서 이 둘은 폴더를 나누고, 뒤쪽은 여러 번 실행해도 같은 결과(멱등)가 되게 씁니다.

db/
├─ migration/                 V__ 테이블 · 컬럼 · 인덱스 · 제약 — 이력이 중요
├─ repeatable/                R__ 지금의 정의가 중요 — 고치면 그 파일을 고침
│  ├─ R__10_fn_order_total.sql      함수 먼저 (번호로 순서 고정)
│  ├─ R__20_v_sales_daily.sql       뷰는 함수 뒤
│  └─ R__30_sp_close_month.sql      프로시저
└─ queries/                   자동 적용하지 않는 SQL — 리포트 · 운영 점검
   ├─ reports/monthly_sales.sql
   └─ ops/blocking_locks.sql        락 확인 (⚙️ 설계 · 튜닝 탭)

멱등하게 쓰는 법 — DB 별

-- PostgreSQL · Oracle · MySQL : 뷰는 OR REPLACE
CREATE OR REPLACE VIEW sales.v_sales_daily AS
SELECT order_date, count(*) AS orders, sum(amount) AS revenue
FROM   sales.orders
GROUP  BY order_date;

-- PG : CREATE OR REPLACE VIEW 는 컬럼을 "뒤에 추가"만 됨
--      컬럼 삭제 · 이름 변경 · 순서 변경은 DROP 후 다시
DROP VIEW IF EXISTS sales.v_sales_daily;   -- CASCADE 는 쓰지 말 것
CREATE VIEW sales.v_sales_daily AS ...;

-- SQLite : OR REPLACE 가 없어서 언제나 DROP IF EXISTS + CREATE
R__ 은 설명 이름순으로 실행됩니다. 뷰가 함수를 쓰면 함수 파일이 먼저 와야 하므로 R__10_ · R__20_ 처럼 번호를 이름 앞에 둡니다. DROP … CASCADE 는 의존하는 다른 뷰까지 지우고 그 뷰는 다시 만들어지지 않으니 피합니다.

sqlite3 로 확인 — 정의만 바꾸면 R 만 다시

-- R__v_member_contact.sql (STR 02 의 예제 저장소)
DROP VIEW IF EXISTS v_member_contact;
CREATE VIEW v_member_contact AS
  SELECT id, email, coalesce(phone, '(없음)') AS phone
  FROM member;
$ python mini_migrate.py      # '-' → '(없음)' 으로만 고친 뒤
applied R__v_member_contact.sql e89685b0
tables: ['member', 'schema_history', 'v_member_contact']
queries/ 는 도구가 실행하지 않는 폴더입니다. "이 SQL 은 어디서 돌던 거지?"를 없애려고 둡니다. 여기에도 린트(STR 09)는 걸어 두면 문법이 깨진 채로 쌓이지 않습니다.

SQL 을 앱 코드에 둘 때 — 어디에 두고 무엇이 생성되나 🟡 중급

가로는 SQL 을 직접 쓰는 정도, 세로는 빌드 때 잡아 주는 정도 SQL 을 손으로 쓰는 정도 → 빌드 때 잡음 실행해야 앎 SQL 그대로 + 스키마와 대조 JPA 엔티티 jOOQ sqlc MyBatis XML 문자열 SQL 오른쪽 아래는 나쁘다는 뜻이 아니라 "테스트가 그 몫을 대신해야 한다"는 뜻입니다
SQL 을 직접 쓸수록 생성 코드나 테스트로 스키마와 맞춰 보는 장치가 필요합니다.
방식SQL 이 사는 곳생성물 · 주의
MyBatissrc/main/resources/mapper/<도메인>/OrderMapper.xml + 같은 이름의 자바 인터페이스mybatis.mapper-locations=classpath:mapper/**/*.xml · 생성물 없음 → 매퍼 테스트 필수
sqlc (Go 등)db/queries/*.sql · 스키마는 db/migration 을 그대로 읽음sqlc.yaml 의 out 폴더에 코드 생성 · 커밋한다면 CI 에서 sqlc diff
jOOQ자바 DSL — SQL 문자열 대신 생성된 테이블 클래스빌드 때 마이그레이션을 적용한 DB 에서 생성 → 보통 build/ 아래 · 커밋 안 함
ORM (JPA 등)엔티티 클래스 · 복잡한 쿼리만 @Query스키마는 마이그레이션이 원본 — Hibernate 의 ddl-auto=update 는 운영 금지 · ORM 선택은 🔌 SECTION 06
정리 — 테이블은 migration/(이력), 뷰 · 함수 · 프로시저는 repeatable/(지금 정의, 멱등), 손으로 돌리는 SQL 은 queries/. 앱 안의 SQL 은 스키마와 맞춰 보는 장치(생성 코드 · 테스트)와 함께 둡니다.
STR 06

시드 · 테스트 데이터 — 세 종류를 섞지 않는다

"초기 데이터"라는 말 하나에 성격이 전혀 다른 세 가지가 섞여 있습니다. 운영에도 있어야 하는 참조 데이터, 개발자 화면을 채우는 샘플, 테스트 하나가 쓰고 버리는 픽스처. 이 셋을 한 파일에 두면 언젠가 샘플 회원 "홍길동"이 운영 DB 에 들어갑니다.

db/
├─ migration/
│  └─ V5__seed_order_status.sql     참조 데이터 — 운영에도 필요하니 마이그레이션으로
├─ seed/
│  └─ dev/                          개발용 샘플 — dev 프로필에서만 적용
│     ├─ R__dev_members.sql
│     └─ R__dev_orders.sql
└─ mask/
   └─ mask_members.sql              운영 사본을 스테이징에 넣기 전에 실행
src/test/resources/fixtures/        테스트 픽스처 — 테스트마다 만들고 지움 (🔌 SECTION 12)
데이터 종류 × 환경 — 진한 칸만 적용합니다 개발 (dev) CI 테스트 스테이징 운영 참조 데이터 적용 적용 적용 적용 개발 샘플 적용 – – 금지 테스트 픽스처 – 테스트마다 – 금지 마스킹 사본 선택 – 적용 원본 운영 열에 진한 칸은 참조 데이터 하나뿐 — 그래서 그것만 마이그레이션에 둡니다
적용 환경이 다르면 폴더도 달라야 합니다 — 운영 열이 진한 것만 migration/ 으로.

참조 데이터는 멱등하게 — sqlite3 로 3번 실행

INSERT INTO order_status(code, label) VALUES
  ('NEW','접수'), ('PAID','결제완료'), ('SHIPPED','배송중')
ON CONFLICT(code) DO UPDATE SET label = excluded.label;
-- 3번 실행 → count(*) = 3 (중복 없음)
-- 그냥 INSERT 였다면 2번째에
-- IntegrityError: UNIQUE constraint failed: order_status.code
# application-dev.yml — dev 프로필에서만 샘플 폴더를 더함
spring:
  flyway:
    locations: classpath:db/migration,classpath:db/seed/dev
# application.yml(운영)은 기본값 db/migration 만
ON CONFLICT … DO UPDATE 는 PostgreSQL · SQLite 문법입니다. MySQL 은 ON DUPLICATE KEY UPDATE, Oracle 은 MERGE.

운영 사본은 마스킹한 뒤에만 — mask_members.sql

-- 스테이징으로 복원한 직후, 앱을 붙이기 전에 실행
UPDATE member SET
  name  = '회원' || id,
  email = 'user' || id || '@example.com',     -- UNIQUE 유지
  phone = CASE WHEN phone IS NULL THEN NULL
               ELSE '010-0000-' || lpad((id % 10000)::text, 4, '0') END;
-- grade · 가입일은 그대로 → 분포가 살아 있어 성능 테스트에 쓸모 있음
(1, '회원1', 'user1@example.com', '010-0000-0001', 'VIP')
(2, '회원2', 'user2@example.com', None, 'BASIC')
# sqlite3 확인 결과 (sqlite 는 lpad 대신 substr('0000'||id, -4))
NULL 은 NULL 로 둡니다. 빈 값까지 가짜 번호로 채우면 "전화번호 없는 회원" 경로가 테스트에서 사라집니다. 마스킹 규칙 자체(무엇이 개인정보인가 · 가명처리)는 🧬 전문가 탭 SECTION 14 참고.
정리 — 운영에도 필요한 참조 데이터만 마이그레이션에, 개발 샘플은 seed/dev 로 프로필을 갈라, 픽스처는 테스트 코드 옆에. 운영 사본은 마스킹 전에는 어디에도 복원하지 않습니다.
STR 07

dbt 프로젝트 구조 — staging · intermediate · marts

분석 쪽 SQL 은 "변경 이력"보다 "어떤 표가 무엇에서 나왔나(계보)"가 중요합니다. dbt 는 SQL 파일과 ref() 로 그 계보를 만들고, 폴더 구조가 곧 데이터가 정제되는 단계가 됩니다. 아래는 dbt Labs 의 공식 글 "How we structure our dbt projects" 의 권장 구조를 따릅니다. dbt 가 무엇이고 run · test · 증분 모델이 어떻게 도는지는 📊 분석 · 벡터 DB 탭 OLAP 12 에 있습니다.

공식 권장 트리

jaffle_shop/
├─ dbt_project.yml        이름 · 폴더별 기본 materialized
├─ packages.yml           dbt_utils 같은 패키지
├─ models/
│  ├─ staging/            원본 표 1개 = 모델 1개 · 이름 · 형 변환만
│  │  └─ jaffle_shop/     원본 시스템별 폴더
│  │     ├─ _jaffle_shop__sources.yml
│  │     ├─ _jaffle_shop__models.yml
│  │     ├─ stg_jaffle_shop__customers.sql
│  │     └─ stg_jaffle_shop__orders.sql
│  ├─ intermediate/       stg 를 조합하는 중간 단계 · 밖에 노출 안 함
│  │  └─ finance/
│  │     └─ int_payments_pivoted_to_orders.sql
│  └─ marts/              사람이 보는 결과 · 업무 영역별
│     ├─ finance/orders.sql
│     └─ marketing/customers.sql
├─ seeds/                 작은 CSV 참조표 (국가 코드 등)
├─ snapshots/             천천히 바뀌는 값의 이력 (SCD2)
├─ macros/  tests/  analyses/
└─ .gitignore             target/ · dbt_packages/ · logs/

폴더별 기본값 — dbt_project.yml

name: jaffle_shop
profile: jaffle_shop      # 접속 정보는 profiles.yml (기본 ~/.dbt/) — 커밋 안 함

models:
  jaffle_shop:
    staging:
      +materialized: view        # 가볍게 · 항상 최신
    intermediate:
      +materialized: ephemeral   # 표로 안 남김 (CTE 로 끼워 넣음)
    marts:
      +materialized: table       # 사람이 조회 → 미리 계산
층이름하는 일
stagingstg_<원본>__<표> (밑줄 두 개)이름 바꾸기 · 형 변환 · 조인 없음
intermediateint_<무엇>_<동사>조인 · 피벗 · 재사용 계산
martsorders 또는 fct_ · dim_업무 단위 결과
데이터는 왼쪽에서 오른쪽으로만 — 폴더 순서가 곧 정제 순서 sources 원본 · 손 안 댐 staging stg_ · view intermediate int_ · ephemeral marts table BI 대시보드 source() 로만 원본 1 : 모델 1 재사용 조합 업무 영역별 marts 만 읽음 ref() 는 오른쪽으로만 — marts 가 source() 를 직접 부르면 계층이 무너집니다 staging 을 건너뛴 모델이 하나 생기면 원본 컬럼 이름 변경이 그 모델까지 번집니다
원본 이름이 바뀌어도 고칠 곳은 staging 한 파일 — 그게 층을 나누는 이유입니다.

staging 모델 — 이름 바꾸기만

-- models/staging/jaffle_shop/stg_jaffle_shop__orders.sql
with source as (
    select * from {{ source('jaffle_shop', 'orders') }}
),
renamed as (
    select
        id          as order_id,
        user_id     as customer_id,
        order_date,
        status
    from source
)
select * from renamed

원본 선언 + 테스트 · 문서 (yml)

# _jaffle_shop__sources.yml
sources:
  - name: jaffle_shop
    schema: raw_jaffle_shop
    tables:
      - name: orders

# _jaffle_shop__models.yml
models:
  - name: stg_jaffle_shop__orders
    description: "주문 1건 = 1행"
    columns:
      - name: order_id
        data_tests: [unique, not_null]
버전 차이 — dbt 1.8 부터 yml 의 tests: 가 data_tests: 로 바뀌었습니다(옛 이름도 동작). 공식 가이드는 marts 를 orders 처럼 엔티티 이름 그대로 두는 예를 보여 주고, Kimball 식 fct_ · dim_ 접두사도 널리 씁니다 — 저장소 안에서 하나로 통일하면 됩니다.
정리 — models/ 아래 staging(정리) → intermediate(조합) → marts(결과), 이름은 stg_ · int_ 로 층을 드러내고, ref() 는 오른쪽으로만. 접속 정보(profiles.yml)와 target/ 은 커밋하지 않습니다.
STR 08

여러 DB · 샤드 · 환경 — 파일은 하나, 설정만 여럿

dev · stg · prod, 샤드 8개, 고객사별 DB 30개 — 대상이 늘어도 마이그레이션 폴더는 하나입니다. 달라지는 것은 접속 설정과 실행 횟수뿐입니다. 환경마다 다른 SQL 파일을 두는 순간 "운영에만 있는 컬럼"이 구조적으로 생깁니다.

환경 · 샤드가 있는 저장소

db/
├─ migration/          모든 환경 · 모든 샤드가 같은 파일 ★
├─ repeatable/
├─ env/
│  ├─ dev.env          FLYWAY_URL · FLYWAY_USER (비밀번호 X)
│  ├─ stg.env
│  └─ prod.env
└─ shards.txt          샤드 접속 URL 목록 (주석 # 허용)

금지  migration-prod/ · if 환경 == prod 같은 SQL 분기
# 환경은 설정만 다르고 파일은 같습니다
set -a; . db/env/stg.env; set +a     # FLYWAY_URL · FLYWAY_USER
export FLYWAY_PASSWORD="$STG_DB_PASSWORD"   # CI 비밀 변수에서
flyway migrate

샤드마다 같은 마이그레이션

#!/usr/bin/env bash
# db/migrate-shards.sh — 하나라도 실패하면 즉시 멈춤
set -euo pipefail
for url in $(grep -v '^#' db/shards.txt); do
  echo "== $url"
  flyway -url="$url" migrate
done

# 끝나면 남은(Pending) 마이그레이션이 있는 샤드가 없는지 확인
for url in $(grep -v '^#' db/shards.txt); do
  out=$(flyway -url="$url" info)
  if grep -q Pending <<< "$out"; then echo "남음: $url"; exit 1; fi
done
샤드 1 은 V21, 샤드 7 은 아직 V20 인 시간이 반드시 있습니다. 그 사이에도 앱은 두 스키마 모두에서 돌아야 합니다. 그래서 샤드 · 멀티 DB 환경에서는 아래의 확장-축소가 선택이 아니라 필수입니다.

블루-그린 · 확장-축소 — 마이그레이션과 배포의 순서 🔴 고급

다섯 단계 각각의 내용(NULL 허용 추가 → 양쪽 쓰기 → 백필 → 읽기 전환 → 정리)은 🔌 앱 연동 탭 SECTION 09 에 있습니다. 여기서 볼 것은 배포 축 위에서 앱 버전과 마이그레이션이 어떻게 겹치는지입니다. 블루-그린 배포는 옛 버전과 새 버전이 같은 DB 를 동시에 씁니다.

확장-축소 — 옛 컬럼을 지우는 것은 맨 마지막, 별도 배포입니다 배포 1 배포 2 배포 3 배포 4 배포 5 V20 확장 · 컬럼 추가 NULL 허용으로 먼저 추가 앱 v1 · 옛 컬럼만 v1 · v2 가 함께 도는 구간 앱 v2 · 양쪽에 씀 옛 · 새 컬럼 모두 씀 백필 배치 청크로 나눠 채움 앱 v3 · 새 컬럼만 읽음 읽기 전환 V24 축소 · 옛 컬럼 DROP v2 가 모두 내려간 뒤에만 샤드가 여럿이면 V20 · V24 가 샤드마다 다른 시각에 적용됩니다 — 그래도 안전해야 합니다
v1 · v2 가 겹치는 구간(축 위 치수선) 때문에 한 배포 안에서 "추가 + 삭제"를 같이 하지 않습니다.
정리 — 환경 · 샤드가 몇 개든 마이그레이션 폴더는 하나, 다른 것은 접속 설정과 실행 횟수. 여러 버전이 같은 DB 를 쓰는 구간이 있으니 확장은 배포 전, 축소는 옛 버전이 사라진 뒤 별도 배포로 합니다.
STR 09

리뷰 · CI — 사람이 보기 전에 기계가 거른다

DB 변경 리뷰가 어려운 이유는 문법이 아니라 운영에서의 결과(잠금 시간 · 구버전 앱 호환)를 봐야 하기 때문입니다. 그래서 문법 · 형식 · 알려진 위험 패턴 · "빈 DB 에 전부 적용되는가"는 CI 가 먼저 거르고, 사람은 설계와 운영 영향만 봅니다.

DB 변경 PR — 앞의 넷은 기계, 마지막 하나만 사람 ① 린트 sqlfluff ② 위험 DDL 잠금 · DROP 탐지 ③ 빈 DB 적용 V1 부터 전부 ④ 되돌리기 down → up 다시 ⑤ 사람 리뷰 설계 · 운영 영향 자동 — 하나라도 실패하면 리뷰를 요청하지 않습니다 ③ 이 가장 중요합니다 — 깨진 마이그레이션은 새 환경을 만들 때에야 드러나기 때문입니다
붉은 칸 — "빈 DB 에 처음부터 전부"가 STR 01 의 기준을 매번 검사합니다.

CI — 빈 PostgreSQL 에 전부 적용 (GitHub Actions)

jobs:
  db:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env: { POSTGRES_PASSWORD: ci }   # CI 전용 일회용 컨테이너
        ports: ["5432:5432"]
        options: --health-cmd pg_isready --health-interval 5s --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - run: pipx install sqlfluff && sqlfluff lint db/
      - run: python db/ci/check_ddl.py $(git diff --name-only --diff-filter=A origin/main -- db/migration)
      - name: migrate on empty DB (twice = no-op)
        run: |
          for i in 1 2; do
            docker run --rm --network host -v "$PWD/db/migration:/flyway/sql" \
              flyway/flyway -url=jdbc:postgresql://localhost:5432/postgres \
              -user=postgres -password=ci migrate
          done
git diff origin/main 을 쓰려면 checkout 에 fetch-depth: 0 이 필요합니다. 되돌리기 검사(④)는 down 을 지원하는 도구에서 alembic upgrade head && alembic downgrade base && alembic upgrade head 처럼 왕복시킵니다. Flyway 무료 판처럼 down 이 없으면 ④ 대신 "고치는 V 파일을 하나 더 쓰는 법"을 PR 설명에 적게 합니다.

위험 DDL 검사 — check_ddl.py (실행 확인)

RULES = [  # PostgreSQL 기준 · 주석(--)은 지우고 검사
  (r"CREATE\s+(UNIQUE\s+)?INDEX\s+(?!CONCURRENTLY)",
   "CONCURRENTLY 없는 인덱스 생성 — 쓰기 차단"),
  (r"ALTER\s+COLUMN\s+\w+\s+(SET\s+DATA\s+)?TYPE",
   "컬럼 타입 변경 — 테이블 재작성 가능"),
  (r"SET\s+NOT\s+NULL", "SET NOT NULL — 전체 검사 동안 잠금"),
  (r"ADD\s+(CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY(?![^;]*NOT\s+VALID)",
   "FK 를 NOT VALID 없이 추가 — 전체 검증"),
  (r"DROP\s+(COLUMN|TABLE)", "DROP — 축소 단계인가?"),
]
$ python check_ddl.py V7__ix.sql V8__ix_ok.sql V9__fk_ok.sql V10__bad.sql
V7__ix.sql: CONCURRENTLY 없는 인덱스 생성 — 쓰기 차단
V10__bad.sql: 컬럼 타입 변경 — 테이블 재작성 가능
V10__bad.sql: FK 를 NOT VALID 없이 추가 — 전체 검증
# exit 1 → CI 실패 · V8(CONCURRENTLY) · V9(NOT VALID + VALIDATE) 는 통과
직접 만들기 싫다면 PostgreSQL 마이그레이션 전용 린터 squawk 가 같은 종류의 규칙을 갖고 있습니다. CREATE INDEX CONCURRENTLY 는 트랜잭션 안에서 못 돌므로 그 문장 하나만 든 파일로 분리해 두는 것이 안전합니다. 잠금이 왜 걸리는지는 ⚙️ 설계 · 튜닝 탭의 락 섹션 참고.

.sqlfluff — 저장소 루트에 하나

[sqlfluff]
dialect = postgres
templater = jinja        # dbt 프로젝트면 dbt
max_line_length = 120

[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper
기존 SQL 이 많은 저장소에 처음 걸면 수천 건이 나옵니다. 처음엔 CI 에서 새로 추가된 파일만 검사하고, sqlfluff fix 로 한 번에 고치는 PR 을 따로 냅니다.

사람 리뷰 체크리스트

#확인할 것
1이미 나간 V 파일을 고치지 않았나 · 변경 하나 = 파일 하나인가
2지금 운영 중인 구버전 앱이 이 스키마에서도 도나 (확장 단계인가)
3큰 테이블이면 스테이징에서 걸린 시간을 PR 에 적었나
4새 인덱스는 어떤 쿼리를 위한 것인가 (실행 계획 첨부)
5이름 규칙 · 스키마 지정 (sales.orders) · 권한 자동 부여
6대량 UPDATE 는 마이그레이션이 아니라 배치로 뺐나
정리 — CI 가 린트 → 위험 DDL → 빈 DB 에 전부 적용(두 번) → 되돌리기를 먼저 돌리고, 사람은 체크리스트로 구버전 호환 · 잠금 시간 · 인덱스의 이유만 봅니다.
STR 10

유형 · 규모별 권장 구조 한 장 — 그리고 문서화

지금까지의 구조를 한 표로 모았습니다. 대부분의 팀은 둘째 줄(서비스 하나 · 앱 저장소의 db/)에서 시작해 오래 머뭅니다. 마지막으로 ERD · 데이터 사전을 손으로 그리지 않고 스키마에서 생성하는 법과, 규칙을 사람이 아니라 도구가 지키게 하는 법을 정리합니다.

유형 · 규모권장 구조흔한 도구
혼자 · 내장 SQLite 앱migrations/ 한 폴더 · 앱 시작 때 적용Alembic · 직접 구현(PRAGMA user_version)
서비스 1 · 팀 2~8명앱 저장소 db/ = migration + repeatable + seed/dev + grantsFlyway · Alembic · Prisma · EF Core
서비스 여러 개 (MSA)서비스마다 자기 db/ · 남의 데이터는 API · 이벤트도구를 하나로 통일하면 CI 재사용
레거시 공유 DB별도 DB 저장소 + 버전 태그 + CODEOWNERS + CHANGELOGLiquibase · Flyway
샤드 · 테넌트별 DB폴더 하나 · 순회 실행 스크립트 · 샤드별 버전 확인Flyway · 직접 스크립트
프로시저 중심 (Oracle 등)repeatable/ 아래 패키지 · 프로시저를 R__ 로 · DBA 리뷰Liquibase · Flyway
분석 웨어하우스dbt 저장소 staging → intermediate → martsdbt · sqlfluff(dbt templater)
DB 코드는 어디에 — 마름모가 질문, 초록 상자가 가장 흔한 답 분석 · 리포트용 웨어하우스인가? 아니오 예 dbt 프로젝트 저장소 STR 07 DB 에 쓰는 앱이 하나뿐인가? 예 앱 저장소의 db/ STR 01 · 가장 흔한 답 아니오 남의 테이블 쓰기를 지금 끊을 수 있나? 예 서비스별 스키마로 분리 각 앱 저장소로 · STR 03 아니오 별도 DB 저장소 CODEOWNERS · 버전 태그 샤드 · 테넌트가 몇 개든 폴더는 하나 — 실행만 순회합니다 (STR 08) 별도 저장소는 목표가 아니라 경계를 긋는 동안의 중간 단계 모든 가지가 답에 닿습니다 — 답이 없는 경우는 "아직 운영 DB 가 없다" 뿐
세 질문이면 끝납니다 — 대부분은 두 번째 마름모에서 "예"로 앱 저장소에 둡니다.

문서화 — ERD 와 데이터 사전은 생성한다 🟡 중급

docs/
└─ db/                 CI 가 생성 · 손으로 고치지 않음
   ├─ README.md        테이블 목록 · 설명 (COMMENT 에서)
   ├─ schema.svg       ERD
   └─ sales.orders.md  테이블별 컬럼 · 제약 · 관계
-- 데이터 사전의 원본은 DB 의 COMMENT — 마이그레이션에 같이 씀
COMMENT ON TABLE  sales.orders        IS '주문 1건 = 1행';
COMMENT ON COLUMN sales.orders.amount IS '부가세 포함 결제 금액(원)';
COMMENT ON COLUMN sales.orders.status IS 'order_status.code 참조';
# 빈 DB 에 마이그레이션 적용(STR 09 의 ③) 직후 — tbls 로 문서 생성
tbls doc "$TBLS_DSN" docs/db --force
git diff --exit-code docs/db   # 커밋된 문서가 최신이 아니면 실패
무엇어디서 생성
ERD · 테이블 문서마이그레이션을 적용한 DB → tbls · SchemaSpy 같은 생성기
컬럼 설명 (데이터 사전)COMMENT ON — 마이그레이션 안에 · DBeaver 같은 도구에도 그대로 보임
분석 모델 계보dbt docs generate — yml 의 description 에서
설계 의도 · 결정사람이 쓰는 docs/adr/ — 생성할 수 없는 유일한 부분
손으로 그린 ERD 는 그린 다음 날부터 틀립니다. 그림 도구의 파일을 저장소에 두더라도 원본은 스키마이고, 생성된 문서가 그 사실을 매 PR 마다 확인하게 둡니다.

규칙을 도구로 강제하기 🟡 중급

규칙지키게 하는 장치
나간 V 파일 수정 금지도구의 체크섬 검사 (flyway validate) · CI 에서 매번
오래 잠그는 DDL 금지check_ddl.py · squawk (STR 09)
명명 규칙카탈로그 조회 테스트 — 오른쪽 SQL 이 0행이어야 통과
DB 변경은 DBA 리뷰CODEOWNERS 에 /db/ @org/dba + 브랜치 보호
앱은 DDL 불가역할 분리 — 앱은 app_rw 로만 접속 (STR 04)
문서 최신CI 에서 재생성 후 git diff --exit-code
-- CI 의 ③ 직후 실행 — 규칙을 어긴 인덱스 이름 찾기 (PostgreSQL)
SELECT schemaname, tablename, indexname
FROM   pg_indexes
WHERE  schemaname IN ('core', 'sales', 'stock')
AND    indexname !~ '^(pk|uq|ix)_';
-- PK · UNIQUE 제약의 인덱스는 제약 이름을 따라가므로
-- 제약에 pk_ · uq_ 를 붙였다면 여기서 함께 걸러집니다
도구는 "확신 있는 만큼"만 고르세요. 위의 장치는 모두 DB 카탈로그 · 체크섬 · 정규식처럼 단순한 것들입니다. 여기서 시작해 규칙이 늘면 그때 전용 린터를 붙여도 늦지 않습니다.
정리 — 대부분은 앱 저장소의 db/로 충분하고, 여러 앱 · 분석 · 샤드일 때만 표의 아래 줄로 갑니다. ERD · 데이터 사전은 스키마에서 생성하고, 규칙은 체크섬 · 린트 · 카탈로그 테스트 · CODEOWNERS 로 도구가 지키게 합니다.