Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/1. poppang-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ jobs:
ADMOB_NATIVE_AD_UNIT_ID = ca-app-pub-3940256099942544/3986624511
EOF

- name: Download React Native release artifacts
env:
GH_TOKEN: ${{ github.token }}
run: ./scripts/download-rn-release.sh v0.1.0

- name: Generate Tuist workspace
run: |
"$HOME/.local/bin/mise" x tuist@$(cat .tuist-version) -- tuist install
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/2. poppang-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ jobs:
ADMOB_NATIVE_AD_UNIT_ID = ca-app-pub-3940256099942544/3986624511
EOF

- name: Download React Native release artifacts
env:
GH_TOKEN: ${{ github.token }}
run: ./scripts/download-rn-release.sh v0.1.0

- name: Generate Tuist workspace
run: |
"$HOME/.local/bin/mise" x tuist@$(cat .tuist-version) -- tuist install
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/3. poppang-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ jobs:
ADMOB_APP_ID = cidummy
EOF

- name: Download React Native release artifacts
env:
GH_TOKEN: ${{ github.token }}
run: ./scripts/download-rn-release.sh v0.1.0

- name: Generate Tuist workspace
run: |
"$HOME/.local/bin/mise" x tuist@$(cat .tuist-version) -- tuist install
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ GoogleService-Info.plist
# codex
.codex/logs/

# React Native release artifacts
.rn-release-temp/
Vendor/PrebuiltReactNativeFrameworks/
Projects/App/Resources/ReactNative/

# macOS
.DS_Store
._*
Expand Down
Binary file added Docs/images/module-dependency-graph-legacy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Docs/images/module-dependency-graph.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 12 additions & 20 deletions Docs/poppang-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ Projects
│ ├── HomeFeature
│ ├── MapFeature
│ ├── OnboardingFeature
│ ├── PopPangRNFeature
│ ├── PopupDetailFeature
│ ├── PopupRequestFeature
│ ├── PopupRequestManagementFeature
│ ├── ProfileFeature
│ ├── ReviewFeature
│ ├── MainTabFeature
Expand Down Expand Up @@ -101,13 +100,9 @@ Projects
- 현재 로그인 사용자는 `AppFeature.session.user`로 표현한다.
- active main flow는 `MainTabFeature`가 TCA `StackState`와 단일 `@Presents` destination으로 소유한다.
- `AppFeature`는 `MainTabFeature.State?`를 저장하고, 메인 진입 시 shared `session`을 전달해 한 번 생성한다.
- TCA-ready feature는 필요한 경우 parent가 내려주는 shared `session`을 직접 읽는다.
- 현재 `CalendarFeature`도 shared `session`을 직접 읽고, 캘린더 로컬 상태를 feature state가 소유한다.
- 현재 `FavoritesFeature`도 shared `session`을 직접 읽고, 찜 로컬 상태를 feature state가 소유한다.
- 현재 `HomeFeature`는 shared `session`을 직접 읽고, 홈 로컬 상태만 feature state가 소유한다.
- 현재 `MapFeature`도 shared `session`을 직접 읽고, 지도 로컬 상태를 feature state가 소유한다.
- 현재 `AlertFeature`도 shared `session`을 직접 읽고, 알림 로컬 상태를 feature state가 소유한다.
- 현재 `ProfileFeature`도 shared `session`을 직접 읽고, 프로필 로컬 상태와 프로필 설정 path는 TCA reducer가 소유한다.
- `MainTabFeature`는 shared `session`을 root session 동기화에만 사용하고, child feature에는 로그인 사용자 snapshot만 전달한다.
- 현재 `HomeFeature`, `CalendarFeature`, `FavoritesFeature`, `MapFeature`, `ProfileFeature`, `ProfileSettingFeature`, `AlertFeature`는 shared `session`을 직접 읽지 않는다.
- 각 feature state는 필요한 사용자 primitive와 화면 로컬 상태를 함께 소유한다.
- legacy feature는 당분간 session-derived primitive 값을 view init으로 주입한다.
- `MainTabFeature.Action`은 탭 child action, `path`, `destination`, parent delegate 중심으로 유지한다.
- 탭 내부의 로컬 sheet/bottom sheet/selected item 상태는 각 feature가 소유한다.
Expand Down Expand Up @@ -138,22 +133,18 @@ Projects
#### Home / Calendar / Map / Favorites / Profile / Alert

- `AppFeature`가 shared `session` source of truth를 소유
- `MainTabFeature`가 shared `session`을 `HomeFeature`, `CalendarFeature`, `MapFeature`, `FavoritesFeature`, `AlertFeature`, `ProfileFeature`, `ProfileSettingFeature`에 전달
- `HomeFeature.State`는 shared `session`을 읽고, `bestPopups`, `filter` 같은 홈 로컬 상태를 직접 소유
- `CalendarFeature.State`는 shared `session`을 읽고, `selectedDate`, `calendarPopups`, `popupEventCounts` 같은 캘린더 로컬 상태를 직접 소유
- `MapFeature.State`는 shared `session`을 읽고, 지도 팝업 목록/시트 상태/위치 상태를 직접 소유한다.
- `FavoritesFeature.State`는 shared `session`을 읽고, `favoritePopups`, `selectedDate`, `popupEventCounts` 같은 찜 로컬 상태를 직접 소유한다.
- `AlertFeature.State`는 shared `session`을 읽고, alert popup/keyword/recent keyword/편집 상태를 직접 소유한다.
- `ProfileFeature.State`는 shared `session`을 읽고, `localIsAlerted`, `errorMessage` 같은 프로필 로컬 상태를 직접 소유
- `ProfileSettingFeature.State`는 shared `session`을 읽고, 닉네임 변경/회원탈퇴용 로컬 상태를 직접 소유한다.
- `MainTabFeature.CoreState`가 로그인 `User` snapshot을 소유하고 각 child state를 생성한다.
- `HomeFeature.State`는 `userUuid`, `nickname`, `isAdmin`을, `CalendarFeature`, `FavoritesFeature`, `MapFeature`는 `userUuid`을 직접 소유한다.
- `ProfileFeature`, `ProfileSettingFeature`, `AlertFeature`는 화면에 필요한 사용자 snapshot과 로컬 상태를 직접 소유한다.
- 프로필 이름과 알림 상태 변경은 delegate로 `MainTabFeature`에 전달하고, MainTab이 child snapshot과 `AppFeature.session`을 함께 동기화한다.
- `HomeFeature`가 `@Dependencies.Dependency(\.homePopupClient)`로 feature-scoped dependency 사용
- `CalendarFeature`는 `@Dependencies.Dependency(\.calendarFeatureClient)`로 feature-scoped dependency를 사용한다.
- `MapFeature`는 `@Dependencies.Dependency(\.mapFeatureClient)`로 feature-scoped dependency를 사용한다.
- `FavoritesFeature`는 `@Dependencies.Dependency(\.favoritesFeatureClient)`로 feature-scoped dependency를 사용한다.
- `AlertFeature`는 `@Dependencies.Dependency(\.alertFeatureClient)`로 feature-scoped dependency를 사용한다.
- `ProfileFeature`와 `ProfileSettingFeature`는 `@Dependencies.Dependency(\.profileFeatureClient)`로 feature-scoped dependency를 사용한다.
- `MainTabFeature`가 홈에서 시작하는 fullScreen presentation을 소유
- 현재 검색과 팝업 제보는 `MainTabFeature.destination`에서 관리
- 현재 검색과 RN 팝업 제보는 `MainTabFeature.destination`에서 관리
- `CalendarFeature`는 지역/정렬 시트를 view local state로 열고, 실제 필터/날짜/좋아요 상태는 reducer가 소유한다.
- `FavoritesFeature`는 segmented selection을 view local state로 두고, 찜 목록/찜 캘린더/좋아요/에러 상태는 reducer가 소유한다.
- 홈에서 시작하는 연속 drill-down push(`오픈예정팝업 리스트 -> 팝업 상세`)는 `MainTabFeature.path`가 소유
Expand All @@ -162,7 +153,7 @@ Projects
- 알림 화면에서 선택한 팝업 상세도 `MainTabFeature.path`가 소유하고, child feature는 delegate action으로 popup selection intent만 올린다.
- 프로필 설정 push도 `MainTabFeature.path`가 소유하고, child feature는 delegate action으로 dismiss/logout intent만 올린다.
- 여러 탭과 공통으로 이어질 수 있는 push 흐름은 `MainTabFeature.path`가 소유
- 현재 팝업 상세, 리뷰 상세, 관리자 팝업 제보 관리 흐름이 여기에 해당
- 현재 팝업 상세, 리뷰 상세, RN 관리자 팝업 제보 관리 흐름이 여기에 해당

```swift
HomeFeatureView(store: store.scope(state: \.core.home, action: \.home))
Expand Down Expand Up @@ -193,7 +184,7 @@ ProfileFeatureView(store: store.scope(state: \.core.profile, action: \.profile))
주의:

- feature가 다른 feature를 직접 import하는 구조는 기본 전략이 아니다.
- 예외로 `PopupRequestFeature`, `PopupRequestManagementFeature`, `PopupSubmissionFormFeature`는 제거 예정 임시 흐름이라 서로 직접 참조를 허용한다.
- `PopPangRNFeature`는 RN host wrapper 전용 모듈이며, 화면 전환은 계속 `MainTabFeature`가 소유한다.
- active main flow에서 feature 간 이동은 `MainTabFeature.Path` 또는 `MainTabFeature.Destination` state로 조립한다.
- 화면 로컬 상태는 feature reducer local state 또는 feature view local state에 둔다.
- 전역 이동, 탭 바깥 push, full screen 전환은 상위 TCA parent reducer로 올린다.
Expand Down Expand Up @@ -419,6 +410,7 @@ DI 변경 시 함께 확인할 파일:
원칙:

- feature target은 기본적으로 `.staticFramework` 성격의 앱 내부 leaf feature로 본다.
- `PopPangRNFeature`는 React Native prebuilt binary의 링크 설정을 보존하기 위해 `.framework`를 사용한다.
- `Coordinator`는 legacy 공유 경계이며 제거 대상이다.
- `Domain`, `Data`, `Core`, `DSKit`, `ThirdParty`는 현재 공유 경계로 본다.
- 장기적으로 `Shared.Models`, `Shared.Clients`, `Shared.Caches`, `SharedFeature` 분리를 검토한다.
Expand Down
3 changes: 3 additions & 0 deletions Docs/static-dynamic-linking.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,12 @@ Data sources
| `Core` | `.framework` | 네트워크, 저장소, formatter 등 여러 레이어가 공유하는 기반 모듈이다. |
| `DSKit` | `.framework` | Feature들이 공유하는 UI resource/API 경계다. |
| `ThirdParty` | `.framework` | 외부 SDK product를 한 곳에서 링크하는 허브다. |
| `PopPangRNFeature` | `.framework` | RN prebuilt binary를 하나의 동적 경계에서 링크하고 host 심볼을 최종 앱에 한 번만 전달한다. |
| demo app | `.app` | 독립 실행 샘플 앱이다. |
| App | `.app` | 최종 앱 산출물이다. |

RN prebuilt 산출물은 저장소에 커밋하지 않고 `scripts/download-rn-release.sh`로 `Vendor/PrebuiltReactNativeFrameworks`와 `Projects/App/Resources/ReactNative`에 내려받는다.

### feature는 왜 static인가

PopPang feature는 아래 성격이다.
Expand Down
27 changes: 12 additions & 15 deletions Docs/tca-navigation-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,13 @@
- 새 화면 전환은 TCA state/action/reducer로 모델링한다.
- tree-based navigation과 stack-based navigation을 함께 사용한다.
- feature는 다른 feature를 직접 조립하지 않고 delegate action으로 intent만 올린다.
- 임시 예외로 제거 예정인 `PopupRequestFeature`, `PopupRequestManagementFeature`, `PopupSubmissionFormFeature` 조합만 직접 조립을 허용한다.
- RN 팝업 제보 화면은 `PopPangRNFeature` wrapper로 감싸고, navigation owner는 계속 `MainTabFeature`가 가진다.
- 전역 세션 상태의 source of truth는 `AppFeature.session`이다.
- 현재 로그인 사용자는 `AppFeature.session.user`로 표현한다.
- `AppFeature`는 root/auth/register 전환을 소유하고, `MainTabFeature` 모듈은 로그인 이후 main flow navigation owner로 동작한다.
- `MainTabFeature`는 shared `session`을 child feature에 전달하고, 탭 로컬 navigation state를 소유한다.
- direct scope가 가능한 feature는 shared `session`을 직접 읽거나 필요한 값을 projection해서 reducer/state에 주입한다.
- 현재 `CalendarFeature`도 shared `session`을 직접 읽고 캘린더 로컬 상태를 feature state가 소유한다.
- 현재 `FavoritesFeature`도 shared `session`을 직접 읽고 찜 로컬 상태를 feature state가 소유한다.
- 현재 `HomeFeature`는 shared `session`을 직접 읽고 홈 로컬 상태를 feature state가 소유한다.
- 현재 `MapFeature`도 shared `session`을 직접 읽고 지도 로컬 상태를 feature state가 소유한다.
- 현재 `ProfileFeature`와 `ProfileSettingFeature`도 shared `session`을 직접 읽고 프로필 로컬 상태를 feature state가 소유한다.
- `MainTabFeature`는 shared `session`을 root session 동기화에만 사용하고, child feature에는 사용자 snapshot을 전달하며 탭 로컬 navigation state를 소유한다.
- direct scope가 가능한 feature는 필요한 사용자 primitive를 state에 주입하고 shared `session`을 직접 읽지 않는다.
- 현재 `HomeFeature`, `CalendarFeature`, `FavoritesFeature`, `MapFeature`, `ProfileFeature`, `ProfileSettingFeature`, `AlertFeature`는 사용자 snapshot과 화면 로컬 상태를 feature state가 소유한다.
- legacy feature는 당분간 session-derived primitive 값을 view init으로 주입한다.

## 용어
Expand Down Expand Up @@ -141,7 +137,7 @@ PopPang에서 stack-based navigation을 쓰는 경우:
- coming popup list에서 popup detail로 이어지는 drill-down
- review detail
- alert에서 popup detail 진입
- popup request management detail
- RN popup request management
- profile setting처럼 탭 root 위로 push되는 화면

### Path 규칙
Expand Down Expand Up @@ -254,11 +250,11 @@ PopPang

## Session Injection Strategy

세션 source of truth는 항상 `AppFeature.session`이다. 하위 feature는 필요 시 parent가 explicit shared state로 내려준 `session`을 읽는다.
세션 source of truth는 항상 `AppFeature.session`이다. `MainTabFeature`는 session 동기화가 필요한 parent 작업에만 shared state를 사용하고, 하위 feature에는 사용자 snapshot을 전달한다.

### 1. TCA-ready feature는 state projection + direct scope

`HomeFeature` 현재 기준선이다.
`HomeFeature`를 포함한 TCA-ready 탭이 현재 기준선이다.

```swift
@ObservableState
Expand All @@ -269,16 +265,17 @@ struct AppFeature.State: Equatable {
```

```swift
init(session: Shared<UserSession>) {
self.home = .init(session: session)
init(user: User) {
self.home = .init(user: user)
self.calendar = .init(userUuid: user.userUuid)
}
```

```swift
HomeFeatureView(store: store.scope(state: \.core.home, action: \.home))
```

이 방식에서는 view가 `userUuid`, `nickname`, `isAdmin` 같은 session-derived primitive를 따로 받지 않는다. feature state는 shared `session`을 통해 최신 값을 읽고, reducer는 feature-scoped dependency를 직접 사용한다.
이 방식에서는 view가 별도 사용자 closure를 받지 않는다. feature state가 `userUuid`, `nickname`, `isAdmin` 같은 필요한 snapshot을 직접 소유하고, reducer는 feature-scoped dependency를 직접 사용한다. 변경 가능한 사용자 값은 child delegate를 통해 MainTab이 session 원본과 각 snapshot에 반영한다.

```swift
@Reducer
Expand Down Expand Up @@ -475,7 +472,7 @@ case .destination:
- `popupRequestManagement`처럼 메인 공통 흐름으로 이어지는 push는 `MainTabFeature.path`가 소유
- `CalendarFeature`: direct scope 완료, region/sort sheet는 view local state로 두고 상세/알림 이동은 `MainTabFeature.path`가 소유
- `FavoritesFeature`: direct scope 완료, segmented selection은 view local state로 두고 상세/알림 이동과 홈 탭 복귀 intent만 parent로 올림
- `Map`: bridge reducer를 유지하며 session primitive만 주입
- `MapFeature`: direct scope로 동작하며 `userUuid` snapshot만 주입
- 각 탭이 TCA reducer entry를 갖추면 `*LegacyBridgeFeature`를 제거하고 direct scope로 전환

## Do Not
Expand Down
15 changes: 12 additions & 3 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import ProjectDescription

let project = Project(
name: "App",
packages: [
.local(path: "../../Vendor/PrebuiltReactNativeFrameworks"),
],
targets: [
.target(
name: "PopPangApp",
Expand Down Expand Up @@ -50,6 +53,7 @@ let project = Project(
"NSLocationAlwaysAndWhenInUseUsageDescription": "주변 팝업스토어를 지도에서 보여주고 거리 순으로 제공하기 위해 위치 정보가 필요합니다.",
"NSLocationAlwaysUsageDescription": "팝업스토어 정보 제공을 위해 사용자의 위치를 받습니다.",
"NSLocationWhenInUseUsageDescription": "주변 팝업스토어를 지도에서 보여주고 거리 순으로 제공하기 위해 위치 정보가 필요합니다.",
"NSPhotoLibraryUsageDescription": "팝업 제보에 사용할 사진을 선택하기 위해 사진 보관함에 접근합니다.",
"NSAppTransportSecurity": [
"NSAllowsArbitraryLoads": false,
],
Expand Down Expand Up @@ -120,7 +124,11 @@ let project = Project(
]
),
sources: ["Sources/**"],
resources: ["Resources/**"],
resources: [
"Resources/Assets.xcassets",
"Resources/GoogleService-Info.plist",
.folderReference(path: "Resources/ReactNative"),
],
entitlements: "PopPangApp.entitlements",
dependencies: [
.project(target: "ADKit", path: "../Shared/ADKit"),
Expand All @@ -132,6 +140,7 @@ let project = Project(
.project(target: "ThirdParty", path: "../Shared/ThirdParty"),
.project(target: "Core", path: "../Shared/Core"),
.project(target: "DSKit", path: "../Shared/DSKit"),
.package(product: "PrebuiltReactNativeFrameworks"),
],
settings: .settings(
base: [
Expand All @@ -141,8 +150,8 @@ let project = Project(
"CODE_SIGN_STYLE": "Manual",
"DEVELOPMENT_TEAM": "",
"DEVELOPMENT_TEAM[sdk=iphoneos*]": "LGX4B4WC66",
"CURRENT_PROJECT_VERSION": "6",
"MARKETING_VERSION": "1.1.5",
"CURRENT_PROJECT_VERSION": "1",
"MARKETING_VERSION": "1.1.6",
"PROVISIONING_PROFILE_SPECIFIER": "",
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "match Development kr.co.poppang.PopPang",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ enum AppSDKInitializer {

// 지도 오토 레이아웃 경고 제거
UserDefaults.standard.set(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable")

Logger.d("1. 라이브러리 configure 완료")
}
}

private enum FirebaseCoreBootstrap {
static func configureIfNeeded() {
FirebaseConfiguration.shared.setLoggerLevel(.error)
FirebaseApp.configure()
Logger.d("FirebaseApp.configure 완료")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import Data
import Domain
import Foundation

// repository
struct AppRepositoryRegistry {
let adminRepository: AdminRepositoryProtocol
let appleAuthRepository: AppleAuthRepositoryProtocol
let googleAuthRepository: GoogleAuthRepositoryProtocol
let kakaoAuthRepository: KakaoAuthRepositoryProtocol
let popupRepository: PopupRepositoryProtocol
let popupSubmissionRepository: PopupSubmissionRepositoryProtocol
let userRepository: UserRepositoryProtocol

static func live() -> AppRepositoryRegistry {
Expand All @@ -18,19 +18,18 @@ struct AppRepositoryRegistry {
googleAuthRepository: GoogleAuthRepositoryImpl(),
kakaoAuthRepository: KakaoAuthRepositoryImpl(),
popupRepository: PopupRepositoryImpl(),
popupSubmissionRepository: PopupSubmissionRepositoryImpl(),
userRepository: UserRepositoryImpl()
)
}
}

// usecase
struct AppUsecaseRegistry {
let adminUsecase: AdminUsecaseProtocol
let appleAuthUsecase: AppleAuthUsecaseProtocol
let googleAuthUsecase: GoogleAuthUsecaseProtocol
let kakaoAuthUsecase: KakaoAuthUsecaseProtocol
let popupUsecase: PopupUsecaseProtocol
let popupSubmissionUsecase: PopupSubmissionUsecaseProtocol
let userUsecase: UserUsecaseProtocol

init(repositories: AppRepositoryRegistry) {
Expand All @@ -39,11 +38,11 @@ struct AppUsecaseRegistry {
self.googleAuthUsecase = GoogleAuthUsecaseImpl(googleAuthRepository: repositories.googleAuthRepository)
self.kakaoAuthUsecase = KakaoAuthUsecaseImpl(kakaoAuthRepository: repositories.kakaoAuthRepository)
self.popupUsecase = PopupUsecaseImpl(popupRepository: repositories.popupRepository)
self.popupSubmissionUsecase = PopupSubmissionUsecaseImpl(repository: repositories.popupSubmissionRepository)
self.userUsecase = UserUsecaseImpl(userRepository: repositories.userRepository)
}
}

// usecase dependency
struct AppDependencyRegistry {
let repositories: AppRepositoryRegistry
let usecases: AppUsecaseRegistry
Expand Down
Loading
Loading