Primary key와 foreign key
현상
- 회원을 삭제했더니, 그 회원이 쓴 글이 존재하지 않는 회원 ID를 가리킨 채 남는다. 글 목록에서 작성자를 불러오다 에러가 나거나 “알 수 없음”으로 뜬다.
- 같은 사람이 두 번 등록됐는데 어느 행이 진짜인지 구분할 방법이 없다.
용어
- table · row · column — 표 하나(table), 그 안의 한 줄 = 데이터 하나(row, 행), 세로 칸 = 속성(column, 열). 엑셀 시트·행·열과 같다.
- primary key(PK, 기본 키) — 각 row를 유일하게 가리키는 값. 중복·빈 값 불가. 보통
id 컬럼.
- 비유: 주민등록번호. 이름이 같은 사람은 있어도 번호가 같은 사람은 없다.
- foreign key(FK, 외래 키) — 다른 테이블의 PK를 가리키는 컬럼.
posts.author_id → users.id. 두 테이블 사이의 관계를 만든다.
- FK 제약(foreign key constraint) — “가리키는 대상이 실제로 존재해야 한다“를 DB가 강제하는 규칙. 없는 회원 ID로 글을 쓰려 하거나, 글이 남아 있는 회원을 지우려 하면 DB가 막는다. FK 컬럼만 있고 제약을 안 걸면 위 현상(주인 없는 글)이 생긴다.
- orphan(고아) 데이터 — 가리키던 부모가 사라져 홀로 남은 row. 위 현상의 이름.
- ON DELETE 옵션 — 부모(회원)를 지울 때 자식(글)을 어떻게 할지 정하는 FK 설정.
- CASCADE — 자식도 함께 삭제. “회원 탈퇴 시 그 사람 글도 삭제”.
- SET NULL — 자식은 남기고 작성자 칸만 비움. “탈퇴해도 글은 남기고 ‘탈퇴한 사용자’로 표시”.
- RESTRICT / NO ACTION — 자식이 있으면 부모 삭제를 거부. 기본값인 경우가 많다.
- 비유: 아파트 세대주가 이사 갈 때 — 가족도 다 같이 이사(CASCADE), 가족은 남고 세대주 칸만 공란(SET NULL), 가족이 남아 있으면 전출 불가(RESTRICT).
지시 예문
- “posts.author_id에 users.id를 가리키는 foreign key 제약을 걸어줘. 회원 탈퇴 시 글은 남기고 작성자만 비우게 ON DELETE SET NULL로.”
- “댓글은 글이 삭제되면 같이 지워져야 해. comments.post_id FK를 ON DELETE CASCADE로 해줘.”
- “기존 데이터에 orphan row가 있는지 먼저 찾아주고, 정리한 뒤에 FK 제약을 추가해줘.”
JOIN 종류: INNER vs LEFT
현상
- 탈퇴한 회원의 글(작성자가
NULL, ON DELETE SET NULL)이 글 목록에서 통째로 사라진다. DB엔 분명 있는데 화면에 안 나온다.
- “주문이 한 번도 없는 회원”을 찾고 싶은데, 회원과 주문을 합치면 그런 회원은 아예 결과에 안 나온다.
- JOIN을 했더니 글 10개를 불러왔는데 결과가 30줄이 나온다.
용어
- JOIN — 두 테이블을 조건(보통 FK = PK)으로 맞붙여 한 결과로 만드는 것. 지난 레슨의 FK가 맞붙이는 기준이 된다.
- INNER JOIN — 양쪽 모두 짝이 있는 row만 남긴다. 작성자가 없는 글은 짝(회원)이 없으니 빠진다. 그냥
JOIN이라고 쓰면 INNER JOIN이다.
- LEFT JOIN — 왼쪽 테이블은 전부 남기고, 오른쪽 짝이 없으면 그 칸을
NULL로 채운다. “글은 다 보여주되 작성자가 없으면 ‘탈퇴한 사용자’”.
- 비유: 반 학생 명단(왼쪽)과 사물함 배정표(오른쪽) 합치기. INNER는 사물함 배정받은 학생만 남긴 명단, LEFT는 학생 전원 명단에 사물함 칸이 비어 있는 학생도 포함.
- “짝이 없는 것 찾기”(주문 없는 회원)도 LEFT JOIN 후 오른쪽이
NULL인 행만 고르면 된다.
- row 뻥튀기(fan-out) — 1:N 관계를 JOIN하면 “1” 쪽 row가 짝 수만큼 복제된다. 글 10개에 각각 태그가 3개면 결과는 30줄. 개수를 셀 땐
COUNT(DISTINCT ...)를 쓰거나, 목록과 태그를 따로 가져와야 한다.
- (참고) RIGHT JOIN은 LEFT의 좌우만 바꾼 것이라 실무에선 거의 LEFT로 통일해 쓴다.
지시 예문
- “탈퇴 회원 글이 목록에서 사라져. users와 INNER JOIN이라 그런 것 같아. LEFT JOIN으로 바꾸고 작성자 없으면 ‘탈퇴한 사용자’로 표시해줘.”
- “주문이 한 번도 없는 회원 목록을 LEFT JOIN으로 뽑아줘.”
- “글 목록에 태그를 JOIN했더니 글이 중복돼서 개수가 틀려. fan-out 안 생기게 쿼리 고쳐줘.”
관계 종류: 1:1 · 1:N · N:M과 중간 테이블
현상
- 글에 태그를 붙이려고
posts.tags 칸에 "react,next,css"처럼 쉼표로 이어 붙인 문자열을 넣었다. 그랬더니 “react 태그가 붙은 글 찾기”, “태그별 글 개수”가 제대로 안 되거나 엄청 느리다. 태그 이름을 바꾸려면 모든 글을 뒤져야 한다.
- 한 회원이 여러 팀에 속하고, 한 팀에 여러 회원이 있는데 어느 테이블에 FK를 둘지 정할 수 없다.
용어 — 두 테이블이 몇 대 몇으로 연결되는지(카디널리티)
- 1:1 — 한쪽 하나에 다른 쪽 하나. 회원 ↔ 회원 상세 프로필. 한 테이블로 합쳐도 되지만, 잘 안 쓰는 큰 데이터를 떼어 둘 때 나눈다.
- 1:N(일대다) — 한쪽 하나에 다른 쪽 여럿. 회원 1명 → 글 여러 개. “N” 쪽(글)에 FK(
posts.author_id)를 둔다. 가장 흔한 관계. 지난 레슨의 fan-out이 여기서 생긴다.
- N:M(다대다) — 양쪽 모두 여럿. 글 ↔ 태그(글 하나에 태그 여러 개, 태그 하나에 글 여러 개), 학생 ↔ 강의, 회원 ↔ 팀. 어느 한쪽에 FK를 둘 수 없다.
- 중간 테이블(junction table, 연결 테이블, pivot table) — N:M을 풀기 위해 양쪽 FK만 담은 테이블을 따로 만든다.
post_tags(post_id, tag_id). 이렇게 하면 N:M이 1:N 두 개로 쪼개진다.
- 비유: 수강신청. 학생 명부와 강의 목록 사이에 수강신청 내역표(학번, 강의코드)를 따로 둔다. 학생 명부에 강의를 쉼표로 적지 않는다.
- 같은 짝이 두 번 들어가지 않게
(post_id, tag_id) 조합에 unique(또는 복합 PK)를 건다.
- 중간 테이블에 관계 자체의 정보를 더 담을 수 있다. 회원↔팀이면
role(팀장/팀원), joined_at(가입일).
- 쉼표 문자열 안티패턴 — 여러 값을 한 칸에 이어 붙이는 것. FK 제약도, index도, JOIN도 못 쓴다. 현상 1의 원인.
지시 예문
- “posts.tags에 쉼표 문자열로 태그를 저장하고 있어. tags 테이블과 post_tags 중간 테이블로 N:M 구조로 바꾸고 기존 데이터도 옮겨줘.”
- “회원과 팀은 N:M이야. team_members 중간 테이블 만들고 role, joined_at 컬럼도 넣어줘. (team_id, user_id)는 unique.”
- “이 스키마에서 테이블 간 관계가 1:1, 1:N, N:M 중 무엇인지 정리해서 보여줘.”
N+1 쿼리
현상
- 글 목록 20개를 띄우는데 쿼리가 21번 실행된다. 목록 1번 + 글마다 작성자 조회 20번.
- 데이터가 적은 로컬에선 빠른데, 글이 많아지거나 배포 환경(DB가 멀리 있음)에선 목록 페이지만 유독 느리다. 504(웹 용어 28번)까지 난다.
- DB 로그에 거의 똑같은 쿼리가 id만 바뀐 채 줄줄이 찍혀 있다.
용어
- N+1 쿼리 문제 — 목록을 가져오는 쿼리 1번 + 목록의 항목 N개마다 연관 데이터를 따로 가져오는 쿼리 N번. 쿼리 하나는 빨라도, DB까지 왕복하는 시간이 N번 쌓여 느려진다.
- 비유: 장보기 목록에 20개가 적혀 있는데, 마트에 가서 하나 사고 집에 왔다가 다시 가서 하나 사고를 20번 반복하는 것. 목록을 들고 한 번에 다 사 오면 된다.
- 원인 — 코드에선 반복문 안에서 자연스럽게 생긴다.
for (post of posts) { post.author = await getUser(post.author_id) }. ORM의 lazy loading(접근하는 순간 몰래 쿼리를 날리는 기능)이면 코드에 쿼리가 보이지도 않는다.
- 해결 방법
- JOIN — 처음부터 글과 작성자를 합쳐 한 번에(2번 레슨).
- 배치 조회(batch) — 작성자 id를 모아
WHERE id IN (1, 2, 3, ...)로 한 번에 가져와 코드에서 짝지어 줌. 쿼리 총 2번.
- eager loading — ORM에 “글 가져올 때 작성자도 미리 같이“라고 지정. Prisma의
include, Supabase의 select('*, author:users(name)') 같은 중첩 select.
- 확인 방법 — 감으로 찾지 말고 쿼리 로그를 켜서 한 페이지에 쿼리가 몇 번 나가는지 센다.
지시 예문
- “글 목록 페이지가 느려. 쿼리 로그 켜고 N+1이 있는지 확인해줘.”
- “반복문 안에서 작성자를 하나씩 조회하고 있어. N+1이니까 JOIN이나 IN 쿼리로 한 번에 가져오게 바꿔줘.”
- “Prisma에서 posts 조회할 때 author를 include로 eager loading하게 바꿔줘.”
Index 심화: 비용 · 복합 index · EXPLAIN
현상
- index를 걸었는데도 쿼리가 여전히 느리다.
- “느리면 index”라며 컬럼마다 index를 걸었더니, 이번엔 글 저장·수정이 느려졌다.
author_id로 JOIN하는 쿼리가 느리다. FK를 걸었으니 index도 있을 줄 알았다.
용어
- index — 특정 컬럼 값을 정렬해 둔 별도의 찾아보기 표. 전체를 훑지 않고 바로 위치를 찾는다. 책 맨 뒤의 색인(찾아보기)과 같다. 그래서 “인덱스 = 색인”.
- full table scan(Seq Scan) — index를 못 쓰고 처음부터 끝까지 훑는 것. 느림의 주범.
- index의 비용 — 공짜가 아니다.
- 저장 공간을 더 쓴다.
- row를 넣고·고치고·지울 때마다 index도 같이 갱신해야 해서 쓰기가 느려진다. 책에 페이지를 추가할 때마다 색인도 고쳐야 하는 것과 같다.
- 그래서 자주 검색·정렬·JOIN하는 컬럼에만 건다.
- FK ≠ index — PostgreSQL은 PK·unique엔 index를 자동으로 만들지만 FK 컬럼엔 자동으로 만들지 않는다. JOIN·삭제(CASCADE)에 쓰이는 FK 컬럼엔 보통 직접 index를 건다.
- 복합 index(composite index) — 여러 컬럼을 묶은 index.
(user_id, created_at)이면 “이 사용자의 글을 최신순”에 딱 맞다.
- 순서가 중요하다(leftmost prefix):
(a, b) index는 a로 찾기, a + b로 찾기엔 쓰이지만 b만으로 찾기엔 못 쓴다.
- 비유: 전화번호부는 성 → 이름 순으로 정렬돼 있다. “김씨” 찾기, “김민수” 찾기는 빠르지만, 성 모르고 “민수”만으로는 처음부터 다 봐야 한다.
- index를 못 타는 흔한 경우
- 컬럼을 함수로 감쌈:
WHERE LOWER(email) = ... (그대로 쓰거나 함수 기준 index 필요)
- 앞쪽 와일드카드:
LIKE '%kim' (뒤쪽 'kim%'은 가능)
- 값 종류가 너무 적음:
is_deleted(true/false)처럼 절반씩 나뉘면 index가 별 도움이 안 된다
- EXPLAIN / EXPLAIN ANALYZE — DB가 쿼리를 어떻게 실행할 계획인지(실제 걸린 시간까지) 보여주는 명령.
Seq Scan이 보이면 index를 못 타고 있다는 신호. 추측 대신 이걸로 확인한다.
지시 예문
- “이 쿼리 EXPLAIN ANALYZE 돌려서 Seq Scan인지 확인하고, 필요한 index를 제안해줘.”
- “posts.author_id가 FK인데 index가 없어. JOIN이 느리니 index 추가해줘.”
- "’내 글 최신순’ 쿼리용으로 (user_id, created_at) 복합 index 걸어줘.”
- “index가 너무 많아서 쓰기가 느려. 실제로 안 쓰이는 index를 찾아줘.”
제약 조건: NOT NULL · UNIQUE · CHECK · DEFAULT
현상
- 이메일이 비어 있는 회원이 몇 명 섞여 있다. 화면에선 필수 입력이었는데.
- 재고가 -3개, 가격이 -1000원인 상품이 있다.
Kim@mail.com과 kim@mail.com이 다른 회원으로 둘 다 가입돼 있다.
WHERE deleted_at = NULL로 찾았는데 아무것도 안 나온다.
용어
- constraint(제약 조건) — 테이블에 거는 데이터 규칙. 규칙을 어기는 데이터는 DB가 저장 자체를 거부한다.
- 왜 화면 검증만으론 부족한가: API를 직접 호출하거나, 관리자 스크립트, 다른 기능, AI가 새로 짠 코드가 화면 검증을 우회한다. 웹 용어 27번 레슨의 “DB가 최후 방어선”이 이것.
- 비유: 화면 검증은 가게 입구 안내문, 제약 조건은 금고 자물쇠. 안내문을 안 읽는 손님은 있어도 자물쇠는 못 피한다.
- NOT NULL — 이 칸은 비울 수 없음. 이메일, 생성일 등.
- NULL — “값이 없음/모름”을 뜻하는 특별한 표시. 빈 문자열
''이나 0과 다르다.
- NULL은 무엇과 비교해도 참이 아니라서
= NULL은 항상 실패한다. IS NULL / IS NOT NULL로 찾는다. 현상 4의 원인.
- UNIQUE — 이 칸(또는 칸 조합)에 같은 값이 두 번 못 들어감. 이메일, 닉네임, 중간 테이블의 (post_id, tag_id).
- 대소문자 함정:
'Kim'과 'kim'은 다른 값이라 둘 다 통과한다. 저장 전에 소문자로 통일하거나, LOWER(email) 기준 unique index를 건다.
- CHECK — 값이 조건을 만족해야 저장.
CHECK (stock >= 0), CHECK (price > 0), CHECK (status IN ('draft', 'published')).
- DEFAULT — 값을 안 주면 자동으로 들어갈 기본값.
created_at DEFAULT now(), status DEFAULT 'draft'.
- 기존 데이터에 이미 규칙 위반이 있으면 제약 추가가 실패한다. 먼저 위반 데이터를 찾아 정리해야 한다.
지시 예문
- “users.email에 NOT NULL과 UNIQUE를 걸어줘. 대소문자 다른 중복도 막게 소문자 기준으로.”
- “products.stock이 음수가 될 수 없게 CHECK 제약 추가해줘. 기존에 음수인 데이터부터 찾아서 보여줘.”
- “삭제 안 된 글 조회가 비어 나와. deleted_at = NULL을 IS NULL로 고쳐줘.”
Transaction 심화: commit · rollback · ACID
현상
- 주문 생성은 됐는데 재고 차감 단계에서 에러가 나서 주문만 있고 재고는 그대로다.
- 코드에서 쿼리 세 개를 차례로 실행하는데, 중간에 하나가 실패하면 앞의 것만 반영된 반쪽 데이터가 남는다.
- 트랜잭션으로 묶었는데도 결제 실패 후 확인 이메일은 이미 발송돼 있다.
용어
- transaction — 여러 작업을 하나의 단위로 묶어 “전부 성공 아니면 전부 없던 일”로 만드는 것.
- commit — 묶인 작업을 확정해 실제로 반영. 이후엔 되돌릴 수 없다.
- rollback — 중간에 실패하면 묶인 작업을 전부 취소해 시작 전 상태로 되돌림. (웹 용어의 배포 rollback과 단어는 같지만 대상이 다르다 — 이건 데이터 변경 취소.)
- 비유: 장바구니 결제. 상품 담기(작업들)를 하다가 결제 버튼(commit)을 눌러야 확정된다. 중간에 창을 닫으면(rollback) 아무것도 산 게 아니다.
- ACID — 트랜잭션이 지키는 네 가지 성질.
- Atomicity(원자성) — 전부 아니면 전무. 반쪽 반영 없음.
- Consistency(일관성) — 끝나고 나면 제약 조건(6번 레슨) 등 규칙이 지켜진 상태.
- Isolation(격리성) — 동시에 도는 다른 트랜잭션이 진행 중인 반쪽 상태를 보지 못함.
- Durability(지속성) — commit된 건 서버가 꺼져도 남는다.
- 함정 ① DB 밖의 일은 rollback되지 않는다 — 이메일 발송, 결제 API 호출, 파일 업로드는 DB가 되돌릴 수 없다. commit이 성공한 뒤에 실행한다. 현상 3의 원인.
- 함정 ② 클라이언트에서 쿼리를 여러 번 부르면 트랜잭션이 아니다 — Supabase처럼 브라우저에서
insert → update를 따로 호출하면 각각 별개다. 묶으려면 서버에서 트랜잭션으로 실행하거나(Prisma $transaction 등), Supabase는 DB 함수(RPC) 하나로 만들어 호출한다.
- 함정 ③ 트랜잭션은 짧게 — 오래 열어두면 그동안 관련 데이터를 붙잡고 있어 다른 요청이 줄줄이 기다린다(다음 레슨 lock과 연결).
지시 예문
- “주문 생성과 재고 차감을 하나의 transaction으로 묶어서, 하나라도 실패하면 rollback되게 해줘.”
- “Supabase에서 insert와 update를 클라이언트에서 따로 호출하고 있어. 하나의 DB 함수(RPC)로 만들어 transaction으로 처리해줘.”
- “주문 확인 이메일은 transaction commit이 성공한 다음에만 보내게 순서를 바꿔줘.”
동시성: lost update · atomic update · lock
현상
- 재고가 1개 남은 상품을 두 사람이 동시에 결제했더니 둘 다 성공했다. 재고는 0인데 2개가 팔렸다(초과 판매).
- 좋아요가 동시에 몰리면 숫자가 실제보다 적게 올라간다.
- 두 관리자가 같은 상품 설명을 동시에 고쳤더니, 나중에 저장한 사람 것만 남고 먼저 저장한 수정이 조용히 사라졌다.
- 로그에 가끔
deadlock detected 에러가 찍힌다.
용어
- 동시성(concurrency) — 여러 요청이 같은 데이터를 동시에 건드리는 상황. 웹 용어 5번 race condition의 DB 버전이다.
- lost update(갱신 손실) — 두 요청이 같은 값을 읽고 → 계산하고 → 쓰는 사이에 서로 끼어들어 한쪽 변경이 사라지는 것.
- 예: 좋아요 10. A가 10을 읽음 → B도 10을 읽음 → A가 11 저장 → B도 11 저장. 두 번 눌렀는데 결과는 11.
- 비유: 공유 화이트보드의 숫자를 두 사람이 동시에 보고 각자 머릿속으로 +1 해서 지우고 다시 쓰는 것.
- atomic update(원자적 갱신) — 읽고 쓰기를 DB 안에서 한 문장으로 끝내 끼어들 틈을 없앤다.
UPDATE posts SET likes = likes + 1 WHERE id = ? — 코드에서 읽어 와 더하지 않는다.
- 재고:
UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0 → 바뀐 행이 0개면 품절 처리. 초과 판매를 막는 가장 간단한 방법.
- lock(잠금) — 한 트랜잭션이 데이터를 쓰는 동안 다른 트랜잭션이 기다리게 하는 것.
- 비관적 잠금(pessimistic lock) — “충돌할 거야”라고 보고 먼저 잠근다.
SELECT ... FOR UPDATE로 행을 잠그면 다른 요청은 트랜잭션이 끝날 때까지 대기. 비유: 화장실 열쇠를 가져간 사람만 들어간다.
- 낙관적 잠금(optimistic lock) — “충돌은 드물어”라고 보고 잠그지 않되,
version 컬럼으로 저장 순간에 검사한다. UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = 내가 읽은 버전 → 바뀐 행이 0개면 “그사이 누가 고쳤어요” 알림. 현상 3(동시 편집 덮어쓰기)에 맞다. 비유: 위키의 “편집 충돌” 경고.
- deadlock(교착 상태) — 두 트랜잭션이 서로 상대가 잡은 잠금을 기다리며 영원히 멈춘 상태. DB가 감지해 한쪽을 강제로 실패시킨다. 비유: 좁은 골목에서 마주 선 두 차가 서로 비켜주길 기다리는 것. 잠그는 순서를 항상 같게 하고 트랜잭션을 짧게 하면 줄어든다.
지시 예문
- “재고 1개 상품이 동시 결제로 초과 판매돼. 재고 차감을 ‘stock > 0’ 조건의 atomic update로 바꾸고, 바뀐 행이 0이면 품절 에러를 내줘.”
- “좋아요를 읽어서 +1 하고 저장하고 있어. lost update 나니까 DB에서 likes = likes + 1로 바꿔줘.”
- “상품 수정에 optimistic lock 넣어줘. version 컬럼 추가하고, 충돌하면 ‘다른 사람이 먼저 수정했어요’ 안내해줘.”
- “deadlock detected가 가끔 나. 어떤 트랜잭션들이 어떤 순서로 잠그는지 분석해줘.”
Migration 심화: drift · backfill · destructive · expand-contract
현상
- 컬럼 이름을
name → nickname으로 바꾸는 migration을 돌렸더니 기존 데이터가 전부 사라졌다.
- 기존 회원이 10만 명인 테이블에
NOT NULL 컬럼을 추가하는 migration이 실패한다.
- Supabase 대시보드에서 컬럼을 직접 추가했더니, 다음 migration부터 에러가 나거나 로컬과 배포 DB 구조가 달라졌다.
- 배포 순간 몇 분간 새 코드가 아직 없는 컬럼을 찾거나, 옛 코드가 이미 지워진 컬럼을 찾아 에러가 난다.
용어
- schema(스키마) — 테이블·컬럼·제약·index 등 DB의 구조 설계도. migration은 이 설계도의 변경 이력이다.
- up / down migration — 변경 적용(up)과 되돌리기(down). down은 구조만 되돌릴 뿐, 지워진 데이터는 돌아오지 않는다.
- destructive migration(파괴적 변경) — 컬럼·테이블 삭제, 타입 변경처럼 데이터를 잃을 수 있는 변경. 도구가 “이름 변경”을 “삭제 후 새로 추가”로 해석하면 현상 1이 생긴다. 실행 전 생성된 SQL을 꼭 확인하고, 실서비스 전엔 백업.
- backfill(채워 넣기) — 새 컬럼을 추가한 뒤 기존 row들의 값을 채우는 작업. 기존 데이터가 있는 테이블에
NOT NULL 컬럼을 바로 추가하면 기존 row가 규칙을 어겨 실패한다(6번 레슨). 순서: ① nullable(또는 DEFAULT 포함)로 추가 → ② backfill → ③ NOT NULL 걸기.
- schema drift(스키마 표류) — migration 파일에 기록된 구조와 실제 DB 구조가 어긋난 상태. 대시보드에서 손으로 고치면 생긴다. 규칙: 스키마 변경은 항상 migration 파일로만.
- 비유: 건물 설계도를 안 고치고 벽을 몰래 허문 것. 다음 공사 때 설계도대로 하다가 사고가 난다.
- expand-contract(확장-수축) 패턴 — 배포 중엔 옛 코드와 새 코드가 잠깐 동시에 돌기 때문에, 한 번에 바꾸지 않고 단계로 나눈다. 컬럼 이름 변경이라면:
- expand — 새 컬럼 추가, 옛 컬럼은 유지
- 코드가 양쪽 다 쓰게 배포 + backfill
- 코드가 새 컬럼만 읽게 배포
- contract — 옛 컬럼 삭제
- 비유: 다리를 새로 놓을 때 옛 다리를 먼저 부수지 않고, 새 다리를 다 지어 차량을 옮긴 뒤에 옛 다리를 철거하는 것.
지시 예문
- “name을 nickname으로 바꾸는 migration 만들어줘. drop/add 말고 RENAME으로, 실행 전에 생성된 SQL 먼저 보여줘.”
- “users에 NOT NULL인 role 컬럼을 추가해야 해. nullable로 추가 → 기존 회원은 ‘member’로 backfill → NOT NULL 순서로 migration 나눠줘.”
- “대시보드에서 손으로 바꾼 게 있어서 schema drift가 생긴 것 같아. 실제 DB와 migration 파일의 차이를 비교해줘.”
- “이 컬럼 변경을 무중단으로 하고 싶어. expand-contract 단계로 나눠서 계획 세워줘.”
Soft delete vs hard delete
현상
- 사용자가 실수로 지운 글을 복구해 달라는데 방법이 없다.
- “삭제”한 글이 검색 결과나 관리자 통계에 여전히 나온다.
- 탈퇴했다가 같은 이메일로 다시 가입하려니 “이미 가입된 이메일” 에러가 난다.
용어
- hard delete(물리 삭제) — row를 DB에서 실제로 지움(
DELETE). 깔끔하지만 되돌릴 수 없다(백업에서 꺼내는 것 말고는).
- soft delete(논리 삭제) — 지우지 않고 “삭제됨” 표시만 남김. 보통
deleted_at 컬럼에 삭제 시각을 넣고, 평소 조회는 WHERE deleted_at IS NULL로 거른다(6번 레슨의 IS NULL).
- 비유: 휴지통. 파일을 지우면 일단 휴지통으로 가고(soft), 휴지통 비우기를 해야 진짜 사라진다(hard).
- 장점: 복구 가능, 누가 언제 지웠는지 기록, FK로 연결된 데이터가 깨지지 않음(1번 레슨 orphan 방지).
- soft delete의 함정
- 필터 누락 — 조회 쿼리 하나라도
deleted_at IS NULL을 빼먹으면 지운 데이터가 나온다(현상 2). 검색·통계·관리자 화면·JOIN 대상까지 전부 챙겨야 한다. 뷰(view)나 ORM의 기본 필터로 한 곳에서 거르면 누락이 줄어든다.
- UNIQUE 충돌 — 지운 회원 row가 남아 있어 같은 이메일 재가입이 막힌다(현상 3). partial unique index(
deleted_at IS NULL인 row끼리만 unique)로 푼다.
- 데이터가 계속 쌓인다 — 테이블이 커지고 느려진다. 일정 기간 뒤 hard delete하는 정리 작업을 둔다.
- 개인정보는 예외 — 탈퇴 회원의 개인정보는 법적으로 실제 파기해야 할 수 있다. 이름·이메일·전화번호 등은 hard delete하거나 알아볼 수 없게 바꾸고(익명화), 글 같은 콘텐츠만 soft delete로 남기는 식으로 나눈다.
지시 예문
- “글 삭제를 soft delete로 바꿔줘. deleted_at 컬럼 추가하고, 모든 조회에서 삭제된 글이 빠지는지 검색·관리자 화면까지 확인해줘.”
- “탈퇴 후 같은 이메일로 재가입이 안 돼. email unique를 deleted_at IS NULL 조건의 partial unique index로 바꿔줘.”
- “회원 탈퇴 시 개인정보 컬럼은 익명화하고, 작성한 글은 soft delete로 남겨줘.”
정규화 vs 비정규화
현상
- 회원이 닉네임을 바꿨는데 예전 글·댓글엔 옛 닉네임이 그대로 남아 있다. 글마다
author_name을 복사해 저장했기 때문.
- 상품 가격을 바꿨더니 이미 끝난 주문의 금액까지 바뀌어 보인다.
- 글 목록마다 댓글 수를 보여주려고 매번
COUNT를 돌리니 목록이 느리다.
용어
- 정규화(normalization) — 한 가지 사실은 한 곳에만 저장하도록 테이블을 나누는 설계. 닉네임은
users에만 두고, 글은 author_id(FK)로 가리킨다. 웹 용어 31번 single source of truth의 DB 버전.
- “한 칸에 값 하나”(3번 레슨 쉼표 문자열 금지)도 정규화의 첫 규칙이다.
- 갱신 이상(update anomaly) — 같은 사실이 여러 곳에 복사돼 있어서, 한 곳만 고쳐지고 나머지는 옛 값으로 남는 문제. 현상 1의 이름.
- 비유: 주소가 바뀌었는데 은행·통신사·쇼핑몰마다 따로 등록돼 있어서, 한 곳만 바꾸고 나머지엔 옛 주소로 우편이 가는 것.
- 비정규화(denormalization) — 조회를 빠르게 하려고 일부러 중복 저장하는 것. 정규화의 반대 방향 선택.
- 예: 글 테이블에
comment_count 컬럼을 두고, 댓글이 달릴 때마다 +1(8번 레슨 atomic update). 목록에서 매번 COUNT하지 않아도 된다(현상 3 해결).
- 대가: 원본과 어긋나지 않게 맞추는 책임이 생긴다. 댓글 삭제 때 -1을 빼먹으면 숫자가 틀어진다. DB trigger(데이터 변경 시 자동 실행되는 함수)로 맞추기도 한다.
- 스냅샷(snapshot)은 중복이 아니다 — 주문 당시 가격·배송지처럼 그 시점의 값이 보존돼야 하는 데이터는 일부러 복사해 둬야 맞다. 상품 가격을 FK로만 가리키면 가격이 바뀔 때 과거 주문 금액도 바뀐다(현상 2). “나중에 바뀌면 같이 바뀌어야 하나?”로 판단한다 — 같이 바뀌어야 하면 FK, 그때 값으로 남아야 하면 복사.
지시 예문
- “글마다 author_name을 복사해 저장하고 있어. 닉네임 바꿔도 옛 글에 반영이 안 돼. author_id FK로 정규화하고 JOIN으로 가져오게 바꿔줘.”
- “주문 금액이 상품 가격 변경에 따라 바뀌어. 주문 시점의 가격을 order_items에 스냅샷으로 저장해줘.”
- “글 목록 댓글 수 COUNT가 느려. posts에 comment_count로 비정규화하고, 댓글 추가·삭제 때 trigger로 맞춰줘.”
RLS (Row Level Security)
현상
- Supabase에 테이블을 새로 만들었더니, 로그인한 사용자가 다른 사람의 주문 내역까지 조회할 수 있다.
- RLS를 켰더니 이번엔 에러도 없이 데이터가 하나도 안 나온다(빈 배열).
- 남의 글을 수정하는 요청을 직접 보내 봤더니 성공해 버렸다.
용어
- 배경: 브라우저가 DB에 직접 붙는 구조 — Supabase·Firebase는 브라우저 코드가 DB를 직접 조회한다. 이때 쓰는
anon 키는 공개해도 되는 키라 누구나 볼 수 있다(웹 용어 17번 public 환경 변수). 그래서 “누가 어떤 row를 볼 수 있나”를 DB 자체가 막아야 한다.
- RLS(Row Level Security, 행 수준 보안) — 테이블의 각 row마다 “이 사용자가 읽고 쓸 수 있는가”를 DB가 검사하는 기능. 웹 용어 23번의 인가(authorization)를 DB 안에서 하는 것.
- 비유: 아파트 우편함. 건물 출입(로그인)은 다 같이 하지만, 자기 호수 우편함만 열린다.
- policy(정책) — RLS의 실제 규칙. “본인 글만 수정 가능”처럼 작업(SELECT/INSERT/UPDATE/DELETE)별로 정한다.
- USING — 기존 row 중 어떤 걸 볼·고칠 수 있는지 거르는 조건.
USING (auth.uid() = user_id) = 내 row만.
- WITH CHECK — 새로 쓰거나 바꾼 값이 규칙에 맞는지 검사. 남의
user_id로 글을 쓰는 걸 막는다.
auth.uid() — 지금 요청한 로그인 사용자의 id.
- RLS의 두 가지 사고
- RLS 꺼짐 →
anon 키만 있으면 누구나 전부 읽고 쓴다(현상 1·3). 가장 위험한 실수.
- RLS 켜짐 + 정책 없음 → 기본이 “전부 거부”라서 에러 없이 빈 결과(현상 2). 버그가 아니라 정책을 안 만든 것.
- service_role 키 — RLS를 무시하는 관리자용 키. 서버에서만 쓰고 절대 브라우저에 노출하면 안 된다.
NEXT_PUBLIC_을 붙이면 전체 DB가 열린다(웹 용어 17번).
지시 예문
- “모든 public 테이블에 RLS가 켜져 있는지 확인하고, 꺼진 테이블 목록을 보여줘.”
- “orders 테이블에 RLS 정책 만들어줘. 본인 주문만 SELECT 가능, INSERT는 WITH CHECK로 user_id가 auth.uid()와 같을 때만.”
- “RLS 켰더니 글 목록이 빈 배열로 와. 에러는 없어. SELECT 정책이 빠졌는지 확인해줘.”
- “service_role 키가 클라이언트 코드에 있는지 검사해줘.”
Connection pool
현상
- 평소엔 괜찮은데 접속자가 몰리거나 배포 직후에
too many connections, remaining connection slots are reserved 에러가 난다.
- 로컬에선 멀쩡한데 Vercel 같은 서버리스 환경에서만 DB 연결 에러가 간헐적으로 난다.
- 쿼리 자체는 빠른데 첫 요청만 유독 느리다.
용어
- connection(연결) — 앱이 DB와 대화하려고 여는 통화 회선. 여는 데 시간이 걸리고(인증·준비), DB가 동시에 유지할 수 있는 개수에 상한(max connections)이 있다. 작은 요금제는 수십~백 개 수준.
- connection pool(커넥션 풀) — 연결을 미리 몇 개 열어두고 돌려쓰는 것. 요청마다 새로 열고 닫지 않는다.
- 비유: 콜센터 회선. 고객마다 새 전화선을 깔면 금방 회선이 바닥난다. 회선 10개를 두고 통화가 끝나면 다음 고객에게 넘긴다.
- 서버리스와 연결 고갈 — 서버리스 함수는 요청이 몰리면 인스턴스가 수십·수백 개로 복제되고, 각자 연결을 연다. 그래서 DB 상한을 금방 넘는다(현상 1·2). 함수가 오래 살지 않아 풀을 유지하기도 어렵다.
- pooler(연결 중계기) — 앱과 DB 사이에서 많은 앱 연결을 적은 DB 연결로 묶어주는 별도 서버. PgBouncer, Supabase의 Supavisor 등. 서버리스에선 DB 직접 주소 대신 pooler 주소(connection string)로 연결하는 게 기본.
- 모드에 따라(transaction mode 등) 일부 기능이 제한될 수 있어, ORM 설정에 pooler용 옵션을 따로 주는 경우가 있다.
- connection leak(연결 누수) — 연결을 빌려 쓰고 반납하지 않아 풀이 점점 비는 것. 에러 처리 경로에서 반납을 빼먹으면 생긴다. 일정 시간 뒤 갑자기 “연결 없음”으로 멈추는 패턴.
- cold start — 서버리스 함수가 처음 깨어날 때 준비하는 시간. 이때 DB 연결도 새로 열어 첫 요청이 느리다(현상 3).
지시 예문
- “Vercel에서 too many connections 에러가 나. DB 직접 주소 대신 Supabase pooler(Supavisor) 주소로 연결하게 바꾸고, ORM에 필요한 pooler 옵션도 확인해줘.”
- “DB 클라이언트를 요청마다 새로 만들고 있어. 모듈 단위로 하나만 만들어 재사용하게 해줘.”
- “몇 시간 지나면 연결 에러로 멈춰. 에러 경로에서 connection을 반납 안 하는 connection leak이 있는지 찾아줘.”
백업과 복구: backup · PITR · seed
현상
- AI에게 테스트 데이터 정리를 시켰더니 실서비스 DB의 회원 테이블이 비었다. 로컬 DB인 줄 알았다.
- 잘못된 UPDATE가 오후 2시 13분에 실행돼 모든 상품 가격이 0원이 됐다. 어제 백업으로 돌리면 오늘 들어온 주문이 다 날아간다.
- 백업은 매일 된다고 들었는데, 막상 복구하려니 방법도 모르고 시간이 얼마나 걸릴지도 모른다.
용어
- backup(백업) — 특정 시점의 DB 전체를 복사해 보관한 것. 보통 하루 한 번 자동 스냅샷.
- 한계: 마지막 백업 이후의 변경은 없다. 어제 밤 백업으로 돌리면 오늘 데이터를 잃는다(현상 2).
- PITR(Point-In-Time Recovery, 시점 복구) — DB가 모든 변경을 기록해 둔 로그를 이용해 원하는 시각(초 단위)으로 되돌리는 복구. “2시 12분 59초 상태로”가 가능하다.
- 비유: 백업은 하루 한 장 찍은 사진, PITR은 CCTV 녹화. 사진은 찍은 순간으로만 돌아가지만, 녹화는 사고 1초 전으로 돌려볼 수 있다.
- 호스팅 서비스·요금제마다 지원 여부와 보관 기간이 다르니 미리 확인해 둔다.
- 복구 리허설 — 한 번도 복구해 본 적 없는 백업은 믿을 수 없다. 실제로 별도 환경에 복구해 보는 연습을 해 둬야 사고 때 당황하지 않는다(현상 3).
- 환경 분리 — 로컬·스테이징·프로덕션 DB를 분리하고, 각자 다른 연결 주소(환경 변수, 웹 용어 17번)를 쓴다. 파괴적 작업 전엔 지금 어느 DB에 연결됐는지 먼저 확인한다(현상 1).
- seed(시드) 데이터 — 개발·테스트용으로 DB에 처음 채워 넣는 샘플 데이터와 그 스크립트. 로컬 DB는 언제든 지우고 seed로 다시 만들 수 있게 해 두면, 실서비스 DB를 건드릴 일이 없다.
- 실서비스 데이터를 바꾸는 일회성 스크립트는 transaction으로 감싸고(7번 레슨), 먼저 SELECT로 대상 개수를 확인한 뒤 실행한다.
지시 예문
- “이 삭제 스크립트 돌리기 전에 지금 연결된 DB가 로컬인지 production인지 먼저 확인해서 알려줘.”
- “실서비스 가격 일괄 수정 스크립트를 transaction으로 감싸고, 실행 전에 바뀔 row 수를 SELECT로 먼저 보여줘.”
- “우리 Supabase 프로젝트의 백업 주기와 PITR 지원 여부를 확인해줘.”
- “로컬 DB를 초기화하고 seed로 다시 채우는 스크립트 만들어줘. 회원 10명, 글 50개.”
데이터 타입 선택: 돈 · 시간 · id · JSON
현상
- 금액 합계가
29999.999999처럼 끝자리가 어긋난다. 정산이 1원씩 안 맞는다.
- 주문 상세 주소가
/orders/1024라서, 숫자만 바꿔 넣으면 남의 주문 페이지가 열린다. 경쟁사는 id만 보고 우리 주문 수를 짐작한다.
- 시간을 저장했는데 서버마다 9시간씩 다르게 읽힌다.
- 이것저것
metadata JSON 칸에 몰아넣었더니 검색도, 제약도, JOIN도 안 된다.
용어
- column type(컬럼 타입) — 칸에 들어갈 값의 종류. 한 번 정하면 바꾸기 번거로워서(9번 레슨 destructive migration) 처음에 잘 고른다.
- 돈 — float 금지 —
float(부동소수점)는 컴퓨터가 소수를 근삿값으로 저장해 0.1 + 0.2 = 0.30000000000000004 같은 오차가 생긴다. 돈은 정수(원·센트 단위의 integer/bigint) 또는 정확한 소수 타입(numeric/decimal)으로.
- 원화는 소수점이 없으니 integer(원 단위)가 가장 단순하다.
- 시간 — timestamptz — PostgreSQL에서
timestamp(시간대 없음)는 시간대 정보를 버려서 해석이 환경마다 달라진다. timestamptz(timestamp with time zone)로 저장하면 UTC 기준으로 정확히 한 순간을 가리킨다(웹 용어 26번). 생일 같은 날짜만은 date.
- id — 연속 숫자 vs UUID
- auto increment(serial/identity) — 1, 2, 3… 자동 증가. 짧고 빠르다. 대신 다음 번호를 추측할 수 있고 전체 개수가 드러난다.
- UUID —
550e8400-e29b-41d4-... 같은 무작위 긴 값. 추측이 불가능하다. 외부에 노출되는 주소(주문·초대 링크)에 적합.
- IDOR(Insecure Direct Object Reference) — 주소의 id만 바꿔서 남의 데이터에 접근되는 보안 구멍. 현상 2의 이름. 근본 해결은 UUID가 아니라 “이 사용자의 것인가” 권한 검사(12번 RLS, 웹 용어 23번 인가)이고, UUID는 추측을 어렵게 하는 보조 수단이다.
- JSON(jsonb) 컬럼 — 구조가 자유로운 데이터를 한 칸에 통째로 넣는 타입. 외부 API 원본, 설정값처럼 모양이 들쭉날쭉한 부가 정보에 좋다. 대신 FK·제약·index가 제한돼, 자주 검색·필터·JOIN하는 핵심 데이터는 일반 컬럼으로 뺀다. 3번 레슨 쉼표 문자열의 고급 버전 함정.
- enum / CHECK —
status 같은 칸에 정해진 값만 들어가게('draft' | 'published'). DB enum 타입이나 CHECK 제약(6번 레슨)으로 막는다.
지시 예문
- “price가 float이라 합계 오차가 나. 원 단위 integer로 바꾸는 migration 만들어줘. 기존 값은 반올림해서 옮겨줘.”
- “created_at이 timestamp(without time zone)야. timestamptz로 바꿔줘.”
- “주문 상세 URL이 연속 숫자 id라 IDOR 위험이 있어. 접근 권한 검사부터 확인하고, 외부 노출 id는 UUID로 바꿔줘.”
- “metadata JSON 안의 category로 자주 필터링해. category를 별도 컬럼으로 빼고 index 걸어줘.”
ORM · query builder · raw SQL · 타입 생성
현상
- migration으로 컬럼을 추가했는데 코드에서
user.nickname에 타입 에러가 나고 자동완성에도 안 뜬다.
- AI가 어떤 파일은 Prisma로, 어떤 파일은 SQL 문자열로, 어떤 파일은 Supabase 클라이언트로 짜서 DB 접근 방식이 뒤죽박죽이다.
- ORM으로 짠 단순한 코드가 뒤에서 예상 못 한 쿼리를 수십 번 날리고 있다(4번 레슨 N+1).
용어 — 코드에서 DB를 부르는 세 가지 층
- raw SQL — SQL 문장을 직접 쓴다. 가장 자유롭고 빠르지만 타입 안전성이 없고, 입력값을 문자열로 이어 붙이면 SQL injection(웹 용어 25번)이 생긴다. 쓸 땐 반드시 parameterized query.
- query builder — SQL을 코드 함수 조합으로 만든다.
db.selectFrom('users').where('id', '=', id). SQL과 거의 1:1로 대응해 무슨 쿼리가 나갈지 예측하기 쉽다. Kysely, Knex 등. (Drizzle은 ORM이지만 이 성격이 강하다.)
- ORM(Object-Relational Mapping) — 테이블을 코드의 객체처럼 다루게 해준다.
prisma.user.findMany({ include: { posts: true } }). 관계·타입을 편하게 다루지만, 실제로 어떤 SQL이 몇 번 나가는지 가려진다. Prisma, Drizzle, TypeORM 등.
- 비유: raw SQL은 직접 운전, query builder는 내비 보며 운전, ORM은 택시. 택시는 편하지만 어느 길로 가는지 안 보이니 가끔 돌아간다(N+1).
- 스키마 → 타입 생성(type generation) — DB 구조로부터 TypeScript 타입을 자동으로 만드는 것.
prisma generate, supabase gen types. migration 후에 다시 생성하지 않으면 코드의 타입이 옛 구조에 머문다(현상 1).
- single source of truth(웹 용어 31번) 원칙: 타입을 손으로 쓰지 말고 DB 스키마를 원본으로 생성한다.
- 한 프로젝트엔 한 방식 — 섞어 쓰면 트랜잭션 묶기·타입 공유·리뷰가 어려워진다(현상 2). 예외(복잡한 통계 쿼리만 raw SQL 등)는 정해 두고 쓴다.
지시 예문
- “migration 후에 타입이 안 맞아. supabase gen types(또는 prisma generate)로 타입 다시 생성하고, 이걸 package.json 스크립트로 만들어줘.”
- “DB 접근 방식이 섞여 있어. 이 프로젝트는 Drizzle로 통일하고, raw SQL로 꼭 남겨야 할 곳만 목록으로 보여줘.”
- “이 ORM 코드가 실제로 어떤 SQL을 몇 번 날리는지 쿼리 로그로 보여줘.”
집계와 통계: GROUP BY · HAVING · view · materialized view
현상
- “카테고리별 매출”을 내려고 주문 전체를 앱으로 가져와 JS 반복문으로 합산하니 느리고 메모리가 터진다.
- “주문 5건 이상인 회원”을 걸렀더니 SQL 에러가 나거나 결과가 이상하다.
- 관리자 대시보드가 열릴 때마다 큰 통계 쿼리를 돌려 몇 초씩 걸리고 DB가 버벅인다.
용어
- aggregate(집계) 함수 — 여러 row를 하나의 값으로 요약.
COUNT(개수), SUM(합), AVG(평균), MIN·MAX. 계산은 DB에서 하고 결과만 받는다(현상 1 해결).
- GROUP BY — 같은 값끼리 묶어서 그룹마다 집계.
GROUP BY category → 카테고리별 매출.
- 비유: 영수증 더미를 카테고리별 봉투에 나눠 담고, 봉투마다 합계를 적는 것.
- WHERE vs HAVING — 둘 다 거르지만 시점이 다르다.
- WHERE — 묶기 전에 개별 row를 거름. “2026년 주문만”.
- HAVING — 묶은 후에 그룹을 거름. “주문 5건 이상인 회원”. 집계 결과(
COUNT(*) >= 5)로 거르는 건 HAVING이어야 한다(현상 2).
- view(뷰) — 자주 쓰는 쿼리에 이름을 붙여 테이블처럼 쓰는 것. 실제 데이터를 저장하지 않고 부를 때마다 쿼리를 다시 실행한다. 복잡한 JOIN·필터를 한 곳에 모아둔다(10번 레슨 soft delete 필터를 view로 강제하는 식).
- materialized view(구체화된 뷰) — 쿼리 결과를 실제로 저장해 둔 view. 조회가 즉시 끝나지만, 원본이 바뀌어도 refresh(갱신)하기 전까지 옛 값이다(웹 용어 12번 cache와 같은 성질).
- 비유: view는 매번 새로 계산하는 계산기, materialized view는 아침에 뽑아둔 보고서. 보고서는 빨리 보지만 오후에 바뀐 숫자는 반영 안 돼 있다.
- 대시보드처럼 “몇 분~하루 지연돼도 괜찮은 통계”에 맞다. 주기적으로(cron 등) refresh한다(현상 3 해결).
지시 예문
- “카테고리별 매출을 JS로 합산하고 있어. SQL GROUP BY와 SUM으로 DB에서 계산하게 바꿔줘.”
- “주문 5건 이상인 회원 목록 쿼리 짜줘. 집계 조건이니까 HAVING으로.”
- “관리자 대시보드 통계가 느려. 일별 매출을 materialized view로 만들고 10분마다 refresh하게 해줘.”
- “삭제 안 된 공개 글만 보여주는 view를 만들어서 조회 쿼리들이 이걸 쓰게 해줘.”
검색: LIKE · full-text search · trigram · vector search
현상
- 검색을
LIKE '%키워드%'로 만들었더니 글이 많아질수록 점점 느려진다(5번 레슨: 앞쪽 와일드카드는 index를 못 탄다).
- “아이폰”으로 검색하면 “아이폰15”는 나오는데 “iPhone”이나 오타 “아이퐁”은 안 나온다.
- “추운 날 입기 좋은 옷”으로 검색하면 “패딩”이 안 나온다. 단어는 다르지만 뜻이 같은 걸 찾고 싶다.
용어
- LIKE 검색 — 문자열에 그 글자가 그대로 들어 있는지만 본다. 작은 데이터엔 충분하지만,
%키워드%는 전부 훑어야 해서 커지면 느리다.
- full-text search(전문 검색) — 글을 단어 단위로 쪼개 색인해 두고 단어로 찾는 검색. 관련도 순 정렬도 된다. PostgreSQL의
tsvector·tsquery.
- 한국어 주의: 한국어는 “검색을”, “검색이”처럼 조사가 붙어 단어를 쪼개기 어렵다. PostgreSQL 기본 전문 검색은 한국어 형태소 분석을 지원하지 않아 결과가 부실할 수 있다.
- trigram 검색(pg_trgm) — 글을 세 글자씩 잘게 쪼개 비슷한 정도로 찾는 방식. 부분 일치·오타 허용에 강하고, index를 걸 수 있어
%키워드%도 빨라진다. 한국어 서비스에서 현실적인 첫 선택지.
- 검색 엔진(Elasticsearch, Meilisearch, Algolia, Typesense 등) — 검색만 전문으로 하는 별도 시스템. 형태소 분석, 오타 교정, 동의어, 자동완성, 필터 조합이 강하다. 대신 DB와 데이터를 동기화해야 하는 부담이 생긴다.
- vector search(벡터 검색, 의미 검색) — 글의 뜻을 숫자 배열(embedding)로 바꿔 두고, 뜻이 가까운 것을 찾는다. “추운 날 옷” → “패딩”처럼 단어가 달라도 찾는다(현상 3). PostgreSQL 확장 pgvector, AI 챗봇의 문서 검색(RAG)에 쓰인다.
- 비유: LIKE는 글자 그대로 찾기(Ctrl+F), full-text는 책 뒤 색인, trigram은 오타도 알아듣는 색인, vector search는 “이런 느낌의 책 있어요?”라고 사서에게 묻기.
- 고르는 순서: 데이터 적음 → LIKE / 부분 일치·오타 → trigram / 본격 검색 기능 → 검색 엔진 / 뜻으로 찾기 → vector search. 둘을 섞는 hybrid search도 흔하다.
지시 예문
- “상품 검색이 LIKE ‘%…%’라 느려. pg_trgm 확장 켜고 상품명에 trigram index 걸어서 부분 일치와 오타를 허용해줘.”
- “검색 결과를 관련도순으로 정렬하고 싶어. 한국어 데이터인 걸 고려해서 full-text search와 trigram 중 뭐가 맞는지 비교해줘.”
- “FAQ 문서를 pgvector로 embedding해서, 질문과 뜻이 비슷한 문서를 찾는 vector search 만들어줘.”
DB 종류와 확장: SQL vs NoSQL · Redis · read replica
현상
- AI가 새 프로젝트에 MongoDB를 쓸지 PostgreSQL을 쓸지 물어보는데 기준을 모르겠다.
- 조회수·랭킹·세션처럼 아주 자주 읽고 쓰는 작은 값 때문에 DB 부하가 크다.
- 서비스가 커져 읽기 요청이 DB 한 대로 감당이 안 된다.
용어
- SQL DB(관계형, RDBMS) — 지금까지 배운 테이블·FK·JOIN·트랜잭션·제약 조건의 세계. PostgreSQL, MySQL. 데이터끼리 관계가 많고 정확해야 하는 서비스(회원·주문·결제)의 기본값.
- NoSQL — 테이블이 아닌 다른 모양으로 저장하는 DB를 통틀어 부르는 말.
- document DB(MongoDB, Firestore) — JSON 같은 문서 단위로 저장. 구조를 유연하게 바꾸기 쉽지만, JOIN·FK·트랜잭션이 약하거나 제한적이라 관계가 많은 데이터는 중복·정합성 관리가 앱 몫이 된다(11번 레슨 갱신 이상).
- key-value DB(Redis) — 이름표(key) → 값 한 쌍으로 저장. 아래 참고.
- 기준: 관계가 핵심이면 SQL. 요즘 PostgreSQL은 JSON 컬럼(15번 레슨)·벡터 검색(18번)까지 돼서 일단 PostgreSQL로 시작하는 경우가 많다.
- Redis — 데이터를 메모리에 두는 초고속 key-value 저장소. 디스크 DB보다 훨씬 빠르지만, 기본적으로 메인 DB를 대신하는 원본 저장소로 쓰지 않는다.
- 쓰임새: 캐시(DB 결과 임시 보관, 웹 용어 12번), 세션 저장, 조회수·좋아요 카운터, 실시간 랭킹, rate limit 카운트(웹 용어 28번 429), 작업 대기열(queue).
- 비유: 메인 DB가 창고라면 Redis는 계산대 옆 진열대. 자주 찾는 물건만 손 닿는 곳에 두고, 원본 재고는 창고에 있다.
- read replica(읽기 복제본) — 메인 DB(primary)의 복사본 DB를 두고 읽기 요청을 나눠 받는 것. 쓰기는 primary로만.
- 함정: 복제에 아주 짧은 지연(replication lag)이 있어, 방금 쓴 글을 replica에서 읽으면 잠깐 안 보일 수 있다. “저장 직후 내 글 조회”는 primary에서 읽는다.
- sharding(샤딩) — 데이터를 여러 DB로 쪼개 나눠 담는 것(예: 회원 id 범위별). 아주 큰 규모에서나 필요하고 복잡도가 크게 올라간다. 대부분의 서비스는 index·캐시·replica로 충분하다.
지시 예문
- “이 서비스는 회원·주문·결제 관계가 많아. SQL과 NoSQL 중 뭐가 맞는지 근거와 함께 추천해줘.”
- “글 조회수 증가가 DB에 부하를 줘. Redis 카운터로 받고 5분마다 DB에 합쳐 반영해줘.”
- “로그인 API에 Redis로 rate limit 걸어줘. IP당 1분에 10회.”
- “목록 조회는 read replica로 보내되, 글 저장 직후 상세 조회는 replication lag 때문에 primary에서 읽게 해줘.”