Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical). - 17개 설계서를 Draft → Approved로 갱신 - #267(backend-user)은 critical 결함으로 06-Reviewer 유지 - 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영 (critical 3 / major 46 / minor 75) - docs/README.md에 18개 설계서 인덱스 추가 - CHANGELOG.md 2026-06-15 섹션 추가 Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
200 lines
12 KiB
Markdown
200 lines
12 KiB
Markdown
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지. -->
|
|
|
|
# 설계서: 프론트 - 로그인 메뉴 (#283)
|
|
|
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
|
> **추적성** — Redmine: #283 · 관련 ADR: 없음
|
|
> · 구현 파일: `frontend/src/components/LoginMenu.tsx` · 테스트: TBD (현재 없음)
|
|
|
|
## 1. 목적 (Why)
|
|
헤더에서 비로그인 사용자가 한 번의 클릭으로 Google 소셜 로그인 모달을 띄워 가입/로그인을 완료할 수 있도록, 다른 UI(지도·바텀시트)와 겹치지 않는 **z-index 안전한 모달** 진입점을 제공한다.
|
|
|
|
## 2. 범위 (Scope)
|
|
- **포함**:
|
|
- "로그인" 버튼 (헤더용)
|
|
- 클릭 시 `<body>`에 Portal로 마운트되는 모달 (백드롭 + 카드)
|
|
- Google 로그인 위젯(`@react-oauth/google`)의 콜백을 부모에 전달
|
|
- 백드롭 클릭 / "✕" 버튼으로 닫기
|
|
- 다크모드 색상 토큰 대응
|
|
- **제외 (out of scope)**:
|
|
- 토큰 저장(`localStorage`), 백엔드 검증, 세션 갱신 — 부모(`onGoogleSuccess`)의 책임
|
|
- 카카오/네이버/이메일 로그인 등 추가 프로바이더
|
|
- 로그아웃 UI (별도 컴포넌트)
|
|
- GoogleOAuthProvider 설정 (`_app`/`layout.tsx`에서 처리)
|
|
|
|
## 3. 인수조건 (이미 구현된 동작 기준)
|
|
- [x] "로그인" 버튼이 헤더 스타일(보더+호버 brand 색)로 노출된다.
|
|
- [x] 클릭 시 모달이 열리고, 화면 전체를 덮는 백드롭(`bg-black/40 backdrop-blur-sm`)이 깔린다.
|
|
- [x] 모달은 `createPortal`로 `document.body`에 마운트되어 부모의 stacking context 영향을 받지 않는다.
|
|
- [x] 모달 z-index는 `99999`로 다른 모든 오버레이(지도, 바텀시트)보다 위에 위치한다.
|
|
- [x] 모달 안에 "소셜 계정으로 간편 로그인" 안내와 Google 로그인 버튼(`size=large, width=260`)이 표시된다.
|
|
- [x] Google 로그인 성공 시 `credential`을 부모 `onGoogleSuccess(credential)`로 전달하고 모달을 닫는다.
|
|
- [x] Google 로그인 실패 시 `console.error("Google login failed")` 로깅(UX는 위젯이 처리).
|
|
- [x] 백드롭 영역(자식이 아닌 본인 클릭)에서만 모달이 닫힌다 (`e.target === e.currentTarget`).
|
|
- [x] 우상단 "✕" 버튼으로 모달을 닫을 수 있다.
|
|
- [x] 다크모드 토큰(`dark:text-gray-300`, `dark:border-gray-600`, `dark:hover:border-brand-500`)으로 색이 적응한다.
|
|
|
|
## 4. 컨텍스트 & 제약
|
|
- **프레임워크**: Next.js 16 App Router, Client Component (`"use client"`)
|
|
- **라이브러리**:
|
|
- `@react-oauth/google` — `<GoogleLogin>` 위젯 사용, 상위 어딘가에 `<GoogleOAuthProvider clientId>` 마운트 필요
|
|
- `react-dom`의 `createPortal`
|
|
- **스타일**: Tailwind, Saffron 디자인 토큰 — `bg-surface`, `text-brand-600`, `border-brand-400`
|
|
- **z-index 제약**: 지도/바텀시트/Drawer 등 기존 오버레이가 다수 존재 → Portal + inline style `z-index: 99999` 사용 (Tailwind 클래스가 아닌 인라인으로 적용해 명시적 우선순위 보장)
|
|
- **SSR 안전성**: `createPortal`이 `document.body`를 참조하므로 `"use client"` 필수. SSR 단계에서는 `open=false` 초기 상태로 모달 미렌더 → 안전.
|
|
- **가정**:
|
|
- 부모는 `onGoogleSuccess`에서 백엔드 `/api/auth/google` 호출 및 토큰 저장을 책임진다.
|
|
- GoogleOAuthProvider clientId는 환경 변수(`NEXT_PUBLIC_GOOGLE_CLIENT_ID`)로 주입된다.
|
|
|
|
## 5. 아키텍처 개요
|
|
- 파일:
|
|
- `LoginMenu.tsx` — 단일 컴포넌트
|
|
- 외부:
|
|
- 부모: 헤더 (예: `Header.tsx`, `page.tsx`) — `onGoogleSuccess` 콜백 제공
|
|
- 상위: `<GoogleOAuthProvider>` 마운트 (layout)
|
|
|
|
```
|
|
[Header]
|
|
└─ <LoginMenu onGoogleSuccess={handleGoogle}>
|
|
│
|
|
│ state: open: boolean
|
|
│
|
|
├─ <button "로그인" onClick={() => setOpen(true)}>
|
|
│
|
|
└─ open && createPortal(
|
|
<Backdrop onClick={closeIfBackdrop}>
|
|
<Card>
|
|
<Header (제목 + ✕)>
|
|
<안내 텍스트>
|
|
<GoogleLogin
|
|
onSuccess={(res) => {
|
|
if (res.credential) {
|
|
onGoogleSuccess(res.credential)
|
|
setOpen(false)
|
|
}
|
|
}}
|
|
onError={console.error}>
|
|
</Card>
|
|
</Backdrop>,
|
|
document.body
|
|
)
|
|
|
|
흐름:
|
|
user → click 로그인
|
|
→ open=true
|
|
→ Portal 모달 마운트
|
|
→ Google 위젯 → OAuth popup/iframe
|
|
→ onSuccess(credential)
|
|
→ 부모 핸들러 (백엔드 검증/토큰 저장)
|
|
→ setOpen(false) → Portal 언마운트
|
|
```
|
|
|
|
- **I/O ↔ 순수 로직 경계**:
|
|
- I/O: Google OAuth (위젯 내부), `document.body` 접근, 부모 콜백
|
|
- 순수: `open` 상태기계(open/close), 백드롭 영역 판정 (`e.target === e.currentTarget`)
|
|
|
|
## 6. 데이터 모델
|
|
타입:
|
|
|
|
```ts
|
|
interface LoginMenuProps {
|
|
onGoogleSuccess: (credential: string) => void;
|
|
}
|
|
|
|
// @react-oauth/google
|
|
interface CredentialResponse {
|
|
credential?: string; // Google ID Token (JWT)
|
|
select_by?: string;
|
|
clientId?: string;
|
|
}
|
|
|
|
// 내부 상태
|
|
type LocalState = { open: boolean };
|
|
```
|
|
|
|
- **경계 검증**:
|
|
- `res.credential`이 falsy(undefined/empty)면 부모 콜백을 호출하지 않음 — 빈 토큰 전파 방지.
|
|
- `credential`은 Google ID Token(JWT) 문자열. 검증은 백엔드(서명·aud·exp) 책임.
|
|
- 모달 백드롭 클릭 판정: `e.target === e.currentTarget` — 자식(카드) 클릭은 닫지 않음.
|
|
|
|
## 7. 함수 명세 (Function Specs)
|
|
|
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
|
|------|-----------|----------------|------|------|-----------|-------|
|
|
| `LoginMenu` | 로그인 트리거 + 모달 렌더 | `({ onGoogleSuccess }) => JSX` | `LoginMenuProps` | JSX (button + portal) | onError → console.error | 단순 |
|
|
| `setOpen(true)` (onClick) | 모달 오픈 | inline | MouseEvent | void | - | 단순 |
|
|
| `closeIfBackdrop` | 백드롭 클릭 시 닫기 | inline `(e) => void` | MouseEvent | void | 자식 클릭이면 no-op | 단순 |
|
|
| `setOpen(false)` (✕ onClick) | 모달 강제 닫기 | inline | MouseEvent | void | - | 단순 |
|
|
| `GoogleLogin.onSuccess` | 토큰 추출 → 부모 콜백 → 닫기 | inline `(res) => void` | `CredentialResponse` | void | credential 없으면 무시 | 단순 |
|
|
| `GoogleLogin.onError` | 실패 로깅 | inline `() => void` | - | void | console.error | 단순 |
|
|
|
|
> 모든 함수가 단순. 외부 I/O(Google OAuth)는 위젯이 캡슐화하므로 추가 fn-*.md 불필요.
|
|
|
|
## 8. 흐름 / 알고리즘
|
|
**① 모달 오픈**
|
|
1. 사용자가 "로그인" 버튼 클릭 → `setOpen(true)`
|
|
2. 리렌더에서 `open === true` → `createPortal(...)` 실행 → `<body>` 마지막 자식으로 모달 마운트
|
|
|
|
**② Google 로그인 성공**
|
|
1. `<GoogleLogin>` 위젯 내부에서 Google OAuth 진행
|
|
2. 위젯이 `onSuccess({ credential })` 호출
|
|
3. `if (res.credential)` 가드
|
|
4. `onGoogleSuccess(credential)` — 부모가 백엔드 `/api/auth/google` 호출 등 처리
|
|
5. `setOpen(false)` → Portal 언마운트
|
|
|
|
**③ 모달 닫기 경로**
|
|
- "✕" 버튼: `onClick={() => setOpen(false)}`
|
|
- 백드롭 클릭: `onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}`
|
|
- 카드 내부 클릭: 이벤트가 백드롭까지 버블링되더라도 `e.target !== currentTarget`이므로 닫히지 않음 ✅
|
|
|
|
**④ 실패**
|
|
- `onError`: 위젯이 호출 → `console.error("Google login failed")` 만 수행. 모달은 그대로 열려 있어 재시도 가능.
|
|
|
|
## 9. 엣지케이스 & 에러 처리
|
|
- **`res.credential` 없음**: 사용자가 OAuth 동의를 거부하거나 Google이 credential 없이 응답하는 경우 — 콜백/닫기 모두 skip하여 모달 유지.
|
|
- **GoogleOAuthProvider 미설정**: 위젯이 렌더되지 않거나 콘솔 에러. 사용자에게는 빈 영역으로 보일 수 있음 → 상위 layout 설정 필수 (배포 체크리스트 항목).
|
|
- **`document.body` 미존재 (SSR)**: `open` 초기값 `false` → 초기 렌더에서 `createPortal` 호출 안 함. 클라이언트 hydration 후에만 모달 가능.
|
|
- **모달 열린 상태에서 라우트 이동**: 컴포넌트 언마운트 → Portal도 자동 제거.
|
|
- **다중 모달 z-index 충돌**: 다른 모달들이 99999 이하라면 항상 위. 동일/상위 z-index 사용 모달이 있으면 디자인 가이드로 합의 필요.
|
|
- **백드롭 위에서 텍스트 드래그**: `e.target === e.currentTarget` 판정으로 의도치 않은 닫힘 방지.
|
|
- **백드롭 클릭으로 닫지 않게 하려면**: 향후 옵션화 필요 (현재는 항상 닫힘).
|
|
- **ESC 키로 닫기**: 미구현 (개선 여지 — 미해결 질문 참조).
|
|
- **Focus trap**: 미구현. 접근성 측면 약점.
|
|
- **연속 클릭으로 다중 OAuth 팝업**: 위젯 자체 가드에 의존. LoginMenu 차원에서는 별도 디바운스 없음.
|
|
|
|
## 10. 테스트 계획
|
|
현재 자동화 테스트 없음 (TBD). 권장:
|
|
- **단위 (Vitest + RTL)**:
|
|
1. 초기 렌더에서 모달 비노출 (`queryByText("소셜 계정으로 간편 로그인")` → null)
|
|
2. "로그인" 클릭 → 모달 노출
|
|
3. "✕" 클릭 → 모달 닫힘
|
|
4. 카드 내부 클릭 → 모달 유지 (백드롭 가드 검증)
|
|
5. 백드롭 클릭 → 모달 닫힘
|
|
6. `GoogleLogin.onSuccess` mock 호출 시 `onGoogleSuccess(credential)` 전달 + 모달 닫힘
|
|
7. `credential = undefined`인 응답 → 콜백 미호출 + 모달 유지
|
|
- **통합**:
|
|
- `@react-oauth/google` 모듈 모킹 → 위젯 자리에 더미 버튼 렌더, 클릭 시 onSuccess 트리거
|
|
- Portal target(`document.body`) 검증
|
|
- **E2E (Playwright)**:
|
|
- 실제 Google OAuth는 외부 의존이라 staging만 — 통상 모킹/스킵
|
|
- **드라이런 전략**: clientId를 테스트 환경 변수로 분리, `GoogleOAuthProvider`를 테스트 wrapper로 대체.
|
|
|
|
## 11. 리스크 & 대안 검토
|
|
- **Google 단일 프로바이더 의존**: 카카오/네이버 확장 시 다중 위젯 배치 필요 → 컴포넌트 분리(`<SocialLoginButtons>`)가 자연스러움. 현 단계는 단순성 우선.
|
|
- **인라인 `z-index: 99999`**: 디자인 시스템 z-index 토큰 부재. 대안: `--z-modal: 99999` CSS 변수 + 클래스. 현재 인라인 사용은 의도된 안전장치.
|
|
- **`@react-oauth/google` 의존**: 라이브러리 미유지 시 직접 Google Identity Services 스크립트 로드 필요. 폴백 계획은 미정.
|
|
- **접근성**: focus trap, ESC 종료, ARIA(`role="dialog"`, `aria-modal`) 미적용 → 단기 개선 후보.
|
|
- **다국어**: "로그인", "소셜 계정으로 간편 로그인" 하드코딩 한국어. i18n 도입 시 추출 필요.
|
|
- **백드롭 닫기 비활성 옵션 부재**: 일부 화면(중단 위험 작업 중)에서 강제로 열어둘 수 없음. props로 옵션화 권장.
|
|
- **부모 콜백의 비동기 실패 처리 누락**: `onGoogleSuccess`가 throw해도 `setOpen(false)`가 먼저 실행되어 사용자 피드백 사라짐. 대안: `await onGoogleSuccess()` 후 분기.
|
|
|
|
## 12. 미해결 질문 (Open Questions)
|
|
- ESC 키 / focus trap / ARIA 속성을 도입할 일정·우선순위는?
|
|
- 추가 OAuth 프로바이더(카카오/네이버/Apple) 도입 계획이 있는가? 있다면 위젯 통합 패턴은?
|
|
- 로그인 후 어떤 화면으로 리다이렉트할지 (현재는 모달만 닫고 위치 유지) — 명시적 라우팅이 필요한 화면이 있는가?
|
|
- 이미 로그인된 상태에서 `<LoginMenu>`가 헤더에 노출되는 경우는? (현재 상위에서 조건부 렌더 가정)
|
|
- 모바일에서 모달 카드 위치/사이즈가 적절한가? (현재 `max-w-xs`)
|
|
- 로그인 실패 사용자에게 인라인 에러 메시지를 보여줘야 하는가? (현재 console.error만)
|
|
- Google ID Token을 부모로 직접 넘기는 대신, `LoginMenu` 안에서 백엔드 호출까지 묶는 것이 더 응집도가 높을까?
|