본문으로 건너뛰기

ADR 0028 — 비즈니스 로직 계층 추출 (packages/business-logic)

  • Status: Accepted — 2026-04-27 PR-1 인프라 + PR #1042~#1113 (50+ 회) 점진 추출 완료. 13 도메인 (cases · clients · documents · evidence · execution · hearings · members · messages · portal · recoveries · refinement-feedback · snippets · tenant) + case-comments 모두 packages/business-logic 으로 이전. 직접 패키지 단위 테스트 전 도메인 통과 (4625 케이스). ops 시나리오 24+ 카드 (10 카테고리) 동작.
  • Date: 2026-04-27 (decision) · 2026-04-28 (sweep 완료)
  • Decision Drivers: 사용자 요구 — "ops 에서 web 의 비즈니스 로직을 시나리오 따라 실행하여 데이터 변경 후 ops 에서 검증하고 싶다". 현재 비즈니스 로직이 apps/web/app/**/_actions/ Server Action 안에 다른 책임 (auth · revalidatePath · activity log) 과 혼재되어 ops 에서 재사용 불가능. 또한 apps/* 간 의존 금지 규칙 (CLAUDE.md SSoT) 으로 코드 복사 외엔 ops 가 같은 비즈니스 결과 내기 불가.
  • Anti-Goal Compliance: ADR 0001 Anti-Goal 4종 무관 — 순수 아키텍처 리팩터.

결정

새 워크스페이스 패키지 packages/business-logic (이름 @neohollo/business-logic) 신설. 도메인 비즈니스 mutation 을 점진적으로 이 패키지로 추출한다.

책임 경계

계층책임
packages/business-logic도메인 검증 (Zod) · 도메인 계산 · Firestore mutation · 결과 객체 반환
apps/web/app/**/_actions/Server Session 추출 (requireStaffSession) · ownership gate · ctx 빌드 → 비즈니스 함수 호출 → revalidatePath · activity log · 클라 응답
apps/ops/app/.../scenarios/masterAdmin 검증 · 테스트 tenant 화이트리스트 검증 · ctx 빌드 (suppressActivityLog=true) → 비즈니스 함수 호출 → e2eRuns 컬렉션에 단계별 결과 기록

공통 컨텍스트 — BusinessLogicCtx

{
db: Firestore, // 호출자가 초기화한 admin app
tenantId: string, // cross-tenant 차단점
actorUid: string, // mutation stamp
actorRole: UserRole | "masterAdmin", // 분기용
suppressActivityLog?: boolean, // ops 시나리오 = true
dryRun?: boolean, // 미리보기용
}

명시적 ctx 주입 = 호출자 환경 어떤 것이든 같은 함수 사용 가능 + 테스트 시 mock ctx 주입 용이.

추출 시퀀스 (PR-2~PR-6)

PR도메인선정 이유
PR-2distributeExecution (#1039)PoC 최적 — 호출자 1곳, 순수 함수 분리됨 (#1035), 코드 신선
PR-3(ops 시나리오 페이지 뼈대)PR-2 결과로 사용자 의도 첫 실현
PR-4addRecovery1:N 패턴 첫 추출
PR-5createCase가장 복잡한 mutation (wizard)
PR-6finalizeDocument학습 루프 핵심 + draftDiffs · legacyDocuments 검증

배경

현재 (PR-1 직전) 구조

web (UI 계층) ops (UI 계층)
│ │
▼ ▼
Server Actions Firestore Admin SDK
├─ auth └─ 운영 mutation 만
├─ Zod 검증 (publicDocuments,
├─ 비즈니스 로직 ◀────────────── tenant metadata 등)
├─ Firestore mutation
├─ revalidatePath
└─ activity log


Firestore

목표 구조

packages/business-logic
├─ execution/distribute()
├─ recoveries/add()
├─ cases/create()
├─ documents/finalize()
└─ ctx (BusinessLogicCtx)
▲ ▲
│ │
web Server Actions ops 시나리오 페이지
(auth + wrap + (masterAdmin gate +
revalidate + 테스트 tenant 검증 +
activity log) e2eRuns 기록)
│ │
└────► Firestore ◄───┘

대안 검토

대안평가
(1) 코드 복사drift 위험 큼. 같은 도메인 함수가 web 과 ops 양쪽에 다른 모양으로 진화 — 회귀 폭탄
(2) ops 가 web 의 callable Cloud Function 호출새 인증 layer 필요 + 네트워크 왕복 비용. 추후 외부 호출자 (mobile app 등) 등장 시 유효한 옵션이지만 현 시점 over-engineering
(3) packages 추출 (선택)한 번 추출하면 호출자 무관 재사용. Hexagonal/Ports-and-Adapters 표준. 점진적 가능
(4) Server Action 직접 importapps/* 간 의존 금지 규칙 위반 — ESLint fail

Anti-Goal 검증

  • ✅ 1번 "민사 외" 미위반 — 아키텍처 리팩터
  • ✅ 2번 "법률 조언 AI" 미위반 — 결정론 mutation 만
  • ✅ 3번 "의뢰인 B2C" 미위반
  • ✅ 4번 "변호사 매칭" 미위반

한계 / 점진성

  • revalidatePath 분리: next/cache 의존 → 패키지 안에 들어가면 안 됨. 호출자 (web Server Action) 가 wrap 후 revalidate 호출.
  • activity log 분리: writeActivityLog 도 호출자 책임. 단순 NotificationType union 만 packages/types 로 옮겨두면 양쪽 동시 활용 가능.
  • 이미 머지된 코드와의 호환: web Server Action 리팩터는 wrapper 화 — 외부 시그니처 변경 없음. 호출자 (UI 컴포넌트) 무수정.
  • 마이그레이션 비용 (실측): 30+ mutating Server Action 중 13 도메인 추출 완료. 미추출 항목은 전부 명시적 사유 (web 전용 helper 의존 / Storage / AI client SDK / 외부 webhook).
  • ops 의 cross-tenant 가드: ops 시나리오는 isTestTenantId 화이트리스트 (config/appMetadata.testing.testTenantIds) 통과 후에만 실행. 운영 tenant 데이터 오염 차단.
  • 테스트 인프라: apps/web/__tests__/_helpers/fake-firestore.ts 가 in-memory Firestore 시뮬 (collection · doc · where('==') · count() · batch · runTransaction) 제공. emulator 없이 4625 테스트 < 11s 통과.

Minority Report

  • P5 (개발자) 우려: 30+ Server Action 전수 추출은 큰 비용. 점진 전략 흔들리면 "두 모양으로 절반씩" drift 발생 위험.
    완화: PR-1 에서 ADR 명문화 + 추출된 함수 명단 유지. 신규 mutating Server Action 작성 시 처음부터 packages 분리 권장.
  • P3 (PM) 우려: 사용자 가치는 ops 시나리오. 그 가치 도달 전에 PR-1·2 만 머지된 상태로 멈추면 인프라만 남고 "그래서 뭐?" 됨.
    완화: PR-3 (ops 시나리오 페이지 뼈대 + 첫 도메인 시연) 까지가 사용자 의도 첫 실현. 이 지점까지 자율 진행 약속.
  • P1 (변호사) 양보 불가: 변호사 데이터 안전성. cross-tenant 누수가 ctx 변경으로 새로 생기면 안 됨.
    완화: ctx.tenantId 가 함수 본문의 모든 path 에 반드시 등장. 함수 단위 unit test 에서 다른 tenantId 주입 시 결과 격리 검증.

후속 과제

  • ✅ PR-2: distributeExecution 추출 (PoC) — 통과
  • ✅ PR-3: ops 시나리오 페이지 뼈대 + 테스트 tenant 화이트리스트
  • ✅ PR-4~6: addRecovery / createCase / finalizeDocument 점진 추출
  • ✅ 신규 mutating Server Action 작성 가이드 → CLAUDE.md 명시 ("ADR 0028 패턴 강제")
  • ✅ 직접 패키지 단위 테스트 — 13 도메인 모두 in-memory fakeDb 기반 회귀 가드
  • ✅ ops 시나리오 카테고리 그룹화 (24+ 카드 → 10 카테고리)
  • 미추출 (의도적):
    • inviteMemberAction — sendEmail 템플릿 · invite-code helper 가 web 전용
    • bank-csv-import — 은행 어댑터·bridgeCsvToRepayments 가 web 전용 (별도 사유: 이체내역 형식 어댑터는 UI 매핑 의존)
    • settlementSnapshot · recoverySnapshot · execSnapshot — web Zod schema 깊이 의존 (도메인 분리 효용 < 비용)
    • submitFeedback · recordModelFeedback · recordRefinementCitation — Storage 업로드 / 외부 webhook (Slack) 결합
    • 모든 AI 액션 (ai-strategy-context-actions, daily-briefing-actions 등) — Firebase AI Logic client SDK 의존, server action 은 reserve/refund wrapper 만