본문 바로가기

CS/디자인패턴

[디자인패턴] Singleton 패턴

싱글톤 패턴을 설명하는 그림. static은 밑줄 그어 표시

설명

 어떤 클래스는 프로그램 상에서 정확히 하나의 인스턴스만 존재하여, 해당 인스턴스가 자신과 관련된 모든 작업을 감독해야 하는 경우가 있다. 이 상황에서 단순히 static 전역변수를 하나 둬서 처리할 수도 있지만 싱글톤 패턴을 이용하여 클래스 자신이 static 변수를 관리하면서 단일 인스턴스를 보장할 수 있다. 일반적인 사용 방식은 다음과 같다.

  • 생성자를 private ( 특이한 경우에는 protected ) 로 설정하여 클래스 외부에서 호출할 수 없게 만든다.
  • 클래스 내부에 static private 인스턴스인 instance을 선언한다.
  • public getInstance 함수를 통해 instance에 대한 접근 권한을 부여한다. 초기에 instance가 없다면 null 체크 후 instance을 생성하는 역할까지 getInstance에게 부여하게 된다.

 대부분의 상황에서는 위 구조를 벗어나지 않을 것이다. gof 책에서는 생성자를 protected로 둬서 싱글톤 클래스를 상속할 수 있도록 구성, 조건에 따라 구체적인 하위 클래스로 instance 을 초기화하는 코드를 소개하고 있다. 이 경우 클래스 상속 및 인스턴스 변경이 자유롭다는 것을 장점으로 볼 수 있다. 다만 이렇게까지 사용하는건 보지 못한 것 같다.

구성 요소

  •  Singleton: 정적 메서드인 getInstance을 정의하여 Singleton 클래스에 대한 유일한 인스턴스인 instance에 대한 접근 권한을 제공한다. 이외의 필드나 메서드는 instance가 동작할 때 필요한 내용들에 해당한다.

구현 고려사항

  1. 인스턴스가 유일함을 보장해야 한다. 이를 달성하기 위해 외부에서는 getInstance 정적 메서드로만 인스턴스에 접근할 수 있도록 한다. 또한 생성자의 접근 제어자를 protected로 설정하여 클래스 외부에서 임의대로 인스턴스를 생성할 수 없도록 제한할 수 있다.
  2. 전역 변수 & 정적 객체 (static으로 프로그램 시작 시점에 초기화해서 사용하는 경우를 의미 )에 대해
    1. 유일한 인스턴스만 선언된다는 보장을 할 수 없다.
      : 클래스를 정적으로 하나만 만들어 사용하려면 생성자가 필요한데, 위에서 언급한 싱글톤 패턴이 아니라 단순히 전역 선언해서 사용하려면 생성자를 public으로 구성해야되니까 유일성 보장 못한다는 이야기로 보인다.
    2. 정적 초기화 시점에 인스턴스를 생성하기에는 필요한 정보가 모두 존재하지 않을 수 있다. 싱글톤 객체는 프로그램 실행 중간에 값을 받아서 초기화 가능하나 반면 전역 변수는 맨 처음 코드 상에 정의된 정보만 이용할 수 있다.
    3.  전역 변수 / 정적 객체는 싱글톤을 사용하지 않더라도 생성된다.
  3. 싱글톤 클래스를 서브클래싱한다.
    :  서브 클래스들 중 하나에 대한 인스턴스로 instance 변수를 초기화할 수 있다. 이때 서브 클래스 타입은 if문을 이용해서 분기 및 선택하도록 작성할 수도 있으나, 이러한 방식은 유연하지 않으므로 환경 변수 및 싱글톤 레지스트리를 조합하여 getInstance가 각 인스턴스에 대한 정보를 알 필요 없이 찾아 사용하도록 구현할 수 있다.
     싱글톤 레지스트리 기반에서는 각 서브클래스가 생성자를 통해 자기 자신을 싱글톤 레지스트리에 등록하도록 코드를 작성한다. 이때 생성자 호출을 위해 정적 객체를 클래스 구현과 함께 선언할 수 있다.
  4. 서브 클래스의 일부만 싱글톤 패턴에서 사용되어야 한다면 아예 동적 링크로 받아오도록 수정할 수도 있다.
  5. 멀티스레드 환경에서는 getInstance에 동시에 접근함으로써 인스턴스를 2개 만드는 상황이 발생할 수 있다. 이 상황을 대비하기 위해 더블 체크 로킹 패턴(NULL 테스트 + 인스턴스 생성 여부 추가 테스트)을 적용하거나, 아예 처음부터 클래스 내에 정적으로 만드는 방법을 채택할 수 있다.

3, 4의 경우 위 설명 부분에서 언급한 protected 생성자를 가진 싱글톤에 대한 이야기이다. 싱글톤 객체는 일단 할당되면 프로그램의 생명 주기 내내 사용되므로, 선택되지 않은 다른 서브 클래스들은 실행되지도 않는 주제에 코드만 차지하게 될 가능성이 높다. 따라서 서브 클래스들을 전부 함께 컴파일하는 대신 동적 모듈 + 환경 변수 조합을 이용하여 필요한 싱글톤 객체만 사용하는 방법을 소개하고 있다. 개인적으로 정말 생소한 개념이라 적어 두었다.

싱글톤 레지스트리?

 gof에서 설명하고 있는 단일체에 대한 레지스트리는 코드 기준으로 일종의 딕셔너리에 가깝다고 느꼈다. 미리 인스턴스가 될 수 있는 객체들을 레지스트리에 등록하고, 차후 env 값을 받아서 할당하는 방식으로 동작하기 때문이다.

 인터넷에 검색해보니, spring 프레임워크에서 싱글톤 레지스트리라는 개념을 이용한다고 한다. spring의 싱글톤 레지스트리는 spring이 생각하는 객체지향 철학을 달성하기 위해 프레임워크 측면에서 지원하는 시스템으로, @Configuration / @Bean 데코레이터를 적용하면 spring 시스템이 알아서 싱글톤으로 관리해준다.

 두 개념이 동일한 것은 아닌 것 같으나, 싱글톤 객체를 등록하여 관리한다는 점 정도는 비슷한 것 같다.

예시 코드

싱글톤 자체가 워낙 자주 사용되는 패턴이라 따로 언급할 필요도 없을 것 같지만, 그래도 추가했다.

export class AudioManager {
    private static instance :AudioManager|null;
    private audioMap: Map<string,string>;
    // 대략 이렇게 동작한다고 가정

    private constructor() {
        this.audioMap = new Map();
    }

    static getInstance(): AudioManager {
        if(this.instance == null) {
            this.instance = new AudioManager();
        }
        return this.instance;
    }

    public play(sname: string) {
        const buffer = this.audioMap.get(sname);
        if(buffer != undefined) {
            console.log(`song: ${sname}, buffer: ${buffer}`);
        } else {
            console.log(`There is no song named ${sname}`);
        }
    }

    public addAudio(sname: string, buffer: string) {
        this.audioMap.set(sname, buffer);
    }

    public removeAudio(sname: string) {
        this.audioMap.delete(sname);
    }
}

  AudioManager은 소리와 관련된 요소를 총괄하는 역할을 맡고 있으므로 시스템 내 단일 객체로 존재하는 편이 좋다. 이런 경우 싱글톤 패턴을 도입할 수 있다. 싱글톤 인스턴스인 instance는 private static으로 지정되어 외부에서 직접 접근할 수 없다. 외부에서 해당 객체에 접근하기 위해서는 반드시 getInstance 메서드를 이용해야 한다.

 getInstance 메서드는 이전에 instance가 존재하지 않는지 검사한다. 만약 인스턴스가 존재하지 않았다면 새로 만들게 된다. if 조건은 getInstance 메서드가 맨 처음 호출된 시점 이후로는 해당되지 않으므로 항상 단일 객체가 보장된다.

 다음으로는 gof 책에 언급되었던 싱글톤 레지스트리를 구현해보자.

export class MazeFactory {
    private static registy = new Map<string, MazeFactory>();
    private static instance: MazeFactory;

    protected constructor() { }

    static register(name: string, singleton: MazeFactory) {
        this.registy.set(name, singleton);
    }

    static getInstance() {
        if (this.instance == undefined) {
            this.instance = this.registy.get(process.env.MAZE_TYPE ?? "")
                ?? new MazeFactory();
        }
        return this.instance;
    }

    public hello() {
        console.log("I'm MazeFactory!");
    }
}

export class EnchantedMazeFactory extends MazeFactory {
    private static _inner = new EnchantedMazeFactory();
    private constructor() { 
        super();
        MazeFactory.register("enchanted", this);
    }

    
    public override hello() {
        console.log("I'm EnchantedMazeFactory!");
    }
}

export class BombedMazeFactory extends MazeFactory {
    private static _inner = new BombedMazeFactory();
    private constructor() { 
        super(); 
        MazeFactory.register("bombed", this);
    }

    public override hello() {
        console.log("I'm BombedMazeFactory!");
    }
}

 위 예시에서 MazeFactory은 싱글톤 클래스이다. 이때 위 예제와 다른 점은 다음과 같다.

  1. 생성자가 private이 아닌 protected로 정의되었다. 생성자가 protected로 정의되는 경우 하위 클래스에게는 생성자가 공개되므로 상속이 가능해진다. 
  2. registry, register 등 싱글톤 레지스트리 운영을 위한 정적 필드 및 메서드가 싱글톤 객체에 추가되었다.
  3. 하위 클래스는 MazeFactory을 상속하고 있으며, 생성자를 private으로 지정하여 외부에서 접근하지 못하게 제한하였다. 생성자 내부에서는 자기 자신을 MazeFactory의 싱글톤 레지스트리에 등록하고 있다.
  4. 하위 클래스의 생성자가 private로 지정되어 외부에서 접근할 수 없다. 이때 생성자는 객체가 생성될 때 실행될 수 있으므로, 각 하위 클래스 내부에 사용되지 않는 정적 인스턴스를 하나 생성하면서 생성자를 호출한다. ( gof에서 언급한 사용 방식에 해당한다. )

 이제 위 코드가 제대로 작동하는지 검사하기 위해 main 함수를 만들어보자.

import { MazeFactory } from "./singleton-pattern2.js";
import * as dotenv from 'dotenv';
dotenv.config();

export function main() {
    console.log(process.env.MAZE_TYPE);
    const singleton = MazeFactory.getInstance();
    singleton.hello();
}

 눈에 띄는 점은 dotenv을 이용해서 환경변수를 읽어온다는 점 정도이다. gof 책에서 싱글톤 레지스트리를 설명할 때 환경 변수를 키로 사용하여 레지스트리로부터 싱글톤 객체를 가져오는 방법을 이용했는데, 이에 대응되게 .env 파일에 정의된 환경 변수의 값을 기반으로 인스턴스를 설정하도록 구현해보았다.

환경 변수의 값에 따라 레지스트리로부터 다른 싱글톤 객체를 가져오는 모습

 정상적으로 동작하는 모습을 볼 수 있다. 여러 서브 클래스 중 하나를 선택해야 하는 정말 드문 경우가 발생한다면 이런 방법을 사용할 수도 있을 것 같긴 하다. 다만 이 방식은 선택된 하나의 인스턴스를 제외하면 전부 사용되지 않아 효율이 좋지 않으므로 동적 모듈 등 다른 방식을 고려하는 게 좋겠다. 과연 이렇게까지 싱글톤을 사용할 경우가 올까?

'CS > 디자인패턴' 카테고리의 다른 글

[디자인패턴] Facade 패턴  (0) 2023.04.30
[디자인패턴] SOLID 원칙  (0) 2023.04.27
[디자인패턴] Bridge 패턴  (0) 2023.04.25
[디자인패턴] Adapter 패턴  (1) 2023.04.14
[디자인패턴] Composite 패턴  (0) 2023.04.11