프로그래밍 언어/JS TS

express에서 공통 트랜잭션 ID 부여하기

트리맨스 2023. 8. 12. 23:18
반응형

 

express를 사용한 서버를 만들면서 각 트랜잭션마다 공통적인 ID가 있으면 좋겠다는 생각이 들었다. 이러한 생각에 기반하여 여러가지 방법을 찾아 보았는데, 역시 동시성 제어 관련해서 문제가 있었다. 해당 문제와 관련해서 해결 방법을 생각해 보았다.

 

개요


한개의 요청마다 고유ID를 부여하여 로그를 쉽게 남길 수 있게 하고 싶다. 이 때 동시성 제어와 관련한 문제를 해결해 보려고 한다.

 

사례 조사


오래전부터 사용되었던 Spring 프레임워크에서는 ThreadLocal를 사용하고 있었다. Java는 기본적으로 멀티스레드를 지원하는 언어이기 때문에 동시에 여러개의 트랜잭션을 처리할 때 스레드를 나누어서 처리한다. 이 때 ThreadLocal를 사용하여 각 스레드마다 특정 값을 저장할 수 있다. 이 덕분에 각 스레드 간의 ThreadLocal에 있는 값은 서로 확인할 수가 없다. 각 스레드에 대해서 값을 따로 저장할 수 있다는 장점이 있지만, 직접 remove()를 호출하여 삭제해야 한다. 그렇지 않으면 메모리 누수가 일어날 가능성이 매우 높아진다.

NodeJS는 싱글스레드를 기반으로 동작한다. 하지만 libuv를 사용한 이벤트 루프를 활용하여 멀티스레드 흉내를 낼 수 있다. 각 Tick을 루프로 돌리면서 작업을 처리한다. 그래서 express도 동시에 여러개의 요청을 받아서 처리할 수 있는 것이다.

 

그렇다면 각 가상의 스레드마다 값을 저장할 수 없을까? 검색해보니 cls-hooked라는 라이브러리가 있었다. 해당 문서에서는 "ThreadLocalStorage같이 동작하지만 노드 스타일 콜백 체인을 기반으로 사용한다" 라는 문구가 있었다. 즉, 개발자 입장에서는 ThreadLocal처럼 사용할 수 있는 인터페이스가 제공되는 것이다.

 

이제 해당 라이브러리와 express를 결합하여 목표한 것을 만들어 보자.

 

구현하기


사실 express에서 단순히 해당 로직을 구현하기 위해서는 변수값을 계속 하위 함수로 내려서 사용하면 되기는 한다.

 

const express = require("express");
const { v4: uuidv4 } = require("uuid");

/**
 * 1초동안 실행되는 mock 함수
 */
const mockDB = (id) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`Log with id: ${id}`);
      resolve();
    }, 1000);
  });
};

const app = express();

app.get("/", function (req, res, next) {
  const id = uuidv4();
  console.log(`Created new log with id: ${id}`);

  mockDB(id).then(() => {
    console.log("Timer done");
  });
  res.send("Hello World");
});

app.listen(4000);

위의 코드에서 1초 이내로 두번의 요청을 보내도, 각 요청마다 ID가 구분이 된다. 하지만 로직이 복잡해질수록 id를 계속 내려야 하고, 이는 (마치 react의 props를 계속 내려보내는 요청같아보이는데) 프로젝트가 커질수록 코드의 가독성을 해치고, 로직의 복잡도를 증가시킨다. 이를 해결하기 위해 logger 객체를 새로 만들고, cls-hooked를 사용해서 id를 구분할 것이다.

 

const express = require("express");
const { v4: uuidv4 } = require("uuid");
var { createNamespace } = require("cls-hooked");

const writer = createNamespace("writer");

function Logger() {}

Logger.prototype.create = () => {
  const id = uuidv4();
  const log = `Created new log with id: ${id}`;
  console.log(log);
  writer.set("id", id);
};
Logger.prototype.log = () => {
  const id = writer.get("id");
  const log = `Log with id: ${id}`;
  console.log(log);
};
const logger = new Logger();

/**
 * cls-hooked를 사용한 미들웨어
 */
const loggingMiddleware = (req, res, next) => {
  writer.bindEmitter(req);
  writer.bindEmitter(res);
  writer.run(() => {
    next();
  });
};

/**
 * 1초동안 실행되는 mock 함수
 */
const mockDB = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      logger.log();
      resolve();
    }, 1000);
  });
};

const app = express();

app.use(loggingMiddleware);

app.get("/", function (req, res, next) {
  logger.create();
  mockDB().then(() => {
    console.log("Timer done");
  });
  res.send("Hello World");
});

app.listen(4000);

기존의 코드보다 길이가 늘어나긴 했지만, 상관없다. 주의해서 볼 곳은 mockDB에 ID를 넘기지 않고서도 id를 불러온다는 것이다. 새로 logMiddleWare를 생성하여 로깅의 역할을 분리한다. 새로 만든 logMiddleWare에서 bindEmitter를 사용한다. bindEmitter는 EventEmitter를 네임스페이스에 바인딩하여 스레드(같은)를 분리한다. (express의 콜백 함수 인자인 req와 res가 EventEmitter로 간주되는 것 같다) 이후 run메서드를 호출하여 컨텍스트를 생성한 이후, 콜백 함수에서 next()를 호출하여 비즈니스 로직을 실행한다.

 

대부분 cls-hooked를 바로 사용하지 않고 각 프레임워크에 맞게 커스텀한 라이브러리를 많이 쓰는 것 같다. 

 

참고자료


https://www.npmjs.com/package/cls-hooked

 

cls-hooked

CLS using AsynWrap instead of async-listener - Node >= 4.7.0. Latest version: 4.2.2, last published: 6 years ago. Start using cls-hooked in your project by running `npm i cls-hooked`. There are 723 other projects in the npm registry using cls-hooked.

www.npmjs.com

https://kyungyeon.dev/posts/43

 

CLS에 대해 알아보자

HTTP Request ID 추적하기 in Node.js

kyungyeon.dev

 

 

반응형