클린 소프트웨어 - 싱글톤과 모노스페이스 패턴

싱글톤과 모노스페이스 패턴

일반적으로 클래스와 인스턴스 사이에는 일대다 관계가 있으며, 이는 하나의 클래스로부터 여러 개의 인스턴스가 생성될 수 있음을 의미한다.

하지만 단 하나의 인스턴스만을 가져야 하는 클래스도 있다. 이러한 인스턴스는 프로그램이 시작했을 때 처음 나타났다가, 프로그램이 끝날 때 사라져야 한다.

단일성을 강제하는 패턴들이다. 대부분의 경우 이 패턴들의 비용은 이들의 표현력이 주는 이익에 비해 상당히 적다.

싱글톤 패턴

class Singleton {
private static var _shared: Singleton!

private init() {}

static func shared() -> Singleton {
if _shared == nil {
_shared = Singleton()
}
return _shared
}
}

shared() 타입 메소드를 통해 Singleton 인스턴스에 접근하며, 여러 번 호출되어도 같은 인스턴스에 대한 참조값이 반환된다.

또한 이니셜라이저의 접근 수준이 private이기 때문에 shared()가 아니고서는 인스턴스를 생성할 방법이 없다.

위의 코드로 인해 Singleton 클래스의 인스턴스는 두 개 이상 존재할 수 없음을 분명히 알 수 있다.

class Singleton {
static let shared = Singleton()
private init() {}
}

위와 같이 작성할 수도 있다.

싱글톤이 주는 이점

  • 플랫폼 호환
  • 어떤 클래스에도 적용 가능
    • 이니셜라이저를 private으로 만들고, 적절한 타입 메소드와 변수를 추가하기만 하면 싱글톤 형태로 만들 수 있다.
  • 파생을 통해 생성 가능
    • 주어진 클래스에서 싱글톤인 서브클래스를 만들 수 있다.
  • lazy evaluation
    • 싱글톤이 사용되기 전까지는 생성되지 않는다.

싱글톤의 비용

  • 디이니셜라이즈가 정의되어 있지 않음
    • 싱글톤을 없애거나 사용을 중지하는 좋은 방법이 없다.
  • 상속되지 않음
    • 어떤 싱글톤에서 파생된 클래스는 싱글톤이 아니다. 정적 메소드와 변수를 추가해야 싱글톤을 만들 수 있다.
  • 효율성
    • shared()를 호출할 때 항상 if문을 수행하는데, 대부분의 경우에 이 작업은 쓸모가 없다.
  • 비투명성
    • 싱글톤의 사용자는 shared() 메소드를 실행해야 하기 때문에, 자신이 싱글톤을 사용한다는 사실을 알고 있다.

동작에 있어서의 싱글톤

애플리케이션 전역에서 서드 파티의 기능에 접근한다고 할 때, 코드 전체에서 서드 파티의 API 사용을 확산시키게 되고, 접근이나 구조에 대한 규정을 강제할 수 있는 여지가 없게 만들게 된다.

퍼사드 패턴을 사용하여 서드 파티 API를 감쌀 수도 있겠지만, 싱글톤 패턴을 사용하면 해당 객체에서 규정을 정의할 수 있다.

protocol UserDatabase {
func readUser(_ username: String) -> User
func writeUser(_ user: User)
}

class UserDatabaseSource: UserDatabase {
static let shared = UserDatabaseSource()
private init() {}

func readUser(_ username: String) -> User { ... }
func writeUser(_ user: User) { ... }
}

모든 데이터베이스 접근이 UserDatabaseSource의 하나의 인스턴스를 통해 이루어짐을 보장한다. 검사, 카운터, 락 등의 기능을 넣어 접근과 구조에 대한 규정을 강제하는 것을 쉽게 할 수 있다.

모노스테이트 패턴

싱글톤 패턴과는 다르게, 모노스테이트 패턴은 여러 개의 인스턴스를 생성할 수 있으나, 그것들이 마치 하나인 것처럼 동작하게 한다. 여러 개의 인스턴스가 같은 객체의 서로 다른 이름을 가지고 있는 것이다.

단일 인스턴스라는 제약을 강제하지 않은 싱글톤 패턴이며, 싱글톤의 행위를 그대로 나타내야 한다.

모든 변수를 정적으로 만드는 것으로 여러 개의 인스턴스가 하나의 객체인 것처럼 동작하게 할 수 있다.

변수는 정적이며, 메소드는 정적이 아니어야 한다.

class Monostate {
static private var x = 0

func set(_ x: Int) {
Self.x = x
}

func get() -> Int {
return Self.x
}
}

데이터를 잃지 않고도 현재 있는 모든 인스턴스를 없애거나 사용을 중지할 수 있다.

var a: Monostate? = Monostate()
var b: Monostate? = Monostate()
a?.set(3)
a?.get() // 3
b?.get() // 3
a = nil
b = nil
var c = Monostate()
c.get() // 3

싱글톤 패턴과 모노스테이트 패턴의 차이는, ‘행위’와 ‘구조’의 차이 중 하나다.

  • 싱글톤 패턴 : 단일 구조 강제, 두 개 이상의 인스턴스가 생성되는 것을 불가능하게 함
  • 모노스테이트 패턴 : 구조를 강제하지 않음, 단일성이 있는 행위를 강제

모노스테이트 패턴에 대한 테스트는 싱글톤 패턴에 대해서도 통과하지만, 싱글톤 패턴에 대한 테스트는 모노스테이트 패턴에 대해서 통과하지 않는다. 싱글톤은 하나의 인스턴스만 존재해야 하나, 모노스테이트는 여러 개의 인스턴스가 존재할 수 있기 때문이다.

모노스테이트가 주는 이점

  • 투명성
    • 일반적인 객체의 사용자와 모노스테이트의 사용자가 똑같이 행동하므로, 사용자는 해당 객체가 모노스테이트임을 알 필요가 없다.
  • 파생 가능성
    • 모노스테이트의 파생 클래스는 모노스테이트다. 모두 같은 정적 변수를 공유한다.
  • 다형성
    • 모노스테이트의 메소드는 정적이 아니므로, 파생 클래스에서 오버라이드할 수 있다. 그러므로 서로 다른 파생 클래스는 같은 정적 변수의 집합에 대해 서로 다른 행위를 제공할 수 있다.
  • 잘 정의된 생성과 소멸
    • 정적 모노스테이트 변수는 생성 및 소멸 시기가 잘 정의되어 있다.

모노스테이트의 비용

  • 변환 불가
    • 일반적인 클래스는 파생을 통해 모노스테이트로 변환될 수 없다.
  • 효율성
    • 하나의 모노스테이트는 실제 객체이므로 생성과 소멸 비용이 든다.
  • 실재함
    • 모노스테이트의 변수는 이 모노스테이트가 사용되지 않는다 하더라도 공간을 차지한다.
  • 플랫폼 한정
    • 하나의 모노스테이트가 여러 플랫폼에서 동작하게 만들 수 없다.

동작에 있어서의 모노스테이트

모노스테이트의 파생 클래스가 다형성을 가지며, 모노스테이트의 파생 클래스도 모노스테이트가 된다는 것을 활용할 수 있다.

이 때 모노스테이트를 일반적인 클래스로 교체하는 것은 매우 어렵다.

모노스테이트의 상속 관계는 SOLID 원칙을 위반하는 것처럼 보이나, 모노스테이트의 파생 클래스는 모노스테이트 추상화의 일부라고 보는 것이 좋다. 모노스테이트가 접근 권한을 가진 변수와 메소드에 대해 동일한 접근 권한을 갖기 때문이다.

결론

싱글톤 패턴은 인스턴스 생성을 제어하고 제한하기 위해 private 이니셜라이저, 한 개의 정적 변수, 한 개의 정적 메소드를 사용한다. 하나의 인스턴스 생성을 보장하여 독특한 구조를 유지한다.

모노스테이트 패턴은 그저 객체의 모든 변수를 정적으로 만든다. 모든 인스턴스가 같은 데이터를 공유하는 구조를 갖는다.

다음과 같은 기준으로 인스턴스 생성을 강제하는 요구 사항을 위한 패턴을 선택할 수 있다.

  • 싱글톤 패턴
    • 파생을 통해 제어하고 싶은 이미 존재하는 클래스가 있을 때
    • 접근하기 위해 shared를 통해야 하는 것이 상관 없을 때
  • 모노스테이트 패턴
    • 클래스의 본질적 단일성이 클라이언트에게 투과되도록 하고 싶을 때
    • 단일 객체의 파생 객체가 다형성을 갖도록 하고 싶을 때

추가

class Singleton {
static let shared = Singleton()
private init() {}

private var x = 0

func set(_ x: Int) { self.x = x }
func get() -> Int { x }
}

class Monostate {
private static var x = 0

func set(_ x: Int) { Self.x = x }
func get() -> Int { Self.x }
}

// singleton1과 singleton2는 같은 객체를 참조한다.
// 같은 객체를 참조하는 것으로 데이터를 공유한다.
let singleton1 = Singleton.shared
let singleton2 = Singleton.shared
singleton1.set(3)
singleton1.get() // 3
singleton2.get() // 3

// monostate1과 monostate2는 서로 다른 객체를 참조한다.
// 정적 변수를 정의하여 데이터를 공유한다.
let monostate1 = Monostate()
let monostate2 = Monostate()
monostate1.set(3)
monostate1.get() // 3
monostate2.get() // 3