2부(3, 4, 5, 6장). 벽돌부터 시작하기: 프로그래밍 패러다임

Posted by yunki kim on May 15, 2022

3장 패러다임 개요

구조적 프로그래밍

  무분별한 점프(goto 문)이 해롭다는 사실과 함께 재안된 최초의 패러다임이다. 무분별한 점프 대신 조건문과 반복문을 상요한다. 구조적 프로그래밍은 다음과 같이 요약할 수 있다.

구조적 프로그래밍은 제어흐름의 직접적인 전환에 대해 규칙을 부과한다.

객체 지향 프로그래밍

  함수 호출 스택 프레임을 힙으로 옮기면, 함수 호출이 반환되어도 함수에서 선언된 지역 변수가 오랫동한 유지된다. 이런 함수가 클래스의 생성자가 되었고, 지역변수가 인스턴스, 중첩 함수가 메서드가 되었다. 함수 포인터를 특정 규칙에 따라 사용하는 과정은 다형성이 되었다. 객체 지향 프로그래밍은 다음과 같이 요약할 수 있다.

객체 지향 프로그래밍은 제어흐름의 간접적인 전환에 대해 규칙을 부과한다.

함수형 프로그래밍

  수학적 문제를 해결하던 중 발견된 람다 계산법에 영향을 받은 패러다임이다. 람다 계산법의 기초 개념은 불변셩과 심볼의 값이 변경되지 않는다는 것이다. 즉, 함수형 프로그래밍에서는 변수 값을 변경하기 어렵다. 함수형 프로그래밍은 다음과 같이 요약할 수 있다.

함수형 프로그래밍은 할당문에 대해 규칙을 부과한다.

 

4장 구조적 프로그래밍

증명

  초기 프로그래밍은 인간의 뇌로 담기에는 너무 많은 세부 사항을 포함하고 있었다. 데이크스트라는 증명(proof)라는 수학적 원리를 통해 이를 해결하고자했다. 이를 위해 공리, 정리, 따름정리, 보조정리로 구성된 유클리드 계층구조를 만들고자 했다. 즉, 입증된 구조를 이용하고, 구조를 코드와 결합시켜 코드가 올바르다는 사실을 스스로 증명하게 되는 방식이다.

  이 과정에서 데이크스트라는 goto 문장이 모듈을 더 작은 단위로 재귀적으로 분해하는 과정에 방해가 된다는 사실을 발견했다. 하지만, goto문이 분기와 반복이라는 단순 제어에 사용된다면 증명 가능한 단위로까지 모듈을 재귀적으로 세분화할 수 있다는 것을 알게 되었다. 또 한, 모든 프로그램은 순차(sequence), 분기(selection), 반복(iteration)이라는 구조로 표현가능한 사실 역시 증명되었다.

해로운 성명서

  데이크스트라는 자신이 발견한 사실을 "Go To Statement Considered Harmful"라는 글을 통해 세상에 공개했다. 이 글이 세상에 공개되고 여러 논쟁이 있었지만 현대적 프로그래밍 언어가 goto를 포함하지 않는 것을 보면 데이크스트라가 승리했음을 알 수 있다. 현대의 프로그래밍 언어는 제어흐름을 아무런 제약 없이 전환할 수 있는 선택권을 제공하지 않는다.

기능적 분해

  구조적 프로그래밍을 통해 모듈을 증명 가능한 더 작은 단위로 재귀적으로 분해할 수 있게 되었고, 이는 결국 모듈을 기능적으로 분해할 수 있음을 의미한다. 이를 토대로 여러 연구가 이루어져 프로그래머는 대규모 시스템을 모듈과 컴포넌트로 나눌 수 있게 되었다. 더 나아가 모듈과 컴포넌트는 입증할 수 있는 아주 작은 기능들로 세부화할 수 있다.

엄밀한 증명은 없었다

  하지만 끝내 프로그램 관점에서 정리에 대한 유클리드 계층구조는 만들어지지 않았다. 그렇지만, 무언가가 올바른지를 입증하는 방식이 엄밀한 수학적 증명한 있는 것은 아니다. 또 다른 방법으로는 과학적 방법(scientific method)이 존재한다. 

과학이 구출하다

  과학은 수학과 다르게 절대 올바름을 증명할 수 없다. 수학이 증명 가능한 서술이 참임을 증명하는 원리인 반면에 과학은 수학과 다르게 경험적 증거와 실험을 토대로 한다. 즉, 수 많은 경험적 증거 중 하나가 거짓이 된다면 거짓이 된다. 따라서 과학적 방식은 반증은 가능하지만 증명은 불가능하다. 만약 반례를 들 수 없는 서술이 있다면 목표에 부합할 만큼은 참이다. 

테스트

  테스트가 오직 프로그램이 목표에 부합할 만큼 충분히 참이라는 것을 증명할 수 있다. 소프트웨어 개발이 수학적 구조를 다루는 듯 보여도, 소프트웨어 개발은 수학적인 시도가 아니다. 오히려 과학과 같다. 최선을 다해 올바르지 않음을 증명하는 데 실패함으로써 올바름을 보여준다.

  이런 부정확함에 대한 증명은 입증 가능한 프로그램에만 적용할 수 있다. 제약 없는 goto 문을 사용하는 프로그램은 테스트를 많이해도 절대 올바르다 볼 수 없다.

  구조적 프로그래밍은 프로그램을 증명 가능한 세부 기능 집합으로 재귀적으로 분해할 것을 강요한다. 그 후 테스트를 통해 증명 가능한 세부 기능들이 거짓인지를 증명하려 시도한다. 여기서 거짓임을 증명하는 테스트가 실패한다면, 이 기능들은 목표에 부합할 만큼 충분히 참이라 여긴다.

 

5장 객체 지향 프로그래밍

캡슐화

  많은 사람들이 객체 지향의 특징 중 하나로 캡슐화를 꼽지만 이는 객체 지향에만 국한된 개념은 아니다.

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
26
// point.h
struct Point;
struct Point* makePoint(double x, double y);
double diatance(struct Ponit *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;
    p->= y;
    return p;
}
 
double distance(struct Point* p, struct Point* p2) {
    double dx = p1->- p2->x;
    double dy = p1->- p2->y;
    return sqrt(dx * dx + dy * dy);
}
cs

  위 코드를 보면 point.h를 사용하는 측에서는 struct Point의 멤버에 접근할 방법이 없다. 또 한, Point 구조체와 두 함수의 구현에 대해 알지 못한다. 오히려 C++에서 객체 지향이 등장하고 캡슐화가 깨지게 되었다.

  C++ 컴파일러는 클래스의 인스턴스 크기를 알 수 있어야 한다는 이유로 클래스의 멤버 변수를 클래스의 헤더 내에 선언할 것을 요구했다. 따라서 코드가 다음과 같이 바뀌게 되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 distance(struct Point* p, struct Point* p2) {
    double dx = p1->- p2->x;
    double dy = p1->- p2->y;
    return sqrt(dx * dx + dy * dy);
}
cs

  접근 제어자로 멤버 변수에 접근하는 일은 막지만 사용자는 멤버 변수가 존재하는 사실을 알게 된다. 예를 들어 멤버 변수 이름이 바뀐다면 point.cc를 다시 컴파일 해야 한다. 접근 제어자는 컴파일러가 헤더 파일에서 멤버 변수를 볼 수 있어야 하기에 조치한 임시방편일 뿐이다. 또 한, 자바는 헤더와 구현체를 분리하는 방식을 아예 버려서 캡슐화는 더 심하게 회손되었다. 자바는 클래스 선언과 정의를 구분하는게 불가능하다.

상속

  상속은 단순히 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 것이다. 이는 객체 지향이 있기 전부터 구현 가능한 방식이였다.

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// point.h
struct Point;
struct Point* makePoint(double x, double y);
double diatance(struct Ponit *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;
    p->= y;
    return p;
}
 
double distance(struct Point* p, struct Point* p2) {
    double dx = p1->- p2->x;
    double dy = p1->- p2->y;
    return sqrt(dx * dx + dy * dy);
}
 
 
// 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;
    p->= 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.00.0"origin");
    struct NamedPoint* upperRight = makeNamedPoint(1.01.0"upperRight");
    printf("distance=%f\n", distance((struct Point*) origin, (struct Point*) upperRight));
}
 
cs

  위 코드를 보면 NamedPoint가 마치 Point인것 처럼 작동한다. 이는 NamedPoint에 선언된 두 변수의 순서가 Point와 동일하기 때문이다. 이는 NamedPoint가 Point를 포함하는 상위 집합으로, Point에 대응하는 맴버 변수의 순서가 그대로 유지되기 때문이다.

  이 방식은 객체지향에서 말하는 상속과는 다소 차이가 있다. 객체 지향에서의 상속만큼 편리하지 않고, 다중 상속을 구현하기 어렵다. 또 한 객체 지향과는 다르게 upcasting을 자동으로 지원하지 않는다. 따라서 객체지향에서 나온 온전히 새로운 개념은 아니지만 데이터 구조에 가면을 씌워 편리한 방식을 제공하고 있다 볼 수 있다.

다형성

  다형성 역시 객체 지향 이전부터 구현할 수 있었다. 함수 포인터를 응용하면 다형성이 된다. 예를 들어 getchar()를 구현하고 싶다면 다음과 같이 할 수 있다. 유닉스 운영체제의 경우 모든 입출력 장치 드라이버가 다섯 가지 표순 함수를 제공한다(open, close, read, write, seek). 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
struct FILE {
    void (*open)(char* name, int mode);
    void (*close)();
    int (*read)();
    void (*write)(char);
    void (*seek)(long index, int mode);
};
 
// console 구현
#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은 콘솔 데이터 구졸르 가리킨다.
extern struct FILE* STDIN;
 
int getchar() {
    return STDIN->read();
}
cs

  위 코드에서 볼 수 있듯 getchar()는 STDIN으로 참조되는 FILE 데이터 구조의 read 포인터가 가리키는 함수를 단순 호출한다.

  따라서 다형성 역시 온전히 새로운 것이다 라고는 할 수 없다. 하지만 객체 지향의 다형성은 C와는 다르게 위험한 함수 포인터를 직접 다루지 않게 해준다(함수 포인터를 사용한다면 모든 포인터를 초기화하고, 포인터를 통해 모든 함수를 호출해야 한다).

  이렇듯 객체 지향은 기존에 있던 기법들에 대해 안전성을 부과하기 때문에 제어흐름을 간접적으로 전환한다는 규칙을 부과한다고 결론지을 수 있다.

의존성 역전

  다형성을 안전하고 편리하게 적용할 수 있는 매커니즘이 등장하기 전 소프트웨어는 소수준 함수가 저수준 함수를 호출하는, 전형적인 트리 모습이였다.

  이런 트리 모습에서는 제어흐름은 시스템의 행위에 따라 결정되고, 소스 코드 의존성은 제어흐름에 따라 결정되었다. 하지만 다형성으로 인해 의존성이 역전되게 되었다.

  ML1과 I 인터페이스 사이의 소스 코드 의존성이 제어의 흐름과는 반대가 된다. 또 한, 객체 지향은 다형성을 안전하고 편리하게 제공하기에 소스 코드의 의존성을 어디에서든 역전시킬 수 있다. 따라서 객체 지향을 사용하면 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖는다.

  이를 사용하면 특정 컴포넌트의 소스 코드가 변경되어도 해당 코드가 포함된 컴포넌트만 다시 배포하면 되는, 배포 독립성(independent deployability)을 얻을 수 있다. 또 한, 서로 다른 팀에서 모듈을 독립적으로 개발할 수 있는, 개발 독립성(independent deveopability)을 얻을 수 있다.

  예를 들어 UI, DB, 비즈니스 규칙이 있을 때, DB와 UI가 비즈니스 규칙에 의존한다면(비즈니스 규칙이 UI, DB를 호출하지 않는다면), 비즈니스 규칙을 UI, DB와는 독립적으로 배포할 수 있다. 또 한, UI와 DB에서 발생한 변경이 비즈니스 규칙에 영향을 미치지 않는다.

 

6장 함수형 프로그래밍

  함수형 언어에서 변수는 불변이다.

불변성과 아키텍처

  어떠한 변수도 변경되지 않는다면 동시성 애플리케이션에서 마주치는 racing condition, deadlock condition(서로가 상대방의 자원을 내놓기를 바라면서 무기한 연기 상황에 빠지는 문제) 등의 문제가 발생하지 않는다. 따라서 무조건 불변성을 보장하면 좋겠지만 저장 공간이 무한하지 않고 프로세서의 속도가 무한히 빠르지 않기 때문에 타협점을 찾아야 한다.

가변성 분리

  불변성에 대한 주요 타협 중 하나는 애플리케이션 내부의 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리하는 것이다. 불편 컴포넌트는 순수 함수형으로만 이루어져 있으며 어떤 가변 변수도 사용되지 않는다. 상태 변경은 컴포넌트를 동시성 문제에 노출하므로 트랜잭션 메모리(transactional memory)와 같은 실천법을 사용해 가변 변수를 보호한다.

  트랜잭션 메모리는 DB가 레코드를 다루는 방식과 동일한 방식으로 메모리의 변수를 처리한다. 예를 들어 클로저의 atom의 경우 atom으로 표시된 구역이 끝나면 트랜잭션이 완료된다. 만약 충돌이 없다면 커밋되고, 있었다면 재시작한다.

1
2
3
atomic {
    ...
}
cs

  atom 기능은 간단한 애플리케이션에는 접합하지만 여러 변수가 상호 의존하는 상황에서는 동시 업데이트와 교착상태로부터 완볏히 보호해주진 못한다.

  트랜잭셔널 메모리로 구현된 프로그램은 트랜잭션 안의 코드가 트랜잭션 중간 단계의 잘못된 데이터를 읽을 수 있다. 예를 들어 아래와 같은 코드가 있다고 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
// 트랜잭션 A
atomic {
    if (x != y) {
        while (true) {}
    }
}
 
// 트랜잭션 B
atomic {
    x++;
    y++;
}
cs

  위 코드에서 B가 x를 변경하고 y를 변경하기 전에 A를 실행한다면 A는 무한루프에 빠진다. 따라서 트랜잭셔널 메모리를 락과 함께 사용하는 등의 가변 변수들을 보호하는 적절한 수단을 동원해야 한다.

   요지는 애플리케이션을 제대로 구조화 하기 위해선 변수를 변경하는 컴포넌트와 변경하지 않는 컴포넌트를 분리해야 하며 가변 변수들을 보호하는 수단이 필요하다는 것이다.

이벤트 소싱

  저장 공간과 처리 능령의 한계를 날이 갈수록 늘어나고 있다. 그에 따라 필요한 가변 상태는 더 적어진다. 

  예를 들어 보자, 계좌 잔고를 관리하는 애플리케이션에서 입출금 트랜잭션이 발생할 때마다 잔고를 변경하는 대신 트랜잭션 자체를 저장한다 해보자. 그러면 가변 변수가 필요 없어진다. 이 전략을 영원히 실행하려면 무한한 저장 공간, 무한한 처리 능력이 필요하다. 하지만 이는 애플리케이션의 생명주기 동안만 동작하면 된다.

  이벤트 소싱(event sourcing)의 기본 발싱이 이것이다. 이벤트 소싱은 순차적으로 발생하는 이벤트를 모두 저장하는 방식이다. 상태가 필요해지면 상태의 시작점부터 모든 트랜잭션을 처리한다 이를 사용하면 애플리케이션은 오직 CR만 발생하므로 동시 업데이트 문제가 발생하지 않는다.

  저장 공간과 처리 능력이 충분하다면 애플리케이션이 완전한 불변성을 갖게 만들 수 있다. 따라서 완전한 함수형으로 만들 수 있다.

 

출처 - 클린 아키텍처