메모리 아키텍처

REIN·2025년 12월 22일

게임 개발 초급 CS

목록 보기
2/19

들어가며

"메모리"라는 단어는 한 글에서 여러 의미로 쓰인다. 어떤 맥락에서는 DRAM 칩, 다른 맥락에서는 운영체제의 가상 주소 공간, 또 다른 맥락에서는 CPU 안의 SRAM 캐시, 심지어 할당자가 들고 있는 thread-local 캐시까지 같은 단어로 부른다. "캐시"도 마찬가지다. CPU 캐시, TLS 캐시, allocator의 thread cache가 모두 "캐시"라는 같은 이름을 쓴다.

이 글은 메모리 계층 전체 구조를 정리하고, 각 층이 어떤 단위로 데이터를 다루는지, 한 층의 미스가 다음 층에 어떤 비용으로 전파되는지 본다. CPU 캐시의 set-associative 구조와 MESI 일관성 프로토콜, false sharing의 정확한 메커니즘, TLB와 페이지 테이블의 관계, NUMA 토폴로지, 그리고 멀티코어와 NUMA에 맞춰 진화한 할당자의 역사까지 한 흐름으로 다룬다.


목차

  1. 메모리 계층 전체 구조
  2. "캐시"의 세 가지 의미
  3. CPU 캐시의 내부: 세트와 way
  4. 캐시 일관성과 MESI 프로토콜
  5. False Sharing: 같은 라인을 두고 벌어지는 경합
  6. Virtual Memory: TLB와 페이지 테이블
  7. NUMA: 코어가 늘어난 이후의 메모리
  8. 할당자 진화의 역사
  9. 모든 것의 연결: malloc(64)의 전체 경로

1. 메모리 계층 전체 구조

하드웨어에서 애플리케이션까지

메모리 시스템은 다섯 개의 층으로 정리할 수 있다.

각 층은 같은 종류의 "접근 시간"으로 비교할 수 없다. malloc은 정책과 메타데이터를 다루는 소프트웨어 연산이고, TLB는 주소 변환 캐시이며, L1과 DRAM은 실제 load/store 경로에 있다. 따라서 고정 나노초 대신 관리 단위와 느려지는 조건을 구분해야 한다.

구성 요소주된 관리 단위비용이 커지는 조건
애플리케이션new, malloc, std::vector객체생성자, 재할당, 복사·이동
사용자 공간 할당자tcache, arena, slab/span크기 클래스~페이지 묶음캐시 미스, 락 경합, 중앙 풀 보충
OS 가상 메모리VMA, 페이지 테이블, page fault페이지매핑 변경, zero-fill, 파일 I/O, TLB shootdown
주소 변환TLB, page walk cache페이지 변환TLB 미스, 다단계 page walk
데이터 계층L1/L2/LLC, DRAM캐시 라인캐시 미스, 일관성 통신, NUMA 원격 접근

구체적인 지연은 CPU 세대와 주파수, 캐시 상태, 페이지 테이블의 캐시 적중 여부, OS와 할당자 버전에 따라 달라진다. 안정적인 사실은 절대 시간이 아니라 fast path에서 벗어날수록 더 많은 공유 상태와 하위 계층을 건드린다는 비용 구조다.

int* p = new int;의 전체 경로

단계계층동작
1애플리케이션new int 호출
2할당자맞는 크기 클래스의 기존 빈 블록 탐색
3할당자(로컬 캐시 미스 시) 중앙 풀이나 arena에서 묶음 보충
4할당자(보유 영역 부족 시) OS에 새 가상 메모리 영역 요청
5OS매핑을 만들거나 기존 매핑을 확장
6애플리케이션*p = 42; (첫 접근)
7CPU/MMUTLB와 캐시를 통해 주소 변환 및 데이터 접근
8OS(페이지가 아직 매핑되지 않은 경우) minor page fault 처리와 zero-fill
9CPU캐시 라인을 적재하고 쓰기 수행

중요한 점은 이 표가 매 할당마다 2번부터 9번까지 실행되는 직선 경로가 아니라는 것이다. 보통의 작은 할당은 이미 확보하고 접근했던 페이지의 빈 블록을 반환하므로 OS 요청도 page fault도 없다. 할당자가 새 익명 매핑을 받아 왔고 해당 페이지를 처음 건드릴 때만 demand paging 경로가 나타난다. 파일 매핑이나 메모리 압박이 관련되면 major fault와 I/O라는 전혀 다른 비용도 생길 수 있다.

// 성공하면 큰 가상 주소 범위를 얻는다. 물리 페이지의 실제 배치는 OS 정책에 따른다.
void* p = mmap(NULL, 1ULL << 30, PROT_READ|PROT_WRITE,
               MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);

// 일반적인 demand-zero 매핑에서는 이 페이지의 첫 쓰기가 minor fault를 일으킨다.
((char*)p)[0] = 42;

익명 매핑은 대개 필요한 페이지를 처음 접근할 때 물리 프레임과 연결한다. 따라서 큰 가상 주소 예약의 크기와 즉시 resident한 물리 메모리의 크기는 같지 않을 수 있다. 다만 OS의 overcommit, eager population, huge page, page locking 정책에 따라 실제 동작은 달라진다.

2. "캐시"의 세 가지 의미

같은 단어가 서로 다른 세 층에서 쓰인다.

CPU 캐시 (하드웨어)

위치캐시일반적 역할상대 지연
코어별L1가장 작은 작업 집합과 load/store 공급가장 낮음
코어별 또는 코어 묶음L2L1 miss 흡수L1보다 높음
여러 코어 공유LLC(L3)코어 간 공유와 DRAM 트래픽 완화L2보다 높음
소켓/노드DRAM캐시가 놓친 데이터 공급가장 높음

하드웨어가 자동으로 관리한다. 프로그래머는 일반 load/store로 캐시의 특정 레벨을 직접 선택하지 않으며, 캐시 친화적인 메모리 접근 패턴(spatial/temporal locality)으로 미스를 줄인다. 용량, associativity, 공유 범위, 지연은 CPU마다 다르고 캐시 라인도 아키텍처별로 확인해야 한다. 현대 x86-64의 데이터 라인은 보통 64바이트다.

TLS (Thread Local Storage)

thread_local int counter = 0;   // 각 스레드가 자신만의 사본을 가진다

void increment() {
    counter++;                  // 스레드 간 충돌 없음
}
Thread 0Thread 1Thread 2
TLS 영역TLS 영역TLS 영역
counter: 5counter: 3counter: 8
errno: 0errno: 2errno: 0

x86-64에서 TLS는 FS/GS 세그먼트 레지스터를 통해 접근한다. OS와 컴파일러/런타임이 공동으로 관리한다.

할당자 캐시 (소프트웨어)

jemalloc의 Thread Cache, tcmalloc의 ThreadCache는 TLS에 저장된 객체 풀이다.

Size Class상태
8B2개 사용, 3개 여유
16B1개 사용
32B3개 사용
64B모두 여유

각 size class별로 작은 free list를 스레드 로컬에 유지해, 락 없이 빠르게 할당/해제한다.

세 가지 비교

구분CPU 캐시TLS할당자 캐시
관리 주체하드웨어OS/런타임할당자 라이브러리
목적DRAM 지연 숨기기스레드별 데이터락 없는 할당
단위64B 캐시 라인변수 단위객체 단위
제어힌트만 가능가능가능
위치CPU 칩 내부각 스레드 스택 근처

세 캐시는 서로 다른 층에서 다른 목적으로 동작한다. 같은 이름을 쓰더라도 동일시하면 안 된다.

3. CPU 캐시의 내부: 세트와 way

캐시는 단순한 하나의 큰 배열이 아니다. 메모리 주소 공간이 캐시보다 훨씬 크기 때문에, 어떤 메모리 주소가 어디에 들어갈지 정하는 매핑 규칙이 필요하다.

Direct-mapped, fully associative, set-associative

구조매핑 규칙단점
Direct-mapped한 주소가 한 슬롯에만 들어간다충돌이 잦으면 thrashing
Fully associative어디든 들어갈 수 있다검색 비용이 큼
Set-associativeNN개 슬롯(way)으로 묶인 set 안 어디든절충

실제 CPU의 L1/L2 캐시는 8-way 또는 16-way set-associative다. 32KB L1 데이터 캐시에 64B 라인, 8-way라면 set 개수는 다음과 같다.

sets=32,76864×8=64\text{sets} = \frac{32{,}768}{64 \times 8} = 64

주소 분해

64비트 가상 주소는 캐시 접근 시 세 부분으로 나뉜다.

  • Offset: 한 캐시 라인 안의 바이트 위치. 64B라면 6비트.
  • Index: 어느 set에 들어갈지. 64 set이면 6비트.
  • Tag: 같은 set 안의 8개 way 중 어느 것이 이 주소에 해당하는지 식별.

CPU가 주소를 보면 index로 set을 고르고, 그 set의 8개 way의 tag를 병렬로 비교한다. 일치하는 way가 있으면 히트, 없으면 미스다.

Way conflict와 cache thrashing

배열을 큰 stride로 접근하면 같은 index가 반복적으로 부딪친다.

// 32KB L1, 8-way, 64B 라인. set은 64개.
// stride 4096 = 64 set × 64B → 매 접근이 같은 set으로 떨어짐
for (int i = 0; i < N; i++) {
    sum += arr[i * 4096 / sizeof(int)];
}

이런 접근 패턴은 같은 set 안의 8개 way를 모두 채우고도 9번째 주소가 들어오면 way 하나를 evict한다. LRU 정책에 의해 막 들어왔던 라인이 다시 쫓겨나는 thrashing이 발생한다. 이 패턴이 set-associative 캐시 설계의 약점이다.

해결책은 데이터 레이아웃을 다시 설계해 stride를 캐시 set 개수의 배수에서 멀어지게 하는 것이다.

4. 캐시 일관성과 MESI 프로토콜

각 코어가 자신의 L1/L2 캐시를 가지므로, 같은 메모리 주소가 여러 코어의 캐시에 동시에 존재할 수 있다. 한 코어가 그 주소를 수정하면 다른 코어의 사본은 stale이 된다. 이를 막는 하드웨어 프로토콜이 캐시 일관성(cache coherence)이다.

MESI 4상태

각 캐시 라인은 다음 네 상태 중 하나다.

상태의미
Modified (M)이 캐시에서 수정됨, 다른 캐시는 가지지 않음, 메모리와 다름
Exclusive (E)이 캐시만 가짐, 메모리와 같음
Shared (S)여러 캐시가 같은 값을 가짐, 메모리와 같음
Invalid (I)무효 (사용 불가)

쓰기의 비용

코어 0이 Shared 상태의 라인에 쓰면, 다른 모든 코어의 같은 라인을 Invalidate해야 한다. 이 invalidate 메시지는 인터커넥트(예: Intel의 Ring bus 또는 mesh)를 타고 모든 코어에 전파된다. 그동안 코어 0의 쓰기는 stall한다.

캐시 상태필요한 동작상대적 비용
M (Modified)현재 코어가 가진 라인에 쓰기가장 짧은 경로
E (Exclusive)로컬 상태를 M으로 전이한 뒤 쓰기외부 데이터 전송 없이 처리 가능
S (Shared)다른 사본의 무효화 권한 획득코어 간 일관성 통신 필요
I (Invalid)라인 데이터와 쓰기 권한 획득캐시·인터커넥트 상태에 따라 가장 길어질 수 있음

이 비용 차이가 false sharing과 같은 멀티스레드 성능 문제의 근원이다.

5. False Sharing: 같은 라인을 두고 벌어지는 경합

False sharing은 두 스레드가 같은 캐시 라인의 다른 변수를 수정할 때 발생한다. 변수는 다르지만 캐시 일관성은 라인 단위로 동작하기 때문에, 한 변수의 수정이 다른 변수의 캐시를 무효화한다.

시나리오

struct Counter {
    long count1;   // CPU 0의 스레드가 사용
    long count2;   // CPU 1의 스레드가 사용
};                 // 두 변수가 같은 64바이트 라인에 있음

두 코어가 번갈아 쓰면 라인의 쓰기 소유권이 인터커넥트를 통해 반복해서 이동한다. 실제 지연은 코어 배치, 소켓 경계, 저장 버퍼, 일관성 프로토콜 구현에 따라 크게 달라지지만, 단일 코어의 로컬 갱신보다 처리량이 급격히 떨어지는 원인은 이 소유권 이동이다.

측정

측정할 때는 같은 총 연산 수를 유지한 채 카운터를 한 라인에 모은 경우와 각 카운터를 std::hardware_destructive_interference_size 이상 떨어뜨린 경우를 비교한다. 수행 시간뿐 아니라 perf c2c, HITM 이벤트, cache-to-cache 전송량을 함께 보면 계산량이 아니라 일관성 트래픽이 병목인지 판별할 수 있다. 패딩의 배율은 시스템마다 다르므로 결과값 자체보다 코어 수에 따른 확장 곡선을 기록해야 한다.

해결: 패딩과 alignment

struct PaddedCounter {
    long count1;
    char pad1[64 - sizeof(long)];
    long count2;
    char pad2[64 - sizeof(long)];
};

C++17은 이를 표준화했다.

struct alignas(std::hardware_destructive_interference_size) Counter {
    long count;
};

std::hardware_destructive_interference_size는 false sharing을 피하기 위한 권장 정렬 크기다. 대부분의 x86-64 시스템에서 64이지만, ARM이나 일부 서버 CPU에서 128일 수 있다(prefetcher가 인접 라인을 함께 가져오기 때문).

allocator 차원에서도 영향이 있다. thread-local 객체를 풀에서 꺼내올 때 같은 캐시 라인에 두 스레드의 객체가 들어가지 않도록 size class를 캐시 라인의 배수로 잡는 설계가 흔하다.

6. Virtual Memory: TLB와 페이지 테이블

가상 주소 공간이 필요한 이유

프로그램 A: "주소 0x1000에 데이터를 저장한다"
프로그램 B: "나도 0x1000에 저장한다"
결과: 충돌

가상 메모리가 없던 시절에는 모든 프로그램이 물리 주소를 직접 다뤘다. 다음과 같은 문제가 있었다.

  • 두 프로그램이 같은 주소를 쓰면 충돌
  • 프로그램 크기가 물리 메모리보다 크면 실행 불가
  • 메모리 보호가 없어 한 프로그램의 버그가 시스템 전체를 망가뜨림

가상 메모리는 각 프로세스에 독립된 가상 주소 공간을 주고, 페이지 테이블이 가상→물리 매핑을 담당한다.

가상 주소프로세스 A프로세스 B물리 주소
0x1000A의 데이터-0x5000
0x1000-B의 데이터0x8000

같은 가상 주소가 다른 물리 주소로 매핑되어 충돌이 사라진다.

Page Table Walk

x86-64는 4단계 페이지 테이블을 사용한다(최근 5단계로 확장).

단계명칭비트
1PML447:39
2PDPT38:30
3PD29:21
4PT20:12
-offset11:0

TLB miss에서 필요한 page-table walk는 최악에는 여러 레벨의 엔트리를 차례로 읽는다. 그러나 각 엔트리는 page-walk cache나 일반 캐시에 있을 수 있고, 하드웨어는 가능한 접근을 겹친다. 따라서 “모든 load마다 DRAM 네 번”으로 환산할 수는 없다. TLB는 이 주소 변환 결과를 캐시해 반복 walk를 피한다.

TLB (Translation Lookaside Buffer)

TLB는 최근 변환된 가상→물리 매핑을 캐시하는 작은 하드웨어 캐시다.

구성역할비용을 좌우하는 조건
TLB최근 가상→물리 매핑 캐시계층, 페이지 크기, 동시 lookup 구조
Page-walk cache상위 page-table 엔트리 재사용마이크로아키텍처와 working set
Page table전체 매핑 저장엔트리의 cache hit/miss, walk 레벨, NUMA

TLB의 엔트리 수와 계층, 페이지 크기별 분할은 CPU마다 다르다. TLB 미스가 나면 하드웨어 또는 소프트웨어 page table walk가 일어나고, 성공한 변환이 TLB에 다시 채워진다.

HugePage

4KB 페이지로 1GB 메모리를 매핑하려면 262,144개의 페이지 테이블 엔트리가 필요하다. TLB 미스가 잦아진다.

해결책은 더 큰 페이지를 쓰는 것이다.

페이지 크기엔트리당 커버
4KB4KB
2MB (Huge Page)2MB
1GB (Gigantic Page)1GB

같은 1GB 메모리를 2MB 페이지로 매핑하면 512개의 엔트리만 필요하다. tcmalloc의 HugePageAwareAllocator가 이를 활용한다.

Demand Paging의 비용

페이지 폴트는 두 종류로 나뉜다.

종류필요한 작업지연을 지배하는 요소
Minor fault디스크 I/O 없이 페이지 테이블 매핑, demand-zero, COW 등 처리페이지 할당, zero-fill, 락 경합, TLB 무효화
Major fault파일이나 swap에서 페이지를 읽은 뒤 매핑저장장치와 page cache 상태, I/O 큐

실시간 경로에서 사용할 메모리를 미리 접근하는 이유가 이것이다. lazy allocation에 맡기면 최초 접근의 fault가 프레임 tail latency에 섞일 수 있다. 다만 무조건 모든 예약 영역을 touch하면 시작 시간과 resident set이 늘어나므로 실제 working set만 준비해야 한다.

7. NUMA: 코어가 늘어난 이후의 메모리

코어가 8개를 넘기 시작하면 모든 코어가 하나의 메모리 컨트롤러를 공유하는 SMP 구조가 한계에 부딪힌다. 모든 메모리 접근이 같은 버스를 타기 때문이다. 해결책은 메모리를 노드별로 분할하는 NUMA(Non-Uniform Memory Access)다.

NUMA 구조

접근 종류경로일반적 경향
Local현재 소켓/노드의 메모리 컨트롤러기준
Remote소켓·노드 간 인터커넥트를 거쳐 다른 메모리 컨트롤러추가 홉과 대역폭 경쟁

코어가 다른 노드의 메모리에 접근하면 UPI나 Infinity Fabric 같은 노드 간 인터커넥트를 거친다. 지연 배율과 대역폭은 토폴로지와 부하에 따라 달라지므로 numactl --hardware, lstopo, 메모리 지연 벤치마크로 대상 장비를 측정해야 한다.

NUMA-aware 할당자

jemalloc과 tcmalloc은 노드별 arena를 두고, 스레드가 위치한 노드의 arena에서 할당받게 한다.

전략설명
Arena per CPU/노드노드별로 별도 arena, 스레드는 자기 노드 arena 사용
First-touch policy페이지를 처음 touch한 스레드의 노드에 물리 페이지 할당
numactl/numa_alloc_onnode명시적으로 특정 노드에서 할당

NUMA를 무시하면 멀티스레드 워크로드가 코어를 추가할수록 오히려 느려질 수 있다. 모든 스레드가 한 노드에서 시작했다가 나중에 다른 노드로 이주하면, 이미 할당된 메모리는 원격이 된다.

8. 할당자 진화의 역사

할당자의 진화는 하드웨어의 진화에 대한 응답이다.

1990년대: 단일 코어

항목내용
CPU단일 코어
메모리수십 MB
malloc 구현단순 free list
락 경합없음
주된 문제단편화
static struct block* free_list;
static pthread_mutex_t lock;     // 단일 코어라 거의 경합 없음

void* malloc(size_t size) {
    pthread_mutex_lock(&lock);
    /* free_list에서 적절한 블록 탐색 */
    pthread_mutex_unlock(&lock);
}

2000년대: 멀티코어

항목내용
CPU2~4 코어
메모리수 GB
새 문제전역 락이 병목
Thread 0: malloc → lock → 작업 → unlock
Thread 1: malloc → lock → [대기] → 작업
Thread 2: malloc → lock → [대기] → 작업

해결책 가운데 하나가 tcmalloc(Google, 2005)의 front-end cache다. 고전 구현은 스레드별 cache를 사용하고, 현대 TCMalloc은 빌드·설정에 따라 per-CPU front end도 제공한다. 빠른 경로는 로컬 size-class cache에서 처리하고, refill·release에는 middle end와의 동기화가 필요하다.

2010년대: NUMA와 대용량 메모리

항목내용
CPU8~16 코어, NUMA
메모리64~128GB, 노드 분리
새 문제원격 접근, TLB 미스

jemalloc이 다음으로 대응했다.

  • Arena per CPU/노드: NUMA 노드별 메모리 관리
  • Extent 기반 관리: 대용량 메모리 효율적 처리
  • Decay 기반 반환: 장기 실행 서버의 메모리 누수 방지

2020년대: 수십 코어와 HugePage

항목내용
CPU64+ 코어
새 문제스레드 캐시 메모리 낭비, 스레드 마이그레이션
방식동작
Per-thread스레드 수에 비례해 캐시가 늘고, 다른 스레드가 해제한 객체가 원래 캐시로 바로 돌아오지 않을 수 있음
Per-CPU현재 CPU가 소유한 캐시를 사용해 메타데이터 경합과 캐시 라인 이동을 줄이지만, 선점·마이그레이션 중 정확한 CPU 소유권 처리가 필요

tcmalloc은 Per-CPU Cache로 전환했고, HugePageAwareAllocator로 TLB 효율을 높였다.

진화 요약

시대하드웨어 변화문제해결책
1990s단일 코어단편화Best-fit, Buddy system
2000s멀티코어락 경합Thread Cache (tcmalloc)
2010sNUMA, 대용량원격 접근, TLB 미스Arena per CPU, Extent
2020s수십 코어캐시 낭비, 스레드 이동Per-CPU Cache, HugePage

할당자 진화는 하드웨어 변화에 대한 누적된 대응이다. 각 시대마다 새로 추가된 층이 이전 층의 약점을 보완하면서 전체 구조가 더 복잡해졌다.

9. 모든 것의 연결: malloc(64)의 전체 경로

malloc(64)의 지연 분포는 하나의 평균값보다 어느 경로를 탔는지로 설명하는 편이 정확하다.

경로필요한 동작관찰해야 할 지표
로컬 fast path크기 클래스 계산, 로컬 free-list pop, 메타데이터 갱신중앙값, 명령어 수
로컬 캐시 보충arena/central cache에서 여러 객체 이동p95/p99, 락·원자 연산 경합
새 extent/span보유 가상 영역 분할 또는 OS 매핑 요청시스템 콜 수, VMA 변화
첫 접근주소 변환, 필요하면 demand-zero fault, 캐시 라인 적재minor fault, dTLB miss, cache miss
원격 해제/NUMA소유자 전달, 원격 메타데이터 또는 원격 DRAM 접근remote access, cache-to-cache 전송

일반적인 작은 객체 워크로드에서는 로컬 fast path의 비중이 높을 수 있지만 그 비율은 할당자 설정, 크기 분포, 스레드 간 소유권 이전, 메모리 압박에 따라 달라진다. 평균만 기록하면 드문 보충·fault 경로가 프레임의 긴 꼬리 지연을 만드는 사실을 놓친다. 게임 루프에서는 중앙값과 함께 p95/p99, 페이지 폴트 수, 프레임별 최대 할당 지연을 측정해야 한다.

용어 정리

용어의미위치
CPU 캐시L1/L2/L3 SRAMCPU 칩 내부 (하드웨어)
TLSThread Local Storage각 스레드의 스택 근처 (OS 관리)
Thread Cache할당자의 스레드별 객체 풀TLS에 저장된 소프트웨어 구조
Arena할당자의 메모리 풀힙 (여러 스레드 공유 가능)
Extent/SpanOS에서 받은 큰 메모리 덩어리힙 (수MB 단위)
가상 메모리프로세스별 주소 공간OS가 관리하는 추상화
물리 메모리실제 DRAM하드웨어
Page Table가상→물리 매핑 테이블OS 커널 자료구조
TLBPage Table의 캐시CPU 내부 (하드웨어)
HugePage2MB 또는 1GB 페이지OS/하드웨어 기능

결론

메모리 계층은 다섯 개의 층으로 정리되지만, 그 각 층마다 자체적인 캐시와 자체적인 단위가 있다. CPU 캐시는 64B 라인을, OS는 4KB 페이지를, 할당자는 size class를, 애플리케이션은 객체 단위를 다룬다. 한 층의 미스는 다음 층의 비용으로 전파되고, 그 비용은 한 자릿수 차수씩 뛴다. 이 사실이 메모리 친화적 코드 작성의 모든 출발점이다.

set-associative 캐시와 MESI 프로토콜은 캐시가 단순한 배열이 아니라 정교한 하드웨어 시스템이라는 점을 보여준다. False sharing, way conflict, NUMA 원격 접근은 모두 그 시스템의 빈틈에서 발생한다. 그 빈틈을 메우는 도구가 패딩, 정렬, NUMA-aware 할당자, HugePage이며, 이들 모두가 같은 압력에 대한 응답이다.

이 계층 구조는 단편적인 최적화 팁을 외우기 위한 것이 아니라, 어느 층의 어떤 미스와 공유 상태가 비용을 만들었는지 설명하는 골격이다.

더 읽을거리

profile
RL Researcher, Video Game Developer

0개의 댓글