본문 바로가기
JavaScript & TypeScript

싱글톤 패턴 이해하기: 리소스 관리부터 테스트 개선까지

by 대박플머 2024. 10. 8.

소프트웨어 디자인 패턴 중 하나인 싱글톤 패턴(Singleton Pattern)은 애플리케이션 내에서 클래스의 인스턴스를 하나만 생성하도록 보장하는 패턴입니다. 이 패턴은 주로 데이터베이스 연결, 설정 파일 관리, 로그 처리 등과 같이 리소스를 효율적으로 관리해야 하는 상황에서 사용됩니다. 하지만 싱글톤 패턴은 테스트의 독립성을 해칠 수 있는 단점도 가지고 있습니다. 이번 글에서는 싱글톤 패턴의 개념, 장단점, 그리고 의존성 주입(Dependency Injection)을 통해 어떻게 이러한 단점을 극복할 수 있는지 살펴보겠습니다.

싱글톤 패턴이란?

싱글톤 패턴은 특정 클래스의 인스턴스가 프로그램 내에서 오직 하나만 존재하도록 보장하는 디자인 패턴입니다. 이는 전역적으로 접근 가능한 인스턴스를 제공하여, 여러 객체가 동일한 자원을 공유하도록 합니다.

특징

  • 전역 접근성: 어디서든 동일한 인스턴스에 접근할 수 있습니다.
  • 인스턴스 제어: 클래스 자체에서 인스턴스 생성과 관리를 담당합니다.

사용 예시

  • 데이터베이스 연결 관리: 다수의 연결을 생성하는 대신 하나의 연결을 공유하여 리소스를 절약합니다.
  • 설정 파일 관리: 애플리케이션 전역에서 동일한 설정을 사용하도록 보장합니다.
  • 로그 처리: 하나의 로그 인스턴스를 통해 일관된 로그 기록을 유지합니다.

싱글톤 패턴의 구현 (TypeScript 예시)

class Singleton {
  private static instance: Singleton;

  // 생성자를 private으로 설정하여 외부에서 인스턴스 생성을 막습니다.
  private constructor() {}

  // 인스턴스를 가져오는 정적 메서드
  public static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }

  public someMethod() {
    console.log('싱글톤 인스턴스 메서드 호출');
  }
}

// 사용 예시
const singleton1 = Singleton.getInstance();
const singleton2 = Singleton.getInstance();

console.log(singleton1 === singleton2); // true

위 코드에서 Singleton 클래스는 자체적으로 인스턴스를 관리하며, getInstance 메서드를 통해서만 접근 가능합니다.

싱글톤 패턴의 장점

  1. 리소스 효율성: 인스턴스 생성 비용이 큰 경우, 한 번만 생성하여 재사용하므로 성능 향상에 도움이 됩니다.
  2. 일관성 유지: 애플리케이션 전역에서 동일한 인스턴스를 사용하므로 상태 일관성을 유지할 수 있습니다.
  3. 명확한 접근 방법: 전역 접근 포인트를 제공하므로 어디서든 쉽게 사용할 수 있습니다.

싱글톤 패턴의 단점

  1. 테스트 어려움: 전역 상태를 가지므로 테스트 간 독립성이 떨어집니다.
  2. 강한 결합: 코드가 싱글톤 인스턴스에 의존하므로 유연성이 감소합니다.
  3. 멀티스레드 이슈: 동시성 문제가 발생할 수 있어 추가적인 처리 필요합니다.

테스트에서의 문제점

싱글톤 패턴은 전역 상태를 가지므로, 하나의 테스트에서 변경된 상태가 다른 테스트에 영향을 줄 수 있습니다. 이는 테스트 결과의 신뢰성을 떨어뜨리고, 디버깅을 어렵게 만듭니다.

예시: 문제 발생 코드

// 싱글톤 클래스
class Config {
  private static instance: Config;
  public settings: { [key: string]: any } = {};

  private constructor() {}

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

// 테스트 코드
describe('Config Tests', () => {
  it('첫 번째 테스트', () => {
    const config = Config.getInstance();
    config.settings['mode'] = 'test';
    expect(config.settings['mode']).toBe('test');
  });

  it('두 번째 테스트', () => {
    const config = Config.getInstance();
    expect(config.settings['mode']).toBeUndefined(); // 실패합니다.
  });
});

위 테스트에서 첫 번째 테스트에서 설정한 값이 두 번째 테스트에 영향을 미칩니다.

의존성 주입을 통한 문제 해결

의존성 주입(Dependency Injection)은 객체 간의 결합도를 낮추고, 테스트 용이성을 높여주는 설계 패턴입니다. 이를 통해 싱글톤 패턴의 단점을 완화할 수 있습니다.

의존성 주입의 장점

  • 테스트 용이성 향상: 모의 객체(Mock Object)를 주입하여 테스트할 수 있습니다.
  • 유연성 증가: 구현체를 쉽게 교체할 수 있어 확장성이 높아집니다.
  • 결합도 감소: 객체 간 의존성을 명시적으로 주입하므로 결합도가 낮아집니다.

수정된 코드 예시

// 인터페이스 정의
interface IConfiguration {
  settings: { [key: string]: any };
}

// 싱글톤 클래스
class Config implements IConfiguration {
  private static instance: Config;
  public settings: { [key: string]: any } = {};

  private constructor() {}

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

// 서비스 클래스
class Service {
  private config: IConfiguration;

  constructor(config: IConfiguration) {
    this.config = config;
  }

  public getMode() {
    return this.config.settings['mode'];
  }
}

// 테스트 코드
describe('Service Tests', () => {
  it('모의 객체를 사용한 테스트', () => {
    const mockConfig: IConfiguration = { settings: { mode: 'mock' } };
    const service = new Service(mockConfig);
    expect(service.getMode()).toBe('mock');
  });
});

위 코드에서 Service 클래스는 IConfiguration 인터페이스를 구현한 객체를 주입받습니다. 테스트 시에는 실제 싱글톤 인스턴스 대신 모의 객체를 주입하여 테스트의 독립성을 보장합니다.

오픈소스 예시: InversifyJS를 활용한 싱글톤 패턴

InversifyJS는 TypeScript로 작성된 의존성 주입 라이브러리로, 싱글톤 패턴과 의존성 주입을 결합하여 사용할 수 있습니다.

설치

npm install inversify reflect-metadata

코드 구현

import 'reflect-metadata';
import { injectable, inject, Container } from 'inversify';

// 인터페이스 정의
interface ILogger {
  log(message: string): void;
}

// 싱글톤으로 사용할 클래스
@injectable()
class ConsoleLogger implements ILogger {
  log(message: string): void {
    console.log(`로그: ${message}`);
  }
}

// 서비스를 사용하는 클래스
@injectable()
class App {
  private logger: ILogger;

  constructor(@inject('ILogger') logger: ILogger) {
    this.logger = logger;
  }

  run() {
    this.logger.log('애플리케이션 시작');
  }
}

// 컨테이너 설정
const container = new Container();
container.bind<ILogger>('ILogger').to(ConsoleLogger).inSingletonScope();
container.bind<App>(App).toSelf();

// 애플리케이션 실행
const app = container.get<App>(App);
app.run();

위 코드에서 ConsoleLogger는 싱글톤 스코프로 등록되며, App 클래스는 의존성 주입을 통해 ILogger를 주입받습니다.

테스트 코드

import { Container } from 'inversify';

// 테스트용 모의 로거
class MockLogger implements ILogger {
  log(message: string): void {
    // 테스트용 로직
  }
}

describe('App Tests', () => {
  it('MockLogger를 사용한 테스트', () => {
    const testContainer = new Container();
    testContainer.bind<ILogger>('ILogger').to(MockLogger);
    testContainer.bind<App>(App).toSelf();

    const app = testContainer.get<App>(App);
    app.run();

    // MockLogger를 사용하므로 실제 로그가 출력되지 않습니다.
  });
});

테스트 시에는 실제 ConsoleLogger 대신 MockLogger를 주입하여 테스트의 독립성을 유지합니다.

결론

싱글톤 패턴은 리소스 관리 측면에서 유용하지만, 테스트의 독립성을 해칠 수 있는 단점을 가지고 있습니다. 이를 극복하기 위해 의존성 주입을 활용하면 객체 간 결합도를 낮추고, 테스트 용이성을 높일 수 있습니다. 오픈소스 라이브러리인 InversifyJS와 같은 도구를 사용하면 이러한 패턴을 더 쉽게 적용할 수 있습니다.