Spring AI로 만드는 NPC 챗봇

김소희·2025년 11월 18일

이번 프로젝트에서는 Spring AI(ChatClient) 을 활용해
게임 속 NPC 상점 직원처럼 말하는 대화형 AI 챗봇을 구현하였다.
NPC에게는 시스템 프롬프트를 이용해 정해진 역할과 성격을 부여하였으며,
사용자가 물어본 아이템 정보에 기반해 능동적이고 세계관에 맞는 응답을 하도록 설계했다.

또한 Map<String, List<Message>>
사용자별로 대화 기록을 저장하여,
이전 대화를 기억하고 맥락을 유지하며 자연스럽게 이어서 대답할 수 있게 만들었다.

DTO 설명

사용자 요청과 응답을 주고받기 위한 객체이다.
userId를 통해 사용자별 대화 기록을 분리할 수 있다.

dto

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatRequestDto {
	// 누가 질의했고 그 메세지는 무엇인지
	private String userId;
	private String message;
}

@Data
@AllArgsConstructor
public class ChatResponseDto {
	private String response;
}

Service 설명

NPC 역할을 부여하고 사용자별 대화 기록을 기억하며,
OpenAI로 응답을 받아 처리하는 핵심 비즈니스 로직이다.

  • ChatClient → OpenAI API와 연결해 메시지를 주고받는 객체
  • chatHistory → 사용자별 대화 저장소
  • systemPrompt → 캐릭터 성격과 설정 정의
  • getChatResponse() → 메인 처리 함수

주요 포인트

기능설명
computeIfAbsentuserId 기준으로 대화 기록 생성/유지
Prompt시스템 프롬프트 + 유저 메시지 + 대화기록 묶음
Validation응답 null 체크
History Trim최근 10개 대화만 유지(메모리 관리)

service

package kr.or.kosa.service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import kr.or.kosa.dto.ChatMessageDto;
import kr.or.kosa.dto.ChatResponseDto;
import kr.or.kosa.entity.ChatMessage;
import kr.or.kosa.repository.ChatMessageRepository;

import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.chat.messages.AssistantMessage;

@Service
public class CustomerSupportService {
	// ChatClient AI 객체 선언
	private final ChatClient chatClient;
	private final ChatMessageRepository chatMessageRepository;
	
	public CustomerSupportService(ChatClient.Builder chatClientBuilder,
			ChatMessageRepository chatMessageRepository) {
		this.chatClient = chatClientBuilder.build(); // open AI 연결한 객체의 주소
		this.chatMessageRepository = chatMessageRepository;
	}
	
	// 챗봇과의 대화 내용을 저장하고 싶은 상황
	// String : hong [new Message()][new Message()][new Message()]...
	private final Map<String, List<Message>> chatHistory = new ConcurrentHashMap<>();
	
	private final String systemPrompt = """
			당신은 RPG 세계 ‘용사마트’의 전설적인 만능 상점 NPC입니다.
			유쾌하고 장난스러운 말투를 사용하여 대답해야 합니다.

			당신의 상점은 **무엇이든 판매하는 마법 상점**이며,
			사용자가 어떤 아이템을 물어보든, 아래 규칙에 따라 **즉석에서 아이템 정보를 생성하여 판매할 수 있습니다.**

			[기본 아이템 예시]  
			- 아이템: 불타는 대검 / 가격 500골드 / 공격력 +50, 화상 확률  
			- 아이템: 힐링 포션 / 가격 50골드 / HP 즉시 회복  
			- 아이템: 투명 망토 / 가격 999골드 / 10초간 투명화  

			[확장 규칙 – 아무 아이템이나 즉석 생성]  
			사용자가 다음을 요청하면 자동 생성하여 안내합니다:
			- 실제로 존재하는 RPG 장비(대검,, 장갑, 방패 등)
			- 음식, 재료, 소비 아이템
			- 스크롤, 주문서, 마법 주문
			- 말도 안 되는 특수 아이템(: ‘운빨 상승 반지’, ‘코딩이 잘 되는 마법 펜’ 등)
			- 현대 물건(핸드폰, 노트북)도 ‘판타지 버전’으로 재해석하여 판매 가능

			[아이템 생성 시 포함할 항목]  
			- 아이템명  
			- 가격(골드)  
			- 능력치 또는 효과  
			- 희귀도(R, SR, SSR, UR 등)  
			- 재고 수량  
			- 짧은 재미있는 lore(세계관 설명)

			[불가능한 경우]  
			- 정말 말이 안 되는 요구 사항에는 장난스럽게 응대  
			예: “전설의 월세할인쿠폰 주세요” → 장난스러운 농담 포함 가능

			[대화 스타일]  
			- 항상 유쾌, 장난스러움  
			- 안내는 쉽고 명확하게  
			- 적당히 추천/농담 포함  
			- 사용자 고민 시 친절한 조언

			""";
	// 만약 목록에 없는 물건을 물으면,
	// 모험가에게 정중히 "그런 아이템은 존재하지 않는다네!" 라고 답해주세요.
	// 사용자가 고민하면 가끔은 추천도 해주고, 간단한 농담도 섞어주세요.
	
	
	/*
	chatHistory는 Map<String, List<Message>> 형태(보통)로,
	각 사용자(userId)의 이전 대화 목록(List<Message>)을 저장하고 있습니다.
	computeIfAbsent()는 map에 값이 없으면 자동으로 생성하는 메서드입니다.
	즉, userId의 대화 기록이 없으면 새로 ArrayList()를 만들고, 있으면 기존 기록을 꺼냄.
	*/
	public ChatResponseDto getChatResponse(String userId, String userMessage) {
		
		// 사용자의 이전 대화 내용 db에서 가져오기
		List<ChatMessage> previousMessages = chatMessageRepository.findByUserIdOrderByCreateAtAsc(userId); 
		
		// 대화 이력을 stream 으로 변환
		List<Message> history = previousMessages.stream()
				.map(cm -> cm.isUser() ? new UserMessage(cm.getContent())
									   : new AssistantMessage(cm.getContent()))
				.collect(Collectors.toList());
		
		
		// 시스템 프롬프트 메세지 + 사용자 메세지 + 기존 대화내용
		List<Message> conversion = new ArrayList<>();
		conversion.add(new SystemPromptTemplate(systemPrompt).createMessage());
		conversion.addAll(history);
		conversion.add(new UserMessage(userMessage));
		
		
		Prompt prompt = new Prompt(conversion);
		
		// 모델 옵션(창의성 증가)
		OpenAiChatOptions options = OpenAiChatOptions.builder()
		        .temperature(0.9)   // 높을수록 다양(0.7~1.2 범위 권장)
		        .topP(0.9)          // 확률 다양화
		        .maxTokens(200)     // 적당히 제어 가능
		        .build();

		// AI 응답 호출 시 옵션 포함
		ChatResponse response = chatClient.prompt(prompt)
		        .options(options)
		        .call()
		        .chatResponse();
		
		String aiResponseMessage = response.getResult().getOutput().getText(); // ai: 당신이 요청한 그림은...
		
		// 현재 질문(true)과 답변(false) 저장
		chatMessageRepository.save(new ChatMessage(userId, userMessage, true));
		chatMessageRepository.save(new ChatMessage(userId, aiResponseMessage, false));
		
		
		// json 구조로 변경해서 메세지 리스트 생성
		List<ChatMessageDto> messages = new ArrayList<>();
		
		for (ChatMessage msg : previousMessages) {
			String sender = msg.isUser() ? "user":"ai";
			String content = msg.getContent();
			
			messages.add(new ChatMessageDto(sender, content));
		}
		
		// 현재 메세지
		messages.add(new ChatMessageDto("user", userMessage));
		messages.add(new ChatMessageDto("ai", aiResponseMessage));
		return new ChatResponseDto(messages);	
	}
}

Controller 설명

단일 엔드포인트 /chat을 제공하며,
Service에서 만들어진 응답을 DTO로 감싸서 반환한다.

controller

package kr.or.kosa.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import kr.or.kosa.dto.ChatRequestDto;
import kr.or.kosa.dto.ChatResponseDto;
import kr.or.kosa.service.CustomerSupportService;

@RestController
public class ChatController {

    private final CustomerSupportService service;

    public ChatController(CustomerSupportService service) {
        this.service = service;
    }

    @PostMapping("/chat")
    public ChatResponseDto chat(@RequestBody ChatRequestDto request) {
        String responseMessage = service.getChatResponse(
                request.getUserId(),
                request.getMessage()
        );
        return new ChatResponseDto(responseMessage);
    }
}

Postman 테스트

기본 요청

모델옵션 설정 전 성격 없는 기본 응답

모델 옵션과 프롬프트를 업그레이드 한 응답

이전 내용을 기억하는 모습


리액트로 화면 구성하기

스프링에는 cors설정을 해줘야한다.

WebConfig class 생성

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:5173",
                        "http://192.168.2.29:5173")
                .allowedMethods("GET","POST","PUT","DELETE","OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

프론트는 편의를 위해 app.jsx과 app.css 파일에 모든 기능을 다 담았다.

app.jsx

import { useState } from "react";
import "./App.css";

const itemTabs = [
  "불타는 대검",
  "얼어붙은 창",
  "폭풍의 활",
  "용가죽 갑옷",
  "수정 방패",
  "은빛 보호 투구",
  "힐링 포션",
  "마나 포션",
  "힘의 물약",
  "파이어볼 스크롤",
  "텔레포트 스크롤",
  "시간 감속 마법서",
  "하늘 민들레",
  "바다의 조개껍질",
  "고대 뼛조각",
  "행운의 반지",
  "시간여행 시계",
  "백엔드 개발자의 펜",
];

function App() {
  const [messages, setMessages] = useState([]);

  // 메시지 전송 함수
  const sendMessage = async (text) => {
    // 사용자 메시지 먼저 화면에 표시
    setMessages((prev) => [...prev, { sender: "user", text }]);

    try {
      const res = await fetch("http://192.168.2.29:8090/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          userId: "kim",
          message: text,
        }),
      });

      // ▼ 응답이 JSON인지 확인
      const data = await res.json();

      if (data?.response) {
        setMessages(data.response); // 서버에서 내려온 전체 메시지로 갱신
      } else {
        setMessages((prev) => [
          ...prev,
          { sender: "ai", text: "응답 형식이 올바르지 않습니다." },
        ]);
      }
    } catch (error) {
      console.error("Fetch Error:", error);

      setMessages((prev) => [
        ...prev,
        { sender: "ai", text: "서버와 통신할 수 없습니다." },
      ]);
    }
  };

  return (
    <div className="shop-wrapper">
      <h2 className="shop-title">용사마트 챗봇</h2>
      <p className="shop-sub">아이템을 선택해보세요:</p>

      {/* 아이템 탭 */}
      <div className="item-tab-container">
        {itemTabs.map((item, i) => (
          <button
            key={i}
            className="item-tab"
            onClick={() => sendMessage(item)}
          >
            {item}
          </button>
        ))}
      </div>

      {/* 채팅 영역 */}
      <div className="chat-container">
        {messages.map((m, i) => (
          <div key={i} className={`chat-bubble ${m.sender}`}>
            {m.text}
          </div>
        ))}
      </div>

      {/* 입력창 */}
      <div className="input-panel">
        <input
          placeholder="메시지를 입력하세요…"
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              sendMessage(e.target.value);
              e.target.value = "";
            }
          }}
        />
        <button>전송</button>
      </div>
    </div>
  );
}

export default App;

h2 데이터베이스 확인

profile
백엔드 개발자의 노트

0개의 댓글