diff --git a/AppleStoreProductKiosk.xcodeproj/project.pbxproj b/AppleStoreProductKiosk.xcodeproj/project.pbxproj
index aaaba52..5df1c83 100644
--- a/AppleStoreProductKiosk.xcodeproj/project.pbxproj
+++ b/AppleStoreProductKiosk.xcodeproj/project.pbxproj
@@ -38,7 +38,7 @@
7F41C5542E798756007C4017 /* Exceptions for "AppleStoreProductKiosk" folder in "AppleStoreProductKiosk" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
- Resources/Info.plist,
+ App/Resources/Info.plist,
);
target = C8F725DD2E790F9F00C2A1DE /* AppleStoreProductKiosk */;
};
@@ -428,7 +428,7 @@
DEVELOPMENT_TEAM = N94CS4N6VR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = AppleStoreProductKiosk/Resources/Info.plist;
+ INFOPLIST_FILE = AppleStoreProductKiosk/App/Resources/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -462,7 +462,7 @@
DEVELOPMENT_TEAM = N94CS4N6VR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = AppleStoreProductKiosk/Resources/Info.plist;
+ INFOPLIST_FILE = AppleStoreProductKiosk/App/Resources/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
diff --git a/AppleStoreProductKiosk.xcodeproj/xcshareddata/xcschemes/AppleStoreProductKiosk.xcscheme b/AppleStoreProductKiosk.xcodeproj/xcshareddata/xcschemes/AppleStoreProductKiosk.xcscheme
new file mode 100644
index 0000000..ae51c67
--- /dev/null
+++ b/AppleStoreProductKiosk.xcodeproj/xcshareddata/xcschemes/AppleStoreProductKiosk.xcscheme
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AppleStoreProductKiosk/App/Application/AppDelegate.swift b/AppleStoreProductKiosk/App/Application/AppDelegate.swift
new file mode 100644
index 0000000..0706632
--- /dev/null
+++ b/AppleStoreProductKiosk/App/Application/AppDelegate.swift
@@ -0,0 +1,27 @@
+//
+// AppDelegate.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+import UIKit
+
+import DiContainer
+
+class AppDelegate: UIResponder, UIApplicationDelegate {
+
+
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+
+ UnifiedDI.enablePerformanceOptimization()
+
+ DependencyContainer.bootstrapInTask { _ in
+ await AppDIContainer.shared.registerDefaultDependencies()
+ }
+
+ return true
+ }
+}
diff --git a/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift
new file mode 100644
index 0000000..7a69a95
--- /dev/null
+++ b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift
@@ -0,0 +1,26 @@
+//
+// AppleStoreProductKioskApp.swift
+// AppleStoreProductKiosk
+//
+// Created by 홍석현 on 9/16/25.
+//
+
+import SwiftUI
+import ComposableArchitecture
+
+@main
+struct AppleStoreProductKioskApp: App {
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ WindowGroup {
+ let store = Store(initialState: AppReducer.State()) {
+ AppReducer()
+ ._printChanges()
+ ._printChanges(.actionLabels)
+ }
+
+ AppView(store: store)
+ }
+ }
+}
diff --git a/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift b/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift
new file mode 100644
index 0000000..4e5d6d1
--- /dev/null
+++ b/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift
@@ -0,0 +1,22 @@
+//
+// Extension+AppDIContainer.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+import DiContainer
+
+extension AppDIContainer {
+ func registerDefaultDependencies() async {
+ await registerDependencies { container in
+ // Repository 먼저 등록
+ let factory = ModuleFactoryManager()
+
+ await factory.registerAll(to: container)
+
+ }
+ }
+}
+
diff --git a/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift b/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift
new file mode 100644
index 0000000..b9d1453
--- /dev/null
+++ b/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift
@@ -0,0 +1,22 @@
+//
+// Extension+RepositoryModuleFactory.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+
+import DiContainer
+
+extension RepositoryModuleFactory {
+ public mutating func registerDefaultDefinitions() {
+ let register = registerModule
+
+ definitions = {
+ return [
+ register.productRepositoryImplModule
+ ]
+ }()
+ }
+}
diff --git a/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift b/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift
new file mode 100644
index 0000000..d288588
--- /dev/null
+++ b/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift
@@ -0,0 +1,22 @@
+//
+// Extension+UseCaseModuleFactory.swift.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+
+import DiContainer
+
+extension UseCaseModuleFactory {
+ public mutating func registerDefaultDefinitions() {
+ let register = registerModule
+
+ definitions = {
+ return [
+ register.productUseCaseImplModule,
+ ]
+ }()
+ }
+}
diff --git a/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift b/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift
new file mode 100644
index 0000000..f5d1990
--- /dev/null
+++ b/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift
@@ -0,0 +1,19 @@
+//
+// ModuleFactoryManager.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+
+import DiContainer
+
+extension ModuleFactoryManager {
+ mutating func registerDefaultDependencies() {
+ // Repository
+ repositoryFactory.registerDefaultDefinitions()
+
+ useCaseFactory.registerDefaultDefinitions()
+ }
+ }
diff --git a/AppleStoreProductKiosk/App/Reducer/AppReducer.swift b/AppleStoreProductKiosk/App/Reducer/AppReducer.swift
new file mode 100644
index 0000000..26b23e7
--- /dev/null
+++ b/AppleStoreProductKiosk/App/Reducer/AppReducer.swift
@@ -0,0 +1,54 @@
+//
+// AppReducer.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+
+import ComposableArchitecture
+
+@Reducer
+struct AppReducer {
+
+ @ObservableState
+ enum State: Equatable {
+ case productList(ProductListFeature.State)
+
+ init() {
+ self = .productList(.init())
+ }
+ }
+
+ enum Action: ViewAction {
+ case view(View)
+ }
+
+ @CasePathable
+ enum View {
+ case productList(ProductListFeature.Action)
+ }
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .view(let ViewAction):
+ handleViewAction(&state, action: ViewAction)
+ }
+ }
+ .ifCaseLet(\.productList, action: \.view.productList) {
+ ProductListFeature()
+ }
+ }
+
+ func handleViewAction(
+ _ state: inout State,
+ action: View
+ ) -> Effect {
+ switch action {
+ case .productList:
+ return .none
+ }
+ }
+}
diff --git a/AppleStoreProductKiosk/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/AppleStoreProductKiosk/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
similarity index 100%
rename from AppleStoreProductKiosk/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
rename to AppleStoreProductKiosk/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
diff --git a/AppleStoreProductKiosk/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/AppleStoreProductKiosk/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
similarity index 100%
rename from AppleStoreProductKiosk/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
rename to AppleStoreProductKiosk/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
diff --git a/AppleStoreProductKiosk/Resources/Assets.xcassets/Contents.json b/AppleStoreProductKiosk/App/Resources/Assets.xcassets/Contents.json
similarity index 100%
rename from AppleStoreProductKiosk/Resources/Assets.xcassets/Contents.json
rename to AppleStoreProductKiosk/App/Resources/Assets.xcassets/Contents.json
diff --git a/AppleStoreProductKiosk/Resources/Info.plist b/AppleStoreProductKiosk/App/Resources/Info.plist
similarity index 100%
rename from AppleStoreProductKiosk/Resources/Info.plist
rename to AppleStoreProductKiosk/App/Resources/Info.plist
diff --git a/AppleStoreProductKiosk/App/View/AppView.swift b/AppleStoreProductKiosk/App/View/AppView.swift
new file mode 100644
index 0000000..f1f7fc6
--- /dev/null
+++ b/AppleStoreProductKiosk/App/View/AppView.swift
@@ -0,0 +1,23 @@
+//
+// AppView.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import SwiftUI
+
+import ComposableArchitecture
+
+struct AppView: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ switch store.state {
+ case .productList:
+ if let store = store.scope(state: \.productList, action: \.view.productList) {
+ ContentView(store: store)
+ }
+ }
+ }
+}
diff --git a/AppleStoreProductKiosk/Application/AppleStoreProductKioskApp.swift b/AppleStoreProductKiosk/Application/AppleStoreProductKioskApp.swift
deleted file mode 100644
index 815782d..0000000
--- a/AppleStoreProductKiosk/Application/AppleStoreProductKioskApp.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-// AppleStoreProductKioskApp.swift
-// AppleStoreProductKiosk
-//
-// Created by 홍석현 on 9/16/25.
-//
-
-import SwiftUI
-
-@main
-struct AppleStoreProductKioskApp: App {
- var body: some Scene {
- WindowGroup {
- ContentView()
- }
- }
-}
diff --git a/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift
new file mode 100644
index 0000000..f7da533
--- /dev/null
+++ b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift
@@ -0,0 +1,56 @@
+//
+// AppleStoreResponseDTO.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+
+struct AppleStoreResponseDTO: Decodable {
+ let code: Int
+ let message: String
+ let data: StoreDataDTO
+}
+
+struct StoreDataDTO: Decodable {
+ let store: StoreInfoDTO
+ let categories: [CategoryDTO]
+}
+
+struct StoreInfoDTO: Decodable {
+ let id: String
+ let region: String
+ let currency: String
+ let updatedAt: String
+}
+
+struct CategoryDTO: Decodable {
+ let category: String
+ let products: [ProductDTO]
+}
+
+struct ProductDTO: Decodable {
+ let id: String
+ let name: String?
+ let description: String?
+ let fromPrice: PriceDTO
+ let links: ProductLinksDTO?
+ let images: ProductImagesDTO
+}
+
+struct PriceDTO: Decodable {
+ let amount: Int
+ let currency: String
+ let formatted: String
+}
+
+struct ProductLinksDTO: Decodable {
+ let buy: String?
+ let imagePage: String?
+}
+
+struct ProductImagesDTO: Decodable {
+ let main: String
+ let thumbnails: [String]
+}
diff --git a/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift b/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift
new file mode 100644
index 0000000..782e12d
--- /dev/null
+++ b/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift
@@ -0,0 +1,34 @@
+//
+// Extension+StoreDataDTO.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+
+extension StoreDataDTO {
+ func toDomain() -> ProductCatalog {
+ let categories = categories.map { categoryDTO in
+ let products = categoryDTO.products.map { productDTO in
+ let productName = productDTO.name ?? productDTO.id.replacingOccurrences(of: "-", with: " ").capitalized
+ let productDescription = productDTO.description ?? "제품 설명이 없습니다."
+
+ return Product(
+ id: productDTO.id,
+ name: productName,
+ description: productDescription,
+ priceFormatted: productDTO.fromPrice.formatted,
+ mainImageURL: URL(string: productDTO.images.main)
+ )
+ }
+
+ return Category(
+ name: categoryDTO.category,
+ products: products
+ )
+ }
+
+ return ProductCatalog(categories: categories)
+ }
+}
diff --git a/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift
new file mode 100644
index 0000000..9f7d1ce
--- /dev/null
+++ b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift
@@ -0,0 +1,132 @@
+//
+// MockProductRepository.swift
+// AppleStoreProductKiosk
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Foundation
+
+class MockProductRepository: ProductInterface {
+ private let categoriesData: [Category]
+
+ init(categories: [Category]? = nil) {
+ if let categories {
+ self.categoriesData = categories
+ } else {
+ self.categoriesData = MockProductRepository.defaultCategories
+ }
+ }
+
+ func fetchProductCatalog() async throws -> ProductCatalog {
+ .init(categories: categoriesData)
+ }
+
+ func fetchProducts(for category: String) async throws -> [Product] {
+ let lowercased = category.lowercased()
+ return categoriesData.first { $0.name.lowercased() == lowercased }?.products ?? []
+ }
+}
+
+// MARK: - Default Mock Data
+extension MockProductRepository {
+ static var defaultCategories: [Category] {
+ [
+ Category(
+ name: "iPhone",
+ products: [
+ Product(
+ id: "iphone-15-pro",
+ name: "iPhone 15 Pro",
+ description: "Titanium. A17 Pro chip. Pro camera system.",
+ priceFormatted: "$999",
+ mainImageURL: URL(string: "https://example.com/images/iphone15pro.jpg")
+ ),
+ Product(
+ id: "iphone-15",
+ name: "iPhone 15",
+ description: "Dynamic Island. 48MP Main camera.",
+ priceFormatted: "$799",
+ mainImageURL: URL(string: "https://example.com/images/iphone15.jpg")
+ )
+ ]
+ ),
+ Category(
+ name: "Mac",
+ products: [
+ Product(
+ id: "macbook-air-m2",
+ name: "MacBook Air",
+ description: "Supercharged by M2.",
+ priceFormatted: "$1199",
+ mainImageURL: URL(string: "https://example.com/images/macbookairm2.jpg")
+ ),
+ Product(
+ id: "macbook-pro-m3",
+ name: "MacBook Pro",
+ description: "M3 power. Stunning Liquid Retina XDR display.",
+ priceFormatted: "$1999",
+ mainImageURL: URL(string: "https://example.com/images/macbookprom3.jpg")
+ )
+ ]
+ ),
+ Category(
+ name: "iPad",
+ products: [
+ Product(
+ id: "ipad-pro-m2",
+ name: "iPad Pro",
+ description: "M2 chip. ProMotion. Thunderbolt.",
+ priceFormatted: "$1099",
+ mainImageURL: URL(string: "https://example.com/images/ipadpro.jpg")
+ ),
+ Product(
+ id: "ipad-mini",
+ name: "iPad mini",
+ description: "Compact. Powerful. Pocketable.",
+ priceFormatted: "$499",
+ mainImageURL: URL(string: "https://example.com/images/ipadmini.jpg")
+ )
+ ]
+ ),
+ Category(
+ name: "Watch",
+ products: [
+ Product(
+ id: "apple-watch-series-9",
+ name: "Apple Watch Series 9",
+ description: "Powerful health features. Double Tap gesture.",
+ priceFormatted: "$399",
+ mainImageURL: URL(string: "https://example.com/images/watchs9.jpg")
+ ),
+ Product(
+ id: "apple-watch-ultra-2",
+ name: "Apple Watch Ultra 2",
+ description: "Adventure ready. Brightest display yet.",
+ priceFormatted: "$799",
+ mainImageURL: URL(string: "https://example.com/images/watchultra2.jpg")
+ )
+ ]
+ ),
+ Category(
+ name: "Accessories",
+ products: [
+ Product(
+ id: "airpods-pro-2",
+ name: "AirPods Pro (2nd generation)",
+ description: "H2 chip. Adaptive Audio.",
+ priceFormatted: "$249",
+ mainImageURL: URL(string: "https://example.com/images/airpodspro2.jpg")
+ ),
+ Product(
+ id: "magic-keyboard",
+ name: "Magic Keyboard",
+ description: "Comfortable typing. Touch ID option.",
+ priceFormatted: "$99",
+ mainImageURL: URL(string: "https://example.com/images/magickeyboard.jpg")
+ )
+ ]
+ )
+ ]
+ }
+}
diff --git a/AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift b/AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift
deleted file mode 100644
index 260d607..0000000
--- a/AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift
+++ /dev/null
@@ -1,7 +0,0 @@
-//
-// Untitled.swift
-// AppleStoreProductKiosk
-//
-// Created by 홍석현 on 9/16/25.
-//
-
diff --git a/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift
new file mode 100644
index 0000000..ce130bc
--- /dev/null
+++ b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift
@@ -0,0 +1,35 @@
+//
+// ProductRepositoryImpl.swift
+// AppleStoreProductKiosk
+//
+// Created by 홍석현 on 9/16/25.
+//
+
+import Combine
+import DiContainer
+
+
+class ProductRepositoryImpl: ProductInterface , ObservableObject {
+ private let provider = AsyncProvider(session: .shared)
+
+ func fetchProductCatalog() async throws -> ProductCatalog {
+ let response = try await provider.requestAsync(.getAllProducts, decodeTo: AppleStoreResponseDTO.self)
+ return response.data.toDomain()
+ }
+
+ func fetchProducts(for category: String) async throws -> [Product] {
+ let catalog = try await fetchProductCatalog()
+ return catalog.categories
+ .first { $0.name.lowercased() == category.lowercased() }?
+ .products ?? []
+ }
+}
+
+
+extension RegisterModule {
+ var productRepositoryImplModule: () -> Module {
+ makeDependencyImproved(ProductInterface.self) {
+ ProductRepositoryImpl()
+ }
+ }
+}
diff --git a/AppleStoreProductKiosk/Domain/Entities/Product.swift b/AppleStoreProductKiosk/Domain/Entities/Product.swift
index 260d607..8294fce 100644
--- a/AppleStoreProductKiosk/Domain/Entities/Product.swift
+++ b/AppleStoreProductKiosk/Domain/Entities/Product.swift
@@ -5,3 +5,23 @@
// Created by 홍석현 on 9/16/25.
//
+import Foundation
+
+struct ProductCatalog: Equatable {
+ let categories: [Category]
+
+}
+
+struct Category: Equatable {
+ public let name: String
+ public let products: [Product]
+}
+
+
+struct Product: Equatable {
+ public let id: String
+ public let name: String
+ public let description: String
+ public let priceFormatted: String
+ public let mainImageURL: URL?
+}
diff --git a/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift b/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift
new file mode 100644
index 0000000..2800b94
--- /dev/null
+++ b/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift
@@ -0,0 +1,13 @@
+//
+// ProductInterface.swift
+// AppleStoreProductKiosk
+//
+// Created by 홍석현 on 9/16/25.
+//
+
+import Foundation
+
+protocol ProductInterface {
+ func fetchProductCatalog() async throws -> ProductCatalog
+ func fetchProducts(for category: String) async throws -> [Product]
+}
diff --git a/AppleStoreProductKiosk/Domain/Interface/ProductRepositoryProtocol.swift b/AppleStoreProductKiosk/Domain/Interface/ProductRepositoryProtocol.swift
deleted file mode 100644
index 260d607..0000000
--- a/AppleStoreProductKiosk/Domain/Interface/ProductRepositoryProtocol.swift
+++ /dev/null
@@ -1,7 +0,0 @@
-//
-// Untitled.swift
-// AppleStoreProductKiosk
-//
-// Created by 홍석현 on 9/16/25.
-//
-
diff --git a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCase.swift b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCase.swift
deleted file mode 100644
index 260d607..0000000
--- a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCase.swift
+++ /dev/null
@@ -1,7 +0,0 @@
-//
-// Untitled.swift
-// AppleStoreProductKiosk
-//
-// Created by 홍석현 on 9/16/25.
-//
-
diff --git a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift
new file mode 100644
index 0000000..304518d
--- /dev/null
+++ b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift
@@ -0,0 +1,65 @@
+//
+// ProductUseCaseImpl.swift
+// AppleStoreProductKiosk
+//
+// Created by 홍석현 on 9/16/25.
+//
+
+import Foundation
+
+import ComposableArchitecture
+import DiContainer
+
+struct ProductUseCaseImpl: ProductInterface {
+ private let repository: ProductInterface
+
+ init(repository: ProductInterface) {
+ self.repository = repository
+ }
+
+ // MARK: - 카테고리들 전체 가지고오기
+ func fetchProductCatalog() async throws -> ProductCatalog {
+ return try await repository.fetchProductCatalog()
+ }
+
+ // MARK: -특정 카테고리 가져오기
+ func fetchProducts(for category: String) async throws -> [Product] {
+ return try await repository.fetchProducts(for: category)
+ }
+}
+
+extension DependencyContainer {
+ var productInterface: ProductInterface? {
+ resolve(ProductInterface.self)
+ }
+}
+
+extension ProductUseCaseImpl: DependencyKey {
+ static var liveValue: ProductInterface {
+ let repository = UnifiedDI.register(\.productInterface) {
+ ProductRepositoryImpl()
+ }
+ return ProductUseCaseImpl(repository: repository)
+ }
+ static var testValue: ProductInterface = MockProductRepository()
+}
+
+extension DependencyValues {
+ var productUseCase: ProductInterface {
+ get { self[ProductUseCaseImpl.self] }
+ set { self[ProductUseCaseImpl.self] = newValue }
+ }
+}
+
+extension RegisterModule {
+ var productUseCaseImplModule: () -> Module {
+ makeUseCaseWithRepository(
+ ProductInterface.self,
+ repositoryProtocol: ProductInterface.self,
+ repositoryFallback: MockProductRepository(),
+ factory: { repo in
+ ProductUseCaseImpl(repository: repo)
+ }
+ )
+ }
+}
diff --git a/AppleStoreProductKiosk/Features/ContentView.swift b/AppleStoreProductKiosk/Features/ContentView.swift
index bb3e5a3..36f7b68 100644
--- a/AppleStoreProductKiosk/Features/ContentView.swift
+++ b/AppleStoreProductKiosk/Features/ContentView.swift
@@ -6,8 +6,10 @@
//
import SwiftUI
+import ComposableArchitecture
struct ContentView: View {
+ @Perception.Bindable var store: StoreOf
var body: some View {
VStack {
Image(systemName: "globe")
@@ -16,9 +18,12 @@ struct ContentView: View {
Text("Hello, world!")
}
.padding()
+ .onAppear {
+ store.send(.view(.onAppear))
+ }
}
}
-#Preview {
- ContentView()
-}
+//#Preview {
+// ContentView()
+//}
diff --git a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift
index d0e565c..28d7d1a 100644
--- a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift
+++ b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift
@@ -5,26 +5,166 @@
// Created by 홍석현 on 9/16/25.
//
+import Foundation
import ComposableArchitecture
+import LogMacro
+
@Reducer
-public struct ProductListFeature {
-
+ struct ProductListFeature {
+ public init() {}
+
@ObservableState
- public struct State {
-
+ struct State: Equatable {
+
+ init() {}
+ var productCatalogModel : ProductCatalog? = nil
+ }
+
+ @CasePathable
+ enum Action: ViewAction {
+ case view(View)
+ case async(AsyncAction)
+ case inner(InnerAction)
+
}
-
- public enum Action {
+
+ //MARK: - ViewAction
+ @CasePathable
+ enum View {
case onTapAddProduct(id: String)
+ case onSelectCategory(String)
+ case onAppear
+ }
+
+
+
+ //MARK: - AsyncAction 비동기 처리 액션
+ @CasePathable
+ enum AsyncAction: Equatable {
+ case fetchProductCatalog
+ case fetchProducts(String)
+
}
-
- public var body: some Reducer {
+
+ //MARK: - 앱내에서 사용하는 액션
+ @CasePathable
+ enum InnerAction: Equatable {
+ case fetchProductCatalogResponse(Result)
+ case fetchProductsResponse(category: String, Result<[Product], DataError>)
+ }
+
+
+ private struct ProductListCancel: Hashable {}
+
+ @Dependency(\.productUseCase) var productUseCase
+ @Dependency(\.mainQueue) var mainQueue
+
+ var body: some ReducerOf {
Reduce { state, action in
switch action {
- case .onTapAddProduct(let _):
- return .none
+ case .view(let viewAction):
+ return handleViewAction(state: &state, action: viewAction)
+
+ case .async(let asyncAction):
+ return handleAsyncAction(state: &state, action: asyncAction)
+
+ case .inner(let innerAction):
+ return handleInnerAction(state: &state, action: innerAction)
+
}
}
}
+
+ private func handleViewAction(
+ state: inout State,
+ action: View
+ ) -> Effect {
+ switch action {
+ case .onTapAddProduct(let id):
+ return .none
+
+ case .onAppear:
+ return .run { send in
+ await send(.async(.fetchProductCatalog))
+ }
+ .debounce(id: ProductListCancel(), for: 0.3, scheduler: mainQueue)
+
+ case .onSelectCategory(let category):
+ return .run { send in
+ await send(.async(.fetchProducts(category)))
+ }
+
+ }
+ }
+
+ private func handleAsyncAction(
+ state: inout State,
+ action: AsyncAction
+ ) -> Effect {
+ switch action {
+ case .fetchProductCatalog:
+ return .run { send in
+ let productCatalogResult = await Result {
+ try await productUseCase.fetchProductCatalog()
+ }
+
+ switch productCatalogResult {
+ case .success(let productCatalogData):
+ await send(.inner(.fetchProductCatalogResponse(.success(productCatalogData))))
+
+ case .failure(let error):
+ await send(.inner(.fetchProductCatalogResponse(.failure(.decodingError(error)))))
+ }
+
+ }
+
+ case .fetchProducts(let category):
+ return .run { send in
+ let result = await Result {
+ try await productUseCase.fetchProducts(for: category)
+ }
+
+ switch result {
+ case .success(let products):
+ await send(.inner(.fetchProductsResponse(category: category, .success(products))))
+ case .failure(let error):
+ await send(.inner(.fetchProductsResponse(category: category, .failure(.decodingError(error)))))
+ }
+ }
+
+ }
+ }
+
+ private func handleInnerAction(
+ state: inout State,
+ action: InnerAction
+ ) -> Effect {
+ switch action {
+ case .fetchProductCatalogResponse(let result):
+ switch result {
+ case .success(let data):
+ state.productCatalogModel = data
+
+ #logDebug("데이터", data)
+
+ case .failure(let error):
+ #logNetwork("데이터 통신 실패", error.localizedDescription)
+ }
+ return .none
+
+ case .fetchProductsResponse(let category, let result):
+ switch result {
+ case .success(let products):
+ state.productCatalogModel = ProductCatalog(categories: [
+ Category(name: category, products: products)
+ ])
+ #logDebug("상품 로드 완료: \(category)", products)
+ case .failure(let error):
+ #logNetwork("상품 로드 실패: \(category)", error.localizedDescription)
+ }
+ return .none
+
+ }
+ }
}
diff --git a/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift b/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift
index 37f8ea5..ba93978 100644
--- a/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift
+++ b/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift
@@ -41,3 +41,17 @@ enum DataError: Error, LocalizedError, Sendable {
}
}
}
+
+
+extension DataError: Equatable {
+ static func == (lhs: DataError, rhs: DataError) -> Bool {
+ switch (lhs, rhs) {
+ case (.noData, .noData):
+ return true
+ case let (.customError(a), .customError(b)):
+ return a == b
+ default:
+ return false
+ }
+ }
+}
diff --git a/AppleStoreProductKioskTests/AppleStoreProductKioskTestPlan.xctestplan b/AppleStoreProductKioskTests/AppleStoreProductKioskTestPlan.xctestplan
new file mode 100644
index 0000000..ddd1c9b
--- /dev/null
+++ b/AppleStoreProductKioskTests/AppleStoreProductKioskTestPlan.xctestplan
@@ -0,0 +1,24 @@
+{
+ "configurations" : [
+ {
+ "id" : "EBCD31E0-26B2-40F7-A045-0A70416E5C44",
+ "name" : "Configuration 1",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "testTimeoutsEnabled" : true
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:AppleStoreProductKiosk.xcodeproj",
+ "identifier" : "C8F725EA2E790FA100C2A1DE",
+ "name" : "AppleStoreProductKioskTests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/AppleStoreProductKioskTests/AppleStoreProductKioskTests.swift b/AppleStoreProductKioskTests/AppleStoreProductKioskTests.swift
deleted file mode 100644
index 4a85617..0000000
--- a/AppleStoreProductKioskTests/AppleStoreProductKioskTests.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-// AppleStoreProductKioskTests.swift
-// AppleStoreProductKioskTests
-//
-// Created by 홍석현 on 9/16/25.
-//
-
-import Testing
-@testable import AppleStoreProductKiosk
-
-struct AppleStoreProductKioskTests {
-
- @Test func example() async throws {
- // Write your test here and use APIs like `#expect(...)` to check expected conditions.
- }
-
-}
diff --git a/AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift b/AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift
new file mode 100644
index 0000000..1bf5418
--- /dev/null
+++ b/AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift
@@ -0,0 +1,36 @@
+//
+// MockProductRepositoryTests.swift
+// AppleStoreProductKioskTests
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Testing
+@testable import AppleStoreProductKiosk
+
+@MainActor
+struct MockProductRepositoryTests {
+ private let repository = MockProductRepository()
+
+ @Test("카탈로그는 기본 카테고리를 포함한다", .tags(.catalog))
+ func fetchProductCatalog_hasDefaultCategories() async throws {
+ let catalog = try await repository.fetchProductCatalog()
+ #expect(!catalog.categories.isEmpty)
+ #expect(catalog.categories.count >= 3)
+ #expect(catalog.categories.contains { $0.name == "iPhone" })
+ }
+
+ @Test("특정 카테고리의 상품을 반환한다", .tags(.products))
+ func fetchProducts_returnsProductsForCategory() async throws {
+ let products = try await repository.fetchProducts(for: "iPhone")
+ #expect(!products.isEmpty)
+ #expect(products.contains { $0.name.contains("iPhone") })
+ }
+
+ @Test("카테고리 매칭은 대소문자를 구분하지 않는다", .tags(.caseInsensitive))
+ func fetchProducts_isCaseInsensitive() async throws {
+ let lower = try await repository.fetchProducts(for: "iphone")
+ let upper = try await repository.fetchProducts(for: "IPHONE")
+ #expect(lower.count == upper.count)
+ }
+}
diff --git a/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift b/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift
new file mode 100644
index 0000000..560c26b
--- /dev/null
+++ b/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift
@@ -0,0 +1,32 @@
+//
+// ProductUseCaseImplTests.swift
+// AppleStoreProductKioskTests
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Testing
+@testable import AppleStoreProductKiosk
+
+@MainActor
+struct ProductUseCaseImplTests {
+ @Test("유스케이스 카탈로그는 레포지토리와 동일하다", .tags(.catalog))
+ func useCase_fetchCatalog_matchesMockRepository() async throws {
+ let repo = MockProductRepository()
+ let useCase = ProductUseCaseImpl(repository: repo)
+
+ let expected = try await repo.fetchProductCatalog()
+ let actual = try await useCase.fetchProductCatalog()
+ #expect(actual == expected)
+ }
+
+
+ @Test("유스케이스로 카테고리 상품을 가져온다", .tags(.products))
+ func useCase_fetchProducts_returnsCorrectCount() async throws {
+ let repo = MockProductRepository()
+ let useCase = ProductUseCaseImpl(repository: repo)
+
+ let products = try await useCase.fetchProducts(for: "Accessories")
+ #expect(products.count == 2)
+ }
+}
diff --git a/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift
new file mode 100644
index 0000000..d42e4ba
--- /dev/null
+++ b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift
@@ -0,0 +1,88 @@
+//
+// ProductListFeatureTest.swift
+// AppleStoreProductKioskTests
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Testing
+import ComposableArchitecture
+@testable import AppleStoreProductKiosk
+
+@Suite("ProductListFeatureTest", .tags(.feature, .productList))
+@MainActor
+struct ProductListFeatureTest {
+
+ @Test("onAppear 시 카탈로그 로드 및 상태 업데이트", .tags(.load))
+ func loadCatalog_onAppear_updatesState() async throws {
+ let expectedCatalog = try await MockProductRepository().fetchProductCatalog()
+
+ let store = TestStore(initialState: ProductListFeature.State()) {
+ ProductListFeature()
+ } withDependencies: {
+ $0.productUseCase = ProductUseCaseImpl.testValue
+ $0.mainQueue = .immediate
+ }
+
+ await store.send(.view(.onAppear))
+ await store.receive(\.async.fetchProductCatalog)
+ await store.receive(\.inner.fetchProductCatalogResponse) {
+ $0.productCatalogModel = expectedCatalog
+ }
+ }
+
+ @Test("카탈로그 로드 실패 시 상태는 변경되지 않는다", .tags(.error))
+ func loadCatalog_failure_keepsStateNil() async throws {
+ struct ThrowingUseCase: ProductInterface {
+ func fetchProductCatalog() async throws -> ProductCatalog { throw DataError.customError("boom") }
+ func fetchProducts(for category: String) async throws -> [Product] { [] }
+ }
+
+ let store = TestStore(initialState: ProductListFeature.State()) {
+ ProductListFeature()
+ } withDependencies: {
+ $0.productUseCase = ThrowingUseCase()
+ $0.mainQueue = .immediate
+ }
+
+ await store.send(.view(.onAppear))
+ await store.receive(\.async.fetchProductCatalog)
+ await store.receive(\.inner.fetchProductCatalogResponse)
+
+ #expect(store.state.productCatalogModel == nil)
+ }
+
+ @Test("상품 추가 탭은 현재 상태를 변경하지 않는다", .tags(.action))
+ func onTapAddProduct_noStateChange() async throws {
+ let initial = ProductListFeature.State()
+ let store = TestStore(initialState: initial) {
+ ProductListFeature()
+ } withDependencies: {
+ $0.productUseCase = ProductUseCaseImpl.testValue
+ }
+
+ await store.send(.view(.onTapAddProduct(id: "some-id")))
+ #expect(store.state == initial)
+ }
+
+ @Test("카테고리 선택 시 상품 목록 로드", .tags(.load))
+ func selectCategory_loadsProducts() async throws {
+ let repo = MockProductRepository()
+ let expected = try await repo.fetchProducts(for: "iPhone")
+
+ let store = TestStore(initialState: ProductListFeature.State()) {
+ ProductListFeature()
+ } withDependencies: {
+ $0.productUseCase = ProductUseCaseImpl.testValue
+ }
+
+ let category = "iPhone"
+ await store.send(.view(.onSelectCategory(category)))
+ await store.receive(\.async.fetchProducts)
+ await store.receive(\.inner.fetchProductsResponse) { state in
+ state.productCatalogModel = ProductCatalog(categories: [
+ Category(name: category, products: expected)
+ ])
+ }
+ }
+}
diff --git a/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift b/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift
new file mode 100644
index 0000000..55adb37
--- /dev/null
+++ b/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift
@@ -0,0 +1,31 @@
+//
+// KioskProductServiceLiveTests.swift
+// AppleStoreProductKioskTests
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Testing
+@testable import AppleStoreProductKiosk
+
+@Suite("실 API 테스트", .tags(.liveAPI, .network, .integration))
+struct KioskProductServiceLiveTests {
+
+ @Test("실 API: /products 응답 디코딩 성공")
+ func live_fetch_products_decodes() async throws {
+ let provider = AsyncProvider.conservative()
+ let response = try await provider.requestAsync(.getAllProducts, decodeTo: AppleStoreResponseDTO.self)
+
+ #expect(!response.data.categories.isEmpty)
+ }
+
+ @Test("실 API: 레포지토리로 도메인 변환 성공")
+ func live_repository_fetchCatalog_returnsDomain() async throws {
+ let repo = ProductRepositoryImpl()
+ let catalog = try await repo.fetchProductCatalog()
+
+ #expect(!catalog.categories.isEmpty)
+ // 적어도 하나의 카테고리에 하나 이상의 상품이 있어야 함
+ #expect(catalog.categories.contains { !$0.products.isEmpty })
+ }
+}
diff --git a/AppleStoreProductKioskTests/TestingTags.swift b/AppleStoreProductKioskTests/TestingTags.swift
new file mode 100644
index 0000000..8aadc0d
--- /dev/null
+++ b/AppleStoreProductKioskTests/TestingTags.swift
@@ -0,0 +1,27 @@
+//
+// TestingTags.swift
+// AppleStoreProductKioskTests
+//
+// Created by Wonji Suh on 9/17/25.
+//
+
+import Testing
+
+extension Tag {
+ @Tag static var mock: Self
+ @Tag static var repository: Self
+ @Tag static var usecase: Self
+ @Tag static var feature: Self
+ @Tag static var productList: Self
+ @Tag static var load: Self
+ @Tag static var error: Self
+ @Tag static var action: Self
+ @Tag static var catalog: Self
+ @Tag static var products: Self
+ @Tag static var caseInsensitive: Self
+ @Tag static var example: Self
+ @Tag static var sanity: Self
+ @Tag static var liveAPI: Self
+ @Tag static var network: Self
+ @Tag static var integration: Self
+}