본문 바로가기
카테고리 없음

포인트 시스템 설계에서 배운 교훈: 잔액 관리의 한계를 넘어 Ledger 구조로 전환하기

by junah201 2026. 11. 4.

소프트웨어를 개발하다 보면 처음에는 간단해 보였던 기능이 실제 운영 환경에서 예상치 못한 문제를 일으키기도 합니다. 특히 포인트, 캐시백, 예치금처럼 금전적 가치를 지닌 데이터를 다룰 때는 단순한 CRUD 방식만으로 안정성을 보장하기 어렵습니다. 데이터의 변경 이력과 동시성, 오류 복구, 정산의 정확성까지 함께 고려해야 하기 때문입니다.

이번 글에서는 개인 외주 프로젝트로 개발하고 운영했던 포인트 시스템의 설계 경험을 바탕으로, 단순한 잔액 관리 방식이 가진 한계와 이를 개선하기 위해 도입한 Immutable Ledger(불변 원장) 구조를 살펴보겠습니다. 실제 개발 과정에서 마주했던 문제와 해결 방법, 그리고 구조를 변경한 이후에도 남아 있는 기술적 과제를 함께 정리합니다.

1. 포인트 시스템의 초기 설계가 단순했던 이유

초기 개발 단계에서는 요구사항을 충족하는 데 집중하기 쉽습니다. 그러나 포인트가 실제 금전적 가치와 연결되어 있다면, 기능 구현뿐 아니라 데이터의 정확성을 유지하는 방법까지 설계해야 합니다.

테더메이커와 테더모아의 서비스 구조

개인 외주 프로젝트로 개발한 테더메이커(TetherMaker)와 테더모아(TetherMoa)는 암호화폐 거래소의 추천인 커미션을 기반으로 운영되는 서비스입니다.

두 서비스는 기본적으로 유사한 비즈니스 모델을 공유합니다. 사용자가 추천인 코드를 통해 거래소에 가입하고 거래하면, 거래소는 추천인에게 일정 비율의 수수료를 지급합니다. 서비스 운영자는 이렇게 확보한 커미션 중 일부를 사용자에게 캐시백 형태로 돌려줍니다.

이러한 구조에서는 거래소에서 발생한 커미션 데이터를 주기적으로 수집하고, 이를 기반으로 사용자별 지급 가능 금액과 누적 캐시백을 관리해야 합니다. 특히 매일 실행되는 자동화 작업과 관리자 수동 작업이 함께 존재하기 때문에, 여러 작업이 동일한 사용자의 잔액을 변경할 가능성이 있습니다.

처음에는 잔액을 저장하고 필요할 때 수정하면 충분하다고 생각했습니다. 실제로 초기 요구사항 역시 사용자의 잔액을 보관하고 변경하는 정도에 머물러 있었습니다.

그러나 금전성 데이터를 관리하는 시스템에서는 현재 잔액만큼이나 그 잔액이 만들어진 과정이 중요합니다. 이 차이를 충분히 고려하지 않은 것이 이후 여러 문제의 출발점이었습니다.

2. Mutable Balance 방식에서 발생한 세 가지 문제

단일 잔액 필드를 직접 수정하는 방식은 구현하기 쉽고 조회 성능도 직관적입니다. 하지만 여러 작업이 동시에 실행되거나 과거 데이터를 복원해야 하는 상황에서는 구조적인 한계가 드러납니다.

2.1. 동시성 문제와 데이터 유실

초기 시스템에서는 사용자 테이블에 balance 필드를 두고, 포인트가 변할 때마다 해당 값을 업데이트했습니다.

예를 들어 다음과 같은 작업이 동시에 실행될 수 있습니다.

  • 관리자가 특정 사용자의 잔액을 직접 조정합니다.
  • 정기 실행되는 Cron Job이 거래소 커미션을 반영합니다.
  • Selenium 기반 자동화 프로그램이 추가 데이터를 수집하고 잔액을 변경합니다.

이러한 작업이 동일한 사용자 데이터를 동시에 읽고 수정하면 Race Condition(경쟁 상태) 이 발생할 수 있습니다.

가령 현재 잔액이 1,000포인트이고 두 작업이 각각 100포인트와 200포인트를 추가한다고 가정하겠습니다. 두 작업이 모두 기존 잔액 1,000을 읽은 뒤 각각 1,100과 1,200을 저장하면, 최종 잔액은 1,300이 아니라 1,100 또는 1,200이 될 수 있습니다.

물론 데이터베이스의 원자적 증가 연산이나 트랜잭션, 적절한 잠금 전략을 활용하면 이러한 문제를 방지할 수 있습니다. 문제는 단순한 잔액 수정 방식만으로는 동시성 제어가 자동으로 보장되지 않는다는 점입니다.

2.2. 변경 이력이 없을 때 발생하는 복구 문제

현재 잔액만 저장하고 이전 상태를 기록하지 않으면 잘못된 데이터가 입력되었을 때 정확한 복구 지점을 찾기 어렵습니다.

예를 들어 실수로 1,000포인트를 추가했다면, 이를 취소하기 위해 무조건 1,000포인트를 차감하는 것이 항상 올바른 해결책은 아닙니다. 그사이에 다른 포인트 지급이나 사용이 발생했다면, 잘못된 변경과 정상적인 변경을 구분해야 하기 때문입니다.

과거 기록이 없다면 오류가 발생한 시점의 잔액과 이후에 발생한 변경을 명확하게 분리하기 어렵습니다.

금전성 데이터에서 이러한 불확실성은 단순한 개발 편의성의 문제가 아닙니다. 고객 문의에 대한 대응, 정산 결과의 검증, 운영자의 실수에 대한 복구 가능성까지 영향을 미칩니다.

2.3. 잔액이 변경된 원인을 추적하기 어려움

잔액 숫자만으로는 포인트가 증가하거나 감소한 이유를 알 수 없습니다.

예를 들어 같은 500포인트라도 다음과 같이 서로 다른 의미를 가질 수 있습니다.

  • 신규 가입 이벤트에 대한 보상
  • 특정 프로모션 참여에 따른 지급
  • 거래소 커미션에 따른 캐시백
  • 사용자가 포인트를 사용하면서 발생한 차감
  • 관리자가 직접 실행한 잔액 조정

변경 사유와 발생 시각이 기록되지 않으면 이벤트별 지급액을 집계하거나, 일정 기간이 지난 포인트를 소멸시키거나, 특정 거래에 대한 이의를 검토하는 작업이 복잡해집니다.

결국 단일 잔액 필드는 현재 상태를 보여주는 데는 유용하지만, 상태가 변화한 과정까지 설명하기에는 부족한 데이터 모델이었습니다.

3. BalanceHistory 테이블 도입과 임시 개선의 한계

잔액 변경 이력을 확인할 수 있도록 별도의 BalanceHistory 테이블을 추가했습니다. 이 방식은 과거 기록이 전혀 없던 초기 구조보다 개선된 선택이었습니다.

변경 전후의 잔액이나 변동 금액을 기록하면, 특정 시점에 어떤 일이 발생했는지 확인하기가 한결 쉬워집니다.

이력 테이블만 추가해서는 해결되지 않는 문제

문제는 현재 잔액과 변경 이력이 서로 다른 데이터로 관리된다는 점이었습니다.

예를 들어 잔액 업데이트는 성공했지만 이력 저장에 실패하거나, 반대로 이력은 저장되었지만 잔액 업데이트가 실패한다면 두 테이블의 데이터가 서로 일치하지 않을 수 있습니다.

물론 두 작업을 하나의 데이터베이스 트랜잭션으로 처리하면 이러한 불일치 가능성을 줄일 수 있습니다. 그러나 모든 변경 경로에서 트랜잭션을 올바르게 적용해야 하며, 이력과 잔액의 관계를 지속적으로 관리해야 한다는 부담은 여전히 존재합니다.

또한 이력 테이블을 추가했다고 해서 동시성 문제가 저절로 해결되는 것도 아닙니다. 동시에 실행되는 작업의 순서와 잔액 계산을 별도로 제어해야 하기 때문입니다.

이 경험을 통해 단순히 기록을 추가하는 것보다 어떤 데이터를 원본으로 삼고, 현재 잔액을 어떤 규칙으로 계산할 것인지 명확히 정의하는 일이 더 중요하다는 사실을 알게 되었습니다.

4. Immutable Ledger 구조로 재설계한 방법

기존 구조의 한계를 해결하기 위해 금융 시스템에서 활용되는 원장(Ledger) 개념과 이벤트 기반 데이터 관리 방식을 검토했습니다.

핵심은 잔액을 직접 수정하는 대신, 잔액에 영향을 주는 모든 변화를 독립적인 기록으로 남기는 것입니다.

4.1. 변경보다 기록을 중심으로 설계하기

Immutable Ledger는 이미 기록된 거래 내역을 임의로 덮어쓰지 않고, 새로운 기록을 추가해 상태의 변화를 표현하는 설계 방식입니다.

기존 방식과 변경된 방식의 차이는 다음과 같습니다.

구분기존 잔액 관리Ledger 기반 관리

데이터 변경 잔액 필드 UPDATE 변경 이벤트 INSERT
과거 상태 별도 기록이 없으면 복원 어려움 이벤트 이력을 통해 추적
오류 정정 기존 값을 직접 수정하거나 차감 정정 기록을 추가
변경 사유 별도 구현이 필요 이벤트에 함께 저장 가능
잔액 계산 저장된 값을 직접 조회 기록된 변경을 집계하거나 스냅샷 조회

이 구조에서는 각각의 이벤트가 하나의 변경 사실을 나타냅니다. 포인트가 지급되거나 사용될 때마다 새로운 행을 추가하며, 기존 행은 일반적인 업무 처리 과정에서 수정하지 않습니다.

4.2. Version을 활용한 기록 순서 관리

여러 이벤트가 같은 사용자 계정에 연결될 수 있으므로, 각 기록의 순서를 구분할 수 있어야 합니다.

프로젝트에서는 사용자와 거래소 계정의 관계를 나타내는 userExchangeId와 version을 조합해 각 잔액 기록을 식별하도록 설계했습니다.

다음은 해당 구조를 간략하게 표현한 Prisma 모델입니다.

model UserBalance {
  userExchangeId          String   @db.Uuid
  version                 BigInt   @db.BigInt

  availablePaybackAmount  Decimal  @db.Decimal(20, 4)
  availablePaybackBalance Decimal  @db.Decimal(20, 4)

  totalPaybackAmount      Decimal  @db.Decimal(20, 4)
  totalPaybackBalance     Decimal  @db.Decimal(20, 4)

  createdAt               DateTime @default(now()) @db.Timestamptz(3)

  @@id([userExchangeId, version])
}

availablePaybackAmount와 totalPaybackAmount는 각각 해당 기록에서 발생한 변동 금액을 나타내고, availablePaybackBalance와 totalPaybackBalance는 해당 이벤트가 반영된 이후의 잔액을 나타냅니다.

실제 구현에서는 지급 가능 금액과 누적 지급액처럼 서로 다른 의미를 가진 값을 구분해 관리했습니다. Decimal 타입을 사용하는 이유는 금액 계산에서 부동소수점 오차를 피하고, 지정한 정밀도로 값을 다루기 위해서입니다.

복합 기본키인 (userExchangeId, version)은 같은 계정에서 동일한 버전이 중복 생성되는 것을 방지합니다. 다만 복합키만으로 이벤트의 순서가 자동으로 보장되는 것은 아닙니다. 새로운 버전을 할당하는 과정에서도 동시성 제어가 필요합니다.

또한 이 모델은 잔액 기록을 표현한 예시입니다. 엄밀한 이벤트 소싱을 구현하려면 변경 사유, 이벤트 유형, 원본 거래 식별자 등 도메인 이벤트에 필요한 정보도 함께 관리하는 것이 좋습니다.

5. Ledger 도입 이후 개선된 데이터 관리 방식

기록 중심의 구조를 적용하면서 잔액 변경을 추적하고 정정하는 방법이 명확해졌습니다. 특히 운영 과정에서 발생할 수 있는 오류를 어떻게 처리할지 규칙을 세우기 쉬워졌습니다.

5.1. 동시성 문제를 기록 순서의 문제로 전환

모든 변경을 INSERT로 처리하면 기존 기록을 덮어쓰면서 발생하는 일부 데이터 유실 문제를 피할 수 있습니다.

그러나 이것이 동시성 문제가 완전히 사라진다는 의미는 아닙니다. 같은 계정에 두 작업이 동시에 새로운 버전을 할당하면 동일한 version이 생성될 수 있습니다.

프로젝트에서는 현재 규모에 맞춰 버전 충돌이 발생하면 재시도하는 방식을 사용했습니다. 충돌한 작업은 최신 상태를 다시 읽고 다음 버전을 계산한 뒤 저장을 재시도합니다.

이 과정에서 중요한 것은 데이터베이스의 고유 제약 조건을 최종적인 중복 방지 장치로 활용하고, 충돌이 발생했을 때 전체 작업을 안전하게 다시 수행하는 것입니다. 외부 API 호출이나 포인트 지급처럼 중복 실행되면 문제가 되는 작업에는 별도의 멱등성(Idempotency) 설계도 필요합니다.

5.2. 오류를 정정하는 두 가지 방법

Ledger 기반 시스템에서는 오류를 수정할 때 기존 기록을 삭제하는 방법과 반대 방향의 거래를 추가하는 방법을 구분할 수 있습니다.

방법 A. Hard Delete

마지막에 추가한 기록이 잘못되었고, 해당 기록을 참조하는 다른 작업도 없으며, 관련된 모든 변경을 안전하게 되돌릴 수 있다면 해당 행을 삭제하는 방법을 고려할 수 있습니다.

예를 들어 마지막 이벤트가 잘못된 200포인트 지급이었다면 해당 기록을 제거하고 이전 상태를 다시 계산할 수 있습니다.

다만 Immutable Ledger의 핵심 원칙은 기록을 보존하는 데 있으므로, 운영 환경에서 이미 확정된 거래를 삭제하는 방식은 신중하게 사용해야 합니다. 후속 이벤트가 존재한다면 단순 삭제만으로 잔액이 올바르게 복구되지 않을 수도 있습니다.

방법 B. Counter-Entry

Counter-Entry는 잘못된 거래를 상쇄하는 반대 방향의 기록을 추가하는 방식입니다.

예를 들어 200포인트를 잘못 지급했다면 기존 기록을 삭제하는 대신 -200포인트의 정정 이벤트를 추가합니다. 이렇게 하면 원래 지급과 오류 정정이 모두 남아 있어, 나중에 데이터를 검토할 때 변경 과정을 명확하게 이해할 수 있습니다.

이 방식은 감사 추적과 운영 이력 보존이 중요한 시스템에서 특히 유용합니다. 단, 정정 이벤트를 생성할 때 원본 거래와의 관계를 기록하고, 정정이 중복 적용되지 않도록 관리해야 합니다.

5.3. 시간에 따른 포인트 정책 구현

이벤트별 발생 시각과 변동 내역을 보관하면 시간에 따라 달라지는 비즈니스 규칙을 구현하기가 쉬워집니다.

대표적인 활용 사례는 다음과 같습니다.

  • 포인트 유효기간 관리: 지급 시점을 기준으로 만료 예정 포인트를 식별하고 소멸 처리할 수 있습니다.
  • 프로모션 정산: 특정 기간에 발생한 이벤트를 조회해 프로모션별 지급액을 계산할 수 있습니다.
  • 사용자 행동 기반 보상: 특정 행동과 연결된 이벤트를 바탕으로 보상 지급 여부를 검증할 수 있습니다.
  • 분쟁 및 오류 조사: 특정 시점의 잔액과 그 이후에 발생한 변경 내역을 추적할 수 있습니다.

단, 포인트 소멸을 정확하게 처리하려면 잔액의 총합만 확인해서는 부족할 수 있습니다. 포인트마다 지급 출처와 만료 시점을 관리하거나, 어떤 지급분이 먼저 사용되는지에 대한 정책을 별도로 정의해야 합니다.

5.4. 확장성을 고려한 데이터 구조

Append-only 방식은 기존 데이터를 계속 수정하는 대신 새로운 기록을 추가한다는 특징을 가집니다. 이 특성은 변경 이력의 보존과 감사 추적에 유리하며, 이벤트를 다른 시스템으로 전달하는 구조를 설계할 때도 도움이 됩니다.

다만 INSERT 중심이라고 해서 자동으로 대용량 처리에 유리하거나 데이터베이스 잠금이 사라지는 것은 아닙니다. 특정 계정의 버전을 할당하는 작업은 여전히 경합할 수 있고, 모든 이벤트를 매번 집계하면 조회 비용이 커질 수 있습니다.

따라서 서비스가 성장하면 이벤트 기록과 조회용 잔액 데이터를 분리하거나, 주기적으로 스냅샷을 생성하는 방식을 검토할 수 있습니다. 데이터베이스를 변경하는 경우에도 이벤트 스키마와 정합성 규칙을 명확히 정의해 두면 마이그레이션 전략을 수립하는 데 도움이 됩니다.

6. Ledger 구조에서도 해결해야 할 기술적 과제

데이터 모델을 개선했다고 해서 운영상의 모든 문제가 해결되는 것은 아닙니다. 실제 서비스에서는 버전 충돌, 데이터 증가, 복구 전략 등을 지속적으로 점검해야 합니다.

6.1. Version 충돌과 재시도 전략

동일한 계정에 여러 작업이 동시에 기록을 추가하면 버전 충돌이 발생할 수 있습니다. 현재와 같이 규모가 작은 서비스에서는 데이터베이스 제약 조건을 이용해 충돌을 감지하고, 최신 버전을 다시 조회한 뒤 재시도하는 방식이 현실적인 선택일 수 있습니다.

하지만 요청량이 증가하면 재시도 횟수가 늘어나고, 같은 계정에 대한 작업이 반복해서 충돌할 가능성이 커집니다.

이때는 계정별 이벤트를 메시지 큐로 전달해 순차적으로 처리하거나, 데이터베이스의 조건부 쓰기 기능을 활용하는 방법을 검토할 수 있습니다. 어떤 방식을 선택하든 중복 이벤트 방지, 재시도 한도, 실패 작업의 복구 절차를 함께 설계해야 합니다.

6.2. BigInt 버전의 범위와 장기 운영

BigInt는 일반적인 서비스에서 버전 번호를 관리하기에 매우 넉넉한 범위를 제공합니다. 따라서 단순히 기록이 많아진다는 이유만으로 최대값 초과를 주요 위험 요소로 볼 필요는 없습니다.

오히려 실제 운영에서는 버전 증가 로직의 오류, 잘못된 초기화, 중복 생성, 조회 성능 저하 등을 먼저 점검하는 것이 합리적입니다.

버전이 데이터베이스에서 표현할 수 있는 범위에 가까워지는 상황까지 고려한다면, 무작정 순환시키는 방식보다는 명시적인 오류 처리와 데이터 마이그레이션 정책을 마련하는 편이 안전합니다. 스냅샷을 활용하더라도 기존 버전과 새로운 버전의 연결 규칙을 명확히 유지해야 합니다.

6.3. 데이터 용량 증가와 조회 성능

Append-only 구조에서는 이벤트가 누적되므로 저장 공간이 지속적으로 증가합니다. 데이터가 적을 때는 큰 문제가 되지 않더라도, 장기간 운영하면 인덱스 크기와 조회 비용이 부담이 될 수 있습니다.

이를 관리하려면 몇 가지 전략을 고려할 수 있습니다.

  • 인덱스 최적화: 계정 식별자, 버전, 발생 시각 등 실제 조회 조건에 맞춰 인덱스를 설계합니다.
  • 스냅샷 활용: 일정 시점까지의 잔액을 저장해 모든 이벤트를 반복해서 집계하지 않도록 합니다.
  • 파티셔닝 및 아카이빙: 데이터 증가 패턴과 보존 정책에 따라 테이블을 분할하거나 오래된 기록을 별도 저장소로 옮깁니다.
  • 조회 모델 분리: 원본 이벤트는 보존하면서 화면이나 API에서 자주 사용하는 잔액은 별도 조회 모델로 관리합니다.

NoSQL 데이터베이스로 이전하는 것도 선택지 중 하나지만, 단순히 이벤트가 많다는 이유만으로 데이터베이스를 바꾸는 것이 정답은 아닙니다. 트랜잭션 요구사항, 일관성 모델, 운영 비용, 쿼리 패턴을 종합적으로 검토해야 합니다.

7. 포인트 시스템 설계에서 얻은 실무적인 교훈

이번 경험에서 가장 큰 변화는 잔액을 저장하는 방법 자체보다 데이터를 바라보는 관점에 있었습니다.

초기에는 고객이 요청한 기능을 빠르게 구현하는 것이 중요하다고 생각했습니다. 그러나 서비스의 수익 구조와 정산 과정을 충분히 이해하지 못한 채 데이터 모델을 설계하면, 운영이 시작된 뒤 더 큰 비용을 지불할 수 있다는 사실을 배웠습니다.

특히 포인트와 캐시백처럼 금전적 가치가 있는 데이터는 단순한 숫자가 아닙니다. 해당 금액이 어떤 사건으로 발생했는지, 어떤 규칙으로 계산되었는지, 오류가 생겼을 때 어떻게 정정할 수 있는지까지 설명할 수 있어야 합니다.

이번 프로젝트에서는 변경 이력을 추가하는 임시 개선에서 출발해, 기록을 중심으로 상태를 관리하는 Ledger 구조로 전환했습니다. 그 결과 변경 과정을 추적하고 정정하는 규칙을 명확하게 정의할 수 있었으며, 장기적으로 필요한 정산과 감사 기능을 설계할 기반도 마련했습니다.

다만 Ledger는 모든 상황에서 단순한 잔액 테이블보다 우월한 선택은 아닙니다. 변경 이력이 중요하지 않은 단순한 카운터라면 기존 방식이 더 적절할 수 있습니다. 반면 정산 정확성, 감사 추적, 오류 복구가 중요한 시스템이라면 원장 중심의 설계를 검토할 가치가 충분합니다.

좋은 데이터 모델은 현재 요구사항을 만족하는 데서 끝나지 않고, 잘못된 데이터가 입력되었을 때 그 원인을 찾아 안전하게 복구할 수 있도록 설계되어야 합니다.

앞으로 비슷한 시스템을 개발한다면 구현에 앞서 도메인의 수익 구조와 데이터 변경 규칙부터 명확하게 파악하고자 합니다. 이번 시행착오가 향후 더 복잡한 정산 시스템과 대규모 데이터 처리 구조를 설계할 때 유용한 기준이 되기를 바랍니다.