최근 직장 동료들과 주 1회 스터디를 통해 "우아한 타입스크립트 with React" 책을 완독 했다.
이후 스터디 내용들을 복습하고 다시 정리할 기회를 가질 예정이지만, 그에 앞서 왜 타입스크립트를 사용하는지, 그리고 타입스크립트가 어떻게 작동하고 있는지에 대한 포스팅을 먼저 작성해보려고 한다.
왜 타입스크립트를 쓰게 되었는가?
자바스크립트(JS)가 웹 브라우저에서 실행되는 동적 프로그래밍 언어로, HTML 및 CSS와 함께 웹 개발의 핵심 요소 중 하나인 것은 널리 알려져 있는 사실이다. 덕분에 개발자들은 웹 환경에서 문서 객체 모달(DOM, Document Object Model)을 조작하여 사용자 상호 작용을 처리하고 비동기적으로 서버와 데이터를 주고받는 등의 다양한 기능을 구현할 수 있었다.
문제는 이 "동적 프로그래밍 언어"에 있는데, 이 말은 변수에 타입을 명시적으로 지정하지 않고 코드가 실행되는 런타임에 변숫값이 할당되어 해당 값의 타입에 따라 변수 타입이 결정된다는 것을 의미한다.
동적으로 데이터의 타입이 추론되는 것은 개발에 있어서는 편리할 수 있지만, 실제 프로그램을 사용하는 데 있어서는 문제가 될 소지가 있다.
let value = 1 + '1'; // 결과: '11'
위 코드는 자바스크립트를 배우게 되면 가장 흔히 볼 수 있는 문제가 되는 코드로, 자바스크립트에서는 숫자와 문자열을 혼동해서 사용해도 하나의 타입으로 통일하여 추론해버리기 때문에 개발 상에서는 문제가 발생하지 않았다. 하지만 실제 사용자에게는 혼동을 가져올 수 있기 때문에 당연히 문제가 되는 코드다.
즉, 지나치게 관대한 타입 추론으로 인해 기계 입장에서는 정상적이지만 사람 입장에서는 정상적이지 않은 코드가 있는 상황이 발생하고 있다.
사과 "5kg"짜리 박스를 "40000원"에 "2개" 구매했는데 "400002원"으로 결제 금액이 발생하면 이상하지 않겠는가?
컴파일타임과 런타임
기계 입장에서 말이 되지만 사람 입장에서 말이 되지 않는 경우 위와 같은 문제가 발생하는 것을 알 수 있다.
개발자들은 보통 이러한 경우에 "컴파일타임에서는 문제가 없었는데, 런타임에서는 문제가 발생했다"라고 말한다.
먼저 프로그래밍 언어는 고수준(high-level) 언어와 저수준(low-level) 언어로 나뉜다. 고수준 언어는 사람이 이해하기 쉬운 형식으로 작성된 프로그래밍 언어를 뜻하고, 저수준 언어는 컴퓨터가 이해하기 쉬운 형식으로 작성된 언어를 뜻한다.
이렇듯 프로그래밍 언어, 즉 소스코드를 분리하고 이해하고 해석하는 데에는 두 가지 방식이 있기 때문에 각각 다른 "시점"이 존재한다.
먼저 소스코드가 컴파일러에 의해 번역되는 시점을 컴파일타임(compile time)이라고 한다. 즉, 소스코드가 컴파일 과정을 거쳐 컴퓨터가 인식할 수 있는 기계어 코드로 번역되어 실행할 수 있는 프로그램이 되는 과정 자체를 의미한다.
컴파일 타임을 거쳐 프로그램이 실제로 실행되는 시점을 런타임(run time)이라고 한다. 컴파일이 완료된 후, 프로그램이 메모리에 로드되고 실행되는데, 실제로 프로그램이 동작하며 사용자 입력을 받고, 파일을 읽거나 쓰고, 네트워크 요청을 처리하는 등 모든 작업이 이루어지는 과정을 런타임이라고 부른다.
타입스크립트의 컴파일
위 개념들을 숙지했을 때, 우리는 일반적으로 고수준 언어에서 저수준 언어로 변환되는 과정을 컴파일 단계라고 할 수 있다.
여기서 재밌는 점이 있다.
타입스크립트는 자바스크립트의 모든 기능을 포함하면서, 추가로 정적 타입(type)을 정의할 수 있는 기능을 제공하는 언어이다. 이를 위해 타입스크립트는 tsc(TypeScript Compiler)라고 불리는 컴파일러를 통해 자바스크립트 코드로 변환된다.
뭔가 이상하지 않은가?
고수준 언어(타입스크립트)가 또 다른 고수준 언어(자바스크립트)로 변환되는 과정인데 "컴파일"이라니?
사실 tsc는 컴파일러라고 명명되어 있지만, 한 프로그래밍 언어로 작성된 코드를 유사한 수준의 다른 언어로 변환하는 도구인 트랜스파일러(Transpiler)라고 부르는 것이 좀 더 명확한 지칭이 맞다.
하지만 TypeScript의 기능 자체가 원래 코드 오류를 사전에 방지하고 타입을 검사하는 역할을 수행하기 때문에, "변환"하는 과정에 대한 시점으로는 컴파일러라고 불러도 괜찮지 않을까 싶기도 하다.
tsc는 어떻게 작동하는가?
tsc의 작동원리는 사실 꽤나 단순하다.
.ts 확장자가 붙은 파일을 찾아내서 컴파일하고, .js 확장자가 붙은 자바스크립트 파일을 만들어내는 과정이 전부다.
하지만 이는 단순히 봤을 때의 얘기고, 우리는 이 과정을 좀 더 자세하게 뜯어볼 예정이다.
위 과정은 타입스크립트 컴파일러가 소스코드를 컴파일하여 프로그램이 실행되기까지의 모든 과정을 요약한 그림이다.
그림을 통해 알 수 있듯, 타입스크립트 소스코드는 1~2단계에서만 사용되고, 최종적으로 만들어지는 프로그램에는 아무런 영향을 주지 않는다.
그런데 여기서 말하는 AST란 게 뭘까?
AST(Abstract Syntax Tree)란?
AST란 직역 하자면 "추상 구문 트리"라고 부르는데, 코드의 구문적 요소(syntax element)를 노드로 표현한 데이터를 말한다.
이름에서 알 수 있듯 노드는 트리 구조로 표현되고, 트리의 각 노드(Node)는 코드의 특정한 구문적 요소를 포함한다. 코드의 구문적 요소는 변수 선언, 함수 호출, 조건문, 반복문 등이 해당하는데, 컴파일러는 코드에서 이러한 구문적 요소들을 분석해 각각을 트리의 노드로 변환시키는 역할을 맡고 있다. 자세한 구문적 요소는 다음 링크를 참조하자.
이해를 위해 다음 예시 코드를 통해 알아보자.
let x: number = 5;
위 코드는 다음과 같은 구문적 요소로 분해할 수 있다:
- 변수 선언 (VariableDeclaration, 즉 let)
- 변수 이름 (Identifier, 즉 x)
- 타입 주석 (TypeAnnotation, 즉 number)
- 할당 (AssignmentExpression)
- 리터럴 값 (Literal, 즉 5)
이처럼 구문적 요소는 작은 단위의 소스 코드에서 코드를 더 작은 조각으로 나눌 때 사용하는 단위로, 해당 소스 코드에서 코드 블록들이 정확히 의미하는 바가 무엇인지를 의미한다고 볼 수 있다.
이 구문적 요소는 다음과 같이 AST의 노드로 표현된다.
VariableDeclaration
├── Identifier (name = "x")
├── TypeAnnotation (type = "number")
└── Literal (value = 5)
이 트리 구조에서 상위 노드는 하위 노드들을 포함하며, 이러한 노드들의 계층적 관계는 코드의 구문적 구조를 그대로 반영한다.
그렇다면 "노드들의 계층적 관계"란 무엇을 말하는 걸까?
AST는 트리 구조를 가지므로 계층적(hierarchical) 관계를 통해 코드의 구조적 요소들을 표현한다고 했다. 이 때문에 컴파일러는 코드의 구문을 쉽게 구분하고 이해가 가능하다고 하는데, 어떻게 계층적 관계를 표현하는 걸까?
앞선 예시에서 알 수 있듯이, 어떠한 구문적 요소들은 상위 노드(parent node)로, 어떤 구문 요소는 하위 노드(child node)로 구분된다.
조금 더 복잡한 예시를 통해 알아보자.
function greet(name: string) {
console.log("Hello, " + name);
}
greet("World");
위 코드는 다음과 같이 AST로 표현될 수 있다.
FunctionDeclaration
├── Identifier (name = "greet")
├── ParameterList
│ └── Parameter (name = "name", type = "string")
├── BlockStatement
│ └── ExpressionStatement
│ └── CallExpression
│ ├── MemberExpression (object = "console", property = "log")
│ └── BinaryExpression
│ ├── Literal (value = "Hello, ")
│ └── Identifier (name = "name")
CallExpression
├── Identifier (name = "greet")
└── Literal (value = "World")
이 AST를 통해 컴파일러는:
- FunctionDeclaration이 함수 선언을 의미
- 함수의 파라미터 리스트와 함수 본문(BlockStatement)이 존재
- 함수 본문 안에서 console.log가 호출되는 것을 알 수 있음
위 세 가지 내용을 도출할 수 있다.
이러한 계층적 구조 덕분에 컴파일러는 어떤 코드가 어떤 구문에 속해 있는지, 어떤 코드가 어떤 작업을 수행하는지 쉽게 이해할 수 있다.
이러한 구문적 요소들을 통한 표현은 생각보다 강력한데, 조금 더 복잡한 예시를 통해 알아볼 수 있다.
let result = add(2, multiply(3, 4));
컴파일러는 위 코드를 다음과 같이 AST로 표현한다.
VariableDeclaration
├── Identifier (name = "result")
└── CallExpression (callee = "add")
├── Literal (value = 2)
└── CallExpression (callee = "multiply")
├── Literal (value = 3)
└── Literal (value = 4)
이 과정을 통해 CallExpression 노드를 통해 컴파일러는 add 함수가 호출되었음을 알고, 그 인자로 2와 multiply(3, 4)가 전달된 것을 파악할 수 있다. 이 구조는 구문적 관계를 명확히 보여주며, 함수의 중첩된 호출 관계까지 쉽게 해석이 가능함을 알 수 있다.
왜 AST를 쓰는가?
AST가 유용한 구조임은 알 수 있었다. 하지만 왜 컴파일러에서 AST를 쓰는지에 대해 좀 더 명확하게 답해보자.
1. 코드 최적화 (Code Optimization)
AST를 활용한 코드 최적화는 보통 불필요한 코드 제거, 반복적인 코드 병합, 또는 성능 개선 등의 작업을 포함한다.
이러한 과정은 AST 상에서 특정 구문을 찾고, 변형하거나 제거하는 방식으로 진행된다.
다음 예시를 통해 알아보자.
let result = 3 + 4 * 2;
위 코드는 다음과 같이 변환된다.
VariableDeclaration
├── Identifier (name = "result")
└── BinaryExpression (operator = "+")
├── Literal (value = 3)
└── BinaryExpression (operator = "*")
├── Literal (value = 4)
└── Literal (value = 2)
이때, 컴파일러는 4 * 2 = 8이라는 계산을 수행할 수 있고, 이어서 3 + 8 = 11이라는 결과를 도출할 수 있다. 최종적으로 최적화된 코드는 다음과 같이 변경된다.
let result = 11;
자연스럽게 AST의 길이도 줄어들게 된다.
VariableDeclaration (kind = "let")
└── VariableDeclarator
├── Identifier (name = "result")
└── Literal (value = 11)
여기서 중요한 점은 두 AST는 다른 트리라는 것이다. 첫 번째 AST는 수식의 구조와 연산 순서에 집중하며, 선언 방식(let, const, var)을 구체적으로 포함하지 않았다. 두 번째 AST는 최적화와 평가가 완료된 최종 구조로, let과 같은 구체적 선언 유형(kind)을 포함하여 최적화 후 실행 가능한 코드를 표현한다.
AST를 다시 AST로 표현한다?
앞선 과정에서는 타입스크립트를 AST 변경했고, AST는 다시 자바스크립트로 표현된 후 해당 자바스크립트를 다시 AST로 변경했다. 이처럼 AST로 표현하는 과정은 여러 번에 걸쳐 최적화를 할 때도 사용하는 데이터 구조임을 잊지 말자.
2. 죽은 코드 제거(Dead Code Elimination)
죽은 코드란 실행되지 않는 코드나 사용되지 않는 변수를 말한다. 인간이 개발하는 과정을 거치다 보니, 개발 과정에서 삭제되지 않은 불필요한 코드들도 이러한 최적화 과정에서 삭제할 수 있다.
예를 들어,
function calculate() {
let x = 10;
let y = 20;
return x * 2;
}
라는 코드가 있다면 AST로 다음과 같이 변환된다.
FunctionDeclaration
├── Identifier (name = "calculate")
└── BlockStatement
├── VariableDeclaration (name = "x", value = 10)
├── VariableDeclaration (name = "y", value = 20) // 사용되지 않음
└── ReturnStatement
└── BinaryExpression (x * 2)
컴파일러는 y 변수가 AST에 있지만, 그 변수가 사용되지 않았다는 것을 인식하고 VariableDeclaration 노드 중에서 y에 해당하는 부분을 제거한다. 코드는 다음과 같이 변경될 것이다.
function calculate() {
let x = 10;
return x * 2;
}
이는 AST로 표현하면 다음과 같다.
FunctionDeclaration (name = "calculate")
├── Identifier (name = "calculate")
├── BlockStatement
│ ├── VariableDeclaration (kind = "let")
│ │ └── VariableDeclarator
│ │ ├── Identifier (name = "x")
│ │ └── Literal (value = 10)
│ └── ReturnStatement
│ └── BinaryExpression (operator = "*")
│ ├── Identifier (name = "x")
│ └── Literal (value = 2)
3. 코드 변환 (Code Transformation)
코드 변환은 특정 구문을 다른 구문으로 바꾸는 작업으로, 컴파일러가 더 최신 버전의 JavaScript로 변환하거나, 다른 환경에 맞게 코드를 변경하는 데 주로 사용된다.
예를 들어, 최신 ECMAScript 문법을 구형 브라우저에서도 실행 가능한 코드로 변환할 때 이러한 작업이 필요하다.
ECMAScript 6에서는 let과 const라는 새로운 변수 선언 키워드가 도입되었지만, 구형 브라우저는 이를 지원하지 않는다. TSC는 트랜스파일링 과정에서 AST의 VariableDeclaration 노드 중 let을 var로 변경해 준다.
4. 코드 병합 (Inlining) 최적화
코드 병합(인라이닝)은 함수 호출 등의 작업을 생략하고 그 자리에 함수의 본문을 직접 넣는 방식으로, 함수 호출로 인한 오버헤드를 줄일 수 있다.
다음 예시를 통해 살펴보자.
function double(x: number) {
return x * 2;
}
let result = double(5);
AST는 double(5) 함수 호출을 CallExpression으로 표시한다.
CallExpression
├── Identifier (name = "double")
└── Literal (value = 5)
컴파일러는 double 함수가 매우 간단하고 한 번만 호출되는 경우, 이를 인라인으로 변경할 수 있다.
즉, 위 함수 선언과 호출 과정을 한 줄의 코드로 정리할 수 있다.
let result = 5 * 2;
5.루프 언롤링 (Loop Unrolling)
루프 언롤링은 반복문을 최적화하여 반복 횟수를 줄이거나, 반복문을 전개하는 방식으로 성능을 향상하는 방법을 말한다.
예를 들어,
for (let i = 0; i < 3; i++) {
console.log(i);
}
위와 같이 콘솔창을 호출하는 단순한 반복문이 있다면, 이를 다음과 같이 최적화한다.
console.log(0);
console.log(1);
console.log(2);
이제 왜 AST를 쓰는지 알 것 같다.
AST는 구문적 구조를 정확하게 표현하여 이러한 다양한 코드 최적화 및 변환 작업을 가능하게 한다. 이를 통해 위와 같은 최적화 과정들을 수행할 수 있게 하는 것이다.
tsc는 어떻게 AST를 통해 타입을 검사하는 걸까?
앞서 여러 예시들을 통해 확인했을 때, 자바스크립트와 타입스크립트가 혼용되어 조금은 헷갈릴 수 있을 것 같다.
해당 과정을 다시 확인해 보자.
앞선 예시를 통해 우리는 AST의 표기 방식이 여러 가지일 수 있고, 이에 따라 포함하는 구문적 요소가 다를 수 있음을 알고 있다.
타입스크립트는 기존 소스 코드에서 우리가 선언한 타입들을 AST 변환 과정에서 추가적으로 기록한다. AST가 생성되면, 타입스크립트는 이 트리를 기반으로 타입 검사(type checking)를 수행한다. 이 과정은 크게 다음과 같은 순서로 이루어진다.
- 심볼 테이블 구축: 컴파일러는 AST를 순회하며 변수, 함수, 클래스 등 모든 심볼의 타입을 기록한다. 각 심볼의 타입은 AST에서 정의된 구문 구조에 따라 추론되거나 명시적으로 지정된 타입을 통해 얻어지게 된다.
- 타입 추론: 명시적 타입이 없는 변수나 함수의 경우, 컴파일러는 코드의 맥락을 바탕으로 타입을 추론한다. 예를 들어 let x = 5;와 같이 작성된 경우 x는 number 타입으로 추론된다.
- 타입 호환성 검사: AST에서 얻어진 타입 정보를 바탕으로 타입 간의 호환성을 검사한다. 예를 들어, number 타입에 string 값을 할당하려는 경우, 타입 오류로 간주된다.
이 과정에서 컴파일러는 AST를 트리 형태로 순회하면서 각 노드의 타입이 올바른지 검토하며, 문제가 있으면 타입 오류를 발생시킨다.
더 자세한 과정은 타입스크립트 컴파일러의 구조에 대해 알아볼 때 포스팅하도록 하겠다. (6장. 타입스크립트 컴파일 - 6.3. 타입스크립트 컴파일러의 구조)
결론
사실 "타입스크립트가 어떻게 작동하는가?"에 대한 완전한 답변은 되지 않았다고 생각한다. 타입스크립트 컴파일러는 어떻게 구성되어 있고, 어떻게 타입 검사를 하는지까지 포함하고 싶었지만 기존 포스팅에서 너무 내용이 길어지는 것 같아 따로 빼 두었다.
추후 "우아한 타입스크립트 with React"의 내용을 정리할 때, 우리는 왜 자바스크립트에서 기존 기능이 그대로인데 정적 타이핑을 지원하는 타입스크립트를 사용하는지, 그리고 왜 타입스크립트 컴파일러는 AST라는 데이터 구조를 활용하는지 먼저 생각해 볼 필요가 있다고 느껴 이번 포스팅을 통해 공유해 보았다.
한편으로는 우리가 사용하는 고수준 언어와 IDE가 얼마나 큰 편의성을 제공해주고 있는지 알 수 있는 기회였다고 생각한다. 과거에는 이러한 과정들을 직접 했을 우리 개발자 선조(?)님들에게 그저 감사할 따름이다.
참조
- https://dev.to/bilelsalemdev/abstract-syntax-tree-in-typescript-25ap
- https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L39
- https://basarat.gitbook.io/typescript/overview/ast
- https://velog.io/@chltjdrhd777/Typescript와-AST (https://ts-ast-viewer.com/#code/JYOwLgpgTgZghgYwgAgHJwLYoN4ChkHJgCeADigFzIDKYUoA5gNy4C+QA)
댓글