이번 강의는 무엇을 노리고 있을까?
Spring Boot 계층 구조 위에 AI를 얹고, 프롬프트로 출력을 제어하고, RAG로 근거를 붙이고, Tool로 실제 행동을 수행한 뒤에, 보안과 메모리, 운영지표 관리해보자
이번 강의는 무엇을 노리고 있을까?
Spring Boot 계층 구조 위에 AI를 얹고, 프롬프트로 출력을 제어하고, RAG로 근거를 붙이고, Tool로 실제 행동을 수행한 뒤에, 보안과 메모리, 운영지표 관리해보자
문서 사전 처리
문서
-> DocumentReader
-> Splitter
-> Chunk
-> EmbeddingModel
-> VectorStore
사용자 요청 처리
사용자
-> Controller
-> Service
-> ChatClient
-> Advisor 체인
-> Safety
-> Memory
-> RAG
-> Audit
-> Token Meter
-> ChatModel
-> 문서가 필요하면 RAG
-> 실시간 행동이 필요하면 Tool
-> 구조화된 DTO
-> 사용자 응답
운영
토큰 측정 지연 시간 측정 오류율 측정 감사 로그 폴백 모델
공부하기 전에 알아놓을 개념
1. LLM은 기억하거나 시실을 조회하거나 프로그램이 아니다.
LLM은 입력을 읽고 다음 토큰을 확률적으로 생성하는 모델
따라서 다음 특성이 생김
- 같은 질문에도 답이 조금 달라짐
- 모르는 내용을 그럴듯하게 만들 수 있다.
- 대화 내요을 스스로 기억하지 않는다
- 이전 대화를 기억시키려면 매번 대화 이력을 다시 넣어야 한다
- 입력과 출력의 길이, 비용, 속도는 토큰을 기준으로 결정된다.
- 컨텍스트 윈도우를 넘으면 오래된 내용이 잘리거나 사용할 수 없게 된다.
Memory는 진짜 기억이 아니라, 이전 대화를 저장했다가 다시 프롬프트에 넣는 기능
RAG는 진짜 학습이 아니라, 외부 문서를 찾아 프롬프트에 넣는 기능
2. AI도 기존 Spring Boot 계층 구조 안에 넣어야 한다.
기본 역할은 다음과 같다.
| 계층 | 책임 |
|---|---|
| Controller | HTTP 요청 수신, 입력 검증, 응답 반환 |
| Service | 업무 흐름, 정책 판단, AI 호출 조합 |
| Repository 또는 Mapper | DB와 외부 시스템 접근 |
| DTO | 계층 간 요청과 응답 형식 |
| Config | ChatClient, Advisor, VectorStore 같은 빈 구성 |
| Advisor | 로깅, 안전, 메모리, RAG 같은 공통 처리 |
가장 중요한 규칙은 Controller가 ChatClient를 직접 호출하지 않는 것.
Controller는 AI가 어떤 모델을 사용하는지, 어떤 프롬프트를 사용하는지, RAG가 붙는지 알 필요가 없다. Controller는 Service만 호출하고,Service가 적절한 시점에 ChatClient를 호출해야 한다.
모델이나 프롬프트가 바뀌어도 웹 계층이 영향을 받지 않도록 설계해야 한다.
자료 역시 AI 코드를 Config, Advisor, Service로 분산하고, Controller는 AI 모르게 하도록 정리해야 한다.
2 Spring AI의 중심은 추상화
- 추상화? : 필요한 기능만 보여주는 것 (메서드로 구현한다.)
| 추상화 | 역할 |
|---|---|
| ChatModel | 프롬프트를 받아 텍스트를 생성 |
| EmbeddingModel | 텍스트를 의미 벡터로 변환 |
| VectorStore | 벡터를 저장하고 유사한 문서를 검색 |
실제로도 ChatClient를 주로 사용한다.
String answer = chatClient.prompt()
.system("너는 상담 도우미다.")
.user(question)
.call()
.content();
ChatClient는 단순한 모델 호출 이외에도 다음 기능을 제공
- 시스템 프롬프트
- 호출 옵션
- Advisor 연결
- Tool 연결
- 구조화 출력
- 스트리밍
- 응답 메타데이터
세 추상화를 잘 결합하면 채팅, 검색, RAG, 문서 검색, 에이전트 기능으로 확장할 수 있다.
또한 애플리케이션 코드는 추상화에 의존하고, 공급자는 Gradle 의존성과 application.yml 설정으로 교체하도록 설계
3. ChatClient는 용도별로 나누는 것이 좋다.
하나의 ChatClient로 요약, 분류, 창작, 상담을 모두 처리하면 설정이 충돌한다.
따라서 아래와 같이 나누는 것이 좋다.
extractClient
목적 : 분류, 추출
temperature : 0
규칙 : 추측 금지, 없으면 null
supportClient
목적 : 상담
temperature : 0.2
규칙 : 친절한 문장
ideaClient
목적 : 아이디어 생성
temperature : 0.8
규칙 : 다양한 아이디어
온도는 모델의 지식향을 조절하는 값이 아니락, 출력의 무작위성을 조절
- 분류, 추출, 요약 : 낮은 온도
- 아이디어, 창작 : 높은 온도
또한 시스템 프롬프트, 옵션, Advisor는 호출할 때마다 적는 것이 아니라 ChatClient Bean의 기본값으로 구성하는 편이 일관성이 높다.
4. 프롬프트는 업무 지지서
종흔 프롬프트의 기본 요소는 네 가지
| 요소 | 질문 |
|---|---|
| 역할 | 누구의 관점에서 답하는가 |
| 맥락 | 어떤 자료와 상황을 참고하는가 |
| 지시 | 정확히 무엇을 해야 하는가 |
| 형식 | 어떤 모양으로 출력해야 하는가 |
역할
너는 사내 고객 문의 분류기다.
[맥락]
문의 카테고리는 결제, 배송, 환불, 기타 네 가지다.
[지시]
사용자 문의를 가장 적절한 카테고리로 분류하라.
확신이 없으면 기타로 분류하라.
[형식]
카테고리 이름 하나만 출력하라.
-> 업무 지시는 이렇게 하는 게 좋음
말로 설명하기 어려운 경우에는
FewShot 예시를 넣도록
입력: 카드 결제가 두 번 됐어요
출력: 결제
입력: 택배가 아직 안 왔어요
출력: 배송
프롬프트가 계속 길어진다면 지시를 계속 추가하기보다 작업을 Router, Chaining, Evaluator 단계로 나눌 수 있다. 자료는 정확도가 부족할 때 프롬프트를 계속 늘리지 말고 호출을 테스트 가능한 단계로 분리하라고 강조
5. 문자열보다는 구조화 출력을 사용해야 한다.
String result = chatClient.prompt()
.user(question)
.call()
.content();
String[] values = result.split(",");
모델이 쉼표를 빼거나 설명을 추가하면 깨질 수 있으므로 일단 형식으로 정리
enum Category {
BILLING, DELIVERY, REFUND, ETC
}
record Ticket(
Category category,
int priority,
List<String> keywords
) {}
- 여기서 알고 가기 : record = 여러 데이터를 하나로 묶어 전달하기 위한 간결한 불변 데이터 객체
그리고 객체로 받는다.
Ticket ticket = chatClient.prompt()
.user(question)
.call()
.entity(Ticket.class);
- 문자열 파싱 코드가 사라진다.
- 필드 타입이 보장된다.
- enum으로 허용값을 제한할 수 있다.
- 목록과 중첩 객체를 그대로 받을 수 있다.
- 형식 위반을 조용히 통과시키지 않고 실패시킬 수 있다.
다만 모델 출력은 항상 성공하지 못하므로, 자료 온도 0, 형식 재요청, 안전한 기본값의 세 단계를 준비해야 함.
6. 스트리밍은 총시간보다 체감시간을 줄인다
call()은 응답이 완성될 때까지 기다린 뒤 한 번에 반환한다.
String answer = chatClient.prompt()
.user(question)
.call()
.content();
stream()은 생성되는 조각을 바로 반환한다.
Flux<String> answer = chatClient.prompt()
.user(question)
.stream()
.content();
Stream()을 사용하면 전체 생성 시간이 같더라도, 더 빨리 출력되기 때문에 사용자는 빠르다고 느낀다.
다만 스트리밍에는 반드시 다음 처리가 필요하다.
- 클라이언트 연결 취소
- 전체 타임아웃
- 오류 시 대체 메시지
- 스트림 종료 처리
- 스트리밍 경로의 별도 계측
7. 임베딩은 의미를 좌표로 바꾸는 과정
임베딩은 문장을 숫자 벡터로 변환하는 것
"강아지"
→ [0.12, -0.54, 0.87, ...]
"반려견"
→ [0.14, -0.51, 0.84, ...]
뜻이 비슷한 문장은 벡터 공간에사도 가깝다.
VectorStore는 다음을 수행
문서 저장:
문서 -> 임베딩 -> 벡터 저장
질문 검색
질문 -> 임베딩 -> 가까운 문서 벡터 검색
임베딩 모델을 바꾸면 기존 벡터를 그대로 사용할 수 없다. -> 차원이 같더라도 의미 공간이 달라질 수 있으므로 전체 문서를 다시 임베딩하고 재색인
8. Chunking은 RAG 품질을 결정하는 핵심 설계
Chunking은 긴 문서를 검색하기 좋은 크기로 나누는 작업
너무 작으면
- 앞뒤 맥락이 잘린다,
- 주어가 무엇인지 알 수 없다.
- 조항과 예외 조건이 분리된다.
너무 크면 :
- 질문과 관련 없는 내용까지 함께 검색된다.
- 잡음이 늘어난다.
- 모델에 넣는 토큰이 증가한다.
Overlap은 인접 청그가 일부 문장을 공유하게 하여 경계에서 근거가 잘리는 것을 막는다.
자료에서는 일반적인 시작값으로 800에서 1200자, 겹침 10에서 20퍼센트를 제시
질문 하나에 답할 만한 분량인가
PDF 178페이지의 청킹 표 역시 문서의 구조와 용도에 따라 크기를 바꾸도록 설명
청크에는 처음부터 다음 메타데이터 넣을 필요 있음
- 출처
- 페이지
- 문서 종류
- 부서
- 버전
- 유효기간
- 접근 권한
메타데이터가 있어야 출처 표시, 권한 필터, 만료 문서 제외, 최신 버전 선택이 가능.
9. RAG는 검색과 생성의 결합
RAG는 두 개의 파이프라인으로 구분
사전 인덱싱
문서
→ Reader
→ Splitter
→ Chunk
→ Embedding
→ VectorStore
질문별 검색과 생성
질문
→ 질문 임베딩
→ 관련 청크 검색
→ 질문과 근거를 프롬프트에 결합
→ 모델 응답
→ 출처 반환
RAG 파이프라인은 사전 준비 단계인 Indexing과 질문마다 수행되는 Retrieval을 명확히 분리
RAG 답변이 틀렸을 때는 다음 순서로 확인해야 한다.
- 관련 문서가 실제로 저장되어 있는가
- 검색 결과에 정답 청크가 포함되는가
- 정답 청크가 상위에 배치되는가
- 메타데이터 필터가 잘못 걸리지 않았는가
- 그다음에 프롬프트와 모델을 확인
검색 결과에 정답이 없다면 프롬프트를 아무리 고쳐도 답은 좋아지지 않는다.
자료도 품질 문제가 발생하면 모델의 답보다 검색 결과를 먼저 눈으로 확인해야 한다.
RAG 고급 전략
| 문제 | 방법 |
|---|---|
| 질문과 문서의 표현이 너무 다름 | HyDE |
| 질문이 짧거나 모호함 | 질의 변환 |
| 정답 문서가 검색되지 않음 | 다중 질의 확장 |
| 정답 문서가 검색됐지만 순서가 낮음 | 재순위 |
| 제품 코드처럼 정확한 문자열이 중요함 | Hybrid Search |
| 검색은 작게, 문맥은 크게 넣고 싶음 | Parent-Child |
| 검색 결과가 부족하면 다시 찾고 싶음 | Agentic RAG |
넓게 검색하고, 재순위한 뒤, 좁게 모델에 넣는다.
예를 들어 20개를 검색하고 재순위한 뒤 상위 4개만 프롬프트에 넣으면 검색 재현율은 유지하면서 잡음과 토큰을 줄일 수 있다.
RAG 평가는 검색과 생성을 나누어서 측정한다.
| 단계 | 지표 |
|---|---|
| 검색 | Recall@k |
| 검색 | Precision@k |
| 생성 | Faithfulness |
| 생성 | Answer Relevancy |
검색 실패와 생성 실패를 한 점수로 뭉치면 어느 부분을 알 수 없기 때문
10. Tool Calling은 모델이 실행하는 것이 아님
판단은 모델이 하고, 실행은 우리 코드가 함
모델은 Tool의 이름, 설명, 파라미터 스키마를 보고 어떤 Tool을 호출할지 결정한다. 실제 DB 조회, API 요청, 파일 변경은 Spring 애플리케이션의 메서드가 수행한다.
모델은 Tool의 이름, 설명, 파라미터 스키마를 보고 어떤 Tool을 호출할지 결정
실제 DB 조회, API 요청, 파일 변경은 Spring 애플리케이션의 메서드가 수행
@Tool(description = "주문번호로 배송 상태를 조회한다")
String orderStatus(
@ToolParam(description = "주문번호") String orderId,
ToolContext context
) {
String userId = context.getContext().get("userId").toString();
return orderService.findOwned(orderId, userId);
}
Tool 설계에서 중요한 점은 다음과 같다.
- 설명을 명확하게 작성한다.
- 하나의 Tool은 하나의 책임만 가진다.
- 입력값을 검증한다.
- 권한을 Tool 내부에서 검사한다.
- 실패를 모델이 이해할 수 있는 메시지로 반환한다.
- DB Entity 전체가 아니라 필요한 정보만 반환한다.
- 사용자 ID 같은 보안 정보는 프롬프트가 아니라 ToolContext로 전달
11. gent는 똑똑한 존재가 아니라 반복 구조
Agent는 다음 과정을 반복한다.
Reason
-> Act
-> Observe
-> 다시 Reason
Tool calling이 한 번의 도구 사용이면, Agent는 결과를 보고 도구를 다시 선택하는 여러 단계의 Tool Calling
반드시 제한해야 할 것은
- 최대 반복 횟수
- 최대 토큰
- 최대 실행 시간
- 동일 Tool과 동일 인자 반복
- Tool별 재시도 횟수
- 전체 비용 예산
자료는 Agent를 만들 때 잘 수행하는 방법보다, 언제 멈출 지 잘 설정하는 게 더 중요하다. (우리의 토큰은 무한하지 않으니까....ㅋㅋㅋ 무한한 토큰이 있다면 시도해보시길)
환불, 삭제, 발송, 취소같이 한 번 실행하면 되돌리기 어려운 작업은 모델이 완료하게 하면 안된다. 일단은 Tool은 승인 요청만 하고, 실제 처리는 승인 이후에 실행하는 HITL 구조가 좀 필요하다.
12. MCP는 Tool 연결의 표준 규격
MCP는 AI 애플리케이션과 외부 Tool 또는 Resource를 연결하는 표준 프로토콜
MCP Client
-> 외부 MCP Server의 Tool을 사용
MCP Server
-> 우리 시스템의 Tool을 다른 AI 애플리케이션에 공개
번 MCP 서버로 만들면 여러 AI 애플리케이션이 같은 Tool을 공유할 수 있다. 다만 원격 실행 통로가 될 수 있으므로 인증과 공개 범위 통제가 필요
MCP는 Spring AI 기초를 배우는 단계에서 가장 먼저 익혀야 할 개념은 아니다. 기본 Tool Calling이 안정된 뒤 Tool 수가 많아지고 다른 애플리케이션과 공유해야 할 때 배우는 것이 적절
13. Advisor는 모든 AI 요청이 지나가는 파이프라인
Advisor는 AOP나 인터셉터와 비슷한 개념
모든 모델 요청에 공통으로 필요한 처리를 한 곳에 모은다.
- 안전 필터
- 대화 메모리
- RAG 근거 주입
- 감사 로그
- 토큰 계측
- 지연 시간 측정
- 응답 후처리
권장 흐름
요청:
Audit
→ Safety
→ Memory
→ RAG
→ Logger
→ Model
응답:
Model
→ Logger
→ RAG
→ Memory
→ Safety
→ Audit
Safety가 Memory 뒤에 있다면, 차단해야 할 공격 문장이 이미 대화 이략에 저장될 수 있음 따라서 안전 필터는 메모리 저장보다 앞에 있어야 한다.
14. Memory는 저장소와 보존 정책까지 포함하는 개념
ChatMemory는 이전 메시지를 저장하고 새 요청에 다시 주입
개발 단계에서는 InMemory를 사용할 수 있지만, 운영에서는 다음을 고려해야 한다.
| 저장소 | 적합한 상황 |
|---|---|
| InMemory | 로컬 개발, 테스트 |
| JDBC | 일반적인 영속 운영 |
| Redis | 다중 인스턴스, 짧은 TTL |
| Cassandra | 초대량 장기 저장 |
대화가 길어지면 모든 메시지를 계속 넣을 수 없으므로 다음 전략이 필요
최근 N개 메시지만 유지 오래된 대화를 요약 중요 결정만 별도 저장 대화 보존 기간 설정 사용자별 삭제 기능 개인정보 마스킹
대화 이력에는 개인정보가 빠르게 쌓이므로 저장소를 선택할 때 보존 기간과 삭제 절차도 동시에 설계해야 한다. (정말 고려해야 할 것들이 많다!)
15. 운영에서는 모델 품질만 보면 안 되고, 다른 것들도 같이 고려해야 한다.
운영 지표는 최소한 다음은 측정해야 한다.
- 입력 토큰
- 출력 토큰
- 총 토큰
- 첫 토큰까지의 시간
- 전체 응답 시간
- RAG 검색 시간
- Tool 호출 횟수
- Tool 실패율
- 모델 오류율
- 풀백 발생 횟수
AI 호출은 외부 의존성이므로 주 공급자가 실패하더라도 서비스 전체가 죽지 않도록 다음 폴백을 준비해야 한다.
주 모델 실패
-> 보조 모델
-> 캐시
-> 정형 응답
-> 최소 기능 응답
요약
반드시 머릿속에 있어야 하는 개념
| 익힐 것 | 이해 완료 기준 |
|---|---|
| Controller, Service, Repository 역할 | ChatClient가 왜 Service에 있어야 하는지 설명할 수 있다 |
| LLM의 확률성과 무상태성 | Memory가 왜 이력을 다시 넣는 기능인지 설명할 수 있다 |
| ChatModel과 ChatClient 차이 | 실무에서 ChatClient를 쓰는 이유를 말할 수 있다 |
| Embedding과 VectorStore | 키워드가 달라도 검색되는 이유를 설명할 수 있다 |
| RAG의 두 단계 | Indexing과 Retrieval을 구분할 수 있다 |
| Tool Calling | 판단은 모델, 실행은 코드라고 설명할 수 있다 |
| Advisor | 순서가 왜 보안 정책이 되는지 설명할 수 있다 |
| Agent | 반복 횟수와 비용 상한이 필요한 이유를 설명할 수 있다 |
반드시 한 번은 직접 구현해야 하는 것
(이거는 주말에 한 번 해보면 될 듯하다.)
| 구현할 것 | 완료 기준 |
|---|---|
| 용도별 ChatClient 빈 | 요약용과 창작용의 옵션을 다르게 설정 |
| 구조화 출력 | entity()로 record와 enum 반환 |
| SSE 스트리밍 | stream()에 timeout과 cancel 처리 |
| RAG 인제스트 | 문서 읽기, 청킹, 임베딩, VectorStore 적재 |
| RAG 검색 | topK, threshold, metadata filter 적용 |
| 출처 반환 | 답변과 함께 문서명 또는 페이지 반환 |
| Tool | @Tool, @ToolParam, ToolContext 적용 |
| 승인 게이트 | 환불이나 삭제를 실행하지 않고 승인 요청으로 접수 |
| Advisor 체인 | Safety가 Memory보다 앞에 오도록 구성 |
| 운영 지표 | 토큰, 지연, Tool 호출을 Micrometer로 측정 |
기본 RAG와 Tool이 완성된 뒤 배우기
(다음 포스팅에서 제공할 예정)
HyDE Agentic RAG Parent-Child Retrieval Contextual Retrieval Multi-Agent MCP Server 공급자별 고유 옵션 복잡한 Evaluator-Optimizer 루프
효율적인 학습 순서
1. 기본 AI API 만들기
Controller
→ Service
→ ChatClient
→ 문자열 또는 DTO 반환
- Controller에 ChatClient가 없다.
- ChatClient는 Config에서 빈으로 생성된다.
- API Key는 환경변수로 주입된다.
- 예외가 안전한 응답으로 변환된다.
2. 출력 품질과 형식 제어
- 역할, 맥락, 지시, 형식이 있는 프롬프트 작성
- Few-shot 추가
- temperature 실험
- entity() 구조화 출력
- stream() SSE 출력
완료 기준:
- 문자열 파싱 코드가 없다.
- 같은 분류 입력에 결과가 안정적이다.
- 스트리밍 취소와 타임아웃이 동작한다.
3. RAG 완성
문서
→ Chunk
→ Embedding
→ VectorStore
→ 검색
→ 출처가 있는 답변
- 검색 결과를 별도 API로 확인할 수 있다.
- 검색된 청크의 출처와 점수가 보인다.
- 문서에 없는 질문에는 모른다고 답한다.
- 문서 재색인 시 중복 청크가 쌓이지 않는다.
4. Tool과 운영 완성
목표: 실시간 주문 조회 Tool 사용자 권한 검증 환불 승인 게이트 Memory와 Advisor 토큰과 지연 계측 폴백 응답 완료 기준: 다른 사용자의 데이터를 조회할 수 없다. 위험 작업은 즉시 실행되지 않는다. Safety가 Memory보다 먼저 실행된다. 모델 장애가 발생해도 기본 서비스는 유지된다
COMMENTS
GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 댓글은 GitHub Discussions에 공개 저장되며, 작성 내용과 GitHub 프로필 정보가 다른 방문자에게 보일 수 있습니다.