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_members → user → name) 이런 현상이 발생합니다.
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. 일정 추가 기능
상세 패널 하단에 "일정 추가" 폼을 넣었습니다. 제목, 시작 시간, 소요 시간을 입력하면 아래 순서로 처리됩니다.
- 해당 날짜의 payload를 PocketBase에서 로드
- Brain Dump 빈 슬롯에 제목 추가 (이미 같은 이름이 있으면 건너뜀)
- 지정 시간대에 Plan 블록 칠하기
- PocketBase에 저장
- 캘린더 갱신
기존 일정 겹침 체크도 추가했습니다. 같은 시간에 이미 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 — 가족 대시보드, 주간 목표, 알림 시스템
'IT > AI' 카테고리의 다른 글
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #12 — 데이터 보호: 덮어쓰기 차단, 백업, 로그 (0) | 2026.03.31 |
|---|---|
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #11 — 리뷰 대시보드 강화 + Plan 레이어 보존 (0) | 2026.03.30 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #10 — 가족 대시보드, 주간 목표, 알림 (0) | 2026.03.29 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #8 — 인프라 보안 & 백업, 그리고 회고 (0) | 2026.03.26 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #7 — 가족끼리 일정 공유 (0) | 2026.03.25 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #6 — PWA: 웹앱을 앱처럼 쓰는 마법 🪄 (0) | 2026.03.24 |