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 #1ConfigParser: char* → std::string낮음
PR #2ConfigParser: new/delete → unique_ptr중간
PR #3ConfigParser: C 스타일 루프 → 범위 for낮음
PR #4NetworkBuffer: 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 도입
3raw 포인터new/delete → unique_ptr/shared_ptr
4STL 컨테이너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++03C++11nullptr, auto, 스마트 포인터, 이동
C++11C++14make_unique, 제네릭 람다, 반환 타입 추론
C++14C++17if constexpr, optional, variant
C++17C++20span, modules, concepts

권장: 한 번에 한 표준씩 올리고, 각 단계에서 테스트·Sanitizer로 검증.

마이그레이션 타임라인 예시 (8주)

주차작업산출물
1–2핵심 경로 테스트 추가, CI 구축회귀 테스트 50개, GitHub Actions
3Clang-Tidy 도입, 경고 수 0 목표.clang-tidy, CI 통합
4–5ConfigParser 현대화 (raw → unique_ptr, string)PR 2개, 리뷰·머지
6NetworkBuffer 현대화 (배열 → vector)PR 1개
7매크로 → constexpr, C 루프 → 범위 forPR 1–2개
8ASan/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: 테스트 주도 현대화

  1. 레거시 코드에 테스트 추가 (동작 고정)
  2. 리팩토링 (내부 변경)
  3. 테스트 통과 확인
  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*, strcpystd::string버퍼 오버런 방지
컨테이너C 배열, 수동 크기vector, map범위 검사, 반복자
동시성전역 변수, 락 없음mutex, atomic데이터 레이스 방지
빌드헤더 의존성 과다PIMPL, 전방 선언재컴파일 범위 축소
상수#defineconstexpr타입 안전, 디버깅 용이

도구로 안전망 쌓기

테스트·정적 분석·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 적용

같이 보면 좋은 글


자주 묻는 질문 (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를 쓰면 참조 카운팅 오버헤드와 순환 참조 위험이 있습니다.