무한 스크롤 실행 안됨 ( API 적용 전, mock Data)

2025. 8. 18. 19:18문제 해결 및 Tip

🧩 문제 상황 (What Happened)

  • 무한스크롤이 동작 하지 않음
    1. 첫 렌더링 시에 useEffect가 실행 되면서 comments를 초기화 시키고 fetchComments를 통해 댓글 10개를 더미 데이터에서 가져와서 화면에 댓글 10개를 렌더링 함. ( 실행됨 ) 
    2. 이후 스크롤을 내리면 useRef를 통해 IntersectionObserver 에 등록된 node (최하단부) 가 화면에 나타나면 다시 fetchComments를 실행하여 조건에 따라 댓글을 추가로 불러와야 함. (실행 안됨)

 

 

< 컴포넌트 로직 부위 >

interface PostData {
  id: number
  category: { id: number; name: string }
  author_id: number
  title: string
  content: string
  view_count: number
  is_visible: boolean
  is_notice: boolean
  attachments: { id: number; file_url: string; file_name: string }[]
  images: [
    {
      id: number
      image_url: string
      image_name: string
      image_type: string
    },
  ]
  created_at: string
  updated_at: string
}

import { useRef, useState, useEffect, type SetStateAction } from 'react'
import { Link } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import axios from 'axios'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import photo from '../assets/profile.png'
import Comment from '../components/commnunityDetail/Comment'
import { AiOutlineLike } from 'react-icons/ai'
import { GoLink } from 'react-icons/go'
import { LuArrowUpDown } from 'react-icons/lu'
import CommentLoading from '../components/commnunityDetail/CommentLoading'
import CommentTextArea from '../components/commnunityDetail/CommentTextArea'
import { useSortComments } from '../hooks'
import { URLCopy } from '@lib/index'
import { IoChatbubbleOutline } from 'react-icons/io5'
import { commentsMockData } from '@components/commnunityDetail/mockData'

export default function CommunityDetail() {
  const { id } = useParams()
  const textareaRef = useRef(null)
  const [postData, setPostData] = useState<PostData | null>(null)
  const [isLike, setIsLike] = useState(false)
  const [likeNum, setLikeNum] = useState(2)

  const [isLoading, setIsLoading] = useState(false)
  const [hasNext, setHasNext] = useState(true)

  const observerRef = useRef(null)

  const {
    comments,
    setComments,
    sortDropdownOpen,
    selectedSort,
    setSortDropdownOpen,
    setSelectedSort,
  } = useSortComments('최신순')

  useEffect(() => {
    axios
      .get(`http://localhost:3000/api/v1/community/posts/` + id)
      .then((res) => setPostData(res.data))
      .catch((err) => console.error('게시글 조회 실패:', err))
  }, [id])

  // 불러오기 함수
  const fetchComments = () => {
    if (isLoading || !hasNext) return

    setIsLoading(true)

    // setTimeout(() => {  // setTimeout 이 있어야 무한스크롤이 실행됩니다.
    const currentLength = comments.length
    const nextBatch = commentsMockData.slice(currentLength, currentLength + 10)

    setComments((prev) => [...prev, ...nextBatch])
    setHasNext(
      nextBatch.length === 10 &&
        currentLength + nextBatch.length < commentsMockData.length
    )

    setIsLoading(false)
    // }, 500)
  }

  useEffect(() => {
    setComments([])
    setHasNext(true)
    fetchComments()
  }, [selectedSort])

  useEffect(() => {
    console.log('useEffect 실행됨')
    if (!observerRef.current) return

    const observer = new IntersectionObserver(
      (entries) => {
        console.log(
          'IntersectionObserver 콜백 실행됨:',
          entries[0].isIntersecting
        )
        if (entries[0].isIntersecting && !isLoading && hasNext) {
          console.log('무한 스크롤 트리거!')
          fetchComments()
        }
      },
      { threshold: 0.1 }
    )

    const current = observerRef.current
    observer.observe(current)

    return () => {
      if (current) observer.unobserve(current)
    }
  }, [isLoading, hasNext])

  const handleSort = (option: SetStateAction<string>) => {
    setSelectedSort(option)
    setSortDropdownOpen((prev) => !prev)
  }

  const handleClickLike = () => {
    setLikeNum((prev) => (isLike ? prev - 1 : prev + 1))
    setIsLike((prev) => !prev)
  }
  console.log('Ref:', observerRef.current)

  console.log('hasNext:', hasNext)
  console.log('isLoading:', isLoading)
  if (!postData) return <div className="text-center mt-36">로딩 중...</div>
  
}

 

 

< UI 로직 부위 >

return <div className="text-center mt-36">로딩 중...</div>

  return (
    <div className="flex justify-center mt-[142px]">
      <div className="relative flex flex-col items-center w-[944px] gap-[100px]">
        <div className="flex flex-col gap-[24px] w-full">
          <div className="flex flex-col gap-[24px] border-b-[1px] pb-[14px] border-[#cecece]">
            <div className="flex flex-col gap-[24px]">
              <div className="flex gap-[5px] items-center text-[#6201e0] w-full text-[20px] font-[700]">
                <div>{postData.category.name}</div>
              </div>
              <div className="flex justify-between w-full">
                <p className="font-[700] text-[23px]">{postData.title}</p>
                <div className="flex items-center justify-between w-[101px]">
                  <img
                    src={photo}
                    alt="작성자"
                    className="h-[48px] rounded-[50%]"
                  />
                </div>
              </div>
            </div>
            <div className="flex items-center justify-between">
              <div className="flex items-center gap-[16px] text-[16px] font-[500] text-[#9d9d9d]">
                <div>조회수 {postData.view_count}</div>
                <div>좋아요 {likeNum}</div>
                <div>
                  {new Date(postData.updated_at).getTime() !==
                  new Date(postData.created_at).getTime()
                    ? `수정됨: ${new Date(postData.updated_at).toLocaleString()}`
                    : new Date(postData.created_at).toLocaleString()}
                </div>
              </div>
              <div className="flex items-center gap-[10px] text-[#707070] font-[500] text-[16px]">
                <Link
                  to={`/CommunityList/CommunityEdit/${postData.id}`}
                  className="text-[#6201e0]"
                >
                  수정
                </Link>
                <div>|</div>
                <div>삭제</div>
              </div>
            </div>
          </div>
          <div className="prose max-w-none">
            <ReactMarkdown
              remarkPlugins={[remarkGfm]}
              components={{
                img: ({ node, ...props }) => {
                  const src = props.src || ''
                  const match = src.match(/^image(\d+)/)
                  if (match) {
                    const index = parseInt(match[1], 10) - 1
                    const actualSrc = postData.images?.[index].image_url
                    if (actualSrc) {
                      return (
                        <img
                          {...props}
                          src={actualSrc}
                          alt={props.alt || 'image'}
                        />
                      )
                    } else {
                      return null
                    }
                  }
                  return <img {...props} alt={props.alt || 'image'} />
                },
              }}
            >
              {postData.content}
            </ReactMarkdown>
          </div>
        </div>
        <div className="flex flex-col gap-[24px] w-full">
          <div className="flex w-full justify-end gap-[12px] pb-[24px] border-b-[1px] border-[#cecece]">
            <button
              className="flex gap-[4px] items-center text-[#707070] border-[1px] border-[#cecece] py-[10px] px-[16px] rounded-[1000px] w-[62px] h-[38px] cursor-pointer"
              onClick={handleClickLike}
            >
              <AiOutlineLike
                className={`h-[18px] w-[18px] ${isLike ? 'text-[#6201e0]' : 'text-[#707070]'}`}
              />
              <div
                className={`text-[12px] font-[500] ${isLike ? 'text-[#6201e0]' : 'text-[#707070]'}`}
              >
                {likeNum}
              </div>
            </button>
            <button
              className="flex gap-[4px] items-center text-[#707070] border-[1px] border-[#cecece] py-[10px] px-[5px] rounded-[1000px] hover:bg-[#ececec] w-[82px] h-[38px] cursor-pointer"
              onClick={async () => {
                const result = await URLCopy()
                alert(
                  `${result ? '복사가 완료되었습니다.' : '복사가 실패하였습니다.'}`
                )
              }}
            >
              <GoLink className="h-[18px] w-[18px]" />
              <div className="text-[12px] font-[500]">공유하기</div>
            </button>
          </div>
          <div className="flex w-full h-[120px] gap-[40px] p-[20px] border-[1px] rounded-[12px] border-[#cecece] focus-within:border-[#6202E0]">
            <CommentTextArea
              textareaRef={textareaRef}
              comments={Array.isArray(comments) ? comments : []}
            />
          </div>
          <div className="flex flex-col w-full gap-[20px]">
            <div className="flex items-center justify-between w-full">
              <div className="flex items-center gap-[12px]">
                <IoChatbubbleOutline className="w-[18px] h-[18px]" />
                <div className="text-[#121212] text-[20px]">
                  {Array.isArray(comments)
                    ? `댓글 ${comments.length}개`
                    : '댓글 0개'}
                </div>
              </div>
              <div className="relative">
                <button
                  onClick={() => setSortDropdownOpen((prev) => !prev)}
                  className="text-sm text-gray-700 hover:text-[#6202E0] flex items-center cursor-pointer"
                >
                  {selectedSort}
                  <LuArrowUpDown className="w-4 h-4 ml-2" />
                </button>
                {sortDropdownOpen && (
                  <div className="absolute top-[100%] right-0 mt-2 bg-white shadow-lg rounded-xl p-2 w-32 text-sm z-20">
                    {['최신순', '오래된 순'].map((option) => (
                      <div
                        key={option}
                        onClick={() => handleSort(option)}
                        className={`cursor-pointer px-3 py-2 rounded-md text-center transition ${selectedSort === option ? 'bg-purple-100 text-[#6202E0] font-bold' : 'text-gray-700 hover:bg-gray-100'}`}
                      >
                        {option}
                      </div>
                    ))}
                  </div>
                )}
              </div>
            </div>
            <div className="flex flex-col gap-[17px] w-full">
              {comments.map((commentData) => (
                <Comment key={commentData.id} commentData={commentData} />
              ))}
            </div>
            {hasNext && (
              <div
                ref={observerRef}
                className="flex items-center justify-center w-full h-[40px]"
              >
                {isLoading && <CommentLoading />}
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  )
}

 

 

 

💡 시도한 해결 방법 (Tried Solutions)

  1. useEffect defendency 조건을 추가 하면서 실행 하였으나 실행 되지 않음.
  2. isLoading 과 hasNext 의 값이 잘못된것으로 판단해서 값을 조정하면서 실행 하였으나 역시 실행되지 않음

 

최종 해결 방법 (Solution)

 

 fetchComments 함수 내부 로딩이 끝나는 구간까지 setTimeout을 적용하여 비동기적으로 실행

const fetchComments = () => {
    if (isLoading || !hasNext) return

    setIsLoading(true)

     setTimeout(() => {       // 비동기로 실행!
    const currentLength = comments.length
    const nextBatch = commentsMockData.slice(currentLength, currentLength + 10)

    setComments((prev) => [...prev, ...nextBatch])
    setHasNext(
      nextBatch.length === 10 &&
        currentLength + nextBatch.length < commentsMockData.length
    )

    setIsLoading(false)   
     }, 500)
  }

 

DOM 이 업데이트 되기 전에 IntersectionObsever가 등록이 되어서 스크롤을 내려도 등록할때 이미 화면에 있었기 때문에 실행이 되지 않았던 것으로 보임.

 

setTimeout 을 적용해서 dom이 완전히 렌더링 되고 다시 Comments가 업데이트 되면서 댓글 아래부분이 화면에서 내려간 후 IntersectionObsever 가 등록이 됨

 

이후 스크롤을 하게 되면 등록된 IntersectionObsever 가 보이면서 fetchComments가 실행

 

📘 순서 분석 (setTimeout 없이 실행된 경우, 동기적으로 실행 시)

(요약 :  DOM 이 생성되기 전에 intersectionObsever가 화면을 등록해서 스크롤를 내려도 같은화면이라고 인식 )

  1. 사용자가 스크롤해서 observerRef가 화면에 보임
  2. IntersectionObserver 콜백 → fetchComments() 호출
  3. setIsLoading(true)
  4. → set 함수는 비동기적이므로 실제 렌더링 전에 상태만 예약
  5. 바로 setComments(...) 실행됨
  6. 바로 setIsLoading(false) 실행됨 (❗ 이때 DOM은 아직 업데이트 안 됨)  --> DOM 이 업데이트 되지도 않았는데 순간적으로 Loading 이 ture에서 false로 변경
  7. comments가 업데이트되어 댓글 목록이 추가됨
  8. → 렌더링 예약됨 (비동기)
  9. 이때 observerRef는 화면 안에 계속 있기 때문에, 브라우저 입장에서는 새로운 변화가 없다고 판단함
  10. isLoading은 false니까 또 fetchComments() 호출할 수 있지만, 브라우저는 더 이상 intersection 이벤트를 발생시키지 않음

 

📘 순서 분석 (비동기로 실행된 경우) 

  1. React 초기 렌더 → observer 등록됨
  2. observerRef가 보임 → fetchComments() 실행됨
  3. setIsLoading(true) → 상태 예약됨
  4. setTimeout(...) → 큐에 들어감
  5. React 리렌더 (isLoading 반영됨)
  6. DOM 업데이트 완료
  7. setTimeout 콜백 실행 

  8. 내부의 set 함수가 재실행 되면서
  8. 댓글이 추가 되어 observerRef가 아래로 밀림

  9. DOM 렌더링 후 useEffect 내부 함수 다시 실행 

 10. 화면에 안보이는 하단 부observerRef 를 등록.
 11. 다시 스크롤하면 → obseverRef 가 다시보이면서 observer 콜백 트리거 → 반복