CS

[컴퓨터 밑바닥의 비밀] 소스 코드의 역사

ri5 2025. 5. 11. 23:15

1. 매우 빠른 스위치 CPU

드라마 삼체의 한 장면

위의 사진은 삼체에 한장면 중 하나입니다. 장면에 대해 간단하게 설명드리자면 3개의 태양으로 인해 발생하는 운동 원칙을 계산하기 위해 사람들을 일렬로 쭉 세워놓고 수많은 사람들이 깃발을 오르락 내리락하면서 마치 하나의 회로판처럼 움직이는데 이건 마치 CPU가 동작하는 모습을 떠올리게 합니다.

 

 

삼체에 한 장면처럼 CPU는 매우 많은 스위치를 가지고 있고 전기 신호를 통해 동작하기 때문에 각각의 스위치는 사람이 깃발을 올리고 내리는 속도보다 훨씬 더 빠르게 동작합니다. 그 덕에 사람들은 주판, 계산기보다 훨씬 빠른 연산장치를 가질 수 있게 되었습니다. 하지만 초기의 컴퓨터는 CPU가 이해할 수 있는 언어가 0, 1 뿐이라 천공 카드(paunched card)에 하나하나 구멍을 뚫어 명령어를 만들었고 실행하고자 하는 연산을 천공카드에 일일이 구멍을 뚫어서 만들어야 했기 때문에 비효율적이고 휴먼에러도 많이 발생했습니다.

천공 카드

 

2. 컴퓨터가 이해할 수 있는 사람의 언어, 어셈블리어

 

위의 과정과 같이 천공 카드에 구멍을 뚫어서 소스 코드를 짜는 것은 아무리 빠르게 짜더라도 사람의 손으로 일일이 종이에 구멍을 뚫어야 했기에 한계가 존재했고 이런 한계를 뛰어넘기 위해 CPU를 효율적으로 명령을 내리는 방법에 대해 연구를 하였고 어셈블리어가 탄생하게 되었습니다. 

 

어셈블리어는 이전에 CPU에게 0과 1을 사용한 형태로 말하지 않고 두개의 숫자를 더하는 가산 명령어와 프로그램의 실행 순서를 바꾸는 점프 명령어 등을 활용하여 명령을 내릴 수 있게 되었습니다. 이를통해 사람들은 add, mov, sub 등과 같은 명령어를 내리면 프로그램이 cpu가 이해할 수 있는 바이너리로 변환해주었고 사람의 언어로 동작할 수 있는 최초의 프로그래밍 언어가 만들어지게 되었습니다.

 

 

3. 저수준 언어에서 고수준 언어까지

 

컴퓨터에게 명령을 내릴 때 저수준 언어(어셈블리어와 같이 기계에 가까운 언어)를 사용하다보니 기계의 동작 하나 하나를 제어해야 했습니다. 예를들어 "card"라는 문자열에 첫번째 문자 "a"를 가져오는 것을 어셈블리어로 구현한다면 아래와 같이 복잡한 과정을 통해 "a"를 가져올 수 있습니다.

section .data
    str:    db  'card',0      ; 메모리에 “c a r d 0” 바이트로 저장

section .text
    global _start
_start:
    lea     rsi, [rel str]    ; 1) 문자열 주소 계산
    mov     al, [rsi]         ; 2) 메모리에서 첫 바이트 읽기 → AL
    ; (이제 AL = 'c' (0x63))
  1. "card"라는 문자열을 메모리에 저장
  2. LEA라는 명령어를 통해 "card"라는 문자열이 있는 메모리 주소를 기억
  3. MOV AL이라는 명령어를 통해 메모리에 있는 문자열에 첫 바이트를 읽어 AL 레지스터에 옮기기.

이처럼 사람이 이해하기 어려운 형태로 프로그래밍을 하다보니 복잡한 요구사항이나 큰 규모의 프로그램을 만들거나 유지보수하기 쉽지 않았고 좀 더 추상화하여 명령을 내릴 방법에 대해 고민하기 시작했습니다. 그 뒤에 발전되어 나온 것이 문(statement)입니다.

 

문(statement)은 컴퓨터에게 명령을 내리는 최소 단위의 개념으로 변수 선언, 조건 분기, 반복, 값 할당 등의 처리 과정을 독립적으로 분리하고 프로그램의 흐름을 체계적으로 관리하기 위해 나온 개념입니다. 프로그래밍 언어는 다양한 제어 흐름을 위한 조건문·반복문 같은 구문을 제공합니다. 아래의 코드를 보면 어떻게 프로그래밍이 쉬워졌는지 한눈에 알 수 있습니다.

public class FirstCharExample {
    public static void main(String[] args) {
        String str = "card";          // ① 문자열 선언
        char first = str.charAt(0);   // ② 첫 글자 읽기
        System.out.println(first);    // ③ 읽은 글자 사용
    }
}

 

그리고 추가적으로 나오게 된 개념은 parameter인데 예시에서 우리가 "card"라는 단어가 아니라 "book"이라는 단어로 바꾸려면 코드를 수정한다면 str이라는 변수에 "book"으로 바꾸게 될 것입니다. 만약 1000만개의 서로 다른 단어가 오게되면 어떻게 될까요? 그럼 코드를 1000만번 수정하는 번거로운 작업을 해야될 것입니다. 이런 번거로운 작업을 줄이기 위해 매개변수(parameter)라는 개념이 나오게 되었고 parameter를 정의하여 argument를 통해 값을 전달할 수 있게 되었습니다.

public class FirstCharExample {
    // 파라미터 parameter: input
    public static void printFirstChar(String input) {
        char first = input.charAt(0);
        System.out.println(first);
    }

    public static void main(String[] args) {
        // 아규먼트 argument: "card" 와 "book"
        printFirstChar("card");  // c
        printFirstChar("book");  // b
    }
}

 

  • input은 파라미터
  • "card", "book"은 아규먼트

 

3.  Syntax란?

문자열을 받아서 첫번째 문자를 출력하라는 문제는 간단하게 해결할 수 있겠지만 만약 네이버 검색 엔진과 같은 기능을 개발하라고 한다면 어떻게 될까? 공휴일이나 날짜를 입력하면 캘린더를 보여주고 네이버 쇼핑이나 네이버 예약 등을 입력하면 해당 페이지로 이동하는 링크를 보여주고 오타가 난 글자가 입력된다면 유사한 단어를 찾아 검색해줄 것 입니다.

 

 

이처럼 무한에 가까운 확장을 해야하는 프로그래밍 언어는 수열이나 재귀와 같은 복잡한 과정들도 쉽게 간결하게 표현할 수 있어야 하는데 그런 구조와 형식을 정의해놓은 것을 문법(Syntax)이라고 합니다. 위의 그림처럼 나뭇가지 형태로 생긴 트리 형태의 재귀를 표현한다면 아래와 같이 코드로 작성하여 만들 수 있을 것입니다.

public class RecursionExample {
    // 1) 재귀 함수 정의
    public static long factorial(int n) {
        // 기저 조건: 0! = 1
        if (n == 0) {
            return 1;
        }
        // 재귀 호출: n! = n * (n-1)!
        return n * factorial(n - 1);
    }

    public static void main(String[] args) {
        int num = 5;
        System.out.println(num + "! = " + factorial(num));
    }
}

 

4.  고수준 언어를 저수준 언어로 해석하는 컴파일러

문법(Syntax)이 실행될 때 소스코드를 트리구조로 변환합니다. 동작제어, 연산,  변수 등과 구성요소들이 노드로 변환되며 이를 문법 트리(Syntax Tree) 또는 구문 트리라고 합니다. 컴파일러는 이처럼 트리화된 소스코드를 리프 노드부터 차례차례 기계어로 변환시키면서 우리가 흔히 아는 컴파일이 되게 됩니다. 이처럼 고수준의 언어를 기계어로 변환하는 컴파일러가 나오게 되면서 소프트웨어 산업은 급성장하게 되고 프로그래머의 효율성 또한 급증하기 시작했습니다.

// 연산 명령어
a = b + c * d

// 문법 트리
   =
  / \
 a   +
    / \
   b   *
      / \
     c   d

 

 

5. 인터프리터 언어의 시작

모든 CPU가 국제 규격에 맞춰 상호 작용하면 좋겠지만 프로세서 아키텍처(설계 방식)에 따라 다른 명령어 집합을 가지고 있습니다. 대표적으로 x86과 arm을 이야기할 수 있으며 서로 다른 명령어 집합(ISA)을 가졌습니다. 그래서 같은 명령어로는 실행할 수 없습니다. 저희는 이런 문제를 어떻게 해결했을까요? 바로 인터프리터 언어만의 표준 명령어를 만들고 각 CPU의 명령어를 표준 명령어로 해석해주는 프로그램이 있다면 해결될 것입니다. 위의 그림처럼요.

 

지금까지의 동작 과정을 요약하자면 모든 언어는 프로그래밍 언어 문법에 맞춰 작성되며 언어 문법에 따라 구문(문법) 트리를 만듭니다. 만들어진 문법트리를 기계언어로 변환하여 CPU에 직접 전달하거나 자바처럼 바이트 코드로 변환하여 가상 머신으로 CPU를 실행합니다. 고수준 언어는 추상화의 수준이 높아 쉽게 사용할 수 있지만 저수준 레벨의 제어는 어렵습니다. 그래서 때때로 저수준의 제어가 필요할 때 어셈블리어를 활용할 때가 있습니다.

 

5. 컴파일러의 동작 과정

컴파일러는 쉽게 말하면 우리가 작성한 소스 코드를 컴퓨터가 이해할 수 있는 언어로 바꿔주는 프로그램입니다. 아까 설명했던 구문 트리를 만들기 위해 각 소스코드를 분류하여 추출합니다. 아래의 예시를 살펴봅시다.

public class Hello {
    public static void main(String[] args) {
        int x = 10;
        System.out.println(x);
    }
}

 

위의 코드는 x라는 변수에 10을 할당하여 x 변수를 출력하는 코드입니다. 컴파일러는 위의 코드를 잘게 쪼개어 아래와 같은 표로 각 코드를KEYWORD, IDENTIFIER, SYMBOL 등과 종류로 분류합니다. 이렇게 분리된 코드를 토큰이라고 합니다. lexeme라고 하는 필드는 각 토큰이 가진 값을 의미합니다. 이렇게 소스코드를 토큰으로 분류하는 과정을 어휘 분석(lexical analysis)이라고 합니다.

순번 토큰 종류 lexeme(어휘)
1 KEYWORD public
2 KEYWORD class
3 IDENTIFIER Hello
4 SYMBOL {
5 KEYWORD public
6 KEYWORD static
7 KEYWORD void
8 IDENTIFIER main
9 SYMBOL (
10 IDENTIFIER String
11 SYMBOL [
12 SYMBOL ]
13 IDENTIFIER args
14 SYMBOL )
15 SYMBOL {
16 KEYWORD int
17 IDENTIFIER x
18 OPERATOR =
19 NUMBER_LITERAL 10
20 SYMBOL ;
21 IDENTIFIER System
22 SYMBOL .
23 IDENTIFIER out
24 SYMBOL .
25 IDENTIFIER println
26 SYMBOL (
27 IDENTIFIER x
28 SYMBOL )
29 SYMBOL ;
30 SYMBOL }
31 SYMBOL }

 

우리가 소스코드의 흐름을 보다보면 if문 안에 if문이 있고 그 안에 for문이 있는 것을 볼 수 있을 것입니다. 내부에 있는 코드의 동작이 끝날 때까지는 외부 코드는 호출되면 안되죠. 그래서 이렇게 만들어진 토큰을 차례대로 실행될 수 있도록 아까 전에 설명했었던 구문트리를 만듭니다. 이 구문트리는 아래의 리프노드부터 실행하도록 구조가 되어있기 때문에 아래의 소스 코드가 실행이 끝나기 전까지는 부모노드에 있는 코드는 실행되지 않습니다. 이러한 전체의 과정을 구문 분석이라고 합니다. 이 과정에서 잘못된 구문이 있다면 Syntax Error가 발생합니다.

 

5. 구문 분석 그 뒤에 과정

구문 트리가 생성된 뒤에 각 토큰이 이상이 없는지 검사를 해야합니다. 그래야 컴파일 단계에서 잘못된 코드를 잡아낼 수 있으니깐요. 구문 트리가 만들어 진 뒤에는 의미 분석(sementic analysis)를 통해 변수 선언, 타입 정의 등을 잘 준수하는지 체크하고 만약 문제가 있다면 컴파일 에러를 발생시킵니다.

 

의미 분석이 끝난 뒤 구문트리를 탐색한 결과를 기반으로 중간 코드를 생성합니다. 중간 코드를 생성하는 이유는 프로그래밍 언어를 바로 기계어로 해석하는 것보다 중간의 로우 레벨의 언어를 해석하는 것이 리소스 사용 측면에서 효율적이고 중간에서 중복코드를 제거하거나 성능을 개선한 형태로 변환할 수 있기 때문입니다. 그리고 중간 코드를 활용하는 가장 큰 이유 중 하나는 특정 CPU나 운영체제에 종속되지 않은 추상화된 코드로 활용하기 위함도 있습니다.

 

그 다음 과정은 중간 코드를 어셈블리어 코드로 변환하는 과정을 거칩니다. 마지막으로는 어셈블리어로 작성된 코드를 기계 명령어로 변환하죠. 이런 과정을 거쳐 컴파일러는 소스 코드라는 문자를 컴퓨터의 명령어로 전환을 합니다. 기술이 점점 발전하면서 컴파일러는 더 복잡하고 많은 역활을 해주고 있지만 크게보면 이런 흐름으로 동작한다고 보면 됩니다. 

 

그리고 컴파일러는 소스코드를 해석한 명령어 정보들을 파일로 저장하는데 이 파일을 대상 파일(object file)이라고 합니다. 모든 소스 파일은 각각의 대상 파일을 가지고 있습니다. 하지만 일반적인 소스 코드는 여러 개의 소스 코드와 상호 작용하는데 각각의 대상 파일을 하나하나 어떻게 실행하는 것일까요? 바로 대상 파일을 하나의 실행 파일로 합쳐주는 역활(링크)를 하는 링커가 있기 때문에 가능합니다. 링커는 다음 글에 좀 더 자세히 설명해드리도록 하겠습니다.