🍎 iOS/아키텍처&디자인 패턴

클린 아키텍처인 사이드 프로젝트 리팩토링하기

dev_zoe 2025. 12. 17. 17:36
반응형

현재 진행중인 사이드 프로젝트가 클린 아키텍처로 되어있는데, 팀원 개발자분과 클린 아키텍처에 관해 이야기를 나누다가

기존 프로젝트 소스코드에서 몇가지 클린 아키텍처에 위배되거나 개선했으면 하는 사항들이 있어서 이부분을 정리해보고자 한다.

 

1. 클린아키텍처 개념 리뷰

이전에 MVVM으로 되어있는 프로젝트를 클린아키텍처로 리팩토링하면서 해당 블로그글을 썼는데,
다시 한번 개념을 간단하게 리뷰해보자

 

클린아키텍처는 Data Layer, Domain Layer, Presentation Layer 3가지 레이어가 존재한다.

 

1) Data Layer (데이터의 원천, 혹은 이를 가져오거나 저장)

- 데이터 원천에 대한 Model인 DTO (ex. API의 response)

- 원천 데이터(DTO)를 가져오고 보관하는 DataSource
* DataSource는 기기 자체의 DataSource인 Local DataSource (ex. CoreData, UserDefaults, ..)와 Remote DataSource (ex. API)로 나눌 수 있다.

- DataSource를 호출하여 DTO를 받아온 다음 조작을 통해 Entity를 return하는 Repository

 

2) Domain Layer (기능 명세, 비즈니스 로직)

- 비즈니스 로직을 기술하는 UseCase

- 비즈니스 로직 기술에 필요한 데이터 모델인 Entity

 

3) Presentation Layer (화면을 표현, 사용자의 입력을 받아 처리)

- View (UIKit의 ViewController, SwiftUI View 등 사용자에게 보여지는 화면)

- 아키텍처에 따라 사용자의 입력을 처리, 뷰에게 보여주기 위한 로직을 담당하는 ViewModel이나 Reducer(TCA)와 같은 객체

 

그리고 클린 아키텍처의 구조와 특징은 다음과 같다.

 

 

 

1) 모듈 간의 분리, 테스트 용이성

- Domain, Data, Presentation Layer 의 각 레이어가 명확한 책임을 가지고 있기 때문에 유지보수성이 좋고, 재사용성이 좋다.

- 각 레이어가 독립적이기 때문에 테스트가 용이해진다.

 

2) 유지보수 용이성

- 각 레이어가 독립적이기 때문에 한 레이어를 변경하여도 다른 레이어에 영향을 미치지 않는다.

 

3) 의존성 역전 원칙 (DIP)

- 고수준 모듈 (안쪽으로 갈수록 고수준, 비즈니스 로직과 가까워짐)은 저수준 모듈 (바깥으로 갈수록 저수준)에 의존하면 안된다.

- 고수준 모듈이 저수준 모듈에 의존하는 경우를 예시를 들어보자면,
API의 response가 변경되었을 때 (데이터 레이어) 비즈니스 로직을 변경해야하는 경우이다. (도메인 레이어)

변경이 적용되어야 하는 코드를 하나하나 찾아서 모두 수정해야하는 비효율이 발생한다.

- 하지만 의존성 역전 원칙을 적용하여 저수준 --> 고수준 방향으로 의존하게 된다면,
데이터 레이어의 변경사항 발생 시 데이터 레이어만 변경하면 된다. (코드의 낮은 결합도)

 

4) 도메인 레이어는 어떠한 다른 계층에 의존하지 않으며, 그 자체로 순수해야한다.

- UseCase는 단일 비즈니스 로직 단위여야만 하고, 다른 UseCase에 의존할 수 없다.
(한 도메인의 비즈니스 로직이 다른 비즈니스 로직에 영향을 끼치게 되므로)

- UseCase는 Data Layer의 DataSource를 통해서만 데이터를 받아와야한다.

 

2. 프로젝트의 클린 아키텍처 구조 살펴보기

현재 사이드프로젝트는 Clean Architecture + TCA 아키텍처로 되어있고, Tuist를 통해 모듈화를 진행하고 있다.

 

1) Data Layer

- NetworkData: Moya 기반 TargetType (DataSource), 서버 DTO

- RepositoryData: API 통신이나 Local DB에서 원천 데이터, DTO를 반환해주는 Repository

 

2) Domain Layer

- Entity: 비즈니스 로직에 필요한 데이터 모델

- UseCase: 비즈니스 로직의 단위

 

3) Feature

- Presenteation Layer의 역할을 하는 모듈화된 Feature 단위이다.

- View와 Reducer (TCA) 로 구성

 

3. 클린 아키텍처 기반으로 코드 리팩토링

팀원과 함께 기존 코드를 검토하면서 몇가지 개선사항을 확인할 수 있었다.

 

1) UseCase는 Repository에만 의존하여 데이터를 가져와야하고, UseCase에서 데이터를 가져오면 안된다.

그 자체의 순수한 비즈니스 로직으로 독립적이어야한다.

public struct ExplorationDetailUseCase {
  public let detail: @Sendable (_ id: Int) async throws -> StoreDetail
}

extension ExplorationDetailUseCase: DependencyKey {
  public static var liveValue = ExplorationDetailUseCase(
    detail: { id in
       // 의존성 방향으로 보면 UseCase가 Entity에 의존하는 것은 맞는 방향
      @DefaultsValue(key: .user, default: nil) var userEntity: UserEntity?
      
      @Dependency(\.storeRepository) var storeRepository
      
      // !!! UseCase는 다른 UseCase에 의존하면 안된다.
      @Dependency(\.storeCategoryUseCase) var categoryUseCase
      @Dependency(\.reviewRepository) var reviewRepository
      let categories = try await categoryUseCase.categories()
      let storeResponse = try await storeRepository.detail(id)

 

이런 경우 StoreCategoryUseCase의 비즈니스 로직이 변경 되면 ExplorationDetailUseCase의 비즈니스 로직까지 영향을 준다.

UseCase는 데이터를 얻어올 때 오직 Repository에서만 얻어와야한다.

다행히도 해당 UseCase에서 categories를 얻어오는 부분은 Repository의 categories를 얻어오는 코드와 동일해서, 분리가 쉽게 가능했다.

 

2) 의존 방향이 Presentation Layer(Feature) -> Domain -> Data로 되어있음 (의존성 역전 원칙(DIP) 위배)

public struct StoreRepository {
  public let detail: @Sendable (_ id: Int) async throws -> StoreListResultResponse
}

extension StoreRepository: DependencyKey {
  public static var liveValue = StoreRepository(
    detail: { id in
      @Dependency(\.network) var network
      return try await network
        .request(StoreAPI.read(id: id))
        .map(StoreListResultResponse.self)
    },

 

 

UseCase가 의존하는 StoreRepository에서는 DTO를 직접 반환하고 있다.

그렇게 되면 DTO의 명세가 변경될 경우 UseCase에 영향을 준다. (의존 방향: Presentation Layer(Feature) -> Domain -> Data)

이에 Presentation Layer -> Domain <- Data로 의존성을 역전시키기 위해

 

1️⃣ Domain Layer에 추상화한 Repository 프로토콜을 만들고,
2️⃣ UseCase는 이에 의존하며

3️⃣ Data Layer에는 프로토콜의 구현체(Repository Impl)이 존재하게 된다.

 

그렇게 되면 Repository의 데이터가 변경되더라도 Data Layer만 변경하면 되고, Domain Layer에 영향을 주지 않도록 할 수 있다.

 

즉, 위 클린아키텍처 개념 리뷰에서 다뤘던 Repository는 추상화된 프로토콜과 구현체(Impl)가 도메인 레이어, 데이터 레이어 별로 각각 존재하게 되는 것이다.

 

3) Repository가 DTO를 return하면서 도메인 레이어인 UseCase는 데이터 레이어인 DTO를 알게된다. 

위 Repository 코드를 보면 DTO를 return하고 있음을 알 수 있다.

 

이 경우, 도메인 레이어가 데이터 레이어에 의존하게 되면서 클린 아키텍처의 의존성 규칙을 위반하게 되고,

관심사의 분리 원칙을 위반하게 된다. (각 레이어는 서로의 존재를 몰라야하고, 영향을 끼치지 말아야함)

 

그래서 2번과 3번을 토대로 리팩토링하게 되면 다음과 같이 수정되어야한다.

 

1️⃣ repository 인터페이스 (프로토콜) - 도메인 레이어에 위치

public protocol StoreRepository: Sendable {
    func fetchDetail(id: Int) async throws -> Store     // Entity
    func searchStores(query: SearchQuery) async throws -> [Store]
    func searchAddress(query: String, page: Int) async throws -> AddressInfo
}

 

2️⃣ repositoryImpl (구현체) - 데이터 레이어에 위치

import Dependencies
import Entity
import Foundation
import NetworkData

public final class StoreRepositoryImpl: StoreRepository {
    public func fetchDetail(id: Int) async throws -> Store {
        @Dependency(\.network) var network
        
        // 1. DTO(Response)를 받아옴
        let response = try await network
            .request(StoreAPI.read(id: id))
            .map(StoreListResultResponse.self)
        
        // 2. Entity로 변환하여 리턴
        return Store(
            id: response.id,
            name: response.title,
            address: response.address,
            coordinate: (response.lat, response.lng)
        )
    }
}

 

3️⃣ UseCase는 repository interface에 의존 (기존 Dependency value였던 storeRepository가 프로토콜로 변경)

extension StoreRepositoryImpl: DependencyKey {
    public static var liveValue: StoreRepository {
        StoreRepositoryImpl()
    }
}

extension DependencyValues {
    public var storeRepository: StoreRepository {
        get { self[StoreRepositoryImpl.self] }
        set { self[StoreRepositoryImpl.self] = newValue }
    }
}

 

이후에도 리팩토링 사항이 생기면 꾸준하게 적용해볼 예정이다.

 

Reference

https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3

https://medium.com/cj-onstyle/android-%EB%B2%84%ED%8B%B0%EC%BB%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%9D%98-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EB%8F%84%EC%9E%85-a26d833e103c

반응형