Skip to content

feat: 초대 링크(Universal Link) 기반 소속 가입 플로우 - #121

Open
Siwon-L wants to merge 13 commits into
developfrom
refactor/Invitation-flow
Open

feat: 초대 링크(Universal Link) 기반 소속 가입 플로우#121
Siwon-L wants to merge 13 commits into
developfrom
refactor/Invitation-flow

Conversation

@Siwon-L

@Siwon-L Siwon-L commented Aug 31, 2026

Copy link
Copy Markdown
Member

개요

초대 링크(Universal Link) 기반 소속 가입 플로우를 구현합니다. 멤버 탭에서 초대 링크를 공유하고, 링크로 앱에 진입하면 로그인 후 장부 화면에서 초대 코드를 확인해 자동으로 소속에 가입/전환합니다.

주요 변경사항

초대 링크 처리 (Universal Link)

  • App.entitlementsapplinks:dev.moneymong.site, applinks:prod.moneymong.site associated domains 추가
  • SceneDelegate.scene(_:continue:) 추가 — webpageURL의 query를 파싱해 .invitationLink 노티로 전달
  • DeepLinkManager.setDestinationsetQuery(_:notiName:) 로 일반화 (위젯 딥링크 + 초대 링크 공용)

소속 가입 로직

  • ConfirmCertificateCodeUseCaseexecute(code:agencyID:) 오버로드 추가 — 이미 가입된 소속이면 인증 없이 전환, 아니면 코드로 인증
  • LedgerReactor.invite(code:agencyID:) 액션 및 로딩 상태 추가
  • LedgerVC가 진입 시 대기 중인 초대 query를 확인해 .invite 디스패치
  • LoginReactor — 대기 중인 딥링크 query가 있으면 회원가입 체크를 건너뛰고 곧바로 main 으로 이동

초대 링크 공유 (멤버 탭)

  • InvitationLinkButton 컴포넌트 추가, staff 에게만 노출
  • MemberTabReactor.didTapInviteButton — 소속 초대 코드/agencyID 를 query 로 담은 링크를 만들어 공유 시트 present

기타

  • ConfigCore/RepositoryShared/Utility 모듈로 이동 (feature 모듈에서 링크 URL 조립에 필요)
  • LineTabViewController / LedgerVC — 자식 VC 에 addChild/didMove(toParent:) 호출 누락 수정
  • SelectAgencySheetVC — 로딩 시 리스트를 통째로 숨기던 동작을 인디케이터 표시로 변경, 리스트 높이 최대 3행으로 제한
  • MockAgencyRepository, MockUserRepository (BaseDomainTesting) 및 ConfirmCertificateCodeUseCase 단위 테스트 추가

테스트

  • tuist test AgencyTests

Siwon-L added 13 commits August 23, 2026 09:27
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 Siwon-L left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드리뷰 — 초대 링크(Universal Link) 기반 소속 가입 플로우

전반적으로 Universal Link 수신 → DeepLinkManagerLedgerVCConfirmCertificateCodeUseCase로 이어지는 흐름 구성은 명확합니다. 다만 현재 상태로는 실제 Universal Link로 초대 가입이 동작하지 않는 결함이 몇 군데 있어 정리합니다.

동작을 막는 이슈 (blocker)

  1. 타입 불일치로 .invite가 절대 실행되지 않음SceneDelegate는 쿼리 파라미터를 String으로 저장하는데 LedgerVC["agencyID"] as? Int로 캐스팅 → 항상 nil → guard 탈락. (SceneDelegate.swift, LedgerVC.swift)
  2. 콜드 스타트 미처리 — 앱 종료 상태에서 링크로 실행 시 scene(_:continue:)가 아니라 willConnectToconnectionOptions.userActivities로 들어오는데 이를 처리하지 않음. 초대 플로우의 가장 흔한 케이스가 누락. (SceneDelegate.swift)
  3. 가입 성공 시 stale 목록 조회로 nil 반환execute(code:agencyID:)certificateCode 성공 후 가입 전에 조회한 목록에서 소속을 찾음. 링크로 처음 가입하는 성공 케이스에서 "소속 없음" 화면. (ConfirmCertificateCodeUseCase.swift)

구조/안정성

  1. DeepLinkManager.query가 non-nil이면 LedgerVCrequestMyAgencies 바인딩을 통째로 스킵 → 오염된 링크/위젯 딥링크/.invite 실패 시 장부 탭이 빈 화면으로 멈춤. 폴백 필요. (LedgerVC.swift)
  2. 위젯 딥링크와 초대 링크가 같은 전역 static var query를 공유 → 소비 순서에 따라 상호 간섭. (DeepLinkManager.swift)
  3. 공유되는 초대 링크가 REST API 베이스(/api/) URL로 생성됨. 전용 경로/웹 랜딩 필요. (MemberTabReactor.swift)

참고 (nit)

  1. .map 안 부수효과, 주입 방식 불일치, 테스트 파일명 오타(Cerrificate), iPad UIActivityViewController popover 등. 각 파일 코멘트 참조.

세부 내용은 인라인 코멘트로 남겼습니다.

components.queryItems?.forEach {
queryItems[$0.name] = $0.value
}
DeepLinkManager.setQuery(queryItems, notiName: .invitationLink)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Universal Link 처리에 세 가지 문제가 있습니다.

  1. 콜드 스타트 미처리: 앱이 완전히 종료된 상태에서 초대 링크로 실행되면 scene(_:continue:)가 호출되지 않고 scene(_:willConnectTo:options:)connectionOptions.userActivities로 전달됩니다. 현재 willConnectTo에서는 urlContexts만 처리하므로 앱 미실행/신규 설치 직후(=초대 플로우의 가장 흔한 케이스)가 동작하지 않습니다.
  2. host/path 검증 없음: associated domain으로 들어오는 모든 브라우징 액티비티를 초대 링크로 간주합니다. 쿼리가 없는 https://dev.moneymong.site/ 같은 링크도 query = [:](non-nil)로 저장되어 LedgerVC의 정상 requestMyAgencies 바인딩을 막습니다. url.path로 초대 경로만 거르고 code/agencyID가 모두 있을 때만 setQuery를 호출해야 합니다.
  3. 타입 불일치: URLQueryItem.valueString?이라 queryItems["agencyID"]에는 "123"(String)이 저장됩니다. 그런데 LedgerVC에서는 noti.userInfo?["agencyID"] as? Int로 캐스팅 → 항상 nil.invite 액션이 한 번도 디스패치되지 않습니다. 저장 시 Int 변환을 하거나 수신 측에서 StringInt($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 }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SceneDelegate가 쿼리 파라미터를 String으로 저장하기 때문에 noti.userInfo?["agencyID"] as? Int는 항상 nil이 되어 guard에 걸립니다. 결과적으로 실제 Universal Link로는 .invite가 한 번도 실행되지 않아 초대 가입이 이루어지지 않습니다. as? StringInt($0) 변환으로 파싱하거나 저장 측 타입을 맞춰야 합니다.

.bind(to: reactor.action)
.disposed(by: disposeBag)

if let query = DeepLinkManager.query {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeepLinkManager.query가 non-nil이면 requestMyAgencies 바인딩 자체를 건너뛰는데 여러 상황에서 위험합니다.

  • querycode/agencyID가 없거나(부분/오염된 링크), 위 타입 이슈로 compactMap이 nil을 반환하면 .invite도, requestMyAgencies도, DeepLinkManager.clear()(.docompactMap 하류라 통과 안 됨)도 실행되지 않아 앱 재실행 전까지 장부 탭이 빈 화면으로 멈춥니다.
  • 위젯 딥링크(destination+agencyID)와 DeepLinkManager.query를 공유하므로, 위젯 진입 후 MainTapViewControllerclear()하기 전에 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 }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

certificateCode로 방금 가입에 성공한 직후인데, 가입 전에 조회한 agencies(stale)에서 소속을 찾고 있어 새로 가입한 소속은 목록에 없고 nil이 반환됩니다. 바로 위 execute(code: [String]) 오버로드처럼 try await agencyRepo.fetchMyAgency().first { $0.id == response.agencyId }로 재조회해야 합니다.

현재 상태로는 링크로 처음 가입하는 성공 케이스에서 LedgerReactor.inviteupdateAgency(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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일명 오타: ConfirmCerrificateCodeUseCaseTestsConfirmCertificateCodeUseCaseTests (CerrificateCertificate).

또한 이 케이스는 "가입되지 않은 소속"을 검증한다면서 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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setQuery가 빈 딕셔너리/필수 키 누락에도 무조건 query에 저장하고 노티를 발송합니다. 필수 키 검증 후에만 저장/발송하도록 하고, 위젯 딥링크와 초대 링크가 같은 static var query를 공유하는 구조라 소비 순서에 따라 서로 간섭합니다(LedgerVC / MainTapViewController 코멘트 참조). 타입을 나누거나 최소한 용도별 저장 슬롯을 분리하는 것을 권합니다. clear() 책임도 현재 LedgerVC.doMainTapViewController 두 곳에 흩어져 있어 링크 종류에 따라 누락 경로가 생깁니다.

.task {
try await confirmCertificateCodeUseCase.execute(code: code, agencyID: agencyID)
}
.map { [weak self] agency in

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • .map 안에서 self?.service.agency.updateAgency(...) 부수효과를 실행합니다. 순수 변환이어야 할 map보다 .do(onNext:)가 적절합니다.
  • .invite 성공 시 agencynil일 수 있는데(위 UseCase stale 목록 버그, 혹은 정상적으로도 재조회 실패) 그대로 updateAgency(nil) / setAgency(nil)을 태워 "소속 없음" 화면이 됩니다. nil이면 requestMyAgencies 폴백 또는 에러 처리가 필요합니다.
  • .requestMyAgencies와 달리 .invite는 성공 후 Reactor 레벨에서 updateSelectedAgencyUseCase.execute(id:) 호출이 없어 두 경로 동작이 비대칭입니다(UseCase 내부 updateSelectedAgency에 의존).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant