1. 개요
정해진 시각마다 도는 batch 서비스가 하나 있다.
오래된 데이터를 정리하고, 조건에 맞는 사용자에게 안내 메일을 보내고, 만료된 레코드를 지우는, 흔한 정리성 잡들의 모음이다.
지금까지는 인스턴스 한 대로만 돌았다. 한 대뿐이니 @Scheduled로 매일 정해진 시각에 잡을 깨우면 그만이었다. 중복도, 경합도 없다.
이걸 여러 대로 늘리기로 했다. 한 대에 몰리는 부하를 나누고, 그 한 대가 죽으면 서비스가 멈추는 구조도 벗어나고 싶었다.
그런데 인스턴스를 열 대로 늘리는 순간 그동안 신경 쓸 필요 없던 것들이 한꺼번에 튀어나온다.
2. 무엇이 문제인가
인스턴스를 늘릴 때 가장 먼저 떠오르는 걱정은 중복 실행이다.
@Scheduled는 각 인스턴스의 타이머에서 독립적으로 수행된다. 열 대면 같은 잡이 동시에 열 번 돈다. job이 멱등하게 짜여 있어도 외부 API를 열 번 호출하거나, 같은 메일을 열 번 보내거나, 같은 행을 두고 경합하는 부작용이 생긴다.
여기에 두 가지를 더 보장해야 한다.
- 인스턴스는 언제든 뜨고 내려간다. 특히 배포할 때마다 롤링으로 교체된다. 처리 도중 내려간 인스턴스가 있어도 그 작업은 유실되지 않고 다음 기회에 처리되어야 한다.
- 가능하다면 부하를 실제로 나누고 싶다. 한 job을 여러 대가 나눠 처리하면 그만큼 빨리 끝난다.
3. Trigger 구조부터 다시 본다
대안을 늘어놓기 전에 한 가지를 먼저 분리했다. 이부분이 이후 판단을 크게 바꿨다.
이 서비스의 job은 trigger되는 방식이 두 가지였다.
하나는 외부 스케줄러가 API를 호출해서 깨우는 job이다. 매일 정해진 시각에 외부 크론이 HTTP 요청을 한 번 쏜다. 그런데 요청 한 번은 로드밸런서를 거쳐 인스턴스 한 대에만 도달한다. 열 대로 늘려도 그 한 대만 잡을 수행하고 나머지 아홉은 아무것도 하지 않는다. 즉 이쪽은 중복이 애초에 생기지 않는다. 손댈 게 없다.
다른 하나는 서비스 안에서 @Scheduled 타이머로 도는 잡이다. 이건 모든 인스턴스에서 발화한다. 진짜 중복 문제는 여기에서만 생긴다.
문제를 이렇게 둘로 가르고 나니, "다중화하려면 분산 처리 프레임워크가 필요하다"는 막연한 전제가 흔들렸다. 실제로 손봐야 하는 건 후자뿐이었다.
4. 설계 Options
그래도 부하 분산까지 욕심내면 선택지는 세 갈래다.
4-1. 분산락
모든 인스턴스가 @Scheduled로 깨어나되, 공유 저장소에서 락을 잡은 한 대만 실제로 일하고 나머지는 그냥 넘어간다. Redis나 DB를 락 저장소로 쓴다.
구현이 가볍고 중복은 확실히 막힌다. 다만 분명히 짚어야 할 점이 있다.
분산락은 부하 분산이 아니다.
락을 잡은 한 대가 일을 다 하고 아홉 대는 논다. 처리 시간은 단일 인스턴스와 똑같다. 이건 중복 방지(즉 고가용성)를 위한 도구이지, 일을 나누는 도구가 아니다.
4-2. 메시지 큐 기반 분산 (Cordinator - Worker)
진짜로 부하를 나누려면 일을 쪼개서 분배해야 한다. 한 인스턴스(코디네이터)가 처리 대상을 chunk로 잘라 큐에 넣고, 모든 인스턴스(워커)가 큐에서 꺼내 나눠 처리하는 구조다.
Redis Stream의 Consumer Group이 이걸 자연스럽게 한다. 같은 그룹에 속한 워커들에게 메시지가 분배되고(한 메시지는 한 워커에게만), 꺼낸 메시지는 ack 전까지 보류 목록에 남아 있어서 처리 도중 워커가 죽어도 다른 워커가 회수해 재처리할 수 있다. 앞 절에서 말한 무결성 요구를 그대로 만족한다.
대신 큐, 워커 루프, 미처리 회수, 결과 집계를 직접 만들고 운영해야 한다. 가볍지 않다.
4-3. 전용 배치 프레임워크 (Spring Batch)
사실 위 Cordinator - Worker 패턴은 Spring Batch의 원격 파티셔닝(Remote Partitioning)과 본질적으로 같은 그림이다.
청크 단위 read-process-write, 실패 지점부터 재시작, 스킵·재시도, 실행 이력 추적을 검증된 표준으로 제공한다.
대량 데이터를 다루고 재처리 정합성이 중요한 배치라면 정석에 가깝다.
대신 개념이 많고 메타 테이블을 깔아야 한다. 단순한 잡 몇 개를 위해 들이기엔 무겁다.
| 분산락(Redis RLock) | 메시지 큐(Redis Stream) | 전용 배치 프레임워크(Spring Batch) | |
|---|---|---|---|
| 중복 방지 | O | O | O |
| 부하 분산 | X | O | O |
| 무결성(재처리) | 잡 멱등성에 의존 | 회수로 보장 | 재시작으로 보장 |
| 무게 | 가벼움 | 중간 | 무거움 |
| 적합 규모 | 경량 잡 | 중·대량 | 대량·복잡 재처리 |
5. 선택
실제 무거운 배치가 도는 job은 수십만 건 대량 메일 발송 처리 등 몇개 되지 않고 (일배치로 돌지 않고 주단위 혹은 월단위) 그를 위해 Spring Batch 를 도입하고 기존 다른 job들을 전부 리팩터링 하는 것은 오버엔지니어링 이라는 생각이 들었다.
하여 메시지 큐 방식을 도입하되, 코디네이터 - 워커로 일을 나눠서 처리할 만큼 부담이 강한 job들에 대해서만 적용하도록 결정하였다.
6. 구현 (In progress...)
6-1. 중복은 트리거를 옮겨서 없앤다
3절에서 갈라 둔 두 트리거를 다시 떠올려 보자. 외부 크론이 API를 호출하는 잡은 로드밸런서를 거쳐 한 인스턴스에만 도달해 중복이 없었고, 문제는 모든 인스턴스에서 발화하는 내부 @Scheduled 잡에서만 생겼다.
그래서 분산락을 새로 얹는 대신, 중복의 원인 자체를 없앴다. 다중화가 필요한 @Scheduled 잡들의 트리거를 전부 "외부 크론 → REST API" 방식으로 옮겼다. 외부 크론이 정해진 시각에 엔드포인트를 한 번 호출하면 로드밸런서가 한 인스턴스에만 전달하고, 그 한 대만 잡을 깨운다. 타이머가 모든 인스턴스에서 동시에 울리던 구조가 사라지니 중복도 함께 사라진다.
// 외부 크론이 정해진 시각에 호출하는 트리거 엔드포인트
@PostMapping("/internal/jobs/{name}")
public ResponseEntity<Void> trigger(@PathVariable String name) {
jobLauncher.run(name); // 요청을 받은 한 인스턴스에서만 실행된다
return ResponseEntity.accepted().build();
}
가벼운 잡은 여기서 끝이다. 한 대가 받아 처리하고, 1초 안쪽에 끝나는 일을 굳이 여러 대로 나눌 이유가 없다. 락도, 큐도 필요 없다.
6-2. 무거운 잡은 Cordinator - Worker로 쪼갠다
수십만 건을 다루는 무거운 잡은 한 대가 받아 전부 처리하기엔 버겁다. 여기서는 요청을 받은 그 한 대를 코디네이터로 삼는다. 코디네이터는 직접 일하지 않고, 처리 대상을 chunk로 잘라 Redis Stream에 넣기만 한다.
RStream<String, String> stream = redisson.getStream("mail-batch");
// 코디네이터: 처리 대상을 chunk 로 잘라 스트림에 적재한다 (직접 처리하지 않는다)
for (List<Long> chunk : partition(targetIds, CHUNK_SIZE)) {
stream.add(StreamAddArgs.entry("ids", serialize(chunk)));
}
그러면 모든 인스턴스에 떠 있는 워커들이 같은 consumer group으로 스트림을 구독하다가 메시지를 나눠 받는다. 한 메시지는 그룹 안의 한 워커에게만 전달되므로, 일이 자연스럽게 여러 대로 분산된다.
RStream<String, String> stream = redisson.getStream("mail-batch");
stream.createGroup("workers"); // 그룹은 최초 1회만 생성
// 워커: 아직 아무도 안 가져간 메시지를 받아 처리하고 ack 한다
Map<StreamMessageId, Map<String, String>> messages =
stream.readGroup("workers", consumer, StreamReadGroupArgs.neverDelivered().count(10));
messages.forEach((id, body) -> {
process(deserialize(body.get("ids")));
stream.ack("workers", id); // 처리 완료를 알린다
});
ack를 하기 전까지 꺼낸 메시지는 그룹의 pending 목록에 남는다. 워커가 처리 도중 죽어 ack를 못 하면 그 메시지는 사라지지 않고 pending에 머물고, 일정 시간이 지나면 다른 워커가 소유권을 회수(claim)해 다시 처리한다. 2절에서 요구한 "처리 도중 인스턴스가 내려가도 작업은 유실되지 않는다"가 이 회수로 보장된다.
// ack 되지 않고 일정 시간 방치된 pending 메시지를 다른 워커가 회수해 재처리
Map<StreamMessageId, Map<String, String>> reclaimed =
stream.claim("workers", consumer, 5, TimeUnit.MINUTES, staleIds);
6-3. 결과 집계는 공유 저장소로
의외의 복병이 하나 있었다. 각 잡의 실행 결과를 모아 하루 한 번 알림으로 보내는 부분이, 인스턴스 메모리 안의 정적 변수에 결과를 쌓고 있었다.
단일 인스턴스에서는 잘 돌았다. 그런데 다중화하면, 게다가 무거운 잡은 여러 워커가 나눠 처리하니, 결과를 쌓은 인스턴스와 그것을 모아 발송하는 인스턴스가 따로 논다. 정적 변수는 인스턴스마다 따로 사니 집계가 깨진다.
이건 트리거를 옮기든 큐를 쓰든 무관한, 다중화 자체가 드러낸 별개의 문제다. 결과를 공유 저장소(Redis 해시)에 기록하도록 바꿨다. Job은 끝날 때 결과를 공유 저장소에 쓰고, 발송 잡은 거기서 그날 결과를 읽어 조합한다.
// 잡 종료 시
resultStore.put("cleanup-job", "success : 120, fail : 0");
// 발송 잡에서
Map<String, String> today = resultStore.getAll(LocalDate.now());
다중화에서 "어디에 상태를 두는가"는 생각보다 자주 발목을 잡는다. 인스턴스 안에 둔 모든 상태는 다중화 순간 의심해 봐야 한다.
7. 정리
분산 처리는 멋있는 도구지만, 인스턴스를 늘린다고 해서 모든 job에 똑같이 필요해지는 것은 아니다. 이번에 가장 도움이 된 건 새 기술을 고른 일이 아니라, 무엇이 실제로 깨지는지를 먼저 분리한 일이었다.
트리거 구조를 둘로 가르자 문제의 절반은 성격이 분명해졌다.
중복은 분산 프레임워크가 아니라 트리거를 외부 크론으로 옮겨 원인 자체를 없앴고, 가벼운 잡은 그것으로 끝났다.
진짜로 부하를 나눌 가치가 있는 무거운 잡에만 Redis Stream 코디네이터-워커를 얹어 일을 쪼갰다. 검증된 표준이 필요한 대량 배치는 나중에 전용 프레임워크(Spring Batch)로 옮길 카드로 남겨 뒀다.
같은 '다중화'라도 잡마다 필요한 무게가 다르다. 중복만 막으면 되는 잡, 부하를 나눠야 하는 잡, 정합성까지 관리해야 하는 잡을 구분하고 나니 각자에게 가장 가벼운 해법이 남았다. 가장 가벼운 해법이 대개 가장 오래간다.
'Back-end' 카테고리의 다른 글
| java 25 가상스레드 적용 시 실제 서비스 기준으로는 얼만큼 성능이 향상될까? (0) | 2026.06.28 |
|---|---|
| 분산 환경 지연 작업 처리: 설계 대안 비교와 Redisson RDelayedQueue 선택 (0) | 2026.06.28 |
| Java - NonWritableChannelException (0) | 2026.06.28 |
| Java try-with-resource (0) | 2026.06.28 |
| SAS에서의 Client - Server Binary file 전송 (0) | 2026.06.28 |