비트셋과 비트 배열 — 대량 Boolean을 메모리 효율적으로 저장하기
"이 값이 존재하는지" 같은 단순한 Yes/No 질문에 해시맵을 쓰는 것은 낭비가 아닐까요?
비트셋이란
비트셋(BitSet)은 각 비트 하나가 하나의 Boolean 값을 표현하는 자료구조입니다.
boolean배열: 요소당 1바이트(8비트) 사용BitSet: 요소당 1비트 사용- 대량의 Boolean 데이터를 다룰 때 메모리를 약 8배 절약할 수 있습니다
boolean 배열: [00000001] [00000000] [00000001] [00000000] → 4바이트, 4개 값
BitSet: [1 0 1 0] → 1바이트 미만, 4개 값
왜 필요한가
다음과 같은 상황에서 비트셋이 유용합니다.
- 1억 개의 정수 중 어떤 수가 존재하는지 확인 → HashSet은 수백 MB, BitSet은 약 12MB
- ** 대규모 소수 판별** (에라토스테네스의 체) → 수억 개의 플래그 저장
- ** 블룸 필터** → 확률적 존재 여부 확인
- ** 권한 시스템** → 비트 플래그로 권한 조합 표현
Java BitSet 기본 사용
import java.util.BitSet;
BitSet bitSet = new BitSet(100); // 100비트 크기
// 비트 설정
bitSet.set(0); // 0번 비트를 1로
bitSet.set(5); // 5번 비트를 1로
bitSet.set(10, 20); // 10~19번 비트를 1로
// 비트 확인
boolean exists = bitSet.get(5); // true
boolean absent = bitSet.get(3); // false
// 비트 해제
bitSet.clear(5); // 5번 비트를 0으로
// 설정된 비트 수
int count = bitSet.cardinality(); // 1로 설정된 비트 개수
// 반복
for (int i = bitSet.nextSetBit(0); i >= 0; i = bitSet.nextSetBit(i + 1)) {
System.out.println("비트 " + i + "이 설정됨");
}
내부 구조
Java BitSet은 long 배열 로 비트를 저장합니다.
// BitSet 내부 (간략화)
public class BitSet {
long[] words; // 64비트씩 묶어서 저장
public void set(int bitIndex) {
int wordIndex = bitIndex >> 6; // bitIndex / 64
words[wordIndex] |= (1L << bitIndex); // 해당 비트를 1로 설정
}
public boolean get(int bitIndex) {
int wordIndex = bitIndex >> 6;
return (words[wordIndex] & (1L << bitIndex)) != 0;
}
}
- 인덱스 5를 설정하면:
words[0]의 5번째 비트가 1이 됩니다 - 인덱스 70을 설정하면:
words[1]의 6번째 비트가 1이 됩니다 (70 = 64 + 6)
비트 연산
BitSet은 집합 연산을 비트 연산 으로 처리합니다. CPU가 64비트를 한 번에 처리하므로 매우 빠릅니다.
BitSet a = new BitSet();
a.set(1); a.set(2); a.set(3);
BitSet b = new BitSet();
b.set(2); b.set(3); b.set(4);
// 합집합 (OR)
BitSet union = (BitSet) a.clone();
union.or(b); // {1, 2, 3, 4}
// 교집합 (AND)
BitSet intersection = (BitSet) a.clone();
intersection.and(b); // {2, 3}
// 차집합
BitSet diff = (BitSet) a.clone();
diff.andNot(b); // {1}
// 대칭 차집합 (XOR)
BitSet symDiff = (BitSet) a.clone();
symDiff.xor(b); // {1, 4}
이런 연산은 long 배열의 각 원소에 비트 연산을 적용하므로, 64개의 요소를 단일 CPU 명령어 로 처리합니다.
에라토스테네스의 체 최적화
소수를 구하는 에라토스테네스의 체를 BitSet으로 최적화할 수 있습니다.
boolean 배열 버전
// boolean 배열: n이 1억이면 약 100MB
boolean[] isComposite = new boolean[n + 1];
for (int i = 2; i * i <= n; i++) {
if (!isComposite[i]) {
for (int j = i * i; j <= n; j += i) {
isComposite[j] = true;
}
}
}
BitSet 버전
// BitSet: n이 1억이면 약 12.5MB
public List<Integer> sieve(int n) {
BitSet composite = new BitSet(n + 1);
List<Integer> primes = new ArrayList<>();
for (int i = 2; i <= n; i++) {
if (!composite.get(i)) {
primes.add(i);
// i가 int 범위를 넘지 않도록 long으로 계산
for (long j = (long) i * i; j <= n; j += i) {
composite.set((int) j);
}
}
}
return primes;
}
메모리 사용량이 8배 줄어들면서, 캐시 효율도 좋아져 실행 속도까지 빨라집니다.
블룸 필터
블룸 필터(Bloom Filter)는 BitSet을 기반으로 한 확률적 자료구조 입니다.
원리
- m 비트의 BitSet과 k개의 해시 함수를 준비합니다.
- 요소를 추가할 때: k개의 해시 함수로 k개의 위치를 계산하고, 해당 비트를 1로 설정합니다.
- 요소를 조회할 때: k개의 위치가 모두 1이면 "있을 수 있음", 하나라도 0이면 "확실히 없음"입니다.
public class SimpleBloomFilter {
private BitSet bitSet;
private int size;
private int hashCount;
public SimpleBloomFilter(int size, int hashCount) {
this.bitSet = new BitSet(size);
this.size = size;
this.hashCount = hashCount;
}
// 해시 함수 (더블 해싱)
private int hash(String value, int i) {
int hash1 = value.hashCode();
int hash2 = hash1 >>> 16; // 상위 비트를 활용
return Math.abs((hash1 + i * hash2) % size);
}
public void add(String value) {
for (int i = 0; i < hashCount; i++) {
bitSet.set(hash(value, i));
}
}
public boolean mightContain(String value) {
for (int i = 0; i < hashCount; i++) {
if (!bitSet.get(hash(value, i))) {
return false; // 확실히 없음
}
}
return true; // 있을 수도 있음 (False Positive 가능)
}
}
특성
- **False Negative 없음 **: "없다"고 하면 진짜 없습니다
- **False Positive 있음 **: "있다"고 해도 실제로는 없을 수 있습니다
- ** 삭제 불가능 **: 비트를 0으로 바꾸면 다른 요소에 영향을 줄 수 있습니다
실무 활용
- Redis:
SETBIT,GETBIT명령어로 블룸 필터 구현 가능 - Cassandra: SSTable에서 읽기 전 블룸 필터로 불필요한 디스크 I/O 방지
- Google Chrome: 악성 URL 사전 필터링
- ** 캐시 전략 **: DB 쿼리 전에 데이터 존재 여부를 빠르게 판단
// 캐시 관통(Cache Penetration) 방지 예시
@Service
public class ProductService {
private final SimpleBloomFilter existFilter;
public Product getProduct(Long id) {
// 1. 블룸 필터로 빠르게 확인
if (!existFilter.mightContain(id.toString())) {
return null; // 확실히 없는 데이터 → DB 조회 불필요
}
// 2. 캐시 확인
Product cached = cache.get(id);
if (cached != null) return cached;
// 3. DB 조회
return productRepository.findById(id).orElse(null);
}
}
비트 플래그 패턴
비트셋의 원리를 작은 규모로 적용한 것이 비트 플래그입니다.
// 권한 시스템 — 비트 플래그
public class Permission {
public static final int READ = 1; // 0001
public static final int WRITE = 1 << 1; // 0010
public static final int EXECUTE = 1 << 2; // 0100
public static final int ADMIN = 1 << 3; // 1000
private int flags;
public void grant(int permission) {
flags |= permission; // OR로 권한 추가
}
public void revoke(int permission) {
flags &= ~permission; // AND NOT으로 권한 제거
}
public boolean has(int permission) {
return (flags & permission) == permission;
}
}
// 사용 예시
Permission perm = new Permission();
perm.grant(Permission.READ | Permission.WRITE); // 읽기 + 쓰기 권한
perm.has(Permission.READ); // true
perm.has(Permission.ADMIN); // false
리눅스 파일 권한(chmod 755)이 바로 이 패턴입니다. 7 = 111(이진) = 읽기 + 쓰기 + 실행.
Java EnumSet — 타입 안전한 비트셋
Java의 EnumSet은 내부적으로 비트셋으로 구현되어 있습니다.
enum Permission { READ, WRITE, EXECUTE, ADMIN }
// 내부적으로 long 하나의 비트를 사용 (enum 원소가 64개 이하일 때)
EnumSet<Permission> permissions = EnumSet.of(Permission.READ, Permission.WRITE);
permissions.add(Permission.EXECUTE);
permissions.contains(Permission.ADMIN); // false
// 합집합, 교집합 등 집합 연산도 가능
EnumSet<Permission> all = EnumSet.allOf(Permission.class);
EnumSet은 비트 연산의 성능을 유지하면서 타입 안전성을 보장하므로, enum 기반 플래그에는 EnumSet을 사용하는 것이 좋습니다.
정리
- BitSet은 ** 비트 하나로 Boolean 값 하나를 표현 **하여 메모리를 8배 절약합니다.
- 내부적으로 long 배열 을 사용하며, 64개의 값을 단일 CPU 명령어로 처리합니다.
- 에라토스테네스의 체 를 BitSet으로 최적화하면 메모리와 속도 모두 개선됩니다.
- 블룸 필터 는 BitSet 기반의 확률적 자료구조로, 캐시 관통 방지 등에 활용됩니다.
- Java에서는
BitSet,EnumSet, 비트 플래그 등으로 비트 연산을 활용할 수 있습니다.
댓글 로딩 중...