티스토리 뷰

Java 서버 애플리케이션을 운영하다 보면, 스레드 관련 문제는 항상 뒤늦게 모습을 드러냅니다. 코드가 작성될 당시에는 정상적으로 동작하고, 테스트 환경에서도 특별한 이상 징후가 보이지 않습니다. 그러나 트래픽이 증가하고, 애플리케이션이 장시간 실행되며, 재시작과 장애 복구가 반복되는 운영 환경에서는 작은 동시성 설계의 균열이 점점 명확한 문제로 드러납니다. 이번 글에서는 langchain4j 코드베이스에 기여하며 경험한 InterruptedException 처리 이슈를 중심으로, JVM 스레드 관리 관점에서 왜 이 문제가 중요했는지를 정리합니다.

 

이번 오픈소스 기여는 단순히 예외 처리 스타일을 다듬는 작업이 아니었습니다. 인터럽트를 어떻게 다루느냐에 따라, 라이브러리가 사용자 애플리케이션의 종료와 취소 흐름을 방해할 수도, 반대로 정상적인 제어 흐름을 보존할 수도 있다는 점을 분명히 보여주는 사례였습니다.

 

 

LangChain4j 오픈소스 소개 

 

LangChain4j는 Java 애플리케이션에서 LLM(대형 언어 모델) 및 벡터 데이터베이스를 손쉽게 통합할 수 있도록 설계된 오픈소스 Java 라이브러리입니다. 통합 API를 제공하여 다양한 LLM 제공자(예: OpenAI, Vertex AI 등)와 벡터 저장소를 코드 변경 없이 쉽게 전환할 수 있으며, RAG(Retrieval-Augmented Generation), 에이전트, 툴 호출 등 고급 LLM 패턴도 지원합니다. 또한 Spring Boot, Quarkus, Helidon 같은 엔터프라이즈 Java 프레임워크와 자연스럽게 통합되도록 설계되어 있어, LLM 기반 챗봇·지식 검색·AI 서비스 라인업 구축에 강력한 기반을 제공합니다. 

 

 

문제가 되었던 실행 흐름

 

문제가 된 코드는 내부적으로 ExecutorService를 사용해 작업을 실행하면서, blocking API를 통해 결과를 기다리는 구조를 가지고 있었습니다. 이 과정에서 InterruptedException이 발생했을 때, 예외를 catch한 뒤 런타임 예외로 래핑하거나 단순히 소비하는 방식으로 처리하고 있었습니다. 코드 자체만 보면 큰 문제가 없어 보입니다. 예외가 발생하면 실패로 처리하고, 상위로 예외를 전달하면 된다고 생각하기 쉽기 때문입니다.

 

그러나 이 선택은 중요한 의미를 놓치고 있었습니다. InterruptedException은 오류 신호가 아니라, “이 스레드의 실행을 중단하라”는 제어 신호입니다. OpenJDK의 공식 문서에서도 인터럽트는 스레드 취소 메커니즘의 핵심으로 설명되어 있으며, blocking 메서드는 인터럽트가 발생하면 즉시 대기 상태를 해제하고 예외를 던지도록 설계되어 있습니다.

 

문제의 코드는 이 제어 신호를 예외 처리 과정에서 소모해 버렸습니다. 인터럽트 상태를 복원하지 않았기 때문에, 상위 호출자는 해당 스레드가 인터럽트되었다는 사실을 더 이상 알 수 없게 됩니다. 결과적으로 스레드는 “취소 요청을 받은 적 없는 상태”처럼 동작을 계속 이어가게 됩니다.

 

 

JVM 스레드 관리 관점에서의 위험성

 

이 문제가 JVM 스레드 관리 관점에서 위험했던 이유는, 이 코드가 애플리케이션 코드가 아니라 라이브러리 코드에 존재했다는 점입니다. 애플리케이션 코드에서는 인터럽트를 무시한 책임이 비교적 명확합니다. 작성자가 자신의 종료 정책을 알고 있기 때문입니다. 그러나 라이브러리 코드는 다릅니다. 라이브러리는 스레드의 생명주기를 직접 통제하지 않으며, 상위 애플리케이션이나 프레임워크가 전달하는 취소 정책을 존중해야 합니다.

 

langchain4j는 내부적으로 비동기 작업과 병렬 실행을 많이 사용합니다. 사용자는 이 라이브러리를 통해 LLM 호출을 수행하고, 그 결과를 기다리거나 취소할 수 있다고 기대합니다. 그런데 라이브러리 내부에서 인터럽트가 무시되면, 사용자가 ExecutorService.shutdownNow()를 호출하거나 애플리케이션 종료 과정에서 인터럽트를 전달해도, 해당 작업은 즉시 중단되지 않습니다.

 

운영 환경에서는 이 문제가 다음과 같은 형태로 관측될 수 있습니다. 애플리케이션 종료 요청이 들어왔지만 JVM이 내려가지 않고 대기 상태로 남습니다. 스레드 덤프를 확인하면, langchain4j 내부 작업 스레드가 blocking 상태에서 반복적으로 실행을 재개하고 있습니다. 결국 운영자는 강제 종료를 선택하게 되고, 이는 처리 중이던 작업의 불완전한 종료와 리소스 누수로 이어집니다.

 

이 문제는 “InterruptedException을 RuntimeException으로 바꿔 던졌기 때문에 발생한 버그”가 아닙니다. 인터럽트를 제어 흐름으로 받아들이지 않고, 단순한 실패 신호로 취급했기 때문에 발생한 설계 문제입니다.

 

 

#4340 #4342 PR에서 수정한 핵심 포인트

: [BUG] Missing interrupt status restoration in InterruptedException handlers 

 

이번 PR의 핵심은 인터럽트를 다시 예외로 포장하는 것이 아니라, 인터럽트 상태를 보존하고 실행 흐름을 종료하는 방향으로 책임을 명확히 한 것이었습니다. InterruptedException을 catch한 뒤에는 현재 스레드의 interrupt 상태를 복원하고, 더 이상의 작업을 진행하지 않도록 흐름을 끊었습니다.

 

이 변경은 코드 한 줄의 차이처럼 보일 수 있습니다. 그러나 의미는 큽니다. 라이브러리 내부에서 인터럽트를 숨기지 않음으로써, 상위 호출자가 설계한 취소 정책이 끝까지 유지됩니다. 작업이 언제든 중단될 수 있다는 계약이 지켜지고, 라이브러리는 스레드의 생명주기를 독단적으로 연장하지 않습니다.

 

중요한 점은, 이 변경이 “더 안전해 보이기 위한 방어 코드”가 아니라는 사실입니다. OpenJDK Javadoc과 java.util.concurrent 패키지 문서에서 반복적으로 강조하는 원칙을 그대로 따랐을 뿐입니다. 인터럽트를 catch했다면, 해당 사실을 무시하지 말고 호출자에게 전달해야 한다는 기본 원칙을 코드에 반영한 것입니다.

 

 

인터럽트를 무시한 코드가 운영 문제로 확장되는 과정

 

인터럽트를 무시한 코드가 즉시 장애를 일으키는 경우는 드뭅니다. 대부분의 경우, 이 문제는 누적됩니다. 취소되지 않은 작업이 하나씩 쌓이고, 종료되지 않은 스레드가 늘어나며, 결국 특정 시점에서 애플리케이션의 종료 지연이나 스레드 풀 고갈로 나타납니다.

 

특히 라이브러리 코드에서는 이 문제가 더 치명적입니다. 라이브러리 사용자는 내부 구현을 알지 못한 채, 정상적인 취소가 동작한다고 믿고 코드를 작성합니다. 그러나 내부에서 인터럽트가 소비되고 있다면, 사용자의 기대와 실제 동작 사이에 괴리가 발생합니다. 이 괴리는 운영 환경에서만 드러나기 때문에, 원인 추적이 어렵고 해결 비용이 큽니다.

 

 

라이브러리 코드에서의 InterruptedException 처리 원칙

 

이번 기여를 통해 다시 확인한 점은, 라이브러리 코드에서의 InterruptedException 처리는 애플리케이션 코드보다 더 엄격해야 한다는 사실입니다. 라이브러리는 “이 작업을 계속할 것인가”를 스스로 결정해서는 안 됩니다. 그 결정권은 항상 상위 호출자에게 있어야 합니다.

 

애플리케이션 코드에서는 인터럽트를 무시하는 선택이 하나의 정책일 수 있습니다. 그러나 라이브러리 코드에서는 그 선택이 사용자에게 강제됩니다. 따라서 인터럽트를 catch했다면, 상태를 복원하고 가능한 한 빠르게 제어를 반환하는 것이 책임 있는 설계입니다.

 

 

정리하며

 

이 PR은 규모가 크지 않은 변경이었지만, 의미는 분명했습니다. JVM 스레드 관리에서 InterruptedException은 예외 처리 기법의 문제가 아니라, 실행 흐름과 책임 경계의 문제입니다. 라이브러리가 이 신호를 어떻게 다루느냐에 따라, 사용자 애플리케이션의 종료와 취소 정책이 존중될 수도, 무력화될 수도 있습니다.

 

langchain4j 사용자에게 이 변경은 “보이지 않는 안정성 개선”에 가깝습니다. 그러나 운영 환경에서는 이 차이가 분명한 결과로 나타납니다. 정상적으로 내려가는 애플리케이션과, 강제 종료에 의존하는 애플리케이션의 차이는 이런 작은 선택에서 시작됩니다.

 

동작하는 코드와 책임 있는 라이브러리 코드는 다릅니다. InterruptedException을 어떻게 처리하느냐는, 그 차이를 가르는 기준 중 하나입니다. 

 



관련 포스팅

Java 동시성 문제 해결: 스레드 관리부터 InterruptedException 처리까지- https://ebson.tistory.com/416

 

GitHub 리포지토리


공식 문서 & 자료

  • 공식 Docs 사이트 (LangChain4j) - https://docs.langchain4j.dev/ 
  • GitHub Contributing Guide (리포 기여 규칙) → 메인 리포의 CONTRIBUTING.md 참고