시프트로 여러 필드를 하나의 정수에 조합하기 — 필드 패킹
NOTE
폭이 서로 다른 값 여러 개(타입, 식별자, 시퀀스 번호 등)를 정수 하나에 차곡차곡 눌러 담는 필드 패킹(field packing) 패턴을 정리한다. 비트플래그(Bit Flag) 연산이 “켜고 끄는 플래그”를 다뤘다면, 이 노트는 “각자 값의 범위를 가진 필드들”을 다룬다.
0. 왜 필요한가
타임스탬프, 식별자, 시퀀스 번호처럼 서로 다른 값 여러 개를 하나의 키(key)나 ID로 합쳐서 다루고 싶을 때가 있다 — 예를 들어 로그 상관관계 추적용 유니크 키를 하나의 64비트 정수로 만들어 여기저기 들고 다니면, 구조체를 통째로 넘기는 것보다 다루기 간단하다. 각 값이 정해진 비트 폭 안에 들어간다면, 시프트(<</>>)와 마스크(&)만으로 여러 필드를 정수 하나에 합쳤다가 다시 분리할 수 있다.
1. 기본 원리 — 왼쪽 시프트로 자리를 만들고 OR로 합친다
32비트 정수 하나에 type(상위 8비트)과 seq(하위 24비트) 두 필드를 담는다고 하자.
unsigned int pack_key(unsigned char type, unsigned int seq)
{
return ((unsigned int)type << 24) | (seq & 0xFFFFFF);
}type을 24비트만큼 왼쪽으로 밀어서 상위 8비트 자리로 옮기고, seq는 & 0xFFFFFF(하위 24비트만 남기는 마스크)로 혹시 넘칠 수 있는 상위 비트를 미리 잘라낸 뒤, 두 값을 |(OR)로 겹쳐서 합친다. 두 필드가 차지하는 비트 구간이 서로 겹치지 않기 때문에 OR로 합쳐도 값이 섞이지 않는다.
2. 꺼내기(unpack) — 시프트와 마스크로 역산
unsigned char unpack_type(unsigned int key) { return (unsigned char)(key >> 24); }
unsigned int unpack_seq(unsigned int key) { return key & 0xFFFFFF; }type을 꺼낼 때는 반대로 오른쪽으로 24비트 밀어서 하위 자리로 옮기고(자연히 그 아래 비트들은 잘려나간다), seq는 애초에 하위 자리에 있으므로 마스크만으로 상위 비트(type이 차지한 부분)를 지우면 된다.
3. 실전 예 — 값 세 개를 하나의 64비트 키로 조합하기
long long make_key(long long timestamp_sec, int id, int seq)
{
long long key = timestamp_sec;
key = (key << 8) & 0xFFFFFFFFFFFFFF00LL; /* 하위 8비트를 비워 id 넣을 자리를 만든다 */
key |= (id & 0xFF);
key = (key << 32) & 0xFFFFFFFF00000000LL; /* 하위 32비트를 통째로 비워 seq 넣을 자리를 만든다 */
key |= (unsigned int)seq;
return key;
}매 단계마다 **“왼쪽으로 밀어서 새 필드가 들어갈 빈 자리를 만든다 → 마스크로 그 자리 이외의 비트를 확실히 지운다 → OR로 새 값을 끼워 넣는다”**는 세 동작이 반복된다. 필드가 몇 개가 되든 이 세 동작을 필드 수만큼 반복하면 된다.
4. 필드 폭을 미리 계산해야 하는 이유
| 필드가 표현해야 하는 최댓값 | 필요한 최소 비트 수 |
|---|---|
| 255 | 8비트 (2⁸ = 256) |
| 65,535 | 16비트 (2¹⁶ = 65,536) |
| 16,777,215 | 24비트 (2²⁴ = 16,777,216) |
필드에 배정한 비트 폭보다 큰 값이 들어오면, 그 값의 상위 비트가 잘려나가는 게 아니라 옆 필드의 영역까지 침범한다. 예를 들어 id를 8비트(0~255)로 잡았는데 256이 들어오면, 256 & 0xFF가 0이 되어 id는 조용히 0으로 사라지거나(마스크를 제대로 했을 때), 마스크를 빼먹은 경우엔 상위 비트가 그대로 timestamp 필드 영역을 오염시킨다. 필드를 설계할 때는 항상 “이 필드가 실제로 가질 수 있는 최댓값”부터 계산하고 비트 폭을 정해야 한다.
5. 자주 하는 실수
- 마스크(
&)를 빼먹기 — 시프트만 하고 마스크를 안 하면, 그 필드가 원래 갖고 있던 상위 비트나 부호 확장된 비트가 그대로 섞여 들어와 옆 필드를 침범한다. 3번 예제처럼 시프트 직후 반드시 마스크로 정리한다. - 값이 필드 폭을 넘는지 검증을 안 하기 — 4번 항목의 문제를 실제로 막으려면, 값을 필드에 넣기 전에 “이 값이 배정된 비트 폭을 넘지 않는가”를 확인하는 코드가 필요하다. 패킹 함수 안에서 조용히 잘라버리면 나중에 원인을 찾기 어려운 데이터 오염으로 이어진다.
- 부호 있는 타입에 오른쪽 시프트(
>>)를 쓰다가 부호 확장 문제 만들기 — 음수를 담을 수 있는signed타입을 오른쪽으로 시프트하면 상위 비트에 부호 비트가 복사되는 구현 정의 동작(대개 산술 시프트)이 벌어질 수 있다. 필드 패킹/언패킹에는unsigned타입을 쓰는 게 안전하다(관련: 실무 C 코드 관례와 UB 함정 정리의 B5). - 큰 상수 리터럴에 타입 접미사를 안 붙여서 잘리기 —
0xFFFFFFFFFFFFFF00처럼 32비트를 넘는 상수는 뒤에LL(long long)이나ULL접미사를 안 붙이면 컴파일러가 더 작은 정수 타입으로 해석해 상위 비트가 잘릴 수 있다. 64비트 필드를 다룰 때는 리터럴에도 반드시 알맞은 접미사를 붙인다.