대학생 AI 챗봇 서비스 강냉봇에 "자연어 기반 시간표 자동 생성" 기능을 추가하면서, 기획 부재 상황에서 애자일하게 MVP를 구현하고, 프론트엔드 주도적인 개발 방식을 통해 난관을 극복한 과정을 공유합니다.
📑 바로가기
🎯 문제 정의: 수강신청은 왜 항상 힘들까?
대학생들에게 수강신청 기간은 전쟁입니다. 듣고 싶은 과목들은 많은데, 어떤 과목이 어떤 시간에 열리는지, 내 공강 날짜와 겹치지는 않는지 하나하나 따져봐야 합니다. 기존의 시간표 서비스들은 대부분 수동 검색 방식이었습니다.
"알고리즘 A반 넣어보고... 안되네? B반으로 바꿔볼까? 아 그럼 데이터베이스랑 겹치네..." 이런 반복적인 시행착오를 수십 번 겪어야 하나의 시간표가 완성됩니다.
핵심 질문: "알고리즘, DB, 컴네 듣고 싶은데 금요일 공강으로 짜줘" 처럼
자연어로 말하면 AI가 알아서 최적의 조합을 찾아줄 순 없을까?
이 단순한 질문에서 시작하여, 강냉봇의 핵심 기능인 AI 시간표 생성 시스템 프로젝트가 시작되었습니다.
🏗️ 시스템 아키텍처 및 설계
시간표 생성 시스템은 RAG (Retrieval-Augmented Generation) 기반의 백엔드와 고도화된 프론트엔드 상태 관리가 결합된 구조입니다.

컴포넌트 구조 설계
components/schedule/
├── ScheduleCanvas.tsx # 메인 컨테이너 (상태 관리 및 레이아웃)
├── ScheduleGrid.tsx # 시간표 시각화 (CSS Grid 기반)
├── ScheduleCarousel.tsx # 생성된 여러 시간표 슬라이더
├── FilterPanel.tsx # 공강 요일/제외 시간대 필터
└── SavedScheduleList.tsx # 저장된 시간표 목록
ScheduleCanvas가 중앙 관제탑 역할을 하며, useScheduleStore를 통해 전체 애플리케이션의 시간표 데이터 흐름을 제어하는 구조로 설계했습니다.

🔥 기술적 도전과제
1. 기획의 부재와 촉박한 일정
가장 큰 난관은 역설적이게도 기술이 아닌 프로세스였습니다. 상세 기획이 없는 상태에서 백엔드 API가 나오길 기다리다가는 마감 기한을 맞출 수 없었습니다. 기존의 폭포수(Waterfall) 방식으로는 불가능했습니다.
2. 다양한 사용자 환경 (주간 vs 야간)
대학에는 주간 수업(09:00~18:00)만 듣는 학생도 있고, 야간 수업(~22:00)을 듣는 학생도 있습니다. 고정된 시간표 그리드를 사용하면 주간 학생에겐 불필요한 공백이 생기고, 야간 학생에겐 수업이 잘리는 문제가 발생합니다.
3. 실시간 필터링 성능 이슈
"금요일 공강으로 보여줘", "1교시는 빼줘" 같은 필터링을 할 때마다 매번 서버에 요청을 보내면, 네트워크 지연으로 인해 사용자 경험이 크게 저하됩니다.
💡 해결 과정
해결책 1: API-First & Agile 개발 전략
기획이 나올 때까지 기다리는 대신, 가설 단위로 MVP 기능을 테스트하며 프론트엔드가 주도적으로 API 명세를 정의하고 개발을 시작했습니다.
🚀 Frontend-First 접근법
- API 명세서 선행 작성: 프론트엔드 관점에서 필요한 데이터 구조를 정의하여 SCHEDULE_API_SPEC.md 작성
- Mock 서비스 구현: 실제 API 대신 더미 데이터를 반환하는 서비스 레이어 구현
- UI/UX 선 검증: 동작하는 MVP를 하루 만에 구현하여 팀원들에게 시연 및 피드백 수렴
- 계약 기반 통합: 백엔드 개발이 완료되었을 때, 미리 정의된 명세(계약) 덕분에 충돌 없이 통합
이 전략 덕분에 백엔드 개발자와 병렬로 작업할 수 있었고, 개발 생산성을 극대화할 수 있었습니다.
해결책 2: 동적 시간표 그리드 (Dynamic Grid)
사용자의 수강 신청 내역에 따라 시간표의 높이가 자동으로 조절되도록 구현했습니다.


💻 동적 범위 계산 로직
// ScheduleGrid.tsx - 실제 구현 코드
const DEFAULT_START_HOUR = 9;
const DEFAULT_END_HOUR = 19; // 기본값 (주간 학생 기준)
const { startHour, endHour } = useMemo(() => {
let minStartHour = DEFAULT_START_HOUR;
let maxEndHour = DEFAULT_END_HOUR;
courses.forEach((course) => {
course.slots.forEach((slot) => {
// 분 단위 → 시간 단위 변환 (올림)
const slotEndHour = Math.ceil(timeToMinutes(slot.endTime) / 60);
// 수업이 19시 이후까지 있으면 그리드 확장
if (slotEndHour > maxEndHour) {
maxEndHour = Math.min(slotEndHour, 24); // 최대 24시
}
});
});
return { startHour: minStartHour, endHour: maxEndHour };
}, [courses]);
🎨 CSS Grid 활용
CSS Grid의 minmax()와 auto-fit을 활용하여, 시간대의 범위가 늘어나도 레이아웃이 깨지지 않고 각 교시(Time Slot)의 높이가 비율에 맞춰 자동으로 조절되도록 스타일링했습니다.
해결책 3: 클라이언트 사이드 필터링 (Client-Side Filtering)
필터링 시 발생하는 네트워크 비용을 0으로 만들었습니다. 이미 받아온 데이터(allSchedules)는 그대로 두고, 화면에 보여지는 데이터(generatedSchedules)만 로컬에서 가공합니다.
� 핵심 설계: Zustand 상태 관리
5개 컴포넌트가 동일한 시간표 데이터를 공유해야 했습니다. 단순히 useState로 관리하면 props drilling이 깊어지고, 필터링 시 원본 데이터를 잃어버릴 수 있었습니다.
- Props Drilling 제거: 어떤 컴포넌트든 store에서 직접 데이터 접근
- 원본/필터 분리:
allSchedules(원본, 불변) ↔generatedSchedules(필터 결과) - 일관성 유지: 저장/불러오기, 뷰 전환 등 모든 상태가 한 곳에서 관리

💻 실제 구현 코드
// useScheduleStore.ts - 실제 구현 코드
// 필터 적용 함수 (순수 함수)
const filterSchedules = (schedules: Schedule[], filters: ScheduleFilters) => {
return schedules.filter((schedule) => {
// 1. 공강 요일 체크 - 원하는 요일에 수업이 없어야 함
const emptyDayPass = filters.emptyDays.every((day) =>
schedule.emptyDays.includes(day)
);
if (!emptyDayPass) return false;
// 2. 제외 시간대 체크 - 특정 시간에 수업이 없어야 함
for (const course of schedule.courses) {
for (const slot of course.slots) {
for (const ex of filters.excludeTimeRanges) {
if (slot.day === ex.day && timeRangesOverlap(...)) {
return false;
}
}
}
}
return true;
});
};
// setFilters 액션 - 필터 변경 시 즉시 재계산
setFilters: (newFilters) => {
set((state) => ({
filters: { ...state.filters, ...newFilters },
generatedSchedules: filterSchedules(state.allSchedules, updatedFilters),
}));
}
이 결과, 사용자가 필터를 클릭하는 순간 0ms의 지연 시간으로 즉시 시간표가 변경되는 쾌적한 경험을 제공할 수 있었습니다.
보너스: 이미지 저장 최적화
단순히 화면을 캡처하는 것이 아니라, html-to-image를 활용하여 사용자 친화적인 저장 기능을 구현했습니다.
- 고해상도 지원:
pixelRatio: 2설정으로 아이패드/맥북 등 Retina 디스플레이에서도 선명하게 저장 - 다크모드 감지: 현재 테마를 감지하여 배경색을 자동으로 흰색(#fff) 또는 남색(#0f172a)으로 설정
- 로딩 피드백: 이미지 생성 중 다운로드 버튼에 스피너를 표시하여 "멈춤"이 아닌 "작업 중"임을 인지시킴
📈 개선 결과
| 항목 | 기존 방식 | 개선된 방식 | 효과 |
|---|---|---|---|
| 조합 생성 시간 | 수작업 30분+ | 자연어 1문장 | 시간 단축 99% |
| 필터 반응 속도 | API 호출 (수백ms) | 즉시 (0ms) | UX 대폭 향상 |
| 개발 생산성 | Waterfall | Agile + API-First | 2주 병렬 개발 |
배운 점
- 프론트엔드의 주도성: 기획이나 백엔드가 주어지길 기다리기보다, API 명세라는 "계약"을 먼저 제시함으로써 프로젝트 전체의 속도를 높일 수 있음을 배웠습니다.
- 사용자 중심의 최적화: 기술적으로 더 쉬운 방법(고정 그리드)보다 사용자에게 더 좋은 방법(동적 그리드)을 택했을 때의 만족도가 훨씬 큼을 확인했습니다.
- 클라이언트의 힘: 모든 것을 서버에 의존할 필요는 없습니다. 적절한 로컬 필터링은 서버 부하도 줄이고 사용자 경험도 개선하는 일석이조의 효과가 있습니다.
🛠 기술 스택 요약
| 영역 | 기술 | 활용 내용 |
|---|---|---|
| 상태 관리 | Zustand | 복잡한 시간표 데이터 및 필터 상태 관리 |
| 이미지 처리 | html-to-image | DOM 요소를 고해상도 PNG로 변환 |
| 스타일링 | Tailwind CSS | 반응형 그리드 및 다크모드 구현 |
| 다국어 | i18next | 4개 국어(한/영/중/일) 완벽 지원 |
시간표 짜는 스트레스에서 해방된 대학생들을 꿈꾸며.
📂 프로젝트 저장소: KangNaengBot-FE
🌐 배포 URL: KangNaengBot
◆ 함께 보면 좋은 글
'웹 프로그래밍 > 프론트엔드' 카테고리의 다른 글
| [React] AI 챗봇의 체감 속도를 높인 프론트엔드 최적화 여정 (0) | 2025.12.25 |
|---|---|
| [React] map() 함수를 사용한 리스트 렌더링 : key 속성으로 반복되는 컴포넌트를 만들어 보자 (0) | 2024.05.18 |
| [HTML/CSS/JS] React를 통해 useState()를 사용해 보자 : 상태를 업데이트 해보자 (0) | 2023.08.13 |
| [HTML/CSS] :root를 통해 CSS에서 변수를 사용해 보자 : 가상 클래스의 활용 (0) | 2023.06.25 |
| [HTML/CSS] cubic-bezier()를 통해 속성 변화를 조절해 보자 : 변화에 변화를 주자 (0) | 2023.06.23 |
댓글