FE 2 / BE 3 / Designer 2 / PM 1
사용자의 여행 조건을 기반으로 AI가 최적화된 일정을 실시간으로 생성해주는 웹 플랫폼입니다.

여행 계획을 세울 때 여행지 선정, 동선 최적화, 시간 배분 등 고려해야 할 요소가 많아 많은 시간이 소요됩니다. Airoad는 사용자가 입력한 여행 조건(지역, 기간, 테마, 인원)을 AI가 분석하여 일차별 최적 일정과 함께 실시간 채팅 기반 일정 수정 기능을 제공합니다.
| 분류 | 기술 |
|---|---|
| Framework | React 19.1.1, TypeScript 5.9.3, Vite 7.1.7 |
| 상태관리 | TanStack React Query 5.90.2, Zustand 5.0.8 |
| 스타일링 | Vanilla Extract 1.17.4, Radix UI Themes 3.2.1 |
| 실시간 통신 | STOMP.js 7.2.1 |
| 라우팅 | React Router 7.9.4 |
| HTTP 클라이언트 | Axios 1.12.2 |
| 지도 | react-kakao-maps-sdk 1.2.0 |
| 테스트 | MSW 2.11.3 |
Feature-Sliced Design (FSD) 아키텍처를 적용하여 레이어 간 의존성을 명확하게 관리합니다.
src/
├── app/ # 엔트리포인트, 프로바이더, 라우터
├── pages/ # 페이지 컴포넌트
│ ├── main/ # 메인 페이지 (일정 생성 폼)
│ ├── trip-chat/ # 일정 상세 (채팅 + 일정 + 지도)
│ ├── trip-list/ # 일정 목록
│ └── redirect/ # OAuth 리다이렉트
├── entities/ # 도메인 모델 + API
│ ├── auth/ # 인증 (토큰 관리)
│ ├── chats/ # 채팅 (메시지 조회, 스토어)
│ ├── map/ # 지도 (좌표, 줌 레벨)
│ ├── members/ # 회원 (프로필)
│ └── trips/ # 여행 (일정 조회, WebSocket 훅)
├── widgets/ # 조합 컴포넌트
│ ├── header/ # 공통 헤더
│ ├── main-layout/ # 메인 레이아웃
│ └── chat-layout/ # 채팅 레이아웃
└── shared/ # 공통 유틸, UI, 스토어
├── ui/ # 공통 UI 컴포넌트
├── lib/ # axios, stomp 유틸
├── hook/ # 커스텀 훅
└── config/ # 상수, 라우트 설정
app > pages > widgets > entities > shared 순으로 import 가능문제 상황
AI가 일정을 생성하는 동안(10~15초) WebSocket 연결이 유지되어야 하는데, 액세스 토큰이 만료되면 STOMP 연결이 끊기고 실시간 데이터 수신이 중단되었다. 사용자는 일정 생성이 멈춘 것처럼 보여 페이지를 새로고침하거나 이탈하는 현상이 발생했다.
원인 분석
액세스 토큰 만료 시 서버에서 WebSocket 연결을 강제 종료하는데, 클라이언트에서 토큰 만료를 감지하고 재연결하는 로직이 없었다. STOMP.js의 기본 reconnectDelay만으로는 토큰 갱신 없이 재연결을 시도하여 계속 실패했다.
해결 방법
onWebSocketClose 콜백에서 토큰 재발급 API를 호출한 뒤 자동 재연결하는 로직을 구현했다. 중복 재발급 방지를 위한 isReissuingRef 플래그를 추가하여 동시에 여러 번 토큰 재발급이 호출되는 것을 막았다.
client.onWebSocketClose = async () => {
if (!autoReconnectRef.current) return;
if (isReissuingRef.current) return;
autoReconnectRef.current = false;
try {
isReissuingRef.current = true;
const { accessToken: newAccessToken } = await reissue();
tokenRef.current = newAccessToken;
cleanup();
connect();
} catch (err) {
const authError: ErrorMessage = {
code: "AUTH_EXPIRED",
message: "로그인이 만료되었습니다. 다시 로그인해주세요.",
};
setError(authError);
onErrorMsg?.(authError);
} finally {
isReissuingRef.current = false;
}
};
결과
토큰 만료로 인한 연결 끊김 0건을 달성했고, 사용자가 인지하지 못하는 사이에 토큰 갱신 및 연결 복구가 자동으로 이루어지게 되었다.
문제 상황
채팅 리스트에서 위로 스크롤하여 이전 대화를 로드할 때, 새 데이터가 상단에 추가되면서 스크롤이 최상단으로 점프하는 현상이 발생했다. 사용자가 보던 메시지 위치를 잃어버려 UX가 크게 저해되었다.
원인 분석
일반적인 무한스크롤은 하단에 데이터를 추가하지만, 채팅은 상단에 이전 데이터를 추가한다. DOM에 새 요소가 상단에 추가되면 기존 scrollTop 값이 유지되어 상대적으로 스크롤 위치가 위로 밀려버리는 현상이 발생했다.
해결 방법
IntersectionObserver 기반 커스텀 훅에서 fetch 전후 scrollHeight 차이를 계산하여 스크롤 위치를 보정했다. requestAnimationFrame을 사용하여 DOM 업데이트 후 정확한 타이밍에 스크롤 위치를 조정했다.
const observer = new IntersectionObserver(
async (entries) => {
const [entry] = entries;
if (entry.isIntersecting && !isFetchingRef.current) {
isFetchingRef.current = true;
try {
const beforeState = {
scrollHeight: container.scrollHeight,
scrollTop: container.scrollTop,
};
await fetchNextPageRef.current();
requestAnimationFrame(() => {
const scrollDiff = container.scrollHeight - beforeState.scrollHeight;
container.scrollTop = beforeState.scrollTop + scrollDiff;
});
} finally {
requestAnimationFrame(() => {
isFetchingRef.current = false;
});
}
}
},
{ root: container, rootMargin: "100px 0px 0px 0px" }
);
결과
스크롤 점프 현상을 100% 해결했고, 이전 대화 로드 후에도 현재 보던 메시지 위치가 정확히 유지되어 자연스러운 역방향 무한스크롤 UX를 제공하게 되었다.
문제 상황
WebSocket으로 수신되는 일차별 일정 데이터와 REST API로 조회한 기존 데이터를 병합할 때 중복이 발생했다. dayNumber 순서도 보장되지 않아 탭 UI가 불안정하게 동작했다.
원인 분석
같은 dayNumber의 일정이 WebSocket 스트림과 API 응답 모두에 존재할 수 있었다. 단순 배열 concat으로는 중복 제거 및 최신 데이터 우선 적용이 불가능했고, spread 순서에 따라 오래된 데이터가 최신 데이터를 덮어쓰는 경우도 있었다.
해결 방법
Map 자료구조를 활용하여 dayNumber를 키로 슬롯 정규화를 구현했다. API 데이터를 먼저 넣고 WebSocket 데이터를 나중에 넣어서, 동일 키 존재 시 최신 스트림 데이터로 자동 덮어쓰기가 되도록 했다.
const dailyPlanList = Array.from(
[...dailyPlans, ...schedule]
.reduce((map, item) => map.set(item.dayNumber, item), new Map())
.values()
);
결과
최대 6일차 일정 데이터 중복 0건을 달성했고, 새 일정이 생성될 때 해당 일차 탭으로 자동 전환되어 사용자가 최신 일정을 즉시 확인할 수 있게 되었다.
문제 상황
AI가 일정을 생성하는 데 평균 10~15초가 소요되는데, 이 동안 화면에 아무런 피드백이 없었다. 사용자가 응답 없음으로 오인하고 페이지를 이탈하거나 새로고침하는 현상이 발생했다.
원인 분석
WebSocket 구독 후 실제 데이터가 도착하기까지 대기 시간이 존재하는데 이를 시각적으로 표현하지 않았다. 또한 일차별로 순차적으로 데이터가 도착하는데, 어떤 일차가 생성 중인지 사용자가 알 수 없었다.
해결 방법
최대 6일차까지의 스켈레톤 UI 컴포넌트를 구현하여 데이터가 도착하기 전 로딩 상태를 표시했다. WebSocket receipt 패턴으로 3개 채널(채팅/일정/에러) 구독 완료 시점을 감지하여 로딩 상태를 전환했다.
client.onConnect = () => {
let receiptCount = 0;
const onReceipt = () => {
receiptCount += 1;
if (receiptCount === 3) {
onReady?.();
}
};
const errSub = client.subscribe(paths.errors, handler, { receipt: "sub-errors" });
client.watchForReceipt("sub-errors", onReceipt);
const chatSub = client.subscribe(paths.chat, handler, { receipt: "sub-chat" });
client.watchForReceipt("sub-chat", onReceipt);
const schedSub = client.subscribe(paths.schedule, handler, { receipt: "sub-schedule" });
client.watchForReceipt("sub-schedule", onReceipt);
};
결과
일정 생성 과정에서 5단계 시각적 피드백(구독 중 → 1일차 생성 → 2일차 생성 → ... → 완료)을 제공하게 되어 사용자 대기 경험이 크게 개선되었다.