JAVA_3_Static(+메모리)
* 해당 포스트는 실습 과정 중 학습을 정리하는 글이기에 주관적인 내용이 포함되어 있을 수 있습니다.
잘못된 부분이 있다면 걸러들으시거나 댓글로 남겨주시면 감사하겠습니다.
이번 포스트는 Static Class, variable 위주의 JAVA의 메모리 할당에 대한 얘기를 풀어보고자한다.
INDEX
1 물리/논리 메모리의 간단한 구조
2 기업들과 OOP
3 static과 인스턴스 Elements의 차이 - 우리가 사용하는 Class는 (static) Class
4 static과 OOP
스프링 공부를 하는데 누군가 Q&A에 static class에 대해 잘 모르는 뉘앙스의 질문글을 올렸고 거기에 답글을 달다가 '내가 알고 있는 메모리 관련 지식이 정확할까?'란 고민에 휩싸여 결국 다시 개념을 정리해보기로 했다.
JAVA를 배우다보면 static 키워드에 대해 배우게 된다. 아마 static 변수(클래스 변수), 메서드 그리고 중첩클래스에 대해 배울 것이다.
물론 맞는 개념이고 그렇기에 우리는 이를 보통 암기하고 넘어간다. 하지만 예를 들어 접근제어자처럼 '메서드에 붙을땐 다 되고 클래스에 붙을땐 private, protected는 사용 불가'같이 한 문장으로 요약할 수 있으면 상관없겠지만 static 키워드의 경우 변수나 메서드에 붙을때와 class에 붙어 중첩클래스로 사용할때가 다소 양상이 달라 이를 단순 암기하고 넘어가면 나중에 까먹을 확률이 높다.
그래서 이번 포스트에선 이를 좀 더 쉽게 알고 넘어갈 수 있지 않을까에 대해 말해보려 하는데, 분량상 중첩클래스에 관련된 자세한 얘기는 다음 포스트에서 다루려고 한다. 때문에 중첩클래스에 대한 내용을 알고싶다면 아래 링크를 추가로 참조하길바란다.
* 다음 포스트 링크는 https://fadet-coding.tistory.com/90
물리/논리 메모리의 간단한 구조
이번 포스트를 이해하기 위해선 메모리에 대해 간단히는 이해하고 있어야한다. 우선 메모리가 할당되는 구조는 일반적으로 다음과 같다.

일단 프로그램이 실행되어 메모리가 할당되는 과정까지의 자세한 내용은 이 글에서 다루려는 요지에 벗어나므로 다음 포스트에서 다루도록 하겠다. 다만 이 글을 읽을때는 메모리를 다루기 전에 우리가 java 코드를 컴파일하는 과정에서 실행되는 것은 IDE, JDK이고 컴파일이 끝난 기계어 코드를 cpu가 처리하는 과정이 어플리케이션 프로세스가 실행되는 것이라는 것만 알고 있어도 충분하다. 이 때 사용되는 기계어 코드가 위 메모리 사진의 CODE 영역에 저장되는 것이다.
메모리가 할당되는 구조라고 했지만 위 그림은 사실 논리적인 메모리 구조다. 그렇기에 우선 실제 물리적인 메모리 구조에는 1:1 대응이 되지 않지만 이해를 위해 이 글에선 엄밀히 구분하진 않겠다. 실제 HW인 물리적인 메모리는 ROM과 RAM 두가지로 구분된다. RAM은 우리가 흔히 시금치라 불리는 조립 부품이고 ROM은 메인보드에 납땜되어 있는 부품이다. CODE~DATA부분은 비휘발성/read only인 ROM, DATA~STACK부분은 휘발성/read-write인 RAM과 대응되고 우리가 이번 포스트에서 다루려고 하는 static같은 경우는 DATA영역에 저장된다. 이때 위화감을 느낄텐데 이 DATA 영역은 RAM, ROM에 둘다 대응되는게 이상하지 않는가? 이는 read-only인 ROM에 원래 DATA 영역이 위치하지만 이럴 경우 여기에 속한 변수들이 초기값만 가지게되니 런타임시 값이 변경되어야할 때를 대비해 RAM에도 DATA 영역을 복사해서 변수들을 저장할 수 있도록 하는 것이다. 여기까지 알면 대략적으로 메모리의 물리, 논리 구조에 대한 파악이 되었을 것이라 생각하고 더 자세한 내용은 불필요하다 생각하기에 이제 논리적인 메모리 공간에서 변수들이 할당되는 과정을 살펴보도록 하자.
여기서 잠깐!
이번 글에선 쓰레드에 대해서 다루지 않고 넘어갈텐데 프로세스, 쓰레드, OS에 대한 포스트는 이후에 할 예정입니다.
그렇기에 간단하게 짚고 넘어갈 점이 있습니다.
위 그림은 다른 요소를 고려하지 않고 메모리 구조를 파악하기 위한 것이고 실제 프로세스에 메모리가 할당되면 각 쓰레드는 각각의 Stack을 가지고 Code/Data/Heap을 공유합니다. 그렇기에 자신이 알고 있는 그림과 다르다고 누가 틀렸다 이런 생각은 금물!
기업들과 OOP
우리는 JAVA를 처음 배울때 클래스와 인스턴스, Scope와 static에 대해 학습한다. 하지만 이들이 메모리에서 어떤 과정을 거치게 되는지 모르고 코드를 짜는 경우가 종종 있고 필자 역시도 복습하는 차원에서 해당 포스트를 작성하게 됐다. 주 내용을 다루기 전에 메모리에 대한 이해가 전무한 채로 코드를 짜게 되면 생길만한 예시 하나를 들어보고자 한다. OOP에 대한 포스트를 이후에 자세히 올리겠지만 정부와 큰 기업들에서 이 OOP 위주의 언어가 빠른 시간에 정착되게 된 이유는 많은 가설 중 '상대적으로 구성원의 level에 구애받지 않고 유지보수가 가능'이 제일 설득력있다고 생각한다. 다른 직업들도 으레 그렇지만 특히 개발자들은 잘하고 못하고가 조금 극명히 드러나기 마련인데 과거에는 그래도 이 간극에도 불구하고 산업의 규모가 그렇게 크지 않았고 수요와 공급의 탄력에 맞춰 시장이 형성되었다. 하지만 최근 IT 산업의 비대화에 따라 실력 있는 개발자들이 부족해지고 구하기 위해선 큰 출혈을 감안해야하기에 그에 맞춰 많은 기업들은 좀 level이 낮은 개발자들이 자신들의 시스템을 유지하길 원했고 그 needs에 OOP가 부합했다고 해야할 것이다.
처음 설계만 실력 있는 개발자가 맡으면 캡슐화, 정보은닉, 다형성, 상속같은 주요 특징부터 단일 책임, 개방 폐쇄, 의존 역전 같은 SOLID 원칙까지 이 많은 속성들을 가진 OOP 언어들은 일을 조금 실력이 부족한 개발자들에게 나눠줄 수 있도록 큰 도움을 준다. 게다가 JAVA같은 강타입 언어는 더더욱 그렇고 OOP의 탄생 배경이 '누구나에게 쉬운 프로그래밍'이기에 더 설득력을 더해준다고 생각하기도 하고... 하지만 이런 모토는 OOP를 협업, 유지보수에 유용하고 버그를 최소화할 수 있는 설계가 가능하도록 하는 정말 좋은 방법론으로 만들어주었고 단지 위 의견은 OOP의 장점을 훼손하기 위한 발언이 아니라 개발자 스펙트럼을 고려해 OOP를 사용하고 OOP 방식은 낮은 level의 개발자들에게 낮은 책임을 부여하기 위해 여러 제약을 걸기 좋고 그 과정에서 성능 이슈가 생길 수도 있다는 개인적인 의견이기에 오해하지 않았으면 좋겠다.
하여튼 이렇게 조금 주제를 벗어난 것 같은 예시를 꺼낸 까닭은 OOP에서 책임을 줄이기 위해 조금 과하게 인스턴스를 만들고 많은 제약을 거는 과정에서 성능 이슈, 여기선 메모리 누수를 감안한 설계라는 것이 와닿기 때문이다. 뭐든지 이해가 있고 여기서 이해득실을 따져가며 도출한 결과가 성능의 희생으로 잃는 비용이 상대적으로 적기에 그런 결정을 한 것이고 그 비용이 임계치를 넘는다면 레거시를 뜯어고칠 것이다. 여기서 말하고 싶은 것은 이런 고민을 할 정도로 메모리 관리가 프로그래머들에겐 중요하다는 것이다.
static과 인스턴스 Elements의 차이
각설하고 다시 본론으로 돌아가서, OOP에서 객체를 다루는 것은 Dynamic(동적)으로 이루어진다. 객체를 다뤄 많은 이점을 얻지만 이 객체는 메모리에 할당되고 해제되는 것을 끝없이 반복하고 더이상 사용되지 않는 객체는 GC에 의해 제거된다. 이 과정에서 당연히 직접 메모리를 관리하는 C 같은 언어보다 편리하긴하지만 메모리 누수가 발생하는 것은 당연하고 과거에는 좋지 않은 컴퓨터 처리 능력과 엮여 OOP가 큰 주목을 받지 못했다. 최근들어 기술의 눈부신 발전으로 OOP가 주목받게 되었지만 당연히 C 같은 언어들에 비하면 메모리 관리에 있어서 수동적이고 프로젝트의 규모가 커지면 개발자들은 성능에 대한 고민을 끝없이 하고 있다. 이와 관련해 구조체를 쓰는 C#과 대비해서 JAVA는 좀 더 메모리 관리가 힘들 수 밖에 없는데 더 자세한 애기는 나중에 다루도록 하겠다.
여기서 우리는 Dynamic에 대비되는 Static에 대해 다뤄보려고 한다. Static은 우리말로 정적이라는 뜻이고 이 의미와 같이 Staic이 붙은 Elements들은 메모리의 DATA 영역에 저장되고 프로그램의 종료까지 존재하게 된다.* 여기서 static을 사용함으로 JAVA가 순수 OOP와 더 거리가 멀어지는데(물론 primitive 사용 등 다른 이유도 있지만) 일단 넘어가도록 하겠다. 앞서 얘기했던 개념을 대충 배우고 넘어간 이들을 위해 Dynamic과 Static을 대비해서 얘기했는데 글 맨 처음에 언급한 것 처럼 JAVA를 처음 배울때 인스턴스, 클래스, static 변수와 메소드 서로의 접근에 대해 표를 그리고 외워서 학습한 사람이 분명 존재할 것이다. 하지만 이는 표를 그려가며 암기할만한 내용이 전혀 아니다.
우선 이를 알기 전에 간단하게 알아야할 것이 있다. JAVA를 배울때 기본적으로 학습하는 것이지만 잠깐 설명하자면 객체가 생성될때 그 객체가 Heap에 저장되고 함수(인스턴스 메소드)가 실행되면 Stack에 후입선출(LIFO)로 인스턴스 멤버가 차례차례 할당되는 것을 학습했을 것이다. 여기서 Stack과 Heap은 맨 위 그림과 같이 메모리 영역을 공유하고(여기서 상대의 영역을 침범하면 우리에게 친숙한 Stack overflow) 코드에 잘못된 루프 사용 등으로 Stack이 비대해지면 Heap이 자연스레 줄어든다. 이것들을 알고 있으면 위 그림에서 나온 Heap이 runtime, Stack이 compiletime에 영향을 준다는 설명을 이해할 것이다. Heap에서 객체가 생성되고 소멸되는 과정에서 GC** 개입, 각종 손상이나 경합 등으로 인해 runtime에 속도 저하를 유발시키고 메소드의 조건, 분기문이 이상하게 꼬여 있다면 compiletime에 그 과정을 컴퓨터에 전달하는 과정에서 속도 저하를 유발시킬 것이다.
** GC(Garbage Collector) : C와 같은 함수는 malloc, free 등으로 메모리를 직접 관리하지만 JAVA는 메모리를 직접 관리하지 않고 GC라는 놈이 해줍니다.
앞서 잠깐 Heap, Stack을 설명했는데 본론으로 돌아와 우리는 static이 붙지 않은 인스턴스는 Heap에 인스턴스 멤버들은 Stack 영역에 할당되고 static이 붙은 클래스, 메소드, 변수들은 프로그램 종료시까지 DATA 영역에 할당된다고 알고있으면 표 같은걸 전혀 그리지 않아도 이를 이해하는데 충분하다. 굳이 인스턴스를 생성할 필요가 없거나 값을 공유해야한다면 static 키워드를 붙이게 되고 이럴 경우 따로 저장 공간이 할당된다. 이 과정은 당연히 특정 메모리를 고정해서 할당하는 만큼 Stack, Heap을 쓰는 것과는 다른 장단점이 각각 존재한다. 그에 대해선 다음에 더 자세히 얘기하고 우선 넘어가자.
앞선 개념을 살펴보면 static이 붙은 '클래스, 메소드, 변수'라고 했는데 사실 메소드, 변수는 이제 이해하고 넘어갈 수 있다. 그런데 여기서 static이 붙은 클래스가 이 개념이 적용될 수 있을까? 우리가 아는 static 클래스는 중첩 클래스인데 이렇게 간단히 이해하고 넘어갈 문제가 아닌 것 같다.
그래서 그 전에 설명해야할 점이 더 있다. 우선 우리가 JAVA를 사용하면서 당연하듯이 사용하는 Class는 어느 메모리에 저장될까?
자바에서 GC 대상은 참조가 있으면 제외되고 풀리면 해당되어 값을 pop
한다. GC를 언급한 이유는 Class 중 Nested(중첩) Class를 다루기 위함이다. 우리는 JAVA를 배울때 Nested Class는 되도록 static Class로 만들 것을 권장한다. 그것은 다른 이유 없이 Nested Class가 static이 아니라면(이 경우 inner Class라고 하고 이후 용어를 이것으로 통일) 바깥 클래스의 인스턴스가 생성되었을때 항상 inner Class가 그 인스턴스를 참조하고 있으므로 GC의 대상에서 제외된다. 그럴 경우 메모리에 그 인스턴스는 계속 남아 있어 메모리 누수를 발생시킬 것이다.
위의 개념을 보고 생각해보면 내 질문에 대한 대답을 할 수 있을 것이다. Class는 코드가 실행될때 항상 참조하는 Elements다. 그래야 필요할 때마다 인스턴스를 찍어낼 수 있으니까. 그렇기에 Class는 DATA 영역에 저장되어 프로그램의 종료까지 유지된다. 만약 클래스가 힙 메모리에 저장된다면 GC 과정에 의해 지워질테니까. 스택 메모리는 더더욱 안될 것이다. 그렇다면 여기서 Class가 앞서 말한 static 변수와 비슷하게 사용된다는 생각이 들지 않는가? 따라서 처음 학습하는 레벨에선 우리가 사용하는 모든 Class는 (static) Class라고 생각하면 개념을 정리하는데 매우 편하다.
* 이 부분에 대해 더 자세한 내용은 다음 포스트인 https://fadet-coding.tistory.com/90 참고
위 문단은 정말 이해만을 위한 개념이며 조금 더 정확한 개념을 소개하면 자바에서 "static class"라는 용어는 오직 중첩 클래스(nested class)에만 적용된다. 일반 클래스는 static이라는 키워드를 붙이지는 않는다. 하지만 클래스의 정보(메타데이터, static 변수, static 메서드 등)는 JVM의 메서드 영역(Method Area, Java 8 이후에는 Metaspace)에 저장되기에 class 역시 static 이 붙은 것으로 이해하는 것이 흐름을 아는데 도움이 된다고 판단하여 이전 문단을 작성한 것이다.
이렇게 이해하고 나면 처음에 소개한 인스턴스 변수, 클래스 변수 등이 서로 접근이 가능할지에 대해 외울 필요없이 static의 유무로 그 관계를 파악할 수 있다란 것이다. 예를 들어 '클래스 메소드가 인스턴스 변수에 접근할 수 없다'를 외울 필요 없이 1 클래스 메소드는 static이 붙는 메소드 2 인스턴스 변수는 static이 붙지 않는 필드 3 static이 붙지 않았으니 객체가 생성되어야 메모리에 할당되므로 에러를 막기 위해 접근을 차단 으로 이해하면 된다. 또 static Elements끼리는 같은 저장 공간에 존재하고 있으니 서로 자유로운 접근이 가능하다고 생각하면 된다.
static과 OOP
지금까지 static이 붙고 안붙고를 code level에서 살펴보았다면 이제 design level에서 살펴보려고 한다. 앞서 static을 사용함으로 순수 OOP가 아니게 된다고 했는데 JAVA가 이렇게 만들어진 이유는 어찌보면 당연하다. 전통적인 절차지향 언어의 코딩 방식은 컴퓨터의 처리구조에 가깝고 인간이 설계하기 쉬운 방식이다. 하지만 절차지향 언어의 단점은 코드가 길어지면 일명 스파게티 코드라 불리는, 버그가 발생하기 쉽고 가독성이 떨어지는 코드가 된다는 것이다. 이런 코드는 협업에 있어서 비효율의 극치이다. 이를 보완하기 위해 객체지향 방식이 등장했고 말 뜻대로 둘은 대비되는 관계가 아닌 상호 보완적이다. PP니 OOP니 FP니 하는 방법론들은 서로 상호 보완적이기에 특정 방법론만을 찬양하는 것은 바람직하고 말고를 떠나서 서로가 얽혀있는 방법론들을 굳이 서로 뭐가 좋느니 하면서 구별하려는 것이니 가장 적합한 도구를 선택해야하는 개발자에게 아이러니한 발상이라고 생각한다. 하여튼간에 static 사용은 모든 객체가 참조 가능하여 정보 은닉이 되지 않고 overriding과 Dynamic Binding이 불가능하니 다형성을 위반하게 되므로 OOP와는 거리가 있게 됨은 맞지만 좋은 설계를 위해 이런 선택도 필요하다고 생각한다. 당연히 그렇다고 static을 너무 남발하면 안되겠지만....
포스트의 결론은 좋은 코드라면 static을 사용함에 있어서 기본적인 이해가 선행되어야 한다는 것이다. static은 잘못 남발하면 메모리 관리에 불이익을 주며 좋은 설계를 망칠 수도 있다. 그러므로 1 static과 메모리에 대한 학습을 끝낸 경우 2 Elements를 사용할 때 인스턴스 참조가 필요 없을 경우 3 싱글톤처럼 프로그램 실행 내내 공유해야하는 값일 경우 잘 사용하면 설계에 큰 도움이 될 것이라 생각한다.
global과 static이 비슷하여 혼동할 수 있는데 따로 포스트하긴 애매해서 따로 적겠습니다.
1 global은 static과 달리 다른 파일에서 참조가 가능함
2 static의 경우 초기화를 하지 않으면 애초에 메모리 상에 올라가지도 않음, global과 달리 생성자를 호출하는 시점을 정할 수 있음