우와한 개발자

[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별 대화 컨텍스트 유지
환경변수 키 관리 파일에 민감 정보 미노출

블로그의 정보

우와한개발자 님의 블로그

우와한개발자

활동하기