2025. 6. 5. 21:24ㆍSwift Architecture/Design Pattern
💡 싱글톤 패턴(Singleton Pattern)은 앱에서 전역적 (global)으로 접근 가능한 하나의 "공유 클래스 인스턴스 (shared instance of a class)"를 만들어 사용하도록 하는 디자인 패턴이다.
예전 제 글 중, Swift 디자인 패턴 중 싱글톤 패턴 (Singleton Pattern)에 대해 설명했던 내용이 있었습니다.
아래에 첨부되어 있는 글의 내용은 타입 프로퍼티 (static 키워드)부터 시작해
실제 싱글톤 패턴을 Swift 코드로 구현하는 방법, 그리고 싱글톤의 가벼운 장단점과, Apple이 채택하고 있는 싱글톤의 사례 (UserDefaults.standard, Notification.default 등등)까지 알아봤었죠.
✔️ 싱글톤 패턴이 무엇인지 아직 개념에 대한 이해가 필요하신 분은, 아래 첨부된 링크의 글을 읽고 오길 권장드립니다 !
[Design Pattern] 내가 보려고 정리하는 Swift 디자인 패턴 (1) - 싱글톤 패턴(Singleton Pattern)
1️⃣ static : 타입 프로퍼티 (Type Property) 개념 정리Swift의 static 키워드는 인스턴스 생성 여부와 상관없이 무조건 1개의 값만 존재하는 프로퍼티를 선언할 때 사용한다.객체 지향 프로그래밍에서
mini-min-dev.tistory.com
오늘은 이 큰 개념에서 한 걸음 더 들어가보고자 합니다!
그냥 shared 객체를 만들어서 이곳저곳에 접근했다 -> "우아! 나 싱글톤 패턴 이번 프로젝트 코드에서 적용했어요!"로 이어지는 흐름이 아니라, 조금 더 깊게 생각하고 / 디자인 패턴 적용의 이유를 확실히 하고 개발하는 여러분들이 되었으면 하는 마음이랄까요?
그래서 이번 글은 싱글톤 패턴 (Singleton Pattern)의 심화 개념을 다루며, 아래와 같은 질문들의 답을 찾아가는 흐름으로 구성해볼까 합니다.
천천히 안전벨트 꽉 매고 따라가 봅시다💨
- 싱글톤 객체의 이름은 왜 항상 shared로 짓는거지? 다른 이름으로 지으면 안 되는 건가?
- 싱글톤 객체는 왜 지연 초기화 (lazy initialization)되어 생성되는 걸까? 그럼으로써 얻는 이점이 있을까?
- 싱글톤 패턴의 단점인 강한 결합 (Tight Coupling) 관계가 암묵적으로 발생하는 암묵적 의존성 (의존성 은닉)이 무엇이지? 어떻게 개선해볼 수 있을까?
- 그렇다면 SwiftUI의 EnvironmentObject와 비교해보면 "공통된 상태를 여러 곳에서 접근할 수 있게 한다"는 점에서 유사해보이는데, 어떤 차이점이 있는 거지?
1. 싱글톤 객체의 이름은 항상 shared여야 하는가?
🤔 싱글톤 객체의 이름은 왜 항상 shared로 짓는거지? 다른 이름으로 지으면 안 되는 건가?
일반적으로 싱글톤 객체를 Swift로 만든다면, 습관적으로 네이밍을 shared로 합니다. 아래 일반적인 싱글톤 코드처럼요!
class Singleton {
static let shared = Singleton()
private init() {}
}
Singleton.shared.어쩌구메서드로_접근()
하지만 사실 shared라는 이름은 네이밍 관례 (Convention) 일뿐, 반드시 shared로 이름을 설정할 필요는 없습니다.
단, shared라는 이름이 "전역에서 동일하게 공유되어 사용할 수 있는 단일 인스턴스"를 나타내기에 가장 직관적이므로 사용했던 것이죠.
Apple API에서의 싱글톤도 보편적으로 UIApplication.shared, URLSession.shared와 같이 shared 네이밍을 사용하고 있습니다.
싱글톤의 역할과 의도가 드러나도록 하는 다른 네이밍은 아래와 같은 것들을 고려해 볼 수 있습니다. (예시도 함께 기재해 보죠!)
- default : 가장 대표적인 설정이나, 기본으로 설정된 규칙을 의미할 때 -> 예시_NotificationCenter.default
- standard : 이름처럼 표준 규격으로 만들어진 인스턴스 의미를 강조하고 싶을 때 -> 예시_UserDefaults.standard
- main : 메인 스레드 (UI)와 관련된 인스턴스 혹은 메인 역할을 담당하는 인스턴스 의미를 강조할 때 -> 예시_UIScreen.main
- current : 현재 상태를 담고 있는 인스턴스 의미를 강조할 때 -> 예시_UserSession.current
여기서 언급된 것 외에도, 환경별 인스턴스 분기처리를 담당하기 위한 mock과 같은 네이밍과 / 구체적인 역할을 포함하는 manager, container, global과 같은 이름도 (굳이굳이) 생각해볼 수 있을 것 같습니다!
(개인적으로는 manager, container, global 이름보다는 shared가 더 Swift에서는 직관적이고 통용적으로 사용할 수 있는 이름이라고 생각합니다만...? 뭐 선택은 여러분들의 몫이니까요!)
2. 싱글톤 객체의 지연 초기화 (lazy initialization) 자세하게 알아보기
🤔 싱글톤 객체는 왜 지연 초기화 (lazy initialization)되어 생성되는 걸까? 그럼으로써 얻는 이점이 있을까?
지연 초기화 (lazy initialization)란 "어떤 객체나 값이 실제로 처음 접근 (사용)할 때 비로소 값이 생성되는 방식"입니다.
쉽게 말하면, 프로그램 시작 시점이 아니라 / 필요해질 때 비로소 객체를 만드는 (= 메모리에 올리는) 개념이죠!
그래서 만약 프로그램 전체에서 해당 객체나 값에 접근할 일이 없다면, 코드가 써있더라도 메모리에 공간이 할당되지 않을 것입니다.
Swift에서는 두 가지 방법으로 지연 초기화 (lazy initialization)를 구현할 수 있습니다.
하나는 인스턴스 프로퍼티에 명시적으로 lazy라는 키워드를 붙여주는 방법이고, 또 다른 하나는 싱글톤 객체의 구현방식인 타입 프로퍼티 (static let)를 사용하는 방법입니다.
여기서는 후자 방법, 즉 싱글톤 객체의 지연 초기화 전략에 대해서만 깊게 살펴볼 거예요!
class Singleton {
static let shared = Singleton() // 이 순간에는 사실 shared에 Singleton 인스턴스가 할당되지 않고 있음
private init() {}
}
Singleton.shared.어쩌구메서드로_접근() // 바로 이 순간에 비로소 인스턴스 객체가 생성된다는 의미!
의문이 듭니다!
도대체 왜 Apple은 그냥 프로그램 시작 시점이 아니라 / 싱글톤을 굳이굳이 사용하는 시점에 객체를 만들어서 사용하도록 한 것일까요?
일단 가볍게 생각해보면 "메모리 공간을 절약할 수 있다는 점"이 있습니다.
아직 사용하지도 않는데 (극단적으로 결국은 사용하지 않을 수도 있는데), 메모리 공간 일부를 객체가 딱하니 차지하고 있자면 너무 비효율적이고 불필요하다고 생각한거죠.
그래서 객체가 사용되는 시점에 후다닥 만들어서 사용합니다.
프로그램의 시작 시점에 필요한 로딩 속도는 빨라질 것이고요 / 처음 접근하는 시점에 로딩 속도는 느려질 것입니다. (장단점이 명확하쥬?)
하지만 이것보다 더 중요한 것은 지연 초기화를 통해 "싱글톤 객체의 스레드 안전성 (Thread Safety)을 보장할 수 있다는 점"입니다.
static let 키워드가 붙어있으면 Swift 컴파일러 (LLVM)가 자동으로 Thread-safe Lazy Initialization을 적용해주도록 구현되어 있습니다.
위와 같은 보장 상황을 원자적 초기화 (Atomic initialization)라고 부르고, 내부적으로는 dispatch_once와 유사한 방식을 사용합니다.
*dispatch_once란 Objective-C나 Swift 초기버전에서 "해당 코드를 앱 전체에서 한 번만 실행하도록 보장하도록, 스레드 안전의 목적"으로 수동으로 호출하던 메서드를 의미한다고 가볍게 이해하고 넘어갑시다.
☑️ 결론적으로 하나의 인스턴스를 사용하려고 했다가, 여러 스레드에서 인스턴스가 생성되는 "멀티 스레드 동기화 이슈"를 해결하기 위해 (= Thread-Safe하게 싱글톤을 사용하기 위해)
Swift는 전역적 접근이 가능한 타입 프로퍼티 (static let)를 지연 초기화 (lazy initialization)하여 사용하도록 만든 것입니다.
+ 메모리 절약의 장점을 함께 곁들인 채.
3. 싱글톤 패턴의 암묵적 의존성 (의존성 은닉) 문제와 개선방법 고민해보기
🤔 싱글톤 패턴의 단점인 강한 결합 (Tight Coupling) 관계가 암묵적으로 발생하는 암묵적 의존성 (의존성 은닉)이 무엇이지? 어떻게 개선해 볼 수 있을까?
싱글톤 (Singleton)은 전역 상태 (Global State)를 사용한다는 특성에서,
필연적으로 싱글톤과 싱글톤을 채택하는 클래스 사이에 암묵적 의존성 (의존성 은닉)이 강한 결합 (Tight Coupling) 관계로 발생하게 됩니다.
*싱글톤을 사용하면 클래스 내부에서 싱글톤 인스턴스를 직접 참조하기 때문에, 해당 클래스가 어떤 의존성을 갖는지 외부에서 명시적으로 드러나지 않게 되는데요. 일반적으로 이 상황을 암묵적 의존성 (의존성 은닉)이라고 부릅니다.
**어떤 클래스가 MyService.shared를 사용해도 이것이 생성자나 메서드 상에서 MyService에 의존하고 있다는 사실을 알 수 없다는 의미!
강한 결합 (Tight Coupling)이란 "한 모듈을 변경하면 다른 모듈에 연쇄적인 변경을 강요하는 상황"을 의미합니다.
다시 말해, 한 모듈의 수정이 다른 여러 모듈에게도 끼치는 파급 효과 (Ripple Effect)가 커져 유지보수를 어렵게 만들 때, 이 모듈들은 서로 강한 결합 (Tight Coupling) 관계에 있다고 말하죠.
반대로 컴포넌트 (객체, 모듈을 포함) 간의 의존성이 최소화되면, 이를 느슨한 결합 (Loose Coupling) 관계에 있다고 정의할 수 있습니다.
- 한 컴포넌트에서 발생한 수정사항이 다른 컴포넌트에 연쇄적으로 수정사항이 미치지 않는 것 (사실 완전 미치지 않는 것이 베스트지만, 현실적으로 쉽지 않기에 "최소화" 정도로 문구를 정함)을 의미
- 다르게 말하면, 하나의 컴포넌트가 다른 컴포넌트의 내부 구조 (구체적인 구현)를 모른 채 협업할 수 있는 관계를 의미
- 상호 독립성이 보장되어 있기 때문에 테스트 시에도 Mock을 주입하기가 용이한 관계
아래 예시 코드를 기준으로 Service 클래스는 `Singleton.shared`를 직접 사용하며, Singleton 클래스에 강하게 의존하고 있습니다.
class Singleton {
static let shared = Singleton()
private init() {}
func doSomething() {
print("싱글톤에서 어떤 일을 했군요...")
}
}
class Service {
func useSingleton() {
Singleton.shared.doSomething()
}
}
의존성 주입 (DI)과 프로토콜 (Protocol) 기반 설계로 이제 이 문제점을 개선해 볼게요!
우선 프로토콜로 문제 해결 방향을 설정해봅니다.
Service 클래스는 위에서 `doSomething()`이라는 역할 (메서드)이 필요해 Singleton 객체를 의존하고 있었습니다.
즉, Singleton이라는 클래스 객체가 직접적으로 필요해서 의존하는 것이 아니라, doSomething() 이라는 역할을 필요로 해서 의존했던 것이죠.
그렇다면, 타입 (Class)이 아닌 역할 (Role)을 기반으로 프로토콜을 정의하게 수정해볼 수 있습니다! 아래처럼요!
protocol DoSomethingProtocol {
func doSomething()
}
그러면 싱글톤의 역할도 다시 정의해볼 수 있습니다.
싱글톤 객체 그 자체로 의미를 갖는 것이 아니라, DoSomethingProtocol 이라는 추상화된 역할을 수행하는 "하나의 구현체"로 다시 재정의하는 것이죠.
class Singleton: DoSomethingProtocol {
static let shared = Singleton()
private init() {}
func doSomething() {
print("싱글톤에서 어떤 일을 했군요...")
}
}
그렇다면, 실제 Service에서는 의존성 주입 (DI)을 사용해 느슨한 결합 관계로 연결해볼 수 있습니다. 문제가 개선된거에요!
Service 클래스 생성자에 어떤 것을 의존 (DoSomethingProtocol)하고 있는지 명시적으로 보여주기 때문에, 의존 관계를 명확하게 개발자가 확인할 수 있구요.
Service 객체에 `Singleton.shared`가 아닌 다른 Mock도 주입할 수 있기에 유연성 또한 커졌다고 볼 수 있습니다.
class Service {
private let worker: DoSomethingProtocol
init(worker: DoSomethingProtocol) {
self.worker = worker
}
func useWorker() {
worker.doSomething()
}
}
let service = Service(worker: Singleton.shared)
service.useWorker()
4. 싱글톤 패턴과 EnvironmentObject의 차이점은?
🤔 그렇다면 SwiftUI의 EnvironmentObject와 비교해보면 "공통된 상태를 여러 곳에서 접근할 수 있게 한다"는 점에서 유사해보이는데, 어떤 차이점이 있는거지?
싱글톤 패턴 개념을 들으면 자연스럽게 떠오르는게 SwiftUI에서 사용하는 환경 객체 @EnvironmentObject일 것입니다.
아래 예시 코드를 살펴보면,
ObservableObject를 상속하고 있는 클래스 UserManager를 / 부모 뷰 (ContentView)의 .environmentObject로 삽입하면 / 그 부모 뷰의 모든 자식 뷰에서는 동일한 객체 (userManager)를 접근할 수 있게 됩니다!
-> 하나의 객체를 이곳저곳에서 접근할 수 있다는 점에서 싱글톤 패턴이랑 유사하다고 생각이 들법 하죠?
final class UserManager: ObservableObject {
@Published var username: String = "Mini"
}
struct RootView: View {
var body: some View {
ContentView()
.environmentObject(UserManager())
}
}
struct ContentView: View {
@EnvironmentObject var userManager: UserManager
var body: some View {
Text(userManager.username)
}
}
하지만 보이는 것처럼 EnvironmentObject는 뷰 계층 (트리 구조)을 타고 전달된다는 점이 싱글톤과의 가장 큰 차이점입니다.
앱 라이프사이클과 함께 유지되는 싱글톤과 다르게 / 환경 객체는 뷰 라이프사이클과 함께 (최상위 뷰 App Scene에서 객체를 주입했다면, 해당 뷰가 속한 Scene이 유지되는 동안) 유지된다는 뜻이죠.
뷰가 소멸되면 객체도 소멸될 수 있다는 의미 -> 특히 다중 Scene을 사용할 때는 각 Scene마다 다른 환경 객체를 주입해서 사용할 수 있어, 윈도우별로 독립된 상태를 유지하는 것이 가능합니다.
또한, EnvironmentObject는 @Published를 함께 사용하여 자동으로 변화를 감지하고 그에 따른 뷰의 업데이트를 그리기 용이합니다.
즉, UI와 관련된 전역 상태는 SwiftUI에서 싱글톤보다 @EnvironmentObject를 사용하는 것이 더 용이하다는 뜻이죠!
*UI와 관련된 전역 상태는 앱 테마, 로그인 여부, 사용자 정보 등이 해당될 수 있을 것 같네요!!
☑️ 결론적으로 SwiftUI에서 UI와 관련되어 뷰 전반에 걸쳐 공통된 상태값을 관리해야 하는 경우,
상위 뷰에서 주입된 상태값을 하위 뷰에서도 필요로 하는 경우 EnvironmentObject가 싱글톤 (Singleton)의 대안이 될 수 있습니다.
-
단, EnvironmentObject 역시 명시적 주입의 누락 가능성과 -> .environmentObject(UserManager())를 빼먹는 경우
프로토콜 (Protocol)을 사용한 추상화 제약 등의 이유로 권장되지 않는 추세인 것 같습니다.
이 역시 위에서 살펴본 것처럼 프로토콜 혹은 프로퍼티 래퍼를 사용해 역할 기반으로 분리하는 관점이 대안이 될 수 있겠군요..!
이번 글에서 준비한 내용은 여기까지입니다!
추가적으로 싱글톤 패턴 (Singleton Pattern)과 관련한 질문사항, 혹은 새로운 인사이트 제공, 잘못된 개념 신고까지 전해주실 수 있는 이야기가 있다면 얼마든지 댓글로 환영입니다 ~~~!!
그럼 끝 ✌🏻
Reference
Managing a Shared Resource Using a Singleton | Apple Developer Documentation
Provide access to a shared resource using a single, shared class instance.
developer.apple.com
Apple iOS — Singleton Design Pattern
The Singleton design pattern is a creational pattern that ensures that only one instance of a class can be created and used throughout the…
imad-ali.medium.com
Hidden Dependency in Single Design Pattern
The Singleton design pattern ensures that a class has only one instance and provides a global point of access to that instance. While this…
medium.com
Demystifying Singleton Design Pattern: Implement Singletons in iOS
Explore the Singleton design pattern in iOS with Swift. Understand its life cycle, implementation, benefits, and challenges.
medium.com