본문 바로가기

study/design pattern

[디자인패턴][행위패턴] Null 객체 - C++

728x90

[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.


Null 객체

인터페이스를 마음대로 선택할 수 있는 경우는 사실 그렇게 많지 않다. 어떤 모듈의 특정 기능을 원하지 않지만 인터페이스에 이미 내장되어 있을 수 있다. 이때 Null 객체를 이용한다.


시나리오

우선 아래와 같은 인터페이스를 갖는 Logger 라이브러리를 사용한다고 가정하자.

struct Logger {
    virtual ~Logger() = default;
    virtual void info(const string &s) = 0;
    virtual void warn(const string &s) = 0;
};

이 라이브러리를 이용해 다음과 같이 은행 계좌 동작들에 로깅 기능을 구현해 보자.

void BankAccount::deposit(int amount) {
    balance += amount;
    log->info("Deposited $" + lexical_cast<string>(amount)
         + " to " + name + ", balance is now $" + lexical_cast<string>(balance));
}

하지만 상황에 따라 로깅을 해서는 안 되는 경우가 있다면 어떻게 해야 할까?


Null 객체

BankAccount 생성자에서 인자로 Logger 객체를 넘겨주어 세팅을 해야 한다. 초기화되지 않은 shared_ptr<Logger>()를 전달해 로깅 기능을 피할 수 있다고 가정하는 것은 안전하지 않다. 추가 설명이 포함되지 않는다면 BankAccount 내부적으로 Logger를 사용하기 전에 포인터 검사를 수행하는지 알 수 없기 때문이다.

따라서 로깅 기능을 사용하지 않으면서도 BankAccount를 안전하게 생성할 수 있는 방법은 Logger의 Null 객체를 전달하는 것이다. Null 객체는 인터페이슈 규약을 모두 준수하면서도 실제 동작은 하지 않는 객체이다.

struct NullLogger : Logger {
    void info(const string &s) override {}
    void warn(const string &s) override {}
};

shared_ptr는 Null 객체가 아니다.

shared_ptr를 포함한 스마트 포인터들은 Null 객체가 아니라는 점을 명확히 인지해야 한다. Null 객체는 인터페이스 규칙에 따라 올바르게 동작하는 특성을 보존하면서 실제로는 아무것도 하지 않는 객체다. Null 객체 대신 초기화되지 않은 스마트 포인터로 멤버 함수를 호출하면 크래시가 발생한다.

스마트 포인터는 멤버 호출에 있어 안전하게 동작할 방법이 없다. 즉, 초기화되지 않은 스마트 포인터 foo에 대해 foo->bar()와 같은 호출이 저절로 아무것도 안 하는 더미 호출이 될 수는 없다.


개선된 디자인

만약 BankAccount를 내 마음대로 바꿀 수 있다면 어떻게 할까? 좀 더 쉽게 인터페이스를 바꿀 수 있을까? 몇 가지 아이디어가 있을 수 있다.

  • 모든 포인터 사용처마다 포인터 유효성 검사를 추가한다. 안전하고 올바르게 사용할 수 있지만 라이브러리 사용자 입장에서는 혼란스럽다. 포인터가 null이어도 된다는 사실을 다른 방법을 통해서 사용자가 알아야만 한다.
  • 디폴트 인자를 추가한다. 즉, const shared_ptr<Logger> &logger = no_logging와 같이 설정한다. no_logging은 디폴트 생성자 객체로 BankAccount 안에 멤버로 갖는다. 당연히 이렇게 한다면 shared_ptr가 감싸는 포인터가 null인지 검사해야 한다.
  • optional 타입을 이용한다. 관례적으로 올바르고 의도적으로 목적하는 바와 합치한다. 하지만 optional<shared_ptr<T>>을 전달하는 번거로움과 공백 여부를 확인하는 작업들이 추가되어야 한다.

묵시적인 Null 객체

또 다른 급진적 아이디어가 있다. 호출과 집행 두 절차로 나누어 로깅을 처리하는 것이다.

struct OptionalLogger : Logger {
    Logger(const shared_ptr<Logger> &logger) : impl(logger) {}
    virtual void info(const string &s) override {
        // null 검사
        if ( impl ) impl->info(s);
    }

    shared_ptr<Logger> impl;
    static shared_ptr<Logger> no_logging;
};

shared_ptr<Logger> BankAccount::no_logging{};

호출 부분을 구현 부분으로부터 추상화했다. 따라서 BankAccount의 생성자느 다음과 같이 재정의 가능하다.

shared_ptr<OptionalLogger> logger;
BankAccount(const string &name, int balance, const shared_ptr<Logger> &logger = no_logging) :
    log(make_shared<OptionalLogger>(logger)), name(name), balance(balance) {}

위 코드에는 기교가 많이 들어갔다. 인자로 받는 로깅 객체를 안전한 사용을 보증하는 OptionalLogger로 감싼다. 프락시 디자인 패턴의 활용이다. 이렇게 함으로써 디폴트 인자 값(no_logging)이 사용되든 사용자가 null을 넘기든 관계없이 로깅 객체가 유효할 때만 로깅 호출이 일어난다.


요약

Null 객체 패턴은 API 설계에서 발생하는 문제를 상기시킨다. 의존하는 객체들에 어떤 종류의 가정을 할 수 있을까? 포인터의 사용처마다 유효성 검사를 해야 할 책임이 있는 걸까?

책임이 없다고 느낀다면 클라이언트로서 유일한 대응 방법은 인터페이스에 내재된 규약을 준수하되 실제 동작은 없는 Null 객체를 구현하는 것이다. 이 방법은 멤버 함수에서만 효과가 있다. 멤버 변수를 사용하고 있다면 Null 객체로 올바른 동작 보증이 어렵다.

적극적으로 Null 객체를 활용하고 싶다면 명시적으로 그렇게 해야 한다. 파라미터에 optional을 지정하거나 자체적인 Null 객체를 디폴트 값(no_logging과 같음)에 지정해 힌트를 주어야 한다.

728x90