본문 바로가기
Programming/프로그래밍 이론

[프로그래밍 이론] 비동기적으로 초기화되는 모듈 다루기

by SpiralMoon 2025. 3. 3.
반응형

비동기적으로 초기화되는 모듈 다루기

동기식 초기화를 지원하지 않는 모듈을 깔끔하게 비동기식으로 다루고 제공하는 몇가지 원리에 대해 알아보자.

작성환경

예시 코드는 Javascript ES6로 작성되었으나 개념적으로는 언어와 무관한 내용을 다룹니다.


동기식 초기화의 문제

우리가 개발하면서 접하는 많은 모듈(라이브러리)은 사용 전에 초기화를 필요로 한다. (특히 네트워크 연결이 필요한 모듈 & 미들웨어)

이 때 초기화 방식은 동기식과 비동기식으로 나뉜다. 비동기를 다루기 전에 동기식 초기화의 특징을 먼저 살펴보자.

동기식 초기화의 장점

  • 사용하기 편리하고 간단함
  • 초기화되기 이전에 모듈의 기능이 호출되는 상황을 고려하지 않아도 됨 (재초기화가 필요한 상황 고려 X)

동기식 초기화의 단점

  • 많은 모듈을 동기식으로 초기화하면 프로그램이 시작이 지연됨
  • 프로그램이나 특정 컴포넌트가 초기화될 때 한 번만 사용되기 때문에 재초기화가 필요한 상황에서 대응이 어려움

현실적으로 동기식 초기화는 단점이 너무 치명적이므로 서버 프로그램을 개발할 때 적용하기 어렵다. 아예 동기식 초기화 방법을 제공하지 않는 라이브러리도 많다.


시나리오

이 글에서는 DB라는 가상의 모듈을 사용하여 데이터베이스를 다루는 예를 들어보겠다.

 

상황 : DB 모듈은 데이터베이스 서버와의 커넥션 및 핸드 쉐이크가 성공적으로 완료된 후에만 API 요청을 수락한다. 따라서 초기화가 완료될 때까지 쿼리 등의 명령을 보낼 수 없다.

 

import { EventEmitter } from 'events';

class DB extends EventEmitter {
  connected = false;
  
  /**
   * DB와 연결을 수행합니다.
   */
  connect() {
    // 연결 지연 시뮬레이션.
    // (실제 외부 DB 연결 상황에서는 네트워크 레이턴시에 의한 지연이 발생합니다.)
    setTimeout(() => {
      this.connected = true;
      this.emit('connected');
    }, 1000);
  }
  
  /**
   * DB 쿼리 명령을 수행합니다.
   * @param queryStr 쿼리 명령어
   */
  async query(queryStr) {
    if (!this.connected) {
      throw new Error('Not connected yet.');
    }
    
    console.log(`Query executed: ${queryStr}`);
  }
}

// DB 인스턴스
export const db = new DB();

 


방법 1. 로컬 초기화 (Local initialization) 확인

첫 번째 방법은 API가 호출되기 이전에 모듈이 초기화 되었는지 확인하는 방법이다. 초기화되지 않았다면 초기화되기를 기다린다.

 

import { once } from 'events';
import { db } from './db.js'

db.connect();

/**
 * 유저를 저장하는 API
 * @param userId 유저 식별자
 */
const addUser = async (userId) => {

  // DB가 연결되지 않은 경우 초기화 완료까지 기다립니다.
  if (!db.connected) {
    await once(db, 'connected');
  }
  
  await db.query(`INSERT INTO users (userId) VALUES ${userId}`);
}

...

await addUser('aaa');

 

가장 간단하고 손쉬운 해결책이다.

 

하지만 이 방법은 비동기 모듈에서 API를 호출할 때마다 수행해야 하므로 API 종류를 늘릴 때마다 검사 코드를 넣어야하는 사용자 부담(보일러 플레이트)이 발생하며, 실수로 코드를 넣지 않는 휴먼 에러의 단점도 존재한다.

 

확실한 장점으로는 기능이 적은 모듈한테는 개발 비용이 적게 들어가므로 적합할 수 있다는 것이다.


방법 2. 지연 시작

두 번째 방법은 모듈이 초기화 작업을 완료할 때 까지 비동기적으로 초기화된 모듈에 의존하는 코드의 실행을 지연시키는 방법이다.

 

import { once } from 'events';
import { db } from './db.js'

/**
 * 비동기적으로 DB를 초기화합니다.
 */
const init = async () => {
  db.connect();
  await once(db, 'connected');
}

/**
 * 유저를 저장하는 API
 * @param userId 유저 식별자
 */
const addUser = async (userId) => {
  await db.query(`INSERT INTO users (userId) VALUES ${userId}`);
}

...

await init();
await addUser('aaa');

 

먼저 초기화가 완료될 때까지 기다린 다음 db 인스턴스를 사용하는 API를 호출한다.

 

이 방법의 가장 큰 단점은 비동기적으로 초기화되어야 하는 모듈을 사용하는 컴포넌트가 어떤 것인지 미리 알아야 한다는 것이다. 이로 인해 휴먼 에러가 발생할 수 있다.

 

이 문제에 대한 해결책은 모든 비동기 서비스가 초기화될 때까지 전체 프로그램의 시작을 지연시키는 간단하고 효과적인 방법이 있다. 그러나 프로그램 시작 시간에 상당한 지연을 초래하며, 비동기 모듈을 재초기화해야 하는 경우는 여전히 해결 방법이 될 수 없다.


방법 3. 초기화 큐와 명령 패턴

세 번째 방법은 Queue를 이용하여 모듈이 초기화된 후에만 모듈의 서비스가 호출될 수 있도록 하는 방법이다.

 

import { EventEmitter } from 'events';

class DB extends EventEmitter {
  connected = false;
  _commandQueue = [];
  
  /**
   * DB와 연결을 수행합니다.
   */
  connect() {
    // 연결 지연 시뮬레이션.
    // (실제 외부 DB 연결 상황에서는 네트워크 레이턴시에 의한 지연이 발생합니다.)
    setTimeout(() => {
      this.connected = true;
      this.emit('connected');
      
      // queue에 저장되있던 함수를 모두 실행하고 queue를 비움
      this._commandQueue.forEach(command => command());
      this._commandQueue = [];
    }, 1000);
  }
  
  /**
   * DB 쿼리 명령을 수행합니다.
   * @param queryStr 쿼리 명령어
   */
  async query(queryStr) {
    // DB가 연결되지 않은 경우. 쿼리 명령 실행을 callback으로 만들어 queue에 저장
    if (!this.connected) {
      console.log(`Query queued: ${queryStr}`);
      
      return new Promise((resolve, reject) => {
        const command = () => {
          this.query(queryStr).then(resolve, reject);
        }
        
        this._commandQueue.push(command);
      });
    }
    
    // DB가 연결된 경우. 쿼리 명령 실행
    console.log(`Query executed: ${queryStr}`);
  }
}

export const db = new DB();

 

모듈이 초기화되지 않은 상태에서 수신된 함수 호출을 일단 Queue에 넣고, 초기화가 완료되는 즉시 Queue에 저장되있던 함수를 실행하는 것이다.

 

이렇게 수정된 DB 모듈을 사용하면 API를 호출하기 전에 모듈이 초기화되었는지 확인할 필요가 없어졌으므로 사용자는 초기화 상태에 대한 걱정 없이 모듈을 사용할 수 있다.


방법 4. 초기화 큐와 명령 패턴 + 상태 패턴

네 번째 방법은 세 번째 방법에 상태 패턴을 추가하여 모듈성을 개선하는 것이다. 두 가지 상태를 만들어 소스코드를 수정해보자.

 

class InitializedState {

  /**
   * DB 쿼리 명령을 수행합니다.
   * @param queryStr 쿼리 명령어
   */
  async query(queryStr) {
    console.log(`Query executed: ${queryStr}`);
  }
}

 

첫 번째 상태는 모듈이 초기화되면 실행할 함수들을 구현한다. 초기화 상태에 대한 걱정 없이 자체적인 비즈니스 로직을 구현하면 된다.

 

const deactivate = Symbol('deactivate');

class QueuingState {
  
  _db;
  _initRequiredMethodNames = ['query']; // 초기화 이후에 호출되어야하는 함수명 목록
  _commandQueue = [];
  
  constructor(db) {
    this._db = db;
    
    // 각 명령 실행을 callback으로 만들어 queue에 저장하는 함수 할당
    _initRequiredMethodNames.forEach(methodName => {
      this[methodName] = (...args) => {
      
        console.log(`Query queued: ${methodName} ${args}`);
        
        return new Promise((resolve, reject) => {
          const command = () => {
            _db[methodName](...args).then(resolve, reject);
          }
          
          this._commandQueue.push(command);
        });
      }
    });
  }
  
  /**
   * QueuingState가 폐기될 때 사용될 함수입니다.
   * _initRequiredMethodNames에 'deactivate' 라는 이름의 함수명이 들어오면 충돌 가능성이 있으므로 symbol로 충돌 방지
   */
  [deactivate]() {
    // queue에 저장되있던 함수를 모두 실행하고 queue를 비움
    this._commandQueue.forEach(command => command());
    this._commandQueue = [];
  }
}

 

두 번째 상태는 모듈 초기화가 완료되기 전에 사용된다. 첫 번째 상태와 동일한 함수들을 구현하지만 이 함수들의 역할은 Queue에 새 명령을 넣는 것이다.

 

이제 DB 클래스를 InitializedStateQueuingState 상태를 이용하도록 다시 정의한다.

 

import { EventEmitter } from 'events';

class DB extends EventEmitter {
  connected = false;
  _state;
  
  constructor() {
    super();
    this._state = new QueuingState(this);
  }
  
  /**
   * DB와 연결을 수행합니다.
   */
  connect() {
    // 연결 지연 시뮬레이션.
    // (실제 외부 DB 연결 상황에서는 네트워크 레이턴시에 의한 지연이 발생합니다.)
    setTimeout(() => {
      this.connected = true;
      this.emit('connected');
      
      // 초기화 완료 상태로 변경하고 oldState의 마무리 작업 실행
      const oldState = this._state;
      this._state = new InitializedState();
      oldState[deactivate] && oldState[deactivate]();
    }, 1000);
  }
  
  /**
   * DB 쿼리 명령을 수행합니다.
   * @param queryStr 쿼리 명령어
   */
  async query(queryStr) {
    return this._state.query(queryStr);
  }
}

export const db = new DB();

 

수정된 DB 모듈을 분석해보면,

  • 생성자에서 인스턴스의 상태를 할당한다. 아직 초기화가 되지 않은 상태이므로 QueuingState가 할당된다.
  • 비즈니스 로직 query() 함수의 역할은 현재 활성화된 상태의 query() 함수를 호출하는 것이다.
  • 데이터베이스 연결이 완료되면 상태가 InitializedState로 변경되며 이전 상태를 비활성화하고 Queue에 저장되있던 모든 명령을 실행한다.

이 접근 방식을 통해 반복적인 초기화 검사 필요 없이 순수 비즈니스 로직(InitializedState)으로 구현된 클래스를 생성할 수 있다. 하지만 이 방식은 모듈을 수정할 수 있는 경우에만 유효하므로 수정이 불가한 경우 Wrapper나 Proxy를 만들어 해결해야한다.


마무리하며

위에서 제시한 세 번째, 네 번째 방법으로 제작된 모듈을 사용하면 쿼리를 보내기 위해 데이터베이스가 연결될 때까지 기다릴 필요가 없다.

이러한 구조는 많은 데이터베이스 드라이버와 ORM 라이브러리에서 사용되고 있다. 이는 좋은 경험을 제공하려는 모든 API의 필수 사항이다.


참조

 

Node.js 디자인 패턴 바이블 | Mario Casciaro - 교보문고

Node.js 디자인 패턴 바이블 | 완벽한 Node.js 애플리케이션 설계를 위한 디자인 패턴 바이블효율적이고 강력한 Node.js 애플리케이션 구축에 필요한 디자인 패턴들을 한 권으로 정리한다. Node.js로 프

product.kyobobook.co.kr

 

mongoose/lib/drivers/node-mongodb-native/collection.js at 321995d769ff085aa0a4553b2befb012eb2c11c8 · Automattic/mongoose

MongoDB object modeling designed to work in an asynchronous environment. - Automattic/mongoose

github.com

 

반응형

댓글