Breadcrumbs

Logo

Breadcrumbs

View My GitHub Profile

21 March 2020

스위프트 코드 리팩토링 스킬들

코드스쿼드에서 미션을 진행하며 받은 리뷰 내용과, 코드 리팩토링 과정을 통해 배운 내용을 정리합니다. 어떤 방식이 절대적으로 더 좋다고 얘기하는 것이 아니라, 어떠한 경우에 이러한 방식이 다른 방식보다 좋은 지 그 이유를 중점적으로 설명하면서 정리해보려 합니다.

하나의 함수에서 너무 많은 일을 하고 있지 않은지 확인합니다.

다음은 사다리를 만드는 macOS 콘솔 프로그램의 사용자 입력 부분입니다.

struct InputView {
    static func readHeight() -> Int {
        print("사다리 높이를 입력해주세요.")
        let height = readLine() ?? ""
        return Int(height) ?? 0
    }
    
    static func readPlayerNames() -> [String] {
        print("참여할 사람 이름을 입력하세요")
        let players = readLine() ?? ""
        return players.split(separator: ",").map{ String($0) }
    }
}

readHeight()는 만들 사다리의 높이를 입력받는 메서드이고, readPlayerNames()는 사다리 게임에 참여할 사람들의 이름을 입력받는 메서드입니다. 위 함수들은 하나의 함수에서 가이드 메시지를 출력하고, 입력을 받고, 프로그램에서 사용하기에 적절한 형태로 변환하는 기능까지 수행하고 있습니다. 기능을 분리하여 각각의 메서드로 만드는 것이 좋습니다.

struct InputView {
    static func read(with description: String) -> String {
        print(description)
        let line = readLine() ?? ""
        return line
    }
    
    static func readNumber(with description: String) -> Int {
        return Int(read(with: description)) ?? 0
    }
    
    static func readHeight() -> Int {
        return readNumber(with: "사다리 높이를 입력해주세요.")
    }
    
    static func readPlayerNames() -> String {
        let players = read(with: "참여할 사람 이름을 입력하세요")
        return players
    }
}

struct Tokenizer {
    static func players(players: String, delimeter: Character = ",") -> [String] {
        return players.split(separator: delimeter).map { String($0) }
    }
}

read(with:)는 가이드 메시지를 출력한 후에 사용자 입력을 받는 메서드입니다. readNumber(with:)read(with:)를 내부적으로 호출하여 입력을 숫자로 변환하는 메서드입니다. 이 두 메서드는 둘 다 입력을 받는 데에 사용하지만 readNumber(with:)가 더 구체적이므로 네이밍 시에 추가적인 단어를 붙였습니다.

readPlayerNames()는 이전과 달리 문자열을 분리하지 않고 그대로 리턴합니다. 문자열을 분리하는 등 데이터를 변환하는 기능은 입력 모듈에 섞여 있는 것 보다는 다른 모듈에 개별적으로 존재하는 편이 더 적절합니다.

이렇게 기능을 작게 분리하면 유지보수 및 단위 테스트가 용이해지며, 가독성이 좋아집니다. 기능 분리 시에 특히 사용자 입출력 등 입출력 로직을 비지니스 로직과 분리하는 것이 좋습니다.

새로운 타입을 만들어 활용합니다.

카드 게임에서의 카드를 클래스로 표현하는 경우를 예를 들어 보겠습니다. 하나의 카드는 모양으로 하트, 다이아몬드, 클로버, 스페이드를 가질 수 있고, 숫자로는 2~10, 그리고 Ace, Jack, King, Queen을 가질 수 있습니다.

cards

얼핏 생각했을 때, 모양은 열거형으로 만들고, A, J, K, Q는 각각 적절한 숫자에 대응시켜서 Int 타입으로 선언하면 될 것 같았습니다.

class Card {
    enum Suit {
        case heart, diamond, club, spade
    }

    // ...

    private let suit: Suit
    private let rank: Int
}

하지만 이렇게 할 경우, 카드 인스턴스는 숫자 프로퍼티에 100, 200이라는 값도 가지게 될 수 있습니다. 마이너스 값을 가질 수도 있습니다. 물론 들어오는 값이 올바른지를 검증하는 로직을 추가할 수도 있겠지만, 카드 인스턴스가 생성되는 곳이 여러 곳일 수도 있는데 모든 경우에서 완벽하게 검증한다는 보장도 없으며, 부가적인 검증 로직이 필요하다는 면에서 별로 좋지 않은 것 같습니다.

그렇기 때문에 더 좋은 방법은 새 열거형 타입을 만드는 것입니다.

class Card {
    enum Suit {
        case heart, diamond, club, spade
    }

    enum Rank: Int {
        case ace = 1
        case two, three, four, five, six, seven, eight, nine, ten, jack, queen, king
    }

    // ...

    private let suit: Suit
    private let rank: Rank
}

열거형으로 새 타입을 만드는 방법의 좋은 점은, 해당 프로퍼티가 가질 수 있는 값을 한정시키고 잘못 된 값이 들어오는 것을 컴파일 시점에 막을 수 있다는 것입니다. 카드 인스턴스를 생성 시에 반드시 Rank 타입의 값들 중 하나를 골라야 해서, 다른 값이 들어오는 경우를 완전히 막을 수 있습니다.

또 한가지 좋은 점은, 프로퍼티가 표현하려는 값이 해당 타입에 구체적으로 나열되어 적혀 있기 때문에 코드를 읽는 사람 입장에서 이해하기 쉽습니다. Int로 지정했을 때보다 Rank로 지정했을 때가 더 명확하고 구체적입니다.

하지만, 새로운 표현 타입을 만드는 것이 적합하지 않은 경우도 있습니다. 일반적인 카드의 경우 rank로 가질 수 있는 값이 13개로 고정되어 있고, rank의 종류가 추가될 가능성이 거의 없다고 할 수 있습니다. 그러나 열거형 타입의 종류가 자주 변한다면, 새 타입을 만드는 일을 한 번 더 고민해 봐야 할 것 같습니다.

구현체를 직접적으로 의존하지 말고, 추상화한 상위 클래스 또는 프로토콜을 의존하도록 설계합니다.

자판기 프로그램을 만드는 경우를 예로 들어 보겠습니다. 자판기 안에는 돈을 계산하는 모듈이 들어 있습니다. 이를 스위프트 코드로 표현하면 다음과 같습니다.

class VendingMachine {
    var cashier = Cashier()
}

위 코드의 경우, 자판기 클래스는 Cashier 클래스에 의존성이 있습니다. 이렇게 특정 타입에 의존성이 있는 클래스는 단위 테스트가 어렵습니다. 테스트 메서드에서 VendingMachine의 메서드를 호출 후에 결과를 확인하기가 어렵기 때문입니다. 테스트를 용이하게 하려면, Cashier가 구현할 Calculable 프로토콜을 만들고 VendingMachineCalculable을 의존하도록 만듭니다.

protocol Calculable {
    func sell(beverage: Beverage)
    // ...
}

class Cashier: Calculable {
    private var cash = 0
    
    func sell(beverage: Beverage) {
        cash -= beverage.price
    }
    // ...
}

class VendingMachine {
    private var cashier: Calculable
    
    init(cashier: Calculable) {
        self.cashier = cashier
    }
    // ...
}

위 방식의 장점은 Cashier에 대한 VendingMachine의 의존성이 줄어들어서, VendingMachineCalculable 프로토콜을 구현한 어떠한 구현체를 넣어도 동작할 수 있다는 것입니다. 이제 VendingMachineCalculable 프로토콜을 구현한 Mock 객체를 넣어서 테스트할 수 있게 되었습니다.

class MockCashier: Calculable {
    var cash = 0
    var sellCalled = 0
    
    func sell(beverage: Beverage) {
        sellCalled += 1
        cash -= beverage.price
    }
    // ...
}

class VendingMachineTests: XCTestCase {
    
    var vendingMachine: VendingMachine!
    var mockCashier: MockCashier!
    
    override func setUp() {
        mockCashier = MockCashier()
        vendingMachine = VendingMachine(cashier: mockCashier)
    }
    
    func testSell() {
        let items = [StrawberryMilk(), Coke(), Fanta(), Top()]
        items.forEach { vendingMachine.fill(beverage: $0) }
        
        vendingMachine.insert(money: 2000)
        vendingMachine.sell(beverage: Coke())
        
        XCTAssertEqual(mockCashier.cash, 400)
        XCTAssertEqual(mockCashier.sellCalled, 1)
    }
}

Calculable 프로토콜을 구현한 MockCashier를 만들고, VendingMachine 인스턴스를 만들 때 넣어 주었습니다. VendingMachine이 잘 동작했는지, MockCashier의 메서드를 잘 호출했는지도 쉽게 테스트할 수 있게 되었습니다.

이렇게 객체 간의 의존성을 줄이고 상위 클래스 또는 프로토콜로 추상화해야 테스트가 용이해집니다.

Mock이란, 실제 객체의 행위를 모방한 모조 객체로, 소프트웨어 테스팅에 자주 사용됩니다. — Wikipedia

프로토콜을 이용한 테스트의 또다른 예시 중 하나는 랜덤 값에 의존하는 유닛을 테스트하는 경우입니다. 예를 들어 카드 게임에서 카드 덱을 섞는 메서드를 구현하는 경우를 보겠습니다.

struct CardDeck {
    private var cards = [Card]()

    mutating func shuffle() {
        cards.shuffle()
    }
    // ...
}

suffle()은 배열의 요소들의 순서를 랜덤으로 섞어주는 스위프트 메서드입니다. suffle()은 위처럼 매개변수로 아무 값도 넘겨주지 않으면 시스템의 기본 랜덤 제너레이터인 SystemRandomNumberGenerator를 사용하여 배열 안의 요소들을 섞습니다. 위 코드를 테스트하려면, 간단히 생각했을 때 셔플 전과 후의 카드 순서가 다른 지를 검사하는 방법이 있습니다. 하지만, 카드가 섞이는 순서는 테스트할 때마다 계속 달라지기 때문에 해당 테스트는 100% 확률로 성공하지는 않습니다.

이 문제를 해결하기 위해서, CardDeckshuffle()RandomNumberGenerator 프로토콜을 의존하게 만듭니다.

struct CardDeck {
    private var cards = [Card]()

    mutating func shuffle<T: RandomNumberGenerator>(using generator: T) {
        var randomGenerator = generator
        cards.shuffle(using: &randomGenerator)
    }
    // ...
}

이렇게 한 후에 실제 프로그램 동작 시에는 시스템 랜덤 제너레이터를 입력하고, 테스트시에는 시드가 고정된 랜덤 넘버 제너레이터를 입력하면 랜덤 넘버에 의존적이지 않은 테스트가 가능해집니다.

class FixedRandomNumberGenerator: RandomNumberGenerator {
    // ...
    func next<T>() -> T where T : FixedWidthInteger, T : UnsignedInteger {
        // 항상 같은 랜덤값을 돌려줍니다.
    }
}

class CardDeckTests: XCTestCase {

    var cardDeck: CardDeck!

    let fullCardDeck = 52

    override func setUp() {
        cardDeck = CardDeck()
    }

    // ...
    
    func testShuffle() {
        let fixedGenerator = FixedRandomNumberGenerator()
        let cardsBeforeSuffle = (0..<fullCardDeck).map { _ in cardDeck.removeOne() }
        
        cardDeck.reset()
        cardDeck.shuffle(using: fixedGenerator)
        
        let cardsAfterSuffle = (0..<fullCardDeck).map { _ in cardDeck.removeOne() }
        XCTAssertNotEqual(cardsBeforeSuffle, cardsAfterSuffle)
    }
}

testShuffle()에서 cardDeckshuffle(using:)을 호출하며 고정된 랜덤 넘버 발생기를 전달해 줍니다. 이 테스트 메서드는 처음 한 번만 성공하면 다음에도 100% 성공한다는 것을 보장받을 수 있습니다.

이렇게 추상화된 프로토콜이나 상위 클래스에 의존하도록 설계하면, 외부에서 구현체를 바꿔주어 테스트하는 방식이 가능해집니다.

객체의 내부 프로퍼티를 보호하고, 인터페이스를 추상화합니다.

객체를 설계할 때는, 객체 내부 속성은 보호하면서 추상화시킨 인터페이스만 외부에 제공하도록 만들어야 합니다.

이 글에서 설명하는 인터페이스는 Java의 인터페이스가 아닌, 두 프로그램 모듈 간의 접점, 즉 구체적으로는 메서드를 의미합니다.

예를 들어, 자판기 안에서 돈을 관리하는 객체를 스위프트 코드로 표현하는 경우를 생각해 보겠습니다.

class Cashier {
    var balance = 0
}

Cashier 안의 balance 프로퍼티는 사용자가 자판기에 넣은 돈을 나타냅니다. 자판기에서 음료를 판매하려고 한다면, 남은 돈에서 음료수의 가격만큼을 빼야 합니다. 이때, 객체 외부에서 balance에 직접 접근하여 빼는 것이 아니라, 다음과 같이 프로퍼티는 접근 제한자를 통해 보호한 뒤 객체의 기능을 추상화시킨 메서드를 만들어 놓고 객체 외부에서는 이 메서드를 호출하게 해야 합니다.

class Cashier {
    private var balance = 0

    func sell(beverage: Beverage) {
        cash -= beverage.price
    }
}

스위프트에서 객체의 내부 값을 보호하는 또 하나의 방법 중 하나는, 행위를 전달받아 객체 안에서 수행하도록 만드는 것입니다.

예를 들어, 카드 게임에서 플레이어들에게 카드를 한 장씩 나눠주는 경우를 생각해 보겠습니다. Players는 게임 참가자들의 배열을 갖고 있는 클래스입니다. 각 참가자들에게는 카드 한 장을 받는 take(card:) 메서드가 있습니다.

class Players {
    let players: [Participant]
    // ...
}

이 때, 카드를 나눠주는 상위 객체에서 players 프로퍼티에 직접 접근하여 take(card:) 메서드를 호출하는 것 보다는, 행위를 전달받아 각 플레이어들마다 실행시켜주는 인터페이스를 구현하는 방식이 더 좋습니다.

class Players {
    // ...
    private let players: [Participant]

    func repeatForEachPlayer<T>(_ block: (Participant) -> T) {
        return players.forEach { block($0) }
    }
}

이제 다음과 같이 카드를 나눠줄 수 있습니다.

Players().repeatForEachPlayer { $0.take(card: cardDeck.removeOne()) }

위와 같이 클로저를 받아서 동작하도록 만들면 객체는 내부 속성을 보호하면서 주체적으로 동작하게 되며, 이제 외부에서는 적절한 명령을 내리기만 하면 됩니다.

클래스가 아닌 열거형의 경우에도 마찬가지로, 자신의 내부 값인 rawValue를 그대로 외부에서 접근하도록 두는 것이 아니라, 추상화된 인터페이스를 제공하는 것이 좋습니다. 위에서도 나온 카드 클래스를 다시 보겠습니다.

class Card {
    enum Suit: String {
        // ...
    }
    
    enum Rank: Int {
        case ace = 1
        case two, three, four, five, six, seven, eight, nine, ten, jack, queen, king
    }
    
    private let suit: Suit
    private let rank: Rank
    
    // ...
    
    func isNext(to card: Card) -> Bool {
        return rank - card.rank == 1
    }
}

만약 두 카드 인스턴스가 연속된 숫자인지를 판단하기 위해 카드의 rank를 비교하려 할 때, rank.rawValue를 외부에서 직접 접근하여 비교하는 것 보다 위와 같이 isNext(to:) 메서드를 만들어 추상화된 인터페이스를 제공하는 방식이 더 좋습니다.

이와 같이 객체가 자신의 프로퍼티를 open하고 수동적으로 동작하는 것 보다, 주체적으로 적절한 기능을 하도록 설계하면, 자연스럽게 하나의 객체에서 너무 많은 일을 하는 경우도 줄어들고, 객체들끼리 일을 잘 분담하게 되는 것 같습니다. 위에서도 언급한 대로 객체들끼리 일을 잘 분담하면 테스트와 유지보수가 수월해집니다.

마무리

이렇게 여러 가지 코드를 개선하는 방법에 대해 정리해 보았습니다. 글을 작성하며 어떤 설계 방식이 왜 좋은지를 다시 한 번 고민해 볼 수 있었습니다. 글 초반에도 언급한 대로, 절대적인 기준은 아니며 객체 간의 관계를 잘 이해하고 상황에 맞게 적절히 적용해야 합니다.

References

tags: Swift - iOS - macOS - Refactoring