소프트웨어를 잘 만드는 방법

5. 변동성을 통해 올바른 의존관계의 디자인

 

지금까지 살펴본 바와 같이 변동성은 매우 기본적이면서도, 현대의 거의 모든 프로그래밍 기법에 응용되고 있는 개념이다. 변동성의 원리를 이해하고 나면 이러한 프로그래밍 기법들이 왜 쓰이게 된 것인가를 더 잘 이해할 수 있을것이며, 앞으로 다른 어떤 방식이 나오더라도 더 잘 이해하고 적응할 수 있을 것이다.

하지만 지금까지 본 것은 이미 우리가 쓰고 있었던 것들의 배후에 변동성의 개념이 존재하고 있었다는 것뿐, 아직 새롭게 유용한 것을 발견했다고 말하기는 이르다. 지금부터 볼 내용은 여러분이 프로그램을 짜면서 늘 고민해 왔을 주제들에 대해 변동성이 해결책을 찾는 좋은 기준이 된다는 것을 알게 해줄 것이다.

클래스를 디자인할 때 자주 등장하는 선택의 상황중 하나는 어떤 기능을 두개이상의 클래스가 함께 구현하도록 할 때에 어느쪽이 어느쪽을 사용할 것인가라는 문제가 된다. 이 문제는 결국 의존관계를 어떻게 설정할 것인가라는 답을 요하는 문제이고, 절대적인 답이 존재하는 것은 아니지만, 현명하지 못한 관계를 기반으로 개발을 진행하게 되면, 요구사항의 변덕에 의해 끊임없이 괴롭힘을 당하게 되고 코드는 누더기가 되어버리고 만다. 이 문제의 보다 현명한 답을 구하는 데에 변동성의 개념은 결정에 영향을 미치는 중요한 개념이다.

 

어떤 그래픽 시스템을 설계한다고 하자. 이 시스템은 삼각형, 사각형, 원 같은 여러가지 shape 들을 Renderer 통해 그려내야 한다.

Shape 라는 베이스 클래스가 있고, Circle, Rectangle, Square 같은 파생 클래스가 있다고 하자. 또한 Renderer 의 경우 그래픽스 API 통해 그리는 역할을 하게 되는데, 렌더링 스타일에 따라 도형을 Wireframe 으로 그릴 수도 있고, Flat color 그릴 수도 있고, Gouraud shading 을 해서 그릴 수도 있고, 텍스춰를 씌워서 그릴 수도 있고, 그림자 생성용으로 그릴 수도 있고, 그림자를 받는 (Shadow receiver) 용도로 그릴 수도 있고, 등등 다양한 가능성이 존재하므로, Renderer 라는 베이스 클래스가 있고, FlatRenderer, GouraudRenderer, TexturedRenderer 등의 파생 클래스들이 존재한다고 가정하자.

이렇게 Shape Renderer 가 있다고 할 때, 그리는 함수를 Draw 라고 한다면, Draw 라는 함수는 과연 어디에 있어야 할까? Shape 쪽에 둘 것인가, 아니면 Renderer 쪽에 둘 것인가?

이에 관련된 클래스 디자인을 하게 된다면 두가지 중 한가지를 선택해야 한다.

 

l         Renderer Draw 하는 경우

FlatRenderer :: DrawCircle(Circle *shape);

FlatRenderer :: DrawRectangle(Rectangle *shape);

FlatRenderer :: DrawSquare(Square *square);

GouraudRenderer :: DrawCircle(Circle *shape);

GouraudRenderer :: DrawRectangle(Rectangle *shape);

GouraudRenderer :: DrawSquare(Square *square);

TexturedRenderer :: DrawCircle(Circle *shape);

TexturedRenderer :: DrawRectangle(Rectangle *shape);

TexturedRenderer :: DrawSquare(Square *square);

이 경우 Circle, Rectangle, Square 는 데이터로서만 존재하게 된다. 실제로 각 도형을 그리는 알고리즘은 Renderer 쪽에 모이게된다.

 

l         Shape 들이 Draw 하는 경우

Circle :: Draw(FlatRenderer *renderer);

Circle :: Draw(GouraudRenderer *renderer);

Circle :: Draw(TexturedRenderer *renderer);

Rectangle :: Draw(FlatRenderer *renderer);

Rectangle :: Draw(GouraudRenderer *renderer);

Rectangle :: Draw(TexturedRenderer *renderer);

Square :: Draw(FlatRenderer *renderer, RenderStyle);

Square :: Draw(GouraudRenderer *renderer, RenderStyle);

Square :: Draw(TexturedRenderer *renderer, RenderStyle);

Renderer 는 하드웨어에 선이나 면을 그리는 기본적인 서비스만을 제공하고, 실제 각 도형을 그리는 알고리즘은 각 Shape 들의 클래스에 속하게 된다.

 

위의 답과 아래의 답중 어느 것이 무조건 맞고 다른 것이 틀린 것일까?

절대적인 답이 있는 것은 아니다. 다만 상황에 따라 더 적절한 답과 적절하지 못한 답이 있을 뿐이다. 그 상황이 무엇일까? 바로 변동성이다.

이런 상황에서 변동성은 무엇을 의미하는가? Renderer 쪽의 변동성이 높은가, Shape 쪽의 변동성이 높은가가 구체적으로 어떤 의미를 가지는 것일까?

 

Renderer 의 변동성은 Renderering Style 에 있다. 위의 예제에서는 FlatRenderer, GouraudRenderer, TexturedRenderer 등의 3가지 Renderer 만 존재하지만 만약 앞으로 ShadowCastRenderer, AlphaBlendedRenderer 같은 식으로 Renderer 가 늘어나게 된다면 Renderer 의 변동성이 높다고 말할 수 있을 것이다.

Shape 의 변동성도 마찬가지로 현재 Circle, Rectangle, Square 3가지 타입이 존재하고 있는데, 앞으로 타원(Ellipse) 이라던가 마름모꼴(Trapezoid) 등의 더 다양한 shape 의 종류가 늘어난다고 하면 그만큼 변동성이 높아지는 것이다.

 

결론을 말하자면, 여러가지 렌더링 스타일에 따른 다양한 Renderer가 계속 생겨날 여지가 많으면서, Shape 의 종류는 몇가지 이상 늘어나지 않는다면, Renderer 의 변동성이 높고, Shape 의 변동성이 낮기 때문에, 변동성이 높은 것이 낮은 쪽에 의존하는 쪽이 더 바람직하므로 첫번째 방식, Renderer Draw 하는 첫번째 경우의 구성이 바람직 하며, 반대로 렌더링 스타일은 몇가지로 제한이 되면서 Shape 의 종류가 계속 늘어날 가능성이 높다면 Shape 들이 Draw 하는 두번째 경우가 바람직하다.

 

만약에 두가지 다 변동성이 높다면 어떻게 해야 할까? 변동성이 둘다 높다고 해서 둘다 서로를 참조하는 방식이 되면 그야말로 최악의 상황이 되어버릴 것이다. 이럴 때에는 오히려 중간에 매개체를 두어 변동성을 떨어뜨리는 방향을 선택해야 한다. 적절한 중간자를 두게 되면 양쪽의 변동성이 높은 경우에도 완충지대가 형성되어 변동성이 서로 조합되면서 더욱 복잡해지는 상황을 막을 수 있다.

 

위의 다양한 Renderer 와 다양한 Shape 의 경우를 가지고 설명해보겠다. 우리가 착안할 수 있는 점은 어떠한 복잡한 형태의 도형이건 결국 화면상에 표시되기 위해서는 점, 선이나 삼각형면의 모임으로 귀결된다는 점이다.

 

l         PrimitiveBuffer라는 매개체를 둔 경우

Circle :: Draw(PrimitiveBuffer *buffer);

Rectangle :: Draw(PrimitiveBuffer * buffer);

Square :: Draw(PrimitiveBuffer * buffer);

FlatRenderer :: Draw (PrimitiveBuffer * buffer);

GouraudRenderer :: Draw (PrimitiveBuffer * buffer);

TexturedRenderer :: Draw (PrimitiveBuffer * buffer);

각각의 Shape 들은 자기 자신을 그리는 알고리즘을 갖고 있고, 그 결과는 Renderer 직접 보내지는 대신, PrimitiveBuffer 라는 중간저장소에 점, , 삼각형으로 변환되어 저장된다. 한편, 다양한 Renderer 들은 Shape 직접 받는 대신 PrimitiveBuffer 에 들어있는 점, , 삼각형들을 그리면 된다.

 

PrimitiveBuffer 의 방식을 사용하면, 위의 인터페이스에서 보다시피, 중간에 한단계를 더 거치게 되는 대신 Renderer Shape 어느쪽의 변동성이 더 늘어나더라도 상대쪽에 영향을 덜 미치게 된다. 변동성의 관점에서 보자면 Shape Renderer 는 변동성이 높지만, PrimitiveBuffer 는 그 변동성이 높지 않다. 변동성이 높은 Shape Renderer 가 변동성이 낮은 PrimitiveBuffer 에 의존하고 있으므로 합리적인 의존관계로 볼 수 있다. John Lakos Large scale software design in C++ 에서는 이런식으로 중간자를 도입시켜 의존관계를 합리화시키는 테크닉들을 다양하게 소개하고 있다.

 

그렇다고 이러한 중간자를 언제나 선호하는 것은 곤란하다. 어디까지나 변동성이 높아질 것이라는 예측이 가능해질 때, 혹은 작업을 하다가 변동성이 높아지게 되었을 때 리팩토링의 차원에서 진행되어야 할 성질의 것이다. 변동성이 얼마나 될것인가에 대한 분석이 충분치 않은 상태에서 단지 이런 디자인 패턴이 많이 쓰이더라라는 이유만으로 불필요한 중간자를 도입하는 등의 설계가 들어가거나 하면 설계를 불필요하게 복잡하게 만들어서 성능을 저하시키는 것은 물론, 시스템을 분석하고 차후 확장할 사람들에게 혼란을 야기시키게 됨을 주의하기 바란다.

 

SideNote

프로그래밍의 발전은 Indirection 의 증가를 의미한다라는 말이 있다. Indirection 는 바로 위에 언급한 중간자의 개념을 의미한다. 중간에 하나의 존재를 더 두면 무엇이 발전한다는 말일까? Indirection 은 범용성과 융통성을 증가시키기 때문이다. 예를 들면 클라이언트/서버 구조에서 미들웨어 같은 중간계층(2-Tier 에서 3-Tier 이나 4-Tier로의 확장)이 늘어나는 것이나, 네트워크 프로그래밍에서 7개의 계층이 존재하는 것이나, 모두 범용성과 융통성을 증가시키기 위한 구조적 설계이다. C 프로그래밍에서 어떤 함수를 호출할 때 경우에 따라 호출될 함수가 달라지도록 함수포인터테이블이란 중간계층을 넣던 것이 C++ 에서 일반화되어 가상함수란 존재가 된 것도 역시 중간계층이 하나 더 늘어난 것이다. 예전엔 그래픽용 하드웨어를 직접 제어하다가 OpenGL 이나 DirectX 같은 GraphicsAPI 등장이후 카드별로 프로그램을 따로 만들 필요가 줄어든 것도 Indirection 의 효과라고 할 수 있다. Indirection 은 범용성과 융통성에 도움을 주는 대신, 성능과 단순함과는 반비례관계에 있다. 얼마만큼 Indirection 을 둘 것인가에 대한 해답은 역시 변동성의 정도에서 찾아야만 한다. 오버엔지니어링도, 언더엔지니어링도 아닌 Just Engineering 상태를 유지하는 것이 Refactoring 의 지향점이다.

 

 

6. 변동성을 통해 올바른 클래스와 메소드의 관계

 

C++ 의 클래스 개념을 공부하다보면 누구나 연습문제 삼아서 한번쯤 만들어보게 되는 것이 String 클래스이다. 그런데 아마추어가 설계한 스트링 클래스를 보면 처음에는 char * 대체하고 = 이나 !=, < 같은 다양한 operator strcmp 같은 함수를 대신하게 만드는 것으로 시작해서 나중에 갈수록 MakeLower 같은 유틸리티성 메소드들이 붙어가면서 점점 비대한 뚱보 클래스가 되어버리는 경향이 생겨서 나중에는 특정 프로젝트에서만 쓰여야 할 메소드 (: IsPlayerName) 스트링 클래스 자체에 포함되어 버리게 된다.

 

처음에는 클래스를 디자인 할 때, 기능이 다양한 것이 좋다라던가 캡슐화를 위배하지 말아야 한다라는 규칙을 우선시하게 되서 스트링 클래스 안에서 모든 것을 다 할 수 있게 만드는 쪽을 선택하게 된 것이지만, 막상 그런 스트링 클래스는 다시 새로운 프로젝트를 시작하게 될 때면 결국 재사용성에 심각한 문제가 있음을 깨닫게 되고, 다시 깔끔하고 가벼운 스트링클래스를 만드는 쪽을 택하게 된다. 참고로 operator 사용해서 strcmp 대체할 수 있고 깔끔하게 보이도록 만들어놓은 것들도 결국 대소문자 구분문제나 locale 문제등 처음에 보이지 않던 복병을 만나서 결국 함수의 형태로 돌아오게 된다.

이런 사례는 비단 string의 경우뿐만 아니라 vector matrix같은 생활필수품 클래스에서 자주 일어나는 문제점이다. 어째서 개발을 하다보면 비만증에 걸린 클래스를 만들게 되고, 결국 그 비만증 때문에 재사용성이 떨어지는 결과를 야기하는 것일까? 그것은 메소드 구성에 있어서 변동성을 기준으로 분리하지 않았고, 캡슐화를 남용했기 때문이다.

 

Direct3D 의 쓸만한 보너스 (DirectX 9에 와서야 그렇긴 하지만) 라고 생각하는 D3DX 라이브러리에는 vector matrix를 비롯한 각종 필수적으로 사용할 수 있는 함수들을 제공한다. 그런데 눈여겨보아야 할 점은 vector matrix 같은 클래스와 그에 관련된 함수 (예를 들면 vector의 절대값 구하기, vector matrix 곱하기 ) 들의 관계이다. 재사용성에 대해 많은 경험이 없는 개발자라면 그런 함수들을 모두 vector matrix 클래스의 멤버 함수로 만들고 싶을 것이다. 클래스의 멤버를 사용하는 것으로 얻어지는 표기상의 우아함과 심리적인 안정감 (vector matrix 클래스의 멤버 변수들을 private 할 수 있으니까 캡슐화가 잘 된 디자인을 했다고 생각하게 된다) 같은 장점이 있을지는 모르지만 클래스가 점점 비만증에 걸리면서 재사용성이 떨어지는 단점은 서서히 그 장점을 압도해간다는 것을 알아야 한다.

 

캡슐화가 중요한 것일까? 재사용성을 높이는 것이 중요한 것일까? 캡슐화라는 것은 어디까지나 재사용성을 높이기 위한 수단일 뿐 그 자체가 목적은 아니다. String 이나 vector, matrix 같은 생활필수품 클래스의 문제점은, 그러한 생활필수품을 사용, 응용하는 방법은 얼마든지 늘어날 수 있다는데에 있다. 그러한 상황에서 택할 수 있는 방법은 두가지중 하나이다.

 

i) 멤버변수를 private 닫아놓고, 그 멤버를 사용하는 메소드들을 모두 멤버메소드로 받아들이거나,

ii) 캡슐화를 포기하고 단지 데이터를 간직한 struct 남고 (또는 멤버변수를 public 으로 만들거나), struct 의 멤버를 사용하는 함수들을 멤버 바깥으로 빼는 것이다. (바깥으로 뺀 함수들은 namespace 묶어놓는 것이 바람직하다)

 

많은 학생들, 아마추어들은 c++ 책에서 단지 캡슐화가 좋고 중요하다고만 배웠기 때문에 대부분 전자를 선호하지만, 그 반면에 여러가지 프로젝트를 수행하고 재사용성의 근본문제에 대해 고민한 엔지니어들은 오히려 C 시대로 후퇴한듯 보이는 struct namespace 묶어놓은 함수의 집합이 오히려 더 재사용성과 범용성이 뛰어나다는 점을 깨닫게 된다. 다시한번 기억해두기 바란다. 캡슐화라는 것은 어디까지나 재사용성을 높이기 위한 수단일 뿐 그 자체가 목적은 아니라는 점. C++ 을 써서 과연 코드재사용이 제대로 되던가 하고 자조적인 목소리를 내는 개발자는 반성할 필요가 있다. 실제로 Bjarne Stroustrup 선생님의 The C++ Programming Language 에 보면 Concrete Data Type Abstract Data Type 을 혼동하지 말기를 당부하고 있다. Vector, matrix 같은 클래스는 명백히 Concrete Data Type 으로 보아야 한다. Concrete Data Type 에서 상속이나 캡슐화 같은 것은 득보다 실이 많을 수 밖에 없다는 것을 기억해두기 바란다.

 

위에서 언급한 문제들은 string 이나 matrix 같은 클래스들 말고도, 어플리케이션 안에서 기본적인 역할을 하는 클래스들이 코딩편의를 목적으로 점점 더 많은 일을 하게 되었을 때 흔히 발생된다. 여러분의 프로그램에 가장 복잡한 클래스 계층도를 가진 클래스들의 가장 베이스 클래스(혹은 그 다음 클래스)가 엄청나게 긴 메소드 리스트를 가지고 있다면 여러분의 프로젝트의 유지보수성에는 먹구름이 짙어지고 있다는 신호로 받아들여야 한다.

그에 대한 해결방법은 그런 메소드들을 변동성이 낮은 메소드들과 변동성이 높은 메소드로 분리하여, 변동성이 높은 메소드는 클래스에서 분리해서 별도의 클래스 혹은 네임스페이스에 넣고, 그런 메소드들이 클래스의 변동성이 낮은 메소드들을 사용하는 방식으로 바꾸는 것이다. 이러한 구조개선은 프로젝트가 커지고 오래되다보면 재발하기 쉬운 문제이니만큼 리팩토링 작업리스트에 넣고 꾸준히 정원을 다듬는 심정으로 관리해야만 하는 것이다.

필자의 경우에는 어떤 메소드가 있을 때 그 메소드가 없어져서는 다른 메소드들도 동작을 할 수 없게 될 정도로 필수적인 메소드들만 클래스의 멤버로 넣고, 코딩의 반복을 줄이기 위해서 만든 유틸리티성 메소드는 따로 xxutil 같은 네임스페이스에다 모아 놓는다. 예를 들면 CActor 클래스를 위한 유틸리티 메소드들은 namespace actorutil 에다 몰아넣는 식이다.

 

 

7. 마치며

 

지금까지 변동성이라는 관점에서 소프트웨어를 어떻게 해야 더 잘 만들 수 있을 것인지 알아보았다. 변동성의 규칙은 Pragmatic Programmer DRY 원칙이나 직교화 설계원칙 만큼이나 소프트웨어 개발에 있어서 큰 영향을 준다고 믿는다. 앞으로 등장하는 기술에 대해서도 변동성의 규칙과 어떤 관계가 있을지 연관지어 생각해보면 도움이 될 것이다.

 

 

imcgames 의 김학규입니다