거부 정책과 역압력
스레드와 큐가 모두 찬 순간을 Abort, Discard, CallerRuns, 사용자 정책으로 처리하고 유실·지연·시스템 보호의 우선순위를 명시합니다.
스레드 풀의 거부는 우연한 예외가 아니라 용량 규칙의 마지막 단계입니다.
실행 중인 작업자가 maximumPoolSize에 도달하고 유한 큐까지 가득 찼다면, 새 요청을 받아들일 자리가 없습니다.
이때 계속 수용하는 척하면 메모리나 외부 의존성이 먼저 무너집니다.
거부 정책은 이 포화를 호출자에게 어떤 의미로 돌려줄지 정합니다.
중요한 질문은 “어떤 정책이 가장 편한가”가 아닙니다. 작업을 잃어도 되는지, 제출자를 느리게 해도 되는지, 재시도가 중복 실행을 만들지 않는지부터 답해야 합니다. 결제·예약처럼 유실할 수 없는 요청과 최신 화면 갱신처럼 오래된 요청을 버려도 되는 작업은 같은 정책을 사용할 수 없습니다.
미처리 거부에 따른 제출 스레드 종료
기본 AbortPolicy는 용량을 넘긴 제출에서 RejectedExecutionException을 던집니다.
이 예외를 접점에서 번역하지 않으면 웹 요청 스레드나 배치 조정 스레드가 예상치 않게 종료될 수 있습니다.
반대로 조용히 삼키면 호출자는 작업이 접수되었다고 오해합니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class UnhandledRejection {
public static void main(String[] args) throws InterruptedException {
CountDownLatch release = new CountDownLatch(1);
var executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
new SynchronousQueue<>());
executor.execute(() -> await(release));
executor.execute(() -> System.out.println("이 줄은 제출되지 않는다"));
release.countDown();
executor.shutdown();
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}두 번째 제출 시점에는 작업자도 바쁘고 SynchronousQueue에는 저장 공간이 없습니다.
따라서 기본 정책이 즉시 예외를 던집니다.
이 코드는 실패 경로를 의도적으로 드러내는 반례이며, 정상 서비스라면 제출 접점에서 포화를 업무 오류나 재시도 가능한 응답으로 바꿔야 합니다.
거부 정책별 전달 보장
AbortPolicy는 실패를 명시하므로 호출자가 대체 경로를 선택할 수 있다.DiscardPolicy는 알리지 않고 버리므로 유실 허용이 문서화된 보조 작업에만 맞는다.DiscardOldestPolicy는 큐의 가장 오래된 항목을 제거한 뒤 새 작업을 다시 제출한다.CallerRunsPolicy는 종료 전이라면 제출 스레드에서 작업을 실행해 생산 속도를 낮춘다.- 사용자
RejectedExecutionHandler는 계측, 별도 저장, 업무 오류 변환을 한 경계에 모은다. - 어떤 정책이든 종료된 실행기에 대한 제출과 실행 중 포화를 구분해야 한다.
DiscardPolicy를 단순히 예외가 없는 정책으로 이해하면 위험합니다.
반환값이나 영속 기록이 없다면 실제 실행 여부를 확인할 방법이 사라집니다.
DiscardOldestPolicy도 “오래된 것이 덜 중요하다”는 업무 전제가 참일 때만 의미가 있습니다.
우선순위 큐에서는 제거 대상의 의미가 달라질 수 있으므로 더 세심한 검증이 필요합니다.
AbortPolicy와 호출자 규칙
제출 API가 결과 객체를 반환하면 포화를 정상적인 분기처럼 처리할 수 있습니다. 거부 예외의 구현 세부를 바깥 계층에 누출하지 않고, 호출자는 재시도·즉시 실패·다른 채널 저장 중 하나를 선택합니다.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class ExplicitAdmission implements AutoCloseable {
enum Admission { ACCEPTED, SATURATED, CLOSED }
private final ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 4, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(8));
Admission submit(Runnable task) {
if (pool.isShutdown()) return Admission.CLOSED;
try {
pool.execute(task);
return Admission.ACCEPTED;
} catch (RejectedExecutionException rejected) {
return pool.isShutdown() ? Admission.CLOSED : Admission.SATURATED;
}
}
@Override
public void close() throws InterruptedException {
pool.shutdown();
if (!pool.awaitTermination(1, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
}
public static void main(String[] args) throws Exception {
try (var admission = new ExplicitAdmission()) {
System.out.println(admission.submit(() -> System.out.println("accepted")));
}
}
}이 구분은 SATURATED와 CLOSED를 구분합니다.
포화는 현재 용량 부족이므로 짧은 지연 뒤 제한된 재시도를 검토할 수 있지만, 종료는 수명주기가 끝났다는 뜻이라 다른 실행기로 라우팅하거나 요청 자체를 취소해야 합니다.
재시도에는 횟수 상한과 지수 지연, 멱등성 키가 필요합니다.
CallerRunsPolicy와 역압력
CallerRunsPolicy는 제출자를 소비자로 잠시 전환합니다.
제출자가 직접 실행하는 동안 새 작업을 만들지 못하므로 생산률이 처리률 쪽으로 내려갑니다.
별도 버퍼를 무한히 늘리지 않고 자연스러운 역압력을 거는 장점이 있지만, 제출자가 응답 지연에 민감한 이벤트 루프라면 전체 요청 처리를 멈출 수 있습니다.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class CallerRunsBackpressure {
public static void main(String[] args) throws InterruptedException {
var executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.CallerRunsPolicy());
for (int task = 1; task <= 5; task++) {
int id = task;
long before = System.nanoTime();
executor.execute(() -> {
sleep(30);
System.out.printf("task=%d worker=%s%n", id,
Thread.currentThread().getName());
});
long submitMicros = (System.nanoTime() - before) / 1_000;
System.out.printf("submit=%d latencyMicros=%d%n", id, submitMicros);
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.SECONDS);
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}일부 작업의 스레드 이름이 main이고 해당 제출 지연이 약 30밀리초로 늘어납니다.
이 지연이 바로 역압력입니다.
HTTP 요청 스레드에 적용한다면 응답 시간 상한을 침범하지 않는지 확인하고, 비동기 이벤트 루프나 잠금을 보유한 호출 경로에서는 직접 실행이 교착이나 전체 정지를 만들지 검토합니다.
사용자 정책에는 계측과 대체 저장을 함께 배치
업무 요청을 버릴 수 없다면 거부 핸들러가 실패 수를 기록하고 내구성 있는 대체 채널로 넘길 수 있습니다. 단, 핸들러 자체가 오래 막히면 모든 제출자가 그 안에 묶입니다. 아래 예시는 학습을 위해 메모리 큐를 사용하지만 운영에서는 크기가 제한된 브로커, 데이터베이스 outbox, 명시적 실패 응답처럼 장애 모델이 분명한 수단을 선택합니다.
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;
public final class MeasuredRejection {
static final class OverflowHandler implements RejectedExecutionHandler {
private final LongAdder rejected = new LongAdder();
private final Queue<Runnable> overflow;
OverflowHandler(Queue<Runnable> overflow) {
this.overflow = overflow;
}
@Override
public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
rejected.increment();
if (executor.isShutdown() || !overflow.offer(task)) {
throw new IllegalStateException("대체 경로도 작업을 받지 못했습니다");
}
}
long rejectedCount() {
return rejected.sum();
}
}
public static void main(String[] args) throws InterruptedException {
Queue<Runnable> overflow = new ConcurrentLinkedQueue<>();
var handler = new OverflowHandler(overflow);
var executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
new SynchronousQueue<>(), handler);
executor.execute(() -> sleep(40));
executor.execute(() -> System.out.println("overflow 실행 후보"));
executor.shutdown();
executor.awaitTermination(1, TimeUnit.SECONDS);
System.out.printf("rejected=%d saved=%d%n",
handler.rejectedCount(), overflow.size());
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}거부 횟수만 보지 말고 큐 사용률, 활성 작업자, 제출 지연, 완료 처리율을 같은 시계열로 관찰합니다. 거부율이 오를 때 처리율도 오르면 순간 폭주일 수 있고, 처리율이 떨어지면서 거부가 늘면 외부 I/O 정체나 작업 시간 증가를 의심해야 합니다. 알림은 단일 거부보다 지속 시간과 비율에 걸어 불필요한 소음을 줄입니다.
정책 선택 기준
| 작업 성질 | 기본 선택 | 반드시 확인할 조건 |
|---|---|---|
| 즉시 응답 가능한 요청 | AbortPolicy 후 업무 오류 변환 | 재시도 횟수와 멱등성 |
| 최신값만 중요한 갱신 | 오래된 항목 교체 정책 | 제거 대상이 정말 오래된 값인지 |
| 제출자를 늦춰도 되는 배치 | CallerRunsPolicy | 호출 스레드의 지연 허용치 |
| 관측용 부가 이벤트 | 계측을 포함한 제한적 폐기 | 유실률 대시보드와 표본 감사 |
| 유실 불가 명령 | 내구성 큐나 outbox | 중복 소비와 재처리 규칙 |
정책을 정한 뒤에는 풀을 실제로 포화시켜야 합니다. 기대한 예외가 어느 계층에 도달하는지, 호출자 실행이 어느 스레드에서 일어나는지, 종료 뒤 제출이 어떻게 보이는지 확인하세요. 거부를 발생시키지 않은 부하 테스트는 용량 구분의 절반만 확인합니다.
연습 문제
CRITICAL, INTERACTIVE, BEST_EFFORT 세 등급을 받는 제출 API를 설계하세요.
포화 시 중요 작업은 호출자에게 실패를 반환하고, 상호작용 작업은 최대 한 번만 재시도하며, 보조 작업은 폐기 수를 계측합니다.
실행기 종료와 단순 포화를 별도로 표현해야 합니다.
정답과 해설
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.LongAdder;
public final class AdmissionPolicySolution {
enum Grade { CRITICAL, INTERACTIVE, BEST_EFFORT }
enum Result { ACCEPTED, RETRY_LATER, DROPPED, CLOSED }
private final ThreadPoolExecutor pool;
private final LongAdder dropped = new LongAdder();
AdmissionPolicySolution(ThreadPoolExecutor pool) {
this.pool = pool;
}
Result submit(Grade grade, Runnable task) {
if (pool.isShutdown()) return Result.CLOSED;
try {
pool.execute(task);
return Result.ACCEPTED;
} catch (RejectedExecutionException rejected) {
if (pool.isShutdown()) return Result.CLOSED;
return switch (grade) {
case CRITICAL, INTERACTIVE -> Result.RETRY_LATER;
case BEST_EFFORT -> {
dropped.increment();
yield Result.DROPPED;
}
};
}
}
long droppedCount() {
return dropped.sum();
}
}재시도 횟수와 지연은 제출 API 바깥의 요청 수명주기에서 관리하는 편이 명확합니다. 중요 작업도 메모리에 무한 보관하지 않고 실패를 공개해야 시스템 전체를 보호할 수 있습니다. 보조 작업은 조용히 버리는 대신 누적 수치를 남겨 품질 저하를 관찰합니다.
거부 정책을 운영에 승인하는 조건
거부 정책은 과부하 순간의 제품 행동입니다. 유실 여부, 호출자 지연, 재시도 안전성, 종료 의미를 먼저 합의한 다음 구현을 고릅니다. 정상 처리량뿐 아니라 포화와 복구 구간까지 실행해 보았을 때 호출자가 결과를 정확히 해석하고 서버 자원에 상한이 유지되면 정책이 제대로 설계된 것입니다.