VOIM 리팩토링: 싱글턴 패턴 도입기 (1~2단계)

Intro

Chrome 확장 프로그램의 백그라운드 서비스에서 발생하던 상태 불일치 문제를 해결하기 위해 싱글턴 패턴과 의존성 주입을 도입하여 리팩토링을 진행했습니다. 기존에는 서비스가 서로 독립적으로 동작하며 서로 다른 상태를 참조하거나 초기화 순서가 뒤엉키는 문제가 있었습니다. 이를 해결하기 위해 서비스 전역에서 단일 인스턴스를 유지하고, 명확한 초기화 흐름을 갖춘 구조로 개선했습니다.
이 글에서는 그 중 1단계(싱글턴 기반 ServiceManager 구현)과 2단계(서비스 간 의존성 주입)까지의 구체적인 구현 과정과 그로 인해 해결된 문제점, 설계상의 장점을 소개합니다.

1. 기존 문제점 분석

1. 다중 객체 생성 문제

// 각 서비스가 개별적으로 인스턴스화되어 상태가 불일치함
export const storageService = new StorageService();
export const settingsService = new SettingsService();
export const iframeService = new IframeService();
서비스들이 각각 독립적으로 인스턴스화되어 실행되다 보니, 동일한 Chrome Storage를 참조하면서도 동기화되지 않는 문제들이 발생했습니다. 특히 UI 상태와 내부 로직 간의 불일치 현상이 빈번하게 발생했습니다.

2. 의존성 관리 복잡성

// SettingsService 내부에서 직접 다른 서비스 참조
import { storageService } from "./storageService";

class SettingsService {
  async resetAllSettings(): Promise<void> {
    await storageService.resetAllSettings(); // 강한 결합
  }
}
서비스 간 의존성이 명확히 드러나지 않고, 암묵적으로 import와 전역 인스턴스를 통해 연결되어 있었습니다. 이는 유지보수 측면에서 구조를 이해하기 어렵게 만들었고, 테스트를 위한 mocking이나 서비스 간 결합도 조절도 어려운 상황이었습니다.

3. 상태 불일치 발생

  • 각 서비스가 개별적으로 Chrome Storage에 접근하며 서로의 상태를 모름
  • 사용자 UI와 실제 설정 값 사이의 불일치가 발생함 (예: 폰트 크기 변경이 반영되지 않음)
  • 특히 시각 장애인을 위한 접근성 관련 기능에서는 상태 동기화가 매우 중요했기에, 이 불일치는 UX 측면에서도 치명적이었습니다

2. 1단계: ServiceManager 클래스 설계 및 싱글턴 구현

📌 리팩토링 목표

  • 모든 서비스 인스턴스를 중앙에서 관리하도록 설계
  • 싱글턴 패턴을 통해 단 하나의 인스턴스만 유지하도록 구현
  • ServiceManager가 초기화 로직을 포함하여 의존성 주입까지 처리함

구현 코드

export class ServiceManager {
  private static instance: ServiceManager | null = null;

  private storageService: StorageService;
  private settingsService: SettingsService;
  private iframeService: IframeService;

  private isInitialized: boolean = false;

  private constructor() {
    this.storageService = new StorageService();
    this.settingsService = new SettingsService();
    this.iframeService = new IframeService();
  }

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

  public async initialize(): Promise<void> {
    if (this.isInitialized) return;

    await this.storageService.loadInitialSettings();

    this.settingsService.setStorageService(this.storageService);
    this.iframeService.setStorageService(this.storageService);

    this.isInitialized = true;
  }

  public getStorageService(): StorageService {
    this.ensureInitialized();
    return this.storageService;
  }

  public getSettingsService(): SettingsService {
    this.ensureInitialized();
    return this.settingsService;
  }

  public getIframeService(): IframeService {
    this.ensureInitialized();
    return this.iframeService;
  }

  private ensureInitialized(): void {
    if (!this.isInitialized) {
      throw new Error("ServiceManager가 초기화되지 않았습니다. initialize()를 먼저 호출하세요.");
    }
  }
}
이 구조의 핵심은 외부에서는 getInstance()만을 통해서 인스턴스에 접근할 수 있고, 초기화는 반드시 명시적으로 initialize()를 호출해야만 진행된다는 점입니다. 이를 통해 서비스의 생성 시점과 초기화 시점을 명확히 구분할 수 있습니다.

백그라운드 스크립트 초기화 변경

기존에는 각 서비스가 파일 단위에서 바로 인스턴스화되었지만, 이제는 ServiceManager를 통해 통합적으로 초기화됩니다.
// src/background/index.ts
async function init() {
  try {
    const serviceManager = ServiceManager.getInstance();
    await serviceManager.initialize();

    initCommandListeners();
    initMessageListeners();
    initStorageListeners();
  } catch (error) {
    logger.error("백그라운드 스크립트 초기화 오류:", error);
  }
}
이제 확장 프로그램이 로드될 때 명확한 순서로 모든 서비스가 초기화되고, 그에 따라 모든 컴포넌트에서 동일한 인스턴스를 공유하게 됩니다.

3. 2단계: 서비스 간 의존성 주입

리팩토링 전략

기존에는 각 서비스가 전역 인스턴스를 import하여 사용했으나, 이제는 필요한 의존성을 외부에서 명시적으로 주입받도록 변경합니다. 이를 통해 테스트 시 mock 주입도 가능해지고, 서비스 간 결합도를 낮출 수 있습니다.

수정된 서비스들

StorageService

export class StorageService {
  private savedSettings: SavedSettings | null = null;
}

SettingsService

export class SettingsService {
  private storageService: StorageService | null = null;

  setStorageService(storageService: StorageService): void {
    this.storageService = storageService;
  }

  async resetAllSettings(): Promise<void> {
    if (!this.storageService) throw new Error("StorageService가 주입되지 않음");
    await this.storageService.resetAllSettings();
  }
}

IframeService

export class IframeService {
  private storageService: StorageService | null = null;

  setStorageService(storageService: StorageService): void {
    this.storageService = storageService;
  }
}

리스너 예시 (storageListeners.ts)

export function initStorageListeners(): void {
  chrome.storage.onChanged.addListener((changes, areaName) => {
    if (areaName !== "local") return;

    const manager = ServiceManager.getInstance();
    const storageService = manager.getStorageService();
    storageService.handleStorageChanges(changes);
  });
}

📌 구현 결과 요약

  • 상태 일관성 확보: 모든 서비스가 ServiceManager를 통해 동일한 인스턴스를 참조
  • 명확한 의존성 주입 구조: 설정과 storage 간의 결합도를 줄임
  • 초기화 순서 제어: initialize() 시점 이후에만 서비스 접근 가능
  • 테스트 가능성 향상: destroyInstance() 등을 통해 테스트 환경에서도 독립적인 초기화 가능

다음 단계 계획

3단계: 메시지 기반 통신 강화

  • MessageService를 도입해 Background <-> Content 간 통신을 중앙화

4단계: 스토리지 접근 중앙화

  • StorageService에서 캐싱 및 변경 알림을 제공하도록 개선

💡 리팩토링을 통해 얻은 인사이트

  • 싱글턴 패턴은 전역 상태 관리에 효과적이다. 복수 인스턴스 생성으로 인한 상태 충돌을 예방할 수 있었다.
  • DI(의존성 주입)는 테스트 가능성과 유연한 아키텍처 설계에 필수적이다. 향후 테스트와 확장성을 고려하면 반드시 도입해야 할 전략임을 체감했다.
  • 초기화 순서의 통제는 서비스 안정성에 큰 영향을 미친다. 초기화 누락, 의존성 미주입 등의 문제를 명확히 방지할 수 있었다.
  • 점진적인 리팩토링이 안정성과 생산성 모두를 높인다. 모든 코드를 한 번에 뜯어고치기보다, 단계적으로 개선하면서 기존 기능을 유지할 수 있었다.

마무리

이번 리팩토링을 통해 구조적인 개선이 가져다주는 장점을 명확히 체감할 수 있었습니다. 단순히 작동하는 코드가 아니라, 확장성과 유지보수성, 사용자 신뢰까지 고려한 설계가 얼마나 중요한지 다시 한번 확인할 수 있는 기회였습니다. 과연 리팩토링을 다 끝낼 수 있을지 다음 시리즈도 봐주시면 감사하겠습니다.