Backend
home
📩

SpringBoot와 WebSocket

생성 일시
2025/06/10 15:57
태그
SpringBoot
게시일
2025/06/11
최종 편집 일시
2025/06/11 08:51

1. WebSocket이란?

WebSocket은 하나의 TCP 접속 위에서 전이중(Full-duplex) 통신을 제공하는 프로토콜이다. 쉽게 말해, 클라이언트와 서버가 한 번 연결을 맺으면 서로 원할 때 언제든지 데이터를 주고받을 수 있는 실시간 양방향 통신 채널이다.

HTTP 통신과의 차이점

HTTP: 클라이언트가 요청(Request)을 보내야만 서버가 응답(Response)할 수 있는 단방향 구조이다. 실시간 통신을 흉내내기 위해 클라이언트가 주기적으로 서버에 요청을 보내는 폴링(Polling) 같은 기법을 사용해야 했지만, 이는 비효율적이며 지연이 발생한다.
WebSocket: 한번 연결이 수립되면 그 연결이 계속 유지되며, 서버가 클라이언트의 요청 없이도 데이터를 보낼 수 있다. 채팅, 실시간 알림, 주식 시세 업데이트 등 즉각적인 데이터 교환이 필요한 서비스에 매우 적합하다.

WebSocket 연결 과정 (Handshake)

WebSocket은 연결을 시작할 때 HTTP를 사용한다. 이 과정을 ‘핸드셰이크(handshake)’ 라고 부른다.
[WebSocket 핸드셰이크 과정] 1. 클라이언트 (브라우저) - 저 "WebSocket 사용하고 싶어요!" 라는 의미로 특정 헤더(Upgrade: websocket)를 담아 서버에 HTTP 요청 전송 GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade ... ------> (HTTP 요청) ------> 2. 서버 - 요청을 받고, "좋아요, 이제부터 이 연결은 WebSocket 입니다!" 라는 의미로 101 Switching Protocols 상태 코드로 응답 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade ... <------ (HTTP 응답) ------- 3. 연결 수립 완료 - 이제부터 클라이언트와 서버는 HTTP가 아닌 WebSocket 프로토콜을 통해 자유롭게 데이터를 주고 받음 클라이언트 <---- (메시지) ----> 서버
Plain Text
복사

2. Spring Boot에서 WebSocket 구현하기

Spring Boot는 spring-boot-starter-websocket 의존성 추가만으로 손쉽게 WebSocket 서버를 구현할 수 있는 환경을 제공한다.
(1) 의존성 추가 (build.gradle)
dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' // ... 기타 의존성 }
Plain Text
복사
(2) WebSocket 설정 (WebSocketConfig.java)
WebSocket 핸들러를 등록하고, 통신을 허용할 엔드포인트(URL)를 지정한다.
import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @Configuration @EnableWebSocket // WebSocket 서버 활성화 public class WebSocketConfig implements WebSocketConfigurer { private final ChatHandler chatHandler; public WebSocketConfig(ChatHandler chatHandler) { this.chatHandler = chatHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { // "/ws/chat" 경로로 WebSocket 연결을 허용하고, chatHandler를 통해 처리합니다. // setAllowedOrigins("*")는 모든 도메인에서의 접속을 허용합니다 (CORS). registry.addHandler(chatHandler, "/ws/chat").setAllowedOrigins("*"); } }
Java
복사
(3) WebSocket 핸들러 (ChatHandler.java)
실제 WebSocket 통신 로직을 담당하는 부분이다. 클라이언트로부터 메시지를 받거나, 연결이 수립/종료될 때 실행될 코드를 작성한다.
TextWebSocketHandler를 상속받아 텍스트 기반의 메시지를 처리한다.
import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.util.concurrent.ConcurrentHashMap; @Component public class ChatHandler extends TextWebSocketHandler { // 현재 연결된 세션들을 저장하는 맵 private final ConcurrentHashMap<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); // 클라이언트가 연결되었을 때 호출 @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.put(session.getId(), session); System.out.println(session.getId() + " 연결됨"); } // 클라이언트로부터 메시지를 받았을 때 호출 @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); System.out.println("수신된 메시지: " + payload); // 여기서는 간단히 받은 메시지를 모든 클라이언트에게 다시 전송 (방송) for (WebSocketSession s : sessions.values()) { s.sendMessage(new TextMessage("Echo: " + payload)); } } // 클라이언트 연결이 끊겼을 때 호출 @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session.getId()); System.out.println(session.getId() + " 연결 끊김"); } }
Java
복사
afterConnectionEstablished: 클라이언트가 접속하면 sessions 맵에 해당 클라이언트의 세션 정보를 저장한다.
handleTextMessage: 클라이언트가 메시지를 보내면, 그 내용을 받아 연결된 모든 세션에 다시 보내는 간단한 ‘에코’ 기능을 수행한다.
afterConnectionClosed: 클라이언트 접속이 끊기면 sessions 맵에서 제거한다.

3. 실시간 AI 챗봇 아키텍처

이제 AI 모델을 연동하는데 일반적인 AI 챗봇 API(e.g., OpenAI, Google Gemini 등)는 HTTP 기반으로 통신한다. 그러므로 Spring 서버가 중간 다리 역할을 해야 한다.
1. 사용자 (클라이언트) - "오늘 날씨 어때?" 메시지 전송 ---- (WebSocket) ---> 2. Spring Boot 서버 (ChatHandler) - WebSocket으로 메시지 수신 - 받은 메시지를 AI 서비스에 전달 ---- (HTTP API Call) ---> 3. 외부 AI 서비스 (e.g., Gemini, OpenAI) - "오늘 날씨 어때?" 질문 처리 - "오늘 서울은 맑고 화창합니다." 답변 생성 <--- (HTTP Response) ---- 4. Spring Boot 서버 - AI의 답변 수신 - 답변을 다시 WebSocket을 통해 원래 사용자에게 전송 <--- (WebSocket) ---- 5. 사용자 (클라이언트) - 화면에 AI의 답변 "오늘 서울은 맑고 화창합니다." 표시
Plain Text
복사
이 구조에서 Spring 서버는 클라이언트와 실시간 통신은 WebSocket 으로, AI 서비스와의 통신은 HTTP로 담당하는 ‘중개자’가 된다.

4. AI 챗봇 연동 전체 코드 예시

(1) AI 서비스 호출을 위한 ChatService.java 추가
AI API 호출 로직을 핸들러에서 분리하여 서비스 계층으로 만든다.
참고로 여기선 Gemini API를 호출한다고 가정한다.
import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.http.*; import java.util.Map; import java.util.Collections; @Service public class ChatService { // HTTP 요청을 보내기 위한 RestTemplate private final RestTemplate restTemplate = new RestTemplate(); // application.properties에서 API 키와 URL 주입 @Value("${gemini.api.key}") private String apiKey; @Value("${gemini.api.url}") private String apiUrl; public String getAiResponse(String userMessage) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); // 실제 API는 인증 방식이 다를 수 있습니다. (e.g., Bearer Token) // headers.set("Authorization", "Bearer " + apiKey); // Gemini API의 요청 형식에 맞게 body 구성 String requestBody = String.format( "{\"contents\":[{\"parts\":[{\"text\":\"%s\"}]}]}", userMessage ); // ?key=... 형태로 URL에 API 키 추가 String requestUrl = apiUrl + "?key=" + apiKey; HttpEntity<String> entity = new HttpEntity<>(requestBody, headers); try { ResponseEntity<Map> response = restTemplate.postForEntity(requestUrl, entity, Map.class); // 응답 구조가 복잡하므로, 실제 API 문서에 맞게 파싱해야 합니다. // 아래는 예시적인 파싱 경로입니다. if (response.getBody() != null && response.getBody().containsKey("candidates")) { Map<String, Object> candidate = ((java.util.List<Map>) response.getBody().get("candidates")).get(0); Map<String, Object> content = (Map<String, Object>) candidate.get("content"); Map<String, Object> part = ((java.util.List<Map>) content.get("parts")).get(0); return (String) part.get("text"); } return "AI 응답을 처리하는 중 오류가 발생했습니다."; } catch (Exception e) { e.printStackTrace(); return "AI 서비스 호출에 실패했습니다."; } } }
Java
복사
AI 서비스의 API키와 URL을 설정 파일에 추가한다.
# application.properties gemini.api.key=YOUR_GEMINI_API_KEY gemini.api.url=https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent
Plain Text
복사
주의: 실제 API 키를 코드나 설정 파일에 직접 노출하는 것은 보안상 좋지 않다. 환경 변수나 외부 설정 관리 도구를 사용하는 것이 좋다.
(3) ChatHandler.java 수정
기존 ChatHandlerChatService를 사용하도록 수정한다.
import org.springframework.stereotype.Component; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; // ... (다른 import문은 동일) @Component public class ChatHandler extends TextWebSocketHandler { private final ChatService chatService; // 세션 관리는 기존과 동일하게 유지할 수 있습니다. // private final ConcurrentHashMap<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); public ChatHandler(ChatService chatService) { this.chatService = chatService; } // afterConnectionEstablished, afterConnectionClosed는 동일 @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String userMessage = message.getPayload(); System.out.println(session.getId() + "로부터 받은 메시지: " + userMessage); // ChatService를 통해 AI 응답 받아오기 String aiResponse = chatService.getAiResponse(userMessage); System.out.println("AI 응답: " + aiResponse); // AI의 답변을 메시지를 보낸 클라이언트에게만 다시 전송 session.sendMessage(new TextMessage(aiResponse)); } }
Java
복사
이제 핸들러는 메시지를 받으면 모든 사람에게 방송하는 대신, ChatService 를 호출하여 AI의 답변을 얻고, 그 답변을 메시지를 보낸 사용자에게만 다시 보낸다.
(4) 클라이언트 측 코드 (index.html)
사용자가 실제로 상호작용할 프론트엔드 코드 예시이다.
<!DOCTYPE html> <html> <head> <title>AI Chatbot</title> <style> body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f0f2f5; margin: 0; } #chat-container { width: 90%; max-width: 600px; height: 80vh; background-color: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); display: flex; flex-direction: column; } #messages { flex: 1; padding: 20px; overflow-y: auto; border-bottom: 1px solid #ddd; } .message { margin-bottom: 15px; } .user-message { text-align: right; } .ai-message { text-align: left; } .message span { display: inline-block; padding: 10px 15px; border-radius: 18px; max-width: 70%; } .user-message span { background-color: #0084ff; color: white; } .ai-message span { background-color: #e4e6eb; color: #050505; } #form { display: flex; padding: 10px; } #input { flex: 1; border: 1px solid #ccc; border-radius: 18px; padding: 10px 15px; font-size: 16px; } #send { background-color: #0084ff; color: white; border: none; border-radius: 18px; padding: 10px 20px; margin-left: 10px; cursor: pointer; } </style> </head> <body> <div id="chat-container"> <div id="messages"></div> <form id="form" action=""> <input id="input" autocomplete="off" placeholder="메시지를 입력하세요..." /><button id="send">전송</button> </form> </div> <script> const form = document.getElementById('form'); const input = document.getElementById('input'); const messages = document.getElementById('messages'); // WebSocket 연결 생성 (주소는 실제 서버 주소에 맞게 변경) const ws = new WebSocket("ws://localhost:8080/ws/chat"); // 연결이 성공적으로 열렸을 때 ws.onopen = function() { console.log("WebSocket 연결 성공"); addMessage("AI 챗봇에 연결되었습니다. 무엇이든 물어보세요!", 'ai'); }; // 서버로부터 메시지를 수신했을 때 ws.onmessage = function(event) { console.log("서버로부터 메시지 수신: " + event.data); addMessage(event.data, 'ai'); }; // 연결이 닫혔을 때 ws.onclose = function() { console.log("WebSocket 연결 종료"); addMessage("연결이 끊어졌습니다.", 'ai'); }; // 폼 제출 시 (메시지 전송) form.addEventListener('submit', function(e) { e.preventDefault(); if (input.value) { ws.send(input.value); addMessage(input.value, 'user'); input.value = ''; } }); // 화면에 메시지를 추가하는 함수 function addMessage(text, type) { const messageDiv = document.createElement('div'); messageDiv.className = 'message ' + type + '-message'; const messageSpan = document.createElement('span'); messageSpan.textContent = text; messageDiv.appendChild(messageSpan); messages.appendChild(messageDiv); // 새 메시지가 추가되면 스크롤을 맨 아래로 이동 messages.scrollTop = messages.scrollHeight; } </script> </body> </html>
JavaScript
복사
이 HTML 파일을 브라우저에서 열면, 지정된 WebSocket 주소로 서버에 연결을 시도하고 채팅 UI를 통해 AI와 대화할 수 있다.

5. 다음 단계

STOMP 프로토콜: 위 예제는 순수 WebSocket API를 사용했다. 하지만 여러 채팅방을 만들거나, 특정 사용자에게만 메시지를 보내는 등 더 복잡한 메시징을 구현해야 한다면 STOMP(Simple Text Oriented Messaging Protocol)를 WebSocket 위에서 사용하는 것을 추천한다. Spring은 STOMP도 매우 잘 지원합니다.
보안: 실제 서비스에서는 WebSocket 연결 시 인증/인가 과정을 추가해야 한다. Spring Security와 JWT 토큰 등을 활용할 수 있다.
예외 처리 및 안정성: AI 서비스 응답 지연, 네트워크 오류 등 다양한 예외 상황에 대비하는 코드를 추가하여 안정성을 높여야 한다.