Discord 등급 명령에 자동 원복과 결제 동기화 추가
목차
Discord를 내부 운영 도구로 쓰기 시작한 건 꽤 됐다. 슬래시 커맨드로 배포를 트리거하거나, 커밋 이벤트를 개발 채널에 자동으로 올려서 "나 배포했어요" 메시지를 따로 보내는 수고를 줄이는 식. 이번엔 여기에 /등급 슬래시 커맨드를 추가했는데, 요구사항이 조금 복잡했다. 10분 후 자동 원복, 외부 결제대행사 API 동기화, 멀티 WAS 환경에서 중복 처리 방지까지.
"버튼 하나 추가"처럼 들리지만 뜯어보면 상태 머신, 스케줄러, 외부 API 호출, 중복 방어 로직이 전부 맞물려 있다. 어느 하나만 빠뜨려도 숫자가 안 맞거나, 타이밍이 꼬여서 엉뚱한 등급으로 굳어버리는 케이스가 생김.
/등급 커맨드와 자동 원복
슬래시 커맨드 자체는 어렵지 않다. Discord Interaction을 수신해서 DB에 기록하고 응답하면 된다. 문제는 "10분 후 원복"이었다.
가장 단순한 방법은 커맨드 실행 시점에 schedule()로 10분 뒤 작업을 예약하는 것. 그런데 서버가 재시작되면 메모리에 있던 스케줄은 날아간다. 그래서 스케줄 정보를 DB에 저장하고, 앱 기동 시 SCHEDULED 상태인 레코드를 스캔해서 남은 시간을 계산한 뒤 재등록하는 방식을 택했음.
// 앱 기동 시 미완료 스케줄 복구
List<GradeSchedule> pending = gradeScheduleRepo.findByStatus(ScheduleStatus.SCHEDULED);
for (GradeSchedule s : pending) {
long delay = Math.max(0,
s.getRevertAt().toEpochMilli() - System.currentTimeMillis());
scheduler.schedule(() -> revertGrade(s.getId()), delay, TimeUnit.MILLISECONDS);
}
여기서 중요한 부분이 중복 INSERT 방어다. 멀티 WAS 환경에서 동일한 유저에 대해 SCHEDULED 레코드가 두 개 이상 생기면 원복이 두 번 실행되거나, 순서가 꼬여서 잘못된 등급으로 확정될 수 있다. (user_id, status) 조합에 unique key를 걸어서 DB 레벨에서 막았다. 애플리케이션 코드로만 막으면 레이스 컨디션에서 뚫린다. 특히 이런 상태 전이는 DB constraint가 최후 방어선이 되어야 한다.
결제대행사 동기화와 재시도 전략
등급 변경은 내부 DB만 바꾸는 게 아니라 외부 결제대행사 API에도 반영해야 한다. 외부 API 호출이 붙는 순간 "한 곳에서 실패했을 때 어떻게 처리할 것인가"가 설계의 핵심이 됨.
정석은 Outbox 패턴이다. DB 트랜잭션 안에서 변경 이벤트를 outbox 테이블에 기록하고, 별도 프로세스가 outbox를 읽어 외부 API를 호출한다. 롤백이 되면 outbox 레코드도 같이 롤백되니 일관성이 보장된다. 이번엔 거기까지 가지 않고 1회 재시도 + 실패 시 채널 알림으로 타협했다. 호출 빈도가 낮고 수동 보정이 가능한 내부 운영 도구라는 판단에서였음.
// 외부 등급 API 동기화 - 1회 재시도 포함
try {
gradeApiClient.sync(userId, newGrade);
} catch (ApiException e) {
log.warn("등급 API 1차 실패, 재시도. userId={}", userId, e);
try {
gradeApiClient.sync(userId, newGrade);
} catch (ApiException retryEx) {
log.error("등급 API 재시도 실패. userId={}, grade={}", userId, newGrade, retryEx);
alertChannel.send("등급 동기화 실패 - 수동 확인 필요: " + userId);
}
}
재시도 간격이 0이면 같은 이유로 연달아 실패할 가능성이 높다. Exponential Backoff를 쓰면 일시적인 네트워크 문제나 외부 서비스 과부하 상황에서 훨씬 유리하다. 외부 API 의존도가 높아지면 Resilience4j 같은 라이브러리를 붙이는 게 맞는 방향이고, 지금 수준에서는 단순 1회로 충분하다고 봤다.
멀티 WAS에서의 중복 처리 방지
WAS가 여러 대면 봇 인스턴스도 여러 개 뜬다. 모든 인스턴스가 동일한 Discord 이벤트를 수신하면 같은 커맨드가 두 번, 혹은 그 이상 처리될 수 있음. Leader Election으로 해결했다.
// Redis SETNX로 leader 획득 (TTL: 30초, 주기적으로 갱신)
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent("discord:leader", instanceId, Duration.ofSeconds(30));
if (!Boolean.TRUE.equals(acquired)) {
return; // follower - 이벤트 처리 skip
}
// leader 인스턴스만 아래 처리 실행
이 방식의 약점은 명확하다. leader가 처리 도중 다운되면 TTL이 만료될 때까지 이벤트가 처리되지 않는다. 30초 TTL이면 최대 30초 지연이 생긴다는 뜻이다. 외부 사용자가 체감하는 서비스가 아니라 내부 운영 도구라서 수용 가능한 trade-off로 판단했다. 더 빠른 장애 복구가 필요하다면 주기적인 heartbeat로 TTL을 짧게 유지하거나, Zookeeper나 etcd 같은 분산 코디네이션 레이어를 붙이는 방향으로 발전시킬 수 있다.
설정 변경 시엔 reload API를 열어서 재시작 없이 봇을 갱신할 수 있도록 했음. 멀티 WAS에서는 배포 스크립트가 각 인스턴스에 순서대로 reload 요청을 보내는 방식으로 자동화했다.
금융/결제가 엮인 도메인에서 계속 체감하는 건, 사내 도구라도 숫자 하나가 틀리면 신뢰가 무너진다는 거다. "대충 맞는 것 같다"로 넘어가면 반드시 다시 돌아온다. 그 시점엔 원인 추적도 어렵고, 이미 잘못된 데이터가 쌓인 뒤라 보정 비용도 크다.
작업 방식은 간단히 유지하고 있다.
- 변경 전 현재 동작 수치 메모 또는 스크린샷
- 수정 후 동일 케이스로 재확인
- 관련 화면 있으면 숫자 cross-check
- 커밋 메시지에 "무엇을"보다 "왜"를 담기
마지막 항목이 생각보다 중요하다. fix: 등급 API 재시도 추가보다 fix: 일시 네트워크 오류로 등급 동기화 누락되는 케이스 대응이 세 달 뒤 git log를 뒤질 때 훨씬 쓸모 있다. 쓰는 데 30초 더 걸리지만, 나중에 "왜 이걸 바꿨지?"를 코드로 추적하는 시간이 훨씬 길다.
작은 커밋을 논리 단위로 쪼개는 것도 마찬가지다. 이번처럼 스케줄러, 외부 API, 중복 방어가 동시에 들어가는 작업은 하나로 몰아넣고 싶은 유혹이 있다. 그렇게 하면 테스트가 어렵고, 뭔가 깨졌을 때 어느 변경에서 터진 건지 특정하기 힘들다. 당장은 번거롭지만 이틀 뒤 디버깅할 때 그 번거로움의 본전을 충분히 뽑는다.
댓글 0
첫 댓글 달아줘.