Discord 봇 버튼 클릭 경쟁 조건과 결제대행사 동기화 문제 해결
목차
Discord 봇을 내부 운영 도구로 쓴 지 꽤 됐다. 슬래시 커맨드로 특정 동작을 트리거하거나, 시스템 이벤트를 채널에 알림으로 보내는 용도로 시작했는데, 개발팀 채널에 커밋·배포 알림을 자동으로 뿌리면 별도로 공유하는 수고가 줄어든다. 기능이 하나씩 붙으면서 등급봇도 그 연장선에서 만들어졌고, 오늘은 거기서 발견한 race condition이랑 결제대행사 sync 이슈를 잡았다.
버튼 클릭 race condition
Discord에서 메시지에 버튼을 붙이면, 누군가 버튼을 눌렀을 때 interaction 이벤트가 봇 서버로 날아온다. 문제는 여기서 시작했음. 같은 버튼을 두 명이 거의 동시에 눌렀을 때, 혹은 멀티 WAS 환경에서 각 인스턴스가 같은 이벤트를 수신했을 때, 처리가 두 번 일어날 수 있다.
등급봇의 경우 버튼 클릭이 DB 상태 변경을 수반하고, 그게 결제대행사 API 호출까지 이어지는 구조였다. 트랜잭션이 두 번 커밋되면 상태가 꼬임. 금융·결제 도메인에서 이건 단순 UI 버그가 아니라 데이터 정합성 문제다.
해결은 두 갈래로 잡았다.
버튼 즉시 비활성화. interaction 이벤트를 받자마자 - 비즈니스 로직 실행 전에 - 버튼이 담긴 메시지를 수정해서 disabled 상태로 업데이트한다. Discord는 interaction 응답을 3초 안에 보내야 하는 제약이 있는데, 버튼 비활성화를 첫 응답으로 처리하면 그 타이밍에 맞출 수 있다. 이후 같은 버튼을 누르려는 시도는 Discord UI 레벨에서 막힌다.
DB 레벨 중복 방지. 버튼 비활성화가 레이스를 줄이긴 하지만, 네트워크 지연이나 멀티 인스턴스 환경에서는 여전히 동시 진입이 가능하다. interaction_id 기준으로 unique constraint를 걸고 중복 진입 시 예외를 내도록 했음.
// interaction_id unique 보장 — 두 번째 진입에서 DuplicateKeyException → skip
INSERT INTO bot_interaction_log (interaction_id, status, created_at)
VALUES (:interactionId, 'PROCESSING', NOW())
ON DUPLICATE KEY UPDATE status = status;
첫 번째 방법만 쓰면 Discord 측 재전송이나 네트워크 재시도에 무방비 상태가 됨. 둘 다 걸어두는 게 맞다.
결제대행사 sync를 afterCommit으로 빼기
원래 구현은 트랜잭션 안에서 결제대행사 외부 API를 호출하는 구조였다. 흔히 보이는 패턴인데, 생각보다 위험하다.
- 트랜잭션 시작 → DB 업데이트 → 외부 API 호출 → 커밋 순서일 때, 외부 API가 성공했는데 이후 다른 이유로 롤백이 일어나면 DB는 원래 상태인데 외부 시스템은 이미 반영된 상태가 된다.
- 반대로 외부 API 응답이 느리면 트랜잭션이 길게 물린다. DB 커넥션 홀드 시간이 늘고, 외부 API 타임아웃이 트랜잭션 실패로 이어지기도 함.
afterCommit 훅으로 빼는 게 정석이다. 트랜잭션이 성공적으로 커밋된 후에만 외부 API를 호출하면 DB 롤백에 의한 불일치는 없앨 수 있다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void syncToPaymentGateway(GradeChangedEvent event) {
// 커밋 확정 이후 실행 — 트랜잭션 컨텍스트 없음
paymentGatewayClient.sync(event.getUserId(), event.getNewGrade());
}
이 훅 안에서는 트랜잭션 컨텍스트가 없으니, 별도 트랜잭션이 필요한 작업은 REQUIRES_NEW로 열어줘야 한다.
afterCommit이 완벽한 해법은 아님. 커밋은 됐는데 훅 실행 중 서버가 죽으면 외부 API 호출이 누락된다. 이걸 완전히 커버하려면 outbox 패턴이나 별도 재시도 큐가 필요한데, 지금은 afterCommit 실패 시 로그를 남기고 모니터링으로 수동 재처리하는 수준으로 타협했다. 트레이드오프를 알고 결정한 것과 모르고 넘어간 건 다르다.
멀티 WAS 환경 고려사항
WAS 인스턴스가 여러 대면 봇도 여러 인스턴스가 동시에 이벤트를 수신함. 단순 알림 전송은 중복 발송이 문제고, 상태 변경을 수반하는 버튼 처리는 위에서 다룬 race condition으로 직결된다.
Redis로 분산 락을 잡는 방법이 단순하고 실용적이다.
// SET NX — atomic 연산으로 첫 번째 인스턴스만 처리
String lockKey = "bot:interaction:" + interactionId;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", Duration.ofSeconds(30));
if (Boolean.TRUE.equals(acquired)) {
processInteraction(interactionId);
}
Leader Election은 인스턴스 중 하나를 리더로 지정해 이벤트 처리를 위임하는 방식이라 인터랙션마다 락을 잡는 것보다 오버헤드가 적다. 다만 리더가 죽었을 때 failover 타이밍에 이벤트가 유실될 수 있어서, 처리량이 높지 않은 내부 봇 수준에서는 인터랙션 단위 락이 더 단순하고 안전하다고 판단했다.
설정 변경 후 봇 갱신은 reload API를 통해 서버 재시작 없이 처리하도록 했음. 멀티 WAS면 모든 인스턴스에 reload 요청을 보내야 하는데 수동으로 하면 빠뜨리기 쉽다. 배포 스크립트에 인스턴스 순회 reload를 포함시켜두는 게 맞다.
사내 서비스를 만들다 보면 기능 하나가 화면에 버튼 하나 추가하는 것으로 끝나지 않는다는 걸 계속 체감함. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 다 엮여 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 사용자한테 이상한 화면이 나온다. 금융·결제 도메인은 특히 숫자 하나가 틀리면 신뢰가 무너진다. "대충 맞는 것 같다"로 넘어가면 반드시 다시 돌아온다.
작업 방식으로는 변경 전 현재 동작을 스크린샷이나 수치로 메모해두고, 수정 후 같은 케이스로 확인하는 걸 습관으로 유지하고 있다. 커밋 메시지는 "무엇을"보다 "왜"를 담으려고 하고, 논리적으로 독립된 단위로 커밋을 쪼개면 문제가 생겼을 때 어느 변경에서 깨졌는지 찾기 훨씬 쉽다.
댓글 0
첫 댓글 달아줘.