본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 2. · 12 Views
Swift 디자인 패턴 완벽 가이드
Swift로 개발하는 모든 개발자를 위한 필수 디자인 패턴 가이드입니다. 실무에서 자주 사용되는 핵심 패턴들을 실전 예제와 함께 쉽고 깊이 있게 설명합니다. 코드의 재사용성과 유지보수성을 획기적으로 높일 수 있습니다.
목차
- Singleton 패턴 - 앱 전역에서 하나의 인스턴스만 유지하기
- Factory 패턴 - 객체 생성 로직을 캡슐화하기
- Observer 패턴 - 객체 간 느슨한 결합으로 이벤트 처리하기
- Delegate 패턴 - 객체 간 통신과 책임 위임하기
- Strategy 패턴 - 알고리즘을 런타임에 선택하기
- Builder 패턴 - 복잡한 객체를 단계별로 생성하기
- Adapter 패턴 - 호환되지 않는 인터페이스를 연결하기
- Decorator 패턴 - 객체에 동적으로 기능 추가하기
1. Singleton 패턴 - 앱 전역에서 하나의 인스턴스만 유지하기
시작하며
여러분이 iOS 앱을 개발할 때 사용자 설정이나 네트워크 매니저를 여기저기서 사용해야 하는데, 매번 새로운 인스턴스를 만들어야 할지 고민해본 적 있나요? 예를 들어, 사용자가 다크모드 설정을 변경했는데 어떤 화면에서는 적용되고 어떤 화면에서는 적용되지 않는 상황이 발생할 수 있습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 같은 데이터를 여러 객체가 각각 보관하면서 동기화가 깨지거나, 메모리를 불필요하게 많이 사용하게 됩니다.
특히 네트워크 연결이나 데이터베이스 접근처럼 리소스가 많이 드는 작업에서는 심각한 성능 저하로 이어질 수 있습니다. 바로 이럴 때 필요한 것이 Singleton 패턴입니다.
앱 전체에서 단 하나의 인스턴스만 생성하고, 모든 곳에서 동일한 인스턴스에 접근할 수 있게 해줍니다.
개요
간단히 말해서, Singleton 패턴은 클래스의 인스턴스가 앱 전체에서 딱 하나만 존재하도록 보장하고, 어디서든 그 인스턴스에 접근할 수 있게 해주는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, 앱의 설정 관리자, 네트워크 관리자, 데이터베이스 핸들러처럼 앱 전체에서 공유해야 하는 리소스를 효율적으로 관리할 수 있습니다.
예를 들어, URLSession을 매번 새로 만들면 불필요한 메모리 낭비가 발생하고 네트워크 연결 설정도 반복해야 합니다. 전통적인 방법과의 비교를 해보면, 기존에는 전역 변수를 사용하거나 매번 새로운 인스턴스를 생성해서 파라미터로 전달했다면, 이제는 단일 인스턴스에 안전하게 접근할 수 있습니다.
이 패턴의 핵심 특징은 첫째, 생성자를 private으로 만들어 외부에서 인스턴스를 생성하지 못하게 하고, 둘째, static 프로퍼티로 유일한 인스턴스를 제공하며, 셋째, Thread-safe하게 구현하여 멀티스레드 환경에서도 안전합니다. 이러한 특징들이 앱의 안정성과 성능을 동시에 보장해주기 때문에 중요합니다.
코드 예제
// 사용자 설정을 관리하는 Singleton 클래스
class SettingsManager {
// 유일한 인스턴스를 저장하는 static 프로퍼티
static let shared = SettingsManager()
// 외부에서 인스턴스 생성을 막기 위한 private 초기화
private init() {
print("SettingsManager 초기화됨")
}
// 사용자 설정 데이터
var isDarkMode: Bool = false
var fontSize: Int = 16
// 설정을 저장하는 메서드
func saveSettings() {
UserDefaults.standard.set(isDarkMode, forKey: "darkMode")
UserDefaults.standard.set(fontSize, forKey: "fontSize")
}
}
// 어디서든 동일한 인스턴스에 접근 가능
SettingsManager.shared.isDarkMode = true
SettingsManager.shared.saveSettings()
설명
이것이 하는 일: Singleton 패턴은 클래스의 인스턴스가 메모리에 단 하나만 존재하도록 제한하고, 이 유일한 인스턴스에 전역적으로 접근할 수 있는 방법을 제공합니다. Swift에서는 static let 프로퍼티와 private init()을 조합하여 이를 구현합니다.
첫 번째로, static let shared = SettingsManager()는 클래스가 로드될 때 단 한 번만 실행되어 인스턴스를 생성합니다. Swift의 static let은 Thread-safe하게 구현되어 있어 여러 스레드가 동시에 접근해도 하나의 인스턴스만 생성됩니다.
shared라는 이름은 관례적으로 사용되지만, default, current 등 의미에 맞는 다른 이름도 사용할 수 있습니다. 두 번째로, private init()이 실행되면서 외부에서는 절대 새로운 인스턴스를 만들 수 없게 됩니다.
만약 다른 곳에서 SettingsManager()를 호출하려고 하면 컴파일 에러가 발생합니다. 이것이 바로 Singleton의 핵심 보안 장치입니다.
세 번째로, 인스턴스의 프로퍼티인 isDarkMode와 fontSize는 앱 어디서든 SettingsManager.shared를 통해 접근할 수 있습니다. A 화면에서 isDarkMode를 true로 설정하면, B 화면에서도 즉시 그 변경사항을 확인할 수 있습니다.
여러분이 이 코드를 사용하면 메모리 사용량을 크게 줄일 수 있고, 데이터 일관성을 보장할 수 있으며, 전역 상태 관리가 간편해집니다. 특히 UserDefaults, 네트워크 세션, 데이터베이스 연결처럼 리소스가 많이 드는 객체를 다룰 때 성능 향상이 두드러집니다.
마지막으로 주의할 점은, Singleton은 강력하지만 남용하면 테스트가 어려워지고 의존성이 높아질 수 있다는 것입니다. 진짜 앱 전역에서 단일 인스턴스가 필요한 경우에만 사용하고, 단순히 편하다는 이유로 무분별하게 사용하지 않도록 주의해야 합니다.
실전 팁
💡 Thread-safe가 걱정된다면 Swift의 static let을 사용하세요. Objective-C와 달리 Swift는 자동으로 Thread-safe하게 초기화해줍니다. dispatch_once를 직접 사용할 필요가 없습니다.
💡 테스트를 위해 Singleton을 의존성 주입 방식으로 개선할 수 있습니다. 프로토콜을 만들고 실제 코드에서는 Singleton을, 테스트 코드에서는 Mock 객체를 주입하는 방식입니다.
💡 Singleton을 너무 많이 만들지 마세요. 앱에 Singleton이 5개 이상이라면 설계를 다시 검토해야 합니다. 대부분의 경우 의존성 주입이나 환경 객체(Environment Object)가 더 나은 선택입니다.
💡 Singleton 내부에 많은 로직을 넣지 말고, 단순히 데이터 저장소나 리소스 관리자 역할만 하도록 설계하세요. 비즈니스 로직은 별도의 클래스로 분리하는 것이 유지보수에 유리합니다.
💡 SwiftUI에서는 @StateObject나 @EnvironmentObject를 사용하는 것이 더 Swift스러운 방법일 수 있습니다. Singleton보다 생명주기 관리가 명확하고 테스트하기 쉽습니다.
2. Factory 패턴 - 객체 생성 로직을 캡슐화하기
시작하며
여러분이 알림 시스템을 개발하는데, 푸시 알림, 이메일 알림, SMS 알림 등 여러 종류의 알림 객체를 생성해야 하는 상황이라고 가정해보세요. 각 알림 타입마다 초기화 방법이 다르고, 어떤 알림을 생성할지 런타임에 결정되는 경우가 많습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 객체 생성 코드가 여기저기 흩어져 있으면, 새로운 알림 타입을 추가하거나 생성 로직을 변경할 때 코드 전체를 수정해야 합니다.
또한 클라이언트 코드가 구체적인 클래스에 직접 의존하게 되어 결합도가 높아지고 유연성이 떨어집니다. 바로 이럴 때 필요한 것이 Factory 패턴입니다.
객체 생성 로직을 전담하는 팩토리 클래스를 만들어서, 클라이언트 코드는 "어떤 객체를 만들지"만 지정하고 "어떻게 만드는지"는 팩토리에 맡깁니다.
개요
간단히 말해서, Factory 패턴은 객체 생성 로직을 별도의 팩토리 클래스로 분리하여, 클라이언트 코드가 구체적인 클래스를 직접 생성하지 않고 팩토리를 통해 객체를 받아 사용하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, 결제 시스템에서 신용카드, 페이팔, 애플페이 등 다양한 결제 방식을 지원해야 할 때, 각 결제 객체의 생성 로직을 한 곳에서 관리할 수 있습니다.
예를 들어, 사용자가 선택한 결제 방식에 따라 적절한 결제 객체를 생성해야 하는 경우에 매우 유용합니다. 전통적인 방법과의 비교를 해보면, 기존에는 if-else나 switch 문으로 각 타입을 직접 생성했다면, 이제는 팩토리가 타입에 맞는 객체를 알아서 생성해줍니다.
클라이언트 코드는 추상 인터페이스만 알면 되므로 구체적인 구현에 의존하지 않습니다. 이 패턴의 핵심 특징은 첫째, 객체 생성 코드가 한 곳에 집중되어 관리가 쉽고, 둘째, 클라이언트 코드와 구체 클래스 간의 결합도가 낮아지며, 셋째, 새로운 타입을 추가할 때 기존 코드 수정이 최소화됩니다.
이러한 특징들이 코드의 확장성과 유지보수성을 크게 향상시키기 때문에 중요합니다.
코드 예제
// 알림 프로토콜 정의
protocol Notification {
func send(message: String)
}
// 구체적인 알림 클래스들
class PushNotification: Notification {
func send(message: String) {
print("📱 푸시 알림: \(message)")
}
}
class EmailNotification: Notification {
func send(message: String) {
print("📧 이메일: \(message)")
}
}
class SMSNotification: Notification {
func send(message: String) {
print("💬 SMS: \(message)")
}
}
// 팩토리 클래스: 알림 객체 생성을 전담
class NotificationFactory {
enum NotificationType {
case push, email, sms
}
// 타입에 맞는 알림 객체를 생성하여 반환
static func createNotification(type: NotificationType) -> Notification {
switch type {
case .push:
return PushNotification()
case .email:
return EmailNotification()
case .sms:
return SMSNotification()
}
}
}
// 클라이언트 코드: 구체 클래스를 몰라도 됨
let notification = NotificationFactory.createNotification(type: .push)
notification.send(message: "새 메시지가 도착했습니다!")
설명
이것이 하는 일: Factory 패턴은 객체 생성의 복잡성을 숨기고, 클라이언트에게는 간단한 인터페이스만 제공합니다. 클라이언트는 "푸시 알림을 만들어줘"라고만 요청하면, 팩토리가 필요한 모든 초기화 작업을 수행하고 완성된 객체를 전달합니다.
첫 번째로, Notification 프로토콜은 모든 알림 타입이 구현해야 하는 공통 인터페이스를 정의합니다. 이것이 핵심입니다.
클라이언트 코드는 이 프로토콜 타입으로만 알림을 다루기 때문에, 실제로 어떤 구체 클래스가 사용되는지 알 필요가 없습니다. 이것이 바로 "인터페이스에 의존하고 구현에 의존하지 말라"는 원칙의 실천입니다.
두 번째로, NotificationFactory.createNotification() 메서드가 실행되면서 타입에 맞는 객체를 생성합니다. 이 메서드 안에서 복잡한 초기화 로직, 의존성 주입, 설정 로드 등의 작업을 모두 처리할 수 있습니다.
예를 들어, EmailNotification을 생성할 때 SMTP 설정을 로드하거나, PushNotification을 생성할 때 APNs 인증서를 설정하는 등의 작업을 이곳에서 할 수 있습니다. 세 번째로, 클라이언트 코드는 단지 createNotification(type: .push)만 호출하면 됩니다.
구체적인 클래스 이름인 PushNotification을 직접 사용하지 않습니다. 나중에 PushNotification의 초기화 방법이 바뀌어도, 클라이언트 코드는 전혀 수정할 필요가 없습니다.
여러분이 이 코드를 사용하면 새로운 알림 타입을 추가할 때 팩토리 클래스만 수정하면 되고, 코드의 확장이 매우 쉬워집니다. 또한 단위 테스트를 작성할 때 Mock 객체를 주입하기도 쉬워집니다.
실무에서는 로그 시스템, 데이터베이스 연결, API 클라이언트 생성 등 다양한 곳에서 활용됩니다. 실제 프로젝트에서는 Abstract Factory 패턴으로 확장하여 관련된 객체들의 패밀리를 생성할 수도 있습니다.
예를 들어, iOS 테마와 Android 테마에 따라 각각 다른 버튼, 입력창, 다이얼로그를 생성하는 팩토리를 만들 수 있습니다.
실전 팁
💡 팩토리 메서드는 static으로 만들거나 Singleton으로 구현하세요. 팩토리 자체는 상태를 가질 필요가 없는 경우가 많으므로, 불필요한 인스턴스 생성을 피할 수 있습니다.
💡 열거형(enum)을 사용하여 생성 가능한 타입을 명확히 정의하세요. 문자열이나 정수로 타입을 지정하면 오타나 잘못된 값으로 인한 런타임 에러가 발생할 수 있습니다.
💡 팩토리가 nil을 반환하지 않도록 설계하세요. 만약 생성 실패 가능성이 있다면 Result 타입이나 예외를 사용하여 명확한 에러 처리를 하세요.
💡 복잡한 객체 생성에는 Builder 패턴과 함께 사용하는 것을 고려하세요. 팩토리가 Builder를 반환하도록 하면, 단계별 설정이 필요한 객체를 더 우아하게 생성할 수 있습니다.
💡 의존성 주입 컨테이너(DI Container)를 사용하는 프로젝트라면, 팩토리 패턴 대신 컨테이너의 기능을 활용하는 것이 더 나을 수 있습니다. 중복된 기능을 구현하지 않도록 주의하세요.
3. Observer 패턴 - 객체 간 느슨한 결합으로 이벤트 처리하기
시작하며
여러분이 소셜 미디어 앱을 개발하는데, 사용자가 새 게시물을 올리면 팔로워들에게 알림을 보내고, 피드를 업데이트하고, 통계를 기록해야 하는 상황을 생각해보세요. 이런 작업들을 게시물 작성 코드 안에 모두 하드코딩하면 코드가 복잡해지고 관리하기 어려워집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 하나의 이벤트가 발생했을 때 여러 곳에서 반응해야 하는데, 모든 의존 관계를 직접 관리하면 코드가 강하게 결합되고 확장이 어려워집니다.
새로운 기능을 추가할 때마다 기존 코드를 수정해야 하는 것은 매우 위험한 일입니다. 바로 이럴 때 필요한 것이 Observer 패턴입니다.
이벤트를 발생시키는 주체(Subject)와 이를 관찰하는 객체들(Observers)을 느슨하게 연결하여, 이벤트가 발생하면 등록된 모든 옵저버에게 자동으로 알림이 전달됩니다.
개요
간단히 말해서, Observer 패턴은 한 객체의 상태 변화를 관찰하는 여러 객체들에게 자동으로 알림을 보내는 일대다(one-to-many) 의존 관계를 정의하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, MVC나 MVVM 아키텍처에서 모델의 변경사항을 여러 뷰에 자동으로 반영하거나, 사용자 인증 상태가 변경되었을 때 여러 화면을 업데이트하는 등의 작업에 필수적입니다.
예를 들어, 쇼핑 앱에서 장바구니에 상품이 추가되면 장바구니 아이콘의 배지, 총 금액 표시, 추천 상품 목록 등 여러 UI 요소를 동시에 업데이트해야 하는 경우에 매우 유용합니다. 전통적인 방법과의 비교를 해보면, 기존에는 이벤트 발생 지점에서 모든 의존 객체를 직접 호출했다면, 이제는 옵저버들을 등록만 해두고 이벤트 발생 시 자동으로 알림이 전달됩니다.
코드의 결합도가 크게 낮아지고 확장이 쉬워집니다. 이 패턴의 핵심 특징은 첫째, Subject와 Observer가 서로를 거의 모르는 느슨한 결합 관계를 유지하고, 둘째, 런타임에 동적으로 옵저버를 추가하거나 제거할 수 있으며, 셋째, 브로드캐스트 방식으로 여러 객체에 동시에 알림을 보낼 수 있습니다.
이러한 특징들이 확장 가능하고 유지보수하기 쉬운 코드를 만들어주기 때문에 중요합니다.
코드 예제
// 옵저버 프로토콜 정의
protocol StockObserver: AnyObject {
func stockPriceDidChange(stock: String, price: Double)
}
// Subject 클래스: 관찰 대상
class StockMarket {
// 약한 참조로 옵저버들을 저장 (메모리 누수 방지)
private var observers: [WeakObserver] = []
private var stockPrices: [String: Double] = [:]
// 옵저버 등록
func addObserver(_ observer: StockObserver) {
observers.append(WeakObserver(observer))
}
// 옵저버 제거
func removeObserver(_ observer: StockObserver) {
observers.removeAll { $0.observer === observer }
}
// 주가 업데이트 시 모든 옵저버에게 알림
func updatePrice(stock: String, price: Double) {
stockPrices[stock] = price
notifyObservers(stock: stock, price: price)
}
private func notifyObservers(stock: String, price: Double) {
// nil이 된 옵저버는 자동 제거
observers = observers.filter { $0.observer != nil }
observers.forEach { $0.observer?.stockPriceDidChange(stock: stock, price: price) }
}
}
// 약한 참조 래퍼 (메모리 관리)
private class WeakObserver {
weak var observer: StockObserver?
init(_ observer: StockObserver) {
self.observer = observer
}
}
// 구체적인 옵저버들
class PriceDisplayView: StockObserver {
func stockPriceDidChange(stock: String, price: Double) {
print("💹 화면 업데이트: \(stock) = $\(price)")
}
}
class PriceAlertService: StockObserver {
func stockPriceDidChange(stock: String, price: Double) {
if price > 100 {
print("🔔 알림: \(stock) 가격이 $\(price)로 상승!")
}
}
}
// 사용 예시
let market = StockMarket()
let display = PriceDisplayView()
let alert = PriceAlertService()
market.addObserver(display)
market.addObserver(alert)
market.updatePrice(stock: "AAPL", price: 150.0)
설명
이것이 하는 일: Observer 패턴은 Subject(주식 시장)가 상태 변화를 감지하면, 등록된 모든 Observer(화면, 알림 서비스 등)에게 자동으로 알림을 전파합니다. 이를 통해 여러 컴포넌트가 동일한 데이터 변화에 독립적으로 반응할 수 있습니다.
첫 번째로, StockObserver 프로토콜은 모든 옵저버가 구현해야 하는 인터페이스를 정의합니다. AnyObject를 상속하여 클래스 전용 프로토콜로 만든 이유는 weak 참조를 사용하기 위함입니다.
이것이 메모리 누수를 방지하는 핵심입니다. 만약 strong 참조를 사용하면 Subject와 Observer가 서로를 참조하는 강한 순환 참조(retain cycle)가 발생하여 메모리에서 해제되지 않습니다.
두 번째로, StockMarket 클래스는 옵저버들을 배열로 관리하면서 addObserver()와 removeObserver() 메서드로 동적으로 옵저버를 추가하거나 제거할 수 있습니다. WeakObserver 래퍼 클래스를 사용하여 옵저버를 약한 참조로 저장하는 것이 포인트입니다.
옵저버가 메모리에서 해제되면 자동으로 nil이 되고, notifyObservers() 메서드에서 필터링으로 제거됩니다. 세 번째로, updatePrice() 메서드가 호출되면 주가를 업데이트하고 notifyObservers()를 통해 모든 옵저버의 stockPriceDidChange() 메서드를 호출합니다.
이때 Subject는 옵저버들이 내부적으로 무엇을 하는지 전혀 알지 못합니다. 단지 프로토콜에 정의된 메서드를 호출할 뿐입니다.
여러분이 이 코드를 사용하면 새로운 기능을 추가할 때 기존 코드를 전혀 수정하지 않고 새로운 옵저버만 추가하면 됩니다. 예를 들어, 주가 변동을 데이터베이스에 기록하는 PriceLogger 클래스를 만들어서 옵저버로 등록만 하면 자동으로 작동합니다.
이것이 개방-폐쇄 원칙(Open-Closed Principle)의 완벽한 예시입니다. 실제 iOS 개발에서는 NotificationCenter, KVO(Key-Value Observing), Combine 프레임워크 등이 모두 Observer 패턴의 구현입니다.
특히 SwiftUI의 @Published와 ObservableObject도 이 패턴을 기반으로 합니다. 따라서 이 패턴을 이해하면 iOS 개발의 핵심 메커니즘을 이해하는 것과 같습니다.
실전 팁
💡 반드시 weak 참조를 사용하세요. Observer와 Subject 간의 강한 순환 참조는 메모리 누수의 가장 흔한 원인입니다. 옵저버 배열에 저장할 때 WeakObserver 래퍼를 사용하는 패턴을 기억하세요.
💡 NotificationCenter를 사용할 때는 반드시 옵저버를 제거하세요. deinit에서 removeObserver()를 호출하지 않으면 앱이 크래시할 수 있습니다. iOS 9 이상에서는 자동으로 제거되지만, 명시적으로 제거하는 것이 안전합니다.
💡 너무 많은 알림을 보내지 마세요. 옵저버가 많고 알림이 빈번하면 성능 문제가 발생합니다. 변경사항을 배치로 모아서 한 번에 알림을 보내거나, 디바운싱(debouncing) 기법을 사용하세요.
💡 SwiftUI 프로젝트에서는 Combine 프레임워크나 @Published 프로퍼티를 사용하는 것이 더 Swift스러운 방법입니다. 직접 Observer 패턴을 구현하는 것보다 Apple이 제공하는 도구를 활용하세요.
💡 옵저버에게 전달하는 데이터는 최소한으로 유지하세요. 변경된 내용만 전달하고, 필요하면 옵저버가 직접 Subject에게 추가 정보를 요청하도록 설계하는 것이 효율적입니다.
4. Delegate 패턴 - 객체 간 통신과 책임 위임하기
시작하며
여러분이 커스텀 테이블 뷰를 만드는데, 셀이 탭되었을 때의 동작을 테이블 뷰 자체가 결정하지 않고 외부에서 정의하고 싶은 상황을 생각해보세요. 테이블 뷰는 데이터를 표시하는 역할만 하고, 사용자 인터랙션에 대한 구체적인 비즈니스 로직은 뷰 컨트롤러에서 처리하는 것이 더 깔끔합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 한 객체가 모든 책임을 떠안으면 코드가 비대해지고 재사용이 어려워집니다.
특히 UI 컴포넌트는 여러 곳에서 다른 동작으로 사용되어야 하는데, 모든 동작을 컴포넌트 내부에 하드코딩할 수는 없습니다. 바로 이럴 때 필요한 것이 Delegate 패턴입니다.
특정 작업의 처리 권한을 다른 객체에게 위임하여, 객체 간의 결합도를 낮추고 유연성을 높입니다. iOS 개발에서 가장 널리 사용되는 패턴 중 하나입니다.
개요
간단히 말해서, Delegate 패턴은 한 객체가 특정 작업을 처리할 권한을 다른 객체(delegate)에게 위임하고, 필요한 시점에 delegate의 메서드를 호출하여 작업을 수행하게 하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, UITableView나 UITextField처럼 재사용 가능한 컴포넌트를 만들 때 컴포넌트와 비즈니스 로직을 분리할 수 있습니다.
예를 들어, 네트워크 요청을 수행하는 클래스가 있을 때, 요청이 완료되었을 때의 처리를 호출한 쪽에서 정의하도록 하면 동일한 네트워크 클래스를 다양한 상황에서 재사용할 수 있습니다. 전통적인 방법과의 비교를 해보면, 기존에는 상속을 사용하여 서브클래스에서 동작을 재정의했다면, 이제는 delegate를 통해 합성(composition)을 사용합니다.
상속보다 유연하고, 런타임에 delegate를 교체할 수도 있습니다. 이 패턴의 핵심 특징은 첫째, 일대일(one-to-one) 관계로 명확한 책임 위임이 이루어지고, 둘째, 프로토콜을 통해 타입 안전성을 보장하며, 셋째, weak 참조를 사용하여 메모리 관리가 안전합니다.
이러한 특징들이 Swift와 iOS 개발의 핵심 아키텍처를 구성하기 때문에 반드시 이해해야 합니다.
코드 예제
// Delegate 프로토콜 정의
protocol FileDownloaderDelegate: AnyObject {
func downloader(_ downloader: FileDownloader, didStartDownloading file: String)
func downloader(_ downloader: FileDownloader, didFinishDownloading file: String, data: Data)
func downloader(_ downloader: FileDownloader, didFailWithError error: Error)
func downloader(_ downloader: FileDownloader, progressUpdated progress: Double)
}
// Delegate를 사용하는 클래스
class FileDownloader {
// weak 참조로 delegate 저장 (순환 참조 방지)
weak var delegate: FileDownloaderDelegate?
func download(file: String) {
// 다운로드 시작 알림
delegate?.downloader(self, didStartDownloading: file)
// 실제 다운로드 로직 시뮬레이션
DispatchQueue.global().async {
// 진행률 업데이트
for progress in stride(from: 0.0, through: 1.0, by: 0.2) {
Thread.sleep(forTimeInterval: 0.5)
DispatchQueue.main.async {
self.delegate?.downloader(self, progressUpdated: progress)
}
}
// 완료 또는 실패 알림
let mockData = Data("file content".utf8)
DispatchQueue.main.async {
self.delegate?.downloader(self, didFinishDownloading: file, data: mockData)
}
}
}
}
// Delegate를 채택하는 클래스
class DownloadViewController: FileDownloaderDelegate {
let downloader = FileDownloader()
func startDownload() {
downloader.delegate = self
downloader.download(file: "example.pdf")
}
func downloader(_ downloader: FileDownloader, didStartDownloading file: String) {
print("📥 다운로드 시작: \(file)")
}
func downloader(_ downloader: FileDownloader, didFinishDownloading file: String, data: Data) {
print("✅ 다운로드 완료: \(file), 크기: \(data.count) bytes")
}
func downloader(_ downloader: FileDownloader, didFailWithError error: Error) {
print("❌ 다운로드 실패: \(error.localizedDescription)")
}
func downloader(_ downloader: FileDownloader, progressUpdated progress: Double) {
print("⏳ 진행률: \(Int(progress * 100))%")
}
}
설명
이것이 하는 일: Delegate 패턴은 작업을 수행하는 객체(FileDownloader)와 그 결과를 처리하는 객체(DownloadViewController)를 분리합니다. FileDownloader는 파일 다운로드만 담당하고, 다운로드 진행 상황이나 완료 시의 처리는 delegate에게 맡깁니다.
첫 번째로, FileDownloaderDelegate 프로토콜은 delegate가 구현해야 하는 메서드들을 정의합니다. 프로토콜 이름의 첫 번째 파라미터로 호출자(downloader)를 전달하는 것이 Apple의 명명 규칙입니다.
이렇게 하면 하나의 delegate가 여러 downloader를 관리할 수 있고, 어떤 downloader에서 온 이벤트인지 구분할 수 있습니다. AnyObject를 상속하여 클래스 전용 프로토콜로 만든 것은 weak 참조를 위함입니다.
두 번째로, FileDownloader 클래스는 weak var delegate로 delegate를 약한 참조로 저장합니다. 이것이 매우 중요합니다.
만약 strong 참조를 사용하면, DownloadViewController가 downloader를 strong으로 참조하고, downloader도 delegate(DownloadViewController)를 strong으로 참조하여 순환 참조가 발생합니다. weak를 사용하면 DownloadViewController가 메모리에서 해제될 때 delegate도 자동으로 nil이 되어 안전합니다.
세 번째로, 다운로드의 각 단계마다 delegate? 옵셔널 체이닝을 사용하여 delegate 메서드를 호출합니다. 옵셔널 체이닝 덕분에 delegate가 nil이어도 크래시가 발생하지 않습니다.
또한 백그라운드 스레드에서 작업을 수행하고 결과는 메인 스레드에서 전달하는 것이 iOS 개발의 일반적인 패턴입니다. 여러분이 이 코드를 사용하면 FileDownloader를 여러 화면에서 다르게 사용할 수 있습니다.
예를 들어, A 화면에서는 다운로드 완료 시 파일을 저장하고, B 화면에서는 UI를 업데이트하고, C 화면에서는 다른 작업을 트리거하는 식으로 동일한 다운로더를 재사용할 수 있습니다. 실무에서는 프로토콜 메서드를 모두 구현하기 부담스러울 때 extension을 사용하여 기본 구현을 제공하거나, 일부 메서드를 optional로 만들 수 있습니다.
Swift에서는 @objc optional을 사용하면 됩니다. UITableView의 delegate 패턴을 보면 필수 메서드와 선택적 메서드가 잘 구분되어 있는 것을 볼 수 있습니다.
실전 팁
💡 Delegate는 항상 weak로 선언하세요. 순환 참조는 디버깅하기 매우 어려운 메모리 누수를 일으킵니다. Instruments의 Leaks 도구를 사용하여 주기적으로 확인하세요.
💡 프로토콜 메서드의 첫 번째 파라미터로 sender를 전달하는 Apple의 규칙을 따르세요. 이렇게 하면 하나의 delegate로 여러 객체를 처리할 수 있고, 코드가 더 유연해집니다.
💡 모든 메서드를 필수로 만들지 말고, 정말 중요한 메서드만 필수로 하고 나머지는 optional로 만드세요. 또는 extension으로 기본 구현을 제공하면 사용자가 필요한 메서드만 오버라이드할 수 있습니다.
💡 Delegate와 Closure 중 선택이 어렵다면, 일회성 콜백은 Closure를, 여러 개의 관련된 이벤트는 Delegate를 사용하세요. 예를 들어, 네트워크 요청 하나의 완료 처리는 Closure가, 다운로드의 시작/진행/완료/실패는 Delegate가 적합합니다.
💡 Combine 프레임워크를 사용할 수 있다면, Publisher/Subscriber 패턴을 고려하세요. 더 선언적이고 함수형 프로그래밍 스타일로 작성할 수 있으며, 여러 이벤트를 조합하거나 변환하기 쉽습니다.
5. Strategy 패턴 - 알고리즘을 런타임에 선택하기
시작하며
여러분이 경로 탐색 앱을 개발하는데, 사용자가 "빠른 경로", "최단 경로", "대중교통 경로" 중 하나를 선택할 수 있어야 하는 상황을 생각해보세요. 각 경로 찾기 알고리즘은 완전히 다르지만, 입력(출발지, 도착지)과 출력(경로)은 동일합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 여러 알고리즘을 if-else나 switch로 분기 처리하면 코드가 복잡해지고, 새로운 알고리즘을 추가하거나 기존 알고리즘을 수정할 때 위험합니다.
알고리즘이 많아질수록 유지보수가 기하급수적으로 어려워집니다. 바로 이럴 때 필요한 것이 Strategy 패턴입니다.
각 알고리즘을 독립적인 전략(Strategy) 객체로 캡슐화하고, 런타임에 원하는 전략을 선택하여 사용할 수 있게 합니다.
개요
간단히 말해서, Strategy 패턴은 동일한 목적을 가진 여러 알고리즘을 각각 별도의 클래스로 캡슐화하고, 이들을 서로 교환 가능하게 만드는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, 결제 시스템에서 신용카드, 페이팔, 가상계좌 등 다양한 결제 전략을 지원하거나, 이미지 압축에서 PNG, JPEG, WebP 등 다양한 압축 알고리즘을 제공해야 할 때 매우 유용합니다.
예를 들어, 데이터 정렬 기능에서 사용자가 "이름순", "날짜순", "인기순" 중 하나를 선택할 수 있게 하는 경우에 각 정렬 방식을 독립적인 전략으로 구현할 수 있습니다. 전통적인 방법과의 비교를 해보면, 기존에는 거대한 switch 문으로 모든 경우를 처리했다면, 이제는 각 알고리즘이 독립적인 클래스가 되어 관리가 쉬워집니다.
새로운 알고리즘을 추가할 때 기존 코드를 전혀 수정하지 않아도 됩니다. 이 패턴의 핵심 특징은 첫째, 알고리즘을 사용하는 클라이언트 코드와 알고리즘 구현이 완전히 분리되고, 둘째, 런타임에 동적으로 알고리즘을 교체할 수 있으며, 셋째, 개방-폐쇄 원칙(OCP)을 완벽하게 따릅니다.
이러한 특징들이 확장 가능하고 테스트하기 쉬운 코드를 만들어주기 때문에 중요합니다.
코드 예제
// Strategy 프로토콜 정의
protocol PaymentStrategy {
func pay(amount: Double) -> String
}
// 구체적인 전략 클래스들
class CreditCardPayment: PaymentStrategy {
let cardNumber: String
init(cardNumber: String) {
self.cardNumber = cardNumber
}
func pay(amount: Double) -> String {
return "💳 신용카드(\(cardNumber))로 $\(amount) 결제 완료"
}
}
class PayPalPayment: PaymentStrategy {
let email: String
init(email: String) {
self.email = email
}
func pay(amount: Double) -> String {
return "🌐 PayPal(\(email))로 $\(amount) 결제 완료"
}
}
class ApplePayPayment: PaymentStrategy {
func pay(amount: Double) -> String {
return "🍎 Apple Pay로 $\(amount) 결제 완료"
}
}
// Context 클래스: 전략을 사용하는 클래스
class ShoppingCart {
private var items: [String: Double] = [:]
private var paymentStrategy: PaymentStrategy?
// 장바구니에 상품 추가
func addItem(name: String, price: Double) {
items[name] = price
print("✅ \(name) ($\(price)) 추가됨")
}
// 결제 전략 설정 (런타임에 교체 가능)
func setPaymentStrategy(_ strategy: PaymentStrategy) {
self.paymentStrategy = strategy
}
// 결제 실행 (선택된 전략을 사용)
func checkout() -> String {
let total = items.values.reduce(0, +)
guard let strategy = paymentStrategy else {
return "❌ 결제 방법을 선택하세요"
}
return strategy.pay(amount: total)
}
}
// 사용 예시
let cart = ShoppingCart()
cart.addItem(name: "노트북", price: 1500)
cart.addItem(name: "마우스", price: 50)
// 런타임에 전략 선택
cart.setPaymentStrategy(CreditCardPayment(cardNumber: "1234-5678"))
print(cart.checkout())
// 전략 변경
cart.setPaymentStrategy(ApplePayPayment())
print(cart.checkout())
설명
이것이 하는 일: Strategy 패턴은 다양한 알고리즘을 독립적인 객체로 만들어, 클라이언트 코드가 알고리즘의 세부 구현을 몰라도 사용할 수 있게 합니다. ShoppingCart는 단지 PaymentStrategy 인터페이스만 알고 있으면, 어떤 결제 방식이든 동일한 방법으로 사용할 수 있습니다.
첫 번째로, PaymentStrategy 프로토콜은 모든 결제 전략이 구현해야 하는 공통 인터페이스를 정의합니다. pay(amount:) 메서드만 있으면 되고, 내부적으로 어떻게 결제하는지는 각 전략이 알아서 처리합니다.
이것이 바로 추상화의 힘입니다. 클라이언트 코드는 "결제한다"는 행위만 알면 되고, "어떻게 결제하는지"는 몰라도 됩니다.
두 번째로, 각 구체적인 전략 클래스(CreditCardPayment, PayPalPayment, ApplePayPayment)는 동일한 프로토콜을 구현하지만, 내부 로직은 완전히 다릅니다. 신용카드는 카드 번호가 필요하고, PayPal은 이메일이 필요하며, Apple Pay는 추가 정보가 필요 없습니다.
이러한 차이를 각 클래스가 독립적으로 관리합니다. 세 번째로, ShoppingCart 클래스는 setPaymentStrategy() 메서드로 런타임에 전략을 변경할 수 있습니다.
처음에는 신용카드로 결제하려다가 사용자가 마음을 바꿔 Apple Pay로 변경할 수 있습니다. 전략을 교체해도 ShoppingCart의 다른 코드는 전혀 변경되지 않습니다.
여러분이 이 코드를 사용하면 새로운 결제 방식을 추가할 때 기존 코드를 전혀 수정하지 않아도 됩니다. 예를 들어, 암호화폐 결제를 추가하려면 CryptoPayment 클래스를 만들어서 PaymentStrategy 프로토콜을 구현하기만 하면 됩니다.
ShoppingCart나 다른 전략 클래스는 건드리지 않아도 됩니다. 실무에서는 Strategy 패턴을 팩토리 패턴과 함께 사용하는 경우가 많습니다.
사용자 입력에 따라 적절한 전략 객체를 생성하는 팩토리를 만들면, 전략 선택 로직도 깔끔하게 관리할 수 있습니다. 또한 의존성 주입(DI) 컨테이너와 결합하면 더욱 유연한 아키텍처를 만들 수 있습니다.
실전 팁
💡 전략 객체를 매번 새로 생성하지 말고, 재사용 가능하다면 캐싱하세요. 특히 상태를 갖지 않는(stateless) 전략은 Singleton으로 만들어서 메모리를 절약할 수 있습니다.
💡 전략 패턴과 State 패턴을 혼동하지 마세요. Strategy는 알고리즘을 교체하는 것이고, State는 객체의 상태에 따라 행동이 변하는 것입니다. 의도가 다릅니다.
💡 너무 간단한 알고리즘에는 Strategy 패턴이 오버엔지니어링일 수 있습니다. 알고리즘이 2-3개뿐이고 변경 가능성이 낮다면, 간단한 enum switch로 처리하는 것이 더 나을 수 있습니다.
💡 Closure를 사용하면 Strategy 패턴을 더 간결하게 구현할 수 있습니다. 전략이 단순한 경우 별도의 클래스 대신 클로저를 전략으로 받는 것도 좋은 방법입니다.
💡 전략의 성능 특성(시간 복잡도, 메모리 사용량)을 문서화하세요. 사용자가 상황에 맞는 전략을 선택할 수 있도록 각 전략의 장단점을 명확히 설명해주는 것이 중요합니다.
6. Builder 패턴 - 복잡한 객체를 단계별로 생성하기
시작하며
여러분이 사용자 프로필 객체를 만드는데, 이름, 이메일, 전화번호, 주소, 생년월일, 프로필 사진, 소개글, 관심사 등 수십 개의 프로퍼티가 있고, 이 중 일부는 필수이고 일부는 선택사항인 상황을 생각해보세요. 생성자에 모든 파라미터를 나열하면 코드가 읽기 어렵고, 어떤 값이 무엇을 의미하는지 알기 어려워집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 복잡한 객체를 생성할 때 여러 생성자를 만들거나(telescoping constructor), 모든 프로퍼티를 mutable로 만들어서 하나씩 설정하는 방법은 안전하지 않고 가독성도 떨어집니다.
특히 불변(immutable) 객체를 만들고 싶을 때 딜레마에 빠지게 됩니다. 바로 이럴 때 필요한 것이 Builder 패턴입니다.
객체 생성 과정을 단계별로 나누고, 메서드 체이닝을 통해 가독성 높은 코드로 복잡한 객체를 만들 수 있게 해줍니다.
개요
간단히 말해서, Builder 패턴은 복잡한 객체의 생성 과정을 단계별로 나누고, 동일한 생성 과정으로 다양한 표현의 객체를 만들 수 있게 하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, HTTP 요청 객체를 만들 때 URL, 메서드, 헤더, 바디, 타임아웃 등 다양한 설정을 조합해야 하거나, 복잡한 UI 컴포넌트를 설정할 때 수십 가지 옵션을 다루어야 하는 경우에 매우 유용합니다.
예를 들어, URLRequest를 생성할 때 필수 항목은 URL뿐이지만, 인증 토큰, 캐시 정책, 타임아웃 등 수많은 선택적 설정이 있습니다. 전통적인 방법과의 비교를 해보면, 기존에는 여러 개의 생성자를 오버로딩하거나, 모든 프로퍼티를 var로 만들어서 하나씩 설정했다면, 이제는 Builder를 통해 유창하고(fluent) 읽기 쉬운 방식으로 객체를 생성할 수 있습니다.
이 패턴의 핵심 특징은 첫째, 메서드 체이닝으로 가독성이 높은 코드를 작성할 수 있고, 둘째, 불변 객체를 쉽게 만들 수 있으며, 셋째, 필수 항목과 선택 항목을 명확히 구분할 수 있습니다. 이러한 특징들이 안전하고 유지보수하기 쉬운 코드를 만들어주기 때문에 중요합니다.
코드 예제
// 복잡한 객체: User 프로필
struct User {
let name: String // 필수
let email: String // 필수
let phone: String? // 선택
let address: String? // 선택
let birthday: Date? // 선택
let bio: String? // 선택
let interests: [String] // 선택
// 직접 생성을 막고 Builder를 통해서만 생성
private init(builder: Builder) {
self.name = builder.name
self.email = builder.email
self.phone = builder.phone
self.address = builder.address
self.birthday = builder.birthday
self.bio = builder.bio
self.interests = builder.interests
}
// Builder 클래스
class Builder {
// 필수 프로퍼티
let name: String
let email: String
// 선택 프로퍼티
var phone: String?
var address: String?
var birthday: Date?
var bio: String?
var interests: [String] = []
// 필수 항목만 받는 생성자
init(name: String, email: String) {
self.name = name
self.email = email
}
// 메서드 체이닝을 위해 self를 반환
func setPhone(_ phone: String) -> Builder {
self.phone = phone
return self
}
func setAddress(_ address: String) -> Builder {
self.address = address
return self
}
func setBirthday(_ birthday: Date) -> Builder {
self.birthday = birthday
return self
}
func setBio(_ bio: String) -> Builder {
self.bio = bio
return self
}
func addInterest(_ interest: String) -> Builder {
self.interests.append(interest)
return self
}
// 최종적으로 User 객체 생성
func build() -> User {
return User(builder: self)
}
}
}
// 사용 예시: 가독성 높은 객체 생성
let user = User.Builder(name: "김철수", email: "kim@example.com")
.setPhone("010-1234-5678")
.setAddress("서울시 강남구")
.setBio("iOS 개발자입니다")
.addInterest("Swift")
.addInterest("디자인 패턴")
.build()
print("✅ 사용자 생성: \(user.name), 관심사: \(user.interests)")
// 최소 정보만으로도 생성 가능
let simpleUser = User.Builder(name: "이영희", email: "lee@example.com")
.build()
설명
이것이 하는 일: Builder 패턴은 복잡한 객체의 생성 과정을 Builder 클래스에 위임하여, 클라이언트 코드가 메서드 체이닝을 통해 단계별로 객체를 구성할 수 있게 합니다. 최종적으로 build() 메서드를 호출하면 완성된 불변 객체가 반환됩니다.
첫 번째로, User 구조체의 생성자를 private으로 만들어서 외부에서 직접 생성할 수 없게 했습니다. 오직 Builder를 통해서만 User 객체를 만들 수 있습니다.
이것이 Builder 패턴의 핵심 아이디어입니다. 생성 과정을 제어하여 항상 유효한 상태의 객체만 만들어지도록 보장합니다.
두 번째로, Builder 클래스의 생성자는 필수 항목인 name과 email만 받습니다. 이렇게 하면 컴파일 타임에 필수 항목이 누락되지 않도록 보장할 수 있습니다.
선택 항목들은 각각의 setter 메서드를 통해 설정하며, 각 메서드는 self를 반환하여 메서드 체이닝을 가능하게 합니다. 세 번째로, setPhone(), setAddress() 같은 메서드들이 return self를 하기 때문에, .setPhone(...).setAddress(...).setBio(...) 형태로 연속해서 호출할 수 있습니다.
이것을 Fluent Interface라고 하며, 코드가 마치 문장을 읽는 것처럼 자연스러워집니다. 어떤 값을 설정하는지 명확하게 드러나서 가독성이 크게 향상됩니다.
여러분이 이 코드를 사용하면 객체 생성 시 실수를 줄이고, 코드 리뷰 시 의도를 명확히 전달할 수 있습니다. 또한 User 객체가 불변이기 때문에 스레드 안전하고, 생성 후에 상태가 변하지 않아 예측 가능한 코드가 됩니다.
선택 항목이 많은 복잡한 객체일수록 Builder 패턴의 장점이 두드러집니다. 실무에서는 Alamofire의 request builder, URLRequest 설정, SwiftUI의 view modifier 등이 모두 Builder 패턴의 변형입니다.
SwiftUI의 .padding().background().cornerRadius() 같은 체이닝이 바로 Builder 패턴의 아이디어를 활용한 것입니다. 또한 Result Builder(@resultBuilder)를 사용하면 더욱 강력한 DSL(Domain Specific Language)을 만들 수 있습니다.
실전 팁
💡 Swift에서는 기본 파라미터 값을 사용하면 Builder 패턴 없이도 비슷한 효과를 낼 수 있습니다. 프로퍼티가 5개 이하라면 기본 파라미터로 충분하고, 그 이상이면 Builder를 고려하세요.
💡 Builder를 별도의 타입으로 만들지 않고, User 내부의 nested class로 만들면 User의 private 생성자에 접근할 수 있어서 더 안전합니다.
💡 build() 메서드에서 유효성 검증을 수행하세요. 예를 들어, 이메일 형식이 올바른지, 필수 항목이 모두 설정되었는지 확인하고, 문제가 있으면 에러를 던지는 것이 좋습니다.
💡 Swift의 Result Builder(@resultBuilder)를 활용하면 SwiftUI처럼 선언적인 DSL을 만들 수 있습니다. 복잡한 설정이 많은 경우 고려해볼 만합니다.
💡 Immutable 객체를 만드는 것이 목표라면 struct를 사용하고, Mutable 상태가 필요하다면 class를 사용하세요. Builder 패턴은 두 경우 모두 적용할 수 있지만, immutability를 강제할 때 진가를 발휘합니다.
7. Adapter 패턴 - 호환되지 않는 인터페이스를 연결하기
시작하며
여러분이 기존 프로젝트에서 사용하던 로깅 라이브러리를 새로운 라이브러리로 교체해야 하는 상황을 생각해보세요. 기존 라이브러리는 log(message: String) 메서드를 사용했는데, 새 라이브러리는 writeLog(level: LogLevel, text: String) 형태입니다.
코드 전체에서 수백 곳에서 로깅을 사용하고 있어서 모두 수정하는 것은 현실적이지 않습니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
서드파티 라이브러리를 교체하거나, 레거시 코드와 새 코드를 통합하거나, 외부 API의 인터페이스가 내부 인터페이스와 맞지 않을 때 큰 어려움을 겪습니다. 모든 호출 지점을 수정하는 것은 위험하고 시간이 오래 걸립니다.
바로 이럴 때 필요한 것이 Adapter 패턴입니다. 호환되지 않는 두 인터페이스 사이에 어댑터를 두어, 기존 코드는 전혀 수정하지 않고도 새로운 라이브러리나 클래스를 사용할 수 있게 해줍니다.
개요
간단히 말해서, Adapter 패턴은 호환되지 않는 인터페이스를 가진 클래스를 함께 동작할 수 있도록 중간에 변환 계층을 제공하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, JSON 파싱 라이브러리를 SwiftyJSON에서 Codable로 마이그레이션하거나, 네트워크 라이브러리를 Alamofire에서 URLSession으로 교체하거나, 결제 SDK를 다른 제공업체로 변경할 때 기존 코드의 영향을 최소화할 수 있습니다.
예를 들어, 앱 전체에서 특정 인터페이스에 의존하고 있을 때, 어댑터를 사용하면 의존하는 코드는 변경하지 않고 구현체만 교체할 수 있습니다. 전통적인 방법과의 비교를 해보면, 기존에는 모든 호출 지점을 찾아서 새로운 인터페이스에 맞게 수정했다면, 이제는 어댑터 하나만 만들면 기존 코드는 그대로 유지됩니다.
어댑터가 인터페이스 변환을 책임지기 때문입니다. 이 패턴의 핵심 특징은 첫째, 기존 코드를 수정하지 않고도 새로운 코드를 통합할 수 있고, 둘째, 인터페이스 불일치 문제를 깔끔하게 해결하며, 셋째, 단일 책임 원칙(SRP)을 따라 인터페이스 변환 로직을 분리합니다.
이러한 특징들이 레거시 시스템과 새 시스템을 안전하게 통합하는 데 필수적이기 때문에 중요합니다.
코드 예제
// 기존 시스템에서 사용하던 인터페이스 (Target)
protocol MediaPlayer {
func play(fileName: String)
}
// 새로운 써드파티 라이브러리 (Adaptee)
class AdvancedMediaPlayer {
func playMp4(fileName: String) {
print("🎬 MP4 재생: \(fileName)")
}
func playMkv(fileName: String) {
print("🎬 MKV 재생: \(fileName)")
}
}
// Adapter: 새 라이브러리를 기존 인터페이스에 맞춤
class MediaAdapter: MediaPlayer {
private let advancedPlayer = AdvancedMediaPlayer()
// 기존 인터페이스를 구현하되, 내부적으로 새 라이브러리 사용
func play(fileName: String) {
if fileName.hasSuffix(".mp4") {
advancedPlayer.playMp4(fileName: fileName)
} else if fileName.hasSuffix(".mkv") {
advancedPlayer.playMkv(fileName: fileName)
} else {
print("❌ 지원하지 않는 형식: \(fileName)")
}
}
}
// 기존 시스템의 간단한 플레이어
class AudioPlayer: MediaPlayer {
func play(fileName: String) {
if fileName.hasSuffix(".mp3") {
print("🎵 MP3 재생: \(fileName)")
} else {
print("❌ 지원하지 않는 형식: \(fileName)")
}
}
}
// 클라이언트 코드: 동일한 인터페이스로 모든 플레이어 사용
class MusicApp {
func playMedia(player: MediaPlayer, fileName: String) {
player.play(fileName: fileName)
}
}
// 사용 예시
let app = MusicApp()
// 기존 플레이어 사용
let audioPlayer = AudioPlayer()
app.playMedia(player: audioPlayer, fileName: "song.mp3")
// 어댑터를 통해 새 라이브러리 사용 (클라이언트 코드는 동일)
let mediaAdapter = MediaAdapter()
app.playMedia(player: mediaAdapter, fileName: "movie.mp4")
app.playMedia(player: mediaAdapter, fileName: "video.mkv")
설명
이것이 하는 일: Adapter 패턴은 기존 시스템이 기대하는 인터페이스(MediaPlayer)와 새로운 시스템이 제공하는 인터페이스(AdvancedMediaPlayer) 사이에서 중재자 역할을 합니다. 클라이언트는 여전히 MediaPlayer 인터페이스만 알면 되고, 어댑터가 내부적으로 새 라이브러리의 메서드를 호출합니다.
첫 번째로, MediaPlayer 프로토콜은 앱 전체에서 사용되는 기존 인터페이스입니다. 이 인터페이스에 의존하는 코드가 수백 곳에 있다면, 이를 모두 변경하는 것은 매우 위험합니다.
Adapter 패턴을 사용하면 이런 코드들은 하나도 수정할 필요가 없습니다. 두 번째로, AdvancedMediaPlayer는 더 다양한 기능을 제공하지만 인터페이스가 완전히 다릅니다.
playMp4()와 playMkv() 같은 구체적인 메서드들이 있습니다. 이것을 직접 사용하려면 모든 호출 지점을 수정해야 하지만, 어댑터를 사용하면 그럴 필요가 없습니다.
세 번째로, MediaAdapter 클래스가 MediaPlayer 프로토콜을 구현하면서, 내부적으로는 AdvancedMediaPlayer를 사용합니다. play() 메서드 안에서 파일 확장자를 확인하고 적절한 메서드를 호출하는 변환 로직이 들어있습니다.
이 변환 로직이 한 곳에 집중되어 있어 관리가 쉽습니다. 여러분이 이 코드를 사용하면 라이브러리를 교체하거나 업그레이드할 때 리스크를 크게 줄일 수 있습니다.
또한 여러 써드파티 라이브러리를 동일한 인터페이스로 감싸서, 나중에 쉽게 교체할 수 있는 유연성을 확보할 수 있습니다. 예를 들어, 네트워크 라이브러리를 추상화하여 Alamofire와 URLSession을 어댑터로 감싸면, 언제든지 전환할 수 있습니다.
실무에서는 Facade 패턴과 함께 사용하는 경우가 많습니다. Adapter가 인터페이스를 변환한다면, Facade는 복잡한 서브시스템을 단순한 인터페이스로 감쌉니다.
둘 다 인터페이스 관련 패턴이지만 의도가 다릅니다. 또한 Protocol Oriented Programming을 활용하면 더욱 강력한 어댑터를 만들 수 있으며, extension을 사용하여 기존 타입에 새로운 프로토콜 준수를 추가할 수도 있습니다.
실전 팁
💡 Swift의 extension을 사용하면 기존 타입을 수정하지 않고도 프로토콜 준수를 추가할 수 있습니다. 이것이 가장 Swift스러운 Adapter 구현 방법입니다.
💡 Adapter가 너무 복잡해진다면 설계를 재검토하세요. Adapter는 단순히 인터페이스를 변환하는 역할만 해야 하고, 복잡한 비즈니스 로직은 포함하지 않아야 합니다.
💡 양방향 어댑터(Two-way Adapter)를 만들 수 있습니다. 두 인터페이스를 모두 구현하여 어느 쪽으로든 변환이 가능하게 하면, 더욱 유연한 통합이 가능합니다.
💡 Dependency Injection과 함께 사용하면 효과가 극대화됩니다. 인터페이스(프로토콜)만 주입받고, 런타임에 실제 구현체나 어댑터를 주입하면 테스트하기도 쉬워집니다.
💡 레거시 코드를 리팩토링할 때 Adapter 패턴으로 시작하세요. 일단 어댑터로 감싼 후, 점진적으로 내부를 개선하는 Strangler Fig 패턴과 결합하면 안전한 마이그레이션이 가능합니다.
8. Decorator 패턴 - 객체에 동적으로 기능 추가하기
시작하며
여러분이 커피 주문 시스템을 개발하는데, 기본 커피에 우유, 시럽, 휘핑크림 등을 추가할 수 있고, 각 추가 옵션마다 가격이 더해지는 상황을 생각해보세요. 모든 조합을 위한 서브클래스를 만들면 "커피", "커피+우유", "커피+시럽", "커피+우유+시럽" 등 기하급수적으로 클래스가 늘어납니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 상속으로 기능을 추가하면 클래스 계층이 복잡해지고, 컴파일 타임에 조합이 고정되어 유연성이 떨어집니다.
또한 하나의 객체에 여러 기능을 동적으로 추가하거나 제거하기가 어렵습니다. 바로 이럴 때 필요한 것이 Decorator 패턴입니다.
객체를 여러 데코레이터로 감싸서, 런타임에 동적으로 기능을 추가할 수 있게 해줍니다. 상속 대신 합성을 사용하는 전형적인 예시입니다.
개요
간단히 말해서, Decorator 패턴은 객체에 동적으로 새로운 책임을 추가하고, 기능 확장을 위해 서브클래싱보다 유연한 대안을 제공하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하면, 텍스트 편집기에서 글자에 굵게, 기울임, 밑줄 등 여러 스타일을 조합하거나, HTTP 요청에 로깅, 암호화, 압축 등의 기능을 선택적으로 추가하거나, UI 컴포넌트에 테두리, 그림자, 애니메이션 등을 동적으로 적용할 때 매우 유용합니다.
예를 들어, 기본 이미지 뷰에 캐싱, 로딩 인디케이터, 에러 처리 등을 레이어별로 추가하는 경우에 각 기능을 독립적인 데코레이터로 만들 수 있습니다. 전통적인 방법과의 비교를 해보면, 기존에는 모든 조합을 위한 서브클래스를 만들거나 if-else로 분기 처리했다면, 이제는 필요한 데코레이터만 조합하여 원하는 기능을 구성할 수 있습니다.
런타임에 동적으로 기능을 추가하거나 제거할 수 있어서 훨씬 유연합니다. 이 패턴의 핵심 특징은 첫째, 상속 없이 기능을 확장할 수 있고, 둘째, 런타임에 동적으로 기능을 조합할 수 있으며, 셋째, 단일 책임 원칙(SRP)을 따라 각 데코레이터가 하나의 기능만 담당합니다.
이러한 특징들이 유지보수가 쉽고 확장 가능한 코드를 만들어주기 때문에 중요합니다.
코드 예제
// 공통 인터페이스
protocol Coffee {
func cost() -> Double
func description() -> String
}
// 기본 구현: 플레인 커피
class SimpleCoffee: Coffee {
func cost() -> Double {
return 3.0
}
func description() -> String {
return "플레인 커피"
}
}
// 데코레이터 베이스 클래스
class CoffeeDecorator: Coffee {
private let decoratedCoffee: Coffee
init(_ coffee: Coffee) {
self.decoratedCoffee = coffee
}
func cost() -> Double {
return decoratedCoffee.cost()
}
func description() -> String {
return decoratedCoffee.description()
}
}
// 구체적인 데코레이터들
class MilkDecorator: CoffeeDecorator {
override func cost() -> Double {
return super.cost() + 0.5
}
override func description() -> String {
return super.description() + " + 우유"
}
}
class SugarDecorator: CoffeeDecorator {
override func cost() -> Double {
return super.cost() + 0.2
}
override func description() -> String {
return super.description() + " + 설탕"
}
}
class WhippedCreamDecorator: CoffeeDecorator {
override func cost() -> Double {
return super.cost() + 0.7
}
override func description() -> String {
return super.description() + " + 휘핑크림"
}
}
// 사용 예시: 동적으로 기능 추가
var coffee: Coffee = SimpleCoffee()
print("\(coffee.description()): $\(coffee.cost())")
// 우유 추가
coffee = MilkDecorator(coffee)
print("\(coffee.description()): $\(coffee.cost())")
// 설탕 추가
coffee = SugarDecorator(coffee)
print("\(coffee.description()): $\(coffee.cost())")
// 휘핑크림 추가
coffee = WhippedCreamDecorator(coffee)
print("\(coffee.description()): $\(coffee.cost())")
// 결과: 플레인 커피 + 우유 + 설탕 + 휘핑크림: $4.4
// 한 번에 조합도 가능
let specialCoffee = WhippedCreamDecorator(
SugarDecorator(
MilkDecorator(
SimpleCoffee()
)
)
)
print("🎉 \(specialCoffee.description()): $\(specialCoffee.cost())")
설명
이것이 하는 일: Decorator 패턴은 기본 객체(SimpleCoffee)를 여러 데코레이터로 차례로 감싸서, 마치 양파 껍질처럼 레이어를 추가하는 방식으로 동작합니다. 각 데코레이터는 이전 레이어의 기능을 유지하면서 자신의 기능을 추가합니다.
첫 번째로, Coffee 프로토콜은 모든 커피 객체(기본 커피와 데코레이터 모두)가 구현해야 하는 공통 인터페이스를 정의합니다. 이것이 핵심입니다.
데코레이터가 원본 객체와 동일한 인터페이스를 구현하기 때문에, 클라이언트는 원본을 사용하는지 데코레이터를 사용하는지 구분할 필요가 없습니다. 이것을 "투명성(Transparency)"이라고 합니다.
두 번째로, CoffeeDecorator 베이스 클래스는 Coffee 객체를 내부에 저장하고, 기본적으로는 그 객체의 메서드를 그대로 호출합니다. 이것이 데코레이터의 골격입니다.
구체적인 데코레이터들은 이 베이스 클래스를 상속하여, super.cost()로 이전 레이어의 값을 가져온 후 자신의 값을 더하는 방식으로 동작합니다. 세 번째로, MilkDecorator, SugarDecorator 같은 구체적인 데코레이터들은 각각 하나의 기능만 담당합니다.
우유 데코레이터는 가격에 0.5를 더하고 설명에 "+ 우유"를 추가할 뿐, 다른 것은 신경 쓰지 않습니다. 이것이 단일 책임 원칙의 완벽한 예시입니다.
여러분이 이 코드를 사용하면 새로운 옵션을 추가할 때 기존 코드를 전혀 수정하지 않아도 됩니다. 예를 들어, 바닐라 시럽을 추가하려면 VanillaSyrupDecorator 클래스만 만들면 됩니다.
또한 런타임에 사용자 선택에 따라 동적으로 데코레이터를 추가하거나 제거할 수 있어서, 유연한 설정 시스템을 만들 수 있습니다. 실무에서는 Java의 InputStream/OutputStream이 Decorator 패턴의 대표적인 예입니다.
SwiftUI의 view modifier(.padding(), .background(), .cornerRadius())도 Decorator 패턴의 아이디어를 활용합니다. 또한 Middleware 패턴과 결합하면 HTTP 요청/응답 파이프라인을 구성할 수 있고, AOP(Aspect-Oriented Programming) 스타일로 로깅, 캐싱, 인증 등의 횡단 관심사(cross-cutting concerns)를 깔끔하게 처리할 수 있습니다.
실전 팁
💡 데코레이터가 너무 많이 중첩되면 디버깅이 어려워집니다. 스택 트레이스가 깊어지고, 어느 레이어에서 문제가 발생했는지 추적하기 힘들어집니다. 적절한 수준에서 멈추세요.
💡 순서가 중요한 경우 주의하세요. 예를 들어, 압축 후 암호화와 암호화 후 압축은 결과가 다릅니다. 데코레이터의 적용 순서를 명확히 문서화하세요.
💡 Swift의 Protocol Extension을 활용하면 더 간결한 Decorator를 만들 수 있습니다. 베이스 클래스 없이도 프로토콜 확장으로 기본 동작을 제공할 수 있습니다.
💡 Decorator와 Proxy 패턴을 혼동하지 마세요. Decorator는 기능을 추가하는 것이 목적이고, Proxy는 접근 제어나 지연 로딩이 목적입니다. 구조는 비슷하지만 의도가 다릅니다.
💡 불변 객체(immutable)를 사용할 때는 각 데코레이터가 새로운 인스턴스를 반환하도록 설계하세요. 이렇게 하면 스레드 안전성이 보장되고 함수형 프로그래밍 스타일과 잘 어울립니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
마이크로서비스 배포 완벽 가이드
Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.
Application Load Balancer 완벽 가이드
AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.
고객 상담 AI 시스템 완벽 구축 가이드
AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.
에러 처리와 폴백 완벽 가이드
AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.
AWS Bedrock 인용과 출처 표시 완벽 가이드
AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.