클래스 파일 네버 다이!
제가 20년도 더 전에 쓴 글 하나를 예토전생시켰습니다. 바로 JDK 1.2 시절 자바로 짠 Hello World 프로그램의 클래스 파일을 분석한 글입니다. 이 글의 원본은 대략 2000년 즈음 제가 군인 신분일 때 작성했습니다.
JDK 21 시대에 왠 1.2냐? 다행히 자바 클래스 파일의 구조는 그때나 지금이나 변한 게 거의 없습니다. 그래서 자바의 한 단면인 클래스 파일의 구조를 ‘대략’ 이해하기에는 여전히 유용합니다. 최근 출간된《JVM 밑바닥부터 파헤치기》에서 같은 주제를 다룬 6장은 무려 66쪽에 달하니, 몸풀기나 정리용으로는 이 글이 더 나을 거라 생각합니다.
바로 시작하죠.
먼저 링크한 구글 시트를 열어 옆에 띄워주세요. 함께 보셔야 더 쉽게 이해하실 수 있습니다. 시트는 아래 그림처럼 생겼습니다.
가장 먼저 왼쪽 반을 차지하는 컬러풀한 테이블이 눈에 띌 것입니다. 구조가 쏙쏙 들어오도록 제가 정성 들여 편집한 Hello World 클래스 파일의 바이트코드입니다. 테이블의 왼쪽부터 바이트코드의 오프셋(offset), 해당 위치의 바이트 값(value), 그 값의 의미(analysis)입니다. 예를 들어 오프셋 00부터 시작하는 4바이트에는 클래스 파일을 구분하기 위한 매직 넘버인 “cafebabe”라는 값이 들어 있다는 뜻입니다.
자바 클래스 파일의 구조를 전체적으로 쭉 훑어보지요. 클래스 파일은 다음처럼 크게 다섯 부분으로 나눌 수 있습니다.
- 클래스 파일 헤더: 클래스 파일인지 여부를 식별하고 클래스 파일의 버전을 명시합니다.
- 상수 풀: 현재 클래스 파일에서 쓰이는 상수들이 자리 잡습니다.
- 클래스 정보: 클래스 파일의 접근 제한자, 클래스 이름, 상위 클래스, 구현하는 인터페이스 등의 정보를 담습니다.
- 필드와 메서드: 필드와 메서드의 구체적인 정보와 바이트코드 명령어들이 나옵니다. 일반적으로 클래스 파일에서 가장 긴 영역입니다. 지금 예에서도 생성자와 main 메서드뿐이지만 비중이 상당합니다.
- 클래스 속성: 실행에는 영향을 주지 않는 부가 정보가 담깁니다. 지금 예에서는 소스 파일이 Hello.java라는 정보만을 담고 있습니다.
구글 시트에도 각 부분을 컬러로 잘 나눠뒀습니다. 그럼 이제부터는 하나씩 자세하게 들여다보겠습니다.
헤더 부분은 따로 설명드릴 필요 없을 만큼 구글 시트에 잘 기술돼 있습니다.
클래스 파일의 첫 4바이트를 점거하는 매직 넘버는 말 그대로 “나는 자바 클래스 파일이다!”라고 주장하는 역할을 합니다. 대부분의 프로그램은 파일 유형 구분에 파일 확장자보다 매직 넘버를 선호합니다. 파일 확장자는 일반 사용자도 쉽게 바꿀 수 있기 때문이죠.
그런데 클래스 파일의 매직 넘버는 로맨틱한 “cafe babe”입니다. 이 값은 자바의 이름이 아직 오크(Oak)이던 시절에 지어졌습니다. 자바 개발팀의 핵심 멤버인 패트릭 노튼에 따르면 재미난 뒷 이야기가 있습니다.
우리는 재미나고 기억하기 쉬운 값을 찾고 있었어요. 그런데 마침 즐겨 찾던 카페(Peet’s Coffee)의 인기 바리스타가 눈에 띄어 ‘cafe babe’로 짖게 되었죠.
다음으로 클래스 파일의 포맷 버전이 나옵니다. 자바 가상 머신은 이 버전을 보고 자신이 지원하는 클래스 파일인지를 판단합니다. 중간중간 예외가 있지만 메이저 버전에서 44를 뺀 값이 JDK 버전입니다. 구글 시트의 예는 JDK 1.2의 컴파일러로 컴파일했지만 45로 되어 있습니다. 이는 컴파일 시 -target 매개변수를 지정하지 않아서 1.x와 호환되게 만들어졌기 때문입니다.
상수 풀은 현재 클래스 파일에서 쓰이는 모든 상수를 모아놓은 곳입니다.
지금 예는 그저 “Hello, world”를 출력하는 간단한 프로그램임에도 구글 시트를 보면 상수 풀에 무언가가 잔뜩 담겨 있습니다. 이 많은 상수들은 대체 뭘까요? 간단히 답하자면 클래스 이름, 슈퍼 클래스 이름, 필드 이름, 메서드 이름, 메서드 입력 매개변수 등을 표현하는 데 필요한 상수들입니다.
상수 풀의 처음 2바이트가 상수의 개수를 알려줍니다. 2바이트라는 제한 때문에 최대 65535개의 상수를 담을 수 있습니다. 또한 0번 상수는 가상 머신이 예약해 놔서, 실제 등장하는 상수보다 1개 많다고 표시됩니다. 즉, 구글 시트의 예에는 총 29개의 상수가 있다고 했지만 파일에서 실제로 등장하는 상수는 28개입니다.
상수 개수 다음에는 실제 상수들이 나열됩니다.
각 상수의 첫 1바이트를 상수 태그(constant tag)라 하여, 상수의 종류를 구분해 줍니다. JDK 1.2 시절에는 1~12번까지의 태그가 쓰이는데, 2번 태그는 쓰이지 않아서 총 11개의 상수 태그가 존재합니다(JDK 21 기준으로는 총 6개가 늘었습니다). 각 상수에 대한 설명은 구글 시트 오른쪽의 표에 정리했습니다. 1번은 UTF8, 3번은 Integer, 4번은 Float, ..., 12번은 NameAndType 등입니다.
여기서 사람 헷갈리게 하는 부분들이 나타납니다.
Integer, Float, Long, Double 같은 숫자형 타입은 어려울 게 없습니다. 그냥 상수 태그 바로 뒤에 적절한 자신의 값을 나타내면 되지요. 예컨대 다음은 ‘Integer 타입의 값 113’을 뜻하는 상수의 바이트코드입니다.
한편, 상수 태그 표를 보면 자바 언어의 boolean, byte, char, short에 해당하는 상수 태그는 존재하지 않습니다. 이들은 모두 가상 머신의 관점에서는 Integer가 되며, 이 변환을 자바 컴파일러가 해주는 것입니다.
그렇다면 숫자가 아닌 타입들은 어떨까요? (여기서부터 살짝 복잡해집니다.)
1번 타입인 UTF8을 봅시다. 구글 시트의 상수 풀에서 가장 먼저 나온 UTF8 상수가 7번이군요. 이 상수는 다음과 같습니다.
바이트코드로는 01 0006입니다. 여기서 앞의 01은 UTF8을 뜻하는 상수 태그고, 다음의 0006은 뒤이은 6바이트가 실제 값이라는 뜻입니다. UTF8 상수이므로 다음 6바이트를 문자열로 변환해 보면 “<init>”입니다. “<init>”는 클래스 파일의 생성자를 뜻합니다. 부연하자면, 자바 언어에서는 생성자의 이름이 클래스 이름과 같습니다. 하지만 바이트코드로 컴파일되면 클래스 이름과 상관없이 모두 “<init>”라는 이름으로 쓰입니다.
8번 상수 역시 UTF8인데, 길이가 3바이트이고 값이 “()V”입니다. 이는 메서드의 입력 매개변수가 없고 반환값이 void임을 뜻합니다. 만약 입력 매개변수가 있다면 그 정보가 괄호 안에 나열됩니다. 괄호 다음에는 반환 값의 타입이 오는데, “V”는 void를 의미합니다.
예를 들어 28번 상수는 값이 “(Ljava/lang/String;)V”입니다. java/lang/String 타입 매개변수를 하나 받고, 반환 값의 타입은 마찬가지로 void라는 뜻입니다. 보다시피 타입을 명시할 때는 클래스의 전체 이름이 쓰입니다. 맨 앞의 “L”은 클래스 타입임을 가리킵니다.
마지막으로 12번 상수는 값이 “([Ljava/lang/String;)V”입니다. 8번 상수와 비슷하지만 상수 타입 맨 앞에 “[” 문자가 하나 추가됐습니다. “[”은 배열을 가리킵니다. 그래서 값이 “main”인 11번 상수와 조합하면 void main(String[] args) 함수를 표현할 수 있습니다.
이처럼 메서드 하나를 표현하려면 메서드의 이름을 뜻하는 상수 하나, 입력 매개변수와 반환 타입을 나타내는 상수 하나, 이렇게 총 두 개의 상수가 쓰임을 알 수 있습니다.
상수 태그 10인 Methodref는 메서드 ‘참조(ref)’를 뜻합니다. 상수 태그 표에서 보듯 총 4바이트로 구성되며, 앞의 2바이트는 Class 타입의 상수 태그 인덱스, 다음 2바이트는 NameAndType 타입의 태그 인덱스입니다(인덱스란 상수 풀에서의 번호를 말합니다). 여기서 Class는 이 메서드를 정의한 클래스를 가리키고, NameAndType은 메서드 자신의 이름과 타입(입력/반환 매개변수 타입)을 말합니다. 구글 시트에서 실제 예를 찾아 분석해 보죠. 상수 풀에서 1번 상수가 마침 Methodref군요.
그림과 같이 상수가 가리키는 곳을 추적하여 조합하면 1번 상수는 java.lang.Object 클래스의 기본 생성자를 뜻함을 알 수 있습니다.
같은 방식으로 4번 상수도 추적해 보면 java.io.PrintStream의 void println(java.lang.Stream) 메서드입니다.
필드 참조인 Fieldref도 같은 원리로 구성됩니다. Fieldref 타입인 2번 상수를 추적해 보면 java.lang.System의 out 필드를 가리키고, 이 필드의 타입은 java.io.Stream임을 알 수 있습니다.
상수 풀 다음에는 클래스 정보가 나옵니다. 그런데 다음 그림에서 보듯 별게 없습니다.
처음 2바이트는 클래스의 접근 플래그입니다. 시트에 정리해 둔 표와 같이 클래스가 public인지, final인지, 인터페이스인지, 추상 클래스인지 등을 식별합니다.
ACC_SUPER는 JDK 1.0.2 때 변경된 invokespecial 바이트코드 명령어의 새로운 의미를 허용하는지 여부입니다. 그래서 JDK 1.0.2 이후 JDK로 컴파일된 클래스에서는 모두 이 플래그가 1로 설정되어 있습니다.
시트에서 현재 클래스의 접근 플래그 값인 0x0021을 이진수로 바꾸면 0000 0000 0010 0001입니다. 1번과 6번 비트가 설정되어 있으니 Hello World 앱의 클래스는 public입니다(6번은 ACC_SUPER).
이어서 자신은 무슨 클래스인지와 부모 클래스는 무엇인지가 나옵니다. 각각 상수 풀에서 5번과 6번 상수라고 하는군요. 각각을 추적하여 해석하면 현재 클래스의 이름은 “Hello”이고 Object를 직접 상속했음을 알 수 있습니다.
마지막으로, 구현한 인터페이스 수는 0개입니다. 무언가 인터페이스를 구현했다면 그 개수가 여기 나오고, 이어서 개수만큼 인터페이스 이름들이 나열되었을 것입니다.
클래스 파일의 몸통이라고 볼 수 있는 필드와 메서드 부분을 알아봅시다.
지금 예는 단순히 “Hello, world”를 출력하는 클래스라서 필드는 없습니다.
메서드는 2개라고 합니다. 바로 기본 생성자와 main 메서드죠. 0번이 생성자이고 1번이 main 메서드입니다. 구조가 매우 비슷하니 우리는 main 메서드만 자세히 보겠습니다.
대부분 설명을 그림에 설명글로 추가했습니다. 지금까지의 내용을 잘 따라오셨다면 이해하시는 데 별 무리가 없을 겁니다. 보다시피 public static void main(String[] args) 형태의 자바 메인 메서드입니다.
‘Code’ 속성에 담긴 바이트코드의 의미를 간단히만 설명드리겠습니다.
getstatic #2: System 클래스에 정의된 PrintStream 타입 정적 필드인 out을, 즉 System.out을 가리키는 포인터를 피연산자 스택의 첫 번째 슬롯에 로드합니다.
ldc #3: “Hello, world” 문자열을 스택의 두 번째 슬롯에 로드합니다.
invokevirtual #4: PrintStream 클래스의 메서드 중 이름이 println이고 입력 타입이 String, 반환 타입이 void인 메서드를 호출합니다. 이때 입력값과 호출 대상 인스턴스를 피연산자 스택에서 꺼내갑니다.
즉, 이상의 세 명령어는 자바 코드의 System.out.println(“Hello, world”)에 해당합니다.
이상으로 메인 메서드가 클래스 파일에서 어떻게 표현되는지를 간략히 살펴보았습니다. 0번 메서드인 생성자에 대해서는 여러분을 위한 여흥 거리로 남겨두겠습니다. 참고로, 0번 메서드는 기본 생성자를 가리키며, 부모 클래스인 java.lang.Object의 생성자를 호출한 후(super();) 곧바로 반환하도록(return;) 구현되었습니다. 컴파일러가 자동으로 만들어주는 생성자의 전형적인 모습입니다. 지역 변수 슬롯이 1개 필요한 이유는 암묵적으로 this를 넘겨주기 때문입니다. 즉, 메서드 안에서 다른 인스턴스 필드나 메서드를 호출할 때 이 this 지역 변수를 참조합니다.
드디어 마지막입니다. 필드와 메서드 다음에는 클래스 속성 정보가 뒤따릅니다. 지금 예에서는 속성이 1개뿐이군요. 어떤 정보가 담겨 있는지 따라가 봅시다.
이 클래스 파일을 생성한 소스 파일이 Hello.java라는 정보가 담겨 있군요.
저는 제대 후 입사한 회사에서 J2ME 앱을 개발했습니다. 당시는 J2ME 지원 핸드폰들이 세상에 갖 등장한 시기였는데, 구동시킬 수 있는 자바 앱의 최대 크기가 30KB를 넘지 못했습니다. 덕분에(?) 공통 라이브러리를 제작하는 건 사치였죠. 하지만 전부터 범용 기능들은 라이브러리로 제작해 활용하던 저는 그 장점을 쉽게 포기할 수 없었습니다.
마침 클래스 파일 구조를 대략 파악하고 있던 저는 토이 프로젝트로 가칭 ‘메서드 킬러’라는 프로그램을 작성해 보았습니다. 함께 패키징되어 배포되는 클래스 파일들에서 실제로 호출되지 않는 클래스, 메서드, 필드를 제거하여 배포 크기를 줄이는 게 목표였죠. 라이브러리를 쓰되, 앱에서 실제로 사용하는 코드만 남기는 것이 메서드 킬러의 기본 아이디어였습니다.
그 외에 클래스 파일의 구조를 이해하면 어디에 응용할 수 있는지를 ChatGPT 4에 물어보았습니다.
디버깅 및 문제 해결: 자바 클래스 파일의 내부 구조를 이해하면, 컴파일된 바이트코드에서 발생하는 문제를 더 쉽게 식별하고 디버깅할 수 있습니다. 예를 들어, 클래스 파일의 특정 부분에서 발생한 오류를 정확히 찾아내는 데 도움이 됩니다.
바이트코드 조작 및 최적화: 바이트코드를 직접 조작하거나 최적화해야 하는 경우, 클래스 파일의 구조를 이해하는 것이 필수적입니다. 이는 성능 개선이나 특정 기능을 추가하거나 변경할 때 유용합니다.
커스텀 클래스 로더 개발: 맞춤형 클래스 로더를 작성하려면 클래스 파일의 형식과 구조를 이해해야 합니다. 이를 통해 특정 조건 하에서 클래스 로딩을 제어하고, 애플리케이션의 동적 로딩 기능을 구현할 수 있습니다.
리플렉션(Reflection) 활용: 리플렉션을 사용하여 클래스의 메타데이터(메서드, 필드, 어노테이션 등)를 동적으로 분석하고 조작할 수 있습니다. 이를 위해 클래스 파일의 구조를 이해하면 더 효과적으로 리플렉션을 활용할 수 있습니다.
바이트코드 라이브러리 사용: ASM, BCEL, Javassist와 같은 바이트코드 조작 라이브러리를 사용할 때, 클래스 파일의 구조를 이해하면 더 쉽게 라이브러리를 활용하고, 필요에 맞게 바이트코드를 생성하거나 수정할 수 있습니다.
보안 분석: 클래스 파일을 분석하여 잠재적인 보안 취약점을 찾아내는 데 유용합니다. 예를 들어, 악성 코드가 포함된 클래스를 식별하거나, 안전하지 않은 코드 패턴을 찾아낼 수 있습니다.
역공학(Reverse Engineering): 클래스 파일의 구조를 이해하면, 자바 프로그램을 역공학하여 원본 소스 코드에 가까운 형태로 복원할 수 있습니다. 이는 특정 프로그램의 동작 방식을 분석하거나 학습 목적으로 유용할 수 있습니다.
성능 분석 도구 개발: 클래스 파일의 구조를 이해하면, 자바 애플리케이션의 성능을 분석하는 도구를 개발하는 데 도움이 됩니다. 예를 들어, 바이트코드 분석을 통해 실행 중인 애플리케이션의 성능 병목 지점을 식별할 수 있습니다.
컴파일러 및 인터프리터 개발: 자바 클래스 파일의 구조를 깊이 이해하면, 자바 컴파일러나 인터프리터를 개발하거나 개선하는 데 도움이 됩니다. 이는 언어 확장이나 새로운 JVM 기능을 추가할 때 유용할 수 있습니다.
플러그인 및 에이전트 개발: 자바 에이전트를 작성하거나 플러그인을 개발할 때, 클래스 파일을 변환하거나 주입하는 작업이 필요할 수 있습니다. 클래스 파일의 구조를 잘 이해하면 이러한 작업을 효과적으로 수행할 수 있습니다.
제가 잠깐 고민하며 떠올린 응용 분야보다 훨씬 다양하군요.
이상으로 자바 Hello World 프로그램을 표현하는 바이트코드의 분석을 마치겠습니다. 이 주제로 이만큼 길게 설명한 자료는 찾기 어려울 겁니다. 사실 2000년 즈음에 쓴 원본은 지금보다 몇 배는 길었습니다. 하지만 너무 만담체라서 싹 잘라내고, 핵심에서 벗어나는 자잘한 설명도 삭제했습니다.
클래스 파일의 더 깊은 지식에 목마른 분은 《JVM 밑바닥부터 파헤치기》의 6장 ‘클래스 파일의 구조’를 참고해 주세요. 훨씬 자세하게, 최신 JDK를 기준으로 설명합니다. 또한 바이트코드 명령어들에 대해서도 알 수 있으니 매우 흥미로울 것입니다.