시작하기에 앞서 진짜 아무것도 모르는 일반인이시라면 Java와 JavaScript가 연관이 있는줄 아는 경우가 대부분입니다. 'JS는 Java에서 script 기능을 넣은건가?'라는 말도 실제로 들어본 질문 중 하나입니다. 하지만 두 언어는 그냥 이름만 비슷한 것이라고 확실히 말씀드리고 포스팅을 진행하겠습니다.
JAVA와 JavaScript
이 글을 보시는 분이라면 아마 처음 공부할 때 두 언어를 비교하는 이런 표들을 많이 보셨으리라 생각합니다.
차이점을 정리한 표의 일부
물론 처음 어느 한 언어에 입문할땐 이런 표만 보고 넘어가도 괜찮을지 모르지만 만약 시간이 좀 지나서 두 언어를 사용해보기 위해 공부하게 될 땐 한계가 있기 마련입니다. 단적인 예로 JAVA는 백엔드, JS는 프론트엔드에 주로 사용된다고 배우지만 JSP 등 JAVA도 화면을 그리는 데 쓰고 Node.js처럼 웹 서버를 구축할 때 JS를 사용하며 이 영역만 해도 큰 생태계를 갖고 있기때문에 절대 어느 언어가 한쪽으로 치우쳐졌다고 할 순 없습니다. 그렇기에 이런 표만 가지고는 어느 언어를 어떻게 사용할지 알기 힘든 경우가 많습니다.
때문에 이번 포스트에선 JAVA와 JS 코드가 메모리 상에서 어떻게 움직이는지를 이해하는 것으로 두 언어의 차이점을 좀 더 깊게 들여다보고 나아가 내가 어느 프로젝트에 어느 언어를 사용하는게 더 어울리는지 고민할 수 있는 능력을 조금 올려보려고 합니다.
자료 구조에서 stack과 queue
우선 메모리 관련 얘기를 하기 전에 간단히 stack과 queue에 대해 언급하고 가겠습니다.
간단히 설명하자면 stack과 queue는 자료를 다루는 방식인데, 평소 대기가 길어지면 우리는 보통 줄을 섭니다. queue는 일반적으로 당연한 우리가 줄을 서면 먼저 기다린 사람이 먼저 지나가는 것(선입선출)을 의미합니다. stack은 반대로 먼저 들어온게 가장 나중에 나가는 것을 의미합니다.(후입선출) 인간의 관점에서 보면 일상 생활에선 queue 방식이 일반적이고 stack 방식은 뭔가 이상한 느낌이 들 수도 있습니다. 하지만 컴퓨터가 자료를 다룰 때 stack 방식은 매우 중요한 구조입니다. 당장에 우리가 undo를 하기위해 ctrl+z를 하는 것부터가 stack 방식인걸 생각해보면 더 와닿으실겁니다.
이렇게 자료를 다루는 두 방식을 간단히 설명했으니 본격적으로 메모리에 대해서 얘기해보겠습니다.
메모리
시금치라 불리는 RAM
위의 사진은 대표적으로 우리가 메모리라 부르는 RAM입니다. CS를 제대로 배운 전공생이라면 메모리가 RAM뿐만 아니라 ROM이나 레지스터, 캐시 메모리 등 더 다양하게 존재한다는 것을 아시겠지만 이번 포스트에선 RAM과 ROM만을 합쳐 메모리라 칭하겠습니다.
우리가 프로그램을 돌리면 각 프로그램들은 운영체제(OS)로부터 이 메모리의 일부를 할당 받습니다.(JAVA는 JVM를 사용해서 조금 다르긴한데 쉽게 이해하기 위해 그냥 넘어가겠습니다.)
대부분의 언어에선 이 할당받은 메모리를 다시 여러 갈래로 분리합니다. 당연히 자로 잰 것처럼 딱딱 분리하는 것은 아니지만 용이한 설명을 위하여 그렇게 설명하겠습니다.
JAVA나 JS 등 객체지향(OOP) 기반, 그러니까 보통 class를 사용하는 대부분의 언어에서는 언어별로 조금 차이가 있지만 이 메모리를 크게 다시 Code, Data, Stack, Heap, Queue 등으로 분류합니다. JAVA와 JS를 비교하는 우리는 크게 Static, Stack, Heap 우선 이 세 개의 영역만을 먼저 알아보도록 합시다. 편의를 위해 각 영역을 한글로 스택, 힙, 스태틱으로 적겠습니다.
static 단어의 뜻은 '정적인'입니다. 한 마디로 이 영역에 저장되는 데이터는 값이 자주 변하는 동적(dynamic)인 것이 아닌 정적인 데이터입니다. 그래서 스태틱 영역은 거의 대부분 비휘발성 메모리인 ROM을 할당받습니다. 일반적으로 JS보단 JAVA에서 static이 많이 사용되므로 JAVA를 기준으로 말하겠습니다. 스태틱 영역은 주로 Code 영역과 Data 영역으로 구분됩니다.
JAVA 학습하면 보시던 변수, 메소드, 이너 클래스에 static을 붙이는 경우 코드의 생명주기 동안은 해제되지 않도록 Data 영역에 저장되고 우리가 짠 바이트 코드의 경우는 Code 영역에 저장됩니다.
메모리 - Stack과 Heap
다음으로 볼 스택 영역은 위에서 살펴봤듯 자료를 stack 방식으로 다룹니다. JAVA 코드로 예를 들면 아래와 같은 코드가 있을 때
아래 그림과 같이 stack 영역에 함수들이 호출됩니다.(저장이 아니라 호출입니다! 이건 뒤에서 더 언급하겠습니다.) 코드를 위에서부터 아래로 읽으니 맨 처음 main함수를 호출하고 그 안에 있는 println 함수가 호출됩니다. 호출된 함수들은 stack 방식에 따라 다시 println 함수부터 logic을 실행되며 끝나면 해제됩니다. 해제된 함수는 스택 영역에서 사라집니다.
이 스택 영역은 Call stack으로도 불리며 쉽게 우리가 코드에 작성한 함수를 호출하고 해제하는데 주 목적이 있다고 생각하시면 됩니다. 코드를 짜다 보면 함수 생성이나 호출에는 주로 중괄호{}를 쓰시게 될텐데 실행 중 {를 만날 때마다 stack frame이 하나씩 생성, 한마디로 함수가 새로 하나 쌓인다고 생각하시면 됩니다.
그런데 java를 공부하다 보면 스택 영역에는 함수만 호출되는 것이 아니라고 배웠을겁니다. 스택 영역에서 함수의 생명 주기는 호출(call) > 대기 > 실행 > 해제(pop)을 거치고 주기가 끝나면 스택 영역에서 해당 함수는 사라집니다. 이 때 함수가 스택 영역에서 실행되기 위해서는 함수에 작성된 member를 당연히 스택도 알아야 하겠죠.
우리가 Example이라는 함수를 만들었다고 했을때 보통 아래와 같이 지역 변수, 매개 변수, 원시 타입(숫자, 불리안) 등을 작성하게 됩니다. 이들은 보통 컴파일했을때 컴퓨터가 그 값을 알고 있습니다.
public Example(parameter) {
int localP = 1;
ExamObject examObject = new ExamObject(10000);
따라서 주로 함수를 호출하는 스택 영역은 이렇게 컴파일 타입에 컴퓨터가 값을 대체로 알고 있는 데이터들을 저장하게 되며 컴파일 타임에 이 값들을 다 알고 있으니 각각 요소에 메모리를 할당해줄때 딱 그만큼의 고정 값을 할당해줍니다. 이를 정적 메모리 할당이라고 하며, 그렇기에 스택 영역은 정적 메모리 할당이 가능한 지역 변수, 매개 변수, 원시 타입 등의 데이터를 주로 저장하게 됩니다.
그렇다면 힙 영역은 위에서 소개한 방식과 다르기에 별개로 존재할겁니다. Call stack이라 부르던 스택 영역과 달리 힙 영역은 Heap memory라고 불립니다. 스택 영역이 정적 메모리 할당을 했다면 힙 영역은 동적 메모리 할당을 할 것이라 예상하실거고 그게 맞습니다. 그렇기에 힙 영역에 저장되는 데이터는 주로 컴파일타임이 아닌 런타임에 알 수 있게 되는 객체, 인스턴스, 함수같이 동적인 데이터가 저장됩니다.
당연히 런타임에 해당 데이터들의 값을 알 수 있게되니 메모리를 할당할 때 고정된 값이 아닌 동적으로 메모리가 할당됩니다. JAVA 등 OOP 언어를 배우셨다면 참조 타입을 쓸 때 stack 부분의 변수에 특정 primitive 타입의 값이 아닌 주소가 저장된다는 것을 아실겁니다. 위에서 사용했던 JAVA 코드가 컴파일되면 아래 그림과 같은 상태일겁니다.
이렇게 Heap 영역에 객체 ExamObject가 메모리를 할당 받게 되면 primitive 타입 10000에만 해당하는 고정된 메모리가 아니라 주소 0x00에 해당되는 메모리 전체를 할당받게 되는 겁니다. 이를 보면 알 수 있듯 힙 영역은 동적 메모리 할당을 위해 자신을 주소로 쪼개어 객체를 담는다고 생각하시면 됩니다.
또한 힙이 스택과 다른 차이점 중 하나는 스택에서 함수가 pop되는 것과 달리 힙의 각 주소에 저장된 객체는 자동으로 pop되지 않습니다. C같은 언어는 그래서 개발자가 직접 객체를 해제해줘야 합니다만 이 과정이 번거롭기도 하고 어렵기도 해서 JAVA나 JS는 힙 영역을 개발자가 직접 해제하지 않고 GC(가비지 콜렉터)가 해제해줍니다.
마지막으로 말한건 이렇게 살펴본 스택과 힙이 별개의 영역이 아니란 것입니다. 처음 말했듯이 단지 하나의 메모리를 여러 영역으로 나눠서 사용하는 것이니만큼 스택 영역 역시 주소 값이 있습니다. 하지만 힙에서의 주소값처럼 사용되지 않을 뿐입니다. 한마디로 스택과 힙은 개발자가 할당하는 만큼 서로 나뉩니다.
메모리의 추상적인 구조
메모리를 추상적으로 구조화하면 위와 같이 일자로 표현할 수 있습니다. 그림의 위 일수록 주소값이 높고 아래로 갈수록 주소값이 낮아지며 개발자가 구분선을 정하듯(실제론 아니지만) 스택과 힙의 최대 영역을 할당한다고 생각하시면 됩니다.
JAVA와 JavaScript의 방식 비교
위에서 우리는 stack과 heap에 대해 살펴봤습니다. 사실 JAVA만 공부하시는 분은 이것까지만 아시면 될테지만 우리는 JS와 비교를 하기 위해 이번 포스팅을 보는 것이니 좀 더 살펴보겠습니다.
두 언어가 같은 객체를 다루다보니 힙 영역에서 일어나는 과정은 거의 비슷합니다. 하지만 앞서 살펴본 것처럼 스택 영역은 함수가 호출되고 해제되는 역할을 하기에 컴파일 타임에 main stream이 이뤄지는 영역입니다. 대부분의 코드들의 실행은 함수(메소드)의 procedure를 따라가니까요. 여기서 JAVA와 JS의 큰 차이가 생깁니다. JavaScript의 큰 특징 중 하나인 비동기식 코드 처리가 이 차이를 만들게 됩니다.
질문에 대한 정답은 'No'입니다. 많이들 비동기 처리하면 쓰레드를 활용한 병렬 처리를 떠올리실거고 실제로 JAVA의 경우 JDK가 메모리를 할당해주면 아래 그림처럼 각 쓰레드마다 스택 영역을 생성하고 힙 영역은 모든 쓰레드가 공유하는 구조로 이루어져 있습니다. 따라서 쓰레드별로 함수를 분배해서 지정해주면 비동기적으로 여러 스택이 활성화되는 것이죠.
하지만 JS는 싱글 쓰레드 기반 언어입니다. JS에서 멀티쓰레드 병렬 처리를 하는 것이 아니라 싱글쓰레드로 비동기 방식이 가능할 수 있는 이유는 Stack, Heap뿐만 아니라 Queue, Loop 영역을 추가로 갖기 때문입니다. Queue 영역은 원래 callbak queue, Loop 영역은 even loop인데 쉽게 영역이라고 칭한 것입니다.
위 그림처럼 스택 영역에 함수가 순서대로 호출되면 이렇게 쌓일텐데 console.log(1), log(2)를 순서대로 출력하다가 중간에 setTimeout()같은 함수가 있으면 그 즉시 #1처럼 그 함수는 실행되지 않고 Loop 영역으로 옮겨집니다. 이런 함수들은 ajax 요청코드나 이벤트 리스너 등이 있겠죠. 그렇게 stack에선 마저 console.log(3) 함수를 실행하고 1초가 지난 후 #2처럼 치워놨던 setTimeout()을 실행하기 위해 QUEUE 영역으로 이동합니다.
이 다음은 스택 영역의 모든 함수 실행이 끝난 뒤 큐 영역에 있던 함수들이 다시 스택으로 옮겨져 실행되는데, 이 때 QUEUE에서 STACK으로 함수를 이동시키기 위해선 stack 영역에서 실행한 모든 함수가 해제되어야 합니다. 한마디로 중간에 loop로 빠진 함수는 기존 함수들이 모두 실행 끝난 뒤에야 실행된단 얘기죠. 위의 경우에서 setTimeout()를 0초로 세팅했더라도 123이 출력된 뒤 실행되는 건 마찬가지라는 겁니다.
이런 JS의 비동기 처리는 JS의 탄생이 어땠는가를 알면 왜인지 바로 답이 나옵니다. JS는 웹사이트의 반응성을 높이기 위해 고안된 언어입니다. 그런데 이런 반응성을 끌어내야 하는 JS가 자바처럼 기본적으로 동기 처리 방식을 도입했다고 한다면 우리가 웹 사이트의 버튼 하나 누른 뒤 로딩이 어떻게 될지 생각해보면 됩니다.
결론
사실 이번 포스트에서 메모리 할당 방식을 살펴봤지만 이건 매우 간단한 요약이기에 정말 성능 최적화를 위해 깊이 있는 이해가 필요하다면 다른 전문적인 글을 보는 것이 좋다고 단언할 수 있습니다. 하지만 이번 포스트는 대략적으로 두 언어가 메모리를 쓰는 방식을 간단하게 요약한 것에 의의가 있다고 생각해주시면 좋겠습니다.
JAVA에서 구현되는 비동기 처리는 앞서 살펴봤듯 thread를 이용하는 멀티쓰레딩을 주로 이용합니다. 물론 Future, Callback이나 다른 라이브러리를 사용해 멀티쓰레딩이 아닌 비동기 처리도 구현이 가능합니다. 하지만 기본적으로 코드가 동기적 처리되는 JAVA로 대부분의 코드를 비동기 처리한다면 매우 비효율적입니다. 여기서 비동기 처리를 위해 사용하는 긴 코드가 sideEffect를 발생시키지 않도록 주의해서 작성되어야 하지만 말처럼 쉬운 일이 아니겠죠. 그런 점들을 모두 고려한다면 코드 역시 불필요하게 길어질 수도 있습니다.
마찬가지로 JS 역시 비동기 처리가 아닌 동기 처리를 위해 callback 함수, promise 객체, async/await 등을 사용할 수 있습니다. 하지만 앞에서 봤듯 내가 진행하는 프로젝트의 대부분 코드가 동기 처리 과정을 지향한다면 기본적으로 비동기 처리 기반인 JS는 정답과는 거리가 있을 수 있겠죠?
당연히 코드에 대한 깊은 이해와 라이브러리를 완벽하게 사용할 수 있게 되면 답이 조금 달라질 수는 있겠지만 이 글을 보시는 분이라면 아직 그 정도의 실력을 갖췄다고 보긴 어려울 것이라 생각됩니다.
이번 포스트를 통해 JAVA와 JS를 공부하시면서 메모리에 대한 이해가 아에 없었는데 학습에 도움이 되셨다면 좋겠네요. 또한 앞서 말했듯 나중에 더 숙달이 되시면 용도나 문법의 차이보다는 같은 OOP 언어 안에서 내가 만들 어플리케이션이 어떤 언어를 사용했을때 메모리 최적화에 도움이 될지에 따라 언어를 선택하는 기준을 세우셨으면 좋겠습니다.