[모던 C++] RAII와 스마트 포인터

C++에서 자원 관리는 안전성과 직결된다. 본 글에서는 RAII 개념과 스마트 포인터(unique_ptr, shared_ptr, weak_ptr)를 다루며, 예제 코드와 함께 자원을 안전하게 관리하는 모던 C++ 방식을 소개한다.

[모던 C++] RAII와 스마트 포인터

C++은 강력한 성능과 유연성을 제공하는 언어이지만, 그만큼 메모리 관리와 자원 관리에 대한 부담이 크다. 개발자가 직접 new와 delete를 사용해 메모리를 해제하거나 파일을 닫는 방식은 작은 실수로도 치명적인 오류나 메모리 누수를 일으킨다. 이를 해결하기 위해 모던 C++은 RAII(Resource Acquisition Is Initialization) 패러다임과 스마트 포인터를 도입해 자원 관리 방식을 근본적으로 개선한다.

RAII


RAII란?

RAII (Resource Acquisition Is Initialization)란 말 그대로 "리소스는 객체의 생성과 동시에 획득되고 소멸과 함께 해제된다"는 개념이자 패러다임이다. 단순한 프로그램 문법이나 규칙이라기 보단 리소스를 안전하게 관리하기 위한 모던 c++의 핵심 패러다임이라고 볼 수 있다.

핵심 아이디어는 정의와 ​같다.

  • 생서자 -> 자원 획득 (파일 열기, 메모리 할당, 락 획득 등)
  • 소멸자 -> 자원 해제 (파일 닫기, 메모리 해제, 락 반환 등)

RAII의 장점은 “자원의 수명과 객체의 수명을 일치시킨다”는 데 있다.
예를 들어 C 언어에서는 FILE*fopen / fclose를 직접 호출해야 했기 때문에,

  • fclose를 깜빡하면 메모리 누수 발생
  • 예외나 조기 return이 있으면 자원 해제가 누락
    같은 문제가 쉽게 생긴다.

C++에서는 std::fstream 같은 클래스가 내부적으로 RAII를 구현하고 있어서, 파일 객체가 소멸될 때 자동으로 닫히도록 보장한다.

예시 코드

#include <cstdio>

void readFileC()
{
    FILE *fp = fopen("example.txt", "r");
    if (!fp)
    {
        return; // fclose 깜빡 → 자원 누수 발생
    }

    char buffer[100];
    while (fgets(buffer, sizeof(buffer), fp))
    {
        printf("%s", buffer);
    }

    fclose(fp); // 반드시 직접 호출해야 함
}

c 방식 (자원을 직접 관리)

#include <fstream>
#include <iostream>
#include <string>

void readFileCpp()
{
    std::fstream file("example.txt"); // 생성자 → 파일 열림
    if (!file.is_open())
    {
        return;
    }

    std::string line;
    while (std::getline(file, line))
    {
        std::cout << line << std::endl;
    }

    // 소멸자 호출 시 자동으로 파일 닫힘 → fclose 필요 없음
}

C++ RAII 방식 (자동 관리)

여기서 std::fstream이 RAII를 보여주는 사례다.

  • 파일이 열릴 때 생성자에서 자원을 획득
  • 함수가 끝나면서 객체가 스코프를 벗어나면 소멸자가 호출 → 자동으로 파일 닫힘

즉, 개발자가 직접 해제를 신경 쓰지 않아도 예외 상황이든 조기 종료든 자원은 안전하게 관리된다.

스마트 포인터


스마트 포인터란?

스마트 포인터(Smart Pointer)는 포인터처럼 동작하지만 소멸될 때 자동으로 메모리를 해제하는 클래스 템플릿이다. 즉, 포인터에 RAII 페러다임을 적용한 도구라 할 수 있다. 기존 C++ 98/03 까지는 new / delete를 직접 관리해야해서 메모리 누수 위험이 컸지만, C++11부터는 스마트 포인터가 표준으로 제공되어서 안전한 메모리 관리를 보장한다.

스마트 포인터는 소유권 관리 방식에 따라 3가지로 구현되어 있다:

  • std::unique_ptr : 하나의 객체를 오직 한 포인터만 소유할 수 있는 스마트 포인터
  • std::shared_ptr : 하나의 객체를 여러 스마트 포인터가 공동 소유할 수 있는 스마트 포인터
  • std::weak_ptr : shared_ptr가 관리하는 객체를 소유하지 않고 약하게 참조하는 포인터 (순환 참조 방지용)

스마트 포인터 vs 동적 할당 비교

구분 Raw Pointer (new/delete) 스마트 포인터 (unique_ptr, shared_ptr, weak_ptr)
메모리 해제 수동 delete 필요, 누락 위험 스코프 종료 시 자동 해제 (RAII)
예외 안전성 예외 시 해제 누락 자동으로 안전하게 해제
소유권 표현 코드 상에서 불명확 소유권 모델이 명확 (단독/공유/비소유)
가독성 관리 코드 많음 간결하고 직관적
확장성 직접 해제 함수 작성 필요 사용자 정의 소멸자 지원 (fclose, free 등)

스마트 포인터의 사용자 정의 소멸자 (Custom deleter)

스마트 포인터(unique_ptr, shared_ptr)는 기본적으로 delete 연산자를 사용해서 자원을 해제한다. 하지만 모든 자원이 delete만으로 해제되는 것은 아니다.

  • 파일 포인터(FILE*) → fclose로 닫아야 함
  • 소켓 핸들 → close 필요
  • 동적 배열(new[]) → delete[] 필요

이럴 때 사용자 정의 소멸자 (커스텀 딜리터, custom deleter)를 지정하면, 스마트 포인터가 스코프를 벗어날 때 원하는 방식으로 자원을 안전하게 해제할 수 있다.

#include <memory>
#include <cstdio>
#include <iostream>

int main()
{
    // FILE*를 unique_ptr로 관리, fclose를 커스텀 딜리터로 지정
    std::unique_ptr<FILE, decltype(&fclose)> file(fopen("test.txt", "r"), &fclose);

    if (file)
    {
        char buffer[100];
        if (fgets(buffer, sizeof(buffer), file.get()))
        {
            std::cout << buffer << std::endl;
        }
    } // 스코프 종료 시 fclose 자동 호출
}

std::unique_ptr의 사용자 정의 소멸자. fclose를 소멸자에 등록함

#include <memory>
#include <iostream>

struct Socket
{
    int fd;
};

void closeSocket(Socket* s)
{
    std::cout << "Closing socket " << s->fd << std::endl;
    delete s; // 실제로는 close(s->fd) 같은 함수 호출
}

int main()
{
    std::shared_ptr<Socket> sock(new Socket{42}, closeSocket);
}

std::shared_ptr의 사용자 정의 소멸자. socket을 close해주는 함수를 등록함

std::unique_ptr


std::unique_ptr 의 특징

std::unique_ptr은 이름처럼, 하나의 객체를 오직 하나의 스마트 포인터만 소유할 수 있도록 보장하는 포인터이다. 따라서 복사가 불가능하고 std::move를 통한 이동만 가능하다.

std::unique_ptr 생성 방법

std::unique_ptr을 생성할 때는 아래와 같이 std::make_unique<TYPE>()를 사용해주면 된다.

#include <memory>
#include <iostream>

void useUniquePtr()
{
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    std::cout << *ptr << std::endl;
}

C++14 이상에서 unique_ptr생성 방법

std::make_unique를 사용하면 코드가 직관적이고 간결해지지만 이 기능은 c++ 14부터 도입되었다. 스마트 포인터가 처음 도입 된 c++11에서는 아래과 같이 new 키워드를 이용해 생성해야 한다. (물론 14 이상 사용한다면 절대로 이렇게 사용할 이유는 없다.)

#include <memory>
#include <iostream>

int main()
{
    std::unique_ptr<int> ptr(new int(42));
    std::cout << *ptr << std::endl; // 42 출력
}

c++ 11 에서의 unique_ptr 생성법

std::unique_ptr의 이동

앞서 언급한 것처럼 std::unique_ptr복사가 불가능하다. 대신 이동(move semantics)으로 소유권을 넘길 수 있다. 코드로 확인하면 다음과 같다.

auto ptr1 = std::make_unique<int>(100);
// auto ptr2 = ptr1;   // 복사 불가
auto ptr2 = std::move(ptr1); // 소유권 이동 가능

std::unique_ptr의 쇼유권 이동

  • ptr1은 이제 아무것도 가리키지 않음
  • ptr2가 해당 자원의 소유권을 가짐

요약

  • std::unique_ptr는 하나의 객체만 소유할 수 있는 단독 소유 스마트 포인터이다.
  • 복사는 불가능하고, std::move를 통해서만 소유권 이동이 가능하다.
  • 생성 시에는 C++14 이상에서 std::make_unique 사용을 권장한다.

std::shared_ptr


std::shared_ptr의 특징

std::shared_ptr 하나의 객체를 여러 스마트 포인터가 공유할 수 있는 포인터이다. 소유한 포인터가 없으면 자동으로 객체를 해제하는데, 내부적으로 다음과 같은 방식을 사용한다:

  • std::shared_ptr는 하나의 객체를 여러 스마트 포인터가 공동 소유할 수 있도록 내부적으로 참조 카운트를 유지함
  • shared_ptr 복사되면 참조 카운트가 증가하고, 소멸되면 참조 카운트가 감소한다.
  • 모든 shared_ptr가 소멸해 참조 카운트가 0이 되면, 객체는 자동으로 해제된다.

std::shared_ptr의 생성 방법

std::shared_ptr는 c++11에서 도입될 때 부터 std::make_shared도 함께 도입되어서 다음과 같이 생성할 수 있다.

#include <memory>
#include <iostream>

void useSharedPtr()
{
    std::shared_ptr<int> p1 = std::make_shared<int>(42);
    std::shared_ptr<int> p2 = p1; // 참조 카운트 증가

    std::cout << *p1 << std::endl;              // 42 출력
    std::cout << "use_count: " << p1.use_count() << std::endl; // 2
} // p1, p2 모두 소멸되면 메모리 해제

std::make_shared로 생성

  • p1p2가 같은 객체를 가리킴
  • 참조 카운트가 2 → 두 포인터가 모두 소멸되면 객체 해제

물론 new 키워드를 통해서도 std::shared_ptr을 생성할 수 있지만, 예외 안전성 문제와 코드 가독성 때문에, C++11 이상에서는 make_shared를 권장한다.

std::shared_ptr의 순환 참조 문제

shared_ptr끼리 서로를 참조하면 참조 카운트가 0이 되지 않아 메모리가 해제되지 않는다. 이때는 std::weak_ptr를 사용해 해결해야 한다.

예를 들어서 아래 코드는 순환참조의 문제가 발생한다.

#include <memory>

struct Node
{
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev; // ❌ 순환 참조 발생
};

위 코드는 next와 prev를 서로 참조하기 때문에 순환참조 문제가 발생한다.

이를 해결하기 위해선 std::weak_ptr을 사용하면 된다. std::weak_ptr은 참조 카운트를 올리지 않기 때문에 순환 참조를 끊을 수 있다. 이는 다음 항목의 std::weak_ptr 을 참고하면 된다.

#include <memory>
#include <iostream>

struct Node
{
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev; // ✅ 순환 참조 방지
};

int main()
{
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();

    a->next = b;
    b->prev = a; // weak_ptr이라 참조 카운트 증가 안 함

    std::cout << "a use_count: " << a.use_count() << std::endl;
    std::cout << "b use_count: " << b.use_count() << std::endl;
}

순환참조 문제를 방지하기 위해서는 std::weak_ptr을 사용하면 된다.

std::weak_ptr


std::weak_ptr의 특징

std::weak_ptr객체를 소유하지 않고 약하게 참조하는 스마트 포인터이다.

  • shared_ptr가 관리하는 객체를 참조하지만, 참조 카운트를 증가시키지 않는다.
  • 따라서 객체의 생명주기에 영향을 주지 않고, 순환 참조(cyclic reference) 문제를 방지하는 데 사용된다.
  • 실제 사용 시에는 lock()을 호출해 shared_ptr로 승격시킨 뒤 접근한다.

std::weak_ptr의 사용 방법

#include <memory>
#include <iostream>

void useWeakPtr()
{
    std::shared_ptr<int> sp = std::make_shared<int>(42);
    std::weak_ptr<int> wp = sp; // 소유권 없음

    if (auto locked = wp.lock()) // weak_ptr → shared_ptr 승격
    {
        std::cout << *locked << std::endl; // 42 출력
    }
    else
    {
        std::cout << "expired" << std::endl;
    }
}
  • weak_ptr은 직접 * 연산으로 접근할 수 없다.
  • lock()으로 안전하게 shared_ptr를 얻어야만 객체에 접근할 수 있다.
  • 만약 이미 객체가 해제되었다면 lock()은 빈 shared_ptr를 반환한다.

std::weak_ptr과 순환 참조 해결

#include <memory>
#include <iostream>

struct Node
{
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev; // ✅ 순환 참조 방지
};

int main()
{
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();

    a->next = b;
    b->prev = a; // weak_ptr은 참조 카운트 증가 안 함

    std::cout << "a use_count: " << a.use_count() << std::endl;
    std::cout << "b use_count: " << b.use_count() << std::endl;
}

순환 참조 문제 해결

a use_count: 1
b use_count: 1

출력 결과

스마트 포인터의 인터페이스


스마트 포인터(std::unique_ptrstd::shared_ptr)는 단순한 포인터 대체가 아니라, “자원을 안전하게 관리하는 인터페이스”를 제공한다. 그 대표적인 예가 바로 reset()이다. reset() 은 스마트포인터가현재 소유 중인 객체를 해제하고, 내부 포인터를 nullptr 상태로 초기화하는 멤버 함수이다. 다만 각각의 스마트포인터별로 reset()의 동작이 조금씩 다르다.

std::unique_ptr::reset()

unique_ptr은 단독 소유이므로 reset()이 호출되면 현재 객체를 즉시 삭제하고 소유권을 포기한다.

#include <iostream>
#include <memory>

int main()
{
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    std::cout << *ptr << "\n"; // 42

    ptr.reset(); // 내부 delete 호출 + nullptr 상태

    if (!ptr)
    {
        std::cout << "포인터가 해제되었습니다.\n";
    }
}

std::unique_ptr의 reset

reset()은 새로운 포인터를 인자로 받을 수도 있다. 이 경우 새로운 포인터로 교체하게 된다.

ptr.reset(new int(100)); // 기존 객체 delete 후, 새 int(100) 소유

reset()할 때 새로운 포인터를 바로 할당

std::shared_ptr::reset()

반면에 shared_ptr의 reset()공은 유 참조 카운트(reference count)를 하나 줄이면서,현재 포인터가 마지막 소유자였다면 객체를 해제한다.

#include <iostream>
#include <memory>

int main()
{
    auto sp1 = std::make_shared<int>(10);
    auto sp2 = sp1; // 공유 소유 (count = 2)

    std::cout << "use_count: " << sp1.use_count() << "\n"; // 2

    sp1.reset(); // sp1만 해제, sp2는 여전히 객체 소유
    std::cout << "use_count after reset: " << sp2.use_count() << "\n"; // 1

    sp2.reset(); // 마지막 소유자 해제 → 객체 삭제
}

std::shared_ptr의 reset

std::weak_ptr::reset()

weak_ptr은 소유권이 없기 때문에, 단순히 “이 포인터가 참조 중인 대상”을 잊어버리기만 한다.

#include <iostream>
#include <memory>

int main()
{
    std::shared_ptr<int> sp = std::make_shared<int>(99);
    std::weak_ptr<int> wp = sp;

    std::cout << "use_count before: " << wp.use_count() << "\n"; // 1

    wp.reset(); // weak_ptr이 가리키던 대상과의 연결만 해제
    std::cout << "use_count after: " << wp.use_count() << "\n"; // 1 (shared_ptr 영향 없음)
}

그 외 자주 사용하는 인터페이스

함수적용 스마트 포인터설명
get()unique_ptr, shared_ptr, weak_ptr내부에서 관리 중인 raw pointer를 반환한다. (소유권은 이전되지 않음) 주로 C 언어로 작성된 API를 호출할 떄 사용한다.
reset()unique_ptr, shared_ptr, weak_ptr현재 관리 중인 포인터를 해제하고 nullptr로 만든다. unique_ptr은 즉시 delete, shared_ptr은 참조 카운트 감소, weak_ptr은 참조만 끊음.
release()unique_ptr내부 포인터의 소유권을 포기하고 raw pointer를 반환한다. 반환된 포인터는 사용자가 직접 delete해야 함. (shared_ptr, weak_ptr에는 없음)
use_count()shared_ptr, weak_ptrshared_ptr가 몇 개의 인스턴스에서 해당 객체를 공유하고 있는지 참조 카운트를 반환한다. (unique_ptr에는 없음)
unique()shared_ptr자신이 유일한 소유자인지(use_count() == 1) 여부를 반환한다.

요약

  • RAII는 객체의 생성과 소멸 주기에 자원 관리를 묶어 예외 상황에서도 안전하게 자원을 해제할 수 있도록 하는 모던 C++의 핵심 패러다임이다.
  • 이를 메모리 관리에 적용한 도구가 스마트 포인터이며, unique_ptr은 단독 소유, shared_ptr은 공유 소유, weak_ptr은 비소유 참조로 각각의 상황에 맞는 메모리 관리 방식을 제공한다.
  • 각각의 스마트포인터는 자원을 안전하게 관리하기 위한 인터페이스를 제공한다
  • 스마트 포인터는 동적 할당 대비 안전성과 가독성이 뛰어나며, 커스텀 딜리터를 통해 메모리뿐 아니라 파일, 소켓 등 다양한 자원 관리에도 활용할 수 있다.