본문 바로가기
책 & 강의/스프링 입문을 위한 자바 객체 지향의 원리와 이해

02. 자바와 절차적/구조적 프로그래밍

by _최우석 2023. 3. 6.

02. 자바와 절차적/구조적 프로그래밍

자바 프로그램 개발 및 구동

자바의 특징

자바 코드 컴파일 과정

현실 세계에서 소프트웨어, 즉 프로그램은 개발자가 개발 도구를 이용해 개발하고 운영체제를 통해 물리적 컴퓨터인 하드웨어 상에서 구동된다. 자바가 만들어 주는 가상 세계도 이와 마찬가지다. 자바 개발 도구인 JDK를 이용해 개발된 프로그램은 JRE에 의해 가상 컴퓨터인 JVM 상에서 구동된다.

자바는 위와 같은 구조에서 프로그램이 실행된다. 이것은 일종의 가상 컴퓨터 세계를 만들어서 그곳에서 프로그램을 실행하는 것으로 해석할 수도 있다.

JDK : 소프트웨어 개발 도구 : 자바 번역기 + JRE(자바 실행기 + JVM)

JRE : JVM용 운영체제 : 자바 실행기 + JVM

JVM : 가상의 컴퓨터

자바 프로그램이 배포되는 방식

자바가 이런 구조(위의 그림)를 택한 이유는 기존 언어로 작성한 프로그램은 윈도우 95용, 윈도우 XP용, 윈도우 7용, 윈도우 8용 등 각 플랫폼용으로 배포되는 설치 파일을 따로 준비해야 했던 불편함을 없애기 위함이다.

.class 파일을 jar나 war 포맷으로 패키징하여 배포한다. 즉, 기존에 C언어에서는 기계마다 약간의 수정사항이 필요했는데, 자바에서는 JVM을 한번 거치기 때문에 이러한 수정사항들을 JVM이 대신하여 처리해주는 것이다. 그리고 배포 시에는 자바 번역기를 통해 생성한 자바 목적 파일을 패키징하여 배포한다.

<aside> 💡 플랫폼 : 하드웨어 + OS

</aside>

자바 프로그램이 메모리를 사용하는 방식

프로그램은 위와 같은 방식으로 메모리를 관리한다.

메모리 영역에 대해서 잘 알면 프로그램을 이해하는데 매우 큰 도움이 된다.

그리고 객체지향 프로그램의 데이터 저장 영역은 앞으로 T 메모리 구조라고 부른다.

T 메모리와 같이 객체지향 프로그램은 데이터를 3개의 영역으로 분할해서 저장한다.

  • 스태틱 영역 : 클래스 데이터 저장
  • 스택 영역 : 메소드 데이터 저장
  • 힙 영역 : 객체 데이터 저장

자바에 존재하는 절차적/구조적 프로그래밍의 유산

자바는 철저한 객체지향 프로그래밍 언어이다. 그러나 그럼에도 불구하고 절차적/구조적 프로그래밍 패러다임의 유산 또한 물려 받고 있다. (사실상 모든 프로그래밍 언어들이 어떤 하나의 패러다임만을 통해서만 구현되지 않고 보통 다양한 패러다임이 혼합되어 구현된다.) 그래서 자바에게 전승된 절차적/구조적 프로그래밍 패러다임의 흔적들을 살펴본다.

절차적 프로그래밍 패러다임은 한 마디로 goto 구문을 사용하지 말라는 것이다. 이 말의 뜻을 이해하려면 절차적 프로그래밍이 나오게 된 배경을 알아야 한다. 이전에는 goto 구문을 이용해서 코드를 작성했었다. 그러나 이러한 문법은 소스 코드의 논리적 흐름을 이해하기 어렵게 만든다. 이처럼 goto 구문으로 인해서 이해하기 어렵게 된 코드를 스파게티 코드라고 부른다. 왜냐하면 논리적 흐름이 스파게티 면처럼 얽히고 설켜 있기 때문이다. 따라서 자바는 이러한 goto 구문을 예약어로 등록해두고 사용하지 못하도록 막아놓고 있다.

구조적 프로그래밍 패러다임은 한 마디로 함수를 사용하라는 것이다. 함수를 사용하면 좋은 점은 다음과 같다.

  1. 중복 코드를 한 곳에 모아서 관리할 수 있다.
  2. 논리를 함수 단위로 분리해서 이해하기 쉬운 코드를 작성할 수 있다.(분할 정복, D&C)
  • 이것뿐만 아니라 구조적 프로그래밍에서는 공유 사용 시 문제가 발생하기 쉬운 전역 변수보다 지역 변수를 쓰라는 지침도 존재한다.

그렇다면 구체적으로 위의 패러다임을 자바의 어느 부분에서 확인할 수 있을까? 바로 메소드 내부이다. goto 구문은 논리의 흐름을 제어하는 용도였고, 함수는 객체의 메소드와 같은 것이다. 따라서 제어문과 함수의 정의는 메소드 내부에서 확인할 수 있다.

다시 보는 main() 메서드 : 메서드 스택 프레임

main() 메서드는 프로그램이 실행되는 시작점이다. main() 메서드가 실행될 때 메모리, 특히 T 메모리(데이터 저장 영역)에는 어떤 일이 일어나는지 알아보자.

JVM이 실행되기 전까지의 과정

  • JRE는 먼저 프로그램 안에 main() 메서드가 있는지 확인한다.
  • main() 메서드가 있으면 JRE는 JVM에 전원을 넣어 부팅한다.
  • 부팅된 JVM은 목적 파일을 받아 그 목적 파일을 실행한다.

<aside> 💡 참고로 모든 자바 프로그램은 반드시 java.lang 패키지를 포함해야만 한다.

</aside>

main() 메서드가 실행되기 전 JVM에서 수행하는 전처리 작업들

  • java.lang 패키지를 T 메모리의 스태틱 영역에 배치한다.
  • import된 패키지를 T 메모리의 스태틱 영역에 배치한다.
  • 프로그램 상의 모든 클래스를 T 메모리의 스태틱 영역에 배치한다.

main() 메서드가 실행될 때 T 메모리 변화

  • 여는 중괄호를 만날 때마다 스택 프레임이 하나씩 생긴다.
  • 닫는 중괄호를 만나면 스택 프레임은 소멸된다.
  • main() 메서드의 여는 중괄호로 인해서 main() 메서드의 스택 프레임이 스택 영역에 할당된다.
  • main() 메서드의 args 인자를 저장할 변수 공간을 스택 프레임 맨 밑에 할당한다.
  • 명령어를 만나게 되면, 명령은 실행될 뿐 데이터 영역에 변화는 없을 수 있다.
  • main() 메서드의 닫는 괄호를 만나면 main() 메서드의 스택 프레임이 소멸된다.
  • main() 메서드가 종료되면 JRE는 JVM을 종료하고 JRE 자체도 운영체제 상의 메모리에서 사라진다.

그림으로 코드 실행 모습을 확인

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

<aside> 💡 JVM의 전처리 과정이 완료되었다. </aside>

<aside> 💡 여기서 System.out.println 명령어가 실행되어 모니터에 문자열이 출력된다. </aside>

<aside> 💡 여기서 프로그램은 종료되어 메모리 할당이 해제된다. </aside>

변수와 메모리 : 변수! 너 어디 있니?

public class Start2{
	public static void main(String[] args) {
		int i;
		i = 10;
		
		double d = 20.0;
	}
}

<aside> 💡 JRE가 main() 메소드가 있는 클래스를 찾는다. 만약에 있다면 JVM을 부팅하여 프로그램을 실행할 수 있도록 전처리 과정을 거친다. </aside>

JVM이 부팅되었다.

JVM이 프로그램을 실행하기 위해서 필요한 클래스 및 패키지들을 스태틱 영역에 로드함으로써 전처리 과정을 완료한다.

 

main() 메소드의 여는 괄호를 만나면 JVM 스택 영역에 스택 프레임이 생성된다.

i라는 변수는 처음에 초기화 되지 않은 채로 선언되었다. 그래서 스택 영역에서도 해당 변수에는 쓰레기값이 저장된다. 이후에 해당 변수에 값을 대입해주면 쓰레기값 대신에 그 값이 할당된다.

d 변수는 선언과 동시에 초기화 되었다. 그래서 스택 영역에 변수가 들어가게 되어도 쓰레기값이 아닌 데이터가 곧바로 매칭되어 있다.

main() 메서드의 닫는 괄호를 만나면 main() 메서드의 스택 프레임은 사라지게 되고 프로그램은 종료된다. 그래서 JRE는 JVM을 종료시키고 JRE 자신의 메모리마저 운영체제에 의해 메모리상에서 사라진다.

블록 구문과 메모리 : 블록 스택 프레임

public class Start3{
	public static void main(String[] args){
		int i = 10;
		int k = 20;
		
		if(){
			int m = k + 5;
			k = m;
		} else {
			int p = k + 10;
			k = p;
		}
	}
}
JRE는 main() 메서드가 어딨는지 찾는다. 만약에 있는 것을 확인했다면 JVM을 부팅시킨다.

JVM은 java.lang 패키지를 기본적으로 스태틱 영역에 로드 시킨 뒤, import 된 패키지들을 스태틱 영역에 로드시킨다. 그 뒤에 프로젝트에서 사용하는 클래스를 스태틱 영역에 로드 시킨다. 이런 과정을 통해 JVM은 프로젝트를 실행시킬 수 있는 전처리 과정 완료하게 된다.

코드를 읽어나갈 때, main() 메서드의 여는 괄호를 만나게 되면 스택 영역에 main() 메서드의 스택 프레임이 생성된다.

if문의 조건이 true이기 때문에 if(true) 스택 프레임이 스택 영역에 생성된다.

위와 같이 스택 프레임이 중첩되어 있는 경우, 내부 스택 프레임은 외부 스택 프레임의 변수들을 참조할 수 있다.
m 변수는 외부 스택 프레임에 있는 변수인 k를 참조하여 값을 할당한다.

코드를 읽어나갈 때, if문의 닫는 괄호를 만나면 해당 스택 프레임은 소멸된다.

main() 메서드의 닫는 괄호를 만나면 해당 스택 프레임은 소멸되고 프로그램은 종료된다. 프로그램이 종료될 시에 JRE는 JVM의 메모리를 소멸시키고 자신의 메모리마저 운영체제에 의해서 소멸되도록 한다.

지역 변수와 메모리 : 스택 프레임에 갇혔어요!

변수는 위와 같이 T 메모리 구조 상에서 모두 존재할 수 있다. 이 부분에서는 지역 변수에 대해서만 살펴본다. 위에서 봤던 코드를 다시 한번 살펴본다. 즉, 스택 프레임 내에서 지역 변수 간의 참조 관계를 살펴본다.

지역 변수는 스택 영역에서 일생을 보낸다. 그것도 스택 프레임 안에서 일생을 보내게 된다. 따라서 스택 프레임이 사라지면 함께 사라진다.

클래스 멤버 변수는 스태틱 영역에서 일생을 보낸다. 스태틱 영역에 한번 자리 잡으면 JVM이 종료될 때까지 고정된(static) 상태로 그 자리를 지킨다.

객체 멤버 변수는 힙에서 일생을 보낸다. 객체 멤버 변수들은 객체와 함께 가비지 컬렉터라고 하는 힙 메모리 회수기에 의해 일생을 마치게 된다.

public class Start3{
	public static void main(String[] args){
		int i = 10;
		int k = 20;

		if(i == 10){
			int m = k + 5;
			k = m;
		} else {
			int p = k + 10;
			k = p;
		}

		//  k = m + p;
	}
}

“외부 스택 프레임에서 내부 스택 프레임의 변수에 접근하는 것은 불가능하나 그 역은 가능하다.”

스택 프레임이 위와 같이 중첩 되어 있는 경우에, 내부 스택 프레임은 외부 스택 프레임의 데이터에 접근할 수 있다. 그러나 외부 스택 프레임은 내부 스택 프레임에 접근할 수 없다.

메서드 호출과 메모리: 메서드 스택 프레임 2

public class Start4{
	public static void main(String[] args){
		int k = 5;
		int m;

		m = square(k);
	}

	private static int square(int k){
		int result;
		k = 25;

		result = k;

		return result;
	}
}
JRE는 main() 메서드가 있는 지 확인한다. main() 메서드가 있으면 해당 프로젝트를 실행하기 위해 JVM을 부팅시킨다.

JVM이 부팅되면 자바 프로젝트를 실행하기 위한 전처리 과정을 거친다. JRE는 JVM의 클래스 로더를 이용해서 런타임 메모리에 각각 필요한 데이터들을 로드시킨다. 먼저 java.lang 패키지를 로드시킨다. 그 다음 프로젝트에 import된 패키지들을 로드시킨다. 그리고 프로젝트에서 사용하는 클래스들을 로드시킨다.

자바 코드를 읽어가던 중 main() 메서드의 여는 괄호를 만나게 되면 해당 메서드의 스택 프레임을 스택 영역에 생성한다. 이때 그림은 5번줄까지 실행한 모습이다.

메서드가 호출되면, 해당 메소드의 스택 프레임이 스택 영역에 새롭게 생성된다. 이때 제일 아래에는 반환값 변수의 스택이 존재하게 된다. 다시 말해 새로운 메소드의 여는 괄호를 만나게 되면 그 메서드의 스택 프레임이 스택 영역에 생성된다.

위와 같이 스택 프레임이 중첩되어 있지 않고 독립적으로 존재할 시에는 스택 프레임은 자신 안에 있는 변수만을 참조한다.

메서드의 닫는 괄호를 만나면 해당 메서드의 스택 프레임은 반환값을 전달하고 소멸된다.

이 코드에서 얻을 수 있는 교훈은 서로 독립적으로 구분된 스택 프레임끼리는 서로 접근이 불가능하다는 것이다.
Call by Value : 값만을 전달하여 변수의 내용을 할당하는 방식을 말한다. 위의 코드에서는 값의 전달을 모두 Call by Value 방식을 따랐다.
메서드의 스택 프레임 내부를 살펴볼 수 없는 이런 행위를 메서드를 블랙박스화 한다고 말한다.

전역 변수와 메모리 : 전역 변수 쓰지 말라니까요!

두 메서드 사이에 값을 전달하는 방법은 메서드를 호출할 때 메서드의 인자를 이용하는 방법과 메서드를 종료할 때 반환값을 넘겨주는 방법이 있다고 했다. 그런데 메서드 사이에 값을 공유하는 방법이 사실 하나 더 있다. 바로 전역 변수를 사용하는 것이다.

public class Start5{
	static int share;

	public static void main(String[] args){
		share = 55;

		int k = fun(5, 7);

		System.out.println(share);
	}

	private static int fun(int m, int p){
		share = m + p;

		return m - p;
	}
}
JRE는 자바 프로젝트 내부에 main() 메서드가 존재하는지 확인한다. 존재한다면 JVM을 부팅시킨다.

부팅된 JVM은 메모리를 할당 받는다. 이때 데이터 저장 영역에 필요한 데이터들을 로드시킴으로써 자바 프로젝트가 실행될 수 있도록 전처리를 한다. 이때 JRE가 클래스 로더에게 JVM의 런타임 메모리 영역에 데이터들을 로드할 수 있도록 한다.

JVM의 스태익 영역에 java.lang 패키지를 제일 먼저 로드 시킨다. 그 뒤에 프로젝트 내부에 import된 패키지들을 스태틱 영역에 로드시킨다. 그리고 필요한 클래스들을 스태틱 영역에 로드시킨다.
스태틱 변수와 같은 경우에는 선언과 동시에 초기화하지 않아도 0이란 기본값으로 초기화 된다. 이때 자료형마다 초기화되는 데이터는 다르다.

5번째 줄까지 실행한 결과의 모습이다. args 인자의 스택이 스택 프레임 내부에 생성되었고, share라는 전역 변수의 값이 다시 대입되었다.

12번째 줄까지 실행된 모습이다. fun() 메서드의 여는 괄호를 만나서 메서드 스택 프레임이 스택 영역에 생성되었다. 그리고 이외의 값들이 초기화되어 저장된 모습이다. 이때 반환값 변수의 스택이 제일 먼저 생성되었음을 유의하라.

그리고 fun 스택 프레임 내부에서 share라는 전역 변수를 수정하였다.

fun 메서드 스택 프레임은 닫는 괄호를 만나서 소멸됨과 동시에 반환값을 반환한다.

main() 메서드의 닫는 중괄호를 만나게 되면 프로그램이 종료되고, JRE는 JVM의 메모리를 소멸시키고 자신의 메모리마저 소멸시킨다.
이 코드의 교훈은 스택 영역에서는 클래스 멤버 변수에게 바로 접근할 수 있다는 것이다.

멀티 스레드 / 멀티 프로세스의 이해

멀티 스레드

멀티 스레드(Multi Thread)의 T 메모리 모델은 스택 영역을 스레드 개수만큼 분할해서 쓰는 것이다.

멀티 프로세스

멀티 프로세스(Multi Process)는 다수의 데이터 저장 영역, 즉 다수의 T 메모리를 갖는 구조다.


멀티 프로세스와 멀티 스레드의 이해

멀티 프로세스는 각 프로세스마다 각자의 T 메모리가 있고 각자 고유의 공간이므로 서로 참조할 수 없다. 그에 반해 멀티 스레드는 하나의 T 메모리만 사용하는데 스택 영역만 분할해서 사용하는 구조다.

멀티 프로세스는 하나의 프로세스가 다른 프로세스의 T 메모리 영역을 절대 침범할 수 없는 메모리 안전한 구조이지만 메모리 사용량은 그만큼 크다.

멀티 스레드는 하나의 T 메모리 안에서 스택 영역만 분할한 것이기 때문에 하나의 스레드에서 다른 스레드의 스택 영역에는 접근할 수 없지만 스태틱 영역과 힙 영역은 공유해서 사용하는 구조다. 따라서 멀티 프로세스 대비 메모리를 적게 사용할 수 있는 구조다.

멀티 스레드에서 전역 변수 사용의 문제점

쓰기 가능한 전역 변수를 사용하게 되면 스레드 안전성이 깨진다고 표현한다. 물론 이를 보완하는 방법으로 락을 거는 방법이 있기는 하다. 하지만 락을 거는 순간 멀티 스레드의 장점을 버린 것과 같다.