이전에 진행했던 프로젝트에서 구현한 역방향 무한스크롤 채팅방을 복기합니다
StompJS 라이브러리를 활용한 WebSocket을 구현합니다.
const client useRef<StompJs.Client>(null!);
const connect = () => {
client.current = new StompJs.cliet({
brokerURL: brokerUrl,
onConnect: () => {
subscribe();
},
});
client.current.activate();
};
useRef를 사용하여 컴포넌트 렌더링과 무관하게 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 }]);
});
};
const publish = (chat: string) => {
if (!client.current.connected) return;
client.current.publish({
destination: "/pub/send",
body: chat,
});
scrollToBottom();
};
const [chatList, setChatList] = useState<IChat[]>([]);
const [message, setMessage] = useState("");
const handleChangeMessage = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
};
messageInputRef를 통한 텍스트 영역 직접 제어const getAlert = async () => {
const res = await getApi({ link: `/message/${id}/refresh/last-read` });
const data = await res.json();
return data;
};
useEffect(() => {
getAlert();
}, [chatList]);
이와 같이 MeetingChatRoom 컴포넌트는 WebSocket 기반 실시간 통신, 상태 관리, 모바일 최적화, 그리고 생명주기 관리가 체계적으로 결합되어 있습니다.
다음은 채팅창 화면 컴포넌트인 ChatWindow.tsx 컴포넌트입니다.
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): 기존 데이터 앞에 새 데이터 추가
스크롤 유지: 이전 메시지 로드 시 스크롤 위치 유지 로직
- 이전 스크롤 높이 저장
- 데이터 추가 후 애니메이션 프레임 내에서 스크롤 위치 복원
- 예: 이전 높이가 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
- 새로 추가된 콘텐츠의 높이만큼 스크롤 위치 조정
- 이로써 사용자가 보던 메시지가 화면에 그대로 유지됨
역방향 스크롤 문제 해결:
- 새 콘텐츠가 위에 추가되면 현재 콘텐츠가 아래로 밀려나므로 스크롤 위치 수동 재조정