김춘식의 짱쎈 블로그.

여러 메모리 관리 방법들

2022년 01월 21일
#programming#memory

1. 현 시대에 왜 메모리 관리을 알아야 할까?

왜 현시대에 메모리 관리 기술을 알아야 할까?

백엔드 개발자들은 빡센 메모리 최적화로 더 낮은 성능에서도 서비스를 유지해 회사에 월 고정비용을 낮추거나 혹은 트래픽이 몰리는 상황에서도 더 안정적으로 버틸 수 있기 때문에 사실상 필수적으로 알아야 하는 내용이다.

하지만 프론트엔드 개발자들은 애초에 트래픽이 몰려도 큰 문제가 없을 뿐더러 (SSR의 경우 제외) V8 등의 강력한 JS 엔진과 React 등의 잘 최적화된 라이브러리를 사용한다면 굳이 신경 쓸 필요가 없는데 말이다.

요약하자면 더 큰 매출을 견인할 수 있기 때문이다.

메모리를 잘 최적화한다면 더 낮은 기기들을 지원할 수 있다. 이는 더 많은 유저가 서비스를 사용할 수 있음을 의미하고 이는 곧 회사의 매출 증대와 연결될 수 있다.

물론, 우리나라의 경우에는 최신 스마트폰 보급률이 굉장히 높기 때문에 피부에 와닿게 경험해볼만 한 일이 없다.

하지만, 글로벌 출시를 가정한다면 어떨까?

국가별 인기 스마트폰 기종 - 2018년 7월 기준

중국과 인도를 보면 알겠지만, 그다지 성능이 그리 좋지 않은 기종들을 많이 사용한다.

결국 글로벌하게 분포된 낮은 수준의 device들을 가정한다면 결국 프론트엔드 개발자들 또한 메모리에 대해서 잘 알아야 한다.

따라서 조금 원론적인 메모리 관리 기법들에 대해서 소개해보려고한다.

2. 메모리의 구조

프로그램이 실행되기 위해서는 프로그램이 메모리에 적재되어야 한다.

그리고 프로그램 런타임에서 할당되는 메모리를 저장할 공간도 필요하다.

따라서 우리가 아는 메모리는 아래와 같은 구조로 동작한다.

메모리의 구조

위에서부터 코드(code)영역, 데이터(data)영역, 힙(heap)영역, 스택(stack)영역으로 구분된다.

코드 영역

코드 영역은 실행하려는 프로그램의 코드가 저장되는 공간이다.

CPU는 이 코드 영역에 저장된 명령어를 하나씩 실행한다.

데이터 영역

프로그램의 전역 변수와 static 변수가 저장되는 공간이다.

프로그램의 시작과 함께 할당되며, 종료와 함께 소멸한다.

스택 영역

함수 및 함수의 지역변수가 저장되는 공간이다.

함수의 호출 과정에서 할당되고, 함수의 호출이 완료되면 소멸한다.

이렇게 스택 영역에 저장되는 함수의 호출 정보를 스택 프레임이라고 한다.

힙 영역

개발자가 직접 관리하는 영역이다. 우리가 보통 할당하는 객체들은 여기에 할당된다.

이 영역은 개발자에 의해 다양한 크기로 동적 할당되고, 해제된다.

우리가 오늘 중요하게 볼 곳은 바로 이 힙 영역이다.

바로 GC가 동작하는 영역이기 때문이다.

3. 개발자가 직접 관리

GC에 대해서 바로 알아보기에 앞서 이전에는 어떻게 메모리를 관리했는지 부터 알아보겠다.

C, C++ 등 개발자의 고대시대 때부터 존재하던 언어들의 경우 언어 단에서 지원하는 메모리 관리 기법들이 없다.

따라서 이 시기에는 할당과 해제를 모두 개발자가 직접 해야 했다. (보통 명시적 메모리 관리, explicit memory management 라고 부른다.)

예를 들면 이렇다.

// 포인터 변수 a에 SomeClass의 인스턴스를 할당
SomeClass* a = new SomeClass();

// a라는 변수의 메모리를 해제
delete a;

아마 가비지 컬렉터가 존재하는 언어들만 경험해본 개발자들은 이 부분에서 까무러칠 것이다.

아니 개발자가 직접 메모리를 해제한다니! 이 얼마나 극악무도한 일인가.

하지만 아래를 보라 이는 무궁한 발전 그 자체였다.

// 포인터 변수 a에 int 크기 만큼의 메모리를 할당
int* a = (int*)malloc(sizeof(int));

// a라는 변수의 메모리를 해제
free(a);

C언어는 할당될 메모리의 크기까지 지정해야 했다.

우리가 모두 알고 있듯이, 여러 사람이 코드를 작성하기 시작하고 가끔 메모리 해제를 까먹는 휴먼에러가 발생하자 해제되지 않는 메모리가 점점 쌓이며 결국 프로그램이 꺼지는 등의 문제가 발생했다.

이를 수도관에서 물이 새는 누수 현상에 빗대어, **Memory Leak(메모리 누수)**라고 부른다.

자동화를 좋아하는 개발자들은 해결 방법을 찾기 시작했고 그 방법이 바로 아래 내용이다.

4. 쓰레기 수집 (Garbage Collection)

Garbage Collection(이하 GC)의 유일한 목적은 프로그램에서 더는 사용하지 않는 메모리를 삭제하는 것이다.

이를 달성하기 위해서는 필연적으로 사용하는 메모리와 사용하지 않는 메모리를 구분해야 한다.

보통 이를 Reachable 하다고 한다. (사용하는 메모리는 Reachable, 사용하지 않는 건 Unreachable 하다고 한다.)

삭제한 뒤에는 메모리를 압축하여 메모리 파편화를 막는다.

1. 메모리 파편화

할당, 해제가 반복되며 중간중간 작은 공간이 남아있는 것을 보고 메모리 파편화(Memory Fragmentation, 메모리 단편화라고도 부름)라고 부른다.

![메모리 파편화 상황](./memory fragmentation.jpg)

위 그림에서 빨간색은 빈 공간, 초록색은 할당된 공간, 파란색은 신규로 할당하려고 하는 메모리이다.

빨간 공간을 모두 합치면 20kb기 때문에, 할당이 가능하지만, 각각 10kb씩 나누어져 있기 때문에 결국은 할당에 실패한다.

결국은 현재 할당하려는 크기가 들어갈 만한 메모리 공간이 없는 게 문제기 때문에, 그 공간을 만들어주면 된다.

보통은 크게 3가지 방법이 존재한다. 방법들의 소개는 글의 메인 주제가 아니므로 간략하게만 설명하겠다.

더 궁금하다면 따로 찾아보는 것을 추천한다.

  1. 페이징 (Paging) 기법

    • 가상의 메모리를 일정한 크기로 나누어 블록으로 만든 뒤, 사용하는 방식이다.
    • 일정한 크기의 블록페이지(Page)라고 한다.
    • 메모리가 부족하다면 SSD, HDD와 같은 저장공간에 페이지를 저장하고 추후 프로세스 실행에 필요할 때 사용한다.
  2. 세그멘테이션 (Segmentation) 기법

    • 크기가 고정된 페이징 기법과는 다르게 세그멘테이션은 크기를 동적으로 나누어 사용한다.
    • code, data, stack, heap 등 개발자가 메모리에 할당하는 것들을 기반으로 나눈다.
    • 페이징과 똑같이 메모리가 부족하면 SSD, HDD와 같은 저장공간에 페이지를 저장하고 추후 프로세스 실행에 필요할 때 사용한다.
  3. 메모리 풀 (Memory Pool) 기법

    • 메모리를 특정 크기, 개수로 미리 할당해두고, 돌려쓰는 것을 의미한다.
    • 주로 메모리의 할당과 해제가 빈번한 경우 사용한다.

2. GC의 동작 시점과 Stop The World

GC의 알고리즘, 메모리 환경 등에 따라 다르나 크게 2가지로 분류할 수 있다.

  1. 개발자가 직접 호출
    • C# 등의 언어에서는 개발자가 필요하면 GC를 직접 호출할 수 있도록 하고 있다.
    • 고급 개발자들만 보통 사용하며, 일반적으로는 직접 호출하지는 않는다.
  2. 프로그램이 호출
    • 가장 일반적인 방식이다.
    • 언어의 런타임 등에서 힙 메모리의 소진을 확인하면 호출한다.


여기까지만 보면 GC를 그때그때 강제로 호출하는게 더 좋을 것 같다는 생각이 들 수도 있다.

아쉽게도 그렇지 않다.

![동작중인 GC](./stop the world.jpg)

GC는 보통 동작할 때 모든 스레드를 멈춘뒤 작업하는데, 이를 보고 Stop The World라고 부른다.

모든 스레드가 멈췄다는 말에서 직감했듯이, 프로그램이 말 그대로 멈춘다. 이는 실 사용자에게는 렉이 걸린 것 처럼 보이거나 프로그램이 뻗은 것 처럼 보일 수 있다.

따라서 GC를 강제로 호출하는 것은 결코 좋은 생각이 아니며, 이 부분은 웬만하면 그냥 GC에게 맡기는게 좋다.

5. GC 다양한 알고리즘

GC는 그 역사가 오래된 만큼 다양한 알고리즘이 존재한다.

이 글에서는 간략하게 3가지 정도의 알고리즘만 소개해보려고 한다.

1. Reference Counting

레퍼런스 카운팅은 python, swift 등에서 현재도 사용하고 있는 방식으로서, 객체가 사용될 때 카운팅 변숫값을 1 올리고, 객체를 사용하지 않을 때 값을 1 내린다. 이후 카운트 값이 0이 되면 내부 로직에서 delete 명령어를 호출해 자동으로 메모리에서 해제된다.

실제로 동작하는 구성을 살펴본다면 아래와 같다.

![레퍼런스 카운팅 동작 모습](./reference counting.gif)

위 gif를 풀어서 설명하면 아래와 같다.

  1. 변수 A를 삭제하여 변수 A의 카운팅을 1낮춰 0이 된다.
  2. 변수 A와 연결된 변수 B의 카운팅을 1낮춰 0이 된다.
  3. 변수 B와 연결된 변수 D의 카운팅을 1낮춰 1이 된다.
  4. 따라서 변수 A, B만 삭제된다.

문제가 아름답게 해결된 것 같다. 해당 변수를 사용하는 다른 변수까지 모두 체크하다 보니, NullPointerException 등으로 프로그램이 꺼질 일도 없다.

하지만 삶은 그렇게 쉽지 않다. 오히려 레퍼런스 카운팅의 핵심인 연결 확인이 문제가 된다.

![레퍼런스 카운팅 순환 참조 발생 모습](./circular reference counting.gif)

위 그림을 보면 변수 B, G, E가 각각 서로를 참조하여 변수 A, F가 삭제되어도 계속 메모리에 남아있다. 즉, 메모리 누수가 여전히 발생하는 것이다.

이러한 상황을 순환 참조라고 부른다.

상황이 이렇다 보니, 사실 레퍼런스 카운팅을 활용해 메모리를 관리하는 경우가 오히려 드물다.

일반적으로는 레퍼런스 카운팅을 활용해 관리하되, 힙 메모리가 가득 차는 순간 GC를 돌려 발생한 메모리 누수를 잡는다.

메모리 누수라는 크나큰 단점이 있지만, 장점 또한 분명하다.

  1. 구현이 쉽다.
  2. 성능상으로 GC보다 빠르고 효율적이다.
  3. 힙 메모리가 소진될 때 동작하는 GC와 달리 카운트가 0이 되면 즉시 삭제하기 때문에 메모리를 즉시 재사용할 수 있다.

2. Mark And Sweep

이 알고리즘은 이름 자체가 동작 방식이다. 사용하지 않는 메모리를 표시(Mark)하고 삭제(Sweep)한다.

보통, GC Root라는 객체를 두고 이 객체에 연결된 메모리를 재귀적으로 탐색하며 Reachable 한지를 판단한다.

GC Root는 스레드, 함수, 변수 등 코드상에 존재하는 다양한 객체들이다.

동작 방식을 정리하면 아래와 같다.

  1. GC Root에서 연결된 노드들을 재귀적으로 탐색한다.
  2. 탐색 된 메모리를 표시(Mark)한다.
  3. 이후 표시되지 않은 메모리를 해제한다.

위 과정을 그림으로 표현하면 아래와 같다.

![Mark And Sweep](./mark and sweep.gif)

3. Scavenge

이전의 Mark And Sweep과는 다르게 해제와 동시에 메모리 압축이 가능한 방법이다.

메모리 공간을 크게 두 공간으로 잡아, 한 공간에만 할당을 하고 이후 꽉 찼을 시 메모리를 옮기며 남아있는 메모리를 해제하는 방식이다.

메모리의 추적 방식은 최상위 스택 포인터가 존재하는데, 이 스택 포인터와 연결된 메모리만 Reachable하다고 판단한다.

동작 방식은 아래와 같다.

  1. 메모리가 꽉 찰 경우, Reachable한 메모리를 현재 공간(From Space)에서 다른 공간(To Space)으로 메모리를 이동시킨다.
  2. 현재 공간 (From Space)에 남아있는 메모리를 모두 해제한다.
  3. 이후 두 공간의 역할을 바꿔 다시 실행한다.

위 과정을 그림으로 표현하면 아래와 같다. Scavenge

4. Generational

Generational GC는 아래 두 가설을 참으로 가정하고 탄생한 알고리즘이다.

  1. 대부분의 객체는 보통 금방 GC의 대상이 된다.
  2. 오래된 객체에서 젊은 객체로의 참조는 매우 적게 발생한다.

물론 이 가설은 어느 정도 증명이 된 것들이긴 하다.

Empirical analysis of applications (프로그램의 경험적 분석)에 따르면 대부분의 객체들은 수명은 짧다.

![할당된 메모리의 세대별 생존 상황](./allocated memory life cycle.png)

따라서 위 처럼 실제로 확인해본 결과 실제로 대부분의 객체는 초기에 매우 많이 할당되었다가, 해제되는 모습을 볼 수 있다.

Generational GC는 이 가설을 기반으로 각 메모리를 세대로 가정하여 공간을 구분해 메모리를 관리하는 방식이다. 굳이 모든 객체를 명확히 관리할 필요는 없기 때문이다.

크게 0세대, 1세대, 2세대로 나누며 오래 생존한 메모리일수록 3세대에 가까워진다. (글마다 각각의 세대에 대해서 nursery, intermediate 등 부르는 명칭이 있지만, 이 글에서는 생략하겠다.)

보통은 세대별로 다른 GC 알고리즘을 섞어 사용하는 경우가 많다.

동작 방식은 아래와 같다.

  1. 객체가 처음 생성되면 0세대 메모리 공간에 할당한다.
  2. 힙 메모리가 꽉 찼을시, GC를 호출하고 살아남은 객체들을 1세대로 보낸다.
  3. 1세대에서는 age threshold를 이용해 특정 조건 이후 GC를 호출하고 이때 살아남은 객체를 2세대로 보낸다.
    • 이때 1 -> 2세대로 옮기는 객체들은 WriteWall이라 부르는 곳에 메모리 주소를 기록한다.
    • 젊은 객체(0, 1세대)가 오래된 객체(2세대)를 참조할 시 이 WriteWall에 적힌 메모리 주소를 보고 참조한다.

위 과정을 그림으로 표현하면 아래와 같다. generational

6. 그럼 보통 어떤 GC를 사용하나요?

이건 언어, 상황마다 다르다. 예를 들어 python의 경우에는 Reference Counting과 Generational GC를 혼합하여 사용한다.

C#과 Java의 경우에는 Generational GC를 사용한다. 0세대는 Mark And Sweep GC를 사용하고, 1세대 -> 2세대로의 이동 시에는 Scavenge GC를 사용한다.

여기에 나온 내용 말고도, Parallel GC, CMS GC, G1 GC 등 더 다양한 알고리즘이 존재하니 궁금한 사람들은 더 찾아보길 바란다.

Copyright ⓒ 2022 김춘식 All rights reserved.

Powered By Gatsby