← Log

채팅방 입장/퇴장 처리 로직

Cola Chat 익명 채팅 개발기· 4 / 7
  1. 1 — Cola Chat — 익명 채팅 프로젝트 소개
  2. 2 — 웹소켓의 기본 개념과 통신 과정
  3. 3 — Socket.IO 웹소켓 세팅
  4. 4 — 채팅방 입장/퇴장 처리 로직
  5. 5 — 실시간 메시지 전송과 수신
  6. 6 — 실시간 사용자 온라인 상태 관리
  7. 7 — 프로젝트 배포와 점검

이전 문서에서 서버와 클라이언트 간 WebSocket 통신 연결을 완료했다. 이제 WebSocket을 이용해 본격적으로 채팅 기능을 구현해 볼 것이다. 가장 우선 구현해야 할 채팅방 입장과 퇴장 처리를 구현해 보자.

기능 구현: 채팅방 입장 및 퇴장

단일 기능에 부가 로직이 가장 많이 포함된 기능은 채팅방 입장과 퇴장이다.

입장 시 참여자 생성, 온라인 상태 처리, 공지사항 전송 등의 작업이 동시에 이루어지며, 퇴장 시에는 이와 반대의 상황이 처리된다.

서버와 클라이언트의 웹 프레임워크는 다음과 같다.

  • 서버: Nest.js
  • 클라이언트: Next.js

서버

  • Gateway 웹소켓 이벤트 리스너

    // src/app/chat/chat.gateway.ts
    
    ...
    
    @WebSocketGateway({ namespace: /\/*.+/ })
    export class ChatGateway {
    
      ...
    
      // 웹소켓 연결
      handleConnection(@ConnectedSocket() socket: Socket): void {
        console.log('WebSocket Connected to', socket.nsp.name);
      }
    
      // 웹소켓 연결 해제(방 퇴장 처리)
      handleDisconnect(@ConnectedSocket() socket: Socket): void {
        this.chatService.handleLeaveRoom(socket);
        console.log('WebSocket Disconnected from', socket.nsp.name);
      }
    
      // 채팅방 입장 처리
      @SubscribeMessage('joinRoom')
      async handleJoinRoom(
        @MessageBody()
        data: { roomId: string; chatterId: string | null },
        @ConnectedSocket() socket: Socket,
      ): Promise<{ data: any }> {
        const result = await this.chatService.handleJoinRoom(data, socket);
        return { data: result };
      }
    }

    @SubscribeMessage 데코레이터를 이용해 특정 이벤트를 명시한 리스너를 등록할 수 있다.

    퇴장 처리를 소켓 연결을 해제할 때 진행하는 이유는, 현재 서비스 로직상, 1채터-1소켓 원칙에 따라 채팅방 퇴장 시 반드시 소켓 연결이 해제되어야 하므로, 별도의 이벤트로 퇴장 처리를 하는 것보다 소켓 연결 해제 시 퇴장 처리까지 함께 진행하는 것이 소켓 인스턴스 관리 측면에서 더 안정적이다.

  • Service 로직

    // src/app/chat/chat.service.ts
    
    ...
    
    @Injectable()
    export class ChatService {
    
      ...
    
      // 채팅방 입장 처리
      async handleJoinRoom(
        data: { roomId: string; chatterId: string | null },
        socket: Socket,
      ): Promise<Chatter> {
        const { roomId, chatterId } = data;
        const namespace: Namespace = socket.nsp;
    
        // 1. 입장 처리
        socket.join(roomId);
    
        // 2. 채터 데이터 조회
        let chatter: Chatter;
        if (chatterId) {
          // 기존 채터 데이터 조회
          ...
        } else {
          // 새로운 채터 데이터 생성
          ...
        }
    
        // 3. 소켓 내부에 채터 설정
        (socket.data.chatters ||= {})[roomId] = chatter.id;
    
        // 4. 퇴장 타임아웃 이벤트 확인
          // 퇴장 타임아웃 이벤트 리스너가 설정되어 있을 경우(새로고침의 증거)
        const timeoutKey = `${roomId}-${chatter.id}`;
        const leaveTimeout = this.leaveTimeout.get(timeoutKey);
        if (leaveTimeout) {
          // 타임아웃 이벤트 제거(새로고침의 상황)
          ...
        } else {
          // 입장 공지사항 이벤트 채팅방에 전송(새로운 입장)
          ...
        }
    
        // 5. 채팅방 온라인 상태 그룹에 소켓 추가 및 온라인 상태 전송
        ...
    
        return chatter;
      }
    
    
      // 채팅방 퇴장 처리
      async handleLeaveRoom(socket: Socket): Promise<void> {
        const namespace: Namespace = socket.nsp;
        const socketChatters = socket.data.chatters;
        const roomId = socketChatters ? Object.keys(socketChatters)[0] : null;
        const chatterId: string = roomId ? socketChatters[roomId] : null;
        if (!roomId || !chatterId) throw NotFoundException;
    
        // 1. 채팅방 온라인 상태 그룹에서 소켓 제거
        ...
    
        // 2. 퇴장 타임아웃 이벤트 리스너 설정
          // 재입장 시간에 따라 타임아웃 이벤트 리스너가 실행되거나 제거됨(새로고침의 증거)
        const timeoutKey = `${roomId}-${chatterId}`;
        const timeout = setTimeout(async () => {
    
          // 2-1. 채팅방 온라인 상태 그룹에서 채터 제거 및 온라인 상태 전송(퇴장 확정)
          ...
    
          // 2-2. 퇴장 공지사항 메세지 채팅방에 전송(퇴장 확정)
          ...
    
          // 2-3. 타임아웃 이벤트 실행 후 제거
          this.leaveTimeout.delete(timeoutKey);
        }, LEAVE_MESSAGE_DELAY_TIME);
    
        this.leaveTimeout.set(timeoutKey, timeout);
      }
    }

    채팅방 입장 및 퇴장의 내부 처리 순서를 문장을 정리하면 다음과 같다.

    • 입장 (새로운 소켓이 연결된 상태)

      1. 소켓 인스턴스에 룸 정보 주입(입장 처리)
      2. 채터ID를 전달받은 경우 채터 정보 조회 또는 생성
      3. 소켓 인스턴스에 채터 정보 주입
      4. 채터ID로 등록되어 있는 타임아웃 이벤트 리스너 존재 여부 확인 4-1. 존재할 경우, 이벤트 리스너 제거 4-2. 존재하지 않을 경우, 입장 공지사항 이벤트 전송
      5. 채팅방 온라인 상태 그룹에 소켓 추가 및 온라인 상태 전송
    • 퇴장 (소켓 연결이 해제된 상태)

      1. 채팅방 온라인 상태 그룹에서 소켓 제거
      2. 채터ID로 타임아웃 이벤트 리스너 설정 2-1. 채팅방 온라인 상태 그룹에서 채터 제거 및 온라인 상태 전송 2-2. 퇴장 공지사항 이벤트 채팅방에 전송 2-3. 타임아웃 이벤트 실행 후 제거

클라이언트

  • Hook 웹소켓 이벤트 트리거

    // src/app/hooks/useSocket.ts
    
    ...
    
      export default function useSocket(props: Props) {
    
      ...
    
      const { id: roomId, namespace } = props.room;
    
      const router = useRouter();
    
      const { socket, setSocket } = useSocketStore();
    
      useEffect(() => {
        if (!namespace) return;
    
        const socketInstance = io(`${SOCKET_BASE_URL}/${namespace}`, {
          transports: ['websocket'],
          autoConnect: false,
        });
    
        // 소켓 연결
        socketInstance.connect();
    
        socketInstance.on(SocketEvent.CONNECT, () => {
          console.log('Connected to WebSocket server');
          setSocket(socketInstance);
    
          // 채팅방 입장 이벤트 전송
          emitJoin(
            socketInstance,
            { roomId, chatterId: getSessionRoomChatters()[roomId] },
    
            // (콜백) 세션과 상태에 채터 정보 저장
            (param1, param2) => saveSessionRoomChatters(param1, param2),
            (param1, param2) => addRoomChatters(param1, param2)
          );
        });
    
        socketInstance.on(SocketEvent.DISCONNECT, () => {
          console.log('Disconnected from WebSocket server');
          setSocket(null);
        });
    
        // 페이지 이동 라우터 변경
        const originalPush = router.push;
        const newPush = (href: string, options?: any) => {
          // 세션에서 채터 정보 제거
          removeSessionRoomChatters(roomId);
          delete roomChatters[roomId];
    
          // 소켓 연결 해제
          socketInstance.disconnect();
          originalPush(href, options);
        };
        router.push = newPush;
    
        return () => {
          // 페이지 이동 라우터 복구
          router.push = originalPush;
    
          // 소켓 연결 해제
          if (socket) socket.disconnect();
        };
      }, [namespace, roomId]);
    }

    채팅방 페이지에 접근하는 것이 곧 채팅방 입장을 의미하므로, 소켓 연결 이벤트 리스너의 콜백 함수에서 채팅방 입장 이벤트를 전송하도록 설정했다.

    이후 서버가 입장 처리를 위한 웹소켓 통신에 응답으로 채터 정보를 반환하면, 이를 브라우저 세션과 상태 관리 시스템에 저장한다.

    퇴장 처리는 간단하다. 채팅방 페이지를 벗어나면 소켓 연결이 자동으로 해제되기 때문이다.

    하지만 서비스 내에서 페이지 이동(로고 클릭으로 홈 화면 접근 등) 시에는 해당 Hook의 언마운트가 감지되지 않기 때문에, 라우터를 통한 페이지 이동 시, 브라우저 세션과 상태 관리 시스템에서 채터 정보를 제거하는 로직을 라우터 설정에 추가하여 처리했다.

개발 과정에서 다양한 실질 테스트를 통해 위 기능 로직을 확정했다.

목차