관찰자 (Observer)

 

객체 사이에 일 대 다의 의존 관계를 정의해두어, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지 받고 자동으로 업데이트될 수 있게 만듭니다.

 

MVC(model-view-controller) 패턴의 기반이 되는 패턴이다.

 

예제

업적 달성 시스템을 추가한다고 해보자.

많은 업적중에 '다리에서 떨어지기' 라는 업적이 존재한다면 실제로 연산되는 물리 엔진 코드에 집어넣는다는 단순한 발상을 떠올릴 수도 있지만, 다른 업적들도 각각 여기저기 커플링을 형성하는 것은 결코 좋지 않다.

 

void Physics::updateEntity(Entity& entity)
{
    bool wasOnSurface = entity.isOnSurface();
    entity.accelerate(GRAVITY);
    entity.update();
    if (wasOnSurface && !entity.isOnSurface())
        notify(entity, EVENT_START_FALL); // 누군가 떨어지는 중이라고 알림
}

 

물리 엔진 코드에 직접 업적 해금을 구현하는것이 아니라 업적 시스템에게 알림을 보내고 끝낸다. 알림은 누가 받는지 전혀 신경쓰지 않고 계속 보내게된다.

완전히 디커플링 되지는 않았지만 어느정도 개선이 되었다.

 

 

작동 원리

class Observer {
public:
    ~virtual ~Observer() {}
    virtual void onNotify(const Entity& entity, Event event) = 0;
};

 

알람을 받을 필요가 있는 클래스에서 상속받아 가상 함수를 구현한다.

 

class Achievements : public Observer {
public:
    virtual void onNotify(const Entity& entity, Event event) { // 알람 수신 시 호출
        switch (event) {
        case EVENT_ENTITY_FELL:
            if (entity.isHero() && heroIsOnBridge_) unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
            break;
        ...
        }
    }
private:
    void unlock(Achievement achievement) {
        /* 업적 잠금 해제 */
    }
    bool heroIsOnBridge_;
};

 

Observer를 상속받은 예

 

class Subject {
public:
    void addObserver(Observer* observer) { /* 컨테이너에 추가 */ }
    void removeObserver(Observer* observer) { /* 컨테이너에서 제거 */ }
    
protected:
    void notify(const Entity& entity, Event event) {
        for (auto observer : observers_)
            observer->onNotify(entity, event); // 나를 관찰중인 모든 대상 호출
    }

private:
    std::vector<Observer*> observers_;
};

 

Observer의 관찰 대상인 Subject는 자신을 관찰중인 관찰자 목록을 가지고 어떤 변화가 생겼을 때 관찰자들에게 알람(notify)을 보낸다.

이 때, 관찰자들끼리는 서로의 존재를 모르기 때문에 커플링이 형성되지 않는다.

 

관찰자 패턴은 특정 인터페이스를 구현한(Observer) 인스턴스 포인터 목록을 관리하는 클래스(Subject) 하나만 있으면 간단하게 만들 수 있다.

 

 

"너무 느려"

관찰자 패턴은 호출되는 객체에서 동적 할당 등의 비용이 큰 연산을 하지 않는 이상 단순히 컨테이너를 순회하면서 가상 함수를 호출하기 때문에 그다지 느리지 않다.

동적 디스패치를 쓰더라도 성능에 민감하지 않은 곳에 사용하는것은 좋은 선택이 될 수 있다.

다만 등록된 관찰자들의 알림 호출이 동기적으로 이루어진다는 점을 잊지 말아야 한다.

오히려 진짜 조심해야 하는점은 관찰자를 멀티스레드, 락과 함께 사용할 때 이다.

 

 

"동적 할당을 너무 많이 해"

벡터같은 컨테이너를 사용한다면 크기가 가변적으로 변하기 때문에 관찰자의 추가, 삭제 시 발생할 수 있는 메모리 단편화가 우려될 수 있다.

 

◾ 관찰자 연결 리스트

관찰자 목록을 제거하고 연결 리스트로 관찰자들을 직접 엮는다.

대신 관찰자 그 자체가 노드로 활용되기 때문에 하나의 관찰자는 하나의 대상(목록)에만 등록될 수 있다.

 

◾ 리스트 노드 풀

일반적으로 여러 관찰자가 하나의 대상에 등록되는 경우가 많기 때문에 대부분은 관찰자 연결 리스트만으로 충분할 수 있다.

그럼에도 하나의 관찰자가 여러 대상에 등록되어야 하는 경우에는 관찰자를 직접 노드로 사용하는게 아니라 간단한 노드를 따로 만들어서 리스트를 만들면 된다.

이렇게 만들어진 노드들은 모두 같은 자료형에 같은 크기이기 때문에 객체 풀에 할당하면 동적 할당도 이루어지지 않는다.

 

근데 이럴거면 벡터 크기를 고정시켜서 할당하거나 array를 쓰면 되는게 아닌가 싶은 생각이 든다.

 

 

남은 문제점들

◾ 만약 대상이나 관찰자가 제거되면 어떻게 되는가?

대상이 제거되는 경우는 어렵지 않게 처리할 수 있다. 대상이 소멸할 때 소멸자에서 등록된 관찰자들에게 알림을 보내면 된다. 알림을 수신받은 관찰자들은 알아서 처리하면 된다.

 

문제는 관찰자가 제거되는 경우이다. 가장 쉽게 처리하는 방법은 보통 관찰중인 대상을 알고 있기 때문에 소멸자에서 등록을 취소하면 된다.

다만 등록 취소를 까먹는다면 문제가 생긴다.

 

◾ 사라진 리스너 문제

예를 들어 GC를 사용하는 언어의 상태창 UI 구현을 생각해보자.

유저가 상태창을 열면 상태창 UI 객체가 생성되고 닫으면 따로 삭제하지 않아도 GC에 의해 회수된다.

하지만 UI를 닫을 때 관찰자 등록을 해제하지 않는다면 관찰자 목록은 상태창 UI를 계속 참조하고 있기 때문에 GC가 수거해가지 않는다.

상태창을 열 때마다 인스턴스가 계속 생성되어 목록에 쌓이고, 대상은 눈에 보이지도 않는 상태창 UI에게 알림을 계속 보내게된다.

대상이 리스너 레퍼런스를 유지하기 때문에 메모리에 UI가 계속 쌓인다.

 

(해결법으로는 관찰자 목록을 weak_ptr로 들고 있으면 될 것 같다.)

 

◾ 어려운 문제 해결

디버깅시 코드가 명시적으로 커플링 되어있다면 어떤 메서드가 호출되는지만 보면 되기 때문에 어렵지 않다.

하지만 관찰자 패턴으로 구현된 코드는 디커플링 되어있기 때문에 어떤 관찰자가 알림을 받는지는 런타임에 확인할 수밖에 없다.

관찰자 패턴은 서로 연관없는 코드들이 결합되지 않고 서로 상호작용하기에 좋은 방법일 뿐, 하나의 기능을 구현하기 위한 코드 안에서는 그다지 유용하지 않다.

경량 (Flyweight)

 

공유를 통해 많은 수의 소립(fine-grained) 객체들을 효과적으로 지원합니다.

 

객체의 공통된 상태를 공유시켜서 메모리를 확보한다.

 

예제

 

게임 내에 수천 그루의 나무가 있고 그 나무들이 렌더링된다면 각 나무마다 가지고 있는 메시, 텍스처, 위치 등의 정보를 매 프레임마다 렌더링 해야 할 것이다.

특히 메시나 텍스처는 용량도 크기때문에 매 프레임마다 버스를 통해 GPU에 수많은 오브젝트들을 전달하기에는 양이 너무 많다.

 

하지만 나무가 수천 그루 있다고 해도 결국 전체 구조는 동일하고 비슷해 보이기 때문에 위치 등을 제외한 대부분의 데이터가 인스턴스별로 다르지 않다.

 

class Tree {
private:
    Mesh mesh;
    Texture bark;
    Texture leaves;
    Vector position;
    double height;
    double thickness;
    Color barkTint;
    Color leafTint;
};

 

나무가 이런 정보들을 가지고 있다고 한다면,

 

class TreeModel {
private:
    Mesh mesh;
    Texture bark;
    Texture leaves;
};

 

같은 메시나 텍스쳐는 메모리에 여러번 올릴 이유가 없기 때문에 따로 빼내서 하나의 공유 객체만 존재하게 만든다.

그뒤에 각 나무 인스턴스들은 공유 객체를 참조하기만 하면 된다.

 

class Tree {
private:
    TreeModel* model;
    
    Vector position;
    double height;
    double height;
    double thickness;
    Color barkTint;
    Color leafTint;
};

 

많은 수의 오브젝트가 하나의 공유 데이터를 사용하기 때문에 메모리 사용은 크게 줄일 수 있다.

하지만 렌더링으로 넘어가면 얘기가 조금 다르다. 공유 데이터는 GPU에 딱 한번만 보내주어야 효율적일 것이다.

공유 데이터를 한번 보내고 난 뒤에, 나머지 속성들을 전달하고 최종적으로 공유 데이터를 사용하라고 일러주기만 하면 된다.

 

이를 인스턴스 렌더링이라고 하며 보통 그래픽카드나 API에서 제공해준다.

두 개의 데이터 스트림을 사용해서 첫 번째 데이터 스트림에는 공유 데이터, 두 번째 데이터 스트림에는 인스턴스 목록과 속성들이 들어간다.

하나의 공유 데이터만을 사용한다면 드로우콜 한번으로 모든 오브젝트를 다 그릴수 있게 된다.

 

 

경량 패턴

인스턴스 렌더링에서는 메모리의 크기보다는 렌더링할 데이터를 GPU 버스로 보내는 데 걸리는 시간이 더 중요하다. 하지만 공유 데이터를 사용함으로써 성능을 끌어올린다는 기본 개념은 경량 패턴과 같다.

 

경량 패턴은 객체 데이터를 공유할 수 있는 데이터인 고유 상태(자유 문맥)와 인스턴스별로 값이 다른 외부 상태로 나눈다.

고유 상태는 반드시 불변 객체임이 보증되어야 한다.

 

어떤 경량 객체가 실제로 필요한지 예측할 수 없다면, 필요할 때 만드는 것이 낫다. 공유기능 유지를 원한다면 이미 만들어져 있는지를 확인해보는 절차가 필요하다.

명령 (Command)

 

요청 자체를 캡슐화하는 것입니다. 이를 통해 요청이 서로 다른 사용자(client)를 매개변수로 만들고, 요청을 대기시키거나 로깅하며, 되돌릴 수 있는 연산을 지원합니다.

 

한줄로 요약하면 메서드 호출을 실체화 한 것이다.

실체화는 무엇인가를 일급(first-class)으로 만들었다는 뜻이고, 메서드 호출을 일급으로 만들었다는 것은 함수를 객체로 감쌌다는 의미이다.

프로그래밍 언어에 따라 콜백, 일급 함수, 함수 포인터, 함수 객체, 클로저 등으로 불린다.

 

입력키 변경

void InputHandler::handleInput()
{
    if (isPressed(BUTTON_X)) jump();
    else if (isPressed(BUTTON_Y)) fireGun();
    else if (isPressed(BUTTON_A)) swapWeapon();
    else if (isPressed(BUTTON_B)) lurchIneffectively();
}

 

모든 게임에는 입력을 읽어들이는 코드가 존재하고 보통 루프를 돌며 매 프레임 호출된다.

입력키 변경을 불가능하게 만든다면 위와 같이 작성해도 무관하지만 대부분의 게임은 키변경을 지원한다.

키변경을 지원하려면 키와 해당 함수를 직접 호출(바인딩)하지 않고 교체 가능한 무엇인가로 바꾸어야한다.

 

class Command {
public:
    virtual ~Command() {}
    virtual void execute() = 0;
};

class JumpCommand : public Command {
public:
    virtual void execute() { jump(); }
};
...

class InputHandler {
public:
    void handleInput();
private:
    Command* buttonX;
    Command* buttonY;
    Command* buttonA;
    Command* buttonB;
};

void InputHandler::handleInput()
{
    if (isPressed(BUTTON_X)) buttonX->execute();
    ...
}

 

직접 호출하는 방식에서 한 단계 우회하는 계층이 생겼다. 이것이 명령 패턴의 핵심이다.

 

 

액터에게 지시하기

위의 코드는 전역 함수가 플레이어 캐릭터를 직접 찾아서 동작하기 때문에 플레이어 캐릭터에만 적용될 수 있다.

함수가 객체를 직접 찾게 하지 말고 인자로 넘겨주면 조금 더 유연하게 사용할 수 있다.

 

class Command {
public:
    virtual ~Command() {}
    virtual void execute(GameActor& actor) = 0;
};
...

class JumpCommand : public Comnand {
public:
    virtual void execute(GameActor& actor) { actor.jump() }
};

 

 

이제 InputHandler가 키입력과 명령을 동시에 처리하게 하지 않고, 키입력시 해당하는 명령 객체를 반환하게 수정하면 된다.

 

Command* InputHandler::handlerInput()
{
    if (isPressed(BUTTON_X)) return buttonX;
    if ...
    return nullptr;
}

Command* command = inputHandler.handlerInput();
if (command) command->execute(actor);

 

 

액터와 명령 사이에 추상 계층을 한 단계 더 두었기 때문에 코드에 유연성이 생겼다.

기존과 다르게 플레이어 뿐만 아니라 AI 로직을 담당하는 객체도 위의 기능을 사용하여 AI를 제어할 수 있을 것이다.

 

액터를 제어하는 Command를 일급 객체로 만든 덕분에 메서드를 직접 호출하지 않게 되면서 디커플링이 가능해졌다.

 

 

실행취소와 재실행

class MoveUnitCommand : public Command {
public:
    MoveUnitCommand(Unit* unit, int x, int y)
    : unit_(unit), x_(x), y_(y) {}
    
    virtual void execute() { unit_->moveTo(x_, y_); }
    
private:
    Unit* unit_; // 명령이 수행될 객체
    int x_; // 기존 좌표값 저장
    int y_;
};

 

이번에는 유닛과 위치 값을 생성자에서 받아서 명령과 명시적으로 바인드했다.

 

Command* handleInput() // 멤버 함수가 아니다
{
    Unit* unit = getSelectedUnit();
    if (isPressed(BUTTON_UP)) {
        int destY = unit->y() - 1;
        return new MoveUnitCommand(unit, unit->x(), destY); // 명령 객체 새로 생성
    }
    ...
    return nullptr;
}

 

 

기존 명령과는 조금 다른점이 있는데, 다른 명령 객체가 매번 재사용 되는것과 달리 이동은 좌표가 매번 다르기 때문에 플레이어가 이동을 선택할 때마다 명령 객체를 새로 생성해야 한다.

 

class Command {
public:
    virtual ~Command() {}
    virtual void execute() = 0;
    virtual void undo() = 0;
};

class MoveUnitCommand : public Command {
public:
    MoveUnitCommand(Unit* unit, int x, int y)
    : unit_(unit), x_(x), y_(y), xBefore_(0), yBefore_(0) {}
    
    virtual void execute() {
        xBefore_ = unit_->x(); // 이동 전 좌표 저장
        yBefore_ = unit_->y();
        unit_->moveTo(x_, y_);
    }
    
    virtual void undo() {
        unit_->moeTo(xBefore_, yBefore_); // 저장된 기존 좌표 사용
    }
private:
    Unit* unit_;
    int x_, y_;
    int xBefore_, yBefore;
};

 

실행취소를 위해 상태 몇 가지가 추가된다.

만약 재실행(redo)를 구현한다면, 가장 최근 명령 하나만 기억하는게 아니라 컨테이너 등에 명령들을 담아서 현재 명령이 무엇인지만 가리키고 있으면 된다.

실행취소한뒤에 새로운 명령을 실행하면 뒤쪽에 저장된 명령들은 모두 버리고 새로운 명령을 저장한다.

 

참고로 재실행은 게임 플레이에서 잘 쓰이지 않을수도 있지만 리플레이 기능에는 매우 자주 쓰인다.

매 프레임마다 전체 게임 상태를 저장하는 것이 아니라 명령들만 저장해서 순서대로 실행시키면 되기 때문이다.

 

 

클래스만 좋고, 함수형은 별로인가?

그런것은 아니다. 다만 상태를 저장할 필요가 있기 때문에 클래스를 사용할 뿐이다.

클로저를 제대로 지원해주는 언어라면 당연히 사용해도 된다.

◾ 추상화와 디커플링을 잘 활용하면 코드를 점차 쉽고 빠르게 만들 수 있다. 하지만, 지금 고민 중인 코드에 유연함이 필요하다는 확신이 없다면 추상화와 디커플링을 적용하느라고 시간 낭비하지 말자.

◾ 개발 내내 성능을 고민하고, 최적화에 맞게 설계해야 한다. 하지만 가정을 코드에 박아 넣어야 하는 저수준의 핵심 최적화는 가능하면 늦게 하라.

◾ 게임 기획 내용을 확인해볼 수 있도록 빠르게 개발하되, 너무 서두르느라 코드를 엉망으로 만들지 말자. 결국 그 코드로 작업해야 하는 건 우리다.

◾ 나중에 버릴 코드를 잘 만들겠다고 시간 낭비하지 말자.

◾ 재미있는 걸 만들고 싶다면 먼저 만드는 데에서 재미를 느껴보라.

+ Recent posts