백엔드트러블슈팅

[POP-SPOT] LangChain4j + Gemini로 AI 팝업 코스 추천 만들기

김동현
··4분 읽기

LangChain4j와 Gemini API로 팝업스토어 AI 코스 추천 기능을 만들면서 겪은 할루시네이션 문제, 응답 속도 개선, SSE 스트리밍 적용 과정.

POP-SPOT에서 제일 재밌게 만든 기능임. 사용자가 "성수동 팝업 코스 짜줘" 라고 입력하면 AI가 근처 팝업스토어를 골라서 동선까지 짜주는 기능.

 


용어 설명 먼저

AI(인공지능) — 사람처럼 질문을 이해하고 답변을 생성하는 프로그램. ChatGPT 같은 것.
Gemini — 구글이 만든 AI. ChatGPT의 구글 버전이라고 보면 됨.
LangChain4j — AI를 Java 코드에서 편하게 쓸 수 있게 도와주는 도구. AI한테 질문 보내고 답변 받는 과정을 단순하게 만들어줌.
API — 두 프로그램이 서로 대화하는 창구. 구글 AI한테 질문을 보내려면 구글이 만들어놓은 API라는 창구를 통해야 함.

왜 LangChain4j를 골랐냐면

Gemini API를 직접 불러도 됐는데, LangChain4j를 쓰면 두 가지가 편해짐.

첫째, 질문 템플릿 관리가 깔끔해짐. AI한테 매번 같은 형식으로 질문해야 하는데 이걸 틀처럼 관리할 수 있음.

둘째, Spring Boot(Java 서버)에서 바로 쓸 수 있음. Python으로 별도 서버 안 만들어도 됨.

Python 서버가 필요한 이유: 원래 AI 관련 도구들은 Python으로 만들어진 게 많음. Java 서버에서 AI 기능 쓰려면 Python 서버를 따로 띄워서 연결하는 경우가 많은데, LangChain4j는 Java용이라 그 과정이 필요 없음.

설정

java
// AiConfig.java — AI 모델 설정 파일
@Configuration
public class AiConfig {

    @Value("${langchain4j.google-ai-gemini.chat-model.api-key}")
    private String apiKey; // Gemini API 키 (구글에서 발급받은 비밀번호 같은 것)

    @Bean
    @Primary
    public ChatLanguageModel chatLanguageModel() {
        return GoogleAiGeminiChatModel.builder()
                .apiKey(apiKey)
                .modelName("gemini-2.5-flash") // 사용할 AI 모델 이름
                .temperature(0.7) // 답변의 창의성 수준 (0에 가까울수록 딱딱, 1에 가까울수록 창의적)
                .timeout(Duration.ofSeconds(60)) // 최대 60초 기다림
                .build();
    }
}

타임아웃 60초 설정이 포인트임.

타임아웃(Timeout) — "몇 초 안에 답장 안 오면 포기하겠다"는 설정. 기본값이 10~30초인데, AI가 복잡한 질문을 처리하다 보면 그 시간을 초과할 때가 있음. 처음에 이걸 몰라서 TimeoutException 에러가 계속 났음.

프롬프트 설계

프롬프트(Prompt) — AI한테 보내는 질문 또는 지시문. 같은 AI라도 질문을 어떻게 쓰느냐에 따라 답변 품질이 완전히 달라짐. "코스 짜줘" 와 "서울 팝업스토어 전문가로서, 아래 목록에서만 골라서 동선 효율적으로 코스 짜줘" 는 결과가 다름.
java
// AiCourseService.java — AI 코스 생성 로직
public String generateCourse(String userRequest, List<PopupStore> nearbyStores) {

    // DB에서 가져온 팝업스토어 목록을 텍스트로 변환
    String storeList = nearbyStores.stream()
        .map(s -> String.format("- %s (위치: %s, 운영시간: %s)",
            s.getName(), s.getAddress(), s.getHours()))
        .collect(Collectors.joining("\n"));

    String prompt = String.format("""
        너는 서울 팝업스토어 전문 코스 플래너야.
        아래 팝업스토어 목록 중에서만 골라서 코스를 짜줘. 목록에 없는 곳은 절대 추천하지 마.
        
        [이용 가능한 팝업스토어 목록]
        %s
        
        [사용자 요청]
        %s
        
        이동 동선 효율성을 고려해서 2~3곳을 골라줘.
        각 장소별로 추천 이유 1~2문장, 예상 소요 시간, 이동 방법을 포함해줘.
        """, storeList, userRequest);

    return chatLanguageModel.generate(prompt);
}

"목록에 없는 곳은 절대 추천하지 마" 이 줄이 핵심임.

할루시네이션(Hallucination) — AI가 없는 정보를 있는 것처럼 지어내는 현상. 사람이 거짓말하는 것과 비슷함. 이 문장 없으면 AI가 실제로 존재하지 않는 팝업스토어를 추천해버림. 실제 DB 데이터만 프롬프트에 넣어줘서 "이것만 봐" 라고 제한하는 방식으로 해결함.

응답 속도 문제

처음엔 팝업스토어 목록 전체를 프롬프트에 넣었더니 응답이 10초씩 걸렸음.

토큰(Token) — AI가 글자를 읽고 쓰는 단위. 대략 단어 하나가 1~2 토큰. 토큰이 많을수록 AI가 읽어야 할 양이 늘어나 응답이 느려지고, 비용도 늘어남.

목록이 길면 토큰이 많아지기 때문에 느려진 것. 사용자 위치 기준 반경 2km 내 가게만 필터링해서 넣는 걸로 바꿨더니 응답 시간이 3~5초로 줄었음.

java
// 사용자 위치 기준 2km 반경 내 팝업스토어만 가져오기
List<PopupStore> nearbyStores = popupStoreRepository
    .findWithinRadius(userLat, userLng, 2.0);

스트리밍 응답 적용

3~5초도 사용자 입장에서는 화면이 멈춰있는 것처럼 느껴짐. 그래서 스트리밍 방식으로 바꿨음.

스트리밍(Streaming) — 결과를 한꺼번에 받는 게 아니라 생성되는 즉시 조금씩 받는 방식. ChatGPT에서 답변이 타이핑되듯 나오는 것도 스트리밍임. "전부 완성되면 줘" 대신 "한 글자 만들어질 때마다 바로 줘" 방식.
java
// 스트리밍 방식으로 AI 응답 전송
@GetMapping(value = "/api/ai/course/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamCourse(@RequestParam String request,
                                @RequestParam Double lat,
                                @RequestParam Double lng) {
    
    // SseEmitter = 서버가 클라이언트에게 데이터를 실시간으로 조금씩 밀어보내는 통로
    SseEmitter emitter = new SseEmitter(60000L);
    
    executorService.execute(() -> {
        streamingModel.generate(prompt, new StreamingResponseHandler<AiMessage>() {
            
            // AI가 단어 하나 만들 때마다 이 메서드가 호출됨
            @Override
            public void onNext(String token) {
                try {
                    emitter.send(token); // 만들어진 단어 즉시 프론트로 전송
                } catch (IOException e) {
                    emitter.completeWithError(e);
                }
            }
            
            // AI 응답이 완전히 끝났을 때 호출
            @Override
            public void onComplete(Response<AiMessage> response) {
                emitter.complete(); // 연결 종료
            }
        });
    });
    
    return emitter;
}

프론트(Next.js)에서는 EventSource라는 기능으로 연결해서 글자가 올 때마다 화면에 추가함.

EventSource — 브라우저가 서버에서 실시간으로 데이터를 받는 기능. 서버가 "새 글자 생겼어" 할 때마다 브라우저가 받아서 화면에 붙여넣는 방식.

한계

지금 구조는 대화가 이어지지 않음. "조금 더 북쪽으로 코스 바꿔줘" 같은 후속 질문을 이해하지 못함.

대화 히스토리 — AI가 이전 대화 내용을 기억하는 것. "아까 내가 뭐 물어봤지?" 를 AI가 알아야 맥락을 이해할 수 있는데, 현재는 매번 새로운 질문으로 인식함.

LangChain4j의 ConversationMemory를 쓰면 해결 가능한데 아직 미구현임.

비용 문제도 있음. Gemini 무료 티어가 있긴 한데 요청이 많아지면 결국 비용이 발생함.