티스토리 뷰

다른 시리즈 보기

 

 

1편에서는 JVM, JRE, JDK에 관한 설명을 하며 Java 8 이후부터는 런타임 이미지가 어떤 식으로 변경되었는지를 알아보며 간단하게 모듈화에 대한 이야기를 하고 Java Compiler를 통하여 Source Code(.java)가 Byte Code(.class)로 변경되어가는 과정을 상세하게 살펴볼 것이다.

 

 

JVM, JRE, JDK

실행원리를 설명하기 위해, 해당하는 용어들에 대하여 살펴보겠다. Java 설치하기 위해 사이트에 들어가보면,

 

그림1. Oracle JDK download site

JDK (Java Development Kit), JRE (Java Runtime Environment)가 보인다. 여기서 알 수 있는 정보는 세 가지가 있다.

 

  1. JDK가 JRE보다 용량이 크다
  2. [Java의 실행원리 개요] 시리즈 개요 그림에서 봤던 Runtime Environment가 JRE이다.
  3. 운영체제 별로 다운받아야 된다.

 

이를 바탕으로 생각할 수 있는 내용은 아래와 같다.

 

  1. JDK가 JRE를 포함하면서 추가적인 어떤 것들이 있을 수 있겠구나.
  2. Runtime Environment 내부에 JVM이 있었는데 JVM이 포함됐을 수 있겠구나.
  3. JVM이 운영체제마다 다르다는 의미일 수 있겠구나.

 

JDK 내부를 보니역시 JDK안에는 JRE 포함되어 있었다. 이외에도 lib 같이 추가적인 것들을 확인할 있다.

 

그림2. zulu OpenJDK download

 

JDK Library에는 추가적인 도구가 있었고, JRE Library 안에는 굉장히 많은 파일과 JVM 있는 것을 확인했다.

 

그림3. JDK lib와 JRE lib 폴더 내부

 

JVM(Java Virtual Machine) = JIT Compiler + Java Interpreter + Garbage Collector : 자바 가상머신이다. Source Code(.java)가 Java Compiler(javac)를 거쳐서 Byte Code(.class)가 되면, 이 byte code를 host 운영체제 위에서 실행할 수 있도록 하는 환경이다. 즉, Java를 실행함에 있어서 JVM은 필수적이다.

 

JRE(Java Runtime Environment) = JVM + Library Classes : Java 애플리케이션이 "실행"될 수 있는 최소한의 환경으로, JRE가 설치되어 있으면 애플리케이션을 실행하는 것에는 문제가 없다. 그러나, 개발을 위한 도구인 컴파일러나 디버거는 없다. JVM, 필수 라이브러리(rt.jar) 등이 있다. 이때, rt는 Runtime을 의미하는 것으로, String 클래스, System 클래스 등이 속한다. 

 

JDK(Java Development Kit) = JRE + Development Tool : 개발 도구라는 이름에서 있듯이, Java 애플리케이션을 "개발" 있는 도구 모음이다. 실행될 있는 최소한의 환경인 JRE 개발을 위한 Java Compiler(javac), 역어셈블러(javap),  Debugger 도구가 있다.

 

 

JVM이 Java를 실행하는 것에 있어서 핵심적인 요소임은 파악하였다. 자바를 위한 가상머신이라는 건데, 가상머신은 프로그램을 실행하기 위하여 물리적인 머신(컴퓨터)과 유사한 머신을 소프트웨어로 구현한 것을 의미한다. 즉, 가상의 컴퓨터라고 이해하자.

 

 

JVM의 특징

 

  • 스택 기반 가상머신 : 인텔 x86 아키텍처, ARM 아키텍처와 같은 하드웨어가 레지스터 기반으로 동작하는 것과는 다르게 JVM은 스택 기반으로 동작한다.
  • 심볼릭 레퍼런스 : 기본 자료형(primitive data type)을 제외한 모든 타입(클래스, 인터페이스)을 명시적인 메모리 주소 기반의 레퍼런스가 아니라 심볼릭 레퍼런스를 통해 참조. (기본 자료형은 명시적인 메모리 주소 기반의 레퍼런스)
  • 가비지 컬렉션 : 클래스 인스턴스는 사용자 코드에 의해 명시적으로 생성되지만, 그 이후에는 GC가 알아서 없애준다.
  • 네트워크 바이트 오더 : 메모리에 바이트를 저장하는 순서인 리틀 엔디안, 빅 엔디안 사이에서 자바 클래스 파일은 플랫폼 독립성을 유지하기 위하여 고정된 바이트 순서를 유지해야하므로, 네트워크 전송 시에 사용하는 바이트 오더인 네트워크 바이트 오더를 자바 클래스 파일에서 사용한다. (네트워크 바이트 오더는 낮은 주소에 데이터의 높은 바이트를 저장하는 빅 엔디안이다.)

 

 

 

 

Java 8 이후에는 어떻게 되었나?

로컬에는 유명한 LTS 버전인 Java 8, 11, 17 이렇게 버전이 있어서 내부를 봤는데, 어라? 자바 11부터는 jdk 내부에 jre 없었다.

 

그림4. Java 11 JDK 폴더 구조

 

Oracle Java11 downloads 오라클 홈페이지의 Java 11 다운로드 페이지에 가보면 JDK만 있고 따로 JRE를 찾을 수 없었다. 이는 JDK 9부터 JRE가 별도로 제공되지 않고 JDK에 포함되어 있기 때문이다. 엥!? JDK 8에도 JRE가 포함되어있다면서!!

 

Java 8에서 Java 9로 마이그레이션 하는 방법 공식문서를 참고해보면, 런타임 이미지를 생성하는 방식이 바뀌었다고 한다. 참고로, 런타임 이미지란 컴퓨터 프로그램이 실행될 때 메모리에 로드되는 실행 파일을 의미한다.

 

JDK 8까지의 런타임 이미지

 

  • JRE ( Java SE 플랫폼의 완전한 구현 )
  • JDK ( jre/ 라는 디렉토리에 전체 JRE와 개발 도구 및 라이브러리 포함)
  • 두 런타임 이미지 모두 비모듈식

 

JDK 9부터의 런타임 이미지

 

  • JRE, JDK 모두 아래의 디렉토리를 포함하는 런타임 이미지

 

이를 바탕으로 디렉토리 구조는 아래와 같은 형식이 된다.

 

그림5. https://hackernoon.com/understanding-java-9-modules-7f573vfe의 그림

 

이렇게 JDK 9부터는 기존의 모놀리틱한 JDK 각각의 모듈로 분리되어 별도의 배포 없이 모듈화된 JRE JDK 포함되어 제공된다고 한다. 하지만 JDK 9 이전 버전에서는 JDK JRE 다른 유형의 런타임 이미지였어서 JDK JRE 모두 설치했어야 하는데 공식 홈페이지에서 JDK 설치하면 JRE 같이 설치되는 원리였던 것이다. 그렇다면, 여기서 확장 가능한 생각이 Java 8 Java 11 차이점에는 모듈화라는 키워드가 추가되는 이다.

 

 

Java 9부터의 Modularization

Modularization(모듈화)은 Java 9부터 도입된 기능으로, 코드를 모듈 단위로 나눌 수 있게 해준다. Java 모듈은 Java 애플리케이션 혹은 Java API를 별도의 Java 모듈로 패키징할 수 있는 패키징 매커니즘으로, Java 모듈은 모듈식 JAR 파일로 패키지된다. 쉽게 말하여, 모듈은 관련된 패키지, 타입, 코드, 데이터 및 정적 리소스 등을 모아놓은 단위로 패키지의 패키지, 패키지보다 상위, 패키지 관리 기능이 포함된 라이브러리를 모듈이라고 이해하면 된다.

 

모듈은 나오게 되었을까? 자바가 많이 사용되고 규모가 커지면서 기존의 방식만으로는 부족하여 여러 부분에서 문제가 생겼기 때문이다. 패키지의 부족함을 보완하기도 하며 모듈화의 부재로 인한 런타임의 거대화 문제점들을 어떻게 해결하였는지 살펴보자.

 

 

1. 모듈식 Java 플랫폼을 통한 더 작은 애플리케이션 배포

 

Project Jigsaw의 일부로, 모든 Java 플랫폼 API가 별도의 모듈로 분할되었다. Java 9 이전에는 모든 Java 플랫폼 API를 Java 애플리케이션과 함께 패키징해서 rt.jar라는 단 하나의 파일에 통합하여 배포하였는데, 이는 사용하지 않는 클래스 또한 배포에 포함된다는 의미로 비효율적이었고 이로 인해 스마트폰, 라즈베리 파이 같은 소형 장치에서 문제가 됐었다. Java 9 이후에는 Java 애플리케이션에 필요한 Java 플랫폼의 모듈을 지정할 수 있게 돼서 애플리케이션이 실제로 사용하는 Java 플랫폼 모듈만 포함하여 애플리케이션을 패키징 할 수 있게 되었다. 그래서 애플리케이션 배포 항목이 더 작아지는 효과를 가져왔다.

 

 

2. 내부 패키지의 캡슐화

 

자바는 클래스, 패키지, JAR 세 가지 수준의 코드 그룹화를 제공하는데, 이 중 패키지와 JAR 수준에서 Java 9 이전까지는 캡슐화가 거의 지원되지 않았었다. 일반적인 라이브러리를 사용할 때는 내부에 포함된 모든 패키지에 외부 프로그램에서의 접근이 가능했었다. 하지만, 객체지향 프로그래밍에서 세부 구현을 숨기는 캡슐화는 필수적인 기법인데, 패키지의 캡슐화(Encapsulation)가 거의 지원되지 않았기에 문제가 생긴 것이다. 클래스에는 private을 붙이면서 정보 은닉이 쉬웠지만, 패키지는 그러한 작업이 힘들어서 여러 패키지에서 공용으로 사용되는 클래스를 은닉하기 힘들었고, 이는 라이브러리 내부 뿐만이 아니라 외부에서도 쉽게 사용할 수 있었다는 의미이다.

 

Java 9 이전의 접근 제어자는 public, private, protected, default가 기본적이 었다면, Java 9부터는 기존의 private, protected, default와 더불어 public을 조금더 세세하게 나눴다. 외부에 모두 public, 특정 모듈에만 public, 모듈 내부만 public과 같이 exports 키워드와 requires 키워드를 이용하여 이를 사용한다. 내보내지(exports) 않은 패키지의 클래스는 다른 자바 모듈에서 사용할 수 없다. 이때 내보내지 않은 패키지는 숨겨진 패키지 또는 캡슐화 된 패키지라고 한다. 이렇게 클래스, 인터페이스, 메서드 등을 제외하고 패키지 단에서도 캡슐화가 가능해지며 한층 더 객체지향스러워 진 것이다.

 

 

3. 빌드 단계에서 누락된 모듈 감지

 

Java 9 이전에는 빌드 단계에서 프로그램 구동에 필요한 모든 클래스가 다 있는지 확인할 수 없었다. 자바는 동적 로딩(Dynamic Loading)을 통해 필요한 클래스를 실행 중에 로드하기 때문에 시작이 빠르고 생성할 클래스를 선택할 수 있지만 실행 직후에는 애플리케이션이 누락된 클래스를 사용하려고 시도하기 전까지 누락 사실을 알 수 없다. 클래스 누락을 방지하려면 실행 중에 로드되는 클래스가 다 있는지 수작업으로 일일이 확인하는 수 밖에 없었다는 의미이다.

 

Java 9 이후에는 Java 애플리케이션도 Java 모듈로 패키징해야해서 애플리케이션 모듈은 사용하는 다른 모듈을 지정한다. 따라서, JVM이 시작될 때 애플리케이션 모듈에서 전체 모듈 종속성 그래프를 확인하고 필요한 모듈이 시작 시 발견되지 않으면 JVM은 누락된 모듈을 보고하고 종료하게 된다. 런타임에 누락된 것을 발견하는 것보다 더 안정적으로 바뀐 것이다.

 

 

Java 9부터 추가된 모듈화로 인한 설명은 여기까지 하고 이제 본격적으로 자바 컴파일러를 통해 어떻게 Source Code(.java) Byte Code(.class) 바뀌는지 알아보겠다.

 

 

Source Code Byte Code 되기까지

그림6. Java 실행원리 - 컴파일시점 환경

 

컴파일러와 인터프리터 게시글에서 설명했듯이, 사람이 작성한 High-Level Programming Language인 java 코드는 컴퓨터가 이해할 수 없어서 기계어까지 바꾸는 동작과정이 필요하다. Java는 한 번 작성하면, 어디서나 실행된다(Write once, run anywhere = WORA)는 가장 중요한 특징이 있는데 실행원리를 통하여 이를 이해할 수 있다.

 

  1. 개발자가 작성한 Source Code (.java) 파일
  2. javac 명령어를 이용하여 Java Compiler에 Source Code 파일 넣기
  3. Intermidate Code인 Byte Code (.class) 파일 생성

 

이렇게 변환된 Byte Code 클래스 로더에 의하여 JVM 메모리에 로드되고 연결하는 과정을 거친다 과정이 Java 작성, 어디서나 실행된다는 말일까? 분명히 Oracle JDK 다운로드 홈페이지를 보면 아래와 같이 Linux, macOS, Windows 운영체제마다 다르게 다운받는 것을 있다.

 

그림7. Oracle JDK download

 

정확하게 말하면, Java 언어 플랫폼 자체는 운영체제에 종속적이지 않고 독립적이지만, JVM은 운영체제에 종속적이다는 의미이다. 결국 Java 코드를 실행할 환경에 있어서는 각각의 OS에 맞는 JVM 설치가 필요하다. 그러면, 결국 OS에 맞는 JVM 설치가 필요한데 왜 Java가 OS에 독립적인 것이 장점일까?

 

Java 코드가 OS에 독립적이라는 것은 Java 애플리케이션을 실행할 수 있는 모든 운영체제에서 동일한 코드가 작동할 수 있도록 보장한다는 의미다. 애플리케이션을 작성할 때 운영체제에 대한 의존성을 고려하지 않아도 되기 때문에 애플리케이션을 다른 운영체제로 이전하거나, 여러 운영체제에서 동시에 실행할 필요가 있을 때 어디에서든 코드가 작동하도록 보장되니까 개발과 배포하는 것이 간편해지는 것이다. 이를 개발 및 배포의 편의성과 이식성을 높여준다고 한다.

 

예를 들어, 내가 개발한 애플리케이션을 AWS EC2의 ubuntu에 배포하려는 상황을 상상해보자.

 

mac OS 환경에서 C/C++ 프로그램을 개발하여 컴파일하고 mac OS 환경에서 실행한다고 하면 아무 문제가 없다. 하지만, mac OS 환경에서 개발하고 컴파일한 C/C++ 파일을 ubuntu에 올려서 실행하면 작동하지 않는다. 일반적으로, C/C++는 하드웨어 아키텍처와 운영체제에 종속적이기 때문에 해당 운영체제에 맞게 컴파일하고 실행하는 과정이 필요하다. 만약, mac OS에서 개발한 프로그램을 다른 운영체제인 ubuntu에서 실행하려면, mac OS에서 타겟 플랫폼인 ubuntu에 맞춰 컴파일하는 "크로스 컴파일"이 필요하다.

 

하지만, Java의 경우는 어떠한가? mac OS 환경에서 개발한 Java 애플리케이션을 ubuntu에 올리더라도 Java는 운영체제에 독립적이기 때문에 해당하는 서버의 OS에 맞는 JVM만 있다면 내가 빌드한 애플리케이션이 서버에서 작동한다는 것을 별다른 검증없이 보장할 수 있게 된다는 의미이다. 이렇게 비교해보니 Java의 장점이 더욱 와닿지 않는가?

 

그렇다면 또, 그러면 매번 크로스 컴파일 하면 되는건데 왜 굳이 JVM을 또 써야하냐는 질문이 있을 수 있다. 

 

내가 mac OS에서 매번 프로그램을 만들때마다 window, linux 뿐만이 아니라 모바일 환경 다양한 디바이스에 맞춰서 그때마다 크로스 컴파일하는 것과, 그냥 타겟에 JVM 설치하면 되는 , 어느 것이 편하다고 생각하는가? 다양한 환경을 고려했을 나는 아무리 생각해도 후자인 같다.

 

 

Java Compiler 동작과정

그렇다면, Java Compiler 어떻게 고수준 언어인 Java Byte Code 변환할까? 기본적인 컴파일러의 원리를 살펴보자.

 

그림8. 컴파일러의 Front-End와 Back-End

 

컴파일러는 소스 코드를 받아서 목적이 되는 목적 코드를 만드는 것이 목적이다. 컴파일러에도 Front-end Back-end 있는데 우리가 아는 개발에서의 의미와는 다르다.

 

Front-End : 프로그래밍 언어에 의존적, 기계에는 독립적

Back-End : 프로그래밍 언어에 독립적, 기계에는 의존적

 

컴파일러의 Front-End 개발자가 작성한 소스 코드를 분석하여 의미를 파악하는 역할이다. Java 작성되었는지 C 작성되었는지에 따라 다르기 때문에 프로그래밍 언어에 굉장히 의존적이다. Back-End Front-End에서 분석한 내용을 가지고 기계가 이해할 있도록 기계에 맞게 기계어(바이너리 코드) 바꿔야 한다. 따라서 프로그래밍 언어에는 독립적이지만, 기계에는 의존적이라는 의미이다.

 

그림9. 일반적인 컴파일러의 구조

 

일반적인 컴파일러의 구조는 그림과 같다. 간단하게만 설명하고 넘어가겠다. 궁금하다면 나의 번째 참고하자.

 

[Compiler Front-End]

 

  1. Lexical Analyzer (Scanner) : 어휘 분석기이다. 소스 코드 파일의 문자 시퀀스를 토큰 시퀀스로 변환하여 컴파일러 내부에서 효율적이며 다루기 쉬운 정수로 바꿔준다. Lexical Analyzer의 결과물은 tokens이다. 예를 들어, if ( a > 10 )이 있다고 하면, if(a>10) 6개의 토큰이 생성되고 if는 32, ( 는 7, a는 4와 같이 토큰에 정수가 매겨진다. 식별 가능한 문자 시퀀스는 키워드(ex. publicclassmain 등), 리터럴(ex. 1L2.3f"Hello World!" 등), 식별자(ex. 변수 이름상수 이름함수 이름 등 ), 연산자(ex. +- 등), 구분 문자(ex. , , [] , {} , () 등) 등이 있다. 이 식별자 토큰은 Symbol Table (심볼 테이블)에 저장되고 나중에 Constant Pool 관련 지식에서 사용될 것이다. 대표적인 Lexical Analyzer에는 Lex가 있다.
  2. Syntax Analyzer (Parser) :  구문 분석기이다. 문장의 문법적 구조를 파악하여 해당하는 프로그래밍 언어의 문법을 준수하는지 등을 확인한다.  위에서 생성된 토큰 시퀀스들을 프로그램의 구문을 나타내는 파스 트리로 생성한다. 파스 트리를 Abstract Syntax Tree, AST인 추상 구문 트리라고 하는 경우도 있다. 대표적인 Syntax Analyzer에는 Yacc이 있다. 보통 Lex + Yacc 조합을 많이 사용한다.
  3. Semantic Analyzer : 프로그램의 의미적인 측면을 분석한다. 이 단계에서는 변수의 유효성을 확인하거나 함수가 올바르게 호출되는지와 같은 작업을 수행한다. 타입 검사, 자동 타입 변환 등이 있는데, 예를 들어 int a = "Hello"; 라고 한다면 Syntax Analyzer에서는 에러를 잡지 못하지만, Semantic Analyzer 단계에서 에러가 나타난다. 위에서 생성된 파스 트리를 기반으로 타입 관련 정보가 추가되어 더 자세한 파스 트리가 완성된다.
  4. Intermediate Code Generator : 중간 코드인 Intermidate Code를 생성하는 곳이다. 컴퓨터가 직접 이해할 수 있는 형태가 아닌, 추상화된 형태로 보통 작성된다. Lexcial Analyzer, Syntax Analyzer, Semantic Analyzer를 거치면서 만들어져 온 Symbol Table을 이용하여 클래스나 인터페이스 별 Constant Pool(상수 풀)을 만드는데 사용된다. 상수 풀에 저장된 정보는 해당 클래스나 인터페이스가 실제 생성될 때 Runtime Constant Pool을 구성하는데 사용되는데, 이는 [Java의 실행원리 3편] Runtime 환경 - JVM Memory, Runtime Data Areas에서 다루도록 한다.

 

[Compiler Back-End]

 

  1. Code Optimizer : 비효율적인 code 구분해내고 효율적인 code 바꿔준다. 최적화를 담당하는 부분이다.
  2. Target Code Generator : 중간 코드로부터 최종 실행 코드 Object Code 기계어를 생성한다. C/C++ 경우 Object Code 링커에 의해 링킹되고 실행 파일이 만들어지는 형태이다.

 

위에서 자바 컴파일러에 의해 소스 코드(.java) 바이트 코드(.class) 변환된다고 했었다. 바로 Compiler Front-End 부분을 자바 컴파일러가 담당하는 것이다. 컴퓨터가 직접적으로 이해할 수는 없지만, 추상화 형태인 Byte Code 생성되기 때문이다. Intermidate Code 바로 Byte Code이다. ( 참고로, 기계어를 이루는 바이너리 코드와 바이트 코드는 다른 것이다. ) 이를 바탕으로 그림을 다시 그려보면 아래와 같다.

 

그림10. Java에서 컴파일러의 작동 원리

 

이 그림을 보면 프로그래밍 언어인 Java는 플랫폼에 독립적이고 JVM은 플랫폼에 의존적이라는 말이 와닿을 것이다. 

 

Front-End : 프로그래밍 언어에 의존적, 기계에는 독립적 ( = Java Compiler 부분 )

Back-End : 프로그래밍 언어에 독립적, 기계에는 의존적 ( = JVM 부분 )

 

Source Code Java 기반으로 어휘 분석, 구문 분석, 의미 분석의 과정을 거치고 중간 코드인 Byte Code 클래스 파일을 만들기 때문에 언어에 의존적이다. 여기까지 작업을 하고 나면 컴퓨터가 직접 이해할 수는 없지만 추상화 형태이기 때문에 JVM 운영체제에 맞게 기계어로 변환해주는 과정을 하는 것이다. 그렇기에 JVM 플랫폼에 의존적이다. ( JIT Compiler 내용은 [Java 실행원리 4] Runtime 환경 - JVM Execute Engine에서 다루기 때문에 여기서는 넘어가겠다. )

 

JVM, JRE, JDK 같은 기본적인 용어를 설명하고 간단한 컴파일러의 동작 과정을 보면서 소스 코드(.java) Java Compiler(javac) 의해 바이트 코드(.class) 변하는 컴파일 시점의 환경을 살펴보았다.

 

참고

 

 

※ 본 게시글은 공부하면서 작성했기에 틀린 부분이 있을 수 있습니다. 질문 사항이나 댓글로 알려주시면 감사하겠습니다.

댓글