언어는 개발자와 컴퓨터 사이의 계약이다
프로그래밍 언어는 사람이 문제를 표현하기 쉽게 만든 약속이다. 하지만 컴퓨터는 if, class, async, try 같은 단어를 그대로 이해하지 못한다. 결국 코드는 CPU가 실행할 수 있는 명령어나, 어떤 실행기가 해석할 수 있는 중간 형태로 바뀌어야 한다.
개발자가 언어와 컴파일러의 기본 원리를 알아야 하는 이유는 단순하다. 우리가 보는 오류 메시지와 성능 차이의 상당수가 이 변환 과정에서 생기기 때문이다.
예를 들어 TypeScript 코드는 타입 검사를 거친 뒤 JavaScript로 변환되어 실행된다. Java 코드는 JVM 위에서 바이트코드로 실행되며, 자주 실행되는 부분은 JIT 컴파일러가 기계어로 바꾼다. Python 코드는 보통 CPython이 바이트코드로 바꾼 뒤 인터프리터가 실행한다. 겉으로는 모두 코드를 실행한다고 말하지만, 내부 경로는 다르다.
이 차이를 알면 다음과 같은 질문에 더 정확히 답할 수 있다.
왜 어떤 오류는 실행 전에 잡히고, 어떤 오류는 운영 중에 터지는가?
왜 같은 알고리즘이어도 언어에 따라 속도가 크게 다른가?
왜 클로저가 메모리를 계속 붙잡고 있는가?
왜 예외가 발생하면 호출한 함수들을 거슬러 올라가는가?
언어는 문법만이 아니라 실행 모델이다. 실행 모델을 이해하면 코드를 더 예측 가능하게 작성할 수 있다.
AI 생성 이미지
정적 타입과 동적 타입
타입은 값의 종류와 그 값에 허용되는 연산을 정하는 규칙이다. 숫자에는 덧셈을 할 수 있고, 문자열에는 길이를 구할 수 있다. 타입 시스템은 이런 규칙을 어디서, 얼마나 엄격하게 검사할지 결정한다.
먼저 정적 타입 언어는 프로그램 실행 전에 타입을 검사한다. Java, Kotlin, C, Go, Rust가 대표적이고, TypeScript처럼 JavaScript에 정적 타입 검사 계층을 더한 언어도 여기에 가깝게 이해할 수 있다.
int count = "10"; // 컴파일 오류Java 컴파일러는 문자열을 정수 변수에 넣을 수 없다는 사실을 실행 전에 잡는다. 그래서 많은 타입 오류가 배포 전에 발견된다. 대규모 코드베이스에서는 이 장점이 크다. 함수의 입력과 출력이 명확해지고, 리팩터링할 때 도구의 도움도 받기 쉽다.
반대로 동적 타입 언어는 실행 중 값이 실제로 사용될 때 타입을 확인한다. Python, Ruby, JavaScript가 대표적이다.
count = "10"
print(count + 1) # 실행 중 TypeErrorPython은 변수 count에 문자열을 넣는 것 자체는 허용한다. 하지만 문자열과 숫자를 더하려는 순간 오류가 발생한다. 동적 타입은 빠른 실험과 유연한 코드 작성에 유리하지만, 테스트가 부족하면 특정 실행 경로에서만 오류가 드러날 수 있다.
여기서 주의할 점이 있다. TypeScript는 정적 타입 검사를 제공하지만, 실행 시점에는 JavaScript다.
function add(a: number, b: number) {
return a + b;
}이 코드는 타입 검사 단계에서 a와 b가 숫자로 사용되는지 확인한다. 하지만 브라우저나 Node.js에서 실행되는 결과물에는 TypeScript 타입 정보가 대부분 사라진다. 또한 설정에 따라서는 타입 오류가 보고되어도 JavaScript 파일이 출력될 수 있다. 외부 API 응답, JSON 파싱 결과, 사용자 입력은 여전히 런타임 검증이 필요하다. 타입 시스템이 모든 현실 데이터를 자동으로 안전하게 만드는 것은 아니다.
강한 타입과 약한 타입
정적 타입과 동적 타입은 타입을 언제 검사하는가에 대한 구분이다. 강한 타입과 약한 타입은 서로 다른 타입 사이의 변환을 얼마나 조심스럽게 다루는가에 가깝다. 이 표현은 언어마다 엄밀한 기준이 조금씩 다르므로, 절대적인 라벨이라기보다 스펙트럼에 가깝게 보는 편이 안전하다.
Python은 동적 타입이지만 비교적 강한 타입 언어다.
"1" + 1 # TypeErrorPython은 문자열과 숫자를 마음대로 섞지 않는다. 개발자가 명시적으로 변환해야 한다.
int("1") + 1반면 JavaScript는 많은 상황에서 자동 형 변환을 수행한다.
"1" + 1 // "11"
"5" - 2 // 3
false == 0 // true이런 자동 변환은 짧은 코드에서는 편해 보일 수 있지만, 실무에서는 버그의 원인이 되기 쉽다. 특히 API 응답, 폼 입력, 쿼리 파라미터처럼 원래 문자열로 들어오는 값을 숫자처럼 사용할 때 문제가 자주 발생한다.
C 언어는 정적 타입 언어지만, 포인터 변환이나 메모리 재해석을 통해 타입 안전성을 쉽게 우회할 수 있다. 그래서 강한 타입이라는 말은 언어마다 맥락을 봐야 한다. 중요한 것은 라벨이 아니다. 개발자가 판단해야 할 질문은 다음이다.
타입 오류를 실행 전에 얼마나 잡을 수 있는가?
자동 형 변환이 예측 가능한가?
외부 입력은 타입 검증을 거치는가?
타입 시스템을 우회하는 코드가 있는가?
타입은 문서이면서 안전장치다. 그러나 안전장치는 신뢰 경계 밖에서 들어오는 데이터를 검증할 때 완성된다.
컴파일러가 코드를 이해하는 과정
컴파일러는 소스 코드를 실행 가능한 형태로 바꾸는 프로그램이다. 모든 컴파일러가 같은 구조는 아니지만, 큰 흐름은 비슷하다.
첫 단계는 어휘 분석이다. 긴 문자열인 소스 코드를 의미 있는 조각으로 나눈다. 이 조각을 토큰이라고 한다.
let total = price + tax;이 코드는 let, total, =, price, +, tax, ; 같은 토큰으로 나뉜다. 이 단계에서는 각 단어가 무엇인지는 구분하지만, 문장 전체가 올바른지는 아직 깊게 판단하지 않는다.
다음은 구문 분석이다. 토큰들이 언어 문법에 맞게 배열되어 있는지 확인하고, 트리 구조로 만든다. 이를 추상 구문 트리, 즉 AST라고 한다. 예를 들어 price + tax는 더하기 연산 노드와 양쪽 피연산자 노드로 표현될 수 있다.
그다음은 의미 분석이다. 문법적으로는 맞지만 의미적으로 잘못된 코드를 찾는다.
int total = price + name;문법만 보면 변수 두 개를 더하는 코드처럼 보인다. 하지만 price가 숫자이고 name이 문자열이라면 언어 규칙에 따라 오류가 될 수 있다. 선언되지 않은 변수를 사용했는지, 함수 호출 인자가 맞는지, 반환 타입이 맞는지도 이 단계에서 확인한다.
이후 컴파일러는 보통 중간 표현을 만든다. 중간 표현은 특정 CPU나 플랫폼에 너무 의존하지 않는 내부 코드 형태다. 컴파일러는 이 형태를 대상으로 최적화를 수행한다.
최적화는 같은 결과를 더 적은 비용으로 얻도록 코드를 바꾸는 과정이다. 예를 들어 부작용이 없고 반복문 밖으로 빼도 결과가 달라지지 않는 계산을 밖으로 이동하거나, 사용되지 않는 코드를 제거하거나, 함수 호출을 직접 코드로 펼칠 수 있다. 다만 최적화는 항상 마법처럼 동작하지 않는다. 코드 구조, 데이터 타입, 런타임 정보에 따라 가능한 최적화가 달라진다.
마지막은 코드 생성이다. 대상 환경이 이해할 수 있는 형태로 바꾼다. C 컴파일러는 특정 CPU의 기계어를 만들 수 있고, Java 컴파일러는 JVM 바이트코드를 만들며, TypeScript 컴파일러는 JavaScript 코드를 만든다.
이 흐름을 알면 컴파일 오류 메시지를 더 잘 읽을 수 있다. 오류가 토큰 단계에서 난 것인지, 문법 단계에서 난 것인지, 타입 검사 단계에서 난 것인지 구분할 수 있기 때문이다.
인터프리터, VM, JIT
언어 실행 방식은 크게 세 가지 관점으로 볼 수 있다. 미리 기계어로 번역해 실행하는 방식, 실행기가 코드를 해석하는 방식, 중간 표현을 가상 머신에서 실행하는 방식이다.
C나 Rust는 보통 소스 코드를 대상 플랫폼의 기계어로 미리 컴파일한다. 실행 파일은 특정 운영체제와 CPU 구조에 맞춰 만들어진다. 실행 시 별도 VM이 필요하지 않아 빠를 수 있지만, 플랫폼별 빌드가 필요하다.
Java는 다른 방식을 택한다. Java 소스 코드는 먼저 바이트코드로 컴파일된다. 바이트코드는 실제 CPU 명령어가 아니라 JVM이 이해하는 중간 명령어다. JVM은 이 바이트코드를 실행하면서 자주 실행되는 부분을 JIT 컴파일러로 기계어로 바꾼다.
JIT는 Just-In-Time의 약자로, 실행 중 필요한 시점에 컴파일한다는 뜻이다. 처음부터 모든 코드를 최적화하지 않고, 실제로 자주 실행되는 뜨거운 경로를 집중적으로 최적화한다. 그래서 Java 애플리케이션은 시작 직후보다 시간이 조금 지난 뒤 성능이 안정되는 경우가 있다.
JavaScript 엔진도 비슷한 전략을 사용한다. 브라우저와 Node.js의 JavaScript 엔진은 코드를 파싱하고 실행하면서, 자주 호출되는 함수나 안정적인 타입 패턴을 최적화한다. 하지만 예상이 깨지면 최적화된 코드를 버리고 덜 최적화된 경로로 돌아갈 수 있다. 이를 디옵티마이제이션이라고 한다. 같은 함수에 숫자만 들어오다가 갑자기 문자열이나 객체가 들어오면 엔진의 최적화 가정이 깨질 수 있다.
Python은 구현체에 따라 다르지만, 널리 쓰이는 CPython은 소스 코드를 바이트코드로 바꾼 뒤 가상 머신이 해석한다. 일반적으로 Java나 최적화된 JavaScript보다 순수 계산 속도가 느린 이유 중 하나다. 대신 개발 속도, 생태계, C 확장 모듈, 데이터 처리 라이브러리의 도움으로 실무 생산성을 얻는다.
즉 언어 성능은 문법만으로 결정되지 않는다. 컴파일 방식, 런타임, 메모리 관리, JIT 최적화, 표준 라이브러리 구현, 외부 네이티브 코드 사용 여부가 함께 영향을 준다.
스코프와 클로저는 변수가 살아 있는 범위를 정한다
스코프는 이름이 유효한 범위다. 어떤 코드 위치에서 변수 x를 썼을 때, 그 이름이 어느 선언을 가리키는지 정하는 규칙이다.
대부분의 현대 언어는 렉시컬 스코프를 사용한다. 렉시컬 스코프는 코드가 작성된 구조를 기준으로 이름을 찾는다.
function outer() {
const message = "hello";
function inner() {
console.log(message);
}
return inner;
}
const fn = outer();
fn();outer 함수 실행은 이미 끝났지만 inner는 여전히 message를 사용할 수 있다. 이것이 클로저다. 클로저는 함수와 그 함수가 참조하는 외부 변수 환경을 함께 묶은 것이다.
클로저는 콜백, 이벤트 핸들러, 함수형 프로그래밍, 비동기 코드에서 매우 유용하다. 하지만 메모리 관점에서는 주의가 필요하다. 클로저가 큰 객체를 참조하고 있으면, 그 객체는 함수 실행이 끝난 뒤에도 회수되지 않을 수 있다.
function createHandler(largeData) {
return function handleClick() {
console.log(largeData.id);
};
}반환된 함수가 살아 있는 동안 largeData도 함께 살아 있을 수 있다. 브라우저 이벤트 리스너나 서버의 장기 캐시에서 이런 참조가 쌓이면 메모리 증가의 원인이 된다. 클로저는 편리한 문법이 아니라 변수 수명을 바꾸는 실행 구조다.
호출 스택과 예외 처리
함수가 호출되면 실행에 필요한 정보가 호출 스택에 쌓인다. 호출 스택은 함수 호출의 순서를 관리하는 메모리 구조다. 새 함수가 호출되면 스택 프레임이 쌓이고, 함수가 끝나면 제거된다.
main
└─ handleRequest
└─ validateUser
└─ parseTokenparseToken에서 오류가 발생하면 프로그램은 현재 함수만 보는 것이 아니라, 자신을 호출한 함수들로 거슬러 올라간다. 이것이 스택 트레이스에 여러 함수 이름이 찍히는 이유다.
예외 처리는 정상 흐름과 오류 흐름을 분리하기 위한 언어 기능이다. 예외가 던져지면 런타임은 현재 호출 스택을 되감으면서 이 예외를 처리할 수 있는 catch 블록을 찾는다.
try {
service.createOrder(request);
} catch (InvalidOrderException e) {
return badRequest();
}적절한 위치에서 예외를 잡으면 오류를 사용자에게 의미 있는 응답으로 바꿀 수 있다. 하지만 너무 넓게 잡으면 문제를 숨긴다.
catch (Exception e) {
return success(); // 위험한 처리
}이런 코드는 실패를 성공처럼 보이게 만든다. 보안, 결제, 데이터 정합성 문제에서는 특히 위험하다. 예외는 단순히 프로그램이 터지는 현상이 아니라, 호출 스택을 따라 전파되는 제어 흐름이다. 어디서 잡고, 어떤 정보로 기록하고, 호출자에게 어떤 의미로 바꿀지 설계해야 한다.
호출 스택은 재귀와도 연결된다. 재귀 함수는 호출할 때마다 새 스택 프레임을 만든다. 종료 조건이 없거나 입력이 너무 깊으면 스택이 가득 차 스택 오버플로가 발생한다. 같은 로직이라도 반복문이나 명시적 스택 자료구조로 바꾸면 이 위험을 줄일 수 있다.
개발자가 가져야 할 판단 기준
언어와 컴파일러 지식은 컴파일러를 직접 만들기 위해서만 필요한 것이 아니다. 실무 개발자는 매일 언어의 실행 모델 위에서 판단한다.
정적 타입 언어를 쓰면 많은 오류를 실행 전에 줄일 수 있다. 하지만 외부 입력 검증은 여전히 필요하다. 동적 타입 언어를 쓰면 유연하게 개발할 수 있다. 하지만 테스트와 런타임 검증이 부족하면 오류가 늦게 발견된다.
JIT 기반 런타임은 반복 실행되는 코드를 빠르게 만들 수 있다. 하지만 타입 패턴이 흔들리거나 객체 구조가 자주 바뀌면 최적화 효과가 줄어든다. VM 기반 언어는 이식성과 런타임 최적화의 장점이 있지만, 시작 시간과 메모리 사용량을 함께 봐야 한다.
클로저는 표현력을 높인다. 하지만 변수의 생명주기를 늘릴 수 있다. 예외 처리는 오류 흐름을 정리한다. 하지만 잘못 잡으면 장애를 숨긴다. 타입 시스템은 안전성을 높인다. 하지만 시스템 경계의 데이터까지 자동으로 보장하지는 않는다.
결국 핵심은 언어 기능을 문법으로만 보지 않는 것이다. 코드는 분석되고, 변환되고, 실행된다. 그 과정에서 타입 오류, 런타임 오류, 메모리 사용, 성능 차이가 발생한다. 이 흐름을 이해하면 특정 언어의 사용법을 넘어, 어떤 언어를 쓰더라도 더 안정적이고 예측 가능한 코드를 작성할 수 있다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.