Visual Studio로 C++ 프로파일링·메모리 진단하기: vcpkg 연동까지
들어가며: Windows에서는 무엇으로 측정할까
Linux와 macOS에서는 perf·gprof·Sanitizer로 성능을 측정하고 메모리 버그를 찾습니다. Windows 네이티브 C++ 프로젝트에서는 도구 체인이 다르며, 어디가 느린지, 메모리가 왜 늘어나는지, 정적 분석을 어떻게 돌리는지에 대한 답은 대부분 Visual Studio에 내장된 도구에 있습니다.
| 도구 | 해결하는 문제 | Linux에서 비슷한 도구 |
|---|---|---|
| 성능 프로파일러 | CPU 병목, 핫 패스 | perf, gprof |
| 메모리 사용량(힙 스냅샷) | 누수, 힙 증가 | Valgrind massif, heaptrack |
AddressSanitizer(/fsanitize=address) | 버퍼 오버플로, use-after-free | ASan |
| 코드 분석 | 정적 버그, Core Guidelines 위반 | clang-tidy |
이 글은 Visual Studio 2022 이후 버전을 기준으로, 프로젝트 구성과 디버깅 기본기, vcpkg 연동, 프로파일러와 메모리 진단, 코드 분석, 그리고 자주 부딪히는 링크·심볼 문제를 차례로 다룹니다. 메뉴 이름과 위치는 버전에 따라 조금씩 다를 수 있습니다.
상황별 도구 선택
flowchart TD
A[성능/버그 문제] --> B{증상이 뭔가요?}
B -->|프로그램이 느려요| C[성능 프로파일러]
B -->|메모리가 계속 늘어나요| D[메모리 사용량]
B -->|가끔 크래시·값이 깨져요| G[AddressSanitizer]
B -->|리뷰에서 놓치는 버그| E[코드 분석]
C --> C1[CPU 사용량 - 샘플링]
C --> C2[인스트루먼테이션 - 호출 횟수·대기 시간]
D --> D1[힙 스냅샷]
D --> D2[스냅샷 비교]
E --> E1[C++ Core Check]
CPU 샘플링은 오버헤드가 작아 첫 병목 탐색에 쓰고, 인스트루먼테이션은 오버헤드가 크지만 정확한 호출 횟수가 필요할 때 씁니다. 힙 스냅샷은 할당을 추적하는 동안 프로그램이 느려지므로 문제 구간을 좁혀서 쓰고, 코드 분석은 실행 비용 없이 빌드 시간만 늘어나므로 PR 전과 CI에서 돌립니다.
Visual Studio 프로젝트 설정
솔루션과 프로젝트
새 프로젝트는 파일 > 새로 만들기 > 프로젝트에서 “콘솔 앱”이나 “빈 프로젝트”를 골라 만듭니다. 솔루션(.sln, 최신 버전은 .slnx)은 여러 프로젝트를 묶고 빌드 순서와 프로젝트 간 의존성을 관리하는 컨테이너이고, 프로젝트(.vcxproj)는 실제 소스 파일과 빌드 설정의 단위입니다.
flowchart TD
subgraph sln[솔루션 MyApp.sln]
P1[MyApp.exe]
P2[MyLib.lib]
P3[Tests.exe]
end
P2 --> P1
P2 --> P3
구성(Configuration)과 플랫폼
.vcxproj 프로젝트는 기본으로 Debug와 Release 두 구성을 만듭니다. Debug는 최적화를 끄고(/Od) 디버그 CRT(/MDd)를 쓰며, Release는 최적화(/O2)와 릴리스 CRT(/MD)를 씁니다. 템플릿의 Release 구성도 PDB를 생성하도록 되어 있지만, 오래된 프로젝트에서는 꺼져 있는 경우가 있습니다. CMake 프로젝트에는 여기에 최적화와 디버그 정보를 함께 켠 RelWithDebInfo 구성이 추가로 있어 프로파일링과 크래시 분석에 편리합니다.
플랫폼은 x86(32비트), x64, ARM64 중에서 고르며, 새 프로젝트는 보통 x64를 기본으로 씁니다. 프로젝트 속성 창 상단의 구성·플랫폼 드롭다운에서 지금 편집하는 설정이 어느 조합에 적용되는지 확인해야 합니다. “모든 구성”을 선택하지 않고 수정하면 Debug에만 경로를 넣고 Release 빌드가 실패하는 일이 흔합니다.
CMake 프로젝트로 열기
CMake 기반 프로젝트는 파일 > 열기 > 폴더(또는 CMake)로 CMakeLists.txt가 있는 폴더를 열면 됩니다. Visual Studio가 CMakePresets.json(없으면 예전 방식의 CMakeSettings.json)을 읽어 구성을 만들고, CMakeLists.txt를 저장하면 자동으로 다시 구성합니다. 캐시가 꼬였을 때는 프로젝트 > CMake 캐시 삭제 후 다시 구성합니다.
include/lib 경로 수동 설정
패키지 관리자 없이 외부 라이브러리를 쓸 때는 프로젝트 속성에서 세 곳을 설정합니다. C/C++ > 일반 > 추가 포함 디렉터리에 헤더 경로(C:\libs\mylib\include)를, 링커 > 일반 > 추가 라이브러리 디렉터리에 lib 경로(C:\libs\mylib\lib\x64)를, 링커 > 입력 > 추가 종속성에 lib 파일 이름(mylib.lib)을 넣습니다. Debug와 Release, x86과 x64마다 lib 경로가 다른 경우가 많으므로 $(Configuration), $(Platform) 매크로를 경로에 쓰면 구성별로 따로 적지 않아도 됩니다.
중단점·조사식·데이터 중단점 디버깅
중단점과 실행 제어
코드 왼쪽 여백을 클릭하거나 F9로 중단점을 겁니다. F5는 디버깅 시작 또는 계속, F10은 함수 안으로 들어가지 않고 한 줄 실행(프로시저 단위 실행), F11은 함수 안으로 들어가는 한 줄 실행, Shift+F11은 현재 함수가 반환될 때까지 실행, Ctrl+Shift+F5는 디버깅 재시작입니다.
조건부 중단점과 적중 횟수
중단점에서 마우스 오른쪽 > 조건을 선택하면 조건식이나 적중 횟수를 지정할 수 있습니다.
for (int i = 0; i < 100; ++i) {
process(data[i]); // 중단점 + 조건: i == 50
}
조건 대신 적중 횟수를 쓰면 루프 변수가 없는 코드에서도 N번째 실행에서 멈출 수 있습니다. 위 루프에서 i == 50과 같은 시점은 51번째 적중이라는 점에 주의해야 합니다. 조건식은 중단점에 도달할 때마다 디버거가 평가하므로, 매우 자주 실행되는 줄에 걸면 프로그램이 눈에 띄게 느려집니다.
조사식과 로컬 창
조사식(Watch) 창(Ctrl+Alt+W, 1)에 변수나 식을 넣으면 현재 시점의 값이 표시됩니다. ptr->size_, vec.size(), arr[3] 같은 식을 쓸 수 있으며, ptr,10처럼 쓰면 포인터를 원소 10개짜리 배열로 펼쳐 보여 줍니다. 함수 호출이 들어간 식은 디버거가 대상 프로세스에서 실제로 함수를 실행해야 하므로, 인라인된 함수나 임시 객체를 만드는 식은 평가에 실패할 수 있습니다. 로컬 창(Ctrl+Alt+V, L)은 현재 스코프의 지역 변수를 자동으로 보여 줍니다.
호출 스택과 스레드
호출 스택 창(Ctrl+Alt+C)은 현재 함수까지의 호출 체인을 보여 주며, 항목을 더블클릭하면 그 프레임의 소스와 지역 변수로 이동합니다. 스레드 창(디버그 > 창 > 스레드)에서 다른 스레드를 선택하면 그 스레드의 호출 스택을 볼 수 있습니다. 특정 스레드에서만 멈추려면 중단점 설정의 필터에 ThreadId = 1234나 ThreadName = "Worker"를 입력합니다.
데이터 중단점
데이터 중단점은 특정 메모리 주소의 값이 바뀌는 순간 실행을 멈춥니다. “누가 이 값을 덮어쓰는가”를 찾을 때 이만한 도구가 없습니다. 디버깅 중 로컬이나 조사식 창에서 변수에 마우스 오른쪽 > “값이 변경되면 중단”을 선택하거나, 중단점 창에서 새로 만들기 > 데이터 중단점을 고르고 주소(&obj.count_)와 바이트 수(1, 2, 4, 8)를 지정합니다. CPU의 하드웨어 디버그 레지스터를 쓰기 때문에 동시에 걸 수 있는 개수가 x64에서는 4개로 제한되고, 감시 대상 변수가 스코프를 벗어나 그 주소가 다른 용도로 재사용되면 엉뚱한 곳에서 멈출 수 있습니다.
디버깅 시 자주 겪는 상황
| 상황 | 원인 | 해결 |
|---|---|---|
| 변수 값이 “최적화로 제거됨”으로 표시 | 최적화된 빌드 | Debug 구성 사용, 또는 해당 파일만 최적화 끄기 |
| 호출 스택이 깨져 보임 | 버퍼 오버플로로 스택 손상, 심볼 누락 | AddressSanitizer로 메모리 오류 검사, PDB 확인 |
| DLL 로드 실패 | 경로 문제, 의존 DLL 누락 | 디버그 > 창 > 모듈에서 로드 여부와 경로 확인 |
vcpkg 연동
vcpkg 설치
Visual Studio 2022 17.6 이후에는 C++ 워크로드에 vcpkg가 포함되어 있어 개발자 명령 프롬프트에서 바로 쓸 수 있습니다. 별도로 설치하려면 다음과 같이 클론하고 부트스트랩합니다.
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
cd C:\vcpkg
.\bootstrap-vcpkg.bat
.\vcpkg integrate install을 실행하면 MSBuild 프로젝트(.vcxproj)가 vcpkg로 설치한 라이브러리를 자동으로 찾습니다. Manifest 모드와 CMake를 쓰면 이 단계는 필요 없습니다.
Manifest 모드 + CMake
프로젝트 루트에 vcpkg.json을 두면 CMake 구성 단계에서 vcpkg가 필요한 패키지를 자동으로 설치합니다. 버전을 재현 가능하게 고정하려면 builtin-baseline에 vcpkg 저장소의 커밋 해시를 지정하고, 특정 패키지의 최소 버전은 version>=로 지정합니다.
{
"name": "my-app",
"version": "1.0.0",
"builtin-baseline": "<vcpkg 저장소 커밋 SHA>",
"dependencies": [
"nlohmann-json",
"fmt",
{ "name": "spdlog", "version>=": "1.12.0" }
]
}
cmake_minimum_required(VERSION 3.20)
project(MyApp LANGUAGES CXX)
# vcpkg 툴체인은 CMakePresets.json 또는 -DCMAKE_TOOLCHAIN_FILE로 전달
find_package(spdlog CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)
add_executable(my_app src/main.cpp)
target_link_libraries(my_app PRIVATE spdlog::spdlog nlohmann_json::nlohmann_json)
툴체인 파일은 CMakePresets.json에 지정하는 것이 가장 깔끔합니다. VCPKG_ROOT 환경 변수를 vcpkg 설치 경로로 설정해 두면 팀원마다 경로가 달라도 같은 프리셋을 쓸 수 있습니다.
{
"version": 3,
"configurePresets": [
{
"name": "windows-base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
}
},
{
"name": "x64-release",
"displayName": "x64 Release",
"inherits": "windows-base",
"architecture": { "value": "x64", "strategy": "external" },
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo"
}
}
]
}
.vcxproj 프로젝트에서 vcpkg 사용
MSBuild 프로젝트도 Manifest 모드를 쓸 수 있습니다. 프로젝트 속성 > vcpkg > “Use Vcpkg Manifest”를 켜고 프로젝트 폴더에 vcpkg.json을 두면, 빌드할 때 패키지가 설치되고 include·lib 경로와 DLL 복사가 자동으로 처리됩니다.
수동으로 경로를 지정할 수도 있지만 권장하지 않습니다. 이 경우 vcpkg install spdlog:x64-windows 후 포함 디렉터리에 installed\x64-windows\include를, 라이브러리 디렉터리에 Release는 installed\x64-windows\lib, Debug는 installed\x64-windows\debug\lib를 넣어야 하며, 이 구분을 빠뜨리면 LNK2038이 납니다.
트리플렛은 x64-windows(동적 링크, /MD), x64-windows-static(정적 라이브러리와 정적 CRT, /MT), x64-windows-static-md(정적 라이브러리와 동적 CRT) 등이 있으며, 프로젝트의 아키텍처와 런타임 라이브러리 설정에 맞춰 고릅니다.
| 증상 | 원인 | 해결 |
|---|---|---|
find_package 실패 | 툴체인 파일 미지정 | CMAKE_TOOLCHAIN_FILE 설정 후 캐시 삭제·재구성 |
| LNK2019 미해결 외부 기호 | lib 미링크, 대상 이름 불일치 | 패키지 설치 시 출력되는 target_link_libraries 안내 확인 |
| 팀원마다 다른 버전 설치 | baseline 미지정 | builtin-baseline과 version>=, 필요하면 overrides 사용 |
CPU 사용량과 인스트루먼테이션 프로파일러
CPU 사용량(샘플링): 첫 병목 찾기
CPU 사용량 도구는 ETW(Event Tracing for Windows)를 통해 일정 간격(기본 초당 약 1,000회)으로 각 스레드의 호출 스택을 수집하고, 어떤 함수에서 샘플이 많이 잡혔는지로 CPU 시간을 추정합니다.
sequenceDiagram
participant App as 애플리케이션
participant ETW as Windows ETW
participant VS as VS 프로파일러
loop 샘플링 간격마다
ETW->>App: 실행 중인 스레드의 호출 스택 수집
ETW->>VS: 샘플 이벤트 전달
end
VS->>VS: 핫 패스·플레임 그래프 생성
Release(또는 RelWithDebInfo) 구성으로 빌드한 뒤, 디버그 > 성능 프로파일러(Alt+F2)에서 “CPU 사용량”을 선택하고 시작합니다. Debug 빌드는 최적화가 꺼져 있고 디버그 CRT와 반복자 검사가 추가되어, 실제 병목과 다른 곳이 느리게 나오기 쉽습니다. 디버거를 붙인 상태의 진단 도구 창에서도 CPU 사용량을 볼 수 있지만, 디버거 자체가 실행을 느리게 하므로 측정에는 디버거 없이 실행하는 성능 프로파일러가 낫습니다.
분석해 볼 예제입니다.
// slow_app.cpp - 병목이 있는 예제
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <vector>
// 병목 1: O(n²) 정렬
void inefficientSort(std::vector<int>& data) {
for (size_t i = 0; i < data.size(); ++i) {
for (size_t j = i + 1; j < data.size(); ++j) {
if (data[j] < data[i]) {
std::swap(data[i], data[j]);
}
}
}
}
// 병목 2: reserve 없이 push_back을 반복해 재할당이 여러 번 일어남
std::vector<int> processData(const std::vector<int>& data) {
std::vector<int> result;
for (int v : data) {
result.push_back(v * 2);
}
return result;
}
int main() {
std::vector<int> data(100000);
std::mt19937 rng(42);
std::generate(data.begin(), data.end(), [&]() { return static_cast<int>(rng()); });
auto start = std::chrono::steady_clock::now();
inefficientSort(data);
auto mid = std::chrono::steady_clock::now();
auto processed = processData(data);
auto end = std::chrono::steady_clock::now();
std::cout << "Sort: "
<< std::chrono::duration<double, std::milli>(mid - start).count() << " ms\n";
std::cout << "Process: "
<< std::chrono::duration<double, std::milli>(end - mid).count() << " ms\n";
std::cout << processed.size() << '\n';
}
10만 개 원소에 대해 inefficientSort는 약 50억 번(n²/2) 비교하고, processData는 10만 번 순회하며 재할당은 로그 횟수만큼만 일어납니다. 그래서 프로파일 결과의 거의 전부가 inefficientSort에서 나오며, processData의 재할당 비용은 이 규모에서는 거의 보이지 않습니다. 직관으로 “재할당이 느리다”고 짐작하고 그쪽부터 고치는 실수를 프로파일러가 막아 주는 예입니다.
결과 화면에서 “총 CPU”(inclusive)는 그 함수와 그 함수가 호출한 함수의 시간을 합친 값이고, “자체 CPU”(exclusive)는 그 함수 본문에서만 쓴 시간입니다. 총 CPU는 큰데 자체 CPU가 작다면 하위 호출을 따라 내려가야 하고, 자체 CPU가 크다면 그 함수 본문이 병목입니다. 플레임 그래프에서는 막대 폭이 그 함수에서 잡힌 샘플 비율입니다.
인스트루먼테이션: 호출 횟수와 대기 시간
샘플링은 CPU를 쓰고 있는 순간만 잡으므로, 락이나 I/O를 기다리느라 멈춰 있는 시간은 보이지 않습니다. 인스트루먼테이션 도구는 함수 진입과 반환을 기록해 정확한 호출 횟수와 대기 시간을 포함한 경과 시간을 보여 줍니다. 대신 짧은 함수가 자주 호출될수록 기록 비용이 실제 실행 시간보다 커져 결과가 왜곡될 수 있으므로, 샘플링으로 대략적인 범위를 좁힌 뒤 특정 모듈이나 함수만 대상으로 지정해 쓰는 것이 좋습니다. 두 도구 모두 함수 이름을 표시하려면 PDB가 필요합니다.
프로파일링은 측정, 상위 몇 개 함수 개선, 재측정을 반복하는 과정입니다. 측정할 때는 같은 입력으로 여러 번 실행해 편차를 확인하고, 다른 무거운 프로세스는 꺼 두는 것이 좋습니다.
flowchart TD
A[Release 빌드 + PDB] --> B[CPU 샘플링 실행]
B --> C[핫 패스 확인]
C --> D[상위 함수 개선]
D --> E[같은 입력으로 재측정]
E --> F{목표 달성?}
F -->|아니오| B
F -->|예| G[완료]
힙 스냅샷과 CRT 누수 감지
힙 스냅샷으로 누수 찾기
메모리 사용량이 시간이 지나며 계속 늘어날 때, 그것이 누수인지 의도된 캐시 증가인지는 어떤 타입의 객체가 어디서 할당되어 남아 있는지를 봐야 알 수 있습니다. 성능 프로파일러에서 “메모리 사용량”을 선택하거나, 디버깅 중 진단 도구 창의 메모리 사용량 탭에서 힙 프로파일링을 켜고 스냅샷을 찍습니다. 의심 구간 전후에 스냅샷을 찍은 뒤 두 스냅샷을 비교하면, 타입별 할당 개수와 크기의 증가량이 표시됩니다. 네이티브 힙에서는 “스택 보기”로 전환하면 증가한 할당이 어느 호출 경로에서 일어났는지 볼 수 있습니다.
// memory_leak_demo.cpp
#include <iostream>
#include <memory>
#include <vector>
struct BigResource {
// 중괄호 {1000000}은 원소 하나짜리 벡터가 되므로 괄호 생성자를 명시
std::vector<int> data = std::vector<int>(1'000'000); // 약 4MB
};
void processOnce() {
auto* ptr = new BigResource(); // delete 없음: 누수
(void)ptr;
}
void processCorrectly() {
auto ptr = std::make_unique<BigResource>();
// 스코프 끝에서 자동 해제
}
int main() {
for (int i = 0; i < 100; ++i) {
processOnce(); // 100회 × 약 4MB = 약 400MB 누수
if (i % 10 == 0) {
std::cout << "Iteration " << i << "\n"; // 여기 중단점을 걸고 스냅샷
}
}
}
스냅샷을 비교하면 BigResource와 그 안의 int[] 할당 개수가 스냅샷마다 10개씩 늘어나고, 스택 보기에서 그 할당이 모두 processOnce에서 왔다는 것이 보입니다. 주석에 적어 두었듯 std::vector<int> data{1000000};은 값이 1000000인 원소 하나짜리 벡터를 만들기 때문에, 크기를 지정하려면 괄호 생성자를 써야 합니다. 이런 초기화 실수도 스냅샷의 할당 크기를 보면 바로 드러납니다.
커스텀 할당자 추적
메모리 풀이나 아레나처럼 malloc/new 위에서 자체적으로 메모리를 나눠 주는 할당자를 쓰면, 힙 스냅샷에는 큰 블록 하나만 보이고 그 안의 개별 객체는 보이지 않습니다. Visual Studio는 이런 할당자가 ETW 이벤트를 발생시켜 개별 할당을 알려 줄 수 있도록 VSCustomNativeHeapEtwProvider.h 헤더와 같은 이름의 라이브러리를 제공합니다.
#include <VSCustomNativeHeapEtwProvider.h>
#pragma comment(lib, "VSCustomNativeHeapEtwProvider.lib")
VSHeapTracker::CHeapTracker g_poolTracker("MyPool");
__declspec(allocator) void* poolAlloc(size_t size) {
void* p = /* 풀에서 할당 */ nullptr;
if (p) g_poolTracker.AllocateEvent(p, static_cast<unsigned long>(size));
return p;
}
void poolFree(void* p) {
if (p) g_poolTracker.DeallocateEvent(p);
/* 풀에 반환 */
}
__declspec(allocator)는 이 함수가 반환하는 포인터가 할당된 메모리라는 것을 도구에 알려 할당 호출 스택을 올바르게 연결하게 합니다. 정확한 헤더 경로와 API는 Visual Studio 버전의 “Custom native ETW heap events” 문서를 함께 확인하는 것이 좋습니다.
CRT 메모리 누수 감지
Debug CRT는 프로그램 종료 시 해제되지 않은 블록을 출력하는 기능을 제공합니다. 별도 도구 없이 단위 테스트에서 누수를 잡을 수 있어 CI에 넣기 좋습니다. Debug 구성(_DEBUG 정의, /MDd 또는 /MTd)에서만 동작합니다.
// crt_leak_check.cpp
#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>
int main() {
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
int* leak = new int(42); // 의도적 누수
(void)leak;
return 0;
// 종료 시 _CrtDumpMemoryLeaks()가 자동 호출되어 출력 창에 표시:
// Detected memory leaks!
// {NN} normal block at 0x..., 4 bytes long.
}
_CRTDBG_LEAK_CHECK_DF 플래그를 켜면 종료 시점에 _CrtDumpMemoryLeaks()가 자동으로 호출됩니다. _CRTDBG_MAP_ALLOC은 malloc 계열 호출에 파일 이름과 줄 번호를 붙여 주지만 new에는 적용되지 않으므로, C++ 코드에서는 출력의 할당 번호({NN})를 _CrtSetBreakAlloc(NN)에 넘겨 해당 할당 시점에 디버거를 멈추게 하는 방법을 많이 씁니다. 전역 객체처럼 종료 직전까지 살아 있는 할당도 누수로 보고될 수 있다는 점은 감안해야 합니다.
코드 분석 (C++ Core Check)
C++ Core Guidelines 정적 분석
MSVC 코드 분석(/analyze)은 컴파일 과정에서 소스를 분석해 컴파일러 경고로는 잡히지 않는 버그와 안티패턴을 찾습니다. C++ Core Check는 그중 C++ Core Guidelines 규칙을 검사하는 확장입니다. 프로젝트 속성 > 코드 분석 > 일반에서 “빌드 시 코드 분석 사용”을 켜고, 규칙 집합으로 “Microsoft Native Recommended Rules”나 “C++ Core Check Rules”를 고릅니다. 편집기에서 실시간으로 경고를 보려면 도구 > 옵션 > 텍스트 편집기 > C/C++ > 코드 분석의 백그라운드 분석 설정을 켭니다.
| 규칙 ID | 내용 | 예시와 대안 |
|---|---|---|
| C26494 | 변수를 항상 초기화(type.5) | int x; → int x{}; |
| C26485 | 배열이 포인터로 붕괴(bounds.3) | 배열 int a[10]을 f(int*)에 전달 → std::span<int> 매개변수 |
| C26481 | 포인터 산술 금지(bounds.1) | *(p + i), p[i] → std::span의 s[i] |
| C26439 | 이 종류의 함수는 예외를 던지면 안 되므로 noexcept 선언 필요(f.6) | 이동 생성자, 이동 대입, swap에 noexcept 추가 |
// code_analysis_demo.cpp
#include <span>
#include <vector>
void uninitializedExample() {
int data; // C26494: 초기화되지 않은 변수
(void)data;
}
void fillPtr(int* p, size_t n) {
for (size_t i = 0; i < n; ++i) {
p[i] = 0; // C26481: 포인터 산술(첨자 연산도 포함)
}
}
void fillSpan(std::span<int> s) { // 권장: 길이를 함께 가진 span
for (auto& v : s) {
v = 0;
}
}
int main() {
int arr[10];
fillPtr(arr, 10); // C26485: 배열이 포인터로 붕괴
fillSpan(arr); // 배열에서 span으로 크기 정보 유지
std::vector<int> vec(10);
fillSpan(vec);
uninitializedExample();
}
명령줄과 CI에서 코드 분석 실행
.vcxproj에서는 다음 속성으로 빌드 시 코드 분석을 켭니다.
<PropertyGroup>
<RunCodeAnalysis>true</RunCodeAnalysis>
<CodeAnalysisRuleSet>NativeRecommendedRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
프로젝트 파일을 고치지 않고 CI에서만 켜려면 MSBuild 속성으로 넘깁니다.
msbuild MyProject.sln /p:Configuration=Release /p:Platform=x64 /p:RunCodeAnalysis=true
CMake 프로젝트라면 target_compile_options(my_app PRIVATE /analyze)로 같은 분석을 켤 수 있습니다. 기존 코드베이스에 처음 도입할 때는 경고가 수백 개 쏟아지는 것이 보통이므로, 새로 작성하는 코드에만 엄격한 규칙을 적용하고 기존 코드는 경고를 줄여 나가는 식으로 점진적으로 도입합니다.
clang-tidy와 확장 프로그램
Visual Studio는 2019 버전부터 clang-tidy를 내장 지원합니다. 프로젝트 속성 > 코드 분석 > 일반에서 “Clang-Tidy 사용”을 켜면 MSVC 코드 분석과 함께 실행되고, 저장소의 .clang-tidy 파일을 그대로 읽으므로 Linux 빌드와 같은 규칙을 Windows에서도 적용할 수 있습니다.
# .clang-tidy
Checks: >
-*,
bugprone-*,
performance-*,
modernize-*,
readability-*
WarningsAsErrors: ''
HeaderFilterRegex: '.*'
리팩터링과 탐색 기능을 보강하는 확장으로는 Visual Assist와 ReSharper C++이 널리 쓰이고, Clang Power Tools는 clang-tidy와 clang-format 실행을 더 세밀하게 제어하고 싶을 때 씁니다.
심볼 미표시, LNK2038, IntelliSense 오작동: 문제 해결
프로파일러에서 심볼이 보이지 않음
PDB가 생성되지 않았거나 프로파일러가 찾지 못하는 경우입니다. .vcxproj에서 Release 구성의 PDB 생성은 컴파일러와 링커 양쪽 설정이 필요합니다.
<ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
<ClCompile>
<DebugInformationFormat>ProgramDatabase</DebugInformationFormat> <!-- /Zi -->
</ClCompile>
<Link>
<GenerateDebugInformation>true</GenerateDebugInformation> <!-- /DEBUG -->
</Link>
</ItemDefinitionGroup>
속성 창에서는 C/C++ > 일반 > 디버그 정보 형식을 “프로그램 데이터베이스(/Zi)“로, 링커 > 디버깅 > 디버그 정보 생성을 “예(/DEBUG)“로 둡니다. 시스템 DLL의 함수 이름까지 보려면 도구 > 옵션 > 디버깅 > 기호에서 Microsoft 기호 서버를 켭니다.
메모리 스냅샷이 비어 있음
메모리 사용량 도구의 힙 프로파일링이 꺼져 있는 경우입니다. 성능 프로파일러에서 메모리 사용량을 선택한 뒤 설정(톱니바퀴)에서 네이티브 힙 프로파일링을 켜거나, 진단 도구 창의 메모리 사용량 탭에서 “힙 프로파일링”을 켠 다음 프로그램을 다시 시작해야 합니다. 이미 실행 중인 프로세스에 대해서는 켤 수 없습니다.
코드 분석 경고가 너무 많음
“Microsoft Native Minimum Rules”처럼 작은 규칙 집합으로 시작하고, 특정 경고는 #pragma warning(disable: 26494)나 [[gsl::suppress("type.5")]] 같은 억제 특성으로 범위를 좁혀 끕니다. 서드파티 헤더에서 나오는 경고는 외부 헤더 설정(/external:I, /external:W0)으로 분리하면 분석 대상에서 빠집니다.
LNK2038: 런타임 라이브러리 불일치
Debug/Release CRT나 정적(/MT)/동적(/MD) CRT가 섞인 경우입니다. 런타임 라이브러리는 PropertyGroup이 아니라 컴파일러 설정(ClCompile)에 속합니다.
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary> <!-- /MDd -->
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
<ClCompile>
<RuntimeLibrary>MultiThreadedDLL</RuntimeLibrary> <!-- /MD -->
</ClCompile>
</ItemDefinitionGroup>
vcpkg 트리플렛도 이 설정과 맞아야 하며, x64-windows는 /MD(Debug는 /MDd)를, x64-windows-static은 /MT를 씁니다. CMake에서는 CMAKE_MSVC_RUNTIME_LIBRARY로 지정합니다.
IntelliSense가 동작하지 않음
include 경로가 빠져 있거나 CMake 캐시가 오래됐거나, 프로젝트가 커서 데이터베이스 생성이 끝나지 않은 경우가 많습니다. 추가 포함 디렉터리를 확인하고, CMake 프로젝트라면 캐시를 삭제하고 다시 구성합니다. 그래도 안 되면 솔루션을 닫고 .vs 폴더를 지운 뒤 다시 열어 IntelliSense 데이터베이스를 재생성합니다.
디버거 위치가 소스와 맞지 않음
PDB가 현재 소스보다 오래됐거나, 최적화로 코드가 재배치되어 줄 번호 매핑이 어긋난 경우입니다. 클린 빌드 후 다시 실행하고, 최적화된 빌드라면 그 함수만 #pragma optimize("", off)로 감싸 확인하는 방법도 있습니다. 소스 파일을 다른 경로로 옮겼다면 솔루션 속성 > 공용 속성 > 디버그 소스 파일에서 검색 경로를 추가합니다.
생산성 기능
키보드 단축키
| 단축키 | 기능 |
|---|---|
Ctrl+K, Ctrl+D | 문서 서식 지정 |
Ctrl+. | 빠른 작업(리팩터링, include 추가 등) |
F12 | 정의로 이동 |
Shift+F12 | 모든 참조 찾기 |
Ctrl+T | 모두 이동(파일·기호 검색) |
Ctrl+Shift+B | 솔루션 빌드 |
Ctrl+F5 | 디버깅 없이 실행 |
Ctrl+K, Ctrl+K / Ctrl+K, Ctrl+N | 책갈피 설정·해제 / 다음 책갈피 |
다중 커서는 Alt+드래그로 세로 블록을 선택하거나, Ctrl+Alt+클릭으로 커서를 추가하고, Shift+Alt+.로 현재 선택과 같은 다음 단어에 커서를 추가합니다. 단축키는 키보드 매핑 구성(도구 > 옵션 > 환경 > 키보드)에 따라 다를 수 있습니다.
코드 조각(snippet)
for, if, class 같은 기본 코드 조각은 키워드를 입력하고 Tab을 누르면 펼쳐집니다. 직접 만든 코드 조각은 도구 > 코드 조각 관리자에서 가져옵니다. $var$ 같은 자리 표시자는 Declarations에 선언해야 합니다.
<?xml version="1.0" encoding="utf-8"?>
<CodeSnippets xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
<CodeSnippet Format="1.0.0">
<Header>
<Title>range for</Title>
<Shortcut>forr</Shortcut>
</Header>
<Snippet>
<Declarations>
<Literal><ID>var</ID><Default>item</Default></Literal>
<Literal><ID>collection</ID><Default>items</Default></Literal>
</Declarations>
<Code Language="cpp"><![CDATA[for (const auto& $var$ : $collection$) {
$end$
}]]></Code>
</Snippet>
</CodeSnippet>
</CodeSnippets>
병렬 빌드
도구 > 옵션 > 프로젝트 및 솔루션 > 빌드 및 실행의 “최대 병렬 프로젝트 빌드 수”는 서로 독립적인 프로젝트를 동시에 빌드하는 수입니다. 한 프로젝트 안의 소스 파일을 병렬로 컴파일하려면 프로젝트 속성 > C/C++ > 일반 > “다중 프로세서 컴파일”(/MP)을 켜야 합니다. 두 설정을 모두 코어 수로 잡으면 동시에 실행되는 컴파일러 프로세스가 코어 수의 제곱까지 늘어날 수 있으므로, 메모리가 부족하면 한쪽을 줄입니다.
빌드 전략과 프로덕션 진단
CMake에서 구성별 설정
Visual Studio 생성기처럼 다중 구성 생성기에서는 구성 단계에 CMAKE_BUILD_TYPE이 정해지지 않으므로, 구성별 설정은 생성기 식(generator expression)으로 씁니다. MSVC는 /MDd나 /MTd로 컴파일할 때 _DEBUG를 자동으로 정의하므로 직접 정의할 필요가 없습니다.
if(MSVC)
# 모든 구성에서 PDB 생성 (Release 프로파일링용)
target_compile_options(my_app PRIVATE /Zi)
target_link_options(my_app PRIVATE /DEBUG /OPT:REF /OPT:ICF)
# Debug에서만 CRT 누수 보고에 malloc 파일·줄 정보 추가
target_compile_definitions(my_app PRIVATE $<$<CONFIG:Debug>:_CRTDBG_MAP_ALLOC>)
endif()
Release에서 /DEBUG를 켜면 링커가 기본으로 /OPT:REF와 /OPT:ICF를 끄기 때문에, 바이너리 크기를 유지하려면 위처럼 다시 켜 줍니다.
크래시 덤프
현장에서 발생한 크래시는 미니덤프를 남겨 PDB와 함께 Visual Studio로 열면 크래시 시점의 호출 스택과 변수를 볼 수 있습니다. 처리되지 않은 예외 필터에서 MiniDumpWriteDump를 호출하는 것이 가장 간단한 방법입니다.
#include <windows.h>
#include <DbgHelp.h>
#pragma comment(lib, "DbgHelp.lib")
LONG WINAPI MiniDumpHandler(EXCEPTION_POINTERS* pException) {
HANDLE hFile = CreateFileW(L"crash.dmp", GENERIC_WRITE, 0, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (hFile != INVALID_HANDLE_VALUE) {
MINIDUMP_EXCEPTION_INFORMATION mei{};
mei.ThreadId = GetCurrentThreadId();
mei.ExceptionPointers = pException;
mei.ClientPointers = FALSE;
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
hFile, MiniDumpNormal, &mei, nullptr, nullptr);
CloseHandle(hFile);
}
return EXCEPTION_EXECUTE_HANDLER;
}
int main() {
SetUnhandledExceptionFilter(MiniDumpHandler);
// ...
}
크래시한 프로세스 안에서 덤프를 쓰는 방식은 힙이 손상된 경우 실패할 수 있어, 신뢰성이 중요하면 별도 감시 프로세스에서 덤프를 남기거나 Windows Error Reporting의 LocalDumps 레지스트리 설정을 쓰는 방법을 함께 고려합니다. 덤프를 분석하려면 빌드할 때 만든 PDB를 버전별로 보관해 두어야 합니다.
팀 공통 설정
들여쓰기·줄 끝·인코딩은 .editorconfig로, 코드 분석 규칙은 저장소에 넣은 .ruleset이나 .clang-tidy로, 의존성과 빌드 구성은 vcpkg.json과 CMakePresets.json으로 공유하면 팀원마다 다른 환경 때문에 생기는 문제를 줄일 수 있습니다.