옵저버(Observer) 패턴

|

가장 많이 사용 되는 패턴 중 하나가 옵저버 패턴(Observer Pattern)입니다. 아마 디자인 패턴을 잘 모르더라도 자신도 모르게 이미 옵저버 패턴을 사용하고 있는 경우가 대부분일 것 같습니다. 예를 들어, 안드로이드 개발을 할 때 Button에 OnClickEventListener를 등록하는 것들이 옵저버(Observer) 패턴에 해당됩니다. 평소엔 가만히 있다가 해당 버튼이 클릭되었을 때 그 이벤트를 알려달라고 리스너를 등록하는 것입니다.

옵저버 패턴은 특정 인스턴스에 이벤트 리스너(EventListener)를 달고 대기하고 있다가 그 인스턴스에 이벤트가 발생하면 그 결과를 통보(Notify)받는 방식이며 UML로 표현하면 다음과 같습니다.

Image


예제 코드

예제 코드는 다음과 같이 간단히 작성할 수 있습니다.

public interface Observer {

  public void update(Subject subject, int event);
}
public abstract class Subject {

  private ArrayList<Observer> mObserverList = new ArrayList<Observer>();

  public void add(Observer observer) {
    mObserverList.add(observer);
  }

  public void Remove(Observer observer) {
    mObserverList.remove(observer);
  }

  public void notify(int event) {
    for(Observer observer : mObserverList) {
      observer.update(this, event);
    }
  }
}

싱글톤(Singleton) 패턴

|

프로그램에서 단 하나의 인스턴스(Instance)만 존재하게 하고 싶을 때 사용하는 패턴입니다. 어디서든지 그 인스턴스에 접근할 수 있기 때문에 전역 변수 등을 관리할 때 상당히 편리하기 때문에 많은 사람들이 남발하고 있는 패턴이기도 합니다.

하지만, 싱글톤(Singleton)은 실제로는 단순 전역 변수와 거의 같은 용도로 많이 쓰이며 그 성격상 객체 지향과는 거리가 있는 패턴입니다. 프로그램 어느 곳에서든 Singleton에 접근할 수 있기 때문에 정보의 보호도 되지 않으며, 누가 어떤 값을 건드렸는지 추적하기도 쉽지 않습니다. 따라서 가급적 사용하지 않는 것이 좋지만 적절하게 제한적을 사용하면 편리하긴 합니다.

#UML

싱글톤 패턴의 UML은 다음과 같습니다. 클래스 하나 뿐이기 때문에 UML이 단순합니다.

Image


예제 코드

싱글톤 패턴을 코드로 구현하면 다음과 같습니다. (다만, 이 코드는 멀티쓰레드(Multi-Thread) 환경에서 문제가 발생하는 코드입니다. 해결법이 여러가지인데, 단계별로 해결된 코드를 설명해나가도록 하겠습니다.)

public class Singleton {

  private static Singleton mInstance = null;

  private Singleton() {
  }

  public static Singleton getInstance() {
    if(mInstance == null) {
      mInstance = new Singleton();
    }

    return mInstance;
  }
}


위 코드는 멀티쓰레드 환경에서 getInstace() 메소드가 끝나기 전에 여러 쓰레드에서 동시에 접근을 할 수 있기 때문에 재수가 없으면 Singleton 인스턴스가 여러 개 생성될 수 있습니다.


함수 전체에 synchronized 적용

이 경우 getInstance() 메소드를 synchronized로 동기화시켜 해결할 수 있습니다.

public class Singleton {

  private static Singleton mInstance = null;

  private Singleton() {
  }

  public synchronized static Singleton getInstance() {
    if(mInstance == null) {
      mInstance = new Singleton();
    }

    return mInstance;
  }
}


하지만, 함수 전체에 synchronized는 동기화 과정에서 속도 문제가 발생하고 성능 저하를 가져 올 수 있습니다. (물론, 위 예제 코드 정도로는 그렇게 치명적인 속도 문제가 발생할 가능성은 많지 않습니다.)


함수 내부 일부분에 synchronized 적용

그래서 함수 내부에 최소 구간에만 synchronized를 거는 방법도 있습니다.

public class Singleton {

  private static Singleton mInstance = null;

  private Singleton() {
  }

  public static Singleton getInstance() {
    if(mInstance == null) {
      synchronized(Singleton.class) {
        if(mInstance == null) {
          mInstance = new Singleton();
        }
      }
    }

    return mInstance;
  }
}


처음부터 인스턴스를 생성

그리고 코드를 좀 더 깔끔하고 쉽게 구현하기 위해서는 아예 처음부터 인스턴스를 생성해버리는 방법도 있습니다. 저는 처음부터 인스턴스를 생성하는 방법을 가장 많이 사용하고 있습니다. 구현도 쉽고 코드도 더 깔끔한 것 같아서입니다.

public class Singleton {

  private static Singleton mInstance = new Singleton();

  private Singleton() {
  }

  public static Singleton getInstance() {
    return mInstance;
  }
}

물론 이 경우는 프로그램이 처음 실행되면서 바로 인스턴스가 생겨버리기 때문에, 불필요한 부분에서 인스턴스가 메모리를 차지해버린다는 단점이 있습니다. 하지만, 그런 경우는 Singleton을 사용하지 않는게 더 적합한 경우가 많기 때문에 대부분의 Singleton 구현은 위의 마지막 예제처럼 구현하면 됩니다.

하지만 최선은 싱글톤 패턴을 사용하지 않거나 최소한으로 사용하는 거라고 생각합니다.

포인터 vs 참조형 타입

|

보통 C++ 개발자는 C 언어를 먼저 배우고 나서 C++을 배우는 경우가 많습니다.

C 언어에서 데이터 주소를 넘기는 방법은 포인터(Pointer)밖에 없습니다. 그러다보니 C++에서도 포인터로 데이터 주소를 넘기는 경우가 종종 있는데, 대부분의 경우는 포인터가 아닌 참조형(Reference) 타입으로 대체할 수 있습니다.

포인터대신 참조형 타입을 사용하면 좋은 점들은 다음과 같습니다.

참조형 타입의 장점

참조형 타입은 포인터보다 안전합니다.

왜냐하면 메모리 주소를 직접 다루지 않기 때문에 nullptr 같은 경우가 발생할 수가 없습니다.


코드 스타일이 좋아집니다.

‘*’ 이나 ‘&’와 같은 심볼을 사용하지 않아도 됩니다.

대신 함수 원형을 보지 않고 호출부만 봤을 경우는 매개변수가 값이 복사되서 넘어가는지 주소값만 넘어가는지 알 수 없는 단점이 있습니다. 항상 함수의 선언부를 봐야 알 수 있습니다.


메모리의 오너십(Ownership)이 어디에 있는지 명확하게 해준다.

누군가가 만든 함수에 다른 프로그래머가 참조형으로 객체를 넘겨줄 경우, 그 함수 안에서는 그 객체를 메모리에서 해제할 수 없습니다. 만약 포인터로 넘겨받았을 경우는 메모리 해제의 책임이 변수를 생성한 사람에게 있는지, 그 함수를 작성한 사람에게 있는지 명확하지 않습니다.


미세하지만 참조형 타입이 포인터보다 성능이 더 좋다.

큰 차이는 없습니다. 다만 참조형 타입은 포인터처럼 주소값 복사의 과정이 없기 때문에 성능면에서는 조금 더 좋습니다.

Inheritance vs Composition

|

개발을 하다보면 남발하는 것 중 하나가 ‘상속(Inheritance)’입니다. 여러 클래스가 있을 때 공통되는 부분을 부모 클래스로 만들어서 상속으로 구현하면 보기에도 좋아보이고 코드양도 줄어들고 뿌듯할 때가 있습니다.

하지만, 상속보다는 이양을 사용하는 편이 설계 측면에서는 훨씬 더 유리합니다. ‘이양’이란 부모/자식간의 관계처럼 서로 상속하는 관계가 아니라 인스턴스안에 다른 인스턴스를 품어서 그 역할을 전달하는 방법입니다.

상속과 이양은 ‘Inheritance vs Composition’으로 표현하기도 하며, ‘is-A vs has-A’로 표현하기도 합니다.

상속 관계는 부모와 자식간의 관계가 아주 밀접하게 결합합니다. 부모 클래스가 수정이 되면 자식 클래스에도 영향을 미칩니다. 상속의 예는 다음과 같습니다.


Inheritance 예제 코드

class Robot {
 public:
  void update() {
    move();
  }
  // ...
  
 private:
  virtual void move() = 0;
  // ...
};

class CleanerRobot : public Robot {
 private:
  virtual void move() override {
    clean();
    moveForward();
    // ...
  }
};

class CombatRobot : public Robot {
 private:
  virtual void move() override {
    attack();
    rotateLeft();
    // ...
  }
};


Composition 예제 코드

위 코드를 이양 관계로 바꾸면 아래와 같습니다.

class RobotBehavior {
 public:
  virtual ~RobotBehavior() {}

  virtual void move() = 0;
};

class Robot {
 public:
  Robot(RobotBehavior* behavior) {
    mBehavior(behavior);
  }

  ~Robot() {
    delete mBehavior;
  }

  void update() {
    mBehavior->move();
  }

 private:
  RobotBehavior* mBehavior;
};

class CleanerRobot : public RobotBehavior {
 public:
  virtual void move() override {
    clean();
    moveForward();
    // ...
  }
};

class CombatRobot : public RobotBehavior {
 private:
  virtual void move() override {
    attack();
    rotateLeft();
    // ...
  }
};

코드가 길어지고 조금 더 복잡해졌습니다. 하지만 RobotBehavior이라는 행동을 하는 추상 클래스가 생기면서 Robot 클래스와 나머지 다른 클래스들인 CleanerRobot, CompatRobot 클래스와의 결합이 끊어졌습니다. 따라서 Robot 클래스의 내용이 변경되더라도 다른 로봇 클래스들에게는 영향을 주지 않습니다.

물론, CleanerRobot, CompatRobot 클래스들은 RobotBehavior 클래스를 상속받기 때문에 RobotBehavior 클래스가 수정이 되면 상속받은 클래스들은 모두 변경이 됩니다. 하지만, RobotBehavior 클래스는 아주 추상적인 레벨의 행동 인터페이스만 정의되어 있기 때문에 향후 변경될 가능성은 많이 낮습니다. 만약 변수나 구현부가 포함이 된 경우에는 향후 변경될 가능성이 아주 높습니다. 따라서 RobotBehavior 클래스에는 최소한의 인터페이스만 정의되어 있어야 합니다.

또한, 상속은 컴파일시 생성이 됩니다. 하지만 이양의 경우는 실행중에도 동적으로 변경이 가능한 장점도 있습니다.

따라서 상속과 이양이 둘 다 가능한 경우에는 가급적 상속보다는 이양을 우선하는게 좋습니다.

함수의 기본 원칙

|

함수화를 잘 하는 것은 유지 보수성이 높은 코드를 만드는 첫 번째 단계라고 볼 수 있습니다. 경험이 중요하지만 기본적인 원칙을 따르며 함수를 작성해가면 새로운 언어 등에서도 유연하게 개발할 수 있습니다.


함수의 기본 원칙

  • 하나의 함수는 하나의 역할만 담당한다.
  • 함수를 두 종류로 분류한다.

하나의 함수가 하나의 역할만 담당하는 것은 상당히 쉽지만 많은 사람들이 실수하는 부분입니다. 함수가 여러 가지 기능을 담당하게되면, 함수의 이름도 복잡해지고 매개변수의 처리나 코드의 복잡성도 높아지게 됩니다.

함수는 크게 두 종류로 분류할 수 있습니다. 각종 연산이나 알고리즘 수행을 행하는 함수와 이런 함수들을 조합해서 관리하는 함수들로 나눌 수 있습니다.

예를 들어 다음의 함수는 게임 등에서 자주 쓰이는 update 함수 예제입니다.

void update(long msec) {
  for (iter i = actor.begint(); i != actor.end(); i++) {
    (*i)->move(msec);
  }

  collide();
}

사물의 이동과 충돌 판정 계산이 같이 포함되어 있는데, 어딘가 부자연스럽습니다. 사물의 이동은 실제 연산 부분이 구현되어 있고, 충돌 판정 부분은 함수 호출로 되어 있어 서로 코드의 레벨이 다르기 때문입니다.

따라서 다음과 같이 코드를 수정할 수 있습니다.

void move(long msec) {
  for (iter i = actors.begint(); i != actors.end(); i++) {
    (*i)->move(msec);
  }
}

void update(long msec) {
  move(msec);
  
  collide();
}


함수화 패턴

함수화를 하는데에는 보통 다음과 같은 패턴이 있습니다. 일일이 따를 필요는 없지만 대략 다음과 같은 패턴으로 함수화를 하면 유지 보수성이 높은 코드를 쉽게 작성할 수 있습니다.

  • 조건식 함수화
  • 계산식 함수화
  • 분기문의 블록 내부 함수화
  • 반복문 함수화
  • 데이터 변환 함수화
  • 데이터 확인 함수화
  • 배열 접근 함수화


조건식 함수화

if 조건문의 조건식을 함수화하는 방법입니다.

if((speed > 100) && (posY > 250)) {
  // ...
}

위 코드는 다음과 같은 형태로 바꿀 수 있습니다.

if(isJump()) {
  // ...
}


계산식 함수화

계산식을 함수화하면 그 함수에 이름을 붙이기 쉽습니다. 그 의미를 명확하게 할 뿐 아니라 그 함수를 호출하는 쪽의 코드 분량이 줄어들어 가독성또한 좋아집니다.

double getDistance(double x1, double y1, double x2, double y2) {
  return std::sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
}

예를 들어, 위와 같은 함수를 작성하면 호출하는 쪽은 단지

double distance = getDistance(x1, y1, x2, y2);

와 같은 간단한 호출만으로 원하는 값을 얻을 수 있습니다.


반복문 함수화

다음 코드는 2개의 반복문을 갖고 있습니다.

void update(long msec) {
  for (iter i = actors.begin(); i != actors.end(); i++) {
    (*i)->update(msec);
  }

  for (iter i = actors.begin(); i != actors.end(); i++) {
    for (iter j = std::next(i); j != actors.end(); j++) {
      (*i)->collide(*j);
    }
  }
}

각각의 반복문들을 다음과 같이 함수화합니다.

void move(long msec) {
  for (iter i = actors.begin(); i != actors.end(); i++) {
    (*i)->update(msec);
  }
}

void collide() {
  for (iter i = actors.begin(); i != actors.end(); i++) {
    for (iter j = std::next(i); j != actors.end(); j++) {
      (*i)->collide(*j);
    }
  }
}

void update(long msec) {
  move(msec);

  collide();
}

이렇게 각각을 함수로 분리함으로써 각 함수는 역할이 분명해지고 가독성도 훨씬 더 좋아졌습니다.

이렇게 분리한 반복문들은 STL을 이용해 더 간략화할 수 있습니다.

void move(long msec) {
  std::for_each(actors.begin(), actors.end(),
                [&](Actor* actor) { actor->update(msec); });
}


작은 함수의 필요성

함수화 패턴에 따라 코드를 함수화해나가면 함수 단위가 아주 작아집니다. 작은 함수는 다음과 같은 점에서 유리한 점을 가집니다.

  • 함수의 이름만으로 설명이 쉽게 된다.
  • 함수 개별 테스트(Unit Test)가 쉬워진다.
  • 함수 재활용성이 강화된다.

함수를 많이 쪼갤수록 성능 저하가 발생할 수 있습니다. 실제로도 성능 저하가 발생할 수는 있지만, 요즘의 대부분의 컴파일러들은 최적화 기능을 통해 이러한 문제점들을 많이 극복하고 있습니다.

예를 들어 Release 모드에서는 아주 작은 함수들은 인라인(inline) 함수로 대체해버려서 함수 호출로 인한 오버헤드(Overhead)가 전혀 없어지기도 합니다. 비주얼 스튜디오 같은 경우는 ‘링크시 코드 생성(LTCG)’ 기능이 있어서 컴파일(Compile) 단계가 아닌 링크(Link) 단계에서 함수를 인라인화하는 옵션도 있습니다. 이는 프로그램 전체의 함수가 최적화되기 때문에 다른 파일에 있는 함수들까지 인라인화되는 옵션입니다.