What I learned about programming languages
2025-10-01
최근에 학부 시절 배웠던 프로그래밍언어를 다시 공부하고 있어서 이번 기회에 실무 경험을 곁들여 다시 정리해보고자 합니다. 당시에는 다소 추상적으로만 알고 넘어갔던 개념들이 현업에서 직접 코드를 다루다 보니 다른 의미로 다가오더군요. 이번 글에서는 프로그래밍 언어를 구분하는 방법을 다시 짚어보고 본격적으로 JavaScript에 대해 알아보겠습니다.
우리가 흔히 언어를 분류할 때 쓰는 기준은 크게 두 가지가 있어요.
사실 저는 딱 "컴파일 언어는 빠르지만 번거롭고, 인터프리터 언어는 느리지만 편리하다" 라고만 기억하고 있었어요. 이번 기회에 더 자세히 알아보겠습니다.
최근 언어들은 하나의 패러다임/기법에만 갇히지 않고, 혼합적(Hybrid) 성격을 띄어요.
내용 정리는 여기까지 하고, 본격적으로 JavaScript에서 대해서 알아볼게요. 이번 주제에서는 1번, 자바스크립트의 프로그래밍 처리 기법에 대해서만 다뤄보겠습니다. 2번 프로그래밍 패러다임에 대해서도 언젠간 정리할 예정이에요.
같은 코드에 대해 C는 0ms, 파이썬은 40ms 이 걸리는 것을 간단하게 알 수 있어요.
JavaScript는 파이썬과 더불어 유명한 인터프리터 언어라고 알고 있지만 단순히 그렇게 설명하기에는 부족해요. 저희가 사용하는 JS는 결국에는 하이브리드 언어이거든요. 엔진(예: V8, SpiderMonkey)은 코드를 인터프리트하다가 반복 실행되는 영역을 감지하면 JIT 컴파일로 최적화해요. 덕분에 “빠르게 테스트 가능”하면서도 “실행 속도도 확보”하는 하이브리드 성격을 갖습니다.
This hybrid approach balances fast startup (interpreted) and fast execution (compiled).
자바스크립트를 제대로 이해하려면 언어레벨과 런타임레벨을 구분해야 해요.
1• 언어 레벨: ECMAScript 사양에 정의된 순수 계산 규칙. (ex. 변수, 함수, 클래스, 연산자 등)2• 런타임 레벨: 실행 환경이 제공하는 API. (ex. 브라우저의 DOM, Node.js의 fs, console.log 등)
순정 JavaScript 🪽 자체는 입출력조차 정의하지 않아요. (콘솔로그도 안됨!) 실제 애플리케이션을 만들 수 있는 건 호스트 환경(Host Environment) 덕분입니다. 이 호스트 환경안에 있는 인터프리터가 실제 코드를 실행해요.
런타임은 인터프리터이지만 요즘 엔진에는 JIT 컴파일러가 내장되어 있어요. 그래서 JS는 인터프리터 언어이자 컴파일도 지원하는 언어라는 말이 있는거죠.
1--- 빌드타임 ---2TypeScript 코드3↓ ① 트랜스파일 (tsc)4--- 런타임 ---5JavaScript 코드6↓ ② 파싱 (엔진)7AST (추상 구문 트리)8↓ ③ 바이트코드(IR) 변환9Bytecode10↓ ④ 인터프리트 실행 + JIT 최적화11Machine Code (최적화됨)
고로 런타임은 아래 3단계를 거쳐 실행됩니다.
이게 무슨 뜻인지는 아래 간소화된 처리과정 예시를 따라가며 조금씩 알아보겠습니다.
1const hello: string = 'hello';2console.log(hello);
빌드타임
목적: 최신 문법(ESNext)을 구형 브라우저나 런타임에서도 동작하게 함. 타입 정보를 떼어볼게요.
1const hello = 'hello'; // transpiled via tsc, 타입 정보가 떼짐2console.log(hello);
런타임
시작), 추상 구문 트리(Abstract Syntax Tree)로 파싱해요.목적: 소스 코드를 다음 단계(엔진)을 위해 규격화하여 트리(문법 트리)로 표현. 엔진은 이 AST를 기반으로 다음 단계(코드 생성/해석)를 수행.
각 소스 코드는 먼저 Lexing(토큰화) → Parsing(문법 분석) 단계를 거쳐 AST로 변환돼요.
이 AST는 이후 바이트코드를 생성하는 기반 자료구조가 됩니다.
1{2"type": "Program",3"body": [4{5"type": "VariableDeclaration", // const hello = 'hello';6"kind": "const",7"declarations": [8{9"type": "VariableDeclarator",10"id": { "type": "Identifier", "name": "hello" },11"init": { "type": "Literal", "value": "hello" }12}13]14},15{16"type": "ExpressionStatement", // console.log(hello);17"expression": {18"type": "CallExpression",19"callee": {20"type": "MemberExpression",21"object": { "type": "Identifier", "name": "console" },22"property": { "type": "Identifier", "name": "log" }23},24"arguments": [{ "type": "Identifier", "name": "hello" }]25}26}27]28}
AST --> 엔진 내부 코드 생성기(Code Generator) --> 바이트코드(일종의 IR) 로 변환. 여기서 IR(Intermediate Representation)은 엔진이 기계어로 바로 변환하기 전에 사용하는 중간 형태를 말해요. (V8에서는 “Ignition 바이트코드”가 이 역할을 함)
목적: AST를 더 가볍고 실행하기 좋은 명령어열(바이트코드)로 바꿔 인터프리터가 바로 실행할 수 있도록 한다. pseudo-bytecode:
10 LOAD_CONST "hello" ; literal "hello" 을 스택에 올림21 STORE_NAME "hello" ; 스택 탑을 변수 hello에 저장32 LOAD_GLOBAL "console" ; 전역 객체 console 로드43 LOAD_ATTR "log" ; console.log 함수 획득54 LOAD_NAME "hello" ; 변수 hello 의 값 로드65 CALL_FUNCTION 1 ; 함수 호출 (1개의 인자)76 RETURN ; 종료
바이트코드가 생성됐으니 이제 인터프리터가 읽을 수 있어요. 그런데 요즘 엔진은 똑똑해서 자주 호출되는 부분을 컴파일해서 더 빠르게 실행해요.
목적: 런타임 프로파일러를 통해 성능을 높인다
이 단계는 아래에서 조금 더 설명하고, 일단 넘어갈게요. 여러번 불리는 영역이라 판단되어 최적화를 진행했다고만 이해해주세요.
1# 최적화된 pseudo assembly2MOV R0, "hello" # 리터럴을 레지스터에 바로 적재3CALL builtin_console_log(R0) # console.log가 빌트인이라면 런타임이 이것을 바로 호출하도록 인라인 처리 가능4RET
hello
자체를 레지스터에 넣었어요.console.log()
를 하나씩 탐색하는게 아니라 바로 (인라인으로) 호출하도록 탐색 경로를 캐싱했어요.목적: 더 빠르게 실행
Hot하지 않은 코드는 여전히 인터프리터로 실행
Hot한 코드는 JIT가 생성한 네이티브 머신코드를 엔진 내 코드 캐시에서 찾아서 바로 실행
4번으로 돌아가서 최적화 단계에 대해 좀 더 알아보겠습니다.
Just-In-Time Compiling
JIT 컴파일은 (Wikipedia) JavaScript고유 특성이 아니에요. 다른 언어에서도 사용됩니다. Runtime compilation, dynamic compilation 등 다양한 용어로 불려요.
전통적 컴파일러는 실행 전 전체코드를 컴파일해서 실행하지만, JIT 컴파일러는 코드를 실행 (인터프리트) 하면서 특정 코드 영역을 실행하기 전 그 코드를 컴파일해서 실행해요.
가장 유명한 JIT 컴파일러는 V8의 Turbofan 입니다. 아래는 개념적 흐름이에요.
코드가 여러 번 실행되면 런타임 프로파일러가 “핫”한 경로를 감지하고, JIT(예: V8의 Turbofan)이 고성능 네이티브 머신코드로 컴파일해 다음에는 바로 사용할 수 있도록 코드캐시에 저장해두어 성능을 올리는 방법이에요.
좋긴 한데 타입 안정성이 필요해요. 만약 들쑥날쑥하거나 내부에서 try/catch, eval등은 오히려 deoptimization 될수도 있기 떄문입니다.
One challenge with JIT optimization is that JavaScript is dynamically typed. V8 might optimize code under certain assumptions (e.g. this variable is always an integer). If a later call violates those assumptions (say the variable becomes a string), the optimized code is invalid. V8 then performs a deoptimization: it falls back to a less optimized version (or re-generates code with new assumptions). This mechanism relies on "inline caches" and type feedback to quickly adapt. The existence of deopt means sometimes peak performance isn't sustained if your code has unpredictable types, but generally V8 tries to handle typical patterns (like a function consistently being passed the same type of object).
네 가능해요. Giving V8 a Heads-Up: Faster JavaScript Startup with Explicit Compile Hints
파일 상단에 //# allFunctionsCalledOnLoad
주석을 달면 모든 함수가 컴파일됩니다.
메모리 효율성을 위해 당연히 무분별한 사용은 지양해야 해요.
아직 함수별로 컴파일 요청할 수 있는 방법은 없으나 작업중이라고 합니다.
JIT 컴파일 과정을 조금 더 자세히 살펴보겠습니다.
1function add(a, b) {2return a + b;3}45for (let i = 0; i < 100000000; i++) {6// 1억번 실행 with different inputs7add(i, i + 1); // 핫한 함수8}
처음 실행할때는 인터프리터가 add()
함수를 실행해요.
근데 몇(천)번 더 돌면🔥 핫하다고 판단하고 컴파일하자고 할거에요. 함수 호출 자체가 오버헤드는 맞으니까요
그러면 JIT 컴파일러가 개입해서 add()
를 머신코드로 컴파일해요.
그 다음부터는 머신코드로 실행되겠죠.
JIT 최적화 몇가지 예시들입니당
1for (let i = 0; i < 100000000; i++) {2// add(i, i+1)를 호출하지 않고, 내부 로직을 직접 대체해요.3i + (i + 1);4}
1const x = 2 * 3 + 4; // 2*3+4는 상수 표현식 -> x = 10;으로 미리 계산해둠 (런타임에 안하도록)2console.log(x);
실행 결과에 영향을 주지 않는 코드를 찾아서 없애는 최적화입니다.
트랜스파일링 | 컴파일링 |
---|---|
같은 추상화 단계에 유지 | 다른 추상화 단계로 변환 |
- Typescript -> JavaScript (babel 혹은 tsc) - JSX, React -> JavaScript (babel) - 다운그레이드 (폴리필 등) | JavaScript -> 기계어 |
따라서 우리는 Javascript를 실행할때 한줄 실행이 아닌, 여러 단계의 트랜스파일링과 컴파일링을 거쳐 실행된다는 것을 알 수 있어요.
References
To study:
이 글의 한계: