우리는 보통 프로그래밍 언어를 배울 때, 아주 초반에 이런 식의 구분을 접한다. 컴파일 언어와 인터프리터 언어. 그리고 거기서 이야기는 끝난다. C나 Rust는 컴파일 언어이고, Python이나 JavaScript는 인터프리터 언어라는 식이다.
조금 더 찾아본 사람들은 여기에 AOT 컴파일러와 JIT 컴파일러라는 개념을 덧붙이기도 한다. 그래서 머릿속에는 대략 이런 구조가 만들어진다. AOT 컴파일러, JIT 컴파일러, 인터프리터. 총 세 가지 실행 방식이 있고, 각 언어는 이 중 하나에 속한다는 식이다.
하지만 이 구분은 금방 한계에 부딪힌다.
언어 이름을 몇 개만 더 떠올려보면 바로 이상해진다.
Java는 어디에 속하는가?
Python이 실행 전에 생성하는 .pyc 파일은 무엇인가?
JIT는 인터프리터처럼 동작하는가, 아니면 컴파일러인가?
이 질문들에 명확하게 답하려고 하면 설명이 꼬이기 시작한다. “Java는 컴파일도 하고 인터프리트도 한다” “Python도 사실은 컴파일 언어다” “JIT는 둘을 섞은 방식이다” 같은 애매한 표현들이 계속 등장한다.
문제는 여기서 생긴다. 우리는 언어를 기준으로 실행 방식을 분류하려고 하고 있다는 점이다.
사실 컴파일러와 인터프리터는 언어의 속성이 아니다. 이 둘은 언어가 아니라 구현과 실행 모델의 문제다. 같은 언어라도 어떤 컴파일러를 쓰느냐, 어떤 런타임 위에서 실행되느냐에 따라 전혀 다른 방식으로 동작할 수 있다.
그럼에도 불구하고 “이 언어는 컴파일 언어다”, “저 언어는 인터프리터 언어다”라는 말이 계속 쓰이는 이유는 개념을 빠르게 전달하기에는 편하기 때문이다. 하지만 그 대가로, 실제 실행 과정에 대한 이해는 계속 흐려진다.
이 글에서는 컴파일러와 인터프리터를 단순히 나누는 대신, 코드가 실제로 어떻게 실행되는지를 기준으로 다시 살펴보려고 한다. 언어 이름이 아니라, 소스 코드가 어떤 단계를 거쳐 어떤 형태로 변하고, 누가 그것을 실행하는지를 하나씩 짚어볼 것이다.
이제부터는 “이 언어는 컴파일 언어인가?”라는 질문을 잠시 내려놓고, “이 코드는 어떤 과정을 거쳐 실행되는가?”라는 질문으로 이야기를 시작해보자.
컴파일이란 무엇인가
컴파일이라는 말을 들으면 보통 “소스 코드를 기계어로 바꾸는 과정”이라고 설명한다. 이 설명은 틀리지는 않지만, 중요한 부분을 생략하고 있다. 컴파일의 본질은 기계어가 아니라 변환에 있다.
컴파일은 어떤 언어로 작성된 코드를, 다른 형태의 코드로 변환하는 과정이다. 여기서 변환의 대상은 반드시 CPU가 바로 실행할 수 있는 기계어일 필요가 없다. 중간 표현(IR)일 수도 있고, 가상 머신이 이해하는 바이트코드일 수도 있다. 중요한 것은 “실행”이 아니라 **“형태를 바꾸는 것”**이다.
예를 들어 C 컴파일러는 소스 코드를 받아 기계어로 변환한다. 하지만 LLVM 기반 컴파일러의 경우, 실제로는 여러 단계를 거친다. 소스 코드는 먼저 중간 표현(IR)으로 변환되고, 최적화 과정을 거친 뒤에야 최종 기계어가 생성된다. 이 전체 과정은 모두 컴파일에 포함된다.
Java 역시 컴파일 과정을 가진다. Java 소스 코드는 javac를 통해 바이트코드로 변환되고, 이 바이트코드는 JVM이 이해할 수 있는 형태로 저장된다. 이 시점에서 이미 컴파일은 끝난 상태다. 다만, 그 결과물이 OS가 직접 실행하는 기계어가 아닐 뿐이다.
Python도 마찬가지다.
CPython은 소스 코드를 바로 실행하는 것처럼 보이지만,
실제로는 내부적으로 바이트코드를 생성한다.
이때 생성되는 것이 바로 .pyc 파일이다.
즉, Python 역시 코드 변환 과정을 거치며, 이 과정은 명백히 컴파일에 해당한다.
이렇게 보면 “컴파일 = 실행 전에 기계어 생성”이라는 정의는 너무 좁다. 컴파일은 언제 실행되느냐와 직접적인 관련이 없다. 실행 전에 일어날 수도 있고, 실행 중에 일어날 수도 있다. 파일로 저장될 수도 있고, 메모리 안에서만 존재할 수도 있다.
그래서 컴파일을 이해할 때 중요한 질문은 이것이다. “이 코드가 언제 실행되는가?”가 아니라, “이 코드는 어떤 형태로 변환되는가?”
이 관점으로 보면, AOT 컴파일러와 JIT 컴파일러의 차이도 자연스럽게 정리된다. 둘 다 컴파일러지만, 단지 컴파일이 일어나는 시점이 다를 뿐이다.
이제 컴파일이 무엇인지 정의했으니, 다음으로는 자연스럽게 이런 질문이 생긴다. “그렇다면 인터프리터는 무엇이 다른가?”
다음 문단에서는 이 질문을 기준으로 인터프리트의 의미를 다시 살펴보자.
인터프리트란 무엇인가
인터프리트(Interprete)는 보통 “코드를 한 줄씩 읽어서 바로 실행하는 방식”으로 설명된다. 하지만 이 설명은 실제 구현과는 거리가 있다. 현대의 인터프리터는 그렇게 단순하게 동작하지 않는다.
인터프리트의 핵심은 변환 결과를 따로 저장하지 않고 실행 흐름에 직접 연결한다는 점이다. 즉, 코드가 어떤 형태로든 해석되기는 하지만, 그 결과가 독립적인 실행 파일로 남지는 않는다.
대부분의 인터프리터는 실행 전에 최소한의 구조 분석을 수행한다. 소스 코드는 토큰으로 분해되고, 문법 규칙에 따라 파싱되며, AST(Abstract Syntax Tree) 같은 내부 표현으로 변환된다. 이 과정이 없다면 조건문이나 반복문 같은 구조를 제대로 처리할 수 없다.
이렇게 만들어진 내부 표현은 기계어로 변환되기보다는, 가상 머신이나 인터프리터 루프에 의해 즉시 실행된다. 여기서 중요한 점은, 실행의 단위가 “소스 코드의 한 줄”이 아니라 이미 해석된 내부 구조라는 것이다.
Python을 예로 들면,
CPython은 소스 코드를 바이트코드로 변환한 뒤
이를 스택 기반 가상 머신에서 실행한다.
이 바이트코드는 파일로 저장될 수도 있고(.pyc),
메모리에만 존재할 수도 있다.
어떤 경우든, 실행 자체는 인터프리터가 담당한다.
이 때문에 “인터프리터는 컴파일을 하지 않는다”라는 말은 정확하지 않다. 인터프리터 역시 파싱과 구조 변환을 수행하며, 이는 앞에서 정의한 의미의 컴파일과 겹치는 부분이 많다. 차이는 변환 결과를 어떻게, 언제 사용하느냐에 있다.
정리하면, 인터프리트는 “컴파일의 반대”가 아니다. 인터프리트는 변환된 코드를 즉시 실행하는 실행 모델이다. 컴파일과 인터프리트는 서로 배타적인 개념이 아니라, 같은 실행 과정 안에서 함께 존재할 수 있다.
이제 컴파일과 인터프리트가 무엇인지 각각 분리해서 보았으니, 다음으로는 가장 혼란을 만드는 존재, JIT가 이 둘 사이에서 어떤 역할을 하는지 살펴볼 차례다.
JIT는 어디에 속하는가
JIT(Just-In-Time)는 컴파일러와 인터프리터를 구분하려는 모든 시도를 가장 먼저 무너뜨리는 개념이다. JIT를 설명하려고 하면 늘 비슷한 말이 따라붙는다. “실행 중에 컴파일한다”, “인터프리터와 컴파일러를 섞은 방식이다”, “둘의 장점을 취한 구조다”.
이 설명들이 애매하게 느껴지는 이유는 간단하다. JIT는 애초에 컴파일러냐 인터프리터냐로 분류될 대상이 아니기 때문이다.
JIT는 실행 시점에 코드의 일부를 기계어로 변환한다. 즉, 컴파일을 한다. 앞에서 정의한 기준으로 보면, 중간 표현이나 바이트코드를 실제 기계어로 변환하는 과정이 존재하고, 이는 명백히 컴파일이다.
다만 그 시점이 다를 뿐이다. AOT 컴파일러는 프로그램이 실행되기 전에 모든 코드를 변환하고, JIT는 프로그램이 실행되는 동안, 그리고 보통 필요해졌을 때만 변환한다.
여기서 중요한 점은, JIT가 모든 코드를 즉시 컴파일하지 않는다는 것이다. 대부분의 JIT 시스템은 먼저 코드를 인터프리트하거나 바이트코드 형태로 실행한다. 그 과정에서 반복적으로 실행되는 코드, 즉 “뜨거운 코드(hot code)”를 찾아낸다.
이후에야 JIT는 해당 코드만을 선택적으로 기계어로 컴파일한다. 이렇게 생성된 기계어는 메모리에 저장되고, 이후 실행부터는 인터프리터를 거치지 않고 직접 실행된다.
이 구조 때문에 JIT는 종종 인터프리터처럼 보인다. 실행을 시작할 때는 분명히 해석 기반으로 동작하기 때문이다. 하지만 실행이 진행될수록, 프로그램은 점점 컴파일된 코드에 의존하게 된다.
그래서 JIT를 “인터프리터의 한 종류”로 분류하는 것도, “컴파일러의 한 종류”로 분류하는 것도 정확하지 않다. JIT는 컴파일의 방식이 아니라, 컴파일이 이루어지는 시점을 바꾸는 전략에 가깝다.
이 지점에서 다시 한 번 분명해진다. 컴파일러와 인터프리터를 언어 기준으로 나누는 것은 실제 실행 구조를 설명하기에는 너무 거칠다. 중요한 것은 코드가 어떤 형태로 존재하다가, 언제, 누가, 어떤 기준으로 실행하느냐이다.
이제 남은 질문은 하나다. 그렇다면 우리는 컴파일러와 인터프리터를 어떤 기준으로 이해해야 할까?
다음 문단에서는 이 질문에 답하기 위해 관점을 “언어”에서 “실행 모델”로 옮겨보자.
언어가 아니라 실행 모델의 문제
앞에서 살펴본 컴파일, 인터프리트, JIT를 다시 떠올려보면 하나의 공통점이 드러난다. 이 개념들은 모두 언어의 성질을 설명하지 않는다는 점이다. 설명하는 대상은 항상 “언어”가 아니라 그 언어가 어떻게 실행되느냐였다.
그럼에도 우리는 여전히 “이 언어는 컴파일 언어다”, “저 언어는 인터프리터 언어다”라는 표현을 사용한다. 이 표현이 편리한 이유는 분명하다. 짧고, 직관적이고, 대략적인 성격을 전달하기에는 충분하기 때문이다.
하지만 이 방식은 중요한 차이를 모두 지워버린다. 같은 언어라도 구현체에 따라 전혀 다른 실행 모델을 가질 수 있다는 사실이다.
Python을 예로 들면, CPython은 바이트코드를 인터프리트하는 구조를 가지고 있지만, PyPy는 JIT를 적극적으로 활용한다. 같은 Python 코드라도 실행 방식, 성능 특성, 메모리 사용 패턴은 크게 달라진다.
JavaScript도 마찬가지다. 언어 차원에서는 동일하지만, V8, SpiderMonkey, JavaScriptCore는 각기 다른 바이트코드 구조와 JIT 전략을 사용한다. “JavaScript는 인터프리터 언어다”라는 한 문장으로는 이 차이를 설명할 수 없다.
결국 중요한 질문은 이것이다. “이 언어는 컴파일 언어인가?”가 아니라, **“이 구현은 어떤 실행 모델을 사용하는가?”**이다.
이 관점으로 보면, 실행 모델은 대략 다음 요소들로 나눌 수 있다. 코드가 어떤 형태로 변환되는가, 그 변환이 언제 일어나는가, 그리고 최종 실행의 주체가 누구인가.
이 기준에 따라 AOT 컴파일, 바이트코드 실행, 인터프리트, JIT는 서로 배타적인 분류가 아니라 하나의 연속된 스펙트럼 위에 놓이게 된다.
이제 우리는 더 이상 컴파일러와 인터프리터를 두 개의 상자로 나눌 필요가 없다. 대신, 코드가 실행되기까지의 과정을 연속된 흐름으로 이해할 수 있다.
다음 문단에서는 이 관점을 바탕으로 대표적인 언어들이 실제로 어떤 실행 모델을 가지는지 조금 더 구체적으로 살펴보자.
실제 언어들은 어떻게 실행되는가
앞에서 실행 모델이라는 관점으로 정리했으니, 이제 몇 가지 익숙한 언어를 이 기준으로 다시 보자. 중요한 점은 “어디에 속하는가”가 아니라 어떤 단계를 거쳐 실행되는가다.
C는 가장 단순한 형태에 가깝다. 소스 코드는 컴파일 타임에 전부 기계어로 변환되고, 그 결과물은 운영체제가 직접 실행한다. 실행 시점에는 컴파일러나 런타임이 개입하지 않는다. 그래서 C 계열 언어는 실행 모델이 명확하고, 성능 특성도 비교적 예측하기 쉽다.
Java는 여기서 한 단계를 더 가진다. 소스 코드는 먼저 바이트코드로 컴파일된다. 이 바이트코드는 운영체제가 아니라 JVM이 실행한다. 실행 초반에는 인터프리트에 가까운 방식으로 동작하다가, 반복적으로 실행되는 코드가 발견되면 JIT가 개입해 해당 부분을 기계어로 컴파일한다. 즉, Java는 “컴파일 언어냐 인터프리터 언어냐”로는 설명이 되지 않고, 바이트코드 + VM + JIT라는 실행 모델로 이해해야 한다.
Python도 비슷하다.
CPython은 소스 코드를 바이트코드로 변환한 뒤
이를 가상 머신에서 실행한다.
이 과정에서 .pyc 파일이 생성될 수 있지만,
이 파일은 실행 결과물이 아니라
다시 해석될 중간 표현일 뿐이다.
실행의 중심은 여전히 인터프리터 루프에 있다.
JavaScript는 더 복합적이다. 현대 JavaScript 엔진들은 대부분 바이트코드 생성과 JIT를 함께 사용한다. 실행 초기에는 빠르게 시작하기 위해 해석 기반으로 동작하고, 실행 패턴이 안정되면 점진적으로 기계어 비중을 늘린다. 그래서 “JavaScript는 느리다”라는 인식은 이미 오래전에 의미를 잃었다.
이 예시들을 보면 공통점이 분명해진다. 언어 이름만으로는 실행 방식을 설명할 수 없고, 실제 동작은 컴파일 단계, 중간 표현, 실행 주체의 조합으로 결정된다.
이제 컴파일러와 인터프리터를 고정된 분류로 볼 이유는 없다. 그 대신, 각 언어 구현이 어떤 선택을 했는지를 보면 된다.
이제 마지막으로, 이런 이해가 왜 중요한지, 그리고 우리가 어떤 질문을 가져야 하는지 정리해보자.
그래서 이 구분이 왜 중요한가
컴파일러와 인터프리터를 정확히 이해하는 이유는 용어를 바로잡기 위해서만은 아니다. 이 차이는 실제로 우리가 코드를 작성하고, 디버깅하고, 배포하고, 성능을 예측하는 방식에 직접적인 영향을 준다.
예를 들어, AOT 컴파일 기반의 실행 모델에서는 실행 전에 대부분의 문제가 드러난다. 타입 오류나 문법 오류는 컴파일 단계에서 잡히고, 실행 중에는 비교적 예측 가능한 동작을 한다. 반대로, 인터프리트나 JIT 기반 모델에서는 실행 중에야 드러나는 문제가 많아진다. 이 차이는 단순한 취향 문제가 아니다.
또 성능을 이야기할 때도 마찬가지다. “컴파일 언어라서 빠르다”, “인터프리터 언어라서 느리다”라는 설명은 이미 현실을 제대로 반영하지 못한다. 어디에서 컴파일이 일어나는지, 어떤 코드가 언제 기계어로 변환되는지가 훨씬 중요하다.
이 관점을 가지면, 언어를 고를 때의 기준도 달라진다. 문법이나 인기보다는 런타임 구조, 실행 모델, 디버깅 방식, 배포 형태를 먼저 보게 된다. 그리고 이건 언어를 “쓰는 입장”뿐 아니라 언어를 “만드는 입장”에서도 마찬가지다.
컴파일러와 인터프리터는 서로 대립하는 개념이 아니다. 현대의 실행 환경에서는 둘이 함께 사용되는 경우가 훨씬 더 일반적이다. AOT, 바이트코드, 인터프리트, JIT는 서로 다른 선택지일 뿐이고, 각각은 명확한 트레이드오프를 가진다.
그래서 이제는 “이 언어는 컴파일 언어인가?”라는 질문 대신, 이 질문을 던지는 편이 낫다.
이 코드는 어떤 형태로 변환되고, 언제 그 변환이 일어나며, 누가 그것을 실행하는가?
이 질문에 답할 수 있다면, 컴파일러와 인터프리터라는 단순한 구분은 더 이상 필요하지 않다.