멀티 WAS Discord 봇 중복 알림 방지와 무중단 설정 갱신 구현
목차
Discord를 내부 운영 도구로 쓴 지 꽤 됐다. 슬래시 커맨드로 특정 동작을 트리거하거나, 시스템 이벤트를 채널에 알림으로 쏘는 식으로. 개발팀 채널에 커밋이나 배포 알림이 자동으로 뜨면 "나 방금 배포했어요" 하고 별도로 공유할 필요가 없다. 이게 생각보다 편해서 활용 범위가 조금씩 늘어났는데, WAS를 여러 대 운영하면서부터 골치 아픈 문제가 생겼다.
WAS가 두 대면 봇도 두 인스턴스가 뜬다. 둘 다 같은 이벤트를 받으면 같은 채널에 같은 알림이 두 번 날아간다. 처음엔 "어? 방금 두 번 왔네" 수준으로 넘어갔는데, 알림 종류가 늘수록 중복이 쌓이고 채널이 빠르게 노이즈가 된다. 슬래시 커맨드 응답이 두 개씩 올라오는 건 더 눈에 띄어서 무시할 수 없는 수준이 됐다. 이번 작업은 이 두 가지를 잡은 것 — Leader Election으로 중복 알림 방지, reload API로 무중단 설정 갱신.
Leader Election으로 단일 인스턴스만 처리
해결 방향은 명확했다. 인스턴스 중 하나만 이벤트를 처리하게 하면 된다. Leader Election 패턴이고, Redis의 SET NX(Not eXists) + TTL 조합이 분산 환경에서 가장 구현이 단순하다.
// 인스턴스 시작 시, 그리고 주기적 heartbeat 시 호출
String leaderId = instanceId; // 각 WAS 고유 ID (예: hostname + port)
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent("discord-bot:leader", leaderId, Duration.ofSeconds(30));
if (Boolean.TRUE.equals(acquired)) {
// 리더 획득 성공 — 이벤트 처리
processEvent(event);
} else {
// 다른 인스턴스가 리더 — skip
log.debug("Not leader, skipping: {}", event.getType());
}
TTL을 30초로 잡은 건 리더 인스턴스가 죽었을 때 다른 인스턴스가 자동으로 리더를 이어받을 수 있게 하기 위해서다. 리더가 살아있는 동안엔 스케줄러가 주기적으로 키를 갱신(heartbeat)해서 TTL 만료를 막는다. heartbeat 주기는 TTL의 절반 이하로 잡는 게 일반적이다.
다만 이 방식에는 타이밍 이슈가 있다. 리더가 이벤트를 처리하는 도중 TTL이 만료되면 다른 인스턴스가 리더를 빼앗아갈 수 있고, 순간적으로 두 인스턴스가 동시에 같은 이벤트를 처리하는 상황이 생길 수 있다. 대안들을 비교하면 이렇다.
| 방식 | 장점 | 단점 |
|---|---|---|
| Redis SETNX + TTL | 구현 단순, 속도 빠름 | 리더 전환 타이밍 이슈 가능 |
| Lua 스크립트 원자 처리 | 중복 방지 강화 | 복잡도 증가 |
| DB 락 기반 | Redis 의존성 없음 | 성능 부담, 병목 가능 |
| Zookeeper / etcd | 프로덕션 수준 내결함성 | 별도 인프라 필요 |
이번 케이스는 Redis SETNX + heartbeat 주기를 짧게 가져가는 방식으로 충분했다. 알림이 드물게 한 번 더 가는 것과 리더 전환 때마다 잠깐 침묵하는 것 중 어느 게 덜 나쁘냐를 따져봤을 때, 알림 중복이 더 감수할 만했다. 그래서 TTL 30초, heartbeat 10초 간격으로 결정. 완벽한 방어보다 적절한 트레이드오프를 선택하는 것.
재시작 없이 설정을 바꾸는 reload API
기능 두 번째는 reload API다. 봇 설정 — 알림 채널 ID, 알림 대상, 필터 조건 같은 것들 — 이 바뀔 때마다 서버를 재시작하면 다운타임이 생긴다. 봇이 슬래시 커맨드 응답을 처리하는 도중에 인스턴스가 내려가면 응답을 못 받는 케이스도 생길 수 있다. 그래서 POST /internal/bot/reload 엔드포인트를 하나 만들어서, DB나 설정 소스에서 최신 값을 다시 읽어 메모리에 반영하는 식으로 구현했다.
@PostMapping("/internal/bot/reload")
public ResponseEntity<String> reload() {
try {
botConfigService.reloadConfig();
log.info("Bot config reloaded on instance: {}", instanceId);
return ResponseEntity.ok("reloaded:" + instanceId);
} catch (Exception e) {
log.error("Config reload failed, keeping existing config", e);
return ResponseEntity.internalServerError().body("reload failed");
}
}
실패 시 기존 설정을 유지하는 방어 코드가 중요하다. reload 중에 이벤트가 들어오는 경우도 있어서, 로드가 실패했을 때 설정이 null이 되면 안 된다.
멀티 WAS에서는 한 서버에만 요청해봐야 나머지는 구 설정을 들고 있다. 그래서 운영 스크립트에서 모든 WAS에 순서대로 reload 요청을 보내도록 했다.
for HOST in $WAS_HOSTS; do
RESULT=$(curl -s -X POST "http://$HOST/internal/bot/reload")
echo "$HOST => $RESULT"
done
간단하지만 실용적이다. 배포 파이프라인 마지막 단계에 이걸 끼워 넣으면 코드 배포 후 별도 조작 없이 설정도 같이 갱신된다.
작업 방식과 회고
이번 작업을 하면서도 다시 느꼈는데, 사내 서비스 기능 하나가 버튼 하나 추가로 끝나는 경우는 거의 없다. Leader Election + reload API라는 두 기능이지만 그 안에 Redis 연동, 인스턴스 식별, heartbeat 스케줄러, 운영 스크립트, 예외 처리가 다 엮여 있다. 어느 하나만 빠뜨려도 이상한 동작이 나타나고, 그걸 로컬에서 재현하기가 쉽지 않다.
작업 방식 측면에서 유지하는 습관들:
- 변경 전에 현재 동작을 확인해두기 — 알림이 실제로 두 번 오는지 채널에서 눈으로 본 다음 작업 시작
- 수정 후 같은 케이스로 확인 — 유닛 테스트로 커버 안 되는 부분은 직접 발송 확인
- 커밋은 논리 단위로 쪼개기 — "Redis Leader Election 연동", "heartbeat 스케줄러 추가", "reload API 엔드포인트" 식으로
- 커밋 메시지는 무엇보다 왜를 — 6개월 뒤의 나를 위해
특히 금융, 결제 도메인 쪽 작업과 맞닿아 있는 운영 도구라면 더 그렇다. 알림 하나가 두 번 가거나 안 가거나 하는 게 신뢰에 영향을 줄 수 있다. "대충 맞는 것 같다"로 넘어가면 반드시 다시 돌아온다는 걸 여러 번 겪었기 때문에, 꼼꼼함이 기본값이 되어 있다.
작은 커밋을 자주 하면 뭔가 이상할 때 어느 변경에서 깨졌는지 찾기가 훨씬 쉽다. 이번 Leader Election 작업도 heartbeat 주기 설정 하나를 잘못 건드렸다가 바로 앞 커밋으로 돌아가서 빠르게 원인을 잡을 수 있었다. 이 습관을 계속 유지하는 이유다.
댓글 0
첫 댓글 달아줘.