640K ought to be enough for anybody.
640K는 누구에게나 충분한 메모리다.
– Bill Gates
한 시대를 풍미했던 컴퓨터 엔지니어, 컴퓨터로 쌓을 수 있는 최고의 부를 거머쥔 사람, 빌 게이츠도 한때 640K 메모리면 온세상 프로그램을 다 돌릴 수 있다고 생각했던 시절이 있었던 것 같다. 빌 게이츠가 실제로 저 말을 했는지 안 했는지는 모르겠지만 한 가지 분명한 사실은 저 이야기가 통용되던 때로부터 시간이 한참은 흘렀다는 점이다. 그 사이 CPU도 변했고, 운영체제도 변했고, 프로그램도 변했다. 이제는 640K로만 돌아가는 프로그램이 있다면 그게 더 신기할 지경인 세상이다.
현대적인 프로그램은 상상하기도 힘들만큼 많은 메모리를 사용한다. 그중에 백미는 역시 게임이다. 게임은 여전히 가장 많은 메모리와 CPU 자원을 필요로 하는 프로그램 중에 하나다. 게임 보안 소프트웨어는 그런 프로그램과 동시에 실행되어야 하기 때문에 다양한 제약 사항들이 존재한다. 메모리도 그 중 하나다. 모 게임의 경우에는 게임 보안 솔루션 때문에 대규모 컨텐츠 패치가 불가능한 상황이라 우리 제품으로 윈백한 경우도 있었다. 보안 제품이 너무 많은 메모리를 사용했던 것이다. 어쨌든 많은 메모리를 필요로 하는 게임, 그리고 동작하기 위해서는 반드시 메모리를 사용할 수 밖에 없는 보안 솔루션의 한집 살이가 쉽지 않다는 사실에는 이견의 여지가 없다.
CPU 이슈는 종종 제기됐지만 메모리 이슈는 크게 부각되지 않았기에 우리는 그간 메모리가 안녕하신줄 알았다. 하지만 최근에 출시한 따끈따끈한 게임은 기존 게임들보다는 더 많은 메모리를 필요로 했기에 메모리는 안녕하지 못했다. 우리는 그 핫한 게임과 한집 살림을 하기 위해서 몇 가지 주요한 기능들을 포기해 가면서 메모리를 좀 더 아껴쓰게 만드는 작업들을 했는데 이 과정이 생각보다 재밌었기에 여기에 조금 소개해볼까 한다.
#0
처음 제기된 이슈는 메모리 단편화였다. 사실 우리가 터무니 없이 많은 메모리를 사용하지는 않기 때문에 단편화에 영향을 줬다면 딱 하나 밖에는 없다. ASLR(Address Space Layout Randomization)이다. 우리는 지난 글에도 썼지만 코드의 분석을 어렵게 하기 위해서 ASLR을 사용한다. 코드를 메모리 여기 저기에 분산 배치시키는 기능을 의미한다. 그런데 이게 아주 큰 연속적인 공간을 필요로 한다면 문제가 될 수 있다. 우리는 이 옵션을 껐고, 게임은 잘 동작했다. 이렇게 문제가 해결되는 것처럼 보였다. 나중에 시간나면 단편화에 영향을 최소한으로 주는 ASLR 알고리즘을 만들어야겠다는 생각을 하면서…
#1
그런데 그 사라졌을 것 같은 이슈는 사라진 게 아니었다. 단지 지연됐을 뿐이었다. 게임을 오래하면 단편화 문제가 계속 발생한다는 것이었다. ASLR을 껐지만 우리도 게임 실행 중 메모리 할당과 해제를 반복하기 때문에 당연히 영향을 줄 수 밖에 없다.
게임 메모리 단편화에 단 한톨의 영향도 끼치지 않겠다는 생각으로 대규모 토목 공사가 시작됐다. 메모리 풀을 만들고 덩어리 메모리를 할당하는 곳은 모두 그 풀에서 동작하도록 구조를 변경하는 작업이 진행됐다. 그러면 할당과 해제를 반복해도 초기에 고정된 풀 내에서만 벌어지기 때문에 게임에 주는 영향은 최소화할 수 있기 때문에 게임 내 메모리 단편화 현상을 피할 수 있다는 생각에서였다.
ASLR을 끈 것이나, 메모리 풀을 사용해서 집중된 영역에 뭔가를 배치시키는 것 자체가 지난 글에서 언급한 보안성과는 전혀 상관없는 내용이다. 하지만 현실 세계에서는 응당 안정성이 보안성에 우선한다. 일단 게임을 할 수 있어야 핵을 잡든 말든 중요한 것 아니겠는가?
대규모 토목 공사인 줄 알았지만 그렇게 많이 고칠 필요는 없었다. 덩어리 메모리를 사용하는 곳이 크게 많지는 않았기 때문이다. 토목 공사가 끝날 즈음에 난 초기 풀 크기로 통크게 100메가 바이트를 지정했다. 풀은 두개를 사용했다. 그러니 시작과 동시에 할당은 아니더라도 메모리 공간은 200메가 바이트를 깔고 들어가는 셈이었다. 물론 우리가 그 정도로 많은 메모리를 사용하지는 않지만 어떤 경우에도 게임 내 단편화 현상에 영향을 주고 싶지는 않았기에 느낌대로 크게 잡아 버렸다. 이때까지만 해도 난 단편화 문제라 보고 받았기 때문에 이는 현명한 선택이라 생각했다.
#2
이 대규모 토목 공사도 새로 출시한 핫한 게임을 만족시키기엔 부족했다. 이 시점부터 난 게임 프로그래머와 직접 대화할 수 있었고 실질적인 문제가 단편화가 아니라는 사실을 알게 되었다. 게임 자체가 2기가 바이트에서 거의 간당 간당하게 돌아가기 때문에 서드 파티 프로그램이 너무 많은 공간을 사용하면 문제가 된다는 것이었다. 64메가 바이트 정도 안쪽으로 공간이 집중될 수 있었으면 좋겠다는 이야기를 했다. 즉, 문제는 단편화도 단편화지만 메모리 공간 자체가 많이 부족하다는 의미였다.
메모리가 이리도 싼 세상에, 심지어 핸드폰도 2기가 넘는 메모리를 탑재하는 시대에 메모리 타령이라니 조금 이상하게 들릴지도 모르겠다. 우리가 여기서 이야기하는 메모리 한계는 실제 물리 메모리가 아니다. 거의 대부분 주소 공간에 관한 이야기다. 주소 공간이란 한 프로그램이 한번에 사용할 수 있는 메모리 공간이라고 생각하면 된다. 16기가 바이트의 메모리를 탑재하고 있다고 16기가 바이트를 다 쓸 수 있는게 아니기 때문이다. 64비트 CPU, 64 운영체제, 64 응용 프로그램이라면 가능하겠지만 게임은 아직 그런 환경을 고려하기란 쉽지 않다. 어쨌든 32비트를 고려해야 하기 때문에 결국은 32비트 응용 프로그램에서 발목이 잡힌다. 윈도우 환경에서 일반적으로 32비트 응용 프로그램이 사용할 수 있는 주소 공간은 2기가 바이트로 제한된다.
이런 상황에서 주소 공간을 낭비하는 가장 대표적인 형태는 VirtualAlloc의 잘못된 사용에서 발생한다. 32비트 환경에서 윈도우는 할당 단위로 64KB(0x10000)를 사용하고, 페이지 크기로 4KB(0x1000)을 사용한다. 할당 단위가 64KB이기 때문에 VirtualAlloc을 64KB의 배수가 아닌 다른 값으로 사용하면 쓸 수 없는 주소 공간, 슬랙이 생겨버린다.
PVOID ptr = VirtualAlloc(NULL, 0x8000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
위와 같은 간단한 할당 코드를 살펴보자. 32KB를 한번에 할당하기 위해서 VirtualAlloc을 호출했다. 메모리가 있다면 위 호출은 정상적으로 실행돼서 ptr에 어떤 값을 반환할 것이다. 보통 이런 호출은 어떠한 문제도 없다. 하지만 공간이 모자란 경우에는 이런 호출 조차도 사치가 된다. 앞서도 이야기 했지만 윈도우는 64KB 할당 단위를 사용하기 때문에 위와 같이 호출해 버리면 ptr 다음의 32KB는 사용할 수 없는 공간, 죽은 메모리 공간이 되기 때문이다. 슬랙을 없애기 위해서는 아래와 같은 형태로 메모리를 꼼꼼하게 사용해야 한다. 아래 코드는 64KB 영역을 예약하고 32KB만 물리 메모리를 할당하는 것을 의미한다. 나중에 32KB가 더 필요하면 ptr 뒤쪽의 32KB를 할당해서 사용할 수 있다.
PVOID ptr = VirtualAlloc(NULL, 0x10000, MEM_RESERVE, PAGE_READWRITE);
VirtualAlloc(ptr, 0x8000, MEM_COMMIT, PAGE_READWRITE);
이런 것들을 완전히 꼼꼼하게 관리하기 위해서는 별도의 풀 메모리 매니저 API를 만들어야 한다. 단지 읽고/쓰기 위한 공간을 할당하기 위한 목적이라면 그냥 힙 관련 API를 사용하는 것이 바람직하다. 힙 API도 내부적으로 큰 덩어리들에 대해서는 VirtualAlloc을 사용하기 때문에 보통 프로그래머들이 생각하는 것처럼 속도 저하가 그리 심하지는 않다. 뭐 직접 메모리 매니저를 구현하는 비용 생각하면 그놈이 그놈인 경우가 다반사다.
#4
풀 크기야 일부러 크게 잡아둔 것이었기 때문에 100MB라고 된 부분을 그냥 10MB로 바꿔버리면 된다. 우리에게 적합한 최소 크기에서 조금의 마진이 있는 형태로 풀 크기를 조정하는 작업을 진행하면서, 게임 프로그래머의 고민을 조금이나마 도와 주기 위해서 게임 메모리 공간을 살펴보기로 했다. 내가 제일 먼저 한 일은 게임이 더 이상 진행 불가가 된 상황의 메모리 맵을 추출하는 일이었다. 다행이도 꼼꼼한 게임 프로그래머는 우리에게 풀 덤프 파일을 제공해 주었고 거기나 난 아래와 같은 거대한 메모리 맵을 손쉽게 추출할 수 있었다.
BaseAddr EndAddr+1 RgnSize Type State Protect Usage
-------------------------------------------------------------------------------------------
* 0 10000 10000 MEM_FREE PAGE_NOACCESS Free
* 10000 20000 10000 MEM_MAPPED MEM_COMMIT PAGE_READWRITE
\* 20000 21000 1000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
\* 21000 30000 f000 MEM\_FREE PAGE\_NOACCESS Free
\* 30000 34000 4000 MEM\_MAPPED MEM\_COMMIT PAGE\_READONLY ActivationContextData
\* 34000 40000 c000 MEM\_FREE PAGE\_NOACCESS Free
\* 40000 42000 2000 MEM\_MAPPED MEM\_COMMIT PAGE\_READONLY ActivationContextData
\* 42000 50000 e000 MEM\_FREE PAGE\_NOACCESS Free
...
엄청난 분량의 로그가 출력됐다. 게임이 정말 많은 메모리를 사용하고 있다는 것을 알 수 있었다. 그 로그 중에서 진짜 할당이 실패했는지 조사하기 위해서 비어 있는 영역만 따로 모아서 크기 순으로 정렬을 해서 살펴보았다. 그랬더니 아래 나와 있는 것처럼 가장 큰 덩어리가 0xF0000 크기의 영역이었다. 15MB 정도를 한번에 할당할 수 있는 영역이다. 하지만 게임이 할당한 것으로 보이는 메모리 중에는 20MB 가까운 메모리도 있었기에 그정도 크기를 요청했다면 실패했을 수도 있겠다는 생각이 들었다.
게임 프로그래머의 이야기처럼 여기저기 단편화된 메모리 조각들도 있었다. 아래 리스트에서도 연한 글씨로 표시된 6d10c000, 4df31000 영역은 조각난 메모리 영역으로 공간은 비어있지만 실제로 그 메모리의 앞쪽 영역은 사용할 수 없는 부분이다.
**\* 7b320000 7c220000 f00000 MEM\_FREE PAGE\_NOACCESS Free
\* 712f6000 72180000 e8a000 MEM\_FREE PAGE\_NOACCESS Free
\* 7e1c0000 7f030000 e70000 MEM\_FREE PAGE\_NOACCESS Free**
* 6d10c000 6def0000 de4000 MEM_FREE PAGE_NOACCESS Free
* 4df31000 4ed00000 dcf000 MEM_FREE PAGE_NOACCESS Free
**\* 4baf1000 4c560000 a6f000 MEM\_FREE PAGE\_NOACCESS Free
\* 67570000 67d40000 7d0000 MEM\_FREE PAGE\_NOACCESS Free**
...
다음으로 할당된 영역만 따로 뽑아서 살펴보았다. 메모리가 그렇게나 많이 필요한 상황은 아니라고 생각했기에 할당 단위의 문제라 생각했다. 앞서 설명한 것과 같이 VirtualAlloc의 사용 방법을 변경하면 상당히 많은 메모리를 절약할 수 있을거라는 믿음이 있었다. 로그의 출력 결과는 나의 이론을 뒷받침하는 것처럼 보였다. 0x12000, 0x11000 등의 크기로 할당된 무수히 많은 메모리 공간들이 보였다. 빙고~ 이 영역들을 없애면 게임이 훨씬 더 많은 공간을 가용 메모리로 사용할 수 있겠다는 생각이 들었다.
* 584d0000 5998a000 14ba000 MEM_PRIVATE MEM_COMMIT PAGE_READWRITE
|-568c2000 57500000 c3e000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
\* 5e3b0000 5ee5c000 aac000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
...
\* 440b0000 440c2000 12000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
\* dda0000 ddb1000 11000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
\* dde0000 ddf1000 11000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
\* de20000 de31000 11000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
\* de40000 de51000 11000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
\* de60000 de71000 11000 MEM\_PRIVATE MEM\_COMMIT PAGE\_READWRITE
...
#5
시간만 있었다면 이 단계에서 성급하게 증명하려 들었을 것 같다. 메모리 매니저 API까지 제공하는 보안 솔루션 하면 간지 나니깐. 하지만 중요한 외부 미팅이 있었기에 생각은 여기서 멈출 수 밖에 없었다. 결과론적으로 그게 나를 구했다.
오늘 출근해서는 메모리 매니저 API를 구현하려고 연습장을 꺼내 들고는 끄적거리는데 생각보다 복잡했다. 우리 풀 매니저를 그냥 게임 API에 연결해서 사용하면 되는데 문제는 VirtualAlloc 따위를 후킹했을 때 훅 함수에서 다시 VirtualAlloc이 호출되는 불상사를 없애야 한다는 점이었다. 모든 동적 할당을 봉쇄한 코드를 새로 작성해야 해서 성가신 작업이었다. 그렇다고 검증도 안된 것을 게임사에 적용해 달라고 하기도 무리수.
어떡하지? 어떡하지? 하고 있는데 좋은 생각이 떠올랐다. 이게 진짜 의미있는 짓인지 알아보자는 생각이었다. 과연 내가 이 변경을 통해서 얼마나 많은 슬랙 공간을 확보할 수 있을까? 뭐, 이 이론적인 가능성이 괜찮다면 게임사에다 이야기 할수도 있겠다는 생각이었다. 이 작업을 하기 위해서 할당된 메모리 크기 별로 카운트를 추출했다. 게임사에서 32KB 이상의 메모리 할당에 VirtualAlloc을 사용한다 했으니 우리의 관심 영역인 32KB(0x8000) 이상 128KB(0x20000) 이하의 메모리에 대해서 먼저 조사를 했다. 이 영역에 사용된 크기별 카운트는 아래 목록에 나와 있는 것과 같다.
00008000: 30
00009000: 39
0000a000: 33
0000b000: 11
0000c000: 35
0000d000: 20
0000e000: 51
0000f000: 240
00010000: 4996
00011000: 403
00012000: 16
00013000: 10
00014000: 8
00015000: 12
00016000: 4
00017000: 5
00018000: 6
00019000: 3
0001a000: 4
0001b000: 4
0001c000: 4
0001d000: 6
0001e000: 1
0001f000: 12
00020000: 7
이 목록에 대해서 완벽한 메모리 풀을 만들었다면 얼마나 많은 슬랙 공안을 확보할 수 있을까? 0x11000으로 할당된 403개의 공간이 나를 향해 빵긋 웃는듯이 보였다. 하지만 이내 그건 초심자의 행운 같은 것임을 깨달았다. 계산 결과는 생각과 달랐기 때문이다. 위 메모리 할당에 사용되는 총 공간은 404MB인데 슬랙으로 낭비는되는 공간은 단지 27MB였다. 헐~ 물론 404MB대비 27MB는 작은 비율은 아니다. 비율에 희망을 걸면서 전체 메모리 할당 내역을 상대로 계산을 확장했다. 그랬더니 할당에 사용된 총 공간은 1331MB였는데 슬랙으로 낭비되는 공간은 꼴랑 40MB에 불과했다. 맙.소.사~
#6
엄마가 그랬다. 얘야 되도록 파이썬을 사용하렴. 성급한 최적화만큼 멍청한 일도 없단다. CPU 보다는 네 시간이 항상 더 소중하다는걸 기억해야지. 그렇다. 성급하게 일을 안 벌리길 잘한것 같다는 생각 ㅋ~
40MB가 작은 메모리가 아닐수도 있다. 마치 티핑 포인트 같은 지점일수도 있다. 단지 20MB만 더 있으면 다시 해제/할당의 사이클을 반복하면서 게임이 계속 진행됐을 수도 있다. 하지만 이는 너무 억척스러운 가정이라는게 함정.
이슈가 끝날 즈음 그런 생각이 들었다. 64비트 시대가 멀지 않았구나. 1MB의 제약을 벗어나려 32비트의 세계가 도래한 것처럼 2GB도 어느새 너무 좁은 공간이 되어 버렸다. 뭐 맛폰 CPU가 64비트로 가는 세상이니 말 다했지.
한집 살림을 위한 안정성은 어느 정도 확보했으니 다시 보안성을 위해서 풀을 쪼개고 할당을 랜덤화할 차례… 뫼비우스의 띠 위를 걷고 있는 듯한 이 느낌은 뭐지. 하앍~
이 글을 끝까지 읽었다면 이제 커피 한잔의 여유를 가져 보아요…
사실 슬랙이 그리 나쁜 건 아니랍니다. 버퍼 오버런을 우아하게 허용해주는 관대함을 가졌죠 ㅋ~
메모리도 인생도 그렇게 조금은 여백이 필요합니다.