Dependency(의존성)
객체지향 프로그래밍에서 Dependency, 의존성은 서로 다른 객체 사이에 의존 관계가 있다는 것을 말한다.
즉, 의존하는 객체가 수정되면 다른 객체도 영향을 받는다는 것이다.
예를들어 아래의 코드를 보자.
class A{
var num = 1
}
class B{
var internalVariable = A()
}
let b = B()
print(b.internalVariable.num) // 1
B클래스는 A클래스를 내부변수로 사용하고있다. 이로써 B클래스는 A클래스에 의존성이 생긴다.
객체끼리 의존하는 경우 많은 문제가 야기된다. 만일 A클래스에 문제가 생긴다면 이를 의존하고 있는 B클래스에도 문제가 생길 수 있으며 재사용성이 낮아진다. (재사용이 가능한건 최상위 클래스인 A뿐이다.)
의존성을 가지는 코드가 많아진다면, 재활용성이 떨어지고 매번 의존성을 가지는 객체들을 함께 수정해주어야한다는 문제가 발생한다.
이러한 의존성을 해결하기 위해 나온 개념이 바로 Dependency Injection, 의존성 주입이다.
Injection(주입)?
Injection, 주입은 내부가 아닌 외부에서 객체를 생성해서 넣는것을 주입(injection)이라고 표현한다.
class A{
var num: Int
init(num: Int) {
self.num = num
}
func setNum(num: Int){
self.num = num
}
}
let a = A(num: Int(3)) // 외부에서 객체를 생성해서 주입
print(a.num)
이 예시에선 외부에서 Int(3)을 생성해 A클래스에 주입했다.
Dependency Injection(의존성 주입)?
자자 의존성 + 주입 용어가 합쳐졌다. 내부에서 만든 객체를 외부에서 넣어 의존성을 주입할 수 있다.
class A{
var anum: Int = 1
}
class B{
var bnum: A
init(num: A){
self.bnum = num
}
}
let b = B(num: A())
B클래스는 A클래스에대한 의존성이 있다. A클래스를 B클래스 내부에서 생성한게 아닌 B클래스 외부에서 생성자로 주입했다.
이로써 클래스의 생성에서 의존성을 주입했다. 하지만 이렇게 외부에서 의존성을 주입하는것 만으로 의존성 주입이라고 부르지 않는다.
여기서 반드시 알고 넘어가야할 개념이 "의존 관계 역전 법칙(DIP: Dependency Injection Principal)"이다.
의존성 주입에서 의존성 분리는 의존 관계 역전 법칙으로 의존 관계를 분리시켜야 한다.
의존 관계 역전 법칙(DIP: Dependency Injection Principal)
원문에서 설명하는 인터페이스는 Swift에서는 프로토콜이다 모두 프로토콜로 작성한다.
객체 지향 프로그래밍의 SOLID 원칙중의 하나이다. 의존 관계 역전 법칙은 상위계층(정책결정)이 하위계층(세부사항)에 의존하는 전통적인 의존관계를 반전시킴으로써 상위계층이 하위계층의 구현으로부터 독립되게 할 수 있는 구조를 말한다.
1. 상위모듈은 하위모듈에 의존해서는 안된다. 상위모듈과 하위모듈 모두 추상화에 의존해야한다.
2. 추상화는 세부사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야한다.
도대체가 무슨소리냐.. 쉽게적으면 안되나?
1. 상위모듈, 하위모듈 모두 추상화(프로토콜)에 의존해야한다.
어떻게든 어렵게 설명하려고 아주..
앞선 예시는 B클래스가 A클래스에 의존하는 구조였다 (B -> A) 의존 관계 역전 법칙에서는 어떤 추상화된 프로토콜에 A, B객체가 모두 의존하고있는 구조(A -> 프로토콜 <- B) 라고 볼 수 있다.
protocol DIInterface: AnyObject{
var num: Int { get set }
}
class A: DIInterface{
var num = 1
}
class B{
var bNum: DIInterface
init(num: DIInterface){
self.bNum = num
}
}
let b = B(num: A())
이 구조는 B(상위모듈), A(하위모듈) 모두 DIInterface(추상화 프로토콜)에 의존해 있어 의존 관계를 독립시킨 상태이다.
그럼 ( A -> DIInterface <- B )이런 구조가 완성된 것이다.
또 다른 예시 2
나쁜 예
class APIHandler { // 하위모듈
func request() -> Data {
return Data(base64Encoded: "This Data")!
}
}
class LoginService { // 상위모듈
let apiHandler: APIHandler = APIHandler()
func login() {
let loginData = apiHandler.request()
print(loginData)
}
}
상위모듈인 LoginService가 하위모듈인 APIHandler에 의존하고 있는 관계로 만약 APIHandler의 구현 방법이 변화하게 되면 프로그램에 영향을 미치게되고 LoginService라는 상위 모듈을 수정해야 하는 상황이 일어날 수 있다. DIP의 원칙을 어긴 프로그램의 설계라고 할 수 있다.
좋은 예
protocol APIHandlerProtocol{
func requestAPI() -> Data
}
class LoginService {
let apiHandler: APIHandlerProtocol
init (apiHandler: APIHandlerProtocol){
self.apiHandler = apiHandler
}
func login() {
let loginData = apiHandler.requestAPI()
print(loginData)
}
}
class LoginAPI: APIHandlerProtocol{
func requestAPI() -> Data {
return Data(base64Encoded: "User")!
}
}
let loginAPI = LoginAPI()
let loginService = LoginService(apiHandler: loginAPI)
loginService.login()
이렇게 작성하게 되면 LoginService는 기존에 APIHandler에 의존하지 않고 추상화 시킨 객체인 APIHandlerProtocol에 의존하게 된다. 그렇기 때문에 APIHandlerProtocol의 구현부는 외부에서 변화에 따라 지정해주면 되기 때문에 LoginService는 구현부에 상관없이 좀 더 변화에 민감하지 않은 DPI원칙을 지킨 프로그램을 설계할 수 있게 된다.
이렇게 외부에서 내부의 변수를 초기화해서 의존 관계를 가지는 경우를 의존성 주입이라고 하게 된다. 이 때 의존성 주입을 추상화시켜 진행하게 되면 더욱 변화에 안전한 프로그램을 설계할 수 있게 된다.
정리
DIP원칙이란
- 의존 관계를 맺을 땐, 변화하기 쉬운 것보다 변화하기 어려운 것에 의존해야 한다는 원칙.
- 여기서 변화하기 어려운것은 추상클래스, 프로토콜(인터페이스) 를 말하고 변화하기 쉬운 것은 구체화된 클래스를 의미.
- 따라서 DIP를 만족한다는 것은 구체적인 클래스가 아닌 프로토콜(인터페이스) 또는 추상클래스와 관계를 맺는다는 것을 의미
// ApChamp와 AdChamp이 공통으로 준수할 프로토콜
// 프로토콜을 사용함으로써 의존성 역전이 된다. 아래에서 설명
protocol Champ: AnyObject{
var name: String { get }
}
// Champ프로토콜 채택
class ApChamp: Champ{
let name: String
init(name: String){
self.name = name
}
}
// Champ프로토콜 채택
class AdChamp: Champ{
let name: String
init(name: String){
self.name = name
}
}
class Player {
let apMost: Champ
let adMost: Champ
// 프로토콜 타입으로 저장하고 프로토콜 타입을 주입받는다.
init(apMost: Champ, adMost: Champ){
self.apMost = apMost
self.adMost = adMost
}
}
// 의존성 역전 관계 설명
// Palyer -> Champ(protocol) <- AP(AD)Champ
// 외부에서 클래스 내부의 값을 "주입"
let 아칼리 = ApChamp(name: "아칼리")
let 야스오 = AdChamp(name: "야스오")
let clamp = Player(apMost: 아칼리, adMost: 야스오)
print(clamp.apMost.name) // 아칼리
print(clamp.adMost.name) // 야스오
이렇게 프로토콜을 활용했을 때 장점은 다음과 같다.
ex) 갑자기 기획자가 Palyer의 apMost자리에 반드시 서포터챔피언을 넣어달라고 요구한다. 근데 딱 보니까 또 그 다음 버전에는 마법사 버전을 넣으라고 변덕 부릴게 뻔하다.
class SupportChamp: Champ{
let name: String
init(name: String){
self.name = name
}
}
class MageChamp: Champ{
let name: String
init(name: String){
self.name = name
}
}
class Player {
let apMost: Champ
let adMost: Champ
// 프로토콜 타입으로 저장하고 프로토콜 타입을 주입받는다.
init(apMost: Champ, adMost: Champ){
self.apMost = apMost
self.adMost = adMost
}
}
let 아칼리 = ApChamp(name: "아칼리")
let 야스오 = AdChamp(name: "야스오")
let supportChamp = SupportChamp(name: "카르마")
let mageChanp = MageChamp(name: "신드라")
// 이 부분만 수정해주면 끝이다. Player의 init속성을 수정할 필요가 없다.
let clamp = Player(apMost: supportChamp, adMost: 야스오)
똑같이 Champ프로토콜을 준수하는 Support, Mage 클래스를 외부에서 생성하고 주입시키는 부분만 변경하면 모든 수정 사항이 완료된다. 이번 버전에는 supportChamp를 주입하고, 바꾸라 그러면 그 때 그 때 프로토콜을 준수하는 클래스로 교체만 해주면 된다. Player클래스 내부의 어떤것도 수정할 필요가 없다.
이 예시는 챔피언 클래스들이 갖고있는 프로퍼티가 name뿐이지만 이런 경우를 생각해보면 효과가 더 강력하다.
// Champ 가 갖는 요소가 훨씬 늘어날 경우
protocol Champ: AnyObject {
var name: String { get }
var HP: Int { get }
var MP: Int { get }
var 공격력: Int { get }
var 방어력: Int { get }
...
func normalSkill()
func ultimateSkill()
...
}
Player 클래스 내부에서 프로토콜의 프로퍼티, 프로토콜의 메서드를 사용한다면 외부 객체 인스턴스를 교체해도 아무렇지 않게 잘 작동하게 된다.
이런 경우 의존성이 낮아졌고, 결합도가 낮아졌다고 표현한다.
SOLID원칙은 각각이 다른 원칙을 설명하고 있긴 하다. 하지만 결국 좋은 품질을 가진 코드를 설계하자는 것에 기안한 원칙이다.
그러다 보니 5가지 원칙이 서로 하나를 지키면 지킬 수 있는 것도 존재하게 되고 얽히고 섥혀있다.
예를 들면 ISP를 지키기 위해 결국 SRP를 지키게 된다던가..
이 모든 원칙을 지키며 프로그램을 설계하기는 힘들것같다. 하지만 최대한 지키면서 생각하며 코드를 짜야 할 것 같다.