JavaScriptMySQL

JavaScript+MySQL은 Async+Block 하게 작동할까

2022.09.28


Node.JS에서 MySQL을 사용하면, async하게 작동하는 Node.JS에서 block 되는 상황이 발생해서 그 궁합이 좋지 않다는 이야기를 듣고, 어떤면에서 그런 것일까 직접 확인해보기 위해 테스트를 진행해보았다.

언어 사용에서의 헷갈림을 해결하기

동기/비동기 와 블로킹/논블로킹의 핵심이 되는 개념은 무엇일까? 여러 글을 참고하다보니 다음과 같이 정리됐다.

  • 동기/비동기 : 프로세스의 수행 순서 보장에 대한 메커니즘

    → 수행 순서가 보장 되는가? (동기) <-> 비동기

  • 블로킹/논블로킹 : 프로세스의 유휴 상태에 대한 개념

    → 프로세스가 앞으로 해야할 작업을 하지 못하고 있는가? (블로킹) <-> 논블로킹

즉, 동기 방식은, 작업의 순차적인 흐름만 지켜진다면, 블로킹이든, 논블로킹이든 상관이 없다. 비동기방식도 작업의 순차적인 진행이 보장되지 않을 뿐, 블로킹이든, 논블로킹이든 상관 없다.

Test 프로젝트 구조

├─ index.js
├─ mysql.js
├─ package-lock.json
└─ package.json

전체 코드

  • index.js
const express = require("express");
const app = express();

const { connection, pool, poolPromise } = require("./mysql");

app.use(express.json());

app.get("/", (req, res) => {
  res.end("hi");
});

// Test 1
app.get("/sleep", (req, res) => {
  const { idx } = req.query;
  connection.query("DO SLEEP(5);", () => {
    logTimeWithMsg(`/sleep?idx=${idx} - after query`);
  });
  const date = logTimeWithMsg(`/sleep?idx=${idx} - main logic`);
  res.end(`${date} - ${idx} wake up!`);
});

// Test 2
app.get("/sleep_pool", (req, res) => {
  const { idx } = req.query;
  pool.getConnection((err, conn) => {
      if (err) {
        logTimeWithMsg(err.message);
        return;
      }
      conn.query("DO SLEEP(5);", () => {
        logTimeWithMsg(`/sleep_pool?idx=${idx} - after query`);
        conn.release();
      });
  });
  const date = logTimeWithMsg(`/sleep_pool?idx=${idx} - main logic`);
  res.end(`${date} - ${idx} wake pool!!`);
});

// Test 3
app.get("/sleep_pool_promise", async (req, res) => {
  const { idx } = req.query;
  logTimeWithMsg(`/sleep_pool_promise?idx=${idx} - main logic`);
  const _ = await poolPromise.query("DO SLEEP(5);");
  const date = logTimeWithMsg(`/sleep_pool_promise?idx=${idx} - after query`);
  res.end(`${date} - ${idx} wake pool promise!!!`);
});

app.listen(3000);

function logTimeWithMsg(msg = "") {
  const date = new Date().toLocaleString().split(",")[1].trim();
  console.log(date, msg);
  return date;
}

  • mysql.js
// mysql.js
const mysql = require("mysql2");

const option = {
  host: "localhost",
  user: "someuser",
  password: "somepassword",
  database: "somedatabase",
};

// Test 1
const connection = mysql.createConnection(option);

// Test 2
const pool = mysql.createPool({
  ...option,
  connectionLimit: 5, // default: 10
});

// Test 3
const poolPromise = mysql
  .createPool({
      ...option,
      connectionLimit: 5,
  })
  .promise();

module.exports = { connection, pool, poolPromise };

실행 환경

  • MacBook Pro (13-inch, 2017, Two Thunderbolt 3 ports)

    • macOS Monterey version 12.4 (21F79)
  • 사용한 Browser : chrome Version 105.0.5195.125 (Official Build) (x86_64)

  • Node.JS v16.13.1

  • dependencies

"express": "^4.18.1",
"mysql2": "^2.3.3"

Test 전체 요약

서버로 요청이 들어올 때마다, db에서는 DO SLEEP(5); 쿼리를 실행한다. 이에 따라 서버가 block 되는지 확인하고, Javascript는 비동기적으로 작동하고 있음을 확인한다.


Test 1. Single Connection

  • 서버에서는 single connection을 사용하여 /sleep 으로 요청이 들어올 때마다, db에 DO SLEEP(5); 쿼리를 실행시킨다.

  • 브라우저에서 /sleep 으로 반복된 요청을 보낸 후의 결과를 관찰해본다.

// browser tab 1's - console
Array(5)
  .fill()
  .forEach(() =>
  fetch("/sleep")
    .then((res) => res.text())
    .then(console.log)
  );

  • 서버 코드 요약
// index.js
const { connection } = require("./mysql");
app.get("/sleep", (req, res) => {
  connection.query("DO SLEEP(5);", () => {
    logTimeWithMsg("/sleep - after query"); // (3)
  });
  logTimeWithMsg("/sleep - main logic");    // (1)
  res.end("wake up!");                      // (2)
});

// mysql.js
const connection = mysql.createConnection(option);

- Browser

- Server

(1), (2) 는 call stack에 쌓인 후, 바로 log를 뱉으며 pop 된다.

connection.query 에 의해, db에서는 5초간 sleep 하게 되고, 이후 callback이 task queue에 들어가며 eventloop에 의해 call stack으로 호출된다.

  • 여기서 서버에 찍힌 시간을 보면, main logic 이 요청 받은대로 log를 찍었고, after query 에 해당하는 부분은 5초 간격으로 찍히는 것을 볼 수 있다. (5개의 request가 14초에 들어왔고, 19초, 24초, 29초, 34초, 39초에 각각 response를 전달한다.)
  • 즉, 단일 커넥션 상황에서 mysql은 들어온 요청을 synchronous 하게 처리하는 것을 확인할 수 있다.
  • 그리고, mysql query가 실행되고 있는 thread는 block 된 상태여서, sleep 하는 동안 다른 작업을 할 수 없다.

  • 위 예시를 살짝 바꿔보면, block되는 상황을 더 잘 확인해 볼 수 있다. res.endconnection.query 의 callback 내부로 집어넣어보는 것이다.
// index.js
const { connection } = require("./mysql");
app.get("/sleep", (req, res) => {
  connection.query("DO SLEEP(5);", () => {
  logTimeWithMsg("/sleep - after query"); // (2)
    res.end("wake up!");                    // (3)
  });
  logTimeWithMsg("/sleep - main logic");    // (1)
});
  • 서버로 5개의 요청이 동시에 들어온다면, (1)에 의해 서버에 로그는 거의 동시에 찍히겠지만, 클라이언트가 response를 받는 시간은 5초 단위로 시간이 증가할 것이다.
  • 만약 connection.query 의 callback 부분에 중요한 비즈니스 로직이 있다면, 먼저 실행되고 있는 쿼리가 결과를 뱉기 전까지는 그 중요한 로직이 블록되는 상황이 생기게 되는 것이다.

Test 2. Connection pool

그렇다면, 여러개의 connection pool을 사용하면 이 문제를 해결할 수 있는 거 아닐까?

connectionLimit을 5개로 부여해보고, 10개의 request를 날려본다.


- 서버 코드 요약

// index.js
const { pool } = require("./mysql");
// 실험 2
app.get("/sleep_pool", (req, res) => {
  const { idx } = req.query;
  pool.getConnection((err, conn) => {
  if (err) {
    logTimeWithMsg(err.message);
    return;
  }
  conn.query("DO SLEEP(5);", () => {
    logTimeWithMsg(`/sleep_pool?idx=${idx} - after query`);
    conn.release();
  });
  });
  logTimeWithMsg(`/sleep_pool?idx=${idx} - main logic`);
  res.end(`${idx} wake pool!!`);
});

// mysql.js
const pool = mysql.createPool({
  ...option,
  connectionLimit: 5, // default: 10
});

- Browser

- Server

connection pool을 만든 갯수 만큼의 요청을 async 하게 처리할 수 있지만, 여전히 나머지 5개의 요청에 대해서는 block 되어있다.

connection pool을 충분히 많이 만든다면 block되는 상황을 막을 수 있겠으나, connection을 무한히 만들 수 없다.

어쨌든, MySQL에서 query하는 동안, callback의 실행이 block 되고 있음을 확인할 수 있다.


Test 3. Connection pool with async / await

  • 서버 코드 요약
// index.js
const { poolPromise } = require("./mysql");
app.get("/sleep_pool_promise", async (req, res) => {
  const { idx } = req.query;
  logTimeWithMsg(`/sleep_pool_promise?idx=${idx} - main logic`);
  const _ = await poolPromise.query("DO SLEEP(5);");
  const date = logTimeWithMsg(`/sleep_pool_promise?idx=${idx} - after query`);
  res.end(`${date} - ${idx} wake pool promise!!!`);
});

// mysql.js
const poolPromise = mysql
  .createPool({
  ...option,
  connectionLimit: 5,
})
.promise();

express 서버가 사용자의 요청이 들어오는대로 처리하지 못하고, connection limit이 넘어가는 순간 block 되는 상황이 발생한다.

- Browser

- Server

1.서버 로그 중, idx=5번 요청을 살펴보면(초록색상자) 34초에 요청을 받았으나, response가 찍히는 시간은 44초이다. 2.서버 로그 중, idx=6번 요청을 살펴보면(하늘색상자) 요청 자체가 39초에 들어온다.

Test 3을 통해서, mysql이 express 서버를 block 하는 것을 확실하게 확인해 볼 수 있는 것 같다.

async가 선언 된 문맥 내부의 await 부분에서 db query가 일어나기 때문에, connectionLimit 이상으로 들어오는 요청에 대해서는 미처 처리하지 못하는 모습이다.

Node.JS를 효과적으로 사용하기 위해서는

  • Test들을 통해서 확인했듯, Node.JS는 싱글 스레드 기반으로 작동하지만, event loop, task queue를 활용하여 asynchronous하고(함수들의 실행 순서를 보장하지 않음), non-blocking하게(함수 간의 실행을 막지 않도록) 작동하게끔 할 수 있다.
  • 내가 코드를 짜면서 의도한 바가 async, non-block 이지만, 특정 서비스에 의해 로직이 block 되는 상황에 주의할 필요가 있겠다.
  • 따라서, Node.JS 환경에서 MySQL을 사용할 때는 적절한 양의 connection pool을 생성해 놓는 것이 block되는 상황을 피할 수 있는 방법일 것이다.
  • 혹은, MySQL이 아닌, 다른 DB를 사용하는 것을 고려해볼 수 있겠다. ex) MariaDB는 non-blocking client API를 제공한다. 링크