본문 바로가기
Refactoring

리팩터링 2판 - 01. 리팩터링: 첫번째 예시 [Swift]

by HaningYa 2021. 9. 29.
728x90

리팩토링 1장은 좀 자세히 한번 봐도 좋을 것 같아서 정리

Playground 기준으로 코드 작성함


프로그램 예제코드 스펙
- 공연 요청이 들어오면 연극의 장르와 관객 규모를 기초로 비용을 책정, 공연과 별개로 포인트를 지급해서 공연료를 할인 받을 수 도 있음

Swift로 바꾼 예시 프로그램 

더보기
import UIKit


enum MyError: String, Error {
    case gerneError = "알수없는 장르"
}

struct Invoice {
    var customer: String
    var performances : [Performance]
}
struct Performance {
    var playID: String
    var audience: Int
}

struct Play {
    var name: String
    var type: String
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var volumnCredits = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        let play = plays[plays.firstIndex { $0.name == perf.playID } ?? 0]
        var thisAmount = 0.0

        switch play.type {
        case "tragedy":
            thisAmount = 40000
            if perf.audience > 30 {
                thisAmount += 10000.0 + 500.0 * Double(perf.audience - 30)
            }
        case "comedy":
            thisAmount = 30000
            if perf.audience > 20 {
                thisAmount += 10000.0 + 500.0 * Double(perf.audience - 20)
            }
            thisAmount += 300.0 * Double(perf.audience)
        default:
            throw MyError.gerneError
        }

        //포인트를 적립한다.
        volumnCredits += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == play.type {
            volumnCredits += floor(Double(perf.audience)/5.0)
        }
        //청구 내역을 출력한다.
        result += "\(play.name): $\(thisAmount/100) (\(perf.audience)석)\n"
        totalAmout += thisAmount
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}

let plays = [
    Play(name: "Hamlet", type: "tragedy"),
    Play(name: "As You Like It", type: "comedy"),
    Play(name: "Othello", type: "tragedy")
]

let invoice = Invoice(customer: "BigCo", performances: [
    Performance(playID: "hamlet", audience: 55),
    Performance(playID: "As You Like It", audience: 35),
    Performance(playID: "Othello", audience: 40)
])

let resultString = try? statement(invoice: invoice, plays: plays)
print(resultString!)

/*
 청구 내역 (고객명BigCo)
 Hamlet: $625.0 (55석)
 As You Like It: $580.0 (35석)
 Othello: $550.0 (40석)
 총액: 1755.0
 적립 포인트: 47.0점
 */
  • 프로그램이 짧아서 특별히 이해해야하는 구조가 없음
  • 그러나 수백줄의 코드에 포함된 일부라면 이해하기 어려움
  • 원하는 동작을 수행하도록 수정해야할 부분을 찾기 쉬워야함

그러기 위해선

  • 코드를 여러 함수와 프로그램 요소로 재구성
  • 구조가 빈약하다면 대체로 구조부터 바로잡은 뒤 기능을 수정

statement 메서드에 대해 추가 수정사항 예시

  • 청구내역을 HTML로 출력하는 기능
  • 희극, 비극 이외의 추가 장르 공연료 정책

위와같은 수정사항이 생길 때 마다 statement() 함수를 수정한다면 코드도 복잡해지고 실수할 가능성이 높아진다.


리팩토링의 첫 단계

  • 리팩토링할 코드 영역을 검사해줄 테스트 코드가 필요하다.
  • 테스트의 중요성은 따로 정리X

statement 함수 테스트 코드

class StatementTestCase: XCTestCase {

    override func tearDown() {

    }

    func testStatement() {
        let answer: String = "청구 내역 (고객명BigCo)\nHamlet: $625.0 (55석)\nAs You Like It: $580.0 (35석)\nOthello: $550.0 (40석)\n총액: 1755.0\n적립 포인트: 47.0점\n"
        let plays = [
            Play(name: "Hamlet", type: "tragedy"),
            Play(name: "As You Like It", type: "comedy"),
            Play(name: "Othello", type: "tragedy")
        ]

        let invoice = Invoice(customer: "BigCo", performances: [
            Performance(playID: "hamlet", audience: 55),
            Performance(playID: "As You Like It", audience: 35),
            Performance(playID: "Othello", audience: 40)
        ])


        let result = try? statement(invoice: invoice, plays: plays)
        XCTAssertEqual(answer, result)
    }
}

StatementTestCase.defaultTestSuite.run()

리팩토링 초기 단계

프로그램의 논리적인 요소를 파악하기 쉽도록 코드의 구조를 보강하는데 주안점을 두고 리팩토링

statement() 함수 쪼개기 : 함수 추출하기

statement코드를 보고 함수로 빼낼만한 코드 -> Switch 문
한번의 공연에 대한 요금을 계산하는 역할

추출할때 생각해야 될 것

  • 유효범위를 벗어나는 변수(새 함수에서 바로 참조하지 못하는 변수 확인)
    • perf: 값을 읽기만 함 -> 매개변수 전달
    • play: 값을 읽기만 함 -> 매개변수 전달
    • thisAmout: 값이 변함 -> 변수 return

함수 추출한 이후 전체 코드 (테스트 코드 포함)

import UIKit
import XCTest

enum MyError: String, Error {
    case gerneError = "알수없는 장르"
}

struct Invoice {
    var customer: String
    var performances : [Performance]
}
struct Performance {
    var playID: String
    var audience: Int
}

struct Play {
    var name: String
    var type: String
}


let plays = [
    Play(name: "Hamlet", type: "tragedy"),
    Play(name: "As You Like It", type: "comedy"),
    Play(name: "Othello", type: "tragedy")
]

let invoice = Invoice(customer: "BigCo", performances: [
    Performance(playID: "hamlet", audience: 55),
    Performance(playID: "As You Like It", audience: 35),
    Performance(playID: "Othello", audience: 40)
])


func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var volumnCredits = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        let play = plays[plays.firstIndex { $0.name == perf.playID } ?? 0]

        let thisAmount: Double = try! amountFor(perf: perf, play: play)

        //포인트를 적립한다.
        volumnCredits += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == play.type {
            volumnCredits += floor(Double(perf.audience)/5.0)
        }
        //청구 내역을 출력한다.
        result += "\(play.name): $\(thisAmount/100) (\(perf.audience)석)\n"
        totalAmout += thisAmount
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}

//switch 문 추출
func amountFor(perf: Performance, play: Play) throws -> Double {
    var thisAmount = 0.0
    switch play.type {
    case "tragedy":
        thisAmount = 40000
        if perf.audience > 30 {
            thisAmount += 10000.0 + 500.0 * Double(perf.audience - 30)
        }
    case "comedy":
        thisAmount = 30000
        if perf.audience > 20 {
            thisAmount += 10000.0 + 500.0 * Double(perf.audience - 20)
        }
        thisAmount += 300.0 * Double(perf.audience)
    default:
        throw MyError.gerneError
    }
    return thisAmount
}

let resultString = try? statement(invoice: invoice, plays: plays)
print(resultString!)

/*
 청구 내역 (고객명BigCo)
 Hamlet: $625.0 (55석)
 As You Like It: $580.0 (35석)
 Othello: $550.0 (40석)
 총액: 1755.0
 적립 포인트: 47.0점
 */


class StatementTestCase: XCTestCase {

    override func tearDown() {

    }

    func testStatement() {
        let answer: String = "청구 내역 (고객명BigCo)\nHamlet: $625.0 (55석)\nAs You Like It: $580.0 (35석)\nOthello: $550.0 (40석)\n총액: 1755.0\n적립 포인트: 47.0점\n"
        let plays = [
            Play(name: "Hamlet", type: "tragedy"),
            Play(name: "As You Like It", type: "comedy"),
            Play(name: "Othello", type: "tragedy")
        ]

        let invoice = Invoice(customer: "BigCo", performances: [
            Performance(playID: "hamlet", audience: 55),
            Performance(playID: "As You Like It", audience: 35),
            Performance(playID: "Othello", audience: 40)
        ])


        let result = try? statement(invoice: invoice, plays: plays)
        XCTAssertEqual(answer, result)
    }
}

StatementTestCase.defaultTestSuite.run()
  • 수정한 이후에는 곧바로 컴파일, 테스트를 통해 변경폭이 작더라도 매번 테스트하는 것이 리팩토링의 핵심이다.
  • 피드백 주기를 짧게 가져가는 습관

테스트 통과

 

* swift도 중첩함수를 지원한다.
- 바깥 함수에서 쓰던 변수를 새로 추출한 함수에 매개변수로 전달할 필요가 없다는 장점이 있다.
- 중첩함수 예시 코드 (접은글)

더보기
func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var volumnCredits = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        let play = plays[plays.firstIndex { $0.name == perf.playID } ?? 0]
        var thisAmount = 0.0
        
        //중첩함수 예시
        func amountFor() throws {
            switch play.type {
            case "tragedy":
                thisAmount = 40000
                if perf.audience > 30 {
                    thisAmount += 10000.0 + 500.0 * Double(perf.audience - 30)
                }
            case "comedy":
                thisAmount = 30000
                if perf.audience > 20 {
                    thisAmount += 10000.0 + 500.0 * Double(perf.audience - 20)
                }
                thisAmount += 300.0 * Double(perf.audience)
            default:
                throw MyError.gerneError
            }
        }

        try! amountFor()

        //포인트를 적립한다.
        volumnCredits += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == play.type {
            volumnCredits += floor(Double(perf.audience)/5.0)
        }
        //청구 내역을 출력한다.
        result += "\(play.name): $\(thisAmount/100) (\(perf.audience)석)\n"
        totalAmout += thisAmount
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}

 

함수를 추출하고 나면 함수를 다시 보면서 지금보다 명확하게 표현할 수 있는 방법이 있는지 검토한다.

  • 변수의 이름
    • thisAmout -> result (함수의 반환값)
    • perf -> aPerformance (매개변수의 역할이 뚜렷하지 않을떄 부정관사(a/an)을 붙임
    • play -> perf변수는 for문을 돌면서 바뀌지만 play는 동일하다. -> 임시 변수를 질의 함수로 바꾼다.
func playFor(aPerformance: Performance) -> Play {
    return plays[plays.firstIndex { $0.name == aPerformance.playID } ?? 0]
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var volumnCredits = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        let play = playFor(aPerformance: perf) //질의 함수로 바꾼다.

        let thisAmount: Double = try! amountFor(aPerformance: perf, play: play)

        //포인트를 적립한다.
        volumnCredits += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == play.type {
            volumnCredits += floor(Double(perf.audience)/5.0)
        }
        //청구 내역을 출력한다.
        result += "\(play.name): $\(thisAmount/100) (\(perf.audience)석)\n"
        totalAmout += thisAmount
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}

변수 인라인 하기를 적용을 통해 statement의 play 로컬 변수 제거

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var volumnCredits = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {

        let thisAmount: Double = try! amountFor(aPerformance: perf, play: playFor(aPerformance: perf))

        //포인트를 적립한다.
        volumnCredits += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == playFor(aPerformance: perf).type {
            volumnCredits += floor(Double(perf.audience)/5.0)
        }
        //청구 내역을 출력한다.
        result += "\(playFor(aPerformance: perf).name): $\(thisAmount/100) (\(perf.audience)석)\n"
        totalAmout += thisAmount
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}


동일하게 amountFor에서의 play함수도 제거한다. (각 단계마다 테스트 실행)

func amountFor(aPerformance: Performance) throws -> Double {
    var result = 0.0
    switch playFor(aPerformance: aPerformance).type {
    case "tragedy":
        result = 40000
        if aPerformance.audience > 30 {
            result += 10000.0 + 500.0 * Double(aPerformance.audience - 30)
        }
    case "comedy":
        result = 30000
        if aPerformance.audience > 20 {
            result += 10000.0 + 500.0 * Double(aPerformance.audience - 20)
        }
        result += 300.0 * Double(aPerformance.audience)
    default:
        throw MyError.gerneError
    }
    return result
}

결과적으로

지역변수를 제거함으로서 함수 추출작업이 쉬워진다 (유효 범위를 신경써야할 대상이 줄어들기 때문)

thisAmount 지역변수도 변수 인라인 하기를 적용한다.

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var volumnCredits = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
		//let thisAmount없앴음
        //포인트를 적립한다.
        volumnCredits += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == playFor(aPerformance: perf).type {
            volumnCredits += floor(Double(perf.audience)/5.0)
        }
        //청구 내역을 출력한다.
        result += "\(playFor(aPerformance: perf).name): $\(try! amountFor(aPerformance: perf)/100) (\(perf.audience)석)\n"
        totalAmout += try! amountFor(aPerformance: perf)
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}

적립 포인트 계산코드 추출하기

+ 변수 이름 적절히 바꾸기 -> result

func volumnCreditsFor(perf: Performance) -> Double{
    var volumnCredits = 0.0
    volumnCredits += max(Double(perf.audience) - 30.0, 0.0)
    //희극 관객 5명 마다 추가 포인트를 제공한다.
    if "comedy" == playFor(aPerformance: perf).type {
        volumnCredits += floor(Double(perf.audience)/5.0)
    }
    return volumnCredits
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var volumnCredits = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        //포인트를 적립한다.
        volumnCredits += volumnCreditsFor(perf: perf) //추출 함수를 이용해 값을 누적
        //청구 내역을 출력한다.
        result += "\(playFor(aPerformance: perf).name): $\(try! amountFor(aPerformance: perf)/100) (\(perf.audience)석)\n"
        totalAmout += try! amountFor(aPerformance: perf)
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}

 

*format함수 내용 생략

volumnCredits 변수 제거하기

문장 슬라이드하기: 코드의 위치를 변경

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        //청구 내역을 출력한다.
        result += "\(playFor(aPerformance: perf).name): $\(try! amountFor(aPerformance: perf)/100) (\(perf.audience)석)\n"
        totalAmout += try! amountFor(aPerformance: perf)
    }

    var volumnCredits = 0.0 // <- (문장 슬라이드 하기)
    for perf in invoice.performances { // <- 별도 for문으로 분리한다. (반복문 쪼개기)
        //포인트를 적립한다.
        volumnCredits += volumnCreditsFor(perf: perf) //추출 함수를 이용해 값을 누적
    }
    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(volumnCredits)점\n"
    return result
}

이후에 volumnCredits 값 계산 코드를 함수로 추출하고 인라인 적용한다.

인라인하기: 라인 안으로 들어오게

func totalVolumnCredits() -> Double {
    var volumnCredits = 0.0
    for perf in invoice.performances {
        //포인트를 적립한다.
        volumnCredits += volumnCreditsFor(perf: perf) //추출 함수를 이용해 값을 누적
    }
    return volumnCredits
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var totalAmout = 0.0
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        //청구 내역을 출력한다.
        result += "\(playFor(aPerformance: perf).name): $\(try! amountFor(aPerformance: perf)/100) (\(perf.audience)석)\n"
        totalAmout += try! amountFor(aPerformance: perf)
    }

    result += "총액: \(totalAmout/100.0)\n"
    result += "적립 포인트: \(totalVolumnCredits())점\n" //<- 함수를 변수 인라인 처리
    return result
}

반복문을 쪼개서 성능이 느려질 수 있지 않을까 걱정될 수 있는데 영향은 미미하다.

저자는 리팩터링 과정에서 성능은 신경쓰지 않고 성능이 떨어졌다면 리팩터링 후에 성능을 개선한다.

적용한 방법을 정리하자면

  1. 반복문 쪼개기
  2. 문장 슬라이드 하기
  3. 함수 추출하기
  4. 변수 인라인 하기

totalAmout 변수도 위와 같은 절차로 제거한다

func totalAmount() -> Double {
    var totalAmout = 0.0
    for perf in invoice.performances {
        totalAmout += try! amountFor(aPerformance: perf)
    }
    return totalAmout
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        //청구 내역을 출력한다.
        result += "\(playFor(aPerformance: perf).name): $\(try! amountFor(aPerformance: perf)/100) (\(perf.audience)석)\n"
    }

    result += "총액: \(totalAmount()/100.0)\n"
    result += "적립 포인트: \(totalVolumnCredits())점\n" //<- 함수를 변수 인라인 처리
    return result
}

중간점검: 난무하는 중첩 함수

지금까지 리팩터링한 코드

더보기
import UIKit
import XCTest

enum MyError: String, Error {
    case gerneError = "알수없는 장르"
}

struct Invoice {
    var customer: String
    var performances : [Performance]
}
struct Performance {
    var playID: String
    var audience: Int
}

struct Play {
    var name: String
    var type: String
}


func statement(invoice: Invoice, plays: [Play]) throws -> String {

    func totalAmount() -> Double {
        var totalAmout = 0.0
        for perf in invoice.performances {
            totalAmout += try! amountFor(aPerformance: perf)
        }
        return totalAmout
    }

    var result = "청구 내역 (고객명\(invoice.customer))\n"

    for perf in invoice.performances {
        //청구 내역을 출력한다.
        result += "\(playFor(aPerformance: perf).name): $\(try! amountFor(aPerformance: perf)/100) (\(perf.audience)석)\n"
    }

    result += "총액: \(totalAmount()/100.0)\n"
    result += "적립 포인트: \(totalVolumnCredits())점\n" //<- 함수를 변수 인라인 처리

    func playFor(aPerformance: Performance) -> Play {
        return plays[plays.firstIndex { $0.name == aPerformance.playID } ?? 0]
    }


    func totalVolumnCredits() -> Double {
        var result = 0.0
        for perf in invoice.performances {
            //포인트를 적립한다.
            result += volumnCreditsFor(perf: perf) //추출 함수를 이용해 값을 누적
        }
        return result
    }


    func amountFor(aPerformance: Performance) throws -> Double {
        var result = 0.0
        switch playFor(aPerformance: aPerformance).type {
        case "tragedy":
            result = 40000
            if aPerformance.audience > 30 {
                result += 10000.0 + 500.0 * Double(aPerformance.audience - 30)
            }
        case "comedy":
            result = 30000
            if aPerformance.audience > 20 {
                result += 10000.0 + 500.0 * Double(aPerformance.audience - 20)
            }
            result += 300.0 * Double(aPerformance.audience)
        default:
            throw MyError.gerneError
        }
        return result
    }

    func volumnCreditsFor(perf: Performance) -> Double{
        var result = 0.0
        result += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == playFor(aPerformance: perf).type {
            result += floor(Double(perf.audience)/5.0)
        }
        return result
    }

    return result
}

class RefactoringStatement {

    let plays = [
        Play(name: "Hamlet", type: "tragedy"),
        Play(name: "As You Like It", type: "comedy"),
        Play(name: "Othello", type: "tragedy")
    ]

    let invoice = Invoice(customer: "BigCo", performances: [
        Performance(playID: "hamlet", audience: 55),
        Performance(playID: "As You Like It", audience: 35),
        Performance(playID: "Othello", audience: 40)
    ])

    init() {
        let resultString = try? statement(invoice: invoice, plays: plays)
        print(resultString!)
    }

}

RefactoringStatement()



/*
 청구 내역 (고객명BigCo)
 Hamlet: $625.0 (55석)
 As You Like It: $580.0 (35석)
 Othello: $550.0 (40석)
 총액: 1755.0
 적립 포인트: 47.0점
 */


class StatementTestCase: XCTestCase {

    override func tearDown() {

    }

    func testStatement() {
        let answer: String = "청구 내역 (고객명BigCo)\nHamlet: $625.0 (55석)\nAs You Like It: $580.0 (35석)\nOthello: $550.0 (40석)\n총액: 1755.0\n적립 포인트: 47.0점\n"
        let plays = [
            Play(name: "Hamlet", type: "tragedy"),
            Play(name: "As You Like It", type: "comedy"),
            Play(name: "Othello", type: "tragedy")
        ]

        let invoice = Invoice(customer: "BigCo", performances: [
            Performance(playID: "hamlet", audience: 55),
            Performance(playID: "As You Like It", audience: 35),
            Performance(playID: "Othello", audience: 40)
        ])


        let result = try? statement(invoice: invoice, plays: plays)
        XCTAssertEqual(answer, result)
    }
}

StatementTestCase.defaultTestSuite.run()

statement()의 HTML 버전을 만드는 작업

  • 기존 statement는 string을 출력
  • HTML을 출력하는 statement 함수를 만들고 싶음
  • 단계 쪼개기

단계 쪼개기를 통해 statement()로직을 두 단계로 쪼갠다.

  1. 필요한 데이터를 처리한다.
  2. 앞서 처리한 결과를 텍스트나 HTML로 표현한다.

두 단계로 나눈다.

필요한 데이터를 처리하는 statement에 계산에 필요했던 중첩함수들을 옮기고 renderrPlainText에는 텍스트만 만드는 코드를 가지도록 수정한다.

이때 지역변수로 공유되고 invoice, plays 매개변수를 사용해서 텍스트를 만들게 되는데 이를 StatementData 형태로 만들어 data 매개변수 하나만 전달해도 되도록 수정한다.

또한 statement 함수에서 StatementData를 만드는데 필요한 코드들을 createStatementData 함수로 빼준다.

수정 후 코드

import UIKit
import XCTest

enum MyError: String, Error {
    case gerneError = "알수없는 장르"
}

struct Invoice {
    var customer: String
    var performances : [Performance]
}

struct Performance {
     let playID: String
     let audience: Int
     var play: Play?
     var amount: Double?
     var volumeCredits: Double?
 }
struct Play {
    var name: String
    var type: String
}
struct StatementData {
    var customer: String = ""
    var performances: [Performance] = []
    var totalAmount: Double = 0.0
    var totalVolumCredits: Double = 0.0
    var play: Play = Play(name: "", type: "")
}

func createStatementData(invoice: Invoice, plays: [Play]) throws -> StatementData {

    func enrichPerformance(aPerformance: Performance) throws -> Performance {
        var result = aPerformance
        result.play = playFor(aPerformance: aPerformance)
        result.amount = try! amountFor(aPerformance: result)
        result.volumeCredits = volumnCreditsFor(perf: result)
        return result
    }

    func playFor(aPerformance: Performance) -> Play {
        return plays[plays.firstIndex { $0.name == aPerformance.playID } ?? 0]
    }

    func totalAmount(performances: [Performance]) -> Double {
        var totalAmout = 0.0
        for perf in performances {
            totalAmout += try! amountFor(aPerformance: perf)
        }
        return totalAmout
    }

    func totalVolumnCredits(performances: [Performance]) -> Double {
        var result = 0.0
        for perf in performances {
            //포인트를 적립한다.
            result += volumnCreditsFor(perf: perf) //추출 함수를 이용해 값을 누적
        }
        return result
    }

    func volumnCreditsFor(perf: Performance) -> Double{
        var result = 0.0
        result += max(Double(perf.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == perf.play?.type {
            result += floor(Double(perf.audience)/5.0)
        }
        return result
    }

    func amountFor(aPerformance: Performance) throws -> Double {
        var result = 0.0
        guard let play = aPerformance.play else {
            print("nil")
            return 0.0
        }
        switch play.type {
        case "tragedy":
            result = 40000
            if aPerformance.audience > 30 {
                result += 10000.0 + 500.0 * Double(aPerformance.audience - 30)
            }
        case "comedy":
            result = 30000
            if aPerformance.audience > 20 {
                result += 10000.0 + 500.0 * Double(aPerformance.audience - 20)
            }
            result += 300.0 * Double(aPerformance.audience)
        default:
            throw MyError.gerneError
        }
        return result
    }

    var statementData = StatementData()
    statementData.customer = invoice.customer
    statementData.performances = invoice.performances.compactMap { try? enrichPerformance(aPerformance: $0) }
    statementData.totalAmount = totalAmount(performances: statementData.performances)
    statementData.totalVolumCredits = totalVolumnCredits(performances: statementData.performances)

    return statementData
}

func renderPlainText(data: StatementData) -> String {

    var result = "청구 내역 (고객명\(data.customer))\n"

    for perf in data.performances {
        //청구 내역을 출력한다.
        result += "\(perf.play!.name): $\(perf.amount!/100) (\(perf.audience)석)\n"
    }

    result += "총액: \(data.totalAmount/100.0)\n"
    result += "적립 포인트: \(data.totalVolumCredits)점\n"

    return result
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    return renderPlainText(data: try createStatementData(invoice: invoice, plays: plays))
}


class RefactoringStatement {

    let plays = [
        Play(name: "Hamlet", type: "tragedy"),
        Play(name: "As You Like It", type: "comedy"),
        Play(name: "Othello", type: "tragedy")
    ]

    let invoice = Invoice(customer: "BigCo", performances: [
        Performance(playID: "hamlet", audience: 55),
        Performance(playID: "As You Like It", audience: 35),
        Performance(playID: "Othello", audience: 40)
    ])

    init() {
        let resultString = try? statement(invoice: invoice, plays: plays)
        print(resultString!)
    }

}

RefactoringStatement()



/*
 청구 내역 (고객명BigCo)
 Hamlet: $625.0 (55석)
 As You Like It: $580.0 (35석)
 Othello: $550.0 (40석)
 총액: 1755.0
 적립 포인트: 47.0점
 */


class StatementTestCase: XCTestCase {

    override func tearDown() {

    }

    func testStatement() {
        let answer: String = "청구 내역 (고객명BigCo)\nHamlet: $625.0 (55석)\nAs You Like It: $580.0 (35석)\nOthello: $550.0 (40석)\n총액: 1755.0\n적립 포인트: 47.0점\n"
        let plays = [
            Play(name: "Hamlet", type: "tragedy"),
            Play(name: "As You Like It", type: "comedy"),
            Play(name: "Othello", type: "tragedy")
        ]

        let invoice = Invoice(customer: "BigCo", performances: [
            Performance(playID: "hamlet", audience: 55),
            Performance(playID: "As You Like It", audience: 35),
            Performance(playID: "Othello", audience: 40)
        ])


        let result = try? statement(invoice: invoice, plays: plays)
        XCTAssertEqual(answer, result)
    }
}

StatementTestCase.defaultTestSuite.run()

이 상태에서 HTML 버전의 코드를 추가한다면


다형성을 활용해 계산 코드 재구성

현재 코드에서 연극 장르를 추가하고 장르마다 공연료와 적립 포인트 계산법을 다르게 지정하도록 하는 기능을 수정해본다.

amountFor 함수를 보면 장르에 따라 계산방식이 달라지는데 이런 조건부 로직은 다른 구조적인 요소로 보완하는게 좋다.

이를 위해 다형성을 사용하여 희극, 비극과 같은 장르 서브 클래스가 각자의 계산 로직을 가지고 있게 하여 호출하는 쪽에서는 다형성 버전(슈퍼클래스)의 계산 함수를 호출하기만 하면 되도록 만든다.

이름하여 조건부 로직을 다형성으로 바꾸기

계산에 관련된 함수인 amountFor과 volumnCreditFor 함수를 장르 서브클래스로 옮겨준다.

import UIKit
import XCTest

enum MyError: String, Error {
    case gerneError = "알수없는 장르"
}

struct Invoice {
    var customer: String
    var performances : [Performance]
}

struct Performance {
     let playID: String
     let audience: Int
     var play: Play?
     var amount: Double?
     var volumeCredits: Double?
 }

struct Play {
    var name: String
    var type: String
}

struct StatementData {
    var customer: String = ""
    var performances: [Performance] = []
    var totalAmount: Double = 0.0
    var totalVolumCredits: Double = 0.0
    var play: Play = Play(name: "", type: "")
}

class PerformanceCalculator {
    let performance: Performance
    let play: Play

    init(aPerformance: Performance, aPlay: Play) {
        self.performance = aPerformance
        self.play = aPlay
    }

    func amountFor() throws -> Double {
        var result = 0.0
        guard let play = performance.play else {
            return 0.0
        }
        switch play.type {
        case "tragedy":
            result = 40000
            if performance.audience > 30 {
                result += 10000.0 + 500.0 * Double(performance.audience - 30)
            }
        case "comedy":
            result = 30000
            if performance.audience > 20 {
                result += 10000.0 + 500.0 * Double(performance.audience - 20)
            }
            result += 300.0 * Double(performance.audience)
        default:
            throw MyError.gerneError
        }
        return result
    }

    func volumnCreditsFor() -> Double{
        var result = 0.0
        result += max(Double(performance.audience) - 30.0, 0.0)
        //희극 관객 5명 마다 추가 포인트를 제공한다.
        if "comedy" == performance.play?.type {
            result += floor(Double(performance.audience)/5.0)
        }
        return result
    }

}

func createStatementData(invoice: Invoice, plays: [Play]) throws -> StatementData {

    func enrichPerformance(aPerformance: Performance) throws -> Performance {
        var result = aPerformance
        result.play = playFor(aPerformance: aPerformance)
        let calculator = PerformanceCalculator(aPerformance: result, aPlay: playFor(aPerformance: result))
        result.amount = try! calculator.amountFor()
        result.volumeCredits = calculator.volumnCreditsFor()
        return result
    }

    func playFor(aPerformance: Performance) -> Play {
        return plays[plays.firstIndex { $0.name == aPerformance.playID } ?? 0]
    }

    func totalAmount(performances: [Performance]) throws -> Double {
        var totalAmout = 0.0
        for perf in performances {
            totalAmout += amountFor(aPerformance: perf)
        }
        return totalAmout
    }

    func totalVolumnCredits(performances: [Performance]) -> Double {
        var result = 0.0
        for perf in performances {
            //포인트를 적립한다.
            result += volumnCreditsFor(perf: perf) //추출 함수를 이용해 값을 누적
        }
        return result
    }


    func volumnCreditsFor(perf: Performance) -> Double {
        return PerformanceCalculator(aPerformance: perf, aPlay: playFor(aPerformance: perf)).volumnCreditsFor()
    }

    func amountFor(aPerformance: Performance) -> Double {
        return try! PerformanceCalculator(aPerformance: aPerformance, aPlay: playFor(aPerformance: aPerformance)).amountFor()
    }

    var statementData = StatementData()
    statementData.customer = invoice.customer
    statementData.performances = invoice.performances.compactMap { try? enrichPerformance(aPerformance: $0) }
    statementData.totalAmount = try totalAmount(performances: statementData.performances)
    statementData.totalVolumCredits = totalVolumnCredits(performances: statementData.performances)

    return statementData
}

func renderPlainText(data: StatementData) -> String {

    var result = "청구 내역 (고객명\(data.customer))\n"

    for perf in data.performances {
        //청구 내역을 출력한다.
        result += "\(perf.play!.name): $\(perf.amount!/100) (\(perf.audience)석)\n"
    }

    result += "총액: \(data.totalAmount/100.0)\n"
    result += "적립 포인트: \(data.totalVolumCredits)점\n"

    return result
}

func renderHTMLText(data: StatementData) -> String {
    var result = "<h1>청구내역 (고객명: \(data.customer)) </h1>]n"
    result += "<table>\n"
    result += "<tr><th>연극</th><th>좌석 수</th><th>금액</th></tr>"
    for perf in data.performances {
        result += " <tr><td>\(perf.play!.name)</td><td>\(perf.audience)석</td>"
        // 생략
    }
    //생략
    return result
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    return renderPlainText(data: try createStatementData(invoice: invoice, plays: plays))
//    return renderHTMLText(data: try createStatementData(invoice: invoice, plays: plays))

}


class RefactoringStatement {

    let plays = [
        Play(name: "Hamlet", type: "tragedy"),
        Play(name: "As You Like It", type: "comedy"),
        Play(name: "Othello", type: "tragedy")
    ]

    let invoice = Invoice(customer: "BigCo", performances: [
        Performance(playID: "hamlet", audience: 55),
        Performance(playID: "As You Like It", audience: 35),
        Performance(playID: "Othello", audience: 40)
    ])

    init() {
        let resultString = try? statement(invoice: invoice, plays: plays)
        print(resultString!)
    }

}

RefactoringStatement()



/*
 청구 내역 (고객명BigCo)
 Hamlet: $625.0 (55석)
 As You Like It: $580.0 (35석)
 Othello: $550.0 (40석)
 총액: 1755.0
 적립 포인트: 47.0점
 */


class StatementTestCase: XCTestCase {

    override func tearDown() {

    }

    func testStatement() {
        let answer: String = "청구 내역 (고객명BigCo)\nHamlet: $625.0 (55석)\nAs You Like It: $580.0 (35석)\nOthello: $550.0 (40석)\n총액: 1755.0\n적립 포인트: 47.0점\n"
        let plays = [
            Play(name: "Hamlet", type: "tragedy"),
            Play(name: "As You Like It", type: "comedy"),
            Play(name: "Othello", type: "tragedy")
        ]

        let invoice = Invoice(customer: "BigCo", performances: [
            Performance(playID: "hamlet", audience: 55),
            Performance(playID: "As You Like It", audience: 35),
            Performance(playID: "Othello", audience: 40)
        ])


        let result = try? statement(invoice: invoice, plays: plays)
        XCTAssertEqual(answer, result)
    }
}

StatementTestCase.defaultTestSuite.run()

 

옮겼으니 계산기를 다형성 버전으로 만든다.

타입 코드 대신 서브클래스를 이용한다.

PerformanceCalculator 서브클래스를 준비하고 createStatementData()에서 적합한 서브 클래스를 사용하게 만든다.

import UIKit
import XCTest

enum MyError: String, Error {
    case gerneError = "알수없는 장르"
}

struct Invoice {
    var customer: String
    var performances : [Performance]
}

struct Performance {
    let playID: String
    let audience: Int
    var play: Play?
    var amount: Double?
    var volumeCredits: Double?
}

struct Play {
    var name: String
    var type: String
}

struct StatementData {
    var customer: String = ""
    var performances: [Performance] = []
    var totalAmount: Double = 0.0
    var totalVolumCredits: Double = 0.0
    var play: Play = Play(name: "", type: "")
}

class PerformanceCalculator {
    let performance: Performance
    let play: Play

    init(aPerformance: Performance, aPlay: Play) {
        self.performance = aPerformance
        self.play = aPlay
    }

    func amountFor() throws -> Double {
        fatalError()
    }

    func volumnCreditsFor() -> Double {
        return max(Double(performance.audience) - 30, 0)
    }
}

class TragedyCalculator: PerformanceCalculator {
    override func amountFor() throws -> Double {
        var result = 0.0
        result = 40000
        if performance.audience > 30 {
            result += 10000.0 + 500.0 * Double(performance.audience - 30)
        }
        return result
    }

}

class ComedyCalculator: PerformanceCalculator {
    override func amountFor() throws -> Double {
        var result = 0.0
         result = 30000
        if performance.audience > 20 {
            result += 10000.0 + 500.0 * Double(performance.audience - 20)
        }
        result += 300.0 * Double(performance.audience)
        return result
    }

    override func volumnCreditsFor() -> Double {
        return super.volumnCreditsFor() + floor(Double(performance.audience) / 5.0)
    }
}

func createStatementData(invoice: Invoice, plays: [Play]) throws -> StatementData {

    func enrichPerformance(aPerformance: Performance) throws -> Performance {
        var result = aPerformance
        result.play = playFor(aPerformance: aPerformance)
        let calculator = createPerformanceCalculator(aPerformance: result, aPlay: playFor(aPerformance: result))
        result.amount = try! calculator.amountFor()
        result.volumeCredits = calculator.volumnCreditsFor()
        return result
    }

    func createPerformanceCalculator(aPerformance: Performance, aPlay: Play) ->  PerformanceCalculator {
        switch aPlay.type {
        case "tragedy": return TragedyCalculator(aPerformance: aPerformance, aPlay: aPlay)
        case "comedy": return ComedyCalculator(aPerformance: aPerformance, aPlay: aPlay)
        default:
            fatalError()
        }
    }

    func playFor(aPerformance: Performance) -> Play {
        return plays[plays.firstIndex { $0.name == aPerformance.playID } ?? 0]
    }

    func totalAmount(performances: [Performance]) throws -> Double {
        var totalAmout = 0.0
        for perf in performances {
            totalAmout += amountFor(aPerformance: perf)
        }
        return totalAmout
    }

    func totalVolumnCredits(performances: [Performance]) -> Double {
        var result = 0.0
        for perf in performances {
            //포인트를 적립한다.
            result += volumnCreditsFor(perf: perf) //추출 함수를 이용해 값을 누적
        }
        return result
    }


    func volumnCreditsFor(perf: Performance) -> Double {
        return createPerformanceCalculator(aPerformance: perf, aPlay: playFor(aPerformance: perf)).volumnCreditsFor()
    }

    func amountFor(aPerformance: Performance) -> Double {
        return try! createPerformanceCalculator(aPerformance: aPerformance, aPlay: playFor(aPerformance: aPerformance)).amountFor()
    }

    var statementData = StatementData()
    statementData.customer = invoice.customer
    statementData.performances = invoice.performances.compactMap { try? enrichPerformance(aPerformance: $0) }
    statementData.totalAmount = try totalAmount(performances: statementData.performances)
    statementData.totalVolumCredits = totalVolumnCredits(performances: statementData.performances)

    return statementData
}

func renderPlainText(data: StatementData) -> String {

    var result = "청구 내역 (고객명\(data.customer))\n"

    for perf in data.performances {
        //청구 내역을 출력한다.
        result += "\(perf.play!.name): $\(perf.amount!/100) (\(perf.audience)석)\n"
    }

    result += "총액: \(data.totalAmount/100.0)\n"
    result += "적립 포인트: \(data.totalVolumCredits)점\n"

    return result
}

func renderHTMLText(data: StatementData) -> String {
    var result = "<h1>청구내역 (고객명: \(data.customer)) </h1>]n"
    result += "<table>\n"
    result += "<tr><th>연극</th><th>좌석 수</th><th>금액</th></tr>"
    for perf in data.performances {
        result += " <tr><td>\(perf.play!.name)</td><td>\(perf.audience)석</td>"
        // 생략
    }
    //생략
    return result
}

func statement(invoice: Invoice, plays: [Play]) throws -> String {
    return renderPlainText(data: try createStatementData(invoice: invoice, plays: plays))
    //    return renderHTMLText(data: try createStatementData(invoice: invoice, plays: plays))

}


class RefactoringStatement {

    let plays = [
        Play(name: "Hamlet", type: "tragedy"),
        Play(name: "As You Like It", type: "comedy"),
        Play(name: "Othello", type: "tragedy")
    ]

    let invoice = Invoice(customer: "BigCo", performances: [
        Performance(playID: "hamlet", audience: 55),
        Performance(playID: "As You Like It", audience: 35),
        Performance(playID: "Othello", audience: 40)
    ])

    init() {
        let resultString = try? statement(invoice: invoice, plays: plays)
        print(resultString!)
    }

}

RefactoringStatement()



/*
 청구 내역 (고객명BigCo)
 Hamlet: $625.0 (55석)
 As You Like It: $580.0 (35석)
 Othello: $550.0 (40석)
 총액: 1755.0
 적립 포인트: 47.0점
 */


class StatementTestCase: XCTestCase {

    override func tearDown() {

    }

    func testStatement() {
        let answer: String = "청구 내역 (고객명BigCo)\nHamlet: $625.0 (55석)\nAs You Like It: $580.0 (35석)\nOthello: $550.0 (40석)\n총액: 1755.0\n적립 포인트: 47.0점\n"
        let plays = [
            Play(name: "Hamlet", type: "tragedy"),
            Play(name: "As You Like It", type: "comedy"),
            Play(name: "Othello", type: "tragedy")
        ]

        let invoice = Invoice(customer: "BigCo", performances: [
            Performance(playID: "hamlet", audience: 55),
            Performance(playID: "As You Like It", audience: 35),
            Performance(playID: "Othello", audience: 40)
        ])


        let result = try? statement(invoice: invoice, plays: plays)
        XCTAssertEqual(answer, result)
    }
}

StatementTestCase.defaultTestSuite.run()

 

수정하기 쉬운 코드가 좋은 코드이다.

자바스크립트 예제 코드를 어거지로 Swift 로 바꿔서 swift 스타일의 좋은 코드는 아닌 것 같다.

예를들어 다형성으로 계산 로직을 각각의 서브클래스에 할당할 때 프로토콜로 만들어도 되고 또 중첩함수를 잘 사용하지 않는데 예제에서는 많이 나와서 코드 가독성이 떨어졌다.

책이 세세할 정도로 과정을 하나씩 담고 있는데 마지막에 예기하는 것 처럼 1장에서 가장 중요한 것은 

리팩토링하는 리듬이다.

리팩토링을 효과적으로 하려면 단계를 잘게 나눠야 빠르게 처리할 수 있다고 말한다.

 

728x90

댓글