LODY/정리

디자인 패턴 / iOS에서 만나는 패턴

iOS에서 만나는 패턴

🟣 패턴은 프레임워크 안에 이미 있다

앞의 세 장을 정리하고 나니 iOS 코드가 다르게 보이기 시작했다. 매일 쓰던 delegate, NotificationCenter, Combine이 사실은 배운 패턴들의 구체적 구현이었다. 애플이 프레임워크를 설계하면서 같은 문제를 같은 방식으로 풀어 뒀던 것이다.

그래서 마지막 장은 새 패턴을 배우기보다, 이미 쓰고 있던 것들의 정체를 확인한다. 이렇게 이름을 붙여 두면 프레임워크의 낯선 API를 만나도 "아, 이건 전략이구나" 하고 구조를 먼저 읽을 수 있다. 이 독해력이 면접과 코드 리뷰에서 실제로 갈리는 지점이었다.


🟣 delegate: 전략과 옵저버가 겹치는 자리

iOS를 배우면 가장 먼저 만나는 게 delegate 패턴이다. 어떤 객체가 자기 일의 일부를 다른 객체에게 맡기고, 맡은 쪽은 정해진 프로토콜을 구현해 응답한다. UITableView가 대표적이다.

흥미로운 건 delegate가 3부의 두 패턴에 동시에 걸쳐 있다는 점이다. 하나의 delegate 프로토콜 안에 성격이 다른 메서드가 섞여 있다.

extension MyViewController: UITableViewDelegate {
    // 이건 전략에 가깝다: "행 높이를 어떻게 정할지"를 밖에서 받는다
    func tableView(_ tableView: UITableView,
                   heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 64
    }
 
    // 이건 옵저버에 가깝다: "행이 선택됐다"는 사건을 통지받는다
    func tableView(_ tableView: UITableView,
                   didSelectRowAt indexPath: IndexPath) {
        // 선택 이벤트 처리
    }
}

heightForRowAt은 테이블이 "높이를 어떻게 계산할까"라는 행위를 밖에서 받아 쓰는 구조다. 값을 돌려주면 테이블이 그걸로 계산한다. 전략이다. 반면 didSelectRowAt은 "선택이 일어났다"는 사건을 뒤늦게 통지받는 구조다. 돌려줄 값도 없다. 옵저버다.

그래서 delegate를 "옵저버냐 전략이냐"로 딱 잘라 답하는 건 정확하지 않다. 메서드마다 다르고, 반환값이 있느냐로 갈린다. 값을 돌려줘 동작에 관여하면 전략 쪽이고, 값 없이 통지만 받으면 옵저버 쪽이다. 이 구분을 할 수 있으면 delegate 하나를 놓고도 설계 의도를 읽어 낼 수 있다.

delegate가 옵저버와 결정적으로 다른 지점이 하나 더 있다. 옵저버는 여럿이 구독하지만, delegate는 보통 하나다. 3부에서 옵저버 배열이 강한 참조로 누수를 일으켰던 문제를, delegate는 weak var delegate라는 관례로 처음부터 막는다. 일대일이라 약한 참조 하나로 순환을 끊을 수 있는 것이다.


🟣 Combine: 옵저버의 현대적 얼굴

3부에서 옵저버를 직접 구현하며 구독 배열 관리와 메모리 누수를 떠안았다. Combine은 그 부담을 프레임워크가 가져간 옵저버다. @Published가 값을 내보내고, sink로 구독하며, 구독은 AnyCancellable이 들고 있다가 해제될 때 자동으로 끊는다.

final class CartViewModel: ObservableObject {
    @Published private(set) var count = 0   // subject 역할
    func add() { count += 1 }
}
 
// 구독하는 쪽
let viewModel = CartViewModel()
let cancellable = viewModel.$count.sink { newCount in
    // count가 바뀔 때마다 자동 통지
    print("장바구니: \(newCount)")
}

3부에서 직접 짠 Cart와 뼈대가 같다. @Published가 값 변화를 통지하는 subject이고, sink의 클로저가 옵저버다. 다른 건 구독 해지다. 직접 구현에서는 약한 참조와 해지를 손수 관리했지만, Combine에서는 cancellable이 해제되는 순간 구독이 자동으로 끊긴다. 옵저버 패턴의 가장 성가신 부분인 생명주기 관리를 타입 시스템에 맡긴 것이 Combine의 핵심 개선이다.

SwiftUI로 가면 이 구독조차 감춰진다. @StateObject로 뷰가 뷰모델을 구독하면, @Published 값이 바뀔 때마다 프레임워크가 알아서 뷰를 다시 그린다. 옵저버를 쓴다는 감각조차 없이 옵저버 위에 서 있는 셈이다.


🟣 데코레이터와 어댑터: 감싸서 바꾼다

구조 패턴 중 iOS에서 자주 마주치는 둘을 정리한다. 이름은 달라도 공통점이 있다. 원본을 고치지 않고 감싸서 목적을 이룬다는 점이다. 상속으로 기능을 늘리는 대신 객체를 한 겹 두르는 방식이라, 여기서도 "상속보다 구성"이 이어진다.

데코레이터는 원본에 기능을 덧입힌다. 같은 인터페이스를 유지한 채 그 앞뒤로 동작을 끼워 넣는다. 네트워크 요청에 로깅이나 재시도를 얹는 상황이 전형적이다.

protocol DataLoader {
    func load(_ url: URL) async throws -> Data
}
 
struct NetworkLoader: DataLoader {
    func load(_ url: URL) async throws -> Data { /* 실제 요청 */ Data() }
}
 
// 원본을 감싸 로깅만 덧입힌다. 인터페이스는 그대로다
struct LoggingLoader: DataLoader {
    let wrapped: DataLoader
    func load(_ url: URL) async throws -> Data {
        print("요청 시작: \(url)")
        let data = try await wrapped.load(url)
        print("요청 완료: \(data.count) bytes")
        return data
    }
}
 
let loader: DataLoader = LoggingLoader(wrapped: NetworkLoader())

LoggingLoaderDataLoader 자리에 그대로 들어간다. 쓰는 쪽은 감싸졌는지 모른다. 재시도나 캐시가 더 필요하면 또 한 겹 감싸면 되고, 원본 NetworkLoader는 손대지 않는다. 애플 프레임워크에서는 URLProtocol을 서브클래싱해 요청 처리 중간에 끼어드는 구조가 이 발상에 가깝다.

어댑터는 목적이 다르다. 기능을 얹는 게 아니라, 맞지 않는 인터페이스를 원하는 모양으로 변환한다. 레거시 API나 외부 SDK가 우리 코드가 기대하는 프로토콜과 시그니처가 다를 때, 그 사이에 변환기를 하나 끼운다.

// 외부 SDK가 주는 인터페이스 (내 코드와 모양이 다르다)
final class LegacyAudioSDK {
    func playFile(named name: String, loop: Bool) { }
}
 
// 내 코드가 기대하는 인터페이스
protocol AlertSound {
    func play()
}
 
// 둘 사이를 변환하는 어댑터
struct LegacySoundAdapter: AlertSound {
    let sdk: LegacyAudioSDK
    let fileName: String
    func play() {
        sdk.playFile(named: fileName, loop: false)   // 호출 형태를 변환
    }
}

LegacySoundAdapter는 2부의 AlertSound 자리에 그대로 끼워진다. 내 코드는 외부 SDK의 낯선 시그니처를 몰라도 되고, SDK가 바뀌면 어댑터 하나만 고친다. 외부 의존성을 앱 안쪽으로 번지지 않게 가두는 얇은 경계로 실무에서 자주 쓴다.


🟣 정리

  • iOS 프레임워크는 앞서 배운 패턴들의 구현체다. 새 패턴을 외우기보다 낯선 API에서 익숙한 구조를 읽어 내는 것이 실무의 값어치다.
  • delegate는 옵저버와 전략에 동시에 걸쳐 있다. 반환값으로 동작에 관여하면 전략이고, 값 없이 통지만 받으면 옵저버다. 일대일이라 weak 하나로 옵저버의 누수 문제를 처음부터 피한다.
  • Combine은 구독 생명주기를 타입 시스템에 맡긴 옵저버다. 3부에서 직접 짠 subject와 뼈대가 같고, 다른 건 AnyCancellable이 해지를 자동으로 관리한다는 점이다. SwiftUI는 그 구독조차 감춘다.
  • 데코레이터와 어댑터는 원본을 고치지 않고 감싼다. 데코레이터는 같은 인터페이스로 기능을 덧입히고, 어댑터는 맞지 않는 인터페이스를 변환한다. 둘 다 상속 대신 구성으로 확장하는 사례다.
  • 면접에서 "이 프레임워크가 무슨 패턴이냐"를 정확히 답하는 것보다, 그 패턴을 왜 그렇게 설계했는지를 설명하는 편이 깊이를 보여 준다. delegate가 왜 weak인지, Combine이 무엇을 개선했는지처럼 설계 의도까지 내려가면 이야기가 달라진다.

여기까지가 이 책의 전부다. 변하는 것을 격리한다는 한 문장에서 출발해, 캡슐화와 SOLID로 원칙을 세우고, 생성과 행위를 격리하는 패턴을 거쳐, iOS 프레임워크가 같은 문제를 어떤 이름으로 풀어 뒀는지까지 왔다. 패턴을 스물세 개의 목록으로 외웠다면 금방 잊었을 것이다. 하나의 원칙이 상황마다 다른 얼굴로 반복된다는 감각으로 다시 엮고 나니, 새 프레임워크를 만나도 구조를 먼저 읽는 눈이 남았다.