면접준비

근본으로 돌아가자(5) - 프로토콜

2료일 2025. 3. 3. 12:39

오랜만에 근본 시리즈다. 그 이유는 좋은 기회로 IOS 현업에 계신 개발자분과 커피챗을 한 결과 오히려 더 기본기를 본다고 한다. 나는 기본기가 충분한가? 기본기가 충분해서 다양한 Kit들을 쓰고 아키텍처를 공부하는가? 모르겟다 그래서 프로토콜에 대해 다시 정리해보려합니다

프로토콜

: 요구사항이 들어있는 청사진 뭐 그냥 인터페이스?

뭐 이정도는 이제 올라온 1학년 개발자도 말할 수준이다. 먼저 왜 프로토콜이 나왔는지부터 살펴보자.

등장 배경

다중 상속의 문제

Objective-C는 동적 디스패치와 런타임에 의존하는 객체지향 언어로, 다중 상속 X. 코드 재사용성과 확장성에 제약이 존재.

왜 그러면 다중 상속이 안될까? 다이아몬드 문제: A클래스를 B,C가 상속받고 D가 B,C를 상속받아 A 메서드 호출할때 어떤 경로인지 모호.

그런데 C++과 Python으로 개발을 해보았던 나는 다중상속을 경험해보았다. 이거 관련해서 왜 되는지 찾아보니

class A { /* ... */ };
class B : virtual public A { /* ... */ };
class C : virtual public A { /* ... */ };
class D : public B, public C { /* ... */ };

- C++는 virtual 키워드를 사용하여 A의 인스턴스가 D에서 단 하나만 존재하도록 한다.  또한 명시적으로 어떤 경로를 통해 메서드를 호출할지 지정할 수 있다고 한다.

- Python의 경우 메서드 해결순서라는 알고리즘을 사용하여 메서드 호출 경로를 결정한다고 한다. 

그렇다면 Swift는 왜 어떠한 알고리즘을 넣어서라도 다중 상속을 지원하지 않았을까?

개념만 봐도 알겠지만 다중 상속은 클래스 계층을 설계하고 이해하는데 복잡성을 더한다. 추가 규칙이나 우선순위를 정한다는것 자체가 유지보수를 어렵게하고 버그를 유발할 가능성을 심는다고 생각한다. 그래서 스위프트는 "간결함, 안정성"을 핵심 가치로 삼고 있기에 아예 배제하여 언어의 설계를 단순화하고, 개발자가 직관적으로 이해할 수 있는 코드를 작성하도록 유도했다고 생각한다. 

그 대신 POP개념이 나오고 프로토콜이 나왔다고 생각한다.

다중 Protocol 채택

protocol ProtocolA {
    func method()
}
protocol ProtocolB {
    func method()
}
//1. Extension이 없을때를 가정하고 2는 있을때를 가정한다
extension ProtocolA {
    func method() { print("From A") }
}
extension ProtocolB {
    func method() { print("From B") }
}

struct MyStruct: ProtocolA, ProtocolB {
    // method() 구현 없음
}

1번의 경우 컴파일러는 A프로토콜 Witness Table, B 프로토콜 Witness Table을 생성하려고 시도한다. 하지만 각 PWT에 필요한 method()가 없어서 컴파일 에러가 뜬다. 

그래서 이번엔 2번 extension을 통해 기본 구현을 제공하고 있다. 하지만 또 다시 컴파일 에러가뜬다. 어떤 protocol의 기본 구현을 사용해야하는지 모른다. 

이에 대한 해결책으로는 

1. MyStruct에서 method구현부를 만든다.

2. Extension으로 프로토콜 A, B에서 각각의 메서드를 만들고 프로토콜을 캐스팅해 호출할 수 있다 

뭐 (Mystruct as ProtocolA).method 이거 자체가 컴파일러는 A프로토콜 PWT만 참조한다는 뜻이다. 

 

가장 근본적으로 protocol은 구조체나 클래스 더 나아가 enum 포함하여 모든 유형이 프로토콜 채택 가능하다. 

저기서 MyStruct구조체는 어떻게 프로토콜 채택이 이루어질지 그냥 근본적으로 궁금해졌다.

Protocol의 Dynmaic Dispatch는 어떻게 이루어질까?

는 쓰다가 너무길어서 분리했어요 ㅎㅎ

https://codeisfuture.tistory.com/109

 

Dynamic Dispatch는 어떻게 이루어지는가? 클래스 VS 프로토콜

사실 이게 첫번째 글이 아니에요. 프로토콜은 어떻게 동작하고 채택당한놈은 어떻게 알고 필수 메서드들을 구현하라고 컴파일 에러가 뜨는지 궁금했던 저는 더 나아가 프로토콜을 채택한 다양

codeisfuture.tistory.com

 

이 글을 꼭 읽고 오셔야 다음 내용도 이해가 잘될꺼에요

프로토콜과 제너릭의 성능 비교

struct + Protocol을 같이 사용하면 단순 Struct에 비해 사이즈가 크면 힙에도 저장하고 RC도 증가시키기에 성능이 안좋아진다. 그렇다면 어떻게 하면 성능을 좋게 만들 수 있을까?

// Drawing a copy using a generic method

protocol Drawable {
 func draw()
}

struct Point: Drawable {
    let x, y: Double
    
    func draw() {
      print("Point 구조체의 메서드 실행 => \(x), \(y)")
    }
}

struct Line: Drawable {
    let x1, y1: Double
    let x2, y2: Double
    
    func draw() {
      print("Line 구조체의 메서드 실행 => \(x1), \(y1) - \(x2), \(y2)")
    }
}

func drawACopy<T: Drawable>(local : T) {
 local.draw()
}

let line = Line()
drawACopy(line)

// ...

let point = Point()
drawACopy(point)

이 코드를 보면 Drawable 프로토콜 채택한것이 drawACopy 메서드의 인자로 들어올 수 있는 제너릭 함수이다. 

뭐 사실 저 위의 코드는

func drawACopy(local : Drawable) {
 local.draw()
}

의미는 동일하다 Drawable 프로토콜 채택한 것이 drawACopy메서드 인자로 들어온다니까.

하지만 성능상으로는 엄청난 차이가있다고 한다. 

Static VS Dynamic Polymorphism

제네릭 코드는 정적 다형성(Static Polymorphism)을 제공합니다. 정적 다형성이란 컴파일 시점에 타입이 결정되는 것을 의미합니다.

제너릭 함수는 호출하는 시점에 파라미터(로컬 변수)의 타입이 바인딩된다.

func foo<T: Drawable>(local : T) {
	bar(local)
}

func bar<T: Drawable>(local: T) { … }

let point = Point()
foo(point)

위의 코드에서 foo를 호출한 시점에 local변수의 타입이 Point로 바인딩된다 == 특정된다

다시 foo에서 bar를 호출할때 파라미터로 인해서 또다시 얘에서도 Point로 바인딩된다.그래서 컴파일러는 이미 Point라는 것을 알고있다고 한다. 정리하자면 제너릭 함수를 호출하면 호출하는 시점에 타입이 바인딩되고 그로 인해 내부에서는 특정된 타입만 사용이되어 동적이 아닌 정적이다. 아직 이해가 안된다. 우선 제너릭 함수 호출시 타입을 특정하고 정적 다형성을 위해서 필요한 조건은

one type per call context = 한번의 호출에 하나의 타입만 전달하는 경우

이 경우 Existential Container가 필요가 없다고 한다.

 근데 이때는 그러면 어떻게 existential container없이 어떻게 메모리 할당하고 내부 메서드 실행하는데? 

대신 Swift는 호출시점에 VWT와 PWT를 추가인자로 전달한다.

파라미터로서 전달받은 것을 로컬 변수로 만들고 값을 저장하려면 전달된 VWT에서 allocate 함수를 통해 Stack에 저장프로퍼티 등을 할당그리고 draw 메서드 호출하면, 전달된 PWT을 사용하여 테이블에서 고정 오프셋의 draw메서드 찾아 구현으로 jump

local argument에 대해 어떻게 메모리를 할당할까 정답은 stack에 그냥 다 저장한다.

이 Static Polymorphism은 제너릭의 Specialization이라 불리는 컴파일러 최적화를 가능하게한다.

Specialization: Generic의 핵심 최적화

Swift 컴파일러는 제너릭 코드를 최적화하기 위해 Specialization 기술을 사용한다.

func drawACopy<T: Drawable>(original: T) {
    let local = original
    local.draw()
}

let point = Point()
drawACopy(original: point)  // Point에 특화된 버전이 생성됨

1. 컴파일러가 Generic 함수가 호출되는 모든 구체적인 타입에 대해 별도의 함수 버전을 생성한다.

2. 각 버전에서 Genric 타입 T를 구체적인 타입으로 대체

3. 이렇게 생성된 코드는 완전히 정적으로 Dispatch 가능해진다. 

이 메서드가 함수 call-site롤 통해 전달된 argument에 의해 우리는 위에서 구체화되어 정적다형성이 이루어진다고 했다.예를들면, 

func drawACopyOfAPoint(local : Point) {
 local.draw()
}
func drawACopyOfALine(local : Line) {
 local.draw()
}

drawACopyOfAPoint(Point(…))
drawACopyOfALine(Line(…))

요렇게 각각 전달된 타입에 맞게 생겼다. 근데 코드가 더 길어졌잖아..? 이게 뭔 최적환데?라고 생각할 수 있지만 간과한게 있다.

정적으로 입력된 정보는 더 적극적인 최적화를 가능하게 하고 결과적으로 더 코드 사이즈를 줄인다!

하 최적화?

이렇게까지 줄이는걸 의미한다고 한다. 보면 drawACopyOFALine 같은 메서드들이 더 이상 참조되지 않아 컴파일러가 이것들을 삭제한다고 한다. 그럼 이 경우는 언제 발생하는데?

1. 컴파일러가 호출 시점에 타입 추론할 수 있어야함.

2. 컴파일러가 사용된 타입의 완전한 정의에 접근할 수 있어야한다.

  • 같은 파일에 있거나 Whole Module Optimization를 통해 모든 파일을 하나의 단위로서 컴파일 될 수 있고 최적화 가능할때 

Line의 경우 4개여서 valueBuffer에는 힙에 저장한 공간의 주소를 담았다. 그런데 Line()을 두개 담고 있어 힙할당이 2번일어났다.

만약 우리가 배운 제너릭을 적용해보면 어떻게 될까?

뭐 바뀐거라곤 무조건 첫번째하고 두번째 타입이 똑같아야한다. 그래서 우리는 하나의 타입만 전달되는 경우에 부합하는 것이다. 

프로토콜을 준수하는 구조체 인스턴스를 함수 내 로컬 변수로 사용해도 제너릭함수이기에 Existential Container로 저장되지 않는다. 대신 함수 실행시 pwt, vwt를 추가 인자로 직접 전달할것이고 값들은 stack에 저장할것이다. 

즉 위의 과정이 Static하게 이루어진다. 

왼쪽에서 제너릭을 이용했다고 오른쪽만큼의 효율이 나오는 것이다.!! 

와 진짜 어려웠다 그냥 프로토콜과 제너릭을 코드의 깔끔함을 위해 쓴다 햇던 나를 반성한다.

프로토콜이란?

: 특정 기능이나 요구사항을 정의하는 인터페이스입니다.Swift가 객체지향의 다중 상속을 막음으로써 나온 코드 재사용성과 확장성을 이룰수 있습니다. 특히 프로토콜을 채택한 구조체의 경우 Dynmaic Dispatch를 하게 됩니다 .여기에 existential container 코드가 자동으로 컴파일시 자동으로 생기고 3 words의 ValueBuffer공간과 1words의 value witness table을 가리키는 포인터와 1words의 protocol witness table을 가리키는 포인터가 생깁니다. 내부에 있는 프로퍼티가 3개보다 작은 경우에는 이 valueBuffer에 복사되지만, 그 초과할 경우에는 힙영역에 복사하고 해당 메모리를 가리키는 주소가 valueBuffer에 저장됩니다. value witness Table은 할당부터 시작하여 복사, 파괴,해제까지를 담당합니다. 프로토콜의 메서드를 호출할때는 이 PWT의 주소값을 따라가 구현체를 실행하게 됩니다. 

여기서 주의할것이 작은 경우에는 stack에 담기지만 3words를 초과할경우 힙에 저장하기에 ARC도 해야하고 속도도 느려져 효율이 매우 안좋아집니다. 그래서 우리는 제너릭 타입을 이용하여 효율을 최적화할 수 있습니다.

기존의 프로토콜로 인한 dynmiac dispatch와 달리 제너릭을 사용하면 컴파일러가 어떤 타입이 오는지 컴파일타임때 알아서 효율적으로 최적화를 합니다. 제너럴 메서드를 각 타입으로 바인딩한 여러 정적 메서드로 변환해주는데 이때 코드의양은 늘어나지만 existianl container를 생성하지 않고 VWT와 PWT를 파라미터로 넘겨주기기에 힙 영역을 따로 할당하지 않아 더빠르다.

 

참고자료
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/protocols/

 

Documentation

 

docs.swift.org