뒤로
씨마스터300
씨마스터300 ·

“UIKit이 좋아 SwiftUI가 좋아?”, “평소에? 아니면 오늘?” (수수 TCA + SwiftUI 적용기)

수수에서 경조금을 언제 받았는지 기록하세요!

안녕하세요. 옥수수 팀에서 iOS 개발을 담당하고 있는 정다함입니다. 이 글은 옥수수 iOS 팀이 SwiftUI + TCA를 적용하기까지의 고민과, 장단점에 대해서 설명하려고 합니다.

SwiftUI는 처음이라 (SwiftUI + TCA를 선택하기 까지)

1. 기술스택 정하기 == 첫 단추 끼우기

프로젝트에서 제일 중요한 순간이 있다면, 기술 스택을 정할 때입니다. 기술 스택에 따라서 사용하는 아키텍처나 디자인 패턴 등 복합적이고도 다양한 것들이 초기에 세팅했던 기술들에 의존성이 생깁니다. 그렇기 때문에 어떤 기술을 선택해야 하는지에 대해 충분한 고민과 팀원들과 상의를 해야 합니다.

image.png

SwiftUI 와 UIKit의 기술 선택에 있어서 어떤 기술을 선택할까에 대한 질문을 했을 때 아래와 같은 장단점들을 정리할 수 있었습니다.

UIKit 장점

  1. 오래된 프레임워크이다. 우리가 구현할 많은 코드들이 인터넷에 존재할 가능성이 농후하다.

  2. 자주 써왔고 프레임워크에 대한 관성과 직관이 있다.

UIKit 단점

  1. 이미 아는 기술이라 새로운 인사이트를 느끼지 못할 수도 있다.

  2. UIKit이 SwiftUI보다 View를 만드는데 느리다.


SwiftUI 장점

  1. UIKit보다 View 만들기 쉽고 빠르다.

  2. 새로운 프레임워크를 공부할 수 있다.

SwiftUI 단점

  1. 비교적 젊은 프레임워크이다. 우리가 직면하는 문제에 답을 찾기까지 오랜 시간이 걸릴 수 있다.

  2. 처음 배우는 프레임워크

  3. 어쩌면 UIKit보다 개발기간이 오래 걸릴 수 있다.


수수 앱의 특성상 안드로이드가 이미 출시된 상황입니다. “빠른 개발을 위해서 어떤 프레임워크를 써야 하는가?”에 대한 대답은 UIKit이었습니다. 당장이라도 내일 작업을 시작해야 했다면 UIKit을 적용하는 게 맞았습니다.

하지만 수수 팀은 SwiftUI에 대한 힘을 믿었습니다. 비록 시작 단계에서는 UIKit 보다 오래 걸려도 나중에 가면 SwiftUI가 그 진가를 발휘할 것이라 생각했습니다. 또한 프로젝트를 통해서 한 단계 더 지식을 배울 수 있다는 생각도 의견도 지배적이었습니다.

(UIKit으로 auto layout 잡기 너무 힘들어요...)

image.png

2. 엄마 저는 커서 “테스트 용이, 일관된 코드, 유지보수 용이”한 아키텍쳐가 될래요(Architecture 정하기)

세상에는 정말 많은 Architecture가 있습니다. MVC, MVVM, MVP, Clean Architecture... 등등.

아키텍처를 선택함에 있어서 유연한 코드 구조, 책임 분리 두 가지를 가장 크게 염두 하였습니다. 매시브 해지는 Domain Logic과 Service Logic을 어떻게 테스트하며 관리해야 할까라는 주제는, 많은 아키텍처들이 생겨난 이유입니다. 그 해답으로서 Clean Architecture와 TCA를 고민하였습니다. 둘의 각각의 장단점이 있었는데, TCA의 가장 큰 단점은 “러닝 커브”였습니다. 아무래도 SwiftUI와 TCA를 한 번에 적용하기 위해서는 공부가 많이 필요했습니다. 하지만 “TCA의 일관된 코드 작성”, “코드 작성의 강제성 부여” 이 두 가지가 가장 크게 다가왔습니다. 사람마다 작성하는 코드 스타일도 다른 것이 후에 다른 사람 코드를 읽었을 때 어지럼증을 유발합니다. 이를 TCA의 힘으로 강제성을 부여함으로써 일관된 코드 작성을 할 수 있다고 생각했습니다. 결론적으로 TCA를 적용하면, 모두가 비슷한 스타일과 방식으로 코드를 작성하게 될 것입니다. 따라서 유지 보수와 디버깅의 작업 강도가 낮아질 것이라 생각했습니다.



image.png

TCA로 일관된 코드 작성하기

1. Component 분리를 통한 코드 재활용성 높이기

개발을 하다 보면 썼던 Component를 재사용 하는 경우가 빈번합니다. 이는 단순히 누르는 버튼부터, 복잡한 기능을 수행하는 커스텀 버튼(유저가 직접 TextField를 입력하여 선택할 수 있는)까지 재사용되는 경우가 있습니다. 수수 프로젝트에서도 비슷한 경우가 있었습니다.

아래는 그 예입니다. 순서대로

  • 단일 버튼 선택, Custom TextField를 만들 수 있음.

  • 여러 개의 버튼을 선택할 수 있음

  • 중복 버튼 선택, Custom TextField를 만들 수 없음.

image.png

image.png

image.png

TCA는 이러한 반복되는 Component와 Generic을 활용하여 우아하게 처리할 수 있습니다. 보이는 비즈니스 로직의 요구사항은 추상화 하면 다음과 같습니다.

  • 공통사항

    • CustomTextField가 존재 가능

    • Item List는 Description을 통해서 사용자에게 정보를 표시

    • 선택된 버튼이 다시 선택될 경우 선택 해제

  • 단일 선택 타입

    • 버튼을 선택하지 않으면 다음 버튼이 비활성화

  • 복수 선택 타입

    • 버튼을 선택하지 않아도 다음 버튼 활성화

위와 같은 제약 사항들은 Reducer의 initial State을 통해서 맵핑할 수 있습니다. view를 그리기 위한 아이템들과, 선택했을 때 Parent Reducer State에게 Shared할 수 있도록 만듭니다.

public struct SSSelectableItemsReducer<Item: SSSelectableItemable> {

	// code ....
  public struct State {
  /// 생성자에 모든 변수들은 @Shared처리 되어서 부모에서 Shared로 선언되어야 합니다.
  /// - Parameters:
  ///   - items: defaults아이템들을 나타냅니다. 이는 SSSelectableItemable을 따라야 합니다.
  ///   - selectedID: 선택된 아이템들의 ID(Int)를 나타내는 Shared 변수 입니다.
  ///   - isCustomItem: 사용자 입력을 통한 새로운 아이템을 받을지 여부를 나타냅니다.
  ///   - multipleSelectionCount: 몇개의 아이템이 선택가능하게 만들어 줍니다.
  ///   - regexPatternString: Custom Item을 만들 시 RegexPattern을 입력받습니다.
  ///
  ///    isCustomItem을 Nil로 할 경우 "직접 입력" 버튼이 추가되지 않습니다.
	  public init(items: Shared<[Item]>, selectedID: Shared<[Int]>, isCustomItem: Shared<Item?>, multipleSelectionCount: Int = 1, regexPatternString: String? = nil) {
	    _items = items
	    _selectedID = selectedID
	    _isCustomItem = isCustomItem
	    self.multipleSelectionCount = multipleSelectionCount
	    self.regexPatternString = regexPatternString
	  }
	  // code ...
	}
	// code ....
}
public protocol SSSelectableItemable: Identifiable, Equatable {
  var title: String { get }
  var id: Int { get }

  /// 커스텀 아이템의 Title을 변경할 때 사용합니다.
  mutating func setTitle(_ val: String)
}


또한 View의 Action이 이뤄지는 행위 또한 간단하게 처리할 수 있습니다. 이미 선택된 버튼이라면 선택을 해제하고, MultipleSelection타입에 따라서 다양한 기능을 할 수 있게 합니다.

  case let .view(.tappedItem(id)):

    // 이미 선택된 버튼일 때
    if state.selectedID.contains(id) {
      state.selectedID = state.selectedID.filter { $0 != id }
      let curSelected = state.selectedID
      return .run { send in
        await send(.delegate(.selected(id: curSelected)))
      }
    }

    // 한개의 버튼을 선택하는 화면일 때
    else if state.multipleSelectionCount == 1 {
      state.selectedID = [id]
      let curSelection = state.selectedID
      return .send(.delegate(.selected(id: curSelection)))
    }

    // 여러개의 버튼을 선택할 수 있을 때
    return .send(.inner(.multipleSelection(id: id)))

  case let .inner(.multipleSelection(id)):
    if state.selectedID.count + 1 <= state.multipleSelectionCount {
      state.selectedID.append(id)
    }
    return .run { [ curSelection = state.selectedID] send in
      await send(.delegate(.selected(id: curSelection)))
    }

아래는 각각 초기값을 init할 때의 예시입니다. 각각 상황에 맞는 Custom View를 손쉽게 만들 수 있습니다.

// 한개의 버튼을 선택할 수 있을 때
createEnvelopeSelectionItems = .init(
  items: relationHelper.defaultRelations,
  selectedID: relationHelper.selectedID,
  isCustomItem: relationHelper.customRelation,
  multipleSelectionCount: 1,
  regexPatternString: RegexPatternString.relationship.regexString // "^[가-힣|a-z|A-Z| ]{1,10}$"
)
// 여러개의 버튼을 선택할 수 있을 때

 createEnvelopeSelectionItems = .init(
  items: additionalSectionHelper.defaultItems,
  selectedID: additionalSectionHelper.selectedID,
  isCustomItem: .init(nil),
  multipleSelectionCount: 20
)

2. 복잡한 흐름 제어하기

간혹 코드를 작성하다 어려운 문제를 맞닥뜨리는 경우가 종종 있습니다. 수수 팀의 경우 Button 형 TextField 서비스 로직을 만드는 데 곤욕을 겪었습니다. 수수 팀의 Button 형 TextField의 경우 다음과 같은 순서의 요구사항이 있었습니다.

image.png

  • Empty State

    • 사용자가 아무것도 누르지 않은 경우(TextField에 아무것도 쓰여지지 않은 경우)에 “직접입력” 표시

    • 만약 선택된 버튼이 있다면, 선택을 해제시켜야 함

  • Edit State

    • 현재 입력된 TextField를 지울 수 있는 Button 표시

    • 저장하여 SavedState로 바뀔 수 있게 함

    • 정규식을 통해 문자열 정책의 위배되는 경우 저장 버튼을 누르지 못하게 방지

  • Saved State

    • 편집 버튼을 통해서 Edit State으로 이동

    • “x” 버튼을 통해 EmptyState으로 이동

버튼에 다양한 상태들이 존재했고 버튼이 현재 사용자에게 보여주는 상태마다 처리해야 할 서비스 로직과 행동이 있었습니다. TCA에서는 이러한 다양한 상태와 분기를 손쉽게 처리할 수 있었습니다.

(1) EmptyState에서 버튼을 누를 경우의 Action들

EmptyState에서 버튼을 누를 경우 Custom Item들에 대해서 초기화에 관한 로직을 수행합니다.

화면 기록 2024-09-02 오후 11.09.38.gif

case .view(.tappedAddCustomTextField):
  return .send(.inner(.startAddCustomRelation))

case .inner(.startAddCustomRelation):
  state.selectedID = []
  state.isAddingNewItem = true
  state.customTitleText = ""
  state.customItemSaved = false
  return .none

(2) EditState에서 버튼을 누를 경우의 Action들

TextField가 바뀌었을 때 regex를 검사하고 이것이 맞는지 Regex에 부합하는지 아닌지 확인합니다. 또한 SaveButton을 누를 경우 Save State로 전환합니다.
화면 기록 2024-09-02 오후 11.13.15.gif

case let .view(.changeTextField(text)):
  state.customTitleText = text
  if let regexString = state.regexPatternString,
     let regexPattern = try? Regex(regexString) {
    return !text.contains(regexPattern) ?
      .send(.delegate(.invalidText(text))) : .none
  }
  return .none

        
case .view(.tappedTextFieldSaveAndEditButton):
  state.customItemSaved.tog      gle()
  state.isCustomItem?.setTitle(state.customTitleText)
  return .none

(3) Save State에서 서비스 로직

저장 상태에서 Edit상태로 바꿀 수 있고, 또한 저장 상태에서 Default State로 넘어갈 수 있습니다.

화면 기록 2024-09-02 오후 11.14.55.gif

 case .view(.tappedTextFieldSaveAndEditButton):
  state.customItemSaved.toggle()
  state.isCustomItem?.setTitle(state.customTitleText)
  return .none
 
case .view(.tappedTextFieldCloseButton):
	state.customTitleText = ""
	if state.customItemSaved {
		state.isAddingNewItem = false
	}
	return .none

3. 다른 컴포넌트와 조합하기 쉬움

다른 컴포넌트와 Compositional하게 배치할 수 있다는 장점입니다. 수수는 TextFeild에서 10글자를 넘게 되면, Toast를 전달합니다. Parent reducer가 상기의 SUSUSeletableView와 ToastView를 연결하는 것은 정말 간단합니다. 위의 SUSUSelectableReducer는 TextField가 정규식에 맞지 않은 경우 delegateAction으로 정규식이 부합하지 않는다는 Action을 보냅니다. 이를 그림으로 그려보면 다음과 같습니다.

image.png

Show toast 를 하기 위해서 scopee된 SelectableRedcuer의 delegateAction을 처리해주면 됩니다.

@Reducer
struct ParentReducer { 

var body: some ReducerOf<Self> { 
Reduce{ state, action in 
switch action { 
//...
  case let .createEnvelopeSelectionItems(.delegate(.invalidText(text))):
    return ToastRegexManager.isShowToastByName(text) ?
      .send(.scope(.toast(.showToastMessage("경조사 명은 10글자까지만 입력 가능해요")))) : .none
// ....
}

개발해 보고 나서 느끼는 TCA 총평

장점

  • 코드 재사용률 상승(컴포넌트를 잘 만들었을 경우)

  • 일관된 코드 작성(협업 시 가독성 증가)

  • 유지보수 용이

  • 유닛테스트 작성 용이

단점

  • 보일러 플레이트 코드 증가(Button이 누르는 동작 하나 만드는 것에 새로운 Action case 도입 등…)

  • 높은 러닝 커브 (버전이 업그레이드되면서 많은 것들이 바뀌어서 옛날 자료가 많음)

  • 정해진 형식을 “무조건” 따라야 함

  • 자주 바뀌는 버전(버전을 올릴 시 migration guide를 참조하여 해결해야 함)

제가 경험해 본 TCA의 장단점을 요약하자면 다음과 같았습니다. TCA가 모든 SwiftUI의 아키텍쳐의 필수 사항은 아니지만, 일관된 코드작성과 코드 재사용률 관점에서는 메리트가 뛰어나다고 생각합니다. 이를 통해 빠른 개발도 가능했던 것 같습니다.

iOS코드가 보고 싶다면?

SwiftUI + TCA를 활용하여 SUSU가 어떻게 만들어졌는지 상세하게 알고 싶다면, 아래 GitHub을 확인해주세요!

➡️ 수수(SUSU)가 궁금할 땐

앱스토어

구글 플레이스토어

인스타 (@team.oksusu)

➡️ 이전 메이커로그 보러가기

💌경조사비, 너로 정했다!💌

수수의 What을 찾아서💰

사이드 프로젝트에서 MVI 써본 썰

Android에서 iOS(같은) DatePicker 만들어버리기

돈이 없다.. 그래서 우리는 Reactive를 선택했다. (극한의 가성비)

수수 SUSU

경조사비 관리 기록 장부 서비스

11

댓글

로그인 후 댓글을 남길 수 있습니다.

극락코딩
극락코딩

오홍!!!

양수진
양수진

크으~ 역시 수수 ios의 아버지 👍 안드 개발자인 저도 얼핏 TCA를 들어봤는데, 사프에 멋지게 적용하다니 대단해요~!

서혜원
서혜원

흥미롭고 유익한 글이군요! 🌽🧙

오상구
오상구

글 잘 봤습니다. :) 하나 궁금한게 리듀서 부분이 복잡할 필요는 없어 보이는데, where 문법으로 분기를 나누지 않으신 이유가 있으실까요?

씨마스터300
씨마스터300

안녕하세요! 말씀하신 "where로 분기"가 어떤것인지 감이 안잡혀서요. 혹시 보충 설명을 요청드려도 될까요? 인사이트 얻어가고 싶습니다!!🙇

오상구
오상구

코딩스타일인 것 같은데, 리듀서가 switch문 기반이다보니 where로 분기하는 것을 저는 선호해서요! 이런 느낌입니다. ``` // 이미 선택된 버튼일 때 case let .view(.tappedItem(id)) where state.selectedID.contains(id): &nbsp; state.selectedID = state.selectedID.filter { $0 != id } &nbsp; let curSelected = state.selectedID &nbsp; return .run { send in &nbsp; &nbsp; await send(.delegate(.selected(id: curSelected))) &nbsp; } // 한개의 버튼을 선택하는 화면일 때 case let .view(.tappedItem(id)) where state.multipleSelectionCount == 1: &nbsp; state.selectedID = [id] &nbsp; let curSelection = state.selectedID &nbsp; return .send(.delegate(.selected(id: curSelection))) // 여러개의 버튼을 선택할 수 있을 때 case let .view(.tappedItem(id)): &nbsp; return .send(.inner(.multipleSelection(id: id))) ```

씨마스터300
씨마스터300

오! 생각지도 못한 부분을 일깨워 주셔서 감사합니다! switch 에 if 하면 depth가 깊어지는데 말씀하신 방법으로 하면 if depth가 사라져서 깔끔해 지네요. 인사이트 감사합니다! 😊

오상구
오상구

caseby로 브레이크포인트 잡는 것을 선호해서 같은 느낌으로 Result<T, Error>도 사용하는 것을 선호해요! case .runResult(.success(let success)): state.error = nil return .none case .runResult(.failure(let failure)): state.error = failure return .none 저야말로 이런 글 너무 좋아요 SwiftUI❤️‍🔥

David Bang
David Bang

swift ui joayo

Doeon Kwon 권도언
Doeon Kwon 권도언

씨마스터300님 메이커로그가 이번주 뉴스레터에 소개되었어요! https://dis.qa/zK0mWO