[ iOS ] 프로젝트에 Clean Architecture를 도입하며 배운 구조적 사고
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는 처음 도입할 땐 다소 복잡하게 느껴지지만, 일단 구조가 자리를 잡으면 그 어떤 기능도 일관된 패턴으로 개발할 수 있게 됩니다. 특히 팀 프로젝트나 장기적으로 유지보수해야 하는 앱에서 그 효과는 훨씬 더 커진다고 느꼈습니다.
앞으로도 기능을 구현할 때 단순히 "작동하는 코드"를 넘어서 **"구조적으로 명확한 코드"**를 고민하는 습관을 가져가고 싶습니다.