Flexible한 S/W 작성하기

Posted at 2007. 1. 27. 14:50 // in S/W개발 // by 김윤수


hard coding 이라는 주제를 가지고 이야기를 풀어가다 보니 생각지 못하게 글이 길어져서 나누어서 글을 쓰게 됐습니다. 그럼 계속해서 hard coding을 피하는 방법을 주제로 글을 풀어갈까 합니다. 혹시 이전글을 보고 싶으신 분은 여기를 누르시기 바랍니다.

이전 글의 Media Player 예를 되새겨 보시죠. invokeDecoder() 안에서 switch 문의 case로 각 개별 컨텐트 포맷에 맞는 decoder를 처리하던 로직을 테이블을 이용한 linear search 방식으로 바꾸어 flexibility를 확보했던 것을 기억하실 겁니다.

Procedural Language 인 C 로 코딩을 할 경우엔 위와 같은 방식으로 flexibility를 확보할 수 있을 것입니다. 그렇지만 Object-oriented paradigm 도 제공하는 C++에서는 언어 자체에서 이런 flexibility를 확보할 수 있는 방법을 제공하고 있습니다. 바로 virtual method 입니다. virtual method 는 아주 low-level에서 본다면 compiler 가 자동 생성해 주는 function table 입니다. 저희가 앞 글에서 사용했던 기법도 결국 function table 이었구요. 저는 C 에서 function table을 이용해 flexibility를 확보했던 방법이 C++의 virtual method 로 이어졌다고 이해하고 있습니다.

여기서 언어의 역사를 다루는 건 제 지식의 범위를 한참 뛰어 넘는 일이니 이쯤에서 각설하구요. 만약 C++를 이용해서 우리 Media Player가 컨텐트 포맷에 대해 flexibile 하도록 만들려면 어떻게 해야 할까요 ? 음... 저라면 아래와 같이 해 보겠습니다-더 좋은 생각이 있으시다거나 아래 코드에 문제가 있다면 좀 알려 주세요. 아주 심사숙고해서 작성한 코드는 아니라서 문제가 있을 수도 있겠습니다.

/* Decoder.h */
#include <map>                                           // STL의 map을 쓰기 위해 include
#include <string>                                         // STL의 string을 쓰기 위해 include

using namespace std;

// base abstract class: 여러 인스턴스가 생성될 필요가 없으므로 singleton
class Decoder {
public:
  virtual int decode(const string &contentFile, Channel* pChannel) = 0;
                                                                 // 실제로 decoding을 수행합니다
  virtual string getDecodableType() = 0;           // 이 Decoder 가 처리할 수 있는 mime type을
                                                                 // 리턴합니다.
  static Decoder* theInstance();
};

extern std::map<string,Decoder*> decoderMap;

/* XXXDecoder.h */
#include "Decoder.h"

class XXXDecoder: public Decoder {
public:
  virtual int decode(const string& contentFile, Channel& rChannel);
  virtual string getDecodableType()
  {
    return string("xxxx/yyyy");
  }
  static XXXDecoder* theInstance();

private:
  XXXDecoder() {};                                        // singleton 이므로 생성자와 소멸자는
  virtual ~XXXDecoder() {};                             // private 으로 선언
};

/* XXXDecoder.c */
#include "XXXDecoder.h"

int XXXDecoder::decoder(const string& contentFile, Channel& rChannel)
{
  ...                       /* decoding logic 정의 */
  return 0;
}

XXXDecoder* XXXDecoder::theInstance()
{
  static XXXDecoder decoder;                      // static 변수에 대해서 최초 함수가 호출될 때
                                                                // 생성자가 한 번만 호출되는 게 보장됩니다
  return &decoder;
}

/* CustomizedDecoderMap.c --> 사용자가 customize할 수 있는 소스로 문서에
 * 명시합니다.
 */
#include "Decoder.h"
#include "JpegDecoder.h"
#include "Mp3Decoder.h"
...                                          // 기타 다른 decoder class include

std::map<std::string,Decoder*> decoderMap;  // string 에 대해서 less<string> 이 정의되어
                                                                  // 있으므로 따로 정의하지 않습니다.

int initializeDecoderMap()
{
  Decoder* decoder;

  decoder = JpegDecoder::theInstance();
  decoderMap[decoder->getDecodableType()] = decoder;
  decoder = Mp3Decoder::theInstance();
  decoderMap[decoder->getDecodableType()] = decoder;
  ...                                       // 기타 다른 decoder instance 등록
}

function table을 쓰는 C 코드와 거의 비슷하게 작성되었습니다. 그렇지만 이번에는 mime type과 decode function 간의 mapping 이 table 이 아니라 map 으로 관리된다는 점이 다릅니다.그럼 invokeDecoder() 는 어떻게 바뀌게 될까요 ?

#include "Decoder.h"

int invokeDecoder(const string& contentFile, Channel& rChannel)
{
  string contentFormat = getContentType(contentFile);
  Decoder* decoder;
  try {
    decoder = decoderMap[contentFormat];
  }
  catch ...                                 // contentFormat 이 지원되지 않을 때 exception 처리

  return decoder->decode(contentFile, rChannel);
}

이제 searchDecoder() 도 필요가 없게 됐네요. map 이 알아서 해주니. C++의 virtual method 와 map template class를 이용해서 효과적으로 flexibility를 확보하고 있습니다.

그런데 여기서 더 나가서 아예 새로운 컨텐트 포맷을 지원하려고 할 때, 소스는 전혀 안 고치고, 컴파일하는 과정 없이 환경 설정 파일만 바꿔도 되는 식으로 할 수는 없을까요 ? 당연히 있습니다.

dynamic loadable library를 이용하면, 환경 설정 파일만 바꿔도 새로운 컨텐트 포맷을 지원하게 할수 있습니다. 단, 이 방법은 dynamic loadable library를 지원하는 OS에서만 써 먹을 수 있는 방법입니다. 아래의 예는 Linux 에서 작성한 예입니다. 다른 OS 에서는 dlopen(), dlsym() 같은 API가 다른 걸로 바뀌어야 합니다.

XXXDecoder.c 파일을 다음과 같이 수정합니다.

/* XXXDecoder.c */
#include "XXXDecoder.h"

int XXXDecoder::decoder(const string& contentFile, Channel& rChannel)
{
  ...                       /* decoding logic 정의 */
  return 0;
}

XXXDecoder* XXXDecoder::theInstance()
{
  static XXXDecoder decoder;                      // static 변수에 대해서 최초 함수가 호출될 때
                                                                // 생성자가 한 번만 호출되는 게 보장됩니다
  return &decoder;
}

// 나중에 dlsym() 함수에서 getXXXDecoder() 라는 symbol을 찾을 수 있도록
// extern "C" 로 선언합니다.

extern "C" XXXDecoder* getXXXDecoder()
{
  return XXXDecoder::theInstance();
}

그리고 이 코드를 컴파일할 때 g++ 같으면 -fPIC 옵션을 주고, 마지막에 library로 만들 때, shared library(libXXXDecoder.so)로 만듭니다. shared library로 만들고 싶을 때는 g++에 -shared 옵션을주면 됩니다. 그리고서는 환경 설정 파일에서는 다음과 같이 명시합니다.

decoders = libJpegDecoder.so libMp3Decoder.so libMpeg1Decoder.so ...

그리고, 마지막으로 initializeDecoderMap()은 다음과 같이 바꿉니다.

#include <dlfcn.h>
#include <vector>
#include <algorithm>
#include "Decoder.h"                                    // concrete decoder class를 include할 필요가
                                                                  // 없어졌습니다.
#include

std::map<std::string,Decoder*> decoderMap;  // string 에 대해서 less<string> 이 정의되어
                                                                  // 있으므로 따로 정의하지 않습니다.

typedef Decoder* (*GetDecoderFunc)(void);

// function object --> 어차피 operator () 만 정의할 것이라
// struct 로 많이 정의들 하더군요
struct RegisterDecoder {
  // 간결성을 위해 에러/예외 처리 코드 생략
  void operator () (string decoderlibfile)
  {
     // 1. decoderlibfile을 dlopen() 으로 open
     void* handle = dlopen(decoderlibfile.c_str(), RTLD_NOW);
     // 2. dlsym() 으로 getXXXDecoder symbol 을 찾아냄.
     // XXX는 library 명으로부터 알아냅니다.
     GetDecoderFunc getDecoder = (GetDecoderFunc)dlsym(handle, "getXXXDecoder");
     // 3. getDecoder 를 호출해서 decoder instance를 알아냄
     Decoder* decoder = getDecoder();
     // 4. 마지막으로 decoderMap 에 등록
     decoderMap[decoder->getDecodableType()] = decoder;
  }
};

int initializeDecoderMap()
{
  vector<string>* pvstr = getDecoders();     // 환경 설정 파일에서 decoder 목록을 읽어들여서
                                                             // vector<string> 으로 리턴해 주는 함수라고 가정
  // STL의 foreach algorithm 사용
  for_each(pvstr->begin(), pvstr->end(), RegisterDecoder());
}





이제 위 코드를 쭉 보시면 알겠지만, concrete decoder class에 dependent한 코드는 하나도 없습니다. 새로운 컨텐트 포맷을 지원하기 위해 필요한 일은 컨텐트 포맷을 지원하는 decoder class 자체를 작성하고, 그 implementation 안에 XXXDecoder* getXXXDecoder() 함수만 정의해 두면 그만입니다. 그리고 나서는 decoders 라는 환경 설정 변수만 바꿔 주면 됩니다.

어떠십니까 ? 이제 우리 제품의 고객은 새로운 컨텐트 포맷을 제공하기 위해 customize 할 소스를 수정하고 컴파일도 할 필요가 없어졌습니다. 그 덕분에 기존에는 우리의 고객은 이전에는 소스코드를 고치고 컴파일할 수 있어야 하는 사람이어야 했는데, 이제는 개발자가 아닌 일반 고급 사용자도 우리의 고객이 될 수 있게 되었습니다. 멋지죠 ? 그렇게 크지 않은 수정으로 수많은 고객을 잠재 고객으로 만들 수 있게 됐으니까요.

그렇지만, 잠깐 다시 생각해 보시면 어디서 듣도 보도 못한 decoder 가 설치돼서 그 안의 버그 또는 악의적인 코드 때문에 여러분의 Media Player 자체가 오동작할 가능성이 있습니다. 쉽게 확장 가능하게 한 경우에는 그걸 어떻게 막을 것인가가 큰 문제가 되기 마련입니다. 게다가 dynamic loadable library를 제공하지 않는 OS에서는 쓸수가 없으니 저희 Media Player의 portability에도 약간 문제가 생겼네요. no free lunch 인 셈이죠. 그런 뭐하려고 장황하게 얘기를 풀어 놓았냐구요 ? 알고 있다면 위와 같은 단점이 문제가 되지 않는 경우에 쓸 수가 있으테니까요.

요즘은-꼭 요즘도 아니죠-hard coding을 여기까지만 생각하는 게 아니라 type 자체에 대한 hard coding 도 생각을 많이 하는 것 같습니다. sorting 알고리즘 중에 가장 성능이 좋다고 알려진 것이 quick sort 라는 건 대충 알고 계실 겁니다. 이 sorting 이라는 연산을 생각해 보면, 이 연산 자체는 특정 데이터 타입과 관계가 없다는 것을 느끼실 수 있습니다. sorting 이라는 건 순서를 정의할 수 있는 임의의 데이터 타입에는 모두 적용될 수 있는 연산입니다. integer, double, string 등은 모두 순서를 정의할 수 있는 데이터 타입이므로 당연히 적용할 수 있을 겁니다. 그렇다면 사용자 정의형 데이터 타입에도 sorting을 정의할 수 있을까요 ? 물론입니다. 사용자 정의형 타입에 대해 사용자가 순서의 의미를 정의해 놓으면 sorting을 할 수가 있습니다. C 에서는 quick sort 알고리즘을 여러 사용자 타입에 대해 적용할 수 있도록 다음과 같이 정의해 놓고 있습니다.

#include <stdlib.h>

void  qsort(void  *base,  size_t  nel,  size_t  width,   int
     (*compar)(const void *, const void *));

base는 데이터를 가지고 있는 배열의 주소이고 nel 은 배열에 포함된 데이터 요소의 개수 width는 각 데이터 요소의 크기입니다. 마지막으로 compar 은 사용자가 제공해야 하는 데이터 요소를 비교하는 함수입니다. compar 는 두 개의 데이터 요소를 인자로 받아서 첫번째 인자가 크면 양수, 작으면 음수, 같으면 0을 리턴해야 합니다.

strong typing language 입장에서 생각해 보면 위의 코드는 정말 위험하기 짝이 없습니다. 수많은 void * 가 난무하지 않습니까 ? void *는 C 가 strong typing language 이면서도 weak typing language 로 만드는 주범이기도 했습니다. strong typing language 의 가장 큰 장점이라면 compile 시에 type 관련된 에러를 잡아낼 수 있다는 것이었는데, 이 큰 장점을 애써 피해가면서 compile time 에러를 run-time 에러로 바꿔버리는 아주 못된 놈이었던 거죠. 그래서 C++ 를 설계한 사람은 이런 문제를 피하면서도 type 에 대한 flexibility를 확보하기를 원했던 것 같습니다. (이런 문제아의 또 하나는 type casting 입니다. type casting의 특기는 컴파일러 속이기입니다. 일종의 S/W 세계의 사기꾼인 셈이죠)

그래서 태어난 것이 바로 C++의 template 입니다.

여러분이 현재 여러 사용자 데이터 타입에 대해 비슷한 작업을 수행해야 하는 소프트웨어를 개발하고 있다고 생각해 보시죠. 예를 들면, 사용자 데이터 타입에 대해 정렬을 한다던지, 어떤 값을 검색을 한다던지, 최대/최소값을 구한다던지 하는 거요. 이런 경우 작업을 하다 보면 자신이 너무나 똑같은 작업을 반복해서 하고 있다는 걸 느끼게 됩니다. 그러면서 이 반복 작업 하는 걸 어떻게 피할 수는 없을까라는 생각을 하게 됩니다. 예를 들어, 배열에서 최대/최소값을 구하는 경우를 생각해 보죠. 우선, int 타입에 대해

int int_max(int a[], size_t nel)
{
  // nel 이 0, 1인 경우를 처리
  int max = a[0];
  for (int i = 1; i < nel; i++)
  {
    if (a[i] > max)
      max = a[i];
  }
  return max;
}

그 다음은 double 타입에 대해

double double_max(double a[], size_t nel)
{
  // nel 이 0, 1인 경우를 처리
  double max = a[0];
  for (int i = 1; i < nel; i++)
  {
    if (a[i] > max)
      max = a[i];
  }
  return max;
}

이렇게 두 개쯤 작성해 놓고 보니, max의 데이터 타입과 a[]의 데이터 타입이 int 와 double 인 것 빼고는 완전히 동일하다는 걸 느끼실 겁니다. double double_max( 이 부분쯤 타이핑을 하다가 막 짜증나기 시작하지 않습니까 ? 이런 걸 한 10개쯤 작성하다보면 내가 이러면서 살아야 하나? 인생에 대한 고민에 빠질 수도 있습니다. 이쯤되면 S/W 개발자들 특유의 게으름이 발동되면서 이걸 어떻게 반복하지 않고, 손의 피로를 덜 수 없을까를 고민하게 될 것입니다. 아주 *건전한* 게으름입니다.

바로 이런 경우를 처리하기 위해 C++ template이 고안되었다고 생각하시면 됩니다. 위 두 개의 알고리즘을 보면 다른 건 데이터 타입 밖에 없고, 그 데이터 타입에 대해 copy constructor, assignment, 및 greater than 비교만 가능하면 max 값을 구하는데 충분한 조건이 된다는 걸 알 수 있습니다. 데이터 타입에 상관 없이 그 개념을 코드로 작성하게 되면 template function이 되는 것입니다. 그래서 generic programming 을 programming by concept 이라고도 한다는 군요. max의 template 버전은 다음과 같이 작성할 수 있겠죠.

template <typename T>             // T 가 typename임을 나타냄

T max(T a[], size_t nel)
{
  // 여기서 nel 이 0, 1인 경우를 처리 --> 간결성을 위해 생략
  T max = a[0];                        // copy constructor 가 필요함
  for (int i = 1; i < nel; i++)
  {
    if (a[i] > max)                    // greater than 이 필요함
      max = a[i];                       // assignment 가 필요함
  }
  return max;
}

이제 T 라는 타입이 copy constructor와 assignment 와 greater than 을 지원하기만 하면 max 라는 알고리즘을 쓸 수 있게 됐습니다. 아참! 그것 뿐만이 아니네요. 잘 눈에 띄진 않지만 T type의 a 라는 배열이 말 그대로 배열이어야 하네요. 배열의 특성 중 a[i]와 같이 random access 가 가능해야 하는 특성을 사용하고 있네요. 그런데, 조금만 더 생각해 보면 max 라는 알고리즘이 꼭 배열에만 가능해야 하는 것이냐 ? 라는 생각이 듭니다. 이걸 좀 더 넓게 사용할 방법은 없을까요 ? 예를 들어, 사용자 데이터 타입의 singly linked list 같은 곳에요. 우선 max 알고리즘을 이렇게 바꿔 볼까요 ?

template <typename T, typename Iter>
T max(Iter start, Iter end)
{
  // start == end 인 경우를 에러 처리
  T max = *start;                       // T에 대해 copy constructor 가 필요함
  for (Iter i = start; i != end; ++i)   // Iter 에 대해 copy constructor, !=, prefix ++ 가 필요함
                                             // prefix ++는 다음 element를 가리켜야 함
  {
    if (*i > max)                        // T에 대해 greater than 이 필요함. Iter 의 * 연산자는
                                             // T와 호환되는 type을 리턴해야 함
      max = *i;                           // T에 대해 assignment 가 필요함
  }
  return max;
}

위와 같은 max 알고리즘에서 중요한 것은 Iter 타입은 * 연산자를 지원하되 그 리턴 타입은 T 타입이 되어야 하구요-예를 들어, T는 int, Iter 타입은 int * 인 경우-T타입은 여전히 copy constructor, greater than, assignment를 지원해야 합니다. 그리고 Iter 타입은 copy constructor, !=, prefix ++ 가 필요한 것을 알 수 있습니다.

위와 같이 할 경우 일단 max는 이전과 같이 int 배열 double 배열에 대해서 다음과 같이 사용할 수 있습니다.

int ia[XXX];
double da[YYY];

int imax = max<int>(&ia, &ia + XXX);
double dmax = max<double>(&da, &da + YYY);

그리고, 여러분이 List 라는 template을 다음과 같이 작성하면 max 알고리즘은 List 타입에 대해서도 쓸 수 있게 될 것입니다.

// 하나 하나의 요소를 나타내는 클래스
template <typename T>
struct ListElem {
  T m_val;
  ListElem<T>* next;
};

// List를 iteration 하는데 필요한 클래스
template <typename T>
class ListIter {
private:
  ListElem<T>* m_elem;

public:
  ListIter<T>(ListElem<T> elem = 0): m_elem(elem) {}   // copy constructor
  T operator * ()                                                       // operator *
  {
    return m_elem->m_val;
  }

  ListIter<T>& operator ++()                                       // operator prefix ++
  {
    m_elem = m_elem->next;
    return *this;
  }

  bool operator != (const ListIter<T>& other)               // operator !=
  {
    return (m_elem != other.m_elem);
  }
};

// singly linked list
template <typename T>

struct List {
  ListElem<T>* m_first;
  ListElem<T>* m_last;
  ListIter<T> begin()
  {
    return ListIterr(m_first);
  }

  ListIter<T> end()
  {
    return ListIter(m_last->next);
  }

  void push_back(T val)
  {
    ListElem<T>* t = new ListElem<T>;
    t->m_val = val;
    t->next = 0;
    if (m_first == 0 && m_last == 0)
    {
      m_first = t;
      m_last = t;
    }
    else
    {
      m_last->next = t;
      m_last = t;
    }
  }
};

만약 T 가 long 타입이라면 다음과 같이 쓸 수 있겠죠.

List<long> ll;

// ll 에 long 데이터를 채워 넣습니다.
ll.push_back(10);
ll.push_back(38);
......
long lmax = max<long>(ll.begin(), ll.end());

max 에서 가정하고 있는 조건-a 가 배열이어야 한다는 조건-을 완화시켜서 max가 List<T> 타입에 대해서도 작동할 수 있도록 했습니다.

이렇게 만들어진 max 는 여러분이 직접 정의한 데이터 타입 중 max 가 가정하고 있는 개념-또는 조건-을 만족하는 것들에 대해서는 공히 사용할 수가 있게 됩니다. 이리하여 데이터 타입을 hard coding 하지 않고, 여러 데이터 타입에 대해 작동할 수 있는 프로그램을 작성했습니다. 더 이상 똑 같은 알고리즘을 서로 다른 데이터 타입에 대해 반복해서 작성할 필요가 없어졌습니다. 이제부터는 여러분이 작성하는 알고리즘의 개념이 데이터 타입에 관계없이 충분히 generic 하다면, template 에 한 번 들이대 보십시오. 그리고선 여러 데이터 타입에 재사용하십시오. 프로그래밍의 기쁨을 누리실 수 있을 것입니다. 여러분의 생산성은 극적으로 좋아질 수 있을테구요. 향상된 생산성으로 일찍 퇴근해서 여가를 즐길지, 남들일까지 뺏아가며 일을 열심히 할지, 보스 눈에 들어서 인정을 받을지는 여러분 자유입니다.

그런데, 한 가지 안타까운 점이 있다면 embedded system 용 C++ 컴파일러 중에는 아직도 template 을 지원하지 않거나, 불완전하게 지원하는 것들이 있다는 겁니다. 이미 컴파일러가 정해져서 빼도 박도 못한다면 어쩔 수 없지만, 여러분이 컴파일러를 정하는 데 조금이라도 영향력을 끼칠 수 있다면 결정권자에게 template 을 지원하는 컴파일러를 찾아보자고 제안해 보세요. *생산성*이 무척 좋아질 수 있다고 하면, 그냥 무시할 수는 없을 것입니다. 여러분의 제안을 무시하는 결정권자가 있다면 여러분도 *무시*하시기 바랍니다.

이상으로 hard coding 과 flexibility 에 대한 기나긴 장정을 마칠 때가 왔네요. 다른 건 다 잊으셔도 좋은데 다음 구절 만큼은 꼭 기억하시기 바랍니다.

"앞으로 변경될 수 있다고 예상되는 것들에 대해서 쉽게 수용할 수 있도록 S/W를 개발하자"

위 구절을 기억하신다면 밤 늦게까지 배고픔을 참아가며 글을 쓴 보람이 있겠습니다.

소프트웨어 관련된 저의 다른 글들도 참고로 읽어 보세요.

소프트웨어는 soft 해야 제 맛이다
Flexible한 S/W 작성하기
소스코드 복사의 위험성
C++ 이야기 첫번째: auto_ptr 템플릿 클래스 소개
C++ 이야기 두번째: auto_ptr의 두 얼굴
C++ 이야기 세번째: new와 delete
C++ 이야기 네번째: boost::shared_ptr 소개
C++ 이야기 다섯번째: 내 객체 복사하지마!
C++ 이야기 여섯번째: 기본기 다지기(bool타입에 관하여)