일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- java
- maeil-mail
- 항해플러스
- sw expert academy
- react
- axios
- 자바
- react-redux
- json-server
- JavaScript
- redux-toolkit
- 프로그래머스
- useDispatch
- 항해99
- 리액트
- SW
- Python
- redux
- Algorithm
- 코딩테스트합격자되기
- 알고리즘
- 테코테코
- 매일메일
- createSlice
- react-router
- 이코테
- redux-saga
- C++
- Get
- programmers
- Today
- Total
Binary Journey
내가 자주 쓰는 언어의 코드가 실행 파일로 만들어지고, 실행되기까지 어떤 과정을 거칠까 본문
내가 자주 쓰는 언어의 코드가 실행 파일로 만들어지고, 실행되기까지 어떤 과정을 거칠까
binaryJournalist 2025. 4. 5. 22:47컴파일러와 링커의 동작 원리는 C 언어와 같은 시스템 언어에서 자주 다루지만, Java와 Kotlin 같은 JVM 언어에서도 유사한 개념이 적용된다. Java/Kotlin 소스 코드는 컴퍼일러에 의해 바이트코드(JVM 명령어 집합)로 변환되고 실행 시에는 JVM이 이 바이트코드를 적재하고 해석, 최적화하여 실제 CPU에서 실행한다.
중점적으로 살펴볼 부분으로는 아래와 같다.
- 컴파일 단계부터 실행 단계까지의 흐름
- 컴파일러, 클래스 로더, 바이트 코드 처리(JIT 컴파일러, 인터프리터), 오류 발생 단계, 중간 산출물(.class, .jar), 전통적인 링커 개념이 JVM에서는 어떻게 나타나는지
1. 소스 코드 → 바이트코드, 컴파일 단계
JVM 바이트코드는 특정 OS나 CPU 아키텍처에 종속적이지 않아 JVM만 설치되어 있으면 어떤 운영체제에서든 동일한 바이트코드(.class
)를 실행할 수 있다.
Java/Kotlin 컴파일러는 언어 문법 검증과 바이트코드 생성까지의 역할을 하고 실제 기계어로의 번역은 JVM 실행 시점으로 넘겨둔다.
Java
Java 소스 파일(Hello.java
등)은 javac
컴파일러를 통해 바이트코드로 컴파일된다.
컴파일 결과로 .class
확장자의 클래스 파일이 생성되며 이 파일에 Java 바이트코드가 담겨 있다.
Hello.java
→ 컴파일 → Hello.class
Java 컴파일 시 잘못된 구문이나 자료형 불일치 등의 문제가 있을 시 컴파일러가 컴파일 타임 에러
를 보고하고 .class
파일은 만들어지지 않는다.
컴파일 타임 에러
는 프로그램이 기계어 코드로 변환되는 컴파일 과정 중 발견되는 오류로 존재하지 않는 변수 호출, 문법 오류 시에 발생한다.
Java의 "Write Once, Run Anywhere"
라는 슬로건은 이 바이트코드와 JVM의 조합을 의미한다.
Kotlin
Kotlin 코드(Hello.kt
등)도 Kotlin 전용 컴파일러인 kotlinc
을 통해 JVM 바이트코드로 변환된다. Kotlin 컴파일러도 .class
파일을 생성한다.
Kotlin은 Kotlin-specific metadata를 클래스 파일에 포함하지만 실행 자체는 표준 JVM 바이트코드로 수행된다고 한다.
- Kotlin 컴파일러는 Kotlin만의 고유한 언어 기능을 표현·저장하기 위해,
.class
파일 안에 Kotlin 메타데이터를 추가로 기록한다. - 이 메타데이터는 Kotlin이 제공하는 언어적 특징(데이터 클래스, 확장 함수, 코루틴, Null-safety 등)을 제대로 활용하거나 Kotlin 리플렉션에서 원본 Kotlin 코드를 복원, 분석할 때 쓰인다.
리플렉션은 프로그램이 런타임에 자신을 스스로 들여다보고 (클래스, 메서드, 프로퍼티 구조 등을) 조작할 수 있게 하는 기술이다.
Java에도 java.lang.reflect 패키지가 있어서 클래스 타입, 메서드 타입, 파라미터 유형 등을 확인할 수 있다.
Kotlin 리플렉션 kotlin.reflect.* 은 여기에 더해 Kotlin만의 추가 정보(데이터 클래스, 기본 파라미터, 널 허용 여부 등)를 런타임에 확인하도록 확장된 형태이다.
2. 중간산출물 - .class
파일과 .jar
패키지
클래스 파일 .class
클래스 파일은 한 개의 Java/Kotlin 클래스나 인터페이스의 바이트코드를 담은 이진 파일이다.
.class
파일 내부에는 상수 풀(constant pool), 필드/메서드 정보, 바이트코드 명령어 등이 이진 형식으로 들어 있다.
클래스 파일이 실행 파일은 아니지만 JVM이 이해할 수 있는 중간 산출물로 CPU에 독립적인 명령어들의 집합이다.
자바 아카이브 .jar
Java ARchive
를 줄여 jar
라고 하며 여러 .class
파일과 관련 자원(리소스 파일 등)을 하나로 묶은 압축 파일이다. .jar
는 일종의 ZIP 압축 형식이고 보통 라이브러리나 어플리케이션 배포 시 사용된다.
.jar
자체는 실행 가능한 바이너리가 아니라 JVM의 클래스 로더가 필요한 .class
파일들을 읽어들이기 위한 패키지이다. 일반적인 단순 JAR에는 다수의 .class
파일이 들어 있을 뿐, "이 중 어디서부터 실행을 시작해야 하는지"에 대한 정보는 없다.
단순한 JAR 안에는 여러 .class
파일만 묶여 있을 뿐 “이 중 어디서부터 실행을 시작해야 하나?”라는 정보가 없다. 그래서 실행 가능한 JAR를 만들려면 JAR에 어떤 클래스의 main 메서드를 프로그램 진입점으로 사용할지를 알려주는 정보가 추가로 필요하다.
만약 JAR 내부에 진입점(메인 클래스) 정보를 포함하고 싶다면 Manifest 파일을 사용한다. Manifest 파일은 META-INF/
디렉터리 아래 위치하는 간단한 텍스트 파일로 JAR 자체에 대한 메타데이터를 정의한다. 여기서 메인 클래스를 지정해주면, 이 JAR는 "실행 가능한 JAR"가 되어, java -jar 파일명.jar
만 입력해도 프로그램이 실행된다.
실행 가능한 JAR라고 해도 내부적으로는 JVM이 해당 JAR 파일을 열어 .class
들을 찾아 로딩하고 지정된 메인 클래스(main
메서드)를 실행하는 과정으로 진행된다.
jar
를 만드는 것은 .class
파일들을 묶고 메타데이터를 추가하는 것이고 실제 실행 로직은 JVM이 담당한다.
3. JVM에서의 클래스 로딩과 링킹 과정
클래스 로더 (Class Loader)
Java/Kotlin 코드를 컴파일하여 얻은 .class
파일은 JVM에 의해 실행 시 적재(load)된다.
JVM은 프로그램을 시작할 때 (예: Hello World
실행 시) 필요한 클래스들을 동적으로 메모리에 적재한다. 이 과정을 담당하는 것이 클래스 로더(Class Loader)이다.
클래스 로더는 컴파일 시가 아닌 런타임에 동적으로 클래스 로딩 및 링크를 수행하므로 필요한 시점까지 클래스를 메모리에 올리지 않아 메모리 효율을 높인다.
계층적인 위임 모델(Delegation Model)
Java의 클래스 로딩 메커니즘은 하나의 클래스 로더만 쓰는 것이 아니라 Bootstrap → Extension → Application 순으로 이어지는 여러 단계의 로더를 통해 클래스를 찾는다.클래스 로더는 “내가 로딩하기 전에 부모한테 먼저 물어보고 부모가 못 찾으면 그때 내가 로드한다.”라는 식의 위임 방식을 따른다.
- JVM의 클래스 로더 시스템의 3단계
- 로딩(Loading)
- 링킹(Linking)
- 초기화(Initialization)
링킹
JVM이 .class
파일을 메모리에 로딩한 후 검증(Verify) → 준비(Preparation) → 해결(Resolution) → (초기화) 순으로 처리하는 과정을 통틀어 링킹이라고 부른다.
이는 전통적인 언어(C/C++ 등)에서 링커가 오브젝트 파일을 결합하면서 기호(함수, 변수 주소)들을 해결하는 과정을 자바가 런타임에 수행한다고 볼 수 있다.
검증 (Verify)
로드된 .class
파일이 JVM 명세에 적합한지를 검사한다. 파일 형식이 올바른지, 잘못된 바이트코드나 보안 위험 요소가 없는지를 확인한다. 문제 발생 시 VerifyError
같은 런타임 에러를 발생시키고 실행을 중단한다.
준비 (Preparation)
클래스가 사용할 메모리 자원을 확보하고 static 변수를 기본값으로 초기화한다.
예를 들면 static int x;
라면 x
는 0으로 초기화되고 객체 생성과 무관하게 클래스 영역에 공간이 마련된다.
해결 (Resolution)
클래스 내부에서 사용하는 심벌릭 참조(symbolic reference)를 실제 메모리상의 직접 참조(Direct reference)로 치환한다.
다른 클래스 A
의 메서드를 호출하는 바이트코드 명령이 있으면, 실행 전에 A
클래스의 실제 메서드 주소와 연결한다.
지연 바인딩(lazy binding)
자바는 무조건 이 단계에서 모든 심벌을 한 번에 해결하지 않고 실제 사용 시점에 해결하기도 한다. 그래서 Resolution 단계를 선택적(Optional)이라 부르기도 한다.재배치(Relocation)
C/C++에서 오브젝트 파일을 OS 링커가 재배치하는 것과 유사하지만 자바에서는 JVM이 런타임에 이 작업을 수행하며 바이트코드 자체를 직접 수정하지는 않는다.
덕분에 동적 링킹이 가능해져, Java/Kotlin 프로그램은 실행 중 필요한 클래스만 로드하며 잘못된 참조도 실행 시점에 잡아낼 수 있다. 반면 C/C++ 정적 링크는 컴파일/링크 시점에 대부분의 참조를 해소하기 때문에 실행 파일 생성 후에는 고정된 주소로 동작하는 점이 다르다.
초기화 (Initialization)
준비 단계에서 기본값으로 초기화됐던 static 변수들을 프로그래머가 지정한 값으로 다시 초기화하고, static
블록이 있다면 이를 실행한다.
상위 클래스부터 차례대로 초기화를 진행한 뒤 최종적으로 해당 클래스까지 완료한다.
모든 클래스 초기화가 끝나면 인스턴스 생성이나 static
메서드 호출 등 해당 클래스를 사용할 준비가 된다.
4. 바이트코드 실행 - 인터프리터와 JIT 컴파일러
클래스 로딩과 링킹/초기화가 완료된 Java/Kotlin의 바이트코드는 JVM의 실행 엔진(Execution Engine)에 의해 실행된다.
실행 엔진은 두 가지 방식으로 동작한다
- 인터프리터(Interpreter)
- JIT(Just-In-Time) 컴파일러
현대 JVM(예: HotSpot JVM)은 이 두 방식을 혼합(Hybrid)하여 사용한다. 초기에는 인터프리터로 빠르게 시작하고, 실행 중 성능을 높이기 위해 반복 실행되는 코드를 JIT 컴파일하여 네이티브 기계어로 전환한다.
이러한 JIT 기반 구조 덕분에 Java/Kotlin은 컴파일 언어의 성능과 인터프리트 언어의 유연성을 동시에 갖출 수 있고 JVM이 충분히 워밍업된 후에는 C/C++처럼 정적 컴파일된 프로그램만큼의 실행 속도를 낼 수 있다.
구분 | 인터프리터 | JIT 컴파일러 |
---|---|---|
실행 방식 | 한 줄씩 해석 | 전체 메서드를 기계어로 컴파일 |
장점 | 빠른 시작 | 빠른 실행 속도 (반복 시) |
단점 | 반복 시 느림 | 초기 컴파일 비용 |
전략 | 초기 단계 실행 | 반복 시 최적화 적용 |
인터프리터
인터프리터는 바이트코드를 한 줄씩 읽어서 즉시 해석하고 실행한다.
예를 들어 바이트코드 명령이 정수 1을 스택에 푸시
라면 JVM은 즉시 CPU에게 1을 레지스터에 로드
하도록 지시한다.
인터프리터 방식은 시작 속도가 빠르고 메모리 사용이 적지만 반복 실행되는 코드도 매번 해석해야 하므로 실행 속도가 느릴 수 있는 단점이 있다.
JVM은 프로그램을 빠르게 시작하기 위해 초기에는 인터프리터로 모드로 코드를 실행한다.
JIT (Just-In-Time) 컴파일러
JIT 컴파일러는 인터프리터의 단점을 보완하기 위해 도입된 기술로 실행 중인 바이트코드를 감시하여 빈번히 실행되는 부분을 기계어로 컴파일한다.
JIT 컴파일러는 실행 중인 바이트코드를 모니터링하고 자주 반복되는 코드를 기계어(Native Code)로 컴파일하여 캐시에 저장한다.
이후부터는 바이트코드를 해석할 필요 없이 바로 기계어로 실행하므로 성능이 크게 향상된다.
예를 들어 어떤 메서드가 수천 번 호출되는 경우 JIT 컴파일러는 해당 메서드를 런타임 중에 기계어로 변환한다. HotSpot JVM은 메서드 호출 횟수, 루프 반복 횟수를 기준으로 JIT 컴파일을 트리거한다. 변환된 기계어는 JVM의 캐시에 저장되며, 동일한 코드를 재실행할 때는 인터프리터를 거치지 않는다.
두 단계 컴파일 (Tiered Compilation)
JIT 컴파일러는 자체적으로 두 단계를 거쳐 속도와 최적화 사이의 균형을 맞춘다.
단계 | 컴파일러 | 특징 |
---|---|---|
1단계 | C1 컴파일러 | 빠르고 가벼운 최적화 (Quick) |
2단계 | C2 컴파일러 | 깊은 분석과 고급 최적화 (Aggressive) |
JVM으로 실행 중인 Java 프로그램에서 사용자가
System.out.println("Hello")
위 코드를 10000번 반복한다면 처음 수백 번 정도는 인터프리터가 매번 바이트코드를 해석하여 println()
수행한다. 반복 횟수가 누적되면 JVM은 println()
호출 루틴을 JIT 컴파일한다. 그 이후부터는 해석 없이 기계어 루틴을 직접 호출하므로 실행 속도가 빨라진다.
사용자는 JVM 위에서 프로그램을 돌리지만 내부적으로는 성능이 최적화된 기계어(Native Code)가 부분적으로 실행되고 있는 상태다.
5. 컴파일 타임 에러 vs 런타임 에러
에러가 발생하는 시점에 따라 컴파일 타임 에러와 런타임 에러로 구분한다.
컴파일 에러
코드를 컴파일하는 단계에서 컴파일러가 감지하는 오류이다.
Java/Kotlin 컴파일러는 소스 코드를 바이트코드로 변환할 때 문법 검사와 정적 타입 체크를 수행한다. 예를 들어 구문 오류(Syntax Error), 타입 불일치(Type Mismatch), 정의되지 않은 변수/메서드 참조 등의 문제가 있으면 컴파일 타임 에러를 보고한다.
컴파일 타임 에러는 개발자가 코드를 잘못 작성한 경우에 컴파일러가 알려주는 오류이고, 런타임 에러는 코드상으로는 문제가 없어 보이지만 실행하면서 발생하는 문제이다.
런타임 에러
프로그램이 컴파일을 정상적으로 마치고 실제 실행되는 중에 발생하는 오류이다.
런타임 에러는 컴파일러 단계에서는 잡아내지 못했던 문제들이 실행 중에 나타나는 것으로 대표적으로 예외(Exception) 와 오류(Error) 가 있다.
Null pointer 참조 (NullPointerException
), 배열 범위 초과 (ArrayIndexOutOfBoundsException
), 형변환 실패 (ClassCastException
), 사용 중이던 클래스 파일을 찾지 못했을 때의 NoClassDefFoundError
등이 모두 런타임 에러이다.
앞서 언급한 바이트코드 검증 실패(VerifyError), 링크 시 ClassNotFoundException 등도 런타임 시점의 오류로 분류된다. 런타임 에러는 프로그램 실행 중에 발생하므로 이들을 적절히 처리(Exception handling)하지 않으면 프로그램이 중단될 수 있다.
6. JVM 언어에서의 링커(Linker) 개념
앞서 3. JVM에서의 클래스 로딩과 링킹 과정
에서 설명한 링킹(verification, preparation, resolution) 개념을 더 보충하자면, Java/Kotlin 같은 JVM 언어에서의 링킹은 전통적인 컴파일 언어(C/C++)에서의 링커 역할을 JVM이 런타임에 대신 수행하는 방식이라 볼 수 있다.
전통적인 링커 C/C++ 의 경우
C/C++ 같은 정적 컴파일 언어에서는 컴파일러가 소스 코드를 기계어로 변환한 .o
또는 .obj
오브젝트 파일을 만든 후 링커(Linker)가 이 여러 개의 오브젝트 파일을 하나로 묶는다. 이 과정에서 심벌릭 참조(함수 호출, 변수 주소 등)를 실제 메모리 주소로 치환하여 최종적으로 실행 가능한 단일 실행 파일(executable)을 만들어낸다.
이 링킹 작업은 컴파일 이후 실행 이전에 완료되며 링킹이 끝난 뒤에는 모든 심벌이 정적으로 해석된 상태가 된다.
JVM 언어의 링킹: 런타임 링킹
Java/Kotlin에서는 이런 식의 링커 프로그램은 별도로 존재하지 않는다. 대신 JVM이 자체적으로 클래스 로딩 시점에 링킹 작업을 수행한다. 이를 런타임 링킹(runtime linking)이라고 부른다.
.class
파일은 컴파일된 결과물이지만 서로 연결된 상태는 아니다. 실행 시 클래스 로더(Class Loader)가 필요한 클래스를 찾아 동적으로 로드하고 링킹 단계(검증, 준비, 해결)를 거쳐 심벌릭 참조를 해석한다.
예를 들어, A
클래스에서 B
클래스의 메서드를 호출할 경우 컴파일러는 해당 호출을 위한 바이트코드 명령만 생성하고 메서드 주소는 적지 않는다. .class
파일에는 해당 호출이 심벌(Symbol) 형태로 남아 있으며 실행 시 JVM이 B
클래스를 로드한 뒤, 해당 메서드 주소를 Resolution 단계에서 연결한다.
JVM 링킹은 실행 중에 새로운 클래스를 로드할 수 있어 플러그인 시스템이나 동적 확장 구조가 가능하다. .class
또는 .jar
파일만으로 외부 라이브러리를 재컴파일 없이 연동 가능하다. .class
간의 관계는 클래스패스 설정만으로 결합할 수 있다.
플랫폼 독립적인 바이트코드와 JVM 표준 덕분에 링킹 방식이 운영체제나 CPU에 종속되지 않는다.
- 전통적 링커는 Windows(COFF), Linux(ELF) 등 OS별 바이너리 포맷을 고려해야 하지만, JVM은
.class
와.jar
만 해석하면 되므로, OS와 무관하게 동일한 로딩/링킹 방식이 유지된다.
코드 실행부터 JVM 내부 동작까지
'weekly > 컴퓨터 밑바닥의 비밀' 카테고리의 다른 글
프로그램은 어떻게 실행될까 - 1 (0) | 2025.04.13 |
---|