본문 바로가기

SW LAB/Algorithm

Clean Architecture : (5장) 객체 지향 프로그래밍

 프롬스의 SWDEVLAB 

객체 지향 프로그래밍

 좋은 아키텍처를 만드는 일은 객체 지향 설계 원칙을 이해하고 응용하는 데서 출발합니다. 그렇다면 객체 지향이란 무엇인가?

 이 질문에 누군가는 "데이터와 함수의 조합" 이라고 답을 하는데, 그다지 만족스러운 대답은 아닙니다. 달과 니가드가 함수 호출 스택 프레임을 힙으로 옮기고 OO를 발명한 1966년 이전부터 프로그래머는 데이터 구조를 함수에 전달해 왔습니다.

 또는 "실제 세계를 모델링하는 새로운 방법"이라고 대답하는데, 이 또한 얼버무리는 수준에 지나지 않습니다. 이 답변이 전달하려는 의도는 OO는 현실 세계와 의미적으로 가깝기 때문에 OO를 사용하면 소프트웨어를 좀 더 쉽게 이해할 수 있다는 데 있는 듯 합니다. 하지만 그 정의가 너무 모호합니다.

 OO의 본질을 설명하기 이해 세 가지 주문에 기대하는 분류도 있는데, 캡슐화, 상속, 다형석이 바로 그 주문입니다. 이들은 이 세가지를 적절하게 조합하거나, 최소한 이 세가지는 반드시 지원해야 한다고 생각합니다. 이 세가지를 먼저 살펴봅시다.

 

캡슐화?

 객체 지향 정의하는 요소로 캡슐화를 언급하는 이유는 데이터와 함수를 쉽고 효과적으로 캡슐화 하는 방법을 객체 지향이 제공하기 때문입니다. 그리고 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있습니다. 구분선 바깥에서 데이터는 은닉되고, 일부 함수만이 외부에 노출됩니다. 이 개념들은 각각 클래스의 private 멤버 데이터와 public 멤버 함수로 표현됩니다. 이러한 개념은 사실 C 언어에서도 완벽한 캡슐화가 가능합니다.

 

point.h

struct Point;
struct Point* makePoint(double x, double y);
double distance (struct Point *p1, struct Point *p2);

point.c

#include "point.h"
#include <stdlib.h>
#include <math.h>

struct Point {
  double x, y;
};

struct Point* makePoint (double x, double y) {
  struct Point* p = malloc(sizeof(struct Point));
  p->x = x;
  p->y = y;
  return p;
}

double distance(struct Point* p1, struct Point* p2) {
  double dx = p1->x - p2->x;
  double dy = p1->y - p2->y;
  return sqrt(dx*dx+dy*dy);
}

 

 point.h를 사용하는 측에서 struct Point의 멤버에 접근할 방법이 전혀 없습니다. 사용자는 makePoint() 함수와 distance() 함수를 호출할 수는 있지만, Point 구조체의 데이터 구조와 함수가 어떻게 구현되었는지에 대해서는 조금도 알지 못합니다. 이것에 완벽한 캡슐화입니다.

 이 후로 C++ 컴파일러는 기술적인 이유(클래스의 인스턴스 크기를 알 수 있어야 한다)로 클래스의 멤버 변수를 해당 클래스의 헤더 파일에 선언할 것을 요구하며 완전환 캡슐화가 깨졌습니다. 헤더파일을 사용하는 측에서 멤버 변수인 x, y를 알게 된 것이지요.

 언어에 public private, protected 키워드를 도입하면서 불완전한 캡슐화는 어느정도 보완하기는 했지만, 이는 컴파일러가 헤더 파일에서 멤버 변수를 볼 수 있어야 했기 때문에 조치한 임시방편일 뿐이었습니다.

 자바와 C#은 헤더와 구현체를 분리하는 방식을 모두 버리며 캡슐화는 더욱 심하게 훼손되었습니다. 클래스 선언과 정의를 구분하는 것이 아예 불가능해진것이지요.

 이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘듭니다. 실제로 많은 OO 언어가 캡슐화를 거의 강제하지 않습니다. (Smalltalk, Python, JavaScript, Lua, Ruby가 이에 해당됩니다.)

 

상속?

 OO 언어가 더 나은 캡슐화를 제공하지는 못했지만, 상속만큼은 OO 언어가 확실히 제공했습니다. 얼추 맞는 말입니다. 하지만 상속이란 단순히 어떤 변수와 함수를 하나의 유효 범위를 묶어서 재정의하는 일에 불과합니다. 사실 OO 언어가 있기 훨씬 이전에도 C 프로그래머는 언어의 도움 없이 손수 이러한 방식으로 구현할 수 있었습니다.

#include "namedPoint.h"

struct NamedPoint {
  double x,y;
  char* name;
}

 

위 코드에서 NamedPoint로 생성한 데이터는 다음과 같이 Point를 인자로 받는 함수에 넘길 수 있습니다.

struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight");
distance((struct Point*) origin, (struct Point*) upperRight);

 위 코드가 가능한 것은 NamePoint에 선언된 두 변수의 순서가 Point와 동일하기 때문입니다. 다시 말해 NamedPoint는 Point의 가면을 쓴 것처럼 동작할 수 있습니다. 이는 OO가 출현하기 이전부터 흔히 사용하던 기법이었습니다. 실제로 C++에서 이 방법을 이용해서 단일 상속을 구현하였습니다.

 이런 방식으로 다중상속을 구현하는 것은 어렵기 때문에.. 상속이 편리한 것은 맞습니다. 그러나 OO가 완전히 새로운 개념을 만든 것은 아니지 때문에 0.5점 정도를 부여할 수 있겠습니다.

 

다형성?

 OO 언어가 있기 이전에 다형성을 표현할 수 있는 언어가 있었을까요? 당연히 있었습니다.

#include <stdio.h>

void copy() {
  int c;
  while ((c=getchar()) != EOF)
    putchar(c);
}

 

 위 코드에서 putchar() 함수는 STDIN에서 문자를 읽습니다. 그러면 STDIN은 어떤 장치인가요? putchar() 함수는 STDOUT으로 문자를 씁니다. STDOUT은 또 어떤 장치인가요? 이러한 함수는 다형적(polymorphic)입니다. 즉, 행위가 STDIN과 STDOUT의 타입에 의존합니다.

 그렇다면 어떤 방식으로 문자를 읽는 장치 드라이버를 호출할 수 있는걸까요? 유닉스 운영체제의 경우 모든 입출력 장치 드라이버가 다섯 가지 표준 함수를 제공할 것을 요구합니다. 열기(open), 닫기(close), 읽기(read), 쓰기(write), 탐색(seek)이 바로 표준 함수들입니다.

 FILE 데이터 구조는 이들 다섯 함수를 가리키는 포인터들을 포함합니다. 따라서 STDIN을 FILE*로 선언하면, STDIN은 콘솔 데이터 구조를 가리키므로 getchar()는 아래와 같은 방식으로 구현할 수 있습니다.

extern struct FILE* STDIN;

int getchar() {
  return STDIN->read();
}

 

 이처럼 단순한 기법이 모든 OO가 지닌 다형성의 근간이 됩니다. 예를 들어 C++에서는 클래스의 모든 가상 함수는 vtable이라는 테이블에 포인터를 가지고 있고, 모든 가상 함수 호출은 이 테이블을 거치게 됩니다. 파생 클래스의 생성자는 생성하려는 객체의 vtable을 단순히 자신(파생 클래스)의 함수들로 덮어 쓸 뿐입니다.

 말하려는 요지는 함수를 가리키는 포인터를 응용한 것이 다형성이라는 점입니다. 따라서 OO가 새롭게 만든 것은 전혀 없습니다. 이 말이 완전히 옳은 것이 아닌 것은... OO언어는 다형성을 제공하지는 못했지만, 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해줍니다. 즉, OO언어는 꿈에서야 볼 수 있는 강력한 기능을 제공한 것입니다. 이러한 이유로 OO는 제어흐름을 간접적으로 전환하는 규칙을 부과한다고 결론지을 수 있습니다.

 

다형성이 가진 힘

 다형성이 뭐가 그렇게 좋은걸까요? 새로운 입출력 장치가 생기면 어떤 변화가 있을까요? 필기체 인식 장치로부터 데이터를 읽어서 음성 합성 장치로 복사할 때 기존 복사 프로그램에서 동작하도록 한다면 어떻게 수정해야할까요 ?

 아무런 변경이 필요치 않습니다. 심지어 복사 프로그램을 다시 컴파일할 필요도 없습니다. 복사 프로그램이 입출력 드라이버 소스 코드에 의존하지 않기 때문입니다. 입출력 드라이버가 FILE에 정의된 다섯 가지 표준 함수만 구현한다면, 복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있습니다. 즉, 입출력 드라이버가 복사 프로그램의 플러그인이 된 것입니다.

 1950년대 후반에 우리는 프로그램이 장치 독립적이어야 한다는 사실을 이미 배웠습니다. 장치에 의존적인 수 많은 프로그램을 만들고 깨닫게 된 것이지요.

 플러그인 아키텍처는 이처럼 입출력 장치 독립성을 지원하기 위해 만들어졌고, 등장 이후 거의 모든 운영체제에서 구현되었습니다. 그럼에도 이러한 개념을 확장하여 적용하지 않았는데, 함수를 가리키는 포인터를 사용하면 위험을 수분하기 때문이었습니다.

 OO의 등장으로 언제 어디서든 플러그인 아키텍처를 적용할 수 있게 되었습니다.

 

의존성 역전 (Dependency Inversion)

 다형성을 안전ㅇ하고 편리하게 적용할 수 있는 매커니즘이 등장하기 전 소프트웨어는 어떤 모습이었을까요? main() 함수는 고수준 함수를 호출하고, 고수준 함수는 다시 중간 수준 함수를 호출하고, 다시 저수준 함수까지 호출하는 트리 구조를 가졌을 것입니다. 소스코드 의존성의 방향은 반드시 제어 흐름(flow of control)을 따르게 되었습니다.

 이러한 제약 조건으로 인해 소프트웨어 아키텍트에게 남은 선택지는 별로 없었습니다. 즉, 제어흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정됩니다. 하지만 여기에 다형성이 끼어들면 특별한 일이 일어납니다.

 

 실제 소스코드에서 HL1은 I의 F() 함수를 호출합니다. 이 인터페이스는 런타임에는 존재하지 않습니다. HL1은 단순히 ML1 모듈의 F() 함수를 호출할 뿐입니다.

 소스코드 의존성은 HL1 -> I 이지만, 제어 흐름은 HL1 -> ML1 입니다. 즉 서로 반대입니다. 이는 의존성 역전(DI)이라고 부르며, 소프트웨어 아키텍트 관점에서 이러한 현상은 심오한 의미를 갖습니다.

 OO 언어가 다형성을 안전하고 편리하게 제공한다는 사실은 소스 코드 의존성을 어디에서든지 역전시킬 수 있다는 뜻이기도 합니다.

 이러한 접근법을 사용한다면, OO 언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 시스템의 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖게 됩니다. 즉, 소스 코드 의존성이 제어흐름의 방향과 일치되도록 제한되지 않습니다.

 이것이 바로 OO가 제공하는 힘입니다. 그리고 이것이 바로 OO가 지향하는 것입니다. (최소한 아키텍트 관점에서는...)

 

마무리

 OO란 무엇인가? 다양한 의관과 답변이 있었지만, 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력입니다. OO를 사용하면 아키텍트는 플러그인 아키텍처를 구성할 수 있고, 이를 통해 고수준의 정책을 포함하는 모듈은 저수준의 세부사항을 포함하는 모듈에 대해 독립성을 보장할 수 있습니다. 저수준의 세부사항은 중요도가 낮은 플러그인 모듈로 만들 수 있고, 고수준의 정책을 포함하는 모듈과는 독립적으로 개발하고 배포할 수 있습니다.

 

관련글

내용이 도움이 되셨으면 공감 버튼 꼬옥 눌러주세요
본문을 퍼가실 경우 댓글을 달아주세요