개성있는 개발자 되기

2부 벽돌부터 시작하기: 프로그래밍 패러다임 본문

Web Development/Clean Architecture

2부 벽돌부터 시작하기: 프로그래밍 패러다임

정몽실이 2020. 1. 29. 23:40

 

 

패러다임이란 프로그래밍을 하는 방법으로, 대체로 언에는 독립적이다. 쉽게 말해, 프로그래밍의 역사, 트렌드라고 생각하면 된다. 책에서는 이 세가지 패러다임을 말하고 있으며, 이 외의 패러다임은 존재하지 않을 것이라고 한다.

 

구조적 프로그래밍

- 최초로 적용된 패러다임

- 데이크스트라는 무분별한 점포(goto 문장)는 프로그램 구조에 해롭다는 사시을 제시한다.

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

 

객체 지향 프로그래밍

- 요한 달(Ole Johan Dahl)과 크리스틴 니가드(Kristen Nygaard)는 알골 언어의 함수 호출 스택 프레임을 힙으로 옮기면, 함수 호출이 반환된 이후에도 함수에서 선언된 지역 변수가 오랫동안 유지될 수 있음을 발견했다.

- 이 함수가 클래스의 생성자가 되었고, 지역변수는 인스턴스 변수, 그리고 중첩 함수는 메서드가 되었다.

- 함수 포인터를 특정 규칙에 따라 사용하는 과정을 통해 다형성이 등장했다. (OO가 생기기 전에도 C에서 함수 포인터를 통해 다형성을 구현하고 있었다.)

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

 

함수형 프로그래밍

- 불변성이 기초인 람다 계산법이 근간이다.

- 심볼의 값이 변경되지 않는다는 개념인데, 이는 함수형 언어에는 변수 값을 변경하는 할당문이 전혀 없다는 뜻이기도 하다.

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

 

△ 위 세가지 프로그래밍 패러다임 모두 프로그래머에게서 권한을 박탈한다. 즉, 패러다임은 무엇을 해야할지를 말하기 보다는 무엇을 해서는 안 되는지를 말해준다.

 


1. 구조적 프로그래밍

 

데이스크트라는 증명이라는 수학적 원리를 적용하여 "아주 작은 세부사항이라도 간과하면 프로그램이 동작하는 것 처럼 보이더라도 결국엔 예상 외의 방식으로 실패한다" 를 증명하려고 했다.

 

이 연구를 진행하면서 goto 문장이 모듈을 더 작은 단위로 재귀적으로 분해하는 과정에 방해가 된다는 사실을 발견했다.

↔ if/then/else와 do/while과 같은 분기와 반복이라는 단순한 제어구조에 해당한다면 goto 문은 좋은 사용방식이라는 사실도 발견했다. 

 

결국, 데이스크트라는 제어구조를 통해, 모든 프로그램은 순차, 분기, 반복이라는 세 가지 구조만으로 표현할 수 있다는 사실을 증명했다.

 

결론 : 

구조적 프로그래밍은 제어흐름을 제약없이 직접 전환하지 않는 것을 뜻하며, 오늘날 우리는 언어에서 프로그램의 제어흐름을 직접 전환할 수 없다.

 

이를 토대로 구조적 분석, 구조적 설계 기법을 사용하면 프로그래머는 대규모 시스템을 모듈과 컴포넌트로 나눌 수 있고, 더 나아가 모듈과 컴포넌트는 입증할 수 있는 아주 작은 기능들로 세분화할 수 있다.

 


2. 객체 지향 프로그래밍

 

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

 

- 데이터와 함수의 조합

- 실제 세계를 모델링하는 새로운 방법

 

위 두 가지 말로 OO를 설명하지만, 다 얼버무리는 것 같다.

OO의 본질만 살펴보자. 캡슐화 encapsulation, 상속 inheritance, 다형성 polymorphism 이다.

 

1) 캡슐화

데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 긋는다.

OO에서는 각각 클래스의 private 멤버 데이터와, public 멤버 함수로 표현된다.

 

물론, 캡슐화는 OO에서만 국한된 것은 아니다. 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"

struct Point {
	double x,y;
}

struct Point* makepoint(double x, double y) {
}

double distance(struct Point* p1, struct Point* p2) {
}

 

위와 같이, point.h를 사용하는 측에서 struct Point의 멤버에 접근할 방법이 전혀 없다. 완벽히 구분되어 있으며, point.h에 선언되어 있는 구조체를 구현할 뿐이다.

 

이렇듯, C에서는 먼저 데이터 구조화 함수를 헤더 파일에 선언하고, 구현 파일에서 이들을 구현했다.


이후에 C++ 컴파일러는 클래스의 멤버 변수를 해당 클래스의 헤더 파일에 선언할 것을 요구했다.

 

point.h

class Point {
public : 
   Point(double x, double y);
   double distance(const Point& p) const;
   
private :
	double x;
    double y;
   
};

 

이렇게 되면 point.h 헤더 파일을 사용하는 측에서는 멤버 변수인 x와 y를 알게되어 캡슐화가 깨져버린다.

자바와 C#에서는 헤더와 구현체를 분리하는 방식을 모두 버렸고, 이로 인해 캡슐화는 더욱 심하게 훼손되었다.

 

이 때문에, OO가 강력한 캡슐화에 의존한다는 정의는 받아들이지 않는다. 실제로 C언어에서 누렸던 완벽한 캡슐화를 약화시켜 온것은 틀림없다.

(인터페이스 구현방식이 헤더와 구현체를 나눴다고 봐도 되지 않을까?)

 

2) 상속

 

C에서도 상속을 구현할 수 있었다.

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

// 업캐스팅
(struct Point*) origin;
(struct Point*) upperRight;

 

Point 구조체와 비교해봤을때, 선언된 두 변수의 순서가 동일하기 때문에, 업캐스팅을 할 수 있다.

상속을 흉내내는 요령은 있었지만, 사실상 다중 상속을 구현하기 훨씬 더 어려웠다.

하지만 OO에서는 비교적 편리한 방식으로 상속을 제공한다고 볼 수 있다.

 

3) 다형성

 

C에서도 다형성을 구현할 수 있었다.

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

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

이 함수들의 행위가 STDIN 과 STDOUT의 타입에 의존하므로 다형적이다.

 

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

열기(open), 닫기,(close), 읽기(read), 쓰기(write), 탐색(seek)이 바로 이 표준 함수들이다.

 

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

FILE 데이터 구조는 이들 다섯 함수를 가리키는 포인터들을 포함한다. 그리고, 위에서 봤던 STDIN을 FILE*로 선언하면, 이 포인터 함수를 호출하는 "함수"를 만들면 된다.

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

 

이러한 기법들이 모두 OO가 지닌 다형성의 근간이 된다. 예를 들어, C++에서는 클래스의 모든 가상함수는 vtable이라는 테이블에 포인터를 가지고 있고, 모든 가상 함수 호출은 이 테이블을 거치게 된다. 파생 클래스의 생성자는 생성하려는 객체의 vtable을 단순히 자신의 함수들로 덮어 쓸 뿐이다.

 

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

 

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

함수 포인터는 위험하기 때문이다.

- 개발자는 포인터를 통해 호출하는 rule을 모두 지켜야하고, 기억해야 한다.

 

△ OO언어는 이러한 제한을 없애주며, 제어흐름을 간접적으로 전환하는 규칙을 부과한다.

 

4) 다형성이 가진 힘

 

기계가 달라져도 동일한 프로그램을 사용할 수 있다면 매우 유용하지 않나.

이처럼 입출력 장치 독립성을 지원하기 위해 플러그인 아키텍처가 등장되었다.

함수를 가리키는 포인터를 사용하면 위험을 수반하였는데, OO의 등장으로 언제 어디서든 플러그인 아키텍처를 적용할 수 있게 되었다.

 

5) 의존성 역전

 

다형성을 안전하고 편리하게 적용할 수 있는 메커니즘이 등장하기 전 소프트웨어는 main 함수로부터 제어의 흐름이 의존성의 방향으로 따라가는 것이었다.

 

하지만 OO에서는 인터피아스를 이용해 의존성의 역전을 수행해낸다.

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

 

이렇게 되면 모든 시스템은 독립적으로 만들 수 있으며, 또 배포도 독립적으로 할 수 있다.

 

결론:

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

OO를 사용하면 아키텍트는 플로그인 아키텍처를 구성할 수 있고, 모듈에 대해 독립성을 보장할 수 있다.

 


함수형 프로그래밍

 

함수형 언어에서 변수는 변경되지 않는다.

 

자바언어라면 정수의 제곱을 출력하는 함수는 아래와 같이 기재된다.

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

아래는 리스프에서 파생한 클로저라는 함수형 언어로 구현한 코드이다.

(println; -- 출력한다.
    (take 25; -- 처음부터 25까지
        (map (fn [x] ( * x x)); -- 제곱을
            (range))))); -- 정수의

 

자바 코드에서 i 는 가변변수로, 변경이 되지만, 클로저에서는 x와 같은 변수가 한 번 초기화되면 절대로 변하지 않는다.

 

1) 불변성과 아키택처

 

경합(race)조건, 교착상태(deadlock) 조건, 동시 업데이트(concurrent update) 문제가 모두 가변 변수로 인해 발생하기 때문에 아키텍처에서 불변성은 중요하다.

 

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

 

2) 가변성의 분리

 

애플리케이션 내부의 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리한다. 

불별 컴포넌트에서는 순수하게 함수형 방식으로만 작업이 처리되며, 어떤 가변변수도 사용되지 않는다. 불변 컴포넌트는 상태를 변경할 수 있는, 즉 순수 함수형 컴포넌트가 아닌 하나 이상의 다른 컴포넌트와 서로 통신한다.

 

예로, 클로저의 atom 기능을 들 수 있다.

(def counter (atom 0)); counter를 0으로 초기화한다.
(swap! counter inc) ; counter를 안전하게 증가시킨다.

클로저에서 atom은 특수한 형태의 변수로, 값을 변경하려면 반드시 swap! 함수 (가변 컴포넌트)를 사용해야 한다는 매우 엄격한 제약이 걸려있다.

 

△ 애플리케이션을 제대로 구조화 하려면 변수를 변경하는 컴포넌트와 변경하지 않는 컴포넌트를 분리해야 한다.

 

3) 이벤트 소싱

 

더 많은 메모리를 확보할수록, 기계가 더 빨라질수록, 필요한 가변상태는 더 적어진다.

 

간단한 예로, 고객의 계좌 잔고를 관리하는 은행 애플리케이션을 생각해보자.

계좌 잔고를 변경하는 대신에 입금 트랜잭션, 출금 트랜잭션 자체를 저장한다고 상상해보자.

누군가 잔고 조회를 요청할 때마다 계좌 개설 시점부터 발생한 모든 트랜잭션을 단순히 더한다.

→ 이 전략에서는 가변 변수가 하나도 필요 없다.

 

이벤트 소싱에 깔려 있는 기본 발상이 바로 상태가 아닌 트랜잭션을 저장하자는 전략이다.

트랜잭션만 저장해두면, 상태가 필요해지면 단순히 상태의 시작점부터 모든 트랜잭션을 처리한다.

 

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

Comments