LODY/정리

디자인 패턴 / 전략과 옵저버

전략과 옵저버

🟣 행위를 밖에서 정하게 하고 싶다

1부에서 "소리 정책을 실행 중에 바꾸고 싶다"는 요구를 프로토콜과 구성으로 풀었다. 사실 그때 이미 두 개의 유명한 패턴을 부분적으로 쓴 것이다. 이 장은 그걸 정면으로 다룬다. 전략과 옵저버는 둘 다 객체의 행위를 밖에서 정하게 하지만, 방향이 다르다.

전략은 "이 일을 어떻게 할지"를 밖에서 끼워 넣는다. 하나의 객체가 여러 알고리즘 중 하나를 골라 쓴다. 옵저버는 반대로 "이 일이 일어났음"을 밖으로 알린다. 하나의 객체가 자기 변화를 여러 관심 있는 쪽에 통지한다. 하나는 행위를 받아들이고, 하나는 행위를 내보낸다. 이 방향 차이가 두 패턴을 가르는 핵심이다.


🟣 전략 패턴: 알고리즘을 부품처럼 바꾼다

전략 패턴은 서로 바꿔 쓸 수 있는 알고리즘들을 각각 객체로 만들어, 실행 중에 골라 끼운다. 1부의 AlertSound가 정확히 이 구조였다. 좀 더 알고리즘다운 예로 정렬이나 할인 계산을 생각하면 감이 온다.

주문 금액에 할인을 매기는데, 정책이 자주 바뀐다고 하자. 정률 할인, 정액 할인, 첫 구매 할인이 있고 상황에 따라 다르게 적용된다. 이걸 if-else로 한 함수에 몰아넣으면, 정책이 늘 때마다 그 함수를 고쳐야 한다. 개방-폐쇄 원칙 위반이다. 대신 각 정책을 프로토콜의 구현으로 뽑는다.

protocol DiscountPolicy {
    func discount(for amount: Int) -> Int
}
 
struct RateDiscount: DiscountPolicy {
    let rate: Double
    func discount(for amount: Int) -> Int { Int(Double(amount) * rate) }
}
 
struct FlatDiscount: DiscountPolicy {
    let value: Int
    func discount(for amount: Int) -> Int { min(value, amount) }
}
 
final class Order {
    var policy: DiscountPolicy   // 실행 중에 교체 가능하다
 
    init(policy: DiscountPolicy) {
        self.policy = policy
    }
 
    func payable(amount: Int) -> Int {
        amount - policy.discount(for: amount)
    }
}
 
let order = Order(policy: RateDiscount(rate: 0.1))
order.policy = FlatDiscount(value: 3000)   // 정책을 갈아끼운다

Order는 할인을 어떻게 계산하는지 모른다. DiscountPolicy라는 추상만 알고, 계산은 주입된 전략에 넘긴다. 새 정책이 생기면 DiscountPolicy를 구현한 타입 하나만 추가하고 Order는 그대로 둔다.

Swift에서는 전략을 프로토콜 대신 클로저 하나로 넣는 경우도 많다. 전략에 상태가 없고 함수 하나면 충분할 때는 이쪽이 더 가볍다.

final class Order {
    var discount: (Int) -> Int   // 전략을 클로저로 받는다
 
    init(discount: @escaping (Int) -> Int) {
        self.discount = discount
    }
}
 
let order = Order(discount: { amount in Int(Double(amount) * 0.1) })

프로토콜로 갈지 클로저로 갈지는 판단의 문제다. 전략이 여러 메서드를 갖거나 이름이 의미를 가지면 프로토콜이 낫고, 함수 하나로 끝나면 클로저가 간결하다. 이 선택을 설명할 수 있으면 "패턴을 안다"에서 "패턴을 적절히 고른다"로 넘어간다.

Order(전략을 들고 있다)DiscountPolicydiscount(for:)RateDiscountFlatDiscount위임구현구현

🟣 옵저버 패턴: 변화를 여럿에게 알린다

옵저버는 방향이 반대다. 어떤 객체의 상태가 바뀌었을 때, 그 변화를 궁금해하는 여러 객체에 자동으로 알린다. 관심 있는 쪽이 몇이든, 누구든, 알림을 보내는 쪽은 신경 쓰지 않는다.

전형적인 예가 데이터 하나를 여러 화면이 함께 보는 경우다. 장바구니 개수가 바뀌면 헤더의 배지도, 목록 화면도, 결제 버튼도 같이 갱신돼야 한다. 장바구니가 세 화면을 일일이 직접 부르게 짜면, 새 화면이 생길 때마다 장바구니 코드를 고쳐야 한다. 여기서도 개방-폐쇄가 무너진다.

옵저버는 이걸 구독 구조로 뒤집는다. 관심 있는 쪽이 스스로 "나 이 변화 알려 줘"라고 등록하고, 장바구니는 등록된 목록에만 통지한다. 장바구니는 구독자가 누구인지 구체적으로 모른다.

protocol CartObserver: AnyObject {
    func cartDidChange(count: Int)
}
 
final class Cart {
    private var observers = [CartObserver]()
    private(set) var count = 0 {
        didSet { notify() }   // 값이 바뀌면 자동 통지
    }
 
    func subscribe(_ observer: CartObserver) {
        observers.append(observer)
    }
 
    private func notify() {
        observers.forEach { $0.cartDidChange(count: count) }
    }
 
    func add() { count += 1 }
}

CartCartObserver라는 추상만 안다. 새 화면이 이 변화를 받고 싶으면 subscribe로 스스로 끼어들면 되고, Cart 코드는 손대지 않는다. 통지 방향이 "장바구니가 화면을 부른다"에서 "화면이 장바구니를 구독한다"로 뒤집힌 것이 요점이다.

구독 화살표(점선)는 관심 있는 쪽에서 subject로 올라가고, 통지 화살표(실선)는 subject에서 내려온다. 등록은 구독자가, 통지는 subject가 한다.

한 가지 실무 함정이 있다. 위에서 observers가 옵저버를 강한 참조로 들고 있으면, 뷰가 화면을 떠나도 Cart가 계속 붙잡고 있어 메모리에서 해제되지 않는다. 순환 참조로 인한 누수다. 그래서 옵저버 배열은 약한 참조로 담거나, 구독 해지를 명시적으로 관리해야 한다. 옵저버를 직접 구현할 때 메모리 관리가 늘 따라온다는 점이, 뒤에 나올 Combine 같은 프레임워크가 존재하는 이유이기도 하다.


🟣 정리

  • 전략과 옵저버는 둘 다 행위를 밖에서 정하게 하지만 방향이 반대다. 전략은 "어떻게 할지"를 받아들이고, 옵저버는 "무슨 일이 일어났는지"를 내보낸다.
  • 전략은 서로 바꿔 쓸 알고리즘을 각각 객체나 클로저로 뽑아 실행 중에 끼운다. 프로토콜과 클로저 중 무엇으로 표현할지가 실무의 판단 지점이다.
  • 옵저버는 통지 방향을 뒤집어, subject가 구독자를 구체적으로 모르게 한다. 새 구독자가 늘어도 subject 코드는 그대로다.
  • 옵저버를 직접 구현하면 강한 참조로 인한 메모리 누수가 따라온다. 약한 참조나 명시적 구독 해지가 필요하고, 이 부담이 곧 프레임워크가 대신 풀어 주는 문제다.
  • iOS로 옮기면 이 둘이 어디에나 있다. UITableViewDelegate는 "셀을 어떻게 그릴지"를 밖에서 받는 전략에 가깝고, NotificationCenter와 KVO, @Published는 옵저버다. 면접에서 delegate와 notification의 차이를 물으면, "하나는 행위를 받고 하나는 변화를 알린다"는 이 방향 차이로 답하는 것이 정확하다.

전략과 옵저버를 직접 구현해 보면, iOS에서 이미 이 구조를 수없이 쓰고 있었다는 걸 알게 된다. 마지막 장은 그 확인이다. delegate가 사실 무엇인지, Combine이 옵저버의 어떤 얼굴인지, 그리고 데코레이터와 어댑터가 iOS 어디에 숨어 있는지를 정리한다.