sgkstudio.
엔지니어링

vmmem 메모리 과다 점유 — 5분마다 자동으로 회수한 방법

vmmem 메모리 과다 점유는 상한을 고정한다고 해결되지 않았고, 오래 안 쓴 캐시만 골라 회수하는 방식으로 바꾸고 나서야 잡혔습니다. 저희는 처음에 메모리 상한(MemoryHigh)을 고정해 캐시를 묶어 두려 했는데, 그 기준으로 참고한 예전 기록의 '최대 3.83GB'가 실제로는 특정 순간의 단면이었고 7일 관측 최고치는 11.39GB였다는 사실이 뒤늦게 드러났습니다. 상한 대신 cgroup v2의 memory.reclaim 기능으로 오래된 캐시 페이지만 5분마다 자동으로 회수하도록 바꾸자 vmmem이 정상 범위로 돌아왔고, 회수한 캐시를 다시 읽는 비용까지 실측해 상시 실행이 안전하다는 근거를 확보했습니다.

2026-09-12메모리 회수 정책 결정 기록(2026년 9월 2일) — 크론 164줄 중 유휴 5분 창을 만족한 관측은 48건 중 13건, 그마저 순간값이었다는 확인7일 관측 기록 재확인 — 실제 사용 메모리(anon) 최고치 11.39GB, 이전 내부 기록의 '최대 3.83GB'는 단면값으로 정정cgroup v2 memory.reclaim 직접 검증 — system.slice 대상 2GB 회수 요청 시 파일 캐시 4.05GB→2.05GB로 정확히 감소, 세션 8개 중 cgroup 슬라이스 분포 확인(init.scope 7개·anon 3.5GB, user.slice 1개)재읽기 비용 실측(2026년 9월 2일, 같은 날 마감) — 실워크로드 3종을 warm·trim 직후·drop_caches 직후 2라운드 교차 측정, 최상위 슬라이스 커버리지 93%(meminfo 캐시 2.54GB 대비)

vmmem 메모리는 왜 줄어들지 않았나

이 조치는 '유휴 메모리를 상시로 회수할 방법이 없을까'라는 단순한 질문에서 시작했습니다. 질문 자체는 짧았지만, 답을 찾아가는 과정에서 예전에 적어 둔 숫자 하나가 틀렸다는 사실까지 함께 드러났습니다.

저희는 여러 사업을 혼자 운영하면서, 윈도우 PC 한 대에서 여러 개의 AI 코딩 에이전트 세션과 정해진 시각마다 자동으로 실행되는 예약 작업(크론)을 동시에 돌립니다. 윈도우 안에서 리눅스 환경을 그대로 돌리는 호환 계층인 WSL2가 이 모든 작업을 처리하는데, WSL2가 실제로 쓰는 메모리 양은 윈도우 작업관리자에 vmmem이라는 이름의 프로세스로 나타납니다.

문제는 이 vmmem이 좀처럼 줄지 않고 계속 커진다는 점이었습니다. WSL2에는 유휴 메모리를 자동으로 회수하는 내장 기능(autoMemoryReclaim=dropCache)이 있지만, 이 기능은 가상머신이 5분 연속으로 유휴 상태일 때만 작동합니다. 그런데 저희는 크론을 164줄 등록해 두었고, 이 크론들이 쉬지 않고 돌면서 5분짜리 유휴 창을 거의 남기지 않았습니다.

실제로 관측 기록 48건을 확인해 보니 부하지표(load1, 최근 1분간 시스템이 얼마나 바빴는지 나타내는 값)가 0.3 미만으로 떨어진 순간은 13건뿐이었고, 그마저도 한 순간의 값이었을 뿐 5분 연속 유지된 적은 없었습니다. 내장 회수 기능이 사실상 발동하지 못하는 상태였고, vmmem은 계속 커지기만 했습니다.

메모리가 계속 쌓이기만 하면 결국 윈도우 전체가 쓸 수 있는 여유 메모리가 줄어듭니다. 여러 에이전트 세션과 크론을 동시에 돌리는 환경에서는 이 여유가 줄어들수록 작업이 밀리거나 느려질 위험이 커지므로, 유휴 시간을 기다리는 방식이 아니라 상시로 도는 다른 방법이 필요했습니다. 문제를 미루면 언젠가 여유 메모리가 바닥나는 순간에야 알아차리게 되는데, 그 시점은 하필 여러 작업이 한꺼번에 몰려 있을 때일 확률이 높습니다.

고정 상한을 쓰려고 처음 확인한 숫자

내장 기능이 못 도는 상황이라면, 리눅스 systemd(리눅스가 프로세스와 자원을 관리할 때 쓰는 표준 관리자)가 그룹별로 걸 수 있는 메모리 상한 값(MemoryHigh)을 직접 걸어 강제로 눌러 두는 방법을 먼저 떠올렸습니다. 설정 파일 한 줄만 추가하면 되는 방법이라, 새 자동화를 따로 만들지 않아도 된다는 점이 매력적이었습니다. 상한을 걸려면 먼저 실제 사용량이 어느 정도인지부터 알아야 했습니다.

처음 참고한 숫자는 새로 잰 값이 아니라 예전에 남겨 둔 내부 기록이었습니다. 시스템을 손볼 때마다 결정 배경을 적어 두는 기록인데, 그 기록에는 '최대 3.83GB'라고 적혀 있었고, 이 값을 보고 4GB 안팎에서 상한을 정하면 캐시만 묶고 실제 작업에는 지장이 없을 것이라고 판단했습니다. 매번 새로 재기보다 이미 적어 둔 값을 참고하는 쪽이 더 빠르다고 여겼기 때문입니다.

메모리 상한을 고정하면 해결될까

세운 가설은 단순했습니다 — systemd MemoryHigh를 3.83GB에서 크게 벗어나지 않는 선인 4GB 안팎으로 고정해 두면, 캐시가 그 이상 커지지 못하게 막으면서도 세션이 실제로 쓰는 메모리는 건드리지 않는다는 가설이었습니다. 고정 상한 하나로 vmmem 문제를 끝낼 수 있어 보였고, 별도 코드 수정 없이 systemd 설정 한 줄로 끝나는 방법이라 가장 먼저 시도할 만했습니다.

이 가설이 맞다면 새로운 자동화를 하나도 추가하지 않고도 문제가 끝나는 셈이었습니다. 상한 값 하나를 정하는 결정만 남아 있었고, 그 결정을 뒷받침할 근거로 예전 기록의 3.83GB를 그대로 쓸 생각이었습니다.

기억 속 숫자와 실측이 어긋났다

가설을 걸기 전에 최근 7일 관측 기록으로 실제 사용 메모리(anon, 프로그램이 직접 쓰고 있는 메모리)의 최고치를 다시 확인했습니다. 결과는 11.39GB였습니다. 예전 기록의 '최대 3.83GB'는 특정 순간을 찍은 단면일 뿐, 실제 최고치인 11.39GB에는 한참 못 미치는 값이었습니다. 이전 기록은 정정 대상이었고, 이 정정 하나로 앞서 세운 가설의 전제 자체가 무너졌습니다.

이 상태로 4GB 안팎에서 상한을 걸었다면 어떤 일이 벌어졌을지는 명확합니다. 실제 작업량이 몰리는 순간마다 상한이 진짜 사용 중인 메모리까지 눌러 세션을 함께 조였을 가능성이 컸습니다. 캐시만 묶으려던 상한이 실제로는 작업 자체를 막는 상한이 될 뻔했습니다. 세운 가설은 여기서 기각됐고, 상한이라는 접근 방식 자체를 다시 검토해야 했습니다.

이 재확인이 아니었다면 4GB 안팎의 상한을 그대로 걸었을 가능성이 큽니다. 그랬다면 여러 세션이 한꺼번에 바빠지는 시점마다 상한에 걸려 응답이 느려지거나 작업이 밀리는 증상이 나타났을 텐데, 그 증상의 원인을 처음에는 상한 설정이 아니라 다른 곳에서 찾았을 수도 있습니다. 근거로 삼은 숫자를 다시 재는 절차 하나가 이런 뒤늦은 원인 추적을 막아 준 셈입니다.

잘라야 할 대상은 상한이 아니라 페이지 단위였다

고정 상한이라는 접근 자체가 틀렸다는 게 드러났습니다. 상한은 그룹 전체의 메모리 양만 볼 뿐, 그 안에서 어떤 페이지가 오래 안 쓴 캐시이고 어떤 페이지가 지금 세션이 실제로 쓰고 있는 메모리인지 구분하지 못합니다. 그룹 전체를 누르는 상한이 아니라, 페이지 단위로 오래된 캐시만 골라 회수하는 방식이 필요했습니다.

실제 사용 메모리(anon)와 파일 캐시는 성격이 다릅니다. anon은 프로그램이 지금 붙들고 있어서 지우면 안 되는 메모리이고, 파일 캐시는 디스크에서 미리 읽어 둔 사본이라 지워도 다시 읽으면 그만인 메모리입니다. 상한은 이 둘을 가리지 못하고 그룹 전체를 똑같이 누르지만, 필요한 조치는 파일 캐시 쪽만 골라 건드리는 것이었습니다. 비유하자면 책상 위에 쌓인 서류(anon)와, 나중에 다시 뽑으면 그만인 참고 자료 사본(파일 캐시)을 한 서랍에 몰아넣고 서랍째 잠가 버리는 방식과, 사본만 골라 치워 서류는 그대로 두는 방식 사이의 차이라고 볼 수 있습니다.

확인할 게 하나 더 있었습니다. 세션이 실제로 어느 cgroup(리눅스 커널이 프로세스 묶음별로 자원을 나눠 관리하는 단위)에 속해 있는지였습니다. 윈도우 터미널에서 wsl.exe로 띄운 세션은 로그인 셸이 아니라 init.scope라는 최상위 그룹에 속했는데, 저희가 돌리던 8개 세션 가운데 7개가 여기 있었고 이 그룹의 anon 메모리만 3.5GB였습니다. 로그인 셸에서 뜬 세션은 user.slice에 딱 1개뿐이었습니다. user.slice 한 곳만 보고 판단했다면 세션 대부분이 쓰는 3.5GB가 통째로 계산에서 빠질 뻔했습니다.

그러니까 최상위 cgroup 슬라이스를 전부 훑어야 한다는 뜻이었습니다. 슬라이스 하나만 보고 '메모리를 다 파악했다'고 판단하면, 정작 세션 대부분이 사는 자리는 계산 밖에 남습니다. 회수 대상을 정확히 잡으려면 시작부터 전체 슬라이스 목록을 먼저 확보해야 했습니다. 처음 상한을 검토할 때는 흔히 참고하는 자리인 user.slice만 봐도 충분하다고 생각했는데, 실제로 세션 대부분이 사는 곳은 그쪽이 아니었던 셈입니다.

세션 8개가 실제로 사는 cgroup 슬라이스 표. init.scope에 7개(anon 3.5GB), user.slice에 1개
user.slice 한 곳만 봤다면 세션 7개분이 계산에서 빠질 뻔했다

여러 세션을 동시에 돌릴 때 눈에 안 보이는 곳에서 문제가 생기는 경우는 이번이 처음이 아니었습니다 — Claude Code 병렬 세션 기록 유실 사례에서도 겉보기엔 멀쩡한데 실제로는 다른 곳에서 새고 있었습니다.

cgroup으로 5분마다 자동 회수하게 만들었다

그래서 다음으로 살펴본 것이 리눅스 커널이 이미 갖고 있는, 그룹 단위가 아니라 페이지 단위로 회수를 요청할 수 있는 기능이었습니다.

최종적으로 채택한 방법은 cgroup v2가 제공하는 memory.reclaim 기능이었습니다. 이 기능에 원하는 회수량을 요청하면, 커널이 그 그룹 안에서 가장 오래 안 쓴 페이지(LRU, 가장 오래 안 쓴 데이터부터 먼저 지우는 방식)부터 요청한 양만큼만 회수합니다. 자주 쓰는 뜨거운 파일은 그대로 남고, 지금 돌고 있는 프로세스도 조여지지 않습니다. 그룹 전체를 누르는 상한과 달리, 페이지 단위로 정확히 원하는 만큼만 덜어낼 수 있는 기능이라는 점이 앞선 시도와 다른 지점이었습니다.

직접 검증해 보았습니다. 시스템 관리용 그룹(system.slice)에 2GB를 회수하라고 명령을 내리자 파일 캐시가 4.05GB에서 2.05GB로 정확히 줄었습니다. 요청한 양과 실제로 줄어든 양이 정확히 일치했다는 것은, 이 기능이 뜨거운 페이지와 차가운 페이지를 실제로 구분해서 움직인다는 뜻이었습니다.

system.slice에 2GB 회수를 요청한 실측 막대그래프. 회수 요청 전 4.05GB, 회수 요청 후 2.05GB
요청한 만큼만 정확히 줄어든다 — 4.05GB에서 2.05GB로

이 방식을 wsl-cache-drop.sh 스크립트에 --trim 2 옵션으로 심었습니다. 최상위 슬라이스마다 파일 캐시가 2GB를 넘는 만큼만 회수하고(1회 최대 4GB), 이 작업을 5분마다 자동으로 돌렸습니다. 슬라이스 하나에서 한 번에 4GB 넘게 회수하지 못하게 상한을 둔 이유는, 회수 자체가 순간적으로 디스크 입출력을 늘리는 작업이라 한 번에 너무 많이 덜어내면 그 순간 다른 작업이 느려질 수 있기 때문입니다.

기존에 있던 30분 간격 백스톱(사용량 85% 초과이면서 유휴 캐시 8GB 이상일 때 drop_caches로 전체를 비우는 안전망)은 그대로 남겨 두었습니다 — 최상위 슬라이스 계정에 안 잡히는 캐시를 위한 마지막 안전망이기 때문입니다. 두 장치의 역할을 나눈 셈입니다. 5분 간격 회수가 평소 경로를 맡고, 30분 백스톱은 그 경로가 죽었을 때만 개입하는 예비 장치로 남습니다.

처음 돌렸을 때 캐시는 6.28GB에서 3.84GB로 줄었습니다. 다만 vmmem 수치에 반영되는 데는 수십 초에서 분 단위 시간이 걸리고, 그사이 새 작업이 몰리면 줄어든 만큼이 상쇄되기도 합니다. 그래서 효과 판정은 그 순간의 수치가 아니라 관측 기록의 6시간 저점으로 하기로 했습니다. 순간값 하나로 성공을 선언하면, 바로 다음 순간 새 작업이 몰려 수치가 다시 튀어 올랐을 때 판단이 뒤집힐 수 있기 때문입니다.

캐시를 다시 읽는 데는 비용이 얼마나 드나

회수 방식을 페이지 단위로 바꾸고 나니 vmmem 문제는 일단락됐지만, 이 방식을 5분마다 상시로 돌려도 괜찮은지 확인하는 절차가 남아 있었습니다.

자동 회수를 5분마다 상시로 돌리기로 하면서 닫아야 할 질문이 하나 남아 있었습니다 — 캐시를 회수한 다음 그 데이터를 다시 읽어야 할 때 비용이 얼마나 드는지였습니다. 캐시를 지우면 메모리는 줄지만, 그 자리에 있던 파일을 다시 열 때마다 디스크에서 새로 읽어야 하므로 그만큼 느려질 수 있습니다. 5분마다 회수를 반복하면 이 재읽기 비용도 5분마다 쌓이는 셈이라, 상시 실행 여부를 정하기 전에 반드시 확인해야 하는 질문이었습니다. 같은 날 이 질문을 실제로 재서 닫았습니다.

실제 업무 3종 — 저장소 상태 확인 명령, 스크립트·규칙 파일 8.9MB 읽기, 관측 기록 2.8MB 읽기 — 을 warm(회수 전 정상 상태) · trim 직후 · drop_caches 직후 세 조건으로 나눠 2라운드씩 교차로 쟀습니다. 세트 소요 시간과 함께, 디스크에서 다시 읽어 들인 페이지 수를 뜻하는 지표(pgpgin)도 같이 기록했습니다 — 이 값이 클수록 캐시가 없어서 디스크를 다시 뒤진 양이 많다는 뜻입니다.

재읽기 비용 실측 — warm·trim 직후·drop_caches 직후 비교 (2026년 9월 2일)
조건세트 소요디스크 재읽기(pgpgin)
warm 기준선1.03~1.26s0~20
trim 직후1.15~1.25s0~224
drop_caches 직후 1회차1.56~1.61s32,576~51,340 (약 130~200MB)
drop_caches 3회차 이후1.19~1.24s0~5,776
warm, trim 직후, drop_caches 직후의 세트 소요 시간과 디스크 재읽기 수를 비교한 표
trim 직후는 warm과 시간이 구분되지 않는다
  • trim 직후의 재읽기 비용은 측정 한계 아래였습니다 — 걸린 시간이 warm 상태와 구분되지 않았고, 재읽기 페이지 수(pgpgin)도 두 자리에서 세 자리 수준이었습니다. 오래 안 쓴 페이지만 골라 회수하는 방식이 실제로 자주 쓰는 페이지를 지킨다는 뜻이고, 5분마다 상시로 돌려도 되는 근거가 됐습니다.
  • drop_caches는 한 번 실행할 때마다 30~35% 손해를 봤습니다 — 첫 실행에서 0.35~0.4초를 더 쓰고 130~200MB를 다시 읽었습니다. 다만 2~3회 반복하면 다시 warm 수준으로 돌아왔으므로, 영구적인 비용이 아니라 한 번의 스파이크였습니다.
  • 최상위 슬라이스 전체를 합친 캐시량은 리눅스가 잡고 있는 전체 캐시(meminfo)의 93%를 덮었습니다(미계정분 0.18GB / 전체 2.54GB). trim이 대부분을 덮고, 남은 7%를 drop_caches로 추가로 가져오려면 재읽기 비용을 40~200배 더 치러야 했습니다.

그래서 기존 30분 백스톱은 그대로 남기기로 했습니다. 비용은 비싸지만, trim 경로가 죽었을 때만 도는 안전망이기 때문입니다. trim이 정상 작동하면 사용량이 85%까지 올라가지 않으므로 백스톱 자체가 발동하지 않습니다. 비싼 도구를 상시 경로에서 빼고 안전망 자리에만 두는 방향이 이번 조치의 요지였습니다. 평소에는 싼 도구가 돌고, 비싼 도구는 평소 경로가 무너졌을 때만 대신 나서는 구조입니다.

두 장치를 같은 자리에 놓고 하나만 고르는 대신, 역할을 나눠 둘 다 남긴 이유는 이 때문입니다. 평소 경로 하나만 믿고 안전망을 없앴다면, trim이 어떤 이유로든 멈췄을 때 회수 자체가 통째로 멎었을 것입니다. 반대로 안전망만 상시로 돌렸다면 매번 30~35%의 재읽기 손해를 감수해야 했습니다. 두 장치를 역할별로 나눠 둔 지금 구조가 그 사이에서 찾은 답입니다.

아직 못 채운 7%와 측정의 함정

이번 조치로 다 해결되지는 않았습니다. 최상위 슬라이스 계정으로 잡히는 캐시는 전체의 93%뿐이고, 나머지 7%(0.18GB)는 지금도 trim 경로가 아니라 30분 백스톱에만 의존합니다. 이 7%를 상시 경로로 끌어오려면 어느 슬라이스에도 안 걸리는 캐시가 정확히 어디서 생기는지부터 다시 찾아야 하는데, 아직 그 작업은 하지 않았습니다. 규모가 크지 않은 만큼 지금 당장의 우선순위는 아니지만, 남겨 둔 숫자를 숨기지 않고 그대로 적어 둡니다.

리눅스 전체 캐시 중 최상위 슬라이스가 덮는 비율 막대그래프. 최상위 슬라이스 합계 93%, 미계정 7%(0.18GB)
덮지 못한 7%는 아직 안전망에만 의존한다

측정 과정에서 함정도 두 개 만났습니다. 하나는 rg 명령이 실제로는 AI 코딩 에이전트가 감싸 둔 셸 함수였다는 점입니다 — 스크립트 안에서는 이 함수가 잡히지 않아서, 실제 파일을 읽는 cat 명령으로 바꿔야 했습니다. 겉보기엔 평소 쓰던 명령 그대로였는데, 그 명령이 측정용 스크립트 안에서는 다르게 동작한 셈입니다.

다른 하나는 5분마다 도는 trim 크론이 측정 도중에 끼어들어 결과를 오염시킨다는 점이었습니다. 재읽기 비용을 재는 동안 trim이 끼어들면 warm 조건에서도 캐시가 일부 빠져 있을 수 있어, 세 조건의 비교 자체가 무의미해집니다. 그래서 벤치마크를 도는 동안에는 이 크론을 crontab에서 잠시 빼 두었고, 끝난 뒤 원래 상태(305줄)와 동일한지 확인하고 다시 넣었습니다.

돌이켜 보면 이번 사례에서 진짜 틀렸던 지점은 '고정 상한'이라는 방법 자체가 아니라, 그 방법을 정할 때 다시 재지 않고 예전 기록을 그대로 믿은 판단이었습니다. 기록은 적어 둔 순간에는 맞는 값이었지만, 7일이 지나는 동안 실제 사용량은 그 기록보다 훨씬 커져 있었습니다. 무엇을 고칠지보다, 그 판단의 근거가 된 숫자를 언제 마지막으로 다시 쟀는지부터 확인하는 습관이 이번에 남은 교훈입니다. 숫자가 오래된 기록일수록, 결정을 내리기 전에 한 번 더 재보는 쪽이 결국 더 빠릅니다.

이 기록을 남기는 이유도 같은 맥락입니다. 지금 남은 7%나 측정 함정 두 개를 감추고 '해결했다'고만 적으면, 다음에 같은 문제를 다시 만났을 때 이번에 확인해 둔 한계를 처음부터 다시 찾아야 합니다. 무엇을 아직 못 했는지를 숫자와 함께 남겨 두는 것이, 다음번에 같은 시간을 또 쓰지 않는 가장 확실한 방법이었습니다.

비슷하게 여러 개의 AI 에이전트 세션과 예약 작업을 한 대의 PC에서 동시에 돌리다가 메모리 문제를 겪고 있다면, 저희가 실제로 어떻게 재고 고쳤는지 상담에서 더 구체적으로 나눌 수 있습니다.

같은 방식으로 우리 업무를 재보고 싶다면

이 글에 쓴 계측·판정·자동화는 저희가 실제로 매일 돌리는 방식 그대로입니다. 어떤 작업을 자동화할 수 있고 어디를 사람이 잡아야 하는지, 현재 업무 흐름을 놓고 같이 짚어 드립니다.

새 글이 올라오면 알려 드립니다

이런 계측 기록을 새로 쓰면 메일로 한 통 보냅니다. 광고나 판촉은 보내지 않습니다.