[Swift] Clean Architecture 적용기 (2) - Data & Factory 라이브러리

파이집·2024년 7월 24일

[The Month]

목록 보기
2/2

안녕하세요!
The Month (iOS) 서비스의 기획자 및 iOS 개발자를 담당하고 있는 파이집🥧 입니다! 혹시 궁금하시다면 다운 받아보시면 좋겠습니다 ㅎㅎ
앱스토어 링크

The Month - 나만의 월간 꼴라쥬 캔버스
SwiftUI + Coordinator + Clean Architecture + Factory + MVVM + 모듈화

기획부터 릴리즈까지 진행하며 문제가 됐던 것, 새로 알아버린 것을 이 [The Month] 시리즈에 쭉 이어 써보겠습니다!

이전 글 - 도입 이유 및 Domain

혹시 첫 글부터 보지 않으셨다면 위의 링크에서 이전 글을 보실 수 있습니다~


클린 아키텍쳐의 코어에는 "고수준으로 갈수록 변동성이 적어야 한다." 라는 로직이 있습니다. 그렇다면 Data 는 Domain 에 비하면 저수준의 레이어로써 변동성이 그래도 많은 편이라고 할 수 있습니다.

Data

Domain 에서 나의 서비스에서 핵심이 되는 모델과 비즈니스 로직에 대한 인터페이스들을 추가했다면, 이제는 네트워킹과 로컬 데이터의 로직을 책임지는 구현부를 만들어야 합니다.

Domain 에서 우리가 뭘 했죠? 핵심 로직에 대한 인터페이스를 만들었죠.
즉, 간단하게 말하면 틀 밖에 없다는 뜻입니다.

내부에서 실제로 돌아가는 코드를 적어야 하죠. 그게 바로?
이 Data 레이어(모듈)에서 하는 것이죠!

Data 모듈은 다음과 같이 구성했습니다.
저는 SwiftData 를 사용했기 때문에 LocalData 가 있구요.
네트워크도 사용하기 때문에 Network 가 있습니다.

그리고 마지막으로 가장 중요한 RepositoryProtocol 을 준수하는 Repository 가 있습니다!


LocalDataCore

~Core 에는 말그대로 해당 모듈에 코어에 해당하는 것들이 들어가 있습니다.
어떤게 있을까요?

네 바로, 로컬 데이터 (CoreData, SwiftData, Realm) 를 이용할 때 필요한 모델들이 있을겁니다.

또, Protocol 에는 Repository 에서 사용하는 LocalDataSource 와 같은 데이터 소스의 Protocol 이 존재하죠. 말그대로 코어가 들어가 있습니다.

import Foundation
import Entities
import SwiftData

@Model
public final class TemporayDraftModel: DiaryDraftType {
    public var id: String
    public var createdDate: Date
    public var createdDateCode: String
    public var lastModifiedDate: Date
    public var title: String
    public var body: String
    public var font: String
    public var fontSize: Int
    public var fontColor: String
    public var backgroundColor: String
    public var backgroundImage: String?
    
    public init(id: String, createdDate: Date, createdDateCode: String, lastModifiedDate: Date, title: String, body: String, font: String, fontSize: Int, fontColor: String, backgroundColor: String, backgroundIamge: String?) {
        self.id = id
        self.createdDate = createdDate
        self.createdDateCode = createdDateCode
        self.lastModifiedDate = lastModifiedDate
        self.title = title
        self.body = body
        self.font = font
        self.fontSize = fontSize
        self.fontColor = fontColor
        self.backgroundColor = backgroundColor
        self.backgroundImage = backgroundIamge
    }
}

이전 글에서도 나온 TemporaryDraft 는 다음과 같은 SwiftData Persistent 모델이죠.
보이시나요? Domain 레이어의 Entities 모듈과 SwiftData 를 받고 있죠? 이제는 Domain 에서 벗어났기 때문에 Framework 에 대해서는 조금 여유로워졌다는 사실을 알 수 있습니다!

LocalDataExtension

~Extension 에는 말그대로 해당 모듈의 ⭐️ 구현부와 필요한 extension 들이 존재합니다.

여기서 이제 DataSource 가 들어가는 겁니다!
실제 구현부가 여기서 들어가죠!

여기에 있는 DataSource 들은 LocalDataCore 의 나온 DataSourceProtocol 을 준수하죠!

import Foundation
import Entities
import LocalDataCore
import SwiftData
import SwiftUI

@Observable
public class LocalDataSource: LocalDataSourceProtocol {

	...

    public func getTemporaryDraft() async throws -> DiaryDraftType? {
        let todayStringCode = Date().toFormattedString()
        
        let predicate = #Predicate<TemporayDraftModel> { $0.createdDateCode == todayStringCode }
        var fetchDescriptor = FetchDescriptor(
            predicate: predicate
        )
        fetchDescriptor.fetchLimit = 1
        ...
    }
    
	...

}

이렇게 실제 구현 로직이 들어갑니다.


Repositories

자 이제, Data 안에 들어가는 Core 와 Extension 에 대해서 알아봤는데요!

이제 이 모든 것들을 종합해서 외부로 보내는 Repository 를 만들어야 합니다!

import Entities
import Foundation
import LocalDataCore
import LocalDataExtension
import RepositoryProtocol
import SwiftData

public final class LocalDataSourceRepository: DiaryRepositoryProtocol {
    private let localDataSource: LocalDataSourceProtocol
    
    public init(localDataSource: LocalDataSourceProtocol) {
        self.localDataSource = localDataSource
    }
    
    ...
    
    public func fetchTemporaryDiaryDraft() async throws -> DiaryDraftType {
        let result = try await localDataSource.getTemporaryDraft()
        if let result {
            return result
        } else {
            throw LocalDataError.nonExistentData
        }
    }
    
    ...
}
  1. Entities 을 의존하고 있죠?
    -> 모델의 인터페이스 DiaryDraftType 사용!
  2. LocalDataCore 를 의존하고 있죠?
    -> LocalDataSourceProtocol 를 사용!
    -> LocalDataError 를 사용!
  3. RepositoryProtocol 을 의존하고 있죠?
    -> DiaryRepositoryProtocol 를 사용!

이렇게 하면, Repository 도 끝!


비즈니스 로직에 대한 준비는 끝!

Domain 과 Data 모듈(레이어)만 준비해도 비즈니스 로직에 대한 준비는 많이 끝났다고 할 수 있죠!

물론! 그외의 상세한 부분은 다른 모듈로 빼서 할 수 있지만, 이 두개가 각 레이어의 대표이고 핵심이니, 이 둘의 틀을 잡아놨다면 거의 다했다고 볼 수 있죠!


Factory

자 이제 이렇게 구성한 UseCase 와 Repository, 그리고 DataSource(또는 Service) 를 그냥 필요한 곳에서 뷰에 쓸 수 있지만

프로젝트의 사이즈가 조금이라도 커지면 반복되는 코드가 많아질 것이 자명합니다...!

DI & 객체 생성 문제

그러니 한 곳에 모아서 Factory 패턴으로 객체 생성의 문제를 해결하고, DI 를 깔끔하게 해결하고자 했습니다.

🫨 왜요? 그냥 View 에다가 냅다 쓸 수 있잖아요. 얼마나 대단한 프로젝트라고..?

라고 물어볼 수 있죠! 왜냐하면 귀찮고, 써야할 코드가 늘어나는 것처럼 보이니까요!

하지만 이건 반은 맞고, 반은 틀린 말입니다.

DI 와 객체 생성을 위한 Factory 패턴은 초반엔 분명 써야할 코드가 많습니다. 하지만 조금만 프로젝트가 커지면, 효율적으로 그리고 더 적은 코드로 의존성 관리와 객체에 대한 관리를 손쉽게 할 수 있죠!

이런 상황을 해결하기 위해 사용합니다. (위에서 말한 것과 이어집니다.)

1. 네비게이션을 위해 View 객체를 만들기 위해서는 ViewModel 객체를 생성해야 합니다.
2. ViewModel 객체를 만들기 위해서는 UseCase 의 객체가 필요합니다.
3. UseCase 객체를 만들기 위해서는 Repository 객체가 필요합니다.
4. Repository 객체를 만들기 위해서는 내부의 Service 나 DataSource 객체가 필요합니다.

이걸 한번 실제로 코드로 봐볼까요?

SettingView(
    viewModel: SettingViewModel(
    	useCaseA: UseCaseA(repositoryA: RepositoryA(...)),
        useCaseB: UseCaseB(repositoryB: RepositoryB(...))
    )
)

보이시나요?
이 커다란 코드의 객체가 View 에 존재한다고 한다면 어떠실 것 같나요?

저는 그저 홈 화면에서 세팅 화면으로 넘어가고자 이만큼 긴 코드를 썼습니다.
심지어 코드를 적기 위해서는 의존성을 모두 import 에서 또 써야하죠.
import Entities
import UseCase
import UseCaseProtocol
...
적어도 10개는 매 View 마다 선언해야했겠죠.

이걸 방지하고 자 우리는 Factory 패턴을 쓰는 겁니다!
(다른 방법도 있지만 저는 가장 가벼운 라이브러리를 쓰고 싶었습니다.)


Factory 패턴을 이번에 써보면서 공부를 했었습니다.

Factory 패턴은...

객체 생성을 별도의 클래스로 분리해서 캡슐화를 하는 패턴입니다.
이렇게 하면, View(SwiftUI) 에서 직접 다음 화면이나 다음 로직을 위한 객체 생성을 생략할 수 있습니다!

예를 들면,
내가 A 화면에서 B 화면으로 넘겨야 합니다. 그런데 B 화면에 B_ViewModel 이 있어서 이 ViewModel 객체를 A 화면에서 선언하고 넘겨야 넘길 수 있습니다. 그러면 View 는 화면만 나타내는 UI 가 아닌 다른 로직을 알아야 한다는거죠.

이런 복잡한 문제를 해결하고, 특히 Coordinator 가 들어간다면 필수적이어야 하는 이 Factory 에 대한 걸 알아보죠..!


Factory 라이브러리

우선 Factory 라이브러리는 Swift 로 만든 써드파티 라이브러리 입니다.
저는 이번 The Month 서비스를 준비하면서 써드파티 라이브러리는 이 Factory 만 사용했는데요.

사용법은 그렇게 크게 어렵지 않습니다.

Container 라는 클래스가 있습니다.
Containers are used by Factory to manage object creation, object resolution, and object lifecycles in general.

즉, 객체 생성객체 해결(resolution), 그리고 객체 생명 주기를 관리하죠.

객체 해결이란, 이미 만들어진 객체를 필요한 곳에 반환하거나, 새로운 객체를 생성하는 역할을 하죠.
객체 생명주기 관리는, 보통 Container 안에서 생성된 객체는 쓰일 때마다 새롭게 생성이 됩니다. 그런데 이걸 싱글톤으로 둬서 단 하나의 객체를 두며 유일성을 유지할지, 아니면 매번 생성하게 할 것인지, 아니면 매번 생성하게 할거지만, 여러 뷰에서 쓰이게 할 것인지에 대한 생명 주기를 관리하게 됩니다.

이제 사용해보죠!
저는 다른 모듈 말고, Coordinator 를 쓰기 때문에 메인 모듈에 DependencyInjection.swift 파일을 만들어서 이 안에서 관리를 했습니다.

import Factory
import Foundation
import SwiftData
import LocalDataCore
import LocalDataExtension
import Repositories
import RepositoryProtocol
import Home
import MyDiary
import MyCanvas
import Fragment
import Setting
import UseCases
import UseCaseProtocol

이 메인 모듈은 클린 아키텍쳐의 가장 외부의 원에 위치하기 때문에 모든 모듈을 의존할 수 있고, 의존해야 합니다.(사용하기 위해서)

  1. 객체 생성
@MainActor
extension Container {
    // MARK: - LocalDataSource
    var localDatasource: Factory<LocalDataSourceProtocol> {
        self { LocalDataSource(
            modelContext: SwiftDataDataSource.shared.modelContext
        )
        }.shared
    }
    
        // MARK: - FirebaseService
    var firebaseService: Factory<FirebaseRepositoryProtocol> {
        self {
            FirebaseService()
        }
    }
    
    ...

--

LocalDataSource 의 객체를 생성하면서 Container 의 extension 에 계산 프로퍼티로 생성을 합니다.

왜 계산 프로퍼티로 생성을 하는지 궁금할 수 있습니다.

  1. Container 는 class 이다.
    -> Class 의 extension 에서는 계산 프로퍼티 밖에 추가하지 못한다. 저장 프로퍼티는 추가할 수 없다.
  2. 1의 이유에서 끝나지만, 사실 이렇게 계산 프로퍼티로 추가해야 OOP 의 확장성 개념을 충족할 수 있습니다.
  3. 필요하지 않을 때, 굳이 메모리에 미리 올라가 있을 필요가 없습니다. 그렇기 때문에 호출이 될 때, 그때서야 작동할 수 있게 계산 프로퍼티로 만든 이유도 있겠죠.

Factory 반환

이를 통해, 반환 타입을 명시적으로 지정함으로써 컴파일 시에 타입 체크가 가능합니다.


위에서 이 코드를 다시 가져왔습니다.

SettingView(
    viewModel: SettingViewModel(
    	useCaseA: UseCaseA(repositoryA: RepositoryA(...)),
        useCaseB: UseCaseB(repositoryB: RepositoryB(...))
    )
)

머리가 아프죠?

이제 이걸 Container 에서 나눠보겠습니다.
잘생각해보시면, 내부에 있는 객체일수록 더 자주, 많은 곳에서 쓰이겠다는 생각이 드시죠?

DataSource 나 Service 는 Repository 에서,
Repository 는 UseCase 에서,
UseCase 는 ViewModel 에서
ViewModel 은 View 에서 사용되죠. (의존)

보통 ViewModel 과 View 는 1대1 대응을 많이들 합니다.
안 그럴때도 있죠

하지만 대게 그렇기에, ViewModel 에서 더욱 Core 로 갈수록 사용 빈도가 늘어납니다.

그래서 Core 부터 먼저!

DataSource / Service 객체를 가장 먼저 추가

Repository 객체를 추가

UseCase 객체를 추가

ViewModel 객체를 추가

이런 순으로 Container 를 채워 나가는겁니다!

그러면 다음과 같은 코드가 나옵니다. 참고하세요!

// MARK: - FileManagerDataSource
  var fileManagerDataSource: Factory<FileManagerDataSourceProtocol> {
      self {
          FileManagerDataSource()
      }
  }

...

var someFileManagerDataSourceRepository: Factory<SomeRepositoryProtocol> {
      self {
          SomeFileManagerDataSourceRepository(
              fileManagerDataSource: self.fileManagerDataSource()
          )
      }
  }

...

var someCanvasUseCase: Factory<SomeUseCaseProtocol> {
      self {
          SomeUseCase(
              repository: self.someFileManagerDataSourceRepository()
          )
      }
  }

...

// MARK: - ViewModels
  var homeViewModel: Factory<HomeViewModel> {
      self {
          HomeViewModel(
              ...
              someCanvasUseCase: self.someCanvasUseCase()
          )
      }
  }

위에서부터 DataSource -> Repository -> UseCase -> ViewModel 이렇게 이어지는게 보이시죠?

이렇게 Container 하나에 모아놓고 사용하면 더 이상 View 에서는 객체를 그렇게 길게길게 매번 쓰지 않아도 됩니다! 정말 만족스럽지 않나요..?

이게 진짜 좋은게, 한곳에 모아놓으니 나중에 수정 사항이 생겨도 이 Container 안에서만 해결을 하면 된다는 거죠.

이걸 View 에서 썼다면... 그 모든 곳을 찾아가서 바꾸는... 그런 문제가... 어후 생각만해도 끔찍하네요.


아무튼 오늘은 Data 레이어와 Factory 사용법을 알아봤는데요.
궁금한게 있으시거나 틀린 점이 있다면 편하게 의견을 공유해주세요!

그럼 다음 글로 찾아오겠습니다!

profile
다박다박 위로 올라가는 iOS 개발자

0개의 댓글