2026. 5. 12. 20:48ㆍ대우개발원 수업 내용/spring기반 ai
VisionController 클래스 추가

package com.example.springai1.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/vision")
public class VisionController {
private ChatClient chatClient;
public VisionController(ChatClient.Builder builder) {
chatClient = builder.build();
}
//GET 요청 : /vision
// 단순히 vision.html 페이지를 반환
@GetMapping
public String visionPage() {
return "vision"; //templates/vision.html 랜더링
}
}
vision.html
추가
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이미지 업로드</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- marked.js를 최신 버전으로 추가 -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
pre {
white-space: pre-wrap; /* 줄바꿈을 허용 */
word-wrap: break-word; /* 긴 단어도 줄바꿈 */
overflow-x: auto; /* 가로 스크롤을 필요 시 추가 */
}
</style>
</head>
<body class="bg-gray-100">
<div class="max-w-xl mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">이미지 업로드</h1>
<form id="uploadForm" class="flex flex-col space-y-4">
<input type="file" id="fileInput" name="file" accept="image/*" required class="border border-gray-300 p-2 rounded-md">
<!-- 이미지 미리보기 -->
<div id="uploadedImage" class="mt-4 hidden">
<h2 class="text-xl font-semibold mb-2">이미지:</h2>
<div class="p-4 bg-gray-50 border border-gray-300 rounded-md">
<img id="imagePreview" alt="업로드된 이미지" class="w-full h-auto">
</div>
</div>
<textarea id="messageInput" name="message" class="border border-gray-300 p-2 rounded-md" rows="3" placeholder="이미지 분석해줘"></textarea>
<button type="submit" class="bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600">
✨ 분석 ✨
</button>
</form>
<!-- 로딩 스피너 -->
<div id="loadingSpinner" class="mt-6 hidden">
<div class="flex items-center justify-center">
<svg class="animate-spin h-8 w-8 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="ml-2 text-blue-500">처리 중...</span>
</div>
</div>
<!-- 분석 결과 표시 -->
<div id="resultSection" class="mt-6 hidden">
<h2 class="text-xl font-semibold mb-2">분석 결과:</h2>
<div id="resultText" class="p-4 bg-gray-50 border border-gray-300 rounded-md" style="word-wrap: break-word; overflow-wrap: break-word;"></div>
</div>
<!-- 오류 메시지 표시 -->
<div id="errorSection" class="mt-6 hidden">
<p id="errorMessage" class="p-4 bg-red-100 border border-red-300 text-red-700 rounded-md"></p>
</div>
</div>
<script>
// 이미지 미리보기를 위한 FileReader 사용
document.getElementById("fileInput").addEventListener("change", function(event) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById("uploadedImage").classList.remove("hidden");
document.getElementById("imagePreview").src = e.target.result;
};
if (file) {
reader.readAsDataURL(file);
}
});
// 업로드 폼을 비동기로 처리하고 로딩 표시
document.getElementById("uploadForm").addEventListener("submit", function(event) {
event.preventDefault();
// 로딩 스피너 표시
document.getElementById("loadingSpinner").classList.remove("hidden");
const fileInput = document.getElementById("fileInput");
const messageInput = document.getElementById("messageInput");
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("message", messageInput.value);
fetch("/vision/upload", {
method: "POST",
body: formData
})
.then(response => response.json())
.then(data => {
// 로딩 스피너 숨기기
document.getElementById("loadingSpinner").classList.add("hidden");
// 결과가 있으면 분석 결과를 마크다운으로 표시
if (data.result) {
document.getElementById("resultSection").classList.remove("hidden");
document.getElementById("resultText").innerHTML = marked.parse(data.result); // marked.parse로 변경
}
// 오류가 발생한 경우
if (data.error) {
document.getElementById("errorSection").classList.remove("hidden");
document.getElementById("errorMessage").textContent = data.error;
}
})
.catch(error => {
document.getElementById("loadingSpinner").classList.add("hidden");
document.getElementById("errorSection").classList.remove("hidden");
document.getElementById("errorMessage").textContent = "파일 업로드 중 오류가 발생했습니다.";
});
});
</script>
</body>
</html>
build.gradle
뒤에 타임리프 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
VisionController 코드에 추가
// POST 요청 : /upload
// 파일 + 메세지를 받아서 AI에게 이미지 분석 요청
@PostMapping("/upload")
@ResponseBody // JSON형태로 응답 반환
public ResponseEntity<Map<String, String>> vision(
@RequestParam("file")MultipartFile file, // 업로드된 이미지 파일
@RequestParam("message") String message // 사용자 요청 메세지
) {
// 응답 데이터를 담을 Map
Map<String, String> response = new HashMap<>();
try {
// 메세지가 없으면 기본값 설정
String userMessage = (message == null || message.isEmpty()) ? "이미지 분석해줘" : message;
// AI 모델 호출
String result = chatClient.prompt()
.user(promptUserSpec -> promptUserSpec
// 텍스트 프롬프트 (사용자 요청)
.text(userMessage)
// 이미지 파일을 함께 전달 (멀티 모달 입력)
.media(MimeType.valueOf(Objects.requireNonNull(file.getContentType())), file.getResource())
)
.call() // AI 호출
.content(); // 결과 텍스트 반환
// 결과를 JSON 형태로 저장
response.put("result", result);
// HTTP 200 OK로 응답
return ResponseEntity.ok(response);
} catch (MaxUploadSizeExceededException e) {
// 업로드 파일 크기 초과 예외 처리4
response.put("error", "업로드한 파일이 너무 큽니다. 최대 10MB 파일만 업로드 가능합니다.");
return ResponseEntity
.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(response);
} catch (Exception e) {
// 기타 예외 처리
response.put("error", "파일 업로드 중 오류가 발생했습니다.");
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(response);
}
}
실행하면
http://localhost:8080/vision
로 접속하면 정상적으로 ai가 이미지를 분석한 결과를 알려준다.
SqlController 하기 위한 단계

3개의 파일을
resources 폴더 안에 넣음

SqlController 생성

package com.example.springai1.controller;
import com.example.springai1.dto.SqlResponse;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource; // 수정: jakarta 대신 org.springframework.core.io.Resource 사용
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException; // 추가: getContentAsString을 위해 필요
import java.nio.charset.Charset;
import java.sql.SQLException;
import java.util.List;
@RestController // REST API 컨트롤러로 등록
@RequestMapping("/sql")
public class SqlController {
//classpath에 있는 schema.sql 파일을 읽어오는 리소스 객체
@Value("classpath:/schema.sql") private Resource ddlResource;
// SQL 생성용 프롬프트 템플릿 파일을 읽어오는 리소스
@Value("classpath:/sql-prompt-template.st") private Resource sqlPromptTemplateResource;
// AI 모델과 통신하기 위한 ChatClient
private final ChatClient aiClient; // 오타 수정: aiClinet -> aiClient
// DB에 SQL을 실행하기 위한 JdbcTemplate;
private final JdbcTemplate jdbcTemplate;
public SqlController(ChatClient.Builder aiClientBuilder, JdbcTemplate jdbcTemplate) { // 오타 수정
// ChatClient 빌드
this.aiClient = aiClientBuilder.build();
// JdbcTemplate 주입
this.jdbcTemplate = jdbcTemplate;
}
// GET 요청 처리: /sql?q=질문
@GetMapping
public SqlResponse sql(@RequestParam(name = "q") String question) throws IOException {
// schema.sql 파일 내용을 문자열로 읽어옴
String schema = ddlResource.getContentAsString(Charset.defaultCharset());
// AI에게 SQL 생성 요청
String query = aiClient.prompt()
.user(userSpec -> userSpec
// 프롬프트 템플릿 적용
.text(sqlPromptTemplateResource)
// 사용자 질문을 템플릿에 바인딩
.param("question", question)
// DB 스키마를 템플릿에 바인딩
.param("ddl", schema)
)
.call() // AI 호출
.content(); // 생성된 SQL 문자열 반환
// 생성된 쿼리가 SELECT 문인지 확인
if (query.toLowerCase().startsWith("select")) {
// SELECT일 경우 실제 DB 실행 후 결과 반환
return new SqlResponse(
query,
jdbcTemplate.queryForList(query) // 결과 리스트 반환
);
}
// SELECT가 아닌경우 (INSERT, UPDATE 등) 결과 없이 쿼리만 반환
return new SqlResponse(query, List.of());
}
}
SqlResponse 생성

package com.example.springai1.dto;
import java.util.List;
import java.util.Map;
// class -> record로 수정 (괄호로 인자를 받는 것은 record 문법입니다)
public record SqlResponse(String sqlQuery, List<Map<String, Object>> results) {
}
실행하면
http://localhost:8080/h2-console/login.jsp?jsessionid=275645a8a589b957d4d1d56e3adc49c0
[ RAG형: VectorStore + Advisor ]
사내 문서, 매뉴얼, 정책, 논문 요약처럼 모델이 원래 모르는 지식을 붙이고 싶을 때는 RAG 패턴을 사용합니다.
Spring AI에서는 VectorStore에 문서를 넣고, 질의 시 QuestionAnswerAdvisor가 관련 문서를 검색해 프롬프트에 합쳐 줍니다.
다음은 공식 문서가 제시하는 구현 흐름입니다.
String answer = ChatClient.builder(chatModel)
.build()
.prompt()
.advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
.user(question)
.call().content();
[ Tool Calling형: 외부 API/DB/서비스 실행 ] ToolController2, ToolController1
모델이 직접 처리하기 어려운 계산이나 최신 정보가 필요한 경우, 외부 도구를 호출해 해결함
예를 들면 날씨 API 조회, 사내 주문 조회, DB 검색, 일정 확인 같은 경우
문서상 핵심은 실행권한은 앱이 쥐고 있고 모델은 요청만 한다는 점
ChatOptions options = ToolCallingChatOptions.builder()
.toolNames("currentWeather")
.build();
Prompt prompt = new Prompt("서울 날씨 알려줘", options);
chatModel.call(prompt);
실무적으로는 이 패턴이 “에이전트처럼 보이는 앱”의 핵심
다만 외부 도구 호출이 들어가므로 권한, 입력 검증, 시간 제한, 로깅을 앱 쪽에서 꼭 관리해야 함
ToolController1 생성
package com.example.springai1.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("news")
public class ToolController1 {
private final ChatClient chatClient;
public ToolController1(ChatClient.Builder builder) {this.chatClient = builder.build();}
@GetMapping
public String showNewsPage() {
return "news";
}
@PostMapping
@ResponseBody
public String getNews(@RequestParam("request") String request) {
return chatClient.prompt()
.system("당신은 뉴스 전문가입니다. 사용자의 질문에서 주제를 영문으로 추출하여" +
"최신 뉴스를 제공합니다. 최종 결과물은 한글로 작성합니다.")
.tools(new HackNewsTools())
.user(request)
.call()
.content();
}
}
news.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>News Search</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
pre {
white-space: pre-wrap;
word-wrap: break-word;
overflow-x: auto;
}
.spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 4px solid #3498db;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col items-center justify-center">
<div class="w-full max-w-xl p-6 bg-white rounded-lg shadow-lg">
<h1 class="text-4xl font-bold text-center mb-6 text-gray-800">해커뉴스</h1>
<div class="flex items-center bg-gray-100 rounded-lg shadow-md p-3">
<input id="newsQuery" type="text" placeholder="Search for news..."
class="flex-grow bg-transparent text-lg focus:outline-none placeholder-gray-400 text-gray-700 p-2"/>
<button id="searchBtn" class="ml-4 bg-blue-500 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded-lg transition duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-400">
검색
</button>
</div>
<div id="loading" class="hidden flex justify-center mt-6">
<div class="spinner"></div>
</div>
<div id="newsResult" class="mt-6 space-y-4">
<!-- 결과가 여기 표시됩니다. -->
</div>
</div>
<script>
document.getElementById("searchBtn").addEventListener("click", function () {
const query = document.getElementById("newsQuery").value;
const newsResult = document.getElementById("newsResult");
const loading = document.getElementById("loading");
// 검색 결과 영역 및 로딩 애니메이션 초기화
newsResult.innerHTML = '';
loading.classList.remove("hidden");
// 비동기 요청 처리
fetch('/news', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
request: query
})
})
.then(response => response.text())
.then(data => {
// 로딩 애니메이션 숨기기
loading.classList.add("hidden");
// 마크다운 텍스트를 HTML로 변환하여 표시
newsResult.innerHTML = marked.parse(data);
})
.catch(error => {
loading.classList.add("hidden");
newsResult.innerHTML = `<p class="text-red-500">Error loading news: ${error}</p>`;
});
});
</script>
</body>
</html>
service 패키지 추가
HackNewsTools 클래스 추가
package com.example.springai1.service;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
// 전 세계의 개발자, 창업가, 기술 애호가들이 모여 컴퓨터 과학, 스타트업, 그리고 다양한 지적 주제에 대해 토론을 나누는 커뮤니티
public class HackNewsTools {
private final RestTemplate restTemplate = new RestTemplate();
@Tool
public Response apply(Request request) {
System.out.println("모델이 HackNewsTools의 apply()를 필요로 하나봐요~~~");
String query = request.query();
List<String> articles = fetchNewsArticles(query);
String summary = summarizeArticles(articles);
return new Response(summary);
}
private List<String> fetchNewsArticles(String query) {
System.out.println("Fetching news articles for query: " + query);
String url = "https://hn.algolia.com/api/v1/search?query={query}&tags=story";
Map<String, Object> response = restTemplate.getForObject(url, Map.class, query);
List<String> articles = new ArrayList<>();
List<Map<String, Object>> hits = (List<Map<String, Object>>) response.get("hits");
if (hits != null) {
for (Map<String, Object> hit : hits) {
String title = (String) hit.get("title");
String articleUrl = (String) hit.get("url");
articles.add(title + "\n" + (articleUrl != null ? articleUrl : ""));
}
}
return articles;
}
private String summarizeArticles(List<String> articles) {
if (articles.isEmpty()) {
return "해당 주제에 대한 기사를 찾을 수 없습니다.";
}
StringBuilder content = new StringBuilder("다음은 요청하신 주제에 대한 최신 뉴스입니다:\n");
for (String article : articles) {
content.append("- ").append(article).append("\n");
}
return content.toString();
}
public record Request(String query, Integer limit) {}
public record Response(String summary) {}
}
실행하면
http://localhost:8080/news
DateTimeTools 날씨 정보를 알려주는 툴 추가

package com.example.springai1.service;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.context.i18n.LocaleContextHolder;
import java.time.LocalDateTime;
public class DateTimeTools {
// 이 어노테이션만 있으면 Tool로 생성
@Tool(description = "Get the current date and time in the user's timezone") // Tool기능 메서드. ToolController2 에서 호출
String getCurrentDateTime() {
System.out.println("모델이 getCurrentDateTime() 를 필요로 하나봐요~~~");
return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
}
}
ToolController2 생성

package com.example.springai1.controller;
import com.example.springai1.service.DateTimeTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/date")
public class ToolController2 {
private final ChatClient chatClient;
public ToolController2(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping("/ko")
public String toll1() {
return chatClient.prompt("내일을 몇일일까?")
.tools(new DateTimeTools()) // tool을 생성해서 호출. 가변인자로 여러개 둘 사용 가능
.call()
.content();
}
@GetMapping("/en")
public String toll2() {
return chatClient.prompt("What day is tomorrow")
.tools(new DateTimeTools()) // tool을 생성해서 호출. 가변인자로 여러개 둘 사용 가능
.call()
.content();
}
}
실행하면
'대우개발원 수업 내용 > spring기반 ai' 카테고리의 다른 글
| spring ai 9일차 / 다중 llm, springai5 (0) | 2026.05.19 |
|---|---|
| spring ai 6,7,8일차 / , springai4 (0) | 2026.05.18 |
| spring ai 3,4일차 / springaigroq groq, springai1 (0) | 2026.05.11 |
| spring ai 2일차 / springaiollama (1) | 2026.05.07 |
| spring ai 1일차 / springai00 멀티 모델 통합 챗봇 환경 구축 (0) | 2026.05.06 |








