데이터는
어디에 쌓이고
어떻게 꺼내는가
이 가이드는 DB를 한 번도 설치해 본 적 없는 사람을 기준으로 시작합니다. 설치 → 접속 → 첫 조회까지 막히지 않게 OS별 명령을 그대로 적어 두었고, 이후 SQL 기초 → 전문가 문법 → DB별 방언 → 튜닝 → 내부 구조까지 이어집니다. 네 가지 관계형 DB(Oracle · PostgreSQL · MySQL/MariaDB · SQLite3)에 더해 NoSQL 두 가지(⚡ Redis · 🍃 MongoDB)도 각각 전용 탭으로 다룹니다.
데이터베이스란 무엇인가
"엑셀로 하면 되지 않나요?" — 맞습니다. 혼자 쓰면 엑셀로 충분합니다. 데이터베이스가 필요해지는 순간은 혼자가 아닐 때입니다.
엑셀 파일로 버티다 반드시 만나는 네 가지 벽
용어 다섯 개면 시작할 수 있습니다
| 용어 | 엑셀로 치면 | 설명 |
|---|---|---|
| 데이터베이스 | 엑셀 파일 하나 | 서로 관련된 표들을 담는 그릇. 서비스마다 보통 하나씩 만듭니다. |
| 테이블 | 시트 한 장 | 같은 종류의 데이터를 모은 표. 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 탭에서 전용으로 다룹니다.
6대 DB 한눈에 — 무엇을 고를까
아래 버튼을 눌러 DB를 바꿔 보세요. 성격 · 강점 · 함정 · 대표 코드가 함께 바뀝니다. 관계형 4개(Oracle·PostgreSQL·MySQL·SQLite)에 NoSQL 2개(Redis·MongoDB)까지 나란히 놓았습니다.
한 장 비교표 — 관계형 4 + NoSQL 2
| 항목 | Oracle | PostgreSQL | MySQL / MariaDB | SQLite | Redis | MongoDB |
|---|---|---|---|---|---|---|
| 종류 | 관계형 (표) | 관계형 (표) | 관계형 (표) | 관계형 (표) | 키-값 (메모리) | 문서형 (JSON) |
| 구조 | 서버형 | 서버형 | 서버형 | 파일 하나 (서버 없음) | 서버형 · 인메모리 | 서버형 |
| 비용 | 유료 (Free 판 제한) | 완전 무료 | 무료 (MySQL은 유료판 별도 · MariaDB는 전부 무료) | 완전 무료 (저작권 자체가 없음) | 무료 (완전 오픈소스는 Valkey) | 무료 (서비스로 팔 때만 제약) |
| 기본 포트 | 1521 | 5432 | 3306 | 없음 | 6379 | 27017 |
| 질의 언어 | SQL (방언 강함) | SQL (표준 준수 최고) | SQL (보통) | SQL (일부만) | 전용 명령 (GET·SET…) | JSON 쿼리 · 파이프라인 |
| 동시 쓰기 | 매우 강함 | 강함 | 강함 | 사실상 1개 | 매우 빠름 (단일 스레드 순차) | 강함 |
| 설치 난이도 | 어려움 | 쉬움 | 쉬움 | 설치 없음 | 아주 쉬움 | 쉬움 |
| 대표 사용처 | 금융·통신·공공 기간계 | 신규 웹·분석·지리·AI | 일반 웹 서비스·커머스 | 모바일·데스크톱·엣지 | 캐시·세션·순위표 | 로그·카탈로그·유연 문서 |
| 이 가이드 탭 | 🅾️ Oracle | 🐘 PostgreSQL | 🐬 MySQL | ✒️ SQLite3 | ⚡ Redis | 🍃 MongoDB |
고르는 기준, 세 문장
🟢 지금 배우는 중이라면
- SQLite로 SQL 문법을 익히세요 — 설치가 없어 바로 시작합니다
- 그다음 PostgreSQL을 도커로 띄워 서버형을 경험합니다
- 취업 목표가 SI·금융이면 Oracle도 한 번은 만져 보세요
🔵 서비스를 만든다면
- 회사에 이미 쓰는 DB가 있으면 그것이 정답입니다
- 새로 정할 수 있다면 PostgreSQL이 무난합니다
- 워드프레스·커머스 솔루션을 쓴다면 MySQL이 사실상 강제됩니다
🟣 규모가 커지면
- DB 종류보다 인덱스·쿼리·스키마 설계가 성능을 좌우합니다
- 앞단에 Redis 캐시를 두는 구성이 사실상 표준입니다
- 읽기 부하는 복제본으로, 쓰기 한계는 샤딩으로 나눕니다
설치 ① Windows
윈도우는 설치 파일을 받아 실행하는 방식이 가장 쉽습니다. 설치 중에 나오는 비밀번호와 포트 번호는 반드시 메모하세요 — 나중에 다시 보기 어렵습니다.
PostgreSQL — 추천 경로
- 공식 배포처(EDB)에서 Windows x86-64 인스톨러를 받습니다 — 검색어:
postgresql windows installer - 실행 → 설치 경로는 기본값 그대로 → 구성 요소는 전부 체크(pgAdmin·Command Line Tools 포함)
- superuser(postgres) 비밀번호 입력 — ★ 메모해 두세요
- 포트 5432 그대로 → 로케일 기본값 → 설치 완료
- 마지막의 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
- MySQL Installer for Windows를 받습니다 (community 판)
- Setup Type은 Developer Default 또는 Server only
- Authentication Method — Strong Password Encryption(기본값) 선택. 오래된 클라이언트를 써야 한다면 Legacy를 고르지만, 가급적 기본값을 유지하세요
- root 비밀번호 입력 — ★ 메모
- Windows Service 로 등록 → 이름 기본값(
MySQL80) → 실행
mysql --version
mysql -u root -p # 비밀번호 입력 후 접속
SQLite — 설치라고 할 것도 없습니다
- sqlite.org 다운로드 페이지에서 sqlite-tools-win-x64-*.zip 을 받습니다
- 압축을 풀어
C:\sqlite같은 폴더에 두고, 그 경로를 Path에 추가합니다 - 끝입니다. 서비스도 계정도 포트도 없습니다
sqlite3 --version
sqlite3 shop.db # 파일이 없으면 새로 만들어집니다
# sqlite> .quit 로 빠져나옵니다
python -c "import sqlite3; print(sqlite3.sqlite_version)" 로 바로 확인됩니다.
Oracle — 윈도우 직접 설치는 권하지 않습니다
Oracle Database Free 를 윈도우에 직접 설치할 수는 있지만, 설치 용량이 크고 리스너·서비스 설정에서 막히는 일이 많습니다. 학습 목적이라면 다음 섹션의 Docker 방식을 강력히 권합니다. 회사에서 이미 운영 중인 오라클에 접속만 하면 되는 경우라면, Oracle Instant Client + SQL Developer 만 설치하면 충분합니다.
netstat -ano | findstr :5432 로 점유 프로세스를 찾거나,
설치 시 포트를 5433 처럼 바꾸세요. 바꿨다면 접속할 때도 그 포트를 써야 합니다.
설치 ② 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
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 postgresql | active (running) |
| 포트가 열렸는가 | ss -lntp | grep 5432 | LISTEN 상태로 표시 |
| 접속이 되는가 | 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를 제한하거나, 애플리케이션 서버만 사설망으로 접근하게 두세요.
설치 ③ Docker — 선택 사항
이 절은 필수가 아닙니다. 앞의 ①(Windows)·②(macOS·Linux) 방식으로 이미 설치했다면 그대로 다음 섹션으로 넘어가세요. 도커는 설치 없이 명령 한 줄로 DB를 띄우는 또 하나의 방법일 뿐입니다 — PC가 지저분해지지 않고 버전 실험이 쉬워, 쓸 줄 알면 편리한 선택지입니다.
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
-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 | 컨테이너 안 셸로 들어가기 |
접속하기 — 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.20 | DB가 있는 컴퓨터 주소 |
| 포트 | 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
; 를 치고 엔터하세요.
(psql의 \dt 같은 역슬래시 명령과 sqlite의 .tables 같은 점 명령은 세미콜론이 필요 없습니다)
계정 · 데이터베이스 · 권한
관리자 계정으로 모든 것을 하지 마세요. 앱마다 전용 계정을 만들고 필요한 권한만 주는 것이 기본이자, 사고가 났을 때 피해를 줄이는 유일한 방법입니다.
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로 애플리케이션을 붙이지 않습니다. 사고 시 원인 추적조차 불가능해집니다.
실습용 샘플 스키마 만들기
이 가이드의 거의 모든 예제가 아래 작은 쇼핑몰 데이터로 돌아갑니다. 한 번만 만들어 두면 끝까지 복붙해서 따라올 수 있습니다.
표준 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);
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;
접속 오류 사전
접속이 안 되는 이유는 거의 정해져 있습니다. ① 서버가 안 떴다 → ② 주소·포트가 다르다 → ③ 계정·비밀번호 → ④ 방화벽 순서로 확인하세요.
| 메시지 | 원인과 해결 |
|---|---|
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
학습 로드맵
이 가이드는 순서대로 읽으면 되지만, 목적에 따라 건너뛰어도 됩니다. 아래는 목적별 최단 경로입니다.
목적별 경로
| 목적 | 순서 |
|---|---|
| 취업 준비 · 비전공 | 🧭 시작 → 📘 SQL 기초 전부 → ⚙️ 튜닝 t01·t04·t10·t11 → 🎓 SQL 전문가 e01~e05 (면접에서 나오는 범위가 거의 이것입니다) |
| 백엔드 개발자 | 📘 SQL 기초 → 🐘 또는 🐬 (쓰는 DB) → ⚙️ 튜닝 전부 → 🔌 앱 연동 → 🧬 전문가 |
| 데이터 분석 | 📘 SQL 기초 → 🎓 SQL 전문가 전부 → ⚙️ t07 실행계획 → 🐘 PostgreSQL |
| SI · 금융 · 공공 | 📘 SQL 기초 → 🅾️ Oracle 전부 → ⚙️ 튜닝 → 🧬 전문가 |
| 앱 · 개인 프로젝트 | 🧭 시작 → 📘 SQL 기초 → ✒️ SQLite3 전부 → 🔌 앱 연동 |
| 이미 SQL을 아는 사람 | 🎓 SQL 전문가 → ⚙️ 튜닝 → 🧬 전문가 · 내부 구조 |
6주 학습 계획 (하루 1시간 기준)
SQL 기초 —
물어보는 법을
배웁니다
이 탭의 문법은 Oracle · PostgreSQL · MySQL · SQLite 어디서나 거의 그대로 돌아갑니다. DB마다 다른 부분은 그때그때 4-DB 대조 카드로 짚어 둡니다. 앞 탭에서 만든 쇼핑몰 샘플 데이터를 그대로 씁니다 — 없다면 🧭 시작 탭 c08 로 먼저 가세요.
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 는 행을 한 줄씩 지우고 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)
윈도우 함수 · CTEDB마다 다른 것
페이징 (LIMIT vs FETCH)
문자열 결합 (|| vs CONCAT)
날짜 함수 이름
자동 증가 컬럼
UPSERT 문법
데이터 타입 이름FROM·WHERE 마다 줄을 바꾸세요 — 읽는 사람이 다릅니다.
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; -- 두 컬럼 조합 기준 중복 제거
SELECT * 는 연습용입니다. 실무 코드에 쓰면
① 필요 없는 컬럼까지 네트워크로 보내 느려지고,
② 나중에 컬럼이 추가되면 앱이 예상 못 한 데이터를 받고,
③ 커버링 인덱스(⚙️ t05)를 못 타게 됩니다.
확인할 때만 쓰고, 코드에는 컬럼을 명시하세요.
별칭에 공백이나 한글을 쓰려면
-- 표준 · PostgreSQL · Oracle · SQLite : 큰따옴표
SELECT name AS "회원 이름" FROM member;
-- MySQL : 백틱도 됩니다 (큰따옴표는 설정에 따라 문자열로 해석될 수 있음)
SELECT name AS `회원 이름` FROM member;
WHERE city = "서울" 이라고 쓰면 PostgreSQL은
"서울이라는 이름의 컬럼"을 찾다가 에러를 냅니다. 값에는 항상 작은따옴표를 쓰세요.
주석
-- 한 줄 주석 (하이픈 두 개 + 공백)
/* 여러 줄 주석
이렇게 감쌉니다 */
SELECT name -- 줄 끝에도 쓸 수 있습니다
FROM member;
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_binOracle
-- 기본은 구분함
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), 저장할 때부터 소문자로 넣으세요.
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 은 어디에 놓일까
| DB | ASC 일 때 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.
NULL — 가장 흔한 함정
NULL은 0도, 빈 문자열도, false도 아닙니다. "모른다"는 뜻입니다. 이 한 줄을 이해하면 처음 겪게 되는 버그의 상당수가 사라집니다.
모르는 값끼리는 비교할 수 없습니다
-- ✗ 절대 참이 되지 않습니다 (결과 0행)
SELECT * FROM member WHERE email = NULL;
-- ✓ 이렇게 씁니다
SELECT * FROM member WHERE email IS NULL;
SELECT * FROM member WHERE email IS NOT NULL;
계산에 섞이면 전부 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 | '' (빈 문자열)의 취급 |
|---|---|
| PostgreSQL | NULL과 다릅니다. '' IS NULL 은 false |
| MySQL / MariaDB | NULL과 다릅니다 |
| SQLite | NULL과 다릅니다 |
| Oracle | 빈 문자열을 NULL로 취급합니다. 오라클만의 특이점이며 마이그레이션에서 반드시 걸립니다 |
NOT NULL + DEFAULT 로 잠그세요.
"일단 NULL 허용해 두자"가 나중에 집계 오류와 NPE(널 포인터)의 근원이 됩니다.
자료형 완전정복
타입을 잘못 고르면 1원이 틀어지고, 한글이 깨지고, 2038년에 서비스가 멈춥니다. 실제로 있었던 일들입니다.
숫자
| 용도 | 표준 · PostgreSQL | Oracle | MySQL |
|---|---|---|---|
| 정수 (일반) | INTEGER ±21억 | NUMBER(10) | INT |
| 정수 (큰 값·PK) | BIGINT | NUMBER(19) | BIGINT |
| 돈 · 정확한 소수 | NUMERIC(12,2) | NUMBER(12,2) | DECIMAL(12,2) |
| 과학 계산 (오차 허용) | DOUBLE PRECISION | BINARY_DOUBLE | 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) | ★ 글로벌 서비스면 이것 |
DATETIME | MySQL — 시간대 변환 없음 | 서버 시간대가 바뀌어도 값이 그대로 |
| (SQLite) | 날짜 타입이 없습니다 | TEXT('2026-08-05') 또는 정수로 저장 |
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이 필요해 실무에서는 코드 테이블을 선호합니다.
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;
① 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;
deleted_at 컬럼을 채우는
소프트 삭제를 씁니다.
③ 삭제 전 백업 테이블에 INSERT … SELECT 로 옮겨 둡니다.
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 KEYMySQL / MariaDB
id INT AUTO_INCREMENT PRIMARY KEYOracle (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'));
JOIN 5종
두 표를 키로 이어 붙이는 것이 JOIN입니다. 엑셀 VLOOKUP이 하는 일과 같습니다. 아래 버튼을 눌러 종류별 결과를 직접 비교해 보세요.
member (왼쪽)
| id | name |
|---|---|
| 1 | 김하늘 |
| 2 | 박바다 |
| 3 | 이구름 (주문 없음) |
orders (오른쪽)
| order_no | member_id |
|---|---|
| 1001 | 1 |
| 1002 | 2 |
| 1005 | 2 |
| 1009 | 9 (없는 회원) |
결과 —
-- 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 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;
employee 표에 있을 때
FROM employee e JOIN employee b ON b.id = e.manager_id 처럼 씁니다.
별칭(e, b)을 반드시 달아야 어느 쪽인지 구분됩니다.
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;
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) 를 쓰세요.
HAVING vs WHERE
둘 다 "거른다"지만 거르는 시점이 다릅니다. WHERE는 묶기 전에, 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; -- ✓
서브쿼리 3형태
쿼리 안의 쿼리입니다. 기준값을 먼저 구해야 할 때 씁니다. 들어가는 위치에 따라 세 가지로 나뉩니다.
① 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;
③ SELECT 절 — 값 하나로 (스칼라 서브쿼리)
SELECT m.name,
(SELECT COUNT(*) FROM orders o WHERE o.member_id = m.id) AS 주문수
FROM member m;
상관 서브쿼리 — 바깥 값을 참조하는 것
-- 카테고리 안에서 평균보다 비싼 상품
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 > 카테고리평균;
집합 연산 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;
| 연산 | PostgreSQL | Oracle | MySQL | SQLite |
|---|---|---|---|---|
| UNION / UNION ALL | ✓ | ✓ | ✓ | ✓ |
| INTERSECT | ✓ | ✓ | ✓ (8.0.31+) | ✓ |
| EXCEPT | ✓ | MINUS | ✓ (8.0.31+) | ✓ |
① 양쪽의 컬럼 개수가 같아야 합니다
② 같은 자리의 타입이 호환돼야 합니다
③ 결과 컬럼 이름은 첫 번째 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);
문자열 · 날짜 · 숫자 함수
여기가 DB별 차이가 가장 심한 영역입니다. 자주 쓰는 것만 대조표로 정리했습니다.
문자열
| 하려는 일 | 표준 · PG | Oracle | MySQL | SQLite |
|---|---|---|---|---|
| 이어 붙이기 | a || b | a || b | CONCAT(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 · LOWER | UPPER · LOWER | UPPER · LOWER | UPPER · 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) |
|| 는 기본적으로 OR 연산자입니다.
문자열 결합에는 CONCAT() 을 쓰세요.
(sql_mode 에 PIPES_AS_CONCAT 을 넣으면 표준처럼 동작하지만, 설정에 의존하는 코드는 위험합니다)
날짜
| 하려는 일 | PostgreSQL | Oracle | MySQL | SQLite |
|---|---|---|---|---|
| 현재 시각 | NOW() | SYSDATE | NOW() | datetime('now') |
| 오늘 날짜 | CURRENT_DATE | TRUNC(SYSDATE) | CURDATE() | date('now') |
| N일 더하기 | d + INTERVAL '7 day' | d + 7 | DATE_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 - d2 | d1 - d2 | DATEDIFF(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 절 생략 가능
7 / 2 는 상황에 따라 3이 됩니다.
소수가 필요하면 7.0 / 2 또는 CAST(7 AS NUMERIC) / 2 처럼 명시하세요.
비율 계산이 0으로 나오는 버그의 단골 원인입니다.
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;
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이면 c | Oracle |
IFNULL(a,b) | NVL과 같음 | MySQL · SQLite |
-- 0으로 나누기 방지 — NULLIF 의 대표 활용
SELECT name,
ROUND(sold * 100.0 / NULLIF(stock + sold, 0), 1) AS 판매율
FROM product;
-- 분모가 0이면 NULL 이 되어 에러 대신 빈 값이 나옵니다
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;
뷰를 쓰는 세 가지 이유
- 복잡함 감추기 — 다섯 개 테이블 조인을 팀원이 매번 쓰지 않아도 됩니다
- 권한 통제 — 원본 테이블 접근은 막고, 필요한 컬럼만 담은 뷰만 열어 줍니다 (급여 테이블에서 주민번호를 뺀 뷰 등)
- 호환 유지 — 테이블 구조를 바꿔도 뷰가 옛 모양을 유지해 주면 앱을 안 고쳐도 됩니다
구체화 뷰 (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;
트랜잭션 입문
여러 변경을 전부 성공하거나 전부 취소로 묶는 장치입니다. 계좌 이체가 "출금만 되고 입금은 안 되는" 상태를 막습니다.
BEGIN; -- 트랜잭션 시작 (Oracle 은 자동 시작)
UPDATE account SET balance = balance - 10000 WHERE id = 1; -- 출금
UPDATE account SET balance = balance + 10000 WHERE id = 2; -- 입금
COMMIT; -- 둘 다 확정
-- 문제가 있으면 ROLLBACK; → 둘 다 없던 일로
| DB | 트랜잭션 시작 | 기본 자동 커밋 |
|---|---|---|
| PostgreSQL | BEGIN; | 켜짐 (문장 하나가 곧 트랜잭션) |
| MySQL | START TRANSACTION; 또는 BEGIN; | 켜짐 |
| Oracle | DML 을 치면 자동 시작 | 꺼짐 — COMMIT 을 꼭 해야 합니다 |
| SQLite | BEGIN; | 켜짐 |
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).
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 참고) |
기초 치트시트
막힐 때 펼쳐 보세요. 이 탭에서 다룬 문법을 한 장으로 모았습니다.
-- ── 조회 ────────────────────────────────────────────
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 |
SQL 전문가 —
반복문을
지우는 문법
기초 문법으로도 대부분의 조회는 됩니다. 여기서부터는 애플리케이션 코드로 하던 일을 SQL로 옮기는 영역입니다. 순위·누적·계층·피벗을 쿼리 한 줄로 처리하면 수백만 번 오가던 왕복이 한 번이 됩니다. 대부분의 문법은 네 DB에 모두 있습니다.
윈도우 함수 — 개념과 문법
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개가 그대로 나오면서
-- 각 행에 자기 카테고리 평균이 붙습니다
문법 구조
함수() 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
@rownum) 트릭 대신 업그레이드를 검토하는 편이 낫습니다.
① 그룹 안 순위 —
ROW_NUMBER() OVER (PARTITION BY x ORDER BY y DESC)
② 전체 대비 비중 —
amount * 100.0 / SUM(amount) OVER ()
③ 직전 값과 비교 —
LAG(amount) OVER (ORDER BY month)
순위 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;
| name | price | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| 27인치 모니터 | 289000 | 1 | 1 | 1 |
| 무선 키보드 | 49000 | 2 | 2 | 2 |
| 마우스패드 XL | 49000 | 3 | 2 | 2 |
| 게이밍 마우스 | 32000 | 4 | 4 | 3 |
| 함수 | 동점일 때 | 쓰는 곳 |
|---|---|---|
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 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%
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 월;
-- 기본값을 주면 첫 행의 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 만 쓴 것과 같습니다 |
-- 누적 매출
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 를 쓰세요 — 의도가 명확합니다.
LAST_VALUE(x) OVER (ORDER BY y) 는 기본 프레임이 "처음~현재"라
마지막 값이 아니라 현재 값이 나옵니다.
진짜 마지막을 원하면 프레임을
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 으로 명시하세요.
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;
WITH 안의 DML + RETURNING).
Oracle·MySQL·SQLite 에서는 두 문장으로 나눠 트랜잭션으로 묶으세요.
WITH x AS MATERIALIZED (…) 로 강제 저장을, NOT MATERIALIZED 로 인라인을 지정할 수 있습니다.
같은 CTE를 여러 번 참조하면 DB에 따라 그만큼 반복 실행될 수 있으니 실행계획으로 확인하세요.
재귀 CTE — 계층 탐색
부모 → 자식 → 손자를 끝까지 따라가는 문법입니다. 조직도·카테고리 트리·댓글 스레드·부품 전개(BOM)가 전부 이 패턴입니다.
-- 예제 데이터
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;
구조는 항상 같습니다
UNION(중복 제거)을 쓰면 느려지고, 순환 데이터에서 결과가 달라질 수 있습니다.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;
-- 키보드 → 주변기기 → 전자제품 → 전체
· 깊이 제한을 걸어 두세요:
WHERE t.depth < 20
· PostgreSQL 14+ 는
CYCLE id SET is_cycle USING path 로 순환을 자동 감지합니다
· Oracle 은
CONNECT BY NOCYCLE (o09) 이 있습니다
DB별 지원
| DB | 재귀 CTE | 비고 |
|---|---|---|
| PostgreSQL | ✓ WITH RECURSIVE | 8.4부터. CYCLE·SEARCH 절은 14+ |
| Oracle | ✓ WITH (RECURSIVE 키워드 없이) | 11gR2부터. CONNECT BY 도 병행 사용 |
| MySQL / MariaDB | ✓ WITH RECURSIVE | MySQL 8.0+ · MariaDB 10.2+ |
| SQLite | ✓ WITH RECURSIVE | 3.8.3+ (사실상 모든 버전) |
/1/2/3/),
클로저 테이블, PostgreSQL의 ltree 확장 같은 대안이 훨씬 빠릅니다.
ROLLUP · CUBE · GROUPING SETS
소계와 총계를 한 번의 쿼리로 뽑습니다. UNION을 세 번 쓰던 보고서 쿼리가 한 줄이 됩니다.
-- ROLLUP : 계층적 소계 (지역 → 지역+등급 → 총계)
SELECT city, grade, COUNT(*) AS 인원
FROM member
GROUP BY ROLLUP (city, grade);
| city | grade | 인원 | 의미 |
|---|---|---|---|
| 서울 | BASIC | 1 | 상세 |
| 서울 | GOLD | 1 | 상세 |
| 서울 | (NULL) | 2 | 서울 소계 |
| 부산 | BASIC | 1 | 상세 |
| 부산 | SILVER | 1 | 상세 |
| 부산 | (NULL) | 2 | 부산 소계 |
| 대전 | GOLD | 1 | 상세 |
| 대전 | (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);
| 문법 | PostgreSQL | Oracle | MySQL | SQLite |
|---|---|---|---|---|
| ROLLUP | ✓ | ✓ | ✓ WITH ROLLUP | ✗ |
| CUBE | ✓ | ✓ | ✗ | ✗ |
| GROUPING SETS | ✓ | ✓ | ✗ | ✗ |
GROUP BY city, grade WITH ROLLUP 처럼 뒤에 붙입니다.
CUBE·GROUPING SETS 가 없으므로 UNION ALL 로 조합해야 합니다.
SQLite 는 셋 다 없으니 애플리케이션에서 소계를 계산하거나 UNION ALL 을 쓰세요.
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;
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);
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
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;
LATERAL 을 붙이면 왼쪽에서 이미 읽은 행의 값을 쓸 수 있습니다.
DB별 대응
| DB | 문법 |
|---|---|
| PostgreSQL | CROSS JOIN LATERAL · LEFT JOIN LATERAL … ON TRUE (9.3+) |
| Oracle | CROSS APPLY · OUTER APPLY · LATERAL() (12c+) |
| MySQL | LATERAL (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;
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'));기준 — WHERE·JOIN·ORDER BY 에 쓰이면 정식 컬럼, 그냥 보관만 하면 JSON.
JSON vs JSONB —
JSON 은 텍스트 그대로 저장(입력 순서·공백 유지, 파싱 매번),
JSONB 는 이진 형태로 저장(파싱 완료, 인덱스 가능, 키 순서 미보존).
거의 항상 JSONB 를 쓰세요.
정규식 · 전문 검색
LIKE '%검색어%' 는 데이터가 커지면 반드시 무너집니다.
대안 두 가지를 정리합니다.
정규식 — 패턴이 복잡할 때
| DB | 문법 |
|---|---|
| PostgreSQL | col ~ '^010-\d{4}' (대소문자 무시는 ~*) · REGEXP_REPLACE |
| Oracle | REGEXP_LIKE(col, '^010-\d{4}') · REGEXP_SUBSTR |
| MySQL 8.0+ | col REGEXP '^010-[0-9]{4}' · REGEXP_REPLACE |
| SQLite | REGEXP 연산자는 있으나 함수를 앱에서 등록해야 동작 |
전문 검색 — 본문에서 단어 찾기
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;집합 기반 사고
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) 조건부 집계 |
한 번에 하되, 너무 크면 잘라서
"한 번에"가 항상 옳은 것도 아닙니다. 수천만 건을 단일 트랜잭션으로 바꾸면 락이 오래 걸리고 로그가 폭증해 오히려 장애가 됩니다. 집합으로 생각하되 청크로 나눠 실행하는 것이 실무의 정답입니다 → 다음 섹션.
대량 배치 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.
쿼리 안티패턴 12선
실제로 장애를 만든 패턴들입니다. 코드 리뷰에서 이것만 걸러도 사고가 크게 줄어듭니다.
| # | 안티패턴 | 왜 나쁜가 · 어떻게 |
|---|---|---|
| 1 | SELECT * |
불필요한 컬럼 전송, 커버링 인덱스 무력화, 스키마 변경에 취약 → 컬럼 명시 |
| 2 | 컬럼에 함수 씌우기WHERE DATE(t)= … |
인덱스를 못 씁니다 → 범위 조건으로 t >= a AND t < b |
| 3 | 앞에 % 붙은 LIKE |
풀스캔 확정 → 전문 검색 또는 역방향 인덱스 |
| 4 | 암묵적 형변환WHERE varchar_col = 123 |
DB가 컬럼을 형변환하며 인덱스가 죽습니다 → 타입을 맞춰 비교 |
| 5 | NOT IN (서브쿼리) |
NULL 하나에 결과가 0행 → NOT EXISTS |
| 6 | 깊은 OFFSET 페이징 |
뒤 페이지가 기하급수적으로 느려짐 → 커서 페이징 (⚙️ t09) |
| 7 | 코드에서 반복 쿼리 (N+1) | 목록 100건에 쿼리 101번 → 조인 · IN 절 · 배치 조회 (🔌 a08) |
| 8 | 트랜잭션 안에서 외부 API 호출 | 락을 잡은 채 수 초 대기 → 트랜잭션 밖으로 분리 |
| 9 | 문자열 이어붙여 쿼리 조립 | SQL 인젝션 · 실행계획 캐시 미스 → 파라미터 바인딩 (🔌 a05) |
| 10 | ORDER BY RAND() |
전체를 정렬합니다 → PK 범위 난수 또는 TABLESAMPLE |
| 11 | 무의미한 DISTINCT |
조인이 잘못돼 중복이 생긴 걸 덮는 용도로 쓰임 → 조인 조건을 고치세요 |
| 12 | COUNT(*) 로 존재 확인 |
전부 세고 나서 >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);
전문가 치트시트
하려는 일을 한글로 검색하세요. 문법 이름을 몰라도 찾을 수 있습니다.
| 하려는 일 | 문법 |
|---|---|
| 그룹별 순위·등수 | 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 |
| 행마다 다른 상위 N | LATERAL / 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·DELETE | LIMIT 로 청크 반복 + 사이에 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() |
Oracle —
설치부터
튜닝까지
국내 금융·통신·공공 기간계는 여전히 오라클 위에서 돕니다. DUAL · ROWNUM · CONNECT BY · PL/SQL 같은 오라클 방언은 표준 SQL만 알아서는 읽히지 않습니다. 설치(가장 큰 관문)부터 실행계획·AWR·파티셔닝까지, 실무에서 실제로 쓰는 것만 담았습니다.
오라클은 어떤 DB인가 · 에디션
기능·안정성·튜닝 도구에서 여전히 최고 수준입니다. 대신 비용이 매우 큽니다. 기술 선택 회의에서 반드시 나오는 이야기라 구조를 알아 둘 필요가 있습니다.
| 에디션 | 내용 |
|---|---|
| Enterprise (EE) | 모든 기능. 파티셔닝·RAC·Data Guard·진단팩(AWR) 등은 대부분 별도 유료 옵션입니다. 코어 단위 라이선스라 서버 사양이 곧 비용입니다. |
| Standard Edition 2 (SE2) | 기능이 제한됩니다(파티셔닝·병렬 쿼리 등 없음). 소켓 수 제한이 있습니다. |
| Free (구 XE) | 무료. 사용자 데이터 12GB, CPU 2코어, 메모리 2GB 제한. 학습·개발·소규모 운영에 씁니다. 상용 사용도 허용됩니다. |
| Autonomous (클라우드) | OCI에서 관리형으로. 항상 무료 등급(Always Free)이 있어 학습용으로 쓸 만합니다. |
버전 흐름 (2026-08 기준)
- 11gR2 — 아직도 현장에 남아 있습니다.
FETCH FIRST·IDENTITY가 없어 ROWNUM·시퀀스를 씁니다. - 12c / 18c / 19c — 19c가 장기 지원(LTS) 버전이라 실무 점유율이 가장 높습니다. 멀티테넌트(CDB/PDB)가 기본이 됐습니다.
- 21c — 혁신 릴리스(단기 지원). JSON 전용 타입 등이 들어왔습니다.
- 23ai — 현재의 장기 지원 버전. BOOLEAN 타입,
IF EXISTS, 벡터 검색, SQL의 도메인·주석 개선 등 오래 기다린 기능들이 들어왔습니다.
설치 — 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;
MOUNTED 상태면 접속할 수 없습니다.
ALTER PLUGGABLE DATABASE FREEPDB1 OPEN; 으로 열고,
재시작해도 유지되게 ALTER PLUGGABLE DATABASE FREEPDB1 SAVE STATE; 를 실행하세요.
도커 이미지는 보통 자동으로 열려 있습니다.
접속 — SQL*Plus · 서비스명
오라클 접속에서 가장 많이 막히는 지점입니다. SID와 서비스명은 다릅니다 — 이것만 이해하면 대부분 풀립니다.
| 개념 | 뜻 |
|---|---|
| 리스너 (Listener) | 1521 포트에서 접속 요청을 받아 DB로 넘겨주는 중개 프로세스. 이게 안 떠 있으면 ORA-12541. |
| SID | 서버에 떠 있는 인스턴스 하나의 이름. 물리적 개념. |
| 서비스명 | 클라이언트가 요청하는 논리적 이름. 여러 인스턴스가 한 서비스를 제공할 수 있습니다(RAC). |
| PDB | 12c 이후의 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
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
SELECT … ; 없이 실행 등
현대적인 편의 기능이 있고 JDBC만 있으면 동작합니다(Instant Client 불필요).
CDB · PDB · 계정 · 권한
12c부터 오라클은 멀티테넌트 구조입니다.
계정을 어디에 만드느냐에 따라 C## 접두사가 필요해집니다 — 여기서 많이 막힙니다.
C## 으로 시작해야 합니다.-- 지금 어디인지 먼저 확인
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; -- 전체 공개 (남용 주의)
DUAL · 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;
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;
NVL · DECODE · 문자열
현장 코드에서 수천 번 만나게 되는 함수들입니다. 표준 대응 문법도 함께 정리합니다.
| Oracle | 하는 일 | 표준 대응 |
|---|---|---|
NVL(a, b) | a가 NULL이면 b | COALESCE(a, b) |
NVL2(a, b, c) | a가 NULL이 아니면 b, NULL이면 c | CASE 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;
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건
IS NULL 조건이 걸리지 않는 행이 생깁니다.
데이터 정제 단계에서 NULLIF(col, '') 로 통일하세요.
DATE · TO_DATE · INTERVAL
오라클의 DATE에는 시분초가 들어 있습니다. 이걸 모르고 날짜를 비교하다 정산이 틀어지는 사고가 정말 많습니다.
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;
| 포맷 | 뜻 | 주의 |
|---|---|---|
YYYY | 4자리 연도 | — |
MM | 월 (01~12) | MI(분)와 헷갈리지 마세요 |
DD | 일 | — |
HH24 | 24시간제 시 | 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;
SYSDATE 는 DB 서버의 OS 시간대를 따르므로,
서버가 옮겨가면 값의 의미가 달라집니다.
시퀀스 · 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;
/
계층 쿼리 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; -- 형제끼리 정렬
| 키워드 | 뜻 |
|---|---|
START WITH | 탐색 출발점 조건 |
CONNECT BY PRIOR a = b | PRIOR가 붙은 쪽이 "이전 행". 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;
오라클 분석 함수
윈도우 함수(🎓 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;
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
);
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(매출)
);
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;
패키지 · 벌크 연산
한 건씩 처리하면 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;
/
힌트와 옵티마이저
힌트는 옵티마이저에게 주는 지시입니다. 잘 쓰면 응급처치, 잘못 쓰면 영구적인 부채가 됩니다.
-- 힌트는 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 — 코드를 못 고치는 패키지 제품에 힌트를 외부에서 주입합니다.
실행계획 · 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'));
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 부족 또는 잘못된 스캔 |
@?/rdbms/admin/spcreate.sql 로 설치).
라이선스 없이 AWR을 조회하는 것만으로도 감사 대상이 됩니다.
파티셔닝 · 인덱스 종류
수억 건 테이블을 조각으로 나눠 관리합니다. 조회도 빨라지고 오래된 데이터를 통째로 버릴 수도 있습니다.
-- 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 | 해시 분산 | 고르게 나누고 싶을 때 (범위 기준이 없을 때) |
| COMPOSITE | RANGE + HASH 등 조합 | 초대용량 (월별 + 지역별) |
파티션 인덱스 — 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;
RAC · Data Guard · RMAN
장애가 나도 서비스가 멈추지 않게 하는 구성입니다. 금융·통신에서는 기본 요건입니다.
| 기술 | 해결하는 문제 | 주의 |
|---|---|---|
| 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;
undo_retention) 안에서만 가능합니다.
탈오라클 마이그레이션
라이선스 비용 때문에 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는 롤백 가능 → 마이그레이션 스크립트에 유리 |
| NUMBER | PG의 NUMERIC 은 정확하지만 느립니다 → 정수는 BIGINT 로 |
| 정렬 순서 | NLS_SORT vs COLLATE — 한글 정렬 결과가 달라질 수 있습니다 |
도구
- ora2pg — 오라클 스키마·데이터·PL/SQL을 PostgreSQL로 변환. 변환 난이도 리포트를 먼저 뽑아 볼 수 있습니다
- AWS SCT / DMS — 클라우드 이전과 함께 갈 때
- orafce 확장 — PostgreSQL에서 오라클 함수(NVL·DECODE 등)를 흉내 내 코드 수정량을 줄입니다
ora2pg 로 난이도 리포트를 뽑아 PL/SQL 양을 확인하고
② 신규 기능부터 PostgreSQL로 만들고
③ 읽기 전용 리포트를 옮겨 검증하고
④ 마지막에 기간계를 옮깁니다.
한 번에 전부 옮기는 빅뱅 방식은 대부분 실패합니다.
PostgreSQL —
무료인데
가장 강력한
라이선스 비용이 0인데 표준 SQL 준수도는 가장 높고, JSONB · 배열 · 전문검색 · 지리 · 벡터까지 확장 하나로 붙습니다. 대신 MVCC 구조 때문에 VACUUM 관리가 필요하고, 이걸 모르면 어느 날 디스크가 가득 찹니다. 실무에서 반드시 알아야 할 것을 순서대로 정리합니다.
PostgreSQL은 어떤 DB인가
1986년 버클리에서 시작해 40년 가까이 이어온 오픈소스 DB입니다. 특정 회사 소유가 아니라 커뮤니티가 개발합니다 — 라이선스 정책이 바뀔 위험이 없다는 뜻입니다.
| 특징 | 내용 |
|---|---|
| 라이선스 | PostgreSQL 라이선스(BSD 계열) — 상용 사용·수정·재배포 전부 자유 |
| 표준 준수 | 주요 DB 중 가장 높습니다. 여기서 배운 SQL이 다른 DB로 잘 옮겨집니다 |
| 확장성 | 타입·연산자·인덱스·언어를 사용자가 추가할 수 있는 구조. PostGIS·pgvector·TimescaleDB 가 여기서 나옵니다 |
| 프로세스 모델 | 커넥션 하나 = OS 프로세스 하나. 그래서 커넥션 풀이 필수입니다 |
| MVCC | 수정 시 새 행을 만들고 옛 행을 남깁니다 → VACUUM 필요 (p11) |
| 릴리스 주기 | 매년 가을 메이저 1회, 각 버전 5년 지원 |
언제 PostgreSQL이 아닌가
- 회사가 이미 오라클/MySQL로 돌아갈 때 — 기술적 우위보다 운영 경험이 중요합니다
- 단순 조회만 초당 수만 건 — MySQL이 더 가벼울 수 있습니다(다만 요즘은 차이가 크지 않습니다)
- 파일 하나로 끝내야 할 때 — SQLite
- DBA 없이 커넥션 수천 개를 다룰 때 — PgBouncer 같은 풀러를 반드시 붙여야 합니다
설치 — 전 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부터 기본값).
옛 클라이언트가 붙지 못하면 클라이언트를 올리는 것이 맞습니다.
sudo -u postgres psql 이 되는 이유 —
local … peer 규칙 때문입니다. OS 계정 postgres 로 실행하면
같은 이름의 DB 계정으로 비밀번호 없이 접속됩니다.
비밀번호로 접속하고 싶으면 psql -h 127.0.0.1 -U postgres 처럼 TCP로 붙으세요.
psql 완전정복
서버에서는 GUI를 못 씁니다. psql의 역슬래시 명령 열 개만 알아도 작업 속도가 완전히 달라집니다.
| 명령 | 하는 일 |
|---|---|
\l | 데이터베이스 목록 |
\c shop | shop 으로 이동 |
\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
역할 · 권한 · 스키마
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;
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; -- 계정 기본값으로
다만 스키마가 수백 개가 되면
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번 회원의 주문만 보입니다
FORCE ROW LEVEL SECURITY 로 강제 가능).
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;
·
INSERT … SELECT … RETURNING 조합 가능
·
UPDATE … FROM other_table 로 다른 표 참조 갱신
·
DELETE … USING other_table
·
GENERATED ALWAYS AS (a * b) STORED — 계산 컬럼(12+)
·
MERGE — 15부터 표준 문법도 지원
배열 · 범위 · 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);
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
· 중간 지점이라면 생성 컬럼으로 뽑아내는 방법도 있습니다:
ALTER TABLE item ADD COLUMN brand TEXT GENERATED ALWAYS AS (data->>'brand') STORED;
JSON 은 원문 텍스트 그대로(입력 순서·공백 보존, 조회 때마다 파싱),
JSONB 는 이진 저장(파싱 완료, 인덱스 가능, 키 중복 제거, 순서 미보존).
거의 항상 JSONB 입니다.
인덱스 6종
B-tree만 아는 것과 여섯 종류를 골라 쓰는 것은 수십 배의 성능 차이를 만듭니다.
| 종류 | 쓰는 곳 | 연산자 |
|---|---|---|
| B-tree | ★ 기본값. 등호·범위·정렬 전부 | = < > BETWEEN LIKE '전방%' |
| GIN | 한 행에 값이 여러 개 — JSONB · 배열 · 전문검색 | @> ? && @@ |
| GiST | 기하·범위·근접 검색 (PostGIS의 기반) | && <-> @> |
| BRIN | 값이 물리 순서와 일치하는 초대용량 (시계열 로그) | 범위 조건 |
| Hash | 등호 전용. B-tree 대비 이점이 작아 잘 안 씁니다 | = |
| SP-GiST | 공간 분할 구조 (IP 대역·전화번호 접두사) | 특수 |
-- 기본
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;
전문 검색 · 한국어
영어는 기본 기능으로 되지만 한국어는 그냥은 안 됩니다. 실무에서 쓰는 방법을 정리합니다.
기본 전문 검색 (영어 기준)
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;
실무에서 쓰는 세 가지 방법
| 방법 | 내용 | 평가 |
|---|---|---|
| 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;
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();
IMMUTABLE(같은 입력에 항상 같은 출력) · STABLE(한 쿼리 안에서 일정) ·
VOLATILE(기본값, 매번 다름).
인덱스 표현식에 쓰려면 IMMUTABLE 이어야 하고,
잘못 선언하면 옵티마이저가 잘못된 최적화를 해 결과가 틀어질 수 있습니다.
MVCC와 VACUUM
PostgreSQL 운영에서 가장 중요한 한 가지를 꼽으라면 이것입니다. 모르고 방치하면 어느 날 디스크가 가득 차거나 DB가 멈춥니다.
수정하면 새 행이 생깁니다
-- 죽은 행이 얼마나 쌓였나 (운영에서 정기적으로 봐야 하는 쿼리)
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;
원인은 거의 항상 ① 오래 열린 트랜잭션 ② 방치된 복제 슬롯 ③ 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 사고의 대부분을 예방합니다.
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;
읽는 법 — 이 네 가지만 보세요
| 볼 것 | 의미 |
|---|---|
| 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(); -- 통계 초기화
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) 결과를
explain.depesz.com · explain.dalibo.com 같은 시각화 도구에 붙여 넣으면
병목 노드가 색으로 표시됩니다. 큰 계획을 읽을 때 시간이 크게 절약됩니다.
파티셔닝
큰 테이블을 날짜 등으로 쪼갭니다. 조회는 필요한 조각만 보고, 오래된 데이터는 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 ordered_at >= '2026-08-01' 은 필요한 파티션만 보지만,
WHERE member_id = 1 만 있으면 모든 파티션을 전부 뒤집니다.
파티션 키 선택은 조회 패턴이 결정합니다.
확장 생태계
PostgreSQL의 진짜 무기입니다. 설치 한 줄로 별도 시스템을 대체합니다.
| 확장 | 하는 일 |
|---|---|
| pg_stat_statements | ★ 쿼리별 실행 통계 — 운영에 사실상 필수 |
| pg_trgm | 부분 문자열 검색·유사도 (LIKE '%…%' 인덱스화) |
| PostGIS | 지리 정보 — 좌표·거리·영역. 지도 서비스의 표준 |
| pgvector | 벡터 유사도 검색 — RAG·AI 검색을 별도 벡터 DB 없이 |
| pgcrypto | 암호화·해시 함수 |
| uuid-ossp | UUID 생성 (18부터는 uuidv7() 이 내장) |
| postgres_fdw | 다른 PostgreSQL의 테이블을 내 DB처럼 조회 |
| TimescaleDB | 시계열 특화 — 자동 파티셔닝·압축·연속 집계 |
| pg_partman | 파티션 자동 생성·정리 |
| pg_cron | DB 안에서 도는 크론 스케줄러 |
-- 설치 (서버에 패키지가 깔려 있어야 합니다)
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;
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; -- 원격 테이블을 로컬처럼 조인 가능
복제 · 읽기 분산
같은 데이터를 여러 서버에 복사해 조회를 나누고 장애에 대비합니다.
| 방식 | 내용 | 쓰는 곳 |
|---|---|---|
| 스트리밍 복제 | 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();
논리 복제 — 부분 복제·무중단 업그레이드
-- 주 서버 (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 보다 다운타임이 훨씬 짧습니다.
백업 · 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 파일을 만든 뒤 기동
archive_command 는 실패를 놓치기 쉽고, 그러면 WAL 이 무한정 쌓입니다.
메이저 버전 업그레이드
| 방법 | 다운타임 | 특징 |
|---|---|---|
| pg_dump / restore | 김 (데이터 크기 비례) | 가장 단순·안전. 소규모에 적합 |
| pg_upgrade | 수 분 | --link 옵션이면 매우 빠릅니다. 롤백이 어려움 |
| 논리 복제 | 수 초 | 구성은 복잡하지만 대용량 서비스의 정석 |
① 마이너 버전은 재시작만으로 끝나지만 메이저는 데이터 파일 형식이 바뀝니다
② 쓰는 확장이 새 버전을 지원하는지 확인
③ 릴리스 노트의 비호환 변경 확인 (기본값 변경으로 쿼리 동작이 달라질 수 있습니다)
④ 반드시 복사본에서 먼저 리허설
postgresql.conf 튜닝
기본 설정은 아주 작은 서버 기준입니다. 메모리 관련 설정 몇 개만 고쳐도 체감이 달라집니다.
| 설정 | 권장 (RAM 16GB 기준) | 설명 |
|---|---|---|
shared_buffers | 4GB (RAM의 25%) | PostgreSQL 전용 캐시. 너무 크게 잡으면 OS 캐시와 중복됩니다 |
effective_cache_size | 12GB (RAM의 75%) | 실제 할당이 아니라 옵티마이저에게 주는 힌트입니다 |
work_mem | 16~64MB | 정렬·해시마다 이만큼 씁니다. 동시 100세션 × 여러 노드면 곱해집니다 |
maintenance_work_mem | 1GB | VACUUM·인덱스 생성용. 크게 잡을수록 유지보수가 빨라집니다 |
max_connections | 100~200 | 크게 잡지 마세요 — 풀러(PgBouncer)를 쓰는 것이 정답 |
random_page_cost | 1.1 (SSD) | 기본 4.0은 HDD 기준. SSD면 반드시 낮추세요 — 인덱스를 더 잘 씁니다 |
checkpoint_timeout | 15min | 길수록 IO 스파이크가 줄지만 복구가 느려집니다 |
max_wal_size | 4GB | 체크포인트 간격. 작으면 체크포인트가 잦아집니다 |
log_min_duration_statement | 1000 (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+ 의 준비된 문 지원을 켜세요.
MySQL ·
MariaDB —
웹의 기본기
세상의 웹 서비스 상당수가 이 위에서 돕니다. 설치가 쉽고 자료가 압도적으로 많습니다. 대신 문자셋(utf8mb4) · 갭 락 · 복제 지연 같은 고유한 함정이 있고, 이걸 모르면 한글이 깨지고 데드락이 나고 조회가 옛 데이터를 봅니다. 실무에서 반드시 겪는 순서대로 정리했습니다.
MySQL과 MariaDB의 갈림길
2010년 오라클이 MySQL을 인수하자 원 개발자들이 갈라져 나와 만든 것이 MariaDB입니다. 처음엔 거의 같았지만 지금은 꽤 벌어졌습니다.
| 항목 | MySQL | MariaDB |
|---|---|---|
| 개발 주체 | Oracle | MariaDB Foundation |
| 라이선스 | GPL + 상용 이중 | GPL |
| 최신 LTS | 8.4 (2024) · 9.x 혁신 | 11.8 (2025) |
| 기본 엔진 | InnoDB | InnoDB (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를 제품에 내장해 파는" 소수의 경우와 유료 기술 지원 여부예요
어느 쪽을 고를까
- 이미 쓰는 쪽이 있으면 그대로 — 전환 이득보다 리스크가 큽니다
- 클라우드 관리형을 쓴다면 — AWS RDS·Aurora, GCP Cloud SQL 모두 양쪽을 지원하지만 Aurora MySQL 호환처럼 MySQL 쪽 생태계가 더 넓습니다
- 완전 오픈소스가 중요하면 — MariaDB (GPL 단일)
- 이 가이드의 SQL — 표시가 없으면 양쪽에서 동일하게 동작합니다. 다른 부분은 MariaDB 배지로 표시했습니다
설치 — 전 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;
설정 파일 위치
| 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;
클라이언트 · 계정 · 권한
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_password | MySQL 8 기본값. 안전하지만 옛 클라이언트가 못 붙습니다 |
mysql_native_password | 구식. 8.4에서 기본 비활성화, 향후 제거 예정 |
auth_socket | OS 계정으로 인증 (로컬 전용) |
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; -- 접속 정보·문자셋 한눈에
문자셋 — utf8mb4 함정
한글이 ??? 로 나오거나 이모지 저장에 실패한다면 100% 여기입니다.
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;
네 곳을 모두 맞춰야 합니다
character-set-server=utf8mb4 — my.cnfcharset=utf8mb4. JDBC URL 이나 DSN 옵션# 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_ci | MySQL 8 기본. 악센트·대소문자 무시. 대부분 이걸 쓰면 됩니다 |
utf8mb4_0900_as_cs | 악센트·대소문자 구분 |
utf8mb4_bin | 바이트 그대로 비교. 완전 구분 |
utf8mb4_general_ci | 구식·부정확하지만 빠름. MariaDB 에서 아직 기본값인 경우가 있습니다 |
Illegal mix of collations 오류가 그것입니다.
또 조인 컬럼의 콜레이션이 다르면 인덱스를 못 탑니다 —
조용히 느려지는 형태라 발견이 어렵습니다.
DB 전체에 하나의 콜레이션을 강제하세요.
VARCHAR(255) 인덱스가 1020바이트가 됩니다.
InnoDB 의 기본 인덱스 키 제한은 3072바이트(DYNAMIC 행 포맷)이므로 보통 문제없지만,
옛 설정(COMPACT + 767바이트 제한)에서는
Specified key was too long 오류가 납니다.
이때는 컬럼 길이를 줄이거나 접두사 인덱스(KEY (col(191)))를 씁니다.
스토리지 엔진 InnoDB
MySQL은 테이블마다 저장 엔진을 고를 수 있습니다. 하지만 현실적인 답은 하나입니다 — InnoDB.
| 엔진 | 트랜잭션 | 락 단위 | 외래키 | 평가 |
|---|---|---|---|---|
| InnoDB | ✓ | 행 단위 | ✓ | ★ 기본값 · 항상 이것 |
| MyISAM | ✗ | 테이블 전체 | ✗ | 쓰기 시 전체 잠김 · 크래시에 취약 |
| MEMORY | ✗ | 테이블 | ✗ | 재시작하면 데이터 소멸 |
| Aria MariaDB | 부분 | 테이블 | ✗ | 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(*) 를 해야 하고, 이는 큰 테이블에서 느립니다.
화면에 총 건수를 항상 표시하는 설계는 대용량에서 재검토 대상입니다.
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;
-- 페이징
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;
|| 는 문자열 결합이 아니라 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 이 날 수 있습니다
VARCHAR(10) 에 20글자를 넣으면 경고만 내고 잘라서 저장하고,
DATE 에 '0000-00-00' 이 들어갑니다.
운영 DB에서 데이터가 이상한 원인의 상당수가 이것입니다. 반드시 켜세요.
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;
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');
날짜 · 타임존
DATETIME 과 TIMESTAMP 의 차이를 모르면
해외 서비스에서 시각이 어긋납니다.
| 항목 | DATETIME | TIMESTAMP |
|---|---|---|
| 저장 방식 | 적은 값 그대로 | UTC로 변환해 저장 |
| 조회 시 | 그대로 반환 | 세션 시간대로 변환해 반환 |
| 범위 | 1000년 ~ 9999년 | 1970 ~ 2038년 |
| 크기 | 8바이트 (5.6+ 는 5바이트) | 4바이트 |
| 쓰는 곳 | 예약일·만료일·생일 등 미래·고정 시각 | 생성·수정 시각 등 과거 이벤트 |
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 시간대를 Asia/Seoul 로 두고 시작하면 나중에 바꾸기가 매우 어렵습니다.
클러스터드 인덱스 구조
InnoDB는 데이터를 기본키 순서대로 디스크에 저장합니다. 이 사실 하나가 기본키 설계·인덱스 성능·UUID 문제를 전부 설명합니다.
① 기본키가 크면 모든 보조 인덱스가 커집니다(전부 기본키 값을 담으므로)
② 기본키가 무작위면 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;
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) 가 표준입니다.
로그 파일을 넣으면 쿼리를 정규화해 총 소요 시간 순위로 정리해 줍니다.
평균이 느린 쿼리보다 총합이 큰 쿼리를 먼저 잡으세요.
갭 락 · 데드락
MySQL은 없는 행에도 락을 겁니다. InnoDB 데드락의 상당수가 여기서 나오며, 다른 DB에서 오면 반드시 당황하게 됩니다.
| 락 종류 | 범위 |
|---|---|
| 레코드 락 | 인덱스의 특정 레코드에 거는 락 |
| 갭 락 (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 하려 하면 대기합니다 (존재하지도 않는 행인데!)
데드락 진단
-- 마지막 데드락 상세 (가장 중요한 명령)
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 는 작업 큐 구현의 정석입니다.
워커 여러 대가 같은 큐 테이블에서 서로 다른 작업을 집어가게 됩니다.
락 대기 없이 동시 처리량이 올라갑니다.
프로시저 · 트리거 · 이벤트
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 서버 한 대의 CPU를 쓰게 됩니다
권장 — 감사 로그·타임스탬프 자동화 정도만 트리거로, 비즈니스 로직은 애플리케이션에 두세요. 스케줄 작업도 외부 크론·잡 스케줄러가 관측·재시도 면에서 낫습니다.
파티셔닝 · 온라인 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 |
대용량 스키마 변경 도구
| 도구 | 방식 |
|---|---|
| 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
);
① 모든 유니크 키(기본키 포함)에 파티션 키가 들어가야 합니다 — 설계를 크게 제약합니다
② 외래키를 쓸 수 없습니다
③ WHERE 에 파티션 키가 없으면 모든 파티션을 뒤집니다
이 때문에 실무에서는 파티셔닝 대신 애플리케이션 레벨 샤딩이나 아카이브 테이블 분리를 택하는 경우도 많습니다.
복제 · 이중화
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 ← 이 값이 복제 지연(초)
· 대량 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 Cluster | Group Replication + MySQL Router + Shell. 자동 페일오버 |
| Galera MariaDB | 동기식 다중 마스터. 쓰기를 어디서나 받을 수 있지만 충돌 시 롤백이 발생 |
| 클라우드 관리형 | RDS·Aurora·Cloud SQL — 페일오버를 서비스가 처리합니다 |
백업 · 시점 복구
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_expire_logs_seconds 기본값은 30일입니다.
이 기간보다 오래된 시점으로는 복구할 수 없습니다.
백업 주기와 binlog 보존 기간을 함께 설계해야 합니다.
my.cnf 운영 튜닝
설정 하나만 제대로 잡아도 성능이 몇 배 달라집니다 — 버퍼 풀입니다.
| 설정 | 권장 (RAM 16GB 전용 서버) | 설명 |
|---|---|---|
innodb_buffer_pool_size | 10~11GB (RAM의 60~70%) | 가장 중요. 데이터·인덱스 캐시 |
innodb_log_file_size | 1~2GB | redo 로그. 작으면 체크포인트가 잦아 쓰기가 느려집니다 |
innodb_flush_log_at_trx_commit | 1 (기본·안전) | 2로 낮추면 빨라지지만 장애 시 1초 유실 가능 |
innodb_flush_method | O_DIRECT | OS 캐시 중복을 피합니다 (리눅스) |
max_connections | 200~500 | 크게 잡지 말고 앱 커넥션 풀로 제어하세요 |
innodb_io_capacity | 2000 (SSD) | 백그라운드 플러시 속도. HDD 기본값 200은 SSD에 너무 낮습니다 |
slow_query_log | ON | ★ 반드시 켜세요 |
long_query_time | 1 | 1초 넘는 쿼리 기록 |
tmp_table_size · max_heap_table_size | 64MB | 임시 테이블이 디스크로 넘어가는 것을 줄입니다 |
# /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
{DBInstanceClassMemory*3/4} 형태).
무작정 값을 넣지 말고 수식과 단위를 확인하세요.
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;
그 밖의 유용한 기능
| 기능 | 내용 |
|---|---|
RETURNING | INSERT·DELETE 결과를 바로 반환 (10.5+) — PostgreSQL 처럼 |
INSERT … RETURNING | INSERT INTO t (a) VALUES (1) RETURNING id; |
| Galera Cluster | 동기식 다중 마스터 클러스터가 내장 |
| ColumnStore | 컬럼 지향 엔진 — 분석 쿼리에 특화 |
| Spider | 샤딩 스토리지 엔진 (여러 서버를 하나처럼) |
| Aria | 크래시에 안전한 MyISAM 대체 (임시 테이블에 사용) |
JSON_TABLE | JSON 을 표로 펼치기 (10.6+) |
| Oracle 호환 모드 | SET sql_mode=ORACLE; — PL/SQL 유사 문법 일부 지원 |
SQLite —
서버가
없는 DB
설치도, 계정도, 포트도 없습니다. DB가 그냥 파일 하나이고 앱 안에 라이브러리로 들어갑니다. 스마트폰·브라우저·항공기 시스템까지 수조 개 규모로 배포돼 있습니다. 장난감이 아니라, 쓰임새가 다른 진짜 DB입니다.
서버가 없는 DB
MySQL·PostgreSQL은 별도 프로그램(서버)이 떠 있고 앱이 네트워크로 요청합니다. SQLite는 앱 안에 들어간 라이브러리가 직접 파일을 읽고 씁니다.
서버형 (MySQL · PostgreSQL)
[앱] --(TCP 5432)--> [DB 서버 프로세스] --> [파일]
· 서버를 설치·기동해야 함
· 계정·권한·포트·방화벽 필요
· 여러 앱이 동시에 안전하게 접근
· 네트워크 왕복 비용이 있음
임베디드 (SQLite)
[앱 + SQLite 라이브러리] --(함수 호출)--> [파일]
· 설치할 서버가 없음
· 계정·포트 개념 자체가 없음
· 접근 통제 = 파일 권한
· 네트워크 왕복이 0 (함수 호출)
어디에 이미 들어 있나
- 모든 안드로이드·iOS 기기 — 앱 데이터 저장의 기본
- 모든 주요 브라우저 — 내부 저장소
- macOS · Windows 10 이상 — 시스템 곳곳에서 사용
- 파이썬 —
import sqlite3만으로 바로 사용 (표준 라이브러리) - 항공기 · 자동차 · IoT 기기 — 임베디드 환경의 사실상 표준
언제 쓰고 언제 안 쓰나
| 판단 | 상황 |
|---|---|
| 쓴다 | 모바일·데스크톱 앱의 로컬 저장 · CLI 도구의 설정과 캐시 · 읽기 위주 웹사이트 · 테스트 픽스처 · 데이터 분석용 임시 저장 · 엣지·IoT · 로그 수집기의 버퍼 |
| 안 쓴다 | 여러 서버가 동시에 쓰기 · 초당 수백 건 이상의 쓰기 · 사용자별 권한이 필요한 경우 · 네트워크 너머에서 접근해야 할 때 · 네트워크 파일시스템(NFS·SMB) 위 (락이 깨져 파일이 손상됩니다) |
fopen() 입니다.
즉 "파일에 직접 저장하는 것"의 대체재입니다.
JSON·CSV 파일에 데이터를 저장하고 있다면, 그 자리가 SQLite 의 자리입니다.
설치와 실행 — 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 등 — 개발 중 빠르게 확인 |
| Datasette | SQLite 파일을 바로 웹 API·탐색 UI로 만들어 줍니다 (데이터 공개에 유용) |
.gitignore 하는 것이 정석입니다.
.dot 명령 완전정복
점(.)으로 시작하는 명령이 sqlite3 CLI의 도구입니다. 세미콜론을 붙이지 않습니다.
| 명령 | 하는 일 |
|---|---|
.tables | 테이블 목록 |
.schema member | CREATE 문 그대로 보기 (생략하면 전체) |
.headers on | 컬럼 이름 표시 — 처음에 반드시 켜세요 |
.mode box | 표 형태 출력 (table·markdown·json·csv 등) |
.import data.csv member --csv | CSV 가져오기 |
.output result.csv | 결과를 파일로 (다시 화면으로는 .output stdout) |
.once result.csv | 다음 한 번만 파일로 |
.backup backup.db | ★ 안전한 백업 (파일 복사와 다릅니다) |
.dump | 전체를 SQL 텍스트로 (다른 DB로 옮길 때) |
.read script.sql | SQL 파일 실행 |
.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;
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 를 넣어 두면 매번 칠 필요가 없습니다.
...> 로 바뀌며 기다립니다.
빠져나오려면 세미콜론을 치거나 Ctrl+C 를 누르세요.
동적 타입 시스템
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;
INTEGER 컬럼에 '123' 을 넣으면 숫자 123으로 바뀌지만,
'안녕' 은 텍스트로 저장됩니다.
| 저장 클래스 | 설명 |
|---|---|
NULL | 빈 값 |
INTEGER | 부호 있는 정수 (1~8바이트 가변) |
REAL | 8바이트 부동소수점 |
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;
CHECK (col IN (…)) 이 그 역할을 합니다.
날짜 형식도 GLOB 패턴으로 강제할 수 있습니다.
타입이 느슨한 만큼 제약으로 채우는 것이 SQLite 설계의 요령입니다.
ROWID · AUTOINCREMENT · UPSERT
SQLite 특유의 기본키 구조를 이해하면 불필요한 설정을 안 하게 됩니다.
모든 행에는 숨은 번호가 있습니다
CREATE TABLE t (name TEXT);
INSERT INTO t VALUES ('a'), ('b');
SELECT rowid, name FROM t; -- rowid 는 자동으로 존재합니다
-- 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 를 쓰세요.
날짜 · 문자열 함수
날짜 타입이 없으므로 저장 형식을 팀에서 먼저 정하는 것이 중요합니다.
날짜 저장 — 세 가지 방식
| 방식 | 예 | 평가 |
|---|---|---|
| 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';
'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 는 대소문자 무시
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 조합이면 충분히 빠르고 안전합니다.
WAL 모드와 동시성
기본 저널 모드에서는 쓰는 동안 아무도 못 읽습니다. WAL로 바꾸면 읽기와 쓰기가 서로를 막지 않습니다 — 실전 앱에서는 거의 필수입니다.
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;
① 네트워크 파일시스템에서 동작하지 않습니다(공유 메모리를 쓰기 때문)
② 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), "초")
인덱스 · 부분 인덱스
문법은 다른 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;
| 계획에 나오는 말 | 의미 |
|---|---|
SEARCH … USING INDEX | 인덱스로 찾음 — 좋습니다 |
USING COVERING INDEX | 인덱스만으로 해결 — 가장 좋습니다 |
SCAN table | 전체 스캔 — 큰 테이블이면 인덱스를 검토하세요 |
USE TEMP B-TREE FOR ORDER BY | 정렬용 임시 구조 생성 — 정렬에 맞는 인덱스가 없습니다 |
.expert 명령 — sqlite3 CLI 에서
.expert 를 켠 뒤 쿼리를 실행하면 필요한 인덱스를 추천해 줍니다.
실험적 기능이지만 시작점으로는 유용합니다.
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;
database is locked 해결
SQLite 질문 1위입니다. 원인은 몇 가지로 정해져 있고, 대부분 설정 두 줄로 해결됩니다.
| 원인 | 해결 |
|---|---|
| 다른 연결이 쓰기 중 | PRAGMA busy_timeout = 5000 — 기본값 0이라 즉시 실패합니다 |
| 기본 저널 모드 | PRAGMA journal_mode = WAL — 읽기가 쓰기를 막지 않게 |
| 트랜잭션을 안 닫음 | 커밋·롤백을 빠뜨린 연결이 락을 붙들고 있습니다. with 문·try/finally 로 보장하세요 |
| 커서를 닫지 않음 | 결과를 끝까지 읽지 않은 커서가 읽기 락을 유지합니다 |
| 여러 스레드가 한 연결 공유 | 연결은 스레드마다 따로 만드세요 |
| 네트워크 드라이브 | 락이 동작하지 않습니다 — 로컬 디스크로 옮기세요 |
| 긴 쓰기 트랜잭션 | 대량 작업을 청크로 나누고 중간에 커밋하세요 |
# 파이썬에서 안전하게 쓰는 형태
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") 로 지정합니다.
sqlite3 는 자동 커밋 동작이 특이합니다.
DML 앞에서 암묵적으로 트랜잭션을 열고, DDL 앞에서는 커밋해 버립니다.
혼란을 피하려면 isolation_level=None 으로 두고
트랜잭션을 직접 BEGIN/COMMIT 하는 편이 명확합니다.
(3.12+ 에서는 autocommit 속성이 추가되어 표준에 가까워졌습니다)
백업 · 무결성 · 암호화
쓰는 중에 파일을 그냥 복사하면 깨집니다. 안전한 방법을 씁니다.
# ✓ 안전한 백업 (온라인 백업 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 | 가장 널리 쓰이는 오픈소스 암호화 빌드. 모바일 앱에서 표준 |
| SEE | SQLite 개발사의 공식 유료 확장 |
| OS 전체 디스크 암호화 | 모바일·노트북이라면 이미 적용돼 있을 수 있습니다 — 먼저 확인 |
| 애플리케이션 레벨 | 민감한 컬럼만 앱에서 암호화해 저장 |
어디까지 써도 되나
"SQLite는 장난감"이라는 말은 사실이 아닙니다. 다만 한계는 분명합니다.
공식 한계 (사실상 안 걸립니다)
| 항목 | 한계 |
|---|---|
| DB 파일 최대 크기 | 281TB (기본 페이지 크기 기준) |
| 테이블 행 수 | 사실상 무제한 (2^64) |
| 컬럼 수 | 기본 2,000 (최대 32,767) |
| 문자열·BLOB 크기 | 기본 1GB |
| 동시 읽기 연결 | 제한 없음 |
| 동시 쓰기 연결 | 사실상 1개 (DB 파일 단위) |
현실적인 판단 기준
🟢 문제없음
- 읽기 위주 웹사이트 (초당 수백~수천 조회)
- 데이터 수 GB, 수천만 행
- 단일 서버에서 동작하는 앱
- 쓰기가 초당 수십 건 이하
🔵 설계가 필요
- 쓰기가 초당 수백 건 — 배치·큐로 직렬화
- 여러 프로세스가 동시에 쓰기
- 장시간 도는 분석 쿼리와 쓰기가 공존
- WAL + busy_timeout 필수
🟣 다른 DB로
- 여러 서버에서 같은 DB에 쓰기
- 네트워크 너머 접근이 필요
- 사용자별 세밀한 권한 관리
- 고가용성·자동 페일오버 요구
- 쓰기가 지속적으로 초당 1,000건 이상
SQLite 를 서버처럼 쓰는 도구들
| 도구 | 하는 일 |
|---|---|
| Litestream | SQLite 변경을 S3 등으로 실시간 복제 — 재해 복구 |
| LiteFS | 파일시스템 수준 복제로 여러 노드에 읽기 복제본 |
| rqlite | Raft 합의를 붙인 분산 SQLite |
| Turso / libSQL | SQLite 포크 + 엣지 분산 서비스 |
| Datasette | SQLite 파일을 읽기 전용 웹 API·탐색 UI 로 |
앱에 내장하기
언어·플랫폼별로 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: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();
}
모바일 · 데스크톱
| 플랫폼 | 권장 방식 |
|---|---|
| Android | Room (Jetpack) — SQLite 위의 ORM. 마이그레이션·타입 안전성 제공 |
| iOS | GRDB.swift 또는 Core Data (내부가 SQLite) |
| Flutter | sqflite · drift |
| Electron | better-sqlite3 — 메인 프로세스에서만 접근하고 IPC 로 넘기세요 |
| 브라우저 | sql.js (WASM) · wa-sqlite — OPFS 에 저장 가능 |
① 메인(UI) 스레드에서 DB 작업을 하지 마세요 — 화면이 멈춥니다
② 스키마 마이그레이션 코드를 처음부터 넣으세요 — 앱은 업데이트해도 사용자 기기의 DB는 그대로입니다
③ 민감 정보는 암호화하거나 OS 키체인에
④ DB 파일이 앱 삭제 시 사라진다는 점을 사용자에게 알리거나 동기화를 제공하세요
Redis —
메모리에서 답하는
가장 빠른 저장소
Redis는 데이터를 디스크가 아니라 메모리에 두고 마이크로초 단위로 답하는 키-값 저장소입니다. 실무에서는 RDBMS 앞에 두는 캐시, 세션 저장소, 순위표, 분산 락으로 쓰입니다. 이 탭은 NoSQL이 뭔지부터 시작해서, Redis를 실서비스에 붙일 때 필요한 것까지 다룹니다.
NoSQL이란 — RDBMS와 무엇이 다른가
NoSQL은 "SQL을 안 쓴다"가 아니라 "표 구조를 강제하지 않는다(Not only SQL)"는 뜻입니다. 관계형(RDBMS)을 대체하는 게 아니라, 관계형이 불편한 자리를 보완하는 도구들의 통칭이에요.
정면 비교 — 무엇이 다른가
| 항목 | RDBMS (PostgreSQL·MySQL…) | NoSQL (Redis·MongoDB…) |
|---|---|---|
| 데이터 모양 | 정해진 칸의 표. 모든 행이 같은 구조 | 키-값·JSON 문서 등 유연한 구조 |
| 스키마 | 먼저 정하고 시작 (칸 추가 = ALTER) | 없거나 느슨함 — 데이터마다 칸이 달라도 됨 |
| 표끼리 연결 | JOIN — 정규화해 나눠 두고 붙여서 조회 | 조인이 없거나 약함 — 한 덩어리로 미리 묶어 저장 |
| 트랜잭션 | ACID 완전 보장 — 돈 계산의 표준 | 제한적 (단일 키·단일 문서 위주, 제품마다 다름) |
| 규모 확장 | 기본은 서버 키우기(수직) + 읽기 복제 | 처음부터 서버 늘리기(수평 분산)를 겨냥한 설계 |
| 질의 언어 | SQL — 표준이 있어 어디서나 비슷 | 제품마다 다름 (Redis 명령어, MongoDB 쿼리…) |
| 잘 맞는 데이터 | 회원·주문·재고 — 정확해야 하는 업무 데이터 | 캐시·세션·로그·피드 — 빠르고 많은 데이터 |
NoSQL 4갈래 — 이름만 알아 두세요
| 갈래 | 대표 제품 | 데이터 모양 · 쓰는 곳 |
|---|---|---|
| 키-값 | Redis · Memcached | 키 → 값. 캐시·세션·카운터. 이 탭에서 다룹니다 |
| 문서형 | MongoDB · CouchDB | JSON 문서. 구조가 자주 바뀌는 데이터. 🍃 MongoDB 탭 |
| 와이드 컬럼 | Cassandra · HBase | 행마다 열이 다른 거대한 표. 시계열·초대용량 쓰기 |
| 그래프 | Neo4j | 점과 선(관계). 추천·친구관계·사기 탐지 |
Redis는 어떤 저장소인가
Redis가 빠른 이유는 단순합니다 — 모든 데이터가 메모리에 있고, 명령을 한 줄로 세워 순서대로 처리하기 때문입니다.
세 가지 특징이 전부입니다
- ① 인메모리 — 데이터가 전부 RAM에 있습니다. 디스크 DB가 밀리초(ms)로 답할 때 Redis는 마이크로초(µs)로 답합니다. 대신 메모리 크기가 곧 저장 용량의 한계입니다.
- ② 싱글 스레드 명령 처리 — 명령을 한 번에 하나씩 순서대로 실행합니다. 락 경합이 없어 단순하고, INCR 같은 연산이 저절로 원자적입니다. 뒤집어 말하면 느린 명령 하나가 전체를 막습니다 (r12에서 다룹니다).
- ③ 자료구조 서버 — 값이 단순 문자열만이 아닙니다. 리스트·집합·정렬된 집합·해시를 서버가 직접 제공해서, "순위표"나 "최근 본 상품" 같은 기능이 명령 몇 개로 끝납니다.
체감 속도 차이
| 작업 | 대략적인 시간 |
|---|---|
| Redis GET (메모리) | 약 0.1ms 이하 — 같은 네트워크 기준 |
| RDBMS 인덱스 조회 (캐시 적중) | 수 ms |
| RDBMS 복잡한 JOIN·집계 | 수십 ms ~ 수 초 |
설치와 첫 접속 — 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)
bind 0.0.0.0 으로 열고 비밀번호 없이 인터넷에 노출하면
몇 분 안에 스캐너가 찾아내 데이터를 지우고 협박 메시지를 남깁니다 (실제로 흔한 사고입니다).
외부에 열어야 한다면 requirepass + 방화벽 + protected-mode yes 를 확인하세요.
키 설계와 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 기준 |
|---|---|
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
SELECT 1로 전환), 실무에서는 번호 구분 대신 키 접두사를 쓰고
용도가 다르면 아예 Redis 인스턴스를 분리하는 것이 표준입니다 — 클러스터 모드에서는 db0만 쓸 수 있기 때문입니다.
자료구조 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) vs Hash — 객체는 어느 쪽에
| 기준 | String + JSON | Hash |
|---|---|---|
| 꺼내는 단위 | 항상 통째로 (직렬화/역직렬화) | 필드 하나씩 읽고 고칠 수 있음 |
| 부분 수정 | 전체를 읽어 고쳐 다시 저장 | HSET user:1 grade GOLD 한 방 |
| 숫자 증가 | 불가 (읽고-고치고-쓰기 경합) | HINCRBY user:1 points 100 원자적 |
| TTL | 키 단위 ✓ | 키 단위만 — 필드별 TTL 없음 |
| 어울리는 곳 | 캐시 응답처럼 통으로 쓰고 버리는 것 | 계속 갱신되는 상태(유저 프로필·장바구니) |
# 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 # 월간 합산
캐시 전략 — Cache-Aside 패턴
Redis의 사용처 1위는 압도적으로 캐시입니다. 패턴은 사실상 하나만 알면 됩니다 — "없으면 DB에서 읽어 채우고, 있으면 바로 준다".
코드로는 이 열 줄입니다 (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 로 알아서 소멸
# 구조 다른 캐시를 역직렬화하다 터지는 배포 사고를 원천 차단
SET NX)으로 한 요청만 DB에 가게 한다,
③ 아주 인기 있는 키는 만료 전에 백그라운드로 미리 갱신한다.
실전 레시피 — 세션 · 순위표 · 분산 락
실무에서 Redis가 실제로 맡는 일 네 가지를 그대로 옮길 수 있는 형태로 정리했습니다.
① 세션 저장소
# 로그인 시 — 세션 ID를 키로, 유저 정보를 값으로, 30분 수명
SET session:9f2a71c0 "{\"userId\":1001,\"grade\":\"GOLD\"}" EX 1800
# 매 요청 — 쿠키의 세션 ID로 조회 + 수명 연장
GET session:9f2a71c0
EXPIRE session:9f2a71c0 1800
② 순위표 — 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"
영속성 — 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
메모리 관리 — maxmemory와 축출
메모리가 차면 어떻게 되는가 — 이 질문의 답을 설정으로 정해 두지 않으면 Redis는 쓰기를 거부하거나(기본), OS가 프로세스를 죽입니다.
# redis.conf (주석은 반드시 별도 줄에)
# 한도를 반드시 명시하세요
maxmemory 2gb
# 차면 "가장 오래 안 쓴 키"부터 지운다
maxmemory-policy allkeys-lru
| 정책 | 차면 하는 일 · 언제 쓰나 |
|---|---|
noeviction (기본) | 쓰기를 에러로 거부 — 캐시 용도에는 부적합, 데이터 보존 용도라면 이것 |
allkeys-lru | 캐시 표준 — 전체 키에서 가장 오래 안 쓴 것부터 축출 |
volatile-lru | TTL 있는 키 중에서만 LRU 축출 — 캐시와 보존 데이터가 섞여 있을 때 |
allkeys-lfu | 사용 빈도가 낮은 것부터 — 접근 패턴이 뚜렷할 때 히트율이 더 좋음 |
used_memory 가 maxmemory 에 붙어 다니면 축출이 상시 발생 중이라는 뜻이고
(캐시라면 정상, 세션 저장소라면 사고), mem_fragmentation_ratio 가 1.5를 넘으면
단편화가 심한 상태입니다. 재시작 또는 activedefrag 를 검토합니다.
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
복제 · Sentinel · Cluster — 죽지 않게
캐시라도 Redis가 죽으면 그 트래픽이 전부 DB로 쏟아집니다. 규모에 따라 세 단계로 고가용성을 올립니다.
| 단계 | 구성 · 언제 |
|---|---|
| ① 복제(replica) | 마스터의 복사본을 1대 이상 유지. 읽기 분산 + 수동 승격이 가능해집니다. replicaof 한 줄. |
| ② Sentinel | 감시 프로세스 3대가 마스터 장애를 합의로 판정하고 자동 승격. 마스터 1 + 복제 N 구조에서의 표준 HA. |
| ③ Cluster | 데이터 자체를 16384 슬롯으로 나눠 여러 마스터에 분산. 메모리가 한 대에 안 담길 때. 다중 키 명령·트랜잭션에 제약이 생깁니다. |
실무 함정 — 이것만은 피하세요
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이 너무 짧거나
캐시할 가치가 없는 키를 캐시하고 있는 것입니다.
앱에서 쓰기 — 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");
MongoDB —
표 대신
JSON 문서로
MongoDB는 데이터를 표가 아니라 JSON 문서로 저장하는 문서형 NoSQL입니다. 문서마다 구조가 달라도 되고, 관련 데이터를 한 문서에 통째로 담아 조인 없이 꺼냅니다. SQL을 아는 사람 기준으로 무엇이 어디에 대응되는지부터 시작합니다. (NoSQL 이 처음이라면 ⚡ Redis 탭 r01의 RDBMS 비교를 먼저 보세요.)
MongoDB는 어떤 DB인가 — 용어 대응부터
SQL 용어와 1:1로 놓고 보면 절반은 이미 아는 것입니다. 다른 절반 — "조인 대신 내장" — 이 MongoDB의 진짜 차이입니다.
용어 대응표
| RDBMS | MongoDB | 설명 |
|---|---|---|
| 데이터베이스 | 데이터베이스 | 같은 개념입니다 |
| 테이블 | 컬렉션(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 }] }
]
}
설치 — 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() // 전체 조회
MONGO_INITDB_ROOT_* 를 반드시 넣고, 방화벽으로 27017을 막으세요.
라이선스는 SSPL — 서비스 백엔드로 쓰는 건 무료지만,
"MongoDB 자체를 클라우드 서비스로 팔" 때만 제약이 있습니다.
CRUD — SQL과 나란히
네 가지 기본 동작을 SQL과 대조하면 문법이 바로 읽힙니다.
| SQL | MongoDB |
|---|---|
INSERT INTO member VALUES (…) | db.member.insertOne({ name: "김하늘" }) |
SELECT * FROM member WHERE city='서울' | db.member.find({ city: "서울" }) |
SELECT name FROM member | db.member.find({}, { name: 1, _id: 0 }) |
UPDATE member SET grade='GOLD' WHERE id=1 | db.member.updateOne({ _id: 1 }, { $set: { grade: "GOLD" } }) |
DELETE FROM member WHERE id=1 | db.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은 첫 한 건만 바꿉니다.
"왜 하나만 바뀌지?"의 답은 대부분 이것입니다. 전부 바꾸려면 updateMany.
쿼리 연산자 · 정렬 · 페이징
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: "서울" })
find({ _id: { $gt: 마지막본_id } }).limit(10) 식의 커서 방식으로 —
원리는 ⚙️ 튜닝 탭 t09와 같습니다.
문서 설계 — 내장 vs 참조
MongoDB 설계의 전부가 이 질문입니다 — "이 데이터를 문서 안에 넣을까(내장), 다른 컬렉션에 두고 id로 가리킬까(참조)?"
| 상황 | 선택 |
|---|---|
| 주소·옵션·설정 — 항상 부모와 같이 읽음, 몇 개 안 됨 | 내장 |
| 주문 내역 — 계속 쌓임, 주문만 따로 조회·집계함 | 참조 (별도 컬렉션) |
| 댓글 — 보통 수십 개, 폭주 가능성 있음 | 혼합 — 최근 10개는 내장 + 전체는 별도 컬렉션 |
| 작성자 이름을 게시글마다 표시 | 참조 + 이름만 복사해 내장(비정규화) — 바뀌면 일괄 갱신 |
집계 파이프라인 — 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 대응 |
|---|---|
$match | WHERE — 파이프라인 맨 앞에 둬야 인덱스를 탑니다 |
$group | GROUP BY + 집계함수 ($sum · $avg · $min · $max · $push) |
$sort / $limit / $skip | ORDER BY / LIMIT / OFFSET |
$project | SELECT 절 — 필드 선택·계산 필드 생성 |
$unwind | 배열을 행으로 펴기 (배열 원소별 집계 전에) |
$lookup | LEFT 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" } ← 매일 갱신되는 집계 테이블처럼 씁니다
인덱스 — 원리는 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 커버링과 같은 원리
트랜잭션과 일관성 — 어디까지 되나
"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 | 확인 안 함 — 로그성 데이터가 아니면 쓰지 마세요 |
복제 셋과 샤딩 — 운영 골격
운영 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 # 인증을 켰으니 여기도 계정이 필요합니다
스키마 검증과 안티패턴
"스키마가 없다"는 "아무거나 넣어도 된다"가 아닙니다. 구조는 코드와 검증 규칙으로 지켜야 하고, 안 지키면 쓰레기 데이터가 조용히 쌓입니다.
스키마 검증 — 컬렉션에 규칙 걸기
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 필드. |
앱에서 쓰기 — 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)
{ "$gt": "" } 같은 연산자가 주입돼 조건이 항상 참이 됩니다 (로그인 우회 단골).
문자열이어야 할 값은 문자열인지 확인하고, Express라면 mongo-sanitize 류로 $ 키를 걸러내세요.
RDBMS와 같이 쓰기 — 무엇을 어디에
마지막 질문은 "MongoDB냐 PostgreSQL이냐"가 아니라 "이 데이터는 어느 쪽 성질인가"입니다. 한 서비스가 둘 다 쓰는 게 보통입니다.
| 데이터 | RDBMS로 | MongoDB로 |
|---|---|---|
| 주문 · 결제 · 재고 · 정산 | ✓ 무조건 — 다중 행 정합성·ACID | |
| 회원 · 권한 | ✓ 관계가 많고 정확해야 함 | |
| 상품 카탈로그 (옵션이 제각각) | 가능 (JSONB) | ✓ 문서형이 자연스러움 |
| 로그 · 이벤트 · 센서 데이터 | ✓ 대량 쓰기 + TTL + 유연 구조 | |
| 사용자 생성 콘텐츠 (설문·양식) | ✓ 구조가 문서마다 다름 | |
| 캐시 · 세션 · 순위표 | → 둘 다 아님 — ⚡ Redis | |
설계와 튜닝 —
같은 결과를
더 빠르게
SQL을 쓸 줄 아는 것과 느리지 않게 쓰는 것은 다른 능력입니다. 이 탭은 DB 종류와 무관하게 통하는 원리를 다룹니다 — 정규화·인덱스·실행계획·트랜잭션·락·커넥션 풀. 실무에서 성능 문제의 90%가 이 안에 있습니다.
정규화 1~3NF
정규화는 어려운 이론이 아닙니다. 같은 정보를 두 군데 적지 않기 — 이 한 줄입니다.
정규화하지 않으면 벌어지는 일
| 주문번호 | 회원명 | 회원주소 | 상품명 | 수량 |
|---|---|---|---|---|
| 1001 | 김하늘 | 서울시 강남구 | 키보드 | 1 |
| 1002 | 김하늘 | 서울시 강남구 | 마우스 | 2 |
| 1003 | 김하늘 | 서울시 강남구 | 모니터 | 1 |
- 갱신 이상 — 김하늘이 이사하면 주문 100건을 전부 고쳐야 합니다. 하나라도 놓치면 데이터가 서로 다릅니다
- 삽입 이상 — 아직 주문하지 않은 회원은 등록할 방법이 없습니다
- 삭제 이상 — 마지막 주문을 지우면 회원 정보도 함께 사라집니다
1NF → 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 는 이론적으로는 존재하지만 실무에서 의식적으로 적용할 일은 드뭅니다.
반정규화는 언제 하나
일부러 중복을 두는 것이 반정규화입니다. 측정해서 느릴 때만, 이유를 적어 두고 합니다.
| 유형 | 예 | 대가 |
|---|---|---|
| 집계 컬럼 | 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 캐시가 더 나은 선택일 수 있습니다
④ 했다면 정합성 검증 배치를 반드시 함께 만드세요
키 설계 — 대리키 · 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 관리 필요 |
-- 실무의 절충안 : 내부는 순차 정수, 외부 노출은 별도 식별자
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) -- 중복 방지가 덤으로 따라옵니다
);
인덱스 원리 B-Tree
인덱스는 책 뒤의 찾아보기입니다. 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개뿐 — 선택도가 낮아 무의미 |
인덱스의 비용
- 쓰기가 느려집니다 — INSERT·UPDATE·DELETE 마다 모든 인덱스를 함께 갱신합니다
- 디스크를 차지합니다 — 인덱스가 테이블보다 커지는 경우도 흔합니다
- 옵티마이저의 선택지가 늘어납니다 — 너무 많으면 잘못된 인덱스를 고를 수도 있습니다
복합 인덱스 순서 · 커버링
복합 인덱스는 컬럼 순서가 성능을 결정합니다. 전화번호부를 이름으로 찾을 수 없는 것과 같은 이유입니다.
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 가 없으므로 인덱스 컬럼에 직접 넣습니다
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);
인덱스가 안 타는 이유 10
"인덱스를 만들었는데 여전히 느려요" — 원인은 거의 이 열 가지 안에 있습니다.
| # | 원인 | 해결 |
|---|---|---|
| 1 | 컬럼에 함수·연산WHERE YEAR(dt) = 2026 |
범위 조건으로 바꾸거나 함수 기반 인덱스를 만듭니다 |
| 2 | 앞에 % 붙은 LIKELIKE '%검색어%' |
전문 검색·trigram 인덱스로 (🐘 p09 · ✒️ s10) |
| 3 | 암묵적 형변환WHERE varchar_col = 123 |
타입을 맞춰 비교합니다. 조인 컬럼 타입 불일치도 같은 문제 |
| 4 | 선행 컬럼 누락 | 복합 인덱스 순서를 조정하거나 별도 인덱스 |
| 5 | 선택도가 낮음 전체의 30% 이상이 조건에 맞음 |
정상 동작입니다. 풀스캔이 실제로 더 빠릅니다 |
| 6 | 통계가 낡음 | ANALYZE 실행. 대량 적재 후에는 반드시 |
| 7 | OR 조건WHERE a = 1 OR b = 2 |
UNION ALL 로 분리하거나 각각 인덱스 (일부 DB는 병합 처리) |
| 8 | NULL 조건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 행도 살림)
실행계획 읽는 법 (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 SCANSQLite
EXPLAIN QUERY PLAN SELECT ...;
-- 볼 것
-- SEARCH ... USING INDEX (좋음)
-- USING COVERING INDEX (가장 좋음)
-- SCAN table (전체 스캔)
-- USE TEMP B-TREE FOR ORDER BY (정렬 비용)어느 DB든 확인하는 것은 같습니다
통계 정보와 카디널리티
옵티마이저는 통계를 보고 계획을 짭니다. 통계가 틀리면 멀쩡하던 쿼리가 갑자기 느려집니다.
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행"이라고 알고 있습니다
- 대량 삭제 직후 — 반대 상황
- 배포 직후 — 새 테이블·인덱스에 통계가 아직 없습니다
- 데이터 분포가 급변 — 이벤트로 특정 값이 폭증한 경우
히스토그램 — 편향된 분포
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');
페이징 — 커서 방식
OFFSET 100000 은 10만 줄을 세어서 버리고 시작합니다.
뒤 페이지가 느린 이유이자, 무한 스크롤이 갈수록 버벅이는 이유입니다.
✗ 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)
· 또는 주기적으로 계산해 캐시합니다
트랜잭션 ACID
돈 계산이 틀리지 않게 하는 네 가지 약속입니다.
| 속성 | 뜻 | 없으면 |
|---|---|---|
| Atomicity 원자성 | 전부 되거나 전부 안 되거나 | 출금은 됐는데 입금이 안 됨 |
| Consistency 일관성 | 제약조건을 항상 만족 | 재고가 −5개인 상태가 저장됨 |
| Isolation 고립성 | 동시 트랜잭션이 서로 간섭하지 않음 | 동시 주문에서 재고가 두 번 팔림 |
| Durability 지속성 | 커밋했으면 정전에도 남음 | "결제 완료" 후 서버가 죽으니 기록이 없음 |
· 애플리케이션의 논리적 오류 — 잘못된 금액을 커밋하면 그대로 남습니다
· 여러 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) # 비동기로
CREATE TABLE·ALTER TABLE 은 MySQL·Oracle 에서
앞의 트랜잭션을 커밋시키고 자신도 되돌릴 수 없습니다.
PostgreSQL 은 예외적으로 DDL 을 롤백할 수 있어
마이그레이션 스크립트를 안전하게 만들 수 있습니다.
격리 수준 4단계
동시에 접근할 때 얼마나 엄격하게 막을지입니다. 아래 버튼으로 각 수준에서 무엇이 막히고 무엇이 뚫리는지 확인하세요.
세 가지 이상 현상
| 현상 | 무슨 일이 일어나나 |
|---|---|
| 더티 리드 Dirty Read |
다른 트랜잭션이 아직 커밋하지 않은 값을 읽습니다. 그 트랜잭션이 롤백하면 존재한 적 없는 값을 본 셈이 됩니다. |
| 반복 불가 읽기 Non-repeatable Read |
같은 행을 두 번 읽었는데 값이 달라집니다. 사이에 다른 트랜잭션이 수정·커밋했기 때문입니다. |
| 팬텀 리드 Phantom Read |
같은 조건으로 두 번 조회했는데 행 개수가 달라집니다. 사이에 새 행이 들어왔기 때문입니다. |
DB별 기본값 — 여기가 핵심입니다
| DB | 기본 격리 수준 | 특이점 |
|---|---|---|
| PostgreSQL | READ COMMITTED | READ UNCOMMITTED 를 요청해도 READ COMMITTED 로 동작 |
| Oracle | READ COMMITTED | REPEATABLE READ 를 지원하지 않습니다 (SERIALIZABLE 은 지원) |
| MySQL InnoDB | REPEATABLE READ | 다른 DB보다 엄격. 갭 락으로 팬텀도 대부분 방지 (🐬 m11) |
| SQLite | SERIALIZABLE | 쓰기가 하나뿐이라 자연히 직렬화됩니다 |
-- 격리 수준 확인·변경
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 로 올리면 충돌·재시도가 급증합니다.
락 · 데드락 분석
데드락은 서로 상대가 잡은 것을 기다리는 상태입니다. 완전히 없앨 수는 없지만 크게 줄일 수 있습니다.
✗ 접근 순서가 다름 — 데드락
-- 트랜잭션 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)
innodb_lock_wait_timeout 은 50초입니다.
그동안 커넥션이 묶여 있으면 연쇄적으로 커넥션 풀이 고갈됩니다.
5~10초 정도로 낮추고 애플리케이션에서 재시도하는 편이 낫습니다.
커넥션 풀 사이징
커넥션 풀은 크게 잡을수록 빨라지지 않습니다. 오히려 느려집니다.
커넥션을 만드는 것은 비싼 작업(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, # ★ 끊긴 커넥션 자동 감지 — 반드시 켜세요
)
max_connections 는 이 총합보다 여유 있게 잡되,
그 자체를 크게 늘리는 것은 해결책이 아닙니다.
PostgreSQL 이라면 PgBouncer 를 두는 것이 정석입니다(🐘 p17).
슬로우 쿼리 잡는 절차
감으로 찾지 마세요. 순서만 지키면 누구나 잡을 수 있습니다.
pg_stat_statements·sys.statement_analysis·AWR 로 총 소요 시간 순위를 뽑습니다.개선 카드 — 효과가 큰 순서
| 순위 | 카드 | 기대 효과 |
|---|---|---|
| 1 | 인덱스 추가·수정 | 보통 수십~수백 배. 가장 먼저 검토 |
| 2 | N+1 제거 | 쿼리 101번 → 2번 |
| 3 | 쿼리 재작성 | 서브쿼리 → 조인, 함수 제거, 커서 페이징 |
| 4 | 필요한 컬럼만 조회 | 네트워크·메모리 절감, 커버링 인덱스 가능 |
| 5 | 통계 갱신 | 계획이 갑자기 나빠졌을 때 |
| 6 | 캐시 도입 | 같은 조회가 반복될 때 |
| 7 | 비정규화·집계 테이블 | 위로 안 되면. 정합성 비용을 감수 |
| 8 | 서버 설정·증설 | 마지막. 대부분 근본 원인을 가릴 뿐입니다 |
· 측정 없이 최적화 — 어디가 느린지 모르는 채로 코드를 고치는 것
· 여러 개를 한 번에 바꾸기 — 무엇이 효과가 있었는지 알 수 없습니다
· 운영에서 바로 인덱스 생성 — 큰 테이블은 반드시
CONCURRENTLY·온라인 DDL 로
· 서버 설정부터 만지기 — 쿼리가 원인인데 메모리를 늘려 봐야 잠깐 미룰 뿐입니다
모니터링 지표 · 알람
무엇을 보고 있어야 장애를 터지기 전에 알 수 있는지 정리합니다.
| 지표 | 정상 범위 · 알람 기준 |
|---|---|
| 커넥션 수 | 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 (별도 구성 없이 바로)
모델링 실전 — 주문 · 재고
이론을 실제 테이블로 옮겨 봅니다. 커머스의 핵심이자 면접 단골 주제입니다.
-- 상품 : 현재 정보
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 안에서
무슨 일이
여기서부터는 "왜 그렇게 동작하는가"입니다. 커밋이 왜 디스크 속도에 묶이는지, 왜 PostgreSQL은 VACUUM이 필요한데 오라클은 아닌지, 옵티마이저가 왜 이상한 계획을 골랐는지 — 튜닝으로 안 풀리는 문제는 대개 이 층위에서 원인을 찾습니다.
디스크에 어떻게 저장되나
DB는 행 단위가 아니라 페이지(블록) 단위로 읽고 씁니다. 한 줄만 고쳐도 페이지 전체가 움직입니다.
| DB | 기본 페이지 크기 | 비고 |
|---|---|---|
| PostgreSQL | 8KB | 컴파일 시 결정 (변경하려면 재빌드) |
| MySQL InnoDB | 16KB | innodb_page_size 로 4·8·16·32·64KB |
| Oracle | 8KB | 테이블스페이스마다 다르게 지정 가능 |
| SQLite | 4KB | PRAGMA 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;
버퍼 풀 · 캐시 히트율
메모리에 올라와 있으면 마이크로초, 디스크까지 가면 밀리초. 1000배 차이가 여기서 납니다.
-- 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');
스캔이 캐시를 밀어내는 문제
큰 테이블을 한 번 풀스캔하면 버퍼 풀에 있던 유용한 페이지가 전부 밀려납니다. 그 뒤 한동안 모든 쿼리가 느려집니다. DB들은 이를 완화하는 장치를 둡니다.
- PostgreSQL — 큰 순차 스캔은 작은 링 버퍼만 사용해 전체를 오염시키지 않습니다
- InnoDB — LRU 리스트를 young/old 로 나눠, 한 번 읽힌 페이지는 old 영역에 두었다가 재사용될 때만 승격합니다
- Oracle — 풀스캔 블록을 LRU 끝에 두거나
KEEP·RECYCLE풀로 분리합니다
WAL · REDO · UNDO
DB는 진짜 데이터보다 로그를 먼저 씁니다. 이것이 커밋이 디스크 속도에 묶이는 이유이자, 전원이 나가도 복구되는 이유입니다.
왜 로그를 먼저 쓰면 빠른가
로그 없이 데이터 파일에 직접
커밋할 때마다
디스크의 여러 위치에 랜덤 쓰기
· 페이지 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 과 함께면 안전하면서 빠름
체크포인트와 크래시 복구
체크포인트는 "여기까지는 디스크에 확실히 반영됐다"는 표시입니다. 재시작 시간이 여기서 결정됩니다.
# 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 # 백그라운드 플러시 속도
복구 과정 (ARIES 방식)
- 분석 — 마지막 체크포인트부터 로그를 읽어 어떤 트랜잭션이 진행 중이었는지 파악
- REDO — 커밋 여부와 무관하게 로그에 있는 모든 변경을 다시 적용해 크래시 직전 상태로
- UNDO — 그중 커밋되지 않은 트랜잭션을 되돌립니다
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 의 복잡성이 대부분 불필요실무에서 드러나는 차이
| 상황 | PostgreSQL | InnoDB · Oracle |
|---|---|---|
| 1억 행 UPDATE 후 | 테이블이 2배로 부풀고 VACUUM 필요 | undo 가 커지지만 테이블은 유지 |
| 대량 롤백 | 즉시 끝남 | 되돌리는 데 오래 걸림 |
| 오래 열린 트랜잭션 | VACUUM 이 막혀 팽창·랩어라운드 위험 | undo 히스토리 증가 · 스냅숏 오류 |
| 과거 시점 조회 | 불가 (확장 필요) | Oracle 은 Flashback 으로 가능 |
락 매니저와 2PL
DB 안의 교통정리 담당입니다. 무엇이 언제 잠기는지 알면 경합의 원인이 보입니다.
2단계 잠금 (Two-Phase Locking)
락의 종류와 계층
| 종류 | 설명 |
|---|---|
| 공유 락 (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로
옵티마이저 — 비용 모델 · 조인 순서
옵티마이저는 가능한 실행 경로 중 가장 싸 보이는 것을 고릅니다. 그 계산 방식을 알면 왜 이상한 계획을 골랐는지 역추적할 수 있습니다.
추정이 빗나가는 전형적 상황
- 컬럼 간 상관관계 — "도시=서울"과 "지역번호=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;
조인 알고리즘 3종
조인 방식은 세 가지뿐입니다. 상황마다 최적이 다르고, 잘못 고르면 수백 배 차이가 납니다.
| 방식 | 동작 | 유리한 상황 |
|---|---|---|
| Nested Loop 중첩 루프 |
바깥 테이블의 각 행마다 안쪽을 조회 | 바깥이 매우 작고 안쪽에 인덱스가 있을 때 |
| Hash Join 해시 조인 |
작은 쪽으로 해시 테이블을 만들고 큰 쪽을 훑으며 매칭 | 양쪽이 크고 등호 조인일 때. 대량 처리의 기본 |
| Merge Join 정렬 병합 |
양쪽을 정렬한 뒤 지퍼처럼 맞춰 나감 | 양쪽이 이미 정렬돼 있거나 범위 조인일 때 |
Nested Loop 바깥 1000행 × 안쪽 인덱스 조회 = 1000번 탐색
→ 바깥이 100만 행이면 100만 번 → 재앙
Hash Join 작은 테이블(10만행)로 해시 생성 → 메모리 사용
큰 테이블(1억행) 1회 스캔하며 매칭
→ 메모리가 부족하면 디스크로 넘침(spill) → 급격히 느려짐
Merge Join 양쪽 정렬 비용 + 1회 스캔
→ 인덱스로 이미 정렬돼 있으면 정렬 비용 0
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;
파티셔닝 · 샤딩 전략
파티셔닝은 한 서버 안에서 나누는 것, 샤딩은 여러 서버로 나누는 것입니다. 샤딩은 되돌리기가 매우 어려우므로 마지막 카드입니다.
| 구분 | 파티셔닝 | 샤딩 |
|---|---|---|
| 범위 | 한 DB 서버 안 | 여러 DB 서버 |
| 해결하는 것 | 큰 테이블의 조회·삭제 효율 | 쓰기 처리량·저장 용량 한계 |
| 애플리케이션 변경 | 거의 없음 | 라우팅 로직 필요 |
| 조인 | 그대로 가능 | 샤드를 넘는 조인이 어려움 |
| 트랜잭션 | 그대로 | 분산 트랜잭션 문제(z11) |
| 되돌리기 | 쉬움 | 사실상 재구축 |
샤드 키 선택 — 되돌릴 수 없는 결정
| 기준 | 장단점 |
|---|---|
| 해시(사용자 ID) | 고르게 분산됩니다. 단, 범위 조회가 전 샤드로 퍼집니다 |
| 범위(날짜) | 최근 데이터에 부하가 집중됩니다(핫 샤드). 아카이빙에는 유리 |
| 디렉터리(매핑 테이블) | 유연하지만 매핑 조회가 병목·단일 장애점이 됩니다 |
| 지역 | 법규(데이터 주권)·지연 시간에 유리. 지역 간 편차가 큽니다 |
① 인덱스·쿼리 튜닝 — 대부분 여기서 해결됩니다
② 읽기 복제본 — 조회 부하는 이것으로 나눕니다
③ 캐시 — Redis 로 반복 조회를 흡수
④ 수직 분할 — 도메인별로 DB를 나눕니다(주문 DB · 회원 DB)
⑤ 아카이빙 — 오래된 데이터를 분리
⑥ 하드웨어 증설 — 클라우드에서는 종종 가장 저렴한 해법입니다
이 여섯을 다 해 보고도 안 되면 그때 샤딩입니다.
샤딩 후에 생기는 문제들
- 전역 유일 ID — AUTO_INCREMENT 를 못 씁니다 → UUID v7·ULID·스노플레이크
- 샤드를 넘는 조인 — 애플리케이션에서 합치거나, 참조 데이터를 모든 샤드에 복제
- 전역 집계 — 각 샤드에서 부분 집계 후 합산 (또는 별도 분석 DB로 적재)
- 리밸런싱 — 샤드를 추가할 때 데이터 이동. 일관된 해싱이나 가상 샤드(논리 샤드를 물리 샤드에 매핑)로 완화
- 운영 복잡도 — 백업·모니터링·스키마 변경을 샤드 수만큼 해야 합니다
복제 · 합의 · HA 설계
서버가 죽어도 서비스가 살아 있게 하는 구성입니다. 자동화에는 함정이 많습니다.
| 복제 방식 | 커밋 시점 | 트레이드오프 |
|---|---|---|
| 비동기 | 주 서버만 기록하면 즉시 | 빠름 / 장애 시 최근 데이터 유실 가능 |
| 반동기 | 복제본이 받았음을 확인 후 | 절충안. MySQL 의 semi-sync |
| 동기 | 복제본이 기록까지 완료 후 | 유실 0 / 지연 증가, 복제본 장애 시 쓰기 중단 위험 |
| 합의 기반 | 과반수가 기록하면 | 유실 0 + 자동 페일오버 (Raft·Paxos) |
스플릿 브레인 — 자동 페일오버의 함정
2노드 구성에서 자동 페일오버를 켜지 마세요. 최소 3노드(과반수 판정 가능) 또는 외부 중재자(witness)가 필요합니다.
RPO 와 RTO — 먼저 정해야 하는 두 숫자
| 지표 | 뜻 | 영향 |
|---|---|---|
| RPO | 얼마만큼의 데이터 유실을 감수할 수 있나 | 0이면 동기 복제 필수 → 지연·비용 증가 |
| RTO | 얼마나 빨리 복구돼야 하나 | 분 단위면 자동 페일오버 필요 → 복잡도 증가 |
관리형 서비스를 쓸 때
AWS RDS Multi-AZ · Aurora · Cloud SQL 등은 페일오버를 서비스가 처리합니다. 다만 페일오버 시 수십 초의 연결 끊김이 발생하므로, 애플리케이션에 재연결·재시도 로직이 반드시 있어야 합니다(🔌 a11). "관리형이니 알아서 되겠지"라고 두면 페일오버 때마다 장애가 됩니다.
분산 트랜잭션 · 2PC · Saga
여러 DB·서비스에 걸친 작업을 한 덩어리처럼 만드는 방법입니다. 요즘은 2PC 보다 Saga + 아웃박스를 씁니다.
2PC (2단계 커밋)
① 코디네이터가 죽으면 참여자들이 락을 잡은 채 무한 대기(blocking)
② 준비~커밋 사이에 모든 참여자의 락이 유지되어 처리량이 급락
③ 마이크로서비스 환경에서 서비스 간 강한 결합을 만듭니다
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 는 레거시 연동 등 정말 불가피할 때만
CAP · 일관성 모델 · NewSQL
분산 시스템에서는 모든 것을 동시에 가질 수 없습니다. 무엇을 포기할지 고르는 것이 설계입니다.
| CAP | 뜻 |
|---|---|
| C 일관성 | 어느 노드에 물어도 같은 최신 값 |
| A 가용성 | 일부가 죽어도 요청에 응답 |
| P 분단 내성 | 네트워크가 갈라져도 동작 |
더 실용적인 틀은 PACELC 입니다 — 분단(P) 시에는 A/C 중, 정상(E) 시에는 지연(L)과 일관성(C) 중 무엇을 택할지.
일관성 모델
| 모델 | 보장 | 예 |
|---|---|---|
| 선형화 | 모든 연산이 단일 시간축 위에 있는 것처럼 | etcd · Spanner |
| 순차 일관성 | 모든 노드가 같은 순서로 보지만 실시간성은 보장 안 함 | — |
| 읽기-쓰기 일관성 | 내가 쓴 것은 내가 반드시 읽음 | 복제본 읽기에서 흔히 요구하는 수준 |
| 최종 일관성 | 언젠가는 같아짐 | DNS · Cassandra 기본 |
NewSQL — 분산인데 SQL·트랜잭션
| 제품 | 특징 |
|---|---|
| Google Spanner | 원자시계(TrueTime)로 전역 일관성. 클라우드 전용 |
| CockroachDB | PostgreSQL 호환 · Raft 기반 · 셀프호스팅 가능 |
| TiDB | MySQL 호환 · HTAP(트랜잭션+분석) |
| YugabyteDB | PostgreSQL 호환 · 다중 리전 |
| Vitess | MySQL 샤딩 미들웨어 (YouTube 에서 출발) |
아카이빙 · 보관 정책
데이터는 계속 쌓입니다. 언제 어디로 치울지를 처음부터 정해야 합니다.
| 단계 | 내용 |
|---|---|
| 핫 (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;
개인정보 보관과 파기
- 보관 기간을 컬럼으로 관리 —
retention_until을 두고 배치가 자동 처리 - 파기는 진짜 삭제여야 — 소프트 삭제(
deleted_at)는 파기가 아닙니다. 백업본까지 고려한 정책이 필요합니다 - 가명처리·비식별화 — 통계 목적이라면 식별 정보를 지우고 남기는 방법도 있습니다
- 삭제 요청 대응 — 사용자가 삭제를 요청했을 때 어느 테이블·백업·로그에 흩어져 있는지 미리 목록을 만들어 두세요
보안 — 권한 · 암호화 · 감사
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만 건을 읽음)
· 권한 변경·계정 생성
· 스키마 변경 (배포 시간 외)
· 실패한 로그인 반복
장애 대응 시나리오
실제로 일어나는 유형과 대응 순서입니다. 당황하지 않는 것이 절반입니다.
| 증상 | 확인 순서 → 조치 |
|---|---|
| 커넥션 고갈 "too many connections" |
① 현재 커넥션 목록에서 idle in transaction 확인 → ② 오래된 세션 종료 → ③ 원인 쿼리·누수 코드 수정 → ④ 풀 설정 재검토 |
| 디스크 가득 참 | ① 로그 파일부터 정리(WAL·binlog·에러 로그) → ② 임시 테이블스페이스 → ③ 오래된 파티션 DROP → ④ VACUUM FULL 은 추가 공간이 필요하니 주의 |
| CPU 100% | ① 현재 실행 중인 쿼리 확인 → ② 가장 무거운 쿼리 종료 → ③ 실행계획 확인(통계·인덱스) → ④ 필요하면 해당 기능 일시 차단 |
| 복제 지연 폭증 | ① 대량 DML 이 있었는지 확인 → ② 복제본 IO 상태 → ③ 읽기 트래픽을 주 서버로 임시 전환 → ④ 병렬 복제 설정 |
| 락으로 전면 정지 | ① 블로킹 체인의 맨 위 세션 찾기 → ② 그 세션 종료 → ③ 원인(긴 트랜잭션·DDL) 파악 → ④ 재발 방지 |
| 실수로 DELETE | ① 즉시 쓰기 중단(추가 변경이 복구를 어렵게 함) → ② Oracle 이면 Flashback → ③ 그 외는 백업 + 로그로 시점 복구 → ④ 복구 전 현재 상태를 먼저 백업 |
| DB 가 안 뜸 | ① 로그를 먼저 읽으세요 → ② 디스크·권한 확인 → ③ 복구 중이면 기다림(강제 종료 금지) → ④ 손상이면 백업에서 복원 |
· 여러 조치를 동시에 — 무엇이 효과가 있었는지 알 수 없게 됩니다
· 재시작부터 — 원인이 사라지고 다시 같은 장애가 납니다. 가능하면 상태를 먼저 수집하세요
· 백업 없이 복구 시도 — 현재 상태를 먼저 보존하세요
· 혼자 판단 — 되돌릴 수 없는 조치(파일 삭제·강제 복구)는 반드시 공유 후에
DBA 없이 살아남기
전담 DBA가 없는 팀이 최소한 이것만은 해야 하는 목록입니다.
매일
- 백업이 실제로 만들어졌는지 확인 (성공 알림이 아니라 파일 크기와 시각)
- 디스크 사용률·커넥션 수 대시보드 확인
- 에러 로그에 새로운 유형이 있는지
매주
- 슬로우 쿼리 상위 10개 확인 — 새로 등장한 것이 있는지
- 테이블·인덱스 크기 증가 추이
- 안 쓰이는 인덱스 확인
매월 · 분기
- 백업에서 실제로 복구해 보기 — 이것을 하지 않으면 백업은 없는 것과 같습니다
- 권한 검토 — 퇴사자·불필요한 권한 정리
- DB 마이너 버전 업데이트 (보안 패치)
- 용량 계획 — 현재 증가율로 언제 한계에 닿는지
한 번은 반드시 해 둘 것
| 항목 | 내용 |
|---|---|
| 복구 절차 문서화 | 새벽 3시에 당직자가 보고 따라할 수 있는 수준으로 |
| RPO · RTO 합의 | 데이터 종류별로 숫자를 정하고 경영진과 공유 |
| 슬로우 쿼리 로그 켜기 | 안 켜 두면 사후에 원인을 찾을 수 없습니다 |
| 모니터링·알람 | t15 의 지표를 대시보드로 |
| 스키마 마이그레이션 도구 | 손으로 ALTER 치는 순간 재현이 불가능해집니다(🔌 a09) |
| 계정·권한 표준 | 새 서비스가 생길 때 따라 할 템플릿 |
| 운영 접속 통제 | 운영 DB에 직접 접속할 수 있는 사람과 경로를 제한 |
앱 연동 —
코드에서
DB 다루기
SQL을 아는 것과 애플리케이션에서 안전하게 쓰는 것은 다른 문제입니다. 커넥션·트랜잭션 경계·인젝션 방어·N+1·마이그레이션 — 실무 코드에서 실제로 사고가 나는 지점만 모았습니다. Python · Java · Node.js 코드를 네 DB 기준으로 나란히 정리합니다.
내 조합 바로 찾기
쓰는 언어와 DB 를 고르면 그 조합의 설치 명령·연결 코드·주의점이 바로 나옵니다. 12가지 조합 전부 복사해서 그대로 쓸 수 있습니다.
드라이버와 연결 문자열
드라이버는 앱과 DB 사이의 번역기입니다. 연결 문자열 한 줄에 주소·계정·옵션이 전부 들어갑니다.
| DB | Python | Java (JDBC) | Node.js |
|---|---|---|---|
| Oracle | oracledb | ojdbc11 | oracledb |
| PostgreSQL | psycopg[binary] | postgresql | pg |
| MySQL | PyMySQL · mysqlclient | mysql-connector-j | mysql2 |
| SQLite | sqlite3 (내장) | sqlite-jdbc | better-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 세션 목록에서 어느 앱이 보낸 쿼리인지 보입니다 — 장애 대응에 큰 도움 |
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"})
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 로 묶입니다 (없으면 배치 효과가 거의 없습니다)
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(['김하늘', '박바다']);
①
await 누락 — 쿼리가 끝나기 전에 다음 코드가 실행되고,
에러가 unhandled rejection 으로 조용히 사라집니다
②
release() 누락 — 예외 경로에서 커넥션을 반납하지 않아
풀이 고갈됩니다. 반드시 finally 에 넣으세요
SQL 인젝션 — 바인딩
원인은 하나입니다 — 문자열을 이어 붙여 쿼리를 만드는 것. 파라미터 바인딩을 쓰면 원천 차단됩니다.
✗ 문자열 연결 — 즉시 뚫립니다
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 가 쿼리 구조와 값을 분리해 처리하므로
# 값 안에 무엇이 들어와도 '값'으로만 취급됩니다
' 나 ; 가
구문으로 해석될 여지 자체가 없습니다.
"특수문자를 이스케이프한다"는 접근은 빠뜨리는 경우가 반드시 생기므로 권장되지 않습니다.
바인딩할 수 없는 것 — 테이블·컬럼 이름
# ✗ 이건 바인딩이 안 됩니다 (구조는 파라미터가 될 수 없음)
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,))
·
raw() · text() · createQuery() 에 문자열을 조립해 넣는 경우
· 정렬·필터 컬럼명을 사용자 입력에서 그대로 받는 경우
·
LIKE 패턴을 조립하는 경우 (%·_ 이스케이프 필요)
"ORM 을 쓰니 안전하다"는 착각이 실제 사고로 이어집니다.
ORM을 쓸 것인가
"ORM 이냐 생쿼리냐"는 잘못된 질문입니다. 어디에 무엇을 쓸지가 옳은 질문입니다.
| 상황 | 권장 |
|---|---|
| 단순 CRUD | ORM — 반복 코드가 사라집니다 |
| 도메인 로직이 있는 저장 | ORM — 객체 상태 관리가 편합니다 |
| 복잡한 조회·통계 | 생쿼리 또는 쿼리 빌더 — ORM 으로 짜면 읽기도 튜닝하기도 어렵습니다 |
| 대량 배치 | 생쿼리 · 전용 대량 적재(a10) |
| 리포트·집계 | 생쿼리 — 윈도우 함수·CTE 를 그대로 씁니다 |
| 성능이 중요한 API | 측정 후 결정. 필요한 컬럼만 조회하는 프로젝션이 핵심 |
| 언어 | ORM | 쿼리 빌더 · 경량 |
|---|---|---|
| Python | SQLAlchemy ORM · Django ORM | SQLAlchemy Core |
| Java | JPA/Hibernate | QueryDSL · MyBatis · jOOQ |
| Node/TS | Prisma · TypeORM | Kysely · 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'}}}
코드에서 트랜잭션 다루기
경계를 어디서 열고 어디서 닫는가가 핵심입니다. 잘못 잡으면 롤백이 안 됩니다.
# 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) { ... }
}
@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]);
});
N+1 문제와 배치 조회
목록 100건을 불러오는데 쿼리가 101번 나갑니다. 실무 성능 문제 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 은 조인이 아니라
batch_fetch_size·selectinload 로 푸세요.
스키마 마이그레이션 도구
손으로 ALTER TABLE 을 치는 순간 재현이 불가능해집니다.
스키마도 코드처럼 버전 관리해야 합니다.
| 언어 | 도구 | 특징 |
|---|---|---|
| Python | Alembic | SQLAlchemy 기반. 모델 변경에서 마이그레이션 자동 생성 |
| Python | Django migrations | Django 내장 |
| Java | Flyway | SQL 파일 기반. 단순하고 예측 가능 — 실무 선호도가 높습니다 |
| Java | Liquibase | XML·YAML 로 DB 중립 기술. 롤백 지원이 강합니다 |
| Node | Prisma 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
무중단 배포를 위한 확장-축소 패턴
· 컬럼 타입 변경 — 대부분 테이블 재작성 (🐬 m13)
· NOT NULL 추가 — 기존 행 검증 필요. 기본값과 함께 하면 DB에 따라 즉시 완료
· 인덱스 생성 —
CONCURRENTLY(PG) · ALGORITHM=INPLACE, LOCK=NONE(MySQL)
· 외래키 추가 — 기존 데이터 전체 검증이 일어납니다
큰 테이블이면 반드시 스테이징에서 시간을 측정하고 진행하세요.
대량 적재 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 HEADERMySQL — 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 */ ... SELECTSQLite — 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로 선언된 제약에만 적용), MySQLSET FOREIGN_KEY_CHECKS=0(적재 후 반드시 되돌리고 정합성 검증) - 적재 후 통계 갱신 —
ANALYZE를 빠뜨리면 이후 쿼리 계획이 엉망이 됩니다 - 스테이징 테이블 활용 — 임시 테이블에 적재 후 검증하고 본 테이블로 옮깁니다
재시도 · 타임아웃 · 누수
네트워크는 반드시 끊깁니다. 페일오버도 일어납니다. 이를 가정하지 않은 코드는 장애 때 서비스 전체를 멈춥니다.
재시도해도 되는 오류와 안 되는 오류
| 판단 | 오류 |
|---|---|
| 재시도 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;
with · Java try-with-resources ·
Node finally { client.release() }.
HikariCP 의 leak-detection-threshold 를 켜 두면
설정 시간 이상 반납되지 않은 커넥션의 스택 트레이스를 로그로 남겨 줍니다.
테스트에서 DB 다루기
"테스트는 SQLite 로, 운영은 PostgreSQL 로"는 운영에서만 터지는 버그를 만듭니다.
· 타입·제약 동작이 다릅니다 (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 | 확실하지만 느립니다. 외래키 순서를 고려해야 합니다 |
| 테스트마다 새 스키마 | 완전 격리 · 병렬 실행 가능. 준비 비용이 있습니다 |
| 컨테이너 재기동 | 가장 느립니다. 스위트 단위로만 |
tmpfs 에 데이터 디렉터리를 두어 디스크 IO를 없애고
④ fsync=off 같은 테스트 전용 설정을 씁니다(운영에는 절대 금지).
빈 PC 한 대를
DB 가 도는 개발 환경으로
한 번에 만듭니다
DB 를 처음 시작하는 사람이 막히는 곳은 문법이 아니라 설치와 접속입니다. "무엇을 깔아야 하나", "받은 파일을 어디까지 눌러야 하나", "깔았는데 왜 접속이 안 되나", "내 파이썬 코드에서는 무슨 패키지를 설치하나" — 이 탭 하나에 그 전부를 모았습니다. DB 고르기 → 도커 설치 → DB 한 줄로 띄우기 → GUI 도구 연결 → 언어별 드라이버 → 환경 변수 → 문제 해결까지 순서대로 따라가면 됩니다. 각 DB 의 세세한 설치 옵션은 이미 전용 탭에 있으니 여기서는 전체 지도를 그리고 자세한 곳으로 링크를 겁니다. 이 탭을 마치면 새 노트북을 받아도 한 시간 안에 DB 붙는 프로젝트를 시작할 수 있습니다.
어떤 DB 를 깔 것인가 — 7개 제품 한 장 비교
설치 첫 단추는 "무엇을 깔까"입니다. 그런데 이건 취향 문제가 아니라 목적 문제입니다. 혼자 공부하는 사람과 사내 시스템을 만드는 사람의 정답이 다릅니다. 이 섹션에서 5분 안에 정하고, 나머지 열한 섹션은 정한 것을 깔고 붙이는 데 씁니다.
7개 제품 비교표 — 용도 · 라이선스 · 설치 난이도
| 제품 | 한 줄 정체 | 라이선스 · 비용 | 설치 난이도 | 기본 포트 | 2026 기준 버전 |
|---|---|---|---|---|---|
| 🐘 PostgreSQL | 표준을 가장 잘 지키는 만능 관계형 | 완전 무료 · PostgreSQL 라이선스(BSD 계열). 상업 이용·재배포 전부 자유 | 🟢 쉬움 | 5432 | 18 (2025-09) |
| 🐬 MySQL | 웹 서비스에서 가장 흔한 관계형 | Community 판 GPL 무료 · 상용 기능은 Enterprise 유료 | 🟢 쉬움 | 3306 | 8.4 LTS · 9.x |
| 🐬 MariaDB | MySQL 에서 갈라진 완전 오픈 판 | 전부 GPL 무료 · 유료판 구분 없음 | 🟢 쉬움 | 3306 | 11.8 LTS 이상 |
| ✒️ SQLite | 파일 하나가 곧 DB · 서버가 없음 | 퍼블릭 도메인 — 저작권 자체가 없어 어디에 넣어도 됩니다 | ⬜ 설치 없음 | 없음 | 3.5x |
| 🟦 SQL Server | 마이크로소프트 · .NET 생태계 기본 | Developer 판 개발용 무료 · Express 무료(10GB 제한) · 운영은 유료 | 🟡 보통 | 1433 | 2022 · 2025 |
| 🅾️ Oracle | 금융·통신 기간계의 절대 강자 | 유료 · Free(옛 XE)는 CPU 2개·데이터 12GB 제한으로 무료 | 🔴 어려움 | 1521 | 23ai Free |
| 🍃 MongoDB | JSON 문서를 그대로 넣는 문서형 | Community 무료(SSPL) · DB 자체를 서비스로 팔 때만 제약 | 🟢 쉬움 | 27017 | 8.x |
| ⚡ Redis | 메모리에 사는 초고속 키-값 저장소 | Redis 8 부터 AGPL/RSAL 이중 · 완전 오픈은 Valkey(리눅스 재단) | 🟢 아주 쉬움 | 6379 | 8.x · Valkey 9 |
"그래서 뭘 깔아요?" — 2026년의 솔직한 기본값
상황별 추천 — 이대로 따라 해도 됩니다
| 상황 | 깔 것 |
|---|---|
| SQL 문법을 오늘 당장 배우고 싶다 | SQLite — 설치 0분 (s02) |
| 포트폴리오 · 토이 프로젝트 | PostgreSQL 도커 (st03) |
| 회사에서 새 서비스를 시작 | PostgreSQL · 사내 표준이 있으면 그것 |
| 워드프레스 · 그누보드 · 커머스 솔루션 | MySQL / MariaDB (사실상 강제) |
| .NET · C# 위주 팀 | SQL Server Developer 판 |
| SI · 금융 취업 준비 | Oracle Free 도커 한 번은 (o02) |
| 로그인 세션 · 조회수 캐시가 필요 | 관계형 + Redis (r03) |
"제대로 골랐나" 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
docker rm -f 한 번이면 없던 일이 됩니다.
한 단계 더 — "관계형 하나 + 보조 하나"가 표준 구성 🟡 중급
고르는 게 아니라 겹쳐 쓰는 것들
초보자가 자주 하는 질문이 "PostgreSQL 이 좋아요, MongoDB 가 좋아요?"인데 둘은 경쟁 관계가 아닙니다. 실무의 표준 구성은 이렇습니다.
| 자리 | 무엇을 놓나 |
|---|---|
| 본체 | 관계형 하나 — 회원·주문·결제처럼 틀리면 안 되는 데이터 |
| 캐시 | Redis — 자주 읽지만 조금 늦어도 되는 값, 로그인 세션 |
| 로그·문서 | MongoDB 또는 관계형의 JSON 타입 — 모양이 자주 바뀌는 것 |
| 파일 | DB 가 아니라 스토리지(S3 등) — 이미지를 DB 에 넣지 않습니다 |
설치 난이도를 결정하는 진짜 변수
위 표의 "난이도"는 프로그램 자체의 복잡도가 아니라 설치 후에 해야 할 일의 개수입니다.
| DB | 설치 후에 더 해야 하는 일 |
|---|---|
| SQLite | 없음. 파일 경로만 정하면 끝 |
| PostgreSQL | 비밀번호 · pg_hba.conf 한 줄 정도 |
| MySQL | root 비번 · utf8mb4 문자셋(m04) · 타임존 |
| SQL Server | 인증 모드(혼합) · TCP/IP 프로토콜 수동 활성화 |
| Oracle | 리스너 · 서비스명 · CDB/PDB 개념(o04) · 용량 10GB+ |
docker run 한 줄이면 뜹니다(st03).
Docker Desktop 설치 — DB 설치의 난이도를 평평하게
DB 설치가 어려운 이유는 제품마다 마법사가 다르고, 깔고 나면 지우기도 어렵기 때문입니다. 도커는 그 전부를 명령 한 줄로 바꿉니다. DB 하나를 배우려고 도커를 배우는 게 손해 같지만, 도커를 30분 배워 두면 이 가이드의 모든 DB 를 3분 만에 띄울 수 있습니다. 정말 남는 장사입니다.
Windows — WSL2 부터 켜야 합니다
① 준비 — WSL2 켜기 (관리자 PowerShell)
# 관리자 권한 PowerShell 을 열고 (시작 → powershell → 오른쪽 클릭 → 관리자로 실행)
wsl --install
# → "가상 머신 플랫폼", "Linux용 Windows 하위 시스템" 기능을 켜고
# Ubuntu 를 함께 설치합니다. 끝나면 재부팅.
# 이미 WSL 이 있다면 버전만 확인
wsl --status
wsl --set-default-version 2
wsl -l -v # VERSION 열이 2 여야 합니다
# 커널이 오래됐다는 경고가 나오면
wsl --update
② 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 가능 |
| 확인 | 작업 표시줄 고래 아이콘이 흔들리지 않고 멈추면 준비 완료 |
③ 설치 확인 — 이 두 줄이면 끝
모든 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 | 개인 · 교육 · 오픈소스 · 소규모 기업 무료. 직원 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 |
맥에서 가볍게 |
alias docker=podman 만으로 대부분 동작하고,
Rancher Desktop 은 docker 명령을 그대로 제공합니다.
이 탭의 모든 docker run·compose.yml 은 세 도구에서 그대로 돕니다.
도커로 DB 띄우기 — 한 줄 명령과 compose 완전판
여기가 이 탭의 핵심입니다. 아래 명령을 그대로 복사해 붙이면 DB 가 뜹니다.
다만 한 줄 명령은 컨테이너를 지우면 데이터도 사라진다는 함정이 있어서,
실제로는 볼륨을 붙이고 compose.yml 파일로 관리합니다. 둘 다 보여 드립니다.
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
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 # 컨테이너 안으로 들어가기
실무형 — 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', '이지은');
알아 두면 덜 헤매는 것 — 컨테이너끼리는 서비스 이름으로 부릅니다 🟡 중급
localhost 가 두 가지 뜻입니다
| 접속하는 쪽 | 써야 할 호스트 |
|---|---|
| 내 PC 의 DBeaver · psql | localhost 또는 127.0.0.1 |
| 내 PC 에서 도는 파이썬·자바 앱 | localhost |
| 다른 컨테이너(같은 compose) | db — 서비스 이름 그대로 |
| 컨테이너 안에서 내 PC | host.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
네이티브 설치 빠른표 — Windows · macOS · Linux 한 장
도커를 못 쓰거나 쓰기 싫은 상황도 많습니다. 회사 PC 정책, 구형 CPU, "그냥 깔고 싶다". 이 섹션은 어디서 받아 무엇을 체크하는가만 압축한 표입니다. 마법사를 한 화면씩 짚는 설명은 각 DB 탭에 이미 있으니 링크로 넘깁니다.
Windows — 어디서 받아 무엇을 체크하나
| DB | 받는 곳 | 마법사에서 반드시 볼 것 | 자세히 |
|---|---|---|---|
| 🐘 PostgreSQL | postgresql.org → Download → WindowsEDB 설치 관리자 |
· superuser(postgres) 비밀번호 — 잊으면 재설치입니다 · 포트 5432 기본 유지 · Locale 은 C 또는 Korean, Korea · Stack Builder 는 체크 해제해도 됩니다 |
c03 · p02 |
| 🐬 MySQL | dev.mysql.com/downloads/installerMySQL 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.htmlsqlite-tools-win-x64-*.zip |
· 설치 프로그램이 없습니다 — 압축을 풀면 sqlite3.exe· 폴더를 PATH 에 추가하면 어디서나 실행 · 파이썬·Node 에는 이미 내장되어 있습니다 |
s02 |
| 🟦 SQL Server | microsoft.com/sql-server/downloadsDeveloper 판(무료) |
· 인증 모드는 혼합 모드(SQL 인증 + Windows 인증) · sa 비밀번호 설정 · 설치 후 SQL Server 구성 관리자 → TCP/IP 사용 ★ · 관리 도구 SSMS 는 별도 설치 |
st05 |
| 🅾️ Oracle | oracle.com → Database 23ai FreeWindows x64 ZIP |
· 계정 로그인 필요 · 압축만 10GB 대 · SYS/SYSTEM 비밀번호 · 서비스명 FREEPDB1, 리스너 1521 · 도커 쪽을 훨씬 권합니다 |
o02 · o03 |
| 🍃 MongoDB | mongodb.com/try/download/communityMSI |
· Install MongoDB as a Service 체크 · MongoDB Compass 같이 설치 체크(권장) |
n02 |
| ⚡ Redis | 공식 윈도우 판이 없습니다 | · 도커 또는 WSL 안에 설치가 정석 · 굳이 네이티브면 Memurai(호환 제품) |
r03 |
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
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
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();"
서비스 켜고 끄기 · 자동 실행 해제
| OS | 명령 / 화면 |
|---|---|
| Windows GUI | services.msc → 해당 서비스 → 속성 → 시작 유형 수동 |
| Windows CLI | net stop MySQL84 · net start MySQL84sc config MySQL84 start= demand |
| macOS | brew services stop postgresql@18 |
| Linux | sudo systemctl stop postgresqlsudo systemctl disable postgresql |
docker compose up -d 로 자연히 몸에 붙습니다.
-p 5433:5432 처럼 옮겨 주면 평화롭게 공존합니다.
클라이언트 도구 — DBeaver 하나로 다 붙습니다
DB 를 띄웠으면 이제 들여다볼 창이 필요합니다. 제품마다 전용 도구가 있지만, 여러 DB 를 오가는 사람에게는 DBeaver 하나가 정답에 가깝습니다. 무료이고, 8개 DB 를 전부 지원하고, 드라이버를 알아서 내려받아 줍니다.
도구 고르기 — 무엇을 언제
| 도구 | 비용 | 지원 DB · 성격 | 설치 |
|---|---|---|---|
| DBeaver CE ★ | 무료 (Apache 2.0) |
거의 모든 DB. 표 편집 · ER 다이어그램 · CSV 임포트 · 드라이버 자동 다운로드. 자바 기반이라 조금 무겁습니다 | dbeaver.ioWin: 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 선택 → 다음 |
| 3 | Host localhost · Port 5432 |
| 4 | Database appdb · Username app · Password devpass |
| 5 | 맨 아래 Test Connection → 드라이버 없으면 Download 버튼이 뜹니다 → 누르면 자동 설치 |
| 6 | "Connected" 가 뜨면 완료 |
| 7 | 왼쪽 트리에서 appdb → Schemas → public → Tables 펼치기 |
.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 마법사로 눌러 처리 — 팀에 공유되지 않습니다
포트 · 방화벽 · 원격 접속 — 왜 내 PC 에서만 붙나
"내 PC 에서는 잘 되는데 옆자리 동료는 접속이 안 됩니다." 설치 다음으로 흔한 벽입니다. 원인은 대부분 세 겹의 문 중 하나가 닫혀 있어서입니다 — DB 가 어느 주소로 듣는가, OS 방화벽이 통과시키는가, DB 계정이 그 IP 를 허용하는가. 순서대로 열면 됩니다.
기본 포트 — 외우면 평생 씁니다
| DB | 기본 포트 | 메모 | 바꾸는 곳 |
|---|---|---|---|
| 🐘 PostgreSQL | 5432 | 여러 버전을 깔면 5433, 5434 로 자동 증가 | postgresql.conf 의 port |
| 🐬 MySQL · MariaDB | 3306 | MySQL X 프로토콜은 33060 을 따로 씁니다 | my.cnf / my.ini 의 port |
| 🟦 SQL Server | 1433 | 명명된 인스턴스는 동적 포트 — UDP 1434 로 찾습니다 | 구성 관리자 → TCP/IP → IPAll |
| 🅾️ Oracle | 1521 | 리스너 포트. EM Express 는 5500 | listener.ora |
| 🍃 MongoDB | 27017 | 복제 셋 멤버는 27018, 27019 관례 | mongod.conf 의 net.port |
| ⚡ Redis | 6379 | Sentinel 은 26379 | redis.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; -- 지금 허용된 조합 확인
'app'@'localhost' 와
'app'@'%' 는 서로 다른 계정입니다.
비밀번호가 맞는데도 Access denied for user 'app'@'192.168.0.55' 가 뜨면
비번이 아니라 host 가 안 맞는 것입니다(st12).
문 ① — Windows 방화벽 인바운드 규칙 만들기
GUI 로 — 클릭 순서 그대로
| 단계 | 화면 |
|---|---|
| 1 | 시작 → wf.msc 입력 → 고급 보안이 포함된 Windows Defender 방화벽 |
| 2 | 왼쪽 인바운드 규칙 클릭 |
| 3 | 오른쪽 새 규칙… |
| 4 | 규칙 종류: 포트 → 다음 |
| 5 | TCP · 특정 로컬 포트 5432 → 다음 |
| 6 | 작업: 연결 허용 → 다음 |
| 7 | 프로필: 개인만 체크 ★ (공용은 해제) |
| 8 | 이름: PostgreSQL 5432 (사내) → 마침 |
| 9 | 만든 규칙 더블클릭 → 범위 탭 → 원격 IP 주소를 192.168.0.0/24 로 제한 ★ |
명령으로 — 한 줄이면 끝
# --- 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
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·admin | 16자 이상 무작위 |
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
-p 0.0.0.0:5432:5432 로 띄우고 OS 방화벽만 열면 끝입니다.
컨테이너 안 설정은 손댈 필요가 없어요. 다만 노트북을 들고 나가면
그 규칙이 카페 와이파이에서도 살아 있으니 프로필을 "개인"으로 두는 것이 중요합니다.
언어별 드라이버 · 연결 문자열 — 7개 언어 설치 명령
DB 가 떴고 GUI 로도 붙었다면, 마지막은 내 코드에서 붙이기입니다. 언어마다 "무엇을 설치하고, 문자열을 어떻게 쓰고, 세 줄로 어떻게 조회하는가"를 한자리에 모았습니다. 문법 깊은 설명은 a01~a04 에 있고 여기서는 설치와 첫 연결만 다룹니다.
연결 문자열 — DB 별 한 줄 규격
| DB | URL 형태 (파이썬 · Node · Rust 계열) | JDBC 형태 (자바 · 코틀린) |
|---|---|---|
| 🐘 PostgreSQL | postgresql://app:pw@localhost:5432/appdb | jdbc:postgresql://localhost:5432/appdb |
| 🐬 MySQL | mysql://app:pw@localhost:3306/appdb | jdbc:mysql://localhost:3306/appdb |
| 🐬 MariaDB | mysql://app:pw@localhost:3307/appdb | jdbc:mariadb://localhost:3307/appdb |
| ✒️ SQLite | sqlite:///./app.db (파일 경로) | jdbc:sqlite:app.db |
| 🟦 SQL Server | Server=localhost,1433;Database=appdb;User Id=sa;… | jdbc:sqlserver://localhost:1433;databaseName=appdb |
| 🅾️ Oracle | oracle+oracledb://app:pw@localhost:1521/?service_name=FREEPDB1 | jdbc:oracle:thin:@//localhost:1521/FREEPDB1 |
| 🍃 MongoDB | mongodb://root:pw@localhost:27017/appdb?authSource=admin | 같음 (드라이버가 URI 를 씁니다) |
| ⚡ Redis | redis://:pw@localhost:6379/0 | 같음 |
@ 는 %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())
%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"));
}
}
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]);
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);
sqlite3.c 한 파일로 넣는 쪽이 훨씬 편합니다.
설치가 끝났는지 확인하는 3줄 — 언어별 🟡 중급
| 언어 | 버전·연결 확인 한 줄 | 실패할 때 흔한 원인 |
|---|---|---|
| 🐍 Python | python -c "import psycopg; print(psycopg.__version__)" | 가상환경을 안 켠 채 설치 |
| ☕ Java | ./gradlew dependencies --configuration runtimeClasspath | grep postgres | 드라이버를 runtimeOnly 로 안 넣음 |
| 🟠 Kotlin | 위와 같음 (Gradle 공용) | Exposed 와 드라이버 버전 불일치 |
| 🟨 Node | node -e "console.log(require('pg/package.json').version)" | node_modules 미설치 · ESM/CJS 혼용 |
| 🟣 C# | dotnet list package | 프로젝트 폴더가 아닌 곳에서 add |
| 🔵 C++ | vcpkg list | findstr libpqxx | CMake 툴체인 파일 미지정 |
| 🦀 Rust | cargo tree | grep sqlx | feature 플래그 누락(postgres 등) |
devpass 를 환경 변수로 빼내는 방법을 다룹니다.
실제 프로젝트에서 비밀번호가 코드에 남아 있으면 그 자체가 사고입니다.
환경 변수 · 시크릿 — 비밀번호를 코드에서 빼냅니다
지금까지 예제에는 devpass 가 그대로 적혀 있었습니다. 배우는 동안은 괜찮지만,
그 코드를 깃허브에 올리는 순간 이야기가 달라집니다.
해결책은 간단합니다 — 비밀번호를 코드 바깥에 두고 실행할 때 주입하면 됩니다.
이 방식은 개발·운영 환경을 나누는 데도 그대로 쓰입니다.
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) |
명령으로 — 임시 · 영구
# --- 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 '새-강한-비밀번호'; -- 🐬
초기 세팅 체크리스트 — 깔고 나서 반드시 하는 여섯 가지
설치가 끝난 DB 는 아직 "쓸 수 있는" 상태가 아닙니다. 계정을 나누고, 문자셋과 타임존을 굳히고, 데이터를 넣어 보고, 백업을 한 번 돌려 봐야 비로소 안심하고 쓸 수 있습니다. 10분이면 되는데 안 하면 나중에 며칠을 잡아먹는 항목들입니다.
① 계정 · 권한 — 최소 권한으로
🐘 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;
🐬 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;
'app'@'%'(어디서나) 또는 'app'@'172.%'(도커 대역)로 둡니다.
범위를 좁힐수록 안전하지만 접속이 안 되는 원인 1위이기도 합니다(st12).
② 문자셋 · ③ 타임존 — 지금 굳혀 두세요
| DB | 확인 | 맞춰 둘 값 | 안 하면 |
|---|---|---|---|
| 🐘 PostgreSQL | SHOW server_encoding;SHOW timezone; |
UTF8 · UTCDB 생성 후엔 인코딩 변경 불가 — 만들 때 정합니다 |
한글 정렬·저장 이상 |
| 🐬 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;
④ 샘플 데이터 넣어 보기
| 샘플 DB | 대상 · 받는 곳 |
|---|---|
| Pagila | PostgreSQL · DVD 대여점. GitHub devrimgunduz/pagila |
| Sakila | MySQL 공식 샘플. dev.mysql.com/doc/index-other.html |
| Chinook | 여러 DB 공용 · 음원 판매. 가볍고 JOIN 연습에 좋습니다 |
| Northwind | SQL 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
⑤ 백업을 한 번 돌려 보기 — 복원까지 해야 백업입니다 🟡 중급
덤프 뜨고 되살리기
# 🐘 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/
⑥ 최종 체크리스트 — 여섯 줄
| 항목 | 확인 방법 | |
|---|---|---|
| ☐ | 관리자 비밀번호를 안전한 곳에 기록 | 비밀번호 관리자 |
| ☐ | 앱 전용 계정으로 접속되는지 | 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; -- 그대로 나오면 문자셋 정상
GUI 없이 터미널로 — CLI 치트시트 5종
서버에 SSH 로 들어갔을 때, 도커 컨테이너 안에서 확인할 때, 스크립트로 자동화할 때는 GUI 를 쓸 수 없습니다. 다행히 각 DB 의 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 목록 | \l | SHOW DATABASES; |
—(파일 하나) | INFO keyspace | show dbs |
| DB 바꾸기 | \c appdb | USE appdb; |
.open other.db | SELECT 1 | use appdb |
| 표 목록 | \dt | SHOW TABLES; |
.tables | SCAN 0 COUNT 100 | show collections |
| 표 구조 | \d member | DESC member; |
.schema member | TYPE key | db.member.findOne() |
| 계정 목록 | \du | SELECT user,host FROM mysql.user; |
— | ACL LIST | db.getUsers() |
| 파일 실행 | \i a.sql | SOURCE a.sql; |
.read a.sql | redis-cli < a.txt | mongosh f.js |
| 세로 출력 | \x | 끝에 \G |
.mode line | 기본 | 기본 |
| 시간 재기 | \timing | 자동 표시 | .timer on | --latency | 자동 표시 |
| 도움말 | \? · \h SELECT | help; |
.help | HELP | help |
| 나가기 | \q | exit; · \q |
.quit | exit | exit |
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
~/.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
psql 14 로
서버의 PostgreSQL 18 에 붙으면 일부 메타 명령이 경고를 냅니다.
같은 버전 이미지를 일회용으로 띄우면 그런 문제가 없습니다.
터미널에서 살아남는 다섯 가지 습관
| 상황 | 대처 |
|---|---|
프롬프트가 -> 로 바뀌고 멈춤 | 세미콜론 미입력 — ; 치거나 Ctrl+C |
| 결과가 끝없이 흘러감 | q (pager 종료) · 애초에 LIMIT 20 |
| 따옴표를 잘못 열어 못 빠져나옴 | Ctrl+C → 다시 입력 |
| 긴 쿼리를 다시 치기 싫음 | ↑ 방향키 · psql 은 \e 로 편집기 열기 |
| 실수로 UPDATE 를 WHERE 없이 | 먼저 BEGIN; 을 치는 습관 → ROLLBACK; |
BEGIN; 부터 칩니다. 결과 행 수를 보고
예상과 같으면 COMMIT;, 아니면 ROLLBACK;.
이 습관 하나가 사고를 대부분 막습니다(q17).
로컬 개발 흐름 — 프로젝트마다 DB 를 띄우고 관리합니다
DB 를 한 번 깔아 놓고 모든 프로젝트가 같이 쓰면 처음엔 편하지만, 곧 스키마가 섞이고 버전이 안 맞고 지울 수 없는 상태가 됩니다. 요즘 방식은 프로젝트 폴더마다 compose 파일 하나를 두고, 스키마 변경은 마이그레이션 파일로 남기는 것입니다.
프로젝트 폴더 구조 — 이 모양이면 충분합니다
폴더와 파일
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 # 스키마가 만들어집니다
.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
up -d, 끝나면 stop.
볼륨은 남아 있으니 데이터는 그대로입니다.
마이그레이션 도구 — 설치와 첫 명령
| 도구 | 주 사용처 | 설치 | 첫 두 명령 |
|---|---|---|---|
| Flyway | ☕ Java · 언어 무관 | Gradle 플러그인 org.flywaydb.flyway · CLI 는 brew install flyway |
flyway infoflyway migrate |
| Liquibase | ☕ Java · 엔터프라이즈 | Gradle/Maven 플러그인 · brew install liquibase |
liquibase statusliquibase 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 initnpx prisma migrate deploy |
| Drizzle Kit | 🟨 Node · TS (가벼움) | npm i -D drizzle-kit |
npx drizzle-kit generatenpx drizzle-kit migrate |
| EF Core | 🟣 C# · .NET | dotnet tool install -g dotnet-ef |
dotnet ef migrations add Initdotnet ef database update |
| golang-migrate | Go · 언어 무관 CLI | brew install golang-migrate |
migrate create -ext sql -dir migrations initmigrate -path migrations -database "$DATABASE_URL" up |
| sqlx-cli | 🦀 Rust | cargo install sqlx-cli |
sqlx migrate add initsqlx migrate run |
테스트용 DB — Testcontainers 로 진짜 DB 를 씁니다
가짜 DB 를 쓰지 않는 이유
예전에는 테스트에 H2 같은 인메모리 DB 를 썼습니다. 빠르지만 문법과 동작이 실제 DB 와 미묘하게 달라서 테스트는 통과하는데 운영에서 터지는 일이 생겼습니다. Testcontainers 는 테스트가 시작될 때 진짜 DB 컨테이너를 띄우고 끝나면 지웁니다.
| 언어 | 설치 |
|---|---|
| ☕ Java · 🟠 Kotlin | testImplementation("org.testcontainers:postgresql") |
| 🐍 Python | pip install testcontainers[postgres] |
| 🟨 Node | npm i -D testcontainers |
| 🟣 .NET | dotnet add package Testcontainers.PostgreSql |
| 🦀 Rust | cargo add --dev testcontainers |
파이썬 예 — 테스트 한 개가 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
# 테스트가 끝나면 컨테이너째 사라집니다 — 뒷정리 코드가 필요 없습니다
scope="session" 으로 전체 테스트에 하나만 띄우고,
테스트마다 트랜잭션을 열었다 롤백하는 방식을 씁니다(a12).
compose up 으로 DB 가 뜨고,
migrate 로 스키마가 맞춰지고, 테스트는 자기 컨테이너를 띄워 돕니다.
사람마다 다른 환경 때문에 생기는 문제가 거의 사라집니다.
설치 문제 사전 — 증상 → 원인 → 해결 한 장
설치에서 만나는 오류는 종류가 생각보다 적습니다. 스무 가지 안쪽이고, 메시지만 제대로 읽으면 원인이 거의 특정됩니다. 이 섹션은 증상을 그대로 검색해 찾는 사전입니다 — 위에서부터 읽지 말고 필요한 줄만 보세요.
증상 사전 ① — 설치 · 기동
| 증상 · 메시지 | 원인 | 해결 |
|---|---|---|
| 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· 이벤트 뷰어
OLTP 말고
그 옆 —
분석과 벡터 검색
지금까지의 탭은 전부 OLTP였습니다 — 한 건을 정확히 넣고 한 건을 빨리 찾는 세계. 그런데 2026년 실무에서 우리를 부르는 일의 절반은 그 옆에 있습니다. "작년 매출을 지역·상품별로 쪼개 줘"(분석)와 "이 질문과 뜻이 비슷한 문서를 찾아 줘"(벡터 검색)입니다. 둘 다 B-Tree 인덱스와 행 저장으로는 잘 안 되는 일이고, 그래서 컬럼 지향 DB와 벡터 인덱스라는 다른 도구가 생겼습니다. 이 탭은 그 둘을 원리부터 실제로 도는 코드까지 훑습니다. 끝나면 DuckDB 로 로컬 분석을 하고, ClickHouse 로 로그를 집계하고, pgvector 로 RAG 검색을 만들 수 있습니다.
OLTP vs OLAP — 행 저장과 컬럼 저장
같은 SQL, 같은 데이터인데 한쪽은 40초, 한쪽은 0.3초입니다. 마법이 아니라 디스크에서 읽는 바이트 수가 100배 다르기 때문입니다.
두 세계는 요구사항 자체가 다릅니다
| 항목 | 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 · Oracle | ClickHouse · 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 초
📊 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.) ← 읽은 양
ts·country·amount 세 열뿐이고, 그마저 압축된 상태입니다.SELECT * FROM events WHERE id = 91238 을 하면
PostgreSQL 은 0.2 ms, ClickHouse 는 수십~수백 ms 가 걸립니다.
컬럼 DB 는 한 행을 다시 조립하려면 모든 열 파일에서 같은 위치를 찾아 모아야 하기 때문입니다.
게다가 UPDATE·DELETE 가 사실상 없고(ClickHouse 의 ALTER … UPDATE 는 비동기 재작성 작업입니다),
외래키·트랜잭션·유니크 제약이 없습니다. 주문·결제를 여기에 넣으면 안 됩니다.
왜 이렇게까지 차이가 나나 — 세 가지 이유 🟡 중급
columnar 계열 확장(Hydra·Citus 계열)과 pg_duckdb 처럼
PostgreSQL 안에서 DuckDB 엔진을 돌리는 확장이 나와 있습니다.
다만 성숙도·운영 편의를 감안하면 2026년 현재 실무의 기본 선택은
"OLTP 는 PostgreSQL, 분석은 별도 엔진" 입니다.
언제 분석 DB 를 따로 두나 🟡 중급
① 운영 DB 에서 바로 집계 — 무거운 GROUP BY 가 버퍼 풀을 다 밀어내(🧬 z02) 정작 주문 조회가 느려집니다. 읽기 복제본으로 옮기는 것만으로도 대부분 해결됩니다(🐘 p15).
② 데이터 100만 행에 ClickHouse 도입 — 그 규모는 PostgreSQL 이 인덱스 하나로 이깁니다. 운영해야 할 시스템만 하나 늘어납니다.
DuckDB — 분석용 SQLite
서버가 없습니다. pip install duckdb 한 줄이면 끝이고, 내 프로그램 안에서 돕니다.
SQLite 가 OLTP 를 그렇게 만들었다면, DuckDB 는 OLAP 을 그렇게 만들었습니다.
설치 — 정말 한 줄입니다
🐍 파이썬 · 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 분석이 됩니다
.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'") # 넘치면 디스크로 흘림
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() 튜플 리스트
분석에 쓰기 좋은 문법들 🟡 중급
-- 열 이름을 패턴으로 고르기
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 파일을 붙잡는 구조는 만들지 마세요.
읽기 전용이면 여러 프로세스가 동시에 열 수 있습니다
(duckdb.connect(path, read_only=True)).
COPY … TO 's3://…/dump.parquet' 로 하루치를 떨구고
② 노트북에서 DuckDB 로 마음껏 파 보고
③ 결론이 나면 그때 정식 파이프라인(v12)을 만듭니다.
운영 DB 를 한 번도 건드리지 않고 분석이 끝납니다.
Parquet · 컬럼 포맷의 속
Parquet 는 "압축된 CSV" 가 아닙니다. 파일 안에 통계와 색인이 들어 있는 작은 데이터베이스에 가깝습니다.
구조를 알면 왜 WHERE 하나로 파일의 90%를 건너뛰는지 이해됩니다.
구조를 용어로 정리하면
| 단위 | 설명 | 실무 감각 |
|---|---|---|
| 파일 | 하나의 .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)가 전부 같은 아이디어입니다.
COPY (SELECT * FROM t ORDER BY ts) TO 'out.parquet' —
쓰기 전에 필터로 자주 쓰는 열로 정렬하는 것, 이것 하나가 조회 속도를 몇 배 바꿉니다.
압축 코덱 — 무엇을 고르나
| 코덱 | 압축률 | 속도 | 언제 |
|---|---|---|---|
| SNAPPY | 보통 | 아주 빠름 | 많은 도구의 기본값. 자주 읽는 핫 데이터 |
| ZSTD | 좋음 (Snappy 보다 20~40% 더) | 빠름 | 2026년의 기본 추천. 레벨 3 이면 속도 손해가 거의 없습니다 |
| GZIP | 좋음 | 느림 | 호환성이 필요한 아카이브 |
| LZ4 | 낮음 | 최고 속도 | CPU 가 병목인 스트리밍 적재 |
| 없음 | — | — | 이미 압축된 값(이미지 바이너리)만 |
파티셔닝된 폴더 — 파일 이름이 곧 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;
ROW_GROUP_SIZE 1000000 정도로 올려 쓰세요.
Arrow 와 Parquet 는 무슨 관계인가 🟡 중급
Parquet — 디스크 포맷
목적 : 저장 · 전송
최적화 : 크기 (압축·인코딩)
읽으려면 → 압축을 풀고 디코딩해야 함
수명 : 몇 년 (파일로 보관)
Arrow — 메모리 포맷
목적 : 처리 · 프로세스 간 공유
최적화 : 접근 속도 (압축 안 함, 고정 폭)
그대로 → CPU 가 바로 SIMD 로 계산
수명 : 밀리초 (메모리에만)
duckdb.sql("SELECT * FROM pldf") 가 공짜였던 이유가 이것입니다.
"Parquet = 디스크의 Arrow, Arrow = 메모리의 Parquet" 정도로 외워 두면 실무에서는 충분합니다.
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 행
ORDER BY 입니다.ORDER BY 를 정하는 법 — 가장 중요한 결정
| 규칙 | 이유 |
|---|---|
| ① 항상 걸리는 필터를 맨 앞에 | 모든 쿼리에 tenant_id 가 들어간다면 ORDER BY (tenant_id, …). 앞쪽 열만 건너뛰기가 잘 듣습니다 |
| ② 카디널리티 낮은 → 높은 순 | country(200) → date → user_id(1억) 순. 반대로 두면 정렬 키가 사실상 무작위가 되어 압축률도 같이 떨어집니다 |
| ③ 3~4개를 넘기지 말 것 | 뒤쪽 열은 어차피 건너뛰기에 거의 기여하지 못하고, 삽입 시 정렬 비용만 늘어납니다 |
| ④ 기본키는 정렬 키의 접두사 | PRIMARY KEY 를 따로 쓰면 정렬 키보다 짧게만 가능합니다. 유일성 보장이 아닙니다 |
| ⑤ 나중에 못 바꿉니다 | 정렬 키 변경 = 테이블 재작성. 설계 때 쿼리 목록을 먼저 적으세요 |
· 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
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 을 알고 있다면 — 다른 점 🟡 중급
| PostgreSQL | ClickHouse |
|---|---|
count(*) | count() — 괄호가 빕니다 (count(*) 도 됩니다) |
VARCHAR(n) · TEXT | String 하나. 길이 제한 없음. 값 종류가 적으면 LowCardinality(String) |
NULL 이 기본 | 기본이 NOT NULL. NULL 을 쓰려면 Nullable(UInt32) — 느려지므로 피하는 것이 관행 |
NOW() · date_trunc | now() · toStartOfHour() · toYYYYMM() — 날짜 함수가 수백 개 |
| UPDATE · DELETE | ALTER TABLE … UPDATE/DELETE WHERE — 비동기 재작성. 일상적으로 쓰면 안 됩니다 |
| 외래키 · UNIQUE 제약 | 없습니다. 중복 방지는 ReplacingMergeTree(v05)로 흉내 냅니다 |
| 트랜잭션 · 격리 수준 | 사실상 없습니다. INSERT 는 파트 단위로 원자적일 뿐 |
ARRAY | Array(T) + arrayJoin·arrayMap·groupArray — 훨씬 강력합니다 |
| 스키마 변경이 무거움 | ALTER TABLE ADD COLUMN 이 즉시 끝납니다 (메타데이터만 변경) |
ClickHouse 실전 — 사전 집계 · TTL · 조인의 한계
원본 1억 행을 매번 훑는 대신 들어올 때 미리 접어 두면 대시보드가 10ms 에 뜹니다. 머티리얼라이즈드 뷰가 ClickHouse 에서는 트리거에 가깝다는 점만 이해하면 됩니다.
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 | 집계 상태를 병합합니다 (위 예제) | 유니크·백분위가 필요한 사전 집계 |
| CollapsingMergeTree | sign 열 +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일 지나면 시간 단위 평균만 남김
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 일반 |
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;
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
가 장애 때 가장 먼저 치는 쿼리입니다.
시계열 DB — 시간이 축인 데이터
메트릭·센서·주가는 "시간 + 값" 이 전부입니다. 이 모양에만 특화하면 PostgreSQL 을 그대로 쓰면서도 10배 빠르고 20배 작게 만들 수 있습니다 — TimescaleDB 가 그 길입니다.
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 을 고름
벡터 검색의 원리 — 뜻으로 찾기
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)
| 측정 | 수식(감각) | 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 이 압도적으로 이득입니다.
HNSW — 계층 그래프를 타고 내려갑니다 🟡 중급
IVFFlat — 먼저 나누고 몇 칸만 뒤집니다 🟡 중급
| 비교 | HNSW | IVFFlat |
|---|---|---|
| 재현율/속도 | 같은 속도에서 더 정확합니다 | 낮음 |
| 빌드 시간 | 느림 (100만 × 1536 → 수십 분) | 빠름 (몇 분) |
| 메모리 | 큼 — 그래프를 통째로 올려야 함 | 작음 |
| 데이터 추가 | 추가하면 그래프에 바로 편입 | 군집이 낡아 주기적 재빌드 필요 |
| 빈 테이블에 | 먼저 만들어도 됩니다 | 데이터가 있어야 군집을 학습합니다 |
| 결론 | 2026년의 기본 선택 | 메모리가 빠듯하거나 벌크 적재 후 한 번만 만들 때 |
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;
| 파라미터 | 기본 | 의미 | 조정 |
|---|---|---|---|
m | 16 | 노드당 이웃 수. 그래프의 촘촘함 | 차원이 크거나(1536+) 재현율이 부족하면 24~32. 메모리·빌드 시간이 함께 늡니다 |
ef_construction | 64 | 빌드할 때 후보 수. 그래프 품질 | 128~200 으로 올리면 재현율이 눈에 띄게 좋아집니다. 빌드만 느려지고 조회는 그대로 |
hnsw.ef_search | 40 | 조회할 때 후보 수 | LIMIT 보다 반드시 커야 합니다. 40 → 100 → 200 으로 올리며 재현율을 재세요 |
lists (IVF) | 100 | 군집 수 | 행 수 100만 이하면 rows/1000, 그 이상이면 sqrt(rows) |
ivfflat.probes | 1 | 뒤질 군집 수 | 기본 1 은 재현율이 형편없습니다. sqrt(lists) 부터 시작 |
ivfflat.probes = 1 인 채로 운영에 올리는 것.
"왜 뻔한 문서가 검색이 안 되죠?" 의 원인 1위입니다.
그리고 IVFFlat 인덱스는 데이터가 들어 있는 상태에서 만들어야 합니다 —
빈 테이블에 만들면 군집이 학습되지 않아 사실상 무작위 분할이 됩니다.
적재 → 인덱스 생성 순서를 지키고, 데이터가 크게 늘면 REINDEX 하세요.
인덱스가 안 타는 경우 🟡 중급
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만 건 | 재현율 손실 |
|---|---|---|---|
vector | 4바이트 (float32) | 약 6.1 GB | 기준 |
halfvec | 2바이트 (float16) | 약 3.1 GB | 거의 없음 — 먼저 시도할 것 |
bit (이진) | 1비트 | 약 192 MB | 큼 — 재순위(rerank)와 함께 써야 합니다 |
sparsevec | 0 이 아닌 값만 | 데이터에 따라 | 희소 임베딩(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);
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);
autovacuum_vacuum_scale_factor 를 낮추고,
대량 재색인 뒤에는 REINDEX INDEX CONCURRENTLY 로 정리하세요.
하이브리드 검색 — 키워드 + 벡터
벡터 검색만 켜면 "ERR_CONN_4021 오류" 같은 정확한 토큰을 놓칩니다. 키워드 검색만 켜면 뜻이 같은 다른 표현을 놓칩니다. 둘 다 켜는 것이 정답입니다.
각자 잘하는 것이 정확히 다릅니다
키워드(BM25 · tsvector)가 이기는 질의
"ERR_CONN_4021" 정확한 오류 코드
"SKU-88213" 제품 번호 · 주문 번호
"김하늘 대리" 고유명사 · 사람 이름
"pg_stat_statements" 함수 · 설정 이름
"2026년 3분기" 숫자가 든 표현
→ 임베딩은 이런 토큰을 뭉개 버립니다.
숫자 4021 과 4022 의 벡터는 거의 같습니다.
벡터가 이기는 질의
"돈 돌려받고 싶어요" → 환불 규정 문서
"화면이 하얗게 떠요" → 렌더링 오류 가이드
"휴가 언제까지 쓰나요" → 연차 사용 촉진 안내
"이거 왜 이렇게 느려요" → 성능 문제 진단 문서
→ 겹치는 글자가 없어 키워드 검색은
결과가 0건입니다.
ts_rank_cd 는 BM25 그 자체는 아니지만 같은 계열의 점수입니다.
정확한 BM25 가 필요하면 PostgreSQL 에도 pg_search(ParadeDB) 같은 확장이 있습니다.
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;
메타데이터 필터와 벡터 검색을 같이 걸 때 — 사후 필터링 함정 🔴 고급
-- ① 후보를 넉넉히 뽑고 거르기
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 이 붙은 쿼리는 그 인덱스만 탐색합니다 (사전 필터링)
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)
# → 대화형 검색이면 충분, 자동완성이면 부담
전용 벡터 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 운영 부담. 이미 쓰고 있다면 가장 실용적인 선택 |
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
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
}
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 한 줄로 들어 있습니다.
GPU_CAGRA)까지 지원하지만,
기본 배포에 etcd·객체 스토리지·메시지 큐가 따라옵니다.
"벡터 1억 개 미만" 인데 Milvus 를 고르면 얻는 것보다 운영 부담이 큽니다.
같은 규모라면 Qdrant 가 훨씬 단순합니다.
pgvector 로 충분한 지점과 넘어가야 하는 지점 🟡 중급
✓ pgvector 로 충분합니다
· 벡터 100만 ~ 2000만 개 (1536차원)
· 검색 QPS 수십 ~ 수백
· 필터가 단순하거나 넓다 (lang · 공개여부)
· 원문 · 권한 · 사용자 정보와 조인이 필요하다
· 임베딩과 원문이 항상 같이 갱신돼야 한다
→ 트랜잭션으로 한 번에 묶을 수 있습니다
· 팀에 DB 하나 더 운영할 여력이 없다
이 조건에서 pgvector 는 지연 5~30ms 로 답합니다.
→ 전용 제품으로 넘어갈 신호
· 벡터가 1억 개를 넘어 메모리에 안 들어간다
· 테넌트가 수천 개고 항상 좁은 필터가 붙는다
(사후 필터링으로 결과가 비는 일이 반복됨)
· 초당 수천 질의 — 읽기 복제본으로도 부족
· 인덱스 빌드가 몇 시간씩 걸려 재색인이 불가능
· 멀티 벡터 · 스파스+덴스 결합이 필요하다
· PostgreSQL 이 벡터 때문에 OLTP 까지 느려졌다
이 중 두 개 이상이면 이전을 검토할 때입니다.
search(query, filters, k) -> [chunk_id, score] 형태의 함수 하나로 감싸 두면
나중에 pgvector → Qdrant 이전이 그 함수 하나를 바꾸는 일이 됩니다.
원문·메타데이터는 계속 PostgreSQL 에 두고 벡터 저장소는 id 만 돌려주게 하세요 —
두 곳에 원문을 복사해 두면 정합성 문제가 반드시 생깁니다.
RAG 를 위한 DB 설계
RAG 프로젝트가 실패하는 자리는 대개 모델이 아니라 스키마입니다. 원문을 어떻게 쪼개고, 무엇을 같이 저장하고, 문서가 바뀌었을 때 무엇을 다시 만들 것인가 — 여기서 갈립니다.
스키마 — 문서 · 청크 · 버전을 나눕니다
-- ① 원본 문서 (사람이 관리하는 단위)
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
[요금 정책 > 환불 > 신청 기간] 이 앞에 붙으면
임베딩에도 그 맥락이 들어가고 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"
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
비용 — 미리 계산해 두면 놀라지 않습니다
| 항목 | 계산 | 문서 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토큰 |
데이터 파이프라인 — OLTP 에서 분석 DB 로
분석 DB 를 만들었다면 다음 질문은 하나입니다. 데이터를 어떻게 계속 옮길 것인가. 배치·CDC·로그 적재 세 가지 길이 있고, 고르는 기준은 얼마나 늦어도 되는가 입니다.
① 배치 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 함수)
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;
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) 그래프가 그려집니다
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';
스키마 변경 대응과 정합성 타협점
| 상황 | 대응 |
|---|---|
| 열 추가 | 안전합니다. ClickHouse ALTER ADD COLUMN 은 즉시. Parquet 는 없는 열이 NULL 로 읽힙니다 |
| 열 삭제 | 분석 쿼리가 깨집니다. 먼저 사용처를 찾고(dbt lineage) 유예 기간을 둡니다 |
| 열 이름 변경 | 가장 위험합니다. 새 열 추가 → 양쪽 쓰기 → 소비자 이전 → 옛 열 제거 순으로 |
| 타입 변경 | INT → BIGINT 는 대개 안전, 문자열 → 숫자는 재적재가 필요합니다 |
| 계약을 두기 | Avro·Protobuf 스키마 레지스트리로 호환성 규칙을 강제합니다(생산자가 깨면 배포가 실패) |
· 지연 — 대시보드가 5분 늦어도 되나요? 그렇다면 CDC 로 충분하고 실시간 스트리밍은 필요 없습니다.
· 중복 — 대부분의 파이프라인은 최소 한 번(at-least-once) 전달입니다. 같은 이벤트가 두 번 올 수 있으니 이벤트 ID 로 멱등하게 설계하세요(
ReplacingMergeTree).
· 순서 — 파티션이 다르면 순서가 뒤집힙니다. 같은 키는 같은 파티션으로 보내야 합니다.
· 대조 — 하루 한 번 원본과 분석 DB 의 건수·합계를 비교하는 배치를 반드시 두세요. 이것 없이는 어긋난 줄도 모릅니다.
실전 프로젝트 — 사내 문서 검색 + 사용량 대시보드
지금까지 나온 것을 하나로 묶습니다. PostgreSQL 이 원문과 권한을, pgvector 가 검색을, ClickHouse 가 접근 로그 집계를, DuckDB 가 임시 분석을 맡습니다. 스키마·쿼리·성능 수치 전부 적습니다.
① 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 ms | 31.8만 청크 · 후보 60개 |
| 벡터 검색 (HNSW) | 11 ms | ef_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 |
운영하며 겪은 것들
| 증상 | 원인 · 해결 |
|---|---|
| 특정 팀만 검색 결과가 빔 | 사후 필터링(v09). 권한 CTE 를 먼저 적용하도록 쿼리를 바꿔 해결 |
| 어느 날 갑자기 전체가 느려짐 | 임베딩 대량 갱신으로 죽은 튜플이 쌓임 → REINDEX INDEX CONCURRENTLY + autovacuum 조정(🐘 p11) |
| 무결과율이 12% → 3% | 청킹을 고정 500토큰에서 헤딩 기반 + 헤딩 경로 삽입으로 바꿈. 코드 40줄, 가장 큰 개선 |
| ClickHouse INSERT 실패 | TOO_MANY_PARTS. Kafka 컨슈머가 건당 INSERT 하고 있었음 → 1만 건 배치로 변경 |
| 대시보드에 오늘 데이터가 없음 | MV 는 생성 이후 INSERT 부터 반영(v05). 과거분 백필을 잊은 것 |
| PDF 문서만 검색이 나쁨 | 표가 텍스트로 추출되며 뭉개짐 → 표는 별도 청크로 분리하고 Markdown 표로 변환해 저장 |
DB 도
저장소에서
자랍니다
애플리케이션 코드는 폴더 구조를 고민하면서, DB 는 누군가의 GUI 에서 손으로 고치는 팀이 아직 많습니다. 그러면 "운영에만 있는 컬럼", "아무도 출처를 모르는 뷰", "개발 DB 를 새로 못 만드는 저장소"가 생깁니다. 이 탭은 DB 를 코드처럼 다룰 때의 저장소 모양 — 마이그레이션 폴더 · 반복 실행 SQL · 시드 · 명명 규칙 · dbt 프로젝트 · 샤드와 환경 · CI — 을 한 장씩 보여 주고, 각 구조에서 무엇을 어디에 두는지, 무엇을 커밋하지 않는지, 언제 다음 단계로 넘어가는지를 짧게 정리합니다. 도구별 설치와 첫 명령은 🧰 설치 총정리 탭 SETUP 11, 마이그레이션 도구 비교 · 확장-축소 5단계는 🔌 앱 연동 탭 SECTION 09 에 있으니 여기서는 폴더 지도에 집중합니다.
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) |
규모별 진화 — 언제 다음 단계로 넘어가나 🟢 기초
| 전환 | 신호 (하나라도 해당하면) | 하는 일 |
|---|---|---|
| GUI → schema.sql | 두 번째 개발자 · "내 PC 에선 테이블이 없어요" | 현재 스키마를 덤프해 커밋, README 에 적용 명령 |
| schema.sql → 마이그레이션 | 운영 DB 가 생김 · dev/stg/prod 중 둘 이상 | 덤프를 V1__baseline.sql 로 두고 이후 변경은 새 파일로 |
| 마이그레이션 → 폴더 나누기 | 뷰 · 함수 10개+ · 시드가 마이그레이션에 섞임 | repeatable/ · seed/ · grants/ 분리 |
| 앱 저장소 → 별도 저장소 | DB 를 쓰는 앱 2개+ · DBA 가 배포를 승인 · dbt | STR 03 의 기준으로 판단 — 대부분은 앱 저장소에 남습니다 |
마이그레이션 디렉터리 — 파일 이름이 곧 순서입니다
도구는 달라도 원리는 같습니다. 변경 하나 = 파일 하나, 도구가 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 의 규칙(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
['V10__…', 'V1__…', 'V2__…'] 를 돌려줍니다 — 도구가 숫자 비교를 하는 이유입니다.
| 도구 | 폴더 · 파일 모양 | 구조상 주의 |
|---|---|---|
| Alembic | alembic.ini · alembic/env.py · alembic/versions/<rev>_<설명>.py | 순서는 파일 안의 down_revision — 두 브랜치가 같은 부모를 가리키면 alembic merge |
| Prisma | prisma/schema.prisma · prisma/migrations/<타임스탬프>_<이름>/migration.sql | migration_lock.toml 도 커밋 · 운영은 migrate deploy 만 |
| EF Core | Migrations/<타임스탬프>_<이름>.cs + .Designer.cs + ModelSnapshot.cs | 스냅숏 파일이 병합 충돌의 단골 — 한 번에 한 사람이 추가 |
| Django | <앱>/migrations/0001_initial.py | 앱마다 폴더 · 앱 사이 의존은 dependencies 로 |
앱 저장소 안 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 코드를 둘 곳 | 이유 |
|---|---|---|
| 서비스 1 : DB 1 | 앱 저장소 db/ | 스키마와 코드가 한 PR — 같이 리뷰 · 같이 롤백 |
| 남의 앱은 읽기만 (리포트) | 소유 앱 저장소 + 읽기 전용 뷰 | 계약은 뷰 · 테이블은 소유 팀이 자유롭게 바꿈 |
| 여러 앱이 같은 테이블에 씀 | 별도 DB 저장소 + 버전 태그 | 어느 한 앱 저장소에 두면 다른 팀이 변경을 모름 |
| 분석 웨어하우스 | dbt 별도 저장소 (STR 07) | 쓰는 사람(분석가)과 배포 주기가 다름 |
| DBA 가 운영 DDL 을 승인 | 어느 쪽이든 + CODEOWNERS | 폴더 단위로 리뷰어를 강제 — 저장소를 나눌 필요는 없음 |
❌ 공유 DB 를 "편해서" 고름
새 서비스가 기존 서비스의 테이블에 바로 SELECT · UPDATE 를 붙입니다. 처음 한 달은 빠르지만, 그 테이블은 이제 두 팀의 공용 API가 되어 컬럼 이름 하나 못 바꿉니다. 누가 쓰는지 아는 방법은 쿼리 로그뿐입니다.
✅ 이미 공유 중이라면 — 경계를 그어 갑니다
① 별도 DB 저장소로 모아 변경을 한 곳에서 보이게 → ② 앱마다 전용 역할 계정(STR 04)으로 누가 무엇을 쓰는지 드러냄 → ③ 남의 테이블 접근을 뷰나 API 로 하나씩 바꿈. 한 번에 쪼개려 하지 않습니다.
명명 규칙 · 스키마 나누기 — 이름과 권한도 구조입니다
DB 의 "폴더"는 스키마(namespace)이고, "파일 이름"은 테이블 · 제약 · 인덱스 이름입니다.
이름 규칙이 있으면 에러 메시지 한 줄만 보고 어느 테이블의 어떤 제약인지 알고, 마이그레이션에서 DROP INDEX 할 대상을 바로 씁니다.
규칙은 무엇이 정답이냐보다 한 저장소 안에서 하나로 통일하는 것이 중요합니다(아래 접두사 체계는 흔한 관례이지 표준은 아닙니다).
이름 규칙 한 장 (README 에 그대로)
| 대상 | 규칙 | 예 |
|---|---|---|
| 테이블 | snake_case · 단수/복수 중 하나로 통일 | order_item |
| PK · FK | pk_<테이블> · 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 비워 둠
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 로
DROP TABLE 이 됩니다.
그리고 ALTER DEFAULT PRIVILEGES 는 FOR ROLE 로 지정한 계정이 만든 객체에만 적용되므로, 테이블을 다른 계정으로 만들면 권한이 안 붙습니다 — DDL 계정을 하나로 정하는 이유입니다.
db/grants/ 에 커밋합니다.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__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']
SQL 을 앱 코드에 둘 때 — 어디에 두고 무엇이 생성되나 🟡 중급
| 방식 | SQL 이 사는 곳 | 생성물 · 주의 |
|---|---|---|
| MyBatis | src/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 은 스키마와 맞춰 보는 장치(생성 코드 · 테스트)와 함께 둡니다.시드 · 테스트 데이터 — 세 종류를 섞지 않는다
"초기 데이터"라는 말 하나에 성격이 전혀 다른 세 가지가 섞여 있습니다. 운영에도 있어야 하는 참조 데이터, 개발자 화면을 채우는 샘플, 테스트 하나가 쓰고 버리는 픽스처. 이 셋을 한 파일에 두면 언젠가 샘플 회원 "홍길동"이 운영 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)
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))
seed/dev 로 프로필을 갈라, 픽스처는 테스트 코드 옆에. 운영 사본은 마스킹 전에는 어디에도 복원하지 않습니다.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 # 사람이 조회 → 미리 계산
| 층 | 이름 | 하는 일 |
|---|---|---|
| staging | stg_<원본>__<표> (밑줄 두 개) | 이름 바꾸기 · 형 변환 · 조인 없음 |
| intermediate | int_<무엇>_<동사> | 조인 · 피벗 · 재사용 계산 |
| marts | orders 또는 fct_ · dim_ | 업무 단위 결과 |
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]
tests: 가 data_tests: 로 바뀌었습니다(옛 이름도 동작). 공식 가이드는 marts 를 orders 처럼 엔티티 이름 그대로 두는 예를 보여 주고,
Kimball 식 fct_ · dim_ 접두사도 널리 씁니다 — 저장소 안에서 하나로 통일하면 됩니다.
models/ 아래 staging(정리) → intermediate(조합) → marts(결과), 이름은 stg_ · int_ 로 층을 드러내고, ref() 는 오른쪽으로만. 접속 정보(profiles.yml)와 target/ 은 커밋하지 않습니다.여러 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
블루-그린 · 확장-축소 — 마이그레이션과 배포의 순서 🔴 고급
다섯 단계 각각의 내용(NULL 허용 추가 → 양쪽 쓰기 → 백필 → 읽기 전환 → 정리)은 🔌 앱 연동 탭 SECTION 09 에 있습니다. 여기서 볼 것은 배포 축 위에서 앱 버전과 마이그레이션이 어떻게 겹치는지입니다. 블루-그린 배포는 옛 버전과 새 버전이 같은 DB 를 동시에 씁니다.
리뷰 · CI — 사람이 보기 전에 기계가 거른다
DB 변경 리뷰가 어려운 이유는 문법이 아니라 운영에서의 결과(잠금 시간 · 구버전 앱 호환)를 봐야 하기 때문입니다. 그래서 문법 · 형식 · 알려진 위험 패턴 · "빈 DB 에 전부 적용되는가"는 CI 가 먼저 거르고, 사람은 설계와 운영 영향만 봅니다.
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) 는 통과
CREATE INDEX CONCURRENTLY 는 트랜잭션 안에서 못 돌므로 그 문장 하나만 든 파일로 분리해 두는 것이 안전합니다.
잠금이 왜 걸리는지는 ⚙️ 설계 · 튜닝 탭의 락 섹션 참고.
.sqlfluff — 저장소 루트에 하나
[sqlfluff]
dialect = postgres
templater = jinja # dbt 프로젝트면 dbt
max_line_length = 120
[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper
sqlfluff fix 로 한 번에 고치는 PR 을 따로 냅니다.
사람 리뷰 체크리스트
| # | 확인할 것 |
|---|---|
| 1 | 이미 나간 V 파일을 고치지 않았나 · 변경 하나 = 파일 하나인가 |
| 2 | 지금 운영 중인 구버전 앱이 이 스키마에서도 도나 (확장 단계인가) |
| 3 | 큰 테이블이면 스테이징에서 걸린 시간을 PR 에 적었나 |
| 4 | 새 인덱스는 어떤 쿼리를 위한 것인가 (실행 계획 첨부) |
| 5 | 이름 규칙 · 스키마 지정 (sales.orders) · 권한 자동 부여 |
| 6 | 대량 UPDATE 는 마이그레이션이 아니라 배치로 뺐나 |
유형 · 규모별 권장 구조 한 장 — 그리고 문서화
지금까지의 구조를 한 표로 모았습니다. 대부분의 팀은 둘째 줄(서비스 하나 · 앱 저장소의 db/)에서 시작해 오래 머뭅니다.
마지막으로 ERD · 데이터 사전을 손으로 그리지 않고 스키마에서 생성하는 법과, 규칙을 사람이 아니라 도구가 지키게 하는 법을 정리합니다.
| 유형 · 규모 | 권장 구조 | 흔한 도구 |
|---|---|---|
| 혼자 · 내장 SQLite 앱 | migrations/ 한 폴더 · 앱 시작 때 적용 | Alembic · 직접 구현(PRAGMA user_version) |
| 서비스 1 · 팀 2~8명 | 앱 저장소 db/ = migration + repeatable + seed/dev + grants | Flyway · Alembic · Prisma · EF Core |
| 서비스 여러 개 (MSA) | 서비스마다 자기 db/ · 남의 데이터는 API · 이벤트 | 도구를 하나로 통일하면 CI 재사용 |
| 레거시 공유 DB | 별도 DB 저장소 + 버전 태그 + CODEOWNERS + CHANGELOG | Liquibase · Flyway |
| 샤드 · 테넌트별 DB | 폴더 하나 · 순회 실행 스크립트 · 샤드별 버전 확인 | Flyway · 직접 스크립트 |
| 프로시저 중심 (Oracle 등) | repeatable/ 아래 패키지 · 프로시저를 R__ 로 · DBA 리뷰 | Liquibase · Flyway |
| 분석 웨어하우스 | dbt 저장소 staging → intermediate → marts | dbt · sqlfluff(dbt templater) |
문서화 — 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/ — 생성할 수 없는 유일한 부분 |
규칙을 도구로 강제하기 🟡 중급
| 규칙 | 지키게 하는 장치 |
|---|---|
| 나간 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/로 충분하고, 여러 앱 · 분석 · 샤드일 때만 표의 아래 줄로 갑니다. ERD · 데이터 사전은 스키마에서 생성하고, 규칙은 체크섬 · 린트 · 카탈로그 테스트 · CODEOWNERS 로 도구가 지키게 합니다.