[JAVA] 멀티스레딩 이해하기

joowonseo·2025년 3월 9일

왜 멀티스레딩이 필요한가?

  1. 응답성 개선: 시간이 오래 걸리는 작업을 별도의 스레드로 분리하면 메인 스레드는 계속해서 사용자 입력에 응답할 수 있습니다.
  2. 자원 활용 최적화: I/O 작업 중 대기 시간에 다른 스레드가 CPU를 사용하여 작업을 수행할 수 있습니다.
  3. 동시 요청 처리: 웹 서버나 API 서버는 멀티스레딩을 통해 다수의 클라이언트 요청을 동시에 처리합니다.

프로세스(Process)와 스레드(Thread)의 이해

프로세스란?

프로세스는 실행 중인 프로그램의 인스턴스입니다. 운영체제로부터 독립된 메모리 영역(코드, 데이터, 힙, 스택)을 할당받아 실행됩니다. 각 프로세스는 다른 프로세스와 격리되어 있어, 한 프로세스에서 발생한 문제가 다른 프로세스에 직접적인 영향을 주지 않습니다.

스레드란?

스레드는 프로세스 내에서 실행되는 작업의 단위입니다. 하나의 프로세스는 최소 하나 이상의 스레드를 가지며, 같은 프로세스 내의 스레드들은 해당 프로세스의 메모리 영역을 공유합니다. 특히 코드, 데이터, 힙 영역을 공유하지만, 각 스레드는 자신만의 스택을 가집니다.

멀티프로세스 vs 멀티스레드

멀티프로세스

멀티프로세스는 여러 개의 프로세스가 동시에 실행되는 환경을 의미합니다.

장점:

  • 한 프로세스가 충돌해도 다른 프로세스에 영향을 주지 않음
  • 프로세스 간 높은 수준의 독립성 유지

단점:

  • 프로세스 간 통신(IPC)이 복잡하고 오버헤드가 큼
  • 메모리 사용량이 많음(각 프로세스마다 독립적인 메모리 영역 필요)
  • 컨텍스트 스위칭 비용이 비교적 높음

멀티스레드

멀티스레드는 하나의 프로세스 내에서 여러 스레드가 동시에 실행되는 환경입니다.

장점:

  • 스레드 간 데이터 공유가 쉬움
  • 메모리 사용량이 적음(공유 메모리 영역 활용)
  • 컨텍스트 스위칭 비용이 비교적 낮음

단점:

  • 한 스레드의 오류가 전체 프로세스에 영향을 줄 수 있음
  • 동기화 문제(경쟁 상태, 교착 상태 등)가 발생할 수 있음
  • 디버깅이 복잡할 수 있음

Java에서의 스레드 구현 방법

Java에서는 크게 두 가지 방법으로 스레드를 구현할 수 있습니다.

1. Thread 클래스 상속

public class MyThread extends Thread {
    @Override
    public void run() {
        // 스레드가 실행할 코드
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();

        thread1.start(); // 스레드 시작
        thread2.start();
    }
}

2. Runnable 인터페이스 구현 (더 권장되는 방법)

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 스레드가 실행할 코드
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(new MyRunnable(), "Thread-1");
        Thread thread2 = new Thread(new MyRunnable(), "Thread-2");

        thread1.start(); // 스레드 시작
        thread2.start();
    }
}

Runnable 인터페이스 구현 방식이 더 선호되는 이유는

  • 자바는 단일 상속만 지원하므로, Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없음
  • 코드와 실행 환경을 분리할 수 있어 더 유연함
  • 객체 지향 설계 원칙에 더 부합함

Java에서의 스레드 생명주기

Java의 스레드는 다음과 같은 상태를 가집니다.

  1. NEW: 스레드가 생성되었지만 아직 시작되지 않은 상태
  2. RUNNABLE: 실행 중이거나 실행 가능한 상태
  3. BLOCKED: 모니터 락을 기다리며 블록된 상태
  4. WAITING: 무기한 대기 중인 상태(wait(), join() 메서드 호출)
  5. TIMED_WAITING: 특정 시간 동안 대기 중인 상태(sleep(), wait(long) 메서드 호출)
  6. TERMINATED: 실행을 완료한 상태

동기화(Synchronization)

멀티스레드 환경에서는 여러 스레드가 동시에 같은 자원에 접근할 때 문제가 발생할 수 있습니다. 이를 해결하기 위해 Java에서는 다양한 동기화 메커니즘을 제공합니다.

1. synchronized 키워드

synchronized void method() { ... }
synchronized(object) { ... }

하지만 동기화된 메소드는 하나의 스레드만 실행할 수 있기 때문에 다른 스레드는 해당 메서드 실행이 완료되기 전까지 기다려야 합니다. 이러한 대기 상태는 멀티스레드 프로그래밍에서 성능문제가 발생할 수 있습니다.

2. volatile 키워드

메인 메모리에서 직접 변수를 읽고 쓰도록 하여 가시성 문제를 해결합니다.

volatile boolean flag;

3. Lock 인터페이스와 구현체

java.util.concurrent.locks 패키지는 보다 유연한 락 메커니즘을 제공합니다.

Lock lock = new ReentrantLock();
lock.lock();
try {
    // 임계 영역
} finally {
    lock.unlock();
}

4. Atomic 클래스

CAS(Compare-And-Swap) 연산을 사용하여 락 없이도 스레드 안전한 연산을 제공합니다.

AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();

선택하는 방법은 상황에 따라 다르며, 단순한 경우 synchronized를, 더 복잡한 시나리오에서는 Lock이나 Atomic 클래스를 사용하는 것이 적합합니다.

고수준 동시성 API (Java 5 이후)

Java 5부터는 java.util.concurrent 패키지를 통해 더 높은 수준의 동시성 프로그래밍을 지원합니다.

1. ExecutorService

스레드 풀을 사용하면 스레드 생성과 삭제에 대한 오버헤드를 줄이고, 쓰레드의 재사용성을 높여서 프로그램의 성능을 향상시킬 수 있습니다.

예를 들어서 1000개의 작업을 처리해야하는 경우에 각각의 작업을 처리하기 위해서 쓰레드를 생성하고 삭제하는 것은 오버헤드가 큽니다. 대신 쓰레드 풀을 사용해서 미리 쓰레드를 생성하고 작업을 할당하는 방식을 사용하면, 쓰레드 생성과 삭제에 대한 오버헤드를 줄이고 성능을 개선할 수 있습니다.

ExecutorService는 스레드 풀을 생성하고 관리하기 위한 메서드들을 제공합니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExecutorExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5);

        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " executed by " +
                    Thread.currentThread().getName());
            });
        }

        executor.shutdown();  // 새 작업 수락 중지
    }
}

2. Future와 Callable

Callable은 작업을 수행하고 결과를 반환할 수 있는 태스크를 정의하며, Future는 비동기 계산의 결과를 나타내는 인터페이스입니다. 미래의 어느 시점에 완료될 작업의 결과를 조회, 취소, 완료 여부 확인 등을 할 수 있습니다.

import java.util.concurrent.*;

public class FutureExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        Future<Integer> future = executor.submit(() -> {
            // 시간이 오래 걸리는 연산
            Thread.sleep(2000);
            return 123;
        });

        // 다른 작업 수행 가능

        // 결과 가져오기 (블로킹 호출)
        Integer result = future.get();
        System.out.println("Result: " + result);

        executor.shutdown();
    }
}

3. CompletableFuture (Java 8 이후)

CompletableFuture는 Java 8에서 도입된 비동기 프로그래밍을 위한 클래스로, Future 인터페이스를 구현하면서 추가 기능을 제공합니다. 콜백, 결과 조합, 예외 처리 등 함수형 스타일의 비동기 프로그래밍을 가능하게 합니다.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // 비동기 작업
    return "결과";
}).thenApply(result -> {
    // 결과 변환
    return result + " 처리됨";
}).exceptionally(ex -> {
    // 예외 처리
    return "오류 발생: " + ex.getMessage();
});

스레드 간 통신

스레드 간에 정보를 공유하고 통신하기 위한 메커니즘에는 여러 가지가 있습니다.

1. wait(), notify(), notifyAll()

Object 클래스의 이 메서드들은 스레드 간 기본적인 통신 메커니즘을 제공합니다.

public class MessageQueue {
    private String message;
    private boolean empty = true;

    public synchronized String receive() {
        while (empty) {
            try {
                wait();  // 메시지가 도착할 때까지 대기
            } catch (InterruptedException e) {}
        }
        empty = true;
        notifyAll();  // 생산자에게 알림
        return message;
    }

    public synchronized void send(String message) {
        while (!empty) {
            try {
                wait();  // 큐가 비워질 때까지 대기
            } catch (InterruptedException e) {}
        }
        empty = false;
        this.message = message;
        notifyAll();  // 소비자에게 알림
    }
}

2. BlockingQueue

java.util.concurrent 패키지의 BlockingQueue는 생산자-소비자 패턴 구현에 적합합니다.

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProducerConsumerExample {
    public static void main(String[] args) {
        BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);

        // 생산자 스레드
        new Thread(() -> {
            try {
                for (int i = 0; i < 20; i++) {
                    queue.put("Item " + i);
                    System.out.println("Produced: Item " + i);
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        // 소비자 스레드
        new Thread(() -> {
            try {
                for (int i = 0; i < 20; i++) {
                    String item = queue.take();
                    System.out.println("Consumed: " + item);
                    Thread.sleep(300);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

🧐 멀티스레딩이 어떻게 활용될까?

사실 저는 위 개념들 중에서는 CompletableFuture를 통한 비동기 통신만 처리를 해본적이 있지, 코드 레벨에서 멀티스레드를 통해 성능을 개선한 경험은 많지 않았습니다. 그래서 멀티스레딩을 통해 어떻게 성능을 개선할 수 있고, 어떤 이점을 얻을 수 있는지 구체적인 예시를 통해 살펴보았습니다.

1. 웹 서버에서의 요청 처리

단일 스레드 상황: 웹 서버가 단일 스레드로 동작한다면, 한 번에 하나의 요청만 처리할 수 있습니다. 만약 한 사용자의 요청이 데이터베이스 쿼리로 5초가 걸린다면, 그 동안 다른 모든 사용자는 대기해야 합니다.

멀티스레드 해결책:

// 톰캣과 같은 웹 서버는 내부적으로 이와 유사한 스레드 풀을 사용합니다
ExecutorService threadPool = Executors.newFixedThreadPool(100);

// 각 HTTP 요청이 들어올 때마다 별도 스레드에서 처리
server.onRequest(request -> {
    threadPool.submit(() -> {
        // 데이터베이스 쿼리 실행 (5초 소요)
        User user = userRepository.findById(request.getUserId());
        // 응답 생성 및 반환
        return new Response(user);
    });
});

이렇게 하면 100개의 요청을 동시에 처리할 수 있어, 한 요청이 5초 걸리더라도 다른 사용자의 요청은 즉시 처리가 시작됩니다.

2. 파일 처리 애플리케이션

대용량 파일을 읽고, 처리하고, 저장하는 애플리케이션이 있다고 가정합시다. 단일 스레드로는 이 세 과정을 순차적으로 수행해야 하므로 전체 처리 시간이 깁니다.

멀티스레드 해결

// 파이프라인 구조의 멀티스레딩
BlockingQueue<File> filesToProcess = new LinkedBlockingQueue<>();
BlockingQueue<ProcessedData> processedData = new LinkedBlockingQueue<>();

// 파일 읽기 스레드
new Thread(() -> {
    while (true) {
        File file = fileSystem.getNextFile();
        filesToProcess.put(file);
    }
}).start();

// 데이터 처리 스레드 (CPU 집약적 작업)
IntStream.range(0, Runtime.getRuntime().availableProcessors()).forEach(i -> {
    new Thread(() -> {
        while (true) {
            File file = filesToProcess.take();
            ProcessedData data = processData(file);
            processedData.put(data);
        }
    }).start();
});

// 결과 저장 스레드 (I/O 작업)
new Thread(() -> {
    while (true) {
        ProcessedData data = processedData.take();
        database.save(data);
    }
}).start();

이 예시는 생산자-소비자 패턴을 사용해 파일 읽기, 처리, 저장을 병렬로 수행함으로써 처리량을 크게 향상시킵니다.

3. 이미지 처리 애플리케이션

사용자가 100장의 이미지를 업로드하고 각 이미지에 필터를 적용하려고 합니다. 각 이미지 처리에 1초가 걸린다면, 단일 스레드에서는 총 100초가 필요합니다.

멀티스레드 해결:

List<BufferedImage> images = loadImages();  // 100개 이미지
List<BufferedImage> processedImages = new CopyOnWriteArrayList<>();

// 병렬 스트림을 사용하여 이미지 처리 병렬화
images.parallelStream().forEach(image -> {
    BufferedImage processed = applyFilter(image);
    processedImages.add(processed);
});

// 또는 CompletableFuture 사용
List<CompletableFuture<BufferedImage>> futures = images.stream()
    .map(image -> CompletableFuture.supplyAsync(() -> applyFilter(image)))
    .collect(Collectors.toList());

List<BufferedImage> processedImages = futures.stream()
    .map(CompletableFuture::join)
    .collect(Collectors.toList());

8코어 CPU에서는 이론적으로 처리 시간이 약 1/8로 줄어 약 12-13초 정도만 소요될 수 있습니다.

4. 실시간 모니터링 시스템

서버 모니터링 애플리케이션이 시스템 상태를 체크하고 GUI를 업데이트해야 합니다. 단일 스레드에서는 상태 체크가 지연되면 UI가 멈춥니다.

멀티스레드 해결:

// UI 스레드
public class MonitoringApp extends Application {
    @Override
    public void start(Stage stage) {
        LineChart<Number, Number> cpuChart = createChart();
        Scene scene = new Scene(cpuChart);
        stage.setScene(scene);
        stage.show();

        // 별도 스레드에서 데이터 수집
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
        scheduler.scheduleAtFixedRate(() -> {
            double cpuUsage = SystemMonitor.getCpuUsage();
            // UI 스레드에서 차트 업데이트
            Platform.runLater(() -> updateChart(cpuChart, cpuUsage));
        }, 0, 1, TimeUnit.SECONDS);
    }
}

이 방식으로 데이터 수집은 백그라운드 스레드에서 주기적으로 실행되며, UI는 항상 응답성을 유지합니다.

5. 외부 API 호출이 필요한 웹 애플리케이션

여러 외부 API를 호출해야 하는 웹 페이지가 있습니다. 각 API 호출이 1초씩 걸린다면, 5개 API 순차 호출에 5초가 필요합니다.

멀티스레드 해결:

@GetMapping("/dashboard")
public DashboardDto getDashboard(Long userId) {
    // 여러 API를 비동기적으로 호출
    CompletableFuture<UserProfile> profileFuture =
        CompletableFuture.supplyAsync(() -> userService.getProfile(userId));

    CompletableFuture<List<Transaction>> transactionsFuture =
        CompletableFuture.supplyAsync(() -> paymentService.getTransactions(userId));

    CompletableFuture<NotificationCount> notificationsFuture =
        CompletableFuture.supplyAsync(() -> notificationService.getCount(userId));

    CompletableFuture<List<Message>> messagesFuture =
        CompletableFuture.supplyAsync(() -> messageService.getLatest(userId));

    CompletableFuture<WeatherInfo> weatherFuture =
        CompletableFuture.supplyAsync(() -> weatherService.getCurrentWeather());

    // 모든 비동기 작업이 완료될 때까지 대기 후 결과 조합
    CompletableFuture<Void> allFutures = CompletableFuture.allOf(
        profileFuture, transactionsFuture, notificationsFuture,
        messagesFuture, weatherFuture
    );

    // 결과 조합 및 반환
    return allFutures.thenApply(v -> new DashboardDto(
        profileFuture.join(),
        transactionsFuture.join(),
        notificationsFuture.join(),
        messagesFuture.join(),
        weatherFuture.join()
    )).join();
}

이 방식으로 5개의 API를 병렬로 호출하면, 총 소요 시간은 가장 오래 걸리는 API의 응답 시간(약 1초)에 가까워집니다.

이러한 예시들은 멀티스레딩이 실제 애플리케이션 성능과 응답성을 크게 향상시킬 수 있는 상황을 보여줍니다. 특히 백엔드 개발에서는 이런 멀티스레딩 기법들이 시스템의 확장성과 효율성에 중요한 역할을 합니다.

멀티스레딩 vs 스케일 아웃

실제 시스템에서는 멀티스레딩과 스케일 아웃을 함께 활용하는 경우가 많습니다. 예를 들어, Spring Boot 애플리케이션은 내부적으로 멀티스레딩을 사용하면서, 필요에 따라 여러 인스턴스로 스케일 아웃할 수 있습니다.

멀티스레딩: 단일 머신의 자원을 최대한 활용
스케일 아웃: 부하가 더 늘어날 경우 여러 머신으로 분산

이 두 접근 방식의 조합은 비용 효율적이면서도 확장성 있는 시스템을 구축하는 데 도움이 됩니다. 예를 들어, Amazon ECS나 Kubernetes 같은 컨테이너 오케스트레이션 플랫폼에서 멀티스레드 애플리케이션을 여러 노드에 배포하여 둘 다의 이점을 누릴 수 있습니다.

⚠️ 멀티스레딩 주의사항

  1. 경쟁 상태(Race Condition): 여러 스레드가 공유 데이터에 동시에 접근할 때 발생할 수 있는 문제
  2. 교착 상태(Deadlock): 두 개 이상의 스레드가 서로의 락을 기다리며 영원히 블록된 상태
  3. 기아 상태(Starvation): 특정 스레드가 필요한 자원에 계속 접근하지 못하는 상태
  4. 라이브락(Livelock): 스레드들이 서로에게 반응하여 계속 상태가 변하지만 실제로는 진행이 없는 상태

이러한 문제를 방지하기 위해서는 적절한 동기화 기법을 사용하고, 가능한 한 공유 상태를 최소화하는 것이 좋습니다.

참고 자료

[Java] java는 멀티쓰레드를 어떤 식으로 지원하나요?

profile
백엔드 개발자 ˚₊✩‧₊ ໒꒱

0개의 댓글