프로그램

Spring Boot와 React에서 WebSocket 구현하기: 완벽 가이드

amanda_ai 2025. 3. 11. 16:48
반응형

Spring Boot와 React에서 WebSocket 구현하기: 완벽 가이드

안녕하세요, 오늘은 최신 기술 스택인 JDK 21, Spring Boot 3.3.1 백엔드와 Vite.js, React, TypeScript 프론트엔드를 사용하여 WebSocket을 구현하는 방법에 대해 알아보겠습니다. WebSocket은 실시간 양방향 통신을 가능하게 하는 프로토콜로, 채팅 애플리케이션, 실시간 대시보드, 협업 도구 등에 필수적인 기술입니다.

목차

  1. WebSocket 개요
  2. 백엔드 구현 (Spring Boot 3.3.1 + JDK 21)
  3. 프론트엔드 구현 (Vite + React + TypeScript)
  4. 테스트 및 디버깅
  5. 성능 최적화
  6. 고급 기능 구현
  7. 배포 시 고려사항

WebSocket 개요

WebSocket은 HTTP와 달리 연결을 지속적으로 유지하면서 서버와 클라이언트 간에 실시간 양방향 통신을 제공합니다. HTTP의 단방향 요청/응답 모델과 다르게, WebSocket은 연결이 수립된 후 어느 쪽에서든 메시지를 보낼 수 있습니다.

WebSocket의 주요 장점:

  • 낮은 지연 시간
  • 실시간 양방향 통신
  • 헤더 오버헤드 감소
  • 서버 푸시 기능

STOMP(Simple Text Oriented Messaging Protocol): Spring에서는 WebSocket 위에 STOMP 프로토콜을 사용하여 메시지 라우팅, 구독 등의 기능을 더 쉽게 구현할 수 있습니다.

백엔드 구현 (Spring Boot 3.3.1 + JDK 21)

1. 필요한 의존성 추가

build.gradle에 다음 의존성을 추가합니다:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // Optional for STOMP
    implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'
}

Maven을 사용한다면 pom.xml에 다음을 추가합니다:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Optional for STOMP -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-reactor-netty</artifactId>
    </dependency>
</dependencies>

2. WebSocket 구성 클래스 생성

package com.example.websocketdemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // WebSocket 연결을 위한 엔드포인트 등록
        // SockJS는 WebSocket을 지원하지 않는 브라우저를 위한 폴백 옵션
        registry.addEndpoint("/ws")
                .setAllowedOrigins("http://localhost:5173") // Vite 기본 포트
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 클라이언트로 메시지를 보낼 때 사용할 prefix
        registry.enableSimpleBroker("/topic");
        
        // 서버로 메시지를 보낼 때 사용할 prefix
        registry.setApplicationDestinationPrefixes("/app");
    }
}

3. 메시지 모델 정의

package com.example.websocketdemo.model;

import java.time.LocalDateTime;

public record ChatMessage(
    String content,
    String sender,
    MessageType type,
    LocalDateTime timestamp
) {
    public enum MessageType {
        CHAT,
        JOIN,
        LEAVE
    }
    
    // JDK 21의 레코드 기능으로 간결한 데이터 클래스 정의
    public ChatMessage withTimestamp() {
        return new ChatMessage(content, sender, type, LocalDateTime.now());
    }
}

4. WebSocket 컨트롤러 생성

package com.example.websocketdemo.controller;

import com.example.websocketdemo.model.ChatMessage;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

    @MessageMapping("/chat.sendMessage")
    @SendTo("/topic/public")
    public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
        // JDK 21 가상 스레드 활용 가능
        Thread.ofVirtual().name("chat-process-" + System.currentTimeMillis()).start(() -> {
            System.out.println("Processing message from " + chatMessage.sender() + " in virtual thread");
            // 메시지 처리 로직 (예: 로깅, 검증 등)
        });
        
        return chatMessage.withTimestamp();
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    public ChatMessage addUser(@Payload ChatMessage chatMessage, 
                               SimpMessageHeaderAccessor headerAccessor) {
        // 웹소켓 세션에 사용자 이름 추가
        headerAccessor.getSessionAttributes().put("username", chatMessage.sender());
        return chatMessage.withTimestamp();
    }
}

5. WebSocket 이벤트 리스너 추가 (선택사항)

package com.example.websocketdemo.listener;

import com.example.websocketdemo.model.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

import java.time.LocalDateTime;

@Component
public class WebSocketEventListener {

    private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);

    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        logger.info("Received a new web socket connection");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

        String username = (String) headerAccessor.getSessionAttributes().get("username");
        if(username != null) {
            logger.info("User Disconnected : " + username);

            var chatMessage = new ChatMessage(
                username + " left the chat!",
                username,
                ChatMessage.MessageType.LEAVE,
                LocalDateTime.now()
            );

            messagingTemplate.convertAndSend("/topic/public", chatMessage);
        }
    }
}

프론트엔드 구현 (Vite + React + TypeScript)

1. 프로젝트 설정

먼저 Vite를 사용하여 React+TypeScript 프로젝트를 생성합니다:

npm create vite@latest websocket-client -- --template react-ts
cd websocket-client
npm install

2. SockJS 및 STOMP 클라이언트 설치

npm install sockjs-client @stomp/stompjs
npm install --save-dev @types/sockjs-client

3. WebSocket 서비스 구현

src/services/WebSocketService.ts 파일을 생성:

import { Client, IMessage } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

export enum MessageType {
  CHAT = 'CHAT',
  JOIN = 'JOIN',
  LEAVE = 'LEAVE',
}

export interface ChatMessage {
  content: string;
  sender: string;
  type: MessageType;
  timestamp?: string;
}

type MessageCallback = (message: ChatMessage) => void;

class WebSocketService {
  private client: Client | null = null;
  private messageCallbacks: Set<MessageCallback> = new Set();

  constructor() {
    this.client = new Client({
      // Vite의 환경 변수를 활용할 수 있습니다
      webSocketFactory: () => new SockJS(import.meta.env.VITE_WEBSOCKET_URL || 'http://localhost:8080/ws'),
      debug: (str) => {
        console.log(str);
      },
      reconnectDelay: 5000,
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
    });

    // 연결 이벤트 핸들러
    this.client.onConnect = this.onConnect.bind(this);
    this.client.onStompError = this.onStompError.bind(this);
  }

  // 연결
  public connect(username: string): void {
    if (!this.client) return;
    
    this.client.activate();
    
    // username을 로컬 스토리지에 저장
    localStorage.setItem('chat_username', username);
  }

  // 연결 해제
  public disconnect(): void {
    if (this.client && this.client.connected) {
      this.client.deactivate();
    }
  }

  // 메시지 수신 콜백 등록
  public subscribeToMessages(callback: MessageCallback): () => void {
    this.messageCallbacks.add(callback);
    
    // 구독 해제 함수 반환
    return () => {
      this.messageCallbacks.delete(callback);
    };
  }

  // 메시지 전송
  public sendMessage(message: ChatMessage): void {
    if (!this.client || !this.client.connected) {
      console.error('WebSocket is not connected');
      return;
    }

    this.client.publish({
      destination: '/app/chat.sendMessage',
      body: JSON.stringify(message),
    });
  }

  // 사용자 참가 메시지 전송
  public joinChat(username: string): void {
    if (!this.client || !this.client.connected) {
      console.error('WebSocket is not connected');
      return;
    }

    const message: ChatMessage = {
      sender: username,
      content: `${username} joined the chat`,
      type: MessageType.JOIN,
    };

    this.client.publish({
      destination: '/app/chat.addUser',
      body: JSON.stringify(message),
    });
  }

  private onConnect(): void {
    console.log('Connected to WebSocket');
    
    // 저장된 사용자 이름 가져오기
    const username = localStorage.getItem('chat_username');
    
    if (username) {
      // 공개 채널 구독
      this.client?.subscribe('/topic/public', (message: IMessage) => {
        try {
          const chatMessage: ChatMessage = JSON.parse(message.body);
          this.messageCallbacks.forEach(callback => callback(chatMessage));
        } catch (e) {
          console.error('Error parsing message', e);
        }
      });
      
      // 연결 후 사용자 참가 메시지 전송
      this.joinChat(username);
    }
  }

  private onStompError(frame: any): void {
    console.error('STOMP error', frame);
  }
}

// 싱글톤 인스턴스 생성
export const webSocketService = new WebSocketService();
export default webSocketService;

4. React 컴포넌트 구현

먼저 src/components/Chat.tsx 파일을 생성:

import React, { useState, useEffect, useRef } from 'react';
import webSocketService, { ChatMessage, MessageType } from '../services/WebSocketService';
import '../styles/Chat.css';

const Chat: React.FC = () => {
  const [connected, setConnected] = useState<boolean>(false);
  const [username, setUsername] = useState<string>('');
  const [message, setMessage] = useState<string>('');
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // 새 메시지를 받았을 때 스크롤을 아래로 이동
  const scrollToBottom = (): void => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  useEffect(() => {
    // 메시지 구독
    const unsubscribe = webSocketService.subscribeToMessages((message: ChatMessage) => {
      setMessages(prevMessages => [...prevMessages, message]);
    });

    // 컴포넌트 언마운트 시 정리
    return () => {
      unsubscribe();
      webSocketService.disconnect();
    };
  }, []);

  const handleConnect = (e: React.FormEvent): void => {
    e.preventDefault();
    if (!username.trim()) return;

    webSocketService.connect(username);
    setConnected(true);
  };

  const handleSendMessage = (e: React.FormEvent): void => {
    e.preventDefault();
    if (!message.trim()) return;

    const chatMessage: ChatMessage = {
      sender: username,
      content: message,
      type: MessageType.CHAT,
    };

    webSocketService.sendMessage(chatMessage);
    setMessage('');
  };

  const getMessageClass = (msg: ChatMessage): string => {
    if (msg.type === MessageType.JOIN || msg.type === MessageType.LEAVE) {
      return 'event-message';
    }
    return msg.sender === username ? 'my-message' : 'other-message';
  };

  return (
    <div className="chat-container">
      {!connected ? (
        <div className="join-form-container">
          <form onSubmit={handleConnect} className="join-form">
            <h2>Join Chat</h2>
            <input
              type="text"
              placeholder="Your name"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              required
            />
            <button type="submit">Join</button>
          </form>
        </div>
      ) : (
        <div className="chat-box">
          <div className="chat-header">
            <h2>WebSocket Chat</h2>
            <p>Connected as: {username}</p>
          </div>
          <div className="messages-container">
            {messages.map((msg, index) => (
              <div key={index} className={`message ${getMessageClass(msg)}`}>
                {msg.type === MessageType.CHAT && (
                  <div className="message-sender">{msg.sender}</div>
                )}
                <div className="message-content">{msg.content}</div>
                {msg.timestamp && (
                  <div className="message-time">
                    {new Date(msg.timestamp).toLocaleTimeString()}
                  </div>
                )}
              </div>
            ))}
            <div ref={messagesEndRef} />
          </div>
          <form onSubmit={handleSendMessage} className="message-form">
            <input
              type="text"
              placeholder="Type a message..."
              value={message}
              onChange={(e) => setMessage(e.target.value)}
            />
            <button type="submit">Send</button>
          </form>
        </div>
      )}
    </div>
  );
};

export default Chat;

5. 스타일 추가

src/styles/Chat.css 파일을 생성:

.chat-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.join-form-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
}

.join-form {
  background-color: #f5f5f5;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  width: 100%;
  max-width: 400px;
}

.join-form h2 {
  margin-top: 0;
  margin-bottom: 20px;
  text-align: center;
}

.chat-box {
  display: flex;
  flex-direction: column;
  height: 100%;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  overflow: hidden;
}

.chat-header {
  background-color: #4a90e2;
  color: white;
  padding: 15px;
}

.chat-header h2 {
  margin: 0;
}

.chat-header p {
  margin: 5px 0 0;
  font-size: 14px;
}

.messages-container {
  flex: 1;
  padding: 15px;
  overflow-y: auto;
  background-color: #f9f9f9;
}

.message {
  margin-bottom: 15px;
  padding: 10px 15px;
  border-radius: 8px;
  max-width: 80%;
  position: relative;
}

.my-message {
  background-color: #dcf8c6;
  margin-left: auto;
}

.other-message {
  background-color: #e2e2e2;
  margin-right: auto;
}

.event-message {
  background-color: #f1f1f1;
  color: #666;
  text-align: center;
  margin: 10px auto;
  padding: 5px 10px;
  border-radius: 15px;
  font-size: 14px;
}

.message-sender {
  font-weight: bold;
  margin-bottom: 5px;
  font-size: 14px;
}

.message-content {
  word-wrap: break-word;
}

.message-time {
  font-size: 12px;
  color: #777;
  text-align: right;
  margin-top: 5px;
}

.message-form {
  display: flex;
  padding: 15px;
  background-color: #f0f0f0;
}

input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

button {
  background-color: #4a90e2;
  color: white;
  border: none;
  padding: 10px 20px;
  margin-left: 10px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  background-color: #3a7bc8;
}

6. App 컴포넌트 업데이트

src/App.tsx 파일을 수정:

import React from 'react';
import Chat from './components/Chat';
import './App.css';

function App() {
  return (
    <div className="App">
      <Chat />
    </div>
  );
}

export default App;

7. 환경 변수 설정

프로젝트 루트에 .env 파일 생성:

VITE_WEBSOCKET_URL=http://localhost:8080/ws

테스트 및 디버깅

백엔드 서버 시작

./gradlew bootRun

또는 Maven을 사용하는 경우:

./mvnw spring-boot:run

프론트엔드 서버 시작

npm run dev

테스트 시나리오

  1. 두 개의 브라우저 탭에서 애플리케이션을 열고 다른 이름으로 로그인합니다.
  2. 각 탭에서 메시지를 보내고 실시간으로 수신되는지 확인합니다.
  3. 한 탭에서 새로고침하여 연결이 자동으로 복구되는지 확인합니다.

디버깅 팁

  1. 백엔드 디버깅:
    • Spring Boot의 로깅 레벨을 DEBUG로 설정하여 WebSocket 관련 로그를 확인합니다:
      # application.propertieslogging.level.org.springframework.web.socket=DEBUG
      
  2. 프론트엔드 디버깅:
    • 브라우저 개발자 도구의 네트워크 탭에서 WebSocket 연결을 모니터링합니다.
    • 콘솔에서 로그와 오류를 확인합니다.

성능 최적화

백엔드 최적화

  1. 가상 스레드 활용 (JDK 21):
  2. // 메시지 처리에 가상 스레드 사용 Thread.ofVirtual().name("message-processor").start(() -> { // 메시지 처리 로직 });
  3. 메시지 배치 처리:
  4. // 한 번에 여러 메시지 처리 public void processBatch(List<ChatMessage> messages) { // 일괄 처리 로직 }
  5. Redis 메시지 브로커 사용:
  6. // WebSocketConfig.java @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableRedisMessageBroker() .setRelayHost("localhost") .setRelayPort(6379); registry.setApplicationDestinationPrefixes("/app"); }

프론트엔드 최적화

  1. 메시지 제한:
  2. // 최대 50개의 메시지만 보여주기 setMessages(prevMessages => { const newMessages = [...prevMessages, message]; return newMessages.slice(-50); });
  3. 메시지 페이징:
  4. // 스크롤 이벤트에 따라 추가 메시지 로드 const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const element = e.currentTarget; if (element.scrollTop === 0) { loadPreviousMessages(); } };
  5. 불필요한 리렌더링 방지:
  6. // React.memo로 컴포넌트 최적화 const Message = React.memo(({ message }: { message: ChatMessage }) => { // 렌더링 로직 });

고급 기능 구현

1. 사용자 온라인 상태 표시

백엔드:

@Controller
public class UserStatusController {
    private final Set<String> onlineUsers = ConcurrentHashMap.newKeySet();
    
    @MessageMapping("/user.connect")
    @SendTo("/topic/users")
    public Set<String> userConnect(@Payload String username) {
        onlineUsers.add(username);
        return onlineUsers;
    }
    
    @MessageMapping("/user.disconnect")
    @SendTo("/topic/users")
    public Set<String> userDisconnect(@Payload String username) {
        onlineUsers.remove(username);
        return onlineUsers;
    }
}

프론트엔드:

// WebSocketService.ts
public getOnlineUsers(): void {
  this.client?.subscribe('/topic/users', (message: IMessage) => {
    try {
      const users: string[] = JSON.parse(message.body);
      // 사용자 목록 업데이트 로직
    } catch (e) {
      console.error('Error parsing users', e);
    }
  });
}

2. 메시지 읽음 표시

백엔드:

@MessageMapping("/chat.messageRead")
@SendTo("/topic/messageStatus")
public MessageStatus messageRead(@Payload MessageStatus status) {
    return status;
}

프론트엔드:

interface MessageStatus {
  messageId: string;
  readBy: string[];
}

public markMessageAsRead(messageId: string): void {
  this.client?.publish({
    destination: '/app/chat.messageRead',
    body: JSON.stringify({
      messageId,
      readBy: this.username
    })
  });
}

3. 타이핑 표시기

백엔드:

@MessageMapping("/chat.typing")
@SendTo("/topic/typing")
public TypingStatus typing(@Payload TypingStatus status) {
    return status;
}

프론트엔드:

interface TypingStatus {
  username: string;
  isTyping: boolean;
}

public sendTypingStatus(isTyping: boolean): void {
  this.client?.publish({
    destination: '/app/chat.typing',
    body: JSON.stringify({
      username: this.username,
      isTyping
    })
  });
}

배포 시 고려사항

1. WebSocket 로드 밸런싱

로드 밸런서가 WebSocket 연결을 지원하는지 확인하세요:

  • AWS Application Load Balancer는 WebSocket을 지원합니다.
  • Nginx 설정:
    map $http_upgrade $connection_upgrade {
        default upgrade;
        '' close;
    }
    
    server {
        listen 80;
        
        location /ws {
            proxy_pass http://backend;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Host $host;
        }
    }

2. 스케일링

  • Spring Boot 애플리케이션을 수평적으로 확장할 때는 Redis나 RabbitMQ와 같은 외부 메시지 브로커를 사용하여 노드 간 메시지 전달을 보장하세요.
  • 클라이언트 측에서는 자동 재연결 로직을 구현하세요.

3. 보안 고려사항

  • WebSocket over TLS(wss://) 사용
  • 인증 토큰을 사용한 WebSocket 연결 보안:
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("http://localhost:5173")
                .withSockJS()
                .setInterceptors(new HttpSessionHandshakeInterceptor());
    }
    
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    // 토큰 인증 로직
                    String token = accessor.getFirstNativeHeader("X-Auth-Token");
                    if (token != null) {
                        // 토큰 검증 로직
                    }
                }
                return message;
            }
        });
    }

결론

이 포스트에서는 Spring Boot 3.3.1(JDK 21)과 React + TypeScript + Vite를 사용하여 실시간 WebSocket 채팅 애플리케이션을 구현하는 방법을 알아보았습니다. 

반응형