본문 바로가기
IT/AI

[AI] Claude와 함께 타임박싱 웹앱 만들기 #9 — 소셜 시스템 + 인라인 캘린더

by 수누다 2026. 3. 27.

8편에서 "앞으로 할 것"에 적었던 친구 맺기와 월간 캘린더를 드디어 만들었습니다. 공유 일정 기능은 이미 있었지만 "누구에게 보낼지" 관리가 안 됐습니다. 유저 목록을 전부 보여주고 체크하는 방식이었으니까요. 캘린더도 없어서 날짜를 < > 버튼으로 하루씩 넘겨야 했고요. 이번 편에서는 친구/그룹 소셜 시스템, 인라인 월간 캘린더, 그룹 단위 공유를 추가한 과정을 정리합니다.


1. 환경 정보

항목 버전/스펙
Backend PocketBase (Self-hosted, SQLite)
Frontend HTML + Vanilla JS (PWA)
Infra Proxmox VE — LXC 컨테이너
Web Server Nginx (리버스 프록시)
SSL Let's Encrypt

2. PocketBase 컬렉션 설계

기존에 users, timebox_daily, shared_events 3개 컬렉션이 있었습니다. 여기에 소셜 기능을 위한 3개를 추가했습니다.

2-1. friendships — 친구 관계

필드 타입 설명
requester Relation → users 요청한 사람
addressee Relation → users 받는 사람
status Select pending / accepted / blocked

처음에는 pending → accepted 과정을 만들었습니다. 일반적인 SNS 패턴이니까요. 그런데 직접 써보니 가족용 앱에 요청/수락 과정은 오버엔지니어링이었습니다. 결국 바로 accepted로 등록하는 방식으로 단순화했습니다. 나중에 외부 사용자가 생기면 그때 다시 넣으면 됩니다.

2-2. groups — 그룹

필드 타입 설명
name Text 그룹명
owner Relation → users 소유자
color Text 그룹 대표 색상

2-3. group_members — 그룹 멤버

필드 타입 설명
group Relation → groups 그룹
user Relation → users 멤버
role Select owner / member

기존 shared_events 컬렉션에는 target_groups 필드(Relation → groups, Multiple)를 추가해서 특정 그룹 단위로 일정을 공유할 수 있게 했습니다.


3. API Rules 삽질기

3-1. 403 에러의 원인

group_members의 Create rule에 @request.auth.id != ""을 넣어야 하는데 빼먹었습니다. 결과는 멤버 추가 시 403 Forbidden. PocketBase에서 API Rule이 비어있으면(잠금 상태) 슈퍼유저만 접근 가능하기 때문입니다. 관리자 대시보드에서 한 줄이면 해결되는 건데, 에러 메시지만 보면 원인을 특정하기 어렵습니다.

PocketBase API Rules 설정 시 체크리스트:

  • List / View rule: 인증된 유저가 볼 수 있는 범위 지정
  • Create rule: @request.auth.id != "" 최소한 이것
  • Update / Delete rule: 소유자 또는 멤버 본인만 가능하도록 제한

참고: PocketBase의 API Rule이 잠금 상태이면 슈퍼유저만 접근 가능합니다. Create rule에서 403이 아닌 400이 반환되는 점도 유의하세요. 자세한 내용은 PocketBase API Rules 공식 문서를 참고하세요.

3-2. 유저 검색용 별도 필드

users 컬렉션에 search_email이라는 Text 필드를 추가했습니다. PocketBase의 authWithPassword가 사용하는 email 필드는 보안상 API로 검색이 안 됩니다. 친구 추가 시 이메일로 검색해야 하니까, 검색 전용 이메일 필드를 만들고 유저가 직접 입력하도록 했습니다.

이건 PocketBase의 의도된 동작입니다. 인증용 이메일이 API 필터로 노출되면 유저 이메일 수집이 가능해지니까요. 별도 필드를 만드는 게 가장 깔끔한 우회법입니다.


4. 소셜 패널 UI

상단 바에 👥 소셜 버튼을 추가했습니다. 누르면 전체화면 오버레이가 뜨고, 두 탭으로 나뉩니다.

4-1. 친구 탭

검색창에 이름, username, email을 입력하면 PocketBase에서 검색합니다. 결과에서 "친구 추가"를 누르면 바로 accepted 상태로 등록됩니다. 내 친구 목록에서는 삭제도 가능합니다.

4-2. 그룹 탭

그룹 이름과 색상을 선택해서 만들 수 있습니다. 중복 이름은 체크하도록 했고, 생성된 그룹에 멤버를 추가/제거할 수 있습니다. 그룹 삭제도 여기서 처리합니다.

4-3. expand 실패 fallback

여기서 제법 시간을 잡아먹은 부분이 있습니다. PocketBase의 expand 기능이 가끔 빈 객체를 반환하는 문제입니다. 특히 관계가 깊어지면(group_membersusername) 이런 현상이 발생합니다.

PocketBase의 공식 문서에 따르면 6단계까지 중첩 expand를 지원하지만, 실제로는 API Rule 설정이나 권한 문제로 expand가 조용히 실패하는 경우가 있습니다. 에러를 던지지 않고 빈 객체를 반환하기 때문에 디버깅이 까다롭습니다.

해결책은 단순합니다. expand가 없으면 별도로 getOne()으로 유저 정보를 가져오는 fallback을 넣었습니다.

let u = m.expand?.user;
if (!u) {
  // fallback: 직접 조회
  try { u = await pb.collection('users').getOne(m.user); } catch(e) {}
}

방어적으로 코드를 짜는 게 결국 시간을 아낍니다. expand를 믿되 검증하는 방식이 PocketBase에서는 안전합니다.


5. 인라인 월간 캘린더

5-1. 팝업 vs 인라인

처음에는 캘린더를 팝업으로 만들었습니다. 하지만 타임그리드 위에 겹치면서 일정 추가 후 바로 확인이 안 됐습니다. 데이터를 보면서 조작해야 하는데, 팝업이 그걸 막고 있었습니다. 결국 타임그리드 자리에 캘린더가 표시되는 인라인 방식으로 바꿨습니다.

📅 캘린더 버튼을 누르면 사이드바가 숨겨지고, 타임그리드 대신 캘린더 뷰가 나타납니다. 버튼은 "⏱ 닫기"로 변경됩니다.

5-2. 캘린더 레이아웃

┌─────────────────────────────────────┐
│  ◀  2026년 3월  ▶      [오늘]      │
├──────────┬──────────────────────────┤
│  월간    │    일별 상세 패널        │
│  그리드  │  ┌──────────────────┐   │
│          │  │ BIG 3            │   │
│ [1][2].. │  │ 타임라인 바      │   │
│ [14]★    │  │ Brain Dump       │   │
│ [28]..   │  │ 메모             │   │
│          │  │ 일정 추가 폼     │   │
│          │  └──────────────────┘   │
└──────────┴──────────────────────────┘

왼쪽은 월간 그리드, 날짜를 클릭하면 오른쪽에 상세 패널이 뜹니다. 해당 날짜의 PocketBase 데이터를 로드해서 BIG 3, 타임라인, Brain Dump, 달성률을 한 눈에 보여줍니다.

5-3. 일정 추가 기능

상세 패널 하단에 "일정 추가" 폼을 넣었습니다. 제목, 시작 시간, 소요 시간을 입력하면 아래 순서로 처리됩니다.

  1. 해당 날짜의 payload를 PocketBase에서 로드
  2. Brain Dump 빈 슬롯에 제목 추가 (이미 같은 이름이 있으면 건너뜀)
  3. 지정 시간대에 Plan 블록 칠하기
  4. PocketBase에 저장
  5. 캘린더 갱신

기존 일정 겹침 체크도 추가했습니다. 같은 시간에 이미 Plan이 있으면 확인 창을 띄워서 덮어쓸지 물어봅니다.

5-4. 캘린더 열기 전 자동 저장

toggleCalendar()async로 바꾸고 await _saveNow()를 호출하도록 했습니다. 타임박스에서 내용을 수정하고 바로 캘린더를 열어도 최신 데이터가 반영됩니다. 이 처리를 안 하면 2초 디바운스 때문에 방금 한 작업이 캘린더에 안 보입니다. 사소해 보이지만 UX에 큰 차이를 만드는 부분입니다.


6. 모바일 캘린더

6-1. 하단 시트 (Bottom Sheet)

모바일(768px 이하)에서 상세 패널이 오른쪽에 나오면 화면이 너무 좁습니다. 그래서 iOS/Android 네이티브 앱에서 흔히 보는 하단 시트 패턴을 적용했습니다.

.cal-detail {
  position: fixed;
  bottom: 0; left: 0; right: 0;
  border-radius: 20px 20px 0 0;
  max-height: 75vh;
  transform: translateY(100%);
  transition: transform .25s ease;
}
.cal-detail.mob-open {
  transform: translateY(0);
}

핵심은 transform: translateY()입니다. position: fixed로 화면 하단에 고정하고, 기본 상태에서는 translateY(100%)로 화면 밖에 숨겨둡니다. 클래스를 토글하면 translateY(0)으로 슬라이드업됩니다. CSS transform은 GPU 가속이 적용되기 때문에 60fps 애니메이션이 가능합니다. 상단에 핸들바와 딤 배경을 더하면 네이티브 앱 느낌이 납니다.

참고: 모바일 하단 시트 구현 시 position: fixed + transform 조합이 표준 패턴입니다. 최근에는 CSS Scroll Snap을 활용한 구현도 나오고 있습니다. 자세한 내용은 Native-like bottom sheets on the web을 참고하세요.

6-2. 스와이프로 월 이동

캘린더 그리드에서 좌우 스와이프로 이전/다음 달을 이동할 수 있게 했습니다. 세로 스크롤과 구분하기 위해 다음 조건을 넣었습니다.

// 수평 이동이 60px 이상이고, 수직 이동의 1.5배보다 클 때만 스와이프로 인식
if (dx > 60 && dx > dy * 1.5) {
  // 월 이동 처리
}

세로 스크롤 의도를 수평 스와이프로 오인하면 사용성이 급격히 나빠집니다. 이 임계값은 여러 번 테스트해서 잡았습니다.

6-3. 이벤트 3개 제한

모바일 셀이 좁아서 이벤트를 다 보여주면 넘칩니다. trimCalendarOverflow()에서 모바일이면 최대 3개만 표시하고, 나머지는 "+N개"로 표시합니다. Google Calendar 모바일 앱에서 익숙한 패턴이라 위화감이 없습니다.


7. 공유 일정 + 캘린더 충돌 버그

이번 편에서 가장 오래 걸린 버그입니다. 캘린더 상세 뷰에서 "개인 타임라인"에 공유 일정이 중복 표시되는 문제였습니다.

7-1. 원인

공유 일정을 Done 클릭하면 셀에 pc-X + done-marked-X 클래스와 체크 표시 스팬이 추가되고 payload에 저장됩니다. 문제는 상세 뷰 파서의 라벨 추출 regex />([^<]+)<\/span>/shared-chk 스팬의 "✓"도 매칭해버린 것입니다. 모든 슬롯이 "✓"라는 라벨의 새 이벤트로 인식되면서 타임라인이 엉망이 됐습니다.

7-2. 해결

regex를 slot-label 클래스 전용으로 변경했습니다.

// Before: 모든 span 매칭
td.h.match(/>([^<]+)<\/span>/)

// After: slot-label만 매칭
td.h.match(/class="slot-label">([^<]+)<\/span>/)

4곳에서 같은 패턴을 쓰고 있어서 전부 수정했습니다. 이 버그는 "왜 같은 regex를 4곳에 복사했는가"라는 근본 문제를 상기시켜줬습니다. 유틸 함수로 빼야 하는데, 그건 다음 리팩토링 때 하겠습니다.


8. 이번 편에서 배운 것

  • PocketBase expand는 믿되 검증하자: 깊은 관계에서 expand가 조용히 실패할 수 있습니다. fallback으로 getOne() 직접 조회를 항상 준비해두면 안전합니다.
  • 인라인 > 팝업: 데이터를 보면서 조작해야 하는 UI는 인라인이 훨씬 자연스럽습니다. 팝업은 컨텍스트를 끊습니다.
  • 모바일 하단 시트는 쉽다: transform: translateY() + transition이면 충분합니다. 네이티브 앱 느낌을 내는 가성비 좋은 패턴입니다.
  • regex는 의도하지 않은 곳에서 매칭된다: 가능하면 클래스명 같은 명시적 앵커를 포함해서 매칭 범위를 좁히세요.
  • API Rules 설정은 CRUD 전부 확인: Create만 빼먹어도 403이 뜹니다. 컬렉션 추가 시 4개 룰을 체크리스트로 확인하는 습관을 들이세요.

다음 편: #10 — 가족 대시보드, 주간 목표, 알림 시스템