본문 바로가기
한눈가계부/데이터 모델

안녕! '신(God) 객체' 👋 더 똑똑한 ViewModel 아키텍처로 가자

by 밤새는 탐험가89 2025. 8. 27.
728x90
SMALL

 모바일 앱을 개발하면서 한 번쯤은 '신(God) 객체'를 만들어본 경험이 있을 겁니다.

하나의 클래스가 너무 많은 역할을 떠맡는 것을 말하죠. 처음에는 편리하지만, 앱의 규모가 커질수록 유지보수와 확장을 어렵게 만드는 주범이 됩니다.

 

 오늘 우리는 하나의 거대한 ViewModel이 모든 것을 처리하던 구조를, 역할에 맞게 분리하여 더 효율적이고 안정적인 MVVM 아키텍처를 구축하는 방법을 이야기해보고자 합니다.


우리의 문제: 모든 것을 다 아는 '전능한 ViewModel'

 기존의 TransactionViewModel은 앱의 모든 거래 내역(transactions)을 관리하고, 데이터를 저장하고 읽고(CRUD), 특정 날짜의 데이터를 계산하고, 차트 데이터를 가공하는 등 너무 많은 역할을 혼자서 담당했습니다.

 

 마치 하나의 도구가 망치, 드라이버, 톱, 칼 등 모든 기능을 다 갖춘 '스위스 아미 나이프'처럼 말이죠. 처음에는 유용하지만, 무거워서 다루기 어렵고 한 기능이라도 고장 나면 전체를 수리해야 하는 단점이 있습니다.


우리의 해결책: '중앙 관리자'와 '각 분야 전문가'로 나누기

우리는 이 문제를 해결하기 위해 ViewModel의 책임을 명확히 분리하기로 결정했습니다.

  1. 중앙 데이터 관리자: 앱의 모든 데이터에 대한 '단일 진실 공급원(Single Source of Truth)' 역할을 합니다.
  2. 각 화면별 전문가: 특정 화면의 UI 요구사항에 맞춰 데이터를 가공하고, 사용자 입력을 처리합니다.

이러한 역할 분리를 통해 탄생한 것이 바로 TransactionManager와 각 화면별 ViewModel입니다.


새로운 플레이어들을 소개합니다: 역할별 ViewModel

1. 중앙 데이터 관리자: TransactionManager

TransactionManager는 앱의 모든 거래 내역을 저장하고 관리하는 '도서관 관장'과 같습니다. 이 클래스는 오직 데이터 그 자체에만 집중합니다.

  • 역할: Core Data와 직접 소통하며 모든 거래 내역에 대한 CRUD(생성, 읽기, 업데이트, 삭제) 작업을 수행합니다.
  • 핵심: 앱의 모든 거래 내역(transactions)을 @Published 변수로 관리하며, 이 데이터가 변경되면 자신을 구독하는 모든 객체에게 알립니다. 데이터 가공, 필터링, UI 관련 로직은 전혀 포함하지 않습니다.
import Foundation
import Combine

final class TransactionManager: ObservableObject {

    // MARK: - Central Data Store
    @Published private(set) var transactions: [ExpenseModel] = []
    
    // MARK: - Dependencies
    private let coreDataManager = TransactionCoreDataManager.shared
    private var cancellables = Set<AnyCancellable>()

    // MARK: - Initialization
    init() {
        // 앱이 시작될 때 모든 거래 내역을 불러옴
        readAllTransactions()
    }

    // MARK: - CRUD Operations
    func createTransaction(_ transaction: ExpenseModel) {
        coreDataManager.createTransaction(transaction)
            .sink(receiveCompletion: { completion in
                if case .failure(let error) = completion { print("Error creating: \(error)") }
            }, receiveValue: { [weak self] newTransaction in
                self?.transactions.append(newTransaction)
            })
            .store(in: &cancellables)
    }

    // ... readAllTransactions(), updateTransaction(), deleteTransaction(by:) 등
}

2. 각 화면별 전문가: HomeViewModel과 AddTransactionViewModel

이들은 중앙 도서관(TransactionManager)에서 필요한 책을 빌려와(데이터를 받아와) 각자의 목적에 맞게 활용하는 '열람실 이용자'와 같습니다.

HomeViewModel: 홈 화면에 필요한 데이터를 가공하고 UI에 전달합니다.

  • 역할: TransactionManager의 transactions 배열을 구독하고, 데이터가 변경될 때마다 '이번 달 총합', '최근 거래 내역' 등을 다시 계산합니다.
  • 핵심: 데이터를 직접 가져오거나 저장하지 않고, 오직 받아온 데이터를 가공해서 UI에 보여주는 역할만 합니다. 
import Foundation
import Combine

final class HomeViewModel: ObservableObject {

    // MARK: - UI-Specific Published Properties
    @Published private(set) var totalBalanceThisMonth: Int = 0
    @Published private(set) var recentTransactions: [ExpenseModel] = []

    // MARK: - Dependencies
    private var cancellables = Set<AnyCancellable>()

    // MARK: - Initialization
    init(transactionManager: TransactionManager) {
        // 중앙 관리자의 데이터 변화를 구독
        transactionManager.$transactions
            .sink { [weak self] allTransactions in
                guard let self = self else { return }
                // 데이터가 변경될 때마다 홈 화면에 필요한 모든 데이터 업데이트
                self.updateHomeData(with: allTransactions)
            }
            .store(in: &cancellables)
    }

    // MARK: - Data Processing
    private func updateHomeData(with transactions: [ExpenseModel]) {
        // 1. 월별 합계 계산
        let calendar = Calendar.current
        let currentMonthTransactions = transactions.filter {
            calendar.isDate($0.date, equalTo: Date(), toGranularity: .month)
        }
        let totalIncome = currentMonthTransactions.filter { $0.transaction == .income }.reduce(0) { $0 + $1.amount }
        let totalExpense = currentMonthTransactions.filter { $0.transaction == .expense }.reduce(0) { $0 + $1.amount }
        self.totalBalanceThisMonth = totalIncome - totalExpense

        // 2. 최근 거래 내역 업데이트
        self.recentTransactions = transactions.sorted { $0.date > $1.date }.prefix(4).map { $0 }
    }
}

 

AddTransactionViewModel: 단일 거래 내역의 추가 및 수정을 담당합니다.

  • 역할: 사용자가 입력하는 임시 데이터(transaction)를 관리하고, '저장' 버튼이 눌리면 이 데이터를 TransactionManager에게 넘겨 저장을 요청합니다.
  • 핵심: 저장 버튼이 눌리기 전까지는 임시 데이터만 관리하며, Core Data를 직접 건드리지 않습니다.
import Foundation
import Combine

final class AddTransactionViewModel: ObservableObject {

    // MARK: - UI-Specific Data (임시 데이터)
    @Published var transaction: ExpenseModel?
    @Published var errorMessage: String?
    
    // MARK: - Dependencies
    private let transactionManager: TransactionManager
    private let mode: AddTransactionMode

    // MARK: - Initialization
    init(transactionManager: TransactionManager, mode: AddTransactionMode) {
        self.transactionManager = transactionManager
        self.mode = mode

        // ... 모드에 따라 초기 데이터 설정
    }

    // MARK: - User Action Methods
    func save() {
        guard let transactionToSave = transaction else { return }
        if case .create = mode {
            transactionManager.createTransaction(transactionToSave)
        } else if case .edit = mode {
            transactionManager.updateTransaction(transactionToSave)
        }
    }
}

결론: 더 똑똑하고, 유지보수하기 쉬운 아키텍처

이러한 'ViewModel 책임 분리'는 다음과 같은 중요한 이점들을 선사합니다.

  • 높은 확장성: 새로운 화면이 추가되어도 기존의 TransactionManager는 그대로 두고, 새로운 목적에 맞는 ViewModel만 만들면 됩니다.
  • 쉬운 유지보수: 각 클래스의 책임이 명확해져서, 특정 버그가 발생했을 때 어디서 문제가 생겼는지 빠르게 파악하고 수정할 수 있습니다.
  • 안정적인 데이터 관리: 데이터가 앱 내에서 한 곳에만 존재하므로, 데이터 불일치로 인한 예기치 않은 버그를 방지할 수 있습니다.
  • 용이한 테스트: 각 ViewModel이 독립적으로 작동하므로, Mock 데이터를 주입하여 개별적으로 단위 테스트를 진행하기가 매우 쉬워집니다.

만약 당신의 앱에도 너무 많은 역할을 하는 '신 객체'가 존재한다면, 오늘 배운 원칙들을 적용하여 코드를 리팩토링해 보세요. 이는 장기적으로 앱의 안정성과 개발 속도를 크게 향상시킬 것입니다.

728x90
LIST