이관 문서 — 원출처: coco-de/unibook 리포
.claude/plans/deep-link-donor-extraction.md· 이관일: 2026-08-31 · 이관 사유: 외주 프로젝트 리포에서 내부 도구 문서 제거 (coco-de/unibook#12615). 본문 내 리포 상대 링크는coco-de/unibook 리포의 <경로>텍스트 표기로 바꾼 것 외에 원문 그대로 보존했다.
딥링크 donor 추출 — co-bricks 동기화 최적화 리팩토링 계획#
상태: 계획(미승인) · 작성 2026-07-30 · donor: kobic → co-bricks
결론 한 줄#
딥링크 2,439줄이 앱 레이어(L7)에 있어 donor sync 공급량이 0 이다. 결합 지점은
AuthBloc·AppRouter·GlobalAuthManager·PodService
넷뿐이고 모두 이미
확립된 런타임 등록 패턴으로 뒤집을 수 있으므로, 범용 엔진을 package/core 로
내리고 kobic 특화 로직을 인터셉터로 분리하면 브릭 하나로 딥링크 전체가 따라간다.
1. 실측 현황#
1-1. 공급량 0#
| 위치 | 파일 | 줄 | 브릭 공급 |
|---|---|---|---|
app/unibook/lib/core/service/deep_link/ |
10 | 1,315 | 0 |
app/unibook/test/core/service/deep_link/ |
6 | 1,124 | 0 |
bricks/core/app/__brick__/lib/core/service/ |
4 | — | web_embed 만 |
bricks/core/app 브릭의 lib/core/ 전체 내용:
lib/core/service/web_embed/embed_sync_message.dart
lib/core/service/web_embed/web_embed_sync_stub.dart
lib/core/service/web_embed/web_embed_sync_web.dart
lib/core/service/web_embed/web_embed_sync.dart
kobic 의 같은 경로에는 21개 파일이 있다(deep_link 10 · notification 6 · firebase 1 · web_embed 4). 17개가 통째로 누락돼 있고 그중 딥링크가 10개다.
백엔드 쪽
deep_link_utils.dart·deep_link_constants.dart는bricks/network/serverpod_backend에 정상 공급된다. 즉 서버는 딥링크를 만들어 보내는데 클라이언트에는 그걸 받을 코드가 없는 상태로 새 프로젝트가 생성된다.
1-2. 파일별 성격 분류#
| 파일 | 줄 | 성격 | 이전 대상 |
|---|---|---|---|
deep_link_service.dart |
745 | 엔진 + kobic 특화 혼재 | 분해 |
parser/deep_link_parser.dart | 133 | 범용 | → core |
constant/deep_link_patterns.dart |
105 | 이미 위임 껍데기 (SSOT 는 core ServiceUrlConverter) |
→ core 흡수 |
model/deep_link_route.dart | 87 | 범용 (순수 데이터) | → core |
app_route_matcher.dart |
51 | 범용, 단 app_router 결합 |
→ core (계약 역전) |
util/bloc_reauthentication_waiter.dart |
49 | 순수 함수 — 의존 0 | → core |
util/coupon_link_helpers.dart |
36 | 순수 함수 — 의존 0 | → core |
in_app_link_router_impl.dart |
22 | 앱 구현체 (계약은 이미 core) | 앱 잔류 |
web_message_support_{stub,web}.dart |
42 | 조건부 import 쌍 | → core |
1-3. deep_link_service.dart 745줄의 내부 구성#
| 구간 | 줄 수 | 내용 | 성격 |
|---|---|---|---|
| 초기화·리스너 | ~100 | AppLinks 구독, handleInitialLink |
범용 |
| 수신·정규화·라우팅 | ~160 | handleIncomingLink, 인증 대기 후 go() |
범용 |
_handlePaymentResult |
~50 | unibook://payment/result 가로채기 |
kobic 특화 |
_handleDesktopHandoff | ~85 | 데스크톱 핸드오프 토큰 교환 | kobic 특화 |
_handleOAuthCallbackFallback | ~55 | OAuth 콜백 폴백 | kobic 특화 |
_handleEmailVerify | ~75 | 이메일 인증 | kobic 특화 |
_handlePasswordResetVerify | ~85 | 비밀번호 재설정 | kobic 특화 |
_extractFirebaseTokenFromHtml | ~25 | HTML 파싱 | kobic 특화 |
범용 ≈ 260줄 / kobic 특화 ≈ 375줄. 특화 5종은 전부
bool _isXxxUrl(Uri) → Future<void> _handleXxx(Uri)
라는 동일한 형태라
인터셉터 계약 하나로 균일하게 뽑아낼 수 있다.
1-4. 실제 결합 지점 (import 개별 제거 실측 — S1 에서 확정)#
⚠️ 초판 정정 (2026-07-30). 최초 조사는 "예상 심볼 목록으로 grep" 해
store·life·unibook_config를 유령 import 로 판정했으나, 셋 다 실사용 이었다. 부정(미사용)을 긍정 목록으로 증명하려 한 오류다. 아래는 각 import 를 하나씩 제거하고dart analyze로 깨지는 심볼을 확인한 실측 결과다.
| 패키지 | 공급 심볼 | 사용 구간 | 이관 시 처리 |
|---|---|---|---|
auth |
AuthBloc(11) · AuthState* · GlobalAuthManager(4) |
인증 대기·재인증 | IAuthReadyGate 계약 |
app_router |
AppRouter.router(10) |
라우팅·현재 경로 조회 | IDeepLinkNavigator (core AppNavigator 기반) |
pod_service |
PodService(3) |
핸드오프·이메일·비번 | 인터셉터로 이동 |
unibook_config |
EnvConfig |
409·510·539 (핸드오프·OAuth·이메일) | 인터셉터로 이동 |
life |
ILocalStorageRepository · LocalStorageRepository |
39~41 (_localStorage getter) |
core 계약 필요 ⚠️ |
store |
PaymentResultService
·
PaymentDeepLinkResult
·
PaymentErrorCode
|
389~392 (결제 결과) | 인터셉터로 이동 |
core |
CoreServices(2) · Log |
전역 | 이미 core |
유령 import 0건 — 계층 래칫 phantom_edges 는 이 Epic 의 지표가 아니다.
⚠️ life 결합이 유일한 설계 미결
EnvConfig·PodService·Payment* 는 전부 kobic 특화 구간(375줄)에서만
쓰이므로 인터셉터를 따라 앱 레이어에 남는다 — 엔진은 깨끗해진다.
그런데 ILocalStorageRepository 는 _localStorage getter(39~41행)로 엔진
초기화부에 있다. S4 에서 둘 중 하나를 택해야 한다:
- 실제 사용처가 특화 흐름뿐이면 → 인터셉터로 내리고 엔진에서 제거 (선호)
- 엔진이 저장소를 정말 필요로 하면 → core 계약(
IDeepLinkStorage) 신설
S4 착수 시 _localStorage 사용처를 전수 확인해 결정한다.
2. 근본 원인 4개#
| # | 원인 | 근거 |
|---|---|---|
| C1 |
앱 레이어(L7) 거주
— donor 주 경로
SyncMonorepoService
는
feature/**
·
package/core
를 실어 나른다.
app/unibook/lib/core/service/
는
core/app
브릭 담당인데 거기 딥링크가 없다
|
§1-1 |
| C2 |
feature 직접 결합
—
AuthBloc
·
AppRouter
를 파일 안에서 직접 참조. 브릭화하면
{{#has_auth}}
조건부 조합에서 컴파일이 깨진다. 조건부 마커는
디렉토리 단위
로만 심을 수 있어 한 파일 안의 부분 결합은 표현할 수 없다
|
§1-4 |
| C3 |
app_router 는 sync 차단 대상
—
kPreservedGenericizedBricks = {'app_router'}
.
AppRouteMatcher
가 그걸 import 하므로 함께 실을 수 없다
|
co-bricks known_sync_features.dart:196 |
| C4 | kobic 서비스 특화 375줄 혼재 — 결제·핸드오프·OAuth·이메일·비번은 범용 딥링크가 아니다. 통째로 브릭화하면 타 프로젝트에 죽은 코드가 실린다 | §1-3 |
3. 목표 상태#
package/core (L2) ── donor sync 자동 공급 ─────────────────┐
app/deep_link/ │
├── deep_link_engine.dart 수신·정규화·파싱·라우팅 │
├── deep_link_parser.dart (이관) │
├── deep_link_route.dart (이관) │
├── deep_link_interceptor.dart ★ 계약 + 레지스트리 │
├── i_route_matcher.dart ★ 계약 (app_router 등록)│
├── i_auth_ready_gate.dart ★ 계약 (auth 등록) │
├── bloc_reauthentication_waiter.dart (이관, 순수) │
└── coupon_link_helpers.dart (이관, 순수) │
│
app/unibook (L7) ── 브릭 골격만, 내용은 프로젝트 고유 ────────┘
core/service/deep_link/
├── interceptor/payment_result_interceptor.dart
├── interceptor/desktop_handoff_interceptor.dart
├── interceptor/oauth_callback_interceptor.dart
├── interceptor/email_verify_interceptor.dart
├── interceptor/password_reset_interceptor.dart
└── deep_link_bootstrap.dart ★ 인터셉터 등록 지점
계약 역전은 이미 이 저장소에 4개 선례가 있다 — AppNavigator(#9664 S2) ·
IAuthGateway(#6658) · NotificationTapHandler · RoutePageRegistry(#8490) ·
InAppLinkRouter(#9734). 새 패턴을 만드는 게 아니라 기존 패턴의 5번째 적용이다.
인터셉터 계약 (초안)#
/// 딥링크 특수 가로채기 계약.
///
/// 엔진이 URL 을 파싱하기 **전에** 순서대로 [matches] 를 물어보고, 참이면
/// [handle] 에 위임한 뒤 일반 라우팅을 중단한다. 프로젝트 고유 흐름
/// (결제 결과·OAuth 폴백 등)을 엔진에서 분리하기 위한 것이다.
abstract interface class IDeepLinkInterceptor {
/// 우선순위 — 낮을수록 먼저 평가된다.
int get priority;
bool matches(Uri uri);
Future<void> handle(Uri uri);
}
abstract final class DeepLinkInterceptors {
static void register(IDeepLinkInterceptor interceptor);
static void reset(); // 테스트 teardown
static List<IDeepLinkInterceptor> get sorted;
}
⚠️ 등록 순서가 아니라 명시적
priority로 정렬한다. 현재 코드의 가로채기 순서(결제 → 핸드오프 → OAuth → 이메일 → 비번)가 동작 계약이며, 등록 순서에 의존하면 부트스트랩 줄 순서를 바꾸는 것만으로 조용히 깨진다.
4. Story 분해#
| # | Story | 범위 | SP | 선행 |
|---|---|---|---|---|
| S1 | 의존 실측 + 계획 정정 | import 개별 제거로 결합 7개 확정. 유령 0건 — 제거 대상 없음 | 1 | — |
| S2 | 순수 유틸 2종 + 모델 이관 |
bloc_reauthentication_waiter
·
coupon_link_helpers
·
deep_link_route
→ core. 의존 0 이라 무위험
|
2 | S1 |
| S3 | 파서·패턴 이관 + IRouteMatcher 역전 |
DeepLinkParser
·
DeepLinkPatterns
→ core,
AppRouteMatcher
→ 계약 +
app_router
등록 (C3 해소)
|
5 | S2 |
| S4 | DeepLinkService 분해 → 엔진 + 인터셉터 5종 |
745줄을 core 엔진 ~260줄 + 앱 인터셉터 5개로.
AuthBloc
→
IAuthReadyGate
,
AppRouter
→
AppNavigator
(C2 해소)
|
8 | S3 |
| S5 | 브릭화 + donor sync 검증 | core/app 브릭에 인터셉터 골격·등록 지점 반영, cob create 로 실증 (C1 해소) |
5 | S4 |
| 합계 | 21 |
S1 — 의존 실측 + 계획 정정 (SP 1) ✅ 완료#
결과: 유령 import 0건. 초판이 유령으로 지목한 3건은 전부 실사용이었다(§1-4).
산출물은 코드 변경이 아니라 정확한 의존 맵이다 — S4 가 무엇을 계약으로
뒤집고 무엇을 인터셉터로 내릴지 판단하는 근거가 된다. 그 과정에서 life
결합이 유일한 설계 미결로 드러났다(§1-4 하단).
실측 방법 (다음에 같은 판정을 할 때 재사용):
# ❌ 하지 말 것 — 예상 심볼 목록으로 grep 해서 " 미사용 " 을 결론짓기
grep -oE " \b(내가|아는|심볼만)\b " file.dart # 목록 밖 심볼을 놓친다
# ✅ import 를 실제로 제거하고 analyzer 에게 묻는다
grep -v " ^import ' package:store/store.dart ' ;$ " file.dart > /tmp/t & & mv /tmp/t file.dart
fvm dart analyze < 디렉토리 > # 깨지는 심볼이 그 import 의 공급 목록
패키지 컨텍스트가 필요하므로 단일 파일이 아니라 디렉토리/패키지 단위로
분석해야 한다. 파일 하나만 넘기면 Undefined name 'Log' 류 노이즈가 대량으로
나와 판정이 불가능하다.
S2 — 순수 유틸·모델 이관 (SP 2)#
bloc_reauthentication_waiter.dart(49) · coupon_link_helpers.dart(36) 는
import 가 dart:async 뿐이고, deep_link_route.dart(87)는 순수 데이터 클래스다.
그대로 package/core 로 옮기고 앱 레이어엔 재수출 shim 만 남긴다.
- 테스트 2종(
*_waiter_test.dart82줄 ·coupon_link_helpers_test.dart151줄)도 함께 이관 - 위험도 최저 — 여기서 이관 절차·shim 방식을 확립하고 S3~S4 에 재사용
S3 — 파서·패턴 이관 + IRouteMatcher 역전 (SP 5)#
DeepLinkPatterns 는 이미 ServiceUrlConverter(core) 위임 껍데기라 이관이
거의 삭제에 가깝다. DeepLinkParser 는 그 껍데기와 AppRouteMatcher 만 의존한다.
AppRouteMatcher 는 app_router 를 import 하므로 계약으로 뒤집는다:
// package/core
abstract interface class IRouteMatcher {
bool hasRoute(String routePath);
}
abstract final class RouteMatcher {
static IRouteMatcher? maybeInstance;
static void register(IRouteMatcher matcher);
static void reset();
/// 미등록 시 **true** — 기존 폴백 동작 보존 (아래 ⚠️)
static bool hasRoute(String path) =>
maybeInstance?.hasRoute(path) ?? true;
}
⚠️
?? true폴백은 반드시 보존한다. 현재AppRouteMatcher.hasRoute는 라우터 미초기화 시true를 반환한다 —false로 바꾸면 모든 딥링크가 외부 브라우저로 샌다(코드 주석에 명시). 계약으로 옮기면서 이 기본값을 무심코false로 두는 것이 이 Story 최대 리스크다.
app_router가 자기 부트스트랩(initializeGlobalAuthManager옆)에서 등록- 기존
matcherOverride테스트 훅은RouteMatcher.register로 자연 대체
S4 — DeepLinkService 분해 (SP 8) ★ 핵심#
745줄을 core 엔진과 앱 인터셉터 5종으로 가른다.
| 뒤집을 결합 | 대체 |
|---|---|
AuthBloc (11회) — 이미 setter 주입 |
IAuthReadyGate
신설 (
waitUntilReady()
/
reauthenticateAndWait()
) —
IAuthGateway
(#6658) 확장 검토
|
AppRouter.router.go (10회) |
AppNavigator.maybeContext 기반 IDeepLinkNavigator — core 에 이미 키 등록 존재 |
GlobalAuthManager (4회) |
IAuthReadyGate.isStrictlyAuthenticated 로 흡수 |
PodService (3회) | 인터셉터 쪽으로 이동 — 전부 특화 흐름에서만 쓰인다 |
인터셉터 5종은 형태가 동일하므로 기계적 추출이 가능하다:
final class PaymentResultInterceptor implements IDeepLinkInterceptor {
@override int get priority => 10;
@override bool matches(Uri uri) => /* 기존 _isPaymentResultUrl */;
@override Future<void> handle(Uri uri) async { /* 기존 _handlePaymentResult */ }
}
- 동작 계약 보존: 가로채기 순서(결제 10 → 핸드오프 20 → OAuth 30 → 이메일 40 → 비번 50)
- 등록 완결성 테스트를
route_page_registry_completeness_test선례대로 추가
S5 — 브릭화 + donor sync 검증 (SP 5)#
package/core이관분은 monorepo 브릭 경로에 자동 편입된다 (별도 작업 불필요)bricks/core/app에 인터셉터 디렉토리 골격 +deep_link_bootstrap.dart등록 지점 반영cob create로 신규 프로젝트 생성 → 딥링크 수신·파싱·라우팅 동작 확인- 인터셉터 0개 등록 상태에서도 범용 딥링크가 동작해야 한다 (이게 이 리팩토링의 합격 기준)
5. 리스크와 대응#
| 리스크 | 영향 | 대응 |
|---|---|---|
RouteMatcher 기본값 반전 |
모든 딥링크가 외부 브라우저로 샘 | S3 에서 ?? true 폴백을 단위 테스트로 고정. 미등록 상태 케이스 필수 |
| 인터셉터 순서 변경 | 결제 URL 이 OAuth 로 잘못 매칭되는 등 조용한 오작동 | priority 명시 + 순서 회귀 테스트 |
| 인증 대기 로직 미묘성 |
_waitForAuthInitialization
은 "이 호출이 발사한 사이클"을 기다리는 정교한 로직(#8958). 계약화하며 깨지면 딥링크가 로그인 화면으로 튕김
|
bloc_reauthentication_waiter 는 S2 에서 먼저 이관해 순수성 확보. S4 는 그 위에서만 배선 변경 |
| 결제 흐름 회귀 | 사용자 금전 영향 | 결제 인터셉터는 마지막에 분리, 실기기 검증 필수 |
| 테스트 1,124줄 이관 누락 | 회귀 가드 상실 | Story 마다 대응 테스트를 같은 커밋에 이관 |
app_router 보존 명단 충돌 |
S3 의 등록 코드가 sync 로 덮여 소실 |
app_router
는
kPreservedGenericizedBricks
라 donor sync 가
차단
됨 → 브릭 쪽 등록 코드는 수동 반영 필요 (S5 에서 확인)
|
6. 범위 밖 (별건)#
| 항목 | 이유 |
|---|---|
notification/ 6파일 · firebase/ 1파일 |
같은 원인(C1) 이지만 FCM·Crashlytics 는 인프라 결합이 달라 별도 조사가 필요. 이 Epic 이 확립한 이관 절차를 재사용해 후속 진행 |
app_router 브릭 자체의 donor 보호 |
진행 중인 별건 (#10011 — console_router 계열) |
| 딥링크 등록 절차 문서화 | 앞선 세션의 GoRouter 등록 문서에서 "범위 밖"으로 남긴 부분. S4 완료 후 인터셉터 등록 절차가 확정되면 그때 문서화 |
7. 검증 방법#
# 각 Story 공통
melos run format & & melos run analyze
python3 .github/scripts/check_package_layers.py # 계층·유령 래칫
cd package/core & & fvm flutter test --exclude-tags patrol
cd app/unibook & & fvm flutter test test/core/service/deep_link --exclude-tags patrol
# S5 전용 — donor sync 실증
# 1) kobic → 브릭 sync
# 2) cob create 로 신규 프로젝트 생성
# 3) 생성물에서 딥링크 수신·파싱·라우팅 동작 확인
# 4) 인터셉터 0개 상태에서 범용 경로가 동작하는지 확인 ← 합격 기준
실기기 검증(딥링크는 시뮬레이터만으로 부족):
# custom scheme
adb shell am start -a android.intent.action.VIEW -d " devunibook://store/book/123 "
xcrun simctl openurl booted " devunibook://store/book/123 "
# app links
adb shell am start -a android.intent.action.VIEW -d " https://stdeeplink.unibook.co.kr/store/book/123 "
8. 관련#
- 메모리
ref-co-bricks-donor-topology— donor 공급 경로 2개, 로컬 저장소 위치 -
coco-de/unibook 리포의
.claude/plans/donor-modularity-roadmap.md— Epic kobic#9897 (본 references 디렉토리의donor-modularity-roadmap.md로 함께 이관됨) - coco-de/unibook 리포의
.claude/rules/package-layers.md— 계층 래칫, 계약 역전 패턴 선택표 -
co-bricks
lib/src/utils/known_sync_features.dart:196—kPreservedGenericizedBricks -
선례: #9664 S2(
AppNavigator) · #8490(RoutePageRegistry) · #9734(InAppLinkRouter) · #6658(IAuthGateway) - #9813 —
ServiceUrlConverterSSOT (딥링크 도메인·스킴) - #8958 —
waitForStateCycle재인증 대기 (S2/S4 의 민감 지점)