자동화 slecs

Discord 봇 버튼 중복 처리와 멀티 인스턴스 race

목차

Discord를 내부 운영 도구로 쓰기 시작하면서 슬래시 커맨드나 버튼 컴포넌트가 꽤 편하다는 걸 알게 됐다. 커밋·배포 알림을 자동으로 채널에 뿌리면 별도 공유 없이 팀이 흐름을 잡고, 특정 동작을 버튼 하나로 트리거하면 내부 어드민 페이지를 따로 열 필요가 없어진다. 근데 이번 작업 하면서 "버튼 하나 추가"가 얼마나 복잡해질 수 있는지 다시 실감했음.

버튼 클릭 race condition이 왜 생기나

Discord 인터랙션은 HTTP 웹훅 방식이다. 사용자가 버튼을 누르면 Discord 서버에서 우리 엔드포인트로 POST 요청을 날린다. 문제는 사람이 버튼을 빠르게 여러 번 누르거나, 두 명이 거의 동시에 같은 버튼을 누르면 요청이 겹친다는 점이다.

메시지 컴포넌트 이벤트는 Discord가 중복 방지를 해주지 않는다. 같은 버튼에 대한 요청이 여러 인스턴스에 동시에 도달할 수 있고, 각각 독립적으로 "아직 처리 안 됐네"라고 판단하고 동작을 실행해버린다. 결과적으로 같은 작업이 두 번 실행되거나 상태가 꼬인다.

기본 대응은 두 레이어로 나뉜다.

  • 애플리케이션 레벨: 버튼 클릭 즉시 비활성화. interaction.deferReply() 또는 interaction.update()로 버튼 disabled: true 상태를 먼저 응답한 뒤, 실제 로직을 실행한다.
  • 인프라 레벨: 멀티 인스턴스 환경이라면 분산 락이나 Leader Election으로 단일 처리자를 보장한다.

버튼 즉시 비활성화는 UX 측면에서도 중요하다. 사용자 입장에서 클릭 후 아무 반응이 없으면 "눌렸나?" 하고 다시 누른다. Discord는 인터랙션에 3초 안에 응답하지 않으면 "인터랙션 실패" 오류를 띄우기 때문에, defer라도 빠르게 보내야 한다. 피드백이 즉각적일수록 중복 클릭 자체가 줄어들고, 3초 타임아웃도 자연스럽게 피하게 된다.

afterCommit 훅으로 외부 API 동기화 분리

버튼 동작이 DB 트랜잭션을 포함하면서 외부 API 연동도 있을 때, 처리 순서를 잘못 잡으면 외부 동기화만 성공하고 DB는 롤백되는 상황이 생긴다. 반대로 트랜잭션 안에서 외부 API를 호출하면 API 응답이 느릴 때 DB 커넥션을 불필요하게 붙잡게 된다.

이번에 afterCommit 훅으로 분리한 이유가 거기 있다. DB 커밋이 확정된 이후에만 외부 동기화를 시작하면 트랜잭션 롤백 때 외부 API가 이미 호출되는 일이 없다. 커넥션 점유 시간도 줄어든다.

@Transactional
public void handleButtonInteraction(InteractionEvent event) {
    // 1. DB 상태 변경
    updateEntityStatus(event.getTargetId(), Status.PROCESSING);

    // 2. 커밋 확정 이후 외부 동기화
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronizationAdapter() {
            @Override
            public void afterCommit() {
                externalApiService.syncAsync(event.getTargetId());
            }
        }
    );
}

트레이드오프가 없는 건 아니다. 비동기로 넘기면 실패 시 재시도 로직을 별도로 둬야 하는 부담이 생긴다. 하지만 외부 API 응답이 느리거나 불안정한 경우라면 동기 호출이 오히려 더 큰 리스크다. 어느 쪽을 택할지는 외부 API 신뢰도와 실패 허용 범위에 따라 다르다. "외부 동기화가 실패해도 내부 상태는 정합해야 한다"는 원칙만 지키면 방향은 자연스럽게 잡힌다.

멀티 WAS에서 봇 인스턴스 관리

WAS가 여러 대면 봇 프로세스도 여러 개가 동시에 Discord 이벤트를 받는다. 아무 처리 없이 두면 같은 이벤트를 두 인스턴스가 모두 처리해 중복 알림이 발생한다. Redis를 이용한 Leader Election이 이 상황에서 가장 간단한 해결책이다.

// 인스턴스 시작 시 "bot:leader" 키 획득 시도
// 성공한 인스턴스만 이벤트 처리, 나머지는 skip
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent("bot:leader", instanceId, Duration.ofSeconds(30));

if (Boolean.TRUE.equals(acquired)) {
    // 이 인스턴스가 leader → 이벤트 처리
} else {
    // 다른 인스턴스가 leader → skip
}

주의할 점은 leader가 죽었을 때의 failover다. TTL을 너무 길게 잡으면 leader 장애 후 다른 인스턴스가 역할을 넘겨받는 데 오래 걸리고, 너무 짧으면 네트워크 순간 지연에도 leader가 교체돼버린다. 통상 30~60초로 잡고 heartbeat로 주기적으로 갱신하는 패턴이 무난하다.

상황 TTL 짧게 (예: 5초) TTL 길게 (예: 60초)
leader 정상 동작 중 잦은 갱신 오버헤드 안정적
네트워크 순간 지연 불필요한 leader 교체 버텨냄
leader 실제 장애 빠른 failover 최대 60초 공백

설정 변경 시 reload API로 재시작 없이 봇을 갱신할 수 있게 한 건 운영 측면에서 꽤 편하다. 다만 멀티 WAS에서는 각 서버에 reload 요청을 순차로 보내야 한다. 한 서버에만 보내면 나머지는 구 설정으로 동작하는 불일치 구간이 생긴다. 배포 스크립트에 reload도 포함시켜 두는 편이 낫다.


사내 도구는 사용자 규모가 작아서 대충 돌아가도 티가 잘 안 난다. 그게 오히려 함정이다. 버그가 있어도 트래픽이 적으면 드러나는 빈도가 낮고, 드러났을 땐 이미 신뢰가 깎인 상태다. 특히 금융·결제 도메인에서 상태를 바꾸는 버튼이라면 "되는 것 같다"로 넘어가면 반드시 다시 돌아온다.

기능 하나가 단순히 화면에 버튼 하나 추가하는 것으로 끝나지 않는다는 걸 계속 체감함. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 다 엮여 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 사용자에게 이상한 화면이 나타난다. 이번처럼 분산 환경까지 겹치면 고려해야 할 레이어가 하나 더 쌓인다.

작업 방식은 계속 이 루틴으로 굳혀가는 중이다. 변경 전에 현재 동작을 스크린샷이나 수치로 메모해두고, 수정 후 같은 케이스로 확인, 관련 화면이 있으면 숫자 cross-check. 커밋은 "무엇을" 보다 "왜"를 담으려고 노력하고, 논리적으로 독립된 단위로 쪼개는 습관을 유지 중이다. 이렇게 해두면 문제가 생겼을 때 어느 변경에서 깨졌는지 찾는 시간이 확 줄어든다.

댓글 0

첫 댓글 달아줘.