2주차 Unit 3.1 — 바이트코드란 무엇인가

Psj·2026년 5월 15일

F-lab

목록 보기
60/240

Unit 3.1 — 바이트코드란 무엇인가

F-LAB JAVA · 2주차 · Phase 3 · 바이트코드와 상수 풀
🎯 2주차의 정점 시작 — .class 파일을 눈으로 읽기 시작한다


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • 바이트코드와 기계어의 결정적 차이는?
  • .java.class 컴파일 후, .class 파일의 내부 구조는?
  • javap -c -v 명령으로 무엇을 볼 수 있나?
  • 바이트코드 명령어가 약 200개라고 하는데, 어떤 카테고리로 나뉘는가?
  • "Write Once, Run Anywhere" 의 비밀이 정확히 무엇인가?
  • Magic Number 0xCAFEBABE 의 의미는?
  • ILIC 운영에서 바이트코드를 직접 읽어야 할 순간은 언제인가?

🎯 핵심 한 문장

바이트코드는 "JVM이라는 가상 CPU의 기계어" 다.
실제 CPU(x86, ARM)의 기계어보다 한 단계 추상화되어 있고, 그 한 단계 위에서 자바의 모든 마법(플랫폼 독립성, JIT 최적화, AOP, 동적 프록시)이 일어난다.
Phase 1~2에서 본 모든 메모리 메커니즘이 바이트코드 명령어로 표현된다.

비유 — 만국 공통 악보

시스템비유
.java 소스작곡가가 자기 언어로 쓴 메모
.class 바이트코드국제 표준 악보 (오선지)
JVM악보를 해석하는 지휘자
x86/ARM 기계어각국 악단이 자기 악기로 연주

작곡가가 한국어로 작곡해도(자바), 국제 표준 악보(바이트코드)로 변환되면 미국 악단(x86)도, 유럽 악단(ARM)도 같은 음악을 연주할 수 있다.

"Write Once, Run Anywhere"의 정체.


🧭 9개 섹션 로드맵

1. 바이트코드의 본질 — 기계어와 무엇이 다른가
2. .java → .class 컴파일 과정
3. .class 파일의 구조 — 10개 섹션
4. javap 도구 마스터하기
5. 바이트코드 명령어 카테고리 — 200개 명령어 분류
6. Write Once Run Anywhere의 비밀
7. ILIC 실무 — 바이트코드 분석이 필요한 순간
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검

1️⃣ 바이트코드의 본질 — 기계어와 무엇이 다른가

1.1 컴파일 단계 비교

C 언어:
  .c → 컴파일 → .exe (x86 기계어)
                      ↑
                      이 .exe는 ARM에선 못 돌림

자바:
  .java → 컴파일 → .class (바이트코드)
                       ↓ JVM이 해석
                       x86 / ARM / RISC-V / ... 어디서든

C는 타겟 OS/CPU의 기계어 로 직접 컴파일.
자바는 JVM이라는 가상 CPU의 기계어 로 컴파일.

1.2 같은 코드의 두 가지 모습

자바 코드:

int sum = a + b;

바이트코드 (JVM이 이해):

iload_1     // a를 스택에 push
iload_2     // b를 스택에 push
iadd        // 더하기
istore_3    // sum에 저장

x86 기계어 (CPU가 이해):

mov eax, [rbp-4]   ; a를 eax 레지스터로
add eax, [rbp-8]   ; eax에 b 더하기
mov [rbp-12], eax  ; eax를 sum 위치에 저장

ARM 기계어 (다른 CPU):

ldr w0, [sp, #8]   ; a를 w0 레지스터로
ldr w1, [sp, #12]  ; b를 w1 레지스터로
add w0, w0, w1     ; w0 = w0 + w1
str w0, [sp, #16]  ; sum에 저장

→ 자바의 바이트코드는 하나의 표현, 기계어는 CPU별로 다름.

1.3 4가지 결정적 차이

항목바이트코드기계어 (x86 등)
대상JVM (가상 CPU)실제 CPU
모델스택 기반레지스터 기반
이식성✓ 어떤 OS든 JVM 있으면 실행❌ CPU별로 다름
레벨추상화 (객체, 메서드 개념 보존)저수준 (메모리 주소만)

1.4 스택 머신 vs 레지스터 머신

Unit 1.4에서 봤듯이 JVM은 스택 머신.

스택 머신 (JVM):
  iload_1     →  스택: [a]
  iload_2     →  스택: [a, b]
  iadd        →  스택: [a+b]
  istore_3    →  스택: []

레지스터 머신 (x86):
  mov eax, a    →  레지스터 eax = a
  add eax, b    →  레지스터 eax = a + b
  mov sum, eax  →  메모리 sum = eax

스택 머신의 장점:

  • 단순함: 명령어 적음 (200개 정도)
  • 이식성: 레지스터 개수 다른 CPU에 무관
  • 컴파일러 단순: 표현식 → 스택 명령 일대일 변환

스택 머신의 단점:

  • 명령어 수 많음 (메모리↔스택 이동 필요)
  • → JIT 컴파일러가 런타임에 레지스터 기반 코드로 변환

1.5 객체 개념의 보존

바이트코드는 단순한 어셈블리 명령보다 고수준.

바이트코드:
  new           #2     // class com/ilic/Shipment    ← 클래스 이름 보존
  invokevirtual #15    // calculate(I)BigDecimal     ← 메서드 시그니처 보존
  getfield      #8     // blNo                        ← 필드 이름 보존
x86 기계어:
  call 0x401234        ; 함수 주소
  mov rax, [rdi+8]     ; 메모리 오프셋

→ 바이트코드는 클래스, 메서드, 필드의 의미적 정보를 그대로 유지.
→ Reflection, AOP, 디버거가 가능한 이유.
→ 사람도 읽을 수 있는 이유.


2️⃣ .java → .class 컴파일 과정

2.1 컴파일러의 역할

javac App.java
# 결과: App.class

javac가 하는 일:

.java 소스 코드
   ↓
1. Lexical Analysis (어휘 분석)
   소스를 토큰으로 분리: int, sum, =, a, +, b, ;
   ↓
2. Syntax Analysis (구문 분석)
   토큰을 AST(Abstract Syntax Tree)로 구성
   ↓
3. Semantic Analysis (의미 분석)
   타입 검사, 변수 선언 검증, 메서드 시그니처 매칭
   ↓
4. Bytecode Generation
   AST → 바이트코드 명령어 시퀀스
   ↓
5. Constant Pool 생성
   문자열, 클래스 참조 등을 풀에 모음
   ↓
.class 파일 작성

2.2 컴파일러가 자동으로 추가하는 것

자바 컴파일러는 사용자가 안 쓴 코드도 자동 삽입:

// 사용자 코드
public class Shipment {
    private String blNo;
}
// 컴파일러가 실제로 만드는 것 (개념적)
public class Shipment extends Object {     // ← extends Object 자동
    private String blNo;
    
    public Shipment() {                     // ← 기본 생성자 자동
        super();                            // ← super() 자동
    }
}

자동 추가되는 것:

  • extends Object (명시 없으면)
  • 기본 생성자 (다른 생성자가 없을 때)
  • super() 호출 (생성자 첫 줄)
  • 필드 초기값을 생성자에 삽입
  • 인스턴스 초기화 블록을 생성자에 삽입
  • enum의 values(), valueOf() 메서드
  • inner class와 outer class의 합성 메서드

2.3 컴파일러의 최적화

자바 컴파일러는 최소한의 최적화만 수행:

  • 상수 폴딩(Constant Folding): 1 + 23
  • 죽은 코드 제거: 사용 안 되는 if-false 블록
  • String 리터럴 결합: "a" + "b""ab"

대부분의 최적화는 JIT 컴파일러가 런타임에 수행 (Unit 2.3 7장).

.class 파일은 의외로 소스 코드의 직역에 가깝다.

2.4 디버그 정보

javac -g App.java

-g 옵션은 추가 정보를 .class 에 포함:

  • LocalVariableTable: 지역 변수 이름 (Unit 1.4)
  • LineNumberTable: 소스 줄 번호 매핑
  • 디버거가 변수명/줄번호를 보여주는 비밀

배포용 코드에는 보통 -g:none 으로 제거 (파일 크기 감소).
하지만 운영 디버깅 어려워지므로 trade-off.


3️⃣ .class 파일의 구조 — 10개 섹션

3.1 한눈에 보는 구조

.class 파일 (바이너리)
┌─────────────────────────────────────────┐
│ 1. Magic Number    (4 bytes)             │  ← 0xCAFEBABE
│ 2. Version Number  (4 bytes)             │  ← JDK 버전
│ 3. Constant Pool                          │  ← Phase 3.2의 주역
│ 4. Access Flags                           │  ← public, abstract 등
│ 5. This Class                             │  ← 자기 클래스 참조
│ 6. Super Class                            │  ← 부모 클래스 참조
│ 7. Interfaces                             │  ← 구현 인터페이스
│ 8. Fields                                 │  ← 필드 선언
│ 9. Methods                                │  ← 메서드 + 바이트코드
│ 10. Attributes                            │  ← 추가 메타데이터
└─────────────────────────────────────────┘

3.2 Magic Number — 0xCAFEBABE

파일 시작 4 bytes: CA FE BA BE

왜 0xCAFEBABE?

  • 1991년 James Gosling이 자바 개발 시 정함
  • 16진수로 영어 표기 가능한 값 (Cafe Babe)
  • 자바 = 커피 이름 → 카페 아가씨 농담
  • 모든 .class 파일이 이 값으로 시작
  • JVM이 첫 4바이트로 "유효한 .class 파일인가?" 확인
# 직접 확인
hexdump -C App.class | head -1

# 출력:
# 00000000  ca fe ba be 00 00 00 41  ...
#           ↑ Magic Number  ↑ Version

3.3 Version Number

Magic 다음 4 bytes: Minor + Major
MajorJava 버전
52Java 8
55Java 11
61Java 17
65Java 21

호환성 규칙:

  • 높은 버전으로 컴파일된 클래스를 낮은 JVM에서 실행 → UnsupportedClassVersionError
  • 낮은 버전 클래스는 높은 JVM에서 OK (forward compatibility)
javac -target 8 App.java   # Java 8 호환으로 컴파일
javac --release 11 App.java # Java 11 API + 호환으로

ILIC 운영 시 흔한 함정: 빌드 환경 JDK와 런타임 JDK 버전 차이 → 이 에러.

3.4 Constant Pool — 다음 Unit의 주역

Constant Pool: 이 클래스가 참조하는 모든 상수의 풀
  - String 리터럴
  - 클래스 참조
  - 메서드 참조
  - 필드 참조
  - 숫자 상수

Phase 3에서 다음 Unit (3.2) 전체가 이것.
→ 바이트코드의 #2, #15 같은 인덱스가 모두 여기 가리킴.

3.5 Methods 섹션 — 바이트코드의 거주지

각 메서드 정보:
  ├─ 접근 플래그 (public, static 등)
  ├─ 이름 인덱스 → Constant Pool의 메서드 이름
  ├─ 시그니처 인덱스 → "(I)Ljava/math/BigDecimal;" 같은 디스크립터
  └─ 속성:
      └─ Code 속성:
          ├─ max_stack (Operand Stack 최대 크기)
          ├─ max_locals (LVA 크기)
          ├─ 바이트코드 명령들 ★
          ├─ Exception Table (try-catch)
          └─ LineNumberTable, LocalVariableTable (선택)

메서드의 바이트코드는 여기에 저장.
→ Unit 1.3의 Non-Static Zone, Static Zone의 실체.

3.6 Attributes — 메타데이터

클래스 레벨, 필드 레벨, 메서드 레벨 모두 attribute 가질 수 있음.

주요 attribute:

  • SourceFile: 원본 .java 파일명
  • InnerClasses: 내부 클래스 정보
  • Signature: 제네릭 정보 (Type Erasure 보존)
  • RuntimeVisibleAnnotations: 런타임 어노테이션
  • Synthetic: 컴파일러가 생성한 합성 멤버

→ Reflection이 어노테이션을 볼 수 있는 이유 = 여기에 보존됨.


4️⃣ javap 도구 마스터하기

4.1 기본 사용법

# 가장 기본 — 메서드 시그니처만
javap Shipment.class

# 출력:
# Compiled from "Shipment.java"
# public class Shipment {
#   public Shipment(java.lang.String);
#   public java.math.BigDecimal calculate(int);
# }

4.2 옵션 모음

옵션효과
-c바이트코드 출력 (가장 자주 사용)
-v (verbose)모든 정보 (상수 풀 포함)
-p (private)private 멤버도 표시
-l (lineNumber)줄 번호 + LocalVariableTable
-s (sigs)내부 시그니처 출력

조합:

javap -c -p -v Shipment.class
# 모든 정보 + 바이트코드 + private 멤버

4.3 실전 — 간단한 클래스 분석

public class Calculator {
    private int counter = 0;
    
    public int add(int a, int b) {
        counter++;
        return a + b;
    }
}
javac Calculator.java
javap -c -p Calculator.class

출력:

Compiled from "Calculator.java"
public class Calculator {
  private int counter;

  public Calculator();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #2                  // Field counter:I
       9: return

  public int add(int, int);
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field counter:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field counter:I
      10: iload_1
      11: iload_2
      12: iadd
      13: ireturn
}

이걸 읽을 수 있게 되는 것이 Phase 3의 목표.

4.4 한 줄씩 해독

public Calculator();      ← 기본 생성자 (컴파일러 자동 생성)
    Code:
       0: aload_0           ← this 로드
       1: invokespecial #1  ← super() — Object의 <init> 호출
       4: aload_0           ← this 다시
       5: iconst_0          ← 0 (counter의 초기값)
       6: putfield #2       ← this.counter = 0  (필드 선언 초기값)
       9: return            ← 생성자 종료

→ Unit 2.4에서 본 "필드 초기값을 컴파일러가 생성자 시작에 자동 삽입" 의 증거.

public int add(int, int);
    Code:
       0: aload_0           ← this
       1: dup               ← 복제 (다음 putfield용)
       2: getfield #2       ← this.counter 읽기 → Stack: [this, counter값]
       5: iconst_1          ← 1 push       → Stack: [this, counter, 1]
       6: iadd              ← +            → Stack: [this, counter+1]
       7: putfield #2       ← this.counter = counter+1 (이게 ++)
      10: iload_1           ← a            → Stack: [a]
      11: iload_2           ← b            → Stack: [a, b]
      12: iadd              ← a + b        → Stack: [a+b]
      13: ireturn           ← 반환

counter++ 가 어떻게 5개 명령으로 풀리는지 직접 확인.

4.5 verbose 모드 — 상수 풀까지

javap -c -p -v Calculator.class

추가로 출력되는 것:

Constant pool:
   #1 = Methodref          #4.#11         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#12         // Calculator.counter:I
   #3 = Class              #13            // Calculator
   #4 = Class              #14            // java/lang/Object
   #11 = NameAndType       #15:#10        // "<init>":()V
   #12 = NameAndType       #6:#7          // counter:I
   #13 = Utf8              Calculator
   #14 = Utf8              java/lang/Object
   ...

→ 바이트코드의 #2 가 정확히 무엇을 가리키는지 풀이됨.
Phase 3.2에서 Constant Pool을 본격적으로 다룬다.


5️⃣ 바이트코드 명령어 카테고리 — 약 200개 정리

5.1 카테고리 개요

JVM 바이트코드는 약 200개의 명령어. 카테고리로 묶으면 단순:

1. 상수 로드     (constant loading)
2. 변수 로드/저장 (load/store)
3. 산술/논리     (arithmetic/logic)
4. 타입 변환     (type conversion)
5. 객체 조작     (object manipulation)
6. 메서드 호출   (method invocation)
7. 제어 흐름     (control flow)
8. 예외 처리     (exception)

5.2 카테고리 1 — 상수 로드

iconst_0  ~  iconst_5     ← 0~5 정수 push
iconst_m1                 ← -1 push
bipush <byte>             ← -128 ~ 127 push
sipush <short>            ← short 범위 push
ldc <index>               ← 상수 풀의 작은 값
ldc_w <index>             ← 상수 풀의 큰 값 (16-bit 인덱스)
ldc2_w <index>            ← long, double 상수
aconst_null               ← null push

예:

int x = 100;
bipush 100
istore_1
String s = "hello";
ldc #2     // 상수 풀에서 "hello" 로드
astore_1

5.3 카테고리 2 — 변수 로드/저장 (LVA 접근)

iload_0 ~ iload_3         ← LVA[0~3]의 int 로드
iload <index>             ← 임의 인덱스
istore_0 ~ istore_3       ← LVA[0~3]에 int 저장
istore <index>

aload_0 ~ aload_3         ← LVA의 참조 로드
astore_0 ~ astore_3       ← LVA에 참조 저장

lload, lstore             ← long
fload, fstore             ← float
dload, dstore             ← double

→ 첫 글자: 타입 (i=int, a=ref, l=long, f=float, d=double, b=byte, s=short).
→ Unit 1.4에서 본 LVA 접근의 정체.

5.4 카테고리 3 — 산술/논리

iadd, isub, imul, idiv, irem    ← int 사칙연산 + 나머지
ineg                             ← 음수화

iinc <index> <const>             ← LVA[index] += const (특수)
                                   예: counter++ → iinc 1 1
                                   
ishl, ishr, iushr                ← 시프트
iand, ior, ixor                  ← 비트 연산

lcmp, fcmpl, fcmpg, dcmpl, dcmpg ← 비교 (long/float/double)

흥미로운 명령: iinc.

counter++;

일반 방식:

iload_1   // 로드
iconst_1  // 1
iadd      // 더하기
istore_1  // 저장

iinc 사용:

iinc 1, 1   // LVA[1] += 1   ← 한 명령으로 해결

→ 단, LVA 변수에 대해서만. 필드는 일반 방식.

5.5 카테고리 4 — 타입 변환

i2l, i2f, i2d              ← int을 long/float/double로
l2i, l2f, l2d              ← long을 다른 타입으로
f2i, f2l, f2d              ← float
d2i, d2l, d2f              ← double
i2b, i2c, i2s              ← int을 byte/char/short로 (좁히기)

checkcast <class>          ← 참조 타입 캐스팅 검증
instanceof <class>         ← 타입 검사

예:

long x = (long) intValue;
iload_1
i2l
lstore_2

5.6 카테고리 5 — 객체 조작

new <class>                ← 객체 생성 (Unit 2.4)
newarray <type>            ← primitive 배열
anewarray <class>          ← 참조 타입 배열
arraylength                ← 배열 길이

getfield <field>           ← 인스턴스 필드 읽기
putfield <field>           ← 인스턴스 필드 쓰기
getstatic <field>          ← static 필드 읽기
putstatic <field>          ← static 필드 쓰기

iaload, aaload, ...        ← 배열 원소 읽기
iastore, aastore, ...      ← 배열 원소 쓰기

dup, dup2, swap, pop, pop2 ← 스택 조작

5.7 카테고리 6 — 메서드 호출 (Unit 1.5에서 본 4 + 1종)

invokestatic <method>      ← static 메서드
invokespecial <method>     ← 생성자, private, super
invokevirtual <method>     ← 일반 인스턴스 메서드
invokeinterface <method>   ← 인터페이스 메서드
invokedynamic <bootstrap>  ← 람다, String concat, switch 패턴 (Java 7+)

ireturn, lreturn, freturn  ← 값 반환
dreturn, areturn, return   ← void 반환

5.8 카테고리 7 — 제어 흐름

ifeq, ifne, iflt, ifle     ← int 비교 분기 (0과 비교)
ifgt, ifge
if_icmpeq, if_icmpne, ...  ← int 두 값 비교 분기
if_acmpeq, if_acmpne       ← 참조 비교
ifnull, ifnonnull          ← null 비교

goto <offset>              ← 무조건 점프
tableswitch                ← 빽빽한 switch
lookupswitch               ← 듬성한 switch

athrow                     ← 예외 던지기

예:

if (x == 0) return;
iload_1
ifne 7      // 0이 아니면 7번 명령으로 점프
return      // 4번
... continue ...  // 7번

5.9 카테고리 8 — 동기화/모니터

monitorenter               ← synchronized 진입
monitorexit                ← synchronized 종료
synchronized (lock) {
    process();
}
aload_1            // lock
monitorenter
... process() 호출 ...
aload_1
monitorexit

5.10 명령어 학습 전략

200개 다 외울 필요 없음.

기본 30개 정도 익히면 대부분 코드 읽기 가능:

iconst_*, bipush, ldc      (상수)
iload_*, istore_*, aload_*, astore_*  (변수)
iadd, isub, imul, idiv, iinc          (산술)
new, dup, getfield, putfield          (객체)
invokestatic, invokespecial, invokevirtual  (메서드)
ifeq, if_icmpeq, goto, return         (제어)

Phase 3.4에서 이걸로 실전 분석.


6️⃣ Write Once Run Anywhere의 비밀

6.1 슬로건의 진정한 의미

"Write Once, Run Anywhere" (WORA)
1995년 Sun Microsystems 슬로건.

의미:

  • 한 번 작성한 자바 코드를
  • 어떤 플랫폼에서든 (Windows, Mac, Linux, ARM, x86, ...)
  • 다시 컴파일 없이 실행

→ 가능한 이유: 바이트코드 + JVM 추상화.

6.2 동작 메커니즘

개발자 코드 (.java)
   ↓ javac (플랫폼 무관)
바이트코드 (.class)
   ↓ JAR로 패키징
배포 (어디든)
   ↓
플랫폼 A의 JVM ─→ 바이트코드 해석 → x86 기계어 실행
플랫폼 B의 JVM ─→ 바이트코드 해석 → ARM 기계어 실행
플랫폼 C의 JVM ─→ 바이트코드 해석 → RISC-V 기계어 실행

핵심:

  • 컴파일 결과물(.class)는 단일
  • JVM이 각 플랫폼에서 자기 CPU의 기계어로 변환

6.3 JVM 구현체들

표준 사양: JVM Specification (Oracle)

구현체:
  - HotSpot (Oracle, 가장 흔함)
  - OpenJDK (오픈소스)
  - GraalVM (다국어 + AOT)
  - Eclipse OpenJ9 (IBM)
  - Amazon Corretto (AWS)
  - Azul Zulu, Zing
  - SAP Machine

모두 같은 .class 파일을 실행 가능.
→ "바이트코드 사양" 이 표준의 핵심.

6.4 다른 언어들도 JVM 위에서

바이트코드를 만드는 컴파일러:
  - javac (Java)
  - kotlinc (Kotlin)
  - scalac (Scala)
  - groovyc (Groovy)
  - clojure (Clojure)
  - jython (Python)
  - jruby (Ruby)

→ Kotlin 코드도 결국 .class 바이트코드. JVM이 실행.
→ Spring Boot 앱에서 Java + Kotlin 혼용 가능한 이유.

// Kotlin
fun add(a: Int, b: Int) = a + b
javap -c MyKotlinFile
// → 일반 Java 바이트코드와 거의 동일

6.5 한계 — Native Image

"Write Once, Run Anywhere" 의 trade-off:

항목JVM (전통)Native Image (GraalVM)
이식성✓ 어디든❌ 빌드 시 타겟 결정
시작 시간느림 (수 초)빠름 (수십 ms)
메모리많음적음 (1/5)
JIT 워밍업필요불필요

→ 서버리스, CLI 도구는 Native Image 가치.
→ 일반 서버는 여전히 JVM이 우월 (워밍업 후).

6.6 ILIC에서의 의미

ILIC 빌드 환경: Linux (CI/CD)
ILIC 운영 환경: Linux (서버)
ILIC 개발 환경: macOS, Windows (각자)

→ 같은 JAR 파일이 모두에서 실행
→ "내 컴퓨터에선 되는데" 같은 OS 의존 문제 거의 없음

→ WORA의 실무 이점.
→ 네이티브 라이브러리 호출 (JNI) 사용 시에만 OS 의존성 발생.


7️⃣ ILIC 실무 — 바이트코드 분석이 필요한 순간

7.1 시나리오 1 — 라이브러리 버그 의심

상황: 외부 라이브러리의 메서드가 예상과 다르게 동작.

// SomeLib.process가 무언가 이상함
SomeLib.process(input);
// 결과가 이상한데, 소스 코드는 안 줘서 못 봄

해결:

# JAR 추출
unzip somelib.jar -d somelib/

# 바이트코드 분석
javap -c -p somelib/SomeLib.class

→ 라이브러리 내부 로직을 바이트코드로 파악 가능.

7.2 시나리오 2 — 컴파일러 옵션 효과 확인

상황: final 키워드가 정말 최적화에 도움 되나?

// 버전 1
class A {
    public int method() { return 100; }
}

// 버전 2
class A {
    public final int method() { return 100; }
}

javap -c로 둘을 비교 → 바이트코드는 사실 거의 동일.
→ 차이는 JIT 런타임에 나타남.
→ "왜 같지?" 의문이 풀림.

7.3 시나리오 3 — AOP가 생성한 코드 확인

@Service
public class ShipmentService {
    @Transactional
    public Shipment save(Shipment s) { ... }
}

Spring AOP가 만든 프록시 클래스는 어떻게 생겼나?

# Spring Boot 실행 시 옵션
-Dorg.springframework.cglib.debugLocation=/tmp/cglib

# 생성된 .class 확인
javap -c /tmp/cglib/com/ilic/ShipmentService\$\$EnhancerBySpringCGLIB*.class

→ AOP self-invocation 함정의 메모리적 원인 (Unit 2.1) 직접 확인.

7.4 시나리오 4 — 람다의 정체

Runnable r = () -> System.out.println("hello");

이 코드의 바이트코드:

0: invokedynamic #2, 0    // BootstrapMethod for lambda
                          // LambdaMetafactory가 클래스 동적 생성
5: astore_1

invokedynamic 명령. 런타임에 람다 클래스 동적 생성.

→ "람다가 익명 클래스와 어떻게 다른가?" 의 답.

7.5 시나리오 5 — switch의 진화

// 전통 switch
switch (status) {
    case "DRAFT": return 1;
    case "ACTIVE": return 2;
    default: return 0;
}

// Java 14+ switch expression
return switch (status) {
    case "DRAFT" -> 1;
    case "ACTIVE" -> 2;
    default -> 0;
};

javap -c로 보면 둘 다 비슷한 lookupswitch 또는 tableswitch.
→ "표현식 switch가 더 빠른가?" → 사실상 차이 없음. 가독성 차이.

7.6 시나리오 6 — 컴파일 산출물 검증

ILIC CI/CD 시나리오:

.java → javac → .class
.class → SpotBugs, ErrorProne 등 분석 도구 입력
.class → JAR 패키징

분석 도구가 .class 를 직접 읽어 패턴 검출.
→ 우리도 가끔 .class 직접 확인해 도구 결과 검증.

7.7 도구 모음

# javap — 표준 도구
javap -c -v -p File.class

# CFR (Java Decompiler)
java -jar cfr.jar File.class

# JD-GUI (그래픽)
jd-gui File.jar

# bytecode-viewer (다양한 디컴파일러 통합)

박승제씨가 IntelliJ IDEA를 쓴다면:

  • .class 파일 더블 클릭 → 자동 디컴파일 (Fernflower)
  • Tools → "Show Bytecode" → javap 결과
  • → 별도 명령어 없이 IDE에서 바로 가능

8️⃣ 흔한 실수 + 디버깅

실수 1 — 바이트코드를 어셈블리로 착각

"바이트코드는 어셈블리네!" → 부분적으로 맞지만 다름.

차이:

  • 어셈블리: CPU 명령에 1:1 대응
  • 바이트코드: JVM 가상 명령. 실제 실행은 JIT가 네이티브로 변환

→ "이 바이트코드가 느림?" → 사실 의미 없음. JIT 후의 코드가 진짜 실행.

실수 2 — 바이트코드 줄 수로 성능 판단

// 코드 1: 1줄
int sum = a + b;

// 코드 2: 3줄
int x = a;
int y = b;
int sum = x + y;

바이트코드:

코드 1:
  iload_1, iload_2, iadd, istore_3   (4 명령)

코드 2:
  iload_1, istore_3                  (저장 x)
  iload_2, istore_4                  (저장 y)
  iload_3, iload_4, iadd, istore_5   (계산)
                                     (8 명령)

명령 수는 다르지만, JIT 후엔 사실상 같음 (Escape Analysis로 변수 제거).
→ 가독성 위해 짧게/길게 쓰는 건 자유. 성능 영향 거의 없음.

실수 3 — final 이 항상 빠르다는 신화

public final int method() { return 100; }

바이트코드:

iconst_100 (또는 ldc)
ireturn

같은 메서드의 non-final 버전도 바이트코드는 동일.
차이는 JIT 시점:

  • final: 인라이닝, devirtualization 더 적극적
  • non-final: 보수적

finalJIT 힌트이지 바이트코드 차이가 아님.

실수 4 — synchronized 의 비용 과대평가

public synchronized void method() { ... }

바이트코드:

  • 메서드 진입 시 monitorenter
  • 메서드 종료 시 monitorexit
  • 예외 시 자동 monitorexit

→ 짧은 메서드의 synchronized 비용은 ~10ns.
→ "synchronized 빼야 함" 의심 전에 측정. 대부분 다른 곳이 병목.

실수 5 — 디컴파일러 결과를 원본으로 착각

java -jar cfr.jar SomeClass.class

디컴파일러는 바이트코드 → 자바 소스 변환.

  • 원본과 100% 같지 않음
  • 변수 이름 손실 (디버그 정보 없으면)
  • 일부 패턴은 다르게 표현
  • 람다, switch 등은 특히 차이

→ 디컴파일은 참고용, 진짜는 바이트코드.

실수 6 — .class 파일 직접 수정 시도

# ❌ 절대 하지 말 것
vim Calculator.class

.class 는 바이너리. 일반 텍스트 에디터로 열면 깨짐.
바이트코드 조작 도구 사용:

  • ASM (Apache, 가장 일반)
  • ByteBuddy (높은 수준 API)
  • Javassist (Hibernate 등이 사용)

→ 실무에선 거의 안 함. 라이브러리 개발 시 가끔.

디버깅 — 운영 환경에서 클래스 버전 확인

# 실행 중인 JVM의 클래스 정보
jcmd <PID> VM.class_loader_stats

# 특정 클래스 정보
jcmd <PID> GC.class_stats   # (JFR 활성화 필요)

# JAR 안의 클래스 버전 확인
unzip -p somelib.jar SomeClass.class | head -c 8 | hexdump -C
# 00 00 ca fe ba be 00 41   ← 41 = Java 17

운영 사고 시 "이 라이브러리 어느 Java 버전이야?" 빠르게 확인.


9️⃣ 면접 질문 + 자기 점검

9.1 면접 단골 질문 매핑

Q핵심 답변
바이트코드와 기계어 차이?JVM 가상 CPU vs 실제 CPU. 스택 머신 vs 레지스터 머신. 이식성
Magic Number 0xCAFEBABE 의미?.class 파일 시작 확인용. James Gosling이 1991년 정함
WORA의 메커니즘?바이트코드(.class) → JVM이 각 플랫폼 기계어로 변환
.class 파일의 주요 섹션?Magic, Version, Constant Pool, Methods, Attributes
javap의 주요 옵션?-c (바이트코드), -v (상수 풀), -p (private)
바이트코드 명령어 카테고리?상수/변수/산술/타입변환/객체/메서드/제어/예외
Kotlin이 JVM에서 도는 이유?kotlinc가 .class 바이트코드 생성. JVM은 언어 무관
Java 8 → 11 → 17 의 .class 차이?Major version 변경. 새 명령어 추가 (invokedynamic 확장 등)
디컴파일러 결과를 원본으로 신뢰?참고용. 변수명/주석/일부 패턴 손실
iinc의 특수성?LVA 변수에 대해 한 명령으로 +N. 필드는 불가

9.2 자기 점검 체크리스트

기본 이해

  • 바이트코드가 기계어와 다른 점을 설명할 수 있다
  • .class 파일의 10개 섹션을 안다
  • Magic Number와 Version의 의미를 안다
  • javap의 주요 옵션을 사용할 수 있다
  • 바이트코드 명령어 카테고리를 안다

실전 적용

  • Calculator.class 같은 간단한 클래스를 javap로 읽을 수 있다
  • aload_0, iadd, putfield 등 기본 명령을 해독할 수 있다
  • 라이브러리 바이트코드를 분석해 버그 디버깅 가능
  • AOP 프록시 클래스를 직접 확인 가능
  • 람다의 invokedynamic 패턴을 안다

면접 대비 — 5분 답변

  • 바이트코드와 기계어의 결정적 차이
  • WORA의 메커니즘
  • .class 파일의 구조
  • 200개 바이트코드 명령어를 카테고리로 정리
  • javap로 코드 분석하는 능력

🎯 핵심 요약 — 3줄 정리

1. 바이트코드 = JVM이라는 가상 CPU의 기계어

  • 스택 머신 기반 (레지스터 머신 x86과 대비)
  • 객체/메서드 개념 보존 (저수준 어셈블리와 차이)
  • ~200개 명령어, 8개 카테고리

2. .class 파일의 표준 구조

  • Magic Number 0xCAFEBABE 로 시작
  • Version (Java 8 = 52, Java 21 = 65)
  • Constant Pool (다음 Unit 주역)
  • Methods (바이트코드 거주지)

3. WORA의 비밀

  • 한 번 컴파일된 .class → 모든 JVM 구현체에서 실행
  • Kotlin, Scala 등도 같은 .class 생성 → JVM 위에서 동작
  • Native Image는 trade-off (이식성 vs 시작 속도)

📚 다음으로...

Unit 3.2 — 상수 풀(Constant Pool)의 생성과 구조

이번 Unit에서 .class 파일의 큰 그림을 봤다면, 다음은 상수 풀에 집중.

지금까지 본 모든 #1, #2, #15 인덱스가 정확히 무엇인지 풀이.

  • 상수 풀의 14가지 항목 타입 (Class, String, MethodRef, FieldRef, ...)
  • javap -v로 상수 풀 직접 확인
  • 컴파일러가 상수 풀을 만드는 과정
  • 같은 String 리터럴이 상수 풀에 1개만 들어가는 이유

Phase 3 진행 상황

🎯 Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점
  ✅ Unit 3.1 바이트코드란 무엇인가 ← 여기
  ⏭ Unit 3.2 상수 풀의 생성과 구조
  ⏭ Unit 3.3 심볼 참조
  ⏭ Unit 3.4 바이트코드 실전 분석

Phase 3 학습 후 갖게 될 능력

javap -c -v Shipment.class

이 한 줄의 출력을 완전히 이해하고, 다음을 할 수 있게 됨:

  • 라이브러리 버그를 바이트코드로 디버깅
  • Spring AOP 프록시 클래스 분석
  • 람다와 익명 클래스의 차이 직접 확인
  • 컴파일러 옵션의 효과 검증
  • 면접에서 "바이트코드 본 적 있어요?" 에 자신 있게 대답
profile
Software Developer

0개의 댓글