Skip to main content

메모리에 대해

· 30 min read
brown
FE developer

본인 초딩 시절 카트라이더을 하는데 너무 렉이 심해서, 어떻게 해야하지 알아보다 램을 사서 끼웠던 기억이 있다.

요즘이야 램이 기본적으로 기가 단위지만, 그 당시(2000년대 초중반)는 하드 10 ~ 25기가에 램은 256MB ~ 512MB가 일반적이었다.

그 후에도, 학창시절 당시 신작 게임들(아이온,마영전 같은)이 출시되면 꼭 플레이 했었는데, 렉이 걸리거나 실행이 안되면, 한번 해보겠다고 그래픽카드도 바꿔보고, 드라이버도 업데이트하고, 포맷도 하는 등 다양한 시도를 했었던 기억이 난다.

그때 메모리가 부족해서 게임이 꺼지기도 가상메모리 사이즈를 올리라는 경고도 봤던 기억이 나는데, 글의 주제와 연관성이 있어 적어봤다 ㅎㅎ.

메모리란?

컴퓨터의 핵심 부품은 4가지로 CPU, 주 기억장치, 보조 기억장치, 입출력장치이다.

하드웨어에서의 메모리란 주기억장치(RAM)을 의미한다.

  • CPU: 중앙처리장치(Central Processing Unit)로, 연산 및 제어 기능을 담당
  • 주기억장치:
    • RAM(Random Access Memory): 메모리는 주로 RAM을 지칭하며, 읽기와 쓰기가 모두 가능하며, 빠른 속도로 데이터에 접근가능한 휘발성 메모리
    • ROM(Read Only Memory):
      • 컴퓨터 부팅에 필요한 펌웨어, BIOS(Basic Input/Output System) 등을 저장하는 데 사용하는 읽기 전용 비휘발성 메모리
      • 메인보드내에 위치
  • 보조기억장치: 하드디스크, SSD(Solid State Drive), USB 메모리 등이 포함되며, 대용량의 데이터를 영구적으로 저장할 수 있는 비휘발성 메모리
  • 입출력장치: 키보드, 마우스, 스피커, 프린터...

RAM(Random Access Memory)의 의미는 어떤 메모리 주소에 접근(random access)하더라도 동일한 시간의 읽고 쓰기가 보장되는 것을 의미한다(하드디스크와 같은 자기 디스크의 경우 데이터가 저장된 주소에 따라 접근하는 시간이 다름).

램에도 DRAM, SRAM, HBM등 종류가 다양한데(잘모름), 기본적인 동작원리는 내부의 트랜지스터로 전기 흐름을 제어 하여 데이터를 저장(전류가 흐를 때를 1, 전류가 흐르지 않을 때를 0)하는 부분일 것이다.

휘발성인 이유는 전기적 신호로 데이터를 저장하기에 전원 종료시(전기 차단) 전부 0이 되기 때문이고, 보조기억장치들은 데이터를 물리적으로 저장하기에 전기가 차단되어도 휘발되지 않는다.

가상 메모리(Virtual Memory)

가상 메모리 기술이 도입되기 이전에는 프로그램을 실행하기 위해서는 프로그램 전체가 물리적인 메모리에 한 번에 로드되어야 했다.

가상 메모리 기술은 하드디스크의 일부를 가상 메모리로 사용하여 프로그램 실행에 필요한 부분만 물리적인 메모리에 로드하고, 나머지는 가상 메모리에서 관리하는 기술로 물리적인 메모리보다 큰 용량의 메모리를 사용하는 것처럼 프로그램을 실행할 수 있게 한다.

프로세스에서 사용되는 메모리 주소는 가상 메모리 주소이며, 메모리 관리 장치(Memory Management Unit)가 이를 실제 물리 메모리 주소로 변환한다.

핵심 개념은 5가지다.

  • 가상 주소 공간(Virtual Address Space):
    • 각 프로세스는 자신만의 독립적인 가상 주소 공간을 가짐
    • 가상 주소 공간은 실제 물리 메모리보다 클 수 있음
  • 페이징(Paging):
    • 가상 주소 공간과 물리 메모리를 일정한 크기의 블록(페이지)으로 나누어 관리하는 기술
      • 가상메모리의 주소 데이터들을 일정한 크기의 블록으로 쪼게 놓은걸 페이지(Page)
      • 물리메모리의 주소 데이터들을 일정한 크기의 블록으로 쪼게 놓은걸 프레임(Frame)
    • 프로세스의 가상 주소 공간은 페이지 단위로 물리 메모리에 매핑
  • 페이지 테이블(Page Table):
    • 가상 주소와 물리 주소 간의 매핑 정보를 저장하는 자료구조
    • 각 프로세스는 자신의 페이지 테이블을 가짐
    • 페이지 테이블을 통해 가상 주소를 물리 주소로 변환
  • 요구 페이징(Demand Paging):
    • 프로세스 실행에 필요한 페이지를 실제로 사용할 때 메모리에 로드하는 기법
    • 초기에는 프로세스의 모든 페이지를 메모리에 로드하지 않고, 필요한 페이지만 로드
  • 페이지 폴트(Page Fault):
    • 프로세스가 접근하려는 페이지가 물리 메모리에 없을 때 발생하는 인터럽트
    • 페이지 폴트가 발생하면 운영체제는 해당 페이지를 하드디스크에서 물리 메모리로 로드하고, 프로세스의 실행을 재개
    • 새로운 페이지를 로드 하는 과정에서 기존에 로드 된 페이지와 교체 될 수 있음

프로세스 메모리 구조

메모리 로드과정에 문제가 없다면 프로그램이 실행되어 프로세스가 생성된다.

클래스와 인스턴스처럼 프로그램을 실행 시키면 해당 프로그램의 인스턴스인 프로세스가 생성되어 실행되는 것이다.

운영체제(OS)는 프로세스에게 동작하기 위한 메모리 공간을 할당해 주는데, 영역은 Code, Data, Stack, Heap으로 나뉜다.

영역설명
Code(Text)- 프로그램의 실행 가능한 명령어(기계어)들이 저장되는 영역
Data- 전역 변수, 정적 변수, 배열 등이 저장되는 영역
- 프로그램의 시작과 함께 할당되며, 프로그램이 종료되면 해제
Heap- 동적 메모리 할당을 위해 사용되는 영역
- 유일하게 런타임 시 크기가 결정되는 영역
- 힙 영역은 메모리의 낮은 주소에서 높은 주소의 방향으로 할당
Empty space- Heap을 위한 여분 공간
Stack- 지역 변수, 함수 호출 시 전달되는 인자, 함수의 반환 주소 등이 저장되는 영역
- LIFO(Last-In-First-Out) 구조
- 스택 영역은 메모리의 높은 주소에서 낮은 주소의 방향으로 할당
+-----------------------+
| Stack |
| | |
| v |
+-----------------------+
| |
| Free |
| |
| ^ |
| | |
+-----------------------+
| Heap |
+-----------------------+
| Data |
+-----------------------+
| Text |
+-----------------------+
  • Code부터 낮은 메모리 주소로 시작해서 Stack까지 올라가며 스택은 높은 메모리 주소부터 사용한다.

과거에는, 스택 영역에 초과되는 크기의 데이터를 저장한 후 커널 영역에 침범해 데이터를 조작하여 관리자 권한같은 해킹이 잦았는데 이를 방지하기 위해 스택 영역은 큰 주소 값을 시작으로 점차 아래로 주소 값이 할당되는 것입니다.

  • 스택은 연속적인 메모리 위치에 저장될 필요가 있음(성능, 효율성, 단순성)
    • 스택 메모리는 필요에 따라 랜덤하게 할당할 수 없고, 컴파일 시점에 크기가 결정되어야함
  • 컴파일 시점에 데이터의 크기를 정확히 알 수 있는 변수들만 스택에 저장될 수 있음
  • 각 스레드는 독립적인 스택을 가지므로, 스택 크기가 클수록 각 스레드가 사용할 수 있는 메모리 영역이 커지지만, 생성할 수 있는 최대 스레드 수는 감소
    • 프로세스가 2GB 메모리 공간를 가진다면, 스택의 크기가 2MB일 때, 최대 1,024개의 스레드를 생성가능
    • 만약 스택의 크기가 4MB로 증가한다면, 최대 스레드 수는 512개로 감소
  • 컴파일 시점에서 데이터의 크기를 정확히 알 수 없는 경우에는 일반적으로 힙 영역에 저장

js를 연관지어 보자면, 원시 타입의 변수들은 데이터가 call stack 영역에 저장되고, 반면에 객체 타입의 변수들은 실제 데이터는 Memory Heap 영역에 저장 되고, 데이터에 대한 참조 값(메모리 주소)이 stack 영역에 저장되는데 이는 stack과 heap의 동작과 동일한 구조를 가지는 것으로 보인다.

js파일을 node로 실행시킨다면, node 프로세스를 실행하는 것이고, 위의 메모리 구조를 가진 프로세스가 동작할 것이다.

그리고 Node.js 프로세스 내부에서 자바스크립트 실행 환경을 제공하기에 V8 엔진의 call stackmemory heap은 노드 프로세스의 heap 영역에 속할 것이다.

뭔가 프로세스의 안의 프로세스 같은 느낌이다(js 실행환경 -> js).

예전부터 다른 원시값 변수과 다르게 문자열 타입은 데이터 사이즈가 가변적(UTF-16 한 글자당 2 bytes)인데도, call stack에 저장 된다는 것이 의아했는데, call stack, memory heap 둘다 nodejs 프로세스의 heap 영역에 있을테니, 데이터 사이즈가 상관 없을 수도 있을 것 같다.

하지만 node 역시 call stack 사이즈를 제한하니, 문자열 길이가 일정 수준을 넘으면 내부적으로 스트링 객체를 활용해 heap에 저장하지 않을까 추측해본다.

// 추측
+-------------------------------+
node process Heap

+-----------------------+
| Call Stack |
| | |
| v |
+-----------------------+
| Free |
| ^ |
| | |
+-----------------------+
| Memory Heap |
+-----------------------+
|Global Execution Context|
+-----------------------+
| js code(Bytecode) |
+-----------------------+

+-------------------------------+

multi thread programming

일반적으로 프로세스는 독립적인 메모리 공간을 가지므로 다른 프로세스의 데이터에 직접 접근할 수 없다.

그러나 경우에 따라 프로세스 간에 데이터를 공유하거나 통신해야 할 때는 IPC(Inter-Process Communication)를 사용한다.

반면 멀티 스레드(프로세스 내에서 실행되는 독립적인 실행 흐름의 단위)의 경우 같은 프로세스 내에서 스택을 제외한 영역을 공유하기 때문에 오버헤드 없이 데이터 공유가 가능하다.

스레드들은 각각 자신만의 stack을 가지는데, stack은 함수 호출 시 전달되는 인자, 되돌아갈 주소값, 함수 내에서 선언하는 변수 등을 저장하는 메모리 공간이기 때문에, 독립적인 스택을 가졌다는 것은 독립적인 함수 호출이 가능하다라는 의미이다.

즉, stack을 가짐으로써 스레드는 독립적인 실행 흐름을 가질 수 있게 되는 것이다.

garbage collection

스택 메모리에 저장되는 변수(데이터)들은 함수 호출이 끝나고 스택 프레임이 제거될 때 함께 메모리에서 자동으로 해제되지만,

Heap 영역에 동적으로 할당한 변수들은 스택 프레임이 제거되어도 사라지지 않고 계속 남아있게 된다.

이렇게 더 이상 사용되지 않지만 해제되지 않고 남아있는 데이터들은 메모리 누수의 원인이 되는데,

이런 데이터들의 메모리 사용을 해제하는 작업을 garbage collection이라 한다.

메모리 관리 차원에서 언어는 메모리 언매니지드 언어(Memory Unmanaged Language), 메모리 매니지드 언어(Memory Managed Language)로 나뉜다.

  • Memory Unmanaged Language:
    • C, C++
    • 개발자가 직접 메모리 할당과 해제를 관리
  • Memory Managed Language:
    • Java,Python,JavaScript,Rust...
    • 개발자가 메모리 영역을 관리하지 않음

Memory Managed Language는 개발자가 메모리 영역을 직접 관리 하지 않기에 일반적으로 garbage collection 기능이 내장되어 있다.

garbage collector가 더 이상 사용되지 않는 메모리를 추적하고 해제하여 메모리 누수를 방지하는데 언어마다 garbage collection 로직은 다를 수 있다.

garbage collection in js

JS는 일반적으로 전역으로 선언 된 컬렉션에 값을 계속 추가하지 않는 이상 거의 메모리 릭이 일어나기 힘들다.

garbage collection 동작 방식은 JavaScript 엔진마다 알고리즘과 최적화 기술이 다를 수 있겠지만,

일반적으로 Mark-and-Sweep,Generational Collection,Incremental Collection 방식을 사용한다고 알려져 있다.

Mark-and-Sweep

  1. 가비지 컬렉터는 루트(root) 정보를 수집하고 이를 ‘mark(기억)’
    • root는 탐색 시작점이라는데 명확하진 않지만 상황에 따라 글로벌 객체, 실행컨텍스트 객체등 인 것 같다.
  2. 루트가 참조하고 있는 모든 객체를 방문하고 이것들을 ‘mark’
  3. mark 된 모든 객체에 방문하고 그 객체들이 참조하는 객체도 mark (재방문X)
  4. 루트에서 도달 가능한 모든 객체를 방문할 때까지 위 과정을 반복
  5. mark 되지 않은 모든 객체를 메모리에서 삭제

Generational Collection

New space에서의 마이너 GC와 Old space에서의 메이저 GC로 분리

  1. 객체를 '새로운 객체’와 '오래된 객체’로 나눕니다.
    • 대부분의 경우 새로운 객체가 오래된 객체보다 쓸모없어질 가능성이 높다(The Generational Hypothesis)
    • 오래된 객체가 쓸모없어질 가능성이 낮은데 GC가 모든 객체를 매번 검사하는 것은 비효율적
  2. 새로운 객체들에 대해 마이너 GC가 공격적으로 메모리에서 제거(전체 탐색X)
  3. 일정 횟수 이상 살아남은 객체는 '오래된 객체’로 분류 후 메이저 GC로 관리

Incremental Collection

GC의 가비지 컬렉션이 수행될 때는 프로세스가 멈추게 된다(stop-the-world).

만약 방문해야 할 객체가 많다면 가비지 컬렉션에 많은 리소스가 사용되어 프로세스가 멈추는 시간이 길어지는데, 이는 좋지 못하다.

브라우저 환경에서는 시간이 길어질수록 페이지가 느려지거나 렌더링이 지연되어 사용자들에게 불편함을 줄 것이다.

이런 현상을 개선하기 위해 Incremental Collection(점진적 수집)은 가비지 컬렉션을 여러 부분으로 분리한 다음, 각 부분을 별도로 수행하는 것이다.

작업을 분리하고, 변경 사항을 추적하는 데 추가 작업이 필요하긴 하지만, 긴 지연을 짧은 지연 여러 개로 분산시킬 수 있다는 장점이 있다.

garbage collection in rust

Memory Managed Language는 메모리 관리를 위해 일반적으로 위에서 언급한 garbage collector가 garbage collection을 수행한다.

하지만 이부분은 성능에 대한 약점으로 작용하는데, rust는 Memory Managed Language임에도 GC가 없다.

Rust는 메모리 관리를 위해 소유권(ownership) 개념을 언어 자체에 내장하여 강제하는데,

이는 C, C++에서 개발자들이 메모리 관리를 위해 수동으로 적용하던 소유권 패턴을 가져온 것이다.

소유권 시스템은 값의 소유자는 오직 하나로 다른 변수를 선언하고 대입하면 기존 변수에서 사용불가(복사가 아닌 이동), 함수에 파라미터로 넘기는 경우에도, 넘긴 후에는 해당 스코프에서 사용불가등의 제약이 있다.

그리고 소유자가 스코프 밖으로 벗어나면 값은 자동으로 해제된다.

이를 보완하는 내용이 더 있는데 복습을 안하니 가물가물하다 ㅋㅋ...

메모리 관련 버그

out of memory

메모리 부족(out of memory)은 프로세스가 필요로 하는 메모리 양이 시스템의 사용 가능한 메모리를 초과할 때 발생하는 상황으로, 더 이상 메모리를 할당받을 수 없게 되어 정상적인 실행이 불가능해진다.

  • 과도한 메모리를 사용(시스템의 물리적 메모리가 부족한 경우)
  • 메모리 누수(memory leak)가 발생하여 점진적으로 메모리가 고갈되는 경우

memory leak

memory leak(메모리 누수)는 프로그램이 동적으로 할당한 메모리를 사용한 후 해제하지 않아 발생하는 문제로

이로 인해 프로그램이 실행되는 동안 사용 가능한 메모리가 점진적으로 줄어들어 성능 저하, 불안정성, 또는 시스템 충돌을 일으킬 수 있다.

buffer overflow

프로그램 내부의 메모리를 다루는 부분에서 오류가 발생하여 잘못된 동작을 하는 버그로 데이터를 저장할 때 정해진 메모리의 영역을 초과하여 저장하는 것을 의미한다.

벗어난 데이터는 인접 메모리를 덮어 쓰게 되며 이때 다른 데이터가 포함되어 있을 수도 있는데, 이로인해 메모리 접근 오류, 잘못된 결과, 프로그램 종료, 또는 시스템 보안 누설이 발생할 수 있다.

stack overflow

스택 오버플로우는 스택 메모리 영역에서 발생하는 버퍼 오버플로우의 한 종류로, 함수 호출 시 할당되는 스택 프레임의 중첩이 스택의 허용 범위(Maximum stacksize)를 초과할 때 발생한다.

아래 코드로 브라우저의 Maximum stacksize를 테스트 해보자

var i = 0;
function inc() {
i++;
inc();
}

try {
inc();
} catch (e) {
// The StackOverflow sandbox adds one frame that is not being counted by this code
// Incrementing once manually
i++;
// RangeError: Maximum call stack size exceeded
console.log("Maximum stack size is", i, "in your current browser");
}

stack buffer overflow

스택 버퍼 오버플로우는 함수 호출 시 할당되는 지역 변수들의 크기가 스택 메모리를 넘어서는 경우라 한다.

뭔가 거의 볼 수 없는 경우로 보인다.

  • 스택 메모리 사이즈를 변경하거나, 변수들의 사이즈를 줄여야 함

heap overflow

힙 오버플로우는 힙 메모리 영역에서 발생하는 버퍼 오버플로우

Heap Overflow 취약점은 공격자가 악의적으로 조작된 입력을 프로그램에 전달하여 프로그램의 흐름을 바꾸거나, 임의의 코드를 실행하는 등의 악의적인 행동을 할 수 있다.

  • 적절한 크기의 메모리 할당
  • 경계 검사: 입력 데이터의 길이를 항상 검사하고, 할당된 메모리 크기를 초과하지 않도록 주의

그외

메모리를 직접 다루는 언어를 사용해 보지 않아 겪어보진 못했지만, 이러한 유형의 버그들이 있다고 한다.

  • 이중 해제(Double Free): 이미 해제된 메모리를 다시 해제하려고 시도할 때 발생
  • 초기화되지 않은 메모리 사용(Use of Uninitialized Memory):할당된 메모리를 초기화하지 않고 사용하는 경우 발생
  • 널 포인터 역참조(Null Pointer Dereference): 널 포인터(유효한 메모리 주소X)를 역참조하여 접근하려고 할 때 발생

outro

CS 학습을 나름 해왔다고 생각했는데, 제대로 이해 하지 못했던 부분도 있었고, 이제 알다니 싶은 부분들도 있었다. 반성한다.

처음 글을 작성하는 시점에서의 목표보다 주제가 계속 확장 된 부분이 있는데, 덕분에 많이 배웠다.

참조