의뢰인 포털 재설계 기획서
구현 상태: ✅ Phase 1~3 구현 완료 (2026-04-09) 변경사항: OTP/OAuth 경로를 모두 걷어내고 4자리 접속 코드 단일 경로로 단순화. SOLAPI 자동 발송 → 변호사가 직접 링크·코드 전달 (복사+카톡 공유).
Phase 1(인증 전환), Phase 2(메시징), Phase 3(레거시 정리)가 모두 구현되었습니다. 포털 본인확인은 4자리 접속 코드 단일 경로이며, 외부 OAuth 공급자는 사용하지 않습니다.
전체 플로우
1. 핵심 원칙
의뢰인 ≠ 서비스 유저
의뢰인 = 하나의 사건에 종속된 외부 열람자
- 의뢰인은 회원가입하지 않는다
- 의뢰인은 Firebase Auth 계정을 갖지 않는다
- 의뢰인은 사건 단위로 링크를 받아 접근한다
- 의뢰인 접근은 플랜(free/starter/pro)과 무관하다
- 한 사건에 여러 의뢰인이 존재할 수 있다 (공동 원고 등)
2. 접근 플로우
[법무사/직원 — admin] [의뢰인 — 외부]
사건 등록 시 의뢰인 정보 입력
(이름, 이메일, 전화번호)
│
├─ 사건 상세 → "포털 공유" 클릭
│
├─ Server Action: createPortalTokenAction
│ ├─ portalTokens/{token} 문서 생성 (4자리 접속 코드 동시 생성)
│ ├─ 기존 토큰이 있으면 자동 revoke
│ └─ 변호사가 URL + 4자리 코드를 함께 직접 전달 (카카오톡/문자 수동 공유)
│ ※ 만료 없음. 변호사의 수동 revoke 또는 오입력 누적 시 자동 revoke
│
│ /portal/view?token=abc123
│ 링크 클릭
│ │
│ 1. 토큰 검증
│ ├─ active → 인증 화면
│ └─ revoked → 안내 화면 (재발급 요청)
│ │
│ 2. 접속 코드 입력 (4자리 숫자)
│ ├─ 평문 비교, 자동 포커스 이동
│ └─ 오입력 10회 누적 시 토큰 자동 revoke
│ │
│ 3. 코드 일치 → 세션 쿠키 발급
│ ├─ 30일 sliding window
│ └─ 접속 시마다 자동 갱신
│ │
│ 4. 포털 화면
│ ├─ 사건 정보 (읽기)
│ ├─ 기일 일정 + D-day
│ ├─ 진행 경과 타임라인
│ ├─ 서류 확인/다운로드
│ └─ 대화 (질문/문의)
│
├─ admin 사건 상세 → 대화 탭 (NEW)
│ └─ 의뢰인과의 대화 확인 + 답변
│
└─ 사건 액티비티 로그에 의뢰인 접속 기록
2.1 접속 코드 자동 잠금 대응
의뢰인이 접속 코드를 누적 ACCESS_CODE_MAX_ATTEMPTS (기본 10회) 틀리면 verifyAccessCodeAction 이 해당 포털 토큰을 자동으로 status: "revoked" 로 전환한다. 단순히 차단만 하면 변호사가 의뢰인 연락을 받을 때까지 상황을 인지하지 못하므로, 세 가지 경로로 동시에 사무소에 노출한다.
| 경로 | 저장 위치 | 소비 UI | 용도 |
|---|---|---|---|
인앱 알림 (writeNotification) | tenants/{tid}/notifications | 대시보드 종 아이콘 피드 | 즉시 인지 — 로그인 중인 모든 owner/staff 에게 unread 로 노출 |
감사 로그 (writeActivityLog) | tenants/{tid}/activityLogs | /activity?caseId=<id> | 사후 추적 — 발생 시각·오입력 횟수·원인 |
사건 상세 배너 (PortalLockoutBanner) | — (SSR 쿼리) | 사건 상세 상단 | 알림을 놓친 변호사도 사건을 열면 즉시 확인 |
NotificationType 과 ActivityType 모두 portal_access_code_locked 타입을 신설해 일반 사건 알림과 구분한다. 두 레코드의 actorId 는 "system", actorName 은 "시스템" 관례를 따르며, caseId 가 함께 기록되어 알림 클릭이나 활동 로그 필터링으로 해당 사건 상세로 이동할 수 있다.
배너의 자가 해제 동작: PortalLockoutBanner 는 사건 상세 SSR 시점에 해당 사건의 최신 portalTokens 1건을 조회해 status === "revoked" AND accessCodeAttempts >= ACCESS_CODE_MAX_ATTEMPTS 조건을 만족할 때만 렌더링한다. 변호사가 다이얼로그의 "새 코드로 재발급" 을 누르면 새 토큰이 생성되면서 기존 잠긴 토큰이 더 이상 "최신" 이 아니게 되고, 다음 페이지 로드에서 배너가 자동으로 사라진다. 별도 상태 리셋 코드 없이 쿼리 순서만으로 self-healing 이 성립한다.
쓰기 실패 처리: 알림/로그 쓰기는 Firestore 트랜잭션 밖에서 Promise.allSettled 로 수행한다. 기록 쪽이 일시적 오류로 실패해도 의뢰인에게 돌아가는 "자동 비활성화되었습니다" 응답과 토큰 revoke 처리는 정상 진행된다.
3. 세션 관리
Firebase Auth를 사용하지 않는다. 서버에서 암호화된 세션 쿠키를 직접 관리한다.
세션 쿠키 (iron-session 또는 JWT 암호화)
┌──────────────────────────────┐
│ portal_session │
│ tenantId: string │
│ caseId: string │
│ clientName: string │
│ tokenId: string │ ← 원본 토큰 참조 (revoke 체크)
│ issuedAt: number │
│ expiresAt: number (30일) │
└──────────────────────────────┘
- 매 접속 시 expiresAt을 현재 +30일로 갱신 (sliding window)
- 토큰이 revoke되면 세션도 무효 (tokenId로 검증)
4. 데이터 모델
4.1 portalTokens (신규, portalInvites 대체)
tenants/{tenantId}/portalTokens/{token}
├ tenantId: string
├ caseId: string
├ clientName: string ← 공동원고면 "홍길동 외 2인" 같은 자유 문자열
├ contact?: string ← 선택. 의뢰인 이메일 — 향후 알림 등 확장 여지
├ status: "active" | "revoked"
├ accessCode: string ← 4자리 숫자. 평문 저장 (재조회·재전송 목적)
├ accessCodeAttempts?: number ← 누적 오입력. 임계치 도달 시 자동 revoke
└ createdAt: Timestamp ← 만료 없음. revoke 는 사무소 수동 또는 오입력 자동
- 같은 사건·의뢰인 이름으로 새 토큰 발급 시 기존 토큰 status → "revoked"
- 한 사건에 공동원고 여러 명이 있으면 하나의 토큰 + 코드를 공유해 편하게 열람 (clientName 은 "홍길동 외 2인" 처럼 표시용 문자열)
- 변호사는 다이얼로그에서 "새 코드로 재발급" 클릭 시 기존 토큰을 revoke 하고 새 URL + 새 4자리 코드를 발급받을 수 있음
4.2 messages (이동: tenant-level → case-level)
변경 전: tenants/{tenantId}/messages (clientPhone 기반)
변경 후: tenants/{tenantId}/cases/{caseId}/messages/{messageId}
├ from: "client" | "staff"
├ senderName: string
├ text: string
├ readAt?: Timestamp ← 상대방 읽은 시간
└ createdAt: Timestamp
4.3 cases (필드 변경)
제거: clientId (Firebase Auth UID — 더 이상 존재하지 않음)
유지: clientName, clientEmail, clientPhone (의뢰인 연락처)
추가: portalTokenId?: string (현재 활성 토큰 참조, 선택적)
4.4 activity logs (추가)
tenants/{tenantId}/cases/{caseId}/logs/{logId}
기존 타입에 추가:
├ type: "client_portal_access" ← 의뢰인 포털 접속
├ type: "client_view_document" ← 서류 열람
├ type: "client_download_document" ← 서류 다운로드
├ type: "client_send_message" ← 대화 전송
├ clientName: string
└ createdAt: Timestamp
5. UserRole 변경
변경 전: "owner" | "staff" | "client"
변경 후: "owner" | "staff"
파급 영향
| 파일 | 변경 |
|---|---|
types/auth.ts | UserRole에서 "client" 제거 |
types/tenant.ts | MemberRole = UserRole (자동 반영) |
lib/firebase/auth.ts | VALID_ROLES에서 "client" 제거 |
(workspace)/layout.tsx | role === "client" redirect 제거 |
(portal)/layout.tsx | Firebase Auth → 포털 세션 쿠키 체크로 전환 |
requireStaffSession() | client 체크 불필요 (간소화) |
proxy.ts | /portal-invite 제거, /portal/view 추가 |
firestore.rules | isClientOfCase(), isClientOfParentCase() 제거 |
firestore.rules | messages client 규칙 제거 (서버 사이드 전용) |
components/layout/ | PLAN_LABEL에서 client 관련 제거 |
Storybook | client role 관련 스토리 업데이트 |
E2E | 포털 관련 테스트 전면 재작성 |
6. 알림 발송 모듈 (범용) — ⏳ 향후 설계안, 현재 미구현
본 섹션(6.1~6.4)은 향후 카카오톡 알림톡 도입 시 참고할 설계 초안입니다. 현재 SOLAPI / NHN 비즈메시지 연동은 구현되지 않았으며, 플랜 만료 리마인더 등 서비스 알림은 이메일(Resend)로만 발송합니다.
현재 상태 (2026-04-10 이후): 포털 폴백 인증은 기존 6자리 이메일 OTP 를 걷어내고 4자리 숫자 접속 코드로 교체되었습니다. 변호사가 포털 URL 과 코드를 카카오/문자로 직접 전달하며, 코드는 평문으로 Firestore 에 저장되어 언제든 재조회·재전송할 수 있습니다. 만료는 없고 revoke 는 사무소 수동 또는 오입력 10회 누적 시 자동입니다. 포털 토큰 스키마는
contact가 optional 로 바뀌었고, 대신accessCode/accessCodeAttempts필드가 추가되었습니다. 플랜 만료 리마인더는 여전히 이메일(Resend) 로 발송됩니다. SMS 구현 대신 후속 Phase 에서는 카카오톡 알림톡(SOLAPI/NHN 비즈메시지 등)을 채택할 예정이며, 아래 섹션은 해당 도입 시 참고할 설계안으로 보존합니다.
6.1 서비스 선정: SOLAPI (구 CoolSMS)
| 기준 | SOLAPI |
|---|---|
| SMS + 알림톡 | 단일 SDK 통합 |
| TypeScript | 네이티브 (solapi npm) |
| Cloud Functions | 호환 |
| 알림톡 실패 → SMS | 자동 fallback |
| 비용 (월 500건) | 약 6,500원 |
대안: 건당 단가 최우선 시 Aligo (REST API 직접 호출)
6.2 카카오 알림톡 설정 절차
- 카카오 비즈니스 채널 개설 — 사업자등록증 필요, 심사 1~3 영업일
- 채널 설정 — 검색 허용 ON, 홈 공개 ON, 고객센터 정보 입력
- SOLAPI에 채널 연동 — PFID 등록
- 템플릿 등록 + 심사 — 카카오 검수 1~3 영업일. 정보성 메시지만 허용
- 발송 — 승인된 템플릿 기반
6.3 범용 알림 모듈 설계
lib/messaging/
types.ts — MessageChannel, SendResult 타입
send.ts — sendMessage(channel, to, content) 통합 함수
templates.ts — 알림톡 템플릿 ID + 변수 매핑
providers/
solapi.ts — SOLAPI SDK 래퍼
email.ts — 기존 lib/email/send.ts 연동
발송 우선순위: 알림톡 → SMS → 이메일 (fallback chain)
6.4 필요한 알림톡 템플릿 (초기)
| 템플릿 | 내용 |
|---|---|
| 포털 링크 발송 | "#{사무소명}에서 사건 진행 현황을 확인하실 수 있습니다. 아래 링크를 눌러주세요." |
| OTP 인증 코드 | "인증 코드: #{OTP}. 10분 이내에 입력해주세요." |
| 기일 알림 (향후) | "#{사건번호} 다음 기일이 #{일수}일 후입니다." |
| 사건 상태 변경 (향후) | "#{사건번호} 사건 상태가 #{상태}로 변경되었습니다." |
7. admin 대화 UI (신규)
사건 상세 페이지에 대화 탭 추가:
cases/{caseId}/messages실시간 조회 (onSnapshot)- 의뢰인별 대화 스레드 표시 (clientName 기준)
- 답변 작성 (from: "staff", senderName: 현재 로그인 유저)
- 읽음 표시 (readAt 업데이트)
8. 삭제 대상
| 항목 | 이유 |
|---|---|
UserRole의 "client" | 의뢰인은 유저가 아님 |
CustomClaims client 관련 | Firebase Auth 불사용 |
portalInvites 컬렉션 | portalTokens로 대체 |
otp-actions.ts | portal-token-actions.ts로 대체 |
portal-invite 페이지 | /portal/view로 대체 |
Firestore rules isClientOfCase() | 서버 사이드 전용 |
Firestore rules isClientOfParentCase() | 서버 사이드 전용 |
| Firestore rules messages client 규칙 | 서버 사이드 전용 |
tenants/{tid}/messages 컬렉션 | cases/{cid}/messages로 이동 |
clientId 필드 (cases) | 토큰 기반 접근, UID 불필요 |
9. 구현 우선순위
Phase 1: 기반 (필수)
UserRole에서"client"제거 + 전체 파급 수정portalTokens데이터 모델 + Server Actions- 포털 세션 관리 (iron-session)
- 4자리 접속 코드 폴백 (사무소 수동 전달, 만료 없음)
/portal/view신규 페이지 (토큰 → 접속 코드 → 열람)- 의뢰인 접속 액티비티 로그
Phase 2: 대화 + 알림
cases/{caseId}/messages서브컬렉션 + 마이그레이션- 포털 대화 UI (의뢰인 측)
- admin 대화 탭 (사건 상세)
- SOLAPI 연동 (SMS + 알림톡)
- 카카오 비즈니스 채널 + 템플릿 심사
Phase 3: 정리
- 기존
portalInvites, messages 마이그레이션/삭제 - Firestore rules 정리
- E2E 테스트 재작성
- Storybook 업데이트