본문 링크 (Original Link)

복잡한 테이블 뷰를 다루는 동시에 멘탈을 지키는 방법

2017.08.30

#

by Marin Benčević, translated by pilgwon

테이블뷰는 iOS 개발에 있어서 가장 중요한 레이아웃 컴포넌트 중 하나입니다. 보편적으로도 우리의 중요한 스크린들은 테이블뷰 입니다: 피드, 설정, 아이템 리스트 등.

복잡한 테이블뷰를 작업해본 모든 iOS 개발자들은 이 작업을 더 끝내주고 더 빠르게 할 수 있다고 알고 있습니다. 거대한 뷰 컨트롤러와 방대한 UITableViewDataSource 메소드 그리고 톤 단위의 if문과 switch문. 그 배열 색인 수학에 가끔씩 (아주 재밌는) 경계를 벗어나는(out-of-bounds) 오류를 추가한다면 당신은 스스로 좌절감을 느끼게 됩니다.

저는 제가 이 문제들을 극복하는데에 도움이 되는 쓰는 사람으로 하여금 행복하게 만들어주는 몇가지 원칙에 도달했습니다. 이 팁들의 좋은 점은 복잡한 테이블뷰에 쓰일뿐만 아니라, 당신의 모든 테이블뷰에 적용해도 좋을 만한 충고들이라는 점입니다.

이제 복잡한 UITableView의 예제를 보시죠.

image1 이 대단한 일러스트는 LazyAmphy가 그린 것입니다.

이것은 포켓몬을 위한 소셜 네트워크인 PokeBall 입니다. 다른 소셜 네트워크들과 같이, 이것은 유저와 관련된 서로 다른 이벤트들을 보여줄 필요가 있습니다. 이 이벤트들은 새로운 사진과 상태 메세지들을 포함하고 있고 일별로 묶여있습니다. 여기서 우리는 고민해야 하는 두 가지가 있습니다: 테이블뷰가 다른 상태값을 가지고 있는 것과 여러가지의 셀과 섹션을 가지고 있다는 것입니다.

1. 셀이 작동하게 만들기

저는 많은 개발자들이 셀 설정을 cellForRowAt: 메소드에 넣는 것을 봤습니다. 잘 생각해보면, 저 메소드의 목적은 셀을 만드는 것입니다. 그리고 UITableViewDataSource의 목적은 데이터를 제공하기 위함입니다. Data Source는 버튼의 폰트를 변경하기 위한 의도로 만들어지지 않았습니다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell

  let status = statuses[indexPath.row]
  cell.statusLabel.text = status.text
  cell.usernameLabel.text = status.user.name

  cell.statusLabel.font = .boldSystemFont(ofSize: 16)
  return cell
}

당신은 실제 셀의 설정과 스타일을 지정하는 데 필요한 코드를 입력해야합니다. 만약 어떤 것은 셀의 라이프사이클 동안 계속 지속될 거라면 (마치 레이블의 폰트), awakeFromNib 메소드에 입력하세요.

class StatusTableViewCell: UITableViewCell {

  @IBOutlet weak var statusLabel: UILabel!
  @IBOutlet weak var usernameLabel: UILabel!

  override func awakeFromNib() {
    super.awakeFromNib()

    statusLabel.font = .boldSystemFont(ofSize: 16)
  }
}

다른 방법으로는 프로퍼티 옵저버를 사용하여 셀의 데이터를 입력할 수 있습니다.

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name
  }
}

이런식으로 바꾼다면 당신의 cellForRow 메소드는 깔끔해지고, 읽기 편해지고, 간결해질 것입니다.

func tableView(_ tableView: UITableView,
  cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.row]
  return cell
}

또, 셀 특유의 로직이 셀과 뷰 컨트롤러 이곳저곳으로 흩어지는게 아니라 한 곳으로 모일 것입니다.

2. 모델이 작동하게 만들기

보통, 당신은 어떠한 백엔드 서비스에서 받아온 모델 객체의 배열로 테이블뷰를 채울 것입니다. 그렇게 되면 셀은 그 모델을 기반으로 자체적으로 변경해야 합니다.

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name

    if status.comments.isEmpty {
      commentIconImageView.image = UIImage(named: "no-comment")
    } else {
      commentIconImageView.image = UIImage(named: "comment-icon")
    }

    if status.isFavorite {
      favoriteButton.setTitle("Unfavorite", for: .normal)
    } else {
      favoriteButton.setTitle("Favorite", for: .normal)
    }
  }
}

당신은 당신의 모델 객체로 초기화를 했고 셀의 타이틀, 이미지 그리고 다른 속성들을 설정해 줄 셀 특유의 모델을 만들 수 있습니다.

class StatusCellModel {

  let commentIcon: UIImage
  let favoriteButtonTitle: String
  let statusText: String
  let usernameText: String

  init(_ status: Status) {
    statusText = status.text
    usernameText = status.user.name

    if status.comments.isEmpty {
      commentIcon = UIImage(named: "no-comments-icon")!
    } else {
      commentIcon = UIImage(named: "comments-icon")!
    }

    favoriteButtonTitle = status.isFavorite ? "Unfavorite" : "Favorite"
  }
}

이제 당신은 많은 양의 셀을 표현하는 로직을 모델 자체로 옮길 수 있습니다. 그렇게 되면 당신은 복잡하고 짜증나게 하는 셀을 유닛 테스트 하지 않아도 모델을 독립적으로 인스턴스화 시키고 유닛 테스트를 처리할 수 있게 됩니다. 이것은 또한 당신의 셀들이 엄청나게 간단해지고 읽기 쉬워진다는 것을 의미합니다.

var model: StatusCellModel! {
  didSet {
    statusLabel.text = model.statusText
    usernameLabel.text = model.usernameText
    commentIconImageView.image = model.commentIcon
    favoriteButton.setTitle(model.favoriteButtonTitle, for: .normal)
  }
}

이것은 MVVM과 비슷한 패턴이지만, 하나의 테이블뷰 셀에 적용됩니다.

3. 행렬로 보기 (하지만 이쁘게)

image2 테이블뷰를 만들고 있는 보통의 iOS 개발자 입니다.

섹션으로 나눠진 테이블뷰는 보통 엄청난 양을 자랑합니다. 이런것 본 적 있으세요?

func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  switch section {
  case 0: return "Today"
  case 1: return "Yesterday"
  default: return nil
  }
}

이것은 아주 많은 코드이면서 아주 많은 이쁘고 간결하면서 변경하기 쉽고 서로 교체하기도 쉬워야 하는 하드 코딩된 값들입니다. 이 문제엔 아주 쉬운 해결책이 있습니다. 바로 행렬이죠.

행렬을 기억하세요? 이것은 머신 러닝을 하는 사람들이나 컴퓨터 과학 1년차 학생들이 사용하는 것이지만, 앱 개발자들은 보통 쓰지 않는 것입니다. 섹션으로 나눠진 테이블뷰를 생각해보면, 실제로 일어나는 일은 섹션의 리스트를 그려주는 일입니다. 각 섹션은 셀의 리스트입니다. 이것은 배열의 배열이나 행렬같이 들립니다.

image3

이것은 섹션으로 나눠진 테이블뷰를 어떻게 모델링 할 것인지를 나타냅니다. 1차원 배열을 쓰는 대신, 2차원 배열을 쓰세요. 이것이 UITableViewDataSource 메소드가 어떻게 구조되어 있는지를 나타냅니다: 당신은 테이블뷰의 n번째 셀을 반환하라고 요청받는게 아니라, m번째 섹션의 n번째 셀을 반환하라고 요청받습니다.

var cells: [[Status]] = [[]]

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.section][indexPath.row]
  return cell
}

그러면 우리는 섹션 컨테이너 타입을 정해서 이 컨셉을 좀 더 확장할 수 있습니다. 이 타입은 특정 섹션에 대한 셀을 갖고 있을 뿐 아니라, 섹션의 타이틀까지도 갖고 있습니다.

struct Section {
  let title: String
  let cells: [Status]
}
var sections: [Section] = []

이제 우리는 하드코딩된 값을 쓰는 대신에, 섹션의 배열을 만들고 그것들의 타이틀을 직접 반환할 수 있습니다.

func tableView(_ tableView: UITableView,
  titleForHeaderInSection section: Int) -> String? {
  return sections[section].title
}

이 방식으로 하면, 우리의 Data Source 메소드에 코드는 적어질 것이고 그 결과 경계를 벗어나는(out-of-bounds) 오류가 일어날 확률이 적어질 것입니다. 그리고 코드 또한 더 효과적이고 읽기 쉬워질 것입니다.

4. Enum은 우리의 친구입니다.

여러가지 타입의 셀을 다루는 것은 매우 까다로운 작업일 수 있습니다. 여러가지 종류의 셀을 보여줘야 하는 사진이나 상태들을 나타내는 것과 같은 피드를 생각해보세요. 당신의 멘탈을 지키면서 기묘한 배열 인덱스 계산을 피하기 위해, 당신은 이들 둘 다 같은 배열에 저장해두어야 합니다.

하지만, 배열은 같은 종류만을 담을 수 있고, 그 말 뜻은 서로 다른 타입으로 된 배열을 만들 수 없다는 것입니다. 가장 먼저 생각나는 해결책은 프로토콜일 것입니다. 심지어, Swift는 프로토콜 지향 언어입니다!

당신은 FeedItem 이라는 프로토콜을 선언할 수 있고, 우리의 셀 모델들이 이 프로토콜을 기반으로 만들 수 있습니다.

protocol FeedItem {}
struct Status: FeedItem { ... }
struct Photo: FeedItem { ... }

당신은 FeedItem들의 배열을 선언할 수 있습니다.

var cells: [FeedItem] = []

하지만, 이 해결책으로 cellForRowAt: 메소드를 구현하면, 우리는 작은 문제를 만날 수 있습니다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  if let model = cellModel as? Status {
    let cell = ...
    return cell
  } else if let model = cellModel as? Photo {
    let cell = ...
    return cell
  } else {
    fatalError()
  }
}

모델을 프로토콜로 업캐스팅하는 과정에서, 당신은 실제로 필요한 정보들을 많이 잃었습니다. 당신은 셀들을 추상화시켰습니다. 하지만 실제로 필요한 것은 구체적인 인스턴스입니다. 따라서 타입으로 변환할 수 있는지 여부를 확인한 다음 이를 기반으로 셀을 표시해야 합니다.

이것은 작동할 것이지만, 이쁘게는 아닐 것입니다. 다운캐스팅은 본질적으로 안전하지 못하고 옵셔널로 이끕니다. 또한 셀 수 없을 정도로 많은 타입이 당신의 프로토콜을 구현할 수 있기 때문에 당신은 모든 케이스를 다 처리했는지를 모를 것입니다. 그것이 당신이 예상하지 못 한 타입이 나왔을 때 fatalError가 나오는 이유입니다.

당신이 프로토콜의 인스턴스를 구체적인 타입으로 캐스팅하려고 하면, 보통 좋은 코드가 아닙니다. 프로토콜은 구체적인 정보가 필요하지 않지만, 대신 원본 데이터의 하위 세트로 작업할 수 있습니다.

좋은 접근법은 Enum이 될 것입니다. 그 방법은 당신이 켤 수 있고, 모든 경우를 관리하지 않으면 컴파일도 되지 않습니다.

enum FeedItem {
  case status(Status)
  case photo(Photo)
}

Enum은 또한 관련된 값들이 있고, 실제 enum 값에 당신이 필요한 값만 입력할 수 있습니다.

당신의 배열의 정의는 그대로 있겠지만, 당신의 cellForRowAt: 메소드는 더욱 깔끔해 보일 것입니다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  switch cellModel {
  case .status(let status):
    let cell = ...
    return cell
  case .photo(let photo):
    let cell = ...
    return cell
  }
}

이 방법을 사용하면, 당신은 캐스팅도 옵셔널, 그리고 챙기지 못 한 케이스도 없을 것이고 그래서 버그도 없을 것입니다.

5. 상태를 명확하게 만듭시다.

image4 이 대단한 일러스트는 LazyAmphy가 그린 것입니다.

빈 화면을 보는 것은 혼란스럽기 때문에, 우리는 보통 테이블뷰가 비었을 때 몇몇의 메세지를 띄웁니다. 우리는 또한 데이터가 로딩될 때 인디케이터를 띄웁니다. 하지만, 상황이 어긋나면, 유저에게 무슨 일이 생겼는지 알려주고 그들이 문제를 어떻게 해결해야 하는지 알려주는것이 좋습니다.

우리의 테이블뷰도 이 상태들, 그리고 또 다른 상태들도 가지고 있습니다. 그것들을 관리하는 것은 괴로운 일입니다.

당신에게 두 가지 가능한 상태가 있다고 쳐봅시다: 데이터가 있는 상태와 데이터가 없는 상태입니다. 네이티브 개발자들은 “데이터가 없는” 상태를 나타낼 때 테이블뷰를 숨기고 데이터가 없을 때 보여주는 화면을 보여줍니다.

noDataView.isHidden = false
tableView.isHidden = true

이 경우 상태를 변경하는 것은 두 개의 불리언 속성을 변경해야 한다는 의미입니다. 뷰 컨트롤러의 또 다른 부분에서는, 당신은 상태를 다른 것으로 설정하고 두 속성을 설정해야 한다는 것을 기억해야 합니다.

실제로는, 이 두 불리언 속성들은 언제나 연결되어 있습니다. 당신은 데이터가 없을 때 보여주는 뷰와 테이블뷰에 데이터를 동시에 보여줄 수는 없습니다.

실제 상태 수와 앱에서 가능한 상태 수의 차이를 생각하는 것은 유용합니다. 두 불리언 값들은 4가지 가능한 조합이 있습니다. 이것은 당신이 사고로 접근할 수 있는 2가지 불가능한 조합이 있다는 것이고, 당신은 이 부분을 관리해야 합니다.

당신은 스크린에 생길 수 있는 모든 가능한 상태를 State Enum으로 선언해두면 위와 같이 불가능한 조합을 만날 수 없을 것입니다.

enum State {
  case noData
  case loaded
}
var state: State = .noData

당신은 또한 화면의 상태를 바꿀 수 있는 유일한 방법이 될 단일 state 속성을 선언할 수 있습니다. 이 속성의 값이 바뀔때마다, 당신은 그 상태에 맞게 스크린을 업데이트 해야 할 것입니다.

var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
    case .loaded:
      noDataView.isHidden = false
      tableView.isHidden = true
    }
  }
}

만약 당신이 상태를 이 state 속성으로만 변경한다면, 당신은 절대 속성을 업데이트하는 것을 까먹지 않는다고 확신할 수 있고, 불가능한 상태가 될 일도 없을 것입니다. 상태를 바꾸는 일은 이제 아주 쉬워졌습니다.

self.state = .noData

가능한 상태가 더 많아질수록, 이 패턴은 더욱 더 유용해질 것입니다.

당신은 심지어 에러메세지와 아이템들의 상관관계를 이용하여 아래와 같이 기능을 향상시킬 수 있습니다.

enum State {
  case noData
  case loaded([Cell])
  case error(String)
}
var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
      errorView.isHidden = true
    case .loaded(let cells):
      self.cells = cells
      noDataView.isHidden = false
      tableView.isHidden = true
      errorView.isHidden = true
    case .error(let error):
      errorView.errorLabel.text = error
      errorView.isHidden = false
      tableView.isHidden = true
      noDataView.isHidden = true
    }
  }
}

당신이 단일 데이터 구조를 만드는 방법은 우리의 테이블 뷰 컨트롤러의 완벽한 간판이라고 할 수 있습니다. 이것은 테스트하기 쉽고 (왜냐하면 Swift의 값만 사용하는 순수함이 있기 때문입니다.), 우리의 테이블뷰에 대한 단일 갱신점(single point of update)단일 진실의 기원(single source of truth) 을 제공합니다. 새로운 쉬운 디버깅의 세계에 오신 것을 환영합니다!

빠른 팁

여기 완전히 그들의 영역을 보증하는것은 아니지만 아주 유용한 몇 가지 마이너한 팁을 소개합니다:

Be reactive!

테이블뷰가 항상 원본 배열의 현재 상태를 표현하는지 확인하세요. 테이블뷰를 리프레시하려면 프로퍼티 옵저버를 쓰시고, 수동으로 동기화 하지 마세요.

var cells: [Cell] = [] {
  didSet {
    tableView.reloadData()
  }
}

Delegate != View Controller

누구든지, 어떤것이든지 프로토콜을 구현할 수 있습니다! 다음에 복잡한 테이블뷰 데이터 소스나 델리게이트를 작성할 때 꼭 기억하세요. 테이블뷰ㅜ의 데이터 소스가 유일한 목적인 타입을 정의하는 것은 완벽하고 유효합니다. 이 원칙들은 당신의 뷰 컨트롤러들을 깔끔하게 만들어주고, 로직과 권한을 각각의 대상이 가져가게 만듭니다.

절대로 지수를 가정하지 마세요!

당신이 만약 특정 인덱스에 대한 IndexPath를 섹션을 바꿔가며 세거나 다른 마법을 부려서 세는 등의 방법으로 직접 확인하고 있는 것을 발견한다면, 당신은 아마 잘못된 일을 하고 있는 것일 수 있습니다. 만약 특정한 장소에 특정한 셀을 가지고 있다면, 그것을 당신의 원본 배열에 나타내세요. 셀들을 코드에 숨기지 마세요.

Demeter의 법칙을 기억하세요

요약하자면, Demeter의 법칙 (또는 최소한의 지식의 원칙)은 친구는 오로지 그들의 친구들이랑만 얘기를 나눌 수 있고, 친구의 친구에게는 얘기를 못 나누게 한다는 말입니다. 잠깐만 뭐라구요?

다르게 말하자면, 이것은 하나의 객체는 그것의 속성에만 접근할 수 있다는 뜻입니다. 속성들의 속성은 혼자 남겨져야 합니다. 그래서 UITableViewDataSource는 셀의 레이블의 속성인 text를 바꾸면 안됩니다. 만약 당신이 한 표현에서 두 점 이상을 보았다면 (예. cell.label.text =), 그것은 당신이 너무 많이 알고 있다는 것을 의미합니다.

만약 당신이 Demeter의 법칙을 따르지 않는다면, 셀을 바꾸는 것이 곧 원본 데이터를 바꾸는 것이라는 의미가 됩니다. 셀을 데이터 소스에서 떼어내는 것은 다른 하나에 영향을 주지 않고 나머지 하나를 바꿀 수 있게 허용합니다.

잘못된 추상화를 조심하세요

가끔씩은 비슷한 여러가지의 UITableViewCell 클래스를 가지는 것이 한 뭉치의 if 문이 있는 단일 클래스를 가지는 것보다 나을 때가 있습니다. 당신은 그들이 미래에 얼마나 갈라질 것인지 절대 알 수 없지만, 그들을 추상화 하는 것은 함정이 될 수 있습니다. YAGNI (당신은 그것이 필요하지 않을 것이다, You Aren’t Gonna Need It)은 따르기 좋은 원칙이지만, 가끔씩은 YJMNI (당신은 그것이 필요할 수도 있다, You Just Might Need It)가 따르기 좋은 원칙일 수 도 있습니다.

. . .

저는 이 팁들이 당신의 고운 머리카락들을 지키는데에 도움이 되었으면 좋겠고, 당신이 테이블뷰를 만들 기회가 분명히 있을 거라 확신합니다. 아래는 당신에게 도움이 될 더 읽을 거리입니다:

질문이나 댓글이 있으시면 마음편하게 아래에 남겨주세요.