Devin.KR

직접 만드는 RAII 타입 - 파일 핸들과 잠금 가드

개발자KR 조회 10

이 장에서 배우는 것

주문 처리 엔진이 체결 결과를 파일에 기록하기 시작하면 관리해야 할 대상이 늘어난다. 파일을 여는 일보다 중요한 것은 열린 파일의 책임을 누구에게 맡기느냐다. 검증 실패로 함수를 빠져나가거나 컨테이너가 저장 공간을 옮기더라도 자원의 소유자는 분명해야 한다. 앞 장에서 살펴본 이동 표현을 이번에는 실제 소유권 이전에 사용한다.

기본서에서 배운 RAII는 자원의 사용 기간을 객체의 수명에 연결하는 방법이다. 이 장에서는 그 원칙을 직접 만든 타입의 계약으로 구체화한다. 파일 래퍼는 소유권을 옮길 수 있게 만들고, 잠금 가드는 자신이 만들어진 범위에서만 사용하게 만든다. 두 타입의 차이를 통해 모든 자원에 똑같은 특수 멤버 함수를 적용할 필요는 없다는 점도 살펴본다.

  • 획득한 자원과 비어 있는 상태를 구분하고, 해제 책임이 하나만 존재하도록 설계한다.
  • 파일 래퍼의 복사를 금지하고 이동을 허용하며, 이동한 원본의 상태를 설명한다.
  • 예외를 내보내지 않는 이동과 vector 재할당의 관계를 이해한다.
  • unique_ptr 사용자 삭제자와 범위 종료 가드로 반복되는 정리 코드를 줄인다.
  • 소멸자의 정리 책임과 호출자가 확인해야 할 입출력 오류를 분리한다.

문제 상황

주문 접수 함수가 파일을 열고, 주문을 임시 목록에 추가하고, 검증한 뒤 체결 로그를 쓴다고 하자. 처음에는 함수 끝에 fclose를 한 번 호출하면 충분해 보인다. 그러나 검증 실패로 예외를 던지는 경로가 생기면 파일을 닫는 코드를 그 앞에도 넣어야 한다. 목록 추가가 메모리 부족으로 실패하는 경로까지 고려하면 정상 경로만 읽어서는 정리 여부를 확인하기 어렵다.

여기에 잠금이 들어오면 실수의 종류가 늘어난다. 파일은 닫았지만 잠금을 풀지 않거나, 잠금은 풀었지만 검증에 실패한 주문이 목록에 남을 수 있다. 자원의 정리와 업무 상태의 복구는 서로 다른 책임이지만, 둘 다 함수를 벗어나는 모든 경로를 따라다닌다는 공통점이 있다.

std::FILE* file = std::fopen("orders.log", "w");
if (file == nullptr) {
    throw std::runtime_error("open failed");
}

mutex.lock();
validate(order);  // 예외가 나면 아래 두 호출에 도달하지 않는다.
mutex.unlock();
std::fclose(file);

이 코드를 고치려고 함수 전체에 try와 catch를 붙이는 방법도 있다. 하지만 정리 대상이 늘어날 때마다 성공 여부를 나타내는 변수를 더하고, 해제 순서를 맞춰야 한다. 더 나은 설계는 파일, 잠금, 임시 변경 각각에 수명을 가진 객체를 대응시키는 것이다. 그러면 함수는 주문 처리 순서를 표현하고, 객체는 자신에게 맡겨진 정리 책임을 수행한다.

이번 예제는 수량이 양수인 주문을 곧바로 체결한 것으로 취급한다. 가격 결정이나 상대 주문 탐색은 넣지 않는다. 살펴볼 문제는 주문 규칙의 복잡성이 아니라 파일 소유권, 잠금 범위, 실패 시 목록 복구가 함께 있을 때의 자원 관리다.

자원 래퍼의 계약부터 정한다

유효 상태와 빈 상태

자원 래퍼(resource wrapper)는 자원에 접근하는 연산과 해제 책임을 하나의 타입으로 묶는다. 구현에 앞서 반드시 정할 것은 상태다. 이 장의 File은 유효한 FILE 포인터를 소유하거나 아무 파일도 소유하지 않는다. 생성에 성공하면 유효 상태가 되고, 이동의 원본이나 명시적으로 닫힌 객체는 빈 상태가 된다.

빈 상태도 정상적인 객체 상태다. 소멸할 수 있고, 다른 파일을 이동 대입받을 수도 있다. 다만 기록하거나 닫기를 요청하면 논리 오류를 보고하도록 정한다. 이런 규칙이 있어야 이동 이후의 사용을 우연한 동작에 맡기지 않는다. 이동한 객체를 다시 사용하는 것이 언어 차원에서 모두 금지되는 것은 아니다. 해당 타입이 허용하는 연산이 무엇인지가 중요하다.

File의 각 연산이 소유권과 실패에 미치는 영향
연산성공 후 상태실패 처리
생성새 파일을 소유한다.열기 실패를 예외로 보고한다.
이동 생성대상이 소유하고 원본은 빈다.예외를 내보내지 않는다.
이동 대입대상의 기존 파일을 정리하고 소유권을 받는다.기존 파일의 닫기 오류는 보고하지 않는다.
명시적 닫기빈 상태가 된다.닫기 실패를 예외로 보고한다.
소멸소유 중인 파일의 해제를 시도한다.예외를 내보내지 않는다.

사용자 삭제자로 해제 방법을 바꾼다

FILE 포인터는 delete로 해제하는 대상이 아니다. fclose와 짝을 이룬다. unique_ptr의 사용자 삭제자(custom deleter)를 지정하면 단독 소유권 관리는 표준 타입에 맡기면서 실제 해제 동작만 바꿀 수 있다. 삭제자는 FILE 포인터를 받아 fclose를 호출하는 작은 함수 객체로 만든다.

File이 unique_ptr을 멤버로 가지면 직접 포인터를 교환하는 코드를 쓸 필요가 없다. 복사는 명시적으로 삭제하고 이동은 기본 구현으로 선언한다. 빈 unique_ptr은 삭제자를 호출하지 않으므로 이동한 원본이 소멸해도 파일이 다시 닫히지 않는다. 기본 이동 대입은 대상이 이미 가진 파일도 먼저 정리한다.

이 방식은 해제 책임을 빠짐없이 수행하게 하지만, 해제 성공까지 보장하지는 않는다. fclose는 버퍼에 남은 데이터를 내보내는 과정에서 실패할 수 있다. 예외를 처리하며 스택을 정리하는 중에 소멸자까지 예외를 내보내면 프로그램이 종료될 수 있으므로, 소멸자 경로에서는 오류를 던지지 않는다. 업무상 기록 완료 확인이 필요하면 별도의 close_checked를 호출한다.

close_checked는 먼저 unique_ptr에서 포인터를 분리한 다음 fclose를 호출한다. 닫기가 실패해도 그 스트림을 다시 사용하거나 다시 닫아서는 안 되기 때문이다. 실패 후에도 래퍼는 빈 상태를 유지한다. 반대로 fflush 실패만으로 소유권을 버리지는 않는다. 스트림을 닫는 호출을 아직 하지 않았으므로 이후 소멸자가 정리할 책임이 남아 있다.

파일 소유권을 이동하면 새 래퍼만 해제 책임을 가지며 원본은 빈 상태가 된다

기록 오류와 저장 완료는 구분한다

완성 코드에서는 주문 한 건을 기록한 뒤 fflush의 결과를 확인한다. 이로써 C 스트림 버퍼를 내보내는 단계에서 발견한 오류를 호출자에게 보고할 수 있다. 그러나 운영체제에 전달된 데이터가 저장 장치에 영속적으로 기록되었다는 뜻은 아니다. 이 장의 성공은 스트림 연산이 성공했다는 범위로 한정한다.

또한 파일 출력은 메모리의 목록처럼 쉽게 되돌릴 수 없다. 기록 도중 실패하면 일부 바이트가 이미 파일에 쓰였을 수 있다. 뒤에서 만드는 가드는 목록을 복구하지만 파일 내용을 원상태로 돌려놓지는 않는다. 자원 해제 보장과 업무 처리 전체의 원자성을 구분해야 예외 안전성의 범위를 정확히 설명할 수 있다.

이동과 컨테이너 재할당의 관계

vector는 용량이 부족하거나 더 큰 용량을 예약하면 새 저장 공간을 마련하고 기존 원소를 그곳에 생성한다. 이때 File 객체의 주소는 바뀌지만, File이 소유한 스트림 자체를 새로 열 필요는 없다. 이동은 스트림 포인터와 해제 책임을 새 원소로 넘긴다.

이동 생성자가 중간에 예외를 던질 수 있으면 복구가 어려워진다. 앞의 원소 몇 개는 이미 원본에서 소유권을 가져갔는데 뒤의 원소를 옮기다가 실패할 수 있기 때문이다. 복사가 가능한 타입이라면 vector는 재할당 때 복사를 선택해 기존 원소를 보존할 수 있다. 이동이 예외를 내보내지 않는다고 선언되어 있으면 이러한 위험 없이 이동을 사용할 수 있다.

복사가 금지되어 있고 이동이 예외를 던질 수 있는 타입도 vector에 사용할 수 있는 경우가 있다. 다만 reserve에서 그 이동 생성자가 예외를 던지면 기존 상태 보장에 예외가 생기며, 효과가 명세상 미지정일 수 있다. 따라서 “이동 가능”과 “안전하게 재배치할 수 있음”을 같은 말로 취급하지 않는다.

noexcept는 속도 향상을 위한 장식이 아니다. 함수 밖으로 예외가 나가지 않는다는 계약이다. 계약을 어기면 예외가 호출자에게 전달되는 대신 std::terminate가 호출된다. 이동 중에 메모리를 할당하거나 로그 문자열을 만드는 코드를 넣고 무조건 noexcept를 붙이는 것은 잘못된 해결이다.

이 장의 File은 기본 이동이 unique_ptr의 이동만 수행한다. 삭제자는 상태가 없고 예외를 내보내지 않는다. 따라서 이동 생성과 이동 대입을 noexcept로 선언할 수 있다. 코드에는 타입 특성을 확인하는 static_assert도 두어, 이후 멤버를 변경할 때 계약을 점검할 위치를 남긴다. 이 검사는 선언된 특성을 확인하며, 함수 본문의 모든 실행 경로가 실제로 예외를 던지지 않는다는 증명은 아니다.

완성 코드에서는 현재 용량보다 하나 큰 값을 reserve에 전달한다. 단순히 reserve(2)를 호출하면 현재 용량이 이미 2 이상인 구현에서는 재할당이 일어나지 않을 수 있기 때문이다. 재할당이 끝난 뒤 첫 원소를 엔진으로 이동한다. 이전에 얻어 둔 원소 참조는 사용하지 않으며, 빈 원소가 나중에 소멸해도 엔진의 파일에는 영향을 주지 않는다.

잠금과 임시 변경도 범위에 묶는다

잠금 가드는 이동을 허용하지 않는다

잠금 가드(lock guard)는 생성할 때 뮤텍스를 잠그고 소멸할 때 푼다. 생성 중 lock이 예외를 던지면 가드 객체의 생성은 완료되지 않으므로 그 가드의 소멸자는 실행되지 않는다. 잠금 획득에 성공한 가드만 나중에 unlock을 호출한다는 구조다.

이번 MutexGuard는 뮤텍스를 소유하지 않고 참조한다. 따라서 뮤텍스가 가드보다 오래 살아 있어야 한다. 복사하면 같은 잠금을 두 번 해제할 수 있으므로 복사를 금지한다. 이동도 금지한다. 이 가드는 만들어진 실행 범위에서 잠금 책임을 끝내도록 설계했으며, 특히 일반적인 mutex의 잠금을 다른 스레드에 넘겨 그곳에서 해제하는 용도로 사용해서는 안 된다.

자원을 감싼다고 해서 모두 이동 가능해야 하는 것은 아니다. 파일은 소유권 이전이 자연스럽지만, 범위에 고정된 잠금 책임은 이동을 막는 편이 사용 규칙을 단순하게 만든다. 실제 제품 코드에서 같은 동작이 필요하면 보통 std::lock_guard를 사용한다. 여기서는 생성과 소멸의 짝을 드러내기 위해 직접 구현한다. 여러 스레드의 작업 조율은 이번 예제에 포함하지 않는다.

범위 종료 가드로 목록을 복구한다

범위 종료 가드(scope guard)는 범위를 떠날 때 지정한 동작을 실행한다. 파일처럼 별도의 자원 타입을 만들 정도는 아니지만, 임시 변경을 반드시 정리해야 할 때 유용하다. 이 장의 ScopeExit는 작은 함수 객체를 보관하고 활성 상태로 소멸하면 그 객체를 호출한다. release를 호출하면 정리 동작을 취소한다.

엔진은 주문을 처리 목록에 임시로 추가한 뒤 검증하고 기록한다. 추가하기 전 목록 크기를 저장하고, 실패하면 그 크기까지 pop_back을 반복하는 동작을 가드에 맡긴다. 목록 추가 자체가 실패하면 크기가 그대로이므로 복구 동작은 아무것도 하지 않는다. 검증이나 파일 기록에서 예외가 발생하면 방금 추가한 주문을 제거한다.

정리 함수는 예외를 내보내지 않아야 한다. ScopeExit는 noexcept로 호출할 수 있는 함수 객체만 받도록 검사한다. 이번 람다는 정수 멤버만 가진 Order를 제거하므로 해당 계약을 지킬 수 있다. 다른 타입에 재사용할 때는 소멸 동작과 정리 함수가 호출하는 연산까지 확인해야 한다. 람다 뒤에 noexcept를 붙이는 것만으로 위험한 연산이 안전해지지는 않는다.

가드의 생성 순서는 해제 순서를 결정한다. submit은 먼저 잠금 가드를 만들고 나중에 목록 복구 가드를 만든다. 지역 객체는 생성의 역순으로 소멸하므로 실패할 때 목록을 복구한 다음 잠금을 푼다. 순서를 반대로 배치하면 공유 상태를 고치는 동안 잠금이 이미 해제되어 있을 수 있다.

복구 가드를 잠금 가드보다 나중에 만들면 예외가 발생해도 목록 복구가 잠금 해제보다 먼저 실행된다

이 ScopeExit는 생성 후 이동할 필요가 없으므로 복사와 이동을 모두 금지한다. 함수 객체는 값으로 받은 뒤 멤버로 이동한다. 앞 장의 전달 기법을 모두 적용하기보다 이번 사용처에 필요한 계약만 남긴 것이다. 생성 자체가 실패하면 아직 목록을 변경하지 않았으므로 복구할 변경도 없다.

완성 코드

다음 내용을 main.cpp로 저장한다. 예제는 실행할 때 작업 디렉터리의 orders.log를 새로 쓰며, 같은 이름의 기존 내용은 지워진다. 출력 순서를 확인하기 쉽도록 한 스레드에서 주문 세 건을 처리한다.

#include <cerrno>
#include <cstdio>
#include <exception>
#include <iostream>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <system_error>
#include <type_traits>
#include <utility>
#include <vector>

struct FileCloser {
    void operator()(std::FILE* file) const noexcept {
        static_cast<void>(std::fclose(file));
    }
};

class File {
public:
    explicit File(const char* path)
        : handle_(std::fopen(path, "w")) {
        if (!handle_) {
            throw std::system_error(
                errno, std::generic_category(), "open log");
        }
    }

    ~File() = default;
    File(const File&) = delete;
    File& operator=(const File&) = delete;
    File(File&&) noexcept = default;
    File& operator=(File&&) noexcept = default;

    void write_fill(int id, int quantity) {
        require_open();
        if (std::fprintf(handle_.get(), "fill %d %d\n",
                         id, quantity) < 0) {
            throw std::runtime_error("write log failed");
        }
        if (std::fflush(handle_.get()) != 0) {
            throw std::runtime_error("flush log failed");
        }
    }

    void close_checked() {
        require_open();
        std::FILE* file = handle_.release();
        if (std::fclose(file) != 0) {
            throw std::runtime_error("close log failed");
        }
    }

private:
    void require_open() const {
        if (!handle_) {
            throw std::logic_error("log is closed");
        }
    }

    std::unique_ptr<std::FILE, FileCloser> handle_;
};

static_assert(!std::is_copy_constructible_v<File>);
static_assert(std::is_nothrow_move_constructible_v<File>);
static_assert(std::is_nothrow_move_assignable_v<File>);

class MutexGuard {
public:
    explicit MutexGuard(std::mutex& mutex) : mutex_(mutex) {
        mutex_.lock();
    }

    ~MutexGuard() noexcept {
        mutex_.unlock();
    }

    MutexGuard(const MutexGuard&) = delete;
    MutexGuard& operator=(const MutexGuard&) = delete;
    MutexGuard(MutexGuard&&) = delete;
    MutexGuard& operator=(MutexGuard&&) = delete;

private:
    std::mutex& mutex_;
};

template <class F>
class ScopeExit {
    static_assert(std::is_nothrow_invocable_v<F&>);

public:
    explicit ScopeExit(F action)
        noexcept(std::is_nothrow_move_constructible_v<F>)
        : action_(std::move(action)) {}

    ~ScopeExit() noexcept {
        if (active_) {
            action_();
        }
    }

    ScopeExit(const ScopeExit&) = delete;
    ScopeExit& operator=(const ScopeExit&) = delete;
    ScopeExit(ScopeExit&&) = delete;
    ScopeExit& operator=(ScopeExit&&) = delete;

    void release() noexcept {
        active_ = false;
    }

private:
    F action_;
    bool active_ = true;
};

struct Order {
    int id;
    int quantity;
};

class Engine {
public:
    explicit Engine(File&& log) noexcept
        : log_(std::move(log)) {}

    void submit(Order order) {
        MutexGuard lock(mutex_);
        const auto previous_size = fills_.size();

        ScopeExit rollback([this, previous_size]() noexcept {
            while (fills_.size() > previous_size) {
                fills_.pop_back();
            }
        });

        fills_.push_back(order);
        if (order.quantity <= 0) {
            throw std::invalid_argument("quantity must be positive");
        }

        log_.write_fill(order.id, order.quantity);
        rollback.release();
    }

    std::size_t fill_count() {
        MutexGuard lock(mutex_);
        return fills_.size();
    }

    void finish() {
        MutexGuard lock(mutex_);
        log_.close_checked();
    }

private:
    File log_;
    std::mutex mutex_;
    std::vector<Order> fills_;
};

int main() {
    try {
        std::vector<File> files;
        files.emplace_back("orders.log");
        files.reserve(files.capacity() + 1);

        Engine engine(std::move(files.front()));

        engine.submit(Order{101, 3});
        std::cout << "accepted 101\n";

        try {
            engine.submit(Order{102, 0});
        } catch (const std::invalid_argument&) {
            std::cout << "rejected 102\n";
        }

        engine.submit(Order{103, 2});
        std::cout << "accepted 103\n";
        std::cout << "fills " << engine.fill_count() << '\n';

        engine.finish();
        std::cout << "log closed\n";
    } catch (const std::exception& error) {
        std::cerr << "error: " << error.what() << '\n';
        return 1;
    }
    return 0;
}

줄별 해설

파일의 획득과 해제

FileCloser의 호출 연산자는 fclose의 반환값을 명시적으로 버린다. 이는 반환값을 확인할 필요가 없다는 뜻이 아니라, 소멸자 경로에서는 실패를 예외로 전달하지 않기로 한 정책을 코드에 드러낸 것이다. 파일 닫기 오류를 확인할 책임은 close_checked를 호출하는 쪽에 있다.

File 생성자의 멤버 초기화는 fopen의 결과를 곧바로 unique_ptr에 넣는다. 파일을 연 뒤 소유 객체에 넣기 전까지 다른 작업을 끼우지 않는다. 생성자 본문에서 포인터가 비어 있는지 확인하고 실패하면 system_error를 던진다. 실패한 경우에는 해제할 파일이 없고, 성공한 경우에는 이후 예외가 발생해도 멤버가 정리 책임을 가진다.

복사 생성자와 복사 대입 연산자의 delete는 단독 소유권 정책을 나타낸다. 소멸자를 명시적으로 선언했으므로 이동 연산도 명시적으로 선언한다. 이동 생성은 소유권만 넘기지만 이동 대입은 대상의 기존 자원 정리까지 포함한다. 따라서 기존 파일의 닫기 결과가 중요하면 이동 대입 전에 close_checked를 호출해야 한다.

write_fill의 require_open은 빈 객체의 오용을 검출한다. 이어지는 fprintf와 fflush는 서로 다른 실패 지점을 검사한다. close_checked의 release는 해제가 아니다. unique_ptr의 소유권 관리에서 포인터를 빼내는 연산이며, 바로 다음 fclose가 실제 닫기를 담당한다.

잠금 책임과 복구 책임

MutexGuard의 참조 멤버는 Engine의 mutex_를 가리킨다. 생성자 본문에서 잠금을 얻고 소멸자에서 푼다. 정상적인 소유 규칙에 따라 같은 스레드가 잠금을 해제한다는 전제에서 사용한다. unlock을 예외 처리 수단으로 감싸기보다, 누가 잠갔고 언제 해제하는지 구조로 제한한다.

ScopeExit의 타입 매개변수 F는 저장할 람다의 타입이다. 사용 지점에서는 생성자 인수로부터 타입이 정해지므로 람다의 타입 이름을 직접 적지 않는다. 여기서는 이 사용 형태만 익히면 된다. 추론과 후보 선택의 구체적인 규칙은 다음 주제에서 다룬다.

action_은 정리 동작이고 active_는 실행 여부다. release는 함수를 즉시 실행하지 않으며, 소멸할 때 실행하지 않도록 표시만 바꾼다. unique_ptr의 release와 이름은 같지만 반환값과 효과가 다르므로 문맥을 구분해야 한다.

submit은 잠금을 먼저 얻은 다음 목록 크기를 읽는다. rollback 람다는 크기 값을 복사하고 엔진 포인터를 저장한다. 람다가 사용하는 엔진은 submit 실행 중 살아 있으며, 가드는 함수 밖으로 이동하지 않는다. 참조나 포인터를 저장하는 정리 동작에서는 이 수명 관계를 반드시 확인한다.

push_back 다음 검증을 수행하는 배치는 임시 상태의 복구를 보여 주기 위한 것이다. 실제 업무에서 변경 없이 검증할 수 있는 항목은 먼저 검사하면 복구할 일이 줄어든다. 그렇더라도 여러 작업 중간에 실패할 수 있는 처리에는 같은 구조를 적용할 수 있다.

성공 경로와 종료 경로

write_fill이 반환한 뒤에만 rollback.release를 호출한다. 너무 일찍 해제하면 기록 실패 때 목록이 남는다. 성공 경로에서는 가드가 아무 작업 없이 사라지고 잠금이 풀린다. 실패 경로에서는 목록이 먼저 복구되고 잠금이 풀린 다음 예외가 호출자에게 전달된다.

main의 reserve는 File 이동 생성이 필요한 재할당을 만든다. 이후 engine으로 파일을 넘기면 files.front는 빈 상태가 된다. Engine 생성자의 매개변수 log는 이름이 있는 표현식이므로, 멤버를 초기화할 때 std::move를 사용해 이동 대상으로 지정한다.

finish는 파일 닫기 결과를 확인하는 명시적 종료 지점이다. 이 예제에서는 finish 이후 주문을 넣지 않는다. 만약 호출한다면 파일의 빈 상태 검사에서 예외가 발생하고 임시 주문은 제거된다. 두 번째 finish 역시 논리 오류로 처리된다. 별도의 엔진 상태 체계 없이도 파일의 상태 계약으로 사용 오류를 발견하는 설계다.

실행 결과

macOS 또는 Linux에서 다음과 같이 빌드하고 실행한다. 코드는 C++20 기능만 사용하며 제시한 경고 옵션을 기준으로 작성했다. 아래 출력은 파일 생성과 기록이 성공한 경우의 예상 결과다.

c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o order_engine
./order_engine
cat orders.log
accepted 101
rejected 102
accepted 103
fills 2
log closed
fill 101 3
fill 103 2

102번 주문은 목록에 잠시 추가되지만 검증 예외가 발생하면서 제거된다. 따라서 체결 수는 2이며 파일에도 101번과 103번 주문만 남는다. 마지막 두 줄은 프로그램의 표준 출력이 아니라 cat이 보여 주는 파일 내용이다. 작업 디렉터리에 쓰기 권한이 없거나 입출력이 실패하면 바깥 catch가 오류를 출력하고 종료 코드 1을 반환한다.

실무에서 자주 틀리는 것

원시 포인터를 그대로 복사한다

다음 타입의 기본 복사는 포인터 값만 복사한다. 두 객체가 같은 스트림의 해제 책임을 주장하므로 먼저 하나가 소멸한 뒤 남은 객체는 유효하지 않은 스트림을 사용하거나 다시 닫게 된다.

struct BadFile {
    std::FILE* handle;
    ~BadFile() {
        if (handle != nullptr) {
            std::fclose(handle);
        }
    }
};

완성 코드처럼 단독 소유 포인터에 책임을 맡기고, 외부에 보이는 타입에서도 복사 금지를 선언한다. 원시 포인터를 직접 보관해야 한다면 이동할 때 원본을 비우는 처리와 이동 대입의 기존 자원 정리까지 구현해야 한다.

File(const File&) = delete;
File& operator=(const File&) = delete;
File(File&&) noexcept = default;
File& operator=(File&&) noexcept = default;

// 소유 멤버
std::unique_ptr<std::FILE, FileCloser> handle_;

닫은 뒤 소유 포인터를 비운다

닫기를 먼저 하고 reset으로 소유 포인터를 비우면 삭제자가 이미 닫힌 스트림을 다시 닫는다. fclose 실패 후에 포인터를 그대로 보유하는 코드도 같은 재시도 문제를 만든다.

std::fclose(handle_.get());
handle_.reset();  // 삭제자가 다시 fclose를 호출한다.

명시적 닫기에서는 먼저 소유권을 분리한다. fclose 결과와 무관하게 래퍼는 빈 상태로 남는다. 단순히 오류를 확인하지 않고 닫기만 할 때는 reset 한 번이면 충분하지만, 여기서는 결과를 보고해야 하므로 release를 사용한다.

std::FILE* file = handle_.release();
if (std::fclose(file) != 0) {
    throw std::runtime_error("close log failed");
}

일부 원소를 이동한 뒤 예외가 나도 원본이 유지된다고 생각한다

복사가 막힌 자원 타입의 이동이 예외를 던질 수 있으면 vector 재할당의 강한 예외 보장을 기대하기 어렵다. 단순한 소유권 이전인데도 noexcept를 빠뜨리면 타입의 실제 성질이 컨테이너에 전달되지 않는다.

File(File&& other)
    : handle_(std::move(other.handle_)) {}

멤버 이동과 삭제자가 예외를 내보내지 않는다는 조건을 확인하고 계약을 선언한다. 반대로 이동 중 실패할 수 있는 추가 작업이 있다면 선언만 바꾸지 말고 그 작업을 이동 경로에서 분리할 수 있는지 검토한다.

File(File&&) noexcept = default;
File& operator=(File&&) noexcept = default;

복구 가드를 먼저 만든다

아래 순서에서는 예외가 발생할 때 lock이 rollback보다 먼저 소멸한다. 결과적으로 목록 복구가 잠금 밖에서 실행된다. 이전 크기를 읽는 시점 역시 잠금 밖에 놓여 있다.

const auto previous_size = fills_.size();
ScopeExit rollback([this, previous_size]() noexcept {
    while (fills_.size() > previous_size) {
        fills_.pop_back();
    }
});
MutexGuard lock(mutex_);

잠금 안에서 상태를 읽고, 잠금을 유지한 채 복구하도록 생성 순서를 바꾼다. 해제 순서를 눈으로 확인하려면 정리 대상의 의존 관계를 먼저 적어 보는 것이 도움이 된다.

MutexGuard lock(mutex_);
const auto previous_size = fills_.size();
ScopeExit rollback([this, previous_size]() noexcept {
    while (fills_.size() > previous_size) {
        fills_.pop_back();
    }
});

한눈에 보기

각 타입이 맡은 책임과 사용 조건
타입 또는 연산책임핵심 조건
File스트림의 단독 소유와 정리복사 금지, 이동 허용, 빈 상태 검사
FileCloserfclose 호출예외를 내보내지 않으며 오류 보고는 별도 처리
noexcept 이동실패 없이 소유권 이전이동 경로의 실제 연산도 계약을 지켜야 함
MutexGuard범위가 끝나면 잠금 해제뮤텍스가 더 오래 살고 같은 스레드에서 사용
ScopeExit임시 목록 변경 복구성공 후에만 release 호출
close_checked닫기 오류를 호출자에게 전달실패해도 스트림을 다시 닫지 않음

설계를 검토할 때는 “누가 소유하는가”, “언제 책임이 이전되는가”, “정리가 실패하면 어디에서 알리는가”를 각각 답한다. 이 세 질문에 대한 답이 다르면 같은 종류의 포인터를 보관하더라도 서로 다른 타입 계약이 필요하다. 또한 정리 코드가 정확하더라도 외부 파일과 메모리 상태가 하나의 작업으로 함께 복구되는 것은 아니다.

연습 문제

  1. File 객체 a와 b가 서로 다른 파일을 소유한다. b = std::move(a)를 수행하면 두 파일과 두 객체의 상태는 어떻게 변하는가. b가 원래 소유한 파일의 닫기 결과를 확인해야 한다면 호출 순서를 어떻게 바꿔야 하는가.
  2. submit에서 rollback.release를 log_.write_fill보다 먼저 호출하도록 바꾸었다. 기록 중 예외가 발생할 때 목록과 파일에 각각 어떤 상태가 남을 수 있는지 설명하라.
  3. close_checked를 호출하지 않고 정상적으로 main을 끝내도 파일은 정리된다. 그렇다면 finish가 필요한 이유는 무엇인가. fflush 성공만으로 저장 장치에 기록되었다고 판단할 수 있는지도 설명하라.
  4. vector 재할당 전에 File& saved = files.front()를 저장했다. 재할당 뒤 saved로 기록하는 코드를 고치고, File의 noexcept 이동이 이 참조를 유효하게 유지하지 못하는 이유를 설명하라.

정답과 해설

  1. b의 기존 파일은 삭제자를 통해 닫히고, a의 파일은 b로 이전된다. a는 빈 상태가 된다. 기존 b의 닫기 결과를 확인하려면 먼저 b.close_checked를 호출하고 성공한 뒤 이동 대입한다. 닫기가 실패하면 예외가 발생하며 b는 이미 빈 상태이고 a는 기존 파일을 계속 소유한다. 이 예제에서는 자기 자신에 대한 이동 대입이 아닌 서로 다른 두 객체를 전제로 한다.

  2. 가드가 비활성화되었으므로 기록 실패에도 임시 주문이 목록에 남는다. 파일은 쓰기 실패 지점에 따라 내용이 없거나 일부 또는 전체 기록이 남을 수 있다. release를 기록 성공 뒤로 옮기면 메모리 목록은 복구할 수 있지만, 부분적으로 기록된 파일까지 복구하지는 않는다.

  3. 소멸자에 맡기면 해제는 시도하지만 fclose 실패를 호출자가 확인할 수 없다. finish는 오류를 보고할 수 있는 실행 지점을 제공한다. fflush는 C 스트림의 출력 버퍼를 내보내는 연산이며 저장 장치의 영속성을 보장하지 않는다. 어느 단계까지 성공으로 정의하는지는 기록 요구 사항에 맞춰 별도로 정해야 한다.

  4. 재할당 뒤 files.front로 새 참조를 얻어 사용한다. noexcept 이동은 원소 이전 과정의 실패 가능성과 관련된 계약이며, 기존 원소의 주소를 보존한다는 계약이 아니다. 스트림은 계속 열려 있어도 그 스트림을 소유하던 이전 File 객체는 이미 소멸했다.

    files.reserve(files.capacity() + 1);
    File& current = files.front();
    current.write_fill(104, 1);

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.