LightHouse 활용하여 성능 최적화하기

하영·2024년 11월 26일
0

팀프로젝트

목록 보기
27/27

마이페이지 페이지네이션과 캐싱을 활용한 성능 최적화

중간발표 전 마이페이지 LightHouse

최종발표 마이페이지 LightHouse

🎉 65점 → 81점

개선된 점

  1. First Contentful Paint (FCP): 0.8s → 0.3s ✨
    • 초기 콘텐츠가 화면에 렌더링되는 시간 단축
    • 원인: 불필요한 렌더링 블로킹 리소스 제거, 이미지 최적화 등이 적용
  2. Largest Contentful Paint (LCP): 4.7s → 2.6s ✨
    • 주요 콘텐츠가 화면에 렌더링되는 시간이 절반가량 개선
    • 원인: 큰 이미지를 적절히 크기 조정 및 lazy loading 개선, 렌더링 최적화.
  3. Cumulative Layout Shift (CLS): 0.228 → 0.106 ✨
    • 레이아웃 이동이 줄어들어 사용자 경험이 개선
    • 원인: 이미지 크기 명시, 동적 콘텐츠 로딩 안정화 등이 기여했을 가능성

초기코드 👩🏻‍💻

랭킹 데이터 불러오기

// 경험치에 따라 레벨을 반복적으로 업데이트하는 함수
export const updateUserLevel = async (userId: string) => {
  try {
    const { data: userData, error: userError } = await supabase
      .from("USER_TABLE")
      .select("user_exp, user_rank")
      .eq("user_id", userId)
      .single();

    if (userError || !userData) {
      console.error("사용자 데이터 불러오기 실패", userError?.message);
      return;
    }

    const { user_exp } = userData;
    let { user_rank } = userData;

    const { data: rankData, error: rankError } = await supabase
      .from("RANK_TABLE")
      .select("rank_base, exp")
      .lte("exp", user_exp) // user_exp 이하의 exp를 가져오기
      .order("exp", { ascending: false }) // exp 기준 내림차순으로 정렬
      .limit(1); // 가장 높은 exp 기준 한 개만 가져오기

    if (rankError || !rankData) {
      console.log("레벨 기준 데이터 불러오기 실패", rankError?.message);
      return; // 조건에 맞는 레벨 기준이 없으면 종료
    }
    const { rank_base } = rankData[0];

    //현재 레벨과 DB 레벨 비교
    if (user_rank !== rank_base) {
      user_rank = rank_base;
      const { error: updateError } = await supabase.from("USER_TABLE").update({ user_rank }).eq("user_id", userId);

      if (updateError) {
        console.log("USER_TABLE 업데이트 오류", updateError.message);
      } else {
        console.log("USER_TABLE 업데이트 성공", { user_exp, user_rank });
      }
    }
  } catch (error) {
    console.error("레벨 업데이트 중 오류 발생:", error);
  }
};

단순 스크롤 구현 (무한스크롤, 페이지네이션 X)

const UserComment = ({ userId }: { userId: string }) => {
  const [comments, setComments] = useState<UserComment[] | null>(null);

  useEffect(() => {
    const loadCommentsWithRecipes = async () => {
      const commentsData = await fetchUserComments(userId);
      if (commentsData?.comments?.length) {
        // 댓글의 post_id를 이용해 레시피 정보 추가
        const commentsWithRecipes = await Promise.all(
          commentsData.comments.map(async (comment) => {
            const recipeData = await fetchRecipeByPostId(comment.post_id);
            return { ...comment, recipe: recipeData };
          })
        );
        setComments(commentsWithRecipes);
      }
    };

    loadCommentsWithRecipes();
  }, [userId]);

  if (!comments)
    return (
      <div className="flex w-full flex-col items-center justify-center gap-2 pt-6">
        <Image src={AlertIcon} alt="느낌표 아이콘" width={30} height={30} />
        아직 작성한 댓글이 없어요!
      </div>
    );

// 하단 로직 생략

코드 수정 🚧

사용자 레벨 데이터 불러오는 로직 → TanStack Query 적용 + 캐싱 처리

export const useUserLevel = (userId: string) => {
  const queryClient = useQueryClient();

  // 사용자 데이터 가져오기
  const { data: userData, isLoading: isUserLoading } = useQuery({
    queryKey: ["userData", userId],
    queryFn: () => fetchUserData(userId),
    enabled: !!userId, // userId가 있을 때만 쿼리가 활성화 (조건부 실행)
    staleTime: 1000 * 60 * 5, // 불필요한 재요청을 방지
    gcTime: 1000 * 60 * 10, // 캐시에 저장된 데이터는 마지막으로 접근된 후 10분 동안 유지
    refetchOnWindowFocus: false, // 사용자가 브라우저 창으로 돌아와도 데이터를 안 가져옴
    refetchInterval: false // 주기적으로 데이터를 재요청하지 않음
  });

  // 랭킹 데이터 가져오기
  const { data: rankData, isLoading: isRankLoading } = useQuery({
    queryKey: ["rankData", userData?.user_exp],
    queryFn: () => fetchRankData(userData!.user_exp),
    enabled: !!userData?.user_exp,
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 10,
    refetchOnWindowFocus: false,
    refetchInterval: false
  });

  // 사용자 레벨 업데이트
  const { mutate: updateRank } = useMutation({
    mutationFn: updateUserRank,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["userData", userId] });
    }
  });

  // 사용자 랭킹 비교 및 업데이트
  const checkAndUpdateRank = () => {
    if (userData && rankData && userData.user_rank !== rankData.rank_base) {
      updateRank({ userId, userRank: rankData.rank_base });
    }
  };

  return {
    userData,
    rankData,
    isLoading: isUserLoading || isRankLoading,
    checkAndUpdateRank
  };
};

Pagination 적용

import LeftArrow from "@images/LeftArrow";
import RightArrow from "@images/RightArrow";

interface PaginationProps {
  currentPage: number;
  pageSize: number;
  totalItems: number;
  className?: string;
  buttonClassName?: string;
  onPageChange: (page: number) => void;
}

const Pagination: React.FC<PaginationProps> = ({
  currentPage,
  pageSize,
  totalItems,
  className,
  buttonClassName,
  onPageChange
}) => {
  const totalPages = Math.ceil(totalItems / pageSize);
  const maxVisiblePages = 5;

  // 표시할 페이지 범위를 계산
  const halfVisiblePages = Math.floor(maxVisiblePages / 2);
  let startPage = Math.max(1, currentPage - halfVisiblePages);
  let endPage = Math.min(totalPages, currentPage + halfVisiblePages);

  // 페이지 범위가 5개 미만일 때 양쪽으로 채워서 항상 5개가 되도록 조정
  if (endPage - startPage + 1 < maxVisiblePages) {
    if (startPage === 1) {
      endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
    } else if (endPage === totalPages) {
      startPage = Math.max(1, endPage - maxVisiblePages + 1);
    }
  }

  const handlePageClick = (page: number) => {
    if (page > 0 && page <= totalPages) {
      onPageChange(page);
    }
  };

  return (
    <div className={`text-body-14 ${className}`}>
      {/* 이전 버튼 */}
      <div className="flex items-center justify-between">
        <button onClick={() => handlePageClick(currentPage - 1)} className={`${buttonClassName}`}>
          <LeftArrow className="cursor-pointer stroke-[#C4C3BE] hover:stroke-Primary-300" />
        </button>

        {/* 페이지 번호 */}
        {Array.from({ length: maxVisiblePages }, (_, index) => {
          const page = startPage + index;
          return (
            <button
              key={index}
              onClick={() => handlePageClick(page)}
              disabled={page > totalPages}
              className={`min-h-[1.875rem;] min-w-[1.875rem;] gap-6 rounded sm:gap-3 ${
                currentPage === page ? "rounded-full bg-Primary-300 text-white" : "text-Primary-300"
              } ${page > totalPages ? "cursor-not-allowed opacity-50" : ""}`}
            >
              {page}
            </button>
          );
        })}

        {/* 다음 버튼 */}
        <button onClick={() => handlePageClick(currentPage + 1)} className={`${buttonClassName}`}>
          <RightArrow className="cursor-pointer stroke-[#C4C3BE] hover:stroke-Primary-300" />
        </button>
      </div>
    </div>
  );
};

export default Pagination;

집밥도감 사이트는 이미지가 많은 편이어서 단순 스크롤로 댓글을 보여줄 시 이미지 렌더링이 오래걸린다.

게다가 이미지 뿐만 아니라 각 댓글 별 사용자 정보, 게시글 정보까지 불러오기 때문에 Pagination으로 나눠서 가져오도록 수정하였다.

Pagination은 집밥도감 사이트 전반적으로 사용하고 있어서 공용컴포넌트로 제작하고 마이페이지에 적용해주었다.


Comment 컴포넌트에 적용

const UserComment = ({ userId }: { userId: string }) => {
  const [comments, setComments] = useState<UserComment[] | null>(null);
  const [commentCount, setCommentCount] = useState(0);
  const [currentPage, setCurrentPage] = useState(1);
  const [isLoading, setIsLoading] = useState(false);
  const pageSize = 4; // 페이지당 댓글 수

  const loadCommentsWithRecipes = async (page: number) => {
    setIsLoading(true);
    const { comments: commentsData, commentCount } = await fetchUserComments(userId, page, pageSize);
    setCommentCount(commentCount);

    if (commentsData?.length) {
      const commentsWithRecipes = await Promise.all(
        commentsData.map(async (comment) => {
          const recipeData = await fetchRecipeByPostId(comment.post_id);
          return { ...comment, recipe: recipeData };
        })
      );
      setComments(commentsWithRecipes);
    } else {
      setComments(null);
    }
    setIsLoading(false);
  };
  
  // 하단 로직 생략
  
  {/* 페이지네이션 */}
      <Pagination
        currentPage={currentPage}
        pageSize={pageSize}
        totalItems={commentCount}
        onPageChange={(page) => setCurrentPage(page)}
        className="min-w-[372px] gap-6"
        buttonClassName="px-10"
      />
      
  }

const pageSize = 4 값을 지정해서 단순 스크롤로 댓글을 불러오는게 아니라 페이지네이션으로 4개로 끊어서 가져오게 했다.

적용 완료 페이지 ✨

주요 개선 사항

  1. 이미지 최적화
    • Properly size images: 리포트에 따르면 이미지 크기 조정으로 142KB 절약 가능.
    • Largest Contentful Paint의 주요 요소가 더 빠르게 로드되며 LCP 점수 개선
  2. 렌더링 블로킹 리소스 제거
    • Render-blocking resources의 지연이 560ms에서 40ms로 감소.
    • 초기 화면 로드 속도를 크게 개선한 요인.
  3. 레이아웃 안정화
    • Cumulative Layout Shift 점수 개선 (0.228 → 0.106).
    • 이미지 크기를 명확히 지정하거나 폰트 로딩 전략 개선 덕분일 가능성.
  4. 불필요한 자바스크립트 감소
    • Unused JavaScript가 이전에 564KB였으나, 현재는 개선된 상태로 보임
profile
왕쪼랩 탈출 목표자의 코딩 공부기록

0개의 댓글

관련 채용 정보