날짜: 2025-08-23
아래는 실무 위주로, 바꿔야 할 지점만 콕 집어 예제 포함해 설명합니다.
“업데이트가 잦은 필드”를 별도 테이블로 분리하면, 메인 테이블 인덱스 재작성 최소화 + HOT 업데이트 확률↑
-- 메인(변경 적음): 인덱스/조인 대상
CREATE TABLE item (
id BIGSERIAL PRIMARY KEY,
list_id BIGINT NOT NULL, -- 정렬 그룹
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_item__list_id ON item(list_id);
-- 변경 잦음(상태/점수/정렬키 등)
CREATE TABLE item_dyn (
item_id BIGINT PRIMARY KEY REFERENCES item(id) ON DELETE CASCADE,
sort_key BIGINT NOT NULL, -- 정렬용
is_active BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
) WITH (fillfactor=80); -- 여유 공간 확보로 HOT 가능성↑
포인트
item
에는 안정적/자주 조회되는 컬럼만.item_dyn.sort_key
가 자주 바뀌어도item
의 인덱스는 영향 없음.
그룹(list_id) 내에서 sort_key 유니크 + 정렬 최적화: 복합 유니크 + 커버링
-- 그룹별 고유 순서 보장
CREATE UNIQUE INDEX uidx_itemdyn__list__sort
ON item_dyn(list_id, sort_key);
-- 정렬 최적화(커버링) 예: 목록 조회용
CREATE INDEX idx_itemdyn__list__sort__cover
ON item_dyn(list_id, sort_key DESC, item_id);
조회 예시:
-- 특정 리스트 상위 50개
SELECT i.id, i.title, d.sort_key
FROM item i
JOIN item_dyn d ON d.item_id = i.id
WHERE d.list_id = 42 AND d.is_active
ORDER BY d.sort_key DESC
LIMIT 50;
선두 컬럼을
list_id
로 두고 그 다음sort_key
→ 정렬 + 필터 동시 최적화
위치 바꿀 때 전체를 밀지 않도록 간격을 두고 배치합니다. (예: 초기에 10, 20, 30 …로 부여)
BIGINT
추천(여유 큼). 정렬 역순이면 큰 수→작은 수 방향으로.초기 부여 예:
-- 새 아이템: 최대 sort_key + 10,000 간격
INSERT INTO item_dyn(item_id, list_id, sort_key)
SELECT :item_id, :list_id,
COALESCE(MAX(sort_key), 0) + 10000
FROM item_dyn WHERE list_id = :list_id;
끼워넣기 예:
-- before_key와 after_key 사이 중간값 부여
UPDATE item_dyn
SET sort_key = ((:before_key + :after_key) / 2)
WHERE item_id = :target_item_id AND list_id = :list_id;
리노멀라이즈(드물게):
-- 해당 list_id만 잠깐 정규화 (간격 재부여)
WITH ordered AS (
SELECT item_id, ROW_NUMBER() OVER (ORDER BY sort_key DESC) AS rn
FROM item_dyn WHERE list_id = :list_id
)
UPDATE item_dyn d
SET sort_key = (10000 * ordered.rn)
FROM ordered
WHERE d.item_id = ordered.item_id AND d.list_id = :list_id;
포인트
- 정렬 변경이 잦아도 대규모 행 이동/충돌이 없음.
- 유니크 제약은 그대로 유지(충돌 시 재시도/중간값 조정).
정렬 변경은 보통 “그룹 단위” 직렬화가 안전합니다. 어드바이저리 락으로 짧게 감싸면 단순+안전.
-- 그룹 단위 직렬화 (세션 내)
SELECT pg_advisory_xact_lock(hashtext('reorder:' || :list_id::text));
-- 여기서 끼워넣기/교환/리노멀라이즈 실행
-- 유니크 충돌(ON CONFLICT) 시 미세 조정 후 재시도
또는 INSERT ... ON CONFLICT (list_id, sort_key) DO UPDATE
패턴으로 재시도 로직 구현.
활성 행만 유니크하게 강제하고, 비활성 기록은 중복 허용:
CREATE UNIQUE INDEX uidx_itemdyn__list__sort__active
ON item_dyn(list_id, sort_key)
WHERE is_active; -- 부분 유니크
활성/비활성 전환도 안전해짐(활성으로 바꿀 때만 충돌 체크).
CREATE TABLE item_dyn_p (
item_id BIGINT,
list_id BIGINT,
sort_key BIGINT,
is_active BOOLEAN,
updated_at TIMESTAMPTZ
) PARTITION BY HASH (list_id);
CREATE TABLE item_dyn_p0 PARTITION OF item_dyn_p FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- p1..p7 생성 생략
CREATE UNIQUE INDEX ON item_dyn_p(list_id, sort_key); -- 전파됨
HASH(list_id)
는 그룹별 재배치를 막고, 특정 그룹 핫스팟을 분산(인서트/업데이트 병렬성↑).
autovacuum per-table 설정: 변경 많은 테이블에 aggressive하게.
ALTER TABLE item_dyn SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02
);
Item
, ItemDynamic
(OneToOne).정렬 변경 API:
좋은 추가 질문이에요 👍 각각 핵심만 딱 정리해드릴게요.
예시:
-- name은 인덱스 없음, email만 인덱스 있음
UPDATE users SET name='foo' WHERE id=1;
-- HOT UPDATE → email 인덱스는 안 건드림
왜?
예시:
CREATE UNIQUE INDEX uidx_orders__listid__sortkey
ON orders(list_id, sort_key);
→ list_id 조건을 먼저 타고, 그 안에서 sort_key 정렬/유니크 보장.
예시:
-- list_id 단위 정렬 변경 시 직렬화
SELECT pg_advisory_xact_lock(hashtext('reorder:' || :list_id::text));
-- 같은 list_id 정렬 요청은 동시에 들어와도 직렬 실행됨
Index Only Scan
)INCLUDE
문법도 지원 (Postgres 11+).예시:
-- 조회 쿼리: status별 최신 id, ordered_at만 뽑기
SELECT id, ordered_at
FROM orders
WHERE status='paid'
ORDER BY ordered_at DESC
LIMIT 20;
-- 커버링 인덱스
CREATE INDEX idx_orders__status__ordered_at_cover
ON orders(status, ordered_at DESC, id);
-- id, ordered_at, status가 모두 인덱스에 있으므로 Heap 접근 X
-- 실행계획: Index Only Scan
효율적으로 만들 수 있는 조건:
item_dyn
)로 분리 → 다른 인덱스 영향 최소화.fillfactor
낮춰 HOT UPDATE 확률↑ (sort_key 자체가 인덱스에 걸려있으니 HOT은 안되지만, is_active 등 다른 필드라도 HOT 유도).1초 1,000번 수준이라면:
즉, sort_key가 인덱스에 걸려있으니 업데이트마다 인덱스 재작성은 피할 수 없음. → “많은 업데이트에도 효율적일 수 있다”는 의미는:
✅ 요약