
백엔드와 데이터베이스를 연결할 때의 핵심 키워드는 DB Connection Pool입니다.
DB Connection Pool은 문제에 대한 해결 방안입니다. 해결 방안을 이해하려면 그것이 해결하려는 문제를 먼저 알아야 합니다. 그래서 이 포스팅은 연결이 어떻게 만들어지고 끊어지는지를 먼저 추적하고, 그 비용을 확인한 뒤 Connection Pool로 넘어갑니다.
사용 기술은 NestJS(백엔드)와 MySQL(데이터베이스)입니다. macOS의 tcpdump 명령어로 실제 패킷을 캡처해 눈으로 직접 확인합니다.
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import * as mysql from 'mysql2/promise';
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private connection: mysql.Connection;
async onModuleInit() {
this.connection = await mysql.createConnection({
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '<본인의_MySQL_비밀번호>',
database: 'sys',
});
}
async onModuleDestroy() {
await this.connection.end();
}
}
onModuleInit()은 서버가 켜질 때, onModuleDestroy()는 서버가 종료될 때 자동으로 실행됩니다. 이 두 시점에서 정확히 어떤 일이 일어나는지를 tcpdump로 추적합니다.
tcpdump 출력을 읽기 전에 세 가지 개념을 먼저 짚고 넘어갑니다.
하나의 컴퓨터에는 여러 프로세스가 동시에 실행됩니다. 외부에서 패킷이 도착했을 때 OS는 이 패킷을 어느 프로세스에 전달할지 알아야 합니다. 이때 사용하는 번호가 포트입니다.
이 포스팅에서 등장하는 포트는 NestJS(3000), MySQL(3306) 두 가지입니다.
MySQL이 3306을 쓰는 이유는 MySQL이 시작될 때 OS에 "3306번으로 오는 패킷을 나에게 달라"라고 등록하기 때문입니다. NestJS도 마찬가지로 3000번을 등록합니다.
포트를 알았으니 이제 소켓입니다. 소켓은 IP + 포트의 조합으로 만들어지는 통신 endpoint입니다. 그런데 소켓을 제대로 이해하려면 한 가지를 더 알아야 합니다.
유닉스 계열 OS에서 소켓은 파일입니다.
유닉스의 철학 중 하나는 "모든 것은 파일이다(Everything is a file)"입니다. 일반 텍스트 파일뿐 아니라 키보드, 화면, 프린터, 그리고 네트워크 연결까지 전부 파일로 추상화합니다. 소켓도 마찬가지입니다. MySQL과 연결을 수립하는 순간, OS는 파일 하나를 만들고 파일 디스크립터(fd)를 부여합니다.
백엔드 프로세스
├── fd 0 (stdin)
├── fd 1 (stdout)
├── fd 2 (stderr)
└── fd 5 (소켓 — MySQL과의 연결)
lsof는 현재 시스템에 열려 있는 모든 파일 목록을 보여주는 명령어입니다. -i는 네트워크 소켓 파일만 보여달라는 옵션입니다. 아래 명령어를 통해 fd가 어떻게 할당되어 있는지 직접 확인할 수 있습니다.
$ lsof -i :3306
파일 디스크립터는 OS가 "이 번호가 그 파일을 가리킨다"라고 관리하는 정수입니다. 백엔드가 MySQL에 쿼리를 보낼 때 실제로 하는 일은 이 파일에 데이터를 쓰는 것이고, MySQL이 응답을 돌려줄 때는 이 파일에서 데이터를 읽는 것입니다. 네트워크 통신이 파일 읽기와 쓰기로 추상화돼 있습니다.
이것이 핵심입니다.
mysql.createConnection()은 소켓 파일을 만들고 fd를 할당받는 과정입니다. connection.end()는 그 파일을 닫는 과정입니다. 파일을 만들고 닫는 데는 비용이 발생합니다. 단순한 함수 호출이 아니라 OS에 자원을 요청하고 반납하는 시스템 콜이기 때문입니다. Connection Pool이 연결을 "반납한다"라고 표현하는 이유도 여기에 있습니다. 파일을 닫지 않고 살려두면서 다음 요청이 같은 fd를 재사용합니다.
실제 tcpdump에서는 백엔드 소켓의 포트가
3000이 아니라65125같은 번호로 표시됩니다.3000은 외부 HTTP 요청을 받는 포트이고, MySQL에 연결을 요청할 때는 OS가 사용 가능한 포트를 임의로 배정합니다. 이를Ephemeral Port(임시 포트)라고 합니다.

백엔드와 MySQL은 TCP로 통신합니다. TCP는 데이터를 주고받기 전에 반드시 두 소켓 사이의 연결을 먼저 수립합니다. 이 과정이 3 Way Handshake입니다. 목적은 하나입니다. 양방향 통신이 가능한지 확인하는 것입니다. "내가 너한테 보낼 수 있고, 너도 나한테 보낼 수 있다"를 양쪽이 모두 확인한 뒤에야 연결이 수립됩니다.
$ sudo tcpdump -i lo0 port 3306 -S -nn
내부 프로세스 간 통신 전용 가상 인터페이스인 루프백(-i lo0)을 지정하여, 동일한 서버 내에서 백엔드와 MySQL 데이터베이스가 3306 포트를 통해 주고받는 로컬 네트워크 패킷을 실시간으로 캡처하는 명령입니다. Sequence Number를 상대값이 아닌 날것 그대로의 절대값으로 표시하는 -S 옵션과, IP 주소와 포트 번호를 순수 숫자로만 강제 출력하는 -nn 옵션을 조합했습니다.
17:20:10.727811 IP 127.0.0.1.65125 > 127.0.0.1.3306: Flags [S], seq 2563091621, win 65535, length 0
onModuleInit()이 실행되는 순간 백엔드(임시 포트 65125)가 MySQL(포트 3306)에 첫 패킷을 보냅니다.
Flags [S]는 SYN 패킷입니다. "연결하고 싶다"라는 요청입니다. seq 2563091621은 초기 시퀀스 번호(ISN)입니다. TCP는 데이터를 패킷으로 쪼개서 보내기 때문에, 수신 측이 패킷들을 올바른 순서로 재조립하려면 "몇 번부터 시작할 것인지"를 상대방에게 알려야 합니다. OS가 무작위로 생성한 값입니다.
17:20:10.727885 IP 127.0.0.1.3306 > 127.0.0.1.65125: Flags [S.], seq 3097717851, ack 2563091622, win 65535, length 0
MySQL이 두 가지를 동시에 처리합니다.
ack 2563091622 — 백엔드의 ISN(2563091621)에 1을 더한 값입니다. TCP에서 ACK는 "다음에 받고 싶은 번호"를 의미합니다. "2563091621 잘 받았다, 다음엔 2563091622번부터 줘"라는 뜻입니다. 백엔드 → MySQL 방향의 통신이 가능함이 확인됩니다.seq 3097717851 — MySQL 자신의 ISN입니다. "나는 이 번호부터 데이터를 보낼 것이다"라고 역방향 연결을 요청합니다.17:20:10.727899 IP 127.0.0.1.65125 > 127.0.0.1.3306: Flags [.], ack 3097717852, win 6379, length 0
백엔드가 MySQL의 ISN(3097717851)에 1을 더한 ack 3097717852를 돌려보냅니다. MySQL → 백엔드 방향의 통신이 가능함이 확인됩니다.
이 시점에서 양방향 통신이 모두 확인됐습니다. 두 소켓 파일이 연결됩니다.
| 단계 | 방향 | 확인된 것 |
|---|---|---|
| Step 1 | 백엔드 → MySQL | 백엔드가 보낼 수 있다 |
| Step 2 | MySQL → 백엔드 | MySQL이 받을 수 있다 + MySQL이 보낼 수 있다 |
| Step 3 | 백엔드 → MySQL | 백엔드가 받을 수 있다 |
TCP 연결이 수립됐다고 곧바로 쿼리를 실행할 수 있는 것은 아닙니다. TCP는 "두 소켓 파일이 연결됐다"는 것만 보장합니다. 이 연결을 통해 접속한 사람이 누구인지, 권한이 있는지는 MySQL이 별도로 확인해야 합니다. 이것이 MySQL 애플리케이션 레이어의 인증 과정입니다.
17:20:10.728960 ... length 77 ← MySQL이 서버 버전·인증방식·nonce 전송
17:20:10.728992 ... length 0 ← 백엔드 ACK
17:20:10.737407 ... length 150 ← 백엔드가 user/password(해시)/database 전송
17:20:10.737449 ... length 0 ← MySQL ACK
17:20:10.737643 ... length 6 ← MySQL OK Packet (인증 성공)
17:20:10.737659 ... length 0 ← 백엔드 ACK
17:20:10.737875 ... length 19 ← 세션 변수 초기화
17:20:10.737891 ... length 0 ← 백엔드 ACK → createConnection() resolve
createConnection()에 넘긴 user, password, database 값이 이 과정에서 MySQL로 전달됩니다. 비밀번호는 MySQL이 보낸 nonce로 해시 된 형태로 전송됩니다.
인증이 완료되는 17:20:10.737891에 createConnection()의 Promise가 resolve 됩니다. 소켓 파일은 열린 채로 대기합니다.

서버가 종료되면 onModuleDestroy()가 호출되고 connection.end()가 실행됩니다. 소켓 파일을 닫는 과정이 시작됩니다.
TCP 연결은 양방향입니다. 백엔드 → MySQL 방향과 MySQL → 백엔드 방향, 두 채널이 독립적으로 존재합니다. 연결 해제도 각 방향을 따로 닫아야 합니다. 3 Way Handshake에서는 SYN과 ACK를 하나의 패킷에 합칠 수 있었습니다. 해제 때는 그렇지 않습니다. 한쪽이 FIN을 보내도 상대방은 아직 보낼 데이터가 남아있을 수 있기 때문에, FIN에 대한 ACK와 자신의 FIN이 분리됩니다. 결과적으로 총 4번의 패킷이 필요합니다.
17:20:15.831748 IP 127.0.0.1.65125 > 127.0.0.1.3306: Flags [F.], seq 2563091772, ...
connection.end()를 호출한 백엔드가 먼저 FIN을 보냅니다. "나는 더 이상 이 소켓 파일에 쓸 데이터가 없다"라는 선언입니다. 먼저 FIN을 보내는 쪽을 능동적 종료자(Active Closer)라고 합니다.
17:20:15.831808 IP 127.0.0.1.3306 > 127.0.0.1.65125: Flags [.], ack 2563091773, ...
MySQL이 백엔드의 FIN을 확인합니다. 이 시점에서 백엔드 → MySQL 방향의 채널이 닫힙니다. MySQL → 백엔드 방향은 아직 열려 있습니다.
교과서적인 4 Way Handshake라면 MySQL이 자신의 FIN을 보내고, 백엔드가 마지막 ACK를 돌려보내야 합니다. 그런데 실제 tcpdump는 다르게 전개됩니다.
17:20:15.831956 ... Flags [P.], length 55 ← MySQL이 데이터를 보냄
17:20:15.831999 ... Flags [R], seq 2563091773, win 0, length 0 ← 백엔드 RST
connection.end()는 내부적으로 MySQL에게 COM_QUIT 명령을 전송합니다. 이에 대해 MySQL은 FIN 대신 55바이트 길이의 OK 패킷으로 응답을 돌려보냅니다. COM_QUIT에 대한 응답이므로 정상적인 흐름입니다.
문제는 타이밍입니다. 백엔드는 이미 Step 1에서 FIN을 선언했고, "이 파일에 더 이상 쓰지 않겠다"라고 선언한 상태입니다. 그런데 이미 반쯤 닫힌 소켓으로 MySQL의 OK 패킷이 들어옵니다. OS의 네트워크 스택은 "닫기로 선언한 쪽에 데이터가 도착했다"는 상황을 비정상으로 간주하고, 자동으로 RST(Reset) 패킷을 발송해 소켓 파일을 강제로 닫습니다.
RST는 오류처럼 들리지만 이 맥락에서는 정상 종료입니다. COM_QUIT → OK 패킷 → RST의 흐름에서 양쪽의 소켓 파일이 모두 닫히고, 연결에 할당됐던 OS 자원도 문제 없이 반환됩니다. 교과서적인 FIN → ACK → FIN → ACK 순서와 절차는 다르지만, "소켓 파일이 닫히고 리소스가 회수된다"는 결과는 동일합니다.
하나의 DB 연결을 수립하는 데 TCP 3 Way Handshake와 MySQL 인증 왕복이 필요합니다. 위의 tcpdump 기준으로 17:20:10.727811(SYN)부터 17:20:10.737891(인증 완료)까지 약 10ms가 걸렸습니다.
이 10ms는 루프백(127.0.0.1) 환경이라 물리 네트워크를 거치지 않아 오히려 빠른 편입니다. 동일한 서버 내부 통신인데도 10ms가 나온다는 것은, 지연의 원인이 네트워크 전송 자체가 아니라 OS에 파일 생성을 요청하고, 상대방과 협상하고, 인증을 마치는 데 드는 총 비용이라는 뜻입니다. 실제 분리된 DB 서버를 사용하는 프로덕션 환경이라면 이 값은 수십 ms로 늘어납니다.
API 요청마다 createConnection()과 connection.end()를 반복한다면, 즉 매 요청마다 소켓 파일을 만들고 닫는다면 이렇게 됩니다.
요청 1: 소켓 생성 + 연결 수립(10ms) → 쿼리(1ms) → 소켓 닫기
요청 2: 소켓 생성 + 연결 수립(10ms) → 쿼리(1ms) → 소켓 닫기
요청 3: 소켓 생성 + 연결 수립(10ms) → 쿼리(1ms) → 소켓 닫기
쿼리 실행보다 소켓을 만들고 닫는 데 10배 이상의 시간이 소비됩니다. 동시 요청이 늘어날수록 이 비용은 선형으로 증가합니다.
Connection Pool은 소켓 파일을 미리 열어두고 재사용합니다.
서버가 시작될 때 지정한 수만큼 소켓을 미리 열고 연결을 수립합니다. 요청이 들어오면 풀에서 연결을 꺼내 쿼리를 실행하고, 소켓 파일을 닫지 않고 다시 풀에 반납합니다. TCP Handshake와 MySQL 인증은 서버 시작 시 단 한 번만 발생합니다. 이후 모든 요청은 이미 열려있는 소켓 파일을 읽고 쓸 뿐입니다.
const pool = mysql.createPool({
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '<본인의_MySQL_비밀번호>',
database: 'sys',
connectionLimit: 10, // 미리 열어둘 소켓(연결)의 최대 수
});
// 소켓 파일을 닫지 않고 자동으로 풀에 반납됩니다
const [rows] = await pool.query('SELECT * FROM user WHERE id = ?', [userId]);
connectionLimit: 10은 예시 값입니다. 이 숫자를 너무 작게 잡으면 연결이 모두 사용 중일 때 후속 요청이 대기 상태에 빠지고, 너무 크게 잡으면 MySQL 쪽에서 최대 연결 수(max_connections) 제한에 걸립니다. 적정값은 서버의 CPU 코어 수, 쿼리 평균 실행 시간, 동시 요청 수를 측정해 결정합니다. 설정 기준과 MySQL의 max_connections와의 관계는 다음 포스팅에서 다룹니다.
tcpdump 관점에서 차이를 보면, createConnection을 반복할 때는 매번 Flags [S] 패킷이 나타납니다. createPool을 쓰면 서버 시작 시 connectionLimit만큼의 Flags [S]가 한 번 나타나고, 이후 수백 번의 API 요청에도 Flags [P.](데이터 패킷)만 보입니다. 소켓 파일이 계속 열려있기 때문입니다.
다음 포스팅에서는 Connection Pool을 실제로 운용할 때 백엔드와 MySQL 각각에서 어떤 설정이 필요한지, 그 설정들이 어떻게 맞물리는지 살펴봅니다. 그 위에서 ORM이라는 추상화가 어떻게 동작하는지도 이어서 소개하겠습니다.
마지막으로, 이번 포스팅을 위해 tcpdump나 lsof 같은 OS 명령어로 실제 데이터를 추적하면서 리눅스 환경과 CLI 명령어를 능숙하게 다루는 것이 얼마나 중요한지 다시금 깨달았습니다.
OverTheWire의 Bandit은 교육용 해킹 워게임(Wargame) 플랫폼입니다. 다음 레벨로 넘어가기 위해서는 현재 레벨에서 요구하는 비밀번호나 플래그를 찾아내야 합니다. 재미있게 리눅스 명령어를 익힐 수 있어 소개 드립니다.