이번 주에는 프로그래밍 언어를 다양한 관점에서 분류해보고, 우리가 작성한 코드를 컴퓨터가 이해할 수 있는 언어로 바꿔주는 '컴파일러'가 어떤 구조로 이루어져 있는지 깊이 있게 다뤄봤다.
##Part 1: Generation of Language
프로그래밍 언어는 그 특징과 등장 시기에 따라 여러 세대로 나눌 수 있다.
1세대: 기계어 (Machine Language)
- 컴퓨터가 직접 이해할 수 있는 0과 1로 이루어진 언어이다. 아주 오래전에는 16진수 키보드로 직접 기계어를 입력해서 코딩하기도 했다.
2세대: 어셈블리어 (Assembly Language)
- 기계어와 1:1로 대응되는 명령어로 이루어져 있어, 기계어보다는 조금 더 사람이 이해하기 쉬운 형태이다. '어셈블러'라는 번역기를 통해 기계어로 번역된다.
3세대: 고급언어 (High-level Language)
- 우리가 흔히 아는 Fotran, C, C++, Java 등이 여기에 속한다. 사람이 사용하는 언어와 비슷하여 훨씬 생산성이 높고 '컴파일러'를 통해 기계어로 번역된다. 우리가 사용하는 대부분의 절차지향적 언어들이 3세대 언어이다.
4세대: 특정 목적용 언어
- 데이터베이스 조회를 위한 SQL처럼 특정 애플리케이션을 위해 설계된 언어이다.
5세대: 인공지능 및 논리언어
- Prolog와 같이 논리 기반의 프로그래밍에 사용되는 언어들이다.
여기서 흥미로운 점은 C컴파일러는 어셈블리어로 만들어졌고, Java와 같은 최신 언어의 컴파일러는 C/C++로 만들어졌다는 사실이다. 이처럼 언어는 서로를 기반으로 계속해서 발전하고 진화하는 특징을 가진다.
명령형(Imperative) vs. 선언형(Declarative): 코딩 스타일의 두 가지 큰 흐름
프로그래밍 언어는 코드를 작성하는 스타일에 따라 크게 두 가지로 나눌 수 있다. 이건 언어 자체의 분류이기도 하지만 프로그래밍을 하는 접근 방식의 차이이기도 하다.
1. 명령형(Imperative) 프로그래밍
- "어떻게(How)" 문제를 해결하는지에 초점을 맞춘다.
- 컴퓨터에게 수행할 작업을 단계별로 명확하게 지시하는 방식이다.
- C, C++, Java와 같은 대부분의 언어가 이 방식에 속한다.
예를 들자면 요리사에게 1. 물이 끓이세요 2. 면을 넣으세요 3. 3분 뒤에 건지세요 와 같이 정확한 레시피를 알려주는 것과 같다.
예시2) Java로 구현한 선택 정렬
아래 코드는 배열을 정렬하기 위해 반복문(for)을 돌며 최솟값을 찾고, 변수(temp)를 이용해 위치를 바꾸는 모든 과정을 하나하나 명시하고 있다. 이것이 바로 명령형 스타일의 전형적인 모습이다.
public static void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
int minIdx = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
if (minIdx != i) {
int temp = arr[i];
arr[i] = arr[minIdx];
arr[minIdx] = temp;
}
}
}
2. 선언형(Declarative) 프로그래밍
- 무엇을(What) 원하는지에 집중한다.
- '어떻게' 처리할지는 컴퓨터에 맡기고, 원하는 결과가 무엇인지만을 선언하는 방식이다.
- SQL, HTML이나 함수형 언어인 ML, Haskell 등이 여기에 속한다.
예를 들어, 레스토랑에서 스테이크 주세요 라고 원하는 요리를 주문하는 것과 가탇. 스테이크를 어떻게 굽는지는 셰프(컴퓨터)가 알아서 한다.
예시) Python의 (List Comprehension)
숫자 리스트의 각 원소를 제곱하는 리스트를 만들 때, 두 가지 스타일로 코드를 짤 수 있다.
명령형 스타일: 빈 리스트를 만들고, 반복문을 돌면서 각 숫자를 제곱해 리스트에 추가한다
numbers = [1, 2, 3, 4, 5]
squares = []
for n in numbers:
squares.append(n * n)
선언형 스타일: "numbers의 각 n에 대해 n의 제곱으로 이루어진 리스트를 원한다"고 선언한다.
squares = [n * n for n in numbers]
선언형 스타일이 훨씬 더 간결하고 사람의 생각과 가깝다.
## Part 2: 컴파일러의 내부 구조: 소스 코드가 기계어가 되기까지
이제 우리가 작성한 코드를 컴퓨터가 이해하도록 번역해주는 컴파일러의 내부를 들여다볼 것이다.
컴파일러는 크게 소스 코드를 분석하는 '프론트엔드(Front End)*와 기계어 코드를 생성하는 '백엔드(Back End)'로 나뉜다.
스캐너 (Scanner) / 어휘 분석기 (Lexical Analyzer)
- 역할: 소스 코드를 문자 단위로 읽어서 의미 있는 덩어리인 '토큰(Token)'으로 분해한다. 주석이나 공백 같은 불필요한 부분은 이 단계에서 제거된다.
- 예시: if (x >= y) y = 42; 라는 코드는 다음과 같은 토큰 스트림으로 변환된다IF, LPAREN, ID(x), GEQ, ID(y), RPAREN, ID(y), BECOMES, INT(42), SCOLON
파서 (Parser) / 구문 분석기 (Syntax Analyzer)
- 역할: 스캐너로부터 전달받은 토큰 스트림이 언어의 문법(Grammar)에 맞는지 검사하고, 코드의 구조를 나타내는 '파스 트리(Parse Tree)' 또는 '추상 구문 트리(AST, Abstract Syntax Tree)'를 생성한다.
- 예시: 위에서 만들어진 토큰 스트림은 아래와 같은 트리 구조로 재구성된다. 이 트리는 '만약 x가 y보다 크거나 같다면, y에 42를 할당하라'는 코드의 문법적 구조를 명확하게 보여준다.
- 의미 분석기 (Semantic Analyzer)
- 역할: 파스 트리를 바탕으로 코드의 의미를 분석한다. 예를 들어, 정수형 변수에 문자열을 할당하려고 하거나, 선언되지 않은 변수를 사용하는 등의 의미상 오류(type error 등)를 여기서 잡아낸다.
- 중간 코드 생성기 (Intermediate Code Generator)
- 역할: 분석이 끝난 코드를 특정 기계에 종속되지 않는 중간 표현(IR, Intermediate Representation)으로 변환한다. 이는 코드 최적화를 용이하게 한다.
- 최적화기 (Optimizer) & 코드 생성기 (Code Generator)
- 역할: 중간 코드를 더 빠르고 효율적으로 실행될 수 있도록 최적화한 후 , 최종적으로 목표 기계(예: x86, ARM)에 맞는 기계어(Object Code)를 생성합니다.
만약 어휘 분석기와 구문 분석기가 잘못된다면?
int a = 3 + 5 * 2; 라는 코드를 예시로 두 분석기가 하는 일과, 이들이 잘못 구현되었을 때 발생할 수 있는 문제를 알아봅시다.
- 어휘 분석기 (Scanner)의 역할과 문제점
- 정상 작동:
코드를 INT, ID(a), BECOMES, INT(3), PLUS, INT(5), MULTIPLY, INT(2), SCOLON 과 같은 토큰들로 정확히 분리
- 구현 오류 시:
- while 같은 키워드를 일반 변수명으로 잘못 인식할 수 있다.
- 123abc처럼 숫자로 시작하는 변수명을 허용되지 않는 문법임에도 불구하고 하나의 토큰으로 잘못 인식할 수 있다.
- 구문 분석기 (Parser)의 역할과 문제점
- 정상 작동: 토큰들을 이용해 파스 트리를 만들 때, 연산자 우선순위(Operator Precedence)를 고려한다. 즉, 덧셈(+)보다 곱셈(*)을 먼저 계산해야 한다는 문법 규칙에 따라 5 * 2를 먼저 하나의 단위로 묶는 트리를 생성합니다.
- 구현 오류 시:
- 연산자 우선순위를 무시하고 코드에 나타난 순서대로 트리를 만들 수 있다.
- 이 경우, 3 + 5를 먼저 계산하게 되어 완전히 다른 결과를 낳는다.
- 잘못된 계산: (3 + 5) * 2 = 16
이처럼 컴파일러의 각 단계는 명확한 역할을 가지고 있으며, 어느 하나라도 잘못되면 우리가 의도한 대로 프로그램이 동작하지 않거나 아예 만들어지지 않을 수 있다.