3장. 패러다임 개요

패러다임은 프로그래밍을 하는 방법으로, 대체로 언어에는 독립적인 개념이다. 패러다임은 어떤 프로그래밍 구조를 사용할지 그리고 언제 이 구조를 사용해야 하는지를 결정한다. 각 패러다임은 부정적인 의도를 가지는 일종의 추가적인 규칙을 부여한다. 즉 패러다임은 무엇을 해야 할지 말하기보다는 무엇을 해서는 안되는지 말해준다.

이 장에서는 세 가지 패러다임인 구조적 프로그래밍(structured programming), 객체지향 프로그래밍(object-oriented programming), 함수형 프로그래밍(functional programming)에 대해서 설명한다.

구조적 프로그래밍

최초로 적용된 패러다임. (최초로 만들어진 패러다임은 아님)
1968년 에츠허르 비버 데이크 스트라(Edsger Wybe Dijkstra)가 발견

  • 무분별한 점프(goto)는 프로그램 구조에 해롭다는 사실을 제시함.
  • 이러한 점프들을 If/then/else와 do/while/until과 같이 더 익숙한 구조로 대체하였다.

제어 흐름의 직접적인 전환에 대해 규칙을 부과한다.
=> goto를 앗아갔다.

객체지향 프로그래밍

두 번째로 적용된 패러다임. 구조적 프로그래밍보다 2년 앞선 1966년 올레 요한달과 크리스텐 니가드에 의해 등장.

  • 알골 언어의 함수 호출 스택 프레임을 힙으로 옮기면, 함수 호출이 반환된 이후에도 함수에서 선언된 지역변수가 오랫동안 유지될 수 있음을 발견
    • 이러한 함수가 클래스의 생성자가 됨
    • 지역변수는 인스턴스 변수
    • 중첩 함수는 메서드가 됨

함수 포인터를 특정 규칙에 따라 사용하는 과정을 통해 필연적으로 다형성이 등장하게 되었다. 제어 흐름의 간접적인 전환에 대해 규칙을 부과한다.
=> 함수 포인터를 앗아갔다.

함수형 프로그래밍

최근에 들어서야 겨우 도입되기 시작했지만. 세 패러다임중 가장 먼저 만들어졌다. 알란조 처치는 앨런 튜링이 똑같이 흥미를 가졌던 어떤 수학적 문제를 해결하는 과정에서 람다 계산법을 발명했는데, 함수형 프로그래밍은 이러한 연구 결과에 직접적인 영향을 받아 만들어졌다.

1958년 존 매카시가 만든 LISP 언어의 근간이 되는 개념이 이 람다 계산법이다.
람다 계산법의 기초가 되는 개념은 불변성으로, 심벌의 값이 변경되지 않는다는 개념이다.
함수형 언어에는 할당 문이 전혀 없다는 뜻이기도 하다. 대다수의 함수형 언어가 변수 값을 변경할 수 있는 방법을 제공하기는 하지만, 굉장히 까다로운 조건 아래에서만 가능하다.
할당 문에 대해 규칙을 부과한다.
=> 할당 문을 앗아갔다.

생각할 거리

각 패러다임은 프로그래머에게서 권한을 박탈한다.

  • 어느 패러다임도 새로운 권한을 부여하지 않는다.
  • 각 패러다임은 무엇을 해야 할지보다는 무엇을 해서는 안 되는지를 말해준다.
    • 세 가지 패러다임은 각각 우리에게서 goto문, 함수 포인터, 할당 문을 앗아간다.

1958년~1968년에 걸친 10년 동안 패러다임이 만들어졌고 이 패러다임은 지금까지 이어져오고 있고 이후 새로 등장한 패러다임은 전혀 없다.

결론

패러다임의 역사로부터 얻을 수 있는 이러한 교훈은 아키텍처와 어떤 관계가 있는가?

  • 모두 다 관계가 있다.
  • 우리는 아키텍처 경계를 넘나들기 위한 메커니즘으로 다형성을 이용한다.
  • 우리는 함수형 프로그래밍을 이용하여 데이터의 위치와 접근 방법에 대해 규칙을 부과한다.
  • 우리는 모듈의 기반 알고리즘으로 구조적 프로그래밍을 사용한다.

세 가지 패러다임과 아키텍처의 세 가지 큰 관심사(함수, 컴포넌트 분리, 데이터 관리)가 어떻게 서로 연관되는지 주목하자.

4장. 구조적 프로그래밍

  • 데이크 스트라
    • 데이크 스트라 알고리즘(최단 경로 알고리즘)
    • 1930년 로테르담 출신, 수많은 업적을 남긴 구조적 프로그램의 선구자
    • 1948년 우수한 성적으로 고등학교 졸업
    • 1952년 21살의 나이로 네덜란드 최초의 프로그래머로 암스테르담 수학센터에 취업

데이크 스트라가 활동하던 시절에는 프로그래머라는 직업이 없었고 사회에서 인정해 주지도 않아 이론 물리학자라는 직업으로 활동하였다. 진공관 시대의 컴퓨터는 매우 거대하고 쉽게 손상되며 느린 데다가 결과마저 믿을 수 없어 극도로 제한적으로만 사용되었다. 입력은 종이테이프나 천공카드와 같은 물리적인 형태를 띄었고 수정, 컴파일, 테스트를 반복하는 일은 최소 몇시간에서 며칠이 걸렸다.

데이크 스트라가 하려던 것들

증명

초기에 인식한 문제는 프로그래밍은 어렵고, 프로그래머는 프로그래밍을 잘하지 못한다는 사실이었다. 아주 작은 세부사항이라도 간과하면 프로그램이 동작하는 것처럼 보이더라도 결국 예상외의 방식으로 실패하곤 했다.
데이크 스트라는 수학적인 원리를 이용하여 이 문제를 해결하려 했다. 공리, 정리, 따름정리, 보조 정리로 구성되는 유클리드 계층구조를 만들어 증명하려고 했지만 끝내 증명은 이루어지지 않았다. 다만 이 연구에서 goto 문장이 모듈을 더 작은 단위로 재귀적으로 분해하는 과정에 방해가 되는 경우가 많다는 사실을 발견했다.

=>> 공리는 증명 없이 참으로 받아들이는 명제를 뜻한다. 유클리드 기하학에서 “두 점이 주어졌을 때, 두 점을 지나는 직선이 단 하나 존재한다”는 명제 역시 증명할 수 없기에 공리라고 부른다, 정리는 증명이라는 과정을 통해 참이라는 것이 밝혀진 명제다. 이러한 정리를 증명하는 데 필요한 정리를 보조 정리로 부르며, 정리를 통해 자연스럽게 도출되는 정리를 따름 정리라고 부른다. 데이크스트라는 프로그램을 증명하는 이러한 계층구조를 만들고자 했다.

해로운성명서

1968년 데이크 스트라는 goto문의 해로움에 대한 내용의 성명서를 발표했고 10년 이상 찬반의 비판과 논란의 여지가 있었지만 결국 데이크스트라가 승리하였다. 그 후 대부분의 언어에서 goto문은 사라졌다. 모든 프로그래밍은 순차 , 분기, 반복이라는 세 가지 구조만으로 표현할 수 있다. 현재의 우리 모두는 구조적 프로그래머이며, 여기에 선택의 여지가 없다. 제어 흐름을 제약 없이 직접 전환할 수 있는 선택권 자체를 언어에서 제공하지 않기 때문이다. 과거에는 goto문을 통해 그것이 가능했지만, 대다수의 현대적 컴퓨터 언어는 goto 문장을 포함하지 않는다.

과학이 구출하다

무엇이 올바른지를 입증할 때 사용하는 전략에는 수학적인 증명만이 있는 것은 아니다. 과학은 수학과는 근본적으로 다른데 과학 이론은 그 올바름을 절대 증명할 수 없기 때문이다.
예를 들면 만유인력의 법칙이 옳다고 증명할 수 없다. 이들 법칙을 소수점 이하 많은 자리의 정확도로 측정할 수는 있지만 수학적으로 증명할 수 없다. 실험을 아무리 많이 수행하더라도, 언젠가는 다른 실험을 통해 만유인력의 법칙이 잘못되었음이 밝혀질 가능성은 항상 열려있다.
즉 과학적 방법은 반증은 가능하지만 증명은 불가능하다. 과학은 서술된 내용이 사실임을 증명하는 방식이 아니라 서술이 틀렸음을 증명하는 방식으로 동작한다. 각고의 노력으로도 반례를 들 수 없는 서술이 있다면 목표에 부합할 만큼은 참이라고 본다. 결론적으로 수학은 증명 가능한 서술이 참임을 입증하는 원리로 볼 수 있고, 과학은 증명 가능한 서술이 거짓임을 입증하는 원리라고 볼 수 있다.

테스트

데이크스트라는 “테스트는 버그가 있음을 보여줄 뿐, 버그가 없음을 보여줄 순 없다”고 말한 적이 있다. 다시 말해 프로그램이 잘못되었음을 테스트를 통해 증명할 수는 있지만, 프로그램이 맞다고 증명할 수는 없다. 즉 테스트에 충분한 노력을 들였다면 테스트가 보장할 수 있는 것은 프로그램이 목표에 부합할 만큼은 충분히 참이라고 여길 수 있게 해 주는 것이 전부이다.
-> 소프트 웨어 개발이 수학적인 구조를 다루는 듯 보이더라도, 수학적인 시도가 아니라 오히려 과학과 같다. 최선을 다하더라도 올바르지 않음을 증명하는데 실패함으로써 올바름을 보여주기 때문이다.(TDD)

결론

  • 구조적 프로그래밍은 프로그램을 증명 가능한 세부 기능 집합으로 재귀적으로 분해할 것을 강요한다. 그리고 나서 테스트를 통해 증명 가능한 세부 기능들이 거짓인지를 증명하려고 시도한다.
  • 구조적 프로그래밍이 오늘날까지 가치 있는 이유는 프로그래밍에서 반증 가능한 단위를 만들어 낼 수 있는 바로 이 능력 때문이다.
  • 가장 작은 기능부터 가장 큰 컴포넌트에 이르기까지 모든 수준에서 소프트웨어는 과학과 같고, 반증 가능성에 의해 주도된다.
  • 때문에 소프트웨어 아키텍트는 모듈, 컴포넌트, 서비스가 쉽게 반증 가능하도록 만들기 위해 분주히 노력해야 한다.
  • 이를 위해 구조적 프로그래밍과 유사한 제한적인 규칙들을 받아들여 활용해야 한다.

5장. 객체지향 프로그래밍

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

‘데이터와 함수의 조합’, ‘실제 세계를 모델링하는 새로운 방법’ 등 객체지향을 정의하는 몇 가지 대답이 있지만 정의가 모호하다. 객체지향의 본질을 들여다보기 위해서는 캡슐화, 상속, 다형성 이 3가지 요소를 적절히 조합하거나 최소한 세 가지 요소를 반드시 지원해야 한다고 말한다.

캡슐화

객체지향에서 캡슐화를 언급하는 이유는 데이터와 함수를 효과적으로 캡슐화하는 방법을 객체지향 언어가 제공하기 때문이다. 캡슐화를 통해 응집력 있게 구성된 집단을 서로 구분하여 일부 데이터는 은닉되고 일부 함수만이 외부에 노출되도록 한다.

그런데 객체지향을 통해서만 함수, 변수의 은닉이 용이하다라고 알려져 있지만 객체지향이 아닌 C언어에서도 header를 통해 완벽한 캡슐화가 가능하므로 캡슐화가 객체지향만의 특성으로 설명하기에는 부족하다. 그 예로 아래의 코드를 보면 Point 스트럭처는 double 타입의 x,y 멤버 변수를 가지고 있지만 외부에서 접근이나 인지할 방법이 없다.

// point.h

struct Point; 
struct Point* makePoint(double x, double y);  
double distance (struct Point *p1, struct Point *p2)    

// point.h 를 사용하는 쪽에서는 struct Point의 멤버는 접근할 수 없다. 사용자는 makePoint()함수와 distance()함수를 호출할 수는 있지만, Point구조체의 데이터 구조와 함수가 어떻게 구현되었는지에 대해서는 조금도 알지 못한다.

// point.c
#include "point.h"
#include <stdih.h>
#include <math.h>

struct Point {
    double x, y;    // 헤더에 정의되지 않은 Point의 멤버변수는 외부 접근이 불가하다.
}

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 - p1->x;
    double dy = p1->y - p2->y;
}

하지만 객체지향인 C++이 나오면서 C가 제공하던 완전한 캡슐화가 깨지게 되었다. 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) const {
    double dx = x-p.x;
    double dy = y-p.y;
    return sqrt(dx*dx + dy*dy);
}

point.h 헤더 파일을 사용하는 측에서는 멤버 변수인 x와 y를 알게 된다. 물론 멤버 변수에 접근하는 것을 컴파일러가 막기는 하지만 사용자 입장에서는 멤버 변수의 존재가 인지 가능하게 되고, 멤버 변수의 이름이 바뀐다면 point.cc파일은 다시 컴파일돼야 한다. 즉 캡슐화가 깨진 것이다.
언어에 public, private, protected 등의 접근 키워드를 도입하여 불완전한 캡슐화를 보완하기는 했지만 컴파일러가 헤더 파일에서 멤버 변수를 볼 수 있어야 했기 때문에 조치한 임시방편일 뿐이다.

자바와 C#에 이르러서는 헤더와 구현체로 분리하는 방식을 버렸고 이로 인해 캡슐화는 더욱 훼손되었으며 이들 언어에서는 클래스 선언과 정의를 구분하는 게 아예 불가능하게 되었다. 이와 같은 이유로 객체지향이 강력한 캡슐화에 의존한다는 정의는 성립하기 힘들다. 실제로 많은 객체지향 언어가 캡슐화를 거의 강제하지 않는다.

상속

상속이란 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의 하는 것이다. 하지만 상속도 객체지향 언어가 있기 훨씬 이전에도 C프로그래머는 언어의 도움 없이 손수 이러한 방식을 구현할 수 있었다.

// namedPoint.h

struct NamePoint;

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>

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

// c2
struct NamedPoint* makeNamedPoint(double x, double y, char* name) {
    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"  // m1
#include "namedPoint.h"
#include <studio.h>

int main(int ac, char** av) {
    struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin"); // m2
    struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight"); // m3

    // m4
    printf("distance=%f\n", 
        distance(
            (struct Point*) origin,
            (struct Point*) upperRight)
        );
}

NamedPoint 타입의 객체를 Point 클래스에 선언된 distance 함수에 전달하여 실행하는 형태로 보이는데 이는 NamedPoint와 Point에 정의된 멤버 변수의 순서가 동일하기 때문에 가능하다.
결국 NamePoint가 Point를 상속받은 형태처럼 보이는 구조가 되며 객체지향 이전부터 상속이 사용되어온 방식이라는 의미가 된다.
하지만 어디까지나 상속처럼 보이는 방식이고 사용이 용이하지 않으며 다중 상속을 구현하려면 복잡도가 더 올라간다.
또한 distance 함수가 Point타입을 인자로 받기 때문에 업 캐스팅을 해야 했고 객체지향에서는 이 과정이 암묵적으로 이뤄진다.

객체 지향 언어가 완전히 새로운 개념을 만들지는 못했지만 데이터 구조에 가면을 씌우는 일을 상당히 편리한 방식으로 제공했다고 볼 수는 있다.

다형성

객체지향 언어가 있기전에 다형성을 표현하는 언어가 있었던가? 아래의 코드는 간단한 복사 프로그램으로 c 값이 존재하는 동안 putchar를 실행하는 프로그램이다.

#include <stdio.h>

void copy() {
    int c;
    while ((c=getchar()) != EOF)    // getchar() 값이 존재하는 동안
        putchar(c); // putchar()를 실행한다
}

getchar() 함수는 STDIN에서 문자를 읽고 putchar() 함수는 STDOUT으로 문자를 쓴다.
getchar()와 putchar()의 행위가 STDIN 과 STDOUT 타입에 의존하므로 다형적이라고 말할 수 있다.

다음은 표준함수를 재정의하여 플러그인 형태로 구현하고 사용하는 예를 보여준다.

struct FILE {
    void (*open)(cahr* name, int mode);
    void (*close)();
    int (*read)();
    void (*read)(char);
    void (*seek)(long index, int mode);
}

Unix 운영체제의 경우 모든 입출력 장치는 다음과 같은 다섯가지의 표준함수를 제공할 것을 요구한다.
“열기open, 닫기close, 읽기read, 쓰기write, 탐색seek
위 코드의 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 포인터가 가리키는 함수를 단순히 호출할 뿐이다.

이처럼 단순한 기법이 모든 객체지향이 지닌 다형성의 근간이 되며 함수를 가리키는 포인터를 응용한 것을 다형성이라고 정의한다.이런 형태의 다형성은 폰 노이만 아키텍처가 구현된 이래로 사용이 가능했지만 함수 포인터를 초기화해야 하며 모든 함수는 이 포인터를 통해 호출해야 한다는 관례가 생기며 이를 어길 경우 버그가 발생하고 보수가 어렵다는 단점이 있었다. 하지만 객체지향에서는 이런 관례를 없애주며, 실수하지 않고 적용 부담이 적으면서 능력은 강력한 기능을 사용할 수 있게 되었다.

객체지향 언어를 사용하면 다형성은 대수롭지 않게 사용할 수 있으며, 위와 같은 이유로 객체지향은 제어 흐름을 간접적으로 전환하는 규칙을 부과한다 라고 결론지을 수 있다.

다형성이 가진 힘

  • 다형성이 뭐가 그렇게 좋은가?
  • 위의 예에서 새로운 입출력 장치가 생긴다면 프로그램에는 어떠한 변화가 생기는가?
  • 새로운 장비에서도 복사 프로그램이 동작하도록 만들려면 소스를 어떻게 수정해야 하는가? 아무런 변경도 필요치 않다.
  • 위에서 작성한 copy() 함수가 입출력 드라이버의 소스코드에 의존하지 않기 때문에 입출력 드라이버가 FILE에 정의된 표준 함수를 구현한다면 copy() 함수는 재사용이 가능해진다. 즉 드라이버가 플러그인의 형태가 되고 copy() 함수는 장치 독립적이라는 의미가 된다. 이런 형태의 플러그인 아키텍처는 등장 이후 거의 모든 운영체제에서 구현되었으나 함수를 가리키는 포인터를 사용하는 것이 위험하기 때문에 대다수의 프로그래머에게 수용되지 못하였는데 객체지향의 등장으로 위험도가 낮아져 수용이 가능해졌다.

의존성 역전

고수준 함수(참조하는 쪽)가 저수준 함수(참조당하는 쪽)를 사용하기 위해 include, import, using 등의 구문으로 참조할 클래스를 지정한다. 이로 인해 제어 흐름이 저수준에서 고수준으로 의존하게 되는 구조가 발생한다.

그림 5.2에는 F() 함수를 가진 인터페이스와 F() 함수가 구현된 ML1 클래스 인터페이스를 통해 간접적으로 ML1의 F() 함수를 호출하는 HL1 클래스의 도식이 있다.

  • ML1과 I 인터페이스 사이의 의존성은 제어 흐름과 반대인 형태로 되어있고 이를 의존성 역전이라고 부른다.
  • 객체지향은 다형성을 안전하고 편하게 제공하므로 소스 코드 의존성을 어디에서든 적용 가능하게 해 준다. 이로 인해 의존성 흐름의 제어가 완전히 가능해진다.
  • 이러한 접근법을 사용한다면 객체지향 언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 시스템의 소스코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖게 된다. 이것이 힘이며 바로 객체지향이 제공하는 힘이다. 그리고 객체지향이 지향하는 바이다.

이러한 힘으로 무엇을 할수 있나?

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

그림 5.3의 업무 규칙은 고정되고 독립적인 형태가 되고 UI와 DB가 플러그인이 된다. 업무 규칙에서 UI나 DB를 호출하지 않게 된다. 따라서 업무규칙, UI, DB는 각각 분리된 컴포넌트로 컴파일할 수 있고 별도로 배포가 가능해진다. 이로 인해 각 모듈을 독립적으로 개발할 수 있고 개발 독립성을 확보할 수 있게 된다.

결론

  • 객체지향이란
    • 다형성을 이용하여 전체 시스템의 소스 코드 의존성 제어 권한을 확보한다.
    • 플러그인 아키텍처를 구성할 수 있게 되어 고수준 정책을 포함하는 모듈을 저수준의 세부사항을 포함하는 모듈로부터 독립성을 보장할 수 있다.(개발 독립성)
    • 저수준의 세부사항은 중요도가 낮은 플러그인 모듈로 만들 수 있고 고수준의 정책을 포함하는 모듈과 별개로 개발 배포할 수 있다.(배포 독립성)

6장. 함수형 프로그래밍

함수형 프로그래밍 개념은 프로그래밍 그 자체보다 앞서 등장했다. 이 패러다임에서 핵심이 되는 기반은 람다 계산법으로 알란조 처치가 1930년대에 발명했다.

25까지의 정수의 제곱을 출력하는 예

public class Squint {
    public static void main(String args[]) {
        for(int i=0; i<25; i++)
            System.out.println(i*i);
    }
}

클로저라는 함수형 언어로 다음과 같이 구현 가능하다.

(println (take 25 (map ( fn [x] (* x x)) (range))))
  • println, take, map, range는 모두 함수다. 리스프에서는 함수를 괄호안에 넣는 방식으로 호출한다.
  • (range) : range 함수 0부터 시작해 끝이 없는 정수리스트를 반환한다.
  • fn [x] (* x x)) : 익명 함수로, 곱셈 함수를 호출하면서 입력 인자를 두번 전달한다. 즉 입력의 제곱을 계산한다.
  • (take 25) : 앞서 제곱된 항목 25개로 구성된 새로운 리스트를 반환한다.
  • (println) : 앞서 25개의 정수에 대한 제곱값으로 구성된 리스트를 입력으로 받아 값을 출력한다.

Java 프로그램은 가변 변수(mutable variable)을 사용(반복문을 제어하는 i)하는데 가변 변수는 프로그램 실행중에 상태가 변할 수 있다. 클로저 프로그램에서는 이러한 가변 변수가 전혀 없다. 클로저에서 x와 같은 변수는 한번 초기화 되면 절대로 변하지 않는다. 즉 함수형 언어에서 변수는 변경되지 않는다.

불변성과 아키텍처

아키텍처를 고려할 때 변수의 가변성이 중요한데, 경합 조건(race condition), 교착상태(dead lock), 동시 업데이트(concurrent update) 문제가 모두 가변 변수로 인해 발생하기 때문이다.

즉, 동시성 애플리케이션에서 마주치는 모든 문제, 즉 다수의 스레드와 프로세스를 사용하는 애플리케이션에서 마주치는 모든 문제는 가변 변수가 없다면 절대로 생기지 않는다.

아키텍트는 동시성 문제에 지대한 관심을 가져야 하며, 설계한 시스템이 다수의 스레드와 프로세스에서 문제없이 돌아갈 수 있도록 변수의 불변성이 실현 가능한지 반드시 확인해 봐야 한다.

가변성의 분리

불변성과 관련한 주요한 타협 중의 하나는 애플리케이션, 또는 내부의 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리하는 것이다. 불변 컴포넌트에서는 순수하게 함수형 방식으로만 작업이 처리되며, 어떤 가변 변수도 사용되지 않는다.

불변 컴포넌트는 하나이상의 순수 함수형 컴포넌트가 아닌 다른 가변 컴포넌트와 서로 통신한다.

상태 변경은 동시성 문제를 노출시키므로, 트랜잭션 메모리와 같은 방식을 통해 메모리의 변수를 처리한다. 즉 트랜잭션을 사용하거나 재시도 기법을 통해 변수를 보호한다.

애플리케이션을 제대로 구조화하려면 변수를 변경하는 컴포넌트와 변경하지 않는 컴포넌트를 분리하고, 이렇게 분리하려면 가변 변수들을 보호하는 적절한 수단을 동원해 뒷받침해야 한다.

현명한 아키텍트라면 가능한 한 많은 처리를 불변 컴포넌트로 옮겨야 하고, 가변 컴포넌트에서는 가능한 한 많은 코드를 빼내야 한다.

이벤트 소싱

이벤트 소싱은 상태가 아닌 트랜잭션을 저장하자는 전략이다. 상태가 필요해지면 단순히 상태의 시작점부터 모든 트랜잭션을 처리한다.

이 전략에는 데이터 저장공간이 많이 필요하다. 현대 시대에 데이터 저장소는 급격하게 증가하여 수 테라바이트도 적다고 여기는 시대이므로 저장공간을 충분히 확보할 수 있다.

중요한 점은 데이터 저장소에서 삭제되거나 변경되는 것이 하나도 없다는 사실이다.

결과적으로 애플리케이션은 CRUD가 아니라 그저 CR만 수행한다. 데이터 저장소에서 변경과 삭제가 전혀 발생하지 않으므로 동시 업데이트 문제 또한 일어나지 않는다. 저장 공간과 처리 능력이 충분하면 애플리케이션이 완전한 불변성을 갖도록 만들 수 있고, 따라서 완전한 함수형으로 만들 수 있다.

=> 소스코드 버전 관리 시스템이 이러한 방식으로 동작한다.

요약

  • 구조적 프로그래밍은 제어흐름의 직접적인 전환에 부과되는 규율이다.
  • 객체지향 프로그래밍은 제어흐름의 간접적인 전환에 부과되는 규율이다.
  • 함수형 프로그래밍은 변수 할당에 부과되는 규율이다.

세 패러다임 모두 무언가를 앗아간다. 각 패러다임은 우리가 코드를 작성하는 방식의 형태를 한정시킨다. 지난 반세기 동안 우리가 배운것은 해서는 안되는 것에 대해서다. 이러한 사실에 기반해볼때 소프트웨어는 급격히 발전하는 기술이 아니라는 사실을 알수 있다. 1946년 앨런 튜링이 최초의 코드를 작성할 때 사용한 소프트웨어 규칙과 지금의 소프트웨어 규칙은 조금도 다르지 않다. 도구는 달라졌고 하드웨어도 변했지만, 소프트웨어 핵심은 여전히 그대로다.