google/promises를 활용한 스위프트 비동기 프로그래밍과 에러 핸들링

Background

올해 1~2월 즈음 우연히 google/promises를 알게 되었는데 소개 글과 샘플 코드 몇 줄을 보고 나서 ‘이건 꼭 써봐야겠다’는 생각이 들었다. 뭘 하는 프레임워크인지는 깃헙에 잘 소개되어 있어 상세한 설명은 생략하지만 한 줄 요약을 하자면 비동기 작업에 대한 결과를 completion handler로 처리하는 iOS의 특성에 기인하는 nested closures 문제를 해소할 수 있다. 여기에 덤으로 에러 처리까지 깔끔해진다.

Promises를 보자마자 써봐야겠다고 느꼈던 이유는 그 전부터 내 코드에서 시도하고자 했던 것들이 있었는데 이 라이브러리가 그걸 달성할 수 있게 해줄것 같았기 때문이다.

  • 사이드 이펙트 없는 함수들의 체이닝
  • 유저에게 의미있는 에러 핸들링
  • 최소한의 튜닝(디펜던시)

이런 작은 목표들은 작업의 흐름을 읽기 쉬운 코드를 짜고 유저에게 더 나은 경험을 주고자 하는 최종적인 목표에 도움이 될 것이라 생각했다.

Chaining functions w/o side effects

2016년 D2 iOS 오픈세미나에서 발표했던 걸 계기로 파파고와 웨일 브라우저를 개발한 팀의 리더님을 알게되어 그 팀에서 단기로 프로젝트를 했던 적이 있다. 멘토님에게서 배운 여러가지 중 가장 기억에 남고 지금도 코딩하면서 매일 실천하기 위해 노력하는 것은 함수는 10줄을 넘기지 않는다이다. 자극적으로 들릴 수도 있지만 본질적으로 함수는 하나의 작업만 하도록 짜라는 강령으로 이해했다. 10줄 이내로 짜려는 노력을 하다보면 계속해서 함수는 작은 기능 단위로 쪼개지고 나는 객체 간의 관계를 설계하는데 고민하는 시간이 늘게 된다. 더 나아가 side effect가 없는 함수들을 체이닝 하면 코드가 쉽게 읽히고 수정이 용이해진다는 장점이 있다. 특히 스위프트의 map, flatMap, compactMap, filter 등의 메서드와 함께 쓸 때 빛을 발한다. Promises에서는 then, always, validate 등으로 함수를 체이닝할 수 있다.

예시:

extension CNContact {
  var mainPhoneNumber: String? {
    return phoneNumbers.map { $0.value.stringValue }
                        .filter(prefixValidater)
                        .map(replaceKoreanCountryCode)
                        .map(removeNonNumerics)
                        .filter { $0.isPhoneNumber && $0.count == 11}
                        .first
  }

  private func prefixValidater(_ target: String) -> Bool { ... }
  private func replaceKoreanCountryCode(_ digits: String) -> String { ... }
  private func removeNonNumerics(_ digits: String) -> String { ... }
}

Conveying meaningful error messages to users

모바일 앱 개발을 몇 년 하다보니 어떻게 에러 핸들링을 잘 할 수 있을까 하는 갈증이 생겼다. 모바일 앱에서 가장 많이 일어나는 작업의 단계는 유저 인터랙션 👉 요청을 처리하기 위한 일련의 작업 👉 화면에 결과 보여주기 인데 일련의 작업을 처리하다보면 다양한 에러가 발생할 수 있다. 데이터가 변질됐거나, 네트워크가 불안정하거나, 서버가 다운됐거나, 권한이 없다거나 하는 등. 이때 단순히 “요청이 실패했습니다”라는 의미없는 메시지보다는 에러의 원인을 유저에게 알리는 것이 사용자 경험 측면에서 월등히 좋다. NSError의 localizedDescription을 활용하거나 스위프트에서는 Error 혹은 LocalizedError 프로토콜을 사용할 수 있다. Promises에서는 여러 연속된 비동기 작업 도중 발생한 에러를 최종 단계에서 통일성 있게 전달 받을 수 있고, 심지어 recover로 복구할 기회도 있다.

이런 에러 메시지를 보여줄 것인가?

아니면 실질적으로 유저에게 도움이 되는 메시지를 보여줄 것인가?

Minimizing dependencies

스탠다드 라이브러리를 감싼 무슨무슨 ~Kit을 쓰는 것에 대한 미묘한 거부감이 있다. 아이폰도 케이스 없이 보호 필름만 붙이고 다니는 성격이라 그런지 프로젝트 한 두군데에서만 쓰기 위해, 혹은 필요한 1만큼의 기능을 가져다 쓰기 위해 10 크기의 라이브러리를 코코아팟에 이것저것 추가하는 것이 무척 꺼려진다. 오토레이아웃도 별도 라이브러리 없이 쓰고 있고 한때 관심을 가졌던 Rx도 공부하다가 결국 과도한 튜닝을 하는 것 같아 도외시했다. 튜닝의 끝은 순정이라고, 순정을 선호하는 입장에서 Promises는 다른 유사 라이브러리보다 GCD를 더 가볍게 감쌌기 때문에 성능에서 앞서고 학습 비용도 적은 것 같다.

Adopting Promises to Your Project

Promises를 도입하겠다고 해서 당장 프로젝트 전체를 뜯어고치지 않아도 되기 때문에 실무에서 차근차근 적용해보기에도 좋다. 보통 작업의 특성별로 담당 클래스가 있을텐데(e.g. 로그인매니저, 이미지다운로더 클래스 등과 같은) 이런 클래스 한 두개만 우선적으로 적용하며 맛보기를 해봐도 충분하다. 또한 wrap을 쓰면 기존에 있던 코드를 손댈 필요도 없이 코드 몇 줄로 Promises식 비동기 함수를 만들 수도 있다.

기존에 completion handler를 파라미터로 받던 함수:

func data(from url: URL, completion: @escaping (Data?, Error?) -> Void)

Promise 객체를 리턴하도록 수정한 비동기 함수:

func data(from url: URL) -> Promise<Data>

그러면 함수 내부에서 Promise 객체를 생성하여 리턴해준 후, fulfill된/될 결과값을 가지고 추가적인 작업을 해주면 된다.

let url = ...
data(from: url).then { data in
  //data로 추가 작업
}.catch { error in
  //error 처리
}

더 다채로운 활용법은 문서에 간단명료하게 잘 설명되어 있다!

Use Cases

그동안 사용해보면서 시행착오를 거쳐 습득한 몇 가지 유즈케이스 및 주의사항을 정리했다.

Partial Application 기법

Promises 파이프라인의 가독성과 함수 재활용성을 높이기 위해 partial application 기법을 활용하는 방법을 소개한다. map, forEach 등의 higher order function들을 사용하고 있었다면 아마 써본적이 있을 확률이 높다.

API 서버에 로그인하여 access token을 받아오는 작업은 로그인 기반의 서비스에서 빠질 수 없는 작업이다. 가상의 로그인 단계는 다음과 같다. 회원가입 👉 로그인 👉 엑세스 토큰 획득. 하지만 이미 회원가입이 되어 있는 유저라면 회원가입에 실패하게 된다. Promises에서는 실패했을때 recover를 사용해서 실패를 복구할 기회가 있다. 그래서 만약 회원가입 실패의 원인이 duplicate user라면 로그인을 시도한다.

Promises 코드:

typealias MyAccessToken = String

func retrieveAccessToken(with naverToken: String) -> Promise<MyAccessToken> {
  return requestSignUp(with: naverToken)
         .then(signIn(with: naverToken))
         .recover(onError(with: naverToken))
}

//Async Server API calls
func requestSignUp(with naverToken: String) -> Promise<SignUpResponse> { ... }
func requestSignIn(with naverToken: String) -> Promise<MyAccessToken> { ... }

//partially applied functions
func signIn(with naverToken: String) -> (SignUpResponse) -> Promise<MyAccessToken> {
  return { _ in requestSignIn(with: naverToken) }
}

func onError(with naverToken:String) -> (Error) -> Promise<MyAccessToken> {
  return { error in
    switch error {
    case SignUpError.duplicateUser:
      return requestSignIn(with: naverToken)
    default:
      return Promise(error)
    }
  }
}

signIn(with:)onError(with:)는 각각 SignUpResponse와 Error 파라미터를 나중에 전달 받도록 짠 partially applied functions이다. 이런 식으로 체이닝을 할 때 클로져를 바로 쓰지 않고 partial application을 활용하여 기존의 API 관련 함수들을 재활용함과 동시에 비동기 작업 파이프라인을 훨씬 읽기 쉽게 만들었다.

단순 체이닝으로 불가능한 작업은 await으로

await은 여러 비동기 작업으로부터 얻은 결과들을 혼합해서 사용해야 할때 유용하다.

예를 들어, 썸네일 이미지에 대한 url을 가지고 UIImage와 해당 이미지의 대표 UIColor 추출해 화면에 그려야 하는 작업이 있다고 가정해본다. 정리하면 URL을 UIImage로 변환 👉 UIImage에서 UIColor 추출 👉 UIImage, UIColor를 가지고 화면에 썸네일 생성 해야했는데 이 작업은 then 체이닝만으로는 구현하기 어려웠고 이를 await으로 해결했다. 또한 이 방식은 비동기 작업을 동기적인 코드처럼 쓰고 싶을 때 사용해도 된다.

Promises 코드:

typealias ThumbnailData = (image: UIImage, color: UIColor)

func thumbnailData(from url: URL) -> Promise<ThumbnailData> {
  return Promise<ThumbnailData>(on: queue) { //queue는 백그라운드 DispatchQueue
    let image = try await(image(from: url))
    let color = try await(dominantColor(from: image))

    return (image: image, color: color)
  }
}

//Async functions
func image(from url: URL) -> Promise<UIImage> { ... }
func dominantColor(from image: UIImage) -> Promise<UIColor> { ... }

활용한 부분:

let cell: MyTableViewCell = ...
let myDatum = data[indexPath.row]
let url: URL = myDatum.imageURL

thumbnailData(from: url).then { result in
  cell.imageView.image = result.image
  cell.dominantColorView.backgroundColor = result.color
}

추가적으로 Promises 사용 전 꼭 알아두면 좋은 내용으로는,

정도가 있다.

Wrap Up

iOS 개발을 하면서 한번이라도 completion handler 방식의 비동기 프로그래밍에 아쉬움을 느껴봤거나 Rx는 나의 필요 이상으로 너무 방대하다는 생각이 든 적이 있다면 한번 시도해보길 추천한다. google/promises는 최소한의 코드 변형으로 비동기 코드를 유연하게 하고, 가독성을 높이고, 적은 학습 비용으로 여러가지 시도해볼 수 있는 확장성 있는 좋은 프레임워크인 것 같다.

Tags: swift, promises, async programming, error handling  

Bool 변수 이름 제대로 짓기 위한 최소한의 영어 문법

Background

프로그래머의 가장 어려운 업무가 이름 짓기라는 설문 결과도 있듯이 변수에 적절한 이름을 지어주는 것은 어렵고 오래걸리는 일이다.

영어가 모국어가 아닌 사람들에게는 더 어려울 수 밖에 없는데 특히 Bool 변수명을 올바르게 지으려면 몇가지 영문법을 숙지해야한다. Bool 변수명은 사소한 차이로도 의미가 많이 바뀌어 코드를 읽는 사람을 더 헷갈리게 할 수도 있기 때문에 조금이라도 더 명확하고 문법적으로 맞는 Bool 변수명을 짓는 것이 중요하다는 생각이다.

Cases

Cocoa Touch의 여러 클래스들을 훑어보면서 Bool 변수 작명을 위해 알아야하는 영문법을 네 가지 케이스들로 정리해봤다.

  • is 용법
  • 조동사 용법
  • has 용법
  • 동사원형 용법

is 용법

is로 시작하는 변수명이 가장 흔한 케이스 아닌가 싶다. 뒤에 나오는 단어의 특징에 따라 세 가지로 나눌 수 있다.

  • is + 명사
  • is + 현재진행형(~ing)
  • is + 형용사

is + 명사

“(무엇)인가?” 라는 뜻으로 쓰인다.

func isDescendant(of view: UIView) -> Bool //UIView: "view의 자식인가?" 

is + 현재진행형(~ing)

“~하는 중인가?” 라는 뜻이 필요할 때 쓰면 된다.

var isExecuting: Bool { get } //Operation: "오퍼레이션의 작업이 현재 실행 중인가?"
var isPending: Bool { get } //MSMessage: "메시지가 보내지기 전 대기 중인가?"

is + 형용사

이제부터 살짝 헷갈릴 수 있다.

형용사도 두 종류로 나뉜다.

  • 단어 자체가 형용사인 것 - opaque, readable, visible 등
  • 과거분사 형태 - hidden, selected, highlighted, completed 등

과거분사(past participle)는 간단히 말해 동사로 만든 형용사라고 생각하면 된다. 동사를 과거분사로 바꾸면 뜻이 여러가지로 바뀔 수 있는데, 일단 여기서는 수동태라고 생각하면 되겠다. hide(숨다) - hidden(숨겨진), select(선택하다) - selected(선택된), complete(완료하다) - completed(완료된) 등등. (보다시피 동사 뒤에 -ed가 붙는 형태가 가장 흔하지만, 내가 쓰려는 동사의 과거분사를 모르겠으면 사전을 찾아보자.)

UIKit을 쓰다보면 정말 많이 보는 UIView의 프로퍼티들이 이런 경우다.

var isOpaque: Bool { get set }
var isSelected: Bool { get set }
var isHighlighted: Bool { get set }
var isHidden: Bool { get set } 

❗주의❗is로 시작하는 변수명을 짓다가 범하는 흔한 실수가 바로 is + 동사원형 을 쓰는 것이다.

isAuthorize, isHide, isFind 등등.

가령,

var isEdit: Bool //gg

edit이라는 단어가 명사로 쓰일수도 있어서 해석의 여지는 있지만 뜻이 명확하지 않아 일반적으로는 곧바로 해석하기 쉽지 않다. 😧 더 잘 할 수 있다.

아래와 같이 적절하게 바꿔주면 해석이 더 쉽고 빠르다.

var isEditable: Bool //편집할 수 있는가?
var isEditing: Bool //편집 중인가?
var canEdit: Bool //편집할 수 있는가? -> 다음 '조동사 용법' 섹션 참고

4월 11일 추가

닷넷 프레임워크에 DataRowView.IsEdit이라는 불리언 변수가 있다는 제보를 받았다. 문서를 보면 ‘row가 edit mode인지’를 나타내는 불리언인데 닷넷 개발자가 아닌 다른 개발자가 edit mode 라는 것을 모르는 상태에서 문서를 읽어보지 않고는 한번에 이해할 수 없었을 것이다. 하지만 만약 IsEdit이 닷넷 프레임워크에서 자주 쓰이는 변수명이자, edit이 edit mode를 의미한다는 것이 컨벤션이라면 괜찮을 수 있다. 변수명에 신경쓰는 이유 자체가 다른 개발자(또는 내일의 나)가 내 코드를 쉽고 빠르게 이해하게 하려는 것이기에.

조동사 용법

조동사(modal verb)는 동사를 돕는 동사란 뜻인데 can, should, will 등이 있다. 주의할 점은 조동사 + 동사원형 으로 써야한다는 것 하나 뿐이다.

can: “~ 할 수 있는가?”

should, will: “~ 해야 하는가?” 혹은 “~ 할 것인가?”

등등.

var canBecomeFirstResponder: Bool { get } //UIResponder: first responder가 될 수 있는가?
var shouldRefreshRefetchedObjects: Bool { get set } //NSFetchRequest: 가져온 값을 refresh 할 것인가?

has 용법

has로 시작하는 Bool 변수명은 상대적으로 빈도가 낮지만 뜻이 전혀 다르게 쓰이는 두 가지가 있어서 알아두면 유용하다.

  • has + 명사
  • has + 과거분사

has + 명사

has 다음 명사가 나오면 “~를 가지고 있는가?” 라는 뜻이다. has는 have의 3인칭 단수인데 3인칭 단수에 대해서는 다음 파트에서 자세히 다룬다.

var hasiCloudAccount: Bool { get } //CKUserIdentity: 관련된 iCloud 계정을 가지고 있는가?
var hasVideo: Bool { get set } //CXCallUpdate: 콜에 비디오가 포함되어 있는가?

has + 과거분사

모든 케이스를 통틀어 가장 덜 쓰이는 케이스 같으므로 만약 이해 안되더라도 넘어가도 지장 없다. 게다가 is + 과거분사와 뜻이 거의 같기 때문에 꼭 알아야할 필요도 없어보인다.

이때의 과거분사는 아까 is + 과거분사 때와는 다른 의미이다. has + 과거분사는 현재완료 의 의미를 가지는데 굳이 해석하자면 ‘과거에 완료된 것이 현재까지 유지되고 있다’는 뜻이다. 따라서 Bool 변수로 쓰이면 ‘~가 유지되고 있는가?’ 라고 해석할 수 있다.

var hasConnected: Bool { get } //CXCall: 콜이 연결되어 있는가?
var hasEnded: Bool { get } //CXCall: 콜이 끝났는가?

근데 isConnected, isEnded로 해도 의미가 비슷하다. 영어로 미묘한 느낌적인 차이가 있긴 하지만 수능을 다시 볼게 아니라면 꼭 알아야하는 건 아니라고 생각한다.

동사원형 용법

Bool 변수 짓기의 끝판왕이라는 생각에 마지막에 넣었다. 이거까지 잘 쓸 줄 알면 원어민 개발자 부러워하지 않아도 된다. (지극히 개인적인 의견)

주의할 점은 동사원형을 3인칭 단수로 써야한다는 것이다. 3인칭 단수는 보통 동사원형 뒤에 -s나 -es가 붙는 형태이다. Cocoa Touch에서 자주 쓰이는 단어들을 보면,

  • supports: ~을 지원하는가?
  • includes: ~을 포함하는가?
  • shows: ~을 보여줄 것인가?
  • allows: ~을 허용할 것인가?
  • accepts: ~을 받아 주는가?
  • contains: ~을 포함하고 있는가?

등이 있다. 이정도 단어들의 쓰임새만 알아도 풍부한 Bool 변수명을 짓기에 충분한 것 같다.

var supportsVideo: Bool //CXProviderConfiguration: 비디오를 지원하는가?
var includesCallsInRecents: Bool //CXProviderConfiguration
var showsBackgroundLocationIndicator: Bool //CLLocationManager
var allowsEditing: Bool //CNContactViewController: 편집을 허용하는가?
var acceptsFirstResponder: Bool //NSResponder

그 외에도 returns, preserves 등도 있었다.

var preservesSuperviewLayoutMargins: Bool //UIView
var returnsObjectsAsFaults: Bool //NSFetchRequest

하지만 3인칭 단수가 아닌 경우도 있다. 꽤나 예외적인 경우로 판단되기 때문에 이 역시 이해가 안되더라도 그냥 넘어가도 무방하다.

//Core Location의 CLRegion 
var notifyOnEntry: Bool { get set }
var notifyOnExit: Bool { get set }

유저의 기기가 해당 region을 벗어나거나 진입할 때 delegate를 통해 노티피케이션을 받을지 말지를 나타내는 Bool 값이다. 이 경우 notifies가 아닌 동사원형 그대로 쓰인 이유는 region 인스턴스가 notify를 하는 주체가 아니기 때문이다. 만약 notifies로 썼다면 region 인스턴스가 노티를 준다는 뜻인데 이는 맞지 않다. (아마도 CLLocationManager가 노티를 주지 않을까. 아무튼 region 인스턴스가 notify를 하는 주체가 아니기 때문에 3인칭 단수를 쓰지 않았다.)

3인칭 단수가 중요한 이유

코드 한 줄을 하나의 문장으로 비유하면 주어 역할을 하는 인스턴스가 3인칭 단수이기 때문에 문법적으로 꼭 써야하는 이유도 있지만, 3인칭 단수로 쓰지 않을 경우 스위프트 API 디자인 가이드와의 일관성이 깨져서 코드를 읽는 사람을 혼란에 빠뜨릴 수 있다. 가이드에 따르면 mutating 함수는 동사원형으로, nonmutating 함수는 동사 뒤에 -ed나 -ing를 붙여서 쓴다.

친숙한 예시로 Array의 sort()와 sorted()를 생각하면 된다.

mutating func sort() -> Void //in-place sort
func sorted() -> [Element] //정렬된 새 배열을 리턴

가령,

let overlaps: Bool = region1.overlaps(region2) //region1과 region2가 겹치는가?
region1.overlap(region2) //region1을 region2와 겹치는 부분으로 mutate
let region3 = region1.overlapping(region2) //region1과 region2가 겹치는 부분만 새로운 region 인스턴스로 리턴

이런 식으로 각각 Bool, mutating, nonmutating 함수의 이름을 지어줘야 가독성을 해치지 않고, 영문법적으로도 올바르고, 가이드에 충실한 네이밍을 할 수 있다.

마무리

정리하자면

  • is 용법
    • is + 명사
    • is + 현재진행(~ing)
    • is + 형용사
    • is + 동사원형 (절대 쓰면 안됨)
  • 조동사 용법
    • can, should, will 등
    • 조동사 + 동사원형
  • has 용법
    • has + 명사
    • has + 과거분사 (is + 과거분사와 의미 거의 동일)
  • 동사원형 용법
    • 3인칭 단수

끝.

Tags: naming, booleans, english, grammar