C++ 코드 생성 방식 비교: 템플릿, X-Macro, Clang libTooling, Protobuf, C++26 리플렉션
들어가며: 구조체가 50개인데 to_json을 50번 쓰고 있다면
C++에는 Java·C#처럼 런타임 리플렉션이 없어서, 직렬화·RPC 스텁·에러 코드 매핑처럼 “타입이나 목록 정의를 보고 기계적으로 만드는 코드”를 손으로 써 왔습니다. 구조체가 몇 개일 때는 괜찮지만, 수십 개로 늘어나면 멤버를 추가하고 직렬화 함수 수정을 잊는 실수가 반복됩니다.
코드 생성은 정의를 한 곳에만 두고 나머지를 기계가 만들게 하는 방법입니다. 템플릿과 매크로는 컴파일 과정 안에서, Clang 도구·Python 스크립트·protoc 같은 외부 생성기는 컴파일 전에 코드를 만듭니다. C++26 리플렉션은 그 중간에 있어서, 외부 도구 없이 컴파일러가 타입 정보를 보고 코드를 펼치게 해 줍니다. 이 글은 각 방식을 예제로 비교하고, 빌드에 통합할 때 자주 생기는 문제를 정리합니다.
요구 환경: C++17 이상 (일부 예제는 C++20, 리플렉션 예제는 C++26 구현이 있는 컴파일러)
손으로 쓰던 코드를 생성해야 하는 순간
네트워크 패킷 직렬화
서버와 클라이언트가 LoginRequest, ChatMessage, InventoryUpdate 같은 패킷 수십 종을 주고받는다면, 각각의 직렬화 코드를 손으로 쓰는 순간 멤버 추가·삭제 때 누락이 생깁니다. 다른 언어의 클라이언트와도 통신한다면 protobuf나 flatbuffers로 .proto·.fbs 정의만 두고 빌드 시 코드를 생성하는 것이 표준적인 해법입니다. C++끼리만 쓰는 내부 패킷이라면 X-Macros나 리플렉션으로도 충분합니다.
에러 코드와 문자열 매핑
enum class ErrorCode가 수십 개 이상이면 로깅용 errorToString의 switch문이 enum과 따로 놀기 쉽습니다. X-Macros로 목록을 한 번 정의하고 enum·switch·문자열 배열을 같은 목록에서 만들면, 항목을 추가할 때 한 줄만 고치면 됩니다.
RPC 스텁
gRPC·Thrift처럼 IDL(Interface Definition Language)로 서비스를 정의하면 서버·클라이언트 스텁을 생성기가 만들어 줍니다. 자체 RPC 프로토콜이라면 간단한 IDL을 정의하고 Python 스크립트로 C++ 헤더·소스를 출력하는 방식이 흔합니다.
리플렉션 등록 코드
리플렉션 글처럼 타입별 TypeInfo 등록을 수동으로 하면 구조체가 늘수록 등록 코드도 늘어납니다. 매크로로 반복을 줄이거나, Clang 기반 도구로 헤더를 파싱해 *_reflection.generated.cpp를 만들거나, C++26 리플렉션으로 등록 자체를 없앨 수 있습니다.
테스트용 mock
인터페이스가 많으면 mock 클래스를 손으로 쓰는 것도 부담입니다. gMock의 MOCK_METHOD로 직접 쓰는 양을 줄이거나, 인터페이스 헤더를 읽어 mock 소스를 출력하는 스크립트를 둡니다.
템플릿·매크로·외부 도구 중 무엇을 고를까
| 방식 | 시점 | 용도 | 장점 | 단점 |
|---|---|---|---|---|
| 템플릿 | 컴파일 타임 | 타입별 코드 생성 | 표준, 의존성 없음 | 멤버 목록은 여전히 손으로 나열, 컴파일 시간 증가 |
| 매크로/X-Macros | 전처리 | enum·switch·배열 동시 생성 | 단순, 빌드 도구 불필요 | 가독성 저하, 디버깅 어려움 |
| C++26 리플렉션 | 컴파일 타임 | 멤버 순회, enum 이름 | 외부 도구 없이 타입 정의가 원본 | 컴파일러 지원이 아직 제한적 |
| Clang libTooling | 빌드 전 | AST 분석·소스 생성 | C++ 문법을 컴파일러와 같게 이해 | 빌드 복잡, LLVM 버전 의존 |
| protobuf/flatbuffers | 빌드 전 | 직렬화·RPC 스텁 | 여러 언어 지원, 검증된 호환성 규칙 | 외부 의존성, 스키마 관리 |
| Python/스크립트 | 빌드 전 | 커스텀 코드 생성 | 유연, 빠른 프로토타입 | 입력 형식을 직접 설계·유지 |
flowchart TD
A[코드 생성 필요] --> B{원본 정의가 어디에 있나?}
B -->|proto/IDL| C[protobuf/Thrift]
B -->|C++ 헤더| D[C++26 리플렉션 또는 Clang libTooling]
B -->|enum/목록만| E[X-Macros]
B -->|타입별 로직| F[템플릿]
C --> G[직렬화/RPC]
D --> H[직렬화/등록/스텁]
E --> I[에러 매핑/패킷]
F --> J[컴파일 타임 생성]
템플릿 기반 코드 생성
핵심 개념
템플릿은 사용된 타입마다 별도 코드를 컴파일 타임에 만들어 냅니다. std::vector<int>와 std::vector<double>이 서로 다른 클래스인 것과 같은 원리입니다. 다만 템플릿만으로는 구조체의 멤버 목록을 알 수 없으므로, 멤버를 나열하는 부분은 사람이 써야 합니다.
// template_codegen.cpp
#include <iostream>
#include <string>
#include <sstream>
// 타입별로 다른 직렬화 로직 — 컴파일 시점에 선택
template <typename T>
std::string to_string_impl(const T& value);
template <>
std::string to_string_impl<int>(const int& value) {
return std::to_string(value);
}
template <>
std::string to_string_impl<double>(const double& value) {
return std::to_string(value);
}
template <>
std::string to_string_impl<std::string>(const std::string& value) {
return "\"" + value + "\"";
}
template <typename T>
struct Serializer;
struct User {
int id;
std::string name;
};
// 멤버 나열은 여전히 수동
template <>
struct Serializer<User> {
static std::string to_json(const User& u) {
std::ostringstream oss;
oss << "{\"id\":" << u.id << ",\"name\":" << to_string_impl(u.name) << "}";
return oss.str();
}
};
int main() {
User u{1, "Alice"};
std::cout << Serializer<User>::to_json(u) << "\n";
return 0;
}
variadic 템플릿으로 N개 타입 처리
// variadic_visitor.cpp
#include <iostream>
#include <string>
#include <variant>
template <typename... Ts>
struct Visitor : Ts... {
using Ts::operator()...;
};
template <typename... Ts>
Visitor(Ts...) -> Visitor<Ts...>;
int main() {
std::variant<int, double, std::string> v = 3.14;
std::visit(Visitor{
[](int i) { std::cout << "int: " << i << "\n"; },
[](double d) { std::cout << "double: " << d << "\n"; },
[](const std::string& s) { std::cout << "string: " << s << "\n"; }
}, v);
return 0;
}
Visitor는 각 람다를 상속해 operator()를 모두 가져오고, std::visit이 variant에 들어 있는 타입에 맞는 오버로드를 호출합니다. variant에 타입을 추가했는데 대응하는 람다를 빠뜨리면 컴파일 에러가 나므로, 처리 누락을 컴파일러가 잡아 줍니다. 마지막 줄의 추론 가이드는 C++17에서 필요하고, C++20부터는 집합체 CTAD 덕분에 생략할 수 있습니다.
매크로·X-Macros 기반
X-Macros 패턴
X-Macros는 한 곳에 데이터 목록을 정의하고, 목록에 넘기는 매크로를 바꿔 가며 여러 형태의 코드를 생성합니다.
// xmacro_errors.cpp
#include <iostream>
#include <string>
// 1. 에러 목록 정의 (단일 소스)
#define ERROR_LIST(X) \
X(NotFound, 404, "Resource not found") \
X(Unauthorized, 401, "Unauthorized") \
X(Timeout, 408, "Request timeout") \
X(InternalError, 500, "Internal server error")
// 2. enum 생성
#define EXPAND_ENUM(name, code, msg) name,
enum class ErrorCode { ERROR_LIST(EXPAND_ENUM) Count };
// 3. HTTP 코드 배열 생성
#define EXPAND_CODE(name, code, msg) code,
static const int g_error_codes[] = { ERROR_LIST(EXPAND_CODE) };
// 4. 메시지 배열 생성
#define EXPAND_MSG(name, code, msg) msg,
static const char* const g_error_messages[] = { ERROR_LIST(EXPAND_MSG) };
const char* errorToString(ErrorCode e) {
size_t i = static_cast<size_t>(e);
if (i >= sizeof(g_error_messages) / sizeof(g_error_messages[0]))
return "Unknown";
return g_error_messages[i];
}
int errorToHttpCode(ErrorCode e) {
size_t i = static_cast<size_t>(e);
if (i >= sizeof(g_error_codes) / sizeof(g_error_codes[0]))
return 500;
return g_error_codes[i];
}
int main() {
std::cout << errorToString(ErrorCode::Timeout) << "\n"; // Request timeout
std::cout << errorToHttpCode(ErrorCode::NotFound) << "\n"; // 404
return 0;
}
ERROR_LIST에 한 줄만 추가하면 enum, 코드 배열, 메시지 배열이 모두 함께 갱신됩니다. 배열 인덱스로 enum 값을 쓰므로, enum 값은 0부터 연속이어야 한다는 전제를 지켜야 합니다.
패킷 정의에서 구조체와 직렬화 생성
패킷마다 필드 목록을 별도의 X-Macro로 두면, 구조체 선언과 직렬화 함수를 같은 정의에서 만들 수 있습니다.
// xmacro_packets.cpp
#include <iostream>
#include <sstream>
#include <string>
inline std::string json_value(int v) { return std::to_string(v); }
inline std::string json_value(const std::string& s) { return "\"" + s + "\""; }
// 패킷별 필드 목록: F(타입, 이름)
#define LOGIN_FIELDS(F) F(int, userId) F(std::string, token)
#define CHAT_FIELDS(F) F(int, roomId) F(std::string, message)
#define LOGOUT_FIELDS(F) F(int, userId)
// 패킷 목록: X(이름, 필드 목록 매크로)
#define PACKET_LIST(X) \
X(Login, LOGIN_FIELDS) \
X(Chat, CHAT_FIELDS) \
X(Logout, LOGOUT_FIELDS)
// 구조체 선언
#define DECLARE_FIELD(type, name) type name;
#define EXPAND_STRUCT(pkt, FIELDS) struct pkt##Packet { FIELDS(DECLARE_FIELD) };
PACKET_LIST(EXPAND_STRUCT)
// to_json 생성
#define WRITE_FIELD(type, name) oss << ",\"" #name "\":" << json_value(p.name);
#define EXPAND_TO_JSON(pkt, FIELDS) \
inline std::string to_json(const pkt##Packet& p) { \
std::ostringstream oss; \
oss << "{\"type\":\"" #pkt "\""; \
FIELDS(WRITE_FIELD) \
oss << "}"; \
return oss.str(); \
}
PACKET_LIST(EXPAND_TO_JSON)
int main() {
LoginPacket p1{1, "abc"};
std::cout << to_json(p1) << "\n"; // {"type":"Login","userId":1,"token":"abc"}
return 0;
}
EXPAND_STRUCT의 치환 결과에 LOGIN_FIELDS(DECLARE_FIELD)가 생기고, 전처리기가 이를 다시 스캔하면서 필드 선언으로 펼칩니다. 필드를 하나 추가하면 구조체와 직렬화가 함께 바뀝니다. 다만 컴파일 에러 메시지가 매크로 전개 결과를 가리키므로, 목록이 이보다 복잡해지면 외부 생성기나 리플렉션으로 넘어가는 편이 낫습니다.
C++26 리플렉션으로 생성 단계 없애기
C++26에는 2025년 6월 정적 리플렉션(P2996)과 확장 문(template for)이 채택되었습니다. ^^T로 타입의 메타 정보를 얻고, 멤버 목록을 template for로 순회하며, obj.[:m:] 스플라이스로 해당 멤버에 접근합니다.
// reflection_to_json.cpp — C++26 리플렉션 구현이 있는 컴파일러 필요
#include <meta>
#include <string>
inline std::string json_value(int v) { return std::to_string(v); }
inline std::string json_value(const std::string& s) { return "\"" + s + "\""; }
template <typename T>
std::string to_json(const T& obj) {
std::string out = "{";
bool first = true;
template for (constexpr auto m : std::define_static_array(
std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::unchecked()))) {
if (!first) out += ",";
first = false;
out += "\"";
out += std::meta::identifier_of(m);
out += "\":";
out += json_value(obj.[:m:]);
}
return out + "}";
}
struct User {
int id;
std::string name;
};
// to_json(User{1, "Alice"}) → {"id":1,"name":"Alice"}
멤버를 추가하면 to_json은 아무것도 고치지 않아도 따라옵니다. 외부 생성기와 달리 빌드 단계가 늘지 않고, 생성 파일을 관리할 필요도 없습니다. 다만 2026년 기준으로 구현은 GCC 16(-std=c++26 -freflection)과 Compiler Explorer의 clang-p2996 실험 브랜치 정도로 제한되어 있고, 표준화 과정에서 API 이름이 여러 번 바뀌었습니다. 여러 컴파일러를 지원해야 하는 코드라면 기능 테스트 매크로로 X-Macros나 수동 구현을 대체 경로로 남겨 둬야 합니다. 자세한 문법은 C++26 리플렉션 기초에서 다룹니다.
Clang libTooling 소스 코드 생성
개요
Clang libTooling은 C++ 소스를 컴파일러와 같은 방식으로 파싱해 AST(Abstract Syntax Tree)를 만들고, 그 AST를 분석·변환하거나 새 소스를 출력할 수 있게 합니다. clang-tidy와 여러 리팩터링 도구가 이 기반 위에 만들어져 있습니다. C++26 리플렉션을 쓸 수 없는 환경에서 C++ 헤더를 원본으로 삼고 싶을 때의 현실적인 선택지입니다.
flowchart LR
A[.h/.cpp] --> B[Clang 파서]
B --> C[AST]
C --> D[RecursiveASTVisitor]
D --> E[코드 생성]
E --> F[.generated.cpp]
최소 예제: 구조체 멤버 추출
// gen_reflection.cpp — libTooling 기반 독립 실행 도구
#include "clang/AST/ASTConsumer.h"
#include "clang/AST/RecursiveASTVisitor.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendAction.h"
#include "clang/Tooling/Tooling.h"
using namespace clang;
class StructVisitor : public RecursiveASTVisitor<StructVisitor> {
public:
bool VisitRecordDecl(RecordDecl* D) {
if (D->isStruct() && D->isCompleteDefinition()) {
llvm::outs() << "struct " << D->getName() << " {\n";
for (auto* F : D->fields()) {
llvm::outs() << " " << F->getType().getAsString()
<< " " << F->getName() << ";\n";
}
llvm::outs() << "};\n";
}
return true;
}
};
class StructConsumer : public ASTConsumer {
public:
void HandleTranslationUnit(ASTContext& Ctx) override {
StructVisitor V;
V.TraverseDecl(Ctx.getTranslationUnitDecl());
}
};
class StructAction : public ASTFrontendAction {
public:
std::unique_ptr<ASTConsumer> CreateASTConsumer(
CompilerInstance&, StringRef) override {
return std::make_unique<StructConsumer>();
}
};
int main() {
// 문자열로 받은 코드를 파싱하는 가장 간단한 형태
return clang::tooling::runToolOnCode(
std::make_unique<StructAction>(), "struct User { int id; double score; };") ? 0 : 1;
}
runToolOnCode는 문자열 하나를 파싱하는 데모용 함수입니다. 실제 헤더 파일을 처리하려면 CommonOptionsParser로 명령줄과 compile_commands.json을 읽고 ClangTool::run을 호출해야, 헤더 검색 경로와 매크로 정의가 실제 빌드와 같아집니다. CMake에서는 find_package(Clang REQUIRED CONFIG) 후 target_link_libraries(gen_reflection PRIVATE clangTooling clangFrontend clangAST)처럼 연결하고, 빌드 중에 gen_reflection user.h -- -std=c++17 > user_reflection.generated.cpp 같은 명령을 add_custom_command로 실행합니다.
외부 도구: protobuf·flatbuffers
Protocol Buffers
protobuf는 .proto 스키마에서 C++·Python·Go 등 여러 언어의 직렬화 코드를 생성합니다.
// user.proto
syntax = "proto3";
message User {
int32 id = 1;
string name = 2;
string email = 3;
}
message LoginRequest {
string username = 1;
string password = 2;
}
# 코드 생성
protoc --cpp_out=. user.proto
# user.pb.h, user.pb.cc 생성
// protobuf_usage.cpp
#include "user.pb.h"
#include <iostream>
int main() {
User user;
user.set_id(1);
user.set_name("Alice");
user.set_email("[email protected]");
std::string serialized;
user.SerializeToString(&serialized);
User parsed;
if (!parsed.ParseFromString(serialized)) return 1;
std::cout << parsed.name() << "\n";
return 0;
}
FlatBuffers
FlatBuffers는 직렬화된 버퍼를 파싱 없이 바로 읽는 zero-copy 방식이라, 읽기 지연이 중요한 게임·임베디드에서 자주 쓰입니다.
// user.fbs
table User {
id: int;
name: string;
email: string;
}
root_type User;
flatc --cpp -o generated user.fbs
// flatbuffers_usage.cpp
#include "user_generated.h"
#include <iostream>
int main() {
flatbuffers::FlatBufferBuilder builder(1024);
auto name = builder.CreateString("Alice");
auto email = builder.CreateString("[email protected]");
auto user = CreateUser(builder, 1, name, email);
builder.Finish(user);
auto* u = flatbuffers::GetRoot<User>(builder.GetBufferPointer());
std::cout << u->name()->str() << "\n";
return 0;
}
외부에서 받은 버퍼라면 GetRoot 전에 flatbuffers::Verifier로 검증해야 합니다. 파싱 단계가 없다는 것은 잘못된 오프셋이 그대로 메모리 접근으로 이어진다는 뜻이기도 합니다.
에러 시스템·enum 생성·테스트 mock 예제
X-Macros로 에러 시스템 헤더
// error_system.h
#pragma once
#include <string_view>
// 코드는 0부터 연속이어야 메시지 배열 인덱스와 일치한다
#define ERROR_LIST(X) \
X(Ok, 0, "Success") \
X(NotFound, 1, "Resource not found") \
X(InvalidArg, 2, "Invalid argument") \
X(Timeout, 3, "Operation timed out") \
X(Internal, 4, "Internal error")
#define EXPAND_ENUM(name, code, msg) name = code,
enum class ErrorCode { ERROR_LIST(EXPAND_ENUM) Count };
#define EXPAND_MSG(name, code, msg) msg,
inline constexpr const char* ERROR_MESSAGES[] = { ERROR_LIST(EXPAND_MSG) };
inline std::string_view to_string(ErrorCode e) {
int i = static_cast<int>(e);
if (i < 0 || i >= static_cast<int>(ErrorCode::Count))
return "Unknown";
return ERROR_MESSAGES[i];
}
#undef EXPAND_ENUM
#undef EXPAND_MSG
Python으로 C++ enum·switch 생성
# gen_enum_switch.py
ENUMS = """
NotFound, 404
Unauthorized, 401
Timeout, 408
InternalError, 500
""".strip().split('\n')
def gen_cpp():
lines = [l.strip() for l in ENUMS if l.strip()]
print("#pragma once")
print("enum class HttpError {")
for line in lines:
name, code = (s.strip() for s in line.split(','))
print(f" {name} = {code},")
print("};")
print()
# 헤더에 함수 정의를 넣으므로 inline이 필요하다 (여러 TU에서 include해도 ODR 위반 없음)
print("inline const char* httpErrorToString(HttpError e) {")
print(" switch (e) {")
for line in lines:
name = line.split(',')[0].strip()
print(f' case HttpError::{name}: return "{name}";')
print(" }")
print(' return "Unknown";')
print("}")
if __name__ == "__main__":
gen_cpp()
python3 gen_enum_switch.py > http_error.generated.hpp
switch에 default를 두지 않고 함수 끝에서 반환하면, 나중에 enum에 항목이 추가됐는데 case가 빠졌을 때 -Wswitch 경고로 알 수 있습니다.
Python으로 gMock 클래스 생성
인터페이스 헤더를 읽어 mock 클래스를 출력하는 스크립트 예시입니다.
# gen_mock.py
import re
import sys
def parse_interface(content):
"""간단한 인터페이스 파싱 — 템플릿 인자나 쉼표가 들어간 타입은 처리하지 못함"""
methods = []
for line in content.split('\n'):
m = re.match(r'\s*virtual\s+(\w+)\s+(\w+)\s*\((.*)\)\s*=\s*0', line)
if m:
methods.append((m.group(1), m.group(2), m.group(3)))
return methods
def gen_mock(methods):
print("#pragma once")
print("#include <gmock/gmock.h>")
print('#include "interface.h"')
print()
print("class MockInterface : public Interface {")
print("public:")
for ret, name, args in methods:
print(f" MOCK_METHOD({ret}, {name}, ({args}), (override));")
print("};")
if __name__ == "__main__":
gen_mock(parse_interface(sys.stdin.read()))
std::map<int, int>처럼 쉼표가 들어간 타입은 MOCK_METHOD 인자에서 괄호로 한 번 더 감싸야 하고, 정규식은 const 메서드나 여러 줄에 걸친 선언도 놓칩니다. 이런 경우가 많아지면 앞의 libTooling 방식으로 바꾸는 것이 맞습니다.
constexpr로 컴파일 타임 문자열 다루기
C++20부터는 문자열 리터럴을 템플릿 인자로 넘길 수 있어, 타입에 이름 태그를 붙이는 데 쓸 수 있습니다.
// compile_time_string.cpp
#include <array>
#include <cstddef>
#include <string_view>
template <std::size_t N>
struct FixedString {
std::array<char, N> data{};
constexpr FixedString(const char (&str)[N]) {
for (std::size_t i = 0; i < N; ++i) data[i] = str[i];
}
constexpr operator std::string_view() const {
return std::string_view(data.data(), N - 1);
}
};
template <FixedString S>
struct Tag {
static constexpr std::string_view name = S;
};
static_assert(Tag<"user">::name == "user");
X-Macros 쉼표·생성 파일 경로·빌드 누락 문제
X-Macros에서 마지막 쉼표
enum 정의와 중괄호 초기화 목록은 C++11부터 마지막 쉼표를 허용하므로, EXPAND(name) name, 형태로 펼쳐 A, B, C,가 되어도 문제없습니다. 문제가 되는 곳은 함수 인자나 템플릿 인자 목록입니다.
#define TYPE_LIST(X) X(int) X(double) X(std::string)
#define EXPAND_COMMA(t) t,
// ✅ enum·배열 초기화: 마지막 쉼표 허용
// ❌ std::variant<TYPE_LIST(EXPAND_COMMA)> → std::variant<int, double, std::string,> 컴파일 에러
// 해결: 첫 항목을 따로 두거나, 마지막에 더미 타입을 붙이거나, 쉼표를 앞에 붙이는 매크로로 펼친다
#define EXPAND_LEADING_COMMA(t) , t
using V = std::variant<std::monostate TYPE_LIST(EXPAND_LEADING_COMMA)>;
확장 매크로의 인자 개수 불일치
목록 매크로가 항목마다 인자 두 개를 넘기는데, 인자 한 개짜리 매크로로 펼치면 전처리 단계에서 에러가 납니다.
#define PAIR_LIST(X) X(1, 2) X(3, 4)
#define SUM(a, b) a + b,
int sums[] = { PAIR_LIST(SUM) }; // ✅ { 1 + 2, 3 + 4, }
#define FIRST(a) a,
// int firsts[] = { PAIR_LIST(FIRST) }; // ❌ macro "FIRST" passed 2 arguments, but takes just 1
#define FIRST2(a, b) a,
int firsts[] = { PAIR_LIST(FIRST2) }; // ✅ 쓰지 않는 인자도 받아야 함
템플릿 인스턴스화 시 “undefined reference”
템플릿 정의가 .cpp에만 있고 다른 파일에서 다른 타입으로 사용하면 링크 에러가 납니다.
// ❌ template_impl.cpp에만 정의
// template_impl.cpp
template <typename T>
void process(T x) { /* ... */ }
template void process<int>(int);
// main.cpp
process<double>(3.14); // 링크 에러: process<double> 정의 없음
// ✅ 헤더에 정의하거나, 사용하는 모든 타입을 명시적으로 인스턴스화
// process.h
template <typename T>
void process(T x);
// template_impl.cpp
#include "process.h"
template <typename T>
void process(T x) { /* ... */ }
template void process<int>(int);
template void process<double>(double);
protobuf 생성 파일 경로 불일치
protoc --cpp_out=generated로 생성했는데 #include "user.pb.h"를 찾지 못하는 경우입니다. 출력 디렉터리를 include 경로에 넣고, 생성되는 두 파일을 모두 OUTPUT으로 선언합니다.
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/generated/user.pb.cc
${CMAKE_BINARY_DIR}/generated/user.pb.h
COMMAND protoc --cpp_out=${CMAKE_BINARY_DIR}/generated
--proto_path=${CMAKE_SOURCE_DIR}/proto
${CMAKE_SOURCE_DIR}/proto/user.proto
DEPENDS ${CMAKE_SOURCE_DIR}/proto/user.proto
)
target_sources(myapp PRIVATE ${CMAKE_BINARY_DIR}/generated/user.pb.cc)
target_include_directories(myapp PRIVATE ${CMAKE_BINARY_DIR}/generated)
생성 코드가 빌드에 포함되지 않음
생성기는 파일을 만들 뿐이므로, 생성된 소스를 타깃에 넣지 않으면 링크 단계에서 undefined reference가 납니다. 생성 파일을 add_custom_command의 OUTPUT으로 선언하고 target_sources에 넣어야 CMake가 “생성 → 컴파일” 순서를 연결합니다. 입력 파일을 DEPENDS에 넣어 둬야 정의를 수정했을 때 다시 생성됩니다.
Clang 도구 빌드 시 LLVM 버전 불일치
libTooling의 C++ API는 메이저 버전마다 바뀌므로, 도구를 빌드한 LLVM 버전과 헤더·라이브러리 버전이 섞이면 컴파일 에러나 크래시가 납니다.
# ✅ 동일 버전으로 고정
# Ubuntu: sudo apt install clang-18 libclang-18-dev
# CMake: find_package(Clang 18 REQUIRED CONFIG)
생성된 소스의 인코딩
한글 주석이나 문자열이 들어간 파일을 생성할 때 인코딩을 지정하지 않으면, Windows의 Python 기본 인코딩과 MSVC의 기본 소스 코드 페이지가 어긋나 글자가 깨집니다.
# ✅ 생성 시 UTF-8 명시
with open("output.generated.hpp", "w", encoding="utf-8") as f:
f.write(generated_code)
MSVC에는 /utf-8 옵션을 줘서 소스와 실행 문자 집합을 모두 UTF-8로 맞춥니다.
X-Macros에서 매크로 재정의 충돌
여러 목록에서 EXPAND 같은 같은 이름의 확장 매크로를 재사용하면, 다른 정의로 다시 #define할 때 재정의 경고가 나고 결과가 섞입니다.
// ✅ 목록별 고유 매크로를 쓰거나, 쓰고 나서 바로 #undef
#define ERROR_LIST(X) X(A) X(B)
#define ERROR_EXPAND(x) x,
enum class Err { ERROR_LIST(ERROR_EXPAND) };
#undef ERROR_EXPAND
protobuf 필드 번호 중복·재사용
같은 메시지에서 필드 번호를 중복하면 protoc가 에러를 냅니다. 더 위험한 것은 삭제한 필드의 번호를 다른 필드에 다시 쓰는 경우입니다. 이전 버전 클라이언트가 보낸 데이터가 새 필드로 잘못 해석되므로, 지운 번호와 이름은 reserved로 막아 둡니다.
message User {
reserved 4; // 예전에 쓰던 필드 번호
reserved "nickname"; // 예전에 쓰던 필드 이름
int32 id = 1;
string name = 2;
string email = 3;
}
생성 코드의 커밋 여부
생성물을 .gitignore에 넣었는데 다른 개발자 환경이나 CI에 생성 도구가 없으면 빌드가 실패합니다. 생성물을 커밋할지, 빌드 때마다 생성할지 팀 정책을 정해야 합니다.
# 옵션 A: 생성물 커밋 — 생성 도구 없이 빌드 가능, 대신 CI에서 재생성 결과와 일치하는지 검사
# 옵션 B: 생성물 미커밋 — 모든 빌드 환경에 protoc, Python 등 설치 필요
generated/
*.pb.cc
*.pb.h
생성 코드를 유지보수하는 원칙
정의는 한 곳에만
원본 정의(.proto, X-Macro 목록, C++ 구조체)는 하나만 두고 나머지는 모두 거기서 파생시킵니다. 생성된 파일을 손으로 고치기 시작하면 다음 생성 때 수정이 사라지므로, 생성 파일 상단에 “자동 생성됨, 직접 수정 금지”와 원본 파일 경로를 적어 둡니다.
생성 결과 검증
생성된 코드에도 테스트를 둬서, 정의가 바뀌었을 때 생성 결과가 기대와 맞는지 확인합니다.
// generated_test.cpp
#include <gtest/gtest.h>
#include "error_system.h"
TEST(ErrorCode, AllCodesHaveMessages) {
for (int i = 0; i < static_cast<int>(ErrorCode::Count); ++i) {
auto msg = to_string(static_cast<ErrorCode>(i));
EXPECT_NE(msg, "Unknown");
EXPECT_FALSE(msg.empty());
}
}
템플릿 인스턴스화 비용 관리
같은 템플릿을 여러 번역 단위에서 같은 타입으로 쓰면, 각 번역 단위가 같은 코드를 따로 인스턴스화하고 링커가 중복을 버립니다. 컴파일 시간이 문제라면 헤더에 extern template 선언을 두고, 한 .cpp에서만 명시적으로 인스턴스화합니다.
// serializer.h
template <typename T>
std::string to_json(const T& value) { /* ... */ }
extern template std::string to_json<User>(const User&); // 다른 TU에서는 인스턴스화하지 않음
extern template std::string to_json<Order>(const Order&);
// serializer_impl.cpp
#include "serializer.h"
template std::string to_json<User>(const User&); // 여기서 한 번만 인스턴스화
template std::string to_json<Order>(const Order&);
생성 도구 버전 고정
protoc, flatc, Python 스크립트의 버전이 바뀌면 생성 결과가 달라질 수 있고, protobuf는 생성 코드와 런타임 라이브러리 버전이 맞지 않으면 컴파일이나 링크가 실패합니다. 컨테이너, vcpkg·Conan 매니페스트로 버전을 고정합니다.
# Dockerfile.build — 배포판 패키지 대신 릴리스 바이너리를 버전 고정으로 설치
FROM ubuntu:24.04
ARG PROTOC_VERSION=29.3
RUN apt-get update && apt-get install -y curl unzip && \
curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip && \
unzip protoc-${PROTOC_VERSION}-linux-x86_64.zip -d /usr/local && \
rm protoc-${PROTOC_VERSION}-linux-x86_64.zip
런타임 라이브러리(libprotobuf)도 같은 버전으로 맞춰야 하므로, 실제로는 vcpkg나 Conan으로 protoc와 라이브러리를 함께 고정하는 편이 관리하기 쉽습니다.
CMake 자동 생성·스키마 호환성·CI 검증
CMake에서 protobuf 생성 자동화
find_package(Protobuf CONFIG REQUIRED)
add_executable(myapp main.cpp proto/user.proto proto/config.proto)
# .proto를 타깃 소스로 넣고 protobuf_generate로 .pb.cc/.pb.h를 생성해 타깃에 연결
protobuf_generate(TARGET myapp)
target_include_directories(myapp PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
target_link_libraries(myapp PRIVATE protobuf::libprotobuf)
예전 CMake 예제에 많은 protobuf_generate_cpp(SRCS HDRS ...)도 여전히 동작하지만, 타깃 기반의 protobuf_generate가 생성 파일과 의존성을 더 깔끔하게 연결합니다.
스키마 호환성
protobuf의 호환성은 버전 필드가 아니라 필드 번호 규칙으로 유지합니다. 새 필드는 새 번호로 추가하고, 기존 필드의 번호와 타입은 바꾸지 않으며, 지운 필드는 reserved로 막습니다. 이 규칙을 지키면 구버전 코드는 모르는 필드를 무시하고, 신버전 코드는 없는 필드를 기본값으로 읽습니다. 이 규칙으로 감당할 수 없는 큰 변경은 package myapp.v2;처럼 패키지를 나눠 새 메시지로 정의합니다.
점진적 도입
기존 수동 코드를 한 번에 바꾸지 말고, 새로 추가하는 타입부터 생성 방식을 적용한 뒤 기존 타입을 하나씩 옮깁니다. 이전과 이후의 직렬화 결과가 같은지 비교하는 테스트를 옮기는 타입마다 두면 회귀를 막을 수 있습니다.
CI에서 생성 결과 검증
생성물을 커밋하는 정책이라면, CI에서 다시 생성한 결과가 커밋된 파일과 같은지 확인합니다.
# .github/workflows/ci.yml
- name: Regenerate and verify
run: |
python scripts/gen_enum_switch.py > include/http_error.generated.hpp
git diff --exit-code -- include/http_error.generated.hpp || (echo "생성 파일이 최신이 아닙니다. 다시 생성해 커밋하세요." && exit 1)
불필요한 재생성 줄이기
재생성 여부를 판단하는 것은 protoc나 flatc가 아니라 빌드 시스템입니다. add_custom_command의 OUTPUT과 DEPENDS를 정확히 적어 두면, Ninja나 Make가 입력 파일의 변경 시각을 보고 바뀐 경우에만 생성기를 다시 실행합니다. 생성 스크립트 자체도 DEPENDS에 넣어야 스크립트를 고쳤을 때 결과가 갱신됩니다.
add_custom_command(
OUTPUT ${GEN_DIR}/http_error.generated.hpp
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/scripts/gen_enum_switch.py > ${GEN_DIR}/http_error.generated.hpp
DEPENDS ${CMAKE_SOURCE_DIR}/scripts/gen_enum_switch.py
COMMENT "Generating http_error.generated.hpp"
)
참고 자료
- Clang LibTooling
- Protocol Buffers C++ Tutorial
- FlatBuffers C++
- X-Macros (Wikipedia)
- 리플렉션 #55-1
- C++ 템플릿 메타프로그래밍 심화 — SFINAE·타입 특성·태그 디스패치
같이 보면 좋은 글
- C++ 커스텀 컴파일러 패스 | Clang 플러그인·AST 변환·커스텀 진단 [#55-6]
- C++ 템플릿 메타프로그래밍 심화 — SFINAE·타입 특성·태그 디스패치
- C++ 컴파일 타임 리플렉션 | C++26 Reflection·magic_enum·매크로 직렬화·검증
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기