C++/Concepts

std::unique_ptr 제대로 이해하기

Enchantée 2026. 6. 18. 19:09
728x90
반응형

C++에서 동적 객체를 다루다 보면 포인터를 피할 수 없습니다.

하지만 raw pointer를 직접 소유권 관리에 사용하면 delete 누락, 이중 해제, 예외 발생 시 자원 누수 같은 문제가 쉽게 생깁니다.

이때 가장 먼저 익혀야 할 도구가 std::unique_ptr입니다.

 

std::unique_ptr은 하나의 객체가 하나의 자원을 단독으로 소유한다는 의도를 코드로 표현하는 스마트 포인터입니다.

 

이번 글에서는 std::unique_ptr이 왜 필요한지, std::move와 어떤 관계가 있는지, 함수 인자로 넘길 때 어떤 기준을 잡으면 좋은지 코드 예제로 정리해보겠습니다.

 


1. unique_ptr은 무엇인가?

std::unique_ptr은 C++ 표준 라이브러리가 제공하는 스마트 포인터입니다.

핵심은 이름 그대로 unique, 즉 단독 소유권입니다.

하나의 동적 객체를 여러 unique_ptr이 동시에 소유할 수 없습니다.

그래서 unique_ptr은 복사를 허용하지 않고, 소유권을 넘길 때는 이동만 허용합니다.

구분 raw pointer std::unique_ptr
소유권 표현 코드만 보고 알기 어려움 단독 소유권이 명확함
자원 해제 delete를 직접 호출해야 함 스코프 종료 시 자동 해제
복사 포인터 값 복사 가능 복사 불가
소유권 이전 규칙을 사람이 지켜야 함 std::move로 명시
예외 안전성 직접 관리 필요 RAII로 누수 가능성을 줄임

unique_ptr은 단순히 delete를 대신 호출해주는 도구가 아닙니다.

이 포인터가 자원을 소유하는지, 다른 곳으로 넘기는지, 잠깐 빌려주는지만 봐도 코드의 의도를 더 쉽게 읽을 수 있게 해줍니다.

 


2. 왜 unique_ptr이 필요한가?

다음과 같은 코드를 생각해보겠습니다.

#include <iostream>

class User {
public:
    explicit User(int id) : id_(id) {
        std::cout << "User created\n";
    }

    ~User() {
        std::cout << "User destroyed\n";
    }

private:
    int id_;
};

void process(bool fail) {
    User* user = new User(1);

    if (fail) {
        return;  // delete 누락
    }

    delete user;
}

 

이 코드는 fail이 true이면 delete가 호출되지 않습니다.

실무 코드에서는 중간 return, 예외, 조건문, 여러 함수 호출이 계속 추가됩니다.

그때마다 모든 경로에서 delete가 호출되는지 사람이 직접 확인하는 방식은 위험합니다.

unique_ptr을 쓰면 객체 생명주기에 자원 해제를 묶을 수 있습니다.

#include <iostream>
#include <memory>

class User {
public:
    explicit User(int id) : id_(id) {
        std::cout << "User created\n";
    }

    ~User() {
        std::cout << "User destroyed\n";
    }

private:
    int id_;
};

void process(bool fail) {
    auto user = std::make_unique<User>(1);

    if (fail) {
        return;
    }

    std::cout << "process user\n";
}

int main() {
    process(true);
}

 

process(true)로 중간에 return되어도 user는 스코프를 벗어나면서 자동으로 해제됩니다.

이것이 RAII와 unique_ptr이 만나는 지점입니다.

자원을 얻은 객체가 자원 해제까지 책임지게 만들면, 예외나 조기 반환에도 훨씬 안전한 코드가 됩니다.

 


3. make_unique를 기본으로 사용하자

unique_ptr을 만들 때는 가능하면 std::make_unique를 사용하는 것이 좋습니다.

auto user = std::make_unique<User>(1);

 

직접 new를 쓰는 방식도 가능은 합니다.

std::unique_ptr<User> user(new User(1));

 

하지만 실무에서는 make_unique를 기본값으로 두는 편이 좋습니다.

  1. new 표현식을 코드에서 줄일 수 있다.
  2. 타입 이름을 반복하지 않아도 된다.
  3. 객체 생성과 unique_ptr 포장을 한 줄에서 안전하게 처리할 수 있다.
  4. 코드 리뷰에서 소유권 의도가 더 명확하게 보인다.

C++14부터 std::make_unique를 사용할 수 있고, C++17 코드라면 특별한 이유가 없는 한 make_unique를 먼저 선택하면 됩니다.

 


4. 소유권 이전은 std::move로 표현한다

unique_ptr은 복사할 수 없습니다.

그 이유는 단순합니다.

하나의 자원을 두 unique_ptr이 동시에 소유하면, 둘 다 소멸될 때 같은 객체를 두 번 delete하려는 문제가 생길 수 있기 때문입니다.

그래서 unique_ptr의 소유권을 넘기려면 std::move를 사용해야 합니다.

#include <iostream>
#include <memory>
#include <utility>

class Job {
public:
    explicit Job(int id) : id_(id) {}

    void run() const {
        std::cout << "run job " << id_ << '\n';
    }

private:
    int id_;
};

int main() {
    auto job = std::make_unique<Job>(7);
    auto owner = std::move(job);

    if (job == nullptr) {
        std::cout << "job no longer owns the object\n";
    }

    owner->run();
}

 

std::move(job)을 호출하면 job 자체가 객체를 이동시키는 것은 아닙니다.

job을 이동 가능한 값으로 취급하게 만들고, unique_ptr의 move constructor가 소유권을 owner로 넘깁니다.

이후 job은 보통 nullptr 상태가 됩니다.

실무에서는 move 이후의 unique_ptr을 다시 사용하기보다, nullptr 체크 또는 새 값 대입 정도로 제한해서 다루는 편이 안전합니다.

 


5. 소유권 흐름을 그림으로 보기

unique_ptr의 소유권 이전은 다음처럼 볼 수 있습니다.

- unique_ptr ownership transfer

처음 상태

job ───────────────> Job 객체
owner              null

std::move(job) 이후

job                null
owner ────────────> Job 객체

스코프 종료

owner 소멸자 호출
   |
   v
Job 객체 자동 delete

 

핵심은 포인터가 가리키는 객체가 복사되는 것이 아니라, 누가 그 객체를 책임지는지가 바뀐다는 점입니다.

그래서 unique_ptr을 보면 “이 객체의 최종 delete 책임이 어디에 있는가?”를 추적할 수 있습니다.

 


6. 함수 인자로 넘길 때 기준

unique_ptr을 함수 인자로 넘길 때 가장 헷갈리는 부분은 소유권을 넘기는지, 잠깐 빌려주는지입니다.

기준은 다음처럼 잡을 수 있습니다.

상황 권장 형태 의미
함수가 소유권을 가져감 std::unique_ptr<T> 호출자는 std::move로 넘김
함수가 객체를 읽기만 함 const T& 또는 const T* 소유권은 호출자에게 남음
함수가 객체를 수정하지만 소유하지 않음 T& 또는 T* 빌려 쓰는 관계
함수가 새 객체를 만들어 반환함 std::unique_ptr<T> 반환 호출자가 소유권을 받음

이 기준을 코드로 보면 더 명확합니다.

#include <iostream>
#include <memory>
#include <string>
#include <utility>

class Document {
public:
    explicit Document(std::string title) : title_(std::move(title)) {}

    const std::string& title() const {
        return title_;
    }

    void rename(std::string title) {
        title_ = std::move(title);
    }

private:
    std::string title_;
};

std::unique_ptr<Document> createDocument() {
    return std::make_unique<Document>("draft");
}

void printTitle(const Document& document) {
    std::cout << document.title() << '\n';
}

void rename(Document& document) {
    document.rename("published");
}

void store(std::unique_ptr<Document> document) {
    std::cout << "store: " << document->title() << '\n';
}

int main() {
    auto document = createDocument();

    printTitle(*document);
    rename(*document);
    printTitle(*document);

    store(std::move(document));

    if (document == nullptr) {
        std::cout << "ownership moved\n";
    }
}

 

printTitle과 rename은 객체를 빌려 쓰기만 합니다.

그래서 unique_ptr 자체를 넘기지 않고, *document로 참조를 넘깁니다.

반면 store는 document의 소유권을 가져갑니다.

호출하는 쪽에서 std::move(document)를 사용해야 하므로, 코드만 봐도 “여기서 소유권이 끝난다”는 사실이 드러납니다.

 


7. 컨테이너 안에서 unique_ptr 사용하기

다형성을 사용하는 코드에서는 unique_ptr을 컨테이너에 담는 경우가 많습니다.

예를 들어 여러 종류의 작업을 공통 인터페이스로 관리할 수 있습니다.

#include <iostream>
#include <memory>
#include <vector>

class Task {
public:
    virtual ~Task() = default;
    virtual void run() const = 0;
};

class EmailTask : public Task {
public:
    void run() const override {
        std::cout << "send email\n";
    }
};

class ReportTask : public Task {
public:
    void run() const override {
        std::cout << "make report\n";
    }
};

int main() {
    std::vector<std::unique_ptr<Task>> tasks;

    tasks.push_back(std::make_unique<EmailTask>());
    tasks.push_back(std::make_unique<ReportTask>());

    for (const auto& task : tasks) {
        task->run();
    }
}

 

여기서 vector는 Task 객체를 직접 담지 않습니다.

대신 각각의 파생 객체를 unique_ptr로 소유합니다.

이 구조는 다형성을 유지하면서도 객체 해제를 자동으로 처리할 수 있습니다.

중요한 점은 기반 클래스 Task의 소멸자가 virtual이라는 것입니다.

기반 클래스 포인터로 파생 객체를 삭제해야 하므로, 다형적 기반 클래스에는 virtual destructor가 필요합니다.

 


8. custom deleter로 파일도 관리할 수 있다

unique_ptr은 new로 만든 객체만 관리하는 도구가 아닙니다.

custom deleter를 지정하면 FILE*처럼 fclose가 필요한 자원도 관리할 수 있습니다.

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

struct FileCloser {
    void operator()(std::FILE* file) const {
        if (file != nullptr) {
            std::fclose(file);
        }
    }
};

int main() {
    std::unique_ptr<std::FILE, FileCloser> file(std::fopen("sample.log", "w"));

    if (!file) {
        std::cerr << "failed to open file\n";
        return 1;
    }

    std::fputs("hello\n", file.get());
}

 

file이 스코프를 벗어나면 FileCloser가 호출되고, 내부의 FILE*에 대해 fclose가 실행됩니다.

이 방식은 직접 fclose를 여러 분기에 넣는 것보다 안전합니다.

다만 표준 라이브러리에 이미 더 좋은 RAII 타입이 있으면 그것을 우선 사용하는 편이 좋습니다.

예를 들어 파일 입출력은 std::ofstream, std::ifstream을 사용할 수 있다면 그쪽이 더 단순합니다.

 


9. 자주 하는 오해

9-1. unique_ptr을 쓰면 포인터를 전혀 몰라도 된다

unique_ptr은 포인터 관리를 쉽게 해주지만, 포인터의 의미를 없애지는 않습니다.

nullptr 가능성, 소유권 이전, 참조와 포인터의 차이, 객체 생명주기는 여전히 이해해야 합니다.


9-2. 모든 포인터를 unique_ptr로 바꾸면 된다

unique_ptr은 소유권이 있는 포인터에 적합합니다.

잠깐 빌려 쓰는 관계라면 T&, const T&, T* 같은 형태가 더 명확할 수 있습니다.

소유하지 않는 포인터까지 무조건 unique_ptr로 바꾸면 오히려 설계 의도가 흐려집니다.


9-3. unique_ptr은 shared_ptr보다 항상 좋다

대부분의 경우 단독 소유권이 명확하면 unique_ptr이 좋은 기본 선택입니다.

하지만 여러 객체가 같은 자원을 공유해야 하고, 소유권 수명이 분명히 공유 구조라면 shared_ptr이 필요할 수 있습니다.

중요한 것은 어떤 스마트 포인터가 더 최신인지가 아니라, 소유권 모델이 무엇인지입니다.

 


10. 실무에서는 어떻게 봐야 할까?

실무에서 unique_ptr은 단순히 메모리 누수를 막는 문법이 아닙니다.

코드에 소유권 규칙을 드러내는 설계 도구입니다.

다음 기준을 기억하면 좋습니다.

  1. 동적 객체를 단독 소유한다면 unique_ptr을 기본으로 고려한다.
  2. 객체를 만들 때는 가능하면 make_unique를 사용한다.
  3. 소유권을 넘길 때만 std::move를 사용한다.
  4. 소유하지 않고 쓰기만 한다면 참조나 raw pointer로 빌려준다.
  5. 다형성 컨테이너에서는 기반 클래스의 virtual destructor를 확인한다.
  6. custom deleter는 외부 자원을 RAII로 감싸야 할 때 사용한다.

특히 코드 리뷰에서는 unique_ptr이 보이면 다음 질문을 해보는 것이 좋습니다.

이 객체의 소유권은 어디서 시작되고, 어디에서 끝나는가?

이 질문에 답할 수 있으면 unique_ptr을 꽤 잘 이해하고 있는 것입니다.

 


11. 정리

std::unique_ptr은 C++에서 단독 소유권을 표현하는 가장 기본적인 스마트 포인터입니다.

핵심만 정리하면 다음과 같습니다.

  • unique_ptr은 하나의 자원을 하나의 포인터가 소유하게 만든다.
  • 복사는 불가능하고, 소유권 이전은 std::move로 명시한다.
  • 객체 생성에는 가능하면 std::make_unique를 사용한다.
  • 함수가 소유권을 가져갈 때만 unique_ptr을 값으로 넘긴다.
  • 소유하지 않고 사용만 한다면 참조나 raw pointer가 더 적절할 수 있다.
  • unique_ptr은 RAII를 통해 예외와 조기 반환 상황에서도 자원 누수를 줄여준다.

결국 unique_ptr을 잘 쓴다는 것은 스마트 포인터 문법을 외우는 것이 아니라, 소유권을 코드에 명확히 드러내는 것입니다.

이 습관이 잡히면 C++ 코드에서 메모리와 자원 생명주기를 훨씬 안전하게 설계할 수 있습니다.

 


728x90
반응형