Histour 개발기
캡스톤디자인 프로젝트 Histour 개발 회고
담당 : FrontEnd
사용 기술 : Next.js, React, TypeScript, Tailwind CSS
들어가며
2026년 1학기 캡스톤디자인 프로젝트에서 우리 팀은 외국인 관광객을 위한 AI 기반 역사 관광 웹 플랫폼 Histour를 개발했다.
프로젝트 GitHub: https://github.com/greenfield-2026-capstone/greenfield-project
배포 링크: https://histour.vercel.app
Histour는 사용자가 역사 관광지를 탐색하고, 해당 장소와 연결된 역사 인물과 대화하며, 선택지에 따라 달라지는 스토리와 결말 영상을 경험할 수 있는 서비스다. 나는 이번 프로젝트에서 프론트엔드 개발을 담당했다. Next.js App Router 기반으로 관광지 탐색 화면, 장소 상세 페이지, AI 역사 인물 대화 화면, 선택형 스토리 UI, 엔딩 화면을 구현했다. 이 글에서는 Histour를 개발하면서 프론트엔드 구조를 어떻게 설계했는지, AI 대화와 선택형 스토리를 화면에서 어떻게 연결했는지, 그리고 프로젝트를 통해 무엇을 배웠는지 정리해보려고 한다.
Histour는 어떤 서비스인가
처음 문제의식은 “외국인 관광객이 한국의 역사 관광지를 얼마나 깊이 있게 이해할 수 있을까?”에서 시작했다.
한국에는 경복궁, 창덕궁, 덕수궁처럼 역사적으로 의미 있는 장소가 많다. 하지만 실제 관광 경험은 안내판을 읽거나 검색을 통해 정보를 찾아보는 방식에 머무르는 경우가 많다. 사전 지식이 부족한 외국인 관광객에게 연도, 인물, 사건 중심의 설명은 다소 어렵게 느껴질 수 있다.
예를 들어 경복궁이 조선의 대표 궁궐이라는 사실은 쉽게 알 수 있다. 하지만 조선이 왜 세워졌는지, 태조 이성계가 어떤 고민을 했는지, 당시 고려 말의 상황이 어땠는지까지 자연스럽게 이해하기는 쉽지 않다.
그래서 우리는 역사 관광을 단순히 “정보를 읽는 경험”이 아니라 “이야기 속에 참여하는 경험”으로 바꾸고 싶었다. 사용자가 관광지를 선택하면 역사 인물이 등장하고, 그 인물과 대화하며, 중요한 순간마다 선택지를 고르는 방식이다. 마지막에는 사용자의 선택 결과에 맞는 결말 영상이 제공된다.

사용자 흐름 설계
프로젝트를 기획하면서 가장 먼저 정리한 것은 사용자 흐름이었다. 아무리 AI 기능이 흥미롭더라도 사용자가 어디서 무엇을 해야 하는지 헷갈리면 좋은 서비스가 될 수 없다고 생각했다.
Histour의 기본 흐름은 다음과 같다.
- 사용자가 역사 관광지를 탐색한다.
- 장소 상세 페이지에서 관련 역사 인물을 확인한다.
- 역사 인물과 AI 대화를 시작한다.
- 중요한 순간마다 선택지를 고른다.
- 선택 결과에 따라 서로 다른 결말로 분기된다.
- 최종 결말에 맞는 영상 콘텐츠를 확인한다.
이 흐름은 프로젝트가 끝날 때까지 중요한 기준이 되었다. 기능을 추가할 때마다 “이 기능이 사용자의 다음 행동을 자연스럽게 이어주는가?”를 먼저 고민했다. 검색, 필터, 대화, 선택지, 영상은 각각 분리된 기능처럼 보이지만, 실제로는 하나의 역사 체험을 만들기 위한 단계라고 생각했다.
프론트엔드가 맡아야 했던 역할
Histour에서 프론트엔드는 단순히 데이터를 보여주는 역할만 하지 않았다. 사용자가 관광지를 발견하고, 장소의 의미를 이해하고, 역사 인물과 대화하며, 선택에 따라 결말까지 도달하는 전체 경험을 화면으로 연결해야 했다.
서비스의 핵심 흐름은 다음과 같다.
관광지 탐색
→ 장소 상세 확인
→ 역사 인물 선택
→ AI 대화
→ 선택지 기반 스토리 진행
→ 엔딩 영상 확인
이 흐름에서 프론트엔드가 담당한 역할은 크게 세 가지였다.
1. 사용자가 다음 행동을 쉽게 이해할 수 있도록 화면을 구성하기
2. 장소, 인물, 대화, 선택지, 엔딩 데이터를 자연스럽게 연결하기
3. AI 응답을 안정적인 UI 상태로 변환해 보여주기
처음에는 “화면을 잘 만들면 된다”고 생각했지만, 개발을 진행하면서 프론트엔드는 사용자의 흐름을 설계하는 역할에 더 가깝다는 것을 느꼈다.
App Router로 사용자 흐름 구성하기
Histour는 단일 페이지로 끝나는 서비스가 아니다. 사용자는 메인 화면에서 관광지를 탐색하고, 장소 상세 페이지로 이동한 뒤, 역사 인물 대화 페이지와 엔딩 페이지까지 이어지는 흐름을 경험한다. 그래서 Next.js의 App Router 구조가 잘 맞는다고 판단했다.
프로젝트의 주요 페이지 구조는 다음과 같다.
FE/app
├── page.tsx
├── places/[placeId]/page.tsx
├── story/[placeId]/[characterId]/page.tsx
└── ending/[placeId]/[characterId]/page.tsx
URL 구조도 사용자 흐름과 맞게 설계했다.
/places/gyeongbokgung
/story/gyeongbokgung/taejo
/ending/gyeongbokgung/taejo
예를 들어 경복궁과 창덕궁은 화면 구조는 같지만 보여주는 데이터가 다르다. 이때 각각 별도의 페이지를 만들면 장소가 추가될 때마다 페이지도 늘어난다. 대신 placeId를 기준으로 데이터를 가져오도록 하면 하나의 상세 페이지 구조를 여러 장소에 재사용할 수 있다.
스토리 페이지도 마찬가지다. placeId와 characterId를 URL에 포함하면 어떤 장소의 어떤 인물과 대화하는지 명확하게 표현할 수 있다. 이 구조 덕분에 새로운 장소나 인물이 추가되더라도 페이지를 새로 만드는 것이 아니라 데이터만 추가하면 확장할 수 있었다.


문화 빅데이터
Histour는 문화빅데이터 플랫폼에서 제공하는 문화·관광 관련 원천 데이터를 기반으로 장소 정보를 구성했다. 원천 데이터에는 관광지, 지역 문화 이야기, 역사 인물, 옛길, 추천 관광지 등 여러 종류의 정보가 포함되어 있었다. 하지만 원천 CSV 데이터를 그대로 프론트엔드 화면에서 사용하기는 어려웠다. 데이터의 기준이 서비스 화면과 다르고, 사용자에게 보여줘야 하는 정보가 여러 파일에 흩어져 있었기 때문이다.
예를 들어 장소 카드에는 장소명, 이미지, 요약, 태그, 관련 인물, 추천 포인트가 필요하고, 상세 페이지에는 장소 설명, 체험 정보, 역사 인물 정보, 엔딩 영상 정보까지 필요했다. 그래서 원천 데이터를 서비스 흐름에 맞게 장소 중심 JSON 데이터로 재구성했다. 프론트엔드에서는 이 정제 데이터를 기반으로 메인 카드, 상세 페이지, 인물 카드, 엔딩 페이지를 렌더링했다.


정제된 데이터는 프론트엔드 타입과 연결했다.
export interface Place {
id: string;
name: string;
location: string;
district: string;
airportCodes: AirportCode[];
era: string;
summary: string;
storyIntro: string;
imageUrl: string;
tags: string[];
recommendationItems: string[];
experiences: ExperienceItem[];
characters: Character[];
endingVideo: EndingVideo;
}
이렇게 데이터 구조를 먼저 정리해두니 화면 구현이 훨씬 쉬워졌다. PlaceCard는 장소 카드에 필요한 정보만 사용하고, PlaceHero는 상세 페이지 상단 정보를 사용하며, CharacterCard는 characters 배열을 기반으로 렌더링할 수 있었다.
문화관광데이터를 단순히 가져오는 것보다 중요했던 것은 프론트엔드 화면에서 바로 사용할 수 있는 형태로 재구성하는 일이었다. 이번 프로젝트를 통해 데이터 구조가 UI 구조에 큰 영향을 준다는 것을 느꼈다.
컴포넌트 분리
프로젝트 초반에는 빠르게 화면을 만드는 데 집중했다. 하지만 메인 화면, 장소 상세 화면, 스토리 화면이 생기면서 같은 UI가 반복되기 시작했다. 예를 들어 관광지 카드는 여러 위치에서 사용되고, 역사 인물 카드는 장소 상세 페이지에서 반복된다. 선택지 버튼도 대화 흐름 안에서 계속 재사용된다. 그래서 기능과 역할을 기준으로 컴포넌트를 분리했다.
PlaceCard: 관광지 카드
PlaceHero: 장소 상세 상단 영역
ExperienceSection: 장소 관련 체험 정보
CharacterCard: 역사 인물 카드
StoryClient: 스토리 화면의 상태 관리
ChatMessenger: AI 대화 UI
StoryChoiceButton: 선택지 버튼
EndingVideoCard: 엔딩 영상 카드
이렇게 분리한 뒤 가장 크게 좋아진 점은 수정 범위가 명확해졌다는 것이다. 예를 들어 선택지 버튼의 스타일을 수정하고 싶으면 StoryChoiceButton만 보면 되고, 관광지 카드의 표시 정보를 수정하고 싶으면 PlaceCard만 보면 된다.
프론트엔드 개발에서 컴포넌트 분리는 단순히 파일을 나누는 작업이 아니라, 화면의 책임을 나누는 작업이라는 것을 체감했다.
스토리 화면
가장 복잡했던 화면은 역사 인물 대화 화면이었다. 단순히 메시지를 입력하고 응답을 보여주는 채팅 UI가 아니라, 다음과 같은 상태를 함께 관리해야 했다.
messages: 현재까지의 대화 목록
input: 사용자가 입력 중인 메시지
choices: 현재 표시할 선택지
progress: 스토리 진행 단계
nationScore: 나라와 백성 중심 선택 점수
emotionScore: 감정과 가족 중심 선택 점수
isLoading: AI 응답 생성 중 여부
ending: 최종 엔딩 여부
conversationCount: 선택지 등장 전 대화 횟수
실제 ChatMessenger에서는 여러 상태를 useState로 관리했다.
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: "assistant",
text: getOpeningLine(character, language),
},
]);
const [input, setInput] = useState("");
const [choices, setChoices] = useState<TaejoChoice[]>([]);
const [selectedChoiceText, setSelectedChoiceText] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [ending, setEnding] = useState<"great" | "lonely" | null>(null);
const [conversationCount, setConversationCount] = useState(0);
처음에는 상태가 많아서 복잡하게 느껴졌지만, 각각의 상태가 화면에서 어떤 역할을 하는지 분명히 하면서 구조를 잡았다.
- messages : 채팅 말풍선을 렌더링
- choices : 선택지 버튼을 렌더링
- isLoading : 응답 생성 중 UI
- progress, nationScore, emotionScore : 스토리 진행과 결말 분기
- ending : 더 이상 입력을 받지 않도록 제어
현재 프로젝트 규모에서는 useState로 충분히 관리할 수 있었지만 장소와 인물 스토리가 더 늘어난다면 전역 상태 관리 도입도 필요하겠다고 느꼈다.
AI 응답을 UI 데이터로 다루기
프론트엔드 입장에서 AI 응답은 단순한 문자열이면 다루기 어렵다. 어떤 응답은 일반 대화이고, 어떤 응답은 선택지를 포함해야 한다. 그래서 AI API는 reply와 choices 형태의 JSON을 반환하도록 맞췄다.
프론트엔드 타입은 다음과 같다.
export type TaejoChoice = {
text: string;
type: "nation" | "emotion";
};
export type ChatMessage = {
role: "user" | "assistant" | "system";
text: string;
};
API 호출 함수는 사용자 입력뿐 아니라 현재 스토리 상태도 함께 전달한다.
export async function askTaejo(
message: string,
progress: number,
history: ChatMessage[],
nationScore: number,
emotionScore: number,
language = "ko"
): Promise<{
reply: string;
choices: TaejoChoice[];
}> {
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const response = await fetch(`${API_BASE_URL}/api/chat/taejo`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
progress,
history,
nationScore,
emotionScore,
language,
}),
});
if (!response.ok) {
throw new Error("태조 응답 생성 실패");
}
return response.json();
}
여기서 중요한 점은 프론트엔드가 AI 응답의 의미를 문자열로 해석하지 않는다는 것이다.
reply는 말풍선으로 렌더링하고, choices는 선택지 버튼으로 렌더링한다. 이 구조 덕분에 AI 기능을 화면에서 안정적으로 다룰 수 있었다.
setMessages((prev) => [
...prev,
{
role: "assistant",
text: result.reply,
},
]);
setChoices(result.choices ?? []);
AI 응답을 문장이 아니라 UI 상태로 변환 가능한 데이터로 보는 것이 중요했다.


선택 결과를 엔딩으로 연결
선택지를 클릭하면 선택 유형에 따라 점수가 누적된다. 태조 이성계 스토리에서는 선택지를 크게 두 방향으로 나누었다.
nation: 나라와 백성, 책임, 대의를 우선하는 선택
emotion: 가족, 감정, 개인적 마음을 우선하는 선택
선택지를 누르면 다음 점수를 계산한다.
const nextProgress = progress + 1;
const nextNationScore =
choice.type === "nation" ? nationScore + 1 : nationScore;
const nextEmotionScore =
choice.type === "emotion" ? emotionScore + 1 : emotionScore;
그리고 마지막 단계에 도달하면 점수를 비교해 엔딩을 결정한다.
const nextEnding =
nextNationScore >= nextEmotionScore ? "great" : "lonely";
nationScore가 더 크거나 같으면 Great Founder, emotionScore가 더 크면 Lonely Father 엔딩으로 이어진다.
엔딩이 결정되면 엔딩 페이지로 이동한다.
router.push(
`/ending/${place.id}/${character.id}?result=${
nextEnding === "great" ? "good" : "bad"
}`
);
이 부분을 구현하면서 단순한 채팅 UI와 선택형 스토리 UI는 상태 관리 방식이 다르다는 것을 느꼈다. 채팅은 메시지 목록만 잘 쌓으면 되지만, 선택형 스토리는 사용자의 선택이 이후 화면과 결과에 영향을 주기 때문에 점수와 진행률을 함께 관리해야 했다.


UI/UX 구성
프론트엔드를 개발하면서 디자인도 여러 번 수정했다. 초기 버전은 카드도 많고 정보도 많았다. 개발자인 입장에서는 모든 정보를 보여주고 싶었지만 실제로 화면을 사용해보니 어디를 눌러야 하는지 오히려 헷갈렸다.
그래서 디자인 방향을 다시 잡았다. 사용자가 지금 해야 할 행동이 무엇인지 가장 먼저 보이도록 만드는 것이 목표였다.
각 화면에서 가장 중요한 행동은 다음과 같았다.
메인 화면: 관광지를 찾는 것
상세 페이지: 역사 인물을 선택하는 것
대화 화면: AI와 대화하거나 선택지를 고르는 것
엔딩 페이지: 결과를 확인하는 것
특히 대화 화면에서는 채팅 메시지와 선택지가 섞여 보이지 않도록 신경 썼다. 일반 대화는 말풍선 형태로 보여주고 선택지는 버튼 형태로 분리했다. 선택지는 클릭 가능한 요소라는 것을 바로 인식할 수 있도록 충분한 여백과 대비를 주었다.
AI 서비스에서 UI는 단순히 예쁜 화면이 아니라 사용자가 AI와 어떻게 상호작용할지 안내하는 장치라고 느꼈다. AI 응답이 아무리 좋아도 사용자가 다음 행동을 알 수 없다면 좋은 경험이 되기 어렵다.
트러블슈팅
개발 중 가장 자주 마주친 문제는 AI 응답의 불확실성이었다. AI는 같은 요청에도 조금씩 다른 형태의 응답을 줄 수 있다. 프론트엔드 입장에서는 이 불확실성이 UI 오류로 이어질 수 있다.
예를 들어 선택지가 있어야 하는데 빈 배열이 오거나, 선택지가 필요 없는 순간에 선택지가 오는 경우가 있었다. 또 응답 생성에 실패하면 사용자가 입력한 메시지만 남고 아무 반응이 없는 것처럼 보일 수도 있었다.
그래서 프론트엔드에서는 실패 상황을 반드시 처리했다.
try {
const result = await askTaejo(
userMessage,
progress,
nextMessages,
nationScore,
emotionScore,
language
);
setMessages((prev) => [
...prev,
{
role: "assistant",
text: result.reply,
},
]);
} catch {
setMessages((prev) => [
...prev,
{
role: "assistant",
text: t.error,
},
]);
} finally {
setIsLoading(false);
}
이 코드는 단순해 보이지만 사용자 경험에는 중요했다. AI 응답이 실패해도 사용자는 최소한 지금은 답하기 어렵다는 메시지를 받는다. 아무 반응이 없는 화면보다 훨씬 낫다. 또한 로딩 상태를 명확히 보여주었다.
<span className="status-chip">
{isLoading ? t.loading : t.available}
</span>
AI 기능을 사용하는 서비스에서는 실패와 지연이 자연스럽게 발생할 수 있다. 그래서 프론트엔드는 성공 케이스만 보는 것이 아니라 응답이 늦거나 실패했을 때도 사용자가 상황을 이해할 수 있도록 만들어야 한다는 것을 배웠다.
협업 프로젝트
이번 프로젝트는 개인 프로젝트가 아니라 팀 프로젝트였다. 그래서 좋은 코드를 작성하는 것만큼 중요한 것이 있었다. 바로 다른 팀원이 이해할 수 있는 구조를 만드는 것이었다.
초반에는 빠르게 개발하는 것이 중요하다고 생각해서 하나의 파일 안에서 여러 기능을 구현하는 경우도 있었다. 하지만 프로젝트가 커질수록 유지보수가 어려워졌다. 같은 컴포넌트를 여러 곳에서 수정해야 했고, 어느 부분을 변경하면 다른 화면까지 영향을 받는 경우도 생겼다.
이후에는 컴포넌트의 역할을 최대한 분리하고, 데이터 구조도 명확하게 정의하기 위해 노력했다. 또한 API 요청 방식이나 데이터 형태를 팀원들과 함께 맞추면서 프론트엔드와 백엔드가 동일한 기준으로 개발할 수 있도록 했다.
특히 AI 응답을 reply와 choices 구조로 정리한 것이 협업 과정에서 도움이 되었다. 프론트엔드는 어떤 데이터를 받아야 하는지 명확하게 알 수 있었고 백엔드도 어떤 형식으로 응답해야 하는지 기준이 생겼기 때문이다. 프로젝트를 진행하면서 협업에서는 화려한 기술보다도 일관된 규칙이 훨씬 중요하다는 것을 느꼈다.
앞으로 개선해보고 싶은 점
프로젝트를 더 발전시킨다면 먼저 상태 관리 구조를 개선하고 싶다. Zustand 같은 가벼운 전역 상태 관리 라이브러리를 도입하면 언어 설정, 사용자 정보, 스토리 진행 상태를 더 깔끔하게 관리할 수 있을 것 같다.
두 번째는 Streaming UI다. 현재는 AI 응답이 모두 생성된 뒤 한 번에 화면에 출력된다. 하지만 실제 채팅 서비스처럼 답변이 생성되는 과정을 실시간으로 보여주면 훨씬 자연스러운 대화 경험을 만들 수 있을 것 같다.
세 번째는 선택지와 엔딩 화면의 사용성 개선이다. 현재도 선택지를 버튼 형태로 제공하지만 사용자가 자신의 선택이 어떤 방향으로 누적되고 있는지 더 잘 이해할 수 있도록 진행 상태를 시각적으로 보여주는 방법을 고민해보고 싶다.
추가적으로는 장소와 인물이 늘어났을 때도 같은 UI 구조를 유지할 수 있도록 데이터 구조와 컴포넌트 구조를 더 정리하고 싶다. 이번 프로젝트에서는 태조 이성계 스토리를 중심으로 구현했지만 앞으로 다양한 역사 인물과 장소를 추가한다면 확장 가능한 구조가 더 중요해질 것이다.
마무리
Histour를 개발하면서 가장 크게 배운 것은 프론트엔드가 단순히 화면을 구현하는 영역이 아니라는 점이다. 특히 AI 기능이 포함된 서비스에서는 사용자가 어떤 흐름으로 AI와 상호작용하는지, 어떤 데이터를 언제 보여줄지, 어떤 순간에 선택지를 제시할지가 서비스 경험을 크게 좌우한다. 이번 프로젝트에서는 Next.js App Router를 활용해 사용자 흐름에 맞는 페이지 구조를 만들고, 컴포넌트 분리를 통해 화면 복잡도를 낮추고, AI 응답을 UI 상태로 변환해 안정적으로 렌더링하려고 했다. 또한 선택 점수와 엔딩 분기를 프론트엔드 상태로 관리하면서 AI 대화와 선택형 스토리를 하나의 경험으로 연결했다.
물론 아쉬운 점도 많았다. 상태 관리 구조를 더 정교하게 만들고 싶었고 AI 응답 실패나 지연 상황에 대한 UI도 더 세밀하게 다듬고 싶었다. 하지만 이번 프로젝트를 통해 AI API를 연결하는 것과 AI 기반 경험을 화면으로 설계하는 것은 다르다는 점을 배울 수 있었다.
앞으로도 AI와 웹 프론트엔드를 함께 활용하는 프로젝트를 경험하면서 단순히 기능을 구현하는 개발자가 아니라 사용자의 흐름과 경험까지 설계하는 개발자로 성장하고 싶다.
Histour Next Prototype
덕수궁 처음 방문해도 만족도가 높은 편 인천 · 김포공항AI 인물 31개 포인트
histour.vercel.app
GitHub - greenfield-2026-capstone/greenfield-project: Repository for Capstone Project
Repository for Capstone Project. Contribute to greenfield-2026-capstone/greenfield-project development by creating an account on GitHub.
github.com