자바 컴파일 과정: 소스코드부터 실행까지

김태현·2023년 4월 9일

왜 자바 개발자는 자바 컴파일 과정을 이해해야 할까요?

자바 프로그래밍을 하다보면 IDE를 사용하면서 컴파일 과정을 자세히 이해하지 않고 개발하는 경우가 많습니다. 하지만 실제로 이해하지 않고 넘어가면서 발생하는 오류나 성능 이슈들은 개발자가 예측하지 못하는 문제를 유발할 수 있습니다.

이러한 이유로 자바 컴파일 과정을 자세히 공부하고자 하였습니다. 이번 포스팅에서는 자바 소스코드가 컴파일되는 과정과 그 과정에서 어떤 일들이 일어나는지에 대해 정리해 보았습니다. 이를 통해 개발자들이 자바의 컴파일 과정을 이해하고, 자바 애플리케이션을 보다 효율적으로 개발할 수 있도록 도움이 되고 싶습니다.


WORA: Write Once, Run Anywhere

혹시 "WORA" 라는 단어를 들어보셨나요? 이는 자바의 플랫폼 독립성을 나타내는 단어입니다. 즉 한 번 작성한 자바 코드는 다른 운영체제나 하드웨어 플랫폼에서도 동일하게 실행되고 같은 결과를 얻을 수 있다는 것을 의미합니다.

C++ 과 같은 언어는 컴파일러가 소스 코드를 특정 운영체제와 하드웨어에 알맞는 기계어 코드를 생성합니다. 따라서 C++로 작성된 프로그램은 컴파일된 운영체제아 하드웨어에서만 실행할 수 있습니다. 예를 들어, 맥OS로 개발하여 컴파일된 프로그램은 윈도우에서 실행할 수 없습니다. 이는 맥OS와 윈도우가 서로다른 운영체제이기 때문입니다. 이를 "운영체제에 종속적이다" 라고 합니다.

반대로 자바는 OS에 독립적입니다. 즉, 맥OS에서 컴파일한 프로그램을 윈도우에서 실행할 수 있습니다. 이를 "이식성이 높다"라고 하는데, 이러한 특징 때문에 여러 운영체제에서 동일한 코드를 실행할 수 있습니다.

다시 돌아와서, WORA(Write Once, Run Anywhere)는 자바의 가장 큰 특징인 OS에 독립적인 특징을 나타내는 말입니다. 즉, 한번 작선한 자바 소스 코드는 어떤 운영체제에서도 동일한 실행 결과를 보장하며, 다시 컴파일하지 않고도 여러 플랫폼에서 실행될 수 있다는 것을 의미합니다.

이러한 WORA 특징은 자바가 서로다른 플랫폼에서 동작하는 애플리케이션을 개발할 수 있습니다. 이를 통해 개발자는 여러 운영체제에서 일관된 개발 환경을 유지할 수 있으며, 유지보수와 배포 등의 작업도 수월하게 처리할 수 있습니다.


자바 컴파일 과정

자바 컴파일 과정은 소스 코드를 실행 가능한 클래스 파일로 변환하는 과정입니다. 이 과정은 다음과 같이 구성됩니다.

1. 소스코드 작성

자바 소스 코드는 개발자가 작성하는 텍스트 파일을 의미합니다. 이 파일은 .java 의 확장자를 가집니다.

2. 컴파일: 바이트코드

2-.1 자바에서 컴파일

자바에서 컴파일(Complie)은 자바 소스코드(.java)를 바이트코드(.class)로 바꾸는 과정을 의미합니다. 여기서 중요한 단어는 바로 "바이트코드" 입니다. 바이트코드 덕분에 위에서 설명한 WORA(Write Once, Run Anywhere)라는 특징을 가질수 있는 것입니다.

자바 소스코드는 컴파일러(javac)에 의해 바이트코드(Bytecode)로 변환됩니다. 이 바이트코드는 JVM에서 실행하는 파일인 클래스파일(class file)로 저장됩니다. 엄밀히 말하면 클래스파일과 바이트코드는 다른 표현방식지만 지금은 같은 뜻으로 이해해주세요. 자세한 내용은 3. 클래스파일과 바이트코드 에 작성 되어있습니다.

바이트 코드는 특정 하드웨어가 아닌 가상 컴퓨터에서 돌아가는 실행 프로그램을 위한 이진 표현법을 의미합니다. 쉽게말해 JVM(JVM을 해석하면 자바 가상 머신입니다. 즉, 가상 컴퓨터)이 읽을 수 있는 0과 1로 구성된 파일을 의미합니다.

지금까지 내용을 정리하면 "자바 소스코드를 컴파일하면 JVM이 읽을수 있는 파일 형태인 클래스파일(바이트코드, 정확히는 다른 개념)이 생성된다" 입니다.

이때 클래스파일은 이진형태로 저장되어 있습니다. 이진형태로 저장되어 있다는 것은 0과 1의 비트(bit) 형태로 저장되어 있다는 의미입니다. 하지만 클래스파일을 텍스트에디터(메모장 등)로 열면 16진수 형태로 표기됩니다. 그 이유는 클래스 파일이 이진형태로 저장되어 있기 때문입니다. 즉, 컴퓨터가 이해할 수 있는 이진수 형태로 저장되어 있기 때문에 16진수로 표기하면 사람이 더욱 쉽게 이해할 수 있습니다. 따라서 텍스트 에디터에서는 일반적으로 이진수 형태로 바이너리 데이터를 표현하지 않습니다. 대신 16진수 표기법을 사용하여 각 바이트(8자리 이진수)를 두개의 16진수 숫자로 변환하여 표기합니다. 각 바이트를 16진수 표기법으로 표시하면 2진수 형태의 데이터를 직접 확인할 수 있는 장점이 있기 때문입니다.

2-2. C/C++ 등 다른 언어의 컴파일

위 내용에 따르면 자바 소스코드를 컴파일하면 클래스파일이 생성됩니다.
하지만 C와 C++ 과 같은 언어에서 컴파일하면 소스코드가 "기계어"로 변환됩니다. 이렇게 기계어로 변환된 파일(코드)은 컴퓨터의 CPU가 직접 실행할 수 있는 이진파일(binary file) 형태로 저장됩니다. 이렇게 C/C++과 같은 컴파일 언어에서는 컴파일 과정에서 소스코드를 바로 기계어로 변환하여 실행파일을 생성하게됩니다. 따라서 실행파일은 운영체제에 종속적입니다. 즉, Windosws 운영체제에서 생성된 실행파일은 macOS나 Linux 등 다른 운영 체제에서는 실행할 수 없습니다.

3. 자바에서 클래스파일과 바이트코드: 차이점?

위에서 "클래스파일과 바이트코드는 다른 개념이다" 라고 언급한 것처럼 클래스파일과 바이트코드는 서로 다른 개념이며, 각각의 특징과 역할이 있습니다.

다시 우리가 소스코드를 작성하는 시점으로 돌아가보겠습니다. 자바 소스코드를 작성(.java)하여 컴파일(javac) 하면 이진형식의 클래스파일(.class)이 생성됩니다.

클래스파일은 JVM이 이해할 수 있는 형태로 구성되어 있으며, 여기에는 클래스 정보, 메서드, 변수 등의 데이터가 포함되어있습니다.

반면 바이트코드는 클래스 파일 안에 들어있는 명령어 집합입니다. JVM이 이해할 수 있는 명령어로 구성되어 있는 코드입니다.

즉, 클래스 파일은 자바 소스코드를 컴파일하여 생성되는 이진 형식의 파일이며, 바이트코드는 클래스 파일을 JVM이 이해할 수 있는 명령어로 변환한 파일입니다.

이때 변환은 JVM에 들어있는 "클래스로더"가 담당합니다. 이 클래스로더는 JVM의 런타임영역의 메서드영역(Method Area)에 속합니다. 자세한 내용 추후 자바 메모리구조에서 다루어 보겠습니다.

클래스파일과 바이트코드의 차이점을 구체적인 코드를 통해 알아보겠습니다.

먼저 HelloWorld.java 라는 자바 소스코드를 아래와 같이 작성합니다.

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}

위 소스코드를 컴파일 하면 HelloWorld.class 라는 클래스파일이 생성됩니다.

CAFEBABE00000034000A0A07000B08000C07000D08000E07000F0A0010000C4C696E654E
756D6265725461626C650100067072696E746C6E000F67657453797374656D50726F706572
7469657301001570726F6772616D6D696E674C616E6775616765730020006A6176612F6C61
6E672F4F626A6563740002100A000800110700120700130100087468726F77730100115B4C
6A6176612F6C616E672F537472696E673B0100063C696E69743E0100032829564C6A617661
2F6C616E672F537472696E673B29560100046D61696E0100106A6176612F6C616E672F5374
72696E673B0100066578656375746501001F4C6A6176612F696F2F5072696E745374726561
6D3B0100106A6176612F696F2F5072696E7453747265616D3B010004436F646501000F4C69
6E654E756D6265725461626C653B0100036F75740100115B4C6A6176612F6C616E672F5374
72696E673B0100077072696E746C6E0100146A6176612F696F2F5072696E7453747265616D
01000A536F7572636546696C65010014746573742F48656C6C6F576F726C642E6A617661
0C000500060101010700100F4C6A6176612F696F2F5072696E7453747265616D3B07001300
140100037073720C000500060101010001000478707101000F48656C6C6F2C20776F726C64
212E

클래스 로더가 HelloWorld.class 라는 클래스파일의 바이트코드를 읽어와서 메모리 상에 올립니다. 이렇게 메모리에 클래스의 정의가 올라가면 JVM은 해당 클래스를 인스턴스화 하거나 해당 클래스의 메서드를 호출할 수 있게됩니다.

다음은 HelloWrold.class 의 바이트코드입니다.

ca fe ba be 00 00 00 34 00 3c 0a 00 03 00 0d 07
00 0e 07 00 0f 01 00 06 3c 69 6e 69 74 3e 01 00
03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69
6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 3b 01 00
0a 53 6f 75 72 63 65 46 69 6c 65 01 00 0c 48 65
6c 6c 6f 57 6f 72 6c 64 2e 6a 61 76 61 0c 00 04
00 05 01 00 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f
62 6a 65 63 74 01 00 0a 53 6f 75 72 63 65 46 69
6c 65 01 00 10 48 65 6c 6c 6f 57 6f 72 6c 64 2e
6a 61 76 61 0c 00 06 00 07 01 00 0f 48 65 6c 6c
6f 2c 20 77 6f 72 6c 64 21 0a 00 03 00 09 0c 00
08 00 09 01 00 15 6a 61 76 61 2f 6c 61 6e 67 2f
53 79 73 74 65 6d 0c 00 0a 00 0b 01 00 06 70 72
69 6e 74 6c 6e 01 00 16 28 4c 6a 61 76 61 2f 6c
61 6e 67 2f 53 74 72 69 6e 67 5b 5b 4c 6a 61 76
61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56
01 00 0a 45 78 63 65 70 74 69 6f 6e 73 07 00 10
0c 00 11 00 12 01 00 12 6a 61 76 61 2f 6c 61 6e
67 2f 41 72 72 61 79 4c 69 73 74 0c 00 13 00 14
01 00 0a 48 65 6c 6c 6f 57 6f 72 6c 64 01 00 1d
6a 61 76 61

위와같이 바이트코드와 소스코드는 다른 데이터를 가지고 있습니다. JVM의 클래스로더가 클래스파일에 담겨있는 바이트코드를 JVM이 읽을수 있는 명령어의 형태로 변환한 것이 바이트코드입니다. 바이트코드는 JVM에서 실행될 때 인터프리터나 JIT 컴파일러에 의해 기계어로 번역되어 실행됩니다.


4. 자바에서 클래스파일과 바이트코드: 둘의 관계와 역할

그런데 클래스파일과 바이트코드는 사실상 같은 개념으로 사용되기도 합니다. 클래스파일은 바이트코드로 구성되어 있으며, 자바 컴파일러가 소스코드를 컴파일하면 바이트코드로 구성된 클래스파일이 생성됩니다. 이후 JVM의 클래스 로더에 의해 파일이 로드되고, 바이트코드가 실행되는 것입니다.

따라서 클래스파일은 자바 소스코드를 컴파일하여 생성된 바이트코드를 저장한 파일이라고 볼 수 있습니다. 클래스 파일이 JVM에서 로드되면 해당 클래스의 바이트코드가 메모리에 올라가고, 자바 실행엔진에 의해 프로그램이 실행되는 것입니다.


5. 클래스로더의 세부 동작 과정

클래스 로더의 동작과정은 크게 3단계로 분류할 수 있습니다. 5단계로 분류할 수 도 있지만 저는 3단계로 분류하는 것이 클래스 로더의 역할을 더 명확하게 이해할 수 있고 각 단계에서 수행되는 작업을 구체적으로 파악할 수 있는것 같습니다.

1. 로딩(Loading)

  • 첫번째로 로딩 단계는 .class 파일을 읽어와 JVM 내부에서 사용할 수 있는 자료구조인 메모리에 적재합니다.
  • 읽어온 .class 파일은 바이트코드(Bytecode) 형태로 변환됩니다.

2. 링크(Linking)

  • 링크 단계는 클래스가 메모리에 로딩뒨 후에 실행되는 단계입니다. 클래스 파일의 정보를 분석하여 해당 클래스가 참조하고 있는 다른 클래스, 메서드, 변수 등의 레퍼런스(참조)를 연결하는 과정입니다.
  • 링크단계는 검증(Verification), 준비(Preparation), 해석(Resolution) 세 가지 단계로 나뉩니다.

1단계: 검증

로딩된 클래스 파일이 올바른 자바 클래스 파일인지 검증하는 과정입니다. 즉, 클래스 파일이 자바 언어 명세에 맞게 작성되어 있는지 확인합니다. 예를 들어, 클래스 파일이 올바른 바이트코드로 작성되어 있는지, 필요한 클래스가 존재하는지 등을 검사합니다. 컴파일러가 파일 형식에 맞지 않게 컴파일 하거나, 클래스 파일이 손상되는 경우도 있으니까요. 만약 클래스 파일이 올바르지 않으면, 'VerifyError'와 같은 예외가 발생합니다.

2단계: 준비

준비 단계에서는 클래스가 필요로 하는 메모리 공간을 할당합니다. 이때, 클래스 변수(static variable)는 기본값으로 초기화되고, 할당된 메모리 공간은 해당 클래스의 인스턴스가 생성될 때 까지 유지됩니다.

3단계: 해석

해석 단계에서는 클래스의 상수 풀(constant pool)에서 필요한 심볼릭 참조(symbolic reference)를 실제 메모리상의 레퍼런스로 교체하는 과정입니다.

레퍼런스를 연결한 다는 말은, 예를 들어 클래스 A가 클래스 B를 참조한다고 가정하겠습니다. 이때 링크과정에서 A클래스의 코드 내에서 B클래스를 참조하는 부분을 찾아, B클래스가 로딩되는 메모리에 올라가고 초기화된 후에 그 참조를 실제로 연결합니다. 이렇게 참조된 클래스가 나중에 사용될때 정적멤버가 초기화되고, 인스턴스 생성시에는 인스턴스 멤버가 초기화됩니다.

상수 풀이란, 클래스 파일 내부에 있는 상수들을 모아 놓은 것으로, 클래스 파일 내부에서 사용되는 모든 상수들이 저장되어 있습니다. 이때, 상수 풀의 정보들은 미리 해성되어 있어야 실행 중에 바로 사용할 수 있습니다.

심볼릭참조

여기서 심볼릭참조란 클래스나 인터페이스의 이름, 필드의 이름, 메서드의 이름 등을 나타내는 것입니다.

일종의 자바 코드 상의 식별자라고 할 수 있습니다. 예를 들어, 다음과 같은 코드에서 클래스 'MyClass'는 심볼릭 참조입니다.

public class MyClass {
    // fields and methods
}

심볼릭 참조는 클래스 로더가 클래스파일을 읽어올 때, 해당 클래스나 인터페이스, 필드, 메서드의 실제 메모리 주소를 찾기 위해 사용됩니다. 이때, 심볼릭 참조는 링크 단계에서 레퍼런스를 해결하는데 사용됩니다. 즉, 클래스나 인터페이스, 필드, 메서드 등을 찾아서 런타임 상수풀(Constant Pool)에 저장하고, 필요한 경우 런타임 상수풀에서 참조를 이용하여 해당 요소를 사용하게 됩니다.

3. 초기화

초기화 단계는 클래스의 정적변수(static variable)와 클래스의 정적블록(static block)이 초기화 되는 단계입니다.

정적변수는 클래스가 로딩되는 과정에서 메모리에 할당됩니다. 그리고 이 변수들은 초기화 전에 기본값으로 초기화 됩니다. 예를들어 정수형 변수는 0, boolean 타입의 변수는 false로 초기화 되는 것이죠. 이후, 정적변수가 명시적으로 초기화되거나 정적 블록에서 초기화됩니다.

정적블록은 클래스가 로딩될 때 실행되는 코드 블록입니다. 이 블록에서는 클래스의 정적 변수를 초기화하거나, 클래스의 정적 메소드를 호출하거나, 예외 처리 등의 작업을 수행할 수 있습니다.

클래스의 인스턴스가 생성될 때는 인스턴스 변수와 인스턴스 불록이 초기화되는데, 이를 객체 초기화 단계라고 부릅니다. 반변, 초기화 단계는 클래스 자체의 초기화 단계를 의미힙니다.

이때 인스턴스 멤버가 생성되는 것이 아니라 정적멤버(static) 멤버가 생성됩니다.
정적 멤버는 클래스 로딩 시에 초기화 되고, 인스턴스 멤버는 new 키우더 등을 통해 객체를 생성할 때 초기화됩니다. 따라서 클래스 로딩은 프로그램 시작 시 한 번만 일어나게 되지반, 객체는 여러 개가 생성될 수 있습니다.
이렇게 생성된 객체는 메모리 상에 독립적으로 존재하며, 객체마다 다른 인스턴스 멤버 변수 값을 가질 수 있습니다.


5. 실행

실행단계는 클래스 로더의 로딩, 링크, 초기화 단계 이후에 시작됩니다.
로딩 단계에서는 클래스파일을 JVM 내부 메모리에 로드합니다.
링크 단계에서는 해당 클래스와 다른 클래스들 간의 레퍼런스를 연결합니다.
초기화 단계에서는 클래스변수(static 변수)들의 초기값을 설정합니다.

이후 실행단계에서는 메인 메서드 등을 실행하여 프로그램이 동작합니다. 클래스파일은 JVM 내부의 런타임 영역(Runtime Data Area)에 저장됩니다. 이 런타임 영역에는 메소드영역(Method Area), 힙영역(Heap), 스택영역(Stack), PC(Program Counter) 레지스터 등이 포함되어 있습니다. 이후, 클래스 파일이 실행되는 시점에서 JVM은 런타임 데이터 영역에 저장된 클래스 파일을 이용하여 프로그램을 실행하게 됩니다.

이 과정에서 인터프리터나 JIT 컴파일러 등의 실행엔진이 바이트코드를 해석하거나 기계어로 번역하여 프로그램을 실행합니다.


마치며

자바 컴파일 과정을 이해하는 것은 자바 프로그래밍을 시작하는 데 매우 중요합니다. 이 과정을 공부하고 이해하면서 기본적인 개념을 더 잘 이해할 수 있었고, 추후 디버깅 시 어떤 문제가 발생했는지 파악하는 데에도 도움이 될 것 같습니다.

profile
안녕하세요. Java&Spring 기반 백엔드 개발자 김태현입니다.

0개의 댓글