트랜잭션 기초: 적립/사용 처리 중 ‘중간 실패’가 있어도 데이터가 깨지지 않게 하기

지금까지는 “SQL이 정상적으로 실행된다”는 전제에서 원장과 조회를 만들었습니다. 하지만 실제 시스템은 언제든 실패할 수 있습니다. 네트워크가 끊길 수도 있고, 디스크가 꽉 찰 수도 있고, 잠깐의 락 경합으로 타임아웃이 날 수도 있습니다.

포인트처럼 숫자에 민감한 도메인에서 가장 위험한 상황은 여러 작업 중 일부만 성공하는 것입니다. 예를 들어 다음 같은 경우는 운영에서 큰 문제로 이어집니다.

  • 포인트 사용(차감) 원장은 기록됐는데, 주문 상태 업데이트가 실패했다
  • 결제는 성공했는데, 포인트 적립 원장 기록이 실패했다
  • balance 테이블을 함께 쓰는데, 원장만 기록되고 balance 갱신이 실패했다

이런 “반쯤 성공”을 막기 위한 도구가 트랜잭션(Transaction)입니다. 트랜잭션은 여러 SQL을 하나의 묶음으로 실행해서, 전부 성공하면 확정(COMMIT), 하나라도 실패하면 전부 되돌림(ROLLBACK)으로 처리합니다.

이 단원의 목적: 포인트 처리의 “원자성(Atomicity)” 확보

트랜잭션을 이해할 때 핵심은 “문법”이 아니라 “보장되는 성질”입니다. 이번 글의 목표는 아래 3가지입니다.

  1. 트랜잭션이 어떤 문제를 해결하는지(중간 실패, 일관성 깨짐)를 이해한다
  2. 포인트 적립/사용에서 트랜잭션을 어떻게 적용하는지 기본 패턴을 익힌다
  3. balance 테이블을 함께 쓸 때 반드시 지켜야 할 트랜잭션 규칙을 정리한다

1) 트랜잭션의 핵심 개념: “전부 또는 전무(All or Nothing)”

트랜잭션은 여러 SQL을 하나의 작업처럼 다룹니다. 아래처럼 생각하면 이해가 빠릅니다.

  • COMMIT: 지금까지의 변경을 “확정”한다
  • ROLLBACK: 지금까지의 변경을 “되돌린다”
  • BEGIN/START TRANSACTION: 변경 묶음을 시작한다

가장 단순한 형태의 흐름은 이렇습니다.

START TRANSACTION; -- 여러 작업 수행 -- 1) 포인트 원장 기록 -- 2) (필요 시) balance 갱신 -- 3) (다른 도메인이라면) 주문 상태 업데이트 등 COMMIT; -- 모두 성공하면 확정 -- 중간에 문제 발생 시 ROLLBACK; -- 전부 되돌림

2) 포인트 적립: 원장만 쓰는 경우의 트랜잭션 패턴

원장(point_history)만 사용하고, 잔액은 매번 SUM으로 계산하는 구조라면 적립은 단순히 INSERT 한 번입니다. 이 경우 트랜잭션이 “필수”는 아닐 수 있지만, 적립과 동시에 다른 작업(예: 이벤트 참여 기록, 쿠폰 사용 처리)이 묶인다면 트랜잭션이 필요합니다.

SET @uid := '11111111-1111-1111-1111-111111111111'; START TRANSACTION; -- 1) 포인트 적립 원장 기록 INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, memo, created_at) VALUES (@uid, 'FREE', 'CHARGE', 300, 'EVENT_20251214', '이벤트 적립', NOW()); -- 2) (예시) 이벤트 참여 처리 같은 다른 테이블 업데이트가 있다고 가정 -- UPDATE event_participation SET rewarded = 1 WHERE uid = @uid AND event_id = '...'; COMMIT;

위 흐름에서 중간 UPDATE가 실패하면 ROLLBACK으로 원장 INSERT도 되돌릴 수 있어, “이벤트 참여는 실패했는데 포인트만 적립되는” 상황을 막게 됩니다.

3) 포인트 사용: “차감 + 다른 상태 변경”이 함께 움직일 때가 많다

사용(차감)은 보통 주문/결제/예약 같은 다른 도메인과 함께 움직입니다. 그래서 트랜잭션이 더 중요해집니다. 예시는 “주문 상태를 결제완료로 바꾸면서 포인트를 차감”하는 상황입니다.

SET @uid := '11111111-1111-1111-1111-111111111111'; START TRANSACTION; -- 1) 포인트 차감 원장 기록 INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, memo, created_at) VALUES (@uid, 'FREE', 'USE', -150, 'ORDER_9001', '주문 결제에 사용', NOW()); -- 2) (예시) 주문 상태 변경 -- UPDATE orders SET status = 'PAID' WHERE order_id = '9001'; COMMIT;

만약 주문 상태 UPDATE가 실패한다면, ROLLBACK으로 포인트 차감 기록도 되돌려야 일관성이 맞습니다. “주문은 실패했는데 포인트만 빠졌다”는 상황이 가장 위험하기 때문입니다.

4) balance 테이블을 함께 쓸 때: 트랜잭션이 사실상 필수

point_balance 같은 요약 테이블을 두면 잔액 조회가 빨라집니다. 하지만 원장과 잔액이 “동시에” 움직여야 하므로, 트랜잭션 없이 처리하면 아주 쉽게 불일치가 생깁니다.

아래는 “적립 시 원장 INSERT + balance UPDATE”를 묶는 기본 패턴입니다.

SET @uid := '11111111-1111-1111-1111-111111111111'; START TRANSACTION; -- 1) 원장 기록 INSERT INTO point_history (uid, point_type, action_type, amount, ref_id, memo, created_at) VALUES (@uid, 'FREE', 'CHARGE', 200, 'EVENT_20251220', '이벤트 적립', NOW()); -- 2) 잔액 갱신 UPDATE point_balance SET balance = balance + 200, updated_at = NOW() WHERE uid = @uid; COMMIT;

위 방식은 point_balance에 해당 uid가 이미 존재한다는 전제입니다. 존재하지 않으면 UPDATE가 0행이 될 수 있으니, 초기 생성 로직(INSERT 또는 UPSERT)이 필요합니다. UPSERT는 DBMS별 문법이 다르므로, 이 부분은 다음 글에서 “동시성/잠금”과 함께 더 안전하게 다룹니다.

5) 트랜잭션이 해결하는 대표 문제 2가지

(1) 중간 실패로 인한 불일치

원장 INSERT는 성공했는데 balance UPDATE가 실패하면, “원장 기준 잔액”과 “balance 잔액”이 달라집니다. 트랜잭션은 둘을 한 묶음으로 처리해 “둘 다 성공/둘 다 실패”를 강제합니다.

(2) 부분 성공이 만들어내는 고객 체감 문제

사용자 입장에서는 “결제 성공”인데 포인트가 안 들어오거나, “결제 실패”인데 포인트가 빠져 있으면 바로 불신이 생깁니다. 트랜잭션은 이 체감 문제를 구조적으로 줄이는 도구입니다.

6) 트랜잭션의 한계: “동시에 여러 요청이 들어오면” 또 다른 문제가 생긴다

여기까지는 “실패하면 되돌린다”의 이야기였습니다. 그런데 포인트 시스템에서 더 어려운 문제는 동시성입니다. 예를 들어 같은 사용자가 동시에 두 번 결제를 눌렀거나, 두 개의 서버 인스턴스가 거의 같은 시점에 같은 uid의 잔액을 갱신하면 어떻게 될까요?

트랜잭션만으로는 “동시에” 들어오는 요청의 순서를 완벽히 통제할 수 없습니다. 이때 등장하는 것이 락(Lock)격리 수준(Isolation Level)입니다.

7) 트랜잭션 기본 습관: 실패를 가정하고 검증 루틴을 만든다

트랜잭션을 적용했으면, 최소한 아래 두 가지를 확인할 수 있어야 합니다.

  • 중간 실패 시 원장/잔액이 함께 롤백되는가
  • 성공 시 원장과 잔액이 같은 결과를 가리키는가
-- 원장 기준 잔액 SELECT SUM(amount) AS balance_from_ledger FROM point_history WHERE uid = @uid AND hide = 0; -- balance 테이블 잔액(사용하는 경우) SELECT balance AS balance_from_cache FROM point_balance WHERE uid = @uid;

두 값이 일치하지 않는다면, 그 시점부터는 “언제부터 어긋났는지”를 추적해야 합니다. 그래서 트랜잭션 도입 후에도 정기 점검 쿼리는 유지하는 편이 좋습니다.

다음 글 예고: 동시성(잠금) — 잔액이 마이너스가 되는 순간을 막는 방법

다음 글에서는 트랜잭션의 다음 단계인 “동시성”을 다룹니다. 특히 같은 uid에 대해 동시에 사용(차감)이 들어올 때, 잔액이 마이너스가 되거나 차감이 두 번 반영되는 문제를 어떤 잠금 전략과 쿼리 패턴으로 막을지 정리합니다.

댓글

이 블로그의 인기 게시물

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

집계 기초: SUM/COUNT로 잔액·기간별 적립/사용 리포트 만들기

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