- 크고 복잡한 레거시 C++ 코드베이스를 맡았다면 목표는 “깨끗한 코드”가 아니라 보안, 개발자 경험, 정확성, 성능을 수용 가능한 상태로 끌어올리는 것임
- 출발점은 조직의 동의와 시간 제한을 확보하고, 지원 플랫폼을 README에 적은 뒤, 로컬 빌드와 테스트가 안정적으로 통과하게 만드는 일임
- 빌드·테스트 속도 개선은 의존성 테스트 제거, 불필요한 타깃 정리,
mold같은 빠른 링커 실험처럼 낮은 난이도 개선부터 측정하며 진행해야 함 - 미사용 코드와 미지원 플랫폼 코드를 줄인 뒤
clang-tidy,cppcheck,clang-format,-fsanitize=address,undefined, CI로 자동 검증 루프를 만들어야 함 - 리라이트나 최신 C++ 표준 도입은 목적이 아니라 수단이며, 메모리 안전 언어 재작성도 강한 이유가 있을 때만 검토할 만함
처음에는 코드보다 사람과 절차를 정리함
- 레거시 C++ 코드베이스 개선은 퇴근 후 혼자 밀어붙이는 작업이나 장기적인 “죽음의 행군”이 아니라, 지속 가능한 소프트웨어 엔지니어링 작업이어야 함
- 상사, 동료, 비기술 구성원에게 문제, 해결책, 시간 제한을 쉬운 말로 설명해야 함
- 새 입사자가 로컬 빌드와 첫 기여까지 3주가 걸렸다면, 이를 몇 분으로 줄이는 목표를 제안할 수 있음
- 간단한 퍼징 설정이 몇 초 만에 앱을 253번 크래시시킨다면, 운영 환경에서의 위험을 설명할 근거가 됨
- 8년 동안 지원되지 않은 FreeBSD 9 빌드 서버 하나에 배포가 의존한다면, 서버 장애 시 배포가 막히는 상태임
- 업계 표준 린터가 운영 버그의 원인이 된 정의되지 않은 동작을 즉시 잡아낸다면, 변경 때마다 실행할 이유가 충분함
- 수동으로 복사해 수정한 암호화 라이브러리 때문에 취약점 영향을 알 수 없다면, 의존성 정리와 자동 알림이 필요함
- 피해야 할 접근도 명확함
- 테스트가 없는데 최신 C++ 표준으로 2주 동안 전면 업그레이드함
- 몇 달 동안 별도 브랜치에서 대규모 변경을 만들고 언젠가 병합될 것이라 기대함
- 몇 주면 끝난다는 가정으로 처음부터 전체 재작성을 시작함
- 무엇을 언제까지 할지 모르는 상태로 “코드베이스 개선”을 시작함
안전한 개선 방식
- 모든 변경은 작고 점진적이어야 하며, 변경 전후 앱이 동작하고 테스트와 린터가 통과해야 함
- 긴급 버그 수정은 기존처럼 가능해야 하며, 개선 작업 때문에 막히면 안 됨
- 각 변경은 측정 가능한 개선이어야 하고, 비전문가에게도 설명하거나 시연할 수 있어야 함
- 우선순위나 예산 문제로 전체 작업이 중단돼도 시작 전보다 측정 가능한 순이익이 남아야 함
지원 플랫폼을 README에 명시함
- README에는 공식 지원하는
<architecture>-<operating-system>쌍을 적어야 함- 예:
x86_64-linux,aarch64-darwin
- 예:
- 이 목록은 모든 지원 플랫폼에서 빌드되는지 확인하는 기준이며, 나중에 지원하지 않는 플랫폼용 코드를 제거하는 근거가 됨
- 필요하다면 ARMv6, ARMv7 같은 아키텍처 버전도 명시할 수 있음
- 지원 플랫폼 목록은 다음 질문에 답하게 해줌
- 부동소수점, SIMD, SHA256 하드웨어 지원에 의존할 수 있는지
- 32비트 지원이 필요한지
- 빅엔디언 플랫폼에서 실행되는지
char가 7비트일 가능성을 고려해야 하는지
- 개발자 워크스테이션도 이 목록에 반드시 포함해야 함
로컬 빌드와 테스트부터 안정화함
- 성공적인 제품의 핵심 C++ 코드베이스도 안정적으로 컴파일되지 않는 경우가 많으며, 목표는 “가끔 되는 빌드”가 아니라 모든 지원 플랫폼에서 일관되게 되는 빌드임
- 가장 좋은 상태는 개발자 머신에서 직접 빌드하고 실행할 수 있는 것임
- 프로젝트가 너무 커서 RAM이 부족하면 큰 서버를 빌드용으로 빌릴 수 있음
io_uring같은 플랫폼 전용 API가 필요하면 심(shim)을 구현하거나 워크스테이션의 가상 머신 안에서 빌드할 수 있음- 그래도 직접 로컬 빌드가 가장 좋은 선택임
- 테스트가 없다면 코드 변경 전에 테스트를 먼저 작성해야 함
- 현실에서 실행되는 프로그램의 입력과 출력을 캡처해 엔드투엔드 테스트를 만드는 방식이 가장 쉬운 출발점임
- 이런 테스트는 기존 동작이 올바른지 보장하지는 않지만, 변경 시 회귀를 줄여줌
- 테스트 스위트가 있는데 일부가 실패하면 일단 비활성화하고, 전체 테스트가 몇 시간이 걸려도 통과하는 상태를 먼저 만듦
빌드와 테스트 방법을 문서화함
- README에는 앱을 빌드하고 테스트하는 방법을 적어야 함
- 이상적인 형태는 빌드 명령 하나와 테스트 명령 하나임
- 처음에 절차가 복잡하다면
build.sh,test.sh에 명령을 감싸 복잡성을 숨길 수 있음 - 목표는 C++ 전문가가 아닌 사람도 질문 없이 빌드와 테스트를 실행할 수 있는 상태임
- 프로젝트 구조나 아키텍처 문서화는 나중에 불필요한 코드를 제거한 뒤 하는 편이 나음
낮은 난이도로 빌드와 테스트 속도를 줄임
- 빌드 시스템을 갈아엎거나 영웅적인 최적화를 하지 않고, 낮은 난이도 개선부터 측정해야 함
- 먼저 확인할 항목들
- 의존성의 테스트를 매번 빌드하고 실행하는지 확인함
unittest++를 CMake 서브프로젝트로 썼을 때 테스트 프레임워크의 테스트까지 매번 빌드·실행된 사례가 있음
- 의존성의 예제 프로그램을 빌드하고 실행하는지 확인함
mbedtls도 CMake 변수로 예제 빌드를 끌 수 있었음
- 자신의 프로젝트가 다른 프로젝트의 서브프로젝트로 포함될 때 테스트와 예제를 기본 빌드하는지 확인함
MYPROJECT_TEST같은 빌드 변수를 기본 비활성화하고, 직접 개발할 때만 켜는 방식을 권장함
- 서드파티 의존성 전체를 빌드하지만 실제로는 작은 일부만 쓰는지 확인함
mbedtls는 필요 없는 부분을 끄는 컴파일 타임 플래그를 많이 제공함
- 타깃 의존성이 잘못돼 작은 변경에도 전체가 다시 빌드되는지 확인함
- 많은 빌드 시스템은 의존성 그래프를 출력할 수 있음
mold같은 빠른 링커를 실험함- 가능하다면 컴파일러도 비교함
- 어떤 프로젝트는 clang이 gcc보다 2배 빠른 사례가 있었고, 어떤 프로젝트는 차이가 없었음
- 의존성의 테스트를 매번 빌드하고 실행하는지 확인함
- 추가로 실험할 수 있지만 이득이 작거나 음수일 수 있는 항목
- LTO 끄기/켜기/thin
- 디버그 정보 분리
- Make와 Ninja 비교
- 파일 시스템 종류와 설정
- 빌드가 너무 오래 걸리면 코드 수정 자체가 현실적이지 않음
불필요한 코드를 제거함
- 코드베이스의 30% 이상이 완전히 죽은 코드였던 사례가 있으며, 이런 코드는 컴파일과 리팩터링 비용을 계속 늘림
- 제거 방법
- 컴파일러의
-Wunused-xxx경고를 사용함- 예:
-Wunused-function - 대부분은 삭제 후 재빌드와 테스트로 끝나지만, 드물게 잘못된 함수가 호출된 버그의 증상일 수 있어 완전 자동화는 조심해야 함
- 예:
cppcheck같은 린터로 미사용 함수나 클래스 필드를 찾음- 상속과 가상 함수에서는 거짓 양성이 많을 수 있지만, 컴파일러가 못 찾은 미사용 요소를 찾을 수 있음
- 링커가 각 함수를 별도 섹션에 넣고 미사용 섹션 제거를 출력하게 하는 방식도 있지만, 표준 라이브러리 함수 노이즈가 많아 실용적이지 않을 수 있음
- 생성된 어셈블리와 소스 코드를 비교하는 방식은 가상 함수에는 잘 맞지 않음
- 컴파일러의
- 지원 플랫폼 목록을 활용해 미지원 플랫폼 코드를 제거함
- 실제로 FreeBSD에서만 돌던 프로젝트의 오래된 Solaris 지원 코드
- 실제 플랫폼에는 항상 난수 생성기가 있는데 자체 난수 생성기를 둔 코드
- 현대 Linux와 macOS에서만 실행되는데 POSIX 2001 미지원 대비 코드
- 빅엔디언 CPU 여부를 검사해 바이트 스왑하는 코드
- 수년 전 들어갔지만 실제 기능으로 이어지지 않은 코드
- 대량의 코드 삭제 PR은 빌드 시간 감소와 유지보수 비용 감소를 동시에 보여줄 수 있음
린터, 포맷터, 새니타이저를 넣음
- 린터는 규칙을 과도하게 켜지 말고 기본적인 몇 가지부터 개발 생명주기에 넣어야 함
clang-tidy,cppcheck는 유용할 수 있지만 느리고 노이즈가 많을 수 있음- 린터가 전혀 없는 상태는 선택지가 아니며, 처음 실행하면 컴파일러 경고만으로 잡히지 않던 실제 문제를 많이 찾을 수 있음
- 코드 포맷팅은 적절한 시점에 한 번에 적용해야 함
- 활성 브랜치가 없는 때를 기다려야 끔찍한 병합 충돌을 피할 수 있음
- 스타일 논쟁에 시간을 쓰지 말고
clang-format같은 도구로 전체 코드베이스를 예외 없이 포맷팅함 - 설정도 함께 커밋함
- 새니타이저는 실제 운영에 영향을 줄 수 있는 찾기 어려운 버그를 잡는 데 필요함
- 기본값으로
-fsanitize=address,undefined를 권장함 - 대체로 거짓 양성이 없으므로 감지되면 수정해야 함
- 테스트도 새니타이저를 켠 상태로 실행해 문제를 찾음
- 성능 예산이 허용된다면 일부 새니타이저를 켠 운영 실행도 고려할 수 있음
- 기본값으로
- 배포용 컴파일러가 새니타이저를 지원하지 않더라도 개발과 테스트에서는 clang 같은 컴파일러를 사용할 수 있음
- 새니타이저를 켜면 오랫동안 숨어 있던 버그와 메모리 누수가 드러날 수 있으며, 수정에 많은 작업과 리팩터링이 필요할 수 있음
- 가능하면 테스트 시 서드파티 의존성도 새니타이저를 켜고 컴파일해 의존성 내부 문제도 찾는 편이 좋음
CI로 자동화함
- CI는 지금까지 설정한 린터, 포맷팅, 테스트 등을 깨끗한 환경에서 자동화함
- 매 변경마다 운영 바이너리를 생성할 수 있어야 함
- 대부분의 CI 시스템은 여러 플랫폼 매트릭스를 지원하므로, README의 지원 플랫폼 목록이 실제로 빌드되는지 확인할 수 있음
- 일반적인 파이프라인은
make all test lint fmt처럼 단순할 수 있음 - 린터와 새니타이저가 보고한 문제는 파이프라인 실패로 이어져야 하며, 그렇지 않으면 아무도 고치지 않음
코드는 점진적으로 단순화함
- 안정적인 빌드·테스트·검증 루프가 생긴 뒤에는 코드를 조금씩 단순화할 수 있음
- 복잡한 클래스가 실제로는 포인터를 할당하고 null 여부만 확인하는 역할이었다면, 사실상 boolean으로 대체 가능한 사례가 있음
- 이 단계는 단순화를 할수록 더 단순화할 지점이 열리기 때문에 시간 제한을 정하기 어렵고, 보수적으로 판단해야 함
- 목표는 보안, 정확성, 성능 같은 구체적 가치여야 하며, “클린 코드” 같은 주관적 기준을 피해야 함
- C++ 표준 업그레이드는 목적이 아니라 수단임
- 예를 들어 수동 이터레이터 증가 코드를
for (auto x : items)루프로 바꾸는 데 도움이 될 수 있음 - 필요한 것이
std::clamp하나뿐이면 직접 작성하는 편이 나을 수 있음
- 예를 들어 수동 이터레이터 증가 코드를
메모리 안전 언어 재작성은 별도 판단이 필요함
- 일부를 메모리 안전 언어로 다시 쓰는 선택지는 가능하지만 주의할 점이 많음
- 강력한 이유가 있을 때만 진행해야 함
의존성 관리는 소스 빌드 중심을 선호함
- C++에는 일관된 의존성 관리가 없으며, 많은 프로젝트가 시스템 패키지 관리자를 사용함
- 시스템 패키지 의존 방식의 문제
- 설치 지침이 OS, 배포판, 배포판 버전에 의존함
- Ubuntu 20.04에서 22.04로 옮길 때 패키지 버전이 달라져 100개 의존성을 동시에 업그레이드해야 하는 상황이 생길 수 있음
- 패키지로 없는 서드파티 의존성은 결국 소스에서 빌드해야 함
- 패키지가 원하는 플래그로 빌드돼 있지 않음
- 새니타이저, LTO,
-march, 디버그 정보, 프레임 포인터, C++ ABI 차이 등이 문제가 될 수 있음
- 새니타이저, LTO,
- 현재 쓰는 정확한 버전의 소스를 감사·개발·디버깅 때 보기 어려움
- 의존성을 패치하고 다시 빌드하기 어려움
- macOS, Ubuntu, FreeBSD 사이에서 정확히 같은 패키지 버전을 쓰기 어려움
- 자동화된 BOM 생성이 어려워짐
- 정적/동적 라이브러리 형태 등 필요한 패키지 변형이 없을 수 있음
- Conan, vcpkg 같은 C++ 패키지 관리자도 개선일 수 있지만 제약이 있음
- 외부 의존성 때문에 CI가 복잡하고 느려질 수 있음
- 모든 패키지 버전이 없을 수 있음
- Conan의 mbedtls는
2.16.12에서2.23.0으로 건너뛰는 예가 있음
- Conan의 mbedtls는
- 필요한 OS나 아키텍처를 지원하지 않을 수 있음
- 권장 방식은 git submodule로 의존성을 가져와 소스에서 컴파일하는 것임
- 단순함
- 수동 벤더링보다 git 이력과 diff를 활용할 수 있음
- 의존성 버전을 커밋 단위로 정확히 알 수 있음
- 단일 의존성 업그레이드는
git checkout으로 가능함 - 모든 플랫폼에서 동작함
- 의존성마다 컴파일 플래그와 컴파일러를 선택할 수 있음
- C++ 경험이 없어도 개발자들이 git을 알고 있음
- 재귀적으로 동작함
- CMake의
add_subdirectory나git submodule foreach make로 각 서브모듈을 빌드할 수 있음 - 서브모듈이 어렵다면 Neovim처럼 스크립트 하나가 의존성을 가져오고 빌드하는 방식도 가능함
- 의존성 그래프가 매우 크다면 Buck2 같은 하이브리드 로컬·원격 빌드 시스템과 빌드 산출물 재사용이 필요할 수 있음
- Go, Rust 같은 컴파일 언어의 패키지 관리자는 소스에서 컴파일하는 접근을 사용함
독자 제안에서 보강된 점
- 테스트는 더 강조할 필요가 있으며, C++ 테스트 스위트는 새니타이저 아래에서 실행해야 거짓 안전감에 빠지지 않음
- vcpkg는 요구사항과 크로스 컴파일을 만족한다면 git submodule보다 나은 선택일 수 있음
- Nix는 C++ 의존성 관리자 역할을 할 수 있지만 복잡성과 느림이 문제임
- 1년에 버그 하나만 고친다면 대규모 리팩터링 투자는 판단 문제지만, 죽은 코드 제거와 새니타이저는 드문 수정에도 가치가 있음
- 코드 제거는 정적 분석으로 호출되지 않음을 확인할 수 있는 경우에 집중해야 하며, 의심되면 제거하지 않는 편이 안전함
- 가상 메서드는 런타임에 호출 대상이 결정되므로 정적 분석으로 제거하기 어려움
- 영업, 제품 관리자, 사용자에게 기능·플랫폼 사용 여부를 물어보는 15분 대화가 기술 작업을 크게 줄일 수 있음
- LLM에 코드를 넣고 질문하는 방식은 법적으로 안전하고 가능하면 로컬에서 실행하며, 결과를 신중히 받아들여야 함
- 코드 분석 도구로 다이어그램과 클래스 관계를 생성해 전체 구조를 파악하는 아이디어도 있음
- 소스 제어 시스템이 없다면 0단계는 코드를 VCS에 넣는 것임
- CI를 1단계로 봐야 한다는 의견도 있으며, 로컬이 더 빠르더라도 타당한 관점임
- 거의 쓰이지 않는 플랫폼을 제거하면 조합 복잡도가 줄고 큰 단순화가 가능할 수 있음
- 완전한 재현 가능한 빌드는 일반적인 C++ 코드베이스에서 현실적이지 않을 수 있지만, 신뢰할 수 있는 빌드는 현실적임
- 전체 코드 포맷팅 커밋은 git이 특정 커밋을 blame에서 무시하도록 설정해 이력 추적 부담을 줄일 수 있음
- VCS 이력 통계로 변경이 잦은 부분과 함께 바뀌는 파일을 찾는 아이디어도 있음
- 이 접근은 C++뿐 아니라 다른 레거시 코드베이스에도 적용할 수 있으며, 새니타이저 같은 C++ 특화 부분은 제외하면 됨
Working effectively with Legacy Code는 관련 조언을 담은 책으로 추천됨- 실제 가치에 집중해야 하지만, 큰 C++ 코드베이스를 보안 관점에서 보면 많은 취약점이 발견될 수 있고 이는 재무 이익이 아니라 위험 감소로서 가치가 있음