- 키워드를 등록해 원하는 팝업 알림 받기
- 검색·필터로 보고 싶은 팝업 바로 찾기
- 달력에서 날짜별 팝업 일정 확인하기
- 지도로 내 주변 팝업 한눈에 보기
- 관심 팝업을 찜해 나만의 리스트로 관리하기
- 상세 정보, 공유, 외부 링크, 리뷰 흐름까지 한 번에 이용하기
| library | description |
|---|---|
| Tuist | Micro Feature Architecture 기반 workspace와 project를 구성하기 위함 |
| TCA | Feature 단위 MVI 상태 관리를 위함 |
| FirebaseSDK | FCM 푸시 알림과 Analytics를 구현하기 위함 |
| KakaoSDK | 카카오 소셜 로그인과 카카오 공유를 구현하기 위함 |
| GoogleSignIn | 구글 소셜 로그인을 구현하기 위함 |
| Moya | 추상화된 네트워크 레이어를 보다 간편하게 사용하기 위함 |
| Kingfisher | 이미지 캐싱 처리 및 UI 성능 개선을 위함 |
| NMapsMap | 지도 기반 팝업 탐색 기능을 구현하기 위함 |
| BottomSheet | 지도와 상세 흐름의 바텀시트 UI를 구현하기 위함 |
| PopPangListKit | UICollectionView 기반 선언형 목록 DSL을 제공하는 외부 라이브러리 |
문제
SwiftUI
List는 표준 목록을 빠르게 만들기 좋지만 복잡한 scroll lifecycle, prefetch, pagination과 업데이트 전략을 직접 제어하기 어려웠음반대로
UICollectionView는 동작을 예측하고 튜닝할 수 있지만 화면마다 data source와 delegate 연결 코드가 반복됐음해결
UICollectionView와 DifferenceKit을 Core로 유지하면서List,Section,Cell로 구성하는 선언형 DSL을 구현기존 UIKit
Component와 SwiftUIView가 같은 diff, layout, event 경로를 공유하도록PopPangListKit외부 라이브러리로 분리성과
🔸 UIKit의 cell reuse, compositional layout과 scroll 제어를 유지하면서 선언형 목록 작성 가능
🔸 UIKit Component와 SwiftUI View를 하나의 Section에서 함께 사용
🔸 DifferenceKit 기반 변경 중심 batch update와
reloadData()fallback 제공🔸 Binding Cell과 최신 snapshot 병합으로 Toggle 같은 입력 상태를 안정적으로 갱신
🔸 framework와 tests는 iOS 13+, Demo app은 iOS 17+ 지원
struct PopupListView: View {
@State private var popups: [Popup] = []
var body: some View {
PopPangList {
Section(id: "popups") {
for popup in popups {
Cell(id: popup.id, item: popup) { popup in
PopupRow(popup: popup)
}
}
}
.withSectionLayout(VerticalLayout(spacing: 8))
}
}
}문제
여러 API가 순차적으로 호출되며 전체 로딩이 길어졌음해결
TaskGroup을 활용해 병렬 처리 구조로 전환성과
🔸 초기 로딩 시간 40% 단축
func getAllPopupData() async {
do {
try await withThrowingTaskGroup(of: (Int, [Popup]).self) { group in
group.addTask { (0, await self.getPersonalRandomPopupList()) }
group.addTask { (1, await self.getPersonalUpcomingPopupList()) }
group.addTask { (2, await self.getPersonalFilteredPopupList()) }
for try await (index, popups) in group {
await MainActor.run {
switch index {
case 0: self.bestPopups = popups
case 1: self.comingPopups = popups
case 2: self.gridPopups = popups
default: break
}
}
}
}
} catch {
Logger.e("❌ 로딩 실패: \(error)")
}
}문제
Moya는 completion 기반이라 async/await과 직접 호환되지 않아
API마다 동일한 변환 코드가 반복됨해결
withCheckedThrowingContinuation기반 공통 async 변환 래퍼 구현성과
🔸 Repository 전역에서 동일한 async 인터페이스 사용
🔸 중복 코드 감소 + 유지보수성 향상
extension MoyaProvider {
func asyncRequest(_ target: Target) async throws -> Response {
try await withCheckedThrowingContinuation { continuation in
self.request(target) { result in
switch result {
case .success(let response):
continuation.resume(returning: response)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
let response = try await provider.asyncRequest(.getPopupList)문제
Feature가 다른 Feature를 직접 의존하면 화면 이동 하나를 변경해도 여러 모듈이 함께 영향을 받고, 각 Feature의 독립성과 재사용성이 낮아짐해결
메인 탭 하위 Feature의 조립과 화면 이동 책임을MainTabFeature로 모으고, 자식 Feature는 delegate action으로 이동 의도만 전달하도록 구성성과
🔸 일반 Feature 사이의 직접 의존 제거 🔸 화면 전환의 상태와 규칙을MainTabFeature에서 한 번에 추적 🔸 자식 Feature를 독립적으로 개발하고 테스트할 수 있는 경계 확보 🔸 화면 이동 closure 없이 TCA state/action으로 네비게이션 표현
PopPangApp
├── AuthFeature
├── OnboardingFeature
└── MainTabFeature
├── HomeFeature
├── CalendarFeature
├── MapFeature
├── FavoritesFeature
├── ProfileFeature
└── Search / PopupDetail / Review / Alert
AppFeature는 launch, onboarding, auth, register, main 전환을 소유하고,
MainTabFeature는 로그인 이후 자식 Feature와 탭 공통 화면 전환을 조립한다.
MainTabFeature는 자식 Feature의 상태와 화면 전환 규칙을 관리하는 reducer이고,
MainTabFeatureView는 해당 상태를 TabView, NavigationStack, fullScreenCover에 연결하는 View다.
Home 검색 버튼
→ HomeFeature.delegate.searchRequested
→ MainTabFeature가 Search 상태 생성
→ MainTabFeatureView가 검색 화면 표시
Destination: 검색이나 팝업 제보처럼 동시에 하나만 표시하는 full-screen/tree navigationPath: 팝업 상세나 리뷰처럼 화면이 순서대로 쌓이는 push/stack navigation- 일반 Feature는 다른 Feature를 직접 import하지 않고 delegate action으로 이동 의도만 전달
- 제거 예정인
PopupRequestFeature,PopupRequestManagementFeature,PopupSubmissionFormFeature조합만 임시 예외로 직접 참조 허용
case .home(.delegate(.searchRequested)):
state.core.destination = .search(
.init(
userUuid: state.currentUser.userUuid,
nickname: state.currentUser.displayNickname
)
)
return .none문제
Localizable.strings를 언어별로 직접 관리하면
키 누락, 오타, 언어별 불일치가 생기기 쉽고 문자열 키를 하드코딩할 때 디버깅 비용도 커졌음해결
Python/localizable.csv를 기준으로
en/ko/ja Localizable.strings와LocalizationKeys.swift를 자동 생성하는
CSV 기반 로컬라이제이션 생성 스크립트 구축성과
🔸 번역 데이터를 CSV 한 곳에서 일괄 관리
🔸LocalizationKeyenum 자동 생성으로 문자열 오타 위험 감소
🔸 번역 작업자, 기획자, 개발자가 같은 포맷으로 협업 가능
Text(LocalizationKey.commonNext.localized(comment: "Next button"))문제
기존V0앱은App,Presentation,Util,DesignSystem이 단일 타깃에 섞여 있어
기능이 늘어날수록 변경 영향 범위가 커지고, 독립 개발과 빌드 검증이 어려웠음해결
App / Features / Domain / Data / Shared구조로 분리하고,Tuist템플릿과Makefile래퍼로 feature, domain, data, core 모듈을 일관되게 생성하도록 구성성과
🔸 기능별 독립 모듈 개발 기반 확보
🔸 App 조립, 화면 흐름, UI 기능, 도메인 규칙, 데이터 구현 책임 분리
🔸 Demo app과 Core/Data 테스트를 통한 모듈 단위 검증 기반 확보
🔸 클린빌드 평균 129.447초 → 46.884초로 개선, 모듈화 프로젝트가 약 2.76배 빠름
🔸 증분빌드 평균 6.415초 → 4.184초로 개선, 모듈화 프로젝트가 약 1.53배 빠름
| 구분 | V0 | 모듈화 프로젝트 | 차이 |
|---|---|---|---|
| 클린빌드 평균 | 129.447초 | 46.884초 | 모듈화가 약 2.76배 빠름 |
| 증분빌드 평균 | 6.415초 | 4.184초 | 모듈화가 약 1.53배 빠름 |
| 측정 조건 | 값 |
|---|---|
| 반복 횟수 | 5회 |
| Destination | generic/platform=iOS Simulator |
| Configuration | Debug |
| V0 기준 | V0/PopPang.xcodeproj, scheme PopPang |
| 모듈화 기준 | PopPang.xcworkspace, scheme PopPangApp |
| 증분빌드 기준 | 파일 수정 없는 no-op 증분빌드 |
Projects
├── App
├── Features
│ ├── AuthFeature
│ ├── OnboardingFeature
│ ├── MainTabFeature
│ ├── HomeFeature
│ ├── SearchFeature
│ ├── PopupDetailFeature
│ ├── MapFeature
│ ├── CalendarFeature
│ ├── FavoritesFeature
│ ├── ProfileFeature
│ ├── AlertFeature
│ └── ReviewFeature
├── Domain
├── Data
└── Shared
├── Core
├── DSKit
└── ThirdParty
문제
외부 SDK가 여러 레이어에 직접 흩어지면 어떤 모듈이 어떤 SDK product를 링크하는지 파악하기 어렵고,
SPM product type 문제로 빌드와 런타임 경고가 발생할 수 있었음해결
외부 SDK product는Projects/Shared/ThirdParty에서만.external(...)로 링크하고,
실제 SDK 타입을 사용하는 파일은import Moya,import Kingfisher,import KakaoSDKShare,import NMapsMap처럼 실제 모듈명을 명시성과
🔸 SDK 링크 지점 단일화
🔸@_exported재노출 없이 실제 SDK 사용처 추적 가능
🔸 Firebase, GoogleSignIn, KakaoSDK, Moya 등 product type 정책을Tuist/Package.swift에서 관리
.target(
name: "ThirdParty",
product: .framework,
dependencies: [
.external(name: "FirebaseAnalytics"),
.external(name: "FirebaseMessaging"),
.external(name: "GoogleSignIn"),
.external(name: "KakaoSDKUser"),
.external(name: "Kingfisher"),
.external(name: "Moya"),
.external(name: "NMapsMap"),
]
)Tuist 버전은 4.115.0으로 고정합니다.
tuist version
tuist install
tuist generate생성된 workspace를 정리 후 다시 만들 때는 아래 명령을 사용합니다.
make regen빌드는 아래 명령으로 확인합니다.
tuist build PopPangApp
tuist build MainTabFeatureCore/Data 테스트는 아래 명령으로 실행합니다.
tuist test Core
tuist test Datamake module LAYER=feature NAME=Home
make module LAYER=feature NAME=PopupDetail INTERFACE=true
make module LAYER=domain NAME=Popup
make module LAYER=data NAME=Popup
make module LAYER=core NAME=HTTPClient
make module LAYER=dskit NAME=DSKit
make module LAYER=shared NAME=UIComponentsAGENTS.md: 저장소 작업 규칙과 아키텍처 기준Docs/tca-navigation-guidelines.md: MainTabFeature와 TCA navigation 기준- PopPangListKit: UICollectionView 기반 선언형 목록 라이브러리와 UIKit·SwiftUI 사용 예제
V0/README.md: 기존 단일 타깃 앱 READMETuist/Package.swift: 외부 의존성과 product type 정책

