[Java] 비동기 처리 구현 (Thread, ExecutorService, @Async)

조성우·2026년 1월 4일

Spring Boot

목록 보기
15/15
post-thumbnail

웹 애플리케이션을 개발하다 보면 다음과 같은 상황을 자주 마주한다.

  • 외부 API 호출
  • DB 조회
  • 파일 I/O
  • 네트워크 통신

이런 작업들의 공통점은 “기다려야 한다”는 점이다.

이 작업들을 메인 쓰레드에서 순차적으로 처리한다면

  1. 하나가 끝날 때까지 다음 작업은 시작조차 못 한다.
  2. 전체 응답 시간이 길어지고, 시스템 효율도 떨어진다.

따라서 이런 상황에서는 비동기 처리를 고려해볼 수 있다.


1. Thread

요구사항

  • 기다려야 하는 작업(API 호출)을 동시에 여러 번 호출하고 싶다.

해결

  • 자바에서 가장 기본적인 방법은 Thread를 직접 생성하여 실행하는 것이다.
for (int i = 0; i < 10; i++) {
    new Thread(() -> service.sendRequest(1, 2)).start();
}

동작 방식

  • start()를 호출하면 메인 쓰레드와 분리되어 비동기로 실행
  • 작업마다 새로운 쓰레드를 생성

문제점

  • 작업 수만큼 쓰레드를 무한 생성
  • 쓰레드 생성/소멸 비용이 큼
  • 쓰레드 개수 제어 불가
  • 장애 시 추적 및 관리 어려움

실무에서는 위에 적어둔 문제점으로 인해 당연하게도 Thread를 직접 생성해 작업을 실행하진 않는다.


2. ExecutorService

추가 요구사항

  • 쓰레드를 재사용하고 싶다.
  • 동시에 실행되는 개수를 제한하고 싶다.

해결

  • Thread Pool + ExecutorService를 활용해 추가 요구사항을 충족시킬 수 있다
  • Thread Pool: 미리 생성된 스레드를 재사용하여 작업을 처리하는 스레드 집합
  • ExecutorService: 스레드 풀을 관리하며 작업 제출과 실행을 담당하는 인터페이스
// 동시에 최대 5개의 스레드를 사용하는 고정 크기 스레드 풀 생성
final ExecutorService executorService = Executors.newFixedThreadPool(5);

for (int i = 0; i < 10; i++) {
    executorService.submit(() -> service.sendRequest(1, 2));
}

구조 이해하기

ExecutorService의 구조는 다음과 같다.

  • 작업(Task)은 큐에 쌓이고
  • 쓰레드 풀의 쓰레드들이 하나씩 꺼내서 처리
  • 쓰레드는 재사용

📌 클래스 구조

Executor 클래스 계층

Executors는 Executor 구현체를 만들어주는 팩토리 유틸 클래스이다.
Executors는 내부적으로 ExecutorService의 구현체인 ThreadPoolExecutor를 리턴한다.

장점

  • 쓰레드 개수 제한 가능
  • 재사용으로 성능 개선
  • 안정적인 리소스 관리

새로운 문제?

“비동기 작업의 결과값을 받고 싶다.”


3. Future

요구사항

  • 비동기로 실행
  • 쓰레드 관리
  • 결과값 반환

해결

  • Future: 비동기 작업의 실행 결과를 나중에 조회하거나 완료 여부를 확인할 수 있는 객체
final ExecutorService executorService = Executors.newFixedThreadPool(5);

for (int i = 0; i < 10; i++) {
    Future<Integer> future =
        executorService.submit(() -> service.sendRequest(1, 2));

    log.info(String.valueOf(future.get()));
}

문제점

  • future.get() 호출 순간 블로킹
  • 결국 작업이 끝날 때까지 기다림 → 사실상 동기적인 작동 방식

4. CompletableFuture

해결 아이디어

  • 기다리지 말고, 작업이 끝났을 때 실행할 로직을 미리 등록하자 (Callback)
CompletableFuture
    .supplyAsync(() -> service.sendRequest(a, b), executorService)
    .thenAccept(result -> log.info(String.valueOf(result)));

장점

  • 메인 쓰레드 블로킹 없음
  • 콜백 기반 처리
  • 비동기 흐름 유지

5. 비동기 흐름 연결하기

5-1. 이전 결과로 다음 비동기 실행 (thenCompose)

// thenCompose: 이전 비동기 작업의 결과를 받아 다음 비동기 작업을 연결해서 실행
completableFuture1
    .thenCompose(t ->
        CompletableFuture.supplyAsync(
            () -> service.sendRequest(t, random.nextInt(100)),
            executorService
        )
    )
    .thenAccept(log::info);

completableFuture1이 완료되면, 그 결과(t)를 이용해 새로운 비동기 작업을 실행한다.
supplyAsync로 executorService 스레드 풀에서 sendRequest를 수행하고, 최종 결과를 thenAccept에서 로그로 출력한다.


5-2. 여러 비동기 결과 조합 (thenCombine)

completableFuture1
    .thenCombine(completableFuture2,
        (t1, t2) -> service.sendRequest(t1, t2)
    )
    .thenAccept(log::info);

completableFuture1과 completableFuture2가 모두 완료되면, 두 결과(t1, t2)를 service.sendRequest에 전달해 처리한다.
결과는 thenAccept에서 로그로 출력한다.


지금까지 봐온 코드의 문제는 다음과 같다.

  • 쓰레드 풀 생성 코드
  • 비동기 제어 코드
  • 비즈니스 로직

전부 한 곳에 섞여 있다.

"비동기는 인프라(스레드 풀 관련) 관심사인데, 왜 비즈니스 코드가 이걸 알아야 하지?”


6. Spring @Async

목표

  • 비동기 인프라 설정과 비즈니스 로직 분리

설정

스프링에서는 어노테이션 기반 설정을 통해 비동기 관리 코드와 비즈니스 로직을 분리할 수 있다.

// 1. 쓰레드 풀 환경 설정 분리
@Configuration
@EnableAsync
public class AsyncConfiguration {
    @Bean(destroyMethod = "shutdown")
    public Executor asyncExecutor() {
        return new ThreadPoolTaskExecutorBuilder()
            .corePoolSize(10)
            .maxPoolSize(10)
            .threadNamePrefix("CustomTP-")
            .build();
    }
}
  • @EnableAsync → Spring이 비동기 메서드(@Async)를 처리하도록 활성화
  • asyncExecutor → 스레드 풀을 Bean으로 생성, 애플리케이션 전반에서 재사용 가능
  • 이렇게 하면 스레드 풀 관련 코드는 전부 한곳에 모이며, 비즈니스 코드에서는 신경 쓸 필요가 없게 된다.

사용

// 2. 비즈니스 로직에만 집중
@Service
@RequiredArgsConstructor
public class PlusAsyncService {

    private final PlusService plusService;

    @Async("asyncExecutor")
    public void sendRequest(int a, int b) {
        plusService.sendRequest(a, b);
    }
}
  • @Async("asyncExecutor") → 지정한 스레드 풀에서 메서드 실행
  • 이제 sendRequest는 순수하게 비즈니스 로직만 처리한다.
  • 스레드 생성, 관리, 비동기 흐름 제어 등은 전혀 신경 쓰지 않아도 된다.

0개의 댓글