2006년 08월 25일
Generics
코드 재활용과 성능 향상을 위한 선택 제네릭스
파라메트릭 다형성을 지원하는 프로그래밍 언어로 우리가 가장 잘 알고 있는 것은 C++이다. C++는 템플릿이라는 것을 통해 이를 지원하고 있다. 또한 최근에 자바도 JDK 5.0부터 제네릭스를 지원하고 있다. 특집 2부에서는 C# 제네릭스의 특징을 CLR 환경과 연관하여 살펴보고, C# 제네릭스의 주요 기능들에 대해 알아보자.
제네릭스(Generics) 혹은 파라메트릭 다형성(Parametric Polymorphism)은 프로그래밍 언어에서 이미 그 장점이 잘 알려진 기능이다. 컴파일 타임에 타입 오류에 대한 버그를 줄여 안전하게 해주며, 명시적으로 형 변환을 해야 하는 경우가 줄어들어 명쾌한 코드를 작성할 수 있다. 또한, 런타임시 타입 검사와 형 변환을 줄여주고, 박싱(Boxing)과 언박싱(Unboxing)을 피할 수 있기 때문에 좋은 성능을 기대할 수 있게 해준다. 그리고 타입별로 동일한 코드를 작성해야할 필요가 없어 재사용성이 높아진다.
강력하지만 단점도 많은 C++ 템플릿
제네릭스 프로그래밍하면 가장 먼저 떠오르는 언어가 C++인 경우가 많을 것이다. C++는 템플릿(template)이라는 것을 사용하여 제네릭스 개념을 지원하고 있다. C++로 만들어진 대표적인 템플릿 라이브러리인 STL(Standard Template Library)은 C++의 템플릿을 사용한 제네릭스 프로그래밍의 정수를 보여준 예라 할 수 있다.
STL은 제네릭스 컨테이너와 제네릭스 알고리즘을 제공하여 C++의 어떤 형에도 사용할 수 있도록 되어 있다. 필자는 C++로 개발을 하면서 간간히 템플릿을 사용하여 간단한 자료구조를 만들어 쓰거나 반복적인 코드를 줄이는데 사용하곤 했는데, STL을 접하고는 정말 템플릿은 STL을 위해 존재하는구나 싶을 정도로 감동을 받았었다. STL을 사용하면서 코드를 간결하게 만들 수 있었고, 성능도 굉장히 좋았던 것으로 기억한다.
하지만 개발 중간에 디버깅 작업을 할 때는 정말 난감할 때가 한두 번이 아니었다. 템플릿 파라미터가 잘못된 경우 다음과 같은 에러 메시지가 나타난다. 그나마도 다음 메시지는 비주얼 스튜디오 닷넷 2003에서 오류 메시지를 나름대로 보기 좋게 출력해준 것이고, 그 이전에는 이보다 두세 배쯤 더 심한 메시지를 던져내기 일수였다. 좀 익숙해지면 그나마 따라갈 수는 있지만, 처음 접했을 때의 당혹스러움은 이루 말할 수 없었다.
C++의 템플릿은 컴파일시에 모두 인라인 코드로 변경된다. 인라인은 함수 호출에 따르는 오버헤드가 없어 실행 성능을 좋게 만들어주기는 하지만, 사용되는 곳마다 코드가 만들어지기 때문에 잘못하면 실행 파일의 크기가 굉장히 커질 수 있다는 단점이 있다. 결국, 템플릿을 사용한 파라메트릭 다형성은 소스코드 차원에서는 재사용성이 있지만, 실행 코드에 대해서는 전혀 재사용이 없다.
또한, 템플릿으로 정의한 클래스나 메쏘드는 이를 사용하는 클래스가 있을 때에 비로소 컴파일되기 때문에 템플릿으로 작성된 코드 자체의 오류가 미리 검사되지 않고 나중에 발견될 가능성이 있다는 문제를 안고 있다. 다음 코드를 보면, MyClass 클래스가 DoNothing()이라는 메쏘드를 가지고 있지 않기 때문에 컴파일시에 오류를 기대할 수 있다. 하지만 실제 템플릿 클래스인 BadClass를 사용한 부분을 주석처리하면 아무 오류 없이 컴파일이 이루어진다. 주석을 제거하고 컴파일하면 error C2039: ‘DoNothing’ : is not a member of ‘MyClass’이라는 컴파일 오류를 발생시킨다.

<화면 1> Object로 구현한 Stack의 IL

<화면 2> Generic으로 구현한 Stack의 IL
이 경우와 유사한 클래스를 제네릭스 C#으로 다음과 같이 작성하고 컴파일을 하면, ‘T’ does not contain a definition for ‘DoNo thing’이라는 오류 메시지를 출력한다. T가 어떤 유형의 클래스인지, 어떤 멤버를 가지고 있는지 알 수 없기 때문에 C# 컴파일러가 오류를 발생시켜 안전한 코드를 작성할 수 있도록 해준다. BadMethod에서 DoNothing 메쏘드를 호출하지 않고, DoSomething을 호출하더라도 결과는 마찬가지이다. 실제로 파라미터 타입으로 지정된 클래스의 메쏘드를 호출하도록 하고 싶다면 제네릭스 제약(Generic Const raints)을 사용하면 된다. 제네릭스 제약에 대해서는 뒤에서 자세히 다루기로 하겠다.
C#에서 제네릭스 사용하기
C++의 템플릿을 사용해본 사람이라면 C#의 제네릭스는 전혀 낯설게 느껴지지는 않을 것이다. 다만, C++의 tempalate 키워드와 같이 제네릭스임을 알려주는 키워드도 지정할 필요가 없어 다소 썰렁하다고나 할까? C#에서 제네릭스 클래스는 C++과 마찬가지로 꺽쇠괄호(‘<’,’>’)를 사용하여 정의할 수 있다. 다음 예제는 하나의 제네릭스 파라미터 T를 가지는 MyGeneric 클래스를 정의한 것이다. MyGeneric 클래스의 메쏘드 DoNothing()은 제네릭스 파라미터 T 타입의 인자를 하나 받아서 사용하는 것으로 되어 있다.
제네릭스 파라미터는 복수 개를 사용할 수도 있다.
CLR 차원의 제네릭스 지원
C#은 언어적으로 C++의 많은 부분을 계승하고 있지만, 실행 모델면에서는 완전히 다르다고 할 수 있다. C++는 컴파일, 링크를 통해서 실행 코드를 만들어 실행하는 전통적인 실행 모델을 따르고 있는 언어이며, 언어적으로 완벽하게 객체지향을 지원하지는 못하고 있다. 반면, 다들 알고 있는 것처럼 C#은 자바 가상머신(Virtual Machine)과 마찬가지로 CLR(Common Languate Runtime)이라는 가상의 실행 환경에서 코드를 수행한다. 또한, 객체지향 개념을 완벽하게 지원하는 언어이다. 이러한 특성에 따라 C#의 제네릭스는 C++의 템플릿과는 근본적인 차이점들을 보이게 된다.
C# 1.x 버전이 수행되는 닷넷 프레임워크의 CLR은 다형성을 지원하지 않았다. 자바의 경우 많은 수의 자바 익스텐션들이 자바 가상머신을 변경하지 않는 선에서 처리할 수 있는 방식으로, 컴파일시에 다형성을 지원하기는 했지만, 가상머신 자체적으로 다형성을 지원한 것은 아니었다.
닷넷 프레임워크 2.0은 IL(Intermediate Language)과 CLR 자체적으로 제네릭스를 지원하고 있다. C# 코드를 컴파일하면 컴파일러는 제네릭스 타입을 다른 타입과 마찬가지로 컴파일하여 IL로 만든다. 이 때, 차이점은 제네릭스 타입에 대한 IL은 실제 사용될 타입을 담기위한 공간을 비워두고 있다는 점이다. <화면 1>과 <화면 2>의 IL을 보면 Generic으로 구현한 클래스의 경우 object 타입이 표시되어야 할 자리에 “!0”가 나타나 있는 것을 볼 수 있다.
C# 컴파일러가 생성한 IL 코드를 실행할 때, 닷넷의 CLR은 IL 코드를 다시 컴파일하면서 제네릭스 파라미터를 실제 사용될 파라미터로 대체하여 실행 코드를 생성하게 된다. 이 때, 실제 사용되는 파라미터가 밸류 타입(value type)인지 레퍼런스 타입(reference type)인지에 따라서 IL 사용 방법이 달라진다.
파라미터가 밸류 타입인 경우, 각각 다른 타입이 파라미터에 사용될 때마다 런타임은 새로운 클래스를 생성하게 된다. 예를 들어 다음과 같은 정수로 이루어진 Stack 클래스를 선언했다고 하자. 이 때, 런타임은 타입 파라미터에 int를 대체시킨 Stack 클래스를 생성한다. 프로그램 내에서 정수로 이루어진 Stack 클래스를 사용하는 경우에는 이 때 만들어진 Stack 클래스를 재사용하게 된다.
Stack하지만 프로그램 내에서 int가 아닌 double 혹은 사용자가 정의한 구조체를 사용하는 Stack 클래스가 필요하다고 하면, 런타임은 double을 타입 파라미터에 대체시킨 Stack 클래스를 새로 생성하게 된다. 이는, 밸류 타입은 각각 크기가 다르기 때문에 한 번 생성한 코드를 그대로 사용하지 못하기 때문이다.
반면, 타입 파라미터가 레퍼런스 타입인 경우에는 하나의 타입에 대해 런타임이 클래스를 생성하게 되면, 다른 타입을 사용하는 클래스를 선언하더라도 제네릭스 타입의 코드를 재사용하게 된다. 이는 모든 레퍼런스의 크기가 동일하기 때문에 가능한 일이다. 예를 들어 Customer 클래스와 Order 클래스가 있을 때, Customer 클래스 타입에 대해 Stack을 만들었다고 가정하자.
Stack이 때, 런타임은 실제 데이터를 저장하는 것이 아니라, 객체의 레퍼런스를 저장할 수 있는 버전의 Stack 클래스를 생성하게 된다. 다음으로, Order 클래스 타입에 대해 Stack을 만든다면 밸류 타입의 경우와는 다르게 새로운 버전의 Stack 클래스가 생성되는 것이 아니고, 이전에 Customer 클래스 타입을 생성할 때 만든 코드를 그대로 사용하여 Stack 인스턴스만 생성하게 된다.
Stack레퍼런스 타입에 대해서는 하나의 코드만 생성하기 때문에 각각의 타입에 대해 코드를 생성하는 것에 비해 코드 양을 현저하게 줄일 수 있다. C++의 템플릿의 겨우 모든 타입에 대해 인라인 코드를 생성하는 것과 차이를 보이는 부분이라고 할 수 있겠다.
제네릭스에서 제약 지정
제네릭스로 작성된 코드를 컴파일하면 C# 컴파일러는 이후에 어떤 타입을 사용할 것인가에 무관하게 IL 코드를 생성하게 된다. 결과적으로 잘못된 타입으로 메쏘드를 호출하거나, 속성이나 멤버 변수를 사용하게 될 위험을 내포하게 된다. 이를 막기 위해 C#에서는 컴파일러에게 타입에 대한 제약을 지정할 수 있도록 한다.
제약에는 타입이 특정 타입으로부터 파생된 것이어야 함을 컴파일러에게 알려주는 파생 제약(Derivation Constraints), 제네릭스 타입은 public으로 지정될 기본 생성자(default constructor)를 가져야한다는 생성자 제약(Constructor Constraints), 그리고 제네릭스 타입이 참조 유형인지 값 유형인지를 제약하는 참조/값 제약(Reference /Value Type Constraints)이 있다. C# 제네릭스에서 제약은 복수로 지정할 수 있다.
파생 제약(Derivation Constraints)
다음 예를 보자. 제네릭스 클래스 Node는 키(Key, K)와 값(Value, T)을 제네릭스 파라미터로 가진다. Node의 속성 Key는 K 타입으로 정의되어 있다고 하자. 이 때, LinkedList 클래스의 Find 메쏘드에서 Key 속성에 대해 CompareTo라는 메쏘드를 호출하고 있다. 그런데, 만약 K 타입에 CompareTo 메쏘드를 가지지 않은 클래스를 사용하면 어떻게 될까? 이러한 문제 때문에 C# 컴파일러는 다음과 같은 코드를 컴파일하지 않고 컴파일러 오류를 발생시키게 된다.
이를 해결하기 위해서는 CompareTo 메쏘드를 정의한 인터페이스를 정의하여 LinkedList 클래스와 Node 클래스의 제네릭스 파라미터가 이 인터페이스로부터 파생된 것임을 나타낼 필요가 있다.
생성자 제약(Constructor Constraints)
제네릭스 클래스에서 제네릭스 객체를 생성할 필요가 있는 경우, 이 타입이 어떤 파라미터를 가지는 생성자를 제공하고 있는 지 알 수 없기 때문에 컴파일 오류가 발생하게 된다. 이 문제를 해결하기 위해 C#에서는 제네릭스 타입에 대해 public으로 지정된 기본 생성자를 지원해야 한다는 제약을 지정할 수 있다. 이 제약의 형태는 다음과 같다.
참조/값 제약(Reference/Value Type Constraints)
제네릭스 타입으로 사용되는 타입이 참조 유형이어야 할지 값 유형이어야 할지를 지정할 수 있는 제약이 참조/값 제약이다. struct 혹은 class 키워드를 지정함으로써 제약을 지정할 수 있다.
제네릭스 메쏘드
C++의 템플릿 함수에 대응되는 기능이 제네릭스 메쏘드(Generic Methods)라고 할 수 있겠다. 메쏘드 레벨에 제네릭스 타입을 지정할 수 있도록 하고 있는데, 제네릭스 타입을 지정하는 방법은 클래스 레벨과 유사하다. 제네릭스 메쏘드에 대해서도 제네릭스 제약을 지정할 수도 있다.
일반적인 인스턴스 메쏘드에 대해서 뿐 아니라 정적(static) 메쏘드가 있는 클래스의 경우에 대해서도 제네릭스 타입을 지정하여 사용할 수 있다. 제네릭스로 정의된 정적 메쏘드를 호출할 때는 클래스 명에 실제 사용하고자 하는 타입을 지정하여 사용하면 된다.
제네릭스 델리게이트
C#에서 제네릭스로 정의할 수 있는 것은 클래스, 인터페이, 메쏘드 만이 아니다. 델리게이트(delegate)도 제네릭스로 정의할 수 있다(사실 델리게이트도 내부적으로는 클래스로 정의된다고 보면 당연한 것이라고 볼 수도 있겠다). 제네릭스 클래스 내에 델리게이트를 정의하는 것도 가능하고, 델리게이트에만 별도로 제네릭스 타입 파라미터를 지정할 수도 있다. 델리게이트는 클래스 외부에서 정의할 수도 있는데, 이 경우에도 제네릭스 파라미터를 지정할 수 있다.
제네릭스 델리게이트는 이벤트와 함께 사용할 때 매우 유용하다. UI가 강한 애플리케이션을 개발하다보면 약간씩 파라미터만 달라지는 델리게이트를 여러 개 만드는 경우가 있다. 이런 경우 제네릭스 델리게이트 하나면 공통으로 사용 가능하다.
제네릭스와 리플렉션
닷넷 프레임워크 2.0의 리플렉션(reflection)은 제네릭스 파라미터를 지원한다. 제네릭스를 지원하도록 확장된 Type 클래스의 제네릭스 관련 멤버는 다음과 같다.
이전 버전의 C#에서와 마찬가지로 Type 정보를 얻기 위해 typeof 연산자와 GetType() 메쏘드를 사용할 수 있다.두 가지 방법에 차이점이 있다면, 특정 타입이 지정되지 않은 제네릭스 타입에 대해 Type 정보를 얻으려면 typeof 오퍼레이터를 사용해야 한다는 것이다. 이 때 “<>”와 같이 빈 괄호를 사용하면 된다.
리플렉션을 사용하는 중요한 이유는 동적으로 타입 정보를 사용할 수 있다는 것이다. Type 클래스의 MethodInfo를 이용하여 메쏘드 정보를 얻어서, 이를 동적으로 호출하는 방법은 제네릭스로 정의한 클래스나 메쏘드에도 동일하게 적용될 수 있다. 다음 예제는 제네릭스로 정의된 LinkedList 클래스의 메쏘드 AddHead를 MethodInfo를 이용하여 호출하는 것을 나타내고 있다.
리플렉션은 로컬 객체에만 적용되는 것이 아니라 리모팅 객체에도 적용될 수 있다. 따라서 리모트 서버에서 제네릭스로 정의된 서버를 사용하고자 하는 타입별로 서비스하면 클라이언트에서는 해당하는 타입에 맞는 서버를 사용하면 된다. 다음 예제는 ISharedInterface
이 예제에서는 원격 객체를 등록하는 작업이나, 로컬에서 이를 사용하기 위해 인스턴스를 생성하는 코드를 하드 코딩한 예를 보여주었으나, 다음과 같이 설정 파일을 통해 제네릭스 파라미터를 가지는 원격 서버 객체에 대해 설정할 수 있다.
제네릭스의 성능
실제 시스템을 개발하는 입장에서 성능은 늘 중요한 이슈가 된다. 제네릭스를 설계하고 개발한 팀의 목표는 제네릭스를 사용했을 때 성능의 저하가 없도록 하겠다는 것이었다고 한다. 여러 벤치마킹 자료들을 보면 제네릭스을 사용하여 구현한 코드가 일반적인 object형을 사용하여 구현한 코드보다 좋은 성능을 나타내는 것으로 나타나고 있다.
필자도 간단히 Stack을 제네릭스를 사용한 경우와 object형을 사용하여 각각 구현하고 다음과 같은 테스트 코드를 작성하여 성능을 비교해 보도록 하겠다.
비교를 위해 C++ 템플릿을 사용한 경우를 이와 동일하게 구현하여 테스트를 수행하였다. C++의 경우 object형과 string형이 없어 테스트를 생략하였다. 결과는 <표 1>과 같다.
object형이나 string에 대해서는 object로 구현한 Stack이나 제네릭스을 사용하여 구현한 Stack이나 별반 차이가 없다. 하지만 int나 double과 같은 value type에 대해 연산을 수행한 경우 제네릭스를 사용하여 구현한 Stack이 3배 이상 좋은 성능을 보여주고 있다. 이는 value type에 대한 박싱과 언박싱이 제거된 효과이다.
좋은 무기는 사용할 때 가치가 있다
C#의 제네릭스는 성능이나 생산성의 저하 없이도 타입에 대해 안전한 클래스를 정의할 수 있도록 해준다. 뿐만 아니라 CLR 차원에서 제네릭스를 지원하고 있기 때문에 개발할 때 뿐 아니라, 실행할 때도 코드의 재사용이 이루어진다.
제네릭스로 정의할 수 있는 것은 클래스와 인터페이스뿐 아니라, 메쏘드와 델리게이트를 포함한다. 제네릭스는 C#과 닷넷 프레임워크 2.0의 핵심적인 기능으로 리플렉션과 리모팅에서도 사용 가능하며, 프레임워크 라이브러리의 많은 부분이 제네릭스를 지원하고 있다.
아쉬운 점은 웹 서비스에서 제네릭스를 사용할 수 없다는 것이다. 이것은 C# 혹은 닷넷의 제약이라기보다는 웹 서비스 표준이 제네릭스를 지원하지 못하는 것 때문이라 할 수 있겠다. 또한, 엔터프라이즈 서비스(enterprise service)에서 서비스 컴포넌트(serviced component) 를 만들 때, 제네릭스 타입을 사용할 수 없다. 이 제약 역시 닷넷 자체적인 제약이 아니라, COM이 제네릭스를 지원하지 않기 때문이라고 할 수 있다(혹시나, 템플릿으로 COM을 정의할 수 있는데, 왜 닷넷에서는 안 되는 것일까 하고 생각하는 독자가 있다면 혼동 없길 바란다. 템플릿을 사용하여 COM을 정의할 수는 있지만, COM 컴포넌트 자체가 파라메트릭하게 정의되는 것은 아니다).
C# 제네릭스를 사용하지 않는 것은 앙꼬 없는 찐빵 먹기에 비유하면 좀 지나친 비유가 될지 모르겠다. 먹는 것이야 취향에 따라 다를 수 있는 문제니 웃어넘긴다 해도, C# 2.0에서 제네릭스를 사용하지 않겠다는 것은 정말 값진 무기를 가지고 있음에도 단검으로 싸우겠다는 것과 같다. 적극적으로 제네릭스에 도전하고 사용하여, C++의 STL과 같이 멋진 라이브러리가 C#으로도 만들어졌으면 하는 바람이다.
참고자료
① Design and Implementation of Generics for the .NET Common Language Runtime. Andrew Kennedy, Don Syme, http://reserach.microsoft.com/projects/clrgen/generics.pdf
② An Introduction to C# Generics, Juval Lowy, http://msdn.microsoft.com/library/defualt.asp?yrl= /library/en-us/dnvs01/html/csharp_generics.asp
③ Introducing Generics in the CLR, Jason Clark, http://msdn.microsoft.com/msdnmag/issuses/03/09/NET/
④ More on Generics in the CLR, Jason Clark, http://msdn.microsoft.com/msdnmag/issuses/03/10/NET/
⑤ Generics in the Java Progamming Language, Gliad Bracha, http://java.sun.com/j2se/1.5/pdf/generics-tutorial.pdf
# by | 2006/08/25 00:48 | 트랙백(1)




☞ 내 이글루에 이 글과 관련된 글 쓰기 (트랙백 보내기) [도움말]
제목 : 제네릭 프로그래밍 - C#에서의 제네릭 프로그램밍 ..
아내가 최근 교육을 다녀와서는 제네릭 프로그래밍에 대하여 물어왔습니다.STL 템플릿 프로그래밍이랑 같은 개념으로 템플릿 프로그래밍이라아 같은 말이다 라고 알려주었습니다.C++에서 템플릿 Java(JDK 5.0이상)와 C#(2.0이상)에서는 제네릭 프로그래밍이라 불리는 컴파일 타임에 형(type)이 결정되는 코딩 기법 말입니다. 그동안 STL을 코딩시 많이 사용해 왔지만, 별도로 제네릭 코딩 개념에 대해 진지하게 생각해 보지 않았다는 생각이 들었......more