Java 멀티스레딩 구현 방식과 스레드 풀 및 ThreadLocal 시스템 이해

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: 대기 중인 작업을 보관하는 큐

주요 스레드 풀 유형

  1. newCachedThreadPool: 필요에 따라 가변적으로 스레드를 생성하며, 유휴 스레드는 60초 후 종료됩니다.
  2. newFixedThreadPool: 고정된 수의 스레드를 사용하며, 작업 큐가 가득 차면 대기합니다.
  3. newSingleThreadExecutor: 단 하나의 스레드만 사용하여 작업을 순차적으로 처리합니다.
  4. 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();
    }
}

메모리 누수 주의사항

ThreadLocalMapEntryWeakReference<ThreadLocal<?>>를 상속받아 설계되었습니다. 이는 Key에 대한 약한 참조를 제공하여 GC가 원활하게 이루어지도록 돕지만, 스레드 풀을 사용하는 환경에서는 스레드가 재사용되므로 작업 종료 시 반드시 remove()를 호출하여 Value에 대한 강한 참조를 해제해야 메모리 누수를 방지할 수 있습니다.

태그: java Multithreading CompletableFuture threadpool ThreadLocal

7월 4일 03:13에 게시됨