사전 바이트코드
개념

바이트코드

gabury1

바이트코드는 프로그램을, 소프트웨어로 만들어 낸 기계인 가상 머신이 바로 읽는 명령으로 옮겨 놓습니다. 사람이 쓴 소스 코드와, 기계가 번역 없이 곧바로 실행하는 기계어 사이에 한 단계를 더 두는 셈입니다. 그래서 한 번 만든 것을 여러 종류의 기계에서 돌릴 수 있습니다.

쉽고 빠른 이해

가상 머신은 소프트웨어로 만들어 낸 기계 한 벌입니다. 바이트코드는 프로그램을 그 기계가 읽는 명령으로 옮겨 놓은 것입니다. 자바 소스를 컴파일하면 나오는 클래스 파일 안에 이 명령이 들어 있습니다.

기계가 번역 없이 곧바로 실행하는 명령을 기계어라고 합니다. 기계어까지 옮기면 그 종류의 기계에서만 돕니다. 한 단계 앞에서 멈추면 가상 머신이 깔린 곳이면 어디서든 같은 파일을 돌릴 수 있습니다.

어떻게 도는가:

  1. 컴파일러가 소스를 바이트코드로 옮깁니다
  2. 그 결과를 파일에 담아 배포합니다
  3. 가상 머신이 명령을 하나씩 읽어 처리합니다

대가가 있습니다. 명령을 읽어 넘기는 겹이 하나 더 있어 프로그램이 뜨는 데 시간이 더 듭니다. 사람 눈에는 안 읽혀 도구를 거쳐야 합니다. 적는 규칙이 뚜렷해서, 도구를 쓰면 남도 원래 코드에 가까운 모양을 되살릴 수 있습니다.

상세

프랜차이즈 빵집 본사는 빵을 굽지 않습니다. 반죽까지만 해서 얼린 생지를 전국 매장으로 보냅니다. 오븐이 놓인 매장이면 어디서 구워도 같은 빵이 나옵니다.

바이트코드는 컴파일러가 소스 코드를 옮긴 결과입니다. 그 명령은 진짜 기계의 것이 아니라 가상 머신이 정한 것입니다.

자바 소스를 컴파일하면 나오는 클래스 파일 안에 들어 있는 명령이 그것입니다. 이름은 명령 하나를 1바이트 크기의 번호로 적는 데서 왔습니다.

가상 머신은 소프트웨어로 만들어 낸 기계 한 벌입니다. 위에 얹힌 프로그램은 그것을 진짜 기계로 알고 명령을 냅니다. 가상 머신이 그 명령을 받아 진짜 하드웨어에 넘깁니다.

옮기기를 중간에서 멈추는 까닭은 기계마다 알아듣는 명령이 다르기 때문입니다. 명령을 알아듣는 것은 기계 안의 CPU(Central Processing Unit, 중앙 처리 장치)입니다. 기계어는 CPU 가 번역 없이 곧바로 실행하는 명령입니다. CPU 종류가 다르면 같은 동작도 다른 번호로 적힙니다.

그래서 기계어까지 옮겨 버리면 그 종류의 CPU 에서만 돕니다. 소스에서 기계어까지 가는 길을 둘로 나눠, 앞쪽만 컴파일러가 하고 뒤쪽은 실행할 때 가상 머신이 맡습니다.

여기까지를 한 그림으로 보면 이렇습니다. 자바 소스를 옮기는 컴파일러는 javac, 코틀린 소스를 옮기는 컴파일러는 kotlinc 입니다. 둘이 내놓는 것은 같은 바이트코드입니다.

flowchart TD
    A1["자바 소스"] --> B1["javac"]
    A2["코틀린 소스"] --> B2["kotlinc"]
    B1 --> C["바이트코드 · 클래스 파일"]
    B2 --> C
    C --> D["가상 머신"]

컴파일러의 몫은 바이트코드까지입니다. 그 아래는 실행할 때의 일입니다.

명령 하나의 생김새

명령 하나는 연산 코드로 시작합니다. 연산 코드는 「무엇을 해라」를 가리키는 번호입니다. 이 번호가 1바이트라서 한 가상 머신이 쓸 수 있는 동작은 256가지 안쪽입니다.

동작만으로 부족한 명령은 뒤에 값을 더 답니다. 이 값을 피연산자라고 부릅니다. 「몇 번 변수를 가져와라」의 「몇 번」이 피연산자입니다.

그래서 바이트코드 한 덩이는 번호와 값이 빈틈없이 이어 붙은 바이트의 줄입니다. 가상 머신은 이 줄을 앞에서부터 한 번호씩 읽어 나갑니다.

명령 셋이 이어 붙은 줄을 그리면 이렇습니다.

block-beta
columns 5
  a["연산 코드"] b["피연산자"] c["연산 코드"] d["연산 코드"] e["피연산자"]

값을 안 다는 명령은 한 바이트로 끝납니다. 그 다음 바이트가 바로 다음 명령의 연산 코드입니다.

값을 쌓아 두고 계산하는 방식

명령은 계산에 쓸 값을 어디에 둘지 정해야 합니다. 바이트코드는 대개 스택 하나를 둡니다. 값을 거기 올렸다 꺼내는 식으로 계산합니다.

더하기를 예로 들면 이렇습니다. 값 두 개를 차례로 올립니다. 더하기 명령이 그 둘을 꺼내 합을 다시 올립니다. 더하기 명령 자체는 어떤 값을 더하는지 안 적습니다. 스택 꼭대기에 있는 것을 더할 뿐입니다.

3 과 4 를 더할 때 스택이 어떻게 변하는지 그리면 이렇습니다.

flowchart TD
    subgraph S1["올리기 전"]
        E1["비었다"]
    end
    subgraph S2["값 둘을 올린 뒤"]
        B1["꼭대기 · 4"]
        B2["아래 · 3"]
    end
    subgraph S3["더하기가 꺼내 합을 올린 뒤"]
        C1["꼭대기 · 7"]
    end
    S1 -->|"값 둘을 올린다"| S2
    S2 -->|"둘을 꺼내 더한 값을 올린다"| S3

꼭대기 두 칸이 한 칸으로 줄었습니다. 더하기 명령이 적은 것은 「더해라」뿐입니다.

명령이 짧아지는 것이 이 방식의 이득입니다. 값을 어디에 둘지 매번 안 적어도 되니 명령 하나가 번호 한 바이트로 끝나는 경우가 많습니다. 대신 값을 올리고 내리는 명령이 따로 필요해 명령 개수는 늘어납니다.

값을 둘 곳마다 번호를 붙여 명령이 그 번호를 적는 갈래도 있습니다. 레지스터 방식이라고 부릅니다. 이쪽은 명령 하나가 길어지는 대신 개수가 줄어듭니다. 자바 계열이 쓰는 것은 앞의 스택 방식입니다.

번지가 아니라 이름으로 적는 참조

바이트코드는 다른 클래스의 메서드를 부를 때 그 메서드가 놓인 메모리 번지를 적지 않습니다. 이름을 적습니다. 그 이름은 실행할 때 찾습니다.

번지를 미리 적을 수 없기 때문입니다. 컴파일하는 때와 실행하는 때는 다릅니다. 그 사이에 상대 클래스가 새 판으로 바뀌어 있을 수도 있습니다. 이렇게 이름으로만 적은 참조를 기호 참조라고 부릅니다.

이름들은 명령 줄 안에 흩어져 있지 않고 한 표에 모여 있습니다. 명령은 그 표의 몇 번째 칸인지만 적습니다. 자바 계열에서 이 표가 상수 풀입니다.

명령이 이름을 어떻게 가리키는지 그리면 이렇습니다.

flowchart TD
    subgraph L["명령 줄"]
        I1["메서드를 부르는 명령 · 7번 칸"]
    end
    subgraph P["상수 풀"]
        P7["7번 칸 · 클래스 이름 · 메서드 이름"]
    end
    I1 --> P7

명령에는 번호만 남습니다. 이름은 표 한 곳에 모입니다.

가상 머신이 이 명령을 처리하는 두 방법

첫째는 한 명령씩 읽어 그때그때 해당하는 동작을 하는 것입니다. 이 방식을 인터프리터라고 부릅니다. 프로그램을 켜자마자 바로 돌기 시작하지만, 같은 곳을 만 번 지나면 만 번 다 읽습니다.

둘째는 자주 지나는 대목을 골라 그 기계의 기계어로 미리 옮겨 두는 것입니다. 다음부터는 옮겨 둔 기계어를 바로 실행합니다. 실행하는 도중에 옮긴다고 해서 JIT 컴파일(Just-In-Time Compilation, 제때 번역)이라고 부릅니다.

가상 머신은 대개 둘을 같이 씁니다. 처음에는 읽어 가며 돌리다가, 반복이 잦은 대목이 드러나면 그 대목만 기계어로 옮깁니다.

자바와 코틀린이 공유하는 명령

자바 소스는 javac 가, 코틀린 소스는 kotlinc 가 바이트코드로 옮깁니다. 두 컴파일러가 내놓는 것은 같은 형식의 클래스 파일이라, JVM(Java Virtual Machine, 자바 가상 머신)은 그 파일이 어느 언어에서 왔는지 따지지 않습니다. 한 프로젝트에서 두 언어를 섞어 쓸 수 있는 바탕이 이것입니다.

옮긴 결과는 javap 로 펴 볼 수 있습니다. 두 정수를 더해 변수에 담는 코드는 이런 명령들로 남습니다.

iload_1     // 1번 변수 값을 스택에 올린다        스택: 3
iload_2     // 2번 변수 값을 스택에 올린다        스택: 3 4
iadd        // 둘을 꺼내 더한 값을 올린다         스택: 7
istore_3    // 꺼내서 3번 변수에 담는다           스택: 비었다

이름이 곧 동작입니다. iload 는 정수를 올립니다. iadd 는 정수 둘을 더합니다. 앞의 i 는 정수라는 표시입니다. 실수를 다루는 명령으로는 fload 와 fadd 가 따로 있습니다.

중간 단계가 주는 이득과 대가

이 방식에는 얻는 것과 잃는 것이 같이 옵니다.

얻는 것 잃는 것
배포 기계 종류마다 다시 컴파일하지 않습니다 받는 쪽에 가상 머신이 깔려 있어야 합니다
실행 자주 도는 대목을 실행 중에 기계어로 옮길 수 있습니다 옮기기 전까지는 명령을 읽어 가며 돌아 시작에 시간이 더 듭니다
코드 도구로 열어 무슨 명령이 들었는지 볼 수 있습니다 남이 열어 보는 것도 똑같이 쉽습니다

마지막 줄은 실무에서 자주 걸립니다. 바이트코드에는 클래스 이름과 메서드 이름이 글자 그대로 남아 있어서, 파일만 있으면 원래 코드에 가까운 모양을 되살릴 수 있습니다. 그래서 이름을 짧고 뜻 없는 것으로 바꾸는 난독화 도구를 씁니다.

관련 항목

바이트코드를 읽어 실행하는 실행기

JVM · 가상 머신 · 인터프리터 · JIT 컴파일 · JRE · 런타임

바이트코드를 만들어 내는 컴파일러

javac · kotlinc · 컴파일러 · JDK · AOT 컴파일

바이트코드가 담기는 파일과 그 구성 요소

클래스 파일 · 상수 풀 · 디스크립터 · JAR

실행할 때 이름을 실제 대상으로 바꾸는 절차

클래스 로딩 · 클래스 로더 · 기호 참조 · 런타임 상수 풀 · 클래스패스

바이트코드를 사람이 읽게 풀어 주는 도구

javap · 디스어셈블러 · 디컴파일러 · 난독화

바이트코드를 고쳐 끼우는 기법

바이트코드 조작 · 계측 · 자바 에이전트 · 리플렉션

같은 역할을 맡는 다른 중간 표현

중간 표현 · WebAssembly · LLVM IR · 파이썬 바이트코드

바이트코드와 맞세워지는 실행 형태

기계어 · 네이티브 코드 · 어셈블리 · 인터프리터 언어

바이트코드가 도는 동안 값이 놓이는 메모리 구조

스택 · 힙 · 피연산자 스택 · 지역 변수 배열 · 프레임

바이트코드 실행을 재는 지표

지연 · 처리량 · 시작 시간 · 메모리 사용량

바이트코드를 정의하는 명세와 규격

JVMS · JLS · Java SE · 명령어 집합

다른 이름: bytecode · byte code · 바이트 코드