우리 서비스에는 AI 기능을 사용할 수 있는 가상 재화인 크레딧이 있다.
처음에는 무료로 지급했지만, 서비스 사용자가 늘면서 비용 부담도 함께 커졌다.
특히 한 번 호출할 때 수천 원의 비용이 발생할 수 있는 고급 비디오 모델까지 제공하기 위해서는, 결제 후 크레딧을 지급하는 유료 크레딧 기능이 필요해졌다.
처음에는 결제 성공 → 크레딧 증가 흐름을 구현하면 되는 일처럼 보였다.
충전 API와 환불 API를 만들고, 결제 서비스와 연결하면 끝날 것 같았다.
하지만 실결제와 연결되는 순간부터 고민할 것들이 꼬리에 꼬리를 물고 늘어났다.
PG사 결제와 결제 핸들링은 사내 다른 MSA 팀들이 담당했다.
우리 팀은 결제가 완료된 뒤
다른 서비스로부터 주문번호와 크레딧 변동량을 전달받고, 크레딧을 관리하는 역할을 맡았다.
그래서, 나의 역할과 책임을
결제 상태의 오너십은 결제 서비스에 있고, 크레딧 상태의 오너십은 우리 서비스에 있다. 에 기반해 정했다.
이 범위가 중요했던 이유는 데이터의 오너십을 정해야 했기 때문이다.
# 스프링캠프 2025 [Track 1] 4. 실전! MSA 트랜잭션 개발 가이드 (김용욱)
해당 영상에서 올바른 MSA 개발을 위해선, 데이터 오너십 원칙을 정하고
해당 서비스를 통해서만 수정하게 해야한다고 한다.
구현을 바로 시작하기보다, 무엇을 어디까지 보장할지 정하기로 했다.
돈이 연결된 민감한 로직에서는 '적당히 잘 동작한다'보다 '어떤 실패는 절대 허용하면 안 되는가'를 정하는 게 더 중요하다고 생각했기 때문이다.
먼저 다음 네 가지를 보장 기준으로 삼았다.
=> 접수된 요청은, '어떻게든 반영하게 한다' 를 목적으로 했다.
다른 서버와의 통신에서, 이 신뢰가 이루어지지 않으면 서로에게 고려할 게 더 많아진다고 생각했다.
구현 전 요구사항 명세는 아래와 같이 잡았다.
앞으로의 설명을 위해서 데이터 구조만 가볍게 짚고 넘어간다.
크레딧은 테이블 하나가 아니라, 매번 수정되는 현재 잔액과 수정하지 않는 두 개의 INSERT ONLY 이력(충전·차감 / 사용·복구)으로 나눴다.
왜 이렇게 나눴는지는 뒤에서 자세히 다룬다.

이제부터는 이 결정을 만든 각각의 고민들을 정리한다.

다른 팀과의 통신은 HTTP로 결정했다.
크레딧 충전·차감 행위를 메시지 기반으로 주고받았다면, 재시도와 타임아웃을 더 쉽게 다룰 수 있었을 것이다.
하지만 당시에는 비용 구조상 우리 서비스는 독자적인 인프라를 운영하고 있었고, 다른 팀과 메시지를 주고받을 환경을 바로 구축하기는 어려웠다.
대신 외부 통신은 HTTP로 유지하되, 요청 안에서 모든 크레딧 처리까지 끝내려고 하지 않았다.
요청을 빠르게 접수하고, 실제 처리는 카프카 컨슈머가 담당하도록 분리했다.
HTTP 요청 수신
→ 카프카 이벤트 발행
→ 요청 접수 응답
→ 컨슈머가 크레딧 처리
→ 처리 완료 콜백이 글의 코드는 모두 예시용이다. 클래스명과 불필요한 요소는 실제와 다르게 재구성했다.
public void charge(String requestId, String orderId, long amount) {
// 이미 처리한 요청이면 스킵 — 멱등성 (뒤에서 설명)
if (processedRequests.existsByRequestId(requestId)) {
return;
}
// 같은 주문의 중복 충전 방지
validateNotAlreadyCharged(orderId);
// 검증까지만 하고 발행 — 실제 반영은 consumer가 담당
publishChargeEvent(requestId, orderId, amount);
}카프카에 메시지만 넣고, 응답한 이유는 아래와 같다.
크레딧 반영과 콜백을 분리하면, 다음과 같은 상태 불일치가 생길 수 있다.
우리 서버는 크레딧을 반영했지만, 상대 서버가 완료 콜백을 받지 못할 수 있다.
이때 두 가지 선택지가 있었다.
나는 첫 번째를 선택했다. 크레딧 데이터의 오너십이 우리 서비스에 있다고 판단했기 때문이다.
관리하는 크레딧을 상대 서비스에 통보하지 못했다는 이유로 반영하지 않는 것은 오너십과 맞지 않았다.
따라서 처리 순서를 다음과 같이 잡았다.
이 순서의 핵심은 콜백 전송이 커밋 이후에만 실행되도록 걸어둔 부분이다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProcessed(CreditProcessedEvent event) {
// 이 시점에 크레딧 반영은 이미 커밋되어 있다 — 콜백은 결과 통보일 뿐
try {
callbackClient.send(buildCallback(event));
callbackResults.markSuccess(event.requestId());
} catch (CallbackRejectedException e) {
// 상대가 거절 — 재시도 대상이 아니므로 분류해 격리
callbackResults.markRejected(event.requestId(), e);
} catch (Exception e) {
// 전달 실패(5xx·네트워크) — 상태만 남기고 재전송에 맡긴다. 크레딧은 되돌리지 않는다
callbackResults.markDeliveryFailed(event.requestId(), e);
}
}AFTER_COMMIT 이 선반영을 보장하고 @Transactional 을 선언하지 않아 외부 API 와 통신하는 동안 DB 커넥션을 가지지 않는다.
이 결정으로 인해 상대 서비스와 일시적으로 상태가 어긋날 수는 있다.
대신 우리 서비스의 크레딧 상태는 한 번 확정되면 유지되고, 상대 서비스에 대한 통보는 별도로 복구할 수 있다.
결제와 연결된 흐름에서 같은 요청이 API로 한 번만 온다고 가정할 수 없다.
그래서 멱등성을 두 층에서 확인했다.
API 층의 목적은 불필요한 메시지가 MQ에 쌓이는 걸 방지하는 것이다.
요청의 Idempotency-Key를 기준으로 처리 이력이 있으면
Kafka 에 발행하지 않고 기존 처리 결과를 반환한다.
API 레벨의 방어는 앞서 charge() 첫 번째 검사
if (processedRequests.existsByRequestId(requestId))
API 레벨을 통과했더라도 Consumer에 같은 메시지가 다시 들어올 수 있다.
주문ID 123 요청 -> (123 처리 되기 전) -> 주문ID 123 재 요청막아야 하는 것은 두 가지다.
동시 반영은 카프카의 구조로 막았다. 크레딧 작업을 Kafka Consumer를 통해서만 처리하게 했다.

이를 통해, 위 케이스는 아래와 같이 동작한다.
@Transactional
public void process(CreditRequestEvent event) {
// Consumer 레벨 멱등성 — 처리 이력이 있으면 이미 반영된 요청
if (processedRequests.existsByRequestId(event.requestId())) {
return;
}
validate(event);
// 처리 이력과 크레딧 반영이 한 트랜잭션
saveProcessedRequest(event);
var result = processorFor(event).process(event);
// 커밋 이후 콜백 전송 - 앞 절 참고
eventPublisher.publishEvent(result);
}처리 이력 저장과 크레딧 반영을 같은 트랜잭션에서 처리해서
=> 요청이 멱등하게 처리된다!
충전과 환불 요청이 거의 동시에 들어올 때 처리 순서가 달라질 가능성도 고려해야 한다.
충전 → (0.1초 후) → 환불순서가 뒤집혀 환불이 먼저 처리되면, 아직 충전되지 않은 크레딧을 환불하려는 요청이 된다.
이 역시 Kafka의 구조로 해결했다.
// 충전과 환불이 같은 발행 경로를 탄다 — 같은 토픽, 같은 key 규칙
sendCreditEvent(CreditRequestType.CHARGE, requestId, orderId, amount);
sendCreditEvent(CreditRequestType.DEDUCT, requestId, orderId, amount);
private void sendCreditEvent(CreditRequestType type, String requestId, String orderId, long amount) {
var event = CreditRequestEvent.of(type, requestId, orderId, amount);
// 주문 ID가 key — 같은 주문은 같은 파티션으로
kafkaTemplate.send(topic, orderId, event);
}같은 key의 메시지는 같은 파티션에 들어가고, 파티션 안에서는 들어온 순서가 유지된다.
-> 따라서 같은 주문의 충전·환불 순서가 보장된다.
다만 이 보장이 적용되는 범위는 정확히 구분해야 한다.
주문 ID를 key로 사용하면 주문에 대한 순서를 보장할 수 있지만, 한 사용자의 서로 다른 주문까지 하나의 순서로 직렬화하는 것은 아니다. 이번에는 이를 고려할 필요가 없었다.
서로 다른 주문 사이에 지켜야 할 순서가 없고, 잔액의 동시 수정은 별도 장치(Lock)가 막기 때문이다.
크레딧 데이터는 하나의 테이블이 모든 역할을 맡도록 하지 않았다.
처음에는 충전 크레딧 테이블을 별도로 두지 않고, 모든 이력을 합산해서 잔액을 계산하는 방법을 생각했다.
이력만을 근거로 현재 상태를 계산하면, 불일치 가능성을 고려할 필요가 없기 때문이다.
하지만 크레딧 만료 정책과 장기간의 환불 가능성을 고려해야 했다.
의사결정 끝에 정해진 기간은 5년이었다.
상법 제64조상 상행위로 인한 채권은 5년의 소멸시효가 있고, 구매일로부터 5년까지 환불을 해줘야 하는 의무가 있다고 판단했다.
잔액을 조회할 때마다 장기간의 이력을 모두 조회해서 합산하는 방식은 비효율적이었다.
캐시에 이전 데이터를 누적하고 최근 변경분만 계산하는 방식도 생각할 수 있었지만,
캐시를 채우는 배치와 과거 거래 환불 처리까지 고려하면 복잡도가 커졌다.
그래서 방향을 바꿨다. 현재 잔액은 빠른 조회를 위해 수정 가능한 상태로 두되
을 통해 왜 그 값인지 설명하고 추적 가능한 상태로 만들었다.
대신 "수정 가능한 상태" 가 본질적으로 가지는 문제, 동시 수정을 고려해야 했다.
크레딧을 수정하는 행위는 크게 충전, 차감, 사용 이 있다.
같은 사용자의 결제 크레딧을 서로 다른 요청이 동시에 수정하면 잔액 계산이 꼬일 수 있다. (Lost Update)
그래서 '사용자 식별자' 기준으로 Redis Lock을 걸고, 한 번에 하나의 크레딧 수정 작업만 처리하도록 했다.
앞서 순서의 단위가 '주문번호'였던 것과 다르다. 순서가 지키는 건 같은 주문 안의 순서고,
Lock이 지켜야 하는 건 '한 사용자의 잔액'이다. 기능 사용 시 특정 주문이 아닌, 크레딧 전체를 건드리므로 사용자 기준이어야 했다.
멱등성과 Lock은 비슷해 보이지만 해결하는 문제가 다르다.
둘 중 하나만으로는 부족했다.
같은 요청의 중복은 멱등성으로 막고, 서로 다른 변경의 충돌은 Lock으로 제어해야 했다.
하지만, 위 Lock으로는 한계가 존재한다. Lock은 HTTP 요청이 끝나면 풀린다.
하지만, 실제 차감은 Kafka Consumer에서 일어난다.
1000 차감 요청 & 응답 -> 400 결제 크레딧 사용 -> 1000 Consumer 차감 처리차감 요청과 요청 처리 사이에 사용자 요청이 들어오면, 잔액은 아직 1000이므로 400 사용은 성공한다.
뒤이어 Consumer가 1000을 차감하면 잔액은 -400이 된다. 즉, 이중 지출이 발생한다.
사용자 단위 Lock을 컨슈머 처리 완료까지 잡고 있기에는,
처리가 끝날 때까지 사용자의 모든 크레딧 요청이 막히기에 UX적으로 상당히 불편함을 초래한다고 판단했다.
그래서 요청 수명보다 오래 사는 잠금이 필요했기에, DB에 영속되는 활성 플래그를 뒀다.
1000 차감 요청 -> 주문 ID 에 해당하는 크레딧 비활성화 -> 응답 -> 1000 Consumer 차감 처리사용 가능 크레딧 조회는 활성 크레딧만 보므로, 이제 400 사용 요청은 차감이 진행 중인 크레딧을 보지 못한다.
또한 주문 크레딧 한 건에만 걸기 때문에, 사용자의 다른 크레딧 처리를 막지 않는다.
@UserCreditLock // 사용자 단위 분산 락
public void deduct(String requestId, String orderId, long amount) {
// 이미 처리한 요청이면 스킵 — 멱등성
if (processedRequests.existsByRequestId(requestId)) {
return;
}
var credit = findCreditOrThrow(orderId);
credit.validateUsable(amount); // 활성 상태 + 차감 잔액 검증
// 접수 시점에 비활성화
credit.deactivate();
creditRepository.save(credit);
try {
publishDeductEvent(requestId, orderId, amount);
} catch (Exception e) {
credit.activate();
creditRepository.save(credit);
throw e;
}
}비활성화가 Lock 안에서 일어난다.
Lock이 풀리는 순간 크레딧은 이미 잠겨 있어서, 보호가 끊기는 순간이 없다.
(Lock 안에서 플래그로 책임을 넘기는 구조)
이때, 의도적으로 트랜잭션은 안 걸었다.
트랜잭션을 걸면 try-catch 구문이 필요 없어 보인다. '발행이 실패하면, active를 되돌려주잖아?'
실패 경로만 보면 맞다. 문제는 성공 경로이다.
1. 트랜잭션 BEGIN
2. active=false 저장
3. Kafka에 메시지 발행
4. 트랜잭션 COMMITKafka 발행 역시도 DB 트랜잭션에 참여하지 않는 외부 호출이다.
트랜잭션 안에서 발행하면 메시지는 커밋 전에 세상에 나간다.
Consumer가 커밋 전에 크레딧을 조회하면, 잠기지 않은 옛 상태를 읽는다!! ⭐️
이벤트를 받았을 때 크레딧은 잠겨 있어야 한다 전제가 깨지고,
최악의 경우에는 트랜잭션 커밋이 Consumer의 차감을 덮어쓸 수도 있다.
그래서 순서를 뒤집었다. 잠금을 먼저 커밋하고, 그다음 발행한다.
이 순서에선 롤백이 없으므로, 발행이 실패하면 잠금 해제를 직접 한다.
다른 선택지도 있었다.
AFTER_COMMIT + @TransactionalEventListener)순서 문제는 풀리게 된다. 하지만, 발행 실패가 HTTP 응답과 분리된다.
리스너로 미루면 "커밋은 됐는데 발행만 실패한 상태"를 호출자에게 알릴 방법이 없다.
이미 커밋된 뒤라 롤백도 불가능해 보상 코드도 사라지지 않는다.
이벤트를 같은 트랜잭션으로 DB에 쓰고, 별도 발행기가 커밋 이후 전송을 보장한다(재시도 포함 등).
발행 유실까지 막아주는 가장 교과서적인 방법이다. 하지만, 테이블 및 스케줄러 같은 인프라가 따라온다.
플래그 하나만 되돌리면 복구되는데, 기존 팀에 없는 Outbox 코드를 추가하는 건 과하다고 생각했다.
그래서 트랜잭션 없이, 커밋을 먼저 하고 실패는 직접 되돌리는 지금의 구조를 택했다.
장치마다 단위도 달랐다.
각 장치가 지키려는 불변식에 따라 자연스럽게 단위가 결정됐다.
비슷해 보이는 문제를 억지로 하나의 장치로 해결하려 하면 빈틈이 생긴다.
어떤 실패와 충돌을 막으려는지 먼저 구분하고, 그다음에 장치를 골라야 하는 걸 깨달았다.
현재 잔액 테이블은 빠른 조회에 필요하다.
하지만 UPDATE되는 상태만 두면, 그 값이 왜 맞는지 설명하기 어렵다.
그래서 현재 잔액과 함께 INSERT ONLY 이력을 보관했다.
잔액은 원본이 아니라 이력의 요약이고, 요약은 언제든 원본으로 검증할 수 있다.
주기적인 자동 검증(정합 스케줄러) 역시 가능하다.
우리 서비스는 크레딧 사용 중 오류가 나면 사용자의 크레딧을 복구해준다. (LLM 기능의 특성상 오류가 잦다)
반면 상대 서비스는 환불 의무 때문에, 잔여 크레딧이 있는 사용자의 탈퇴나 멤버 제거를 막아야 한다.
각각은 정상적인 정책이다. 하지만, 결합하면 문제가 된다.
이 문제는 어느 한쪽에 조건을 추가한다고 완전히 해결되지 않는다. 그래서 상대 서비스와 책임을 합의했다.
처리 중인 크레딧이 있는 동안의 판단은 상대 서비스가 하되, 판단에 필요한 정보는 우리가 제공한다.
그렇기에, 우리 서비스에서 잔여 크레딧을 알려 줄 때 현재 처리 중인 크레딧의 양도 함께 알려 주기로 했다.
{
"totalCredit": 1000,
"remainCredit": 600,
// 처리 중인 크레딧
"processingCredit": 400
}서비스 간 비즈니스 정책과 책임 범위를 먼저 확인하고 합의해야 한다는 걸 깨달았다.
각 서비스가 정상적으로 동작해도, 결합된 전체 흐름은 깨질 수 있기 때문이다.
결제 크레딧 시스템을 만드는 일은 충전·차감 API를 붙이는 일이 아니었다.
어긋날 수 있는 상태를 하나씩 찾아내고, 그것을 운영 가능한 형태로 바꾸는 일이었다.
1장에서 꼬리를 물던 질문들은 이렇게 정리됐다.
이 중에는 처음 설계에 없던 것도, 놓쳤던 것도 있다.
'의도적으로 걸지 않은 트랜잭션'이나 'Lock의 간극을 메우는 active 칼럼' 같은 것들은 만들면서 발견했다.
처음부터 완벽한 설계는 없었다.
대신 “이 실패는 어떻게 되지?”를 계속 묻는 습관이 시스템을 조금씩 단단하게 만들었다.
그리고 그 질문의 답은 대부분 정답이 아닌 선택이었다.
'어떤 실패를 감수하고, 어떤 실패를 절대 허용하지 않을 것인가.'
최선이 있으면 최선을, 없으면 최악이 아닌 차악을 고르는 것. 그 판단 자체가 설계다.