비동기 처리 설계
응답과 실행의 시간을 분리하고 Executor 또는 내구성 메시지 큐를 실패 의미에 맞게 선택한다.
이 문서의 목차
Overview
비동기 처리는 호출자가 작업 완료를 기다리는 시간과 실제 작업이 수행되는 시간을 분리하는 설계다. 요청 Thread가 다른 Thread에 일을 넘기는 @Async도 비동기이고, Broker에 메시지를 저장해 별도 Consumer가 처리하는 것도 비동기다. 하지만 둘의 내구성과 실패 의미는 전혀 다르다. 빠르게 응답한다는 공통점만 보고 같은 선택으로 취급하면 장애 때 작업이 사라지거나, Queue가 무한히 쌓이거나, 오류를 아무도 관찰하지 못한다.
한 문장 설명: 주문 접수 창구와 실제 조리 시간을 분리하되, 메모지로 넘길지 분실되지 않는 주문 전표 시스템으로 넘길지를 업무 중요도에 따라 선택하는 것이다.
이 문서는 다음 세 층을 연결한다.
- Java
CompletableFuture: 같은 Process에서 결과를 조합하는 비동기 Programming 도구 - Spring
@Async와TaskExecutor: Proxy를 통해 Method 실행을 별도 Thread Pool에 위임하는 도구 - Kafka, ActiveMQ, SQS: Process가 죽어도 작업을 보존하고 다른 Consumer가 이어받을 수 있는 Durable Message Queue
Source 구분: Sync/Async와 Blocking/Non-blocking 차이,
CompletableFuture,@AsyncProxy, Pool/Queue/Rejection, ThreadLocal/MDC 전파, ActiveMQ 적체와 SQS Visibility/DLQ는 연결된 Notion 학습 기록을 통합했다. 아래 Timeout Budget, Retry Amplification, Admission Control과 Broker 독립적인 Shutdown Checklist는 Dev Atlas 보충이다.
핵심 용어
| 용어 | 정의 | 놓치기 쉬운 점 |
|---|---|---|
| Synchronous | 호출 흐름이 결과 또는 실패를 직접 기다림 | Blocking과 항상 같은 뜻은 아님 |
| Asynchronous | 요청 수락과 작업 완료의 시간·실패 경계를 분리 | 실행 성공을 의미하지 않음 |
| Blocking | Thread가 결과를 기다리며 진행하지 못함 | Async 작업 내부에서도 Blocking I/O 가능 |
| Non-blocking | 기다리는 동안 Thread를 점유하지 않는 실행 모델 | Reactive API를 쓴다고 전체가 자동 Non-blocking은 아님 |
| CompletableFuture | 비동기 결과의 성공·실패·조합을 표현 | Executor와 예외 관찰을 명시해야 함 |
| TaskExecutor | Spring이 Task 실행을 추상화한 Interface | 기본 Executor 설정을 무심코 쓰면 위험 |
| Bounded Queue | 최대 길이가 정해진 작업 대기열 | 꽉 찬 뒤 정책이 시스템 성격을 결정 |
| Rejection Policy | Pool과 Queue가 포화일 때 새 Task 처리 규칙 | 무음 폐기는 데이터 유실 |
| Backpressure | 생산 속도가 소비 능력을 넘을 때 압력을 upstream으로 전달 | 무한 Queue는 Backpressure가 아님 |
| Timeout | 더 기다리지 않겠다는 호출자 정책 | 취소·Rollback·실제 작업 중단과 동일하지 않음 |
| Retry | 일시 실패 작업을 다시 시도 | 중복과 부하 증폭을 만든다 |
| DLQ | 자동 Retry에서 격리한 실패 메시지 저장소 | 보관만 하고 복구하지 않으면 묘지 |
| Idempotency | 중복 실행돼도 최종 비즈니스 효과가 같게 함 | Event 수신 횟수가 아니라 Side Effect 기준 |
| Ordering | 정의한 범위에서 처리 순서를 유지 | Global Ordering은 비용이 큼 |
| Context Propagation | Security, MDC, Trace Context 등 호출 문맥 전달 | ThreadLocal은 자동 전파되지 않음 |
| Graceful Shutdown | 새 Task 수락을 멈추고 진행 중 작업을 제한 시간 내 정리 | In-memory 대기 Task의 내구성은 별도 문제 |
왜 필요한가
동기 처리는 이해하기 쉽다. 호출자가 성공과 실패를 직접 받고 Transaction 경계도 비교적 선명하다. 그러나 Email, Thumbnail, 대규모 집계처럼 결과가 즉시 필요하지 않은 작업까지 기다리면 응답 시간이 길어지고, 느린 Downstream이 요청 Thread와 Connection Pool을 붙잡아 장애를 전파한다.
비동기는 응답 시간을 줄이고 Burst를 흡수하며 작업을 독립적으로 Scale할 수 있다. 대신 다음 복잡성을 새로 만든다.
- “요청 접수 성공”과 “업무 처리 성공”이 달라진다.
- 실패가 호출 Stack에서 사라져 별도 관측과 보상 경로가 필요하다.
- Thread 또는 Process 경계를 넘으면 Transaction과 Security Context가 끊긴다.
- Retry와 At-least-once 전달로 중복 실행이 정상 상황이 된다.
- 병렬 처리로 순서가 바뀌고 공유 자원 경쟁이 커진다.
- 생산 속도가 소비 속도를 넘으면 Queue와 Memory, Broker Disk가 고갈된다.
- 배포·종료·재기동 때 대기 작업을 어떻게 할지 정해야 한다.
동기와 비동기의 경계
sequenceDiagram; participant C as Client; participant A as API; participant W as Worker; participant Q as Queue; C->>A: request; alt synchronous; A->>W: execute; W-->>A: result/error; A-->>C: completed response; else asynchronous; A->>Q: durable enqueue or executor submit; Q-->>A: accepted; A-->>C: 202 + jobId; Q->>W: deliver; W->>W: process / retry; end
API가 202 Accepted를 반환하려면 무엇이 accepted 되었는지 정의해야 한다. 단순히 executor.execute()가 예외 없이 끝났다는 뜻인지, Broker가 메시지를 내구성 있게 저장했다는 ACK인지, 업무 DB와 Outbox가 Commit됐다는 뜻인지에 따라 보장이 달라진다. Client에는 Job ID, 상태 조회 또는 결과 Notification 방법, 최종 실패 의미를 계약으로 제공한다.
비동기와 Non-blocking도 분리해서 설명해야 한다. @Async Method가 내부에서 JDBC를 호출하면 별도 Thread에서 Blocking I/O를 한다. 호출 Thread는 빨리 반환하지만 Worker Thread는 대기한다. 반대로 Non-blocking HTTP Client는 하나의 Thread가 여러 I/O를 다룰 수 있지만, 사용자에게 동기 결과를 반환하도록 기다린다면 업무 계약은 동기일 수 있다.
실행 모델의 내부 동작
flowchart LR; A[Producer] --> B{인계 방식}; B -->|in-process| E[Bounded Queue]; E --> T[TaskExecutor Workers]; B -->|durable| Q[Kafka / ActiveMQ / SQS]; Q --> C[Consumers]; T --> R{Result}; C --> R; R -->|temporary failure| X[Retry + Jitter]; X --> E; X --> Q; R -->|permanent failure| D[DLQ / Manual Recovery]Thread Pool은 보통 Core Worker, 최대 Worker, Queue, Keep-alive와 Rejection Policy로 구성된다. 실행 중 Thread가 Core보다 적으면 새 Worker를 만들고, Core가 바쁘면 Queue에 넣고, Queue가 꽉 차면 최대치까지 Worker를 늘린 뒤 그래도 포화면 Rejection Policy를 적용한다. 정확한 순서는 Executor 구현 설정에 따라 확인한다.
Broker Queue는 Disk Replication, ACK, Visibility Timeout, Consumer Group/Competing Consumer 등 제품별 내구성 장치를 가진다. 그렇다고 무한하지 않다. Retention, Partition, Journal Disk, In-flight, Consumer DB Pool이 한계가 된다. Executor와 Broker 모두 Capacity와 Admission을 명시하지 않으면 지연이 무한히 늘어난 뒤 장애로 드러난다.
선택 기준
| 방식 | 내구성 | Latency 특성 | Ordering | Replay | Backpressure | 주요 운영 비용 |
|---|---|---|---|---|---|---|
| 직접 동기 호출 | 호출자가 성공·실패를 즉시 인지하지만 별도 작업 보존 없음 | Downstream 시간을 응답에 포함 | 호출 흐름 안에서 직렬화 가능 | 별도 기록 없으면 없음 | Timeout·동시 요청 제한으로 생산자에게 즉시 전달 | Tail Latency와 장애 전파 관리 |
CompletableFuture | Process Crash 시 기본적으로 유실 | 독립 I/O 병렬화로 총 응답을 줄일 수 있음 | Completion 순서가 실행 순서와 다를 수 있음 | 별도 저장·재실행 구현 필요 | Bounded Executor와 Rejection Policy | Executor, Timeout, 예외 조합 |
Spring @Async | In-memory Queue는 Process Crash 시 유실 | 호출은 빨리 반환하지만 Queue Wait가 생김 | Pool 병렬성만큼 역전 가능 | 별도 저장·Scheduler 구현 필요 | Bounded Queue, Reject 또는 CallerRuns | Proxy, Pool, Context, Shutdown |
| Kafka | 설정에 따른 Log 보존·복제 | Network와 Broker 지연, Batch로 처리량 최적화 | Partition 안에서만 보장 범위를 설계 | Offset 이동으로 보존 기간 내 재소비 | Producer Quota, Consumer Lag, 유입 제한을 조합 | Partition, Lag, Schema, Cluster |
| ActiveMQ | Persistent Message와 Journal 설정에 따름 | Queue와 Prefetch/Ack 설정에 영향 | Queue·Message Group 정책에 따름 | 보존·DLQ·Browser/재주입 정책 필요 | Producer Flow Control과 Store/Memory Limit | Journal/Disk, Ack, DLQ, 적체 |
| SQS | Managed Queue Retention 동안 보존 | Polling과 Visibility, Network 지연 | Standard는 순서 미보장, FIFO는 Message Group 범위 | DLQ Redrive 또는 원본 재전송 | Consumer 동시성·유입 Rate Limit을 명시 | Visibility, DLQ, Polling, 요청 비용 |
판단 순서는 “비동기니까 Kafka”가 아니다.
- Process가 죽어도 작업이 남아야 하는가?
- 결과가 같은 요청 안에서 필요한가, 나중에 조회해도 되는가?
- Retry 가능한가, Side Effect는 Idempotent한가?
- 필요한 Ordering 범위와 Replay 기간은 무엇인가?
- 생산 Peak와 소비 Capacity 차이는 얼마인가?
- 팀이 Broker Cluster와 Schema, DLQ를 운영할 수 있는가?
짧고 유실 가능하며 같은 App 안에서 끝나는 Cache Warm-up은 Executor가 단순하다. 결제 이후 배송 지시처럼 잃으면 안 되고 Process Crash를 넘어야 하는 작업은 업무 Commit과 Durable Outbox 운영 설계 및 Broker를 검토한다.
Example
아래 예제들은 결과 조합에는 CompletableFuture, Application 내부 위임에는 Spring @Async와 전용 TaskExecutor, Thread 문맥 전달에는 제한적인 TaskDecorator를 적용하는 흐름을 보여준다. 중요한 업무를 Process 재시작 너머로 보존해야 한다면 이 예제의 In-memory Queue 대신 Durable Broker와 Outbox를 선택한다.
CompletableFuture
CompletableFuture는 Thread를 자동으로 효율화하는 마법이 아니라 결과와 Dependency Graph를 표현하는 API다. Executor를 생략하면 Common Pool을 공유해 다른 작업과 간섭할 수 있다. Blocking I/O를 Common Pool에 많이 넣으면 CPU 작업도 굶을 수 있으므로 용도별 Bounded Executor를 전달한다.
CompletableFuture<User> user = CompletableFuture.supplyAsync(
() -> userClient.get(userId), ioExecutor
);
CompletableFuture<List<Order>> orders = CompletableFuture.supplyAsync(
() -> orderClient.list(userId), ioExecutor
);
UserDashboard dashboard = user
.thenCombine(orders, UserDashboard::new)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(error -> fallbackFor(unwrap(error)))
.join();병렬화는 총 지연을 줄일 수 있지만 Downstream 동시 요청은 늘어난다. 요청 하나가 5개 Future를 만들고 API 동시 요청이 천 개면 최대 5천 작업이 몰릴 수 있다. Timeout이 발생해도 이미 시작한 HTTP/DB 작업이 자동으로 중단되거나 Transaction이 Rollback된다고 가정하지 않는다. Client 수준 Timeout과 취소 지원을 함께 설정한다.
allOf()는 개별 결과를 반환하지 않고 하나가 실패했을 때 나머지 작업의 취소·결과 사용 정책을 직접 결정해야 한다. 예외를 exceptionally에서 무조건 빈 결과로 바꾸면 장애가 정상처럼 보인다. 허용 가능한 Partial Result와 반드시 실패해야 하는 결과를 업무 계약으로 구분한다.
Spring @Async와 TaskExecutor
Spring @Async는 보통 Proxy가 Method 호출을 가로채 TaskExecutor에 제출한다. 따라서 같은 Bean 내부에서 this.asyncMethod()로 호출하는 Self Invocation은 Proxy를 통과하지 않아 동기 실행될 수 있다. 별도 Bean으로 책임을 분리하거나 호출 경계를 명시하고 Test에서 실제 Thread 이름과 실행 시점을 검증한다.
@Configuration
@EnableAsync
public class AsyncConfiguration {
@Bean("notificationExecutor")
public ThreadPoolTaskExecutor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("notification-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setTaskDecorator(new ContextCopyingTaskDecorator());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}@Service
public class NotificationTask {
@Async("notificationExecutor")
public CompletableFuture<DeliveryResult> send(OrderPaid command) {
try {
DeliveryResult result = notificationClient.send(command);
return CompletableFuture.completedFuture(result);
} catch (Exception error) {
return CompletableFuture.failedFuture(error);
}
}
}void 반환 @Async의 예외는 호출자에게 전달되지 않는다. AsyncUncaughtExceptionHandler와 Metric/Log를 구성하더라도 업무 복구가 필요한 작업에는 결과 Store 또는 Durable Queue가 더 적합하다. Future를 반환해도 호출자가 결과를 관찰하지 않으면 실패는 사실상 유실된다.
Self Invocation 실패 예
@Service
class ReportService {
public void request() {
this.generateAsync(); // Proxy 우회: 같은 Thread에서 실행될 수 있다.
}
@Async("reportExecutor")
public void generateAsync() {
// ...
}
}@Transactional과 @Async를 같은 Method에 붙이면 호출 Thread의 Transaction이 Worker Thread로 이동하는 것이 아니다. Worker가 Method를 시작할 때 새 Transaction을 열 수 있지만 원래 Transaction과는 독립적이다. 원래 Transaction Commit 전에 Worker가 조회하면 아직 데이터가 보이지 않을 수 있다. Commit 뒤 실행이 필요하면 Transaction Synchronization만으로 메모리 Task를 등록하는 방식의 Crash Window를 이해하거나 Outbox를 사용한다.
Bounded Queue와 Rejection Policy
Unbounded Queue는 순간 오류를 지연으로 숨길 뿐이다. 생산 속도가 소비 속도보다 빠른 시간이 길어지면 Heap, 대기 시간, Timeout 뒤 무의미해진 Task가 늘어난다. Bounded Queue는 한계를 조기에 드러내고 Backpressure를 설계할 기회를 준다.
| Rejection Policy | 동작 | 적합성과 위험 |
|---|---|---|
| AbortPolicy | 즉시 예외 | 호출자가 실패/Retry/503을 명확히 처리할 때 안전 |
| CallerRunsPolicy | 제출 Thread가 직접 실행 | 생산자를 늦춰 자연 Backpressure, 요청 지연 증가 |
| DiscardPolicy | 알림 없이 폐기 | 유실 허용이 명시된 Telemetry 외에는 위험 |
| DiscardOldestPolicy | 가장 오래된 대기 Task 폐기 | Ordering·업무 Task에는 대체로 위험 |
| Custom | Metric, 우선순위, 별도 저장 | 복잡성과 실패 경로 Test 필요 |
CallerRunsPolicy는 항상 정답이 아니다. Event Loop나 Consumer Thread가 Task를 직접 실행하면 전체 Polling/Ack가 멈추고 Rebalance나 Visibility Timeout을 유발할 수 있다. 해당 Thread를 늦춰도 안전한지 확인한다. 중요 작업은 Reject 시 Broker/DB에 내구성 있게 되돌릴 경로가 있어야 한다.
Pool 크기는 공식 하나로 결정하지 않는다. CPU-bound는 Core 수 근처에서 시작하고, I/O-bound는 대기/계산 비율을 참고하되 Downstream Connection Pool과 Rate Limit을 상한으로 둔다. Thread 100개가 있어도 DB Connection이 10개면 90개가 대기하며 Context Switching만 늘 수 있다.
관찰할 지표는 Active Thread, Pool Size, Queue Depth/Capacity, Queue Wait p95/p99, Task Runtime, Completed/Rejected Count, Timeout/Cancel, 제출자별 Rate다. Queue Depth가 낮아도 Wait가 길면 느린 Task가 Worker를 점유하는지 본다.
Timeout, Retry, DLQ와 취소
Timeout은 실패를 없애지 않고 “호출자가 결과를 더 기다리지 않는다”는 불확실성을 만든다. 작업은 이미 성공했지만 응답만 잃었을 수 있다. 따라서 Retry 전에 Idempotency Key와 상태 조회를 준비한다.
Timeout Budget은 End-to-end Deadline에서 역산한다. API 1초 Budget인데 내부 호출 각각에 1초를 주고 3회 Retry하면 Deadline을 훨씬 넘는다. 남은 시간을 Propagate하고 Connect/Read/Queue Wait를 분리하며, Retry에는 Exponential Backoff와 Jitter를 사용한다. 같은 장애를 API Gateway, Service, Client, Consumer가 모두 Retry하면 부하가 곱셈으로 증폭되는 Retry Storm이 된다. 한 계층을 주 Retry 책임자로 정한다.
DLQ는 자동 처리에서 분리한 메시지와 오류 문맥을 보관한다. 다음이 없으면 복구 체계가 아니다.
- 원래 Message ID, Correlation/Trace ID, Source Queue/Partition
- 실패 분류, 시도 횟수, 첫·마지막 실패 시각
- 민감정보 Masking과 접근 권한
- 알림, 소유 팀, 수정·Replay Runbook
- Replay Rate Limit, Dry-run, Audit와 중단 기능
- 원래 Idempotency/Ordering 규칙 재사용
Future의 cancel(true)는 Interrupt 요청일 뿐 Library가 Interrupt를 무시하거나 이미 외부 Side Effect를 수행했을 수 있다. SQS Message Visibility를 연장하지 못하면 다른 Consumer가 동시에 처리할 수 있고, Kafka Consumer 처리 시간이 Poll Interval을 넘으면 Rebalance가 생길 수 있다. 제품별 Lease/Ack 규칙과 작업 Timeout을 맞춘다.
Kafka, ActiveMQ, SQS 비교
Kafka
Kafka는 Partition Append Log와 Consumer Offset을 기반으로 높은 처리량과 Replay에 적합하다. 같은 Key를 같은 Partition에 보내 Aggregate Ordering을 설계할 수 있다. Producer Idempotency와 Transaction 기능의 범위를 이해하되, Consumer DB Side Effect까지 자동 Exactly Once가 된다고 단정하지 않는다. Partition 수는 병렬성 상한과 Ordering 범위를 결정하며, Consumer Lag과 Retention을 함께 운영한다. 자세한 내용은 Kafka에서 본다.
ActiveMQ
ActiveMQ/JMS는 Queue/Topic, Persistent Delivery, Selector, Acknowledgement와 DLQ를 제공하며 기존 Enterprise 통합에서 유용하다. Persistent 설정만으로 Disk-full, Journal 손상, Store Usage, Expiry와 Consumer 적체 운영이 사라지지 않는다. Prefetch가 크면 한 Consumer에 메시지가 몰리고 처리 중 장애 때 Redelivery가 늘 수 있다. ActiveMQ와 JMS 운영에서 Journal과 DLQ를 연결한다.
SQS
SQS Standard Queue는 일반적으로 At-least-once와 중복 가능성을 전제로 한다. Consumer가 받으면 Visibility Timeout 동안 숨겨지고 완료 후 Delete해야 한다. 작업보다 Timeout이 짧으면 처리 중 다시 보이므로 연장하거나 작업을 쪼갠다. FIFO도 Message Group 안의 순서와 Dedup Window 등 정확한 범위를 확인한다. DLQ Redrive와 Maximum Receive Count, Long Polling, Consumer 동시성이 DB Pool을 넘지 않게 한다. Amazon SQS·SNS에서 상세 운영을 본다.
Broker 선택은 기능표만이 아니라 현재 팀의 장애 복구 능력, Cloud 제약, Replay 요구, Throughput과 Ordering 단위, 비용을 포함한다. 이미 ActiveMQ가 안정적으로 요구를 만족한다면 유행만으로 Kafka로 옮길 이유는 없다. 반대로 긴 Replay와 고처리량 Stream이 핵심이면 Queue 모델만 확장하는 것보다 Kafka가 자연스러울 수 있다.
Idempotency와 Ordering
비동기 처리의 Retry, ACK 유실, Consumer Crash는 중복을 만든다. Message ID를 Consumer Inbox의 Unique Key로 Insert하고 업무 Update를 같은 Transaction으로 Commit한다. 외부 API 호출은 Provider가 지원하는 Idempotency Key나 자체 발송 상태를 사용한다. 단지 “Consumer Method가 한 번 호출됨”이 아니라 최종 Side Effect가 한 번 반영됐는지 기준으로 본다.
Ordering은 Global보다 Aggregate별로 좁힌다. Kafka Partition Key, SQS Message Group, ActiveMQ의 Group 기능 등 제품별 메커니즘과 Aggregate Sequence를 함께 사용한다. 병렬 Worker가 Version 12를 11보다 먼저 받으면 보류·Retry하거나 Source of Truth에서 현재 상태를 재구성한다. 자세한 패턴은 멱등성·중복 전달과 순서에서 다룬다.
Retry가 원래 Queue 뒤로 들어가면 같은 Aggregate의 이후 Event가 먼저 처리될 수 있다. Retry Topic 단계, Key별 직렬 처리, Sequence Gap Buffer 중 어떤 전략을 쓸지 실패 빈도와 지연 요구로 정한다. 무한히 순서를 기다리지 말고 최대 보류 시간 뒤 조정 또는 운영 개입 경로를 둔다.
Transaction과 Context 경계
Thread가 바뀌면 호출 Thread의 DB Transaction, Security Context, MDC와 Trace Context가 자동으로 안전하게 이어진다고 가정하면 안 된다. ThreadLocal 값은 Thread Pool에서 이전 요청의 값이 남을 수도 있어 반드시 복사 범위를 최소화하고 finally에서 정리한다.
final class ContextCopyingTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> mdc = MDC.getCopyOfContextMap();
String traceId = TraceContext.currentTraceId();
return () -> {
try {
if (mdc != null) MDC.setContextMap(mdc);
TraceContext.set(traceId);
task.run();
} finally {
TraceContext.clear();
MDC.clear();
}
};
}
}Security Context 전체를 무조건 복사하면 권한이 오래 살아 있거나 잘못된 사용자로 실행될 수 있다. Background Job에는 필요한 Subject/Tenant ID와 권한 Snapshot 또는 Service Identity를 명시적으로 Message에 담고 Consumer에서 다시 검증한다. JWT 원문이나 Credential을 Queue Payload에 넣지 않는다.
Trace Context는 W3C Trace Context 같은 표준 Header로 Broker 경계를 넘기고, Producer Span과 Consumer Span을 연결한다. 오래 지연된 Message를 하나의 거대한 Span으로 유지할지 Link로 연결할지는 관측 도구와 의미에 맞춘다. Correlation ID, Message ID, Attempt, Queue Wait를 Structured Log에 남긴다.
실무 실패 시나리오
| 상황 | 겉으로 보이는 현상 | 실제 원인 | 대응 |
|---|---|---|---|
@Async인데 같은 Thread | 응답이 느림 | Self Invocation으로 Proxy 우회 | Bean 분리, Proxy 경계 Test |
| 작업이 조용히 사라짐 | 로그도 실패도 없음 | void @Async, DiscardPolicy, 미관찰 Future | 명시적 결과/Handler, Reject Metric |
| Heap 급증 | 처리량은 그대로 | Unbounded Queue 적체 | Bounded Queue, Admission Control |
| Timeout 뒤 이중 처리 | Client는 실패 응답 | 작업 성공 후 응답 유실·Retry | Idempotency Key, 상태 조회 |
| 배포 때 알림 유실 | 재기동 후 대기 Task 없음 | In-memory Queue | Durable Queue 또는 Drain 정책 |
| MDC 사용자 섞임 | 잘못된 Trace/User Log | ThreadLocal 미정리 | TaskDecorator finally Clear |
| Consumer만 늘렸는데 DB 장애 | Queue Lag은 잠시 감소 | DB Pool/Lock이 실제 병목 | Downstream Capacity 기반 동시성 |
| DLQ 무한 증가 | 정상 Queue는 조용함 | Schema/Payload 영구 오류 | 소유 팀 Alert, 수정·Replay Runbook |
| Kafka 순서 역전처럼 보임 | Aggregate 상태가 뒤로 감 | Key 불일치 또는 Retry 경로 | Key/Sequence 검증, Gap 처리 |
| SQS 중복 처리 | 같은 Job 동시 실행 | Visibility Timeout보다 작업이 김 | 연장, 작업 분할, Idempotency |
Trade-off
비동기는 Latency를 없애지 않고 다른 시간과 장소로 이동시킨다. API 응답 p99는 좋아져도 업무 완료 시간은 길어질 수 있다. Queue가 장애를 흡수하지만 적체와 운영 복잡성이 생긴다. 병렬성은 처리량을 늘리지만 Ordering과 자원 경쟁을 어렵게 한다. Retry는 가용성을 높이지만 중복과 장애 부하를 키운다.
동기 호출은 실패를 즉시 전달하고 구현이 단순하지만 Downstream Latency와 장애에 결합된다. Executor는 도입이 간단하고 결과 조합이 좋지만 Process Crash를 넘는 내구성이 없다. Broker는 내구성, Buffer와 독립 Scale을 제공하지만 Schema, 중복, DLQ, Lag, 비용을 운영해야 한다.
흔한 오해
@Async를 붙이면 서버 처리량이 항상 늘지 않는다. 병목이 DB나 외부 API면 동시 부하만 늘 수 있다.- 비동기는 Non-blocking과 같은 말이 아니다. Worker에서 Blocking I/O를 할 수 있다.
- Queue에 넣었다고 처리 성공이 아니다. 수락, 저장, 전달, 업무 반영은 서로 다른 상태다.
- Timeout은 작업을 Rollback하거나 확실히 취소하지 않는다.
- Retry는 Error Handling의 대체물이 아니다. 영구 오류 분류와 DLQ가 필요하다.
- Broker가 메시지를 한 번만 전달한다고 가정하지 않는다. Consumer Idempotency가 필요하다.
- Kafka의 Exactly Once 표현이 외부 DB·Email·결제까지 한 번만 실행된다는 뜻은 아니다.
- ThreadLocal과 Transaction은 Thread 경계를 자동으로 건너지 않는다.
- Queue 크기를 무한히 늘리는 것은 Backpressure가 아니라 장애 발견을 늦추는 것이다.
Production Considerations
설계 Checklist
- 요청 응답이 의미하는 상태를
accepted,processing,completed,failed로 계약화한다. - 유실 가능 여부로 In-memory Executor와 Durable Broker를 먼저 구분한다.
- Executor는 이름 있는 전용 Pool, Bounded Queue, 명시적 Rejection Policy를 쓴다.
- Downstream DB Pool, HTTP Connection, Rate Limit보다 Worker 동시성을 크게 잡지 않는다.
- Queue Wait, Runtime, Active Thread, Rejection, Broker Lag, Oldest Message Age를 관찰한다.
- Timeout Budget과 Retry 책임 계층, Backoff/Jitter, 최대 시도·경과 시간을 정한다.
- Message/Event ID와 Consumer Idempotency, Aggregate Ordering 범위를 정의한다.
@AsyncProxy와 Self Invocation, 예외 관찰을 Test한다.- Transaction Commit 전 실행되는 Race를 막고 중요 작업은 Outbox로 인계한다.
- Security, MDC, Trace Context의 허용된 값만 복사하고 반드시 정리한다.
- DLQ 소유 팀, Alert, 개인정보, Replay 승인과 Audit를 운영한다.
- Payload Schema Version과 호환성, 과거 메시지 Replay를 Test한다.
Graceful Shutdown
종료 신호를 받으면 새 Task/Message 수락을 먼저 멈추고 Readiness를 내린다. Executor는 정해진 시간 동안 Queue와 실행 Task를 Drain한다. 제한 시간이 끝났을 때 In-memory Task를 유실해도 되는지 명시한다. 유실 불가 작업이라면 종료 Hook에 의존하지 말고 Durable Store를 사용한다.
Consumer는 새 Poll을 멈추고 진행 중 작업을 완료한 뒤 ACK/Offset Commit한다. ACK 전에 Process가 죽으면 Redelivery될 수 있으므로 Idempotency가 필요하다. Kafka Rebalance, SQS Visibility, ActiveMQ Session Ack의 제품별 시간 제한을 Shutdown Budget과 맞춘다. Kubernetes terminationGracePeriodSeconds가 App의 await 시간보다 짧으면 Graceful 설정은 효과가 없다.
용량과 장애 훈련
평균 처리량이 아니라 Peak 유입, Task Runtime p99, Queue 한계와 복구 Catch-up Capacity를 측정한다. 부하 Test에서 Executor Queue 포화, Broker 장애, DB Pool 제한, 느린 Consumer, Poison Message, 배포 중 종료를 주입한다. “거절 없이 모두 받음”보다 포화 시 호출자가 예측 가능한 429/503 또는 Durable 수락 결과를 받는지 검증한다.
Dashboard는 API Accepted Rate, Executor Queue Wait, Broker Produce/Consume Rate, Lag, DLQ, 업무 완료 Latency를 한 흐름으로 보여준다. Trace가 끊겨도 Message ID와 Correlation ID로 단계별 상태를 찾을 수 있어야 한다.
다른 사람에게 설명한다면
30초 설명: “비동기 처리는 요청 응답과 실제 실행 시간을 분리합니다. 같은 Process의 짧고 유실 가능한 작업은 @Async와 Bounded TaskExecutor로 처리할 수 있지만, 재시작 후에도 남아야 하는 업무는 Kafka·ActiveMQ·SQS 같은 Durable Queue가 필요합니다. 어느 쪽이든 Queue 한계, Rejection/Backpressure, Timeout·Retry, 실패 관찰이 필요하고, Thread나 Process 경계를 넘으면 Transaction과 MDC가 자동 전파되지 않습니다.”
2분 설명: “먼저 수락 성공과 처리 성공을 분리합니다. CompletableFuture는 한 요청 안에서 결과를 조합하기 좋고, Spring @Async는 Proxy가 Method를 TaskExecutor에 넘기므로 Self Invocation과 전용 Pool 설정을 주의합니다. Executor Queue는 Bounded로 두고 Abort나 CallerRuns 같은 포화 정책을 명시합니다. 하지만 Process가 죽으면 메모리의 대기 작업은 사라지므로 결제 후 배송처럼 중요한 작업은 업무 DB와 Outbox를 Commit하고 Broker로 전달합니다. Kafka는 Replay와 Partition Stream, ActiveMQ는 JMS Queue, SQS는 Managed Visibility 모델에 강점이 있습니다. Broker도 중복과 순서 역전을 만들 수 있어 Consumer Idempotency와 Aggregate Sequence가 필요합니다. 마지막으로 Timeout은 취소가 아니고 Retry는 부하를 증폭하므로 Deadline, Backoff, DLQ를 설계하며, Security/MDC/Trace Context는 필요한 값만 복사하고 finally에서 정리합니다. 종료 때는 새 작업을 막고 Drain하되 유실 불가 작업은 애초에 Durable Queue를 사용합니다.”
Interview Questions
- 비동기와 Non-blocking의 차이를 예로 설명해 보라.
- Follow-up:
@AsyncMethod 안의 JDBC 호출은 어떤 모델인가?
- Follow-up:
CompletableFuture의 Common Pool을 Blocking I/O에 무심코 쓰면 어떤 문제가 생기는가?- Spring
@Async의 Self Invocation이 동작하지 않는 이유는?- Follow-up:
@Transactional호출 문맥이 Worker Thread에 그대로 전달되는가?
- Follow-up:
- Core Pool, Max Pool, Bounded Queue가 Task 제출 시 어떤 순서로 작동하는가?
- AbortPolicy와 CallerRunsPolicy의 Trade-off는?
- Follow-up: Event Loop에서 CallerRuns가 위험한 이유는?
- Timeout 후 Retry가 중복 Side Effect를 만드는 과정을 설명해 보라.
- Executor 대신 Durable Broker가 필요한 기준은 무엇인가?
- Kafka, ActiveMQ, SQS를 Replay, Ordering, 운영 비용 관점에서 비교해 보라.
- SQS Visibility Timeout보다 작업이 길면 어떤 일이 생기는가?
- Consumer Idempotency와 Aggregate Ordering을 어떻게 구현하는가?
- Security, MDC, Trace Context는 비동기 Thread로 어떻게 전달하고 정리하는가?
- Graceful Shutdown 중 작업 유실과 중복을 어떻게 다루는가?
Related Topics
- Durable Outbox 운영 설계 — 업무 Commit과 내구성 있는 Broker 인계
- 멱등성·중복 전달과 순서 — Retry와 At-least-once의 비즈니스 방어선
- Spring Proxy·Advice·Pointcut —
@Async와 Self Invocation의 기반 - Thread Pool — Worker와 Queue 크기 결정
- Kafka — Partition Log와 Consumer Lag
- ActiveMQ와 JMS 운영 — Persistent Queue, Journal과 DLQ
- Amazon SQS·SNS — Visibility Timeout과 Managed Queue
SOURCE REFERENCES
이 문서의 근거
본문은 Dev Atlas 안에서 완결되며, 검증이 필요할 때만 원문을 확인할 수 있습니다.