클린 아키텍처 적용기 - 1

문인범·2024년 12월 17일

Macro 챌린지

목록 보기
1/6
post-thumbnail

아키텍처 적용기

아카데미에서 제법 규모가 있는 팀에서 챌린지를 진행 중에 있습니다.
해봤자 7명이긴 하지만 여기 아카데미 안에서는 생각보다 큰 팀입니다.

처음에는 기능 구현에 중점을 두어 개발을 진행했습니다.
그래서 사실 딱히 아키텍처의 중요성을 생각하지 못했습니다.

하지만 1차 출시가 이루어지고 우리 팀이 짜놓았던 코드를 서로 설명하는 기회를 가지게 되었는데 이 때 서로 코드들이 엉켜있다 보니 이해하는데에 어려움이 들었습니다.

또한 기능 구현에만 중점을 두다 보니 하나의 뷰모델에 다 때려 넣어 버리는 아주 매시브한 괴물 뷰모델이 탄생하기도 했습니다.
그래서 이 뷰모델에서 내가 사용하는 프로퍼티를 찾기도 힘들어졌습니다.
이때가 되어서야 틀이 중요하구나를 깨닫게 되었고 적용해보기로 마음 먹었습니다.

지금 우리 프로젝트 뷰 모델의 상태

찾아보니 다양한 아키텍처를 사용할 수 있었는데 이번에 사용한 것은
클린 아키텍처를 적용했습니다!

그 이유는 일단

  1. 가장 인기가 많다.
  2. 그러므로 레퍼런스 자료가 많다.
  3. 현재까지도 사용되고 있는 것을 보면 아직 현역이다.
  4. 왠지 모르게 취업에 도움이 될 것 같다.

뭐 크게 중요한 이유들은 없지만 가장 대중적인 것을 일단 맛보는 것이 좋지 않겠나? 생각이 들어서 선택하게 되었습니다.

제가 직접 공부해 본 클린 아키텍처에 대한 정보를 정리해보고 어떻게 우리 프로젝트에 적용했는지 작성하겠습니다.

그렇다면 클린 아키텍처는 무엇일까요?
(개인적으로 공부해보고 적은 내용이라 틀릴 수 있다는 점 양해 부탁드립니다😅)

클린 아키텍처란?

최근 소프트웨어들은 유지보수성(Maintainability), 테스트 가능성(Testability), 관심의 분리(Seperation of Concerns) 들이 보편적으로 적용되고 있습니다.

이런 가치들을 지키기 위한 틀 중 하나가 클린 아키텍처인 것입니다.

이런 틀을 지켜 소프트웨어 시스템을 보다 관리 가능하고 유지보수가 가능한 형태로 구축하게 하는 것에 목적이 있습니다.

클린 아키텍처에서는 이러한 목적을 달성하기 위해서 의존성 규칙(Dependency Rule)을 지키는 것에서 출발합니다.
사실 이게 클린 아키텍처가 원하는 알파이자 오메가라고 할 수 있습니다.
클린 아키텍처에서는 모듈의 의존성이 단방향으로 흐르도록 해야 한다는 것입니다.

이렇게 구조적으로 코드를 짬으로써 위에서 말한 이점을 얻을 수 있다고 합니다.


아키텍처 vs 디자인 패턴

이러한 아키텍처와 디자인 패턴 용어를 보통 혼용해서 많이 사용하는데 어느정도 차이점이 있다고 볼 수 있습니다.
아키텍처는 디자인 패턴보단 더 큰 다양한 서비스에 적용될 수 있는 방법론 적인 것을 뜻할 수 있습니다.
그리고 디자인 패턴은 이런 아키텍처를 적용하기 위한 하나의 수단이라고 할 수 있습니다.

예를 들어서 클린 아키텍처와 MVVM 패턴은 같은 선상에서 비교되는 대상이 아닙니다.
MVVM 패턴은 클린 아키텍처를 이루기 위한 수단 중 하나인 것입니다.
이와 더불어 Repository 패턴이나 UseCase 패턴 또한 클린 아키텍처를 이루기 위한 방법인 것이라고 볼 수 있습니다!


의존성이란?

하나의 클래스가 다른 클래스를 참조하고 있으면 의존관계를 갖고 있다고 봅니다.

class Animal {
	let cat: Cat
}

class Cat {}

여기서 Animal 클래스는 Cat 클래스를 가지고 있습니다.
그렇다면 Animal 클래스는 Cat 클래스를 의존한다고 합니다.

이렇게 의존관계가 형성되면 Animal 클래스는 Cat 클래스의 영향을 받게 됩니다.
쉽게 말하면 Cat 클래스가 변하게 되면 그 여파가 Animal 클래스 에게도 오게 된다는 것입니다.

Animal 클래스가 Cat 클래스를 의존하게 되는 것입니다. 종속되는 것 입니다.

이런 메소드가 있다고 한다면

class Animal {
	let cat: Cat
		
	func getCatOne() -> Int {
		return getOne()
	}
}

class Cat {
	func getOne() -> Int {
		return 1
	}
}

여기서 Cat의 메소드의 반환 타입을 변경하게 되면

class Animal {
	let cat: Cat
		
	func getCatOne() -> Int { // error 발생
		return getOne()
	}
}

class Cat {
	func getOne() -> String {
		return 1
	}
}

분명 Cat 클래스를 변경했음에도 불구하고 Animal 클래스에도 그 여파가 발생하게 됩니다.

위 예시처럼 간단한 클래스를 의존하는 경우에는 쉽게 고칠 수 있겠지만

구체적인 클래스(Concrete Class)를 의존하는 클래스일 경우 그 여파가 더욱 크게 올 것 입니다.

이런 문제를 해결하기 위해서는 의존성 역전(Dependency Inversion)을 해야 합니다.
말 그대로 의존성을 반대로 만드는 것 입니다.

그걸 가능하게 만들어 주는 것이 인터페이스(Interface)입니다.
스위프트에서는 프로토콜(protocol)로 사용이 가능합니다.
위 클래스를 반대로 의존해버리면 내가 원하는 기능이 제대로 구현되지 않을까 생각이 드네요.

예를 들어 아래와 같은 클래스가 있다고 한다면

class FoodListViewModel: ObservableObject {
    @Published var foods: [String] = []
    
    private let koreanFoodsService: KoreanFoodsService
    private let chineseFoodsService: ChineseFoodsService
    
    init(koreanFoodsService: KoreanFoodsService, chineseFoodsService: ChineseFoodsService) {
        self.koreanFoodsService = koreanFoodsService
        self.chineseFoodsService = chineseFoodsService
    }
    
    func getKoreanFoods() {
        self.foods = koreanFoodsService.fetchFoods()
    }
    
    func getChineseFoods() {
        self.foods = chineseFoodsService.fetchFoods()
    }
}

class ChineseFoodsService {
    func fetchFoods() -> [String] {
        // 서버에서 받아오는 메소드
        return ["짜장면", "짬뽕", "탕수육", "라조기"]
    }
}

class KoreanFoodsService {
    func fetchFoods() -> [String] {
        // 서버에서 받아오는 메소드
        return ["제육", "국밥", "칼국수", "김치찌개"]
    }
}

만약에 여기서 서버에서 받아오는 음식의 데이터가 추가가 된다면 받아서 사용하게 되는 ViewModel 또한 수정이 들어가게 됩니다.
서버의 모델 변경이 뷰 끝단 까지 영향을 주는 모습입니다.

이런 문제를 해결하기 위해 의존성 역전을 시켜보겠습니다.

하는 방법은 간단합니다. ViewModelFoodsService 사이에 protocol을 두면 되는 것 입니다.

class FoodListViewModel: ObservableObject {
    @Published var foods: [String] = []
    
    private let foodsRepository: FoodsRepository
    
    init() {
        
    }
    
    func getFoods() {
        self.foods = foodsRepository.fetchFoods()
    }
}

protocol FoodsRepository {
    func fetchFoods() -> [String]
}

class ChineseFoodsService: FoodsRepository {
    func fetchFoods() -> [String] {
        // 서버에서 받아오는 메소드
        return ["짜장면", "짬뽕", "탕수육", "라조기"]
    }
}

class KoreanFoodsService: FoodsRepository {
    func fetchFoods() -> [String] {
        // 서버에서 받아오는 메소드
        return ["제육", "국밥", "칼국수", "김치찌개"]
    }
}

이렇게 되면 FoodListViewModel은 클래스에 의존하지 않게 되고 Interface에 의존하게 됩니다.

해당 Interface를 의존함에 따라서 fetchFoods() 를 통해 음식 리스트를 가져오는 것’ 만 알면 되는 것 입니다.
안에서 어떻게 불러오는지는 알지 않아도 되는 것 입니다.

이렇게 되면 실제로 데이터를 불러오는 KoreanFoodsService에서 수정이 생기더라도 FoodListViewModel까지 전파가 되지 않으므로 FoodListViewModel을 수정할 일이 사라지게 됩니다.

이러한 방식으로 의존성 역전을 하게 되고 해당 방식이 클린 아키텍처에 핵심이라고 할 수 있습니다.

클린 아키텍처의 계층

클린 아키텍처에 핵심은 안 쪽의 원이 바깥쪽에 의존성을 가지면 안된다는 것 입니다. 오직 바깥에서 안으로 의존이 되어야 합니다.

쉽게 말해 안쪽의 원은 바깥쪽의 원에 존재를 몰라야 한다는 것 입니다.

위의 원을 그룹핑하면 3개의 계층으로 나눌 수 있습니다.

Presentation Layer

단어 뜻대로 보여지는 영역을 담당하고 있습니다.
해당 영역은 Views(UI)ViewModels(Presenters)를 포함하고 있습니다.

여기서 UseCase를 실행하게 됩니다.
해당 계층은 반드시 도메인 계층만을 의존해야 합니다.

Domain Layer

다른 계층으로부터 고립되어 다른 영역의 의존성을 갖고 있지 않는 영역 입니다.
해당 영역은 Entity(Business Models), Use Case, Repository Interface를 포함합니다.

이렇게 도메인 영역을 분리함으로써 앱을 빌드하지 않아도 단위 테스트를 가능하게 해 줍니다. 도메인 영역은 다른 계층에 의존성을 갖고 있지 않기 때문에 다른 것 들을 구현하지 않아도 따로 굴릴 수 있기 때문입니다!

이 Use Case를 중심으로 구조를 짜 나가는게 핵심이라고 합니다.

Data Layer

해당 영역은 Repository Implementations, Data Source(API or Persistent DB) 들이 포함되어 있습니다.

Repository가 Data source에서 데이터를 가져 오는 역할을 하고 또한 JSON 데이터를 맵핑하는 것 또한 진행할 수 있습니다. (Json을 decode하여 도메인 영역에 전달)

해당 영역은 반드시 도메인 영역만을 의존해야 합니다.

데이터 흐름

  1. 뷰에서 뷰모델의 메소드를 호출한다.
  2. 뷰 모델에서 유즈 케이스를 실행한다.
  3. 유즈 케이스에서 유저와 레포지토리로 부터 데이터를 합친다.
  4. 각각의 레포지토리에서 데이터를 불러 온다(from Remote Data, Persistent DB, In-memory Data etc.)
  5. 받은 데이터는 뷰까지 반대 방향으로 흐르게 된다.

Use Case

어플리케이션의 핵심 비즈니스 로직이 담겨져 있는 곳 입니다.
프레젠터나 뷰 모델로 부터 받은 요청을 처리하고 레포지토리에서 불러온 데이터를 가공 및 반환 합니다.

사실상 유즈 케이스에서 핵심 기능들이 작동한다고 볼 수 있습니다.
위에 사진에서 볼 수 있다 싶이 유즈 케이스는 원 제일 안쪽에 있으며 어떤 레이어도 의존을 하고 있지 않습니다.
이러한 특성이 유닛 테스트를 더 하기 쉽게 해주며 만약에 API가 업데이트 되어도 Use Case 선에서 정리할 수 있는 것 입니다.

저의 개인적인 생각은 도메인 주도 개발이 가능하다는 장점이 있다고 봅니다.

실제 프로젝트에서는 다양한 기능들이 많이 들어가고 사라지게 되는데 이런 것들을 UseCase로 분리 함으로써 쉽게 기능들을 추가하고 테스트 까지 할 수 있게 하기 때문에 프로세스를 더욱 애자일 하게 진행하게 만든다고 생각합니다.

Repository

데이터와 관련된 정보들이 모여있는 곳 입니다.
외부 API에서 데이터를 요청하고 받아오고, 또한 iOS에서는 내부 DB인 CoreData라던지 아니면 서드파티 DB등등 다양한 데이터를 처리하는 레이어 입니다.

UI(뷰)에서 처리하지 않고 따로 존재하는 해당 레이어에서만 데이터를 처리하기 때문에 다양한 계층에서 들고와 사용할 수 도 있고 하나의 레포지토리를 통해 다양한 뷰에 동일한 정보를 보낼 수 도 있습니다.

마무리

이렇게 프로젝트에 리팩토링을 적용해보기 전 클린 아키텍처가 어떤 것인지에 대해 공부를 해봤습니다!

다음 글에는 이를 바탕으로 어떻게 프로젝트에 적용했는지 적어보겠습니다!!

profile
월클 개발자를 향한 도전일지

0개의 댓글