🟣 new 한 줄이 새는 곳
앞 장에서 상위 모듈은 추상에만 의존하기로 했다. 그런데 프로그램 어딘가에서는 반드시 구체 타입을 만들어야 한다. let sound = ChimeSound() 같은 줄이 그 순간이다. 이 한 줄이 문제가 되는 이유는, 이 줄을 쓴 코드가 ChimeSound라는 구체 타입 이름을 알고 있다는 데 있다. 애써 추상에 의존하게 만들어 놓고, 생성하는 지점에서 다시 구체 타입에 손을 대는 셈이다.
이 new가 코드 곳곳에 흩어져 있으면 더 나빠진다. 어느 날 ChimeSound를 PremiumChimeSound로 바꾸려면, 그걸 생성하던 모든 자리를 찾아 고쳐야 한다. 생성 패턴은 이 "만드는 행위"를 한곳으로 모으고 격리해서, 무엇을 만들지가 바뀌어도 만드는 자리는 하나만 고치게 한다. 1부에서 세운 개방-폐쇄 원칙을 객체 생성에 적용한 것이다.
🟣 팩토리 메서드: 생성을 함수 뒤로 숨긴다
가장 단순한 형태는 생성 로직을 함수 하나로 감싸는 것이다. 흩어진 new를 하나의 함수로 모으면, 호출하는 쪽은 구체 타입 이름을 몰라도 된다.
enum SoundKind {
case chime, silent
}
enum AlertSoundFactory {
static func make(_ kind: SoundKind) -> AlertSound {
switch kind {
case .chime: return ChimeSound()
case .silent: return SilentSound()
}
}
}
// 호출하는 쪽은 구체 타입을 모른다
let sound = AlertSoundFactory.make(.chime)호출부는 AlertSound라는 추상만 돌려받는다. ChimeSound라는 이름은 팩토리 안에만 남는다. 새 소리를 추가하면 switch에 한 줄만 늘리고, 호출부는 손대지 않는다. 구체 타입을 아는 코드가 프로젝트에서 이 팩토리 한 군데로 좁혀진 것이 핵심 이득이다.
고전적인 팩토리 메서드 패턴은 이걸 상속으로 구현한다. 부모가 "무언가를 생성해서 그걸로 작업한다"는 흐름만 정의하고, 실제로 무엇을 생성할지는 추상 메서드로 비워 자식이 채우게 한다. Swift에서는 위처럼 switch나 클로저로 푸는 편이 더 흔하지만, "생성 결정을 나중으로 미룬다"는 의도는 같다.
🟣 추상 팩토리: 한 벌로 움직이는 객체들
팩토리 메서드가 객체 하나의 생성을 다룬다면, 추상 팩토리는 서로 어울려야 하는 객체들의 묶음을 다룬다. 필요해지는 상황이 분명하다. 다크 모드와 라이트 모드를 생각해 보면, 버튼도 배경도 텍스트도 전부 같은 테마로 맞아야 한다. 라이트 버튼에 다크 배경이 섞이면 안 된다.
이럴 때 "테마 하나를 고르면 그 테마에 맞는 부품 일습을 통째로 만들어 주는" 팩토리를 둔다.
protocol Theme {
func makeButton() -> Button
func makeBackground() -> Background
}
struct LightTheme: Theme {
func makeButton() -> Button { LightButton() }
func makeBackground() -> Background { LightBackground() }
}
struct DarkTheme: Theme {
func makeButton() -> Button { DarkButton() }
func makeBackground() -> Background { DarkBackground() }
}
// 테마를 한 번 고르면 이후 부품은 자동으로 짝이 맞는다
func buildScreen(theme: Theme) {
let button = theme.makeButton()
let background = theme.makeBackground()
}호출부는 Theme이라는 추상 하나만 받는다. 어떤 테마가 들어오든 부품끼리 짝이 어긋날 일이 없다. "한 벌로 움직여야 하는 제약"을 팩토리가 대신 보장해 주는 것이 추상 팩토리의 값어치다.
🟣 싱글턴: 하나만 있어야 한다는 유혹
생성 패턴 중 가장 많이 쓰이고 가장 많이 오용되는 것이 싱글턴이다. 의도는 간단하다. 프로그램 전체에서 인스턴스가 딱 하나만 존재하도록 강제하고, 어디서나 그 하나에 접근하게 한다. 설정 값이나 로그 수집기처럼 여럿일 이유가 없는 대상에 쓴다.
final class AnalyticsLogger {
static let shared = AnalyticsLogger()
private init() {} // 외부에서 새로 만들지 못하게 막는다
func log(_ event: String) { /* ... */ }
}
AnalyticsLogger.shared.log("screen_view")private init()이 핵심이다. 생성자를 막아 외부에서 새 인스턴스를 못 만들게 하고, static let shared로 유일한 하나를 노출한다. Swift의 static let은 처음 접근할 때 한 번만, 스레드 안전하게 초기화되므로 이 형태 자체는 안전하다.
문제는 안전성이 아니라 싱글턴이 전역 상태라는 데 있다. 어디서나 접근할 수 있다는 건, 어디서나 그 상태를 바꿀 수 있다는 뜻이기도 하다. 누가 언제 값을 바꿨는지 추적이 어려워지고, 의존 관계가 코드에 드러나지 않고 숨는다. 어떤 함수가 AnalyticsLogger.shared를 몰래 쓰고 있으면, 그 함수의 시그니처만 봐서는 로거에 의존한다는 사실을 알 수 없다.
가장 아픈 건 다시 테스트다. 테스트마다 shared가 같은 인스턴스를 물고 있으니, 앞 테스트가 남긴 상태가 뒤 테스트로 샌다. 진짜 서버로 이벤트를 쏘는 로거라면 테스트에서 가짜로 바꿀 방법도 마땅치 않다.
그래서 실무의 절충은 이렇게 잡았다. 싱글턴 인스턴스는 편의를 위해 두되, 의존하는 쪽은 shared를 직접 부르지 말고 프로토콜로 주입받게 한다.
protocol Logging {
func log(_ event: String)
}
extension AnalyticsLogger: Logging {}
final class CheckoutViewModel {
private let logger: Logging
// 실행에서는 .shared를 넣고, 테스트에서는 가짜를 넣는다
init(logger: Logging = AnalyticsLogger.shared) {
self.logger = logger
}
}이러면 평소에는 싱글턴의 편의를 그대로 쓰면서, 테스트에서는 Logging을 구현한 가짜를 주입해 상태 누수를 끊는다. 1부의 의존성 역전이 싱글턴의 부작용을 막는 안전장치로 돌아온 셈이다.
🟣 정리
- 애써 추상에 의존해도 어딘가에서는 구체 타입을 생성해야 한다. 생성 패턴은 그
new를 한곳으로 모아, 무엇을 만들지가 바뀌어도 만드는 자리만 고치게 한다. - 팩토리 메서드는 생성 로직을 함수 뒤로 숨겨, 구체 타입을 아는 코드를 프로젝트에서 한 군데로 좁힌다.
- 추상 팩토리는 짝이 맞아야 하는 객체들을 한 벌로 만들어, 부품이 어긋날 제약을 팩토리가 대신 보장한다.
- 싱글턴은 인스턴스를 하나로 강제하지만 전역 상태라는 대가를 치른다. 의존 관계가 숨고 테스트가 어려워지므로,
shared를 직접 부르지 말고 프로토콜로 주입받아 부작용을 막는다. - iOS는 싱글턴으로 가득하다.
URLSession.shared,UserDefaults.standard,FileManager.default가 모두 이 패턴이다. 실무에서는 이들을 직접 부르는 대신 프로토콜로 감싸 주입하는 판단이 자주 쓰이고, 면접에서 "싱글턴의 문제가 무엇이냐"를 물으면 스레드 안전성이 아니라 전역 상태와 테스트 어려움을 짚는 것이 한 단 깊은 답이다.
객체를 만드는 문제는 정리했다. 다음은 만들어진 객체가 어떻게 행동하는가다. 소리 정책을 실행 중에 바꾸고 싶던 1부의 요구를 정면으로 다루는 두 패턴, 전략과 옵저버를 본다.