Node.js 데이터베이스 연동 | MongoDB, PostgreSQL, MySQL

이 글의 핵심

요청마다 새 DB 연결을 여는 코드는 트래픽이 조금만 늘어도 연결 수 제한에 걸립니다. 문서형과 관계형 데이터베이스를 고르는 기준을 먼저 제시하고, ORM과 Raw Query의 트레이드오프, 관계 조회에서 생기는 N+1 문제, 커넥션 풀 크기와 모니터링을 짚어 운영에서 버티는 데이터 계층을 만들게 합니다.

들어가며

데이터베이스 종류

Node에서 DB에 붙을 때는 연결 한 번에 쿼리 하나가 아니라, 보통 풀(pool)에 연결을 재사용하며, 드라이버·ORM이 비동기 API로 결과를 Promise로 돌려줍니다. Express 라우트 안에서는 await User.find(...)처럼 쓰되, 연결 끊김·재시도·트랜잭션은 설정과 코드 패턴으로 다루는 것이 일반적입니다.

SQL (관계형):

  • PostgreSQL: 강력한 기능, 표준 SQL
  • MySQL: 빠른 속도, 널리 사용
  • SQLite: 파일 기반, 간단한 프로젝트

NoSQL (비관계형):

  • MongoDB: 문서 기반, 유연한 스키마
  • Redis: 인메모리, 캐싱
  • Cassandra: 분산 데이터베이스

선택 가이드

데이터베이스장점사용 사례
PostgreSQL강력한 기능, ACID복잡한 쿼리, 금융
MySQL빠름, 안정적웹 앱, CMS
MongoDB유연한 스키마프로토타입, 실시간
Redis매우 빠름캐싱, 세션

표는 출발점일 뿐이고, 실제 선택은 데이터 사이의 관계가 얼마나 촘촘한가로 갈리는 경우가 많습니다. 주문·결제·재고처럼 여러 테이블이 서로를 참조하고 한 번의 작업에서 여러 행을 함께 바꿔야 한다면, 외래 키와 트랜잭션을 기본으로 제공하는 관계형 DB가 버그를 막아 줍니다. 반대로 게시글 하나와 그 댓글, 설정 묶음처럼 “한 번에 통째로 읽고 쓰는 덩어리”가 중심이라면 문서형 DB가 자연스럽습니다. “MongoDB는 스키마가 없어 개발이 빠르다”는 말도 반만 맞습니다. DB가 스키마를 강제하지 않을 뿐, 애플리케이션 코드는 여전히 특정 모양의 데이터를 기대하므로 Mongoose 스키마처럼 어딘가에서 구조를 정의하고 지켜야 합니다. 그 책임이 DB에서 코드로 옮겨 갈 뿐입니다.

스택과 연결하기: PostgreSQL·MySQL 실습은 아래 각 절에서 이어지며, C++에서는 libpq·연결 풀로 Node 드라이버·ORM과 같은 문제(풀링, 파라미터 바인딩, 트랜잭션)를 다룹니다. ORM과 Raw Query 트레이드오프는 본문 ORM vs Raw Query와 PostgreSQL vs MySQL 선택을 함께 보세요. 캐시 계층은 Redis 캐싱 패턴으로, Docker Compose로 API·DB·Redis를 묶고 Nginx·Kubernetes(minikube)로 배포를 이어가면 됩니다. 서버 디스크·inode 이슈는 Linux 디스크/inode 트러블슈팅과 맞물립니다.


MongoDB와 Mongoose

설치

# MongoDB 드라이버
npm install mongodb
# Mongoose (ODM)
npm install mongoose

연결

// db.js
const mongoose = require('mongoose');
const connectDB = async () => {
    try {
        // Mongoose 6+에서는 useNewUrlParser/useUnifiedTopology 옵션이 필요 없음(기본 동작)
        // localhost 대신 127.0.0.1: Node 17+에서 localhost가 IPv6(::1)로 먼저 해석되는 문제 회피
        await mongoose.connect('mongodb://127.0.0.1:27017/mydb');
        
        console.log('MongoDB 연결 성공');
    } catch (err) {
        console.error('MongoDB 연결 실패:', err.message);
        process.exit(1);
    }
};
module.exports = connectDB;

예전 글이나 튜토리얼에서 흔히 보이는 useNewUrlParser: true, useUnifiedTopology: true는 MongoDB 드라이버 3.x 시절의 옵션입니다. Mongoose 6부터는 이 동작이 기본값이 되었고, 최신 드라이버에서는 이 옵션을 넘기면 useNewUrlParser is a deprecated option 같은 경고만 출력됩니다. 연결이 안 될 때 가장 흔한 원인은 오히려 주소입니다. Node.js 17부터 localhost가 IPv6 주소 ::1로 먼저 해석되는데, MongoDB는 기본 설정에서 127.0.0.1에만 바인딩되므로 MongooseServerSelectionError: connect ECONNREFUSED ::1:27017이 납니다. 서버가 분명히 켜져 있는데 이 오류가 보인다면 주소를 127.0.0.1로 바꿔 보세요.

mongoose.connect()는 연결 전에 실행된 쿼리를 버퍼에 쌓아 두었다가 연결되면 실행합니다. 편리하지만 연결이 끝내 실패하면 첫 쿼리가 10초 뒤에 Operation users.find() buffering timed out after 10000ms로 실패하므로, 진짜 원인(연결 실패)과 증상(쿼리 타임아웃)이 떨어져 보입니다. 위 코드처럼 서버 시작 전에 await connectDB()로 연결을 먼저 확인하는 이유가 이것입니다.

스키마 정의

// models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
    name: {
        type: String,
        required: [true, '이름이 필요합니다'],
        trim: true,
        minlength: 2,
        maxlength: 50
    },
    email: {
        type: String,
        required: true,
        unique: true,
        lowercase: true,
        match: [/^\S+@\S+\.\S+$/, '유효한 이메일이 아닙니다']
    },
    age: {
        type: Number,
        min: 0,
        max: 150
    },
    role: {
        type: String,
        enum: ['user', 'admin'],
        default: 'user'
    },
    isActive: {
        type: Boolean,
        default: true
    },
    createdAt: {
        type: Date,
        default: Date.now
    }
});
// 인덱스 (email은 위에서 unique: true로 이미 인덱스가 생성되므로 여기서 다시 선언하지 않음)
userSchema.index({ role: 1, createdAt: -1 });
// 가상 필드
userSchema.virtual('info').get(function() {
    return `${this.name} (${this.email})`;
});
// 인스턴스 메서드
userSchema.methods.greet = function() {
    return `안녕하세요, ${this.name}님!`;
};
// 정적 메서드
userSchema.statics.findByEmail = function(email) {
    return this.findOne({ email });
};
// 미들웨어 (pre hook)
userSchema.pre('save', function(next) {
    console.log('저장 전:', this.name);
    next();
});
// 미들웨어 (post hook)
userSchema.post('save', function(doc) {
    console.log('저장 후:', doc.name);
});
module.exports = mongoose.model('User', userSchema);

스키마에서 가장 오해가 많은 옵션은 unique: true입니다. 이것은 Mongoose의 검증(validator)이 아니라 MongoDB에 유니크 인덱스를 만들라는 지시입니다. 그래서 중복 이메일을 저장하면 ValidationError가 아니라 DB에서 E11000 duplicate key error collection: mydb.users index: email_1 dup key 오류(코드 11000)가 올라오고, 에러 처리도 err.code === 11000으로 따로 해야 합니다. 또 인덱스는 앱이 시작될 때 백그라운드로 만들어지므로, 이미 중복 데이터가 들어 있는 컬렉션에서는 인덱스 생성이 조용히 실패하고 이후에도 중복이 계속 저장될 수 있습니다. 같은 필드에 unique: true와 schema.index({ email: 1 })을 함께 선언하면 Duplicate schema index on {"email":1} found 경고가 나므로 한쪽만 씁니다. 운영 환경에서는 autoIndex: false로 앱 시작 시 인덱스 생성을 끄고, 인덱스는 배포 절차에서 명시적으로 만드는 경우가 많습니다. 큰 컬렉션에 인덱스를 만드는 작업은 부하가 크기 때문입니다.

CRUD 작업

const User = require('./models/User');
// CREATE
async function createUser() {
    const user = new User({
        name: '홍길동',
        email: '[email protected]',
        age: 25
    });
    
    await user.save();
    console.log('사용자 생성:', user);
    
    // 또는
    const user2 = await User.create({
        name: '김철수',
        email: '[email protected]',
        age: 30
    });
}
// READ
async function readUsers() {
    // 모두 조회
    const users = await User.find();
    
    // 조건 조회
    const adults = await User.find({ age: { $gte: 18 } });
    
    // 하나만 조회
    const user = await User.findOne({ email: '[email protected]' });
    
    // ID로 조회
    const userById = await User.findById('507f1f77bcf86cd799439011');
    
    // 필드 선택
    const names = await User.find().select('name email -_id');
    
    // 정렬
    const sorted = await User.find().sort({ age: -1 });  // 내림차순
    
    // 페이지네이션
    const page = 1;
    const limit = 10;
    const paginated = await User.find()
        .skip((page - 1) * limit)
        .limit(limit);
    
    return users;
}
// UPDATE
async function updateUser(id) {
    // 방법 1: findByIdAndUpdate
    const user = await User.findByIdAndUpdate(
        id,
        { age: 26 },
        { new: true, runValidators: true }
    );
    
    // 방법 2: save
    const user2 = await User.findById(id);
    user2.age = 26;
    await user2.save();
    
    // 여러 개 업데이트
    await User.updateMany(
        { age: { $lt: 18 } },
        { isActive: false }
    );
}
// DELETE
async function deleteUser(id) {
    // ID로 삭제
    await User.findByIdAndDelete(id);
    
    // 조건으로 삭제
    await User.deleteOne({ email: '[email protected]' });
    
    // 여러 개 삭제
    await User.deleteMany({ isActive: false });
}

관계 (Relationship)

// models/Post.js
const postSchema = new mongoose.Schema({
    title: { type: String, required: true },
    content: { type: String, required: true },
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',  // User 모델 참조
        required: true
    },
    tags: [String],
    createdAt: { type: Date, default: Date.now }
});
const Post = mongoose.model('Post', postSchema);
// 포스트 생성
const post = await Post.create({
    title: '첫 글',
    content: '내용',
    author: userId  // User의 ObjectId
});
// Populate (조인)
const posts = await Post.find().populate('author');
// author 필드에 User 객체가 채워짐
// 선택적 populate
const posts2 = await Post.find().populate('author', 'name email');
// author에서 name과 email만 가져옴

populate는 SQL의 JOIN처럼 보이지만 실제로는 쿼리를 한 번 더 보내는 것입니다. Post.find()로 글을 가져온 뒤, 글들의 author ID를 모아 User.find({ _id: { $in: [...] } })를 따로 실행하고 결과를 자바스크립트에서 끼워 넣습니다. 그래서 N+1은 피하지만 DB 쪽에서 조인 조건으로 필터링하거나 정렬할 수는 없습니다. “작성자 이름으로 정렬된 글 목록”처럼 참조된 문서의 필드로 조건을 걸어야 한다면 $lookup 집계 파이프라인을 쓰거나, 자주 함께 읽는 필드(작성자 이름)를 글 문서에 복사해 두는 비정규화를 검토해야 합니다. 관계가 많고 이런 조회가 잦다면 그 자체가 관계형 DB가 더 맞는다는 신호일 수 있습니다.


PostgreSQL: pg와 Sequelize

설치

# PostgreSQL 드라이버
npm install pg
# Sequelize (ORM)
npm install sequelize

Raw Query (pg)

// db.js
const { Pool } = require('pg');
const pool = new Pool({
    host: 'localhost',
    port: 5432,
    database: 'mydb',
    user: 'postgres',
    password: 'password',
    max: 20,  // 최대 연결 수
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000
});
module.exports = pool;
// queries.js
const pool = require('./db');
// SELECT
async function getUsers() {
    const result = await pool.query('SELECT * FROM users');
    return result.rows;
}
// INSERT
async function createUser(name, email) {
    const query = 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *';
    const values = [name, email];
    
    const result = await pool.query(query, values);
    return result.rows[0];
}
// UPDATE
async function updateUser(id, name) {
    const query = 'UPDATE users SET name = $1 WHERE id = $2 RETURNING *';
    const result = await pool.query(query, [name, id]);
    return result.rows[0];
}
// DELETE
async function deleteUser(id) {
    const query = 'DELETE FROM users WHERE id = $1';
    await pool.query(query, [id]);
}
// 트랜잭션
async function transferMoney(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');
        console.log('이체 성공');
    } catch (err) {
        await client.query('ROLLBACK');
        console.error('이체 실패:', err.message);
        throw err;
    } finally {
        client.release();
    }
}

pg에서 트랜잭션을 쓸 때 가장 흔한 실수는 pool.query('BEGIN'), pool.query('UPDATE ...')처럼 풀에 직접 쿼리를 보내는 것입니다. pool.query()는 호출할 때마다 풀에서 아무 연결이나 빌려 쓰고 돌려주므로, BEGIN과 UPDATE와 COMMIT이 서로 다른 연결에서 실행되어 트랜잭션이 전혀 성립하지 않습니다. 에러도 나지 않아서 발견이 늦습니다. 위 코드처럼 pool.connect()로 연결 하나를 빌려 그 연결로만 쿼리를 보내고, finally에서 반드시 release()해야 합니다.

pg의 풀이 가득 차 있으면 새 요청은 빈 연결이 생길 때까지 기다리다가 connectionTimeoutMillis(위 설정은 2초)가 지나면 Error: timeout exceeded when trying to connect로 실패합니다. 이 오류가 보이면 DB가 느린 것인지, 어딘가에서 release()하지 않아 연결이 새고 있는 것인지를 먼저 구분해야 하는데, 뒤의 “모니터링” 절에서 다루는 pool.waitingCount와 pool.totalCount를 찍어 보면 대개 바로 드러납니다. 또 COUNT(*)나 BIGINT 컬럼은 자바스크립트 숫자 범위를 넘을 수 있어서 pg가 기본적으로 문자열로 돌려줍니다. total이 "42"로 와서 API 응답의 타입이 달라지는 일이 흔하므로, SELECT COUNT(*)::int AS total처럼 SQL에서 변환하거나 Number()로 바꿔서 씁니다.

Sequelize (ORM)

// db.js
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize('mydb', 'postgres', 'password', {
    host: 'localhost',
    dialect: 'postgres',
    logging: false,  // SQL 로그 비활성화
    pool: {
        max: 5,
        min: 0,
        acquire: 30000,
        idle: 10000
    }
});
module.exports = sequelize;
// models/User.js
// 변수 선언 및 초기화
const { DataTypes } = require('sequelize');
const sequelize = require('../db');
const User = sequelize.define('User', {
    id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
    },
    name: {
        type: DataTypes.STRING(100),
        allowNull: false,
        validate: {
            len: [2, 50]
        }
    },
    email: {
        type: DataTypes.STRING(255),
        allowNull: false,
        unique: true,
        validate: {
            isEmail: true
        }
    },
    age: {
        type: DataTypes.INTEGER,
        validate: {
            min: 0,
            max: 150
        }
    },
    role: {
        type: DataTypes.ENUM('user', 'admin'),
        defaultValue: 'user'
    }
}, {
    tableName: 'users',
    timestamps: true  // createdAt, updatedAt 자동 생성
});
module.exports = User;
// CRUD
const { Op } = require('sequelize');
const User = require('./models/User');
// CREATE
const created = await User.create({
    name: '홍길동',
    email: '[email protected]',
    age: 25
});
// READ
const users = await User.findAll();
const user = await User.findByPk(1);
const filtered = await User.findAll({
    where: { age: { [Op.gte]: 18 } },
    order: [['createdAt', 'DESC']],
    limit: 10,
    offset: 0
});
// UPDATE
await User.update(
    { age: 26 },
    { where: { id: 1 } }
);
// DELETE
await User.destroy({ where: { id: 1 } });

Sequelize를 쓸 때는 개발 중에 logging: console.log로 실제 SQL을 한 번씩 확인하는 것이 좋습니다. ORM 호출 한 줄이 생각보다 많은 쿼리를 만드는 경우(연관 모델을 include 없이 반복문에서 getPosts()로 불러오는 N+1)가 흔하고, 이는 로그를 보지 않으면 알아차리기 어렵습니다. 또 sequelize.sync({ alter: true })는 모델 정의에 맞춰 테이블을 자동으로 바꿔 주지만, 컬럼 이름 변경을 “삭제 후 추가”로 처리하는 등 데이터를 잃을 수 있으므로 개발용으로만 쓰고 운영에서는 뒤에서 다룰 마이그레이션을 씁니다.


MySQL: mysql2

설치

npm install mysql2

연결

// db.js
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'mydb',
    waitForConnections: true,
    connectionLimit: 10,
    queueLimit: 0
});
module.exports = pool;

쿼리 실행

const pool = require('./db');
// SELECT
async function getUsers() {
    const [rows] = await pool.query('SELECT * FROM users');
    return rows;
}
// INSERT
async function createUser(name, email) {
    const [result] = await pool.query(
        'INSERT INTO users (name, email) VALUES (?, ?)',
        [name, email]
    );
    
    return {
        id: result.insertId,
        name,
        email
    };
}
// UPDATE
async function updateUser(id, name) {
    const [result] = await pool.query(
        'UPDATE users SET name = ? WHERE id = ?',
        [name, id]
    );
    
    return result.affectedRows;
}
// DELETE
async function deleteUser(id) {
    const [result] = await pool.query(
        'DELETE FROM users WHERE id = ?',
        [id]
    );
    
    return result.affectedRows;
}
// 트랜잭션
async function transferMoney(fromId, toId, amount) {
    const connection = await pool.getConnection();
    
    try {
        await connection.beginTransaction();
        
        await connection.query(
            'UPDATE accounts SET balance = balance - ? WHERE id = ?',
            [amount, fromId]
        );
        
        await connection.query(
            'UPDATE accounts SET balance = balance + ? WHERE id = ?',
            [amount, toId]
        );
        
        await connection.commit();
        console.log('이체 성공');
    } catch (err) {
        await connection.rollback();
        console.error('이체 실패:', err.message);
        throw err;
    } finally {
        connection.release();
    }
}

mysql2에는 query()와 execute() 두 가지 실행 방식이 있습니다. query()는 드라이버가 값을 이스케이프해 SQL 문자열에 끼워 넣어 보내고, execute()는 서버 측 prepared statement를 만들어 값을 따로 보냅니다. execute()는 같은 쿼리를 반복할 때 유리하지만, LIMIT ?에 숫자를 넘기면 버전에 따라 Incorrect arguments to mysqld_stmt_execute 오류가 나는 알려진 함정이 있어서 LIMIT에는 문자열로 변환한 값을 넘기거나 query()를 쓰는 식의 우회가 필요합니다. 두 방식 모두 값을 SQL 문자열에 직접 이어 붙이는 것보다는 안전하므로, 어느 쪽을 쓰든 ? 자리표시자를 쓰는 것이 핵심입니다.


Express REST API에 DB 붙이기

MongoDB + Express

// app.js
const express = require('express');
const mongoose = require('mongoose');
const User = require('./models/User');
const app = express();
app.use(express.json());
// MongoDB 연결
mongoose.connect('mongodb://127.0.0.1:27017/mydb')
    .then(() => console.log('MongoDB 연결됨'))
    .catch(err => console.error('연결 실패:', err));
// 모든 사용자 조회
app.get('/api/users', async (req, res) => {
    try {
        const { page = 1, limit = 10, sort = '-createdAt' } = req.query;
        
        const users = await User.find()
            .sort(sort)
            .skip((page - 1) * limit)
            .limit(parseInt(limit));
        
        const total = await User.countDocuments();
        
        res.json({
            users,
            pagination: {
                page: parseInt(page),
                limit: parseInt(limit),
                total,
                pages: Math.ceil(total / limit)
            }
        });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 특정 사용자 조회
app.get('/api/users/:id', async (req, res) => {
    try {
        const user = await User.findById(req.params.id);
        
        if (!user) {
            return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
        }
        
        res.json(user);
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 사용자 생성
app.post('/api/users', async (req, res) => {
    try {
        const user = await User.create(req.body);
        res.status(201).json(user);
    } catch (err) {
        if (err.name === 'ValidationError') {
            return res.status(400).json({ error: err.message });
        }
        res.status(500).json({ error: err.message });
    }
});
// 사용자 수정
app.put('/api/users/:id', async (req, res) => {
    try {
        const user = await User.findByIdAndUpdate(
            req.params.id,
            req.body,
            { new: true, runValidators: true }
        );
        
        if (!user) {
            return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
        }
        
        res.json(user);
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 사용자 삭제
app.delete('/api/users/:id', async (req, res) => {
    try {
        const user = await User.findByIdAndDelete(req.params.id);
        
        if (!user) {
            return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
        }
        
        res.status(204).send();
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
app.listen(3000, () => {
    console.log('서버 실행 중: http://localhost:3000');
});

이 예제는 구조를 보여 주기 위한 최소 버전이라, 실제 서비스에 그대로 쓰면 곤란한 부분이 세 가지 있습니다. 첫째, User.create(req.body)와 findByIdAndUpdate(id, req.body)는 요청 본문을 그대로 저장하므로 클라이언트가 {"role": "admin"}을 끼워 보내면 권한이 바뀝니다(mass assignment). 허용할 필드만 골라서 넘겨야 합니다. 둘째, /api/users/abc처럼 ObjectId 형식이 아닌 값을 넣으면 CastError: Cast to ObjectId failed for value "abc" (type string) at path "_id" for model "User"가 나서 500이 반환되는데, 이는 클라이언트 잘못이므로 mongoose.isValidObjectId()로 먼저 검사해 400을 돌려주는 편이 맞습니다. 셋째, limit 쿼리 값에 상한이 없어서 ?limit=1000000 한 번으로 컬렉션 전체를 읽게 만들 수 있으므로 Math.min(parseInt(limit) || 10, 100)처럼 제한을 둡니다. 또 수정 라우트의 runValidators: true는 업데이트 쿼리에도 스키마 검증을 적용하는 옵션인데, 기본값이 false라서 빼먹으면 save() 때는 막히던 잘못된 값이 업데이트로는 그대로 저장됩니다.

MySQL(mysql2) + Express

아래 예제는 앞 절의 mysql2/promise 풀(? 자리표시자, [rows] 구조 분해, insertId, ER_DUP_ENTRY)을 기준으로 합니다. pg를 쓴다면 자리표시자를 $1, $2로, 결과를 result.rows로, 새 행은 INSERT ... RETURNING *으로 받고, 중복 오류는 PostgreSQL 에러 코드 23505로 판별합니다.

// app.js
const express = require('express');
const pool = require('./db');
const app = express();
app.use(express.json());
// 모든 사용자 조회
app.get('/api/users', async (req, res) => {
    try {
        const { page = 1, limit = 10 } = req.query;
        const offset = (page - 1) * limit;
        
        const [users] = await pool.query(
            'SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?',
            [parseInt(limit), offset]
        );
        
        const [[{ total }]] = await pool.query('SELECT COUNT(*) as total FROM users');
        
        res.json({
            users,
            pagination: {
                page: parseInt(page),
                limit: parseInt(limit),
                total,
                pages: Math.ceil(total / limit)
            }
        });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 사용자 생성
app.post('/api/users', async (req, res) => {
    try {
        const { name, email, age } = req.body;
        
        const [result] = await pool.query(
            'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
            [name, email, age]
        );
        
        const [users] = await pool.query(
            'SELECT * FROM users WHERE id = ?',
            [result.insertId]
        );
        
        res.status(201).json(users[0]);
    } catch (err) {
        if (err.code === 'ER_DUP_ENTRY') {
            return res.status(400).json({ error: '이미 존재하는 이메일입니다' });
        }
        res.status(500).json({ error: err.message });
    }
});
app.listen(3000);

인덱스, N+1, Projection, 페이지네이션

인덱스

// MongoDB
userSchema.index({ email: 1 });  // 단일 인덱스
userSchema.index({ name: 1, age: -1 });  // 복합 인덱스
userSchema.index({ email: 1 }, { unique: true });  // 유니크 인덱스
// PostgreSQL
await pool.query('CREATE INDEX idx_email ON users(email)');
await pool.query('CREATE INDEX idx_name_age ON users(name, age)');
await pool.query('CREATE UNIQUE INDEX idx_email_unique ON users(email)');

N+1 문제 해결

문제:

// ❌ N+1 쿼리 (느림)
const posts = await Post.find();  // 1번 쿼리
for (const post of posts) {
    const author = await User.findById(post.author);  // N번 쿼리
    console.log(author.name);
}
// 총 1 + N번 쿼리

해결:

// ✅ Populate 사용 (2번 쿼리)
const posts = await Post.find().populate('author');
for (const post of posts) {
    console.log(post.author.name);  // 추가 쿼리 없음
}
// 총 2번 쿼리 (posts + authors)

쿼리 선택 (Projection)

// ❌ 모든 필드 가져오기
const users = await User.find();
// ✅ 필요한 필드만 가져오기
const users = await User.find().select('name email');
// MySQL (mysql2)
const [users] = await pool.query('SELECT name, email FROM users');

페이지네이션

// MongoDB
async function paginateUsers(page = 1, limit = 10) {
    const skip = (page - 1) * limit;
    
    const [users, total] = await Promise.all([
        User.find().skip(skip).limit(limit),
        User.countDocuments()
    ]);
    
    return {
        users,
        page,
        limit,
        total,
        pages: Math.ceil(total / limit)
    };
}
// MySQL (mysql2)
async function paginateUsers(page = 1, limit = 10) {
    const offset = (page - 1) * limit;
    
    const [users] = await pool.query(
        'SELECT * FROM users LIMIT ? OFFSET ?',
        [limit, offset]
    );
    
    const [[{ total }]] = await pool.query('SELECT COUNT(*) as total FROM users');
    
    return {
        users,
        page,
        limit,
        total,
        pages: Math.ceil(total / limit)
    };
}

skip/OFFSET 방식은 구현이 쉽고 “몇 페이지로 바로 이동”을 지원하지만, DB가 앞의 행을 전부 읽고 버린 뒤에 결과를 돌려주므로 페이지 번호가 커질수록 느려집니다. 또 사용자가 페이지를 넘기는 사이에 새 글이 추가되면 같은 글이 두 페이지에 걸쳐 보이거나 빠지는 문제도 생깁니다. 무한 스크롤이나 피드처럼 “다음 묶음”만 필요하다면 마지막으로 본 항목의 정렬 키를 기준으로 가져오는 커서(keyset) 페이지네이션이 낫습니다. WHERE created_at < $1 ORDER BY created_at DESC LIMIT 10처럼 쓰면 인덱스를 타고 바로 해당 위치부터 읽습니다. 정렬 키가 중복될 수 있다면 (created_at, id)처럼 고유한 값을 함께 써야 경계에서 항목이 빠지지 않습니다. countDocuments()나 COUNT(*)도 큰 테이블에서는 매 요청마다 부담이 되므로, 전체 개수가 꼭 필요하지 않다면 생략하는 것도 방법입니다.


커넥션 풀 설정과 모니터링

설정

// MongoDB
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/mydb', {
    maxPoolSize: 10,  // 최대 연결 수
    minPoolSize: 2,   // 최소 연결 수
    maxIdleTimeMS: 30000
});
// PostgreSQL
const { Pool } = require('pg');
const pool = new Pool({
    max: 20,  // 최대 연결 수
    min: 5,   // 최소 연결 수
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000
});
// MySQL
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
    connectionLimit: 10,
    queueLimit: 0,
    waitForConnections: true
});

모니터링

// PostgreSQL
pool.on('connect', () => {
    console.log('새 연결 생성');
});
pool.on('acquire', () => {
    console.log('연결 획득');
});
pool.on('release', () => {
    console.log('연결 반환');
});
// 풀 상태 확인
console.log('총 연결:', pool.totalCount);
console.log('유휴 연결:', pool.idleCount);
console.log('대기 중:', pool.waitingCount);

풀 크기는 “클수록 좋다”가 아닙니다. DB 서버가 동시에 효율적으로 처리할 수 있는 쿼리 수는 CPU 코어와 디스크에 묶여 있어서, 연결을 무작정 늘리면 대기가 풀에서 DB 내부로 옮겨 갈 뿐이고 연결마다 DB 메모리도 차지합니다. 특히 조심할 것은 인스턴스 수와 곱해진다는 점입니다. max: 20인 Node 프로세스를 PM2 클러스터나 쿠버네티스 파드로 8개 띄우면 최대 160개 연결이 되어, PostgreSQL 기본 max_connections(100)를 넘고 FATAL: sorry, too many clients already 오류가 납니다. 저도 처음 서버 인스턴스를 늘렸을 때 이 오류를 만났는데, 원인은 DB 성능이 아니라 이 곱셈이었습니다. 인스턴스를 수평으로 늘릴 계획이라면 인스턴스당 풀은 작게 잡고, 필요하면 PgBouncer 같은 커넥션 풀러를 DB 앞에 둡니다. 서버리스 환경(Lambda 등)은 실행 환경마다 풀이 따로 생기므로 이 문제가 더 심해져, RDS Proxy 같은 관리형 풀러를 쓰는 경우가 많습니다.


Sequelize 마이그레이션

Sequelize 마이그레이션

npm install --save-dev sequelize-cli
npx sequelize-cli init

마이그레이션 생성:

npx sequelize-cli migration:generate --name create-users-table
// migrations/20260329-create-users-table.js
module.exports = {
    up: async (queryInterface, Sequelize) => {
        await queryInterface.createTable('users', {
            id: {
                type: Sequelize.INTEGER,
                primaryKey: true,
                autoIncrement: true
            },
            name: {
                type: Sequelize.STRING(100),
                allowNull: false
            },
            email: {
                type: Sequelize.STRING(255),
                allowNull: false,
                unique: true
            },
            age: {
                type: Sequelize.INTEGER
            },
            created_at: {
                type: Sequelize.DATE,
                defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
            },
            updated_at: {
                type: Sequelize.DATE,
                defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
            }
        });
        
        await queryInterface.addIndex('users', ['email']);
    },
    
    down: async (queryInterface, Sequelize) => {
        await queryInterface.dropTable('users');
    }
};

실행:

# 마이그레이션 실행
npx sequelize-cli db:migrate
# 롤백
npx sequelize-cli db:migrate:undo
# 모두 롤백
npx sequelize-cli db:migrate:undo:all

블로그 API 전체 코드

// models/Post.js (MongoDB)
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema({
    title: {
        type: String,
        required: true,
        trim: true,
        minlength: 1,
        maxlength: 200
    },
    content: {
        type: String,
        required: true
    },
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true
    },
    tags: [String],
    published: {
        type: Boolean,
        default: false
    },
    views: {
        type: Number,
        default: 0
    }
}, {
    timestamps: true
});
// 인덱스
postSchema.index({ title: 'text', content: 'text' });  // 전문 검색
postSchema.index({ author: 1, createdAt: -1 });
// 가상 필드
postSchema.virtual('url').get(function() {
    return `/posts/${this._id}`;
});
module.exports = mongoose.model('Post', postSchema);
// routes/posts.js
const express = require('express');
const router = express.Router();
const Post = require('../models/Post');
// 모든 글 조회
router.get('/', async (req, res) => {
    try {
        const { page = 1, limit = 10, tag, author } = req.query;
        
        const query = {};
        if (tag) query.tags = tag;
        if (author) query.author = author;
        
        const posts = await Post.find(query)
            .populate('author', 'name email')
            .sort({ createdAt: -1 })
            .skip((page - 1) * limit)
            .limit(parseInt(limit));
        
        const total = await Post.countDocuments(query);
        
        res.json({
            posts,
            pagination: {
                page: parseInt(page),
                limit: parseInt(limit),
                total,
                pages: Math.ceil(total / limit)
            }
        });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 글 검색
router.get('/search', async (req, res) => {
    try {
        const { q } = req.query;
        
        if (!q) {
            return res.status(400).json({ error: '검색어가 필요합니다' });
        }
        
        const posts = await Post.find(
            { $text: { $search: q } },
            { score: { $meta: 'textScore' } }
        )
        .sort({ score: { $meta: 'textScore' } })
        .populate('author', 'name');
        
        res.json({ posts, count: posts.length });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 글 작성
router.post('/', async (req, res) => {
    try {
        const post = await Post.create({
            ...req.body,
            author: req.user.id  // 인증 미들웨어에서 설정
        });
        
        await post.populate('author', 'name email');
        
        res.status(201).json(post);
    } catch (err) {
        if (err.name === 'ValidationError') {
            return res.status(400).json({ error: err.message });
        }
        res.status(500).json({ error: err.message });
    }
});
// 조회수 증가
router.post('/:id/view', async (req, res) => {
    try {
        const post = await Post.findByIdAndUpdate(
            req.params.id,
            { $inc: { views: 1 } },
            { new: true }
        );
        
        if (!post) {
            return res.status(404).json({ error: '글을 찾을 수 없습니다' });
        }
        
        res.json({ views: post.views });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
module.exports = router;

연결 누수, SQL Injection, 트랜잭션 누락

연결 누수

원인: 연결을 반환하지 않음

// ❌ 연결 누수
async function bad() {
    const connection = await pool.getConnection();
    const [rows] = await connection.query('SELECT * FROM users');
    return rows;  // connection.release() 누락!
}
// ✅ finally로 보장
async function good() {
    const connection = await pool.getConnection();
    
    try {
        const [rows] = await connection.query('SELECT * FROM users');
        return rows;
    } finally {
        connection.release();  // 항상 실행
    }
}

SQL Injection

// ❌ SQL Injection 취약
async function vulnerable(email) {
    const query = `SELECT * FROM users WHERE email = '${email}'`;
    const [rows] = await pool.query(query);
    return rows;
}
// 공격: email = "' OR '1'='1"
// ✅ Prepared Statement 사용
async function safe(email) {
    const [rows] = await pool.query(
        'SELECT * FROM users WHERE email = ?',
        [email]
    );
    return rows;
}

트랜잭션 누락

// ❌ 트랜잭션 없음 (데이터 불일치 가능)
async function bad(fromId, toId, amount) {
    await pool.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]);
    // 여기서 에러 발생 시 첫 번째 쿼리만 실행됨!
    await pool.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]);
}
// ✅ 트랜잭션 사용
async function good(fromId, toId, amount) {
    const connection = await pool.getConnection();
    
    try {
        await connection.beginTransaction();
        
        await connection.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]);
        await connection.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]);
        
        await connection.commit();
    } catch (err) {
        await connection.rollback();
        throw err;
    } finally {
        connection.release();
    }
}

환경별 설정, 연결 재시도, Graceful Shutdown

환경별 설정

// config/database.js
module.exports = {
    development: {
        mongodb: 'mongodb://localhost:27017/mydb-dev',
        postgres: {
            host: 'localhost',
            database: 'mydb_dev',
            user: 'postgres',
            password: 'password'
        }
    },
    production: {
        mongodb: process.env.MONGODB_URI,
        postgres: {
            host: process.env.DB_HOST,
            database: process.env.DB_NAME,
            user: process.env.DB_USER,
            password: process.env.DB_PASSWORD,
            ssl: true
        }
    }
};
const env = process.env.NODE_ENV || 'development';
module.exports = module.exports[env];

연결 재시도

async function connectWithRetry(maxRetries = 5) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            await mongoose.connect('mongodb://localhost:27017/mydb');
            console.log('MongoDB 연결 성공');
            return;
        } catch (err) {
            console.error(`연결 실패 (${i + 1}/${maxRetries}):`, err.message);
            
            if (i === maxRetries - 1) {
                throw err;
            }
            
            const delay = Math.pow(2, i) * 1000;
            console.log(`${delay}ms 후 재시도...`);
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
}
connectWithRetry();

Graceful Shutdown

const mongoose = require('mongoose');
async function gracefulShutdown() {
    console.log('서버 종료 중...');
    
    try {
        await mongoose.connection.close();
        console.log('MongoDB 연결 종료');
        
        await pool.end();
        console.log('PostgreSQL 연결 종료');
        
        process.exit(0);
    } catch (err) {
        console.error('종료 실패:', err.message);
        process.exit(1);
    }
}
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

데이터베이스 연동 요약

  1. MongoDB: 문서 기반, Mongoose ODM
  2. PostgreSQL: 관계형, pg 드라이버, Sequelize ORM
  3. MySQL: 관계형, mysql2 드라이버
  4. 커넥션 풀: 연결 재사용, 성능 향상
  5. 인덱스: 쿼리 성능 최적화
  6. 트랜잭션: 데이터 일관성 보장

데이터베이스 비교

특징MongoDBPostgreSQLMySQL
타입NoSQLSQLSQL
스키마유연엄격엄격
트랜잭션✅✅✅
조인PopulateJOINJOIN
확장성수평 확장 쉬움수직 확장수직 확장
학습 곡선낮음높음중간

표의 “트랜잭션 ✅“에는 단서가 있습니다. MongoDB의 다중 문서 트랜잭션은 4.0부터 지원되지만 레플리카 셋이나 샤드 클러스터에서만 동작합니다. 로컬에 단일 mongod를 띄워 놓고 session.startTransaction()을 쓰면 Transaction numbers are only allowed on a replica set member or mongos 오류가 나므로, 개발 환경에서도 단일 노드 레플리카 셋으로 띄워야 합니다. “수평 확장이 쉽다/수직 확장”도 기본 제공 기능 기준의 단순화입니다. PostgreSQL·MySQL도 읽기 복제본으로 읽기 부하를 나누는 것은 흔하고, 쓰기까지 여러 서버로 나누는 샤딩은 MongoDB에서도 샤드 키 설계를 잘못하면 오히려 병목이 됩니다.

ORM vs Raw Query

특징ORMRaw Query
생산성✅ 높음⭕ 낮음
성능⭕ 오버헤드✅ 최적화 가능
타입 안전성✅❌
복잡한 쿼리⭕ 어려움✅ 쉬움
유지보수✅ 쉬움⭕ 어려움

다음 단계

추천 학습 자료

MongoDB:


자주 묻는 질문 (FAQ)

Q. 커넥션 풀을 쓰는데 시간이 지나면 연결이 고갈되는 이유는 무엇인가요?

A. pool.getConnection()으로 빌린 연결을 release()하지 않으면 풀에 반환되지 않아, 요청이 쌓일수록 사용 가능한 연결이 줄어듭니다. 특히 쿼리 중 예외가 나면 release()까지 도달하지 못하므로 try...finally로 반환을 보장해야 합니다. 트랜잭션이 필요 없는 단일 쿼리라면 pool.query()를 직접 호출해 풀이 연결 반환을 관리하게 하는 것이 더 간단합니다.


같이 보면 좋은 글