로버트 C. 마틴의 클린 아키텍처: 소프트웨어 구조와 설계의 원칙을 읽으며 기록하고 싶은 부분만 발췌해 각색하여 작성한 글입니다. 하여 실제 글쓴이의 의도와 다르게 작성될 수 있음을 알립니다.
좋은 아키텍처를 만드는 일은 객체 지향 Object-Oriented, OO 설계 원칙을 이해하고 응용하는 데서 출발한다. 그렇다면 객체 지향이란 무엇인가?
객체 지향의 본질을 설명하기 위해 캡슐화 encapsulation, 상속 inheritance, 다형성 polymorphism 세 가지 주문에 기대는 부류도 있는데, 이들은 객체 지향이 이 세 가지 개념을 적절하게 조합한 것이거나, 또는 객체 지향 언어는 최소한 세 가지 요소를 반드시 지원해야 한다고 말한다.
# 캡슐화
객체 지향을 정의하는 요소 중 하나로 캡슐화를 언급하는 이유는 데이터와 함수를 쉽고 효과적으로 캡슐화하는 방법을 객체 지향 언어가 제공하기 때문이다. 그리고 이를 통해 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있다. 구분선 바깥에서 데이터는 은닉되고, 일부 함수만이 외부에 노출된다. 이 개념들이 실제 객체 지향 언어에서는 각각 클래스의 private 멤버 데이터와 public 멤버 함수로 표현된다. 이러한 개념이 객체 지향에만 국한된 것은 아니다. 사실 C언어에서도 완벽한 캡슐화가 가능하다. 아래의 간단한 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가 제공하던 완전한 캡슐화가 깨지게 되었다. 언어에 public, private protected 키워드를 도입함으로써 불완전한 캡슐화를 사실상 어느 정도 보완하기는 했다. 하지만 이는 임시방편일 뿐이다. 자바와 C#은 헤더와 구현체를 분리하는 방식을 모두 버렸고, 이로 인해 캡슐화는 더욱 심하게 훼손되었다. 이들 언어에서는 클래스 선언과 정의를 구분하는 게 아예 불가능하다. 이 때문에 객체 지향이 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다. 실제로 많은 객체 지향 언어가 캡슐화를 거의 강제하지 않는다.
객체 지향 프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을 거라는 믿음을 기반으로 한다. 하지만 객체 지향을 제공한다고 주창한 언어들이 실제로는 C언어에서 누렸던 완벽한 캡슐화를 약화시켜 온 것은 틀림없다.
# 상속
객체 지향 언어가 더 나은 캡슐화를 제공하지는 못했지만, 상속만큼은 객체 지향 언어가 확실히 제공했다. 하지만 상속이란 단순히 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 일에 불과하다. 사실상 객체 지향 언어가 있기 훨씬 이전에도 C 프로그래머는 언어의 도움 없이 손수 이러한 방식으로 구현할 수 있었다.
객체 지향 언어가 고안되기 훨씬 이전에도 상속과 비슷한 기법이 사용되었다고 말할 수 있다. 하지만 이렇게 말하는 데는 어폐가 있다. 상속을 흉내내는 요령은 있었지만, 사실상 상속만큼 편리한 방식은 절대 아니기 때문이다. 게다가 이 기법을 이용해서 단일 상속을 구현할지라도 다중 상속을 구현하기란 훨씬 더 어려운 일이었다. 또한 이 기법에서는 자식 인자를 부모의 타입으로 강제로 변환하기도 하는데, 진짜 객체 지향 언어에서는 이러한 업캐스팅이 암묵적으로 이뤄진다.
따라서 객체 지향 언어가 완전히 새로운 개념을 만들지는 못했지만, 데이터 구조에 가면을 씌우는 일을 상당히 편리한 방식으로 제공했다고 볼 수는 있다. 간략히 요약하면, 캡슐화에 대해서는 객체 지향에 점수를 줄 수 없고, 상속에 대해서만 0.5점 정도를 부여할 수 있다. 이렇게만 보면 그저 그런 점수다. 하지만 아직 고려해야 할 속성이 하나 더 남았다.
# 다형성
객체 지향 언어가 있기 이전에 다형성을 표현할 수 있는 언어가 있었던가? 당연히 있었다. 아래의 C로 작성한 간단한 복사 프로그램을 보자.
#include <stdio.h>
void copy() {
int c;
while ((c=getchar() != EOF)
putchar(c);
}
getchar() 함수는 STDIN에서 문자를 읽는다. putchar() 함수는 STDOUT으로 문자를 쓴다. 이러한 함수는 다형적polymorphic이다. 즉, 행위가 STDIN과 STDOUT의 타입에 의존한다.
STDIN과 STDOUT은 사실상 자바 형식의 인터페이스로, 자바에서는 각 장치별로 구현체가 있다. 물론 예제의 C 프로그램에는 이러한 인터페이스는 없다. 그렇다면 getchar() 함수를 호출할 때 어떤 방식으로 문자를 읽는 장치 드라이버를 호출할 수 있는걸까? 유닉스 운영체제의 경우 모든 입출력 장치 드라이버가 다섯 가지 표준 함수를 제공할 것을 요구한다. 열기(open), 닫기(close), 읽기(read), 쓰기(write), 탐색()seek이 바로 이 표준 함수들이다.
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 clode() {/*...*/}
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 포인터가 가리키는 함수를 단순히 호출할 뿐이다. 이처럼 단순한 기법이 모든 객체 지향이 지닌 다형성의 근간이 된다. 예를 들어 C++에서는 클래스의 모든 가상 함수 virtual function는 vtable이라는 테이블에 포인터를 가지고 있고, 모든 가상 함수 호출은 이 테이블을 거치게 된다. 파생 클래스의 생성자는 생성하려는 객체의 vtable을 단순히 자신의 함수들로 덮어 쓸 뿐이다.
말하려는 요지는 함수를 가리치는 포인터를 응용한 것이 다형성이라는 점이다. 1940년대 후반 폰 노이만 아키텍처가 처음 구현된 이후 프로그래머는 다형적 행위를 수행하기 위해 함수를 가리키는 포인터를 사용해 왔다. 따라서 객체 지향이 새롭게 만든 것은 전혀 없다.
이 말이 완전히 옳은 말이 아니긴 하다. 객체 지향 언어는 다형성을 제공하지는 못했지만, 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다. 함수에 대한 포인터를 직접 사용하여 다형적 행위를 만드는 이 방식에는 문제가 있는데, 함수 포인터는 위험하다는 사실이다. 이러한 기법은 프로그래머가 특정 관례를 수동으로 따르는 방식이다. 즉, 이들 포인터를 초기화하는 관례를 준수해야 한다는 사실을 기억해야 한다. 그리고 이들 포인터를 통해 모든 함수를 호출하는 관례를 지켜야 한다는 점도 기억해야 한다. 만약 프로그래머가 관례를 지켜야 한다는 사실을 망각하게 되면 버그가 발생하고, 이러한 버그는 찾아내고 없애기가 지독히도 힘들다.
객체 지향 언어는 이러한 관례를 없애주며, 따라서 실수할 위험이 없다. 객체 지향 언어를 사용하면 다형성은 대수롭지 않은 일이 된다. 객체 지향 언어는 과거 C 프로그래머가 꿈에서야 볼 수 있던 강력한 능력을 제공한다. 이러한 이유로 객체 지향은 제어흐름을 간접적으로 전환하는 규칙을 부과한다고 결론지을 수 있다.
# 다형성이 가진 힘
다형성이 뭐가 그렇게 좋은걸까? 다시 복사 프로그램 예제를 살펴보자. 새로운 입출력 장치가 생긴다면 프로그램에는 어떤 변화가 생기는가? 아무런 변경도 필요치 않다! 심지어 복사 프로그램을 다시 컴파일할 필요조차 없다. 왜냐하면 복사 프로그램의 소스 코드는 입출력 드라이버의 소스 코드에 의존하지 않기 때문이다. 입출력 드라이버가 FILE에 정의된 다섯가지 표준 함수를 구현한다면, 복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있다. 객체 지향의 등장으로 언제 어디서든 플러그인 아키텍처를 적용할 수 있게 되었다.
# 의존성 역전
다형성을 안전하고 편리하게 적용할 수 있는 메커니즘이 등장하기 전 소프트웨어는 어떤 모습이었을까. 전형적인 호출 트리의 경우 main 함수가 고수준 함수를 호출하고, 고수준 함수는 중간 수준 함수를 호출하며, 중간 수중 함수는 다시 저수준 함수를 호출한다. 이러한 호출 트리에서 소스 코드 의존성의 방향은 반드시 제어 흐름 flow of control을 따르게 된다.

main 함수가 고수준 함수를 호출하려면 고수준 함수가 포함된 모듈의 이름을 지정해야만 한다. C의 지정자는 #include, 자바나 스위프트는 import 구문이다. C#에서는 using 구문이다. 실제로 모든 호출 함수는 피호출 함수가 포함된 모듈의 이름을 명시적으로 지정해야 한다. 이러한 제약 조건으로 인해 소프트웨어 아키텍트에게 남은 선택지는 별로 없었다. 즉, 제어흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정된다. 하지만 다형성이 끼어들면 특별한 일이 일어난다.

그림 5.2에서 HL1 모듈은 ML1 모듈의 F() 함수를 호출한다. 소스 코드에서는 HL1 모듈은 인터페이스를 통해 F() 함수를 호출한다. 이 인터페이스는 런타임에는 존재하지 않는다. HL1은 단순히 ML1 모듈의 함수 F()를 호출할 뿐이다.
하지만 ML1과 I 인터페이스 사이의 소스 코드 의존성(상속 관계)이 제어흐름과는 반대인 점을 주목하자. 이는 의존성 역전 dependency inversion이라고 부르며, 소프트웨어 아키텍트 관점에서 이러한 현상은 심오한 의미를 갖는다. 객체 지향 언어가 다형성을 안전하고 편리하게 제공한다는 사실은 소스 코드 의존성을 어디서든 역전시킬 수 있다는 뜻이기도 하다.
이제 다시 그림 5.1의 호출 트리를 보면 수많은 소스 코드 의존성을 확인할 수 있다. 이러한 소스 코드 의존성은 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다. 이러한 접근법을 사용한다면, 객체 지향 언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 시스템의 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖는다. 이것이 바로 객체 지향이 제공하는 힘이고, 이것이 최소한 아키텍트의 관점에서는 객체 지향이 지향하는 것이다.
그럼 이 힘으로 무엇을 할 수 있을까? 예를 들어 업무 규칙이 DB와 UI에 의존하는 대신에, 시스템의 소스 코드 의존성을 반대로 배치하여 DB와 UI가 업무 규칙에 의존하게 만들 수 있다.

즉, UI와 DB가 업무 규칙의 플러그인이 된다는 뜻이다. 다시 말해 업무 규칙의 소스 코드에서는 UI나 DB를 호출하지 않는다. 결과적으로 업무 규칙, UI, DB는 세 가지로 분리된 컴포넌트 또는 배포 가능한 단위로 컴파일할 수 있고, 이 배포 단위들의 의존성 역시 소스 코드 사이의 의존성과 같다. 따라서 업무 규칙을 포함하는 컴포넌트는 UI와 DB를 포함하는 컴포넌트에 의존하지 않는다.
따라서 업무 규칙을 UI와 DB와는 독립적으로 배포할 수 있다. UI나 DB에서 발생한 변경사항은 업무 규칙에 일절 영향을 미치지 않는다. 즉, 이들 컴포넌트는 개별적이며 독립적으로 배포 가능하다. 이것이 바로 배포 독립성 independent deployability이다.
시스템의 모듈을 독립적으로 배포할 수 있게 되면, 서로 다른 팀에서 각 모듈을 독립적으로 개발할 수 있다. 그리고 이것이 개발 독립성 independent developability이다.
# 결론
객체지향이란 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력이다. 객체 지향을 사용하면 아키텍트는 플러그인 아키텍처를 구성할 수 있고, 이를 통해 고수준의 정책을 포함하는 모듈은 저수준의 세부사항을 포함하는 모듈에 대해 독립성을 보장할 수 있다. 저수준의 세부사항은 중요도가 낮은 플러그인 모듈로 만들 수 있고, 고수준의 정책을 포함하는 모듈과는 독립적으로 개발하고 배포할 수 있다.
로버트 C. 마틴의 클린 아키텍처: 소프트웨어 구조와 설계의 원칙을 읽으며 기록하고 싶은 부분만 발췌해 각색하여 작성한 글입니다. 하여 실제 글쓴이의 의도와 다르게 작성될 수 있음을 알립니다.
좋은 아키텍처를 만드는 일은 객체 지향 Object-Oriented, OO 설계 원칙을 이해하고 응용하는 데서 출발한다. 그렇다면 객체 지향이란 무엇인가?
객체 지향의 본질을 설명하기 위해 캡슐화 encapsulation, 상속 inheritance, 다형성 polymorphism 세 가지 주문에 기대는 부류도 있는데, 이들은 객체 지향이 이 세 가지 개념을 적절하게 조합한 것이거나, 또는 객체 지향 언어는 최소한 세 가지 요소를 반드시 지원해야 한다고 말한다.
# 캡슐화
객체 지향을 정의하는 요소 중 하나로 캡슐화를 언급하는 이유는 데이터와 함수를 쉽고 효과적으로 캡슐화하는 방법을 객체 지향 언어가 제공하기 때문이다. 그리고 이를 통해 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있다. 구분선 바깥에서 데이터는 은닉되고, 일부 함수만이 외부에 노출된다. 이 개념들이 실제 객체 지향 언어에서는 각각 클래스의 private 멤버 데이터와 public 멤버 함수로 표현된다. 이러한 개념이 객체 지향에만 국한된 것은 아니다. 사실 C언어에서도 완벽한 캡슐화가 가능하다. 아래의 간단한 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가 제공하던 완전한 캡슐화가 깨지게 되었다. 언어에 public, private protected 키워드를 도입함으로써 불완전한 캡슐화를 사실상 어느 정도 보완하기는 했다. 하지만 이는 임시방편일 뿐이다. 자바와 C#은 헤더와 구현체를 분리하는 방식을 모두 버렸고, 이로 인해 캡슐화는 더욱 심하게 훼손되었다. 이들 언어에서는 클래스 선언과 정의를 구분하는 게 아예 불가능하다. 이 때문에 객체 지향이 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다. 실제로 많은 객체 지향 언어가 캡슐화를 거의 강제하지 않는다.
객체 지향 프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을 거라는 믿음을 기반으로 한다. 하지만 객체 지향을 제공한다고 주창한 언어들이 실제로는 C언어에서 누렸던 완벽한 캡슐화를 약화시켜 온 것은 틀림없다.
# 상속
객체 지향 언어가 더 나은 캡슐화를 제공하지는 못했지만, 상속만큼은 객체 지향 언어가 확실히 제공했다. 하지만 상속이란 단순히 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 일에 불과하다. 사실상 객체 지향 언어가 있기 훨씬 이전에도 C 프로그래머는 언어의 도움 없이 손수 이러한 방식으로 구현할 수 있었다.
객체 지향 언어가 고안되기 훨씬 이전에도 상속과 비슷한 기법이 사용되었다고 말할 수 있다. 하지만 이렇게 말하는 데는 어폐가 있다. 상속을 흉내내는 요령은 있었지만, 사실상 상속만큼 편리한 방식은 절대 아니기 때문이다. 게다가 이 기법을 이용해서 단일 상속을 구현할지라도 다중 상속을 구현하기란 훨씬 더 어려운 일이었다. 또한 이 기법에서는 자식 인자를 부모의 타입으로 강제로 변환하기도 하는데, 진짜 객체 지향 언어에서는 이러한 업캐스팅이 암묵적으로 이뤄진다.
따라서 객체 지향 언어가 완전히 새로운 개념을 만들지는 못했지만, 데이터 구조에 가면을 씌우는 일을 상당히 편리한 방식으로 제공했다고 볼 수는 있다. 간략히 요약하면, 캡슐화에 대해서는 객체 지향에 점수를 줄 수 없고, 상속에 대해서만 0.5점 정도를 부여할 수 있다. 이렇게만 보면 그저 그런 점수다. 하지만 아직 고려해야 할 속성이 하나 더 남았다.
# 다형성
객체 지향 언어가 있기 이전에 다형성을 표현할 수 있는 언어가 있었던가? 당연히 있었다. 아래의 C로 작성한 간단한 복사 프로그램을 보자.
#include <stdio.h>
void copy() {
int c;
while ((c=getchar() != EOF)
putchar(c);
}
getchar() 함수는 STDIN에서 문자를 읽는다. putchar() 함수는 STDOUT으로 문자를 쓴다. 이러한 함수는 다형적polymorphic이다. 즉, 행위가 STDIN과 STDOUT의 타입에 의존한다.
STDIN과 STDOUT은 사실상 자바 형식의 인터페이스로, 자바에서는 각 장치별로 구현체가 있다. 물론 예제의 C 프로그램에는 이러한 인터페이스는 없다. 그렇다면 getchar() 함수를 호출할 때 어떤 방식으로 문자를 읽는 장치 드라이버를 호출할 수 있는걸까? 유닉스 운영체제의 경우 모든 입출력 장치 드라이버가 다섯 가지 표준 함수를 제공할 것을 요구한다. 열기(open), 닫기(close), 읽기(read), 쓰기(write), 탐색()seek이 바로 이 표준 함수들이다.
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 clode() {/*...*/}
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 포인터가 가리키는 함수를 단순히 호출할 뿐이다. 이처럼 단순한 기법이 모든 객체 지향이 지닌 다형성의 근간이 된다. 예를 들어 C++에서는 클래스의 모든 가상 함수 virtual function는 vtable이라는 테이블에 포인터를 가지고 있고, 모든 가상 함수 호출은 이 테이블을 거치게 된다. 파생 클래스의 생성자는 생성하려는 객체의 vtable을 단순히 자신의 함수들로 덮어 쓸 뿐이다.
말하려는 요지는 함수를 가리치는 포인터를 응용한 것이 다형성이라는 점이다. 1940년대 후반 폰 노이만 아키텍처가 처음 구현된 이후 프로그래머는 다형적 행위를 수행하기 위해 함수를 가리키는 포인터를 사용해 왔다. 따라서 객체 지향이 새롭게 만든 것은 전혀 없다.
이 말이 완전히 옳은 말이 아니긴 하다. 객체 지향 언어는 다형성을 제공하지는 못했지만, 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다. 함수에 대한 포인터를 직접 사용하여 다형적 행위를 만드는 이 방식에는 문제가 있는데, 함수 포인터는 위험하다는 사실이다. 이러한 기법은 프로그래머가 특정 관례를 수동으로 따르는 방식이다. 즉, 이들 포인터를 초기화하는 관례를 준수해야 한다는 사실을 기억해야 한다. 그리고 이들 포인터를 통해 모든 함수를 호출하는 관례를 지켜야 한다는 점도 기억해야 한다. 만약 프로그래머가 관례를 지켜야 한다는 사실을 망각하게 되면 버그가 발생하고, 이러한 버그는 찾아내고 없애기가 지독히도 힘들다.
객체 지향 언어는 이러한 관례를 없애주며, 따라서 실수할 위험이 없다. 객체 지향 언어를 사용하면 다형성은 대수롭지 않은 일이 된다. 객체 지향 언어는 과거 C 프로그래머가 꿈에서야 볼 수 있던 강력한 능력을 제공한다. 이러한 이유로 객체 지향은 제어흐름을 간접적으로 전환하는 규칙을 부과한다고 결론지을 수 있다.
# 다형성이 가진 힘
다형성이 뭐가 그렇게 좋은걸까? 다시 복사 프로그램 예제를 살펴보자. 새로운 입출력 장치가 생긴다면 프로그램에는 어떤 변화가 생기는가? 아무런 변경도 필요치 않다! 심지어 복사 프로그램을 다시 컴파일할 필요조차 없다. 왜냐하면 복사 프로그램의 소스 코드는 입출력 드라이버의 소스 코드에 의존하지 않기 때문이다. 입출력 드라이버가 FILE에 정의된 다섯가지 표준 함수를 구현한다면, 복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있다. 객체 지향의 등장으로 언제 어디서든 플러그인 아키텍처를 적용할 수 있게 되었다.
# 의존성 역전
다형성을 안전하고 편리하게 적용할 수 있는 메커니즘이 등장하기 전 소프트웨어는 어떤 모습이었을까. 전형적인 호출 트리의 경우 main 함수가 고수준 함수를 호출하고, 고수준 함수는 중간 수준 함수를 호출하며, 중간 수중 함수는 다시 저수준 함수를 호출한다. 이러한 호출 트리에서 소스 코드 의존성의 방향은 반드시 제어 흐름 flow of control을 따르게 된다.

main 함수가 고수준 함수를 호출하려면 고수준 함수가 포함된 모듈의 이름을 지정해야만 한다. C의 지정자는 #include, 자바나 스위프트는 import 구문이다. C#에서는 using 구문이다. 실제로 모든 호출 함수는 피호출 함수가 포함된 모듈의 이름을 명시적으로 지정해야 한다. 이러한 제약 조건으로 인해 소프트웨어 아키텍트에게 남은 선택지는 별로 없었다. 즉, 제어흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정된다. 하지만 다형성이 끼어들면 특별한 일이 일어난다.

그림 5.2에서 HL1 모듈은 ML1 모듈의 F() 함수를 호출한다. 소스 코드에서는 HL1 모듈은 인터페이스를 통해 F() 함수를 호출한다. 이 인터페이스는 런타임에는 존재하지 않는다. HL1은 단순히 ML1 모듈의 함수 F()를 호출할 뿐이다.
하지만 ML1과 I 인터페이스 사이의 소스 코드 의존성(상속 관계)이 제어흐름과는 반대인 점을 주목하자. 이는 의존성 역전 dependency inversion이라고 부르며, 소프트웨어 아키텍트 관점에서 이러한 현상은 심오한 의미를 갖는다. 객체 지향 언어가 다형성을 안전하고 편리하게 제공한다는 사실은 소스 코드 의존성을 어디서든 역전시킬 수 있다는 뜻이기도 하다.
이제 다시 그림 5.1의 호출 트리를 보면 수많은 소스 코드 의존성을 확인할 수 있다. 이러한 소스 코드 의존성은 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다. 이러한 접근법을 사용한다면, 객체 지향 언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 시스템의 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖는다. 이것이 바로 객체 지향이 제공하는 힘이고, 이것이 최소한 아키텍트의 관점에서는 객체 지향이 지향하는 것이다.
그럼 이 힘으로 무엇을 할 수 있을까? 예를 들어 업무 규칙이 DB와 UI에 의존하는 대신에, 시스템의 소스 코드 의존성을 반대로 배치하여 DB와 UI가 업무 규칙에 의존하게 만들 수 있다.

즉, UI와 DB가 업무 규칙의 플러그인이 된다는 뜻이다. 다시 말해 업무 규칙의 소스 코드에서는 UI나 DB를 호출하지 않는다. 결과적으로 업무 규칙, UI, DB는 세 가지로 분리된 컴포넌트 또는 배포 가능한 단위로 컴파일할 수 있고, 이 배포 단위들의 의존성 역시 소스 코드 사이의 의존성과 같다. 따라서 업무 규칙을 포함하는 컴포넌트는 UI와 DB를 포함하는 컴포넌트에 의존하지 않는다.
따라서 업무 규칙을 UI와 DB와는 독립적으로 배포할 수 있다. UI나 DB에서 발생한 변경사항은 업무 규칙에 일절 영향을 미치지 않는다. 즉, 이들 컴포넌트는 개별적이며 독립적으로 배포 가능하다. 이것이 바로 배포 독립성 independent deployability이다.
시스템의 모듈을 독립적으로 배포할 수 있게 되면, 서로 다른 팀에서 각 모듈을 독립적으로 개발할 수 있다. 그리고 이것이 개발 독립성 independent developability이다.
# 결론
객체지향이란 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력이다. 객체 지향을 사용하면 아키텍트는 플러그인 아키텍처를 구성할 수 있고, 이를 통해 고수준의 정책을 포함하는 모듈은 저수준의 세부사항을 포함하는 모듈에 대해 독립성을 보장할 수 있다. 저수준의 세부사항은 중요도가 낮은 플러그인 모듈로 만들 수 있고, 고수준의 정책을 포함하는 모듈과는 독립적으로 개발하고 배포할 수 있다.