이번 주 공부 발표

쌓을 것과
쌓지 않을 것

Stacked PR · Scala와 꼬리 재귀 · 그리고 화석 하나
2026-08-29 · Rami
오프닝

이번 주는 스택 이야기 셋

쌓는다
Stacked PR
리뷰는 작은 단위로 쌓는다.
이번 주에 실제로 머지한
PR 체인이 사례다.
안 쌓는다
Tail Recursion
호출 스택은 쌓지 않는다.
재귀를 반복으로 바꾸는
누산기 이야기.
걷어낸다
df.cache()
아무도 읽지 않는 사본은
쌓아 두지 않는다.
172GB 화석 제거기.
1부 · Stacked PR

기능이 크다고 PR도 커야 하는 것은 아니다

+3,000 lines
-800 lines
47 files changed

// 설계 + 구현 + 마이그레이션
// + 테스트가 한 diff에
앞부분의 설계가 바뀌면
뒷부분의 리뷰가 무의미해진다
PR 1
이 인터페이스 설계가 적절한가?
PR 2
구현이 인터페이스의 계약을 지키는가?
PR 3
기존 파이프라인에 올바르게 연결됐는가?
각 PR이 하나의 질문에 답한다
1부 · Stacked PR

원리는 하나 — 브랜치는 포인터다

AB main CD feature-a (PR 1: base = main) EF feature-b (PR 2: base = feature-a) PR 2의 diff는 E와 F뿐 base와 head의 공통 조상 이후, head에 추가된 변경만 보인다
harammm.me — Stacked PR
1부 · Stacked PR

앞이 바뀌면 뒤를 다시 쌓는다 — restack

// 앞 PR이 수정됐다 (커밋 G)
B──C──D──G        add-bread
      \
       E──F       add-peanut-butter

// 후속 커밋을 새 tip 위로
git rebase add-bread
B──C──D──G──E'──F'

// 해시가 바뀌었으니
git push --force-with-lease
// squash·rebase merge 후:
// 후속 커밋만 명시적으로 옮긴다

git rebase --onto \
  origin/main \      새 기반
  add-bread \        제외할 기반
  add-peanut-butter  옮길 브랜치
비용도 있다 — restack 연쇄, 중복 충돌, merge 순서.
의존 관계가 있는 큰 기능일 때만 쓴다.
1부 · Stacked PR

이번 주에 실제로 머지한 스택

data-pipeline #20636 커넥터 세대별 리더 8/24 머지 #20577 MongoDB 읽기 파이프라인 8/25 머지 · DATA-2735 완료 data-portal #2160 서버 transpile 주입·검증 8/25 머지 #2175 클라이언트 폼 8/25 머지 비용도 실제로 치렀다 — base가 main이 아니라 풀 CI가 안 돈다 로컬에서 spark 3.2·3.5 양축 빌드와 E2E로 공백을 메웠다 (양축 각 192개 통과)
2부 · Scala와 꼬리 재귀

계기 — 리스트 뒤집기 (S-99 P05)

// 처음 쓴 소박한 재귀
def reverse[A](ls: List[A]): List[A] =
  ls match {
    case Nil          => Nil
    case head :: tail =>
      reverse(tail) :+ head
  }

// 동작은 한다. 그런데…
reverse(tail)이 끝난 뒤에도 :+ head라는 일이 남아 있다.
재귀 호출이 함수의 마지막 작업이 아니다.
이게 왜 문제일까?
이 위화감에서 꼬리 재귀 공부가 시작됐다.
2부 · Scala와 꼬리 재귀

List는 비어 있거나, 머리와 꼬리가 있다

List("red", "green") = "red" :: "green" :: Nil "red" "green" Nil head와 tail의 사슬, 끝은 반드시 Nil
// 개념적으로는 재귀 ADT
enum Chain[+A]:
  case End                  // 멈추는 모양
  case Link(head: A,
            tail: Chain[A]) // 다시 자신
정의가 재귀적이니, 다루는 도구도 자연히
패턴 매칭(모양 구분)과 재귀(tail로 전진)다.
harammm.me — Scala for S-99
2부 · Scala와 꼬리 재귀

일반 재귀 — 스택이 기억해야 하는 것

def length[A](xs: List[A]): Int =
  xs match {
    case Nil       => 0
    case _ :: tail =>
      1 + length(tail)
  }
호출이 끝나도 바로 반환하지 못한다.
"나중에 할 + 1"을 스택이 기억해야 한다.
length(List(1, 2, 3))
= 1 + length(List(2, 3))
= 1 + (1 + length(List(3)))
= 1 + (1 + (1 + length(Nil)))

// 미룬 계산이 스택에 쌓인다
// 입력이 크면 StackOverflowError
S-99 P04 — 직접 푼 코드 · harammm.me
2부 · Scala와 꼬리 재귀

꼬리 재귀 — 남은 계산을 누산기로 넘긴다

@tailrec
def loop(remaining: List[A],
         count: Int): Int =
  remaining match {
    case Nil       => count
    case _ :: tail =>
      loop(tail, count + 1)
  }
// 호출 뒤에 남은 일이 없다
loop(List(1, 2, 3), 0)
loop(List(2, 3),    1)
loop(List(3),       2)
loop(Nil,           3)  → 3

// remaining = tail
// count = count + 1
// 처음으로 이동 — 그냥 루프다
호출 스택이 O(n)에서 O(1)이 된다
S-99 P05 최종 풀이도 이 모양 — 소박한 버전은 주석으로 남겨 뒀다
2부 · Scala와 꼬리 재귀

@tailrec은 스위치가 아니라 검사 장치다

최적화는 컴파일러가 알아서 한다
@tailrec의 역할은 꼬리 재귀 형태임을 보증하게 만드는 것. 수정하다 재귀 호출 뒤에 계산이 생기면, 조용히 일반 재귀로 돌아가는 대신 컴파일 오류가 난다.
한계도 정확히
JVM에서는 자기 자신을 직접 호출할 때 적용된다. 두 함수가 서로를 부르는 상호 재귀는 일반적으로 최적화되지 않는다.
복잡도까지 바뀐다 — 리스트 뒤집기
reverse(tail) :+ head는 비꼬리에 끝 붙이기 비용까지 더해 O(n²)이 될 수 있고, 누산기 방식 loop(tail, head :: result)는 앞 붙이기 O(1)로 전체 O(n)이다.
3부 · cache는 흔적 기관

캐시 이론 30초 — 언제 정당한가

Spark는 게으르다. transformation은 기록만 하고 action이 DAG를 실행한다 — cache는 그 사이에 사본을 끼워 넣는 장치다.
정당 — 액션이 둘 Mongo cache storage pool 사본 액션 1 · S3 parquet 액션 2 · BigQuery 스캔은 1회, 두 번째 액션이 캐시를 읽는다 낭비 — 액션이 하나 Mongo cache 읽는 사람 없음 액션 1 · BigQuery 직렬화 CPU + 메모리 점유 + GC 압박만 남는다 cache() = persist(MEMORY_AND_DISK) — level은 사본의 위치만 바꾼다. 기준은 액션 수다.
3부 · cache는 흔적 기관

두 번 쓴다는데, 코드는 한 번만 쓴다

문서와 주석의 기억
"parquet과 BigQuery 두 번 쓰기 때문에 df.cache()로 컬렉션 전체를 메모리에 잡는다"
그런데
현재 코드
쓰기는 BigQuery 한 번뿐. 그런데 df.cache()는 여전히 있다.
// MongoDumpParquet.scala

.transform(df =>
  if (transferBigQuery)
    df.cache()
  else df)

// 8/25 Wrapup:
// "v10에서는 필요 없지 않나?"
// → 존재 이유를 조사하기 시작
3부 · cache는 흔적 기관

같은 코드, 반대가 된 뜻

2022 — 원산지 본업: S3 parquet + Hive 등록 (항상 실행) 옵션: BigQuery 전송 = 액션이 둘 가드의 뜻: "두 번째 액션이 생길 때만 캐시" — 정당 2025-08-05 — PR #8996 parquet 경로 제거. cache는 그대로 생존 액션이 하나가 됐다 같은 가드의 뜻: "유일한 액션 앞에서 항상 캐시" 조건식은 한 글자도 안 바뀌었는데 뜻이 반대가 됐다 지워진 블록이 cache 아랫줄부터 시작해서, 이 반전은 diff 화면에 보이지 않았다 PR 본문도 리뷰 봇도 cache를 언급하지 않았다
3부 · cache는 흔적 기관

로컬 A/B — cache만 켜고 꺼 봤다

Spark 3.5.6 · 300만 행 · 같은 잡no-cachecache배율
Spark job 수12적재 잡이 따로 생긴다
wall time1,348ms2,570ms1.9배
태스크 시간 합13.3초26.3초2.0배
GC 시간 합632ms2,083ms3.3배
피크 힙1.3GB3.1GB2.4배
산출물 체크섬동일동일기능 동치
소스 스캔은 어느 쪽이든 1회다. 캐시가 있으면 한 번에 흐를 데이터가 전체를 메모리에 올린 뒤에야 쓰이기 시작한다 — 재사용이 아니라 불필요한 중간 기착지.
노션 — df.cache() 관련 research
3부 · cache는 흔적 기관

실측 — 아무도 읽지 않는 172GB

business_crm_kr제거 전 (cache)제거 후변화
잡 수23 (컬렉션당 save 2개)14 (save 1개)구조 변화의 지문
GC 합1,266초140초−89%
태스크 시간 합64,076초56,308초−12.1%
캐시 읽기172GB0소멸
output11.6억 행11.6억 행동등 — 기능 동치
프로덕션 S3 이벤트 로그를 직접 파싱해 같은 날의 제거 전후 런을 비교했다. 주석의 125GB는 낡은 값 — 컬렉션이 자라며 캐시도 172GB까지 자라 storage 상한(127GB)을 넘어 디스크에 걸쳐 있었다. 제거 PR #21689, 8/28 머지.
3부 · cache는 흔적 기관

같은 레포가 기준을 스스로 보여준다

MongoDumpParquet
액션 1개, 캐시는 화석.
걷어냈다 (#21689)
JdbcDumpParquet
#7504가 parquet 쓰기를 제거해 같은 화석.
MySQL 덤프는 DT 이관 대상이 아니라 별도 후속으로.
DynamoCDCParquet
쓰기 액션이 (parquet, saveAsTable, BigQuery).
정당한 캐시 — 남긴다
코드가 아니라 액션 수를 세면 판정이 끝난다
3부 · cache는 흔적 기관

돈은 적분값 — 절감을 두 축으로 나눈다

시간축 — 즉시 실현
잡이 짧아지면 같은 할당이라도 pod가 일찍 내려간다.

1차 추산: 하루 12런 × 런당 약 5분 단축 × 물리 노드 7대
≈ 하루 7 노드시간
자원축 — 사이징 PR 후
청구는 requests 기준. usage가 내려간 그래프는 근거이지 아직 돈이 아니다.

메모리 요구의 축이 바뀐다:
컬렉션 크기 비례 → 동시 태스크 수 비례

같은 커서 동시성 21로 XLARGE×7이면
물리 노드 7대 → 3.5대
제거는 최적화를 걷어내는 일이 아니라, 진행 중인 손해를 멈추는 일이다
마무리

리뷰는 작게 쌓고, 호출 스택은 쌓지 않고,
아무도 읽지 않는 사본은 걷어낸다

"쌓을 것과 쌓지 않을 것을 정하는 일이 설계다."
지난주: 문서가 침묵하면 plan을 직접 본다 · 이번 주: 이미 있는 코드에도 존재 이유를 묻는다