
우선 토크나이징과 렉싱이 어디서 사용되는걸까?
토크나이징과 렉싱은 프로그램이 컴파일러에 의해서 처리되는 과정 중 첫 번째인 소스코드를 컴퓨터가 이해할 수 있는 언어로 바꾸기 위해 토큰으로 쪼개지는 단계에서 사용된다.
이 때 토큰은 다음 단계인 파싱 단계에서 AST를 만드는데 사용된다.
토큰을 만드는게 무엇인지 알아보자.
var a = 2;라는 코드가 있다.
이걸 토큰으로 나누게 되면 ["var", "a", "=", "2", ";"], 이렇게 var, a와 같이 토큰으로 나눠지고 각각의 토큰에 다음과 같이 의미를 부여한다.
[
{ type: "Keyword", value: "var" }, // var는 키워드
{ type: "Identifier", value: "a" }, // a는 식별자
{ type: "Operator", value: "=" }, // = 는 연산자
{ type: "NumericLiteral", value: "2" }, // 2는 숫자 리터럴
{ type: "Punctuator", value: ";" } // ;는 punctuator
]
기존에는 var, a와 같이 의미없이 나눠진 토큰을 var는 키워드, a는 식별자와 같이 의미를 부여해준다.
그리고 그렇게 의미가 부여된 토큰 목록을 파싱을 통해서 아래와 같이 AST를 생성하는 것이다.
// AST
{
"type": "VariableDeclaration",
"kind": "var",
"declarations": [
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "a" },
"init": { "type": "Literal", "value": 2 }
}
]
}
이렇게 소스코드를 토큰화 하고 파싱을 통해서 AST가 생성되는 과정을 간략화해봤다.
그럼 이제 각 과정에서 어떤게 토크나이징이고 어떤게 렉싱인지 그리고 차이는 무엇인지 한 번 알아보자.
토크나이징은 문자열을 단순히 쪼개는 작업이다.
즉, 공백, 구분자(세미콜론, 괄호, 연산자 등)를 기준으로 문자를 분리해서 "var", "a", "=", "2", ";" 와 같이 기본 단위 토큰으로 나누는 것이다.
좀 더 학술적인 표현으로 이 과정을 "무상태 방식" 으로 동작한다고 한다.
즉, 앞뒤 문맥을 기억하지 않고, 단순히 규칙에 따라 잘라내는 것이다.
조금 이해가 안되더라도 뒤에 반대 경우인 렉싱을 본다면 이해가 될 것이다.
렉싱은 학술적인 설명으로는 토크나이저가 문자열을 별개의 토큰으로 분리할지 아니면 다른 토큰의 일부로 처리할지를 결정할 때 상태 유지 파싱 규칙을 적용하여 토큰을 생성하는 과정이라고 할 수 있다.
토크나이징과는 반대로 "상태 유지 방식" 으로 토큰을 인식한다는 말이다.
그렇다면 여기서 말하는상태라는건 뭘까?
상태는 소스 코드의 앞뒤 문맥을 말한다.
좀 더 이해가 쉽도록 let str = "hello world!";라는 문자열을 가지고 엔진이 어떻게 상태를 유지하며 토큰화를 거치는지 알아보자.
| 문자 | 상태 | 동작 | 토큰 버퍼 |
| ---- | ---------------- | ----------------- | --------------- |
| `l` | default | 식별자 시작 | `l` |
| `e` | default | 계속 식별자 | `le` |
| `t` | default | 계속 식별자 | `let` |
| (공백) | default | 식별자 종료 (`let` 생성) | - |
| `s` | default | 식별자 시작 | `s` |
| `t` | default | 계속 식별자 | `st` |
| `r` | default | 계속 식별자 | `str` |
| (공백) | default | 식별자 종료 (`str` 생성) | - |
| `=` | default | 연산자 생성 (`=`) | - |
| (공백) | default | 무시 | - |
| `"` | default → string | 상태 전환 | - | // "를 만나서 상태를 string으로 바꿈
| `h` | string | 문자열로 누적 | `h` |
| `e` | string | 계속 | `he` |
| ... | string | 계속 | ... |
| `"` | string → default | 문자열 종료, 토큰 생성 | `"hello world"` | // "를 만나서 상태를 default로 다시 바꿈
이때 렉서는 새로운 토큰 분할을 시작할 때 앞뒤 문맥 상태를 기억하거나 참고해서 다음에 어떻게 처리할지 정한다.
상태 유지 규칙이 각 토큰의 의미와 종류가 결정되는 시점을 제어하는 기능을 하는 것이다.
그렇기 때문에 "hello world" 문자열 사이에 공백이 있지만 상태가 string이기 때문에 토큰을 끊지 않고 다음 "가 나와서 상태가 바뀔 때까지 하나의 문자 리터럴 토큰으로 처리한 것이다.
이렇게 봐도 이해가 잘안갈 수도 있기 때문에 상태 유지의 예시를 좀 더 보자.
연산자로도 사용되고 정규식 표현으로 쓰이는 /가 있다.
// 정규식의 사용
let x = /abc/;
// 연산자의 사용
let y = 9;
let z = x / 3;
변수 x와 z에서 똑같이 /가 사용되었지만 앞선 토큰이 x에서는 연산자 =이고 z에서는 식별자 x이기 때문에 렉서는 뒤에 오는 /를 정규식 표현이 아닌 연산자(나누기)로 판단하는 것이다.
정리하자면 렉싱에서 상태 유지 규칙의 역할은 두 가지다.
토큰화 시점과 종류 결정
문맥 기반으로 의미 해석
대표적인 상태로는 다음과 같이 있으니 참고하자.
default : 기본 상태, 코드 대부분 처리string : 문자열 내부 처리 중 (큰따옴표나 작은따옴표로 시작됨)template : 템플릿 리터럴 내부 처리 중 (...)regexp : 정규표현식 내부 (/.../)singleLineComment : // 주석 내부multiLineComment : / ... / 주석 내부escape : 이스케이프 문자 처리 중numeric : 숫자 토큰 처리 중hex, binary, octal : 숫자 리터럴의 진법에 따라 처리 상태identifier : 식별자 토큰 생성 중그렇다면 둘은 뭐가 다르고 언제 어떻게 사용되는건가?
우선은 가장 큰 차이는 상태 유지 규칙을 사용하는지 아닌지이다.
위에서 설명한 것과 같이 토크나이징은 상태없이, 렉싱은 상태를 고려하면서 토큰을 나눈다.
그 다음은 분할된 토큰에 의미가 부여되는지다.
토크나이징은 분할된 토큰들이 별도의 의미가 없이 그냥 각각의 토큰이지만 렉싱은 각 토큰마다 키워드, 식별자 등의 의미를 부여한다.
그렇다면 왜 의미를 부여하는 것일까?
그 이유는 그 다음 단계인 파싱에 있다.
파싱의 결과물인 AST는 계층적 구조이고 이러한 구조를 만들기 위해서는 각 토큰의 역할을 알 수 있는 의미를 알아야한다.
그렇기 때문에 의미가 없는 토크나이징만 거쳐도 이후 파싱 단계에서 파서가 의미를 부여하고 AST를 생성하기도 하지만 효율을 위해서 의미를 부여하는 단계까지 포함된 렉싱을 사용하는 것이다.
즉, 렉싱은 토크나이징의 단점을 보완하기 위해서 뒤에 나온 좀 더 고도화된 기능이다.
토크나이징 + 의미 부여 = 렉싱이라고 볼 수도 있기 때문에 토크나이징은 렉싱에 속한 부분집합이라고 할 수도 있을 것 같다.