Spring AI 개발 일지 (3) - 챗봇 구현

2025. 12. 5. 08:59·Spring

✅ 개요

Spring AI를 사용해 RAG 없이 단순 대화 챗봇을 구현해보겠습니다.

✅ 사전 준비

1️⃣ 의존성 추가 및 환경설정

implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-repository-jdbc'

기본 Spring AI 의존성만 추가하면 인메모리 기반의 레포지토리 빈이 등록됩니다. 대화 내역을 DB에 저장하기 위해서 jdbc 의존성을 추가했습니다.

spring:
  ai:
    chat:
      memory:
        repository:
          jdbc:
            initialize-schema: never

자동 스키마 생성 기능을 사용할 경우 오류가 자주 발생한다고 해 never로 설정한 다음 직접 스키마를 생성하였습니다. 스키마 정의는 Spring AI GitHub에 DB별로 정의되어 있습니다.

2️⃣ 빈 등록

@Configuration
public class AIConfig {

    @Bean
    public ChatClient openAiChatClient(ChatModel openAiChatModel) {
        return ChatClient.create(openAiChatModel);
    }

    @Bean
    public ChatMemory loginChatMemory(JdbcChatMemoryRepository jdbcChatMemoryRepository) {
        return MessageWindowChatMemory.builder()
                                      .chatMemoryRepository(jdbcChatMemoryRepository)
                                      .build();
    }

    @Bean
    public ChatMemory anonymousChatMemory() {
        return MessageWindowChatMemory.builder().build();
    }

    @Bean
    public MessageChatMemoryAdvisor loginMemoryAdvisor(ChatMemory loginChatMemory) {
        return MessageChatMemoryAdvisor.builder(loginChatMemory).build();
    }

    @Bean
    public MessageChatMemoryAdvisor anonymousMemoryAdvisor(ChatMemory anonymousChatMemory) {
        return MessageChatMemoryAdvisor.builder(anonymousChatMemory).build();
    }
}

Spring AI는 `ChatMemory`라는 대화 상태를 관리하는 추상화된 인터페이스를 제공하며, 기본 구현체로 `MessageWindowChatMemory`가 있습니다.

ChatMemory

로그인한 사용자나 하지 않은 사용자나 똑같이 챗봇과 대화를 할 수 있되, 로그인하지 않은 사용자는 DB에 저장하지 않고 메모리에 저장하길 원했기 때문에 인증용, 미인증용 `ChatMemory` 빈 두개를 등록했습니다.

💡 빌더를 통해 `MessageWindowChatMemory`를 생성할 때 `ChatMemoryRepository`를 지정하지 않으면 `InMemoryChatMemoryRepository` 구현체가 기본으로 정해집니다.

`MessageChatMemoryAdvisor`는 LLM에게 프롬프트를 전달하기 전에 사용자 메시지를 저장하고, LLM으로부터 받은 응답 메시지를 저장하는 과정을 자동화 해줍니다. 역시 인증용은 jdbc, 미인증용은 인메모리에 저장하도록 했습니다.

 

✅ 코드 구현

✔️ 컨트롤러

@RestController
@RequestMapping("/api/chat-bot")
@RequiredArgsConstructor
public class ChatbotController {

    private final ChatbotService chatbotService;

    @PostMapping
    public Flux<String> postMessage(@CurrentMemberId(required = false) Long memberId,
                                    @RequestBody ChatbotReqDto reqDto,
                                    HttpSession session) {
        return chatbotService.postMessage(memberId, reqDto.message(), session);
    }

    @GetMapping
    public ResponseEntity<List<ChatbotHistoryResDto>> getMessages(@CurrentMemberId(required = false) Long memberId,
                                                                  HttpSession session) {
        List<ChatbotHistoryResDto> response = chatbotService.getMessages(memberId, session);
        return ResponseEntity.ok(response);
    }
}

public record ChatbotReqDto(@NotBlank String message) { }

 

✔️ 서비스

@Service
@RequiredArgsConstructor
public class ChatbotService {

    private final ChatClient chatClient;
    private final ChatMemory loginChatMemory;
    private final ChatMemory anonymousChatMemory;
    private final MessageChatMemoryAdvisor loginMemoryAdvisor;
    private final MessageChatMemoryAdvisor anonymousMemoryAdvisor;

    public Flux<String> postMessage(Long memberId, String message, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        MessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;

        return chatClient.prompt()
                         .user(message)
                         .advisors(adv -> adv
                                 .advisors(advisor)
                                 .param(ChatMemory.CONVERSATION_ID, conversationId)
                         )
                         .stream()
                         .content();
    }

    public List<ChatbotHistoryResDto> getMessages(Long memberId, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        ChatMemory chatMemory = isLogin ? loginChatMemory : anonymousChatMemory;

        return chatMemory.get(conversationId)
                         .stream()
                         .map(message -> {
                             String content = message.getText();
                             MessageType messageType = message.getMessageType();
                             return new ChatbotHistoryResDto(content, messageType);
                         })
                         .toList();
    }

    private String getConversationId(HttpSession session) {
        String sessionKey = "conversationId";
        String conversationId = (String) session.getAttribute(sessionKey);

        if (!StringUtils.hasText(conversationId)) {
            conversationId = UUID.randomUUID().toString();
            session.setAttribute(sessionKey, conversationId);
        }

        return conversationId;
    }
}

`ChatMemory`는 `conversationId` 값으로 어떤 대화에 속한 메시지인지를 구분합니다. 인증 사용자인 경우 고유Id로, 미인증 사용자인 경우 임시 구분용이면 되기 때문에 세션에 uuid로 저장했습니다.

 

📌 문제 발생

`MessageWindowChatMemory` 클래스의 Javadoc을 보면 다음과 같이 설명되어 있습니다.

MessageWindowChatMemory

"메시지 수가 최대 크기를 초과하면 이전 메시지가 제거됩니다."라고 적혀 있습니다. 이는 LLM 호출 시 전달되는 컨텍스트를 적절히 제한해서 비용 절감 + 성능 안정을 위함이며, 최근 N개의 데이터만을 활용해 멀티턴을 구현합니다. 즉 과거의 오래된 메시지를 제거하여 저장 공간을 과도하게 사용하지 않고 있습니다.

여기서 N개는 `maxMessages` 필드값이며, 기본값은 20으로 설정되어 있습니다. 즉 20개의 메시지만 저장을 하는 것이고, 챗봇과 10번 대화를 주고 받고나서 11번째 대화부터는 과거 대화 기록이 하나씩 제거됩니다.

사실 멀티턴 대화만을 위해서라면 굳이 건드릴 필요는 없지만, 사용자에게 과거 전체 대화 내역을 보여줄 수 있어야 한다고 생각해 DB와 메모리에 전체 메시지를 저장하도록 했습니다.

 

🧩 문제 해결하기

✔️ 메시지 저장

먼저 메모리 또는 DB에 저장하기 위한 인터페이스를 정의했습니다.

public interface ChatbotHistoryMemory {
    void save(String conversationId, Message message);
}

다음은 인메모리용 레포지토리 구현체입니다.

public class InMemoryChatbotHistoryMemory implements ChatbotHistoryMemory {

    Map<String, List<ChatbotHistoryDto>> chatbotHistoryStore = new ConcurrentHashMap<>();

    @Override
    public void save(String conversationId, Message message) {
        chatbotHistoryStore.putIfAbsent(conversationId, new ArrayList<>());
        chatbotHistoryStore.get(conversationId).add(ChatbotHistoryDto.of(message));
    }
}

---------

public record ChatbotHistoryDto(
        MessageType type,
        String text,
        LocalDateTime createdAt) {

    public static ChatbotHistoryDto of(Message message) {
        return new ChatbotHistoryDto(message.getMessageType(), message.getText(), LocalDateTime.now());
    }
}

메모리에 저장하기 때문에 단순 Map과 DTO를 사용해 저장합니다.

 

다음은 DB(Jpa) 저장용 레포지토리 구현체입니다. Jpa를 사용하기 때문에 엔티티를 만들고 JpaRepository에게 위임하도록 했습니다.

@Component
@RequiredArgsConstructor
public class JpaChatbotHistoryMemory implements ChatbotHistoryMemory {

    private final ChatbotHistoryRepository chatbotHistoryRepository;

    @Override
    public void save(String conversationId, Message message) {
        chatbotHistoryRepository.save(ChatbotHistory.of(conversationId, message));
    }
}

--------------

public interface ChatbotHistoryRepository extends JpaRepository<ChatbotHistory, Long> {
}

--------------

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "chatbot_histories")
public class ChatbotHistory extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "chatbot_history_id")
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(name = "message_type", nullable = false)
    private MessageType type;

    @Column(name = "text", nullable = false)
    private String text;

    @Column(name = "conversation_id", nullable = false)
    private String conversationId;

    public static ChatbotHistory of(String conversationId, Message message) {
        return new ChatbotHistory(message.getMessageType(), message.getText(), conversationId);
    }

    private ChatbotHistory(MessageType type, String text, String conversationId) {
        this.type = type;
        this.text = text;
        this.conversationId = conversationId;
    }
}

 

그리고 기존에 `MessageChatMemoryAdvisor`를 사용해 `maxMessages` 만큼만 메시지를 저장했다면, 이제는 모든 메시지를 저장할 수 있어야 하기 때문에 `MessageChatMemoryAdvisor` 코드를 그대로 가져와 추가로 커스텀하였습니다.

더보기
public class CustomMessageChatMemoryAdvisor implements BaseChatMemoryAdvisor {

    private final ChatMemory chatMemory;
    private final ChatbotHistoryMemory chatbotHistoryMemory; // 추가

    private final String defaultConversationId;
    private final int order;
    private final Scheduler scheduler;

    private CustomMessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int order,
                                           Scheduler scheduler, ChatbotHistoryMemory chatbotHistoryMemory) {
        Assert.notNull(chatMemory, "chatMemory cannot be null");
        Assert.hasText(defaultConversationId, "defaultConversationId cannot be null or empty");
        Assert.notNull(scheduler, "scheduler cannot be null");
        Assert.notNull(chatbotHistoryMemory, "chatbotHistoryMemory cannot be null");
        this.chatMemory = chatMemory;
        this.defaultConversationId = defaultConversationId;
        this.order = order;
        this.scheduler = scheduler;
        this.chatbotHistoryMemory = chatbotHistoryMemory;
    }

    @Override
    public int getOrder() {
        return this.order;
    }

    @Override
    public Scheduler getScheduler() {
        return this.scheduler;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        String conversationId = getConversationId(chatClientRequest.context(), this.defaultConversationId);

        // 1. Retrieve the chat memory for the current conversation.
        List<Message> memoryMessages = this.chatMemory.get(conversationId);

        // 2. Advise the request messages list.
        List<Message> processedMessages = new ArrayList<>(memoryMessages);
        processedMessages.addAll(chatClientRequest.prompt().getInstructions());

        // 3. Create a new request with the advised messages.
        ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
                                                                        .prompt(chatClientRequest.prompt().mutate()
                                                                                                 .messages(processedMessages)
                                                                                                 .build())
                                                                        .build();

        // 4. Add the new user message to the conversation memory.
        UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
        this.chatMemory.add(conversationId, userMessage);

        // 추가
        chatbotHistoryMemory.save(conversationId, userMessage);

        return processedChatClientRequest;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        List<Message> assistantMessages = new ArrayList<>();
        if (chatClientResponse.chatResponse() != null) {
            assistantMessages = chatClientResponse.chatResponse()
                                                  .getResults()
                                                  .stream()
                                                  .map(g -> (Message) g.getOutput())
                                                  .toList();
        }
        String conversationId = this.getConversationId(chatClientResponse.context(), this.defaultConversationId);
        this.chatMemory.add(conversationId, assistantMessages);

        // 추가
        assistantMessages.forEach(assistantMessage -> chatbotHistoryMemory.save(conversationId, assistantMessage));

        return chatClientResponse;
    }

    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
                                                 StreamAdvisorChain streamAdvisorChain) {
        // Get the scheduler from BaseAdvisor
        Scheduler scheduler = this.getScheduler();

        // Process the request with the before method
        return Mono.just(chatClientRequest)
                   .publishOn(scheduler)
                   .map(request -> this.before(request, streamAdvisorChain))
                   .flatMapMany(streamAdvisorChain::nextStream)
                   .transform(flux -> new ChatClientMessageAggregator().aggregateChatClientResponse(flux,
                           response -> this.after(response, streamAdvisorChain)));
    }

    public static CustomMessageChatMemoryAdvisor.Builder builder(ChatMemory chatMemory) {
        return new CustomMessageChatMemoryAdvisor.Builder(chatMemory);
    }

    public static final class Builder {

        private String conversationId = ChatMemory.DEFAULT_CONVERSATION_ID;

        private int order = Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER;

        private Scheduler scheduler = BaseAdvisor.DEFAULT_SCHEDULER;

        private ChatMemory chatMemory;
        private ChatbotHistoryMemory chatbotHistoryMemory; // 추가

        private Builder(ChatMemory chatMemory) {
            this.chatMemory = chatMemory;
        }

        /**
         * Set the conversation id.
         *
         * @param conversationId the conversation id
         * @return the builder
         */
        public CustomMessageChatMemoryAdvisor.Builder conversationId(String conversationId) {
            this.conversationId = conversationId;
            return this;
        }

        /**
         * Set the order.
         *
         * @param order the order
         * @return the builder
         */
        public CustomMessageChatMemoryAdvisor.Builder order(int order) {
            this.order = order;
            return this;
        }

        public CustomMessageChatMemoryAdvisor.Builder scheduler(Scheduler scheduler) {
            this.scheduler = scheduler;
            return this;
        }

        // 추가
        public CustomMessageChatMemoryAdvisor.Builder chatbotHistoryMemory(ChatbotHistoryMemory chatbotHistoryMemory) {
            this.chatbotHistoryMemory = chatbotHistoryMemory;
            return this;
        }

        /**
         * Build the advisor.
         *
         * @return the advisor
         */
        public CustomMessageChatMemoryAdvisor build() {
            // 추가
            if (this.chatbotHistoryMemory == null) {
                this.chatbotHistoryMemory = new InMemoryChatbotHistoryMemory();
            }
            return new CustomMessageChatMemoryAdvisor(this.chatMemory, this.conversationId, this.order, this.scheduler, this.chatbotHistoryMemory);
        }

    }
}

`ChatbotHistoryMemory` 인터페이스를 필드로 가져 `before`와 `after`에서 각각 사용자와 LLM 메시지를 저장하는 로직을 추가하였습니다.

 

빈 등록도 다음과 같이 수정하였습니다.

@Configuration
public class AIConfig {

    ...
    
    @Bean
    public CustomMessageChatMemoryAdvisor loginMemoryAdvisor(ChatMemory loginChatMemory, ChatbotHistoryMemory chatbotHistoryMemory) {
        return CustomMessageChatMemoryAdvisor.builder(loginChatMemory)
                                             .chatbotHistoryMemory(chatbotHistoryMemory)
                                             .build();
    }

    @Bean
    public CustomMessageChatMemoryAdvisor anonymousMemoryAdvisor(ChatMemory anonymousChatMemory) {
        return CustomMessageChatMemoryAdvisor.builder(anonymousChatMemory).build();
    }
}

 

서비스 로직에서는 어드바이저를 주입받는 로직만 수정하였습니다.

    ...
    
    private final CustomMessageChatMemoryAdvisor loginMemoryAdvisor;
    private final CustomMessageChatMemoryAdvisor anonymousMemoryAdvisor;

    public Flux<String> postMessage(Long memberId, String message, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;

        return chatClient.prompt()
                         .user(message)
                         .advisors(adv -> adv
                                 .advisors(advisor)
                                 .param(ChatMemory.CONVERSATION_ID, conversationId)
                         )
                         .stream()
                         .content();
    }
    
    ...

 

✔️ 메시지 조회

메시지 조회 같은 경우 서비스에서 `ChatbotHistoryMemory`를 직접 사용하는 대신 이를 참조하고 있는 `CustomMessageChatMemoryAdvisor`에 추가하기로 했습니다.

 

먼저 `ChatbotHistoryMemory` 인터페이스에 조회 메서드를 추가했습니다.

public interface ChatbotHistoryMemory {
    void save(String conversationId, Message message);
    List<ChatbotHistoryDto> getMessages(String conversationId); // 추가
}

 

인메모리용 구현체 구현입니다.

public class InMemoryChatbotHistoryMemory implements ChatbotHistoryMemory {

    Map<String, List<ChatbotHistoryDto>> chatbotHistoryStore = new ConcurrentHashMap<>();

    ...

    @Override
    public List<ChatbotHistoryDto> getMessages(String conversationId) {
        return new ArrayList<>(chatbotHistoryStore.getOrDefault(conversationId, List.of()));
    }
}

 

DB 구현체 구현입니다.

@Component
@RequiredArgsConstructor
public class JpaChatbotHistoryMemory implements ChatbotHistoryMemory {

    private final ChatbotHistoryRepository chatbotHistoryRepository;

    ...

    @Override
    public List<ChatbotHistoryDto> getMessages(String conversationId) {
        return chatbotHistoryRepository.findAllByConversationId(conversationId)
                                       .stream()
                                       .map(ChatbotHistoryDto::of)
                                       .toList();
    }
}

------------

public record ChatbotHistoryDto(
        MessageType type,
        String text,
        LocalDateTime createdAt) {

    ...

    public static ChatbotHistoryDto of(ChatbotHistory chatbotHistory) {
        return new ChatbotHistoryDto(chatbotHistory.getType(), chatbotHistory.getText(), chatbotHistory.getCreatedAt());
    }
}

 

`CustomMessageChatMemoryAdvisor`에 `getMessages()` 메서드를 추가하였습니다. 참조하고 있는 `ChatbotHistoryMemory`에게 위임합니다.

public class CustomMessageChatMemoryAdvisor implements BaseChatMemoryAdvisor {

    private final ChatMemory chatMemory;
    private final ChatbotHistoryMemory chatbotHistoryMemory;

    ...

    private CustomMessageChatMemoryAdvisor(ChatMemory chatMemory, String defaultConversationId, int order,
                                           Scheduler scheduler, ChatbotHistoryMemory chatbotHistoryMemory) {
        ...
    }

	// 추가
    public List<ChatbotHistoryDto> getMessages(String conversationId) {
        return chatbotHistoryMemory.getMessages(conversationId);
    }

서비스 로직에서는 위 메서드를 그대로 호출해서 반환합니다.

public List<ChatbotHistoryDto> getMessages(Long memberId, HttpSession session) {
    boolean isLogin = memberId != null;

    String conversationId = isLogin ? memberId.toString() : getConversationId(session);

    CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;
    return advisor.getMessages(conversationId);
}

 

최종 수정된 서비스 로직은 다음과 같습니다.

더보기
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import project.airbnb.clone.config.ai.ChatbotHistoryDto;
import project.airbnb.clone.config.ai.CustomMessageChatMemoryAdvisor;
import reactor.core.publisher.Flux;

import java.util.List;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class ChatbotService {

    private final ChatClient chatClient;
    private final CustomMessageChatMemoryAdvisor loginMemoryAdvisor;
    private final CustomMessageChatMemoryAdvisor anonymousMemoryAdvisor;

    public Flux<String> postMessage(Long memberId, String message, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;

        return chatClient.prompt()
                         .user(message)
                         .advisors(adv -> adv
                                 .advisors(advisor)
                                 .param(ChatMemory.CONVERSATION_ID, conversationId)
                         )
                         .stream()
                         .content();
    }

    public List<ChatbotHistoryDto> getMessages(Long memberId, HttpSession session) {
        boolean isLogin = memberId != null;

        String conversationId = isLogin ? memberId.toString() : getConversationId(session);

        CustomMessageChatMemoryAdvisor advisor = isLogin ? loginMemoryAdvisor : anonymousMemoryAdvisor;
        return advisor.getMessages(conversationId);
    }

    private String getConversationId(HttpSession session) {
        String sessionKey = "conversationId";
        String conversationId = (String) session.getAttribute(sessionKey);

        if (!StringUtils.hasText(conversationId)) {
            conversationId = UUID.randomUUID().toString();
            session.setAttribute(sessionKey, conversationId);
        }

        return conversationId;
    }
}

 

'Spring' 카테고리의 다른 글

Spring AI 개발 일지 (4) - RAG 개념 정리  (0) 2025.12.14
Spring AI 개발 일지 (2) - OpenAI 사용해 후기 요약 구현해보기  (0) 2025.11.19
Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델  (0) 2025.11.19
PageableExecutionUtils.getPage로 페이징 성능 개선하기  (0) 2025.10.24
Spring 비동기로 이메일 전송하기  (0) 2025.10.21
'Spring' 카테고리의 다른 글
  • Spring AI 개발 일지 (4) - RAG 개념 정리
  • Spring AI 개발 일지 (2) - OpenAI 사용해 후기 요약 구현해보기
  • Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델
  • PageableExecutionUtils.getPage로 페이징 성능 개선하기
이런개발
이런개발
geun-00의 흔적 보관소
  • 이런개발
    내일이 기대되는 오늘
    이런개발
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 백엔드 면접
      • SQL N
        • SUM, MAX, MIN
        • SELECT
        • GROUP BY
        • JOIN
      • Spring
      • JPA
      • 트러블슈팅
      • Infra
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    JPA
    raid
    데브코스
    토스 페이먼츠
    자바
    오블완
    스프링
    티스토리챌린지
    백엔드 면접
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
이런개발
Spring AI 개발 일지 (3) - 챗봇 구현
상단으로

티스토리툴바