image.png

image.png

위는 백엔드 전달 사항입니다. 한 페이지에서 같이 보면 좋을 것 같아 첨부합니다.

제가 구현한 내용은 다음과 같습니다.

  1. WebSocketContext.tsx

    해당 파일의 주요 로직은 두 가지로 나뉩니다.

    첫 번째로, 웹소켓 연결을 전역적으로 관리합니다. 만약 페이지마다 웹소켓 연결을 생성한다면, 최적화도 안 된 지금 페이지 리렌더링마다 웹소켓 서버와 불필요한 연결/해제(Handshake)가 반복됩니다. 이러면 리소스 낭비가 심해질 뿐더러 연결 상태가 매우 불안정해지기 때문에… 소켓은 context에서 전역적으로 생성하고 관리합니다.

    useEffect(() => {
        console.log('Connecting to WebSocket at:', process.env.NEXT_PUBLIC_API_BASE_URL);
        // Socket.IO 연결
        const newSocket = io(`${process.env.NEXT_PUBLIC_API_BASE_URL}/ws`, {
          transports: ['websocket'],
          withCredentials: true, // 쿠키 자동 전송
        });
    
        newSocket.on('connect', () => {
          console.log('웹소켓 연결됨');
          setIsConnected(true);
        });
    
        newSocket.on('disconnect', () => {
          console.log('웹소켓 연결 해제됨');
          setIsConnected(false);
        });
    
        setSocket(newSocket);
    
        return () => {
        /* WebSocketProvider가 언마운트 될 때 
           * -> 사용자가 브라우저 탭을 닫을 때 
           * socket.close()로 연결 종료 */
          newSocket.close();
        };
      }, []); // 빈 배열이므로 처음 렌더링 될 때만 실행
    

    두 번째로, 통신 함수를 추상화합니다. 여기서 통신함수는 아래 두 가지라고 생각하시면 됩니다.

    맨 위 그림에서 설명하는 웹소켓 통신의 전체 틀은 [소켓 연결 → 구독 → 이벤트 수신 → 데이터 처리 → 구독 해제 → 연결 해제] 의 흐름을 따릅니다. 이 과정에서 '소켓 연결' 과 '연결 해제' 가 서버와 클라이언트 간의 통신 채널을 열고 닫는 물리적인 과정이라면, 그 통신 채널 안에서 이루어지는 상호작용은 모두 '이벤트' 기반으로 동작합니다.

    클라이언트가 특정 페이지의 실시간 업데이트를 받기 위해 가장 먼저 하는 일은 '구독(subscribe)' 입니다. 이는 socket.emit('subscribe', ...) 함수를 통해 서버 측으로 **구독 이벤트를 발생(trigger)**시키는 행위입니다. 즉, "이 방 이벤트 다 받겠다"고 서버에 선전포고를 하는 거죠!

    그럼 서버는 “ㅇㅇ줄게” 하면서 해당 주제에 변경사항이 생길 때마다 발행(publish) 이벤트를 보내주고, 클라이언트는 페이지를 벗어날 때 구독 취소(unsubscribe) 이벤트를 보내는 방식으로 통신이 이루어진다고 아시면 되겠습니다.

    const subscribe = useCallback((eventType: SubEventType, id: number) => {
        if (socket && isConnected) {
          socket.emit('subscribe', { eventType, id });
          console.log(`구독: ${eventType}:${id}`);
        }
      }, [socket, isConnected]);
    
      const unsubscribe = useCallback((eventType: SubEventType, id: number) => {
        if (socket && isConnected) {
          socket.emit('unsubscribe', { eventType, id });
          console.log(`구독 해제: ${eventType}:${id}`);
        }
      }, [socket, isConnected]);
    
  2. webSocket.ts

    /types/webSocket.ts 경로에 위치한 타입 정의 파일입니다. 해당 파일에서 웹소켓에 필요한 모든 타입을 정의하고 있으니, 페이지에 새롭게 타입이나 인터페이스 정의하지 마시고 여기서 임포트해서 사용하시면 됩니다. 파일 내용이 길기도 하고, 주석으로 전부 어떤 타입인지 달아뒀으니 확인해보세요! 페이지 내에서 사용하는 모든 타입은 바로 정의하지 마시고 해당 파일에 있는지 먼저 확인 후!! 없으면 해당 파일에 정의해주세요!! payload 타입도 전부 정의되어 있습니다. 타입 가드도 구현해뒀는데 페이지 구현 중에 충돌이나 오류 발생 시에 알려주세요!

  3. /dashboard/page.tsx

    페이지 내에서 구독과 이벤트 수신, 수신한 이벤트에 따른 데이터 처리, 구독 해제를 구현합니다.

    // 웹소켓 이벤트 처리
      useEffect(() => {
        if (isConnected && socket) {
          const projectIdNum = parseInt(projectId);
          
          // 1. project:dashboard 룸 구독
          subscribe(SubEventType.PROJECT_DASHBOARD, projectIdNum);
          
          // 2. publish 이벤트 수신 - 실시간 업데이트 처리
          const handlePublish = (data: WebSocketResponseUnion) => {
            console.log('웹소켓 이벤트 수신:', data);
            
            // 타입 가드를 사용하여 entity가 'task' 또는 'step'인 경우에만 처리
            if (isTaskResponse(data) || isStepResponse(data)) {
              console.log(`변경 사항 발생에 따라 대시보드 쿼리 무효화 (이벤트: ${data.entity}.${data.type})`);
    
              queryClient.invalidateQueries({
                queryKey: ['dashboard', projectIdNum]
              });
            }
          };
          
          // 3. unsubscribe-forced 이벤트 수신 - 강제 구독 해제
          const handleForceUnsubscribe = () => {
            console.log('강제 구독 해제');
            window.location.href = '/home/tasks';
          };
          
          // 이벤트 리스너 등록
          socket.on('publish', handlePublish);
          socket.on('unsubscribe-forced', handleForceUnsubscribe);
          
          // 컴포넌트 언마운트 시 정리
          return () => {
            // 구독 해제
            unsubscribe(SubEventType.PROJECT_DASHBOARD, projectIdNum);
            
            // 이벤트 리스너 제거
            socket.off('publish', handlePublish);
            socket.off('unsubscribe-forced', handleForceUnsubscribe);
          };
        }
      }, [isConnected, socket, projectId, subscribe, unsubscribe, queryClient]);
    

    제 페이지에서는 payload로 수신한 내용을 반영할 필요가 없어 이벤트 발생시 쿼리 무효화로 해당 페이지 리렌더링! 하는 방식으로 진행했습니다. 그런데 업무 상세 페이지나 팀 일정 상세 페이지 같은 경우에는 payload 내의 내용을 해당 텍스트 필드에 반영해야하므로 handlePublish() 함수 안의 내용을 각자 페이지에 맞게 작성하시면 되겠습니다.