안녕하세요 K입니다. 오늘은 의존 관계 역전 원칙(DIP,Dependency Inversion Principle)에 대해 알아보겠습니다. DIP에 대해 많이 들어보셨나요? 저는 이번에 Unit Test 코드를 작성 중 MockURLSession 테스트 코드를 작성하면서 이 개념을 만났는데요, 쉽게 이해가 안되기도 하고, 굉장히 중요한 개념이기도 해서 이번 기회에 제대로 공부해보았습니다.
의존 관계 역전 원칙(DIP,Dependency Inversion Principle)이 필요한 이유
우선, 의존 관계 역전 원칙(이하 DIP)을 공부하기 전에 제 성향상 이 개념이 필요한 이유와 상황부터 알아야 하는 지라 이것부터 알아보겠습니다. (특정 개념을 공부하면서 그 이유와 목적을 모른다는 것은 말이 안되겠죠?) 도대체 왜 DIP가 필요한 것일까요?
저는 URLSession의 테스트 코드를 작성하면서 DIP 개념을 맞닥뜨렸습니다. 테스트할 때마다 API를 호출한다면 시간도 오래걸리고, 다른 외부 상황에 의해 테스트에 실패할 요인이 생기기 때문에 임의의 MockURLSession을 작성하면서 테스트를 진행해야 합니다. 이거랑 DIP랑 어떤 관계가 있을까요?
뒤에서 제대로 살펴보겠지만, DIP는 추상화에 의존한다는 원칙으로, 효율적인 코드를 짜기 위해 지켜야하는 원칙이라고 합니다. '효율적'이라는 게 구체적으로 어떤 걸 얘기하는 것이며, 얼마나 좋길래 DIP가 중요한 걸까요?
우선 제가 DIP를 맞닥뜨린 상황인 테스트에 용이한 코드를 짤 수 있고, 테스트에 용이한 코드는 곧 좋은 구조의 코드를 의미합니다. 여기서 말하는 좋은 구조란, 테스트에 용이하고 서로 영향을 적게 미치고 유지보수가 쉬운 효율적인 코드를 말합니다. 결국 의존 관계 역전 원칙을 잘 지킨다면 좋은 구조의 코드를 짤 수 있습니다.
왜 의존 관계 역전 원칙이 중요한지 감이 오시나요? 이제 이 개념을 왜 배우는지 납득이 됐으니, 구체적으로 왜 중요하고 어떻게 구현하는지 알아보겠습니다.
의존 관계 역전 원칙(DIP,Dependency Inversion Principle)이란?
”객체 지향 프로그래밍에서 의존 관계 역전 원칙은 소프트웨어 모듈을 분리하는 특정 형태를 말합니다. 이 원칙을 따르면 상위 수준의 정책 설정 모듈에서 하위 수준의 종속성 모듈로 설정된 기존의 종속성 관계가 역전되어 상위 수준의 모듈이 하위 수준의 모듈 구현 세부 사항과 무관하게 됩니다.” - 위키피디아
의존 관계 역전 원칙은 객체 지향 프로그래밍을 위한 SOLID 원칙 중 하나로, 구체적인 객체는 추상적인 객체에 의존해야 한다는 원칙입니다. 그리고 이 DIP 개념을 코드에 적용하는 것이 의존성 주입(Dependency Injection)입니다. 다른 표현으로는 의존 관계 역전 원칙을 따르기 위해선, High level의 객체가 Low level의 객체에 의존하면 안되고, 둘 다 추상화에 의존해야 합니다.
조금 풀어서 얘기해보겠습니다. 더 높은 상위 계층의 객체가 자신의 하위 계층의 객체에 의존하면 안됩니다. 즉, 어떤 Low level의 객체에 접근하기 위해 High level의 객체부터 접근하는 일이 없도록 해야합니다.
예를 들어보겠습니다. ‘내가 선호하는 아침 메뉴’라는 객체에 접근하기 위해 ‘내가 좋아하는 음식’이라는 High level의 객체부터 접근하는 일이 없어야 한다는 뜻입니다. 왜냐하면, 어느날 다른 음식에 빠져서 아침 메뉴를 바꾸려 하는 상황이라고 생각해봅시다. ‘내가 선호하는 아침 메뉴’를 바꾸기 위해서라면, ‘내가 좋아하는 음식’의 High level부터 순차적으로 타고 들어가야 하고, 동시에 지나가는 객체들을 모두 바꿔야 하는 비효율적인 일이 생기기 때문입니다. 그런데 만약, ‘내가 선호하는 아침 메뉴’와 ‘내가 좋아하는 음식’ 둘 다 ‘간이 슴슴한 것’이라는 추상적인 객체를 좇는다면 (의존한다면) 추상적인 객체 하나만 수정하면 됩니다. 이해가 가시나요?
쪼오금 더 구체적인 상황을 보면서 얘기해보겠습니다.
자 여기, Sender와 Receiver 객체가 있습니다. (이 내용에 대한 코드는 아래에 있습니다.)
Specific Receiver에 접근하기 위해 Sender를 타고 들어가야 합니다. 여기서 Sender는 High Level이면서, Specific Receiver에 종속되어 있다고 말합니다.
하지만, Sender 내부에 Receiver 인터페이스를 정의한다면? 즉 Sender에 의존성을 주입한다면?
화살표가 바뀌면서, Specific Receiver가 Sender에 의존한다(종속되어 있음)고 말할 수 있습니다.
더 나아가서, Sender와 Specific Receiver가 Receiver 프로토콜을 준수하기만 한다면, Receiver를 외부에 정의할 수 있습니다.
Sender와 Specific Receiver는 Receiver에 의존한다 (종속되어 있다)고 합니다.
이렇게 하면, Sender와 Specific Receiver는 언제든지 재사용될 수 있어 효율적입니다. Specific Receiver는 언제든지 Another Receiver로 교체될 수 있고, Sender의 business logic은 재사용될 수 있다는 의미이기도 합니다.
DIP 예시 코드
아무리 개념을 이해했다고 해도, 코드로 적용을 못한다면 말짱 도루묵이겠죠? 3가지 상황의 코드를 각각 DIP 적용 전, 후를 비교하면서 살펴보겠습니다.
Sender와 Receiver
위에서 말했던 개념을 코드로 정리한 것입니다.
// DIP 적용 전
protocol Receiver {
func receive()
}
class ReceiverA: Receiver {
func receive() {
//doSomething
}
}
class Sender {
private var receiver: ReceiverA
init(receiver: ReceiverA) {
self.receiver = receiver
}
func doSomething() {
receiver.receive()
}
}
ReceiverA의 receive 함수를 호출하기 위해서 Sender 클래스에 접근해서 직접 receive 함수를 호출합니다. 이것을 High Level인 Sender가 Low Level인 ReceiverA에 의존한다 또는 종속되어 있다고 합니다.
이 상황은 안좋습니다. 왜냐면, ReceiverA를 수정하려면 Sender도 수정해야 하기 때문이죠. (Sender 부터 접근해야 하니까) 하나를 바꾸고 싶은데 두 개를 만져야 한다? 비효율적인 상황입니다.
조금 더 구체적으로 ReceiverA 대신 ParseService, receive() 대신 fetchUserWith() 메소드를 사용하는 상황을 보겠습니다.
// DIP 적용 전
class ParseService {
func fetchUserWith(id: String, completionHandler: (user: PFObject?, error: ErrorType? -> ()) ) {
// fetch User
}
}
class Sender {
private var service: ParseService
init(service: ParseService) {
self.service = service
}
func doSomething() {
service.fetchUserWith(id: "abs") { user, error in
// fetch User
}
}
}
만약, ParseService에 이상이 생긴다면? ParseService에 의존하는 모든 클래스를 수정해야 할 것입니다. 이것은 아래 두 항목에 위배되는 코드이기도 합니다.
A. 상위 레벨 모듈은 하위 레벨 모듈에 의존해서는 안 됩니다. 둘 다 추상화(프로토콜)에 의존해야 합니다.
B. 추상화는 세부 사항에 의존해서는 안 됩니다. 세부 사항은 추상화(프로토콜)에 의존해야 합니다.
자, 그럼 DIP를 적용하면서 추상화에 의존하도록 코드를 수정해보겠습니다.
// DIP 적용 후
protocol Service {
func fetchUserWith(id: String, completionHandler: (user: User?, error: ErrorType? -> ())
}
class Sender {
// Service 프로토콜을 준수하는 인스턴스 정의 : 변수를 추상적 유형으로 지정하게 함으로써 의존 관계 역전 원칙을 준수함. 이것을 다형성이라고 부름
// 런타임에 이 변수는 프로토콜을 준수하는 구체적인 클래스의 인스턴스가 됩니다. 그러나 컴파일 시 컴파일러는 아직 구체적인 유형을 알 필요가 없습니다.
// 런타임에 상위 레벨인 Sender는 여전히 하위 레벨인 SpecificReceiver에 의존합니다. 그러나 컴파일 시에는 Sender와 SpecificReceiver가 모두 동일한 수신자 프로토콜에 종속됩니다. 따라서 의존성 반전 원칙은 소스 코드 의존성만 반전시키고 런타임 의존성은 상위 수준에서 하위 수준으로 유지합니다.
private var service: Service
init(service: Service) {
self.service = service
}
func doSomething() {
service.fetchUserWith(id:"abc") { user, error in
//fetch User
}
}
}
// Service 프로토콜을 준수하는 ParseService 클래스 정의
class ParseService: Service {
func fetchUserWith(id: String, completionHandler: (user: User?, error: ErrorType? -> ()) {
// fetch User code
// Map PFObject into User
}
}
Sender 내부에서 service의 변수를 추상화 유형인 Service 프로토콜에 의존하도록 지정했습니다. 덕분에 여기에 ParseService 대신 FirebaseService나 MyBackendService로 언제든지 대체할 수 있습니다.
이 말은 언제든지 다른 Service로 대체할 수 있다는 말입니다. 예를 들면 Unit Test를 실시하기 위해 TestService로 교체할 수 있습니다.
이런 상황이죠.
Database와 UserTransaction
// DIP 적용 전
class Database {
func create(_ name: String) {}
func insert(_ user: User) {}
func update(_ user: User) {}
func delete(_userID: String) {}
required init() {}
}
struct User: Decodable {
let name: String
let id: String
}
class UserTransaction {
// Database에 의존함
private let dataBase: Database
init(dataBase: Database) {
self.dataBase = dataBase
}
func add(user: User) {
dataBase.insert(user)
}
func edit(user: User) {
dataBase.update(user)
}
func delete(id: String) {
dataBase.delete(_userID: id)
}
}
let db = Database()
let userTransaction = UserTransaction(dataBase: db)
High Level인 UserTransaction 객체는 Low Level인 Database 객체에 의존하고 있습니다.
DIP에 준수하는, 추상화에 의존하는 코드로 바꿔보겠습니다.
// DIP 적용 후
protocol DatabaseManager {
func create(_ name: String) {}
func insert(_ user: User) {}
func update(_ user: User) {}
func delete(_userID: String) {}
init() {}
}
struct User: Decodable {
let name: String
let id: String
}
// 프로토콜에 의존하는 클래스 객체
class Database: DatabaseManager {
func create(_ name: String) {}
func insert(_ user: User) {}
func update(_ user: User) {}
func delete(_userID: String) {}
required init() {}
}
class UserTransaction {
// 추상화(프로토콜)에 의존
private var database: DatabaseManager
init(database: DatabaseManager) {
self.database = database
}
func add(_ user: User) {
database.insert(user)
}
func edit(_ user: User) {
database.update(user)
}
func delete(_ userID: String) {
database.delete(userID)
}
}
let db: DatabaseManager = Database()
let userTransaction = UserTransaction(database: db)
URLSession
마지막으로, 제가 DIP를 만났던 URLSession 상황에서도 적용해보겠습니다.
// DIP 적용 전
class DataService {
...
private func getElements(from urlString: String, className: String, completionHandler: @escaping ((Elements?) -> Void)) {
guard let url = URL(string: urlString) else { return }
// URLSession.shared에 의존
let dataTask = URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else {
completionHandler(nil)
return
}
do {
let htmlContents = String(data: data, encoding: .utf8)
let document = try SwiftSoup.parse(htmlContents ?? "")
let elements = try document.getElementsByClass(className)
completionHandler(elements)
} catch {
completionHandler(nil)
}
}
dataTask.resume()
}
...
// DIP 적용 후
// URLSessionProtocol.swift
typealias DataTaskCompletionHandler = (Data?, URLResponse?, Error?) -> Void
protocol URLSessionProtocol {
func dataTask(
with url: URL,
completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask
}
extension URLSession: URLSessionProtocol { }
// DataService.swift
class DataService {
...
// URLSessionProtocol이라는 추상화 객체에 의존
var urlSession: URLSessionProtocol = URLSession.shared
private func getElements(from urlString: String, className: String, completionHandler: @escaping ((Elements?) -> Void)) {
guard let url = URL(string: urlString) else { return }
let dataTask = urlSession.dataTask(with: url) { (data, response, error) in
guard let data = data else {
completionHandler(nil)
return
}
do {
let htmlContents = String(data: data, encoding: .utf8)
let document = try SwiftSoup.parse(htmlContents ?? "")
let elements = try document.getElementsByClass(className)
completionHandler(elements)
} catch {
completionHandler(nil)
}
}
dataTask.resume()
}
감이 오시나요?
의존 관계 역전 원칙은 객체 지향 프로그래밍의 SOLID 원칙 중 하나인 중요한 원칙입니다. 떄문에 잘 이해하고 적용하는 것이 중요합니다. 내용에 틀린 부분이 있으면 언제든지 댓글 남겨주세요!
세 줄 요약
- URLSession 테스트 코드 작성 중 의존 관계 역전 원칙(DIP)을 만났다.
- DIP는 구체적인 객체(class)가 추상적인 객체(프로토콜)에 의존하게 하는 원칙이다.
- DIP를 준수하면 효율적인 코드 작성이 가능하다.
출처
Dependency Inversion - A Little Swifty Architecture - Clean Swift
The Dependency Inversion Principle is the most important principle in software application architecture. Learn how to use DIP in your Swift iOS apps.
clean-swift.com
Dependency Inversion — A Swift guide
A quite basic guide
stevenpcurtis.medium.com
'iOS > Swift' 카테고리의 다른 글
[iOS - Swift] Networking 테스트 코드 작성하기 (feat. URLSession) (0) | 2023.03.17 |
---|---|
[iOS - Swift] TDD를 위한 Unit Test 코드 작성하기 (Feat. XCTest) (0) | 2023.03.08 |
[iOS - Swift] XCTest FAQ (XCTest를 공부하다 생긴 궁금증들) (0) | 2023.03.07 |
[iOS - Swift] static func vs func (0) | 2023.03.06 |
[Swift] Struct, Initializer (구조체와 이니셜라이저) (0) | 2022.05.10 |