본문 바로가기

언어론

Java에서는 동시성을 어떻게 다뤄야 할까?(1)

1. 서론

이월엔 DevBloom 에서 들은 옥찬호님의 Introduction to Rust Concurrency 강의를 듣고 Java에서 비슷한 개념을 어떻게 적용할 수 있을지 궁금해졌다. Rust는 안전성과 성능을 고려한 동시성 모델을 제공하고, Java는 오래된 언어이지만 다양한 동시성 도구를 갖추고 있다.

이 글에서는 Rust와 Java의 동시성 구현 방식을 비교하며, 각각의 특징과 장단점을 알아보고자 한다. 특히, 멀티스레드 처리(Fork-Join 패턴, 채널, 뮤텍스(Mutex)), 조건 변수(Condition Variable), 원자적 연산(Atomic Operation) 같은 주요 개념들을 Rust와 Java에서 어떻게 다루는지 살펴볼 것이다.

예제는 옥찬호님 발표자료를 참고했다. https://github.com/utilForever/2025-DEVCON-Rust-Concurrency


2. Fork-Join 방식

2-1. Fork-Join 방식이란?

Fork-Join은 작업을 여러 개의 독립적인 작업으로 나누고(Fork), 병렬적으로 실행한 후 최종적으로 하나로 합치는(Join) 기법이다.

  • Fork (포크): 하나의 큰 작업을 작은 독립적인 작업으로 나누어 각각의 스레드에서 실행
  • Join (조인): 각각의 스레드에서 처리한 결과를 모아 최종적으로 하나의 결과로 병합

이 방식을 활용하면 멀티코어 환경에서 병렬 처리의 성능 향상을 기대할 수 있다.


2-2. Rust의 Fork-Join 방식과 예제

Rust의 단일 스레드 파일 처리

use std::io;
fn process_files(filenames: Vec<String>) -> io::Result<()> {
    for document in filenames {
        let text = load(&document)?;// 파일 로드 (I/O 연산)
        let results = process(text);// 파일 처리 (CPU 연산)
        save(&document, results)?;// 파일 저장 (I/O 연산)
    }
    Ok(())
}

Rust에서 파일을 하나씩 순차적으로 처리하는 코드이다.

위 코드는 for 루프에서 파일을 하나씩 가져와 처리하므로, CPU 코어가 여러 개 있더라도 단일 스레드에서 실행된다. 따라서 파일이 수천개라면 병목 현상이 일어날 수 있다. 또한 아래와 같은 문제가 발생할 수 있다.

  1. 파일 로드(load)와 저장(save) 작업은 I/O 연산 → 디스크에서 데이터를 읽고 쓰는 작업은 보통 CPU보다 훨씬 느리다. 따라서 ****CPU는 빠른 연산이 가능하지만, 단일 스레드는 파일이 로드될 때까지 아무것도 못 하고 기다려야 한다는 문제가 발생한다. (I/O 대기).
  2. 멀티코어 CPU를 활용하지 못함 → 현대적인 CPU는 4개 이상의 코어를 가지고 있지만, 이 코드는 모든 코어를 사용하지 않고 단일 코어에서만 실행된다.

따라서 이를 해결하기 위해 멀티스레드 Fork-Join 패턴을 사용할 수 있다.

Rust의 Fork-Join 구현

use std::{io, thread};

fn process_files_in_parallel(filenames: Vec<String>) -> io::Result<()> {
    const NUM_THREADS: usize = 4; //4개의 스레드를 사용
    let worklists = filenames.chunks(NUM_THREADS).map(|chunk| chunk.to_vec()).collect::<Vec<_>>();//병렬 실행을 위해 파일 리스트를 여러 개의 작업 그룹으로 분할
    let mut handles = vec![];

    for worklist in worklists {
        let handle = thread::spawn(move || { //새로운 스레드를 생성하여 파일 처리
            for document in worklist {
                let text = load(&document).unwrap();
                let results = process(text);
                save(&document, results).unwrap();
            }
        });
        handles.push(handle);//스레드 핸들을 리스트에 저장 (나중에 join()을 호출하기 위해 필요)
    }

    for handle in handles { //모든 스레드가 종료될 때까지 기다림 (Join 단계)
        handle.join().unwrap();
    }

    Ok(())
}

위와 같이 Fork-Join 방식으로 코드를 개선하면 다음과 같은 장점이 생긴다.

  • 기존 단일 스레드 방식에서는 CPU의 1개 코어만 사용만 위와 같은 Fork-Join 방식에서는 CPU의 여러 코어에서 동시에 작업 수행 할 수 있다. → 각 코어에서 동시에 파일을 처리하므로 속도 향상될 수 있다.
  • I/O 대기 시간을 줄여 더 빠른 처리 가능해진다.

2-3. Java의 Fork-Join 방식과 예제

Java에서도 Thread 클래스를 사용하거나, ForkJoinPool을 활용하여 Fork-Join 방식을 구현할 수 있다.

<Java에서 Fork-Join 사용 예제>

1) Thread 클래스를 사용한 Fork-Join 방식 구현

import java.util.ArrayList;
import java.util.List;

public class FileProcessorManager {//Fork-Join을 관리 (스레드 생성, 실행, 종료)하는 클래스

    private final int numThreads;

    public FileProcessorManager(int numThreads) {
        this.numThreads = numThreads;
    }

    public void processFilesInParallel(List<String> filenames) {
        List<Thread> threads = new ArrayList<>();
        int chunkSize = filenames.size() / numThreads;

        // Fork 단계: 여러 스레드에서 파일 처리 실행
        for (int i = 0; i < numThreads; i++) {
            int start = i * chunkSize;
            int end = (i == numThreads - 1) ? filenames.size() : start + chunkSize;
            List<String> subList = filenames.subList(start, end);

            Thread thread = new FileProcessorThread(subList);
            threads.add(thread);
            thread.start();
        }

        // Join 단계: 모든 스레드가 종료될 때까지 대기
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
import java.util.List;

class FileProcessorThread extends Thread {//개별 스레드에서 파일을 로드, 처리, 저장하는 클래스
    private final List<String> filenames;

    public FileProcessorThread(List<String> filenames) {
        this.filenames = filenames;
    }

    @Override
    public void run() {
        for (String file : filenames) {
            String text = FileProcessor.load(file);
            String results = FileProcessor.process(text);
            FileProcessor.save(file, results);
            }
        }
    }
}

Java에서 Thread 클래스를 활용한 방법은 Rust에서 Fork-Join 방식을 사용하는 것과 유사해 보이지만 몇 개의 차이가 있다.

  • 스레드 강제 종료 가능성: Java에서 interrupt()를 통해 스레드를 강제 중단해야 하는 경우가 필요하다면, interrupt() 과 join() 함께 사용시, InterruptedException이 발생할 수 있다. 하지만, Rust에서는 기본적으로 스레드를 외부에서 강제 중단할 방법이 없으므로 join()이 안전하게 실행될 수 있다.
  • *데이터 경합 방지 방식: Rust는 컴파일 타임에서 멀티스레드 안정성을 보장하지만, Java는 런타임에 동기화(synchronized, volatile)를 사용하여 멀티스레드 문제를 해결해야 한다.
    *데이터 경합: 여러 개의 스레드가 동시에 같은 변수(공유 데이터)에 접근하면서, 결과가 예측 불가능한 상태

2) ForkJoinPool을 활용한 Fork-Join 방식 구현

import java.util.concurrent.*;

class FileProcessorTask extends RecursiveTask<Void> {
    private final List<String> filenames;

    public FileProcessorTask(List<String> filenames) {
        this.filenames = filenames;
    }

    @Override
    protected Void compute() {
        if (filenames.size() == 1) {//파일이 하나일 경우 
            String text = load(filenames.get(0));
            String results = process(text);
            save(filenames.get(0), results);
        } else { //여러 개의 파일이 있으면 분할 (Fork)
            int mid = filenames.size() / 2;//재귀적으로 leftTask와 rightTask를 생성하여 두 개의 병렬 작업을 수행
            FileProcessorTask leftTask = new FileProcessorTask(filenames.subList(0, mid));
            FileProcessorTask rightTask = new FileProcessorTask(filenames.subList(mid, filenames.size()));
            invokeAll(leftTask, rightTask);//두 개의 작업을 병렬로 실행
        }
        return null;
    }
}

public class Main {
    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool();
        pool.invoke(new FileProcessorTask(filenames));//ForkJoinPool을 사용하여 병렬 실행
    }
}

Java에서는 ForkJoinPool인 병렬처리 프레임워크를 사용하면 Fork-Join 패턴을 좀 더 쉽게 구현할 수 있다.

스레드의 수는 CPU 코어 개수에 맞게 자동으로 최적화하여 생성해 준다.


2-4. Rust와 Java에서 멀티스레드 예외(패닉) 처리 방식 비교

Rust는 패닉이 발생해도 다른 스레드에 영향을 주지 않고 독립적으로 실행된다.

use std::thread;
fn main() {
    let handle1 = thread::spawn(|| {
        println!("스레드 1 실행 중...");
    });

    let handle2 = thread::spawn(|| {
        println!("스레드 2 실행 중...");
        panic!("스레드 2 패닉 발생!"); //  패닉 발생
    });

    match handle1.join() {
        Ok(_) => println!("스레드 1 정상 종료"),
        Err(e) => println!("스레드 1 오류 발생: {:?}", e),
    }

    match handle2.join() {
        Ok(_) => println!("스레드 2 정상 종료"),
        Err(e) => println!("스레드 2 오류 발생: {:?}", e), // 패닉 발생 감지 가능
    }

    println!("메인 스레드 종료"); // 메인 스레드는 정상 실행됨
}
스레드 1 실행 중...
스레드 2 실행 중...
스레드 2 오류 발생: Any { .. }
스레드 1 정상 종료
메인 스레드 종료

Rust에서는 join()을 통해 패닉이 발생한 스레드를 감지하고, 안전하게 컨트롤 가능하다.

→ join() 이 std::thread::result를 반환하며, 자식 스레드가 패닉에 빠졌으면 오류로 간주하기 때문에

하나의 스레드에서 패닉이 발생해도 다른 스레드에는 영향을 주지 않는다.

즉, 한 스레드에서 문제가 생겨도 전체 프로그램이 무조건 멈추지 않고, 다른 스레드는 정상적으로 실행될 수 있다.

반면, Java에서는 예외가 발생하면 해당 스레드의 실행이 중단될 수 있다.

public class Main {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            System.out.println("스레드 1 실행 중...");
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드 2 실행 중...");
            throw new RuntimeException("스레드 2 예외 발생!"); // 예외 발생
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            System.out.println("스레드 1 정상 종료");
        } catch (InterruptedException e) {
            System.out.println("스레드 1 오류 발생: " + e.getMessage());
        }

        try {
            thread2.join(); // 예외는 여기서 감지되지 않음
            System.out.println("스레드 2 정상 종료");
        } catch (InterruptedException e) {
            System.out.println("스레드 2 오류 발생: " + e.getMessage());
        }

        System.out.println("메인 스레드 종료");
    }
}
스레드 1 실행 중...
스레드 2 실행 중...
Exception in thread "Thread-1" java.lang.RuntimeException: 스레드 2 예외 발생!
	at Main.lambda$main$1(Main.java:10)
	at java.base/java.lang.Thread.run(Thread.java:833)
스레드 1 정상 종료
메인 스레드 종료

Java에서는 Rust와 달리 thread.join()을 호출해도 예외가 감지되지 않는다!

예외가 발생한 스레드는 단순히 로그를 출력하고 종료될 뿐, join() 메서드 만으로는 처리할 방법이 없다.

따라서 아래와 같이 예외를 try-catch로 직접 캐치한 후, 부모 스레드에게 전달해야 한다. 또한 Future<?>의 get메서드를 사용해 안전한 예외처리를 위해 ExecutorService + Future<?> 를 사용해서 구현하는 것이 좋다.

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) {
		    //2개의 스레드를 관리하는 스레드 풀 생성
        ExecutorService executor = Executors.newFixedThreadPool(2);

        Future<?> future1 = executor.submit(() -> {
            System.out.println("스레드 1 실행 중...");
        });

        Future<?> future2 = executor.submit(() -> {
            System.out.println("스레드 2 실행 중...");
            throw new RuntimeException("스레드 2 예외 발생!");
        });

        try {
            future1.get();// `get()`을 호출하면 스레드가 끝날 때까지 기다림
            System.out.println("스레드 1 정상 종료");
        } catch (Exception e) {
            System.out.println("스레드 1 오류 발생: " + e.getMessage());
        }

        try {
            future2.get(); // `get()`을 호출하면 예외를 감지할 수 있음
            System.out.println("스레드 2 정상 종료");
        } catch (Exception e) {
            System.out.println("스레드 2 오류 발생: " + e.getMessage());
        }

        executor.shutdown();//스레드 풀 종료
        System.out.println("메인 스레드 종료");
    }
}
스레드 1 실행 중...
스레드 2 실행 중...
스레드 2 오류 발생: java.lang.RuntimeException: 스레드 2 예외 발생!
스레드 1 정상 종료
메인 스레드 종료

위 클래스에서는 ExecutorService 와 Future<?> 를 활용하여 멀티스레드 관리를 더 쉽게 할 수 있으며, get()을 이용해 예외까지 안전하게 처리하도록 변경하였다.


분량 조절 실패로 채널, 뮤텍스(Mutex)), 조건 변수(Condition Variable), 원자적 연산(Atomic Operation) 2탄에서 다루겠다....