"돈이 오가는데, 서버가 죽으면 어떡하죠?"
저희 시스템은 매일 아침 5시, 크리에이터들의 누적 토큰 거래 볼륨을 정산하여 스마트 컨트랙트로 보상을 지급(Payout)합니다.
이 기능을 처음 설계할 때 제 머릿속을 맴돈 가장 큰 공포는 '이중 결제(Double Payout)'였습니다. 블록체인 네트워크는 일반적인 API 통신과 다릅니다.
혼잡도에 따라 응답이 몇 초에서 몇 분까지 지연되기도 하고, 뜬금없이 타임아웃이 발생하기도 합니다. 만약 백엔드 서버가 스마트 컨트랙트를 호출해 토큰을 전송해 놓고, DB에 '지급 완료' 처리를 하기 직전 찰나의 순간에 OOM(Out of Memory) 등으로 서버가 뻗어버린다면 어떻게 될까요? 다음 배치 스케줄러가 돌 때, 시스템은 이 유저를 '아직 정산받지 않은 상태'로 간주합니다. 그리고 똑같은 토큰을 또 지급하겠죠. 말 그대로 '돈이 복사되는 치명적인 금융 사고'가 터지는 아찔한 상황이었습니다.

섣부른 인프라 추가 vs 현재 리소스의 극대화
이런 동시성 문제와 이중 처리 방지를 고민할 때, 백엔드 개발자라면 자연스럽게 Kafka나 RabbitMQ 같은 메시지 큐(MQ)를 떠올리게 됩니다. 저 역시 "MQ를 붙여서 이벤트 기반으로 비동기 재시도 로직을 짜볼까?" 하는 강렬한 유혹에 빠졌습니다.
하지만 냉정하게 현실을 돌아봤습니다. 초기 스타트업의 인프라 환경에서 무거운 MQ 클러스터를 추가하고 운영하는 것은 명백한 오버엔지니어링이었습니다. 관리 포인트만 늘어나고 SPOF을 하나 더 만드는 꼴이 될 수 있었죠.
그래서 저는 결심했습니다. "주어진 자원(RDBMS)을 극한까지 쥐어짜보자." 현재 사용 중인 PostgreSQL의 트랜잭션 제어와 상태기반의Outbox 패턴을 결합하면, 외부 MQ 없이도 충분히 "정확히 한 번(Exactly-once)" 지급을 보장하는 견고한 시스템을 만들 수 있다고 판단했습니다.
트랜잭션 범위(Boundary)의 분리와 I/O 격리
가장 먼저 뜯어고친 것은 트랜잭션의 생명주기입니다. 처음에는 불안한 마음에 스마트 컨트랙트 호출부터 DB 업데이트까지 전부 하나의 @Transactional로 묶으려는 실수를 할 뻔했습니다. 하지만 앞서 말했듯 블록체인 응답은 느립니다. 긴 네트워크 I/O 동안 DB 커넥션을 물고 있으면, 순식간에 커넥션 풀이 고갈되어 서비스 전체가 마비됩니다.
그래서 저는 성능과 안정성을 위해 과감하게 로직을 찢었습니다. 스마트 컨트랙트를 호출하는 코드는 채굴(tx.wait())을 기다리지 않고, 네트워크에 트랜잭션을 던진 뒤 접수증(tx.hash)만 즉시 반환받는 Fire-and-Forget 형태로 구성했습니다.
// 블록체인 통신 구간: 트랜잭션 던지고 hash만 바로 리턴받음
public async swapTokenAndAllocate(input: ISwapWithAllocateInput): Promise<string> {
// ...
const tx = await contract.swapSingleBuyWithAllocate(
input.tokenAddress, amountOutMin, perPacked, { value: amountInWei }
)
return tx.hash
}
외부 통신 구간에서는 철저히 DB 트랜잭션을 배제하고, 모든 통신이 성공한 뒤 상태를 확정 짓는 마지막 찰나의 순간에만 짧고 굵게 @Transactional을 걸어 DB I/O를 격리했습니다.
Outbox 테이블과 멱등성(Idempotency) 보장
외부 통신과 트랜잭션을 분리했으니, 이제 "중간에 서버가 죽었을 때 어떻게 복구할 것인가?"를 해결할 차례입니다.
여기서 RewardPayout 테이블을 활용한 Outbox 패턴이 등장합니다.
블록체인으로 트랜잭션을 쏘기 전, 반드시 DB에 Payout 기록(PENDING)을 먼저 남깁니다. 이 구조의 진가는 장애가 발생하여 재시도(Recovery)할 때 빛을 발합니다. 실패한 건을 주워 담아 처리하는 스케줄러는 이전 Payout의 마지막 상태(latestPayoutStatus)를 확인하고 완벽하게 분기 처리합니다.

// 실패한 보상 재처리 스케줄러의 핵심 분기 로직
async processReadyCreatorFailRewards() {
// ...
if (target.latestPayoutStatus === PayoutStatus.SUBMITTED) {
await this.handleSubmitted(target) // 🚨 이미 돈이 나갔을 확률이 높은 상태
} else if (target.latestPayoutStatus === PayoutStatus.PENDING) {
await this.handlePending(target) // 돈이 안 나갔으니 처음부터 재시작
}
}
private async handleSubmitted(target: RewardTarget) {
const logs = await this.writeService.claimLogsForProcessingWithPending(target.userAddress)
// 💡 가장 중요한 포인트:
// 스마트 컨트랙트(네트워크 통신)를 다시 호출하지 않습니다!
// 이미 발급받은 latestPayoutId를 들고 바로 DB 최종 확정 단계로 직행합니다.
await this.finalizeRewardProcessing(target, logs, target.latestPayoutId)
}
서버가 찰나의 순간에 죽어 SUBMITTED(txHash 발급됨) 상태로 멈췄다면? 저는 이것을 "이미 스마트 컨트랙트가 실행되어 토큰이 전송되었을 확률이 100%"라고 간주했습니다.
따라서 handleSubmitted 로직에서는 블록체인 재전송을 원천 차단하고, 이미 확보한 txHash를 이용해 밀린 DB 작업인 잔여 볼륨 차감 및 완료 처리만 마저 마무리하게 됩니다. 어떤 타이밍에 크래시가 나더라도 결제는 딱 한 번만 일어나는 멱등성(Idempotency)을 완벽히 통제한 것입니다.
현재 아키텍처의 한계와 넥스트 스텝
시스템을 런칭하고 현재까지 데이터 정합성 이슈는 단 한 건도 없었습니다. 하지만 엔지니어로서 제가 만든 아키텍처가 결코 '은탄환(Silver Bullet)'이 아님을 뼈저리게 알고 있습니다. 운영을 하며 발견한 명확한 한계(Trade-off)와 이를 개선할 3가지가 존재합니다.
① Mempool 딜레마 (가장 아찔한 엣지 케이스) 현재는 트랜잭션을 쏘고 tx.hash를 반환받아야 DB 상태가 SUBMITTED가 됩니다. 만약 트랜잭션이 블록체인 Mempool에 들어간 직후, 서버가 tx.hash를 응답받기 0.1초 전에 죽는다면? DB는 PENDING인데 실제 돈은 나가는 최악의 상황이 아주 희박한 확률로 존재합니다.
- 개선안: 서버에서 트랜잭션을 쏘기 전에 미리 서명하여 txHash나 Nonce를 결정해 Outbox에 업데이트하겠습니다. 재시도 시에는 무조건 온체인 데이터를 먼저 조회해 해당 트랜잭션의 채굴(Mined) 여부를 교차 검증하는 방어 로직을 두르겠습니다.
② 단일 워커 병목 문제 (Scale-out의 한계) 현재는 Redis Custom Lock을 사용해 단 1대의 배치 인스턴스만 작업을 수행합니다. 하루 정산 대상자가 수십 명일 땐 문제가 없지만, 수만 명으로 스케일업 되면 단일 루프로는 정산에만 수 시간이 걸리는 병목이 확정적입니다.
- 개선안: PostgreSQL의 SELECT FOR UPDATE SKIP LOCKED 구문을 도입하겠습니다. 스케일 아웃된 여러 대의 워커가 대상자 목록을 행(Row) 단위로 겹치지 않게 가져가 병렬 처리하도록, DB 자체를 완벽한 분산 큐로 진화시킬 예정입니다.
③ 무거운 Polling 방식의 한계 매일 아침 5시마다 누적 볼륨을 계산하기 위해 거대한 테이블을 뒤지는 쿼리는 필연적으로 DB 락 경합과 CPU 스파이크를 유발합니다.
- 개선안: 유저가 거래를 하는 시점(Event)에 임계치 달성 여부를 판단하여 정산 대기열에 바로 집어넣는 이벤트 드리븐(Event-Driven) 트리거 방식으로 전환해, 배치의 부하를 평탄화(Flattening)하겠습니다.
마치며
솔직히 카프카(Kafka) 같은 세련된 인프라를 썼다면 상태 관리나 재시도(Retry) 구현이 훨씬 우아했을지도 모릅니다.
하지만 제한된 리소스 속에서 외부 I/O와 트랜잭션의 생명주기를 치열하게 고민하고, 오직 DB의 상태 값과 아웃박스 패턴만으로 분산 환경에서의 멱등성을 보장해 낸 이번 경험은 저에게 엄청난 엔지니어링 사고를 키워주었습니다.
"망가질 수 있는 모든 것은 망가진다." 신뢰할 수 없는 인프라 환경에서도 시스템은 반드시 결과를 보장해야 한다는 백엔드 개발자의 진짜 책임감을 배운, 제 엔지니어링 커리어의 가장 뜻깊은 트러블슈팅이었습니다.
'Project > 기록' 카테고리의 다른 글
| K8s 환경의 실시간 소켓 서버 최적화: 스케일아웃 이슈와 백엔드/인프라 설계 전략 (0) | 2026.02.27 |
|---|---|
| Batch 서버가 여러대가 띄워져 있고, 돈복사 버그 방지하기위해 분산락을 끼얹으면? (0) | 2026.02.26 |
| 소켓 서버 지탱하기: OS 커널부터 K8s 오케스트레이션까지의 선제적 최적화 (0) | 2026.02.20 |
| 매일 아침 9시의 ACU 스파이크: PostgreSQL 최적화에서 CloudFront 오프로딩까지 (0) | 2026.02.20 |
| Postgre의 MVCC 문제 해결 (1) | 2026.02.12 |