fastapi

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

캘린더 풀스택 개발: FastAPI + Next.js로 반복 일정까지 구현하기
0 views
views
13 min read
#fastapi

이 글은 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 results

Part 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.conf

2. 내용 붙여넣기 후 저장

에디터에서 아래 내용을 붙여넣고, Ctrl+O (저장) → EnterCtrl+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 nginx

6. SSL 인증서 발급 (Certbot)

sudo certbot --nginx -d calendar.funq.kr

Certbot이 Nginx 설정을 자동으로 수정하고 HTTPS 인증서를 적용한다.


결과물

지원하는 기능

기능설명
멀티 뷰월간, 주간, 일간 뷰 전환
일정 CRUD생성, 조회, 수정, 삭제
반복 일정매일/매주/매월/매년 + 요일 지정
카테고리색상별 분류
가족 구성원멤버별 색상 표시
자동 등록첫 로그인 시 자동 회원 생성
반응형모바일/태블릿/데스크톱 대응

지원하는 반복 패턴

패턴API 요청 예시
매일{"frequency": "DAILY"}
매주{"frequency": "WEEKLY"}
매주 월/수/금{"frequency": "WEEKLY", "weekdays": ["MO", "WE", "FR"]}
격주{"frequency": "WEEKLY", "interval": 2}
매월{"frequency": "MONTHLY"}
매년{"frequency": "YEARLY"}

마치며

배운 점

백엔드

  1. Protocol 패턴: 테스트 용이성이 크게 향상된다
  2. RRULE 표준: 바퀴를 재발명하지 말자. RFC 5545는 검증된 표준이다
  3. 점진적 개선: 에러를 만나면서 개선하자. 테스트가 있으면 두렵지 않다

프론트엔드

  1. TypeScript 엄격 모드: 런타임 에러를 빌드 타임에 잡을 수 있다
  2. shadcn/ui: 커스터마이징 가능한 컴포넌트로 개발 속도 향상
  3. 타임존 처리: toISOString() 대신 로컬 Date 메서드를 사용하자

풀스택

  1. API 계약: 백엔드-프론트엔드 응답 형식을 명확히 정의하자
  2. 자동 등록: 사용자 온보딩 마찰을 줄이자
  3. 에러 핸들링: Graceful degradation으로 오프라인에서도 동작하게

시리즈 안내

이 글은 캘린더 프로젝트 시리즈의 2편입니다:

  1. Claude Code + Superpowers로 캘린더 만들기: brainstorm→plan→execute - 개발 워크플로우
  2. 캘린더 풀스택 개발 (현재 글) - 기술 구현 상세

GitHub: https://github.com/nasodev/calendar

Live Demo: https://calendar.funq.kr

궁금한 점은 댓글로 남겨주세요!