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

  • 재고, 포인트, 카운터는 왜 서로 다른 동시성 전략이 필요한가?
  • 같은 숫자 컬럼 업데이트라도 어떤 경우에는 원장이나 비동기 집계가 필요한가?
  • 개인 프로젝트에서 동시성 역량을 어떻게 보여줄 수 있는가?

개요

재고, 포인트, 카운터는 모두 숫자를 다루지만 요구사항이 다르다. 재고는 초과 판매를 막아야 하고, 포인트는 감사와 복구가 중요하며, 카운터는 약간의 지연을 허용할 수 있다.

같은 count = count + 1처럼 보여도 설계 기준은 다르다.

원리

재고, 포인트, 카운터의 원리는 모두 숫자 변경이지만 실패 비용과 복구 방법이 다르다는 점이다. 재고는 초과 판매를 막아야 하고, 포인트는 감사와 재계산이 가능해야 하며, 카운터는 정확도보다 처리량과 지연 허용이 더 중요할 수 있다.

따라서 “숫자 컬럼을 update한다”로 같은 설계를 쓰면 안 된다. 백엔드 개발자는 정확성, 중복 요청, hot row, 감사 로그, 지연 허용, 재처리 방법을 기준으로 저장 모델과 동시성 패턴을 고른다.

재고

재고는 초과 판매 방지가 핵심이다.

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

영향 row 수가 0이면 품절이다.

int updated = productRepository.decreaseStock(productId);
if (updated == 0) {
    throw new SoldOutException();
}

인기 상품은 hot row가 될 수 있다. 트래픽이 크면 큐잉, 재고 토큰, 사전 할당, 이벤트 처리 같은 구조가 필요하다.

운영에서 hot row는 다음 신호로 보일 수 있다.

  • 특정 product row update latency 증가
  • lock wait 증가
  • deadlock 또는 retry count 증가
  • 주문 API p99만 급증
  • DB CPU보다 row lock 대기가 두드러짐

이때 단순히 pool size를 키우면 같은 row를 더 많은 요청이 때려 대기가 커질 수 있다.

포인트

포인트는 잔액뿐 아니라 이력이 중요하다.

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_idempotency UNIQUE (idempotency_key)
);

잔액 테이블만 업데이트하면 장애 후 왜 잔액이 그렇게 되었는지 추적하기 어렵다. 원장은 감사와 재처리의 기준이다.

포인트 잔액은 원장에서 재계산 가능해야 한다.

SELECT member_id, sum(amount) AS recalculated_balance
FROM point_ledger
WHERE member_id = :memberId
GROUP BY member_id;

운영 정정은 잔액을 직접 덮어쓰기보다 보정 원장 row를 추가하는 방식이 감사에 유리한 경우가 많다.

카운터

조회수나 좋아요 수는 즉시 정확성이 덜 중요할 수 있다.

UPDATE posts
SET view_count = view_count + 1
WHERE id = :postId;

인기 게시글에서 이 row가 hot row가 되면 이벤트로 쌓고 비동기 집계할 수 있다.

INSERT INTO post_view_events (post_id, member_id, viewed_at)
VALUES (:postId, :memberId, now());

이후 집계 worker가 카운터를 갱신한다. 읽기에는 약간의 지연이 생기지만 쓰기 병목을 줄일 수 있다.

카운터는 제품과 정확도 SLA를 합의해야 한다. 조회수는 1분 지연이 괜찮을 수 있지만, 결제 잔액이나 재고는 그렇지 않다. 지연 허용 범위를 모르면 비동기 집계가 장애인지 정상 동작인지 판단할 수 없다.

비교 기준

대상핵심 요구추천 출발점
재고초과 판매 방지조건부 원자 update
포인트감사, 복구, 중복 방지ledger + idempotency
좋아요 중복사용자당 1회Unique 제약
조회수높은 쓰기량, 지연 허용이벤트 적재 후 집계
잔액최신성, 이중 사용 방지비관적 락 또는 원자 update + 원장

개인 프로젝트 기준

개인 프로젝트에서도 다음 중 하나를 제대로 구현하면 좋다.

  • 좋아요 중복을 (post_id, member_id) Unique로 막는다.
  • 재고 차감을 조건부 update와 동시성 테스트로 검증한다.
  • 포인트 적립에 idempotency key와 ledger를 둔다.
  • 조회수는 이벤트 적재와 배치 집계로 분리한다.

단순 CRUD보다 훨씬 백엔드다운 결과물이 된다.

실전 팁

  • 숫자 컬럼을 보면 먼저 정확성 요구사항을 묻는다.
  • 재고와 잔액은 실패 처리와 중복 요청을 함께 설계한다.
  • 카운터는 정확도와 지연 허용 범위를 제품과 합의한다.
  • 고트래픽 이벤트는 DB row 하나에 모든 쓰기를 모으지 않는다.
  • 동시성 테스트를 작성해 race condition을 재현한다.
  • 재고 hot row는 queue lag, lock wait, retry count를 함께 모니터링한다.
  • 포인트는 잔액 row와 ledger row의 불일치를 탐지하는 검증 쿼리를 둔다.
  • 카운터는 정확도 SLA와 집계 지연 허용치를 제품 요구사항으로 명시한다.

위험 신호!

  • 재고를 select 후 애플리케이션에서 1 감소시켜 save한다.
  • 포인트 적립에 중복 요청 방지 키가 없다.
  • 조회수 증가가 인기 게시글 row 하나에 집중된다.
  • 좋아요 중복을 프론트 버튼 비활성화로만 막는다.
  • 실패 후 재처리할 수 있는 원장이 없다.
  • 인기 상품 장애에서 pool size부터 키워 hot row 경합을 악화시킨다.
  • 포인트 잔액을 직접 수정하고 보정 이력을 남기지 않는다.
  • 조회수 지연을 허용한다고 해놓고 사용자에게 실시간 정확도를 약속한다.

확인 질문

  • 재고 차감에 조건부 update가 좋은 출발점인 이유는 무엇인가?
    • 재고 확인과 감소를 하나의 SQL로 처리해 동시성 경쟁에서 초과 판매를 줄일 수 있기 때문이다.
  • 포인트에 ledger가 필요한 이유는 무엇인가?
    • 잔액 변경 이유를 추적하고 장애 복구, 감사, 중복 방지, 재계산을 가능하게 하기 위해서다.
  • 카운터를 비동기 집계할 수 있는 조건은 무엇인가?
    • 사용자 경험상 약간의 지연과 eventual consistency를 허용할 수 있어야 한다.
  • 재고와 조회수 카운터가 같은 숫자 update라도 다르게 설계되는 이유는 무엇인가?
    • 재고는 초과 판매 방지가 핵심이라 강한 원자성이 필요하고, 조회수는 일정 지연과 근사치를 허용할 수 있어 이벤트 적재와 비동기 집계가 가능하기 때문이다.

참고 문서