Skip to content
목록으로 돌아가기
Iceberg V3 Deletion Vector 최적화와 Upstream 기여

Iceberg V3 Deletion Vector 최적화와 Upstream 기여

2026년 9월 23일오픈소스기여

Iceberg V3 deletion vector 의 읽기 비용을 파고들다 병목을 하나 찾았고, 그것을 패치로 만들어 Apache Iceberg 에 올렸다. 이 글은 패치를 올린 뒤부터 머지되기까지 의 기록이다. 병목을 어떻게 찾았는지는 본편에 있다.

  ↳ 본편 : Iceberg V3 Deletion Vector 성능 해부 및 CPU 레벨 최적화

       → Issue : #18026        → PR : #18027        → 백포팅 PR : #18207


1. 앞선 이야기

이 글은 본편에서 이어진다. 패치가 무엇을 바꾸는 것인지만 먼저 짚고 가겠다.

Iceberg V3 는 삭제된 행을 deletion vector 로 표시한다. 데이터 파일 하나에 DV 하나가 붙고, 그 안은 Roaring Bitmap 이다. Roaring 은 위치 공간을 65,536 개짜리 청크로 나눠 밀도에 맞는 컨테이너를 고른다. 청크 안의 삭제가 4,096 개 이하면 array, 그보다 많으면 bitmap 이다.

text
4,096 / 65,536 = 0.0625 = 6.25%

Spark 벡터화 리더는 배치를 읽으면서 행마다 PositionDeleteIndex.isDeleted(pos) 를 호출한다. 배치가 5,000 행이면 5,000 번이다. 그런데 배치가 읽는 position 은 무작위가 아니라 연속된 오름차순 구간이다. 같은 컨테이너를 찾는 이진 탐색을 행마다 처음부터 다시 하고 있었다는 뜻이다.

프로파일을 떠보니 좁은 스캔에서 이 delete 체크가 스캔 CPU 의 40% 를 차지했다. 그리고 비용 곡선이 단조롭지 않았다. array 에서 bitmap 으로 넘어가는 6.25% 바로 아래가 가장 비쌌고, 삭제율을 더 올리면 오히려 싸졌다.

그래서 제안한 변경은 이것이다.

text
기존 : 행마다 isDeleted(pos)          → 배치 크기만큼 반복
변경 : forEachInRange(start, end)     → 구간을 한 번 순회

PositionDeleteIndex 에 구간 순회 메서드를 하나 추가하고, ColumnarBatchUtil 이 그 경로를 타게 했다. 마이크로벤치에서 56~150배, Spark 실측 delete-check CPU 에서 2.6~9.3배 가 줄었다.

측정 과정, 환경별 편차, 왜 6.25% 가 변곡점인지는 전부 본편에 있다. 여기서부터는 이 패치를 커뮤니티에 올린 뒤의 이야기다.


2. 업스트림

2.1 이슈와 PR

💬 Issue

측정 기록을 이슈로 먼저 냈다. apache/iceberg#18026 이다. 어디가 비싼지, 어떤 조건에서 그런지, 그리고 어떤 조건에서는 이 숫자들이 성립하지 않는지까지 적었다.

마지막 항목을 따로 절로 뺐다. 단일 JVM 조건이라는 것, 노이즈 바닥이 9.7% 라는 것, 재측정 후 철회한 숫자가 두 개 있다는 것, 측정하지 않은 조건이 무엇인지를 적었다.

이슈를 내기 전에 기존 논의가 있는지 찾아봤다. ColumnarBatchUtil 을 건드리는 이슈가 두 개 있었지만 javadoc 과 단위 테스트였고, 행 단위 조회 비용에 대한 논의나 Roaring 구간 API 에 대한 언급은 저장소에서 찾지 못했다.

💬 개선안

패치는 spark/v4.2 한 곳만 건드린다. diff 를 검토 가능한 크기로 유지하기 위해서다.

ColumnarBatchUtil 은 v3.5, v4.0, v4.1 에서 바이트 단위로 동일하므로, 방향이 맞으면 후속 PR 로 채우겠다고 적었다.

호환성 측면은 이렇다. default 구현이 현행 동작 그대로라 소스·바이너리 호환이고, 외부 구현체는 아무것도 안 고쳐도 된다. 포맷이나 명세 변경도 없다.

💬 테스트

새 API 전용 테스트 파일을 하나 추가했다(227줄). 위의 경계 조건들을 담았다.

빌드는 Spark 4.2 / Scala 2.13 기준으로 돌렸고, 코드 포맷 검사도 통과시켰다.

💬 PR

apache/iceberg#18027 로 올렸고 CI 43개가 전부 통과했다.

Slack #dev 에 알렸더니 커미터 한 명이 dev 메일링 리스트에도 올려 더 많은 눈을 받는 게 좋겠다고 제안했다. 공개 인터페이스에 메서드를 추가하는 변경이라 타당한 지적이다. [DISCUSS] 로 리스트에 올렸다. 여기서부터가 길었다.

공개 인터페이스를 어떻게 정리할지는 리뷰 결과에 따라 달라질 수 있다. 다만 6.25% 경계와 행 단위 조회 비용은 패치의 최종 형태와 별개로 측정된 사실이다.


2.2 메일링 리스트

💬 Slack 을 통한 소통

PR을 올린 후, 평소 많은 도움을 받고 있는 Iceberg Slack의 Dev 채널에 리뷰를 요청했다.

Iceberg Slack #dev 채널에 올린 리뷰 요청과, 메일링 리스트로 가져가 보자는 Eduard Tudenhoefner 의 답글.

그랬더니 메인테이너인 Eduard Tudenhoefner 님께서 좋은 안건인 것 같다며, 메일링 리스트에 올려 다른 사람들과 함께 논의해보자는 의견을 주셨다.

dev@iceberg.apache.org 에 올린 [DISCUSS] 스레드 : Reducing per-row position delete index probes in the vectorized read path.

메일링 리스트 논의 내용 : https://lists.apache.org/thread/5v8j1zj2l8ntygph47r0k61xyskxc5o1

그래서 안내해주신 대로 메일링 리스트에 해당 내용을 공유하고 토의를 요청했다.

💬 요약이 조건 하나를 거꾸로 바꿔놨다

메일링 리스트에 올린 글을 보고 Péter Váry(pvary) 님이 답을 주셨다. 내 설명을 LLM 에 넣어 요약을 받아보셨다며, 그 결과를 그대로 물어오셨다.

So if your query is:
- Narrow projection
- Scan-heavy
- DV-heavy
- Little aggregation/filtering

then you might actually see 13-19% faster queries.

Is this accurate? You might want to highlight in which use-case your PR helps

13 ~ 19%라는 수치 자체는 맞다. 오히려 보수적이다. 5컬럼 이하 정수 투영에서 측정한 값이고, 1컬럼 스캔 전용 쿼리는 전용 인스턴스에서 29%까지 나왔다. 10컬럼을 넘어가면 노이즈 바닥(중앙값 9.7%) 안으로 들어가 아무것도 주장하지 않았다.

문제는 조건 하나가 거꾸로 들어갔다는 것이었다. DV-heavy, 즉 삭제가 많을수록 이득이 크다는 조건이다. 측정한 곡선은 그 반대다.

삭제율 컨테이너 현행 패치본 개선
0.5% array 674 79 8.56배
6.1% array 873 94 9.28배
7.0% bitmap 420 113 3.73배
8.0% bitmap 499 142 3.51배
50% bitmap 584 225 2.60배

6.1%가 정점이고 그 뒤로는 계속 내려간다. 패치가 없애는 것이 이진 탐색인데, 그 탐색은 array 컨테이너일 때만 한다. 6.25%를 넘어 bitmap이 되면 조회가 이미 비트 하나를 보는 상수 시간이라 없앨 것이 적다.

50%가 바닥인 이유는 또 다르다. 삭제가 절반이면 건너뛸 빈 구간이 없다. 살아 있는 행이 하나씩 띄엄띄엄 있어서 구간 순회의 장점이 사라지고, 남는 이득은 행마다 컨테이너를 다시 찾지 않는 것뿐이다. 그래서 2.60배에서 더 내려가지 않는다.

그래서 답글에 이렇게 적었다.

DV-heavy is not monotonic. More deletes is not more benefit.

그리고 PR 설명을 어떤 경우에 도움이 되는지부터 먼저 나오도록 다시 썼다. 메일링 리스트에도 TL;DR을 앞에 붙여 다시 보냈다.

여기서 배운 것이 하나 있다. 곡선이 비단조라는 사실은 본문에 분명히 적어뒀는데도, 요약을 거치니 "많을수록 좋다"로 뒤집혔다. 사람이든 도구든 요약하는 쪽은 단조 관계를 기본값으로 가정한다. 그렇지 않은 결과라면 그 사실 자체를 맨 앞에 적어야 했다.

덧붙여 실무에서 자주 보는 CDC성 워크로드가 정확히 이 구간이다. 삭제가 드문드문 있고 청크당 6.25% 바로 아래다. 가장 흔한 경우가 가장 비싸고, 고쳤을 때 가장 많이 좋아진다.


2.3 첫 리뷰

💬 첫 리뷰

Iceberg PMC 인 Péter Váry(pvary) 님께서 리뷰를 주셨다. 인라인 11건에 JMH 벤치마크 요청 1건, 모두 12건이다.

리뷰 상태가 CHANGES_REQUESTED 가 아니라 COMMENTED 였다. 접근 자체를 되돌리라는 이야기는 없었고, 시그니처와 테스트 배치, 그리고 근거를 저장소 안에 남기는 방식에 대한 지적이었다. 방향은 받아들여졌고 다듬는 단계라고 읽었다.

대응은 커밋 하나로 정리했다. 10개 파일에 +451 / -283 이고, core/deletes 테스트가 55개에서 63개로 늘었다. CI 는 42개 전부 통과했다.

💬 시그니처를 바꾸자 불변식이 같이 깨졌다

Other methods have long posStart, long posEnd. Shall we use that?

같은 인터페이스의 delete(long posStart, long posEnd) 가 이미 시작 포함 / 끝 제외를 쓰고 있었다. 맞는 지적이라 그대로 받았다.

그런데 이름만 바꾸는 일이 아니었다. 기존 구현이 길이가 int 라는 데 기대고 있었고, 그 전제를 주석에 적어두기까지 했다.

java
// the range spans at most two keys because the length is bound by Integer.MAX_VALUE,
// which is smaller than the number of positions a single key covers

Roaring Bitmap 은 64비트 위치를 상위 32비트 키와 하위 32비트로 나눠 담는다. 길이가 int 라면 구간이 아무리 길어도 키 두 개를 넘지 못하므로, 중간에 통째로 걸쳐지는 키가 생길 수 없다. 그런데 끝 위치를 long 으로 받으면 키 세 개 이상에 걸칠 수 있고, 그때 중간 키는 32비트 공간 전체를 훑어야 한다.

MAX_POS_32_BITS = 0xFFFFFFFF   (2^32 - 1)
lowEnd - lowStart = 2^32
(int) 2^32 = 0                 ← 아무것도 순회하지 않는다

길이를 int 로 좁히는 순간 0이 되어서, 예외도 없이 그 키에 있는 삭제를 통째로 건너뛴다. 기존 코드에도 같은 형태의 계산이 있었지만 "최대 두 키" 라는 전제 덕분에 그 경로에 닿지 않았을 뿐이다. 시그니처를 바꾸는 순간 닿는다.

내부 헬퍼를 int length 대신 long lowStart, long lowEnd 를 받게 고치고, 32비트를 넘는 구간은 청크로 나눠 돌게 했다. RoaringBitmap.forEachInRange 자체가 길이를 int 로 받는 것은 라이브러리 제약이라 우회할 수 없어서, 호출하는 쪽에서 잘라 넣는 방식이다.

실무에서 닿을 일은 없다. 데이터 파일 하나가 42억 행을 넘어야 한다. 다만 공개 API 가 long 을 받게 된 이상 그 범위에서 맞아야 한다고 보고 고쳤다.

💬 스타일은 추측하지 않고 옆 코드에서 가져왔다

같은 파일에 setRange(long posStartInclusive, long posEndExclusive) 가 이미 있었다. 파라미터 이름, 예외 문구, 빈 구간 처리를 전부 그쪽에서 그대로 가져왔다.

덕분에 두 번째 지적이 같이 해결됐다.

Shall we throw instead?

if (length <= 0) return; 이 이상한 입력을 조용히 삼키고 있었다. setRange 와 같은 문구로 바꿨다.

java
Preconditions.checkArgument(
    posStartInclusive <= posEndExclusive,
    "Start position must not exceed end position: [%s, %s)",
    posStartInclusive,
    posEndExclusive);

시작과 끝이 같은 것은 정상이고 아무 일도 하지 않는다. 시작이 끝보다 클 때만 예외다. 이것도 setRange 와 같은 규칙이다.

💬 나머지 인라인 지적

지적 대응
No impl details in the javadoc 인터페이스 주석에서 "비트맵이 컨테이너를 한 번 찾아서 훑는다" 같은 내부 설명을 뺐다. 구현이 바뀌면 주석이 거짓말이 된다
Could we shortcut if there are no deletes? 삭제가 하나도 없으면 순회 전에 바로 null 을 반환한다
Could we just call it inside the IsDeletedBuilder.accept? DeleteFilter 를 넘겨 accept 안에서 세게 했다. 카운터 필드와 getter 가 통째로 사라졌다
This is a bit tricky to read (suggestion 첨부) 제안 버튼은 누르지 않았다. 위 방식을 RowIdMappingBuilder 에도 적용하니 세는 루프 자체가 없어져서, 읽기 어려운 코드가 남지 않았다
this is not a build method appendRemainingLiveRows()liveRowCount() 로 분리했다

순 11줄이 줄었다. 두 개의 카운트 루프가 accept() 안 한 줄로 들어갔다.

제안을 따르지 않은 것은 더 나은 방법이 있어서였지만, 그 이유를 답글에 반드시 적어야 했다. 적지 않으면 무시한 것으로 읽힌다.

💬 테스트를 대상별로 나눴다

Why new test class? Shall we put these into TestRoaringPositionBitmap, or TestBitmapPositionDeleteIndex?

227줄짜리 새 테스트 파일을 지우고 기존 두 파일로 나눴다.

어디로 무엇을
TestRoaringPositionBitmap (+9) 컨테이너와 키 경계, 세 키 걸침, 미할당 키, run 인코딩, 잘못된 구간 예외 비트맵이 하는 일
TestBitmapPositionDeleteIndex (+8) 빈 인덱스, 경계, 오름차순 보장, isDeleted 와의 무작위 대조 인덱스 표면의 계약

옮기면서 없던 테스트 두 개를 새로 넣었다. 하나는 예외를 던지게 되면서 생긴 동작이고, 다른 하나는 위에서 본 int 넘침 경로다. 두 번째가 없으면 그 버그를 잡지 못한다. 옆에 이미 testAddRangeSpanningThreeKeys 가 있어서 이름과 모양을 맞췄다.

파일이 없어지면서 package private 과 테스트 접두어 지적은 해당 사항이 없어졌다. 기존 파일에 들어가면 그 파일의 스타일을 따르는 것이 맞다.

다만 확인해보니 저장소 관례가 한쪽으로 통일돼 있지 않았다. 최근에 추가된 테스트에도 public classclass 가 둘 다 있었다. 그래서 답글에 원하시면 두 파일 다 새 관례로 바꾸겠다고 덧붙였다.


2.4 JMH 벤치마크

💬 JMH 벤치마크

Could you please add JMH test to show the gains?

숫자를 보여달라는 것이 아니라 저장소에 벤치마크 코드를 넣어달라는 요청이었다. 저장소를 확인해보니 분명했다.

JMH 벤치 파일        199개    ← 코드는 커밋한다
커밋된 결과 파일        0개
.gitignore:42   */benchmark/*   ← 결과는 무시된다

core/src/jmhPositionDeleteIndexBenchmark 를 추가했다. 500만 위치를 5,000개씩 읽는다. 벡터화 리더가 실제로 읽는 방식이고, 밀도는 array 와 bitmap 경계의 양쪽을 잡았다.

설정은 옆 파일인 RoaringPositionBitmapBenchmark 를 그대로 따랐다. @Fork(1), SingleShotTime, 5분 타임아웃, javadoc 에 ./gradlew 실행법까지 같은 모양이다. 내가 측정에 쓰던 DvBatchBench@Fork(3)AverageTime 이라 그대로 옮기면 안 됐다. 저장소에 들어가는 코드는 저장소 관례를 따라야 한다.

Benchmark                        (density)  Mode  Cnt    Score    Error  Units
probePerPosition                       0.5    ss    5   82.893 ± 13.257  ms/op
probePerPosition                       6.1    ss    5  111.101 ± 43.291  ms/op
probePerPosition                      12.0    ss    5   68.924 ± 18.444  ms/op
traverseRange                          0.5    ss    5    0.369 ±  0.378  ms/op
traverseRange                          6.1    ss    5    1.304 ±  0.891  ms/op
traverseRange                         12.0    ss    5    2.456 ±  1.660  ms/op
밀도 컨테이너 행마다 구간 순회
0.5% array 82.9 ± 13.3 0.37 ± 0.38
6.1% array 111.1 ± 43.3 1.30 ± 0.89
12.0% bitmap 68.9 ± 18.4 2.46 ± 1.66

배수를 적지 않은 이유가 있다. 구간 순회 쪽은 오차가 값과 맞먹는다. 0.37 ± 0.38 이면 실제 값이 0 에 가까울 수도 있고 0.75 일 수도 있어서, 배수는 111배일 수도 무한대일 수도 된다. 225배 같은 특정 값을 주장할 근거가 없다.

이 글은 다른 곳에서 범위가 겹치면 주장하지 않는다 를 지켰다. 여기서만 어길 수는 없어서 두 자릿수 차이라고만 적는다.

Spark 프로파일에서 봤던 비단조 곡선이 순수 JMH 에서도 그대로 나왔다. 6.1% 가 가장 비싸고 12% 가 오히려 싸다.

💬 같은 패치인데 숫자가 세 개다

여기서 주의할 것이 하나 있었다. 이 글에 이미 배수가 두 종류 나와 있는데, 새 벤치는 또 다른 값을 낸다.

출처 무엇을 재나 배수
새 JMH 파일 전체, 청크 약 77개 두 자릿수
앞의 마이크로벤치 청크 하나로 컨테이너 타입만 분리 5 ~ 144배
Spark 실측 실제 스캔의 delete-check CPU 2.6 ~ 9.3배

isDeleted 한 번은 두 단계다. 어느 32비트 비트맵인지 찾고, 그 안에서 찾는다.

앞의 마이크로벤치는 청크를 하나만 만들어서 1단계를 사실상 0으로 만들었다. 컨테이너 타입의 영향만 떼어내려는 의도였다. 새 벤치는 500만 위치라 비트맵이 여러 개고, 1단계가 실제 비용이 된다. 그래서 배치당 19.76µs 이던 것이 68.92µs 로 비싸졌다.

그리고 구간 순회는 1단계를 배치당 한 번으로 상각하므로 격차가 더 벌어진다. 12% 에서 5배이던 것이 두 자릿수로 벌어진 이유다.

패치가 좋아진 것이 아니라 측정 범위가 넓어진 것이다. 앞의 숫자가 과소평가였고, 사용자가 체감하는 값은 여전히 Spark 쪽 2.6 ~ 9.3배다.

이 설명을 답변에 같이 적지 않으면 "두 자릿수라더니 본문에는 왜 9.3배냐" 를 듣게 된다. 분모가 다르면 배수는 비교할 수 없다.


2.5 커뮤니티 싱크

💬 커뮤니티 싱크 정식 안건이 되었다

Iceberg 는 3주마다 Community Sync 라는 공개 회의를 연다. 메인테이너와 기여자들이 모여 진행 중인 제안을 하나씩 훑는 자리다. 안건 문서에 올라가야 논의되는데, 내 PR 이 여기에 항목으로 올라갔다.

Iceberg Community Sync 안건 문서에 올라간 항목. Reducing per-row position delete index probes in the vectorized read path.

문서에는 이렇게 적혀 있었다.

[Daehong]: Reducing per-row position delete index probes in the vectorized read path
  - Vectorized reads probe the position delete index once per row, but the
    positions in a batch are a contiguous ascending range. The index can be
    traversed once instead.
  - Significant gain when few columns are projected. Not visible end to end on
    wide projections, where the delete check is a small share of scan CPU.
  - Needs a new API: PositionDeleteIndex.forEachInRange. Is the community OK with that?

회의 자체는 55분이었고 내 안건은 49분 54초부터 52분 26초까지 다뤄졌다. 한국 시간으로 자정이 넘은 시각이라 나는 참석하지 못했고, 진행자가 "지구 반대편이라 지금 자정쯤일 텐데" 라며 확인한 뒤 pvary 님이 대신 설명해 주셨다.

벡터화 읽기의 delete 적용을 최적화할 방법을 찾았다. 질문은 position index 에 새 API 를 추가해도 되느냐다. 전체가 아니라 주어진 구간에만 consumer 를 적용하는 API. 구현했고 결과도 꽤 좋다.

그리고 바로 이어서 이런 말이 나왔다.

다만 그의 PR 은 LLM 이 생성한 문서로 가득하다. 아이디어는 타당한데 그 주변 것들 때문에 리뷰가 매우 어려웠다. 아이디어도 좋고 구현도 좋은데, 다 헤치고 보석을 찾는 데 시간이 꽤 걸렸다.

결과만 놓고 보면 잘 끝났다. "추가에 이견 있는 분?" 에 아무도 손을 들지 않아 새 API 는 승인됐고, nastra 님이 그 주에 리뷰하겠다고 하셨다. 기술적 반대는 한 건도 없었다. 누군가 "별도 이슈로 떼면 도움이 될까요?" 라고 물었을 때도 pvary 님은 이렇게 답했다.

아뇨, 이슈는 괜찮다. 설명이 너무 길 뿐이다. 이 몇 문장이면 다 커버된다.

지적은 분량 하나뿐이었다. 그런데 나는 이게 제일 아팠다.

나는 근거를 남기려고 길게 썼다. 측정 조건, 반증, 성립하지 않는 경우까지 전부 적었다. 그게 성실한 태도라고 생각했는데, 받는 쪽에서는 리뷰를 가로막는 벽이었다. 회의에서 공개적으로 "보석을 찾는 데 시간이 꽤 걸렸다" 는 말을 들을 만큼.

앞서 메일이 요약을 거치며 조건이 뒤집혔던 것과 같은 문제다. 길게 쓰는 것과 전달되게 쓰는 것은 다른 일이다. 내용을 빠짐없이 적는 것으로 내 몫을 다했다고 생각했지만, 읽는 사람의 시간을 쓰게 만든 만큼은 내 몫이 아니었다.

💬 nastra 님의 리뷰

회의에서 말한 대로 nastra 님이 리뷰를 주셨다.

첫째, 인터페이스 javadoc 의 성능 조언을 지웠다.

not sure if that's helpful, maybe just remove this

diff
-   * <p>Callers that test a contiguous range of positions should prefer this method over calling
-   * {@link #isDeleted(long)} once per position.
-   *

인터페이스 주석은 무엇을 보장하는지를 적는 자리이지 어느 쪽이 빠른지를 적는 자리가 아니다. 1차 리뷰에서 pvary 님이 "No impl details in the javadoc" 이라고 했던 것과 같은 지적인데, 그때 이 문단을 남겨뒀다. 같은 지적을 두 번 받은 셈이다.

둘째, 파라미터 이름을 구간이 드러나게 바꿨다.

we use similar naming in RoaringPositionBitmap.setRange(..), so I think being explicit about the ranges is helpful. Please also update the implementing methods

diff
-  default void forEachInRange(long posStart, long posEnd, LongConsumer consumer) {
+  default void forEachInRange(long posStartInclusive, long posEndExclusive, LongConsumer consumer) {

1차 때 setRange 를 본떠 예외 문구와 빈 구간 처리를 가져왔으면서 이름만 짧은 쪽으로 남겨뒀던 것이다. @param@throws 까지 같이 고쳤고, 구현체도 빠짐없이 훑었다.

파일 역할
PositionDeleteIndex default 구현
BitmapPositionDeleteIndex 구현체
EmptyPositionDeleteIndex 구현체
RoaringPositionBitmap 내부 (원래 맞았음)

셋째, 끝이 시작보다 작은 경우의 테스트를 넣었다.

we also need tests where end < start

찾아보니 비트맵 레벨에만 있고 인덱스 레벨에는 없었다. 그런데 인덱스 표면에서는 구간 검증이 세 군데에서 따로 일어난다. default 구현, BitmapPositionDeleteIndex, EmptyPositionDeleteIndex 이다.

java
@Test
public void testForEachInRangeInvalidRange() {
  // each implementation validates the range on its own, so check all of them
  assertThatThrownBy(() -> collect(indexOf(1L, 2L, 3L), 5L, 3L))
      .isInstanceOf(IllegalArgumentException.class)
      .hasMessageContaining("Start position must not exceed end position");

  assertThatThrownBy(() -> collect(PositionDeleteIndex.empty(), 5L, 3L))
      ...
  assertThatThrownBy(
          () -> collect(new SetBackedPositionDeleteIndex(Sets.newHashSet(1L, 2L, 3L)), 5L, 3L))
      ...
}

테스트가 실제로 세 곳을 다 덮는지 확인해야 했다. 그래서 세 구현의 Preconditions 를 하나씩 무력화해보고 그때마다 테스트가 실패하는지 봤다. 셋 다 실패해야 셋 다 덮인 것이다. 앞서 통과하기만 하고 아무것도 검증하지 않는 테스트를 본 뒤로 붙은 습관이다.


2.6 머지와 백포팅

💬 머지

2026년 9월 21일, pvary 님이 머지하셨다.

apache/iceberg#18027 머지됨. pvary 가 JeonDaehong:spark-dv-range-scan 브랜치의 커밋 7개를 apache:main 으로 머지했다.

JeonDaehong:spark-dv-range-scan 에서 apache:main 으로 커밋 7개, 파일 9개, +804 −37 이다. nastra 님과 pvary 님 두 분의 승인을 받았고 라벨은 corespark 가 붙었다. 댓글 82건에 CI 체크 42개가 돌았고, 이슈 #18026 도 함께 닫혔다.

최종 PR 페이지. 설명이 "When this helps" 로 시작하고, 그 다음에 도움이 되지 않는 조건이 온다. 삭제율별 개선 표가 본문에 들어가 있다.

최종 PR 설명은 처음 올렸던 것과 순서가 다르다. 어떤 조건에서 도움이 되는지가 맨 앞에 있고, 도움이 되지 않는 경우가 바로 뒤에 붙어 있다. 문자열 컬럼을 투영할 때, 집계가 지배적일 때, 테이블이 작은 파일로 쪼개져 있을 때는 효과가 없다고 적었다. 삭제율별 표도 본문에 넣었다. 앞에서 본 것처럼 요약 한 번에 조건이 거꾸로 뒤집힌 뒤로 고친 구조다.

다만 이 변경은 spark/v4.2 에만 들어갔다. 리뷰를 한 덩어리로 유지하려고 처음부터 그렇게 올렸다. 나머지 세 버전은 후속 PR 로 남겨뒀다.

💬 백포팅

2026년 9월 23일, 백포팅도 머지됐다. apache/iceberg#18207 이다.

apache/iceberg#18207 머지됨. pvary 가 JeonDaehong:spark-dv-range-scan-backport 브랜치의 커밋 1개를 apache:main 으로 머지했다.

ColumnarBatchUtil 은 v3.5 · v4.0 · v4.1 · v4.2 에서 바이트 단위로 동일하다. 그래서 백포팅이라기보다 같은 파일을 세 번 더 놓는 일에 가까웠다. 핵심 API 인 PositionDeleteIndex.forEachInRangecore 에 있고 이미 main 에 들어가 있었으므로, 버전별로 다시 손댈 것이 없었다.

항목 내용
브랜치 spark-dv-range-scan-backportapache:main
커밋 1개
파일 6개 (버전 3개 × ColumnarBatchUtil · TestColumnarBatchUtil)
변경량 +708 −111
승인·머지 pvary

💬 들어간 코드

버전마다 바뀐 것은 buildRowIdMappingbuildIsDeleted 앞에 분기를 하나씩 세운 것이다.

java
if (!deletes.hasEqDeletes()) {
  // positions in a batch form a contiguous ascending range, so the index can be traversed once
  // for the whole range instead of being probed once per row
  return deletedPositions == null
      ? null
      : buildRowIdMapping(deletedPositions, deletes, rowStartPosInBatch, batchSize);
}

equality delete 가 없을 때만 새 경로로 빠진다. 있으면 그 아래 기존 행 단위 루프를 그대로 탄다.

새 경로가 하는 일은 LongConsumer 하나가 전부다.

java
@Override
public void accept(long pos) {
  int deletedRowId = (int) (pos - rowStartPosInBatch);
  for (int rowId = nextRowId; rowId < deletedRowId; rowId++) {
    rowIdMapping[liveRowId] = rowId;
    liveRowId++;
  }

  deletes.incrementDeleteCount();
  this.nextRowId = deletedRowId + 1;
}

루프가 뒤집혔다는 점이 핵심이다. 기존에는 배치의 모든 행을 돌면서 행마다 "이거 삭제됐나" 를 물었다. 이제는 삭제된 위치만 오름차순으로 받고, 직전 삭제 위치와 이번 삭제 위치 사이의 빈틈을 살아 있는 행으로 채운다. 마지막 삭제 위치 뒤에 남는 꼬리는 순회가 끝난 뒤 appendRemainingLiveRows() 가 이어서 채운다.

그래서 이 경로의 비용은 배치 크기가 아니라 삭제 개수에 비례한다. 50% 구간이 개선폭 바닥이었던 이유가 코드에 그대로 드러난다. 삭제가 절반이면 채울 빈틈이 거의 없어서, 뒤집은 루프가 결국 원래 루프와 비슷한 횟수를 돈다.

buildIsDeleted 쪽은 더 짧다. 빈틈을 채울 필요가 없으니 받은 위치에 표시만 하면 되고, consumer 본문이 두 줄이다.

java
@Override
public void accept(long pos) {
  isDeleted[(int) (pos - rowStartPosInBatch)] = true;
  deletes.incrementDeleteCount();
}

로컬에서는 Spark 4.1 로 테스트 17개를 돌려 확인했다.

이걸로 Iceberg 가 유지하는 Spark 네 버전이 전부 같은 읽기 경로를 쓴다. 처음 올린 PR 이 머지되고 이틀 뒤였다.



3. 결론

3.1 열려 있던 두 가지

본편을 쓰기 시작할 때는 PR 측면에서 두 가지가 열려 있었다.

첫째, 리뷰 결과에 따라 인터페이스의 형태가 달라질 수 있다.

둘째, 방향이 확정되면 v3.5 / v4.0 / v4.1 을 후속 PR 로 이어서 구현해야 한다.

둘 다 닫혔다. 시그니처는 리뷰를 거치며 한 번 바뀌었고 나머지 지적은 논의로 정리됐다. 그 형태 그대로 9월 21일에 머지됐고, 이틀 뒤 나머지 세 버전 백포팅도 머지됐다.

문제를 정확히 정의했고, 그 원인을 측정으로 좁혔으며, 제안한 변경이 커뮤니티의 리뷰를 거쳐 Iceberg 가 유지하는 Spark 전 버전에 반영됐다.

3.2 리뷰에서 배운 것

💬 시그니처를 바꾸면 그 타입에 기대던 전제도 같이 깨진다

길이를 int 에서 long 으로 바꾼 것은 이름을 고치는 수준의 일로 보였다. 실제로는 "구간이 키 두 개를 넘지 못한다" 는 전제가 함께 무너져서, 조용히 삭제를 건너뛰는 경로가 생겼다.

그 전제는 주석에 적혀 있었다. 주석을 지우기 전에 왜 있었는지 읽어야 한다. 리뷰어가 지적한 것은 시그니처였지만, 고쳐야 했던 것은 그 아래 계산이었다.

💬 스타일은 추측하지 말고 옆 코드에서 가져온다

같은 파일의 setRange 를 그대로 본떴더니 지적 두 개가 한 번에 해결됐고, 리뷰어가 보기에 원래 있던 코드처럼 읽힌다.

반대로 관례는 확인하고 나서 따라야 한다. 테스트 클래스 접근 제어자 지적을 저장소 전체의 규칙으로 알았는데, 확인해보니 최근 파일에도 두 스타일이 섞여 있었다. 확인하지 않았다면 멀쩡한 기존 파일을 잘못 고쳤을 것이다.

💬 제안을 따르지 않을 때는 이유를 적어야 한다

리뷰어가 코드 제안까지 붙여준 지적이 하나 있었는데, 그 버튼을 누르지 않았다. 구조를 바꾸니 문제의 루프 자체가 없어져서 더 나은 결과였지만, 말하지 않으면 무시한 것으로 읽힌다. 답글에 왜 다르게 갔는지를 적는 것까지가 대응이다.

💬 길게 쓰는 것과 전달되게 쓰는 것은 다른 일이다

이 글에서 같은 이야기가 세 번 나온다. 메일링 리스트에서 요약을 거치며 조건이 거꾸로 뒤집혔을 때, 커뮤니티 싱크에서 분량 지적을 들었을 때, 그리고 PR 설명을 "어떤 경우에 도움이 되는지" 부터 나오도록 다시 썼을 때다.

빠짐없이 적는 것으로 내 몫을 다했다고 생각했지만, 읽는 사람의 시간을 쓰게 만든 만큼은 내 몫이 아니었다.


4. 참고문헌

Comments