
LangChain4j와 Spring AI: Java 애플리케이션이 LLM과 대화하게 만드는 배관 작업
요약
Java 환경에서 LLM 애플리케이션을 구축하기 위한 LangChain4j와 Spring AI 라이브러리를 비교 및 소개합니다. 단순한 API 호출을 넘어 대화 기록 관리, RAG, 함수 호출 등 실무적인 AI 서비스 구현을 위한 가이드를 제공합니다.
핵심 포인트
- Java 생태계의 대표적 AI 프레임워크인 LangChain4j와 Spring AI 소개
- LLM은 상태가 없는 함수(stateless function)라는 핵심 개념 이해
- 대화 기록 관리, 문서 검색(RAG), 함수 호출 등 AI 배관 작업의 중요성
- Spring Boot 환경에서 Python 없이 직접 LLM 서비스 구축 가능
만약 여러분이 LangChain에 대해 듣고 그것이 Python 전용이라고 생각했다면, 그건 타당한 생각입니다. 실제로 대부분 그랬으니까요.
LangChain이 인기를 얻은 이유는 LLM(Large Language Model)을 활용한 개발이 생각보다 많은 반복적인 배관 작업(plumbing)을 수반하기 때문입니다. 대화 기록(conversation history)을 관리하고, 문서를 청크(chunks) 단위로 나누고, 임베딩(embeddings)을 생성하고, 벡터 스토어(vector store)를 검색하고, 모델이 호출할 수 있는 함수를 연결하고, 결과로 나오는 무엇이든 파싱(parse)해야 합니다. 이 중 어느 것도 어렵지는 않지만, 모든 프로젝트마다 이를 처음부터 작성하는 것은 금방 지루해집니다. LangChain은 이러한 요소들을 재사용 가능한 컴포넌트(components)로 패키징했고, 그 패턴이 널리 퍼졌습니다.
이제 Java 생태계에도 두 가지 방식으로 그것이 존재합니다. LangChain4j는 동일한 아이디어를 기반으로 구축된 Java 라이브러리로, 다른 언어에서 포팅된 것이 아니라 처음부터 Java를 위해 작성되었습니다. Spring AI는 자동 설정(auto-configuration)과 의존성 주입(dependency injection)을 통해 Spring 방식대로 동일한 작업을 수행하며, 지난 6월 2.0 버전에 도달했습니다.
두 가지 모두 프로덕션 환경에서 사용할 준비가 되어 있습니다. 기존의 Spring Boot 서비스에서 약 6줄의 코드만으로 LLM을 호출할 수 있으며, 이를 위해 Python 사이드카(sidecar)나 별도의 서비스가 필요하지 않습니다.
하지만 흥미로운 부분은 그 6줄의 코드가 아닙니다. 중요한 것은 텍스트를 그대로 에코(echo)하는 채팅 엔드포인트와 실제로 배포할 만한 서비스 사이의 간극을 메우는 것입니다. 즉, 문자열 대신 타입이 지정된 객체(typed objects)를 받고, 여러분의 자체 문서에 답변의 근거를 두며(grounding), 모델이 앱 내의 실제 코드를 트리거할 수 있도록 하는 것입니다.
이 포스트에서 다룰 내용이 바로 이것입니다. 우리는 Hello-world 단계부터 시작하여, 여러분의 내부 문서에 대한 질문에 답하고 API를 호출할 수 있는 서비스까지 한 단계씩 구축해 나갈 것입니다. 대부분의 사용자가 이미 Boot 서비스 환경에 있다는 점을 고려하여 가이드에는 Spring AI를 사용할 것이며, 그 다음 LangChain4j에서는 동일한 작업이 어떻게 보이는지 보여줌으로써 여러분이 선택할 수 있도록 하겠습니다.
저는 여러분이 Java와 Spring Boot는 알고 있지만, AI에 대해서는 아무것도 모른다고 가정하겠습니다. 수학도, 이론도 필요 없습니다. 그저 무언가를 만들기 위해 필요한 부분들만 다룹니다.
모든 것이 제자리를 찾게 해줄 아이디어 하나를 먼저 말씀드리겠습니다.
이 개념을 이해하게 만드는 멘탈 모델 (The mental model)
LLM은 상태가 없는 함수 (stateless function)입니다. 텍스트가 입력되면 텍스트가 출력됩니다. LLM은 당신의 마지막 호출을 기억하지 못하며, 인터넷에 접속할 수 없고, 당신의 시스템에 대해 아무것도 알지 못합니다.
아래의 모든 내용은 이를 해결하기 위한 우회 방법 (workaround)입니다:
- 대화 내용을 기억해야 하나요? 당신이 대화 기록을 다시 보냅니다.
- 내부 문서를 알아야 하나요? 당신이 관련 페이지를 찾아 그것을 붙여넣습니다.
- 실시간 데이터를 확인해야 하나요? 당신이 모델이 요청한 함수를 실행하고 그 결과를 전달합니다.
모델은 결코 스스로 아무것도 하지 않습니다. 당신의 코드가 모든 것을 합니다. 모델은 텍스트를 생성하며, 때때로 그 텍스트는 당신의 코드가 다음에 무엇을 해야 할지에 대한 결정이 됩니다.
이 하나의 아이디어만으로 이 분야의 대부분이 명확해집니다. 여기서부터의 모든 것은 상태가 없는 함수 주변의 배관 작업 (plumbing)이며, 배관 작업은 우리가 이미 잘하고 있는 일입니다.
본격적으로 시작하기 전에 LLM, RAG, 그리고 에이전트 (agents)가 어떻게 결합되는지에 대한 더 자세한 배경 지식을 원하신다면, 여기서 관련 글을 작성했습니다:
AI 파도 해독하기: 백엔드 엔지니어를 위한 LLM, RAG, 에이전트 가이드
shayesta
팔로우
AI 파도 해독하기: 백엔드 엔지니어를 위한 LLM, RAG, 에이전트 가이드
읽기 시간 9분
1단계: 인사하기
Spring AI 2.0은 Spring Boot 4.0+ 및 Java 17+가 필요합니다.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
...
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.model=gpt-4o-mini
@RestController
class ChatController {
...
작동하는 엔드포인트가 완성되었습니다. Spring Boot는 JdbcTemplate을 제공하는 것과 동일한 방식으로 ChatClient.Builder를 자동 설정(auto-configured)해주므로, Spring 부분에 대해 새로 배울 것은 없습니다.
2단계: 문자열(strings)만 반환받는 상황 탈피하기
이 지점이 대부분의 첫 시도가 실패하는 구간입니다. 구조화된 데이터(structured data)를 요청했는데 문단 하나를 받거나, 마크다운 구분자(markdown fences)로 감싸진 JSON을 받습니다. 혹은 JSON 앞에 수다스러운 서문(preamble)이 붙어 나오기도 합니다. 그래서 파서(parser)를 만들고, 폴백 파서(fallback parser)를 만들고, 정규 표현식(regex)을 만듭니다. 금방 비참해지기 마련입니다.
대신 타입을 요청하세요:
record ActionItem(String task, String owner, String dueDate) {}
record MeetingNotes(String summary, List<ActionItem> actionItems) {}
MeetingNotes notes = chatClient.prompt()
.user("다음 전사 기록에서 요약과 실행 항목을 추출하세요:\n" + transcript)
.call()
...
.entity()는 사용자의 레코드(record)로부터 JSON 스키마(schema)를 도출하고, 모델에게 이를 준수하도록 지시하며, 응답을 역직렬화(deserializes)합니다. 여러분은 객체(object)를 얻게 됩니다. 이 객체를 전달하거나, 테스트하거나, 영속화(persist)할 수 있습니다.
이것은 프레임워크에서 가장 영향력이 큰(highest-leverage) 기능입니다. LLM 데모를 실제 시스템에 배치할 수 있는 컴포넌트로 바꿔주는 핵심 요소입니다.
사람들을 놀라게 하는 한 가지는 필드 이름을 명확하게 짓는 것입니다. d2보다 dueDate가 더 나은 결과를 가져오는데, 생성된 스키마가 모델이 보는 프롬프트(prompt)의 일부가 되기 때문입니다.
3단계: 당신의 데이터에 대해 가르치기
"우리의 롤백 절차(rollback procedure)는 무엇인가요?"라고 물으면, 모델은 자신 있게 가짜 절차를 만들어낼 것입니다. 해결책은 화려하지 않습니다. 관련 문서를 찾아 프롬프트에 붙여넣는 것입니다.
그것이 바로 RAG (Retrieval-Augmented Generation, 검색 증강 생성)입니다. 이름은 거창한 아키텍처처럼 들리지만, 실제로는 좋은 검색 기능 앞에 붙여넣기(paste) 작업을 수행하는 것입니다.
유일하게 흥미로운 부분은 검색(search)입니다. 키워드 매칭(Keyword matching)은 여기서 너무 취약합니다. 사용자는 "rollback"에 대해 묻고 있는데, 여러분의 런북(runbook)에는 "reverting a bad deploy"라고 적혀 있을 수 있기 때문입니다. 그래서 대신 **임베딩 (embeddings)**을 사용합니다. 각 텍스트 청크(chunk)는 그 의미를 나타내는 벡터(vector)로 변환되며, 유사한 의미를 가진 것들은 서로 가까이 위치하게 됩니다. 이제 이 두 구절은 공유하는 단어가 하나도 없음에도 불구하고 서로 매칭됩니다.
문서를 한 번 로드하세요:
var reader = new TextReader(runbook);
var splitter = new TokenTextSplitter();
vectorStore.add(splitter.apply(reader.get()));
검색(retrieval)을 연결합니다:
@Bean
ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
return builder
...
질문하기:
String answer = ragChatClient.prompt()
.user("What's our deployment rollback procedure?")
.call()
...
호출 코드가 1단계와 동일하다는 점에 주목하세요. 코드 어디에도 "RAG"라는 말은 없습니다. QuestionAnswerAdvisor가 중간에 위치하여 작업을 수행합니다. 즉, 요청을 가로채고, 질문을 임베딩하며, 저장소를 검색하고, 매칭된 내용을 프롬프트(prompt)에 주입한 다음, 이를 전달합니다.
이것이 바로 어드바이저 (Advisor) 패턴이며, Spring AI의 핵심 아이디어입니다. 만약 서블릿(servlet) Filter나 HandlerInterceptor를 작성해 본 적이 있다면, 이미 그 구조를 알고 있는 것입니다. 메모리(Memory), 재시도(retries), 도구 호출(tool calling) 모두 동일한 방식으로 작동합니다.
4단계: 무언가를 수행하게 하기
RAG는 사전에 로드한 문서만을 불러올 수 있습니다. 주문이 발송되었는지 여부는 문서가 아니라 실시간 조회(live lookup)가 필요하기 때문에 RAG가 알려줄 수 없습니다. 이를 위해서는 **도구 호출 (tool calling)**이 필요합니다.
이름이 약간 오해의 소지가 있는데, 모델이 여러분의 도구를 호출하는 것이 아니기 때문입니다. 실제로 일어나는 일은 다음과 같습니다:
- 여러분이 모델에게 함수를 설명합니다.
- 모델이 응답합니다: "
orderId=A1234로getOrderStatus를 호출하고 싶습니다." - 여러분의 코드가 이를 실행합니다.
- 여러분이 결과를 다시 보냅니다.
- 모델이 그 결과를 사용하여 답변합니다.
모델은 결정하고, 여러분의 코드가 실행합니다. 실제로 Spring AI는 2단계부터 4단계까지의 과정을 사용자로부터 숨겨줍니다. 여러분은 그저 메서드를 작성하기만 하면 됩니다:
@Component
class OrderTools {
...
String answer = chatClient.prompt()
.user("Has order A1234 shipped yet?")
.tools(orderTools)
...
설명(Description)이 곧 API입니다. 이것은 인간을 위한 문서가 아닙니다. 모델이 여러분의 메서드를 호출하기에 적절한 것인지 결정할 때 사용하는 유일한 정보이므로, 모호한 설명은 잘못된 호출을 유발합니다.
이전 튜토리얼을 보고 계신다면 알아두어야 할 점이 있습니다: Spring AI 2.0은 도구 호출 루프(tool-calling loop)를 개별 채팅 모델에서 어드바이저 체인(advisor chain)으로 이동시켰습니다. 1.x 버전에서도 도구를 호출할 수는 있었지만, 루프 자체를 기반으로 기능을 구축할 수는 없었습니다. 이제는 루프를 가로채거나(intercept) 그 주변에 기능을 조합(compose)할 수 있으며, 이는 에이전트(agents)를 구축할 때 매우 중요합니다.
MCP의 위치
현재 MCP (Model Context Protocol)에 대해 많은 이야기를 듣게 될 것이며, 이는 도구 호출(tool calling)과 끊임없이 혼동되곤 합니다. 차이점은 간단합니다: 도구 호출은 기능(capability)이고, MCP는 전달 메커니즘(delivery mechanism)입니다. 4단계의 모든 과정은 MCP 없이도 작동합니다.
MCP는 다른 질문에 답합니다: 만약 그 도구가 다른 앱에서도 사용 가능해야 한다면 어떻게 될까요? 모든 팀이 동일한 통합 기능의 각자 버전을 하드코딩하는 대신, 도구는 모든 MCP 호환 클라이언트가 연결할 수 있는 독립적인 서버에 존재하게 됩니다. 이는 새로운 종류의 전기가 아니라 USB에 더 가까운 표준 인터페이스입니다.
자체 도구 몇 개를 사용하는 하나의 앱을 구축 중이라면, MCP는 건너뛰고 4단계를 사용하세요. 만약 서비스 전반에 걸쳐 도구를 재사용하고 싶거나, 이미 구축된 서버들의 성장하는 생태계에 연결하고 싶다면, 그때가 바로 MCP가 제 역할을 하는 시점입니다.
LangChain4j에서의 동일한 작업
위의 모든 내용은 LangChain4j에서도 작동합니다. 차이점은 철학적인 부분에 있습니다: Spring AI는 구성(composition)에 대해 확고한 견해(opinionated)를 가지고 있어 모든 것이 어드바이저 체인을 통해 흐르도록 Spring이 알아서 연결해 줍니다. 반면 LangChain4j는 독립적인 빌딩 블록(building blocks)을 제공하고 사용자가 직접 이를 조립할 수 있게 합니다.
LangChain4j의 가장 큰 특징은 AI Services입니다. 인터페이스를 선언하면 구현체가 생성됩니다:
SupportAssistant assistant = AiServices.builder(SupportAssistant.class)
.chatModel(model)
.contentRetriever(retriever) // RAG
...
단 하나의 빌더(builder) 안에서 이 네 가지 단계가 모두 이루어집니다. 만약 Spring Data 리포지토리(repositories)나 Feign 클라이언트(clients)를 사용해 본 적이 있다면, 이 패턴은 설명이 필요 없을 것입니다. 타입이 지정된 인터페이스(typed interface)에 원하는 바를 기술하면, 라이브러리가 나머지를 처리합니다.
| Spring AI | LangChain4j | |
|---|---|---|
| 가장 적합한 경우 | 이미 Spring Boot를 사용 중일 때 | Quarkus, Micronaut 또는 순수 Java를 사용할 때 |
| ... |
Spring을 사용하지 않거나, 구성(composition)을 직접 제어하고 싶다면 LangChain4j를 사용하세요. 이미 Boot 서비스 환경에 있으며 자동 설정(auto-configuration), Micrometer 관찰성(observability), 그리고 이를 기반으로 구축할 어드바이저 체인(advisor chain)을 원한다면 Spring AI를 사용하세요.
둘 다 좋습니다. 이 결정은 괴로워하며 고민할 만큼 중대한 문제가 아니므로, 현재 사용 중인 스택에 맞는 것을 선택하세요.
알아두면 좋은 두 가지
프레임워크 오버헤드보다 토큰(Token) 사용량이 더 중요합니다. 모델로 향하는 네트워크 지연 시간(latency)은 어떠한 추상화 비용보다 훨씬 크기 때문에, 성능 때문에 프레임워크를 선택하지 마세요. 실제로 누적되는 것은 토큰입니다. 두 프레임워크 모두 요청에 메모리(memory), RAG 청크(chunks), 도구 정의(tool definitions) 등을 조용히 추가합니다. 규모를 확장하기 전에 실제로 무엇이 전송되고 있는지 로그를 남기세요.
아마도 미세 조정(fine-tuning)이 아니라 RAG를 원할 것입니다. 모델이 문서나 현재 데이터와 같이 무언가를 알아야(know) 할 때는 RAG를 사용하세요. 톤(tone)이나 형식을 맞추는 것과 같이 특정 방식으로 행동해야(behave) 할 때는 미세 조정을 사용하세요. 미세 조정을 시도하는 대부분의 사람들은 실제로는 RAG를 원하는 것이며, RAG는 비용이 더 저렴하고 데이터베이스에 쓰는 것만으로 업데이트가 가능합니다.
가치 있다고 느껴지는 것보다 더 작게 시작하세요
문자열을 반환하는 ChatClient를 먼저 만드세요. 그것은 반나절이면 충분합니다. 그다음, 그것이 단순한 장난감이 아니게 되는 시점인 레코드(record)를 반환하도록 만드세요. 그다음 도구(tool)를 하나 추가하세요. 그다음 RAG를 추가하세요.
각 단계는 진정으로 작으며, 라이브러리들이 충분히 훌륭해졌기 때문에 배관 작업(plumbing)은 대부분 사라집니다. 남는 것은 흥미로운 부분입니다. 즉, 당신의 시스템이 실제로 무엇을 해야 하는지 결정하는 것입니다.
- Spring AI 레퍼런스 — ChatClient 및 Advisors로 시작하세요
- LangChain4j 문서
⚠️ 주의 사항: Spring AI 2.0은 1.x 버전과 비교했을 때 파괴적 변경 사항(breaking changes)이 포함된 대대적인 재설계가 이루어졌습니다. 온라인에서 찾을 수 있는 많은 튜토리얼은 여전히 1.x 버전을 기준으로 작성되어 있습니다. 무엇인가를 복사하기 전에 읽고 있는 버전이 무엇인지 확인하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기