본문 바로가기

웹개발 프레임워크(must-have skills)/라라벨

[Deep Dive] 대용량 Git Diff를 다루는 기술: Go와 PHP(라라벨)의 정규식 오프셋 청킹과 메모리 엔지니어링

한 줄 요약: 10MB 대용량 Git Diff 파싱 시 발생하는 Go와 PHP의 메모리 동작 차이, 정규식 오프셋 청킹의 공학적 계산, 그리고 LazyCollection의 은탄환 환상과 트레이드오프를 심층 분석합니다.


📌 목차

  1. 프롤로그: Git Diff 파싱과 메모리 폭탄의 공포
  2. 라라벨 내장 함수로는 메모리 절감형 청킹이 불가능할까?
  3. TOP 10 프로그래밍 언어의 청킹 메커니즘과 속도 경쟁
  4. 원본 Go 버전의 반전: PikeVM을 쓰는데 왜 strings.Split을 썼을까?
  5. 64비트 엔진 레벨의 메모리 오버헤드 정밀 계산 (Go vs PHP)
  6. 의문의 10.03MB: "오프셋만 찾았는데 왜 0.03MB가 아니죠?"
  7. 은탄환은 없다: LazyCollection 도입의 치명적인 대가
  8. 에필로그: 실무 엔지니어를 위한 최종 아키텍처 제언

1. 프롤로그: Git Diff 파싱과 메모리 폭탄의 공포

수만 라인, 수백 개 파일이 변경된 대규모 PR을 AI 코딩 어시스턴트로 코드 리뷰하려면 수십 MB 크기의 git diff 텍스트를 파싱해야 합니다.

가장 단순한 접근은 explode("\n", $diff)strings.Split(diff, "\n")으로 줄 단위로 쪼갠 뒤 루프를 도는 것입니다. 하지만 이 접근법은 스크립트 언어에서 순식간에 수십~수백 MB의 힙 메모리를 집어삼키며 Fatal error: Allowed memory size exhausted 에러를 터뜨립니다.

이 문제를 해결하기 위해 고안된 기법이 바로 "정규식 오프셋 기반 파일 단위 청킹(Regex Offset Chunking)"입니다.


2. 라라벨 내장 함수로는 메모리 절감형 청킹이 불가능할까?

결론부터 말하면 라라벨 내장 함수(단일 헬퍼)만으로는 불가능합니다.

// 1. Str::of()->split() 방식
$chunks = Str::of($rawDiff)->split('/(?=^diff --(?:git|no-index) )/m');
  • 문제점: 내부적으로 preg_split()을 호출하여 분할된 모든 조각을 한꺼번에 PHP 배열과 Collection 객체로 생성합니다. 메모리가 2배 이상 복제되며, 초대용량 문자열에서 Lookahead 정규식은 PCRE 백트래킹 한계를 초과할 수 있습니다.
// 2. Str::matchAll() 방식
$matches = Str::matchAll('/^diff --(?:git|no-index) /m', $rawDiff);
  • 문제점: PREG_OFFSET_CAPTURE 플래그를 넘기지 않고 매칭된 문자열 자체만 반환하므로, 오프셋 기반 슬라이싱에 활용할 수 없습니다.

해법

결국 PHP 네이티브의 preg_match_all(..., PREG_OFFSET_CAPTURE)을 사용해 바이트 오프셋 정수 배열을 추출하고, 라라벨의 LazyCollection과 PHP 제너레이터(yield)를 결합해야만 진정한 스트리밍 청킹이 가능합니다.


3. TOP 10 프로그래밍 언어의 청킹 메커니즘과 속도 경쟁

대용량 텍스트에서 경계를 찾아 쪼갤 때, 주요 언어들은 크게 3가지 패러다임으로 나뉩니다.

[ 패러다임 1: Zero-Copy / Zero-Alloc (Rust, C#, Go, C++) ]
원본 버퍼 ────────> [ 포인터(8B) + 길이(8B) 슬라이스 ] ──> 힙 할당 0 바이트

[ 패러다임 2: Lazy Generator (Python, JS/TS, Kotlin, Ruby) ]
원본 버퍼 ────────> yield chunk[i] ──(처리 후 즉시 GC)──> 1청크 크기 유지

[ 패러다임 3: Offset Scan + Slicing (Java, PHP) ]
정규식 오프셋 추출 ──> substr() 물리 복제 ──> 언어 특성에 따라 GC/메모리 부하 발생

누가 가장 빠른가?

  • 절대적 1위: Rust (regex + &str)
    • memchr, Teddy 알고리즘 기반의 AVX-512/AVX2 SIMD 벡터 가속으로 초당 30~50GB 속도로 바이트 스캔.
    • 슬라이스(&str)는 포인터와 길이만 담는 Fat Pointer로 힙 할당 0회, GC 0회.
  • Managed 언어 1위: C# (.NET 8/9)
    • ReadOnlySpan<char>Regex.EnumerateMatches 조합.
    • ref struct 스택 할당으로 힙 할당 0 바이트(Zero Allocations) 달성.

4. 원본 Go 버전의 반전: PikeVM을 쓰는데 왜 strings.Split을 썼을까?

오리지널 open-code-review(Go 버전)의 internal/diff/parser.go를 뜯어보면 흥미로운 사실을 발견할 수 있습니다.

// open-code-review/internal/diff/parser.go
func ParseDiffText(...) ([]model.Diff, error) {
    lines := strings.Split(diffText, "\n") // 🚨 전체 diff를 통째로 분할?!
    ...
    for _, line := range lines {
        if m := diffHeaderRe.FindStringSubmatch(line); m != nil { ... }

Go의 regexp 패키지는 Russ Cox의 RE2 설계(PikeVM / OnePass DFA) 기반으로 동작하여 $O(N)$ 선형 시간을 보장합니다. 하지만 정규식 오프셋 청킹은 전혀 쓰지 않고 단순 strings.Split을 사용하고 있었습니다.

Go 작성자는 왜 일부러 오프셋 청킹을 안 했을까?

  1. Go 문자열의 Zero-Copy 특성: Go의 string은 불변 포인터 뷰(Data 8B + Len 8B)입니다. strings.Split을 해도 텍스트 본문 복사가 발생하지 않습니다.
  2. 복잡한 라인 단위 상태 머신: index 1234..5678 해시 라인 제거(LLM 프롬프트 토큰 절약), inHunk 여부에 따른 +/- 통계 계산 등 정밀한 라인 처리가 필수적이었습니다.
  3. 실용주의: 일반적인 PR diff는 수 MB 수준이므로, Go에서는 가독성 높은 strings.Split으로도 1~2MB 추가 메모리와 수 밀리초만에 충분히 끝났기 때문입니다.

5. 64비트 엔진 레벨의 메모리 오버헤드 정밀 계산 (Go vs PHP)

기준 조건: 10MB Unified Diff (100,000줄, 200개 파일 변경, x86_64 환경)

Go: strings.Split(diffText, "\n")

  • 슬라이스 헤더: 24 bytes
  • []string 백킹 배열: $100,000 \times 16 \text{ bytes} = 1,600,000 \text{ bytes} \ (\approx 1.53 \text{ MB})$
  • 문자열 본문 복제: 0 bytes (모든 슬라이스가 원본 버퍼 주소를 가리킴)
  • 👉 추가 메모리: 약 1.53 MB (원본 대비 +15.3%)

PHP 8.x: explode("\n", $rawDiff)

  • zend_array (Packed Array): $2^{17} = 131,072$ 슬롯 $\times 16\text{B} (zval) = 2,097,152 \text{ bytes} \ (\approx 2.00 \text{ MB})$
  • 10만 개의 독립된 zend_string:
    • 구조체 헤더(24B) + 본문(100B) + NUL(1B) = 125B
    • Zend Memory Manager(ZMM) 128B Size Class 할당 $\rightarrow 100,000 \times 128\text{B} = \mathbf{12.21 \text{ MB}}$
  • ZMM 페이지/단편화 오버헤드: 약 0.8 MB
  • 👉 추가 메모리: 약 15.01 MB (원본 대비 +150.1%)
    (단 한 줄 실행으로 프로세스 메모리가 10MB + 15MB = 25MB로 폭증)

6. 의문의 10.03MB: "오프셋만 찾았는데 왜 0.03MB가 아니죠?"

라라벨 포팅 버전(DiffParser.php)에서 PREG_OFFSET_CAPTURE를 적용했을 때의 메모리를 계산하면 다음과 같습니다.

방식 추가 메모리 피크 이유
현재 오프셋 청킹 배열 방식 ~10.03 MB 오프셋(0.03MB) + 200개 청크 문자열 배열(10MB)
LazyCollection (yield) 방식 ~0.08 MB 오프셋(0.03MB) + 현재 처리 중인 청크 1개(0.05MB)

10.03MB가 나오는 이유

$chunks = [];
for ($i = 0; $i < $count; $i++) {
    $chunk = substr($rawDiff, $start, $length); // 🚨 PHP는 여기서 메모리에 바이트를 새로 복사함
    $chunks[] = $trimmedChunk;                  // 🚨 200개 청크를 배열에 계속 쌓아둠
}
return $chunks;
  1. PHP의 substr()은 Go/Rust와 달리 원본 문자열을 물리적으로 복사합니다.
  2. 200개 파일 청크를 $chunks = [] 배열에 전부 담아서 리턴하므로, 루프가 끝난 시점에는 원본 10MB와 복제본 200개의 합 10MB가 메모리에 동시에 공존하게 됩니다.

0.03MB 수준으로 낮추려면 청크를 배열에 모으지 않고 yield로 1개씩 흘려보내는 제너레이터를 써야 합니다.


7. 은탄환은 없다: LazyCollection 도입의 치명적인 대가

그렇다면 메모리를 0.08MB로 줄여주는 LazyCollection을 무조건 써야 할까요? 절대 아닙니다.

1. 재순회 시 전체 정규식 재실행 (Multiple Iteration Trap)

count($allFiles);                   // 🚨 1번째: 정규식 오프셋 청킹 전체 실행
$filter->filter($allFiles);         // 🚨 2번째: 정규식 오프셋 청킹 전체 다시 실행!
$engine->review($reviewableFiles);  // 🚨 3번째: 또다시 처음부터 파싱 재실행!

LazyCollection은 단방향 스트림이므로 순회할 때마다 제너레이터가 처음부터 다시 돕니다. 이를 막으려고 ->remember()를 쓰는 순간 메모리에 모든 결과를 캐싱하여 지연 평가의 의미가 사라집니다.

2. 순수 CPU 연산 속도 저하

C 언어 레벨의 빠른 배열 순회(FE_FETCH_R)와 달리, 제너레이터는 매 항목마다 스택 프레임 컨텍스트 스위칭이 발생하여 순수 실행 속도가 2~4배 느립니다.

3. Swoole 병렬 워커와의 부조화

여러 코루틴이나 백그라운드 워커에 작업을 분배할 때 제너레이터는 쪼갤 수 없습니다. 결국 $lazyCollection->all()로 배열로 풀어야 하므로 무의미해집니다.


8. 에필로그: 실무 엔지니어를 위한 최종 아키텍처 제언

                      [ Git Diff 크기 판정 ]
                                │
               ┌────────────────┴────────────────┐
        [ 일반 PR (< 15MB) ]              [ 초대형 Diff (> 50MB) ]
               │                                 │
     현재의 Offset Chunking             LazyCollection (Stream)
     (Array 적재 방식 유지)                  (Single-Pass 파이프라인)
               │                                 │
     - 다중 순회 편의성 확보             - 메모리 피크 0.08MB 방어
     - Swoole 동시성 분배 용이          - 재순회 절대 금지
     - CPU 처리 속도 극대화             - OOM 원천 차단
  1. Go가 strings.Split을 쓴 것은 게으름이 아니라 언어적 축복이었습니다. (Zero-Copy 슬라이스 덕분에 1.5MB로 방어 가능)
  2. PHP에서 explode를 피하고 PREG_OFFSET_CAPTURE를 쓴 것은 훌륭한 엔지니어링이었습니다. (10만 개 객체 폭탄과 GC 지옥을 200개 청크로 격리)
  3. 10MB 안팎의 일반적인 워크로드라면 현재의 Offset Chunking (배열 반환) 방식이 최선입니다. 10MB RAM을 투자하여 다중 순회 편의성, 빠른 CPU 속도, Swoole 병렬 처리 호환성을 얻는 것이 훨씬 남는 장사이기 때문입니다.