모각존은 여러 명이 함께 공부할 수 있는 공간으로, 상태 변경을 통해 해당 유저의 공부 여부를 알 수 있다.
입/퇴장과 상태 변경, 채팅까지 모두 실시간으로 반영되어야 하므로 웹소켓을 사용했다.

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 에서 한 번만 연결 시도 및 구독하도록 하여 해결했다.
'Dazz6 > ABOUT' 카테고리의 다른 글
| 모각 프로젝트 - #2-4. 비밀 고민 상담 (0) | 2025.08.04 |
|---|---|
| 모각 프로젝트 - #2-3. 모각챌 (0) | 2025.08.03 |
| 모각 프로젝트 - #2-1. 홈 화면 (4) | 2025.08.01 |
| 모각 프로젝트 - #1. 소개 (2) | 2025.07.31 |
| 고마워써 프로젝트 - #3. 배포 및 회고 (0) | 2025.01.17 |