C++에서 DB 연동하기: SQLite·PostgreSQL(libpq), 연결 풀, Prepared Statement, 트랜잭션

들어가며: “데이터베이스 연동이 복잡해요”

문제 시나리오

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 핸드셰이크, 인증, 세션 초기화를 거칩니다. PostgreSQL은 연결마다 서버 프로세스를 새로 띄우기 때문에 이 비용이 특히 큽니다.
  • 리소스 고갈: 연결을 닫지 않으면 연결이 계속 쌓이다가 max_connections에 도달해 “too many connections” 에러로 새 요청을 받지 못합니다.
  • SQL 인젝션: 사용자 입력을 문자열로 이어 붙이면 '; DROP TABLE users; -- 같은 악의적 SQL이 실행됩니다.
  • 트랜잭션 혼선: 여러 쿼리를 하나의 단위로 묶지 않으면 중간에 실패했을 때 일부만 반영되어 데이터가 어긋납니다.

해결책은 연결 풀, Prepared Statement(파라미터 바인딩), 트랜잭션 관리, 그리고 이것들을 감싸는 DB 래퍼로 일관된 패턴을 만드는 것입니다.

추가 문제 시나리오

주문 처리 중 일부만 반영

사용자가 결제 버튼을 눌렀을 때 orders 테이블 INSERT는 성공했는데, accounts 테이블의 잔액 UPDATE는 네트워크 끊김으로 실패했습니다. 주문은 생성됐는데 돈은 차감되지 않아 데이터가 어긋납니다. 트랜잭션 없이는 이런 부분 실패를 막을 수 없습니다.

피크 타임에 “too many connections”

동시 접속이 100명을 넘는 순간, 각 요청이 새 연결을 만들어 PostgreSQL max_connections(기본 100)를 초과합니다. 서버가 새 연결을 거부하고 503 에러를 반환합니다. 연결 풀 없이는 확장이 불가능합니다.

검색창에 '; DELETE FROM products; -- 입력

사용자 입력을 "SELECT * FROM products WHERE name LIKE '%" + input + "%'"처럼 문자열로 이어 붙였습니다. 이 쿼리를 여러 문장을 허용하는 API(libpq의 PQexec 등)로 실행하면 악의적 입력이 그대로 실행되어 상품 테이블 전체가 삭제될 수 있습니다. 파라미터 바인딩을 쓰는 것이 가장 확실한 방어입니다.

Statement/Result 해제 누락

PGresult* res = PQexec(...) 후 예외가 발생하면 PQclear(res)가 호출되지 않습니다. 오래 도는 서버에서는 이런 누수가 쌓여 메모리가 계속 늘어나므로, RAII 패턴으로 해제를 보장해야 합니다.

다루는 내용:

  • SQLite와 PostgreSQL(libpq) 기본 사용법
  • RAII 기반 DB 래퍼와 에러 처리
  • 연결 풀(Connection Pool)
  • Prepared Statement와 SQL 인젝션 방지
  • 트랜잭션 관리(BEGIN/COMMIT/ROLLBACK)
  • 자주 발생하는 에러(Connection Leak, Deadlock 등)
  • 풀 사용 여부에 따른 비용 차이
  • 사용자 관리·캐시 테이블 예시

요구 환경: 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 기본

연결

sqlite3_open("app.db", &db)는 app.db 파일을 열어 db 핸들을 채웁니다. 파일이 없으면 새로 만듭니다. 주의할 점은 열기에 실패해도 대개 핸들이 할당된다는 것입니다. 그래서 실패했을 때도 sqlite3_errmsg(db)로 원인을 읽은 뒤 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_close를 생략했는데, 실제 코드에서는 아래 래퍼처럼 실패 시에도 핸들을 닫아 줍니다.

쿼리 실행 (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 종료 문자까지 읽으라는 뜻입니다. 바인딩 인덱스는 1부터 시작하지만 sqlite3_column_*의 컬럼 인덱스는 0부터 시작한다는 차이에 주의하세요. 이 둘을 헷갈려 엉뚱한 컬럼을 읽는 실수가 흔합니다. sqlite3_column_text가 돌려준 포인터는 다음 step이나 finalize 전까지만 유효하므로, 오래 보관하려면 std::string으로 복사해야 합니다.


PostgreSQL (libpq) 기본

연결

PQconnectdb에 키=값 형태의 연결 문자열을 넘기면 PGconn*이 반환됩니다. 연결에 실패해도 nullptr가 아닌 객체가 돌아오므로 반드시 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 텍스트와 분리되어 전달되므로 SQL 인젝션을 막을 수 있습니다. 파라미터를 텍스트 형식(format 0)으로 보낼 때는 길이 배열이 무시되고 null 종료 문자열로 처리됩니다.

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);

SQLite·PostgreSQL을 감싸는 RAII 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);
}

연결 풀 (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) {
                std::string msg = PQerrorMessage(conn);  // PQfinish 전에 복사
                PQfinish(conn);
                throw std::runtime_error("Pool init failed: " + msg);
            }
            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) {
        // 트랜잭션이 열린 채 반환되면 롤백해서 깨끗한 상태로 돌려놓음
        if (PQtransactionStatus(conn) != PQTRANS_IDLE) {
            PQclear(PQexec(conn, "ROLLBACK"));
        }
        // 연결이 끊어졌을 때만 재연결 (PQreset은 연결을 새로 맺으므로 비쌈)
        if (PQstatus(conn) != CONNECTION_OK) {
            PQreset(conn);
        }
        std::lock_guard<std::mutex> lock(mtx_);
        available_.push(conn);
        cv_.notify_one();
    }
};

release에서 매번 PQreset을 호출하는 예제를 종종 보는데, PQreset은 연결을 닫고 새로 맺는 함수라서 이렇게 하면 풀을 쓰는 의미가 사라집니다. 반환 시점에는 PQtransactionStatus로 트랜잭션이 남아 있는지 확인해 롤백만 하고, 재연결은 연결이 실제로 끊겼을 때만 하는 편이 맞습니다. 이 풀은 소멸 시점에 대여 중인 연결은 닫지 못하므로, 풀이 모든 가드보다 오래 살아 있어야 합니다.

스코프 가드로 안전한 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_; }
};

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);

바인딩 원리

SQL 텍스트는 먼저 파싱되고, 파라미터 값은 그 뒤에 데이터로만 전달됩니다. 그래서 '; DROP TABLE users; --' 같은 문자열도 SQL 구문으로 해석되지 않고 그냥 문자열 값으로 비교됩니다. 단, 테이블 이름이나 컬럼 이름, ORDER BY 방향처럼 식별자·키워드 자리는 바인딩할 수 없습니다. 이런 값은 허용 목록(whitelist)으로 검증한 뒤에만 쿼리에 넣어야 합니다.


트랜잭션 관리

트랜잭션이란

여러 쿼리를 하나의 단위로 묶어 전부 반영(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;
    }
};

주문 처리 예제: 풀 + 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. PQexecParams로 파라미터 바인딩
        // 임시 std::string의 c_str()을 배열에 넣으면 문장이 끝나는 순간 댕글링 포인터가 되므로
        // 문자열을 변수로 잡아 두고 포인터를 꺼냄
        const std::string uid = std::to_string(user_id);
        const std::string amt = std::to_string(amount);
        const char* insert_params[] = {uid.c_str(), amt.c_str()};
        PGresult* r_insert = PQexecParams(conn,
            "INSERT INTO orders (user_id, amount) VALUES ($1::int, $2::int) RETURNING id",
            2, nullptr, insert_params, nullptr, nullptr, 0);
        if (PQresultStatus(r_insert) != PGRES_TUPLES_OK) {
            PQclear(r_insert);
            throw std::runtime_error("INSERT failed");  // catch에서 ROLLBACK
        }
        int order_id = std::stoi(PQgetvalue(r_insert, 0, 0));
        PQclear(r_insert);
        // 3. 잔액 차감 (같은 트랜잭션): $1 = amount, $2 = user_id
        const char* update_params[] = {amt.c_str(), uid.c_str()};
        PGresult* r_update = PQexecParams(conn,
            "UPDATE accounts SET balance = balance - $1::int WHERE user_id = $2::int AND balance >= $1::int",
            2, nullptr, update_params, nullptr, nullptr, 0);
        if (PQresultStatus(r_update) != PGRES_COMMAND_OK || std::string(PQcmdTuples(r_update)) == "0") {
            PQclear(r_update);
            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 (...) {
        PQclear(PQexec(conn, "ROLLBACK"));
        throw;
    }
}

이 예제를 처음 작성할 때 흔히 저지르는 실수가 두 가지 있습니다. 하나는 {std::to_string(user_id).c_str(), ...}처럼 임시 문자열의 포인터를 배열에 담는 것입니다. 임시 객체는 그 문장이 끝나면 파괴되므로 PQexecParams가 읽는 시점에는 이미 해제된 메모리를 가리킵니다. 디버그 빌드에서는 우연히 동작하다가 릴리스 빌드에서 이상한 값이 들어가는 식으로 드러나 찾기 어렵습니다. 다른 하나는 INSERT와 UPDATE가 같은 파라미터 배열을 공유하는 것입니다. 두 쿼리의 $1, $2 순서가 다르면 금액 자리에 사용자 ID가 들어가도 컴파일러는 아무 경고를 하지 않습니다. 쿼리마다 파라미터 배열을 따로 만드는 편이 안전합니다.

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가 연결의 대여와 반환을 맡고, PQexecParams가 값을 바인딩하며, 중간에 실패하면 catch 블록의 ROLLBACK이 INSERT까지 함께 취소합니다. 잔액 조건을 WHERE balance >= $1로 UPDATE 문 안에 넣었기 때문에, 먼저 SELECT로 잔액을 읽고 나중에 UPDATE하는 방식과 달리 두 요청이 동시에 들어와도 잔액이 음수가 되지 않습니다.


연결 누수, 교착 상태, PQclear 누락, database is locked

Connection Leak (연결 누수)

증상: too many connections, FATAL: remaining connection slots are reserved 원인: 연결을 닫지 않거나 풀에 반환하지 않기 때문입니다.

// ❌ 나쁜 예
void handle_request() {
    PGconn* conn = PQconnectdb(...);
    // ... 처리 ...
    // return;  // 예외 발생 시 PQfinish 호출 안 됨!
}

해결:

  • RAII 가드로 연결을 감싸 예외 경로에서도 반환되게 합니다.
  • 연결 풀의 acquire/release 쌍을 반드시 맞춥니다.
// ✅ 좋은 예
void handle_request() {
    PgConnectionGuard guard(pool);
    PGconn* conn = guard.get();
    // ... 처리 ...
}  // 자동 release

Deadlock (교착 상태)

증상: 쿼리가 한동안 멈췄다가 ERROR: deadlock detected(SQLSTATE 40P01)로 실패 원인: 두 트랜잭션이 서로가 잠근 행을 기다리기 때문입니다. PostgreSQL은 deadlock_timeout(기본 1초) 동안 기다린 뒤 교착을 검사하고, 둘 중 하나를 에러로 중단시킵니다.

트랜잭션 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'로 잠금 대기가 길어지지 않게 합니다.
  • 재시도: 40P01 에러를 받으면 트랜잭션 전체를 짧은 대기 후 다시 실행합니다.
-- PostgreSQL: 잠금 타임아웃
SET lock_timeout = '2s';

Prepared Statement 재사용 시 reset과 바인딩

증상: 같은 statement를 다시 실행했는데 결과가 없거나, 새로 바인딩하지 않은 파라미터에 이전 값이 그대로 들어감 해결: 다시 실행하기 전에 sqlite3_reset(stmt)로 statement를 처음 상태로 되돌리고 필요한 값을 다시 bind합니다. sqlite3_reset은 바인딩 값을 지우지 않으므로, 이전 값을 없애려면 sqlite3_clear_bindings(stmt)를 따로 호출해야 합니다.

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 호출 안 됨

해결: C++에는 finally가 없으므로 소멸자에서 PQclear를 호출하는 RAII 래퍼를 씁니다. std::unique_ptr<PGresult, decltype(&PQclear)>로도 같은 효과를 낼 수 있습니다.

// ✅ 좋은 예: 스코프 내에서 항상 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());

SQL NULL과 범위 밖 접근 (PQgetvalue)

증상: NULL 컬럼이 0이나 빈 문자열로 조용히 바뀌거나, 결과가 0행일 때 크래시 원인: PQgetvalue(res, row, col)은 SQL NULL 컬럼이면 빈 문자열 ""을 반환하므로, 반환값만 보고는 NULL과 빈 문자열을 구별할 수 없습니다. 또 행·열 번호가 결과 범위를 벗어나면 nullptr가 반환되는데, 이를 atoi에 넘기면 UB입니다.

// ❌ 위험: 0행 결과면 nullptr, NULL이면 "" → 0으로 둔갑
const char* val = PQgetvalue(res, 0, 0);
int id = atoi(val);

해결: PQntuples로 행 수를 확인하고 PQgetisnull로 NULL 여부를 먼저 판별합니다.

// ✅ 안전
std::optional<int> id;
if (PQntuples(res) > 0 && !PQgetisnull(res, 0, 0)) {
    id = std::stoi(PQgetvalue(res, 0, 0));
}

SQLite: database is locked

증상: SQLITE_BUSY, database is locked 원인: SQLite는 데이터베이스 파일 단위로 잠급니다. 한 번에 하나의 쓰기만 가능하며, 잠금을 얻지 못한 연결은 기본 설정에서 기다리지 않고 바로 SQLITE_BUSY를 반환합니다. 해결:

  • 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);

연결 문자열, 쿼리 타임아웃, 풀 크기, 배치 INSERT

연결 문자열은 환경 변수/설정 파일에서

// ❌ 하드코딩
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");

쿼리 타임아웃 설정

-- PostgreSQL: 세션별
SET statement_timeout = '30s';
// libpq: 연결 후
PQexec(conn, "SET statement_timeout = '30s'");

연결 풀 크기 = 워커 스레드 수 또는 약간 더

  • 풀이 너무 작으면: 대기 시간 증가
  • 풀이 너무 크면: DB 서버 max_connections 초과 가능

로깅: 쿼리 실행 시간, 에러 메시지

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());

인덱스 활용

  • WHERE, JOIN, ORDER BY에 자주 쓰는 컬럼에 인덱스
  • EXPLAIN ANALYZE로 쿼리 플랜 확인

배치 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");

연결 풀 vs 매번 새 연결: 비용이 어디서 생기나

SELECT 1처럼 가벼운 쿼리를 반복할 때, 매번 새로 연결하면 쿼리 자체보다 연결 비용이 훨씬 커집니다. 새 연결에는 TCP 핸드셰이크, (TLS를 쓴다면) TLS 핸드셰이크, 인증, 그리고 PostgreSQL의 경우 백엔드 프로세스 fork와 세션 초기화가 포함됩니다. 풀에서 빌린 연결은 이 과정을 모두 건너뛰고 쿼리 왕복 한 번만 치릅니다.

정확한 차이는 네트워크 거리, 인증 방식(scram-sha-256은 해시 계산이 들어감), TLS 사용 여부에 따라 크게 달라지므로 자기 환경에서 직접 재 보는 것이 좋습니다. 같은 머신의 localhost에서도 차이가 크게 나고, DB가 다른 가용 영역에 있으면 왕복 지연이 늘어나 차이가 더 벌어집니다. 요청마다 연결하는 서버는 트래픽이 늘어나는 순간 max_connections에도 먼저 부딪힙니다.

SQLite vs PostgreSQL (연결 비용)

항목SQLitePostgreSQL
연결 비용낮음 (파일 오픈)높음 (TCP, 인증)
동시 쓰기제한적 (파일 잠금)뛰어남
적합 용도임베디드, 소규모서버, 다중 클라이언트

회원가입·로그인과 DB 캐시 테이블 예제

사용자 관리 (회원가입·로그인)

// 회원가입
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;
}

Asio 서버에서 블로킹 DB 호출 다루기

블로킹 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 응답

권장 구조

  • 연결 풀: DB 작업을 실행하는 스레드 수와 같게 맞춥니다. 스레드보다 연결이 많으면 놀고 있는 연결만 늘어납니다.
  • 완전 비동기(고급): libpq의 PQsendQuery/PQsendQueryParams로 쿼리를 보내고, PQsocket으로 얻은 소켓을 Asio에 등록해 읽기 가능할 때 PQconsumeInput/PQgetResult를 호출하면 스레드 풀 없이도 이벤트 루프에서 처리할 수 있습니다. PQsetSingleRowMode는 큰 결과를 행 단위로 받기 위한 기능이지 타임아웃 기능이 아닙니다.
  • SQLite: 로컬 파일 I/O이므로 동기 호출이 일반적이지만, 쓰기가 오래 걸린다면 전용 스레드로 보내는 편이 좋습니다.

재시도와 헬스 체크

// 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(); }
};

DB 연동 배포 전 확인 사항

프로덕션 배포 전 확인 사항:

  • 연결 풀 사용 (매 요청 새 연결 금지)
  • Prepared Statement / PQexecParams로 SQL 인젝션 방지
  • 트랜잭션으로 다중 쿼리 원자성 보장
  • RAII로 연결·Statement·Result 해제
  • 연결 문자열·비밀번호 환경 변수/설정 파일 사용
  • statement_timeout, connect_timeout 설정
  • 에러 로깅 (쿼리, 에러 메시지, 소요 시간)
  • Connection Leak·Deadlock 대응 (가드, 잠금 순서)
  • SQLite: WAL 모드, busy_timeout 설정

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 트랜잭션 도중 예외가 나면 rollback은 어떻게 보장하나요?

A. BEGIN 뒤에 예외로 함수를 빠져나가면 COMMIT도 ROLLBACK도 호출되지 않아, 트랜잭션이 열린 연결이 그대로 풀에 반환될 수 있습니다. 생성자에서 BEGIN을 실행하고, commit()이 호출되지 않은 채 소멸되면 소멸자에서 ROLLBACK을 실행하는 RAII 트랜잭션 가드를 쓰면 예외 경로에서도 정리가 보장됩니다. 연결 풀과 함께 쓸 때는 트랜잭션이 정리된 연결만 풀로 돌아가도록 가드의 수명을 연결 대여 범위 안에 둡니다.

Q. SQLite에서 database is locked(SQLITE_BUSY) 에러가 자주 납니다.

A. SQLite는 파일 단위로 잠금을 걸기 때문에 여러 연결이 동시에 쓰려고 하면 나중에 온 쪽이 SQLITE_BUSY를 받습니다. sqlite3_busy_timeout(db, 5000)처럼 대기 시간을 설정해 즉시 실패하지 않게 하고, PRAGMA journal_mode=WAL로 읽기와 쓰기가 서로 막지 않게 하며, 쓰기 작업은 한 스레드나 큐로 직렬화하는 것이 좋습니다.

Q. PQgetvalue 결과를 atoi에 넘겼더니 크래시가 납니다.

A. 본문 코드처럼 PQgetvalue가 돌려준 포인터를 검사 없이 atoi 같은 함수에 넘기면, 값이 없을 때 크래시로 이어질 수 있습니다. libpq에서 SQL NULL 여부는 PQgetisnull(res, row, col)로 판별하므로 먼저 NULL인지 확인하고, 숫자 변환은 실패를 알 수 있는 std::from_chars 같은 함수로 처리하는 편이 안전합니다.


이전 글: C++ 실전 가이드 #31-2: REST API 서버 다음 글: C++ I/O 병목 줄이기