인터프리터

섬네일|300px|W3sDesign 인터프리터 디자인 패턴 UML

인터프리터(, )는 소스 코드를 미리 기계어로 컴파일하지 않고 직접 실행하는 소프트웨어이다. 인터프리트 방식의 런타임 시스템은 실행 전 소스 코드의 번역을 요구하는 CPU 네이티브 실행 코드 처리 방식과 다르다. 인터프리터는 소스 코드를 바이트코드와 같은 중간 표현으로 번역할 수 있다. 하이브리드 환경에서는 바이트코드를 직접 해석하는 대신, 닷넷이나 자바의 사례와 같이 적시(JIT) 컴파일을 통해 바이트코드를 기계어로 번역하기도 한다.

인터프리터가 널리 채택되기 전, 컴퓨터 프로그램의 실행은 소스 코드를 기계어로 번역하고 컴파일하는 컴파일러에 주로 의존했다. 리스프 및 베이직 인터프리터를 위한 초기 런타임 환경은 소스 코드를 직접 구문 분석할 수 있었다. 그 후, 런타임 성능을 향상시키기 위해 실행 전 소스 코드를 중간 형식으로 번역하는 언어(펄, 라쿠, 파이썬, 매트랩, 루비 등)를 위한 런타임 환경이 개발되었다.

인터프리터에서 실행되는 코드는 호환 가능한 인터프리터가 있는 모든 플랫폼에서 실행될 수 있다. 각 플랫폼에 맞는 실행 파일을 별도로 빌드하는 대신, 동일한 코드를 해당 플랫폼들에 배포할 수 있다. 비록 각 프로그래밍 언어가 보통 특정 런타임 환경과 연관되어 있지만, 하나의 언어가 서로 다른 환경에서 사용될 수도 있다. 전통적으로 컴파일과 연관된 알골, 포트란, 코볼, C 및 C++과 같은 언어들을 위해서도 인터프리터가 제작되었다.

 역사 
컴퓨팅 초기에는 컴파일러가 인터프리터보다 더 흔하게 발견되고 사용되었는데, 당시의 하드웨어는 인터프리터와 인터프리트되는 코드를 모두 지원할 만큼 성능이 충분하지 않았고, 당시의 전형적인 일괄 처리 환경이 인터프리터의 장점을 제한했기 때문이다.

인터프리터는 프로그램 저장 공간의 부족이나 부동소수점에 대한 네이티브 지원 부재와 같은 당시 컴퓨터의 한계 내에서 프로그래밍을 용이하게 하기 위해 이르면 1952년부터 사용되었다. 또한 인터프리터는 저급 기계어 간의 번역에도 사용되어, 아직 제작 중인 기계를 위한 코드를 작성하고 이를 이미 존재하는 컴퓨터에서 테스트할 수 있게 해주었다. 인터프리터 방식으로 구현된 최초의 고급 언어는 리스프(Lisp)였다. 리스프는 스티브 러셀에 의해 IBM 704 컴퓨터 상에서 처음 구현되었다. 러셀은 존 매카시의 논문 "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I"을 읽고, 리스프의 eval 함수가 기계어로 구현될 수 있다는 것을 (매카시가 놀랄 정도로) 깨달았다. 그 결과 리스프 프로그램을 실행하거나, 더 정확하게는 "리스프 식을 평가"하는 데 사용할 수 있는 작동하는 리스프 인터프리터가 탄생했다.

에디팅 인터프리터(editing interpreter)의 발전은 대화형 컴퓨팅의 필요성에 의해 영향을 받았다. 1960년대 시분할 시스템의 도입으로 여러 사용자가 동시에 컴퓨터에 접속할 수 있게 되었고, 실시간으로 코드를 관리하고 수정하기 위해 에디팅 인터프리터가 필수적이 되었다. 최초의 에디팅 인터프리터는 메인프레임 컴퓨터용으로 개발되었을 것으로 보이며, 그곳에서 즉석으로 프로그램을 생성하고 수정하는 데 사용되었다. 에디팅 인터프리터의 가장 초기 사례 중 하나는 1960년대 후반 PDP-1 컴퓨터를 위해 개발된 EDT(Editor and Debugger for the TECO) 시스템이다. EDT는 사용자가 명령어와 매크로의 조합을 사용하여 프로그램을 편집하고 디버깅할 수 있게 하여 현대적인 텍스트 편집기와 통합 개발 환경의 길을 열었다.

 용도 
인터프리터의 주요 용도는 다음과 같다:

 명령 및 스크립트 인터프리터는 명령줄 인터페이스 명령어와 스크립트를 실행하는 데 자주 사용된다.

 가상화 인터프리터는 인터프리터를 실행하는 하드웨어 아키텍처와 다른 아키텍처를 위한 기계어를 실행하기 위한 가상 머신 역할을 한다.

 에뮬레이션 인터프리터(가상 머신)는 다른 컴퓨터 시스템용으로 작성된 코드를 실행하기 위해 해당 시스템을 에뮬레이트할 수 있다.

 샌드박싱 일부 유형의 샌드박스는 운영체제의 보호 기능에 의존하지만, 인터프리터(가상 머신)는 보안 규칙을 위반하는 코드를 차단하는 등 추가적인 제어를 제공할 수 있다.

 자체 수정 코드 자체 수정 코드는 인터프리터 언어로 구현될 수 있다. 이는 리스프와 인공지능 연구에서의 인터프리터 기원과 관련이 있다.

 효율성 
인터프리터 오버헤드는 네이티브(컴파일된) 코드 대신 인터프리터를 통해 코드를 실행할 때 발생하는 실행 시간 비용이다. 인터프리터는 네이티브 코드의 동등한 기능에 대해 여러 기계어 명령어를 실행하기 때문에 더 느리다. 특히 변수에 대한 접근은 인터프리터에서 더 느린데, 식별자와 저장 위치의 매핑이 컴파일 타임이 아니라 런타임에 반복적으로 수행되어야 하기 때문이다. 그러나 (짧은 편집-빌드-실행 주기와 같은 요인으로 인한) 더 빠른 개발 속도는 실행 속도의 가치를 상회할 수 있으며, 특히 편집-빌드-실행 주기가 빈번한 프로토타이핑과 테스트 단계에서 그러하다.

인터프리터는 빠른 런타임 성능 등의 목표를 달성하기 위해 소스 코드로부터 프로그램의 중간 표현(IR)을 생성할 수 있다. 컴파일러도 IR을 생성할 수 있지만, 컴파일러는 나중에 실행할 기계어를 생성하는 반면 인터프리터는 프로그램을 실행할 준비를 한다. 이러한 서로 다른 목표는 서로 다른 IR 설계를 이끈다. 많은 베이직 인터프리터는 키워드를 단일 바이트 토큰으로 대체하며, 이 토큰은 점프 테이블에서 명령어를 찾는 데 사용될 수 있다. PBASIC 인터프리터와 같은 일부 인터프리터는 바이트 단위가 아닌 비트 단위의 프로그램 메모리 구조를 사용하여 더 높은 수준의 프로그램 압축을 달성한다. 여기서 명령 토큰은 약 5비트를 차지하고, 명목상의 "16비트" 상수는 3, 6, 10 또는 18비트를 요구하는 가변 길이 코드로 저장되며, 주소 피연산자에는 "비트 오프셋"이 포함된다. 많은 베이직 인터프리터는 자신의 토큰화된 내부 표현을 저장하고 다시 읽어올 수 있다.

인터프리터를 사용할 때의 개발 속도와 컴파일러를 사용할 때의 실행 속도 사이에는 다양한 절충안이 존재한다. 일부 시스템(일부 리스프 등)은 인터프리트된 코드와 컴파일된 코드가 서로를 호출하고 변수를 공유할 수 있게 한다. 이는 루틴이 인터프리터 하에서 테스트되고 디버깅되면 컴파일하여 빠른 실행의 이점을 얻는 동시에, 다른 루틴은 계속 개발할 수 있음을 의미한다.

 구현 
인터프리트와 컴파일의 초기 단계는 유사하기 때문에, 인터프리터는 컴파일러와 동일한 어휘 분석기 및 구문 분석기를 사용하여 생성된 추상 구문 트리를 해석할 수 있다.

 예시 
C++로 작성된 표현식 인터프리터.

import std;

using std::runtime_error;
using std::unique_ptr;
using std::variant;

// 추상 구문 트리를 위한 데이터 타입
enum class Kind: char {
 VAR,
 CONST,
 SUM,
 DIFF,
 MULT,
 DIV,
 PLUS,
 MINUS,
 NOT
};

// 전방 선언
class Node;

class Variable {
public:
 int* memory;
};

class Constant {
public:
 int value;
};

class UnaryOperation {
public:
 unique_ptr
 right;
};

class BinaryOperation {
public:
 unique_ptr
 left;
 unique_ptr
 right;
};

using Expression = variant
;

class Node {
public:
 Kind kind;
 Expression e;
};

// 인터프리터 프로시저
[[nodiscard]]
int executeIntExpression(const Node& n) {
 int leftValue;
 int rightValue;
 switch (n->kind) {
 case Kind::VAR:
 return std::get
(n.e).memory;
 case Kind::CONST:
 return std::get
(n.e).value;
 case Kind::SUM:
 case Kind::DIFF:
 case Kind::MULT:
 case Kind::DIV:
 const BinaryOperation& bin = std::get
(n.e);
 leftValue = executeIntExpression(bin.left.get());
 rightValue = executeIntExpression(bin.right.get());
 switch (n.kind) {
 case Kind::SUM:
 return leftValue + rightValue;
 case Kind::DIFF:
 return leftValue - rightValue;
 case Kind::MULT:
 return leftValue * rightValue;
 case Kind::DIV:
 if (rightValue == 0) {
 throw runtime_error("Division by zero");
 }
 return leftValue / rightValue;
 }
 case Kind::PLUS:
 case Kind::MINUS:
 case Kind::NOT:
 const UnaryOperation& un = std::get
(n.e);
 rightValue = executeIntExpression(un.right.get());
 switch (n.kind) {
 case Kind::PLUS:
 return +rightValue;
 case Kind::MINUS:
 return -rightValue;
 case Kind::NOT:
 return !rightValue;
 }
 default:
 std::unreachable();
 }
}

 JIT 컴파일 
적시(Just-in-time, JIT) 컴파일은 런타임에 중간 형식(예: 바이트코드)을 네이티브 코드로 변환하는 프로세스이다. 이는 네이티브 코드 실행으로 이어지므로, 인터프리터 개발로 이어진 이점들을 유지하면서 인터프리터 사용에 따른 런타임 비용을 피하는 방법이다.

 변형 
 제어테이블 인터프리터 로직이 테이블 형식의 데이터로 지정된다.

 바이트코드 인터프리터 일부 인터프리터는 고급 언어에서 컴파일된 로직의 중간 형식인 바이트코드를 처리한다. 예를 들어, 이맥스 리스프는 바이트코드로 컴파일된 후 인터프리터에 의해 해석된다. 이 컴파일된 코드는 인터프리터에 의해 구현된 가상 머신을 위한 기계어라고 할 수 있다. 이러한 인터프리터는 때때로 컴프리터(compreter)라고 불린다.

 스레드 코드 인터프리터 스레드 코드 인터프리터는 바이트코드 인터프리터와 유사하지만 바이트 대신 포인터를 사용한다. 각 명령어는 함수나 명령어 시퀀스를 가리키는 워드이며, 뒤에 매개변수가 올 수 있다. 스레드 코드 인터프리터는 명령어를 가져와서 가리키는 함수를 호출하는 루프를 돌거나, 첫 번째 명령어를 가져와서 점프하고, 모든 명령어 시퀀스는 다음 명령어를 가져와서 점프하는 것으로 끝난다. 스레드 코드의 한 예로 오픈 펌웨어 시스템에서 사용되는 포스 코드가 있다. 소스 언어는 "F 코드"(바이트코드)로 컴파일된 후 가상 머신에 의해 해석된다.

 추상 구문 트리 인터프리터 추상 구문 트리 인터프리터는 소스 코드를 추상 구문 트리(AST)로 변환한 다음 이를 직접 해석하거나, JIT 컴파일을 통해 네이티브 코드를 생성하는 데 사용한다. 이 방식에서 각 문장은 단 한 번만 구문 분석되면 된다. 바이트코드와 비교했을 때 AST는 글로벌 프로그램 구조와 문장 간의 관계(바이트코드 표현에서는 손실됨)를 유지하며, 압축했을 때 더 간결한 표현을 제공한다는 장점이 있다. 따라서 AST를 사용하는 것이 바이트코드보다 더 나은 중간 형식으로 제안되기도 했다. 그러나 인터프리터의 경우, AST는 유용한 작업을 수행하지 않는 구문 관련 노드들, 덜 순차적인 표현(더 많은 포인터 순회 필요), 그리고 트리를 방문하는 오버헤드로 인해 바이트코드 인터프리터보다 더 많은 오버헤드를 발생시킨다.

 템플릿 인터프리터 소프트웨어 스택이나 트리 워크에서 작동하면서 가능한 모든 바이트코드를 포함하는 거대한 switch 문을 통해 코드 실행을 구현하는 대신, 템플릿 인터프리터는 호스트 하드웨어에서 실행될 수 있는 해당 네이티브 기계어 명령어에 직접 매핑된 바이트코드(또는 효율적인 중간 표현)의 대형 배열을 키-값 쌍(또는 더 효율적인 설계에서는 네이티브 명령어의 직접 주소)으로 유지하며, 이를 "템플릿"이라고 한다. 특정 코드 세그먼트가 실행될 때 인터프리터는 단순히 템플릿의 옵코드 매핑을 로드하거나 그리로 점프하여 하드웨어에서 직접 실행한다. 그 설계 특성상 템플릿 인터프리터는 전통적인 인터프리터보다는 JIT 컴파일러와 매우 흡사하지만, 전체 코드 세그먼트로부터 최적화된 CPU 실행 명령어 시퀀스를 생성하는 대신 언어의 코드를 한 번에 한 옵코드씩 네이티브 호출로 단순히 번역한다는 점에서 엄밀히 말해 JIT는 아니다. 호출을 직접 구현하지 않고 하드웨어로 직접 전달하는 단순한 설계 덕분에 바이트코드 인터프리터를 포함한 다른 모든 유형보다 훨씬 빠르고 버그가 발생할 확률도 어느 정도 낮지만, 플랫폼 독립적인 가상 머신/스택 대신 여러 다른 아키텍처로의 번역을 지원해야 하므로 유지보수가 더 어렵다는 단점이 있다. 현재까지 널리 알려진 언어 중 템플릿 인터프리터가 구현된 사례는 자바의 공식 참조 구현인 Sun HotSpot 자바 가상 머신 내의 인터프리터와 구글 V8 자바스크립트 실행 엔진의 Ignition 인터프리터뿐이다.

 마이크로코드 마이크로코드는 기계어를 더 낮은 수준의 기계어로 구현하는 하드웨어 인터프리터로서 추상화 계층을 제공한다. 이는 고수준 기계어 명령어를 기본 회로와 분리하여 고수준 명령어를 더 자유롭게 설계하고 변경할 수 있게 한다. 또한 컴퓨터 회로의 복잡성을 줄이면서 복잡한 다단계 명령어 제공을 용이하게 한다.

 같이 보기 
 컴파일 언어
 동적 컴파일
 REPL

 각주 

 외부 링크 
 IBM Card Interpreters page at Columbia University
 Theoretical Foundations For Practical 'Totally Functional Programming' (Chapter 7 especially) Doctoral dissertation tackling the problem of formalising what is an interpreter
 Short animation explaining the key conceptual difference between interpreters and compilers

분류:프로그래밍 언어 구현