새벽의 기록

[iOS] 클린아키텍처 공부할 때 주저리주저리 썼던 거 본문

[iOS]

[iOS] 클린아키텍처 공부할 때 주저리주저리 썼던 거

OneTen 2025. 10. 1. 18:47

1. 클린 아키텍처에 대하여


클린 아키텍처가 뭐지?

수많은 아키텍처들은 전반적으로 관심사 분리테스트 가능성이라는 보편적인 요구를 갖고 있다.
클린 아키텍처는 이러한 요건을 만족하는, 간단히 말하면 추상화 개념으로써 관심사를 분리시키고 의존도를 낮추는 것에 목적을 둔 아키텍처이다.

기본적인 원리는 종속성 규칙을 지키는 것. 
각 코드의 종속성은 외부에서 내부로 안쪽으로만 가리킬 수 있고, 고수준 정책저수준 정책의 변경에 영향을 받지 않도록 하는 것.

고수준 정책은 보통 UI 또는 인터페이스와 거리가 먼 비즈니스 영역 Business Rules, Entities 등을 의미하며,
저수준 정책은 위와 반대로 거리가 가까운 인프라 영역이나 UI 영역 Presentation, Controllers 등을 의미한다.

클린 아키텍처의 의존성은 밖에서 안으로 향하고, 바깥 원은 안쪽 원에 영향을 미치지 않는다. 

경계의 바깥으로 갈수록 덜 중요하고 세부적인 영역으로 표현되며, 안으로 갈수록 고수준(좀더 추상화된 개념)으로 표현된다.
좀더 풀어 설명하면 고수준이 운동을 한다라면, 저수준은 집에서 팔벌려뛰기 운동을 한다 라고 할 수 있다.

 


내부에 위치할수록 고수준 정책이며 외부에 위치할수록 저수준 정책이다.
즉, 원 바깥쪽에서 안쪽으로 의존성을 지녀야 하며 안쪽의 요소들은 바깥쪽 원에게 의존성을 지니지 않고 독립적으로 존재해야 한다.

레이어는 보통 아래 3가지를 고정으로 나누고 프로젝트와 팀마다 유동적으로 분리한다.
(네트워크를 별도로 분리한다던지)

- Domain Layer = Entities + Use Cases + Repositories Interfaces
- Data Layer = Repositories Implementations + API (Network) + Persistence DB
- Presentation Layer = ViewModels + Views

분리한 3가지의 레이어는 아래 사진과 같은 의존성을 지녀야한다.



가장 내부에 위치한 도메인 레이어에게 presentation레이어와 데이터 레이어가 의존성을 지니는 모습이다.
즉, 도메인 레이어는 의존성을 지니지 않고 스스로 독립적으로 존재하는 게 가장 이상적인 모습이라고 할 수 있다.

 

클린 아키텍처를 사용하는 이유는?(목표는)


예를 들어 내가 A 배달 앱의 개발자이며, 어느 날 A 배달 앱이 B 배달 앱과 통합된다고 가정하면, 이때 나는 다음과 같은 요구를 받게 된다.

A 배달 앱 시스템이 잘 되어 있으니 A 앱의 핵심 기능은 유지하고, UI와 DB 쪽만 바꿔 주세요.
또는 다음과 같은 요구를 받을 수도 있다.
A 배달 앱이 너무 잘되니 서비스를 웹으로 확장해 봅시다.

비즈니스 로직은 비슷한데, 변경해야 하는 부분도 많고, 아예 새로 만들 수도 없는 이러한 상황이라면, 매우 난처해질 수 있을 것이다.

만약 클린 아키텍처를 도입했다면, 단순하게 Presentaion 레이어만 수정하면 된다. 
고객과 업체 사이에서 배달 서비스를 중계한다는 비즈니스 로직은 변하지 않았기 때문이다. 

이와 같이 클린 아키텍처는 비즈니스 로직은 바꾸지 않으면서, 언제든 DB와 프레임워크에 구애 받지 않고 교체할 수 있는 아키텍처인 셈이다.

 


그래서 너는 왜 이 프로젝트에 클린 아키텍처를 접목하고 싶은데?(어디서 불편을 느꼈는데?)


현 프로젝트(CERTI)에서는 도메인 레이어와 데이터 레이어가 Network 폴더에 모두 포함되어 있다.
또한, Network에서 Entity와 DTO를 분리하지 않고 DTO 하나로 UI를 그리는 데 사용하고 있는 뷰도 있다.
즉, 네트워크단에서 DTO에 변경이 생기거나 UI에서 보여줘야 하는 요소가 바뀌게 되면 Data, Domain, Presentation 레이어 모두 변경이 필요해지는 상황이 초래될 수 있다.

또한 ViewModel이 비즈니스 로직, 네트워크 요청, Keychain 요청, Coordinator로부터 이벤트 결과를 전달받는 등 매우 많은 역할을 하고 있다. 이 역할을 단일한 작업을 하는 클래스들로 분리하고자 하는것이 목표다.

현 프로젝트에서 하나의 예시를 들어 설명하자면, 

HomeViewModel의 추천 자격증 호출 로직

 

RecommendViewModel의 추천 자격증 호출 로직



네트워크에서 추천자격증을 받아오는 DTO 파일



ViewModel 간 중복 로직


- HomeViewModel.getRecommendCertificationList와 RecommendViewModel.getRecommendCertificationList는 
네트워크 호출 → DTO 변환 → 상태 반영 흐름이 거의 동일함.
- 차이는 단지 변환 메서드(toRecommendLicenseCardModel vs toLicenseCardModel)뿐.
- 이런 경우, 나중에 API 스펙이 바뀌면 두 ViewModel 모두 수정해야 함 → 유지보수 비용 증가.


DTO가 Presentation Layer와 결합


- RecommendCertification(DTO)가 UI Model 변환 메서드(toRecommendLicenseCardModel, toLicenseCardModel)를 직접 가지고 있음.
- DTO는 Data Layer의 객체인데, Presentation Layer(뷰모델에서 쓰는 UI 모델)로 직접 변환한다는 건 레이어 침범.
- 이렇게 되면 Data Layer 변경이 곧바로 Presentation Layer에 영향 
클린 아키텍처의 계층 독립성이 깨짐. 
→ 왜냐하면 Presentation은 Domain에만 의존성을 지니고 있어야 하기 때문, 그런데 지금 Data가 변경되었다고 Presentation도 변경되어야 하는 상황이 유발될 수 있음


Model 변환 책임이 여기저기 흩어짐


- 지금은 DTO → UI Model 변환이 DTO 내부 extension에 있지만, 어떤 ViewModel은 다른 모델 변환 규칙을 또 들고 있을 수도 있음.
- 변환 규칙이 분산되면 일관성 유지가 어려워짐.

문제 예시


서버에서 필드명이 바뀔 경우

{
    "id": 1,
    "name": "정보처리기사",
    "type": "국가기술",
    "examType": "필기",
    "labels": ["IT", "프로그래밍"],
    "score": 95,
    "favorite": true
}


- certificationId → id
- certificationName → name
- tags → labels
- recommendationScore → score
- isFavorite → favorite


(1) DTO를 수정해야함

struct RecommendCertification {
    let id: Int // 변경
    let name: String // 변경
    let certificationType: String
    let testType: String
    let labels: [String] // 변경
    let score: Int // 변경
    let favorite: Bool // 변경
}



(2) toRecommendLicenseCardModel() 수정

func toRecommendLicenseCardModel() -> RecommendLicenseCardModel {
    RecommendLicenseCardModel(
        id: id,
        licenseName: name,
        recommendScore: score,
        tagChip: labels
    )
}



(3) toLicenseCardModel()도 수정

func toLicenseCardModel() -> LicenseCardModel {
    LicenseCardModel(
        certificationId: id,
        certificationName: name,
        certificationType: certificationType,
        tags: labels,
        testType: testType,
        isFavorite: favorite
    )
}



결과적으로

- DTO 변경 → DTO 안의 두 변환 메서드 모두 수정
- 변환 메서드 내부에서 필드명 변경 사항 반영
- 이 과정에서 HomeViewModel, RecommendViewModel 양쪽이 영향을 받음
    
    (심지어 변환 로직이 두 ViewModel 각각에서 추가로 가공하고 있다면 거기도 수정 필요)
    
변환 로직이 DTO 내부에 박혀 있어, DTO 필드 변경 시 모든 변환 메서드를 손으로 찾아서 수정해야 함.
→ 변환 메서드가 많아질수록 수정 범위가 기하급수적으로 커짐.

→ ViewModel이 DTO 변환에 직접 의존하므로, DTO 변경이 곧 ViewModel 변경으로 이어짐.
(클린 아키텍처에서 말하는 계층 독립성 완전히 붕괴)

 

더보기

왜 독립성이 붕괴됨?
    - DTO (RecommendCertification)는 Data 레이어에 속함. (API 응답 형식에 1:1 매칭되는, 외부 시스템과 맞추기 위한 모델)
    - ViewModel(Presentation)은 이 DTO를 직접 사용 + 변환 메서드(toRecommendLicenseCardModel)를 DTO에 붙여놓음.
    - 결과적으로:
        1. API 스펙 변경 → DTO 변경
        2. DTO 변경 → ViewModel 변경
            
 → Data → Presentation 방향의 의존성 발생 (Clean Architecture 원칙 위반)


---

2. domain은 무엇을 전담하나?


비즈니스 로직을 관리하는 계층이다. 즉, 최대한 변경을 지양해야 하는 Layer.
또한 어디에도 의존성을 지니지 않기에 완전히 격리되어 있는 상태이고, 다른 프로젝트에도 재사용될 수 있어야 한다.
보편적으로 Entities + Use Cases + Repositories Interfaces 로 구성됨.

---

 

3. data는 무엇을 전담하나?


Repositories Implementation(구체타입), API(Network), Persistence DB등이 여기에 속한다.
(Repository Interface는 Data Layer가 아닌 Domain Layer에 속한다.)

더보기

- Repository Interface와 Implementation 차이(gpt)
    
    1. Repository Interface
    
    - Domain Layer에 위치
    - 앱에서 “데이터를 어떻게 가져올지”가 아니라 “무슨 데이터를 가져올 수 있는지”만 선언
    - UI나 비즈니스 로직이 데이터 저장소 세부사항을 알지 않도록 하는 계약서 역할
    - Data Layer의 구체적인 구현과 Presentation Layer의 ViewModel을 분리해줌
    
    ```
    // Domain Layer
    protocol CertificationRepository {
        func getRecommended() async -> [RecommendCertificationEntity]
        func getFavorites() async -> [RecommendCertificationEntity]
        func toggleFavorite(certificationId: Int) async
    }
    ```
    
    - 여기서는 API 쓸 건지, CoreData 쓸 건지, 로컬 캐시 쓸 건지 같은 건 전혀 모름
    - ViewModel은 이 인터페이스만 의존 → 데이터 소스 변경 시 ViewModel은 수정 안 해도 됨
    
    ---
    
    2. Repository Implementation
    
    - Data Layer에 위치
    - Repository Interface를 실제로 구현
    - API 호출, DB 쿼리, 파일 읽기 등 데이터를 가져오는 구체적인 방법을 여기서 작성
    - DTO ↔ Entity 변환도 여기서 담당
    
    ```
    // Data Layer
    final class CertificationRepositoryImpl: CertificationRepository {
        private let api: CertificationAPI
    
        init(api: CertificationAPI) {
            self.api = api
        }
    
        func getRecommended() async -> [RecommendCertificationEntity] {
            let dtoList = await api.getRecommend()
            return dtoList.map { $0.toEntity() }
        }
    
        func getFavorites() async -> [RecommendCertificationEntity] {
            let dtoList = await api.getFavorites()
            return dtoList.map { $0.toEntity() }
        }
    
        func toggleFavorite(certificationId: Int) async {
            await api.toggleFavorite(id: certificationId)
        }
    }
    ```
    
    ---
    
    📌 핵심 차이 정리
    
    | 항목 | Repository Interface | Repository Implementation |
    | --- | --- | --- |
    | 위치 | Domain Layer | Data Layer |
    | 내용 | “어떤 기능을 제공할지” 정의 (계약서) | “그 기능을 어떻게 구현할지” 정의 |
    | 목적 | Presentation이 Data 세부사항을 몰라도 되게 함 | Interface에서 정의한 기능을 실제로 수행 |
    | 변경 영향 | 거의 없음 (자주 변하지 않음) | 데이터 소스(API, DB) 변경 시 수정됨 |
    | 의존성 방향 | Presentation → Domain | Data → Domain |
    
    ---
    
    📌 왜 이렇게 나누나?
    
    이렇게 나누면 의존성 역전이 완성돼서:
    
    - API → 로컬 DB로 바꿔도 ViewModel은 전혀 건드릴 필요 없음
    - 테스트 시 Mock Repository를 만들어서 실제 네트워크 없이 테스트 가능
    
    ```
    final class MockCertificationRepository: CertificationRepository {
        func getRecommended() async -> [RecommendCertificationEntity] {
            return [.init(id: 1, name: "Mock License", score: 100, tags: ["Test"])]
        }
        ...
    }
    ```
    

Data Source는 Network나 Local DB등이 해당한다. (Server, CoreData, Realm, Keychain..)

---

4. presentation은 무엇을 전담하나?


화면에 보이는 영역을 담당한다.
MVVM-C에서는 ViewView Model, Coordinator가 여기에 해당한다

---

 

 

아직 프로젝트에 클린 아키텍처를 도입하기 전, 이론 공부만 하던 시기에 적은 글으로

지금과는 생각이 다른 부분도 많이 있고 읽다보니 틀린 부분도 몇 보이는 것 같다.

어디까지나 개인 기록용이니 혹시 참고하더라도 과신금지!

 

 

Comments