시프트로 여러 필드를 하나의 정수에 조합하기 — 필드 패킹

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. 필드 폭을 미리 계산해야 하는 이유

필드가 표현해야 하는 최댓값필요한 최소 비트 수
2558비트 (2⁸ = 256)
65,53516비트 (2¹⁶ = 65,536)
16,777,21524비트 (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비트 필드를 다룰 때는 리터럴에도 반드시 알맞은 접미사를 붙인다.

관련 문서