안녕하세요? 꽤 오랜만에 글을 쓰네요.
저는 디프만 17기 서버 개발자로 활동했습니다. 지난 활동을 회고해보려고 해요.
우리 팀은 주식 회고 서비스를 만들었는데, 사용자의 회고글에 대해 피드백을 생성해주는 AI 가 필요했어요. 이 글에서는 피드백 생성 AI 를 어떻게 구현했는지 설명하려고 합니다.

우선 피드백 생성 AI 는 멀티 에이전트 구조로 개발했어요. 기존에는 single agent 구조로, AI API 1번 호출하여 유저 인풋을 전달하는 방식이었습니다. 단점은 프롬프트를 길게 줄 수 없다는 점이었어요. 토큰 처리량에 한계가 있고, 프롬프트가 길어질수록 답변 퀄리티가 낮아지기 때문입니다.

그래서 위와 같이 멀티 에이전트 구조로 전환했습니다. 이것은 여러 개의 LLM 에이전트가 역할을 나눠서 문제를 해결하도록 한 구조예요. 에이전트들이 체이닝 형식으로 응답을 생성하는데, LLM 호출을 단계적으로 연결하여 하나의 응답 파이프라인을 만들었습니다.
유저 인풋 레이어, 오케스트레이션 레이어, 최종 판단 레이어를 분리하였고, 오케스트레이션 레이어에서는 세 개의 에이전트가 앞선 에이전트의 응답을 인풋으로 받게 됩니다. 더욱 체계적인 답변을 구조화할 수 있죠.
단점은 에이전트 수마다 openai API 호출을 하게 하게 되므로, 속도가 느려지고 비용이 늘어나요. 근본적으로 해결은 불가함. 에이전트 여러 개를 두면 당연히 지연시간이 늘어나기 때문입니다. 답변이 완성도가 높아지는 만큼 속도와 비용 문제가 발생하는 일종의 트레이드 오프인 거죠.
그래도 속도 개선을 위해서 비동기 스레드를 호출했습니다.
// 병렬로 리서치 진행
CompletableFuture<MarketData> marketDataFuture = collectMarketDataAsync(symbol, date, retrievedContext);
CompletableFuture<TechnicalAnalysis> technicalAnalysisFuture = analyzeTechnicalAsync(symbol);
CompletableFuture<FundamentalAnalysis> fundamentalAnalysisFuture = analyzeFundamentalAsync(symbol);
// 모든 분석 완료 대기(비동기 메서드 여러 개이므로)
CompletableFuture.allOf(marketDataFuture, technicalAnalysisFuture, fundamentalAnalysisFuture).join();
위와 같이 `CompletableFuture` 클래스를 사용하면 비동기 호출이 가능해집니다. 하나의 스레드가 끝날 때까지 대기하지 않고 그 다음 스레드가 시작되기 때문에 전체 스레드가 완료 되기까지 속도가 빨라지죠.
하지만 속도 이슈를 크게 개선하기는 어려우니, UX 를 개선하는 방향으로 가는 게 더 나을 것 같습니다. 두 가지 방법이 떠오르는데요, 첫 번째는 프론트를 개선하는 거예요. 피드백 로딩 화면 대신 프로세스 바를 두어서 사용자에게 생성 현황을 공유하면 덜 답답하겠죠. 두 번째는 서버 팀원이 말해준 건데 메시지큐를 두어서 생성되고 있는 피드백을 큐에 넣고, 사용자는 피드백 생성 직후 홈화면으로 다이렉트되도록 해요. 그리고 생성이 완료되면 큐에서 꺼내서 푸시알림 전송하는 거죠. 그럼 사용자는 로딩 화면에서 멈춰서 기다리지 않고, 나중에 알림만 확인하면 되는 것이죠.
또한, 피드백 AI 의 응답을 고도화하기 위한 여러 방법을 생각해보았어요. 먼저 가장 쉽게 할 수 있는 방법인 프롬프트 엔지니어링이었어요. 시스템 프롬프트와 유저 프롬프트를 구분했습니다. 시스템 프롬프트에는 지침(instruction)이 들어가요. 에이전트의 역할(투자 분석 컨설턴트)을 지정해주었습니다. 이때 추가로 원샷 프롬프팅 기법도 사용했는데, LLM 에게 예시를 1개 보여주어서 응답 포맷에 맞추어 응답을 생성하도록 제어했습니다. 이건 출력 패턴을 명확하게 하는 데 도움이 되었어요. JSON 구조로 출력되어야 DTO 객체에 넣어서 다음 에이전트에게 인풋으로 전달할 수 있었거든요! 그리고 유저 프롬프트는 사용자 데이터 DB 에서 조회하여 인풋으로 들어가도록 했습니다.
런칭데이 때, 다른 디퍼들과 외부인들이 우리 서비스를 테스트해보았습니다. 그때 받은 피드백 중 인상깊었던 것이 현재 주식 데이터 기반으로 피드백을 만들어주면 좋겠다는 것이었습니다.
그래서 생각했던 방법은 RAG 기반으로 답변을 생성하는 것이었습니다. RAG 란, LLM 이 답변을 생성할 때 외부 지식을 함께 가져와 활용하는 기술입니다. 이것은 chat gpt 와 notebooklm 을 비교해서 설명하면 이해하기 쉬운데요. 우리가 gpt 에게 프롬프트를 보내면, gpt는 인터넷에 있는 모든 정보를 가져와 그것을 기반으로 답변합니다. notebooklm은 써보신 분들은 아시겠지만, 사용자가 소스를 업로드할 수 있어요. 예를 들면, 교수님의 강의 자료를 소스로 업로드하면, 이 자료 내에서만 검색하여 그것을 기반으로 답변합니다. 훨씬 정확도가 높아지죠. 시험기간에 유용하게 썼습니다..
어쨌든, RAG 는 notebooklm 과 유사합니다. 내가 구축한 지식 베이스 내에서 검색하여 답변을 제공합니다. 그렇다면 저는 이것을 어떻게 구현했을까요?
우선 Spring batch 를 사용해 한국투자증권 API 를 호출했습니다. 매일 정해진 시각에 스케쥴링하여 시장, 종목코드, 현재 시점 기준 전날 날짜, 기간으로 주식 데이터를 조회합니다.
그리고 해당 데이터를 chromaDB 에 문서 형태로 저장합니다.
String documentText = String.format(
"%s (%s)의 %s 주가는 시가 %.2f, 고가 %.2f, 저가 %.2f, 종가 %.2f를 기록했으며, 거래량은 %d주였습니다.",
stock.getCompanyName(), stock.getCode(), dailyData.getDate(),
dailyData.getOpen(), dailyData.getHigh(), dailyData.getLow(),
dailyData.getClose(), dailyData.getVolume());
설명을 덧붙이면, chromaDB 는 일종의 데이터베이스입니다. 정확히 말하면 vectorDB 로, 텍스트, 이미지 같은 데이터를 숫자 백터로 변환해 저장하고, 유사한 백터를 빠르게 찾아주는 데이터베이스입니다.
docker run -d -p 8000:8000 --name chromadb \
-v /path/on/host/chromadb-data:/data \
ghcr.io/chroma-core/chroma:latest
chromaDB 는 애플리케이션 실행 이전에 docker 로 띄워주고, 볼륨 마운트 경로를 로컬 경로로 지정해두면 로컬에서 데이터 저장이 가능합니다.
그 다음에는 ChromaDB 에 저장된 문서를 기반으로 검색하도록 하면 됩니다.
// 1. Retrieval (검색): 검색 쿼리 문자열로, 현재 매매와 관련된 유사 정보를 ChromaDB에서 검색
String queryText = String.format(
"%s 종목의 %s 날짜 근처의 시장 상황 및 주가 흐름",
symbol,
date.toString());
// 유사도가 높은 상위 5개의 문서 찾는다.
SearchRequest searchRequest = SearchRequest.builder()
.query(queryText)
.topK(5)
.build();
List<Document> documents = vectorStore.similaritySearch(searchRequest);
// 2. Augmented Prompt (프롬프트 강화)
String retrievedContext = documents.stream()
.map(Document::getText)
.collect(Collectors.joining("\n"));
// 3. Generation (생성): LLM 호출
LlmResponse llmResponse = analyzeWithMultiAgent(symbol, price, volume, orderType, date, principleCheckAndImage,
retrievedContext);
주석에 써있다시피, 정해진 질의를 chromaDB 에서 검색해서 유사도가 높은 상위 5개의 문서를 찾습니다. 해당 문서들을 프롬프트에 넣어서 LLM 을 호출하는 것이죠! 프롬프트가 더 정교해집니다.

전체 흐름을 알 수 있는 아키텍처입니다. 출처는 이곳 입니다. 애플리케이션과 chromaDB의 상호작용을 아주 잘 나타낸 그림이라고 생각해요. 주식 데이터(쿼리)가 문서 형태로 chromaDB 에 임베딩되어 저장됩니다. 임베딩이란 텍스트, 이미지 같은 데이터를 숫자 벡터로 변환하는 과정입니다. 숫자 벡터로 변환하면 AI 가 그 의미를 더 잘 이해할 수 있지요. 의미가 유사한 데이터는 벡터 공간에서 가까운 곳에 놓이게 됩니다. 임베딩된 데이터는 LLM 호출 시에 프롬프트에 들어갑니다. 지식 베이스 기반으로 답변할 수 있게 됩니다.
spring 에서 chromaDB 를 사용하기 위해 추상화가 이미 너무 잘되어있습니다. 전 공식 문서를 보고 구현하는 걸 좋아하는데, 아래 문서가 잘 정리되어있으니 이것을 보고 쉽게 spring 에서 chromaDB 를 구현할 수 있을 것입니다.
https://docs.spring.io/spring-ai/reference/api/vectordbs/chroma.html
Chroma :: Spring AI Reference
If you prefer to configure the Chroma Vector Store manually, you can do so by creating a ChromaVectorStore bean in your Spring Boot application. Add these dependencies to your project: * Chroma VectorStore. org.springframework.ai spring-ai-chroma-store Ope
docs.spring.io
혹시 궁금하신 분들이 있을까봐 트러블 슈팅 과정도 적습니다. chromaDB 의 내부 동작에 관한 내용인데요.
처음에 chromaDB 를 도커로 띄우지 않고 터미널에서 실행했다가 컬렉션 조회가 반복적으로 실패하길래 API 경로가 잘못되었는지 확인하다가 내부 동작까지 뜯어보게 되었습니다..
우선 저는 .yml에서 chromaDB 컬렉션 생성을 자동으로 설정하였으나, 제대로 동작하지 않는 것 같아 VectorStore 를 수동으로 설정하기 위해 VectorStoreConfig 를 만들었습니다.
공식 문서를 보면, ChromaDB authorization configurations 를 생성하기 위해 아래와 같은 샘플 코드를 제공합니다.
@Bean
public VectorStore chromaVectorStore(EmbeddingModel embeddingModel, ChromaApi chromaApi) {
return ChromaVectorStore.builder(chromaApi, embeddingModel)
.tenantName("your-tenant-name") // default: SpringAiTenant
.databaseName("your-database-name") // default: SpringAiDatabase
.collectionName("TestCollection")
.initializeSchema(true)
.build();
}
spring ai 에서 임베딩 클라이언트 구현체(EmbeddingModel) 를 제공하는데, 이것을 인자로 받아 ChromaVectorStore 객체를 만듭니다. 임베딩을 알아서 해주는 것이죠!
ChromaVectorStore 클래스 들어가면, 아래와 같이 구현이 되어있어요.
protected ChromaVectorStore(Builder builder) {
super(builder);
this.chromaApi = builder.chromaApi;
this.tenantName = builder.tenantName;
this.databaseName = builder.databaseName;
this.collectionName = builder.collectionName;
this.initializeSchema = builder.initializeSchema;
this.filterExpressionConverter = builder.filterExpressionConverter;
this.objectMapper = JsonMapper.builder().addModules(JacksonUtils.instantiateAvailableModules()).build();
if (builder.initializeImmediately) {
try {
// afterPropertiesSet() 메서드를 호출함
afterPropertiesSet();
}
catch (Exception e) {
throw new IllegalStateException("Failed to initialize ChromaVectorStore", e);
}
}
afterPropertiesSet() 메서드에서는 chromaApi.getCollection() 를 호출하는데, 이곳의 URI 로 chromaDB 의 컬렉션을 조회합니다. 정리하면, ChromaVectorStore를 생성하려면 chromadb 의 컬렉션 조회가 먼저 이루어지는데 조회 URI가 chromadb 의 API 명세가 다르면 조회가 실패합니다.
@Override
public void afterPropertiesSet() throws Exception {
if (!this.initialized) {
// chromaApi.getCollection() 를 호출하는 것을 확인할 수 있음.
var collection = this.chromaApi.getCollection(this.tenantName, this.databaseName, this.collectionName);
if (collection == null) {
if (this.initializeSchema) {
var tenant = this.chromaApi.getTenant(this.tenantName);
if (tenant == null) {
this.chromaApi.createTenant(this.tenantName);
}
var database = this.chromaApi.getDatabase(this.tenantName, this.databaseName);
if (database == null) {
this.chromaApi.createDatabase(this.tenantName, this.databaseName);
}
collection = this.chromaApi.createCollection(this.tenantName, this.databaseName,
new ChromaApi.CreateCollectionRequest(this.collectionName));
}
else {
throw new RuntimeException("Collection " + this.collectionName
+ " doesn't exist and won't be created as the initializeSchema is set to false.");
}
}
if (collection != null) {
this.collectionId = collection.id();
}
this.initialized = true;
}
}
chromadb 도커로 띄운 이후에 localhost:8000/docs 들어가면 swagger 에서 API 명세 확인이 가능합니다. chromadb 의 API 명세와 spring ai 버전에서 ChromaApi 클래스의 메서드 시그니처에서 호출하는 URI 가 동일한지 확인해야합니다.
// 컬렉션 조회 메서드
@Nullable
public Collection getCollection(String tenantName, String databaseName, String collectionName) {
try {
return this.restClient.get()
// 여기 URI 가 chromadb 의 swagger 명세와 동일한 확인하라.
.uri("/api/v2/tenants/{tenant_name}/databases/{database_name}/collections/{collection_name}",
tenantName, databaseName, collectionName)
.headers(this::httpHeaders)
.retrieve()
.toEntity(Collection.class)
.getBody();
}
catch (HttpServerErrorException | HttpClientErrorException e) {
String msg = this.getErrorMessage(e);
if (String.format("Collection [%s] does not exists", collectionName).equals(msg)) {
return null;
}
throw new RuntimeException(msg, e);
}
}
사실 최신 버전으로 둘 다 설치했다면 이슈는 없을 것입니다.
아래 이슈는 chromaDB 버전 업데이트 이후 spring AI 버전에서 제공하는 URI 경로가 서로 일치하지 않아서 실행 불가하다는 이슈입니다. 버전 이슈는 빈번하게 발생하니, 이런 이슈가 흥미롭네요.
https://github.com/spring-projects/spring-ai/issues/2648
Chroma vectorstore not working after updating chroma db to version 1.0.0 · Issue #2648 · spring-projects/spring-ai
i use translate Spring ai version is using set('springAiVersion', "1.0.0-M6") When connecting to chroma db of version 1.0.0, an exception occurs: Caused by: org.springframework.web.client.HttpClien...
github.com
여기까지 마치겠습니다. Spring AI 아주 재밌었습니다.