자바 아키텍처·Spring 학습 노트 목차

비동기·스케줄링·배치 — 요청 흐름 밖의 일

지금까지의 이야기는 대부분 요청이 들어오면 응답을 돌려주기까지의 흐름이었다. 그런데 모든 일이 그 안에서 끝나지는 않는다. 가입 환영 메일은 응답을 붙잡고 있을 이유가 없고, 매출 정산은 사용자 요청과 무관하게 매일 새벽에 돌아야 하며, 백만 건짜리 마이그레이션은 한 트랜잭션·한 메모리에 도저히 담을 수 없다. Spring이 이 "요청 흐름 밖의 일"을 다루는 세 가지 방식을, 스레드 풀이 어떻게 도는지까지 들여다보며 본다.

응답을 막지 않기 — @Async, 그리고 위험한 기본값

메일 발송처럼 느리지만 응답과 상관없는 부수 작업은 @Async로 별도 스레드에 넘기고 호출자는 즉시 반환한다.

@Async
public void sendWelcomeMail(User user) { ... }   // 호출 즉시 반환, 메일은 뒤에서

두 가지 함정은 이미 본 원리의 반복이다 — 자기 호출하면 프록시를 우회해 비동기가 안 걸리고(02편), void 메서드의 예외는 조용히 삼켜진다(AsyncUncaughtExceptionHandlerCompletableFuture로 받아야 한다). 그런데 더 위험한 건 기본 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로 재시작)로 잘라 메모리·실패·재시작을 통제한다 — 그리고 그 모두를 멱등하게.

관측성 — Actuator·Micrometer·분산추적리액티브 — WebFlux·Reactor·백프레셔