🗓️ 2025. 07. 24
⏱️ 32

Node.js의 동시성 문제

자칫하면 흠칫하고 꼬입니다

개요

Node.js에서 동시성 문제는 상대적으로 가볍게 여겨지곤 합니다. 싱글스레드 기반이라 멀티스레드 환경에서 자주 발생하는 문제들을 쉽게 피할 수 있다는 말들도 흔히 찾아볼 수 있죠. 하지만 동시성을 띤다는 건 경쟁 상태(race condition)를 피할 수 없음을 암시하기에 개발자는 항상 이를 염두에 둬야 합니다.

'Node.js 디자인 패턴 바이블'에서도 다음과 같이 경쟁 상태 문제를 상기시키고 있습니다.

Node.js에서는 모든 것이 단일 스레드에서 실행되기 때문에 일반적으로 멋진 동기화 메커니즘을 필요로 하지 않습니다. 그러나 이것이 우리가 경쟁 조건을 가지지 않는다는 것을 의미하지 않으며, 오히려 아주 일반적일 수 있습니다. 문제의 근본적인 원인은 비동기 작업 호출과 그 결과에 대한 통지 사이에 생기는 지연입니다.

이 글에서는 Node.js에서 발생할 수 있는 동시성 문제를 다룹니다. 왜 Node.js에선 동시성 문제가 잘 다뤄지지 않는지, 데이터베이스같은 외부 자원없이 순수 Node.js 코드만으로 어떤 동시성 문제가 발생할 수 있는지를 설명하고 그에 대한 해결책 4가지를 소개합니다.

Node.js에서 동시성 문제는 왜 잘 안 다뤄질까?

아시다시피 Node.js는 동시성을 갖습니다.
우리가 물을 마시면서 모니터를 볼 때 우린 멀티태스킹을 한다고 생각하지만, 실제론 뇌에서 아주 짧은 시간 동안 물 마시기와 모니터 보기를 번갈아가며 수행하듯 Node.js에서도 여러 작업이 병렬적으로 처리되고 있는 것 같지만 실제론 이벤트 기반의 콜백들이 번갈아가며 호출됩니다.

여러 개를 한번에 하고 있는 것 같지만여러 개를 한번에 하고 있는 것 같지만
실제론 번갈아가면서 하고 있다실제론 번갈아가면서 하고 있다

다시 말하자면 우리가 작성하는 Node.js 코드는 동시성을 갖지만, 병렬성은 갖지 않는다는 뜻입니다.
(worker_thread같은 멀티스레딩 모듈을 이용하지 않는 한)

보통 다른 언어에서는 I/O 작업이나 오래 걸리는 CPU 연산 작업을 멀티스레딩으로 해결합니다. 웹서버에선 요청마다 스레드를 할당하는 request per thread 방식을 쓰기도 하구요. 그런데 Node.js는 I/O 작업을 내부 코어인 libuv에게 맡기고, CPU 작업은 별도의 모듈로 만들어 처리시키곤 합니다. 내장 라이브러리로 node:crypto도 그렇고, 우리에게 친숙한 sharp도 C++로 구현되어 있죠. 그래서인지 Node.js에서는 직접적으로 멀티스레딩을 접할 일이 거의 없습니다.

반면 멀티스레딩을 지원하는 언어의 기본서를 보면 멀티스레딩 기법이 반드시 소개되며, 이때 동시성 문제까지 함께 다룹니다. 덕분에 공유 자원, 임계 영역, 뮤텍스, 원자적 연산같은 용어들도 자연스레 알게 됩니다. 이런 이유로 Node.js로 처음 개발을 시작했다면 동시성 문제가 상대적으로 낯설 수 밖에 없습니다.

그럼 싱글스레드 기반의 Node.js에서는 어떤 동시성 문제가 발생할 수 있을까요?
Node.js 디자인 패턴 바이블에서 말하는 '비동기 작업 호출과 그 결과에 대한 통지 사이에 생기는 지연'이란 어떤 의미일까요?

비동기 컨텍스트 꼬임 문제

직접 동시성 문제를 확인해볼 수 있게 약간 실전적인 요구사항을 정의해보겠습니다.

  1. 사용자에게 결제 영수증을 전송해야 한다.
  2. 사용자 정보엔 휴대폰 번호와 이메일 주소가 있다. 이때 이메일은 선택 입력 사항이다.
  3. 이메일이 등록된 사용자에겐 영수증을 이메일로 보내고, 등록되지 않았다면 SMS로 전송한다.

사용자와 영수증을 간단하게 정의해보면 다음과 같습니다.

interface User {
  id: number;
  phoneNumber: string;
  email?: string; // 이메일은 optional
}

interface Receipt {
  productName: string;
  price: number;
  orderedAt: Date;
}

디자인 패턴에 익숙하신 분이라면 요구사항을 보고 곧바로 Strategy 패턴을 떠올리셨을 겁니다.
흔한 디자인 패턴 예시이므로 예제에서도 Strategy 패턴을 사용해보겠습니다.

우선 영수증을 전송할 인터페이스를 정의합니다.

interface ReceiptSendStrategy {
  send(to: string, receipt: Receipt): boolean;
}

그리고 이메일과 SMS를 전송하는 위 인터페이스의 구현체를 정의합니다.

class EmailReceiptSendStrategy implements ReceiptSendStrategy {
  send(to: string, receipt: Receipt): boolean {
    console.log(`Sending receipt ${receipt.productName} to ${to} via email`);
    return true;
  }
}

class SmsReceiptSendStrategy implements ReceiptSendStrategy {
  send(to: string, receipt: Receipt): boolean {
    console.log(`Sending receipt ${receipt.productName} to ${to} via sms`);
    return true;
  }
}

상황에 맞게 어떤 구현체로 영수증을 보낼지 결정하는 ReceiptSender라는 클래스도 정의해보겠습니다.

class ReceiptSender {
  private strategy: ReceiptSendStrategy;

  setStrategy(strategy: ReceiptSendStrategy) {
    this.strategy = strategy;
  }

  // 현재 설정된 방식으로 사용자에게 영수증을 전송한다
  send(to: string, receipt: Receipt) {
    return this.strategy.send(to, receipt);
  }
}

각 사용자에게 보낼 영수증들은 배열로 간단히 표현할 수 있습니다.
준비된 데이터에서 1번 사용자는 이메일 주소가 있지만, 2번 사용자는 없습니다.

const data: { user: User; receipt: Receipt }[] = 
  [
    {
      user: {
        id: 1,
        phoneNumber: '01012345678',
        email: 'test1@test.com',
      },
      receipt: {
        productName: 'Product 1',
        price: 10000,
        orderedAt: new Date(),
      },
    },
    {
      user: {
        id: 2,
        phoneNumber: '01012345679',
        // 2번 사용자는 이메일 주소가 없다
      },
      receipt: {
        productName: 'Product 2',
        price: 20000,
        orderedAt: new Date(),
      },
    },
  ];

이제 준비된 코드를 모두 실행해보겠습니다!

const sender = new ReceiptSender();

data.map(({ user, receipt }) => {
  // 이메일 주소가 등록된 사용자는 이메일로 영수증을 보낸다
  const strategy = user.email
    ? new EmailReceiptSendStrategy()
    : new SmsReceiptSendStrategy();

  // 사용자 정보에 따라 결정된 방식으로 영수증을 전송한다
  sender.setStrategy(strategy);
  sender.send(user.email ?? user.phoneNumber, receipt);
});

코드가 매우 짧기 때문에 결과를 금방 예측할 수 있습니다.

Sending receipt Product 1 to test1@test.com via email
Sending receipt Product 2 to 01012345679 via sms

1번 사용자는 이메일이 등록돼있기 때문에 이메일로, 2번 사용자는 이메일이 등록되지 않았기 때문에 휴대폰 번호로 SMS가 전송됐습니다. 이처럼 모든 코드가 동기적인 경우엔 동시성 문제가 발생하지 않습니다.

이번엔 사용자에게 영수증을 전송하는 작업이 비동기가 되게끔 의도적으로 타이머를 사용해보겠습니다.

import { setTimeout } from 'node:timers/promises';

const sender = new ReceiptSender();

data.map(async ({ user, receipt }) => {
  const strategy = user.email
    ? new EmailReceiptSendStrategy()
    : new SmsReceiptSendStrategy();

  sender.setStrategy(strategy);
  await setTimeout(1); // 작업 비동기화
  
  sender.send(user.email ?? user.phoneNumber, receipt);
});

언뜻보기엔 문제가 없는 것처럼 보입니다. 1ms를 대기하는 코드만 추가됐을 뿐이지 작업 로직이 바뀐 건 없으니까요. 하지만 실제로 코드를 실행해보면 기대와 다른 결과가 출력됩니다.

Sending receipt Product 1 to test1@test.com via sms
Sending receipt Product 2 to 01012345679 via sms

분명 1번 사용자에겐 이메일로 영수증이 전송되어야 하는데 이메일 주소로 SMS가 전송됐습니다.
이메일과 SMS 전송을 연동까지 해두었다면 오류가 발생했겠죠.
1초도 아니고 고작 0.001초를 대기했을 뿐이데 왜 이런 차이가 생기는 걸까요?

사실 차이를 만들어내는 건 '얼마나 대기했는가?'가 아니라 '작업이 비동기적인가?'입니다.

개발자가 제어하는 Node.js 코드는 싱글스레드 기반으로 동작하기 때문에 순차적으로 실행됩니다.
모든 코드가 동기적으로 작성된 첫 번째 코드는 실행 순서를 예상하기 쉽습니다.

코드가 동기적일 때 실행 순서코드가 동기적일 때 실행 순서

반면 비동기적으로 동작하는 두 번째 코드는 전체 코드 실행 순서를 예상하기 어렵습니다.

코드가 비동기적일 때 실행 순서코드가 비동기적일 때 실행 순서

비동기 코드에서는 타이머로 인해 1ms 동안 사용자 1의 작업이 지연되고, 그 사이 사용자 2의 작업이 시작됩니다.
그림에서 알 수 있듯 사용자 1의 지연이 끝나서 send를 호출할 땐 사용자 2의 setStrategy가 호출되어 strategy가 sms로 덮어써집니다. 이 글의 개요에서 인용한 Node.js 디자인 패턴 바이블의 '비동기 작업 호출과 그 결과에 대한 통지 사이에 생기는 지연'이 바로 이런 상황을 의미합니다.

여러 개의 비동기 작업이 동시에 실행될 때, 각각의 비동기 작업이 동일한 자원(ReceiptSender 인스턴스)을 공유하게 되면서 경쟁 상태를 갖게 된 거죠. 멀티스레딩을 지원하는 언어에서 공유 자원으로 인해 경쟁 상태가 생기는 것과 동일합니다. 차이점은 Node.js는 비동기 코드에서 이런 문제가 발생한다는 것입니다.

이처럼 동시에 실행되는 비동기 작업들이 각자의 컨텍스트를 잃고 기대와 다른 동작을 하게 되는 것이 바로 비동기 컨텍스트 꼬임 문제입니다. 그럼 싱글스레드 기반의 Node.js에서는 이 문제를 어떻게 해결할 수 있을까요?

동시성 문제의 해결책

공유 자원 갖지 않기

가장 단순하고 효과적인, 게다가 빠르기까지한 해결책은 애초에 공유 자원을 갖지 않는 것입니다.
ReceiptSender를 공유 자원으로 만들지 않고, 각 컨텍스트 내에서 인스턴스를 생성하게 만들면 됩니다.

// const sender = new ReceiptSender();
// 비동기 컨텍스트들의 공유 자원으로 만들지 않기

data.map(async ({ user, receipt }) => {
  const sender = new ReceiptSender(); // 비동기 컨텍스트 내에서 생성

  // ...
});

사실 코드 관점에서 봤었을 때 ReceiptSender가 과해보이기도 합니다. 별다른 역할이 없거든요.
그래서 아예 해당 클래스를 없애서 복잡도를 낮출 수도 있습니다.

data.map(async ({ user, receipt }) => {
  // strategy를 직접 사용
  const strategy: ReceiptSendStrategy = user.email
    ? new EmailReceiptSendStrategy()
    : new SmsReceiptSendStrategy();

  await setTimeout(1);
  
  strategy.send(user.email ?? user.phoneNumber, receipt);
});

또 한 가지 주의해야 할 것은 ReceiptSender가 strategy라는 상태를 갖는 클래스라는 점입니다. 맨 처음 코드에서 ReceiptSender는 비동기 컨텍스트들의 공유 자원이자 단일 인스턴스입니다.

백엔드 영역에서 단일 인스턴스에 mutable한 상태를 두는 것은 예측하기 어려운 오류를 발생시키는 주된 원인 중 하나이기 때문에 의도적으로 설계한 경우가 아니라면 반드시 지양해야 합니다. 특히 Nest.js에서는 provider가 기본적으로 싱글턴이기 때문에 더더욱 주의가 필요하구요.

알고 있어도 자칫 놓치기 쉬운 부분이기 때문에 공유 자원이나 싱글턴 객체에 mutable한 상태가 있는지 검토하는 규칙을 만들어 AI의 도움을 받아 동시성 문제를 회피하는 것도 좋은 방안이 될 수 있습니다.

Lock 걸기

또다른 해결책은 Lock을 거는 방식입니다.
Node.js 자체 라이브러리는 없지만 async-mutex를 이용하면 어렵지 않게 구현할 수 있습니다.
동기적인 작업에는 별다른 Lock이 필요없으니 비동기 작업에 필요한 Lock임을 라이브러리 이름에서 느낄 수 있네요.

npm install async-mutex

인터페이스 또한 다른 언어에서 Lock을 거는 방식과 크게 다르지 않습니다.

import { Mutex } from 'async-mutex';

const sender = new ReceiptSender();
const lock = new Mutex(); // 락 생성

data.map(async ({ user, receipt }) => {
  // 락 획득 대기
  const release = await lock.acquire();

  try {
    const strategy = user.email
      ? new EmailReceiptSendStrategy()
      : new SmsReceiptSendStrategy();

    sender.setStrategy(strategy);

    await setTimeout(1);

    sender.send(user.email ?? user.phoneNumber, receipt);
  } finally {
    release(); // 락 획득 해제
  }
});

당연히 이 방식은 널리 알려진 Lock의 단점들(대기시간, 데드락 등)을 그대로 안고 갑니다. 우리가 예제에서 마주친 상황은 불필요하게 공유 자원을 만들어 발생한 문제였기 때문에 사실 Lock이 적절한 해법은 아닙니다. 그럼에도 정말 공유 자원이 필요한 경우엔 충분히 검토해볼 수 있겠습니다.

명시적 컨텍스트 전달

비동기 컨텍스트에게 현재의 컨텍스트를 명시적으로 전달하는 방법도 있습니다.
더 와닿는 이해를 위해 이번엔 살짝 다른 예제를 준비해보겠습니다.

HTTP 요청으로 사용자 id와 메시지를 받아서 해당 사용자의 휴대폰 번호로 SMS를 전송하는 예제입니다.
편의를 위해 배열로 유저 데이터를 준비하고, find 메서드로 조회하겠습니다.

const users: User[] = [
  { id: 1, phoneNumber: '01012345678' },
  { id: 2, phoneNumber: '01012345679' },
];

async function getUserById(id: number) {
  console.log(`finding user id: ${id}`);
  return users.find(user => user.id === id)!;
}

마찬가지로 SMS 전송도 간단히 로그만 찍어서 호출 여부를 확인하겠습니다.

async function sendSms(phoneNumber: string, message: string) {
  console.log(`sending sms ${phoneNumber}: ${message}`);
  return true;
};

엔드포인트에서는 JSON으로 받은 userId와 message로 사용자를 조회하고 SMS를 전송합니다.

app.post('/send-sms', async (req, res) => {
  const { userId, message } = req.body as {
    userId: number;
    message: string;
  };

  const user = await getUserById(userId);
  await sendSms(user.phoneNumber, message);

  res.end();
});

이렇게 만들어진 서버에 요청을 연달아 날려보면 로그가 정상적으로 출력되지만, 각각의 로그가 어떤 요청으로부터 생성된 건지 알 수가 없다는 문제가 있습니다.

finding user id: 1
finding user id: 2
sending sms 01012345678: hello
sending sms 01012345679: hello

위 로그에서도 유저 조회 로그와 SMS 전송 로그가 서로 매칭이 되지 않아서 로그간의 관계를 파악할 수가 없습니다. 데이터베이스에 접근해 유저의 휴대폰 번호를 확인해보지 않는 이상 "sending sms 01012345678: hello"라는 로그가 id=1인 유저를 조회한 로그와 관련있는지 id=2인 유저를 조회한 로그와 관련있는지 추적할 수가 없는 거죠.

그래서 우린 동시에 여러 요청이 들어오더라도 로그를 추적할 수 있게 로그에 requestId를 남기려 합니다.
각 함수에게 requestId를 전달할 수 있도록 파라미터를 추가해주겠습니다.

interface RequestContext {
  requestId: string
}

async function getUserById(context: RequestContext, id: number) {
  console.log(`[${context.requestId}] finding user id: ${id}`);
  return users.find(user => user.id === id)!;
}

async function sendSms(
  context: RequestContext,
  phoneNumber: string,
  message: string,
) {
  console.log(`[${context.requestId}] sending sms ${phoneNumber}: ${message}`);
  return true;
}

엔드포인트에서는 UUID로 생성한 requestId를 context에 할당하고, 함수 호출시 값을 넘겨주겠습니다.

import { randomUUID } from 'node:crypto';

app.post('/send-sms', async (req, res) => {
  // ...
  const context: AsyncContext = {
    requestId: randomUUID()
  };
  
  const user = await getUserById(context, body.userId);
  await sendSms(context, user.phoneNumber, message);
  // ...
});

요청마다 context 객체를 생성하기 때문에 다른 요청에 의해 requestId가 덮어써지는 문제가 발생하지 않습니다. 앞선 Strategy 예제에서 확인했던 경쟁 상태가 발생하지 않는 건데요, 사실 이런 패턴은 이미 우리에게 req.user로 익숙한 방식입니다. 요청 컨텍스트에 유저 정보를 묶어서 요청마다 어떤 유저의 요청인지 식별할 수 있게 만들어주는 거죠.

요청 컨텍스트는 모든 엔드포인트에서 필요하기 때문에 미들웨어를 이용할 수도 있습니다.
우선 모든 req 객체에 컨텍스트가 추가되므로 타입을 확장시켜주겠습니다.

declare namespace Express {
  export interface Request {
    context: RequestContext;
  }
}

그리곤 미들웨어를 정의해서 req 객체에 컨텍스트를 할당해줍니다.

import { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';

const contextMiddleware = (req: Request, res: Response, next: NextFunction) => {
  const context: RequestContext = { requestId: randomUUID() };
  req.context = context;
  next();
};

app.use(contextMiddleware);

컨텍스트를 사용하는 곳에선 이렇게 req 객체에서 컨텍스트를 뽑아내면 됩니다.

app.post('/send-sms', async (req, res) => {
  // ...
  const user = await getUserById(req.context, body.userId);
  await sendSms(req.context, to, message);
  // ...
});

일단 문제는 해결했지만 여전히 아쉬운 점이 있습니다.

  1. 로깅이 필요한 모든 함수에 context 파라미터가 추가되어야 한다는 점
  2. 그때문에 함수 호출의 depth가 깊어지면 그 파라미터의 전달의 전달의 전달이 생긴다는 것.

이런 아쉬운 점들은 코드의 확장과 유지보수를 어렵게 만듭니다.
더 좋은 방법은 없을까요? 다른 언어에서도 분명 같은 문제가 있을 텐데 다들 어떻게 해결하고 있는 걸까요?

AsyncLocalStorage

동시성으로 인해 비동기 컨텍스트가 꼬이는 이유는 결국 비동기 작업별로 컨텍스트를 식별할 수 없기 때문입니다.
그래서 우린 비동기 작업 내에서 새로 컨텍스트를 생성하거나 작업 바깥에서 컨텍스트를 만들어 전달하는 방식으로 해결했는데요, 사실 Node.js는 비동기 컨텍스트를 식별할 수 있는 자체 라이브러리를 이미 제공하고 있습니다.

그 라이브러리를 설명하기 전에 아까 살펴본 로그 문제를 멀티스레딩을 지원하는 언어에서는 어떻게 해결할 수 있는지 짧게 살펴보겠습니다. 멀티스레딩을 지원하는 언어에는 TLS(Thread-Local Storage)라는 개념이 있습니다. 말뜻대로 스레드마다 지역적인 저장소를 갖는 방식인데, 여러 스레드가 똑같은 변수에 접근하는 것처럼 보이지만 실제론 스레드마다 다른 값을 갖습니다. 읽기 쉬운 Python 코드로 아주 잠깐 진짜 잠깐만 어떤 느낌인지 맛보겠습니다.
(슥 훑어보기만 해도 대충 감이 오실 거예요)

import threading
from time import sleep
from uuid import uuid4

# 전역에서 thread local 변수 선언
thread_local_data = threading.local()


def worker():
    # context_id 할당
    thread_local_data.context_id = uuid4()

    # 식별을 위해 thread_id 확인
    thread_id = threading.current_thread().ident

    # 1초마다 스레드의 context id 출력
    while True:
        print(f"Thread #{thread_id} context_id = {thread_local_data.context_id}")
        sleep(1)

이제 3개의 스레드를 생성해서 결과를 확인해보겠습니다.

threads: list[threading.Thread] = []

for i in range(3):
    t = threading.Thread(target=worker)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

thread_local_data이라는 변수가 전역 변수이기 때문에 마치 공유 자원을 덮어쓰고 있을 것처럼 보이지만 실제론 스레드마다 값이 독립적으로 관리되어 context_id가 잘 유지되고 있음을 확인할 수 있습니다.

Thread #6148730880 context = 72f253eb-6b8c-4f06-bba3-3fb97f5873ab
Thread #6165557248 context = 4ef86a79-dbfc-4c94-aa06-3a210f1e273a
Thread #6182383616 context = fb30d795-9293-441d-92df-0af9cc2fa2ba
Thread #6148730880 context = 72f253eb-6b8c-4f06-bba3-3fb97f5873ab
Thread #6182383616 context = fb30d795-9293-441d-92df-0af9cc2fa2ba
Thread #6165557248 context = 4ef86a79-dbfc-4c94-aa06-3a210f1e273a
...

Node.js에서는 TLS와 유사한 ALS(AsyncLocalStorage)라는 자체 라이브러리가 있습니다. 비동기 작업의 컨텍스트를 전파할 수 있게 도와주는 도구이고, 웹브라우저의 LocalStorage와는 아예 다른 것이니 오해하시면 안 됩니다!

ALS의 대표적인 메서드는 rungetStore입니다.

run 메서드는 첫 번째 인자에 컨텍스트의 초기값을 설정하고, 두 번째 인자에 비동기 작업을 할당합니다.
두 번째 인자로 전달받은 비동기 작업 내에서 첫 번째 인자로 전달했던 컨텍스트를 불러올 수 있습니다.

import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

asyncLocalStorage.run({ counter: 0 }, callback);

getStore 메서드는 비동기 작업 내에서 호출됐을 때 해당 작업에 맞는 컨텍스트를 불러옵니다.

const store = asyncLocalStorage.getStore()

설명만으론 이해가 어렵기 때문에 위에서 살펴본 Python 코드와 유사하게 예제를 작성해보겠습니다.
우선 컨텍스트의 인터페이스(타입)을 정의하고 ALS 인스턴스를 생성합니다.

import { AsyncLocalStorage } from 'node:async_hooks';

interface AsyncContext {
  taskId: number; // 스레드 id 대신 사용할 작업 id
  contextId: string;
}

const asyncLocalStorage = new AsyncLocalStorage<AsyncContext>();

이렇게 생성된 ALS 인스턴스를 이용해 비동기 컨텍스트 안에서 getStore로 컨텍스트를 불러올 수 있습니다.
마찬가지로 1초마다 taskId와 contextId를 출력합니다.

function worker() {
  setInterval(() => {
    const context = asyncLocalStorage.getStore();
    console.log(`task #${context?.taskId} context = ${context?.contextId}`);
  }, 1000);
}

컨텍스트 초기값을 설정하고 getStore로 컨텍스트를 정상적으로 불러오기 위해 비동기 작업을 run 메서드로 감싸줍니다.
Python에서 3개의 스레드를 생성했듯 3개의 작업을 실행시켜보겠습니다.

import { randomUUID } from 'node:crypto';

function run(taskId: number) {
  const asyncContext: AsyncContext = {
    taskId: taskId,
    contextId: randomUUID(),
  };

  asyncLocalStorage.run(asyncContext, () => {
    // 이 함수안에서만 컨텍스트를 불러올 수 있다

    worker();
  });
}

for (let i = 0; i < 3; i++) {
  run(i);
}

코드를 실행해보면 컨텍스트를 잃지 않고 각 비동기 작업 내에서 taskId와 contextId를 정상적으로 식별하는 모습을 확인할 수 있습니다.

task #0 context = 9496e2ca-88c6-417f-baa4-d3df61850461
task #1 context = b2761c8e-a1f7-474b-ac0c-d119de3d36f9
task #2 context = afd288f1-3d6e-47a2-84da-66e341cbfe96
task #0 context = 9496e2ca-88c6-417f-baa4-d3df61850461
task #1 context = b2761c8e-a1f7-474b-ac0c-d119de3d36f9
task #2 context = afd288f1-3d6e-47a2-84da-66e341cbfe96
...

그럼 로그 문제를 이번엔 ALS로 해결해보겠습니다.
run 메서드에 미들웨어의 next 함수를 전달하면 모든 요청이 이 미들웨어를 거치게 되면서 자연스럽게 비동기 컨텍스트 추적이 가능해집니다.

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();

const contextMiddleware = (req: Request, res: Response, next: NextFunction) => {
  // 컨텍스트 초기값을 설정
  const context: RequestContext = { requestId: randomUUID() };

  // next 함수를 전달
  asyncLocalStorage.run(context, next);
};

// HTTP 요청 컨텍스트 내에서 호출되는 함수이므로 getStore로 컨텍스트 불러오기 가능
const sendSms = async (to: string, message: string) => {
  const context = asyncLocalStorage.getStore();
  
  console.log(`[${context?.requestId}] try to send sms ${to}: ${message}`);
  await setTimeout(10000);
  console.log(`[${context?.requestId}] sent sms ${to}: ${message}`);
  
  return true;
};

연달아 HTTP 요청을 날려보면 이번엔 추적 가능한 로그를 남는 모습을 볼 수 있습니다.

[9690510f-5996-45f1-a48c-9d19275dc925] try to send sms 01012345678: hello
[58cb41f1-2df6-439f-925d-464dcf106510] try to send sms 01012345678: hello
[9690510f-5996-45f1-a48c-9d19275dc925] sent sms 01012345678: hello
[58cb41f1-2df6-439f-925d-464dcf106510] sent sms 01012345678: hello

정리

마지막으로 ALS의 주의사항을 언급하며 글을 마치도록 하겠습니다.
ALS에는 크게 2가지 문제가 있습니다.

  1. ALS에 대한 암시적 의존성
  2. 콜백에서는 간헐적으로 컨텍스트를 잃음

아마 첫 번째는 글을 읽으시면서 눈치채셨을 텐데, 겉으로 보기에 드러나지 않는 의존성이 생기기 때문에 주의가 필요합니다. ALS를 사용하기 전에 명시적인 전달이나 새 인스턴스 생성으로 쉽게 해결할 순 없는지 먼저 검토해보시기 바랍니다.

두 번째 문제는 제가 경험해보진 못했지만 이미 잘 알려진 이슈(#41285, #41978)입니다. 공식 문서에서도 콜백 기반의 코드를 작성할 경우 util.promisify()를 사용해 promise 기반으로 변환할 것을 권장합니다. 때문에 ALS를 꼭 사용해야 한다면 직접 구현하시기 보다 이런 이슈에 어느정도 대응이 되어있는 라이브러리를 이용하시길 권해드립니다.
(@fastify/request-contextnestjs-cls같은 라이브러리가 있습니다.)

ALS의 적극적인 도입 사례로 MikroORM의 예시까지 다루고 싶었는데 글이 너무 길어져서 이건 다음 기회에 다뤄보도록 하겠습니다.

참고

돌아가기
© 2025 VERYCOSY.