✨ [Feat] 마이페이지 v3 — 명함 화면 · MPC 근거리 교환 · QR 딥링크 (#1196) - #1200
Merged
Conversation
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 오버로드로 교체.
- 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 은 라우팅에서 계속 쓰이므로 존치 범위를 명시
GthingkingG
force-pushed
the
feature/1196
branch
from
August 20, 2026 19:22
077a8b2 to
ae2fe5f
Compare
This was referenced Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
✨ PR 유형
✨ Feature — 마이페이지를 명함 중심 v3 로 재설계하고, 근거리 교환 transport 와 화면 전체를
싣습니다. 스택 4개 PR 중 마지막입니다.
이슈 제목은 「WiFiAwareTransport」이지만 transport 는 MPC 로 확정됐습니다. Wi-Fi Aware 는
DeviceDiscoveryUI로 사전 페어링한 기기끼리만 연결되는데, 시안(12654:32255)이 그리는 건처음 만난 사람이 목록에 뜨는 화면이라 전제가 깨집니다. MPC 는 페어링 없이 탐색하고, MPC 가
주지 못하는 거리는 NearbyInteraction(UWB)이 따로 잽니다.
Closes #1196
🛠️ 작업내용
근거리 교환 transport (
CoreNearbyExchange)MPCTransport— MultipeerConnectivity, 페어링 없이 주변 탐색. 실기기에서 물린 함정3가지를 구조로 회피: 수신 continuation 을 광고 재시작이 닫지 않게, 초대는 한 함수에서만
(동시 초대 시 타이브레이크),
foundPeer1회 전제(재시도 타이머 별도)PeerRangingCoordinator— NI(UWB) 실측 거리를 발견 목록에 표시. NI 는 데이터를 못나르고 MPC 는 거리를 모릅니다 — 둘 다 필요합니다. NI 토큰은 MPC 봉투(
NearbyMessage)로 교환(−1,156줄), Info.plist 키·entitlements·SDK 링크 정리. ✨ Feature: ExchangePayload v2 · NearbyTransport 광고 API 일반화 #1193 의
notPaired도 여기서 제거QR 딥링크 (Android 와 규격 통일)
umc://card?memberId={id}로 확정 — 저쪽 QR 화면 소스와 문자 단위 대조.커스텀 스킴은 AASA/assetlinks 검증이 없어 서버 배포 없이 양쪽 카메라 스캔이 동작합니다
AppDeepLink(스레드·명함 링크 단일 진입점, ✨[Feat] 메시지 링크 카드 · 스레드 공유 딥링크 (#1142) #1171DeepLinkStore확장) →마이페이지 탭 →
/member/profile/{id}조회 → 명함첩 저장(QR-{memberId}결정적 키) →교환 완료 화면. 자기 QR 스캔은 저장하지 않고 이유를 알립니다 (무반응 = 고장 오인 방지)
umc://card/{id}·Android 의/community/threads/card경로 전부 해석 (이미 구워진 QR 은 회수 불가)화면 (
BusinessCardPresentation+ MyPage 재구성)교환(레이더 → 발견 목록, SE 320pt 대응) · 교환 완료
BusinessCardEntry(MyPage 중립 enum) →BusinessCardDestination섹션 헤더 semibold, 루트 타이틀
.inlineLarge(⚙ 버튼과 한 행), 알럿 조사(「Github를」),장식 이미지 VoiceOver 차단
AC 체크 (Wi-Fi Aware 전제 → MPC 등가로 검증)
이슈의 완료 조건은 Wi-Fi Aware 전제로 작성됐습니다. transport 확정이 바뀌어 페어링을
전제한 2개 항목은 전제 자체가 소멸했고(페어링 단계 없음 — 그것이 전환 사유), 나머지는
MPC 등가로 실기기 2대(서로 다른 신원, 2026-08-18)에서 확인했습니다.
기수·학교·이메일·외부 링크 3종이 상대 값 그대로 도착 (저장소를 devicectl 로 꺼내 sqlite 직접 확인)
저장 1장(memberId upsert), 되돌아온 자기 명함은 미저장
수명(
.task취소), 재진입 시 새 세션테스트 + 실기기에서 이탈·재진입 반복 확인
미페어링 상태로 시작하면 페어링 안내 에러가 표면화된다— 전제 소멸 (MPC 는페어링이 없음). 대신 세션 실패 시 로컬 네트워크 권한 안내를 표면화합니다
리뷰 전 실기기 재검증 때 함께 확인하겠습니다
tuist build성공 + 테스트 128건 통과(Domain 36 · Presentation 33 · Data 27 · App 32)
확인 방법
(자기 QR 이면 「내 명함이에요」 알럿)
xcrun simctl openurl booted "umc://card?memberId={id}"로 2번과 동일 경로📋 추후 진행 상황
ReceivedCard.isConnected복원 필요Sources/Debug/BusinessCard/검증 하네스 — 전부#if DEBUG, 실기기 재검증용으로 유지 중$memberId를 굽는 버그② 프로필 로드 실패 시 memberId 21 하드코딩 폴백 QR ③ 명함첩 신원키가 name+nickname 이라
memberId 미보존(동명이인 충돌)
iOS 는 MPC. 프로토콜 비호환이라 iOS↔Android 교환은 QR 경로가 유일합니다
📌 리뷰 포인트
/mypage/cardUniversal Link 정본안을 접고 저쪽이이미 출하 경로로 굳힌 커스텀 스킴을 따랐습니다. 서버 AASA 없이 지금 동작하는 쪽을
택한 판단이 맞는지
없이 남고 다음 진입 때 회복). 실패를 드러내는 쪽이 낫다고 보시면 말씀 주세요
ExchangeCompletedView.onContinue옵셔널화 — QR 경로는 이어갈 세션이 없어 버튼을감춥니다. 버튼이 하나일 때 주 동작 스타일로 승격하는 처리 포함
✅ Checklist
PR이 다음 요구 사항을 충족하는지 확인해주세요!!!