좋은 아키텍처를 만드는 일은 객체 지향(Object-Oriented, 이하 OO) 설계 원칙을 이해하고 응용하는 데서 출발한다. 그렇다면 OO란 무엇인가?
이 질문에 누군가는 "데이터와 함수의 조합" 이라고 대답하기도, "실제 세계를 모델링하는 새로운 방법"이라고들 답한다. 하지만 불분명하며 모호하다.
혹은 OO의 본질을 설명하기 위해 세 가지 주문에 기대는 부류 또한 있는데, 캡슐화, 상속, 다형성이 바로 그 주문이다. 이들은 OO가 이 세 가지 개념을 조합한 것이거나, 최소한 이 세 가지 요소를 반드시 지원해야 한다고 말한다. 그렇다면 이들 세 가지 개념을 차례대로 살펴보자.
OO를 정의하는 요소로 캡슐화를 언급하는 이유는 OO 언어가 효과적으로 캡슐화하는 방식을 제공하기 때문이다. 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있다. 구분선 바깥에서 데이터는 은닉되고, 일부 메서드만 노출된다.
하지만 이러한 개념이 OO에만 국한된 것은 아니다. 사실 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 언어에서도 완벽하게 캡슐화가 가능하다.
하지만, 이후 C++에서는 기술적인 이유로 클래스의 멤버 변수를 해당 클래스 헤더 파일에 선언할 것을 요구했다. 따라서 사용자는 멤버 변수가 존재한다는 사실 자체를 알게 된다.
또한 자바와 C#은 헤더와 구현체를 분리하는 방식을 모두 버렸고, 이로 인해 캡슐화는 더욱 심하게 훼손되었다.
이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다. 실제로 많은 OO 언어가 캡슐화를 거의 강제하지 않는다. OO 프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을 거라는 믿음을 기반으로 한다.
상속만큼은 OO 언어가 확실히 제공했다. 얼추 맞는 말이지만 상속이란 단순히 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 일에 불과하다. 이 역시 C 언어에서는 손수 이러한 방식으로 구현할 수 있었다. 앞의 point.h C 프로그램에 이어서 아래의 코드를 보자.
// namedPoint.h
struct NamedPoint;
struct NamedPoint* makeNamedPoint(double x, double y, char* name);
void setName(struct NamedPoint* np, char* name);
char* getName(struct NamedPoint* np);
// namedPoint.c
#include "namedPoint.h"
#include <stdlib.h>
#include <string.h>
struct NamedPoint {
double x, y;
char* name;
};
struct NamedPoint* makeNamedPoint(double x, double y, char* name) {
struct NamedPoint* p = malloc(sizeof(struct NamedPoint));
p->x = x;
p->y = y;
p->name = strdup(name);
return p;
}
void setName(struct NamedPoint* np, char* name) {
free(np->name);
np->name = strdup(name);
}
char* getName(struct NamedPoint* np) {
return np->name;
}
// main.c
#include "point.h"
#include "namedPoint.h"
#include <stdio.h>
int main(int argc, char** argv) {
struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight");
/* ... */
}main 프로그램을 잘 살펴보면 NamedPoint 데이터 구조가 마치 Point 데이터 구조로 부터 파생된 구조인 것처럼 동일하게 동작하는 것을 볼 수 있다. 이는 NamedPoint가 순전히 Point를 포함하는 상위 집합으로, Point에 대응하는 멤버 변수의 순서가 그대로 유지되기 때문이다.
이와 같은 눈속임 방식은 OO가 출현하기 이전부터 사용되던 기법이다. 하지만 엄밀히 말하면 이는 상속을 흉내내는 요령이지만, 상속만큼 편리한 방식은 아니다. 또한 이 기법을 사용해 다중 상속을 구현하기란 훨씬 어렵다.
따라서 OO언어가 상속이라는 완전히 새로운 개념을 만들지는 못했지만, 상당히 편리한 방식으로 제공하도록 했다고는 볼 수 있다.
OO 언어 이전에 다형성을 표현할 수 있던 언어가 있던가? 당연히 있다. C 언어에서는 함수를 가리키는 포인터를 활용하여 다형성을 제공한다. 아래의 C 언어로 작성한 복사 프로그램을 살펴보자.
#include <stdio.h>
void copy() {
int c;
while ((c = getchar()) != EOF)
putchar(c);
}getChar()는 STDIN에서 문자를 읽고, putChar()는 STDOUT으로 문자를 쓴다. 그러면 STDIN, STDOUT 은 어떠한 장치인가? 이러한 함수는 다형적이다. 즉, 행위가 STDIN과 STDOUT의 타입에 의존한다.
STDIN과 STDOUT은 사실상 자바 형식의 인터페이스로, 자바에서는 각 장치별로 구현체가 있다. 하지만 C 언어의 프로그램에서는 이러한 인터페이스는 없다. 그렇다면 getChar()와 putChar()를 호출할 때 어떻게 장치 드라이버를 호출할 수 있는 것인가?
유닉스 운영체제의 경우 모든 입출력 장치 드라이버가 다섯 가지 표준 함수를 제공할 것을 요구한다. 열기, 닫기, 읽기, 쓰기, 탐색 이 표준 함수들이다.
FILE 데이터 구조는 이들 다섯 함수를 가리키는 포인터들을 포함한다. 이 예제의 경우라면 다음과 같을 것이다.
struct FILE {
void (*open)(char* name, int mode);
void (*close)();
int (*read)();
void (*write)(char);
void (*seek)(long index, int mode);
};콘솔용 입출력 드라이버에서는 이들 함수를 아래와 같이 정의하며, FILE 데이터 구조를 함수에 대한 주소와 함께 로드할 것이다.
#include "file.h"
void open(char* name, int mode) {/*...*/}
void close() {/*...*/}
int read() {int c; /*...*/ return c;}
void write(char c) {/*...*/}
void seek(long index, int mode) {/*...*/}
struct FILE console = {open, close, read, write, seek};이제 STDIN을 FILE*로 선언하면, STDIN은 콘솔 데이터 구조를 가리키므로, getChar()는 아래와 같은 방식으로 구현할 수 있다.
extern struct FILE* STDIN;
int getchar () {
return STDIN->read();
}다시 말해 getChar()는 STDIN으로 참조되는 FILE 데이터 구조의 read 포인터가 가리키는 함수를 단순히 호출할 뿐이다. 이렇게 되면 어떠한 장치든 간에 문자를 읽고 쓸 수 있다.
이처럼 단순한 기법이 모든 OO가 지닌 다형성의 근간이 된다. 함수를 가리키는 포인터를 응용한 것이 다형성이다. 따라서 OO가 새롭게 마든것은 전혀없다. 하지만 역시 OO 언어는 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다.
함수에 대한 포인터를 직접 사용하여 다형적 행위를 만드는 이 방식에는 문제가 있다. 함수 포인터는 위험하기 때문이다. 이를 사용하려면 포인터를 초기화하는 관례를 지켜야 한다. 또한 포인터를 통해 모든 함수를 호출하는 관례를 지켜야한다. 이를 망각하면 버그가 발생한다.
OO 언어는 이러한 관례를 없애주며, 따라서 실수할 위험이 없다. 이러한 이유로 OO는 제어흐름을 간접적으로 전환하는 규칙을 부과한다고 결론지을 수 있다.
다형성의 매력을 알아보기 위해 앞서 본 복사 프로그램 예제를 다시 살펴보자. 새로운 입출력 장치가 생긴다고 치자. 프로그램에 어떤 변화가 생길까?
아무런 변화가 필요없다. 왜냐면 복사 프로그램의 소스 코드는 이미 입출력 드라이버의 소스 코드에 의존하지 않기 때문이다. 입출력 드라이버는 FILE에 정의된 다섯 가지 표준함수(유닉스 운영체제의) 만 참조할 뿐이다.
다시말해 입출력 드라이버가 복사 프로그램의 플러그인이 되는 것이다.
그렇다면 왜 유닉스 운영체제는 입출력 장치들을 플러그인 형태로 만들었는가? 1950년대 후반, 그 당시 여러 프로그램을 만들고 나서 프로그램은 장치 독립적이어야한다는 사실, 즉 프로그램이 다른 장치들에서도 동일하게 동작할 수 있다는 사실을 깨달았기 때문이다.
이러한 플러그인 아키텍처는 입출력 장치 독립성을 지원하기 위해 만들어졌고, 등장 이후 거의 모든 운영체제에서 구현되었다. OO의 등장으로 언제 어디서든 플러그인 아키텍처를 적용할 수 있게 되었다.
다형성을 적용하기 전 소프트웨어의 모습을 상상해보자. 전형적인 호출 트리의 경우 main 함수 에서 시작하여, 고수준 함수, 중간 수준 함수, 저수준 함수 순서대로 각각의 함수가 아래로 차래대로 호출하게 된다. 이러한 호출 트리에서 소스 코드 의존성의 방향은 반드시 제어흐름(flow of control)을 따르게 된다.
함수를 호출하려면 함수가 포함된 모듈의 이름을 지정해아한다. 이러한 제약 조건으로 인해 소프트웨어 아키텍트에게 선택지는 별로 없었다. 즉, 제어 흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어 흐름에 따라 결정된다.
하지만 이때 다형성이 끼어들면 무언가 특별한 일이 일어난다.
그림 5.2 에서 HL1 모듈은 ML1 모듈의 F() 함수를 호출한다. 소스 코드에서는 HL1 모듈은 I 인터페이스를 통해 F() 함수를 호출한다. 이 인터페이스는 런타임에는 존재하지 않는다. HL1 은 그저 ML1 모듈의 함수 F() 를 호출하는 것 뿐이다.
하지만 ML1 과 I 인터페이스 사이의 소스 코드 의존성이 제어 흐름과 반대인 점을 주목하자. 앞서 소스 코드 의존성은 제어 흐름을 따라간다. F() 함수를 호출하려면 그보다 상위 수준의 모듈은 그 함수가 포함된 모듈의 이름을 지정해야한다.
하지만 위의 경우, 호출되는 함수인 F()는 상속관계를 통해 I 인터페이스와 소스 코드 의존성을 가진다. 따라서 제어흐름과 관계 없는 소스 코드 의존성이 생성된다.
이는 의존성 역전이라고 부른다.
이러한 접근법을 사용한다면 OO 언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 시스템의 소스 코드 의존성 번부 에대한 방향을 시스템의 행위가 아닌 자신이 직접 결정할 수 있는 절대적인 권한을 갖는다.
즉, 소스 코드 의존성이 제어 흐름의 방향과 일치되도록 제한되지 않는다. 호출 혹은 호출 받는 모듈이든 소스 코드 의존성을 원하는 방향을 설정할 수 있다.
이러한 힘으로 예를 들어, 업무 규칙이 데이터베이스와 UI에 의존하는 대신, 시스템의 소스 코드 의존성을 반대로 배치하여 데이터 베이스와 UI가 업뮤 규칙에 의존하게 만들 수 도있다.
결과적으로 업무 규칙, UI, 데이터베이스는 세 가지로 분리된 컴포넌트 또는 배포 가능한 단위로 컴파일할 수 있으며, 이 배포 단위들의 의존성 역시 소스 코드 사이의 의존성과 같다.
이는 또한 배포를 독립적으로 가능하다는 것을 의미하며, 결과적으로 각 모듈을 독립적으로 개발할 수 있는 개발 독립성으로 이어지게 된다.
"OO란 무엇인가?" 에 대한 답변은 소프트웨어 아키텍트 관점에서 명백하게 말할 수 있다. OO는 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력이다.
이를 통해 고수준 모듈과 저수준 모듈의 독립성을 보장할 수 있다. 고수준의 정책을 포함하는 모듈과 저수준의 세부사항은 중요도가 낮은 플러그인 모듈로 만들어 배포할 수 있다.

