CodeGym /행동 /C++ SELF /std::unique_ptr의 custom deleter: handle, FILE*

std::unique_ptr의 custom deleter: handle, FILE*

C++ SELF
레벨 43, 레슨 2
사용 가능

1. custom deleter가 필요한 이유

처음에는 세상이 두 부분으로 나뉜다고 생각하기 쉽습니다: “메모리가 있다” 또는 “메모리가 없다”. 그래서 해제는 보편적이라고 여겨집니다: 포인터가 있으면 delete를 써야 한다고요. 하지만 실제로는 프로그램이 다루는 많은 자원이 “어떤 값처럼 보이지만” 특별한 함수로 해제된다는 것을 곧 알게 됩니다: 파일은 닫아야 하고, 소켓은 닫아야 하며, 운영체제 핸들은 해제해야 합니다.

이를 흔히 handle(“리소스의 손잡이”)라고 부릅니다: 리소스 자체가 아니라 그 리소스를 조작하기 위해 사용하는 객체/포인터/숫자입니다.

가장 이해하기 쉬운 교육용 예는 C의 FILE*입니다. 이것은 포인터이지만 항상 new로 할당된 메모리를 의미하지 않습니다. 이것은 “열려 있는 파일의 손잡이”입니다. fopen으로 열고 fclose로 닫습니다. 그리고 파일을 delete로 닫으려 하면, 마치 TV 리모컨으로 컴퓨터를 끄려는 것과 같아서 버튼은 있지만 의미가 없고(그리고 결과가 좋지 않을 수 있습니다).

여기서 핵심 아이디어가 나옵니다: std::unique_ptr는 단순히 “스마트 delete”가 아니라 범용 RAII 래퍼입니다. 기본적으로는 delete를 호출하지만, 우리는 그것이 임의의 해제 함수를 호출하도록 가르칠 수 있습니다.

deleter란 무엇이고 unique_ptr는 어떻게 해제 방법을 아는가

Deleter는 리소스를 “올바르게 해제”할 수 있는 객체(또는 함수)입니다. 일반적인 std::unique_ptr<int>의 기본 deleter는 사실상 “delete를 호출하라”는 동작과 같습니다.

하지만 리소스가 다르게 해제되어야 하면, 우리는 unique_ptr에게 말할 수 있습니다: “네가 소멸되거나 reset()될 때 delete를 하지 말고 이 함수를 호출해라.”

문법은 다음과 같습니다:

std::unique_ptr<T, Deleter> p;

즉, unique_ptr에 두 번째 템플릿 매개변수—삭제자 타입—가 생깁니다. 중요한 점: std::unique_ptr<FILE, FileCloser>와 std::unique_ptr<FILE, AnotherCloser>는 서로 다른 타입이며, 서로 대입할 수 없습니다.

그래서 실제 코드에서는 보통 복잡한 이름을 프로젝트 전반에 흩뿌리지 않기 위해 타입 별칭(using)을 만듭니다.

2. FILE*를 RAII로: 수동 fclose 없이

미니 예제: 펑터 deleter로 FILE* + fclose

이제 실용적인 예를 만들어 보겠습니다: 함수에서 일찍 벗어나더라도 파일이 자동으로 닫힙니다. 이것이 바로 RAII입니다. 단, new/delete가 아니라 fopen/fclose를 위한 것입니다.

작은 deleter부터 시작합시다. 가장 이해하기 쉬운 스타일은 struct에 operator()를 정의하는 것(이를 펑터라고 부릅니다)입니다:

#include <cstdio>

struct FileCloser {
    void operator()(std::FILE* f) const {
        if (f) {
            std::fclose(f);
        }
    }
};

여기서 주의할 점: deleter는 nullptr을 문제없이 받아들입니다. 이는 좋은 규율로, unique_ptr는 비어 있을 수 있고 “아무것도 닫지 않는 것”이 정상적인 상황입니다.

이제 편한 타입 별칭을 선언해봅시다:

#include <memory>
#include <cstdio>

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

그리고 파일을 소유권과 함께 즉시 열어봅니다:

#include <cstdio>
#include <memory>

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

int main() {
    FilePtr f(std::fopen("log.txt", "w"));
    if (!f) {
        return 1;
    }
} // 여기서 FileCloser를 통해 자동으로 fclose가 호출됩니다

여기서 느껴야 할 포인트: 우리는 delete를 쓰지 않았지만, 일반적인 unique_ptr와 동일한 편의를 얻었다는 점입니다. 리소스는 scope를 벗어날 때 자동으로 해제됩니다.

애플리케이션에 통합하기: 수동 fclose 없는 FILE* 로거

단순한 예제로 끝나지 않게, 교육용 콘솔 프로젝트(가령 TodoLite)를 조금 이어가 보겠습니다. “로그를 파일에 쓰기” 기능을 추가하되, 코드 곳곳에 수동 fclose가 나오지 않도록 합니다.

애플리케이션 컨텍스트에 로그 파일의 “손잡이”를 담아봅시다:

#include <cstdio>
#include <memory>
#include <string>

struct FileCloser {
    void operator()(std::FILE* f) const {
        if (f) std::fclose(f);
    }
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

struct AppContext {
    FilePtr log;
};

이제 로그 쓰기 함수입니다. 주의할 점: 이 함수는 파일을 소유하지 않고 단지 사용만 합니다. 따라서 컨텍스트를 참조로 받고 ctx.log.get()로 접근합니다.

#include <cstdio>
#include <string>

void write_log(AppContext& ctx, const std::string& line) {
    if (!ctx.log) return;
    std::fputs(line.c_str(), ctx.log.get());
    std::fputs("\n", ctx.log.get());
}

컨텍스트 초기화:

#include <cstdio>

AppContext make_context() {
    AppContext ctx;
    ctx.log = FilePtr(std::fopen("todolite.log", "a"));
    return ctx;
}

파일을 “닫는 것을 잊어버려도” main 종료 시(또는 현재 블록을 벗어날 때) unique_ptr가 deleter를 호출하여 파일이 올바르게 닫힙니다.

Deleter를 함수 포인터로: &std::fclose

때로는 struct FileCloser를 전혀 쓰지 않고 싶을 때가 있습니다. FILE*의 경우 실제로 가능하며, 이미 존재하는 해제 함수인 std::fclose를 deleter로 사용할 수 있습니다. 즉, 함수 포인터를 deleter로 저장할 수 있습니다.

#include <cstdio>
#include <memory>

int main() {
    using FilePtr = std::unique_ptr<std::FILE, decltype(&std::fclose)>;

    FilePtr f(std::fopen("log.txt", "w"), &std::fclose);
    if (!f) return 1;

    std::fputs("Hello!\n", f.get());
}

여기에는 두 가지 미묘한 점이 있습니다.

첫째: deleter는 생성자의 두 번째 인수로 전달됩니다. 만약 &std::fclose를 빼먹으면, 컴파일이 되지 않습니다. unique_ptr가 어떤 deleter를 사용해야 할지 “추측”할 수 없기 때문입니다.

둘째: FilePtr의 타입이 템플릿스럽게 보입니다. 학습용 프로젝트에서는 괜찮지만, 큰 코드베이스에서는 보통 struct FileCloser와 using을 함께 써서 코드를 읽고 유지보수하기 쉽게 합니다.

C-API와 작업할 때의 get()과 release()

실무에서는 거의 항상 다음과 같은 상황을 만납니다: “내가 RAII 소유자를 가지고 있는데 라이브러리 함수는 원시 T*를 받는다.” 이는 정상입니다: 대부분의 C-API는 unique_ptr를 모릅니다.

해결책은 간단합니다: get()을 전달하라는 것이고, 소유권이 이전되지 않는다는 점을 명심하십시오.

미니 예제: 파일에 문자열을 출력합니다.

#include <cstdio>

void print_line(std::FILE* f) {
    if (!f) return;
    std::fputs("line\n", f);
}

int main() {
    FilePtr f(std::fopen("a.txt", "w"));
    print_line(f.get());
} // fclose가 자동으로 호출됩니다

만약 “get()는 소유권을 넘기지 않는다”는 규칙을 지킨다면, 이로 인해 발생하는 두 가지 큰 문제—이중 해제와 use-after-free—를 거의 자동으로 피할 수 있습니다.

반면 release()는 다른 이야기입니다: 이 함수는 unique_ptr를 비우고 원시 포인터를 반환합니다. 하지만 custom deleter가 있을 경우 추가적인 함정이 생깁니다: 리소스는 delete가 아니라 deleter에 맞춘 방법으로 닫아야 합니다.

#include <cstdio>

int main() {
    FilePtr f(std::fopen("a.txt", "w"));
    std::FILE* raw = f.release();  // 이제 f는 더 이상 소유하지 않음

    if (raw) {
        std::fclose(raw);          // 수동으로 닫음 (delete가 아님!)
    }
}

이 코드는 동작하지만 다시 수동 자원 관리의 세계로 돌아갑니다. 따라서 release()를 사용하는 가장 안전한 스타일은 “즉시 다음 소유자에게 자원을 전달”하는 것입니다:

#include <cstdio>

int main() {
    FilePtr a(std::fopen("a.txt", "w"));
    std::FILE* raw = a.release();

    FilePtr b(raw);        // 이제 b가 소유자
} // b가 파일을 닫고, a는 이미 빈 상태

release()를 본다면, 대개 스스로에게 물어야 합니다: “이제 누가 소유자이고 누가 해제 책임을 지는가?”

unique_ptr 아래에서 FILE*의 생명주기 도식

시각화가 도움이 될 때가 있습니다. 간단한 흐름도: “리소스를 생성 → 사용 → 자동으로 닫음”입니다.

flowchart TD
    A["std::fopen(...) -> FILE*"] --> B["FilePtr(file) takes ownership"]
    B --> C["Work: pass f.get() to fputs/fprintf"]
    C --> D["Exit scope / reset()"]
    D --> E["FileCloser::operator() -> std::fclose(file)"]

다이어그램의 요지는 하나입니다: 코드에서 수동으로 닫는 단계가 사라지므로 “함수를 일찍 빠져나오고 파일 닫기를 잊음”과 같은 오류 범주가 사라집니다.

3. unique_ptr의 메커닉과 단순한 설계 규칙

언제 unique_ptr가 deleter를 호출하는가

메커닉을 명확히 말해두면 유용합니다. 많은 실수는 “여기가 해제되는 줄 알았는데 저기서 해제된다”는 오해에서 발생합니다.

unique_ptr는 표준 경우에 delete를 호출할 때와 동일한 상황에서 deleter를 호출합니다:

unique_ptr의 사건 리소스에 무슨 일이 일어나는가
unique_ptr가 소멸된다(스코프를 벗어남) deleter가 호출된다
reset()가 인수 없이 호출된다 옛 리소스에 대해 deleter가 호출되고, 포인터는 nullptr이 된다
reset(new_ptr) 옛 리소스에 대해 deleter가 호출된 후 새 리소스가 저장된다
이동 대입(p = std::move(q)) p의 옛 리소스에 deleter가 호출된 다음, p가 q의 리소스를 가져간다
release()
deleter는 호출되지 않는다; 책임은 외부로 넘어간다

이것이 바로 release()가 custom deleter 상황에서도 “위험한 버튼”인 이유입니다: 리소스를 RAII 밖으로 꺼내면, 이제 그 리소스를 올바른 방식으로 직접 닫을 책임이 프로그래머에게 있습니다.

설계를 복잡하게 만들지 않는 법: 세 가지 실용 규칙

custom deleter를 “템플릿 마법을 위한 마법”으로 바꾸기는 매우 쉽습니다. 아래 세 가지 원칙이 도움이 됩니다.

첫 번째 원칙: 리소스가 프로젝트에서 한 번 이상 등장한다면, 곳곳에 unique_ptr<..., decltype(lambda)>를 두지 마십시오. 대신 struct Deleter와 using Alias = unique_ptr<...>를 만드세요. 이렇게 하면 복잡한 타입이 사람이 읽을 수 있는 이름으로 바뀌고, 독자가 decltype을 해석하느라 고생하지 않아도 됩니다.

두 번째 원칙: deleter는 단순해야 합니다. 그의 임무는 하나뿐: 리소스를 해제하는 것. Deleter에 로깅, 재시도, “닫지 못하면 다시 열기” 같은 로직을 넣지 마세요. 복잡한 정책이 필요하면, 리소스 소유자를 인수로 받는 별도 함수를 만들어 명시적으로 처리하세요.

세 번째 원칙: 소유권을 “누가 다음에 가질지”에 대한 분명한 답이 없다면 release()를 쓰지 마세요. 특히 단순히 T*를 얻기 위해 release()를 사용하지 마세요. 단순히 조회하려면 get()를 쓰면 됩니다.

핸들 리소스는 반드시 포인터일 필요 없음

이 아이디어를 확실히 하기 위해 custom deleter가 “파일만”의 이야기가 아님을 보겠습니다. 이 방식은 어떤 리소스에도 적용됩니다.

가령 외부 C 라이브러리가 어떤 리소스를 생성하고 포인터를 반환한다고 합시다:

struct Connection; // 타입은 라이브러리 내부에 숨겨져 있음

Connection* connect_create();
void connect_close(Connection* c);

귀하의 코드에서는 FILE*와 같은 방식으로 deleter를 작성하고 RAII 소유자를 얻으면 됩니다.

#include <memory>

struct ConnectionCloser {
    void operator()(Connection* c) const {
        if (c) connect_close(c);
    }
};

using ConnPtr = std::unique_ptr<Connection, ConnectionCloser>;

이제 로직은 “기본적으로 올바르게” 됩니다: 만들고, 래핑하고, 필요할 때 소유권을 전달하거나 단순 사용을 위해 c.get()를 전달하면 됩니다.

심지어 실제 라이브러리에서 리소스가 포인터가 아니라 숫자(handle가 int인 경우 등)라도 원칙은 같습니다. 오늘은 그런 변형까지 깊게 다루지는 않지만 핵심 메시지는 동일합니다: unique_ptr + deleter는 코드에게 “청소 규칙은 이렇다, 자동으로 수행하라”고 말하는 수단입니다.

4. custom deleter 사용 시 흔한 실수

실수 #1: delete로 해제하면 안 되는 리소스에 대해 기본 std::unique_ptr<T>를 사용하는 것.
가장 흔한 논리적 실수입니다: “포인터가 있으니 기본 unique_ptr로 충분하다”고 생각하는 것. 하지만 리소스가 close_xxx, fclose, DestroyHandle 등으로 닫혀야 한다면, unique_ptr는 올바른 deleter를 알아야 합니다. 그렇지 않으면 정의되지 않은 동작(최선의 경우는 크래시, 최악의 경우는 은밀한 데이터 손상)이 발생합니다.

실수 #2: unique_ptr가 소유하고 있는 동안 리소스를 수동으로 닫는 것.
예: std::fclose(f.get())를 살아있는 FilePtr f에 대해 호출하는 경우. 겉으로는 무해해 보이지만 실제로는 리소스를 “너무 일찍” 닫은 것이고, 나중에 unique_ptr가 소멸될 때 다시 닫으려 시도하여 이중 해제가 발생합니다. 이 모델에서는 delete 대신 fclose로 이중 해제가 일어나는 셈입니다.

실수 #3: 원시 관찰자(raw observation)를 너무 오래 유지하는 것.
예를 들어 std::FILE* raw = f.get()를 한 뒤 어딘가에서 f.reset()를 호출하면, raw는 댕글링 포인터가 됩니다. 교육용 예제에서는 잘 드러나지 않지만 실제 프로그램에서는 이런 관찰자들이 소유자를 생존기간보다 오래 살아남아 밤중에 발생하는 버그를 만들어냅니다.

실수 #4: 계획 없이 release()를 사용하는 것.
release()는 합법적인 도구지만 RAII를 끄고 책임을 프로그래머에게 돌립니다. 초보자는 종종 단순히 포인터를 얻기 위해 release()를 사용하지만, 이럴 경우에는 get()가 더 적절합니다. 만약 release()를 사용했다면 “누가 다음 소유자인가, 그리고 어떤 함수로 해제할 것인가?”에 대한 매우 구체적인 답이 있어야 합니다.

실수 #5: deleter를 미니 프레임워크 수준으로 복잡하게 만드는 것.
Deleter는 비즈니스 로직을 담을 곳이 아닙니다. 내부에 조건문과 부수효과가 많아질수록 스코프를 벗어날 때 어떤 일이 벌어질지 이해하기 어려워집니다(스코프에서 벗어나는 일은 코드의 아무 곳에서나 일어날 수 있습니다). 좋은 deleter는 보통 3–5줄 안에 들어가고 정확히 한 가지 해제 작업만 수행합니다.

1
과제
C++ SELF, 레벨 43, 레슨 2
잠금
임시 금고
임시 금고
1
과제
C++ SELF, 레벨 43, 레슨 2
잠금
체크 리스트
체크 리스트
1
과제
C++ SELF, 레벨 43, 레슨 2
잠금
C API를 위한 라인 처리
C API를 위한 라인 처리
1
과제
C++ SELF, 레벨 43, 레슨 2
잠금
닫힌 횟수 카운터
닫힌 횟수 카운터
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION