2025. 8. 18. 19:18ㆍ문제 해결 및 Tip
🧩 문제 상황 (What Happened)
- 무한스크롤이 동작 하지 않음
- 첫 렌더링 시에 useEffect가 실행 되면서 comments를 초기화 시키고 fetchComments를 통해 댓글 10개를 더미 데이터에서 가져와서 화면에 댓글 10개를 렌더링 함. ( 실행됨 )
- 이후 스크롤을 내리면 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)
- useEffect defendency 조건을 추가 하면서 실행 하였으나 실행 되지 않음.
- 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가 화면을 등록해서 스크롤를 내려도 같은화면이라고 인식 )
- 사용자가 스크롤해서 observerRef가 화면에 보임
- IntersectionObserver 콜백 → fetchComments() 호출
- setIsLoading(true)
- → set 함수는 비동기적이므로 실제 렌더링 전에 상태만 예약
- 바로 setComments(...) 실행됨
- 바로 setIsLoading(false) 실행됨 (❗ 이때 DOM은 아직 업데이트 안 됨) --> DOM 이 업데이트 되지도 않았는데 순간적으로 Loading 이 ture에서 false로 변경
- comments가 업데이트되어 댓글 목록이 추가됨
- → 렌더링 예약됨 (비동기)
- 이때 observerRef는 화면 안에 계속 있기 때문에, 브라우저 입장에서는 새로운 변화가 없다고 판단함
- 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 콜백 트리거 → 반복
'문제 해결 및 Tip' 카테고리의 다른 글
| 깃허브 action 과 로컬에서 파일명 대/소문자 인식에 따른 오류 (4) | 2025.08.20 |
|---|---|
| Enter 키 이벤트 한글 입력 오류 (1) | 2025.08.20 |
| AWS cloudfront 배포 페이지 새로고침 오류 (0) | 2025.08.18 |
| OpenWeather API 로 날씨 렌더링 (0) | 2025.04.18 |
| 모달 창 만들기 (0) | 2025.04.17 |