[DEV] 기록

[SpringBoot] parallel stream 버그 해결

꾸준함. 2024. 7. 7. 15:10

개요

Hibernate의 CurrentTenantIdentifierResolver와 AbstractDataSourceBasedMultiTenantConnectionProviderImpl를 이용해 멀티 테넌시를 적용한 서비스에서, 시간이 비교적 오래 걸리는 import 기능을 제공합니다.

이는 RDBMS 특성상 테이블들이 정규화되어 있어, 여러 테이블에 데이터를 넣어줘야 하고 넣을 때 외래키 제한에 걸리지 않도록 순서를 맞춰야 하기 때문입니다.

저희 팀은 처리 시간을 단축시키기 위해 연관되어 있지 않은 테이블끼리 묶어서 parallel stream을 적용해 병렬 처리를 시도했으나, 간헐적으로 ThreadLocal에서 다른 테넌트, 즉 다른 스키마로 요청을 보내는 문제가 발생하여 foreign key constraint(null) 에러가 발생하는 것을 확인했습니다.

 

원인 파악

원인은 Spring Boot에 내장된 Tomcat에서 관리하는 쓰레드 풀과 parallel stream에서 사용하는 ForkJoinPool별개의 쓰레드 풀이기 때문입니다.

원인을 분석하기 위해서는 쓰레드 풀의 정의, Spring MVC, 그리고 ForkJoinPool에 대한 지식이 필요합니다.

 

ThreadPool

  • 여러 개의 쓰레드를 미리 생성해 두고, 필요할 때 이 스레드들을 재사용하여 작업을 처리하는 방식
  • 쓰레드 풀의 주요 목적은 쓰레드 생성과 소멸에 드는 비용를 줄이고, 시스템 자원을 효율적으로 관리하는 것

 

Tomcat에서 관리하는 쓰레드 풀

 

Spring MVC에서는 클라이언트 요청마다 하나의 쓰레드가 생성되며 해당 쓰레드는 필터, DispatcherServlet, 인터셉터를 거쳐 Controller에 도달하며 비즈니스 로직을 수행한 후에는 다시 역순으로 인터셉터, DispatcherServlet, 필터를 거쳐 클라이언트에게 응답을 반환합니다.

요청이 들어올 때 인터셉터는 헤더에 저장된 테넌트 코드를 기반으로 ThreadLocal 컨텍스트를 설정하고, 응답을 반환할 때는 해당 컨텍스트를 초기화합니다.

따라서 쓰레드가 재사용되더라도 컨텍스트 오염으로 인한 장애가 발생하지 않습니다.

 

인터셉터 예시 코드

 

 

parallel stream에서 사용하는 ForkJoinPool

 

반면, ForkJoinPool의 경우 메인 쓰레드에서 요청이 들어오면 병렬 작업을 효율적으로 처리하기 위해 데몬 쓰레드를 생성하여 작업을 분할하고 병렬로 실행합니다.

여기서 문제가 발생했습니다.

Spring MVC의 경우, 인터셉터에서 메인 쓰레드의 컨텍스트를 초기화하지만, 데몬 쓰레드의 경우에는 초기화하는 코드가 없었습니다.

이로 인해 데몬 쓰레드가 다른 스키마로 설정된 컨텍스트를 재사용하게 되어, 요청과 전혀 무관한 테넌트의 테이블에 insert 시도를 하게 되었습니다.

다행히 외래키 제약 조건 덕분에 엉뚱한 데이터가 삽입되지는 않았지만, 큰 장애가 발생할 뻔했습니다.

또한, 이 문제가 간헐적으로 발생한 이유는 데몬 스레드의 생애 주기가 짧아 기본값이 1분이었기 때문이었고 이로 인해 통합 테스트 과정에서만 해당 오류가 발생했습니다.
  • A 테넌트에 import 요청을 수행한 뒤 곧장 B 테넌트에 요청을 수행했을 때만 해당 에러가 발생

 

https://docs.oracle.com/javase%2F9%2Fdocs%2Fapi%2F%2F/java/util/concurrent/ForkJoinPool.html

 

해결 방법

parallel stream을 호출하기 전에 ThreadLocal의 컨텍스트를 불러와, parallel stream 내 비즈니스 로직을 수행하기 직전에 해당 컨텍스트로 덮어쓰는 코드를 작성하여 문제를 해결할 수 있었습니다.

 

 

 

반응형