Skip to content

Repository files navigation

📢 프로젝트 개요

제주 올레요 브로셔

📋 프로젝트 소개

  • Jeju Olleyo (제주 올레요)는 제주도 여행 일정을 쉽고 빠르게 계획하고, 다른 사람과 공유할 수 있는 웹 서비스입니다.
    일정 저장, 지도 제공, 북마크 및 좋아요 기능 등을 제공하여 사용자가 직접 만든 여행 플랜을 기록하고 공유 할 수 있습니다. 사용자는 관심 있는 장소를 검색하고, 해당 장소가 포함된 공개 일정을 확인 할 수 있습니다.

🔗 배포 링크

📆 프로젝트 기간

  • 2025.04.01 - 2025.04.30

💡 프로젝트 계기

  • 해외여행 수요 증가로 국내여행 수요가 급감하는 상황에서 제주도의 관광 활성화를 돕기 위해 제주에 특화된 여행 플랫폼을 기획하게 되었습니다.
  • 여행 정보를 찾고 저장하는 과정을 간소화하여 한눈에 제주 여행 일정을 설계할 수 있는 서비스를 만들고자 했습니다.
  • 공공 데이터 API 등을 활용해 신뢰도 높은 관광 정보를 제공하고자 하였습니다.
  • 여행 일정을 공유하는 커뮤니티를 제공하여 더 많은 사람들이 다시 제주를 찾도록 유도하고자 하였습니다.

🏞️ 화면 소개

홈 화면 카테고리별 장소 목록 장소 상세 페이지
일정 생성 내 일정 관리 커뮤니티
항공권 예약 마이페이지 로그인 및 회원가입

📝 주요 기능

📆 일정 생성 및 공유

  • 사용자는 일정 생성 페이지를 통해 일정의 제목, 설명, 여행 기간, 대표 이미지를 설정할 수 있습니다.
  • 특정 날짜의 장소들을 복사, 삭제할 수 있는 편의 기능을 제공합니다.
  • 화면 사이드에 장소 검색, 내가 북마크한 장소를 제공하여 검색 후 장소를 추가하거나 북마크한 장소를 일정에 추가할 수 있습니다.
  • 일정 저장 시 공개/비공개 여부를 선택하여 공개한 일정은 커뮤니티에 개시할 수 있습니다.

📍 장소 정보 제공 및 검색

  • 장소 이름 및 주소 기반으로 검색 할 수 있습니다.
  • 특정 키워드 검색없이 카테고리를 통해 제주도 내 명소, 맛집, 카페, 숙박 장소를 확인 할 수 있습니다.

🛫 항공권 검색

  • 인원, 좌석 등급, 출발지, 일정을 입력하여 왕복 항공권을 검색 할 수 있습니다.
  • 가는 편, 오는 편 항공기를 선택하여 항공기를 예약합니다.

🪪 마이페이지

  • 프로필 사진, 닉네임, 연락처 등의 사용자 계정 정보를 확인하고 수정할 수 있습니다.
  • 예약한 항공권, 북마크한 장소, 좋아요한 일정, 저장한 일정을 확인할 수 있습니다.

🛠 기술스택

📌 프로그래밍 언어 및 프레임워크

TypeScript React React

🗃️ 상태 관리 및 데이터 패칭

Zustand TanStack Query React Hook Form

🔍 데이터 검증

Zod

🎨 UI 관련 라이브러리

Tailwind CSS Shadcn/UI Framer Motion Embla Carousel React DatePicker Hello Pangea DND

🗄️ 백엔드 및 데이터베이스

Supabase

코드 품질 및 포맷팅

ESLint Prettier

🗃️ 버전 관리 및 배포

Git GitHub Vercel


🚀 트러블 슈팅

🚿 Next.js 하이드레이션 오류

문제 상황

데이터를 불러오는 페이지에서 다음과 같이 하이드레이션 오류 발생. 스크린샷 2025-04-29 오후 2 46 21 스크린샷 2025-04-29 오후 2 46 36

원인 분석

https://nextjs.org/docs/messages/react-hydration-error

에러 메시지에서 제공한 Next.js 공식 문서 내용을 살펴보면 이 에러의 원인은 크게 다음과 같이 나눌 수 있음.

  • 잘못된 태그 사용
  • 클라이언트에서 서버와 불일치한 데이터 사용

💡 원인은 서버 컴포넌트 페이지 안에서 쓰이는 클라이언트 컴포넌트에서 데이터를 불러올 때 isLoading 여부에 따라 다르게 렌더링했기 때문!

// 로딩일 때
if (isLoading) return <Loading />;

// 로딩 아닐 때
return (...)

이렇게 코드를 작성할 경우 결과적으로 서버에서 생성된 HTML과 클라이언트에서 React가 생성하려는 가상 DOM 구조가 달라질 수 있음.

→ ❌ Next.js에서 권장하는 방법이 아님 ❌

해결

  1. 방법 1: 로딩에 따른 결과 반환 부분을 지우고 서버 컴포넌트에서 해당 클라이언트 컴포넌트를 Suspense로 감싸줌
  2. 방법 2: 로딩에 따른 결과 반환 부분을 지우고 loading.tsx를 작성

이미 루트에 loading.tsx가 있으므로 2번 방법으로 해결.

🐓 검색창 새로고침 시 검색어 초기화 문제

문제 상황

  • 검색어 입력 후 router.push() 로 검색 결과 페이지로 이동 시 url에 ?query=키워드가 잘 붙었음
  • 하지만, 페이지를 새로고침 시 url은 유지되지만, 검색어 입력창 (inputValue)가 초기화되는 문제 발생 (사용자 경험 저하)

원인 분석

  • useEffect 내 if문을 통해 검색바 내부 검색어 제어 시도
  • 검색어 상태 복원은 할 수 없는 로직

해결

  • useSearchParams() 사용
    • 위 메서드 활용, url에 붙은 쿼리 문자열을 읽어 초기로 설정함
'use client';

import { useSearchParams } from 'next/navigation';
import { useState } from 'react';

const searchParams = useSearchParams();
const queryFromUrl = searchParams.get('query') ?? '';
const [inputValue, setInputValue] = useState(queryFromUrl);

페이지를 새로고침해도 검색어가 input에 유지됨.

🐣 useSearchParams의 올바른 사용

문제 상황

검색바 내부의 검색어 유지하기 위해 useSearchParams를 도입한 뒤, pnpm build 과정에서, 프로젝트의 모든 정적 페이지를 프리렌더링이 불가하다는 에러 메세지가 뜸.

원인 분석

  • useSearchParams는 런타임 중 url을 읽어야 하는 클라이언트 전용 훅
  • 빌드 타임에는 searchParams를 알 수 없기 때문에, 검색바가 포함된 헤더를 렌더링하는 모든 정적 페이지들이 해당 페이지를 정적으로 미리 생성할 수 없어서 에러 발생 추정
    • 정적 빌드를 목표로 하는 페이지 내 런타임 전용 코드가 섞이면 충돌이 발생

해결

  • <Suspense>로 컴포넌트 감싸기 : 공식문서 제안 방법
    • useSearchParams()를 사용하는 컴포넌트만 <Suspense>로 감싸서 비동기 처리를 허용함
      • 검색바 부분만 런타임에 로딩
      • 페이지 전체는 정적 최적화 유지
{
  /* 검색바 */
}
<div className="w-[310px] sm:w-[251px] md:w-[335px]">
  <Suspense fallback={<LoadingSpinner />}>
    <SearchBar />
  </Suspense>
</div>;
  • 선택하지 않은 방법 : 페이지 상단 export const dynamic = ‘force-dynamic’ 선언
    • 빌드 에러는 해결할 수 있지만, 매번 서버에서 렌더링됨.
      • 정적 최적화 이용 할 수 없음
🔖 북마크 버튼 클릭 시 페이지 새로고침 문제

문제 상황

  • 발생 위치: 일정 생성 페이지 (/plan-detail/new)
  • 발생 시점: 사이드바 내 북마크 버튼 또는 장소 상세 모달창에서 북마크 버튼 클릭시
  • 증상
    • 북마크 버튼 초기 클릭시 페이지 전체가 새로고침되며 작성 중이던 모든 내용 초기화
    • 첫 클릭 시에만 문제 발생, 이후 발생하지 않음.

원인 분석

  • 북마크 버튼 내부에서 revalidatePath(’/place/[id]’) 호출; 해당 경로는 현재 페이지와 무관한 경로였음.
  • 하지만 revalidatePath() 호출로 인해 클라이언트 전체가 리렌더링되면서, 상태값 초기화
    • revalidatePath(): 서버 캐시 무효화 및 해당 경로 외 전체 경로 트리의 재생성 유도
  • 특정 경로만 revalidate한다고 생각했지만, 내부적으로는 루트 경로까지 연쇄적으로 영향을 주는 것이였음.

해결

  • 북마크 로직에서 revalidatePath() 호출 완전히 제거
  • 쿼리 invalidation만을 사용함

이후 revalidatePath 사용에 대한 의사결정

  • 예상치 못한 전체 리렌더링을 유도할 수 있으므로 신중하게 사용 할 것
  • 비제어컴포넌트로 폼 제출이 가능하고, 별도 입력 상태 관리가 필요없는 경우엔 server action 기반으로 revalidatePath() 사용을 고려할 수 있음.
  • 입력값이나 작성 도중의 상태를 클라이언트에서 관리해야 할 경우 : 탠스택 쿼리의 캐시 갱신 방식 사용 권장
🌧️ 날씨 API 성능 이슈 및 데이터 동기화 문제

문제 상황

  • 성능 이슈: 클라이언트에서 외부 API 호출 → 성능 저하
  • API 호출 제한: 무료 API의 제한된 호출 횟수
  • 국제화 문제: 영어 날씨 정보 → 한글 변환 필요
  • 데이터 일관성: 사용자마다 다른 시간에 API 호출 시 데이터 불일치

해결 방법 1: 서버 사이드 API 라우트

// src/app/api/weather/route.tsexport const revalidate = 21600;// 6시간마다 자동 갱신export async function GET(request: NextRequest) {
  const forceFresh = request.nextUrl.searchParams.get('fresh') === 'true';

  try {
    const weatherData = await weatherApi.fetchWeatherFromAPI(
      API_KEY,
      LOCATION,
      forceFresh,
    );

// 캐시 설정 (강제 갱신이 아닌 경우만)if (!forceFresh) {
      headers.set(
        'Cache-Control',
        'public, s-maxage=21600, stale-while-revalidate=3600',
      );
    }

    return NextResponse.json(responseData, { status: 200, headers });
  } catch (error) {
// 에러 처리
  }
}

해결 방법 2: SSG와 ISR This is Static Site Generation & Incremental Static Regeneration 활용

// src/lib/apis/home/home.weather.api.tsexport const getStaticWeatherData = cache(
  async (): Promise<StaticWeatherResult> => {
    try {
      const API_KEY = process.env.VISUALCROSSING_API_KEY;
      const LOCATION = 'Jeju,KR';

      const weatherData = await weatherApi.fetchWeatherFromAPI(
        API_KEY,
        LOCATION,
        false,
      );

      return { weatherData, error: null };
    } catch (error) {
// 에러 처리
    }
  },
);

해결 방법 3: 자동 갱신 컴포넌트

export const WeatherRefresher = ({
  initialWeatherData,
}: {
  initialWeatherData: ProcessedDayWeather[];
}) => {
  // 자정에 데이터 갱신 설정
  useEffect(() => {
    const msUntilMidnight = weatherUtil.calculateMsUntilMidnight();

    const midnightTimer = setTimeout(() => {
      setRefreshQueued(true);
    }, msUntilMidnight);

    return () => clearTimeout(midnightTimer);
  }, []);

  // 갱신 플래그가 설정되면 날씨 데이터 갱신
  useEffect(() => {
    if (!refreshQueued) return;

    const refreshWeatherData = async () => {
      // 새로운 날씨 데이터 가져오기
    };

    refreshWeatherData();
  }, [refreshQueued]);

  return null; // UI를 렌더링하지 않는 컴포넌트
};

날씨 화면

날씨 화면 캡처

날씨 데이터 한글 변환 구현

// src/lib/apis/home/home.weather.api.ts
translateWeatherCondition(condition: string): string {
  const conditionMap: Record<string, string> = {
    Clear: '맑음',
    'Partially cloudy': '구름 조금',
    Overcast: '흐림',
    Rain: '비',
    'Rain, Partially cloudy': '구름 조금, 비',
    'Rain, Overcast': '흐림, 비',
    Snow: '눈',
    'Snow, Partially cloudy': '구름 조금, 눈',
    'Snow, Overcast': '흐림, 눈',
    Thunderstorm: '천둥번개',
    Fog: '안개',
    'Freezing Drizzle/Freezing Rain': '동결성 비',
    Drizzle: '이슬비',
  };

  return conditionMap[condition] || condition;
}

날씨 개선 결과

  1. 성능 향상: 빌드 시점에 날씨 데이터를 가져와 즉시 제공 (TTFB 개선)
  2. API 호출 최소화: 6시간마다 서버에서 한 번만 API 호출
  3. UX 개선: 로딩 시간 감소 및 한글 날씨 정보 제공
  4. 확장성: API 제공자 변경 시 서버 코드만 수정하면 되어 유지보수 용이
❗️ 캐러셀 이미지 전환 및 프로그레스 바 동기화 문제

문제 상황

홈페이지 메인 캐러셀을 구현하는 과정에서 다음과 같은 주요 문제들이 발생했습니다.

  1. 타이밍 불일치
    • 이미지가 전환되는 시점과 프로그레스 바 진행이 서로 맞지 않음
    • 사용자에게 시각적 혼란을 줌
  2. 호버 상태 일관성 부재
    • 사용자가 마우스를 올리면 이미지 자동 전환은 멈추지만
    • 프로그레스 바는 계속 진행되는 불일치 발생
  3. 성능 이슈
    • setInterval 사용으로 인한 브라우저 탭 비활성화 시 타이밍 문제 발생
    • 불필요한 상태 업데이트로 성능 저하
  4. 메모리 누수
    • 이벤트 리스너와 타이머가 컴포넌트 언마운트 시 제대로 정리되지 않음

해결 접근법 개요

  • 관심사 분리: 파일 구조를 통해 캐러셀의 여러 부분을 개별 컴포넌트로 분리
  • 커스텀 훅 개발: useCarouselProgress 훅을 만들어 타이밍 동기화 문제 해결
  • requestAnimationFrame 활용: setInterval 대신 시간 기반 계산으로 정확한 타이밍 구현
  • 상태 공유: 호버 상태를 컴포넌트 간에 공유하여 일관된 사용자 경험 제공

구현 세부 사항

1. 프로젝트 구조

src/
├── components/features/home/
│   ├── home-main-carousel.tsx         # 메인 캐러셀 컴포넌트
│   ├── home-main-carousel-container.tsx # 컨테이너 컴포넌트
│   ├── home-navigation-button.tsx     # 네비게이션 버튼
│   └── home-progress.tsx              # 프로그레스 인디케이터
├── lib/hooks/
│   └── use-carousel-progress.ts       # 커스텀 훅
├── constants/
│   └── home.constants.ts              # 상수 정의
└── types/
    └── home.carousel.type.ts          # 타입 정의

이렇게 구조화함으로써 각 컴포넌트의 역할을 명확히 하고 유지보수성을 향상시켰습니다.

2. 타입과 상수 정의

타입 정의

// 캐러셀 이미지 타입
export type CarouselImages = {
  id: number;
  title: string;
  image_url: string;
  link_url: string;
};

// 메인 캐러셀 컴포넌트 props
export type MainCarouselProps = {
  imageList: CarouselImages[] | undefined;
};

// 네비게이션 버튼 props
export type NavigationButtonProps = {
  direction: 'left' | 'right';
  onClick: () => void;
};

// 프로그레스 인디케이터 props
export type ProgressIndicatorProps = {
  current: number;
  total: number;
  progress: number;
};

상수 정의

export const MAIN_CAROUSEL_OPTIONS = {
  AUTO_ROLLING_TIME: 5000, // 자동 롤링 시간 (ms)
  WIDTH: 1024, // 이미지 원본 너비
  HEIGHT: 340, // 이미지 원본 높이
  NAVIGATION_ICON_SIZE: 48,
  NAVIGATION_ICON_STROKE_WIDTH: 2,
};

명확한 타입과 상수를 정의함으로써 컴포넌트 간 인터페이스를 명확히 하고 타입 안정성을 확보했습니다.

3. 핵심 해결책: useCarouselProgress 커스텀 훅

가장 중요한 부분은 프로그레스 바 동기화 문제를 해결하는 커스텀 훅입니다.

export const useCarouselProgress = (
  api: EmblaCarouselType | null,
  duration: number,
  paused: boolean,
) => {
  // 1. 상태 초기화
  const [currentIndex, setCurrentIndex] = useState(0);
  const [totalCount, setTotalCount] = useState(0);
  const [progressWidth, setProgressWidth] = useState(0);

  // 2. 렌더링과 독립적인 값 관리를 위한 Ref 사용
  const startTimeRef = useRef(Date.now());
  const rafRef = useRef<number | null>(null);

  // 3. 프로그레스 업데이트 함수
  const updateProgress = useCallback(() => {
    if (paused) return;

    // 실제 경과 시간 기반 계산
    const elapsed = Date.now() - startTimeRef.current;
    const progress = Math.min(100, (elapsed / duration) * 100);
    setProgressWidth(progress);

    if (progress < 100) {
      rafRef.current = requestAnimationFrame(updateProgress);
    }
  }, [duration, paused]);

  // 4. 프로그레스 리셋 함수
  const resetProgress = useCallback(() => {
    setProgressWidth(0);
    startTimeRef.current = Date.now();

    if (!paused) {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(updateProgress);
    }
  }, [paused, updateProgress]);

  // 5. Embla 캐러셀 API 연동
  useEffect(() => {
    if (!api) return;

    setTotalCount(api.scrollSnapList().length);
    setCurrentIndex(api.selectedScrollSnap());

    const onSelect = () => {
      setCurrentIndex(api.selectedScrollSnap());
      if (!paused) resetProgress();
    };

    api.on('select', onSelect);
    if (!paused) resetProgress();

    return () => {
      api.off('select', onSelect);
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [api, paused, resetProgress]);

  // 6. 일시 정지 상태 변경 처리
  useEffect(() => {
    if (paused) {
      setProgressWidth(0);
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    } else {
      resetProgress();
    }
  }, [paused, resetProgress]);

  return { currentIndex, totalCount, progressWidth };
};

이 훅의 핵심 개선 사항:

  • 정확한 시간 계산: setInterval 대신 Date.now()와 실제 경과 시간을 사용
  • 효율적인 렌더링: requestAnimationFrame으로 브라우저 렌더링 사이클에 맞춘 업데이트
  • 호버 상태 연동: paused 파라미터를 통해 호버 상태와 프로그레스 바 동기화
  • 자원 관리: 효과적인 클린업 로직으로 메모리 누수 방지

4. 메인 캐러셀 컴포넌트 구현

메인 캐러셀 컴포넌트에서는 Embla Carousel과 커스텀 훅을 연결합니다.

const MainCarousel = ({ imageList }: MainCarouselProps) => {
  // 호버 상태 관리
  const [isHovered, setIsHovered] = useState(false);

  // Embla Carousel 초기화
  const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [
    Autoplay({
      delay: MAIN_CAROUSEL_OPTIONS.AUTO_ROLLING_TIME,
      stopOnMouseEnter: true, // 마우스 오버 시 자동 재생 중지
      stopOnInteraction: false, // 사용자 상호작용 시에도 자동 재생 유지
    }),
  ]);

  // 커스텀 훅으로 프로그레스 상태 관리
  const { currentIndex, totalCount, progressWidth } = useCarouselProgress(
    emblaApi ?? null,
    MAIN_CAROUSEL_OPTIONS.AUTO_ROLLING_TIME,
    isHovered, // 호버 상태 전달
  );

  return (
    <div
      className="relative overflow-hidden"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onKeyDown={(e) => {
        if (e.key === 'ArrowLeft') emblaApi?.scrollPrev();
        if (e.key === 'ArrowRight') emblaApi?.scrollNext();
      }}
      tabIndex={0}
      role="region"
      aria-label="이미지 캐러셀"
    >
      {/* 캐러셀 컨테이너 */}
      <div className="overflow-hidden" ref={emblaRef}>
        {/* 이미지 목록 렌더링 */}
        {/* ... */}
      </div>

      {/* 네비게이션 버튼 */}
      <NavigationButton
        direction="left"
        onClick={() => emblaApi?.scrollPrev()}
      />
      <NavigationButton
        direction="right"
        onClick={() => emblaApi?.scrollNext()}
      />

      {/* 프로그레스 인디케이터 */}
      <ProgressIndicator
        current={currentIndex + 1}
        total={totalCount}
        progress={progressWidth}
      />
    </div>
  );
};

이 컴포넌트의 주요 포인트:

  • 호버 상태 관리: useState로 관리되는 호버 상태가 Embla Carousel과 프로그레스 바에 모두 전달됨
  • 일관된 동작: 마우스 오버 시 이미지 전환과 프로그레스 바가 함께 정지
  • 접근성 개선: 키보드 네비게이션 및 ARIA 속성 추가

주요 기술적 도전과 해결법

1. setInterval에서 requestAnimationFrame으로 전환

문제점:

  • setInterval은 브라우저 탭이 비활성화되면 실행 간격이 지연됨
  • 실제 시간과 애니메이션 진행 불일치 발생
  • 불필요한 상태 업데이트로 성능 저하

해결법:

  • requestAnimationFrame 사용으로 브라우저 렌더링 사이클에 최적화
  • 실제 시간 기반 계산으로 정확한 프로그레스 표시
  • 비활성 탭에서 자동 일시정지로 리소스 절약

개선 코드

// 기존: setInterval 사용
const timer = setInterval(() => {
  setProgress((prev) => prev + step);
}, 100);

// 개선: 실제 시간 측정 + requestAnimationFrame
const updateProgress = () => {
  const elapsed = Date.now() - startTime;
  const progress = (elapsed / duration) * 100;
  setProgressWidth(progress);

  if (progress < 100) {
    requestAnimationFrame(updateProgress);
  }
};

2. 호버 상태와 프로그레스 바 동기화

문제점:

  • 사용자가 마우스를 올리면 이미지는 멈추지만 프로그레스 바는 계속 진행
  • 사용자에게 혼란스러운 경험 제공

해결법:

  • isHovered 상태를 메인 컴포넌트에서 관리
  • 호버 상태를 커스텀 훅에 전달하여 프로그레스 바 제어
  • Embla Carousel과 프로그레스 바의 행동 일치

키 포인트:

// 메인 컴포넌트에서 호버 상태 관리
const [isHovered, setIsHovered] = useState(false);

// 훅에 호버 상태 전달
const { ... } = useCarouselProgress(
  emblaApi,
  duration,
  isHovered// 호버 상태를 paused 파라미터로 전달
);

// 훅 내부에서 paused 상태에 따라 처리
if (paused) {
// 애니메이션 중지 및 정리
}

3. 리소스 관리와 메모리 누수 방지

문제점:

  • 이벤트 리스너 누적
  • 언마운트 시 타이머 정리 불완전
  • 장기간 사용 시 메모리 사용량 증가

해결법:

  • 모든 useEffect에 철저한 클린업 함수 구현
  • 이벤트 리스너, 애니메이션 프레임 명시적 제거
  • Ref를 활용한 리소스 추적 및 정리

핵심 코드:

useEffect(() => {
  // 설정 코드...

  // 철저한 클린업
  return () => {
    api.off('select', onSelect);
    if (rafRef.current) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    }
  };
}, [dependencies]);
🤔 인증 시스템의 상태 관리 최적화 문제

문제 상황

인증 시스템의 상태 관리와 관련된 두 가지 핵심 문제가 발생했습니다.

문제 1: 불필요한 데이터 중복 저장

Server Actions으로 인증 작업(로그인, 회원가입, 정보 업데이트)을 처리하고 Supabase가 쿠키에 세션을 저장했습니다. 그러나 동시에 Zustand와 persist 미들웨어를 사용해 동일한 사용자 정보를 세션 스토리지에도 중복 저장했습니다.

이로 인해:

  • 같은 정보가 두 곳에 중복되어 리소스 낭비
  • 여러 저장소 관리로 인한 복잡성 증가
  • 데이터 불일치 가능성 상승

문제 2: 실시간 상태 동기화 실패

Server Actions으로 사용자 정보(예: 닉네임)를 변경한 후, 이 변경사항이 Zustand 상태에 자동으로 반영되지 않았습니다.

상태 업데이트가 발생하는 경우

  • 페이지 새로고침 시
  • 다른 페이지로 이동 후 돌아올 때
  • onAuthStateChange 이벤트 발생 시 (로그인/로그아웃에만 발생)

실제 사용자 경험에 미친 영향

  • 사용자가 프로필에서 닉네임 변경 → 헤더나 사이드바에는 여전히 이전 닉네임 표시
  • UI 불일치로 인한 사용자 혼란과 신뢰도 하락

기존 접근 방식과 한계점

데이터 흐름의 복잡성

기존 인증 시스템은 다음과 같이 작동했습니다.

  1. Server Actions으로 인증 처리 (로그인, 정보 변경 등)
  2. Supabase가 쿠키에 세션 정보 저장
  3. 개발자가 수동으로 Zustand 상태 업데이트 코드 작성
  4. Zustand persist 미들웨어가 세션 스토리지에 같은 정보 저장

코드로 보는 문제점

1. 중복 저장 문제

// Zustand 스토어 + 세션 스토리지 중복 저장
const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }),
      updateUserInfo: (userInfo) =>
        set((state) => ({
          user: state.user ? { ...state.user, ...userInfo } : null,
        })),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => sessionStorage), // 중복 저장
    },
  ),
);

2. 수동 동기화 필요 (프로필 업데이트)

// 프로필 업데이트 함수
const handleUpdateProfile = async (nickname) => {
  try {
    // 서버 액션 호출
    const result = await updateUserProfile(nickname);

    if (result.success) {
      // 수동으로 Zustand 상태 업데이트 필요// 이 코드를 잊으면 UI 불일치 발생
      updateUserInfo({ nickname });
      return true;
    }
    return false;
  } catch (error) {
    return false;
  }
};

3. 컴포넌트 간 불일치 (UI 문제)

// 프로필 페이지 (닉네임 업데이트 후)
const ProfilePage = () => {
  const { user } = useAuth();
// 여기서는 업데이트된 닉네임 표시
  return <p>닉네임: {user?.nickname}</p>;
};

// 헤더 컴포넌트 (다른 파일)
const Header = () => {
  const { user } = useAuth();
// 여기서는 이전 닉네임 계속 표시
  return <div>안녕하세요, {user?.nickname}</div>;
};

한계점

  1. 개발자 의존성 - 모든 상태 변경 후 수동 업데이트 코드 필요
  2. 일관성 부재 - 여러 컴포넌트에서 서로 다른 상태 표시
  3. 코드 중복 - 비슷한 동기화 로직이 여러 곳에 반복
  4. 오류 가능성 - 수동 업데이트 코드 누락 시 UI 불일치 발생
  5. 불필요한 복잡성 - 쿠키가 이미 있는데 세션 스토리지 중복 사용

해결 방법: TanStack Query 도입

쿠키를 단일 진실 소스(single source of truth)로 사용하고, TanStack Query를 활용하여 UI와 서버 상태 간 동기화를 자동화하는 방식으로 전환했습니다.

  1. 쿠키만 사용 - 사용자 정보는 쿠키에만 저장 (중복 저장 제거)
  2. TanStack Query - 서버 상태와 UI를 자동으로 동기화
  3. Server Actions - 인증 로직은 계속 서버에서 처리

개선된 코드 구현

1. 사용자 정보 조회 (서버 액션)

// 현재 사용자 정보를 서버에서 가져오기
export async function getCurrentUser() {
  const supabase = getServerClient();

  try {
    // 쿠키 기반 세션에서 최신 정보 가져오기
    const { data, error } = await supabase.auth.getUser();

    if (error) throw error;

    return data.user
      ? {
          id: data.user.id,
          email: data.user.email,
          nickname: data.user.user_metadata?.nickname || null,
        }
      : null;
  } catch (error) {
    console.error('사용자 정보 조회 실패:', error);
    return null;
  }
}

2. TanStack Query 사용자 정보 훅

// 사용자 정보를 가져오는 쿼리 훅
export const useCurrentUser = () => {
  return useQuery({
    queryKey: ['user'],
    queryFn: getCurrentUser,
    staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
    refetchOnWindowFocus: false,
  });
};

3. 프로필 업데이트 뮤테이션

// 프로필 업데이트 뮤테이션 훅
export const useUpdateProfile = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateUserProfile,
    onSuccess: () => {
      // 핵심: 성공 시 사용자 쿼리 자동 무효화
      queryClient.invalidateQueries({ queryKey: ['user'] });
    },
  });
};

4. 컴포넌트에서의 사용

// 프로필 페이지
const ProfilePage = () => {
// 사용자 정보 쿼리
  const { data: user } = useCurrentUser();
// 프로필 업데이트 뮤테이션
  const updateProfileMutation = useUpdateProfile();

  const onUpdateNickname = async (nickname) => {
    try {
      await updateProfileMutation.mutateAsync(nickname);
      toast.success("프로필이 업데이트되었습니다.");
// 수동 상태 업데이트 코드 불필요 - 자동 처리
    } catch (error) {
      toast.error("오류가 발생했습니다.");
    }
  };

  return <p>닉네임: {user?.nickname || "이름 없음"}</p>;
};

// 헤더 컴포넌트 (다른 파일)
const Header = () => {
// 동일한 쿼리 사용 - 자동으로 동기화됨
  const { data: user } = useCurrentUser();
  return <div>안녕하세요, {user?.nickname || "사용자"}</div>;
};

개선 효과

1. 코드 간소화

이전: 상태 변경 후 항상 수동 업데이트 코드 필요

// 수동 상태 관리 방식
const handleUpdateProfile = async (nickname) => {
  const result = await updateUserProfile(nickname);
  if (result.success) {
    // 수동 업데이트 필요
    updateUserInfo({ nickname });
  }
};

이후: 선언적이고 자동화된 상태 관리

// 자동 상태 관리 방식
const handleUpdateProfile = async (nickname) => {
  await updateProfileMutation.mutateAsync(nickname);
  // 자동으로 모든 컴포넌트 업데이트
};

2. 실시간 UI 동기화

이전

  • 컴포넌트 간 상태 불일치 발생
  • 새로고침/페이지 이동 후에야 상태 동기화

이후

  • 모든 컴포넌트에서 항상 동일한 최신 상태 표시
  • 상태 변경 즉시 모든 UI에 자동 반영

3. 데이터 저장 효율화

이전

  • 사용자 정보가 쿠키와 세션 스토리지에 중복 저장
  • 두 저장소 간 동기화 로직 필요

이후

  • 쿠키만을 단일 진실 소스로 사용
  • 불필요한 중복 저장 제거
  • 메모리와 스토리지 사용량 감소

4. 개발자 경험 향상

이전

  • 상태 업데이트 로직을 모든 변경 후 수동 작성
  • 실수 가능성과 디버깅 부담 증가
  • 코드 중복으로 인한 유지보수 어려움

이후

  • 자동 동기화로 추가 코드 작성 불필요
  • 일관된 패턴으로 모든 데이터 변경 처리
  • 코드 가독성과 유지보수성 향상
💥 일정에 추가되는 장소 카드의 고유 ID 충돌

문제 상황

  • 발생 위치: 일정 상세 페이지의 장소 드래그 앤 드롭 기능
  • 문제 현상:
    • 장소 순서 변경 시 고유 ID 충돌 발생
    • 일부 장소 데이터 드래그시 다른 장소 카드도 같이 드래그 되며 layout shift 문제 발생

콘솔 에러 메시지

Unable to find any drag handles in the context "2"
A setup problem was encountered.> Invariant failed: Draggable[id: 2850913-0]: Unable to find drag handle

원인 분석

일정에 추가된 장소 카드들을 관리하는 key를 다음과 같이 부여

장소 고유 id + 해당 일자에서의 장소 index(0,1,2,3,…)

// 특정 날짜에 장소 추가
const handleAddPlace = (newPlace: Place, activeTab: number | '전체보기') => {
		let uniqueId = '';
		setPlaceCount((prev) => {
      uniqueId = `${newPlace.id}-${prev}`; // 현재 index로 unique id 부여
      return prev + 1;
    });
    const newPlaceWithId = { ...newPlace, uniqueId };

    setDayPlaces((prev: DayPlaces) => ({
      ...prev,
      [dayNumber]: [...(prev[dayNumber] || []), newPlaceWithId],
    }));
}

// 특정 날짜에 있는 장소들 복사한 것을 붙여넣기
const handlePasteDayPlaces = (targetDay: number) => {
    if (copiedDay === null) return;

    setDayPlaces((prev: DayPlaces) => {
      const copiedPlaces = [...(prev[copiedDay] || [])];
      const newPlaces = copiedPlaces.map((place) => ({
        ...place,
        // 이미 존재하는 장소 뒤에 붙여넣기, 다음 index로 unique id 부여
        uniqueId: `${place.id}-${placeCount + copiedPlaces.indexOf(place)}`,
      }));
      ...
}
  • 문제 1. 고유 ID 중복 현상 발생 ex) 동일 장소 3개 추가 후(0,1,2 index) 1번 index 장소 삭제, 이후 동일 장소 추가시 동일 장소 id+동일한 2번 index를 가짐 ⇒ 고유성 위반 문제
  • 문제 2. id 의미 상실 순서 변경시 고유 id 인덱싱 의미 상실

해결

nanoid 라이브러리를 사용해 고유한 id 생성

⇒ ID 충돌 문제 해결, 시스템 안정성 향상

const handleAddPlace = (newPlace: Place, activeTab: number | '전체보기') => {
  if (activeTab === '전체보기' || isReadOnly) return;

  const dayNumber = activeTab as number;
  const uniqueId = nanoid();
  const newPlaceWithId = { ...newPlace, uniqueId };

  const updatedDayPlaces = {
    ...dayPlaces,
    [dayNumber]: [...(dayPlaces[dayNumber] || []), newPlaceWithId],
  };
  setDayPlaces(updatedDayPlaces);
  setStoreDayPlaces(updatedDayPlaces);
};

const handlePasteDayPlaces = (targetDay: number) => {
  if (copiedDay === null) return;

  const copiedPlaces = [...(dayPlaces[copiedDay] || [])];
  const newPlaces = copiedPlaces.map((place) => ({
    ...place,
    uniqueId: nanoid(),
  ...
}));
📈 경로 탐색 API 과다 호출 문제

문제 상황

  • 발생 위치: 일정 상세 페이지의 지도 경로 탐색 기능
  • 문제 현상:
    • 불필요한 네트워크 요청으로 인한 성능 저하 및 API 비용 발생
    • 사용자 경험 저하

원인 분석

  • 장소 순서를 빈번하게 변경할 경우, 경로 재계산이 전체적으로 이루어져 API 호출비용이 과다하게 증가 ⇒ 일정에 대한 캐싱 기능 도입 필요성 제기
  • dayPlaces 객체가 변경될 때마다 마커와 경로가 재계산됨, dayPlaces 객체는 일정에 대한 필수 데이터들을 모두 갖고 있고 zustand로 상태 관리를 하고 있어, 다음 상황에서 실제 변경된 내용이 없어도 리렌더링 발생하는 경우 존재
    • zustand 스토어 업데이트시 실제 변경된게 없을 때(드래그 결과가 이전과 같음)에도 새로운 객체를 참조하게 되어 계산/리렌더링 발생 ⇒ 이전 dayPlaces 객체 상태에 대한 참조를 갖고 있도록 해야 하는 필요성 제기
// 경로에 대한 거리/시간 정보 계산
const searchRoute = async (markers: MarkerProps[], day: number) => {
  if (!map || markers.length < 2) return;

  const routeInfo = createRouteInfo(markers);
  const { path, sections } = await getCarRoute(routeInfo);

  setPaths((prev) => ({ ...prev, [day]: path }));
  setRouteSummary({ ...prevSummary, [day]: sections });
};

해결

  • 경로 캐시 상태를 가져 동일한 경로를 재계산하지 않게 하기 위해 routeCache추가
    • 키 형식: 'day-위도,경도|위도,경도...' (일차와 마커 위치 정보를 조합)
    • 저장 내용: 계산된 경로(path)와 섹션 정보(sections, 거리/이동시간)
    • 사용 시점: searchRoute 함수 내에서 경로 계산 전에 캐시 확인
  • 이전 dayPlaces 상태를 추적하기 위한 참조인 prevDayPlaces 추가
 const [routeCache, setRouteCache] = useState<{
    [key: string]: { path: { lat: number; lng: number }[]; sections: any[] };
  }>({});

// routeCache 사용
const searchRoute = useCallback(
  async (markers: MarkerProps[], day: number) => {
    if (!map || markers.length < 2) return;

    try {
      // 캐시 키 생성 (일차와 마커 위치 정보를 조합)
      const cacheKey = `${day}-${markers.map((m) => `${m.position.lat},${m.position.lng}`).join('|')}`;

      // 캐시된 경로가 있으면 재사용
      if (routeCache[cacheKey]) {
        const { path, sections } = routeCache[cacheKey];
        setPaths((prev) => ({ ...prev, [day]: path }));
        const prevSummary = usePlanStore.getState().routeSummary;
        setRouteSummary({ ...prevSummary, [day]: sections });
        return;

        ...
}

// prevDayPlaces 사용
const updateMarkersAndRoutes = async () => {
      try {
        const newMarkers = getMarkersToShow();
        // dayPlaces 변경 여부 확인
        const dayPlacesChanged =
          JSON.stringify(dayPlaces) !==
          JSON.stringify(prevDayPlacesRef.current);

        // 마커나 dayPlaces가 변경된 경우에만 업데이트
        if (
          dayPlacesChanged ||
          JSON.stringify(markers) !== JSON.stringify(newMarkers)
        ) {
	        ...
        }
}

장점

  • 실제 변경이 있을 때만 업데이트 실행
  • 불필요한 리렌더링 방지
  • API 호출 최소화
  • 성능 향상
👯 일정 생성/수정 시 상태 동기화 문제

문제 상황

  • 발생 위치: 일정 생성/수정 기능의 상태 관리
  • 문제 현상:
    • 여러 컴포넌트 간 상태 동기화 불안정
    • 상태 업데이트 시 예상치 못한 사이드 이펙트 발생

원인 분석

이전 방식은 하위 컴포넌트로 일정 정보를 props로 전달

  • 여러 상태가 동시에 업데이트되어야 할 때 개별적으로 업데이트
    • 상태 간 의존성 관리가 제대로 되지 않고 있음
  • 상태 초기화 타이밍 문제
    • 각 컴포넌트가 상태 업데이트 시점이 공유되지 않아 일부 상태만 초기화되는 경우 발생
// 경로에 대한 거리/시간 정보 계산
const searchRoute = async (markers: MarkerProps[], day: number) => {
  if (!map || markers.length < 2) return;

  const routeInfo = createRouteInfo(markers);
  const { path, sections } = await getCarRoute(routeInfo);

  setPaths((prev) => ({ ...prev, [day]: path }));
  setRouteSummary({ ...prevSummary, [day]: sections });
};

해결

  • 일정에 관한 상태들을 그룹화하여 zustand 전역 상태로 관리
  • 상태 업데이트 및 초기화가 동시에 발생
  • selector를 사용하여 컴포넌트가 필요로 하는 특정 상태만 구독 ⇒ store 안에 저장된 특정 props가 변경됐을 때 다른 요소를 구독하고 있는 컴포넌트에서 리렌더링이 발생하지 않게 만든다.
export const usePlanStore = create<PlanState>((set) => ({
  // 여행 기간
  startDate: null,
  endDate: null,
  setStartDate: (date) => set({ startDate: date }),
  setEndDate: (date) => set({ endDate: date }),

  // 계획 기본 정보
  title: '',
  description: '',
  planImg: null,
  isReadOnly: false,
  planId: 0,
  setTitle: (title) => set({ title }),
  setDescription: (description) => set({ description }),
  setPlanImg: (planImg) => set({ planImg }),
  setIsReadOnly: (isReadOnly) => set({ isReadOnly }),
  setPlanId: (planId) => set({ planId }),

  // 일정 관련 정보 초기값
  dayPlaces: {},
  activeTab: '전체보기',
  setActiveTab: (activeTab) => set({ activeTab }),
  setDayPlaces: (dayPlaces) => set({ dayPlaces }),

  // 경로 요약 정보
  routeSummary: {},
  setRouteSummary: (routeSummary) => set({ routeSummary }),

  // 이미지 업데이트 후 상태 초기화 함수 추가
  resetPlanState: () =>
    set({
      title: '',
      description: '',
      planImg: null,
      isReadOnly: false,
      planId: 0,
      dayPlaces: {},
      activeTab: '전체보기',
      routeSummary: {},
    }),
}));

// Selectors
export const usePlanTitle = () => usePlanStore((state) => state.title);
export const usePlanDescription = () =>
  usePlanStore((state) => state.description);
export const usePlanImg = () => usePlanStore((state) => state.planImg);
export const usePlanIsReadOnly = () =>
  usePlanStore((state) => state.isReadOnly);
export const usePlanId = () => usePlanStore((state) => state.planId);
export const usePlanStartDate = () => usePlanStore((state) => state.startDate);
export const usePlanEndDate = () => usePlanStore((state) => state.endDate);
export const usePlanDayPlaces = () => usePlanStore((state) => state.dayPlaces);
export const usePlanActiveTab = () => usePlanStore((state) => state.activeTab);

// Setter functions
export const usePlanSetTitle = () => usePlanStore((state) => state.setTitle);
export const usePlanSetDescription = () =>
  usePlanStore((state) => state.setDescription);
export const usePlanSetImg = () => usePlanStore((state) => state.setPlanImg);
export const usePlanSetIsReadOnly = () =>
  usePlanStore((state) => state.setIsReadOnly);
export const usePlanSetId = () => usePlanStore((state) => state.setPlanId);
export const usePlanSetStartDate = () =>
  usePlanStore((state) => state.setStartDate);
export const usePlanSetEndDate = () =>
  usePlanStore((state) => state.setEndDate);
export const usePlanSetDayPlaces = () =>
  usePlanStore((state) => state.setDayPlaces);
export const usePlanSetActiveTab = () =>
  usePlanStore((state) => state.setActiveTab);

장점

  • 상태 동기화 문제 해결
  • 디버깅 용이성 향상
🐸 문자열 parsing 오류

문제 상황

toslice() is not a function 에러 발생

원인 분석

날짜 정보를 불러오고 해당 정보를 기존 HHMM 형식이던 데이터를 HH:MM 으로 표현하는 과정에서 일부 파라미터 데이터 값만 string이 아닌 number로 인식됨

해결

toString()으로 parameter 값을 형 변환후 파싱 및 리턴

//기존코드
const formatTime = (timeStr: string | number) => {
  if (!timeStr || timeStr.length !== 4) return timeStr;
  return `${timeStr.slice(0, 2)}:${timeStr.slice(2)}`;
};

//변경 후 코드
const formatTime = (timeString: string | number) => {
  const timeStr = timeString.toString(); // 강제 형변환
  if (!timeStr || timeStr.length !== 4) return timeStr;
  return `${timeStr.slice(0, 2)}:${timeStr.slice(2)}`;
};

⚙️ 시스템 아키텍처

arichetecture

📁 프로젝트 폴더 구조

src/
├─ app/
│  ├─ (auth)/           # 인증 관련 페이지 (로그인, 회원가입 등)
│  ├─ (mypage)/         # 마이페이지
│  ├─ air-ticket/       # 항공권 검색 및 예약 페이지
│  ├─ api/              # API 라우트 핸들러
│  ├─ auth/             # 인증 관련 API 및 핸들러
│  ├─ categories/       # 카테고리별 장소 보기 페이지
│  ├─ community/        # 커뮤니티 페이지
│  ├─ my-plan/          # 나의 일정 리스트 페이지
│  ├─ places/           # 장소 상세 정보 페이지
│  ├─ plan-detail/      # 여행 일정 상세 페이지
│  └─ search/           # 검색 결과 페이지
├─ components/
│  ├─ commons/          # 공통으로 사용할 컴포넌트
│  ├─ features/         # 특정 기능 관련 컴포넌트
│  ├─ icons/            # 아이콘 컴포넌트
│  ├─ layouts/          # 레이아웃 관련 컴포넌트
│  └─ ui/               # Shadcn UI 컴포넌트
├─ config/
├─ constants/           # 상수 모음
├─ data
├─ lib/
│  ├─ apis/             # 통신 함수
│  ├─ hooks/            # 커스텀 훅
│  ├─ mutations/        # Tanstack Query 뮤테이션 훅
│  ├─ queries/          # Tanstack Query 쿼리 훅
│  ├─ schemas/          # Zod 스키마 모음
│  └─ utils/            # 유틸 함수
├─ types/               # 타입 모음
└─ zustand/             # Zustand 설정 및 스토어

👥 팀원 소개

YounghyunJo
조영현

Jeonghyun Min
민정현

Pureun Kang
강푸른

인증시스템 설계 및 구현, 메인페이지
DB 설계, 항공권 조회 및 예약 페이지
검색바, 검색 결과, 장소 상세 페이지
Soheun Lee
이소흔

YongjunKo
고용준

마이페이지, 커뮤니티/장소 페이지, 모의 결제 기능
내 일정 페이지, 일정 생성/수정/조회 페이지

Releases

Packages

Used by

Contributors

Languages