새 프로젝트를 시작할 때 가장 먼저 마주치는 선택 중 하나가 데이터베이스입니다. SQL(관계형)과 NoSQL(비관계형)은 데이터를 저장하고 조회하는 방식이 근본적으로 다릅니다. "어떤 게 더 좋아?"가 아니라 "이 상황에 무엇이 적합한가"로 접근해야 합니다.
관계형 DB(SQL) — PostgreSQL 예시
데이터를 테이블(행과 열)로 저장하고, 테이블 간 관계를 외래 키로 정의합니다.
-- 테이블 생성
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT,
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- JOIN으로 연관 데이터 조회
SELECT
u.name AS author,
p.title,
p.created_at
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.published = TRUE
ORDER BY p.created_at DESC
LIMIT 10;
트랜잭션 — ACID 보장
-- 계좌 이체: 출금과 입금이 동시에 성공/실패해야 함
BEGIN;
UPDATE accounts SET balance = balance - 10000
WHERE id = 1 AND balance >= 10000;
UPDATE accounts SET balance = balance + 10000
WHERE id = 2;
-- 두 업데이트 모두 성공해야 커밋
COMMIT;
-- 하나라도 실패하면 ROLLBACK;
비관계형 DB(NoSQL) — MongoDB 예시
데이터를 문서(Document) 형태로 저장합니다. 스키마가 유연하며 중첩 구조를 그대로 저장할 수 있습니다.
// MongoDB — 문서(Document) 예시
{
_id: ObjectId("507f1f77bcf86cd799439011"),
email: "dev@devlog.kr",
name: "홍길동",
// 관련 데이터를 중첩해서 저장 (JOIN 불필요)
posts: [
{
title: "Node.js 입문",
tags: ["javascript", "backend"],
published: true,
views: 1234
}
],
preferences: {
theme: "dark",
notifications: { email: true, push: false }
}
}
// 조회
db.users.find({
"posts.published": true,
"preferences.theme": "dark"
}).sort({ createdAt: -1 }).limit(10);
// 집계 파이프라인
db.posts.aggregate([
{ $match: { published: true } },
{ $group: { _id: "$tags", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
]);
Redis — 인메모리 캐시 DB
const redis = require('redis');
const client = redis.createClient();
// 세션 저장 (1시간 만료)
await client.setEx(`session:${userId}`, 3600, JSON.stringify(sessionData));
// 캐시 패턴
async function getUser(id) {
const cached = await client.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
await client.setEx(`user:${id}`, 300, JSON.stringify(user)); // 5분 캐시
return user;
}
// 카운터 (조회수, 좋아요 수)
await client.incr(`post:${postId}:views`);
// 순위 (리더보드)
await client.zAdd('leaderboard', { score: 1500, value: 'user:123' });
const top10 = await client.zRangeWithScores('leaderboard', 0, 9, { REV: true });
언제 무엇을 선택할까?
SQL (PostgreSQL, MySQL)을 선택할 때:
✅ 데이터 간 복잡한 관계가 많을 때 (ERP, 금융)
✅ 트랜잭션 일관성이 중요할 때 (주문, 결제)
✅ 데이터 구조가 명확하고 잘 변하지 않을 때
✅ 복잡한 쿼리, 집계, 리포팅이 필요할 때
MongoDB를 선택할 때:
✅ 데이터 구조가 유연하거나 자주 바뀔 때
✅ 문서 형태 데이터 (블로그 글, 상품 카탈로그)
✅ 빠른 프로토타이핑이 필요할 때
✅ 스케일 아웃(수평 확장)이 중요할 때
Redis를 선택할 때:
✅ 세션, 캐시, 임시 데이터 저장
✅ 실시간 순위표, 카운터
✅ 메시지 큐, Pub/Sub
✅ 응답 속도가 ms 단위여야 할 때
인덱스 — 성능의 핵심
-- 자주 검색하는 컬럼에 인덱스 추가
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_created ON posts(created_at DESC);
-- 복합 인덱스 (조건에 자주 함께 등장하는 컬럼)
CREATE INDEX idx_posts_user_published
ON posts(user_id, published)
WHERE published = TRUE; -- 부분 인덱스
-- 인덱스 활용 여부 확인
EXPLAIN ANALYZE
SELECT * FROM posts WHERE user_id = 1 AND published = TRUE;
SQL은 데이터 일관성과 복잡한 관계, NoSQL은 유연한 스키마와 수평 확장에 강합니다. 많은 실제 서비스는 둘을 함께 사용합니다: PostgreSQL로 핵심 비즈니스 데이터를 관리하고, Redis로 캐시/세션을 처리합니다. 인덱스 설계는 쿼리 성능에 결정적인 영향을 미치므로 반드시 EXPLAIN으로 검증하세요.
쿼리 최적화 — EXPLAIN ANALYZE 활용
느린 쿼리를 개선할 때는 먼저 EXPLAIN ANALYZE로 실행 계획을 확인해야 합니다. 추측보다 데이터가 우선입니다.
-- 실행 계획 확인 (PostgreSQL)
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2026-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 10;
-- 출력에서 확인할 항목:
-- Seq Scan → 인덱스 없음 (풀 스캔)
-- Index Scan → 인덱스 사용 중
-- actual time=... rows=... → 실제 소요 시간과 행 수
Seq Scan이 보이면 인덱스 추가를 검토하세요. 단, 전체 데이터의 20% 이상을 조회하는 쿼리라면 인덱스보다 풀 스캔이 더 빠를 수 있습니다. 데이터 건수와 선택도(selectivity)를 함께 고려하세요.
연결 풀링 (Connection Pooling)
데이터베이스 연결은 비용이 큽니다. 요청마다 새 연결을 만들면 성능이 크게 저하됩니다. 연결 풀로 연결을 재사용하세요.
// Node.js - pg 라이브러리 연결 풀
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASS,
max: 20, // 최대 연결 수
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
async function getUser(id) {
const { rows } = await pool.query(
'SELECT * FROM users WHERE id = $1',
[id] // SQL 인젝션 방지: 파라미터로 전달
);
return rows[0];
}
PgBouncer(PostgreSQL용), ProxySQL(MySQL용) 같은 전용 연결 풀러를 앞에 두면 수천 개의 앱 연결을 수십 개의 DB 연결로 집약할 수 있습니다. 트래픽이 많은 서비스에서 연결 풀링만으로도 데이터베이스 병목을 크게 줄일 수 있습니다.
안전한 스키마 마이그레이션
운영 중인 서비스에서 스키마를 변경하는 것은 매우 위험합니다. 특히 대용량 테이블에서 ALTER TABLE은 테이블 잠금을 유발할 수 있습니다.
-- ❌ 위험: 대용량 테이블에서 테이블 잠금 유발
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT '';
-- ✅ 안전: 3단계로 나누기
-- 1단계: NULL 허용으로 추가 (잠금 없음)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- 2단계: 기존 데이터 배치 업데이트
UPDATE users SET phone = '' WHERE phone IS NULL;
-- 3단계: NOT NULL 제약 추가
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
마이그레이션 도구로는 Node.js 환경에서 Prisma Migrate, Knex.js가 많이 쓰입니다. 마이그레이션 파일은 반드시 버전 관리(git)에 포함하고, 롤백 스크립트도 함께 작성해두세요.
데이터베이스 선택이 서비스 아키텍처를 결정한다
SQL이냐 NoSQL이냐의 선택은 단순한 기술 스택의 문제가 아니라, 데이터 모델과 서비스의 성격에 따라 결정되어야 합니다. 데이터 간 관계가 명확하고 복잡한 쿼리가 필요한 서비스라면 관계형 데이터베이스가 훨씬 강력합니다. 예를 들어 전자상거래 플랫폼에서 주문-상품-사용자-결제 간의 관계를 다루고, 특정 기간 매출 집계나 재구매율 분석 쿼리를 수시로 실행해야 한다면 PostgreSQL이나 MySQL이 적합합니다. 반면 사용자별 맞춤 데이터를 저장하거나, 데이터 구조가 서비스 성장에 따라 자주 변한다면 NoSQL의 유연성이 유리합니다. 소셜 미디어의 사용자 피드, IoT 센서 데이터, 게임 유저 상태 저장 같은 용도에서 MongoDB나 DynamoDB가 빛을 발합니다.
하나의 서비스에 여러 데이터베이스를 함께 쓰는 폴리글랏 퍼시스턴스(Polyglot Persistence)도 실무에서 점점 일반화되고 있습니다. 핵심 비즈니스 데이터는 PostgreSQL로 관리하면서, 검색 기능은 Elasticsearch, 세션 저장과 캐시는 Redis, 분석용 집계는 BigQuery로 처리하는 구조가 대표적인 예입니다. 각 저장소의 강점을 목적에 맞게 조합하면 단일 데이터베이스로 모든 것을 처리하려 할 때 생기는 성능 병목을 해소할 수 있습니다. 다만 운영 복잡도가 높아지므로, 작은 팀이나 초기 서비스라면 하나의 데이터베이스에서 시작해 병목이 생겼을 때 분리하는 점진적 접근이 현실적입니다.