Devin.KR

PHP · 심화

설계와 보안으로 깊어지는 PHP

제너레이터 - 큰 파일을 메모리 적게

yield 와 키, yield from, 지연 파이프라인, IteratorAggregate, memory_get_peak_usage 로 비교(출력은 결정적 비교 결과만)

개발자KR · 원고 갱신

이 장에서 배우는 것

수강 신청 시스템은 쌓이는 데이터가 많아질수록 느려진다. 출결 기록은 회차마다 수강생 수만큼 늘어나서, 한 학기가 지나면 파일 하나가 수만 줄이 된다. 이 장에서는 그 파일을 통째로 읽지 않고 한 줄씩 흘려보내며 처리하는 제너레이터(generator)를 다룬다. 제너레이터를 이어 붙여 지연 파이프라인을 만들고, 그 결과를 foreach 로 여러 번 쓸 수 있는 객체로 감싼 뒤, 메모리 사용량을 실제로 재서 비교한다.

  • yield 로 값과 키를 함께 내보내는 함수를 작성한다.
  • yield from 으로 다른 순회 가능한 값을 이어 붙이고, 그때 생기는 키 겹침을 이해한다.
  • 제너레이터 함수를 겹쳐서 요청이 있을 때만 일하는 지연 파이프라인을 만든다.
  • IteratorAggregate 로 제너레이터를 여러 번 순회할 수 있는 객체로 감싼다.
  • memory_get_peak_usage 로 즉시 방식과 지연 방식을 비교한다.

문제 상황

학원 원장이 학기 말에 한 가지를 요청했다. 출결 기록 파일에서 결석 기록만 뽑아 앞쪽 몇 건을 미리 보고, 출석·지각·결석 건수를 집계해 달라는 것이다. 기본서에서 배운 방식으로 짜면 보통 이렇게 된다. file() 로 모든 줄을 배열에 읽고, 각 줄을 다시 연관 배열로 바꿔 또 하나의 배열에 담은 다음 array_filter 와 count 를 쓴다.

수백 줄짜리 테스트 데이터에서는 아무 문제가 없다. 그러나 이 방식은 파일의 모든 줄과, 줄마다 만든 배열을 한꺼번에 메모리에 올린다. 필요한 것은 결석 앞 세 건뿐인데도 전체를 읽는다. 기록이 몇십만 줄이 되면 PHP 의 memory_limit 에 걸려 중간에 죽을 수 있다. 서버 메모리를 늘리는 것은 임시방편이다. 처리 방식 자체를 한 줄씩 읽고, 쓰고, 버리는 쪽으로 바꾸는 편이 낫다.

이 장에서는 같은 문제를 두 방식으로 풀어 최대 메모리 사용량을 비교한다. 출력에는 실제 바이트 수를 싣지 않는다. 바이트 수는 PHP 빌드와 환경마다 달라지기 때문에, "어느 쪽이 더 적게 썼는가" 라는 결정적인 비교 결과만 출력한다.

제너레이터의 기본: yield 와 키

함수 본문에 yield 가 하나라도 있으면 그 함수는 호출해도 본문이 실행되지 않고 Generator 객체를 돌려준다. 이 객체는 Iterator 를 구현하므로 foreach 로 순회할 수 있다. foreach 가 값을 한 번 요구할 때마다 함수는 다음 yield 까지 실행되고, 값을 내보낸 자리에서 멈춘다. 지역 변수와 실행 위치는 그대로 보존되어 있다가 다음 요구에서 이어진다.

yield 값 만 쓰면 키는 0 부터 자동으로 붙는다. 키를 직접 정하려면 yield 키 => 값 형태로 쓴다. 키는 문자열이든 정수든 상관없고 중복되어도 오류가 아니다. 이 장의 예제에서는 파일의 줄 번호를 키로 쓴다. 그러면 파이프라인 끝에서 결과를 받을 때 "이 기록이 원본 파일의 몇 번째 줄인가" 를 함께 알 수 있다.

제너레이터가 파일 핸들을 쥐고 있을 때는 try/finally 로 닫는다. 순회가 끝까지 가거나 도중에 break 로 멈춘 뒤 제너레이터 객체가 해제되면 finally 블록이 실행된다. 예외로 중단될 때도 마찬가지다. 기본서에서 다룬 예외 처리와 같은 규칙이 제너레이터 안에서도 적용된다.

제너레이터의 본문은 첫 순회가 시작될 때까지 실행되지 않는다. 따라서 fopen 실패 같은 오류도 함수를 호출하는 시점이 아니라 첫 foreach 에서 드러난다. 이 점은 뒤의 "자주 틀리는 것" 에서 다시 다룬다.

지연 파이프라인

제너레이터를 받아 제너레이터를 돌려주는 함수를 겹치면 파이프라인이 된다. 각 단계는 앞 단계에 값을 요청하고, 처리한 뒤 내보낸다. 끝의 foreach 가 값을 요구하지 않으면 어느 단계도 일하지 않는다. 이런 동작을 지연 평가(lazy evaluation)라고 한다. 이 장의 파이프라인은 네 단계다.

즉시 방식은 모든 줄을 배열에 올리고 지연 방식은 한 줄만 보유한 채 집계로 넘긴다.

각 단계가 하는 일은 다음과 같다. 입력 형식은 모두 iterable 이므로 배열이든 제너레이터든 IteratorAggregate 객체든 받을 수 있다.

파이프라인 단계와 역할
함수입력출력하는 일
readLines파일 경로줄 번호 => 문자열한 줄씩 읽고 줄바꿈을 지운다
parseRecords줄 순회줄 번호 => 연관 배열머리글을 건너뛰고 쉼표로 나눈다
onlyStatus기록 순회같은 형식지정한 출결 상태만 통과시킨다
take기록 순회같은 형식앞의 n 건만 내보내고 멈춘다

흐름을 그림으로 보면 값이 앞에서 뒤로 흐르는 것처럼 보이지만, 실제로 일을 일으키는 것은 맨 끝의 요청이다. take 가 세 건을 내보내고 끝나면 readLines 는 더 이상 호출되지 않으므로, 결석 앞 세 건을 보는 데 파일 전체를 읽을 필요가 없다.

foreach 의 요청이 파이프라인을 거슬러 올라가고 그때마다 한 건씩 흘러내려 온다.

yield from 으로 이어 붙이기

yield from 순회가능값 은 안쪽 순회 가능 값의 원소를 그대로 내보낸다. 반복문 안에서 yield 를 직접 쓰는 것과 비슷하지만, 안쪽 키를 그대로 유지한다는 점이 다르다. 안쪽이 제너레이터라면 그 제너레이터의 반환값이 yield from 식의 값이 된다.

키가 유지된다는 사실은 편리하지만 함정도 된다. 학기 파일이 둘로 나뉘어 있고 각각 줄 번호가 1 부터 시작한다면, 이어 붙인 결과에는 같은 키가 여러 번 나온다. foreach 는 키가 겹쳐도 모든 원소를 돌지만, iterator_to_array 는 기본값으로 키를 보존해서 배열을 만든다. 겹친 키는 뒤의 값이 앞의 값을 덮어쓴다. 완성 코드에서 이 차이를 숫자로 확인한다.

IteratorAggregate 로 감싸기

제너레이터 객체는 한 번 끝까지 돌면 되감을 수 없다. 이미 끝난 제너레이터를 다시 foreach 하면 예외가 난다. 같은 출결 기록을 집계에도 쓰고 미리 보기에도 쓰려면, 순회할 때마다 새 제너레이터를 만들어 주는 객체가 필요하다.

IteratorAggregate 는 getIterator() 메서드 하나만 요구하는 인터페이스다. 이 메서드가 제너레이터를 돌려주면, 객체를 foreach 에 넣을 때마다 PHP 가 getIterator() 를 호출해 새 제너레이터를 얻는다. 파일 경로만 들고 있다가 순회가 시작될 때 파일을 여므로, 객체를 만드는 비용은 거의 없다.

순회 가능한 값의 세 가지 형태 비교
형태메모리여러 번 순회사용처
배열원소 전체가능작은 데이터, 임의 접근이 필요할 때
제너레이터 객체현재 상태만불가능한 번만 훑는 단계
IteratorAggregate 객체현재 상태만가능같은 원천을 반복해서 훑을 때

완성 코드

아래 프로그램은 임시 디렉터리에 출결 파일을 만들고, 지연 방식과 즉시 방식으로 집계한 뒤 지운다. 파일은 머리글 한 줄과 50,000 줄의 기록으로 이루어지며, 각 줄은 수강 번호, 회차, 상태(P 출석, L 지각, A 결석)를 쉼표로 이은 형태다. 제너레이터 설명은 공식 문서(제너레이터)에서 사실을 확인할 수 있다.

main.php

<?php
declare(strict_types=1);

const TOTAL_ROWS = 50000;

function writeAttendanceFile(string $path, int $rows): void
{
    $handle = fopen($path, 'wb');
    if ($handle === false) {
        throw new RuntimeException("파일을 열 수 없다: {$path}");
    }
    try {
        fwrite($handle, "enrollment_id,session_no,status\n");
        for ($i = 0; $i < $rows; $i++) {
            $status = match ($i % 7) {
                0 => 'A',
                1 => 'L',
                default => 'P',
            };
            fwrite($handle, sprintf("%d,%d,%s\n", $i % 200 + 1, intdiv($i, 200) + 1, $status));
        }
    } finally {
        fclose($handle);
    }
}

function tempAttendance(int $rows): string
{
    $path = tempnam(sys_get_temp_dir(), 'att');
    if ($path === false) {
        throw new RuntimeException('임시 파일을 만들 수 없다');
    }
    writeAttendanceFile($path, $rows);
    return $path;
}

function readLines(string $path): Generator
{
    $handle = fopen($path, 'rb');
    if ($handle === false) {
        throw new RuntimeException("파일을 열 수 없다: {$path}");
    }
    try {
        $lineNo = 0;
        while (($line = fgets($handle)) !== false) {
            $lineNo++;
            yield $lineNo => rtrim($line, "\r\n");
        }
    } finally {
        fclose($handle);
    }
}

function parseRecords(iterable $lines): Generator
{
    foreach ($lines as $lineNo => $line) {
        if ($lineNo === 1 || $line === '') {
            continue;
        }
        [$enrollment, $session, $status] = explode(',', $line, 3);
        yield $lineNo => [
            'enrollment' => (int) $enrollment,
            'session' => (int) $session,
            'status' => $status,
        ];
    }
}

function onlyStatus(iterable $records, string $status): Generator
{
    foreach ($records as $key => $record) {
        if ($record['status'] === $status) {
            yield $key => $record;
        }
    }
}

function take(iterable $items, int $limit): Generator
{
    if ($limit <= 0) {
        return;
    }
    $taken = 0;
    foreach ($items as $key => $item) {
        yield $key => $item;
        if (++$taken >= $limit) {
            return;
        }
    }
}

function concatRecords(string ...$paths): Generator
{
    foreach ($paths as $path) {
        yield from parseRecords(readLines($path));
    }
}

final class AttendanceLog implements IteratorAggregate
{
    public function __construct(private readonly string $path)
    {
    }

    public function getIterator(): Generator
    {
        yield from parseRecords(readLines($this->path));
    }
}

function summarize(iterable $records): array
{
    $counts = ['P' => 0, 'L' => 0, 'A' => 0];
    foreach ($records as $record) {
        $counts[$record['status']]++;
    }
    return $counts;
}

function loadAll(string $path): array
{
    $lines = file($path, FILE_IGNORE_NEW_LINES);
    if ($lines === false) {
        throw new RuntimeException("파일을 읽을 수 없다: {$path}");
    }
    $rows = [];
    foreach ($lines as $index => $line) {
        if ($index === 0 || $line === '') {
            continue;
        }
        [$enrollment, $session, $status] = explode(',', $line, 3);
        $rows[] = [
            'enrollment' => (int) $enrollment,
            'session' => (int) $session,
            'status' => $status,
        ];
    }
    return $rows;
}

function measurePeak(callable $job): array
{
    $before = memory_get_peak_usage();
    $result = $job();
    return [$result, memory_get_peak_usage() - $before];
}

function yesNo(bool $value): string
{
    return $value ? '예' : '아니오';
}

$big = tempAttendance(TOTAL_ROWS);
$first = tempAttendance(3);
$second = tempAttendance(2);

try {
    echo '[1] 결석 기록 앞 3건', PHP_EOL;
    $absent = take(onlyStatus(parseRecords(readLines($big)), 'A'), 3);
    foreach ($absent as $lineNo => $record) {
        echo sprintf('줄 %d: 수강 %d, 회차 %d', $lineNo, $record['enrollment'], $record['session']), PHP_EOL;
    }

    echo '[2] 집계', PHP_EOL;
    $log = new AttendanceLog($big);
    $counts = summarize($log);
    echo sprintf('출석 %d, 지각 %d, 결석 %d', $counts['P'], $counts['L'], $counts['A']), PHP_EOL;

    echo '[3] 재순회', PHP_EOL;
    echo iterator_count($log), ' / ', iterator_count($log), PHP_EOL;

    echo '[4] 이어 붙이기', PHP_EOL;
    echo '이어 붙인 건수: ', iterator_count(concatRecords($first, $second)), PHP_EOL;
    echo '키를 보존한 배열 크기: ', count(iterator_to_array(concatRecords($first, $second), true)), PHP_EOL;
    echo '키를 버린 배열 크기: ', count(iterator_to_array(concatRecords($first, $second), false)), PHP_EOL;

    echo '[5] 메모리 비교', PHP_EOL;
    [$lazyCounts, $lazyPeak] = measurePeak(fn () => summarize(new AttendanceLog($big)));
    [$eagerCounts, $eagerPeak] = measurePeak(fn () => summarize(loadAll($big)));
    echo '두 방식 집계 동일: ', yesNo($lazyCounts === $eagerCounts), PHP_EOL;
    echo '지연 방식 증가량 1MiB 미만: ', yesNo($lazyPeak < 1024 * 1024), PHP_EOL;
    echo '즉시 방식 증가량 4MiB 이상: ', yesNo($eagerPeak >= 4 * 1024 * 1024), PHP_EOL;
    echo '지연 방식이 더 적게 썼다: ', yesNo($lazyPeak < $eagerPeak), PHP_EOL;
} finally {
    foreach ([$big, $first, $second] as $path) {
        unlink($path);
    }
}

줄별 해설

writeAttendanceFile 은 예제 데이터를 만든다. 한 줄씩 fwrite 하므로 이 함수 자체는 메모리를 거의 쓰지 않는다. 수강 번호는 1 에서 200 이 반복되고, 회차는 200 줄마다 하나씩 올라간다. 상태는 7 로 나눈 나머지가 0 이면 결석, 1 이면 지각, 그 밖에는 출석이다. 그래서 50,000 줄의 집계 결과는 미리 계산할 수 있다. 결석과 지각은 각각 7,143 건이고 출석은 35,714 건이다.

readLines 는 가장 바깥 단계다. fgets 가 false 를 돌려줄 때까지 반복하면서 yield $lineNo => ... 로 줄 번호와 내용을 내보낸다. 파일 핸들은 try/finally 안에서 닫는다. 앞 장에서 다룬 예외 계층을 쓰지 않고 RuntimeException 을 던지는 것은 이 장의 주제를 흐리지 않기 위해서다.

parseRecords 는 키를 그대로 받아 같은 키로 내보낸다. 머리글은 줄 번호가 1 인 줄로 판단해 건너뛴다. 그래서 첫 기록의 키는 2 가 된다. explode 의 세 번째 인수 3 은 쉼표가 더 있어도 세 조각으로 제한한다는 뜻이다.

onlyStatus 는 조건에 맞는 기록만 내보낸다. take 는 정해진 개수만큼 내보낸 직후 return 한다. 세 번째 값을 내보낸 뒤 바로 끝내기 때문에 앞 단계에서 네 번째 후보를 더 꺼내지 않는다. return 만 쓴 제너레이터는 그 자리에서 순회가 끝난다.

concatRecords 는 파일 경로를 가변 인수로 받아 각 파일의 기록을 yield from 으로 이어 붙인다. 안쪽 키가 그대로 나오므로 두 파일의 키가 겹친다. 첫 파일은 3 건이라 키가 2, 3, 4 이고, 둘째 파일은 2 건이라 키가 2, 3 이다.

AttendanceLog 는 경로만 보관한다. getIterator 가 호출될 때마다 readLines 부터 새로 시작하므로 객체를 몇 번이든 순회할 수 있다. summarize 의 인수 타입이 iterable 이라서 이 객체를 그대로 넘길 수 있다.

loadAll 은 대조군이다. file() 이 모든 줄을 배열로 읽고, 다시 줄마다 연관 배열을 만들어 또 하나의 큰 배열에 담는다. 두 배열이 동시에 메모리에 있는 구간이 있어 최대 사용량이 크게 올라간다.

measurePeak 는 작업 전의 memory_get_peak_usage() 와 작업 후의 값을 빼서, 그 작업 때문에 최대치가 얼마나 올랐는지 구한다. 최대치는 스크립트가 끝날 때까지 줄어들지 않기 때문에, 측정 순서가 중요하다. 지연 방식을 먼저 재고 즉시 방식을 나중에 재야 한다. 순서를 바꾸면 즉시 방식이 올려 놓은 최대치 아래에서 지연 방식을 재게 되어 증가량이 항상 0 으로 나온다.

마지막 비교는 바이트 수를 찍지 않고 불리언 결과만 찍는다. 1MiB, 4MiB 같은 기준은 이 예제의 크기에 맞춘 넉넉한 값이므로, 데이터 크기를 바꾸면 기준도 함께 조정해야 한다. 임시 파일은 finally 에서 지운다.

실행 결과

$ php main.php
[1] 결석 기록 앞 3건
줄 2: 수강 1, 회차 1
줄 9: 수강 8, 회차 1
줄 16: 수강 15, 회차 1
[2] 집계
출석 35714, 지각 7143, 결석 7143
[3] 재순회
50000 / 50000
[4] 이어 붙이기
이어 붙인 건수: 5
키를 보존한 배열 크기: 3
키를 버린 배열 크기: 5
[5] 메모리 비교
두 방식 집계 동일: 예
지연 방식 증가량 1MiB 미만: 예
즉시 방식 증가량 4MiB 이상: 예
지연 방식이 더 적게 썼다: 예

실무에서 자주 틀리는 것

1. 키가 겹치는 제너레이터를 배열로 바꾼다

틀린 코드는 iterator_to_array 의 기본 동작을 그대로 쓴다.

$all = iterator_to_array(concatRecords($first, $second));
echo count($all); // 3, 두 건이 사라진다

키가 필요 없다면 두 번째 인수에 false 를 준다.

$all = iterator_to_array(concatRecords($first, $second), false);
echo count($all); // 5

2. 끝난 제너레이터를 다시 순회한다

제너레이터 객체를 변수에 담아 두 번 쓰면 두 번째 foreach 에서 예외가 난다.

$records = parseRecords(readLines($big));
$counts = summarize($records);
foreach ($records as $record) { // 이미 닫힌 제너레이터라 예외
    echo $record['status'];
}

순회할 때마다 새 제너레이터를 만드는 IteratorAggregate 로 바꾼다.

$records = new AttendanceLog($big);
$counts = summarize($records);
foreach ($records as $record) {
    echo $record['status'];
}

4. 파이프라인 중간에서 배열로 모은다

단계 사이에 iterator_to_array 를 끼우면 그 지점에서 모든 기록이 메모리에 올라간다. 지연 파이프라인의 장점이 사라지고, 즉시 방식과 같은 최대 메모리를 쓰게 된다.

$parsed = iterator_to_array(parseRecords(readLines($big)));
$absent = take(onlyStatus($parsed, 'A'), 3);

제너레이터를 그대로 다음 단계에 넘긴다.

$absent = take(onlyStatus(parseRecords(readLines($big)), 'A'), 3);

3. 오류가 첫 순회에서야 드러난다

제너레이터 함수는 호출해도 본문이 실행되지 않는다. 존재하지 않는 경로를 넘겨도 함수 호출은 성공하고, 예외는 foreach 가 시작될 때 나온다. 오류를 일으킨 지점과 보고되는 지점이 멀어진다.

$lines = readLines('/no/such/file'); // 여기서는 아무 일도 없다
foreach ($lines as $line) {          // 여기서 예외
}

인수 검사는 yield 가 없는 바깥 함수에서 하고, 실제 순회는 안쪽 제너레이터에 맡긴다.

function readLinesChecked(string $path): Generator
{
    if (!is_readable($path)) {
        throw new RuntimeException("읽을 수 없다: {$path}");
    }
    return readLines($path);
}

한눈에 보기

이 장에서 쓴 문법과 함수
항목형태핵심
값 내보내기yield $값키는 0 부터 자동 부여
키 지정yield $키 => $값중복 키를 허용한다
이어 붙이기yield from $순회안쪽 키를 유지한다
배열 변환iterator_to_array($it, false)키가 겹치면 두 번째 인수를 false 로
재순회IteratorAggregate순회마다 새 제너레이터
측정memory_get_peak_usage()줄어들지 않으므로 측정 순서에 유의
즉시 방식과 지연 방식의 선택 기준
상황적합한 방식이유
데이터가 작고 여러 번 뒤적인다배열임의 접근과 정렬이 쉽다
크기를 예측하기 어렵다제너레이터메모리가 크기와 무관하다
앞쪽 일부만 필요하다제너레이터필요한 만큼만 읽는다
같은 원천을 여러 번 훑는다IteratorAggregate매번 새로 시작한다

연습 문제

  1. 회차 번호를 키로, "1회차" 같은 문자열을 값으로 내보내는 제너레이터 sessionLabels(int $last) 를 작성하라. 마지막 회차 번호를 인수로 받는다.
  2. 앞의 n 건을 건너뛰고 나머지를 내보내는 파이프라인 단계 skip(iterable $items, int $count) 를 작성하라. 키는 그대로 유지한다.
  3. 다음 코드는 두 번째 iterator_count 에서 예외가 난다. 원인을 설명하고 고쳐라.
    $records = parseRecords(readLines($path));
    echo iterator_count($records), iterator_count($records);
    
  4. take(onlyStatus(iterator_to_array(parseRecords(readLines($path))), 'A'), 3) 은 결과가 같은데도 메모리를 많이 쓴다. 이유를 설명하라.

정답과 해설

  1. 키를 지정하는 yield 를 반복문 안에서 쓴다.
    function sessionLabels(int $last): Generator
    {
        for ($session = 1; $session <= $last; $session++) {
            yield $session => "{$session}회차";
        }
    }
    
    회차 번호가 1 부터 시작하므로 키도 1 부터 나온다. 키를 생략했다면 0 부터 시작했을 것이다.
  2. 건너뛴 개수를 세면서 조건에 따라 continue 한다.
    function skip(iterable $items, int $count): Generator
    {
        $seen = 0;
        foreach ($items as $key => $item) {
            if ($seen++ < $count) {
                continue;
            }
            yield $key => $item;
        }
    }
    
    yield $key => $item 으로 원래 키를 유지해야 앞 단계의 줄 번호가 보존된다.
  3. 제너레이터 객체는 한 번 끝까지 순회하면 닫힌다. iterator_count 를 처음 호출할 때 끝까지 돌았으므로 두 번째 호출은 닫힌 제너레이터를 순회하려다 예외가 난다. new AttendanceLog($path) 처럼 IteratorAggregate 객체를 만들어 쓰면, 호출할 때마다 새 제너레이터가 생기므로 두 번 호출해도 같은 값이 나온다.
  4. iterator_to_array 가 파이프라인 중간에서 모든 기록을 배열로 모은다. 이 순간 50,000 건이 한꺼번에 메모리에 올라가고, 뒤에서 take 로 세 건만 가져가도 이미 늘어난 최대 메모리는 줄어들지 않는다. 지연 평가는 단계가 끝까지 이어질 때만 효과가 있으므로, 중간에 배열로 바꾸는 호출을 빼고 제너레이터를 그대로 넘겨야 한다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.