swimminginthecode DIVE!

Dazz6/ABOUT

모각 프로젝트 - #2-2. 모각존

dazz6 2025. 8. 2. 18:00

모각존은 여러 명이 함께 공부할 수 있는 공간으로, 상태 변경을 통해 해당 유저의 공부 여부를 알 수 있다.

입/퇴장과 상태 변경, 채팅까지 모두 실시간으로 반영되어야 하므로 웹소켓을 사용했다.

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const jwt = await getJwtFromServerCookie();

  const data = await ZoneDetail(id, jwt);

  return {
    title: `모각 | ${data.name}`,
    description: `${data.name} 모각존에서 함께 공부해요.`,
  };
}

 

MetaData title 을 모각존 이름으로 설정하기 위해 generateMetadata 를 사용했다.

또한 웹소켓 연결 전 입장 혹은 상태 변경을 시도할 수 없도록, 연결 중일 때는 skeleton UI 가 출력되도록 설정했다.

모각존 이미지는 방장이 설정 및 변경 가능하다.

제일 애먹었던 부분은 아무래도 웹 소켓 연결 및 브로드캐스팅인데... 먼저 소켓과 관련된 함수는 이렇게 작성해 두었다.

import SockJS from "sockjs-client";
import { Client, IMessage } from "@stomp/stompjs";
import { getJwtFromCookie } from "@/utils/client/auth.client.util";

let stompClient: Client | null = null;
let isConnecting = false;

type TopicHandler<T = unknown> = {
  mogakZoneId: string;
  onMessage: (msg: T) => void;
};

const subscribedTopics = new Map<string, TopicHandler>();

export async function connectAndSubscribeSocket<T>({
  topic,
  mogakZoneId,
  onMessage,
}: {
  topic: string;
  mogakZoneId: string;
  onMessage: (msg: T) => void;
}) {  
  const token = getJwtFromCookie();
  if (!token) {
    console.log("[socket] JWT 없음. 구독 중단");
    return;
  }

  if (isConnecting) { 
    await waitUntilConnected();
  } else if (!stompClient || !stompClient.connected) { 
    await ensureConnected(mogakZoneId);
  } 

  subscribe(topic, mogakZoneId, onMessage);
}

export function subscribe<T>(
  topic: string,
  mogakZoneId: string,
  onMessage: (msg: T) => void
) {
  if (!stompClient || !stompClient.connected) {
    return;
  }

  if (subscribedTopics.has(topic)) { 
    return;
  }

  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  console.log("[socket] 토픽 구독 시작:", topic);
  stompClient.subscribe(
    topic,
    (message: IMessage) => {
      try {
        const payload = JSON.parse(message.body);

        const isSystemBroadcast =
          typeof payload === "object" &&
          "sessionId" in payload &&
          "memberId" in payload &&
          "mogakZoneId" in payload &&
          "name" in payload &&
          Object.keys(payload).length === 4;

        if (isSystemBroadcast) { 
          return;
        }

        console.log("[socket] 수신된 메시지:", payload, topic);
        onMessage(payload);
      } catch (err) {
        console.error("[socket] 메시지 파싱 오류:", err);
      }
    },
    { Authorization: jwt, mogakZoneId }
  );

  subscribedTopics.set(topic, {
    mogakZoneId,
    onMessage: (msg) => onMessage(msg as T),
  });
}

let onConnectedCallback: (() => void) | null = null;

export function setOnConnectedCallback(cb: () => void) {
  onConnectedCallback = cb;
}

export async function ensureConnected(mogakZoneId: string): Promise<void> {
  const token = getJwtFromCookie();
  if (!token) {
    throw new Error("JWT 없음");
  }

  if (!stompClient || !stompClient.active) {
    stompClient = new Client({
      webSocketFactory: () =>
        new SockJS(`${process.env.NEXT_PUBLIC_BACKEND_API_URL}/api/mogak/ws`),
      connectHeaders: {
        Authorization: token,
        mogakZoneId,
      },
      reconnectDelay: 5000,
      onConnect: () => {
        console.log("[socket] 웹소켓 연결 성공");
        if (onConnectedCallback) {
          onConnectedCallback();
        }
        subscribedTopics.forEach((sub, topic) =>
          subscribe(topic, sub.mogakZoneId, sub.onMessage)
        );
      },
      onStompError: (frame) => {
        console.error("[socket] STOMP 에러:", frame);
      },
    });
  }

  if (!stompClient.connected && !isConnecting) {
    isConnecting = true;
    stompClient.activate();
    await waitUntilConnected();
    isConnecting = false;
  } else {
    await waitUntilConnected();
  }
}

function waitUntilConnected(timeout = 10000): Promise<void> {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    const check = () => {
      if (stompClient && stompClient.connected) {
        resolve();
      } else if (Date.now() - start > timeout) {
        reject("[socket] 소켓 연결 타임아웃");
      } else {
        setTimeout(check, 100);
      }
    };
    check();
  });
}

export async function sendStatus(
  zoneId: string,
  status: string,
  memberId: string
) { 
  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  try {
    await ensureConnected(zoneId);
    stompClient!.publish({
      destination: `/app/api/mogak/zone/${zoneId}/status`,
      headers: { Authorization: jwt, mogakZoneId: String(zoneId) },
      body: JSON.stringify({ memberId, status }),
    }); 
  } catch (err) {
    console.error("[socket] status 전송 실패", err);
  }
}

export async function sendChat(
  zoneId: string,
  memberId: string,
  message: string
) { 
  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  try {
    await ensureConnected(zoneId);
    stompClient!.publish({
      destination: `/app/api/mogak/zone/${zoneId}/message`,
      headers: { Authorization: jwt, mogakZoneId: String(zoneId) },
      body: JSON.stringify({ memberId, message }),
    }); 
  } catch (err) {
    console.error("[socket] chat 전송 실패", err);
  }
}

export async function sendDetail(zoneId: string) { 
  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  try {
    await ensureConnected(zoneId);
    stompClient!.publish({
      destination: `/app/api/mogak/zone/${zoneId}`,
      headers: { Authorization: jwt, mogakZoneId: String(zoneId) },
    }); 
  } catch (err) {
    console.error("[socket] detail 전송 실패", err);
  }
}

export function disconnectSocket() {
  if (stompClient) {
    console.log("[socket] 웹소켓 연결 종료 및 구독 초기화");
    stompClient.deactivate();
    stompClient = null;
    subscribedTopics.clear();
    isConnecting = false;
  }
}

 

웹 소켓 연결 / 구독 / 각 기능별 브로드캐스팅 / 연결 끊기 가 가능하며 let 으로 선언한 stompClient 와 isConneting 으로 연결 여부를 판단한다.

이를 기능이 필요한 곳에서 연결 시도 및 구독, 그리고 이벤트가 발생했을 때 브로드캐스팅만 해 주면 된다.

모각존 detail 의 page.tsx 는 server component 로 유지하고 싶었으므로 zone-wrapper.tsx 파일을 생성하여 page.tsx 에서는 import 하는 방식으로 사용하였다.

 

 useEffect(() => {
    setOnConnectedCallback(() => {
      console.log("소켓 연결 완료");
      setConnected(true);
    });

    const timeout = setTimeout(() => {
      if (!connected && id === data.hostMemberId) {
        console.warn("소켓 연결 실패: 재참가 필요");
        setJoined(false);
        setShowReconnectModal(true);
      }
    }, 3000);

    connectAndSubscribeSocket<ZoneDetailResponse>({
      topic: `/topic/api/mogak/zone/${id}`,
      mogakZoneId: id,
      onMessage: (parsedRes) => {
        setLoadData((prev) => {
          const base = prev ?? data;
          return {
            ...base!,
            zoneMemberInfoList: parsedRes.zoneMemberInfoList,
            joinedUserCount: parsedRes.joinedUserCount,
          };
        });
      },
    }).then(() => {
      const userId = localStorage.getItem("memberId");

      if (joined) {
        sendStatus(id, "RESTING", String(userId));
      }
    });

    return () => {
      clearTimeout(timeout);
      setConnected(false);
      disconnectSocket();
    };
  }, []);

 

위는 참여 멤버의 입/퇴장 이벤트를 위한 구독 및 브로드캐스팅이다.

.then()을 사용하여 입장 후 자동으로 '휴식 중' status 설정되도록 하였다.

 

같은 방식으로 상태 변경을 위한 user-card 와 chatting-ui 에도 웹소켓 코드를 삽입하였는데,

처음에 user-card 에 connectAndSubscribeSocket 함수를 작성했더니 유저 component 별로 구독을 시도하고 memberId 등이 꼬여 에러가 발생했다.

그래서 page.tsx - zone-wrapper.tsx - user-card-wrapper.tsx - user-card 의 구조로 리팩토링하고, user-card-wrapper 에서 한 번만 연결 시도 및 구독하도록 하여 해결했다.