CodeGym /행동 /JAVA 25 SELF /병렬 프로그래밍 모범 사례

병렬 프로그래밍 모범 사례

JAVA 25 SELF
레벨 54, 레슨 4
사용 가능

1. 병렬화할 수 있다고 해서 반드시 병렬화해야 하는 것은 아니다

Java에는 작업을 병렬화하는 방법이 많습니다. 하지만 “병렬화 = 언제나 더 빠름”이라고 생각하는 것은, 수프에 소금을 더 넣으면 더 맛있어진다고 믿는 것과 같습니다. 어느 정도까지는 맞지만, 그 이후로는 안 하는 편이 낫습니다.

ExecutorService는 실행하고 제어해야 하는 명시적인 작업이 있을 때 매우 적합합니다: 요청 처리, 비동기 데이터 로딩, 독립적인 계산 등. 스레드 풀의 크기를 스스로 결정하고 작업의 생명주기를 관리할 수 있습니다.

parallelStream은 연산이 독립적이고 부작용이 없을 때 컬렉션 처리를 빠르게 병렬화하는 방법입니다. “무거운” 컬렉션(수만 개 이상의 요소)에서 수지가 맞습니다.

ForkJoinPool은 하위 작업으로 잘 분할되는 작업에 적합한 선택입니다(divide & conquer): 정렬, 탐색, 대규모 배열 집계 등. parallelStream 내부에서도 사용되지만, 직접 제어할 수도 있습니다.

“혹시 몰라서” 병렬화를 쓰지 마세요. 작업이 작다면 스케줄링, 컨텍스트 스위칭, 동기화에 대한 오버헤드가 이득을 모두 잡아먹을 수 있습니다.

예시: 병렬화가 필요 없는 경우

List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
    .mapToInt(x -> x)
    .sum(); // 단 5개의 숫자에 병렬화를 쓰는 것은 오버킬!

2. 스레드 안전성: 공유 가변 데이터를 피하기

병렬 세계의 핵심 위협은 레이스 컨디션(race condition)입니다. 여러 스레드가 하나의 변수를 변경하면 결과가 예상과 달라질 수 있습니다.

  • 공유 가변 변수를 피하세요. counter++ 같은 표현도 원자적이지 않습니다.
  • thread-safe 컬렉션과 원자적 연산을 사용하세요. 예: ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
  • 병렬 스트림에서는 부작용 금지. parallelStream 안에서 외부 구조를 변경하지 마세요.

나쁜 코드 예

List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // 위험함!

여기서 result.add()는 thread-safe가 아닙니다. 결과적으로 요소가 누락되거나 예외가 발생할 수 있습니다.

올바른 방법

List<Integer> result = numbers.parallelStream()
    .map(n -> n * 2)
    .collect(Collectors.toList());

3. 성능: 스레드가 많다고 항상 더 좋은 것은 아니다

작은 작업을 병렬화하는 것은 비경제적입니다. 작업이 밀리초 수준이라면, 병렬 실행은 오버헤드 때문에 오히려 느릴 때가 많습니다.

성능을 측정하세요. 빠른 측정에는 System.nanoTime()이 유용합니다:

long start = System.nanoTime();
// ... 사용자 코드 ...
long end = System.nanoTime();
System.out.println("실행 시간: " + (end - start) + " ns");

진지한 마이크로벤치마크에는 JMH (Java Microbenchmark Harness)를 사용하세요.

예: 순차 스트림과 병렬 스트림 비교

List<Integer> bigList = IntStream.range(0, 1_000_000)
    .boxed().collect(Collectors.toList());

long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();

System.out.println("순차: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("병렬:     " + (t3 - t2) / 1_000_000 + " ms");

자신의 컴퓨터에서 시도해 보세요 — 정말 큰 컬렉션과 무거운 연산에서 이득이 눈에 띕니다.

4. 오류 처리: 스레드의 예외를 무시하지 말기

Future와 예외 처리

ExecutorService.submit()으로 작업을 실행했다면, 예외가 자동으로 “전파”되지 않습니다 — Future.get()으로 처리해야 합니다:

Future<Integer> future = executor.submit(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("이런!");
    return 42;
});
try {
    Integer result = future.get(); // ExecutionException을 던질 수 있음
} catch (ExecutionException e) {
    System.err.println("작업에서 오류: " + e.getCause());
}

ForkJoin과 예외 처리

ForkJoinPool에서는 예외가 작업 안에 “포장”됩니다. join()/get() 호출 시 표면화됩니다:

ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
    int result = pool.invoke(task);
} catch (Exception e) {
    System.err.println("ForkJoin 오류: " + e);
}

InterruptedException 처리를 잊지 말기

많은 메서드(예: Future.get(), Thread.sleep())는 InterruptedException을 던질 수 있습니다. 이를 “삼키지” 말고, 올바르게 대응하세요: 인터럽트 플래그를 다시 설정하거나 작업을 종료하세요.

5. 병렬 코드의 디버깅과 테스트

로깅과 디버깅

병렬 버그는 교묘하며 종종 불안정하게 나타납니다. 스레드를 표시하여 로깅하세요: Thread.currentThread().getName(). 누가 언제 코드를 실행하는지 이해하는 데 도움이 됩니다.

복잡한 경우에는 멀티스레드 디버깅을 지원하는 디버거(예: IntelliJ IDEA)를 사용하세요. 임시 Thread.sleep()은 드문 레이스를 “잡는” 데 도움이 될 때가 있습니다.

멀티스레드 시나리오 테스트

병렬 연산에 대해 별도의 테스트를 두고, 조건 대기를 위한 유틸리티(예: Awaitility)를 사용하세요. 이러한 테스트는 여러 번 실행하세요: 어떤 문제는 100번째나 1000번째 실행에서야 드러납니다.

6. 유용한 뉘앙스와 팁

가독성과 유지보수성: 이해하기 쉬운 병렬 코드를 작성하라

  • 문서화하세요. 복잡한 부분과 도구 선택의 근거를 주석으로 남기세요.
  • 고수준 추상화를 사용하세요. 수동 스레드 관리 대신 ExecutorService, parallelStream, ForkJoinPool을 선호하세요.
  • “마법”을 피하세요. 더 단순하게 할 수 있다면 동기화를 복잡하게 만들지 마세요.

표: 언제 어떤 도구를 사용할까

시나리오 권장 도구
서로 독립적인 작업이 많을 때
ExecutorService
대용량 컬렉션 처리 parallelStream 또는 ForkJoin
“분할 정복” 작업
ForkJoinPool + RecursiveTask
간단한 비동기 작업
CompletableFuture
작은 작업이 매우 많음 순차 스트림
부작용이 있는 작업 오직 thread-safe 컬렉션!

병렬 프로그래머의 “계명”

  • thread-safe임을 확신하지 않는 이상, 공유 가변 변수를 만들지 마라.
  • 그냥 병렬화를 위한 병렬화는 하지 말고, 잠재적 이득을 평가하라.
  • 풀을 꼭 닫아라: shutdown()/shutdownNow().
  • parallelStream으로 부작용 있는 연산을 하지 마라.
  • Future와 ForkJoinTask의 예외를 처리하는 것을 잊지 마라.
  • InterruptedException을 “삼키지” 말고 — 작업을 올바르게 종료하라.

7. 병렬 프로그래밍에서의 흔한 실수

오류 №1: 작은 작업의 병렬화. 초보자는 작업이 마이크로초 수준일 때도 무작정 병렬화하곤 합니다. 결과는 오버헤드 때문에 더 느려집니다.

오류 №2: 스트림에서의 부작용. parallelStream에서 외부 변수나 컬렉션을 변경하면 레이스 컨디션과 예측 불가능한 버그를 만납니다.

오류 №3: 예외 무시. Future.get() 또는 ForkJoinTask에서 발생한 오류를 처리하지 않으면, 작업이 왜 실패했는지 알 수 없습니다.

오류 №4: ExecutorService의 shutdown()을 잊음. 명시적으로 종료하지 않으면 애플리케이션이 종료 시 “멈출” 수 있습니다.

오류 №5: thread-safe가 아닌 컬렉션 사용. 일반 ArrayList에 여러 스레드에서 쓰면 오류로 직행입니다.

오류 №6: InterruptedException을 “삼키기”. 스레드가 인터럽트되었다면 — 이를 존중하고 작업을 올바르게 종료하세요.

오류 №7: 지나치게 복잡한 동기화 로직. 과도한 synchronized 블록은 deadlock/livelock으로 이어집니다. 고수준 추상화를 선호하세요.

1
과제
JAVA 25 SELF, 레벨 54, 레슨 4
잠금
아티팩트 복제의 혼돈 마법 실험 🧪
아티팩트 복제의 혼돈 마법 실험 🧪
1
과제
JAVA 25 SELF, 레벨 54, 레슨 4
잠금
디지털 도서관의 고대 두루마리 분석 📜
디지털 도서관의 고대 두루마리 분석 📜
1
설문조사/퀴즈
병렬성 и ForkJoin, 레벨 54, 레슨 4
사용 불가능
병렬성 и ForkJoin
병렬성 и ForkJoin
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION