Struct

  • 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가지

  1. stack vs heap
  2. reference counting
  3. 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) 덕분에 가능하다. > 컴파일 시점에 부르는 곳 마다 타입이 정해져 있고, 런타임시 바뀌지 않으며, 특수화가 가능하다.
Reference

https://www.youtube.com/watch?v=z1Gf6EosaUQ

  • 발표자님께서 참고하신 WWDC
    • WWDC 2016 : S416 : Understanding Swift Performance
    • WWDC 2015 : S409 : Optimizing Swift Performance
    • WWDC 2015 : S414 : Building Better Apps With Value Types in Swift

'iOS & Swift' 카테고리의 다른 글

UICollectionViewCompositionalLayout  (0) 2022.10.31
UINib Basic  (3) 2021.10.04
Property Wrapper  (0) 2021.06.07
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16

저번 포스팅에서 설명했듯이 이번에는 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의 구성

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 >

App Store

Compositional Layout 을 정말 잘 활용해서 만든 앱이 앱스토어라고 생각합니다. 앱스토어를 보면서 간단한 예제를 한번 만들어보겠습니다. Carousel UI가 바로 보이는군요! 세부특징을 아래와 같이 설명할 수 있을 것 같습니다.

  1. 하나의 셀의 Width가 전체 Width 의 60% 정도 (혹은 250 fix), 높이는 대략 150
  2. spacing은 8인데 양옆에는 20 정도의 inset이 있음
  3. 헤더뷰가 있고 다음 섹션과의 차이는 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에 아래와 같이 작성하겠습니다.

// ... 중략
section.orthogonalScrollingBehavior = .groupPaging

let supplementaryItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20))
let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: supplementaryItemSize,
                                                                    elementKind: UICollectionView.elementKindSectionHeader,
                                                                    alignment: .top)
section.boundarySupplementaryItems = [supplementaryItem]
return section

위에서 설정한 것처럼 먼저 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을 설정한다면 원하는 결과가 나오는 것을 확인할 수 있습니다.

// ...
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: supplementaryItemSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading, absoluteOffset: CGPoint(x: -20, y: 0))
// ...

딱 붙는다.

하지만 여전히 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
}

 

Reference

ViewController Code

Cell Code

 

HeaderView Code

 

https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout

https://developer.apple.com/documentation/swift/mainactor

https://developer.apple.com/documentation/uikit/nscollectionlayoutboundarysupplementaryitem/3213820-init

https://developer.apple.com/documentation/uikit/nscollectionlayoutanchor

 

 

 

 

'iOS & Swift' 카테고리의 다른 글

스위프트 성능 이해하기 - Let's Swift  (0) 2023.06.20
UINib Basic  (3) 2021.10.04
Property Wrapper  (0) 2021.06.07
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16

이직을 하면서 처음에 가장 어려움을 겪었던 부분이 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 등을 살펴볼 예정입니다!

Reference

https://developer.apple.com/documentation/uikit/uinib/

https://www.indelible.org/ink/nib-loading/

https://coderedirect.com/questions/369715/how-to-use-a-common-target-object-to-handle-actions-outlets-of-multiple-views

'iOS & Swift' 카테고리의 다른 글

스위프트 성능 이해하기 - Let's Swift  (0) 2023.06.20
UICollectionViewCompositionalLayout  (0) 2022.10.31
Property Wrapper  (0) 2021.06.07
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16

이번에는 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를 통해서 다양한 방식으로 활용이 가능한 것을 확인할 수 있었습니다. 이를 활용해서 다양한 방식으로 코드의 중복을 낮추고 효율을 높이셨으면 좋겠습니다.

 

Reference

https://docs.swift.org/swift-book/LanguageGuide/Properties.html#ID617

https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md

'iOS & Swift' 카테고리의 다른 글

UICollectionViewCompositionalLayout  (0) 2022.10.31
UINib Basic  (3) 2021.10.04
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16
Intrinsic Size & CHCR Priorities  (2) 2021.05.09

개발을 하다보면 공식 문서를 빈번하게 찾고는 합니다. 단순히 메서드 혹은 클래스는 현재 apple document 에서도 쉽게 찾을 수 있지만, 동작원리를 알아야 하는 경우 기존에 있던 레퍼런스를 찾는데 가끔 찾기가 힘든 경우가 있습니다. 대표적인 몇가지 가이드 및 레퍼런스 링크를 준비했으며 도움이 될만한 애플 공식 레퍼런스가 있다면 댓글 남겨주시면 감사하겠습니다!

 

Swift Document

 

Human Interface Guidelines

 

Document Archive

Document Archive 의 경우 더 이상 업데이트가 되지 않습니다. 기존의 문서 및 코드들을 아카이브 해놓은 곳이며 아래의 대부분 링크는 여기서 가져왔습니다. 최신 가이드가 있을 수도 있다는 점 유의하시길 바랍니다.

 

View Programming Guide

 

View Controller Programming Guide

 

Resource Programming Guide

 

UITableView Guide

 

UICollectionView Programming Guide

 

Auto Layout Guide

 

Text Layout Programming Guide

 

Scrolling View Programming Guide

 

App Programming Guide for iOS ( UIKit )

 

Threading Programming Guide

 

Concurrency Programming Guide

 

Core Animation Basics

'iOS & Swift' 카테고리의 다른 글

UINib Basic  (3) 2021.10.04
Property Wrapper  (0) 2021.06.07
Escaping Closure  (0) 2021.05.16
Intrinsic Size & CHCR Priorities  (2) 2021.05.09
Swinject 사용하기 ( 2 / 2 )  (0) 2021.04.21

자주 사용하고 있지만 정확한 개념을 설명하기 어렵다는 생각이 들어서 이번 포스팅을 통해 정리하고자 합니다. 처음 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 라고 하는 것입니다.

 

func testEscaping(_ esClosure: @escaping (Int) -> Void) { 
  let urlRequest = URLRequest(url: URL(string: "hello world")!)
  var testCount = 3
	session.dataTask(with: urlRequest) { data, response, error in 
    print("world")
    esClosure(testCount)
  }.resume() 

  print("hello")
} 

 

대표적인 예제를 하나 살펴보겠습니다. 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 라는 개념이 존재했기 때문에 가능했다고 생각하면 될 것 같습니다.

 

문서를 최대한 참고하였으며, 설명이 부족하거나 이해가 안되는 부분은 직접 확인하시는 것이 제일 좋습니다. 혹여나 제가 잘못 알고 있는 부분이 있거나 전달이 부족한 부분이 있다면 댓글 부탁드립니다. 감사합니다!

 

reference

https://andybargh.com/lifetime-scope-and-namespaces-in-swift/#Scope

Capturing Values

Escaping Closure

Strong Reference Cycles for Closures

Resolving Strong Reference Cycles for Closures

'iOS & Swift' 카테고리의 다른 글

Property Wrapper  (0) 2021.06.07
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Intrinsic Size & CHCR Priorities  (2) 2021.05.09
Swinject 사용하기 ( 2 / 2 )  (0) 2021.04.21
Swinject 사용하기 ( 1 / 2 )  (1) 2021.04.21
글을 쓰다보니 존댓말을 쓰는 것이 조금 더 자연스러울 것 같아 이번 포스팅부터 존댓말을 쓰도록 하겠습니다 !

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 이상이 되어야 한다면 어떻게 해야할까요?

 

  1. 라벨간의 거리를 16으로 제약을 겁니다.
  2. 늘어나야하는 라벨 ( 콘텐츠 라벨 )의 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 Priorities ( ContentHugging & CompressionResistance )

 

그렇다면 이 두개를 동시에 사용하는 일이 있을까요? 네 있습니다. 위에서도 설명했듯이 CHCR Priority 는 intrinsicSize 를 기준으로 합니다. 따라서 상황에 따라서는 두가지를 모두 정확하게 작성을 해야 어느 텍스트가 들어오더라도 의도된 UI가 나올 것입니다.

 

 

CompressionResistancePriority를 설정한 예제에서 오른쪽 라벨의 intrinsic 사이즈만 줄인 경우입니다. 보시다시피 레이아웃이 시스템에 적절하지 않습니다. 이유는 ContentHuggingPriority가 동일하기 때문에 어떤 라벨의 사이즈를 더 키워야 할지 시스템에서는 모르기 때문입니다.

 

예시로 왼쪽의 라벨의 크기를 유동적으로 하는것으로 하겠습니다. 왼쪽 라벨의 CHPriority를 낮추는 경우 intrinsicSize가 작아 제약조건을 만족하지 못할때 왼쪽의 라벨이 커지게 되고, intrinsicSize 가 커서 제약조건을 넘어가는 경우 왼쪽 라벨의 크기가 줄어들게 됩니다.

 

 

마무리

이렇게 ContenteHugging 그리고 CompressionResistance Priority 에 대해 알아봤습니다. 활용할 부분도 굉장히 많고 UI 디버깅을 진행하다보면 다른 부분에서도 Priority 가 많이 적용된 것을 확인할 수 있습니다. 관심있게 보신다면 좋은 UI 작성 방식이 될 것이라고 생각합니다.

 

Reference

Apple - AutoLayoutGuide

https://developer.apple.com/documentation/uikit/uiview/1622600-intrinsiccontentsize

'iOS & Swift' 카테고리의 다른 글

Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16
Swinject 사용하기 ( 2 / 2 )  (0) 2021.04.21
Swinject 사용하기 ( 1 / 2 )  (1) 2021.04.21
SOLID Principle  (0) 2021.04.12

이전 포스트에 이어서 이번 포스트는 실제로 예제를 통해서 Swinject를 어떻게 사용하는지 설명하는 글이다. container를 활용하는 버전과 assembly 를 활용하는 버전 둘다 설명한다.

 

Swinject 사용하기 ( 1 / 2 )

Swinject 사용하기 ( 2 / 2 ) ( 현재 )

Example - CocktailMaster

012
cocktail master example

 

첫번째 화면에서는 어떤 알파벳으로 시작하는 칵테일을 볼 것인지를 선택한다. 이 셀을 누르게 되면 두번째 화면으로 넘어간다.

두번째 화면은 해당 알파벳으로 시작하는 칵테일 리스트를 볼 수 있다. 여기에서 셀을 누르게 되면 세번째 화면으로 넘어간다.

세번째 화면에서는 칵테일의 디테일 요소를 보여준다. 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이 된다. 이 과정을 아래와 같은 단계로 풀어서 설명할 수 있다.

 

  1. MainViewModeling 을 register 한다. resolve를 하게되면 MainViewModel instance가 생성될 것이다.
  2. SwinjectStoryboard.create 를 통해서 만들고자 하는 Storyboard 그리고 identifier 를 연결한다. 이때 container 인자에 사용하고자 하는 container 를 등록한다.
  3. 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 를 추가하게 된다면 이 포스트에 사용법을 적어놓을 예정이다. 많은 도움이 되었기를 바라며 마치겠다. 👏

( 언제든지 피드백은 환영입니다. 👍🏻 )

Reference

직접 만든 예제 - CocktailMaster

예제에서 사용한 API - CocktailDB

Swinject

Swinject - Document

SwinjectStoryboard

SwinjectAutoRegistration

laywenderlich - Swinject Tutorial for iOS: Getting Started

Swinject in practice

'iOS & Swift' 카테고리의 다른 글

Escaping Closure  (0) 2021.05.16
Intrinsic Size & CHCR Priorities  (2) 2021.05.09
Swinject 사용하기 ( 1 / 2 )  (1) 2021.04.21
SOLID Principle  (0) 2021.04.12
Responder Chain ( 앱은 유저의 인터렉션을 어떻게 처리하는가 ? )  (0) 2021.03.18

Swinject

저번 포스팅에서 SOLID를 공부한 이유는 사실 DI를 이해하고 이를 적용하기 위함이었다. 직접 프로토콜을 설계하고 작업하려다가 기존에 나와있는 DI Library 는 어떻게 활용하는지 궁금해서 문서를 읽고 예제를 만들어봤다. 오늘 소개할 라이브러리는 Swinject 라는 DI Library 이고, 추가로 SwinjectStoryboard를 간단하게 사용할 예정이다.

 

이번 포스팅에서는 문서에서 중요하다고 생각한 부분들을 추리고, 예제 및 문서의 코드를 직접 활용하면서 어떻게 사용했는지 설명 할 생각이다. 문서의 양이 얼마 되지 않으니 한번 전체를 읽어보고 다시 와서 핵심을 파악하는 것을 추천한다.

 

글이 너무 길어져서 읽기 힘들다는 생각이 들어 2개의 포스트로 나누었다. 첫번째 포스트에서는 Document 위주로 설명을 하는 포스트가 될 것이고, 두번째 포스트는 이를 토대로 실제 예제에 어떻게 적용을 시키는지에 대해서 작성을 할 예정이다. 레퍼런스의 경우 2번째 포스트의 맨 아래에 적어놓을 예정이다.

 

Swinject 사용하기 ( 1 / 2 ) ( 현재 )

Swinject 사용하기 ( 2 / 2 )

Container

DI Container 로 이해하면 되며, 사실상 Swinject의 핵심이라고 할 수 있다. Container 에 원하는 serviceType을 register 하면 된다. 이후에 필요한 곳에서 resolve를 통해 해당 타입에 맞는 객체가 생성되어 나온다. 샘플 프로젝트에서 예시를 가져왔다.

let container = Container()
// CocktailNameListViewModeling (protocol)
// CocktailNameListViewModel (class)
// register
container.register(CocktailNameListViewModeling.self) { (_, alphabet: String) in
    return CocktailNameListViewModel(alphabet)
}
// resolve
let viewModel = container.resolve(CocktailNameListViewModeling.self, argument: targetAlphabet(at: indexPath))

ViewModel 을 실제로 등록하고 사용하는 부분의 코드이다. CocktailNameListViewModeling이라는 protocol을 serviceType 으로 등록하고 해당 프로토콜을 채택한 CocktailNameListViewModel을 리턴하도록 container 에 등록한다.

 

이후 ViewModel을 injection 해줘야 하는 부분에서 resolve를 통해 구체화된 객체 CocktailNameListViewModel을 가져온다. 이때는 등록할때의 클로저(factory closure) 에 필요한 인자들을 넘길 수 있다.

register 가 중복되는 경우는?

이 문서를 봤을 때 궁금했던 점은 동일한 프로토콜을 여러 곳에서 사용하려면 ( 여러 구현체가 있다면 ) 어떻게 해야할까? 라는 점이었다. 결론부터 말하자면 내부적으로 생성되는 Key인 Registration Key 를 통해서 구분할 수 있다. 라이브러리에 구현된 코드를 가져왔으며 이를 보면서 설명하겠다.

// Container.arguments.swift
@discardableResult
public func register<Service, Arg1>(
    _ serviceType: Service.Type,
    name: String? = nil,
    factory: @escaping (Resolver, Arg1, ...) -> Service
) -> ServiceEntry<Service> {
    return _register(serviceType, factory: factory, name: name)
}    

container 에 등록을 할때 내부적으로 Registration Key 가 생성되어 서로를 구분하게 된다. 이때의 키는 위에서 register 할때의 인자 3가지 를 통해서 구분하는 것을 확인할 수 있다. 인자는 다음과 같이 설명할 수 있다.

 

  1. type of the service ( serviceType )
  2. name of registration
  3. number and type of the arguments

이 덕분에 동일한 타입을 등록하더라도 다른 register 함수와의 차이를 판단하고 서로 다른 구현체를 resolve 할 수 있다. 2번에 대한 설명을 문서에 있는 코드와 함께 설명하겠다. 문서의 코드를 확인해보면 name: "cat" 라는 인자를 넘긴 것을 확인할 수 있다.

container.register(Animal.self, name: "cat") { _ in Cat(name: "Mimi") }    

또한 Registration Key를 구분하는 3가지의 인자중 하나라도 다른 경우 nil을 리턴하게 되니 이를 조심해야 한다.

Circular Dependencies

Swinject를 사용하면 자동으로 서로 resolve 되도록 설계할 수 있고, 이를 통해서등록이 되니 편함을 느낄 수 있다. 이때 만약 A를 만들때 B가 필요하고 B를 만들때 A가 필요한 Circular Dependencies가 생긴다면 이는 어떻게 해야할까? 생각해보면 생성하는 단계에서 무한으로 서로 resolve 할 가능성이 있어 보인다.

 

이를 해결하기 위해서는 A와B 둘중 하나라도 property injection이 들어가야 한다고 문서에서 설명한다. 즉 intializer/initialzer dependencies는 지원하지 않는다. injection 에 대한 설명은 문서에서 확인할 수 있고, 아래는 Initializer/Property Dependencies의 코드를 예시로 가져왔다.

protocol ParentProtocol: AnyObject { }
protocol ChildProtocol: AnyObject { }
// initializer injection
class Parent: ParentProtocol {
    let child: ChildProtocol?
    init(child: ChildProtocol?) {
        self.child = child
    }
}
// property injection
class Child: ChildProtocol {
    weak var parent: ParentProtocol?
}

initCompleted closure 에서 필요한 dependency를 property injection 방식으로 넘기면 된다. 이를 통해서 아래와 같이 사용하면 Circular Dependencies 를 해결할 수 있다.

let container = Container()
container.register(ParentProtocol.self) { r in
    Parent(child: r.resolve(ChildProtocol.self)!)
}
container.register(ChildProtocol.self) { _ in Child() }
    .initCompleted { r, c in
        let child = c as! Child
        child.parent = r.resolve(ParentProtocol.self)
     }

Object Scope

resolve를 통해서 생성된 객체를 유지하려면 어떻게 해야할까? 혹은 생성된 인스턴스의 lifecycle은 어떻게 될까? 해당 부분에 대한 설명은 Object Scope를 통해서 할 수 있다. Swinject 에서 제공하는 Object Scope는 4가지이다.

 

  1. Transient
    Transient 를 사용한다면 Instance는 공유되지 않는다. 즉 resolve를 할 때마다 Instance 가 생성된다. Circular Dependencies 에서 사용하는 경우는 주의를 해야한다.
  1. Graph(default)
    Transient 와 비슷하게 container 에서 resolve 를 하는 경우 항상 생성한다. Transient와의 차이는 factory closure에서 resolve 를 하게 되는 경우 resolution이 끝날때 까지 해당 인스턴스는 공유된다는 점이다.
  1. Container
    다른 DI frameworks의 singleton이라고 생각하면 된다. container 를 통해서 생성된 instance 는 해당 container 그리고 chile containers(child containers 가 궁금하다면 Container Hierarchy 문서를 확인해보자)까지 공유가 된다. 다시 말해서 어떤 container 에서 A 타입을 register 한 이후에 resolve 를 하게 되면, 이후 A 타입에 대한 resolve 는 앞서 생성한 instance 가 동일하게 리턴된다. ( 동일한 객체 )
  1. Weak
    재미있는 케이스이다. instance를 strong 으로 잡고 있는 경우는 Container 처럼 서로 공유하지만 더 이상 strong reference 가 없는 경우 더 이상 공유되지 않는다. 이후 resolve를 하게 되면 새로운 인스턴스가 생성이 된다.

Custom Scope

위에서 설명한 4가지 스코프는 Swinject에서 정한 스코프이다. 이 외에도 직접 Scope를 만들어서 사용할 수 있는데 이를 Custom Scope라고 한다. 이때 container scope 처럼 공유가 되는데 필요에 따라서 객체들을 reset 할 수 있다는 차이가 있다.

// 생성
extension ObjectScope {
    static let custom = ObjectScope(storageFactory: PermanentStorage.init)
}
// 제거
container.resetObjectScope(.custom)

조금 더 자세하게 알고 싶다면 해당 블로그에서 확인해보자

Assembly

위와 같이 container 자체로 활용할 수도 있지만 만약 도메인별로 관리하고 싶은 경우는 어떻게 해야할까? 혹은 연관된 기능들을 필요한 부분만큼만 관리하고 싶다면 어떻게 해야할까?

 

연관된 서비스를 그룹화하는 기능을 제공하기 위해서 Swinject 에서는 Assembly 라는 프로토콜을 제공한다. 이를 통해서 아래와 같은 기능을 사용할 수 있다.

  • 하나의 장소에서 여러개의 서비스를 관리할 수 있음
  • 공유된 Container 를 제공함
  • 서로 다른 설정을 가진 assembly를 등록할 수 있다. 따라서 mock implement에 유리하다.
  • 제대로 설정이 되면 ( 등록 과정이 끝나면 ) 알림을 받을 수 있다.

물론 다 옵션이므로 필요한 부분만 사용하면 된다. 아래 예시 코드를 간단하게 가져왔다. ManagerAssembly 에서 ServiceAssembly 에서 등록한 protocol을 사용하는 것을 확인할 수 있다.

class ServiceAssembly: Assembly {
    func assemble(container: Container) {
        container.register(FooServiceProtocol.self) { r in
           return FooService()
        }
        container.register(BarServiceProtocol.self) { r in
           return BarService()
        }
    }
}

class ManagerAssembly: Assembly {
    func assemble(container: Container) {
      // 다른곳에서 등록한 protocol 또한 활용할 수 있다.
        container.register(FooManagerProtocol.self) { r in                                                     
           return FooManager(service: r.resolve(FooServiceProtocol.self)!)
        }
        container.register(BarManagerProtocol.self) { r in
           return BarManager(service: r.resolve(BarServiceProtocol.self)!)
        }
    }
}

Assembler

하지만 위의 코드는(Assembly) 리소스를 준비하는 과정이고, 실제로 사용하기 위해서는 Assembler 를 사용해야 한다. 여러 곳에서 준비된 Assembly들을 잘 조합해서 Assembler를 사용하는 것이다.

let assembler = Assembler([
    ServiceAssembly(),
    ManagerAssembly()
])

let fooManager = assembler.resolver.resolve(FooManagerProtocol.self)!
// or lazy load assemblies
assembler.applyAssembly(LoggerAssembly())

Assembler 는 Assemblies 그리고 Container 를 관리한다. Assembler 를 통해서 등록된 Assembly 들만 Container 에서 사용할 수 있다는 것을 기억해두자. 또한 resolve하기 위해서 접근하는 resolver는 assembler의 resolver(assembler.resolver) 로 제한이 된다.

Thread Safety

Swinject는 concurrent applications 로 구현이 되어있다. 따라서 Container 는 thread safe 하지 않다. 하지만 이를 thread safe 하도록 하는 방법이 있는데 synchronize method를 이용해서 리턴되는 resolver를 사용하는 것이다.

let container = Container()
container.register(SomeType.self) { _ in SomeImplementation() }
let threadSafeContainer: Resolver = container.synchronize()
// Do something concurrently.
for _ in 0..<4 {
    dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)) {
        let resolvedInstance = threadSafeContainer.resolve(SomeType.self)
        // ...
    }
}

container 가 thread safe 하지만 이는 resolve에만 해당하는 이야기이다. register 는 여전히 thread safe하지 않다. 따라서 registeration 은 단일 쓰레드에서 실행이 되어야 한다.

 

또한 만약 container 가 hierarchy 를 갖고 있다면 연결된 모든 컨테이너는 synchronize()를 사용해야 한다는 점을 알아두자

 

다음포스트 >

코드를 작성하다보면 SOLID Principle이라는 규칙을 자주 보고는 한다. 물론 이를 공부한다고 모든 코드를 SOLID에 입각한 코드로 변경하는 것은 무리다. 하지만 객체지향을 공부하는 사람으로써 바이블격인 SOLID를 공부하고 이를 정리해야겠다는 생각이 들었다. 글의 순서는 아래와 같다.

저 또한 배우고 있는 과정이기 때문에 문제가 있거나 잘못된 부분이 있다면 감사하겠습니다!

  • SOLID Principle
  • Single Responsibility Principle
  • Open/Close Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

SOLID Principle

  • 어떤 장점이 있는가?
    • 재사용성이 높은 코드를 만들어 컴포넌트화를 통한 생산성을 높힐 수 있다.
    • 테스트가 가능한 코드를 만들어 관리할 수 있다.
    • 여러곳에 묶여 있는 스파게티 코드를 만들지 않는 기준을 세울 수 있다.
    • 변경에 유연하며 확장성이 높은 코드를 만들 수 있다.
    • 결국 관리하는데 필요한 리소스가 줄어든다.

S (Single Responsibility Principle , 단일 책임 원칙 - SRP )

  • 하나의 모듈은 하나의 이해관계자를 만족시켜야 한다.
    • 여기서 모듈이란 데이터와 함수의 응집된 집합을 말한다. ( 보통 클래스 )
    • 이런 관점에서 쉽게 말하자면, 단일 클래스는 단일 책임을 가지고 있어야 한다.
  • 코드의 응집성을 높힌다.

Why?

처음에 모듈을 만들 때 이 규칙만큼은 쉽게 지킬 수 있을 것이다. 문제는 작은 기능이 하나둘씩 추가 되면서 시작된다. 처음에는 하나의 기능만 추가 된다고 하지만 하나만 추가 되리라는 보장은 없다. 계속해서 기능이 추가되고 점점 해당 모듈의 책임은 많아지게 된다.

해당 모듈의 책임이 많아 질수록 코드가 병합될 확률이 높아진다. 예를 들어 Alpha 클래스에 A, B 라는 책임을 갖고 있을때는 A와 B의 코드가 서로 병합되는 상황이 발생할 수도 있으며, 책임(기능)이 하나 둘씩 늘어날수록 그 가능성은 점점 커질 것이다.

이때 만약 A의 기능을 리팩토링 한다고 하자. 병합이 많아질수록 A 뿐만 아니라 다른 기능 또한 수정해야 할 가능성이 높아지며, 해당 기능이 제대로 동작하는지 또한 다시 확인해야 한다. 결국 이를 해결하기 위해서는 꼬인 끈을 모두 풀어내는 수 밖에 없으며, 기능이 많아질수록 이에 대한 비용은 기하 급수적으로 늘어나게 된다.

특히 iOS 의 경우 UIViewController가 대표적인 예라고 할 수 있다. UI, Network, Navigation 등등 모든 역할을 책임지기 때문이다. 이를 잘 나누는 것이 중요하다고 할 수 있다.

How?

첫번째로, 작은 피쳐를 그만 추가해라. 모듈, 컴포넌트, API 형식으로 분리하는것을 생각해봐라. 기능을 붙일 생각을 하지말고 라이브러리로 만들 생각을 해야한다.

그리고 하나의 기능을 할 수 있는 클래스를 만들어라. 만약 커다란 문제에 가로막혔다면 이를 분리해라. 그리고 분리한 클래스를 사용하는 클래스를 하나 만들어라

O ( Open / Close Principle , 개방/폐쇄 원칙 - OCP )

  • 확장에는 열려있고, 변경에는 닫혀있어야 한다.
    • 즉 개체의 행위는 확장할 수 있어야 하지만, 개체가 변경되어서는 안된다.

Why?

예를 들어서 Bread House 라는 클래스를 만들고 여기서 빵을 만들어서 판매한다고 하자. 초기에는 단팥빵만 판매한다고 생각했다. 따라서 단팥빵에 맞는 로고를 만들고, 기구를 들였으며, 저장 창고도 만들어놓았다. 그런데 어느날 Bread House 에서 카스테라를(확장) 팔아야 하는 상황이 왔다. 이미 단팥빵에 맞춰서 House를 설계했기 때문에 로고는 물론, 기구, 저장창고도 바꿔야 하는 최악의 상황이 나타날 수도 있다. 결국 이에 대한 비용은(변경) 굉장히 많을 것이다.

만약 처음부터 특정 빵에 전문화하지 않고, 다른 빵을 판매할 가능성이 있다고 고려한 다음 설계했다면 어땠을까? 확장에 대한 비용은 급격하게 줄어들 것이라고 확신한다. 이 방식이 OCP를 고려한 방식이다.

실제로 사용하는 예시를 들자면 network 에서 데이터를 가져온 뒤 이를 decode 하는 방식을 들 수 있다. 아래의 코드를 살펴보자 타입이 추가될때 마다 decode관련 함수를 추가해야할 것이다. 이때 타입이 수십개 추가된다면 어떻게 될까?

class Parent: Decodable { }
class Child: Decodable { }

func decodeParentData(_ data: Data) -> Parent {
    let decoder = JSONDecoder()
    return try! decoder.decode(Parent.self, from: data)
}

func decodeChildData(_ data: Data) -> Child {
    let decoder = JSONDecoder()
    return try! decoder.decode(Child.self, from: data)
}

How?

아우를 수 있는 확장성이 높은 코드를 사용하는 것이 좋다. 제네릭, 상속, 프로토콜을 사용하도록 하자. 결국 다른 타입이 추가 되더라도, 기존의 코드는 최대한 건드리지 않으면서 새로운 타입에 대해서도 기존에 사용했던 기능들은 그대로 사용할 수 있어야 한다.

위의 예시를 Generic과 Protocol을 사용해서 변경한다면 Parent 혹은 Child 심지어 다른 타입이 추가되더라도 Decodable 프로토콜을 채택한다면 전혀 문제가 없을 것이다. 즉, 다른 타입이 추가되는 확장에도 문제가 없으며, decode 함수가 변경될 일이 없으므로 변경에도 닫혀 있는 코드이다.

func decodeData<T: Decodable>(_ data: Data, _ type: T.Type) -> T? {
    let decoder = JSONDecoder()
    return try? decoder.decode(type.self, from: data)
}

L ( Likov Substitution Principle, 리스코브 치환 원칙 - LSP )

  • S가 자료형 T의 하위타입이라면 프로그램의 속성의 변경없이 자료형 T의 객체를 자료형 S의 객체로 교체할 수 있어야 한다.
    • 상위 타입의 객체를 사용하는 곳에 하위 타입의 객체를 사용하더라도 의도한대로 동작이 되어야 한다.
    • 객체는 프로그램의 정확성을 해치지 않으며, 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
    • 쉽게 말하자면 자식 클래스는 최소한 자신의 부모클래스의 행위는 수행할 수 있어야 한다는 말이다.

전형적인 위반 사례

해당 규칙이 제일 이해하기가 애매해서 대표적인 위반 사례를 가져왔다. Circle-elipse(sqaure-rectangle) Problem이다.

너비와 높이의 조회 및 할당 메서드를 가진 직사각형 클래스로부터 정사각형 클래스를 파생하는 경우를 예로 들 수 있다. 직사각형을 상위타입으로 생각하고, 정사각형을 하위타입이라고 하자.

하지만 직사각형과 다르게 정사각형은 넓이와 높이가 항상 같은 특징을 가지고 있다. 따라서 너비와 높이를 따로 관리하는 직사각형을 사용하는 프로그램에서 이와 다른 정사각형을 대체하는 경우 얘기치 못한 문제가 생길 가능성이 높다.

Why?

명확하게 경계를 긋지 않는다면 생길 수 있는 문제점들이 많기 때문에 해당 부분을 신경써야 한다. 위의 예시를 생각해보자. 직사각형을 부모로 생각하고 정사각형을 하위 요소로 생각했기 때문에 문제가 발생했다. 이는 잘못된 상속의 사례를 보여줌으로써 이를 활용할 수 있는 방안이 제한되는 것을 보여준다.

(클린 아키텍쳐에서는 이렇게 설명한다.) 이는 method의 override 가 어떻게 이루어져야 하는지를 설명하기도 한다. 예를 들어서 a라는 메서드를 오버라이드 했을 때 성공한 경우, 실패한 경우, 중간에 변경되어야 하는 경우 등의 흐름이 a라는 메서드와 동일해야 한다는 말이다.

a에서는 실패한 경우 message를 보내주는데, 오버라이드 한 메서드는 실패한경우 아무것도 일어나지 않는다면 이는 LSP를 어긴 것이다. 따라서 동일하게 동작한다는 것은 실제로 동일한 코드가 아니라 동일한 흐름을 가져야 한다는 것이다.

How?

다른 하위타입으로 대체가 되더라도 동일한 동작을 해야한다. 이를 위해서 상속의 범위를 잘 생각해야 한다. 사용하는 메서드가 전체를 아우르는 메서드가 맞다면 동일한 동작을 해야하며, 그렇지 않다면 따로 작성해야 함을 의미한다. ( Circle-elipse )

또한 method를 override 할 때 내부의 동작 흐름이 기존의 동작 흐름과 동일한지 생각해봐야 한다. 예를 들어서 a라는 메서드를 오버라이드 했을 때, 성공한 경우, 실패한 경우, 중간에 변경되어야 하는 경우 등의 흐름이 a라는 메서드와 동일해야 한다는 말이다. a에서는 실패한 경우 message를 보내주는데, 오버라이드 한 메서드는 실패한경우 아무것도 일어나지 않는다면 이는 LSP를 어긴 것이다. 따라서 동일하게 동작한다는 것은 실제로 동일한 코드가 아니라 동일한 흐름을 가져야 한다는 것이다.

따라서 오버라이드를 했는데 다른 부분을 수정하거나 추가한다면 이는 잘못된 것이다. ( 치환해도 동일하게 작동해야 한다는 뜻이 이 뜻이다. ) 서브클래스를 제외하고서는 다른 부분이 변경되어서는 안된다.

I ( Interface Segregation Principle, 인터페이스 분리 원칙 - ISP )

  • 일반적인 인터페이스보다 각각의 구체적인 인터페이스를 가지는 것이 낫다.
  • 불필요한 기능이 들어간 인터페이스 설계를 피해야 한다.

Why?

애초에 불필요한 기능은 넣을 필요가 없다. 하나의 일만 하는데 다섯개의 기능이 들어가 있다면, 나머지 4개의 코드를 관리하는데 드는 비용은 전혀 중요하지 않은 비용이 된다.

How?

Fat Interface 를 만들지 말자. 필요한 기능만 인터페이스에 추가해서 사용하도록 하며, 여러개를 사용하는 경우는 이를 합치면 된다. Swift에서의 대표적인 예로 Codable 이 있다. Codable의 경우 Decodable 과 Encodable protocol이 합쳐진 typealias 이다. 따라서 두개의 기능이 필요 없다면 Decodable, Encodable 한쪽만 사용하면 된다. Decode만 수행하면 되는데 굳이 Codable을 쓸 필요가 없다. 바나나를 원하는데 고릴라와 함께 온다면, 바나나는 얻었지만 고릴라는 귀찮은 존재가 된다. 그만큼 세분화 해서 나눠야 적절한 곳에 배치할 수 있다.

D ( Dependency Inversion Principle, 의존관계 역전 원칙 - DIP )

  • loose coupling module을 만들기 위한 principle이다.
  • 상위 레벨 클래스가 하위 레벨 클래스에 의존하지 않도록 한다.
    • 다시 말해서 상위 레벨 클래스는 하위 레벨 클래스의 구현과 독립되어야 한다.
    • 여기서 상위 레벨이란 ViewController / ViewModel 등을 말하며, 하위 레벨이란 Storage / Network Module 을 말한다.

Why?

decoupling 을 위해서 사용이 되는 principle이다. decoupling 이란 strong coupling이 아닌 loose coupling을 추구하는 과정을 말한다. 다른 구체화된 개체에 강하게 의존했을 때 strong coupling 이 생기게 된다. 이때 하나의 변수를 수정할 때에도 여러 의존성이 서로 묶여있기 때문에 쉽게 변경이 불가능 할 것이다. 이런 상황을 strong coupling 이라고 하며, 이가 많아질 수록 코드의 확장성은 떨어지고, 유지보수 비용은 급격하게 증가하게 된다.

또한 구체화된 개체보다 추상화된 개체를 선택하는 것이 변동성이 작기 때문이다. 프로토콜(추상화)를 의존한다면 내부의 동작(구체화된 개체)에 대해서는 신경쓰지 않아도 된다. 즉 구현체의 변경에 민감하지 않다. 반대로 구현체를 의존하게 된다면 구현체가 변경 될 때마다 민감하게 반응을 해야 할 것이다.

How?

high-level 에서 단순히 low-level을 참조하는 것이 아닌 추상화를 서로 참조하는 방식을 사용한다. 쉽게 말해 기존에 A → B의 형태로 코드가 작성되었을 때는 A 와 B가 tight coupling 이지만, A → B protocol ← B 형태의 코드를 작성하게 된다면 loose coupling 이 되며 서로의 변경에 영향이 적어지게 된다.

이를 설명할 때 dependency inversion은 low-level에서 high-level을 참조하는 것이 아니다. 단지 기존의 흐름을 바꾸는 것을 inversion으로 표현하는 것으로 알고 있다. 특히 예시의 화살표 방향을 살펴보면 B는 참조를 받는 입장에서 B protocol을 참조하는 방향으로 바뀌는것을 확인할 수 있다. 기존의 제어 흐름과 소스코드의 의존성이 반대가 되는 것이다.

reference

클린아키텍쳐 ( book )

Wiki - SOLID

[iOS] 객체지향과 SOLID 원칙 - Swift

Scaledrone realtime messaging service - Blog

+ Recent posts