지금까지 데이터를 메모리 배열에 넣고 흉내냈다. 이제 진짜 — PostgreSQL. node-postgres (보통 pg 라 부름) 가 Node 진영 표준 클라이언트. 매주 3천만+ 다운로드, ORM 들이 다 이걸 기반으로 한다.
이번 편은 raw SQL 로 CRUD. ORM(Prisma·Drizzle)은 위에 얹는 추상화고, 그 밑에서 일어나는 일을 한 번은 봐야 디버깅이 빨라진다.
1. 설치와 첫 쿼리
$ npm install pg
$ npm install -D @types/pg # TS 쓸 때
가장 단순한 쿼리:
// db.js
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const { rows } = await pool.query('SELECT NOW() AS now');
console.log(rows);
// [ { now: 2026-04-25T01:23:45.678Z } ]
DATABASE_URL 은 17편 dotenv 챕터의 예시 형식 — postgres://user:pass@host:5432/dbname. 한 줄로 끝.
2. Pool vs Client — 왜 Pool 인가
pg 는 두 가지 클래스를 제공한다.
| 구분 | Client | Pool |
| 연결 수 | 1개 | 여러 개 재사용 |
| 일생 | 수동 connect/end | 자동 관리 |
| 적합한 곳 | 짧은 스크립트 | 웹 서버 (사실상 항상) |
웹 서버는 무조건 Pool. 매 요청마다 새 연결 만들면 TCP 핸드셰이크·인증으로 100ms 가까이 쓴다. Pool 은 미리 N개 연결을 만들어 두고 요청마다 빌려준 뒤 반납. 같은 일에 1ms.
3. 파라미터 바인딩 — SQL Injection 방지
가장 중요한 보안 규칙. 사용자 입력을 SQL 문자열에 직접 박지 말 것.
// ❌ 절대 금지 — SQL Injection 천국
const result = await pool.query(
`SELECT * FROM users WHERE id = '${userId}'`
);
// userId = "1' OR 1=1; --" 이면 전체 테이블 노출
// ✅ 파라미터 바인딩 — $1, $2... 위치
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
pg 가 안전하게 escape 하고 PostgreSQL 에 전달한다. 사용자가 어떤 값을 넣어도 SQL 구조가 안 깨진다.
최악의 사고 — "내부 어드민이라 안전해요" 같은 핑계로 문자열 concat. 한 줄로 회사 DB 전체 노출. Node 진영에서 SQL Injection 사고의 99% 가 이 패턴. 예외 없이 무조건 $1 바인딩.
4. CRUD 4가지 풀 셋
// CREATE
const { rows: [post] } = await pool.query(
`INSERT INTO posts (title, body, author_id)
VALUES ($1, $2, $3)
RETURNING *`,
[title, body, authorId]
);
// READ — 목록
const { rows: posts } = await pool.query(
`SELECT id, title, created_at FROM posts
WHERE author_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[authorId, perPage, (page - 1) * perPage]
);
// READ — 단일
const { rows: [post] } = await pool.query(
'SELECT * FROM posts WHERE id = $1',
[id]
);
if (!post) throw new NotFoundError('글');
// UPDATE
const { rowCount } = await pool.query(
`UPDATE posts SET title = $1, body = $2, updated_at = NOW()
WHERE id = $3 AND author_id = $4`,
[title, body, id, currentUserId]
);
if (rowCount === 0) throw new NotFoundError('글 또는 권한');
// DELETE
await pool.query('DELETE FROM posts WHERE id = $1', [id]);
두 가지 패턴 — RETURNING * 으로 INSERT/UPDATE 후 결과 받기, WHERE id = $1 AND author_id = $2 로 소유권까지 한 쿼리에서 검사. 미들웨어 없이도 보안이 단단해진다.
5. 트랜잭션 — 모두 성공 or 모두 실패
송금처럼 여러 쿼리가 묶여야 하는 경우. 한 쿼리 실패하면 다 롤백.
async function transfer(fromId, toId, amount) {
const client = await pool.connect(); // 같은 연결 유지
try {
await client.query('BEGIN');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId]
);
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release(); // 풀에 반납 (필수!)
}
}
핵심 — pool.connect() 로 같은 연결을 빌려, BEGIN·COMMIT/ROLLBACK. 끝나면 client.release() 필수. 안 하면 풀 고갈로 다음 요청이 멈춘다.
실전 — ORM 으로 가는 길 — raw pg 로 5~10 테이블을 다루다 보면 타입 안전성·마이그레이션이 그리워진다. 그 단계가 ORM 도입 시점. Drizzle(가벼움, SQL 그대로) 또는 Prisma(편리, 자동 생성) 가 양강. 이 교재는 raw pg 로 멈추지만, 회사 가면 거의 ORM 만난다.
요약 — 18편 좌표
여기까지 정리. npm i pg → new Pool({ connectionString }) → pool.query(sql, [params]). 웹 서버는 무조건 Pool, Client 는 짧은 스크립트. $1 파라미터 바인딩이 SQL Injection 방지의 유일한 정답. RETURNING 으로 INSERT 결과 받기, 트랜잭션은 pool.connect() + BEGIN/COMMIT/ROLLBACK + release(). 다음 편에서 JWT 인증으로 로그인 흐름을 만든다.
다음 편 예고 — JWT 인증
로그인 흐름, 토큰 발급·검증, refresh 패턴. 19편.