C++와 Rust 간의 메모리 안전성 논의는 소프트웨어 엔지니어링에서 가장 활발한 주제 중 하나가 되었습니다. 정부 기관이 의견을 제시하고, 컨퍼런스 발표가 이 주제에 할애되며, 양쪽 모두 강한 견해를 가지고 있습니다.

먼저 솔직하게 말씀드리겠습니다: Rust는 훌륭한 언어입니다. 소유권 모델과 빌림 검사기는 진정으로 혁신적이며, 컴파일 시점에 버그의 전체 범주를 잡아냅니다. 새 프로젝트를 시작하는데 Rust가 팀과 생태계에 적합하다면, 그것은 훌륭한 선택입니다.

동시에 C++는 세계에서 가장 성능이 중요한 소프트웨어의 근간으로 남아 있습니다: 운영체제 커널, 게임 엔진, 브라우저, 데이터베이스, 금융 시스템 등이 그렇습니다. 이는 우연이 아니며, 해당 팀들이 Rust를 모르기 때문도 아닙니다.

이 글에서 탐구하고자 하는 것은 논쟁에서 종종 간과되는 지점입니다: 모던 C++ (C++11 이후)는 메모리 안전한 코드를 작성하기 위한 강력한 도구를 제공합니다. 스마트 포인터, RAII, 범위 검사가 되는 컨테이너, 그리고 풍부한 표준 라이브러리는 Rust 컴파일러가 강제하는 것과 동일한 범주의 버그를 해결합니다. 두 언어는 단지 같은 문제에 대해 서로 다른 설계 접근 방식을 취합니다: 컴파일러 강제 vs. 개발자가 선택하는 관용구.

추상적으로 논쟁하는 대신, 구체적이고 실행 가능한 예제를 통해 제가 의미하는 바를 보여드리겠습니다.

면책 조항: 이 글에 표현된 의견은 저의 개인적인 것이며 전문적 경험에 기반합니다. 모든 코드 예제는 교육 목적으로 의도적으로 단순화되었습니다. 모든 언어에는 트레이드오프가 있으며, 이 글은 어떤 언어나 커뮤니티를 비하하려는 것이 아니라 사려 깊은 논의를 장려하는 것을 목표로 합니다. 개인에 따라 경험이 다를 수 있습니다.

예제 1: 댕글링 포인터 (Use-After-Free)

이것은 가장 흔한 메모리 안전성 버그 중 하나입니다. 포인터가 가리키는 메모리가 해제된 후에도 해당 포인터가 계속 사용되는 것입니다.

나쁜 C++ (잘못된 관행)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(42);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    // ptr is now dangling (it still holds the address of freed memory)
 8    delete ptr;
 9
10    // Undefined behavior: reading freed memory
11    std::cout << "After delete: " << *ptr << std::endl;
12
13    return 0;
14}

무엇이 잘못되었나: 개발자가 new로 수동으로 메모리를 할당하고, delete로 수동으로 해제한 후, 원시 포인터를 계속 사용했습니다. 이것은 전형적인 use-after-free 버그입니다. 문제는 C++가 이것을 “허용했다"는 것이 아니라, 모던 대안이 존재하는데 1990년대 스타일의 패턴을 사용했다는 것입니다.

좋은 C++ (모던 관행)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(42);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Explicitly releases the memory
 9    ptr.reset();
10
11    if (!ptr) {
12        std::cout << "Pointer is null, safe to handle." << std::endl;
13    }
14
15    return 0;
16}

무엇이 달라졌나:

std::unique_ptr가 리소스를 소유합니다. 스코프를 벗어나거나 reset되면 메모리가 해제되고 동시에 포인터가 null 검사 가능한 상태가 됩니다. 댕글링 포인터가 없습니다. 정의되지 않은 동작도 없습니다. 이것은 2011년부터 표준 C++입니다.

예제 2: 버퍼 오버플로우

배열의 범위를 넘어서 읽거나 쓰는 것은 또 다른 대표적인 취약점 원인입니다.

나쁜 C++ (잘못된 관행)

 1#include <iostream>
 2#include <cstring>
 3
 4int main() {
 5    char buffer[10];
 6
 7    // Copying more data than the buffer can hold
 8    const char* source = "This string is way too long for the buffer";
 9    std::strcpy(buffer, source);
10
11    std::cout << "Buffer: " << buffer << std::endl;
12    return 0;
13}

무엇이 잘못되었나:

개발자가 고정 크기 C 배열과 범위 검사가 전혀 없는 C 시대의 함수(strcpy)를 사용했습니다. 이런 종류의 코드가 C++의 평판을 나쁘게 만들지만, 이것은 모던 C++가 아닙니다. C++ 컴파일러로 컴파일한 C 코드입니다.

좋은 C++ (모던 관행)

 1#include <iostream>
 2#include <string>
 3
 4int main() {
 5    std::string buffer;
 6
 7    // std::string manages its own memory and grows as needed
 8    buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
 9
10    std::cout << "Buffer: " << buffer << std::endl;
11
12    // Need a substring? Bounds-checked:
13    try {
14        std::string sub = buffer.substr(0, 10);
15        std::cout << "Substring: " << sub << std::endl;
16
17        // This would throw std::out_of_range:
18        // std::string bad_sub = buffer.substr(9999, 10);
19    } catch (const std::out_of_range& e) {
20        std::cout << "Caught: " << e.what() << std::endl;
21    }
22
23    return 0;
24}

무엇이 달라졌나:

std::string은 자체적으로 메모리를 관리합니다. 오버플로우가 발생할 수 없습니다. .at()이나 잘못된 인덱스로 .substr()을 사용한 범위 밖 작업 시도는 잡을 수 있는 예외를 발생시킵니다. 이것은 C++98부터 표준에 포함되어 있습니다.

예제 3: 배열 범위 밖 접근

나쁜 C++ (잘못된 관행)

 1#include <iostream>
 2
 3int main() {
 4    int arr[5] = {10, 20, 30, 40, 50};
 5
 6    // Accessing index 10 (way out of bounds)
 7    std::cout << "arr[10] = " << arr[10] << std::endl;
 8
 9    return 0;
10}

무엇이 잘못되었나:

C 스타일 원시 배열은 범위 검사가 전혀 없습니다. 개발자가 잘못된 인덱스에 접근하여 쓰레기 값(또는 더 나쁜 경우, 보안 취약점)을 얻었습니다. 다시 한 번, 이것은 사용 가능한 가장 원시적인 데이터 구조를 사용하겠다는 개발자의 선택입니다.

좋은 C++ (모던 관행)

 1#include <iostream>
 2#include <array>
 3#include <stdexcept>
 4
 5int main() {
 6    std::array<int, 5> arr = {10, 20, 30, 40, 50};
 7
 8    try {
 9        // .at() performs bounds checking and throws on invalid access
10        std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11        std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12    } catch (const std::out_of_range& e) {
13        std::cout << "Caught out-of-range: " << e.what() << std::endl;
14    }
15
16    return 0;
17}

무엇이 달라졌나:

std::array는 자신의 크기를 아는 고정 크기 컨테이너입니다. .at() 메서드는 런타임에 범위 검사를 수행하고 범위를 초과하면 예외를 발생시킵니다. C++11부터 사용 가능합니다.

예제 4: 이중 해제 (Double Free)

나쁜 C++ (잘못된 관행)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(100);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    delete ptr;
 8    // Double free (undefined behavior, potential crash or exploit)
 9    delete ptr;
10
11    return 0;
12}

무엇이 잘못되었나:

개발자가 같은 메모리를 두 번 해제했습니다. 이것은 힙 할당자의 내부 상태를 손상시킬 수 있으며 잘 알려진 공격 벡터입니다. 이것 역시 수동 메모리 관리 실수입니다.

좋은 C++ (모던 관행)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(100);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Memory is freed exactly once when ptr goes out of scope.
 9    // You literally cannot double-free with unique_ptr.
10    // Calling reset() is also safe:
11    ptr.reset();
12    // No-op. No crash. No UB.
13    ptr.reset();
14
15    std::cout << "Done safely." << std::endl;
16    return 0;
17}

무엇이 달라졌나:

std::unique_ptr는 단일 소유권 의미론을 보장합니다. 메모리는 정확히 한 번만 해제됩니다: 포인터가 스코프를 벗어날 때 또는 reset()이 호출될 때입니다. 이미 null인 unique_ptrreset()을 호출하면 안전하게 아무 일도 일어나지 않습니다.

예제 5: 공유 소유권의 잘못된 사용 vs. 올바른 사용

나쁜 C++ (잘못된 관행)

 1#include <iostream>
 2
 3void use(int* p) {
 4    std::cout << "Using: " << *p << std::endl;
 5}
 6
 7int main() {
 8    int* shared = new int(99);
 9
10    use(shared);
11
12    delete shared;
13
14    // Another function tries to use the same pointer (use-after-free)
15    use(shared);
16
17    return 0;
18}

무엇이 잘못되었나:

코드의 여러 부분이 원시 포인터를 공유하며, 명확한 소유권이 없습니다. 한 부분이 메모리를 삭제하면, 다른 모든 부분은 댕글링 포인터를 들고 있게 됩니다.

좋은 C++ (모던 관행)

 1#include <iostream>
 2#include <memory>
 3
 4void use(std::shared_ptr<int> p) {
 5    std::cout << "Using: " << *p
 6              << " (ref count: " << p.use_count() << ")" << std::endl;
 7}
 8
 9int main() {
10    auto shared = std::make_shared<int>(99);
11
12    use(shared);
13    use(shared);
14
15    // Memory freed automatically when the last shared_ptr goes out of scope.
16    std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18    return 0;
19}

무엇이 달라졌나:

std::shared_ptr는 참조 카운팅을 사용하여 공유 소유권을 관리합니다. 메모리는 이를 참조하는 마지막 shared_ptr가 소멸될 때만 해제됩니다. 댕글링 포인터 없음. 수동 delete 없음. C++11부터 사용 가능합니다.

예제 6: RAII - 모던 C++ 안전성

RAII (Resource Acquisition Is Initialization)는 아마도 C++에서 가장 중요한 단일 관용구일 것입니다. 개념은 간단합니다: 리소스의 수명을 객체의 수명에 연결하는 것입니다. 객체가 생성될 때 리소스를 획득하고, 객체가 소멸될 때(스코프를 벗어날 때) 리소스를 해제합니다. 수동 정리 없음. 잊어버릴 가능성 없음.

스마트 포인터는 RAII의 한 가지 응용에 불과합니다. 이 패턴은 모든 리소스에 적용됩니다: 파일, 뮤텍스, 네트워크 소켓, 데이터베이스 연결, GPU 핸들 등 무엇이든 가능합니다.

나쁜 C++ (수동 리소스 관리)

 1#include <iostream>
 2#include <fstream>
 3#include <stdexcept>
 4
 5void process_file(const std::string& filename) {
 6    FILE* f = fopen(filename.c_str(), "r");
 7    if (!f) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    char buf[256];
12    if (!fgets(buf, sizeof(buf), f)) {
13        // Oops - if we throw or return here, we leak the file handle
14        throw std::runtime_error("Cannot read file");
15    }
16
17    // If any exception is thrown above, fclose is never called
18    fclose(f);
19    std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23    try {
24        process_file("example.txt");
25    } catch (const std::exception& e) {
26        std::cerr << "Error: " << e.what() << std::endl;
27    }
28    return 0;
29}

무엇이 잘못되었나:

개발자가 C 스타일 fopen/fclose를 사용했습니다. open과 close 사이에서 예외가 발생하면 파일 핸들이 누수됩니다. 리소스와 에러 경로가 늘어날수록, 모든 것을 안정적으로 닫는 것은 유지보수의 악몽이 됩니다.

좋은 C++ (std::ifstream을 사용한 RAII)

 1#include <iostream>
 2#include <fstream>
 3#include <string>
 4
 5void process_file(const std::string& filename) {
 6    std::ifstream file(filename);
 7    if (!file.is_open()) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    std::string line;
12    if (!std::getline(file, line)) {
13        throw std::runtime_error("Cannot read file");
14    }
15
16    // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17    std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21    try {
22        process_file("example.txt");
23    } catch (const std::exception& e) {
24        std::cerr << "Error: " << e.what() << std::endl;
25    }
26    return 0;
27}

무엇이 달라졌나:

std::ifstream은 파일 핸들에 대한 RAII 래퍼입니다. 파일은 생성자에서 열리고 소멸자에서 닫히며, 이는 함수가 어떻게 종료되든(정상 반환, 예외, 조기 반환) 보장됩니다. 설계상 리소스 누수가 발생하지 않습니다.

커스텀 리소스를 위한 RAII (뮤텍스 잠금)

RAII는 표준 라이브러리에만 국한되지 않습니다. 스레드 동기화에서 어떻게 작동하는지 보겠습니다:

 1#include <iostream>
 2#include <mutex>
 3#include <thread>
 4#include <vector>
 5
 6std::mutex mtx;
 7int shared_counter = 0;
 8
 9void increment(int times) {
10    for (int i = 0; i < times; ++i) {
11        // std::lock_guard locks the mutex on construction,
12        // unlocks it on destruction (end of scope, even if an exception occurs)
13        std::lock_guard<std::mutex> lock(mtx);
14        ++shared_counter;
15    }
16}
17
18int main() {
19    std::vector<std::thread> threads;
20    for (int i = 0; i < 4; ++i) {
21        threads.emplace_back(increment, 10000);
22    }
23
24    for (auto& t : threads) {
25        t.join();
26    }
27
28    // Always prints 40000 (no data race, no forgotten unlock)
29    std::cout << "Counter: " << shared_counter << std::endl;
30
31    return 0;
32}

나만의 RAII 래퍼 만들기

RAII는 어떤 리소스에든 적용할 수 있습니다. 다음은 최소한의 예제입니다:

 1#include <iostream>
 2#include <stdexcept>
 3
 4// A generic RAII wrapper (acquires in constructor, releases in destructor)
 5class SocketHandle {
 6    int fd_;
 7public:
 8    explicit SocketHandle(int fd) : fd_(fd) {
 9        if (fd_ < 0) throw std::runtime_error("Invalid socket");
10        std::cout << "Socket " << fd_ << " acquired." << std::endl;
11    }
12
13    ~SocketHandle() {
14        if (fd_ >= 0) {
15            std::cout << "Socket " << fd_ << " released." << std::endl;
16            fd_ = -1;
17        }
18    }
19
20    // Prevent copying (single ownership, like unique_ptr)
21    SocketHandle(const SocketHandle&) = delete;
22    SocketHandle& operator=(const SocketHandle&) = delete;
23
24    // Allow moving
25    SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26        other.fd_ = -1;
27    }
28
29    int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33    SocketHandle sock(42); // "Acquired"
34    std::cout << "Using socket " << sock.get() << std::endl;
35    // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39    do_network_stuff();
40    std::cout << "Back in main - no resource leak." << std::endl;
41    return 0;
42}

핵심 요점:

RAII는 임시방편이나 “모범 사례 팁"이 아닙니다 - 일관되게 적용하면 리소스 누수를 구조적으로 불가능하게 만드는 C++의 근본적인 설계 패턴입니다. Rust의 소유권 모델보다 먼저 존재했으며, 여러 면에서 그것에 영감을 주었습니다.

Rust와 비교하면?

Rust의 빌림 검사기는 이러한 문제의 상당수를 코드가 실행되기도 전에 컴파일 시점에서 잡아냅니다. 이것은 진정한 의미 있는 장점이며, 인정받을 만합니다.

트레이드오프를 솔직하게 정리하면 다음과 같습니다:

  1. Rust는 컴파일러 수준에서 안전성을 강제하고, C++는 표준 라이브러리와 관용구를 통해 안전성을 제공합니다. 두 접근 방식 모두 유효하며, 서로 다른 설계 철학을 반영합니다.

  2. 모던 C++는 권장 도구를 사용하면 동일한 범주의 버그를 방지합니다. 스마트 포인터, RAII, std::array, std::string, std::span, std::optional — 이것들은 알려지지 않은 라이브러리 기능이 아닙니다. 이것들은 C++를 작성하는 권장 방법이며, 10년이 넘는 기간 동안 그래왔습니다.

  3. 어느 언어도 만능은 아닙니다. Rust의 unsafe 키워드가 존재하는 데에는 이유가 있습니다 — 때로는 FFI, 성능, 또는 저수준 작업을 위해 안전망 바깥으로 나가야 합니다. 그리고 논리 버그, 경쟁 조건(일부 패턴에서), 설계 오류는 모든 언어에 존재합니다.

  4. 실용주의가 중요합니다. 수십억 줄의 검증된 C++ 코드가 현재 프로덕션에서 돌아가고 있습니다. 많은 팀에게 모던 C++ 관행, 더 나은 도구, 정적 분석의 도입은 전면 재작성보다 더 현실적인 경로이며, 실질적인 안전성 향상을 가져옵니다.

도구는 어떤가요?

모던 C++ 개발자들은 언어 자체가 강제하는 것 이상의 문제를 잡아내는 강력한 안전성 도구에 접근할 수 있습니다:

도구용도
AddressSanitizer (ASan)use-after-free, 버퍼 오버플로우, 스택 오버플로우 탐지
MemorySanitizer (MSan)초기화되지 않은 메모리 읽기 탐지
UndefinedBehaviorSanitizer (UBSan)부호 있는 오버플로우, null 역참조 등 탐지
Valgrind메모리 누수 탐지 및 프로파일링
Clang-Tidy모던화 제안이 포함된 정적 분석
C++ Core Guidelines안전하고 모던한 C++를 위한 산업 표준 가이드라인

이 글의 “나쁜” 예제들을 -fsanitize=address로 컴파일하면 런타임에 즉시 문제를 발견할 수 있습니다. 도구는 존재합니다 — 문제는 개발자가 이를 사용하느냐입니다.

결론

C++와 Rust는 서로 다른 각도에서 메모리 안전성에 접근합니다:

  • Rust는 안전성을 기본값으로 설정하고 unsafe로 명시적으로 옵트아웃하도록 요구합니다.
  • **C++**는 개발자에게 완전한 제어권을 주고 표준 라이브러리, RAII, 그리고 잘 확립된 관용구를 통해 안전성을 제공합니다.

C++ 코드베이스의 대부분의 메모리 안전성 문제는 오래된 패턴 — 원시 new/delete, C 스타일 배열, strcpy 같은 함수 — 을 사용하는 데서 비롯됩니다. 언어가 10년 넘게 제공해 온 모던 도구가 아닌 것들 말입니다.

좋은 소식은 C++의 나아갈 길이 명확하고 잘 문서화되어 있다는 것입니다:

  • 원시 new/delete 대신 스마트 포인터(std::unique_ptr, std::shared_ptr)를 사용하세요
  • C 스타일 버퍼 대신 표준 컨테이너(std::string, std::vector, std::array)를 사용하세요
  • 모든 리소스 관리에 RAII를 적용하세요
  • CI 파이프라인에서 새니타이저(ASan, MSan, UBSan)와 정적 분석을 활용하세요
  • C++ Core Guidelines 를 따르세요

두 언어 모두 강력한 도구이며, 현대 소프트웨어 개발에서 각자의 자리가 있습니다. 최선의 선택은 프로젝트, 팀, 그리고 제약 조건에 달려 있습니다 — 인터넷 논쟁이 아닙니다.

이 글이 유용하거나 생각할 거리가 되었다면 공유해 주세요. 다른 관점이 있으시다면 아래 댓글 섹션에서 의견을 듣고 싶습니다. 최고의 엔지니어링 논의는 의견이 아닌 코드로 토론할 때 이루어집니다.