C++ 로깅·Assertion | 프로덕션 간헐적 크래시, 로그 없이 재현 불가일 때
💡 초보자를 위한 한 줄: 로그에는 상관 ID·요청 ID·스레드 ID처럼 “나중에 grep할 키”를 남기고,
assert는 내부 불변 조건에만 쓰는 편이 안전합니다(사용자 입력은 명시적 검증).static_assert는 컴파일 타임에 틀린 가정을 막습니다. 16-2 Sanitizers 다음이 읽기 순서에 맞습니다.
들어가며: “어디서 잘못됐는지 모르겠어요”
프로덕션(실제 서비스가 돌아가는 운영 환경)에서 간헐적으로 크래시가 발생했습니다. 하지만 로그가 없어서 원인을 찾을 수 없었습니다.
로그는 “언제, 어디서, 어떤 값이었는지”를 남겨서 재현이 어려운 버그를 좁혀 주고, assert(어서션—“이 조건이 참이어야 한다”고 코드에 적어 두며, 거짓이면 프로그램을 중단해 버그를 드러내는 매크로)는 “이 조건이 깨지면 더 이상 진행하면 안 된다”는 불변 조건을 코드에 명시합니다. 실무에서는 로그 레벨(DEBUG/INFO/ERROR)을 환경별로 나누고, assert 실패 시 스택과 상태를 남기도록 설정해 두면 추적이 훨씬 수월해집니다.
문제의 코드:
void processOrder(Order* order) {
// 크래시 발생... 하지만 어디서?
order->calculate();
order->validate();
order->save();
}
로깅 추가 후:
void processOrder(Order* order) {
LOG_INFO("Processing order: " << order->getId());
order->calculate();
LOG_DEBUG("Calculation done");
order->validate();
LOG_DEBUG("Validation done");
order->save();
LOG_INFO("Order saved");
}
주의사항: 민감한 개인정보·토큰은 마스킹하고, DEBUG 로그 폭주는 I/O 병목이 될 수 있으므로 레벨을 런타임에 조절하세요. 로그 출력:
[INFO] Processing order: 12345
[DEBUG] Calculation done
[ERROR] Validation failed: invalid price
원인 발견: validate()에서 실패했습니다.
이 예시에서 로그가 알려 준 것은 “어디서”만이 아닙니다. [DEBUG] Calculation done 다음에 [ERROR]가 찍혔다는 순서 자체가 계산은 성공했고 검증에서 멈췄다는 사실을 알려 줍니다. 크래시 원인을 좁힐 때 로그의 가치는 이렇게 “마지막으로 성공한 지점”을 남기는 데 있습니다. 반대로 로그가 너무 많으면 필요한 한 줄을 찾기 어렵고 디스크와 I/O를 잡아먹으므로, 무엇을 어떤 레벨로 남길지 정하는 것이 로깅 설계의 핵심입니다. 처음 운영 장애를 겪을 때 흔히 하는 실수가 장애 직후 모든 곳에 DEBUG 로그를 추가해 배포하는 것인데, 로그량이 갑자기 불어나 오히려 새로운 성능 문제를 만들기도 합니다.
로그와 assert가 필요한 상황
고객 서버에서만 재현되는 크래시는 메모리 레이아웃, 코어 수, 타이밍 차이로 드러나는 레이스 컨디션이나 특정 입력 조합이 원인인 경우가 많아, 크래시 직전 상태를 남긴 로그가 거의 유일한 단서가 됩니다. 코드 변경 없이 “어제는 됐는데 오늘은 안 되는” 문제는 배포 환경·외부 서비스·데이터 변화가 원인이므로, 주요 분기점의 INFO 로그로 어디까지 실행됐는지를 추적합니다. 여러 서비스를 거치는 요청이라면 진입 시점에 요청 ID를 만들어 모든 로그에 넣어야 어느 사용자 요청에서 생긴 에러인지 이어 볼 수 있습니다.
assert는 문서에만 적혀 있던 전제 조건(“size > 0일 때만 호출”)을 코드로 옮겨, 위반하는 호출자를 개발 중에 즉시 멈춰 세웁니다. 다만 use-after-free 같은 메모리 오류는 로그와 assert만으로는 잡기 어려우므로 Sanitizer를 함께 씁니다.
간단한 로거와 로깅 매크로
간단한 로깅
#include <iostream>
#include <fstream>
#include <chrono>
#include <iomanip>
#include <string>
class Logger {
std::ofstream file;
public:
Logger(const std::string& filename) {
file.open(filename, std::ios::app);
}
template <typename T>
void log(const T& message) {
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
file << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S")
<< " " << message << "\n";
file.flush(); // 버퍼 즉시 반영 (크래시 시에도 로그 보존)
}
};
int main() {
Logger logger("app.log");
logger.log("Application started");
logger.log("Processing data...");
}
flush()를 호출하지 않으면 버퍼에 쌓인 로그가 크래시 시 손실될 수 있습니다. 반대로 매번 flush하면 로그마다 시스템 콜이 일어나므로, 프로덕션에서는 에러 이상만 즉시 flush하고 나머지는 주기적으로 flush하는 정책을 씁니다. std::localtime은 내부 정적 버퍼를 쓰므로 여러 스레드에서 호출하면 안전하지 않습니다(POSIX의 localtime_r, Windows의 localtime_s 사용).
매크로로 간편화
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o log_macro log_macro.cpp && ./log_macro
#include <iostream>
#define LOG(msg) \
std::cout << "[" << __FILE__ << ":" << __LINE__ << "] " << msg << "\n"
int main() {
LOG("Starting application");
int x = 10;
LOG("x = " << x);
return 0;
}
실행 결과:
[log_macro.cpp:6] Starting application
[log_macro.cpp:8] x = 10
로깅 레벨 설계와 사용 예
레벨 정의
enum class LogLevel {
DEBUG, // 상세 정보 (개발용)
INFO, // 일반 정보
WARNING, // 경고
ERROR, // 에러
FATAL // 치명적 에러
};
class Logger {
LogLevel minLevel = LogLevel::INFO;
public:
void setLevel(LogLevel level) {
minLevel = level;
}
void log(LogLevel level, const std::string& message) {
if (level < minLevel) return;
std::cout << "[" << levelToString(level) << "] " << message << "\n";
}
private:
std::string levelToString(LogLevel level) {
switch (level) {
case LogLevel::DEBUG: return "DEBUG";
case LogLevel::INFO: return "INFO";
case LogLevel::WARNING: return "WARNING";
case LogLevel::ERROR: return "ERROR";
case LogLevel::FATAL: return "FATAL";
}
return "UNKNOWN";
}
};
Windows에서는 <windows.h>가 ERROR를 매크로로 정의하므로 위 열거자 이름이 충돌할 수 있습니다. 그래서 kError나 Error처럼 대문자만으로 된 이름을 피하는 프로젝트가 많습니다.
사용 예제
Logger logger;
logger.setLevel(LogLevel::INFO);
logger.log(LogLevel::DEBUG, "This won't be printed");
logger.log(LogLevel::INFO, "Application started");
logger.log(LogLevel::ERROR, "Failed to open file");
출력:
[INFO] Application started
[ERROR] Failed to open file
로그 레벨 흐름도
flowchart TD
subgraph 환경별[환경별 로그 레벨]
DEV[개발: DEBUG]
STG[스테이징: INFO]
PRD[프로덕션: WARNING 또는 ERROR]
end
subgraph 메시지[메시지 레벨]
D[DEBUG]
I[INFO]
W[WARNING]
E[ERROR]
F[FATAL]
end
DEV --> D
DEV --> I
DEV --> W
DEV --> E
DEV --> F
STG --> I
STG --> W
STG --> E
STG --> F
PRD --> W
PRD --> E
PRD --> F
assert와 static_assert
assert (런타임 검증)
#include <cassert>
void processArray(int* arr, int size) {
assert(arr != nullptr); // null 체크
assert(size > 0); // 크기 체크
for (int i = 0; i < size; ++i) {
arr[i] *= 2;
}
}
int main() {
int arr[10];
processArray(arr, 10); // ✅ OK
// processArray(nullptr, 10); // ❌ Assertion failed!
}
assert 비활성화:
# Release 빌드에서 assert 제거
g++ -DNDEBUG main.cpp -o myapp
assert vs static_assert vs NDEBUG 비교
| 구분 | assert | static_assert | NDEBUG |
|---|---|---|---|
| 검증 시점 | 런타임 | 컴파일 타임 | 매크로 (빌드 시) |
| 용도 | 불변 조건·전제 조건 검증 | 타입·플랫폼·상수 검증 | assert 비활성화 |
| 실패 시 | 프로그램 중단(abort) | 컴파일 에러 | - |
| 프로덕션 | NDEBUG 시 제거됨 | 항상 포함 | Release 빌드에 정의 |
| 부수 효과 | 넣으면 안 됨 | 없음 | assert 전체 제거 |
NDEBUG 상세:
NDEBUG가 정의되면assert(expr)는((void)0)으로 치환되어 완전히 제거됩니다.- CMake Release 빌드, Visual Studio Release 구성은 기본적으로
NDEBUG를 정의합니다. - 주의: assert 내부에 할당·초기화 등 부수 효과를 넣으면 Release에서 해당 코드가 실행되지 않아 버그가 발생합니다.
// NDEBUG 동작 원리 (cassert 내부)
#ifdef NDEBUG
#define assert(condition) ((void)0)
#else
#define assert(condition) /* condition이 거짓이면 abort */
#endif
static_assert (컴파일 타임 검증)
template <typename T>
class Buffer {
static_assert(std::is_trivially_copyable_v<T>,
"T must be trivially copyable");
T data[100];
};
int main() {
Buffer<int> buf1; // ✅ OK
// Buffer<std::string> buf2; // ❌ 컴파일 에러
}
커스텀 assert
#define ASSERT(condition, message) \
do { \
if (!(condition)) { \
std::cerr << "Assertion failed: " << message << "\n" \
<< "File: " << __FILE__ << "\n" \
<< "Line: " << __LINE__ << "\n"; \
std::abort(); \
} \
} while (0)
int main() {
int x = 10;
ASSERT(x > 0, "x must be positive");
ASSERT(x < 100, "x must be less than 100");
}
Assertion 패턴 모음
패턴 1: 전제 조건 검증 (Precondition)
// 함수 호출 전 반드시 만족해야 하는 조건
void resizeBuffer(std::vector<int>& buf, size_t newSize) {
assert(newSize > 0 && "newSize must be positive");
assert(newSize <= MAX_BUFFER_SIZE && "Buffer size limit exceeded");
buf.resize(newSize);
}
패턴 2: 사후 조건 검증 (Postcondition)
// 함수가 끝날 때 반드시 만족해야 하는 조건
int clampToRange(int v, int lo, int hi) {
assert(lo <= hi && "invalid range"); // 전제 조건
int r = v < lo ? lo : (v > hi ? hi : v);
assert(r >= lo && r <= hi && "result out of range"); // 사후 조건
return r;
}
사후 조건은 수학적으로 항상 참이어야 하는 식만 써야 합니다. 예를 들어 정수 나눗셈 결과에 result * b == a를 단언하면 7 / 2처럼 나누어떨어지지 않는 정상 입력에서도 실패합니다.
패턴 3: 불가능한 분기 (Unreachable)
switch에서 “나올 수 없는” default에 assert를 두면, 나중에 누군가 열거형에 새 값을 추가하고 이 switch를 고치지 않았을 때 개발 중에 바로 드러납니다. 다만 릴리스 빌드에서는 assert가 사라져 그 경로가 조용히 진행되므로, 반환값이 필요한 함수라면 assert 뒤에 안전한 기본 동작(에러 로그 후 기본값 반환)을 함께 두는 편이 좋습니다. C++23의 std::unreachable()은 “절대 도달하지 않는다”고 컴파일러에 약속해 최적화에 쓰게 하는 것이라, 실제로 도달하면 정의되지 않은 동작이 된다는 점에서 assert와 목적이 반대입니다.
enum class State { Idle, Running, Done };
void handleState(State s) {
switch (s) {
case State::Idle: /* ... */ break;
case State::Running: /* ... */ break;
case State::Done: /* ... */ break;
default:
assert(false && "Unhandled state");
}
}
열거형 전체를 다루는 switch라면 default를 아예 빼는 방법도 있습니다. 그러면 새 열거자가 추가됐을 때 -Wswitch(GCC·Clang의 -Wall에 포함)가 처리하지 않은 값을 컴파일 시점에 경고해 줍니다.
패턴 4: static_assert로 플랫폼 검증
// 64비트 환경에서만 컴파일 허용
static_assert(sizeof(void*) == 8, "This code requires 64-bit platform");
// 특정 타입 크기 검증
static_assert(sizeof(int) == 4, "int must be 4 bytes");
패턴 5: 타입 특성 검증
template <typename T>
void fastCopy(T* dest, const T* src, size_t count) {
static_assert(std::is_trivially_copyable_v<T>,
"T must be trivially copyable for fast copy");
std::memcpy(dest, src, count * sizeof(T));
}
spdlog로 레벨, 포맷, 로테이션 설정하기
spdlog (추천)
#include "spdlog/spdlog.h"
#include "spdlog/sinks/basic_file_sink.h"
int main() {
spdlog::info("Application started");
spdlog::debug("Debug message"); // 기본 레벨이 info라 출력되지 않음
spdlog::error("Error occurred");
// 파일 로깅
auto file_logger = spdlog::basic_logger_mt("file_logger", "logs/app.log");
file_logger->info("Logged to file");
}
spdlog 로그 레벨 전체 예제
#include "spdlog/spdlog.h"
int main() {
spdlog::set_level(spdlog::level::debug);
spdlog::trace("추적"); spdlog::debug("디버그"); spdlog::info("정보");
spdlog::warn("경고"); spdlog::error("에러"); spdlog::critical("치명적");
spdlog::info("User {} from {}", 12345, "192.168.1.1");
return 0;
}
설치
# vcpkg
vcpkg install spdlog
# CMakeLists.txt
find_package(spdlog CONFIG REQUIRED)
target_link_libraries(myapp PRIVATE spdlog::spdlog)
spdlog 커스텀 싱크 (Custom Sink)
로그를 파일·콘솔 외에 Syslog, 네트워크 등으로 보내려면 spdlog::sinks::base_sink를 상속해 커스텀 싱크를 구현합니다. Linux에서는 spdlog/sinks/syslog_sink.h의 syslog_sink_mt를 사용할 수 있습니다.
#include "spdlog/sinks/base_sink.h"
#include <mutex>
#include <vector>
template<typename Mutex>
class MemoryBufferSink : public spdlog::sinks::base_sink<Mutex> {
std::vector<std::string> buffer_;
protected:
void sink_it_(const spdlog::details::log_msg& msg) override {
spdlog::memory_buf_t formatted;
spdlog::sinks::base_sink<Mutex>::formatter_->format(msg, formatted);
buffer_.push_back(std::string(formatted.data(), formatted.size()));
}
void flush_() override {}
};
spdlog 완전한 예제 (레벨, 포맷, 로테이션)
#include "spdlog/spdlog.h"
#include "spdlog/sinks/rotating_file_sink.h"
#include "spdlog/sinks/stdout_color_sinks.h"
void setupProductionLogger() {
// 콘솔: 색상 출력
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
console_sink->set_level(spdlog::level::info);
// 파일: 5MB마다 로테이션, 최대 3개 파일 유지
auto file_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
"logs/app.log", 1024 * 1024 * 5, 3);
file_sink->set_level(spdlog::level::debug);
std::vector<spdlog::sink_ptr> sinks{console_sink, file_sink};
auto logger = std::make_shared<spdlog::logger>("multi_sink", sinks.begin(), sinks.end());
// 포맷: [2026-03-10 14:30:00.123] [info] [main.cpp:42] 메시지
// %s:%#(파일:줄)는 SPDLOG_INFO 같은 매크로로 남긴 로그에만 채워지고,
// spdlog::info() 함수 호출에서는 소스 위치가 없어 빈칸이 됨
logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [%s:%#] %v");
logger->set_level(spdlog::level::debug);
spdlog::set_default_logger(logger);
}
int main() {
setupProductionLogger();
spdlog::info("Application started");
spdlog::debug("Detailed debug info: x={}", 42);
spdlog::error("Error: file not found - {}", "config.json");
return 0;
}
함수 진입 로깅, 성능 측정, 구조화된 로깅
패턴 1: 함수 진입/종료 로깅
class FunctionLogger {
std::string funcName;
public:
FunctionLogger(const char* name) : funcName(name) {
std::cout << "Entering " << funcName << "\n";
}
~FunctionLogger() {
std::cout << "Exiting " << funcName << "\n";
}
};
// 밑줄 두 개로 시작하는 이름은 구현 예약이므로 쓰지 않음, __func__는 표준(C++11)
#define LOG_FUNCTION() FunctionLogger func_logger_(__func__)
void processData() {
LOG_FUNCTION();
// 작업...
}
int main() {
LOG_FUNCTION();
processData();
}
출력:
Entering main
Entering processData
Exiting processData
Exiting main
패턴 2: 조건부 로깅
#ifdef DEBUG
#define LOG_DEBUG(msg) std::cout << "[DEBUG] " << msg << "\n"
#else
#define LOG_DEBUG(msg)
#endif
int main() {
LOG_DEBUG("This only prints in debug build");
}
패턴 3: 성능 측정 로깅
#include <chrono>
#include <iostream>
#include <thread>
class PerfLogger {
std::string name;
std::chrono::steady_clock::time_point start; // 시계 조정에 영향받지 않는 단조 시계
public:
PerfLogger(const char* n) : name(n) {
start = std::chrono::steady_clock::now();
}
~PerfLogger() {
auto end = std::chrono::steady_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << name << " took " << duration.count() << " ms\n";
}
};
#define LOG_PERF(name) PerfLogger perf_logger_(name)
void slowFunction() {
LOG_PERF("slowFunction");
std::this_thread::sleep_for(std::chrono::seconds(1));
}
패턴 4: 에러 컨텍스트
#include <iostream>
#include <string>
#include <vector>
class ErrorContext {
std::vector<std::string> context;
public:
void push(const std::string& msg) {
context.push_back(msg);
}
void pop() {
if (!context.empty()) {
context.pop_back();
}
}
void printContext() {
std::cout << "Error context:\n";
for (const auto& msg : context) {
std::cout << " " << msg << "\n";
}
}
};
ErrorContext errorCtx;
void processOrder(int orderId) {
errorCtx.push("Processing order " + std::to_string(orderId));
// 에러 발생
if (orderId < 0) {
errorCtx.printContext();
throw std::runtime_error("Invalid order ID");
}
errorCtx.pop();
}
이 코드는 예외가 나면 pop()이 호출되지 않아 컨텍스트가 쌓입니다. 실제로 쓸 때는 생성자에서 push, 소멸자에서 pop하는 RAII 가드로 감싸고, 여러 스레드에서 쓴다면 thread_local로 둡니다.
패턴 5: 구조화된 로깅
#include <iostream>
#include <map>
#include <string>
class StructuredLogger {
public:
void log(const std::string& event,
const std::map<std::string, std::string>& fields) {
std::cout << "event=" << event;
for (const auto& [key, value] : fields) {
std::cout << " " << key << "=" << value;
}
std::cout << "\n";
}
};
int main() {
StructuredLogger logger;
logger.log("user_login", {
{"user_id", "12345"},
{"ip", "192.168.1.1"},
{"timestamp", "2026-03-17"}
});
}
출력:
event=user_login user_id=12345 ip=192.168.1.1 timestamp=2026-03-17
assert 부수 효과, 입력 검증에 assert 사용 같은 오류
오류 1: 로그 문자열 연결 시 성능 저하
로그 메시지를 함수 인자로 넘기면, 함수 안에서 레벨을 확인해 출력을 건너뛰더라도 인자인 문자열은 호출 전에 이미 만들어집니다. 잘못된 예:
void logDebug(const std::string& msg) {
if (currentLevel <= LogLevel::DEBUG) std::cout << msg << '\n';
}
// ❌ DEBUG가 꺼져 있어도 computeExpensiveString()과 문자열 결합이 항상 실행됨
logDebug("expensive: " + computeExpensiveString());
매크로는 이 문제를 피할 수 있는 몇 안 되는 도구입니다. #define LOG_DEBUG(msg) if (enabled) std::cout << msg처럼 조건을 매크로 안에 두면, 조건이 거짓일 때 msg 부분은 실행되지 않습니다. 다만 이 형태는 if (x) LOG_DEBUG(a); else foo();에서 else가 매크로 안의 if에 붙어 버리는 dangling-else 문제가 있으므로, 아래처럼 do { ... } while (0)로 감싸는 것이 관례입니다.
올바른 예:
// 매크로 안의 조건이 거짓이면 expr 자체가 평가되지 않음
#define LOG_DEBUG_EXPR(expr) \
do { if (LOG_LEVEL <= DEBUG) { expr; } } while (0)
LOG_DEBUG_EXPR(std::cout << "expensive: " << computeExpensiveString());
spdlog의 spdlog::debug("{}", x)처럼 포맷 문자열을 쓰면 레벨이 꺼져 있을 때 포맷팅 비용은 들지 않지만, 인자 x를 계산하는 비용은 여전히 듭니다. spdlog::debug("{}", computeExpensiveString())에서 computeExpensiveString()은 함수 호출 전에 반드시 실행됩니다. 인자 계산까지 건너뛰려면 if (spdlog::default_logger()->should_log(spdlog::level::debug))로 감싸거나, SPDLOG_DEBUG(...) 매크로를 쓰고 SPDLOG_ACTIVE_LEVEL을 정의해 컴파일 시점에 호출 자체를 없애야 합니다.
오류 2: assert에 부수 효과(side effect) 넣기
NDEBUG 정의 시 assert 전체가 제거되므로, assert 안의 코드가 실행되지 않습니다.
잘못된 예:
assert(ptr = getNext()); // ❌ Release에서 ptr 할당이 사라짐!
assert(initConnection()); // ❌ Release에서 초기화가 수행되지 않음
올바른 예:
ptr = getNext();
assert(ptr != nullptr);
bool ok = initConnection();
assert(ok && "Connection init failed");
오류 3: 사용자 입력 검증에 assert 사용
assert는 프로그래머 실수를 잡기 위한 것이지, 잘못된 사용자 입력을 처리하는 용도가 아닙니다. 잘못된 예:
void setAge(int age) {
assert(age >= 0 && age <= 150); // ❌ 사용자가 -1 입력 시 프로세스 종료
}
올바른 예:
bool setAge(int age) {
if (age < 0 || age > 150) {
LOG_ERROR("Invalid age: {}", age);
return false;
}
// ...
return true;
}
오류 4: 로그 파일 권한/경로 오류
로그 디렉터리가 없거나 쓰기 권한이 없으면 로거 생성이 실패합니다(spdlog는 spdlog_ex 예외를 던짐). 시작 시 디렉터리를 만들어 둡니다.
#include <filesystem>
void ensureLogDirectory(const std::string& path) {
std::filesystem::path p(path);
if (!std::filesystem::exists(p)) {
std::filesystem::create_directories(p);
}
}
// 사용
ensureLogDirectory("logs");
auto logger = spdlog::basic_logger_mt("app", "logs/app.log");
오류 5: static_assert 메시지 누락
메시지 인자는 C++17부터 생략할 수 있지만, 없으면 컴파일 에러에 조건식만 나와 의도를 파악하기 어렵습니다. 잘못된 예:
static_assert(sizeof(void*) == 8); // ❌ 조건식만 보임
올바른 예:
static_assert(sizeof(void*) == 8, "This code assumes a 64-bit platform");
오류 6: 멀티스레드 환경에서 로거 공유
단일 스레드용 로거(basic_logger_st)를 여러 스레드에서 동시에 사용하면 데이터 레이스가 발생합니다.
잘못된 예:
// ❌ st = single-threaded
auto logger = spdlog::basic_logger_st("app", "app.log");
std::thread t1([&]{ logger->info("from t1"); });
std::thread t2([&]{ logger->info("from t2"); }); // 데이터 레이스!
올바른 예:
// ✅ mt = multi-threaded
auto logger = spdlog::basic_logger_mt("app", "app.log");
std::thread t1([&]{ logger->info("from t1"); });
std::thread t2([&]{ logger->info("from t2"); });
오류 7: assert로 예외 처리 대체
assert가 실패하면 std::abort()가 호출되어 스택 언와인딩이 일어나지 않고, 예외 핸들러와 소멸자도 실행되지 않습니다. 열린 파일 디스크립터 자체는 프로세스가 끝날 때 운영체제가 닫아 주지만, std::ofstream 버퍼에 남은 데이터가 기록되지 않거나, 임시 파일·락 파일이 지워지지 않거나, 원격 서버에 “종료” 메시지를 보내지 못하는 등 애플리케이션 수준의 정리가 모두 누락됩니다. 그리고 릴리스 빌드에서는 assert가 사라져 이 검사 자체가 없어진다는 점이 더 큰 문제입니다.
잘못된 예:
void processFile(const std::string& path) {
std::ifstream file(path);
assert(file.is_open() && "File open failed"); // ❌ 실패 시 file 닫기 안 됨
// ...
}
올바른 예:
void processFile(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) {
spdlog::error("Failed to open file: {}", path);
throw std::runtime_error("File open failed");
}
// RAII로 file 자동 정리
}
오류 8: 로그 포맷과 인자 개수 불일치
spdlog::info("User {} logged in", id, name)처럼 {} 개수와 인자 개수가 맞지 않는 경우입니다. fmt 8 이상을 쓰는 최신 spdlog는 C++20 모드에서 문자열 리터럴 포맷을 컴파일 시점에 검사해 이런 불일치를 컴파일 에러로 알려 주지만, 포맷 문자열을 변수로 넘기거나 오래된 버전을 쓰면 런타임에 fmt::format_error가 발생하고, spdlog는 이를 잡아 로그 대신 에러 메시지를 출력합니다. {} 개수와 인자 개수를 일치시키고, 포맷 문자열은 가능한 한 리터럴로 둡니다. 로그 메시지를 사용자 입력으로 만든 문자열로 포맷 자리에 넘기면 {가 포함된 입력 하나로 포맷 에러가 날 수 있으므로, 항상 spdlog::info("{}", userText)처럼 인자로 넘겨야 합니다.
로그 레벨 체크, 비동기 로깅, rate limiting으로 오버헤드 줄이기
팁 1: 로그 레벨로 불필요한 연산 건너뛰기
// ❌ 나쁜 예: 항상 문자열 생성
logger.debug("User " + userId + " did " + action);
// ✅ 좋은 예: 레벨 체크 후에만 포맷
if (logger.should_log(LogLevel::DEBUG)) {
logger.debug("User {} did {}", userId, action);
}
spdlog는 내부에서 레벨을 확인한 뒤 포맷팅하므로, 인자가 이미 계산된 값이라면 spdlog::debug("{}", x)만 써도 포맷 비용은 들지 않습니다. 인자 계산 자체가 비싸면 위처럼 레벨 확인으로 감쌉니다.
팁 2: 구조화된 로깅으로 파싱 부담 감소
// ❌ 나쁜 예: 자유 형식 문자열 → 파싱 어려움
logger.info("User 12345 logged in from 192.168.1.1 at 2026-03-10");
// ✅ 좋은 예: JSON/키=값 형식 → 로그 수집기에서 바로 파싱
logger.info(R"({"event":"login","user_id":12345,"ip":"192.168.1.1"})");
팁 3: 비동기 로깅으로 I/O 병목 완화
// spdlog 비동기 로거: 로그를 버퍼에 넣고 별도 스레드에서 파일에 기록
#include "spdlog/async.h"
#include "spdlog/sinks/basic_file_sink.h"
auto async_file = spdlog::basic_logger_mt<spdlog::async_factory>(
"async_logger", "logs/async.log");
팁 4: 프로덕션에서 DEBUG 비활성화
#ifdef NDEBUG
#define LOG_DEBUG(...) ((void)0)
#else
#define LOG_DEBUG(...) logger.debug(__VA_ARGS__)
#endif
팁 5: 로그량 제한 (Rate Limiting)
// 단일 스레드용 예시: 여러 스레드에서 쓰려면 lastLog 접근을 뮤텍스로 보호
class RateLimitedLogger {
std::unordered_map<std::string, std::chrono::steady_clock::time_point> lastLog;
std::chrono::seconds minInterval{1};
public:
void log(const std::string& key, const std::string& msg) {
auto now = std::chrono::steady_clock::now();
auto it = lastLog.find(key);
if (it != lastLog.end() && (now - it->second) < minInterval) {
return; // 스킵
}
lastLog[key] = now;
std::cout << msg << "\n";
}
};
환경별 설정, 크래시 시 flush, 요청 ID 추적, 민감 정보 마스킹
패턴 1: 환경별 로그 설정
LogLevel getLogLevelFromEnv() {
const char* env = std::getenv("LOG_LEVEL");
if (!env) return LogLevel::WARNING; // 기본: 프로덕션은 WARNING
if (strcmp(env, "DEBUG") == 0) return LogLevel::DEBUG;
if (strcmp(env, "INFO") == 0) return LogLevel::INFO;
if (strcmp(env, "WARNING") == 0) return LogLevel::WARNING;
if (strcmp(env, "ERROR") == 0) return LogLevel::ERROR;
return LogLevel::WARNING;
}
int main() {
Logger::get().setLevel(getLogLevelFromEnv());
// ...
}
패턴 2: 크래시 시 로그 flush
#include <csignal>
void setupCrashHandlers() {
auto flushAndExit = [](int) {
Logger::get().flush();
std::abort();
};
signal(SIGABRT, flushAndExit);
signal(SIGSEGV, flushAndExit);
signal(SIGFPE, flushAndExit);
}
이 패턴은 널리 쓰이지만 최선의 노력(best effort) 이라는 점을 알고 써야 합니다. 시그널 핸들러 안에서 호출해도 안전한 함수는 write, _exit 같은 일부(async-signal-safe 함수)뿐이고, 로거의 flush()는 내부적으로 뮤텍스를 잡거나 메모리를 할당할 수 있습니다. 크래시가 하필 로거가 뮤텍스를 잡은 상태에서 일어났다면 핸들러의 flush()가 같은 뮤텍스를 기다리며 프로세스가 영원히 멈추고, 이는 크래시보다 더 나쁜 결과(죽지 않고 응답만 없는 프로세스)가 됩니다. 그래서 마지막 로그를 확실히 남기는 1차 수단은 에러 레벨 이상에서 즉시 flush하는 설정(spdlog의 flush_on(spdlog::level::err))이고, 크래시 원인 분석은 코어 덤프(ulimit -c unlimited)나 크래시 리포터(Crashpad, Breakpad)에 맡기는 조합이 안정적입니다.
패턴 3: 요청 ID 추적 (Request Tracing)
#include <string>
#include <thread>
thread_local std::string g_requestId;
void setRequestId(const std::string& id) {
g_requestId = id;
}
void logWithRequestId(LogLevel level, const std::string& msg) {
std::string full = "[" + g_requestId + "] " + msg;
Logger::get().log(level, full);
}
// HTTP 요청 처리 시
void handleRequest(const Request& req) {
setRequestId(req.getId());
logWithRequestId(LogLevel::INFO, "Request started");
// ...
}
패턴 4: 로그 로테이션 및 보관 정책
// spdlog: 크기 기반 로테이션
auto logger = spdlog::rotating_logger_mt("app", "logs/app.log", 1024*1024*100, 5);
// 100MB마다 로테이션, 최대 5개 파일 (app.log, app.1.log, ...)
// 날짜 기반 로테이션 (spdlog/sinks/daily_file_sink.h)
auto daily = spdlog::daily_logger_mt("daily", "logs/daily", 0, 0);
// 매일 자정 새 파일
패턴 5: 민감 정보 마스킹
std::string maskSensitive(const std::string& input) {
if (input.size() <= 4) return "****";
return input.substr(0, 2) + "****" + input.substr(input.size() - 2);
}
void logUserInfo(const User& u) {
LOG_INFO("User login: id={}, email={}", u.id, maskSensitive(u.email));
}
같이 보면 좋은 글
- C++ 디버깅 기초 | GDB·LLDB 브레이크포인트·워치포인트·단계 실행
- C++ Sanitizers | ASan·TSan으로 메모리 버그·data race 자동 탐지
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ 프로파일링
- C++ 캐시 친화 코드: AoS vs SoA, 데이터 지향 설계, False Sharing