서버 확장 뒤 포인트가 중복 지급된 문제와 해결

애플리케이션 인스턴스마다 로컬 스케줄러가 실행되는 문제와 단일 조정 지점을 둔 구조

한 대의 서버에서 매일 한 번 실행되는 포인트 지급 작업을 만들었을 때는 별다른 문제가 없었다. 정해 둔 시각에 @Scheduled 메서드가 실행되고, 대상 회원에게 포인트를 지급했다. 문제가 드러난 건 애플리케이션을 여러 서버에서 실행하기 시작한 뒤였다. 서버마다 같은 메서드가 등록되어 있으니, 같은 날 같은 대상에게 포인트를 두 번 지급할 수 있었다.

그때 처음 배운 건 스케줄 실행 시각과 작업의 단일 실행 보장이 별개라는 점이었다. Spring의 @Scheduled는 애플리케이션 안에서 스케줄을 등록한다. 서버가 세 대면 스케줄도 세 군데 등록된다. 프레임워크가 어느 서버를 대표로 뽑아 주지는 않는다.

서버 A의 @Scheduled ─┐
서버 B의 @Scheduled ─┼─ 같은 시각에 같은 지급 작업 시작
서버 C의 @Scheduled ─┘

단일 서버에서는 실행 주체가 하나라서 놓치기 쉬운 구조다. 배포 중에 구 버전과 신 버전이 잠시 함께 동작하는 경우에도 같은 일이 생길 수 있다. 한 서버가 작업을 마치기 전에 다음 실행 시각이 오거나, 실패한 작업을 운영자가 다시 실행하는 경우도 중복 경로가 된다.

우리 프로젝트에서 바꿔 본 방식

당시에는 AWS Lambda와 EventBridge를 이용해 정해진 시각에 작업을 시작하는 구성을 적용했다. 애플리케이션의 모든 인스턴스가 각자 시간을 기다리는 대신 외부 스케줄러가 실행 시점을 관리하고, Lambda가 애플리케이션의 작업을 호출하는 형태였다. 호출 엔드포인트가 일반 사용자에게 실행되지 않도록 요청 키도 확인하게 했다.

이 방식은 스케줄 책임을 애플리케이션 인스턴스에서 분리했다는 점에서 도움이 됐다. 다만 지금 다시 보면 요청에 고정 키 하나를 넣는 것만으로는 충분한 보호가 아니다. 키가 코드나 로그에 남으면 재사용될 수 있고, 같은 요청이 재전달되는 상황도 막아 주지 않는다. 외부 경로를 사용하기 어렵게 만드는 것과 인증·중복 방지는 다른 문제다.

스케줄러가 한 번 불러도 작업은 중복될 수 있다

관리형 스케줄러는 정해진 시각에 실행을 시작하고 실패한 호출을 재시도할 수 있다. AWS EventBridge Scheduler 역시 재시도 정책과 DLQ를 제공한다. 재시도는 실패한 작업을 복구하는 데 필요하지만, 이미 처리된 요청의 응답이 유실된 경우에는 같은 작업이 다시 전달될 수 있다. 따라서 “호출을 한 번만 보내기”보다 “같은 호출이 여러 번 와도 결과가 한 번만 반영되기”를 먼저 보장해야 한다.

포인트 지급이라면 회원과 지급 기준 기간을 조합해 논리적인 작업 키를 만들 수 있다.

monthly-point:2024-04:member-42

지급 이력에 이 키를 유일하게 저장하고, 지급 이력 생성과 포인트 증가를 같은 DB 트랜잭션에서 처리한다. 이미 같은 키의 기록이 있으면 다시 지급하지 않는다. 이렇게 하면 스케줄러 재시도, 운영자 재실행, 두 인스턴스의 동시 진입을 같은 규칙으로 다룰 수 있다. 단순히 JVM의 synchronized를 붙이는 방법은 한 프로세스 안에서만 통하므로 서버가 여러 대인 문제는 해결하지 못한다.

실행 시각 관리 → 작업 전달 → 작업 키 중복 확인 → 결과를 한 번 반영

작업이 여러 회원을 한 번에 처리한다면 회원별 지급을 작은 트랜잭션으로 나눌 수도 있다. 어느 회원까지 처리했는지 기록해 두면 중간에 서버가 종료된 뒤 전체 작업을 처음부터 돌려도 완료된 회원을 건너뛸 수 있다. 중요한 건 재시작 가능한 단위와 진행 상태가 코드 밖의 영속 저장소에 남는 것이다.

운영 환경에 맞는 선택

작업량이 작고 애플리케이션이 한 대라면 @Scheduled만으로 충분할 수 있다. 서버가 여러 대라면 DB 기반 작업 점유나 별도 스케줄러를 둘 수 있다. 이미 AWS를 사용하고 있고 실행 시간이 명확한 작업이라면 EventBridge와 Lambda 같은 관리형 구성도 선택지다. 어떤 방식을 고르더라도 작업의 멱등성, 실패 기록, 재시도 한도는 애플리케이션 쪽에 남겨야 한다.

이 경험의 결론은 “스케줄러를 외부로 옮기면 중복이 해결된다”가 아니었다. 외부 스케줄러는 시작 시점을 한 곳에서 관리하게 해 줬고, 중복 지급을 막는 책임은 작업의 지급 이력과 트랜잭션에 남아 있었다. 실행을 촉발하는 곳과 결과를 안전하게 반영하는 곳을 나눠 보니, 장애가 났을 때 무엇을 재시도해야 하는지도 더 분명해졌다.

참고 문서: Spring Task Execution and Scheduling, EventBridge Scheduler의 재시도와 DLQ

당시 기록: Tistory - Scheduler