한입 CS

코드는 어떻게 실행될까: 토큰, AST, 바이트코드

토크나이저, 재귀 하향 파서, 스택 기계, V8과 CPython

9장 · 코드가 도는 층 · 읽는 시간 약 18분

목차 · 진행 0%

    배포 전에 디버깅용 console.log를 모두 지우기로 했다. 에디터의 정규식 치환으로 console\.log\(.*\);?를 빈 문자열로 바꿨다. 그런데 결과가 이상하다. 사용 안내 문자열 'console.log()로 값을 찍어 보세요'의 가운데가 잘려 나갔고, 인자가 여러 줄에 걸친 console.log( 호출은 첫 줄만 지워져 문법 오류가 났다.

    정규식은 코드를 글자의 줄로 본다. 문자열 안의 글자와 진짜 함수 호출을 구별하지 못하고, 괄호가 어디서 닫히는지도 모른다. ESLint나 ast-grep 같은 도구는 같은 일을 할 때 코드를 먼저 로 바꾼다. JavaScript 엔진과 Python 인터프리터(interpreter)도 코드를 실행하기 전에 똑같이 트리를 만든다. 이 장은 1 + 2 * 3 한 줄이 글자 → 토큰 → 트리 → 명령 목록으로 바뀌어 실행되기까지를 직접 짜 보며 따라간다.

    토큰화: 글자를 낱말로

    사람은 12 + 3 * 4를 보면 "12, 더하기, 3, 곱하기, 4"로 읽는다. 컴퓨터가 받는 것은 글자 10개(공백 포함)다. 첫 단계는 글자를 의미 있는 낱말로 자르는 일이고, 이 낱말을 토큰(token), 자르는 프로그램을 토크나이저(tokenizer) 또는 렉서(lexer)라고 한다. 숫자 글자가 이어지면 하나로 묶고, 공백은 버리고, 기호는 하나씩 자른다.

    예측먼저 답해 보세요

    12 + 3 * 4를 토큰으로 자르면 몇 개가 될까? (공백은 토큰이 되지 않는다)

    선택지와 해설 보기
    실험 글자를 토큰으로 자르기
    실험글자를 토큰으로 자르기

    한 글자씩 보며 숫자는 이어 붙이고, 공백은 건너뛰고, 나머지 기호는 한 글자를 토큰 하나로 만듭니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const src = '12 + 3 * 4'const tokens: string[] = []let i = 0while (i < src.length) {  const ch = src[i]  if (ch === ' ') {    i++  } else if (ch >= '0' && ch <= '9') {    let num = ''    while (i < src.length && src[i] >= '0' && src[i] <= '9') {      num += src[i]      i++    }    tokens.push('NUM:' + num)  } else {    tokens.push('OP:' + ch)    i++  }}console.log(tokens.join(' '))
    변수
    src
    '12 + 3 * 4'
    출력
     
    src
    1. 1
    2. 2
    3. ␣
    4. +
    5. ␣
    6. 3
    7. ␣
    8. *
    9. ␣
    10. 4
    tokens
    1. 비어 있음
    1/66단계.
    • 해볼 것
    • 12 + 3 * 4를 끝까지 실행해 12가 토큰 하나가 되는 것을 본다
    • 공백 없는 (7-2)*10을 끝까지 실행해 토큰 7개로 잘리는 것을 본다

    바깥 반복 한 바퀴 안에서 안쪽 반복이 글자 두 개를 모아 12를 NUM:12 토큰 하나로 만든다. 공백은 i만 한 칸 옮기고 사라진다. 공백 없는 (7-2)*10도 공백이 있을 때와 똑같이 토큰 7개로 잘린다.

    12 + 3 * 4는 이어진 숫자 12가 하나로 묶여 토큰 5개가 된다. 토큰 목록에는 공백이 몇 개였는지가 남지 않는다. 실제 언어의 토크나이저는 여기에 문자열, 주석, 키워드, 변수 이름 규칙이 더해질 뿐 하는 일은 같다. 문자열 'console.log()'는 글자가 아무리 함수 호출처럼 생겨도 문자열 토큰 하나가 된다.

    파싱: 우선순위는 트리 모양이다

    토큰 목록 1 + 2 * 3은 아직 평평한 줄이다. 곱하기를 먼저 한다는 규칙은 어디에도 적혀 있지 않다. 이 규칙을 담는 단계가 파싱(parsing)이고, 결과물이 추상 구문 (abstract syntax tree, AST)다. JavaScript에서도 곱하기가 더하기보다 우선순위(precedence)가 높아 3 + 10 * 2는 23이다(MDN: 연산자 우선순위).

    손으로 짜기 쉬운 파서는 재귀 하향 파서(recursive descent parser)다. 문법(grammar) 규칙 하나를 함수 하나로 짠다.

    함수맡은 규칙읽는 모양
    expr더하기·빼기term을 읽고, +나 -가 보이는 동안 term을 더 읽는다
    term곱하기factor를 읽고, *가 보이는 동안 factor를 더 읽는다
    factor숫자, 괄호숫자 하나, 또는 ( 다음에 expr을 읽고 )

    우선순위가 높은 연산일수록 호출 사슬에서 더 아래에 있는 함수(expr → term → factor)가 맡는다(곱하기는 term 안에서 끝낸 뒤에야 expr로 돌아오기 때문에 더하기보다 먼저 계산된다).

    예측먼저 답해 보세요

    expr·term·factor로 나눈 재귀 하향 파서가 1 + 2 * 3을 읽을 때 2 * 3을 계산하는 함수는?

    선택지와 해설 보기
    실험 재귀 하향 파서로 계산하기

    아래 코드는 트리를 따로 만들지 않고 읽는 즉시 계산한다. 토큰 끝에는 'EOF'(입력의 끝)를 붙여 두어 tokens[0]이 늘 있게 했다.

    실험재귀 하향 파서로 계산하기

    함수 하나가 규칙 하나입니다. expr은 더하기·빼기, term은 곱하기, factor는 숫자나 괄호를 맡습니다. 호출 스택이 어떻게 쌓이는지 보세요.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const tokens = ['1', '+', '2', '*', '3', 'EOF']function expr(): number {  let left = term()  while (tokens[0] === '+' || tokens[0] === '-') {    const op = tokens.shift()    const right = term()    left = op === '+' ? left + right : left - right  }  return left}function term(): number {  let left = factor()  while (tokens[0] === '*') {    tokens.shift()    const right = factor()    left = left * right  }  return left}function factor(): number {  const tok = tokens.shift()!  if (tok === '(') {    const inner = expr()    tokens.shift()    return inner  }  return Number(tok)}console.log(expr())
    변수
    tokens
    ['1', '+', '2', '*', '3', 'EOF']
    출력
     
    호출 스택
    1. 비어 있음
    tokens
    1. 1
    2. +
    3. 2
    4. *
    5. 3
    6. EOF
    1/34단계.
    • 해볼 것
    • 1 + 2 * 3을 끝까지 실행해 곱하기가 어느 함수에서 끝나는지 본다
    • (1 + 2) * 3을 끝까지 실행해 괄호에서 expr이 다시 불리는 것을 본다

    1 + 2 * 3에서 expr은 먼저 term을 부른다. term은 factor로 1을 읽고, 다음 토큰이 *가 아니라서 바로 1을 돌려준다. expr이 +를 보고 다시 term을 부르면, 이번 term은 2를 읽은 뒤 *를 보고 3까지 읽어 6을 만든 다음에야 돌아온다. 그래서 expr이 받는 오른쪽 값은 2가 아니라 6이고, 결과는 7이다.

    (1 + 2) * 3에서는 factor가 (를 만나 expr을 다시 부른다. 호출 스택(call stack)이 expr → term → factor → expr → term → factor로 여섯 프레임까지 쌓인다. 괄호 안이 다시 처음부터 시작하는 수식이라는 문법이 그대로 재귀 호출이 됐다. 결과는 9다.

    계산 대신 노드를 만들어 돌려주면 그대로 AST가 된다.

    실험 수식을 트리로 그려 보기

    아래 실험의 파서는 앞의 expr·term·factor 파서에 나누기(/)를 더하고 숫자 대신 노드를 돌려준다.

    실험수식을 트리로 그려 보기

    정수, + - * /, 괄호로 쓴 수식을 파서가 트리로 그립니다. 계산은 아래쪽 노드부터 합니다. 연산자 옆의 → 값은 그 노드 아래 부분식을 계산한 결과입니다. 수식을 직접 바꿔 보세요.

    +  → 7
    ├─ 1
    └─ *  → 6
       ├─ 2
       └─ 3
    

    계산 결과: 7.

    • 해볼 것
    • 1 + 2 * 3을 넣고 *가 +보다 아래에 달린 것을 본다
    • 괄호를 넣어 맨 위 노드를 *로 바꾼다
    • 10 - 4 - 3을 넣고 왼쪽 -가 먼저 계산되는 모양을 본다

    1 + 2 * 3의 트리는 맨 위가 +이고 *는 그 오른쪽 아래에 달린다. 계산은 아래쪽 노드부터 하므로 *가 먼저 끝난다. 우선순위는 트리의 높낮이로 바뀌었다. 10 - 4 - 3은 왼쪽 -가 아래에 달려 (10 - 4) - 3 = 3이 된다. 같은 우선순위끼리 왼쪽부터 묶는 규칙(왼쪽 결합, left-associative)은 expr의 반복이 지금까지의 결과를 왼쪽 자식으로 넣으며 만든다. 괄호는 트리의 모양만 바꾸고 트리 안에는 남지 않는다.

    Python은 자기 코드의 AST를 표준 라이브러리 ast 모듈로 보여 준다(Python 문서: ast). 같은 수식을 넣으면 같은 모양이 나온다(Python 3.14에서 실행한 결과).

    >>> import ast
    >>> print(ast.dump(ast.parse('1 + 2 * 3', mode='eval'), indent=2))
    Expression(
      body=BinOp(
        left=Constant(value=1),
        op=Add(),
        right=BinOp(
          left=Constant(value=2),
          op=Mult(),
          right=Constant(value=3))))

    실행: 바이트코드와 JIT

    AST를 실행하는 가장 단순한 방법은 를 내려가며 노드마다 바로 계산하는 것이다. 이것을 트리 순회 인터프리터(tree-walking interpreter)라고 한다. 짜기는 쉽지만, 노드마다 함수를 부르고 자식을 따라가는 일이 실행할 때마다 되풀이된다. 그래서 많은 엔진은 AST를 한 번 더 바꿔 평평한 명령 목록, 바이트코드(bytecode)를 만든다.

    CPython의 바이트코드는 dis 모듈로 볼 수 있다. 문서에 따르면 CPython 인터프리터는 값을 스택에 쌓아 두고 명령이 그 스택에서 값을 꺼내 쓴다. BINARY_OP은 스택 맨 위 두 값을 꺼내 계산하고 결과를 다시 넣는다(Python 문서: dis). 명령의 순서는 노드마다 왼쪽 자식, 오른쪽 자식, 연산자 순으로 내려 적은 순서다. 그래서 아래에 달린 노드의 명령이 그 위 연산자의 명령보다 먼저 나온다.

    예측먼저 답해 보세요

    return a + b * c를 CPython 바이트코드로 보면 BINARY_OP 명령이 두 개 나온다. 먼저 나오는 쪽은?

    선택지와 해설 보기
    >>> import dis
    >>> def f(a, b, c):
    ...     return a + b * c
    >>> dis.dis(f)
      1           RESUME                   0
    
      2           LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
                  LOAD_FAST_BORROW         2 (c)
                  BINARY_OP                5 (*)
                  BINARY_OP                0 (+)
                  RETURN_VALUE

    Python 3.14에서 실행한 결과다. a, b, c를 스택에 차례로 올리고, 맨 위 두 값(b, c)을 곱해 다시 올린 뒤, 남은 두 값(a, b * c)을 더한다. 명령 이름(LOAD_FAST_BORROW 등)은 문서가 밝히듯 CPython 버전마다 바뀔 수 있는 구현 세부다. 바뀌지 않는 것은 순서다. 트리의 왼쪽 자식, 오른쪽 자식, 연산자 순으로 내려 적는다. 이 장의 보너스 문제에서 이 변환을 직접 짠다.

    V8(Chrome과 Node.js의 JavaScript 엔진)도 함수를 먼저 바이트코드로 바꿔 인터프리터 Ignition으로 실행한다. Ignition은 CPython의 스택 대신 레지스터(register, CPU 안의 작은 저장 칸)에 값을 두는 레지스터 기계(register machine) 방식이다(V8 블로그: Ignition). 바이트코드를 해석하며 실행하는 것은 기계어(machine code)보다 느리다. 그래서 V8은 자주 도는 함수를 기계어로 바꾸는 컴파일러(compiler)를 단계별로 둔다. 실행 중에 기계어로 컴파일하는 이 방식을 JIT(just-in-time) 컴파일이라고 한다(V8 블로그: Maglev).

    단계하는 일
    Ignition바이트코드를 해석해 실행한다. 실행하며 값의 타입·객체 모양 같은 피드백을 모은다
    Sparkplug바이트코드를 같은 동작의 기계어로 거의 즉시 옮긴다(기본 JIT)
    Maglev모은 피드백으로 적당히 최적화한 기계어를 빨리 만든다(Chrome 117부터)
    TurboFan시간을 더 들여 최고 성능의 기계어를 만든다

    Maglev와 TurboFan은 "이 변수는 늘 숫자였다", "이 객체는 늘 같은 모양이었다" 같은 피드백을 믿고 기계어를 만든다. 가정이 깨지면 그 기계어를 버리고 바이트코드 실행으로 되돌아간다. 이것을 최적화 해제(deoptimization)라고 한다.

    객체 모양은 V8이 히든 클래스(hidden class)로 기록한다. 같은 속성을 같은 순서로 가진 객체는 같은 히든 클래스를 쓰고, 속성을 하나 더할 때마다 다른 히든 클래스로 옮겨 간다(V8 블로그: Fast properties). { x, y }로 만든 객체와 { y, x }로 만든 객체는 속성이 같아도 모양이 다르다. 같은 종류의 객체를 한 곳(생성 함수, 클래스 생성자)에서 같은 순서로 만들면 엔진이 모은 피드백이 계속 들어맞는다.

    글자로 고치기와 트리로 고치기

    처음 장면으로 돌아가자. ESLint 규칙은 소스를 직접 훑지 않는다. ESLint가 코드를 AST로 바꾼 뒤 트리를 내려가고 올라오며 노드를 방문하고, 규칙은 "CallExpression 노드를 만나면 불러 달라"처럼 노드 종류에 함수를 걸어 둔다(ESLint 문서: Custom Rules). ast-grep도 AST를 기준으로 코드를 찾고 고치는 도구다(ast-grep). console.log(...) 호출 노드를 찾으면 문자열 토큰 안의 글자는 처음부터 후보가 아니고, 인자가 몇 줄에 걸쳐 있든 노드 하나로 지워진다. 번들러가 import 문을 찾아 의존성을 잇는 일도 코드를 파싱한 결과 위에서 한다.

    직접 짠 코드에서도 같은 차이가 난다. 사용자가 입력한 수식을 계산하는 기능을 AI에게 맡기면 글자를 쪼개는 코드가 나오기 쉽다.

    함정 찾기 AI가 짠 계산기 입력 처리, 어디가 문제일까?
    함정 찾기AI가 짠 계산기 입력 처리, 어디가 문제일까?
    내가 AI에게 한 요청
    “사용자가 입력한 사칙연산 수식(예: 1 + 2 * 3)을 계산하는 함수를 짜 줘. 빠른 버전도 하나.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 4개입니다.

    정리하면, 공백으로 쪼개 왼쪽부터 계산하는 코드는 1+2처럼 붙여 쓴 입력을 못 읽고 1 + 2 * 3을 9로 만든다. 빠르다며 eval에 넘기면 수식 대신 들어온 아무 코드나 그 페이지의 권한으로 실행된다. 이 장의 토크나이저와 재귀 하향 파서처럼 허용한 토큰만 받아 트리로 만들어야 한다.

    글자 수준의 치환이 늘 나쁜 것은 아니다. 파일 이름 한 줄, 설정 값 하나를 바꾸는 데 파서를 꺼낼 필요는 없다. 바꿀 대상이 코드의 구조(호출, 선언, 괄호의 짝)라면 트리로 다루는 도구를 고른다.

    확인 문제 장을 덮기 전에 떠올려 보기
    확인 문제장을 덮기 전에 떠올려 보기

    1. 10 - 4 - 3의 AST에서 맨 위 - 노드의 왼쪽 자식은? (빼기는 왼쪽 결합이다)

    2. 코드에서 console.log 호출만 지울 때 정규식보다 AST 도구(ESLint, ast-grep)가 나은 이유는?

    3. V8에서 두 객체가 같은 히든 클래스를 쓰려면? (같은 속성 이름을 가진 두 객체)

    더 풀어 보기(선택) · AST를 바이트코드로 컴파일하고 실행하기
    보너스 문제AST를 바이트코드로 컴파일하고 실행하기

    AST를 배열로 적는다. 숫자는 그대로, 연산은 [연산자, 왼쪽, 오른쪽]이다. 1 + 2 * 3은 ['+', 1, ['*', 2, 3]]이다. 이 트리를 스택 기계(stack machine) 명령 목록(PUSH n, ADD, SUB, MUL)으로 바꾸는 컴파일러와, 그 목록을 실행해 값을 내는 작은 VM을 짜 보자. 트리를 다시 보지 않고 명령 목록만으로 계산해야 한다.

    ['+', 1, ['*', 2, 3]]
    → PUSH 1, PUSH 2, PUSH 3, MUL, ADD
    → 7
    풀 때 쓰는 것: 『개발 수학』 Unit 10. 그래프 용어: 정점, 간선, 트리, DAG (트리의 층 수) · 『개발 수학』 Unit 3. 증가 속도: n, n log n, n², 2ⁿ (컴파일과 실행이 노드 수에 비례하는 O(n))

    이 장을 언급한 노트

    다음10장. 해시, 암호화, 인코딩은 무엇이 다를까마지막 장에서는 해시, 암호화, 인코딩이 각각 무엇을 지키고 무엇을 지키지 못하는지 가릅니다.

    『한입 CS』 9장. 코드는 어떻게 실행될까: 토큰, AST, 바이트코드 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션