클린 아키텍처

[개발서적] 클린 아키텍처 05 - 객체 지향 프로그래밍 (Object-Orented)

Lo_gos 2024. 12. 13. 14:54

 

객체 지향(Object-Orented) 설계 원칙이란?

객체 지향 설계 원칙은 좋은 아키텍트를 설계하는 출발선이다. 그렇다면 객체 지향이란 무엇일까? 일반적으로 "데이터와 함수의 조합", "실제 세계를 모델링하는 새로운 방법"과 같은 답변을 답할 수 있을 것이다. 그러나 이 답변들은 OO에 대해 명확한 설명이라고 할 수는 없다. OO에 대한 본질을 설명하기 위해서 캡슐화(encapsulation), 상속(inheritance), 다형상(polyorphism)에 기대는 부류도 있다. OO는 이 세 가지 개념을 적절하게 조합한 것이거나, 또는 OO 언어는 최소한 세 가지 요소를 반드시 지원해야 한다고 말한다.

 

캡슐화

OO는 데이터와 함수를 효과적으로 캡슐화하는 방법을 제공한다. 이를 통해 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있다. 구분된 바깥선에서는 데이터는 은닉되고 일부 함수만 외부에 노출된다. OO 언어는 각 클래스의 private 멤버 데이터와 public 함수로 표현된다.

 

그러나 이러한 캡슐화 개념은 OO가 아닌 C언어에서도 완벽한 캡슐화가 가능하다. 아래 간단한 예제를 통해 C언어의 캡슐화를 확인할 수 있다. 아래 예제를 보면 point.h를 사용하는 측에서 struct Point의 멤버에 접근할 방법이 전혀 없다. makePoint(), distance() 함수를 호출할 수는 있지만 어떻게 구현되어 있는지는 예측이 불가능하다. C언어는 완벽한 캡슐화를 지원한다.

// pint.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);
}

 

 

그러나 C++라는 형태로 OO가 등장하게 되고 완전한 형태의 캡슐화는 깨지게 된다. C++은 컴파일러라는 기술적 이유로 클래스의 멤버 변수를 해당 클래스 헤더 파일에 선언할 것을 요구했다. 

// point.h
class Point {
    public:
        Point(double x, double y);
        double distance(const Point& p) const;
    private:
        double x;
        double y;
}
// point.cc
#include "point.h"
#include <math.h>


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

double Point::distance(const Point& p) {
	double dx = x-p.x;
    double dy = y-p.y;
    return sqrt(dx*dx+dy*dy);
}

 

이로서 point.h 를 사용하는 측에서 멤버 변수인 x와 y를 알 수 있게 되고 완벽한 캡슐화는 깨지게 된다. (컴파일러가 멤버 변수 접근을 막겠지만 멤버 변수를 변경 시 다시 컴파일해야 한다.) 언어에 public, private, protected 키워드를 도입해 불완전한 캡슐화를 보완하기는 했지만 임시방편이다. 자바와 C# 이후부터는 헤더와 구현체를 분리하는 방식을 버리게 되었고 캡슐화는 더욱 심하게 훼손되었다.

OO는 C 언어에서 누렸던 완벽한 캡슐화를 약화시켜 왔다.

 

이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다. OO 프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회하지 않고 사용할 거라는 믿음을 기반으로 한다. (OO는 캡슐화를 거의 강제하지 않는다. 이러한 언어는 자바스크립트, 파이썬, 루비 등이 있다.)

 

상속

OO 언어가 더 나은 캡슐을 제공하지는 못했지만 상속은 확실히 제공한다. 그러나 상속이란 단순히 어떤 변수와 함수를 하나의 유효 범위에 묶어서 재정의하는 일에 불과하다. 상속 역시 OO가 있기 전부터 C언어에서 사용 중이었다. 아래 예제를 통해 C언어에서 구현한 상속을 확인할 수 있다.

 

main 프로그램을 보면 NamedPoint 데이터 구조가 Point 데이터 구조로부터 파생된 구조인 것처럼 동작하는 것을 볼 수 있다. 이는 NamedPoint가 Point의 가면을 쓴 것처럼 동작할 수 있는데, 이는 NamedPoint 가 Point를 포함하는 상위 집합으로 Point에 대응하는 변수 (x, y)의 순서가 그대로 유지되기 때문이다.

 

// namedPoint.h
struct NamedPoint;

struct NamedPoint* makeNamePoint(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>

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

struct NamedPoint* makeNamedPoint(double x, double y, char* name) {
	struct NamedPoint* p = mallo(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;
}
// main.c
#include "point.h"
#include "namedPoint.h"
#include <stdio.h>

int main(int ac, char** av) {
	struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
    struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight");
    printf("distance=%f\n", 
        // 강제 타입 변환
    	distance((struct Point*) origin,(struct Point*) upperRight)
    ); 
}

 

이 눈속임처럼 보이는 방식을 통해 C++에서는 단일 상속을 구현한다. OO가 출현하기 이전부터 상속과 비슷한 기법이 사용되었지만 절대 OO의 상속만큼 편리한 방식은 아니다. main.c 에서 NamedPoint를 Point로 강제 타입 변환하는 점을 확인할 수 있는데 OO에서는 암묵적 업캐스팅(upcasting)이 이뤄진다. 따라서 OO 언어가 완전히 새로운 개념을 만들지는 못했지만 데이터 구조에 가면을 씌우는 일을 상당히 편리하게 제공한다고 볼 수 있다. 

 

 

다형성

다형성 또한 OO 언어가 있기 전에도 표현할 수 있는 언어가 있었다. C로 작성한 간단한 복사 프로그램을 살펴보자.

#include <stdio.h>

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

 

위에서 보여주는 함수 두 가지는 아래와 같이 동작한다. 

  • getchar() :  STDIN에서 문자를 읽는다.
  • putchar() : STDOUT에서 문자를 쓴다.

STDIN과 STDOUT는 어떤 장치일까? 이러한 함수는 다형적(polymorphic)이다. 즉 행위가 STDIN과 STDOUT의 타입에 의존한다. 

C 언어에서는 자바와 같이 각 장치별 인터페이스 구현체가 없다. 그렇다면 어떻게 동작할까?

 

유닉스 운영체제는 모든 입출력 장치 드라이버에 5가지 표준 함수를 제공할 것을 요구한다. 그리고 C언어의 FILE 데이터 구조는 이 다섯 함수를 가리키는 포인터를 포함한다.

열기(open), 닫기(close), 읽기(read), 쓰기(write), 탐색(seek)
struct FILE {
	void (*open)(char* name, int mode);
    void (*close)();
    int (*read)();
    void(*write)(char);
    void(seek)(long index, int mode);
}

 

 

콘솔용 입출력 드라이버에서는 이들 함수를 아래와 같이 정의하며 FILE 데이터 구조를 함수에 대한 주소와 함께 로드한다. 그리고 STDIN을 FILE*으로 선언하면 STDIN은 콘솔 데이터 구조를 가리키므로 getchar()는 아래와 같이 구현할 수 있다.

 

#include "file.h"


void (*open)(char* name, int mode) { /**...**/ };
void (*close)() { /**...**/ };
int (*read)() { /**...**/ };
void(*write)(char) { /**...**/ };
void(seek)(long index, int mode) { /**...**/ };

struct FILE console = {open, close, read, write, seek};
extern struct File* STDIN;

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

 

getchar()는 STDIN으로 참조되는 FILE 데이터 구조의 read 포인터를 가리키는 함수를 호출할 뿐이다. 이러한 기법이 모든 OO가 지닌 다형성의 근간이 된다. 

즉 함수를 가리키는 포인터를 응용한 것이 다형성이다.

 

OO 언어는 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 만든다.  C언어에서는 관례적으로 함수의 포인터를 직접 다루는 방식으로 다형성 행위를 만들었는데 이러한 함수 포인터를 직접 사용하는 행위는 위험하다. 따라서 OO 언어는 이러한 관례를 없애주고 실수할 위험을 제거했다. 결론적으로 OO 언어는 제어흐름을 간접적으로 전환하는 규칙을 부과한다.

 

다형성이 가진 힘

다형성이 가진 힘이 무엇일까? 예를 들어 새로운 입출력 장치가 생긴다면 프로그램에 어떠한 변화가 생기는가? 아무런 변화가 없을 것이다. 복사 프로그램의 컴파일을 다시 할 필요도 없다. 복사 프로그램은 입출력 드라이브 소스에 의존하지 않고 있기 때문이다.

 

새로운 입출력 장치가 FILE에 정의된 다섯 가지 표준 함수를 구현한다면 복사 프로그램은 얼마든지 이 입출력 프로그램을 사용할 수 있다. 즉 입출력 장치가 복사 프로그램의 플러그인(plugin)이 된 것이다. 이러한 형태를 보고 프로그램이 "장치 독립적"이어야 한다는 사실을 다시 상기할 수 있을 것이다. 

 

플러그인 아키텍처(plugin architecture)는 입출력 장치 독립성을 지원하기 위해 등장했고 이후 거의 모든 운영체제에서 구현되었다. OO의 등장으로 이제 언제 어디서든 플러그인 아키텍처를 적용할 수 있게 되었다.

 

의존성 역전

다형성을 안전하게 적용할 수 있는 메커니즘이 등장하기 전에는 소프트웨어의 제어 흐름은 main 함수에서 고수준 함수로, 고수준 함수에서 중간 함수로, 중간 수준 함수는 다시 저수준 함수를 호출한다 이러한 호출 트리의 의존성 방향은 반드시 제어흐름을 따르게 된다. (그림 1)

그림 1 소스 코드 의존성 vs. 제어흐름

 

main  함수가 고수준 함수를 호출하려면 고수준 함수가 포함된 모듈의 이름을 지정해야 한다. (자바의 import, C #include) 모든 호출 함수는 피호출 함수가 포함된 모듈의 이름을 명시적으로 지정해야 하기에 제어흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정된다.

  • 제어 흐름: 시스템 행위로 결정됨
  • 소스 코드 의존성 : 제어 흐름에 따라 결정됨

 

 

다형성을 이용한 의존성 역전

여기서 다형성이 끼어들면 의존성의 방향은 달라지게 된다.

그림 2. 의존성 역전

 

 

그림 2에서  HL1 모듈은  ML1 모듈의 F() 함수를 호출한다. 소스 코드에서 HL1 모듈은 인터페이스를 통해 F() 함수를 호출한다. 이 인터페이스는 런타임에는 존재하지 않는다. HL1은 ML 모듈의 함수 F() 호출할 뿐이다.

  • HL1 모듈 : ML1 > F() 함수 호출
  • 소스 코드 : HL1 모듈은 인터페이스를 통해 F() 함수 호출
  • 런타임 : HL1 -> ML1.F() 호출 (인터페이스는 존재하지 않음)

여기서 주목할 점은 ML1과 I 인터페이스 사이의 제어 흐름이 반대인 점인데 이를 의존성 역전이라 부른다.  OO 언어가 다향성을 안전하고 편리하게 제공한다는 것은 "소스 코드 의존성을 어디에서든 역전시킬 수 있다"는 뜻이기도 하다. 이러한 접근법을 사용한다면 소프트웨어 아키텍트는 시스템의 소스 코드 의존성 전부에 대한 방향을 결정 할 수 있는 절대적 권한을 갖는다. 이것이 OO가 지향하는 것이다.

 

그렇다면 의존성 역전으로 할 수 있는 것이 무엇일까? 예를 들어 업무 규칙이 데이터베이스 사용자와 사용자 인터페이스(UI)에 의존하는 대신 소스코드의 의존성을 반대로 배치하여 UI와 데이터베이스가 업무 규칙에 의존하게 만들 수 있다. UI와 데이터베이스가 업무 규칙의 플러그인이 되는 것이다.

 

결과적으롤 업무 규칙, UI, 데이터 베이스는 분리된 컴포넌트 또는 배포 가능한 단위가 된다. 이것이 바로 배포 독립성(independent deployability)이다. 배포 독립성을 가지게 되면 다음과 같은 이점이 있다.

 

배포 독립성의 이점

  • 독립적 배포가 가능
  • 서로 다른 팀에서 모듈을 독립적으로 개발할 수있다.

이로인해 결과적으로 개발 독립성을 가지게 된다.

 

 

결론

OO란 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력이다. 

 

OO 프로그래밍으로 할 수 있는 것들

  • 플로그인 아키텍처를 구성할 수 있다.
  • 고수준 정책을 포함하는 모듈은 저수준 세부사항을 포함하는 모듈에 대해 독립성을 보장한다.
  • 저수준 세부사항은 중요도가 낮은 플러그인 모듈로 만들 수 있다.
  • 독립적으로 개발하고 배포할 수 있다.