[Spring AI] Spring AI로 Notion MCP 챗봇 구현하기
by 우와한개발자
1. 전체 구조
사용자 (HTML)
↓
Spring Boot 챗봇 서버 (WebFlux)
↓
OpenAI (gpt-4o-mini)
↓
Docker Notion MCP 서버 (port 3000)
↓
Notion Workspace
2. 사전 준비
1) Notion Integration Token 발급
- app.notion.com/developers/connections 접속
- "신규 연결" 클릭
- 권한 설정 (읽기 / 업데이트 / 제거 등)
- 허용할 페이지 지정
- ntn_xxxxxx 형태의 토큰 발급
2) 시스템 환경변수 등록
(1) Windows
- 윈도우 검색 → "시스템 환경 변수 편집" 검색
- 환경 변수 버튼 클릭
- 사용자 변수 → 새로 만들기
- 변수 이름: OPENAI_API_KEY / 변수 값: sk-xxxxxx
- 변수 이름: NOTION_TOKEN / 변수 값: ntn_xxxxxx
- 변수 이름: MY_NOTION_AUTH_TOKEN / 변수 값: 임의설정값
- 확인 → IDE 완전 종료 후 재시작
(2) Mac
- 설정파일 열기
open ~/.zshrc 또는
vim ~/.zshrc 로 설정파일 열기
- 환경변수 추가하기
export OPENAI_API_KEY=sk-xxxxxx
export NOTION_TOKEN=ntn_xxxxxx
export MY_NOTION_AUTH_TOKEN=임의설정값
- 추가한 환경변수 즉시 적용하기
source ~/.zshrc
- .zshrc : Mac 터미널이 시작될 때 자동으로 실행되는 설정 파일
| 파일 | 쉘 | 설명 |
| ~/.zshrc | zsh | Mac 기본 (Catalina 이후) |
| ~/.bash_profile | bash | Mac 구버전 |
| ~/.bashrc | bash | Linux |
3. Notion MCP 서버 (Docker)
1) docker-compose.yml
services:
notion-mcp:
image: node:trixie
working_dir: /app
command: >
sh -lc "npx -y @notionhq/notion-mcp-server --transport http --port 3000"
environment:
NOTION_TOKEN: ${NOTION_TOKEN}
AUTH_TOKEN: ${MY_NOTION_AUTH_TOKEN}
ports:
- "3000:3000"
restart: unless-stopped
- image: node:trixie → Notion MCP 서버는 Node.js 기반이라 Node 이미지 사용
- command → Notion 공식 MCP 서버 패키지 실행
- -transport http → HTTP 방식으로 통신 (SSE 포함)
- -port 3000 → 3000번 포트로 서버 실행
- NOTION_TOKEN → Notion API 접근용 토큰 (시스템 환경변수에서 주입)
- AUTH_TOKEN → MCP 서버 자체 접근 제어용 토큰 (시스템 환경변수에서 주입)
- ports: 3000:3000 → 호스트 3000번 ↔ 컨테이너 3000번 포트 연결
- restart: unless-stopped → 컨테이너가 꺼지면 자동 재시작 (수동 종료 제외)
2) 실행
docker compose up -d
4. Spring Boot 프로젝트 설정
1) build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.6'
id 'io.spring.dependency-management' version '1.1.7'
}
ext {
set('springAiVersion', "2.0.0-RC1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux' // 비동기 웹 서버
implementation 'org.springframework.ai:spring-ai-starter-mcp-client-webflux' // MCP 클라이언트
implementation 'org.springframework.ai:spring-ai-starter-model-openai' // OpenAI 모델
implementation 'io.modelcontextprotocol.sdk:mcp-core:2.0.0-RC1' // MCP 프로토콜 SDK
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
}
}
- webflux → AI 응답 대기 시간이 길어서 비동기 논블로킹 방식 사용
- mcp-client-webflux → Docker MCP 서버와 통신하는 클라이언트
- model-openai → OpenAI로 답변 생성
- mcp-core → MCP 프로토콜 SDK
2) application.properties
# OpenAI 설정
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.model=gpt-4o-mini # 빠르고 저렴한 모델
spring.ai.openai.chat.temperature=0.1 # 낮을수록 일관된 답변
spring.ai.openai.chat.max-completion-tokens=512
# Notion MCP 서버 설정
notion.mcp.base-url=http://localhost:3000
notion.mcp.auth-token=${MY_NOTION_AUTH_TOKEN}
# 자동 Tool Callback 비활성화 → 개발자 정의 빈만 사용 (충돌 방지)
spring.ai.mcp.client.toolcallback.enabled=false
5. 코드 구성
1) NotionMcpConfig.java - MCP 서버 연결 설정
@Configuration
public class NotionMcpConfig {
// 앱 종료 시 MCP 서버 연결 안전하게 닫음
@Bean(destroyMethod = "closeGracefully")
McpSyncClient notionMcpClient(
@Value("${notion.mcp.base-url}") String baseUrl,
@Value("${notion.mcp.auth-token}") String authToken
) {
HttpRequest.Builder rb = HttpRequest.newBuilder();
// MCP 서버 접근용 AUTH_TOKEN 인증
rb.setHeader("Authorization", "Bearer " + authToken)
// JSON 또는 SSE 방식 모두 수용
rb.setHeader("Accept", "application/json, text/event-stream");
var transport = HttpClientStreamableHttpTransport.builder(baseUrl)
.endpoint("/mcp") // MCP 서버 엔드포인트
.requestBuilder(rb)
.build();
var client = McpClient.sync(transport).build(); // 동기 방식 MCP 클라이언트 생성
client.initialize(); // MCP 서버와 실제 핸드셰이크 연결 (앱 시작 시 연결 검증)
return client;
}
// toolcallback.enabled=false 로 자동 등록 빈을 끄고 이 빈을 직접 사용 (충돌 방지)
@Bean(name = "notionMcpToolCallbacks")
ToolCallbackProvider notionMcpToolCallbacks(McpSyncClient notionMcpClient) {
return SyncMcpToolCallbackProvider.builder()
.mcpClients(notionMcpClient) // MCP 클라이언트를 Tool Callback으로 래핑
.build();
}
}
2) ChatConfig.java - ChatClient 빈 설정
@Configuration
public class ChatConfig {
@Bean
ChatClient chatClient(
OpenAiChatModel chatModel, // OpenAI 채팅 모델
ChatMemory chatMemory, // 대화 내역 저장소 (userId별 메모리)
ToolCallbackProvider notionMcpToolCallbacks) { // Notion MCP Tool
return ChatClient.builder(chatModel)
// 대화 내역을 자동으로 컨텍스트에 포함
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build())
// ToolCallbackProvider를 통째로 전달 (.defaultToolCallbacks()와 달리 배열 변환 불필요)
.defaultTools(notionMcpToolCallbacks)
.build();
}
}
3) ChatService.java - 스트리밍 채팅 로직
@Service
@RequiredArgsConstructor
public class ChatService {
private final ChatClient chatClient;
public Flux<String> chatMemoryStream(String userInput, String userId) {
return chatClient.prompt()
.user(userInput)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId)) // userId별 대화 내역 분리 저장
.stream()
.content() // OpenAI 응답을 스트리밍으로 수신
.map(this::preserveSseLeadingSpace) // SSE 공백 처리
.concatWith(Flux.just("[[END]]")); // 스트리밍 종료 신호를 프론트에 전달
}
// SSE 특성상 공백으로 시작하는 청크가 유실되는 것을 방지
private String preserveSseLeadingSpace(String chunk) {
if (chunk == null) return "";
return chunk.startsWith(" ") ? " " + chunk : chunk;
}
}
4) ChatController.java - API 엔드포인트
@RestController
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
private final ChatMemory chatMemory; // 대화 내역 직접 조회용
// SSE 방식으로 스트리밍 응답 전송
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> memoryStream(
@RequestParam("id") String id, // userId (대화 메모리 구분용)
@RequestParam("q") String q) { // 사용자 질문
return chatService.chatMemoryStream(q, id)
.map(chunk -> ServerSentEvent.builder(chunk).build()); // 청크를 SSE 형식으로 변환
}
// 해당 유저의 전체 대화 내역 반환
@GetMapping("/chat/history")
public List<ChatMsgDto> history(@RequestParam("id") String conversationId) {
return chatMemory.get(conversationId).stream()
.map(m -> new ChatMsgDto(
m.getMessageType().name(), // USER / ASSISTANT / SYSTEM
m.getText()
))
.toList();
}
// role(USER/ASSISTANT)과 content로 분리해서 프론트에 전달
public record ChatMsgDto(String role, String content) {}
}
6. 전체 흐름
사용자 → 메시지 입력
↓
GET /chat/stream?id=user01&q=질문
↓
ChatController → ChatService
↓
ChatClient → MessageChatMemoryAdvisor (대화 내역 컨텍스트 포함)
↓
OpenAI (gpt-4o-mini) → Tool Call 필요 판단
↓
notionMcpToolCallbacks → HttpClientStreamableHttpTransport
↓
Docker Notion MCP 서버 (localhost:3000/mcp)
↓
Notion API → 데이터 반환
↓
Tool Callback → OpenAI가 Notion 데이터로 답변 생성
↓
Flux<String> SSE 스트리밍 → [[END]] 신호로 종료
↓
사용자
| 항목 | 선택 | 이유 |
| WebFlux | 비동기 논블로킹 | AI 응답 대기 시간이 길어서 |
| McpSyncClient | 동기 MCP 클라이언트 | MCP 통신 자체는 동기 처리 |
| temperature=0.1 | 낮은 창의성 | Notion 데이터 기반 일관된 답변 |
| toolcallback.enabled=false | 자동 빈 비활성화 | 개발자 정의 빈과 충돌 방지 |
| destroyMethod=closeGracefully | 안전한 종료 | 연결 누수 방지 |
| [[END]] 신호 | 스트리밍 종료 감지 | SSE는 자동 종료 감지가 불안정해서 |
| ChatMemory | 대화 내역 관리 | userId별 대화 컨텍스트 유지 |
| 환경변수 | 키 관리 | 파일에 민감 정보 미노출 |
'AI > Spring AI' 카테고리의 다른 글
블로그의 정보
우와한개발자 님의 블로그
우와한개발자