렉서가 위치를 기억하는 이유
한 줄 요약
렉서는 글자의 줄을 토큰(종류가 붙은 낱말)의 줄로 바꾸면서 공백과 주석을 걸러 내고, 토큰마다 어디서 왔는지(줄·칸) 를 붙여 둡니다. 파서는 그 덕분에 글자가 아니라 뜻 있는 조각을 보고, 뒤의 모든 오류 메시지는 그 위치를 물려받습니다.
왜 이게 필요했나
파서가 글자를 직접 읽는다고 해 봅시다. let x=10; 과 let x = 10 ; 과 let x /* 설명 */ = 10; 은 같은 문장인데, 파서의 모든 규칙이 공백과 주석을 건너뛰는 코드를 따로 가져야 합니다. <= 를 만날 때마다 "다음 글자가 = 인가" 를 규칙마다 확인해야 하고, letter 가 키워드 let 으로 시작한다는 사실도 규칙마다 조심해야 합니다.
렉서는 이 일을 한곳에 모읍니다. 파서가 보는 것은 let, IDENT(x), =, INT(10), ;, EOF 여섯 개뿐입니다. 그리고 한 가지를 더 합니다 — 위치를 기억합니다. 사용자에게 "뭔가 틀렸다" 가 아니라 "3번 줄 17번째 칸의 @ 를 모른다" 고 말할 수 있는 것은, 가장 앞 단계가 글자를 셀 때 줄과 칸을 함께 세어 두었기 때문입니다. 이 좌표는 파서의 "; 이 와야 한다", 의미 분석의 "선언되지 않은 이름", 실행 중의 "0 으로 나눔" 까지 전부 같은 좌표계를 씁니다.
어떻게 동작하나
렉서는 커서 하나로 글자를 한 번 훑습니다. 매 걸음마다 지금 글자를 보고 어떤 토큰이 시작되는지 정한 뒤, 그 토큰이 끝날 때까지 삼킵니다.
let x1 <= 007 ; // 끝
^^^ ^^ ^^ ^^^ ^
let IDENT <= INT ; (주석과 공백은 버리고) EOF
1:2 1:7 1:10 1:13 1:17 1:23
지켜야 할 규칙이 네 가지 있습니다.
가장 긴 것부터(maximal munch). <= 를 읽을 수 있으면 < 와 = 로 자르지 않습니다. 두 글자 기호를 먼저 확인하고 안 되면 한 글자로 내려옵니다. 이름도 같습니다 — letter 는 글자를 끝까지 읽은 뒤에 키워드인지 봅니다. 앞 세 글자가 let 이라고 먼저 자르면 let + ter 가 됩니다.
칸은 글자로 셉니다. 파일은 UTF-8 바이트지만 사람이 보는 칸은 글자입니다. /* 한글 */ x 에서 x 는 10번째 글자이지만 14번째 바이트입니다. 바이트로 세면 한글 주석이 있는 줄의 모든 오류 위치가 밀립니다. 줄바꿈을 지나면 칸은 1로 돌아갑니다.
오류는 그 시작 위치로. 닫히지 않은 /* 는 파일 끝에서 발견되지만, 알려 줄 위치는 주석이 시작한 곳입니다. 끝 위치를 알려 주면 사용자는 파일 끝에서 헤맵니다. 64비트에 들지 않는 정수도 그 숫자의 첫 칸으로 알립니다.
끝을 토큰으로. 마지막에는 늘 EOF 토큰을 둡니다. 파서가 "토큰이 더 있나" 를 따로 확인하지 않아도 되고, "} 가 와야 하는데 파일이 끝났다" 는 오류도 EOF 의 위치(마지막 글자 바로 뒤)로 정확히 찍힙니다.
한 번만 훑는다는 것도 중요합니다. 남은 글자를 src = src[1:] 처럼 매번 잘라 새로 만들면 글자 하나 읽을 때마다 파일 전체를 복사하게 되어, 파일이 두 배가 되면 시간이 네 배가 됩니다. 커서는 자리 번호 하나를 옮길 뿐이어야 합니다.
현장에서 만나는 모습
- 컴파일러 오류의 밑줄. clang·rustc 가 오류 줄을 보여 주고 그 아래
^표시로 정확한 칸을 짚는 것은 렉서가 남긴 위치 덕분입니다. 위치가 틀린 컴파일러는 오류 메시지가 맞아도 쓰기 괴롭습니다. - 편집기의 칸 번호 사고. 언어 서버 프로토콜(LSP)은 칸을 UTF-16 코드 단위로 세기로 정해 두었습니다. 서버가 바이트로, 편집기가 UTF-16 으로 세면 한글·이모지가 있는 줄에서 빨간 밑줄이 엉뚱한 곳에 그어집니다. "칸을 무엇으로 세는가" 는 사소해 보여도 실제 도구끼리 어긋나는 대표 지점입니다.
- 렉서 생성기와 손으로 쓴 렉서. flex 같은 도구는 정규식 목록에서 렉서를 만들어 줍니다. 편하지만 '가장 긴 것부터' 와 '먼저 적은 규칙이 이긴다' 는 두 규칙이 함께 돌아, 키워드와 이름의 우선순위를 정규식 순서로 맞추다 헷갈리는 일이 잦습니다. 그래서 GCC·Clang·rustc·Go 는 렉서를 손으로 씁니다 — 줄·칸을 정확히 세고, 오류 메시지를 다듬고, 한 번만 훑는 것을 직접 통제하려고요. 이 실습의 렉서도 그 방식입니다.
- 파이썬의 tokenize 모듈. 파이썬도 같은 일을 합니다.
python3 -m tokenize 파일.py를 돌리면 토큰마다 (줄, 칸) 범위가 찍힙니다. 들여쓰기가 뜻을 가진 언어라 INDENT·DEDENT 라는 토큰까지 렉서가 만든다는 점이 다를 뿐입니다.
다음 실습에서 할 것
미니 언어의 렉서를 lexer.py 에 다섯 조각으로 만듭니다 — 줄·칸을 세는 커서, 공백과 주석 건너뛰기, 정수와 이름·키워드, 가장 긴 것부터 자르는 기호, 그리고 이것들을 잇는 tokenize. 채점기는 토큰 하나하나의 줄·칸까지 기준 렉서와 대조하고, 한글이 섞인 주석·64비트를 넘는 정수·닫히지 않은 주석에서 오류 글자까지 같은지 보며, 끝으로 입력을 네 배로 늘려 시간이 선형으로 느는지 잽니다.