iOS

[ iOS ] 프로젝트에 Clean Architecture를 도입하며 배운 구조적 사고

Sheep1sik 2025. 5. 2. 13:45
반응형

iOS 앱 개발을 하며 규모가 커질수록 기능마다 역할과 책임이 명확히 나뉜 구조가 필요하다는 것을 느꼈고, 그 해답 중 하나로 Clean Architecture를 도입해보았습니다. 이 글은 Clean Architecture의 핵심 개념과 이를 iOS 프로젝트에 어떻게 적용했는지를 정리한 기록입니다.


Clean Architecture란?

Clean Architecture는 의존성 방향이 바깥에서 안으로만 흐르고, 핵심 로직(비즈니스 규칙)을 외부 구현과 분리하는 소프트웨어 아키텍처입니다. 이 구조는 Robert C. Martin(aka. Uncle Bob)이 제안했으며, 다음의 원칙을 따릅니다

  • 의존성은 안쪽 계층으로만 흐른다.
  • 바깥 계층은 안쪽 계층의 존재를 알 수 있지만, 그 반대는 아니다.
  • 구현보다 추상에 의존해야 한다.

📚 참고 링크: The Clean Architecture by Uncle Bob

 

이를 통해 핵심 로직은 외부 도구(Firebase, Alamofire, UIKit 등)에 의존하지 않게 되고, 테스트 가능성과 유지보수성이 향상됩니다.


 

Clean Architecture 다이어그램 기준 설명

 

이 그림은 핵심 규칙인 "의존성은 안쪽을 향한다"는 철학을 중심으로 구성되어 있으며, 다음과 같은 구성 요소로 이루어져 있습니다.

  • Entities (가장 안쪽): 애플리케이션의 핵심 비즈니스 객체이며, 외부의 어떤 기술에도 의존하지 않습니다. 예: User, Movie 등.
  • Use Cases: 애플리케이션의 특정 기능(로그인, 예매 등)을 수행하는 로직을 담고 있으며, 엔티티를 활용해 구체적인 작업을 정의합니다.
  • Interface Adapters: ViewModel, Repository Interface 등이 위치하며, 내부 Use Case와 외부 시스템을 연결하는 역할을 합니다.
  • Frameworks & Drivers (가장 바깥): UIKit, Firebase, TMDB API 등 실제 구현체가 있는 영역입니다.

📌 핵심 개념: 바깥쪽 계층은 안쪽 계층을 사용할 수 있지만, 그 반대는 불가능합니다.


Clean Architecture의 계층 구성

Clean Architecture는 다음과 같은 계층으로 구성됩니다

Presentation Layer   - UI(ViewController, View)
Application Layer    - ViewModel, UseCase
Domain Layer         - Entity, Protocol(Interface)
Data Layer           - RepositoryImpl, Service

각 계층의 역할

계층 책임
Presentation 사용자 인터페이스, 입력 처리 및 상태 표시
Application (UseCase) 도메인 로직을 표현, 한 기능 단위의 작업 흐름을 정의
Domain 핵심 비즈니스 모델 및 Repository 추상화 정의
Data 실제 데이터 소스와 연결, API/DB 작업 수행 (예: FirebaseService)

 

이 구조를 통해 어떤 화면이든 UI → ViewModel → UseCase → Repository → Service 흐름을 따르게 됩니다.


실제 프로젝트에 적용한 사례 - NABAMovie

프로젝트 디렉토리 구조 예시

NABAMovie/
├── App
│   ├── AppDelegate.swift
│   ├── SceneDelegate.swift
│   ├── Coordinator
│   └── Factory
├── Data
│   ├── Network
│   │   ├── DTO
│   │   └── MovieNetworkManager.swift
│   ├── RepositoryImpl
│   └── Service
│       └── FirebaseService.swift
├── Domain
│   ├── Entities
│   ├── Protocols
│   └── UseCases
├── Presentation
│   ├── Login
│   ├── Signup
│   ├── HomeView
│   ├── BookingPage
│   └── MovieListView
├── Resources
├── Utils
└── Extensions

위와 같이 계층별 폴더를 분리하여 책임을 명확히 나눴고, 실제 개발 시 각 계층은 명확히 하나의 방향으로만 의존성을 갖도록 구성하였습니다.

프로젝트 개요

  • 영화 예매 iOS 앱 (UIKit 기반, iOS 16 이상)
  • 영화 검색, 로그인, 회원가입, 찜, 예매 기능 포함
  • 외부 API: TMDB API, Firebase Auth/Firestore 사용

구조 적용 예시

아래는 로그인 기능을 기준으로 Clean Architecture가 실제로 어떻게 구현되었는지에 대한 코드 흐름 예시입니다.

LoginViewController
→ LoginViewModel
→ LoginUseCase
→ UserRepository (Protocol)
→ FirebaseService (실제 구현)

 

1. LoginViewModel.swift

final class LoginViewModel {
    private let loginUseCase: LoginUseCase

    init(loginUseCase: LoginUseCase) {
        self.loginUseCase = loginUseCase
    }

    func login(email: String, password: String) async {
        do {
            let user = try await loginUseCase.execute(email: email, password: password)
            onLoginSuccess?(user.username)
        } catch {
            onLoginError?(error.localizedDescription)
        }
    }
}

2. LoginUseCase.swift

protocol LoginUseCase {
    func execute(email: String, password: String) async throws -> User
}

final class DefaultLoginUseCase: LoginUseCase {
    private let userRepository: UserRepository

    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }

    func execute(email: String, password: String) async throws -> User {
        return try await userRepository.login(email: email, password: password)
    }
}

3. UserRepository.swift (Protocol)

protocol UserRepository {
    func login(email: String, password: String) async throws -> User
}

4. UserRepositoryImpl.swift

final class UserRepositoryImpl: UserRepository {
    private let firebaseService: FirebaseServiceProtocol

    init(firebaseService: FirebaseServiceProtocol) {
        self.firebaseService = firebaseService
    }

    func login(email: String, password: String) async throws -> User {
        return try await firebaseService.login(email: email, password: password)
    }
}

5. FirebaseServiceProtocol.swift

protocol FirebaseServiceProtocol {
    func login(email: String, password: String) async throws -> User
}

6. FirebaseService.swift

final class FirebaseService: FirebaseServiceProtocol {
    func login(email: String, password: String) async throws -> User {
        let result = try await Auth.auth().signIn(withEmail: email, password: password)
        return User(uid: result.user.uid, username: result.user.displayName ?? "")
    }
}

이렇게 계층을 나눔으로써 다음과 같은 이점이 있었습니다

  • TMDB API에서 다른 영화 API로 변경 시 RepositoryImpl만 수정하면 됨
  • Firebase 인증 방식 변경 시 Service만 수정하면 되므로 도메인 로직은 그대로 유지
 

GitHub - Sparta-bootcamp-master-2team/NABAMovie

Contribute to Sparta-bootcamp-master-2team/NABAMovie development by creating an account on GitHub.

github.com

 


왜 Clean Architecture를 적용했는가?

  • Clean Architecture 를 적용해보면서 좋은 아키텍처란 무엇인가? 를 생각해보고 경험해보기 위해

처음에는 구조를 나누는 것이 오히려 비효율적으로 느껴졌지만, 실제로 적용해보면서 다음과 같은 장점을 얻었습니다

  • 기능별 책임이 분리되어 코드가 깔끔해짐
  • 네트워크나 외부 API 변경 시 다른 계층에 영향이 없음

구현 시 느낀 점

계층이 늘어나면서 파일 수도 많아지고 처음엔 관리가 어려웠지만, 기능이 점점 복잡해질수록 구조의 진가가 드러났습니다. 특히 UseCase를 기준으로 기능 단위를 캡슐화할 수 있어, 기능 단위의 변경이 매우 수월해졌습니다.

또한 Repository 인터페이스를 활용해 의존성을 추상화함으로써, 외부 서비스를 바꿔도 내부 비즈니스 로직은 변경하지 않아도 되는 구조를 만들 수 있었습니다.


마무리

Clean Architecture는 처음 도입할 땐 다소 복잡하게 느껴지지만, 일단 구조가 자리를 잡으면 그 어떤 기능도 일관된 패턴으로 개발할 수 있게 됩니다. 특히 팀 프로젝트나 장기적으로 유지보수해야 하는 앱에서 그 효과는 훨씬 더 커진다고 느꼈습니다.

앞으로도 기능을 구현할 때 단순히 "작동하는 코드"를 넘어서 **"구조적으로 명확한 코드"**를 고민하는 습관을 가져가고 싶습니다.

반응형