- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 1. 들어가며
- 2. HBase 데이터 모델 기본 원리
- 3. RowKey 설계 패턴
- 4. 핫스팟 문제의 원인과 탐지
- 5. 핫스팟 회피 전략
- 6. 스키마 설계 실전 패턴
- 7. 성능 안정화 운영
- 8. 실전 체크리스트와 안티패턴
- 9. 마무리

1. 들어가며
HBase는 Google Bigtable 논문을 기반으로 설계된 분산 Column-Family NoSQL 데이터베이스다. HDFS 위에서 동작하며 수십억 행, 수백만 컬럼 규모의 데이터를 밀리초 단위 지연으로 처리할 수 있다. 그러나 이 성능은 올바른 데이터 모델링이 전제될 때만 달성된다. RowKey 하나를 잘못 설계하면 수십 대의 RegionServer 중 단 한 대에 전체 트래픽이 몰리는 핫스팟이 발생하고, 클러스터 전체의 처리량이 사실상 단일 서버 수준으로 전락한다.
이 글은 HBase의 일반적인 아키텍처 소개가 아니라, 데이터 모델링과 핫스팟 회피에 집중하는 실전 플레이북이다. RowKey 설계 패턴, 핫스팟 탐지 방법, Region 분산 전략, 스키마 설계 패턴, 성능 안정화를 위한 운영 기법을 체계적으로 다룬다.
2. HBase 데이터 모델 기본 원리
핵심 구성 요소
HBase의 데이터 모델은 RDBMS와 근본적으로 다르다. 다음 다섯 가지 요소가 하나의 셀(Cell)을 구성한다.
| 구성 요소 | 설명 | 예시 |
|---|---|---|
| Row (RowKey) | 행을 고유하게 식별하는 바이트 배열. 사전순 정렬 | user001_20260308 |
| Column Family | 컬럼의 물리적 그룹. 테이블 생성 시 정의 | cf, info, stats |
| Column Qualifier | Column Family 내의 개별 컬럼명. 동적 추가 가능 | name, email, count |
| Timestamp | 셀의 버전을 나타내는 밀리초 단위 시각 | 1709856000000 |
| Cell (Value) | 위 네 좌표로 특정되는 실제 데이터 값 | "김영주" |
물리적 저장 구조
HBase의 데이터는 논리적으로는 테이블이지만, 물리적으로는 Column Family 단위로 분리 저장된다. 이 점이 스키마 설계에서 가장 중요한 제약이다.
논리적 뷰:
┌──────────────────────────────────────────────────────────────┐
│ RowKey │ CF:info │ CF:metrics │
│ │ name │ email │ cpu_avg │ mem_used │
├──────────────────────────────────────────────────────────────┤
│ server_web01 │ Web-01 │ ... │ 72.5 │ 8192 │
│ server_db01 │ DB-01 │ ... │ 45.2 │ 16384 │
└──────────────────────────────────────────────────────────────┘
물리적 저장 (Column Family별로 별도의 HFile):
[HFile: info]
(server_db01, info:email, t1) → "admin@example.com"
(server_db01, info:name, t1) → "DB-01"
(server_web01, info:email, t1) → "web@example.com"
(server_web01, info:name, t1) → "Web-01"
[HFile: metrics]
(server_db01, metrics:cpu_avg, t2) → 45.2
(server_db01, metrics:mem_used, t2) → 16384
(server_web01, metrics:cpu_avg, t2) → 72.5
(server_web01, metrics:mem_used, t2) → 8192
RowKey 정렬과 Region 분배
HBase 테이블은 RowKey의 사전순(lexicographic order) 으로 정렬되며, 연속된 RowKey 범위가 하나의 Region을 구성한다. Region은 RegionServer에 할당되어 실제 읽기/쓰기를 처리한다.
테이블 전체 RowKey 공간:
[aaa...] ─────────── [mmm...] ─────────── [zzz...]
Region 분할:
Region 1: [aaa ~ fff] → RegionServer A
Region 2: [fff ~ mmm] → RegionServer B
Region 3: [mmm ~ sss] → RegionServer C
Region 4: [sss ~ zzz] → RegionServer D
이 구조에서 핵심은, RowKey의 분포가 곧 부하 분포라는 점이다. 특정 범위의 RowKey에 쓰기가 집중되면, 해당 Region을 담당하는 단일 RegionServer에 부하가 몰린다.
Column Family 설계 원칙
Column Family는 반드시 테이블 생성 시 정의해야 하며, 물리적 저장과 설정(압축, TTL, 버전 수 등)의 단위가 된다. 설계 시 다음 원칙을 따른다.
- CF 수는 2~3개 이하로 유지: CF 간 flush가 연쇄적으로 발생하므로, CF가 많으면 불필요한 I/O가 증가한다.
- 접근 패턴이 다른 데이터는 분리: 자주 읽는 메타데이터와 대용량 바이너리를 같은 CF에 두면 BlockCache 효율이 떨어진다.
- CF 이름은 짧게: CF 이름은 모든 KeyValue에 반복 저장되므로,
information보다i가 저장 효율이 높다.
# Column Family 설계 예시
create 'metrics', \
{NAME => 'd', VERSIONS => 1, COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW', TTL => 7776000}, \
{NAME => 'm', VERSIONS => 1, COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW', TTL => 31536000}
# d: 원본 데이터 (90일 TTL)
# m: 집계된 메타 정보 (1년 TTL)
3. RowKey 설계 패턴
RowKey 설계는 HBase 성능의 80%를 결정한다. 읽기/쓰기 패턴, 데이터 분포, 스캔 범위를 모두 고려해야 한다.
3.1 Salting (접두사 분산)
Salting은 RowKey 앞에 해시 기반 접두사(salt)를 붙여 데이터를 여러 Region에 균등 분산시키는 기법이다.
public class SaltedRowKeyGenerator {
private static final int NUM_BUCKETS = 16; // Region 수에 맞춤
/**
* 원본 RowKey에 salt prefix를 추가하여 분산시킨다.
* 예: "20260308_sensor001" → "0a_20260308_sensor001"
*/
public static byte[] generateSaltedKey(String originalKey) {
int bucket = Math.abs(originalKey.hashCode() % NUM_BUCKETS);
String saltPrefix = String.format("%02x", bucket);
String saltedKey = saltPrefix + "_" + originalKey;
return Bytes.toBytes(saltedKey);
}
/**
* 특정 원본 키에 대한 salt를 역산한다 (Get 요청 시).
*/
public static byte[] getSaltedKey(String originalKey) {
return generateSaltedKey(originalKey); // 동일한 해시 결과
}
/**
* 전체 범위 Scan: salt별로 병렬 스캔을 실행해야 한다.
*/
public static List<Scan> createParallelScans(String startKey, String endKey) {
List<Scan> scans = new ArrayList<>();
for (int i = 0; i < NUM_BUCKETS; i++) {
String prefix = String.format("%02x", i);
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes(prefix + "_" + startKey));
scan.withStopRow(Bytes.toBytes(prefix + "_" + endKey));
scans.add(scan);
}
return scans;
}
}
적합한 시나리오: 쓰기 처리량 극대화가 필요하고, 범위 스캔이 드문 경우 (로그 적재, 이벤트 수집)
트레이드오프: 범위 스캔 시 모든 salt bucket에 대해 병렬 스캔을 수행해야 하므로 스캔 비용이 증가한다.
3.2 Hashing (해시 접두사)
RowKey 전체 또는 일부를 해시 함수로 변환하여 균등 분산을 달성한다. Salting과 유사하지만, 해시 결과 자체를 접두사로 사용하여 더 넓은 분산 범위를 확보한다.
import org.apache.commons.codec.digest.DigestUtils;
public class HashedRowKeyGenerator {
/**
* MD5 해시의 앞 4자리를 접두사로 사용.
* 65,536가지 분산 범위를 제공한다.
*/
public static byte[] createHashedKey(String userId, long timestamp) {
String baseKey = userId + "_" + timestamp;
String hashPrefix = DigestUtils.md5Hex(baseKey).substring(0, 4);
String rowKey = hashPrefix + "_" + userId + "_" + timestamp;
return Bytes.toBytes(rowKey);
}
/**
* 특정 사용자의 데이터를 조회할 때도 동일한 해시 계산이 필요.
*/
public static byte[] getHashedKey(String userId, long timestamp) {
return createHashedKey(userId, timestamp);
}
}
// 사용 예시
// 원본: "user001_1709856000000"
// 해시: "a3f2_user001_1709856000000"
적합한 시나리오: Point Get 위주의 접근 패턴. 특정 RowKey를 알고 있을 때 빠른 조회가 필요한 경우.
주의: 해시는 원본 키의 순서를 파괴하므로, 범위 스캔이 사실상 불가능하다.
3.3 Key Reversing (키 뒤집기)
도메인명이나 타임스탬프처럼 앞부분은 유사하고 뒷부분이 다양한 키를 뒤집어서 분산을 확보한다.
public class ReversedKeyExamples {
/**
* 도메인 뒤집기: 같은 TLD에 속하는 도메인들을 분산시킨다.
* "com.google.www" → "www.elgoog.moc" (뒤집기)
* "com.google.mail" → "liam.elgoog.moc"
*
* 또는 더 실용적인 방법: reverse domain notation을 그대로 사용
* "www.google.com" → "moc.elgoog.www"
*/
public static String reverseDomain(String domain) {
return new StringBuilder(domain).reverse().toString();
}
/**
* Reverse Timestamp: 최신 데이터를 먼저 스캔하기 위한 패턴.
* HBase는 RowKey 오름차순 정렬이므로, Long.MAX_VALUE에서 빼면
* 최신 타임스탬프가 가장 작은 값(상위)에 위치한다.
*/
public static long reverseTimestamp(long timestamp) {
return Long.MAX_VALUE - timestamp;
}
/**
* 사용자별 최신 활동을 빠르게 조회하는 RowKey 설계.
*/
public static byte[] createUserActivityKey(String userId, long timestamp) {
long reversedTs = Long.MAX_VALUE - timestamp;
String rowKey = userId + "_" + String.format("%019d", reversedTs);
return Bytes.toBytes(rowKey);
// 결과: "user001_9223370449055775807"
// Scan(startRow=user001_, stopRow=user002) → 최신순 반환
}
}
적합한 시나리오: "특정 사용자의 최근 N건"처럼 특정 접두사 내에서 최신 데이터를 우선 조회하는 패턴.
3.4 Composite Key (복합 키)
여러 차원의 데이터를 하나의 RowKey에 결합한다. 구분자로 _나 null byte(\x00)를 사용한다.
public class CompositeKeyDesign {
/**
* IoT 센서 데이터용 복합 키 설계.
* 구조: {region_code}_{device_id}_{reverse_timestamp}
*
* - region_code: 지리적 분산 (2바이트)
* - device_id: 디바이스 식별 (가변)
* - reverse_timestamp: 최신순 정렬 (8바이트)
*/
public static byte[] createIoTRowKey(String regionCode, String deviceId, long timestamp) {
long reversedTs = Long.MAX_VALUE - timestamp;
// 고정 길이로 패딩하여 정렬 일관성 보장
String rowKey = String.format("%s_%s_%019d", regionCode, deviceId, reversedTs);
return Bytes.toBytes(rowKey);
}
/**
* 메시징 시스템용 복합 키.
* 구조: {chat_room_id}_{reverse_timestamp}_{message_id}
* → 특정 채팅방의 최신 메시지를 Scan으로 효율적 조회
*/
public static byte[] createMessageRowKey(String roomId, long timestamp, String msgId) {
long reversedTs = Long.MAX_VALUE - timestamp;
return Bytes.toBytes(roomId + "_" + String.format("%019d", reversedTs) + "_" + msgId);
}
}
3.5 시계열 데이터를 위한 RowKey 설계
시계열 데이터는 HBase에서 가장 흔한 워크로드이면서, 핫스팟이 가장 발생하기 쉬운 유형이다. 순차적 타임스탬프를 RowKey로 직접 사용하면 항상 마지막 Region에 쓰기가 집중된다.
[잘못된 설계] 타임스탬프를 RowKey 앞에 배치
RowKey: 20260308120000_sensor001
RowKey: 20260308120001_sensor001
RowKey: 20260308120002_sensor001
→ 모든 쓰기가 마지막 Region에 집중 (핫스팟!)
[올바른 설계] salt + 디바이스ID + reverse timestamp
RowKey: 0a_sensor001_9223370449055775807
RowKey: 03_sensor002_9223370449055775807
RowKey: 0f_sensor003_9223370449055775807
→ 16개 Region에 균등 분산
시계열 데이터에 권장하는 RowKey 패턴:
/**
* 시계열 메트릭 수집용 RowKey 생성기.
*
* 패턴: {salt}_{metric_name}_{device_id}_{reverse_timestamp}
*
* 장점:
* 1. salt로 Region 분산
* 2. metric_name + device_id로 특정 메트릭의 Scan 범위 한정
* 3. reverse_timestamp로 최신 데이터 우선 조회
*/
public class TimeSeriesRowKeyGenerator {
private static final int SALT_BUCKETS = 32;
public static byte[] generate(String metricName, String deviceId, long timestamp) {
String baseKey = metricName + "_" + deviceId;
int salt = Math.abs(baseKey.hashCode() % SALT_BUCKETS);
long reversedTs = Long.MAX_VALUE - timestamp;
String rowKey = String.format("%02x_%s_%s_%019d",
salt, metricName, deviceId, reversedTs);
return Bytes.toBytes(rowKey);
}
/**
* 특정 디바이스의 특정 메트릭에 대한 시간 범위 Scan.
* salt를 알고 있으므로 단일 Region에 대한 효율적 스캔이 가능.
*/
public static Scan createRangeScan(
String metricName, String deviceId,
long startTime, long endTime) {
String baseKey = metricName + "_" + deviceId;
int salt = Math.abs(baseKey.hashCode() % SALT_BUCKETS);
String prefix = String.format("%02x", salt);
// reverse timestamp이므로 start/end가 반전
long reversedEnd = Long.MAX_VALUE - startTime;
long reversedStart = Long.MAX_VALUE - endTime;
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes(
prefix + "_" + metricName + "_" + deviceId + "_"
+ String.format("%019d", reversedStart)));
scan.withStopRow(Bytes.toBytes(
prefix + "_" + metricName + "_" + deviceId + "_"
+ String.format("%019d", reversedEnd)));
scan.setCaching(500);
return scan;
}
}
RowKey 설계 의사결정 가이드
| 접근 패턴 | 권장 RowKey 전략 | 이유 |
|---|---|---|
| 랜덤 Point Get 위주 | Hashing | 균등 분산, 순서 불필요 |
| 특정 엔티티의 최신 N건 | entity_id + reverse timestamp | 접두사 Scan으로 최신순 조회 |
| 대량 순차 쓰기 (로그) | Salting + composite key | 쓰기 분산 + 최소한의 Scan 지원 |
| 시계열 범위 조회 | salt + metric + device + reverse ts | 분산과 범위 조회 모두 지원 |
| 전문 검색 대체 | reverse domain + path | 하위 도메인 그룹핑 |
4. 핫스팟 문제의 원인과 탐지
핫스팟이란
핫스팟(Hotspot)은 특정 Region에 읽기 또는 쓰기 요청이 비정상적으로 집중되는 현상이다. HBase는 수평 확장을 전제로 설계되었지만, 핫스팟이 발생하면 단일 RegionServer의 처리 한계가 전체 클러스터의 병목이 된다.
정상 분포:
RS-1: ████████ (25%)
RS-2: ████████ (25%)
RS-3: ████████ (25%)
RS-4: ████████ (25%)
핫스팟 발생:
RS-1: ██ (5%)
RS-2: █ (2%)
RS-3: █ (3%)
RS-4: ████████████████████████████████████████ (90%) ← 과부하!
핫스팟의 주요 원인
1. Sequential RowKey (순차 키)
타임스탬프, 자동 증가 ID 등 단조 증가하는 값을 RowKey로 사용하면, 새로운 데이터가 항상 키 공간의 끝(마지막 Region)에 쓰인다.
# 잘못된 예시: 타임스탬프 기반 RowKey
2026030812000001 → Region [2026030811~ 2026030812] ← 여기에만 쓰기 집중
2026030812000002 → Region [2026030811~ 2026030812]
2026030812000003 → Region [2026030811~ 2026030812]
2. 편향된 키 분포
특정 접두사의 데이터가 다른 접두사보다 압도적으로 많은 경우. 예를 들어, 사용자 ID가 user_ 접두사로 시작하는 데이터가 90%이고 나머지 접두사는 10%라면, user_ 범위의 Region에 부하가 집중된다.
3. 인기 키(Popular Key)
소수의 특정 RowKey에 읽기/쓰기가 집중되는 경우. 유명인의 프로필, 인기 상품 페이지 등이 해당된다.
핫스팟 탐지 방법
HBase Shell로 Region별 요청 수 확인:
# 테이블의 Region 분포 확인
hbase shell <<'EOF'
status 'detailed'
EOF
# 특정 테이블의 Region별 요청 수 확인
hbase shell <<'EOF'
list_regions 'metrics_table'
EOF
# 출력 예시:
# REGION | START_KEY | END_KEY | SIZE | REQ | LOCALITY
# metrics_table,,1709... | (empty) | 08_ | 2.1GB | 1204 | 0.95
# metrics_table,08_,17... | 08_ | 10_ | 2.3GB | 982341 | 0.92 ← 핫스팟!
# metrics_table,10_,17... | 10_ | 18_ | 1.8GB | 1102 | 0.97
JMX 메트릭으로 RegionServer별 부하 확인:
# RegionServer별 요청 수 비교
curl -s "http://regionserver1:16030/jmx?qry=Hadoop:service=HBase,name=RegionServer,sub=Server" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for bean in data['beans']:
print(f\"ReadRequests: {bean.get('readRequestCount', 'N/A')}\")
print(f\"WriteRequests: {bean.get('writeRequestCount', 'N/A')}\")
print(f\"TotalRequests: {bean.get('totalRequestCount', 'N/A')}\")
"
# 모든 RegionServer의 요청 비율을 비교하여
# 특정 서버의 요청이 평균의 3배 이상이면 핫스팟 의심
HBase 메트릭을 활용한 자동 탐지 스크립트:
#!/bin/bash
# hotspot_detector.sh - Region별 요청 편차 탐지
TABLE="metrics_table"
THRESHOLD=3.0 # 평균 대비 3배 이상이면 경고
echo "=== Hotspot Detection for $TABLE ==="
# Region별 요청 수 추출
REGIONS=$(echo "list_regions '$TABLE'" | hbase shell 2>/dev/null | grep -E "^\s+\{" | \
awk -F',' '{for(i=1;i<=NF;i++) if($i ~ /REQ/) print $i}' | \
grep -oP '\d+')
if [ -z "$REGIONS" ]; then
echo "No region data found."
exit 1
fi
# 평균 계산
TOTAL=0
COUNT=0
for req in $REGIONS; do
TOTAL=$((TOTAL + req))
COUNT=$((COUNT + 1))
done
AVG=$((TOTAL / COUNT))
echo "Average requests per region: $AVG"
echo "Threshold (${THRESHOLD}x average): $(echo "$AVG * $THRESHOLD" | bc | cut -d. -f1)"
echo ""
# 핫스팟 Region 식별
IDX=0
for req in $REGIONS; do
RATIO=$(echo "scale=2; $req / $AVG" | bc)
if (( $(echo "$RATIO > $THRESHOLD" | bc -l) )); then
echo "[HOTSPOT] Region $IDX: $req requests (${RATIO}x average)"
fi
IDX=$((IDX + 1))
done
5. 핫스팟 회피 전략
5.1 Pre-splitting (사전 분할)
테이블 생성 시 Region을 미리 분할하면, 초기 데이터 적재 시 단일 Region에 부하가 집중되는 것을 방지할 수 있다.
# 방법 1: 균등 분할 (hex 기반)
# RowKey가 해시 접두사로 시작하는 경우에 적합
create 'events', 'data', SPLITS => [
'10', '20', '30', '40', '50', '60', '70', '80', '90',
'a0', 'b0', 'c0', 'd0', 'e0', 'f0'
]
# 방법 2: HexStringSplit 유틸리티 사용
# 지정된 Region 수로 hex 키 공간을 균등 분할
create 'events', 'data', {NUMREGIONS => 16, SPLITALGO => 'HexStringSplit'}
# 방법 3: UniformSplit (바이너리 키 균등 분할)
create 'events', 'data', {NUMREGIONS => 32, SPLITALGO => 'UniformSplit'}
# 방법 4: 커스텀 split 포인트 파일 사용
# splits.txt 파일에 split 포인트를 한 줄에 하나씩 기록
create 'events', 'data', SPLITS_FILE => '/path/to/splits.txt'
Pre-split Region 수 결정 기준:
권장 공식:
초기 Region 수 = RegionServer 수 × 서버당 권장 Region 수(10~30)
예시:
- 10대 RegionServer 클러스터
- 서버당 20 Region 목표
- 초기 Region 수 = 10 × 20 = 200
단, RowKey의 분포를 고려하여 실제로 균등하게 나뉘는지 검증 필요.
5.2 Key 분산 (Bucketing)
RowKey 앞에 고정된 개수의 bucket 번호를 접두사로 추가하여, 쓰기를 여러 Region에 분산시킨다.
/**
* Bucket 기반 RowKey 분산기.
* N개의 bucket으로 쓰기를 분산시키되, 읽기 시에는
* bucket 번호를 계산하여 정확한 Region으로 직접 접근한다.
*/
public class BucketedKeyStrategy {
private final int numBuckets;
public BucketedKeyStrategy(int numBuckets) {
this.numBuckets = numBuckets;
}
/**
* bucket 번호는 RowKey의 해시로 결정되므로,
* 같은 원본 키는 항상 같은 bucket에 매핑된다.
*/
public byte[] createKey(String entityId, long timestamp) {
int bucket = Math.abs(entityId.hashCode() % numBuckets);
String key = String.format("%04d_%s_%d", bucket, entityId, timestamp);
return Bytes.toBytes(key);
}
/**
* 특정 엔티티의 모든 데이터를 조회할 때:
* bucket 번호를 계산하여 단일 Scan으로 조회 가능.
*/
public Scan createEntityScan(String entityId) {
int bucket = Math.abs(entityId.hashCode() % numBuckets);
String prefix = String.format("%04d_%s_", bucket, entityId);
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes(prefix));
scan.withStopRow(Bytes.toBytes(prefix + "~")); // ~는 ASCII에서 높은 값
return scan;
}
/**
* 전체 데이터를 스캔할 때:
* 모든 bucket에 대해 병렬 Scan 실행.
*/
public List<Scan> createFullScans() {
List<Scan> scans = new ArrayList<>();
for (int i = 0; i < numBuckets; i++) {
String startPrefix = String.format("%04d_", i);
String endPrefix = String.format("%04d_~", i);
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes(startPrefix));
scan.withStopRow(Bytes.toBytes(endPrefix));
scans.add(scan);
}
return scans;
}
}
5.3 TTL 기반 데이터 파티셔닝
시계열 데이터에서 오래된 데이터를 자동으로 만료시키면, Region 크기를 일정하게 유지하고 Compaction 부하를 줄일 수 있다.
# Column Family에 TTL 설정 (초 단위)
# 90일 후 자동 삭제
alter 'sensor_data', {NAME => 'raw', TTL => 7776000}
# 집계 데이터는 1년 보관
alter 'sensor_data', {NAME => 'agg', TTL => 31536000}
<!-- hbase-site.xml: 테이블 레벨 TTL 정책과 연계한 설정 -->
<configuration>
<!-- MemStore flush 후 Major Compaction 시 만료 데이터 정리 -->
<property>
<name>hbase.hstore.compaction.min</name>
<value>3</value>
</property>
<!-- TTL이 설정된 테이블에서 Major Compaction 주기를 단축하여 -->
<!-- 만료 데이터를 빠르게 정리 (기본 7일 → 1일) -->
<property>
<name>hbase.hregion.majorcompaction</name>
<value>86400000</value>
</property>
</configuration>
5.4 시간 기반 테이블 파티셔닝
데이터를 시간 단위로 별도의 테이블에 저장하면, 오래된 테이블을 통째로 삭제하여 Compaction 없이 빠르게 정리할 수 있다.
/**
* 일별/월별 테이블 파티셔닝 전략.
* 테이블명에 날짜를 포함시켜 생명주기를 관리한다.
*/
public class TimePartitionedTableStrategy {
private final Connection connection;
private final Admin admin;
public TimePartitionedTableStrategy(Connection connection) throws IOException {
this.connection = connection;
this.admin = connection.getAdmin();
}
/**
* 월별 테이블 자동 생성.
* 예: logs_202603, logs_202604
*/
public Table getOrCreateMonthlyTable(String baseName, LocalDate date) throws IOException {
String tableName = baseName + "_" + date.format(DateTimeFormatter.ofPattern("yyyyMM"));
TableName tn = TableName.valueOf(tableName);
if (!admin.tableExists(tn)) {
TableDescriptorBuilder builder = TableDescriptorBuilder.newBuilder(tn);
ColumnFamilyDescriptor cf = ColumnFamilyDescriptorBuilder
.newBuilder(Bytes.toBytes("d"))
.setCompressingAlgo(Algorithm.SNAPPY)
.setBloomFilterType(BloomType.ROW)
.setMaxVersions(1)
.build();
builder.setColumnFamily(cf);
// 16개 Region으로 Pre-split
byte[][] splits = RegionSplitter.HexStringSplit
.split(16);
admin.createTable(builder.build(), splits);
}
return connection.getTable(tn);
}
/**
* 90일 이상 된 테이블 삭제.
* DROP TABLE은 Compaction 없이 즉시 완료되므로 TTL보다 효율적.
*/
public void purgeOldTables(String baseName, int retentionDays) throws IOException {
LocalDate cutoff = LocalDate.now().minusDays(retentionDays);
String cutoffStr = cutoff.format(DateTimeFormatter.ofPattern("yyyyMM"));
for (TableDescriptor td : admin.listTableDescriptors()) {
String name = td.getTableName().getNameAsString();
if (name.startsWith(baseName + "_")) {
String datePart = name.substring(baseName.length() + 1);
if (datePart.compareTo(cutoffStr) < 0) {
admin.disableTable(td.getTableName());
admin.deleteTable(td.getTableName());
System.out.println("Purged table: " + name);
}
}
}
}
}
6. 스키마 설계 실전 패턴
6.1 Tall-Narrow vs Flat-Wide
HBase 테이블 설계에서 가장 중요한 구조적 선택이다.
Tall-Narrow (높고 좁은 구조)
행 수가 많고, 각 행의 컬럼 수가 적다. 각 이벤트나 측정값을 별도의 행으로 저장한다.
RowKey | d:value | d:type
────────────────────────────────────┼─────────┼────────
sensor001_9223370449055775807 | 72.5 | cpu
sensor001_9223370449055775808 | 71.3 | cpu
sensor001_9223370449055775809 | 73.1 | cpu
Flat-Wide (납작하고 넓은 구조)
행 수가 적고, 각 행의 컬럼 수가 매우 많다. 한 엔티티의 모든 시계열 데이터를 Column Qualifier에 인코딩한다.
RowKey | d:20260308120000 | d:20260308120100 | d:20260308120200 | ...
─────────────┼──────────────────┼──────────────────┼──────────────────┤
sensor001 | 72.5 | 71.3 | 73.1 | (수천 컬럼)
비교와 선택 기준:
| 기준 | Tall-Narrow | Flat-Wide |
|---|---|---|
| 원자적 행 연산 | 행 단위로만 원자적 | 전체 시계열이 원자적 |
| 스캔 효율 | 시간 범위 Scan 용이 | 단일 Get으로 전체 조회 |
| 행 크기 제한 | 제한 없음 | 컬럼 수가 많으면 Region Split 문제 |
| 삭제 효율 | 행 단위 삭제 | 컬럼 단위 삭제 필요 |
| 권장 사용처 | 대부분의 시계열 | 소규모 시계열, 단일 Get 패턴 |
일반적으로 Tall-Narrow를 권장한다. HBase의 Scan 성능은 행 수보다 데이터 크기에 비례하며, Flat-Wide는 단일 행이 너무 커지면 Region Split 지점 결정이 어려워진다.
6.2 역인덱스 (Secondary Index) 패턴
HBase는 RowKey에 대한 인덱스만 기본 제공한다. 다른 컬럼으로 검색하려면 역인덱스 테이블을 별도로 관리해야 한다.
/**
* 역인덱스 테이블을 활용한 다차원 검색 지원.
*
* 메인 테이블: users (RowKey = user_id)
* 인덱스 테이블: users_by_email (RowKey = email, value = user_id)
* 인덱스 테이블: users_by_region (RowKey = region_user_id, value = "")
*/
public class SecondaryIndexManager {
private final Table mainTable;
private final Table emailIndex;
private final Table regionIndex;
/**
* 데이터 삽입 시 메인 테이블과 인덱스 테이블 모두 업데이트.
*/
public void putWithIndex(String userId, String email, String region,
Map<String, String> attributes) throws IOException {
// 1. 메인 테이블에 데이터 삽입
Put mainPut = new Put(Bytes.toBytes(userId));
mainPut.addColumn(Bytes.toBytes("info"), Bytes.toBytes("email"),
Bytes.toBytes(email));
mainPut.addColumn(Bytes.toBytes("info"), Bytes.toBytes("region"),
Bytes.toBytes(region));
for (Map.Entry<String, String> attr : attributes.entrySet()) {
mainPut.addColumn(Bytes.toBytes("info"),
Bytes.toBytes(attr.getKey()),
Bytes.toBytes(attr.getValue()));
}
mainTable.put(mainPut);
// 2. 이메일 인덱스 업데이트
Put emailPut = new Put(Bytes.toBytes(email));
emailPut.addColumn(Bytes.toBytes("idx"), Bytes.toBytes("uid"),
Bytes.toBytes(userId));
emailIndex.put(emailPut);
// 3. 리전 인덱스 업데이트 (복합 키: region_userId)
Put regionPut = new Put(Bytes.toBytes(region + "_" + userId));
regionPut.addColumn(Bytes.toBytes("idx"), Bytes.toBytes(""),
Bytes.toBytes(""));
regionIndex.put(regionPut);
}
/**
* 이메일로 사용자 조회: 인덱스 → 메인 테이블 2-hop 조회.
*/
public Result getUserByEmail(String email) throws IOException {
Get indexGet = new Get(Bytes.toBytes(email));
Result indexResult = emailIndex.get(indexGet);
byte[] userId = indexResult.getValue(Bytes.toBytes("idx"),
Bytes.toBytes("uid"));
if (userId == null) return null;
Get mainGet = new Get(userId);
return mainTable.get(mainGet);
}
/**
* 특정 리전의 모든 사용자 조회: 인덱스 테이블 Prefix Scan.
*/
public List<String> getUsersByRegion(String region) throws IOException {
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes(region + "_"));
scan.withStopRow(Bytes.toBytes(region + "_~"));
List<String> userIds = new ArrayList<>();
try (ResultScanner scanner = regionIndex.getScanner(scan)) {
for (Result r : scanner) {
String rowKey = Bytes.toString(r.getRow());
String userId = rowKey.substring(region.length() + 1);
userIds.add(userId);
}
}
return userIds;
}
}
역인덱스 운영 시 주의사항:
- 메인 테이블과 인덱스 테이블 간의 일관성은 애플리케이션에서 보장해야 한다. HBase는 테이블 간 트랜잭션을 지원하지 않는다.
- 데이터 삭제/갱신 시 인덱스도 함께 정리해야 한다. 그렇지 않으면 고아 인덱스(orphan index) 가 누적된다.
- Phoenix나 HBase Coprocessor를 활용하면 인덱스 관리를 자동화할 수 있다.
6.3 Phoenix를 활용한 Secondary Index
Apache Phoenix는 HBase 위에 SQL 레이어를 제공하며, Secondary Index를 자동으로 관리한다.
-- Phoenix에서 테이블 생성과 인덱스 활용
-- 테이블 생성
CREATE TABLE IF NOT EXISTS users (
user_id VARCHAR NOT NULL PRIMARY KEY,
email VARCHAR,
region VARCHAR,
created_at TIMESTAMP,
login_count BIGINT
) SALT_BUCKETS=16, COMPRESSION='SNAPPY';
-- Covered Index: 인덱스에 추가 컬럼을 포함하여 메인 테이블 조회 없이 결과 반환
CREATE INDEX idx_users_email ON users (email)
INCLUDE (region, login_count);
-- 이메일로 조회 시 인덱스 자동 사용
SELECT user_id, email, region, login_count
FROM users
WHERE email = 'user@example.com';
-- 리전별 사용자 조회
CREATE INDEX idx_users_region ON users (region, created_at DESC)
INCLUDE (email);
SELECT user_id, email, created_at
FROM users
WHERE region = 'ap-northeast-2'
ORDER BY created_at DESC
LIMIT 100;
7. 성능 안정화 운영
7.1 Compaction 전략
Compaction은 HBase 성능에 가장 큰 영향을 미치는 백그라운드 작업이다. 잘못 관리하면 쓰기 지연, 읽기 성능 저하, I/O 폭주를 일으킨다.
Minor Compaction: 작은 HFile들을 병합. 삭제 마커(tombstone)는 유지. 자동으로 수시 실행.
Major Compaction: 모든 HFile을 하나로 병합. 삭제 마커와 만료 데이터를 제거. 대량 I/O를 발생시키므로 신중하게 관리해야 한다.
<!-- hbase-site.xml: 프로덕션 Compaction 설정 -->
<configuration>
<!-- Major Compaction 자동 실행 비활성화 -->
<property>
<name>hbase.hregion.majorcompaction</name>
<value>0</value>
<description>0으로 설정하여 자동 Major Compaction 비활성화.
cron으로 비피크 시간에 수동 실행.</description>
</property>
<!-- Minor Compaction 트리거: HFile 3개 이상이면 실행 -->
<property>
<name>hbase.hstore.compactionThreshold</name>
<value>3</value>
</property>
<!-- Minor Compaction에 포함할 최대 HFile 수 -->
<property>
<name>hbase.hstore.compaction.max</name>
<value>10</value>
</property>
<!-- Compaction 대상 HFile 최소 크기 -->
<property>
<name>hbase.hstore.compaction.min.size</name>
<value>134217728</value> <!-- 128MB -->
</property>
<!-- Compaction throttle: I/O 제한으로 서비스 영향 최소화 -->
<property>
<name>hbase.regionserver.throughput.controller</name>
<value>org.apache.hadoop.hbase.regionserver.compactions.PressureAwareCompactionThroughputController</value>
</property>
<property>
<name>hbase.hstore.compaction.throughput.lower.bound</name>
<value>52428800</value> <!-- 50MB/s 하한 -->
</property>
<property>
<name>hbase.hstore.compaction.throughput.higher.bound</name>
<value>104857600</value> <!-- 100MB/s 상한 -->
</property>
</configuration>
비피크 시간에 Major Compaction 실행 (cron):
#!/bin/bash
# major_compaction_scheduler.sh
# crontab: 0 3 * * 0 /opt/hbase/scripts/major_compaction_scheduler.sh
TABLES=("metrics_table" "events_table" "logs_table")
LOG_FILE="/var/log/hbase/major_compaction_$(date +%Y%m%d).log"
echo "=== Major Compaction Start: $(date) ===" >> "$LOG_FILE"
for table in "${TABLES[@]}"; do
echo "Compacting $table..." >> "$LOG_FILE"
echo "major_compact '$table'" | hbase shell >> "$LOG_FILE" 2>&1
# 테이블 간 1시간 간격을 두어 I/O 부하 분산
sleep 3600
done
echo "=== Major Compaction End: $(date) ===" >> "$LOG_FILE"
7.2 Region Split과 Merge 관리
자동 Split 정책 튜닝:
<configuration>
<!-- Region 최대 크기: 이 크기에 도달하면 자동 Split -->
<property>
<name>hbase.hregion.max.filesize</name>
<value>10737418240</value> <!-- 10GB -->
</property>
<!-- Split 정책 선택 -->
<property>
<name>hbase.regionserver.region.split.policy</name>
<value>org.apache.hadoop.hbase.regionserver.SteppingSplitPolicy</value>
<description>
SteppingSplitPolicy: Region 수가 적을 때는 빠르게 분할하고,
Region 수가 충분해지면 max.filesize까지 기다림.
IncreasingToUpperBoundRegionSplitPolicy보다 안정적.
</description>
</property>
</configuration>
수동 Split과 Merge:
# 핫스팟 Region 수동 분할
# 먼저 핫스팟 Region의 encoded name과 적절한 split key를 확인
hbase shell <<'EOF'
list_regions 'metrics_table'
EOF
# 특정 키에서 Region 분할
hbase shell <<'EOF'
split 'metrics_table', '08_sensor500'
EOF
# Region Merge: 과도하게 분할된 소규모 Region 병합
# HBase 2.x에서는 hbase shell의 merge_region 명령 사용
hbase shell <<'EOF'
merge_region 'ENCODED_REGION_NAME_1', 'ENCODED_REGION_NAME_2', true
EOF
7.3 BlockCache와 BucketCache 설정
읽기 성능의 핵심은 캐시 적중률이다. BlockCache는 HFile의 데이터 블록을 메모리에 캐싱한다.
<configuration>
<!-- On-heap BlockCache 비율 (힙의 40%) -->
<property>
<name>hfile.block.cache.size</name>
<value>0.4</value>
</property>
<!-- BucketCache 활성화: Off-heap 메모리로 캐시 확장 -->
<property>
<name>hbase.bucketcache.ioengine</name>
<value>offheap</value>
<description>offheap, file:/path/to/cache, mmap:/path/to/cache 중 선택</description>
</property>
<!-- BucketCache 크기 (MB) -->
<property>
<name>hbase.bucketcache.size</name>
<value>8192</value> <!-- 8GB Off-heap -->
</property>
<!-- CombinedBlockCache 사용: On-heap(인덱스/메타) + Off-heap(데이터) -->
<property>
<name>hbase.bucketcache.combinedcache.enabled</name>
<value>true</value>
</property>
</configuration>
# RegionServer JVM 설정 (hbase-env.sh)
# On-heap 32GB + Off-heap(BucketCache) 8GB
export HBASE_REGIONSERVER_OPTS="
-Xmx32g -Xms32g
-XX:MaxDirectMemorySize=10g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=65
"
캐시 적중률 모니터링:
# BlockCache 적중률 확인 (JMX)
curl -s "http://regionserver:16030/jmx?qry=Hadoop:service=HBase,name=RegionServer,sub=Server" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for bean in data['beans']:
hit = bean.get('blockCacheHitCount', 0)
miss = bean.get('blockCacheMissCount', 0)
total = hit + miss
ratio = (hit / total * 100) if total > 0 else 0
print(f'BlockCache Hit Ratio: {ratio:.1f}%')
print(f' Hits: {hit:,}')
print(f' Misses: {miss:,}')
print(f' Evictions: {bean.get(\"blockCacheEvictionCount\", 0):,}')
"
# 목표: 95% 이상 적중률 유지
7.4 MemStore 튜닝
MemStore는 쓰기 버퍼로, 가득 차면 HFile로 Flush된다. MemStore 크기와 Flush 빈도가 쓰기 성능에 직접적인 영향을 준다.
<configuration>
<!-- 개별 MemStore flush 크기 -->
<property>
<name>hbase.hregion.memstore.flush.size</name>
<value>134217728</value> <!-- 128MB -->
</property>
<!-- RegionServer 전체 MemStore 상한 (힙의 40%) -->
<property>
<name>hbase.regionserver.global.memstore.size</name>
<value>0.4</value>
</property>
<!-- 전체 MemStore가 이 비율을 초과하면 강제 Flush 시작 -->
<property>
<name>hbase.regionserver.global.memstore.size.lower.limit</name>
<value>0.95</value>
</property>
<!-- MemStore + BlockCache 합이 힙의 80%를 초과하지 않도록 -->
<!-- global.memstore.size(0.4) + block.cache.size(0.4) = 0.8 -->
</configuration>
8. 실전 체크리스트와 안티패턴
설계 단계 체크리스트
- RowKey 설계 검증: 가장 빈번한 읽기/쓰기 패턴을 기준으로 RowKey를 설계했는가?
- 핫스팟 시뮬레이션: 예상 데이터로 RowKey를 생성하고 Region 분포를 시뮬레이션했는가?
- Pre-split 계획: 초기 Region 수를 클러스터 규모에 맞게 결정했는가?
- Column Family 최소화: CF 수가 3개 이하인가? CF 간 접근 패턴이 확실히 다른가?
- TTL/VERSIONS 설정: 불필요한 데이터가 무한히 쌓이지 않도록 TTL과 버전 수를 설정했는가?
- Bloom Filter 설정: Get 위주면 ROW, Get + Column 위주면 ROWCOL로 설정했는가?
- 압축 설정: SNAPPY 또는 LZ4 압축을 활성화했는가?
- 역인덱스 필요성 검토: RowKey 외 컬럼으로 검색이 필요하면 인덱스 전략을 수립했는가?
운영 단계 체크리스트
- Major Compaction 스케줄: 자동 실행을 비활성화하고 비피크 시간에 수동 실행하는가?
- Region 분포 모니터링: Region별 요청 수를 주기적으로 확인하여 핫스팟을 탐지하는가?
- BlockCache 적중률: 95% 이상을 유지하고 있는가?
- GC Pause 모니터링: 5초 이상의 STW(Stop-the-World) GC가 발생하지 않는가?
- MemStore Flush 빈도: 비정상적으로 잦은 Flush가 발생하지 않는가?
- Compaction Queue: Queue 크기가 지속적으로 10 이상이 아닌가?
- HDFS 디스크 사용률: 80% 이하를 유지하고 있는가?
안티패턴 모음
안티패턴 1: 타임스탬프를 RowKey 앞에 배치
# 잘못된 예
RowKey: 20260308120000_event_click
# → 모든 최신 쓰기가 마지막 Region에 집중
# 올바른 대안
RowKey: 0a_click_20260308120000 (salt + event_type + timestamp)
안티패턴 2: 과도한 Column Family 수
# 잘못된 예: CF가 10개
create 'user_profile', 'basic', 'contact', 'preference', 'history',
'security', 'billing', 'social', 'activity', 'settings', 'cache'
# → CF 간 연쇄 flush로 I/O 폭주, MemStore 메모리 낭비
# 올바른 대안: 2~3개 CF로 통합
create 'user_profile', \
{NAME => 'i', VERSIONS => 1}, \ # info: 기본 + 연락처 + 설정
{NAME => 'a', VERSIONS => 1, TTL => 7776000} # activity: 활동 로그 (90일)
안티패턴 3: 가변 길이 RowKey의 정렬 문제
# 잘못된 예: 숫자를 문자열로 저장 (사전순 정렬 주의)
"1", "10", "100", "2", "20", "3" ← 사전순으로 1 < 10 < 100 < 2
# 올바른 대안: 고정 길이 패딩
"001", "002", "003", "010", "020", "100" ← 올바른 순서
안티패턴 4: RowKey에 민감 정보 포함
# 잘못된 예: 이메일을 RowKey에 직접 사용
RowKey: user@example.com_20260308
# → RowKey는 WAL, HFile, 메타 테이블에 평문으로 노출
# 올바른 대안: 해시 처리
RowKey: sha256(user@example.com)_20260308
안티패턴 5: 단일 Row가 너무 큰 경우
# 잘못된 예: 한 Row에 수만 개의 컬럼 (Flat-Wide 극단)
# → Region Split 시 해당 Row를 분할할 수 없음
# → MemStore에 단일 Row가 flush 크기를 초과할 수 있음
# 올바른 대안: Tall-Narrow로 전환하거나, Row를 시간 단위로 분할
RowKey: entity001_20260308_00 (시간 단위로 Row 분할)
RowKey: entity001_20260308_01
안티패턴 6: Scan에 범위를 지정하지 않음
// 잘못된 예: 전체 테이블 Scan
Scan scan = new Scan();
// → 수십억 행을 순회하며 RegionServer에 과부하
// 올바른 대안: 범위를 명확히 제한
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes("sensor001_"));
scan.withStopRow(Bytes.toBytes("sensor001_~"));
scan.setCaching(500); // RPC당 반환 행 수
scan.setMaxResultSize(5 * 1024 * 1024); // 5MB 제한
scan.addColumn(Bytes.toBytes("d"), Bytes.toBytes("value")); // 필요한 컬럼만
9. 마무리
HBase 데이터 모델링은 "RowKey 설계가 전부"라고 해도 과언이 아니다. 핵심을 정리하면 다음과 같다.
- RowKey가 부하 분포를 결정한다: Sequential Key는 반드시 피하고, Salting/Hashing/Bucketing으로 분산을 확보하라.
- 읽기 패턴이 RowKey를 결정한다: 가장 빈번한 쿼리가 Prefix Scan이나 Point Get으로 효율적으로 처리될 수 있도록 RowKey를 구성하라.
- Tall-Narrow를 기본으로 선택하라: 대부분의 워크로드에서 Flat-Wide보다 안정적이다.
- 핫스팟은 예방이 최선이다: Pre-split, Key 분산, 모니터링으로 핫스팟이 발생하기 전에 차단하라.
- Compaction을 통제하라: Major Compaction 자동 실행을 끄고, 비피크 시간에 수동 실행하며, I/O throttle을 적용하라.
- 캐시 적중률을 사수하라: BlockCache + BucketCache를 적절히 구성하고, 95% 이상의 적중률을 목표로 삼아라.
- Column Family는 적게, RowKey는 짧게: 저장 효율과 성능 모두를 위해 간결함을 유지하라.
올바른 데이터 모델링은 클러스터 규모를 2배로 늘리는 것 이상의 성능 개선을 가져온다. RowKey 하나의 설계에 충분한 시간을 투자하라.