C++ 데이터베이스 연동 완벽 가이드 | SQLite·PostgreSQL·연결 풀·트랜잭션 [#31-3]

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 StatementSQL 인젝션 방지
  • 트랜잭션 관리 (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 트러블슈팅과 함께 점검하세요.


목차

  1. SQLite 기본
  2. PostgreSQL (libpq) 기본
  3. 완전한 DB 래퍼 구현
  4. 연결 풀 (Connection Pool)
  5. Prepared Statement와 SQL 인젝션 방지
  6. 트랜잭션 관리
  7. 통합 예제: 풀 + Prepared + 트랜잭션
  8. 자주 발생하는 에러
  9. 베스트 프랙티스
  10. 성능 비교: 풀 vs 새 연결
  11. 프로덕션 예시
  12. 서버와 연동 패턴

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 (연결 비용)

항목SQLitePostgreSQL
연결 비용낮음 (파일 오픈)높음 (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 등으로 검색하시면 이 글이 도움이 됩니다.


정리

항목내용
SQLitesqlite3_open, prepare, bind, step, finalize
PostgreSQLPQconnectdb, PQexecParams, PQgetvalue
안전Prepared Statement, 파라미터 바인딩, 인젝션 방지
연결 풀미리 생성·재사용, acquire/release
트랜잭션BEGIN → 쿼리 → COMMIT/ROLLBACK
서버스레드 풀/연결 풀, 블로킹 분리

핵심 원칙:

  1. 쿼리는 반드시 바인딩으로 파라미터 전달
  2. 리소스(연결, statement, result) RAII로 해제
  3. 비동기 서버에서는 DB 작업을 별도 스레드/풀에서 실행
  4. 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]