[개인 프로젝트 ShareLife] SNS 만들기 무한스크롤 구현(게시글)

규갓 God Gyu·2024년 8월 26일
2

프로젝트

목록 보기
66/81

자 이제 몇일 고생했던 무한 스크롤을 다시 해보려 한다...
해낼 수 있을까....

일단 공식문서를 참고해서 다시 한번 로직이 에러가 안뜨는지 체크해볼건데,

queryFn

  • 데이터 확인하는 Promise를 반환해야함
  • pageParam에 액세스할 수 있음

getNextPageParam getPreviousPageParam

  • 다음 또는 이전 페이지 가져오는 방법

을 핵심으로 생각하고 접근해보자

근데 지금 상황에서는 usePosts안에 게시글 조회 수정 삭제 생성이 다 진행되고 있어서 react-query로 비동기 처리를 위해 하나하나 단계를 밟아볼 예정이다.

usePosts 함수 안에 모든 post관련 기능을 다 담아냈더니 너무 가독성이 떨어진다
단일 훅 안에 모든 기능을 담아내는게 내 개인프로젝트에선 상관없지만 협업을 한다는 가정하에는 기능마다 분리시켜놓는게 오히려 좋을 것 같아서 훅 분리와 기본 tanstack-query로

게시글 가져오기 / 캐싱 / 자동 다시 가져오기 / 오류처리

등 다양한 비동기 처리 관련 작업을 진행하였다.

기존 usePosts안에 훅을 다 담은 이전 코드

import { useEffect, useState } from "react"
import { Post } from "./type"
import { useToast } from "@/components/ui/use-toast"
import { supabase } from "@/lib/supabase"

const usePosts = () => {
  const [posts, setPosts] = useState<Post[]>([])
  const [editPostId, setEditPostId] = useState<number | null>(null)
  const [title, setTitle] = useState<string>("")
  const [content, setContent] = useState<string>("")
  const [imageFile, setImageFile] = useState<File | null>(null)
  const [imagePreview, setImagePreview] = useState<string>("")
  const [showModal, setShowModal] = useState<boolean>(false)
  const [currentUserId, setCurrentUserId] = useState<string | null>(null)
  const [nickname, setNickname] = useState<string>("")
  const { toast } = useToast()

  const fetchPosts = async () => {
    const { data, error } = await supabase
      .from("posts")
      .select("*")
      .order("created_at", { ascending: false })

    if (error) {
      toast({
        title: "게시글 불러오기 오류",
        description: error.message,
      })
    } else {
      setPosts(data || [])
    }
  }

  useEffect(() => {
    fetchPosts()
  }, [])

  const uploadImage = async (file: File) => {
    const fileName = `image-${Date.now()}.png`
    const { error: uploadError } = await supabase.storage
      .from("images")
      .upload(fileName, file)

    if (uploadError) {
      toast({
        title: "이미지 업로드 중 오류가 발생하였습니다.",
        description: uploadError.message,
      })
      return null
    }
    return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`
  }

  const createPost = async (uploadedImageUrl: string) => {
    const { data } = await supabase.auth.getSession()
    const user = data.session?.user

    if (!user) {
      toast({
        title: "로그인이 필요합니다.",
        description: "로그인 후 다시 시도해주세요.",
      })
      return
    }

    const { error } = await supabase.from("posts").insert([
      {
        user_id: user.id,
        title,
        content,
        image_url: uploadedImageUrl,
      },
    ])

    if (error) {
      toast({
        title: "게시글 작성 중 오류가 발생하였습니다.",
        description: error.message,
      })
    } else {
      toast({ title: "게시글이 작성되었습니다.", description: "upload post" })
      resetForm()
      setShowModal(false)
      fetchPosts()
    }
  }

  const updatePost = async (uploadedImageUrl: string) => {
    if (!editPostId) return

    const { error } = await supabase
      .from("posts")
      .update({ title, content, image_url: uploadedImageUrl })
      .eq("id", editPostId)

    if (error) {
      toast({
        title: "게시글 수정 중 오류가 발생하였습니다.",
        description: error.message,
      })
    } else {
      toast({ title: "게시글이 수정되었습니다." })
      resetForm()
      setShowModal(false)
      fetchPosts()
    }
  }

  const deletePost = async (postId: number) => {
    const confirmDelete = window.confirm("정말로 이 게시글을 삭제하시겠습니까?")
    if (!confirmDelete) return

    const { error } = await supabase.from("posts").delete().eq("id", postId)

    if (error) {
      toast({
        title: "게시글 삭제 중 오류가 발생하였습니다.",
        description: error.message,
      })
    } else {
      toast({ title: "게시글이 삭제되었습니다." })
      fetchPosts()
    }
  }

  const handleCreateOrUpdatePost = async () => {
    if (editPostId) {
      if (imageFile) {
        const uploadedImageUrl = await uploadImage(imageFile)
        if (uploadedImageUrl) {
          await updatePost(uploadedImageUrl)
        }
      } else {
        await updatePost(imagePreview)
      }
    } else {
      if (imageFile) {
        const uploadedImageUrl = await uploadImage(imageFile)
        if (uploadedImageUrl) {
          await createPost(uploadedImageUrl)
        }
      } else {
        await createPost(imagePreview)
      }
    }
  }

  const handleEditPost = (post: Post) => {
    setEditPostId(post.id)
    setTitle(post.title)
    setContent(post.content)
    setImagePreview(post.image_url)
    setShowModal(true)
  }

  const resetForm = () => {
    setTitle("")
    setContent("")
    setImageFile(null)
    setImagePreview("")
    setEditPostId(null)
  }

  return {
    title,
    content,
    imagePreview,
    showModal,
    setTitle,
    setContent,
    setImageFile,
    setImagePreview, // Add this setter to be accessible from HomePage
    setShowModal,
    handleCreateOrUpdatePost,
    handleEditPost,
    deletePost,
    posts,
    fetchPosts,
    editPostId,
    currentUserId,
    setCurrentUserId,
    nickname,
    setNickname,
    resetForm, // Added resetForm to easily reset form fields
  }
}

export default usePosts

단일 훅으로 나누면서 리엑트 쿼리를 적용해볼 예정인데,

계획은 게시물 데이터 관리 / 게시물 생성,수정,삭제 / 이미지 업로드 / 폼 상태 관리(제목, 내용, 이미지 파일 및 미리보기 상태) 이렇게 구분지어서 react-query를 적용해보려한다.

useInfiniteQuery 무한스크롤 구현

참고할 커뮤니티

/* useInfiniteQuery 기본 구조 */
const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})

자주 쓰이는 반환 값

fetchNextPage
다음 페이지 요청 시 사용되는 메서드
hasNextPage
다음 페이지가 있는지 판별하는 boolean 값
isFetchingNextPage
다음 페이지를 불러오는 중인지 판별하는 boolean 값

옵션

getNextPageParam
lasePage, allPages는 콜백 함수에서 리턴된 값, lastPage는 직전에 반환된 리턴 값, allPages는 이제까지 받아온 전체 페이지
마지막 페이지면 undefined를 리턴하여 hasNextPage값을 false로 설정

추후 나도 저 커뮤니티 글처럼 개별 게시글 + 스켈레톤 작업을 해주긴 해야함

useInfiniteQuery를 사용하면서 posts, isLoading, isError, fetchNextPage, isFetchingNextPage를 반환해야함.

스크롤 내릴때 viewPort에 마지막 요소가 보여지는지 체크할 수 있는 react-intersection-observer의 useInview훅을 이용할 수 있음
요소가 뷰포트에 진입/제외되는 시점을 파악할 수 있음
viewPort에 보일 때를 체크할 element에 ref속성 걸면(<div ref={ref}/>) 이 요소가 뷰포트 안에 보였을 때 (inView == true) fetchNextPage를 실행해 다음 페이지를 가져옴
그 동안 isFetchingNextPage에서 스켈레톤 컴포넌트를 노출시킨다는데 아직 난 스켈레톤 컴포넌트가 없다 ㅠ

무한 스크롤 QueryFn 오버로드 호출 오류 or 타입 오류

일단 내가 오류가 계속 발생되는 부분때문에 여러 커뮤니티를 참고중인데,

계속 1~2개의 인수가 필요한데 3개를 가져온다고 뜬다.

그래서 아예 객체형식으로 useInfiniteQuery안에 인수를 넣어줬고,

그러면 queryFn에 대한 오버로드가 없다는 에러가 뜬다.

queryFn 은
queryFn: ({ pageParam = 1, queryKey }) => { ... } 과 같은 형태의 객체를 useInfiniteQuery에 전달을 해줘야하는데, 그럴려면 QueryFunctionContext 타입의 매개변수를 받아야 한다고 한다.

타입에서 에러가 생겨서 여러개의 타입을 추가해줘도

문제는 여전함

그래서 애초에 fetchPosts 에서 받는 pageParam에 대해

QueryFunctionContext 와 Promise로 Post[] 타입은 선언해주자
pageParam이 unknown형식
그리고 오히려 useInfiniteQuery와 일치하는 오버로드가 없다고 뜸

문제는 지금 useInfiniteQuery타입, queryFn, getNextPageParam함수의 정의가 일치하지 않고 있음.

어떠한 커뮤니티, gpt를 참고해도 타입이 해결되지 않고 있어서 타입에 대한 컴파일러와 호환성 문제가 아닌가싶어서 컴파일러를 체크해봄
문제없다고 뜨는데 흠...

어떠한 타입을 다 가져다 써봐도 오버로드 문제가 계속 뜬다

망할 오버로드 ^^ 니 뭐 좀 되냐? 커세어로 혼내줘??

오버로드 해결 코드

  type FetchPostsResult = {
    data: Post[]
    nextPage: number | undefined
  }

  const fetchPosts = async (
    pageParam: number = 1,
  ): Promise<FetchPostsResult> => {
    const { data, error } = await supabase
      .from("posts")
      .select("*", { count: "exact" })
      .order("created_at", { ascending: false })
      .range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1)

    if (error) {
      throw new Error(error.message)
    }

    return {
      data: data || [],
      nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
    }
  }

  const {
    data: postsData,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ["posts"],
    queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 1,
  })

  const posts = useMemo(
    () => postsData?.pages.flatMap((page) => page.data) || [],
    [postsData],
  )

이 영광을 claude에게 돌립니다 ㅠㅠ

일단 기존에 수많은 에러들과 무슨 차이가 있길래 이 코드는 허용이 되었는지 분석해보면,

useInfiniteQuery 설정 정확성

  • queryKey:['posts']로 쿼리가 정확시 식별되도록 함
  • queryFn: 오버로드 호출이 안되던 수많은 에러를 양상한 queryFn에 대해서 바로 fetchPosts로 값을 넣어준게 아닌, pageParam을 인수로 받고 그 값을 fetchPosts의 인자로 넘겨줘서 결과적으로 fetchPosts에서 데이터를 가져올 수 있게 조치함
  • getNextPageParam : lastPage에서 nextPage의 속성을 확인하여 다음 페이지 가져올지 boolean으로 결정함

주요 변경사항

  • initialPageParam을 추가해줌 - React Query 5.x버전에서 이 옵션이 필수라는데, useInfiniteQuery훅에서 사용하는 옵션으로, 처음으로 로드할 페이지의 '페이지 매개변수'(page parameter)를 지정해줌. 페이지를 초기화하거나 데이터를 처음 로드할 때 어떤 페이지를 기준으로 데이터를 가져올지를 명확히 하기 위함.

initialPageParam의 역할
useinfiniteQuery가 처음 데이터 페칭할 때 어떤 pagaParam을 사용할지 지정해줌.
기본적으로 1부터 시작하는 경우가 많음.
그래서 무한 스크롤 구현 시, initialPagaParam이 1로 설정되어 있다면,
첫 번째 데이터 로드는 pagaParam을 1로 사용하여 데이터를 가져옴
이 옵션이 필수가 된 이유는 무한 스크롤과 같은 페이지네이션 방식의 데이터 로드에서 첫 페이지가 어디서 시작해야 하는지를 명확히 지정할 필요가 있기 때문.
데이터 로드의 시작점을 명확히 정의하지 않는다면, 데이터가 어떻게 페칭될지 불확실해짐.
그래서 정리하면

  • 명확한 시작 시점
  • 다양한 페이지네이션 전략(페이지 네이션이 1이 아닌 다른 숫자나 값으로 시작해야 하는 경우 유연하게 설정할 수 있음)
  • 안정성 및 일관성
    의 이유로 꼭 필요한 옵션이 됨
  • infiniteQueryOptions를 별도의 변수로 분리하지 않고, 직접 useInfiniteQuery함수에 옵션 객체를 전달함
    여러번 테스트 하던 중
const infiniteQueryOptions = {
  queryKey: ["posts"],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextPage,
  initialPageParam: 1,
}

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(infiniteQueryOptions)

이렇게 따로 변수로 옵션을 빼서 넣어본적도 있었는데, 에러의 원인이라고 생각이 들진 않지만 공식문서처럼 그대로 적어줬음.

  • queryFn의 타입을 계속 와따가따 fetchPosts에서도 바꿔주고 별의 별 테스트를 해보았는데, typescript가 자동으로 추론할 수 있도록 수정함
  • getNextPageParam함수에서 lastPage의 타입 또한 명시적으로 지정하지 않고 TypeScript가 추론하도록 바꿔줌

결론적으로 명시적으로 type을 정의하지 않고, initialPageParam을 사용하였기 때문에 해결되지 않았나 추측해봄

무한 스크롤 성공한 코드 리뷰

type FetchPostsResult = {
  data: Post[]
  nextPage: number | undefined
}

일단 fetchPosts 함수의 반환 타입을 정의하였고, 여기서 data는 가져온 게시물 배열(Post는 따로 type.ts에서 선언해놨음)
nextPage는 다음 페이지가 number 타입인데 만약 없다면 undefined라고 명시해줌

const fetchPosts = async (pageParam: number = 1): Promise<FetchPostsResult> => {
  // ...
}

비동기로 데이터 받아서 처리하는 fetchPosts 함수는 매개변수로 pageParam: number=1을 받아옴
숫자가 1이라는 의미가 아닌 pageParam의 타입이 number라는 의미이고, 기본 값을 1로 설정해놨음.
이 의미는 혹여나 pageParam 인자가 제공되지 않으면, 기본적으로 1이 사용된다는 의미도 내포함
Promise<FetchPostsResult는 함수가 반환하는 값의 타입을 위에서 명시한걸 그대로 가져다 쓴건데, 그냥 FetchPostsResult 타입을 가져오는게 아닌, FetchPostsResult 타입의 객체를 포함하는 Promise를 반환한다는 의미이다.

여기서 Promise 는
비동기 작업의 완료 또는 실패와 그 결과 값을 나타내는 JS 객체
아직 완료되지 않은 비동기 작업의 결과를 나타내며, 작업이 완료(성공or실패)되면 그 결과에 접근하도록 도와준다.

  • Pending(대기):작업이 아직 완료되지 않은 상태
  • Fulfilled(이행됨):작업이 성공적 완료된 상태
  • Rejected(거부됨):작업이 실패한 상태

그래서 Promise를 왜 fetchPosts에서 사용했냐??
1. 비동기 작업 결과 반환 위해

  • fetchPosts함수는 서버에 요청을 보내 데이터를 가져오는 비동기 작업 수행
    서버 요청은 시간이 걸릴 수 있고, JS는 이러한 작업 기다리지 않고 코드의 나머지 부분 계속 실행할 수 있어야함
  • 비동기 작업 완료되었을 때 데이터 반환할 수 있게 해줌(성공 or 실패 중 하나)
  1. 코드 비동기적 작성 위해
    Promise 사용하면 비동기 작업 동기적 코드 작성 가능

여기까지는 Promise의 존재 이유에 대해 명확히 알아보긴 했지만 왜 꼭! 필요한지는 아직 감이 안잡힌다.
그 이유는 Promise사용없이 비동기 작업 처리하면 동기적 처리를 해주는 효과가 없어져서 비동기 작업 끝나기 전 다른 코드가 실행되서 예상치 못한 결과가 발생할 수 있음.
그래서 Promise없이 비동기 작업 처리하게 되면

const fetchPosts = (pageParam: number = 1) => {
  // 비동기 작업을 "기다리지 않고" 수행
  const { data, error } = supabase
    .from("posts")
    .select("*", { count: "exact" })
    .order("created_at", { ascending: false })
    .range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1)

  if (error) {
    throw new Error(error.message)
  }

  return {
    data: data || [],
    nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
  }
}

아래 작업들이 서버로 부터 데이터를 가지오지도 않았는데 반환값을 반환할 수도 있어서 error와 data가 undefined일 수 있다.

그럼 여기서 또 드는 의문
async와 await도 비동기 코드를 위해 사용하며 내부적으로 Promise를 사용하는 함수인데, 이것만 사용하면 안되나?

async await역할
JS엔진이 자동으로 Promise.resolve로 래핑해줌.
그리고 Promise를 내부적으로 수행하기 때문에 await 완료를 기다렸다가 결과를 반환함


아니였다. 실질적으로 타입을 명시적으로 기입하지 않아도 에러가 발생하지 않았다.
타입스크립트가 반환 타입을 자동으로 추론해주기 때문이다.

그러나 이미 Promise<>타입을 명시적으로 선언해주는게 더 명확하고, 강력하게 타입 체크할 수도있으므로 유지하기로 하였다.

const { data, error } = await supabase
  .from("posts")
  .select("*", { count: "exact" })
  .order("created_at", { ascending: false })
  .range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1)

이 부분은 비동기로 supabase의 posts 테이블에서 '*' 전체 행과 열을 가져오는데, 여기서
{count:'exact'}옵션은 쿼리 실행 시 총 행 수를 정확하게 계산하도록 요청
이 옵션은 페이지네이션과 같이 데이터 총 개수 알아야 할 때 유용(정확한 데이터 개수 반환)

.order("created_at", { ascending: false })
데이터 정렬하는 방법 지정
정렬 기준 - 'created_at'
{ascending:false} - 내림차순 정렬, 최신 게시물이 가장 먼저 나타남
.range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1)
range메서드는 데이터 가져올 범위 설정 SQL의 LIMIT과 OFFSET에 해당하는 역할
(pageParam - 1) ROWS_PER_PAGE 이부분은 시작 인덱스, pageParam은 현재 페이지 번호,
pageParam
ROWS_PER_PAGE - 1 는 끝 인덱스
내가 ROWS_PER_PAGE를 10으로 설정하고 pageParam=1이면 0,9 만큼의 데이터 집합만을 가져옴(10개씩)

if (error) {
  throw new Error(error.message)
}

return {
  data: data || [],
  nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
}
if (error) {
  throw new Error(error.message)
}

오류가 발생하면 error 객체에 오류 메시지가 포함되어 있는데,
throw new Error 구문을 사용하면 JS에서 새 오류를 생성하여 해당 함수의 실행을 즉시 중지 시키고 오류를 호출자에게 전달시켜줌

return {
  data: data || [],
  nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
}

비동기 함수여서 결과로 Promise를 반환해야함, 자동으로 Promise로 래핑됨

data: data || []
supabase의 select 쿼리가 데이터를 성공적으로 가져오면 data객체에 결과를 담음.
그러나 쿼리 안에 무조건 값이 있는건 아닐 수 있으므로 null 또는 undefined에 대한 조건인 []도 반환하도록 세팅해줘야함
결국은 data가 어떻게든 정의된 상태로 반환되도록 보장해줌
nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined
페이지 네이션을 위해 다음 페이지가 있는지 여부를 결정해줌
data?.length는 data가 null이 아닐 경우, 데이터의 길이를 검사함
그래서 data의 길이가 ROWS_PER_PAGE와 같으면 더 많은 데이터가 남아 있을 수 있어서 pageParam + 1로 다음페이지 요청할 수 있도록 함.
이 과정에서 totalCount를 세팅해주면 미리 pageParam이 몇개정도 있을지 알려줄 수 있다고 해서 나중에 리펙토링하면서 추가해볼 예정

const {
  data: postsData,
  error,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
  status,
} = useInfiniteQuery({
  queryKey: ["posts"],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextPage,
  initialPageParam: 1,
})

이후 위에서 설명해놓은 무한스크롤을 위한 데이터 fetching관리
queryFn은 각 페이지를 가져오는 함수
getNextPageParam은 다음 페이지 파라미터를 결정해줌

리마인드할 겸 다시 보면
data:postsData
쿼리로 가져온 데이터의 모든 페이지 결과를 포함하는 객체
error
쿼리를 수행하는 동안 발생한 오류 정보
fetchNextPage
다음 페이지 데이터 가져오기 위한 함수
스크롤을 끝까지 내렸을 때 호출해서 새로운 데이터 가져오는데 사용
hasNextPage
다음 페이지가 있는지 여부 나타내는 불리언 값 true 면 더 가져올 데이터가 남아있음
isFetchingNextPage
다음 페이지 가져오는 중인지 여부 나타내는 불리언 값
데이터가 모두 로드되면 false로 바뀜
status
쿼리의 현재 상태 나타내는 문자열(loading, error,success 중 하나의 값을 가짐)

useMemo

const posts = useMemo(
  () => postsData?.pages.flatMap((page) => page.data) || [],
  [postsData],
)

일단 useMemo는 컴포넌트가 리렌더링될 때 특정 값이 변경되지 않으면, 이전에 계산된 결과를 재사용하여 성능 최적화할 수 있음.

useMemo는 첫번째 인수로 전달된 함수를 실행해서 반환값을 메모이제이션함(기억함)
이 메모이제이션된 값은 두 번째 인수로 전달된 의존성 배열이 변경될 때만 다시 계산함

postsData?.pages.flatMap((page) => page.data) || []
여기서 postsData는 useInfiniteQuery로부터 반환된 모든 데이터 객체
postsData?.pages는 각 페이지의 데이터 배열
flatMap은 각 페이지의 데이터를 하나의 배열로 평평하게 만듬. --> 즉 여러 페이지의 데이터를 하나의 단일 배열로 결합함

postsData.pages = [
  { data: [post1, post2] },
  { data: [post3, post4] },
  { data: [post5] }
]

데이터가 이런식으로 들어있다 가정하면
flatMap을 사용하면

[post1, post2, post3, post4, post5]

이렇게 하나의 배열로 결합시켜줌 결국 맵핑을 새로 해주긴하는 메서드!
그러다 데이터가 없을땐 [] 빈배열 반환시켜줌

useMemo이유와 장점
1. 비용이 큰 계산 최적화
복잡한 수학연산, 데이터 변환, 반복되는 필터링 작업 등 필요할 때 유용
이전에 계싼된 값을 반환하여 계산을 다시 수행하지 않음(성능 최적화)
2. 불필요한 재계산 방지
컴포넌트 리렌더링될때마다 코드 실행하지 않고 의존성배열이전 결과 재사용할 수 있음
3. 의존성에 따른 재계산
의존성 배열에 있는 값이 변경될 때만 내부 함수가 다시 실행되어 값을 갱신
4. 리액트 컴포넌트 성능 최적화

useEffect와는 그럼 무슨 차이가?

useEffect

  • 컴포넌트 렌더링된 후 수행되어야 하는 작업(데이터 가져오기, 구독,DOM조작 등) 처리
  • 비동기작업,API호출 등 비 UI 관련 작업에 사용
    useMemo
  • 컴포넌트의 성능 최적화하기 위해 사용
  • 특정 연산이 불필요하게 반복되지 않도록, 값을 메모이제이션하여 재사용

useEffect는 컴포넌트 렌더링 된 이후 의존성 배열이 변경되면 다시 실행, 렌더링 이후 실행되므로, 화면에 그려지는 것과 독립적으로 동작

useMemo는 컴포넌트가 렌더링되는 동안 실행, 렌더링 진행 도중 특정 값이 필요할 때 즉시 평가하여 메모이제이션된 값을 반환, 렌더링 중에 계산되므로, 리턴하는 값이 렌더링된 결과물에 직접 영향

const loadMorePosts = () => {
  if (hasNextPage && !isFetchingNextPage) {
    fetchNextPage()
  }
}

더 많은 다음 페이지가 있고, fetching중이지 않으면 다음 페이지를 가져오는 함수

일단은 구현은 성공했으니 여기까지~~가 아니고
함수는 잘 짰으니 어떻게 활용하는지를 또 코드리뷰해봐야지

실제 무한 스크롤 사용하는 컴포넌트

"use client"

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import React, { useCallback, useRef } from "react"
import usePosts from "./usePosts"
import useAuth from "./useAuth"
import { AiOutlineHeart, AiFillHeart } from "react-icons/ai"
import useLike from "./useLike"

const HomePage = () => {
  const {
    title,
    content,
    imagePreview,
    showModal,
    setTitle,
    setContent,
    setImageFile,
    setShowModal,
    setImagePreview,
    handleCreateOrUpdatePost,
    handleEditPost,
    deletePost,
    posts,
    editPostId,
    loadMorePosts,
    hasNextPage,
    isFetchingNextPage,
  } = usePosts()

  const observer = useRef<IntersectionObserver | null>(null)
  const lastPostElementRef = useCallback(
    (node: HTMLDivElement | null) => {
      if (isFetchingNextPage) return
      if (observer.current) observer.current.disconnect()
      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting && hasNextPage) {
          loadMorePosts()
        }
      })
      if (node) observer.current.observe(node)
    },
    [isFetchingNextPage, hasNextPage, loadMorePosts],
  )

  const { currentUserId, nickname, handleLogout } = useAuth()
  const { toggleLike, isPostLikedByUser, getLikeCountForPost } = useLike({
    currentUserId,
  })

  return (
    <div>
      <Button onClick={handleLogout}>로그아웃</Button>
      <Dialog open={showModal} onOpenChange={setShowModal}>
        <DialogTrigger>{nickname}님의 일상을 남겨보세요!</DialogTrigger>
        <DialogContent>
          <DialogHeader>
            <Input
              placeholder="제목을 작성해주세요"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
            <Input
              placeholder={`${nickname}님, 무슨 일상을 공유하고 싶으신가요?`}
              value={content}
              onChange={(e) => setContent(e.target.value)}
              className="mt-2"
            />
            <Input
              type="file"
              accept="image/*"
              onChange={(e) => {
                if (e.target.files && e.target.files[0]) {
                  const file = e.target.files[0]
                  setImageFile(file)
                  setImagePreview(URL.createObjectURL(file))
                }
              }}
            />
            {imagePreview && (
              <img src={imagePreview} alt="Preview" className="mt-2" />
            )}
            <Button onClick={handleCreateOrUpdatePost} className="mt-4">
              {editPostId ? "수정" : "게시"}
            </Button>
          </DialogHeader>
        </DialogContent>
      </Dialog>
      <div className="mt-8">
        {posts.map((post, index) => {
          const isLiked = isPostLikedByUser(post.id)
          const likeCount = getLikeCountForPost(post.id)

          return (
            <div
              key={post.id}
              className="border p-4 mb-4"
              ref={index === posts.length - 1 ? lastPostElementRef : null}
            >
              <h3 className="font-bold">{post.title}</h3>
              <p>{post.content}</p>
              {post.image_url && (
                <img src={post.image_url} alt="Post Image" className="mt-2" />
              )}
              <div className="flex items-center mt-2">
                <button
                  onClick={() => toggleLike(post.id)} // currentUserId 제거
                  className="mr-2 text-xl"
                >
                  {isLiked ? (
                    <AiFillHeart className="text-red-500" />
                  ) : (
                    <AiOutlineHeart className="text-gray-500" />
                  )}
                </button>
                <span>{likeCount} Likes</span>
              </div>
              {post.user_id === currentUserId && (
                <>
                  <Button onClick={() => handleEditPost(post)} className="mt-2">
                    수정
                  </Button>
                  <Button
                    onClick={() => deletePost(post.id)}
                    className="mt-2 ml-2"
                  >
                    삭제
                  </Button>
                </>
              )}
            </div>
          )
        })}
        {isFetchingNextPage && <div>Loading more posts...</div>}
      </div>
    </div>
  )
}

export default HomePage

주목할 부분은
Intersection Observer API
페이지의 끝에 도달했을 때 다음 게시물들을 가져오기 위한 트리거 역할

const observer = useRef<IntersectionObserver | null>(null)
const lastPostElementRef = useCallback(
  (node: HTMLDivElement | null) => {
    if (isFetchingNextPage) return
    if (observer.current) observer.current.disconnect()
    observer.current = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasNextPage) {
        loadMorePosts()
      }
    })
    if (node) observer.current.observe(node)
  },
  [isFetchingNextPage, hasNextPage, loadMorePosts],
)

observer
useRef 사용해 IntersectionObserver 인스턴스를 저장할 참조를 생성함

여기서 useRef IntersectionObserver이란?
useRef
React 훅 중 하나로, 변경 가능한 참조 객체를 생성하고 유지하기 위해 사용됨. 일반적으로 DOM 요소에 접근하거나 유지해야 하는 값을 저장할 때 사용.
useRef를 사용하면 컴포넌트 리렌더링되도 참조하는 값은 변경되지 않고 유지
특징으로는

  • 초기값: 초기값이 설정되면 current속성에 저장됨
  • 리렌더링하지않음: .current값 변경해도 컴포넌트가 리렌더링하지 않음. 즉, 어떤 값이 유지되어야 하지만 변경이 렌더링을 트리거하지 않아야 할 때 유용함
  • DOM요소 접근: 일반적으로 React에서 useRef로 DOM요소에 직접 접근함

IntersectionObserver
브라우저 내장 API로ㅓ, 특정요소가 뷰포트에 들어오거나 나갈 때, 또는 다른 요소와 교차할 때 알림을 받을 수 있게 해주는 API
특징은

  • 비동기적 관찰: 메인 스레드를 차단하지 않고 요소의 가시성 변화를 비동기적으로 감지
  • 다양한 사용 사례: 무한 스크롤, lazy loading 이미지, 애니메이션 트리거 등 요소의 가시성에 따라 다양한 기능 구현 가능
  • 콜백함수:가시성 상태를 변경할 때 실행되는 콜백함수 지정 가능

결국 observer가 useRef사용해 IntersectionObserver 인스턴스 저장할 참조 생성했으니, 이 참조는 컴포넌트 리렌더링 되도 변경안됨

lastPostElementRef:useCallback 사용해서 최적화된 콜백 함수 생성.
이 함수는 스크롤이 끝에 도달할 때 실행될 로직을 포함

useCallback이란?
react에서 제공하는 훅, 함수 컴포넌트 내에서 메모이제이션된 콜백 함수를 반환함
성능 최적화를 위해 사용, 특정 의존성이 변경되지 않는 한 같은 함수를 재사용할 수 있게 도와줌

이 훅은 함수 컴포넌트가 렌더링 될 때마다 새롭게 생성되는 함수를 캐싱하여, 특정 의존성이 변경되지 않는 한 동일한 함수 객체를 반환해줌

const memoizedCallback = useCallback(
  () => {
    // 함수 로직
  },
  [dependency1, dependency2] // 의존성 배열
);

첫 번째 매개변수는 메모이제이션하고자 하는 함수 자체(이전의 값을 계속 기억)
두 번째 매개변수는 의존성 배열로 배열에 포함된 값이 변경되면 메모이제이션된 함수가 새로 생성됨. 만약 의존성 배열이 비어있다면, 해당 함수는 컴포넌트의 첫 번째 렌더링 시에만 생성되고 이후엔 동일한 함수를 계속 사용하게 됨

useCallback이 필요한 이유는
react에서 함수 컴포넌트가 렌더링될 때마다 내부에 정의된 함수들이 새로 생성됨. 만약 컴포넌트의 자식 요소에 이러한 함수들을 props로 전달하면, 부모 컴포넌트가 리렌더링될때마다 자식 컴포넌트도 불필요하게 리렌더링 될 수 있음. useCallback을 사용하면 함수의 참조 동일성을 보장해 불필요한 리렌더링 방지함

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  // 함수가 리렌더링될 때마다 새로 생성됨
  const increment = () => {
    setCount(count + 1);
  };

  return <ChildComponent onClick={increment} />;
};

useCallback을 사용하지 않는다면
ParantComponent가 리렌더링되면 increment함수가 새로 생성되고 그걸 인식한 ChildComponent도 불필요하게 리렌더링됨

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  // 함수 메모이제이션: count가 변경될 때만 새 함수 생성
  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return <ChildComponent onClick={increment} />;
};

uiseCallback으로 increment함수를 메모이제이션했으므로, increment는 count가 변경될 때만 새로 생성되고 count가 변경되지 않으면 이전의 메모이제이션된 함수를 계속 사용 그래서 ParentComponent가 리렌더링되도 ChildComponent가 불필요하게 리렌더링되지 않음

지금은 예시로 count만 넣어주었지만, ParentComponent의 부모컴포넌트가 리렌더링되거나, 다른 상태가 리렌더링되는 경우도 있으므로, useCallback은 그 중 count에 대해서만 체크해서 리렌더링할지 말지 정해줌

그래서 다시 코드로 넘어가서
lastPostElementRef의 의존성 배열에 담긴[isFetchingnextPage, hasNextPage, loadMorePosts]에 대해 변화가 감지되면?? 그때 이제 내부 콜백함수가 생성됨.

node는 마지막 게시물의 DOM 노드를 참조
isFetchingNextPage로 추가 데이터 로딩 중이면 새로운 요청 방지시킬 수 있음
observer해제- IntersectionObserver 설정 전 기존 옵저버가 관찰하던 모든 대상에 대한 관찰 해제
이후 새로 설정된 옵저버는 entries를 통해 화면에 마지막 게시물이 보이는지 확인하는 코드

이후 실제 ui코드에서

ref={index === posts.length - 1 ? lastPostElementRef : null}

마지막 게시물에 대해 lastPostElementRef 참조 설정해서 마지막 게시물이 화면에 보이면 추가 데이터 요청

그래서 무한 스크롤 동작 흐름은

  • 페이지 첫 렌더링될 때 usePosts훅이 초기 데이터 가져옴
  • 스크롤이 마지막 게시물에 도달하면 IntersectionObserver가 트리거되어 loadMorePosts함수 호출
  • loadMorePosts 함수는 hasNextPage와 isFetchingNextPage상태를 확인하여 더 많은 데이터 요청할 수 있는지 판단, 가능하면 추가 데이터 요청
  • 새로운 데이터 로드되면, 상태가 업데이트, 컴포넌트가 다시 렌더링. 그 과정에서 lastPostElementRef는 업데이트된 마지막 게시물에 대해 다시 설정
  • 더이상 데이서 요청할 수 없을 때까지 반복(hasNextPage가 false될때까지)
profile
웹 개발자 되고 시포용

1개의 댓글

comment-user-thumbnail
2024년 8월 27일

오버로듴ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ

답글 달기