iOS 개발하다 보면 이런 순간이 옵니다. 기본 기능은 그대로 두고 로깅 하나만, 캐싱 하나만 얹고 싶은데, 그걸 하려고 클래스를 상속받기 시작하면 어느새 상속 트리가 엉망이 되어 있죠.
네트워크 클라이언트에 기능을 하나씩 붙이다 보면 어느새 CachingLoggingRetryingClient 같은 이름의 클래스가 만들어지기 쉽죠.
오늘은 바로 그 문제를 푸는 Swift 데코레이터 패턴을 정리해볼게요. 상속 없이 기능을 양파처럼 겹겹이 입히는 방법입니다.
데코레이터 패턴은 같은 프로토콜을 따르는 객체로 원본을 감싸서, 원래 코드를 건드리지 않고 기능을 한 겹씩 덧입히는 방식입니다.
결론부터 말하면 이렇습니다.
- 공통 동작을 프로토콜로 먼저 정의한다
- 데코레이터는 그 프로토콜을 따르면서, 같은 프로토콜 타입 하나를 속에 품는다
- 자기 일(로깅·캐싱 등)을 하고 나머지는 품은 객체에 위임한다
- 필요한 만큼 계속 감싸면 기능이 쌓인다
데코레이터 패턴이 뭔가요? 상속이랑 뭐가 다른가요
상속은 ’~의 한 종류’를 표현합니다. 자식이 부모의 모든 걸 물려받죠.
문제는 조합입니다. 로깅되는 클라이언트, 캐싱되는 클라이언트, 둘 다 되는 클라이언트를 상속으로 만들면 조합마다 클래스가 하나씩 생겨요.
데코레이터는 ’~을 감싼 것’을 표현합니다.
감싸는 객체와 감싸이는 객체가 같은 인터페이스를 공유하기 때문에 바깥에서 보면 둘이 구분되지 않아요. 그래서 몇 겹을 감싸든 사용하는 쪽 코드는 그대로입니다.
한마디로 상속은 컴파일 시점에 관계가 고정되고 데코레이터는 실행 중에 자유롭게 조립됩니다.
Swift 데코레이터 패턴 구현, 코드로 보기
예제로 데이터를 불러오는 DataLoader를 만들어볼게요. 먼저 모두가 따를 프로토콜입니다.
protocol DataLoader {
func load(id: String) -> Data?
}
// 실제 일을 하는 기본 구현
struct NetworkLoader: DataLoader {
func load(id: String) -> Data? {
print("네트워크에서 \(id) 요청")
return Data("payload-\(id)".utf8)
}
}
여기까지는 평범한 프로토콜과 구현이에요.
이제 핵심입니다. 데코레이터는 DataLoader를 따르면서, 속에 또 다른 DataLoader를 품습니다.
// 로그를 남기는 데코레이터
struct LoggingLoader: DataLoader {
let wrapped: DataLoader // 감싸는 대상
func load(id: String) -> Data? {
print("[로그] load 시작: \(id)")
let result = wrapped.load(id: id) // 실제 일은 위임
print("[로그] load 완료: \(id)")
return result
}
}
LoggingLoader는 자기 할 일(로그 찍기)만 하고, 진짜 로딩은 wrapped에게 넘깁니다.
이 구조 덕분에 무엇을 감쌌는지 신경 쓸 필요가 없어요. DataLoader이기만 하면 됩니다.
기능을 겹겹이 쌓아보기
이번엔 캐싱 데코레이터를 하나 더 만들어서 진짜로 겹쳐볼게요.
final class CachingLoader: DataLoader {
let wrapped: DataLoader
private var cache: [String: Data] = [:]
init(wrapping loader: DataLoader) { self.wrapped = loader }
func load(id: String) -> Data? {
if let hit = cache[id] { return hit } // 캐시에 있으면 바로
let data = wrapped.load(id: id) // 없으면 위임
cache[id] = data
return data
}
}
이제 조립하는 부분이 데코레이터 패턴의 백미입니다.
let loader = LoggingLoader(
wrapped: CachingLoader(wrapping: NetworkLoader())
)
안쪽부터 읽으면 됩니다. 네트워크 로더를 캐싱으로 감싸고 그걸 다시 로깅으로 감쌌어요.
호출이 들어오면 로그 → 캐시 확인 → (없으면) 네트워크 순서로 흐릅니다.
순서를 바꾸고 싶다면 감싸는 순서만 바꾸면 돼요. NetworkLoader도, CachingLoader도 코드 한 줄 안 고칩니다.
필요 없는 기능은 그냥 그 겹을 빼면 그만이고요.
상속이랑 데코레이터, 언제 뭘 쓸까
둘 중 하나가 항상 정답은 아닙니다. 상황을 나눠봤어요.
| 상황 | 추천 |
|---|---|
| 명확한 ‘~의 종류’ 관계 | 상속 |
| 기능을 자유롭게 조합·탈착 | 데코레이터 |
| 실행 중 기능을 켰다 껐다 | 데코레이터 |
| 원본 코드를 못 건드릴 때 | 데코레이터 |
| 조합 경우의 수가 많을 때 | 데코레이터 |
특히 로깅, 캐싱, 재시도, 인증 헤더 추가처럼 본질이 아닌 부가 기능은 데코레이터가 잘 맞습니다.
반대로 억지로 데코레이터를 쓰면 감싸는 겹이 많아져 디버깅할 때 콜 스택이 깊어지는 단점도 있어요. 이건 감안하고 쓰시는 게 좋습니다.
자주 나오는 질문 두 가지
Q. struct랑 class 중 뭘 써야 하나요?
상태(캐시 같은 저장 공간)를 데코레이터가 직접 들고 있어야 하면 class가 편합니다. 단순 위임만 하면 struct로도 충분해요. 위 예제에서 캐싱만 class로 둔 이유입니다.
Q. 감싸는 겹이 많아지면 성능 문제 없나요?
호출이 한 겹씩 타고 내려가니 아주 미세한 오버헤드는 있습니다. 다만 네트워크나 디스크 I/O에 비하면 무시할 수준이라, 대부분의 앱에서는 신경 쓸 정도가 아니에요.
데코레이터 패턴은 결국 프로토콜 하나 정의하고 ’같은 프로토콜을 품는 객체’를 만들어 위임하는 게 전부입니다.
다음에 기능 하나 붙이려고 상속 트리를 뒤적이게 되면, 한 겹 감싸는 방식을 떠올려 보세요. 코드가 훨씬 가벼워질 거예요. 오늘 예제 그대로 플레이그라운드에 옮겨보시길 추천드립니다 🙂
