Readable swift code

Swift 코드의 가독성을 높이는 방법

이 강연에서 Krzysztof Siejkowski는 Swift 코드의 가독성을 높이는 방법을 설명합니다. 코드의 가독성은 코드 설계와도 긴밀히 연결되어 있습니다. Swift 코드 가독성 향상을 위해 주로 사용하는 Cocoa 패턴과 Swift 문법들을 살펴보고, 가독성을 향상하기 위해 도입한 방법들이 지닌 문제점과 개선방안을 알아보겠습니다. 또한, 여러분은 Swift 타입 시스템을 활용해 코드 가독성을 높이는 방법을 배울 수 있습니다.


소 개

Polidea에서 iOS 개발자로 근무하고 있는 Christopher입니다. 우리 회사는 폴란드 바르샤바에 있고, 블루투스 또는 와이파이를 사용해 하드웨어와 통신하는 앱을 제공하고 있습니다. 저는 Mobile Warsaw라는 모임의 공동 주최자이기도 합니다. 소개는 이 정도로 마무리하고, 강연 주제인 코드 가독성에 대해 알아볼까요?

가장 큰 문제는 무엇인가?

2006년, 마이크로소프트는 카네기 멜런 대학과의 공동 연구를 통해 “정신 모형 유지하기: 개발자의 근무 습관에 관한 연구(Maintaining Mental Models: A Study of Developer Work Habits)”라는 논문을 발표했습니다. 논문의 저자들은 마이크로소프트에 근무하는 프로그래머를 대상으로 설문 조사를 했습니다. 그 질문 중의 하나는 “문제라고 생각하는 작업은 무엇인가요?” 였습니다.

많은 프로그래머가 문제라고 생각한 작업은 코드에 담긴 의도를 이해하는 것 이었습니다. 프로그래머들은 이 문제를 어떻게 해결했을까요? 대부분은 단순히 코드를 읽어서 이해하는 방법을 선택했습니다. 문제를 해결하기 위해 걸린 시간 중 코드 읽기 과정에 40% 이상을 사용했습니다. 코드를 읽는 것만으로 충분하지 않을 때는, 디버거를 사용하거나 print문을 추가하는 등의 작업을 했습니다. 이것마저 실패한 경우, 코드 작성자를 직접 찾아가서 설명을 부탁했습니다.(프로그래머를 대상으로 한 설문 결과에서 팀 동료로부터 코드에 대한 설명을 요청받은 경우가 2위를 차지했음)

저는 이 논문을 상당히 공감하면서 읽었습니다. 저는 코드를 이해하기 위해 노력한 적도 있고, 제가 작성한 코드에 관해 설명을 요청받은 적도 있습니다. 때로는 제가 6개월 전에 작성한 코드임에도 불구하고 제대로 설명하지 못한 적도 있었는데 그 당시에 저도 이해하지 못한 코드를 작성했기 때문이었죠. 이 논문을 통해 우리가 얻을 수 있는 교훈은 무엇일까요? 코드를 이해하는데 가독성이 매우 중요하다는 것입니다. 코드 가독성은 코드 작성 방식에도 상당한 영향을 줍니다.

가독성 높은 코드 작성의 중요성과 관련해 그동안 많은 의견이 제시되었습니다. 가장 유명한 문구 중의 하나는 Structure and Interpretation of Computer Programs 책에서 언급된 것입니다.

“프로그램은 사람들이 읽을 수 있게 작성하는 것이 가장 중요하고, 컴퓨터가 실행할 수 있게 프로그램을 작성하는 것은 부수적인 것이다.”

우리는 소프트웨어로 구체화한 지적 복잡성(intellectual complexity)을 제어하는 기술이 필요합니다. 코드를 작성한다는 것은 복잡성을 제어한다는 것을 의미합니다. 작성하는 코드가 복잡해질수록, 코드를 이해하기가 더 어려워집니다.

복잡성

작성하려고 하는 로직이 본질적(intrinsic)으로 복잡성을 가지고 있는 경우라면 우리가 할 수 있는 일들이 많지 않습니다. 하지만 우리 스스로가 만들어낸 부수적인 복잡성(incidental complexity) 은 제어할 수 있습니다. 이를 최소화하려면, 코드의 가독성을 높이기 위해 노력해야 합니다.

가독성 높이기 도전과제

가독성을 높이기 위한 게임을 시작해볼까요? 우리의 임무는 가독성을 높일 수 있는 부분을 찾는 것입니다.

이런 개발 뉴스를 더 만나보세요

레벨 1-1: 주석(comments) 달기

첫 번째로 살펴볼 곳은 API 디자인 영역입니다. 이메일 주소를 문자열로 받아서 사용자 이름과 도메인으로 분리하길 원한다고 가정해보겠습니다.


func split(email: String)  (String, String)? {
    let components = email.components(separatedBy: "@")
    guard components.count == 2,
          let username = components.first,
          let domain = components.last,
          !username.isEmpty && !domain.isEmpty
        else { return nil }
    return (username, domain)
}

let splitEmail = split(email: "[email protected]")

@ 기호를 기준으로 이메일 주소를 분리(split)합니다. 그다음 이름과 도메인이 존재하는지 확인하고 isEmpty를 이용해 이름과 도메인이 빈 문자열(empty)은 아닌지 검사합니다. 만약 이 과정이 실패한다면 nil을 반환합니다. 성공하면 components의 첫 번째 요소인 username과 두 번째 요소인 domain을 튜플로 구성하여 반환합니다. 간단하죠?

가독성 관점에서 살펴볼까요? 우리는 API 디자인 영역을 살펴보기로 했으므로, 메서드 시그니처(method signature) 에 초점을 맞춰보도록 하죠.


func split(email: String)  (String, String)?

이 메서드 시그니처를 처음 본 사람들은 어떻게 생각할까요? 아마도 다음과 같은 다양한 질문들이 나올 수 있을 것 같네요. 어떻게 email을 분리한다는 거지? 튜플 안에 담긴 String 요소들은 무엇을 의미하는 것일까? 왜 반환 값이 optional 타입이지?

가독성 게임의 1단계는 주석 입니다.


/// Splits the email into username and domain.
/// - Parameter email: A string that possibly
/// contains the email address value.
/// - Returns: Either:
///     - Tuple with username as first element
///     and domain as second
///     - Nil when passed argument cannot be split
func split(email: String)  (String, String)?

주석으로 상세한 설명을 추가했습니다. 주석은 코드의 내용을 설명할 수 있는 훌륭한 도구이지만, 혼란을 일으키기도 합니다. Swift에서 주석은 컴파일 대상도 아니고 실행되지도 않습니다. 코드를 변경할 때 주석도 함께 변경해야 하지만, 주석은 개발자가 작성하는 것이기 때문에 주석을 수정하지 않을 가능성이 항상 존재합니다.

또한, 주석은 프로그래밍 언어를 사람들이 이해할 수 있는 언어로 번역한 것이기 때문에 이 과정에서 오해나 누락이 발생할 수 있습니다. 예제 코드 주석에는 다음과 같은 구문이 있습니다.

Nil when passed argument cannot be split

이것은 사실이 아닙니다. argument를 분리할 수 있는 경우에도 nil이 반환될 수 있기 때문이죠. usernamedomain이 빈 문자열인 경우가 여기에 해당합니다. 주석을 이용해 인간의 언어로 표현할 때 놓친 부분이죠.

레벨 1-2: 심볼(symbols)

Swift 언어를 이용해 이 문제를 해결할 수 있습니다. 가독성 게임의 2단계는 심볼 입니다.


typealias SplitEmail = (username: String,
                          domain: String)
                          
struct SplitEmail {
    let username: String
    let domain: String
}

func split(email: String)  SplitEmail?

우리는 emailusernamedomain으로 분리된다는 것을 표현하길 원합니다. typealiasstruct를 이용하면 튜플에 이름을 부여할 수 있습니다. typealiasstruct로 표현해도 메서드 시그니처 관점에서는 변한 것이 없지만, 이들 문법 구조 안에 정의된 심볼들의 개수가 증가하는 단점이 있습니다.

심볼이 추가되면 비용이 발생합니다. 이들 비용은 코드를 읽는 사람들이 부담해야 합니다. 동료 프로그래머가 SplitEmail를 처음 보았을 때, 이 심볼이 무엇을 의미하는 것인지 확인하고 이해하려고 할 것입니다. 이것을 발견 가능성(discoverability) 비용이라고 합니다. SplitEmail를 다시 만났을 때는 이제는 낯선 개념이 아니기 때문에 심적 부담이 감소합니다. 이를 인지(recognition) 비용이라고 부릅니다.

우리의 목표는 이들을 자주 사용하거나 전혀 사용하지 않는 것 입니다. 발견 가능성 비용은 고정적이며 인식 비용은 적기 때문에 한 번만 사용되는 유형이라면 도입할 필요가 없습니다. 또한, 표준 라이브러리 타입들은 발견 가능성 비용이 오래전에 지급되었고 지속적해서 학습되었기 때문에 일반적으로 비용이 매우 낮습니다.

이제, 메서드 시그니처로 SplitEmail 구조체를 사용하겠습니다. 하지만, 여전히 반환 타입이 optional인 이유를 설명하지 못했네요. Optional은 값이 없을 수도 있다는 것을 표현할 수 있지만, 값이 없는 이유 에 대해서는 표현하지 못합니다.

레벨 1-3: 래퍼(wrapper)

가독성 게임의 3단계는 래퍼입니다. 래퍼는 컨테이너(container)의 확장이라고 할 수 있습니다. 래퍼는 값들을 저장할 뿐만 아니라 이들에 대한 추가적인 정보도 제공합니다.


enum SplitEmail {
    case valid(username: String, domain: String)
    case invalid
    
    init(from email: String) {
        let components = email.components(separatedBy: "@")
        if /* same checks as before */ {
            self = .valid(username: username, domain: domain)
        } else {
            self = .invalid
        }

우리는 전달받은 문자열을 분리할 때, username, domain이 빈 문자열일 때는 문자열을 분리하지 않는다는 점을 표현하고 싶습니다. enum 래퍼는 두 개의 case 문을 가지고 있는데, 적합성에 부합할 경우 valid(username: String, domain: String) case에서 관련된 값들을 처리하며, invalid case는 적절하지 않은 email을 처리합니다.

이 방법을 통해 메서드 시그니처에서 optional 반환 타입을 제거할 수 있게 되었습니다.

func split(email: String)  SplitEmail

이 방법은 가독성을 높이는 데 도움이 되나요? 아니면 가독성을 떨어뜨리는 방법인가요? email을 분리하는 방법이 훨씬 복잡해졌습니다. 이제 코드를 읽는 사람들은 enum에 있는 case 문과 긴 초기화 코드가 무엇을 의미하는지 이해해야 합니다. 이로 인해 발견 가능성 비용이 상승할 것입니다.

다음 단계에서는 컨셉(concepts) 을 이용한 분리방법에 대해 살펴보도록 하죠.

레벨 1-4: 컨셉


protocol Splitter {
    associatedtype Splitting
    associatedtype Splitted
    func split(value: Splitting)  (Splitted, Splitted)?
}

struct EmailSplitter: Splitter {
    func split(value: String)  (String, String)? {
        let components = value.components(separatedBy: "@")
        guard /* same checks as before */ else { return nil }
        return (first, second)
    }

가독성 게임의 4단계는 컨셉 입니다. 분리하기(splitting) 구조를 작성하기 위해 Swift의 타입 시스템을 사용했습니다. 분리하기는 하나의 값이 두 개의 값으로 변환하는 과정입니다. 이 변환을 associatedtype을 사용해 protocol로 표현했고, EmailSplitterSplitter 프로토콜을 구현한 것입니다.

enum Split<Value, S: Splitter> where S.Splitting == Value,
                                      S.Splitted == Value {
    case splitted(Value, Value)
    case invalid
    
    init(_ value: Value, using splitter: S) {
        if let (first, second) = splitter.split(value: value) {
            self = .splitted(first, second)
        } else { self = .invalid }

연산은 성공하거나 실패할 수 있어야 하므로 반환 값은 두 개의 case 문을 가진 enum으로 구성했습니다. value 타입과 변환 타입은 제너릭(코드에서 ValueS가 해당함)으로 표현했습니다. 이들은 변환 과정이 제대로 작동할 수 있게 도와줍니다. 제가 Swift에서 이 패턴을 처음 발견한 곳은 Benjamin Encz의 Validated framework 입니다. (꼭 방문해서 확인해보시길 바랍니다.)


func split(email: String)  Split<String, EmailSplitter>

// result: Split
// value: String
// transform: EmailSplitter

이제 반환 값은 사용자가 정의한 타입을 갖게 되었고, value도 자신만의 타입을 갖고 있으며 변환의 결과 역시 자신만의 타입을 갖게 되었습니다.

시그니처는 우리가 앞에서 주석을 이용해 설명했던 모든 정보를 포함하고 있습니다. 시그니처는 email 문자열을 EmailSplitter를 사용해 분리할 수 있으며, 분리결과는 실패할 수 있다는 것을 표현하고 있습니다.

올바른 복잡성인가요?

위의 방법으로 구현하면 코드의 가독성은 증가할까요? 감소할까요? 상황에 따라 다릅니다. 앱에서 분리하기 로직이 중요한 부분을 차지한다면, 여러 종류의 값들을 처리하기 위해 종류별로 EmailSplitter와 같은 splitter를 구현할 것입니다. 이때, splitter 각각은 본질적인 복잡성을 갖게 됩니다.

하지만, 가독성 게임에서 제시한 방법대로 메서드 시그니처를 이용해 분리하기 로직을 구현한다면, 부수적인 복잡성만 도입한 구조가 될 것입니다. 대신, 코드의 가독성은 떨어졌기 때문에 주석을 남겨야만 합니다.

4단계를 거쳐 가독성 게임의 첫 번째 영역인 API 디자인 영역에 대한 탐험을 마쳤습니다. API 디자인 영역에서는 광범위하게 적용할 수 있는 해결책은 없고 장단점을 가진 해결책만 존재한다는 사실을 배울 수 있었습니다. 두 번째 영역에서 좀 더 나은 해결책을 찾을 수 있기를 기대해봅니다.

두 번째 영역: 델리게이션(Delegation)

두 번째 영역은 델리게이션 패턴과 관련되어 있습니다. 프로토콜을 이용해 델리게이트를 정의하고 announcing 객체에 전달합니다. 이 개념을 코드로 표현하면 다음과 같이 수많은 시나리오를 구현할 수 있습니다. 델리게이트가 한 번만 할당되었나, 여러 번 할당되었는가? optional인가 required인가? retain인가?

애플이 이 질문들에 대해 어떤 답변을 했는지 일반적인 델리게이션 구현방법을 통해 알아보도록 하죠.


protocol Delegate: class {
    func foo()
}

struct Announcing {
    open weak var delegate: Delegate?
}

var announcing = Announcing()
announcing.delegate = delegate

delegate 속성은 public, mutable, weak로 설정되어 있네요. 이들을 통해 4가지 사실을 알 수 있습니다.

  1. delegateweak이기 때문에 retain 되지 않습니다.
  2. delegaterequired가 아니므로 설정하지 않아도 됩니다.
  3. delegatevar로 선언되었기 때문에 announcing 객체의 라이프타임 동안 변경될 수 있습니다.
  4. open으로 선언했기 때문에 delegate에 자유롭게 접근할 수 있습니다.

이러한 가정은 적합한가요?

가독성을 위한 관례(Conventions)

때로는 주석이나 문서에 설명이 있는 경우도 있지만, 그곳에 답이 없는 경우도 많습니다. 애플은 문서 대신 라이브러리의 관례를 따라 delegate가 retain 하지 않고, optional 이며, 변경되지 않는다는 것을 표현할 수 있으며, 이를 위해 이들 메서드를 직접 호출하지 않아도 됩니다. 프로그래밍 가이드와 커뮤니티에서도 이러한 지식을 새로운 멤버들과 공유합니다.

애플이 택한 답변을 좀 더 발전시켜 활용해 볼까요? delegate를 required, immutable로 만들고 싶다고 가정해보죠. 두 번째 영역의 1단계는 이니셜라이져 주입(initializer injection) 입니다.

레벨 2-1: 이니셜라이져 주입


struct Announcing {
    private weak var delegate: Delegate?
    
    init(to delegate: Delegate) {
        self.delegate = delegate
    }
}

let announcing = Announcing(to: delegate)

속성을 private로 변경했고, delegate 설정은 이니셜라이져인 init(to delegate: Delegate) 안으로 옮겼습니다. 이제 delegate 없이 announcing 객체를 생성하는 것은 불가능하며, 외부에서 delegate에 직접 접근하는 것도 허용되지 않습니다.

안타깝게도 이러한 변경으로 인해 부수적인 피해가 발생했습니다. public API에서 delegateweak라는 정보가 빠진 것이죠. 레퍼런스 사이클의 존재 여부를 확인하려면 announcing 객체의 구현코드를 읽어야만 합니다.

다른 접근법을 사용하면 이 문제를 해결할 수 있습니다. 그것은 바로 2단계인 클로저 안에서 weak 캡쳐하기(a weak capture in a closure) 입니다.

레벨 2-2: Weak 클로저


struct Announcing {
    private let delegate: ()  Delegate?
    
    init(to delegate: @escaping ()  Delegate?) { ... }
}

let announcing = Announcing(to: {
    [weak delegate] in delegate
})

announcing 객체가 delegate를 retain 할 것인지를 결정하는 곳이 외부로 옮겨졌습니다. announcing 객체는 delegate가 strong 하게 시작하는지 weak 하게 시작하는지 알지 못합니다. 이렇게 하면, 이니셜라이져안의 클로저가 레퍼런스 사이클을 피할 방법이라고 코드를 읽는 사람들에게 명확하게 전달될까요? 저는 그렇게 생각하지 않습니다.

클로저는 여러 방법으로 여러 장소에서 사용될 수 있는 문법 구조이며 위의 코드에서는 클로저가 하는 역할에 대한 정보가 없습니다. 이제 3단계로 넘어가 볼까요? 함수 안에서 클로저 생성하기 입니다.

레벨 2-3: Weak 함수


func weak<T: AnyObject>(_ object: T)  ()  T? {
    return { [weak object] in object }
}

struct Announcing {
    private let delegate: ()  Delegate?
    init(to delegate: @escaping ()  Delegate?) { ... }
}

let announcing = Announcing(to: weak(delegate))

weak함수는 클로저안의 레퍼런스를 래핑(wrapping)하는 역할만 수행합니다. 이 레퍼런스는 announcing 객체에서 넘겨준 것입니다. 이를 통해 weak 처리된 delegae가 전달됩니다.

하지만, 여전히 이니셜라이져의 시그니처에 표현된 내용이 명확하지 않은 것 같습니다. 클로저 역할이 여전히 정의되지 않았습니다. 가독성 게임의 첫 번째 영역에서 사용했던 래퍼(wrapper) 패턴을 이용해 이 문제를 개선해보도록 하겠습니다.

레벨 2-4: Weak 래퍼


struct Weak<T: AnyObject> {
    private let internalValue: ()  T?
    init(_ value: T) {
        internalValue = { [weak value] in value }
    }
}
struct Announcing {
    private let delegate: Weak<Delegate>
    init(to delegate: Weak<Delegate>) { ... }
}

자, 래퍼를 사용했더니 메서드 시그니처가 향상되었나요? 첫 번째 영역에서 겪었던 문제가 다시 발생했네요. 우리는 첫 번째 영역에서 typealias, 컨테이너, 컨셉 등을 이용한 방법을 살펴보았습니다. 이제, 두 번째 영역에서도 가독성을 향상할 수 있는 완벽한 부분이 존재하지 않는다는 사실을 알게 되었습니다.

가독성은 문맥과 관련되어 있습니다.

개선된 델리게이션 구현코드로 인해 가독성이 향상되었나요? 우리는 애플이 제공하는 관례를 따르지 않았습니다. 프로토콜 개발자들은 많지 않기 때문에 애플의 관례를 따르지 않으면 가독성을 감소시키는 결과만을 초래할 뿐입니다.

따라서, 좀 더 넓은 문맥 속에서 코드를 살펴봐야 합니다. 가독성 높은 코드를 작성하기 위해서는 우선, 우리가 통제할 수 있는 것이 무엇인지 살펴봐야 합니다. 할 수 있는 게 뭐가 있는지, 다른 영역으로 이동해야 하는지 등을 고민해봐야 합니다. 수많은 영역이 있지만 완벽한 해결책을 제공하는 영역은 존재하지 않는다고 생각합니다.

명확한 지침이 없는 상태에서 어떻게 하면 가독성 높은 코드를 작성할 수 있을까요? 복잡성, 심볼 비용, 관례 따르기, 구성 요소의 역할 설명들 사이에서 적절한 균형점을 찾는 방법은 무엇일까요? 다행스럽게도, 가독성 게임에는 공감(empathy) 이라는 만능열쇠가 있습니다.

공감

가독성 높은 코드를 작성하는 방법을 선택하기 위해서는 독자의 관점을 받아들일 필요가 있습니다. 그들이 어떻게 생각할지, 어떤 질문을 던질지를 상상해야 합니다. 더욱 합리적인 방법을 선택하기 위해 다른 사람의 처지에서 생각해봐야 합니다.

사람들은 개인적인 경험, 프로그래밍 경험, Swift 언어 능숙도, 관심도 등을 바탕으로 코드를 읽습니다. 시스템에 대해 다른 사람에게 설명하는 방법이 코드라면, 다른 표현법들처럼 코드 역시 설명을 듣는 사람에게 초점을 맞춰야 합니다.

가독성은 독자에게 달려있습니다.

독자가 없다면 가독성도 필요 없을 것입니다. 그렇기 때문에 공감이 중요한 것입니다. 가독성은 독자의 기능입니다. 우리가 나아가야 할 방향이기도 하죠.

가독성 게임에서 이기기 위해서는 팀 동료와 커뮤니티의 목소리를 경청해야 합니다. 또한, 타인을 생각하는 것이 미래의 자신을 돕는 것이라는 사실을 기억하시길 바랍니다.

코드를 작성하는 단 하나의 올바른 방법은 존재하지 않습니다. 다른 사람들을 이해하고 배울 수 있는 준비가 되었느냐가 중요합니다. 열린 마음으로 피드백을 받아들이고 다른 관점들의 가치를 알아볼 수 있다면 가독성 게임에서 높은 순위를 차지할 수 있을 것입니다.

컨텐츠에 대하여

2016년 3월에 진행한 try! Swift Tokyo 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Krzysztof Siejkowski

Krzysztof (or Chris) is an iOS developer at Polidea, a hardware-friendly software house in Warsaw, Poland. He’s a co-organizer of Mobile Warsaw, a community for mobile developers, and a Swift enthusiast. A cultural anthropologist by training, he tries to see programming techniques from a humanistic perspective.

4 design patterns for a RESTless mobile integration »

close