본문으로 이동
메뉴 여닫기
환경 설정 메뉴 여닫기
개인 메뉴 여닫기
로그인하지 않음
지금 편집한다면 당신의 IP 주소가 공개될 수 있습니다.
Bytecode; 바이트코드
가상 머신이 실행하도록 설계한, 특정 CPU에 묶이지 않은 명령어 집합

바이트코드(bytecode)는 소스 코드를 컴파일해 얻는 중간 형태의 코드로, 실제 CPU가 아니라 소프트웨어로 만든 인터프리터나 가상 머신이 실행하도록 설계한 명령어 집합이다. 이식 가능한 코드(portable code), p-code라고도 한다. 이름은 명령어 하나를 1바이트짜리 연산 코드(opcode)로 나타내고 그 뒤에 필요한 피연산자를 붙이는 구성에서 왔다. 사람이 읽는 소스 코드와 달리 숫자로 된 연산 코드, 상수, 주소 참조로 이루어지며, 컴파일러가 구문 분석과 의미 분석(타입, 유효 범위, 중첩 깊이 등)을 끝낸 결과를 담는다.

바이트코드는 소스를 매번 해석하는 방식보다 빠르고, CPU마다 다른 기계어를 만드는 방식보다 이식성이 높다. 같은 바이트코드 파일을 가상 머신이 있는 어느 플랫폼에서나 돌릴 수 있고, 필요하면 가상 머신이 실행 중에 기계어로 번역한다(JIT 컴파일). Java, C#(.NET), Python, Lua, Erlang, 안드로이드 앱, WebAssembly가 대표적인 바이트코드 기반 환경이다. 흔히 바이트코드라고 하면 Java 바이트코드를 떠올리기 때문에, 다른 가상 머신의 것과 구별하려고 "Java 바이트코드", "CPython 바이트코드"처럼 이름을 붙여 부르는 일이 많다. .NET의 CIL처럼 바이트 단위가 아닌 것도 있어 환경마다 부르는 이름이 다르며, 텍스트 형식의 어셈블리 언어와 이진 형식을 묶어 중간 언어(intermediate language)라고 부르기도 한다.

기계어·중간 표현과 비교

편집 원본 편집
형태 실행 주체 이식성 특징 예
소스 코드 인터프리터가 직접 해석하거나 컴파일 높음 사람이 읽고 쓰는 텍스트 .java, .py, .js
추상 구문 트리(AST) 트리 순회 인터프리터 프로세스 안에서만 존재 파일로 배포하지 않음 Ruby 1.8, Perl 5의 실행 방식
바이트코드 가상 머신(인터프리터, JIT) 가상 머신만 있으면 어디서나 배포·캐시용 이진 형식, 명령어가 조밀함 JVM .class, .NET 어셈블리, .pyc, .dex, .wasm
컴파일러 중간 표현(IR) 컴파일러 내부 컴파일러 안에서만 사용 최적화하기 좋게 설계, 보통 배포용이 아님 LLVM IR, GCC GIMPLE
기계어 CPU CPU와 운영체제에 묶임 가장 빠름 x86-64, ARM64 목적 코드

바이트코드는 중간 표현의 한 종류지만 "배포하고 실행하는 것"을 목표로 한다는 점에서 컴파일러 내부 IR과 다르다. 경계가 흐린 경우도 있다. LLVM IR은 이진 형식(bitcode)으로 저장할 수 있고 Apple은 한때 앱 스토어에 bitcode를 올리게 했으며, 바이트코드 해석기 목록에 LLVM IR을 넣기도 한다. 반대로 각 바이트코드에는 사람이 읽을 수 있는 어셈블리 형태의 표기(Java의 javap 출력이나 Jasmin, .NET의 ILAsm, WebAssembly의 WAT, 안드로이드의 smali)가 있다.

바이트코드를 만드는 단계까지는 컴파일이고, 만들어진 바이트코드는 가상 머신이 실행한다. 실행하는 방법은 크게 셋이다.

  • 해석: 인터프리터가 연산 코드를 하나씩 읽고 분기해 처리한다. 가장 이식성이 높고 구현이 쉽다. 연산 코드마다 처리 코드로 곧장 점프하게 만드는 스레디드 코드(threaded code)나 계산된 goto로 분기 비용을 줄인다.
  • JIT 컴파일: 실행하면서 자주 쓰이는 부분을 기계어로 번역한다. 가상 머신은 하드웨어에 묶이지만 바이트코드는 여전히 이식 가능하다. 번역하는 동안 지연이 생기지만, 소스를 직접 해석하는 것보다 대체로 한 자릿수 이상 빠르다.
  • AOT 컴파일: 배포 전이나 설치할 때 바이트코드를 기계어로 바꾼다. 안드로이드 ART의 dex2oat, .NET의 ReadyToRun과 Native AOT, GraalVM Native Image가 이렇게 한다. 가상 머신을 거치지 않아 시작이 빠르지만, 대상 플랫폼마다 결과물이 따로 필요하고 실행 중 클래스 로딩 같은 동적 기능이 제한될 수 있다.

속도로 보면 바이트코드 해석은 소스를 매번 해석하는 방식과 기계어를 직접 실행하는 방식 사이에 있다. Python처럼 인터프리터 언어로 알려진 구현도 내부적으로는 소스를 바이트코드로 컴파일한 뒤 실행하며, 소스가 바뀌지 않았으면 캐시해 둔 바이트코드를 다시 쓴다.

바이트코드 없이 소스에서 곧장 기계어로 가는 설계도 있었다. 초기 V8은 JavaScript 소스를 Full-codegen으로 바로 기계어로 만들었지만, 모바일 기기에서 기계어가 메모리를 너무 많이 차지하고 새 언어 기능을 따라가기 어려워 2017년 레지스터 기반 바이트코드를 쓰는 Ignition 인터프리터로 바꾸었다.[1] 지금 V8은 모든 JavaScript를 먼저 Ignition 바이트코드로 만든다.

스택 기반과 레지스터 기반

편집 원본 편집

가상 머신은 피연산자를 어디에 두느냐에 따라 스택 기반과 레지스터 기반으로 나뉜다. 여기서 레지스터는 CPU의 실제 레지스터가 아니라 함수 프레임 안의 번호 붙은 칸이다.

구분 스택 기반 레지스터 기반
피연산자 암묵적인 피연산자 스택에서 꺼내고 결과를 다시 넣음 명령어에 레지스터 번호를 적음
명령어 길이 짧음(피연산자 번호가 없음) 긺
명령어 수 많음(값을 스택에 올리고 내리는 명령이 필요) 적음
컴파일러 구현 쉬움 레지스터 할당이 필요
예 JVM, .NET CIL, CPython, WebAssembly, Smalltalk-80, 이더리움 EVM Lua 5.0 이후, 안드로이드 Dalvik, V8 Ignition(누산기 포함), Erlang BEAM, Parrot

같은 덧셈 a + b를 두 방식으로 쓰면 다음과 같다.

; 스택 기반(JVM)          ; 레지스터 기반(Dalvik)
iload_0   ; a를 스택에    add-int v0, v2, v3   ; v0 = v2 + v3
iload_1   ; b를 스택에
iadd      ; 둘을 꺼내 더해 결과를 스택에

2005년 연구는 JVM을 레지스터 기반으로 바꿔 구현해 비교했는데, 실행하는 VM 명령어 수가 평균 46% 넘게 줄고 바이트코드 크기는 약 26% 늘었으며, 해석 방식에 따라 1.15~1.48배 빨랐다.[2] Lua는 1993년부터 스택 기반 VM을 쓰다가 2003년 Lua 5.0에서 레지스터 기반으로 바꾸었고, 개발진은 이것을 널리 쓰인 첫 레지스터 기반 VM이라고 소개했다.[3] V8의 Ignition은 레지스터 기계에 누산기(accumulator) 하나를 더해, 많은 명령이 누산기를 암묵적 입출력으로 써서 명령어 길이를 줄인다.[4]

주요 바이트코드

편집 원본 편집

p-code(portable code)는 1970년대 파스칼 구현에서 이름이 굳었다. 취리히 연방 공과대학교(ETH)의 Pascal-P 컴파일러는 가상 스택 기계용 p-code를 내놓았고, 케네스 볼스가 이끈 캘리포니아 대학교 샌디에이고(UCSD)의 UCSD Pascal은 이 계열 컴파일러와 p-code 인터프리터를 바탕으로 운영체제(p-System)까지 만들어 여러 마이크로컴퓨터에 옮겼다.[5] 인터프리터만 새 기계에 옮기면 컴파일러와 운영체제 전체가 돌아가는 구조였다. 비슷한 시기 BCPL의 O-code, Infocom의 게임용 Z-machine, 마이크로소프트 Visual Basic과 Visual C++의 P-code도 같은 발상이다.

JVM 바이트코드

편집 원본 편집

Java 컴파일러(javac)는 소스를 클래스 파일(.class)로 컴파일하고, 그 안의 메서드 본문이 JVM 바이트코드다. JVM 명세에 따르면 명령어는 1바이트 연산 코드와 0개 이상의 피연산자로 이루어지며, 여러 바이트 피연산자는 빅 엔디언으로 저장한다. 각 메서드 프레임은 지역 변수 배열과 피연산자 스택을 가지며, 두 크기는 컴파일할 때 정해져 클래스 파일에 기록된다.[6] 연산 코드가 1바이트이므로 명령어는 최대 256개이고, iadd(int), ladd(long), fadd(float), dadd(double)처럼 타입별로 따로 있다.

public class Add {
    static int add(int a, int b) { return a + b; }
}
$ javac Add.java && javap -c Add
  static int add(int, int);
    Code:
       0: iload_0      // 지역 변수 0(a)을 스택에
       1: iload_1      // 지역 변수 1(b)을 스택에
       2: iadd         // 두 값을 꺼내 더한 뒤 결과를 스택에
       3: ireturn      // 스택 맨 위 int를 반환

클래스 파일은 매직 넘버 0xCAFEBABE로 시작하고 그 뒤에 버전 번호가 온다(JDK 23으로 컴파일하면 주 버전 67). JVM은 클래스를 로드할 때 바이트코드 검증기(verifier)로 스택 넘침, 타입 불일치, 잘못된 점프가 없는지 확인한다. Java 7은 동적 언어를 위한 invokedynamic 명령을 추가했고, Java 8의 람다식도 이 명령으로 구현된다. JVM은 Java 언어를 몰라도 되기 때문에 Kotlin, Scala, Clojure, Groovy 같은 언어도 같은 바이트코드로 컴파일된다. 실행 중에 바이트코드를 만들거나 고치는 라이브러리로 ASM, Byte Buddy, Javassist, BCEL이 있으며, 프레임워크의 프록시 생성이나 모니터링 에이전트가 이를 쓴다.

.NET의 C#, F#, VB.NET은 CIL(Common Intermediate Language, 예전 이름 MSIL)로 컴파일되어 어셈블리(.dll, .exe)에 담긴다. 형식과 의미는 ECMA-335(공통 언어 기반, CLI) 표준이 정한다. CIL도 스택 기반이지만, JVM과 달리 add 하나가 피연산자 타입에 따라 동작하고, 풍부한 메타데이터가 함께 저장되며, 제네릭 형식 정보가 실행 시점까지 남는다.

.method static int32 Add(int32 a, int32 b) cil managed
{
    ldarg.0     // 인수 0을 스택에
    ldarg.1     // 인수 1을 스택에
    add         // 더하기
    ret
}

CoreCLR은 CIL을 해석하지 않고 메서드를 처음 호출할 때 JIT로 기계어를 만든다(RyuJIT). ILDasm으로 CIL을 볼 수 있고, ILSpy나 dnSpy는 CIL을 C# 소스로 되살린다.

CPython 바이트코드

편집 원본 편집

CPython은 모듈을 가져올 때 소스를 바이트코드로 컴파일하고 결과를 .pyc 파일로 캐시한다. Python 3.2부터 .pyc는 __pycache__ 디렉터리 안에 모듈.cpython-312.pyc처럼 구현과 버전을 이름에 넣어 저장하며, 바이트코드 형식이 바뀔 때마다 파일 앞의 매직 넘버가 바뀐다.[7] 표준 라이브러리 dis 모듈로 바이트코드를 볼 수 있다.

>>> import dis
>>> def add(a, b):
...     return a + b
...
>>> dis.dis(add)          # Python 3.10의 출력
  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 RETURN_VALUE

CPython 바이트코드는 구현 세부 사항이어서 버전마다 명령이 추가, 삭제, 변경될 수 있다. 3.6부터 모든 명령어가 2바이트(워드코드)가 되었고, 3.11에서는 BINARY_ADD 같은 개별 명령이 BINARY_OP 하나로 합쳐지고 함수 시작에 RESUME이 붙으며, 실행 중 자주 쓰는 타입에 맞춰 명령을 특수화된 버전으로 바꿔 끼우는 적응형 인터프리터(PEP 659)와 인라인 캐시 항목이 들어왔다.[8][9] 3.13의 실험적 JIT는 이렇게 특수화된 바이트코드를 다시 마이크로 연산으로 쪼개 기계어로 번역한다. 따라서 .pyc 파일은 만든 버전의 인터프리터에서만 쓸 수 있고, 한 버전용 디컴파일러가 다른 버전에서는 통하지 않는다.

Lua는 소스를 레지스터 기반 바이트코드로 컴파일해 실행하며, luac로 미리 컴파일한 청크를 저장할 수 있다. luac -l로 명령어를 볼 수 있고, ADD A B C(레지스터 A에 B와 C의 합을 넣음)처럼 레지스터 번호를 직접 적는다. 바이트코드 형식은 Lua 버전마다 호환되지 않는다.

안드로이드 앱은 Java나 Kotlin으로 쓴 뒤 JVM 클래스 파일을 다시 DEX(Dalvik Executable) 형식으로 변환해 APK에 넣는다. Dalvik 바이트코드는 16비트 코드 단위를 쓰는 레지스터 기반 기계이며, 레지스터는 32비트이고 64비트 값은 인접한 두 레지스터에 담는다.[10] 클래스 파일마다 따로 있던 상수 풀을 한 파일에 합쳐 크기를 줄였다. DEX를 사람이 읽을 수 있게 풀어 쓴 어셈블리 표기가 smali이며, baksmali로 분해하고 smali로 다시 조립할 수 있어 APK를 고치는 데 쓰인다. ART는 DEX를 JIT와 AOT로 기계어로 바꿔 실행한다.

WebAssembly는 스택 기반 가상 머신용 이진 명령어 형식으로, 2019년 12월 5일 W3C 권고안이 되었다.[11] 공식 문서는 "바이트코드"라는 말 대신 이진 형식이라고 부르지만 성격은 같다. 다른 바이트코드와 달리 구조화된 제어 흐름(block, loop, if)만 허용하고 모든 함수의 타입이 정해져 있어, 로드할 때 한 번의 선형 검증으로 안전성을 확인하고 곧바로 기계어로 번역할 수 있다. 텍스트 표기 WAT로 쓰면 다음과 같다.

(func $add (param $a i32) (param $b i32) (result i32)
  local.get $a
  local.get $b
  i32.add)
바이트코드 사용처
Smalltalk-80 바이트코드 Smalltalk 가상 머신. JIT 기법의 초기 실험장이었다
Emacs Lisp 바이트코드(.elc) Emacs 확장 기능. 바이트 컴파일해 속도를 얻는다
BEAM Erlang, Elixir. 레지스터 기반이며 OTP 24부터 로드 시 JIT 번역
YARV Ruby 1.9부터의 공식 VM. 그전의 Ruby 1.8은 AST를 순회해 실행했다
Zend 옵코드 PHP. OPcache가 옵코드를 공유 메모리에 캐시한다
ActionScript 바이트코드 Adobe Flash Player와 AIR의 ActionScript 가상 머신(AVM)
SQLite VDBE SQLite는 SQL 문을 자체 바이트코드로 번역해 가상 머신에서 실행한다. EXPLAIN으로 볼 수 있다
eBPF 리눅스 커널 안에서 검증 후 실행되는 바이트코드. 대부분 JIT로 기계어 번역
EVM 바이트코드 이더리움 스마트 계약. 256비트 스택 기계
OCaml 바이트코드 ocamlc 출력. 네이티브 컴파일러 ocamlopt와 함께 제공
R 바이트코드 R 2.13부터 표준인 compiler 패키지

디컴파일과 난독화

편집 원본 편집

바이트코드에는 클래스 이름, 메서드 이름, 타입, 제어 구조 같은 정보가 많이 남아 있어 기계어보다 원래 소스에 가깝게 되살리기 쉽다. 그래서 바이트코드로 배포한 프로그램은 리버스 엔지니어링에 약하다.

  • Java: JD-GUI, CFR, Procyon, Fernflower(IntelliJ IDEA에 내장)
  • .NET: ILSpy, dnSpy, dotPeek
  • 안드로이드: jadx(DEX를 Java 소스로), apktool과 baksmali(리소스와 smali로 분해)
  • Python: 버전별 디컴파일러(uncompyle6, decompyle3, pycdc 등). 바이트코드가 버전마다 바뀌어 최신 버전은 지원이 늦다
  • WebAssembly: wasm2wat, wasm-decompile

이를 막으려고 코드 난독화 도구로 이름을 의미 없는 기호로 바꾸고, 쓰지 않는 코드를 지우고, 제어 흐름을 꼬고, 문자열을 암호화한다. 안드로이드의 R8(과 전신인 ProGuard), .NET의 ConfuserEx와 Dotfuscator, Python의 PyArmor가 쓰인다. 난독화는 분석을 어렵게 할 뿐 막지는 못하므로, 비밀 키 같은 민감한 정보를 바이트코드 안에 두어서는 안 된다.

바이트코드 가상 머신은 코드를 실행하기 전에 검증할 수 있다는 장점이 있다. JVM 검증기와 WebAssembly 검증은 스택 균형, 타입 일치, 점프 대상이 올바른지 확인해, 검증을 통과한 코드가 가상 머신의 메모리 안전성을 깨지 않게 한다. 반대로 CPython은 .pyc를 검증하지 않으므로 조작된 바이트코드를 실행하면 인터프리터가 비정상 종료하거나 임의 동작을 할 수 있다. 실행 중에 바이트코드를 바꾸는 기능(Java 에이전트, 바이트코드 조작 라이브러리)은 모니터링과 테스트에 쓰이지만 악성 코드가 같은 기법으로 동작을 숨기기도 한다.