momodudu.zip

#10 Swift - Protocol as Interface 본문

ios/Swift

#10 Swift - Protocol as Interface

ally10 2020. 4. 29. 15:29

 

이번에 일을 하다가, 조금 설계적으로 고민이 필요한 부분이 있어서 "이걸 어떻게 swift로 구현할까?" 라는 고민을 하기 시작했다.

 

상황은 이렇다. C++로 설명하자면, Interface가 되는 abstract class가 하나 있고 그 밑으로 이 Interface를 구현하는 subclass들을 만들고 싶었다. 익숙한 C++로 짜면 금방 짤 수 있을텐데,  사실 요새 ios개발자스럽게 코딩하자! 라는 생각을 의식적으로 계속 하고있어서, 어떻게 해야 swift스럽게 잘 짤수 있을까? 를 고민하고 있었다.

 

인터넷을 조금 찾아보던중에 swift의 abstract class 구현 패턴을 몇가지 찾을 수 있었다.

 

1. abstract class의 Empty implementation

class Animal {
    func sound() { }
}

class Cat: Animal {
    override func sound() {
    	print("meow")
    }
}

class Dog: Animal {
    override func sound() {
    	print("bark")
    }
}

Animal이 abstract class이고, sound라는 빈 구현체의 function을 가지고 있다. 이 BaseClass를 서브클래싱하는 Cat, Dog에서 실제로 함수 오버라이딩으로 구현체가 정의된다. 그래서 Cat 혹은 Dog instance의 sound() func을 호출하게 되면 각 함수가 알맞게 호출될것이다. 이 잘체로 봤을때는 문제가 전혀 없는 패턴이다.

 

그러나 위처럼 서브클래싱을 했더라도, 개발자의 실수로  상위 클래스의 함수들을 오버라이딩 하지 않아도 컴파일에러가 발생하지 않는다. 개발자의 실수로 아래와같이 서브클래싱 하는 함수에서 함수를 빼먹는다면? 그리고 그 인스턴스의 함수를 호출한다면?

class Animal {
    func sound() { }
}

class Cat: Animal {
    override func sound() {
    	print("meow")
    }
}

class Dog: Animal {
	
}

Dog dog
dog.sound()

위와 같이 Dog에서 실수로 sound()를 빼먹었다고 가정하고, dog.sound()를 호출한다면 어떤일이 발생할까? c++ n년차로서는 ... 느낌상으로는 상위클래스의 sound가 호출되어야 할것만 같다.... 하지만, swift에서는 죽는다. 옵씨에서 없는 함수에 대해 performSelector를 respondSelector를 체크하지 않고 불렀을때 발생하는 참사랑 비슷한 철학일까... 아무튼 죽는다.

 

그래서 사실 개발자가 빼먹지 않고 꼼꼼하게 잘 구현해놓고, 오버라이딩 하지 않은 함수는 서브클래스에서 호출하지 않는것을 규칙으로 딱딱 맞게 지키면 문제가 없는 패턴이지만, 위와 같은 추상 클래스 패턴은 swift에서 컴파일에러조차 야기하지 않기 때문에 실수가 발생할 가능성이 매우 높다.

 

2. Abstract class as a Protocol

그래서 스위프트에서 제공하는 강력한 무기가 바로 프로토콜이다. 이 article을 읽기 전까지는 사실 protocol은 나에게 단순히 delegate같은 단순한 느낌이 강했는데, 이 글을 읽고나서 클래스 인터페이스로서도 사용할 수 있구나... 라는걸 알게되었다.

 

protocol Animal {
    func sound()
}

class Cat: Animal {
    func sound() {
    	print("meow")
    }
}

class Dog: Animal {
    func sound() {
    	print("bark")
    }
}

Animal 을 class가 아닌 Protocol로 구현하여 Cat과 Dog의 blueprint를 작성하는것이다. Animal에서는 사실 sound() function이 어떻게 동작할지는 정의하지 않기 때문에, 해당 function의 이름과 리턴 타입등만 지정을 해주고 이 프로토콜을 준수하는 클래스에서 구현부를 작성하도록 청사진을 제공한다.

이렇게 되면 Animal에서 optional로 지정하지 않은 함수들은 모두 이 프로토콜을 conform하는 클래스에서 다 구현을 해주지 않으면 컴파일 에러가 발생한다. 즉, 개발자의 실수가 발생할 가능성을 낮춰준다!

 

그래서 이 abstract class, 혹은 interface로서의 protocol을 조금 더 검색해보다가 스위프트 랭귀지 가이드에서 "프로토콜을 extension하면 default 함수도 작성할 수 있다!"라는 구절을 본 기억이 났다. 그래서 Cat과 Dog의 완전 공통 함수들(굳이 예를 들자면 sleep()같은 기능?) protocol에서 default로 구현할 수 있지 않을까? 라는 생각으로 이에 대한 자료들을 좀 더 찾아봤다.

 

검색을 하자마자 바로 "why you should avoid using default implementation in protocols"라는 article이 나왔다. (;;;)

https://medium.com/better-programming/swift-why-you-should-avoid-using-default-implementations-in-protocols-eeffddbed46d

 

Swift: Why You Should Avoid Using Default Implementations in Protocols

Default implementation, composition over inheritance, the interface-segregation principle, method dispatch, and unit testing

medium.com

 

일단 위의 Animal 예제를 다시 가져와서 설명을 하자면,

protocol Animal {
    func sound()
    
    func sleep()
}

extension Animal {
    func sleep() {
    	printf("sleeping...")
    }
}

class Cat: Animal {
    func sound() {
    	print("meow")
    }
}

class Dog: Animal {
    func sound() {
    	print("bark")
    }
}

 

나는 사실 약간 위 같은 모양을 원했다. Animal protocol은 sleep()을 구현해야 하지만, extension으로 default implementation을 구현해놓은것이다. Animal에 해당하는 모든 공통함수들은 Animal Extension에 때려박아서 코드 중복을 피하면 subclass들을 좀더 깔끔하게 할 수 있지 않을까? 라는 생각이었다.

 

하지만 위 아티클에서는 몇가지 이유로 이 방법을 지양하자! 라고 하고있다.

 

- OOP규칙에 맞지 않게 flexible하지 않고 구체적이게 되어버린다. 이는 런타임에 어떤 실행 결과를 가져올지 예측하기 어렵고, 에러를 발생시킬 가능성이 높다. 가독성도 떨어진다.

 

- SOLID중 하나인 Interface segregation이 지켜지지 않는다. 나는 이런 경우는 아니지만, 대부분 이렇게 default implementation을 쓰는 주된 이유중 하나가, objective C에서는 optional protocol이 없어서 그렇다고 한다. 즉, 한 protocol을 작성해놓고 어떤 두 종류의 클래스가 다 이 프로토콜을 따르는데, 하나는 함수 하나가 필요없고, 다른 클래스 하나는 이 함수가 필요하고... 이런 일련의 구구절절(?)과정이 말이 안된다는 얘기다. 이런 경우에는 깔끔하게 프로토콜을 두개로 분리해라. 라는 의견인거같다.

 

- method 호출 방식을 예측하기 어렵다. 이 경우는 예제를 다시 보자.

protocol Animal {
    func sound()
    func eat()
}

extension Animal {

    func eat() {
    	print("Eating Animal...")
    }

    func sleep() {
    	print("Sleeping Animal...")
    }
}

class Cat: Animal {
    func sound() {
    	print("meow")
    }
    
    func eat() {
    	print("Eating Cat...")
    }
    
    func sleep() {
    	print("Sleeping Cat...")
    }
}

class Dog: Animal {
    func sound() {
    	print("bark")
    }
    
    func eat() {
    	print("Eating Dog...")
    }
    
    func sleep() {
    	print("Sleeping Dog..")
    }
}

 

일단 한눈에 봐도 런타임 결과가 어떻게 나올지 예측조차 안된다 -__.... C++ 의 dynamic binding이랑 비슷한 맥락이라고 생각하면 되는데.. 일단, protocol에서는 sound와 eat을 정의했고, sound는 각 이 프로토콜을 준수하는 클래스에서 구현했다. 그래서 cat.sound(), 혹은 dog.sound()가 정상적으로 출력될것이다.

 

문제는 eat과 sleep인데... 일단 모두 서브클래스에서 구현은 했다. 하지만 두 함수의 차이는, eat은 프로토콜에 정의되어 있고, sleep은 protocol의 extension에만 정의가 되어있다. 

 

let animal: Animal = Cat()
animal.eat()
animal.sleep()

위와 같이 코드를 실행했을때 결과는 어떻게 될까?

 

let animal: Animal = Cat()
animal.eat() // print "Eating Cat..."
animal.sleep() // print "Sleeping Animal..."

놀랍게도 결과가 다르다... 이유인 즉슨, protocol-required method는 dynamic method dispatch, C++로 치자면 다이나믹 바인딩이 일어나서 누구의 function을 호출할지? 가 런타임에 결정이 되지만, extension에 정의되는 method는 static distpatch, 즉 정적 바인딩이 일어나서 이건 런타임에 결정되는게 아니라 컴파일타임에 이미 extension의 sleep을 호출하자! 라고 결정이 되어버린다. 와우....

 

뭐 이것도 해결 방법이 있어서 위 사이트에 예시가 있는데, 일단 보기에도 가독성이 매우 떨어져서 생략한다 (...) 이럼에도 불구하고 쓰고싶으면 써라! 라는 느낌이여서....

 

 

뭐 사실 이런건 방법론적인 이야기일 뿐이고, 정답은 아니다. 개발자가 원하는 방향으로 맞춰서 실수하지않고 짜면 되지만.. 난 실수를 고치면서도 실수를 하는 개발자라서...^^ 대세를 따르기로 한다. 뭔가 스위프트에 프로그래밍언어론 적으로 접근할 수 있어서 좋았다~

'ios > Swift' 카테고리의 다른 글

#11 MVC와 MVVM 패턴에 관하여  (0) 2020.08.28
#9 Swift - class, struct에 대해서 다시 짚어보기  (0) 2020.04.23
#8 Swift - Automatic Reference Counting  (0) 2019.12.28
#7 Swift - Protocol(1)  (0) 2019.11.28
#6 Swift - Extension  (0) 2019.11.28