동시성 처리
동시성 프로그래밍(Concurrency Programming)이란, 여러 작업(Task)을 논리적으로 동시에 처리할 수 있도록 만드는 프로그래밍 방식이다.
즉, 한 번에 여러 작업이 병렬 또는 번갈아 수행되도록 작성하는 방식이다.
동시성 프로그래밍 측면에서 자바는 항상 앞서갔다.
- 처음 릴리스된 1996년부터 스레드 동기화, wait/notify를 지원했다.
- Java 5: java.util.concurrent 패키지와 Executor 프레임워크 도입.
- Java 7: Fork/Join 프레임워크 추가.
- Java 8: Stream API를 통한 병렬 처리(parallel stream) 지원.
자바는 동시성 프로그래밍을 꾸준히 지원하는데, 동시성 프로그래밍을 할 때는 안정성과 응답 가능 상태를 유지를 해야 한다.
자바는 동시성 프로그래밍을 지속적으로 강화해왔으며, 병렬 스트림을 포함한 모든 동시성 처리에서 안정성과 응답 가능성 유지가 핵심이다.
잘못된 병렬화는 성능 저하뿐 아니라, 잘못된 결과나 시스템 오류로 이어질 수 있기 때문이다.
이번 시간에는 스트림과 스트림 병렬화 작업에 대해 정리해보자.
스트림 병렬화의 문제점
예제: BigInteger 소수에서 메르센 소수를 구하는 스트림
Stream.iterate나 limit 같은 연산이 포함된 스트림 파이프라인은 병렬화해도 성능 개선을 기대하기 어렵다.
위의 코드에는 두 문제 Stream.iterate와 limit을 모두 지니고 있다.
왜 Stream.iterate와 limit은 병렬화해도 성능이 좋아지지 않을까?
왜 Stream.iterate와 limit를 쓰면 성능이 나빠질까?
1. Stream.iterate는 순차적이다.
Stream.iterate(seed, f)는 이전 값이 있어야 다음 값을 만들 수 있는 구조이다.
예: T1 → T2 → T3 → ...즉, 데이터를 병렬로 쪼갤 수 있는 기준(splitting point)이 없기 때문에 병렬 스트림이 내부적으로 제대로 나눌 수 없다.
결과적으로, 병렬로 나눠 처리하는 것이 아니라 사실상 하나의 스레드에서 순차 처리가 된다.
2. limit(n)은 병렬 처리에 방해가 된다.
파이프라인 병렬화는 limt를 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다.
그런데 이 코드의 경우 새롭게 메르센 소수를 찾을 때마다 그 전 소수를 찾을 때보다 2배 정도 더 오래걸린다. (이전까지의 원소 전부를 계산한 비용을 합친 것만큼 든다.)
스트림 파이프라인의 명세 규약
스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.
이를 방지하기 위해 Java Stream 명세(specification)에서는 스트림에서 사용하는 함수 객체에 대한 엄격한 규약을 정의하고 있다.
reduce 연산의 규약
Stream.reduce 연산에 전달하는 accumulator와 combiner 함수는 반드시 아래 조건을 만족해야 한다.
associative (결합법칙 만족)
병렬 실행에서는 연산 순서가 바뀔 수 있기 때문에, 결과가 동일하려면 반드시 결합법칙을 따라야 한다.
non-interfering (비간섭)
함수는 스트림 외부 상태를 읽거나 변경하면 안 된다.
stateless (무상태)
함수는 호출 간 내부 상태를 유지하지 않아야 하며, 입력값만으로 결과가 결정되야 한다. (순수 함수, pure function)
대표적인 reduction 연산 예시
Java Stream에서는 다음과 같은 연산들이 reduce의 대표적인 예시로 사용된다.
count(): 스트림의 원소 수를 셈. 내부적으로reduce(0, (count, e) -> count + 1)과 비슷하게 동작함.sum(): 숫자 스트림(IntStream, LongStream 등)의 총합을 계산.min(),max(): 최소/최대값을 구함.Comparator를 이용하거나 기본 타입 스트림(IntStream 등)에서 직접 제공됨.reduce(): 두 값을 결합하는 연산자를 직접 정의해서 누적. 예:reduce(0, Integer::sum)
이들은 모두 결합법칙을 만족해야 병렬로 안전하게 실행될 수 있다.
예를 들어, Integer::sum은 결합법칙을 만족하므로 아래처럼 병렬화해도 안전하다.
int sum = IntStream.rangeClosed(1, 1_000_000)
.parallel()
.reduce(0, Integer::sum);
하지만 String 결합처럼 결과가 원소 순서에 의존하는 연산은 결합법칙을 만족하더라도 병렬화에 적합하지 않다.
예를 들어, 아래 병렬 스트림은 출력 결과가 "abcd"가 아닐 수 있다.
String result = Stream.of("a", "b", "c", "d")
.parallel()
.reduce("", (s1, s2) -> s1 + s2);
System.out.println(result); // 결과는 실행마다 달라질 수 있음
// "cbad"처럼 순서가 뒤섞인 결과가 나올 수 있다.
병렬 스트림
앞의 예제 메르센 소수 프로그램 코드를 병렬화해보자.
예제 : 병렬화된 스트림
.parallel()로 병렬모드로 전환했지만, forEach()로 인해 순서가 보장되지 않았다.
.parallel() 호출로 인해 스트림은 병렬 모드로 전환되지만,
이후 .forEach()를 사용하면 출력된 소수의 순서가 올바르지 않을 수 있다.
그 이유는 .forEach()는 출력 순서를 보장하지 않기 때문이다.
예제 : 순서보장한 병렬화된 스트림
이번에는 병렬 스트림 + 순서 보장 출력을 함께 사용했다.
출력 결과는 순서대로 나오지만, 사용된 소스(Stream.iterate)와 limit 때문에 병렬 처리의 성능 이점은 거의 없고 오히려 비효율적일 수 있다.
병렬화에 드는 추가 비용이 크고, 작업 자체가 상대적으로 작다면 오히려 성능은 그대로거나 더 나빠질 수도 있다.
.parallel()로 병렬화를 적용하고 .forEachOrdered()로 출력 순서까지 보장할 수는 있지만, 실제로 성능이 향상될지는 또 다른 문제다.
병렬화에 드는 추가 비용이 크고, 작업 자체가 상대적으로 작다면 오히려 성능은 그대로거나 더 나빠질 수도 있다.
이를 판단하기 위해서는 원소 수 × 연산량이 수십만 이상 되는지 추정해보는 것이 좋은 지표가 된다.
스트림 병렬화 적용하기 전에 고려해야 할 사항
스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다.
변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.
병렬 스트림은 공통 포크-조인 풀(ForkJoinPool.commonPool)을 사용한다.
자바의 병렬 스트림(.parallel())은 기본적으로 ForkJoinPool.commonPool이라는 공용 스레드 풀에서 실행된다.
이 풀은 자바 전체 애플리케이션 내에서 공유되는 단 하나의 공통 풀이다.
병렬 스트림이 너무 많은 작업을 이 공용 풀에 넘기면,
다른 곳(다른 병렬 작업)에서 이 풀을 사용하던 작업들까지 지연되거나 멈출 수 있다.
잘못 구성된 병렬 파이프라인 하나가 시스템 내 다른 병렬 작업들에도 악영향을 줄 수 있다는 점을 반드시 기억하자.
스트림 파이프라인을 병렬화가 좋을 때
조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있다.
(예시로 머신러닝, 데이터 처리 같은 특정 분야에서 적합함)
예시 : 소수 계산 스트림 파이프라인 - 병렬화에 적합하다.
다음은 pi(n), 즉 n보다 작거나 같은 소수의 개수를 계산하는 함수다.
계산하는데 시간이 단축된다.
병렬 처리(.parallel())
숫자 범위를 여러 CPU 코어에 나누어 동시에 처리하기 때문에 전체 시간이 크게 단축된다.
LongStream.rangeClosed
이 스트림은 쉽게 분할할 수 있어 병렬화에 최적화되어 있다.
isProbablePrime(50)
소수 판별 연산이 비교적 무겁기 때문에 병렬 처리로 인한 오버헤드 대비 실질적인 이득이 크다.
병렬 스트림에 가장 적합한 데이터 소스
병렬화 성능이 가장 좋은 데이터 소스는 다음과 같다.
- ArrayList (인덱스 기반 빠른 분할 가능하기 때문)
- HashMap / ConcurrentHashMap (내부적으로 잘 분할 가능한 Spliterator 지원하기 때문)
- HashSet (해시 기반 컬렉션으로 분할 가능하기 때문)
- 배열 (Arrays) (인덱스 기반으로 분할이 최적화되어 있기 때문)
- int, long 범위 스트림 (IntStream.range, LongStream.range) (숫자 범위를 쉽게 균등 분할 가능하기 때문)
이들 데이터 소스들은 효율적인 Spliterator 구현 덕분에 병렬 처리에 적합하다.
Spliterator는 데이터를 여러 개의 청크(chunk)로 분할해 여러 스레드에 작업을 나눠주는데, 이 과정에서 빠르고 균등한 분할이 이루어질수록 병렬 처리 효율이 높아진다.
순차적으로 실행할 때의 참조 지역성이 뛰어나다.
또한, 위 데이터 구조들은 원소들을 순차적으로 처리할 때 참조 지역성(Locality)이 뛰어나다.
참조 지역성 : 메모리에 연속해서 저장되어 있다.
참조 지역성은 다량의 데이터를 처리하는 벌크 연산 을 병렬화할 때 아주 중요한 요소로 작용한다.
참조 지역성이 왜 중요한가?
데이터가 메모리에 연속해서 저장되면 CPU는 캐시 메모리를 효율적으로 활용할 수 있어 빠른 접근이 가능하다.
그런데, 만약 컬렉션 안의 참조들이 가리키는 실제 객체들이 메모리에서 서로 떨어져 있다면, 참조 지역성은 나빠진다.
참조 지역성이 낮으면 CPU 스레드는 필요한 데이터를 주 메모리에서 캐시 메모리로 불러오는 동안 대기 시간이 길어져서 연산 속도가 느려진다.
특히 대용량 데이터를 병렬 처리하는 벌크 연산에서는 이런 캐시 미스가 누적되어 병렬 처리 효율이 크게 떨어질 수 있다.
따라서, 참조 지역성은 병렬 처리 성능을 좌우하는 매우 중요한 성능 변수이다.
참조 지역성이 가장 뛰어난 자료구조는?
참조 지역성이 가장 뛰어난 자료구조는 기본 타입 배열이다.
기본 타입 배열은 객체 참조가 아니라 데이터 자체가 메모리에 연속적으로 저장되기 때문에 캐시 효율이 매우 높다.
반면에, 객체를 담는 컬렉션들은 객체들이 힙 메모리의 임의 위치에 흩어져 있을 수 있어서 참조 지역성이 떨어지는 경우가 많다.
이 점을 고려해 병렬 스트림을 설계하는 것이 성능 최적화에 중요하다.
Random 값 스트림 병렬화
| 클래스 | 설명 |
|---|---|
ThreadLocalRandom |
각 스레드 별로 독립적인 난수 생성기를 제공하여 단일 스레드 환경에서 빠르고 효율적임. 병렬 스트림에서 직접 사용하기에는 한계가 있음. |
SplittableRandom |
병렬 스트림과 같은 병렬 환경에서 난수 생성에 최적화되어 설계됨. 내부적으로 난수 생성기를 쉽게 분할(splittable)하여 병렬 처리에 적합. |
Random |
내부적으로 모든 메서드가 동기화(synchronized) 되어 있어, 멀티스레드 환경에서 성능 저하가 심함. 병렬 스트림에 사용 시 최악의 성능을 보일 수 있음. |
결론
- 스트림을 잘못 병렬화하면, 응답 불가와 같은 문제뿐 아니라,
성능이 오히려 나빠지고,
결과 자체가 잘못되거나 예상치 못한 동작이 발생할 수 있다. - 특히, 데이터 소스가 Stream.iterate이거나, 중간 연산으로 limit를 사용하는 경우,
파이프라인 병렬화로 성능 개선을 기대하기 어렵다. - 반면, 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap 인스턴스, 배열,
또는 int, long 범위 스트림일 때, 병렬화의 효과가 가장 크다. - 따라서 단순히 “병렬화하면 무조건 빨라질 것”이라는 확신 없이 파이프라인 병렬화는 시도조차 하지 말아야 한다.
'Dev Books > Effective Java' 카테고리의 다른 글
| [item 12] toString을 항상 재정의하라. (2) | 2025.07.18 |
|---|---|
| [item 11] equals를 재정의하려거든 hashCode도 재정의하라 (3) | 2025.07.17 |
| [item 47] 반환 타입으로는 스트림보다 컬렉션이 낫다 (1) | 2025.07.15 |
| [item 46] 스트림에서는 부작용 없는 함수를 사용하라 (2) | 2025.07.11 |
| [item 45] 스트림은 주의해서 사용하라 (4) | 2025.07.09 |
댓글