Java에서 멀티스레딩은 애플리케이션의 병렬 처리를 위한 핵심 기술입니다. 기본적으로 Java 스레드는 명시적인 관리가 가능하며, C++과 달리 모든 스레드에 대해 반드시 join을 선언해야 하는 제약에서 비교적 자유롭습니다. 본 아티클에서는 Java에서 스레드를 생성하는 다양한 방법과 스레드 풀 활용법, 그리고 데이터 격리를 위한 ThreadLocal에 대해 다룹니다.
1. Java 스레드 생성의 세 가지 방식
Java에서는 크게 세 가지 인터페이스와 클래스를 활용하여 스레드를 구현할 수 있습니다.
1.1 Thread 클래스 상속
가장 기본적인 방법으로 Thread 클래스를 상속받아 run() 메서드를 오버라이딩합니다.
class Processor extends Thread {
public Processor(String name) {
super(name);
}
@Override
public void run() {
System.out.println("현재 실행 중인 스레드: " + getName());
}
}
public class Main {
public static void main(String[] args) {
Processor p1 = new Processor("Worker-1");
Processor p2 = new Processor("Worker-2");
p1.start();
p2.start();
}
}
1.2 Runnable 인터페이스 구현
다중 상속이 불가능한 Java의 특성상, 다른 클래스를 상속받아야 하는 경우 Runnable 인터페이스를 구현하는 방식이 더 선호됩니다.
class JobUnit implements Runnable {
@Override
public void run() {
System.out.println("작업 유닛이 별도 스레드에서 실행됩니다.");
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new JobUnit());
thread.start();
}
}
1.3 Callable과 FutureTask 활용
반환값이 필요한 경우 Callable을 사용합니다. 이는 결과값을 Future 객체를 통해 전달받을 수 있어 비동기 작업의 결과를 추적하기 용이합니다.
public class AsyncExample {
public static void main(String[] args) {
FutureTask<Integer> task = new FutureTask<>(() -> {
int sum = 0;
for(int i=0; i<10; i++) sum += i;
return sum;
});
new Thread(task).start();
try {
// get() 메서드는 결과가 나올 때까지 메인 스레드를 블로킹합니다.
Integer result = task.get();
System.out.println("계산 결과: " + result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
2. 현대적인 비동기 처리: CompletableFuture
Java 8에서 도입된 CompletableFuture는 기존의 Future보다 강력한 기능을 제공합니다. 콜백을 등록하거나 여러 작업을 조합하는 것이 훨씬 간편합니다.
- supplyAsync: 반환값이 있는 비동기 작업 실행
- runAsync: 반환값이 없는 비동기 작업 실행
- thenApply: 작업 완료 후 결과를 가공하여 반환
- thenAccept: 작업 완료 후 결과를 소비 (반환값 없음)
public class CFExample {
public static void main(String[] args) throws Exception {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "Hello";
}).thenApply(res -> res + " World");
System.out.println(future.get()); // "Hello World" 출력
}
}
3. 효율적인 스레드 관리: 스레드 풀(Thread Pool)
매번 스레드를 생성하고 소멸시키는 것은 비용이 많이 듭니다. ThreadPoolExecutor를 사용하면 정해진 수의 스레드를 재사용하여 성능을 최적화할 수 있습니다.
ThreadPoolExecutor의 핵심 파라미터
- corePoolSize: 항상 유지할 최소 스레드 수
- maximumPoolSize: 최대 허용 스레드 수
- keepAliveTime: 코어 스레드 외의 유휴 스레드가 유지되는 시간
- workQueue: 대기 중인 작업을 보관하는 큐
주요 스레드 풀 유형
- newCachedThreadPool: 필요에 따라 가변적으로 스레드를 생성하며, 유휴 스레드는 60초 후 종료됩니다.
- newFixedThreadPool: 고정된 수의 스레드를 사용하며, 작업 큐가 가득 차면 대기합니다.
- newSingleThreadExecutor: 단 하나의 스레드만 사용하여 작업을 순차적으로 처리합니다.
- newScheduledThreadPool: 지연 실행이나 주기적인 작업 수행에 적합합니다.
4. 스레드 로컬(ThreadLocal)과 데이터 격리
ThreadLocal은 각 스레드마다 독립적인 변수를 가질 수 있게 해주는 메커니즘입니다. 멀티스레드 환경에서 공유 변수로 인한 데이터 오염을 방지하기 위해 사용됩니다.
내부 동작 원리
모든 Thread 객체는 내부에 ThreadLocalMap이라는 멤버 변수를 가집니다. 이 맵의 Key는 ThreadLocal 인스턴스 자체이며, Value는 해당 스레드가 저장한 값입니다.
public class ContextHolder {
private static final ThreadLocal<String> userContext = new ThreadLocal<>();
public static void setUserName(String name) {
userContext.set(name);
}
public static String getUserName() {
return userContext.get();
}
public static void clear() {
userContext.remove();
}
}
메모리 누수 주의사항
ThreadLocalMap의 Entry는 WeakReference<ThreadLocal<?>>를 상속받아 설계되었습니다. 이는 Key에 대한 약한 참조를 제공하여 GC가 원활하게 이루어지도록 돕지만, 스레드 풀을 사용하는 환경에서는 스레드가 재사용되므로 작업 종료 시 반드시 remove()를 호출하여 Value에 대한 강한 참조를 해제해야 메모리 누수를 방지할 수 있습니다.