C# 코딩 연습, 객체의 상속과 포함 (이벤트 코드 생성기의 HistoryManager 클래스의 소스 분석을 겸하여)

C# 코딩 연습 2009. 11. 1. 23:41

졸작 이벤트 코드 생성기 1.4 버전에 추가된 히스토리 기능은 이전에 생성한 이벤트 이름과 매개변수 쌍을 기억하는 기능입니다.

이 기능을 어떤 식으로 구현하는 것이 좋을까요?

아마도 클래스가 있는 언어를 사용한다면 히스토리를 관리하는 클래스를 만들어 관련된 모든 로직을 캡슐화하는 것이 정석일 것입니다.

구체적으로 이 클래스가 할 일을 정리해 본다면,

  1. 히스토리를 추가한다. 다만 추가할 수 있는 갯수에는 제한이 있어 최대값에 도달하면 선입선출 식으로 제거된다.
  2. 추가된 히스토리를 삭제할 필요는 없다.
  3. 히스토리 목록을 저장소(여기에서는 파일 시스템)에 저장하고 저장소에서 불러온다.
  4. 윈폼의 BindingSource 객체의 DataSource로 지정될 수 있다. (즉 히스토리 목록을 열거할 수 있다.)
  5. 프로그램 전반에 걸쳐 단 한 개의 인스턴스가 생성된다.

각각의 요구사항에 대한 해결 방법을 (간단한 것 부터) 찾아봅시다.

5 <- 이건 그냥 싱글톤 패턴으로 바로 해결이 가능하겠네요.

4 <- ‘열거’ 라는 말에서 착상할 수 있듯이 IEnumerable<T> 인터페이스를 구현하면 역시 간단하게 해결할 수 있겠습니다.

3 <- 히스토리 목록이 직렬화가 가능하도록 하면 되겠습니다. Serializable 특성을 붙이거나 ISerializable 인터페이스를 구현하거나 직접 (역)직렬화 로직을 구현하는 방법이 있겠습니다.

여기서는 닷넷 프레임웍 3.0에 추가된 DataContract와 DataMember 특성을 사용하겠습니다.


이제 제일 중요한 1번과 2번이 남았는데요.

직관적으로 결국 이 클래스는 바로 히스토리의 목록을 조작하는 클래스라는 것을 알 수 있는데, 그렇다면 문제는 히스토리의 목록을 어떻게 표현하느냐로 좁혀집니다.

즉, 히스토리의 목록으로 어떤 자료구조를 사용하느냐, 닷넷 프레임웍의 용어로 이야기하자면 어떤 컬렉션을 선택하느냐의 문제에 다름 아닙니다.

(자료구조와 컬렉션에 대해서는 졸고를 참고하십시오.)


그런데 사실 이 문제는 별로 고민할 필요가 없는 것이, 이 프로그램처럼 윈폼의 데이터바인딩 메커니즘과 함께 사용하는 경우라면 거의 대부분의 경우 List<T> 혹은 BindingList<T>가 정답이 될 것입니다.

(드문 경우긴 하지만 IList<T>나 IBindingList<T> 인터페이스를 구현한 클래스를 직접 만들어 사용하는 경우도 있긴 합니다.)

물론 List<T>는 윈폼 뿐만 아니라 닷넷 베이스 클래스 라이브러리를 통틀어 가장 많이 사용되는 자료구조가 아닐까 싶은데요.

STL과 자바에서는 ‘벡터’라고 불리는 이 가변배열은 자료구조의 기본이면서도 정말 유용합니다.

혹시 아직도 습관적으로 배열이나 ArrayList를 사용하고 있다면 List<T>를 대신 사용할 것을 고려해 보시기 바랍니다.


히스토리의 목록으로 List<T>를 사용하기로 결정했다면 이제 마지막 문제, 오늘의 주제이기도 한 문제가 남았습니다.

List<T> 클래스를 이용하여 히스토리 목록을 구현할텐데, 어떤 방식으로 이용하느냐는 것입니다.


OOP에 있어서 한 클래스가 다른 클래스를 이용(재사용)하는 방법에는 두 가지가 있습니다.

이른 바 is-a, has-a 관계라고도 일컬어지는 상속포함(또는 합성 혹은 위임)이 그것입니다.

상속은 한 클래스가 다른 클래스의 (private 멤버를 제외한) 멤버를 그대로 물려받는 것이며

반면에 포함은 한 클래스가 다른 클래스의 인스턴스를 멤버로 가지고 있는 경우를 말합니다.

물론 상속과 포함은 각각의 장단이 있긴 때문에 상황에 따라 선택을 해야할 문제이긴 하지만, OOP 언어의 대가들은 대체로 포함 방식을 사용할 것을 권장하고 있습니다.

그 근거 중 가장 대표적인 것이 상속의 경우에는 두 클래스 간에 (보다) 강력한 결합이 발생한다는 것인데, 예를 들면 이렇습니다.

보시다시피 A 클래스의 Foo 메서드가 Foo2로 이름이 변경되면 B.Foo()를 호출하던 코드는 이제 수정을 하여야 합니다.

반면에 포함의 경우에는 이런 문제가 발생하지 않습니다.

A 클래스가 변경되더라도 B 클래스에서 A에 대한 위임 부분만 바꾸면 되므로, B 클래스를 사용하는 입장에서는 변함이 없습니다.


워밍업은 이 정도로 하고 이제부터는 본격적으로 히스토리를 관리하는 클래스를 만들어 보도록 합시다.

클래스의 이름을 HistoryManager 라고 하고, List<T>를 포함하는 형태로 한다면 아래와 같은 클래스를 선언할 수 있겠네요.

아, 아직 지네릭 타입인 T를 결정하지 않았네요.

히스토리의 목록이라고 했으니까 이 T는 히스토리 아이템 타입이 될 것입니다. 히스토리 아이템 타입은 아래와 같이 정의하겠습니다.

이벤트의 이름과 매개변수를 각각 string 형으로 가지고 있는 단순한 클래스이고요.

직렬화를 위해 DataContract 특성과 DataMember 특성을 가지고 있습니다.

이제 HistoryManager의 정의는 다음과 같이 바뀌겠네요.


프로그램 전체에 걸쳐 단 하나의 인스턴스만 생성할테니, 싱글톤 패턴을 추가해봅시다.

싱글톤 패턴은 워낙에 잘 알려져 있으니 따로 이야기하지 않아도 될테구요.

리스트에 저장할 최대 갯수인 MaxHistoryItems와 파일에 저장하기 위한 파일명을 상수로 정의한 것을 기억하여 주십시오.

IsolatedStorageHelper 클래스는 격리된 저장소에 대한 파일 입출력 작업을 담당하는 헬퍼 클래스인데 구체적인 구현은 이벤트 코드 생성기의 소스를 참조하시기 바랍니다. 여기서는 그냥 로컬 파일시스템에서 데이터를 읽어오는 코드 정도로만 생각하셔도 무방합니다.


히스토리 아이템을 추가하는 메서드는 아래와 같이 구현할 수 있습니다.

리스트의 갯수가 최대값에 도달하면 마지막 아이템을 삭제하고, 새로운 아이템을 제일 앞에 삽입하고 있습니다.

여기서 List<T>와 관련하여 한 가지 사족을 달자면, List<T>에는 원소를 추가하는 메서드가 두 개가 있습니다.

Add와 Insert가 그것인데요. Add는 내부 배열의 마지막에 원소를 덧붙이는 반면에, Insert는 배열 가운데에 원소를 삽입합니다.

(그래서 Insert에는 삽입할 위치를 나타내는 인자가 하나 더 있습니다.)

문제는 Insert를 통해 원소가 삽입되면, 내부 배열에서 인덱스가 그 이후인 모든 원소들이 한 칸식 뒤로 밀려나게 된다는 것입니다.

여기서 뒤로 밀려난다는 것은 기술적으로 말하자면, 객체의 이동(즉 메모리의 복사)을 의미하는데, 그 갯수가 많다면 이는 성능상의 오버헤드가 될 수 있습니다.

예를 들어 원소가 만 개인 리스트의 0번째에 삽입을 한다면 만 개의 객체를 이동하여야 하는 것입니다.


그리고 List<T>의 삭제의 경우에도 마지막 원소가 아닌 중간의 원소를 지우면 이후 원소들은 한 칸식 앞으로 이동하는 문제가 있습니다.


결국 위 코드에서는 Insert를 사용하나 Add를 사용하나 이론적으로는 같은 성능을 낸다고 할 수 있겠습니다.


여기서 한 가지 상속과 포함에 관하여 생각해 볼 것이 있습니다.

만일 HistoryManager 클래스가 List<T>를 상속하는 구조였다면 이 코드는 아마도 아래와 같이 작성되었을 것입니다.

Add 메서드의 시그니처가 상속받은 List<T>의 메서드에 맞추어 변경되고, (List<T>.Add가 가상 메서드가 아니기 때문에 new 키워드를 붙여줘야 합니다.)

Count 나 Insert 같은 속성이나 메서드를 가지고 있지 않으므로 상속받은 List<T>의 것을 사용하고 있습니다.

(참고로 HistoryManager를 사용하는 클래스는 Add 메서드 만을 알고 있긴 때문에, 실제로는 Add가 아니라 Insert 이지만 이름은 Add로 하였습니다.)


포함 대신 상속을 사용하면, 히스토리를 추가하는 것은 이런 식으로 가능하겠지만, 2번 요구사항인 ‘삭제를 할 수 없다’ 에서 문제가 생기게 됩니다.

포함의 경우라면, HistoryManager 매니저에 Remove 메서드를 작성하지 않는 한 당연히 외부에서는 이를 호출할 수 없습니다.

하지만 상속이라면, HistoryManager 클래스는 상속받은 List<T>의 모든 공용 속성과 메서드를 노출하므로 HistoryManager의 사용자가 List<T>의 Remove 메서드를 호출하는 것을 막을 방법이 없습니다.

그래서 다음과 같이 런타임에 예외를 발생시키는 어색한 코드라도 추가할 수 밖에 없습니다.


이번에는 히스토리 목록을 파일로 저장하는 (직력화) 하는 코드를 살펴봅시다.

IsolatedStorageHelper 클래스 덕분에 코드가 가능합니다.

여기서 한 가지 주의할 점은, 직렬화의 대상이 되는 객체는 HistoryManager 객체 자체가 아니라 그 필드인 히스토리 목록 (List<HistoryItem> _list) 객체라는 것입니다.

따라서 HistoryManager에는 DataContract 특성을 붙일 필요가 없으며, List<T>의 지네릭 타입인 HistoryItem에 이 특성을 붙여야 한다는 것입니다.

(List<T> 클래스에는 닷넷 프레임웍 내부에서 이미 이 특성이 붙어 있습니다.)


마지막으로 HistoryManager 객체를 열거가능하게 해 봅시다.

제일 위 스크린 샷에서 히스토리 콤보박스의 데이터소스로 HistoryManager 인스턴스를 지정하는 코드를 간략하게 적으면 아래와 같습니다.

물론 이 코드는 런타임에 예외를 발생시킵니다.

HistoryManager가 히스토리 목록을 열거하는 기능을 가지고 있지 않기 때문입니다.


그럼 HistoryManager에 히스토리 목록을 열거하는 기능을 어떻게 추가할 수 있을까요?

간단하게는 필드로 가지고 있는 히스토리 목록을 외부로 바로 노출하는 것입니다.

그럼 위 코드는 아래와 같이 바꾸면 됩니다.

물론 동작은 하겠지만, 이 코드는 OOP의 기본 원칙 중 하나인 캡슐화를 위배해버렸습니다.

즉 히스토리 목록이 외부로 직접 노출되었기 때문에, 이를 통해 외부에서 히스토리 목록에 아이템을 제거하거나 하는 일을 할 수 있게 된 것입니다.


히스토리 목록을 노출하는 것 보다 좋은 방법은 HistoryManager 클래스 자체를 열거 가능한 형으로 만드는 것입니다.

즉 HistoryManager가 IEnumerable<HistoryItem> 인터페이스를 구현하도록 한 후 IEnumerable<HistoryItem>의 GetEnumerator 메서드를 적절하게 구현하는 것입니다.

지네릭 버전이 아닌 IEnumerable 인터페이스는 구현하지 않고, 지네릭 버전의 IEnumerable 인터페이스만 구현하기 위해서는 위 코드 처럼 두 개의  GetEnumerator 메서드를 구현을 하여야 하는데, 둘 중 하나는 암시적으로, 나머지 하나는 명시적으로 구현을 하여야 합니다. (인터페이스의 명시적 구현에 관해서는 역시 졸고를 참고하십시오.)


그럼 이제 HistoryManager는 아래와 같이 콤보박스의 데이터소스로 바로 사용될 수 있습니다.

 


이번 C# 코딩 연습에서는 OOP의 기본 이론 중 하나인 클래스의 상속과 합성에 대해서 다루어 보았습니다.

아시다시피 C#은 (기본적으로) OOP 언어입니다. 그래서 OOP에 대한 깊이 있는 이해가 C#을 제대로 활용하기 위한 첩경이 됩니다.

OOP를 이해하는 데 이 글이 미약한 도움이나마 되셨기를 바랍니다.


우리는 모두 한국의 개발자입니다.

그래서 이 글을 읽는 분의 경쟁력이 곧 이 글을 쓴 저의 경쟁력이기도 합니다.

장인(匠人)으로서의 자부심을 가지고 피나는 용맹정진을 할 것을 (스스로에게) 다짐하고, 또 (여러분에게) 당부합니다.


연습문제

HistoryManager 클래스에 아래와 같은 상황에서 발생할 이벤트를 추가해 보시기 바랍니다.

  1. 히스토리 목록이 추가되려고 함 –> 히스토리 목록의 원소 갯수를 알려주어야 함
  2. 히스토리 목록이 추가되었음 –> 히스토리 목록의 원소 갯수를 알려주어야 함
  3. 히스토리 목록이 파일 시스템에 저장되었음 –> 저장된 파일명을 알려주어야 함

이왕이면 이벤트 코드 생성기를 사용해 보십시오.


연습문제 정답 :

: