Skip to content

feat: MOIM V1 서비스 공식 런칭 및 UX/UI 고도화 통합 반영 - #56

Merged
kokkumong merged 105 commits into
mainfrom
dev
Jun 11, 2026
Merged

feat: MOIM V1 서비스 공식 런칭 및 UX/UI 고도화 통합 반영#56
kokkumong merged 105 commits into
mainfrom
dev

Conversation

@Siul49

@Siul49 Siul49 commented Jun 11, 2026

Copy link
Copy Markdown
Owner

[PR] MOIM V1 서비스 공식 런칭 및 UX/UI 고도화 통합 반영 (dev -> main)

📌 개요

본 Pull Request는 dev 브랜치에서 진행된 MOIM V1 전체 개발 내역을 main 브랜치에 병합하여 실서비스(Production) 환경에 배포하기 위한 통합 PR입니다.
인증, 일정 조율 알고리즘, 외부 캘린더 연동, 모바일 UX 고도화, 그리고 최신 QA 피드백 결함 해결 사항이 모두 포함되어 있습니다.


🛠️ 주요 반영 사항 및 해결된 이슈

1. 인증 및 회원 관리 (Authentication & Onboarding)

  • Supabase Auth 기반 인증 통합: Google, Kakao, Apple OAuth 소셜 로그인 및 이메일/닉네임 회원가입 및 로그인을 완전 통합했습니다.
  • 동적 리디렉션 파라미터 보존: 회원가입 및 로그인 완료 시 redirect 혹은 next 쿼리 파라미터를 파싱하여 유저가 원래 있던 흐름(예: 모임 저장 후 대시보드)으로 안전하게 진입하도록 보장합니다.
  • 회원가입 소셜 로그인 연동 수정: 회원가입 페이지(/signup) 내 소셜 로그인 버튼들이 잘못된 API 라우트로 매핑되어 있던 현상을 수정하고, 로그인 화면과 동일하게 클라이언트 사이드 signInWithOAuth 핸들러로 연동하여 안정성을 확보했습니다.

2. 일정 생성 및 조율 동선 고도화 (Host Creation Flow)

  • 4단계 위저드 개편: 모임 생성 동선을 4단계(CreateScheduleClient)로 구조화하여 주최자가 단계별로 명확하게 정보를 입력하도록 유도합니다.
  • 참여자 & 호스트 링크 분리: 생성 완료 시 hostPath에 고유 hostToken 쿼리 파라미터를 추가하여, 참여자용 초대 링크와 호스트용 결과 화면을 완전히 격리했습니다.
  • 세션 기반 호스트 인가 확대: 브라우저의 호스트 토큰 쿠키가 유실되거나 기기가 바뀌더라도, 로그인 유저가 해당 모임의 생성자(creatorId === user.id)인 경우 대시보드 및 모임방에서 호스트 권한을 보장하도록 API 인가 처리를 확장했습니다.

3. 외부 캘린더 및 시간표 연동 (Calendar & Timetable Sync)

  • Google OAuth & iCloud CalDAV 동기화: API를 통해 외부 캘린더의 바쁜 시간대를 가져와 일정 생성 시 자동으로 차단하고 비활성화 피드백을 제공합니다.
  • 에브리타임 데이터 영구 저장: 파싱된 시간표 데이터(slots)를 Supabase user_metadata에 영구 보존하여 사용자가 기기를 이동해도 연동 상태를 온전하게 복원할 수 있습니다.
  • 참여 화면 스마트 매핑: 로그인된 유저가 초대받은 모임에 참여할 때, 이미 연동된 에브리타임 일정 정보를 기반으로 빈 시간대를 스케줄 격자에 자동으로 미리 칠해주는 지능형 매핑 UX를 제공합니다.

4. 모바일 사용성 및 프리미엄 UX/UI 개선 (Mobile UX & Micro-Animations)

  • 모바일 스크롤 충돌 해결: 시간표 격자 드래그 입력 시 모바일 브라우저 세로 스크롤이 간섭을 겪는 문제를 해결하기 위해, 드래그 상태에서만 세로 스크롤을 무효화하는 네이티브 touchmove 리스너(passive: false)를 연동했습니다.
  • 레이저 스캔 애니메이션: 에브리타임 이미지 분석 대기 시간 동안 dynamic한 격자 펄스 모션과 레이저 스캔 빔 효과를 가미하여 AI 분석 로딩 경험을 개선했습니다.
  • 제출 완료 디자인 개선: 참여 완료 패널 (SubmissionDonePanel)에 라운딩 처리 및 프리미엄 섀도우를 적용하고 가입 유도 영역에 그라데이션 및 버튼 hover/click 마이크로 인터랙션을 반영했습니다.

5. 데이터 안정성 및 멱등성 보장 (Data Consistency)

  • 중복 제출 멱등성 보장 (upsert): 모임 참여자가 동일한 이름으로 여러 번 일정을 수정 제출하거나 페이지를 새로고침할 때 발생하던 중복 참여자 누적 및 500 DB 에러를 upsert 로직(기존 제출 건 update)으로 해결했습니다.

🧪 검증 결과 요약

1. 프로덕션 빌드 성공

  • npm run build 결과 15개 정적/동적 라우트가 타입 에러 없이 성공적으로 최적화 빌드 완료되었습니다.

2. 테스트 스위트 전체 통과

  • 단위/통합 테스트 (Vitest): 약속 조율 알고리즘, ICS 파싱, 캘린더 연동 해제 등 핵심 비즈니스 로직에 대한 222개 테스트 케이스가 무결하게 통과했습니다.
  • E2E 테스트 (Playwright): 주최자 일정 생성부터 참가자 정보 제출까지의 전체 동선 시나리오가 모든 모바일/데스크톱 에뮬레이터에서 통과했습니다.
  • 로그인 요율 우회: Supabase Auth의 이메일 전송 요율 제한(over_email_send_rate_limit)을 우회할 수 있도록 E2E 테스트 모드 전용 Mocking 세션 쿠키 제어를 설계하여 테스트 안정성을 확보했습니다.

✅ 체크리스트

  • 브랜치 컨벤션(feature/00-name)을 지켰나요?
  • 커밋 컨벤션(feat:, fix: 등)을 지켰나요?
  • 작업 전에 관련 이슈를 생성하고 연결했나요?
  • 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요?

Siul49 and others added 30 commits April 14, 2026 21:43
iCloud 캘린더 연동을 위한 CalDAV 클라이언트, API 라우트, ICS 파서/빌더, 암호화 유틸 및 DB 마이그레이션 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crypto, ICS 빌더/파서, CalDAV 클라이언트/생성/쿼리 모듈의 단위 테스트 69개 추가.
ICS 파서의 TZID 타임존 변환 시 오프셋 이중 적용 버그 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Google OAuth 2.0 기반 캘린더 연동 (목록 조회, 일정 읽기/쓰기) 구현.
토큰 자동 갱신, 쿠키 기반 세션 관리, Zod 입력 검증 포함.
실제 Google Calendar 서버 통합 테스트 완료. 단위 테스트 30개 통과.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
package.json과 package-lock.json 불일치로 CI npm ci 실패 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
체이닝된 메서드의 eslint-disable 주석 위치 문제 해결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GeistVF.woff/GeistMonoVF.woff 파일 누락으로 빌드 실패 수정.
Inter/JetBrains Mono Google Font으로 대체.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
macOS에서 생성 시 누락된 Linux optional dependencies 포함하여 재생성

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Next.js 빌드 시 @vitejs/plugin-react 타입 에러 방지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
환경변수 미설정 CI 환경에서 정적 프리렌더링 시 에러 방지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 누락된 로컬 폰트를 Google Fonts(Inter, JetBrains Mono)로 대체
- tsconfig.json 추가 (@/* 경로 alias, vitest.config.ts exclude)
- API 라우트에 force-dynamic 추가하여 프리렌더링 방지
- package-lock.json 재생성 (Linux 네이티브 바인딩 포함)
- cn() 유틸리티 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@/shared/lib/supabase/server → @/lib/supabase/server 경로 수정.
빌드 통과를 위해 createClient 스켈레톤 export 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat(google): Google Calendar OAuth 연동 기능 구현
feat(icloud): iCloud CalDAV 캘린더 연동 기능 구현
- CalendarEvent 표준 타입과 SearchWindow/TimeRange 정의
- DateTimeSlot, ParticipantDateAvailability 타입을 schedule.ts에 추가
- time-slot.ts: sortSlots/mergeOverlapping 구현 + Date 기반 변종 추가
- free-slots.ts: busy 이벤트를 윈도우 내 free 슬롯으로 반전
- findCommonDateSlots: durationMinutes 필터를 가진 Date 기반 교집합
- CalendarAdapter 인터페이스와 iCloud/Google/Manual 어댑터 구현
- photo 어댑터는 후속 이슈를 위한 스텁

Refs #15
- Prisma 5 + SQLite 로컬 DB 구성 및 User 모델 마이그레이션
- zod v4 기반 서버 사이드 유효성 검사 (이메일·전화번호·닉네임·비밀번호·약관)
- bcryptjs를 이용한 비밀번호 해싱 저장 (평문 미저장)
- email·phoneNumber·nickname 중복 시 409 응답 및 field 명시
- 전화번호 입력값 010-XXXX-XXXX 형식으로 정규화 저장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 이메일 저장 전 trim·toLowerCase 정규화 처리
- bcryptjs.hash를 try 블록 안으로 이동하여 해싱 오류도 500 처리
- normalizePhoneNumber에 비숫자 제거 및 유효성 검사 추가, 잘못된 입력 시 예외 발생

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- POST /api/auth/login: 이메일 또는 닉네임으로 로그인, httpOnly JWT 쿠키 발급
- GET /api/auth/me: accessToken 쿠키 검증 후 사용자 정보 반환
- POST /api/auth/logout: accessToken 쿠키 삭제
- jose 기반 JWT 서명/검증 유틸 (signAccessToken, verifyAccessToken)
- bcryptjs comparePassword 유틸 분리
- zod loginSchema 작성 (loginId, password 필수 검증)
- JWT payload에 userId, email, nickname만 포함 (민감정보 제외)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor: 에브리타임 시간표 유효성 검사 강화 및 빌드 오류 수정
- SocialAccount 테이블 추가 (provider, providerUserId, userId FK)
- User에 profileCompleted 필드 추가 (소셜 신규 가입 시 추가 정보 입력 유도)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…back)

- getKakaoAuthUrl / getKakaoToken / getKakaoUser / extractKakaoUserInfo 유틸 구현
- JWT signAccessToken / verifyAccessToken (jose HS256, 7일 유효)
- /api/auth/kakao/login: CSRF state 쿠키 발급 후 카카오 인가 URL 리다이렉트
- /api/auth/kakao/callback: state 검증, 토큰 교환, 신규/기존 유저 처리, JWT 쿠키 발급

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- jose 패키지 추가 (JWT 서명/검증)
- .env.example에 KAKAO_REST_API_KEY, KAKAO_CLIENT_SECRET, KAKAO_REDIRECT_URI 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Siul49 and others added 13 commits June 10, 2026 14:20
애플 연동만 앱이 채택하지 않은 Supabase Auth 중심 설계 위에 구현되어
일반 로그인(커스텀 JWT) 유저는 connect에서 401, profiles FK 미충족으로
저장 실패했다. 구글/네이버와 동일하게 HttpOnly 쿠키 저장으로 전환한다.

- src/lib/caldav/connection-cookie.ts 신설: 앱 전용 암호를 AES-256-GCM으로
  암호화해 icloud_connection 쿠키에 저장/조회/삭제
- icloud 라우트 5개(connect/calendars/events·query/create/disconnect)에서
  requireSession+Supabase 제거, 쿠키 기반으로 전환
- events 라우트 계약 calendarId(UUID) → calendarUrl로 변경
- calendar/status: Supabase/세션 게이트 제거, 쿠키 존재로 판단
  (구글 분기도 빈 테이블 조회로 깨져 있어 함께 수정)
- .env.example에 ENCRYPTION_SECRET 추가 (배포에 필수)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix: 애플(iCloud) 캘린더 연동을 JWT+쿠키 방식으로 재정렬 (#54)
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
moim-app Ready Ready Preview, Comment Jun 11, 2026 4:00am

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 200 files, which is 50 over the limit of 150.

To get a review, narrow the scope:
• coderabbit review --type committed # exclude uncommitted changes
• coderabbit review --dir # limit to a subdirectory
• coderabbit review --base # compare against a closer base

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4580efeb-1575-4e8f-b905-64bf6c1783ac

📥 Commits

Reviewing files that changed from the base of the PR and between af21bdb and 1594878.

⛔ Files ignored due to path filters (44)
  • docs/MOIM_role_screen/MOIM_role_screen_01_guest_role_hub.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_02_guest_value_landing.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_03_guest_action_choice.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_04_guest_save_later_prompt.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_05_guest_signed_in_comparison.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_06_signed_in_home.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_07_signed_in_calendar_settings.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_08_signed_in_default_settings.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_09_signed_in_recent_meetings.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_10_signed_in_profile.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_11_host_create_meeting.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_12_host_calendar_setup.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_13_host_candidate_times.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_14_host_share_link.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_15_host_status_board.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_16_participant_invite_entry.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_17_participant_calendar_detected.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_18_participant_upload_recovery.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_19_participant_time_grid.png is excluded by !**/*.png
  • docs/MOIM_role_screen/MOIM_role_screen_20_participant_done_result.png is excluded by !**/*.png
  • docs/business-plan/references/사업계획서 개선점.zip is excluded by !**/*.zip
  • docs/business-plan/references/사업계획서 개선점/image.png is excluded by !**/*.png
  • docs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.15.29.png is excluded by !**/*.png
  • docs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.16.07.png is excluded by !**/*.png
  • docs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.16.46.png is excluded by !**/*.png
  • docs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.20.32.png is excluded by !**/*.png
  • docs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.22.25.png is excluded by !**/*.png
  • docs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.22.50.png is excluded by !**/*.png
  • docs/startup-data/processed-indicators.csv is excluded by !**/*.csv
  • docs/survey/모임(응답) - 설문지 응답 시트1.csv is excluded by !**/*.csv
  • package-lock.json is excluded by !**/package-lock.json
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/file.svg is excluded by !**/*.svg
  • public/globe.svg is excluded by !**/*.svg
  • public/next.svg is excluded by !**/*.svg
  • public/vercel.svg is excluded by !**/*.svg
  • public/window.svg is excluded by !**/*.svg
  • src/app/favicon.ico is excluded by !**/*.ico
  • src/app/fonts/Pretendard-Bold.woff2 is excluded by !**/*.woff2
  • src/app/fonts/Pretendard-ExtraBold.woff2 is excluded by !**/*.woff2
  • src/app/fonts/Pretendard-Light.woff2 is excluded by !**/*.woff2
  • src/app/fonts/Pretendard-Medium.woff2 is excluded by !**/*.woff2
  • src/app/fonts/Pretendard-Regular.woff2 is excluded by !**/*.woff2
  • src/app/fonts/Pretendard-SemiBold.woff2 is excluded by !**/*.woff2
📒 Files selected for processing (200)
  • .agents/docs/1_DOMAIN_CONTEXT.md
  • .agents/docs/2_EXECUTION_PLAN.md
  • .agents/docs/3_FILE_INDEX.md
  • .agents/docs/4_API_RESPONSE_FORMATS.md
  • .coderabbit.yaml
  • .env.example
  • .eslintrc.json
  • .github/ISSUE_TEMPLATE/bug_report.md
  • .github/ISSUE_TEMPLATE/feature_request.md
  • .github/pull_request_template.md
  • .github/workflows/ci.yml
  • .github/workflows/issue-compliance.yml
  • .github/workflows/pr-compliance.yml
  • .gitignore
  • .husky/commit-msg
  • .husky/pre-commit
  • .vscode/settings.json
  • ARCHITECTURE.md
  • README.md
  • commitlint.config.js
  • components.json
  • convention.md
  • docs/README.md
  • docs/archive/legacy-business-plans/20212578_7팅_이동윤_사업계획서.hwpx
  • docs/archive/legacy-business-plans/20222074_이동은_사업계획서.hwpx
  • docs/archive/legacy-business-plans/20223007_김유현_예창패.hwpx
  • docs/archive/legacy-business-plans/20223026_정규호_예창패.hwpx
  • docs/business-plan/MOIM_2026_학생창업유망팀300_사업계획서_raw_text.txt
  • docs/business-plan/MOIM_2026_학생창업유망팀300_사업계획서_보강초안.md
  • docs/business-plan/MOIM_사업계획서_1_dump.txt
  • docs/business-plan/MOIM_사업계획서_final_dump.txt
  • docs/business-plan/README.md
  • docs/business-plan/references/사업계획서 개선점/사업계획서 개선점 3724a6ec39318075b301ddd9e2abe7eb.md
  • docs/business-plan/references/사업계획서의 이해_part1_extracted.txt
  • docs/business-plan/references/사업계획서의 이해_part2_extracted.txt
  • docs/business-plan/references/사업계획서의 이해_part3_extracted.txt
  • docs/startup-data/public-data-analysis.md
  • docs/v1/codex-work-context.md
  • docs/v1/prd.md
  • docs/v1/provided-documents-summary.md
  • docs/v1/ui-screen-modification-plan.md
  • docs/v1/user-flow.md
  • e2e/calendar-integration.spec.ts
  • e2e/host-flow.spec.ts
  • e2e/mocks/sample.ics
  • e2e/participant-flow.spec.ts
  • eslint.config.mjs
  • next.config.mjs
  • next.config.ts
  • package.json
  • playwright.config.ts
  • postcss.config.js
  • postcss.config.mjs
  • prisma/schema.prisma
  • prototype/button_proposals.html
  • prototype/login_ui_proposals.html
  • prototype/moim.html
  • prototype/nudge_button_proposals.html
  • scripts/ensure-sqlite-schema.mjs
  • scripts/seed_availability.js
  • scripts/with-database-url.mjs
  • src/app/(auth)/callback/route.ts
  • src/app/(auth)/forgot-password/page.tsx
  • src/app/(auth)/layout.tsx
  • src/app/(auth)/login/page.tsx
  • src/app/(auth)/reset-password/complete/page.tsx
  • src/app/(auth)/signup/additional-info/page.tsx
  • src/app/(auth)/signup/page.tsx
  • src/app/api/auth/callback/route.ts
  • src/app/api/auth/dev-bypass/route.ts
  • src/app/api/auth/forgot-password/route.ts
  • src/app/api/auth/login/route.ts
  • src/app/api/auth/logout/route.ts
  • src/app/api/auth/me/route.ts
  • src/app/api/auth/naver/callback/route.ts
  • src/app/api/auth/naver/login/route.ts
  • src/app/api/auth/profile/complete/route.ts
  • src/app/api/auth/reset-password/complete/route.ts
  • src/app/api/auth/signup/route.ts
  • src/app/api/calendar/status/route.ts
  • src/app/api/everytime/timetable/route.ts
  • src/app/api/google/auth/route.ts
  • src/app/api/google/calendars/route.ts
  • src/app/api/google/callback/route.ts
  • src/app/api/google/disconnect/route.ts
  • src/app/api/google/events/create/route.ts
  • src/app/api/google/events/query/route.ts
  • src/app/api/icloud/calendars/route.ts
  • src/app/api/icloud/connect/route.ts
  • src/app/api/icloud/disconnect/route.ts
  • src/app/api/icloud/events/create/route.ts
  • src/app/api/icloud/events/query/route.ts
  • src/app/api/schedules/[id]/availability/route.ts
  • src/app/api/schedules/[id]/route.ts
  • src/app/api/schedules/route.ts
  • src/app/calendar/connect/page.tsx
  • src/app/dashboard/layout.tsx
  • src/app/dashboard/meetings/page.tsx
  • src/app/dashboard/page.tsx
  • src/app/dashboard/settings/page.tsx
  • src/app/dev-hub/page.tsx
  • src/app/globals.css
  • src/app/layout.tsx
  • src/app/page.tsx
  • src/app/reference/page.tsx
  • src/app/schedule/[id]/ScheduleRoomClient.tsx
  • src/app/schedule/[id]/page.tsx
  • src/app/schedule/create/CreateScheduleClient.tsx
  • src/app/schedule/create/page.tsx
  • src/components/moim/TermsModal.tsx
  • src/components/moim/auth-social.tsx
  • src/components/moim/reference-ui.tsx
  • src/components/ui/button.tsx
  • src/components/ui/scroll-reveal.tsx
  • src/features/auth/__tests__/social-profile.schema.test.ts
  • src/features/auth/login.schema.ts
  • src/features/auth/signup.schema.ts
  • src/features/auth/social-profile.schema.ts
  • src/features/calendar/lib/auth.ts
  • src/features/calendar/lib/google.ts
  • src/lib/__tests__/crypto.test.ts
  • src/lib/auth/fetch-with-timeout.ts
  • src/lib/auth/session.ts
  • src/lib/caldav/__tests__/client.test.ts
  • src/lib/caldav/__tests__/create.test.ts
  • src/lib/caldav/__tests__/query.test.ts
  • src/lib/caldav/client.ts
  • src/lib/caldav/connection-cookie.ts
  • src/lib/caldav/create.ts
  • src/lib/caldav/discovery.ts
  • src/lib/caldav/query.ts
  • src/lib/calendar/adapter.ts
  • src/lib/calendar/adapters/__tests__/google.test.ts
  • src/lib/calendar/adapters/__tests__/icloud.test.ts
  • src/lib/calendar/adapters/__tests__/manual.test.ts
  • src/lib/calendar/adapters/google.ts
  • src/lib/calendar/adapters/icloud.ts
  • src/lib/calendar/adapters/manual.ts
  • src/lib/calendar/adapters/photo.ts
  • src/lib/crypto.ts
  • src/lib/everytime/__tests__/auth.test.ts
  • src/lib/everytime/__tests__/converter.test.ts
  • src/lib/everytime/__tests__/ics-converter.test.ts
  • src/lib/everytime/__tests__/timetable.test.ts
  • src/lib/everytime/__tests__/url-scraper.test.ts
  • src/lib/everytime/auth.ts
  • src/lib/everytime/converter.ts
  • src/lib/everytime/ics-converter.ts
  • src/lib/everytime/timetable.ts
  • src/lib/everytime/url-scraper.ts
  • src/lib/google/__tests__/auth.test.ts
  • src/lib/google/__tests__/calendars.test.ts
  • src/lib/google/__tests__/events.test.ts
  • src/lib/google/auth.ts
  • src/lib/google/calendars.ts
  • src/lib/google/events.ts
  • src/lib/ics/__tests__/builder.test.ts
  • src/lib/ics/__tests__/parser.test.ts
  • src/lib/ics/builder.ts
  • src/lib/ics/parser.ts
  • src/lib/prisma.ts
  • src/lib/schedules/__tests__/store.test.ts
  • src/lib/schedules/host-cookie.ts
  • src/lib/schedules/store.ts
  • src/lib/scheduling/__tests__/availability-date.test.ts
  • src/lib/scheduling/__tests__/availability.test.ts
  • src/lib/scheduling/__tests__/free-slots.test.ts
  • src/lib/scheduling/__tests__/ics-parser.test.ts
  • src/lib/scheduling/__tests__/time-slot.test.ts
  • src/lib/scheduling/availability.ts
  • src/lib/scheduling/free-slots.ts
  • src/lib/scheduling/ics-parser.ts
  • src/lib/scheduling/time-slot.ts
  • src/lib/supabase/__tests__/supabase.test.ts
  • src/lib/supabase/admin.ts
  • src/lib/supabase/client.ts
  • src/lib/supabase/env.ts
  • src/lib/supabase/server.ts
  • src/lib/utils.ts
  • src/middleware.ts
  • src/shared/lib/supabase/client.ts
  • src/shared/lib/supabase/middleware.ts
  • src/shared/lib/supabase/server.ts
  • src/test-setup.ts
  • src/types/calendar-event.ts
  • src/types/everytime.ts
  • src/types/google-calendar.ts
  • src/types/icloud.ts
  • src/types/schedule.ts
  • src/types/user.ts
  • supabase/migrations/20260406000000_initial_schema.sql
  • supabase/migrations/20260406000001_rls_policies.sql
  • supabase/migrations/20260413000000_init_profiles.sql
  • supabase/migrations/20260414000000_icloud_connections.sql
  • supabase/migrations/20260415000000_google_connections.sql
  • supabase/migrations/20260416000000_scheduling_sessions.sql
  • supabase/migrations/20260607000000_profiles_auth_fields.sql
  • tailwind.config.ts
  • tsconfig.json
  • vitest.config.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Siul49

Siul49 commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai \src/lib/\ 디렉토리와 데이터베이스 관련 변경 사항에 대한 세부 리뷰를 요청합니다.

  • 특히 \�ddParticipantAvailability\의 upsert 로직 등 트랜잭션 처리 중 발생할 수 있는 동시성 이슈(Race Condition)나 에러 발생 가능성이 있는지 검증해 주세요.
  • 비즈니스 로직에서의 데이터 유효성 검증(Validation) 누락이나 예외 처리(Error handling) 취약점을 중점적으로 리뷰해 주세요.

@Siul49

Siul49 commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai \src/app/\ 디렉토리(특히 인증 및 로그인 리디렉션 흐름, \signup/page.tsx, \login/page.tsx, /api/auth/callback\ 등)에 대한 세부 리뷰를 요청합니다.

  • Next.js App Router의 Server/Client 컴포넌트 경계가 잘 나누어져 있는지 검사해 주세요.
  • Supabase Auth를 통한 쿠키 세션 발급/갱신 처리 및 미들웨어(\middleware.ts)와의 흐름 정합성 중 보안 취약점이나 리디렉션 튕김 요인이 될 만한 곳이 있는지 검증해 주세요.

@Siul49

Siul49 commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai UI 컴포넌트(\src/components/)와 모바일 조작 스크롤 간섭 제어, 그리고 Playwright E2E 테스트(\e2e/) 코드에 대한 세부 리뷰를 요청합니다.

  • 모바일에서 터치 드래그로 그리드를 칠할 때 발생할 수 있는 이벤트 버블링/터치 이벤트 누락 요인이 있는지 확인해 주세요.
  • E2E 테스트 코드(\host-flow.spec.ts, \participant-flow.spec.ts)에서 발생할 수 있는 비결정적 테스트(Flaky Test) 가능성이 있는지 리뷰해 주세요.

@kokkumong
kokkumong self-requested a review June 11, 2026 05:02
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Repository root files"
fd -d 2 '^(middleware|package|next\.config|tsconfig|supabase|README)' . || true

echo
echo "## Target auth/app files"
fd -i '^(page|route|middleware)\.(tsx|ts|js|jsx)$' . | rg -n '(^|/)src/(app|middleware)|signup|login|callback|auth|middleware' || true

echo
echo "## Supabase/auth utility files"
fd -i '(supabase|auth|middleware|session|redirect|callback).*\.(ts|tsx|js|jsx)$' . | sort || true

echo
echo "## Relevant source tree under src/app (auth/login/signup/callback only)"
fd . src/app -t f 2>/dev/null | rg -n '/(auth|login|signup|api/auth|callback)|middleware' || true

echo
echo "## Read target files if present"
for f in \
  src/app/signup/page.tsx \
  src/app/login/page.tsx \
  src/app/api/auth/callback/route.ts \
  src/middleware.ts \
  middleware.ts
do
  if [ -f "$f" ]; then
    echo
    echo "===== $f ====="
    nl -ba "$f" | sed -n '1,260p'
  fi
done

echo
echo "## Search auth redirect/session/cookie usage with context"
rg -n -C 3 --type-add 'tsx:*.tsx' --type-add 'ts:*.ts' \
  '(signInWithOAuth|exchangeCodeForSession|getUser|getSession|cookies\(|redirectTo|next=|redirect=|NextResponse\.redirect|middleware|createServerClient|createBrowserClient|supabase\.auth|auth\.callback|callbackUrl|returnTo|origin|URLSearchParams)' \
  src middleware.ts 2>/dev/null || true

Length of output: 4616


🏁 Script executed:

#!/bin/bash
set -euo pipefail

print_file() {
  f="$1"
  if [ -f "$f" ]; then
    echo
    echo "===== $f ====="
    awk '{printf "%5d  %s\n", NR, $0}' "$f" | sed -n '1,320p'
  else
    echo "MISSING: $f"
  fi
}

for f in \
  'src/app/(auth)/signup/page.tsx' \
  'src/app/(auth)/login/page.tsx' \
  'src/app/(auth)/signup/additional-info/page.tsx' \
  'src/app/api/auth/callback/route.ts' \
  'src/app/api/auth/login/route.ts' \
  'src/app/api/auth/signup/route.ts' \
  'src/app/api/auth/logout/route.ts' \
  'src/app/api/auth/me/route.ts' \
  'src/app/api/auth/profile/complete/route.ts' \
  'src/lib/auth/session.ts' \
  'src/middleware.ts'
do
  print_file "$f"
done

echo
echo "## Supabase client helpers"
fd -i 'supabase.*\.(ts|tsx|js|jsx)$' src | while read -r f; do print_file "$f"; done

echo
echo "## Auth/social component usage"
print_file 'src/components/moim/auth-social.tsx'

echo
echo "## Cross-file auth redirect/cookie references"
rg -n -C 4 --type-add 'tsx:*.tsx' --type-add 'ts:*.ts' \
  '(signInWithOAuth|exchangeCodeForSession|getUser|getSession|setSession|refreshSession|cookies\(|redirectTo|next=|redirect=|NextResponse\.redirect|createServerClient|createBrowserClient|createMiddlewareClient|supabase\.auth|URLSearchParams|searchParams|callbackUrl|returnTo|origin|x-forwarded|DEV_BYPASS|dev-bypass)' \
  src 2>/dev/null || true

Length of output: 122412


🏁 Script executed:

#!/bin/bash
set -euo pipefail

print_file() {
  f="$1"
  if [ -f "$f" ]; then
    echo
    echo "===== $f ====="
    awk '{printf "%5d  %s\n", NR, $0}' "$f" | sed -n '1,260p'
  else
    echo "MISSING: $f"
  fi
}

echo "## signup tail with social OAuth handler"
if [ -f 'src/app/(auth)/signup/page.tsx' ]; then
  awk '{printf "%5d  %s\n", NR, $0}' 'src/app/(auth)/signup/page.tsx' | sed -n '320,390p'
fi

for f in \
  'src/lib/supabase/server.ts' \
  'src/lib/supabase/client.ts' \
  'src/lib/supabase/env.ts' \
  'src/app/api/auth/dev-bypass/route.ts' \
  'src/features/auth/signup.schema.ts' \
  'src/features/auth/login.schema.ts' \
  'src/features/auth/social-profile.schema.ts'
do
  print_file "$f"
done

Length of output: 14904



@Siul49 요청하신 src/app/ 인증/로그인 리디렉션 흐름과 Supabase 세션/middleware 정합성 위주로 확인했습니다.

핵심 결론

  • Next.js App Router의 Server/Client 컴포넌트 경계는 전반적으로 적절합니다.

    • login/page.tsx, signup/page.tsx, signup/additional-info/page.tsx"use client"로 선언되어 있고, 브라우저 Supabase client 또는 fetch()만 사용합니다.
    • src/app/api/auth/*/route.tssrc/middleware.ts는 서버 전용 Supabase client/cookie API를 사용하고 있어 큰 경계 위반은 보이지 않습니다.
  • 다만 리디렉션 URL 검증 로직에는 보안상 보완이 필요합니다.

    • 특히 next/redirect 파라미터가 여러 곳에서 중복 처리되고 있고, //evil.com 같은 protocol-relative URL을 허용할 수 있습니다.

발견 사항

1. [High] next/redirect 검증이 protocol-relative URL을 허용해 open redirect 가능성이 있습니다

관련 위치:

  • src/app/(auth)/login/page.tsx Line 50-58
  • src/app/(auth)/signup/page.tsx Line 130-141
  • src/app/(auth)/signup/page.tsx Line 335-352
  • src/app/api/auth/callback/route.ts Line 7, Line 13

현재 클라이언트 검증은 대략 다음 조건입니다.

if (next && (next.startsWith("/") || !next.includes("://"))) {
  redirectUrl = next;
}

이 조건은 //evil.examplestartsWith("/") === true라서 통과합니다.
특히 이메일/비밀번호 로그인 흐름에서는 Line 58에서 바로:

window.location.href = redirectUrl;

를 수행하므로 /login?next=//evil.example 형태가 들어오면 브라우저가 외부 도메인으로 이동할 수 있습니다.

OAuth callback 쪽도 서버에서 next를 재검증하지 않고 Line 13에서 바로 사용합니다.

return NextResponse.redirect(`${origin}${next}`);

클라이언트에서 검증했다는 가정에 의존하면 안 됩니다. /api/auth/callback은 직접 호출 가능한 서버 엔드포인트이므로 서버 측에서 반드시 same-origin relative path만 허용해야 합니다.

권장 방향:

function safeRedirectPath(value: string | null, fallback = "/schedule/create") {
  if (!value) return fallback;

  try {
    // 반드시 현재 origin 기준 URL로 파싱
    const url = new URL(value, "https://placeholder.local");

    // 절대 URL, protocol-relative URL 차단
    if (value.startsWith("//")) return fallback;
    if (!value.startsWith("/")) return fallback;

    // 필요하면 허용 경로 allowlist 적용
    return `${url.pathname}${url.search}${url.hash}`;
  } catch {
    return fallback;
  }
}

그리고 callback route에서는 예를 들어:

const next = safeRedirectPath(searchParams.get("next"), "/schedule/create");
return NextResponse.redirect(new URL(next, origin));

처럼 처리하는 것이 안전합니다.


2. [Medium] src/app/api/auth/callback/route.ts에서 malformed next가 500 또는 의도치 않은 redirect를 만들 수 있습니다

src/app/api/auth/callback/route.ts Line 7:

const next = searchParams.get("next") ?? "/";

Line 13:

return NextResponse.redirect(`${origin}${next}`);

next/dashboard처럼 정상적인 path면 문제없지만, dashboard, ?x=1, https://..., 제어문자 포함 문자열 등 비정상 입력에 대해 명시적 방어가 없습니다.

이 route는 인증 완료 후 세션 쿠키를 발급하는 중요한 지점이므로, 실패 시 단순히 /login?error=auth_failed로 보내는 것 외에도:

  • next 정규화
  • 허용 경로 제한
  • malformed redirect 방지
  • exchangeCodeForSession() 예외 처리

를 넣는 것이 좋습니다.


3. [Medium] middleware가 보호 경로 redirect 시 query string을 보존하지 않습니다

src/middleware.ts Line 53-55:

const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", request.nextUrl.pathname);

현재는 pathname만 저장하므로 /calendar/connect?from=schedule 같은 요청은 로그인 후 /calendar/connect로만 복귀합니다.

현재 보호 경로는:

const PROTECTED_ROUTES = ["/schedule/create", "/calendar/connect"];

라서 치명적이지는 않지만, 인증 후 원래 흐름 복귀를 목표로 한다면 query도 보존하는 편이 안전합니다.

권장:

const nextPath =
  request.nextUrl.pathname + request.nextUrl.search + request.nextUrl.hash;

loginUrl.searchParams.set("next", nextPath);

단, 이 값을 다시 사용할 때는 위의 safeRedirectPath() 같은 검증을 반드시 적용해야 합니다.


4. [Medium/Dev-only] development mock session cookie는 서명/HttpOnly 없이 임의 세션 주입이 가능합니다

관련 위치:

  • src/middleware.ts Line 35-43
  • src/lib/supabase/server.ts Line 35-44
  • src/app/api/auth/dev-bypass/route.ts Line 72-88

development 또는 E2E 환경에서 e2e_mock_uid, e2e_mock_email 쿠키를 세션처럼 인정합니다. Production에서는 NODE_ENV === "development" 조건이 꺼지므로 직접적인 production 취약점은 아닙니다.

다만 dev/staging 환경이 외부 접근 가능한 형태로 배포되는 경우, 사용자가 임의로 cookie를 심어 mock user로 인식될 수 있습니다.

권장:

  • E2E_TEST === "true"일 때만 mock session 허용
  • development bypass가 필요하면 signed cookie 또는 서버 측 nonce 검증 추가
  • 최소한 mock cookie에 httpOnly, sameSite, secure 옵션을 명시

5. [Low] remember 값이 API로 전달되지만 실제 세션 정책에는 반영되지 않습니다

관련 위치:

  • src/app/(auth)/login/page.tsx Line 12, Line 40
  • src/features/auth/login.schema.ts Line 6
  • src/app/api/auth/login/route.ts Line 30 이후

클라이언트는 remember를 전달하지만, route handler에서는 구조분해하지 않고 Supabase session duration에도 반영하지 않습니다.

Supabase Auth의 세션 유지 정책을 프로젝트 설정으로 고정할 의도라면 괜찮지만, UI에 “로그인 유지”가 표시되는 이상 사용자는 동작 차이를 기대할 수 있습니다.
사용하지 않을 계획이면 UI 제거, 사용할 계획이면 별도 정책/문구 정리가 필요합니다.


긍정적으로 확인된 부분

  • src/lib/auth/session.ts에서 getSession()getSession()이 아니라 supabase.auth.getUser()를 사용하고 있어, 쿠키 기반 세션을 서버에서 검증하는 방향은 적절합니다.
  • src/middleware.ts에서 Supabase SSR client의 setAll() cookie를 redirectResponse에 전파하려는 처리는 session refresh와 redirect가 겹칠 때 필요한 방어입니다.
  • 회원가입/추가정보 API는 zod schema로 서버 측 validation을 수행하고 있어, 클라이언트 validation에만 의존하지 않습니다.
  • signup/page.tsx의 소셜 로그인 버튼은 로그인 페이지와 동일하게 client-side signInWithOAuth()를 사용하도록 맞춰져 있습니다.

우선 수정 추천 순서

  1. next/redirect를 처리하는 공통 helper 작성
    • //..., absolute URL, non-path 문자열 차단
    • path + query + hash만 허용
  2. login/page.tsx, signup/page.tsx, api/auth/callback/route.ts, middleware.ts에서 동일 helper/동일 정책 적용
  3. middleware에서 query string 보존
  4. development mock session 허용 조건을 E2E_TEST === "true" 중심으로 축소
  5. remember UI/동작 정합성 정리

가장 중요한 건 리디렉션 파라미터를 클라이언트에서만 검증하지 말고, callback route에서도 반드시 동일하게 재검증하는 것입니다.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== src/lib/ directory structure ==="
find src/lib -type f | sort

echo ""
echo "=== src/app/api directory structure ==="
find src/app/api -type f | sort 2>/dev/null || echo "not found"

echo ""
echo "=== Search for addParticipantAvailability / upsert ==="
rg -rn "addParticipantAvailability\|upsert" src/ --type ts -A 5 -B 2

echo ""
echo "=== Search for participant submission API routes ==="
rg -rn "participant\|availability\|submit" src/app/api --type ts -l 2>/dev/null || echo "not found"

Length of output: 3849


🏁 Script executed:

#!/bin/bash
echo "=== All lib files content ==="
find src/lib -type f -name "*.ts" | sort | while read f; do
  echo "======= FILE: $f ======="
  cat "$f"
  echo ""
done

Length of output: 192793


🏁 Script executed:

#!/bin/bash
echo "=== API routes for participant/availability/schedule ==="
find src/app/api -type f -name "*.ts" | sort | while read f; do
  echo "======= FILE: $f ======="
  cat "$f"
  echo ""
done

Length of output: 53703


🏁 Script executed:

#!/bin/bash
echo "=== Search for supabase upsert calls ==="
rg -rn "\.upsert\(" src/ --type ts -A 8 -B 3

echo ""
echo "=== Search for transaction or BEGIN/COMMIT ==="
rg -rn "BEGIN\|COMMIT\|ROLLBACK\|rpc\|transaction" src/ --type ts -A 3 -B 2

echo ""
echo "=== Search for validation schemas (zod/yup/joi) ==="
rg -rn "z\.object\|z\.string\|schema\|validate\|safeParse\|parse(" src/ --type ts -l

Length of output: 1792



@Siul49 src/lib/ 전체와 API 라우트 코드를 면밀히 분석했습니다. 아래에 발견된 이슈들을 심각도별로 정리합니다.


🔴 Critical — 동시성 / Race Condition

1. addParticipantAvailability — DB 유니크 제약 없는 Check-Then-Act

파일: src/lib/schedules/store.ts

const existing = await tx.scheduleParticipant.findFirst({
  where: { scheduleId, name: normName },
});

if (existing) {
  return tx.scheduleParticipant.update({ ... });
}
return tx.scheduleParticipant.create({ ... }); // ← 동시 요청 시 중복 생성 가능

Serializable 격리 레벨을 사용하지만, PostgreSQL의 SSI(Serializable Snapshot Isolation)는 비존재 행(phantom row)의 삽입에 대한 반읽기-쓰기 anti-dependency를 항상 감지하지 못합니다. 즉, 두 요청이 동시에 들어오면:

시각 요청 A 요청 B
T1 findFirst → null
T2 findFirst → null
T3 create 성공
T4 create 성공 → 중복 참여자

해결 방법: Prisma 스키마에 @@unique([scheduleId, name]) 제약을 추가하고, upsert로 전환하거나 create 실패 시 고유 제약 위반을 catch하도록 처리해야 합니다.

// Prisma schema에 추가 (prisma/schema.prisma)
model ScheduleParticipant {
  // ...
  @@unique([scheduleId, name])
}
// store.ts: upsert 방식으로 교체
return tx.scheduleParticipant.upsert({
  where: { scheduleId_name: { scheduleId, name: normName } },
  create: {
    id: createToken(12),
    scheduleId,
    name: normName,
    available: JSON.stringify(normalizeAvailability(schedule, input.available)),
  },
  update: {
    available: JSON.stringify(normalizeAvailability(schedule, input.available)),
    submittedAt: new Date(),
  },
});

2. updateMany 쓰기 잠금 패턴이 의미론적으로 취약

const updateResult = await tx.schedule.updateMany({
  where: { id: scheduleId, status: "open" },
  data: { status: "open" }, // 실제 변경 없음 — "터치" 전략
});

status: "open"status: "open" 쓰기는 PostgreSQL에서 행 수정으로 인식되어 직렬화 충돌 감지에 도움이 되지만, 일부 ORM/드라이버 최적화 또는 DB 버전에서 no-op 쓰기를 스킵할 수 있습니다. status: "processing" 같은 임시 상태로 실제 변경 후 롤백하거나, 별도 버전 카운터 컬럼(version)을 활용한 Optimistic Lock 패턴이 더 안전합니다.


🟠 High — 유효성 검증 / 에러 처리

3. API 라우트에서 요청 본문 스키마 검증 누락

파일: src/app/api/schedules/[id]/availability/route.ts

const participant = await addParticipantAvailability(
  id,
  await request.json(), // ← null, [], 또는 비객체가 그대로 전달됨
);

request.json()이 반환할 수 있는 null, 배열, 원시형이 store.tsnormalizeParticipantName(input.name) 등에서 TypeScript 타입 안전성 없이 처리됩니다. Zod 스키마 검증을 API 레이어에 추가해야 합니다.

import { z } from "zod";

const AvailabilitySchema = z.object({
  name: z.string().min(1).max(40),
  available: z.array(z.object({
    day: z.enum(["MON","TUE","WED","THU","FRI","SAT","SUN"]),
    startHour: z.number().int().min(0).max(23),
    endHour: z.number().int().min(1).max(24),
  })).min(1),
});

// POST 핸들러 내부:
const parsed = AvailabilitySchema.safeParse(await request.json());
if (!parsed.success) {
  return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const participant = await addParticipantAvailability(id, parsed.data);

4. 내부 에러 메시지 클라이언트 노출 (에러 메시지 유출)

파일: src/app/api/schedules/[id]/availability/route.ts, src/app/api/schedules/[id]/route.ts

const message = error instanceof Error ? error.message : "invalid request";
// Prisma 내부 오류, DB 연결 오류 메시지가 그대로 응답에 포함됨
return NextResponse.json({ error: message }, { status });

Prisma 에러(PrismaClientKnownRequestError 등)나 내부 로직 오류 메시지가 클라이언트에 노출됩니다. 에러 타입을 구분하여 사용자 친화적 메시지만 반환하고 상세 내용은 서버 로그에만 기록해야 합니다.

} catch (error) {
  if (error instanceof Error) {
    const knownMessages = ["schedule not found", "schedule is not open", "availability must"];
    const isKnown = knownMessages.some(m => error.message.includes(m));
    if (isKnown) {
      return NextResponse.json({ error: error.message }, { status: error.message === "schedule not found" ? 404 : 400 });
    }
  }
  console.error("[availability] 서버 오류:", error);
  return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
}

🟡 Medium — 보안 / 데이터 안정성

5. dev-bypass 쿠키에 httpOnly 플래그 누락

파일: src/app/api/auth/dev-bypass/route.ts

res.cookies.set("e2e_mock_uid", user.id, {
  path: "/",
  maxAge: 60 * 60 * 24,
  // httpOnly: true 누락 — JavaScript에서 접근 가능
});

E2E 모드라도 세션 쿠키에 httpOnly: true를 추가해야 XSS로 인한 세션 탈취를 방지합니다.

6. 개발 모드에서 잔여 E2E 쿠키로 인한 인증 우회

파일: src/lib/supabase/server.ts

if (process.env.E2E_TEST === "true" || process.env.NODE_ENV === "development") {
  const mockUid = cookieStore.get("e2e_mock_uid")?.value;
  const mockEmail = cookieStore.get("e2e_mock_email")?.value;

  if (process.env.E2E_TEST === "true" || (mockUid && mockEmail)) {
    // ← 개발 모드에서 이전 테스트 잔여 쿠키만 있어도 목 인증 활성화

개발 환경에서 이전 E2E 테스트 쿠키가 브라우저에 남아있으면 실제 Supabase 인증 없이 목 유저로 동작합니다. E2E_TEST === "true" 조건만으로 목 활성화를 제한하는 것을 권장합니다.

// 수정 제안: E2E_TEST 환경변수로만 제한
if (process.env.E2E_TEST === "true") {
  // 목 인증 처리
}

7. reset-password/complete — 빈 refresh_token 전달

파일: src/app/api/auth/reset-password/complete/route.ts

await supabase.auth.setSession({
  access_token: token,
  refresh_token: "", // ← 빈 문자열
});

Supabase SDK 버전에 따라 빈 refresh_token으로 setSession 호출 시 예기치 않은 동작이 발생할 수 있습니다. 비밀번호 재설정 토큰 처리는 verifyOtp 또는 exchangeCodeForSession을 활용하는 것이 공식 권장 방식입니다.


🔵 Low — 코드 품질 / 잠재 위험

8. clearSchedules() — 프로덕션 가드 없음

export async function clearSchedules() {
  await prisma.$transaction([
    prisma.scheduleParticipant.deleteMany(), // 전체 삭제
    prisma.schedule.deleteMany(),            // 전체 삭제
  ]);
}

테스트 유틸리티 함수가 export되어 있어 실수로 프로덕션 코드에서 호출될 위험이 있습니다. 테스트 전용 파일로 분리하거나, 환경 가드를 추가하는 것이 안전합니다.

export async function clearSchedules() {
  if (process.env.NODE_ENV === "production") {
    throw new Error("clearSchedules()는 프로덕션에서 사용할 수 없습니다.");
  }
  // ...
}

9. createAdminClient — 모듈 레벨 싱글턴 캐싱

파일: src/lib/supabase/admin.ts

let cached: SupabaseClient | null = null;
export function createAdminClient(): SupabaseClient {
  if (cached) return cached;
  // ...
  cached = createClient(url, serviceRoleKey, ...);
  return cached;
}

환경변수가 런타임에 교체되는 테스트 환경이나 핫리로딩 시 구 설정이 사용될 수 있습니다. Next.js의 서버 컴포넌트 환경에서는 허용되지만, 의도를 명시하는 주석 추가를 권장합니다.


요약 테이블

심각도 이슈 파일
🔴 Critical DB 유니크 제약 없는 upsert 경쟁 조건 src/lib/schedules/store.ts
🔴 Critical updateMany no-op 잠금 패턴 취약성 src/lib/schedules/store.ts
🟠 High API 레이어 요청 스키마 검증 누락 src/app/api/schedules/[id]/availability/route.ts
🟠 High 내부 에러 메시지 클라이언트 노출 src/app/api/schedules/[id]/availability/route.ts, [id]/route.ts
🟡 Medium 세션 쿠키 httpOnly 누락 src/app/api/auth/dev-bypass/route.ts
🟡 Medium 개발 모드 E2E 쿠키 잔존 시 인증 우회 src/lib/supabase/server.ts
🟡 Medium refresh_token으로 세션 설정 src/app/api/auth/reset-password/complete/route.ts
🔵 Low clearSchedules 프로덕션 가드 없음 src/lib/schedules/store.ts
🔵 Low 관리자 클라이언트 싱글턴 캐싱 src/lib/supabase/admin.ts

가장 우선적으로 처리해야 할 항목은 ① DB 유니크 제약 추가 + upsert 전환 (중복 참여자 방지)과 ② API 레이어 Zod 스키마 검증 (비정상 입력 방어)입니다. 궁금한 점이 있으시면 말씀해 주세요!

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find grid/schedule related components with touch event handling
echo "=== Touch event related files ==="
rg -rn "touchmove|touchstart|touchend|onTouchMove|onTouchStart|onTouchEnd|passive" src/components/ --type tsx -l 2>/dev/null || \
rg -rn "touchmove|touchstart|touchend|onTouchMove|onTouchStart|onTouchEnd|passive" src/components/ -l

echo ""
echo "=== Grid component files ==="
fd -e tsx -e ts "grid|Grid|schedule|Schedule|timetable|Timetable" src/components/

echo ""
echo "=== E2E test files ==="
fd -e ts -e spec.ts "host-flow|participant-flow" e2e/
fd -e ts "spec" e2e/

Length of output: 859


🏁 Script executed:

#!/bin/bash
# Read E2E test files
echo "=== host-flow.spec.ts ==="
cat e2e/host-flow.spec.ts 2>/dev/null || fd "host-flow" e2e/ --exec cat {}

echo ""
echo "=== participant-flow.spec.ts ==="
cat e2e/participant-flow.spec.ts 2>/dev/null || fd "participant-flow" e2e/ --exec cat {}

Length of output: 5592


🏁 Script executed:

#!/bin/bash
# Find touch event handling in components
rg -rn "touchmove|touchstart|touchend|addEventListener|removeEventListener|passive" src/components/ -A 3 -B 1

echo ""
echo "=== All component files with drag logic ==="
rg -rn "isDragging|onMouseDown|onMouseMove|onTouchStart|dragStart" src/components/ -l

Length of output: 597


🏁 Script executed:

#!/bin/bash
echo "=== Directory structure of src/components/ ==="
tree src/components/ 2>/dev/null || find src/components/ -type f | head -60

echo ""
echo "=== Search for touch events anywhere in src/ ==="
rg -rn "touchmove|touchstart|touchend|addEventListener.*touch|passive.*false" src/ -l

echo ""
echo "=== Search for drag logic anywhere in src/ ==="
rg -rn "isDragging|dragStart|onMouseDown.*slot|onTouchStart.*slot|useRef.*drag" src/ -l

Length of output: 994


🏁 Script executed:

#!/bin/bash
echo "=== Full src directory structure ==="
find src/ -type f -name "*.tsx" -o -name "*.ts" | grep -v "node_modules" | sort | head -80

echo ""
echo "=== Playwright config ==="
cat playwright.config.ts 2>/dev/null || cat playwright.config.js 2>/dev/null

Length of output: 5094


🏁 Script executed:

#!/bin/bash
echo "=== CreateScheduleClient.tsx ==="
cat src/app/schedule/create/CreateScheduleClient.tsx

echo ""
echo "=== ScheduleRoomClient.tsx ==="
cat src/app/schedule/[id]/ScheduleRoomClient.tsx

Length of output: 83418



@Siul49 아래에 src/app/schedule/*/ 내 터치 드래그 컴포넌트와 e2e/ 테스트 코드에 대한 세부 리뷰를 정리해 드립니다.

참고: 터치 이벤트 처리 로직은 src/components/가 아닌 src/app/schedule/create/CreateScheduleClient.tsxsrc/app/schedule/[id]/ScheduleRoomClient.tsx에 위치하고 있습니다.


🟥 터치 드래그 — 치명적 Race Condition (양 파일 공통)

문제: preventDefaultTouch 클로저가 첫 touchmove에서 항상 stale

두 파일 모두 아래와 같은 패턴을 사용합니다.

useEffect(() => {
  const gridEl = gridRef.current;
  if (!gridEl) return;

  const preventDefaultTouch = (e: TouchEvent) => {
    if (isDragging) {           // ← 클로저로 isDragging을 캡처
      if (e.cancelable) e.preventDefault();
    }
  };

  gridEl.addEventListener("touchmove", preventDefaultTouch, { passive: false });
  return () => {
    gridEl.removeEventListener("touchmove", preventDefaultTouch);
  };
}, [isDragging]);  // ← isDragging 변경 시마다 re-register

문제 흐름:

  1. 사용자가 셀을 터치 → handleTouchStartsetIsDragging(true) (state 업데이트 예약)
  2. React 렌더링 완료 전에 브라우저가 첫 번째 touchmove를 즉시 발사
  3. 이 시점에서 effect cleanup/re-register는 아직 실행되지 않은 상태
  4. 기존 리스너의 클로저에는 isDragging = falsee.preventDefault() 호출 안 됨
  5. 브라우저 스크롤이 개시됨

수정 방법: ref로 isDragging 값을 동기적으로 추적합니다.

const isDraggingRef = useRef(false);

// 드래그 시작 시
const handleTouchStart = (e: React.TouchEvent, key: string) => {
  isDraggingRef.current = true;
  setIsDragging(true);
  // ...
};

const handleTouchEnd = () => {
  isDraggingRef.current = false;
  setIsDragging(false);
  setDragAction(null);
};

// 리스너를 한 번만 등록, 의존성 배열에서 isDragging 제거
useEffect(() => {
  const gridEl = gridRef.current;
  if (!gridEl) return;

  const preventDefaultTouch = (e: TouchEvent) => {
    if (isDraggingRef.current && e.cancelable) {
      e.preventDefault();
    }
  };

  gridEl.addEventListener("touchmove", preventDefaultTouch, { passive: false });
  return () => {
    gridEl.removeEventListener("touchmove", preventDefaultTouch);
  };
}, []); // 마운트 시 한 번만 등록

🟧 중복 mouseup 글로벌 리스너 (CreateScheduleClient.tsx)

CreateScheduleClient.tsx에는 window.addEventListener("mouseup", ...) 을 등록하는 useEffect두 개 존재합니다.

// Effect 1 (Line ~158): mouseup + touchend 모두 등록
useEffect(() => {
  const handleGlobalUp = () => { setIsDragging(false); setDragAction(null); };
  window.addEventListener("mouseup", handleGlobalUp);
  window.addEventListener("touchend", handleGlobalUp);
  return () => { ... };
}, []);

// Effect 2 (Line ~199): mouseup 다시 등록 (중복!)
useEffect(() => {
  const handleGlobalMouseUp = () => { setIsDragging(false); setDragAction(null); };
  window.addEventListener("mouseup", handleGlobalMouseUp);
  return () => window.removeEventListener("mouseup", handleGlobalMouseUp);
}, []);

두 번째 effect를 제거하세요.


🟧 개별 셀의 onTouchMove 핸들러 불필요

touchmove는 터치가 시작된 요소에서만 발사되므로, 개별 셀에 onTouchMove={handleTouchMove}를 부착하면 손가락이 이동할 때 원본 셀에서만 이벤트가 발생합니다. document.elementFromPoint로 현재 셀을 찾는 방식은 올바르지만, 이 핸들러는 gridRef 컨테이너에 하나만 부착하는 것으로 충분합니다.

// gridRef 컨테이너에만 onTouchMove 부착
<div ref={gridRef} onTouchMove={handleTouchMove} ...>
  {/* 개별 셀에서는 onTouchMove 제거 */}
  <div data-slot-key={key} onTouchStart={...} onTouchEnd={...} ... />
</div>

🟨 E2E 테스트 — Flaky Test 위험 요소

1. waitForTimeout 남용 (host-flow.spec.ts)

await page.waitForTimeout(2000); // React Hydration 대기
await page.waitForTimeout(3000); // 페이지 컴파일 대기
await page.waitForTimeout(1000); // Step 전환 대기

고정 대기 시간은 CI 서버 속도에 따라 너무 짧거나 너무 길어 비결정적 실패의 주원인이 됩니다.

// ❌ 현재
await page.waitForTimeout(2000);
const emailInput = page.locator("`#email`");

// ✅ 개선
const emailInput = page.locator("`#email`");
await emailInput.waitFor({ state: "visible", timeout: 15000 });

2. Step 전환 검증 없이 버튼 연속 클릭

// "다음 단계로 →" 버튼이 Step 1→2, Step 2→3에 모두 사용됨
await page.getByRole("button", { name: "다음 단계로 →" }).click();
await page.waitForTimeout(1000);  // Step이 실제로 바뀌었는지 검증 없음
await page.getByRole("button", { name: "다음 단계로 →" }).click();

클릭이 실패해도 테스트가 조용히 계속 진행됩니다. 각 단계 전환 후 해당 Step의 고유 UI 요소가 나타나는지 확인하세요.

await page.getByRole("button", { name: "다음 단계로 →" }).click();
// Step 2 특유의 텍스트 확인
await expect(page.getByText("캘린더 연동 관리")).toBeVisible();

await page.getByRole("button", { name: "다음 단계로 →" }).click();
// Step 3 특유의 텍스트 확인
await expect(page.getByText("후보 시간대 설정")).toBeVisible();

3. 취약한 비밀번호 입력 셀렉터

// ❌ 인덱스 기반 셀렉터 - DOM 순서 변경 시 즉시 깨짐
const pwInput = page.locator('input[type="password"]').first();
const pwConfirmInput = page.locator('input[type="password"]').last();

// ✅ 개선 - id나 label 기반 셀렉터 사용
const pwInput = page.getByLabel("비밀번호");
const pwConfirmInput = page.getByLabel("비밀번호 확인");

4. participant-flow.spec.ts — 제출 후 검증 없이 호스트 뷰 이동

await page.getByRole("button", { name: "가능 시간 제출" }).click();
await expect(page.getByText("시간 제출 완료! 🎉")).toBeVisible({ timeout: 15000 });

await page.waitForTimeout(2000); // ← DB 반영 대기를 하드코딩된 시간으로 처리
await page.goto(created.hostPath);

API 응답이 완료됐더라도 DB 반영 지연이 있을 수 있습니다. 호스트 뷰 네비게이션 후 getByText("민지")waitFor로 충분한 timeout과 함께 기다리면 더 안정적입니다.

5. 테스트 간 데이터 격리 없음

fullyParallel: true 설정이지만, 테스트 후 생성된 스케줄/참여자 데이터를 정리하는 afterEach/afterAll 훅이 없습니다. 특히 로컬 환경에서 여러 워커가 동시에 실행될 때 간섭이 발생할 수 있습니다. playwright.config.ts에 글로벌 teardown을 추가하거나, 각 테스트에서 생성한 리소스를 명시적으로 삭제하는 것을 권장합니다.


요약

심각도 위치 이슈
🟥 치명 양 파일 preventDefaultTouch closure stale → 첫 touchmove에서 스크롤 방지 실패
🟧 높음 CreateScheduleClient 중복 mouseup 글로벌 리스너
🟧 높음 host-flow.spec.ts waitForTimeout 남용 + step 전환 미검증
🟧 높음 host-flow.spec.ts 비밀번호 input 인덱스 셀렉터
🟨 중간 participant-flow.spec.ts 제출 후 고정 2초 대기
🟨 중간 양 E2E 파일 테스트 데이터 cleanup 없음
🟩 낮음 양 파일 개별 셀 onTouchMove → 컨테이너로 통합 권장

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants