[Design Patterns] Singleton 패턴

·2024년 1월 23일
0

patterns

목록 보기
1/11
post-thumbnail

항상 MVC, MVVM 등 디자인 패턴에 대해 의문을 가질 뿐 이게 프론트엔드 개발에 어떻게 적용이 되는지 이해가 되지 않았다.
자바스크립트 관점에서 디자인 패턴에 대해 작성된 책을 번역한 사이트를 살펴보며 이해해보자!

Design Pattern

디자인 패턴은 소프트웨어를 개발하는 과정의 반복되는 일반적인 문제들에 대해 기준이 되는 해결책을 제공하는 중요한 개념이다. 디자인 패턴은 소프트웨어의 특정 구현을 직접 제공하지는 않지만, 반복되는 문제 상황들을 최적화된 방법으로 해결하도록 돕는 컨셉들이다.

현대 웹 개발은 다양한 패턴으로 발전하고 있다. 여기서는 ES2015 이상 혹은 React에서 쓰이는 일반적인 디자인 패턴들의 구현, 장점 및 단점과 주의사항에 대해 다루고 그 외에도 웹 앱을 발전시킬 수 있는 다른 여러 패턴들에 대해서도 다룬다.

Singleton 패턴

Singleton이란

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

ES2015 class와 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++ 같은 다양한 언어들은 Javscript처럼 객체를 직접적으로 만들어낼 수 없다. 이런 객체지향 프로그래밍 언어에서는 객체를 만들기 위한 클래스를 꼭 작성해야 한다. 이렇게 만든 객체는 위의 instance 변수와 같이 클래스의 인스턴스가 된다.
Javascript에서는 클래스를 작성하지 않아도 객체를 만들 수 있기 때문에 위의 예제는 약간의 오버 엔지니어링이라고 볼 수 있다. 클래스 대신 객체 리터럴을 사용해서도 동일한 구현을 할 수 있다.

Singleton 패턴을 사용할 때의 단점을 확인해보자.

객체 리터럴 사용하기

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

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

[ counter.js ]

let count = 0;

const counter = {
  increment() {
    return ++count;
  },
  
  decrement() {
    return --count;
  }
};

Object.freeze(counter);

export { counter };

[ blueButton.js ]

import { counter } from './counter';

const button = document.getElementById('blue')

button.addEventListener('click', () => {
  console.log('counter total: ', counter.increment());
})

[ redButton.js ]

import { counter } from './counter';

const button = document.getElementById('red')

button.addEventListener('click', () => {
  console.log('counter total: ', counter.decrement());
})

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

테스팅

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

명확하지 않은 의존

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

[ superCounter.js ]

import Counter from './counter';

export default class SuperCounter {
  constructor() {
    this.count = 0
  }
  
  increment() {
    Counter.increment();
    return (this.count += 100);
  }
  
  decrement() {
    Counter.decrement();
    return (this.count -= 100);
  }
}

전역 동작

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

만약 전역 변수가 잘못된 판단으로 올바르지 않게 만들어진 경우, 잘못된 값으로 덮어쓰여질 수 있으며 이 변수를 참조하는 구현들이 모두 예외를 발생시킬 수 있다.
ES2015에선 전역변수를 생성하는게 일반적이지 않은 것이, 새로 만들어진 let, const 키워드들은 변수를 블록 스코프 내에 선언하게 하여 실수로 전역에 변수를 선언하는 것을 예방해주기 때문이다.
또 새로운 module 시스템은 export 구문과 import 구문으로 전역 객체를 수정하지 않고 모듈 내에서 전역으로 쓸 수 있는 변수를 만들게 해준다.
그러나 Singleton 패턴은 일반적으로 앱에 전역 상태를 위해 사용한다. 코드의 여러 부분에서 수정 가능한 하나의 객체를 직접 접근하도록 설계하면 예외가 발생하기 쉬워진다.

보통 어떤 코드들은 데이터를 읽어들이는 부분을 위해 전역 상태를 수정하기도 한다. 이 경우 실행 순서가 중요해진다. 데이터가 만들어지지 않았는데 사용할 수는 없기 때문이다.
앱의 규모가 커지고 전역 상태를 참조하는 컴포넌트가 많아지며 서로를 참조하는 상황에서는 데이터의 흐름을 파악하기 어려워진다.

React의 상태관리

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

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

< 출처 : https://patterns-dev-kr.github.io/design-patterns/singleton-pattern/ >

profile
개발을 개발새발 열심히➰🐶

0개의 댓글