Home
All Posts

내 코드는 어떻게 실행되는걸까?

Created
2024/05/31
Tags
CS
Category
Knowledge
2 more properties
프로그래밍을 하다보면 종종 이 짤과 같은 상황을 마주할 때가 있다.
이번 포스팅에서는 우리가 코드를 쓸 때 어떤 일이 생기는지를 이야기 해보려고 한다.
내용은 책 <컴퓨터 밑바닥의 비밀>을 참고했으며, 책보다 더 쉽고 자세하게 써보았다.

모든지 원리를 알면 쉽습니다

평소 코드가 어떻게 동작하는지, 내가 쓴 코드를 컴퓨터가 어떻게 인식할 수 있는지 궁금했다. 또한 개발자라면 이런건 알아야지 생각했다. 개발은 요리와 같다는 관점에서 말이다.
요리를 할 때 각각의 조미료와 재료가 어떤 특성이 있는지 알면 좋다. 예를 들면 버터는 음식에 풍미를 더해준다든가, 식초는 신 맛을 내니 김치를 신김치로 만들어줄 수 있다든가. 어쨌든 각 재료가 음식에 어떤 영향을 주는지 알면 요리에 도움이 된다.
개발도 마찬가지다.
각각의 코드나 라이브러리가 프로그램에 어떤 영향을 미치는지 잘 알아야 내가 원하는 모습의 프로그램으로 만들 수 있다. 혹은 원하는 모습이 되지 않았을 때 어떤 것이 문제인지 금방 찾아낼 수도 있다. 만약 그렇지 않으면 성능 상의 문제가 생기거나 유지보수의 어려움이 뒤따를 수 있다.
컴퓨터는 내가 작성한 코드를 어떻게 인식할 수 있을까?
이 질문에 대한 대답으로는 당연하게도, “컴파일러가 실행 파일을 생성하면 프로그램이 시작된다”고 할 수 있다.
정확하긴 하다. 하지만 너무 추상적이다.
다시 한번 질문을 해보겠다.
그렇다면 컴파일러는 어떻게 코드를 기반으로 실행 파일을 생성(build) 할까?
더 나아가, 프로그래밍 언어는 어떻게 만들어졌을까?
실행 파일은 왜 실행될 수 있을까? 실행 파일은 어떤 형태로 실행될까?
물음표 살인마

먼저 프로그래밍 언어부터 알아보자

CPU는 똑똑하지만 바보

CPU는 굉장히 똑똑하고 많은 일을 하는 것처럼 보이지만, 사실 0과 1밖에 모르는 단세포 생물이다. 데이터를 옮기고, 연산한 후에 다시 그 데이터를 옮기는 역할만 한다.
이렇게 보면 CPU가 별로 하는 일이 없어 보인다.
하지만 CPU는 사실 매우 빠르다는 강점이 있다. 하지만 빨랐죠

최초의 명령어 0과 1

프로그래밍의 창세기라고 여겨지는 때는 0과 1로 구성된 명령어를 작성했다. 이 명령어로 CPU에게 일을 시켰는데, 이때는 천공카드(punched card)를 이용했다.
출처: 나무위키
지금의 프로그래밍 모습과 많이 다르지만, 이때는 이것이 바로 코드이고, 소스였다. 이 천공 카드를 통해 명령어를 작성하면 컴퓨터가 작업을 할 수 있었다.
하지만 보다시피 무슨 말인지 모르겠다. 인간보다 CPU의 관점에서 이해하기 좋은 코드인 것 같다.

어셈블리어의 등장

인간 개발자는 자존심이 상한다. 왜인지 CPU에게 진 것만 같다.
이제는 인간의 언어와 가까운 언어를 개발하기로 한다. 이에 기계어와 특정 작업을 대응시켜, 기계어를 인간이 읽고 이해할 수 있는 단어와 대응시켜 보았다.
sub $8, %rsp mov $.LCO, %edi call puts mov $0, %eax
Assembly
복사
이제는 1101.. 를 복잡하게 기억할 필요 없이 sub, mov와 같이 인간이 인식할 수 있는 단어만 기억하면 된다. 이렇듯 인간이 인식할 수 있는 기계 명령어를 CPU가 인식할 수 있는 0과 1로 구성된 바이너리로 변환하는 프로그램을 사용하기 시작했다.
이것이 바로 어셈플리어의 탄생이었다.
처음으로 인간이 인식할 수 있는 프로그래밍 언어가 탄생했다.
흠. 나름 애썼지만.. 솔직히 아직 완전의 인간의 언어라고 하기엔 어렵다.

저수준 언어와 고수준 계층의 추상화

여전히 어셈블리어는 기계어와 마찬가지로 저수준 언어(low-level language)에 가깝다. 이 말은 즉, 우리가 프로그래밍을 하면서 여러가지 세부사항에 신경을 써야 한다는 것이다.
어떤 세부사항을 신경써야 하냐면,
앞서 CPU는 똑똑하지만 바보라고 했다. 데이터를 이동하고 연산하고 또 이동하는 것만 할 수 있다. 따라서 저수준 언어로 프로그래밍을 하면 ‘데이터를 이동하고 연산하고 또 이동하는 것’처럼 몇 가지 간단한 명령어만을 사용해 복잡한 수준의 프로그래밍을 해야 한다는 것이다.
예를 들어 친구에게 사과를 줘 라는 명령어가 있다 치자.
이를 어셈블리어와 같은 저수준 언어로 표현하려면 아래 세부사항을 모두 신경써야 한다.
사과를 찾는다 → 사과가 없다면 눈을 굴린다 → 사과가 있다면 사과를 잡기 위해 팔을 뻗는다 → 사과를 잡는다 → 친구를 찾는다 → 친구가 없다면 친구를 기다린다 → 친구가 있다면 친구를 발견한다 → 친구가 나를 본다면 친구를 부른다 → ….
휴. 나는 그냥 친구에게 사과를 주고 싶을 뿐인데 너무 많은 것들을 고려해야 한다. 정말 번거롭다.
그냥 ‘친구에게 사과를 줘’ 라고 하면 끝날 일인데, 컴퓨터는 이러한 추상적인 문장을 이해하지 못한다. 이렇게 가다간 친구에게 사과를 주는 것 뿐만 아니라 친구와 만나 커피를 마시고 밥을 먹지도 못하겠다.
그렇다면 인간과 기계 사이의 거리를 좁힐 수 있는 방법이 있을까?
혹은 인간이 말하는 추상적인 표현을 CPU가 이해할 수 있게 변환할 수는 없을까?

고급 프로그래밍 언어의 시작

인간과 CPU 사이의 거리를 좁히기 위해 여러 고심을 하다가, 아까 썼던 세부사항을 다시 돌아보았다. 그랬더니 몇몇 명령어가 CPU에 특정 작업을 수행하라고 아주 단도직입적으로 이야기하고 있는 것을 발견했다.
사과를 찾는다 → 사과가 없다면 눈을 굴린다 → 사과가 있다면 사과를 잡기 위해 팔을 뻗는다 → 사과를 잡는다친구를 찾는다 → 친구가 없다면 친구를 기다린다 → 친구가 있다면 친구를 발견한다 → 친구가 나를 본다면 친구를 부른다 → ….
위와 같이 단도직입적인 명령어에 문(statement)이라는 이름을 붙였다.
또한, 이외의 단도직입적이지 않은 문장들에는 특정 상황에 따라 어떤 명령어를 실행해야 할지 선택해야 하는 상황도 있었다.
사과를 찾는다 → 사과가 없다면 눈을 굴린다사과가 있다면 사과를 잡기 위해 팔을 뻗는다 → 사과를 잡는다 → 친구를 찾는다 → 친구가 없다면 친구를 기다린다친구가 있다면 친구를 발견한다친구가 나를 본다면 친구를 부른다 → ….
위와 같은 명령어를 인간의 언어로 표현한다면 “만약 ~라면 ~ 하고, ~하지 않다면 ~한다”가 된다. 이것을 지금 우리가 많이 쓰고 있는 코드로 나타내면 아래와 같다.
if (사과를 찾는다) { 사과를 잡기 위해 팔을 뻗는다 } else { 눈을 굴린다 }
JavaScript
복사
또한 비슷한 명령어가 반복되고 있다. 이걸 좀 효율적으로 쓸 수 있지 않을까?
사과를 찾는다 → 사과가 없다면 눈을 굴린다 → 사과가 있다면 사과를 잡기 위해 팔을 뻗는다 → 사과를 잡는다 → 친구를 찾는다 → 친구가 없다면 친구를 기다린다 → 친구가 있다면 친구를 발견한다 → 친구가 나를 본다면 친구를 부른다 → ….
주어만 다를 뿐 하는 행동은 같은 반복되는 명령어는 주어만 바꿔도 좋겠다. 여기서 주어를 ‘매개변수(parameter)’ 라고 한다. 이를 별도로 분리하고 매개변수를 제외한 나머지 명령어를 하나로 묶어, 하나의 코드로 지정하기만 하면 된다.
이렇게 함수가 탄생한다.
function findSomething(subject) { ... }
JavaScript
복사
우와. 아까 이해하기 어렵던 어셈블리어와 비교하면 매우 비약적인 발전이다.

재귀의 본질

그런데 아까 언급한 아래의 코드에서 은 무엇일까?
function findSomething(subject) { ... // 여기 }
JavaScript
복사
그리고 도 단순한 문(statement)일까?
아닐수도 있다. 는 while문이 될 수도 있고, if else일 수도 있다. 혹은 안에는 if else 안에 또다른 if else가 포함될 수도 있다. 그리고 그 안에는 또또다른 if else가 포함될 수도 있다. …… (반복)
이것이 바로 재귀다.
끝없이 중첩된 것처럼 보이는 것도 사실 재귀로 표현될 수 있다. 이렇게 복잡한 중첩을 간결한 문장으로 표현할 수 있다. 이 몇 가지 표현에 ‘구문(syntax)’ 이라는 이름을 붙여본다.
이렇듯 모든 코드는 아무리 복잡하더라도 결과적으로는 모두 ‘구문’으로 귀결된다. 이것이 가능한 이유는 모든 코드는 구문에 기초하여 작성되기 때문이다.

컴퓨터가 재귀를 이해하도록 만들기

이제 다음 단계로 가보자. 이제 이 구문을 컴퓨터가 인식할 수 있는 기계어로 변환하려면 어떻게 해야 할까? 즉, 컴퓨터가 재귀 구문으로 표현된 문자열을 인식할 수 있게 하려면 어떻게 해야 할까?
바로 구문 트리(syntax tree)로 표현하면 된다.
출처: 위키백과
위의 그림을 보면 단계 안에 또다른 단계가 중첩되며 계속해서 자식을 가지고 뻗어나간다.
그렇다. 이것 또한 재귀다.
다시 말해, 재귀 구문에 따라 작성된 코드를 트리구조로 표현할 수 있다는 것이다.
이제 코드는 트리 형태로 표현될 수 있다. 자세히 보면 사실 리프 노드(leap node)의 표현이 매우 간단하게 바뀌어서 기계어로도 번역할 수 있다는 것을 알 수 있다.
따라서 리프 노드를 기계어로 번역하기만 하면, 그 결과를 리프 노드의 부모 노드에 적용할 수 있다. 이렇게 번역 결과를 차례차례 부모 노드에 적용하는 방식으로 타고타고 올라가다 보면, 결국 전체 트리를 구체적인 기계 명령어로 번역할 수 있다.

컴파일러는 번역가

이 작업을 담당하는 담당하는 프로그램은 ‘컴파일러’다.
출처: 네이버 사전
컴파일러는 말그대로 명령어를 번역하는 번역가의 역할을 한다.
즉, CPU가 인식할 수 있는 기계 명령어로 번역한다.
컴파일러가 없다면 우리는 여전히 1010처럼 이해할 수 없는 기계어로 코딩을 하고 있었을 것이다. 그만큼 중요한 존재이며, 컴파일러로 인해 지금까지 소프트웨어 발전과 개발자들의 생산성이 엄청나게 향상되었다고 해도 과언이 아니다.

해석형 언어 - 인터프리터의 탄생

그런데 여기서 또 문제가 있다.
세상에는 다양한 CPU가 있고, 각각의 CPU는 자신만의 고유한 언어를 가지고 있다. 그래서 CPU-A에서 생성된 기계어를 → CPU-B에서는 실행할 수 없다는 것이었다.
개발자가 작성한 코드가 가능한 다양한 플랫폼에서 실행되기를 원하지만, 계속 다시 컴파일하는 과정을 반복하고 싶지 않다면 어떻게 해야 할까?
오늘날 세계의 많은 나라가 영어를 국제 통용어로 사용하고 있다. 그래서 어느 나라의 관광지를 가든 영어로 된 표지판을 쉽게 찾아볼 수 있다.
출처: unsplash
이렇듯 CPU도 각각 다른 유형이 있고, 각각 다른 하드웨어 제조사가 설계한 것이기에 직접 바꾸기엔 어렵다. CPU가 기계어를 실행하는 존재라는 것을 떠올려 보자. 그렇다면 우리도 국제 통용어처럼 직접 표준 명령어 집합을 정의하고, CPU의 기계어 실행 과정을 ‘모방’하는 프로그램을 작성해 사용할 수도 있지 않을까?
즉, 한번의 코드 작성으로 어디서나 그 코드를 실행하는 것이다.
이것이 바로 ‘인터프리터(interpreter)’의 탄생이다.

컴파일러는 어떻게 작동할까?

우리에게 컴파일러는 가장 친숙한 도구 중 하나일 것이다. 하지만 이 친숙함이라는 게, 많이 접해보는 것일 뿐이지 잘 안다고 할 수 있을까? 실행 버튼만 누르면 사용할 수 있지만 컴파일러가 묵묵하게 뒤에서 어떤 일을 하고 있는지 자세히 알아보자.

컴파일러란 무엇일까?

컴파일러는 고수준 언어를 저수준 언어로 번역하는 프로그램이다. 즉, 우리가 쓰는 소스코드를 컴파일해서 기계가 알아들을 수 있는 기계어로 번역하는 일을 한다.
출처: https://www.curioustem.org/
개발자가 프로그래밍 언어의 구문 규칙에 따라 인간이 인식할 수 있는 단어로 코드를 작성하면, 코드는 일반적인 텍스트 파일 형태로 저장된다. 이를 소스파일(source file)이라고 한다.
이 소스파일을 컴파일러에 먹여주면, 컴파일러는 이것을 꼭꼭 씹어서 맛본 후 실행 파일 형태로 뱉어낸다. 이 파일에 저장된 것이 바로 CPU가 실행할 수 있는 기계 명령어인 것이다.
소스코드 하울정식을 먹고 실행파일을 내뱉는 컴파일러 캘시퍼
정리하자면, 컴파일러는 앞서 말했듯 넓게 보면 번역기 역할인거고, 작게 보면 ‘텍스트 처리 프로그램(text processor)’ 이라는 것을 알 수 있다.

컴파일러가 하는 일

아래에 간단한 자바스크립트 코드가 있다.
let a = 1; let b = 2; while (a < b) { b = b - 1; };
JavaScript
복사
인간의 관점에서 코드는 아래와 같이 해석할 수 있다.
// 변수 a에 1을 할당한다. // 변수 b에 2를 할당한다. // a < b 이면 // b가 1씩 줄어든다. // 더이상 a < b가 성립되지 않을 때까지 앞의 while문을 반복한다.
JavaScript
복사
하지만 CPU는 이런 추상적인 표현을 이해할 수 없다. 이건 컴파일러가 해석해 주어야 한다.
그럼 컴파일러는 어떻게 소스 코드를 실행 파일로 해석할까? 알아보자.
컴파일러는 먼저 각 항목을 쪼갠다. 이때, 각 항목이 가지고 있는 추가 정보를 함께 묶어 관리한다. 예를 들어, 코드 첫 줄의 let 아래의 두 가지 정보가 포함된다.
1. 이것이 키워드라는 정보
2. 그중에서도 let 키워드라는 추가 정보
이렇듯, 각 항목에 추가로 정보를 결합한 것을 ‘토큰(token)’ 이라고 한다.
컴파일러가 하는 첫 번째 작업은 소스 코드를 돌아다니면서 모든 토큰을 찾아내는 것이다. 그리고 소스 코드에서 토큰을 추출하는 과정을 어휘 분석(lexical analysis)라고 한다.

토큰이 표현하고자 하는 의미

이제 소스 코드가 하나의 토큰으로 바뀌었다. 하지만 아직까지 토큰 상태에서는 어떤 것을 의미하는지 알기 어렵다. 개발자가 전달하고자 하는 토큰의 의도를 표현해야 한다.
앞서 코드가 구문에 따라 작성되어야 한다는 것을 알았다. 그렇다면 컴파일러가 구문에 따라 토큰을 처리한다는 것은 어떤 의미일까?
while (표현식) { 반복할 내용 }
JavaScript
복사
위의 while문에서 컴파일러가 while 키워드의 토큰을 찾으면, 다음 토큰이 ( 라는 것을 알고있는 상태로 기다린다. 하지만 다음 토큰이 while 키워드에 필요한 토큰이 아니라면, 컴파일러는 문법 오류(syntax error)를 뱉는다.
반대로 이 과정을 무사히 넘어가면 다음 토큰이 boolean 표현식이어야 한다는 것을 알고 기다린다. 그리고 또 쭉쭉 가다가 마지막의 } 을 만날 때까지 계속해서 기다리고 처리하는 과정을 반복한다.
이러한 일련의 과정을 해석(parsing)이라고 하며, 컴파일러는 구문에 따라 한 글자도 놓치지 않고 아주 꼼꼼하게 작업을 진행한다.
그렇다면 컴파일러가 구문에 따라 해석해 낸 ‘구조’는 어떻게 표현할까? 가장 좋은 방법은 위에서도 언급한 ‘트리’ 구조로 표현하는 것이다. 구문 규칙에 따라 토큰을 해석한 후 생성된 트리가 구문 트리이며, 이 트리를 생성하는 전체 과정을 ‘구문 분석’ 이라고 한다.

생성된 구문 트리 점검하기

구문 트리가 생성되면 이상이 없는지도 확인해야 한다. 예를 들어, 숫자에 문자열을 더한다든가, 비교 기호에 좌우 값 타입이 다르면 안된다. 이 단계를 통과하면 프로그램에 이상이 없기 때문에 위에서도 언급한 컴파일 오류가 없다는 것이 증명되고, 그렇지 않으면 오류가 발생한다.
이러한 과정을 의미 분석(semantic analysis)이라고 한다.

중간 코드 생성과 코드 변환

의미 분석이 끝났다? 그 다음 컴파일러는 구문 트리를 탐색한 결과를 바탕으로 이전보다 더 다듬어진 형태를 생성한다. 이 산출물을 ‘중간 코드(Intermediate Representation Code : IR Code)’라고 한다.
위의 과정이 완료되었다면 컴파일러는 앞에서 생성된 중간 코드를 어셈블리어 코드로 변환한다.

코드 생성

마지막으로, 위의 과정이 모두 문제없이 끝났다면 드디어 컴파일러가 어셈블리어 코드를 기계어로 변환한다. 이러한 일련의 과정을 거쳐 인간이 소스 코드라고 부르는 문제열을 CPU가 실행할 수 있는 기계 명령어로 번역하는 것이다.

하나의 실행 파일로 합치기

끝인가? 아니다! 이것으로 전체 컴파일 과정이 끝났다고 보기 어렵다.
GCC 컴파일러를 예로 들어보겠다. 앞서 예로 들었던 소스 코드 조각이 전체 소스 코드 파일인 code.c의 일부분이라고 가정해 보겠다. 그리고 컴파일 과정을 거쳐 기계어 데이터는 code.o라는 파일에 저장된다. 이때, .o 확장자를 가지는 파일을 대상 파일(object file)이라고 한다.
이렇듯 모든 소스 파일에는 각각의 대상 파일이 있다. 프로젝트가 복잡해져서 소스 파일이 n개가 있다면 대상 파일은 n개가 되는 것이다. 따라서 이 대상 파일들을 하나의 실행 파일로 합쳐주는 무언가가 있어야 하는 것이다.

링커의 존재를 아시나요

링크란 무엇일까

여러 개의 대상 파일들을 하나의 실행 파일로 합쳐주는 ‘링크(link)’ 라고 한다. 그리고 링크를 담당하는 프로그램을 링커(linker)라고 한다.
출처: 네이버 사전
영어 사전의 의미처럼 서로 관련있는 대상 파일들을 하나로 연결해 준다고 생각하면 된다.
링크 과정은 컴파일만큼 잘 알려져있지 않기 때문에 링크라는 단계가 있는지 모를 수 있다.
일반적으로 링커는 뒤에 숨어서 잘 동작한다는데, 구체적으로 링커는 무엇일까?

링커는 무슨 일을 할까

링커는 컴파일러와 마찬가지로 일반적인 프로그램이다. 알집 같은 압축 프로그램이 파일 여러 개를 하나의 압축 파일로 묶어주는 것처럼, 컴파일러가 생성한 대상 파일 여러 개를 하나로 묶어, 하나의 최종 실행 파일을 생성한다.
출처: www.researchgate.net
그렇다면 링커가 실제로 어떻게 일을 할까?
예를 들어 전체적인 링크 과정은 여러 명의 저자가 각각 특정 부분을 맡아 챕터별로 따로 집필한 후, 한 권의 책으로 묶어 출판하는 것과 비슷하다.
책 <개발자의 원칙>
이외에도 링커가 주로 하는 일은 종속성이 올바르게 설정되어 있는지, 즉 인터페이스 구현이 종속된 모듈에서 사용가능한지 확인한다. 이러한 과정을 ‘심벌 해석(symbol resolution)’ 이라고 하는데, 책 집필에 비유하면 이렇다.
책의 특정 챕터가 다른 챕터의 내용을 참고 → 종속성
서로 참고한 내용이 실제로 책 안에 있는지 정리 → 종속된 모듈에서 사용가능한지 확인
정리하자면, 우리가 참조하고 있는 외부 심벌에 대한 실제 구현이 어느 모듈이든 단 하나만 있어야 하는데, 링커는 이를 찾아내 연결하는 작업을 한다. 이것을 심벌 해석이라고 하는 것이다.
더불어, 코드 안에서는 ‘재배치(relocation)’ 이라는 과정도 일어난다.
특정한 소스 파일에서 다른 모듈에 정의되어 있는 함수를 참조할 때, 컴파일러가 이 소스 파일을 컴파일하는 시점에 함수가 어느 메모리 주소에 위치할지 정확히 알 수 없다. 따라서 컴파일러는 이 함수를 N으로 표시해 두고 일단 넘어간다. 이후, 링크 과정에서 링커가 이러한 N 표시를 확인한다. 그리고는 실행 파일을 생성하는 과정에서 함수의 정확한 주소를 확인하고 임시 표시해두었던 N을 실제 메모리 주소로 대체한다.
지금까지 심벌 해석, 살행 파일 생성, 재배치 등 링커의 작업 과정에서 중요한 단계들을 간단히 알아보았다.
위의 설명은 매우 간략하게 해놓은 것이니 아래에서 더 자세하게 설명해 보겠다.

심벌 해석

심벌이란, 전역 변수와 함수의 이름을 포함하는 모든 변수 이름을 의미한다. 지역 변수는 모듈 내에서만 사용되어 외부 모듈에서 사용될 수 없기에 링커의 관심 대상이 아니다.
여기서 링커의 할 일은 대상 파일에서 참조하고 있는 각각의 모든 외부 심벌마다 대상 정의가 반드시 존재하는지, 단 하나만 존재하는지를 확인하는 것이다.
그런데 링커가 이러한 정보를 어떻게 알고 확인하는 것일까?
바로 컴파일러가 알려준다.
그렇다면 컴파일러는 이 정보를 어떻게 링커에게 알려주는 걸까?
컴파일러는 인간이 이해할 수 있는 코드를 기계어로 번역하고, 그 정보를 대상 파일에 저장한다고 했다. 실제로 컴파일러는 기계어를 생성할 뿐만 아니라 이 명령어를 실행시키는 데이터도 생성하는데, 이 데이터는 대상 파일에 반드시 포함되어야 한다.
따라서 컴파일러로 인해 생성된 대상 파일에는 중요한 두 영역이 포함된 것이다.
1. 명령어 부분: 기계어가 저장되는 부분 → 코드 영역
2. 데이터 부분: 소스 파일의 전역 변수가 저장되는 부분 → 데이터 영역
컴파일러는 컴파일 과정에서 외부에서 정의된 전역 변수나 함수를 발견할 경우, 해당 변수의 선언이 존재하는 한, 그 변수가 실제로 정의되었는지는 신경쓰지 않는다. 왜냐하면 엄밀히 말하면 참조된 변수 정의를 찾는 일은 컴파일러가 아닌, 링커의 몫이기 때문이다.
대신 그동안 컴파일러는 다른 작업을 한다. 소스 파일마다 외부에서 참조 가능한 심벌이 어떤 것인지 파악하고 그 정보를 기록한다. 혹은 어떤 외부 심벌을 참조하고 있는지도 기록한다. 이렇듯, 컴파일러가 외부 심벌 정보를 기록하는 표를 ‘심벌 테이블(symbol table)’이라고 한다.
정리하자면, 심벌 테이블은 아래 두 가지를 표현한다.
1. 내가 정의한 심벌, 즉 다른 모듈에서 사용할 수 있는 심벌
2. 내가 사용하는 외부 심벌
그렇다면 컴파일은 위의 심벌 테이블을 어디에 저장할까?
컴파일러는 이 테이블을 대상 파일에 저장한다. 아까 대상 파일에 코드 영역과 데이터 영역 두 가지 포함되어 있다고 했는데, 이제는 심벌 테이블을 포함해 세 가지가 포함되었다. 여기서 대상 파일은 링커가 작업 과정에서 필수적으로 사용하는 파일이므로 이제 링커가 어떻게 필요한 정보를 얻어내는지 알 수 있다.
지금까지의 심벌 해석 과정을 정리해 보자.
심벌 해석은 각 대상 파일에서 사용할 외부 심벌이 심벌 테이블에서 유일한 정의를 발견 가능한지 확인하는 작업이다. 이를 공급과 수요로 비유할 수 있다.
1. 내가 정의한 심벌, 즉 다른 모듈에서 사용할 수 있는 심벌 → 공급
2. 내가 사용하는 외부 심벌 → 수요
공급과 수요가 맞아야 하니 링커도 공급이 수요를 충족하는지 확인한다. 즉, 내가 정의한 심벌이 내가 사용할 외부 심벌과 매칭되는지를 파악하는 일이 바로 심벌 해석이다.
만약 공급과 수요가 맞지 않다면, 즉 정의된 심벌이 참조되지 않거나 참조된 심벌이 정의되지 않았다면, 링킹 과정에서 오류가 발생한다. 예를 들어, 실제로는 사용하지 않는 함수를 정의한 경우 해당 함수에 대한 참조가 없다는 오류가 발생한다. 반대로, 필요한 함수나 변수가 정의되지 않았다면 정의되지 않은 심벌을 참조했다는 오류가 발생한다.
아까 비유한 책 출판을 다시 가져와 보면, “다음 내용은 2장을 참고하세요” 라고 했는데 막상 책을 완성하고보니 안타깝게도 2장이 없는 경우라고 할 수 있겠다.

정적 라이브러리

정적 라이브러리 기능을 사용하면 소스 파일 여러 개를 ‘미리 개별적으로’ 컴파일하고 링크해, 정적 라이브러리로 생성할 수 있다. 이때, 소스 파일마다 단독으로 컴파일한다는 점이 핵심이다.
출처: 책 <컴퓨터 밑바닥의 비밀>
이후 실행 파일을 생성할 때는 자신의 코드만 컴파일하고, 미리 컴파일된 정적 라이브러리는 다시 컴파일할 필요 없이 링크 과정에서 그대로 실행 파일에 복제된다.
출처: 책 <컴퓨터 밑바닥의 비밀>
따라서 코드가 의존하는 외부 코드를 매번 컴파일하지 않아도 되므로, 컴파일 속도가 빨라진다. 이 과정을 정적 링크(static linking)라고 한다. 정적 링크을 쉽게 설명하면, 대상 파일을 한데 모아 각각의 대상 파일에서 데이터 영역과 코드 영역을 각각 결합하는 것이다.
이때, 정적 링크는 라이브러리를 실행 파일에 직접 복사하기 때문에 거의 모든 프로그램에 적용되는 표준 라이브러리를 사용하면 정적 링크로 생성된 실행 파일은 모두 동일한 코드와 데이터의 복사본을 갖게 된다. 이 경우 디스크와 메모리를 엄청 낭비할 수 있다. 굳이 필요하지도 않은 중복된 데이터들이 엄청나기 때문이다.
더불어, 정적 라이브러리의 모든 내용이 서로 의존하고 또 의존하는 종속성을 가지고 있다 가정하자. 그렇다면 정적 라이브러리 코드가 변경될 때마다 속해있는 프로그램들도 매번 컴파일해야하는 번거로움이 생긴다.
이러한 문제를 어떻게 해결할 수 있을까?
바로, 동적 라이브러리를 사용하는 것이다.

동적 라이브러리

동적 라이브러리는 ‘공유 라이브러리’ 또는 ‘동적 링크 라이브러리’ 라고도 한다. 라이브러리라는 이름을 보듯, 동적 라이브러리에도 정적 라이브러리와 마찬가지로 코드 영역과 데이터 영역 등이 포함되어 있다.
단지 사용 방식과 사용 시간이 다를 뿐이다.

정적 라이브러리 vs. 동적 라이브러리

출처: Tatiana Fernández’s LinkedIn
정적 라이브러리를 사용하면 정적 라이브러리의 코드 영역과 데이터 영역을 모두 하나로 묶어 실행 파일에 복사한다. 반면 동적 라이브러리를 사용하면 동적 라이브러리가 실행 파일에 라이브러리 내용을 모두 복사하는 것이 아니라, 참조된 동적 라이브러리의 이름, 심벌 테이블, 재배치 정보 등 ‘필수 정보’만 실행 파일에 포함된다.
이렇게 하면 쓸데없는 중복이 없어 실행 파일 내에서 정보가 차지하는 구역이 적어지므로 실행 파일 자체의 크기를 확실히 줄일 수 있다.
그렇다면 이 필수 정보는 어느 시점에 사용될까?
바로, 동적 링크(dynamic linking)가 일어날 때다.

동적 링크

정적 라이브러리는 컴파일 단계에서 실행 파일에 함께 복사되기에 실행 파일에는 정적 라이브러리의 전체 내용이 포함된다. 반면, 동적 라이브러리에 의존하는 실행 파일에는 컴파일 단계에서 ‘필수 정보’만 저장되기에 동적 링크는 실제 프로그램의 실행 시점까지 미룬다.
참고로 동적 링크는 두 가지 방식이 있다. 첫 번째 방식에서는 프로그램이 메모리에 적재될 때 동적 링크가 진행된다. 여기서 적재란 실행 파일을 실행하기 위해 디스크에서 읽고 메모리의 특정 영역으로 이동시키는 과정을 의미한다.
두 번째로는 프로그램이 먼저 실행된 후, 프로그램의 실행 시간(runtime)동안 코드가 직접 동적 링크를 실행할 수 있다. 여기서 실행 시간이란 CPU가 프로그램을 실행하기 시작한 시점부터 실행이 완료되어 프로그램이 종료된 시점까지 시간을 의미한다.

동적 라이브러리의 장점

앞서 설명한듯이 만약 모든 프로그램들이 정적 라이브러리를 사용한다면 내용이 중복된 코드 복사본이 저장되어 디스크 공간이 낭비될 것이다.
이때 동적 라이브러리를 사용한다면 의존하는 프로그램이 몇 개든 디스크에는 동적 라이브러리 복사본 하나만 딱 저장된다. 마찬가지로 메모리에 적재되는 동적 라이브러리 코드 역시 모든 프로세스가 하나의 코드를 공유하기에 디스크 용량을 절약할 수 있다. 동적 라이브러리를 공유 라이브러리라고 말하는 이유이기도 하다.
또한, 동적 라이브러리의 코드가 수정되어도 해당 동적 라이브러리만 다시 컴파일하면 된다. 때문에 실행 파일을 매번 컴파일할 필요 없이 동적 라이브러리만 새 버전으로 교체하면 다음에 실행 파일이 실행될 때 새 버전의 동적 라이브러리가 사용된다. 이로 인해 프로그램의 유지 보수와 업그레이드를 용이하게 할 수 있다.
그리고 ‘플러그인’ 방식을 사용해 새로운 동적 라이브러리를 제공하기만 하면 프로그램은 곧바로 새로운 기능을 가질 수도 있다. 덕분에 프로그램의 기능을 쉽게 확장할 수 있다.
무엇보다 동적 라이브러리의 가장 강력한 장점은 여러 언어들을 섞어 개발할 때 발휘된다. 예를 들어 파이썬의 성능은 C++에 미치지 못한다. 이때 더 높은 성능이 요구되는 부분은 C++로 작성한 후에 컴파일해서 동적 라이브러리를 생성한다. 나머지 부분은 파이썬으로 작성하지만 높은 성능이 요구되는 부분은 동적 라이브러리를 통해 C++ 코드를 직접 호출할 수 있는 것이다.

동적 라이브러리의 단점

동적 라이브러리는 정적 링크를 사용할 때보다 성능이 약간 떨어진다. 프로그램이 적재되는 시간 또는 실행 시간에 링크되기 때문이다.
그리고 동적 라이브러리의 코드는 특정 메모리 주소와 독립적으로 동작하기에 ‘독립 코드’ 라고 불린다. 동적 라이브러리는 메모리에 단 하나의 복사본만 존재하고 이 코드는 여러 프로세스가 공유할 수 있기에 동적 라이브러리의 코드는 임의의 메모리 절대 주소로 참조할 수 없다.
이러한 주소 독립적 설계는 동적 라이브러리의 변수를 참조할 때 더 ‘간접적인 접근’을 해야하지만, 동적 라이브러리를 사용할 때의 이점이 위와 같은 성능 손실을 감안할 정도로 가치가 크다.

재배치: 심벌 실행 시 주소를 결정하기

자바스크립트 등 프로그래밍 언어를 처음 배울 때 우리는 모든 변수나 함수에 ‘메모리 주소’가 있다고 배운다. 어셈블리어로 작성된 아래의 코드를 살펴보자.
call 0x4004d6
Assembly
복사
명령어에 변수의 정보가 없지만 메모리 주소를 사용하고 있다. 이 명령어는 실행을 시작하려면 메모리 주소 0x4004d6로 이동하라는 의미로, 함수의 명령어가 위치하고 있는 주소다.
링커가 실행 파일을 생성할 때 프로그램이 실행되는 시점에 함수가 적재될 메모리 주소를 확정해야 한다. 하지만 이때, 함수가 호출하는 call 명령어 뒤에 들어갈 메모리 주소 0x4004d6 를 링커가 어떻게 알 수 있을까?
사실 이 질문은 대상 파일을 생성할 때부터 시작된다.
컴파일러는 컴파일 과정을 통해 대상 파일을 생성한다. 이때 함수가 어느 메모리 주소에 적재될지 즉, call 명령어 뒤에 어떤 메모리 주소를 넣을지 아직 알 수 없다. 때문에 이 시점에는 일단 아래와 같이 임의로 기록해둔다.
“링커야, 나 컴파일러인데~ 실행 파일 만들 때 이 명령어 수정해주라~”
call 0x00
Assembly
복사
귀여운 그림으로 표현하자면.. 이렇다!
출처: 농담곰 (그림은 직접 만듦)
자, 이제부터는 링커의 몫이다.
컴파일러가 임시로 지정해 둔 메모리 주소를 수정해야 한다.
링커는 앞서 설명한 심벌 해석 단계를 거쳐 링크 과정에 오류가 없다고 확신한 후, 대상 파일에서 각 유형의 영역이 모두 결합되면 함수의 메모리 주소인 0x4004d6 를 알 수 있다. 그런 다음, 실행 파일에서 해당 명령어를 정확히 찾아서 이동할 주소를 0x00 에서 0x4004d6 로 수정할 수 있다.
출처: SP - 7.2 Fundamentals of Linking (2)
이와 같이 심벌의 메모리 주소를 수정하는 과정을 ‘재배치(relocation)’라고 한다.
내용이 조금 많은데, 이쯤에서 내용을 한번 정리하고 넘어가보자.
컴파일러의 역할
컴파일러는 소스 코드를 분석하고, 각 함수와 변수의 사용을 추적하여 대상 파일을 생성한다. 이 대상 파일에는 함수가 어느 메모리 주소에 적재될지에 대한 정보는 포함되지 않는다.
링커의 역할
링커는 여러 대상 파일을 결합하여 실행 파일을 만든다. 이 과정에서 링커는 각 함수와 변수가 실제로 메모리의 어디에 위치할지를 결정한다. 링커는 컴파일러가 남긴 표시를 참고하여 메모리 주소를 수정한다.
재배치 과정
컴파일러가 함수 호출을 위한 call 명령어를 생성할 때, 실제 주소를 알 수 없으므로 임시 주소를 남긴다.링커는 실행 파일을 생성하면서 이 임시 주소를 실제 메모리 주소로 바꾼다. 이를 통해 프로그램이 올바르게 실행될 수 있도록 한다.
그런데 여기서, 링커가 변수의 실행 시간 메모리 주소를 미리 알 수 있는 방법은 무엇일까?
바로 가상 메모리를 활용하는 것이다.

가상 메모리와 프로그램 메모리 구조

예를 들어, CPU가 프로그램 A를 시작할 때 메모리 주소 0x4004d6 에서 가져온 명령어는 프로그램 A에 속하고, 프로그램 B를 실행할 때 메모리주소 0x4004d6 에서 가져온 명령어는 프로그램 B에 속한다. 둘 다 같은 메모리 주소에서 가져왔지만 데이터는 서로 다르다.
가상 메모리는 말 그대로 물리적으로 존재하지 않는 가짜 메모리다. 가상 메모리는 각각의 프로그램이 실행 중일 때 자기 자신이 모든 메모리를 독점적으로 사용하고 있는 것처럼 착각하게 만든다.
즉, 프로그램이 실행될 때 각 프로그램은 마치 전체 컴퓨터 메모리를 자신만 사용하는 것처럼 인식한다. 실제로는 여러 프로그램이 동시에 메모리를 공유하고 있지만, 가상 메모리 시스템 덕분에 각 프로그램은 독립적으로 실행되는 것처럼 느낀다.
출처: SP - 8.1 Virtual Memory Concepts
따라서 가상 메모리는 실제 물리 메모리의 구조가 아닌 논리적으로만 존재하는 허상이다.
이는 모든 프로그램이 동일한 표준적인 메모리 구조를 가질 수 있게 해준다. 다시 말해, 모든 프로그램이 같은 메모리 주소를 사용하더라도 충돌이 발생하지 않고, 프로그램 간의 메모리 접근이 안전하게 분리된다.
이 시스템 덕분에 개발자는 프로그램이 실제 물리 메모리의 위치나 크기에 대해 걱정할 필요 없이 표준화된 방식으로 메모리에 접근할 수 있다. 이는 프로그램의 이동성, 안정성, 그리고 다중 작업 환경에서의 효율성을 높여준다.

컴퓨터 과학에서 추상화가 중요한 이유

만약 우리가 이름을 붙이지 않는 사회에 살고 있다고 가정해보자. 그럼 “엘라는 개발자입니다” 라는 표현을 하지 못하고 “한국에 사는 여성이 있다. 그 여성은 후드티를 입고 안경을 쓰고 있다. 프론트엔드 개발을 하며 주로 자바스크립트 타입스크립트 리액트로 개발한다. (어쩌구저쩌구..)”
이렇듯 이름이 존재하지 않는다면 모든 세부사항을 구체적으로 설명해야하기 때문에 엘라가 개발자라는 사실을 간단하게 설명해내지 못할 것이다. 반면, 이러한 세부사항들을 모두 묶어 “엘라” 라는 하나의 이름으로 추상화를 하면 세부 사항을 모두 나열하지 않고도 매우 간단하게 설명할 수 있다.
이것이 추상화의 힘이다.

프로그래밍과 추상화

프로그래밍도 마찬가지다. 프로그래머는 추상화를 통해 복잡도를 제어할 수 있다. 복잡한 내부 구현사항을 고민할 필요 없이 추상화된 API에만 집중할 수 있다.
출처: https://architectelevator.com
예를 들어, 모든 프로그래밍 언어는 추상화를 위해 각자만의 매커니즘을 제공한다. 예를 들어 우리는 OOP(객체 지향 언어)의 장점은 다형성과 클래스 등을 이용해 개발자가 간편하게 추상화를 할 수 있다고 어디선가 들어 보았다.
이러한 매커니즘들 덕분에 우리는 구체적인 내부 구현보다, 추상화된 부분만 고려해 프로그램을 확장하고, 유저의 요구사항에 따라 손쉽게 유지보수를 할 수 있는 것이다.

시스템 설계와 추상화

CPU도 마찬가지다. CPU의 하드웨어는 여러 개의 트랜지스터로 구성되어 있지만, 명령어 집합이라는 개념으로 내부 구현 세부 사항을 보호한다. 개발자는 이러한 세부사항을 고려할 필요 없이 명령어 집합에 포함된 기계어로 CPU에 작업을 지시하기만 하면 된다.

마무리

추상화 덕분에 개발자들은 저수준 계층의 세부사항들을 신경쓰지 않아도 된다. 더불어 점점 기계어에서 멀어지는 고수준의 언어가 발달되면서 프로그래밍 언어의 러닝커브도 낮아지고 있다.
하지만 우리가 저수준 계층을 아예 몰라도 될까?
개인적으로는 아니라고 생각한다.
물론 중요도나 긴급도는 낮을 수 있다. 하지만 추상화는 낙원일 뿐, 자신만의 또다른 낙원을 만들고 싶다면 이 낙원이 어떤 방식으로 운영되고 만들어졌는지 이해할 필요가 있을 것이다.

참고 자료