Skip to content

ahh0619/HANJANHAE

Repository files navigation

한잔해

나만을 위한 AI 맞춤 전통주 큐레이션! -> 사이트 보러가기

한잔해 이미지 1


🍶 프로젝트 소개

  • 서비스 기획 의도 :
    최근 전통주에 대한 관심이 높아지는 가운데,
    사용자 취향에 따라 전통주를 추천하고 전통주와 어울리는 음식과의 페어링 정보를 제공함으로써
    더 많은 사람들이 전통주의 매력을 느끼고 즐길 수 있도록 돕기 위해 기획되었습니다.
  • 프로젝트 한 줄 설명 :
    사용자 취향에 따른 전통주 추천 및 음식과의 페어링, 전통주 관련 경험을 함께 제공하는 플랫폼.

👨‍👩‍👧‍👦 팀 소개

안현희 김현지 박가나 김호준 조혜빈 유지연
안현희 김현지 박가나 김호준 조혜빈 유지연
팀장 부팀장 팀원 팀원 디자이너 디자이너
말포이 론 위즐리 헤르미온느 해리포터 덤블도어 도비
Github Badge
Velog Badge
Github Badge
Velog Badge
Github Badge
Tistory Badge
Github Badge
Velog Badge
HelloThere Badge
Designer Badge
HiGuys Badge
Designer Badge

👪 역할 분담

담당 기능
✝️ 안현희 공통 컴포넌트, 홈 페이지, 주류 상세 페이지, 마이 페이지
🐰 김현지 취향 조사 페이지, 좋아요 페이지, 추천결과 페이지
🍫 박가나 홈 페이지, 회원가입/로그인 페이지, 다이닝바 상세 페이지
🕊️ 김호준 검색 페이지, 검색 결과 페이지
🎨 조혜빈 UX/UI, 모바일 및 웹 디자인
🎨 유지연 UX/UI, 모바일 및 웹 디자인



📂 프로젝트 기능

🍀 주요 기능

1. OpenAI Assistants 맞춤형 전통주 추천

  • 취향 조사를 통해 사용자별 선호도를 파악하고, 개인화된 전통주 추천 목록을 제공합니다.
    • 설문조사로 수집한 컨텍스트를 기반으로, 오픈AI 어시스턴스 툴과 API/레그(rag) 시스템을 연동하여 개인화된 전통주 추천을 제공합니다. 이를 통해 사용자 취향을 정교하게 분석하고, 최적의 전통주를 정확하게 추천할 수 있습니다.
  • 다양한 테마별 전통주를 추천해 드립니다.
    • 겨울에 어울리는 전통주, 단맛이 나는 전통주, 파전에 어울리는 전통주 등 다양한 테마로 전통주를 추천해 드립니다.
  • OpenAI Assistants
    • 오픈AI 어시스턴스 툴과 API·레그(rag) 시스템을 연동하여, 사용자 취향을 정교하게 분석하고 최적의 전통주를 정확하게 추천해 드립니다. 설문조사나 검색 키워드 등으로 수집된 데이터를 바탕으로 이용자의 선호도를 세분화하고, 실시간으로 맞춤형 전통주를 안내해 드리는 것이 특징입니다.
RAG (Retrieval-Augmented Generation)시스템

오픈AI 어시스턴스라는 컨텍스트 참조와 모델이 결합되어 RAG 시스템이 적용된 서비스 api를 사용하여 개인화된 전통주 추천을 제공합니다.

예컨대 설문조사나 검색 키워드 등 사용자 취향 정보를 입력받으면,
레그 시스템이 이를 적절히 전처리·분류한 뒤 오픈AI 어시스턴스로 전달합니다.

이후 오픈AI 어시스턴스에서 응답받은 결과를 다시 레그 시스템이 취합·가공하여,
사용자가 쉽게 이해하고 활용할 수 있는 형태로 제공하게 됩니다.

즉, “사용자 입력 -> OpenAI Assistants (입력에 따른 context 참조 -> 모델 응답) -> 최종 사용자”의 흐름으로,
서비스 전반의 데이터 처리를 원활하게 돕는 중요한 연결고리라고 보시면 됩니다.


2. 전통주 및 다이닝바 상세 정보

  • 전통주 카드를 클릭하면 도수, 맛, 지역, 페어링 등 세부 정보 확인이 가능합니다.
  • 다이닝바 상세 페이지에서 주소, 영업시간, 대표 메뉴 등을 볼 수 있어, 전통주와 함께 즐길 수 있는 장소를 손쉽게 찾을 수 있습니다.

3. 좋아요

  • 전통주에 좋아요를 누르고 취소하는 토글 형태 기능을 지원합니다.
  • 좋아요 수를 바탕으로 한 인기 전통주 순위도 제공됩니다.
  • 로그인하지 않은 상태에서 좋아요를 누르면, 로그인 페이지로 안내해 편리한 서비스 이용을 돕습니다.

4. 검색 및 필터

  • 원하는 전통주를 키워드 검색으로 빠르게 찾을 수 있습니다.
  • 종류, 도수, 맛(단맛·신맛·청량감·바디감) 등 다양한 조건별 필터를 제공해, 세밀하게 취향에 맞는 전통주를 탐색 가능합니다.

5. 마이페이지

  • 프로필 이미지, 닉네임 등의 회원 정보를 확인하고 수정할 수 있습니다.
  • 내 취향 관리를 통해 취향 설정을 추가·수정할 수 있습니다.
  • 로그아웃회원 탈퇴 기능을 제공해 편리한 회원 관리가 가능합니다.

6. 리뷰 & 공유하기

  • 전통주 상세 페이지에서 리뷰(내용, 별점)를 작성해 다른 사용자와 소통할 수 있습니다.
  • 무한 스크롤 형태로 리뷰를 볼 수 있어 편리하며, 내 리뷰는 수정·삭제가 가능합니다.
  • 공유하기 기능을 통해 카카오톡 등 다양한 채널로 손쉽게 콘텐츠를 공유할 수 있습니다.


🍀 부가 기능

푸시 알림 (PWA & FCM)

  • 새 전통주가 등록되면, 브라우저에서 포그라운드·백그라운드 알림이 실시간으로 도착합니다.
  • 알림 클릭 시 홈 페이지로 이동해 확인할 수 있습니다.
  • PWA 지원으로 앱 설치 없이 모바일 환경에서도 알림을 받아볼 수 있어, 최신 정보를 놓치지 않습니다.
  • Next PWA + Firebase Cloud Messaging(FCM)
Next PWA + FCM

Next PWA

  1. 서버 사이드 렌더링(SSR) 및 정적 사이트 생성(SSG) 지원
    • 검색 엔진 최적화(SEO)와 빠른 초기 로딩을 위해 SSR, SSG가 필수적이었습니다.
    • Next.js는 React 기반으로 SSR과 SSG를 쉽게 적용할 수 있어, 초기 페이지 로딩 속도와 SEO 효과를 극대화할 수 있습니다.
  2. PWA 특성 구현
    • 오프라인 지원, 홈 화면에 앱 설치, 웹 푸시 알림 등 사용자 경험을 개선하기 위해 PWA 기능을 도입했습니다.
    • Next.js는 서비스 워커(Workbox 등) 설정이나 매니페스트(Manifest) 파일 구성 등 PWA 관련 설정이 비교적 간편합니다.
  3. 개발 생산성 및 확장성
    • 라우팅, 코드 스플리팅 등 Next.js가 제공하는 구조화된 개발 방식으로 대규모 프로젝트를 체계적으로 관리할 수 있습니다.
    • 커뮤니티와 생태계가 활발해, 에러나 기능 구현 시 참고 자료가 풍부합니다.

Firebase Cloud Messaging(FCM)

  1. 푸시 알림 기능 구현의 용이성
    • 사용자에게 실시간 알림(전통주 신규 등록, 이벤트, 공지 등)을 전달하기 위해 푸시 메시징 기능이 필요했습니다.
    • FCM은 브라우저(웹)와 모바일(안드로이드/iOS) 모두 지원하며, 설정과 연동 과정이 비교적 간단해 빠르게 MVP를 구축할 수 있었습니다.
  2. 글로벌 인프라 및 신뢰도
    • 구글 클라우드 기반 인프라를 사용하기 때문에 글로벌 서비스 운영에도 안정적입니다.
    • 대량의 메시지 트래픽 처리 경험이 풍부해, 스케일링 문제에 대한 부담이 적습니다.
  3. 다른 Firebase 서비스와의 연계 가능성
    • 필요 시 Firebase Authentication, Cloud Functions 등 다른 Firebase 서비스를 손쉽게 연동할 수 있어 확장성이 높습니다.


📅 개발기간

2024. 12. 31. ~ 2025. 02. 06.


⚙️ 기술스택

✔️ Language

TypeScript Badge

✔️ Framework & Libraries

Next.js Badge React Badge TanStack Query Badge Zustand Badge Tailwind CSS Badge
Supabase Badge Firebase Badge Firebase Admin Badge Next PWA Badge

✔️ Monitoring & Error Tracking

Sentry Badge

✔️ Hosting & Deployment

Vercel Badge

✔️ Version Control

Git Badge GitHub Badge

✔️ API

KakaoMap Badge OPEN AI Assistants Badge


🚀 시스템 아키텍처

undefined (3)



🔖 ERD

스크린샷 2025-02-06 144523



📖 프로젝트 구조

📦 HANJANHAE
├─ ⚙️ .eslintrc.json
├─ ⚙️ .gitignore
├─ ⚙️ .prettierrc
├─ 📁 .vscode
│  └─ ⚙️ settings.json
├─ ⚙️ next.config.mjs
├─ ⚙️ package.json
├─ ⚙️ postcss.config.mjs
├─ 📁 public
│  ├─ 📁 assets
│  │  └─ 📁 icons
│  ├─ 📁 icons
│  ├─ 📄 service-worker.js
│  ├─ 📄 service-worker.js.map
│  ├─ 📄 workbox-1bb06f5e.js
│  └─ 📄 workbox-1bb06f5e.js.map
├─ 📄 README.md
├─ ⚙️ sentry.client.config.ts
├─ ⚙️ sentry.edge.config.ts
├─ ⚙️ sentry.server.config.ts
├─ 📁 src
│  ├─ 📁 app
│  │  ├─ 📁 actions
│  │  ├─ 📁 api
│  │  │  ├─ 📁 auth
│  │  │  │  ├─ 📁 signin
│  │  │  │  └─ 📁 social
│  │  │  ├─ 📁 drink
│  │  │  ├─ 📁 drinks
│  │  │  │  └─ 📁 new
│  │  │  ├─ 📁 recommend
│  │  │  └─ 📁 sentry-example-api
│  │  ├─ 📁 drink
│  │  │  ├─ 📁 [id]
│  │  │  │  ├─ 📄 error.tsx
│  │  │  │  └─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  ├─ 📄 global-error.tsx
│  │  ├─ 📄 layout.tsx
│  │  ├─ 📁 like
│  │  │  ├─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  ├─ 📄 manifest.ts
│  │  ├─ 📁 mypage
│  │  │  ├─ 📄 error.tsx
│  │  │  ├─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  ├─ 📄 not-found.tsx
│  │  ├─ 📄 page.tsx
│  │  ├─ 📁 password
│  │  │  ├─ 📁 check
│  │  │  │  └─ 📄 page.tsx
│  │  │  ├─ 📁 reset
│  │  │  │  └─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  ├─ 📁 place
│  │  │  ├─ 📁 [id]
│  │  │  │  ├─ 📄 error.tsx
│  │  │  │  └─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  ├─ 📁 preferences
│  │  │  ├─ 📁 customization
│  │  │  │  ├─ 📄 error.tsx
│  │  │  │  ├─ 📄 page.tsx
│  │  │  │  └─ 📁 _components
│  │  │  └─ 📁 result
│  │  │     ├─ 📄 error.tsx
│  │  │     ├─ 📄 page.tsx
│  │  │     └─ 📁 _components
│  │  ├─ 📁 providers
│  │  ├─ 📄 providers.tsx
│  │  ├─ 📁 search
│  │  │  ├─ 📄 error.tsx
│  │  │  ├─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  │     └─ 📁 _ui
│  │  ├─ 📁 sentry-example-page
│  │  │  └─ 📄 page.tsx
│  │  ├─ 📁 signin
│  │  │  ├─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  ├─ 📁 signup
│  │  │  ├─ 📄 page.tsx
│  │  │  └─ 📁 _components
│  │  └─ 📁 survey
│  │     ├─ 📄 error.tsx
│  │     ├─ 📄 page.tsx
│  │     └─ 📁 _components
│  ├─ 📁 components
│  │  ├─ 📁 auth
│  │  ├─ 📁 common
│  │  ├─ 📁 home
│  │  └─ 📁 layout
│  ├─ 📁 constants
│  ├─ 📁 firebase
│  ├─ 📁 fonts
│  ├─ 📁 hooks
│  │  ├─ 📁 auth
│  │  ├─ 📁 common
│  │  ├─ 📁 like
│  │  ├─ 📁 mypage
│  │  ├─ 📁 preference
│  │  ├─ 📁 result
│  │  ├─ 📁 review
│  │  ├─ 📁 search
│  │  └─ 📁 survey
│  ├─ 📄 instrumentation.ts
│  ├─ 📁 lib
│  ├─ 📄 middleware.ts
│  ├─ 📁 store
│  ├─ 📁 styles
│  ├─ 📁 types
│  └─ 📁 utils
│     ├─ 📁 auth
│     ├─ 📁 common
│     ├─ 📁 drink
│     ├─ 📁 filter
│     ├─ 📁 recommend
│     ├─ 📁 review
│     ├─ 📁 share
│     └─ 📁 supabase
├─ ⚙️ tailwind.config.ts
├─ ⚙️ tsconfig.json
└─ 📄 yarn.lock


트러블 슈팅

로그인 성공 시 zustand에 유저 정보가 제대로 저장되지 않는 이슈

문제 발생

로그인 성공 시 유저 정보를 zustand에 저장하고 이후 유저 정보가 필요할 때마다 매번 API를 호출하는 것이 아니라 zustand에 저장된 유저 정보를 가져와서 사용하도록 구현하였는데, 로그인을 성공했음에도 불구하고 유저 정보를 제대로 받아오지 못하고 새로고침을 해야 비로소 유저 정보를 받아오는 문제가 발생하였다.

원인 파악

'use client';

import { createContext, useContext, useEffect, useState } from 'react';

import { useAuthStore } from '@/store/authStore';
import { SignInDataType } from '@/types/Auth';

import { fetchUser, signout } from '../actions/auth';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const { user, setUser, removeUser } = useAuthStore();

  const [isAuthenticated, setIsAuthenticated] = useState(false);

  useEffect(() => {
    const fetchSignedUser = async () => {
      try {
        setUser(await fetchUser());
        setIsAuthenticated(true);
      } catch (error) {
        setUser(null);
        setIsAuthenticated(false);
      }
    };

    fetchSignedUser();
  }, [isAuthenticated]);

/* 로그인 */const login = async (values: SignInDataType) => {
    try {
      await signin();
      setIsAuthenticated(true);
    } catch (error) {
      throw error;
    }
  };

/* 로그아웃 */const logout = async () => {
    try {
      await signout();
      removeUser();
      setIsAuthenticated(false);
    } catch (error) {
      throw error;
    }
  };

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => useContext(AuthContext);
export const fetchUser = async (): Promise<UserType | null> => {
  const supabase = createClient();

  const {
    data: { user },
    error: authError,
  } = await supabase.auth.getUser();

  if (authError || !user) {
    return null;
  }

  const { data: userData, error: userError } = await supabase
    .from('users')
    .select('*')
    .eq('id', user.id)
    .single();

  if (userError || !userData) {
    throw new Error(userError.message || '유저 정보를 가져올 수 없습니다.');
  }

  return {
    id: userData.id,
    nickname: userData.nickname,
    profile_image: userData.profile_image || null,
    agree_terms: userData.agree_terms,
  };
};

기존 코드를 살펴보면 다음 3가지 동작이 발생할 때마다 fetchUser()의 결과값을 zustand의 user에 반영해주고 있다.

  • 서비스에 처음 접속했을 때
  • 화면이 새로고침 됐을 때
  • isAuthenticated 값이 변경될 때

코드에는 문제가 없는 것 같은데 왜 원하는대로 동작을 안하지? 라는 생각을 하면서 fetchUser() 코드를 살펴보는 순간 원인을 알게 되었다. fetchUser() 코드를 살펴보면 getUser()에서 오류가 발생하거나 user 데이터를 반환해주지 않는 경우 에러가 아닌 null 값을 반환하고 있다.

즉, fetchUser()가 null을 반환하게 되면 에러가 나는 상황이 아니기 때문에 isAuthenticated에 true를 적용해주게 되고, 그렇게 되면 로그인을 성공해도 isAuthenticated가 이미 true이기 때문에 useEffect가 실행되지 않아서 setUser()가 실행되지 않게 되는 것이다.

해결

useEffect(() => {
    const fetchSignedUser = async () => {
        try {
            const currentUser = await fetchUser();
            setUser(currentUser);
            setIsAuthenticated(!!currentUser);
        } catch (error) {
            setUser(null);
            setIsAuthenticated(false);
        }
    };

    fetchSignedUser();
}, [isAuthenticated]);

최종적으로, 단순히 fetchUser()에서 에러가 발생하지 않는다고 무조건 isAuthenticated를 true로 적용하는 것이 아니라 반환값에 따라 적용해줌으로써 문제를 해결할 수 있었다.


배포 링크에서 로그인이 너무 오래 걸리는 이슈

문제 발생

MVP 기능 개발을 마무리하고 중간 발표를 위해서 vercel에 배포를 하였는데, 로컬에서는 문제없이 동작하던 로그인 기능이 배포 링크에서는 너무 오래 걸리는 현상이 발생하였다.

원인 파악

가장 먼저 region 문제가 아닐까 싶어서 확인해봤는데 vercel과 supabase 모두 서울로 잘 설정되어 있었다. 계속해서 원인을 찾아보던 중 비슷한 문제를 겪고 있는 stack overflow를 발견하였고, server action을 route handler로 변경해보기로 하였다.

https://stackoverflow.com/questions/78078248/dalle3-request-in-next-js-14-server-actions-leading-to-function-invocation-timeo

해결

/* 변경 전 server action */

export const signin = async (data: SignInDataType): Promise<void> => {
  const supabase = createClient();

  const { email, password } = data;

  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  if (error) throw new Error(error.message);

  redirect('/');
};
/* 변경 후 route handler */

export async function POST(request: Request) {
  const supabase = createClient();

  const { email, password } = await request.json();

  try {
    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (error) {
      return NextResponse.json({ errorMessage: error.message });
    }

    return NextResponse.json({ successMessage: '로그인 성공' });
  } catch (error: any) {
    return NextResponse.json({ errorMessage: 'server error' });
  }
}

route handler를 사용하니 문제가 해결되었다. 콜드 스타트로 인해 route handler보다는 server action의 속도가 빠르다고 알고 있었는데 정확히 반대되는 결과가 나왔고, 30초 가까이 걸리던 로그인 기능이 server action을 route handler로 변경해주는 것만으로 해결된 이유가 궁금해졌다. 명확한 이유를 알아내지는 못했지만 다음과 같은 이유가 아닐까 추측해보았다.

  • 엣지(Edge)와 Node.js(Serverless) 환경의 차이
    • 엣지 런타임은 빠른 응답을 위해 경량화된 환경에서 동작을 하기 때문에 Node.js의 일부 모듈이나 네이티브 API를 사용할 수 없거나 동작 방식이 달라서 문제가 생길 수 있음
    • Supabase, OpenAI 같은 일부 라이브러리는 엣지 런타임에 완벽히 호환되지 않는 부분들이 종종 보고됨
    • WebSocket, 이벤트 스트리밍, 특정 노드 내장 모듈 의존성 등이 있는 경우 문제가 발생할 수 있음
  • 타임아웃(Timeout) 정책 차이
    • Vercel의 엣지 함수는 기본적으로 매우 짧은 타임아웃이 설정되어 있음
    • Node.js 함수는 엣지 함수보다 조금 더 긴 타임아웃이나 다른 정책을 적용받을 수 있음

throw new Error 동작하지 않는 이슈

문제발생

Next.js의 클라이언트 컴포넌트에서 handleSubmit 함수 내에서 발생한 예외를 throw new Error(error.message)로 던졌으나, 예상과 달리 Next.js의 error.tsx로 넘어가지 않고 브라우저 콘솔에 Uncaught (in promise) Error: ... 만 출력되는 문제가 발생했다.

const handleSubmit = async () => {
  try {
    if (mode === 'edit') {
      await updateSurvey({ surveyData: preferences, userId: user.id });
      handleOpenModal();
    } else {
      await saveSurveyData(preferences);
      router.push('/preferences/result');
    }
  } catch (error) {
    throw new Error(error.message); // `error.tsx`로 넘어가지 않음
  }
};

error.tsx로 넘어가지 않고 브라우저 콘솔에 Uncaught (in promise) 오류 메시지가 출력됨.

원인 분석

  1. 비동기 함수에서 발생한 에러는 렌더링 과정에서 발생한 것이 아니다.
    • Next.js의 error.tsx는 React의 Error Boundary를 기반으로 동작하며, 기본적으로 렌더링 과정 중 발생한 에러만 감지할 수 있음.
    • 하지만 handleSubmit 내부에서 발생한 에러는 이벤트 핸들러에서 실행된 비동기 코드의 일부이며, React의 Error Boundary는 이러한 비동기 에러를 잡지 않음.
  2. 비동기 함수에서 발생한 에러는 Promise.reject 형태로 처리된다.
    • 비동기 함수(async/await) 내부에서 throw하면, JavaScript 엔진은 이를 Promise.reject(new Error(...)) 형태로 처리함.
    • React의 렌더링 과정과 별개의 비동기 콜 스택에서 발생한 에러이므로 Error Boundary에서 감지할 수 없음.
  3. 렌더링 과정에서 throw해야만 error.tsx가 작동한다.
    • Next.js에서 error.tsx(혹은 React의 ErrorBoundary)는 컴포넌트가 렌더링되는 동안 발생한 예외를 감지할 수 있음.
    • 따라서 catch 블록에서 직접 throw하는 것이 아니라, 상태를 업데이트하여 컴포넌트가 렌더링 과정 중에 에러를 던지도록 해야 함.

해결

비동기 함수에서 throw하는 대신, 에러 상태를 업데이트한 후, 렌더링 과정에서 throw 하도록 수정했다.

import { useState } from 'react';

const MyComponent = () => {
  const [submitError, setSubmitError] = useState(null);

  const handleSubmit = async () => {
    try {
      if (mode === 'edit') {
        await updateSurvey({ surveyData: preferences, userId: user.id });
        handleOpenModal();
      } else {
        await saveSurveyData(preferences);
        router.push('/preferences/result');
      }
    } catch (error) {
      setSubmitError(error.message); // 상태 업데이트
    }
  };

  if (submitError) throw new Error(submitError); // 렌더링 과정에서 에러 발생

  return <button onClick={handleSubmit}>제출</button>;
};

handleSubmit 함수에서 setSubmitError(error.message);가 실행되면, submitError 상태가 업데이트된다. 상태가 변경되면서 React는 해당 컴포넌트를 재렌더링하고, 이 과정에서 if (submitError) throw new Error(submitError);가 실행된다. 이렇게 해서 에러가 렌더링 중에 발생한 것으로 인식되어, Next.js의 error.tsx로 정상적으로 이동하게 되었다.


tailwind css 클래스명을 함수로 분리했을때 값을 불러와지지만 클래스 적용이 되지 않는 이슈

트러블슈팅 : ProductCard

이게 무슨 말이냐하면 이 컴포넌트가 세 가지 버전으로 재사용이 되어야했다.

이전에는 모바일만 했었으니까 쉽게 커스텀을 할 수 있었는데 데스크탑 버전도 같이 구현해야돼서 참 애를 먹었다.

첫 시도

export const productCardVariants = {
  default: {
    mobile: {
      container: 'w-[124px] h-[186px]',
      image: 'w-[124px] h-[152px]',
      marginName: 'mt-3',
    },
    desktop: {
      container: 'xl:w-[224px] xl:h-[333px]',
      image: 'xl:w-[224px] xl:h-[291px]',
      marginName: 'xl:mt-5',
    },
  },
  result: {
    mobile: {
      container: 'w-[124px] h-[186px]',
      image: 'w-[124px] h-[186px]',
      marginName: '',
    },
    desktop: {
      container: 'xl:w-[160px] xl:h-[222px]',
      image: 'xl:w-[160px] xl:h-[222px]',
      marginName: '',
    },
  },
  search: {
    mobile: {
      container: 'w-[163px] h-[241px]',
      image: 'w-[163px] h-[207px]',
      marginName: 'mt-3',
    },
    desktop: {
      container: 'xl:w-[224px] xl:h-[333px]',
      image: 'xl:w-[224px] xl:h-[291px]',
      marginName: 'xl:mt-5',
    },
  },
  like: {
    mobile: {
      container: 'w-[163px] h-[241px]',
      image: 'w-[163px] h-[207px]',
      marginName: 'mt-3',
    },
    desktop: {
      container: 'xl:w-[224px] xl:h-[333px]',
      image: 'xl:w-[224px] xl:h-[291px]',
      marginName: 'xl:mt-5',
    },
  },
} as const;

export type ProductCardScenario = keyof typeof productCardVariants;
  • 이렇게 유틸 함수를 하나 만들고,

'use client';

import Link from 'next/link';
import React from 'react';
import { twMerge } from 'tailwind-merge'; // tailwind merge용 라이브러리(optional)

import LikeButton from './LikeButton';
import OptimizedImage from './OptimizedImage';
import {
  productCardVariants,
  ProductCardScenario,
} from '@/constants/productCardVariants';

type ProductCardProps = {
  /** 전통주 id */
  id: string;
  /** 전통주 이름 */
  name: string;
  /** 이미지 URL */
  imageUrl: string;
  /** 좋아요 여부 */
  isLiked: boolean;
  /** 좋아요 토글 함수 */
  onToggleLike: () => void;
  /** 데스크탑 & 모바일에서 적용할 사이즈 시나리오 */
  scenario?: ProductCardScenario; // 추가
  /** 술 이름 노출 여부 */
  isNameVisible?: boolean;
};

/**
 * 모바일 & 데스크탑에 대응 가능한 Product Card
 */
const ProductCard: React.FC<ProductCardProps> = ({
  id,
  name,
  imageUrl,
  isLiked,
  onToggleLike,
  scenario = 'default', // 기본값
  isNameVisible = true,
}) => {
  // scenario 에 해당하는 class들 가져오기
  const classes = productCardVariants[scenario];

  return (
    <div
      // container 부분
      className={twMerge(
        'relative flex flex-col', // 공통
        classes.mobile.container, // 모바일 사이즈
        classes.desktop.container, // 데스크탑 사이즈
      )}
    >
      {/* 좋아요 버튼 */}
      <div className="absolute bottom-[34px] right-0 z-10">
        <LikeButton isLiked={isLiked} onClick={onToggleLike} />
      </div>

      {/* 상세 페이지 링크 */}
      <Link href={`/drink/${id}`} className="flex flex-col">
        {/* 이미지 영역 */}
        <div
          className={twMerge(
            // 공통적인 스타일(테두리, 배경 등)
            'relative overflow-hidden rounded-[8px] border border-grayscale-200 bg-gray-100 bg-opacity-50',
            // scenario별로 image 스타일
            classes.mobile.image,
            classes.desktop.image,
          )}
        >
          <OptimizedImage
            src={imageUrl}
            alt={name}
            fill
            className="rounded-lg object-cover"
          />
        </div>

        {/* 이름 */}
        {isNameVisible && (
          <div
            className={twMerge(
              // scenario별 margin top
              classes.mobile.marginName,
              classes.desktop.marginName,
              // 공통적으로 들어가는 스타일
              'w-full overflow-hidden text-ellipsis whitespace-nowrap text-left text-title-mm',
            )}
          >
            {name}
          </div>
        )}
      </Link>
    </div>
  );
};

export default ProductCard;
  • 이런식으로 했는데, 이미지 높이가 1.1212px이렇게 됐다. 하드코딩을 하면 분명히 되는데 이렇게만 하면 왜 안되지??? 도대체 이유를 모르겠어서 오랜시간 헤메다가 피드백을 요청했다.
  • 모든 값이 불러와지고 클래스도 잘 들어가지만 Tailwind가 위와 같은식으로 불러오면 먹히질 않는다...

피드백 반영

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  /* ======================== default 시나리오 ======================== */
  .product-card-default {
    @apply relative flex h-[186px] w-[124px] flex-col;
  }
  .product-card-default-image {
    @apply relative h-[152px] w-[124px] overflow-hidden rounded-lg border border-grayscale-200 bg-gray-100 bg-opacity-50;
  }
  /* 좋아요 버튼 (default) */
  .product-card-default-likeBtn {
    @apply absolute bottom-[34px] right-0 z-10;
  }

  @screen xl {
    .product-card-default {
      @apply h-[333px] w-[224px];
    }
    .product-card-default-image {
      @apply h-[291px] w-[224px];
    }
    /* 데스크톱에서만 bottom-44px, right-8px */
    .product-card-default-likeBtn {
      @apply bottom-[44px] right-[8px];
    }
  }

  /* ======================== result 시나리오 ======================== */
  .product-card-result {
    @apply relative flex h-[186px] w-[124px] flex-col;
  }
  .product-card-result-image {
    @apply relative h-[186px] w-[124px] overflow-hidden rounded-lg border border-grayscale-200 bg-gray-100 bg-opacity-50;
  }
  /* 좋아요 버튼 (result) → bottom:0, right:0 */
  .product-card-result-likeBtn {
    @apply absolute bottom-0 right-0 z-10;
  }

  @screen xl {
    .product-card-result {
      @apply h-[222px] w-[160px];
    }
    .product-card-result-image {
      @apply h-[222px] w-[160px];
    }
    /* result 시나리오에서 데스크톱도 그대로 bottom-0 right-0
       => 별도 추가 스타일이 없다면 비워둬도 됨
    */
  }

  /* ======================== search 시나리오 ======================== */
  .product-card-search {
    @apply relative flex h-[241px] w-[163px] flex-col;
  }
  .product-card-search-image {
    @apply relative h-[207px] w-[163px] overflow-hidden rounded-lg border border-grayscale-200 bg-gray-100 bg-opacity-50;
  }
  /* 좋아요 버튼도 default와 동일한 위치라 가정 */
  .product-card-search-likeBtn {
    @apply absolute bottom-[34px] right-0 z-10;
  }

  @screen xl {
    .product-card-search {
      @apply h-[333px] w-[224px];
    }
    .product-card-search-image {
      @apply h-[291px] w-[224px];
    }
    /* desktop 시 위치 */
    .product-card-search-likeBtn {
      @apply bottom-[44px] right-[8px];
    }
  }

  /* ======================== like 시나리오 ======================== */
  .product-card-like {
    @apply relative flex h-[241px] w-[163px] flex-col;
  }
  .product-card-like-image {
    @apply relative h-[207px] w-[163px] overflow-hidden rounded-lg border border-grayscale-200 bg-gray-100 bg-opacity-50;
  }
  /* 좋아요 버튼도 default와 동일한 위치라 가정 */
  .product-card-like-likeBtn {
    @apply absolute bottom-[34px] right-0 z-10;
  }

  @screen xl {
    .product-card-like {
      @apply h-[333px] w-[224px];
    }
    .product-card-like-image {
      @apply h-[291px] w-[224px];
    }
    .product-card-like-likeBtn {
      @apply bottom-[44px] right-[8px];
    }
  }
}
  • 대충 이런식으로 글로벌.css에 설정해주고,

'use client';

import Image from 'next/image';
import Link from 'next/link';

import LikeButton from './LikeButton';

type ProductCardScenario = 'default' | 'result' | 'search' | 'like';

type ProductCardProps = {
  id: string;
  name: string;
  imageUrl: string;
  isLiked: boolean;
  onToggleLike: () => void;
  scenario?: ProductCardScenario;
  isNameVisible?: boolean;
};

const scenarioToClass = (scenario: ProductCardScenario) => {
  switch (scenario) {
    case 'default':
      return {
        container: 'product-card-default',
        image: 'product-card-default-image',
        likeBtn: 'product-card-default-likeBtn',
      };
    case 'result':
      return {
        container: 'product-card-result',
        image: 'product-card-result-image',
        likeBtn: 'product-card-result-likeBtn',
      };
    case 'search':
      return {
        container: 'product-card-search',
        image: 'product-card-search-image',
        likeBtn: 'product-card-search-likeBtn',
      };
    case 'like':
      return {
        container: 'product-card-like',
        image: 'product-card-like-image',
        likeBtn: 'product-card-like-likeBtn',
      };
    default:
      return {
        container: 'product-card-default',
        image: 'product-card-default-image',
        likeBtn: 'product-card-default-likeBtn',
      };
  }
};

const ProductCard = ({
  id,
  name,
  imageUrl,
  isLiked,
  onToggleLike,
  scenario = 'default',
  isNameVisible = true,
}: ProductCardProps) => {
  const classes = scenarioToClass(scenario);

  return (
    <div className={classes.container}>
      {/* 좋아요 버튼 */}
      <div className={classes.likeBtn}>
        <LikeButton isLiked={isLiked} onClick={onToggleLike} />
      </div>

      {/* 상세 페이지 링크 */}
      <Link href={`/drink/${id}`} className="flex flex-col">
        {/* 이미지 영역 */}
        <div className={classes.image}>
          {/* fill 모드 */}
          <Image
            src={imageUrl}
            alt={name}
            fill
            className="rounded-lg object-cover"
          />
        </div>

        {/* 이름 */}
        {isNameVisible && (
          <div className="mt-3 w-full overflow-hidden text-ellipsis whitespace-nowrap text-left text-title-mm xl:mt-5">
            {name}
          </div>
        )}
      </Link>
    </div>
  );
};

export default ProductCard;
  • 이런식으로 해줬더니 이제 잘된다.
  • scenarioToClass : 유틸함수로 분리가 가능할것 같아서 분리했다가 실패했다. 마찬가지로 다른 파일에서 불러오는것은 안될것같아서 어쩔 수 없이 이 컴포넌트내에서 처리했다.

이전 데이터가 잠깐 표시된 후 새로운 데이터가 로드되면서 화면이 깜빡이는 현상

문제발생 :

기존 setState 를 이용해 검색을 하던 방식에서

파라미터를 이용한 검색 로직으로 리팩토링 중

데이터를 파라미터를 통해 불러오긴 하나

이전 데이터를 한번 보여주고 깜빡이며 새로운 데이터를

불러오는 문제가 발생하였습니다.

원인 :

// useInfiniteQuery 커스텀 훅

const useFilterSortedResults = () => {
  const searchParams = useSearchParams();

  const selectedTypes = getSelectedTypes(searchParams);
  const alcoholStrength = getAlcoholStrength(searchParams);
  const tastePreferences = getTastePreferences(searchParams);
  const { selectedSort } = useSortStore();

  const filterParams: FilterParams = {
    types: selectedTypes,
    alcoholStrength,
    tastePreferences,
  };

  const { data, isLoading, isError, fetchNextPage, hasNextPage, refetch } =
    useInfiniteQuery({
      // filterSortedDrinks
      queryKey: ['filterDrinks', filterParams, selectedSort === 'alphabetical'],
      queryFn: ({ pageParam = 1 }) =>
        filterSortedDrinks({ ...filterParams, page: pageParam }),
      getNextPageParam: (lastPage) =>
        lastPage.hasNextPage ? lastPage.nextPage : null,
      initialPageParam: 1,
      enabled: false,
      staleTime: 1000 * 60 * 5,
      retry: 1,
    });
  // triggerFetch true일 때 refetch 호출
  useEffect(() => {
    if (triggerFetch) {
      refetch(); // enabled false를 이용한 트리거
      setTriggerFetch(false);
    }
  }, [triggerFetch]);

  // 전체 데이터 개수 계산
  const totalCount = data?.pages[0]?.totalCount || 0;

  return {
    filterSortData: data?.pages.flatMap((page) => page.drinks) || [],
    isLoading,
    totalCount,
    isError,
    fetchNextPage,
    hasNextPage,
  };
};
 // 필터 정보 적용눌렀을 때 
 const handleApplyfilters = () => {
    const newUrl = useNavigateToFilter();
    queryClient.removeQueries({
      queryKey: ['filterDrinks'],
      exact: false,
    });
    router.push(newUrl);
    if (alcoholStrength === null) {
      setAlcoholStrength([0, 100]);
    }
    closeModal();
    setIsFiltered(true);
    setTriggerFetch(true);
    setSelectedSort('alphabetical');
  };

이전에 단순이 검색할 때 라우터만 변경되게 흉내냈고,

queryClient.removeQueries() 로 기존의 queryKey를 한번

지워내고, 새로운 데이터를 패칭하는 방식을 사용하였다.

검색버튼을 눌렀을 때 useInfiniteQuery에선 useEffect로

triggerFetch가 변경되었을 때 enabled로 인해 동작하게 구동하였습니다.

원인 파악 :

const useNavigateToFilter = () => {
  const router = useRouter(); 

  const navigateToFilter = useCallback(
    (
      selectedTypes: string[],
      alcoholStrength: [number, number] | null,
      tastePreferences: TastePreferences,
    ) => {
      if (!router) return; 

      const params = new URLSearchParams();

      if (selectedTypes.length > 0) {
        params.append('selectedTypes', selectedTypes.join(','));
      }

      if (alcoholStrength) {
        params.append('alcoholStrength', JSON.stringify(alcoholStrength));
      }

      if (Object.keys(tastePreferences).length > 0) {
        params.append(
          'tastePreferences',
          encodeURIComponent(JSON.stringify(tastePreferences)),
        );
      }

      router.replace(`/search?${params.toString()}`);
    },
    [router],
  );

  return navigateToFilter;
};
 const handleApplyfilters = () => {
    const newUrl = useNavigateToFilter();
    queryClient.removeQueries({
      queryKey: ['filterDrinks'],
      exact: false,
    });
    router.push(newUrl);
    setTriggerFetch(true);
  };

필터를 진행했을 때 파라미터로 전환하는 useNavigateToFilter 라는 유틸함수를 이용하였고,

(커스텀 훅이라 생각했지만 커스텀 훅의 형태는 아니기에 정정합니다.)

위의 필터 값을 파라미터로 변환하는 과정에서

.append() 를 이용하였는데 URLsearchParams의 내부 메서드를 여러번 호출하기 때문에 원인이 될 수 있었다.

또한 아래의 queryClient.removeQueries도 문제였는데

비동기로 처리되어 이전에 깜빡이는 문제의 원인은

캐싱된 데이터 지워짐 → 파라미터에 이미 존재하는 값이 존재함 → useInfiniteQuery가 패칭함 → 뒤늦게 router.push가 이루어짐 → useInfiniteQuery가 변경된 URL로 재요청을 보냄

해결 :

// useInfiniteQuery 커스텀 훅 리팩토링
const useFilterSortedResults = () => {
  const searchParams = useSearchParams();

  const selectedTypes = getSelectedTypes(searchParams);
  const alcoholStrength = getAlcoholStrength(searchParams);
  const tastePreferences = getTastePreferences(searchParams);

  const liked = getLiked(searchParams);
  const isLikedMode = liked === 'liked';
  const hasValidParams =
    searchParams.get('selectedTypes') !== null ||
    searchParams.get('alcoholStrength') !== null ||
    searchParams.get('tastePreferences') !== null;

  const filterParams: FilterParams = {
    types: selectedTypes,
    alcoholStrength,
    tastePreferences,
  };
  const effectiveKeyword = isLikedMode ? undefined : filterParams;

  const { data, isPending, isError, fetchNextPage, hasNextPage } =
    useInfiniteQuery({
      queryKey: ['filterDrinks', effectiveKeyword],
      queryFn: ({ pageParam = 1 }) => {
        return filterSortedDrinks({ ...filterParams, page: pageParam });
      },
      getNextPageParam: (lastPage) => {
        return lastPage.hasNextPage ? lastPage.nextPage : null;
      },
      initialPageParam: 1,
      staleTime: 1000 * 60 * 5,
      retry: 1,
      enabled: hasValidParams && !isLikedMode,
    });

  return {
    filterSortData: data?.pages.flatMap((page) => page.drinks) || [],
    isPending,
    totalCount: data?.pages[0]?.totalCount || 0,
    isError,
    fetchNextPage,
    hasNextPage,
  };
};
// .append() 대신 & 를 이용 
export const generateUrl = ({
  selectedTypes = [],
  alcoholStrength = null,
  tastePreferences = {},
  keyword = '',
  sort = 'alphabetical',
}: GenerateUrlType): string => {
  const queryParams = [
    selectedTypes.length > 0 ? `selectedTypes=${selectedTypes.join(',')}` : '',

    alcoholStrength ? `alcoholStrength=${JSON.stringify(alcoholStrength)}` : '',

    Object.keys(tastePreferences).length > 0
      ? `tastePreferences=${encodeURIComponent(JSON.stringify(tastePreferences))}`
      : '',

    keyword ? `keyword=${encodeURIComponent(keyword)}` : '',

    sort ? `sort=${encodeURIComponent(sort)}` : '',
  ]
    .filter(Boolean)
    .join('&');

  return `/search${queryParams ? `?${queryParams}` : ''}`;
};

// 파라미터 생성 함수 하나로 관리하다 개별 함수로 분리
export const getSelectedTypes = (searchParams: URLSearchParams): string[] => {
  return searchParams.get('selectedTypes')
    ? searchParams.get('selectedTypes')!.split(',')
    : [];
};

export const getAlcoholStrength = (
  searchParams: URLSearchParams,
): [number, number] | null => {
  if (!searchParams.get('alcoholStrength')) return null;
  try {
    const values = JSON.parse(searchParams.get('alcoholStrength')!) as [
      number,
      number,
    ];
    return Array.isArray(values) && values.length === 2 ? values : null;
  } catch (error) {
    console.error('Invalid alcoholStrength format:', error);
    return null;
  }
};

export const getTastePreferences = (
  searchParams: URLSearchParams,
): Record<string, number> => {
  if (!searchParams.get('tastePreferences')) return {};
  return Object.fromEntries(
    searchParams
      .get('tastePreferences')!
      .replace(/^\{|\}$/g, '')
      .split(',')
      .map((pair) => {
        const [key, value] = pair.split(':').map((item) => item.trim());
        return [key, Number(value)];
      }),
  );
};
  // 입력 시 아래와 같이 수정
  const handleApplyfilters = () => {
    setIsFiltered(true);
    const newUrl = generateUrl({
      selectedTypes,
      alcoholStrength,
      tastePreferences,
    });
    router.push(newUrl);
    closeModal();
  };

위와 같이 수정하여 2번 깜빡이는 문제를 해결하였다.