비동기·스케줄링·배치 — 요청 흐름 밖의 일
지금까지의 이야기는 대부분 요청이 들어오면 응답을 돌려주기까지의 흐름이었다. 그런데 모든 일이 그 안에서 끝나지는 않는다. 가입 환영 메일은 응답을 붙잡고 있을 이유가 없고, 매출 정산은 사용자 요청과 무관하게 매일 새벽에 돌아야 하며, 백만 건짜리 마이그레이션은 한 트랜잭션·한 메모리에 도저히 담을 수 없다. Spring이 이 "요청 흐름 밖의 일"을 다루는 세 가지 방식을, 스레드 풀이 어떻게 도는지까지 들여다보며 본다.
응답을 막지 않기 — @Async, 그리고 위험한 기본값
메일 발송처럼 느리지만 응답과 상관없는 부수 작업은 @Async로 별도 스레드에 넘기고 호출자는 즉시 반환한다.
@Async
public void sendWelcomeMail(User user) { ... } // 호출 즉시 반환, 메일은 뒤에서
두 가지 함정은 이미 본 원리의 반복이다 — 자기 호출하면 프록시를 우회해 비동기가 안 걸리고(02편), void 메서드의 예외는 조용히 삼켜진다(AsyncUncaughtExceptionHandler나 CompletableFuture로 받아야 한다). 그런데 더 위험한 건 기본 Executor다. @Async에 커스텀 Executor를 등록하지 않으면 기본 ThreadPoolTaskExecutor가 코어 1·큐 무한에 가깝게 동작해, 트래픽이 몰리면 작업이 큐에 무한히 쌓이다 OOM이 난다(10편 사고⑤). 그래서 반드시 풀을 명시한다.
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(16); // 평소 유지 스레드
ex.setMaxPoolSize(32); // 최대
ex.setQueueCapacity(200); // 큐 상한 — 무한 금지
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
여기서 풀이 늘어나는 순서를 알아야 한다 — ① 코어 스레드가 다 차면 큐에 쌓고, ② 큐가 가득 차야 비로소 maxPool까지 스레드를 늘리며, ③ max도 큐도 다 차면 거부 정책이 작동한다. 그래서 큐를 무한으로 두면 maxPool은 영영 안 늘어난다(흔한 오해). 거부 정책 중 **CallerRunsPolicy**가 실무에서 유용한데, 거부된 작업을 호출 스레드가 직접 실행해 — 사실상 유입 속도를 늦추는 백프레셔 역할을 한다(작업을 잃지 않는다). 반면 DiscardPolicy는 작업을 버린다. 풀 크기는 작업 성격에 맞춘다 — CPU 바운드면 코어 수 근처(jcode 07), I/O 바운드면 더 크게. 다만 Java 21 가상 스레드를 켜면(spring.threads.virtual.enabled=true, Boot가 가상 스레드 기반 SimpleAsyncTaskExecutor를 구성) I/O 대기가 싸져 이 고민이 줄어든다. (출처: ThreadPoolTaskExecutor 관리.)
정해진 때에 — @Scheduled
다음은 주기적으로 도는 일이다.
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시 (초 분 시 일 월 요일)
@Scheduled(fixedDelay = 5000) // '이전 작업이 끝나고' 5초 뒤
@Scheduled(fixedRate = 5000) // '이전 작업이 시작된' 시점 기준 5초마다
fixedDelay는 이전 실행이 끝난 다음부터 간격을 재므로 작업이 길어도 겹치지 않고, fixedRate는 시작 시점 기준이라 작업이 간격보다 길면 밀리거나 겹친다. 함정도 둘이다. 하나, 스케줄러의 기본 스레드는 한 개라 작업이 여럿이면 하나가 길어질 때 나머지가 밀린다 — TaskScheduler 풀 크기를 키운다. 둘, 더 무서운 건 인스턴스가 여러 대일 때다. @Scheduled는 각 인스턴스에서 따로 돌아, 3대로 배포돼 있으면 정산이 3번 실행된다. 그래서 다중 배포에서는 분산 락(ShedLock·Redis 등)으로 한 시점에 한 인스턴스만 실행하게 막는다 — MSA 편의 멱등성·중복 방지와 같은 결의 문제다.
한 번에 담을 수 없는 양 — Spring Batch의 청크
마지막은 대량 데이터다. 백만 건을 한 트랜잭션·한 메모리에 올리면 undo 로그·락이 거대해지고, 메모리에 다 올려 OOM이 나고, 한 건만 실패해도 전부 롤백된다. 그래서 Spring Batch는 일을 청크(chunk) 단위로 끊는다.
동작은 정밀하다 — ItemReader가 한 건씩 읽어 ItemProcessor가 변환하고, 이렇게 *commit-interval(청크 크기, 예: 1000)*만큼 모이면 ItemWriter가 그 1000건을 한 트랜잭션으로 써서 커밋한다. 그리고 다음 청크로 넘어간다. 이렇게 끊으면 메모리에 올라오는 양이 청크 크기만큼으로 일정하고, 락·undo 로그도 작게 유지되며, 실패해도 그 청크만 롤백·재시도하면 된다. 게다가 JobRepository가 진행 상황(StepExecution·ExecutionContext)을 주기적으로 저장하니, 중간에 죽어도 마지막 성공 지점부터 재시작할 수 있고 skip·retry 정책으로 불량 레코드를 건너뛰거나 재시도할 수 있다. (출처: Spring Batch — Chunk-oriented Processing.) 결국 이건 경계를 어디서 끊을 것인가의 문제로, OS가 거대한 데이터를 버퍼링·페이징으로 잘게 다루는 발상과 같다. 단, 배치는 재시작될 수 있으니 두 번 돌아도 안전하도록 멱등하게 짜야 한다.
정리하면, 요청 흐름 밖의 일은 성격에 따라 도구가 갈린다. 느린 부수 작업은 @Async로 떼어 내되 반드시 풀(코어·max·큐 상한·거부 정책)을 명시하고(기본 무한 큐는 OOM, CallerRunsPolicy로 백프레셔), 주기 작업은 @Scheduled로 돌리되 다중 인스턴스에서는 분산 락으로 중복을 막으며, 한 번에 담을 수 없는 대량은 Spring Batch의 청크(reader→processor→writer, 청크당 트랜잭션, JobRepository로 재시작)로 잘라 메모리·실패·재시작을 통제한다 — 그리고 그 모두를 멱등하게.