기술 아키텍처
시스템 구조
모노레포 구조
| 디렉토리 | 패키지 | 설명 |
|---|---|---|
apps/web | @neohollo/web | Next.js 메인 앱 (변호사 대시보드 + 의뢰인 포털) |
apps/ops | @neohollo/ops | 운영팀 콘솔 (Master Admin, 테넌트 관리) |
apps/docs | @neohollo/docs | Docusaurus 정적 문서 |
packages/types | @neohollo/types | 공유 타입 (UserPlan, UserRole, ServerSession 등) |
packages/ui | @neohollo/ui | 공유 유틸 (cn) + 공유 UI 컴포넌트 |
functions | — | Cloud Functions (별도 npm 워크스페이스) |
apps/web/app/(workspace)/는apps/ops/와 다릅니다. 전자는 로그인된 사용자의 대시보드 라우트 그룹(apps/web 내부)이고, 후자는 너홀로프로 운영팀 전용 별도 앱입니다.
의존성 규칙 (절대 원칙)
apps/*간 import 금지 — 각 앱은 독립 배포 단위입니다. apps/web ↔ apps/ops ↔ apps/docs 모두 서로 참조 불가.packages/*→apps/*금지 — 상향 의존 금지. 의존 방향은 한 쪽(apps → packages) 뿐입니다.packages/*간 import 허용 — 예:@neohollo/ui가@neohollo/types를 소비 가능.
강제 수단: 루트 eslint.config.mjs 의 no-restricted-imports — 위반 시 pnpm lint 실패. 공유가 필요하면 packages/ 로 승격하거나 각 앱에 복제합니다.
Turborepo 설정
# 전체 앱 실행
pnpm dev # 모든 apps dev 서버
pnpm build # 프로덕션 빌드
# 개별 앱 선택
pnpm dev --filter=@neohollo/web # web만 (포트 3000)
pnpm dev --filter=@neohollo/ops # ops만 (포트 3001)
pnpm dev --filter=@neohollo/docs # docs만
# 테스트
pnpm test --filter=@neohollo/web # web 테스트만
Firestore masterAdmins 컬렉션
운영팀 콘솔(apps/ops) 은 masterAdmins/{email} 화이트리스트로 접근 제어한다.
- 문서 구조:
{ email: string, role: "super_admin" | "admin" | "viewer" } - 쓰기는 Firebase Console / Admin SDK 전용 (
firestore.rules의allow write: if false). - 읽기는 인증된 사용자가 본인 레코드 존재를 확인할 수 있다 — ops
AuthProvider가 로그인 후 조회. - 컬렉션명
masterAdmins는 Phase 1 스키마로 유지(앱 이름이ops로 바뀌었지만 컬렉션 스키마 변경은 Phase 2+ 에 포함).
cache 전략:
build:.next/**,build/**출력 캐싱dev: 캐시 비활성화 (persistent)test: 의존성 빌드 후 캐싱lint,type-check: 캐싱만
기술 스택
프론트엔드
| 기술 | 버전 | 용도 |
|---|---|---|
| Next.js | 16.2.2 | App Router, Server Actions, SSR |
| React | 19.2 | UI 레이어 |
| TypeScript | 5.9 | 타입 안전성 |
| Tailwind CSS | 4 | 스타일링 |
| shadcn/ui | 최신 | UI 컴포넌트 (Radix UI 기반) |
| Zod | 4 | 스키마-first 데이터 검증 |
| React Hook Form | 최신 | 폼 관리 |
| Tiptap | 3 | 서류 리치텍스트 편집기 |
| Phosphor Icons | 최신 | 아이콘 |
Firebase 서비스
| 서비스 | 역할 |
|---|---|
| App Hosting | Next.js 배포 (Cloud Run, asia-northeast3) |
| Authentication | 이메일/패스워드 인증, Custom Claims (tenantId, role) |
| Firestore | 주 데이터베이스 (실시간 동기화, 경로 기반 테넌트 격리) |
| Cloud Storage | 증거자료, AI 생성 서류, OCR 원본 텍스트 |
| Cloud Functions v2 | 서버리스 백엔드 (트리거, 스케줄) |
| Cloud Scheduler | 매일 09:00 KST 기일 알림 |
AI 서비스
| 서비스 | 역할 | 호출 위치 |
|---|---|---|
| Firebase AI Logic (Gemini) | 서류 생성, 전략 분석 | 클라이언트 (firebase/ai) |
| Vertex AI Search | RAG (사무실 기억 검색 — 업무 유산) | 서버 (Server Action) |
| Cloud Vision API | 증거/레거시 문서 OCR | Cloud Functions |
| Cloud DLP | PII 자동 비식별화 | Cloud Functions |
기타
| 서비스 | 용도 |
|---|---|
| Resend | 이메일 발송 (초대, OTP, 기일 알림) |
| Sentry | 프로덕션 에러 모니터링 |
| reCAPTCHA v3 | 봇 방지 (무료 티어) |
AI 파이프라인
공통 원칙 5가지, (A) 자동 갱신형 / (B) 사용자 트리거형 폴백 정책, executeAiAction 래퍼, 어시스트 통화(weight 0/1/3), PII 마스킹, 신규 AI 화면 복제 템플릿은 AI 파이프라인 문서가 SSoT 입니다. 본 섹션은 현재 구현된 각 AI 기능의 데이터 흐름도 를 모은 기능 카탈로그입니다.
대시보드 브리핑
페이지 방문 or visibilitychange + 30분+ stale
│
├─ Server Action: getDashboardBriefingContextAction
│ ├─ 활성 사건 쿼리 (ACTIVE_STATUSES)
│ ├─ 임박 마감 TOP 5 (D-day 오름차순, 지연 포함)
│ └─ 구조화 BriefingContext 반환 (state + topDeadlines + outstandingClosedCases)
│
├─ 활성 0건 or 임박 없음 → buildFallbackText(ctx) 로 바로 렌더
│ ├─ empty_new → "첫 사건을 등록하면…" CTA
│ ├─ empty_closed → "지금은 조용한 시간입니다…"
│ └─ active + 없음 → "임박한 기일이 없습니다…"
│
├─ Server Action: reserveAssistAction(ASSIST_WEIGHTS.dashboardBriefing) (실패 시 폴백으로 수렴)
│
├─ 클라이언트: firebase/ai → generateDashboardBriefing(prompt)
│ ├─ buildAiPromptInput(ctx) → TOP 5 액션 후보를 프롬프트로 변환
│ └─ Gemini 가 "오늘 놓치면 안 되는 일 1~3가지" 를 출력
│
└─ isValidBriefing 통과 → sessionStorage { text, createdAt } 캐시
실패·빈값 → buildFallbackText(ctx) 로 수렴 (캐시 없음)
출력: "오늘의 브리핑 · N분 전" 카드 한 장 (1~3개 번호 매긴 액션). 대시보드 본문의 유일한 주 컴포넌트.
서류 자동 생성
사용자: "AI 서류 생성" 클릭
│
├─ executeAiAction({ weight: ASSIST_WEIGHTS.simple, feature: "docGeneration" }) 래퍼
│ └─ 내부: reserveAssistAction → getRagContextForDocGenAction (Vertex AI Search RAG)
│
├─ 클라이언트: firebase/ai → generateLegalDoc(docType, input, ragContext)
│ └─ 서류 초안 생성 (retryable 실패 시 래퍼가 자동 refund)
│
└─ Server Action: saveDocWithHtmlAction (Firestore + Storage)
지원 서류: 지급명령, 소장, 내용증명, 강제집행신청서 (4종). 폴백 불가 — AI 실패 시 재시도 안내.
AI 전략 분석
사용자: "전략 분석" 클릭 (or 사건 업데이트 후 "재분석" 배너)
│
├─ executeAiAction({ weight: ASSIST_WEIGHTS.strategyReport, feature: "strategyReport" }) 래퍼
├─ Server Action: getStrategyContextAction
│ ├─ 사건 정보 + 증거 요약 수집
│ ├─ maskPII() 주민번호·전화번호 마스킹
│ └─ Vertex AI Search RAG 검색
│
├─ 클라이언트: firebase/ai → generateStrategyReport (JSON mode + Zod 검증)
│ └─ StrategyReportResponseSchema 스키마 강제
│
└─ Server Action: saveStrategyReportAction
출력: 요건 충족 분석 (결정론 집계) + 강점/약점/리스크 + 권장 전략. 수치화된 승소 확률 필드는 ADR 0006 §10 (변호사법 §109) 으로 금지 — 구 보고서의 winProbability 는 직렬화에서 의도적으로 누락된다.
폴백 불가 대응 (#89): JSON 스키마 의존이라 자연스러운 텍스트 폴백 불가. 대신 getLatestStrategyReportAction 이 사건 updatedAt 도 병렬 반환해 사건 업데이트 후 생성된 보고서인지 를 클라이언트가 판정. 생성 후 사건이 변경되었으면 amber 경고 배너 + "재분석 →" 버튼 노출. 헤더에 "N시간 전" 상대 시각 표시.
사무실 기억 AI 분석
사용자: 사건 상세에서 "기억에서 찾기 + AI 분석" 1-클릭 (#90)
│
├─ Server Action: findRelatedMemoriesAction(caseId) — Vertex AI Search + caseSummary 반환 (무료)
│ └─ 결과 0건 → "비슷한 과거 기억을 찾지 못했습니다" 빈 상태
│
├─ Server Action: reserveAssistAction(ASSIST_WEIGHTS.simple, "ragSearch") — 실패 시 검색 결과 목록만 노출하고 분석 스킵 (폴백)
│
└─ 클라이언트: firebase/ai → generateRelatedMemoriesAnalysis(caseSummary, snippets)
├─ 성공 → AI 분석 카드 + 결과 목록 동시 노출
└─ 실패·빈값 → 결과 목록만 노출 (폴백, 에러 배지 없음)
출력: "AI 분석" 카드 (유사점·차이점·활용 포인트) + 관련도 순 검색 결과 목록. 이전엔 2-클릭(검색→분석)이었으나 1-클릭으로 통합. Gemini 프롬프트에 caseSummary 를 주입해 "현재 사건" 을 AI 가 이해하도록 컨텍스트 빈곤 해소.
증거자료 멀티모달 요약
사용자: 사건 상세에서 파일 업로드 (이미지/PDF)
│
├─ Server Action: addEvidenceAction (Firestore 메타데이터 저장)
├─ executeAiAction({ weight: ASSIST_WEIGHTS.simple, feature: "briefing" }) 래퍼
│ └─ 한도 초과·readOnly 시 파일 메타 기반 폴백 description 저장
│
├─ 클라이언트: firebase/ai → summarizeEvidence(file) (Gemini 멀티모달)
│ └─ 실패·빈값 시 fallbackDescription(file) 로 수렴 ("PDF 문서 · 2.3MB")
│
└─ Server Action: updateEvidenceDescriptionAction (Firestore 저장)
OCR 파이프라인 (Cloud Functions)
사건 종결 → onCaseClosedLegacyPipeline 트리거
│
├─ Cloud Vision API: 문서 OCR
├─ Cloud DLP: PII 비식별화
├─ Cloud Storage: 원본 텍스트 보관
├─ Firestore: 비식별화 텍스트만 저장
└─ Vertex AI Search: RAG 인덱스 업데이트
보안
라우트 그룹과 접근 제어
| 라우트 그룹 | 접근 조건 | 인증 방식 |
|---|---|---|
(auth) | Public | 없음 (로그인/회원가입) |
(setup) | Firebase Auth + tenantId 없음 | Firebase Auth |
(workspace) | Firebase Auth + owner/staff | Firebase Auth + Custom Claims |
(portal) | iron-session 세션 쿠키 | 4자리 접속 코드 |
인증 계층
| 계층 | 메커니즘 |
|---|---|
| Edge (Middleware) | proxy.ts — 쿠키 유무 확인, PUBLIC_PATHS 외 차단 |
| Layout | getServerSession() — 토큰 서명 검증 + Custom Claims 확인 |
| Server Action | requireStaffSession() — role 기반 접근 제어 |
| 포털 세션 | getPortalSession() — iron-session 쿠키 검증 + 토큰 revoke 체크 |
| Firestore Rules | isStaffOrOwner(), isOwner() — 최종 방어 계층 |
역할 체계
의뢰인은 Firebase Auth 계정 없이 변호사가 전달한 4자리 접속 코드로 접근하는 사건 종속 외부 열람자입니다. 상세: 포털 재설계 문서
PII 보호
- OCR 원본 텍스트 → Cloud Storage에만 보관 (Firestore 저장 금지)
- AI 전달 전 주민번호/전화번호 패턴 마스킹
- Cloud DLP로 레거시 문서 자동 비식별화
- Signed URL 15분 만료 (증거파일 접근)
RSC 직렬화
Server Component / Server Action 이 Firestore 문서를 Client Component 에 넘길 때 반드시 직렬화 가능한 형태로 변환해야 한다. Firestore Timestamp·DocumentReference 등 클래스 인스턴스가 RSC 경계를 직접 통과하면 Next.js 16 이 Only plain objects can be passed ... 로 거부하여 페이지가 깨진다.
- 공용 유틸:
apps/web/lib/firestore/serialize.ts의serializeFirestoreValue(value)—Timestamp → ISO string,DocumentReference → path string, nested 객체/배열 재귀 처리. - 도메인 화이트리스트: 페이지별
_lib/내toPlain*()(예: 대시보드toPlainStats, 사건 목록toPlainCase) 로 전달할 필드만 추려서 내보낸다. 전체 문서를 넘기지 말고 필요한 필드만 화이트리스트에 포함. - onSnapshot 리스너: 클라이언트 onSnapshot 콜백에서 받은 값은 이미 Client Context 라 별도 직렬화가 필요 없지만, Server Component 가
getDoc()으로 읽어 props 로 전달하는 경로는 반드시 거쳐야 한다. - 체크리스트: PR 리뷰 시 Server → Client props 에 Firestore 원본 snapshot/data 가 그대로 넘어가는 코드는 블로커. 테스트는
JSON.stringify(props)를expect하는 방식으로 검증 가능.
배포 및 CI/CD
| 구분 | 도구 |
|---|---|
| 호스팅 | Firebase App Hosting (Cloud Run, asia-northeast3) |
| 브랜치 정책 | main = production. feature 브랜치 → PR → merge |
| 자동 배포 | main push → Firebase App Hosting 자동 빌드/배포 |
| 릴리스 | PR merge → feat: 포함 시 minor, 그 외 patch → 태그 + GitHub Release |
| CI | GitHub Actions (ci.yml) |
| 문서 | GitHub Actions (docs.yml) → GitHub Pages |
Cloud Functions (7개 배포)
| 함수 | 트리거 | 용도 |
|---|---|---|
cleanupDocumentStorage | Firestore onDelete | 서류 삭제 시 Storage 정리 |
aggregateCaseStats | Firestore onChange | 사건 변경 시 KPI 사전 집계 (totalCases, activeCases, totalAmount, recoveredAmount, statusDistribution) |
sendHearingReminders | Cloud Scheduler (매일 09:00 KST) | 기일 D-7/3/1/0 + 항소기한 알림 (Resend 이메일 + 인앱) |
sendPlanExpiryReminders | Cloud Scheduler (매일 09:00 KST) | 플랜 만료 D-7/1/0 + Grace D-1 사전 고지 (Resend 이메일 + 인앱, plan_expiring_soon 타입) |
onCaseClosedLegacyPipeline | Firestore onUpdate | 사건 종결 시 OCR+DLP 파이프라인 (Pro) |
retryLegacyProcessing | onCall | 레거시 문서 재처리 |
downgradeExpiredPlans | Cloud Scheduler (매일 00:05 KST) | 프로모션/구독 만료 자동 다운그레이드 (plan_downgraded 타입) |
테스트
| 종류 | 도구 | 현황 |
|---|---|---|
| 단위 테스트 | Vitest | 66개 파일 / 1171 테스트 |
| E2E 테스트 | Playwright | 17개 스펙 / 77개 테스트 |
| 컴포넌트 | Storybook 10 | UI 컴포넌트 시각적 검증 |
| 정적 분석 | ESLint + TypeScript | 빌드 시 자동 검증 |
비용 예측 (Pro 플랜 1,000 사무소 기준)
| 서비스 | 예상 월 비용 |
|---|---|
| App Hosting (Cloud Run) | 약 50~100만원 |
| Firestore | 약 20~50만원 |
| Cloud Functions | 약 10~30만원 |
| Cloud Storage | 약 5~15만원 |
| Vertex AI Search | 약 30~50만원 |
| Cloud DLP | 약 10~20만원 |
| 합계 | 약 125~265만원/월 |