036. (Clean Architecture) 12. 컴포넌트

12. 컴포넌트

컴포넌트는 시스템의 구성 요소로 배포할 수 있는 가장 작은 단위다.

자바의 경우 jar파일이, 루비는 gem파일이, 닷넷에서는 DLL이 컴포넌트라고 볼 수 있다.

컴파일형 언어에서 컴포넌트는 바이너리 파일의 결합체이며, 인터프리터형 언어에서는 소스 파일의 결합체이다.

즉, 모든 언어에서 컴포넌트를 배포할 수 있는 단위 입자라고 볼 수 있다.

이때 여러 컴포넌트를 서로 링크하여 실행 간으한 단일 파일로 생성할 수 있고, 여러 컴포넌트를 서로 묶어서 war 파일과 같은 단일 아카이브로 만들 수도 있다.

또는 컴포넌트 각각을 jar나 dll같이 동적으로 로드할 수 있는 플러그인이나, exe 파일로 만들어서 독립적으로 배포할 수도 있다.

이처럼 잘 설계된 컴포넌트는 마지막에 어떤 형태로 배포되든 간에 반드시 독립적으로 배포 가능해야 한다.

따라서, 독립적으로 개발 가능한 능력을 갖추어야 한다.

12.1. 컴포넌트의 간략한 역사

소프트웨어 개발 초창기에는 메모리에서의 프로그램 위치와 레이아웃을 개발자가 직접 제어해야 했다.

따라서 프로그램의 시작부에는 프로그램이 로드될 주소를 선언하는 오리진(origin) 구문이 나와야 했다.

아래는 간략한 PDP-9 프로그램이다.

이 프로그램은 키보드로부터 문자열을 입력받아 버퍼에 저장하는 GETSTR이라는 이름의 서브루틴을 포함한다.

그리고 이 GETSTR을 검사하는 간단한 단위 테스트 프로그램도 포함한다.

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
        *200
TLS
START, CLA
TAD BUFR
JMS GETSTR
CLA
TAD BUFR
JMS PUTSTR
JMP START
BUFR, 3000

GETSTR, 0
DCA PTR
NXTCH, KSF
JMP -1
KRB
DCA I PTR
TAD I PTR
AND K117
ISZ PTR
TAD MCR
SZA
JMP NXTCH

K117, 117
MCR, -15

프로그램의 시작부인 *200 명령어는 메모리 주소 200에 로드할 코드를 생성하라고 컴파일러에게 알려준다.

요즘의 프로그램은 메모리의 어느 위치에 로드할지 고민할 필요가 거의 없지만, 초창기에는 로드할 메모리의 위치를 정해야했다.

또한 프로그램의 위치가 한 번 결정되면 재배치가 불가능했다.

이러한 상황에서 라이브러리 함수에는 접근할 수 있을까?

이 때에는 라이브러리 함수의 소스 코드를 애플리케이션 코드에 직접 포함시켜 단일 프로그램으로 컴파일하였다.

즉, 라이브러리는 바이너리가 아닌 소스 코드의 형태로 유지되었다.

당시의 컴퓨터는 느리고 메모리도 비쌌기때문에 이러한 접근법은 문제가 있었다.

메모리가 너무 작았기 때문에 소스 코드 전체를 메모리에 상주시킬 수가 없었고, 이를 해결하기위해 컴파일러는 소스 코드 전체를 여러 번에 걸쳐서 읽어야 했다.

당연히 라이브러리 함수의 소스 코드가 클수록 컴파일은 오래 걸렸다.

이 컴파일 시간을 단축시키기 위해 프로그래머는 라이브러리 함수의 소스 코드를 애플리케이션 코드로부터 분리했고, 각각 컴파일하여 컴파일된 바이너리를 메모리의 특정 위치에 로드했다.

이 경우 메모리는 아래 그림과 같이 배치되었다.

Early memory layout

최초에는 잘 동작하겠지만, 애플리케이션이 점점 커지는 경우 할당된 공간을 넘어서게 된다.

이때 개발자는 아래와 같이 애플리케이션을 두 개의 주소 세그먼트로 분리하여 함수 라이브러리 공간을 사이에 두고 오가며 동작하도록 배치해야한다.

Splitting the application into two address segments

이러한 상황은 지속 가능하지 않다.

애플리케이션과 마찬가지로 라이브러리에 더 많은 함수를 추가하게 되면 라이브러리 역시 할당된 메모리 공간을 넘어서게 되고, 이때마다 계속해서 세그멘테이션을 수행하는 건 한계가 있기때문이다.

12.2. 재배치성

위 문제에 대한 해결책은 재배치가 가능한 바이너리(relocatable binary)이다.

지능적인 로더를 사용해서 메모리에 재배치할 수 있는 형태의 바이너리를 생성하도록 컴파일러를 수정하는 방식이다.

이때 로더는 재배치 코드가 자리할 위치 정보를 전달받고, 로드한 데이터내에서 어느 부분을 수정해야 정해진 주소에 로드할 수 있는지를 식별하기 위한 플래그가 삽입되었다.

참고 대개 이러한 플래그는 바이너리에서 참조하는 메모리의 시작 주소 였다.

이제 개발자는 라이브러리를 로드할 위치와 애플리케이션을 로드할 위치를 로더에게 지시할 수 있게 되었다.

실제로 로더는 여러 바이너리를 입력받은 후, 하나씩 차례로 메모리에 로드하면서 재배치하는 작업을 처리했다.

이 과정을 통해 개발자는 오직 필요한 함수만을 로드할 수 있게 되었다.

또한 컴파일러는 재배치가 가능한 바이너리 안의 함수 이름을 메타데이터 형태로 생성하도록 수정되었다.

만약 프로그램이 라이브러리 함수를 호출한다면 컴파일러는 라이브러리 함수의 이름을 외부 참조(external reference)로 생성했고,

반대로 라이브러리 함수를 정의하는 프로그램이라면 컴파일러는 해당 이름을 외부 정의(external definition)으로 생성하였다.

이를 통해서 외부 정의를 로드할 위치가 정해지기만 하면 로더가 외부 참조를 외부 정의에 링크시킬 수 있게 되었다.

이게 바로 링킹 로더(linking loader) 의 탄생이다.

12.3. 링커

링킹 로더의 등장으로 프로그래머는 프로그램을 개별적으로 컴파일하고 로드할 수 있는 단위로 분할할 수 있께 되었다.

작은 프로그램과 작은 라이브러리에 대한 링킹은 대체로 잘 동작하였으나, 1970년대부터 프로그램이 훨씬 커지게 되면서 링킹 로더 조차 너무 느려지게 되었다.

이 시점에 로드와 링크가 두 단계로 분리되게 되었다.

개발자는 비교적 느린 부분인 링크 과정을 맡았고, 링커(linker)라는 별도의 애플리케이션으로 이 작업을 처리하도록 만들었다.

링커는 링크가 완료된 재배치 코드를 만들어 주었고, 그 덕분에 로더의 로딩 과정이 빨라지게 되었다.

링커 자체는 느리게 실행 파일을 만들지만, 한 번 만들어둔 실행 파일은 언제라도 빠르게 로드할 수 있게 된 것이다.

1980년대가 되면서 개발자들은 고수준 언어를 사용하기 시작하였고, 프로그램 코드가 수십만 라인을 넘어서는 것도 특별한 일이 아니게 되었다.

소스 모듈은 컴파일된 후 링커로 전달되어 바르게 로드될 수 있는 형태의 실행 파일로 만들어졌다.

각 모듈을 컴파일하는 과정은 상대적으로 빨랐지만, 전체 모듈을 컴파일하는 일은 꽤 시간이 걸리게 되었고

이후 링커에서는 더 많은 시간이 걸리게되면서 전체 소요 시간도 덩달아 늘어나기 시작하였다.

로드 시간이 아무리 빠르더라도 컴파일-링크 시간의 병목을 해결할 수가 없었다.

1980년대 후반 무어의 법칙에 따라 디스크는 작아지면서도 엄청나게 빨라지기 시작하였다.

메모리 또한 매우 저렴해지면서 디스크에 저장할 데이터를 모두 램에 캐싱할 수 있을 정도가 되었고, 컴퓨터의 클록 속도고 폭발적으로 증가했다.

하드웨어의 발달로 프로그램의 성장 속도보다 링크 시간의 감소가 더욱 빨라졌고, 대다수의 링크 시간은 초단위 수준이 될 정도로 감소했다.

이렇게 ActiveX와 공유 라이브러리 시대가 개막하였고, jar 파일도 등장하기 시작하였다.

그러고도 멈추지않은 하드웨어의 발전은 로드와 링크를 동시에 수행할 수 있을 정도가 되었고

다수의 라이브러리를 순식간에 링크한 후, 링크가 끝난 프로그램을 실행할 수 있게 되었다.

이렇게 컴포넌트 플러그인 아키텍처(Component Plugin Architecture) 가 탄생하였다.