- Postgres 공식 문서는 훌륭하지만 Postgres 17 PDF가 3,200페이지에 달해, 초급자가 실무 전에 스키마 설계·SQL 동작·운영 함정을 모두 문서만으로 익히기 어려움
- 특별한 이유가 없다면 데이터는 정규화하고, 읽기 성능을 위해 중복 데이터를 두는 비정규화는 불일치와 쓰기 복잡도라는 비용을 감수해야 함
- SQL 키워드는 대소문자를 가리지 않지만, NULL은 “알 수 없음” 에 가까워 일반 언어의
null처럼 비교하면 예상과 다른 결과가 나옴 psql은 pager,\x,.psqlrc,\pset null, 자동완성, 백슬래시 명령,\copy만 잘 써도 출력 가독성·탐색·CSV 내보내기가 크게 편해짐- 인덱스·락·트랜잭션·JSONB는 강력하지만, 쿼리 계획과 운영 제약을 모르면 성능 저하나 가용성 문제로 이어질 수 있음
방대한 공식 문서 전에 알아둘 맥락
- Postgres 공식 문서는 현재 버전 17 기준 US letter PDF로 출력하면 3,200페이지이며, A4로 출력하면 3,024페이지임
- Postgres를 쓰기 전에 알면 좋은 실무 지식이 많고, 일부는 다른 SQL DBMS에도 적용될 수 있지만 적용 범위가 항상 확실한 것은 아님
데이터는 기본적으로 정규화하기
- 정규화는 데이터베이스 스키마에서 중복되거나 불필요한 데이터를 제거하는 과정임
documents테이블에user_email을 직접 저장하면 사용자가 이메일을 바꿀 때 해당 사용자의 모든 문서 행을 갱신해야 함- 대신
documents의 각 행이users같은 다른 테이블의 행을user_id외래 키로 참조하게 만들 수 있음
- 대신
- “1st normal form” 같은 각 정규형을 모두 외울 필요는 없지만, 일반적인 정규화 과정은 더 유지보수하기 쉬운 스키마로 이어질 수 있음
- 비정규화는 특정 데이터를 매번 재계산하지 않고 빠르게 읽기 위해 중복 데이터를 두는 방식임
- 직원 교대 근무 앱에서는 올해 누적 근무 시간을 매번 모든 shift duration 합산으로 계산하지 않고, 주기적으로 또는 근무 시간 변경 시 계산해 저장할 수 있음
- 이 데이터는 Postgres 내부에 둘 수도 있고 Redis 같은 캐시 계층에 둘 수도 있음
- 비정규화에는 거의 항상 비용이 따르며, 대표적인 비용은 데이터 불일치 가능성과 쓰기 복잡도 증가임
Postgres 프로젝트의 “하지 말 것” 조언
- 공식 Postgres 위키에는 “Don’t do this” 목록이 있음
- 모든 항목을 이해하지 못해도 괜찮고, 이해하지 못하는 항목은 그 실수를 할 가능성도 낮음
- 특히 다음 조언을 기억할 만함
- 텍스트 저장에는
text타입을 사용하기 - timestamp 저장에는
timestampz/time with time zone을 사용하기 - 테이블 이름은 snake_case로 짓기
- 텍스트 저장에는
SQL에서 헷갈리기 쉬운 동작
-
SQL 키워드는 대문자일 필요 없음
- SQL 키워드는 대소문자를 구분하지 않음
- 다음 쿼리들은 같은 의미임
SELECT * FROM my_table WHERE x = 1 AND y > 2 LIMIT 10; select * from my_table where x = 1 and y > 2 limit 10; SELECT * from my_table WHERE x = 1 and y > 2 LIMIT 10;- 이 특성은 Postgres에만 한정된 것은 아님
-
NULL은 일반 언어의 null/nil과 다름
- SQL의
NULL은 일반 프로그래밍 언어의null이나nil보다 “알 수 없음”에 가까움 NULL = NULL은true가 아니라NULL을 반환함- 한쪽이
NULL인 비교는 대부분 결과도NULL임 NULL비교에는 다음 연산을 사용해야 함x IS NULL:x가NULL이면truex IS NOT NULL:x가NULL이 아니면truex IS NOT DISTINCT FROM y:x = y와 비슷하지만NULL을 일반 값처럼 취급x IS DISTINCT FROM y:x != y/x <> y와 비슷하지만NULL을 일반 값처럼 취급
WHERE절은 조건이true일 때만 행을 반환함SELECT * FROM users WHERE title != 'manager'는title이NULL인 행을 반환하지 않음NULL != 'manager'의 결과가NULL이기 때문임
COALESCE는 여러 인자 중 첫 번째NULL이 아닌 값을 반환함
COALESCE(NULL, 5, 10) = 5 COALESCE(2, NULL, 9) = 2 COALESCE(NULL, NULL) IS NULL - SQL의
psql을 더 유용하게 쓰기
-
출력 가독성 개선
- 컬럼이 많거나 값이 긴 테이블을 조회했을 때 출력이 읽기 어렵다면 pager가 꺼져 있을 수 있음
- 터미널 pager는 큰 텍스트나
psql테이블을 viewport로 스크롤해 볼 수 있게 해줌 - 컬럼이 많은 테이블은
\pset expanded또는\x로 expanded mode를 켤 수 있음 - 기본값으로 쓰고 싶다면 홈 디렉터리의
~/.psqlrc에\x를 추가하면 됨
-
NULL 출력 명확히 하기
- 기본 설정은 출력에서
NULL여부를 명확히 보여주지 않음 psql에서NULL표시 문자열을 지정할 수 있음
\pset null '[NULL]'- Unicode 문자열도 가능하며, 기본값으로 쓰려면
~/.psqlrc에 같은 명령을 추가하면 됨
- 기본 설정은 출력에서
-
자동완성과 백슬래시 명령 활용
psql은 대화형 콘솔처럼 자동완성을 지원함- 키워드나 테이블 이름 일부를 입력한 뒤 Tab을 누르면 나머지를 채울 수 있음
- 유용한 백슬래시 명령은 다음과 같음
\?: 모든 shortcut 목록\d: relation, 즉 테이블과 시퀀스 목록 및 소유자 표시\d+:\d에 크기와 일부 메타데이터 추가\d table_name: 테이블 스키마, 컬럼 타입, nullable 여부, 기본값, 인덱스, 외래 키 제약 표시\e:$EDITOR환경 변수에 설정된 기본 편집기에서 쿼리 편집\h SQL_KEYWORD: 해당 SQL 키워드 문법과 문서 링크 표시
-
CSV 내보내기와 SELECT 별칭
\copy로 쿼리 결과를 CSV로 저장할 수 있음
\copy (select * from some_table) to 'my_file.csv' CSV- 컬럼명을 첫 줄에 포함하려면
HEADER옵션을 추가함
\copy (select * from some_table) to 'my_file.csv' CSV HEADER\copy는 더 표준적인COPY문에 필요한 상승 권한을 피할 수 있음SELECT출력 컬럼은AS로 별칭을 붙일 수 있음
SELECT vendor, COUNT(*) AS number_of_backpacks FROM backpacks GROUP BY vendor ORDER BY number_of_backpacks DESC;GROUP BY와ORDER BY에서는SELECT뒤에 등장한 컬럼 번호를 참조할 수 있음
SELECT vendor, COUNT(*) AS number_of_backpacks FROM backpacks GROUP BY 1 ORDER BY 2 DESC;- 이 축약형은 유용하지만, 프로덕션에 배포되는 쿼리에는 넣지 않는 편이 좋음
인덱스는 추가한다고 항상 쓰이지 않음
-
인덱스와 쿼리 계획
- 인덱스는 테이블 행을 특정 필드 기준으로 찾기 위한 바로가기 디렉터리 역할을 하는 데이터 구조임
- 가장 흔한 인덱스는 B-tree이며,
WHERE a = 3같은 정확한 동등 조건과WHERE a > 5같은 범위 조건에 동작함 - Postgres에 특정 인덱스를 쓰라고 직접 지시할 수는 없음
- Postgres는 각 테이블에 대해 유지하는 통계를 바탕으로, 인덱스가 테이블을 처음부터 끝까지 읽는 sequential scan보다 빠를지 예측함
SELECT ... FROM ...앞에EXPLAIN을 붙이면 Postgres가 쿼리를 어떻게 실행할지에 대한 쿼리 계획을 볼 수 있음- 쿼리 계획을 읽을 때는 thoughtbot의 EXPLAIN ANALYZE 가이드, pganalyze 문서, 공식 문서, explain.depesz.com을 참고할 수 있음
-
작은 테이블과 다중 컬럼 인덱스
- 로컬 개발 DB처럼 행이 적은 테이블에서는 인덱스가 큰 도움이 되지 않을 수 있음
- 100행 정도라면 Postgres가 인덱스보다 sequential scan이 빠르다고 판단할 수 있음
- Postgres는 다중 컬럼 인덱스를 지원함
CREATE INDEX CONCURRENTLY ON tbl (a, b);WHERE a = 1 AND b = 2같은 조건은a와b에 각각 별도 인덱스를 둔 경우보다 빠를 수 있음- 하나의 B-tree를 순회하면서 검색 조건을 효율적으로 결합할 수 있기 때문임
(a, b)인덱스는a만 필터링하는 쿼리도a단독 인덱스만큼 빠르게 함WHERE b = 5같은 쿼리는 빨라질 수도 있지만 최선은 아닐 수 있음- 인덱스가 먼저
a, 그다음b로 키가 잡혀 있어 모든a값을 거쳐b값을 찾아야 함
- 인덱스가 먼저
- 여러 컬럼 조합으로 쿼리해야 한다면
(a, b)와b단독 인덱스를 함께 두는 경우가 많음 - 필요에 따라
a,b각각의 단독 인덱스에 의존할 수도 있음
-
prefix match에는 text_pattern_ops 사용
- materialized path 방식으로 계층형 디렉터리를 저장하고, 특정 prefix로 시작하는 모든 descendant를 찾아야 할 수 있음
SELECT * FROM directories WHERE path LIKE '/1/2/3/%'path컬럼에 기본 B-tree 인덱스를 만들어도 이 쿼리에 사용되지 않을 수 있음
CREATE INDEX CONCURRENTLY ON directories (path);- prefix match나 pattern match에 필요한 문자 단위 정렬을 가능하게 하려면 operator class를 지정해야 함
CREATE INDEX CONCURRENTLY ON directories (path text_pattern_ops);
락과 트랜잭션이 만드는 운영 문제
-
Postgres의 락
- 락(lock) 또는 mutex는 위험한 작업을 한 번에 한 클라이언트만 수행하게 하는 장치임
- 데이터베이스에서 row, table, view 같은 개체 업데이트는 전체가 성공하거나 전체가 실패해야 하며, 동시 작업으로 일부만 성공하는 상황을 막기 위해 관련 개체에 락을 획득함
- Postgres의 테이블 락 수준은 덜 제한적인 것부터 더 제한적인 것까지 여러 단계가 있음
ACCESS SHARE:SELECTROW SHARE:SELECT ... FOR UPDATEROW EXCLUSIVE:UPDATE,DELETE,INSERTSHARE UPDATE EXCLUSIVE:CREATE INDEX CONCURRENTLYSHARE:CREATE INDEX, 단CONCURRENTLY아님ACCESS EXCLUSIVE: 많은 형태의ALTER TABLE,ALTER INDEX
- 한 테이블에서 다음 동작은 가능하거나 대기해야 함
UPDATE중SELECT: 가능UPDATE중CREATE INDEX CONCURRENTLY: 가능SELECT중CREATE INDEX: 가능SELECT중ALTER TABLE: 일반적으로 대기ALTER TABLE중SELECT: 일반적으로 대기
- 일부
ALTER TABLE형태는 더 약한 락을 요구할 수 있으며, 전체 정보는 공식 명시적 락 문서와 operation별 락 충돌 가이드에서 확인할 수 있음
-
느린 ALTER TABLE과 락 대기열
ALTER TABLE이 오래 걸리면 같은 테이블을 읽는SELECT도 막힐 수 있음- 웹 앱의 모든 요청이 참조하는
users같은 핵심 테이블이면 요청이 대기하다 timeout되고 503을 반환할 수 있음 - 느린
ALTER TABLE의 흔한 원인은 다음과 같음- non-constant default가 있는 컬럼 추가
- 컬럼 타입 변경
- uniqueness constraint 추가
- Postgres 11 이후에는 컬럼 추가 시 모든 default가 느리게 만드는 문제는 수정됐으며, non-constant default가 문제가 될 수 있음
ALTER TABLE자체가 빠른 작업이어도 락을 얻기 전까지는 실행되지 않음- 예전부터 있던 내부 대시보드의 느린
SELECT가 실행 중이면ALTER TABLE이 기다려야 함
- 예전부터 있던 내부 대시보드의 느린
- Postgres 락은 대기열을 만들기 때문에, 대기 중인
ALTER TABLE뒤에 들어온 같은 테이블의 후속 쿼리들도 기다릴 수 있음 - 같은 시나리오는 Migrations and exclusive locks에서 더 살펴볼 수 있음
-
장기 트랜잭션도 위험함
- 트랜잭션은 여러 데이터베이스 문을 all-or-nothing으로 묶는 방식이며,
BEGIN으로 시작하고COMMIT으로 끝냄 - 트랜잭션 중인 변경은 다른 클라이언트에 보이지 않으며,
COMMIT시 데이터베이스에 공개됨 - 송금처럼 한 계좌 잔액 감소와 다른 계좌 잔액 증가가 함께 성공하거나 함께 취소돼야 하는 작업에 적합함
- 트랜잭션이 락을 얻으면
COMMIT까지 락을 유지함 BEGIN후 특정 row를UPDATE하고 자리를 비우면, 다른 클라이언트의 해당 rowDELETE는 트랜잭션이 commit될 때까지 멈춰 있음- 필요 이상으로 오래 열린 트랜잭션은 다른 클라이언트의 쿼리나 업데이트를 막을 수 있음
- 트랜잭션은 여러 데이터베이스 문을 all-or-nothing으로 묶는 방식이며,
JSONB는 날카로운 도구
-
JSONB의 성능과 스키마 문제
- JSONB는 유연하지만 잘못 쓰면 단점이 큼
- Postgres는 JSONB 컬럼의 통계를 추적하지 않으므로, 단일 JSONB 컬럼에 대한 동등한 쿼리가 일반 컬럼 집합에 대한 쿼리보다 훨씬 느릴 수 있음
- 한 사례에서는 JSONB 때문에 2000배 느려지는 예를 볼 수 있음
- JSONB 컬럼에는 사실상 무엇이든 들어갈 수 있어 강력하지만, 구조에 대한 보장은 적음
- 일반 테이블은 스키마를 보고 쿼리 결과를 예측할 수 있지만, JSONB는 key 이름이 camelCase인지 snake_case인지, 상태가 boolean인지 enum인지 확실하지 않음
- 일반 Postgres 데이터가 가지는 정적 타입 특성이 JSONB에는 같은 방식으로 적용되지 않음
-
JSONB 타입 비교의 어색함
backpacks테이블의 JSONB 컬럼data에서brand필드가JanSport인 행을 찾으려 할 때 다음 쿼리는 동작하지 않음
select * from backpacks where data['brand'] = 'JanSport';- Postgres는 비교의 오른쪽 타입이 왼쪽 타입과 맞기를 기대하며, 오른쪽은 올바른 JSON 문서여야 함
- JSON 문서는 객체, 배열, 문자열, 숫자, boolean, null이어야 하므로
JanSport단독은 유효한 JSON이 아님 - 올바른 쿼리는 JSON 문자열로 비교하거나, 왼쪽을 Postgres
text로 변환하는 방식임
select * from backpacks where data['brand'] = '"JanSport"'; select * from backpacks where data['brand'] = '"JanSport"'::jsonb; select * from backpacks where data->>'brand' = 'JanSport';- SQL의
NULL과 JSONB의null은 다르게 동작함'null'::jsonb = 'null'::jsonb는true지만,NULL = NULL은NULL임
- JSONB에는 전용 연산자와 함수가 많아 한 번에 기억하기 어려움
- Postgres에는 JSON 값을 텍스트로 저장하는
JSON과 효율적인 바이너리 형식으로 변환하는JSONB가 모두 있음 - JSONB는 인덱싱 가능 같은 장점이 있으며, JSON 형식은 특수한 경우로 볼 수 있음