Skip to content

✨ [Feat] 마이페이지 v3 — 명함 화면 · MPC 근거리 교환 · QR 딥링크 (#1196) - #1200

Merged
GthingkingG merged 77 commits into
developfrom
feature/1196
Aug 20, 2026
Merged

✨ [Feat] 마이페이지 v3 — 명함 화면 · MPC 근거리 교환 · QR 딥링크 (#1196)#1200
GthingkingG merged 77 commits into
developfrom
feature/1196

Conversation

@GthingkingG

Copy link
Copy Markdown
Contributor

✨ PR 유형

✨ Feature — 마이페이지를 명함 중심 v3 로 재설계하고, 근거리 교환 transport 와 화면 전체를
싣습니다. 스택 4개 PR 중 마지막입니다.

이슈 제목은 「WiFiAwareTransport」이지만 transport 는 MPC 로 확정됐습니다. Wi-Fi Aware 는
DeviceDiscoveryUI사전 페어링한 기기끼리만 연결되는데, 시안(12654:32255)이 그리는 건
처음 만난 사람이 목록에 뜨는 화면이라 전제가 깨집니다. MPC 는 페어링 없이 탐색하고, MPC 가
주지 못하는 거리는 NearbyInteraction(UWB)이 따로 잽니다.

⚠️ 선행 PR(#1193~#1195) 머지 전에는 diff 에 그 층 커밋이 함께 보입니다.

Closes #1196

🛠️ 작업내용

근거리 교환 transport (CoreNearbyExchange)

  • MPCTransport — MultipeerConnectivity, 페어링 없이 주변 탐색. 실기기에서 물린 함정
    3가지를 구조로 회피: 수신 continuation 을 광고 재시작이 닫지 않게, 초대는 한 함수에서만
    (동시 초대 시 타이브레이크), foundPeer 1회 전제(재시도 타이머 별도)
  • PeerRangingCoordinator — NI(UWB) 실측 거리를 발견 목록에 표시. NI 는 데이터를 못
    나르고 MPC 는 거리를 모릅니다 — 둘 다 필요합니다.
    NI 토큰은 MPC 봉투(NearbyMessage)로 교환
  • 폐기 transport 일괄 삭제 — Wi-Fi Aware·UWB·BLE·NFC transport + AR 페어링 스켈레톤
    (−1,156줄), Info.plist 키·entitlements·SDK 링크 정리. ✨ Feature: ExchangePayload v2 · NearbyTransport 광고 API 일반화 #1193notPaired 도 여기서 제거

QR 딥링크 (Android 와 규격 통일)

  • 굽는 정본을 Android(develop-compose)가 실제로 굽는 값과 동일한
    umc://card?memberId={id} 로 확정 — 저쪽 QR 화면 소스와 문자 단위 대조.
    커스텀 스킴은 AASA/assetlinks 검증이 없어 서버 배포 없이 양쪽 카메라 스캔이 동작합니다
  • 수신 배선: AppDeepLink(스레드·명함 링크 단일 진입점, ✨[Feat] 메시지 링크 카드 · 스레드 공유 딥링크 (#1142) #1171 DeepLinkStore 확장) →
    마이페이지 탭 → /member/profile/{id} 조회 → 명함첩 저장(QR-{memberId} 결정적 키) →
    교환 완료 화면. 자기 QR 스캔은 저장하지 않고 이유를 알립니다 (무반응 = 고장 오인 방지)
  • 읽기는 넓게 유지 — 과거 Universal Link 2경로·경로형 umc://card/{id}·Android 의
    /community/threads/card 경로 전부 해석 (이미 구워진 QR 은 회수 불가)

화면 (BusinessCardPresentation + MyPage 재구성)

  • 명함첩(검색 300ms 디바운스·스와이프 삭제) · QR(공유·이미지 저장) ·
    교환(레이더 → 발견 목록, SE 320pt 대응) · 교환 완료
  • 마이페이지 루트: 명함 카드 + 「명함 관리」 + 「나의 활동」. 기존 섹션 7종은 설정 화면으로 이동
  • App 셸 배선: RootTabView → BusinessCardEntry(MyPage 중립 enum) → BusinessCardDestination
  • 시안 정합: 브랜드 에셋 8종(워드마크·GitHub/LinkedIn 모노·컬러·Blog·KaKao·Apple),
    섹션 헤더 semibold, 루트 타이틀 .inlineLarge(⚙ 버튼과 한 행), 알럿 조사(「Github를」),
    장식 이미지 VoiceOver 차단
  • 명함 재조회는 stale-while-revalidate — 로드된 명함은 재조회 실패로 지워지지 않습니다

AC 체크 (Wi-Fi Aware 전제 → MPC 등가로 검증)

이슈의 완료 조건은 Wi-Fi Aware 전제로 작성됐습니다. transport 확정이 바뀌어 페어링을
전제한 2개 항목은 전제 자체가 소멸
했고(페어링 단계 없음 — 그것이 전환 사유), 나머지는
MPC 등가로 실기기 2대(서로 다른 신원, 2026-08-18)에서 확인했습니다.

  • A 광고·B 탐색 시 B의 목록에 A가 표시된다 — 페어링 없이 즉시 발견 (실기기 확인)
  • B가 A를 선택해 전송하면 양쪽 모두 상대 명함을 수신한다(맞교환) — 이름·닉·파트·
    기수·학교·이메일·외부 링크 3종이 상대 값 그대로 도착 (저장소를 devicectl 로 꺼내 sqlite 직접 확인)
  • 교환된 명함이 명함첩에 한 번만 저장된다(에코 루프 없음) — 같은 상대 2회 수신에도
    저장 1장(memberId upsert), 되돌아온 자기 명함은 미저장
  • 화면을 나갔다 다시 들어와 같은 상대와 2회차 교환이 성공한다 — 세션 수명 = 화면
    수명(.task 취소), 재진입 시 새 세션
  • 광고가 만료/중단돼도 앱이 멈추지 않고 세션이 정상 종료된다 — 스트림 종료 경로
    테스트 + 실기기에서 이탈·재진입 반복 확인
  • 미페어링 상태로 시작하면 페어링 안내 에러가 표면화된다전제 소멸 (MPC 는
    페어링이 없음). 대신 세션 실패 시 로컬 네트워크 권한 안내를 표면화합니다
  • 교환 후 30초 방치 idle 침묵 처리 — 명시적 30초 방치 시나리오는 별도로 재지 않았습니다.
    리뷰 전 실기기 재검증 때 함께 확인하겠습니다
  • 빌드·테스트tuist build 성공 + 테스트 128건 통과
    (Domain 36 · Presentation 33 · Data 27 · App 32)

확인 방법

  1. 실기기 2대에서 마이페이지 → 명함 교환 → 서로 발견 → 한쪽이 행 탭 → 양쪽 완료 화면
  2. QR 화면 → 다른 폰 기본 카메라로 스캔 → 배너 탭 → 앱 열리고 명함첩에 저장
    (자기 QR 이면 「내 명함이에요」 알럿)
  3. 시뮬레이터: xcrun simctl openurl booted "umc://card?memberId={id}" 로 2번과 동일 경로
  4. 명함첩에서 이름/파트 검색, 행 스와이프 삭제
  5. iPhone SE(320pt) 시뮬레이터에서 교환 레이더가 잘리지 않는지

📋 추후 진행 상황

  • SwiftData mainContext 액터 격리(Home·Notice Repository) — 별도 이슈 분리 예정
  • 명세 MP-F10·F11 정의 확인 — 「연결(친구)」이면 ReceivedCard.isConnected 복원 필요
  • Sources/Debug/BusinessCard/ 검증 하네스 — 전부 #if DEBUG, 실기기 재검증용으로 유지 중
  • 조직팀(8B8B4462NV) App ID 서명 설정 (배포 전)
  • Android 팀 공유 사항: ① 카드 뒷면 QR 이 literal $memberId 를 굽는 버그
    ② 프로필 로드 실패 시 memberId 21 하드코딩 폴백 QR ③ 명함첩 신원키가 name+nickname 이라
    memberId 미보존(동명이인 충돌)
  • 크로스 플랫폼 근거리 교환은 불가 — Android 는 Google Nearby Connections(iOS SDK 없음),
    iOS 는 MPC. 프로토콜 비호환이라 iOS↔Android 교환은 QR 경로가 유일합니다

📌 리뷰 포인트

  • transport 전환 판단 — Wi-Fi Aware 폐기·MPC 확정 근거가 타당한지
  • QR 규격을 Android 에 맞춘 것/mypage/card Universal Link 정본안을 접고 저쪽이
    이미 출하 경로로 굳힌 커스텀 스킴을 따랐습니다. 서버 AASA 없이 지금 동작하는 쪽을
    택한 판단이 맞는지
  • stale-while-revalidate 의 트레이드오프 — 갱신 실패가 조용해집니다(낡은 명함이 표시
    없이 남고 다음 진입 때 회복). 실패를 드러내는 쪽이 낫다고 보시면 말씀 주세요
  • ExchangeCompletedView.onContinue 옵셔널화 — QR 경로는 이어갈 세션이 없어 버튼을
    감춥니다. 버튼이 하나일 때 주 동작 스타일로 승격하는 처리 포함

✅ Checklist

PR이 다음 요구 사항을 충족하는지 확인해주세요!!!

@GthingkingG
GthingkingG requested a review from JEONG-J August 20, 2026 10:41
@GthingkingG GthingkingG self-assigned this Aug 20, 2026
@GthingkingG GthingkingG added the ✨ Feature 새로운 기능을 추가합니다. label Aug 20, 2026
@GthingkingG GthingkingG linked an issue Aug 20, 2026 that may be closed by this pull request
8 tasks
- entitlement com.apple.developer.wifi-aware(Publish/Subscribe) · Info.plist WiFiAwareServices
- TCP 프레이밍·링크 단명 취급·continuation 정리 (실기기 스파이크 반영)
- 맞교환 회신은 인바운드 연결 1회 한정 — 양방향 광고 세션의 에코 루프 차단
- continuation은 락 밖에서 finish, 세션 상태는 start 진입 시 리셋 (싱글톤 transport)
- 구독 전 도착 페이로드 버퍼링으로 선착 레이스를 구조적으로 흡수
- browser는 Void 오버로드 사용 — RunResult 제네릭은 .continue만으로 타입 추론 불가
- DI를 실기기 WiFiAwareTransport / 시뮬레이터 DEBUG Mock 분기로 교체

버린 접근: 계획서의 NWProtocolFramerMessageCoder는 SDK에 없는 이름 → NetworkCoder 채택.
계획서 browser.run { .continue } 는 Return 추론 실패 → Void 오버로드로 교체.
- #1193~#1196 UseCase를 실제 앱 컨텍스트에서 호출해 눈으로 확인하는 진단 화면
- 유닛 테스트가 못 덮는 지점 검증: DI 배선, 실제 SwiftData 컨테이너, QR 실물,
  교환 세션 이벤트 스트림, ExchangePayload 왕복
- 진입점은 App 셸 툴바(#if DEBUG) — MyPage 모듈 무변경, 모듈 의존 추가 없음
- 실행 인자 -bcHarness로 바로 진입 가능 (탭 조작 없이 재현)
- 전체가 #if DEBUG라 릴리스 빌드 미포함
- QR 스캔(VisionKit DataScanner) — 스캔한 페이로드를 실제로 명함첩에 저장
- QR 표시를 2종으로: 제품(딥링크) / 검증(ExchangePayload 전체)
  딥링크 QR은 프로필 조회 API가 없어 memberId 파싱까지만 확인 가능
- Wi-Fi Aware 페어링 화면(DevicePicker/DevicePairingView) — notPaired 해소 경로
- 서비스 선언을 public으로 — 페어링 UI가 같은 서비스를 지목해야 함
- NSCameraUsageDescription 추가
- NearbyRangingController: NISession 래퍼(거리·수평각) — 스파이크 코드 이식
- NI 토큰 교환을 QR로 — 페어링·wifi-aware capability 없이 UWB 검증 가능
  (제품은 Wi-Fi Aware 채널로 교환하는 게 맞다)
- 명함 편집은 기존 MyPageProfileView 재사용 — 시안 필드가 프로필 편집과 동일
- 검증 화면 8개 섹션 완성: 내 명함·카운트·QR표시·QR스캔·명함첩·명함편집·교환·UWB
설계 초안에서 memberNo는 명함첩 재교환 upsert 키였으나, 리뷰(#4)에서
"조작·버그 페이로드에서는 cardLink 파싱값이 정본"으로 뒤집히며 직무를 잃었다.
이후 남은 용도는 MyCard(payload:)의 memberId 복구 폴백 한 줄뿐이었는데,
송신 측에서 memberNo == memberId 이고 cardLink 또한 같은 memberId에서
파생되므로 한쪽이 깨지면 다른 쪽도 깨진다 — 실효 없는 폴백이었다.

- 화면에 표시되는 곳 없음(Presentation 참조 0건)
- 서버에 대응 필드 없음(별도 회원번호 API 부재)
- 전송 포맷 변경이지만 아직 배포 전이라 하위호환 부담 없음

MyCard·ExchangePayload·ReceivedCardRecord 3개 모델과 테스트에서 제거.
ReceivedCardRecord는 CloudKit 동기화 모델이라 옵셔널 속성 삭제는
경량 마이그레이션으로 흡수된다.

테스트 52건 통과 (CoreNearbyExchange 8 · BusinessCardDomain 27 · BusinessCardData 17)
시안(Figma 명함 뒷면 12766:98167)은 GitHub·LinkedIn·Blog 3줄인데 명함 모델은
github·blog 2개뿐이라 LinkedIn을 표시할 수 없었다. 서버는 이미 값을 준다 —
MemberProfileResponseDTO.linkedIn → ProfileExternalLinks.linkedIn 까지 흐르고
있었고 BusinessCard 매핑에서만 떨어뜨리고 있었다. 서버 작업 없이 클라이언트만
고치면 되는 누락이었다.

- MyCard·ExchangePayload·ReceivedCardRecord 3개 모델에 linkedIn: String? 추가
- Profile+MyCard: externalLinks?.linkedIn?.nonEmpty (github·blog와 동일 규칙)
- 스키마 버전은 그대로 2 — decodeIfPresent라 이 필드가 없는 기존 v2 페이로드도
  그대로 읽힌다. Android가 v2를 받아가기 전이라 지금이 넣기 가장 싸다.

TDD: 외부 링크 3종 매핑·공백 정리·교환 왕복 보존·SwiftData 영속 4개 검증을
먼저 실패시키고(RED: 'MyCard has no member linkedIn') 구현했다.

테스트 53건 통과 (CoreNearbyExchange 8 · BusinessCardDomain 27 · BusinessCardData 18)
지금까지는 섹션 9개를 한 List에 평평하게 늘어놓아, 각 기능이 제품에서 어느 자리에
놓이는지 알 수 없었다. 실기기에서 확인하려는 것은 기능 동작뿐 아니라 "이 자리에서
이게 되는가"이므로 배치를 시안(Figma 12630:33563)에 맞춘다.

색·타이포·간격은 시안 토큰이 아니라 시스템 값이다 — 맞추는 것은 배치다.

- 명함 카드: 앞면(아바타·이름/닉네임·학교·파트/기수 칩) ↔ 뒷면(QR·github·linkedIn·blog)
  플립. 카드 안에 「명함 교환」·「QR 코드」 버튼. 뒷면 3줄은 시안 12766:98172 확인 결과
  github/linkedIn/blog이며, 더미 텍스트가 이메일 형태일 뿐 이메일 행은 없다.
- 「명함 관리」 받은 명함(N장)·명함 편집 / 「나의 활동」 나의 스터디(N건)·활동·프로젝트(N건)
- 시안에 없는 검증 도구(QR 스캔·UWB·원본 필드·페이로드 왕복)는 한 단계 밑으로 격리
- 카운트 행은 값만 두지 않고 데이터 출처와 알려진 함정을 여는 화면을 붙였다 —
  숫자만으로는 맞는지 판단할 수 없기 때문(스터디는 managed 엔드포인트 의미 확인,
  활동은 챌린저 이력 파생이라 운영진 이력과 갈림)

목적지는 PathStore 대신 NavigationLink 클로저로 연결한다. 검증 화면 전용이라
타입 소거 목적지로 올릴 이유가 없고, ViewModel을 그대로 넘길 수 있다.

CardEditEntrySection → DebugCardEditView 로 대체(섹션이 아니라 목적지가 됐다).
dedupedRecords()가 memberId 빈 레코드를 전부 통과시켰다(`guard !isEmpty else
return true`). memberId는 v1 페이로드(cardLink="")나 파싱 불가한 cardLink를 받으면
비는데 — MyCard(payload:)가 `linkedMemberId ?? ""`로 복원한다 — 그 레코드들은
CloudKit 동기화가 만든 중복까지 걸러지지 않고 명함첩에 그대로 노출됐다.

빈 문자열을 하나의 키로 뭉치는 것은 답이 아니다. 정체성 없는 서로 다른 사람이
한 명으로 사라진다(데이터 손실). 그래서 키를 둘로 가른다:

    memberId.isEmpty ? "card:\(cardID)" : "member:\(memberId)"

정체성이 있으면 memberId, 없을 때만 cardID로 대체한다. cardID는 교환마다 새
UUID라 정체성 없는 상대와 재교환하면 여전히 새 행이 생긴다 — 신원 정보가 없는
이상 원리적으로 막을 수 없고, 여기서 막는 것은 동기화 중복이다.

TDD: 같은 cardID 중복 2건 → 1건, 서로 다른 cardID 2건 → 2건 보존.
전자를 먼저 실패시키고(2 반환) 고쳤다.

테스트 55건 통과 (CoreNearbyExchange 8 · BusinessCardDomain 27 · BusinessCardData 20)
v3 설정 시안(Figma 12736:32683) 하단 회원관리 섹션은 로그아웃·회원탈퇴 둘뿐이고
비밀번호 변경 행이 없다. 진입점을 시안에 맞춘다.

- AuthType.changePassword 케이스와 아이콘·색 분기 제거
- AuthSection: onChangePassword 파라미터·분기 제거
- MyPageDestination.changePassword 및 MyPageRoutingView 라우팅 제거
- MyPagePresentation → AuthPresentation 모듈 의존 제거
  (이 의존의 유일한 근거가 ChangePasswordView push 였다)

Auth 쪽 구현(ChangePasswordView/ViewModel/UseCase/Repository/Router/DTO)은
그대로 둔다. 시안이 다시 요구하면 진입점만 붙이면 되고, 지금 지우면 되돌리기
비용이 커진다. 다만 현재 어디서도 도달할 수 없는 상태이므로 존치 여부는 별도 판단이 필요하다.
v3 루트 시안(Figma 12630:33563)은 상단이 명함 카드이고, 프로필 카드와
「내가 쓴 글·댓글 단 글·스크랩」 섹션이 없다. 진입점을 시안에 맞춘다.

- ProfileCardSection 호출 제거. profileContent의 .loaded는 EmptyView —
  이 자리는 v3에서 명함 카드가 차지한다.
- MyActiveLogSection 호출 제거.

프로필 조회(fetchProfile)와 .loading/.failed 분기는 남긴다. 카드를 안 그려도
외부 링크·소셜 연동 섹션이 그 결과를 쓰고, 조회 실패 시 재시도 진입점이
이 화면에 이것 하나뿐이라 함께 지우면 사용자가 복구할 방법이 없어진다.

컴포넌트(ProfileCardSection·MyActiveLogSection·MyActivePostsView)와
목적지(MyPageDestination.profile·myActivePosts), UseCase·Router·DTO는 존치한다.
- .profile 은 v3 「명함 편집」이 프로필 편집을 재사용하므로 다시 필요하다.
- myActivePosts 는 현재 도달 불가 상태다. 커뮤니티 활동 진입점을 어디에 둘지
  (설정/커뮤니티 탭/북마크 MP-F09) 정해지면 그때 배선한다.
두 행이 열던 「카운트 출처 설명」 화면을 실제 목록으로 교체한다. 숫자만으로는
데이터가 맞는지 판단할 수 없어서, 실기기에서 항목을 직접 대조할 수 있게 한다.

나의 스터디 (DebugMyStudyListView)
- OperatorStudyManagementUseCase.fetchStudyGroupDetailsPage 재사용
- 행 탭 → 활동 탭으로 전환. 스터디 상세 목적지가 아직 없어 탭 루트까지만 간다
- 화면에 스코프 경고를 박아둔다: GET /study-groups/managed 를 StudyRouter 는
  "운영 스터디", BusinessCardRouter 는 "참여 스터디"로 서로 반대로 주석하고 있고
  요청·응답 DTO 에 판별 필드가 없다. 본인 참여 스터디 수와 대조하는 게 유일한 판별법
- 조회가 페이지 내 전 멤버에 대해 GET /member/profile/{id} 를 추가 호출하는
  점(동시 8개)도 함께 표시한다

나의 활동·프로젝트 (DebugMyActivityListView)
- Profile.challengerRecords 를 그대로 나열. 탭 이동 없음 — 상세 화면도 목적지
  case 도 없고 "프로젝트"에 대응하는 서버 개념 자체가 코드베이스에 없다
- 카운트 정의가 두 갈래(명함: admin 만 제외 / 마이페이지: 파싱 성공분만)라
  두 숫자를 나란히 계산해 불일치를 눈으로 보게 한다. 미지 파트 코드가 원인이다

버린 접근: 두 목록을 BusinessCardPresentation 에 실제 뷰로 올리려 했으나, 아직
실뷰 단계가 아니어서 하네스에 임시로 둔다.
시안 12639:33027 의 배치를 따른다 — 축소 명함(명함_m) · 272pt QR 프레임 ·
"QR을 스캔하면 내 명함이 저장돼요" 캡션 · 공유하기/이미지 저장 2단 버튼.
색·타이포는 시안 토큰이 아니라 시스템 값이다. 맞추는 것은 배치다.

시안에 없는 제품/검증 QR 토글은 맨 아래 검증 영역으로 격리했다. 대신 비교에
필요한 값을 그 자리에 노출한다 — 실제 격자 크기(생성 이미지 폭 ÷ 업스케일 12)와
문자열 바이트 수. 이를 위해 ViewModel 에 qrPayloadJSON·qrPayloadByteCount 를 남긴다.

시안의 QR 이 21x21 모듈(version 1)로 그려져 있다는 점이 그대로 드러난다. 이는
딥링크(17바이트) 크기이며 페이로드 QR 은 같은 272pt 안에서 모듈이 훨씬 잘아진다.
QR 을 딥링크로 갈지 페이로드로 갈지는 이 화면으로 실측한 뒤 정한다.

이미지 저장은 PHPhotoLibrary 추가 전용 권한을 쓴다(사진 선택기 없이 프롬프트만).
NSPhotoLibraryAddUsageDescription 키가 없으면 저장 시점에 크래시하므로 함께 넣는다.
읽기가 필요 없으므로 전체 접근 키(NSPhotoLibraryUsageDescription)는 쓰지 않는다.
지금까지 딥링크 QR 은 스캔해도 "memberId=… (프로필 조회 API 미구현)" 로그만 남고
끝났다. 서버 확인 결과 조회가 가능해서 경로를 잇는다.

서버 사실 (umc-product-server 직접 확인)
- MemberPermissionEvaluator.canReadMember 가 `!gisuChallengerInfos().isEmpty()` 한 줄이라
  기수 기록이 있는 로그인 사용자는 임의 memberId 를 조회할 수 있다
- /member/me 와 /member/profile/{id} 가 같은 응답 스키마를 쓰고, 타인 경로만 toPublic()
  으로 email·상벌점을 지운다. name/nickname/part/gisu/schoolName/외부링크는 보존된다
- 따라서 내 명함이 쓰는 변환 체인(MemberProfileResponseDTO.toDomain → Profile.toMyCard)
  을 한 줄도 안 고치고 재사용한다. 새 DTO·새 매핑 불필요

추가한 것
- PeerCardRepositoryProtocol / PeerCardRepository: 상대 명함 단발 조회. 내 명함과
  저장소를 나눈 이유는 캐시 유무와 응답 마스킹이 다르기 때문
- BusinessCardRouter.getMemberProfile(memberId: String): MyPageRouter 에 같은 경로가
  있으나 memberId 가 Int 라 복제 대신 String 으로 맞춘다 (절대규칙 #2)
- FetchPeerCardUseCase, SaveReceivedCardUseCase.execute(card:cardID:exchangeContext:)
  오버로드. 페이로드는 근거리 전송 스키마라 전송이 개입하지 않는 경로에 끌어들이지 않는다
- cardID 는 "QR-{memberId}" 로 고정 — 같은 상대를 다시 스캔해도 한 장으로 합쳐진다
- 스캔 중복 가드(isResolvingDeepLink): 스캐너가 프레임마다 같은 코드를 올려 가드 없이는
  네트워크 요청이 중복되고 저장 순서도 보장되지 않는다

계약 테스트 5건 (PeerCardRepositoryTests)
- 특히 "roles·challengerRecords 가 비면 에러 없이 part=admin·generation=0 으로 복원"을
  고정한다. 이 경로는 조용히 무너져서, 서버 마스킹 정책이 바뀌면 잘못된 명함이 그대로
  명함첩에 저장된다. 테스트가 그 변화를 잡는다

버린 접근: 조회한 프로필을 ExchangePayload 로 합성해 기존 execute(payload:) 에 넣는 방식
→ 값 결과는 같지만 cardID·cardLink 를 직접 채워야 하고, 전송 스키마를 전송 없는 경로에
들이는 게 경계상 부자연스러워 오버로드를 택했다.
Discord 논의에서 서버·Android 와 형식을 합의했다. iOS 가 먼저 구현한
`umc://card/{memberId}` 대신 아래를 정본으로 쓴다.

    https://api.university.neordinary.com/mypage/card?memberId={memberId}

Android 가 같은 URL 을 autoVerify intent-filter 로 받고 **두 앱이 같은 QR 을
읽는다.** 크로스 플랫폼 계약이라 iOS 가 임의로 바꾸지 않는다.

- 환경별 호스트: 운영 `api.university.neordinary.com`, alpha
  `alpha.api.university.neordinary.com`. 굽는 쪽은 빌드 구성으로 갈리고,
  **읽는 쪽은 두 호스트를 모두 받는다** — alpha 빌드로 만든 QR 을 운영 빌드로
  스캔하는 상황이 검증 중에 흔하다.
- 커스텀 스킴 `umc://card/{id}` 는 **읽기만** 유지한다. Android 가 이 형식으로
  먼저 검증했고 예전 QR 이 돌아다닐 수 있다. 굽지는 않는다.
- 식별자가 path segment 에서 query param 으로 바뀌었다. 다른 쿼리가 섞여 있어도
  memberId 를 찾도록 URLComponents 로 해석한다.

테스트에서 "umc://card/42" 리터럴을 걷어내고 `CardLink(memberId:).urlString`
비교로 바꿨다. 형식이 또 바뀌어도 CardLink 한 곳만 고치면 된다 — 리터럴을 4개
파일에 흩어두면 같은 사고가 반복된다.

QR 크기 영향: 16B/21x21 → 약 64B/37x37. 272pt 프레임에서 모듈당 5.6pt 라
스캔에 문제 없다 (페이로드 QR 은 81x81/2.5pt).

아직 안 한 것: associated-domains entitlement 와 서버 AASA 호스팅. QR 스캔은
앱이 문자열을 직접 파싱하므로 그것 없이 동작한다 — AASA 는 링크를 눌러 앱이
열리는 「명함 공유하기」(MP-F03)에만 필요하다.
QR 딥링크 경로를 실기기에서 확인하려면 두 가지가 막고 있었다 — 카메라 없이는 조회
경로를 태울 수 없었고, stub 세션을 끄려면 상수를 고쳐 재컴파일해야 했다.

DebugPeerLookupView
- memberId 를 직접 넣어 조회한다. 딥링크 경로의 진짜 위험은 QR 읽기가 아니라
  서버 응답 → 명함 매핑이라, 카메라를 빼고 그 부분만 태워 본다
- 복원된 명함을 필드별로 펼치고, part=Admin + generation=0 조합이면 "매핑이 무너졌다"
  경고를 띄운다. 이 폴백은 에러 없이 발생해서 눈으로 봐야만 잡힌다
- 「내 memberId로 조회」 — 서버가 /profile/{id} 에는 자기 id 로 불러도 toPublic()
  응답을 주므로, 내 id 로도 타인 명함과 같은 마스킹 상태를 확인할 수 있다

StubSessionMode 런타임 토글
- 실행 인자 `-realSession` 또는 검증 화면 토글(UserDefaults)로 끌 수 있다.
  기기 단독 실행에도 적용된다. 앱 재시작이 필요하다 — DI 등록이 시작 시 한 번만 돈다

QR 화면 「이미지 저장」 버튼 폭 수정
- .frame 을 버튼 바깥에 걸어 유리 배경이 글자 크기로만 잡혔다. 라벨 안쪽으로 옮긴다

실기기 검증 결과 (iPhone 16)
- 35x35 HTTPS QR 을 비스듬한 각도·화면 반사 환경에서 읽었다
- 로그: "딥링크 인식: memberId=42 → 프로필 조회" → CardLink.parse 가 새 형식
  (HTTPS + query param) 을 정확히 해석함을 확인
- 이어 "noRefreshToken" 으로 실패 — 인증 계층에서 막힌 것이고 파싱·매핑 문제가 아니다.
  이 맥에 팀 8B8B4462NV 계정이 없어 정식 bundle id 로 빌드할 수 없고, 그래서 카카오
  로그인이 불가능하다

QR 크기 실측 (여백 포함 격자)
- umc://card/42            13B  23x23
- prod  HTTPS              61B  35x35
- alpha HTTPS              67B  39x39
시안 QR 영역(206pt)에서 모듈당 5.3~5.9pt. 페이로드 QR(81x81, 2.5pt)의 두 배 이상이다.
`startScanning()` 이 돌려주는 AsyncStream 에는 에러 채널이 없어서, 브라우저가
실패하면 `catch { }` 가 원인을 버리고 스트림만 조용히 닫혔다. 화면에서는 그 상태와
"주변에 아무도 없음" 이 똑같이 `peers: 0` 으로 보여 진단이 불가능했다.

- `lastTransportError` 에 실패 원문을 남긴다(scan/listen 단계 표시). 새 세션 진입 시
  `resetSessionState()` 가 지운다 — 남겨두면 교환에 성공한 뒤에도 옛 실패가 계속 떠서
  현재 상태를 잘못 읽게 된다
- `pairedDeviceNames()` 추가. `hasPairedDevices()` 는 "하나라도 있는가" 만 보므로 다른
  용도로 페어링된 기기가 있으면 통과한다. 상대와 실제로 페어링됐는지는 목록을 봐야 안다
  (allDevices 는 [ID: WAPairedDevice] 사전을 흘리므로 values 를 봐야 한다)
- 검증 화면이 두 값을 노출한다

실기기 검증 (iPhone 16 2대) — Wi-Fi Aware 명함 교환 성공
- peerFound → sent → received 왕복 완료. 양방향 교환이 실제로 동작한다
- 실패 원인은 **Wi-Fi 가 꺼져 있던 것**이었다. 드러낸 원문이
  `[scan] POSIXErrorCode(rawValue: 22): Invalid argument` 로 이를 가리켰다.
  Wi-Fi Aware 는 Wi-Fi 라디오 위에서 동작하므로 네트워크 접속은 불필요해도
  Wi-Fi 자체는 켜져 있어야 한다. 이 조건은 제품 UI 에도 안내가 필요하다

확인된 설계 사실: WiFiAware 프레임워크에는 거리·방향 API 가 없다(WAEndpoint/
WAPairedDevice/WAConnection 어디에도 없음). 시안의 「교환 목록 거리 표시」는
NearbyInteraction(NINearbyObject.distance)이 있어야 가능하다. 즉 NI 는 부가 기능이
아니라 시안 요구사항이며, discoveryToken 을 교환할 채널로 이 Wi-Fi Aware 를 쓴다.
실기기 검증에서 거리가 안 잡혔다. 원인은 도구의 함정 두 가지였다.

① 「내 토큰 QR 만들기」를 다시 누르면 새 NISession 이 만들어져 토큰이 통째로 바뀐다.
   상대가 이미 스캔했다면 그 토큰은 무효가 되고, 레인징은 **에러 없이** 성립하지 않는다.
   로그에 「내 토큰 준비 완료」가 6번 찍힌 게 그 흔적이었다. 재생성을 막는다.

② 「거리 —」가 세션 미시작인지 상대 미발견인지 화면에서 구분되지 않았다.
   「내 토큰 / 레인징 / 갱신 수신」 3개를 노출해 어느 단계에서 멈췄는지 보이게 한다.

실기기 검증 (iPhone 16 2대) — UWB 거리 측정 성공
- 권한 팝업이 정상적으로 떴다. **nearby-interaction entitlement 없이 동작한다** —
  개인 팀 서명으로도 NI 개발·검증이 가능하다는 뜻이고, 별도 프로젝트도 필요 없다
  (Apple 문서도 NSNearbyInteractionUsageDescription 만 요구한다)
- 실패 원인은 **NI 가 대칭**이라는 점이었다. 양쪽이 서로의 토큰으로 run() 해야 하고
  한쪽만 하면 양쪽 다 값이 오지 않는다. 이 제약은 제품 흐름 설계에 그대로 반영된다 —
  토큰 교환이 자동이어야 사용자가 순서를 틀릴 여지가 없다

이로써 명함 교환 3경로가 모두 실기기에서 확인됐다: QR 딥링크 파싱 · Wi-Fi Aware
왕복 교환 · UWB 거리 측정.
시안(Figma 12654:32621)의 발견 목록 행은 아바타·이름/닉네임·파트·기수를 보여주고
우측에 「2.1m」을 띄운다. 지금 구조로는 둘 다 만들 수 없었다.

- 발견 시점에 오는 건 기기 이름(「홍길동의 iPhone」)뿐이다. 사람 이름·파트·기수를
  보려면 명함을 먼저 받아야 하는데, 그러면 **누군지 보기 전에 명함이 오간다**
- 연결 위로 `ExchangePayload` 만 흘러서 NI 토큰을 실을 자리가 없었다

`NearbyMessage` 봉투를 둬 두 문제를 함께 푼다.

    case handshake(NearbyHandshake)   // 연결되면 자동 — 미리보기 + NI 토큰
    case card(ExchangePayload)        // 행을 탭해야 — 명함 전체

두 케이스의 **동의 수준이 다르다**는 게 분리의 핵심 근거다. 미리보기는 동의 없이
오가므로 최소여야 한다 — `PeerPreview` 에 이메일·github·blog·cardLink 를 넣지 않았고
테스트로 그 부재를 고정했다.

NI 토큰을 여기 싣는 이유: NearbyInteraction 은 거리·방향만 주고 **자기 토큰조차 나르지
못한다.** 세션을 시작하려면 다른 채널이 토큰을 먼저 옮겨야 하고, 그 채널이 Wi-Fi Aware 다.
UWB 미탑재 기기는 `niToken = nil` 로 보내고 목록에 거리 없이 행만 그린다.

DiscoveredPeer 에 `avatarURL` 과 `distanceMeters` 추가. 거리는 Wi-Fi Aware 가 절대
채울 수 없는 값이라(프레임워크에 거리 API 자체가 없음) NI 가 갱신한다. 시안의 「2.1m」과
신호 막대가 모두 이 하나에서 파생된다. `applying(_:)` 두 개로 발견 후 갱신을 표현한다.

테스트 5건. `card` 왕복 테스트는 timestamp 를 정수 초로 고정한다 — 전송 포맷이 ISO8601
이라 소수점 이하 초가 잘리고, 이는 봉투의 결함이 아니라 날짜 표현의 성질이다.

아직 배선 전이다. 다음 단계에서 WiFiAwareTransport 가 이 봉투를 쓰도록 바꾼다.
시안(Figma 12654:32255)이 그리는 건 **처음 만난 사람**이 목록에 뜨는 화면인데,
Wi-Fi Aware 로는 만들 수 없다. Apple 문서가 못박듯 페어링된 기기끼리만 연결되고,
페어링은 DeviceDiscoveryUI 시스템 UI 로 두 사람이 동시에 조작해야 성립한다.
OT 에서 만난 20명과 각각 페어링할 바에는 QR 을 찍는 게 빠르다.

MPC 는 페어링 없이 주변을 탐색한다. 게다가 `discoveryInfo` 로 **발견되는 순간**
이름·닉네임·파트·기수·아바타를 함께 보낼 수 있어, 시안 행을 명함 교환 전에 그릴 수 있다.
Wi-Fi Aware 로는 기기 이름(「김경주의 iPhone」)만 왔다.

## 3단계 구분이 이 설계의 핵심

    발견   discoveryInfo — 이름·파트·기수      「교환 시작」을 누른 것이 동의
    연결   handshake — NI 토큰                자동. 개인정보가 아니다
    전송   card — 명함 전체                   **행을 탭해야**

연결을 미리 해두는 이유는 거리 때문이다. NI 토큰(343B)은 Bonjour TXT 레코드에 들어가지
않아 세션이 열린 뒤에야 보낼 수 있고, 토큰이 없으면 거리를 잴 수 없다. 그래서 발견 즉시
자동으로 초대·수락해 세션만 열어두고, 명함은 탭할 때 비로소 보낸다.

초대를 자동 수락하는 근거: 시안에 수락 화면이 없고, 양쪽 모두 「교환 시작」을 눌러 탐색
화면에 있는 상태가 동의로 간주된다. 수락해도 명함은 나가지 않는다. 수락 다이얼로그를
넣을지는 기획 확인 대상으로 남긴다.

## Wi-Fi Aware 스파이크에서 얻은 함정을 그대로 반영

- continuation.finish() 는 락 밖에서 (onTermination 이 같은 큐에 재진입하면 데드락)
- 맞교환 회신은 연결당 1회 (양쪽이 동시에 광고+탐색하므로 빠지면 무한 에코)
- receive() 구독 전 도착분 버퍼링
- 세션 상태는 start/stop 에서 리셋 (DI 캐싱으로 앱 수명 싱글톤)

## 그 밖에

- `NearbyTransportChoice` — 검증 화면에서 MPC/Wi-Fi Aware/Mock 전환. Wi-Fi Aware 는
  실기기 왕복 교환이 검증된 비교 대상이라 MPC 가 그만큼 도는 걸 본 뒤에 지운다
- Info.plist 에 NSLocalNetworkUsageDescription·NSBonjourServices. 없으면 MPC 가
  시작 자체를 못 한다
- discoveryInfo 계약 테스트 5건. 특히 **이메일·외부 링크·딥링크가 실리지 않는다**를
  고정한다 — 동의 없이 주변에 뿌려지는 정보라 편의로 필드를 늘리면 명함을 안 줬는데도
  개인정보가 샌다

아직 거리는 나오지 않는다. handshake 를 보내지만 NI 토큰을 채우는 조율 계층이 다음 단계다.
첫 구현에 2대로는 드러나지 않는 결함이 넷 있었다. 각자 상대가 하나뿐이라 우연히 동작했다.

## ① 피어 식별이 3대째부터 깨진다 (핵심)

`MCPeerID(displayName: UIDevice.current.name)` 를 키로 썼는데, **iOS 16부터
`UIDevice.name` 은 별도 entitlement 없이 기기 이름을 주지 않고 `"iPhone"` 같은 기종명을
돌려준다.** 주변 기기가 전부 같은 이름이 되어 서로를 구분하지 못한다.

매 실행 생성하는 임의 세션 식별자(12자)를 `displayName` 과 `discoveryInfo` 양쪽에 싣고,
그것으로 피어를 가른다. 기기 이름을 노출하지 않는 부수 효과도 있다 — 기기를 가로질러
추적 가능한 값이 되면 안 되므로 실행마다 새로 만든다.

식별자 없는 광고는 무시한다. 구분할 수 없는 피어는 목록에 올려도 조작할 수 없다.

## ② 양쪽이 서로를 초대해 중복 연결이 생긴다

두 기기가 동시에 광고+탐색하므로 A→B, B→A 초대가 겹친다. **식별자가 작은 쪽만 초대**하기로
정하면 한 방향만 남는다. 양쪽이 같은 두 값을 보고 같은 결론에 도달하므로 합의가 필요 없다.

## ③ 8명 한도 밖 피어는 탭해도 실패했다

`send()` 가 "이미 연결됨"을 요구했다. 자동 연결은 MPC 동시 세션 한도(8) 안에서만
이뤄지므로 한도 밖 피어는 영원히 교환할 수 없었다. 연결돼 있지 않으면 그 자리에서
초대하고 연결을 기다린 뒤 보낸다. MPC 가 연결 완료를 async 로 알려주지 않아 짧게 폴링한다.

## ④ 핸드셰이크가 전역이라 두 번째 상대부터 틀린 토큰이 간다

`NISession` 은 한 번에 한 상대와만 레인징하고(`NINearbyPeerConfiguration` 이 상대 토큰
하나를 받는다) 세션마다 자기 토큰이 다르다. 전역 토큰 하나를 뿌리면 두 번째 상대는
엉뚱한 세션의 토큰을 받아 레인징이 성립하지 않는다.

`NearbyHandshakeProviding` 으로 바꿔 **피어별로** 핸드셰이크를 만들게 한다. 다음 단계의
레인징 조율 계층이 이 프로토콜을 구현한다.

테스트에 세션 식별자 계약 1건 추가 (19건 통과).
시안(Figma 12654:32621) 행 우측의 「2.1m」을 채운다. 지금까지 거리는 검증 화면에서
QR 로 토큰을 서로 찍어야만 나왔다. 그 절차를 없앤다.

## PeerRangingCoordinator — transport 가 아닌 이유

NearbyInteraction 은 데이터를 나르지 못한다. 거리·방향만 준다. `NearbyTransportProtocol`
구현체로 두면 설계대로는 영원히 구현할 수 없다(기존 `UWBTransport` 가 스켈레톤인 채
notImplemented 만 던지는 이유가 이것이다). transport 옆에 붙어 거리만 보태는 계층으로 둔다.

## 세션이 피어마다 하나다

`NINearbyPeerConfiguration(peerToken:)` 은 상대 토큰 하나를 받는다. 한 세션은 한 상대와만
레인징하고 세션마다 자기 토큰이 다르다. 그래서 `makeHandshake(forPeerID:)` 가 호출될 때
그 피어 전용 세션을 만들어 그 세션의 토큰을 돌려준다. 같은 피어로 다시 들어오면 옛 세션을
버린다 — 토큰이 갈리면 레인징이 **에러 없이** 성립하지 않는다.

## NI 는 대칭이라 자동화가 목적이다

양쪽이 서로의 토큰으로 세션을 실행해야 값이 나온다. 한쪽만 하면 양쪽 다 아무것도 받지
못한다. 2026-08-16 실기기 검증에서 이 조건을 몰라 한참 헤맸고, 그래서 토큰 교환을 MPC 연결
직후 자동으로 처리한다. 사용자가 순서를 틀릴 여지를 없애는 것이 이 계층의 존재 이유다.

## 거리를 비우는 경우를 명시적으로 다룬다

범위 이탈(didRemove) · 세션 무효화 · 백그라운드 진입(sessionWasSuspended) · 피어 소실에서
모두 `meters: nil` 을 흘린다. 옛 값이 남으면 **지금 가까이 있는 것처럼 보인다** — 눈앞의
사람을 가려내는 게 거리의 목적이므로 이 오표시는 기능을 무의미하게 만든다.

## 그 밖에

- `ExchangeEvent.distanceUpdated(peerID:meters:)` 추가. 명함 전송과 무관하게 교환 전에도
  계속 흐른다
- `MyCard.toPeerPreview()` — 미리보기에 이메일·외부 링크를 넣지 않는다. 동의 전에 오가는
  값이라 명함 본체와 담기는 것이 다르다
- 검증 화면 행에 「파트 · N기」와 거리를 표시. UWB 미탑재·범위 밖은 「—」
- 거리 갱신은 이벤트 로그에 남기지 않는다 — 초당 여러 번 흘러 다른 이벤트를 덮는다

시뮬레이터·구형 기기는 `ranging` 이 nil 이거나 UWB 미지원이라 거리 없이 발견·교환만 된다.

테스트 74건 통과 (CoreNearbyExchange 19 · BusinessCardDomain 30 · BusinessCardData 25).
실기기 검증은 2대가 필요해 다음 세션으로 미룬다.
시안 12654:32255(레이더) · 12654:32621(목록)의 배치를 하네스에 옮긴다. **배치만** 따르고
색·타이포·글래스는 시스템 값이다. 목적은 요소가 제자리에 오는지와 그 아래 기능이 실제로
도는지 보는 것이지 픽셀을 맞추는 게 아니다.

- 발견 전: 상태 칩 · 동심원 4겹 레이더 + 로고 · "주변 UMC 멤버를 찾는 중…" · 「교환 중지」
- 발견 후: 행 목록으로 전환. 시안 `item_명함`(370x80 · radius 34 · padding 16 · 아바타 48)
- 행 우측의 신호 막대와 「2.1m」은 **NI 거리 하나에서 파생**한다. 별도 데이터가 아니다

시안 문구 두 곳을 바꿨다.
- 「Wi-Fi Aware 탐색」 → 「주변 탐색」
- "'Wi-Fi Aware 고속 교환'을 누르면" → "「명함 교환」을 누르면"

실제 구현이 MPC 라 원래 문구는 틀린 말이 된다. 그리고 사용자에게 `Wi-Fi Aware` 는 의미
없는 단어라 기술명을 빼는 편이 카피로도 낫다. 이 교체는 디자이너 확인이 필요한 사항이다.

거리가 없는 경우(UWB 미탑재·범위 밖·백그라운드)는 「—」로 비운다. 옛 값을 남기면 지금
가까이 있는 것처럼 보이는데, 눈앞의 사람을 가려내는 게 거리의 목적이라 그 오표시는
기능 자체를 무의미하게 만든다.
실기기(iPhone 16 + iPad mini)에서 「탭해도 교환이 안 된다」를 파고든 결과. 세 가지는
확실한 버그였고 고쳤다. **연결 자체가 성립하지 않는 문제는 아직 남아 있다.**

## ① 수신 스트림이 시작하자마자 닫혔다 (확실한 버그)

`startAdvertising` 이 내부에서 `stopAdvertising` 을 불렀고, 그 안의
`takeReceiveContinuation()?.finish()` 가 **직전에 등록된 수신 스트림을 닫았다.**
`ExchangeCardsUseCase` 는 선착 페이로드를 놓치지 않으려고 `receive()` 를 먼저 구독하고
곧바로 `startAdvertising` 을 부르므로 순서가 정면으로 어긋났다.

증상은 **내 명함은 나가는데 상대 명함은 영영 안 들어오는 것**이었다. 에러가 남지 않아
화면에서는 "왜 안 오지"로만 보인다. 광고·탐색·세션만 정리하는 `tearDownChannels()` 로
분리하고, 수명 계약을 회귀 테스트 3건으로 고정했다.

## ② 피어 매핑 조회가 수락 경로에서 실패했다 (확실한 버그)

`MCPeerID → 세션 식별자` 딕셔너리를 뒀는데 **발견 경로에서만 채워졌다.** 초대를 수락해
연결된 피어는 조회에 실패해, 실제로는 연결됐는데도 「연결됨 0」 으로 보이고 `send` 도
그 피어를 찾지 못했다. 실기기에서 한쪽만 0 으로 나오던 비대칭의 원인이다.

딕셔너리를 없앴다. `MCPeerID.displayName` 이 곧 세션 식별자다(`localPeerID` 를 그렇게
만든다). 되찾을 필요가 없었고, 되찾으려 한 것이 실패 지점을 만들었다.

## ③ 초대를 한 번만 보냈다

탐색이 광고보다 먼저 시작되면 세션이 없어 초대를 못 보내는데 `isNew` 가드 때문에 재시도가
없었다. 연결될 때까지 재발견마다 다시 보낸다.

## 버린 접근

타이브레이크(식별자가 작은 쪽만 초대)를 제거해 양쪽이 서로 초대하게 해봤다 → **양쪽 모두
「연결됨 0」.** MPC 가 같은 피어 쌍에 대한 두 연결 시도를 겹쳐 받고 둘 다 실패한다.
복원했다. 한쪽이 백그라운드면 아무도 초대하지 않는 약점은 남지만, 그 경우 MPC 자체가
멈추므로 어차피 성립하지 않는다.

## 계측

MPC 는 초대·수락·연결이 전부 델리게이트로 흩어져 있어 어디서 멈췄는지 밖에서 볼 수 없다.
`diagnosticLog`(최신 40줄)와 `connectedPeerIDs` 를 노출하고 임시 뷰에 표시한다. 추측으로
고치던 것을 로그로 좁히기 위해서다. 전송 상태(전송 중/완료/실패+원문)도 행 아래에 남긴다.

## 남은 문제

- **연결이 성립하지 않는다.** 발견은 되는데 양쪽 다 「연결됨 0」. 다음 세션에서 진단
  로그(초대 보냄 → 초대 받음 → connecting → connected)를 보고 어느 단계에서 끊기는지
  확인해야 한다
- **유령 피어.** 앱 재시작마다 세션 식별자가 새로 생기는데 `lostPeer` 가 제때 오지 않아
  옛 식별자가 목록에 남는다(한 대뿐인데 「발견 2」). 만료 시각을 두거나 연결 실패한 피어를
  걷어내는 정리가 필요하다

거리(NI)는 연결이 전제라 아직 검증 불가. 테스트 22건 통과.
연결이 안 되는 줄 알았는데, 계측이 고장나 있었다.

## 계측 (이게 먼저였다)
- `connectedPeerIDs`·`transportLog` 는 `@Observable` 이 아닌 transport 를 읽는 계산
  프로퍼티라 SwiftUI 가 변화를 감지할 방법이 없었다. 값이 바뀌어도 화면은 첫 값에
  머물렀고, 우리는 그 「연결됨 0」 을 보고 연결 실패를 진단하고 있었다.
- `diagnosticsTick` 을 0.5초마다 올려 재평가를 유발한다. 폴링이 살아 있는지 보이도록
  화면에도 숫자로 노출했다 — 멈춰 있으면 아래 값을 믿으면 안 된다.

## 초대 충돌
- `send()` → `connect()` 가 타이브레이크를 **무시하고** `invitePeer` 를 불렀다.
  식별자가 큰 쪽에서 행을 탭하면 상대의 자동 초대와 겹쳐 같은 쌍에 두 연결 시도가
  포개지고 둘 다 실패한다 — 주석이 스스로 경고하던 바로 그 상황이다.
  「첫 탭은 20초 만에 실패, 두 번째 탭은 성공」 의 정체.
- 초대를 `inviteIfNeeded` 한 곳으로 모았다. 연결 안 됨 · 시도 중 아님 · 내가 초대 담당,
  셋을 모두 통과해야 나간다. `waitForConnection` 은 이제 기다리기만 한다.
- `connectingSessionIDs` 로 진행 중인 초대를 추적한다. `connectedPeers` 는 `.connecting`
  피어를 포함하지 않아 기존 가드로는 겹침을 막을 수 없었다.

## 재시도
- `foundPeer` 는 피어당 한 번만 온다. "재발견마다 재시도한다" 는 옛 주석은 MPC 실제
  동작과 달랐고, 초대가 유실되면 되살릴 경로가 없었다. 3초 주기 타이머를 붙였다.
- 세션이 아직 없을 때도 매핑을 남긴다. 예전엔 지우고 오지 않을 재발견을 기다렸다.

## 유령 피어
- 앱을 강제 종료하면 Bonjour goodbye 가 나가지 않아 상대 목록에 옛 광고가 TTL 만큼
  남는다. 기기 한 대에 「발견 2」 가 뜨던 이유. 그 행을 탭하면 없는 기기에게 초대를
  보내고 타임아웃을 통째로 태운다.
- `discoveredPeerIDs` 로 목록을 대조해 걷어내고, 행마다 세션 식별자와 연결 표시등을
  노출했다 — 식별자가 실행마다 바뀌어서 이것 없이는 어느 쪽이 살아 있는지 알 수 없다.
- `peers.append` 에 중복 제거를 넣었다. `ForEach(id:)` 가 중복 id 로 행을 잘못 재사용했다.

## 버린 접근
- 앱 삭제·재설치로 로컬 네트워크 권한 초기화 → 원인이 아니었다. TN3179 확인 결과
  Bonjour 등록·브라우즈·연결이 하나의 권한이라 "발견은 되고 연결만 안 되는" 중간
  상태를 만들 수 없다. 발견이 보였다는 것 자체가 양쪽 다 허용됐다는 증거다.
- 타이브레이크 제거 → 양쪽 다 「연결됨 0」. 되돌렸고 이제 단일 경로로 강제한다.

테스트: CoreNearbyExchange 22건 통과.
두 대를 같은 계정으로 켜면 이름·파트·기수가 전부 같아서 어느 행이 폰이고 어느 행이
패드인지 알 수 없었다. 세션 식별자는 실행마다 바뀌어 기억할 수도 없다.

- `hardwareModel` (`iPhone17,3` · `iPad16,1`) 을 `discoveryInfo` 에 실어 보낸다.
  **DEBUG 광고에만** — 릴리스에서 기능에 필요 없는 정보를 주변에 뿌릴 이유가 없다.
- `UIDevice.name` 대신 이걸 쓰는 이유는 iOS 16부터 entitlement 없이는 기기 이름 대신
  기종명만 오기 때문이다. 하드웨어 식별자는 그 제약을 받지 않는다.
- 진단 첫 줄에 `이 기기 — <모델> · <세션 id>` 를 띄운다. 두 화면을 나란히 놓고
  볼 때 지금 보는 게 누구 것인지부터 알아야 한다.
- 같은 기종 두 대는 이걸로 못 가린다 — 그 구분은 여전히 세션 식별자가 맡는다.

실기기 확인: 폰·패드 양쪽 「연결됨 1 / 발견 1」, 한 번 탭으로 교환·저장 성립.
- BusinessCardPreviewData 추가: 파트 7종을 모두 커버하는 ReceivedCard 7장 +
  MyCard 1장을 한곳에 모아 명함 화면·컴포넌트 #Preview 가 공유하게 함
- BusinessCardFaceView·BusinessCardSummaryView·ReceivedCardCell·
  ExchangeCompletedView 의 인라인 프리뷰 시드를 공유 데이터로 교체해 중복 제거
- 프리뷰 전용이라 파일 전체를 #if DEBUG 로 가드 — 런타임 명함첩은 그대로 빈 상태
진입 시 네트워크 장애로 profileData·myCard가 함께 실패하면, 카드 retry가
loadBusinessCard만 재실행해 profileData는 .failed로 남았다. 「명함 편집」
행이 활성 외관인 채 무반응이 되고 화면 안에 fetchProfile을 다시 부를 수단이
없었다. retryAction을 MyPageView.retryCardAndProfile로 뽑아 두 로드를
함께 재시도하고, 그 동작을 고정하는 회귀 테스트를 추가했다.
Features/BusinessCard/Project.swift의 presentationExtraDependencies에서
CoreRouting을 제거했다 — import CoreRouting이 모듈 전체에 0건이라
"import하는 모듈만 명시 선언" 규약과 어긋나는 가짜 의존 엣지였다.

BusinessCardPreviewData.swift의 referenceDate 주석도 실제 타임스탬프
(1_786_413_600 = 2026-08-11 11:00 KST)에 맞게 정정했다.
- Figma 브랜드 SVG 3종을 에셋 카탈로그에 추가하고 자리표시자를 교체
  - UMC 워드마크(47×15.16): 카드 헤더의 "UMC" 텍스트 대체
  - github·linkedIn 모노 아이콘(18×18, 흰색): 뒷면 링크 행의 SF Symbol 대체
    (blog 행만 시안도 SF Symbol `link`라 유지)
- SectionHeaderView에 weight 파라미터 추가(기본 regular) — 시안이
  Headline-emphasized(semibold)를 요구하는 마이페이지 v3 두 섹션만 opt-in,
  기존 호출부 10곳은 기본값으로 무변경
- 네비 타이틀 "마이 페이지" → "마이페이지" (시안 표기)
- 명함_l·명함_s 이름 행을 `이름/닉네임` 규칙으로 통일, 닉네임이 비어 있으면
  이름만 싣는다(빈 닉네임이 "이름/" 꼬리를 남기는 것 방지)

시뮬레이터에서 카드 앞/뒷면 렌더링 확인, MyPagePresentation·
BusinessCardPresentation 테스트 통과. 시안 편차 잔여 1건(루트 배경색
#F2F2F7 vs umcDefaultBackground의 grey100×0.55)은 전앱 공용 modifier라
보류 — 결정 필요.
- transport는 MPC로 확정(2026-08-17), 실기기 왕복 검증 완료(2026-08-18).
  폐기 구현체 삭제: WiFiAwareTransport·UWBTransport·BLETransport·NFCTransport·
  ARPairingCoordinator(UWB Phase-2 스켈레톤)
- BLEAdvertisementPayload(+Card 파생) 제거 — 소비자가 BLE/NFC/UWB뿐이라 전부
  죽은 코드. 계약 테스트의 광고 파생 2건도 함께 제거, DiscoveredPeer·Mock
  테스트는 유지
- NearbyExchangeDIRegistration.swift 삭제 — BLE Phase 계획 주석만 남은 빈
  enum, 실제 등록은 DIContainer+BusinessCard가 수행
- 검증 화면: Wi-Fi Aware 페어링 뷰·페어링 목록·지원 여부 표시 제거,
  NearbyTransportChoice는 multipeer/mock 2택으로 축소
- Info.plist: NSBluetooth 2종·NFCReaderUsageDescription·WiFiAwareServices 제거
  (MPC용 NSLocalNetwork/NSBonjour·NI 키는 유지)
- entitlements: wifi-aware·nfc.readersession.formats 키 제거
- CoreNearbyExchange SDK 의존 축소: CoreBluetooth·CoreNFC·ARKit·RealityKit·
  WiFiAware 링크 해제, NearbyInteraction(거리 측정)만 유지
- Wi-Fi Aware를 제품 채널로 서술하던 주석 정정(프로토콜·검증 화면·NI 계측)

시뮬레이터 앱 빌드·CoreNearbyExchange 테스트 통과.
Wi-Fi Aware 폐기 사유: DeviceDiscoveryUI로 사전 페어링한 기기끼리만 연결돼
"처음 만난 사람이 목록에 뜨는" 시안 전제가 깨짐. MPC는 페어링 없이 탐색.
- retryCardAndProfile은 fetchProfile을 먼저 기다린 뒤 loadBusinessCard를
  재실행하는데, 그 앞 구간에는 myCard가 여전히 .failed라
  isRetrying(myCard.isLoading)이 false — 네트워크가 느리면 재시도 버튼을
  눌러도 수 초간 아무 표시가 없어 씹힌 것처럼 보였다
- MyPageViewModel.isCardRetryInFlight 추가(myCard/profileData 어느 쪽이
  로딩이어도 true) — isCardEditPending과 같은 "뷰가 아니라 테스트 가능한
  VM에 둔다" 패턴. MyPageView의 isRetrying이 이를 소비
- 프로필 구간·카드 구간·완료 후 소등까지 4개 상태를 고정하는 스위트 추가
  (TDD: 심볼 부재 red 확인 후 구현)
- UMCPartType.allCases는 associated value 제약으로 .admin을 제외한 7종이라
  기존 시드로는 admin 배지 렌더 경로가 프리뷰에 전혀 나오지 않았다
  (뷰 라운드 최종 리뷰가 이월한 「admin 파트 프리뷰 미포함」 건)
- allCases 7장 뒤에 .admin 명함 1장을 덧붙여 명함첩 프리뷰가 8종 전부를 그린다
- loadBusinessCard가 진입마다 myCard=.loading·qrImage=nil로 밀어서
  ① pop 복귀 재조회 때 로드된 카드가 Progress로 교체됐다 나타나며 깜빡였고
  ② 재조회가 취소되면 myCard만 previousState로 복원되고 qrImage는 nil로
  남아 뒷면 QR만 빈 채가 됐다
- 로드된 카드가 있으면 상태를 건드리지 않고 재조회 — 두 증상이 한 뿌리라
  같이 사라진다. 처음 진입·실패 후 재시도만 .loading을 그린다
- 중복 호출 가드를 myCard.isLoading에서 isCardLoadInFlight 플래그로 교체 —
  카드를 유지한 채 재조회하는 동안에는 상태로 인플라이트를 알 수 없다
- 취소 복원·재조회 중 카드 유지·로드된 상태 dedup 3건을 고정하는 스위트 추가
  (TDD: 앞 2건 red 확인 후 구현, dedup은 회귀 핀)
- 버린 접근: 취소 catch에서 qrImage도 previousQR로 복원 → 로드된 카드를
  유지하면 qrImage를 애초에 비우지 않아 복원 자체가 불필요해짐
- 설정 화면 「외부 링크」(Figma 12736:32709)와 프로필 수정 「외부 프로필 링크」
  (12804:30609) 행이 시안은 서비스 브랜드 컬러 이미지(32×32, 라운드 8)인데
  앱은 SF Symbol + 틴트 타일로 그리고 있었다
- Figma 원본 PNG 3종을 CoreUIComponents 카탈로그 MyPage 폴더에 추가
  (githubColor·linkedInColor·blogColor) + Image 접근자 확장
  (기존 Image+BusinessCardAssets와 같은 번들 해석 사유)
- SocialLinkType+UI: SF Symbol icon·color 프로퍼티를 brandIcon(Image)으로
  교체 — 소비처가 두 행뿐이라 죽은 프로퍼티는 남기지 않음
- MyPageSectionRow에 brandIcon 경로 추가(배경 타일 없이 시안 틀에 얹는다),
  ProfileLinkEditSection은 24pt 틴트 심볼 → 32pt 브랜드 이미지
- 명함 뒷면(12766:98167)의 github·linkedIn 모노 SVG는 시안 에셋과 바이트
  동일함을 확인 — 이번 수정 범위 아님

MyPagePresentation 57테스트 통과, 앱 빌드·번들 에셋 포함 확인.
- 시안(12766:98169 Toolbar-Top)은 SF Pro Bold 34 좌측 정렬 = 표준 Large Title 인데
  앱은 inline(중앙 소형)으로 그리고 있었다 — "타이틀이 더 커야 한다" 실기기 대조 지적
- displayMode .inline → .large 한 줄 변경. 설정 버튼(topBarTrailing)은 시스템이
  대형 타이틀 행 우측에 배치한다
- 피어 발견 전 빈 상태가 범용 ContentUnavailableView(스피너)였다 — "시안에 빈
  상태가 없다"는 이전 주석은 오인: 12654:32255 가 바로 탐색 중 화면이다
- ExchangeSearchingView 추가: 탐색 배지(흰 캡슐+그림자) · 인디고 레이더 원 4종
  (364 stroke10% / 300 fill5% / 230·160 stroke15%, 코어 indigo500=#4869F0 시안
  원값 일치) · 중앙 내 아바타 80 · 안내문(Title3-emphasized + 강조 스팬) —
  치수 전부 시안 실측
- 시안 문구의 Wi-Fi Aware 는 폐기된 기술명이라 화면에 남기지 않는다: 배지
  「근거리 탐색」, 강조 스팬은 상대가 실제 누르는 버튼명 「명함 교환」으로 교체
- Text `+` 연결이 iOS 26 deprecated 라 보간(interpolation)으로 강조 스팬 구현
- 목록 상태(12654:32621)는 기존 DiscoveredPeerRow 가 이미 시안 구현이라 무변경

BusinessCardPresentation 25 · MyPagePresentation 57 테스트 통과, 앱 빌드 확인.
시뮬레이터 창이 닫혀 있어 화면 캡처 검증은 보류 — 열리면 재확인 예정.
- 설정 화면 「소셜계정 연동」 행(Figma 12736:32832)이 시안은 카카오 노란
  타일·Apple 검은 타일(32×32, 라운드 8)인데 앱은 SF Symbol link + 브랜드색
  타일로 그리고 있었다 — 외부 링크 행과 같은 편차
- Figma 원본 PNG 2종(kakaoColor·appleColor)을 MyPage 카탈로그에 추가.
  로그인 버튼 글리프(SocialType.image)는 배경 없는 PDF라 재사용 불가
- MyPageSectionRow에 brandIcon + rightText 생성자 추가
- 구글은 appConnectableCases 밖이라 시안 타일이 없다 — 도달 불가 분기는
  switch 완전성용으로 로그인 글리프 폴백
- 설정 화면(12736)·프로필 수정 화면(12804)의 모든 섹션 헤더가 시안은
  Pretendard SemiBold 17(Headline-emphasized)인데 앱은 regular였다 —
  v3 루트 2섹션(f851863)만 고치고 나머지가 남아 있던 것
- SectionHeaderView 호출부 10곳에 weight: .semibold 지정 — 설정 6
  (ProfileLink·Setting·Law·Info·Auth·UMCChannel) + 프로필 수정 4
  (ConnectionSocial·ProfileLinkEditSection·ActiveLogs·ReadOnlyTextField).
  소셜계정 연동 헤더는 직전 커밋(아이콘 교체)에 포함
- 기본값은 regular 유지 — Activity 등 타 피처 호출부 3곳은 해당 시안 미확인이라
  건드리지 않는다. MyActiveLogSection은 사용처 0곳(죽은 파일)이라 제외
- a1d8f56 이 `.large` 를 쓴 게 오판이었다. `.large` 는 정의상 타이틀을 바 아래
  별도 행으로 내리는 모드라 trailing 툴바 아이템과 절대 같은 행에 못 온다 —
  실기기에서 ⚙ 가 타이틀 위에 홀로 뜨고, 타이틀 행이 통째로 끼면서 카드까지
  ~50pt 밀렸다
- 시안(12766:98169)은 `Toolbar - Top` 컨테이너 하나가 Title(34pt)과 Trailing 을
  한 flex 행에 담는다. Apple 문서상 그 배치는 `.toolbarTitleDisplayMode(.inlineLarge)`
  ("큰 타이틀을 툴바 안에 그린다")다
- CoreUIComponents 에 `navigationInlineLarge(naviTitle:)` + InlineLargeTitleModifier
  추가. 기존 `navigation(naviTitle:displayMode:)` 는 그대로 두어 타 화면 무영향
- 치수 교차 검증: 시안 툴바 top 62 · 바닥 ~113 · 콘텐츠 top 132(간격 19)로
  현재 콘텐츠 top 패딩 16 과 부합. `.large` 였을 때만 여백이 벌어졌다
- 주의: `.inlineLarge` 는 leading·centered 툴바 아이템을 오버플로로 밀어낸다
  (Apple 문서). 이 화면은 trailing ⚙ 하나뿐이라 해당 없음

MyPagePresentation 57테스트 통과, 실기기(Thingking) 설치·실행 완료.
- 19b217e 가 주석으로 "로드된 카드가 있으면 그대로 보여 둔 채 재조회한다"고 선언했지만
  실제로 보존하는 건 취소 두 분기뿐이었다. AppError·일반 Error 는 여전히 .failed 로
  덮어써서, pop 복귀 재조회 중 네트워크가 한 번 흔들리면 멀쩡히 보이던 카드가 통째로
  사라지고 전체 화면 재시도 뷰로 바뀌었다
- failureState(_:previous:) 추가 — 보여줄 카드가 있으면 실패로 덮지 않는다. 실패한 건
  갱신이지 카드가 아니다. 카드가 없을 때(첫 진입·실패 후 재시도)만 .failed 로 전이해
  재시도 경로는 그대로 남긴다
- 트레이드오프: 갱신 실패가 조용해진다(낡은 카드가 표시 없이 남음). 다음 진입·pop 복귀가
  다시 조회해 스스로 회복하므로 카드를 잃는 쪽보다 낫다고 판단
- AppError 보존·일반 Error 보존·카드 없을 때 .failed 유지 3건 테스트 추가
- NearbyMessage.swift 가 141f6d6 의 주석 정정 대상에서 통째로 누락됐다. "Wi-Fi Aware
  연결 위로 오가는 봉투"·"그 채널이 Wi-Fi Aware 다"는 살아있는 MPC 코드를 잘못 설명한다.
  PeerPreview 존재 이유도 "Wi-Fi Aware 는 기기 이름만 준다"가 아니라 Bonjour TXT 크기
  제약 + NI 토큰 동봉이 맞다(MPC discoveryInfo 는 이미 이름·파트·기수를 싣는다)
- NearbyError 에서 생산자가 사라진 케이스 2종 제거: notPaired(Wi-Fi Aware 전용),
  notImplemented(ARPairingCoordinator·UWBTransport 와 함께 사라진 "Phase 2" 개념).
  유일 참조였던 테스트 픽스처는 MPC 가 실제로 던지는 transportFailure 로 교체
- 교환 실패 안내가 "블루투스와 로컬 네트워크 권한을 확인"이었는데 BLE 폐기로 블루투스
  권한 항목 자체가 없다 — 찾을 수 없는 설정을 가리키던 문구를 로컬 네트워크로 정정
- MockNearbyTransport 의 "BLE/NFC 하드웨어" → "무선 하드웨어"
- ExchangeSearchingView 레이더가 지름 364pt 고정이라 폭 320pt 기기(SE)에서 바깥 링이
  양옆으로 잘렸다. 정사각 컨테이너가 남는 폭까지만 차지하게 하고(최대 364) 링·아바타를
  그 비율로 축소 — 세로 여백도 함께 줄어든다
- 브랜드 이미지가 VoiceOver 에 에셋 이름("githubColor")을 그대로 읽혔다. 옆 텍스트와
  완전히 중복되는 장식이므로 accessibilityHidden 처리 (명함 뒷면 링크·워드마크,
  마이페이지 행 타일, 프로필 수정 입력 행)
- MyPageSectionRow 의 아이콘이 종류마다 30/32 로 달라 같은 Form 안에서 타이틀 시작
  x 가 어긋났다. 바깥 틀을 시안 값 32 로 통일
- d88cf5a 의 헤더 semibold sweep 에서 빠진 MyActiveLogSection 보완

CoreNearbyExchange 20 · BusinessCardPresentation 25 · MyPagePresentation 60 통과.
- 외부 링크: 「Github 열 수 없어요」 → 「Github를 열 수 없어요」 (LinkedIn 은 「을」).
  시안 12736:32900·33150·33400 이 서비스별로 조사를 가려 쓰는데 코드는 통째로 빠져 있었다.
  영문 표기라 문자열로는 종성 판별이 불가능(LinkedIn → 「인」의 ㄴ 받침)해
  SocialLinkType.objectParticle 로 종류별로 못박는다
- 로그아웃: 「정말 로그아웃 하시겠습니까?」 → 「정말 로그아웃을 하시겠습니까?」 (시안 12736:33897)

계정 삭제 알럿은 시안과 동일. 시안 원문에 물음표가 없으나 로그아웃 쪽과 어긋나는
표기라 코드의 물음표를 유지한다.
- 공식 표기는 「GitHub」지만 시안은 설정·명함편집의 행 라벨 6곳과 알럿 문구까지
  전부 「Github」로 쓴다. 정본을 시안에 맞춘다
- 표시 문자열만 바꾸고 산문 주석의 GitHub 표기는 그대로 둔다
QR 화면(MP-F04)이 「QR을 스캔하면 내 명함이 저장돼요」라고 약속하는데 읽는 쪽이
제품 코드에 없었다. 도메인·데이터는 이미 딥링크 경로를 갖추고 있었고 앱 셸 배선만
비어 있어서, 링크 해석부터 저장·완료 화면까지 잇는다.

- AppDeepLink: MessageLink·CardLink 두 파서를 셸의 단일 진입점으로 묶는다.
  DeepLinkStore가 MessageLink만 보던 탓에 명함 링크는 파싱조차 못 되고 소셜 로그인
  핸들러로 흘러가 조용히 버려졌다
- CardLinkReceiveViewModel: memberId로 상대 명함을 조회(/member/profile/{id})해
  저장 UseCase의 딥링크 진입점에 넘긴다. 명함첩 키가 QR-{memberId}로 결정적이라
  같은 사람을 다시 스캔해도 한 장이고, 저장소가 memberId를 먼저 보므로 근거리 교환으로
  이미 받은 상대와도 합쳐진다
- 자기 QR을 찍으면 저장 UseCase가 nil을 주는데 그대로 두면 무반응으로 읽힌다 —
  저장하지 않은 이유를 알린다
- ExchangeCompletedView의 「계속 교환하기」를 옵셔널로 — 링크로 받은 사람에겐 이어서
  교환할 세션이 없다. 버튼이 하나면 그것이 주 동작
- 테스트 17건 (수신 VM 8 · 링크 해석 9)

남은 것: 카메라 앱 스캔은 Universal Link라 associated-domains 엔타이틀먼트와 서버
AASA 배포가 있어야 앱이 열린다. 지금 이 배선으로 동작하는 건 umc://card/{memberId}
커스텀 스킴 경로다.
「Android 는 같은 URL 을 autoVerify intent-filter 로 받는다」고 현재형으로 적어 뒀는데
합의 내용이지 구현이 아니었다. 2026-08-19 확인 결과:

- umc-product-android 매니페스트의 intent-filter 는 Firebase·런처·카카오 OAuth 3개뿐.
  autoVerify 0건, neordinary 호스트 0건, QR 관련 0건
- cygnus-server 에 assetlinks.json·apple-app-site-association 둘 다 없음

AASA 가 필요한 범위도 정정한다. 파싱은 AASA 와 무관하다는 서술은 맞지만, 그 문장이
「그래서 QR 스캔은 서버 없이 된다」로 읽혔다 — 인앱 스캐너가 있을 때의 이야기고
시안에는 그 화면이 없다. 시안의 전제(상대가 카메라 앱으로 스캔)에서는 AASA 가 필수다.
Android 12+ 도 미검증 https intent-filter 를 앱에 넘기지 않아 assetlinks.json 이
똑같이 필요하다는 점도 남긴다 — iOS 만의 제약이 아니다.
Android(umc-product-android `develop-compose`)를 직접 받아 확인한 결과 URL 규격이
우리와 달랐다. 지금 상태로는 **UMC iOS 가 UMC Android 명함 QR 을 못 읽는다.**

저쪽이 실제로 굽는 값 (QrCodeViewModel.generateMyUserCardQr):
    umc://card?memberId={id}          ← 질의형. 우리는 경로형만 받고 있었다
저쪽이 등록한 딥링크 (MainNavHost):
    umc://card?memberId={id}
    https://api.university.neordinary.com/community/threads/card?memberId={id}
우리 표기:
    https://api.university.neordinary.com/mypage/card?memberId={id}

읽기만 넓힌다 — 굽는 값은 그대로 `/mypage/card` 다. 어느 경로가 정본인지는 팀이 정할
문제이고(저쪽 경로는 스레드 딥링크 필터를 복제해 명함이 /community/threads 아래에 있다),
그 전에 상대 QR 을 못 읽는 것만 먼저 막는다.

- 질의형 커스텀 스킴 `umc://card?memberId=` 수용 (경로형도 그대로)
- Universal Link 경로에 `/community/threads/card` 추가
- 테스트: Android 표기 3종 수용 + 값 없는 두 표기 거절 2건
umc://card?memberId={id} 를 정본으로 굽는다 (팀 결정: Android 에 맞춘다).

Android(develop-compose) 전수 감사 결과 — 33개 주장 적대적 검증:
- 저쪽 QR 화면이 실제로 굽는 값이 정확히 이 문자열이다 (QrCodeViewModel:120)
- 저쪽 수신부는 umc://card 매니페스트 필터 + NavHost 질의형 패턴. /mypage/card 는
  등록조차 안 돼 있어 기존 https 표기는 Android 가 영영 못 읽는다
- 커스텀 스킴은 AASA/assetlinks 검증이 없다 — 서버 배포 없이 양쪽 폰 카메라 스캔이
  즉시 동작한다는 것이 이 형식의 핵심. Universal Link 정본안은 서버 AASA 부재로
  카메라 스캔이 사파리로 빠지는 상태였다
- Android 는 memberId 를 무가드 toLong() 으로 받는다 — 숫자 아닌 id 를 구우면
  저쪽이 크래시하므로 주석으로 못박음

읽기는 굽기보다 넓게 유지 (과거 Universal Link 두 경로·경로형 커스텀 스킴) —
이미 구워진 QR 은 회수할 수 없다. 파싱 순서는 정본(커스텀 스킴) 우선으로 교체.
쓸모없어진 CardLink.host(환경별 https 호스트 선택)는 제거 — 사용처 0.

- 버린 접근: Universal Link 정본 유지 + 서버 AASA 요청 → Android 가 이미 커스텀
  스킴으로 출하 경로를 굳혔고, 크로스 플랫폼 스캔이 지금 동작하는 쪽을 택함
이 스택(#1196 구간)이 바꾼 현실을 문서가 현재형으로 부정하고 있었다:

- build-and-modules.md 2곳: BusinessCard 를 "3D 렌더링(RealityKit) 캡슐화" 모듈로
  서술 — 렌더링은 2D SwiftUI(BusinessCardFaceView)로 확정됐고 141f6d6 에서
  ARKit·RealityKit 링크까지 해제돼 UMCApp 전체 참조 0건. 경계 정책에서 살아남은
  부분(카드 UI 는 BusinessCardPresentation 링크로 재사용)만 남기고 3D 전제를 지움
- tuist-file-mapping.md 2행: ProfileCardSection(5a946f7 루트 제외)·
  MyActiveLogSection(7662750 v3 루트가 MyActivitySection 으로 대체)이 소비자
  0건이 됐는데 문서 자체 범례가 요구하는 dormant 표기가 없어 살아있는 컴포넌트로
  오인될 상태. 선례(136행 StudyManagementItem) 형식으로 표기. MyActiveLogsType
  enum 은 라우팅에서 계속 쓰이므로 존치 범위를 명시
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 새로운 기능을 추가합니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ Feature: WiFiAwareTransport — Wi-Fi Aware 근거리 명함 교환

1 participant