Typescript로 다시 쓰는 GoF - Singleton

아홉번째태양·2023년 8월 12일
0

Singleton이란?

클래스는 new 키워드를 사용하여 인스턴스화 시킬 수 있으며, new를 사용할때마다 말 그대로 새로운 인스턴스를 생성한다. 하지만 경우에 따라서 인스턴스를 단 하나만 생성하여 재사용 하고 싶은 때도 있다.

예를들어, 클래스 안에 프로그램 내 여러 곳에서 공용으로 접근하는 자원을 저장하는 경우가 있을 수 있다.



Singleton의 구현

Singleton 패턴은 비교적 단순하게 Singleton이 될 클래스 하나만 있으면 된다. 단, 생성된 인스턴스를 얻기위한 static메소드를 구현하는 것이 일반적이며 생성자를 private으로 처리하여 안정성을 더할 수 있다.


Singleton

class Singleton {
    private static instance: Singleton;
    private count = 0;

    private constructor() {
        console.log('Singleton created');
    }

    public static getInstance(): Singleton {
        if (!Singleton.instance) {
            Singleton.instance = new Singleton();
        }

        return Singleton.instance;
    }

    public addCount(): number {
        return this.count++;
    }
}

Singleton의 인스턴스는 오로지 getInstance 메소드를 통해서만 얻을 수 있으며, 생성자를 사용해 직접 인스턴스를 생성하려하는 경우 에러가 발생한다.

const s1 = new Singleton();

// Constructor of class 'Singleton' is private and only accessible within the class declaration.ts(2673)

그리고 자바스크립트에서는 자바처럼 생성된 인스턴스를 단순히 s1 == s2로 비교할 수가 없기 때문에 프로퍼티가 공유되는지를 addCount 메소드로 확인을 해보자.

const s1 = Singleton.getInstance();
const s2 = Singleton.getInstance();

console.log(s1.addCount());
console.log(s2.addCount());
Singleton created
0
1

생성자에서 console.log를 한번만 출력하고, 또 s1으로 호출한 addCount 메소드에 의해 s2에서 호출할 때 영향을 받은 것을 확인 할 수 있다.


모듈을 이용한 Singleton

하지만 위 코드는 지극히 자바스러운 코드다.

자바스크립트에서는 모듈을 활용해 생성한 인스턴스만을 export하는 방식도 흔하게 쓰인다.

// singleton.ts
class Singleton {
    private count = 0;

    constructor() {
        console.log('Singleton created');
    }

    public getCount(): number {
        return this.count++;
    }
}

export const singleton = new Singleton();

이렇게 생성한 인스턴스만을 모듈로서 내보내게된다면, 외부에서는 어차피 클래스의 생성자에 접근할 방법이 없다. 예를들어, 두 개의 서로 다른 모듈에서 singleton을 불러와 사용한다해도 이는 처음 singleton.ts라는 모듈이 실행되면서 생성한 인스턴스를 그대로 사용할 뿐이다.

// test1.ts
import { singleton } from "./singleton";
import "./test2";

console.log("Singleton");
console.log(singleton.getCount());


// test2.ts
import { singleton } from "./singleton";

console.log("GOF");
console.log(singleton.getCount());
Singleton created
GOF
0
Singleton
1

이렇게 두번 import를 하여도 인스턴스는 한번만 생성되고, 같은 인스턴스가 사용되는 것을 볼 수 있다.




Java언어로 배우는 디자인 패턴 입문 - 쉽게 배우는 Gof의 23가지 디자인패턴 (영진닷컴)







번외) 두 가지 방법의 차이

1. Export Instance

class Singleton {
    private count = 0;

    constructor() {
        console.log('Singleton created');
    }

    public getCount(): number {
        return this.count++;
    }
}

export const singleton = new Singleton();

export를 이용해 모듈화하였기 때문에 프로젝트 내에서 싱글톤으로서 동작

장점

  • 간단하다

단점

  • 인스턴스가 생성되는 시점에 어떠한 조작이 불가능하다.
  • 모듈의 경로가 파싱되는 순간 생성되기 때문에 lazy-loading 등을 할 수 없다.

2. Static Method

getInstance 메소드를 이용해 싱글톤 인스턴스 컨트롤

장점

  • getInstance를 호출하기 전까지 생성되지 않는다.
  • getInstance를 호출하는 시점에 로깅, 트랙킹 등 혹은 기타 인스턴스 조작을 유동적으로 할 수 있다.

단점

  • 조금 더 복잡하다.
class Singleton {
    private static instance: Singleton;

    static getInstance(): Singleton {
        if (this.instance === undefined) {
            this.instance = new this()
        }

        return this.instance;
    }
}

문제

위 static method를 쓰는 방법은 타입추론이 잘 되지 않는다.

예를들어,

class Singleton {
  private static instance: Singleton

  static getInstance(): Singleton {
    if (!this.instance) {
      this.instance = new this()
    }

    return this.instance
  }
}


class User extends Singleton {
  constructor() {
    super();
    console.log('User constructor');
  }

  getName() {
    return 'user'
  }
}

const user = User.getInstance();

타입스크립트 컴파일러는 userUser가 아닌 Singleton으로 타입을 추론한다.

대안?

Singleton 클래스에 제네릭을 추가해 타입추론을 유도할 수 있다.

class Singleton<T extends Singleton<T>> {
  private static instances: Record<string, any> = {};

  static getInstance<T extends Singleton<T>>(this: new () => T): T {
    const className = this.name;
    if (!Singleton.instances[className]) {
      Singleton.instances[className] = new this();
    }

    return Singleton.instances[className];
  }
}

class User extends Singleton<User> {
  constructor() {
    super();
    console.log('User constructor');
  }

  getName() {
    return 'user';
  }
}

const user = User.getInstance();

여기서,

  • 제네릭을 단순히 <T> 대신 <T extends Singleton<T>>로 함으로서 T는 항상 Singleton을 상속받는 클래스임을 명시할 수 있다.
  • private static instances: Record<string, any> = {};로 Record 형태로 타입핑을 한 것은, static 속성에는 제네릭을 사용하지 못하기 때문이다

    Static members cannot reference class type parameters.ts(2302)

  • static getInstance<T extends Singleton<T>>(this: new () => T): T {에서 매개변수에 this: new () => T를 넣음으로서, getInstance 메소드를 호출하는 클래스가 Singleton을 상속받는 클래스 T임을 암시한다.

0개의 댓글