본문 바로가기
강의 정리

WWDC 2016 - Protocol and Value Oriented Programming in UIKit Apps 세션에 관하여

by iOS 개린이 2023. 6. 8.

Model Layer

dream은 우리의 모델이다.

 

'description', 'creature' 그리고 'effects' 를 가지고 있다.

Model에서 클래스를 사용하는 것이 왜 문제가 될까?

 

 

참조타입은 암시적 공유를 하고 있기 때문에 'dream2' 의 'description' 을 변경하면 'dream1'의 'description' 도 암시적으로 변경된다.

따라서 'dream1' 의 원하지 않는 변경을 통해 버그가 발생할 수 있다.

 

관계도

 

이 다이어그램은 관계를 보여준다.

이런 관계 중 일부는 명시적이고 암묵적일 수 있고, 일부는 one-way 또는 two-way일 수 있으며,

일부는 dynamic 또는 static 할 수도 있다.

그리고 이 관계는 매우 복잡하다.

 

구조체를 사용하여 해결

 

값 타입을 사용했을 때, 변수 'dream1' 과 'dream2' 는 각각 독립적인 저장소를 가지고 있기 때문에 

'dream2' 의 'description' 을 변경해도 'dream1' 에 변경이 일어나지 않는다.

 

Model 계층에서 값 타입을 사용하는 것은 논란의 여지가 없다.

앱의 다른 부분에서도 방금 같은 이점을 활용하고 싶지 않나요?

최근에 인터넷에서 다음과 같은 글을 보았다.

"간단한 모델 유형에 대해서만 값 타입을 사용해라!"

별로 좋아보이지 않죠?

이제 값 타입을 단순히 모델 데이터에 사용하는 것을 넘어 어떻게 사용할 수 있는지 집중적으로 알아보자.

 

 

View Layer

다음과 같은 TableView가 있다. 

 

Cell 계층구조

 

UITableViewCell의 하위 클래스로 레이아웃을 작성했다.

'DecoratingLayoutCell' 에는 왼쪽과, 오른쪽에는 컨텐츠 영역으로 레이아웃을 작성해두었다.

그리고 Cell에 구체적인 로직을 추가한 하위 클래스 'DreamCell' 을 만들었다.

해당 레이아웃을 다른 곳에서도 재사용할 수 있도록 분리작업을 수행했다.

 

하지만 다른 Cell에서 레이아웃을 재사용하는 데에는 도움이 되었지만, 테이블 뷰 외부에서는 사용하기가 어려웠다.

따라서 'Dream' 에 대한 정보를 보여주는 상세 뷰인 'DreamDetailView' 를 새로 만들었고, 이것에 레이아웃 Cell을 재사용할 수 없었다.

 

그래서 우리는 레이아웃을 Cell에서도 사용하고, 일반 UIView에서도 사용할 수있도록 하는 구조를 만들고 싶다.

또한 'SpriteKit' 을 추가하여 멋진 파티클 효과를 제공하고 여기서도 레이아웃을 사용할 수 있도록 하고 싶다.

바로 아래 그림처럼.

 

 

문제 해결 시작

 

그림에서도 보았듯이 우리는 오른쪽엔 'content', 왼쪽엔 'decoration' 두 개의 뷰를 가지고 있다.

이것을 구조체로 변경해보자.

 

 

동일하게 두 개의 뷰를 가지고 있고, 레이아웃 로직을 추가했다.

이제 이 구조체를 가지고 'Dream Cell'을 업데이트 해보자.

 

 

이 레이아웃 구조체를 'DreamDetailView' 에서도 동일하게 재사용 가능하다.

 

이렇게 레이아웃 부분을 구조체로 만들어서 분리하면, 원하는 곳에서 언제든지 사용할 수 있게 된다.

 

Unit Test에서도 사용할 수 있다.

 

이제 테이블뷰를 만들거나 레이아웃 콜백이 발생할 때까지 기다릴 필요가 없이, 레이아웃이 작동하도록 지시하고, 출력을 확인할 수 있다.

 

여기까지 정리

우리는 DecoratingLayoutCell의 레이아웃 코드를 다른 곳에서도 써먹고 싶었음.

근데 계층구조를 보면 UITableViewCell에  종속되어 있기 때문에 외부에서 써먹기 힘듬.

그래서 구조체를 통해 레이아웃 로직을 분리했고, 모든 UIView에서 사용이 가능하게 됨.

또한 코드가 테스터블해지기도 함. 

 

 

프로토콜의 사용

다시 'DecoratingLayout' 으로 돌아가서,

우리는 이제 'SpriteKit' 에서도 사용될 수 있도록 만들 것이다.

하지만 SKNode는 UIView의 하위 클래스가 아니기 때문에 'DecoratingLayout' 을 사용할 수 없다.

그럼 어떻게 하나의 레이아웃으로 모든 요구사항을 적합하게 할 수 있을까? 

 

우리가 필요한 것은 'content' 나 'decoration' 의 frame을 설정하는 것이다.

따라서 해당 요구사항을 프로토콜로 나타낸다.

그리고 UIView와 SKNode가 프로토콜을 준수하도록 한다.

 

우리는 이제 두 가지 유형에서 모두 작동하는 레이아웃을 가지고 있다.

그리고 슈퍼클래스 대신 프로토콜에 의존하는 것은 다형성을 위한 훌륭한 이점 중 하나이다.

이제 레이아웃도 UIKit에 더 이상 의존하지 않는다.

 

Generic의 사용

 

위 코드에서는 'content', 'decoration' 프로퍼티가 각각 다른 타입을 할당받을 수 있다는 문제가 있다. 

제네릭을 통해 해결할 수 있다.

 

 

'Child' 라는 타입은 'Layout' 프로토콜을 따라야 한다는 조건으로 제네릭 타입을 만들었다.

이제 'content' 와 'decoration' 프로퍼티는 채택하는 곳의 타입에 맞게 사용될 수 있다.

 

Generic 장점 설명

 

제네릭은 많은 타입을 컨트롤할 수 있게 해준다.

또한 제네릭은 컴파일러에게 코드가 무엇을 하는지에 대한 더 많은 정보를 주어 좀 더 최적화된다.

 

여기까지 정리

우리는 'DecoratingLayout' 을 UIView 뿐만 아니라 SKNode에서도 사용하고 싶음.

근데 SKNode는 UIView 클래스의 서브클래스도 아니여서 관계가 없음.

 

레이아웃이 하는 일은 'content' 와 'decoration' 에 대한 프레임 설정이죠? -> 굳이 UIView로 타입을 설정하여 종속될 필요가 없음.

따라서 Protocol을 사용해서 공통적인 타입으로 묶어버림. 

프로토콜을 채택하는 쪽에서 frame만 설정해주면 모두 사용가능.

프로토콜을 통해 유연성이 살아나니 UIView에 종속되지 않고, SKNode와 같이 사용이 가능해짐.

 

여기서 문제가 발생한다.

'UIView' 와 'SKNode' 는 모두 'Layout' 프로토콜을 준수함.

Swift는 컴파일타임에 각 변수, 상수의 타입이 무엇인지 알아야 하는데,

문제는 'content' 와 'decoration' 이 'Layout' 프로토콜을 준수하는 어떤 타입의 인스턴스라도 가질 수 있다는 것임.

즉, 각각 프로퍼티에 동일한 타입이 들어가지 않을 수 있고, 동일한 타입으로 처리하려는 설계 의도와 맞지 않음.

 

이 문제를 해결하기 위해 제네릭을 사용함.

'Child' 라는 타입을 인수로 받는 제네릭 구조체를 만들고, 'Child' 는 'Layout' 프로토콜을 준수해야 하는 타입이며,

각 프로퍼티가 'Child' 타입을 가짐.

따라서 'DecoratingLayout' 구조체의 인스턴스를 생성할 때, 'Child' 타입을 결정하면, 그 인스턴스 내에서는 'Child' 타입이 고정된다. 즉, 'content' 와 'decoration' 프로퍼티가 반드시 같은 타입('Child')을 가지도록 강제하는 것임.

따라서 문제 해결완료.

 

 

Sharing code

 

지금까지 우리는 'DecoratingLayout' 을 훌륭하게 구현했다.

하지만 우리의 앱에는 더 많은 레이아웃을 가지고 있다. (계단형식 뷰)

그리고 이 레이아웃은 우리의 'DecoratingLayout'과 매우 유사하다. 

우리는 새로운 레이아웃을 만들어서 코드에 붙이는 것을 원하지 않고,

둘 다 사용할 수 있는 공유 추상화를 만들고 싶다.

 

어떻게 코드를 공유할 수 있을까?

보통 상속은 코드를 공유하기 위해 사용된다.

하지만 상속은 슈퍼클래스가 무엇을 하고 있는지, 서브 클래스가 무엇을 재정의하고, 변경하고자 하는지에 대해 고려해야한다.

또한 작업 중인 코드를 생각해야 하는데 앱 전체에 존재하는 많은 양의 코드를 하나로 묶어야 한다. 이것은 빙산의 일각에 불과하다. 

이것을 해결하기 위해 Compostion에 대해서 알아보자.

 

Composition은 무엇?

공유를 위한 더 좋은 방법인 Composition 이 있다.

Composition은 작은 조각들을 모아 더 큰 조각을 만드는 것이다.

조각들을 합치더라도 각 조각에 대해 독립적으로 어떤 것인지 알 수 있다.

또한 추상화를 할 때 Subclass, Superclass에 대한 걱정 없이 캡슐화를 수행할 수 있다.

 

우리가 이전에 사용하던 방법은 각각 따로 뷰를 만드는 것이었다. 

하지만 이것은 큰 문제가 있는데, 바로 클래스의 인스턴스는 비싸다는 것이다.

따라서 View의 갯수를 최소화하기 위해 노력한다.

 

이것을 ValueType과 Composition을 함께 사용하여 해결할 수 있다.

구조체는 굉장히 가볍고, 더 나은 캡슐화를 할 수 있다.

레이아웃을 구성해보자.

 

문제 정의

우리는 이 계단식 뷰를 'CascadingLayout' 이라는 구조체를 통해 레이아웃을 설정할 것이다.

또한 이 레이아웃을 'DecoratingLayout' 과 Compose 하는 것이 우리의 목표다.

 

 

하지만 여기서 변경해야 할 것이 하나 있다. 이 레이아웃에는 UIView 또는 SKnodes 의 자식들만 있어야 한다.

제네릭을 사용하여 레이아웃을 구성해보도록 하자.

 

Layout Protocol 요구사항 변경.

 

'Layout' 프로토콜은 'frame' 이라는 프로퍼티를 요구사항으로 가지고 있다.

하지만 프로퍼티를 위해 굳이 'getter' 를 할 필요없고, 'Setter'를 통해 새로운 값을 설정하는 것만 필요하다.

그리고 굳이 frame이 있는지 신경쓰지 않기 때문에 반영하여 프로토콜을 변경해보자.

 

 

이제 채택하는 곳에서 'rect' 를 결정하면 그대로 레이아웃을 처리한다.

 

 

'UIView'와 'SKNode' 는 여전히 프로토콜을 준수하고 있다.

'rect' 를 전달하고, 레이아웃을 지정하라는 요청을 받으면 프레임을 설정하는데 사용한다.

하지만 이제 우리의 레이아웃 구조체도 이 프로토콜을 준수하게 만들 수 있다.

또한 ChildType 이 더 많은 유연성을 가질 수 있도록 'DecoratingLayout' 에 변화를 줄 수 있는데,

이것은 나중에 자세하게 알아보겠다.

 

 

이제 'CascadingLayout'과 'DecoratingLayout' 은 함께 Composing되어 레이아웃을 구축할 수 있다.

이렇게 'Composition' 을 통해 고급 레이아웃을 구축할 수 있었다.

우리가 앱에서 작업을 진행할 때, 코드를 재사용하거나 'Composition'을 사용하여 일부 동작을 커스터마이징할 수 있다.

 

여기까지 정리

우리에게 새로운 계단식 뷰가 등장했음.

근데 구조적으로 'DecoratingLayout' 과 비슷하니까 이걸 최대한 활용하고 싶음.

공유를 하기 위해서 클래스를 사용하면 너무 비쌈. 

따라서 ValueType인 구조체와 Composition을 사용할 것임.

 

우리의 프로토콜 요구사항을 변경해주고, 각 레이아웃 구조체가 이것을 준수하도록 했음.

이것을 레이아웃들에게 적용시키면, 'DecoratingLayout' 에 있는 구조물은 그대로 가지고 있으면서,

'decoration' 부분만 'CancadingLayout' 부분으로 전달하여 레이아웃을 설정시킬 수 있음.

 

 

associatedtype의 사용

 

이전에 우리는 레이아웃의 contents를 superview나 spritekit에 추가할 수 있기를 원한다고 했다.

그리고 이것의 중요한 부분은 계단식 레이아웃의 컨텐츠들이 올바른 순서로 라인업 되는 것이다.

따라서 프로토콜을 확장하여 이를 지원할 수 있도록 하자.

 

 

'Layout' 프로토콜에 'contents' 프로퍼티를 추가하여 내용을 반환할 수 있도록 했다.

그러면 우리의 레이아웃은 컨텐츠를 올바른 순서대로 반환할 것이다.

하지만 'contents' 타입을 'Layout' 타입으로 지정하면, UIView와 SKnode가 혼합되는 환경이 만들어질 수 있죠?

'associatedtype' 을 사용하여 해결해보자.

 

 

'associatedtype' 은 Placeholder type과 같다.

이를 준수하는 타입은 사용할 Content 타입을 설정한다.

 

변경한 'Layout' 프로토콜 적용해보기.

 

'Layout' 프로토콜을 적용하여 UIView와 SKnode 컨텐츠로만 포함된 레이아웃을 작성할 수 있다.

이것은 TypeSafe하다.

 

하지만 이전과 마찬가지로 UIView와 SKnode에 대한 별도의 레이아웃을 만들 필요가 없다.

 

UIView와 SKNode 모두 지원하는 단일 레이아웃을 만들기

 

'Content' 타입이 해당 프로토콜을 준수하는 컨텐츠 타입에 의해 결정되기 때문에 

UIViews와 SKnodes만 사용할 수 있는 레이아웃을 만들 수 있다.

이렇게 associatedtype은 프로토콜을 더욱 강력하게 만드는 좋은 방법이다.

 

 

이제 우리는 향상된 'DecorationLayout' 을 사용함으로 계층도를 다시 확인해본다.

둘 다 UIView일 경우에는 효과적이지만, 앞서 'composition' 을 위해 언급한 것처럼

'CascadingLayout' 을 UIView와 함께 사용하려는 경우에는 효과적이지 않다.

 

이제 우리가 정말 원하는 것은 모든 컨텐츠가 동일한 타입을 갖는 것이다.

반영해서 업데이트해보자.

 

 

'where Child.Content == Decoration.Content'

 

구조체에 위와 같은 제약조건을 추가했다.

이것은 우리의 Content 타입이 'Decoration의 Content' 타입과 동일하다는 제약 조건을 거는 것이다.

이를 통해 우리는 정확한 타입이 오도록 제한할 수 있다. 

 

 

이제 우리의 프로토콜은 완성되었다. 

레이아웃 프로세스의 일부가 되는 것이 무엇을 의미하는지 나타내는 완전한 작업의 집합이다.

 

Unit test에서 사용

 

'Layout' 프로토콜을 활용할 수 있는 또 다른 곳은 Unit tests 이다.

우리는 'frame' 프로퍼티를 가지고 있고, 프로토콜을 준수한다면 UIView 대신 이것을 통해 단위 테스트를 진행할 수 있다.

 

이제 우리의 레이아웃은 단순한 구조체에 'frame' 을 설정하는 것이다.

이것은 테스트가 UIView와 완전히 독립적이고, 자체 레이아웃 및 테스트 구조의 로직에만 의존한다는 것을 의미한다.

 

지금까지 View layer에서 프로토콜과 타입을 사용하는 방법에 대한 몇 가지 예시를 보았다.

먼저 ValueType을 사용하여 Local reasoning을 개선할 수 있는 방법을 살펴 보았다. (클래스는 암묵적으로 공유를 하고, 값 타입은 그렇지 않은 면에서 직관적으로 로컬 추론이 가능하다는 얘기.)

 

다음은 제네릭 타입을 사용하여 더 나은 타입 안전성과 유연한 코드를 얻는 방법에 대해서 알아보았다.

또한, Composition of Value 를 통해 복잡한 동작을 커스터마이징할 수 있었다.

 

여기까지 정리

우리는 계단식 레이아웃의 컨텐츠들이 올바른 순서로 라인업 되도록 하고 싶음.

따라서 프로토콜에 'var contents: [Layout] {get}' 이라는 컨텐츠 배열을 넣어주었음.

근데 Layout 타입에는 UIView와 SKNodes 타입이 섞여서 들어갈 수 있음.

따라서 프로토콜의 제네릭 기능과 동일한 'associatedtype' 을 사용해줌.

 

근데 'DecoratingLayout'에서 'CascadingLayout' 과 UIView가 함께 사용되려는 경우에는 뭔가 효과적이지 않음.(계층구도 확인)

따라서 제약 조건을 걸어줌.  

'DecoratingLayout' 은 'Child' 와 'Decoration' 이라는 서로 다른 'Layout' 타입의 매개변수를 가진다.

또한 이 두 개의 'Layout' 타입은 동일한 'Content' 타입을 가져야 한다.

이를 통해 정확한 타입이 오도록 제한할 수 있었음.

 

완성된 프로토콜은 Unit Test에서도 사용이 가능함.

 

 

Controller Layer

 

이와 같은 뷰에서 'Shake to undo' 를 하지만 아무일도 일어나지 않는다.

코드를 보고, 이와 같은 버그가 왜 생기는지 찾아보자.

 

 

자 우리는 지금 두 개의 Model 프로퍼티를 가지고 있다.

'dreams' 와 관련된 기능에서 '실행 취소' 기능을 구현했고, 여기까지는 잘 작동했다.

문제는 'favoriteCreature' 에는 '실행 취소' 기능을 구현하지 않아서 버그가 발생한 것이다.

 

 

 

그래서 우리는 이 기능을 추가하기 위해 또 다른 경로를 만들 수 있다.

하지만 다른 Model 프로퍼티가 추가될 때마다 다른 경로를 추가해줘야 하기 때문에 유지보수면에서 좋지 않다.

 

따라서 우리는 위 그림과 같이 Model들을 하나의 Model로 묶어서 작동하도록 만들 것이다.

이 접근방식은 두 Model 프로퍼티에 대한 코드 경로가 하나뿐이고, 다른 Model 프로퍼티를 추가해도 유지되기 때문에 

매우 유용하다. 

 

Model 묶기

 

'DreamListViewController' 에 있던 두 개의 모델 프로퍼티를 새로운 'Model' 구조체로 옮기고, 

'DreamListViewController' 에 새로운 모델 프로퍼티를 추가해주었다.

 

'실행 취소' 기능 구현

 

왼쪽에는 뷰컨트롤러의 모델 값이 표시되고, 오른쪽에는 작업과 '실행 취소' 스택이 있다.

'dreams[1]' 이 제거되면, 테이블 뷰에서 해당 row를 삭제하는 식이다.

모델 프로퍼티를 변경하고, 뷰를 업데이트하는 것을 개별적으로 진행하는 방식은 불안정하다.

왜냐하면 모델의 변경을 뷰의 변경과 정확하게 일치시켜야 하기 때문이다.

그렇게 하지 않으면 모델과 뷰 사이의 불일치로 인한 버그가 발생할 수 있다.

 

UI 업데이트

 

뷰 컨트롤러에서는 모델이 변경될 때마다 'modelDidChange()' 가 실행된다.

이전 모델 값과 새 모델 값의 차이를 찾고 일치하도록 UI를 업데이트 해야한다.

 

old Model의 'favoriteCreature' 와 new Model의 'favoriteCreature' 를 비교한다.

그리고 둘의 비교를 통해 테이블뷰의 섹션을 reload한다.

그러면 'favoriteCreature's row 가 포함된 섹션을 변경할 것이다.

 

마지막으로 모델을 이전 값으로 재설정하는 'registerUndo' 를 수행한다.

 

이점

 

UI를 업데이트할 수 있는 단일 코드 경로가 있고, 작업이 순서에 구애받지 않는다.

UI 업데이트 코드와 우리의 코드의 로컬 추론에 도움이 많이 되었다.

 

UIState

 

네비게이션 오른쪽 공유 버튼을 누르면 테이블뷰가 선택가능한 'selecting' 상태로 변경된다.

그리고 선택하고 공유하기를 누르면 'Sharing'으로 변경된다.

문제는 'selecting' 상태에서 취소버튼을 누르면 다시 'viewing' 상태로 넘어가야 하는데,

UI가 그에 맞게 변경이 안되는 불일치가 발생한다.

 

상태를 나타내는 뷰컨트롤러 코드.

 

자 이 코드는 상태가 변경될 때마다 각 프로퍼티를 모두 변경해주어야 한다.

너무 번거롭죠?

 

enum의 사용

 

이렇게 UI의 상태를 변경해주는 기능이 필요할 경우 enum을 사용하면 효과적으로 처리할 수 있다.

 

 

 

 

마치며..

본 세션은 MVC 패턴을 사용하는 앱에서 프로토콜과 값 타입을 어떻게 활용하며, 이로인해 어떤 이점을 얻을 수 있는지 다룬다.

 

일반적으로 MVC 패턴에서는 모델 계층에서만 값 타입을 이용하여 이점을 가져온다.

하지만 이 세션에서는 뷰와 컨트롤러 계층에서도 값 타입을 효율적으로 활용하는 방법을 소개한다.

 

뷰 계층에서 다룬 주제 

1. 레이아웃 기능의 재사용성 및 단위 테스트를 향상시키기 위해 프로토콜과 값 타입을 사용하는 방법.

2. 제네릭을 통해 타입의 안전성을 보장하고, 컴파일 타임을 최적화하는 방법.

3. Class를 사용하지 않고 값 타입의 Composition 을 통해 복잡한 레이아웃을 설정하는 방법.

4. 프로토콜의 제네릭 기능인 'associatedtype' 을 통한 타입 안전성, 그리고 where 절을 사용한 타입 제약에 대한 방법.

 

컨트롤러 계층에서 다룬 주제

1. 여러 Model 데이터를 하나로 묶어 UI와의 불일치 문제를 해결하는 방법.

2. UI State 관리를 위해 enum을 활용하는 방법. 

 

해당 세션이 오래된 세션이긴 하지만,

Swift에서 프로토콜을 어떻게 활용할 수 있는지에 대한 경험을 제공한다.

Swift에서는 Class의 사용보다는 값 타입과 프로토콜을 활용하는 것을 지향하고 있고,

프로토콜을 사용하기 위해서는 해당 기능이 필요로 하는 요구사항을 먼저 파악해야 함을 알 수 있다.

예를 들어 'UIView' 타입이었던 레이아웃 기능을 근본적으로 필요한 것이 무엇인지를 먼저 파악하고, 프로토콜로 요구사항을 만들어 유연성을 가져가는 것이 인상 깊었다.

 

해당 세션의 핵심 메세지는 다음과 같다.(개인적 의견)

"ValueType과 Protocol을 같이 사용하면, 참조타입이라는 비싼 타입을 사용하지 않고, 앱을 만들 수 있다! "

 

 

 

 

Reference:

https://developer.apple.com/videos/play/wwdc2016/419/