포인트 시스템에 필요한 최소 테이블 목록 설계: “나중에 고치기 어려운 것”부터 잡는다

포인트 기능을 만들 때 가장 흔한 시행착오는 “일단 구현하고 나중에 정리하자”입니다. 그런데 포인트는 금액에 준하는 데이터라, 설계가 흔들리면 운영에서 바로 비용으로 돌아옵니다. 이번 글에서는 SQL 문법보다 한 단계 앞에서, 포인트 시스템에 필요한 최소 테이블을 확정하고, 각 테이블이 어떤 질문(조회/검증)에 답해야 하는지까지 정리합니다.

포인트 데이터는 2가지 질문을 반드시 견뎌야 한다

  1. 왜 잔액이 이 숫자인가? (원장/히스토리로 추적 가능해야 함)
  2. 지금 잔액이 얼마인가? (빠르게 조회되어야 함)

첫 번째 질문은 “감사(추적)”의 영역이고, 두 번째는 “성능”의 영역입니다. 이 둘을 동시에 만족시키는 가장 단단한 출발점은 원장(내역) 테이블을 중심으로 두고, 필요하면 잔액 테이블을 추가하는 방식입니다.

최소 테이블 2개 + 선택 테이블 1개

처음부터 많은 테이블을 만들 필요는 없습니다. 아래 2개는 필수, 1개는 성능 목적의 선택입니다.

구분 테이블 필요 이유 대표 조회
필수 users uid(사용자 식별) 기준을 고정 회원별 잔액/내역 조회
필수 point_history (원장) 적립/차감/만료/취소의 “사실”을 남김 기간별 내역, 합계, 감사 추적
선택 point_balance “지금 잔액”을 즉시 응답하기 위한 캐시성 테이블 uid로 잔액 1건 조회

1) users: 최소 컬럼만으로 시작해도 된다

여기서 중요한 건 회원정보가 아니라 uid가 일관되게 유지되는 것입니다. uid는 문자열(UUID)일 수도 있고 정수형 id일 수도 있습니다. 어떤 것을 쓰든 “한 번 정하면 바꾸기 어렵다”는 점만 기억하면 됩니다.

CREATE TABLE users ( uid CHAR(36) PRIMARY KEY, -- UUID 가정 created_at DATETIME NOT NULL );

2) point_history(원장): 포인트 시스템의 중심

포인트는 잔액보다 “내역”이 핵심입니다. 원장 테이블이 흔들리면 환불/취소/분쟁 대응이 거의 불가능해집니다.

원장 테이블에서 반드시 정해야 하는 3가지

  1. 금액(amount)의 부호 정책: 적립은 +, 차감은 -로 통일할지
  2. 중복 방지 키: 같은 요청이 두 번 들어와도 한 번만 반영되게 할 근거(ref_id)
  3. 시간 축(created_at): 모든 조회/정산은 시간 기준을 타므로 인덱스가 중요
CREATE TABLE point_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, uid CHAR(36) NOT NULL, point_type ENUM('PAID','FREE') NOT NULL, action_type ENUM('CHARGE','USE','REFUND','USE_CANCEL','EXPIRE','ADJUST') NOT NULL, amount INT NOT NULL, -- 정책 통일: 적립(+), 차감(-) ref_id VARCHAR(64) NULL, -- 주문번호/이벤트ID/요청ID 등 (중복 방지 근거) memo VARCHAR(255) NULL, -- 운영 확인용(선택) created_at DATETIME NOT NULL, -- 조회 성능을 위한 인덱스 INDEX idx_uid_created (uid, created_at), INDEX idx_created (created_at), -- ref_id를 중복 방지에 쓰려면 “고유성” 정책을 명확히 해야 합니다. -- 예: (uid, ref_id)가 유일해야 한다면 아래처럼 UNIQUE를 둘 수 있습니다. UNIQUE KEY uk_uid_ref (uid, ref_id), CONSTRAINT fk_point_history_user FOREIGN KEY (uid) REFERENCES users(uid) );

UNIQUE(중복 방지)는 “무조건”이 아니다

(uid, ref_id) UNIQUE는 강력하지만, ref_id가 항상 존재하고 항상 유일하다는 전제가 있어야 합니다. 예를 들어 “출석체크 적립”처럼 하루 1회가 규칙이면 ref_id를 uid+날짜로 만들 수 있고, 결제 주문이면 주문번호가 ref_id가 될 수 있습니다. 반대로 ref_id가 비거나 규칙이 모호하면 UNIQUE는 오히려 운영 장애를 만듭니다.

그래서 원칙은 간단합니다. ref_id가 명확한 이벤트부터 UNIQUE로 잠그고, 모호한 이벤트는 별도의 규칙이 잡힐 때까지 NULL 허용 + 운영 검증 쿼리로 감시합니다.

3) point_balance(선택): “잔액 즉시 조회”가 필요할 때

원장 합산(SUM)만으로도 잔액을 만들 수 있지만, 데이터가 커지면 응답이 느려질 수 있습니다. 이때 point_balance는 “캐시성 요약”으로 붙입니다.

CREATE TABLE point_balance ( uid CHAR(36) PRIMARY KEY, balance BIGINT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL, CONSTRAINT fk_point_balance_user FOREIGN KEY (uid) REFERENCES users(uid) );

중요한 점은 이것입니다. 정답은 언제나 point_history이고, point_balance는 “빠른 응답”을 위한 보조 테이블입니다. 따라서 두 테이블을 함께 쓸 경우, 적립/차감은 반드시 트랜잭션으로 묶어 일관성을 지켜야 합니다. (트랜잭션과 동시성은 후반 단계에서 본격적으로 다룹니다.)

이 설계가 제대로 동작하는지, 가장 작은 데이터로 검증해보기

테이블을 만들었으면 “작게 넣고, 작게 확인”하는 습관이 승인용 글에서도 신뢰도를 올립니다.

-- 1) 사용자 1명 생성 INSERT INTO users (uid, created_at) VALUES ('11111111-1111-1111-1111-111111111111', NOW()); -- 2) 적립 2건, 사용 1건 기록(부호 정책: 적립 +, 사용 -) INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, created_at) VALUES ('11111111-1111-1111-1111-111111111111', 'FREE', 'CHARGE', 1000, 'EVENT_20251214', NOW()), ('11111111-1111-1111-1111-111111111111', 'PAID', 'CHARGE', 500, 'ORDER_9001', NOW()), ('11111111-1111-1111-1111-111111111111', 'FREE', 'USE', -300, 'USE_7001', NOW()); -- 3) 원장 합산으로 잔액 확인 SELECT uid, SUM(amount) AS balance FROM point_history WHERE uid = '11111111-1111-1111-1111-111111111111' GROUP BY uid; -- 4) 최근 내역 확인(시간순) SELECT id, action_type, amount, ref_id, created_at FROM point_history WHERE uid = '11111111-1111-1111-1111-111111111111' ORDER BY created_at DESC, id DESC;

이번 단계에서 일부러 “아직” 다루지 않은 것들

포인트 시스템을 조금이라도 해본 사람은 곧바로 이런 질문을 떠올립니다. “만료는?”, “부분 사용은?”, “무료/유료가 섞이면?”, “FIFO는?” 맞습니다. 다만 설계는 순서가 있습니다.

  • 만료(Expire)는 17단계에서 “배치 쿼리 + 검증 쿼리”로 완성합니다.
  • 사용 우선순위(FIFO 등)는 18단계에서 “차감 규칙”으로 다룹니다.
  • 환불/사용취소는 19단계에서 “되돌리기 원칙”을 정리합니다.
  • 동시성/잠금은 15단계에서 “왜 꼬이는지”부터 확실히 잡습니다.

지금 단계에서 중요한 건 “테이블이 늘어나는 것”이 아니라, 나중 기능이 추가되어도 원장 중심 구조가 흔들리지 않는 것입니다.

다음 글 예고: SELECT 기초로 “내 포인트 잔액/내역”을 제대로 조회하기

다음 글에서는 SELECT의 기본을 포인트 조회에 바로 연결합니다. 단순히 문법을 나열하지 않고, 실제로 자주 쓰는 질문(잔액, 기간 내역, 유형별 합계)을 쿼리로 만들면서 WHERE/ORDER BY가 왜 중요한지까지 함께 정리합니다.

댓글

이 블로그의 인기 게시물

SELECT 기초로 “내 포인트 잔액/내역” 조회하기: 문법보다 ‘질문’을 먼저 만든다

포인트 시스템으로 배우는 SQL 로드맵: 적립·차감·만료를 끝까지 완성하는 20단계 계획