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", "홍길동", "hong@example.com", 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"을 함께 넘깁니다. MongoDB 5.0 이상이라면 $dateTrunc(unit: "day" 또는 "hour", timezone 지정)가 날짜를 Date 타입 그대로 잘라 주므로, 문자열을 만드는 것보다 정렬과 후처리가 편합니다. 첫 단계의 $match가 createdAt 인덱스를 쓸 수 있도록 필터는 가능한 한 파이프라인 앞쪽에 둡니다. $group 뒤에서는 인덱스를 쓸 수 없습니다.

$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로 꺼내야 합니다. $unwind를 쓸 때 preserveNullAndEmptyArrays: true를 주지 않으면 매칭되는 사용자가 없는 주문은 결과에서 사라집니다. 주문마다 users 컬렉션을 조회하므로 foreignField에 인덱스가 없으면 입력 문서 수만큼 컬렉션 스캔이 반복됩니다(여기서는 _id라 기본 인덱스가 있습니다).

$lookup의 결과가 늘 빈 배열이라면 거의 항상 두 필드의 타입이 다른 경우입니다. 주문 문서의 userId는 문자열 "64f..."인데 users._id는 ObjectId라면 값이 같아 보여도 매칭되지 않습니다. 저장할 때부터 타입을 통일하는 것이 가장 좋고, 이미 섞여 있다면 lookup 전에 변환합니다.

stages.add_fields(make_document(
    kvp("userOid", make_document(kvp("$toObjectId", "$userId")))));
stages.lookup(make_document(
    kvp("from", "users"),
    kvp("localField", "userOid"),
    kvp("foreignField", "_id"),
    kvp("as", "userInfo")));

$toObjectId는 24자리 16진수가 아닌 문자열을 만나면 오류를 내므로, 형식이 섞여 있다면 $convert에 onError를 지정해 씁니다.

$facet과 $merge: 대시보드 통계

같은 입력으로 일별 매출과 상위 사용자처럼 여러 통계를 한 번에 만들 때는 $facet을 씁니다.

void dashboard(mongocxx::collection& orders) {
    mongocxx::pipeline p;
    p.match(make_document(kvp("status", "paid")));   // 공통 필터는 $facet 앞에
    p.facet(make_document(
        kvp("byDay", make_array(
            make_document(kvp("$group", make_document(
                kvp("_id", make_document(kvp("$dateTrunc", make_document(
                    kvp("date", "$createdAt"), kvp("unit", "day"))))),
                kvp("total", make_document(kvp("$sum", "$amount")))))),
            make_document(kvp("$sort", make_document(kvp("_id", 1)))))),
        kvp("topUsers", make_array(
            make_document(kvp("$group", make_document(
                kvp("_id", "$userId"),
                kvp("orders", make_document(kvp("$sum", 1)))))),
            make_document(kvp("$sort", make_document(kvp("orders", -1)))),
            make_document(kvp("$limit", 10))))));

    for (auto&& doc : orders.aggregate(p)) {
        std::cout << bsoncxx::to_json(doc) << "\n";   // {byDay: [...], topUsers: [...]} 문서 하나
    }
}

$facet의 하위 파이프라인은 병렬로 실행되지 않으며, 이득은 입력을 한 번만 읽고 왕복을 한 번으로 줄이는 데 있습니다. 하위 파이프라인 안에서는 인덱스를 쓸 수 없으므로 공통 필터는 앞의 $match에 두고, 결과가 문서 하나라 BSON 크기 제한인 16MB를 넘을 수 없으므로 하위 결과가 클 수 있다면 $limit으로 자릅니다.

대시보드가 열릴 때마다 큰 집계를 돌리는 대신, 주기적으로 집계해 결과를 별도 컬렉션에 저장해 두고 그것을 읽는 방법도 흔합니다. 파이프라인 끝에 $merge(MongoDB 4.2 이상)를 붙이면 됩니다.

p.merge(make_document(
    kvp("into", "daily_stats"),
    kvp("on", "_id"),
    kvp("whenMatched", "replace"),
    kvp("whenNotMatched", "insert")));
auto cursor = db["events"].aggregate(p);
for (auto&& _ : cursor) { (void)_; }   // 커서를 순회해야 서버에 명령이 나간다

$merge는 지정한 키로 기존 문서와 맞춰 갱신하거나 삽입하므로, 최근 이틀 치만 다시 집계해도 오래된 날짜의 통계는 그대로 남습니다. $out은 대상 컬렉션 전체를 결과로 교체하므로 증분 갱신에는 맞지 않습니다. mongocxx의 aggregate는 커서를 돌려주기만 하고 실제 명령은 커서를 처음 순회할 때 보내므로, $merge처럼 결과를 쓰지 않는 파이프라인도 위처럼 빈 루프를 돌려야 실행됩니다.

큰 집계와 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가 기본으로 켜져 있어서 명시하지 않아도 디스크를 쓸 수 있지만, 이전 버전과의 호환을 위해 명시해 두는 경우가 많습니다. 디스크를 쓰면 느려지므로 먼저 $match와 $project로 그룹화할 문서 수와 필드를 줄이는 것이 우선입니다.


인덱스

인덱스가 없는 필드로 조회하면 서버는 컬렉션 전체를 훑는 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 타입이어야 합니다.

범위 조건을 정렬 필드보다 앞에 두면, 범위에 걸리는 여러 구간을 합친 뒤 메모리에서 다시 정렬해야 해서(SORT 스테이지) 인덱스의 정렬 순서를 쓸 수 없습니다.

위의 일반 유니크 인덱스는 필드가 없는 문서도 null로 인덱싱하므로, email이 없는 문서가 둘만 되어도 중복 키 오류가 납니다. 부분 인덱스로 대상 문서를 제한하면 이 문제를 피하고 인덱스 크기도 줄어듭니다. 다만 조회 조건이 부분 인덱스의 필터 조건을 포함해야 그 인덱스가 선택됩니다.

mongocxx::options::index opts{};
opts.unique(true);
opts.partial_filter_expression(make_document(
    kvp("email", make_document(kvp("$exists", true)))));
users.create_index(make_document(kvp("email", 1)), opts);

텍스트 검색용 텍스트 인덱스는 컬렉션당 하나만 만들 수 있으므로 검색 대상 필드를 한 인덱스에 모두 넣습니다. 언어별 어간 추출과 불용어 처리를 하는데 지원 언어에 한국어는 없어서, 한국어 본문이라면 default_language("none")으로 단순 토큰 매칭을 하거나 형태소 분석이 필요한 수준이면 Atlas Search나 Elasticsearch 같은 별도 검색 엔진을 쓰는 편이 현실적입니다.

create_index는 이미 같은 인덱스가 있으면 아무 일도 하지 않으므로 시작 시 매번 호출해도 되지만, 같은 키에 다른 옵션(예: unique 여부)으로 만들려고 하면 IndexOptionsConflict(코드 85)나 IndexKeySpecsConflict(코드 86) 오류가 납니다. 이 오류를 잡아 무시하면 의도한 옵션이 적용되지 않은 채 운영하게 되므로, 기존 인덱스를 지우고 다시 만들어야 하는지 확인하는 신호로 다룹니다. 데이터가 이미 많은 컬렉션에 유니크 인덱스를 만들 때 중복이 있으면 생성이 실패하므로, 먼저 집계로 중복을 찾아 정리합니다. 조회가 인덱스를 쓰는지는 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 확인

결과에서 볼 부분은 세 가지입니다. winningPlan에 IXSCAN이 있는지(COLLSCAN이면 인덱스를 못 쓴 것), SORT 스테이지가 있는지(있으면 인덱스 순서로 정렬하지 못하고 메모리에서 정렬한 것), 그리고 verbosity를 executionStats로 했을 때 totalDocsExamined가 nReturned보다 훨씬 큰지입니다. 반환한 문서 수에 비해 읽은 문서가 많다면 인덱스가 조건을 충분히 좁히지 못하고 있다는 뜻입니다.


레플리카셋과 Read/Write Concern

레플리카셋은 쓰기를 받는 Primary 하나와 이를 복제하는 Secondary 여러 개로 구성됩니다. URI에 replicaSet 이름과 호스트 목록을 주면(mongodb://host1:27017,host2:27017,host3:27017/?replicaSet=rs0) 드라이버가 전체 구성을 파악하고, Primary가 바뀌면 자동으로 새 Primary를 찾아갑니다. 기본적으로 읽기도 Primary에서 하며, read preference로 Secondary 읽기를 허용할 수 있습니다.

replicaSet 옵션 없이 호스트 하나만 적으면 드라이버는 그 서버에 직접 연결합니다. 그 서버가 지금 Primary라면 문제없이 동작하다가, 장애 조치로 Secondary가 되는 순간 쓰기가 NotWritablePrimary 오류로 실패하기 시작합니다. 평소에는 드러나지 않다가 장애 조치 때 터지는 설정 실수이므로, 레플리카셋에는 항상 replicaSet 이름을 넣습니다.

Read Preference동작
k_primaryPrimary에서만 읽음(기본값). Primary가 없으면 실패
k_primary_preferredPrimary 우선, 없으면 Secondary
k_secondarySecondary에서만 읽음. Secondary가 없으면 실패
k_secondary_preferredSecondary 우선, 없으면 Primary
k_nearest응답 지연이 가장 짧은 노드(일정 범위 안에서 무작위)
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에서 읽어야 합니다. rp.max_staleness(std::chrono::seconds{90})처럼 복제 지연이 큰 Secondary를 제외할 수 있는데, 이 값은 90초 이상으로만 설정할 수 있습니다. Secondary로 읽기를 분산해도 쓰기 부하는 줄지 않습니다. Secondary도 같은 쓰기를 모두 복제 적용하고 있기 때문입니다. 설정했는데도 Primary에서 읽힌다면 옵션 객체를 find/aggregate 호출에 실제로 넘겼는지, URI에 replicaSet이 있는지 확인합니다. 컬렉션 단위 기본값은 collection.read_preference(rp)로 정할 수 있습니다. w: majority는 과반수 노드가 쓰기를 기록한 뒤에 응답하므로, Primary가 장애로 바뀌어도 그 쓰기가 롤백되지 않습니다. 이때 wtimeout을 넘기면 오류가 나지만, 쓰기 자체가 취소되는 것은 아니라는 점에 주의해야 합니다. 이미 Primary에는 기록되었고 복제 확인만 시간 안에 못 받은 것이므로, 오류를 받았다고 같은 쓰기를 그대로 다시 보내면 중복이 생길 수 있습니다. 재시도할 쓰기는 upsert나 고유 키로 멱등하게 만들어 둡니다. 결제처럼 내구성이 중요한 쓰기는 wc.journal(true)로 저널 기록까지 확인한 뒤 응답받게 할 수 있습니다. MongoDB 5.0부터는 대부분의 구성에서 기본 write concern이 이미 majority입니다.


자주 만나는 오류

중복 키 (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());

find·aggregate 옵션에 정렬이나 프로젝션을 make_document(...).view() 형태로 넘기는 것도 같은 문제입니다. 임시 value가 그 문장 끝에서 파괴되어 실제 호출 시점에는 해제된 메모리를 가리키므로, 옵션에는 value를 그대로 넘기거나 변수에 담아 호출이 끝날 때까지 살려 둡니다.

이런 코드는 컴파일 경고 없이 빌드되고, 해제된 메모리가 아직 덮어써지지 않았다면 정상처럼 동작하다가 가끔 이상한 데이터나 크래시를 냅니다. 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처럼 멱등이 아닌 쓰기는 응답만 유실된 경우 두 번 적용될 수 있습니다. 서버가 붙여 주는 오류 레이블로 재시도 가능 여부를 판단하는 것이 안전합니다.

다음 글: C++에서 PostgreSQL 연동(#52-4) 이전 글: C++에서 Redis 쓰기


참고 자료


같이 보면 좋은 글