키 설계 기초: PRIMARY KEY/UNIQUE로 ‘중복 적립’과 ‘중복 차감’을 구조적으로 막는 방법

포인트 시스템에서 가장 골치 아픈 문제는 “한 번만 처리되어야 하는 요청이 두 번 처리되는 것”입니다. 네트워크 재시도, 클라이언트 중복 클릭, 서버 타임아웃 후 재요청은 운영에서 흔한 일이고, 이때 코드만으로 완벽하게 막는 건 생각보다 어렵습니다.

그래서 많은 서비스는 마지막 방어선으로 키(Primary Key/Unique Key)를 활용합니다. 키 설계가 잘 되면, 애플리케이션이 실수하더라도 DB가 중복을 거절합니다. 이번 글에서는 “키는 그냥 ID 하나”라는 수준을 넘어서, 포인트 원장(히스토리)에서 실제로 필요한 멱등성(idempotency)을 어떻게 만들지에 초점을 맞춥니다.

이 단원의 목적: “중복이 생길 수밖에 없는 현실”을 전제로 설계하기

포인트 기능은 보통 결제, 이벤트, 쿠폰, 고객센터 보정 등 다양한 경로에서 호출됩니다. 호출 경로가 늘어날수록 중복 처리 가능성도 늘어납니다. 이번 글의 목표는 아래 3가지입니다.

  1. PK/UNIQUE의 역할을 정확히 이해하고 “어디에 걸어야 하는지” 결정한다
  2. 포인트 원장에서 중복을 막는 대표 전략(요청 ID/ref_id)을 설계한다
  3. 실제로 중복 요청이 들어오는 상황을 가정해, DB 레벨에서 안전하게 막는다

1) PRIMARY KEY는 “행을 식별하는 유일한 주소”다

PRIMARY KEY(PK)는 테이블에서 한 행을 유일하게 식별합니다. 흔히 AUTO_INCREMENT id를 PK로 두는 이유는 단순합니다. 쉽고, 빠르고, 안정적이기 때문입니다.

하지만 포인트 시스템에서는 “id가 유일하다”만으로는 부족합니다. id는 단지 “기록의 번호”일 뿐, “같은 요청이 두 번 들어왔다”는 문제를 막아주지 않습니다. 즉, PK는 필요조건이지만 중복 방지의 충분조건은 아닙니다.

-- 기록 자체의 식별자(기본) PRIMARY KEY (id)

2) UNIQUE는 “같은 의미의 기록이 두 번 들어오는 것”을 막는다

UNIQUE는 “비즈니스적으로 같은 의미”의 중복을 차단하는 도구입니다. 포인트에서는 보통 이런 상황이 중복입니다.

  • 같은 주문(ORDER_9001)에 대해 적립이 두 번 기록됨
  • 같은 이벤트 지급(EVENT_20251214)이 두 번 처리됨
  • 같은 사용 요청(USE_7001)이 두 번 차감됨

이 중복을 막으려면 “그 요청을 유일하게 대표하는 값”이 필요합니다. 그게 ref_id(요청/거래 식별자)입니다.

3) 멱등성(idempotency): 같은 요청을 여러 번 보내도 결과는 한 번만 반영

용어가 낯설어도 개념은 단순합니다. “적립 요청을 1번 보내든 3번 보내든, DB에는 1번만 기록되어야 한다.” 이게 멱등성이고, 포인트 시스템에서는 사실상 필수 성질입니다.

멱등성을 만들기 위한 가장 현실적인 방법은 아래 둘 중 하나입니다.

  1. 요청 ID를 발급하고 (uid, request_id)에 UNIQUE를 건다
  2. 거래 단위를 대표하는 ref_id(주문번호/이벤트ID)를 만들고 UNIQUE를 건다

4) (uid, ref_id) UNIQUE가 자주 쓰이는 이유

같은 ref_id라도 사용자(uid)가 다르면 다른 의미일 수 있습니다. 예를 들어 이벤트 지급 ID가 “이벤트 자체”를 뜻한다면, 사용자별로 1회 지급이므로 (uid, ref_id)가 자연스럽습니다. 또한 운영에서 “이 사용자에게 이 주문/이벤트가 한 번만 반영되었는지” 확인할 때도 조회가 쉽습니다.

UNIQUE KEY uk_uid_ref (uid, ref_id)

다만 이 방식에는 전제가 있습니다. ref_id가 반드시 존재하고, 해당 이벤트를 유일하게 대표할 수 있어야 합니다. ref_id가 NULL이거나 규칙이 들쑥날쑥하면 UNIQUE는 장애를 만들 수 있습니다.

5) 복합 UNIQUE를 설계할 때 자주 고민하는 3가지 패턴

(1) “사용 요청” 중복 차감 방지: (uid, ref_id, action_type)

어떤 서비스는 ref_id 하나가 “주문”을 뜻하고, 그 주문에 대해 적립(CHARGE)과 사용(USE)이 모두 발생할 수 있습니다. 이때 (uid, ref_id)만 UNIQUE로 묶어버리면 “적립 내역이 들어온 뒤 같은 주문 ref_id로 사용 내역이 들어오면” 충돌이 납니다.

이런 경우는 UNIQUE 범위를 더 구체화합니다.

-- 주문 단위 ref_id가 적립/사용 모두에 쓰일 수 있다면: UNIQUE KEY uk_uid_ref_action (uid, ref_id, action_type)

이 패턴은 “같은 주문(ref_id)에 대해 같은 action_type이 중복되면 안 된다”는 의미입니다. 즉, 주문 하나에 대해 CHARGE는 한 번, USE도 한 번만 허용하는 모델입니다. 물론 실제 정책(분할 사용 가능 여부)에 따라 이 모델은 달라질 수 있습니다.

(2) “하루 1회 출석체크” 같은 규칙: (uid, action_type, 날짜)

이벤트 ref_id가 따로 없는 경우라도, 비즈니스 규칙이 명확하면 키로 만들 수 있습니다. 예를 들어 “하루 1회 지급”이라면 날짜가 곧 유일성 기준입니다. 이때는 created_at에서 날짜를 뽑아 UNIQUE를 걸고 싶지만, DBMS에 따라 함수 기반 인덱스/생성 컬럼이 필요할 수 있습니다.

학습 관점에서는 가장 단순하게 “event_day” 같은 컬럼을 추가하는 방식이 이해가 쉽습니다.

-- (선택) 출석체크 같은 이벤트용 컬럼 추가 예시 ALTER TABLE point_history ADD COLUMN event_day DATE NULL; -- 유일성: 유저는 하루에 한 번만 CHARGE(출석) 가능 UNIQUE KEY uk_uid_action_day (uid, action_type, event_day)

핵심은 “중복을 막는 기준은 데이터에서 만들어야 한다”는 점입니다. 코드에서만 규칙을 관리하면, 결국 예외 케이스가 누락됩니다.

(3) “요청 ID” 방식: 비즈니스와 무관하게 가장 깔끔한 멱등성

ref_id를 주문/이벤트에 섞어 쓰다 보면 규칙이 복잡해질 수 있습니다. 이때는 아예 모든 포인트 변경 요청에 대해 request_id(멱등 키)를 발급하는 방식이 깔끔합니다. request_id는 “이 요청은 한 번만 반영되어야 한다”는 기술적 식별자입니다.

-- request_id 추가 예시 ALTER TABLE point_history ADD COLUMN request_id CHAR(36) NULL; -- 모든 요청은 (uid, request_id)로 유일해야 한다 UNIQUE KEY uk_uid_request (uid, request_id)

이 방식의 장점은 비즈니스 규칙과 분리된다는 점입니다. 단점은 “요청을 호출하는 쪽”에서 request_id를 반드시 생성/전파해야 하므로 시스템 전반의 설계가 필요합니다.

6) 실제로 중복이 막히는지 테스트해보기

키 설계는 문서로만 이해하면 의미가 없습니다. 실제로 중복 INSERT가 실패하는지 확인하는 테스트를 한 번이라도 해보면, “DB가 안전장치가 되는 느낌”이 확실히 잡힙니다.

SET @uid := '11111111-1111-1111-1111-111111111111'; -- 같은 ref_id로 CHARGE를 2번 넣어보자 (중복이면 실패가 정상) INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, created_at) VALUES (@uid, 'FREE', 'CHARGE', 100, 'EVENT_IDEMPOTENT', NOW()); INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, created_at) VALUES (@uid, 'FREE', 'CHARGE', 100, 'EVENT_IDEMPOTENT', NOW());

두 번째 INSERT가 실패하면 “중복 적립 방지”가 동작하는 것입니다. 실패를 “문제”로 보지 말고, 원래 막으려고 만든 안전장치가 잘 작동하는 증거로 보는 관점이 중요합니다.

7) 키 설계를 할 때 자주 놓치는 현실적인 포인트

  1. ref_id가 정말 유일한가?
    주문번호처럼 유일성이 보장되는 값인지, 이벤트명처럼 애매한 값인지 구분해야 합니다.
  2. 정책이 바뀌면 키도 바뀐다
    “주문당 1회 사용”이 “분할 사용 가능”으로 바뀌면 (uid, ref_id, action_type) UNIQUE는 깨질 수 있습니다.
  3. 중복 방지는 결국 ‘기준’이 있어야 한다
    기준이 없다면 키로도 막을 수 없고, 운영에서 끝없이 예외 처리를 하게 됩니다.

다음 글 예고: 인덱스 기초로 “내역 조회 속도” 올리기 (uid, created_at이 왜 핵심인가)

다음 글에서는 인덱스를 다룹니다. 포인트 원장 테이블은 시간이 갈수록 커지기 때문에, 조회가 느려지면 사용자 경험이 바로 나빠집니다. 특히 “내 포인트 내역 보기”는 거의 항상 uid + 기간 + 정렬 조합으로 동작하므로, (uid, created_at) 인덱스의 의미를 확실히 이해하면 성능 감각이 빠르게 잡힙니다.

댓글

이 블로그의 인기 게시물

JOIN 기초: users와 point_history를 합쳐 ‘회원별 요약(잔액/최근 활동)’ 만들기

점검 SQL: “원장 합계(SUM) vs balance” 불일치를 찾아내고 원인을 좁히는 방법

EXPLAIN 기초: 점검/리포트 쿼리가 느려질 때 “왜 느린지” 확인하는 방법