티스토리 뷰

LangChain4j는 Java 환경에서 대규모 언어 모델을 활용한 애플리케이션을 구축할 수 있도록 돕는 오픈소스 프레임워크입니다. Spring 기반 서버 애플리케이션이나 장시간 실행되는 JVM 환경에서 사용되는 경우가 많기 때문에 단순히 기능 구현만 잘 되어 있다고 해서 충분하지 않습니다. 내부에서 생성되는 객체의 생명주기, 스레드 관리 방식, 메모리 사용 패턴까지 안정적으로 설계되어야 실제 운영 환경에서 문제 없이 사용할 수 있습니다. 이번 글에서는 LangChain4j 오픈소스에 기여하면서 경험한 사례를 바탕으로, 싱글톤 패턴과 JVM 메모리 관리가 실무에서 어떻게 연결되는지 정리해보고자 합니다.

 

이번 경험은 단순히 버그를 하나 수정한 사건이 아니라, 서버 애플리케이션에서 반복적으로 등장하는 설계 문제를 다시 생각하게 만든 계기였습니다. 특히 싱글톤 패턴과 ExecutorService 사용 방식이 JVM 리소스 관리 안정성과 얼마나 밀접하게 연결되는지를 실제 코드 수준에서 확인할 수 있었던 사례였습니다.

 


문제를 처음 인지하게 된 계기

 

LangChain4j 내부 구현을 살펴보던 중, AI 서비스 객체를 생성하는 과정에서 build() 호출 시마다 새로운 ExecutorService 인스턴스가 생성되는 구조를 발견하게 되었습니다. 처음에는 크게 문제가 없어 보였습니다. executor를 하나 더 생성한다고 해서 바로 장애가 발생하는 것은 아니기 때문입니다.

 

그러나 서버 애플리케이션의 특성을 생각해보면 상황이 달라집니다. 서비스 인스턴스를 여러 번 생성하거나, 애플리케이션 내 여러 컴포넌트에서 동일한 생성 과정을 반복하게 되면 executor 인스턴스 역시 계속 누적될 수 있습니다. 더 큰 문제는 생성된 executor가 명시적으로 shutdown되지 않는 구조였다는 점입니다.

 

이 문제를 재현 가능한 시나리오로 정리하고, 설계적으로 어떤 문제가 발생할 수 있는지 분석하여 GitHub Issue로 등록하게 되었습니다.

 


#4343 Issue에서 드러난 문제의 본질

 

해당 문제의 핵심은 단순히 executor를 종료하지 않았다는 점이 아니었습니다. 진짜 문제는 executor의 생명주기를 누가 책임지고 관리하는지가 불명확했다는 점입니다.

 

ExecutorService는 내부적으로 워커 스레드를 생성하고 유지합니다. JVM 관점에서 스레드는 단순한 객체가 아니라 네이티브 리소스를 포함하는 비교적 무거운 자원입니다. shutdown되지 않은 executor는 GC 대상이 되지 않으며, 클래스 로더가 유지되는 동안 계속 살아 있게 됩니다.

 

결과적으로 build() 호출이 반복될 경우 executor가 계속 생성되고, 스레드 역시 계속 누적될 가능성이 생깁니다. 이런 상황은 즉시 장애로 이어지지는 않지만, 장시간 실행되는 서비스 환경에서는 메모리 사용량 증가와 스레드 증가로 이어질 수 있습니다.

 

이 문제는 JVM 힙 메모리만의 문제가 아니라 스레드 스택, 네이티브 리소스, 스케줄링 비용 증가까지 연결될 수 있는 구조적 문제였습니다.

 


JVM 메모리 관점에서 본 근본 원인

 

JVM에서 메모리 누수는 단순히 객체가 해제되지 않는 상황만을 의미하지 않습니다. 더 정확하게는 더 이상 필요하지 않은 객체가 여전히 참조되어 GC 대상이 되지 않는 상황을 의미합니다.

 

특히 서버 애플리케이션에서는 싱글톤 객체나 전역적으로 공유되는 객체가 자주 등장합니다. 이런 객체가 내부적으로 스레드 풀이나 외부 리소스를 보유하고 있다면, 해당 자원 역시 애플리케이션 전체 생명주기 동안 유지됩니다.

 

ExecutorService를 매번 생성하는 구조는 단기 실행 프로그램에서는 큰 문제가 되지 않지만, 서버 환경에서는 불필요한 자원 생성과 누적을 유발할 수 있습니다. 따라서 executor는 필요할 때만 생성하고, 가능한 한 재사용하도록 설계하는 것이 더 합리적입니다.

 

이러한 관점에서 기존 구조는 개선 여지가 충분히 있다고 판단했습니다.

 


기존 코드 구조의 한계

 

기존 코드에서는 AI 서비스를 생성할 때마다 새로운 executor를 만들었습니다. 하지만 executor를 누가 언제 종료해야 하는지는 명확하지 않았습니다. 프레임워크 사용자 입장에서는 내부에서 executor가 생성되는 사실조차 알기 어렵습니다.

 

이 구조는 프레임워크 내부 구현이 사용자 코드의 생명주기에 영향을 주는 형태였습니다. 사용자가 의도하지 않았더라도, 서비스 인스턴스가 여러 번 생성되는 구조라면 executor 역시 계속 생성될 수 있는 상황이었습니다.

 

결국 executor를 어디서 관리해야 하는지, 생명주기를 어떻게 통제할 것인지에 대한 설계 개선이 필요하다고 판단하게 되었습니다.

 


#4344 PR에서 적용한 설계 변경

 

PR에서는 executor를 매번 새로 생성하는 대신, Initialization-on-Demand Holder Idiom을 활용한 싱글톤 형태의 DefaultExecutorProvider를 도입했습니다.

 

이 방식은 필요할 때만 executor가 초기화되도록 하면서도, 한 번 생성된 executor를 재사용할 수 있게 합니다. JVM 클래스 로딩 메커니즘을 활용하여 스레드 안전성을 보장하면서도 불필요한 초기화를 방지할 수 있는 구조입니다.

 

또한 executor가 실제로 필요한 시점까지 생성되지 않도록 레이지 로딩 구조를 적용했습니다. moderation 처리 등 executor가 필요한 기능이 호출될 때만 생성되도록 변경하여, 사용되지 않는 자원이 미리 생성되는 것을 방지했습니다.

 

이 변경은 단순한 리팩터링이 아니라, 프레임워크 내부 자원의 생명주기를 더 명확하게 관리하도록 설계를 개선한 작업이었습니다.

 


리뷰 과정에서의 논의

 

리뷰 과정에서는 단순히 코드 변경 자체보다 설계 의도가 충분히 타당한지에 대한 논의가 이어졌습니다. executor를 재사용하는 구조가 프레임워크 전체 설계 방향과 잘 맞는지, lazy initialization 방식이 적절한지에 대한 의견이 오갔습니다.

 

특히 레이지 로딩 방식은 실제 필요 시점까지 리소스 생성을 늦출 수 있어 서버 애플리케이션에서 유용하다는 의견이 있었고, 해당 방향으로 구조를 정리하게 되었습니다.

 

리뷰 과정에서 받은 피드백을 반영하면서, 단순히 동작하는 코드가 아니라 유지보수 관점에서도 이해하기 쉬운 구조로 정리하는 과정이 필요하다는 점을 다시 느끼게 되었습니다.

 


병합 이후 얻은 시사점

 

이번 기여를 통해 다시 확인하게 된 점은 싱글톤 패턴 자체가 문제가 아니라, 싱글톤 객체가 어떤 자원을 보유하고 있는지가 중요하다는 사실이었습니다.

 

JVM 서버 환경에서는 한 번 생성된 객체가 애플리케이션 종료 시점까지 살아남는 경우가 많습니다. 이런 객체가 스레드 풀이나 외부 리소스를 보유하고 있다면, 그 영향은 장기간 누적될 수 있습니다.

 

ExecutorService를 어디서 생성하고, 누가 관리하며, 언제 종료할 것인지를 설계 단계에서 고민하는 것이 중요하다는 점을 다시 생각하게 되었습니다.

 

이번 경험을 통해 얻은 가장 큰 인사이트는 메모리 누수 문제는 대부분 코드 한 줄의 문제가 아니라 설계의 문제로 이어진다는 점이었습니다. 오픈소스 기여를 통해 이러한 부분을 실제 코드 수준에서 고민하고 개선해볼 수 있었던 점은 개인적으로도 의미 있는 경험이었습니다.

 

앞으로도 프레임워크 내부 구조를 살펴볼 때 단순히 기능 동작 여부를 넘어서 객체 생명주기와 자원 관리 관점에서 코드를 바라보는 습관을 계속 가져가야겠다고 생각하게 되었습니다.



 

관련 포스팅

Holder Idiom vs Enum: 실무에서 선택하는 Java 싱글톤 구현

 

GitHub 리포지토리

 

공식 문서 & 자료

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