LogoSkills

딥링크 donor 추출 — co-bricks 동기화 최적화 리팩토링 계획

딥링크 2,439줄이 **앱 레이어(L7)에 있어 donor sync 공급량이 0** 이다. 결합 지점은

이관 문서 — 원출처: 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.dartbricks/network/serverpod_backend정상 공급된다. 즉 서버는 딥링크를 만들어 보내는데 클라이언트에는 그걸 받을 코드가 없는 상태로 새 프로젝트가 생성된다.

1-2. 파일별 성격 분류#

파일성격이전 대상
deep_link_service.dart 745 엔진 + kobic 특화 혼재 분해
parser/deep_link_parser.dart133범용→ core
constant/deep_link_patterns.dart 105 이미 위임 껍데기 (SSOT 는 core ServiceUrlConverter) → core 흡수
model/deep_link_route.dart87범용 (순수 데이터)→ 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
구간줄 수내용성격
초기화·리스너 ~100 AppLinks 구독, handleInitialLink 범용
수신·정규화·라우팅 ~160 handleIncomingLink, 인증 대기 후 go() 범용
_handlePaymentResult ~50 unibook://payment/result 가로채기 kobic 특화
_handleDesktopHandoff~85데스크톱 핸드오프 토큰 교환kobic 특화
_handleOAuthCallbackFallback~55OAuth 콜백 폴백kobic 특화
_handleEmailVerify~75이메일 인증kobic 특화
_handlePasswordResetVerify~85비밀번호 재설정kobic 특화
_extractFirebaseTokenFromHtml~25HTML 파싱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 에서 둘 중 하나를 택해야 한다:

  1. 실제 사용처가 특화 흐름뿐이면 → 인터셉터로 내리고 엔진에서 제거 (선호)
  2. 엔진이 저장소를 정말 필요로 하면 → core 계약(IDeepLinkStorage) 신설

S4 착수 시 _localStorage 사용처를 전수 확인해 결정한다.


2. 근본 원인 4개#

#원인근거
C1 앱 레이어(L7) 거주 — donor 주 경로 SyncMonorepoServicefeature/** · 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개로. AuthBlocIAuthReadyGate , AppRouterAppNavigator (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.dart 82줄 · coupon_link_helpers_test.dart 151줄)도 함께 이관
  • 위험도 최저 — 여기서 이관 절차·shim 방식을 확립하고 S3~S4 에 재사용

S3 — 파서·패턴 이관 + IRouteMatcher 역전 (SP 5)#

DeepLinkPatterns이미 ServiceUrlConverter(core) 위임 껍데기라 이관이 거의 삭제에 가깝다. DeepLinkParser 는 그 껍데기와 AppRouteMatcher 만 의존한다.

AppRouteMatcherapp_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_routerkPreservedGenericizedBricks 라 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:196kPreservedGenericizedBricks
  • 선례: #9664 S2(AppNavigator) · #8490(RoutePageRegistry) · #9734(InAppLinkRouter) · #6658(IAuthGateway)
  • #9813 — ServiceUrlConverter SSOT (딥링크 도메인·스킴)
  • #8958 — waitForStateCycle 재인증 대기 (S2/S4 의 민감 지점)