ForkJoinPool과 클래스 로더
문제가 발생한 상황
회사에서 설치형 솔루션 프로젝트를 진행하던 중이었다. 해당 프로젝트를 jar로 패키징하여 테스트 서버에 배포하고 기능 테스트를 진행하는데, 이러한 에러를 보게 되었다.
java.lang.RuntimeException: java.io.FileNotFoundException: class path resource [클래스패스 리소스 경로] cannot be opened because it does not exist
흠? 분명 클래스패스 경로 설정도 정상적으로 되어 있고, 해당 경로에 파일도 잘 패키징 되었는데 does not exist
라니 이상했다.
처음에는 경로 작성 방법에 문제가 있나 싶었는데, 에러가 발생한 부분을 호출하기 전에도 클래스패스 경로에서 리소스를 접근하는 로직이 있는데 해당 부분은 정상적으로 통과하는 것이었다. 즉 경로를 잘못 작성한 문제는 아니었다.
문제가 되는 시점에 해당 로직을 호출하는 곳을 확인했더니 특이점이 있었다. 바로 parallelStream
내부에서 호출되고 있었다는 점이다.
sampleList.parallelStream()
.map(param -> working(param)) // working 메서드가 클래스패스 리소스를 접근하는 메서드이다.
.collect(Collectors.toList());
parallelStream
여러 스레드에서 병렬로 데이터를 처리하는 데 사용하는 스트림 유형이다.
동작 원리를 알기 전에 Fork/Join 프레임워크를 알아야 하는데, 이는 병렬화할 수 있는 작업을 재귀적으로 작은 작업으로 분할 후, 서브 태스크의 각각의 결과를 합쳐 최종 결과를 만든다. (분할 정복 알고리즘)
내부적으로 ForkJoinPool
이라는 스레드 풀을 사용하며, Fork/Join 프레임워크에서는 ForkJoinPool
의 모든 스레드를 거의 공정하게 분할한다.
parallelStream
의 병렬 스트림 또한 내부적으로 ForkJoinPool
을 사용하며, 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 chunk 단위로 분할한 스트림을 사용한다.
ForkJoinPool의 스레드와 클래스 로더
위와 같은 이슈의 원인은 다음과 같다:
- 자바는 계층적 클래스 로더 구조를 가지고 있다. BootStrap 클래스 로더, Extension 클래스 로더, Application 클래스 로더 이렇게 3 계층으로 구성된다.
- 자바는 스레드별로 컨텍스트 클래스 로더라는 개념이 존재하는데, 이는 주로 자바의 서비스 제공자 인터페이스 메커니즘에서 사용되며 스레드가 실행 중인 코드의 클래스 로더 컨텍스트를 유지하는 데 도움을 준다.
ForkJoinPool
은 작업 훔치기 (work-stealing) 알고리즘을 사용하여 병렬 처리를 구현한다. 작업 훔치기 알고리즘은 모든 스레드를 거의 공정하게 분할한다. 각 스레드는 자신에게 할당된 태스크를 포함하는 이중 연결리스트를 참조하고, 작업이 끝날 때마다 큐의 헤드에서 다른 태스크를 가져와 작업을 처리한다. 작업 훔치기 알고리즘 과정에서 새로운 스레드들이 생성되는데, 이 스레드들은 기본적으로 시스템 클래스 로더를 컨텍스트 클래스 로더로 사용한다.- 여기서 클래스패스 경로를 통해 리소스에 접근하려는 로직은 현재 스레드의 컨텍스트 클래스 로더를 사용하여 리소스를 찾는다.
ForkJoinPool
의 스레드에서 이러한 로직이 실행되면ForkJoinPool
스레드의 컨텍스트 클래스 로더가 애플리케이션의 클래스 로더와 다를 수 있기 때문에 애플리케이션의 클래스패스에 있는 리소스를 찾지 못할 수 있다. - 이유는 자바의 클래스 로더 격리 원칙 때문이다. 각 클래스 로더는 자신만의 네임스페이스를 가지며, 다른 클래스 로더에서 로드한 클래스나 리소스를 직접 접근할 수 없다.
ForkJoinPool
또는parallelStream
을 사용할 때 작업이 다른 스레드로 옮겨지면서 컨텍스트 클래스 로더가 변경될 수 있다. 이로 인해 원래 스레드에서는 접근 가능했던 리소스에 새로운 스레드에서는 접근하지 못하는 상황이 발생할 수 있다.
해결 방법
내가 선택한 해결 방법은 클래스패스 리소스에 접근하는 working() 메서드 내부에서 스레드의 컨텍스트 클래스로더를 설정해주었다.
Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader());
멀티 스레드 프로그래밍에서는 개발자의 의도대로 동작하지 않는 부분이 꽤 있어서 항상 주의해서 개발해야 한다는 것을 다시 한 번 일깨우게 되었다.
멀티 스레드 프로그래밍을 테스트하는 방법에 대해서도 알아봐야겠다.