재편집 전 원문 백업 | SECTION 11 | 2026-10-14
PostgreSQL에서는 SQL 문법이 같아 보여도 자료형·NULL·파티션·트랜잭션·MVCC의 동작이 다를 수 있다.
개발 단계에서 이런 특성을 모르고 작성한 SQL은 성능뿐 아니라 결과 정확성·동시성·운영 안정성 문제를 만들 수 있다.
이번 SECTION은 SECTION 01. SQL REMINDER 소개과 원본 교재의 PostgreSQL 전용 실습을 사용한다. SECTION 02~10이 검색·JOIN·집계 작성법을 다뤘다면, 여기서는 PostgreSQL로 개발할 때 별도로 기억해야 하는 동작을 다룬다.
실행 여부와 근거
•
아래 예상 실행계획은 실측 결과가 아닌 개념적 형태다. cost, actual rows, 실행시간, Buffers 등의 값을 임의로 만들지 않는다.
•
오류·트랜잭션 격리·잠금·COMMIT은 일반적인 EXPLAIN으로 확인할 수 없으므로 예상 오류/세션 동작을 별도로 적는다.
•
tr_ord_big_p는 실습 교재에서 별도로 생성한 ord_dtm RANGE 파티션 테이블이다. tr_ord_big과 이름·구조를 혼동하지 않는다.
•
원본 교재에서 직접 확인되는 실습과 PostgreSQL 17 공식 문서를 근거로 보강한 내용을 항목별로 구분한다. DML·DDL·잠금 실습 코드는 운영 DB에서 그대로 실행하지 않는다.
이번 섹션의 핵심 규칙
ID | 구분 | 기억할 규칙 |
11-01 | MUST | 문자형 컬럼을 숫자와 무심코 비교하지 말자 |
11-02 | MUST | PostgreSQL DESC 정렬에서 NULL은 기본적으로 앞에 온다 |
11-03 | MUST | 파티션 키를 검색 가능한 범위 조건으로 작성하자 |
11-04 | CHECK | 파티션 키 없는 조인의 반복 접근을 확인하자 |
11-05 | MUST | READ COMMITTED에서는 문장마다 읽는 시점이 달라질 수 있다 |
11-06 | MUST | 트랜잭션 오류 후에는 ROLLBACK 처리를 명확히 하자 |
11-07 | MUST | SELECT 후 열린 트랜잭션도 DDL을 기다리게 할 수 있다 |
11-08 | TIP | FOR UPDATE의 잠금 시간과 NOWAIT·SKIP LOCKED를 구분하자 |
11-09 | MUST | 불필요한 UPDATE로 Dead Tuple을 늘리지 말자 |
11-10 | CHECK | 통계와 AUTOVACUUM 상태를 점검하자 |
MUST: 결과 정확성·안정성을 위한 기본 규칙 / TIP: 필요한 업무에 적용할 기능 / CHECK: 실제 DB 상태와 계획 확인 필요
REMINDER 11-01. 문자형 컬럼을 숫자와 무심코 비교하지 말자
MUST · 상황: 실습에서 추가한 tr_ord_big.ord_ymd는 8자리 문자형 날짜(YYYYMMDD)다. 특정 일자의 주문 건수를 조회한다.
BAD — 자료형이 다른 숫자 리터럴과 직접 비교
SELECT COUNT(*) AS ord_cnt
FROM tr_ord_big AS o
WHERE o.ord_ymd = 20230401;
SQL
복사
BAD 우회 — 오류를 피하려고 컬럼을 numeric으로 캐스팅
SELECT COUNT(*) AS ord_cnt
FROM tr_ord_big AS o
WHERE o.ord_ymd::numeric = 20230401;
SQL
복사
GOOD — 컬럼과 맞는 자료형을 사용
SELECT COUNT(*) AS ord_cnt
FROM tr_ord_big AS o
WHERE o.ord_ymd = '20230401';
SQL
복사
예상 오류 및 실행계획
BAD 1: PostgreSQL에서 varchar = integer 비교 연산자를
찾지 못해 SQL 오류가 발생할 수 있음 (계획 없음)
BAD 2 (ord_ymd 일반 B-tree 인덱스가 있어도 가능한 형태)
Aggregate
-> Index Only Scan / Seq Scan on tr_ord_big
Filter: ((ord_ymd)::numeric = 20230401)
→ 원래 ord_ymd 인덱스의 직접 검색 조건이 아님
GOOD (tr_ord_big_x05(ord_ymd) 선택 시)
Aggregate
-> Index Only Scan using tr_ord_big_x05
Index Cond: (ord_ymd = '20230401')
Plain Text
복사
기억할 이유: PostgreSQL은 문자 컬럼과 숫자 리터럴의 비교를 다른 DBMS처럼 항상 암묵적 변환으로 처리하지 않는다. 비교 값 쪽을 컬럼의 자료형과 맞추고, 인덱스 컬럼을 불필요하게 CAST하지 않는다.
주의: ord_ymd는 원본 ERD에는 표시되지 않고, 튜닝 연습을 위해 추가된 반정규화 컬럼이다. 일반적인 날짜 모델에서 문자를 저장하라는 권장이 아니다. 상세한 WHERE 컬럼 가공 규칙은 SECTION 03에서 배웠다.
교재 근거: PDF p.143, SQL-XPERF3-VARCHAR, SQL-CRIX-TR_ORD_BIG_X05.
REMINDER 11-02. DESC에서 NULLS LAST를 명시하자
MUST · 상황: 회원 M00019의 주문 중 가장 최근 픽업일시가 존재하는 주문을 한 건 보여준다.
BAD — NULL이 있으면 NULL 행이 맨 앞에 올 수 있음
SELECT o.ord_no, o.pkup_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019'
ORDER BY o.pkup_dtm DESC, o.ord_no DESC
LIMIT 1;
SQL
복사
GOOD — 실제 픽업일시가 있는 주문만 조회
SELECT o.ord_no, o.pkup_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019'
AND o.pkup_dtm IS NOT NULL
ORDER BY o.pkup_dtm DESC NULLS LAST, o.ord_no DESC
LIMIT 1;
SQL
복사
예상 실행계획과 결과 차이
BAD / GOOD 모두 가능한 기본 형태
Limit
-> Sort / Index Scan on tr_ord_big
Sort Key: pkup_dtm DESC [NULLS FIRST 또는 LAST], ord_no DESC
→ 정렬을 지원하는 인덱스가 없다면 Sort 발생
결과 차이
BAD: NULL pkup_dtm 주문을 최신 1건으로 선택할 수 있음
GOOD: 실제 픽업일시가 있는 주문 중 최신 1건 선택
Plain Text
복사
기억할 이유: PostgreSQL에서 기본 DESC 정렬의 NULL 위치는 NULLS FIRST다. SQL을 읽는 개발자가 가장 늦은 값으로 착각하지 않도록 NULLS LAST를 명시하고, 업무상 NULL을 제외해야 하면 IS NOT NULL도 넣는다.
주의: GOOD은 업무 요구사항이 픽업일시가 실제로 존재하는 주문일 때만 맞다. NULL 주문도 목록에 필요하다면 IS NOT NULL을 임의로 추가하면 안 된다. NULLS LAST를 명시한 인덱스와 정렬 방향이 다르면 Sort가 발생할 수 있다.
자료 구분: BIG 모델의 pkup_dtm 컬럼은 PDF p.5에서 확인된다. DESC의 기본 NULL 정렬 규칙은 PostgreSQL 17 ORDER BY 공식 문서 기반 추가 보강 내용이다.
REMINDER 11-03. 파티션 키를 조건에 직접 사용하자
MUST · 상황: 월별 ord_dtm RANGE 파티션 테이블 tr_ord_big_p에서 2024년 3월 카드 결제 주문만 집계한다.
BAD — 파티션 키를 TO_CHAR로 가공
SELECT COUNT(*) AS ord_cnt
FROM tr_ord_big_p AS o
WHERE TO_CHAR(o.ord_dtm, 'YYYYMM') = '202403'
AND o.pay_tp = 'CARD';
SQL
복사
GOOD — 파티션 키의 반열린 날짜 범위로 작성
SELECT COUNT(*) AS ord_cnt
FROM tr_ord_big_p AS o
WHERE o.ord_dtm >= TIMESTAMP '2024-03-01'
AND o.ord_dtm < TIMESTAMP '2024-04-01'
AND o.pay_tp = 'CARD';
SQL
복사
예상 실행계획
BAD (파티션 가지치기가 어려운 경우)
Aggregate
-> Append
-> Seq Scan / Index Scan on tr_ord_big_p_p_min
-> Seq Scan / Index Scan on tr_ord_big_p_p_202301
-> ... 여러 월별 파티션 ...
Filter: (TO_CHAR(ord_dtm, 'YYYYMM') = '202403')
GOOD (실제 월별 파티션이 정의되어 있을 때)
Aggregate
-> Seq Scan / Index Scan on 2024년 3월 파티션
Filter: (pay_tp = 'CARD')
또는 ord_dtm을 활용한 Index Cond
→ 다른 월 파티션을 제외(Partition Pruning)할 수 있음
Plain Text
복사
기억할 이유: RANGE 파티션을 잘라 읽으려면 파티션 키의 비교 범위가 SQL에 명확해야 한다. TO_CHAR(ord_dtm,...)가 있으면 PostgreSQL이 파티션 경계를 사용해 제외하기 어렵다.
주의: tr_ord_big_p는 PDF p.312에서 PARTITION BY RANGE (ord_dtm)으로 생성하는 실습 테이블이다. 파티션 이름, 실제 생성된 월, 인덱스와 데이터 분포에 따라 노드 이름은 달라진다. 한 파티션의 대부분을 읽는다면 GOOD에서도 그 파티션의 Seq Scan이 올바른 선택일 수 있다.
교재 근거: PDF p.312~313, SQL-파티션-생성, SQL-파티션-조회1. 컬럼 TO_CHAR 가공은 PDF p.142의 인덱스 사례를 파티션 검색에 적용한 교육용 비교다.
REMINDER 11-04. 파티션 키 없는 조인에서는 반복 접근을 확인하자
CHECK · 상황: PLAT 회원의 전체 기간 주문금액을 회원별로 합산한다. 업무 요구상 기간 조건이 없으므로 임의 날짜 조건을 추가할 수 없다.
BAD 후보 — 회원을 Driving으로 NL JOIN해 파티션을 반복 조회하는 상황
SELECT m.mbr_id, SUM(o.ord_amt) AS ord_amt
FROM ms_mbr_big AS m
JOIN tr_ord_big_p AS o
ON o.mbr_id = m.mbr_id
WHERE m.mbr_gd = 'PLAT'
AND m.join_tp = 'INV'
AND m.join_dtm < TIMESTAMP '2022-05-01'
GROUP BY m.mbr_id;
SQL
복사
GOOD 비교 후보 — 전체 주문을 한 번 집계한 뒤 필요한 회원과 JOIN
SELECT m.mbr_id, x.ord_amt
FROM ms_mbr_big AS m
JOIN (
SELECT o.mbr_id, SUM(o.ord_amt) AS ord_amt
FROM tr_ord_big_p AS o
GROUP BY o.mbr_id
) AS x ON x.mbr_id = m.mbr_id
WHERE m.mbr_gd = 'PLAT'
AND m.join_tp = 'INV'
AND m.join_dtm < TIMESTAMP '2022-05-01';
SQL
복사
예상 실행계획 — 둘 다 모든 월 파티션을 대상으로 한다
BAD (회원 Driving NL JOIN을 선택한 경우)
HashAggregate
-> Nested Loop
-> ms_mbr_big 회원 필터
-> Append
-> Index Scan on 여러 월 파티션 (mbr_id 조건)
→ 파티션 키 제한이 없어 다수 파티션에 반복 접근 가능
GOOD (선집계가 먼저 수행되는 경우)
Hash Join
-> 회원 필터 집합
-> Hash
-> HashAggregate (mbr_id)
-> Append
-> 모든 월별 파티션을 한 번씩 스캔
→ 파티션 전체를 읽지만 반복 NL 접근을 피할 가능성
Plain Text
복사
기억할 이유: tr_ord_big_p가 ord_dtm을 기준으로 나뉘어 있는데 조인 조건이 mbr_id뿐이라면 해당 회원의 행이 여러 파티션에 흩어져 있을 수 있다. NL의 Driven에 파티션 테이블이 있으면 월별 인덱스를 여러 번 확인할 수 있다.
주의·예외: BAD SQL 텍스트 자체가 틀렸다는 의미는 아니다. PostgreSQL은 BAD에서도 Hash Join을 선택하거나 각 파티션 접근을 합리적으로 계획할 수 있다. GOOD 역시 회원 집합이 극히 적으면 불필요한 전량 집계가 더 비쌀 수 있다. 원본 PDF는 pg_hint_plan으로 NL을 강제한 특수 실습이며 기본 옵티마이저의 실제 결과라고 해석하면 안 된다. 전체 기간 업무에 성능을 이유로 날짜 조건을 임의로 추가해서도 안 된다.
교재 근거: PDF p.314, SQL-파티션-조회2. 선집계 GOOD은 원본의 직접 비교 SQL이 아니라 반복 파티션 접근 위험을 설명하기 위한 교육용 대안이다.
REMINDER 11-05. READ COMMITTED에서는 문장마다 결과가 달라질 수 있다
MUST · 상황: 트랜잭션 안에서 같은 회원의 상태를 두 번 조회한다. 그 사이 다른 세션이 회원 상태를 변경·COMMIT한다.
BAD — BEGIN만 하면 모든 SELECT가 같은 시점의 데이터를 본다고 가정
-- Session A: 하나의 트랜잭션
BEGIN;
SHOW TRANSACTION ISOLATION LEVEL; -- 보통 read committed
SELECT mbr_st FROM ms_mbr_big WHERE mbr_id = 'M00019';
-- Session B가 같은 회원 상태를 변경하고 COMMIT한 뒤
SELECT mbr_st FROM ms_mbr_big WHERE mbr_id = 'M00019';
COMMIT;
SQL
복사
GOOD — 일관된 스냅샷이 필요한 업무에서는 격리 수준을 먼저 결정
-- Session A: 동일한 트랜잭션 안에서 일관된 스냅샷 필요
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT mbr_st FROM ms_mbr_big WHERE mbr_id = 'M00019';
-- 그 사이 Session B가 변경하고 COMMIT했다고 가정
SELECT mbr_st FROM ms_mbr_big WHERE mbr_id = 'M00019';
COMMIT;
SQL
복사
예상 동작 — 실행계획 차이가 핵심이 아님
BAD (READ COMMITTED)
첫 SELECT: 해당 문장 시작 시 보이는 COMMIT 데이터 조회
다음 SELECT: 그다음 문장 시작 시의 새 스냅샷 사용
→ Session B가 그 사이 COMMIT했다면 값이 달라질 수 있음
GOOD (REPEATABLE READ)
같은 트랜잭션에서 일반 SELECT는 동일 스냅샷을 기준으로 읽음
→ 그 사이 다른 트랜잭션이 COMMIT한 변경을 보지 않을 수 있음
두 경우의 예상 조회 노드
Index Scan on ms_mbr_big (mbr_id = 'M00019') 또는 Seq Scan
→ 실행계획만 봐서는 스냅샷 차이를 판정할 수 없음
Plain Text
복사
기억할 이유: PostgreSQL의 기본 격리 수준은 READ COMMITTED다. 한 트랜잭션 안이라도 서로 다른 SQL 문장 사이에 다른 세션의 COMMIT이 개입할 수 있다.
주의·예외: REPEATABLE READ를 모든 업무에 적용하라는 권고는 아니다. 동시 수정·충돌 처리, 재시도 로직, 잠금 유지 시간까지 고려해야 한다. 애플리케이션 커넥션 풀에서는 트랜잭션 상태와 격리 수준의 시작 위치가 중요하다. 위 Session B의 변경은 설명을 위한 가정이며 이 페이지에서는 UPDATE를 실행하지 않았다.
교재 근거: PDF p.318~320(격리 수준·자동 COMMIT). 상세한 스냅샷 설명은 PostgreSQL 17 Transaction Isolation 공식 문서를 근거로 보강했다.
REMINDER 11-06. 트랜잭션에서 오류가 나면 ROLLBACK 처리를 하자
MUST · 상황: 수동 트랜잭션으로 여러 SQL을 실행하다 한 문장에서 오류가 발생한다.
BAD — 실패 후 같은 트랜잭션에서 다음 SQL을 그대로 실행
-- 오류 동작을 설명하기 위한 데모. 그대로 실행하면 에러 발생.
BEGIN;
SELECT 1 / 0; -- division by zero
SELECT COUNT(*) FROM ms_mbr_big; -- 트랜잭션 오류 상태
COMMIT;
SQL
복사
GOOD — 오류를 처리하고 트랜잭션을 종료한 뒤 재시도
-- 애플리케이션의 예외 처리 흐름을 SQL로 표현
BEGIN;
-- 여러 SELECT / DML을 실행한다.
-- 중간에 오류가 발생했다면:
ROLLBACK;
-- 필요한 경우 새 트랜잭션으로 처음부터 재시도
BEGIN;
SELECT COUNT(*) FROM ms_mbr_big;
COMMIT;
SQL
복사
예상 오류·동작
BAD
SELECT 1 / 0
ERROR: division by zero
그 뒤 같은 트랜잭션의 SELECT
ERROR: current transaction is aborted,
commands ignored until end of transaction block
COMMIT 요청은 실패한 트랜잭션을 성공 상태로 되돌리지 못함
ROLLBACK으로 실패한 트랜잭션을 끝내야 함
GOOD
ROLLBACK -> 오류 트랜잭션 종료
새 BEGIN -> 정상적인 새 트랜잭션 시작 가능
Plain Text
복사
기억할 이유: 명시적으로 시작한 PostgreSQL 트랜잭션에서 문장 오류가 발생하면 이후 SQL을 계속할 수 없는 실패 상태가 된다. 연결을 계속 쓰는 애플리케이션은 예외 처리에서 ROLLBACK을 보장해야 한다.
주의·예외: 모든 중간 오류에서 전체 거래를 취소해야 하는지는 업무 정책에 따른다. 부분 복구가 명확히 필요한 경우 사전에 SAVEPOINT를 두고 ROLLBACK TO SAVEPOINT하는 방식도 있다. DBeaver의 Manual Commit과 애플리케이션 드라이버의 자동 COMMIT은 다르게 설정될 수 있다.
교재 근거: PDF p.316(에러가 발생해도 자동 ROLLBACK이 수행되지 않음), p.320(자동 COMMIT). 오류 코드·실패 상태의 설명은 PostgreSQL 일반 트랜잭션 동작을 교육용으로 구체화했다.
REMINDER 11-07. SELECT 뒤에 열린 트랜잭션도 DDL을 막을 수 있다
MUST · 상황: 세션 A가 트랜잭션을 시작하고 조회만 한 채 방치했다. 세션 B는 같은 테이블에 컬럼을 추가하려 한다.
BAD — 조회 후 트랜잭션을 종료하지 않음
-- 설명용 별도 실습 테이블(sql_reminder_tx_demo)이
-- 이미 생성되어 있다고 가정. 운영 테이블 사용 금지!
-- Session A
BEGIN;
SELECT id FROM sql_reminder_tx_demo LIMIT 1;
-- COMMIT/ROLLBACK 없이 트랜잭션을 열어 둠
-- Session B (별도 연결)
ALTER TABLE sql_reminder_tx_demo ADD COLUMN note text;
-- Session A가 종료될 때까지 대기할 수 있음
SQL
복사
GOOD — 트랜잭션 범위를 필요한 만큼만 유지
-- Session A
BEGIN;
SELECT id FROM sql_reminder_tx_demo LIMIT 1;
COMMIT; -- 조회 완료 후 종료
-- Session B (Session A 종료 후)
BEGIN;
ALTER TABLE sql_reminder_tx_demo ADD COLUMN note text;
COMMIT; -- DDL도 트랜잭션에 포함됨
SQL
복사
예상 잠금 동작
BAD
Session A의 SELECT
→ 테이블 ACCESS SHARE 락을 트랜잭션 종료까지 유지
Session B의 ALTER TABLE
→ ACCESS EXCLUSIVE 락 요청이 충돌하여 WAIT 가능
GOOD
Session A COMMIT → 해당 테이블 락 해제
Session B ALTER TABLE → 다른 충돌 락이 없으면 진행
Session B COMMIT → DDL 변경 확정
Plain Text
복사
기억할 이유: PostgreSQL에서는 명시적 트랜잭션 안의 SELECT도 락을 보유할 수 있다. 또한 일반적인 ALTER TABLE 등 DDL도 트랜잭션과 함께 COMMIT/ROLLBACK된다. 조회와 DDL을 같은 연결에서 장시간 묶지 않는다.
주의: 위 예제는 임시로 만든 별도 테스트 테이블에만 적용할 시나리오다. 실제 운영 BIG 테이블에 ALTER를 실행하지 않는다. 모든 DDL이 똑같은 락을 요구하는 것은 아니며, CREATE INDEX CONCURRENTLY처럼 일반 트랜잭션 블록에서 실행할 수 없는 명령도 있다. DDL의 필요 락은 개별 명령마다 확인한다.
교재 근거: PDF p.329, SQL-TRAN-DDL-S1/S2. 실제 교재는 m_acc 테이블의 SELECT 후 COMMIT 누락과 ALTER 대기를 두 세션에서 실습한다.
REMINDER 11-08. FOR UPDATE 잠금의 대기 정책을 명시하자
TIP · 상황: 다른 세션이 회원 한 건을 변경하고 있을 때 행 잠금을 획득하려 한다.
BAD — Oracle식 WAIT 5 구문을 PostgreSQL에 그대로 사용
SELECT m.mbr_id, m.mbr_st
FROM ms_mbr_big AS m
WHERE m.mbr_id = 'M00019'
FOR UPDATE WAIT 5; -- PostgreSQL에서 지원하지 않는 구문
SQL
복사
GOOD — PostgreSQL lock_timeout과 FOR UPDATE 사용
-- 독립된 실습 트랜잭션에서만 실행
BEGIN;
SET LOCAL lock_timeout = '5s';
SELECT m.mbr_id, m.mbr_st
FROM ms_mbr_big AS m
WHERE m.mbr_id = 'M00019'
FOR UPDATE;
COMMIT;
SQL
복사
대기 정책에 따라 선택할 다른 방식
-- 즉시 락을 얻지 못하면 오류 (NOWAIT)
SELECT m.mbr_id FROM ms_mbr_big AS m
WHERE m.mbr_id = 'M00019'
FOR UPDATE NOWAIT;
-- 이미 잠긴 행을 건너뛰고 다음 행을 처리 (SKIP LOCKED)
SELECT m.mbr_id FROM ms_mbr_big AS m
WHERE m.mbr_st = 'INAC'
FOR UPDATE SKIP LOCKED;
SQL
복사
예상 오류·실행계획
BAD: FOR UPDATE WAIT 5는 PostgreSQL 문법 오류
GOOD (잠금을 얻는 정상 경우의 가능한 형태)
LockRows
-> Index Scan / Seq Scan on ms_mbr_big
Index Cond 또는 Filter: (mbr_id = 'M00019')
다른 세션이 해당 행을 잠근 상태라면
- 기본 FOR UPDATE : 잠금 해제까지 기다릴 수 있음
- lock_timeout : 설정된 대기 시간 초과 시 오류
- NOWAIT : 즉시 오류
- SKIP LOCKED : 잠긴 행을 반환 대상에서 제외
Plain Text
복사
기억할 이유: 행 잠금은 DB에서 대기 시간을 만들 수 있다. FOR UPDATE가 언제 시작되는지, 어떤 잠금을 기다릴지, 실패·재시도 정책은 무엇인지 애플리케이션에서 명확하게 결정한다.
주의: SKIP LOCKED는 처리 대기열처럼 다른 작업자가 잡은 행을 건너뛰어도 되는 업무에 적합하다. 계좌 잔액·권한 확인처럼 반드시 특정 행을 읽어야 하는 조회에 쓰면 결과를 누락할 수 있다. SET LOCAL lock_timeout은 현재 트랜잭션에만 적용된다. 잠금 타임아웃으로 SQL 오류가 발생했다면 트랜잭션 롤백 처리가 필요하다. 예제는 실제 잠금 측정을 수행하지 않았다.
교재 근거: PDF p.327~328, SQL-TRAN-FOR-S1/S2, FOR UPDATE NOWAIT, SKIP LOCKED, lock_timeout.
REMINDER 11-09. 불필요한 UPDATE를 줄여 Dead Tuple을 최소화하자
MUST · 상황: 매일 실행되는 배치가 이미 ACTV인 회원까지 동일한 상태로 UPDATE한다.
BAD — 값이 같아도 대상 회원을 UPDATE
-- 설명용 SQL. 실제 BIG 테이블에 실행하지 않는다.
UPDATE ms_mbr_big AS m
SET mbr_st = 'ACTV'
WHERE m.mbr_id = 'M00019';
SQL
복사
GOOD — 실제 값이 달라야만 UPDATE
UPDATE ms_mbr_big AS m
SET mbr_st = 'ACTV'
WHERE m.mbr_id = 'M00019'
AND m.mbr_st IS DISTINCT FROM 'ACTV';
SQL
복사
예상 실행계획 및 변경 동작
BAD / GOOD 모두 가능한 DML 기본 형태
Update on ms_mbr_big
-> Index Scan / Seq Scan on ms_mbr_big
Index Cond 또는 Filter: (mbr_id = 'M00019')
BAD: 이미 ACTV이더라도 PostgreSQL UPDATE는
기존 행 버전을 대체하는 새 행 버전을 만들 수 있음
GOOD: 현재 상태가 ACTV라면 0건 UPDATE
실제 상태가 다르면 해당 행을 UPDATE
→ 불필요한 새 행 버전·WAL·인덱스 유지 작업을 줄일 가능성
Plain Text
복사
기억할 이유: PostgreSQL은 MVCC에 따라 UPDATE 시 새로운 행 버전을 생성하고, 더는 필요 없는 예전 버전은 VACUUM의 정리 대상이 될 수 있다. 애플리케이션에서 변경할 필요 없는 행까지 UPDATE하지 않는 습관이 중요하다.
주의·예외: 이 변경은 최종 mbr_st 값이 같더라도 UPDATE 건수, UPDATE 트리거 실행 여부, 감사 로그, 갱신시각 등 부수 효과가 달라질 수 있다. 이런 동작까지 업무 요구사항에 포함된다면 단순히 변경 조건을 붙여서는 안 된다. IS DISTINCT FROM은 NULL도 비교 대상으로 처리한다. Dead Tuple이 자동으로 즉시 제거된다는 뜻이 아니며, 오래 유지되는 트랜잭션은 VACUUM 정리를 지연시킬 수 있다.
교재 근거: PDF p.331(Update/DELETE와 Dead Tuple), p.336~337(UPDATE에 의한 용량 변화와 Dead Tuple 최소화). IS DISTINCT FROM을 이용한 무변경 UPDATE 생략 예제는 일반 PostgreSQL 기능을 적용한 교육용 보강이다.
REMINDER 11-10. 통계·AUTOVACUUM 상태를 확인하자
CHECK · 상황: 회원상태 INAC는 매우 적고 ACTV는 많다. 같은 인덱스가 있는데도 두 조건의 실행계획이 다르다.
BAD — 값의 분포를 무시하고 '인덱스가 있으니 항상 Index Scan'이라고 판단
-- 같은 인덱스 ms_mbr_big_x11(mbr_st)을 전제
SELECT m.mbr_gd, COUNT(*) AS cnt
FROM ms_mbr_big AS m
WHERE m.mbr_st = 'ACTV'
GROUP BY m.mbr_gd;
SQL
복사
GOOD — 통계·유지보수 상태를 확인한 다음 SQL 계획 비교
-- 값의 분포를 확인 (교재의 pg_stats 예제)
SELECT tablename, attname, n_distinct,
most_common_vals, most_common_freqs
FROM pg_stats
WHERE schemaname = current_schema()
AND tablename = 'ms_mbr_big'
AND attname = 'mbr_st';
-- VACUUM / ANALYZE 상황을 확인
SELECT relname, n_live_tup, n_dead_tup,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = current_schema()
AND relname = 'ms_mbr_big';
-- 실제 실행하지 않고 두 조건의 예상 계획만 비교
EXPLAIN (COSTS OFF)
SELECT m.mbr_gd, COUNT(*) AS cnt
FROM ms_mbr_big AS m
WHERE m.mbr_st = 'INAC'
GROUP BY m.mbr_gd;
EXPLAIN (COSTS OFF)
SELECT m.mbr_gd, COUNT(*) AS cnt
FROM ms_mbr_big AS m
WHERE m.mbr_st = 'ACTV'
GROUP BY m.mbr_gd;
SQL
복사
예상 실행계획 — 실제 교재의 분포 상황을 전제로 한 가능성
INAC (극히 일부)
HashAggregate / GroupAggregate
-> Index Scan using ms_mbr_big_x11
Index Cond: (mbr_st = 'INAC')
ACTV (대부분)
HashAggregate / GroupAggregate
-> Seq Scan on ms_mbr_big
Filter: (mbr_st = 'ACTV')
이 차이는 인덱스의 존재 유무보다
값 분포·통계·읽어야 할 비율에 의해 발생할 수 있음
Plain Text
복사
통계가 오래되었거나 대량 데이터를 적재한 경우에 검토할 관리 작업
-- 아래 명령은 실제 통계를 갱신한다.
-- 운영 권한과 배포 절차를 확인한 뒤 담당자와 협의하여 사용
ANALYZE ms_mbr_big;
-- 자동 관리 기능의 활성 상태 확인
SHOW autovacuum;
SQL
복사
기억할 이유: PostgreSQL의 옵티마이저는 통계와 값의 분포를 참고해 Index Scan·Seq Scan을 결정한다. 또한 VACUUM은 Dead Tuple 재활용과 visibility map 유지에 관여하며, ANALYZE는 계획에 사용할 통계를 갱신한다.
주의·예외: n_live_tup, n_dead_tup은 정확한 실시간 행 수가 아닌 추정·누적 통계다. ANALYZE를 수행해도 원하는 인덱스가 반드시 선택되는 것은 아니다. 파티션 부모 테이블은 자식 파티션의 변경만으로 부모 ANALYZE가 자동 실행되지 않을 수 있어 별도 계획이 필요하다. VACUUM FULL은 강한 락과 테이블 재작성 비용이 있어 임의 실행하면 안 된다. autovacuum_enabled=false는 원본 교재의 격리된 성능 테스트용 설정이지 운영 권장 사항이 아니다.
교재 근거: PDF p.284 SQL-맛튜닝-값분포1/2, p.331~338(VACUUM·Autovacuum 설정과 Dead Tuple). 파티션 부모 통계 관리와 VACUUM의 유지보수 범위는 PostgreSQL 17 Routine Vacuuming 공식 문서에 근거해 추가했다.
PostgreSQL 개발자가 특히 구분해야 할 동작
대상 | SQL 실행계획으로 확인할 항목 | 별도 확인해야 할 항목 |
자료형·NULL | Index Cond / Filter / Sort | 형변환 오류·NULL 정렬·결과 정확성 |
파티션 | Append / 파티션 Scan / Subplans Removed | 실제 파티션 범위·조인 대상·인덱스 구성 |
트랜잭션 | Index Scan 등 조회 경로 | 격리 수준·스냅샷·COMMIT/ROLLBACK 상태 |
잠금 | LockRows·실제 실행 시 대기 | 다른 세션과의 경합·lock_timeout·락 획득 시점 |
MVCC·통계 | Index/Seq Scan·계획 행 수 | Dead Tuple·AUTOVACUUM·ANALYZE·실측 Buffers |
실행계획은 매우 유용하지만 트랜잭션의 성공 여부나 업무상 결과의 정확성까지 검증하지는 않는다. 이번 SECTION은 SECTION 12의 실행계획 자가 점검과 연결되는 사전 지식이다.
실습 환경에서 우선 확인할 읽기 전용 SQL
-- 1. 현재 데이터베이스·세션 정보
SELECT current_database() AS database_name,
current_schema() AS schema_name,
current_setting('server_version') AS pg_version;
-- 2. 현재 트랜잭션 격리 수준
SHOW TRANSACTION ISOLATION LEVEL;
-- 3. 현재 DB의 파티션 정의 확인 (tr_ord_big_p가 있을 때)
SELECT parent.relname AS parent_table,
child.relname AS partition_table,
pg_get_expr(child.relpartbound, child.oid) AS partition_bound
FROM pg_inherits AS inh
JOIN pg_class AS parent ON parent.oid = inh.inhparent
JOIN pg_class AS child ON child.oid = inh.inhrelid
WHERE inh.inhparent = 'tr_ord_big_p'::regclass
ORDER BY child.relname;
SQL
복사
tr_ord_big_p는 원본 실습에서 별도로 생성하므로, 실제 DB에 존재해야 3번 파티션 정의 조회를 실행할 수 있다.
예상 실행계획 확인 예제
-- 실제 쿼리 결과를 읽지 않고 계획만 확인
EXPLAIN (COSTS OFF)
SELECT COUNT(*) AS ord_cnt
FROM tr_ord_big_p AS o
WHERE o.ord_dtm >= TIMESTAMP '2024-03-01'
AND o.ord_dtm < TIMESTAMP '2024-04-01';
-- 실제 실행·측정은 테스트 데이터가 있는 환경에서만
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT COUNT(*) AS ord_cnt
FROM tr_ord_big_p AS o
WHERE o.ord_dtm >= TIMESTAMP '2024-03-01'
AND o.ord_dtm < TIMESTAMP '2024-04-01';
SQL
복사
EXPLAIN ANALYZE는 SQL을 실제 실행한다. DML·DDL과 잠금 명령은 운영 환경에서 연습하지 않는다.
개발 전 체크리스트
SQL의 비교 값이 컬럼 자료형과 일치하는가?
ORDER BY ... DESC의 NULL 정렬을 업무 요구사항에 맞게 명시했는가?
파티션 테이블 검색에 실제 파티션 키 조건이 있는가?
파티션 키 없는 JOIN에서 모든 파티션 반복 접근을 확인했는가?
READ COMMITTED에서 두 SELECT 결과가 달라질 가능성을 고려했는가?
트랜잭션 중 오류 발생 시 ROLLBACK 경로가 보장되는가?
조회만 하는 트랜잭션도 불필요하게 오래 열어 두지 않는가?
FOR UPDATE의 잠금 대기·NOWAIT·SKIP LOCKED가 업무 요구에 맞는가?
이미 같은 값인 데이터를 불필요하게 UPDATE하지 않는가?
변경 없는 UPDATE를 생략할 때 트리거·감사 로그에 영향이 없는가?
대량 적재·UPDATE 이후 통계의 최신성을 점검했는가?
n_dead_tup과 AUTOVACUUM 상태를 담당 DBA와 확인할 수 있는가?
예상 실행계획과 실제 트랜잭션·잠금 동작을 구분했는가?
출처 및 재구성 범위
제공된 PostgreSQL 교재·실습에서 직접 확인한 사항
•
StartUPTuning_PG_20250616.pdf p.143 — ord_ymd 문자형과 숫자 비교 오류, 컬럼 캐스팅 시 인덱스 활용.
•
p.284 — INAC와 ACTV의 값 분포, pg_stats 조회와 인덱스·Seq Scan 선택.
•
p.308~314 — tr_ord_big_p 파티션 구조, PARTITION BY RANGE(ord_dtm), 파티션별 Scan·파티션 키 없는 NL JOIN.
•
p.316~329 — 트랜잭션, READ COMMITTED, 자동 COMMIT, 두 세션의 WAIT, FOR UPDATE NOWAIT/SKIP LOCKED, DDL의 트랜잭션 포함.
•
p.331~338 — MVCC·Dead Tuple·VACUUM·AUTOVACUUM·ANALYZE 관련 기초.
•
StartUPTuning_PG_SQL_Script.sql 및 StartUPTuning_PG_SQL_Script_실습용.sql — 관련 실습 구문과 파티션·트랜잭션 SQL을 참고.
•
— 운영 환경의 쿼리 성능 튜닝 사례 모음. 이번 섹션의 트랜잭션·MVCC 예제들을 그 사례의 실제 실측 결과로 주장하지 않는다.
별도 PostgreSQL 일반 동작을 확인·보강한 사항
•
•
PostgreSQL 17: Transaction Isolation — READ COMMITTED 문장별 스냅샷과 REPEATABLE READ.
•
PostgreSQL 17: Routine Vacuuming — VACUUM/ANALYZE, visibility map, 파티션 부모 통계, VACUUM FULL의 강한 락.
•
IS DISTINCT FROM을 통한 무변경 UPDATE 생략, pg_inherits 조회 등은 개발자가 활용할 수 있도록 추가한 교육용 SQL이다.
실행 여부: 전체 BAD/GOOD SQL 및 실행계획은 실습 자료의 전제를 반영해 재구성했으며, 실제로 실행한 SQL·실측 계획·성능 향상 수치는 없다. 일부 코드는 의도적으로 오류·락 대기를 보여주므로 테스트용으로만 참고한다.
그림 제작 메모 — 이미지 미삽입
•
그림 A · 자료형 오류와 인덱스: varchar = integer 오류 → 컬럼 캐스팅 Filter → 올바른 문자 비교 Index Cond의 3단 비교
•
그림 B · NULL 정렬: DESC에서 NULL이 앞에 오는 BAD와 DESC NULLS LAST의 순서 비교
•
그림 C · 파티션 Pruning: TO_CHAR로 모든 월 파티션에 접근하는 경로와 ord_dtm 범위로 한 달만 스캔하는 경로
•
그림 D · 파티션 Driven 반복: 회원 N명이 월별 파티션 전부를 반복 탐색하는 NL 구조
•
그림 E · 트랜잭션 격리: 두 세션의 COMMIT 시점과 READ COMMITTED/REPEATABLE READ의 스냅샷 차이
•
그림 F · 잠금과 DDL: SELECT의 미종료 트랜잭션이 ALTER TABLE을 기다리게 만드는 타임라인
•
그림 G · MVCC: UPDATE 전후 행 버전과 Dead Tuple, VACUUM 정리·ANALYZE 통계 갱신의 역할 비교
SECTION 11 한 줄 요약: 자료형·NULL·파티션·트랜잭션·MVCC의 PostgreSQL 동작을 알아야 SQL이 실행되는 것 이상으로 정확하고 안정적인 프로그램을 만들 수 있다.




