서론
코딩 테스트를 했을 때, Stream을 사용했는데 사용한 이유에 대해서 면접관이 물어본 적이 있다. 나는 대충 "사람의 언어에 가까운 함수형 프로그래밍으로 가독성이 좋다" 이런 식으로 대답했었다. 면접관은 원하는 대답이 아니었는지, 멀티스레드 환경에서 Stream 장점을 설명해보라 했는데 자세히 알지 못해서 그냥 "하드웨어와 애플리케이션이 멀티스레드 환경이라면 병렬 처리를 지원해줘서 더 좋다"라고 대답했었다. 그러고는 질문이 끊겼는데... 좋은 인상을 준 건 아닌 느낌😰
이번 기회에 Stream과 멀티스레드가 무슨 관계가 있는지 좀 더 자세히 알아보려 한다.
Stream 탄생 이유
Collection을 가공하고 처리하는 Java8 API다. 기존의 Collection으로도 요구사항을 다 처리할 수 있는데 왜 굳이 Stream API를 추가했을까? 여러 가지 이유가 있겠지만 가장 핵심은 외부 반복과 내부 반복에 있다고 생각한다.
Collection 데이터를 가공하기 위해서 직접적으로 반복문을 명시하고 로직을 작성해야 한다. 이를 외부 반복이라 부른다. 명시적으로 Collection 요소를 하나씩 가져와서 처리한다.
반면에 Stream은 내부반복을 사용한다. 내부적으로 반복문을 처리하기 때문에 로직만 작성하면 알아서 처리한다.
그럼 내부적으로 처리하면 무슨 장점이 있는 걸까?
내부 반복을 사용하면 병렬 처리에 유리하다. 기존에 외부 반복에서 병렬 처리를 하려면 개발자가 직접 관리해야 한다. 하지만 Stream 내부 반복은 병렬 처리를 자동으로 선택해주기 때문에 구현이 편리하다. 이러한 이유로 Stream API가 탄생했다.
Stream 병렬처리 관리
병렬 처리를 하기 위해서 개발자가 해야 될 것은 파이프라인에 중간 연산으로 parallel()만 추가해주면 끝이다. 편리하긴 한데... 그럼 Stream은 내부적으로 어떻게 병렬 처리를 관리해주는 걸까?
Stream은 람다식을 사용해 별도의 스레드에서 병렬 처리를 관리한다. 람다식 내부에서 외부지역변수를 참조하려 할 때, final 또는 effectively final한 변수를 복사해서 가져간다. 왜 복사본을 만들어서 사용하는 걸까?
이를 이해하기 위해서는 Java에서 사용하는 메모리 영역에 대한 구분이 필요하다. JVM이 실행되면 JVM은 OS에서 할당받은 메모리 영역을 다음과 같이 세부영역을 구분해서 사용한다.
- 메서드 영역(Method Area)
- 힙 영역(Heap Area)
- JVM 스택 영역(JVM Stack Area)
영역들 마다 특징이 있는데 여기서 봐야 할 것은 메서드와 힙 영역은 스레드 간에 공유가 되지만, JVM 스택 영역은 스레드 별도로 가지는 영역으로 스레드 간에 공유되지 않는다.
그리고 지역변수는 JVM 스택 영역에 저장된다. 위에서 언급했듯이 람다식은 별도의 스레드를 가지기 때문에 다른 스레드의 JVM 스택 영역 안의 지역변수를 참조할 수 없다. 그래서 가져갈 때는 final 또는 effectively final 한 변수를 복사해서 가져가는 것이다.
그리고 이 복사한 값을 람다식 내부에서 변경하려 하면 컴파일 에러가 발생한다. 복사된 값인데 수정하는 것을 왜 막는 걸까? 여기에 Stream이 병렬처리를 관리하는 방식이 숨어있다.
이유부터 말하면 멀티스레드 환경에서 발생할 수 있는 동시성 문제점을 관리하기 위해서다.
public class Tester {
int count = 0;
int limit = 100;
ExecutorService executor = Executors.newFixedThreadPool(1);
public void testMultiThreading() {
// 스레드 A
boolean isOpen = true;
executor.execute(() -> {
// 스레드 B
if (isOpen) {
count++;
}
});
if (count < limit) {
isOpen = false;
}
}
}
이런 선착순 100명만 수행하는 로직이 있다고 가정해보자. 위의 식을 작성하면 아래 그림과 같이 컴파일 오류가 발생한다.
멀티스레드 환경에서 count가 99명이 됐을 때 동시에 5명이 접근했다고 가정해보자. 그럼 각 스레드에서는 isOpen 값을 true로 복사해가기 때문에, 5명 모두에게 로직이 실행되는 것이다. 선착순 100명이기 때문에 1명에게만 실행되어야 할 로직이 총 104명에게 실행되는 것이다. 개별 스레드에서 변경되는 isOpen값을 다른 스레드에서는 모르기 때문에 데이터가 최신 데이터임을 보장할 수 없는 문제가 발생한다.
그럼 스트림 실행 전에 isOpen 값을 먼저 확인하고 수행하면 어떻게 될까?
스레드에서 값을 변경시키지 않고 실행 전에 값을 변경했다. 물론 이것으로 완벽하게 동시성 문제를 막지는 못하지만, 적어도 내부 반복 과정에서는 불변이기 때문에 동시성 문제를 걱정할 필요가 없다.
기존의 반복문을 사용하면 반복문 내부에서 지역변수 값의 변경이 가능하기 때문에 이런 문제들을 개발자들이 하나하나 신경쓰고 처리해야 한다. 하지만 스트림은 스레드 간에 공유되지 않는 변수를 참조하거나 변경할 때, final 또는 effectively final한 변수로 제한함으로써 병렬처리에서 발생할 수 있는 동시성 문제를 조금이나마 막는다. 혹시나 개발자들이 신경쓰지 못하고 코드를 작성하더라도, 컴파일 에러로 쉽게 파악가능하다.
결론
이 글에서는 병렬 처리에 관한 장점만 다뤘지만 Stream은 여러 특징을 가지고 있고, 그에 따라 장점도 많다. 물론 단점으로 성능에 관한 이슈도 존재한다.
면접 때 받은 질문 덕에 단순히 가독성이 좋아서 사용했던 Stream API를 멀티스레드 환경에서 사용하는 병렬 처리에 대해 조금 더 깊게 공부할 수 있었다. 물론 실제로 멀티스레드 환경을 구현해 본 적이 없기 때문에 내가 이해한 내용이 정확하지 않을 수도 있다.
그렇지만 만약 저 질문을 지금 다시 받는다면, 그때보다 조금 더 면접관이 원했던 대답을 할 수 있을 것 같다.
'Backend > Java' 카테고리의 다른 글
YAML) @PropertySource에서 EnvironmentPostProcessor까지 (0) | 2021.07.03 |
---|---|
SpringBoot profile logback (0) | 2021.06.24 |
JDBC query vs queryForObject (0) | 2021.03.30 |
Spring Security JWT Token (0) | 2021.03.22 |
@RequestBody 모델에 기본생성자, setter/getter가 필요한가? (4) | 2021.01.21 |