LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


템플릿 메서드 패턴으로 모순 없는 상태 보장하기

시작하기 전에

안녕하세요. LINE Pay의 iOS 개발을 맡고 있는 정지인입니다. LINE Pay iOS의 결제 기능을 리팩토링하는 데에 적용했던 템플릿 메서드 패턴을 이용한 계약 기반 프로그래밍 기법에 대해 소개하려고 합니다. 템플릿 메서드 패턴을 이용하면 복잡한 상태의 아이템을 모순적인 상태에 빠뜨리는 일 없이 구현할 수 있습니다. 템플릿 메서드 패턴에 대해 간단하게 소개하고, 템플릿 메서드 패턴을 상태 관리에 어떻게 활용할 수 있는지 코드 레벨에서 살펴보겠습니다. iOS 개발자뿐만 아니라 복잡한 상태 처리 때문에 유지 보수에 어려움을 겪는 모든 개발자에게 도움이 되었으면 하는 바람입니다.

이 글에서는 템플릿 메서드 패턴을 소개하고, 리팩토링의 대상이 되는 결제 기능에 관해 설명한 뒤, 리팩토링 전과 후의 구현 방식을 각각 자세히 설명하는 순서로 진행하겠습니다.

템플릿 메서드 패턴이란?

템플릿 메서드 패턴은 객체지향 디자인 패턴 중 하나로, 기능의 뼈대(템플릿)와 실제 구현을 분리하는 패턴입니다.

그림1. 템플릿 메서드 패턴

상위 클래스에서 뼈대 역할에 해당하는 템플릿 메서드를 정의합니다. 템플릿 메서드는 기능의 뼈대이자 계약 조건을 구현하고, 이 계약 조건에 포함된 각 메서드의 구체적인 구현은 하위 클래스가 담당합니다. 하위 클래스는 상위 클래스가 정의한 메서드의 구현만 맡아서 하기 때문에 각 메서드가 어떤 계약으로 동작하는지 알 수 없고, 계약을 변경할 수도 없습니다. 단 하나의 뼈대 로직을 기반으로 기능별 복잡한 로직은 하위 클래스에 위임할 수 있습니다. 다른 말로 하자면, 템플릿 메서드 패턴은 알고리즘을 상위 클래스에서 정의하는 패턴입니다.

용어 소개

템플릿 메서드 패턴의 이해를 돕는 용어를 간단히 설명하겠습니다.

  • hook 메서드
    • 추상 클래스에서 선언하며, 기본적인 내용만 구현하거나 비워놓는 메서드입니다.
    • 하위 클래스에서 오버라이딩하면 다양한 용도로 사용할 수 있습니다.
  • 의존성 부패(Dependency Rot)
    • 의존성이 얽히고설켜 복잡하게 꼬여 있는 상황을 말합니다.

템플릿 메서드 패턴의 특징

템플릿 메서드 패턴은 다음과 같은 특징이 있습니다.

  • 중복을 줄이고, 코드의 재사용성을 확보할 수 있습니다.
  • 핵심 알고리즘의 관리가 쉽습니다.
  • 추상 메서드 재정의를 통한 서브 클래스 확장에 유리합니다.
  • 상위 클래스의 메서드만 보더라도 전체 동작을 이해하기 쉽습니다.
  • 상위 클래스에서 선언된 메서드를 하위 클래스에 구현할 때 그 메서드가 어느 타이밍에 호출되는지 이해하고 있어야 합니다. 상위 클래스의 코드를 볼 수 없으면 구현이 어려울 수 있습니다.
  • 상위 클래스에서 기술을 많이 하면 하위 클래스의 자유도가 줄어듭니다. 반대로 상위 클래스에서 기술을 적게 하면 하위 클래스에서 모두 재정의해야 하기 때문에 복잡도가 증가하고, 코드의 중복이 생길 수 있습니다.

이 글에서는 마지막 특징, 상위 클래스에서 기술을 많이 하면 하위 클래스의 자유도를 제한할 수 있다는 점을 장점으로 활용할 겁니다.

전략 패턴과의 비교

전략 패턴과 템플릿 메서드 패턴은 사용 목적으로 확연히 구분할 수 있습니다. 전략 패턴은 알고리즘을 숨기고, 쉽게 교체할 수 있게 만드는 데에 목적이 있습니다. 반면 템플릿 메서드 패턴은 알고리즘의 특정 단계만 제어하고 싶을 때 사용합니다.

LINE Pay의 결제 기능 소개

템플릿 메서드 패턴을 활용한 리팩토링 과정을 설명하기 전에, 먼저 리팩토링 대상인 LINE Pay 결제 기능에 대해 살펴보겠습니다.

결제 흐름

LINE Pay 결제 기능의 흐름은 다음과 같습니다.

그림2. LINE Pay 결제 화면

1. 결제 화면으로 이동

사용자가 오프라인 가맹점에서 QR 코드를 스캔하거나, 온라인 가맹점에서 LINE Pay로 결제하기를 누르면 결제화면으로 이동합니다.

2. 결제 수단 선택

결제 화면에서 결제 수단을 선택합니다. 결제 수단은 주 결제 수단과 부 결제 수단으로 나뉩니다. 주 결제 수단을 먼저 선택합니다. 그림2에서 ⓶로 표시된 라디오 버튼을 눌러 선택할 수 있습니다.

  • 밸런스: LINE Pay만의 계좌/통장입니다. 밸런스를 충전해 결제에 사용할 수 있습니다.
  • 카드
    • 일반 신용카드
    • 충전 & 결제 카드
  • 은행 계좌: LINE Pay와 은행 계좌를 연동해 잔고로 결제할 수 있습니다.

주 결제 수단과 함께 부 결제 수단을 선택할 수 있습니다. 부 결제 수단은 세 가지가 있습니다.

  • 포인트
  • 쿠폰
  • LINK (암호 화폐)

3. 결제 진행

사용자가 유효한 결제 수단을 선택하면 그림2에서 의 결제 버튼이 활성화되어 클릭할 수 있습니다. 결제 버튼을 눌러 결제를 진행합니다.

주 결제 수단의 상태

이번 리팩토링은 LINE Pay 결제 기능 중에서도 주 결제 수단의 상태(state)를 결정하는 로직과 관련이 있습니다. 앞서 설명한 주 결제 수단인 밸런스, 카드, 은행 계좌는 다음의 5가지 상태 중 하나의 상태입니다.

  1. 등록 필요(needRegister)
  2. 결제 수단 활성화(enabled)
  3. 선택 버튼 표시(visible)
  4. 선택 가능(selectable)
  5. 선택됨(checked)

어떤 결제 수단인지에 따라 상태를 결정하는 조건이 다릅니다. 각 결제 수단의 상태를 결정하는 조건을 표1에 정리했습니다. 한 칸에 여러 개의 조건이 나열된 경우, 모든 조건을 만족해야 합니다.

표1. 주 결제 수단의 상태 조건
상태
밸런스(balance)
카드(card)
은행 계좌(bank)

등록 필요
(needRegister)

  • 본인 인증 필요
  • 카드 미등록
  • 본인 인증 필요

결제 수단 활성화
(enabled)

  • 포인트/LINK/쿠폰 전액 결제가 아님
  • 일반 결제(payment type)
  • 0원 결제
  • LINE Bank와 연동 에러 없음
  • 등록 필요(needRegister) 조건 만족하지 않음
  • 포인트/LINK/쿠폰 전액 결제가 아님
  • 일반 결제(payment type)
  • 0원 결제
  • 등록 필요(needRegister) 조건 만족하지 않음
  • 포인트/LINK/쿠폰 전액 결제가 아님
  • 일반 결제(payment type)
  • 0원 결제
  • 등록 필요(needRegister) 조건 만족하지 않음

선택 버튼 표시
(visible)

  • 다중 결제 수단의 정기 결제(multiRegKey)가 아님
  • 결제 수단 활성화(enabled) 조건을 만족
  • 다중 결제 수단의 정기 결제(multiRegKey)가 아님

선택 가능
(selectable)

  • 보유 밸런스가 음수(minus balance)가 아님
  • 보유 밸런스로 결제 가능
  • 선택 버튼 표시(visible) 조건을 만족
  • 카드 소유자 이름(holder name)이 등록됨
  • 선택 버튼 표시(visible) 조건을 만족
  • 신용카드인 경우, 가맹점에서 지원하는 카드 브랜드임
  • 은행이 운영중(alive)
  • 선택 버튼 표시(visible) 조건을 만족

선택됨
(checked)

  • 사용자가 선택함
  • 선택 가능(selectable) 조건을 만족
  • 사용자가 선택함
  • 선택 가능(selectable) 조건을 만족
  • 사용자가 선택함
  • 선택 가능(selectable) 조건을 만족

기존 구현 방식

기존 구현 내용

결제 수단 뷰 그리기

그림3. 결제 수단

결제 수단의 상태에 따라 결제 수단의 뷰를 다르게 그려야 합니다. 이를 위해 결제 수단의 상태를 조회하는 메서드가 결제 수단 별로 있고, 모두 같은 클래스 안에 파편화되어 있습니다.

serverMethods.foreach { method in
    switch payMethod.type {
    case .balance: // 밸런스 뷰 그리기
        let needRegister = isNeedRegisterBalance()
        let enabled = isEnabledBalance()
        let visible = isVisibleBalance()
        let selectable = isSelectableBalance()
        let checked = isCheckedBalance()
       let balance = BalanceMethod(needRegister, enabled, visible, selectable, checked)
        showBalanceMethod(balance)
    case .creditCard: // 신용카드 뷰 그리기
        let needRegister = isNeedRegisterCard()
        let enabled = isEnabledCard()
        let visible = isVisibleCard()
        let selectable = isSelectableCard()
        let checked = isCheckedCard()
       let card = CreditCardMethod(needRegister, enabled, visible, selectable, checked)
        showCardMethod(card)   
    case .topupPay: // 충전&결제 카드 뷰 그리기
        let needRegister = isNeedRegisterCard()
        let enabled = isEnabledCard()
        let visible = isVisibleCard()
        let selectable = isSelectableCard()
        let checked = isCheckedCard()
       let card = TopupPayCardMethod(needRegister, enabled, visible, selectable, checked)
        showCardMethod(card)
    case .debit: // 은행 계좌 뷰 그리기
        let needRegister = isNeedRegisterDebit()
        let enabled = isEnabledDebit()
        let visible = isVisibleDebit()
        let selectable = isSelectableDebit()
        let checked = isCheckedDebit()
       let debit = DebitPayMethod(needRegister, enabled, visible, selectable, checked)
        showDebitPayMethod(debit)
    }
}

결제 수단 선택 시 상태 확인

그림4. 결제 수단 선택

사용자가 결제 수단을 선택한 시점에도 결제 수단이 여전히 선택할 수 있는 상태인지 알 수 없습니다. 따라서, 방어 로직을 통해 다시 한 번 상태를 확인합니다.

if selectedMethodCell.enabledCheckButton() {
    if !selectedMethodCell.isChecked() {
        checkSelectedMethod(indexPath.row)
    }
}
else if selectedMethodCell.enabledNextButton() {
    goNextSelectedMethod(indexPath.row, selectedMethodCell.nextButton)
}

결제 버튼 활성화 여부 결정

그림5. 결제 버튼

결제 수단이 제대로 설정되지 않은 채로 결제 버튼을 누르면 곧바로 장애로 이어집니다. 따라서 화면 갱신 시 매번 결제 버튼의 활성화 여부를 결정합니다.

func setBottomButtonEnabled() {
    bottomButton.isEnabled = false
 
    let viewType = dataSource.paymentInfo?.viewType ?? .payment
    if dataSource.canSelectBalance()
       || (
           dataSource.canSelectCard(.creditCard)
           && !(dataSource.cardAccountId ?? "").isEmpty
           )
       || showNoPayView() {
           bottomButton.isEnabled = true
    }
 
    if dataSource.allPaidByPointOrCoupon() {
        if viewType == .payment {
            bottomButton.isEnabled = true
        }
        else if showNoPayView() {
            bottomButton.isEnabled = true
        }
     }
     else {
         bottomButton.isEnabled = dataSource.isCurrentPayMethodAvailable() // 결제 버튼 활성화 여부 결정
     }
}

기존 구현의 문제점

기존의 구현 방식에는 다음과 같은 문제점이 있습니다.

  • 각 상태가 서로에게 의존하지 않습니다.
    • 따라서 모순된 상태가 되기 쉽습니다.
    • 예를 들어, 결제 수단 활성화(enable)의 조건을 만족하지 못해도 선택 가능(selectable) 상태가 될 수 있습니다.
  • 상태가 개별적으로 구현되었습니다.
    • 중복 코드가 있을 가능성이 높습니다.
    • 사용자의 동작을 제한하는 처리를 넣으면 버그가 발생할 확률이 올라갑니다.
    • 예를 들어, 카드는 등록 필요(needRegister)일 때 선택할 수 없도록 제한하는 경우가 있습니다.
  • 결제 수단을 그리는 시점에 각 결제 수단의 상태를 확인합니다.
    • 따라서, 결제 버튼을 활성화해도 괜찮은지 결정하기 위해서는 결제 수단의 상태를 한 번 더 종합적으로 확인해야 합니다.
  • 상태의 무결성을 보장할 수 없습니다.
    • 사용자가 결제 수단을 선택하는 시점에 상태가 옳은지 판단하는 로직이 추가로 필요합니다.
    • 비슷한 로직이 분산되어 있어, 각 로직 데이터의 일치를 보장할 수 없습니다.

실제로 사용자가 카드를 선택할 때, 선택한 카드 정보가 서버에 완전하게 전달되지 않는 경우가 있었습니다. 이는 오랜 기간 동안 서버에 "불완전한 정보"라는 에러 로그로 계속 쌓여 왔습니다. 클라이언트에서는 원인 파악이 어려워 로그를 통해 확인하려 했으나, 100% 해결이 되지 않고 있었습니다.

새로운 구현 방식

위에서 살펴본 기존 구현의 문제를 아래처럼 템플릿 메서드 패턴으로 해결했습니다.

  1. 의존적인 계약 설정
  2. 모든 결제 수단이 같은 계약 공유
  3. 모순되지 않는 상태 보장

새로운 구현 방식의 내용과 장점에 대해 자세히 알아보겠습니다.

새로운 구현 내용

다음 그림은 템플릿 메서드 패턴에 따라 새로 구현한 결제 수단 클래스 다이어그램입니다. AbstractPayMethod 클래스는 추상 클래스이자 모든 결제 수단의 상위 클래스입니다. AbstractPayMethod 클래스에 모든 결제 수단의 뼈대를 구현하고, 결제 수단의 상태 조건은 각 결제 수단 클래스가 AbstractPayMethod 클래스를 상속받아 메서드 오버라이딩으로 구현합니다.

그림6. 결제 수단 클래스 다이어그램

AbstractPayMethod - create()

AbstractPayMethod의 create()는 계약 조건을 구현합니다. 계약에 따르면 각 결제 수단 클래스가 앞선 상태를 만족해야만 다음 상태를 판단합니다. 추상 클래스인 AbstractPayMethod에서 호출하는 makeXXX()와 createEmpty(), create()는 모두 hook 메서드로 하위 클래스에서 구현해야 합니다.

let enabled = makeEnabled()
if makeEmptyMethod() { // 등록이 필요한 경우
    return createEmpty(enabled) // 하위 클래스에서 등록 필요 상태를 가지는 결제 수단 생성
}
 
let visible = makeVisible()
let selectable = enabled && makeSelectable()
let checked = selectable && makeChecked()
create(enabled, visible, selectable, checked) // 하위 클래스에서 상위 클래스의 계약 조건에 맞는 결제 수단 생성

AbstractPayMethod - makeCommonEnabled()

모든 결제 수단에 공통으로 필요한 로직은 상위 클래스의 extension에서 구현합니다. 표1에서 살펴본 것과 같이, 결제 수단 활성화(enabled) 상태는 모든 결제 수단에 공통 조건이 세 가지 있습니다.

  • 포인트/LINK/쿠폰 전액 결제가 아님
  • 일반 결제(payment type)
  • 0원 결제

이 공통 조건을 makeCommonEnabled()에서 구현합니다.

func makeCommonEnabled(
        allPaidByPointOrCoupon: Bool,
        paymentViewType: Bool,
        calculatedAmount: Decimal?
    ) -> Bool {
        if allPaidByPointOrCoupon, paymentViewType, let calculatedAmount, calculatedAmount == 0 {
            return false
        }
        return true
}

BalancePayMethod

AbstractPayMethod에 정의한 템플릿 메서드를 밸런스 결제 수단의 세부 조건에 맞게 오버라이딩합니다.

struct BalancePayMethod: AbstractPayMethod {
    func makeEmptyMethod() -> Bool {
        needRegisterBalance
    }
     
    func makeVisible() -> Bool { // 밸런스의 visible
        !isMultiRegKey
    }
     
    func makeEnabled() -> Bool { // 밸런스의 enabled
        if !bankErrorMessage.isEmpty || bankStatus == .maintenance {
            return false
        }
         
        return makeCommonEnabled(
            allPaidByPointOrCoupon: allPaidByPointOrCoupon,
            paymentViewType: paymentViewType,
            calculatedAmount: calculationBalanceAmount
        )
    }
     
    func makeSelectable() -> Bool { // 밸런스의 selectable
        if !userBalance.maskedAmountString.isEmpty || bankStatus == .minusBalance {
            return false
        }
         
        return sufficientAmount
    }
     
    func makeChecked() -> Bool {
        balanceSelected
    }
     
    func createEmpty(
        index: UInt
    ) -> PayMethod {
        PayMethod(
            index: index,
            payMethod: .emptyBalance()
        )
    }
     
    private var sufficientAmount: Bool {
        let payAmount = calculationBalanceAmount ?? paymentInfoBalanceAmount
        return userBalance.amount >= payAmount
    }
     
    func create(
        index: UInt
    ) -> PayMethod {
        return PayMethod(
            index: index,
            payMethod: .balance(method)
        )
    }
}

Factory class

각 결제 수단 클래스를 생성하는 부분이자 템플릿 메서드 패턴을 사용하는 부분입니다. 서버로부터 결제 수단 목록을 받아온 뒤, 반복문으로 각 클래스를 생성합니다. 모든 결제 수단 클래스는 AbstractPayMethod 클래스를 상속받았기 때문에, create() 호출만으로 상위 클래스가 정한 계약에 따라 상태를 갖고 속성이 정해집니다.

let methods = serverMethods.map { method in // 결제 수단 타입에 따라 각 클래스를 생성합니다.
    switch method.type {
    case .balance:
        return BalancePayMethod()
    case .creditCard:
        return CreditCardPayMethod()
    case .topupPay:
        return TopupPayCardMethod()
    case .debit:
        return DebitPayMethod()
    }
}
methods.foreach { method in
    method.create() // create() 호출
}

새로운 구현의 장점

  • 공통 조건은 상위 클래스에서 구현합니다.
    • 모든 하위클래스가 계약을 위반하지 않음을 보장할 수 있습니다.
    • 하위 클래스에서 세부 조건이 필요하면 오버라이딩으로 구현합니다.
  • 결제 수단을 그리는 시점부터 각 결제 수단의 상태를 결정할 수 있습니다.
    • 결제 수단 선택 시점에 정보가 보장되어, 추가 로직이 필요하지 않습니다.
    • 결제 버튼 활성화 여부를 결정하기 쉽습니다. 선택 가능(selectable)한 결제 수단 중 어느 것이 선택됨(checked)인지만 확인하면 됩니다.
  • 상태를 뷰에 설정할 필요가 없습니다.
    • 상태 정보를 구성하는 것만으로 사용자의 동작을 제한할 수 있어 로직을 분산할 필요가 없습니다.
    • 동작을 예측할 수 있기에 버그 발생이 줄어듭니다.
    • 결제 수단마다 UITableViewCell을 row에 설정하는 방식에서, 템플릿 메서드 패턴을 사용해 만든 결제 수단 목록 전체를 SwiftUI View에 바인딩하는 방식으로 변경했습니다.
    • 뷰는 Imutable state와 1:1 관계며, mutation 기능은 select 기능만 제공됩니다

마치며

LINE Pay의 결제 수단 상태 설정 기능의 리팩토링 과정으로 템플릿 메서드 패턴을 어떻게 활용할 수 있고, 그 혜택이 무엇인지 살펴보았습니다. 이 예시에서는 상위 클래스의 템플릿 메서드에 알고리즘을 구현하고 하위 클래스는 해당 알고리즘을 따르게 했습니다. 결과적으로 의존성 부패를 방지하는 것에 의미를 찾을 수 있습니다.

템플릿 메서드 패턴은 할리우드 원칙으로도 불립니다. 할리우드 원칙은 "우리한테 연락하지 마세요. 우리가 먼저 연락합니다."라는 말에서 유래했습니다. 저수준의 구성요소가 고수준의 구성요소에 접근할 수는 있어도 직접 호출할 수 없고, 고수준의 구성요소에 의해 어떻게 쓰일지 결정하게 해야 한다는 원칙입니다.

간단한 기능이라도 조건이 복잡하다면, 게다가 여러 기능이 이 조건을 참고한다면 의도하지 않아도 어느새 불필요한 중복 코드를 작성하기 쉽습니다. 이는 결국 유지보수를 어렵게 만들어, 예상치 못한 버그가 발생하는 원인이 됩니다. 객체지향 디자인 패턴을 잘 활용하면 원하는 곳에 제약을 걸면서 동일한 기능을 보장하고, 중복 코드를 제거할 수 있습니다.