본문 바로가기
개발 이야기

[iOS] 클린 아키텍처로 완성한 토스뱅크 클론 개발 여정

by Jimmy_iOS 2025. 5. 5.

들어가며: 왜 토스뱅크 클론 프로젝트를 시작하게 되었나요? 

안녕하세요! 오늘은 제가 개발한 토스뱅크 클론 프로젝트에 대한 이야기를 나눠볼게요.

실무 프로젝트에서 적용했던 클린 아키텍처, MVVM 패턴, 의존성 주입, 모듈화 등의 디자인 패턴과 아키텍처 원칙을 좀 더 체계적으로 적용해보고 싶었어요.


특히 복잡한 비즈니스 로직과 엄격한 사용자 경험을 요구하는 앱을 개발하면서 이러한 원칙들이 어떻게 실제 코드베이스에 적용될 수 있는지 탐구하고자 했죠.

 

토스뱅크는 복잡한 금융 기능을 직관적인 UI로 제공하는 대표적인 금융 앱으로,

이를 클론 코딩하는 과정에서 다음과 같은 목표를 설정했어요.

  1. 클린 아키텍처 원칙에 따른 계층 분리 구현
  2. 기능별 모듈화를 통한 확장성과 유지보수성 확보
  3. 의존성 주입을 통한 테스트 용이성 향상
  4. 비동기 상태 관리를 위한 효율적인 ViewModel 패턴 개발
  5. 재사용 가능한 디자인 시스템 구축

이 글에서는 토스뱅크 클론 프로젝트를 개발하면서 마주한 도전과 해결 과정, 그리고 그 과정에서 배운 교훈을 공유해볼게요.


아키텍처는 어떻게 설계했나요? 🏗️

클린 아키텍처 계층 구조는 어떻게 구성했나요?

토스뱅크 클론 프로젝트의 핵심은 클린 아키텍처를 기반으로 한 계층 구조예요.
각 계층은 명확한 책임을 가지며, 의존성은 항상 외부에서 내부로 향하도록 설계했답니다.

아키텍처 다이어그램

 

계층 구조 (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를 통해 구현했으며, 다음과 같은 이점을 제공했답니다:

  1. 병렬 개발 용이성 👥: 각 모듈을 독립적으로 개발할 수 있어요
  2. 빌드 성능 향상 ⚡: 필요한 모듈만 빌드하여 빌드 시간을 단축할 수 있죠
  3. 코드 재사용성 ♻️: 모듈 간 명확한 경계와 인터페이스로 재사용성이 향상돼요
  4. 테스트 용이성 🧪: 각 모듈을 독립적으로 테스트할 수 있어요

비동기 상태 관리는 어떻게 했나요? ⏱️

토스뱅크 클론 프로젝트에서 가장 고민했던 부분 중 하나는 비동기 작업(네트워크 요청, 데이터베이스 조회 등)과 UI 상태 관리를 효율적으로 처리하는 방법이었어요.
이를 위해 Swift ConcurrencyCombine을 결합한 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 패턴의 핵심은 무엇인가요? 🧠

이 패턴의 핵심은 다음과 같아요:

  1. 명확한 입력-출력 흐름 🔄: 사용자 입력(Input) → 변환(transform) → 액션(Action) → 실행(perform) → 상태 업데이트
  2. 선언적 UI 연동 📲: @Published 속성을 통해 SwiftUI와 자연스럽게 연동돼요
  3. 비동기 작업 분리 ⚙️: transform과 perform 메서드를 비동기로 정의하여 복잡한 비동기 로직을 처리해요
  4. 에러 처리 단일화 🚨: 모든 에러는 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 패턴은 다음과 같은 장점을 제공했어요:

  1. 관심사 분리 🧩: 입력 처리, 비즈니스 로직, 상태 관리, 에러 처리가 명확히 분리돼요
  2. 테스트 용이성 🧪: 각 단계(transform, perform, handleError)를 독립적으로 테스트할 수 있어요
  3. 비동기 코드 단순화 📝: Swift Concurrency를 활용하여 복잡한 비동기 코드를 단순화했죠
  4. 상태 일관성 🔄: 상태 업데이트가 중앙화되어 일관성 있는 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)
    }
}

디자인 시스템의 이점은 무엇인가요? 💡

디자인 시스템 구축을 통해 얻은 주요 이점은 다음과 같아요:

  1. 일관된 사용자 경험 🌟: 모든 화면에서 동일한 디자인 언어를 사용할 수 있어요
  2. 개발 속도 향상 ⚡: 표준화된 컴포넌트 재사용으로 UI 개발 시간이 단축됐어요
  3. 유지보수 용이성 🔧: 디자인 변경 시 중앙에서 관리되는 스타일만 수정하면 전체 앱에 반영돼요
  4. 디자이너-개발자 협업 향상 👥: 명확한 디자인 시스템 문서를 통한 의사소통이 개선됐어요

어떤 핵심 문제들을 해결했나요? 🚀

토스뱅크 클론 프로젝트 개발 중 마주한 주요 도전과 해결 방법을 공유할게요.

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 시대의 상태 관리에 새로운 시각을 제공한다고 생각해요.

향후에는 이 프로젝트를 기반으로 실제 업무 환경에서 더 효율적이고 유지보수하기 쉬운 코드베이스를 구축하는 데 도움이 되길 기대해봅니다! 😊