들어가며: 왜 토스뱅크 클론 프로젝트를 시작하게 되었나요?
안녕하세요! 오늘은 제가 개발한 토스뱅크 클론 프로젝트에 대한 이야기를 나눠볼게요.
실무 프로젝트에서 적용했던 클린 아키텍처, MVVM 패턴, 의존성 주입, 모듈화 등의 디자인 패턴과 아키텍처 원칙을 좀 더 체계적으로 적용해보고 싶었어요.
특히 복잡한 비즈니스 로직과 엄격한 사용자 경험을 요구하는 앱을 개발하면서 이러한 원칙들이 어떻게 실제 코드베이스에 적용될 수 있는지 탐구하고자 했죠.
토스뱅크는 복잡한 금융 기능을 직관적인 UI로 제공하는 대표적인 금융 앱으로,
이를 클론 코딩하는 과정에서 다음과 같은 목표를 설정했어요.
- 클린 아키텍처 원칙에 따른 계층 분리 구현
- 기능별 모듈화를 통한 확장성과 유지보수성 확보
- 의존성 주입을 통한 테스트 용이성 향상
- 비동기 상태 관리를 위한 효율적인 ViewModel 패턴 개발
- 재사용 가능한 디자인 시스템 구축
이 글에서는 토스뱅크 클론 프로젝트를 개발하면서 마주한 도전과 해결 과정, 그리고 그 과정에서 배운 교훈을 공유해볼게요.
아키텍처는 어떻게 설계했나요? 🏗️
클린 아키텍처 계층 구조는 어떻게 구성했나요?
토스뱅크 클론 프로젝트의 핵심은 클린 아키텍처를 기반으로 한 계층 구조예요.
각 계층은 명확한 책임을 가지며, 의존성은 항상 외부에서 내부로 향하도록 설계했답니다.
아키텍처 다이어그램
계층 구조 (Clean Architecture)
도메인 계층은 어떤 역할을 하나요? 🧩
도메인 계층은 프로젝트의 핵심이자 가장 안쪽에 위치한 계층으로, 비즈니스 규칙과 로직을 정의해요.
외부 프레임워크나 기술에 의존하지 않는 순수한 Swift 코드로 구성했답니다.
// 계좌 엔티티 예시
public struct Account: Identifiable, Equatable {
public let id: String
public let name: String
public let balance: Decimal
public let accountNumber: String
public let type: AccountType
public enum AccountType: String, Codable {
case checking
case savings
case investment
}
}
// 리포지토리 인터페이스 예시
public protocol AccountRepository {
func getAccounts() async throws -> [Account]
func getAccountDetails(id: String) async throws -> AccountDetail
func getTransactions(accountId: String, page: Int) async throws -> [Transaction]
}
// 유스케이스 예시
public final class GetAccountsUseCase {
private let repository: AccountRepository
public init(repository: AccountRepository) {
self.repository = repository
}
public func execute() async throws -> [Account] {
return try await repository.getAccounts()
}
}
데이터 계층은 무엇을 담당하나요? 📊
데이터 계층은 도메인 계층에 정의된 리포지토리 인터페이스를 구현하여 실제 데이터 소스와의 상호작용을 담당해요.
// DTO 예시
struct AccountDTO: Decodable {
let id: String
let name: String
let balance: String
let accountNumber: String
let type: String
}
// 리포지토리 구현 예시
final class AccountRepositoryImpl: AccountRepository {
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func getAccounts() async throws -> [Account] {
let accountDTOs = try await apiClient.send(AccountAPIRequests.getAccounts())
return accountDTOs.map { dto in
Account(
id: dto.id,
name: dto.name,
balance: Decimal(string: dto.balance) ?? 0,
accountNumber: dto.accountNumber,
type: Account.AccountType(rawValue: dto.type) ?? .checking
)
}
}
// 다른 메서드 구현...
}
프레젠테이션 계층은 어떻게 구성했나요? 🖼️
프레젠테이션 계층은 UI와 사용자 상호작용을 담당하며, MVVM 패턴과 SwiftUI를 주로 활용했어요.
// ViewModel 예시 (AsyncViewModel 패턴 적용)
final class AccountListViewModel: AsyncViewModel {
enum Input {
case loadAccounts
case refreshAccounts
case selectAccount(id: String)
}
enum Action {
case fetchAccounts
case updateAccountList
case navigateToDetail(id: String)
}
@Published var accounts: [Account] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let getAccountsUseCase: GetAccountsUseCase
var onAccountSelected: ((String) -> Void)?
init(getAccountsUseCase: GetAccountsUseCase) {
self.getAccountsUseCase = getAccountsUseCase
}
// AsyncViewModel 구현...
}
// View 예시
struct AccountListView: View {
@StateObject var viewModel: AccountListViewModel
var body: some View {
List {
ForEach(viewModel.accounts) { account in
AccountRow(account: account)
.onTapGesture {
viewModel.send(.selectAccount(id: account.id))
}
}
}
.onAppear {
viewModel.send(.loadAccounts)
}
.refreshable {
viewModel.send(.refreshAccounts)
}
}
}
기능별 모듈화는 어떻게 구현했나요? 📦
토스뱅크 클론 프로젝트는 기능별로 명확히 구분된 모듈 구조를 가져요. 각 모듈은 독립적으로 개발 및 테스트가 가능하도록 설계했답니다.
코어 모듈은 어떤 것들이 있나요?
- DomainModule 📋: 비즈니스 엔티티, 리포지토리 인터페이스, 유스케이스 등 핵심 비즈니스 로직
- DataModule 💾: 리포지토리 구현체, DTO, 데이터 변환 로직
- NetworkModule 🌐: 네트워크 통신 인프라
- AuthenticationModule 🔐: 인증 관련 기능
- SharedModule 🔄: 코디네이터, DIContainer 등 공통 기능
- DesignSystem 🎨: UI 컴포넌트 및 테마
기능 모듈은 어떻게 구성되어 있나요?
- Account 💰: 계좌 조회 및 관리 기능
- Auth 🔑: 로그인 및 보안 관련 기능
- Transfer 💸: 송금 및 이체 기능
- Settings ⚙️: 앱 설정 기능
각 기능 모듈은 자체적인 Coordinators, DIContainer, Presentation 레이어를 포함하고 있어요.
이러한 모듈화는 Tuist를 통해 구현했으며, 다음과 같은 이점을 제공했답니다:
- 병렬 개발 용이성 👥: 각 모듈을 독립적으로 개발할 수 있어요
- 빌드 성능 향상 ⚡: 필요한 모듈만 빌드하여 빌드 시간을 단축할 수 있죠
- 코드 재사용성 ♻️: 모듈 간 명확한 경계와 인터페이스로 재사용성이 향상돼요
- 테스트 용이성 🧪: 각 모듈을 독립적으로 테스트할 수 있어요
비동기 상태 관리는 어떻게 했나요? ⏱️
토스뱅크 클론 프로젝트에서 가장 고민했던 부분 중 하나는 비동기 작업(네트워크 요청, 데이터베이스 조회 등)과 UI 상태 관리를 효율적으로 처리하는 방법이었어요.
이를 위해 Swift Concurrency와 Combine을 결합한 AsyncViewModel 패턴을 개발했답니다.
https://jimmy-ios.tistory.com/57
[iOS] UIFusionKit을 활용해 SwiftUI와 UIKit 통합하기
UIFusionKit을 활용해 SwiftUI와 UIKit 통합하기: 비동기 상태 관리의 재발견 ✨들어가며: 두 세계 사이의 간극 🌉안녕하세요! 오늘은 UIFusionKit이라는 프레임워크를 개발하게 된 이야기를 나눠보려고
jimmy-ios.tistory.com
@MainActor
public protocol AsyncViewModel: ObservableObject {
associatedtype Input
associatedtype Action
/// 사용자 입력을 액션으로 변환
func transform(_ input: Input) async throws -> [Action]
/// 액션을 수행하여 상태 업데이트
func perform(_ action: Action) async throws
/// 에러 처리
func handleError(_ error: Error) async
/// 입력 전송 메서드 (기본 구현 제공)
func send(_ input: Input)
}
public extension AsyncViewModel {
func send(_ input: Input) {
Task { [weak self] in
guard let self = self else { return }
// Input을 Action으로 변환
let actions = await self.transform(input)
// 각 Action 순차적으로 처리
for action in actions {
do {
try await self.perform(action)
} catch {
await self.handleError(error)
}
}
}
}
}
AsyncViewModel 패턴의 핵심은 무엇인가요? 🧠
이 패턴의 핵심은 다음과 같아요:
- 명확한 입력-출력 흐름 🔄: 사용자 입력(Input) → 변환(transform) → 액션(Action) → 실행(perform) → 상태 업데이트
- 선언적 UI 연동 📲: @Published 속성을 통해 SwiftUI와 자연스럽게 연동돼요
- 비동기 작업 분리 ⚙️: transform과 perform 메서드를 비동기로 정의하여 복잡한 비동기 로직을 처리해요
- 에러 처리 단일화 🚨: 모든 에러는 handleError 메서드로 집중되어 일관된 에러 처리가 가능해요
실제로 어떻게 적용했나요?
실제 적용 예시를 살펴볼게요:
final class TransferViewModel: AsyncViewModel {
// 입력 정의
enum Input {
case loadAccounts
case selectSourceAccount(id: String)
case selectDestinationAccount(id: String)
case setAmount(Decimal)
case transfer
}
// 액션 정의
enum Action {
case fetchAccounts
case updateSourceAccount(id: String)
case updateDestinationAccount(id: String)
case updateAmount(Decimal)
case executeTransfer
case handleTransferSuccess
}
// 상태 (@Published 속성)
@Published var accounts: [Account] = []
@Published var sourceAccount: Account?
@Published var destinationAccount: Account?
@Published var amount: Decimal = 0
@Published var isLoading = false
@Published var errorMessage: String?
@Published var transferCompleted = false
// 의존성
private let getAccountsUseCase: GetAccountsUseCase
private let transferUseCase: TransferUseCase
init(getAccountsUseCase: GetAccountsUseCase, transferUseCase: TransferUseCase) {
self.getAccountsUseCase = getAccountsUseCase
self.transferUseCase = transferUseCase
}
// 입력을 액션으로 변환
func transform(_ input: Input) async throws -> [Action] {
switch input {
case .loadAccounts:
return [.fetchAccounts]
case .selectSourceAccount(let id):
return [.updateSourceAccount(id: id)]
case .selectDestinationAccount(let id):
return [.updateDestinationAccount(id: id)]
case .setAmount(let amount):
return [.updateAmount(amount)]
case .transfer:
return [.executeTransfer, .handleTransferSuccess]
}
}
// 액션 수행
func perform(_ action: Action) async throws {
switch action {
case .fetchAccounts:
isLoading = true
defer { isLoading = false }
accounts = try await getAccountsUseCase.execute()
case .updateSourceAccount(let id):
sourceAccount = accounts.first { $0.id == id }
case .updateDestinationAccount(let id):
destinationAccount = accounts.first { $0.id == id }
case .updateAmount(let amount):
self.amount = amount
case .executeTransfer:
guard let source = sourceAccount, let destination = destinationAccount else {
throw TransferError.invalidAccount
}
isLoading = true
defer { isLoading = false }
try await transferUseCase.execute(
from: source.id,
to: destination.id,
amount: amount
)
case .handleTransferSuccess:
transferCompleted = true
}
}
// 에러 처리
func handleError(_ error: Error) async {
isLoading = false
if let transferError = error as? TransferError {
switch transferError {
case .insufficientFunds:
errorMessage = "잔액이 부족합니다."
case .invalidAccount:
errorMessage = "유효하지 않은 계좌입니다."
case .exceedsLimit:
errorMessage = "이체 한도를 초과했습니다."
}
} else {
errorMessage = "이체 중 오류가 발생했습니다. 다시 시도해주세요."
}
}
}
AsyncViewModel 패턴의 장점은 무엇인가요? 💡
AsyncViewModel 패턴은 다음과 같은 장점을 제공했어요:
- 관심사 분리 🧩: 입력 처리, 비즈니스 로직, 상태 관리, 에러 처리가 명확히 분리돼요
- 테스트 용이성 🧪: 각 단계(transform, perform, handleError)를 독립적으로 테스트할 수 있어요
- 비동기 코드 단순화 📝: Swift Concurrency를 활용하여 복잡한 비동기 코드를 단순화했죠
- 상태 일관성 🔄: 상태 업데이트가 중앙화되어 일관성 있는 UI 상태 관리가 가능해요
의존성 주입과 테스트는 어떻게 구현했나요? 🧪
토스뱅크 클론 프로젝트에서는 의존성 주입을 통해 코드의 결합도를 낮추고 테스트 용이성을 향상시켰어요.
각 모듈과 기능마다 DIContainer를 구현하여 의존성을 체계적으로 관리했답니다.
DIContainer 패턴은 어떻게 작동하나요? 🧩
DIContainer는 각 모듈이나 기능에 필요한 모든 의존성을 생성하고 제공하는 역할을 해요. 의존성 주입의 중심점으로 작동하며, 객체 그래프를 구성하죠.
// DIContainer 프로토콜 정의
public protocol AccountDIContainerProtocol {
func makeAccountListViewModel() -> AccountListViewModel
func makeAccountDetailViewModel(accountId: String) -> AccountDetailViewModel
}
// 실제 구현체
public final class AccountDIContainer: AccountDIContainerProtocol {
private let environment: AppEnvironment
private let networkService: NetworkServiceProtocol
public init(environment: AppEnvironment, networkService: NetworkServiceProtocol) {
self.environment = environment
self.networkService = networkService
}
// API 클라이언트 생성
private func createAPIClient() -> APIClient {
return NetworkAPIClient(networkService: networkService)
}
// 리포지토리 생성
private func createAccountRepository() -> AccountRepositoryProtocol {
return AccountRepositoryImpl(apiClient: createAPIClient())
}
// 유스케이스 생성
private func createGetAccountsUseCase() -> GetAccountsUseCase {
return GetAccountsUseCase(repository: createAccountRepository())
}
// ViewModel 팩토리 메서드
public func makeAccountListViewModel() -> AccountListViewModel {
return AccountListViewModel(
getAccountsUseCase: createGetAccountsUseCase()
)
}
public func makeAccountDetailViewModel(accountId: String) -> AccountDetailViewModel {
return AccountDetailViewModel(
accountId: accountId,
getAccountDetailUseCase: GetAccountDetailUseCase(
repository: createAccountRepository()
),
getTransactionsUseCase: GetTransactionsUseCase(
repository: createAccountRepository()
)
)
}
}
테스트 용이성은 어떻게 향상시켰나요? 📈
의존성 주입을 통해 다음과 같은 방식으로 테스트 용이성을 크게 향상시켰어요:
1. Mock 객체 활용은 어떻게 하나요? 🎭
테스트에서는 실제 구현체 대신 Mock 구현체를 주입하여 특정 동작을 시뮬레이션할 수 있어요:
// Mock 리포지토리 예시
class MockAccountRepository: AccountRepositoryProtocol {
var getAccountsResult: Result<[Account], Error> = .success([])
var getAccountDetailsResult: Result<AccountDetail, Error> = .success(AccountDetail.mock)
func getAccounts() async throws -> [Account] {
return try getAccountsResult.get()
}
func getAccountDetails(id: String) async throws -> AccountDetail {
return try getAccountDetailsResult.get()
}
}
// ViewModel 테스트 예시
func testLoadAccounts_Success() async {
// Given: 성공적인 결과를 반환하는 Mock 리포지토리
let mockRepository = MockAccountRepository()
mockRepository.getAccountsResult = .success([
Account.mock(id: "1", name: "계좌 1"),
Account.mock(id: "2", name: "계좌 2")
])
// 테스트할 ViewModel 생성 (Mock 의존성 주입)
let viewModel = AccountListViewModel(
getAccountsUseCase: GetAccountsUseCase(repository: mockRepository)
)
// When: 계좌 목록 로드
await viewModel.send(.loadAccounts)
// Then: 계좌 목록이 정상적으로 로드됨
XCTAssertEqual(viewModel.accounts.count, 2)
XCTAssertEqual(viewModel.accounts[0].name, "계좌 1")
XCTAssertEqual(viewModel.accounts[1].name, "계좌 2")
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.errorMessage)
}
2. 테스트 DIContainer는 어떻게 활용하나요? 🧪
테스트용 DIContainer를 구현하여 테스트 환경 설정을 간소화할 수 있어요:
// 테스트용 DIContainer
class TestAccountDIContainer: AccountDIContainerProtocol {
let mockRepository = MockAccountRepository()
func makeAccountListViewModel() -> AccountListViewModel {
return AccountListViewModel(
getAccountsUseCase: GetAccountsUseCase(repository: mockRepository)
)
}
func makeAccountDetailViewModel(accountId: String) -> AccountDetailViewModel {
return AccountDetailViewModel(
accountId: accountId,
getAccountDetailUseCase: GetAccountDetailUseCase(repository: mockRepository),
getTransactionsUseCase: GetTransactionsUseCase(repository: mockRepository)
)
}
}
3. 계층별 테스트는 어떻게 진행하나요? 🔍
각 계층(도메인, 데이터, 프레젠테이션)을 독립적으로 테스트할 수 있어요:
// 유스케이스 테스트
func testGetAccountsUseCase() async throws {
// Given: Mock 리포지토리와 유스케이스
let mockRepository = MockAccountRepository()
let useCase = GetAccountsUseCase(repository: mockRepository)
// Mock 데이터 설정
let mockAccounts = [
Account.mock(id: "1"),
Account.mock(id: "2")
]
mockRepository.getAccountsResult = .success(mockAccounts)
// When: 유스케이스 실행
let result = try await useCase.execute()
// Then: 결과 검증
XCTAssertEqual(result, mockAccounts)
}
의존성 주입의 주요 이점은 무엇인가요? 💪
의존성 주입을 통해 얻은 주요 이점은 다음과 같아요:
- 단위 테스트 커버리지 향상 📊: 복잡한 외부 의존성 없이 격리된 테스트 작성이 가능해요
- 통합 테스트 단순화 🔄: 실제 서비스와 Mock 서비스를 쉽게 전환하며 테스트 환경을 구성할 수 있어요
- 테스트 실행 속도 향상 ⚡: 네트워크나 데이터베이스 같은 느린 외부 의존성을 Mock으로 대체하여 빠른 테스트 실행이 가능해요
디자인 시스템은 어떻게 구축했나요? 🎨
토스뱅크 클론 프로젝트에서는 일관된 디자인 구현과 개발 효율성을 위해 체계적인 디자인 시스템을 구축했어요.
디자인 시스템의 구조는 어떻게 되나요? 🧩
디자인 시스템은 다음과 같은 요소로 구성되었어요:
1. 색상 시스템은 어떻게 구성했나요? 🌈
의미에 따라 분류된 색상 팔레트를 만들었어요:
enum Colors {
// 배경 색상
static let backgroundPrimary = Color("BackgroundPrimary")
static let backgroundSecondary = Color("BackgroundSecondary")
static let backgroundCard = Color("BackgroundCard")
static let backgroundInverse = Color("BackgroundInverse")
// 텍스트 색상
static let textPrimary = Color("TextPrimary")
static let textSecondary = Color("TextSecondary")
static let textTertiary = Color("TextTertiary")
static let textInverse = Color("TextInverse")
// 브랜드 색상
static let brandPrimary = Color("BrandPrimary")
static let brandSecondary = Color("BrandSecondary")
static let brandAccent = Color("BrandAccent")
// 상태 색상
static let stateError = Color("StateError")
static let stateSuccess = Color("StateSuccess")
static let stateWarning = Color("StateWarning")
}
2. 타이포그래피는 어떻게 정의했나요? 📝
일관된 텍스트 스타일을 다음과 같이 정의했어요:
struct Typography {
// 헤딩
static let heading1 = Font.system(size: 28, weight: .bold)
static let heading2 = Font.system(size: 24, weight: .bold)
static let heading3 = Font.system(size: 20, weight: .bold)
// 본문
static let bodyLarge = Font.system(size: 18)
static let bodyMedium = Font.system(size: 16)
static let bodySmall = Font.system(size: 14)
// 라벨
static let label = Font.system(size: 12, weight: .medium)
static let caption = Font.system(size: 10)
}
3. 컴포넌트 라이브러리는 어떻게 구현했나요? 🧰
재사용 가능한 UI 요소들을 다음과 같이 만들었어요:
// 버튼 컴포넌트
struct PrimaryButton: View {
let title: String
let action: () -> Void
let isEnabled: Bool
init(title: String, isEnabled: Bool = true, action: @escaping () -> Void) {
self.title = title
self.isEnabled = isEnabled
self.action = action
}
var body: some View {
Button(action: action) {
Text(title)
.font(Typography.bodyMedium)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.background(isEnabled ? Colors.brandPrimary : Colors.brandPrimary.opacity(0.4))
.foregroundColor(Colors.textInverse)
.cornerRadius(12)
.disabled(!isEnabled)
}
}
// 카드 컴포넌트
struct CardView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.padding(16)
.background(Colors.backgroundCard)
.cornerRadius(16)
.shadow(color: Colors.backgroundInverse.opacity(0.05), radius: 8, x: 0, y: 2)
}
}
4. 스페이싱 시스템은 어떻게 구현했나요? 📏
일관된 여백과 간격을 위해 다음과 같은 시스템을 만들었어요:
enum Spacing {
static let extraSmall: CGFloat = 4
static let small: CGFloat = 8
static let medium: CGFloat = 16
static let large: CGFloat = 24
static let extraLarge: CGFloat = 32
}
디자인 시스템은 실제로 어떻게 적용했나요? 🖼️
디자인 시스템을 실제 화면에 적용한 예시를 살펴볼게요:
struct AccountDetailView: View {
@StateObject var viewModel: AccountDetailViewModel
var body: some View {
ScrollView {
VStack(spacing: Spacing.medium) {
// 계좌 요약 카드
CardView {
VStack(alignment: .leading, spacing: Spacing.small) {
Text(viewModel.account?.name ?? "")
.font(Typography.heading3)
.foregroundColor(Colors.textPrimary)
Text(viewModel.account?.accountNumber ?? "")
.font(Typography.bodySmall)
.foregroundColor(Colors.textSecondary)
Spacer().frame(height: Spacing.medium)
Text(viewModel.formattedBalance)
.font(Typography.heading2)
.foregroundColor(Colors.textPrimary)
}
}
// 거래 내역 섹션
VStack(alignment: .leading, spacing: Spacing.small) {
Text("거래 내역")
.font(Typography.heading3)
.foregroundColor(Colors.textPrimary)
ForEach(viewModel.transactions) { transaction in
TransactionRow(transaction: transaction)
}
}
Spacer()
}
.padding(Spacing.medium)
}
.background(Colors.backgroundPrimary)
.onAppear {
viewModel.send(.loadAccountDetail)
}
}
}
struct TransactionRow: View {
let transaction: Transaction
var body: some View {
HStack {
VStack(alignment: .leading, spacing: Spacing.extraSmall) {
Text(transaction.description)
.font(Typography.bodyMedium)
.foregroundColor(Colors.textPrimary)
Text(transaction.formattedDate)
.font(Typography.caption)
.foregroundColor(Colors.textTertiary)
}
Spacer()
Text(transaction.formattedAmount)
.font(Typography.bodyMedium)
.foregroundColor(transaction.type == .credit ? Colors.stateSuccess : Colors.textPrimary)
}
.padding(Spacing.small)
.background(Colors.backgroundSecondary)
.cornerRadius(8)
}
}
디자인 시스템의 이점은 무엇인가요? 💡
디자인 시스템 구축을 통해 얻은 주요 이점은 다음과 같아요:
- 일관된 사용자 경험 🌟: 모든 화면에서 동일한 디자인 언어를 사용할 수 있어요
- 개발 속도 향상 ⚡: 표준화된 컴포넌트 재사용으로 UI 개발 시간이 단축됐어요
- 유지보수 용이성 🔧: 디자인 변경 시 중앙에서 관리되는 스타일만 수정하면 전체 앱에 반영돼요
- 디자이너-개발자 협업 향상 👥: 명확한 디자인 시스템 문서를 통한 의사소통이 개선됐어요
어떤 핵심 문제들을 해결했나요? 🚀
토스뱅크 클론 프로젝트 개발 중 마주한 주요 도전과 해결 방법을 공유할게요.
1. 계층 간 의존성 관리의 복잡성은 어떻게 해결했나요? 🧩
문제 🤔: 클린 아키텍처에서 계층 간 올바른 의존성 방향을 유지하면서도 실제 코드에서 의존성을 효율적으로 관리하는 것이 어려웠어요.
해결 방법 💡: 의존성 역전 원칙(DIP)과 DIContainer 패턴을 활용했어요.
- 도메인 계층에서 인터페이스(프로토콜)를 정의하고 데이터 계층에서 구현했어요
- 각 모듈에 DIContainer를 두어 객체 생성 및 의존성 주입 책임을 부여했죠
- 의존성 그래프 가시화 및 문서화로 의존성 방향을 명확하게 했어요
코드 예시:
// 도메인 계층 - 인터페이스 정의
public protocol AuthenticationService {
func login(username: String, password: String) async throws -> AuthToken
func logout() async throws
var isAuthenticated: Bool { get }
}
// 데이터 계층 - 구현체
public class AuthenticationServiceImpl: AuthenticationService {
private let apiClient: APIClient
private let secureStorage: SecureStorage
init(apiClient: APIClient, secureStorage: SecureStorage) {
self.apiClient = apiClient
self.secureStorage = secureStorage
}
// 인터페이스 구현...
}
// DIContainer를 통한 의존성 관리
public class AuthDIContainer {
private let networkService: NetworkServiceProtocol
private let secureStorage: SecureStorage
// 의존성 생성 및 주입
private func createAuthenticationService() -> AuthenticationService {
return AuthenticationServiceImpl(
apiClient: NetworkAPIClient(networkService: networkService),
secureStorage: secureStorage
)
}
// ViewModel 팩토리
func makeLoginViewModel() -> LoginViewModel {
return LoginViewModel(
authenticationService: createAuthenticationService()
)
}
}
2. 비동기 작업과 상태 관리의 복잡성은 어떻게 다루었나요? ⏱️
문제 🤔: 비동기 네트워크 요청과 UI 상태 업데이트 간의 조화를 이루기 어려웠고, 특히 여러 비동기 작업이 연쇄적으로 발생하는 경우 코드 복잡성이 증가했어요.
해결 방법 💡: AsyncViewModel 패턴 개발 및 적용했어요.
- 비동기 작업을 선언적으로 처리하는 프로토콜을 정의했어요
- Swift Concurrency(async/await)와 Combine(@Published)의 장점을 결합했죠
- 입력(Input) → 변환(transform) → 액션(Action) → 상태 업데이트(perform)의 명확한 흐름을 구성했어요
성과 🌟:
- 비동기 코드의 가독성과 유지보수성이 향상됐어요
- 복잡한 상태 전환을 일관된 방식으로 처리할 수 있게 됐죠
- 에러 처리 단일화로 버그가 감소했어요
3. 화면 간 데이터 전달 및 네비게이션 복잡성은 어떻게 해결했나요? 🧭
문제 🤔: 여러 화면을 오가며 데이터를 전달하고 적절한 네비게이션 계층을 유지하는 것이 복잡했어요.
해결 방법 💡: 코디네이터 패턴 도입 및 확장했어요.
protocol Coordinator: AnyObject {
var navigationController: UINavigationController { get }
var childCoordinators: [Coordinator] { get set }
func start()
}
class AccountCoordinator: Coordinator {
let navigationController: UINavigationController
var childCoordinators: [Coordinator] = []
private let diContainer: AccountDIContainerProtocol
init(navigationController: UINavigationController, diContainer: AccountDIContainerProtocol) {
self.navigationController = navigationController
self.diContainer = diContainer
}
func start() {
showAccountList()
}
private func showAccountList() {
let viewModel = diContainer.makeAccountListViewModel()
viewModel.onAccountSelected = { [weak self] accountId in
self?.showAccountDetail(accountId: accountId)
}
let accountListVC = AccountListViewController(viewModel: viewModel)
navigationController.pushViewController(accountListVC, animated: true)
}
private func showAccountDetail(accountId: String) {
let viewModel = diContainer.makeAccountDetailViewModel(accountId: accountId)
viewModel.onTransferTapped = { [weak self] account in
self?.showTransfer(sourceAccount: account)
}
let accountDetailVC = AccountDetailViewController(viewModel: viewModel)
navigationController.pushViewController(accountDetailVC, animated: true)
}
private func showTransfer(sourceAccount: Account) {
let transferCoordinator = TransferCoordinator(
navigationController: navigationController,
diContainer: TransferDIContainer(),
sourceAccount: sourceAccount
)
childCoordinators.append(transferCoordinator)
transferCoordinator.start()
}
}
성과 🌟:
- 화면 전환 로직을 ViewModel에서 분리하여 단일 책임 원칙을 준수할 수 있었어요
- 코디네이터 간 계층 구조로 명확한 네비게이션 흐름을 설정할 수 있었죠
- 화면 간 데이터 전달이 단순화됐어요
4. UI와 비즈니스 로직 간의 균형은 어떻게 맞췄나요? 🎨
문제 🤔: 화려한 토스뱅크 UI를 구현하면서도 코드의 구조와 아키텍처 원칙을 유지하는 것이 도전적이었어요.
해결 방법 💡: 명확한 아키텍처 가이드라인 수립 및 디자인 시스템 도입했어요.
- View와 ViewModel 간의 역할을 명확히 구분했어요
- 재사용 가능한 UI 컴포넌트 라이브러리를 구축했죠
- 디자인 토큰(색상, 타이포그래피, 간격 등)을 중앙화했어요
성과 🌟:
- 화려한 UI와 깔끔한 코드 구조가 공존할 수 있게 됐어요
- UI 수정 시 코드 변경을 최소화할 수 있었죠
- 일관된 디자인 언어로 사용자 경험이 향상됐어요
마치며 🎁
토스뱅크 클론 프로젝트는 단순한 클론 코딩을 넘어, 실제 기업 수준의 앱 개발에서 필요한 아키텍처와 디자인 패턴을 체계적으로 적용해보는 소중한 경험이었어요.
특히 클린 아키텍처, 모듈화, 비동기 상태 관리, 의존성 주입 같은 주요 원칙들을 실제 코드로 구현해보며 이론과 실제의 차이를 배울 수 있었답니다.
이 프로젝트에서 개발한 패턴과 아키텍처는 복잡한 금융 앱뿐만 아니라 다양한 도메인의 앱 개발에도 적용할 수 있는 견고한 기반이 될 거예요.
특히 AsyncViewModel 패턴과 같은 혁신적인 접근법은 Swift Concurrency 시대의 상태 관리에 새로운 시각을 제공한다고 생각해요.
향후에는 이 프로젝트를 기반으로 실제 업무 환경에서 더 효율적이고 유지보수하기 쉬운 코드베이스를 구축하는 데 도움이 되길 기대해봅니다! 😊