Node.js 교재 · 18편 · PostgreSQL

PostgreSQL 연동 — node-postgres로 CRUD

Express 와 DB 가 만나는 지점. 5줄짜리 보안 사고를 미리 차단.

Node 가 PostgreSQL 풀에 연결되는 컨셉 일러스트

지금까지 데이터를 메모리 배열에 넣고 흉내냈다. 이제 진짜 — 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 는 두 가지 클래스를 제공한다.

구분ClientPool
연결 수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 pgnew Pool({ connectionString })pool.query(sql, [params]). 웹 서버는 무조건 Pool, Client 는 짧은 스크립트. $1 파라미터 바인딩이 SQL Injection 방지의 유일한 정답. RETURNING 으로 INSERT 결과 받기, 트랜잭션은 pool.connect() + BEGIN/COMMIT/ROLLBACK + release(). 다음 편에서 JWT 인증으로 로그인 흐름을 만든다.

다음 편 예고 — JWT 인증

로그인 흐름, 토큰 발급·검증, refresh 패턴. 19편.

© 2026 주나이테크(주) @JUNAITECH