본문으로 건너뛰기
Previous
Next
C++ Technical Debt: Strategic Refactoring of Legacy

C++ Technical Debt: Strategic Refactoring of Legacy

C++ Technical Debt: Strategic Refactoring of Legacy

이 글의 핵심

Complete legacy modernization guide: Prioritize risky areas, modernize incrementally with tests and sanitizers, migrate raw pointers and macros, refactor build systems, and production patterns.

Introduction: “Scary codebases”

Legacy C++ mixes raw pointers, macros, C style, and tangled builds. Big-bang rewrites rarely ship safely. Strategic refactoring means prioritizing, incremental changes, and preserving behavior with verification. Topics: prioritization, RAII / smart pointers, STL, Clang-Tidy, Sanitizers, CI, migration phases, production patterns.

Table of contents

  1. Common legacy issues
  2. Assessment and prioritization
  3. Incremental modernization strategy
  4. Memory management migration
  5. Macro and constant migration
  6. Build system modernization
  7. Real-world examples
  8. Testing strategies
  9. Common mistakes
  10. Best practices
  11. Production patterns

1. Common legacy issues

Typical problems

IssueImpactModern solution
Memory leaksCrashes, OOMunique_ptr, containers, RAII
Raw pointersUse-after-free, danglingSmart pointers, references
Macro constantsNo type safetyconstexpr, enum class
Manual resource managementException unsafetyRAII wrappers
Implicit conversionsSilent bugsexplicit, strong types
Thread safetyData racesstd::mutex, atomic, TSan
Build fragilityCI failuresCMake, dependency management
No testsFear of changeCharacterization tests

Example legacy code

// Legacy style
#define MAX_SIZE 1024
#define LOG(msg) printf("%s\n", msg)
class OldClass {
    char* buffer;
    FILE* file;
public:
    OldClass() {
        buffer = (char*)malloc(MAX_SIZE);
        file = fopen("data.txt", "r");
    }
    
    ~OldClass() {
        free(buffer);  // What if exception before this?
        fclose(file);  // What if file is NULL?
    }
    
    void process() {
        // Manual memory management everywhere
        char* temp = (char*)malloc(100);
        // ....forgot to free temp!
    }
};

2. Assessment and prioritization

Risk matrix

High Impact, High Frequency → Fix FIRST

│  Security holes
│  Crash-prone modules
│  Build breakages

├─────────────────────────────────

│  High-churn code without tests
│  Performance bottlenecks

Low Impact, Low Frequency → Fix LAST

Assessment checklist

// 1. Identify hot spots
// - Crash reports
// - Memory leak reports (Valgrind, ASan)
// - Git blame for high-churn files
// - Security scan results
// 2. Measure test coverage
// - Use gcov/lcov
// - Identify untested critical paths
// 3. Static analysis
// - Run clang-tidy
// - Check compiler warnings (-Wall -Wextra -Werror)
// 4. Dynamic analysis
// - ASan (AddressSanitizer)
// - TSan (ThreadSanitizer)
// - UBSan (UndefinedBehaviorSanitizer)

Prioritization example

// Priority 1: Security (CVE fix)
void handle_input(char* input) {
    char buffer[100];
    strcpy(buffer, input);  // Buffer overflow!
}
// Priority 2: Frequent crashes
void process_data(Data* data) {
    data->value++;  // Null pointer dereference
}
// Priority 3: Memory leaks in hot path
void* allocate_temp() {
    return malloc(1024);  // Never freed
}
// Priority 4: Technical debt (low risk)
#define OLD_CONSTANT 42  // Replace with constexpr

3. Incremental modernization strategy

Phase 1: Safety net

// Step 1: Add characterization tests
TEST(LegacyTest, PreservesBehavior) {
    // Capture current behavior
    auto result = legacy_function(input);
    EXPECT_EQ(result, expected_output);
}
// Step 2: Enable sanitizers in CI
cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -g"
// Step 3: Add static analysis
clang-tidy --checks='*' src/*.cpp

Phase 2: Boundary refactoring

// Create modern interface at module boundaries
class ModernInterface {
public:
    virtual ~ModernInterface() = default;
    virtual std::string process(const std::string& input) = 0;
};
// Wrap legacy implementation
class LegacyAdapter : public ModernInterface {
    LegacyClass* legacy_;  // Still uses old code internally
public:
    std::string process(const std::string& input) override {
        char* result = legacy_->old_process(input.c_str());
        std::string modern_result(result);
        free(result);
        return modern_result;
    }
};

Phase 3: Incremental migration

// Small, reviewable changes
// PR 1: Replace one malloc/free pair
std::unique_ptr<char[]> buffer(new char[size]);
// PR 2: Replace one raw pointer parameter
void process(std::string& data);  // Was: void process(char* data)
// PR 3: Replace one macro
constexpr int MAX_SIZE = 1024;  // Was: #define MAX_SIZE 1024

4. Memory management migration

From malloc/free to RAII

// ❌ Legacy
void process() {
    char* buffer = (char*)malloc(1024);
    if (error_condition) {
        return;  // Leak!
    }
    // ....use buffer ...
    free(buffer);
}
// ✅ Modern
void process() {
    std::vector<char> buffer(1024);
    if (error_condition) {
        return;  // Automatic cleanup
    }
    // ....use buffer ...
}  // Automatic cleanup

From raw pointers to smart pointers

// ❌ Legacy: Unclear ownership
class Manager {
    Resource* resource;  // Who owns this?
public:
    Manager() : resource(new Resource()) {}
    ~Manager() { delete resource; }  // Manual cleanup
};
// ✅ Modern: Clear ownership
class Manager {
    std::unique_ptr<Resource> resource;
public:
    Manager() : resource(std::make_unique<Resource>()) {}
    // Automatic cleanup, move-only semantics
};
// ✅ Shared ownership when needed
class SharedManager {
    std::shared_ptr<Resource> resource;
public:
    SharedManager(std::shared_ptr<Resource> res) : resource(std::move(res)) {}
};

Migration pattern

// Step 1: Identify ownership
// - Unique: std::unique_ptr
// - Shared: std::shared_ptr
// - Non-owning: raw pointer or reference
// Step 2: Migrate one class at a time
class MigratedClass {
    std::unique_ptr<Data> data_;  // Migrated
    OldClass* old_;               // Not yet migrated
public:
    void setData(std::unique_ptr<Data> d) {
        data_ = std::move(d);
    }
};
// Step 3: Update callers gradually
auto data = std::make_unique<Data>();
obj.setData(std::move(data));

5. Macro and constant migration

Replace macros with constexpr

// ❌ Legacy
#define MAX_SIZE 1024
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define LOG(msg) printf("%s\n", msg)
// ✅ Modern
constexpr int MAX_SIZE = 1024;
template<typename T>
constexpr const T& min(const T& a, const T& b) {
    return (a < b) ? a : b;
}
#include <spdlog/spdlog.h>
#define LOG(msg) spdlog::info(msg)  // Or remove macro entirely

Replace enum with enum class

// ❌ Legacy
enum Color {
    RED,
    GREEN,
    BLUE
};
Color c = RED;  // Pollutes namespace
int x = RED;    // Implicit conversion
// ✅ Modern
enum class Color {
    Red,
    Green,
    Blue
};
Color c = Color::Red;  // Scoped
// int x = Color::Red;  // Error: no implicit conversion

6. Build system modernization

From Makefiles to CMake

# Legacy Makefile
CC = g++
CFLAGS = -Wall -O2
OBJS = main.o utils.o
app: $(OBJS)
	$(CC) $(CFLAGS) -o app $(OBJS)
main.o: main.cpp
	$(CC) $(CFLAGS) -c main.cpp
utils.o: utils.cpp
	$(CC) $(CFLAGS) -c utils.cpp
# Modern CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(MyApp VERSION 1.0)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(app
    main.cpp
    utils.cpp
)
target_compile_options(app PRIVATE
    -Wall -Wextra -Werror
    $<$<CONFIG:Release>:-O3>
    $<$<CONFIG:Debug>:-g -fsanitize=address>
)
target_link_libraries(app PRIVATE
    $<$<CONFIG:Debug>:-fsanitize=address>
)

Dependency management

# Modern: Use package managers
find_package(Boost REQUIRED COMPONENTS system filesystem)
find_package(spdlog REQUIRED)
target_link_libraries(app PRIVATE
    Boost::system
    Boost::filesystem
    spdlog::spdlog
)

7. Real-world examples

Example 1: File handling migration

// ❌ Legacy
void read_file(const char* path) {
    FILE* file = fopen(path, "r");
    if (!file) return;
    
    char buffer[1024];
    while (fgets(buffer, sizeof(buffer), file)) {
        process(buffer);
    }
    fclose(file);  // What if exception in process()?
}
// ✅ Modern
void read_file(const std::filesystem::path& path) {
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Cannot open file");
    }
    
    std::string line;
    while (std::getline(file, line)) {
        process(line);
    }
}  // Automatic close, even on exception

Example 2: String handling migration

// ❌ Legacy
char* concatenate(const char* a, const char* b) {
    size_t len = strlen(a) + strlen(b) + 1;
    char* result = (char*)malloc(len);
    strcpy(result, a);
    strcat(result, b);
    return result;  // Caller must free!
}
// ✅ Modern
std::string concatenate(const std::string& a, const std::string& b) {
    return a + b;  // Simple, safe, automatic memory management
}

Example 3: Thread safety migration

// ❌ Legacy
static int counter = 0;
void increment() {
    counter++;  // Data race!
}
// ✅ Modern
#include <atomic>
std::atomic<int> counter{0};
void increment() {
    counter.fetch_add(1, std::memory_order_relaxed);
}
// Or with mutex for complex operations
std::mutex mutex;
int counter = 0;
void increment() {
    std::lock_guard<std::mutex> lock(mutex);
    counter++;
}

8. Testing strategies

Characterization tests

// Capture current behavior before refactoring
TEST(LegacyTest, CharacterizeBehavior) {
    // Test with various inputs
    EXPECT_EQ(legacy_func(0), 0);
    EXPECT_EQ(legacy_func(1), 1);
    EXPECT_EQ(legacy_func(-1), -1);
    
    // Test edge cases
    EXPECT_THROW(legacy_func(INT_MAX), std::overflow_error);
}

Golden output tests

// Compare output with known-good reference
TEST(LegacyTest, GoldenOutput) {
    std::ostringstream output;
    legacy_process(input, output);
    
    std::string expected = read_file("golden/output.txt");
    EXPECT_EQ(output.str(), expected);
}

Approval tests

// Human-approved output
TEST(LegacyTest, ApprovalTest) {
    auto result = legacy_complex_function(input);
    
    // First run: creates approved.txt
    // Subsequent runs: compares with approved.txt
    ApprovalTests::verify(result);
}

9. Common mistakes

Mistake 1: Big-bang rewrite

// ❌ BAD: Rewrite entire module at once
// - High risk
// - Long review
// - Difficult to rollback
// ✅ GOOD: Incremental changes
// PR 1: Add tests
// PR 2: Refactor function A
// PR 3: Refactor function B
// ...

Mistake 2: Changing behavior while refactoring

// ❌ BAD: Fix bugs during refactoring
void refactored_function() {
    // Changed behavior + refactored structure
    // Which change caused the regression?
}
// ✅ GOOD: Separate concerns
// PR 1: Refactor (preserve behavior)
// PR 2: Fix bug (with test)

Mistake 3: No rollback plan

// ❌ BAD: Deploy without feature flag
if (use_new_implementation) {
    return new_impl();
} else {
    return old_impl();
}
// ✅ GOOD: Feature flag for gradual rollout

10. Best practices

  1. Test first: Add characterization tests before refactoring
  2. Small PRs: One logical change per PR
  3. Preserve behavior: Refactor and fix bugs separately
  4. Use tools: clang-tidy, sanitizers, static analyzers
  5. Document ownership: Use smart pointers to clarify
  6. Enable warnings: Treat warnings as errors
  7. CI enforcement: Run tests and sanitizers on every PR
  8. Feature flags: Allow gradual rollout
  9. Pair programming: For risky refactorings
  10. Celebrate progress: Track and communicate improvements

11. Production patterns

Pattern 1: Strangler fig

// Gradually replace old system with new
class SystemFacade {
    OldSystem* old_;
    NewSystem* new_;
    bool use_new_;
    
public:
    Result process(Request req) {
        if (use_new_ && new_->supports(req)) {
            return new_->process(req);
        }
        return old_->process(req);
    }
};

Pattern 2: Parallel run

// Run both implementations, compare results
Result process(Request req) {
    auto old_result = old_impl(req);
    auto new_result = new_impl(req);
    
    if (old_result != new_result) {
        log_discrepancy(req, old_result, new_result);
    }
    
    return old_result;  // Use old until confident
}

Pattern 3: Feature flags

class FeatureFlags {
    std::unordered_map<std::string, bool> flags_;
    
public:
    bool is_enabled(const std::string& feature) {
        return flags_[feature];
    }
};
void process() {
    if (flags.is_enabled("new_algorithm")) {
        new_algorithm();
    } else {
        old_algorithm();
    }
}

Summary

  • Assess: Prioritize by risk and impact
  • Test: Add characterization tests first
  • Incremental: Small, reviewable changes
  • Tools: clang-tidy, sanitizers, CI
  • Memory: Migrate to smart pointers and RAII
  • Macros: Replace with constexpr and templates
  • Build: Modernize to CMake
  • Safety: Feature flags and parallel runs Key principle: Preserve behavior, change structure incrementally, verify continuously. Next: C++ career roadmap (#45-3)
    Previous: Rust interop (#44-2)

Keywords

C++ legacy, refactoring, technical debt, modernization, smart pointers, RAII, incremental migration


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Complete legacy modernization guide: Prioritize risky areas, modernize incrementally with tests and sanitizers, migrate … 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ unique_ptr 고급 완벽 가이드 | 커스텀 삭제자·배열
  • C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
  • C++ 디자인 패턴 종합 가이드 | Singleton·Factory

이 글에서 다루는 키워드 (관련 검색어)

C++, legacy, refactoring, technical debt, modernization 등으로 검색하시면 이 글이 도움이 됩니다.