C++에서 MongoDB 쓰기: mongocxx 설치와 연결, CRUD, Aggregation, Replica Set

C++ 서비스에서 MongoDB를 쓰는 이유는 대개 데이터의 모양 때문입니다. 시스템 로그나 이벤트처럼 필드 구성이 자주 바뀌는 데이터를 관계형 DB에 넣으면 필드가 늘 때마다 ALTER TABLE과 마이그레이션이 필요하지만, MongoDB는 문서마다 필드가 달라도 그대로 저장합니다. API로 받은 JSON 형태의 데이터를 테이블 구조로 쪼개지 않고 문서 하나로 저장할 수 있다는 점도 편합니다.

C++에서는 MongoDB가 공식으로 유지보수하는 드라이버를 씁니다. 드라이버는 두 라이브러리로 나뉩니다. bsoncxx는 MongoDB의 저장·전송 형식인 BSON(Binary JSON) 문서를 만들고 읽는 라이브러리이고, mongocxx는 서버 연결과 CRUD, 집계, 트랜잭션을 담당하며 bsoncxx에 의존합니다. 이 글은 mongocxx 4.x를 기준으로 설치, 연결, CRUD, 집계, 인덱스, 레플리카셋, 그리고 자주 만나는 오류를 다룹니다.

flowchart TB
  subgraph App[C++ 애플리케이션]
    Main[main]
    Client[mongocxx::client / pool]
    Coll[collection]
  end
  subgraph Driver[mongocxx / bsoncxx]
    Instance[mongocxx::instance]
    Uri[uri]
    Doc[bsoncxx::document]
  end
  subgraph Mongo[MongoDB 서버]
    Store["(컬렉션)"]
  end
  Main --> Instance
  Main --> Client
  Client --> Uri
  Client --> Coll
  Coll --> Doc
  Client -->|BSON/TCP| Store

mongocxx 설치와 CMake 설정

항목버전비고
C++ 표준C++17 이상mongocxx 4.x는 C++17 필수 (3.x는 C++11부터 지원)
mongocxx / bsoncxx4.x같은 버전으로 함께 설치됨
mongo-c-driver드라이버 버전에 맞는 버전mongocxx가 내부적으로 사용
CMake3.15 이상find_package 사용

MongoDB 서버 실행

테스트용 서버는 Docker로 띄우는 것이 가장 간단합니다.

docker run -d --name mongo -p 27017:27017 mongo:7

드라이버 설치

패키지 매니저를 쓸 수 있다면 vcpkg가 가장 편합니다.

vcpkg install mongo-cxx-driver

소스에서 빌드할 때는 릴리스 tarball을 받아 빌드합니다. 최근 버전은 mongo-c-driver가 설치되어 있지 않으면 CMake 단계에서 자동으로 받아 함께 빌드합니다.

curl -OL https://github.com/mongodb/mongo-cxx-driver/releases/download/r4.1.4/mongo-cxx-driver-r4.1.4.tar.gz
tar -xzf mongo-cxx-driver-r4.1.4.tar.gz
cd mongo-cxx-driver-r4.1.4/build
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=17
cmake --build .
sudo cmake --build . --target install

CMakeLists.txt

cmake_minimum_required(VERSION 3.15)
project(mongodb_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(mongocxx REQUIRED)
add_executable(mongodb_demo main.cpp)
target_link_libraries(mongodb_demo PRIVATE mongo::mongocxx_shared)

mongo::mongocxx_shared는 bsoncxx를 의존성으로 함께 끌어오므로 따로 링크하지 않아도 됩니다. 정적 링크라면 mongo::mongocxx_static을 씁니다.

간단한 테스트라면 pkg-config로 컴파일할 수도 있습니다. pkg-config 모듈 이름은 3.x에서 libmongocxx였고 4.x에서 mongocxx로 바뀌었으므로, 설치된 버전에 맞춰 씁니다.

c++ -std=c++17 main.cpp $(pkg-config --cflags --libs mongocxx) -o mongodb_demo

/usr/local에 설치한 뒤 macOS에서 Library not loaded: @rpath/libmongocxx... 오류가 나면 실행 파일에 rpath가 없어서 동적 라이브러리를 못 찾는 것입니다. 링크 옵션에 -Wl,-rpath,/usr/local/lib를 추가합니다.


mongocxx::instance와 첫 연결

드라이버를 쓰기 전에 mongocxx::instance를 프로세스에서 정확히 한 번 만들어야 합니다. 내부적으로 C 드라이버를 초기화하고 소멸 시 정리하는 객체라서, 이 객체가 살아 있는 동안에만 다른 드라이버 객체를 써야 합니다. 두 번 만들면 예외가 발생하고, 만들지 않고 client를 쓰는 것은 지원되지 않는 사용법입니다. 보통 main의 첫 줄에 둡니다.

#include <bsoncxx/builder/basic/document.hpp>
#include <bsoncxx/json.hpp>
#include <mongocxx/client.hpp>
#include <mongocxx/instance.hpp>
#include <mongocxx/uri.hpp>
#include <iostream>

using bsoncxx::builder::basic::kvp;
using bsoncxx::builder::basic::make_document;

int main() {
    mongocxx::instance inst{};   // 프로세스당 1회

    mongocxx::client client{mongocxx::uri{"mongodb://localhost:27017"}};
    auto db = client["mydb"];
    auto collection = db["greetings"];

    // client 생성만으로는 연결을 확인하지 않으므로, ping으로 서버 응답을 확인
    auto pong = client["admin"].run_command(make_document(kvp("ping", 1)));
    std::cout << "Ping: " << bsoncxx::to_json(pong) << "\n";

    auto doc = make_document(kvp("message", "Hello, MongoDB!"));
    auto insert_result = collection.insert_one(doc.view());
    if (insert_result) {
        std::cout << "Inserted id: "
                  << insert_result->inserted_id().get_oid().value.to_string() << "\n";
    }

    auto found = collection.find_one(make_document(kvp("message", "Hello, MongoDB!")));
    if (found) {
        std::cout << "Found: " << bsoncxx::to_json(found->view()) << "\n";
    }
}

mongocxx::client의 생성자는 서버에 접속하지 않습니다. 실제 연결은 첫 작업을 수행할 때 이루어지므로, 주소가 틀려도 생성자는 성공하고 첫 run_command나 find에서 서버 선택 시간 초과(serverSelectionTimeoutMS, 기본 30초) 뒤에 예외가 납니다. 시작 시점에 연결 문제를 빨리 알고 싶다면 위처럼 ping을 보내고, 시간 초과를 짧게 설정해 둡니다.

// 인증 포함 (사용자가 admin DB에 정의되어 있을 때)
mongocxx::uri a("mongodb://user:password@localhost:27017/mydb?authSource=admin");
// 시간 제한
mongocxx::uri b("mongodb://localhost:27017/?serverSelectionTimeoutMS=3000&connectTimeoutMS=3000&socketTimeoutMS=10000");
// 레플리카셋
mongocxx::uri c("mongodb://host1:27017,host2:27017,host3:27017/?replicaSet=rs0");
// MongoDB Atlas (DNS SRV 레코드로 호스트 목록을 조회)
mongocxx::uri d("mongodb+srv://cluster0.example.mongodb.net/?retryWrites=true&w=majority");

인증 실패(Authentication failed)는 대부분 사용자 정의가 있는 DB와 authSource가 일치하지 않아서 생깁니다. URI 경로의 DB 이름(/mydb)은 기본 인증 DB로도 쓰이므로, 사용자가 admin에 정의되어 있다면 authSource=admin을 명시해야 합니다.


CRUD

Insert

#include <bsoncxx/builder/basic/document.hpp>
#include <mongocxx/collection.hpp>
#include <iostream>
#include <vector>

using bsoncxx::builder::basic::kvp;
using bsoncxx::builder::basic::make_document;

void insert_examples(mongocxx::collection& collection) {
    auto doc = make_document(
        kvp("name", "Mongo's Burgers"),
        kvp("cuisine", "American"),
        kvp("rating", 4.5));
    auto result = collection.insert_one(doc.view());
    if (result) {
        std::cout << "Inserted: " << result->inserted_id().get_oid().value.to_string() << "\n";
    }

    std::vector<bsoncxx::document::value> docs;
    docs.push_back(make_document(kvp("name", "Mongo's Pizza")));
    docs.push_back(make_document(kvp("name", "Mongo's Tacos")));
    auto many_result = collection.insert_many(docs);
    if (many_result) {
        std::cout << "Inserted count: " << many_result->inserted_count() << "\n";
    }
}

_id를 지정하지 않으면 드라이버가 ObjectId를 생성해 넣습니다. 쓰기 작업의 결과가 std::optional인 이유는, write concern을 unacknowledged(w: 0)로 설정하면 서버가 응답하지 않아 결과를 알 수 없기 때문입니다. 기본 설정에서는 값이 들어 있습니다.

Find와 커서

#include <mongocxx/options/find.hpp>
#include <bsoncxx/json.hpp>

void find_examples(mongocxx::collection& collection) {
    auto one = collection.find_one(make_document(kvp("name", "Mongo's Burgers")));
    if (one) {
        std::cout << "Found: " << bsoncxx::to_json(one->view()) << "\n";
    }

    // 조건에 맞는 문서를 커서로 순회
    auto cursor = collection.find(make_document(kvp("cuisine", "American")));
    for (auto&& doc : cursor) {
        std::cout << bsoncxx::to_json(doc) << "\n";
    }

    // 정렬, 개수 제한, 필요한 필드만 가져오기
    mongocxx::options::find opts{};
    opts.limit(10);
    opts.sort(make_document(kvp("rating", -1)));              // value를 넘김: 옵션이 소유
    opts.projection(make_document(kvp("name", 1), kvp("rating", 1)));
    for (auto&& doc : collection.find({}, opts)) {
        std::cout << bsoncxx::to_json(doc) << "\n";
    }
}

find는 결과를 한꺼번에 가져오지 않고 커서를 돌려줍니다. 순회하면서 서버에서 배치 단위로 문서를 받아 오므로, 결과가 수십만 건이어도 한 번에 메모리에 올라가는 양은 배치 하나 분량입니다. 반대로 모든 문서를 std::vector<document::value>에 복사해 모으면 이 이점이 사라집니다. 커서는 한 번만 순회할 수 있고, 다시 보려면 find를 다시 호출해야 합니다. 순회 중 얻는 doc은 커서가 들고 있는 현재 배치를 가리키는 view이므로, 다음 문서로 넘어간 뒤에도 쓰려면 bsoncxx::document::value{doc}로 복사해야 합니다.

옵션에 정렬이나 프로젝션을 넘길 때 make_document(...).view()처럼 임시 value의 view를 넘기면 안 됩니다. 옵션 객체는 그 view만 보관하는데, 임시 value는 그 문장이 끝나면 파괴되므로 실제 find 호출 시점에는 해제된 메모리를 가리키게 됩니다. 위 코드처럼 value를 그대로 넘기면 옵션 객체가 소유권을 가져갑니다. 프로젝션으로 필요한 필드만 가져오면 네트워크로 전송하는 양과 BSON 파싱 비용이 함께 줄어듭니다.

Update와 upsert

#include <mongocxx/options/update.hpp>

void update_examples(mongocxx::collection& collection) {
    auto result = collection.update_one(
        make_document(kvp("name", "Mongo's Burgers")),
        make_document(kvp("$set", make_document(kvp("rating", 4.8)))));
    if (result) {
        std::cout << "Matched: " << result->matched_count()
                  << ", modified: " << result->modified_count() << "\n";
    }

    collection.update_many(
        make_document(kvp("cuisine", "American")),
        make_document(kvp("$set", make_document(kvp("updated", true)))));

    // 조건에 맞는 문서가 없으면 새로 삽입
    mongocxx::options::update opts{};
    opts.upsert(true);
    collection.update_one(
        make_document(kvp("name", "New Restaurant")),
        make_document(kvp("$set", make_document(kvp("cuisine", "Korean")))),
        opts);
}

matched_count와 modified_count는 다릅니다. 조건에 맞는 문서가 있어도 $set으로 이미 같은 값을 쓰면 수정된 것이 없으므로 modified_count는 0입니다. “업데이트가 성공했는가”를 판단할 때 modified_count > 0을 쓰면 이런 경우를 실패로 잘못 처리합니다. 또한 update 문서에 $set 같은 연산자 없이 일반 필드만 넣으면 update_one은 오류를 냅니다. 문서 전체를 바꾸려면 replace_one을 씁니다.

Delete

void delete_examples(mongocxx::collection& collection) {
    auto result = collection.delete_one(make_document(kvp("name", "Mongo's Tacos")));
    if (result) {
        std::cout << "Deleted: " << result->deleted_count() << "\n";
    }
    collection.delete_many(make_document(kvp("rating", make_document(kvp("$lt", 3.0)))));
}

예제: 사용자 프로필 저장소

#include <bsoncxx/builder/basic/document.hpp>
#include <bsoncxx/json.hpp>
#include <bsoncxx/types.hpp>
#include <mongocxx/client.hpp>
#include <mongocxx/instance.hpp>
#include <mongocxx/uri.hpp>
#include <chrono>
#include <iostream>
#include <optional>
#include <string>

using bsoncxx::builder::basic::kvp;
using bsoncxx::builder::basic::make_document;

class UserProfileStore {
public:
    explicit UserProfileStore(mongocxx::database db) : collection_(db["users"]) {}

    bool upsertProfile(const std::string& userId, const std::string& name,
                       const std::string& email, int age) {
        mongocxx::options::update opts{};
        opts.upsert(true);
        auto result = collection_.update_one(
            make_document(kvp("_id", userId)),
            make_document(kvp("$set", make_document(
                kvp("name", name),
                kvp("email", email),
                kvp("age", age),
                kvp("updatedAt", bsoncxx::types::b_date{std::chrono::system_clock::now()})))),
            opts);
        // 기존 문서가 있었거나(matched) 새로 삽입됐으면(upserted_id) 성공
        return result && (result->matched_count() > 0 || result->upserted_id());
    }

    std::optional<bsoncxx::document::value> getProfile(const std::string& userId) {
        return collection_.find_one(make_document(kvp("_id", userId)));
    }

    bool deleteProfile(const std::string& userId) {
        auto result = collection_.delete_one(make_document(kvp("_id", userId)));
        return result && result->deleted_count() > 0;
    }

private:
    mongocxx::collection collection_;
};

int main() {
    mongocxx::instance inst{};
    mongocxx::client client{mongocxx::uri{"mongodb://localhost:27017"}};
    UserProfileStore store(client["mydb"]);
    store.upsertProfile("user123", "홍길동", "[email protected]", 30);
    if (auto profile = store.getProfile("user123")) {
        std::cout << bsoncxx::to_json(profile->view()) << "\n";
    }
}

getProfile이 document::value를 돌려주는 데 주목해야 합니다. document::view를 돌려주면 함수 안의 value가 파괴된 뒤 해제된 메모리를 가리키게 됩니다. 이 클래스는 mongocxx::collection을 보관하므로, 그 collection을 만든 client보다 오래 살아서는 안 됩니다.

트랜잭션

MongoDB에서 문서 하나에 대한 수정은 내장 배열이나 하위 문서를 포함해 항상 원자적입니다. 그래서 관련 데이터를 한 문서에 담을 수 있다면 트랜잭션이 필요 없습니다. 계좌 이체처럼 여러 문서를 함께 바꿔야 할 때만 트랜잭션을 씁니다.

#include <mongocxx/client_session.hpp>

void transfer(mongocxx::client& client) {
    auto accounts = client["mydb"]["accounts"];
    auto session = client.start_session();

    // with_transaction은 일시적 오류(TransientTransactionError)나
    // 커밋 결과 불명(UnknownTransactionCommitResult) 시 콜백과 커밋을 재시도합니다.
    session.with_transaction([&](mongocxx::client_session* s) {
        accounts.update_one(*s,
            make_document(kvp("_id", "account_a")),
            make_document(kvp("$inc", make_document(kvp("balance", -100)))));
        accounts.update_one(*s,
            make_document(kvp("_id", "account_b")),
            make_document(kvp("$inc", make_document(kvp("balance", 100)))));
    });
}

start_transaction / commit_transaction / abort_transaction을 직접 호출할 수도 있지만, 그러면 일시적 오류에 대한 재시도를 직접 구현해야 합니다. 트랜잭션은 레플리카셋(MongoDB 4.0 이상)이나 샤드 클러스터(4.2 이상)에서만 쓸 수 있고, 단독(standalone) 서버에서는 지원되지 않습니다. 개발 환경에서도 트랜잭션을 테스트하려면 mongod --replSet rs0로 띄우고 rs.initiate()로 노드 하나짜리 레플리카셋을 만들면 됩니다.


집계 파이프라인

집계 파이프라인은 문서가 여러 단계(stage)를 차례로 거치며 변환되는 구조입니다. $match는 SQL의 WHERE, $group은 GROUP BY, $sort는 ORDER BY, $project는 SELECT 목록에 대응합니다.

flowchart LR
    Docs[문서들] --> S1["$match: 필터"]
    S1 --> S2["$group: 그룹핑"]
    S2 --> S3["$sort: 정렬"]
    S3 --> S4["$project: 필드 선택"]
    S4 --> Result[결과]

일별 활성 사용자

#include <mongocxx/pipeline.hpp>

void daily_active_users(mongocxx::collection& events) {
    using namespace std::chrono;
    mongocxx::pipeline stages;
    stages
        .match(make_document(kvp("createdAt", make_document(kvp(
            "$gte", bsoncxx::types::b_date{system_clock::now() - hours(24 * 7)})))))
        .group(make_document(
            kvp("_id", make_document(kvp("$dateToString", make_document(
                kvp("format", "%Y-%m-%d"), kvp("date", "$createdAt"))))),
            kvp("users", make_document(kvp("$addToSet", "$userId")))))
        .project(make_document(
            kvp("activeUsers", make_document(kvp("$size", "$users")))))
        .sort(make_document(kvp("_id", 1)));

    for (auto&& doc : events.aggregate(stages)) {
        std::cout << bsoncxx::to_json(doc) << "\n";
    }
}

$addToSet으로 사용자 ID 집합을 만든 뒤 $size로 개수를 셉니다. $sum: 1로 세면 사용자 수가 아니라 이벤트 수가 됩니다. $dateToString은 기본적으로 UTC 기준으로 날짜를 자르므로, 한국 시간 기준 일자가 필요하면 timezone: "Asia/Seoul"을 함께 넘깁니다. 첫 단계의 $match가 createdAt 인덱스를 쓸 수 있도록 필터는 가능한 한 파이프라인 앞쪽에 둡니다.

$lookup으로 다른 컬렉션 결합

#include <bsoncxx/builder/basic/array.hpp>
using bsoncxx::builder::basic::make_array;

void orders_with_user(mongocxx::collection& orders) {
    mongocxx::pipeline stages;
    stages.lookup(make_document(
        kvp("from", "users"),
        kvp("localField", "userId"),
        kvp("foreignField", "_id"),
        kvp("as", "userInfo")));
    stages.project(make_document(
        kvp("amount", 1),
        kvp("userName", make_document(kvp("$arrayElemAt", make_array("$userInfo.name", 0))))));

    for (auto&& doc : orders.aggregate(stages)) {
        std::cout << bsoncxx::to_json(doc) << "\n";
    }
}

$lookup의 결과는 항상 배열이라서, 하나만 매칭되는 관계라도 $arrayElemAt이나 $unwind로 꺼내야 합니다. 주문마다 users 컬렉션을 조회하므로 foreignField에 인덱스가 없으면 비용이 커집니다(여기서는 _id라 기본 인덱스가 있습니다).

큰 집계와 allowDiskUse

#include <mongocxx/options/aggregate.hpp>

void top_cuisines(mongocxx::collection& restaurants) {
    mongocxx::pipeline stages;
    stages
        .match(make_document(kvp("rating", make_document(kvp("$exists", true)))))
        .group(make_document(
            kvp("_id", "$cuisine"),
            kvp("avgRating", make_document(kvp("$avg", "$rating"))),
            kvp("count", make_document(kvp("$sum", 1)))))
        .sort(make_document(kvp("avgRating", -1)))
        .limit(5);

    mongocxx::options::aggregate opts{};
    opts.allow_disk_use(true);
    for (auto&& doc : restaurants.aggregate(stages, opts)) {
        std::cout << bsoncxx::to_json(doc) << "\n";
    }
}

$group이나 $sort 단계는 단계별로 100MB의 메모리 제한이 있고, 이를 넘으면 오류가 납니다. allowDiskUse를 켜면 임시 파일을 써서 계속 진행합니다. MongoDB 6.0부터는 서버 파라미터 allowDiskUseByDefault가 기본으로 켜져 있어서 명시하지 않아도 디스크를 쓸 수 있지만, 이전 버전과의 호환을 위해 명시해 두는 경우가 많습니다.


인덱스

인덱스가 없는 필드로 조회하면 서버는 컬렉션 전체를 훑는 COLLSCAN을 하고, 인덱스가 있으면 IXSCAN으로 필요한 문서만 찾습니다.

#include <mongocxx/options/index.hpp>

void create_indexes(mongocxx::collection& collection) {
    collection.create_index(make_document(kvp("userId", 1)));

    // 복합 인덱스: userId로 거르고 createdAt 역순으로 정렬하는 조회에 맞춤
    collection.create_index(make_document(kvp("userId", 1), kvp("createdAt", -1)));

    mongocxx::options::index unique_opts{};
    unique_opts.unique(true);
    collection.create_index(make_document(kvp("email", 1)), unique_opts);

    // TTL 인덱스: createdAt으로부터 7일이 지난 문서를 자동 삭제
    mongocxx::options::index ttl_opts{};
    ttl_opts.expire_after(std::chrono::seconds(7 * 24 * 60 * 60));
    collection.create_index(make_document(kvp("createdAt", 1)), ttl_opts);
}

복합 인덱스는 필드 순서가 중요합니다. {userId: 1, createdAt: -1} 인덱스는 userId만으로 조회할 때도 쓰이지만, createdAt만으로 조회할 때는 쓰이지 않습니다. 등호 조건 필드를 앞에, 정렬 필드를 그다음에, 범위 조건 필드를 뒤에 두는 것이 일반적인 원칙입니다(ESR 규칙). TTL 인덱스의 삭제는 백그라운드 작업이 대략 60초 간격으로 수행하므로, 만료 시점과 실제 삭제 시점 사이에 지연이 있습니다. 대상 필드는 Date 타입이어야 합니다.

create_index는 이미 같은 인덱스가 있으면 아무 일도 하지 않으므로 시작 시 매번 호출해도 되지만, 같은 키에 다른 옵션으로 만들려고 하면 오류가 납니다. 조회가 인덱스를 쓰는지는 explain 명령으로 확인합니다. mongocxx의 find 옵션에는 explain 설정이 없으므로 명령을 직접 실행합니다.

auto plan = client["mydb"].run_command(make_document(
    kvp("explain", make_document(
        kvp("find", "events"),
        kvp("filter", make_document(kvp("userId", "user123"))))),
    kvp("verbosity", "queryPlanner")));
std::cout << bsoncxx::to_json(plan) << "\n";   // winningPlan에서 IXSCAN/COLLSCAN 확인

레플리카셋과 Read/Write Concern

레플리카셋은 쓰기를 받는 Primary 하나와 이를 복제하는 Secondary 여러 개로 구성됩니다. 드라이버는 URI에 적힌 호스트 중 하나에 접속해 전체 구성을 파악하고, Primary가 바뀌면 자동으로 새 Primary를 찾아갑니다. 기본적으로 읽기도 Primary에서 하며, read preference로 Secondary 읽기를 허용할 수 있습니다.

flowchart TB
    subgraph RS[레플리카셋]
        P[Primary]
        S1[Secondary 1]
        S2[Secondary 2]
    end
    App[C++ 앱] -->|쓰기| P
    App -->|읽기 기본값| P
    App -.->|secondaryPreferred| S1
    App -.->|secondaryPreferred| S2
#include <mongocxx/read_preference.hpp>
#include <mongocxx/write_concern.hpp>

void replica_options(mongocxx::collection& collection) {
    mongocxx::read_preference rp{};
    rp.mode(mongocxx::read_preference::read_mode::k_secondary_preferred);
    mongocxx::options::find find_opts{};
    find_opts.read_preference(rp);
    auto cursor = collection.find({}, find_opts);

    mongocxx::write_concern wc{};
    wc.acknowledge_level(mongocxx::write_concern::level::k_majority);
    wc.timeout(std::chrono::milliseconds{5000});
    mongocxx::options::insert insert_opts{};
    insert_opts.write_concern(wc);
    collection.insert_one(make_document(kvp("event", "login")), insert_opts);
}

Secondary에서 읽으면 Primary의 부하를 덜 수 있지만, 복제 지연만큼 오래된 데이터를 읽을 수 있습니다. 방금 쓴 데이터를 바로 읽어야 하는 흐름이라면 Primary에서 읽어야 합니다. w: majority는 과반수 노드가 쓰기를 기록한 뒤에 응답하므로, Primary가 장애로 바뀌어도 그 쓰기가 롤백되지 않습니다. 이때 wtimeout을 넘기면 오류가 나지만, 쓰기 자체가 취소되는 것은 아니라는 점에 주의해야 합니다. 이미 Primary에는 기록되었고 복제 확인만 시간 안에 못 받은 것이므로, 오류를 받았다고 같은 쓰기를 그대로 다시 보내면 중복이 생길 수 있습니다.


자주 만나는 오류

중복 키 (E11000)

_id나 유니크 인덱스가 걸린 필드에 이미 있는 값을 넣으면 insert_one, insert_many가 mongocxx::bulk_write_exception을 던집니다. 서버 오류 코드는 code()로 얻을 수 있습니다.

#include <mongocxx/exception/bulk_write_exception.hpp>

bool insert_if_absent(mongocxx::collection& coll, bsoncxx::document::view doc) {
    try {
        coll.insert_one(doc);
        return true;
    } catch (const mongocxx::bulk_write_exception& e) {
        if (e.code().value() == 11000) {
            return false;   // 이미 존재
        }
        throw;              // 다른 오류는 그대로 전파
    }
}

“없으면 넣고 있으면 갱신”이 목적이라면 예외를 처리하기보다 처음부터 update_one + upsert(true)를 쓰는 편이 간단합니다. insert_many는 기본적으로 순서 있는(ordered) 삽입이라 중간에 중복이 나면 나머지 문서를 넣지 않고 멈춥니다. 중복 문서만 건너뛰고 나머지를 계속 넣으려면 mongocxx::options::insert의 ordered(false)를 씁니다.

view 수명 문제

bsoncxx::document::view는 데이터를 소유하지 않습니다. 다음 함수는 반환하는 순간 doc이 파괴되므로 dangling view를 돌려줍니다.

// 잘못된 예
bsoncxx::document::view getFilter() {
    auto doc = make_document(kvp("name", "test"));
    return doc.view();
}

// 올바른 예: value를 반환하고 호출자가 보관
bsoncxx::document::value getFilter2() {
    return make_document(kvp("name", "test"));
}
auto filter = getFilter2();
collection.find_one(filter.view());

이런 코드는 컴파일 경고 없이 빌드되고, 해제된 메모리가 아직 덮어써지지 않았다면 정상처럼 동작하다가 가끔 이상한 데이터나 크래시를 냅니다. AddressSanitizer(-fsanitize=address)로 빌드하면 heap-use-after-free로 바로 잡힙니다.

연결 실패

서버가 꺼져 있거나 주소, 포트, 방화벽 설정이 틀리면 첫 작업에서 서버 선택 시간 초과 예외가 납니다. 먼저 mongosh --eval "db.adminCommand('ping')"로 같은 URI에 접속되는지 확인합니다. Docker로 띄운 서버에 다른 컨테이너에서 접속한다면 localhost가 아니라 컨테이너 이름이나 네트워크 주소를 써야 하고, 레플리카셋이라면 rs.conf()에 등록된 호스트 이름이 클라이언트에서 해석 가능해야 합니다. 드라이버는 URI의 호스트가 아니라 레플리카셋 구성에 적힌 호스트 이름으로 다시 접속하기 때문입니다.


멀티스레드와 연결 풀

mongocxx::client는 스레드 안전하지 않습니다. 하나의 client(또는 거기서 얻은 database, collection, cursor)를 여러 스레드가 동시에 쓰면 안 됩니다. 멀티스레드 서버에서는 mongocxx::pool을 하나 만들고, 각 스레드가 작업할 때마다 풀에서 client를 빌려 씁니다.

#include <mongocxx/pool.hpp>

mongocxx::instance inst{};
mongocxx::pool pool{mongocxx::uri{"mongodb://localhost:27017/?maxPoolSize=50"}};

void handle_request(const std::string& userId) {
    auto client = pool.acquire();   // 스코프를 벗어나면 풀로 반환
    auto users = (*client)["mydb"]["users"];
    auto doc = users.find_one(make_document(kvp("_id", userId)));
    // ...
}

maxPoolSize는 풀이 동시에 빌려줄 수 있는 client 수의 상한이고 기본값은 100입니다. 모두 빌려 간 상태에서 acquire()를 호출하면 반환될 때까지 기다리므로, 동시에 DB 작업을 하는 스레드 수에 맞춰 정합니다. 단일 스레드 프로그램이라면 mongocxx::client 하나를 만들어 계속 재사용하면 됩니다. 어느 쪽이든 요청마다 client를 새로 만드는 것은 피해야 합니다. client마다 서버 탐색과 연결 수립, 인증을 다시 하기 때문입니다.

mongocxx::instance는 프로세스에 하나만 있어야 하므로, client를 감싸는 래퍼 클래스의 멤버로 instance를 두면 래퍼를 두 번 만드는 순간 예외가 납니다. instance는 main에서 만들거나 함수 내 static 객체로 한 번만 만들도록 분리합니다.

재시도

MongoDB 4.2 이상의 서버와 최신 드라이버에서는 retryWrites와 retryReads가 기본으로 켜져 있어서, Primary 교체 같은 일시적 네트워크 오류가 나면 드라이버가 단일 쓰기와 읽기를 한 번 자동으로 재시도합니다. 애플리케이션 수준에서 재시도를 더 넣을 때는 재시도해도 안전한 오류만 골라야 합니다.

#include <mongocxx/exception/operation_exception.hpp>
#include <thread>

template <typename Func>
auto withRetry(Func&& f, int maxAttempts = 3) -> decltype(f()) {
    for (int attempt = 1;; ++attempt) {
        try {
            return f();
        } catch (const mongocxx::operation_exception& e) {
            bool transient = e.has_error_label("RetryableWriteError") ||
                             e.has_error_label("TransientTransactionError");
            if (!transient || attempt == maxAttempts) throw;
            std::this_thread::sleep_for(std::chrono::milliseconds(100 * (1 << (attempt - 1))));
        }
    }
}

모든 예외를 잡아 재시도하면 중복 키 오류나 문법 오류처럼 다시 해도 같은 결과가 나오는 오류에 시간만 쓰고, $inc처럼 멱등이 아닌 쓰기는 응답만 유실된 경우 두 번 적용될 수 있습니다. 서버가 붙여 주는 오류 레이블로 재시도 가능 여부를 판단하는 것이 안전합니다.

다음 글: MongoDB 드라이버 고급: 집계·인덱싱·레플리카셋(#52-4) 이전 글: C++에서 Redis 쓰기


참고 자료


같이 보면 좋은 글