🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

가상 메모리 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 28. · 2 Views

가상 메모리 완벽 가이드

운영체제의 핵심 개념인 가상 메모리를 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. 요구 페이징부터 스래싱까지, 실무에서 마주치는 메모리 관리의 모든 것을 다룹니다.


목차

  1. 가상_메모리_개념
  2. 요구_페이징
  3. 페이지_폴트_처리
  4. 페이지_교체_알고리즘
  5. 프레임_할당_정책
  6. 스래싱

1. 가상 메모리 개념

김개발 씨는 입사 첫 주에 놀라운 경험을 했습니다. 8GB RAM이 장착된 개발 PC에서 무려 16GB가 넘는 데이터를 처리하는 프로그램이 정상적으로 돌아가고 있었던 것입니다.

"어떻게 이게 가능하지?"라는 의문이 머릿속을 가득 채웠습니다.

가상 메모리는 실제 물리 메모리보다 더 큰 메모리 공간을 사용할 수 있게 해주는 운영체제의 메모리 관리 기법입니다. 마치 작은 책상에서 도서관 전체의 책을 활용하는 것과 같습니다.

필요한 부분만 책상 위에 올려놓고, 나머지는 서가에 보관해두는 방식으로 동작합니다.

다음 코드를 살펴봅시다.

// 가상 메모리 주소 변환 시뮬레이션
public class VirtualMemorySimulation {
    private static final int PAGE_SIZE = 4096;  // 4KB 페이지
    private int[] pageTable;  // 페이지 테이블

    // 가상 주소를 물리 주소로 변환
    public int translateAddress(int virtualAddress) {
        int pageNumber = virtualAddress / PAGE_SIZE;
        int offset = virtualAddress % PAGE_SIZE;

        // 페이지 테이블에서 프레임 번호 조회
        int frameNumber = pageTable[pageNumber];

        // 물리 주소 계산: 프레임 번호 * 페이지 크기 + 오프셋
        return frameNumber * PAGE_SIZE + offset;
    }
}

김개발 씨는 입사 첫 주에 신기한 광경을 목격했습니다. 팀의 데이터 분석 프로그램이 무려 32GB의 데이터를 처리하고 있었는데, 정작 서버의 RAM은 16GB밖에 되지 않았던 것입니다.

분명히 물리적으로 불가능해 보이는 이 상황에 김개발 씨는 고개를 갸웃거렸습니다. 옆에 앉아 있던 박시니어 씨가 웃으며 말했습니다.

"신기하죠? 그게 바로 가상 메모리의 마법이에요." 그렇다면 가상 메모리란 정확히 무엇일까요?

쉽게 비유하자면, 가상 메모리는 마치 도서관 시스템과 같습니다. 여러분의 책상은 작지만, 도서관에는 수만 권의 책이 있습니다.

책상에는 지금 읽고 있는 책 몇 권만 올려두고, 필요할 때마다 서가에서 다른 책을 가져옵니다. 다 읽은 책은 다시 서가에 반납하고요.

이렇게 하면 작은 책상으로도 도서관 전체의 책을 활용할 수 있습니다. 컴퓨터에서도 마찬가지입니다.

**물리 메모리(RAM)**가 책상이라면, 하드디스크가 도서관 서가 역할을 합니다. 프로그램은 마치 거대한 메모리 공간을 혼자 사용하는 것처럼 느끼지만, 실제로는 운영체제가 필요한 부분만 RAM에 올려두고 나머지는 디스크에 보관하는 것입니다.

가상 메모리가 없던 시절에는 어땠을까요? 초창기 컴퓨터에서는 프로그램이 물리 메모리의 크기를 직접 알아야 했습니다.

개발자는 "이 컴퓨터에는 메모리가 64KB밖에 없으니까, 내 프로그램도 64KB 안에서 돌아가게 만들어야 해"라고 고민해야 했습니다. 게다가 여러 프로그램을 동시에 실행하려면 각 프로그램이 서로 다른 메모리 영역을 사용하도록 세심하게 조율해야 했습니다.

조금만 실수해도 한 프로그램이 다른 프로그램의 메모리를 덮어쓰는 대참사가 벌어졌습니다. 바로 이런 문제를 해결하기 위해 가상 메모리가 등장했습니다.

가상 메모리 시스템에서 각 프로세스는 자신만의 가상 주소 공간을 가집니다. 32비트 시스템에서는 4GB, 64비트 시스템에서는 이론적으로 16엑사바이트까지 가능합니다.

프로세스는 이 거대한 공간을 마음껏 사용할 수 있다고 생각하지만, 실제로는 운영체제가 페이지 테이블이라는 매핑 정보를 통해 가상 주소를 물리 주소로 변환해줍니다. 위의 코드를 살펴보겠습니다.

translateAddress 메서드는 가상 주소를 물리 주소로 변환하는 핵심 로직을 보여줍니다. 가상 주소를 페이지 크기로 나누면 페이지 번호가 나오고, 나머지가 오프셋이 됩니다.

페이지 테이블에서 해당 페이지가 어느 프레임에 저장되어 있는지 찾은 다음, 프레임 번호와 오프셋을 조합하면 실제 물리 주소가 계산됩니다. 실제 현업에서 가상 메모리는 어떻게 활용될까요?

웹 서버를 운영한다고 가정해봅시다. 수백 명의 사용자가 동시에 접속하면, 각 연결마다 별도의 메모리 공간이 필요합니다.

가상 메모리 덕분에 실제 RAM 용량을 초과하더라도 서버는 계속 동작할 수 있습니다. 물론 디스크 접근이 잦아지면 속도가 느려지겠지만, 최소한 서버가 다운되는 것은 막을 수 있습니다.

하지만 주의할 점도 있습니다. 가상 메모리를 너무 과도하게 사용하면 디스크 I/O가 급증하여 시스템 전체가 느려질 수 있습니다.

이것을 스래싱이라고 하는데, 뒤에서 자세히 다루겠습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 메모리 사용량이 RAM을 넘어도 프로그램이 돌아갔던 거군요!" 가상 메모리를 이해하면 메모리 관련 성능 문제를 진단하고 해결하는 데 큰 도움이 됩니다.

다음 섹션에서는 가상 메모리의 핵심 메커니즘인 요구 페이징에 대해 알아보겠습니다.

실전 팁

💡 - 가상 메모리 크기가 물리 메모리보다 훨씬 크다고 해서 무한정 사용하면 성능이 급격히 저하됩니다

  • top, htop 명령어로 프로세스의 가상 메모리(VIRT)와 실제 사용 메모리(RES)를 구분해서 확인하세요

2. 요구 페이징

김개발 씨가 처음 작성한 프로그램은 시작할 때마다 모든 데이터를 메모리에 올렸습니다. 실행까지 30초나 걸렸죠.

박시니어 씨는 웃으며 말했습니다. "요즘 운영체제는 그렇게 안 해요.

필요할 때 가져오는 거죠."

**요구 페이징(Demand Paging)**은 프로그램 실행에 필요한 페이지만 그때그때 메모리에 적재하는 기법입니다. 마치 넷플릭스 스트리밍처럼, 영화 전체를 다운로드하지 않고 보는 부분만 실시간으로 가져오는 것과 같습니다.

이 방식 덕분에 프로그램 시작이 빨라지고 메모리 효율이 크게 향상됩니다.

다음 코드를 살펴봅시다.

// 요구 페이징 시뮬레이션
public class DemandPaging {
    private boolean[] validBit;      // 페이지가 메모리에 있는지 표시
    private int[] frameTable;        // 페이지-프레임 매핑

    public int accessPage(int pageNumber) {
        // Valid bit 확인: 페이지가 메모리에 있는가?
        if (!validBit[pageNumber]) {
            // 페이지 폴트 발생! 디스크에서 가져와야 함
            handlePageFault(pageNumber);
        }
        // 이제 페이지가 메모리에 있음
        return frameTable[pageNumber];
    }

    private void handlePageFault(int pageNumber) {
        int freeFrame = findFreeFrame();
        loadPageFromDisk(pageNumber, freeFrame);
        validBit[pageNumber] = true;
        frameTable[pageNumber] = freeFrame;
    }
}

김개발 씨는 자신이 만든 이미지 편집 프로그램에 불만이 있었습니다. 프로그램을 실행하면 사용하지도 않을 필터 모듈까지 전부 메모리에 올리느라 시작하는 데만 한참 걸렸던 것입니다.

"다 쓰지도 않는데 왜 미리 다 올려야 하지?" 박시니어 씨가 조언했습니다. "예전에는 그랬어요.

프로그램 전체를 메모리에 올려야 실행할 수 있었거든요. 하지만 지금은 요구 페이징 덕분에 그럴 필요가 없어요." 요구 페이징이란 정확히 무엇일까요?

비유하자면, 요구 페이징은 마치 음식 배달 앱과 같습니다. 냉장고에 일주일치 식재료를 미리 채워놓는 대신, 먹고 싶을 때 그때그때 주문해서 먹는 것이죠.

피자가 먹고 싶으면 피자를 시키고, 치킨이 먹고 싶으면 치킨을 시킵니다. 미리 모든 음식을 사두면 냉장고가 꽉 차고 상하는 음식도 생기지만, 필요할 때 주문하면 항상 신선한 음식을 효율적으로 먹을 수 있습니다.

컴퓨터에서도 마찬가지입니다. 프로그램의 코드와 데이터는 페이지라는 단위로 나뉘어 있습니다.

요구 페이징에서는 프로그램이 실행될 때 모든 페이지를 메모리에 올리지 않습니다. 대신 CPU가 어떤 페이지에 접근하려고 할 때, 그 페이지가 메모리에 없으면 그때서야 디스크에서 가져옵니다.

이것이 가능한 비밀은 Valid Bit에 있습니다. 페이지 테이블의 각 항목에는 해당 페이지가 현재 메모리에 있는지 없는지를 나타내는 1비트 플래그가 있습니다.

CPU가 주소를 변환하려고 페이지 테이블을 참조할 때, Valid Bit가 1이면 해당 페이지는 메모리에 있으므로 바로 접근할 수 있습니다. 하지만 Valid Bit가 0이면, 페이지 폴트가 발생하고 운영체제가 개입하여 해당 페이지를 디스크에서 읽어옵니다.

위의 코드를 살펴보면, accessPage 메서드에서 먼저 validBit 배열을 확인합니다. 페이지가 메모리에 없으면(false) handlePageFault가 호출되어 디스크에서 페이지를 가져온 후 Valid Bit를 true로 설정합니다.

이 과정이 바로 요구 페이징의 핵심입니다. 요구 페이징의 장점은 무엇일까요?

첫째, 프로그램 시작 시간이 빨라집니다. 전체를 로드하지 않고 필요한 부분만 로드하므로, 사용자는 프로그램이 훨씬 빨리 시작되는 것처럼 느낍니다.

둘째, 메모리 사용량이 줄어듭니다. 사용하지 않는 기능의 코드는 메모리를 차지하지 않습니다.

셋째, 더 많은 프로그램을 동시에 실행할 수 있습니다. 실제 현업에서는 어떻게 활용될까요?

대규모 게임을 생각해보세요. 100GB짜리 게임이 있다면, 게임 전체를 메모리에 올리는 것은 불가능합니다.

요구 페이징 덕분에 현재 플레이하는 맵의 데이터만 메모리에 있고, 다른 맵은 필요할 때 로드됩니다. 이것이 가능한 이유는 프로그램 실행에서 **지역성(Locality)**이 있기 때문입니다.

즉, 한 번 접근한 데이터는 곧 다시 접근할 가능성이 높고, 근처의 데이터도 접근할 가능성이 높습니다. 하지만 주의할 점도 있습니다.

페이지 폴트가 너무 자주 발생하면 성능이 크게 저하됩니다. 디스크 접근은 메모리 접근보다 수십만 배 느리기 때문입니다.

따라서 요구 페이징이 효율적으로 동작하려면 페이지 폴트 비율을 최소화해야 합니다. 김개발 씨는 이해가 되었습니다.

"아, 그래서 요즘 프로그램들이 빨리 시작되는 거군요. 다 쓰지도 않을 걸 미리 안 올리니까요!"

실전 팁

💡 - 프로그램의 지역성을 높이도록 코드를 작성하면 페이지 폴트를 줄일 수 있습니다

  • 자주 사용하는 데이터는 메모리에 상주하도록 설계하세요

3. 페이지 폴트 처리

김개발 씨의 프로그램이 갑자기 느려졌습니다. 모니터링 도구를 보니 페이지 폴트가 급증하고 있었습니다.

"페이지 폴트가 뭔데 이렇게 느려지는 거야?" 박시니어 씨가 차근차근 설명을 시작했습니다.

페이지 폴트는 CPU가 접근하려는 페이지가 물리 메모리에 없을 때 발생하는 인터럽트입니다. 마치 도서관에서 책을 찾았는데 서가에 없어서 사서에게 창고에서 가져다 달라고 요청하는 것과 같습니다.

운영체제는 이 요청을 받아 디스크에서 해당 페이지를 읽어와 메모리에 적재합니다.

다음 코드를 살펴봅시다.

// 페이지 폴트 처리 과정
public class PageFaultHandler {
    public void handlePageFault(int faultingPage, Process process) {
        // 1. 현재 프로세스 상태 저장
        saveProcessState(process);

        // 2. 빈 프레임 찾기 (없으면 페이지 교체 필요)
        int freeFrame = memoryManager.findFreeFrame();
        if (freeFrame == -1) {
            freeFrame = pageReplacer.selectVictim();
            swapOutPage(freeFrame);  // 희생 페이지를 디스크로
        }

        // 3. 디스크에서 페이지 읽어오기 (가장 오래 걸림!)
        diskIO.readPage(faultingPage, freeFrame);

        // 4. 페이지 테이블 업데이트
        process.pageTable[faultingPage] = freeFrame;
        process.validBit[faultingPage] = true;

        // 5. 프로세스 재개
        resumeProcess(process);
    }
}

어느 날 김개발 씨가 작성한 데이터 분석 프로그램이 갑자기 엄청나게 느려졌습니다. 평소에는 1분이면 끝나던 작업이 10분이 넘도록 진행 중이었습니다.

시스템 모니터를 열어보니 "Page Faults"라는 항목의 숫자가 무섭게 올라가고 있었습니다. 박시니어 씨가 화면을 보더니 말했습니다.

"아, 페이지 폴트가 폭발하고 있네요. 이러면 느려질 수밖에 없어요." 페이지 폴트란 무엇일까요?

비유하자면, 페이지 폴트는 마치 도서관에서 책을 찾는 상황과 같습니다. 여러분이 서가에 가서 책을 찾았는데, 그 자리에 책이 없습니다.

다른 누군가가 빌려갔거나, 창고에 보관 중인 것이죠. 이때 사서에게 요청하면, 사서가 창고에 가서 책을 찾아옵니다.

그동안 여러분은 기다려야 합니다. 이 기다리는 시간이 바로 페이지 폴트의 비용입니다.

컴퓨터에서는 이런 과정이 일어납니다. CPU가 메모리의 어떤 주소에 접근하려고 합니다.

먼저 페이지 테이블을 확인하는데, Valid Bit가 0입니다. 이는 해당 페이지가 물리 메모리에 없다는 뜻입니다.

이 순간 페이지 폴트 인터럽트가 발생하고, CPU의 제어권은 운영체제로 넘어갑니다. 페이지 폴트 처리는 여러 단계를 거칩니다.

위의 코드에서 보듯이, 먼저 현재 프로세스의 상태를 저장합니다. 그 다음 빈 프레임을 찾습니다.

빈 프레임이 없으면 기존 페이지 중 하나를 내보내야 합니다. 이것을 페이지 교체라고 합니다.

그 다음 디스크에서 필요한 페이지를 읽어옵니다. 이 단계가 가장 오래 걸립니다.

디스크 접근은 메모리 접근보다 약 10만 배나 느리기 때문입니다. 페이지가 메모리에 올라오면 페이지 테이블을 업데이트하고, Valid Bit를 1로 설정합니다.

마지막으로 중단되었던 프로세스를 다시 실행합니다. CPU는 마치 아무 일도 없었던 것처럼 원래 하려던 메모리 접근을 다시 시도하고, 이번에는 성공합니다.

페이지 폴트의 비용이 얼마나 클까요? 메모리 접근 시간이 약 100나노초라면, 페이지 폴트 처리 시간은 약 10밀리초입니다.

10만 배 차이입니다. 만약 1000번의 메모리 접근 중 1번만 페이지 폴트가 발생해도, 전체 평균 접근 시간은 10배 이상 늘어납니다.

이것이 페이지 폴트 비율을 최소화해야 하는 이유입니다. 실제 현업에서는 어떤 상황에서 페이지 폴트가 급증할까요?

예를 들어, 데이터베이스에서 인덱스 없이 전체 테이블을 스캔하면 순차적으로 엄청난 양의 데이터에 접근합니다. 이 데이터가 메모리보다 크면 페이지 폴트가 폭발합니다.

또는 메모리 누수가 있는 프로그램이 점점 많은 메모리를 차지하면, 다른 프로그램들의 페이지가 쫓겨나면서 페이지 폴트가 증가합니다. 김개발 씨는 자신의 프로그램을 분석해보았습니다.

거대한 배열을 랜덤하게 접근하고 있었던 것입니다. 순차 접근으로 바꾸자 페이지 폴트가 크게 줄어들었습니다.

"아, 메모리 접근 패턴이 이렇게 중요한 거였구나!"

실전 팁

💡 - vmstat, iostat 명령어로 시스템의 페이지 폴트 현황을 모니터링하세요

  • 데이터 접근 패턴을 순차적으로 만들면 페이지 폴트를 크게 줄일 수 있습니다

4. 페이지 교체 알고리즘

김개발 씨가 박시니어 씨에게 물었습니다. "메모리가 꽉 차면 어떤 페이지를 내보내야 해요?" 박시니어 씨는 웃으며 답했습니다.

"좋은 질문이에요. 그걸 결정하는 게 바로 페이지 교체 알고리즘이에요.

선택을 잘못하면 성능이 확 떨어지거든요."

페이지 교체 알고리즘은 메모리가 가득 찼을 때 어떤 페이지를 내보낼지 결정하는 방법입니다. FIFO는 가장 오래된 페이지를, LRU는 가장 오랫동안 사용하지 않은 페이지를, Optimal은 앞으로 가장 오래 사용하지 않을 페이지를 선택합니다.

좋은 알고리즘을 선택하면 페이지 폴트를 크게 줄일 수 있습니다.

다음 코드를 살펴봅시다.

// 페이지 교체 알고리즘 비교
public class PageReplacementAlgorithms {
    // FIFO: 가장 먼저 들어온 페이지를 교체
    public int fifoReplace(Queue<Integer> frameQueue) {
        return frameQueue.poll();  // 큐의 맨 앞(가장 오래된) 페이지
    }

    // LRU: 가장 오랫동안 사용하지 않은 페이지를 교체
    public int lruReplace(Map<Integer, Long> lastUsedTime) {
        return lastUsedTime.entrySet().stream()
            .min(Map.Entry.comparingByValue())
            .map(Map.Entry::getKey)
            .orElse(-1);
    }

    // Optimal: 앞으로 가장 늦게 사용될 페이지를 교체 (이론적 최적해)
    public int optimalReplace(int[] futureReferences, Set<Integer> frames) {
        int victim = -1, farthest = -1;
        for (int frame : frames) {
            int nextUse = findNextUse(frame, futureReferences);
            if (nextUse > farthest) {
                farthest = nextUse;
                victim = frame;
            }
        }
        return victim;
    }
}

김개발 씨는 새로운 고민에 빠졌습니다. 메모리가 가득 찬 상황에서 새 페이지를 올려야 하는데, 도대체 어떤 페이지를 내보내야 할까요?

아무거나 내보냈다가 바로 다시 필요해지면 또 페이지 폴트가 발생할 텐데요. 박시니어 씨가 화이트보드 앞으로 김개발 씨를 이끌었습니다.

"자, 이걸 페이지 교체 문제라고 해요. 어떤 페이지를 희생양으로 삼을지 결정하는 거죠.

여러 알고리즘이 있는데, 하나씩 살펴볼까요?" 먼저 가장 단순한 FIFO(First-In First-Out) 알고리즘입니다. 이름 그대로, 가장 먼저 메모리에 들어온 페이지를 가장 먼저 내보냅니다.

마치 줄 서기와 같습니다. 가장 오래 줄 서 있던 사람이 먼저 빠지는 것이죠.

구현이 매우 간단합니다. 큐에 페이지를 넣고, 교체가 필요하면 큐의 맨 앞에서 꺼내면 됩니다.

하지만 FIFO에는 문제가 있습니다. 오래 전에 들어왔지만 계속 사용 중인 페이지도 내보내버립니다.

마치 도서관에서 가장 오래된 책을 폐기하는데, 그게 매일 10명씩 빌려가는 인기 도서라면 곤란하겠죠? 게다가 Belady의 모순이라는 이상한 현상도 있습니다.

메모리를 더 늘렸는데 오히려 페이지 폴트가 더 많아지는 경우가 생길 수 있습니다. 다음은 LRU(Least Recently Used) 알고리즘입니다.

가장 오랫동안 사용하지 않은 페이지를 내보냅니다. 최근에 사용한 페이지는 곧 다시 사용될 가능성이 높다는 시간적 지역성 원리를 활용한 것입니다.

마치 옷장 정리할 때 "이 옷은 1년 동안 안 입었으니까 버리자"라고 결정하는 것과 같습니다. LRU는 FIFO보다 훨씬 좋은 성능을 보입니다.

하지만 구현이 복잡합니다. 각 페이지가 마지막으로 사용된 시간을 기록해야 하고, 교체 시 가장 오래된 시간을 찾아야 합니다.

실제 운영체제에서는 정확한 LRU 대신 근사 LRU 알고리즘을 많이 사용합니다. 마지막으로 Optimal 알고리즘입니다.

앞으로 가장 오랫동안 사용되지 않을 페이지를 내보냅니다. 이론적으로 가장 적은 페이지 폴트를 발생시키는 최적의 방법입니다.

하지만 문제가 있습니다. 미래를 알아야 합니다!

어떤 페이지가 언제 다시 사용될지 미리 알 수 없기 때문에, 실제로는 구현이 불가능합니다. 그래서 Optimal은 다른 알고리즘의 성능을 평가하는 기준으로만 사용됩니다.

위의 코드를 보면, FIFO는 단순히 큐에서 꺼내고, LRU는 마지막 사용 시간이 가장 오래된 것을 찾고, Optimal은 미래 참조 배열을 보고 가장 늦게 사용될 페이지를 찾습니다. 실제 현업에서는 어떤 알고리즘을 사용할까요?

대부분의 운영체제는 LRU의 근사 버전을 사용합니다. Linux는 Clock 알고리즘의 변형을 사용하고, Windows는 Working Set 기반의 알고리즘을 사용합니다.

데이터베이스 시스템에서도 버퍼 풀 관리에 LRU 변형을 많이 사용합니다. 김개발 씨가 물었습니다.

"그러면 무조건 LRU가 좋은 건가요?" 박시니어 씨가 고개를 저었습니다. "항상 그렇지는 않아요.

순차적으로 한 번씩만 접근하는 경우에는 LRU가 FIFO보다 나을 게 없어요. 워크로드 특성에 따라 다르답니다."

실전 팁

💡 - 대부분의 상황에서 LRU 또는 LRU 근사 알고리즘이 좋은 선택입니다

  • 순차 스캔이 많은 워크로드에서는 LRU가 오히려 비효율적일 수 있으니 주의하세요

5. 프레임 할당 정책

회사에 새 프로젝트가 생겼습니다. 여러 팀이 같은 서버를 공유해야 하는데, 김개발 씨 팀이 메모리를 너무 많이 써서 다른 팀의 프로그램이 느려졌습니다.

"메모리도 공정하게 나눠야 하지 않나요?" 이 질문에 박시니어 씨가 프레임 할당 정책을 설명하기 시작했습니다.

프레임 할당 정책은 여러 프로세스에게 물리 메모리 프레임을 어떻게 분배할지 결정하는 방법입니다. 균등 할당은 모든 프로세스에게 동일한 수의 프레임을 주고, 비례 할당은 프로세스 크기에 비례하여 프레임을 배분합니다.

또한 전역 교체와 지역 교체 정책에 따라 페이지 교체 범위도 달라집니다.

다음 코드를 살펴봅시다.

// 프레임 할당 정책 구현
public class FrameAllocationPolicy {
    private int totalFrames;
    private List<Process> processes;

    // 균등 할당: 모든 프로세스에게 동일하게 분배
    public Map<Process, Integer> equalAllocation() {
        int framesPerProcess = totalFrames / processes.size();
        Map<Process, Integer> allocation = new HashMap<>();
        for (Process p : processes) {
            allocation.put(p, framesPerProcess);
        }
        return allocation;
    }

    // 비례 할당: 프로세스 크기에 비례하여 분배
    public Map<Process, Integer> proportionalAllocation() {
        long totalSize = processes.stream()
            .mapToLong(Process::getVirtualMemorySize).sum();
        Map<Process, Integer> allocation = new HashMap<>();
        for (Process p : processes) {
            int frames = (int)(totalFrames * p.getVirtualMemorySize() / totalSize);
            allocation.put(p, Math.max(frames, MIN_FRAMES));
        }
        return allocation;
    }
}

회사에서 큰 문제가 발생했습니다. 마케팅 팀의 분석 프로그램, 개발 팀의 빌드 서버, 운영 팀의 모니터링 도구가 모두 같은 서버에서 돌아가고 있었는데, 마케팅 팀의 프로그램이 거대한 데이터셋을 처리하느라 메모리를 엄청나게 사용했습니다.

그 바람에 다른 팀의 프로그램들이 자꾸 페이지 폴트에 시달리며 느려졌습니다. 박시니어 씨가 말했습니다.

"이건 프레임 할당 정책의 문제예요. 어떤 프로세스에게 메모리를 얼마나 줄 것인가를 결정해야 해요." 가장 단순한 방법은 **균등 할당(Equal Allocation)**입니다.

100개의 프레임이 있고 10개의 프로세스가 있다면, 각 프로세스에게 10개씩 줍니다. 공평하죠?

하지만 문제가 있습니다. 작은 메모장 프로그램과 거대한 데이터베이스 서버에게 똑같은 메모리를 주는 게 과연 합리적일까요?

그래서 **비례 할당(Proportional Allocation)**이 등장했습니다. 프로세스의 크기에 비례하여 프레임을 배분합니다.

가상 메모리가 100MB인 프로세스와 1GB인 프로세스가 있다면, 후자에게 10배 더 많은 프레임을 줍니다. 이렇게 하면 큰 프로세스는 큰 대로, 작은 프로세스는 작은 대로 적절한 메모리를 받습니다.

하지만 비례 할당에도 한계가 있습니다. 프로세스의 크기가 크다고 해서 실제로 그만큼의 메모리가 항상 필요한 것은 아닙니다.

어떤 프로세스는 크기는 크지만 실제로 활발하게 사용하는 부분은 작을 수 있습니다. 그래서 실제 운영체제에서는 Working Set 개념을 활용합니다.

프로세스가 특정 시간 동안 실제로 참조한 페이지들의 집합을 Working Set이라고 하고, 이 크기에 맞게 프레임을 할당합니다. 또 다른 중요한 결정이 있습니다.

페이지 교체가 필요할 때, **전역 교체(Global Replacement)**를 할 것인가, **지역 교체(Local Replacement)**를 할 것인가입니다. 전역 교체에서는 모든 프로세스의 프레임 중에서 희생자를 선택합니다.

이렇게 하면 전체적으로 가장 효율적인 선택을 할 수 있지만, 한 프로세스가 다른 프로세스의 프레임을 빼앗을 수 있습니다. 지역 교체에서는 자기 자신의 프레임 중에서만 희생자를 선택합니다.

다른 프로세스에게 피해를 주지 않지만, 전체적인 효율은 떨어질 수 있습니다. 위의 코드에서 equalAllocation은 단순히 총 프레임 수를 프로세스 수로 나누고, proportionalAllocation은 각 프로세스의 가상 메모리 크기 비율에 따라 프레임을 계산합니다.

MIN_FRAMES를 보장하는 것도 중요한데, 너무 적은 프레임으로는 프로세스가 제대로 실행될 수 없기 때문입니다. 김개발 씨는 이제 이해가 되었습니다.

"아, 그래서 컨테이너 환경에서 메모리 제한을 거는 거군요. 한 컨테이너가 너무 많은 메모리를 쓰면 다른 컨테이너가 피해를 보니까요."

실전 팁

💡 - 컨테이너 환경에서는 cgroup을 통해 메모리 제한을 설정하여 프레임 할당을 제어할 수 있습니다

  • 프로세스의 실제 메모리 사용 패턴을 모니터링하여 적절한 할당량을 결정하세요

6. 스래싱

어느 날 서버가 완전히 먹통이 되었습니다. CPU 사용률은 100%인데 아무것도 진행되지 않았습니다.

김개발 씨가 당황해서 서버를 재시작하려는데, 박시니어 씨가 말렸습니다. "잠깐, 이건 스래싱이에요.

왜 이런 일이 생기는지 알아야 다음에 예방할 수 있어요."

**스래싱(Thrashing)**은 프로세스가 실제 작업보다 페이지 교체에 더 많은 시간을 쓰는 현상입니다. 마치 책상이 너무 좁아서 책을 읽는 시간보다 책을 찾고 치우는 시간이 더 긴 것과 같습니다.

시스템이 스래싱에 빠지면 CPU는 바쁘게 돌아가지만 실제 처리량은 거의 0에 가까워집니다.

다음 코드를 살펴봅시다.

// 스래싱 감지 및 대응
public class ThrashingMonitor {
    private static final double THRASHING_THRESHOLD = 0.5;
    private Queue<Long> pageFaultTimes = new LinkedList<>();

    public boolean detectThrashing() {
        // 최근 페이지 폴트 빈도 계산
        long now = System.currentTimeMillis();
        pageFaultTimes.add(now);
        while (!pageFaultTimes.isEmpty() &&
               now - pageFaultTimes.peek() > 1000) {
            pageFaultTimes.poll();
        }

        // 초당 페이지 폴트가 임계값 초과시 스래싱
        double pageFaultRate = pageFaultTimes.size() / 1000.0;
        return pageFaultRate > THRASHING_THRESHOLD;
    }

    public void handleThrashing() {
        // 스래싱 대응: 프로세스 수 줄이기
        Process victim = selectProcessToSuspend();
        victim.suspend();  // 프로세스 일시 중지
        swapOutProcess(victim);  // 전체를 디스크로
    }
}

그날의 악몽을 김개발 씨는 잊을 수 없습니다. 점심시간이 끝나고 돌아오니 서버가 완전히 멈춰 있었습니다.

SSH 접속조차 되지 않았고, 웹 서비스는 타임아웃이 났습니다. 간신히 콘솔에 접속해서 상태를 확인해보니, CPU 사용률은 100%인데 웹 요청은 하나도 처리되지 않고 있었습니다.

박시니어 씨가 모니터를 들여다보며 말했습니다. "전형적인 스래싱 증상이에요.

메모리가 부족해서 시스템이 페이지 교체만 하느라 정작 일을 못 하는 거예요." 스래싱은 어떻게 발생할까요? 비유하자면, 스래싱은 마치 아주 작은 책상에서 여러 권의 책을 동시에 보려는 상황과 같습니다.

책상에는 책 한 권밖에 올려놓을 수 없는데, 국어, 수학, 영어 책을 번갈아 봐야 합니다. 국어책을 보다가 수학 문제를 풀어야 해서 국어책을 치우고 수학책을 올립니다.

잠시 후 영어 단어를 확인해야 해서 수학책을 치우고 영어책을 올립니다. 그런데 바로 국어 지문을 다시 봐야 해서...

이러다 보면 실제 공부하는 시간보다 책을 바꾸는 시간이 더 길어집니다. 컴퓨터에서도 똑같은 일이 벌어집니다.

프로세스가 필요로 하는 Working Set의 총합이 사용 가능한 물리 메모리보다 크면, 끊임없이 페이지 폴트가 발생합니다. 페이지를 메모리에 올려도 금방 다른 페이지에게 자리를 내줘야 하고, 내보낸 페이지가 바로 다시 필요해집니다.

이렇게 되면 CPU는 쉴 새 없이 페이지 폴트를 처리하느라 실제 프로세스 명령어를 실행할 시간이 없어집니다. 아이러니한 것은, 운영체제가 상황을 더 악화시킬 수 있다는 점입니다.

CPU 사용률이 낮으면 운영체제는 "일할 프로세스가 부족하군"이라고 판단하고 새 프로세스를 더 실행합니다. 하지만 실제로는 CPU가 페이지 폴트 처리에 바빠서 낮게 보이는 것입니다.

새 프로세스가 추가되면 메모리 경쟁은 더 심해지고, 스래싱은 더욱 악화됩니다. 스래싱을 예방하고 대응하는 방법은 무엇일까요?

첫째, Working Set 모델을 활용합니다. 각 프로세스의 Working Set 크기를 추적하고, 총합이 물리 메모리를 초과하지 않도록 프로세스 수를 제한합니다.

둘째, Page Fault Frequency(PFF) 전략을 사용합니다. 프로세스의 페이지 폴트 비율을 모니터링하다가 너무 높아지면 프레임을 더 할당하고, 너무 낮으면 프레임을 회수합니다.

위의 코드에서 detectThrashing은 최근 1초간의 페이지 폴트 빈도를 계산합니다. 이 값이 임계값을 초과하면 스래싱으로 판단합니다.

handleThrashing에서는 프로세스 하나를 선택해서 일시 중지시키고 디스크로 내보냅니다. 이렇게 하면 남은 프로세스들이 더 많은 프레임을 사용할 수 있어서 스래싱에서 벗어날 수 있습니다.

실제 현업에서는 어떻게 대응할까요? 클라우드 환경에서는 메모리 사용량이 임계값을 넘으면 자동으로 스케일 아웃하거나 알람을 발생시킵니다.

Docker나 Kubernetes에서는 메모리 제한을 설정하여 한 컨테이너가 과도한 메모리를 사용하는 것을 방지합니다. 또한 OOM Killer가 메모리를 과도하게 사용하는 프로세스를 강제 종료하기도 합니다.

김개발 씨는 그날 이후로 서버의 메모리 사용량을 주시하게 되었습니다. 스왑 사용량이 급증하거나 페이지 폴트가 늘어나면 바로 대응하게 되었죠.

"스래싱을 미리 알아차리는 게 중요하군요. 빠지고 나면 빠져나오기가 정말 힘드니까요."

실전 팁

💡 - free, vmstat 명령어로 스왑 사용량과 페이지 폴트를 모니터링하세요

  • 스왑 사용량이 급격히 증가하면 스래싱의 전조일 수 있으니 빠르게 대응하세요
  • 컨테이너 환경에서는 메모리 제한을 적절히 설정하여 스래싱을 예방하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#OperatingSystem#VirtualMemory#Paging#MemoryManagement#PageReplacement#Operating System

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.