같은 데이터베이스, 서로 다른 잔액
서비스 백엔드는 레거시(PHP)와 신규(Node)가 같은 데이터베이스를 써요. 크레딧 충전은 주로 레거시(PHP)가 처리하고, 문자 발송에 따른 차감은 신규(Node)가 맡아요.
두 서비스가 같은 DB를 쓰니 잔액도 자연스럽게 일치할 것처럼 보여요. 하지만 실제 정합성을 좌우한 것은 데이터베이스 공유 여부가 아니라 어느 데이터를 잔액의 기준값(Source of Truth)으로 삼느냐였어요.
신규(Node)는 잔액의 기준을 현재 크레딧 기준값으로 옮겼어요. 그 뒤 문자 포인트를 차감할 때는 이 값을 바탕으로 계산하고 그 결과를 원장의 잔액 스냅샷에 기록했어요. 문제는 레거시(PHP)에 같은 변경을 적용하지 않았다는 점이에요. 레거시(PHP)는 충전 금액을 원장에만 기록하고 현재 크레딧 기준값은 갱신하지 않았어요.
가령 기준값이 0일 때 레거시(PHP)가 일정 포인트를 충전하면 원장에는 충전 금액이 기록되지만 신규(Node)가 보는 기준값은 여전히 0이에요. 이 상태에서 문자를 한 통 발송하면 신규(Node)는 0에서 발송 비용을 차감해 새 잔액으로 기록해요. 방금 충전한 금액이 다음 차감 시점에 사라진 것처럼 보이는 잔액 되감기가 발생한 거예요.
여기에 레거시(PHP) 발송 API의 검증 로직까지 겹쳤어요. 원장의 마지막 잔액 스냅샷이 0 이하이면 HTTP 402로 발송을 거절했어요. 이 때문에 충전 직후에는 한 통만 발송되고 그 뒤로는 계속 차단될 수 있었어요. 실제로 한 고객 기관의 계정에서도 포인트를 구매하고 입금했지만 문자를 발송할 수 없는 문제가 확인됐어요.
크레딧 자체가 사라진 것은 아니었어요. 충전 데이터는 남아 있었지만 두 서비스가 서로 다른 잔액을 기준으로 판단하면서 사용자에게는 쓸 수 없는 포인트가 됐어요.
레거시 쓰기 경로에서 신규 기준값도 갱신하기
해결의 핵심은 레거시(PHP)와 신규(Node)가 같은 잔액 기준값을 유지하게 만드는 것이었어요.
레거시(PHP)의 충전·차감·취소는 모두 공통 원장 쓰기 메서드를 거쳐요. 각 호출 지점을 일일이 수정하는 대신 공통 원장 쓰기 경로에서 현재 크레딧 기준값도 함께 갱신하도록 바꿨어요. 레거시(PHP)가 원장에 변동을 기록할 때 신규(Node)가 읽는 기준값도 갱신되므로, 다음 차감에서 잔액이 충전 이전 값으로 되감기는 경로를 막을 수 있었어요.
공유 DB는 데이터 접근만 공유할 뿐 그 의미까지 저절로 통일해 주지는 않아요. 레거시와 신규 서비스가 공존하는 동안에는 다음 항목을 명확히 관리해야 해요.
- 잔액의 기준 저장소가 어디인지
- 어떤 서비스가 그 값을 읽는지
- 모든 쓰기 경로가 기준값을 갱신하는지
- 원장의 잔액 값이 기준값인지, 계산 결과를 기록한 스냅샷인지
이 구분이 없으면 같은 테이블을 보는 서비스끼리도 서로 다른 잔액을 계산해요.
구매 확정 재실행과 중복 지급
잔액 정합성만 맞춘다고 끝나는 문제는 아니었어요. 같은 구매를 두 번 처리해 포인트가 중복 지급되는 문제도 있었어요.
크레딧을 원장에 추가하는 공통 적립 메서드에는 “이 구매를 이미 처리했는가”를 확인하는 멱등성 검사가 없었어요. 이 함수를 호출하는 경로는 여러 군데였고 스태프가 실행하는 ‘구매 확정’도 여기에 포함됐어요. 구매 확정을 다시 실행하면 같은 구매에 같은 금액이 또 적립됐어요. 실제 데이터에서는 서로 다른 복수의 고객 기관에 동일 금액이 두 번 지급된 사례가 확인됐고 초과 지급도 발생했어요.
금전성 데이터를 바꾸는 작업은 네트워크 재시도나 운영자의 재실행이 발생해도 결과가 한 번만 반영돼야 해요. 하지만 기존 로직은 호출 측에서 “함수를 한 번만 호출할 것”이라는 정상 흐름에 기대고 있었어요.
금액이 아니라 처리 완료 사실로 판단하기
초기 설계에서는 구매 식별자와 처리 유형에 해당하는 원장 금액을 합산해 이미 지급된 구매인지 판단하려 했어요. 지급 예정 금액만큼 원장에 쌓여 있다면 처리가 끝났다고 보는 방식이었어요.
하지만 리뷰 과정에서 이 방식은 정상 지급까지 누락할 수 있는 Critical 결함이 있다는 사실을 확인했어요. 원장의 금액 합계는 크레딧 변동의 결과일 뿐 특정 업무 단계가 끝났다는 직접적인 증거는 아니에요. 금액이 우연히 일치한다는 사실과 구매 확정 작업을 실행했다는 사실을 같은 의미로 봐서는 안 돼요.
최종 설계는 기존 코드의 선례를 따랐어요. 구매 대기 처리 스크립트에서 이미 쓰던 구매 확정 완료 단계 기록이 있는지를 멱등성 기준으로 삼았어요.
판정 기준은 이렇게 단순해졌어요.
해당 구매에 구매 확정 완료 step이 존재한다
→ 이미 구매 확정이 처리됐다
→ 크레딧을 다시 지급하지 않는다
금액을 역산하는 대신 명시적인 처리 기록을 확인해 재무 결과와 워크플로 상태를 분리했어요. 멱등성 키 역할을 하는 것은 “얼마가 적립됐는가”가 아니라 “이 구매의 확정 단계가 이미 수행됐는가”예요.
이 선택은 기존 시스템에서 이미 쓰던 판정 규칙과 일치해요. 새로운 상태 모델을 추가하지 않고도 검증된 업무 흔적을 재사용할 수 있고, 원장 금액의 형태를 추측할 필요도 없어요. 크레딧 원장은 잔액 변동을 기록하고 구매 확정 완료 단계 기록은 구매 확정이라는 업무를 실행했는지 나타내요. 각 데이터가 자신이 표현하는 사실만 맡는 셈이에요.
공유 DB 과도기에 남은 교훈
두 변경은 서로 다른 문제를 해결하지만 원칙은 같아요. 시스템이 판단해야 할 사실을 간접 데이터로 추측하지 않고 명시적인 기준값과 처리 기록에서 읽도록 한 거예요.
잔액 정합성 문제는 레거시(PHP)의 원장 쓰기 경로가 신규(Node)의 현재 크레딧 기준값까지 갱신하도록 바꿔 해결했어요. 이로써 충전 후 다음 차감에서 잔액이 되감기고 발송이 차단되는 경로를 막았어요. 중복 지급 문제는 원장 금액 합계가 아니라 구매 확정 완료 단계 기록이 있는지를 기준으로 처리 완료를 판단해 해결했어요. 같은 구매 확정을 다시 실행해도 크레딧이 재지급되는 경로를 차단했어요.
레거시를 현대화하는 과정에서 구버전과 신버전이 같은 DB를 쓰는 기간은 피하기 어려워요. 중요한 것은 저장소를 공유한다는 사실 자체가 아니에요. 모든 쓰기 경로가 하나의 기준값을 일관되게 갱신하는지, 재실행할 수 있는 작업에 명시적인 멱등성 근거가 있는지가 더 중요해요.
공유 DB는 통합이 끝난 상태가 아니라 과도기적으로 결합된 상태일 수 있어요. 이 기간을 안전하게 운영하려면 잔액에는 명확한 Source of Truth가 필요하고, 금전성 작업에는 결과와 분리된 처리 완료 기록이 필요해요.
근거: 관련 내부 변경사항