본문 바로가기
JAVA

Java 멀티스레딩 입문: Thread, ExecutorService, CompletableFuture

by 요료료룡 2026. 4. 8.

자바 개발을 하다 보면 멀티스레딩이 필요한 순간이 반드시 와요. 외부 API를 여러 개 동시에 호출하거나, 무거운 작업을 백그라운드에서 처리하거나, 대용량 데이터를 병렬로 처리해야 할 때죠. 이번 포스팅에서는 Thread부터 시작해 현대적인 CompletableFuture까지 멀티스레딩의 발전 과정을 따라가볼게요.

1단계: Thread - 가장 기본적인 방법

Java의 멀티스레딩 출발점은 Thread 클래스예요. 직접 쓰는 경우는 드물지만 원리를 이해하는 데 도움이 돼요.

// Runnable로 스레드 생성
Thread thread = new Thread(() -> {
    System.out.println("별도 스레드에서 실행: " + Thread.currentThread().getName());
});
thread.start();  // start()를 호출해야 새 스레드에서 실행됨 (run() 아님!)

// Thread를 직접 상속하는 방식 (잘 쓰지 않음)
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("스레드 실행");
    }
}

// join(): 스레드가 끝날 때까지 대기
Thread t = new Thread(() -> heavyWork());
t.start();
t.join();  // t가 끝날 때까지 현재 스레드 대기
System.out.println("작업 완료");
2단계: ExecutorService - 스레드 풀 관리

스레드를 매번 new로 만들면 생성/소멸 비용이 크고 스레드가 무한정 늘어날 수 있어요. ExecutorService는 스레드 풀을 만들어 재사용하고 동시 실행 수를 제어해줘요.

// 고정 크기 스레드 풀 생성
ExecutorService executor = Executors.newFixedThreadPool(4); // 스레드 4개

// Runnable 작업 제출
executor.submit(() -> {
    System.out.println("작업 실행: " + Thread.currentThread().getName());
});

// 반환값이 있는 작업: Callable + Future
Future<Integer> future = executor.submit(() -> {
    Thread.sleep(1000);
    return 42;
});

System.out.println("다른 작업 수행 중...");
Integer result = future.get(); // 결과 나올 때까지 블로킹 대기
System.out.println("결과: " + result);

// 반드시 종료 처리
executor.shutdown();
3단계: CompletableFuture - 비동기의 완성

CompletableFuture는 Java 8에서 도입된 비동기 프로그래밍의 핵심이에요. Future의 단점(블로킹 get, 예외 처리 어려움)을 해결하고 콜백 체이닝이 가능해요.

// 비동기 작업 실행
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // 별도 스레드에서 실행
    return fetchUserFromDB(userId);
});

// 완료 후 다음 작업 체이닝
future
    .thenApply(user -> user.toUpperCase())     // 변환
    .thenAccept(result -> System.out.println(result)) // 소비
    .exceptionally(ex -> {                     // 예외 처리
        System.err.println("오류: " + ex.getMessage());
        return null;
    });

// 여러 비동기 작업 병렬 실행
CompletableFuture<String> userFuture    = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<String> productFuture = CompletableFuture.supplyAsync(() -> fetchProduct(id));

// 둘 다 완료될 때까지 대기
CompletableFuture.allOf(userFuture, productFuture).join();

String user    = userFuture.get();
String product = productFuture.get();
동기화 문제: synchronized와 Lock

여러 스레드가 같은 데이터를 동시에 수정하면 데이터 불일치가 발생해요. 이를 막는 방법을 알아볼게요.

// synchronized 키워드로 임계 구역 보호
public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;  // 한 번에 하나의 스레드만 실행
    }

    public synchronized int getCount() {
        return count;
    }
}

// 더 유연한 ReentrantLock
import java.util.concurrent.locks.ReentrantLock;

public class SafeCounter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // 반드시 finally에서 해제
        }
    }
}

// 원자적 연산이 필요하면 AtomicInteger가 가장 간단
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger atomicCount = new AtomicInteger(0);
atomicCount.incrementAndGet();  // 스레드 안전, synchronized 불필요

 

Tip
💡 Java 21에서 도입된 Virtual Thread(가상 스레드)는 기존 OS 스레드보다 훨씬 가볍게 수십만 개의 동시 작업을 처리할 수 있어요. Spring Boot 3.2+에서는 설정 한 줄로 활성화할 수 있으니 최신 프로젝트라면 꼭 검토해보세요.

 

'JAVA' 카테고리의 다른 글

Spring Boot 핵심 개념 정리: DI, IoC, AOP를 쉽게 이해해보자  (0) 2026.04.07
JAVA 17 vs 21 vs 25  (0) 2026.03.04
[JAVA] Recode  (0) 2024.04.18
JAVA Collection  (0) 2024.04.18
[SPRING] @Transactional  (0) 2024.04.16