[Swift/디자인패턴] Singleton Pattern

frogKing·2023년 4월 25일
0

디자인패턴

목록 보기
5/6
post-thumbnail

오늘은 쉽게 가져다 쓰기 편리하지만 함부로 사용하면 나중에 피를 볼 수도 있는(?) 싱글턴 패턴에 대해 소개하려고 한다.

Singleton Pattern

정의

싱글톤 패턴은 클래스의 인스턴스화를 단일 인스턴스로 제한하는 소프트웨어 디자인 패턴입니다. 객체 지향 소프트웨어에서 반복되는 문제를 해결하는 방법을 설명하는 잘 알려진 "Gang of Four" 디자인 패턴 중 하나인 이 패턴은 시스템 전체에서 작업을 조정하는 데 정확히 하나의 객체가 필요할 때 유용합니다. - Wikipedia
보다 구체적으로, 싱글톤 패턴은 개체가 다음을 수행할 수 있도록 합니다.

  • 인스턴스가 하나만 있는지 확인
  • 해당 인스턴스에 대한 쉬운 액세스 제공
  • 인스턴스화 제어(예: 클래스 생성자 숨기기)

싱글턴 패턴을 단순히 "클래스의 인스턴스를 하나만 갖도록 한다"로 정의하기엔 다소 부족하다. 싱글턴 패턴의 조건은 다음과 같다.

  1. 생성자를 숨김으로써 인스턴스화를 제어한다.
  2. 클래스의 인스턴스를 하나만 갖도록 한다.
  3. 해당 인스턴스에 쉽게 접근이 가능하다.

각 조건을 살펴보면서 싱글턴 패턴이 어떤 패턴인지 살펴보도록 하자.

생성자를 숨김으로써 인스턴스화를 제어한다.

class FileManager {
    private init() {}
}

우선 싱글턴이라는 이름에 걸맞게 외부에서 인스턴스를 생성할 수 없다. 그렇다면 인스턴스를 어디에다 생성하지? 그것은 외부가 아니라 내부가 된다.

클래스의 인스턴스를 하나만 갖도록 한다.

class FileManager {
    let shared = FileManager()
    
    private init() {}
}

이니셜라이저를 private으로 생성하면 외부에서 접근할 수 없지, 내부에서는 가능하다. 따라서 클래스가 프로퍼티로 자기 자신의 인스턴스를 들고 있는다. 하지만 해당 프로퍼티는 외부에서 접근이 가능한가? 불가능하다. 왜냐하면 인스턴스 프로퍼티로 생성되어 있기 때문에 인스턴스화를 제한하면서 인스턴스 프로퍼티를 쓸 수는 없는 노릇.. 이를 어쩐다..

해당 인스턴스에 쉽게 접근이 가능하다.

class FileManager {
    static let shared = FileManager()
    
    private init() {}
    
    func introduce() {
        print("나는 싱글턴 파일매니저야!")
    }
}

FileManager.shared.introduce() // "나는 싱글턴 파일매니저야!"

이때, 클래스 외부에서 인스턴스에 접근할 수 있도록 static 키워드를 붙여주었다.

참고로 static 키워드를 붙이면 해당 프로퍼티가 호출되기 전까지는 메모리에 올라가 있지 않다가, 호출되고 나면 프로그램이 종료될 때까지 메모리에 남아있게 된다.


이렇게 싱글턴 패턴의 세 가지 조건을 알아보았다.

물론 여기서 끝이 아니다. 위 코드는 몇 가지 문제점을 안고 있다.

문제점

동시성 문제

만약 싱글 스레드에서 앱을 돌린다면 위 코드는 문제가 되지 않는다. 하지만 멀티 스레드에서 돌린다면..?

다음과 같은 싱글턴 클래스가 있다고 가정해보자.

class Account {
    static let shared = Account()
    
    var money = 3000
    
    private init() {}
    
    func checkMyMoney() {
	    return money
    }
}

다음은 내 은행 계좌인데 돈이 3천원 들어있는 상황이다. 하지만 마침 세 곳에서 중고 거래로 물건을 팔아서 동시에 돈이 입금된다고 한다. 물건 값은 각각 1200원, 2000원, 4500원이다. 과연 나는 잔액 조회를 했을 때 얼마가 들어와 있다고 단언할 수 있을까?

없을 것이다. 내가 조회를 했을 때 물건 값이 들어오지 않아 3천원이 찍힐 수도 있고, 1200원만 들어와서 4200원일 수도 있다.

앱 개발자니까 앱을 예로 들어보자. 토스 앱에서 사용자가 돈을 인출한다고 하자. 잔액 3천원에서 3천원을 인출하려는데 동시에 선불 교통카드로 2500원이 빠져나가려고 한다. 만약 동시성 문제를 고려하지 않았다면 선불 교통카드 인출로 잔액은 500원이 되었는데 프로그램은 잔액이 3천원인 순간에 3천원을 인출하여 잔액이 0원이 될 수도 있다. 이를 이해하기 쉽게 표현하면 다음과 같다.

  • 인출 프로세스
    1. 잔액을 확인하여 인출 가능한지 확인한다.(A)
    1. 인출한다. (B)
  • 동시성 문제
    1. A - 3천원 인출 가능하네?
    1. A - 선불 교통카드 2500원 인출 가능하네?
    2. B - 선불 교통카드 인출하고 잔액 500원!
    3. B - 3천원 인출하고 잔액 0원!
    4. 잔액 : 0원

실제로 인출한 총액은 5500원인데 잔액이 0원이 뜨게 된다. 마이너스 통장이 된다면 -2500원, 그렇지 않다면 교통카드만 인출되고 3천원은 인출이 안되는 것이 맞다.

이처럼 싱글턴을 사용하면 동시에 여러 곳에서 접근할 가능성이 생겨 동시성 문제가 발생한다. 어느 곳이든 쉽게 접근할 수 있다는 장점이 단점으로 돌아오는 순간이라고 볼 수 있다.

테스트하기 어려워진다.

다음과 같이 UserManager라는 싱글턴 객체가 있다고 가정해보자.

class UserManager {
    static let shared = UserManager()
    private var users: [User] = []

    private init() {}

    func addUser(_ user: User) {
        users.append(user)
    }

    func removeUser(_ user: User) {
        users.removeAll(where: { $0.id == user.id })
    }

    func getUser(withID id: Int) -> User? {
        return users.first(where: { $0.id == id })
    }
}

이 객체를 UserViewModel에서 접근하게 되는데..

class UserViewModel {
    func addUser(_ user: User) {
        UserManager.shared.addUser(user)
    }

    func removeUser(_ user: User) {
        UserManager.shared.removeUser(user)
    }

    func getUser(withID id: Int) -> User? {
        return UserManager.shared.getUser(withID: id)
    }
}

문제는 UserManager 싱글턴 객체를 그대로 가져다 쓰는 바람에 UserViewModel과 UserManager의 강한 결합이 생겨 UserManager와 UserViewModel을 분리해서 테스트할 수 없게 된다.

(이를 해결하기 위해 의존성 주입이라는 개념이 있으니 꼭 알아보시길..)

profile
내가 이걸 알고 있다고 말할 수 있을까

0개의 댓글