캘린더 풀스택 개발: FastAPI + Next.js로 반복 일정까지 구현하기

Table of Contents
- 기술 스택
- 백엔드
- 프론트엔드
- 풀스택 아키텍처
- Part 1: 백엔드 구현
- Protocol 패턴으로 테스트 용이성 확보
- protocol.py
- 반복 일정 핵심: RRULE
- 반복 일정 확장
- Part 2: 프론트엔드 구현
- 프로젝트 구조
- API 클라이언트: Bearer Token 자동 주입
- 멀티 뷰 캘린더
- 반복 일정 UI
- 반응형 디자인
- Part 3: 트러블 슈팅
- 트러블 슈팅 1: 타임존
- 트러블 슈팅 2: API 응답 형식 불일치
- 트러블 슈팅 3: ESLint react-hooks 에러
- Part 4: 테스트 전략
- 백엔드 테스트 피라미드(트로피 방식이 되었다)
- 반복 로직 Unit 테스트
- 프론트엔드 E2E 테스트 (Playwright)
- Part 5: 배포
- Nginx 설정 및 SSL 발급
- syntax is ok 메시지 확인
- 결과물
- 지원하는 기능
- 지원하는 반복 패턴
- 마치며
- 배운 점
- 시리즈 안내
이 글은 Claude Code + Superpowers로 캘린더 만들기의 후속편입니다. AI를 이용한 개발 워크플로우가 궁금하다면 이전 글을 먼저 읽어보세요.
이 글에서는 FastAPI 백엔드와 Next.js 프론트엔드로 가족 캘린더를 구축한 전체 과정을 공유한다.
기술 스택
백엔드
Python 3.13 + FastAPI 0.115
PostgreSQL + SQLAlchemy 2.0
Firebase Authentication
python-dateutil (반복 일정)
pytest (TDD)
프론트엔드
Next.js 16.1.1 + React 19
TypeScript 5 (Strict Mode)
Tailwind CSS 4
shadcn/ui + Radix UI
Firebase SDK 12.7
date-fns 4.1 (한국어 로케일)
인증은 Firebase로 통일했다. 프론트엔드에서 Firebase로 로그인하면 ID Token을 발급받고, 백엔드는 이 토큰을 검증하여 사용자를 식별한다.
풀스택 아키텍처
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Next.js │ │ shadcn/ui │ │ Firebase Auth │ │
│ │ App Router │ │ Components │ │ (로그인 + ID Token) │ │
│ └──────┬──────┘ └──────────────┘ └───────────┬────────────┘ │
│ │ │ │
│ │ API Client (Bearer Token) │ │
│ └───────────────────┬────────────────────┘ │
└─────────────────────────────┼───────────────────────────────────┘
│ HTTPS
┌─────────────────────────────┼───────────────────────────────────┐
│ Backend │
│ │ │
│ ┌───────────────────────────┴────────────────────────────────┐ │
│ │ FastAPI │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
│ │ │ Auth │ │ Members │ │Categories│ │ Events │ │ │
│ │ │ Router │ │ Router │ │ Router │ │ Router │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │ │
│ │ │ │ │ │ │ │
│ │ └─────────────┴──────┬──────┴──────────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────────────┴─────────────────────────────┐ │ │
│ │ │ Service Layer (Protocol 기반) │ │ │
│ │ │ MemberService │ CategoryService │ EventService │ │ │
│ │ └─────────────────────────┬─────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────────────┴─────────────────────────────┐ │ │
│ │ │ PostgreSQL + SQLAlchemy ORM │ │ │
│ │ │ FamilyMember │ Category │ Event │ RecurrenceException││ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Part 1: 백엔드 구현
Protocol 패턴으로 테스트 용이성 확보
Python의 Protocol을 사용해 인터페이스를 정의하고, 실제 서비스와 Fake 구현체를 분리했다.
# protocol.py
class MemberServiceProtocol(Protocol):
def get_all(self) -> list[FamilyMemberResponse]: ...
def create(self, data: FamilyMemberCreate) -> FamilyMemberResponse: ...
def verify_and_link(self, email: str, firebase_uid: str) -> FamilyMemberResponse: ...이 패턴 덕분에 PostgreSQL 없이도 92개의 테스트를 0.12초 만에 실행할 수 있다.
반복 일정 핵심: RRULE
RRULE(Recurrence Rule)은 iCalendar 표준(RFC 5545)에서 정의한 반복 규칙 형식이다.
FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR
이 규칙은 "매주 월, 수, 금 반복"을 의미한다. 사용자 친화적인 API를 위해 패턴 객체 방식도 지원한다.
{
"title": "학원",
"start_time": "2024-01-15T16:00:00",
"end_time": "2024-01-15T18:00:00",
"recurrence_pattern": {
"frequency": "WEEKLY",
"weekdays": ["MO", "WE", "FR"]
},
"recurrence_end": "2024-12-31"
}반복 일정 확장
일정 조회 시, 반복 일정은 해당 기간 내 발생하는 모든 날짜로 확장된다.
def get_by_date_range(self, start_date: date, end_date: date):
results = []
# 1. 일반 일정 조회
non_recurring = self.db.query(Event).filter(
Event.recurrence_rule.is_(None),
Event.start_time.between(start_date, end_date),
).all()
# 2. 반복 일정 확장
recurring = self.db.query(Event).filter(
Event.recurrence_rule.isnot(None),
Event.start_time <= end_date,
).all()
for event in recurring:
occurrences = get_occurrences(
rrule_str=event.recurrence_rule,
dtstart=event.start_time,
range_start=start_date,
range_end=end_date,
)
for occurrence_date in occurrences:
results.append(self._event_to_response(event, occurrence_date))
return resultsPart 2: 프론트엔드 구현
프로젝트 구조
src/
├── app/
│ ├── page.tsx # 메인 캘린더 (인증 필요)
│ └── login/page.tsx # 로그인 페이지
├── components/
│ ├── calendar/ # 캘린더 컴포넌트
│ │ ├── CalendarHeader.tsx # 뷰 전환 + 네비게이션
│ │ ├── MonthView.tsx # 월간 그리드
│ │ ├── WeekView.tsx # 주간 타임라인
│ │ ├── DayView.tsx # 일간 상세
│ │ ├── EventDialog.tsx # 일정 생성/수정
│ │ └── CategoryDialog.tsx # 카테고리 관리
│ └── ui/ # shadcn/ui 컴포넌트
├── lib/
│ ├── api.ts # API 클라이언트
│ ├── auth.ts # Firebase 인증 함수
│ └── firebase.ts # Firebase 초기화
└── hooks/
└── useAuth.ts # 인증 상태 관리 훅
API 클라이언트: Bearer Token 자동 주입
모든 API 요청에 Firebase ID Token을 자동으로 추가한다.
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await getIdToken();
const headers = {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
};
const response = await fetch(`${API_URL}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}멀티 뷰 캘린더
세 가지 뷰를 제공한다.
| 뷰 | 용도 | 특징 |
|---|---|---|
| 월간 | 전체 일정 파악 | 반응형 이벤트 표시 (1~3개) |
| 주간 | 시간대별 확인 | 24시간 타임라인 |
| 일간 | 상세 일정 관리 | 시간 슬롯 클릭 생성 |
{view === 'month' && <MonthView currentDate={currentDate} events={events} />}
{view === 'week' && <WeekView currentDate={currentDate} events={events} />}
{view === 'day' && <DayView currentDate={currentDate} events={events} />}반복 일정 UI
type Frequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
type Weekday = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
interface RecurrencePattern {
frequency: Frequency;
interval?: number;
weekdays?: Weekday[];
}주간 반복 선택 시 요일 버튼이 나타난다.
{frequency === 'WEEKLY' && (
<div className="flex flex-wrap gap-1">
{WEEKDAY_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant={weekdays.includes(opt.value) ? 'default' : 'outline'}
size="sm"
className="w-9 h-9 p-0"
onClick={() => toggleWeekday(opt.value)}
>
{opt.label}
</Button>
))}
</div>
)}반응형 디자인
같은 데이터를 화면 크기에 따라 다르게 렌더링한다.
// 화면 크기별 표시 개수
const mobileCount = 1;
const tabletCount = 2;
const desktopCount = 3;
// 모바일: 1개만 표시
<div className="md:hidden">
{dayEvents.slice(0, mobileCount).map(renderEvent)}
{dayEvents.length > mobileCount && (
<p className="text-xs">+{dayEvents.length - mobileCount}</p>
)}
</div>
// 태블릿: 2개
<div className="hidden md:block lg:hidden">
{dayEvents.slice(0, tabletCount).map(renderEvent)}
</div>
// 데스크톱: 3개
<div className="hidden lg:block">
{dayEvents.slice(0, desktopCount).map(renderEvent)}
</div>Part 3: 트러블 슈팅
트러블 슈팅 1: 타임존
증상: 1월 3일 일정이 1월 4일에 표시됨
원인: toISOString()이 UTC로 변환하면서 날짜가 밀림
// 문제 코드
const dateStr = date.toISOString().split('T')[0];
// 1월 3일 00:00 KST → "2026-01-02" (UTC 기준)해결: 로컬 Date 메서드 사용
// 해결 코드
const getEventsForDate = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
return events.filter((event) => {
// 반복 일정은 occurrence_date 사용
if (event.is_recurring && event.occurrence_date) {
const [y, m, d] = event.occurrence_date.split('-').map(Number);
return y === year && m - 1 === month && d === day;
}
// 일반 일정은 start_time 파싱
const eventDate = new Date(event.start_time);
return (
eventDate.getFullYear() === year &&
eventDate.getMonth() === month &&
eventDate.getDate() === day
);
});
};트러블 슈팅 2: API 응답 형식 불일치
증상: t.filter is not a function 에러
원인: 백엔드가 { events: [...] } 객체를 반환하는데, 프론트엔드는 배열을 기대
해결: 응답 추출 로직 추가
export async function getEvents(startDate: string, endDate: string) {
const response = await fetchWithAuth(
`/calendar/events?start_date=${startDate}&end_date=${endDate}`
);
// 객체에서 배열 추출
return response?.events || [];
}AI를 통한 해결: 백엔드 프로젝트에서 calendar관련 request, response를 문서로 만들고 해당 문서를 프론트엔드 프로젝트에서 AI를 통한 검수를 하여 수정을 하였다.
트러블 슈팅 3: ESLint react-hooks 에러
증상: react-hooks/set-state-in-effect 에러
원인: useEffect 안에서 여러 setState 호출
해결: 폼 상태를 단일 객체로 통합
// Before: 10개의 개별 useState
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
// ...
// After: 단일 FormState 객체
interface FormState {
title: string;
description: string;
startDate: Date;
startTime: string;
// ...
}
const [formState, setFormState] = useState<FormState>(getInitialFormState);Part 4: 테스트 전략
백엔드 테스트 피라미드(트로피 방식이 되었다)
/ \
/ \
/ \
/ E2E \ 8개 (실제 Firebase, DB)
/─────────\
/Integration\ 43개 (Fake DI)
/─────────────\
/ Unit \ 19개 (순수 로직)
─────────────────
반복 로직 Unit 테스트
def test_weekly_specific_days(self):
"""매주 월, 수, 금 반복"""
occurrences = get_occurrences(
rrule_str="FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR",
dtstart=datetime(2024, 1, 1, 10, 0),
range_start=date(2024, 1, 1),
range_end=date(2024, 1, 7),
)
assert len(occurrences) == 3
assert date(2024, 1, 1) in occurrences # 월
assert date(2024, 1, 3) in occurrences # 수
assert date(2024, 1, 5) in occurrences # 금프론트엔드 E2E 테스트 (Playwright)
test('일정 생성 및 표시', async ({ page }) => {
await page.goto('/');
// 일정 추가 버튼 클릭
await page.click('text=일정 추가');
// 폼 작성
await page.fill('[name=title]', '테스트 일정');
await page.click('text=저장');
// 캘린더에 일정 표시 확인
await expect(page.locator('text=테스트 일정')).toBeVisible();
});Part 5: 배포
Nginx 설정 및 SSL 발급
서버의 기본 환경(Ubuntu Server, Nginx, Certbot, 방화벽 등)은 블로그 서버 세팅 글에서 이미 구성해두었다. 여기서는 캘린더 앱을 위한 설정만 추가하면 된다.
1. 설정 파일 생성
sudo nano /etc/nginx/sites-available/calendar.conf2. 내용 붙여넣기 후 저장
에디터에서 아래 내용을 붙여넣고, Ctrl+O (저장) → Enter → Ctrl+X (종료)
server {
server_name calendar.funq.kr;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
listen 80;
}3. 심볼릭 링크 생성
sudo ln -s /etc/nginx/sites-available/calendar.conf /etc/nginx/sites-enabled/4. Nginx 설정 문법 테스트
sudo nginx -t
# syntax is ok 메시지 확인5. Nginx 재시작
sudo systemctl reload nginx6. SSL 인증서 발급 (Certbot)
sudo certbot --nginx -d calendar.funq.krCertbot이 Nginx 설정을 자동으로 수정하고 HTTPS 인증서를 적용한다.
결과물
지원하는 기능
| 기능 | 설명 |
|---|---|
| 멀티 뷰 | 월간, 주간, 일간 뷰 전환 |
| 일정 CRUD | 생성, 조회, 수정, 삭제 |
| 반복 일정 | 매일/매주/매월/매년 + 요일 지정 |
| 카테고리 | 색상별 분류 |
| 가족 구성원 | 멤버별 색상 표시 |
| 자동 등록 | 첫 로그인 시 자동 회원 생성 |
| 반응형 | 모바일/태블릿/데스크톱 대응 |
지원하는 반복 패턴
| 패턴 | API 요청 예시 |
|---|---|
| 매일 | {"frequency": "DAILY"} |
| 매주 | {"frequency": "WEEKLY"} |
| 매주 월/수/금 | {"frequency": "WEEKLY", "weekdays": ["MO", "WE", "FR"]} |
| 격주 | {"frequency": "WEEKLY", "interval": 2} |
| 매월 | {"frequency": "MONTHLY"} |
| 매년 | {"frequency": "YEARLY"} |
마치며
배운 점
백엔드
- Protocol 패턴: 테스트 용이성이 크게 향상된다
- RRULE 표준: 바퀴를 재발명하지 말자. RFC 5545는 검증된 표준이다
- 점진적 개선: 에러를 만나면서 개선하자. 테스트가 있으면 두렵지 않다
프론트엔드
- TypeScript 엄격 모드: 런타임 에러를 빌드 타임에 잡을 수 있다
- shadcn/ui: 커스터마이징 가능한 컴포넌트로 개발 속도 향상
- 타임존 처리:
toISOString()대신 로컬 Date 메서드를 사용하자
풀스택
- API 계약: 백엔드-프론트엔드 응답 형식을 명확히 정의하자
- 자동 등록: 사용자 온보딩 마찰을 줄이자
- 에러 핸들링: Graceful degradation으로 오프라인에서도 동작하게
시리즈 안내
이 글은 캘린더 프로젝트 시리즈의 2편입니다:
- Claude Code + Superpowers로 캘린더 만들기: brainstorm→plan→execute - 개발 워크플로우
- 캘린더 풀스택 개발 (현재 글) - 기술 구현 상세
GitHub: https://github.com/nasodev/calendar
Live Demo: https://calendar.funq.kr
궁금한 점은 댓글로 남겨주세요!