[2026] C++ 데이터베이스 연동 완벽 가이드 | SQLite·PostgreSQL·연결 풀·트랜잭션 [#31-3]
이 글의 핵심
C++ 데이터베이스 연동이 복잡해요. SQLite·PostgreSQL 기본, DB 래퍼 구현, 연결 풀, Prepared Statement, SQL 인젝션 방지, 트랜잭션, Connection Leak·Deadlock 해결, 성능 비교, 프로덕션 예시까지.
들어가며: “데이터베이스 연동이 복잡해요”
문제 시나리오
REST API 서버를 만들었는데, 사용자 로그인·주문 저장·메시지 기록을 어디에 저장해야 할지 고민입니다:
“매 요청마다 DB 연결을 새로 만들면 느리고, 연결을 안 닫으면 서버가 죽어요. 트랜잭션도 언제 COMMIT하고 ROLLBACK 해야 할지 헷갈려요.” 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 나쁜 예: 매 요청마다 새 연결
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 트러블슈팅과 함께 점검하세요.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 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) 로 반드시 닫아야 합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 로 컬럼 값을 꺼냅니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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) 로 정리한 뒤 종료합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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 인젝션을 방지합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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)
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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) : "";
}
};
사용 예시
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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)
개념
연결 풀은 미리 생성한 연결들을 재사용하는 패턴입니다. 요청마다 새 연결을 만들지 않고, 풀에서 빌려 쓰고 반환합니다. 다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 연결 풀 구현
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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
다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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) 되게 하는 것입니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
flowchart LR
A[BEGIN] --> B[쿼리 1]
B --> C[쿼리 2]
C --> D{성공?}
D -->|Yes| E[COMMIT]
D -->|No| F[ROLLBACK]
SQLite 트랜잭션
아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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 트랜잭션
다음은 cpp를 활용한 상세한 구현 코드입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 트랜잭션 가드
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 잔액 차감을 원자적으로 처리합니다.
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 + 트랜잭션)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#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)
다음은 cpp를 활용한 상세한 구현 코드입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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();
}
필요한 스키마
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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
원인: 연결을 닫지 않고 반환하지 않음.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 나쁜 예
void handle_request() {
PGconn* conn = PQconnectdb(...);
// ....처리 ...
// return; // 예외 발생 시 PQfinish 호출 안 됨!
}
해결:
- RAII 또는 Connection Guard 사용
- 연결 풀의
acquire/release쌍을 반드시 맞추기 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 좋은 예
void handle_request() {
PgConnectionGuard guard(pool);
PGconn* conn = guard.get();
// ....처리 ...
} // 자동 release
Deadlock (교착 상태)
증상: 쿼리가 무한 대기, 타임아웃 원인: 두 트랜잭션이 서로가 잠근 리소스를 기다림. 아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
트랜잭션 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 패턴.
아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 좋은 예: 스코프 내에서 항상 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. 연결 문자열은 환경 변수/설정 파일에서
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 하드코딩
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. 로깅: 쿼리 실행 시간, 에러 메시지
아래 코드는 cpp를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 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. 프로덕션 예시
사용자 관리 (회원가입·로그인)
다음은 cpp를 활용한 상세한 구현 코드입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 회원가입
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);
}
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 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 캐시 테이블)
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 캐시 테이블
CREATE TABLE cache (
key VARCHAR(512) PRIMARY KEY,
value TEXT,
expires_at TIMESTAMP NOT NULL
);
-- 만료된 항목 정리 (주기적 실행)
DELETE FROM cache WHERE expires_at < NOW();
아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 비동기 서버에서는 별도 스레드 풀에서 실행합니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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: 단일 파일이므로 동기 사용이 일반적
프로덕션 패턴: 재시도 + 헬스 체크
다음은 cpp를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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); });
아래 코드는 cpp를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 연결 풀 헬스 체크: 주기적으로 연결 유효성 검사
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는 마스터로 분리할 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.
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++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.