자바 개발을 하다 보면 멀티스레딩이 필요한 순간이 반드시 와요. 외부 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 |