Study/프로젝트 회고

Gallery Hub 프로젝트 회고

haseulla 2026. 1. 22. 18:03

1. 프로젝트 개요

디자인 전공을 하다가 실제로 구현되는 과정이 재밌어서 컴퓨터공학을 복수전공하고 프론트엔드 개발 기술을 배운 지 3년 정도가 되었다. 나의 아이디어를 구체화하기 위해서 배운 기술이었으나 당장 다른 사람들이 기획한 아이디어를 구현하는 데 급급하기만 해서 항상 아쉬움이 남았었다. 이번 기회를 통해서 내가 필요하다고 생각하는 서비스를 구체화하여 내 손으로 직접 0부터 100까지 만들어보는 경험을 가지고 싶어서 프로젝트를 시작하게 되었다.

전시회에 관심이 많은데 정보를 하나하나 검색해보거나 운 좋게 원하는 광고가 뜨면 전시회를 보러 다녀서 원하는 전시회를 보러 가곤 했다. 이렇게 한눈에 볼 수 있는 서비스가 없어 어떤 전시회가 운영되는지 몰라서 방문하지 못한 경험이 꽤 있었다. 하나의 플랫폼에서 전시회 공연에 대한 정보를 모아두고 카테고리로 쉽게 필터링할 수 있으며 저장하기 등의 기능으로 개인화할 수 있는 기능이 있으면 좋겠다고 생각이 들어서 이번 서비스를 기획하게 되었다.

혼자서 프로젝트를 진행하여 기획부터 디자인, 프론트엔드 나아가 백엔드까지 AI의 도움을 받아서 서비스를 구체화할 수 있었다. 이왕 프로젝트를 하는 김에 내가 구현해보고 싶었던 기능, 배워보고 싶었던 기능까지 넣어서 구현해보기로 했다. 내가 할 수 있는 모든 것들을 모아서 보여주자는 목표로 구현했다.

 

주요 구현 기능

  • 전시회 목록 조회
  • 전시회 상세 정보
  • 찜 목록 관리
  • 인증 시스템(로그인, 회원가입

프로젝트 메인 화면 스크린샷


2. 핵심 성과

성능 개선 지표

  • 위시리스트 API 호출 최적화: N번 → 1번 (90% 감소)
  • 상세 페이지 성능: FCP 0.5s → 0.2s, LCP 1.7s → 0.7s, Lighthouse 93점 → 100점
  • 목록 페이지 성능: FCP 0.5s → 0.2s, CLS 0.045 → 0 (완전 제거)

위시리스트 API 호출 최적화

위시리스트 API 호출 최적화 변경 전

 

위시리스트 API 호출 최적화 변경 후

 

 

상세 페이지 성능

 

Before 라이트하우스 결과

상세 페이지 성능 Before
상세 페이지 성능 Before

 

 

After 라이트하우스 결과

상세 페이지 성능 After

 

상세 페이지 성능 After

 


 

 

3. 기술적 도전과 해결

3.1 위시리스트 API 호출 최적화

문제 상황

각 전시 아이템마다 개별적으로 위시리스트 체크 API를 호출하여 목록 페이지에서 9개 아이템 조회 시 총 9번의 API 호출이 발생했다.

위시리스트 API 호출 최적화 변경 전



해결 방법

전체 위시리스트를 한 번에 조회하고 Set 자료구조를 활용해 O(1) 시간 복잡도로 검색하도록 개선했다.

 
 
const { data: wishlistData } = useWishList();
const wishlistedIds = new Set(
  wishlistData?.map((item) => item.item_id) || []
);
위시리스트 API 호출 최적화 변경 후

 

배운 점

단순히 코드 줄 수를 줄이는 것보다 API 호출 최소화가 성능에 훨씬 큰 영향을 미친다는 것을 체감했다. 또한 Set 자료구조를 활용해 O(1) 검색을 구현하면서, 적절한 자료구조 선택이 중요하다는 것을 깨달았다. 앞으로 알고리즘/자료구조 학습을 꾸준히 하고, 실무에서 어떻게 활용할 수 있을지 학습해야겠다고 생각했다. 


3.2 목록 페이지 초기 렌더링 속도 개선

문제 상황

400여 개의 전시 데이터를 한 번에 호출하여 초기 로딩 시간이 오래 걸렸다.

해결 방법

TanStack Query의 InfiniteQuery와 Intersection Observer를 활용한 무한스크롤을 구현하여 한 번에 9개씩만 데이터를 가져오도록 했다. 이를 통해 FCP를 0.5s에서 0.2s로 개선했다.

 

 
 
useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    },
    { threshold: 1.0 }
  );

  const currentTarget = observerTarget.current;
  if (currentTarget) {
    observer.observe(currentTarget);
  }

  return () => {
    if (currentTarget) {
      observer.unobserve(currentTarget);
    }
  };
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);

배운 점

무한스크롤은 불러올 데이터가 많은 리스트 페이지에서 필수적으로 넣어야 할 기능인 것 같다.


3.3 상세 페이지 LCP 개선

문제 상황

상세 페이지의 메인 이미지 로딩 속도가 느려 사용자 경험이 저하되었다.

 

해결 방법

useEffect + setState 기반의 이미지 로딩 로직을 제거하고, useMemo를 사용해 이미지 src를 최초 렌더 시점에 확정했다. 이를 통해 브라우저가 첫 페인트 시점부터 실제 이미지 다운로드를 시작할 수 있도록 렌더링 경로를 단순화했다.

 

기존 코드

useEffect(() => {
  if (data?.imgUrl) {
    setImageSrc(sanitizeImageUrl(data.imgUrl));
  }
}, [data?.imgUrl]);

return (
  <Image
    src={imageSrc}
    alt={he.decode(data.title)}
    fill
    className="object-cover"
    priority
    onError={() => {
      console.log("이미지 로드 실패:", imageSrc);
      setImageSrc("/images/placeholder.svg");
    }}
  />
)

 

개선 코드

const imageSrc = useMemo(() => {
  if (!data?.imgUrl) return "/images/placeholder.svg";
  return sanitizeImageUrl(data.imgUrl);
}, [data?.imgUrl]);

return (
  <ImageSection imageSrc={imageSrc} title={data.title} />
)

 

Lighthouse LCP 개선 결과 (1.7s → 0.7s 비교)

상세 페이지 항목별 점수 비교
상세 페이지 성능 Performance 세부 항목 비교

 

 

배운 점

처음에는 사용자의 불안감을 줄이기 위해 임시 이미지를 먼저 보여주고 원본으로 교체하는 방식이 좋다고 생각했다. 하지만 실제로는 이미지를 교체하는 과정 자체가 추가 렌더링 비용을 발생시켰다. 상세 페이지의 메인 이미지처럼 핵심 콘텐츠는 처음부터 원본을 로드하는 것이 LCP 개선에 더 효과적이라는 것을 알게 되었다.


3.4 TanStack Query의 InvalidQueries를 활용한 효율적인 캐시 관리

문제 상황

상세 페이지에서 찜하기 버튼을 클릭했을 때 관련된 데이터만 선택적으로 업데이트해야 했다.

 

해결 방법

계층적 Query Key를 설계하고 invalidateQueries를 사용해 필요한 캐시만 무효화했다.

const WISHLIST_KEYS = {
  all: ["wishlist"] as const,
  lists: () => [...WISHLIST_KEYS.all, "list"] as const,
  check: (itemId: string) => [...WISHLIST_KEYS.all, "check", itemId] as const,
};
wishlist/
├── list/           ← 전체 좋아요 목록
└── check/
    ├── item-123/   ← 특정 아이템의 좋아요 상태
    └── item-456/

좋아요를 추가했을 때 업데이트해야 할 곳은 두 군데다.

  1. 상세 페이지의 하트 버튼 → ["wishlist", "check", "item-123"]
  2. 좋아요 목록 페이지 → ["wishlist", "list"]
 
// 필요한 것만 무효화
queryClient.invalidateQueries({
  queryKey: ["wishlist", "list"]
});
queryClient.invalidateQueries({
  queryKey: ["wishlist", "check", "item-123"]
});

 

배운 점

계층적 Query Key 설계 덕분에 필요한 부분만 선택적으로 무효화할 수 있었다. invalidateQueries는 데이터를 삭제하는 것이 아니라 "이 데이터는 낡았으니 다시 가져와라"고 표시하는 역할을 한다. 이를 통해 불필요한 네트워크 요청을 최소화하고 서버 부담도 줄일 수 있었다.


3.5 Suspense와 Hydration Error 해결

문제 상황

sonner 라이브러리를 사용한 Toast 알림이 화면에 나타나지 않았다.

 

원인 분석

Next.js의 useSearchParams 훅으로 인해 서버가 최초 HTML을 만들 때 클라이언트 전용 훅의 최종값을 알지 못한 채로 렌더링했다. 브라우저에서 React가 hydrate될 때 DOM 불일치가 발생하여 Hydration error가 발생했고, React는 해당 컴포넌트 트리의 렌더링을 불안정하게 처리하여 Toast가 노출되지 않았다.

 

해결 방법

useSearchParams를 사용하는 컴포넌트를 Suspense로 감싸주었다.

// Before: Toast 안 보임
export default function LoginPage() {
  return <LoginForm />;
}

// After: Toast 정상 작동
export default function LoginPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LoginForm />
    </Suspense>
  );
}

 

 

배운 점
Next.js 공식 문서에 따르면 useSearchParams를 사용하는 컴포넌트는 반드시 Suspense boundary로 감싸야 한다. 이를 통해 서버와 클라이언트 간의 렌더링 불일치를 방지할 수 있다.



4. Next.js 깊게 이해하게 된 부분

4.1 서버/클라이언트 환경에서의 API 호출

문제 상황
상대 경로로 API를 호출했더니 서버 환경에서 URL을 인식하지 못하는 에러가 발생했다. 

[TypeError: Failed to parse URL from /api/exhibitions?page=1&limit=20]
{
  digest: '1099032911',
  [cause]: [TypeError: Invalid URL] {
    code: 'ERR_INVALID_URL',
    input: '/api/exhibitions?page=1&limit=20'
  }
}

 

원인 분석

Next.js의 Suspense SSR 때문에 서버에서 먼저 API 호출을 시도했다. 상대 경로 /api/...는 서버 환경에서 인식할 수 없어 실패했고, 이후 클라이언트에서 재시도하여 성공했다.

 

해결 방법

서버 환경에서도 절대 URL로 요청하도록 수정했다.

export function getBaseUrl() {
  // 브라우저 환경
  if (typeof window !== "undefined") {
    return "";
  }

  // 서버 환경 - 환경변수 사용
  return process.env.NEXT_PUBLIC_SITE_URL || "";
}

 

 

배운 점
Next.js의 SSR 환경에서는 상대 경로가 동작하지 않는다. 서버와 클라이언트 환경을 모두 고려한 URL 처리가 필요하다.




4.2 서버 컴포넌트에서 클라이언트 컴포넌트로 함수 전달

문제 상황
ErrorBoundary의 FallbackComponent에 함수를 전달했더니 에러가 발생했다.

Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server"

 

원인 분석

page.tsx는 기본적으로 Server Component다. FallbackComponent={ErrorFallback}처럼 함수 자체를 props로 전달하려 했는데, 서버 컴포넌트에서 클라이언트 컴포넌트로 함수를 직접 전달하는 것은 금지되어 있다. 함수는 네트워크를 통해 직렬화할 수 없기 때문이다.

 

해결 방법

FallbackComponent 대신 JSX를 직접 전달하는 fallback prop을 사용했다.

 
export default function ExhibitionPage() {
  return (
    <section className="flex justify-center">
      <ErrorBoundary 
        fallback={
          <div className="w-full flex justify-center items-center min-h-screen">
            <p className="text-xl text-red-500">에러가 발생했습니다</p>
          </div>
        }
      >
        <Suspense fallback={<div>로딩 중...</div>}>
          <ExhibitionContainer />
        </Suspense>
      </ErrorBoundary>
    </section>
  );
}

 

배운 점
서버 컴포넌트에서 클라이언트 컴포넌트로 함수를 전달할 수 없다. 꼭 함수 컴포넌트를 사용해야 한다면 별도 파일로 분리하고 "use client"를 추가해야 한다.




4.3 빌드 시 Pre-render 에러

문제 상황
빌드 시 /favorites 페이지를 pre-render하려다 에러가 발생했다.

Error occurred prerendering page "/favorites"
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

 

원인 분석

Next.js가 빌드 시 /favorites 페이지를 pre-render하려 시도했다. 서버 환경에서 useWishList 훅이 실행되었고, fetch('/api/wishlist')가 상대 경로로 호출되어 실패했다. HTML 응답을 JSON으로 파싱하려다 에러가 발생한 것이다.

 

해결 방법

dynamic import와 ssr: false 옵션을 사용해 서버 사이드 렌더링을 비활성화했다.

 
const FavoritedExhibitionsContainer = dynamic(
  () => import("./components/FavoritedExhibitionsContainer"),
  {
    ssr: false,
    loading: () => (
      <div className="w-full max-w-5xl flex justify-center items-center min-h-screen">
        <p className="text-xl">로딩 중...</p>
      </div>
    ),
  }
);

 

배운 점

Next.js의 static route는 빌드 시 자동으로 pre-render를 시도한다. 하지만 로그인한 사용자별로 다른 데이터를 보여주는 페이지(예: /favorites)는 빌드 시점에 렌더링할 수 없다. 이런 경우 ssr: false 또는 dynamic = "force-dynamic"을 명시적으로 설정해 클라이언트 사이드에서만 렌더링되도록 해야 한다.


5. 아쉬운 점 & 다음에 시도할 것

아쉬운 점

성능 측정이 일회성이었음

Lighthouse로 성능을 개선한 건 좋았는데, 배포 후에 실제 사용자 환경에서 어떻게 동작하는지 모니터링하지 못했다. 개발 환경과 프로덕션 환경의 차이를 확인하지 못한 게 아쉽다.

다음에는 Vercel Analytics나 Google Analytics를 붙여서 실제 사용자들의 페이지 로딩 속도를 추적하고 싶다.

 

초기 설계 없이 바로 구현

기능을 추가할 때마다 "일단 되게 만들고" → "나중에 리팩토링"하는 식으로 진행했다. 위시리스트 API를 9번 호출했던 것도 초기 설계가 없어서 생긴 문제였다.

다음에는 주요 기능의 데이터 흐름을 미리 그려보고, API 호출 전략을 먼저 설계한 후 구현하고 싶다.

 

다음에 시도할 것

React Query의 고급 기능 활용

이번에 invalidateQueries는 잘 활용했는데, optimistic updates나 prefetching 같은 기능은 못 써봤다. 특히 찜하기 버튼 클릭 시 API 응답을 기다리지 않고 바로 UI를 업데이트하면 사용자 경험이 더 좋을 것 같다.

 

 

사용자 피드백 수집

주변 사람들에게 실제로 써보게 하고 어떤 부분이 불편한지 피드백을 받고 싶다. 개발자 관점에서는 괜찮아 보여도 실제 사용자는 다르게 느낄 수 있으니까

 

검색 기능 추가

지금은 카테고리 필터만 있는데, 전시회 제목이나 장소로 검색하는 기능이 있으면 더 유용할 것 같다. 이걸 구현하면서 debounce 같은 최적화 기법도 적용해보고 싶다.