HBase 테이블에 총 746건의 데이터가 존재하는 상황에서, Spark으로 처리하기 어려운 알고리즘을 Java로 직접 구현하여 HBase를 조회해야 했다. 성능 향상을 위해 필터를 적극 활용했으나, 예상치 못한 데이터 누락 문제가 발생했다.
초기 RowKey 설계 및 문제 발생
처음에는
시간_주문ID 형태로 RowKey를 구성했다.RowKey 구조: yyyyMMddHHmm_주문식별자
예시: 201904010000_ORDER-2024-A1B2
시간 범위로 빠르게 필터링하기 위해
RowFilter와 BinaryComparator를 조합한 코드를 작성했다.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의 바이트 배열을 처음부터 순차적으로 비교하는데, 201904010000과 201904010000_ORDER-001은 전혀 다른 값이다.예를 들어
startTime="20190401"으로 필터링하면:20190401_ORD-001→20190401보다 큼 ✓201904010000_ORD-002→20190401보다 큼 ✓201904005959_ORD-003→20190401보다 작음 ✗ (실제로는 2019-04-00 59:59, 유효하지 않지만 바이트 비교상)
더 큰 문제는
201904010000과 201904010000_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는 편리하지만 전체 테이블 스캔 후 클라이언트 측에서 정규식 평가를 수행하므로 대용량 데이터에는 부적합하다. 더 나은 접근법은 다음과 같다:- Salting + 시간 범위 스캔: RowKey를
해시접두사_시간_주문ID로 구성해 Region 분산과 범위 스캔을 동시에 달성 - Secondary Index: Phoenix나 자체 인덱스 테이블을 활용한 시간 기반 인덱싱
- FilterList.Operator.MUST_PASS_ONE 대신 MUST_PASS_ALL를 명시해 필터 간 AND 조건 보장
최종 개선 후 746건 전체 데이터 정상 조회 확인.