OPEN SOURCE

[Open source contribution] Apache Paimon 오픈소스 기여 경험기 - byte[] 키를 가진 맵이 잘못 비교되던 자리를 고친 과정

ebson 2026. 7. 21. 19:20

byte[] 키를 자바 Map으로 찾으면 어긋납니다

Apache Paimon의 paimon-common 모듈에는 내부 데이터 구조를 비교하고 해시하는 InternalRowUtils가 있습니다. 이 유틸리티가 MAP 타입을 비교하는 방식에 어긋난 부분이 있어, 이번 글에서는 그것을 고친 apache/paimon#8536을 이야기하려 합니다.

문제는 맵의 키를 찾는 방식이었습니다. 기존 equals는 한쪽 맵의 키를 꺼내 다른 쪽 맵에서 contains와 get으로 찾았습니다.

 

// AS-IS
Object key = get(keyArray1, i, mapType.getKeyType());
if (!map2.contains(key)
        || !equals(map1.get(key), map2.get(key), mapType.getValueType())) {
    return false;
}

 

이 방식은 키가 자바에서 값처럼 비교될 때만 옳습니다. 그런데 BINARY 타입의 키는 내부적으로 byte[]로 다뤄지고, 자바에서 배열의 equals와 hashCode는 내용이 아니라 객체 정체성을 기준으로 동작합니다. 내용이 똑같은 두 byte[]라도 서로 다른 객체이면 다르다고 판정됩니다. 그래서 내용은 같지만 순서만 다르게 담긴 두 맵을 비교하면, 한쪽 키를 다른 쪽에서 찾지 못해 실제로는 같은 맵을 다르다고 판정할 수 있었습니다.

 


타입 동등성으로 짝을 찾도록 바꾸기

고칠 방향은 키를 자바 Map의 조회에 맡기지 않고, Paimon이 이미 갖고 있는 타입 기반 equals로 직접 짝을 찾는 것이었습니다. 한쪽 맵의 각 엔트리에 대해, 다른 쪽 맵의 엔트리를 훑으며 키와 값이 모두 타입 동등성으로 같은 것을 찾는 방식입니다.

private static boolean hasEqualMapEntry(
        InternalArray keyArray1, InternalArray valueArray1, int pos1,
        InternalArray keyArray2, InternalArray valueArray2,
        boolean[] matched, DataType keyType, DataType valueType) {
    Object key1 = get(keyArray1, pos1, keyType);
    Object value1 = get(valueArray1, pos1, valueType);
    for (int j = 0; j < keyArray2.size(); j++) {
        if (matched[j]) {
            continue;
        }
        Object key2 = get(keyArray2, j, keyType);
        if (equals(key1, key2, keyType)) {
            Object value2 = get(valueArray2, j, valueType);
            if (equals(value1, value2, valueType)) {
                matched[j] = true;
                return true;
            }
        }
    }
    return false;
}

 

equals(key1, key2, keyType)는 키 타입을 알고 비교하므로, BINARY 키라면 내용을 비교합니다. matched 배열은 이미 짝지어진 오른쪽 엔트리를 표시해 두어, 같은 항목이 여러 왼쪽 키에 중복으로 매칭되는 것을 막습니다. 이 부분이 없으면 한쪽에 같은 키가 여러 번 들어 있는 경우를 잘못 같다고 볼 수 있어, 짝짓기를 일대일로 묶어 두는 장치가 필요했습니다.

 

해시도 함께 손봐야 했습니다. equals가 순서에 무관하게 같다고 판정하려면, 해시도 순서와 무관하게 같은 값을 내야 하기 때문입니다. 그렇지 않으면 같은 두 맵이 서로 다른 해시를 갖는, equals와 hashCode의 규약을 어기는 상태가 됩니다. 기존 해시는 한 키의 해시와 그 키로 조회한 값의 해시를 누적하는 방식이라, 이 역시 get에 기댔습니다. 이를 키 배열과 값 배열을 같은 위치에서 함께 읽어, 각 엔트리의 해시를 순서에 무관한 방식으로 더하도록 바꿨습니다.

 

// TO-BE — 순서에 무관하게 엔트리 해시를 누적
InternalArray keyArray = map.keyArray();
InternalArray valueArray = map.valueArray();
for (int i = 0; i < map.size(); i++) {
    Object key = get(keyArray, i, mapType.getKeyType());
    Object value = get(valueArray, i, mapType.getValueType());
    result +=
            37 * hash(key, mapType.getKeyType()) + hash(value, mapType.getValueType());
}

각 엔트리의 기여분을 곱셈으로 뒤섞은 뒤 더하기로 누적하기 때문에, 엔트리의 순서가 바뀌어도 최종 해시는 같습니다. 값을 조회할 때도 get으로 키를 되찾는 대신 값 배열을 같은 위치에서 직접 읽어, 배열 키 문제에 다시 걸리지 않게 했습니다.


정상과 거부를 함께 검증하는 테스트

동작을 바꾸는 변경이라 InternalRowUtilsTest에 testEqualsMapWithBinaryKeys를 더했습니다. 테스트 클래스 이름에 Test 접미사를 붙이는 관례를 따랐고 JUnit 5와 AssertJ로 작성했습니다.

 

테스트는 여러 경우를 함께 담았습니다. 내용은 같고 순서만 다르게 담은 두 맵이 같다고 판정되는지, 그리고 그 둘의 해시가 같은지 확인했습니다.

Map<byte[], Integer> map1 = new HashMap<>();
map1.put(new byte[] {1, 2}, 1);
map1.put(new byte[] {3, 4}, 2);
Map<byte[], Integer> map2 = new HashMap<>();
map2.put(new byte[] {3, 4}, 2);
map2.put(new byte[] {1, 2}, 1);

assertThat(InternalRowUtils.equals(new GenericMap(map1), new GenericMap(map2),
        DataTypes.MAP(DataTypes.BINARY(2), DataTypes.INT()))).isTrue();

여기에 더해, 키 하나의 내용이 다른 맵은 다르다고 거부하는지, 그리고 짝짓기를 일대일로 묶어 두지 않으면 잘못 같다고 볼 수 있는 경우까지 확인했습니다. 같은 값을 넣은 왼쪽·오른쪽 순서를 바꿔 두 방향 모두 거부되는지 본 것은, matched 배열이 방향에 상관없이 제대로 동작하는지 못 박기 위해서였습니다.


규칙을 따라가며 확인한 것들

이 변경은 paimon-common에 있고, 바꾼 것은 비교와 해시의 내부 로직이라 디스크에 남는 포맷과는 무관했습니다. 바뀌는 것은 배열 키를 가진 맵이 내용 기준으로 올바르게 비교되고, 같은 맵이 같은 해시를 갖게 되는 것이었습니다.

 

빌드는 바꾼 모듈만 스코프로 잡아 진행했고, 포맷은 mvn spotless:apply -pl paimon-common으로 맞춘 뒤 모듈 전체 검증을 통과시켰습니다. squash 머지라 PR 제목이 곧 커밋 메시지가 되어 [module] Description 형식을 지켜야 해서, 제목을 [common] Fix map equality for binary keys로 정했습니다. 본문은 템플릿의 ### Purpose와 ### Tests 두 섹션으로 나눴습니다.


equals와 hashCode를 함께 봐야 했던 이유

이번 수정에서 오래 남은 것은, 맵의 동등성을 고치려면 해시도 반드시 함께 봐야 한다는 점이었습니다. equals만 순서에 무관하게 바꾸고 해시를 그대로 두면, 같은 맵이 서로 다른 해시를 갖는 새로운 어긋남이 생깁니다. 두 메서드는 하나의 규약으로 묶여 있어, 한쪽을 손대면 다른 쪽도 같은 기준으로 맞춰야 한다는 것을 실제로 해 보며 알게 됐습니다.

 

자바 컬렉션의 조회에 값 비교를 맡기는 습관이 배열처럼 정체성으로 비교되는 타입에서 조용히 어긋난다는 것도 직접 겪었습니다. 타입을 아는 비교 함수가 이미 있을 때는 그것에 맞춰 짝을 찾는 편이, 언어가 제공하는 조회에 기대는 것보다 안전한 경우가 있다는 감각을 얻었습니다.