영역의 지도
아무리 거칠더라도 지도가 있어야 한다. 그렇지 않으면 사방을 헤매게 된다. 《반지의 제왕》에서 나는 누구도 특정 날에 갈 수 있는 거리보다 더 멀리 가게 하지 않았다.
J. R. R. 톨킨
우리는 사방을 헤매고 싶지 않으므로, 출발하기 전에 이전 언어 구현자들이 개척해 놓은 영역을 살펴보겠습니다. 이는 우리가 어디로 가고 있으며, 다른 사람들이 어떤 대체 경로를 택했는지 이해하는 데 도움이 될 것입니다.
먼저, 몇 가지 약어를 정하겠습니다. 이 책의 대부분은 언어의 구현(implementation)에 관한 것인데, 이는 어떤 플라톤적 이상 형태로서의 언어 자체(language itself)와는 다릅니다. "스택", "바이트코드", "재귀 하향 파싱"과 같은 것들은 특정 구현이 사용할 수 있는 핵심 부품들입니다. 사용자 입장에서는, 최종 결과물이 언어의 사양을 충실히 따른다면 이 모든 것은 구현 세부 사항일 뿐입니다.
우리는 이러한 세부 사항에 많은 시간을 할애할 것이므로, 매번 "언어 구현"이라고 쓰는 것은 손가락이 아플 것입니다. 따라서, 구분이 중요하지 않은 한 "언어"라는 용어를 언어나 그 구현, 또는 둘 다를 지칭하는 데 사용하겠습니다.
2 . 1언어의 구성 요소
엔지니어들은 컴퓨팅의 암흑 시대부터 프로그래밍 언어를 만들어 왔습니다. 컴퓨터와 대화할 수 있게 되자마자, 우리는 그것이 너무 어렵다는 것을 알게 되었고, 컴퓨터의 도움을 받게 되었습니다. 오늘날의 기계가 말 그대로 백만 배 더 빠르고 훨씬 더 많은 저장 공간을 가지고 있음에도 불구하고, 우리가 프로그래밍 언어를 만드는 방식은 거의 변하지 않았다는 점이 흥미롭습니다.
언어 설계자들이 탐험한 영역은 광대하지만, 그들이 개척한 길은 몇 안 됩니다. 모든 언어가 정확히 같은 경로를 따르는 것은 아니지만, 일부는 한두 가지 지름길을 택하기도 합니다. 그러나 전반적으로는 놀랍도록 유사합니다. 그레이스 호퍼(Grace Hopper) 제독의 첫 COBOL 컴파일러부터, Git 저장소 어딘가에 있는 형편없이 편집된 README 파일 하나로 "문서"가 전부인, 새로 등장한 트랜스파일-투-자바스크립트(transpile-to-JavaScript) 언어에 이르기까지 말이죠.
저는 구현이 선택할 수 있는 경로 네트워크를 산을 오르는 것으로 시각화합니다. 가장 아래에서 프로그램은 순수한 원본 텍스트, 즉 문자열로 시작됩니다. 각 단계는 프로그램을 분석하고, 저자가 컴퓨터에 무엇을 시키고 싶은지—즉 의미(semantics)—가 더 명확해지는 더 높은 수준의 표현으로 변환합니다.
결국 우리는 정상에 도달합니다. 사용자의 프로그램을 조감하고 그들의 코드가 무엇을 의미하는지 알 수 있게 됩니다. 이제 산의 반대편으로 하산하기 시작합니다. 우리는 이 최고 수준의 표현을 점차 낮은 수준의 형태로 변환하여 CPU가 실제로 실행할 수 있는 것에 점점 더 가까워지게 합니다.
이러한 경로와 흥미로운 지점들을 하나씩 따라가 보겠습니다. 우리의 여정은 사용자의 원본 코드의 순수한 텍스트로 왼쪽에서 시작됩니다.
2 . 1 . 1스캐닝(Scanning)
첫 번째 단계는 스캐닝(scanning)이며, 렉싱(lexing) 또는 (누군가에게 깊은 인상을 주려 한다면) 어휘 분석(lexical analysis)이라고도 합니다. 이들은 모두 거의 같은 의미입니다. 저는 "렉싱"이라는 단어가 사악한 슈퍼 빌런이 할 법한 일처럼 들려서 좋아하지만, "스캐닝"이 조금 더 일반적으로 사용되는 것 같아서 이 단어를 사용하겠습니다.
스캐너(scanner) (또는 렉서(lexer))는 선형적인 문자 스트림을 받아들여 "단어"와 유사한 일련의 덩어리로 묶습니다.
프로그래밍 언어에서 이러한 각 단어를 토큰(token)이라고 합니다.
어떤 토큰은 (와 ,처럼 단일 문자입니다.
다른 토큰들은 숫자(123), 문자열 리터럴("hi!"), 식별자(min)처럼 여러 문자로 이루어질 수 있습니다.
소스 파일의 일부 문자는 실제로는 아무 의미가 없습니다. 공백은 종종 중요하지 않으며, 주석은 정의상 언어에 의해 무시됩니다. 스캐너는 일반적으로 이러한 것들을 버리고, 의미 있는 토큰의 깨끗한 시퀀스를 남깁니다.
2 . 1 . 2파싱(Parsing)
다음 단계는 파싱(parsing)입니다. 이 단계에서 우리의 구문은 문법(grammar)을 얻게 됩니다. 즉, 더 작은 부분들로 더 큰 표현식과 문장을 구성할 수 있는 능력을 얻는 것입니다. 영어 시간에 문장 구조를 도식화해 본 적이 있나요? 그렇다면 파서가 하는 일을 해본 것입니다. 단, 영어는 수천 개의 "키워드"와 넘쳐나는 모호성으로 가득 차 있는 반면, 프로그래밍 언어는 훨씬 더 간단합니다.
파서(parser)는 평평한 토큰 시퀀스를 받아들여 문법의 중첩된 특성을 반영하는 트리 구조를 만듭니다. 이러한 트리들은 소스 언어의 순수한 구문 구조에 얼마나 가까운지에 따라 파스 트리(parse tree) 또는 추상 구문 트리(abstract syntax tree) 등 여러 이름으로 불립니다. 실제로 언어 개발자들은 일반적으로 이를 구문 트리(syntax trees), AST(Abstract Syntax Trees) 또는 단순히 트리(trees)라고 부릅니다.
파싱은 컴퓨터 과학 분야에서 인공지능 커뮤니티와 밀접하게 연관된 길고 풍부한 역사를 가지고 있습니다. 오늘날 프로그래밍 언어를 파싱하는 데 사용되는 많은 기술들은 원래 AI 연구자들이 컴퓨터가 우리와 대화할 수 있도록 인간 언어를 파싱하기 위해 고안되었습니다.
인간 언어는 파서가 처리할 수 있는 엄격한 문법에는 너무 복잡했지만, 프로그래밍 언어의 더 단순한 인공 문법에는 완벽하게 들어맞았습니다. 아쉽게도, 우리 불완전한 인간은 여전히 그 단순한 문법을 잘못 사용하기 때문에, 파서의 역할에는 구문 오류(syntax errors)를 보고하여 우리가 언제 잘못하는지 알려주는 것도 포함됩니다.
2 . 1 . 3정적 분석(Static analysis)
첫 두 단계는 모든 구현에서 상당히 유사합니다. 이제 각 언어의 개별적인 특성이 발휘되기 시작합니다. 이 시점에서 우리는 코드가 어떤 표현식이 다른 표현식 안에 중첩되어 있는지와 같은 구문 구조는 알지만, 그 이상은 잘 모릅니다.
a + b와 같은 표현식에서 우리는 a와 b를 더하고 있다는 것을 알지만, 그 이름들이 무엇을 참조하는지는 모릅니다. 그것들이 지역 변수인가요? 전역 변수인가요? 어디서 정의되었나요?
대부분의 언어가 수행하는 첫 번째 분석 작업은 바인딩(binding) 또는 해결(resolution)이라고 합니다. 각 식별자(identifier)에 대해 해당 이름이 어디에서 정의되었는지 찾아내고 둘을 연결합니다. 여기서 스코프(scope)가 중요한 역할을 합니다. 스코프는 특정 이름이 특정 선언을 참조하는 데 사용될 수 있는 소스 코드의 영역을 말합니다.
만약 언어가 정적 타입(statically typed)이라면, 이때 타입 검사를 수행합니다.
a와 b가 어디에 선언되었는지 알게 되면, 그들의 타입도 알아낼 수 있습니다.
만약 그 타입들이 서로 더하는 것을 지원하지 않는다면, 타입 오류(type error)를 보고합니다.
숨을 깊이 들이쉬세요. 우리는 산 정상에 도달했고, 사용자의 프로그램에 대한 광활한 시야를 확보했습니다. 분석을 통해 우리가 볼 수 있는 이 모든 의미론적 통찰력은 어딘가에 저장되어야 합니다. 이를 보관할 수 있는 몇 가지 장소가 있습니다:
-
종종 구문 트리 자체의 속성(attributes)으로 직접 저장되기도 합니다. 파싱 중에는 초기화되지 않지만 나중에 채워지는 노드의 추가 필드들입니다.
-
다른 경우에는 데이터를 옆에 있는 조회 테이블에 저장할 수 있습니다. 일반적으로 이 테이블의 키는 식별자, 즉 변수와 선언의 이름입니다. 이 경우 이를 심볼 테이블(symbol table)이라고 부르며, 각 키에 연결된 값은 해당 식별자가 무엇을 참조하는지 알려줍니다.
-
가장 강력한 기록 도구는 트리를 코드의 의미를 더 직접적으로 표현하는 완전히 새로운 데이터 구조로 변환하는 것입니다. 다음 섹션에서 다룰 내용입니다.
이 시점까지의 모든 과정은 구현의 프런트 엔드(front end)로 간주됩니다. 이후의 모든 것을 백 엔드(back end)라고 짐작할 수도 있지만, 그렇지 않습니다. "프런트 엔드"와 "백 엔드"라는 용어가 처음 만들어진 옛날에는 컴파일러가 훨씬 더 간단했습니다. 나중에 연구자들은 두 절반 사이에 새로운 단계를 끼워 넣는 방법을 고안했습니다. 오래된 용어를 버리기보다는, William Wulf와 동료들은 이 새로운 단계들을 매력적이지만 공간적으로 역설적인 이름인 미들 엔드(middle end)로 묶었습니다.
2 . 1 . 4중간 표현(Intermediate representations)
컴파일러는 각 단계의 역할이 사용자의 코드를 표현하는 데이터를 다음 단계 구현을 더 간단하게 만드는 방식으로 정리하는 파이프라인이라고 생각할 수 있습니다. 파이프라인의 프런트 엔드는 프로그램이 작성된 원본 언어에 특화됩니다. 백 엔드는 프로그램이 실행될 최종 아키텍처와 관련이 있습니다.
중간 단계에서 코드는 중간 표현(intermediate representation, IR)으로 저장될 수 있으며, 이는 원본이나 대상 형식(따라서 "중간")에 긴밀하게 묶여 있지 않습니다. 대신 IR은 이 두 언어 사이의 인터페이스 역할을 합니다.
이렇게 하면 적은 노력으로 여러 소스 언어와 대상 플랫폼을 지원할 수 있습니다. 예를 들어, Pascal, C, Fortran 컴파일러를 구현하고 싶고, x86, ARM, 그리고 스팍(SPARC)을 대상으로 하고 싶다고 가정해 봅시다. 일반적으로 이는 Pascal→x86, C→ARM 등 모든 조합을 포함하여 아홉 개의 완전한 컴파일러를 작성해야 한다는 의미입니다.
공유되는 중간 표현은 이를 획기적으로 줄여줍니다. 각 소스 언어에 대해 IR을 생성하는 하나의 프런트 엔드를 작성합니다. 그런 다음 각 대상 아키텍처에 대해 하나의 백 엔드를 작성합니다. 이제 이들을 조합하여 모든 조합을 얻을 수 있습니다.
코드를 의미를 더 명확하게 만드는 형태로 변환하려는 또 다른 큰 이유가 있습니다 . . .
2 . 1 . 5최적화(Optimization)
사용자 프로그램이 무엇을 의미하는지 이해하고 나면, 우리는 그것을 동일한 의미를 가지면서도 더 효율적으로 구현하는 다른 프로그램으로 바꿀 자유가 있습니다. 즉, 우리는 그것을 최적화(optimize)할 수 있습니다.
간단한 예시로 상수 폴딩(constant folding)이 있습니다. 만약 어떤 표현식이 항상 정확히 같은 값으로 평가된다면, 우리는 컴파일 시점에 평가를 수행하고 그 표현식의 코드를 결과값으로 대체할 수 있습니다. 만약 사용자가 이렇게 입력했다면:
pennyArea = 3.14159 * (0.75 / 2) * (0.75 / 2);
우리는 컴파일러에서 모든 산술 연산을 수행하고 코드를 다음과 같이 변경할 수 있습니다:
pennyArea = 0.4417860938;
최적화는 프로그래밍 언어 비즈니스의 큰 부분입니다. 많은 언어 개발자들은 벤치마크를 1%의 몇 분의 1이라도 더 빠르게 만들기 위해 컴파일러에서 가능한 모든 성능을 짜내는 데 평생을 바칩니다. 그것은 일종의 강박 관념이 될 수 있습니다.
이 책에서는 대부분 그 함정은 건너뛸 것입니다. 많은 성공적인 언어들은 놀랍도록 적은 컴파일 시간 최적화를 가집니다. 예를 들어, Lua와 CPython은 비교적 최적화되지 않은 코드를 생성하며, 대부분의 성능 노력은 런타임에 집중합니다.
2 . 1 . 6코드 생성(Code generation)
우리는 사용자 프로그램에 생각할 수 있는 모든 최적화를 적용했습니다. 마지막 단계는 기계가 실제로 실행할 수 있는 형태로 변환하는 것입니다. 즉, 코드 생성(generating code) (또는 코드 젠(code gen))을 하는 것입니다. 여기서 "코드"는 일반적으로 CPU가 실행하는 원시적인 어셈블리 같은 명령어를 의미하며, 사람이 읽고 싶어 할 "소스 코드"의 종류를 의미하는 것은 아닙니다.
드디어 우리는 산의 반대편을 내려가는 백 엔드(back end)에 도달했습니다. 이 시점부터 코드의 표현은 점점 더 원시적으로 변해가는데, 마치 역진화처럼 우리의 단순한 기계가 이해할 수 있는 것에 점점 더 가까워집니다.
우리는 결정을 내려야 합니다. 실제 CPU용 명령어를 생성할 것인가, 아니면 가상 CPU용 명령어를 생성할 것인가? 만약 실제 기계 코드를 생성한다면, OS가 칩에 직접 로드할 수 있는 실행 파일을 얻게 됩니다. 네이티브 코드는 번개처럼 빠르지만, 생성하는 데 많은 노력이 필요합니다. 오늘날의 아키텍처는 수많은 명령어, 복잡한 파이프라인, 그리고 747 비행기의 수하물 칸을 채울 만큼의 역사적 부담을 가지고 있습니다.
칩의 언어를 사용한다는 것은 또한 컴파일러가 특정 아키텍처에 종속된다는 것을 의미합니다. 컴파일러가 x86 기계 코드를 대상으로 한다면, ARM 장치에서는 실행되지 않을 것입니다. 1960년대 컴퓨터 아키텍처의 캄브리아기 폭발 시기에는 이러한 이식성 부족이 실제적인 장애물이었습니다.
이를 극복하기 위해 BCPL의 마틴 리차드(Martin Richards)와 파스칼의 니클라우스 비르트(Niklaus Wirth)와 같은 해커들은 자신의 컴파일러가 가상 머신 코드를 생성하도록 만들었습니다. 이들은 실제 칩의 명령어 대신 가상의 이상적인 기계를 위한 코드를 생성했습니다. 비르트는 이를 이식성(portable)을 의미하는 p-코드(p-code)라고 불렀지만, 오늘날에는 각 명령어가 종종 단일 바이트 길이이기 때문에 일반적으로 바이트코드(bytecode)라고 부릅니다.
이러한 합성 명령어들은 언어의 의미에 조금 더 밀접하게 매핑되도록 설계되었으며, 특정 컴퓨터 아키텍처의 특성과 그에 축적된 역사적 잡동사니에 얽매이지 않도록 합니다. 이를 언어의 저수준 연산을 밀도 높게 이진 인코딩한 것이라고 생각할 수 있습니다.
2 . 1 . 7가상 머신(Virtual machine)
만약 컴파일러가 바이트코드를 생성한다면, 작업이 그것으로 끝나는 것은 아닙니다. 그 바이트코드를 이해하는 칩이 없으므로, 번역하는 것이 당신의 일입니다. 다시 두 가지 옵션이 있습니다. 각 대상 아키텍처를 위해 바이트코드를 해당 기계의 네이티브 코드로 변환하는 작은 미니 컴파일러를 작성할 수 있습니다. 지원하는 각 칩에 대해 여전히 작업을 해야 하지만, 이 마지막 단계는 매우 간단하며 지원하는 모든 기계에서 컴파일러 파이프라인의 나머지 부분을 재사용할 수 있습니다. 기본적으로 바이트코드를 중간 표현으로 사용하는 것입니다.
또는 런타임에 가상 아키텍처를 지원하는 가상의 칩을 에뮬레이션하는 프로그램인 가상 머신(Virtual Machine, VM)을 작성할 수 있습니다. VM에서 바이트코드를 실행하는 것은 미리 네이티브 코드로 변환하는 것보다 느립니다. 왜냐하면 모든 명령어가 실행될 때마다 런타임에 시뮬레이션되어야 하기 때문입니다. 그 대신 단순성과 이식성을 얻을 수 있습니다. 예를 들어 C로 VM을 구현하면, C 컴파일러가 있는 모든 플랫폼에서 언어를 실행할 수 있습니다. 이것이 이 책에서 만들 두 번째 인터프리터의 작동 방식입니다.
2 . 1 . 8런타임(Runtime)
우리는 마침내 사용자 프로그램을 실행할 수 있는 형태로 만들었습니다. 마지막 단계는 실행하는 것입니다. 만약 머신 코드로 컴파일했다면, 단순히 운영체제에 실행 파일을 로드하라고 지시하면 됩니다. 바이트코드로 컴파일했다면, VM을 시작하고 거기에 프로그램을 로드해야 합니다.
두 경우 모두, 가장 기본적인 저수준 언어를 제외하고는, 프로그램이 실행되는 동안 언어가 제공하는 일부 서비스가 일반적으로 필요합니다. 예를 들어, 언어가 자동으로 메모리를 관리한다면, 사용되지 않는 비트를 회수하기 위해 가비지 컬렉터가 작동해야 합니다. 언어가 "instance of" 테스트를 지원하여 어떤 종류의 객체를 가지고 있는지 확인할 수 있다면, 실행 중에 각 객체의 타입을 추적할 어떤 표현이 필요합니다.
이 모든 것은 런타임에 진행되므로, 적절하게도 런타임(runtime)이라고 불립니다. 완전히 컴파일된 언어에서는 런타임을 구현하는 코드가 결과 실행 파일에 직접 삽입됩니다. 예를 들어 Go에서는 각 컴파일된 애플리케이션에 Go 런타임의 자체 복사본이 직접 포함됩니다. 언어가 인터프리터나 VM 내에서 실행되는 경우에는 런타임이 그 안에 존재합니다. 이것이 Java, Python, JavaScript와 같은 언어의 대부분의 구현 방식입니다.
2 . 2지름길과 대체 경로
이는 구현할 수 있는 모든 가능한 단계를 다루는 긴 경로입니다. 많은 언어들이 전체 경로를 따르지만, 몇 가지 지름길과 대체 경로도 있습니다.
2 . 2 . 1단일 패스 컴파일러(Single-pass compilers)
일부 간단한 컴파일러는 파싱, 분석 및 코드 생성을 상호 배치하여, 구문 트리나 다른 IR을 전혀 할당하지 않고 파서에서 직접 출력 코드를 생성합니다. 이러한 단일 패스 컴파일러(single-pass compilers)는 언어의 설계를 제한합니다. 프로그램에 대한 전역 정보를 저장할 중간 데이터 구조가 없으며, 이전에 파싱된 코드 부분을 다시 방문하지 않습니다. 이는 어떤 표현식을 보자마자 그것을 올바르게 컴파일하는 데 필요한 충분한 정보를 알아야 한다는 것을 의미합니다.
Pascal과 C는 이러한 제약사항을 중심으로 설계되었습니다. 당시에는 메모리가 너무 귀하여 컴파일러가 전체 소스 파일은 물론이고 전체 프로그램을 메모리에 담을 수조차 없었을 수 있습니다. 이것이 Pascal 문법이 타입 선언을 블록의 맨 앞에 두도록 요구하는 이유입니다. C에서는 명시적인 전방 선언(forward declaration)이 없으면, 함수를 정의하는 코드보다 위에 있는 코드에서 해당 함수를 호출할 수 없는 이유도 마찬가지입니다. 전방 선언은 컴파일러가 나중에 정의될 함수 호출을 위한 코드를 생성하는 데 필요한 정보를 알려줍니다.
2 . 2 . 2트리 순회 인터프리터(Tree-walk interpreters)
일부 프로그래밍 언어는 AST로 파싱한 직후(약간의 정적 분석이 적용될 수도 있음) 코드를 실행하기 시작합니다. 프로그램을 실행하기 위해 인터프리터는 구문 트리를 한 번에 한 가지 가지와 리프(leaf)씩 순회하며 각 노드를 평가합니다.
이 구현 스타일은 학생 프로젝트나 작은 언어에서는 흔히 사용되지만, 느린 경향이 있어 범용 언어에는 널리 사용되지 않습니다. 어떤 사람들은 "인터프리터"라는 단어를 이러한 종류의 구현만을 의미하는 것으로 사용하지만, 다른 사람들은 더 일반적으로 정의합니다. 그래서 저는 논란의 여지없이 명확한 트리 순회 인터프리터(tree-walk interpreter)라는 용어를 사용하겠습니다. 우리가 만들 첫 번째 인터프리터가 이런 방식입니다.
2 . 2 . 3트랜스파일러(Transpilers)
언어의 완전한 백 엔드를 작성하는 것은 많은 노력이 필요할 수 있습니다. 기존의 일반적인 IR을 대상으로 한다면, 프런트 엔드를 그 위에 덧붙일 수 있습니다. 그렇지 않으면 막다른 길에 다다른 것처럼 보일 수 있습니다. 하지만 다른 소스 언어를 중간 표현인 것처럼 다룬다면 어떨까요?
자신이 만든 언어를 위한 프런트 엔드를 작성합니다. 그런 다음, 백 엔드에서는 의미론을 어떤 원시적인 타겟 언어로 낮추는 모든 작업을 하는 대신, 당신의 언어만큼 고수준인 다른 언어의 유효한 소스 코드를 생성합니다. 그 후, 해당 언어의 기존 컴파일 도구를 사용하여 산에서 벗어나 실행할 수 있는 무언가로 가는 탈출 경로로 삼는 것입니다.
이것을 예전에는 소스-투-소스 컴파일러(source-to-source compiler) 또는 트랜스컴파일러(transcompiler)라고 불렀습니다. 브라우저에서 실행하기 위해 JavaScript로 컴파일되는 언어들이 등장한 이후, 이들은 힙스터스러운 별명인 트랜스파일러(transpiler)라는 이름으로 불리게 되었습니다.
최초의 트랜스컴파일러는 하나의 어셈블리 언어를 다른 어셈블리 언어로 번역했지만, 오늘날 대부분의 트랜스파일러는 더 고수준의 언어에서 작동합니다. 유닉스가 다양한 기기에 광범위하게 퍼진 후, C를 출력 언어로 생성하는 컴파일러의 오랜 전통이 시작되었습니다. C 컴파일러는 유닉스가 있는 모든 곳에서 사용할 수 있었고 효율적인 코드를 생성했기 때문에, C를 대상으로 하는 것은 많은 아키텍처에서 언어를 실행하는 좋은 방법이었습니다.
웹 브라우저는 오늘날의 "기계"이며, 그들의 "기계어"는 JavaScript입니다. 따라서 요즘에는 거의 모든 언어가 JS를 대상으로 하는 컴파일러를 가지고 있는 것 같습니다. 그것이 브라우저에서 코드를 실행하는 주된 방법이기 때문입니다.
트랜스파일러의 프런트 엔드(스캐너와 파서)는 다른 컴파일러와 비슷하게 작동합니다. 만약 원본 언어가 대상 언어 위에 단순한 구문적 외피에 불과하다면, 분석 단계를 완전히 건너뛰고 대상 언어로 유사한 구문을 바로 출력할 수도 있습니다.
만약 두 언어가 의미론적으로 더 다르다면, 분석 및 심지어 최적화를 포함하여 완전한 컴파일러의 일반적인 단계들을 더 많이 보게 될 것입니다. 그리고 코드 생성 단계에서는 기계어와 같은 이진 언어를 출력하는 대신, 대상 언어의 문법적으로 올바른 소스(정확히는 대상) 코드를 문자열로 생성합니다.
어떤 방식으로든, 결과 코드를 출력 언어의 기존 컴파일 파이프라인을 통해 실행하면 됩니다.2 . 2 . 4적시 컴파일(Just-in-time compilation)
이 마지막 방법은 지름길이라기보다는 전문가를 위한 위험한 고산 등반에 가깝습니다. 코드를 실행하는 가장 빠른 방법은 머신 코드로 컴파일하는 것이지만, 최종 사용자의 기계가 어떤 아키텍처를 지원하는지 알지 못할 수 있습니다. 어떻게 해야 할까요?
HotSpot Java 가상 머신(JVM), 마이크로소프트의 Common Language Runtime (CLR), 그리고 대부분의 JavaScript 인터프리터가 하는 것과 동일한 방법을 사용할 수 있습니다. 최종 사용자의 기기에서 프로그램이 로드될 때—JS의 경우 소스 코드에서, JVM과 CLR의 경우 플랫폼 독립적인 바이트코드에서—사용자 컴퓨터가 지원하는 아키텍처에 맞는 네이티브 코드로 컴파일합니다. 당연히 이를 적시 컴파일(just-in-time compilation)이라고 부릅니다. 대부분의 개발자들은 그냥 "JIT"이라고 부르며, "fit"과 운을 맞춰 발음합니다.
가장 정교한 JIT는 생성된 코드에 프로파일링 훅을 삽입하여 어떤 영역이 성능에 가장 중요하며 어떤 종류의 데이터가 흐르는지 확인합니다. 그런 다음 시간이 지남에 따라 핫스팟을 더 고급 최적화와 함께 자동으로 재컴파일합니다.
2 . 3컴파일러와 인터프리터
이제 여러분의 머리에 프로그래밍 언어 전문 용어를 가득 채웠으니, 마침내 코더들을 오랜 세월 괴롭혀 온 질문에 답할 수 있습니다. 컴파일러와 인터프리터의 차이점은 무엇일까요?
이것은 과일과 채소의 차이점을 묻는 것과 같습니다. 이분법적인 선택처럼 보이지만, 실제로는 "과일"은 식물학적 용어이고 "채소"는 요리적 용어입니다. 하나가 다른 것의 부정을 엄격하게 의미하지 않습니다. 채소가 아닌 과일(사과)이 있고, 과일이 아닌 채소(당근)도 있지만, 토마토처럼 과일이자 채소인 식용 식물도 있습니다.
자, 다시 언어로 돌아가서:
-
컴파일(Compiling)은 소스 언어를 다른 형태(일반적으로 저수준 형태)로 번역하는 구현 기법입니다. 바이트코드나 머신 코드를 생성할 때 컴파일하는 것입니다. 다른 고수준 언어로 트랜스파일할 때도 컴파일하는 것입니다.
-
언어 구현이 "컴파일러(compiler)"라고 말할 때, 이는 소스 코드를 다른 형태로 번역하지만 실행하지 않는다는 의미입니다. 사용자는 결과 출력물을 가져와 직접 실행해야 합니다.
-
반대로, 구현이 "인터프리터(interpreter)"라고 말할 때, 이는 소스 코드를 받아들여 즉시 실행한다는 의미입니다. "소스 코드에서" 프로그램을 실행하는 것입니다.
사과와 오렌지처럼, 일부 구현은 분명히 컴파일러이지 인터프리터가 아닙니다. GCC와 Clang은 C 코드를 받아 머신 코드로 컴파일합니다. 최종 사용자는 해당 실행 파일을 직접 실행하며, 어떤 도구가 컴파일에 사용되었는지조차 모를 수 있습니다. 따라서 이들은 C 언어의 컴파일러입니다.
Matz의 루비 표준 구현 초기 버전에서는 사용자가 소스 코드에서 루비를 실행했습니다. 구현은 이를 파싱하고 구문 트리를 순회하여 직접 실행했습니다. 내부적으로든 사용자에게 보이는 어떤 형태로든 다른 번역은 발생하지 않았습니다. 따라서 이것은 확실히 루비의 인터프리터였습니다.
하지만 CPython은 어떨까요? CPython으로 Python 프로그램을 실행하면 코드가 파싱되어 내부 바이트코드 형식으로 변환된 다음 VM 내에서 실행됩니다. 사용자 입장에서는 분명히 인터프리터입니다. 즉, 소스 코드에서 프로그램을 실행합니다. 하지만 CPython의 비늘 같은 껍질 아래를 들여다보면, 분명히 어떤 컴파일 과정이 진행되고 있다는 것을 알 수 있습니다.
정답은 둘 다라는 것입니다. CPython은 인터프리터이면서 컴파일러를 가지고 있습니다. 실제로 대부분의 스크립팅 언어는 아래 그림과 같이 이런 식으로 작동합니다.
가운데 겹치는 영역은 우리의 두 번째 인터프리터가 존재하는 곳이기도 합니다. 내부적으로 바이트코드로 컴파일하기 때문입니다. 따라서 이 책은 명목상 인터프리터에 관한 것이지만, 컴파일에 대해서도 다룰 것입니다.
2 . 4우리의 여정
이 모든 것을 한 번에 받아들이기에는 많은 정보입니다. 걱정하지 마세요. 이 장에서 이 모든 구성 요소를 이해할 필요는 없습니다. 단지 이러한 것들이 존재하며, 대략적으로 어떻게 서로 연결되어 있는지 알아두시길 바랍니다.
이 지도는 이 책에서 안내하는 길을 넘어 더 넓은 영역을 탐험할 때 유용하게 쓰일 것입니다. 이 산을 스스로 개척하고 이곳저곳을 헤매고 싶어 하는 열망을 품고 이 장을 마치시길 바랍니다.
하지만 지금은 우리 자신의 여정을 시작할 시간입니다. 신발 끈을 조이고, 짐을 단단히 메고 따라오세요. 이제부터는 눈앞의 길에만 집중하면 됩니다.
도전 과제
-
좋아하는 언어의 오픈 소스 구현체를 하나 선택하세요. 소스 코드를 다운로드하여 살펴보세요. 스캐너와 파서를 구현한 코드를 찾아보세요. 직접 작성된 것인가요, 아니면 Lex와 Yacc 같은 도구를 사용하여 생성된 것인가요? (.l 또는 .y 파일은 보통 후자를 의미합니다.)
-
적시 컴파일(JIT)은 동적 타입 언어를 구현하는 가장 빠른 방법인 경향이 있지만, 모든 동적 타입 언어가 이를 사용하는 것은 아닙니다. JIT를 사용하지 않는 이유는 무엇일까요?
-
C로 컴파일되는 대부분의 Lisp 구현체에는 Lisp 코드를 즉석에서 실행할 수 있는 인터프리터도 포함되어 있습니다. 그 이유는 무엇일까요?