- ↗ 9명 팀 프로젝트 (PM 1, 디자이너 2, BE 3, FE 3)
- ↗ 5개월 기획 ~ 개발 완료
- ↗ 실서비스 배포 (trip-pixel.com)
서비스 소개
TripPixel은 여행 계획의 처음부터 끝을 하나의 플랫폼에서 해결한다. YouTube 영상 URL을 넣으면 AI가 영상에서 여행지를 추출해주고, 멤버들이 지도 위에서 장소를 고르고 투표해 일정을 만든다.
팀 구성: PM 1 · 디자이너 2 · BE 3 · FE 3 (총 9명, WAPO 티켓 기반 협업)
아키텍처
┌──────────────────────────────────────────────────────────────┐
│ Frontend (Next.js App Router) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 보관함 │ │ 플랜 │ │ 마이페이지 │ │
│ │ (컬렉션) │ │ (일정/예산) │ │ 알림 │ │
│ └─────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TanStack Query (서버 상태 + 캐싱) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Google Maps API │ │ shadcn/ui │ │ Axios │ │
│ │ (후보지 지도) │ │ + Tailwind │ │ + 인터셉터 │ │
│ └─────────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────┘
│ REST API
▼
┌─────────────────┐
│ Backend (BE) │
│ AI 장소 추출 │
│ 여행/예산 API │
│ 알림/멤버 API │
└─────────────────┘
내가 구현한 기능
인증 & 온보딩
소셜 로그인(카카오·네이버·구글) 페이지와 OAuth 콜백 처리를 구현했다. Axios 인터셉터로 401 응답 시 reissue API를 자동 호출해 토큰을 갱신하고, TERMS_REQUIRED 403 응답 시 온보딩 페이지로 리다이렉트하는 플로우를 만들었다.
// 401 발생 시 reissue → 원래 요청 재시도
axiosInstance.interceptors.response.use(
(res) => res,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
await reissue(); // refresh token으로 access token 갱신
return axiosInstance(error.config);
}
return Promise.reject(error);
}
);
보관함 (컬렉션)
보관함 목록/생성/수정/상세 페이지를 전담했다. 핵심은 장소별 PICK·PASS 투표 시스템과 장소 추가 플로우다.
장소 추가 — 3가지 방식
- 검색: Google Maps Place API 기반 장소 검색 → 보관함에 추가
- AI 추출: YouTube URL 입력 → BE의 AI 파이프라인이 영상에서 장소 추출 → 폴링으로 완료 감지 → 장소 목록 표시
- 수동 입력: 직접 장소명/주소 입력
// YouTube AI 추출 — 완료될 때까지 폴링
const useAIExtractPoller = (taskId: string) => {
return useQuery({
queryKey: ['ai-extract', taskId],
queryFn: () => fetchExtractStatus(taskId),
refetchInterval: (data) =>
data?.status === 'COMPLETED' ? false : 2000, // 완료 전까지 2초 간격
enabled: !!taskId,
});
};
장소 상세 페이지에는 AI 요약 섹션, PICK·PASS 투표 버튼, 투표 참여자 바텀시트, 메모 편집이 포함된다. 페이지 이탈 시 진행 중인 AI 추출 작업을 자동 취소하는 로직도 추가했다.
플랜 (여행 일정)
플랜 관련 페이지 중 내가 담당한 범위는 넓다.
프로젝트 생성·목록·수정
여행 이름, 날짜 범위, 멤버를 설정하는 폼. 수정 페이지에서 여행 일자 변경 시 update_type에 따라 확인 다이얼로그가 분기되고, 완료 후 returnTo 쿼리 파라미터로 이전 화면에 복귀한다.
플랜 편집 모드 (PlanEditPage)
DayTimeBlocks: day별 블록을 무한 스크롤로 렌더링useStickyStuck훅으로 DayNav sticky 감지 → 스타일 토글- 블록 삭제·날짜/시간 수정 바텀시트, 후보지 재선택 UI
플랜 보기 모드 (PlanPage)
ViewTimeBlock,ViewPlace컴포넌트 구현ViewPlace에 의견 바텀시트 연동- 블록 CRUD 성공 시 infinite 캐시 invalidation
DayNav + Scrollspy 날짜 탭을 클릭하면 해당 일차로 부드럽게 스크롤하고, 스크롤 위치에 따라 현재 날짜를 하이라이팅한다.
// 스크롤 위치 기반 현재 날짜 추적
const useScrollspy = (dayIds: string[]) => {
const [activeDay, setActiveDay] = useState(dayIds[0]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => {
if (e.isIntersecting) setActiveDay(e.target.id);
}),
{ threshold: 0.4 }
);
dayIds.forEach((id) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [dayIds]);
return { activeDay, setActiveDay };
};
후보지 & Google Maps
일정 블록에 여러 장소를 후보지로 등록하고, 지도 위에서 CandidatePin으로 위치를 확인하며 최종 장소를 선택한다.
// 후보지 선택 시 지도 자동 fitBounds
useEffect(() => {
if (!mapRef.current || candidates.length === 0) return;
const bounds = new google.maps.LatLngBounds();
candidates.forEach((c) => bounds.extend({ lat: c.lat, lng: c.lng }));
mapRef.current.fitBounds(bounds);
}, [candidates]);
커스텀 Zoom 컨트롤도 직접 구현했다 (기본 컨트롤 디자인이 디자인 시스템과 맞지 않아서).
예산
예산 탭 전체를 담당했다. BudgetSummaryCard(총 예산·지출·1인당 비용), 일별 지출 항목 CRUD, BudgetBottomSheet(항목 추가/수정/삭제), 예산 편집 다이얼로그를 구현했다.
지출 항목이 많아질 때 성능을 위해 day별 병렬 조회 방식을 사용했다.
// 전체 day 지출을 병렬로 조회
const expenseQueries = useQueries({
queries: days.map((day) => ({
queryKey: ['expenses', planId, day.id],
queryFn: () => fetchDayExpenses(planId, day.id),
})),
});
멤버 관리
MemberSideDrawer에서 플랜·컬렉션 멤버 목록을 보여주고, 관리 모드에서 소유자 지정·멤버 강퇴·나가기를 처리한다. 초대 링크 생성 API를 연동해 복사 다이얼로그(CollectionInviteDialog)도 구현했다.
마이페이지 & 알림
마이페이지/프로필 편집: 닉네임 수정(유효성 검증), 프로필 이미지 변경(presigned URL 방식 S3 업로드), 회원 탈퇴 2단계 다이얼로그.
알림 드로어: 헤더 알림 버튼 클릭 시 우측에서 슬라이드인. 무한 스크롤로 알림 목록 조회, 카테고리별 배지, 전체 삭제 확인 다이얼로그.
배운 점
TanStack Query 캐시 설계
여러 페이지에서 같은 데이터를 참조하다 보니 mutation 이후 어느 queryKey를 invalidate해야 하는지 초반에 명확하지 않아 화면 불일치가 생겼다. queryKey 구조를 계층적으로 설계하고 팀에서 합의하는 게 먼저라는 걸 배웠다.
Google Maps는 한국어 레퍼런스가 거의 없다 카카오맵 대비 국내 레퍼런스가 없어서 타입 정의와 공식 문서를 직접 파고들어야 했다. 덕분에 영어 공식 문서를 빠르게 읽는 습관이 생겼다.
AI 추출 같은 비동기 작업의 UX 추출이 진행 중일 때 사용자가 뒤로가기 하면 작업이 서버에 남는다. 페이지 언마운트 시 취소 API를 자동 호출하는 처리가 필요했다 — UX와 서버 리소스 모두 고려해야 한다는 걸 알게 됐다.