NodeJS

AsyncHooks로 context에 DB connection 저장하기

iKay 2020. 6. 14. 14:56
반응형

서론

Node 13.X 버전부터 AsyncHooks라는 것이 도입되었다. AsyncHook이란, 쉽게 설명하자면, 실행 context마다 고유한 id를 줘서 callback후에도 callback 전의 context를 알 수 있게 해주는 것이다. 더 자세한 내용은 공식 문서(https://nodejs.org/api/async_hooks.html)에 설명이 잘 되어 있는 편이니 참고하기 바란다.

 

현재 14.X 버전에서도 experimental이지만, stable이 된다면 production에서도 context 당 db connection을 저장할 때 사용하면 될 것 같다.

 

아래 설명은 node 14.x 버전에서 진행한 것이다. 설명에 대한 소스코드를 참고하려면 여기를 보면 된다. https://github.com/i-kay/db-conn-per-ctx-with-async-hooks

context당 고유한 db connection을 가지면 좋은 점

다른 언어에서는 http req당 하나의 쓰레드를 생성하기 때문에 쓰레드:context = 1:1 관계라 볼 수도 있겠지만, javascript는 아니다. javascript는 nodejs 런타임에서 싱글 쓰레드(엄연히 말하면 싱글 쓰레드는 아니지만...) 이벤트 루프 기반으로 동작한다. 멀티 쓰레드를 사용하지 않고, 동시에 여러 요청을 처리하기 I/O 작업이 필요할 때 콜백을 사용한다. 때문에 다른 서버 언어에 비해 운영하는데 가벼운 편이지만, 한편으로는 쓰레드에 공유된 자원을 저장했다가 필요한 context 시점(I/O가 끝나서 콜백을 받은 시점)에 원하는 자원을 얻는 문제가 발생한다.

 

특히, db connection을 처리하기가 까다로운 편인 것 같다. 어떤 프레임워크를 사용하든 context당 db connection을 쉽게 저장하기 어렵기 때문이다. context(=ctx)당 db connection(=dbConn)을 저장하면 얻게 되는 이점은 무엇일까?

 

다음과 같이 OrderService, UserRepository, OrderRepository, NotificationRepository가 있다.  OrderService는 고객으로 부터 주문을 처리하는 메소드(placeOrder)가 있고, 나머지 Repository는 DB와 연관된 클래스이다. 

 

만약, 주문이 성공하면 OrderRepository에 주문한 내용을 저장하고 동시에 주문이 완료 되었음을 고객에게 email로 알리고 싶은 경우 아래와 같이 transaction으로 처리하는 경우를 생각해보자.

 

다음 코드의 문제점은 dbConn 파라미터이다. transaction을 처리하기 위해 동일한 dbConn을 가져야 하기 때문에 서비스에서 생성한 dbConn을 repository에 계속 넘겨 줘야 한다.

 

클라이언트로 부터 http request가 왔을 때 현재 ctx에 dbConn을 저장했다가 service, repository 등에서 필요할 때 현재 ctx에 저장된 dbConn을 가져오면 되지 않을까?

class OrderService {
    constructor(
        private orderRepository = new OrderRepository(),
        private notificationRepository = new NotificationRepository(),
        private userRepository = new UserRepository(),
    ) {}

    async placeOrder(userId, products) {

        const dbConn = createDbConnection();

        await dbConn.query('START TRANSACTION');
        try {
            const [user] = await this.userRepository.findById(dbConn, userId);
            await this.orderRepository.save(dbConn, userId, products);

            throw new Error('unexpected error');

            await this.notificationRepository.save(dbConn, 'email', user.email);
            await dbConn.query('COMMIT');
        } catch (err) {
            console.log('err', err);
            await dbConn.query('ROLLBACK');
        }
       
    }
}

class UserRepository {
    async findById(dbConn, id) {
        return dbConn.query(
            'SELECT * FROM `user` WHERE `userId` = ?',
            [id],
        )
    }
}

class OrderRepository {
    async save(dbConn, userId, products) {
        const productIds = products.map(product => product.productId).toString();
        const qtys = products.map(product => product.qty).toString();

        return dbConn.query(
            'INSERT INTO `order` (`userId`, `productIds`, `qtys`, `orderedAt`) VALUES(?, ?, ?, ?)', 
            [userId, productIds, qtys, new Date()],
        )
    }
}

class NotificationRepository {
    async save(dbConn, method, destination) {
        return dbConn.query(
            'INSERT INTO `notification` (`method`, `destination`) VALUES(?, ?)',
            [method, destination],
        )
    }
}

 

AsyncHooks로 context당 고유한 db connection을 저장하기

우선 ctx를 저장하는 ctx map을 살펴보자.

 

- createCtx: http request가 있을 때 마다 ctx를 만든다.

- getCtx: ctx를 map에서 얻는다.

 

이렇게 두 가지 함수가 필요할 것 같다.

import * as asyncHooks from 'async_hooks';
import * as Koa from 'koa';

const ctxMap = new Map();

export function createCtx(ctx) {
  ctxMap.set(asyncHooks.executionAsyncId(), ctx)
}

export function getCtx(): Koa.Context {
  return ctxMap.get(asyncHooks.executionAsyncId());
}

class AsyncCallbacks {
  init(asyncId, type, triggerAsyncId, resource) {
    if (ctxMap.has(triggerAsyncId)) {
      ctxMap.set(asyncId, ctxMap.get(triggerAsyncId));
    }  
  }

  destroy(asyncId) {
    if (ctxMap.has(asyncId)) {
      ctxMap.delete(asyncId);
    }
  }
}

export const asyncHook = asyncHooks.createHook(new AsyncCallbacks());

 

그리고 서비스, 레포지토리, 미들웨어 등을 아래와 같이 설정해 준다. 그러면 원하던대로 request ctx당 하나의 dbConn을 저장할 수 있다.

 

class OrderService {
    constructor(
        private orderRepository = new OrderRepository(),
        private notificationRepository = new NotificationRepository(),
        private userRepository = new UserRepository(),
    ) {}

    async placeOrder(userId, products) {

        await getCtx().dbConn.query('START TRANSACTION');
        try {
            const [user] = await this.userRepository.findById(userId);
            await this.orderRepository.save(userId, products);

            throw new Error('unexpected error');

            await this.notificationRepository.save('email', user.email);
            await getCtx().dbConn.query('COMMIT');
        } catch (err) {
            console.log('err', err);
            await getCtx().dbConn.query('ROLLBACK');
        }
       
    }
}

class UserRepository {
    async findById(id) {
        return getCtx().dbConn.query(
            'SELECT * FROM `user` WHERE `userId` = ?',
            [id],
        )
    }
}

class OrderRepository {
    async save(userId, products) {
        const productIds = products.map(product => product.productId).toString();
        const qtys = products.map(product => product.qty).toString();

        return getCtx().dbConn.query(
            'INSERT INTO `order` (`userId`, `productIds`, `qtys`, `orderedAt`) VALUES(?, ?, ?, ?)', 
            [userId, productIds, qtys, new Date()],
        )
    }
}

class NotificationRepository {
    async save(method, destination) {
        return getCtx().dbConn.query(
            'INSERT INTO `notification` (`method`, `destination`) VALUES(?, ?)',
            [method, destination],
        )
    }
}

app.use(bodyParser());
app.use(async (ctx, next) => {
    // context를 생성
    createCtx(ctx);    
    await next();
});
app.use(async (ctx, next) => {
    // db connection을 context에 주입
    const dbConn = createDbConnection();
    const currentCtx = getCtx();
    currentCtx.dbConn = dbConn;
    await next();
});
app.use(router.routes());
app.use(router.allowedMethods());
app.use(async (ctx, next) => {
    // db connection을 종료
    const currnetCtx = getCtx();
    await currnetCtx.dbConn.close();
    await next();
});

 

결론

AsyncHooks를 통해 request당 하나의 dbConn을 갖기 위해, http 요청시 ctx에 dbConn을 저장하는 방법을 간단히 살펴봤다. 현재 AsyncHook은 experimental 이지만, stable이 되면 실제 이런식으로 production에 적용할 수 있을 것 같다.

반응형