- 모듈을 나눴더니 다른 모듈의 화면을 못 띄웁니다. 어떻게 푸나요?
- 왜 구현이 아니라 Interface만 참조합니까? 다른 해법은 없나요?
- Factory·클로저·Route enum·DI 컨테이너 중 무엇을 언제 씁니까?
- DI 컨테이너에서
resolve로 꺼내면 뭐가 문제인가요?
문제 — 화면을 띄우려면 "만들 줄" 알아야 한다 🔥 핵심
02장에서 모듈을 나눴습니다. 그러자 바로 벽에 부딪힙니다 — 검색 화면에서 예약 화면으로 가야 하는데, Search 모듈은 Booking 모듈을 볼 수 없습니다.
화면 이동은 겉보기엔 "이동"이지만 실제로는 생성입니다.
BookingView를 화면에 올리려면 인스턴스를 만들어야 하고, 만들려면
그것이 필요로 하는 것들까지 전부 알아야 합니다.
모든 해법이 공유하는 발상은 하나입니다 — "무엇을 원하는지"는 Search가 정하고, "어떻게 만들지"는 Booking(또는 앱)이 안다. 아래 네 방식은 그 부탁을 어떤 모양으로 주고받느냐의 차이일 뿐입니다.
왜 구현을 직접 참조하면 안 되는가
"그냥 Booking 모듈을 import하면 되지 않나?"가 자연스러운 반문입니다. 안 되는 이유가 넷 있습니다.
① 순환 의존이 쉽게 생긴다
Search → Booking("예약 열기")과 Booking → Search("완료 후 검색으로 복귀")는 지극히 평범한 요구인데, 구현끼리 직접 참조하면 순환이 됩니다. Xcode·SPM의 타깃 의존 그래프는 사이클을 허용하지 않으므로 빌드가 실패합니다.
"순환을 깨려면 Interface 모듈이 필요하다"는 말은 과장입니다. 순환은 델리게이트·클로저 콜백·공용 프로토콜 모듈 하나·Route 값으로도 깨집니다. Interface/Implementation 완전 분리는 여러 해법 중 가장 구조적인 하나일 뿐이며, 그만큼 보일러플레이트라는 대가를 치릅니다. 면접에서 "그것 말고는 방법이 없다"고 말하면 "델리게이트로도 되잖아요?"에 막힙니다.
② 재컴파일이 전파된다
02장에서 본 그대로입니다. 구현을 직접 import하면 Booking을 건드릴 때마다 Search와 그 상위가 전부 다시 컴파일됩니다. 추상만 참조하면 시그니처가 그대로인 한 전파되지 않습니다.
③ 전이 의존이 폭발한다
Search가 Booking 구현을 import하면 Booking이 쓰는 것까지 딸려옵니다 — 결제 SDK, 지도, 이미지 라이브러리. 몇 개만 이렇게 얽혀도 거의 모든 모듈이 거의 모든 것에 의존하는 상태가 되고, 그 시점에 모듈화는 이름만 남습니다.
④ 테스트에서 갈아끼울 수 없다
Search를 테스트할 때 진짜 Booking 화면이 필요하지 않습니다. 추상에 의존하면 아무것도 안 하는 가짜를 넣고 "예약 버튼을 누르면 요청이 나가는가"만 검증할 수 있습니다.
조립 지점 — 유일하게 양쪽을 다 아는 곳
그럼 진짜 BookingView는 누가 만들까요? 앱 타깃(조립 지점, composition root)입니다.
여기가 추상과 구현을 동시에 볼 수 있는 유일한 장소입니다.
네 가지 방식 — 결합도로 갈린다 🔥 핵심
Factory가 유일한 답은 아닙니다. 실무에서 쓰이는 선택지가 넷 있는데, ①②③은 컴파일 타임 안전성이 대등합니다. 실제로 갈리는 축은 누구와 결합하느냐입니다.
switch면 케이스 누락을 컴파일러가 잡습니다. 대신 모든 피처가 한 타입을 공유하는 결합이 생깁니다.① Factory 프로토콜 — 기본값
// BookingInterface 모듈 — 다른 모듈이 보는 유일한 표면
public protocol BookingFactory {
func makeBookingView(clinicID: Clinic.ID) -> AnyView
}
// Search 모듈 — 구현이 아니라 프로토콜에만 의존
struct SearchView: View {
let bookingFactory: any BookingFactory
var body: some View {
NavigationLink("예약하기") {
bookingFactory.makeBookingView(clinicID: clinic.id)
}
}
}
구현 쪽은 자기 의존을 스스로 조립해서 밖으로 흘리지 않는 게 핵심입니다.
아래처럼 init이 내부 타입을 받으면 그 타입도 public이 되어야 하고,
결국 내부 구조가 공개 표면으로 새어 나갑니다.
// ❌ 내부 타입이 공개 init으로 새어 나간다
public struct LiveBookingFactory: BookingFactory {
public init(repository: BookingRepository) { … } // BookingRepository도 public이어야 함
}
// ✅ 밖에는 앱이 이미 아는 것만 노출하고, 내부 조립은 안에서 끝낸다
public struct LiveBookingFactory: BookingFactory {
private let api: APIClient // 앱 공용 타입
public init(api: APIClient) { self.api = api }
public func makeBookingView(clinicID: Clinic.ID) -> AnyView {
let repository = LiveBookingRepository(api: api) // 내부 타입은 내부에서만
return AnyView(BookingView(store: BookingStore(clinicID: clinicID, repository: repository)))
}
}
명시적이고 타입 안전합니다. 팩토리를 안 넘기면 컴파일이 안 되므로 의존성이 코드에 드러납니다. 대신 목적지가 늘어날수록 넘겨줘야 할 팩토리도 늘어납니다.
② 클로저 주입 — 더 가벼운 같은 것
struct SearchView: View {
let makeBookingView: (Clinic.ID) -> AnyView // 프로토콜 타입조차 공유 안 함
var body: some View {
NavigationLink("예약하기") { makeBookingView(clinic.id) }
}
}
프로토콜을 따로 선언하지 않아도 되고, 테스트는 { _ in AnyView(EmptyView()) }로 끝납니다.
결합 범위가 가장 좁습니다 — Search는 Booking이라는 이름조차 모릅니다.
대신 이름이 없어서 생성 함수가 여러 개면 흩어지고, 시그니처만으로는 용도가 덜 명확합니다.
판단 기준 — 목적지가 1~2개면 클로저, 여러 개를 묶어야 하면 프로토콜입니다. 프로토콜의 진짜 값어치는 "관련된 생성 메서드를 한 이름 아래 모으는 것"입니다.
③ Route enum + 중앙 라우터
발상을 뒤집습니다. 피처 모듈은 화면을 만들지 않고, "어디로 가고 싶다"는 값만 내보냅니다.
// 공용 Route 모듈 — 아주 가벼운 값 타입만 있다
public enum Route: Hashable {
case booking(Clinic.ID)
case clinicDetail(Clinic.ID)
}
// Search 모듈 — 주입받는 게 없다. 값만 넣는다
NavigationLink(value: Route.booking(clinic.id)) { Text("예약하기") }
// App 타깃 — 한 곳에서 Route를 화면으로 바꾼다
.navigationDestination(for: Route.self) { route in
switch route { // 케이스를 빠뜨리면 컴파일 실패
case let .booking(id): BookingView(store: makeBookingStore(id))
case let .clinicDetail(id): ClinicDetailView(store: makeDetailStore(id))
}
}
| 장점 | 단점 |
|---|---|
| 뷰마다 팩토리를 넘길 필요가 없다 | Route enum이 전 피처의 공용 결합점이 된다 |
AnyView가 불필요 — @ViewBuilder switch가 구체 타입을 만든다 |
경로가 늘면 그 모듈이 자주 바뀌어 재컴파일을 유발 |
| 딥링크·상태 복원과 자연스럽게 맞는다 (값이라 직렬화 가능) | 화면 전환 로직이 피처에서 멀어져 흐름 추적이 간접적 |
단점은 피처별로 Route를 쪼개면 상당 부분 완화됩니다 —
각 FeatureInterface가 자기 Route를 선언하고 앱이 조합하는 방식입니다.
"하나의 거대 enum"이 되지 않게 하는 것이 관건입니다.
④ DI 컨테이너에서 꺼내기 — 편하지만 절벽이 있다
let view = container.resolve(BookingFactory.self)! // ← 이 강제 언래핑
주입을 넘길 필요가 없어 가장 편합니다. 그리고 여기서만 컴파일러의 보증이 사라집니다.
| 문제 | 왜 |
|---|---|
| 컴파일 타임 보증 상실 | 등록을 빠뜨려도 빌드는 성공하고 실행 중에 크래시 |
| 의존성이 숨는다 | 초기화 함수만 봐서는 무엇을 필요로 하는지 알 수 없음 |
| 테스트가 무거워진다 | 객체 하나 테스트하려고 컨테이너 전체를 구성 |
| 전역 상태 | 테스트 간 등록이 새어 순서 의존적 실패 발생 |
이 방식은 서비스 로케이터(Service Locator)라 부릅니다. Mark Seemann은 이를 안티패턴으로 규정하지만 Martin Fowler는 상황에 따른 선택지로 봅니다 — 합의된 결론은 아닙니다. 다만 위 네 가지 대가는 실재하므로, 쓰더라도 조립 지점에서만 꺼내고 피처 내부로는 생성자 주입으로 내려보내는 게 안전합니다.
덧 — AnyView의 비용 🧩 롱테일
①②는 AnyView를 반환합니다. 동작하지만 대가가 있고, 스스로 짚으면 점수가 됩니다.
SwiftUI는 뷰의 타입으로 structural identity를 판정합니다.
서로 다른 뷰라도 AnyView로 감싸면 타입 시스템 관점에서 완전히 동일해 보이므로
SwiftUI가 갱신을 최적화할 근거를 잃습니다. 정확히는 감싼 타입이 달라졌을 때
이전 것을 버리고 새 서브트리를 만듭니다.
| 대안 | 방법 | 대가 |
|---|---|---|
| Route enum | 앱에서 switch로 구체 타입 반환 | 타입 소거 없음 — 가장 깔끔 |
| 제네릭 파라미터 | struct SearchView<F: BookingFactory> | 제네릭이 부모 뷰로 전염됨 |
| associatedtype | associatedtype Body: View | any로 담아 쓰기 까다로움 |
| UIKit | -> UIViewController 반환 | 이미 클래스라 소거 비용 없음 |
"AnyView는 무조건 피하라"가 정설처럼 통하지만,
최신 SwiftUI에서 그 비용이 과장됐다는 반론도 있습니다.
현실적인 결론 — 화면 단위처럼 자주 다시 그려지지 않는 경계에서는 실용적이고,
문제가 되는 건 리스트 셀이나 매 프레임 갱신되는 자리입니다.
"비용을 알고 이 자리에서는 감수했다"고 말할 수 있으면 충분합니다.
정리 — 무엇을 고를 것인가
| 상황 | 추천 |
|---|---|
| 목적지 1~2개, 단순 이동 | ② 클로저 주입 — 결합이 가장 좁다 |
| 한 모듈이 여러 화면을 제공 | ① Factory 프로토콜 — 이름으로 묶인다 |
| 딥링크·상태 복원 중요, 화면이 많음 | ③ Route enum — 피처별로 분할할 것 |
| 어디서나 편하게 꺼내고 싶음 | ④ — 조립 지점 밖에서는 피할 것 |
면접에서의 좋은 답 — "네 가지가 있고, ①②③은 안전성이 대등해서 결합 범위로 고릅니다. ④만 컴파일러 보증을 포기하는 거라 조립 지점 밖에서는 안 씁니다." 하나를 정답으로 미는 것보다 축을 제시하는 답이 강합니다.