C++ 데이터베이스 연동 완벽 가이드 | SQLite·PostgreSQL·연결 풀·트랜잭션 [#31-3]
이 글의 핵심
데이터베이스 연동이 복잡해요. SQLite·PostgreSQL 기본, DB 래퍼 구현, 연결 풀, Prepared Statement, SQL 인젝션 방지, 트랜잭션, Connection Leak·Deadlock 해결, 성능 비교, 프로덕션 예시까지.
들어가며: “데이터베이스 연동이 복잡해요”
문제 시나리오
REST API 서버를 만들었는데, 사용자 로그인·주문 저장·메시지 기록을 어디에 저장해야 할지 고민입니다:
“매 요청마다 DB 연결을 새로 만들면 느리고, 연결을 안 닫으면 서버가 죽어요. 트랜잭션도 언제 COMMIT하고 ROLLBACK 해야 할지 헷갈려요.”
// ❌ 나쁜 예: 매 요청마다 새 연결
void handle_request(const Request& req) {
PGconn* conn = PQconnectdb("host=localhost dbname=app");
PGresult* res = PQexec(conn, "SELECT * FROM users WHERE id = " + req.user_id); // SQL 인젝션!
// ... 처리 ...
PQfinish(conn); // 매번 연결/해제 → 느림
}
왜 이런 일이 발생할까요?
- 연결 오버헤드: PostgreSQL·MySQL은 TCP 연결·인증·초기화 비용이 큽니다. 요청마다 새 연결을 만들면 수 ms~수십 ms가 추가됩니다.
- 리소스 고갈: 연결을 닫지 않으면 Connection Leak으로 서버가 “too many connections” 에러로 죽습니다.
- SQL 인젝션: 사용자 입력을 문자열로 이어 붙이면
'; DROP TABLE users; --같은 악의적 SQL이 실행됩니다. - 트랜잭션 혼선: 여러 쿼리를 하나의 단위로 묶지 않으면, 중간에 실패해도 일부만 반영되는 불일치가 발생합니다.
해결책: 연결 풀, Prepared Statement(파라미터 바인딩), 트랜잭션 관리, DB 래퍼로 일관된 패턴을 적용합니다.
추가 문제 시나리오
시나리오 2: 주문 처리 중 일부만 반영
사용자가 결제 버튼을 눌렀을 때 orders 테이블에 INSERT는 성공했는데, accounts 테이블의 잔액 UPDATE에서 네트워크 끊김으로 실패했습니다. 주문은 생성됐는데 돈은 차감되지 않아 데이터 불일치가 발생합니다. 트랜잭션 없이는 이런 부분 실패를 막을 수 없습니다.
시나리오 3: 피크 타임에 “too many connections”
동시 접속 100명이 넘는 순간, 각 요청이 새 연결을 만들어 PostgreSQL max_connections(기본 100)를 초과합니다. 서버가 새 연결을 거부하고 503 에러를 반환합니다. 연결 풀 없이는 확장이 불가능합니다.
시나리오 4: 검색창에 '; DELETE FROM products; -- 입력
사용자 입력을 "SELECT * FROM products WHERE name LIKE '%" + input + "%'"처럼 문자열로 이어 붙였습니다. 악의적 입력이 실행되어 전체 상품 테이블이 삭제됩니다. Prepared Statement 없이는 SQL 인젝션을 막을 수 없습니다.
시나리오 5: Statement/Result 해제 누락
PGresult* res = PQexec(...) 후 예외가 발생하면 PQclear(res)가 호출되지 않습니다. 메모리 누수가 쌓여 서버가 OOM으로 죽습니다. RAII 패턴으로 리소스 해제를 보장해야 합니다.
목표:
- SQLite·PostgreSQL 기본 사용법
- 완전한 DB 래퍼 구현 (RAII, 에러 처리)
- 연결 풀 (Connection Pool)
- Prepared Statement와 SQL 인젝션 방지
- 트랜잭션 관리 (BEGIN/COMMIT/ROLLBACK)
- 자주 발생하는 에러 (Connection Leak, Deadlock)
- 성능 비교 (풀 vs 새 연결)
- 프로덕션 예시 (사용자 관리, 캐싱)
요구 환경: C++17 이상, sqlite3, libpq (PostgreSQL)
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
다른 언어·인프라와의 연결: Node.js에서 PostgreSQL·ORM·ODM을 연결하는 방법은 드라이버·ORM vs Raw Query 관점에서 이 글의 연결 풀·Prepared Statement와 대응됩니다. 엔진 선택은 PostgreSQL vs MySQL을, 캐시·배포는 Redis 캐싱 패턴, Docker Compose, Nginx 리버스 프록시, Kubernetes(minikube) 순으로 이어 읽으면 DB → 캐시 → 컨테이너 → 오케스트레이션 흐름이 한 줄로 잡힙니다. 호스트 디스크·inode는 Linux 트러블슈팅과 함께 점검하세요.
목차
- SQLite 기본
- PostgreSQL (libpq) 기본
- 완전한 DB 래퍼 구현
- 연결 풀 (Connection Pool)
- Prepared Statement와 SQL 인젝션 방지
- 트랜잭션 관리
- 통합 예제: 풀 + Prepared + 트랜잭션
- 자주 발생하는 에러
- 베스트 프랙티스
- 성능 비교: 풀 vs 새 연결
- 프로덕션 예시
- 서버와 연동 패턴
1. SQLite 기본
연결
sqlite3_open(“app.db”, &db) 는 app.db 파일을 열어 db 핸들을 채웁니다. 파일이 없으면 새로 만들고, 성공 시 db 는 nullptr 이 아닙니다. 사용 후 sqlite3_close(db) 로 반드시 닫아야 합니다.
#include <sqlite3.h>
#include <iostream>
int main() {
sqlite3* db = nullptr;
int rc = sqlite3_open("app.db", &db);
if (rc != SQLITE_OK) {
std::cerr << "DB open failed: " << sqlite3_errmsg(db) << "\n";
return 1;
}
// ... 사용 ...
sqlite3_close(db);
return 0;
}
코드 설명: sqlite3_open의 두 번째 인자는 sqlite3* 포인터의 주소입니다. 성공 시 해당 포인터에 DB 핸들이 들어가고, SQLITE_OK가 반환됩니다. 실패 시 sqlite3_errmsg(db)로 에러 메시지를 얻을 수 있습니다.
쿼리 실행 (prepare-bind-step-finalize)
sqlite3_prepare_v2 로 ? 플레이스홀더가 있는 SQL을 stmt 로 컴파일하고, sqlite3_bind_int(stmt, 1, userId) 로 첫 번째 ? 에 userId 를 바인딩합니다. sqlite3_step(stmt) 가 SQLITE_ROW 를 반환하는 동안 한 행씩 읽고, sqlite3_column_int·sqlite3_column_text 로 컬럼 값을 꺼냅니다.
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db, "SELECT id, name FROM users WHERE id = ?", -1, &stmt, nullptr);
sqlite3_bind_int(stmt, 1, userId);
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
const char* name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
}
sqlite3_finalize(stmt);
sqlite3_close(db);
코드 설명: prepare_v2의 세 번째 인자 -1은 SQL 문자열을 null 종료까지 읽으라는 뜻입니다. sqlite3_bind_int(stmt, 1, value)는 첫 번째 ? (인덱스 1)에 정수를 바인딩합니다. sqlite3_step은 한 번 호출당 한 행을 진행하며, SQLITE_ROW면 **sqlite3_column_*(stmt, 컬럼인덱스)**로 0부터 시작하는 컬럼 값을 읽습니다.
2. PostgreSQL (libpq) 기본
연결
PQconnectdb 에 연결 문자열(키=값 형태)을 넘기면 PGconn* 이 반환됩니다. PQstatus(conn) == CONNECTION_OK 인지 확인하고, 실패 시 PQfinish(conn) 로 정리한 뒤 종료합니다.
#include <libpq-fe.h>
#include <iostream>
int main() {
PGconn* conn = PQconnectdb("host=localhost dbname=mydb user=u password=p");
if (PQstatus(conn) != CONNECTION_OK) {
std::cerr << "Connection failed: " << PQerrorMessage(conn) << "\n";
PQfinish(conn);
return 1;
}
// ... 사용 ...
PQfinish(conn);
return 0;
}
파라미터화 쿼리 (PQexecParams)
PQexecParams 는 $1, $2 같은 플레이스홀더에 인자 배열을 바인딩해 쿼리를 실행합니다. SQL 인젝션을 방지합니다.
const char* param_values[] = {"123"};
const int param_lengths[] = {3};
const int param_formats[] = {0}; // 0 = 텍스트
PGresult* res = PQexecParams(conn,
"SELECT id, name FROM users WHERE id = $1::int",
1, nullptr, param_values, param_lengths, param_formats, 0);
if (PQresultStatus(res) == PGRES_TUPLES_OK) {
int n = PQntuples(res);
for (int i = 0; i < n; ++i) {
int id = atoi(PQgetvalue(res, i, 0));
const char* name = PQgetvalue(res, i, 1);
}
}
PQclear(res);
3. 완전한 DB 래퍼 구현
설계 목표
- RAII: 연결·Statement 자동 해제
- 에러 처리: 예외 또는
std::expected스타일 - 타입 안전: 문자열·정수 바인딩 헬퍼
SQLite 래퍼 (RAII)
#include <sqlite3.h>
#include <string>
#include <stdexcept>
#include <memory>
class SqliteDb {
sqlite3* db_ = nullptr;
public:
explicit SqliteDb(const std::string& path) {
int rc = sqlite3_open(path.c_str(), &db_);
if (rc != SQLITE_OK) {
std::string msg = db_ ? sqlite3_errmsg(db_) : "unknown";
if (db_) sqlite3_close(db_);
db_ = nullptr;
throw std::runtime_error("SqliteDb open failed: " + msg);
}
}
~SqliteDb() {
if (db_) {
sqlite3_close(db_);
db_ = nullptr;
}
}
SqliteDb(const SqliteDb&) = delete;
SqliteDb& operator=(const SqliteDb&) = delete;
sqlite3* get() { return db_; }
sqlite3* get() const { return db_; }
void exec(const std::string& sql) {
char* err = nullptr;
int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &err);
if (rc != SQLITE_OK) {
std::string msg = err ? err : "unknown";
sqlite3_free(err);
throw std::runtime_error("sqlite3_exec failed: " + msg);
}
}
};
Statement 래퍼 (RAII)
class SqliteStmt {
sqlite3* db_ = nullptr;
sqlite3_stmt* stmt_ = nullptr;
public:
SqliteStmt(sqlite3* db, const std::string& sql) : db_(db) {
int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt_, nullptr);
if (rc != SQLITE_OK) {
throw std::runtime_error("prepare failed: " + std::string(sqlite3_errmsg(db)));
}
}
~SqliteStmt() {
if (stmt_) {
sqlite3_finalize(stmt_);
stmt_ = nullptr;
}
}
SqliteStmt(const SqliteStmt&) = delete;
SqliteStmt& operator=(const SqliteStmt&) = delete;
void bind_int(int index, int value) {
sqlite3_bind_int(stmt_, index, value);
}
void bind_text(int index, const std::string& value) {
sqlite3_bind_text(stmt_, index, value.c_str(), -1, SQLITE_TRANSIENT);
}
bool step() {
return sqlite3_step(stmt_) == SQLITE_ROW;
}
int column_int(int col) { return sqlite3_column_int(stmt_, col); }
std::string column_text(int col) {
const char* p = reinterpret_cast<const char*>(sqlite3_column_text(stmt_, col));
return p ? std::string(p) : "";
}
};
사용 예시
SqliteDb db("app.db");
db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)");
SqliteStmt stmt(db.get(), "INSERT INTO users (id, name) VALUES (?, ?)");
stmt.bind_int(1, 1);
stmt.bind_text(2, "Alice");
stmt.step();
SqliteStmt sel(db.get(), "SELECT id, name FROM users WHERE id = ?");
sel.bind_int(1, 1);
while (sel.step()) {
int id = sel.column_int(0);
std::string name = sel.column_text(1);
}
4. 연결 풀 (Connection Pool)
개념
연결 풀은 미리 생성한 연결들을 재사용하는 패턴입니다. 요청마다 새 연결을 만들지 않고, 풀에서 빌려 쓰고 반환합니다.
flowchart TB
subgraph Pool["연결 풀"]
C1[연결 1]
C2[연결 2]
C3[연결 3]
end
subgraph Workers["워커 스레드"]
W1[요청 1]
W2[요청 2]
W3[요청 3]
end
W1 -->|acquire| C1
W2 -->|acquire| C2
W3 -->|acquire| C3
C1 -->|release| W1
PostgreSQL 연결 풀 구현
#include <libpq-fe.h>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <string>
#include <memory>
class PgConnectionPool {
std::string conninfo_;
size_t pool_size_;
std::queue<PGconn*> available_;
std::mutex mtx_;
std::condition_variable cv_;
public:
PgConnectionPool(const std::string& conninfo, size_t size = 10)
: conninfo_(conninfo), pool_size_(size) {
for (size_t i = 0; i < pool_size_; ++i) {
PGconn* conn = PQconnectdb(conninfo_.c_str());
if (PQstatus(conn) != CONNECTION_OK) {
PQfinish(conn);
throw std::runtime_error("Pool init failed: " + std::string(PQerrorMessage(conn)));
}
available_.push(conn);
}
}
~PgConnectionPool() {
std::lock_guard<std::mutex> lock(mtx_);
while (!available_.empty()) {
PQfinish(available_.front());
available_.pop();
}
}
PGconn* acquire() {
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return !available_.empty(); });
PGconn* conn = available_.front();
available_.pop();
return conn;
}
void release(PGconn* conn) {
PQreset(conn); // 연결 상태 초기화 (트랜잭션 롤백 등)
std::lock_guard<std::mutex> lock(mtx_);
available_.push(conn);
cv_.notify_one();
}
};
스코프 가드로 안전한 acquire/release
class PgConnectionGuard {
PgConnectionPool* pool_ = nullptr;
PGconn* conn_ = nullptr;
public:
PgConnectionGuard(PgConnectionPool& pool) : pool_(&pool) {
conn_ = pool_->acquire();
}
~PgConnectionGuard() {
if (pool_ && conn_) {
pool_->release(conn_);
}
}
PGconn* get() { return conn_; }
};
5. Prepared Statement와 SQL 인젝션 방지
SQL 인젝션이란
사용자 입력을 문자열 연결으로 쿼리에 넣으면, 악의적 입력이 SQL 명령으로 실행됩니다.
입력: userId = "1; DROP TABLE users; --"
생성된 쿼리: SELECT * FROM users WHERE id = 1; DROP TABLE users; --
결과: users 테이블 삭제!
❌ 위험한 코드
// 절대 하지 마세요
std::string sql = "SELECT * FROM users WHERE id = " + user_input;
PQexec(conn, sql.c_str());
✅ 파라미터 바인딩 (SQLite)
// ? 플레이스홀더에 바인딩
SqliteStmt stmt(db.get(), "SELECT * FROM users WHERE id = ?");
stmt.bind_int(1, std::stoi(user_input)); // 정수로 변환 후 바인딩
✅ 파라미터 바인딩 (PostgreSQL)
-- $1, $2 플레이스홀더
SELECT * FROM users WHERE id = $1::int AND name = $2
const char* params[] = {user_id_str.c_str(), user_name.c_str()};
PGresult* res = PQexecParams(conn,
"SELECT * FROM users WHERE id = $1::int AND name = $2",
2, nullptr, params, nullptr, nullptr, 0);
바인딩 원리
- DB 엔진이 파라미터를 데이터로만 취급합니다.
'; DROP TABLE users; --'같은 문자열이 SQL 구문으로 해석되지 않습니다.- 항상 Prepared Statement 또는 PQexecParams를 사용하세요.
6. 트랜잭션 관리
트랜잭션이란
여러 쿼리를 하나의 단위로 묶어, 전부 성공(COMMIT) 하거나 전부 취소(ROLLBACK) 되게 하는 것입니다.
flowchart LR
A[BEGIN] --> B[쿼리 1]
B --> C[쿼리 2]
C --> D{성공?}
D -->|Yes| E[COMMIT]
D -->|No| F[ROLLBACK]
SQLite 트랜잭션
db.exec("BEGIN");
try {
db.exec("INSERT INTO orders (user_id, amount) VALUES (1, 100)");
db.exec("UPDATE accounts SET balance = balance - 100 WHERE user_id = 1");
db.exec("COMMIT");
} catch (...) {
db.exec("ROLLBACK");
throw;
}
PostgreSQL 트랜잭션
PGresult* r1 = PQexec(conn, "BEGIN");
if (PQresultStatus(r1) != PGRES_COMMAND_OK) {
PQclear(r1);
return;
}
PQclear(r1);
PGresult* r2 = PQexecParams(conn, "INSERT INTO orders ...", ...);
bool ok = (PQresultStatus(r2) == PGRES_COMMAND_OK);
PQclear(r2);
if (ok) {
PQexec(conn, "COMMIT");
} else {
PQexec(conn, "ROLLBACK");
}
RAII 트랜잭션 가드
class TransactionGuard {
sqlite3* db_;
bool committed_ = false;
public:
explicit TransactionGuard(sqlite3* db) : db_(db) {
sqlite3_exec(db_, "BEGIN", nullptr, nullptr, nullptr);
}
~TransactionGuard() {
if (!committed_) {
sqlite3_exec(db_, "ROLLBACK", nullptr, nullptr, nullptr);
}
}
void commit() {
sqlite3_exec(db_, "COMMIT", nullptr, nullptr, nullptr);
committed_ = true;
}
};
7. 통합 예제: 풀 + Prepared + 트랜잭션
연결 풀, Prepared Statement, 트랜잭션을 한 번에 사용하는 실전 예제입니다. 주문 생성 시 orders INSERT와 accounts 잔액 차감을 원자적으로 처리합니다.
sequenceDiagram
participant App as 애플리케이션
participant Pool as 연결 풀
participant DB as PostgreSQL
App->>Pool: acquire()
Pool->>App: conn
App->>DB: BEGIN
App->>DB: INSERT orders ($1, $2)
App->>DB: UPDATE accounts SET balance...
alt 성공
App->>DB: COMMIT
else 실패
App->>DB: ROLLBACK
end
App->>Pool: release(conn)
PostgreSQL: 주문 처리 (풀 + PQexecParams + 트랜잭션)
#include <libpq-fe.h>
#include <string>
#include <stdexcept>
// 주문 생성: orders INSERT + accounts 잔액 차감 (트랜잭션)
void create_order(PgConnectionPool& pool, int user_id, int amount) {
PgConnectionGuard guard(pool);
PGconn* conn = guard.get();
// 1. 트랜잭션 시작
PGresult* r_begin = PQexec(conn, "BEGIN");
if (PQresultStatus(r_begin) != PGRES_COMMAND_OK) {
PQclear(r_begin);
throw std::runtime_error("BEGIN failed: " + std::string(PQerrorMessage(conn)));
}
PQclear(r_begin);
try {
// 2. Prepared Statement 스타일: PQexecParams로 파라미터 바인딩
const char* params[] = {std::to_string(user_id).c_str(), std::to_string(amount).c_str()};
PGresult* r_insert = PQexecParams(conn,
"INSERT INTO orders (user_id, amount) VALUES ($1::int, $2::int) RETURNING id",
2, nullptr, params, nullptr, nullptr, 0);
if (PQresultStatus(r_insert) != PGRES_TUPLES_OK) {
PQclear(r_insert);
PQexec(conn, "ROLLBACK");
throw std::runtime_error("INSERT failed");
}
int order_id = atoi(PQgetvalue(r_insert, 0, 0));
PQclear(r_insert);
// 3. 잔액 차감 (같은 트랜잭션)
PGresult* r_update = PQexecParams(conn,
"UPDATE accounts SET balance = balance - $1::int WHERE user_id = $2::int AND balance >= $1::int",
2, nullptr, params, nullptr, nullptr, 0);
if (PQresultStatus(r_update) != PGRES_COMMAND_OK || PQcmdTuples(r_update)[0] == '0') {
PQclear(r_update);
PQexec(conn, "ROLLBACK");
throw std::runtime_error("Insufficient balance or update failed");
}
PQclear(r_update);
// 4. 커밋
PGresult* r_commit = PQexec(conn, "COMMIT");
if (PQresultStatus(r_commit) != PGRES_COMMAND_OK) {
PQclear(r_commit);
throw std::runtime_error("COMMIT failed");
}
PQclear(r_commit);
} catch (...) {
PQexec(conn, "ROLLBACK");
throw;
}
}
SQLite: 통합 예제 (RAII 트랜잭션 + Prepared)
// SQLite: 주문 생성 (트랜잭션 + Prepared Statement)
void create_order_sqlite(SqliteDb& db, int user_id, int amount) {
TransactionGuard tx(db.get());
SqliteStmt insert(db.get(), "INSERT INTO orders (user_id, amount) VALUES (?, ?)");
insert.bind_int(1, user_id);
insert.bind_int(2, amount);
insert.step();
SqliteStmt update(db.get(),
"UPDATE accounts SET balance = balance - ? WHERE user_id = ? AND balance >= ?");
update.bind_int(1, amount);
update.bind_int(2, user_id);
update.bind_int(3, amount);
update.step();
if (sqlite3_changes(db.get()) == 0) {
throw std::runtime_error("Insufficient balance");
}
tx.commit();
}
필요한 스키마
-- PostgreSQL: orders, accounts 테이블
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE accounts (
user_id INT PRIMARY KEY,
balance INT NOT NULL DEFAULT 0
);
코드 설명:
- 풀:
PgConnectionGuard로 acquire/release 자동화 - Prepared:
PQexecParams로$1,$2바인딩 → SQL 인젝션 방지 - 트랜잭션: BEGIN → INSERT/UPDATE → COMMIT 또는 롤백. 중간 실패 시 전체 롤백
8. 자주 발생하는 에러
Connection Leak (연결 누수)
증상: too many connections, FATAL: remaining connection slots are reserved
원인: 연결을 닫지 않고 반환하지 않음.
// ❌ 나쁜 예
void handle_request() {
PGconn* conn = PQconnectdb(...);
// ... 처리 ...
// return; // 예외 발생 시 PQfinish 호출 안 됨!
}
해결:
- RAII 또는 Connection Guard 사용
- 연결 풀의
acquire/release쌍을 반드시 맞추기
// ✅ 좋은 예
void handle_request() {
PgConnectionGuard guard(pool);
PGconn* conn = guard.get();
// ... 처리 ...
} // 자동 release
Deadlock (교착 상태)
증상: 쿼리가 무한 대기, 타임아웃
원인: 두 트랜잭션이 서로가 잠근 리소스를 기다림.
트랜잭션 A: UPDATE users SET ... WHERE id=1 (users.id=1 잠금)
트랜잭션 B: UPDATE orders SET ... WHERE user_id=1 (orders 잠금)
트랜잭션 A: UPDATE orders SET ... (B가 잠근 orders 대기)
트랜잭션 B: UPDATE users SET ... WHERE id=1 (A가 잠근 users 대기)
→ Deadlock
해결:
- 잠금 순서 통일: 항상 users → orders 순으로 잠금
- 타임아웃 설정:
SET lock_timeout = '2s' - 재시도: Deadlock 감지 시 짧은 대기 후 재시도
-- PostgreSQL: 잠금 타임아웃
SET lock_timeout = '2s';
Prepared Statement 재사용 시 바인딩 초기화
증상: 이전 쿼리 결과가 섞여 나옴
해결: sqlite3_reset(stmt) 후 다시 bind 호출.
sqlite3_reset(stmt);
sqlite3_bind_int(stmt, 1, new_user_id);
while (sqlite3_step(stmt) == SQLITE_ROW) { ... }
PGresult/PQclear 누락 (메모리 누수)
증상: 장시간 실행 시 메모리 사용량이 계속 증가
원인: PGresult* res = PQexec(...) 후 PQclear(res)를 호출하지 않음. 예외 발생 시에도 누수.
// ❌ 나쁜 예
PGresult* res = PQexecParams(conn, "SELECT ...", ...);
// 예외 발생 시 PQclear 호출 안 됨
해결: RAII 래퍼 사용 또는 try/finally 패턴.
// ✅ 좋은 예: 스코프 내에서 항상 PQclear
struct PgResultGuard {
PGresult* res_;
~PgResultGuard() { if (res_) PQclear(res_); }
};
PGresult* res = PQexecParams(...);
PgResultGuard guard{res};
// 사용 후 자동 PQclear
Connection refused / 타임아웃
증상: could not connect to server, connection timed out
원인: DB 서버 미실행, 방화벽, 잘못된 host/port, 네트워크 지연
해결:
connect_timeout파라미터 설정- 연결 문자열:
host=localhost port=5432 connect_timeout=5 - 재시도 로직 (지수 백오프)
// libpq: 연결 타임아웃 설정
std::string conninfo = "host=localhost dbname=app connect_timeout=5";
PGconn* conn = PQconnectdb(conninfo.c_str());
NULL 반환 무시 (PQgetvalue)
증상: Segmentation fault, 예기치 않은 크래시
원인: PQgetvalue(res, row, col)은 NULL 컬럼일 때 nullptr 반환. atoi(nullptr) 등은 UB.
// ❌ 위험
const char* val = PQgetvalue(res, 0, 0);
int id = atoi(val); // val이 nullptr면 크래시
해결: NULL 체크 후 사용.
// ✅ 안전
const char* val = PQgetvalue(res, 0, 0);
int id = val ? atoi(val) : 0;
SQLite: database is locked
증상: SQLITE_BUSY, database is locked
원인: SQLite는 기본적으로 파일 잠금. 동시 쓰기 시 블로킹.
해결:
sqlite3_busy_timeout(db, 5000)설정 (5초 대기)- WAL 모드 활성화:
PRAGMA journal_mode=WAL - 쓰기 작업을 직렬화
sqlite3_busy_timeout(db, 5000); // 5초 대기
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nullptr, nullptr, nullptr);
9. 베스트 프랙티스
1. 연결 문자열은 환경 변수/설정 파일에서
// ❌ 하드코딩
PGconn* conn = PQconnectdb("host=prod-db password=secret123");
// ✅ 환경 변수 또는 설정
std::string conninfo = "host=" + config.db_host + " dbname=" + config.db_name +
" user=" + config.db_user + " password=" + getenv("DB_PASSWORD");
2. 쿼리 타임아웃 설정
-- PostgreSQL: 세션별
SET statement_timeout = '30s';
// libpq: 연결 후
PQexec(conn, "SET statement_timeout = '30s'");
3. 연결 풀 크기 = 워커 스레드 수 또는 약간 더
- 풀이 너무 작으면: 대기 시간 증가
- 풀이 너무 크면: DB 서버
max_connections초과 가능
4. 로깅: 쿼리 실행 시간, 에러 메시지
auto start = std::chrono::steady_clock::now();
PGresult* res = PQexecParams(conn, sql, ...);
auto elapsed = std::chrono::steady_clock::now() - start;
if (PQresultStatus(res) != PGRES_TUPLES_OK) {
spdlog::error("Query failed: {} - {}", sql, PQerrorMessage(conn));
}
spdlog::debug("Query took {} ms", std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count());
5. 인덱스 활용
WHERE,JOIN,ORDER BY에 자주 쓰는 컬럼에 인덱스EXPLAIN ANALYZE로 쿼리 플랜 확인
6. 배치 INSERT
// ❌ N번 INSERT
for (const auto& row : rows) {
PQexecParams(conn, "INSERT INTO t VALUES ($1, $2)", 2, ...);
}
// ✅ COPY 또는 배치
PQexec(conn, "BEGIN");
for (const auto& row : rows) {
PQexecParams(conn, "INSERT INTO t VALUES ($1, $2)", 2, ...);
}
PQexec(conn, "COMMIT");
10. 성능 비교: 풀 vs 새 연결
벤치마크 시나리오
- 환경: PostgreSQL localhost, 10,000회 SELECT
- 쿼리:
SELECT 1(단순)
| 방식 | 평균 지연 (μs) | QPS |
|---|---|---|
| 매 요청 새 연결 | ~2,500 | ~400 |
| 연결 풀 (10개) | ~50 | ~20,000 |
해석
- 새 연결: TCP 핸드셰이크 + 인증 + 초기화 → 수 ms 수준
- 풀: 연결 재사용 → 수십 μs 수준
- 프로덕션: 반드시 연결 풀 사용 권장
SQLite vs PostgreSQL (연결 비용)
| 항목 | SQLite | PostgreSQL |
|---|---|---|
| 연결 비용 | 낮음 (파일 오픈) | 높음 (TCP, 인증) |
| 동시 쓰기 | 제한적 (파일 잠금) | 뛰어남 |
| 적합 용도 | 임베디드, 소규모 | 서버, 다중 클라이언트 |
11. 프로덕션 예시
사용자 관리 (회원가입·로그인)
// 회원가입
void register_user(PgConnectionPool& pool, const std::string& email,
const std::string& hashed_password) {
PgConnectionGuard guard(pool);
PGconn* conn = guard.get();
const char* params[] = {email.c_str(), hashed_password.c_str()};
PGresult* res = PQexecParams(conn,
"INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id",
2, nullptr, params, nullptr, nullptr, 0);
if (PQresultStatus(res) != PGRES_TUPLES_OK) {
PQclear(res);
throw std::runtime_error("Insert failed");
}
int new_id = atoi(PQgetvalue(res, 0, 0));
PQclear(res);
}
-- users 테이블 스키마
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
캐싱 (Redis 없이 DB 캐시 테이블)
-- 캐시 테이블
CREATE TABLE cache (
key VARCHAR(512) PRIMARY KEY,
value TEXT,
expires_at TIMESTAMP NOT NULL
);
-- 만료된 항목 정리 (주기적 실행)
DELETE FROM cache WHERE expires_at < NOW();
std::string get_cached(PGconn* conn, const std::string& key) {
const char* params[] = {key.c_str()};
PGresult* res = PQexecParams(conn,
"SELECT value FROM cache WHERE key = $1 AND expires_at > NOW()",
1, nullptr, params, nullptr, nullptr, 0);
if (PQresultStatus(res) != PGRES_TUPLES_OK || PQntuples(res) == 0) {
PQclear(res);
return "";
}
std::string value = PQgetvalue(res, 0, 0);
PQclear(res);
return value;
}
12. 서버와 연동 패턴
블로킹 DB 호출
DB 호출은 블로킹이므로, Asio 비동기 서버에서는 별도 스레드 풀에서 실행합니다.
sequenceDiagram
participant C as 클라이언트
participant I as io_context
participant T as DB 스레드 풀
participant DB as PostgreSQL
C->>I: HTTP 요청
I->>T: post(DB 작업)
T->>DB: 쿼리 실행
DB->>T: 결과
T->>I: 완료 핸들러
I->>C: HTTP 응답
권장 구조
- 연결 풀: 스레드 수 또는 그 이상
- 타임아웃: libpq
PQsetSingleRowMode+ 이벤트 루프 연동 (고급) - SQLite: 단일 파일이므로 동기 사용이 일반적
프로덕션 패턴: 재시도 + 헬스 체크
// Deadlock/일시적 오류 시 재시도 (지수 백오프)
template<typename Func>
auto with_retry(Func&& f, int max_retries = 3) {
for (int i = 0; i < max_retries; ++i) {
try {
return f();
} catch (const std::runtime_error& e) {
if (i == max_retries - 1) throw;
std::this_thread::sleep_for(std::chrono::milliseconds(100 << i));
}
}
throw std::runtime_error("Max retries exceeded");
}
// 사용
with_retry([&] { create_order(pool, user_id, amount); });
// 연결 풀 헬스 체크: 주기적으로 연결 유효성 검사
void health_check(PgConnectionPool& pool) {
PgConnectionGuard guard(pool);
PGconn* conn = guard.get();
PGresult* res = PQexec(conn, "SELECT 1");
if (PQresultStatus(res) != PGRES_TUPLES_OK) {
PQclear(res);
throw std::runtime_error("DB health check failed");
}
PQclear(res);
}
프로덕션 패턴: 읽기/쓰기 분리 (선택)
읽기 전용 복제본이 있다면, SELECT는 복제본, INSERT/UPDATE는 마스터로 분리할 수 있습니다.
class ReadWritePool {
PgConnectionPool read_pool_; // 복제본
PgConnectionPool write_pool_; // 마스터
public:
PGconn* acquire_read() { return read_pool_.acquire(); }
PGconn* acquire_write() { return write_pool_.acquire(); }
};
구현 체크리스트
프로덕션 배포 전 확인 사항:
- 연결 풀 사용 (매 요청 새 연결 금지)
- Prepared Statement / PQexecParams로 SQL 인젝션 방지
- 트랜잭션으로 다중 쿼리 원자성 보장
- RAII로 연결·Statement·Result 해제
- 연결 문자열·비밀번호 환경 변수/설정 파일 사용
- statement_timeout, connect_timeout 설정
- 에러 로깅 (쿼리, 에러 메시지, 소요 시간)
- Connection Leak·Deadlock 대응 (가드, 잠금 순서)
- SQLite: WAL 모드, busy_timeout 설정
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]
- C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]
- C++ 백준/프로그래머스 C++ 세팅과 입출력 최적화 한 번에 정리 [#32-1]
이 글에서 다루는 키워드 (관련 검색어)
C++ 데이터베이스, DB 연동, 연결 풀, SQL 인젝션 방지, libpq, sqlite3 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| SQLite | sqlite3_open, prepare, bind, step, finalize |
| PostgreSQL | PQconnectdb, PQexecParams, PQgetvalue |
| 안전 | Prepared Statement, 파라미터 바인딩, 인젝션 방지 |
| 연결 풀 | 미리 생성·재사용, acquire/release |
| 트랜잭션 | BEGIN → 쿼리 → COMMIT/ROLLBACK |
| 서버 | 스레드 풀/연결 풀, 블로킹 분리 |
핵심 원칙:
- 쿼리는 반드시 바인딩으로 파라미터 전달
- 리소스(연결, statement, result) RAII로 해제
- 비동기 서버에서는 DB 작업을 별도 스레드/풀에서 실행
- Connection Leak·Deadlock 주의
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. REST API 서버, 채팅 서버, 주문 시스템 등 C++에서 사용자·메시지·주문 데이터를 영속 저장할 때 SQLite·PostgreSQL 연동이 필요합니다. 연결 풀과 트랜잭션 패턴은 프로덕션 필수입니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. sqlite3 공식 문서, libpq 문서, cppreference를 참고하세요.
한 줄 요약: SQLite·libpq로 C++에서 DB를 연동하고, 연결 풀·Prepared Statement·트랜잭션으로 안전하고 빠르게 구축할 수 있습니다. 다음으로 입출력 최적화(#32-1)를 읽어보면 좋습니다.
이전 글: C++ 실전 가이드 #31-2: REST API 서버
다음 글: [C++ 코테 압축 #32-1] 백준/프로그래머스 C++ 세팅과 입출력 최적화 완벽 정리
관련 글
- C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]
- C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]
- C++ 데이터베이스 쿼리 최적화 완벽 가이드 | 인덱스·실행 계획·캐싱·N+1 해결 [#51-8]
- C++ PostgreSQL 클라이언트 완벽 가이드 | libpq·libpqxx
- C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]