Spring + React로 채팅 구현 (2) - Session ID로 내가 보낸 메시지 오른쪽에 표시하기

박계현·2025년 1월 5일
0
post-thumbnail

들어가며

🌱 해당 포스트는 한걸음 스터디에서 발표한 내용입니다. 발표 내용을 아래 영상에서 확인하실 수 있습니다.

🌱한걸음은 각자 학습한 내용을 토대로 블로그 글을 작성하고, 대면으로 모여서 발표하며, 녹화해 유튜브에 업로드하는 스터디입니다.

한걸음 자세히 알아보기

이전에 WebSocket과 STOMP에 대해 설명하고, 해당 기술로 Spring과 React를 이용해를 아주 간단한 채팅을 구현하는 포스트를 작성하였습니다. 해당 포스트에 관심 있는 분들은 아래를 참고해주시면 감사하겠습니다.

스프링 + 리액트 + 웹소켓 + STOMP로 간단한 채팅 구현하기

작년 캡스톤 디자인 프로젝트를 위해 웹소켓 기반 채팅 및 실시간 이벤트 서비스를 위한 기능들을 구현한 경험이 있습니다. 채팅이라는 기능 참 도처에 널려있다보니 간단하게 느껴졌으나, 실제로 이를 구현해보려고 하니 고려해야할 부분이 생각보다 훨씬 많았습니다. 해당 기능들을 구현하기 위해서 다양한 자료를 참고하였고, 이런 저런 트러블슈팅을 거쳤습니다. 프로젝트는 끝이 났지만 이렇게 구현된 기능을 가능한한 보편적으로 재사용하기 편하도록 정리해놓으면, 저도 나중에 다시 쓸 수 있고 저처럼 자신의 서비스에 채팅 기능을 넣고 싶다고 생각하시는 분들에게 조금이나마 도움이 되지 않을까 해서 해당 프로젝트를 진행하게 되었습니다. 이름하여 Spring Starter Pack 프로젝트입니다. 근데 이제 Chat App편인.

💁‍♂️ 프로젝트 링크: https://github.com/gyehyun-bak/spring-starter-pack-chat

프로젝트 목표

🚩 학교 과제, 캡스톤 디자인, 공모전, 해커톤 등 다양한 프로젝트에서 쉽게 가져다 사용할 수 있도록 템플릿을 만들어 제공하는 것이 목적입니다.

⭐ 제가 충분히 쓸만하게 잘 만들었지 성과를 측정하기 위해 깃헙 스타 10개를 목표로 만들고 홍보하고 피드백 받아보고자 합니다. 받은 피드백을 토대로 기능을 추가해나가며, 이것을 편리하게 원하는 기능만 골라서 사용할 수 있도록 설계하려고 합니다.

💁‍♂️ 홍보 차 + 제가 만들고 싶은게 하나 있어서, 해당 프로젝트를 통해 얼마나 쉽고 빠르게 원하는 실시간 채팅 기반의 서비스를 만들 수 있는지, 서비스를 하나 제작하고 배포하는 시연을 해보려고 합니다.

📝 프로젝트 진행 과정을 블로그에 기록합니다.

Session ID로 내가 보낸 메시지 오른쪽에 표시하기

기존 프로젝트의 상태입니다. 기본적으로 흰 배경에 메시지만 보내고 받을 수 있으며 그 이상은 없습니다. 여기까지의 구현은 앞서 언급한 블로그 포스트를 참고해주시면 감사하겠습니다. 첫 업데이트로 내가 보낸 메시지를 오른쪽에 표시하도록 하는 기능을 추가해보겠습니다.

현재 구조

현재 서비스 구조는 매우 단순합니다...만 이것저것 추가하기 시작하면 금방 복잡해지기 시작하므로 하나씩 추가하는게 좋을 것 같습니다.

세션 아이디

유저 서비스가 있고, JWT 토큰 등을 통해 인증/인가를 관리하는 경우, 웹소켓 메시지 헤더에 JWT 토큰을 포함하여 이를 이용해 유저의 본인 여부를 확인하고 활용할 수 있을 것입니다. 하지만 유저 서비스를 통합시키는 것은 생각보다 무거운 작업이고, 별도의 가입이 없는 기능을 우선적으로 제공하는게 맞다고 생각합니다.

따라서 해당 기능의 구현을 위해 세션 아이디를 이용할 것입니다. 채팅을 위해 웹소켓 연결이 되면 자동적으로 세션 아이디가 생성되기 때문에 쉽게 활용할 수 있습니다. 그리고 무엇보다 세션 아이디라는 개념이 이후 기능 확장을 설명하기에 편리합니다.

백엔드

스프링 서버와 클라이언트가 웹소켓과 STOMP로 연결이 되면 WebSocketSession 객체에 해당 WebSocket 세션 아이디가 저장됩니다. 또한 SimpMessageHeaderAccessor를 컨트롤러의 메서드에서 사용하면 해당 세션의 헤더로부터 세션 아이디를 가져올 수 있습니다. 세션 아이디는 말 그대로 저희 WAS와 연결된 웹소켓 세션의 아이디를 식별하는 값입니다. 유저가 웹사이트를 벗어나 세션이 삭제되었다면 해당 세션 아이디도 더 이상 유효하지 않게 됩니다. 이를 이용하면 추후에 각 세션을 직접 도메인으로 정의하고 원하는 데이터를 저장하고 관리할 수 있습니다. sessionIdSession 객체를 매핑해 저장하는 리포지토리를 정의하면, 각 메시지의 sessionIdSession을 검색하여, Session 내에 저장한 nickname 등을 활용할 수 있는 것입니다.

지금은 Session 도메인 객체를 따로 정의하지 않을 것이기 때문에, 컨트롤러 단에서 SimpMessageHeaderAccessor를 통해 sessionId를 조회하여 MessageRequestDto와 함께 서비스로 넘겨줄 것 입니다. 이는 마치 컨트롤러 단에서 @AuthenticationPrincipal 등의 Spring Security에서 생성된 인증 컨텍스트를 활용해 유저 정보를 서비스에 넘기는 것과 비슷합니다.

서비스에서는 해당 sessionId를 메시지 응답 형식인 MessageResponseDto에 담아보낼 것입니다.

프론트엔드

리액트에서는 SockJS 라이브러리를 통해 연결된 웹소켓 세션 데이터에 접근할 수 있습니다. 세션 아이디를 핸드쉐이크 과정에 SockJS가 생성해서 URL에 포함한다고 합니다.

sessionId (number OR function)

Both client and server use session identifiers to distinguish connections. If you specify this option as a number, SockJS will use its random string generator function to generate session ids that are N-character long (where N corresponds to the number specified by sessionId). When you specify this option as a function, the function must return a randomly generated string. Every time SockJS needs to generate a session id it will call this function and use the returned string directly. If you don't specify this option, the default is to use the default random string generator to generate 8-character long session ids.

출처: sockjs-client 리포지토리 README.md

GET http://localhost:8080/ws/123/abcdef/websocket

위에서 abcdef가 세션 아이디입니다.

참조: Stack Overflow - How to get session id on the client side? (WebSocket)

둘이 같은 값을 갖는지 간단히 확인해보겠습니다.

테스트

  • App.tsx
const socket = new SockJS(SOCKET_URL);
...
// stomp onConnect()에서
const sessionId = socket._transport.url.split("/").slice(-2, -1)[0]; // 세션 ID는 URL의 뒤에서 두 번째 부분에 위치
console.log("Session ID:", sessionId);
  • ChatController
@Controller
@RequiredArgsConstructor
public class ChatController {
    private final ChatService chatService;

    @MessageMapping("/chat")
    @SendTo("/topic/chat")
    public MessageResponseDto sendChatMessage(MessageRequestDto requestDto, SimpMessageHeaderAccessor accessor) {
        System.out.println(accessor.getSessionId());
        return chatService.processMessage(requestDto);
    }
}
  • 결과

동일한 세션 ID를 공유하는 것을 확인할 수 있습니다. 이제 기능을 구현해보겠습니다.

구현

MessageResponseDto

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class MessageResponseDto {
    private String content;
    private String sessionId;
}

ChatController

@Controller
@RequiredArgsConstructor
public class ChatController {
    private final ChatService chatService;

    @MessageMapping("/chat")
    @SendTo("/topic/chat")
    public MessageResponseDto sendChatMessage(MessageRequestDto requestDto, SimpMessageHeaderAccessor accessor) {
        return chatService.processMessage(requestDto, accessor.getSessionId());
    }
}

ChatService

public interface ChatService {
    MessageResponseDto processMessage(MessageRequestDto requestDto, String sessionId);
}

ChatServiceImpl

@Service
public class ChatServiceImpl implements ChatService {
    public MessageResponseDto processMessage(MessageRequestDto requestDto, String sessionId) {
        return new MessageResponseDto(requestDto.getContent(), sessionId);
    }
}

App.tsx - 프론트엔드

한 파일이라 좀 길지만 주요 부분만 다시 짚어보겠습니다.

import { useEffect, useRef, useState } from "react";
import { Client } from "@stomp/stompjs";
import SockJS from "sockjs-client";

// 메시지 객체
interface MessageRequestDto {
  content: string;
}

interface MessageResponseDto {
  content: string;
  sessionId: string;
}

// 서버 웹소켓 엔드포인트트
const SOCKET_URL = "http://localhost:8080/ws";

function App() {
  const [message, setMessage] = useState("");
  const [messages, setMessages] = useState<MessageResponseDto[]>([]);
  const [sessionId, setSessionId] = useState(""); // 유저가 보낸 메시지 식별용 Session Id
  const stompClientRef = useRef<Client | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    const socket = new SockJS(SOCKET_URL);
    const stompClient = new Client({
      webSocketFactory: () => socket as any,
      debug: (msg: string) => console.log("[STOMP]:", msg),
      onConnect: () => {
        // 세션 아이디 추출
        const sessionId = (socket as any)._transport.url.split("/").slice(-2, -1)[0]; // 세션 ID는 URL의 뒤에서 두 번째 부분에 위치
        setSessionId(sessionId);

        console.log("[STOMP] 연결 성공: ", stompClient);
        // 채팅 토픽 구독
        const callback = (message: any) => {
          if (message.body) {
            console.log("[STOMP] 메시지 수신: ", message.body);
            const newMessage: MessageResponseDto = JSON.parse(message.body);
            setMessages((prevMessages) => [...prevMessages, newMessage]);
          }
        };

        stompClient.subscribe("/topic/chat", callback);
      },
      onStompError: (e) => {
        console.error("[STOMP] 연결 실패: ", e);
        stompClient.deactivate();
      },
      onDisconnect: () => console.log("STOMP 연결 해제"),
      reconnectDelay: 5000,
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
    });

    stompClient.activate();
    stompClientRef.current = stompClient;

    return () => {
      stompClient.deactivate();
    };
  }, []);

  const sendMessage = () => {
    if (
      !message.trim() ||
      !stompClientRef.current ||
      !stompClientRef.current.connected
    )
      return;

    const messageDto: MessageRequestDto = {
      content: message,
    };

    stompClientRef.current.publish({
      destination: "/app/chat",
      body: JSON.stringify(messageDto),
    });

    setMessage("");
    inputRef.current?.focus();
  };

  const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      sendMessage();
    }
  };

  return (
    <div className="flex justify-center w-screen h-screen">
      <div className="flex flex-col max-w-screen-sm w-full h-full bg-neutral-50">
        {/* Body */}
        <div className="flex-1 overflow-auto p-4">
          <div className="flex flex-col gap-1">
            {messages.map((message, index) => (
              <div
              key={index}
              className={`px-4 py-3 my-1 rounded-xl w-fit shadow-md ${
                message.sessionId === sessionId
                  ? "bg-blue-600 text-white self-end" // 자신의 메시지
                  : "bg-white self-start"  // 다른 사람의 메시지
              }`}
            >
              {message.content}
            </div>
            ))}
          </div>
        </div>

        {/* Input */}
        <div className="px-10 pb-10 pt-5 flex items-center w-full">
          <input
            ref={inputRef}
            type="text"
            className="flex-1 p-4 rounded-xl mr-2 shadow-lg"
            placeholder="메시지를 입력하세요..."
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            onKeyDown={handleKeyPress}
          />
          <button
            className="p-4 bg-blue-600 text-white rounded-xl shadow-lg"
            onClick={sendMessage}
          >
            전송
          </button>
        </div>
      </div>
    </div>
  );
}

export default App;
interface MessageResponseDto {
  content: string;
  sessionId: string;
}

기존 MessageResponseDto 타입에 sessionId 필드를 추가했습니다.

const [sessionId, setSessionId] = useState(""); // 유저가 보낸 메시지 식별용 Session Id

...

// 세션 아이디 추출
const sessionId = (socket as any)._transport.url.split("/").slice(-2, -1)[0]; // 세션 ID는 URL의 뒤에서 두 번째 부분에 위치
setSessionId(sessionId);

서버와 SockJS를 통해 웹소켓 연결이 이루어지면 sessionId를 저장합니다.

<div className="flex flex-col gap-1">
  {messages.map((message, index) => (
    <div
      key={index}
      className={`px-4 py-3 my-1 rounded-xl w-fit shadow-md ${
        message.sessionId === sessionId
          ? "bg-blue-600 text-white self-end" // 자신의 메시지
          : "bg-white self-start" // 다른 사람의 메시지
      }`}
    >
      {message.content}
    </div>
  ))}
</div>;

전달받는 각 message에서 sessionId를 클라이언트측 sessionId와 비교해 다른 UI로 렌더링 합니다.

결과

두 개의 브라우저를 띄워서 실행할 결과입니다. 정상적으로 각 유저가 보낸 메시지를 식별하는 것을 알 수 있습니다.

마무리

이후에는 이제 웹사이트 접속 시 닉네임을 입력할 수 있는 기능을 추가해보겠습니다. 이를 위해 Session 도메인 클래스를 정의하고 자바의 ConcurrentHashMap을 이용해 인메모리 상 임시로 구현하는 리포지토리를 만들어볼 것입니다. 이렇게하면 이제 sessionId에 대응하는 세션을 검색하고, 필요한 정보를 저장/조회할 수 있습니다. 나아가 후에 유저 도메인을 추가하게 되면 Session안에 해당하는 유저 데이터를 저장할 수도 있습니다.

profile
안녕하세요, 백엔드 열심히 공부 중인 개발자입니다😊

0개의 댓글

관련 채용 정보