Singleton 패턴

Singleton은 1회에 한하여 인스턴스화가 가능하며 전역에서 접근 가능한 클래스를 지칭한다. 만들어진 Singleton 인스턴스는 앱 전역에서 공유되기 때문에 앱의 전역 상태를 관리하기에 적합하다.

먼저 ES2015의 클래스로 작성된 Singleton을 보자. 이 예제에서는 아래 메서드를 가진 Counter 클래스를 만든다

  • getInstance 메서드는 인스턴스 자체를 반환한다
  • getCount 메서드는 counter 변수를 반환한다
  • increment 메서드는 counter 변수를 1 증가한다
  • decrement 메서드는 counter 변수를 1 감소한다
let counter = 0

class Counter {
  getInstance() {
    return this
  }

  getCount() {
    return counter
  }

  increment() {
    return ++counter
  }

  decrement() {
    return --counter
  }
}

하지만 위의 코드는 Singleton 패턴의 조건을 만족하지 않는다. Singleton은 인스턴스를 단 한 번만 만들 수 있어야 한다. 현재 위의 코드에서 Counter 인스턴스를 여러 번 만들 수 있다.

let counter = 0

class Counter {
  getInstance() {
    return this
  }

  getCount() {
    return counter
  }

  increment() {
    return ++counter
  }

  decrement() {
    return --counter
  }
}

const counter1 = new Counter()
const counter2 = new Counter()

console.log(counter1.getInstance() === counter2.getInstance()) // false

new 메서드를 두번 호출하여 counter1counter2 를 각각 별개의 인스턴스를 가르키도록 했다. 각 인스턴스의 getInstance 메서드를 호출해 반환되는 레퍼러스는 같지 않다 (동일한 인스턴스가 아니다)

Counter 클래스가 한 번만 만들어질 수 있도록 코드를 수정해 보자.

이 인스턴스를 한 번만 만들 수 있는 방법 중에 하나는 instance 라는 변수를 만드는 것이다. Counter 클래스의 생성자에서 instance 변수가 새로 생성된 인스턴스를 가리키도록 한다. 이제 instance 라는 변수가 값이 있음을 검사하는것으로 새로운 인스턴스의 생성을 막을 수 있다. 예제의 경우 인스턴스가 이미 존재할 경우 에러를 발생시켜 개발자에게 인지시켜 주고 있다.

let instance
let counter = 0

class Counter {
  constructor() {
    if (instance) {
      throw new Error('You can only create one instance!')
    }
    instance = this
  }

  getInstance() {
    return this
  }

  getCount() {
    return counter
  }

  increment() {
    return ++counter
  }

  decrement() {
    return --counter
  }
}

const counter1 = new Counter()
const counter2 = new Counter()
// Error: You can only create one instance!

이제 인스턴스를 여러 번 만들려 하면 예외가 발행하여 더 이상 진행할 수 없게 된다.

이렇게 만들어진 Counter 인스턴스를 export하기 전에 인스턴스를 freeze 하도록 하자. Object.freeze 메서드는 객체를 사용하는 쪽에서 직접 객체를 수정할 수 없도록 해 준다. freeze 처리 된 인스턴스는 프로퍼티의 추가 및 수정이 불가하므로 Singleton 인스턴스의 프로퍼티를 덮어쓰는 실수를 예방할 수 있다.

let instance
let counter = 0

class Counter {
  constructor() {
    if (instance) {
      throw new Error('You can only create one instance!')
    }
    instance = this
  }

  getInstance() {
    return this
  }

  getCount() {
    return counter
  }

  increment() {
    return ++counter
  }

  decrement() {
    return --counter
  }
}

const singletonCounter = Object.freeze(new Counter())
export default singletonCounter

위의 Counter 를 구현한 프로젝트는 아래의 파일 구조를 가지게 된다.

  • counter.js : Counter 클래스가 구현되어 있고. Counter 의 Singleton 인스턴스를 default export 한디
  • index.js : redButton.jsblueButton.js 모듈을 로드한다
  • redButton.js : Counter Singleton 인스턴스를 import하고. 붉은 버튼의 클릭 이벤트 리스너에서 Counterincrement 메서드를 실행하고 getCount 를 호출하여 현재 counter 의 값을 콘솔에 출력한다.
  • blueButton.js : Counter Singleton 인스턴스를 import하고. 파란 버튼의 클릭 이벤트 리스너에서 Counterdecrement 메서드를 실행하고 getCount 를 호출하여 현재 counter 의 값을 콘솔에 출력한다.

blueButton.jsredButton.js 둘 다 counter.js 에서 동일한 Singleton 인스턴스를 import한다.

increment 메서드가 redButton.js 혹은 blueButton.js 어느쪽에서 실행되더라도. Counter Singleton 인스턴스의 counter 값은 양쪽 파일에서 모두 공유한다. 어떤 버튼을 클릭하더라도 모든 곳에서 동일한 값이 공유된다. 이게 다른 파일들에서 메서드를 실행하더라도 카운터가 초기화되지 않고 계속 증가할 수 있는 이유다.

단점과 장점

인스턴스를 하나만 만들도록 강제하면 꽤 많은 메모리 공간을 절약할 수 있다. 매번 새로운 인스턴스를 만들어 메모리 공간을 차지하도록 하는 대신에. 우리는 예제에서 앱 전체에서 사용 가능한 하나의 인스턴스를 저장하기 위한 메모리를 사용했다. 하지만 Singleton은 안티패턴 혹은 자바스크립트에서는 하지 말아야 할 것으로 언급되곤 한다.

Java와 C++ 같은 다양한 언어들은 JavaScript 처럼 객체를 직접적으로 만들어낼 수 없다. 이런 객체지향 프로그래밍 언어에서는 객체를 만들기 위한 클래스를 꼭 작성해야 한다. 이렇게 만든 객체는 위의 instance 변수와 같이 클래스의 인스턴스가 된다.

JavaScript에서는 클래스를 작성하지 않아도 객체를 만들 수 있기 때문에 위의 예제는 약간 오버 엔지니어링이라 볼 수 있다. 객체 리터럴을 사용해서도 동일한 구현을 할 수 있다. 이어서 Singleton패턴을 사용할 때의 단점을 확인해 보자.

객체 리터럴 사용하기

이전에 구현했던 예제를 이번에는 단순히 객체 리터럴을 사용하여 구현해 보자. counter 객체는 아래 프로퍼티들을 가지고 있다.

  • count 프로퍼티
  • increment 메서드. 호출되면 count 값을 1 증가한다
  • decrement 메서드. 호출되면 count 값을 1 감소시킨다

객체의 레퍼런스가 넘어갔기 때문에 redButton.jsblueButton.js 는 동일한 counter 객체를 참조할 수 있다. count 프로퍼티를 수정하면 두 파일 모두에서 값이 변경된다.

테스팅

Singleton패턴으로 구현된 코드를 테스트하는것은 조금 까다롭다. 인스턴스를 매번 생성할 수 없기 때문에 모든 테스트들은 이전 테스트에서 만들어진 전역 인스턴스를 수정할 수 밖에 없다. 테스트들이 실행에 순서가 생기게 되면 작은 수정사항이 전체 테스트의 실패로 이어질 수 있다. 하나의 테스트가 끝나면 인스턴스의 변경사항들을 초기화 해 주어야 한다.

명확하지 않은 의존

아래 superCouter.js 처럼 다른 모듈로부터 import될 때 Singleton인지 아닌지 분명하지 않다. 예제 코드의 index.js 에서 하는것 처럼 SuperCounter 를 import 하여 인스턴스를 만들고 메서드를 호출했지만 싱글톤 객체의 값을 수정하게 되었다. 여러 Singleton 인스턴스들이 앱에서 공유될 때 이처럼 직접 수정하게 될 수 있고 예외로 이어질 수 있다.

전역 동작

Singleton 인스턴스는 앱의 전체에서 참조할 수 있어야 한다. 전역 스코프에서 전역 변수를 접근할 수 있는 한. 해당 변수는 앱 전체에서 접근할 수 있기 때문에. 전역 변수는 반드시 같은 동작을 구현하는 데 사용해야 한다.

만약 전역 변수가 잘못된 판단으로 올바르지 않게 만들어진 경우. 잘못된 값으로 덮어쓰여질 수 있으며. 이 변수를 참조하는 구현들이 모두 예외를 발생시킬 수 있다.

ES2015에선 전역변수를 생성하는게 일반적이지 않은 것이. 새로 만들어진 let, const 키워드들은 변수를 블록 스코프 내에 선언하게 하여. 실수로 전역에 변수를 선언하는것을 예방해 주기 때문이다. 또 새로운 module 시스템은 export 구문과 import 구분으로 전역 객체를 수정하지 않고 모듈 내에서 전역으로 쓸 수 있는 변수를 만들게 해 준다.

그러나 Singleton 패턴은 일반적으로 앱에 전역 상태를 위해 사용한다. 코드의 여러 부분에서 수정가능한 하나의 객체를 직접 접근하도록 설계하면 예외가 발생하기 쉬워진다.

보통 어떤 코드들은 데이터를 읽어들이는 부분을 위해 전역 상태를 수정하기도 한다. 이 경우 실행 순서가 중요해진다. 데이터가 만들어지지 않았는데 사용할 수는 없기 때문이다.

앱의 규모가 커지고 전역 상태를 참조하는 컴포넌트가 많아지며 서로를 참조하는 상황에서는 데이터의 흐름을 파악하기 어려워진다.

React 의 상태 관리

React에선 전역 상태 관리를 위해 Singleton 객체를 만드는 것 대신 ReduxReact Context를 자주 사용한다. Singleton과 유사해 보이지만 Singleton은 인스턴스의 값을 직접 수정할 수 있는 반면에, 언급한 도구들은 읽기 전용 상태를 제공한다. Redux를 사용할 땐 오직 컴포넌트에서 디스패쳐를 통해 넘긴 액션에 대해 실행된 순수함수 리듀서를 통해서만 상태를 업데이트할 수 있다.

위에서 언급한 전역 상태에 대한 단점이 모두 사라지는 것은 아니지만. 컴포넌트가 직접 상태를 업데이트하게 두는 것은 아니고 개발자가 의도한대로만 수정되도록 하고 있는 것이다.


참조