비동기 처리란? 언제, 왜, 어떻게 사용하는가
비동기 처리란? 언제, 왜, 어떻게 사용하는가
비동기 처리(asynchronous processing)는 백엔드 개발에서 성능, 확장성, 응답성을 높이기 위한 핵심 기법 중 하나입니다. 이 글에서는 비동기 처리의 개념부터 사용 시기, 실무 이슈, 테스트 전략, 실패 대응까지 모두 정리합니다.
1. 비동기 처리란?
비동기란 작업을 요청한 후, 그 결과를 기다리지 않고 다른 작업을 수행할 수 있는 방식입니다. 이는 주로 블로킹 I/O(예: 데이터베이스 조회, 파일 읽기, 외부 API 호출 등)에서 응답을 기다리는 동안 리소스를 낭비하지 않도록 설계하는 기법입니다.
비유: 동기는 전화 통화, 비동기는 문자 메시지. 전자는 상대의 응답을 기다리며, 후자는 메시지만 보내고 기다리지 않습니다.
// 동기 방식
String result = slowService(); // 이 줄이 끝날 때까지 다음 코드 실행 안 됨
System.out.println(result);
// 비동기 방식
CompletableFuture.supplyAsync(() -> slowService())
.thenAccept(result -> System.out.println(result));
예시 설명: 위 예제는 시간이 오래 걸리는 서비스(slowService)를 동기와 비동기로 처리했을 때의 차이를 보여줍니다. 비동기 방식은 결과가 준비된 후 콜백 함수(thenAccept)를 통해 결과를 처리합니다.
2. 왜 비동기를 사용하는가?
- I/O 병목 해소: DB, API, 파일 I/O 작업 중 대기 시간 동안 다른 작업을 처리할 수 있음
- 고성능 처리: 수백~수천 개 요청을 동시에 처리해야 할 경우 유리함
- 서비스 응답성 향상: 주요 로직 외의 부가 작업을 비동기로 처리하여 사용자 체감 속도 개선
3. 자바에서의 비동기 처리 방법
1) CompletableFuture
Java 8부터 제공되며, 병렬 처리, 비동기 처리, 콜백 체이닝 등을 지원합니다.
@Service
public class AsyncService {
public CompletableFuture<String> loadData() {
return CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000); // I/O 지연 시뮬레이션
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return "데이터 로드 완료";
});
}
}
2) Spring의 @Async
Spring에서 기본 제공하는 방식으로, 간단히 어노테이션으로 메서드를 비동기화할 수 있습니다.
@Async
public CompletableFuture<String> asyncMethod() {
return CompletableFuture.completedFuture("비동기 응답");
}
주의사항: @Async는 스프링 컨텍스트에 빈으로 등록된 클래스에서만 정상 작동하며, 같은 클래스 내 self-invocation은 비동기로 작동하지 않습니다.
4. 비동기 도입 시 발생할 수 있는 이슈들 🔥
1) 예외 처리의 어려움
비동기 로직에서 예외가 발생하면 일반적인 try-catch로는 처리되지 않고 콜백 내에서 처리해야 합니다.
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("예외 발생");
}).exceptionally(ex -> {
System.err.println("예외 처리됨: " + ex.getMessage());
return null;
});
예시 설명: exceptionally를 사용하면 예외를 감지하고 기본값 반환 또는 로깅 등의 처리를 할 수 있습니다. 단, 발생한 예외는 정상적으로 전파되지 않으므로 로깅 누락에 유의해야 합니다.
2) 순서 보장 어려움
비동기 작업의 실행 순서는 예측이 어렵습니다. 다음 작업이 이전 작업의 결과에 의존하는 경우 thenCompose 또는 thenApply를 사용해야 합니다.
CompletableFuture.supplyAsync(() -> "1단계")
.thenApply(stage1 -> stage1 + " → 2단계")
.thenAccept(System.out::println);
예시 설명: thenApply는 이전 단계의 결과를 가공하고, thenAccept는 최종 결과를 소비하는 단계입니다. thenCompose는 CompletableFuture 내부 중첩을 펼칠 때 사용합니다.
3) 디버깅 난이도
비동기 실행은 별도 스레드에서 진행되기 때문에 스택 트레이스가 단절되고, 요청 단위 추적이 어렵습니다. 이를 해결하기 위해 MDC (Mapped Diagnostic Context)
를 사용하거나 Spring Sleuth
, Zipkin
등 분산 추적 도구를 활용합니다.
4) 스레드풀 고갈 문제
비동기 작업이 몰릴 경우 기본 ThreadPool이 부족해지면 작업이 지연되거나 거부될 수 있습니다. 따라서 적절한 설정이 필요합니다.
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(50);
executor.initialize();
return executor;
}
설명: corePoolSize와 maxPoolSize는 기본 및 최대 스레드 수를 설정하며, queueCapacity는 대기열 길이를 의미합니다. 비즈니스에 따라 적절한 조정이 필요합니다.
5. 테스트 전략 🧪
1) Future.get()으로 블로킹 테스트
CompletableFuture<String> future = asyncService.loadData();
String result = future.get(); // 블로킹
assertEquals("데이터 로드 완료", result);
설명: get() 메서드를 통해 테스트에서 결과가 반환될 때까지 기다릴 수 있습니다. 단, 타임아웃을 설정하지 않으면 테스트가 무한 대기할 수 있습니다.
2) Awaitility 사용
Awaitility는 비동기 테스트를 명시적으로 기다릴 수 있게 해주는 Java 라이브러리입니다.
Awaitility.await()
.atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertTrue(future.isDone()));
장점: 시간 초과와 조건을 조합하여 안정적인 테스트 수행이 가능합니다.
3) Mockito와 함께 사용하는 방식
when(mockService.asyncMethod()).thenReturn(CompletableFuture.completedFuture("결과"));
설명: 실제 비동기 로직 대신, 예측 가능한 CompletableFuture를 반환함으로써 테스트 안정성을 높입니다.
6. 실패 시 대응 전략 🔐
1) Timeout 설정
future.get(2, TimeUnit.SECONDS);
설명: 예상치 못한 지연이 전체 시스템 병목이 되는 것을 방지할 수 있습니다.
2) Fallback 처리
예외 발생 시 기본값을 반환하거나, 대체 경로로 처리합니다.
.exceptionally(ex -> "기본값");
3) Retry 전략 (Resilience4j)
Resilience4j 라이브러리를 통해 재시도, 서킷 브레이커 등을 쉽게 구성할 수 있습니다.
Retry retry = Retry.ofDefaults("myService");
Supplier<String> retryableSupplier = Retry.decorateSupplier(retry, () -> callService());
String result = retryableSupplier.get();
4) Circuit Breaker
지속적인 오류 발생 시 호출 자체를 차단하여 시스템을 보호합니다.
5) 로깅 및 알림 시스템 연동
Slack, Kibana, ELK Stack과 연동하여 실시간 경고 및 로그 분석이 가능하도록 구성합니다.
7. 언제 비동기를 피해야 할까?
비동기 처리는 유용하지만, 모든 상황에 적합한 것은 아닙니다. 오히려 복잡성만 더해지는 경우도 많습니다.
- 트랜잭션 일관성이 중요한 경우
여러 DB 작업이 반드시 순차적으로 성공해야 하는 경우라면, 비동기 처리는 롤백이나 순서 보장이 어려워 신중해야 합니다. - 명확한 순서가 필요한 비즈니스 로직
예: 대기열 순번 처리, 순차 지급 등에서는 처리 순서를 보장하는 동기 방식이 안정적입니다. - 단순한 작업에서 과한 복잡성 유발
CRUD 수준의 단순 요청이나 초당 수십 건 이하의 요청에는 굳이 비동기를 도입할 필요가 없습니다.
8. 함께 쓰면 좋은 기술 스택
비동기 처리의 효과를 극대화하고 유지보수를 쉽게 하기 위해 함께 고려하면 좋은 기술들을 소개합니다.
- Kafka / RabbitMQ
비동기 이벤트를 메시지로 처리할 수 있어 시스템 간의 의존도를 낮추고 확장성을 확보할 수 있습니다. - 이벤트 기반 아키텍처
각 도메인 컴포넌트가 이벤트를 발행하고 수신하는 구조로, 비동기 처리를 자연스럽게 녹여낼 수 있습니다. - Reactive 프로그래밍
RxJava, Reactor 등 선언적 방식으로 비동기 흐름을 제어할 수 있어 유지보수와 성능 두 마리 토끼를 잡을 수 있습니다. - Spring WebFlux
Netty 기반으로 동작하는 논블로킹 웹 프레임워크로, 고성능 API 서버 구축에 적합합니다.
9. 마무리하며 ✨
비동기 처리, 잘 쓰면 약이 되고 잘못 쓰면 독이 됩니다.
성능과 응답성을 높일 수 있는 강력한 도구이지만, 다음을 꼭 기억하세요:
- 무조건 도입은 금물: 상황에 맞게, 꼭 필요한 부분에만 적용하세요.
- 설계와 테스트 전략이 필수: 예외 처리, 타임아웃, 로깅, 디버깅 등을 철저히 준비해야 합니다.
- 스레드풀 설정은 생명줄: 기본 설정 그대로 쓰면 오히려 병목과 장애의 원인이 됩니다.
- 도구의 도움을 받자: Resilience4j, Awaitility, Kafka, WebFlux 등과 함께라면 더 안정적인 비동기 환경을 만들 수 있습니다.
비동기 처리, 그 자체보다 언제, 왜, 어떻게 쓸지가 핵심입니다. 여러분의 서비스에 알맞은 전략적 선택이 되길 바랍니다.
📌 참고 키워드: Java 비동기, CompletableFuture, Spring @Async, 비동기 예외 처리, 비동기 테스트, Resilience4j, Awaitility, Kafka, WebFlux
실무에서는 항상 "왜 비동기를 써야 하는가?"를 먼저 묻고, 목적에 맞게 단계적으로 적용하는 전략이 중요합니다.