본문 바로가기

Library/C/C++

일반화된 데이터 컬렉션

일반화된 데이터 컬렉션을 만들 수는 없을까? 예를 들어, 프로세스 사이에서 어떤 데이터를 공유해야 하는데, 데이터가 하나가 아니라 여러개 묶음이라 해보자. 그렇다면, IPC를 사용해서 일일이 이 데이터를 옮겨야 하는데, 이것은 번거로운 일이다. 그래서 PushInt, GetInt, MakeField..와 같은 메서드를 가진 컬렉션을 만들게된다. 하지만, 이런 방법은 타입 안전성을 가질 수 있는 반면, 이미 서로간에 알려진 타입이어야 한다는 문제가 있다. 즉, 보통의 경우에는 다음과 같이 된다는 이야기인데..

Collection col;
col.PushInt(id, 1);
col.GetInt(id);

이런 방법 대신에 템플릿을 이용해서 다음과 같이 할 수 없을까?

Colleciton col;
col.PushData< int >(id, 1);
col.GetData< int >(id);

이렇게 처리하기 위해서는 다음과 같은 문제가 있다. 첫번째로, 동적으로 타입을 판별하고 그에 맞는 데이터를 찾아주는 메카니즘이 필요하다. 그런 메카니즘과 컨테이너를 제작할 수 있다면, 이런 코드는 불가능한 것은 아니다.

문제는, 그런 컨테이너는 존재하지 않는다는 것이다. 벡터를 사용한다고 해도, 벡터는 동일한 타입에 대해 연속적인 메모리 공간을 확보해줄 뿐이며, 이런 컬렉션 구조는 여러개의, 컴파일 시간에는 알 수 없는 타입에 대한 정보는 알 수 없다. 그렇다면, 벡터에 직접 타입 정보를 넣는 것이 아니라, 해당 타입을 가지는 또 다른 컨테이너에 대한 포인터를 넣으면 어떠한가? 즉,

std::vector< void* > collection;
std::map< std::string, Type > ...;

이와 같은 구조를 생각해 볼 수 있는데, 이것 또한 문제가 있다. 클래스 내에서 템플릿 메서드를 사용할 수 있지만, 정작 해당 메서드 안에서 std::map을 사용하여 collection에 포인터를 추가한다고 해보자. 여기까지는 아무런 문제가 없다. 코드를 쓴다면,

template< typename T >
.... ::PushData(const char* id, T value)
{
    std::map< std::string, T > map;
    map.insert(std::make_pair(std::string(id), value));
    collection.push_back(&map);
}

그렇지만, 컨테이너에 어떤 것을 삽입하기 전에, 동일한 타입의 컨테이너가 collection 벡터에 이미 존재하는지 확인해보는 절차가 필요하다. 문제는 여기다. void* 포인터만 가지고서는, 지금 추가해주려는 타입의 컨테이너가 존재하는지 파악할 방법이 없다. 물론, dynamic_cast를 사용한다면 문제는 해결될지도 모르지만, RTTI를 사용하기 위해서는 해당 클래스가 가상메서드를 가져야 하기 때문에, 기본 타입에 대해서는 무용지물이라는 문제점이 존재한다. 대안으로 typeid 연산자를 생각해볼 수도 있지만, 이 또한 void* 타입에 대해서는 무력할 뿐이다. 다음 코드를 보자.

for(collection_itr = collection.begin();
    collection_itr != collection.end();
    collection_itr++)
{
    if(typeid(*collection_itr) == typeid(std::map< std::string, T >*))
    {
        ....
    }
}

collection_itr을 사용하여 벡터를 순회하면서 T 타입을 가진 맵과 동일한 타입인지 순회하는데, void* 타입과 비교하는 것은 정상적인 타입 비교를 할 수 없다. 또, PushData와 같은 메서드를 통해서만 파악할 수 있는 T 타입에 대한 정보는 애초에 벡터를 선언할 때 알 수 없는 내용이기 때문에, 결론적으로 이런 방식의 컬렉션을 제작하는 것은 가능하지 않다는 결론이 나온다.


그렇다면, 일반화된 컬렉션 제작은 불가능한 것일까? 그렇지 않다. 일반화된 데이터 컬렉션을 제작하는 방법은 세가지 정도가 있을 수 있다. 하나는 템플릿 특수화를 통해 예상 가능한 타입에 대한 컨테이너를 추가하고 그에 맞는 Set / Get 메서드를 특수화하는 것이고, 다른 하나는 타입의 안전성을 희생하여 일반화된 컬렉션을 만드는 것이다. 그리고 마지막 방법은, 클래스 상속을 통해 타입 문제를 해결하는 방법이다.

템플릿 특수화로 이 문제에 대응하는 것은 Brute-Force 접근이다. 그에 맞는 컨테이너와 특수화 코드를 추가해야 하는 막대한 노동력이 소모되지만, 타입의 안전성은 확실하다. 반면, 해당 컨테이너를 가져야 하기 때문에 메모리 사용량이 늘어나며, 템플릿 코드의 크기가 커지는 코드 블러팅 현상을 발생할 수 있다.

다른 방법은, 템플릿 인자로 들어오는 타입은 캐스팅하는데 사용하고, 해당 타입에 맞는 메모리를 힙에서 할당받은 뒤 그 포인터를 컨테이너에 저장한다. 이 포인터만 관리하면 되기 때문에 필요한 컨테이너는 하나 뿐이다. 그렇지만, 이 데이터를 가져올 때 사용자가 요청한 타입이 맞는지 틀린지는 전적으로 사용자의 책임이다. 이 방법 또한 타입의 안전성을 확보할 수 없기 때문에 좋은 해결 방법은 아니다.

그리고, 마지막 방법으로, 클래스 상속을 통해 타입 문제를 해결하는 방법이 있다. 위에서, 가장 문제가 되는 것은 실제적으로 데이터를 보관하는 std::map과 같은 것은 실행 시간에 타입 정보를 얻어오는 반면, 이 std::map을 저장해야 하는 컬렉션은 어떤 타입의 std::map을 저장해야 하는지에 대한 정보를 모른다는 것이다. RTTI를 사용하기 위해서는 가상 메서드를 가진 클래스가 존재해야 하는데, 즉 기초 클래스를 하나 만들고, 이것을 상속받는 실제적인 타입 정보를 가지는 클래스에서 데이터 입출력을 처리하는 방법으로 제작하는 것이 가능하다.

class Base
{
    virtual ~Base();
};

template< typename T >
class Map : public Base
{
public:
    ~Map();
    template< typename T > PushData(const char* id, T value);
    ....
};

Base와 Map은 컬렉션 클래스의 내포 클래스(netsted class)다. 즉, 최종적으로 컬렉션 클래스에서 가지는 벡터는 다음과 같다. 특히, Base의 소멸자가 가상함수로 선언된 것이 중요한데, 기초 클래스가 가상으로 소멸되어야 실제적인 파생 클래스의 소멸자까지 모두 호출될 수 있다. 즉, 템플릿 인자에 대한 정보를 가지고 있지 않은 메서드에서는(예를 들면, 이 로컬 클래스를 가지는 호스트 클래스에서의 소멸자) Map 클래스에 대한 인스턴스를 얻어낼 수 없다. 즉, 뒷 정리 작업을 위한 소멸자 호출을 위해서, 정상적으로 파생 클래스의 소멸자까지 호출될 수 있도록 기초 클래스의 소멸자는 반드시 가상 함수로 선언되어야 한다.

std::vector< Base* > collection;

이렇게 되면, 컬렉션 클래스에서 데이터 입출력 함수를 작성할 때, dynamic_cast의 사용이 가능해진다. 즉, 다음과 같은 코드가 가능해진다는 것이다.

template< typename T >
bool Collection::PushData(const char* id, T value)
{
    COLLECTIONITR collection_itr;
    for(collection_itr = collection.begin();
        collection_itr != collection.end();
        collection_itr++)
    {
        Map< T >* map = dynamic_cast< Map< T >* >(*collection_itr);
        if(map != 0)
            map->PushData(id, value);

            ....
    }
    ....
}

이것은 타입 안전성을 가질 수 있으면서, 컨테이너의 수를 최소로 유지할 수 있는 가장 좋은 방법이지만, 가상 메서드의 추가로 속도의 손실을 감수해야 한다. 이런 방식으로 구현되었다면, 빠른 속도로 데이터를 검색하는데는 적당하지 않은 방법이다. 여기에 캐시와 같은 장치를 도입해서 이런 단점을 보완해야 한다. 어떤 데이터를 검색하고자 할 때, 일단 벡터를 뒤지면서 원하는 데이터 타입과 맞는 map을 찾고, 다시 여기서 해당 키를 찾아야 한다. 보통, 이런 식의 데이터를 얻어오는 것은 동일한 데이터 타입을 연속적으로 얻어오는 경우가 많다. 이럴 경우, 어떤 map에서 원하는 데이터를 찾았다면, 이 map에서 연속적으로 데이터를 요청할 가능성이 많다. 따라서, 캐시를 도입한다고 하면, 최종적으로 검색이 성공했을 때 발견한 map에 대한 포인터를 캐시로 유지하고, 다음에 다시 검색 요청이 들어왔을 때 먼저 이 map을 검색하고, 여기서 원하는 데이터가 발견되지 않았을 때 벡터를 탐색한다면 검색 성능에 있어서 상당한 효과를 볼 수 있을 것이다.

그리고, 이것은 데이터의 삽입 과정에도 동일한 논리가 적용될 수 있다. 데이터 삽입이 일어날 때는, 보통 동일한 데이터 타입을 연속적으로 넣는 경우가 많기 때문에, 위와 같은 전략이 효과적일 수 있다.


그렇지만, 위의 방법 중 어떤 것이라도 완벽한 해결책은 아니며, 모두 성능, 메모리 사용량, 작업량과 같은 문제에 있어서 트레이드 오프 관계에 있다고 할 수 있다.