feat: 초대 링크(Universal Link) 기반 소속 가입 플로우 - #121
Conversation
Add scene(_:continue:) to parse webpageURL query items and post via invitationLink notification when the app is opened through a Universal Link.
Support confirming an invitation by agencyID: skip certification if the user already belongs to the agency, otherwise certify with the code and update the selected agency.
Post a notification with the pending invitation code/agencyID when LedgerVC loads, and confirm it via a new invite reactor action instead of the normal my-agencies fetch. LoginReactor now routes straight to main when a deep link query is pending, skipping the sign-up check so the invitation can be resolved on the Ledger screen.
Replace the isLoading -> full-content-hiding behavior with a small indicator inside the agency list area, and cap the list height at 3 rows with correct inter-item spacing.
Config is now needed outside of Repository (e.g. for building universal-link URLs in feature modules), so relocate it to the shared Utility module and expose it publicly.
Add an InvitationLinkButton to the member tab that fetches the agency's invitation code, builds a link with code/agencyID query params from Config.base, and presents the system share sheet with it. The button is only shown to staff members.
lineTab과 pageViewController를 addSubview로만 붙이고 부모-자식 컨테인먼트 관계를 설정하지 않아 뷰컨트롤러 생명주기 이벤트가 자식에게 제대로 전파되지 않던 문제를 수정. addChild(_:)와 didMove(toParent:) 호출을 추가했다.
Wire AgencyTests to BaseDomainTesting so the new tests can use MockAgencyRepository and MockUserRepository.
Siwon-L
left a comment
There was a problem hiding this comment.
코드리뷰 — 초대 링크(Universal Link) 기반 소속 가입 플로우
전반적으로 Universal Link 수신 → DeepLinkManager → LedgerVC → ConfirmCertificateCodeUseCase로 이어지는 흐름 구성은 명확합니다. 다만 현재 상태로는 실제 Universal Link로 초대 가입이 동작하지 않는 결함이 몇 군데 있어 정리합니다.
동작을 막는 이슈 (blocker)
- 타입 불일치로
.invite가 절대 실행되지 않음 —SceneDelegate는 쿼리 파라미터를String으로 저장하는데LedgerVC는["agencyID"] as? Int로 캐스팅 → 항상 nil → guard 탈락. (SceneDelegate.swift,LedgerVC.swift) - 콜드 스타트 미처리 — 앱 종료 상태에서 링크로 실행 시
scene(_:continue:)가 아니라willConnectTo의connectionOptions.userActivities로 들어오는데 이를 처리하지 않음. 초대 플로우의 가장 흔한 케이스가 누락. (SceneDelegate.swift) - 가입 성공 시 stale 목록 조회로
nil반환 —execute(code:agencyID:)가certificateCode성공 후 가입 전에 조회한 목록에서 소속을 찾음. 링크로 처음 가입하는 성공 케이스에서 "소속 없음" 화면. (ConfirmCertificateCodeUseCase.swift)
구조/안정성
DeepLinkManager.query가 non-nil이면LedgerVC가requestMyAgencies바인딩을 통째로 스킵 → 오염된 링크/위젯 딥링크/.invite실패 시 장부 탭이 빈 화면으로 멈춤. 폴백 필요. (LedgerVC.swift)- 위젯 딥링크와 초대 링크가 같은 전역
static var query를 공유 → 소비 순서에 따라 상호 간섭. (DeepLinkManager.swift) - 공유되는 초대 링크가 REST API 베이스(
/api/) URL로 생성됨. 전용 경로/웹 랜딩 필요. (MemberTabReactor.swift)
참고 (nit)
.map안 부수효과, 주입 방식 불일치, 테스트 파일명 오타(Cerrificate), iPadUIActivityViewControllerpopover 등. 각 파일 코멘트 참조.
세부 내용은 인라인 코멘트로 남겼습니다.
| components.queryItems?.forEach { | ||
| queryItems[$0.name] = $0.value | ||
| } | ||
| DeepLinkManager.setQuery(queryItems, notiName: .invitationLink) |
There was a problem hiding this comment.
Universal Link 처리에 세 가지 문제가 있습니다.
- 콜드 스타트 미처리: 앱이 완전히 종료된 상태에서 초대 링크로 실행되면
scene(_:continue:)가 호출되지 않고scene(_:willConnectTo:options:)의connectionOptions.userActivities로 전달됩니다. 현재willConnectTo에서는urlContexts만 처리하므로 앱 미실행/신규 설치 직후(=초대 플로우의 가장 흔한 케이스)가 동작하지 않습니다. - host/path 검증 없음: associated domain으로 들어오는 모든 브라우징 액티비티를 초대 링크로 간주합니다. 쿼리가 없는
https://dev.moneymong.site/같은 링크도query = [:](non-nil)로 저장되어LedgerVC의 정상requestMyAgencies바인딩을 막습니다.url.path로 초대 경로만 거르고code/agencyID가 모두 있을 때만setQuery를 호출해야 합니다. - 타입 불일치:
URLQueryItem.value는String?이라queryItems["agencyID"]에는"123"(String)이 저장됩니다. 그런데LedgerVC에서는noti.userInfo?["agencyID"] as? Int로 캐스팅 → 항상 nil →.invite액션이 한 번도 디스패치되지 않습니다. 저장 시Int변환을 하거나 수신 측에서String→Int($0)로 파싱해야 합니다.
추가로 queryItems[$0.name] = $0.value는 값 없는 파라미터(?code)일 때 nil 대입 → 키 삭제가 됩니다.
| NotificationCenter.default.rx.notification(.invitationLink) | ||
| .compactMap { noti -> (code: String, agencyID: Int)? in | ||
| guard let code = noti.userInfo?["code"] as? String, | ||
| let agencyID = noti.userInfo?["agencyID"] as? Int else { return nil } |
There was a problem hiding this comment.
SceneDelegate가 쿼리 파라미터를 String으로 저장하기 때문에 noti.userInfo?["agencyID"] as? Int는 항상 nil이 되어 guard에 걸립니다. 결과적으로 실제 Universal Link로는 .invite가 한 번도 실행되지 않아 초대 가입이 이루어지지 않습니다. as? String 후 Int($0) 변환으로 파싱하거나 저장 측 타입을 맞춰야 합니다.
| .bind(to: reactor.action) | ||
| .disposed(by: disposeBag) | ||
|
|
||
| if let query = DeepLinkManager.query { |
There was a problem hiding this comment.
DeepLinkManager.query가 non-nil이면 requestMyAgencies 바인딩 자체를 건너뛰는데 여러 상황에서 위험합니다.
query에code/agencyID가 없거나(부분/오염된 링크), 위 타입 이슈로compactMap이 nil을 반환하면.invite도,requestMyAgencies도,DeepLinkManager.clear()(.do가compactMap하류라 통과 안 됨)도 실행되지 않아 앱 재실행 전까지 장부 탭이 빈 화면으로 멈춥니다.- 위젯 딥링크(
destination+agencyID)와DeepLinkManager.query를 공유하므로, 위젯 진입 후MainTapViewController가clear()하기 전에LedgerVC.bind가 먼저 실행되면 역시requestMyAgencies가 누락됩니다. .invite가 네트워크 오류 등으로 실패해도 기존 소속 목록을 불러오는 폴백이 없습니다.
초대 처리와 별개로 requestMyAgencies는 항상 바인딩하고 초대는 그 위에 얹는 형태가 안전합니다. 또한 bind(reactor:) 안에서 동기적으로 NotificationCenter.post를 호출하는 것도 구독 시점 의존성이 있어 rx.viewDidLoad 기반으로 트리거하는 편이 예측 가능합니다.
| let response = try await agencyRepo.certificateCode(code: code) | ||
| if response.certified { | ||
| userRepo.updateSelectedAgency(id: response.agencyId) | ||
| return agencies.first { $0.id == response.agencyId } |
There was a problem hiding this comment.
certificateCode로 방금 가입에 성공한 직후인데, 가입 전에 조회한 agencies(stale)에서 소속을 찾고 있어 새로 가입한 소속은 목록에 없고 nil이 반환됩니다. 바로 위 execute(code: [String]) 오버로드처럼 try await agencyRepo.fetchMyAgency().first { $0.id == response.agencyId }로 재조회해야 합니다.
현재 상태로는 링크로 처음 가입하는 성공 케이스에서 LedgerReactor.invite가 updateAgency(nil) / setAgency(nil)을 타서 가입은 됐지만 화면은 "소속 없음"으로 보입니다.
| // Arrange | ||
| let agency = Agency(id: 2, name: "몽테스트", count: 1) | ||
| mockAgencyRepo.returnValue.fetchMyAgency = [agency] | ||
| mockAgencyRepo.returnValue.certificateCode = CertificationResult(certified: true, agencyId: agency.id) |
There was a problem hiding this comment.
파일명 오타: ConfirmCerrificateCodeUseCaseTests → ConfirmCertificateCodeUseCaseTests (Cerrificate → Certificate).
또한 이 케이스는 "가입되지 않은 소속"을 검증한다면서 returnValue.fetchMyAgency = [agency]로 이미 목록에 그 소속이 들어있게 세팅해서, 프로덕션 코드의 stale 목록 버그(ConfirmCertificateCodeUseCase.swift 코멘트 참조)를 가려버립니다. 가입 전 조회에는 해당 소속이 없고([] 또는 다른 소속만), 인증 후 재조회에서야 등장하도록 mock이 호출 순서에 따라 다른 값을 반환하게 구성하는 편이 실제 시나리오에 맞고 버그도 잡힙니다.
| case .didTapInviteButton: | ||
| guard let code = currentState.invitationCode, | ||
| let agencyID = currentState.agencyID else { return .empty() } | ||
| var components = URLComponents(string: Config.base) |
There was a problem hiding this comment.
Config.base는 REST API 베이스(https://dev.moneymong.site/api/)라서 생성되는 링크가 https://dev.moneymong.site/api/?code=...&agencyID=...가 됩니다.
- 앱이 없는 사람이 열면 API 엔드포인트(혹은 404)로 연결됩니다.
- Universal Link의 AASA
paths는 보통/api/*를 포함하지 않으며 포함해서도 안 됩니다. 이 경우 링크를 눌러도 앱이 열리지 않을 수 있습니다.
초대 전용 경로(예: https://<host>/invitation?...)와 웹 랜딩을 두고, API 베이스와 분리된 상수를 쓰는 게 좋겠습니다.
| "query": query, | ||
| "agencyID": agencyID | ||
| ] | ||
| public static func setQuery(_ query: [String : Any], notiName: Notification.Name) { |
There was a problem hiding this comment.
setQuery가 빈 딕셔너리/필수 키 누락에도 무조건 query에 저장하고 노티를 발송합니다. 필수 키 검증 후에만 저장/발송하도록 하고, 위젯 딥링크와 초대 링크가 같은 static var query를 공유하는 구조라 소비 순서에 따라 서로 간섭합니다(LedgerVC / MainTapViewController 코멘트 참조). 타입을 나누거나 최소한 용도별 저장 슬롯을 분리하는 것을 권합니다. clear() 책임도 현재 LedgerVC의 .do와 MainTapViewController 두 곳에 흩어져 있어 링크 종류에 따라 누락 경로가 생깁니다.
| .task { | ||
| try await confirmCertificateCodeUseCase.execute(code: code, agencyID: agencyID) | ||
| } | ||
| .map { [weak self] agency in |
There was a problem hiding this comment.
.map안에서self?.service.agency.updateAgency(...)부수효과를 실행합니다. 순수 변환이어야 할map보다.do(onNext:)가 적절합니다..invite성공 시agency가nil일 수 있는데(위 UseCase stale 목록 버그, 혹은 정상적으로도 재조회 실패) 그대로updateAgency(nil)/setAgency(nil)을 태워 "소속 없음" 화면이 됩니다.nil이면requestMyAgencies폴백 또는 에러 처리가 필요합니다..requestMyAgencies와 달리.invite는 성공 후 Reactor 레벨에서updateSelectedAgencyUseCase.execute(id:)호출이 없어 두 경로 동작이 비대칭입니다(UseCase 내부updateSelectedAgency에 의존).
개요
초대 링크(Universal Link) 기반 소속 가입 플로우를 구현합니다. 멤버 탭에서 초대 링크를 공유하고, 링크로 앱에 진입하면 로그인 후 장부 화면에서 초대 코드를 확인해 자동으로 소속에 가입/전환합니다.
주요 변경사항
초대 링크 처리 (Universal Link)
App.entitlements에applinks:dev.moneymong.site,applinks:prod.moneymong.siteassociated domains 추가SceneDelegate.scene(_:continue:)추가 —webpageURL의 query를 파싱해.invitationLink노티로 전달DeepLinkManager.setDestination→setQuery(_:notiName:)로 일반화 (위젯 딥링크 + 초대 링크 공용)소속 가입 로직
ConfirmCertificateCodeUseCase에execute(code:agencyID:)오버로드 추가 — 이미 가입된 소속이면 인증 없이 전환, 아니면 코드로 인증LedgerReactor에.invite(code:agencyID:)액션 및 로딩 상태 추가LedgerVC가 진입 시 대기 중인 초대 query를 확인해.invite디스패치LoginReactor— 대기 중인 딥링크 query가 있으면 회원가입 체크를 건너뛰고 곧바로 main 으로 이동초대 링크 공유 (멤버 탭)
InvitationLinkButton컴포넌트 추가, staff 에게만 노출MemberTabReactor.didTapInviteButton— 소속 초대 코드/agencyID 를 query 로 담은 링크를 만들어 공유 시트 present기타
Config를Core/Repository→Shared/Utility모듈로 이동 (feature 모듈에서 링크 URL 조립에 필요)LineTabViewController/LedgerVC— 자식 VC 에addChild/didMove(toParent:)호출 누락 수정SelectAgencySheetVC— 로딩 시 리스트를 통째로 숨기던 동작을 인디케이터 표시로 변경, 리스트 높이 최대 3행으로 제한MockAgencyRepository,MockUserRepository(BaseDomainTesting) 및ConfirmCertificateCodeUseCase단위 테스트 추가테스트
tuist test AgencyTests