iOS 공부를 시작한 지 얼마 안 된 시절, 개인 프로젝트를 진행하며 싱글톤이 동작하지 않는 문제를 경험한 적 있습니다. 그때의 기억을 떠올려 비슷하게 코드를 작성해 보면 아래와 같았습니다.
struct Singleton {
static var shared = Singleton()
private init() {}
var name = "민톨"
}
let singleton = Singleton.shared
print("내 이름은 " + singleton.name)
Singleton.shared.name = "한톨"
print("내 이름은 " + singleton.name)
싱글톤 패턴을 이용할 때 우리는 '하나의 객체'를 떠올립니다. 타입 프로퍼티로 생성된 shared 객체를 전역적으로 접근 + 추가 객체의 생성을 막음으로써 하나의 객체를 보장하는 것이죠.
그래서 처음 위 코드를 작성하며 기대했던 동작은 정상적으로 이름이 민톨에서 한톨로 바뀌는 것이었지만, 실제 결과는 달랐습니다.
왜 이런 결과가 나왔을까요? 답은 값 타입과 참조 타입의 특성에 있습니다.
값 타입과 참조 타입 Re-cap
우리가 Swift 문법을 공부하며 가장 많이 듣는 이야기는 추측컨대 아래 문장일 것입니다.
struct는 값(Value) 타입이고, class는 참조(Reference) 타입이다!
이 문장을 조금 더 자세히 풀어보면 값 타입은 말 그대로 값 그 자체를 의미하고, 참조 타입은 메모리 주소를 의미합니다. 즉 변수에 담을 때 struct는 값을 복사하지만, class는 메모리 주소를 복사합니다.
let struct1 = Struct()
let struct2 = struct1
print(struct1)
print(struct2)
// 구조체는 동일한 값을 담은 변수가 두개 생긴 것
// Struct()
// Struct()
let class1 = Class()
let class2 = class1
// 클래스는 같은 메모리 주소를 담은 변수가 두개 생긴 것
// 0x0000600000e9c1e0
// 0x0000600000e9c1e0
print("class1: \(ObjectIdentifier(class1))")
print("class2: \(ObjectIdentifier(class2))")
우린 여기서 struct로 생성한 싱글톤이 왜 의도한 대로 동작하지 않았는지 알 수 있습니다.
문제의 코드 다시 뜯어보기
처음 만든 싱글톤 코드의 동작 방식을 순차적으로 뜯어보겠습니다.
1. 타입 프로퍼티로 인스턴스 생성
struct Singleton {
static var shared = Singleton() // ⭐️ 현재 값이 담긴 인스턴스!
private init() {}
var name = "민톨"
}
struct로 싱글톤을 구현하기 위해 타입 프로퍼티로 Singleton() 객체를 생성하고 있습니다. 값 타입의 특성상 shared 안에는 현재 '값'이 들어가 있습니다.
2. singleton 상수 안에 싱글톤 객체(인스턴스) 담기
let singleton = Singleton.shared // ⭐️ 동일한 값이 담긴 새로운 인스턴스
Singleton.shared로 접근해 꺼내온 '값'을, singleton 상수 안에 복사하고 있습니다. 느낌이 오시나요? 결론부터 이야기하자면 이 부분이 가장 큰 문제였습니다.
값을 복사해 새로운 상수 안에 담았다는 것은, 새로운 값이 하나 더 생성되었다는 뜻입니다. 우리가 접근하려는 단일 객체와는 아무런 상관이 없는 새로운 값이 담긴 것입니다.
3. 첫 번째 출력
print("내 이름은 " + singleton.name)
첫 번째 출력은 동일한 값이 담겼으니 역시 초깃값인 '민톨'이 출력됩니다.
4. Singleton 값 업데이트
Singleton.shared.name = "한톨" // ⭐️ 구조체 내부의 shared 값의 업데이트
singleton이 상수로 선언되었기 때문에 자연스레 Singleton의 타입 프로퍼티로 업데이트를 시도합니다. 이 코드는 원래 의도대로 단일 객체의 '값'이 업데이트 된 것이 맞습니다.
5. 두 번째 출력
print("내 이름은 " + singleton.name)
하지만 마지막 출력은 '값'을 복사해 새롭게 만들어진 상수 singleton을 담고 있기 때문에(즉, 업데이트한 적이 없기 때문에) '민톨'을 출력하며 코드는 끝이 나게 됩니다.
결국 싱글톤 패턴의 의도인 '단일 객체'에 대한 접근이 아닌, '새로운 값'으로 접근했기 때문에 이런 문제가 발생한 것으로 정리할 수 있습니다.
아니! 당연히 타입 프로퍼티로 접근해야죠!
여기서 충분히 반론할 수 있습니다. "상수에 값을 복사해서 담았으니 당연히 의도대로 동작하지 않을 것이고, 타입 프로퍼티로 접근해 업데이트하면 원래 의도대로 사용할 수 있다!"라고요.
struct Singleton {
static var shared = Singleton()
private init() {}
var name = "민톨"
}
print("내 이름은 " + Singleton.shared.name)
Singleton.shared.name = "한톨"
print("내 이름은 " + Singleton.shared.name)
실제로 위 코드를 실행하면 원래 의도대로 이름이 정상적으로 업데이트됩니다. '단일 객체에 대한 접근'을 한 것이 맞으니까요.
하지만 여기서 다시 한번 되짚어봐야 하는 것은 싱글톤 패턴의 '의도'입니다. 싱글톤 패턴을 한 문장으로 정의하면,
객체를 하나만 생성하고, 전역적으로 접근할 수 있도록 '보장'하는 디자인 패턴.
즉, struct로 싱글톤을 만든다는 것은 전역적으로 접근할 수 있도록 보장할 순 있지만, 객체(인스턴스)를 하나만 생성하는 것은 보장할 수 없기 때문에 싱글톤 패턴을 구현하지 못했다고 볼 수 있습니다.
값을 복사해 새로운 변수에 담음으로써 객체를 추가로 생성(정확히는 복사)할 수 있기 때문에 단일 객체를 보장할 수 없게 되는 것입니다.
그래서 우리는 class로 싱글톤을 생성 후, 타입 프로퍼티 안에 담긴 메모리 주소를 활용해 싱글톤 패턴을 구현하게 됩니다.
class Singleton {
static let shared = Singleton() // ⭐️ 메모리 주소가 담기게 됩니다!
private init() {}
var name = "민톨"
}
let singleton = Singleton.shared // ⭐️ 그 메모리 주소를 복사해 새로운 변수에 담습니다.
print("내 이름은 " + singleton.name)
Singleton.shared.name = "한톨"
print("내 이름은 " + singleton.name)
// 내 이름은 민톨
// 내 이름은 한톨
결국 구조체로 싱글톤을 생성할 순 없을까?
다시 한번 정리하자면, struct로 싱글톤을 생성하는 것은 1. 값을 복사해 새로운 변수에 담음으로써 2. 단일 객체를 보장할 수 없기 때문에 3. 싱글톤 패턴을 구현할 수 없습니다.
하지만 반대로 말하면, 복사를 막아 단일 객체를 보장할 수 있다면 struct로 싱글톤 패턴을 만들 수 있다는 뜻이기도 합니다.
WWDC23에서 소개된 Noncopyable 프로토콜을 채택하면 이를 가능하게 만들 수 있습니다. 해당 포스팅은 이에 대한 것은 아니기 때문에 간단히 요약하자면 복사가 불가능한 타입을 나타내는 프로토콜입니다.
Noncopyable 프로토콜을 채택함으로써 새로운 변수에 값을 담는 것(인스턴스를 복사하는 것)을 명시적으로 막을 수 있게 됩니다. 결국, struct에서도 싱글톤 패턴을 어찌어찌(?) 구현이 가능하다고 볼 수 있을 것 같습니다.
하지만 싱글톤 패턴의 맥락과 더 잘 맞아떨어지고, 구현의 번거로움까지 생각한다면 class를 사용하지 않을 이유가 없기도 합니다. 😌
중요한 것은, struct로 싱글톤을 생성했을 때의 문제점을 이해하고 인지하는 것일 테니까요!
정리하며
처음 개인 프로젝트를 진행하며 삽질을 했던 것이 생생합니다. struct와 class의 본질을 제대로 이해하게 된 결정적 계기이기 때문에 더 그런 것 같습니다.
기술 면접에서 대답을 꽤나 잘했다고 생각했는데 역시나 더 잘할 수 있는 구석은 언제나 있는 것 같습니다.
'iOS' 카테고리의 다른 글
Array / Set / Dictionary Swift의 컬렉션 알아보기 (2) | 2025.02.06 |
---|---|
캐플 리팩토링 두 번째 이야기 - 프로젝트 세팅하기 (0) | 2025.01.30 |
캐플 리팩토링 첫 번째 이야기 - 방향성 설정하기 (0) | 2025.01.22 |
비동기 작업의 단위, Task 알아보기 (0) | 2025.01.22 |
MVC와 Cocoa MVC, 뭐가 다를까? (0) | 2025.01.16 |