개발 블로그
article thumbnail
Published 2023. 4. 9. 21:02
[WIL] 항해 1주차 항해99

이번 주 자바 기초를 학습하면서 가장 애먹었던 2가지는 '객체지향프로그래밍' 그리고 'JVM'이었다.

그래서 한 주를 마무리 하는 느낌으로 어려웠던 2가지 개념에 대해 정리를 해보고자 한다.

 

객체 지향 프로그래밍

사실 '지향', '프로그래밍' 이 두 단어는 익숙하기에 문제가 없었는데 그래서 대체 '객체'가 뭔데? 라는 생각이 들었다.

여러 자료를 찾아보고 내가 내린 결론은

 

객체란? 물리적으로든, 추상적으로든 존재하고 식별가능한 것

 

물리적, 추상적 하니까 뭔가 어렵게 느껴지지만 그냥 우리가 사는 세계에서 우리가 식별할 수 있는 모든 것이 객체라고 생각하면 된다.

 

우라가 사는 세계는 존재하는 모든 것들의 상호작용으로 이루어져있는데 이러한 점에 착안해서 프로그램을 구성하는 요소들을 객체로 정의하고 객체들 간의 유기적인 상호작용으로 프로그램이 동작하도록 설계하는 것을 객체 지향 프로그래밍이라고 한다.

 

프로그래밍에서는 이러한 객체를 만들어내기 위한 설계도를 '클래스' 라고 하고 이 클래스에 객체가 가져야 하는 속성(필드)과 기능(메서드)을 정의해놓는다.

 

 

객체 지향 프로그래밍의 특징 4가지

추상화

컴퓨터 과학에서 추상화는 '복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려내는 것'을 말한다.

여기서 중요한 것은 '핵심적인 개념 또는 기능을 간추려내는 것'이다.

 

예를 들어 자동차를 생각해보면 GV80, 람보르기니, 아반떼, K5 등등 다양한 차종들이 모두 객체가 될 수 있다. 그런데 이 객체들은 모두 자동차이기에 공유하는 핵심적인 속성이나 기능들이 있다.

 

예를 들어 바퀴나 핸들이 있어야 한다거나 액셀을 밟으면 차가 움직어야 한다는 등의 것들 말이다.

만약 추상화라는 개념이 없다면 이 모든 속성과 기능들을 각각의 객체를 만들기 위한 클래스들에 모두 정의해주어야 할 것이다.

 

하지만 추상화를 통해서 핵심적인 공통의 속성과 기능을 추출하고 별도의 클래스로 정의한다면 코드를 작성함에 있어도 반복적인 작업을 또 할 필요가 없는 것이다.

 

상속

추상화를 통해서 공통의 속성과 기능을 별도의 클래스(자동차)에 정의해놨는데 GV80, 람보르기니, 아반떼, K5 클래스에서 이 공통의 속성과 기능을 가져다 쓸 수 있어야 하지 않겠는가?

 

이것을 가능하게 하는 것이 바로 상속이라는 개념이다.

 

우리가 흔히 상속이라는 말을 들어본 것은 아마 '재산 상속'이라는 말에서 들어봤을 건데 부모가 자식에게 자기가 가진 재산을 물려주는 것을 의미한다. 

 

객체 지향 프로그래밍에서의 상속도 이와 마찬가지로 한 클래스가 다른 클래스에게 자기가 가진 필드와 메서드를 물려주는 것을 의미하고 물려 주는 클래스를 부모 클래스(super-class), 물려 받는 클래스를 자식 클래스(sub-class) 라고 한다.

 

다형성

다형성은 하나의 객체나 메서드가 여러 가지 다른 형태를 가질 수 있는 것을 의미한다. 자바는 타입의 논리로 동작하는 프로그래밍 언어라는 것을 생각해보면 다형성은 프로그램을 만드는 개발자에게 정말 많은 편리함을 준다.

 

메서드 관점에서 다형성은 '메서드 오버로딩'과 '메서드 오버라이딩' 있는데 

오버로딩은 메서드의 파라미터 갯수나 파라미터의 타입을 다르게 정의해도 같은 이름의 메서드로 정의할 수 있는 것이고

오버라이딩은 부모 클래스로부터 상속 받은 메서드를 자식 클래스 만의 기능으로 재정의 하는 것을 말한다.

 

객체 관점에서 다형성은 하나의 객체가 여러 가지 타입의 참조 변수에 할당될 수 있는 것인데 객체를 참조하는 변수의 타입은 객체의 부모 클래스이거나 객체가 구현하고 있는 인터페이스여야 한다.

 

그리고 부모 클래스나 인터페이스 타입의 변수에 이를 상속하거나 구현하는 클래스의 객체를 할당하면 해당 변수를 통해 사용할 수 있는 자식 클래스의 멤버는 부모 클래스이 멤버나 인터페이스의 추상 메서드로 제한된다.

 

이는 어찌보면 당연한 것인데 부모 클래스나 인터페이스 타입으로 선언된 변수인데 여기서 선언되지도 않은 것을 사용하는 것은 말이 안 되기 때문이다.

 

캡슐화

객체의 데이터와 기능을 하나로 묶고 외부에 노출되지 않도록 숨기는 것을 의미한다.

객체의 데이터와 기능을 하나로 묶는 것은 클래스를 만드는 것이라고 보면 되는데 외부에 노출되지 않도록 숨기는 것? 이것은 어떻게 가능할까?

 

바로 접근 제어자를 통해 가능하다.

 

자바의 접근 제어자는 총 4가지가 있는데 public, private, default, protected 이다.

 

public : 어디서나 접근 가능

private : 해당 클래스 내에서만 접근 가능

default : 같은 패키지 내에서만 접근 가능(아무것도 적지 않으면 default)

protected : 같은 패키지 내 또는 다른 패키지면 자식 클래스에서만 접근 가능

 

외부에 노출되지 않도록 숨기고 싶은 정도에 따라서 이 4가지 접근 제어자 중 선택해서 사용하면 된다.

 

 

JVM

이번 주에 공부하면서 가장 애먹었던 JVM이다... 사실 자바 프로그램이 어떻게 동작하는 가에 관점에서 JVM은 그냥 컴파일 된 바이트 코드 실행하여 OS가 이해할 수 있는 기계어로 바꿔주는 역할을 하는 것이라 크게 어렵지 않았다.

 

그런데 그 과정이 어떻게 일어나는가를 이해하기 위해 JVM 내부구조로 들어가보니 정말 어려웠는데 이해한 대로 정리를 해보고자 한다.

 

출처 : https://velog.io/@host92/JVMJava-Virtual-Machine

 

 

먼저 우리가 작성 자바 소스 코드(.java)를 컴파일 하면 바이트 코드(.class)로 바뀌게 된다.

Class Loader 는 바이트 코드를 실행하기 위해 필요한 각종 데이터를 Runtime Data Area에 로드하고 Execution Engine 이 바이트 코드를 실제로 실행하게 된다.

 

Class Loader

먼저 클래스로더는 컴파일 된 .class 파일을 runtime data area에 로드하는 작업을 하는데 이 때 로드하는 작업은 한 번에 모든 것을 메모리에 올리는 것이 아니라 프로그램 실행 중 필요한 경우에 동적으로 로드하게 된다.

 

로딩하는 작업은 크게 3단계로 이루어진다.

1. Loading : 클래스 파일을 가져와서 JVM 메모리에 로드

 

2. Linking : 클래스 파일을 사용하기 위해서 검증하는 과정

    2-1. Verifying : 클래스 파일이 JVM 명세를 잘 따르고 있는지 확인

    2-2. Preparing : 클래스가 필요로 하는 메모리를 할당

    2-3. Resolving : Constant Pool 내에 있는 모든 Symboilc Reference 를 Direct Reference 로 변경

 

3. Initialization : 클래스 변수(static 필드)들을 적절한 값으로 초기화

 

여기서 Resolving 에 대한 설명은 Runtime Data Area에서 이어가보려 한다.

 

Runtime Data Area

출처 : https://inpa.tistory.com/entry/JAVA-%E2%98%95-JVM-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD-%EC%8B%AC%ED%99%94%ED%8E%B8#%EC%9E%90%EB%B0%94_%EA%B0%80%EC%83%81_%EB%A8%B8%EC%8B%A0jvm%EC%9D%98_%EB%8F%99%EC%9E%91_%EB%B0%A9%EC%8B%9D

 

우선 Runtime Data Area는 쉽게 말하면 JVM이 자바 프로그램을 실행할 때 필요한 데이터를 저장해놓는 메모리 공간이다.

그럼 각각의 영역에 어떤 데이터가 저장되는지 알아보자.

 

Method Area

클래스나 인터페이스에 대한 필드, 메서드, 클래스 변수(static), 메서드 바이트 코드 등이 보관된다. 그리고 Method Area에 포함된 Runtime Constant Pool은 클래스, 인터페이스, 필드, 메서드, 상수 등에 대한 모든 Reference를 저장하는 영역이다.

 

Reference? 아까 Class Loader에서 Resolving을 설명할 때 Symbolic Reference와 Direct Refence가 등장했는데 이와 연결되는 부분이다.

 

JVM이 바이트 코드를 실행하는 과정에서 필요에 따라 Runtime Constant Pool에 있는 Reference를 참조하게 된다.

예를 들어 우리가 만든 push() 라는 메서드가 있다고 가정해보자.

public class Main {
	public static void push() {
        System.out.println("push!");
    }
    
    public static void main(String[] args) {
        push();
    }
}

 

Main 클래스의 Constant Pool 이렇게 구성이 된다.

push 메서드를 찾기 위해 흐름을 따라가 보면

#5 -> #23 -> #12 를 살펴보면 되는데 #12는 'Utf8' 타입의 push 이다. 이는 그냥 push를 글자를 의미하는데 이것이 바로 Symbolic Reference다. 

 

Symbolic Reference 상태에서는 실제 메서드나 클래스가 저장된 메모리 주소가 아니라 그 이름만을 저장하고 있다. 

Class Loader 에서는 이를 Direct Reference(실제 데이터가 있는 주소를 연결) 로 변경해주는데 이것이 Resolving 이다.

 

Heap

힙 영역에는 JVM이 자바프로그램을 실행하면서 만들어지는 객체들이 저장된다.

우리가 코드를 작성하면서 Class a = new Class(); 이런 식으로 객체를 만드는 데 변수 a 에는 객체에 대한 참조값이 담긴기게 된다.

 

즉 실제 만들어진 객체는 Heap 영역에 저장이 되고 이를 참조하기 위한 주소값이 변수 a에 저장이 된다.(이 변수는 Stack 영역에 저장이 되는데 Stack 영역에 대한 설명은 뒤에서 마저하겠다.)

 

만약에 이 객체가 더 이상 쓰이지 않거나 명시적으로 null을 선언해서 참조 변수와의 연결을 끊으면 GC 대상으로 선정되어 추후 GC가 동작할 때 메모리에서 사라지게 된다.

 

Stack

앞서 이야기 한 두 영역(Method Area, Heap)은 모든 스레드가 공유하는 영역이었는데 스택부터는 스레드 마다 고유의 영역을 갖는다.

 

이 스택 영역에는 또 '스택 프레임(Stack Frame)'이라는 것이 담기게 되는데 스택 프레임은 메서드 하나 당 하나씩 생기게 된다.

그래서 스택 영역에 호출되는 메서드의 순서대로 각 메서드의 스택 프레임이 쌓이게 되고 그 과정에서 메서드가 종료되면 스택에서 해당 메서드의 프레임이 삭제되는 방식으로 스택에 저장과 삭제가 이루어지게 된다.

 

스택 프레임은 크게 3가지 영역으로 이루어져 있다.

1. Local Variables Table : 지역 변수 테이블

2. Operand Stack : 연산을 위해 사용하는 스택

3. Frame Data : Runtime Constant Pool, 이전 스택 프레임에 대한 정보, 현재 메서드가 속한 클래스/객체 에 대한 참조 정보 등이 담긴 영역

 

그럼 실제로 메서드를 하나 만들어 실행해보면서 스택 프레임 내부에서 어떻게 동작하는지 살펴보자.

public class Main {
    public static void main(String[] args) {
        int a = 2;
        int b = 5;
        int c = a + 100 * b;
        System.out.println(c);
    }
}

이를 바이트 코드로 변환해보면

main 메서드 바이트 코드

명령어를 하나하나 해석해가면서 스택 프레임 내부에서 어떤 일이 일어나고 있는지 살펴보자

 

먼저 stack = 3은 이 메서드를 실행하면서 stack 의 최대 사이즈가 3이라는 의미이고

locals = 4는 local variable이 총 4개가 있다는 의미이다.

 

엥? 분명 자바 소스 코드에서는 a, b, c밖에 없는데 4개?? 라고 생각할 수 있지만 매개변수로 들어오는 args도 우리가 사용을 안 해서 그렇지 지역 변수라서 총 4개가 맞다.

 

그럼 명령어를 하나씩 따라가보자.

 

iconst_2 : int 타입 숫자 2를 Operand Stack에 넣어라

istore_1 : Operand Stack에서 데이터를 꺼내서 Local Variables Table 1번(a)에 넣어라

iconst_5 : int 타입 숫자 5를 Operand Stack에 넣어라

istore_2 : Operand Stack에서 데이터를 꺼내 Local Varaibles Table 2번(b)에 넣어라

iload_1 : Local Variables Table 1번에서 데이터(2)를 가져와 Operand Stack에 넣어라

bipush 100 : 상수 100을 OperandStack에 넣어라

iload_2 : Local Variables Table 2번에서 데이터(5)를 가져와 Operand Stack에 넣어라

imul : Operand Stack의 2개의 값을 꺼내서 곱하고 다시 Operand Stack에 넣어라

iadd : Operand Stack의 2개의 값을 꺼내서 더하고 다시 Operand Stack에 넣어라

istore_3 : Operand Stack에서 값을 꺼내서 Local Variables Table 3번(c)에 넣어라

getstatic #2 : Runtime Constant Pool 인덱스 2번을 참조해서 System 클래스의 static 필드 out(PrintStream 타입)에 접근해라

iload_3 : Local Variables Table 3번(c)에서 데이터(502)를 가져와 Operand Stack에 넣어라

invokevirtual #3 : Runtime Constant Pool 인덱스 3번을 참조해서 PrintStream 클래스의 println 메서드를 실행(502 출력)

return : 메서드 종료

 

 

앞서 설명한 대로 스택 프레임에 있는 3가지 영역

Local Variables Table, Operand Stack, Frame Data 를 필요에 따라 맞게 활용하면서 우리가 원하는 결과를 도출해내는  것을 볼 수 있다.

 

PC(Program Counter) Register & Native Method stacks

PC Register는 현재 JVM이 수행 중인 명령어(바이트코드)의 주소를 저장하는 공간이며

Native Method stacks 은 자바 외의 언어로 작성된 네이티브 코드들을 위한 메모리 공간이다.

 

쉽게 얘기해서 PC Register는 바이트 코드 중에서 지금 몇 번째 줄을 JVM이 수행하고 있는지에 대한 정보를 저장하는 공간인 것이다.

 

Execution Engine 

Class Loader 를 통해서 Runtime Data Area 에 배치된 바이트 코드를 명령어 단위로 읽고 실행하는 게 바로 Execution Engine 이다.

 

JVM 설명 초반부에 JVM의 역할은 바이트 코드를 OS가 이해할 수 있는 기계어로 바꿔주는 작업을 한다고 했는데 실제로 바꾸는 작업을 하는 것이 바로 Execution Engine 인 것이다.

 

그 과정에서 Execution Engine은 인터프리터, JIT 컴파일러 두 가지 방식을 혼합해서 바이트 코드를 실행하고 있다.

 

인터프리터

바이트 코드 명령어를 '한 줄 단위'로 읽고 해석하고 실행하는 방식이다.

 

JVM은 기본적으로 인터프리터 방식으로 바이트코드를 실행하는데 이러한 방식은 이미 한 번 호출해서 해석을 마친 메서드라도 다시 호출할 때는 또 한 줄 한 줄 다 읽고 해석해야 해서 속도가 느리다는 단점이 있다.

 

JIT 컴파일러

인터프리터 방식이 코드를 한 줄 한 줄 읽고 해석하고 실행하는 방식이라고 한다면 컴파일 방식은 코드를 한 꺼번에 컴퓨터가 이해할 수 있는 기계어로 변환하는 방식이다. 실행 전에 미리 다 변환을 해놓기 때문에 인터프리터 방식보다 더 속도면에서 장점을 가지는 방식인다.

 

기본적으로 인터프리터 방식으로 동작하는 JVM은 속도가 느리다는 단점을 보완하기 위해 컴파일 방식을 혼용해서 사용하고 있고 이 때 쓰는 것이 바로 JIT(Just - In - Time) 컴파일러이다. JIT 이라는 이름이 붙은 것은 기존 컴파일 방식은 실행 전에 미리 다 변환을 해놓는 것에 반해 런타임에 필요한 부분만 컴파일 하기 때문이다.

 

그래서 이 두 가지 방식이 혼용되는 과정을 살펴보면

 

먼저 JIT 컴파일러가 바이트 코드를 한 번 쭉 읽으면서 중복되는 소스코드를 Native Code로 컴파일 해놓고 캐싱(리소스를 저장해두었다가 요청 시 제공하는 것)해 둔다.

 

그 후 인터프리터가 바이트 코드를 한 줄 씩 변환하고 실행하면서 이미 JIT 컴파일러에 의해서 컴파일 된 부분들은 캐싱해 두었던 Native Code를 통해서 실행하는 것이다.

 

가비지 컬렉터(GC)

가비지 컬렉터는 Runtime Data Area 중 Heap 영역에서 더 이상 사용되지 않는 객체가 있으면 이를 자동으로 메모리에서 제거해주는 역할을 한다.

 

C, C++ 등의 언어는 개발자가 직접 필요에 따라서 메모리를 할당하고 더 이상 이 데이터를 쓸 일이 없으면 해제해서 메모리를 관리해야 하는데 Java 에서는 이 가비지 컬렉터가 그 역할을 알아서 해주고 있기 때문에 별도로 메모리 할당이나 해제 대한 작업을 개발자가 해 줄 필요가 없는 것이다.

 

물론 메모리를 보다 효율적으로 관리하기 위해서 가비지 컬렉터의 알고리즘을 다룰 필요가 있긴 하다.

 

 

 

마무리

이렇게 한 주 동안 공부했던 '객체 지향 프로그래밍' 그리고 'JVM'에 대해서 한 번 쭈욱 정리해봤다.

 

JVM을 접하고 학습하면서 각 영역에 대한 이해는 어느 정도 됐는데 이를 유기적으로 연결해서 JVM 내부구조에서 어떻게 동작하는지 이해하는 데 굉장히 애를 먹었다...

 

그래도 어찌저찌 나름 이해가 되고 정리가 돼서 다행인 것 같고 다음 주에도 열심히 새로운 것들을 배우며 항해를 이어갈 생각이다.

'항해99' 카테고리의 다른 글

[TIL] DAY12  (0) 2023.04.15
[TIL] DAY 11  (0) 2023.04.13
[TIL] DAY 10  (0) 2023.04.12
[TIL] Day 9  (0) 2023.04.12
[TIL] Day8  (0) 2023.04.10
profile

개발 블로그

@하얀.손

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!