C++ 기술 부채 관리: 레거시 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;
}
메모리 누수, 버퍼 오버런, 매크로 지옥: 레거시에서 겪는 현실
시나리오 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 사용.
우선순위 정하기
위험·변경 빈도·의존성
flowchart TD
subgraph criteria[우선순위 기준]
A[위험도] --> P1[높음: 메모리·버퍼·동시성]
B[변경 빈도] --> P2[높음: 자주 수정되는 파일]
C[의존성] --> P3[높음: 외부 API·공개 인터페이스]
end
P1 --> first[1순위: 먼저 손대기]
P2 --> second[2순위: 이득 큼]
P3 --> third[3순위: 호환성 유지하며]
- 위험도: 메모리 누수나 버퍼 오버런이 의심되는 경로, 여러 스레드가 접근하는데 락 규칙이 불분명한 부분을 프로파일러와 Sanitizer로 먼저 찾습니다. 이런 곳은 테스트를 보강한 다음에 손댑니다.
- 변경 빈도: 자주 수정되는 파일을 현대화하면 이후 모든 수정이 쉬워지므로 이득이 큽니다. 몇 년째 아무도 건드리지 않은 코드는 나중으로 미뤄도 됩니다.
- 의존성: 외부에 노출된 API는 호환성을 지키면서 내부 구현만 바꾸거나, 새 API를 추가하고 구 API에 deprecated 경로를 두는 식으로 단계를 나눕니다.
우선순위 매트릭스
| 구분 | 위험 | 변경 빈도 | 의존성 | 조치 |
|---|---|---|---|---|
| 핵심 파서 | 높음 | 높음 | 내부 | 1순위: 테스트 추가 후 raw 포인터 → unique_ptr |
| 로깅 유틸 | 낮음 | 낮음 | 내부 | 4순위: 나중에 |
| 공개 SDK | 중간 | 낮음 | 외부 | 2순위: 새 API 추가, 구 API deprecated |
| 네트워크 버퍼 | 높음 | 중간 | 내부 | 1순위: vector, 범위 검사 |
점진적 리팩토링 전략
격리·인터페이스·한 번에 한 가지
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 | 중간 |
raw 포인터→unique_ptr, 매크로→constexpr, PIMPL 격리 예제
예제 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&&) noexcept; // 이동 연산도 선언만 하고
Widget& operator=(Widget&&) noexcept; // 정의는 .cpp에서
};
// 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;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
이동 대입은 기존 pImpl을 해제해야 하므로 Impl의 완전한 정의가 필요합니다. 헤더에서 = default로 두면 소멸자와 같은 불완전 타입 에러가 나므로 .cpp로 옮깁니다.
예제 5: 동시성 — 전역 상태를 클래스로 감싸고 mutex로 보호
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 <optional>
#include <string>
#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;
}
};
마이그레이션 전략
단계별 로드맵
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/array, 수동 연결 리스트 → 표준 컨테이너 |
| 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, 제네릭 람다, 반환 타입 추론 |
| 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, 마이그레이션 가이드 |
shared_ptr 과다 사용, PIMPL 소멸자 누락, use-after-move 같은 에러
에러 1: 스마트 포인터 전환 시 소유권 순환
증상: 서로를 가리키던 raw 포인터를 기계적으로 스마트 포인터로 바꾸면, 두 객체가 서로를 소유하게 되어 어느 쪽도 해제되지 않습니다.
원인: 소유권이 순환합니다. shared_ptr끼리 서로 가리키면 참조 카운트가 0이 되지 않아 누수가 생깁니다.
// ❌ 잘못된 설계: 서로 소유
struct B;
struct A {
std::shared_ptr<B> b;
};
struct B {
std::shared_ptr<A> a; // 순환: A → B → A, 둘 다 해제되지 않음
};
해결법: 소유권은 한 방향으로만 둡니다. B가 A를 되짚어 참조해야 한다면 소유권이 없는 raw 포인터나 참조를 쓰고, 둘 다 shared_ptr로 관리해야 하는 구조라면 역방향은 weak_ptr로 둡니다.
// ✅ 올바른 설계
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 에러.
원인: 소멸자를 아예 선언하지 않거나 헤더 안에서 인라인으로 정의하면, Impl이 불완전 타입인 번역 단위에서 unique_ptr<Impl>의 삭제 코드가 만들어져 컴파일 에러가 납니다. 반대로 선언만 해 두고 .cpp에 정의를 빠뜨리면 링크 에러가 납니다.
// ❌ 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 후 원본의 값을 그대로 쓰면 예상과 다른 결과가 나옵니다. 표준 라이브러리 타입은 이동 후 “유효하지만 지정되지 않은(valid but unspecified)” 상태가 되므로, 값을 다시 대입하기 전에는 내용을 가정하면 안 됩니다. 직접 만든 클래스는 이동 후 상태를 보장하지 않는 경우가 많아 크래시로 이어지기도 합니다.
// ❌ 잘못된 코드
std::vector<int> vec = {1, 2, 3};
std::vector<int> other = std::move(vec);
std::cout << vec[0]; // 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))를 템플릿 함수로 바꾸면, 매크로 시절에는 조용히 넘어가던 MAX(1, 2u) 같은 호출이 컴파일 에러가 됩니다. 두 인자의 타입이 달라 T를 하나로 추론할 수 없기 때문입니다.
// ❌ 타입 불일치
template <typename T>
constexpr T max_val(T a, T b) { return a > b ? a : b; }
max_val(1, 2u); // 에러: T를 int와 unsigned 중 무엇으로 추론할지 결정 불가
해결법: 이 에러는 사실 매크로가 숨기고 있던 signed/unsigned 혼합 비교를 드러낸 것입니다. 공통 타입으로 받아 주는 템플릿을 만들면 컴파일은 되지만 -1과 1u 비교 같은 문제는 그대로 남으므로, 호출부에서 타입을 명시하는 편이 낫습니다.
// ✅ 표준 std::max를 쓰고 비교할 타입을 명시
#include <algorithm>
auto m = std::max<long long>(1, 2u);
에러 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 경계 함수에서 try/catch로 모든 예외를 잡아 C 스타일 에러 코드로 변환합니다. 경계 함수를 noexcept로만 표시하면 예외가 빠져나가는 순간 std::terminate가 호출되므로, 예외를 삼키는 처리는 따로 있어야 합니다.
// C API 경계
extern "C" int process_data(const char* path) {
try {
auto result = modernProcess(std::string(path));
return 0; // 성공
} catch (...) {
return -1; // C 스타일 에러
}
}
Facade 래핑, Feature Flag 롤아웃, Adapter, 테스트 주도 현대화
패턴 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 | 타입 안전, 디버깅 용이 |
도구로 안전망 쌓기
테스트·정적 분석·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-make-unique
modernize-use-nullptr
modernize-use-override
modernize-use-emplace
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
현대화 작업 점검 항목
- [ ] 핵심 경로 회귀 테스트 추가
- [ ] 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 적용
같이 보면 좋은 글
- unique_ptr 고급 활용
- 스마트 포인터 입문
- C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기 [#38-3]
- C++ 오픈소스 기여: 유명 라이브러리 분석부터 첫 Pull Request까지 [#45-1]
- X-Macro로 중복 없애기
- 주니어에서 시니어까지: C++ 개발자가 단계별로 갖춰야 할 역량
- C++26 프리뷰: Reflection과 신규 표준 라이브러리 제안들 [#44-1]
- SFINAE 패턴 정리
자주 묻는 질문 (FAQ)
Q. PIMPL로 바꾼 뒤 unique_ptr의 불완전 타입 컴파일 에러가 나는 이유는 무엇인가요?
A. std::unique_ptr<Impl>을 멤버로 두면, 소유 클래스의 소멸자가 만들어지는 지점에서 Impl이 완전한 타입이어야 delete를 생성할 수 있습니다. 소멸자를 선언하지 않으면 컴파일러가 헤더 안에서 암시적으로 만들기 때문에, Impl의 정의가 없는 번역 단위에서 불완전 타입 에러가 납니다. 헤더에는 소멸자를 선언만 해 두고 Impl을 정의한 .cpp 파일에서 = default로 정의하면 해결되며, 이동 생성자와 이동 대입도 같은 방식으로 .cpp로 옮겨야 합니다.
Q. 전면 재작성 vs 점진적 현대화, 어떤 게 나을까요?
A. 대부분의 경우 점진적 현대화가 낫습니다. 전면 재작성은 비용과 리스크가 크고, 기존 동작과 같은지 검증하기 어렵습니다. 테스트를 보강한 뒤 모듈별로 격리해 바꾸면 리스크를 줄일 수 있습니다.
Q. unique_ptr와 shared_ptr, 어떤 걸 써야 하나요?
A. 소유자가 하나면 unique_ptr, 여러 곳이 수명을 함께 책임져야 하면 shared_ptr입니다. 막연히 shared_ptr를 쓰면 참조 카운팅 오버헤드와 순환 참조 위험이 있습니다.