넘치게 채우기

2-3. 객체 지향 프로그래밍 본문

개발/Clean Architecture

2-3. 객체 지향 프로그래밍

riveroverflow 2023. 11. 6. 22:16
728x90
반응형

객체 지향 프로그래밍. OOP(Object-Oriented-Programming)에서

이 OO의 본질을 설명하기 위해서 세 가지 주문에 기대곤 한다.

  • 캡슐화(encapsulation)
  • 상속(inheritance)
  • 다형성(polymorphism)

OO언어는 최소한 이 세가지를 충족해야한다고 한다.

 

캡슐화?

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의 멤버에 접근할 방법이 전혀 없다.

사용자는 makePoin()함수와 distance()함수를 호출할 수는 있지만, Point 구조체의 데이터 구조와 함수가 어떻게 구현 되었는지에 대해서는 조금도 알지 못한다.

 

C 프로그래머는 이러한방식(데이터 구조와 함수를 헤더 파일에 선언하고, 구현 파일에서 구현)을 사용해왔다.

프로그램 사용자는 구현 파일에 작성된 항목에 대해서 어떠한 방법으로도 접근할 수 없었다.

이후에 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.cpp
#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 = x-p.y;
    return sqrt(dx*dx+dy*dy);
}

이제 point.h를 사용하는 측에서 멤버 변수인 x와 y를 알게 된다.

물론 접근하는 것은 컴파일러가 막겠지만,

사용자는 멤버 변수의 존재를 알게 된다.

예를 들어 멤버 변수의 이름이 바뀐다면 point.cpp의 파일은 다시 컴파일해야 한다! 캡슐화가 깨진 것이다.

언어에 public, private, protected 키워드의 도입으로 불완전한 캡슐화를 사실상 어느 정도 보완하기는 했다.

하지만 이는 컴파일러가 헤더 파일에서 멤버 변수를 볼 수 있어야 했기 때문에 조치한 임시방편일 뿐이다.

이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다.

실제로 많은 OO언어가 캡슐화를 거의 강제하지 않는다(Python, JavaScript, Ruby 등)

OO프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을거란 기반으로 한다.

하지만 OO를 제공한다고 주장한 언어들이 실제로는 C에서 누린 완벽한 캡슐화를 약화시킨 것은 틀림없다.

상속?

OO언어가 상속은 확실히 제공한다.

하지만, 상속이란 단순히 어떤 변수와 함수를 하나의 유효범위로 묶어서 재정의하는 일에 불과하다.

사실상 OO언어가 있기 전에도 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>

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 = 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("diatance=%f\n",
           diatance(
               (struct Point *)origin,
               (struct Point *)upperRight));
}

main프로그램을 잘 살펴보면 NamedPoint 데이터 구조가 마치 Point 데이터 구조로부터 파생된 구조인 것처럼 동작한다는 사실을 볼 수 있다.

 

이는 NamedPoint에 선언된 두 변수의 순서가 Point와 동일하기 때문이다.

다시말해 NamedPointPoint의 가면을 쓴 것처럼 동작할 수 있는데,

이는 NamedPoint가 순전히 Point를 포함하는 상위 집합으로, Point에 대응하는 멤버 변수의 순서가 그대로 유지되기 때문이다.

눈속임처럼 보이는 이 방식은 OO가 출현하기 전부터 사용하던 기법이었다.

따라서 OO 언어가 고안되기 전부터 상속과 비슷한 기법이 사용되었다고 말할 수 있다.

 

물론 이렇게 말하는 데에는 어폐가 있다.

상속을 흉내내는 요령일 뿐, 상속만큼 편리하지는 않다.

게다가 이 기법으로 다중 상속을 표현하기란 훨씬 더 어렵다.

또한, main.c에서 NamedPoint인자를 Point로 타입을 강제로 변환한 점도 확인할 수 있다.

진짜 OO 언어에서는 이러한 업캐스팅이 암묵적으로 이뤄진다.

 

따라서 OO언어가 완전히 새로운 개념을 만들지는 못했지만,

데이터 구조에 가면을 씌우는 일을 상당히 편리한 방식으로 제공했다고 볼 수 있다.

요약하면, 캡슐화에 대해서는 OO에 대해 점수를 줄 수 없고, 상속에 대해서는 0.5점 정도를 줄 수 있다.

이렇게만 보면 그저 그런 점수다.

하지만 우리는 하나가 더 남았다.

 

 

다형성?

OO 언어가 있기 전에 다형성을 표현할 수 있는 언어가 있었던가?

당연히 있었다.

아래 C로 작성된 간단한 복사 프로그램을 살펴보자.

#include <stdio.h>

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

getchar()함수는 STDIN에서 문자를 읽는다.

putchar()함수는 STDOUT으로 문자를 쓴다.

 

이러한 함수는 다형성이다.

즉, 행위가 STDINSTDOUT의 타입에 의존한다.

STDINSTDOUT은 사실상 자바 형식의 인터페이스로, 자바에서는 각 장치별로 구현체가 있다.

물론 예제의 C 프로그램에서는 이러한 인터페이스는 없다.

유닉스 시스템의 경우 모든 입출력 장치 드라이버가 다섯 가지 표준 함수를 제공할 것을 요구한다.

(열기: open, 닫기: close, 읽기: read, 쓰기: write, 탐색: seek)

FILE 데이터 구조는 다섯 함수를 가리키는 포인터를 포함한다.

 

다음과 같을 것이다.

//file.h
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) {/* ... */};
void seek(long index, int mode) {/* ... */};

struct FILE console = {open, close, read, write, seek};

이제 STDINFILE*로 선언하면 STDIN은 콘솔 데이터 구조를 가리키므로, getchar()는 아래와 같은 방식으로 구현할 수 있다.

extern struct FILE* STDIN;

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

다시 말해 getchar()STDIN으로 참조되는 FILE 데이터 구조의 read 포인터가 가리키는 함수를 단순히 호출할 뿐이다.

이처럼 단순한 기법이 모든 OO가 지닌 다형성의 근간이 된다.

말하려는 요지는 함수를 가리키는 포인터를 응용한 것이 다형성이라는 점이다.

 

1940년대 후반 폰 노이만(Von Neumann) 아키텍처가 처음 구현된 이후 프로그래머는 다형적 행위를 수행하기 위해 함수를 가리키는 포인터를 사용해 왔다.

따라서 OO가 새롭게 만든 것은 전혀 없다.

 

그러나 이 말이 완전히 옳은 말이 아니긴 하다.

OO 언어는 다형성을 제공하지는 못했지만, 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다.

함수에 대한 포인터를 직접 상용하여 다형적 행위를 만드는 이 방식에는 문제가 있는데, 함수 포인터는 위험하다는 사실이다.

이러한 기법은 프로그래머가 특정 관례를 스스로 따르는 방식이다.

 

만약 프로그래머가 관계를 지켜야 한다는 사실을 망각하게 되면 버그가 발생하고, 이러한 버그는 찾아내고 없애기가 지독히도 힘들다.

OO언어는 이러한 관계를 없애주며, 따라서 실수할 위험이 없다.

OO언어를 사용하면 다형성은 대수롭지 않은 일이 된다.

OO 언어는 과거 C프로그래머가 꿈에서나 볼 수 있던 강력한 능력을 제공한다. 이러한 이유로 OO는 제어흐름을 간접적으로 전환하는 규칙을 부과한다고 결론지을 수 있다.

 

다형성이 가진 힘

다형성은 뭐가 좋을까?

복사 프로그램의 예제를 다시 살펴보자.

새로운 입출력 장치가 생긴다면 프로그램에는 어떤 변화가 생기는가?

필기체 인식 장치로부터 데이터를 읽어서 음성 합성 장치로 복사할 때도 이 프로그램을 사용해야 한다고 해보자.

이 새로운 장비에서도 복사 프로그램이 동작하도록 만들려면 어떻게 수정해야 하는가?

 

아무런 변경도 필요하지 않다!

심지어 복사 프로그램을 다시 컴파일할 필요조차 없다.

왜냐고? 복사 프로그램의 소스 코드는 입출력 드라이버의 소스 코드에 의존하지 않기 때문이다.

입출력 드라이버가 FILE에 정의된 다섯 가지 표준 함수를 구현한다면, 복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있다.

입출력 드라이버가 복사 프로그램의 플러그인이 된 것이다.

 

플러그인 아키텍처(plugin architecture)는 입출력 장치 독립성을 지원하기 위해 만들어졌고, 등장 이후 거의 모든 운영체제에서 구현되었다.

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

 

의존성 역전

다형성을 안전하고 편리하게 적용할 수 있는 메커니즘이 등장하기 전 소프트웨어는 어떤 모습이었을지 생각해 보자.

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

 

이러한 호출 트리에서 소스 코드 의존성의 방향은 반드시 제어흐름(flow of control)을 따르게 된다.

그림 5-1: 소스 코드 의존성 vs 제어흐름

 

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

이러한 제약 조건으로 인해 소프트웨어 아키텍트에게 남은 선택지는 별로 없었다.

즉 제어흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정된다.

 

하지만 다형성이 끼어들면 무언가 특별한 일이 일어난다.

 

그림 5.2: 의존성 역전

 

그림 5.2에서 HL1 모둘은 ML1 모듈의 F()함수를 호출한다.

소스 코드에서는 HL1 모듈은 인터페이스를 통해서 F()함수를 호출한다.

이 인터페이스는 런타임에 존재하지 않는다. HL1은 단순히 ML1의 함수를 호출 할 뿐이다.

하지만 ML1I 인터페이스 사이의 소스 코드 의존성(상속 관계)이 제어흐름과는 반대인 점을 주목이자.

이를 의존성 역전(dependency inversion)이라고 한다.

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

 

이제 다시 그림 5.1의 호출 트리를 보면 수많은 소스 코드 의존성을 확인할 수 있다.

이러한 소스 코드 의존성은 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다.

이러한 접근법을 사용하면 OO언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖는다.

 

즉, 소스 코드 의존성이 제어흐름의 방향과 일치되도록 제한되지 않는다.

호출하는 모듈이든 아니면 호출받는 모듈이든 관계없이 소프트웨어 아키텍트는 소스 코드 의존성을 원하는 방향으로 설정할 수 있다.

이것이 OO의 힘이고, OO가 지향하는 것이다.

그럼 이 힘으로 무엇을 할 수 있을까?

예를 들어보면, 업무 규칙이 데이터베이스와 사용자 인터페이스에 의존하는 대신에, 시스템의 소스 코드 의존성을 반대로 배치하여 데이터베이스와 UI가 업무 규칙에 의존하게 만들 수 있다.

 

그림 5.3: 데이터베이스와 사용자 인터페이스가 업무 규칙에 의존한다.

 

즉, UI와 데이터베이스가 업무 규칙의 플러그인이 된다는 뜻이다.

다시 말해 업무 규칙의 소스 코드에서는 UI나 데이터베이스를 호출하지 않는다.

결과적으로 업뮤 규칙, UI, 데이터베이스는 세 가지로 분리된 컴포넌트 또는 배포 가능한 단위로 컴파일 할 수 있고, 이 배포 단위들의 의존성 역시 소스 코드 사이의 의존성과 같다.

 

따라서 업무 규칙을 포함하는 컴포넌트는 UI와 데이터베이스를 포함하는 컴포넌트에 의존하지 않는다.

다시 말해 특정 컴포넌트의 소스 코드가 변경되면, 해당 코드가 포함된 컴포넌트만 다시 배포하면 된다.

이것이 바로 배포 독립성(independent deployability)이다.

시스템의 모듈을 독립적으로 배포할 수 있게 되면, 서로 다른 팀에서 각 모듈을 독립적으로 개발할 수 있다.

그리고 이것이 개발 독립성(independent developability)이다.

728x90
반응형