이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Hot row는 무엇이고 왜 수평 확장으로 해결하기 어려운가?
  • 재고, 좋아요 수, 포인트 잔액은 어떤 병목을 만들 수 있는가?
  • Hot row를 완화하는 설계 선택지는 무엇인가?

개요

Hot row는 많은 트랜잭션이 같은 행을 동시에 읽거나 업데이트하는 상황이다. DB는 같은 행의 변경을 동시에 무한히 처리할 수 없다. 결국 직렬화되거나 lock wait가 발생한다.

백엔드에서는 좋아요 수, 조회수, 재고, 포인트 잔액, 쿠폰 발급 수, 전역 설정 row에서 자주 발생한다.

원리

다음 쿼리는 게시글 좋아요 수를 증가시킨다.

UPDATE posts
SET like_count = like_count + 1
WHERE id = 100;

동시에 수천 명이 같은 게시글을 좋아요하면 모두 posts.id = 100 행을 업데이트하려 한다. DB는 이 업데이트를 같은 행에 대해 순서대로 처리해야 한다. 애플리케이션 서버를 늘려도 같은 DB row가 병목이면 해결되지 않는다.

재고 차감

재고 차감도 대표 hot row다.

UPDATE products
SET stock = stock - 1
WHERE id = 10
  AND stock > 0;

이 쿼리는 원자적 조건 업데이트라 overselling을 줄이는 좋은 출발점이다. 하지만 인기 상품 하나에 요청이 몰리면 같은 row 업데이트가 병목이 된다.

서비스 규모에 따라 선택지가 달라진다.

  • 단순 서비스: 조건부 UPDATE와 실패 처리
  • 중간 규모: 주문 요청 큐잉, 재고 선점, timeout과 재시도 제한
  • 큰 이벤트: 재고 토큰 분산, Redis 원자 연산, 이벤트 소싱, 사전 할당

중요한 것은 정확성 요구사항을 먼저 나누는 것이다.

재고 초과 판매: 절대 불가
좋아요 수 표시: 수 초 지연 허용
조회수: 지연 집계 허용
포인트 잔액: 강한 정합성 필요
랭킹: 주기적 재계산 허용

모든 카운터를 즉시 정확하게 만들려고 하면 hot row가 생긴다. 모든 것을 비동기로 밀면 사용자가 반드시 믿어야 하는 값이 흔들린다.

카운터 분산

조회수나 좋아요 수는 즉시 정확성이 덜 중요할 수 있다. 이 경우 분산 카운터를 쓸 수 있다.

CREATE TABLE post_like_counters (
    post_id bigint NOT NULL,
    shard_no int NOT NULL,
    count bigint NOT NULL DEFAULT 0,
    PRIMARY KEY (post_id, shard_no)
);

증가 시 여러 shard 중 하나를 업데이트한다.

UPDATE post_like_counters
SET count = count + 1
WHERE post_id = 100
  AND shard_no = 7;

조회 시 합산한다.

SELECT sum(count)
FROM post_like_counters
WHERE post_id = 100;

이 방식은 쓰기 병목을 분산하지만 읽기와 정합성 복잡도가 늘어난다.

포인트와 잔액

포인트 잔액은 카운터처럼 분산하기 어렵다. 최신 잔액과 이중 사용 방지가 중요하기 때문이다.

좋은 설계는 잔액 row만 보는 것이 아니라 원장 ledger를 함께 둔다.

CREATE TABLE point_ledger (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    member_id bigint NOT NULL,
    amount bigint NOT NULL,
    reason varchar(50) NOT NULL,
    idempotency_key varchar(100) NOT NULL,
    created_at timestamp NOT NULL DEFAULT now(),
    CONSTRAINT uq_point_ledger_idempotency UNIQUE (idempotency_key)
);

잔액은 빠른 조회를 위한 projection일 수 있고, 원장은 복구와 감사의 기준이 된다.

실전 팁

  • hot row가 가능한 도메인은 처음부터 QPS를 추정한다.
  • 정확한 즉시 카운터가 필요한지 제품 요구사항을 확인한다.
  • 카운터는 비동기 집계나 shard counter로 분산할 수 있다.
  • 잔액과 재고는 멱등성, 원장, 상태 전이를 함께 설계한다.
  • 이벤트 트래픽에서는 DB보다 앞단에서 큐잉과 rate limit을 둔다.
  • 카운터마다 정확성, 최신성, 감사 필요성을 분류한 뒤 저장 구조를 정한다.
  • 인기 이벤트는 사전 부하 테스트로 hot row lock wait와 update TPS 한계를 측정한다.
  • 원장 기반 도메인은 projection이 깨져도 원장으로 재계산 가능하게 설계한다.

위험 신호!

  • 인기 게시글의 모든 좋아요가 posts.like_count 한 행을 업데이트한다.
  • 쿠폰 재고를 하나의 row lock으로만 처리한다.
  • 포인트 적립 요청에 멱등 키가 없다.
  • 재고 차감 실패를 즉시 무한 재시도한다.
  • DB 스펙만 올려 hot row 문제를 해결하려 한다.
  • 좋아요 수와 포인트 잔액을 같은 정확성 기준으로 설계한다.
  • hot row 병목을 발견한 뒤에도 제품 요구사항의 지연 허용 범위를 다시 묻지 않는다.

확인 질문

  • Hot row가 수평 확장으로 해결되기 어려운 이유는 무엇인가?
    • 모든 요청이 같은 DB 행을 업데이트하므로 애플리케이션 서버를 늘려도 그 행에 대한 변경은 직렬화되기 때문이다.
  • 카운터를 분산할 때 얻는 것과 잃는 것은 무엇인가?
    • 쓰기 병목은 줄지만 읽기 합산과 정합성, 운영 복잡도가 증가한다.
  • 포인트 잔액에 원장이 필요한 이유는 무엇인가?
    • 장애 복구, 감사, 중복 방지, 재계산을 위해 변경 이력을 보존해야 하기 때문이다.
  • Hot row 완화 전 제품 요구사항을 다시 확인해야 하는 이유는 무엇인가?
    • 정확성, 최신성, 지연 허용 범위에 따라 조건부 update, 분산 카운터, 큐잉, 이벤트 집계 중 선택지가 달라지기 때문이다.

참고 문서