πŸ’¬ μ±„νŒ… μžλ™ 슀크둀 κΈ°λŠ₯ κ΅¬ν˜„

YeonnΒ·2025λ…„ 3μ›” 13일
0

κΈ°λŠ₯ κ΅¬ν˜„ν•˜κΈ°

λͺ©λ‘ 보기
8/10
post-thumbnail

μ±„νŒ…λ°©μ—μ„œλŠ” μ‹€μ‹œκ°„μœΌλ‘œ λ©”μ‹œμ§€κ°€ μ˜€κ°„λ‹€.
μƒˆλ‘œμš΄ λ©”μ‹œμ§€κ°€ 도착할 λ•Œ μ‚¬μš©μžκ°€ λΆˆνŽΈν•¨ 없이 λ‚΄μš©μ„ 확인할 수 μžˆλ„λ‘ ν•˜κΈ° μœ„ν•΄ μžλ™ 슀크둀 κΈ°λŠ₯이 ν•„μš”ν–ˆλ‹€.

μ²˜μŒμ—λŠ” λ‹¨μˆœνžˆ μƒˆλ‘œμš΄ λ©”μ‹œμ§€κ°€ λ„μ°©ν•˜λ©΄ μ΅œν•˜λ‹¨μœΌλ‘œ μ΄λ™ν•˜λ„λ‘ κ΅¬ν˜„ν–ˆλŠ”λ°,
μ‚¬μš©μžκ°€ μŠ€ν¬λ‘€μ„ 직접 μ˜¬λ €μ„œ 이전 λ©”μ‹œμ§€λ₯Ό 보고 μžˆμ„ κ²½μš°μ—λ„ μƒˆλ‘œμš΄ λ©”μ‹œμ§€κ°€ 올 경우λ₯Ό κ³ λ €ν•΄μ•Όν•œλ‹€λŠ” 생각이 λ“€μ—ˆλ‹€...

❓ κ΅¬ν˜„ λͺ©ν‘œ

κ·Έλž˜μ„œ μ±„νŒ…λ°©μ— 슀크둀과 κ΄€λ ¨ν•΄μ„œ 일어날 수 μžˆλŠ” κ²½μš°λ“€μ— λŒ€ν•΄ 생각해 λ³΄μ•˜λ‹€.

πŸ“ κ΅¬ν˜„ μ‹œ κ³ λ €ν•œ λΆ€λΆ„

  • 초기 λ Œλ”λ§ μ‹œ κ°€μž₯ μ΅œμ‹  λ©”μ‹œμ§€λ‘œ 이동
  • μƒˆλ‘œμš΄ λ©”μ‹œμ§€κ°€ λ„μ°©ν•˜λ©΄ μ‚¬μš©μžκ°€ ν•˜λ‹¨μ— μžˆμ„ 경우 μžλ™ 슀크둀
  • μ‚¬μš©μžκ°€ μŠ€ν¬λ‘€μ„ μ‘°μž‘ν–ˆμ„ 경우 μžλ™ 슀크둀 쀑단
  • μžλ™ 슀크둀이 μ€‘λ‹¨λœ 경우 μƒˆ λ©”μ‹œμ§€μ— λŒ€ν•œ μ•Œλ¦Ό ν•„μš” 및 μ‚¬μš©μžκ°€ μ•Œλ¦Όμ„ 클릭할 경우 μžλ™ 슀크둀
  • λ¬΄ν•œ μŠ€ν¬λ‘€μ„ ν™œμš©ν•˜μ—¬ 이전 λ©”μ‹œμ§€ 뢈러올 λ•Œ ν˜„μž¬ μœ„μΉ˜ μœ μ§€

❗️ κ΅¬ν˜„ 방법

λ¨Όμ € 초기 λ Œλ”λ§, μ±„νŒ… λͺ©λ‘ μ»¨ν…Œμ΄λ„ˆ 등을 κ°μ§€ν•˜κΈ° μœ„ν•΄ useRefλ₯Ό ν™œμš©ν–ˆκ³ ,
상단과 ν•˜λ‹¨ 감지λ₯Ό μœ„ν•΄ IntersectionObserverλ₯Ό ν™œμš©ν–ˆλ‹€.

πŸ“ 초기 λ Œλ”λ§λ  λ•Œ μ΅œμ‹  λ©”μ‹œμ§€κ°€ 보이도둝 슀크둀 μ΄λ™ν•˜κΈ°

useEffect(() => {
  	// 초기 λ Œλ”λ§μ΄ 아닐 경우 리턴
    if (!isInitial.current) return;
  	// μ±„νŒ… λͺ©λ‘ μ»¨ν…Œμ΄λ„ˆκ°€ 없을 경우 리턴
    if (!chatContainerRef.current) return;
  	// μ±„νŒ…λ©”μ„Έμ§€κ°€ 없을 경우 리턴
    if (chatMsgs.length === 0) return;

    chatContainerRef.current.scrollTo({
        top: chatContainerRef.current.scrollHeight,
        behavior: 'smooth',
    });
  	// 초기 λ Œλ”λ§μ΄ λλ‚˜λ©΄ false둜 μ„€μ •ν•΄ λ‹€μŒ λΆ€ν„° 이 μ½”λ“œ 블둝이 μ‹€ν–‰λ˜μ§€ μ•Šλ„λ‘ ν•œλ‹€.
    isInitial.current = false;
}, [chatMsgs]);

μ²˜μŒμ—λŠ” μ˜μ‘΄μ„± 배열에 아무것도 λ‹΄μ§€ μ•Šμ•„ 초기 λ Œλ”λ§ μ‹œμ—λ§Œ μ‹€ν–‰λ˜λ„λ‘ κ΅¬ν˜„ν–ˆμ—ˆλŠ”λ°,
μ†ŒμΌ“ μ΄λ²€νŠΈμ— λŒ€ν•œ μ²˜λ¦¬κ°€ μ™„λ£Œλ˜κΈ° 전에 μ‹€ν–‰λ˜μ–΄ μ œλŒ€λ‘œ 슀크둀 λ˜μ§€ μ•ŠλŠ” κ²½μš°κ°€ λ°œμƒν–ˆλ‹€.

κ·Έλž˜μ„œ μ†ŒμΌ“ 이벀트 μ²˜λ¦¬κ°€ μ™„λ£Œλ˜μ–΄ μ±„νŒ… λ©”μ‹œμ§€κ°€ 생기면 μ‹€ν–‰λ˜λ„λ‘ μ˜μ‘΄μ„± 배열에 μ±„νŒ… λ©”μ‹œμ§€λ₯Ό λ„£μ–΄μ£Όκ³ ,
초기 λ Œλ”λ§μΈμ§€λ₯Ό ꡬ뢄할 수 μžˆλŠ” useRefλ₯Ό λ”°λ‘œ μ„€μ •ν–ˆλ‹€.

πŸ“ `IntersectionObserver둜 슀크둀 μœ„μΉ˜ κ°μ§€ν•˜κΈ°

 const lastChatRef = useCallback((node: HTMLDivElement) => {
    if (lastObserverRef.current) lastObserverRef.current.disconnect();

    lastObserverRef.current = new IntersectionObserver(
      ([entry]) => {
        // ν•˜λ‹¨μ— 슀크둀이 λ„μ°©ν•˜λ©΄ μƒˆλ‘œμš΄ λ©”μ‹œμ§€ μ•Œλ¦Ό UI μƒνƒœ false μ„€μ •
        if (entry.isIntersecting) {
          setIsNewMsg(false);
        }
        // κ΄€μ°° μš”μ†Œ, 즉 ν•˜λ‹¨μ˜ λ§ˆμ§€λ§‰ μ±„νŒ… λ©”μ„Έμ§€κ°€ ν™”λ©΄μ—μ„œ λ“€μ–΄μ˜€κ±°λ‚˜ λ‚˜κ°ˆ λ•Œ λ§ˆλ‹€ μ‹€ν–‰
        // 화면에 κ΄€μ°° μš”μ†Œκ°€ 있으면 true, μ—†μœΌλ©΄ false
        setIsNearBottom(entry.isIntersecting);
      },
      
      { threshold: 0.1 }
    );

    if (node) {
      lastObserverRef.current.observe(node);
    }
  }, []);

βœ… entry.isIntersecting: κ΄€μ°° λŒ€μƒμ΄ λ·°ν¬νŠΈμ™€ ꡐ차할 λ•Œ λ§ˆλ‹€ μ‹€ν–‰λœλ‹€.

πŸ“ μ‚¬μš©μžκ°€ λ§ˆμ§€λ§‰ λ©”μ‹œμ§€λ₯Ό 보고 μžˆμ„ λ•Œ μƒˆ λ©”μ‹œμ§€ μˆ˜μ‹  μ‹œ μžλ™ 슀크둀

  useEffect(() => {
    if (!chatContainerRef.current) return;

    // intersectionObserverλ₯Ό 톡해 ν•˜λ‹¨ 인식 && μƒˆ λ©”μ‹œμ§€ μˆ˜μ‹  μ‹œ μ„€μ •ν•œ stateκ°€ true μΌλ•Œ μžλ™ 슀크둀
    if (isNearBottom && isNewMsg) {
      chatContainerRef.current.scrollTo({
        top: chatContainerRef.current.scrollHeight,
        behavior: 'smooth',
      });
    }
  }, [chatMsgs]);

πŸ“ μ‚¬μš©μžκ°€ μœ„μͺ½ 슀크둀 쀑 μƒˆ λ©”μ‹œμ§€ μˆ˜μ‹  μ‹œ 클릭 UI 쑰건뢀 λ Œλ”λ§

{!isNearBottom && newMsg && isNewMsg && (
	<NewChatMsg
		nickname={nickname}
     	// μƒˆ λ©”μ‹œμ§€ μˆ˜μ‹  이벀트 λ°œμƒ μ‹œ ν•΄λ‹Ή λ‚΄μš©μ„ μƒνƒœλ‘œ μ €μž₯ν•΄μ„œ ν™œμš©
        message={newMsg}
        chatContainerRef={chatContainerRef}
        setNewMsg={setNewMsg}
	/>
)}

// ν•΄λ‹Ή UIλ₯Ό 클릭할 경우 μ‹€ν–‰λ˜λŠ” ν•¨μˆ˜
const handleNewMsg = () => {
    if (!chatContainerRef.current) return;

  	// μ±„νŒ…λ°© μ΅œν•˜λ‹¨μœΌλ‘œ 슀크둀 이동
    const container = chatContainerRef.current;
    container.scrollTo({
      top: container.scrollHeight,
      behavior: 'smooth',
    });

  	// μƒˆ λ©”μ‹œμ§€ λ‚΄μš© μƒνƒœ null둜 λ³€κ²½
    setNewMsg(null);
  };

πŸ“ λ¬΄ν•œ 슀크둀 μ μš©ν•˜κΈ°( 이전 μ±„νŒ… 뢈러였기 )

const handleScroll = useCallback(() => {
    if (!chatContainerRef.current) return;

    const { scrollTop } = chatContainerRef.current;

  	// μ΅œμƒλ‹¨μ— λ‹Ώκ³ , nextCursorκ°€ μžˆμ„ 경우 이전 μ±„νŒ… 뢈러였기 μš”μ²­
    if (scrollTop === 0 && nextCursor) {
      if (debounceTimeout.current) return;

      debounceTimeout.current = setTimeout(() => {
        socket.emit(CHAT.HISTORY.FETCH, {
          roomId: chatRoomId,
          cursor: nextCursor,
        });

        socket.on(CHAT.HISTORY.FETCHED, handleGetChatting);

        setTimeout(() => {
          debounceTimeout.current = null;
        }, 100);
      }, 300);
    }
  }, [chatRoomId, nextCursor]);

슀크둀이 μ΅œμƒλ‹¨μ— λ‹Ώμ•˜μ„ λ•Œ, nextCursorλ₯Ό ν™œμš©ν•˜μ—¬ 이전 λ©”μ„Έμ§€λ₯Ό ν˜ΈμΆœν•œλ‹€.
debounceTimeout으둜 κ³Όλ„ν•œ μš”μ²­ λ°œμƒμ„ λ°©μ§€ν•œλ‹€.

const handleGetChatting = ({ chatHistory, nextCursor }: ChatHistoryData) => {
  	// 초기 λ Œλ”λ§μ΄ μ•„λ‹Œ κ²½μš°μ—λ§Œ 호좜
    if (!isInitial.current) {
      // μƒˆ λ©”μ‹œμ§€ μƒνƒœλ₯Ό false둜 μ„€μ •ν•˜μ—¬ ν•˜λ‹¨μœΌλ‘œ μŠ€ν¬λ‘€λ˜λŠ” 것 λ°©μ§€
      setIsNewMsg(false);
      setChatMsgs(() => {
        const currentMsgs = chatMsgsRef.current;

        // κΈ°μ‘΄ λ©”μ‹œμ§€μ˜ 쀑볡 μ—†λŠ” 아이디 λ°°μ—΄ 생성
        const existingMsgIds = new Set(currentMsgs.map((msg) => msg.id));
        // κΈ°μ‘΄ λ©”μ‹œμ§€μ™€ μ€‘λ³΅λ˜μ§€ μ•Šμ€ λ©”μ‹œμ§€λ§Œ 담은 이전 λ©”μ‹œμ§€ λ°°μ—΄ 생성
        const filteredNewMsgs = chatHistory.filter(
          (msg) => !existingMsgIds.has(msg.id)
        );

        // 쀑볡 제거된 이전 λ©”μ‹œμ§€μ™€ ν˜„μž¬ λ©”μ‹œμ§€λ₯Ό μ°¨λ‘€λ‘œ 담은 updatedMsgs 배열을 μƒμ„±ν•˜μ—¬ λ°˜ν™˜
        const updatedMsgs = [...filteredNewMsgs, ...currentMsgs];

        return updatedMsgs;
      });

      // λ‹€μŒ 이전 λ©”μ‹œμ§€ ν˜ΈμΆœμ„ μœ„ν•œ nextCursor μž¬μ„€μ •
      setNextCursor(nextCursor ?? null);

      // κΈ°μ‘΄ 슀크둀 μœ„μΉ˜λ‘œ μŠ€ν¬λ‘€μ„ λ‹€μ‹œ λ‚΄λ €μ€€λ‹€.
      if (chatContainerRef.current) {
        const currentScrollHeight = chatContainerRef.current.scrollHeight;
        chatContainerRef.current.scrollTo({
          top: currentScrollHeight * 0.057,
        });
      }
    }
  };

이전 λ©”μ‹œμ§€λ₯Ό μš”μ²­ν–ˆμ„ λ•Œ,

기쑴에 이미 λ Œλ”λ§ 된 μ±„νŒ… λ©”μ‹œμ§€κ°€ μ€‘λ³΅μœΌλ‘œ μ„œλ²„μ—μ„œ λ„˜μ–΄μ˜€λŠ” κ²½μš°κ°€ μžˆλŠ” 것을 ν™•μΈν–ˆλ‹€.
이 λ©”μ‹œμ§€κ°€ λ‹€μ‹œ μ€‘λ³΅μœΌλ‘œ λ Œλ”λ§λ˜λŠ” 것을 λ°©μ§€ν•˜κΈ° μœ„ν•΄
λ©”μ‹œμ§€μ˜ μ•„μ΄λ””λ‘œ 검사λ₯Ό ν•œ λ’€ μ€‘λ³΅λœ λ©”μ‹œμ§€λŠ” μ œκ±°ν•˜κ³  λ Œλ”λ§ ν•΄μ€€λ‹€.


✨ κ΅¬ν˜„ 결과와 λŠλ‚€μ 

μ±„νŒ…μ€ μ‹€μ‹œκ°„μœΌλ‘œ μ΄λ²€νŠΈκ°€ λ°œμƒν•˜λ‹€ λ³΄λ‹ˆ λ‹€λ₯Έ νŽ˜μ΄μ§€λ“€μ— λΉ„ν•΄

초기 λ‘œλ”©, μƒˆλ‘œμš΄ λ©”μ‹œμ§€ μˆ˜μ‹ , μƒˆλ‘œμš΄ λ©”μ‹œμ§€ μˆ˜μ‹  μ‹œμ˜ 상황, κ³Όκ±° λ©”μ‹œμ§€ 뢈러였기, 슀크둀의 μœ„μΉ˜ λ“± λ‹€μ–‘ν•œ 상황을 κ³ λ €ν•΄μ•Ό ν–ˆλ‹€. λ§Žμ€ κ°€λŠ₯성을 κ³ λ―Όν•΄μ„œ μžμ—°μŠ€λŸ¬μš΄ UXλ₯Ό μ œκ³΅ν•  수 μžˆλ„λ‘ λ…Έλ ₯ν–ˆλ‹€.

또 κΈ°λŠ₯ κ΅¬ν˜„λ§Œμ΄ λ¬Έμ œκ°€ μ•„λ‹ˆλΌ 이벀트 λ¦¬μŠ€λ„ˆ 정리가 맀우맀우 μ€‘μš”ν–ˆλ‹€.
일단 κ΅¬ν˜„μ— μ§‘μ€‘ν•΄μ„œ λ§Œλ“€λ‹€ 보면 이벀트 λ¦¬μŠ€λ„ˆκ°€ μ œλŒ€λ‘œ 정리 λ˜μ§€ μ•Šμ•„μ„œ λ©”λͺ¨λ¦¬ λˆ„μˆ˜κ°€ λ°œμƒν•˜λŠ” 상황이 생겼닀.

μ•žμœΌλ‘œ μ‹€μ‹œκ°„ μ±„νŒ… ν™˜κ²½μ—μ„œ 더 λ‚˜μ€ UX 제곡 및 μ΅œμ ν™”λ₯Ό μœ„ν•΄ κ°œμ„ μ„ 더 ν•΄λ³Ό μ˜ˆμ •μ΄λ‹€ !

0개의 λŒ“κΈ€