테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

컴파일러와 프로그래밍 언어

사람이 쓴 코드가 토큰, 구문 트리, 중간 표현을 거쳐 기계어가 되는 과정과, 인터프리터·JIT·가비지 컬렉터처럼 언어가 실행되는 방식을 작은 식 하나를 직접 파싱하고 최적화한 그림으로 따라간다.

목차 (12)
  1. 컴파일러 파이프라인
  2. 렉서와 파서
  3. 타입 시스템
  4. 중간 표현(IR)과 SSA
  5. 최적화: 상수 접기와 죽은 코드 제거
  6. 인터프리터, 바이트코드 VM, JIT
  7. 가비지 컬렉션
  8. 메모리 관리 방식 비교
  9. 링커와 로더
  10. 스코프와 클로저
  11. 흔한 오해
  12. 핵심 자료

컴파일러 파이프라인

컴파일러는 한 언어로 쓴 프로그램을 같은 뜻의 다른 언어로 옮기는 프로그램이다. 대개 사람이 읽는 언어에서 CPU가 실행하는 기계어로 옮긴다. 한 번에 하지 않고, 앞 단계의 출력을 다음 단계가 받는 여러 단계로 나눈다.

  • 프런트엔드: 글자를 토큰으로 자르고(렉서), 문법에 맞춰 트리로 묶고(파서), 이름이 정의돼 있는지·타입이 맞는지 확인한다(의미 분석). 언어마다 따로 있다.
  • 미들엔드: 트리를 기계에 가까운 중간 표현(IR)으로 바꾸고 최적화한다. 언어와 CPU 모두와 무관하다.
  • 백엔드: IR을 특정 CPU의 명령으로 고르고, 변수를 레지스터에 배정하고, 명령 순서를 다듬는다. CPU마다 따로 있다.
소스 코드가 렉서, 파서, 의미 분석, IR 생성, 최적화, 코드 생성을 거쳐 기계어가 되는 여덟 단계프런트엔드 — 언어마다 따로소스 코드y = 2 + 3 * x렉서→ 토큰 열파서→ AST의미 분석이름·타입 확인IR 생성3주소 코드·SSA최적화같은 뜻, 더 빠르게코드 생성명령·레지스터 선택기계어목적 파일(.o)미들엔드 — 언어·CPU와 무관백엔드 — CPU마다 따로
컴파일러의 여덟 단계. 미들엔드(색칠)를 공유하면 언어 N개와 CPU M개를 N × M개가 아니라 N + M개의 부품으로 지원할 수 있다.

이 구조를 가장 잘 보여 주는 것이 LLVM이다. Clang(C·C++), Rust, Swift, Zig 같은 언어가 저마다 프런트엔드만 만들어 LLVM IR을 내놓으면, 최적화와 x86·Arm·RISC-V·WebAssembly 백엔드는 LLVM이 함께 맡는다. GCC도 같은 구조다.

렉서와 파서

렉서(어휘 분석기)는 글자 열을 의미 있는 낱말인 토큰으로 자른다. 숫자 10, 이름 rate, 연산자 *, 괄호처럼 종류를 붙이고, 공백과 주석은 버린다. 토큰의 모양은 정규 표현식으로 적을 수 있어서 유한 오토마타로 빠르게 처리한다.

글자 열 2 + 3 * (x - 1)가 토큰 9개로 잘린 그림글자 열2·+·3·*·(x·-·1)토큰2숫자+연산자3숫자*연산자(여는 괄호x이름-연산자1숫자)닫는 괄호
렉서. 글자 15개(·는 공백)가 토큰 9개가 된다. 파서는 이제 글자가 아니라 토큰만 본다. 토큰은 tokenize 함수로 잘랐다.

파서(구문 분석기)는 토큰 열이 문법에 맞는지 확인하며 구조를 트리로 만든다. 처음 만든 구문 트리에서 괄호·세미콜론 같은 군더더기를 걷어낸 것이 AST(추상 구문 트리)이고, 이후 단계는 모두 AST를 다룬다. 산술식에서 핵심은 우선순위와 결합 방향이다. 2 + 3 * 4는 곱셈이 먼저 묶여 14이고, 8 - 3 - 2는 왼쪽부터 묶여 3, 거듭제곱 2 ^ 3 ^ 2는 오른쪽부터 묶여 512다.

손으로 짜는 파서는 대개 재귀 하강 방식이다. 문법 규칙 하나를 함수 하나로 만들고, 규칙이 다른 규칙을 부르면 함수가 함수를 부른다(재귀와 호출 스택은 알고리즘 › 기초). 이항 연산자의 우선순위는 우선순위 오름(precedence climbing, Pratt 파서와 같은 계열)으로 깔끔하게 처리한다. 왼쪽 피연산자를 읽은 뒤, 다음 연산자의 우선순위가 지금 기준 이상이면 오른쪽을 더 높은 기준으로 재귀해 읽어 묶는다. GCC·Clang·V8도 손으로 짠 재귀 하강 파서를 쓴다. 문법 파일에서 파서를 자동으로 만들어 주는 도구(yacc·Bison, ANTLR)도 있다.

식 2 + 3 * (x - 1)의 AST가 잎부터 한 노드씩 만들어지는 단계 그림2 + 3 * (x - 1)2+3*x-1(2 + (3 * (x - 1))) — x = 5이면 14
1 / 9

토큰 열 2 + 3 * ( x - 1 )을 왼쪽부터 읽는다. 노드는 피연산자가 다 갖춰져야 만들어진다.

파싱. "2 + 3 * (x - 1)"를 우선순위 오름(precedence climbing) 파서로 읽어 AST를 만든다. 색칠된 노드가 지금 단계에서 만들어지거나 완성된 것이다.

타입 시스템

타입은 값에 붙은 "무엇으로 쓸 수 있는가"의 약속이다. 정수에 문자열을 더하거나, 없는 메서드를 부르는 실수를 잡는다. 두 축으로 나눠 보면 정리가 쉽다.

  • 정적 vs 동적: 타입 검사를 컴파일할 때 하느냐, 실행하면서 값마다 하느냐. 정적 타입은 실행 전에 오류를 잡고 컴파일러가 더 빠른 코드를 만들 근거가 된다. 동적 타입은 짧고 유연하게 쓸 수 있지만 오류가 그 줄이 실행될 때에야 드러난다.
  • 강함 vs 약함: 타입이 맞지 않을 때 오류를 내느냐, 알아서 변환하느냐. 공식 정의가 없는 정도의 문제라 "어느 쪽에 가깝다" 정도로만 말한다.
정적·동적과 강함·약함 두 축으로 나눈 네 칸에 언어를 놓은 그림컴파일할 때 검사실행할 때 검사변환 엄격변환 느슨정적 · 강함Rust · Haskell · Java · Go"1" + 1 → 컴파일 오류동적 · 강함Python · Ruby"1" + 1 → 실행 중 TypeError정적 · 약함C · C++(암시적 변환이 많음)int에 double을 넣으면 조용히 잘림동적 · 약함JavaScript · PHP"1" + 1 → "11", "2" * 3 → 6TypeScript는 정적 검사를 얹었지만 실행은 JavaScript 그대로다(타입은 지워진다)
두 축으로 놓은 언어들(대략의 자리). 위 줄(색칠)은 맞지 않는 타입을 섞으면 오류를 내고, 아래 줄은 조용히 변환한다.

정적 타입이라고 모든 타입을 적어야 하는 것은 아니다. 타입 추론은 컴파일러가 쓰임새에서 타입을 알아내는 것이다. ML·Haskell은 Hindley–Milner 추론으로 함수 전체의 타입까지 알아내고, Rust의 let, Kotlin의 val, C++의 auto, TypeScript는 주로 변수 하나의 초깃값에서 추론한다. 제네릭(매개변수 다형성)은 "어떤 타입 T에 대해서든"을 표현해 같은 코드를 타입마다 다시 쓰지 않게 한다.

중간 표현(IR)과 SSA

AST는 원래 언어의 모양을 너무 많이 닮았고, 기계어는 CPU에 너무 묶여 있다. 그 사이의 IR이 최적화의 작업대다. 흔한 꼴은 3주소 코드로, t1 = b * 2처럼 한 줄에 연산 하나, 피연산자 둘만 쓴다. 복잡한 식은 임시 변수로 쪼갠다. 코드는 분기 없이 이어지는 덩어리(기본 블록)와 그 사이의 화살표로 이뤄진 제어 흐름 그래프로 표현한다.

현대 컴파일러 대부분은 IR을 SSA(정적 단일 대입) 꼴로 둔다. 모든 변수가 딱 한 번만 대입되도록, 같은 변수에 다시 대입할 때마다 x₁, x₂처럼 새 이름을 붙인다. 갈래가 합쳐지는 곳에서는 어느 쪽에서 왔는지에 따라 값을 고르는 φ(파이) 함수를 둔다. 그러면 "이 값은 어디서 정의됐나"가 이름만으로 정해져, 상수 전파·죽은 코드 제거 같은 분석이 단순하고 빨라진다. LLVM IR과 GCC의 GIMPLE이 SSA다.

x에 두 번 대입하는 코드를 x₁, x₂, x₃와 φ 함수로 바꾼 SSA 형태와 비교보통 코드x = 1if (c) goto B1B0x = 2B1y = x + 1B2SSA 꼴x₁ = 1if (c) goto B1B0x₂ = 2B1x₃ = φ(x₁, x₂)y₁ = x₃ + 1B2
같은 코드를 SSA로 바꾸면 x에 대한 두 대입이 x₁, x₂로 갈리고, B2 입구의 φ가 B0에서 왔으면 x₁, B1에서 왔으면 x₂를 고른다.

최적화: 상수 접기와 죽은 코드 제거

최적화는 프로그램의 뜻(관찰 가능한 동작)을 바꾸지 않으면서 더 빠르거나 작게 만드는 변환이다. 하나하나는 단순한 규칙이고, 여러 패스를 차례로, 때로는 되풀이해 적용해 서로가 서로의 기회를 만든다.

  • 상수 전파·접기: 값이 이미 정해진 변수는 그 값으로 바꾸고, 피연산자가 모두 상수인 연산은 컴파일할 때 미리 계산한다.
  • 죽은 코드 제거: 결과가 어디에도 쓰이지 않는 계산, 절대 실행되지 않는 분기를 지운다. 뒤에서부터 "나중에 쓰이는 변수" 집합을 들고 거꾸로 훑는다.
  • 인라이닝: 작은 함수 호출을 함수 본문으로 바꿔 호출 비용을 없애고, 호출한 쪽 문맥에서 다른 최적화가 더 잘 되게 한다. 가장 효과가 큰 최적화로 꼽힌다.
  • 루프 최적화: 반복마다 같은 값인 계산을 루프 밖으로 빼고(불변 코드 이동), 여러 반복을 한 번에 처리하도록 펼치거나 SIMD 명령으로 바꾼다(벡터화).
다섯 줄짜리 코드가 상수 접기 뒤 값이 미리 계산되고, 죽은 코드 제거 뒤 한 줄만 남는 세 단계원래 코드a = 4b = a * 2c = b + 1d = x * ce = a + b① 상수 전파·접기a = 4b = 8c = 9d = x * 9e = 12② 죽은 코드 제거a = 4b = 8c = 9d = x * 9e = 12x는 실행할 때 들어오는 값, 결과로는 d만 쓰인다(return d)다섯 줄 → 1줄: 곱셈 하나만 남았고, 결과는 모든 x에서 같다
다섯 줄 코드에 상수 전파·접기를 하면 a·b·c·e가 상수가 되고(색칠), 이어서 죽은 코드 제거를 하면 쓰이는 d 계산만 남는다. 각 열은 최적화 함수의 실제 출력이다.

최적화는 언어 규칙이 허용하는 범위에서만 한다. C·C++에서 부호 있는 정수 넘침이나 배열 밖 접근 같은 정의되지 않은 동작은 "일어나지 않는다"고 가정해도 되므로, 그런 코드가 있으면 최적화 뒤 엉뚱한 결과가 나올 수 있다. 이것이 메모리 안전성 문제와 맞닿는 지점은 보안 › 시스템 보안에서 다룬다.

인터프리터, 바이트코드 VM, JIT

모든 언어가 미리 기계어로 컴파일되지는 않는다. 실행 방식은 시작 속도와 실행 속도 사이의 저울질이다.

  • 트리 인터프리터: AST를 재귀로 걸으며 노드마다 바로 계산한다. 만들기 가장 쉽지만 노드를 오갈 때마다 포인터를 따라가야 해서 느리다.
  • 바이트코드 VM: AST를 한 번 더 컴파일해 가상 기계의 명령 열(바이트코드)로 만들고, 명령을 하나씩 읽어 실행하는 루프로 돌린다. 명령이 메모리에 촘촘히 붙어 있어 트리보다 훨씬 빠르다. CPython·Lua·Java·.NET이 이렇게 시작한다. Python에서 dis 모듈로 바이트코드를 직접 볼 수 있다.
  • JIT(실행 중 컴파일): 바이트코드로 돌리면서 자주 도는 함수·루프를 찾아 그때 기계어로 컴파일한다. 실행 중에 실제로 들어온 타입을 보고 그 경우에 맞게 특수화할 수 있어서, 동적 언어도 빨라진다. 가정이 깨지면 최적화된 코드를 버리고 인터프리터로 돌아온다(역최적화). V8은 Ignition 인터프리터에서 시작해 Sparkplug·Maglev·TurboFan 순으로 점점 더 공들여 컴파일한다. CPython도 3.13에서 실험적 JIT를 들였다.
  • AOT(미리 컴파일): 실행 전에 기계어로 다 바꿔 둔다. 시작이 빠르고 실행 중 컴파일 비용이 없다. 대신 실행 중 정보를 쓸 수 없다.
트리 인터프리터, 바이트코드 VM, JIT, AOT 컴파일의 실행 경로와 예트리 인터프리터소스ASTAST를 걸으며 실행예: 초기 Ruby(1.8까지), 교재용 언어바이트코드 VM소스바이트코드VM 루프가 실행예: CPython, LuaJIT바이트코드자주 도는 부분 감지기계어로 번역·실행예: V8(JS), JVM HotSpot, .NETAOT 컴파일소스최적화기계어 파일예: C · C++ · Rust · Go
실행 방식 네 가지. 색칠한 칸이 실제로 계산이 일어나는 곳이다.

바이트코드 VM은 대개 스택 기계다. 피연산자를 스택에 쌓고, 연산 명령이 맨 위 둘을 꺼내 계산한 결과를 다시 쌓는다. AST를 후위 순서(왼쪽, 오른쪽, 연산)로 걸으며 명령을 내보내면 그대로 바이트코드가 된다. 레지스터 번호를 명령에 적는 레지스터 기계(Lua 5, Android의 Dalvik)는 명령 수가 적어 더 빠른 대신 명령이 길다.

바이트코드 7개를 실행하며 스택이 쌓였다 줄어드는 모습, 마지막 값 14PUSH 22PUSH 323LOAD x235PUSH 12351SUB234MUL212ADD14x = 5. 연산 명령은 맨 위 둘을 꺼내 계산하고 결과 하나를 넣는다. 색칠 = 스택 맨 위
앞의 AST를 스택 바이트코드 7개로 바꿔 x = 5로 실행했다. 열마다 그 명령을 실행한 뒤의 스택이다. 결과 14는 트리를 직접 계산한 값과 같다.

가비지 컬렉션

힙에 만든 객체는 언젠가 돌려줘야 한다. 가비지 컬렉터(GC)는 프로그램이 더는 쓸 수 없는 객체를 찾아 자동으로 돌려준다. "쓸 수 없다"를 판단하는 방법이 두 갈래다.

  • 참조 횟수: 객체마다 자기를 가리키는 참조 수를 세다가 0이 되면 바로 해제한다. 단순하고 해제가 즉시라 메모리 사용이 고르다. 하지만 서로를 가리키는 순환은 밖에서 아무도 안 가리켜도 1 이상에 머물러 영원히 새어 나간다.
  • 추적: 스택·전역 변수(루트)에서 출발해 화살표를 따라 닿는 객체만 살아 있다고 본다. 마크 앤 스윕은 닿는 객체에 표시(mark)한 뒤 힙 전체를 훑어(sweep) 표시 없는 것을 해제한다. 순환도 루트에서 닿지 않으면 함께 치운다.
루트에서 닿는 객체 A, B, E, C를 표시하고 닿지 않는 D, F, G를 해제하는 단계 그림루트A1B1C1D1해제E1F1해제G0해제참조 횟수 표시표시된 객체 = 살아 있음
1 / 6

힙의 객체 7개. 숫자는 들어오는 화살표 수(참조 횟수)다. 참조 횟수 방식은 0인 G만 해제하고, 서로를 가리키는 D·F는 1에 머물러 새어 나간다.

마크 앤 스윕. 프로그램이 지금 쓸 수 있는 객체는 루트(스택·전역 변수)에서 화살표를 따라 닿는 것뿐이다. 닿는 것을 모두 표시한 뒤 나머지를 해제한다.

단순한 마크 앤 스윕은 표시하는 동안 프로그램을 멈춰야 하고(stop-the-world), 쓸고 나면 빈칸이 흩어진다. 실제 GC는 여기에 몇 가지를 더한다. 살아남은 객체를 한쪽으로 옮겨 빈칸을 모으는 복사·압축, 프로그램과 나란히 표시하는 동시 GC(Go, Java의 ZGC·G1 일부), 그리고 가장 효과가 큰 세대별 GC다.

젊은 세대(새 객체 영역과 생존자 영역)와 늙은 세대, 그리고 승격 화살표젊은 세대 — 자주 치움(minor GC)새 객체 영역할당 = 포인터 하나 밀기생존자살아남은 것만 복사늙은 세대 — 드물게(major GC)오래 사는 객체캐시, 설정, 긴 자료구조몇 번 살아남으면 승격세대 가설: 대부분의 객체는 만들어지자마자 죽는다(임시 문자열, 반복 중 객체).젊은 세대를 치울 때는 산 것만 옮기면 되니, 죽은 객체가 많을수록 오히려 빠르다.늙은 → 젊은 참조는 쓰기 장벽(write barrier)으로 따로 기록해 둔다.
세대별 GC. 젊은 세대는 작아서 자주 치워도 빠르고, 대부분의 객체가 여기서 죽는다. Java의 G1, V8의 Orinoco, .NET이 이 구조다.

메모리 관리 방식 비교

메모리를 언제 누가 돌려주느냐는 언어 설계의 큰 갈림길이다. 실행 속도·멈춤 시간·안전성·쓰기 쉬움을 서로 다르게 맞바꾼다.

방식언어어떻게대가
수동 관리C, (예전) C++malloc으로 받고 free로 돌려준다. 언제 돌려줄지는 프로그래머가 정한다.실행 비용이 가장 적다. 대신 해제 후 사용·이중 해제·누수가 사람 실수로 생긴다.
참조 횟수Swift(ARC), CPython, C++ shared_ptr객체마다 가리키는 수를 세고 0이 되는 순간 해제한다.해제 시점이 바로라 예측하기 쉽다. 증감 비용이 늘 들고, 순환 참조는 따로 끊어야 한다(약한 참조, CPython의 순환 수거기).
추적 GCJava, C#, Go, JavaScript가끔 루트에서 닿는 객체를 찾아 나머지를 한꺼번에 치운다.순환도 문제없고 할당이 빠르다. 대신 메모리를 넉넉히 써야 하고, 언제 얼마나 멈출지 정하기 어렵다.
소유권Rust, C++ unique_ptr(관례)값마다 주인이 하나이고 주인이 범위를 벗어나면 해제된다. 컴파일러가 규칙을 검사한다.실행 비용은 수동 관리 수준이고 메모리 오류가 컴파일 단계에서 막힌다. 대신 규칙을 익히는 데 시간이 든다.

Rust의 소유권은 GC 없이 안전을 얻는 방법이다. 값마다 주인 변수가 하나이고, 대입하면 주인이 옮겨 가며(이동) 원래 변수는 쓸 수 없게 된다. 주인을 바꾸지 않고 잠깐 쓰려면 빌린다(&). 빌림은 "읽기 전용 여럿" 또는 "쓰기 하나"만 동시에 허용하고, 빌린 참조는 주인보다 오래 살 수 없다. 이 규칙을 컴파일러의 빌림 검사기가 확인하므로, 해제 시점이 코드 구조에 정해지고 해제 후 사용·데이터 경쟁이 컴파일 단계에서 막힌다.

스택의 a, b, r과 힙의 문자열. a는 무효가 되고 b가 주인, r은 빌린 참조다코드let a = String::from("hi");let b = a; // 이동let r = &b; // 빌림} // b가 범위를 벗어남 → 해제스택a (이동됨)b (주인)r = &b힙"hi"주인은 늘 하나. 빌림은 "읽기 여럿" 또는 "쓰기 하나" 가운데 하나만 동시에 허용.이 규칙을 컴파일러(빌림 검사기)가 확인하므로 해제 시점이 코드에 정해지고,실행 중 GC도, 해제 후 사용(use-after-free)도 없다.
소유권 이동과 빌림. b = a 뒤에는 a를 쓰면 컴파일 오류다. r은 b를 빌렸을 뿐이라 주인은 여전히 b이고, b의 범위가 끝나는 곳에서 해제 코드가 자동으로 들어간다.

링커와 로더

컴파일러는 보통 소스 파일 하나를 목적 파일(.o) 하나로 만든다. 목적 파일에는 기계어와 함께 "나는 main을 정의한다", "add와 printf가 필요한데 어디 있는지 모른다" 같은 기호 표가 들어 있다. 링커는 목적 파일과 라이브러리를 모아 필요한 기호를 정의한 곳과 짝짓고(기호 해결), 모든 코드와 데이터의 최종 주소를 정해 그 주소를 쓰는 자리를 고쳐 쓴다(재배치). 짝을 못 찾으면 익숙한 "undefined reference" 오류가 난다.

라이브러리를 실행 파일에 복사해 넣으면 정적 링크, 실행할 때 공유 라이브러리(.so·.dll·.dylib)를 찾아 잇게 하면 동적 링크다. 동적 링크는 여러 프로그램이 라이브러리 한 벌을 메모리에서 함께 쓰고 보안 패치를 한 곳에서 할 수 있지만, 배포 환경의 라이브러리 버전에 기대게 된다. Go와 Rust가 정적 링크를 기본으로 하는 이유다. 로더는 운영체제의 일부로, 실행 파일(Linux는 ELF, Windows는 PE, macOS는 Mach-O)을 읽어 주소 공간에 올리고 동적 라이브러리를 이은 뒤 시작 주소로 뛴다. 그 뒤의 이야기는 운영체제 노트의 프로세스·가상 메모리 절로 이어진다.

main.o, math.o, libc가 링커를 거쳐 실행 파일이 되고 로더가 메모리에 올리는 흐름main.o정의: main필요: add, printfmath.o정의: add필요: 없음libc정의: printf …공유 라이브러리링커기호 해결·재배치실행 파일ELF · PE · Mach-O로더메모리에 올림정적 링크: 라이브러리 코드를 실행 파일에 복사동적 링크: 실행할 때 로더가 공유 라이브러리를 이어 줌재배치: 함수·변수의 최종 주소를 코드 곳곳에 채워 넣음
링커와 로더. main.o의 미해결 기호 add는 math.o에서, printf는 C 라이브러리에서 찾는다.

스코프와 클로저

이름이 어느 변수를 가리키는지 정하는 규칙이 스코프다. 대부분의 언어는 렉시컬 스코프를 쓴다. 함수가 어디서 불렸는지가 아니라 코드에서 어디에 쓰였는지로 정해지므로, 안쪽 함수는 바깥 함수의 지역 변수를 볼 수 있고, 이것은 파서가 코드 구조만 보고 정할 수 있다. 컴파일러의 의미 분석 단계가 이름마다 어느 선언인지를 이 규칙으로 묶어 둔다.

클로저는 함수와 그 함수가 만들어질 때의 바깥 환경을 함께 묶은 것이다(MDN). 바깥 함수가 이미 끝났는데도 안쪽 함수가 바깥 변수를 계속 읽고 쓸 수 있다. 문제는 지역 변수가 원래 스택에 있어 함수가 끝나면 사라진다는 점이다. 그래서 구현은 붙잡힌 변수를 스택 밖으로 옮긴다. Crafting Interpreters 25장의 바이트코드 VM은 붙잡힌 변수를 업밸류(upvalue)라는 간접 칸으로 가리키다가, 변수가 스택을 떠나는 순간 그 값을 힙으로 옮겨 닫는다. 대부분의 지역 변수는 그대로 빠른 스택에 두고, 붙잡힌 것만 대가를 치르는 방식이다.

  • 루프 안의 클로저: JavaScript에서 var로 선언한 반복 변수는 루프 전체에 하나뿐이라, 반복마다 만든 콜백이 모두 같은 변수(마지막 값)를 본다. 반복마다 새 바인딩을 만드는 let을 쓰면 콜백마다 자기 값을 갖는다. MDN이 흔한 실수로 꼽는 예다.
  • 클로저와 메모리: 클로저가 살아 있는 동안 붙잡힌 바깥 변수도 살아 있다. 이벤트 리스너에 큰 객체를 붙잡은 클로저를 걸고 떼지 않으면, 그 객체는 GC가 치우지 못한다(아래 흔한 오해).
  • 인자 전달: 값을 복사해 넘기는 값 전달과, 호출한 쪽 변수 자체의 별칭을 넘겨 함수가 그 변수를 바꿀 수 있는 참조 전달(C++의 & 매개변수)이 있다. Python 문서는 인자가 대입으로 전달된다고 적는다. 객체를 가리키는 참조가 복사돼 넘어가므로, 함수 안에서 리스트를 고치면 밖에서도 보이지만 매개변수에 새 리스트를 대입해도 바깥 변수는 바뀌지 않는다(Python FAQ). 참조 전달이 아니다.

흔한 오해

  • "컴파일 언어와 인터프리터 언어가 따로 있다": 컴파일과 인터프리트는 언어가 아니라 구현의 성질이다. Crafting Interpreters는 이를 과일과 채소에 빗댄다. 둘 다인 것도 있다. CPython은 사용자에게는 소스를 바로 실행하는 인터프리터지만 안에서는 바이트코드로 컴파일한다(2장). 위 실행 방식 절처럼 JavaScript도 인터프리터와 JIT를 오간다.
  • "GC가 있으면 메모리 누수가 없다": "이 메모리가 앞으로 필요한가"는 일반적으로 판정할 수 없는 문제라, GC는 "루트에서 닿을 수 있는가"로 근사한다(MDN 메모리 관리). 다시는 쓰지 않을 객체도 전역 캐시, 떼지 않은 리스너, 위의 클로저가 붙잡고 있으면 닿을 수 있으니 치우지 않는다. 캐시처럼 남이 쥐고 있는 동안만 의미 있는 연결은 WeakMap 같은 약한 참조로 둔다.
  • "정적 타입이면 강한 타입이다": 위 타입 시스템 절의 두 축은 서로 다르다. C는 정적이지만 암묵 변환이 많고, Python은 동적이지만 "1" + 1을 오류로 막는다.
  • "최적화 단계를 바꿔도 동작은 같다": 정의된 동작만 쓰는 코드라면 맞다. 정의되지 않은 동작이 있는 C·C++ 코드는 최적화 수준에 따라 결과가 달라질 수 있다(위 최적화 절).

핵심 자료

직접 만들어 보며 배우려면 Crafting Interpreters가 가장 좋은 출발점이다.

자료종류요지링크
Crafting Interpreters (Robert Nystrom)무료 교재같은 언어를 트리 인터프리터(Java)와 바이트코드 VM(C)으로 두 번 만든다. 렉서·파서·GC를 처음부터 직접 짜 볼 수 있다.craftinginterpreters.com
Compilers: Principles, Techniques, and Tools (용 책, 2판)교재렉서·파서 이론부터 데이터 흐름 분석·코드 생성까지 다루는 고전 교재. 이 페이지는 저자들이 둔 보조 자료 모음이다.Stanford
LLVM Language Reference Manual공식 문서Clang·Rust·Swift 등이 공통으로 쓰는 SSA 기반 IR의 명세. phi 명령과 타입 체계를 직접 볼 수 있다.llvm.org
LLVM's Analysis and Transform Passes공식 문서상수 전파·죽은 코드 제거·인라이닝·루프 최적화 등 실제 최적화 패스 목록과 한 줄 설명.llvm.org
The Garbage Collection Handbook교재참조 횟수, 마크 앤 스윕, 복사, 세대별, 동시·실시간 GC를 체계적으로 정리한 표준 참고서.gchandbook.org
The Rust Programming Language — What is Ownership?공식 교재소유권 이동·빌림·수명을 예제로 설명한다.rust-lang.org