Modal을 사용한 독립적인 플로우 구현 시 자주 마주치는 문제와 해결 방법을 실제 코드와 함께 살펴보겠습니다!
회원가입, 약관동의, 온보딩 같은 플로우는 메인 앱과는 다른 성격을 가진다:
이러한 특성 때문에 독립적인 플로우는 Modal 기반으로 관리하는 것이 적합하다.
약관동의나 회원가입 플로우를 Modal로 분리하는 가장 중요한 이유는 접근 제어 포인트인데
만약 약관동의를 메인 Navigation Stack에 추가한다면:
// 문제가 되는 접근 방식
class HomeViewController: UIViewController {
@objc private func startTermsFlow() {
let termsVC = TermsViewController()
navigationController?.pushViewController(termsVC, animated: true)
// 사용자가 약관동의 중간에 다른 화면으로 이동 가능
}
}
코드의 구성에 따라 다르겠지만 이러한 문제점이 생길수있다는점이다.
결국 개발자가 의도한 동작대로 가지 않는 시나리오 인거다.
// 올바른 접근 방식
class HomeViewController: UIViewController {
@objc private func startTermsFlow() {
let termsVC = TermsViewController()
termsVC.modalPresentationStyle = .fullScreen
present(termsVC, animated: true)
// 사용자는 오직 두 가지 선택만 가능: 완료 또는 종료
}
}
Modal로 분리하면 사용자에게 두 가지 선택지만 제공된다
중간에 다른 곳으로 빠져나갈 여지가 원천적으로 차단하는것이다.
특히 금융, 의료, 개인정보 처리 앱에서는 이런 접근 제어가 필수인데
결론적으로 Modal을 사용한 컨텍스트 분리는 단순한 UI 패턴이 아니라, 앱의 보안과 법적 안전성을 보장하는 핵심적인 아키텍처 설계 결정인셈이다.
허나 여기서문제가 발생하는데
일반적인 Modal Present 방식은 이러하다
let termsVC = TermsViewController()
present(termsVC, animated: true)
허나 약관동의같은 부분은 위 이미지처럼 다음을 포함한 형식으로 여러단계에 걸쳐서 구성되는 경우가 많다.
이용약관 동의 → 개인정보처리방침 동의 → 마케팅 수신동의 → 회원가입 등등
사용자가 "다음" 버튼을 눌렀을 때 다음 화면으로 이동해야 하는데:
class TermsViewController: UIViewController {
@objc private func nextButtonTapped() {
let privacyVC = PrivacyViewController()
// 이 코드는 아무 일도 하지 않습니다!
navigationController?.pushViewController(privacyVC, animated: true)
print(navigationController) // nil 출력
// Modal로 띄워진 단일 VC에는 navigationController가 없음
}
}
navigationController?.pushViewController(privacyVC, animated: true)
Modal로 present된 단일 ViewController에는 navigationController가 nil이기 때문에 pushViewController는 동작하지 않는다.
간단하게 모달을 하나 더띄우거나 dismiss하고 새로 띄우면 되는거 아닌가? 할수도 있지만 또 다른 문제점을 야기하는데
@objc private func nextButtonTapped() {
let privacyVC = PrivacyViewController()
present(privacyVC, animated: true) // Modal 위에 Modal
}
@objc private func nextButtonTapped() {
dismiss(animated: true) {
// 메인 화면에서 다음 Modal 띄우기
let privacyVC = PrivacyViewController()
self.presentingViewController?.present(privacyVC, animated: true)
}
}
그렇기때문에 Modal은 써야 하지만, 내부에서 자연스러운 화면 전환도 필요한것이고.
이 두 가지 요구사항을 모두 만족시키는 방법이 바로 UINavigationController로 Modal을 감싸는 것이다.
// 올바른 해결책
let termsVC = TermsViewController()
let navController = UINavigationController(rootViewController: termsVC)
navController.modalPresentationStyle = .fullScreen
present(navController, animated: true)
class FinalStepViewController: UIViewController {
@objc private func completeFlow() {
// 전체 Modal Navigation을 한 번에 종료
dismiss(animated: true)
// 메인 앱으로 깔끔하게 복귀
}
}
그래서 실제로 어떻게 진행되는지 궁금해서 테스트를 진행해보았다.
동일한 ViewController를 사용하되, 하나는 단일 Modal로, 하나는 NavigationController로 감싸서 비교했다.
시각적 결과
- navigationController가 없어서 Navigation Bar가 표시되지 않음
- title 속성은 설정되지만 화면에 보이지 않음
- 상단에 제목 영역 없음
Push 전환 시도 결과
- navigationController가 nil이므로 Push 불가능
- beforeCount와 afterCount 모두 0 (변화 없음)
- 대안으로 새로운 Modal을 띄우게 됨
전체 플로우 종료 시도 결과
- Modal이 여러 개 쌓여있으면 하나씩만 닫힘
- 사용자는 여전히 이전 Modal들을 봐야 함
- 전체 플로우를 한 번에 종료할 수 없음
@objc
private func touchUpInsideDirectModalButton(_ sender: UIButton) {
let modalVC = ModalViewController1()
modalVC.modalPresentationStyle = .fullScreen
modalVC.title = "모달만 띄운경우"
self.present(modalVC, animated: true)
}
@objc
private func tryPush() {
let nextVC = ModalViewController2()
// Push 시도 전후의 ViewController 개수 확인
let beforeCount = navigationController?.viewControllers.count ?? 0
navigationController?.pushViewController(nextVC, animated: true)
// 잠깐 대기 후 결과 확인
try? await Task.sleep(for: .seconds(0.5))
let afterCount = navigationController?.viewControllers.count ?? 0
if afterCount > beforeCount {
updateStatus("Push 성공!")
} else {
// 단일 Modal에서는 여기로 진입
updateStatus("Push 실패! navigationController가 nil입니다.")
try? await Task.sleep(for: .seconds(2))
await showAlternativeAction() // 새로운 Modal 띄우기
}
}
@objc private func backToRoot() {
// 단일 Modal에서는 navigationController가 nil
dismiss(animated: true) // 현재 Modal만 닫힘
}
시각적 결과
- Navigation Bar가 표시되어 title이 보임
- 자연스러운 Navigation 인터페이스 제공
- 상단에 제목과 표준 Navigation Bar 스타일 적용
Push 전환 시도 결과
동일한 tryPush() 코드 실행 시:
- navigationController가 존재하므로 Push 성공
- beforeCount = 1, afterCount = 2 (ViewController 추가됨)
- 자연스러운 화면 전환 가능
- "Push 성공!" 메시지 표시
- 새로운 화면으로 부드럽게 전환
결과:
한 번의 dismiss로 전체 플로우 종료
깔끔하게 메인 앱으로 복귀
중간에 쌓인 모든 화면들이 한 번에 정리됨
사용자는 바로 메인 화면으로 돌아감
@objc
private func touchUpInsideStackModalButton(_ sender: UIButton) {
let modalVC = ModalViewController1()
modalVC.title = "네비모달을 띄운경우"
let navController = UINavigationController(rootViewController: modalVC)
navController.modalPresentationStyle = .fullScreen
self.present(navController, animated: true)
}
@objc
private func tryPush() {
let nextVC = ModalViewController2()
// Push 시도 전후의 ViewController 개수 확인
let beforeCount = navigationController?.viewControllers.count ?? 0
navigationController?.pushViewController(nextVC, animated: true)
// 잠깐 대기 후 결과 확인
try? await Task.sleep(for: .seconds(0.5))
let afterCount = navigationController?.viewControllers.count ?? 0
if afterCount > beforeCount {
updateStatus("Push 성공!")
} else {
// 단일 Modal에서는 여기로 진입
updateStatus("Push 실패! navigationController가 nil입니다.")
try? await Task.sleep(for: .seconds(2))
await showAlternativeAction() // 새로운 Modal 띄우기
}
}
@objc
private func backToRoot() {
if let navController = navigationController,
let presentingVC = navController.presentingViewController {
// 전체 Modal Navigation을 한 번에 dismiss
presentingVC.dismiss(animated: true)
}
}
Modal 사용은 선택이 아닌 필수인셈이다. 보안과 법적 요구사항 때문입니다.
하지만 단순한 Modal만으로는 복잡한 플로우를 처리할 수 없다. Push 전환이 안 되기 때문입니다.
( 정확히는 navigationController가 nil이기때문에 Push를 할수 없는것)
따라서 UINavigationController로 Modal을 감싸는 것이 유일한 올바른 해결책!
"Modal을 쓸 수밖에 없고, Modal을 제대로 쓰려면 NavigationController로 감쌀 수밖에 없다" - 이것이 핵심입니다.