029. (Clean Architecture) 5. 객체 지향 프로그래밍

5. 객체 지향 프로그래밍

좋은 아키텍처를 만드는 일은 객체 지향 설계 원칙을 이해하고 응용하는 데서 출발한다.

그렇다면 도대체 객체 지향이란 무엇일까?

누군가는 데이터와 함수의 조합이라고도, 또 누군가는 실제 세계를 모델링하는 새로운 방법이라고도 대답한다.

하지만 이 정의는 의도도 불분명하고 모호하다.

또 다른 방법으로 객체 지향의 본질을 이해하기 위한 세 가지 개념인 캡슐화, 상속, 다형성을 적절히 조합하거나 지원 요소로 분리하기도 한다.

각 개념을 차례대로 살펴보자.

5.1. 캡슐화

객체 지향을 정의한 요소 중 하나로 캡슐화를 언급하는 이유는, 데이터와 함수를 쉽고 효과적으로 캡슐화하는 방법을 객체 지향 언어가 제공하기 때문이다.

이 캡슐화를 통해 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있다.

이렇게 그어진 구분선을 기준으로 바깥에서 보면 데이터는 은닉되어있고, 일부 함수만이 관찰된다.

이 개념들은 클래스의 접근 제어자인 private이나 public으로 표현된다.

다만 캡슐화는 객체 지향 언어에서만 가능한 것이 아니다.

아래는 C언어로 작성된 캡슐화한 코드이다.

1
2
3
4
// point.h
struct Point;
struct Point* makePoint(double x, double y);
double distance(struct Point* p1, struct Point* p2);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// point.c
#include "point.h"
#include <stdlib.h>
#include <math.h>

struct Point {
double x;
double 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를 사용하는 point.c 코드에서는 struct Point의 멤버에 접근할 수 있는 방법이 전혀 없다.

또한 makePoint() 함수와 distance() ㅎ함수를 호출할 수는 있지만 내부적으로 어떤 데이터 구조와 함수를 가지고 있는지는 전혀 알 수 없다.

C로 작성한 완벽한 캡슐화의 예시임과 동시에, 객체 지향 언어가 아니더라도 캡슐화는 충분히 구현할 수 있음을 뜻한다.

C로 프로그램을 개발하는 개발자는 먼저 데이터 구조와 함수를 헤더 파일에 선언하고, 구현 파일에서 이들을 구현해냈다.

이러면 개발자는 구현 파일에 작성된 항목에 대해서는 어떻게해도 접근할 수 없었다.

이후 C++ 언어를 통해 명시적으로 객체 지향을 지원하게 되면서, 오히려 C가 제공하던 완전한 캡슐화가 깨지게 되었다.

위의 프로그램을 C++로 작성한다면 아래와 같이 작성해야 한다.

1
2
3
4
5
6
7
8
9
10
// point.h
class Point {
public:
Point(double x, double y);
double disatance(const Point& p) const;

private:
double x;
double y;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// point.cc
#include "point.h"
#include <math.h>

Point::Point(double x, double y): x(x), y(y) {

}

double Point::distance(const Point& p) const {
double dx = x - p.x;
double dy = y - p.y;

return sqrt(dx * dx + dy * dy);
}

이제 point.h 파일을 사용하는 측에서는 멤버 변수인 xy를 인지하게 되었다.

물론 직접적인 접근은 컴파일러에 의해 차단되지만, 개발자는 결국 해당 변수의 존재를 인히가데 된다.

완벽한 은닉이 실패했다는 것이며, 멤버 변수의 이름이라도 바뀌면 cc 파일까지 새로 컴파일 해야한다.

즉, 캡슐화가 깨지게 된다.

이러한 예시로 객체지향이 강력한 캡슐화의 의존적이다라는 정의는 설득력을 잃었다.

5.2. 상속

객체 지향 언어가 더 나은 캡슐화는 제공하지 못했더라도, 상속만큼은 확실하게 제공한다.

하지만 상속은 단순히 변수와 함수를 하나의 유효 범위로 묶어 재정의하는 동작일 뿐이며, 다소 수고스럽더라도 특정 언어의 도움 없이도 구현이 가능하다.

위에서 작성한 point.h 코드를 좀 더 고도화해보자.

1
2
3
4
5
6
// 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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// namedPoint.c
#include "namedPoint.h"
#include <stdlib.h>

struct NamedPoint {
double x;
double 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 = name;
return p;
}

void setName(struct NamedPoint* np, char* name) {
np -> name = name;
}

char* getName(struct NamedPoint* np) {
return np -> name;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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");

printf("distance=%f", distance(
(struct Point*) origin,
(struct Point*) upperRight,
))
}

main.c 코드를 잘 보면 NamedPoint 구조체가 Point 구조체의 파생인 것처럼 동작함을 알 수 있다.

이는 서로 연관이 없어도 선언된 변수의 순서가 동일하기때문에 그러한 것처럼 보이는 것이다.

즉, 눈속임으로 상속인 척 하는 것이다.

이 방법은 객체 지향의 개념이 출현하기 전에 흔히 사용되던 일종의 방법론이었다.

실제로 C++에서는 위 방법으로 단일 상속을 구현했다.

또한 NamedPointPoint로 강제로 업캐스팅하는 방법도 쓰인 것을 볼 수 있다.

참고 다만, 상속의 기능적인 부분을 대치하지않기대문에 다중 상속 등의 구현은 훨씬 힘든 일이 맞다.

객체 지향 언어가 원래는 없었던 새로운 개념을 만들어내지는 못했지만, 상속의 구현을 위하여 특정 클래스를 편하게 랩핑해준다는 것에 의의는 둘 수 있다.

결론적으로 캡슐화의 측면에서 객체 지향의필요성은 그저 그런 상태이며, 상속에 대해서는 어느 정도 점수를 받는 것이라고 볼 수 있다.

5.3. 다형성

마지막으로 고려해야할 속성은 다형성이다.

객체 지향 전에도 다형성을 표현할 수 있었을까?

이번에도 C언어로 작성된 프로그램을 참고해보자.

1
2
3
4
5
6
7
8
9
#include <stdio.h>

void copy() {
int c;

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

C 언어를 익혔다면 표준 입출력을 통해 입력과 출력을 수행할때 STDINSTDOUT 이라는 스트림을 통해 진행했음을 기억할 것이다.

그렇다면 어떤 물리적 장치로 입력과 출력을 지원하는 것일까?

여기에 객체지향 이전의 다형성에 대한 답이 있다.

UNIX의 경우 모든 입출력 장치가 다섯 가지 표준 함수를 제공할 것으로 요구한다.

참고 다섯 가지 표준 함수는 열기(open), 닫기(close), 읽기(read), 쓰기(write), 탐석(seek)을 말한다.

FILE 데이터 구조는 이 다섯 함수를 가리키는 포인들을 포함하여 작성되어있어 어떤 입출력 장치가 오더라도 데이터를 입력받고 출력해줄 수 있다.

이 개념을 C언어로 표현해보자.

1
2
3
4
5
6
7
struct FILE {
void(*open)(char* name, int mode);
void(*close)();
int(*read)();
void(*write)(char);
void(*seek)(long index, int mode);
};

위의 FILE 데이터 구조를 콘솔용 입출력 드라이버는 아래와 같이 접근할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#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 };

이제 STDINFILE* 형태로 선언하면 콘솔 데이터 구조를 가리키므로 아래와 같이 구현할 수 있다.

1
2
3
4
5
extern struct FILE* STDIN;

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

이처럼 단순한 기법이 모든 객체 지향이 지닌 다형성의 근간이 되었다.

예를 들어 C++에서는 클래스의 모든 가상 함수가 vtable이라는 이름의 가상 함수 포인터를 가지고 있었고,

모든 가상 함수의 호출은 이 테이블을 거치게 된다.

파생 클래스의 생성자는 생성하려는 객체의 vtable을 단순히 자신의 함수들로 덮어쓸 뿐이다.

결론적으로 함수를 가리키는 포인터의 응용이 곧 다형성인 것이며, 객체 지향을 통해 새롭게 정립된 개념은 아닌 것이다.

참고 물론 상속처럼 객체 지향 언어를 통해 편리하게 다형성을 구현할 수 있다는 것은 명백하다.

5.3.1. 의존성 역전

다형성을 안전하고 편리하게 적용할 수 있는 매커니즘이 등장하기전 소프트웨어는 어떤 모습이었을까?

전형적인 호출 트리의 경우 main 함수가 고수준 함수를 호출하고, 고수준 함수는 다시 중간 수준 함수를 호출하고,

중간 수준 함수는 저수준 함수를 호출하는 방시긍로 진행된다.

이러한 호출 트리에서는 소스 코드의 의존성 방향은 아래와 같이 반드시 제어 흐름을 따르게 된다.

Source code dependencies versus flow of control

main 함수가 고수준의 함수를 호출하려면 고수준 함수가 포함된 모듈의 이름을 지정해야한다.

참고 C의 #include, Java의 import, C#의 using이 이에 해당한다.

실제로 모든 호출 함수는 피호출 함수가 포함된 모듈의 이름을 명시적으로 지정해야 하며,

이러한 제약 조건으로 인해 제어 흐름은 시스템의 행위에 따라 결정되고, 소스 코드 의존성은 제어 흐름에 따라 결정되었다.

이때 다형성이 끼어들면서 상황이 바뀌게 되었다.

아래 그림을 보자.

Dependency inversion

HL1 모듈은 ML1 모듈의 F() 함수를 호출한다.

소스 코드로 치환해보면 HL1 모듈은 인터페이스를 통해 F()를 호출하고 있을 것이다.

허나, 이 인터페이스는 런타임 시점에 존재하지않는다.

이때 ML1 모듈과 I 인터페이스의 의존성 방향이, 제어 흐름과는 반대되는 점을 주목하자.

이러한 현상을 의존성 역전(Dependency Inversion) 이라고 부른다.

객체 지향 언어가 다형성을 안전하고 편리하게 제공한다는 것은 소스 코드의 의존성을 어디에서든 역전시킬 수 있다는 뜻이다.

이러한 접근법을 사용한다면 객체 지향 언어로 개발된 시스템을 다룰때, 소스 코드 의존성 전부에 대한 방향을 결정할 수 있게된다.

이것이 바로 객체 지향이 제공하는 힘이며, 지향하는 바이다.

그렇다면 의존성 역전으로 무엇을 할 수 있을까?

하나의 예를 들어보자.

업무 규칙이 데이터베이스와 사용자 인터페이스에 의존하는 대신에, 시스템의 소스 코드 의존성을 반대로 바꾼다면 어떻게 될까?

The database and the user interface depend on the business rules

위와 같이 의존성을 역전시키면 데이터베이스와 사요자 인터페이스가 업무 규칙에 의존하게 만들 수 있다.

결과적으로 업무 규칙, 사용자 인터페이스, 데이터베이스라는 세 가지로 분리된 컴포넌트로 구조화할 수 있고,

각각 독립적으로 배포 및 유지보수가 가능한 상황을 연출할 수 있다.

참고 이를 배포 독립성(Independent Deployability) 라고 한다.

시스템의 배포 독립성은 서로 다른 팀에서 각 모듈을 독립적으로 개발할 수 있게 되기에 개발 독립성(Independen Developability) 로 이어지게 된다.