C++로 Singleton 패턴 이해하기

Kang Chang Hwan·2024년 5월 28일

초콜릿 공장 예시

초콜릿 공장에서 초콜릿을 끓여서 다른 공정으로 넘기는 놈(Chocolate Boiler)가 있다.

이것의 코드 예시는 다음과 같다.

class ChocolateBoiler {
private:
  bool empty = true;
  bool boiled = false;
public:
  // 초콜릿이 보일러에 없으면 채움.
  void fill();
  // 보일러에 초콜릿이 꽉차고 다 끓이면 다른 공정에 넘김.
  void drain();
  // 보일러에 초콜릿이 꽉차고 끓지 않은 상태라면 끓임.
  void boil();
  // 초콜릿 보일러가 비었는지 확인.
  bool isempty() { return empty; }
  // 초콜릿 보일러 속 초콜릿이 끓었는지 확인.
  bool isBoiled() { return boiled; }
};

초콜릿 보일러의 인스턴스를 반드시 1개만 생성하도록 하려면 어떻게 코드를 수정할 수 있을까?

포인터 변수를 사용하는 방법

class ChocolateBoiler {
private:
  bool empty = true;
  bool boiled = false;
  static ChocolateBoiler* chocolateBoilerInstance;
  ChocolateBoiler() {}
public:
  static ChocolateBoiler* getInstance() { 
    if (chocolateBoilerInstance == nullptr) {
      chocolateBoilerInstance = new ChocolateBoiler();
    }
    return chocolateBoilerInstance;
   }
  void fill();
  void drain();
  void boil();
  bool isempty() { return empty; }
  bool isBoiled() { return boiled; }
};
  1. 생성자를 private으로 선언한다!
  • "초콜릿 보일러 클래스: 내 안에서 호출하는 거 아니면 호출 안됌 ㅋ"
  1. 인스턴스를 초기화하려면?
  • getInstance() 메서드 사용.
    • 이 때 static으로 선언하여 메서드를 전역적으로 사용할 수 있게 하고
    • chocolateBoilerInstance가 객체에 대한 주소를 가지고 있는지 체크
      • 있으면 > 바로 반환
      • 없으면 > 생성자를 사용해 객체 생성후 주소 반환

객체를 사용해 Singleton 구현

이 때는 copy 생성자와 대입 연산자를 삭제하여 객체의 복사를 방지한다.

class ChocolateBoilerObject {
private:
  bool empty = true;
  bool boiled = false;
  ChocolateBoilerObject() {}
public:
  static ChocolateBoilerObject& getInstance() { 
    static ChocolateBoilerObject instance;
    return instance;
   }
  void fill();
  void drain();
  void boil();
  bool isempty() { return empty; }
  bool isBoiled() { return boiled; }

  ChocolateBoilerObject(const ChocolateBoilerObject& obj) = delete;
  ChocolateBoilerObject& operator =(const ChocolateBoilerObject&) = delete;
};

유의해서 봐야할 점은 3가지다.

1. static instance 생성

static ChocolateBoilerObject& getInstance() { 
    static ChocolateBoilerObject instance;
    return instance;
   }

인스턴스를 static으로 로컬에서 선언하는 이유는 다음과 같다.

  • getInstance 메서드가 외부에서 호출되면 처음 호출 시에만 instance에 ChocolateBoilerObject의 객체가 메모리의 static 저장소에 생성되고 이후로는 호출되어도 바뀌지 않는다.
  • 해당 객체가 프로그램이 실행부터 종료 전까지 유지되고 종료 시 사라진다.

static으로 선언하면 해당 변수의 값은 변경될 수 없나요?

  • 메모리 구조

    | |
    | |
    --------- <- 다른 데이터
    | |
    | |
    --------- <- 전역 변수 영역
    | |
    | | <- static ChocolateBoilerObject instance1;
    --------- <- 스택 영역 (함수 호출 시 사용됨)
    | |
    | |
    | |
    --------- <- 힙 영역 (동적 메모리 할당 시 사용됨)

    • 이렇게 전역 변수 영역에 instance가 할당되는데 이는 프로그램이 실행과 동시에 종료까지 유지되기 때문에 같은 타입의 다른 값을 할당할 수 없음.
int main() {
// 위 메모리 상의 instance1 반환.
  ChocolateBoilerObject& obj1 = ChocolateBoilerObject::getInstance();
// 위 메모리 상의 instance1 반환.
  ChocolateBoilerObject& obj2 = ChocolateBoilerObject::getInstance(); 
};

이렇게 다른 변수로 인스턴스를 반환받아도 같은 인스턴스를 반환하여 class의 인스턴스가 1개만 생성되도록 보장하는 것이다.(Singleton)

그리고 생성된 객체의 참조를 반환한다.

참조를 반환하는 이유

  • 실제 객체를 반환하게 되면 객체의 복사본을 반환 됨 > 다른 메모리 location에 객체들이 복사된다. 이는 Singleton 원칙(class의 객체를 1개만 유지)에 위배하는 것이다.

    잠시 참조(Reference)가 뭔가요?

    • 참조는 pointer + 포인터에 접근으로 생각하면 된다.
      즉, 객체의 메모리 주소 + 접근하는 것이다.
      • 예를 들어 Chocolate& obj = Chocolate(); 라면 "초콜릿 객체가 생성된 주소를 반환받고 거기에 접근할게~) 이다.
  • 예시 코드
class ChocolateBoilerObject {
public:
    // ...
    static ChocolateBoilerObject getInstance() { // 객체를 반환
        static ChocolateBoilerObject instance;
        return instance;
    }
    // ...
};
int main() {
    ChocolateBoilerObject obj1 = ChocolateBoilerObject::getInstance();
    ChocolateBoilerObject obj2 = ChocolateBoilerObject::getInstance();
    // obj1과 obj2는 서로 다른 객체의 복사본.
    // 이는 Singleton 패턴의 목적에 위배됨.
}

2. 복사 생성자와 복사 할당 연산자 삭제

  ChocolateBoilerObject(const ChocolateBoilerObject& obj) = delete;
  ChocolateBoilerObject& operator =(const ChocolateBoilerObject&) = delete;

위 코드에서는 복사 생성자와 복사 할당 연산자를 삭제해줬다.

Copy 생성자란?

함수에서 객체를 반환하거나 인자로 객체를 전달할 때 복사가 일어날 때 호출되는 것이 Copy Constructor다.

예를 들어 value가 42인 객체를 obj2에 할당하려고 한다.

obj1                                obj2
+-----------+                       +-----------+
| value: 42 |   ---복사 생성자--->  | value: 42 |
+-----------+                       +-----------+

그럼 obj1의 참조를 전달받아 해당 객체에 접근해 값을 obj2 객체에 복사한다.

그래서 싱글톤 패턴은 해당 클래스의 객체는 1개만 생성되어야 하고 그 객체를 복사가 되면 안된다.(그럼 그 클래스의 객체가 2개가 되어버림...)

따라서 다음과 같이 복사 생성자를 삭제해줬다.

  ChocolateBoilerObject(const ChocolateBoilerObject& obj) = delete;

또한 복사 할당 연산자도 삭제해줬는데, 이유는 똑같다.

  ChocolateBoilerObject& operator =(const ChocolateBoilerObject&) = delete;
  
int main() {
  ChocolateBoilerObject& instance = ChocolateBoilerObject::getInstance();
  ChocolateBoilerObject instance2;
  // 복사 할당 연산자 호출
  instance2 = instance;
  // 컴파일 에러(복사 할당 연산사 삭제)
};

싱글톤 패턴의 정의

클래스의 인스턴스의 개수를 1개로 보장하고 그 인스턴스로의 전역 접근을 제공.

1) 인스턴스의 개수를 하나로 보장

  • 생성자를 private으로 선언.
  • static으로 선언한 변수에 인스턴스 초기화.

2) 인스턴스의 전역적인 접근 제공

  • "클래스: 내 인스턴스 가져가~ (getInstance())"
  • static 메서드기 때문에 클래스 수준에서 어디서나 접근 가능.

멀티스레드 환경에서의 문제점

스레드 1과 2에서 동시에 다음 코드를 실행한다고 생각해보자.

static ChocolateBoiler* getInstance() { 
    if (chocolateBoilerInstance == nullptr) {
      chocolateBoilerInstance = new ChocolateBoiler();
    }
    return chocolateBoilerInstance;
   }

스레드 1과 2가 12:00:00에 동시에 if (chocolateBoilerInstance == nullptr)을 실행함.

"스레드1: chocolateBoilerInstance야 너 null이니?"
"chocolateBoilerInstance: 네!"
"스레드1: 어 그래? ㅇㅋ 객체 생성해줘"
"chocolateBoilerInstance: 새로운 객체1를 할당했어요"

"스레드2: chocolateBoilerInstance야 너 null이니?"
"chocolateBoilerInstance: 네!"
"스레드2: 어 그래? ㅇㅋ 객체 생성해줘"
"chocolateBoilerInstance: 새로운 객체2를 할당했어요"

이렇게 멀티쓰레드 환경에서 인스턴스 변수가 null일 때는 동시에 이를 실행하게 되면 한 클래스의 객체가 2개가 생성될 수도 있다는 문제가 있다.

이를 방지하기 위해 java에서는 synchronized 키워드를 사용해 동시 접속을 방지하여 해결한다.

연이은 문제: Synchronized를 사용하는 게 맞나?

음.. 근데 문제가 될 수 있는 객체가 초기화되기 전(null)만 그런데... getInstance 메서드를 호출할 때 마다 synchronized 키워드가 적용되니까 오히려 손해 아닌가..? 에서 문제의식을 갖게 된다.

profile
아쉬움 없이 살자. 모든 순간을 100%로!

0개의 댓글