역방향 무한스크롤 채팅방 구현하기

김현중·2025년 3월 16일

연구소

목록 보기
30/34

이전에 진행했던 프로젝트에서 구현한 역방향 무한스크롤 채팅방을 복기합니다

1. WebSocket 통신 구현

StompJS 라이브러리를 활용한 WebSocket을 구현합니다.

const client useRef<StompJs.Client>(null!);

const connect = () => {
  client.current = new StompJs.cliet({
    brokerURL: brokerUrl,
    onConnect: () => {
      subscribe();
    },
  });
  client.current.activate();
};

STOMP 프로토콜 활용

  • Client 객체 관리: useRef를 사용하여 컴포넌트 렌더링과 무관하게 WebSocket 클라이언트 객체 유지
  • 구독 모델: STOMP의 pub/sub 패턴을 활용해 메시지 발행 및 수신
  • 연결 생명주기: 컴포넌트 마운트/언마운트와 연동된 WebSocket 연결 관리

메시지 구독 로직

const subscribe = () => {
  if (!id) return;
  
  client.current.subscribe(`/sub/${id}`, (body: { body: string }) => {
    const json_body = JSON.parse(body.body);
    setChatList((_chat_list) => [..._chat_list, { ...json_body }]);
  });
};
  • 채팅방별 구독: 각 채팅방(id)에 대한 구독 설정
  • 실시간 메시지 수신: 서버로부터 메시지를 받으면 chatList 상태 업데이트
  • 데이터 변환: 서버로부터 받은 JSON 문자열을 파싱하여 메시지 객체로 변환

메시지 발행 로직

const publish = (chat: string) => {
  if (!client.current.connected) return;
  
  client.current.publish({
    destination: "/pub/send",
    body: chat,
  });

  scrollToBottom();
};
  • 연결 상태 확인: 메시지 전송 전 WebSocket 연결 상태 검증
  • 메시지 포맷: JSON 문자열로 변환된 메시지 데이터 전송
  • UI 반응: 메시지 전송 후 스크롤 자동 이동


2. 채팅 상태 관리

메시지 상태 및 입력 관리

const [chatList, setChatList] = useState<IChat[]>([]);
const [message, setMessage] = useState("");

const handleChangeMessage = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  setMessage(e.target.value);
};
  • 메시지 목록: chatList 상태로 모든 채팅 메시지 관리
  • 텍스트 영역 참조: messageInputRef를 통한 텍스트 영역 직접 제어

3. 채팅방 알림 및 읽음 처리

const getAlert = async () => {
    const res = await getApi({ link: `/message/${id}/refresh/last-read` });
    const data = await res.json();
    return data;
};

useEffect(() => {
  getAlert();
}, [chatList]);
  • 새 메시지 읽음 처리: 채팅방 입장 시와 새 메시지 수신 시 읽음 상태 업데이트
  • 서버 통신: API 호출을 통한 읽음 상태 서버 동기화

이와 같이 MeetingChatRoom 컴포넌트는 WebSocket 기반 실시간 통신, 상태 관리, 모바일 최적화, 그리고 생명주기 관리가 체계적으로 결합되어 있습니다.



다음은 채팅창 화면 컴포넌트인 ChatWindow.tsx 컴포넌트입니다.

1. 메시지 데이터 페이지네이션과 무한스크롤

초기 메시지 로딩 및 상태 관리

const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);

useEffect(() => {
  if (hasMore) getChatting(page);
}, [page, id]);
  • 페이지 상태: 현재 로드된 페이지를 page 상태로 관리
  • 추가 데이터 여부: 더 로드할 데이터가 있는지 hasMore 상태로 판단

페이지 기반 메시지 로딩

const getChatting = async (pageNum: number) => {
  const chatResponse = await getApi({
    link: `/message/${id}/page?page=${pageNum}&size=20`,
  });
  const chatData = await chatResponse.json();
  const formattedChatData = chatData.reverse().map((chat: ChatMessage) => ({
    nickname: chat.nickname,
    content: chat.content,
    createdAt: chat.createAt,
  }));

  // 새로운 데이터가 없을 경우 더이상 스크롤 이벤트를 발생시키지 않음
  if (formattedChatData.length < 20) setHasMore(false);
  
  if (pageNum === 0) {
    setChatList(formattedChatData);
    // 페이지 초기 로딩 시 최하단으로 스크롤
    setTimeout(() => chatEndRef.current?.scrollIntoView(), 100);
  } else {
    // 현재 컨테이너 높이 측정, 이전 스크롤 높이 저장
    const previousScrollHeight = chatContainerRef.current?.scrollHeight ?? 0;
    // 새로 가져온 과거 메시지를 기존 메시지 앞에 배치
    setChatList((prevChats: ChatMessage[]) => {
      return [...formattedChatdata, ...prevChats];
      // 과거 메시지가 위, 최신 메시지가 아래
    });
    // 스크롤 위치 복원
    requestAnimationFrame(() => {
      // 실제 스크롤 위치 계산 및 적용
      setTimeout(() => {
        const currentScrollHeight = chatContainerRef.current?.scrollHeight ?? 0;
        chatContainerRef.current?.scrollTo(0, currentScrollHeight - previousScrollHeight);
      }, 1);
    }
};
  • API 호출: 페이지 번호와 크기를 파라미터로 메시지 데이터 요청

  • 데이터 가공: 서버 응답을 컴포넌트에 맞는 형식으로 변환

  • 상태 업데이트 분기 처리:

    최초 로딩(page=0): 데이터 교체 후 최하단 스크롤
    추가 로딩(page>0): 기존 데이터 앞에 새 데이터 추가

  • 스크롤 유지: 이전 메시지 로드 시 스크롤 위치 유지 로직

    1. 이전 스크롤 높이 저장
    2. 데이터 추가 후 애니메이션 프레임 내에서 스크롤 위치 복원
    3. 예: 이전 높이가 1000px이고, 새 콘텐츠 추가로 1300px이 되었다면, 300px 아래로 스크롤하여 사용자 시점 유지
  • 종료 조건: 응답 데이터가 20개 미만일 경우 더 이상의 데이터가 없다고 판단

    이중 비동기 사용(requestAnimationFrame + setTimeout) 이유
    렌더링 사이클 보장: requestAnimationFrame은 다음 화면 그리기 전에 실행
    DOM 업데이트 완료 보장: setTimeout으로 미세한 추가 지연 제공
    브라우저 차이 대응: 브라우저마다 다른 렌더링 타이밍 흡수
    안정성 향상: 복잡한 DOM 업데이트에도 정확한 스크롤 위치 계산 가능

무한 스크롤 구현

useEffect(() => {
  const handleScroll = () => {
    if (!chatContainerRef.current) return;
    const isAtTop = chatContainerRef.current.scrollTop === 0; // 상단인지
    if (isAtTop && hasMore) {
      setPage((prevPage) => prevPage + 1);
    }
  };
  
  const chatContainer = chatContainerRef.current;
  chatContainer?.addEventListener("scroll", handleScroll);
  return () => chatContainer?.removeEventListener("scroll", handleScroll);
}, [hasMore]);
  • 스크롤 이벤트 리스너: 컨테이너 스크롤 위치 감지
  • 상단 도달 감지: scrollTop === 0일 때 상단 도달 판단
  • 조건부 페이지 증가: 데이터가 있을 때 페이지 증가
  • 메모리 관리: 컴포넌트 언마운트 시 이벤트 리스너 제거


역방향 구현 핵심

데이터 배치 순서

  • 서버 데이터 역순 처리: .reverse()로 서버에서 받은 데이터 순서 반전
    - 역순 정렬로 가장 오래된 메시지가 먼저 오도록 변환

  • 배열 병합 순서: [...formattedChatData, ...prevChats]
    - 새로운(과거) 메시지를 기존 메시지 앞에 추가

스크롤 위치 보존

핵심 계산: currentScrollHeight - previousScrollHeight
- 새로 추가된 콘텐츠의 높이만큼 스크롤 위치 조정
- 이로써 사용자가 보던 메시지가 화면에 그대로 유지됨

역방향 스크롤 문제 해결:
- 새 콘텐츠가 위에 추가되면 현재 콘텐츠가 아래로 밀려나므로 스크롤 위치 수동 재조정

profile
진짜 성실한 사람

0개의 댓글