blog

Postgre SQL - 테이블 설계 Tip: 업데이트가 잦은 필드 + 그 필드로 정렬 + 유니크 보장까지 해야 하는 경우

날짜: 2025-08-23

목록으로


아래는 실무 위주로, 바꿔야 할 지점만 콕 집어 예제 포함해 설명합니다.


1) 기본 원칙 (핵심만)


2) 수직 분할로 HOT 업데이트 확보

“업데이트가 잦은 필드”를 별도 테이블로 분리하면, 메인 테이블 인덱스 재작성 최소화 + 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 가능성↑

포인트


3) 정렬 + 유니크 보장 (그룹 내 고유 순서)

그룹(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 → 정렬 + 필터 동시 최적화


4) “대량 이동” 없는 정렬: Gap 기반 sort_key

위치 바꿀 때 전체를 밀지 않도록 간격을 두고 배치합니다. (예: 초기에 10, 20, 30 …로 부여)

초기 부여 예:

-- 새 아이템: 최대 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;

포인트


5) 유니크 충돌/경합 처리 (동시성)

정렬 변경은 보통 “그룹 단위” 직렬화가 안전합니다. 어드바이저리 락으로 짧게 감싸면 단순+안전.

-- 그룹 단위 직렬화 (세션 내)
SELECT pg_advisory_xact_lock(hashtext('reorder:' || :list_id::text));

-- 여기서 끼워넣기/교환/리노멀라이즈 실행
-- 유니크 충돌(ON CONFLICT) 시 미세 조정 후 재시도

또는 INSERT ... ON CONFLICT (list_id, sort_key) DO UPDATE 패턴으로 재시도 로직 구현.


6) “활성만 유니크”가 필요한 경우 (부분 유니크)

활성 행만 유니크하게 강제하고, 비활성 기록은 중복 허용:

CREATE UNIQUE INDEX uidx_itemdyn__list__sort__active
  ON item_dyn(list_id, sort_key)
  WHERE is_active;  -- 부분 유니크

활성/비활성 전환도 안전해짐(활성으로 바꿀 때만 충돌 체크).


7) 파티셔닝으로 유지/운영 비용 절감

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)는 그룹별 재배치를 막고, 특정 그룹 핫스팟을 분산(인서트/업데이트 병렬성↑).


8) 운영 파라미터/메인터넌스 팁


9) Django/DRF 실무 적용 포인트


요약 체크리스트

위 내용에 대한 추가 개념 설명

좋은 추가 질문이에요 👍 각각 핵심만 딱 정리해드릴게요.


1) HOT 업데이트란?


2) 그룹 선두 컬럼 → 정렬 컬럼 복합 인덱스


3) 어드바이저리 락(Advisory Lock)


4) 커버링 인덱스란? (+ 예제)

예시:

-- 조회 쿼리: 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

5) sort_key에 “1초당 천 번” 업데이트, 효율적일까?

즉, sort_key가 인덱스에 걸려있으니 업데이트마다 인덱스 재작성은 피할 수 없음. → “많은 업데이트에도 효율적일 수 있다”는 의미는:


✅ 요약


목록으로