“클린 아키텍처: 소프트웨어 구조와 설계의 원칙” 책을 바탕으로 정리를 진행했습니다. 제가 이해한 바를 정리하고 싶어서 적은 포스팅이라 책에 있는 내용과는 맞지 않거나 오류가 있을 수 있습니다. 더 많은 내용을 보고 싶으신 분들은 책을 직접 읽으시는걸 추천드립니다.
Clean Architecture - 3부 - 설계 원칙(SRP, OCP), Clean Architecture - 3부 - 설계 원칙(LSP, ISP) 에서 이전 내용을 보실 수 있습니다.
SOLID
좋은 소프트웨어 시스템은 깔끔한 코드로부터 시작합니다. 좋은 벽돌로 좋은 아키텍처를 만들어야 한다는 겁니다.
그러기 위해선 좋은 아키텍처를 정의하는 원칙이 필요했습니다. 그게 바로 SOLID 입니다.
SOLID는 함수와 데이터 구조를 클래스로 배치하는 방법과 이들 클래스를 서로 결합하는 방법을 정의합니다.
SOLID는 ⓵변경에 유연하고 ⓶이해하기 쉬우며 ⓷많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 되는 소프트웨어 구조를 만드는데 도움이 됩니다.
SOLID는 좋은 아키텍처를 정의하는 5가지 원칙을 나타냅니다.
•
단일 책임 원칙: Single Responsibility Principle(SRP)
•
개방-폐쇄 원칙: Open-Closed Principle(OCP)
•
리스코프 치환 원칙: Liskov Substitution Principle(LSP)
•
인터페이스 분리 원칙: Interface Segregation Principle(ISP)
•
의존성 역전 원칙: Dependency Inversion Principle(DIP)
이번 포스팅에서는 설계 원칙 중에서도 DIP를 다뤄보려고 합니다.
의존성 역전 원칙: Dependency Inversion Principle
의존성 역전을 통해서 “유연성이 극대화된 시스템”을 만들 수 있습니다.
“유연성이 극대화된 시스템”이란 소스 코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템을 말합니다.
정적 타입 언어는 오직 인터페이스, 추상 클래스같은 추상적인 선언만 참조해야 합니다. 구체적인 대상에는 절대로 의존해서는 안됩니다. 우리는 변동성이 큰 구체적인 요소를 의존하지 않도록 피해야 합니다. 변동성이 큰 구체적인 요소는 우리가 열심히 개발하는 중이라서 자주 변경될 수 밖에 없는 모듈들입니다.
개발 중에 인터페이스에서 변경이 발생하면 우리는 구현체를 수정해야 합니다. 반대로 구현체에서 변경이 발생하면 인터페이스를 따라서 수정하는 경우는 적습니다. 즉, 인터페이스가 구현체보다 변동성이 낮습니다.
안정된 소프트웨어 아키텍처는 변동성이 큰 구현체에 의존하는 일을 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처를 말합니다.
Dependency Inversion를 통해서 얻을 수 있는건 뭔가요?
1.
DI를 통해서 Coupling를 줄일 수 있습니다.
예를 들어서, ViewModel이 CoreDataService에 의존하고 있다고 해봅시다.
ViewModel이 CoreDataService의 구현에 크게 의존하고 있다면, 강한 결합이 생겨 코드를 변경하기 어렵게 만듭니다. CoreDataService에서 RealmService로 Service를 변경하게 된다면 ViewModel은 CoreDataService에 크게 의존하고 있었기에 문제가 발생할 겁니다.
따라서, 우리는 ViewModel이 구체에 의존하지 않고 추상에 의존하도록 만들어 줘야 합니다.
CoreDataService가 아닌 DatabaseService라는 프로토콜을 사용하는 겁니다. 프로토콜은 클래스보다 구체적이지 않기 때문에 DatabaseService를 준수하는 모든 구체적인 클래스들을 Service로 사용할 수 있게 됩니다. Coupling이 줄어들게 되는 겁니다.
2.
DI를 통해서 명확해집니다.
Dependency Injection을 통해서 클래스, 구조체의 책임과 요구사항이 명확해집니다.
A클래스를 B클래스에 삽입하면 B클래스가 A클래스에 의존한다는 것을 알 수 있습니다. B클래스가 A클래스를 관리하고 A클래스 내부 메서드나 속성을 다룬다는 걸 알 수 있습니다.
3.
DI를 통해서 Testing이 쉬워집니다.
Unit Test가 훨씬 쉬워집니다.
의존성 주입을 통해서 클래스의 종속성을 mock object로 쉽게 대체 가능하기 때문이죠. mock object는 프로토콜만 준수하면 됩니다.
4.
DI를 통해서 관심사가 분리됩니다.
의존성을 주입하게 되면 어떻게 인스턴스화하는 지에 대해서 알 필요가 없어집니다. 인스턴스화가 아닌 해당 Protocol의 동작에 관심이 많아 집니다.
Dependency Inversion를 위해서 우리가 실천할 수 있는 것은 무엇이 있을까요?
1.
변동성이 큰 구체 클래스를 참조하지 말아야 합니다.
위에서도 얘기했지만 유연성이 극대화된 시스템을 만들기 위해서는 소스 코드의 의존성이 추상에 의존하며 구체에 의존하지 않아야 합니다. 구체 클래스보다는 추상 인터페이스를 참조해야 합니다.
이 방식은 객체 생성 방식을 강하게 제약합니다. 일반적으로는 추상 팩토리를 사용하도록 강제합니다.
2.
변동성이 큰 구체 클래스로부터 파생하지 말아야 합니다.
상속은 소스 코드에 존재하는 모든 관계 중에 가장 강력하면서도 뻣뻣합니다. 즉, 변경하기 어렵습니다.
따라서, 상속을 사용하려면 신중하게 생각한 후에 사용해야 합니다.
3.
구체 함수를 오버라이드하지 말아야 합니다.
구체 함수는 소스 코드 의존성을 필요로 합니다. 구체 함수를 오버라이드하게 되면 그 의존성을 상속받게 됩니다.
의존성을 제거하기 위해서는 추상 함수로 선언하고 구현체의 용도 맞게 구현하도록 해야 합니다.
4.
구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말아야 합니다.
Dependency Inversion를 실천할 수 있는 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 합니다. 모든 언어에서 객체 생성 시에 객체를 구체적으로 정의한 코드를 작성합니다. 해당 코드에 의해서 소스 코드 의존성이 발생하게 됩니다.
이 의존성을 처리하기 위해서는 추상 팩토리를 사용해야 합니다.
추상 팩토리(Abstract Factory)
추상 팩토리는 구체적인 클래스를 지정하지 않고 관련성을 갖는 객체들의 집합을 생성하거나 서로 독립적인 객체들의 집합을 생성할 수 있는 인터페이스를 제공하는 디자인 패턴입니다.
클린 아키텍처에 있는 추상 팩토리 예시를 첨부했습니다.
Application은 Service Interface를 통해서 ConcreteImpl를 사용하고 있습니다. 그러기 위해서는 Application에서는 어떤 식으로든 ConcreteImpl 인스턴스를 생성해야 합니다.
하지만, Application 내부에 ConcreteImpl 인스턴스를 생성하는 것은 좋지 않은 방법입니다.
따라서, 인스턴스를 대신 생성해줄 ServiceFactory Interface를 생성합니다. ServiceFactory에 있는makeService 메서드를 호출해서 Service 인스턴스를 대신 생성할 수 있습니다.
하지만, ServiceFactory는 Interface이기 때문에 구현체가 필요합니다. 구현체 역할을 하는 건 ServiceFactoryImpl입니다. 구현체에서 ConcreteImpl 인스턴스를 생성한 후에 Service 타입으로 반환해줍니다.
이렇게 하면 소스 코드의 의존성이 모두 추상적인 쪽을 향하게 됩니다. 현재 위에 있는 다이어그램을 봐도 화살표가 추상적인 인터페이스 쪽으로 향하는 걸 볼 수 있습니다. 소스 코드 의존성이 제어 흐름과는 반대 방향으로 역전되는 겁니다.
ServiceFactoryImpl에서 ConcreteImpl 인스턴스를 생성한 후에 Service 타입으로 반환해야 했기에 구체 클래스에 의존하는 모습을 볼 수 있습니다. 이는 DIP를 위배하는 행위입니다.
하지만, DIP를 위배하는 ServiceFactoryImpl안에 구체 컴포넌트가 모이기 때문에 나머지 부분과 분리할 수 있습니다. 구체 컴포넌트들은 ServiceFactory에서만 생성할 수 있게 되는겁니다.
의존성 주입(Dependency Injection)
Dependency Inversion이 어떤 장점이 있고, 어떤 구조를 가져야 제어 흐름과 소스 코드 의존성이 반대가 되는지도 봤습니다. 하지만, 어떤 식으로 의존성을 주입해줄 수 있는지는 아직 얘기가 안된거 같네요.
의존성을 주입하는 방식은 여러 가지가 있습니다.
1.
Initializer Injection
초기화 단계에서 종속성을 전달하는 방식입니다.
초기화 단계에서 인수를 전달해야 하기 때문에 종속성을 전달받는 개체에 종속성이 무엇인지 인지하기 좋습니다. 또한, 전달된 종속성을 let으로 받아서 불변으로 만들 수 있습니다.
protocol ViewModel { }
class ViewModelImpl: ViewModel {
// ...
}
class ViewController {
private let viewModel: ViewModel
override init(viewModel: ViewModel) {
self.viewModel = viewModel
}
}
let viewController = ViewController(viewModel: ViewModelImpl())
Swift
복사
위의 예시처럼 인수의 타입을 프로토콜로 해두게 되면 호환 가능한 모든 구현체가 ViewController의 ViewModel 자리에 들어올 수 있게 됩니다. 이후에 Unit Test를 진행할 때에 ViewModel 프로토콜을 준수하는 Mock 객체를 만든다면 ViewController에 넣어서 사용할 수 있게 됩니다.
2.
Property Injection
Public한 속성을 선언해서 해당 속성에 종속성을 주입하는 방식입니다.
속성에다가 종속성을 주입하는 것이기에 편리하지만 종속성을 수정하거나 대체 가능하다는 문제가 있습니다. 의존성은 불변해야 하는데, 중간에 바뀌는 부분이 생길 수도 있다는 겁니다.
class ViewController {
var viewModel: ViewModel?
}
let viewController = ViewController()
viewController.viewModel = ViewModelImpl()
Swift
복사
또한, 종속성이 옵셔널로 되어 있기 때문에 필수 종속성임에도 불구하고 종속성 주입을 잊을 수도 있습니다.
하지만, Storyboard를 사용하는 경우에는 초기화 단계에서 종속성 주입을 해줄 수 없습니다. 따라서, 차선책으로 Property Injection이나 Method Injection 같은 방식을 사용해주어야 합니다.
3.
Method Injection
Setter 메서드를 사용해서 의존성을 주입하는 방식입니다.
해당 방식은 set 메서드를 만들어서 매개 변수로 종속성을 받아서 설정해줍니다. 해당 방식도 Property Injection처럼 필수로 구현해야 하는 사항이 아니기 때문에, 필수 종속성 주입을 잊기 쉽습니다.
class ViewController {
private var viewModel: ViewModel?
func setViewModel(_ viewModel: ViewModel) {
self.viewModel = viewModel
}
}
let viewController = ViewController()
let viewModel = ViewModelImpl()
viewController.setViewModel(viewModel)
Swift
복사
어떤 방식이 좋고, 어떤 방식이 나쁘다는 건 없습니다. 사용 사례를 고려해서 어떤 타입으로 종속성 주입을 하는 것이 가장 적합한지 결정해서 사용하면 됩니다.
Dependency Injection Container
의존성 주입 컨테이너는 모든 종속성을 저장하고 관리하는 개체입니다. 컨테이너는 종속성을 처리하는 책임을 가지고 있습니다.
Container에는 두 가지 주요한 기능이 있습니다.
•
register : 주입할 타입을 등록합니다.
•
resolve : 해당 의존성 개체를 주입할 곳에 넣어줍니다.
protocol DependencyInjectable {
func register<Service>(type: Service.Type, service: Any)
func resolve<Service>(type: Service.Type) -> Service?
}
Swift
복사
DependencyInjectable를 준수하는 Container 클래스를 만들어 줍니다.
해당 클래스는 인스턴스를 강제로 사용하고 예측 불가능한 동작이 발생하는 것을 방지하기 위해서 Singleton 패턴으로 만들어 주겠습니다.
final class Container: DependencyInjectable {
static let shared = Container()
private init() {}
var services: [String: Any] = [:]
func register<Service>(type: Service.Type, service: Any) {
self.services["\(type)"] = service
}
func resolve<Service>(type: Service.Type) -> Service? {
return services["\(type)"] as? Service
}
}
Swift
복사
종속성을 저장하고 관리할 Container는 다 만들었습니다.
Container를 사용하지 않고 초기화 단계에서 종속성을 주입하는 예시가 있습니다. 해당 예시를 Container를 사용하는 방법으로 바꿔보겠습니다.
protocol DatabaseService {
func fetchUsers()
}
final class CoreDataService: DatabaseService {
func fetchUsers() {
// ...
}
}
final class ViewModel {
private let service: DatabaseService
init(service: DatabaseService) {
self.service = service
}
}
Swift
복사
Container를 사용해서 init 매개 변수에 기본값을 설정해줄 수 있습니다.
final class ViewModel {
private let service: DatabaseService
init(service: DatabaseService = Container.shared.resolve(type: CoreDataService.self)!) {
self.service = service
}
}
Swift
복사
이런 식으로 테스트 목적의 종속성을 지정할 수도 있습니다. Container에 mock 종속성만 등록한다면 가능합니다. 지금보니 종속성 등록 부분이 빠져 있습니다. 종속성 등록은 ViewModel를 사용하기 전에 해주면 됩니다.
let container = Container.shared
container.register(type: CoreDataService.self, service: CoreDataService())
container.register(type: RealmService.self, service: RealmService())
Swift
복사
종속성이 등록되었기 때문에 ViewModel에서는 해당 종속성을 resolve메서드를 통해서 사용할 수 있게 됩니다.