stack 영역을 사용한다. Heap을 쓰지 않으므로 Reference couting 이 필요하지 않음
다른 변수에 할당한 경우 값 전체가 복사가 되며, 할당된 값을 변경하더라도 기존의 값이 변경되지 않는다.
따라서 Thread 의 의도치 않은 공유로부터 안전하다.
Identity가 아닌 Value 자체가 중요하다.
그렇다면 성능은?
내부 데이터(property)가 만약 Heap 과 혼용되는 경우는 결국 값의 복사 + 레퍼런스도 카피가 된다.
이때 말하는 내부 데이터를 예를 들자면 string, array, set, dictionary
또한 COW(Copy On Write) 를 통해서 불필요한 작업을 줄임
만약 immutable 하게 만들면 어떤가?
실행할때 새로운 객체를 만드는 것은 비효율적이다. mutable + value type의 경우 값을 변경하면 되는데 immutable + reference 의 경우 객체를 생성해서 넘겨야하는 경우가 있다면. 변경하지 않아도 되는 값 마저 다 생성해서 넘기는 것이 맞는 것인가? 에 대한 생각이 든다.
Class
언제쓰는가? Value 자체보다 Identity가 더 중요한 경우가 있다.
대표적으로 UIView 를 생각해보자.
성능에 영향을 미치는 3가지
stack vs heap
reference counting
method dispatch > static vs dynamic
stack vs heap - Heap 할당 줄이기
Heap 할당이 성능에 영향을 미치는 요소는 다음이 포함되어 있다.
비워진 공간을 찾고 원하는 만큼 할당시켜야 하며, 이를 관리하는 것이 복잡하다.
동시에 thread safe 해야하기 때문에 lock synchroniztion 동작등이 성능에 큰 영향을 미친다.
예를 들어 Dictionary 의 key값을 설정할때 string 을 사용하지 말고 struct를 활용해보자
string을 사용하는 경우 변수에 할당할때 heap 에 저장하게 된다. 만약 loop 내부에서 돌게 된다면???
따라서 value type인 struct를 써보자 이때 hashable 만 채택하면 된다.
reference counting
정말 자주 사용된다. 또한 성능에 영향을 미치는 큰 이유중 하나가 thread safe 해야하기 때문
따라서 최대한 줄이는 것이 좋다. 위와 마찬가지로 loop 내부에 있다면…?? value sementics에 비해 성능이 좋지 못할 것
Method Dispatch
static
컴파일 시점에 메소드의 실제 코드 위치를 안다면 실행중 찾는 과정 없이도 해당 코드 주소로 점프가 가능함.
컴파일러 최적화 및 메소드 인라이닝 가능
메소드 인라이닝이란, 컴파일 시점에 메소드 호출부도 같이 넣은것 처럼 동작하는 것
이로 인해 call stack overhead 가 줄어들고 루프 안에서 호출하는 경우는 큰 효과를 누릴 수 있음
dynamic
static 과 다르게 컴파일 시점에는 어디에 있는 메소드가 호출되는지 모른다. 따라서 런타임시 이를 찾아내야함. 이로 인해 컴파일러 최적화가 어려움.
따라서 static 으로 강제하기 위해서 final, private 의 사용을 습관화 해야함
Xcode : Whole Module Optimization 옵션 사용할 것
추상화 기법들
Class 는 성능 안좋은 것은 다 가지고 있다.
그나마 개선이 가능한 부분은 final 을 통한 static dispatch
Struct 를 사용하는데 내부에 Reference Sementics 가 많다면??
값을 복사한다고 가정했을 때 내부의 reference Type 만큼 reference counting 이 일어나게 됨. 이는 성능에 영향을 미친다.
참고로 String, Array, Dictionary 도 Struct 이지만 내부적으로는 reference 를 사용하고 있다.
따라서 줄이기 위해서 enum 등을 사용하고, 가능하다면 reference type들을 묶을 수 있는 방법을 생각해보자.
Protocol 은 Value Type에서도 다형성을 구현할 수 있는 큰 장점이 있다.
의문점 1 : 어떤 타입이 들어갈지 모르는데 사이즈를 어떻게 계산해 놓는가?
일정 크기를 넘어가면 내부적으로 heap을 사용하고, 실제로는 주소만 갖고 있다. 프로토콜의 실제 값을 관리하는 Existential Container를 통해 이뤄진다.
이렇게 일정 크기를 판단하고 Existential Container를 관리하는 주체는 VWT ( Value Witness Table ) 라고 한다. 이는 각 프로토콜 마다 존재한다.
의문점 2 : 어떻게 적절한 함수를 호출하는가?
PWT ( Protocol Witness Table )이 있다. V-table과 유사함.
위에서 설명한 VWT, PWT 는 Extential Containor 내부에서 참조할 수 있다.
다시한번 볼것 VWT, PWT 차이점 등등
만약 큰 값을 가진 Protocol 을 복사한다면?
위에서 말한것 처럼 Heap을 사용하는 protocol을 복사하게 되면 heap을 또 사용해야한다. 즉, 복사를 할때마다 heap 을 할당해야함. 이는 비 효율적
이는 COW ( Copy On Write ) 를 통해서 개선하게 되었다.
Generic
protocol 과 비슷하게 VWT, PWT 로 구현이 되어있다.
Generic 특수화 : 여러 타입을 대응하려고 Generic을 만들었지만, 만약에 성능을 생각해서 각 타입으로 나눠서 쓴다면 메소드 인라이닝이 가능해져서 성능이 좋아질 것이다. > 성능을 생각하면 Generic을 쓰면 안되는가?? > WMO가 이를 대신 해준다.
이런 특수화는 정적 다형성(Static Polymorphism) 덕분에 가능하다. > 컴파일 시점에 부르는 곳 마다 타입이 정해져 있고, 런타임시 바뀌지 않으며, 특수화가 가능하다.
저번 포스팅에서 설명했듯이 이번에는 Layout 관련된 포스팅을 작성해보려고 합니다. CompositionalLayout 으로 넘어오면서 하나의 페이지 전체를 CollectionView로 구성하는 경우도 굉장히 많아졌습니다. 그만큼 확장성 및 활용도가 굉장히 뛰어난 Layout 이라고 생각합니다. 문서를 간단하게 읽고 이후에 직접 적용해보겠습니다.
문서를 먼저 살펴보게되면, 한마디로 유연한 레이아웃 객체라고 설명할 수 있습니다. iOS 13 부터 지원을 하며 UICollectionViewLayout 을 상속받는 것을 확인할 수 있습니다.
@MainActor ?
MainActor가 붙어있는 것을 확인할 수 있습니다. 마찬가지로 iOS 13 부터 사용이 가능하며, 문서를 살펴보면 “A singleton actor whose executor is equivalent to the main dispatch queue.” 라고 되어있습니다. main dispatch queue에서 실행할 수 있도록 하는 actor라고 되어있네요. 즉, 이 친구를 통해서 Main Thread 에서 실행을 시키겠구나 라고 생각하시면 될 것 같습니다. 문서를 들어가보면 actor, @globalactor 등의 정의가 되어있는데 이 부분은 다음에 Actor를 정리할때 작성해보겠습니다. 일단은 이정도만 보고 넘어가도록 하겠습니다.
Compositional Layout 은 하나 이상의 섹션으로 이루어지며, 각 섹션은 items를 들고있는 Group 들로 이루어져 있습니다. 따라서 Compositional Layout을 구성할때는 Section Group Item 세가지를 먼저 구성해야 완성되는 것을 알 수 있습니다. 문서에 나온 예제 코드 간단하게 살펴보겠습니다. 코드가 길지만 알고보면 4개의 단계로 나뉘어져 있다는 것을 확인할 수 있습니다.
func createBasicListLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// Phase 1 : Set Item
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(44))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
// Phase 2 : Set Group
let section = NSCollectionLayoutSection(group: group)
// Phase 3 : Set Section
let layout = UICollectionViewCompositionalLayout(section: section)
// Phase 4 : Set Layout
return layout
}
처음에는 section 과 group 의 차이가 뭐지? 라는 생각을 굉장히 많이 했습니다. 쉽게 설명 드리자면 그룹이 큰 바구니이고 내부에 여러개의 아이템이 있다로 생각하시면 좋습니다. 또한 섹션의 헤더와 푸터, 인셋등을 설정할때의 기준이 그룹이 된다는 것을 인지하시면 좋습니다. 이제 예제를 만들어가면서 코드를 보고 설명을 이어나가겠습니다.
< Practice : App Store Carousel UI >
Compositional Layout 을 정말 잘 활용해서 만든 앱이 앱스토어라고 생각합니다. 앱스토어를 보면서 간단한 예제를 한번 만들어보겠습니다. Carousel UI가 바로 보이는군요! 세부특징을 아래와 같이 설명할 수 있을 것 같습니다.
하나의 셀의 Width가 전체 Width 의 60% 정도 (혹은 250 fix), 높이는 대략 150
spacing은 8인데 양옆에는 20 정도의 inset이 있음
헤더뷰가 있고 다음 섹션과의 차이는 40 정도
디테일은 위와 같이 되어있다고 가정하고 코드를 작성해보겠습니다. 그 전에 Layout을 잡기 전에 기본 설정들을 먼저 살펴보겠습니다.
private enum Section: Int, CaseIterable {
case iconicCharacter
case categories
}
private typealias Datasource = UICollectionViewDiffableDataSource<Section, AnyHashable>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, AnyHashable>
private lazy var mainCollectionView = UICollectionView(frame: .zero, collectionViewLayout: compositionalLayout)
private var compositionalLayout: UICollectionViewLayout = {
UICollectionViewCompositionalLayout { sectionIndex, env in
// SectionProvider
switch Section(rawValue: sectionIndex) {
case .iconicCharacter:
return baseCarouselLayout()
case .categories:
return baseCarouselLayout()
case .none:
return nil
}
}
}()
먼저 명확하게 Section을 선언해주었습니다. enum을 사용해 혹시 모를 human error 를 방지할 수 있기 때문에 이를 자주 활용하는 편입니다. 또한 자주 사용되는 Datasoure와 Snapshot을 typealias 로 관리할 수 있도록 하였습니다. 이렇게 레이아웃을 구성하기 전 Layout과 필요한 요소들을 미리 선언해두었습니다.
UICollectionViewCompositionalLayout 설정
UICollectionViewCompositionalLayout은 위의 SectionProvider를 활용한 이니셜라이저를 통해서 만들어 주었습니다. SectionProvider의 경우 상단에 typealias로 선언되어 있으며 이를 통해서 NSCollectionLayoutSection 을 리턴해줘야 한다는 것을 알 수 있습니다. 인자로 받는 Int의 경우 sectionIndex 이며 enviroment 를 통해서 다양한 값들을 받을 수 있습니다.
위에서 설정한 CollectionViewLayout은 CollectionView 전체에 대한 Layout 입니다. 따라서 각 섹션에 들어갈 Layout을 설정하고 이를 리턴해줘야 합니다. 복잡해 보이지만 하나씩 조립한다고 생각하시면 조금 수월할 것입니다. 그렇다면 해당 섹션의 Layout 을 설정해주겠습니다.
private func baseCarouselLayout() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// let groupWidth: NSCollectionLayoutDimension = .absolute(250)
let groupWidth: NSCollectionLayoutDimension = .fractionalWidth(0.6)
let groupSize = NSCollectionLayoutSize(widthDimension: groupWidth, heightDimension: .absolute(150))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 40, trailing: 20)
section.interGroupSpacing = 8
section.orthogonalScrollingBehavior = .groupPaging
return section
}
먼저 설정은 아이템 > 그룹 > 섹션 순서이지만 설계를 할때는 그 반대로 하는것이 편합니다. 사전개념 없이 해당 UI를 살펴보면 하나의 큰 카드가 옆으로 스크롤되는 것을 확인할 수 있습니다. 이때 전체적인 부분이 하나의 섹션이고 그리고 카드를 섹션 이라고 생각하면 됩니다. 그리고 그 섹션 안에 하나의 아이템이 꽉 차있다고 생각합니다.
설정은 역순으로 했다고 말씀드렸습니다. 따라서 카드안에 꽉차있는 아이템을 표현해야합니다. 아이템을 설정하기 위해서 사이즈를 먼저 설정해야 합니다. 이때 사이즈의 설정에 사용되는 변수인 LayoutDemension는 사진과 같이 정의되어 있습니다.
여기에서 fractional이 붙은 함수를 사용하여 LayoutDemension을 잡아줍니다. 이는 주어진 영역에서 몇퍼센트를 차지할 것인가? 를 설정하는것입니다. LayoutDemension.fractionalWidth(0.5)라고 한다면 주어진 영역의 50% 를 차지한다는 뜻입니다. 꽉 차있는 아이템을 표현해야하기 때문에 1.0을 설정하여 100%를 차지하도록 하였습니다.
그 다음 해당 아이템이 포함된 그룹입니다. 그룹도 마찬가지로 사이즈를 설정해야합니다. 그룹의 너비는 주어진 영역의 60%를 차지하도록 하였습니다. 그리고 height의 경우 150px로 설정하기 위해서 .absolute(150)를 설정해주었습니다. fractional, absolute 이외에도 estimated를 볼수 있는데 셀의 layout에 따라 크기를 맞춰줍니다. 이는 다음 기회에 조금 더 알아보겠습니다. 그룹의 사이즈를 통해서 그룹을 만들어 줍니다. 그룹의 사이즈와 아이템들을 받는 것을 알 수 있습니다. 여러개의 아이템 레이아웃을 활용할 수 있도록 Array로 받는 것을 확인할 수 있습니다. 지금은 하나의 아이템이므로 만들어둔 아이템 하나만 넣습니다.
이렇게 아이템 > 그룹 설정이 완료되었습니다. 마지막으로 해당 그룹을 통해서 섹션을 만들어 줍니다. NSCollectionLayoutGroup는 크게 horizontal, vertical, custom 이렇게 나누어집니다. 여기서는 섹션들을 좌우로 스크롤할 예정이므로 horizontal을 사용합니다.
각 그룹의 거리는 8이어야 하므로 interGroupSpacing을 8로 설정합니다. 또한 섹션 내부의 그룹에 대한 inset을 설정해줍니다. 이를 통해서 양옆 그리고 하단의 inset을 설정해주었습니다. 마지막으로 원하는 방식대로 좌우 스크롤을 하기 위해서는 꼭 orthogonalScrollingBehavior를 설정해야 합니다. .groupPaging 으로 설정하면 원하는 carousel 동작이 되는 것을 확인할 수 있습니다. 이 외에도 .continuous, .groupPagingCentered 등등 값이 있으니 한번 직접 실행시켜 확인해보시기를 권장드립니다.
마지막으로 collectionView에 연결된 dataSource에 snapshot를 만들어준 뒤 적용합니다. 이를 모두 적용하면 아래와 같은 결과물을 얻을 수 있습니다. 셀의 설정등은 글이 길어져서 생략하였습니다. 필요하시다면 하단 레퍼런스를 살펴보시길 바랍니다.
섹션 헤더
마지막으로 섹션 헤더를 설정해보겠습니다. UICollectionReusableView를 만들어 collectionView에 등록해줍니다. 이후 dataSource에 각 세션마다 활용할 reusableView 를 아래와 같이 설정해줄 수 있습니다.
// supplymentaryViewProvier는 아래 SupplementaryViewProvider 입니다.
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind,
withReuseIdentifier: "BaseCarouselHeaderView",
for: indexPath)
return headerView
}
// SupplementaryViewProvider From Document
public typealias SupplementaryViewProvider = (_ collectionView: UICollectionView, _ elementKind: String, _ indexPath: IndexPath) -> UICollectionReusableView?
동일하게 indexPath를 통해서 각 섹션에 필요한 view를 설정할 수 있습니다. 이후 supplymentaryView의 Layout을 설정해야 합니다. NSCollectionLayoutSection에 아래와 같이 작성하겠습니다.
위에서 설정한 것처럼 먼저 size를 잡아줍니다. 그리고 kind를 설정한 뒤 header를 어디에 위치시킬것인지 설정합니다. 일단 alignment 를 .top으로 설정해주면 아래와 같은 결과가 나옵니다. ( 구분하기 편하도록 collectionView에 검정색 border를 넣고 Section Header에는 background color를 주었습니다. )
나름 성공적이지만, 조금만 더 들어가보겠습니다. 만약 섹션 헤더를 왼쪽으로 붙이고 싶다면 어떻게 할까요? 단순히 alignment를 .topleading으로 설정하면 끝일 것 같으나, 결과는 위와 동일하게 양옆에 inset이 남아있습니다. 그 이유는 layoutSize의 width가 fractionalWidth(1.0)이기 때문입니다. 이로 인해 저 상태가 top, leading, trailing가 이미 붙어있는 형태가 됩니다.
따라서 양 옆의 inset만큼 조절이 필요합니다. NSCollectionLayoutBoundarySupplementaryItem 이니셜라이저 중에는 absoluteOffset을 받을 수 있는 이니셜라이저가 있습니다. 섹션의 leading inset이 20이므로 아래와 같이 absoluteOffset을 설정한다면 원하는 결과가 나오는 것을 확인할 수 있습니다.
하지만 여전히 SectionHeader가 상하단과 붙어있는 UI가 살짝 불편하네요. 상하단에 10씩 inset을 주고싶다면 어떻게 하면 될까요? alignment를 .top으로 주고 absoluteOffset의 Y를 10으로 주면 될까요?? 상단에 inset이 생길것 같으나 그렇지 않습니다. 말한 내용을 적용한 UI를 살펴보면 아래 그림과 같습니다.
섹션헤더의 영역을 30이라고 가정했을때 10만큼 아래로 밀려나는것을 확인할 수 있습니다. 이유는 이미 alignment를 통해서 .top에 붙어있기 때문에 offset을 주더라도 섹션 헤더와 상단의 inset은 생기지 않습니다. 즉 alignment를 통한 제어로는 원하는 결과를 얻을 수 없습니다.
이를 위해서는 NSCollectionLayoutSupplementaryItem의 이니셜라이저 중에서 NSCollectionLayoutAnchor를 인자로 받는 이니셜라이저를 사용해야 합니다. NSCollectionLayoutAnchor 설명을 보면 8방향을 설정할 수 있으며, 여기에 offset을 설정할 수 있습니다. Anchor라는 이름을 보시면 아시겠지만, alignment와 다르게 offset이 설정되는것을 짐작할 수 있습니다.
이것이 끝이면 좋겠으나 하나의 아쉬운 점이 남아있습니다. alignment를 통해서 설정한 경우 section Header를 위한 영역을 마련해주었지만, anchor는 그렇지 못합니다. 애초에 alignment를 받는 이니셜라이저는 NSCollectionLayoutBoundarySupplementaryItem에 있으나, anchor를 받는 이니셜라이저는 그 부모인 NSCollectionLayoutSupplementaryItem에 있습니다. 상속받은 만큼 편하게 만들어둔 것이죠.
따라서 Section의 topInset은 10(상단 inset) + 30(sectionHeader Height) + 10(하단 inset) = 50 이 되어야 하고, anchor를 아래와 같이 설정하면 50 내부에 Section Header가 위치하는 것을 확인할 수 있습니다. 완성된 layout 코드는 아래와 같습니다.
private func baseCarouselLayout() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// let groupWidth: NSCollectionLayoutDimension = .absolute(250)
let groupWidth: NSCollectionLayoutDimension = .fractionalWidth(0.6)
let groupSize = NSCollectionLayoutSize(widthDimension: groupWidth, heightDimension: .absolute(150))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
// top을 50으로 변경!
section.contentInsets = NSDirectionalEdgeInsets(top: 50, leading: 20, bottom: 40, trailing: 20)
section.interGroupSpacing = 8
section.orthogonalScrollingBehavior = .groupPaging
let supplementaryItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20))
let supplementaryItemLayoutAnchor = NSCollectionLayoutAnchor(edges: [.top], absoluteOffset: CGPoint(x: 0, y: 10))
let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: supplementaryItemSize,
elementKind: UICollectionView.elementKindSectionHeader,
containerAnchor: supplementaryItemLayoutAnchor)
section.boundarySupplementaryItems = [supplementaryItem]
return section
}
오랜만에 어떤 글을 쓸까 고민하다가 가장 많이 활용되는 것부터 하나씩 파헤쳐보자는 생각에 Diffable Datasource 관련된 글을 쓰기로 결정했습니다. 생각보다 양이 많을것 같아서 조금 주저했는데, 그래도 앞으로도 계속 사용될 친구이기 때문에 이 부분에 대해서 작성하기로 하였습니다. 먼저 Diffable의 데이터를 책임지는 SnapShot에 대해서 글을 쓰고 이후에 Layout과 dataSource에 대해서 글을 작성할 예정입니다. 문서를 기반으로 작성되었으며 하단에 레퍼런스 기재하였습니다.
NSDiffableDataSourceSnapshot
A representation of the state of the data in a view at a specific point in time. 특정 시점에 대해서 데이터에 대한 상태를 표현한것 이라고 되어있군요. iOS 13부터 사용이 가능하며, Generic Structure로써 SectionIdentifiertType 그리고 ItemIdentifierType을 받아야 하고 이는 모두 Hashable 과 Sendable 을 채택해야 합니다. 또한 앞에는 @proconcurrency 라는 propertyWrapper 가 달려있군요. 이 세가지에 대해서 한번 알아보겠습니다.
Hashable?
A type that can be hashed into a Hasher to produce an integer hash value. Hasher를 통해서 Int값인 HashValue를 제공할 수 있는 타입이라고 되어있습니다. 그리고 Equatable을 채택하는 것을 확인할 수 있습니다.
사실 여기서는 가볍게 넘어가지만 해쉬라는 개념은 컴퓨터 분야에서 굉장히 자주 사용됩니다. 간단하게 말씀드리자면 어떤 값에 대해 해쉬 함수를 통해서 해쉬 값을 얻습니다. 이를 통한 검색과 저장이 용이하며 O(1) 만큼의 시간만 걸리기 때문에 많이 사용됩니다. 물론 무조건 다 되는것도 아니고 상황에 따라서는 해쉬 충돌도 야기시킬 수 있습니다. 자세한 내용은 따로 공부하는 것을 추천드립니다.
Swift에서도 Hashable protocol은 범용적으로 활용이 되고 있습니다. String, Set, Dictionary 등등 값이 같은지 다른지 비교하는데 많이 사용됩니다. 따라서 어떤 상황에 따라서는 Hashable을 채택한 값을 받도록 하는 경우도 있으며, 직접 만든 타입에 Hashable 을 채택해야 하는 경우도 있습니다. 참고로 Swift 에서는 structure 의 모든 프로퍼티가 Hashable 을 채택하는 경우와 enum에서 모든 associated value가 Hashable 을 채택하는 경우 hash값을 자동으로 만들어줍니다. 여기서는 이정도만 알고 넘어가겠습니다.
Sendable?
A type whose values can safely be passed across concurrency domains by copying. concurrency 환경에서 복사를 통해 값이 안전하게 전달될 수 있는 타입을 말합니다. 기본적으로 Value Type, Reference Type중 mutable storage가 없거나 상태가 내부적으로만 관리되는 타입 그리고 function, closure 등이 있습니다. Sendable 의 경우 내부에 따로 채택해야할 함수나 값은 없지만, 상황에 따라서는 명시적으로 채택해야 한다고 합니다.
@Preconcurrency
결론만 얘기하자면 의도치 않은 warning을 제거하기 위함입니다. Swift 버전에 따라서 Sendable이 충분히 채택되지 않은 경우가 있는데 이 경우 warning이 발생할 수 있습니다. 이런 워닝을 없애기 위해서 만들었다고 하며, 정리하기에는 내용이 길기 때문에 하단에 레퍼런스 남겨두겠습니다. proposal link를 확인하시길 바랍니다.
NSDiffableDataSourceSnapshot Document
다시 돌아와서 문서쪽을 살펴보겠습니다. diffableDataSource는 Snapshot을 통해서 데이터를 관리합니다. 이를 통해서 디스플레이를 해주고 싶은 데이터를 관리하며 변경점 또한 반영할 수 있습니다. 이는 섹션과 아이템으로 만들어지며 추가 삭제 이동등의 작업을 할 수 있습니다.
스냅샷을 만들어서 이를 datasource에 적용 시킬때는 두가지 방식으로 접근할 수 있습니다. 빈 스냅샷을 만들어서 섹션과 아이템들을 넣는 방식과 기존 스냅샷을 가져와 변경하는 방식입니다. 아래 간단하게 코드로 예시를 나타냈습니다.
// 이렇게 선언해두면 사용하기에 편합니다.
private typealias SnapShot = NSDiffableDataSourceSnapshot<Int, String>
---
// 기본 데이터 - 1번째 방식
var snapShot = SnapShot()
snapShot.appendSections([0, 1])
snapShot.appendItems(["hello", "world"], toSection: 0)
snapShot.appendItems(["hello2", "world2"], toSection: 1)
dataSource.apply(snapShot)
----
// 데이터 추가 - 2번째 방식
var originSnapshot = dataSource.snapshot()
originSnapshot.deleteItems(["world2"])
// or
originSnapshot.appendItems(["world3"], toSection: 1)
dataSource.apply(originSnapshot, animatingDifferences: true , completion: nil)
일단 dataSource.apply는 다음에 알아볼 예정이므로 저렇게 하면 적용이 되겠구나 까지만 알아두시면 되겠습니다. 이렇게 기존에 있던 dataSource에 접근하여 snapshot을 가져오면 현재 data가 담겨져 있는 snapshot을 가져올 수 있고 이를 수정하여 적용시킬 수 있습니다.
중요한점은 각각의 섹션과 아이템은 Hashable을 채택한 unique identifiers를 가져야 합니다. 만약 class를 identifier로 사용한다면 해당 클래스는 NSObject를 상속받아야 합니다. 참고로 itemidentifier는 Hash Value입니다. 그래서 itemIdentifierType은 Hashable을 채택해야 사용할 수 있는 것입니다. 이는 문서에 명시적으로는 나와있지 않으나 WWDC 2019 에서 확인할 수 있었습니다.
For our mountain type, we'll look at our Mountains Controller, which is again, our model layer. And here, we see that we've declared mountain as a Swift struct. And we declared that struct type as hashable so that we can use it with DiffableDataSource natively rather than explicitly have to pass an identifier. And the important requirement there is just that each mountain beuniquely identifiable by its hash value. So we achieve this by giving each mountain an automatically generated unique identifier.
WWDC2019 : Advances in UI Data Sources
Q : 동일한 데이터를 계속 넣고 싶다면?
우리는 데이터를 item으로 변환하여 snapshot에 적용하고 있습니다. 위에서 설명을 보셨다시피 동일한 데이터를 통해서 동일한 item을 넣는것은 안된다고 하였습니다. 왜냐면 어떤 아이템에 대해서 어느 섹션에 어디에 위치시킬지 명확하게 알기 위해서입니다. 그리고 이것이 중복이 된다면 더 이상 명확하지 않기 때문입니다. 마치 기존에 indexPath는 어떠한 셀이라도 다른것과 마찬가지 입니다. 그렇다면 다른 섹션에는 동일한 데이터라도 괜찮지 않을까요?? 그렇지 않습니다. 이는 snapshot이 데이터를 어떻게 다루는지를 보면 알수 있는데, 예를 들어서 deleteItems([ItemIdentifierType]) 을 봤을때. 어떤 섹션에 있는 아이템들을 삭제하는지 명시하지 않습니다. 즉 스냅샷에 대해서 아이템들은 유일한 identifier를 가진다는 것을 의미합니다. 따라서 스냅샷은 섹션과 아이템 모두 유일하여야 합니다.
하지만 현실에서는 상황에 따라 동일한 UI를 계속해서 보여줘야 하는 경우가 있을수도 있습니다. 그렇다면 어떻게 해야 할까요? 만약 1이라는 숫자를 3개의 셀에서 동시에 보여줘야 한다면??? 같은 테이블뷰 혹은 같은 섹션이라면??? 보여지는 것은 1 이더라도 각 셀은 유일한 identifier를 가져야 합니다. Int만으로는 동일한 1이라는 숫자를 표현할 수 없습니다. 1의 해쉬값은 동일하니까요. 따라서 새로운 타입이 추가되어야 하며 이는 Hashable 을 채택해야 합니다. 다음과 같이 선언하고 value 값만 가져와서 사용하면 됩니다.
struct NewItem: Hashable {
private let id = UUID()
var value: Int
}
이직을 하면서 처음에 가장 어려움을 겪었던 부분이 Storyboard 기반으로 UI를 작성하는 일이었습니다. UI를 그리는 자체는 어렵지 않았으나, 생성되는 시점이 기존에 사용하던 CodeBase와는 너무 달랐기 때문이었습니다. 이에 대해서 정리를 해야겠다고 생각을 했고, 생성 시점, File's owner 등등 여러가지 주제를 다룰 생각입니다. 하지만 제일 먼저 UINib에 대해서 명확하게 아는 것이 중요하다고 생각하여 오늘은 UINib에 대해서 정리를 해볼 생각입니다.
UINib
An object that contains Interface Builder nib files.
먼저 문서를 기반으로 몇가지를 살펴보겠습니다. 정의를 살펴보면 Interface Builder nib files 들을 갖고 있는 객체라고 합니다. 첫번째로 한개가 아닌 여러개의 nib files 라는 것을 알 수 있습니다. 조금 더 읽어보겠습니다.
UINib은 nib file 콘텐츠를 caching 하고 있으며, unarchiving 그리고 instantiation 을 위한 준비를 합니다. 만약 nib의 콘텐츠가 필요한 경우 data를 로드할 필요 없이 부를 수 있기 때문에 성능측면에서 좋습니다. 만약 메모리가 부족한 경우 메모리에 있는 캐시된 nib 파일을 날립니다. 그리고 다음에 필요한 경우 nib을 생성합니다. ( 캐시된 데이터가 날라갔으므로 다시 data를 로드해서 부른다는 말 )
만약 반복적으로 instantiate가 필요한 경우 UINib을 사용해야 합니다. 왜냐면 이미 UINib에 대한 contents가 캐싱 되었기 때문에 이는 성능에 대한 향상으로 이뤄질 수 있습니다.
만약 nibfile의 contents를 사용해서 UINib 객체를 생성했다면, 해당 객체는 object graph에 있는 nib file reference를 통해서 생성한 것이지만, unarchiving이 제대로 되지 않은것 입니다. nib data를 모두 unarchive하기 위해서 instatiate(withOwner:options:)를 호출해야 합니다. UINib Object와 nib's object graph 에대한 자세한 내용은 Resource Programming Guide 를 살펴보시면 됩니다.
간단하게 알아보려 했으나 새로운 내용들이 자꾸만 나오는군요. archive 그리고 unarchive라는 개념, nib file 의 caching, 그리고 resource programming guide 까지 알아야 할 부분들이 참 많은 것 같습니다. Resource Programming Guide 를 먼저 읽어야 조금 수월하게 읽힐 것 같은 느낌이지만, 일단 지금은 나와있는 init 그리고 위에서 언급된 instantiate 를 한번 살펴보겠습니다.
UINib init
init(nibName:bundle:)
Returns a nib object from the nib file in the specified bundle.
특정한 번들로부터 nib file을 리턴한다고 합니다. name 인자는 따라서 nibfile의 이름이 될 것이고, bundle은 특정한 bundle이 되겠군요. 여기서 Bundle의 경우 Optional로 되어 있습니다. 이 경우에는 main bundle에서 nib file을 자동으로 찾는다고 합니다. ( bundle의 정확한 개념도 한번 공부해야봐야 겠습니다 ㅠㅠ )
file의 위치를 찾지 못하는 경우 error를 던지는 것을 확인할 수 있습니다.
init(data:bundle:)
Creates a nib object from nib data stored in memory.
메모리에 있는 저장된 Nib data로부터 객체를 생성합니다. 위의 initializer와 마찬가지로 data를 찾지 못하는 경우에 error를 던지는 것을 확인할 수 있습니다. 동일한 듯 보이지만 사실상 애플에서는 상단에 있는 nibName을 통해서 UINib 객체를 생성하는 것을 선호한다고 합니다. 그 이유는 위에서 잠깐 언급했듯이 low memory 인 경우에서 memory에 있는 캐시된 nib data 는 release 될 수 있다고 했는데요, 만약 init(data:bundle:)을 사용한다면 해당 데이터는 release 되지 않는 특성이 있다고 합니다. 그러므로 상황에 따라서 memory를 관리할 수 있도록 하는 nibName을 통해서 init을 하라고 말하는 것 같습니다.
두 이니셜라이저 모두 bundle에 있는 language-specific한 directories를 먼저 찾으며, 그 이후 Resources directory 에 있는 nib file을 찾습니다.
instance Method
그렇다면 위와 같은 과정을 통해서 UINib 객체를 만들었을 때 사용하는 메서드를 살펴보겠습니다. instantiate(withOwner:options:) 입니다.
instantiate(withOwner:options:)
Unarchives and instantiates the in-memory contents of the nib object’s nib file, creating a distinct object tree and set of top-level objects.
메모리에 있는 nib data를 unarchive 하고 instantiates 하여, 고유한 객체 트리와 최상위 레벨 집합을 만듭니다.
말이 조금 어렵네요. 제가 생각하기에는 메모리상에는 캐싱된 데이터가 있으며 이를 통해 내부에 있는 nib files를 만들 수 있는데, instantiate 를 해서 공유되지 않은 고유한 객체들을 만든다고 이해하면 될 것 같습니다. 따라서 UINib만 만들어 사용하면 되는 것이 아니라 instantiate를 통해서 우리가 사용할 수 있게끔 따로 객체를 만들어야 하는 것으로 이해할 수 있습니다.
두개의 파라미터에 대해서 살펴보자면 먼저 첫번째 파라미터 withOwner 자리에 들어가는 ownerOfNil: Any? 가 있습니다. nib file의 owner(file's Owner)로 사용하는 객체를 넣어야 합니다. 만약 nib file이 owner 가 있다면, 반드시 유효한 값의 객체 (owner) 를 넣어야 합니다.
생각해보니 우리가 nib을 만들고 이를 사용하기 위해서는 두가지 방식이 있습니다. 첫번째로 file's Owner를 사용하는 방법, 두번째로 Custom Class를 사용하는 방법입니다. 이때 첫번째 file's Owner 방식으로 Nib을 사용하고 싶다면 반드시 owner 에 유효한 객체를 넣어야 함을 이해할 수 있겠군요!
그 다음 두번째 파라미터 optionsOrNil: [UINib.OptionsKey : Any]? 가 있네요. nibfile을 열때 사용하는 옵션이라고 이해할 수 있겠습니다. 살펴보니 externalObjects라는 하나의 키를 갖고 있네요. nib 파일이 외부의 객체를 참조하게 되는 경우 외부 객체는 해당 키를 통해서 전달이 되어야 한다고 합니다. ( 자료가 많이 없어 궁금하신 분들은 하단의 마지막 2개 링크 참고하시길 바랍니다. )
이런 파라미터를 통해서 리턴되는 값은 autoreleased 된 NSArray 로써 nib file에 있는 최상위 객체들을 포함하고 있습니다.
해당 메서드를 통해서 nib에 있는 객체를 제공할 수 있습니다. 해당 메서드를 통해서 각각의 객체를 unarchives하며 initialize하고 각각의 객체와 connection을 다시 합니다. 자세한 내용은 Resource Programming Guide를 살펴보시길 바랍니다. 만약 nib file 이 단순히 File's Owner를 넘어서 proxy objects 를 포함하고 있다면, option을 통해 런타임시에 변경될 object를 명시할 수 있습니다.
마무리
먼저 간단하게 UINib의 문서에 있는 내용들을 살펴 보았습니다. 사실 init(coder) 와 archiving 등을 알아보고 싶었는데, UINib만해도 알아볼 내용이 산더미인 것을 실감하였습니다 ^^... 다음에는 여기에서 살펴보지 못한 Bundle, nib 파일에 있는 객체를 사용하는 두가지 방식 ( file's owner , custom class ) , init coder 그리고 Resource Programming Guide 등을 살펴볼 예정입니다!
안녕하세요 ! Mash-Up IT 동아리에서 3년 이상 활동하면서 여러 경험을 하고 이를 한번 블로그에도 공유를 하고자 합니다. 참고로 동아리의 홍보성 글이 아닌 제가 느낀 점을 그대로 작성할 생각입니다. IT 동아리가 궁금하신 분들 그리고 회사 일도 바쁜데 지원을 할까 말까 고민을 하시는 분들에게 제 경험이 도움이 되기를 바랍니다.
처음에는 가벼운 마음으로 다른 사람들이랑 협업이나 해봐야겠다! 는 생각으로 들어갔는데, 같이 프로젝트도 하고 이런저런 고민도 얘기해보고 좋은 사람들을 만났습니다. 지금 생각해보면 중요하다고 하는 20대의 큰 부분을 차지한 감사한 경험이라는 생각이 들었습니다. 저희 동아리는 학생 + 현직자의 비율이 거의 3:7 ~ 4:6 정도였는데 운이 좋게도 저는 학생일 때 들어가서 많은 부분들을 미리 경험할 수 있었습니다. 이런 경험을 하지 않았더라면 개발자에 대해서 고민을 많이 안 해봤을 수도 있었고, 회사를 다니면서 우물 안 개구리마냥 자신의 실력에 만족하면서 그저 그런 개발자가 되었을 수도 있었겠다는 생각 합니다.
활동을 하면서 각 키워드별로 좋았던 점 그리고 아쉬웠던 점을 적어보려고 합니다. 시작하기에 앞서 지극히 주관적인 생각이고 상황과 사람에 따라 느낀 점이 다를 수 있습니다. 게다가 현재는 모임이 거의 불가능한.... 시국이다 보니 ㅜㅜ 저의 경험의 70% 정도는 오프라인 때 느낀 경험이라는 점을 말씀드리며, 이에 따라서 현재의 상황과 다를 수 있습니다. 이 부분 또한 참고하셨으면 좋겠습니다.
친목활동
장점
프로젝트가 결국 사람들과 시간을 들여 일을 함께 하는 것이다 보니 저희 동아리에서는 친해지는 기간을 먼저 가졌습니다. 어떤 활동을 하는지는 동아리 자체에서는 정해지지 않았고 각 기수마다 운영진의 재량에 달려있었습니다. 저의 경우는 많은(?) 활동을 했었는데 랜덤으로 키워드를 정해 수행하기, 팀별로 월을 나타내는 사진을 찍어오기, 팀마다 키워드를 내고 조합해서 수행하기 등등 다양한 활동들을 했었습니다. 프로젝트 팀이 아니라 전체에서 랜덤으로 팀을 짜서 진행을 하며, 자칫 프로젝트만 한다면 모를 수 있었던 사람들도 이 기회에 얼굴이라도 조금씩 익혔습니다. 프로젝트를 진행할 때 실제로 같은 팀이 되면 조금 더 편하고 플랫폼별로 아는 사람들이 적어도 한 명은 있다 보니 다른 팀과의 교류도 비교적 활발했었습니다.
그 외에도 다양한 소모임들이 있었습니다. 같이 마라톤을 나가는 사람들도 있었고 각종 행사와 해커톤 공모전등을 나가기도 했으며, 보드게임 PC게임 운동 등등 일반적으로 흥미를 가질만한 활동들은 조금씩 모여서 같이 활동을 하곤 했습니다. 사람들이 부담 없이 모임에 참여할 수 있었기 때문에 모임에 참여하면서 숨겨진 재능(?)을 찾는 분들도 종종 있어서 재밌었던 경험이었습니다. 기수가 끝나도 활발한 활동들은 꾸준히 유지가 되기 때문에 저는 아직도 모임에 들어가 있습니다 하하...
아쉬운 점
다만 동아리의 목적 자체가 친목이 아니기에 미션의 경우 일회성으로 진행이 되어서, 생각보다 많은 얘기를 나눌 수 없었던 것은 개인적으로 조금은 아쉬웠습니다. 또한 오히려 반대로 친분보다는 기술적인 성장을 많이 기대하시고 온 분들은 다소 아쉬워하는 경향이 있긴 했습니다. 결국 사람과 친해지는 것도 시간을 투자해야 하기 때문에 경우에 따라서는 이에 대한 부담이 없잖아 있었던 것 같습니다.
프로젝트 + 해커톤
장점
회사에서 할 수 없는 기술이나 프로젝트를 진행하다 보니 주도적으로 프로젝트를 진행할 수 있다는 흥미가 가장 컸습니다. 잘되면 회사 그만둬야 하나 이런 행복회로를 돌린 적도 있었습니다. ( 물론 그런 일은 거의 일어나지 않습니다 .... ) 아무래도 회사에서는 대부분 주어진 기획에 맞춰 일을 진행한다면, 동아리에서는 서로 얘기를 나누면서 기획도 바꿔보고 더 좋은 기획이나 디자인 혹은 기능이 있다면 얼마든지 서로 제안하여 유동적으로 진행을 합니다.
프로젝트를 모르는 사람들과 진행하는 것은 자신의 역량을 키울 수 있는 좋은 기회라고 생각합니다. 아무래도 경험이 없는 프로그램, 아키텍쳐, 도메인등은 낯선 환경이라서 적응하기 힘들지만. 옆에 잘하는 동료가 있거나 서로 스터디를 하면서 공부할 수 있는 환경이 자연스럽게 만들어졌습니다. 특히 해커톤을 하면 하루 종일 붙어있기 때문에 그때 서로 많이 물어보고 새로운 단축키나 신기한 기능들을 훔치기도(?) 했습니다. 내가 생각하지 못한 부분들을 채워주는 경우도 많았고 내가 알려주면서 리마인드 하거나 부족한 부분들은 다시 공부하기도 했습니다. 또한 옆팀에 가서 직접 물어보면서 배우기도 하고 가르침을 얻어 전파하는 경우도 많았으며 이런저런 일들을 다 공유하기 때문에 모든 활동이 공부가 되었던 것 같습니다. 해커톤을 하면서 중간중간 아이스브레이킹이나 간식시간도 너무 재밌었습니다.
또한 협업에 대한 부분을 빼놓을 수 없겠죠. 대규모 팀에서 나눠진 역할에 맞춰 개발을 하는 것과 바로 옆에서 얘기를 주고받으며 어떻게 할지 설계하는 것은 큰 차이가 있다고 생각합니다. 이 부분에서 서로의 니즈를 조금 더 깊게 파악할 수 있었고, 커밋이나 이슈 깃헙프로젝트 등 서로 협업을 조금 더 잘할 수 있도록 노력했기에 많은 경험이 쌓였습니다. 또한 개발자라면 디자이너가 어떤 기능을 사용하는지도 배울 수 있어서 저는 실제로 회사에 디자인 관련된 기능을 제안하기도 하였습니다. 이는 디자이너 분들도 마찬가지로 많은 부분들을 배울 수 있다고 생각합니다.
아쉬운 점
위에서 말한 기획이 유동적이라는 부분은 사실 좋은 점만이 아니라는 것은 다들 아실 겁니다. 기획을 먼저 잡고 해커톤을 시작하지만 경우에 따라서는 기획이 아직 끝나지 않거나 진행하려 할 때 엎어지는 경우도 종종 봤습니다. 해당 프로젝트 팀은 아무래도 기획단에서 시간이 많이 걸렸기 때문에 대부분 프로젝트도 시간 안에 진행을 하지 못하는 불상사가 벌어지기도 합니다. 현직이 있는 사람들이 많기 때문에 시간상의 여유 또한 생각보다 많지 않을 수 있습니다.
개인의 역량의 부분도 마찬가지입니다. 동아리는 모임을 제공해 줄 뿐이지 멘토 멘티 시스템처럼 누군가 붙어서 도와주는 시스템이 아니기 때문에 개인이 각자 노력해야 합니다. 이 부분은 당연한 부분이지만, 이 과정에서도 누군가는 코드 리뷰를 적극적으로 하고 싶으나 팀원들이 잘 못 따라오는 경우도 있고, 새로운 아키텍쳐나 기술 또한 마찬가지인 경우도 있습니다.
운영진들이 열심히 힘을 써서 프로젝트가 잘 진행되도록 도와주는 것은 확실합니다. 하지만 사실상 팀 내에서도 그리고 각 플랫폼 별로도 누군가는 적극적으로 팀을 이끌어나가고 주어진 기능이나 일들에 대해서 조금은 책임지고 진행해야 합니다. 그런 부분들까지 신경을 써서 팀이 짜여지는 것은 아니기에 상황에 따라서는 프로젝트의 진행이 조금 더딘 팀도 종종 보았습니다.
스터디 그리고 전체 세미나
장점
격주로 각 플랫폼 스터디 그리고 전체세미나가 번갈아 진행되며 이 또한 유동적으로 진행되었습니다. 또한 각 플랫폼별 스터디 말고도 해당 플랫폼 내부에서도 원하는 주제에 대해서 스터디를 개설하고 소수의 인원끼리 서로 공부를 하는 경우도 많았습니다. 아무래도 플랫폼 내부에서 진행하는 스터디라서 현실적으로 많은 부분들을 배울 수 있고 다양한 키워드들을 접할 수 있었습니다. 또한 혼자 하기는 힘들지만 사람들과 같이 하다 보면 어쩔 수 없이...? 공부를 하기 때문에 많은 도움이 되었습니다.
또한 회사에서 진행하지 못했던 새로운 기술들이나, 개인적으로 궁금하고 알고 싶었던 내용들은 적극적으로 어필하여 배움을 얻을 수 있는 좋은 기회라고 생각합니다. 누구나 발표할 수 있으며 원하신다면 많은 사람들 앞에서 발표할 수 있는 경험을 하실 수 있습니다.
아쉬운 점
사람들에 따라 다르겠지만 특히 개인 스터디 모임 같은 경우는 동아리 활동 자체에서 본다면 우선순위가 가장 낮은 활동은 사실입니다. 프로젝트를 진행하는 것이 가장 우선순위가 높으며 그다음으로 각종 세미나 및 팀별 스터디이고, 가장 마지막으로 따로 개인 스터디를 진행하는 것입니다. 즉, 시간을 많이 투자할 사람들이 하는 것이 좋은 것 같습니다. 종종 다른 일정들로 인해서 스터디가 폭파되는 경우도 있었습니다. 열정만 있다면 다양한 사람들과 가장 원하는 활동을 할 수 있는 기회임은 분명하지만, 그만큼 시간을 많이 쏟아야 하는 것은 모든 팀원이 마찬가지이기에 누군가는 스터디장의 역할을 맡고 잘 이끌어가야 운영이 되었던 것 같습니다.
마무리
아무래도 지금은 시국이 이렇다 보니 위에서 말한 장점들이 현재는 많이 부각되지 않아서 참 안타깝습니다 ㅠㅠ 저는 정말 애정 하는 동아리이고 그만둘 때도 이직에 집중하는 이유가 아니었다면 계속 다녔을 것 같기도 합니다. 정말 좋은 사람들 많이 만났고 많은 경험을 하면서 기술적으로나 인간적으로나 많은 성장을 할 수 있었습니다. 만약 회사생활에 회의감을 느낀다거나 개발자, 디자이너로서의 고민들을 갖고 있다면 지원해보는 것도 좋은 경험이지 않을까 생각합니다. ( 사실 저도 지금 지원하면 붙을지는 잘 모르겠습니다... 경쟁률이 생각보다 높더라고요 ㅠㅠ )
이 글을 읽고 저희 동아리 활동을 하신다면 만날 기회가 생길 수 있을 거라고 생각합니다. 또한 제가 말하는 동아리 말고도 다른 동아리들도 좋은 장점들이 너무 많으니, 적극적으로 활동하면 좋은 경험이 될 것이라고 의심치 않습니다. 언젠가 좋은 곳에서 뵐 수 있었으면 좋겠습니다. 감사합니다. 🙏🏻
이번에는 Swift 5.1 부터 사용이 가능해진 Property Wrapper 에 대해서 알아보겠습니다. 간단하게 요약하자면 Property Wrapper 는 getter와 setter 관련된 로직을 포함하고 있는 프로퍼티를 쉽게 선언할 수 있도록 합니다. 문서의 예시와 더불어 설명이 부족한 부분은 직접 중간 중간에 추가하였습니다. 모든 레퍼런스는 하단에 준비되어 있습니다.
간단한 정의와 더불어 몇 개의 예시를 들면서 어떻게 사용하는 지 배워보겠습니다. Property Wrapper 의 정확한 정의는 값을 저장하는 코드와 정의하는 코드를 분리시킬 수 있도록 하는 레이어를 추가하는 것입니다. 예를 들어 몇가지 프로퍼티에서 값을 가져올때 thread-safe 를 체크하거나, 값을 저장할 때 database 에 저장해야 한다면 모든 프로퍼티에 일일이 설정을 해줘야 할 것입니다. 이런 반복되는 작업을 없애고 특정 프로퍼티에 Property Wrapper를 추가함으로써 공통된 코드를 없앨 수 있습니다. 한번 Property Wrapper를 정의한다면 다양한 프로퍼티에서 이를 쉽게 재사용할 수 있습니다.
정의 및 사용 예시
이런 Property Wrapper 를 정의할 때에는 structure, enumeration, class 를 통해서 wrappedValue 를 정의해줘야 합니다. 문서에 있는 Property Wrapper의 정의와 사용법에 대한 예시를 보겠습니다.
// Definition - 최대 12의 값을 가질 수 있는 프로퍼티를 만들고 싶다.
@propertyWrapper
struct TwelveOrLess {
private var number = 0
var wrappedValue: Int {
get { return number }
set { number = min(newValue, 12) }
}
}
// Use
struct SmallRectangle {
@TwelveOrLess var height: Int
@TwelveOrLess var width: Int
}
var rectangle = SmallRectangle()
print(rectangle.height)
// Prints "0"
rectangle.height = 10
print(rectangle.height)
// Prints "10"
rectangle.height = 24
print(rectangle.height)
// Prints "12"
@propertyWarpper 를 선언한 이름의 어노테이션 ( @{propertyWrapperName} )을 붙인 뒤 Structure 내부에서 wrappedValue를 정의한 것을 확인할 수 있습니다. 참고로 내부에 number는 private으로 외부에서 접근을 할 수 없는데, 이는 주어진 방식을 통해서 저장 혹은 값을 읽는 처리를 하기 위함이라고 이해하시면 됩니다. 사용하는 방법 또한 굉장히 간단합니다. 사용하고자 하는 프로퍼티 앞 혹은 위에 @{propertyWrapperName} 을 통해서 위에서 정의한 PropertyWrapper 를 사용할 수 있습니다.
TwelveOrLess 예시를 확인해보면 먼저 최대 12의 값을 가질 수 있는 Property Wrapper 를 정의하였습니다. 이후 height 과 width 프로퍼티를 Property Wrapper와 함께 선언해주었습니다. 이후 0을 할당한 경우 그대로 0 이 들어가며 마찬가지로 10을 할당한 경우 그대로 10이 할당됩니다. 여기서 height 에 24를 할당한 경우는 @TwelveOrLess 의 setter에서 정의한 대로 24와 12중 작은 값인 12가 할당됩니다.
초기값 정의하기
상단에서는 Property Wrapper 내부에서 초기 값을 정의했습니다. 이렇게 내부에서 처리하는 방식은 다른 초기값을 설정해 줄 수 없다는 문제점이 발생합니다. 초기값 그리고 Customization을 지원하기 위해서 PropertyWrapper 에서도 initializer를 추가할 수 있습니다. 아래의 예시를 보겠습니다.
// Definition
@propertyWrapper
struct SmallNumber {
private var maximum: Int
private var number: Int
var wrappedValue: Int {
get { return number }
set { number = min(newValue, maximum) }
}
init() {
maximum = 12
number = 0
}
init(wrappedValue: Int) {
maximum = 12
number = min(wrappedValue, maximum)
}
init(wrappedValue: Int, maximum: Int) {
self.maximum = maximum
number = min(wrappedValue, maximum)
}
}
// Use
struct UnitRectangle {
@SmallNumber var height: Int = 1
@SmallNumber var width: Int = 1
}
var unitRectangle = UnitRectangle()
print(unitRectangle.height, unitRectangle.width)
// Prints "1 1"
여기에서 height = 1 부분을 주목하시기를 바랍니다. 이렇게 직접 할당을 하는 경우 Property Wrapper 는 init(wrapperValue:) 를 찾아 이를 생성하려고 합니다. 만약 해당 이니셜라이저 대신 하나의 argument 를 가지지만 다른 프로퍼티를 초기화하는 intializer 가 있는 경우는 어떻게 될까요? 이 경우는 에러가 발생하여 원하는 결과를 얻을 수 없습니다.
@propertyWrapper
struct SmallNumber {
...
init(test: Int) {
self.maximum = test
self.number = test
}
}
// ...
// Error: incorrect argument label in call (have 'wrappedValue:', expected 'test:')
@SmallNumber var height: Int = 1
그렇다면 init(wrappedValue:maximum:) 은 어떻게 사용할까요? 어노테이션을 사용하면서 이니셜라이저를 사용하면 됩니다. 아래의 예시를 보면 쉽게 이해할수 있습니다.
struct NarrowRectangle {
@SmallNumber(wrappedValue: 2, maximum: 5) var height: Int
@SmallNumber(wrappedValue: 3, maximum: 4) var width: Int
}
var narrowRectangle = NarrowRectangle()
print(narrowRectangle.height, narrowRectangle.width)
// Prints "2 3"
narrowRectangle.height = 100
narrowRectangle.width = 100
print(narrowRectangle.height, narrowRectangle.width)
// Prints "5 4"
해당 예제에서 height은 init(wrappedValue: 2, maximum: 5) 를 통해서 생성된 프로퍼티이며 초기값은 2를 가지고 최댓값은 5가 될 것입니다. width의 경우 init(wrappedValue: 3, maximum: 4)를 통해서 생성된 프로퍼티로 초기값은 3을 가지고 최댓값은 4가 될 것입니다. 따라서 첫번째 프린트에서는 초기값인 "2 3"이 나오는 것을 확인할 수 있으며, 이후에 각각 프로퍼티에 100을 할당하고 다시 프린트 한 경우는 각자의 최댓값이 나오는 것을 확인할 수 있습니다.
여기에서 조금 더 변형된 initalizer를 고려해보자면 아래와 같습니다. Swift 에서 PropertyWrapper의 assignment(할당)는 wrappedValue 에 인자를 넘기는 것과 동일하게 생각합니다. 따라서 wrappedValue와 다른 arguments가 결합된 형태라면 할당된 값은 wrappedValue에 전달되며 나머지 인자는 따로 구현을 해줘야 합니다. 아래의 예제를 살펴보겠습니다.
struct MixedRectangle {
@SmallNumber var height: Int = 1
// init(wrappedValue: 1)
@SmallNumber(maximum: 9) var width: Int = 2
// init(wrappedValue: 2, maximum: 9)
}
var mixedRectangle = MixedRectangle()
print(mixedRectangle.height)
// Prints "1"
mixedRectangle.height = 20
print(mixedRectangle.height)
// Prints "12"
width를 살펴보면 2를 할당하고 maximum은 따로 설정한 것을 확인할 수 있습니다. 제대로 보지 않으면 순서가 헷갈릴 수 있지만, 할당 자체가 wrappedValue 인자로 전달된다는 것을 인지한다면 크게 어렵지 않을 것입니다.
Projecting a Value From a Property Wrapper
Property Wrapper는 Wrapped Value와 더불어서 Projected Value라는 예약어가 있습니다. Projected Value는 $ 로 시작하는 것을 제외하고는 Wrapped Value와 동일합니다. 다만 이를 사용할 때는 $를 붙여서 사용하며 해당 값은 사전에 정의된 로직을 제외하고 따로 조작할 수 없다는 차이점이 있습니다. 아래 예제와 함께 설명하겠습니다.
@propertyWrapper
struct SmallNumber {
private var number = 0
var projectedValue = false
var wrappedValue: Int {
get { return number }
set {
if newValue > 12 {
number = 12
projectedValue = true
} else {
number = newValue
projectedValue = false
}
}
}
}
struct SomeStructure {
@SmallNumber var someNumber: Int
}
var someStructure = SomeStructure()
someStructure.someNumber = 4
print(someStructure.$someNumber)
// Prints "false"
someStructure.someNumber = 55
print(someStructure.$someNumber)
// Prints "true"
우리가 배운 것 처럼 Property Wrapper는 정의에 따라서 사용자가 할당한 값 이외에 다른 값으로 변환되는 경우가 있습니다. 예제에서는 Projected Value를 이용해서 변환이 이루어졌는지 확인할 수 있도록 하였습니다. 따라서 4를 할당한 경우 변환이 이루어지지 않았기 때문에 Projected Value인 someStructure.$someNumber 는 false 를 리턴합니다. 하지만 55를 할당한 경우 내부 정의에 따라서 변환이 이루어졌기 때문에 해당 값은 true가 됩니다.
Property Wrapper는 어느 타입이던 상관없이 Pojected Value를 정의하고 리턴할 수 있습니다. 예시에서는 비교적 단순한 Bool 값을 사용했지만 더 많은 정보가 필요하다면 Data Type 을 따로 정의해 리턴 할 수도 있으며, 자기 자신을 리턴할 수도 있습니다. 이를 위해서는 단순히 projectedValue 라는 이름의 프로퍼티에 값을 정의해주면 됩니다. 아래는 Pojected Value를 String으로 사용하는 예시입니다.
@propertyWrapper
struct SmallNumber {
private var number = 0
var projectedValue: String = ""
var wrappedValue: Int {
get { return number }
set {
if newValue > 12 {
number = 12
projectedValue = "world"
} else {
number = newValue
projectedValue = "hello"
}
}
}
}
struct SomeStructure {
@SmallNumber var someNumber: Int
}
var someStructure = SomeStructure()
someStructure.someNumber = 4
print(someStructure.$someNumber)
// Prints "hello"
someStructure.someNumber = 55
print(someStructure.$someNumber)
// Prints "world"
이렇게 문서를 참고하고 예시를 덧붙여서 개념을 알아봤습니다. 그렇다면 이런 Property Wrapper를 어떻게 사용하는지 그리고 왜 사용하는지에 대해서 한번 알아보겠습니다. 예시는Proposals 문서를 참고하여 작성하였습니다.
How : User Default
처음 Property Wrapper의 개념을 공부하고 나서 User Default를 PropertyWrapper를 통해 사용한다면 정말 편하겠다는 생각을 했습니다. 마침 swift-evolution 문서에도 이 방식으로 Example을 제안한 것을 확인할 수 있었습니다. 값을 추출하는 방식과 값을 저장하는 방식을 미리 정의하고 이를 사용한다면 자동으로 User Default 에 원하는 값이 저장되고 추출 될 것입니다. 아래는 문서에서의 예시 입니다.
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
var wrappedValue: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
enum GlobalSettings {
@UserDefault(key: "FOO_FEATURE_ENABLED", defaultValue: false)
static var isFooFeatureEnabled: Bool
@UserDefault(key: "BAR_FEATURE_ENABLED", defaultValue: false)
static var isBarFeatureEnabled: Bool
}
먼저 예시에서 보이는 것 처럼 Property Wrapper는 제네릭으로도 사용이 가능합니다. 또한 필요한 부분들을 할당하지 않고 initializer 처럼 생성할 수 있습니다. 따라서 User Default 에 접근하기 위한 key 그리고 만약 값이 존재하지 않은 경우 defulatValue를 생성시에 받는 것을 확인할 수 있습니다. get 구문에 의해서 어떤 key의 값을 가져올 때 UserDefault 를 확인하고 만약 값이 nil이라면 defaultValue를 리턴하는 것을 확인할 수 있으며, set에 의해서 propertyWrapper 를 변경해준다면 해당 값이 UserDefault 에 저장되는 것을 확인할 수 있습니다. 예시에서는 Bool 값을 FOO_FEATURE_ENABLED, BAR_FEATURE_ENABLED 키를 통해서 UserDefault에 접근하는 것을 확인할 수 있습니다.
How : Delayed Initialization
Swift 에서는 Lazy 라는 키워드가 있습니다. 해당 키워드를 변수에 사용하게 되면 해당 값이 불릴 때 비로소 값이 할당되도록 하는 키워드 입니다. 보통 클래스 혹은 struct 에서 다른 프로퍼티를 참고하여 값을 할당해야 하는 경우 사용하게 됩니다. 해당 객체는 아직 생성되지 않았기 때문에 프로퍼티를 만드는 당시에 다른 프로퍼티를 참고할 수 없습니다. 따라서 Lazy 를 이용해 객체가 생성된 이후 시점에서 해당 값을 사용하는 로직을 위해 사용합니다. 이런 방식을 Property Wrapper 를 통해서도 구현이 가능합니다. 일단 프로퍼티와 타입을 정의해 놓고 값은 나중에 할당하는 방식입니다. 아래의 예시를 보겠습니다.
@propertyWrapper
struct DelayedMutable<Value> {
private var _value: Value? = nil
var wrappedValue: Value {
get {
guard let value = _value else {
fatalError("property accessed before being initialized")
}
return value
}
set {
_value = newValue
}
}
/// "Reset" the wrapper so it can be initialized again.
mutating func reset() {
_value = nil
}
}
// 이렇게만 선언해도 컴파일 에러가 발생하지 않습니다.
@DelayedMutable var hello: String
물론 DelayedMutable를 사용한다면 예상치 못한 경우 fatalError 가 발생할 수 있기 때문에 이를 Lazy 대신에 사용하는 것은 좋지 않은 방법입니다. 여기에서는 Lazy 또한 이런식으로 구현할 수 있겠구나 라고 생각하시면 될 것 같습니다. 또한 문서에 DelayedImmutable 이라는 PropertyWrapper 가 있는데 흥미로운 방식이니 이를 참고하시는 것 또한 추천드립니다.
마무리
이렇게 Property Wrapper 를 간단하게 알아봤습니다. 우리가 모듈이나 별도의 로직이 들어간 함수를 만들어 여러 곳에서 사용하는 것처럼 Property Wrapper 또한 프로퍼티를 위한 함수라고 생각해도 될 것 같습니다. 생각보다 Customization 이 굉장히 열려 있으며 ProjectedValue를 통해서 다양한 방식으로 활용이 가능한 것을 확인할 수 있었습니다. 이를 활용해서 다양한 방식으로 코드의 중복을 낮추고 효율을 높이셨으면 좋겠습니다.
개발을 하다보면 공식 문서를 빈번하게 찾고는 합니다. 단순히 메서드 혹은 클래스는 현재 apple document 에서도 쉽게 찾을 수 있지만, 동작원리를 알아야 하는 경우 기존에 있던 레퍼런스를 찾는데 가끔 찾기가 힘든 경우가 있습니다. 대표적인 몇가지 가이드 및 레퍼런스 링크를 준비했으며 도움이 될만한 애플 공식 레퍼런스가 있다면 댓글 남겨주시면 감사하겠습니다!
자주 사용하고 있지만 정확한 개념을 설명하기 어렵다는 생각이 들어서 이번 포스팅을 통해 정리하고자 합니다. 처음 Swift를 공부하던 시절에 제일 헷갈렸던 부분이었던 것 같습니다. escaping closure를 이해하기 위해서는 몇가지 개념을 미리 알고 있는 것이 좋습니다. 비동기 코드를 쓰는 이유, scope의 개념, capturing values 를 먼저 설명한 뒤 escaping closure 그리고 이를 통해 생길 수 있는 strong reference cycle에 대해서 간단하게 설명하도록 하겠습니다.
비동기 코드를 쓰는 이유
이미 다른 블로그를 통해서 동기와 비동기에 대해서 많이 설명을 해놨기 때문에 저는 직접적으로 iOS에서 활용하는 방식을 예를 들어 설명하겠습니다.
escaping closure 가 가장 많이 활용되는 곳은 네트워크를 통해서 데이터를 받아올 때 입니다. 기본적으로 우리가 함수를 작성하면 해당 함수는 동기적으로 작동합니다. 반면에 네트워크 API는 어떤 형식으로든 비동기적으로 작성을 할 수 있도록 유도하는 것을 많이 확인할 수 있습니다. 이유는 네트워크를 받아오는 시간동안 사용자가 아무런 동작을 못하는 freezing 현상을 방지하기 위함입니다.
func testEscaping(_ esClosure: @escaping (Data?) -> Void) {
let urlRequest = URLRequest(url: URL(string: "hello world")!)
session.dataTask(with: urlRequest)
{ data, response, error in
print("world")
print("update UI or Alert")
}.resume()
print("hello")
}
해당 코드를 실행해보면 session.dataTask ( Network )는 비동기 방식이기 때문에 실행 이후, 네트워크의 응답을 기다리지 않고 hello가 찍힐 것입니다. 그 이후 네트워크를 통해 응답을 받게되면 world, update UI or Alert 가 찍히게 되겠죠. 이때 hello 가 찍히고 나서 응답이 올때까지 기다리는 동안에도 사용자는 앱을 동작시킬 수 있다는 것이 비동기의 큰 장점입니다. 이를 통해서 사용자의 경험을 높힐 수 있기 때문입니다.
< 비동기 방식 : 요청 - 사용자 행동 ( 사용자 경험 Good ) - 응답 - 내부 동작 >
반대로 네트워크가 동기적으로 작동한다면 어떻게 될까요? 만약 네트워크가 느려서 응답을 받을 때까지 2초라는 시간이 걸린다고 생각을 해보겠습니다. 사실상 모바일 앱을 사용하면서 2초라는 시간은 굉장히 길고 2초동안 사용자가 어떠한 동작도 하지 못한다면 사용자의 경험을 해칠 수 있습니다.
< 동기 방식 : 요청 - 기다림 ( freezing , 사용자 경험 Bad ) - 응답 - 내부동작 >
Scope
Scope는 크게 global scope, local scope 2가지로 나누어집니다. global scope에 대한 자세한 내용은 여기에서 확인하는 것을 추천합니다. 예제를 통해서 local scope인 함수의 스코프에 대해서 간단하게 설명을 드리겠습니다.
func someFunc() -> Int {
let hello = "world"
return 0
}
someFunc()
someFunc 라는 함수가 있습니다. 그리고 내부에 hello 라는 변수가 있습니다. 일반적으로 함수의 스코프라 함은 함수의 바디 즉 { 부터 } 까지를 의미합니다. 해당 스코프는 함수가 실행될 때 활성화되며, 리턴이 되면 해당 스코프 내부에 있는 변수들은 소멸됩니다. 함수가 실행될 때에만 필요한 변수들은 실행 이후 리턴이 되는 순간 가지고 있을 필요가 없기 때문입니다. 이런 메커니즘을 가진 것이 local scope이며, 내부의 변수를 local variable 이라고 합니다.
하지만 local variable이 이런 local scope를 무시하고 클로저에서 계속 참조되는 것 처럼 보이는 예외의 경우가 있는데, 이 경우는 아래에서 설명하는 capturing values 를 통해서 이루어집니다.
capturing values
클로저를 실행할 때 필요한 변수들을 따로 캡쳐합니다. 문서를 한번 확인해보겠습니다.
A closure can capture constants and variables from the surrounding context in which it’s defined. The closure can then refer to and modify the values of those constants and variables from within its body, even if the original scope that defined the constants and variables no longer exists.
요약하자면 클로저는 주변에 있는 변수를 참조할 수 있으며, original scope 를 무시한다고 합니다. 즉, 기존의 스코프와 관계없이 클로저만의 변수를 갖고 있다고 생각하면 됩니다. 문서의 예제를 보며 설명드리겠습니다.
// 1번 예제
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
var runningTotal = 0
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}
return incrementer
}
// ** 실행 **
let incrementByTen = makeIncrementer(forIncrement: 10)
incrementByTen()
// returns a value of 10
incrementByTen()
// returns a value of 20
let incrementBySeven = makeIncrementer(forIncrement: 7)
incrementBySeven()
// returns a value of 7
incrementByTen()
// returns a value of 30
makIncrementer 함수 내부에 incrementer 함수만 살펴보면 runningTotal 과 amount 변수는 사실상 바깥에서 선언한 변수입니다. incrementer 만 본다면 에러가 나지만 runningTotal과 amount 를 capture 했기 때문에 내부에서 사용이 가능합니다.
또한 두번째로 살펴볼 것은 아래 실행 부분입니다. 위에서 클로저만의 변수를 갖고있다고 설명을 드렸듯이 incrementByTen, incrementBySeven 의 runningTotal 은 공유가 되지 않습니다. 각각 runningTotal 을 참조하는 변수를 가지고 있는 것이죠. 따라서 incrementBySeven() 을 실행시킨 뒤 incrementByTen() 을 실행시키더라도 각각의 플로우를 따르는 것을 확인할 수 있습니다.
하지만 변수가 공유되는 경우 또한 존재합니다. 아래의 예제를 살펴보겠습니다.
// 2번 예제
class ClosureTestManager {
var runningTotal = 0
func add(_ amount: Int) -> () -> Int {
return {
self.runningTotal += amount
return self.runningTotal
}
}
}
// ** 실행 **
let ma = ClosureTestManager()
let addTen = ma.add(10)
let addThree = ma.add(3)
addTen()
// returns a value of 10
addThree()
// returns a value of 13
addTen()
// returns a value of 23
결과에서 보이다시피 addThree() 를 실행시킨 경우 0 + 3 이 아닌 기존에 더해진 10 + 3 이 되어 13 이 리턴되는 것을 확인할 수 있습니다. 1번 예제에서는 runningTotal이 서로 공유되지 않았지만, 2번 예제에서는 서로 공유가 되어 결과가 다른 것을 확인할 수 있습니다.
이유는 reference type인 ClosureTestManager 의 인스턴스의 변수를 캡쳐했기 때문입니다. 단순히 값 타입을 복사한 경우는 새로운 복사본이 할당되어 사용할 수 있지만, reference type은 reference count를 증가시키면서 인스턴스의 주소를 가져옵니다. 따라서 동일한 주소의 변수를 접근하기 때문에 서로 공유가 되어 결과가 다른 것입니다. 따라서 클로저에서 값을 사용하는 경우에는 어떤 타입의 값을 캡쳐하는지 유의해서 작성을 해야 한다는 것을 알아두시길 바랍니다.
escaping closures
위의 내용들을 파악하고 이 부분을 읽는다면 큰 무리가 없을 것이라 예상됩니다. 먼저 문서를 한번 살펴보겠습니다. 정의는 이렇게 나와있습니다.
A closure is said to escape a function when the closure is passed as an argument to the function, but is called after the function returns.
인자로 전달받은 함수 중 함수의 리턴 이후에 실행할 수 있는 함수가 escaping closure 이다. 쉽게 말해 함수의 리턴 이후 실행할 수 있는 함수이다. 라고 이해하시면 될 것 같습니다. 계속해서 문서를 보겠습니다.
One way that a closure can escape is by being stored in a variable that’s defined outside the function. As an example, many functions that start an asynchronous operation take a closure argument as a completion handler. The function returns after it starts the operation, but the closure isn’t called until the operation is completed—the closure needs to escape, to be called later
바깥의 변수에 의해서 클로저가 저장되는 경우에 closure는 escape를 할 수 있다고 하네요. 대표적으로 사용하는 예시로는 비동기 작업을 하는 경우 completion handler로 escaping closure 를 사용하는 것을 확인할 수 있습니다. 작업이 시작되면 함수가 리턴되지만 클로저는 작업이 끝날 때 까지 호출되지 않습니다. 그리고 이 클로저를 나중에 호출하기 위해서 escape가 필요합니다.
여기서 escape의 의미를 생각해봐야합니다. 어디에서 탈출하는 걸까요? 함수의 스코프에서 탈출한다고 이해하면 좋을 것 같습니다. 함수가 리턴이 되면 일반적으로 해당 스코프는 사라지게 됩니다. 더 이상 접근할 수 없는 상태가 되는 것이죠. 하지만 비동기 작업이 끝나고 실행되어야 하는, 예외 상황에서는 사라진 스코프 내부에서 클로저를 실행시켜야 할 때가 있습니다. 이때 실행되는 이 클로저를 escaping closure 라고 하는 것입니다.
대표적인 예제를 하나 살펴보겠습니다. testEscaping를 살펴보면 내부에서 asyncronous operation 인 dataTask가 있습니다. 그리고 dataTask의 completion Hanlder 에서 esClosure를 사용하는 것을 확인할 수 있습니다. testEscaping을 외부에서 실행하는 순간 "hello"가 로그로 찍히고 내부의 스코프는 접근할 수 없지만, dataTask 작업이 끝나고 completionHandler 가 실행되며 "wolrd" 가 로그에 찍히고 esClosure 가 캡쳐된 인자와 함께 실행되는 것을 확인할 수 있을 것입니다.
Strong reference cycle for Closures
여태까지 escaping closure가 무엇인지, 어떻게 실행되는지, 왜 사용해야 하는지 등을 알아봤습니다. 하지만 escaping closure 뿐만 아니라 closure 자체를 사용할 때 주의해야할 점이 있는데, reference Value 를 캡쳐하게 될 때 Strong Reference Cycle 이 생길 수 있다는 점입니다.
ARC 를 살펴보면 reference Type 의 값은 참조가 되는 경우 reference count 가 증가하고 참조가 필요없는 경우는 count가 감소하며, 0 이 될때 인스턴스가 사라지게 됩니다. capturing values 또한 마찬가지로 변수에 값을 할당하는 것입니다. 이때 만약 self 에서 해당 클로저를 참조하고 클로저에서 self 를 참조하는 경우에 Strong Reference Cycle 이 생겨 인스턴스가 사라지지 않는 경우가 생길 수 있습니다. 이런 경우를 조심하고 Capture List 를 선언하여 이를 방지해야합니다.
이 포스트에서 정리를 하려고 했으나, 내용이 너무 길어질 것 같아서 관련된 ARC의 개념을 한번 정리를 한 다음 링크를 걸도록 하겠습니다. 자세한 내용을 알고싶다면 하단의 Strong Reference Cycle 관련 레퍼런스를 넣었으니 확인하시기를 바랍니다.
마무리
연관된 개념들을 하나씩 간단하게 정리를 하였습니다. 요약을 하자면 Escaping Closure는 함수가 리턴된 이후에 실행되는 함수 로써 이해를 하면 됩니다. 그리고 해당 클로저가 필요한 이유는 비동기, 스코프 등의 한계를 깨기 위해서 라고 생각합니다. 또한 내부의 동작은 capturing values 라는 개념이 존재했기 때문에 가능했다고 생각하면 될 것 같습니다.
문서를 최대한 참고하였으며, 설명이 부족하거나 이해가 안되는 부분은 직접 확인하시는 것이 제일 좋습니다. 혹여나 제가 잘못 알고 있는 부분이 있거나 전달이 부족한 부분이 있다면 댓글 부탁드립니다. 감사합니다!
글을 쓰다보니 존댓말을 쓰는 것이 조금 더 자연스러울 것 같아 이번 포스팅부터 존댓말을 쓰도록 하겠습니다 !
UI를 그리다보면 어쩔수 없이 레이아웃이 겹치는 부분 혹은 겹치지 않는 부분이 생기거나, 그 두가지가 공존하는 경우가 생기고는 합니다. 특히 라벨 버튼 등등 변동되는 텍스트를 작업할 때는 이런 상황이 생길수 밖에 없습니다. 이때마다 CHCRPriorities (ContentHugging , CompressionResistance Priority )를 사용하고는 하는데 늘 헷갈려서 찾아보고는 했습니다. 그래서 이번 기회에 정리하는 것이 좋다는 생각이 들었습니다.
핵심은 intrinsicContentSize( intrinsicSize ) 를 기준으로 생각하는 것이고 이보다 큰 경우를 대응하기 위해서는 contentHugging 을 설정하고, 이보다 작은 경우를 대응하기 위해서는 compresssionResistance 를 설정하면 됩니다.
intrinsicContentSize
intrinsicContentSize(이하 intrinsicSize)란 뷰가 가지고 있는 본연의 사이즈를 말합니다.
커스텀뷰는 일반적으로 레이아웃 시스템이 알지 못하는 방식으로 표시가 됩니다. 따라서 프로퍼티를 설정하여 콘텐츠에 맞춰서 사이즈를 시스템에 전달할 수 있도록 해야합니다. 일반적으로 커스텀뷰의 경우 내부의 콘텐츠를 레이아웃 시스템에 맞춰서 사이즈를 정해야 합니다. 이 사이즈는 변경된 높이를 기반으로 변경된 너비를 레이아웃시스템에 전달할 방법이 없기 때문에, 반드시 frame과는 무관해야 합니다.
대표적으로 intrinsicSize 를 갖고 있는 뷰로 UILabel 을 예시로 들수 있습니다. 자체적으로 Text 사이즈를 통해서 Label의 사이즈를 정하기 때문입니다. 따라서 UILabel 는 center를 superview 와 동일하게 잡기만 해도 layout 이 제대로 잡히는 것을 볼 수 있습니다. 이는 UIImageView 또한 마찬가지입니다.
반면에 intrinsicSize를 제대로 갖고 있지 않는 뷰의 경우는 center를 superview에 동일하게 잡더라도, 해당 뷰의 사이즈를 측정할 수 없기 때문에 layout 에러가 발생합니다. 레이아웃 시스템이 판단할 수 없기 때문입니다.
하지만 이렇게 intrinsicSize이 존재하더라도 복잡한 레이아웃이 되는 경우는 시스템이 판단할 수 없는 경우가 종종 있습니다. 이 경우를 해결하기 위해서 애플에서는 CompressionResistancePriority, ContentHuggingPriority 를 제시하여 해결할 수 있도록 하였습니다. 대부분 ContentHugging 은 250을 기본값으로 가지고 있으며, CompressionResistance 는 750을 기본값으로 갖고 있습니다. 자세한 내용은 Auto Layout Guide 를 참고하시길 바랍니다.
ContentHuggingPriority ( CH Priority )
intrinsic Size 에 비해서 더 늘어나야 하는 경우에 사용합니다. 위의 이미지를 보면 hugging 이 Text를 감싸고 있는 것을 볼 수 있습니다. 따라서 양쪽에서 압력을 가하는 정도 혹은 압력을 가하는 정도라고 해석하면 편합니다. 아래의 예제와 함께 설명하겠습니다.
여기에 두개의 라벨이 있습니다. 이때 (왼쪽)제목 라벨은 고정이고, (오른쪽)콘텐츠 라벨은 텍스트의 사이즈가 늘어날 수 있으며, 서로의 거리는 16 이상이 되어야 한다면 어떻게 해야할까요?
라벨간의 거리를 16으로 제약을 겁니다.
늘어나야하는 라벨 ( 콘텐츠 라벨 )의 contentHugging 을 고정된 라벨보다 낮게 측정합니다.
1번을 진행한다면 레이아웃이 깨지는 현상을 확인할 수 있습니다. 라벨들의 기본 intrinsicSize는 텍스트의 사이즈에 따라서 결정이 됩니다. 그렇기 때문에 적절한 텍스트를 적는다면 16의 제약을 만족할 수 있겠지만 위와 같은 일반적인 상황은 그렇지 않기 때문에 주어진 레이아웃을 만족하기는 어렵습니다.
따라서 2번의 제약을 걸어야 합니다. 오른쪽에 content Hugging 을 낮춰 왼쪽에 비해 오른쪽이 양쪽에서 안아주는 압력이 낮아지도록 하여 크기를 상대적으로 커질 수 있도록 하는 것입니다. 이를 통해 오른쪽 라벨의 width를 intrinsicSize 보다 키워서 16의 제약조건을 만족할 수 있도록 하는 것입니다.
CompressionResistancePriority ( CR Priority )
CompressionResistance는 intrinsicSize 에 비해서 줄어 들어야 하는 경우 사용합니다. 만약 두개의 라벨의 intrinsicSize가 너무 커서 레이아웃을 만족할 수 없다면 어떻게 될까요? 이런 경우에 사용하는 프로퍼티가 CompressionResistancePriority 입니다. 압축을 저항하는 정도로 해석하면 편합니다.
양 라벨과의 거리 제약은 8이며 왼쪽 라벨의 사이즈를 작게하고 싶습니다. 이 경우 압축에 저항하는 정도를 낮춘다 > 더 압축된다 라고 생각하면 되기 때문에 왼쪽의 라벨의 compression Resistance 를 낮추면 해결될 것 같습니다. 왼쪽 라벨의 경우 priorty가 749 이며, 오른쪽 라벨이 750 입니다. 따라서 압축을 저항하는 정도 ( compression Resistance ) 가 왼쪽라벨이 더 낮기 때문에, 크기가 intrinsicSize 보다 작아지게되어 ( 더 압축되어 ) 왼쪽 라벨이 줄어들게 됩니다.
그렇다면 이 두개를 동시에 사용하는 일이 있을까요? 네 있습니다. 위에서도 설명했듯이 CHCR Priority 는 intrinsicSize 를 기준으로 합니다. 따라서 상황에 따라서는 두가지를 모두 정확하게 작성을 해야 어느 텍스트가 들어오더라도 의도된 UI가 나올 것입니다.
CompressionResistancePriority를 설정한 예제에서 오른쪽 라벨의 intrinsic 사이즈만 줄인 경우입니다. 보시다시피 레이아웃이 시스템에 적절하지 않습니다. 이유는 ContentHuggingPriority가 동일하기 때문에 어떤 라벨의 사이즈를 더 키워야 할지 시스템에서는 모르기 때문입니다.
예시로 왼쪽의 라벨의 크기를 유동적으로 하는것으로 하겠습니다. 왼쪽 라벨의 CHPriority를 낮추는 경우 intrinsicSize가 작아 제약조건을 만족하지 못할때 왼쪽의 라벨이 커지게 되고, intrinsicSize 가 커서 제약조건을 넘어가는 경우 왼쪽 라벨의 크기가 줄어들게 됩니다.
마무리
이렇게 ContenteHugging 그리고 CompressionResistance Priority 에 대해 알아봤습니다. 활용할 부분도 굉장히 많고 UI 디버깅을 진행하다보면 다른 부분에서도 Priority 가 많이 적용된 것을 확인할 수 있습니다. 관심있게 보신다면 좋은 UI 작성 방식이 될 것이라고 생각합니다.
첫번째 화면에서는 어떤 알파벳으로 시작하는 칵테일을 볼 것인지를 선택한다. 이 셀을 누르게 되면 두번째 화면으로 넘어간다.
두번째 화면은 해당 알파벳으로 시작하는 칵테일 리스트를 볼 수 있다. 여기에서 셀을 누르게 되면 세번째 화면으로 넘어간다.
세번째 화면에서는 칵테일의 디테일 요소를 보여준다. ingredient 또한 확인하여 어떻게 구성되어있는지 확인할 수 있다.
칵테일을 검색하고 이를 만들기 위해서 어떤 요소들이 필요한지 확인할 수 있는 간단한 예제를 만들었다. MVVM 구조를 따르도록 하였으며 Swinject, SwinjectStoryboard를 사용하여서 어플리케이션을 구성하였다.여기에서 예제를 확인할 수 있으며태그를 확인해보면 Container만을 사용하는seperatedContainer 태그와 Assembly를 활용한assembly 태그가 있다. 각각 다운받은 뒤 pod install 을 하고 앱을 실행하여 확인해볼 수 있다.
SwinjectStoryboard
Storyboard 에 있는 뷰컨트롤러를 만들 때에도 dependency를 관리하고 싶은 경우 활용하면 좋다. SceneDelegate.swift 에 있는 코드를 가져왔다.
lazy var initialContainer: Container = {
let container = Container()
container.register(MainViewModeling.self) { _ in
return MainViewModel()
}
container.storyboardInitCompleted(MainViewController.self) { (r, c) in
// initCompleted closure
c.viewModel = r.resolve(MainViewModeling.self)
}
return container
}()
// ....
let mainViewController = SwinjectStoryboard.create(name: "Main", bundle: nil, container: initialContainer)
.instantiateViewController(withIdentifier: "MainViewController")
MainViewController 에는 MainViewModel이 필요한데 이 뷰모델은 MainViewModeling 이라는 프로토콜을 채택해야 한다. 또한 MainViewController 에서는 구현체인 MainViewModel 이 아니라 MainViewModeling을 참조하도록 만들었다. MainViewController가 생성되면 자동으로 MainViewModel이 생성되어 injection이 된다. 이 과정을 아래와 같은 단계로 풀어서 설명할 수 있다.
MainViewModeling 을 register 한다. resolve를 하게되면 MainViewModel instance가 생성될 것이다.
SwinjectStoryboard.create 를 통해서 만들고자 하는 Storyboard 그리고 identifier 를 연결한다. 이때 container 인자에 사용하고자 하는 container 를 등록한다.
2에서 사용한 container 에 container.storyboardInitCompleted를 사용하여 initCompleted closure 에 viewModel 을 property injection 형태로 넣어준다.
위와 같은 과정을 거치면 MainViewController를 만들었을 때 자동으로 ViewModel 이 injection 된 ViewController instance 를 얻을 수 있다.
SwinjectStoryboard 단점
한가지 아쉬웠던 점은 SwinjectStoryboard.create 를 통해서 인자를 넘길 수 없다는 점이었다.
예를 들어서 MainViewModel에id: String가 필요하다고 하자 SwinjectStoryboard.create 단계에서 id 인자를 넘기면 MainViewModeling을 resolve 할 때 이를 넣어주는 형태가 되었으면 조금 더 활용도가 높았을 텐데 하는 아쉬움이 있다. 문서를 찾아봤지만 이렇다 할 해법이 나와있지 않아 굉장히 아쉬웠다.
따라서 만약 ViewModel을 만들 때 어떤 인자가 필요하다면 initCompleted 를 사용하는 방식이 아닌, ViewController와 ViewModel을 각각 resolve 한 뒤 직접 injection 하는 방식으로 사용하거나, viewModel의 ObjectScope를 변경하여 관리하는 방식으로 사용해야 한다. 아래 예제에 설명이 나와있다.
tag - seperatedContainer
위에서 말했다시피 예제 코드를 보면 두가지의 태그가 있다 이중 첫번째 seperatedContainer 에 대한 간단한 설명을 하고자 한다. seperatedContainer는 필요한 경우 viewModel에서 container 를 만들어서 이를 활용하는 방식으로 어플리케이션을 구성하였다. 아래의 코드는 MainViewModel.swift 에서의 코드이다.
protocol MainViewModeling: BaseViewModeling {
var alphabetListRelay: BehaviorRelay<[String]> { get }
var cocktailNameListViewControllerRelay: PublishRelay<CocktailNameListViewController> { get }
func targetAlphabet(at indexPath: IndexPath) -> String
func didTapAlphabetCell(at indexPath: IndexPath)
}
final class MainViewModel: BaseViewModel, MainViewModeling {
let alphabetListRelay = BehaviorRelay<[String]>(value: [])
let cocktailNameListViewControllerRelay = PublishRelay<CocktailNameListViewController>()
let container = Container()
override init() {
super.init()
setAlphabetList()
setContainer()
}
private func setAlphabetList() {
// ...
}
private func setContainer() {
container.register(CocktailNameListViewModeling.self) { (_, alphabet: String) in
return CocktailNameListViewModel(alphabet)
}
container.storyboardInitCompleted(CocktailNameListViewController.self) { (r, c) in
// do somthing if u want ...
}
}
func targetAlphabet(at indexPath: IndexPath) -> String {
// ...
}
func didTapAlphabetCell(at indexPath: IndexPath) {
let viewModel = container.resolve(CocktailNameListViewModeling.self, argument: targetAlphabet(at: indexPath))
guard let viewController = SwinjectStoryboard.create(name: "Main", bundle: nil, container: container).instantiateViewController(withIdentifier: "CocktailNameListViewController") as? CocktailNameListViewController else { return }
viewController.viewModel = viewModel
cocktailNameListViewControllerRelay.accept(viewController)
}
}
SwinjectStoryboard를 설명하는 단계에서 나온 MainViewModeling , MainViewModel이다. MainViewModeling은 BaseViewModeling protocol을 채택하여야 한다.
이제 container를 보도록 하자 container를 만들고 init단계에서 setContainer()를 호출하여 register 를 하였다. 이후 MainViewController 에서 셀을 누르는 경우 didTapAlphabetCell(at: IndexPath) 가 실행된다. 여기서 다음 단계인 CocktailNameListViewController 를 만들기 위한 작업이 이루어진다.
CocktailNameListViewController에 필요한 ViewModel protocol은 CocktailNameListViewModeling protocol이며, 구현체가 CocktailNameListViewModel이 된다. 이때 CocktailNameListViewModel에는 리스트를 가져올 알파벳을 알아야 하기 때문에 인자로 alphabet을 받게 된다.
따라서 viewModel을 따로 resolve 하면서 targetAlphabet을 넣어준 다음, SwinjectStoryboard.create 를 통해서 만들어진 ViewController 에 만들어진 viewModel을 넣었다. 만들어진 ViewController는 relay에 넘겨서 navigationPush를 할 수 있도록 하였다.
Container의 단점?
사용은 편하게 하였으나 확장성에 의문이 생겼다. 만약 다른 곳에서 register 한 요소들을 가져오려면 어떻게 해야할까? 혹은 여기서 등록한 요소를 다른곳에서 어떻게 활용할 수 있을까?
Container Hierarchy를 살펴보면 parent - child 형태로 연결된 container에서 parent container 내부에 등록된 요소들은 child container 에서 resolve 가 가능하다. ( 반대는 안된다. ) 이를 활용하면서 object scope를 적절하게 사용한다면 어느 정도 확장성에 대한 해결이 될것이라고 생각한다.
tag - assembly
위에서 말한 container hierarchy 를 사용하면 마치 클래스의 상속처럼 parent - child 형식으로 범위를 넓혀나갈 수 있을 것 같다. 하지만 프로토콜과 같이 어디서든 쉽게 붙였다 떼었다 할 수 있으면 조금 더 활용도가 높을 것 같다는 생각이 들었다. 문서를 살펴보니 assembly protocol이 있었고, parent-child hierarchy를 사용하는 것 또한 가능했으며, 이를 프로젝트에 직접 활용해보았다.
// MainViewModel.swift
class MainAssembly: Assembly {
func assemble(container: Container) {
container.register(CocktailNameListViewModeling.self) { (_, alphabet: String) in
return CocktailNameListViewModel(alphabet)
}
}
}
// CocktailNameListViewModel.swift
class CocktailListAssembly: Assembly {
func assemble(container: Container) {
container.register(CocktailDetailViewModeling.self) { (_, id: String) in
// 제대로 assemble이 되었는지 확인하는 로그
let test = container.resolve(CocktailNameListViewModeling.self, argument: "z")
if let test = test as? CocktailNameListViewModel {
print("Integration")
print(test.startedAlphabet)
} else {
print("not Integrated")
}
return CocktailDetailViewModel(id)
}
}
}
// 실제 사용하는 코드
let assembler = Assembler([MainAssembly(), CocktailListAssembly()])
...
let viewModel = assembler.resolver ~> (CocktailDetailViewModeling.self, argument: selectedCocktail.idDrink)
// assembler.resolver.resolve(...) 과 동일하다.
// ~> 는 SwinjectAutoregistration을 받으면 사용할 수 있다.
먼저 SwinjectAutoregistration를 사용해서 ~> 라는 기호를 사용했는데 여기에서는 특별한 의미가 없고 단순 resolve를 저렇게 표현했다고 생각하면 된다. 관심이 있다면 해당 라이브러리도 참고 하는것을 추천한다.
제일 중요하게 봐야될 키워드는Assembly그리고Assembler이다. Assembly는 서로 다른 파일에 구현이 되어있다. 그리고 사용이 되는 코드에 Assembler 를 보면 MainAssembly 그리고 CocktailListAssembly 두개를 합친 것을 볼 수 있다. 그리고 CocktailListAssembly 에서 CocktailDetailViewModeling를 resolve 할때 MainAssembly에 등록된 프로토콜이 제대로 resolve 되는지 확인하기 위한 로그도 남겨놓았다.
개인적으로는 Assembly를 사용하는 것이 Container 를 사용하는 것 보다 조금 더 확장성이 있다고 느껴진다. 어떤 파일에 있던 가져올 수 있다면 쉽게 붙였다 떼는것이 가능할 뿐 더러, hierarchy 그리고 objectScope 모두 지원한다.
마치며
글이 이렇게 길어질지는 몰랐는데 생각보다 설명할 부분이 많았던 것 같다. 이 내용을 토대로 앞으로 프로젝트를 Assembly를 활용하여 관리할 생각이다. 또한 이번에 SwinjectAutoregistration를 제대로 활용하지는 않았는데 이 또한 활용하면 재밌을 것 같다는 생각이 들었다. 한번 활용해보고 예제를 만들어 tag 를 추가하게 된다면 이 포스트에 사용법을 적어놓을 예정이다. 많은 도움이 되었기를 바라며 마치겠다. 👏