
LLM기본적인 AI를 스트리밍 응답 기능과 개인 도메인 지식 베이스(RAG)를 갖춘 지능형 대화형 애플리케이션으로 점진적으로 구축해 왔습니다.
하지만 RAG는 강력하지만, 수동 유지 관리가 필요한 정적 지식 베이스에 의존합니다.
사용자가 "오늘의 최신 주식 시장 상황은 어떻습니까?" 또는 "새롭게 출시된 휴대폰에 대한 의견은 어떠십니까?"와 같은 질문을 할 때, 저희 AI는 정보 지연으로 인해 여전히 응답하지 못합니다.
이러한 장벽을 극복하기 위해 온라인 검색 기능을 강화해야 합니다.

SearXNG사용자 개인 정보를 보호하면서 여러 검색 엔진(예: Google, Bing, Baidu)의 결과를 통합할 수 있는 고도로 맞춤화된 메타 검색 엔진입니다.
Docker를 사용하여 자체 SearXNG인스턴스를 빠르게 배포할 것입니다.
먼저 SearXNGDocker Hub에서 최신 이미지를 가져옵니다.
docker pull searxng/searxng:latest
다음으로, 컨테이너를 실행합니다. 실제 상황에 맞게 로컬 설정 파일의 매핑 디렉터리를 수정해야 합니다.
docker run -p 6080:8080 --name searxng -d --restart=always -v "D:\devolop\SearXNG:/etc/searxng" -e "BASE_URL=http://localhost:6080/" -e "INSTANCE_NAME=lee-instance" searxng/searxng:latest
-p 6080:8080: 컨테이너 내부의 포트를 8080호스트 머신의 포트에 매핑하여 6080쉽게 접근할 수 있도록 합니다.
--name searxng: 컨테이너에 대한 친근한 이름을 지정합니다.
-v "D:\devolop\SearXNG:/etc/searxng": 중요한 단계입니다! 컨테이너의 구성 파일 디렉터리를 /etc/searxng로컬 D:\devolop\SearXNG디렉터리에 마운트합니다. 이렇게 하면 컨테이너에 들어가지 않고도 로컬에서 직접 구성 파일을 수정할 수 있습니다.
-e "BASE_URL=...": 서비스의 공개 접근 주소를 설정합니다.
컨테이너가 성공적으로 실행되면 검색 엔진이 준비됩니다.


이제 브라우저를 열면 기본 인터페이스가 http://localhost:6080표시됩니다 .SearXNG
SearXNG의 가장 큰 장점 중 하나는 높은 수준의 설정 가능성입니다.
오른쪽 상단의 "환경설정"을 클릭하여 활성화할 검색 엔진을 자유롭게 선택하세요.
네트워크 환경을 고려하여 검색 기능을 안정적으로 사용하려면 Baidu, Bing, Sogou 등을 활성화하고 Google은 비활성화하는 것이 좋습니다.

구성이 완료되면 검색 기능을 테스트해 보겠습니다.

Spring Boot 나중에 애플리케이션이 API를 통해 호출할 수 있도록 하려면 SearXNG명시적으로 . 형식의 출력을 허용해야 합니다
json. 이전에 로컬로 마운트한 설정 파일(여기서는 . )을 열고 -> 를 D:\devolop\SearXNG\settings.yml찾아 아래에 추가합니다
.searchformats- json

수정 및 저장 후에는 컨테이너를 다시 시작해야 합니다 SearXNG. docker이것으로 환경 준비가 완료됩니다.
통합 SearXNG과정은 매우 간단하며 기본적으로 HTTP 요청을 통해 검색 API를 호출하는 것을 포함합니다.
먼저, 요청을 전송하는 데 도움이 되는 HTTP 클라이언트 라이브러리가 필요합니다. 여기서는 OkHttp안정적이고 사용하기 쉬운 이 라이브러리를 선택했습니다.
pom.xml. 에 종속성을 추가합니다 .
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
그런 다음 구성 클래스를 만들고 OkHttpClient이를 Spring Bean으로 등록하여 프로젝트에서 주입하고 재사용할 수 있도록 합니다.
package com.sleekydz86.searchai.global.config;
import okhttp3.OkHttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.concurrent.TimeUnit;
@Configuration
public class OkHttpConfig implements WebMvcConfigurer {
@Bean
public OkHttpClient okHttpClient() {
return new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
}
}
다음 서비스에 대한 관련 구성을 application.yml추가합니다 .SearXNG
internet:
websearch:
url: http://localhost:6080/search
counts: 25 # 검색 결과의 최대 개수 (너무 많으면 응답이 느려질 수 있음)
반환된 JSON 응답을 Java 객체에 매핑 하려면 SearXNG해당 엔터티 클래스를 만들어야 합니다
package com.sleekydz86.searchai.global.beans;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SearchResult {
private String title;
private String url;
private String content;
private Double score;
private String engine;
private String category;
private String publishedDate;
}
package com.sleekydz86.searchai.global.beans;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.List;
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class SearXNGResponse {
private String query;
private List<SearchResult> results;
}
이제 SearXNGAPI를 호출하기 위한 핵심 로직을 작성해 보겠습니다.
package com.sleekydz86.searchai.service.impl;
import cn.hutool.json.JSONUtil;
import com.sleekydz86.searchai.global.beans.SearXNGResponse;
import com.sleekydz86.searchai.global.beans.SearchResult;
import com.sleekydz86.searchai.service.SearXngService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; // Slf4j 불러오기
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody; // ResponseBody 불러오기
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j // Slf4j 어노테이션 추가
public class SearXngServiceImpl implements SearXngService {
@Value("${internet.websearch.url}")
private String SEARXNG_URL;
@Value("${internet.websearch.counts}")
private Integer SEARXNG_COUNTS;
private final OkHttpClient okHttpClient;
@Override
public List<SearchResult> search(String query) {
HttpUrl url = HttpUrl.get(SEARXNG_URL)
.newBuilder()
.addQueryParameter("q", query)
.addQueryParameter("format", "json")
.build();
Request request = new Request.Builder()
.url(url)
.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.addHeader("Accept", "application/json")
.build();
log.info("SearXNG에 요청을 보내는 중: {}", url);
try (Response response = okHttpClient.newCall(request).execute()) {
// --- 핵심: 상세한 오류 정보 제공 ---
if (!response.isSuccessful()) {
String errorBody = "응답 본문을 가져올 수 없습니다";
try (ResponseBody body = response.body()) {
if (body != null) {
errorBody = body.string();
}
} catch (IOException e) {
log.error("SearXNG 오류 응답 본문 읽기 실패", e);
}
// 상태 코드와 응답 본문이 포함된 상세한 예외 발생
throw new RuntimeException(String.format(
"SearXNG 요청 실패. 상태 코드: %d, URL: %s, 응답 본문: %s",
response.code(), url, errorBody));
}
ResponseBody body = response.body();
if (body != null) {
String responseBody = body.string();
// 디버깅을 위해 반환된 JSON 내용을 로그에 추가
log.debug("SearXNG 응답 내용: {}", responseBody);
SearXNGResponse searXNGResponse = JSONUtil.toBean(responseBody, SearXNGResponse.class);
if (searXNGResponse != null && searXNGResponse.getResults() != null) {
return dealResult(searXNGResponse.getResults());
} else {
log.warn("SearXNG에서 반환된 JSON을 파싱할 수 없거나 결과가 비어 있습니다. 응답: {}", responseBody);
return Collections.emptyList();
}
}
} catch (IOException e) {
// 네트워크 연결과 관련된 IO 예외에 대해서도 더 상세한 로그 제공
log.error("SearXNG 요청 중 네트워크 IO 예외 발생, URL: {}", url, e);
throw new RuntimeException("SearXNG 요청 중 네트워크 IO 예외 발생", e);
}
return Collections.emptyList();
}
private List<SearchResult> dealResult(List<SearchResult> results) {
if (results.isEmpty()) {
return Collections.emptyList();
}
// 참고: 기존의 subList와 parallelStream 조합은 results 수가 SEARXNG_COUNTS보다 작을 경우 문제가 될 수
// 있습니다.
// 먼저 limit를 적용한 후 정렬하는 것이 더 안전하고 효율적입니다.
return results.stream()
.limit(SEARXNG_COUNTS)
.sorted(Comparator.comparingDouble(SearchResult::getScore).reversed())
.collect(Collectors.toList());
}
}
이제 기존 채팅 로직에 "인터넷 검색"을 새로운 대화 모드로 통합해야 합니다.
먼저, ChatMode모든 대화 모드를 관리하는 열거형을 정의합니다.
package com.sleekydz86.searchai.global.enums;
public enum ChatMode {
/**
* 직접 대화 모드
*/
DIRECT,
/**
* 업로드된 문서를 기반으로 하는 지식 베이스 모드 (RAG)
*/
KNOWLEDGE_BASE,
/**
* 인터넷 검색 모드
*/
INTERNET_SEARCH
}
이전의 부울 값 대신 열거형을 ChatEntity사용하도록 요청 엔터티를 업데이트합니다 .ChatMode
package com.sleekydz86.searchai.global.beans;
import com.sleekydz86.searchai.global.enums.ChatMode;
import lombok.Data;
@Data
public class ChatEntity {
private String currentUserName;
private String message;
private String botMsgId;
private ChatMode mode;
}
마지막이자 가장 중요한 단계는 다양한 로직을 실행할 수 있도록 변환하는 것 ChatServiceImpl입니다 ChatMode.
package com.sleekydz86.searchai.service.impl;
import com.sleekydz86.searchai.global.beans.ChatEntity;
import com.sleekydz86.searchai.global.beans.Document;
import com.sleekydz86.searchai.global.beans.SearchResult;
import com.sleekydz86.searchai.global.enums.ChatMode;
import com.sleekydz86.searchai.global.enums.SSEMsgType;
import com.sleekydz86.searchai.service.ChatService;
import com.sleekydz86.searchai.service.DocumentService;
import com.sleekydz86.searchai.service.SearXngService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Slf4j
public class ChatServiceImpl implements ChatService {
private final ChatClient chatClient;
@Resource
private DocumentService documentService;
@Resource
private SearXngService searXngService;
private static final String RAG_PROMPT_TEMPLATE = """
아래 제공된 컨텍스트 지식 베이스 내용을 기반으로 사용자의 질문에 답변해 주세요.
규칙:
1. 답변 시 컨텍스트 정보를 충분히 활용하되, "컨텍스트에 따르면", "지식 베이스에 따르면"과 같은 문구를 직접 언급하지 마세요.
2. 컨텍스트에 질문에 답할 충분한 정보가 없다면, "기존 지식으로는 이 질문에 답변할 수 없습니다."라고 명확하게 알려주세요.
3. 답변은 직접적이고, 명확하며, 관련성이 있어야 합니다.
【컨텍스트】
{context}
【질문】
{question}
""";
// 인터넷 검색 모드용 프롬프트 템플릿
private static final String INTERNET_SEARCH_PROMPT_TEMPLATE = """
당신은 실시간 인터넷 검색 능력을 갖춘 스마트 비서입니다. 아래 제공된 최신 인터넷 검색 결과를 바탕으로 사용자의 질문에 답변해 주세요.
규칙:
1. 모든 검색 결과를 종합적으로 분석하여 사용자에게 포괄적이고 정확하며 일관된 답변을 제공하세요.
2. 답변에서 "검색 결과에 따르면..."과 같은 문구를 직접 인용하지 말고, 자연스럽게 문장을 구성하세요.
3. 검색 결과에 충분한 정보가 없다면, "현재 검색 결과로는 질문에 대한 정확한 정보를 찾을 수 없습니다."라고 솔직하게 알려주세요.
4. 답변은 간결하고 요점을 명확히 해야 합니다.
【인터넷 검색 결과】
{context}
【사용자 질문】
{question}
""";
public ChatServiceImpl(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@Override
public void streamChat(ChatEntity chatEntity) {
String userId = chatEntity.getCurrentUserName();
String question = chatEntity.getMessage();
// 프런트엔드에서 전달된 모드를 가져오고, 없으면 기본적으로 직접 대화 모드로 설정
ChatMode mode = chatEntity.getMode() != null ? chatEntity.getMode() : ChatMode.DIRECT;
Prompt prompt;
// 모드에 따라 다른 로직을 선택
switch (mode) {
case KNOWLEDGE_BASE:
log.info("【사용자: {}】가 【지식 베이스 모드】로 질문합니다.", userId);
prompt = createRagPrompt(question);
break;
case INTERNET_SEARCH:
log.info("【사용자: {}】가 【인터넷 검색 모드】로 질문합니다.", userId);
prompt = createInternetSearchPrompt(question);
break;
case DIRECT:
default:
log.info("【사용자: {}】가 【직접 대화 모드】로 질문합니다.", userId);
prompt = new Prompt(question);
break;
}
Flux<String> stream = chatClient.prompt(prompt).stream().content();
stream.doOnError(throwable -> {
log.error("【사용자: {}】의 AI 스트림 처리 중 오류 발생: {}", userId, throwable.getMessage(), throwable);
SSEServer.sendMsg(userId, "죄송합니다, 서비스에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.", SSEMsgType.FINISH);
SSEServer.close(userId);
})
.subscribe(
content -> SSEServer.sendMsg(userId, content, SSEMsgType.ADD),
error -> log.error("【사용자: {}】의 스트림 구독 최종 실패: {}", userId, error.getMessage()),
() -> {
log.info("【사용자: {}】의 스트림이 성공적으로 종료되었습니다.", userId);
SSEServer.sendMsg(userId, "done", SSEMsgType.FINISH);
SSEServer.close(userId);
}
);
}
/**
* RAG (지식 베이스) 모드용 프롬프트 생성
*/
private Prompt createRagPrompt(String question) {
List<Document> relatedDocs = documentService.doSearch(question);
String context = "관련된 지식 베이스 정보를 찾지 못했습니다.";
if (!CollectionUtils.isEmpty(relatedDocs)) {
context = relatedDocs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n---\n"));
}
String promptContent = RAG_PROMPT_TEMPLATE
.replace("{context}", context)
.replace("{question}", question);
return new Prompt(promptContent);
}
/**
* 인터넷 검색 모드용 프롬프트 생성
*/
private Prompt createInternetSearchPrompt(String question) {
// 1. 인터넷 검색 실행
List<SearchResult> searchResults = searXngService.search(question);
String context = "유효한 인터넷 검색 결과를 가져오지 못했습니다.";
// 2. 컨텍스트 구성
if (!CollectionUtils.isEmpty(searchResults)) {
// 검색 결과를 명확한 컨텍스트 텍스트로 형식화
context = searchResults.stream()
.map(result -> String.format("【출처 제목】: %s\n【내용 요약】: %s\n【링크】: %s",
result.getTitle(),
result.getContent(),
result.getUrl()))
.collect(Collectors.joining("\n\n---\n\n"));
}
// 3. 프롬프트 생성
String promptContent = INTERNET_SEARCH_PROMPT_TEMPLATE
.replace("{context}", context)
.replace("{question}", question);
return new Prompt(promptContent);
}
}
마지막으로 프런트엔드 페이지를 변환하고 원래의 "지식 기반" 스위치를 세 가지 모드가 있는 드롭다운 선택 상자로 업그레이드해야 합니다.
<!DOCTYPE html>
<html lang="ko-KR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RAG & 인터넷 검색 강화 스트리밍 대화</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f4f7f9;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.chat-container {
width: 90%;
max-width: 800px;
height: 90vh;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.chat-header {
background-color: #4a90e2;
color: white;
padding: 16px;
font-size: 1.2em;
text-align: center;
font-weight: bold;
}
.chat-messages {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 15px;
}
.message {
padding: 12px 18px;
border-radius: 18px;
max-width: 75%;
line-height: 1.5;
word-wrap: break-word;
}
.user-message {
background-color: #dcf8c6;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.bot-message {
background-color: #e9e9eb;
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.chat-input-area {
display: flex;
padding: 15px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
align-items: center;
}
#message-input {
flex-grow: 1;
padding: 12px;
border: 1px solid #ccc;
border-radius: 20px;
resize: none;
font-size: 1em;
margin-right: 10px;
}
#send-button {
padding: 12px 25px;
border: none;
background-color: #4a90e2;
color: white;
border-radius: 20px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s;
flex-shrink: 0;
}
#send-button:disabled {
background-color: #a0c7ff;
cursor: not-allowed;
}
#upload-button {
width: 44px;
height: 44px;
border: 1px solid #ccc;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
margin-right: 10px;
background-color: #fff;
transition: background-color 0.3s;
flex-shrink: 0;
}
#upload-button:hover {
background-color: #f0f0f0;
}
#upload-button svg {
width: 20px;
height: 20px;
fill: #555;
}
#upload-button.loading {
cursor: not-allowed;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#file-input {
display: none;
}
#toast-notification {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 12px 25px;
border-radius: 8px;
color: white;
font-weight: bold;
opacity: 0;
visibility: hidden;
transition: opacity 0.5s, visibility 0.5s;
z-index: 1000;
}
#toast-notification.show {
opacity: 1;
visibility: visible;
}
#toast-notification.success {
background-color: #28a745;
}
#toast-notification.error {
background-color: #dc3545;
}
/* --- 채팅 모드 선택기 스타일 --- */
.chat-mode-selector {
position: relative;
margin-right: 10px;
}
.chat-mode-selector select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 20px;
padding: 10px 30px 10px 15px;
font-size: 0.9em;
color: #333;
cursor: pointer;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23007AFF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-position: right 10px top 50%;
background-size: .65em auto;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">RAG & 인터넷 검색 강화 스트리밍 대화</div>
<div class="chat-messages" id="chat-messages">
</div>
<div id="toast-notification"></div>
<div class="chat-input-area">
<label for="file-input" id="upload-button" title="지식 문서 업로드">
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M761.6 364.8c-12.8-12.8-32-12.8-44.8 0l-160 160c-12.8 12.8-12.8 32 0 44.8 12.8 12.8 32 12.8 44.8 0l102.4-102.4v300.8c0 19.2 12.8 32 32 32s32-12.8 32-32V467.2l102.4 102.4c12.8 12.8 32 12.8 44.8 0 12.8-12.8 12.8-32 0-44.8L761.6 364.8zM896 896H128V128h448c19.2 0 32-12.8 32-32s-12.8-32-32-32H128C64 64 0 128 0 192v704c0 64 64 128 128 128h768c64 0 128-64 128-128V448c0-19.2-12.8-32-32-32s-32 12.8-32 32v448z"></path></svg>
</label>
<input type="file" id="file-input" accept=".txt,.pdf,.md,.docx">
<div class="chat-mode-selector" title="대화 모드 선택">
<select id="chat-mode-select">
<option value="DIRECT">직접 대화</option>
<option value="KNOWLEDGE_BASE">지식 기반</option>
<option value="INTERNET_SEARCH">인터넷 검색</option>
</select>
</div>
<textarea id="message-input" placeholder="질문을 입력하세요..." rows="1"></textarea>
<button id="send-button">보내기</button>
</div>
</div>
<script>
// --- DOM 요소 가져오기 ---
const chatMessages = document.getElementById('chat-messages');
const messageInput = document.getElementById('message-input');
const sendButton = document.getElementById('send-button');
const fileInput = document.getElementById('file-input');
const uploadButton = document.getElementById('upload-button');
const toast = document.getElementById('toast-notification');
// 새로운 드롭다운 선택 상자 요소 가져오기
const chatModeSelect = document.getElementById('chat-mode-select');
// --- 핵심 변수 ---
const userId = 'user-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
let eventSource = null;
let currentBotMessageElement = null;
// --- 파일 업로드 로직 ---
async function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
uploadButton.classList.add('loading');
uploadButton.style.pointerEvents = 'none';
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/rag/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (response.ok && result.status === 200) {
showToast(`문서 "${file.name}" 업로드 성공!`, 'success');
// 스마트 전환: 업로드 성공 후, 자동으로 지식 기반 모드로 전환
chatModeSelect.value = 'KNOWLEDGE_BASE';
updatePlaceholder();
} else {
showToast(`업로드 실패: ${result.msg || '알 수 없는 오류'}`, 'error');
}
} catch (error) {
console.error('Upload failed:', error);
showToast('업로드 실패, 네트워크를 확인하거나 관리자에게 문의하세요.', 'error');
} finally {
uploadButton.classList.remove('loading');
uploadButton.style.pointerEvents = 'auto';
fileInput.value = '';
}
}
// --- Toast 알림 ---
function showToast(message, type = 'success') {
toast.textContent = message;
toast.className = 'show';
toast.classList.add(type);
setTimeout(() => {
toast.className = toast.className.replace('show', '');
}, 3000);
}
// --- SSE 연결 ---
function connectSSE() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource(`/sse/connect?userId=${userId}`);
eventSource.addEventListener('add', (event) => {
if (!currentBotMessageElement) {
currentBotMessageElement = createMessageElement('bot-message');
chatMessages.appendChild(currentBotMessageElement);
}
if (event.data && event.data.toLowerCase() !== 'null') {
currentBotMessageElement.innerHTML += event.data.replace(/\n/g, '<br>');
}
scrollToBottom();
});
eventSource.addEventListener('finish', (event) => {
console.log('Stream finished:', event.data);
currentBotMessageElement = null;
sendButton.disabled = false;
messageInput.disabled = false;
eventSource.close();
});
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
if (currentBotMessageElement) {
currentBotMessageElement.innerHTML += '<br><strong style="color: red;">연결이 끊어졌습니다. 다시 시도해 주세요.</strong>';
}
sendButton.disabled = false;
messageInput.disabled = false;
eventSource.close();
};
}
// --- 메시지 전송 (핵심) ---
async function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
displayUserMessage(message);
messageInput.value = '';
messageInput.focus();
sendButton.disabled = true;
messageInput.disabled = true;
connectSSE();
// 현재 선택된 모드 가져오기
const selectedMode = chatModeSelect.value;
try {
await fetch('/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// 모드 정보가 포함된 전체 JSON 객체 전송
body: JSON.stringify({
currentUserName: userId,
message: message,
mode: selectedMode // 백엔드에서 예상하는 필드는 'mode'
}),
});
} catch (error) {
console.error('Failed to send message:', error);
if(currentBotMessageElement) {
currentBotMessageElement.innerHTML += '<br><strong style="color: red;">메시지 전송 실패, 백엔드 서비스를 확인해 주세요.</strong>';
} else {
displayBotMessage('<strong style="color: red;">메시지 전송 실패, 백엔드 서비스를 확인해 주세요.</strong>');
}
sendButton.disabled = false;
messageInput.disabled = false;
}
}
// --- UI 보조 함수 ---
function createMessageElement(className, htmlContent = '') {
const div = document.createElement('div');
div.className = `message ${className}`;
div.innerHTML = htmlContent;
return div;
}
function displayUserMessage(message) {
const userMessageElement = createMessageElement('user-message', message);
chatMessages.appendChild(userMessageElement);
scrollToBottom();
}
function displayBotMessage(message) {
const botMessageElement = createMessageElement('bot-message', message);
chatMessages.appendChild(botMessageElement);
scrollToBottom();
}
function scrollToBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// 드롭다운 상태에 따라 입력 상자 힌트 업데이트
function updatePlaceholder() {
const selectedMode = chatModeSelect.value;
switch(selectedMode) {
case 'KNOWLEDGE_BASE':
messageInput.placeholder = '업로드된 지식 기반으로 질문하세요...';
break;
case 'INTERNET_SEARCH':
messageInput.placeholder = '인터넷 검색 후 답변해 드릴게요...';
break;
case 'DIRECT':
default:
messageInput.placeholder = '직접 대화하세요...';
break;
}
}
// --- 이벤트 바인딩 ---
sendButton.addEventListener('click', sendMessage);
messageInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
fileInput.addEventListener('change', handleFileUpload);
// 드롭다운에 change 이벤트 바인딩, placeholder 업데이트
chatModeSelect.addEventListener('change', updatePlaceholder);
// placeholder 초기화
updatePlaceholder();
</script>
</body>
</html>
모든 것이 준비되었습니다. 최종 결과를 테스트해 보겠습니다.
테스트 1: 일반 모드 먼저, "직접 대화" 모드에서 홍금보에 대한 정보를 요청했습니다.

예상대로 이 모델은 홍금보에 대한 관련 학습 데이터가 없기 때문에 블로거에 대해 아무것도 알지 못합니다.
테스트 2: 온라인 검색 모드 이제 온라인 검색 모드로 전환하여 같은 질문을 다시 해보세요.

성공했습니다!
먼저 SearXNG인터넷을 검색한 후, Spring AI검색 결과를 맥락으로 활용하여 정확하고 유창한 소개를 생성했습니다.
AI는 현실 세계와 진정으로 소통할 수 있는 능력을 갖추고 있습니다!
비공개로 구축된 메타 검색 엔진 SearXNG를 통합함으로써 저희 Spring AI 애플리케이션은 온라인 검색 기능을 성공적으로 확보했습니다.
이제 애플리케이션은 일반 대화, 비공개 지식 Q&A, 실시간 정보 수집 등 포괄적인 기능 시스템을 갖추게 되었습니다.
그러나 이러한 기능은 여전히 단일 형태의 대화형 상호작용으로 제한됩니다.
이러한 한계를 극복하고 AI가 외부 시스템과 더욱 긴밀하게 상호 작용할 수 있도록 다음 기사에서는 MCP(모델 컨텍스트 프로토콜)의 적용을 살펴보겠습니다.
이를 통해 AI와 외부 도구 통합의 새로운 장이 열릴 것입니다.