C++ 기술 부채 관리: 레거시 C++ 프로젝트를 현대화하는 전략적 리팩토링 [#45-2]
이 글의 핵심
C++ 기술 부채 관리: 레거시 C++ 프로젝트를 현대화하는 전략적 리팩토링 [#45-2]에 대해 정리한 개발 블로그 글입니다. 레거시 C++는 raw 포인터, 매크로, C 스타일, 빌드/의존성 난맥이 섞여 있어 "한 번에 갈아엎자"는 위험하고 비현실적인 경우가 많습니다. 전략적 리팩토링은 우선순위를 정하고 점진적으로 바꾸면서 동작을 유지하는… 개념과 예제 코드를 단계적으로 다루며, 실무·…
들어가며: “손대면 무서운” 코드베이스
전면 재작성이 답은 아니다
레거시 C++는 raw 포인터, 매크로, C 스타일, 빌드/의존성 난맥이 섞여 있어 “한 번에 갈아엎자”는 위험하고 비현실적인 경우가 많습니다. 전략적 리팩토링은 우선순위를 정하고 점진적으로 바꾸면서 동작을 유지하는 것입니다.
이 글에서는 어디부터 손댈지, 테스트·정적 분석·CI를 어떻게 활용할지, 스마트 포인터·STL·모던 문법 도입 순서를 어떻게 잡을지, 실제 문제 시나리오와 완전한 현대화 예제, 자주 발생하는 에러, 마이그레이션 전략, 프로덕션 패턴까지 다룹니다.
실행 가능 예제 (레거시를 모던 스타일로 바꾼 최소 예 — raw 포인터 → unique_ptr):
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o legacy_demo legacy_demo.cpp && ./legacy_demo
#include <memory>
#include <iostream>
int main() {
auto p = std::make_unique<int>(42); // 레거시: int* p = new int(42);
std::cout << *p << "\n";
return 0;
}
이 글에서 다루는 것:
- 문제 시나리오: 레거시에서 겪는 현실적인 문제들
- 우선순위: 위험·변경 빈도·의존성 파악
- 점진적 변경: 격리·인터페이스 유지·한 번에 한 가지
- 완전한 현대화 예제: raw 포인터 → unique_ptr, 매크로 → constexpr 등
- 마이그레이션 전략: 단계별 전환 로드맵
- 자주 발생하는 에러: 실패 패턴과 해결법
- 프로덕션 패턴: 실무에서 검증된 접근법
- 도구: 테스트·Clang-Tidy·Sanitizer·CI
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 문제 시나리오: 레거시에서 겪는 현실
- 우선순위 정하기
- 점진적 리팩토링 전략
- 완전한 현대화 예제
- 마이그레이션 전략
- 자주 발생하는 에러와 해결법
- 프로덕션 패턴
- 도구로 안전망 쌓기
- 정리
1. 문제 시나리오: 레거시에서 겪는 현실
시나리오 1: 메모리 누수로 프로덕션 크래시
증상: 서버가 72시간 가동 후 메모리 사용량이 계속 증가하다 OOM으로 종료됨.
원인: C 스타일 malloc/new와 raw 포인터 사용. 예외 경로나 분기에서 delete 누락.
// ❌ 레거시: 예외 시 메모리 누수
void processRequest(const char* path) {
FILE* fp = fopen(path, "r");
if (!fp) return;
char* buffer = (char*)malloc(4096);
if (!buffer) { fclose(fp); return; }
// ... 파싱 중 예외 발생 시 buffer, fp 둘 다 누수
free(buffer);
fclose(fp);
}
해결: RAII·스마트 포인터·표준 컨테이너로 자동 정리.
// ✅ 현대화: RAII로 자동 정리
void processRequest(const std::string& path) {
std::ifstream file(path);
if (!file) return;
std::vector<char> buffer(4096);
// 예외 발생해도 vector, ifstream 소멸자가 자동 정리
}
시나리오 2: 버퍼 오버런으로 보안 취약점
증상: strcpy, sprintf 사용으로 스택/힙 오버플로우, ASLR 우회 가능성.
// ❌ 레거시: 버퍼 오버런 위험
void copyName(char* dest, const char* src) {
strcpy(dest, src); // src 길이 검증 없음
}
해결: std::string, std::array, 범위 기반 루프.
// ✅ 현대화: 안전한 API
void copyName(std::string& dest, const std::string& src) {
dest = src; // 길이 자동 관리
}
시나리오 3: 동시성 버그 — 락 없이 공유 자원 접근
증상: 멀티스레드 환경에서 가끔 데이터 손상, 크래시. 재현 어려움.
// ❌ 레거시: 전역 변수에 락 없이 접근
static std::vector<int> g_cache;
void addToCache(int x) {
g_cache.push_back(x); // 데이터 레이스!
}
해결: std::mutex, std::atomic, 스레드 로컬 저장소.
// ✅ 현대화: 락으로 보호
static std::vector<int> g_cache;
static std::mutex g_mutex;
void addToCache(int x) {
std::lock_guard<std::mutex> lock(g_mutex);
g_cache.push_back(x);
}
시나리오 4: 매크로 남발로 디버깅 지옥
증상: #define MAX(a,b) ((a)>(b)?(a):(b)) 같은 매크로가 MAX(i++, j)에서 부작용, 전처리 후 코드 추적 어려움.
해결: constexpr 함수, inline 함수.
// ❌ 레거시
#define MAX(a, b) ((a) > (b) ? (a) : (b))
// ✅ 현대화
template <typename T>
constexpr T max_val(T a, T b) { return a > b ? a : b; }
시나리오 5: 빌드 의존성 지옥
증상: 한 헤더 수정 시 200개 파일 재컴파일, 전체 빌드 15분.
해결: PIMPL(#19-3), 전방 선언, 모듈(C++20).
시나리오 6: 예외 안전성 부재
증상: 예외 발생 시 리소스 누수, 부분적으로만 적용된 변경으로 데이터 불일치.
// ❌ 레거시: 예외 시 리소스 누수
void loadConfig() {
FILE* f = fopen("config.ini", "r");
char* buf = (char*)malloc(1024);
parseConfig(buf); // 예외 던지면 f, buf 둘 다 누수
free(buf);
fclose(f);
}
해결: RAII, 스마트 포인터, 표준 스트림.
// ✅ 현대화: 예외 안전
void loadConfig() {
std::ifstream f("config.ini");
std::string buf;
buf.resize(1024);
parseConfig(buf); // 예외 나도 f, buf 자동 정리
}
시나리오 7: 플랫폼별 #ifdef 난맥
증상: #ifdef _WIN32 / #ifdef __linux__가 수십 곳에 흩어져 있어 가독성·유지보수 어려움.
해결: 추상화 레이어, 플랫폼별 구현체(PIMPL·브릿지), std::filesystem(C++17) 등 플랫폼 중립 API 사용.
2. 우선순위 정하기
위험·변경 빈도·의존성
flowchart TD
subgraph criteria["우선순위 기준"]
A[위험도] --> P1[높음: 메모리·버퍼·동시성]
B[변경 빈도] --> P2[높음: 자주 수정되는 파일]
C[의존성] --> P3[높음: 외부 API·공개 인터페이스]
end
P1 --> first[1순위: 먼저 손대기]
P2 --> second[2순위: 이득 큼]
P3 --> third[3순위: 호환성 유지하며]
- 위험한 부분: 메모리 누수·버퍼 오버런이 의심되는 경로, 동시성이 있는데 락이 불명확한 부분을 프로파일·Sanitizer로 찾고, 테스트를 먼저 보강한 뒤 손댑니다.
- 변경 빈도: 자주 수정되는 파일을 현대화하면 이득이 크고, 한 번도 안 건드리는 부분은 나중으로 미뤄도 됩니다.
- 의존성: 외부에 노출되는 API는 호환성을 유지하면서 내부 구현만 바꾸거나, 새 API를 추가하고 deprecate 경로를 두는 식으로 단계를 나눕니다.
우선순위 매트릭스
| 구분 | 위험 | 변경 빈도 | 의존성 | 조치 |
|---|---|---|---|---|
| 핵심 파서 | 높음 | 높음 | 내부 | 1순위: 테스트 추가 후 raw 포인터 → unique_ptr |
| 로깅 유틸 | 낮음 | 낮음 | 내부 | 4순위: 나중에 |
| 공개 SDK | 중간 | 낮음 | 외부 | 2순위: 새 API 추가, 구 API deprecated |
| 네트워크 버퍼 | 높음 | 중간 | 내부 | 1순위: vector, 범위 검사 |
3. 점진적 리팩토링 전략
격리·인터페이스·한 번에 한 가지
sequenceDiagram
participant Dev as 개발자
participant Test as 테스트
participant Code as 코드베이스
Dev->>Test: 1. 기존 동작 테스트 추가
Test->>Code: 회귀 테스트 통과 확인
Dev->>Code: 2. 한 모듈만 격리
Dev->>Code: 3. 내부만 변경 (API 유지)
Dev->>Test: 4. 테스트 재실행
Test->>Dev: 통과 시 다음 단계
- 격리: 한 모듈·한 클래스 단위로 경계를 정하고, 외부 동작은 테스트로 고정한 뒤 내부만 바꿉니다. PIMPL(#19-3, #38-3)로 구현을 숨기면 내부 교체가 수월해집니다.
- 인터페이스 유지: 공개 API 시그니처는 유지하고, 내부에서만 스마트 포인터·STL·auto를 도입합니다. 새 API가 필요하면 오버로드나 새 함수로 추가하고 구 API는 deprecated로 표시합니다.
- 한 번에 한 가지: “이번 PR은 raw 포인터 → unique_ptr만”, “이번 PR은 매크로 → constexpr만” 같이 한 종류의 변경으로 나누면 리뷰와 롤백이 쉽습니다.
PR 단위 분리 예시
| PR | 변경 범위 | 리스크 |
|---|---|---|
| PR #1 | ConfigParser: char* → std::string | 낮음 |
| PR #2 | ConfigParser: new/delete → unique_ptr | 중간 |
| PR #3 | ConfigParser: C 스타일 루프 → 범위 for | 낮음 |
| PR #4 | NetworkBuffer: raw 배열 → std::vector | 중간 |
4. 완전한 현대화 예제
예제 1: raw 포인터 → unique_ptr (리소스 관리 클래스)
Before (레거시):
// ❌ 레거시: 수동 메모리 관리
class DataLoader {
int* buffer_;
size_t size_;
public:
DataLoader(size_t n) : size_(n), buffer_(new int[n]) {}
~DataLoader() { delete[] buffer_; }
// 복사 생성자, 대입 연산자 누락 → 얕은 복사 위험
};
After (현대화):
// ✅ 현대화: unique_ptr로 소유권 명확화
#include <memory>
#include <vector>
class DataLoader {
std::unique_ptr<int[]> buffer_; // 또는 std::vector<int>
size_t size_;
public:
explicit DataLoader(size_t n)
: buffer_(std::make_unique<int[]>(n)), size_(n) {}
// 복사 비활성화 (기본), 이동은 자동
DataLoader(DataLoader&&) = default;
DataLoader& operator=(DataLoader&&) = default;
// 소멸자 자동 생성, 메모리 안전
};
주의점: std::vector<int>를 쓰면 unique_ptr<int[]>보다 더 단순하고 표준적입니다. vector가 크기·반복자·범위 검사까지 제공합니다.
// ✅ 더 권장: vector 사용
class DataLoader {
std::vector<int> buffer_;
public:
explicit DataLoader(size_t n) : buffer_(n) {}
// 복사·이동·소멸 모두 자동
};
예제 2: C 스타일 배열·매크로 → STL·constexpr
Before (레거시):
// ❌ 레거시
#define MAX_ITEMS 100
#define GET_ITEM(arr, i) ((arr)[(i)])
void processItems(int* items, int count) {
for (int i = 0; i < count; ++i) {
int val = GET_ITEM(items, i);
// ...
}
}
After (현대화):
// ✅ 현대화
#include <vector>
#include <span> // C++20
constexpr size_t max_items = 100;
void processItems(std::span<const int> items) {
for (int val : items) {
// ...
}
}
C++17 이하에서는 std::span 대신 std::vector 참조 또는 (ptr, size) 쌍을 사용합니다.
// ✅ C++17: vector 참조
void processItems(const std::vector<int>& items) {
for (int val : items) {
// ...
}
}
예제 3: 팩토리 함수 — raw 포인터 반환 → unique_ptr
Before (레거시):
// ❌ 레거시: 소유권 불명확, 누수 위험
Widget* createWidget() {
return new Widget();
}
// 호출자가 delete 해야 함 — 누락 시 누수
After (현대화):
// ✅ 현대화: 소유권 이전 명확
std::unique_ptr<Widget> createWidget() {
return std::make_unique<Widget>();
}
// 호출자가 unique_ptr로 받으면 자동 해제
예제 4: PIMPL로 컴파일 의존성 격리
Before (레거시): 헤더에 구현 노출 → include 변경 시 대량 재컴파일.
// ❌ widget.h — 무거운 의존성 노출
#include "heavy_library.h" // 50개 헤더 끌어옴
class Widget {
HeavyType member_; // Widget 사용하는 모든 파일이 heavy_library 필요
};
After (현대화): PIMPL로 구현을 .cpp로 이동.
// ✅ widget.h — 경량
#include <memory>
class Widget {
struct Impl;
std::unique_ptr<Impl> pImpl;
public:
Widget();
~Widget(); // .cpp에 정의 필수
Widget(Widget&&) = default;
Widget& operator=(Widget&&) = default;
};
// widget.cpp — 여기서만 heavy_library 사용
#include "widget.h"
#include "heavy_library.h"
struct Widget::Impl {
HeavyType member_;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
예제 5: 동시성 — 전역 락 없이 스레드 안전 설계
Before (레거시):
// ❌ 레거시: 전역 상태, 락 없음
static std::map<int, std::string> g_cache;
void setCache(int k, const std::string& v) {
g_cache[k] = v; // 데이터 레이스
}
After (현대화):
// ✅ 현대화: mutex로 보호
#include <mutex>
#include <unordered_map>
class ThreadSafeCache {
std::unordered_map<int, std::string> cache_;
mutable std::mutex mtx_;
public:
void set(int k, std::string v) {
std::lock_guard<std::mutex> lock(mtx_);
cache_[k] = std::move(v);
}
std::optional<std::string> get(int k) const {
std::lock_guard<std::mutex> lock(mtx_);
auto it = cache_.find(k);
return it != cache_.end() ? std::optional(it->second) : std::nullopt;
}
};
5. 마이그레이션 전략
단계별 로드맵
flowchart LR
A[1. 테스트] --> B[2. 정적분석]
B --> C[3. raw 포인터]
C --> D[4. STL 컨테이너]
D --> E[5. 모던 문법]
E --> F[6. PIMPL/구조]
| 단계 | 작업 | 목표 |
|---|---|---|
| 1 | 테스트 | 핵심 경로 회귀 테스트 추가 |
| 2 | 정적 분석 | Clang-Tidy, Cppcheck CI 도입 |
| 3 | raw 포인터 | new/delete → unique_ptr/shared_ptr |
| 4 | STL 컨테이너 | C 배열 → vector, map → unordered_map |
| 5 | 모던 문법 | auto, 범위 for, nullptr, override |
| 6 | 구조 | PIMPL, 인터페이스 분리 |
API 호환성 유지: deprecated 경로
// 새 API 추가, 구 API deprecated
class LegacyAPI {
public:
// ✅ 새 API
void process(const std::string& path);
// ⚠️ 구 API — deprecate 유지
[[deprecated("Use process(const std::string&) instead")]]
void process(const char* path) {
process(std::string(path)); // 내부 전환
}
};
점진적 C++ 표준 업그레이드
| 현재 | 목표 | 주의점 |
|---|---|---|
| C++03 | C++11 | nullptr, auto, 스마트 포인터, 이동 |
| C++11 | C++14 | make_unique, 범위 for 개선 |
| C++14 | C++17 | if constexpr, optional, variant |
| C++17 | C++20 | span, modules, concepts |
권장: 한 번에 한 표준씩 올리고, 각 단계에서 테스트·Sanitizer로 검증.
마이그레이션 타임라인 예시 (8주)
| 주차 | 작업 | 산출물 |
|---|---|---|
| 1–2 | 핵심 경로 테스트 추가, CI 구축 | 회귀 테스트 50개, GitHub Actions |
| 3 | Clang-Tidy 도입, 경고 수 0 목표 | .clang-tidy, CI 통합 |
| 4–5 | ConfigParser 현대화 (raw → unique_ptr, string) | PR 2개, 리뷰·머지 |
| 6 | NetworkBuffer 현대화 (배열 → vector) | PR 1개 |
| 7 | 매크로 → constexpr, C 루프 → 범위 for | PR 1–2개 |
| 8 | ASan/TSan 빌드 추가, 문서화 | Sanitizer CI job, 마이그레이션 가이드 |
6. 자주 발생하는 에러와 해결법
에러 1: unique_ptr로 전환 시 순환 참조
증상: A가 B를 unique_ptr로 갖고, B가 A를 참조할 때 A 소멸 시 B가 먼저 파괴되어야 하는데, B가 A를 가리키면 문제.
원인: 소유권이 순환됨.
// ❌ 잘못된 설계
struct B;
struct A {
std::unique_ptr<B> b;
};
struct B {
A* a; // 순환: A → B → A
};
해결법: 소유권은 한 방향으로. B가 A를 참조할 때는 raw 포인터 또는 참조 (소유권 없음) 사용.
// ✅ 올바른 설계
struct B;
struct A {
std::unique_ptr<B> b;
};
struct B {
A* a; // 소유권 없음: A가 B를 소유, B는 A를 참조만
};
에러 2: shared_ptr 과다 사용
증상: 단일 소유인데 shared_ptr 사용 → 참조 카운팅 오버헤드, 순환 참조 위험.
원인: “포인터니까 shared_ptr” 습관.
// ❌ 불필요한 shared_ptr
std::shared_ptr<Config> config = std::make_shared<Config>();
// Config는 한 곳에서만 소유
// ✅ 올바른 선택
std::unique_ptr<Config> config = std::make_unique<Config>();
해결법: 소유권이 하나면 unique_ptr, 여러 곳에서 공유해야 할 때만 shared_ptr.
에러 3: PIMPL 소멸자 누락
증상: unique_ptr<Impl> 사용 시 링크 에러 또는 incomplete type 에러.
원인: 소멸자를 헤더에만 두고 .cpp에 정의하지 않음. Impl이 불완전 타입인 상태에서 unique_ptr의 소멸자가 호출되면 안 됨.
// ❌ widget.h — 소멸자 정의 없음
class Widget {
struct Impl;
std::unique_ptr<Impl> pImpl;
public:
~Widget(); // 선언만, 정의 없음 → 링크 에러
};
해결법: 소멸자를 .cpp에 정의.
// ✅ widget.cpp
Widget::~Widget() = default; // Impl이 완전한 타입인 시점에서 정의
에러 4: 이동 후 원본 사용 (Use-After-Move)
증상: std::move 후 원본 객체 사용 시 빈 값, 크래시, UB.
// ❌ 잘못된 코드
std::vector<int> vec = {1, 2, 3};
std::vector<int> other = std::move(vec);
vec.push_back(4); // 위험: vec는 비어 있거나 불안정
해결법:
// ✅ 올바른 코드
std::vector<int> vec = {1, 2, 3};
std::vector<int> other = std::move(vec);
// vec 사용 금지. 필요하면 새로 할당:
vec = {1, 2, 3, 4};
정적 분석: Clang-Tidy bugprone-use-after-move 체크로 검출 가능.
에러 5: std::move를 const 객체에 적용
증상: 이동이 의도대로 동작하지 않음 (복사가 호출됨).
원인: const T를 std::move해도 이동 생성자는 const를 제거할 수 없어 복사 생성자가 선택됨.
// ❌ 잘못된 코드
const std::string str = "Hello";
std::string other = std::move(str); // 복사! (const이므로 이동 불가)
해결법:
// ✅ 올바른 코드
std::string str = "Hello";
std::string other = std::move(str); // 이동
에러 6: 매크로 → constexpr 전환 시 타입 문제
증상: #define MAX(a,b) ((a)>(b)?(a):(b))를 constexpr 함수로 바꿀 때 int와 unsigned 비교 등에서 경고.
// ❌ 타입 불일치
template <typename T>
constexpr T max_val(T a, T b) { return a > b ? a : b; }
max_val(1, 2u); // T 추론 불일치 가능
해결법:
// ✅ std::max 사용 또는 공통 타입
template <typename T, typename U>
constexpr auto max_val(T a, U b) -> decltype(a > b ? a : b) {
return a > b ? a : b;
}
// 또는 그냥 std::max 사용
에러 7: 레거시 API와의 호환성 깨짐
증상: C API(void*, 콜백)와 연동하는 코드에서 unique_ptr를 넘기다 ABI가 맞지 않음.
해결법: 경계에서 get()으로 raw 포인터 전달, 소유권은 호출자 책임으로 문서화.
// C API와의 경계
extern "C" void c_api_process(void* data);
void modern_wrapper() {
auto ptr = std::make_unique<MyData>();
c_api_process(ptr.get()); // C API가 소유권 가져가지 않음
// ptr 소멸 시 자동 해제
}
에러 8: 레거시 코드에서 예외 사용 시 ABI 불일치
증상: 레거시 라이브러리가 예외를 사용하지 않는데, 현대화된 코드에서 예외를 던지면 경계에서 충돌.
해결법: C API 경계에서는 noexcept로 예외 전파를 막거나, try/catch로 감싸서 C 스타일 에러 코드로 변환.
// C API 경계
extern "C" int process_data(const char* path) {
try {
auto result = modernProcess(std::string(path));
return 0; // 성공
} catch (...) {
return -1; // C 스타일 에러
}
}
7. 프로덕션 패턴
패턴 1: Facade로 레거시 래핑
레거시 모듈 전체를 새 인터페이스로 감싸고, 내부만 점진적으로 현대화합니다.
// modern_facade.h — 새 API
#include <memory>
#include <string>
class LegacyFacade {
struct Impl;
std::unique_ptr<Impl> pImpl;
public:
LegacyFacade();
~LegacyFacade();
void process(const std::string& path); // 모던 API
};
// legacy_facade.cpp — 내부에서 레거시 호출
#include "legacy_facade.h"
#include "legacy_parser.h" // C 스타일 레거시
struct LegacyFacade::Impl {
LegacyParser* parser; // 레거시 타입
};
LegacyFacade::LegacyFacade() : pImpl(std::make_unique<Impl>()) {
pImpl->parser = legacy_create();
}
LegacyFacade::~LegacyFacade() {
legacy_destroy(pImpl->parser);
}
void LegacyFacade::process(const std::string& path) {
legacy_parse(pImpl->parser, path.c_str()); // 경계에서 변환
}
패턴 2: Feature Flag로 점진적 롤아웃
// feature_flags.h
namespace config {
inline constexpr bool use_modern_parser = true; // 빌드 시 전환
}
// parser.cpp
void parse(const std::string& path) {
if constexpr (config::use_modern_parser) {
modernParse(path);
} else {
legacyParse(path.c_str());
}
}
패턴 3: Adapter로 구 API 유지
// 기존 코드가 기대하는 인터페이스
class OldInterface {
public:
virtual ~OldInterface() = default;
virtual void doWork(const char* path) = 0;
};
// 새 구현을 Adapter로 연결
class ModernAdapter : public OldInterface {
std::string path_;
public:
void doWork(const char* path) override {
path_ = path;
modernProcess(path_); // 내부는 모던
}
};
패턴 4: 테스트 주도 현대화
- 레거시 코드에 테스트 추가 (동작 고정)
- 리팩토링 (내부 변경)
- 테스트 통과 확인
- 반복
// 테스트로 동작 고정
TEST(LegacyParser, ParseBasicConfig) {
std::string result = parseLegacy("config.ini");
EXPECT_EQ(result, "expected_output");
}
// 이후 parseLegacy 내부를 modernParse로 교체해도
// 테스트 결과가 같으면 성공
패턴 5: 모듈별 현대화 순서
flowchart TB
subgraph phase1["Phase 1: 기반"]
T1[테스트 인프라]
SA[정적 분석 CI]
T1 --> SA
end
subgraph phase2["Phase 2: 핵심"]
P1[파서/로더]
P2[버퍼 관리]
P1 --> P2
end
subgraph phase3["Phase 3: 확장"]
P3[유틸리티]
P4[공개 API]
P3 --> P4
end
SA --> P1
P2 --> P3
패턴 6: 레거시 C API 래퍼
C 라이브러리를 C++로 감쌀 때 RAII 래퍼를 두면 리소스 관리가 명확해집니다.
// C API: 리소스 수동 관리
// void* create_handle();
// void destroy_handle(void*);
class HandleGuard {
void* handle_;
public:
HandleGuard() : handle_(create_handle()) {
if (!handle_) throw std::runtime_error("create failed");
}
~HandleGuard() { destroy_handle(handle_); }
HandleGuard(const HandleGuard&) = delete;
HandleGuard& operator=(const HandleGuard&) = delete;
void* get() const { return handle_; }
};
현대화 전후 비교 요약
| 항목 | 레거시 | 현대화 | 효과 |
|---|---|---|---|
| 메모리 관리 | new/delete 수동 | unique_ptr, vector | 누수·이중 해제 방지 |
| 문자열 | char*, strcpy | std::string | 버퍼 오버런 방지 |
| 컨테이너 | C 배열, 수동 크기 | vector, map | 범위 검사, 반복자 |
| 동시성 | 전역 변수, 락 없음 | mutex, atomic | 데이터 레이스 방지 |
| 빌드 | 헤더 의존성 과다 | PIMPL, 전방 선언 | 재컴파일 범위 축소 |
| 상수 | #define | constexpr | 타입 안전, 디버깅 용이 |
8. 도구로 안전망 쌓기
테스트·정적 분석·CI
- 테스트: 기존 동작을 회귀 테스트로 잡아 두고, 리팩토링 전후에 계속 돌립니다. 테스트가 없으면 먼저 핵심 경로에만이라도 추가하는 것이 좋습니다.
- 정적 분석: Clang-Tidy·Cppcheck를 CI에 넣고, 새로 도입하는 규칙은 경고부터 켜서 점진적으로 에러로 올립니다.
- Sanitizer: ASan(AddressSanitizer)·TSan(ThreadSanitizer)으로 리팩토링 후 테스트를 돌리면 숨은 메모리·경합 버그를 잡을 수 있습니다. CI에 Sanitizer 빌드 job을 두면 안전합니다.
Clang-Tidy 예시
# .clang-tidy 설정 예시
Checks: >
modernize-*,
bugprone-*,
performance-*,
-modernize-use-trailing-return-type
# 권장 체크
modernize-use-unique_ptr
modernize-use-emplace_back
bugprone-use-after-move
performance-for-range-copy
CI 파이프라인 예시
# GitHub Actions 예시 (개념)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cmake -B build && cmake --build build
- name: Test
run: ./build/tests
- name: Clang-Tidy
run: run-clang-tidy
- name: ASan Build
run: cmake -B build-asan -DSANITIZE=Address && cmake --build build-asan
- name: ASan Test
run: ./build-asan/tests
Sanitizer 빌드
# ASan: 메모리 오류 검출
g++ -fsanitize=address -g -O1 -o app app.cpp
# TSan: 데이터 레이스 검출
g++ -fsanitize=thread -g -O1 -o app app.cpp
9. 정리
| 항목 | 요약 |
|---|---|
| 문제 시나리오 | 메모리 누수, 버퍼 오버런, 동시성 버그, 매크로, 빌드 의존성 |
| 우선순위 | 위험·변경 빈도·의존성으로 순서 정하기 |
| 전략 | 격리·인터페이스 유지·한 번에 한 종류 변경 |
| 완전한 예제 | raw 포인터→unique_ptr, 매크로→constexpr, PIMPL, 동시성 |
| 마이그레이션 | 테스트→정적분석→포인터→STL→문법→구조 |
| 자주 발생하는 에러 | 순환 참조, shared_ptr 과다, PIMPL 소멸자, use-after-move |
| 프로덕션 패턴 | Facade, Feature Flag, Adapter, 테스트 주도 |
| 도구 | 테스트·Clang-Tidy·Sanitizer·CI로 안전망 |
45-2로 레거시 C++ 현대화의 전략적 리팩토링을 정리했습니다.
현대화 체크리스트
- [ ] 핵심 경로 회귀 테스트 추가
- [ ] Clang-Tidy/Cppcheck CI 도입
- [ ] ASan/TSan 빌드 job 추가
- [ ] raw 포인터 → unique_ptr/shared_ptr (우선순위 순)
- [ ] C 배열 → std::vector, std::array
- [ ] 매크로 → constexpr, inline
- [ ] C 스타일 루프 → 범위 for
- [ ] 공개 API: deprecated 경로 유지
- [ ] PIMPL: 무거운 헤더 격리
- [ ] 동시성: mutex/atomic 적용
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ unique_ptr 고급 완벽 가이드 | 커스텀 삭제자·배열
- C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
- C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기 [#38-3]
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
이 글에서 다루는 키워드 (관련 검색어)
레거시 현대화, 리팩토링, C++, unique_ptr, PIMPL, Clang-Tidy, Sanitizer, 기술 부채 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 레거시 C++ 코드베이스를 안전하게 현대화하는 전략: 우선순위, 점진적 변경, 테스트·정적 분석·CI 활용을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 전면 재작성 vs 점진적 현대화, 어떤 게 나을까요?
A. 대부분의 경우 점진적 현대화가 낫습니다. 전면 재작성은 비용·리스크가 크고, 기존 동작 검증이 어렵습니다. 테스트를 보강한 뒤 모듈별로 격리하면 리스크를 줄일 수 있습니다.
Q. unique_ptr와 shared_ptr, 어떤 걸 써야 하나요?
A. 소유권이 한 곳이면 unique_ptr, 여러 곳에서 공유해야 하면 shared_ptr입니다. 막연히 shared_ptr를 쓰면 참조 카운팅 오버헤드와 순환 참조 위험이 있습니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다. PIMPL(#19-3), 인터페이스·ABI(#38-3)를 읽어보면 좋습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: 점진적 리팩토링·테스트·의존성 정리로 레거시를 현대 C++로 끌어올릴 수 있습니다. 다음으로 C++ 개발자 로드맵(#45-3)를 읽어보면 좋습니다.
이전 글: 커리어 가이드 #45-1: 오픈소스 기여
다음 글: [커리어 가이드 #45-3] C++ 개발자 로드맵: 주니어에서 시니어로 가기 위한 필수 역량 총정리
관련 글
- C++ 오픈소스 기여: 유명 라이브러리 분석부터 첫 Pull Request까지 [#45-1]
- C++ X-Macro 완벽 가이드 | enum-string 매핑·에러 코드·상태 머신·커맨드 테이블 실전
- C++ 개발자 로드맵: 주니어에서 시니어로 가기 위한 필수 역량 총정리 [#45-3]
- C++26 프리뷰: Reflection과 신규 표준 라이브러리 제안들 [#44-1]
- C++ SFINAE 완벽 가이드 | enable_if·void_t