Neoself의 기술 블로그

클린 아키텍처 도입기 본문

개발지식 정리/Swift

클린 아키텍처 도입기

Neoself 2025. 1. 15. 15:24

이 글에서는 제가 클린 아키텍처를 공부하고, 이를 실제 프로젝트에 적용하면서 겪은 경험을 공유하고자 합니다. 클린 아키텍처 자체에 대한 이해도를 높히고 싶으신 개발자 분들께 도움이 되었으면 합니다.

 

배경

Todo 앱을 개발하면서 가장 신경 쓴 부분은 오프라인 상태에서도 앱이 정상적으로 동작하는 것이었습니다. 사용자가 지하철에서 Todo를 추가하거나 수정하더라도, 네트워크가 복구되면 자연스럽게 서버와 동기화되어야 했죠. 이를 위해 CoreDataSyncService라는 동기화 전담 서비스를 구현했습니다.

 

하지만 시간이 지날수록 이 서비스는 점점 더 많은 책임을 떠안게 되었습니다. CRUD 작업마다 로컬 저장소 처리, 네트워크 요청, 위젯 업데이트까지... 모든 로직이 긴밀하게 얽혀있었죠.

func updateTodo(_ todo: Todo) -> AnyPublisher<Todo, Error> {
    // 1. CoreData에 Todo 저장
    try saveTodoToStore(todo)
    
    // 2. 위젯 업데이트
    widgetManager.updateWidget(.todoList)
    
    // 3. 오프라인이면 SyncQueue에 작업 추가
    if !NetworkManager.shared.isConnected {
        return syncQueue.process(SyncCommand(
            id: UUID(),
            operation: .updateTodo(todo),
            status: .pending,
            retryCount: 0
        ))
    }
    
    // 4. 온라인이면 서버에 즉시 반영
    return todoService.updateTodo(todo: todo)
        .tryMap { updatedTodo in
            // 5. DailyStat 업데이트 필요 시 추가 요청
            if needsStatUpdate(updatedTodo) {
                try refreshDailyStat(for: updatedTodo.deadline)
            }
            return updatedTodo
        }
        .eraseToAnyPublisher()
}

 

또한 CoreDataSyncService 뿐만 아니라, ViewModel들에도 CoreDataSyncService 메서드들을 활용한 비즈니스 로직들이 구현되어있었습니다.

 

이런 구조로 인해  얼마 지나지 않아 실제 문제를 일으켰습니다.

투두 수정 비즈니스 로직에 대한 엣지 케이스를 파악하지 못한 것인데요.

Todo 수정 시 마감기한을 유지할 경우, 문제가 되지 않았지만, Todo를 변경하면서 날짜를 변경하게 될 경우, 변경전 날짜와 변경 후 날짜의 일간 통계 데이터를 모두 서버에 요청 및 반환받아야한다는 케이스를 고려하지 못했던 것이였습니다.

때문에, 마감기한을 수정하게 될 경우, 화면에 보여지는 월간통계자료와 서버에 저장되어있는 월간통계자료 간의 정합성이 준수되지 않는 이슈가 있었습니다.

이러한 트러블 슈팅 과정에 대해 서로 코드를 리뷰하는 과정에서, 각 파일마다의 책임이 혼재하기에 비즈니스 로직을 명확하게 분리하고, 각 계층의 책임을 명확히 하면 이러한 문제해결에 도움이 될 수 있을 것이라는 조언을 들을 수 있었습니다.

 

그래서 클린 아키텍처는 뭐죠

SW의 구조를 계층화함으로서 관심사를 분리한 구조입니다.

여기서 클린아키텍처의 핵심은 관심사의 분리로, 각 계층이 독립적이고 명확한 책임을 가지도록 설계하는 것입니다.

 

1. 현재 아키텍처의 구조

 

현재 아키텍처의 가장 큰 문제점은 CoreDataSyncService에 과도한 책임이 집중되어 있다는 것입니다. 해당 파일 내부에 CRUD 작업, 로컬 저장소 관리, 네트워크 동기화 그리고 위젯 업데이트까지 너무 많은 기능이 한 클래스 안에 구현이 되어있었죠.

때문에, 앞서 말씀드린 투두 수정과 같은 로직을 수정하는 과정에서 유지보수 리소스가 커지는 문제가 있었고, 엣지 케이스를 확인하지 못해 버그를 확인하지 못했던 것이죠.

 

ViewModel의 구조 또한 개선이 필요했습니다. 기존 ViewModel 파일은 CoreDataSyncService를 직접 접근해 비즈니스 로직도 관리하고 있었습니다. 이는 ViewModel이 프리젠테이션 로직만을 처리하지 않고 있어 SRP 원칙에도 위배될 뿐만 아니라,  비즈니스 로직이 두 파일에 걸쳐 구현되어있기에 관심사 분리가 안된다는 문제점도 갖고 있었습니다.

 

뿐만 아니라, UseCase와 Repository와 같은 Interface Adaptor가 부재해 직접적인 의존성이 발생해 격리된 테스트가 불가능하다는 치명적인 문제점도 있었습니다. 단적인 예시로 Todo 수정 기능이 있는데요.

// HomeViewModel
class HomeViewModel: ObservableObject {
    private let syncService = CoreDataSyncService.shared
    
    func editTodo(_ todo: Todo) {
        syncService.updateTodo(todo)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] updatedTodo in
                // UI 업데이트 로직
            }
    }
}

현재 ViewModel은 싱글톤 패턴이 적용된 CoreDataSyncService를 직접적으로 의존하고 있어, ViewModel이 데이터 저장 방식을 직접 알고있죠.

 

2. 도메인 계층 설계

클린 아키텍처 도입의 첫 단계로, 앱의 핵심 비즈니스 로직을 담당할 도메인 계층을 설계했습니다. 도메인 계층은 외부 의존성 없이 순수한 비즈니스 규칙만을 포함하도록 했습니다.

2.1. 엔티티 모델 정의

먼저 기존 Models 디렉토리에 있던 구조체로 앱의 핵심 데이터 구조인 엔티티 모델들을 정의했습니다. 

struct Todo: Identifiable, Codable {
    let id: String
    var raw: String 
    ...
    enum CodingKeys: String, CodingKey {
    }
}

struct DailyStat: Codable, Identifiable {
    let id: String
    let date: String
    ...
    
    enum CodingKeys: String, CodingKey {
    }
}

struct Tag: Codable, Identifiable {
    let id: String
    var name: String
    ...
    
    enum CodingKeys: String, CodingKey {
    }
}

2.2. 도메인 에러 정의

도메인 규칙 위반에 대한 에러 또한 추후 변경될 수 없다고 판단해 도메인 계층에 정의했습니다.

enum TagError: Error {
    case duplicateName
    case invalidColorFormat 
}

 

2.3. UseCase 계층 도입

그 후, 앞서 정의한 도메인 모델들을 기반으로 실제 비즈니스 로직을 구현하는 UseCase 계층을 설계했습니다. UseCase는 하나의 비즈니스 시나리오를 구현하며, 여러 Repository를 조합하여 복잡한 작업을 수행하도록 설계했습니다.

class TodoUseCase: TodoUseCaseProtocol {
    private let todoRepository: TodoRepository
    private let dailyStatRepository: DailyStatRepository
    
    func updateTodoWithStats(_ todo: Todo, from originalDate: String) async throws -> ([DailyStat]) {
        // 1. Todo 업데이트
        try await todoRepository.updateSingle(todo)
        
        // 2. 변경 전/후 날짜의 통계 데이터 갱신
        let updatedStat1 = try await dailyStatRepository.getSingle(for: originalDate)
        let updatedStat2 = try await dailyStatRepository.getSingle(for: todo.deadline)
        
        // 3. 위젯 업데이트
        WidgetManager.shared.updateWidget(.all)
        
        return [updatedStat1, updatedStat2].compactMap{$0}
    }
}

보시는 것과 같이 CoreDataSyncService와 ViewModel에 분산되어 있던 로직을 하나의 UseCase로 캡슐화하였는데요. 덕분에, 앞서 언급했던 Todo 날짜 변경 시 두 날짜의 통계를 모두 업데이트해야 하는 비즈니스 규칙이 명확해져, 유지보수의 용이성을 높일 수 있음을 확인할 수 있었습니다.

 

태그 생성 시나리오에서는 앞서 구현했던 도메인 규칙을 활용해 메서드 유효성 검증도 UseCase에서 병행하도록 했습니다.

class TagUseCase: TagUseCaseProtocol {
    func createTag(name: String, color: String) async throws -> Tag {
        // 중복 이름 검증
        let existingTags = try await tagRepository.get()
        guard !existingTags.contains(where: { $0.name.lowercased() == name.lowercased() }) else {
            throw TagError.duplicateName
        }
        
        return try await tagRepository.createSingle(name: name, color: color)
    }
}

 

UseCase에 적용된 의존성 역전

보시는 바와 같이 UseCase는 정의한 비즈니스 시나리오 수행을 위해 각 repository에 접근해 실제 CRUD 메서드를 실행시킬 수 있어야 합니다.

하지만 클린 아키텍처의 핵심은 안쪽 레이어는, 바깥쪽 레이어에 대해 몰라야 한다는 것입니다.

UseCase는 Domain Layer로 Interface Adapters(데이터) 레이어에 위치하는 Repository보다 안쪽에 위치하고 있기 때문에, 이를 충족시키고자 Repository 구현체가 아닌 Protocol(인터페이스)에 의존하도록 하였으며, 구체적인 구현체는 initializer에서 주입받도록 설계하였습니다.

class AuthenticationUseCase: AuthenticationUseCaseProtocol {
    private let repository: AuthRepositoryProtocol
    
    init(repository: AuthRepositoryProtocol = AuthRepository()) {
        self.repository = repository
    }
}

 

3. Interface Adapters 계층: Repository 패턴 적용

앞서 설계한 UseCase 계층 아래에 데이터 접근을 추상화하는 Repository 계층을 구현했습니다. Repository는 클린 아키텍처의 Interface Adapters 계층에서 Gateway 역할을 수행하며, 도메인 계층이 외부 데이터 소스로부터 독립적으로 동작할 수 있게 합니다.

 

3.1. Repository의 역할과 책임

데이터 접근을 추상화하는 인터페이스 계층이며, 두개 DataSource를 조합해 작업을 수행하게 됩니다.

여기서 데이터 접근을 추상화한다는 것은 UseCase에서는 repository 인터페이스에만 의존함으로써, 로컬에서 가져올지, 원격에서 가져올지와 같은 데이터 접근에 대한 구체적인 접근 방식은 모르게 만들어주기 때문입니다.

protocol TodoRepositoryProtocol {
    func get(in date: String) async throws -> [Todo]
    func updateSingle(_ todo: Todo) async throws
}

class TodoRepository: TodoRepositoryProtocol {
    private let remoteDataSource: TodoRemoteDataSourceProtocol
    private let localDataSource: TodoLocalDataSourceProtocol
    
    func updateSingle(_ todo: Todo) async throws {
        if NetworkManager.shared.isConnected {
            _ = try await remoteDataSource.updateTodo(todo)
            try localDataSource.saveTodo(todo)
        } else {
            try localDataSource.saveTodo(todo)
            syncManager.enqueueOperation(.updateTodo(todo))
        }
    }
}

 

3.2. Gateway로서의 Repository

Repository 패턴은 클린 아키텍처 관점에서는 Gateway로서 Interface Adapter를 구현한 대표적인 예시라고 볼 수 있습니다.

Gateway: 외부 시스템(로컬 저장소, Network API)과 도메인 계층 사이의 추상화된 인터페이스를 제공

 

추상화된 인터페이스 제공

// UseCase는 구체적인 데이터 접근 방식을 모름
class TodoUseCase: TodoUseCaseProtocol {
    private let repository: TodoRepositoryProtocol // 인터페이스에만 의존
    
    func updateTodoWithStats(_ todo: Todo) async throws -> ([DailyStat]) {
        // 데이터 저장소 구현 방식과 무관하게 동작
        try await repository.updateSingle(todo)
        // ...
    }
}

즉, Repository는 의존성 방향 기준으로, 도메인 레이어와 Framework, Drivers 레이어 사이에 Gateway로서 삽입되는데요.

Domain Layer (UseCase)
      ↑    
Interface Adapters (Repository/Gateway)
      ↑
Frameworks & Drivers (DataSources)

이로서, 도메인 계층은 Core Data나 네트워크 통신과 같은 외부 의존성을 직접 알지 않아도 되며, 데이터 접근에 대한 일관된 인터페이스를 제공해줍니다.

 

4. Infrastructure Layer: Repository 별 DataSource 구현

DataSource는 특정 데이터 저장소 기술에 특화된 구체적인 구현을 제공하며, CoreData와 Alamofire과 같은 프레임워크에 강하게 의존하고 있습니다. 여기서 구체적인 구현이란, LocalDataSource의 경우 실제 로컬 저장소의 CRUD를, RemoteDataSource는 네트워크 API와의 통신을 의미하죠.

 

같은 데이터에 대해 로컬 저장소과 네트워크 API 2곳의 출처가 공존하고 있기에, 각 Repository 마다 Remote(API)와 Local(CoreData) DataSource로 나누어 구현했습니다.

// Remote DataSource - API 통신
class TodoRemoteDataSource: TodoRemoteDataSourceProtocol {
    private let networkService: NetworkService
    
    func updateTodo(_ todo: Todo) async throws -> TodoResponse {
        return try await networkService.request(
            .updateTodo(todo.id),
            method: .put,
            parameters: todo.dictionary
        )
    }
}

// Local DataSource - CoreData 작업
class TodoLocalDataSource: TodoLocalDataSourceProtocol {
    private let coreDataStack: CoreDataStack
    
    func saveTodo(_ todo: Todo) throws {
        try coreDataStack.performInTransaction {
            let entity = try getOrCreateEntity(for: todo.id)
            entity.update(from: todo)
        }
    }
}

 

1. DataSource의 역할과 책임

Remote DataSource

- API 통신 처리

- 네트워크 요청/응답 관리

- 데이터 직렬화/역직렬화

Local DataSource

- CoreData 영속성 관리

- 로컬 데이터 CRUD 작업

- 저장소 모델 변환

2. DataSource의 데이터 모델 -> 엔티티 모델 변환

class TodoLocalDataSource: TodoLocalDataSourceProtocol {
    func getTodos(for date: String) throws -> [Todo] {
        let request = TodoEntity.fetchRequest()
        request.predicate = NSPredicate(format: "deadline == %@", date)
        
        let entities = try coreDataStack.context.fetch(request)
        return entities.map { entity in
            Todo(
                id: entity.id ?? "",
                title: entity.title ?? "",
                isCompleted: entity.isCompleted,
                // ... 다른 속성들
            )
        }
    }
}

5.  CoreDataSyncService 책임분산

기존 CoreDataSyncService의 경우 아래 기능을 구현하며, 방대한 책임범위를 갖고 있었습니다.

- CRUD 작업
- 로컬 저장소 관리  
- 네트워크 동기화
- 위젯 업데이트

1. 책임 분리

따라서 과도한 책임을 분산시키기 위해, 동기화 로직을 별도의 SyncManager로 분리하고, 나머지 책임을 Repository/DataSource 패턴으로 재구성했습니다.

Repository
├── 데이터 접근 추상화
└── DataSource 조합
    ├── LocalDataSource: 로컬 저장소 작업
    └── RemoteDataSource: 네트워크 요청

SyncManager
├── 동기화 작업 관리
└── 오프라인 작업 처리

 

SyncManager는 기존 CoreDataSyncService가 의존하는 SnycQueue 클래스의 내부 구현 메서드의 상당부분을 동일하게 갖고 있으며, 동기화 작업만을 전담하게 됩니다.

 

2. SyncManager 구현

class SyncManager: SyncManagerProtocol {
    private let todoService: TodoRemoteDataSourceProtocol
    private let tagService: TagRemoteDataSourceProtocol
    private let maxRetries = 3
    
    func enqueueOperation(_ type: SyncOperationType) {
        let operation = SyncOperation(type: type)
        try? coreDataStack.performInTransaction {
            let entity = SyncOperationEntity(context: coreDataStack.context)
            entity.payload = try JSONEncoder().encode(operation)
            entity.status = operation.status.rawValue
        }
    }
    
    private func processPendingOperations() {
        guard NetworkManager.shared.isConnected else { return }
        
        // 1. 대기 중인 작업 조회
        let operations = try? coreDataStack.context.fetch(request)
        
        for entity in operations ?? [] {
            guard let operation = decodeOperation(from: entity) else { continue }
            
            // 2. 재시도 횟수 확인
            if operation.retryCount >= maxRetries {
                updateCommandStatus(entity, to: .maxRetriesExceeded)
                continue
            }
            
            // 3. 작업 실행
            Task {
                do {
                    try await processOperation(operation)
                    updateCommandStatus(entity, to: .completed)
                } catch {
                    handleOperationError(entity, operation, error)
                }
            }
        }
    }
}

 

3. Repository와의 통합

이렇게 구현된 SyncManager는 오프라인 상태일때 Repository에서 참조해 사용하게 됩니다.

class TodoRepository: TodoRepositoryProtocol {
    private let syncManager: SyncManager
    
    func updateSingle(_ todo: Todo) async throws {
        if NetworkManager.shared.isConnected {
            _ = try await remoteDataSource.updateTodo(todo)
            try localDataSource.saveTodo(todo)
        } else {
            try localDataSource.saveTodo(todo)
            syncManager.enqueueOperation(.updateTodo(todo))
        }
    }
}

 

6.  ViewModel: UseCase의 분리를 통한 관심사 분리

마지막으로, 앞서 설계한 UseCase 계층을 활용해 ViewModel의 책임을 순수한 화면 상태 관리로 제한하는 리팩토링을 진행했습니다. 이를 통해 비즈니스 로직과 프레젠테이션 로직을 명확히 분리할 수 있었습니다.

 

기존 ViewModel

// Before: 비즈니스 로직이 ViewModel에 포함된 경우
class HomeViewModel: ObservableObject {
    private let syncService = CoreDataSyncService.shared
    
    func editTodo(_ todo: Todo) {
        syncService.updateTodo(todo)
            .sink { updatedTodo in
                // 비즈니스 로직과 UI 업데이트가 혼재
                self.refreshDailyStat()
                self.updateTodoList()
            }
    }
}

 

UseCase 도입 후 ViewModel

@MainActor
class HomeViewModel: ObservableObject {
    // UI 상태 관리
    @Published var weekCalendarData: [DailyStat] = []
    @Published var todosForDate: [Todo] = []
    @Published var isLoading: Bool = false
    
    // UseCase 의존성
    private let todoUseCase: TodoUseCaseProtocol
    
    func editTodo(_ todo: Todo) {
        Task {
            isLoading = true
            defer { isLoading = false }
            
            do {
                // 비즈니스 로직은 UseCase에 위임
                let updatedStats = try await todoUseCase.updateTodoWithStats(todo, from: selectedDate.apiFormat)
                
                // ViewModel은 UI 상태 업데이트에만 집중
                if let index = todosForDate.firstIndex(where: {$0.id == todo.id}) {
                    todosForDate[index] = todo
                }
                
                for stat in updatedStats {
                    updateCalendarData(with: stat)
                }
                
                ToastManager.shared.show(.todoEdited)
            } catch {
                print("Edit Todo error: \(error)")
            }
        }
    }
}

 

UseCase를 통해 비즈니스 로직을 캡슐화할 수 있게 되다 보니, UseCase 단위들을 활용해, 여러 비동기 작업들이 혼재했던 복잡한 작업들도 깔끔하게 처리할 수 있게 되었습니다.

class HomeViewModel: ObservableObject {
    func fetchData(_ dataType: DataType) async {
        do {
            switch dataType {
            case .all(let date):
                // 여러 UseCase를 병렬로 실행
                async let todosTask = todoUseCase.getTodos(in: date)
                async let statsTask = dailyStatUseCase.getMonthStats(in: date)
                async let tagsTask = tagUseCase.getAllTags()
                
                // 결과를 한 번에 처리
                (todosForDate, weekCalendarData, tags) = try await (todosTask, statsTask, tagsTask)
            }
        } catch {
            print("Error refreshing \(dataType): \(error)")
        }
    }
}

 

 

7. 결과 및 회고

클린 아키텍처 도입을 통해 다음과 같은 구조적 개선을 이룰 수 있었습니다.

 

7.1 핵심 개선사항

비즈니스 로직의 명확한 분리

UseCase 계층을 별도로 분리 및 구현함을 통해, ViewModel과 CoreDataSyncService에 혼재했던 비즈니스 로직들을 캡슐화 할 수 있었습니다. 이로써, ViewModel에서는 UI 관리 로직 자체에 더 집중할 수 있게 되었습니다.

 

데이터 접근 추상화

CoreDataSyncService에 모두 구현되었었던, 오프라인 동작과, 네트워크 호출 동작이 각각 LocalDataSource와 RemoteDataSource로 책임이 분산되었으며, Repository 패턴을 통해 Interface Adapter를 UseCase와 DataSource 사이에 삽입함으로써, 데이터 저장소를 추상화 시켜, DataSource의 변화가 useCase 구현체의 변화로 이어지지 않게 설계할 수 있었습니다.

 

7.2 배운점

아키텍처 설계 간, 관심사의 분리가 코드 품질에 큰 영향을 끼친다는 사실을 알게 되면서, 초기 설계의 중요성을 실감할 수 있게 되었습니다.

 

 

감사합니다.

 


Reference

https://daryeou.tistory.com/280

 

클린 아키텍처(Clean Architecture) 개념 및 원칙

개발이란 마치 여러 개의 기반이 되는 블록을 만들어 설계 원칙에 따라 조립하여 완성해 나아가는 과정이라고 생각합니다. 여기서 설계 원칙은 수 많은 디자인 패턴들을 의미하며, 이번 주는 아

daryeou.tistory.com

 

https://medium.com/@jungkim/%EB%B2%84%ED%84%B0%ED%94%8C%EB%9D%BC%EC%9D%B4-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%A5%BC-%EC%86%8C%EA%B0%9C%ED%95%A9%EB%8B%88%EB%8B%A4-9d4abd71c3c1

 

버터플라이 아키텍처를 소개합니다

iOS 클린 아키텍처에 대한 해석

medium.com