Hexadecimal: 4비트를 한 글자로 압축하는 16진법 표기
16진법(Hexadecimal)은 0~9와 A~F 16개의 기호로 수를 표현하는 진법으로, 이진수 4자리를 정확히 1자리로 대응시킬 수 있어 컴퓨터 내부 데이터를 사람이 읽기 쉽게 표현하는 데 쓰인다. 메모리 주소, 색상 코드, 해시값, 디버깅 로그 등 저수준 데이터를 다룰 때 사실상 표준 표기법으로 자리 잡았다. 이 글에서는 16진법이 왜 2진법과 궁합이
1. 개념
16진법(Hexadecimal, 줄여서 hex)은 16을 밑(base)으로 하는 수 표현 체계다. 0~9까지는 숫자 그대로 쓰고, 10~15는 각각 A, B, C, D, E, F 알파벳으로 표현한다.
10진수: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
16진수: 0 1 2 3 4 5 6 7 8 9 A B C D E F코드에서는 보통 0x 접두사를 붙여 16진수임을 표시한다. 예를 들어 0xFF는 10진수로 255다.
핵심은 4비트(bit)가 정확히 16진수 한 자리와 1:1 대응한다는 점이다. 2^4 = 16이기 때문에, 이진수 4자리 조합(0000~1111)이 16진수 한 글자(0~F)와 정확히 일치한다.
2. 왜 사용하는가
컴퓨터는 내부적으로 모든 데이터를 2진수(binary)로 저장하지만, 사람이 긴 이진수를 읽고 쓰는 건 비효율적이고 오류가 나기 쉽다.
2진수: 1101 1010 1111 0001
16진수: D A F 1같은 정보를 2진수는 16자리, 16진수는 4자리로 표현한다. 8진법(Octal)도 대안이 될 수 있지만, 8은 2의 거듭제곱이 아니라서(2^3=8이지만 바이트 단위 8비트와 딱 안 맞아떨어짐) 바이트 경계와 자연스럽게 맞아떨어지지 않는다. 반면 16진법은 4비트 니블(nibble) 단위, 8비트 바이트(byte) 단위와 깔끔하게 정렬되기 때문에 저수준 프로그래밍에서 압도적으로 선호된다.
3. 동작 원리
3.1 진법 변환의 수학적 원리
16진수의 각 자리는 16의 거듭제곱 가중치를 가진다.
0x1A3F
= 1×16³ + A(10)×16² + 3×16¹ + F(15)×16⁰
= 1×4096 + 10×256 + 3×16 + 15×1
= 4096 + 2560 + 48 + 15
= 67193.2 2진수 ↔ 16진수 변환이 빠른 이유
10진수로 변환하지 않고도 2진수와 16진수는 서로 직접 치환이 가능하다. 4비트씩 끊어서 대응표만 외우면 되기 때문이다.
1101 1010 1111 0001
D A F 1
→ 0xDAF110진수와 2진수 사이의 변환은 나눗셈을 반복해야 하지만, 16진수와 2진수 사이는 단순 치환(lookup)만으로 끝난다. 이것이 하드웨어 레지스터 값이나 메모리 덤프를 표기할 때 16진법을 쓰는 근본적인 이유다.
4. 코드 예제
# 진법 변환 예제 (Python)
# 10진수 -> 16진수
n = 6719
print(hex(n)) # '0x1a3f'
print(f"{n:X}") # '1A3F' (대문자)
print(f"{n:#06x}") # '0x1a3f' (0으로 패딩)
# 16진수 문자열 -> 10진수
hex_str = "1A3F"
print(int(hex_str, 16)) # 6719
# 2진수와 16진수의 관계 직접 확인
binary_str = "1101101011110001"
n2 = int(binary_str, 2)
print(hex(n2)) # '0xdaf1'
# 4비트씩 끊어서 수동 변환 (원리 확인용)
def bin_to_hex_manual(bstr: str) -> str:
# 4비트 단위로 패딩
padded = bstr.zfill((len(bstr) + 3) // 4 * 4)
hex_map = "0123456789ABCDEF"
chunks = [padded[i:i+4] for i in range(0, len(padded), 4)]
return "".join(hex_map[int(chunk, 2)] for chunk in chunks)
print(bin_to_hex_manual("1101101011110001")) # 'DAF1'// 바이트 배열을 hex 문자열로 (실무에서 흔한 패턴, 해시값 표현 등)
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
const data = new Uint8Array([255, 10, 0, 171]);
console.log(bytesToHex(data)); // 'ff0a00ab'
// 16진수 색상 코드 파싱
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace("#", "");
const r = parseInt(clean.substring(0, 2), 16);
const g = parseInt(clean.substring(2, 4), 16);
const b = parseInt(clean.substring(4, 6), 16);
return [r, g, b];
}
console.log(hexToRgb("#FF5733")); // [255, 87, 51]5. 시간 복잡도 또는 성능 특성
16진법 자체는 알고리즘이 아니라 표기 체계이므로 시간 복잡도 개념이 직접 적용되지는 않는다. 대신 성능/공간 관점에서 짚을 점은 다음과 같다.
- 표현 밀도: n비트 정수를 16진수로 표현하면 문자열 길이는
n/4자리다. 반면 2진수 문자열은n자리, 10진수는 대략n × log10(2) ≈ n × 0.301자리가 필요하다. 32비트 정수 기준으로 2진수 32자리, 16진수 8자리, 10진수 최대 10자리다. - 변환 비용: 2진수 ↔ 16진수 변환은 비트 시프트와 마스킹(masking) 연산만으로 O(n) 시간에 처리되며, 자릿수 나눗셈이 필요한 10진수 변환보다 하드웨어 친화적이다.
6. 실무 사용 사례
- 메모리 주소 및 포인터 표기: 디버거나 프로파일러에서
0x7ffee3a1c9d8같은 형태로 주소를 표시한다. - 색상 코드: CSS/디자인 툴에서
#FF5733처럼 RGB 각 채널을 2자리 16진수로 표현한다. - 해시값/체크섬: MD5, SHA-256 등의 해시 함수 출력은 관례적으로 16진수 문자열로 표기한다(
e3b0c44298fc1c14...). - 네트워크/저수준 디버깅:
tcpdump,xxd,hexdump같은 도구로 패킷이나 파일의 바이트를 16진수로 덤프해서 확인한다. - 유니코드 코드포인트:
U+1F600처럼 문자 코드를 16진수로 표기한다. - 비트 플래그 마스크: 권한이나 옵션 플래그를
0x01,0x02,0x04처럼 16진수로 정의해 가독성을 높인다.
# 파일을 16진수로 덤프해서 확인
xxd sample.bin | head -3
# 문자열의 SHA-256 해시값(16진수 출력)
echo -n "hello" | sha256sum7. 주의할 점
- 대소문자 혼용 주의:
0xff와0xFF는 값이 같지만, 코드 스타일 가이드나 프로젝트 컨벤션에 따라 대소문자를 통일해야 협업 시 혼란이 없다. - 자릿수 패딩 누락:
0xA를 색상 코드 등 고정 자릿수가 필요한 곳에 쓸 때0x0A로 패딩하지 않으면 파싱 오류가 날 수 있다. - 부호 있는 수 표현 혼동: 16진수
0xFFFFFFFF가 부호 없는(unsigned) 32비트에서는 4294967295지만, 부호 있는(signed) 32비트에서는 -1로 해석된다. 2의 보수(two's complement) 표현을 이해하지 못하면 버그로 이어진다. - 문자열 파싱 시 접두사 처리:
int(hex_str, 16)처럼 진법을 명시하는 함수에0x접두사가 섞여 있으면 언어/라이브러리마다 처리 방식이 다르므로 확인이 필요하다.
8. 핵심 정리
16진법은 4비트와 1:1로 대응하기 때문에 2진수를 압축해서 사람이 읽기 편하게 만든 표기법이다. 진법 변환 자체에 새로운 계산이 필요한 게 아니라 비트 그룹을 단순 치환하는 것이므로 메모리 주소, 색상 코드, 해시값, 비트 플래그 등 저수준 데이터를 다룰 때 표준처럼 쓰인다. 값 자체는 2진수와 동일하므로 부호 표현이나 자릿수 패딩 같은 표현상의 규칙만 정확히 이해하면 실무에서 헷갈릴 일이 없다.