HBase RowKey 필터링 실패 원인과 해결 방안

HBase 테이블에 총 746건의 데이터가 존재하는 상황에서, Spark으로 처리하기 어려운 알고리즘을 Java로 직접 구현하여 HBase를 조회해야 했다. 성능 향상을 위해 필터를 적극 활용했으나, 예상치 못한 데이터 누락 문제가 발생했다.

초기 RowKey 설계 및 문제 발생

처음에는 시간_주문ID 형태로 RowKey를 구성했다.
RowKey 구조: yyyyMMddHHmm_주문식별자
예시: 201904010000_ORDER-2024-A1B2
시간 범위로 빠르게 필터링하기 위해 RowFilterBinaryComparator를 조합한 코드를 작성했다.
FilterList compositeFilter = new FilterList(FilterList.Operator.MUST_PASS_ALL);

if (fromDt != null) {
    RowFilter lowerBound = new RowFilter(
        CompareOperator.GREATER_OR_EQUAL,
        new BinaryComparator(Bytes.toBytes(fromDt))
    );
    compositeFilter.addFilter(lowerBound);
}

if (toDt != null) {
    RowFilter upperBound = new RowFilter(
        CompareOperator.LESS_OR_EQUAL,
        new BinaryComparator(Bytes.toBytes(toDt))
    );
    compositeFilter.addFilter(upperBound);
}

scan.setFilter(compositeFilter);
실행 결과는 361건. 전체 746건 중 절반에 가까운 데이터가 누락되었다.

누락 원인 분석

문제는 바이트 단위의 문자열 비교에 있었다. BinaryComparator는 RowKey의 바이트 배열을 처음부터 순차적으로 비교하는데, 201904010000201904010000_ORDER-001은 전혀 다른 값이다.
예를 들어 startTime="20190401"으로 필터링하면:
  • 20190401_ORD-00120190401보다 큼 ✓
  • 201904010000_ORD-00220190401보다 큼 ✓
  • 201904005959_ORD-00320190401보다 작음 ✗ (실제로는 2019-04-00 59:59, 유효하지 않지만 바이트 비교상)
더 큰 문제는 201904010000201904010000_XXX의 비교다. 후자가 더 길어 바이트 비교 시 후자가 더 "큰" 것으로 판정되어 범위 필터가 의도대로 동작하지 않는다.

해결: RowKey 구조 재설계

핵심 원칙은 필터링 조건을 접두사(prefix)로 만들어야 한다는 것이다. RowKey를 주문ID_시간으로 변경하고, 시간 필터는 정규표현식을 활용한 접미 패턴 매칭으로 처리했다.
RowKey 구조: 주문식별자_yyyyMMddHHmm
예시: ORDER-2024-A1B2_201904010000
정규표현식 필터를 적용한 개선 코드:
FilterList compositeFilter = new FilterList(FilterList.Operator.MUST_PASS_ALL);

if (fromDt != null) {
    String fromPattern = ".*_" + fromDt;
    RowFilter lowerBound = new RowFilter(
        CompareOperator.GREATER_OR_EQUAL,
        new RegexStringComparator(fromPattern)
    );
    compositeFilter.addFilter(lowerBound);
}

if (toDt != null) {
    String toPattern = ".*_" + toDt;
    RowFilter upperBound = new RowFilter(
        CompareOperator.LESS_OR_EQUAL,
        new RegexStringComparator(toPattern)
    );
    compositeFilter.addFilter(upperBound);
}

scan.setFilter(compositeFilter);

완전한 조회 로직

public static List<Map<String, String>> scanWithRegexFilter(
        String tblName, 
        String beginAt, 
        String finishAt) {
    
    Connection conn = null;
    Table tbl = null;
    ResultScanner scanner = null;
    List<Map<String, String>> resultList = new ArrayList<>();

    try {
        conn = ConnectionFactory.createConnection(cfg);
        tbl = conn.getTable(TableName.valueOf(tblName));
        
        Scan scan = new Scan();
        scan.setCacheBlocks(false);
        scan.setCaching(500);

        FilterList filters = new FilterList(FilterList.Operator.MUST_PASS_ALL);
        
        if (beginAt != null && !beginAt.isEmpty()) {
            filters.addFilter(new RowFilter(
                CompareOperator.GREATER_OR_EQUAL,
                new RegexStringComparator(".*_" + beginAt)
            ));
        }
        
        if (finishAt != null && !finishAt.isEmpty()) {
            filters.addFilter(new RowFilter(
                CompareOperator.LESS_OR_EQUAL,
                new RegexStringComparator(".*_" + finishAt)
            ));
        }
        
        scan.setFilter(filters);
        scanner = tbl.getScanner(scan);

        for (Result row : scanner) {
            Map<String, String> record = new HashMap<>();
            for (Cell cell : row.listCells()) {
                String qualifier = Bytes.toString(
                    cell.getQualifierArray(),
                    cell.getQualifierOffset(),
                    cell.getQualifierLength()
                );
                String value = Bytes.toString(
                    cell.getValueArray(),
                    cell.getValueOffset(),
                    cell.getValueLength()
                );
                record.put(qualifier, value);
            }
            if (!record.isEmpty()) {
                resultList.add(record);
            }
        }
        
    } catch (IOException ex) {
        throw new RuntimeException("HBase scan operation failed", ex);
    } finally {
        CloseUtil.closeQuietly(scanner);
        CloseUtil.closeQuietly(tbl);
        CloseUtil.closeQuietly(conn);
    }
    
    return resultList;
}

추가 고려사항

RegexStringComparator는 편리하지만 전체 테이블 스캔 후 클라이언트 측에서 정규식 평가를 수행하므로 대용량 데이터에는 부적합하다. 더 나은 접근법은 다음과 같다:
  1. Salting + 시간 범위 스캔: RowKey를 해시접두사_시간_주문ID로 구성해 Region 분산과 범위 스캔을 동시에 달성
  2. Secondary Index: Phoenix나 자체 인덱스 테이블을 활용한 시간 기반 인덱싱
  3. FilterList.Operator.MUST_PASS_ONE 대신 MUST_PASS_ALL를 명시해 필터 간 AND 조건 보장
최종 개선 후 746건 전체 데이터 정상 조회 확인.

태그: HBase RowKey Design RegexStringComparator BinaryComparator Apache HBase Filter

6월 1일 16:30에 게시됨