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"
chrome에서는 같은 주소로 반복된 network 요청시 같은 내용을 download하는 것을 방지하기 위해 lock을 건다.
- Issue 345643003: Http cache: Implement a timeout for the cache lock. - Code Review
- Chrome stalls when making multiple requests to same resource?
- cache에 대한 issue를 방지하기 위해,
Disable cache
를 켠 상태로 실험을 진행했다.
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.end
를connection.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를 제공한다. 링크