Sign In

Swift에서의 싱글턴 패턴

한결

싱글턴 패턴.

싱글턴은 어떤 하나의 공유되는 인스턴스를 생성해서 앱 전반적으로 활용하는 방법이다.
위 문장에서 밑줄 그어진 부분이 아무래도 중요할 것인데, 내가 이해한 싱글턴 패턴은 앱이 동작하고 있는 라이프사이클 전반에 걸쳐 하나만 생성되어 마치 값이 메모리에 올라가 있는 것처럼 활용되는 인스턴스를 의미한다.
많은 사람들이 싱글턴 패턴에서 아래의 조건을 잘 충족하는 것이 중요하다고 한다.
하나의 인스턴스만 참조하여 재사용할 것 (= 생성자로 다른 인스턴스 참조를 만들지 못해야 할 것)
앱 라이프사이클 상에서는 계속 유지되는 참조를 가져야 한다.
값이 변경되지 않은 상태(=멤버 프로퍼티)만 정의할 것
그래서 보통은 아래와 같은 형태로, 앱을 이루는 여러 코드에서 '공유(=shared)' 될 수 있는 타입 상수 멤버를 통해 자기 자신의 하나뿐인 인스턴스에 접근한다. 하나뿐인 인스턴스에 모든 뷰 객체(뷰 컨트롤러 객체)나 뷰 모델 객체들이 접근할 수 있는 것은 모두 하나의 주소값만 참조한다는 뜻이다. 그렇기 때문에 앱 전체에서 변경되지 않아야 하는 글로벌한 설정에 대한 부분을 싱글턴 패턴으로 많이 작성하고는 한다.
final class SomeSingleton {
    static let shared = SomeSingleton()

    private init() {} // initializer에 접근할 수 없도록 막는다.

    func someMethod() {}
}
static 상수를 통해서 메모리에 올라와 있는 하나의 객체에만 접근할 수 있게된다. (앱이 동작하는 동안 참조 지속) 다른 여러 파일에서 shared라고 하는 변수가 초기화 되는 것처럼 보여질 수 있는데, 실제로 SomeSingleton.shared 형식으로 접근을 하면, 메모리에 올라간 그 객체의 주소를 참조하게 되는 것이다.

싱글턴 패턴은 어떤 이유에서 사용될까.

다른 여러 객체에서 하나의 참조 주소만 바라볼 수 있기 때문에.
1.
메모리 측면에서 관리가 용이하다.
2.
1번의 연장선에서, 앱 전반에서 '굳이' 필요시마다 객체를 생성할 필요 없이, 하나의 객체로 작업을 처리시킬 수 있다.
아무래도 해당 객체가 단 한 번의 인스턴스 초기화가 되어 앱 라이프 사이클 내에서 관리되기 때문에 1번에서 말한 것처럼 메모리에서의 재사용, 누수 관리가 편할 수 있다. 더 연장해서 살펴보면, 늘 같은 주소를 참조하는 하나의 객체만 재사용하기 때문에 그 객체에 대한 접근 속도도 조금은 더 빨라질 수 있겠다. (초기화 시점을 제외하고는)
2번에 대해서, 앱에 처음 접근하거나 필요에 의해서 저장된 유저 정보를 가져와야 하는 UserManager 객체를 구성한다고 생각해보자. 이럴 때마다 해당 객체를 매번 새롭게 인스턴스 초기화하고, 내부의 자원이나 메서드를 활용하는 방식은 비효율적일 수 있다. 공유되는 하나의 객체가 있고, 유저 정보에 대한 상태를 가지는게 아니라, 그 유저정보를 불러오거나 인증을 처리하는 로직을 하는 메서드들 있다면 싱글턴이 효율적일 수 있다.
싱글턴 객체가 아무래도 전역적인 메모리 상태에서 서로 다른 객체에 의존성을 만들 수 있기 때문에 '상태'를 돌려쓰는 형태로 설계되지 않아야 한다는 말이 있다. 처음에는 이게 무슨 말인지 잘 이해가 되지 않았다. 그런데 조금 더 고민을 해보면 여러 객체에서 비동기 작업을 통해 우리가 알 수 없는 동시적인 상태 변화를 야기한다고 가정했을 때, 그럴때는 정말 아찔한 상태 관리가 필요해질 것이다. 그런 점에서, 동시성을 의도적으로 컨트롤 할 수 있는 Actor와 같은 참조형 타입의 틀로 싱글턴을 만들어 상태를 관리하는 방법이 고려될 수 있겠다.
이전 프로젝트에서 JWT 토큰의 유효성을 검증하고 갱신하는 로직을 Actor 객체의 싱글턴 패턴으로 구현한 적이 있다. 공유될 수 있는 토큰 상태는 내부 인스턴스로 따로 두지 않은 상태에서, keychain 또는 UserDefaults에 저장한 토큰을 여러 객체가 바라볼 수 있도록 설계했었다. 동시적인 토큰 접근을 잘 제어할 수 있었고, 관리해야 하는 객체가 하나 뿐이어서 코드 작성에도 큰 이점이 있었다.
actor TokenManager {
   static let shared = TokenManager()

   // networkManager 역시 싱글턴 패턴의 참조형 객체다.
   private let networkManager: NetworkManager = .shared
   
   private enum encryptKeys: String {
      case access = "accessKey"
      case refresh = "refreshKey"
   }
   
   private init() {}

   func readAccessToken() throws -> String {
      do {
         return try readToken(by: .access)
      } catch {
         throw TokenErrors.notFoundTokenError
      }
   }

   func refreshToken() async -> Bool {
      do {
         let access = try readAccessToken()
         let refresh = try readRefreshToken()
         let refreshTokenInput: RefreshTokenInput = .init(
                                                accessToken: access, 
                                                refreshToken: refresh)
         
         let newAccessToken = try await networkManager.request(
            by: AuthEndPoint.refreshToken(input: refreshTokenInput),
            of: RefreshTokenOutput.self
         ).accessToken
         
         return registerAccessToken(by: newAccessToken)
      } catch {
         return false
      }
   }
     
   ...
}

실제로 싱글턴 패턴을 사용해본다면.

싱글턴 패턴은 iOS 개발을 하면서 알게 모르게 많이 활용하게 된다. 로컬 notification을 처리할 때라던지, UserDefaults에 저장한 특정한 값을 활용할 때 NotiificationCenter.default, Userdefaults.standard 와 같은 형태로 사용된다. 이렇게 Swift 자체적인 API들도 싱글턴 형태로 접근하는 경우가 아주 많다.
또, 보통은 로컬 DB에 접근하고 CRUD를 담당하는 로직을 싱글턴 객체로 만든다던지, 네트워크 통신을 하는 코드들을 관리하는 객체를 메모리 상에서 하나의 인스턴스로 관리하는 경우도 많이 봤던 것 같다.
그렇다면, struct나 enum과 같은 값 타입의 객체로 싱글턴 패턴을 만든다는 이야기가 없는 것은 또 무엇일까? 사실 한 발짝만 더 생각해보면, struct로 생성된 인스턴스는 그 초기화 시점에 메모리 공간을 차지하게 되어 싱글턴 패턴과 결이 비슷할 수 있지만, 생성 시점마다 새로운 복사값을 메모리에 올려둔다는 것이 다르다.
싱글턴 패턴에서 강조하는 것은 여러 객체들이 의존성을 가진채로 해당 하나의 싱글턴 객체를 바라볼 수 있어야 하는 것이다. 구조체로 만든 인스턴스는 생성되는 시점마다 새로운 인스턴스를 만들기 때문에, 접근하는 객체들이 사실상 하나를 '공유'하는 것이 아닌 새로운 독립적인 객체를 바라보는 것이다.
같은 맥락에서, 구조체로 작성한 싱글턴 패턴을 닮은 객체가 내부에서 어떤 상태를 가진다고 생각해보면, 각 객체들이 서로 다른 상태를 그저 변경하고 활용하게 되어 앱 전체적으로 의도하지 않은 상태의 값이 관리될 수도 있다.
열거형은 어떨까? 우선 열거형은 인스턴스를 만드는 개념이 아니다. case에 따른 값의 선택지를 관리하고, 활용되는 시점에 메모리에 잠시 올려졌다가, 그 값이 다 활용되면 메모리에서 해제된다. (여느 값 타입과 마찬가지로) 그래서 앱 전체의 라이프사이클 동안 동일한 객체 하나를 바라본다는 싱글턴 패턴의 기본 정의와도 어긋나는 부분이 있다.
열거형으로 싱글턴 패턴의 코드를 작성해본다고 한다면, 케이스가 없는 (caseless enum) 형태로 만들어서 불필요한 값의 공간을 만들지 않을 수 있고, 열거형 자체가 저장 속성을 가지지 못하기 때문에 외부에 의해 각기 다른 상태를 가질 우려도 없다. 또한 내부적으로 static한 메서드나 연산 프로퍼티를 만들 수 있어서 메모리 우위도 있다.
그럼에도 열거형으로 싱글턴을 만드는게 맞는지는 모르겠다. 열거형은 어떤 '값'들에 대한 케이스를 열거해주는 역할이 우선이라고 생각한다. 그 값이 필요한 시점에 정의된 내부의 케이스를 활용하는 용도가 열거형의 존재 이유라고 생각하기 때문에 싱글턴에서는 고려 대상이 아니지 않나 싶다.

글을 정리하자면.

나도 프로젝트를 하면서 싱글턴 패턴으로 클래스 객체를 많이 만들어 활용하곤 한다. Swift 언어적으로나 코드를 작성의 용이성으로나 싱글턴 패턴을 차용하는게 쓸모가 있을 때가 많았던 것 같다. 전체 프로젝트에서 단 하나의 객체로만 존재하고, 그 객체가 다른 객체에 공유될 필요가 있는 상황이라면 싱글턴 패턴을 앞으로도 적극적으로 활용할 것 같다.
만약 굳이 객체간에 공유가 되지 않아도 되는 상황이라면, 구조체나 열거형 내부에서 특정 객체를 주입해 static한 함수로 활용하고 그 객체의 주입을 끊어버리는 형태도 고려해볼 수 있다. (싱글턴의 메모리 관리 효율성을 대체하기 위해) 물론, 계속해서 변경되는 상태를 가지지 않는 상황이라는 점은 동일할 것이고 말이다.
Ha
Subscribe to 'hankyeol'
Subscribe to my site to be the first to receive notifications and emails about the latest updates, including new posts.
Join Slashpage and subscribe to 'hankyeol'!
Subscribe
👏
1