왜 2, 8, 16인가: 임의의 선택이 아닌 비트 묶음으로서의 진법
프로그래머가 정확히 이 진법들을 쓰는 이유는 각각이 비트에 깔끔하게 대응되기 때문입니다. 16진수 한 자리는 정확히 4비트(니블)라서 hex 두 자리가 1바이트입니다 — 0xFF는 11111111이고 255이며, 32비트 값은 언제나 hex 8자리입니다. 메모리 주소, 해시 다이제스트, MAC 주소, 색상 값이 hex로 표기되는 이유가 이것입니다 — 텍스트에서 바이트 경계를 그대로 읽어낼 수 있으니까요. 8진수는 비트를 3개씩 묶는데, 이는 Unix 권한 모델과 완벽히 일치합니다. rwx가 3비트이므로 rwxr-xr-x는 111 101 101, 곧 755입니다.
반면 10진수는 비트 경계와 아무 관계가 없습니다 — 255라는 숫자는 어떤 비트가 켜져 있는지 시각적으로 전혀 알려주지 않습니다. 10진수를 2진수로 바꾸려면 2로 반복해서 나눠야 하지만, hex를 2진수로 바꾸는 것은 자리마다 표 하나 찾아보는 일입니다. 숙련된 엔지니어가 hex-2진 변환은 암산으로 하면서 큰 수의 10진-2진 변환은 아무도 안 하는 이유입니다.
유용한 기억 닻은 거듭제곱 사다리입니다: 2^8 = 256, 2^10 = 1024, 2^16 = 65,536, 2^32 = 4,294,967,296. 여기서 실무에서 만나는 대부분의 한계를 유도할 수 있습니다 — 바이트의 0~255 범위, TCP 포트 최대값 65,535, 32비트 주소 공간의 4GiB 한계. 그리고 Base64는 이런 의미의 진법이 아닙니다. 6비트 그룹을 문자에 대응시키는 이진-텍스트 인코딩입니다. Base64로는 산술을 할 수 없고, 진법 변환기로 변환하려는 것은 범주 오류입니다.
hex F F octal 7 5 5
| | | | |
bin 1111 1111 bin 111 101 101
= 0xFF = 255 = rwxr-xr-x (chmod 755)
# read a 32-bit value byte by byte
0xDEADBEEF = DE AD BE EF = 222 173 190 239
# the powers ladder
2^8 = 256 2^10 = 1,024 2^16 = 65,536 2^32 = 4,294,967,2962의 보수: 부호는 비트 폭에 산다
비트 패턴 11111111에는 본질적인 부호가 없습니다. 부호 없는 8비트 정수로 해석하면 255, 부호 있는(2의 보수) 8비트 정수로 해석하면 -1입니다. 같은 바이트, 두 가지 계약. 2의 보수가 승리한 이유는 산술이 균일하기 때문입니다 — 부호 반전은 '모든 비트 뒤집고 1 더하기'이고, 같은 가산기 회로가 양수와 음수를 모두 처리합니다. n비트 부호 있는 정수의 범위는 -(2^(n-1))부터 2^(n-1)-1까지 — 8비트라면 -128부터 127입니다. 이 비대칭은 실재하며 가끔 뭅니다. 8비트 레지스터에서 -128을 부호 반전하면 오버플로해서 다시 -128이 됩니다. C에서 가장 작은 음수의 abs()가 미정의 동작인 이유입니다.
이것이 변환기에서 중요한 이유: 비트 폭이 선언되지 않은 변환기는 음수를 보여줄 수 없습니다 — '-5의 2진수'라는 것은 존재하지 않고, '-5의 8비트(11111011) 또는 32비트(…11111011) 2의 보수'만 존재합니다.
JavaScript는 자체 반전을 추가합니다. 비트 연산자는 숫자를 부호 있는 32비트 정수로 강제 변환합니다. 그래서 0xFFFFFFFF는 리터럴로는 4294967295지만 0xFFFFFFFF | 0은 -1입니다 — 31번 비트가 부호 비트가 되기 때문입니다. 0만큼의 부호 없는 오른쪽 시프트(value >>> 0)는 값을 부호 없는 32비트로 재해석하는 표준 관용구이고, 타입드 어레이(Int8Array vs Uint8Array)는 같은 바이트의 두 해석을 명시적으로 드러냅니다. 레지스터 덤프나 네트워크 캡처의 hex 값이 '거대하고 이상해' 보이면, 부호 없는 형식으로 출력된 음수가 아닌지 확인하세요.
(0xFF | 0) // 255 — fits in int32, stays positive
(0xFFFFFFFF | 0) // -1 — bit 31 became the sign bit
(-1 >>> 0).toString(16) // 'ffffffff'
(-1 >>> 0).toString(2) // '11111...' (32 ones)
// same byte, two contracts
new Int8Array([0xFF])[0] // -1
new Uint8Array([0xFF])[0] // 255
// negate = flip bits, add one
// 5 = 00000101
// ~5 = 11111010
// +1 = 11111011 = -5 in 8-bit two's complementIEEE 754와 2^53 절벽: JavaScript가 정수를 조용히 반올림할 때
JavaScript의 숫자 타입은 하나뿐입니다: IEEE 754 배정밀도 부동소수점. double은 52비트 가수부에 암묵적 1비트를 더해 2^53(9,007,199,254,740,991 — Number.MAX_SAFE_INTEGER)까지 모든 정수를 정확히 표현합니다. 그 하나 너머부터 표현 가능한 값이 건너뛰기 시작합니다. 2^53 + 1은 2^53으로 반올림되고, 9007199254740992 === 9007199254740993 비교는 true입니다. 아무것도 throw하지 않습니다. 반올림이 조용하다는 점이 바로 위험한 이유입니다.
이건 실제 시스템을 끊임없이 습격합니다. 64비트 정수 ID가 어디에나 있기 때문입니다 — 데이터베이스 bigint 컬럼, Twitter/X 스노플레이크 ID, Discord ID, 주문 번호. JSON 자체에는 정수 크기 제한이 없지만, JSON.parse가 19자리 ID를 Number로 구체화하는 순간 하위 자릿수가 사라집니다 — 그리고 손상된 ID는 그럴듯해 보이는 채로 코드를 무사히 돌아다닙니다. 해결책: 큰 ID는 문자열로 전송하거나, BigInt를 인지하는 reviver로 파싱하세요. BigInt는 임의 정밀도를 다루며 문자열 형태에서 진법 접두사도 받아들입니다.
같은 IEEE 754 이야기가 그 유명한 0.1 + 0.2 !== 0.3도 설명하며, 이것은 근본적으로 진법 변환 문제입니다. 0.1은 10진법에서는 유한하지만 2진법에서는 무한 순환 소수(0.0001100110011...)라서 52비트에 맞게 반올림돼야 합니다. 분모가 순수한 2의 거듭제곱이 아닌 모든 10진 소수가 이 성질을 가집니다. 돈을 정수 센트로 다뤄야 하는 이유이고, 소수까지 다루는 진법 변환기라면 순환 전개를 보여줘야 하는 이유입니다.
Number.MAX_SAFE_INTEGER // 9007199254740991 (2^53 - 1)
9007199254740992 === 9007199254740993 // true (!)
// 64-bit ID from an API, silently corrupted
JSON.parse('{"id": 900719925474099267}').id
// -> 900719925474099300
// exact alternatives
BigInt('900719925474099267') // 900719925474099267n
BigInt('0xff') // 255n — radix prefix works
(255n).toString(16) // 'ff'
// the fraction side of the same coin
0.1 + 0.2 // 0.30000000000000004
// 0.1 in binary = 0.000110011001100... (repeats forever)언어별 진법 리터럴과 파싱 함정
현대 언어들은 접두사에 합의했습니다: hex는 0x, 2진은 0b, 8진은 0o(JavaScript, Python, Rust, Swift 모두 지원), 가독성을 위한 밑줄 자릿수 구분자 1_000_000도 있습니다. 역사적 흉터는 C 스타일 8진법입니다 — 맨 앞의 0 하나로, 010이 8을 뜻합니다. 이 표기법은 실제 버그를 충분히 많이 냈습니다(0으로 패딩된 열을 코드에 붙여넣기, chmod 값을 10진수로 전달). 그래서 strict 모드 JavaScript는 레거시 8진 리터럴을 문법 오류로 만들고 0o를 도입했습니다. 파일 모드를 일반 숫자로 받는 API라면 644와 0o644는 완전히 다른 값입니다(10진 644는 0o1204).
JavaScript의 파싱 함수는 각자 개성이 있습니다. parseInt(str, radix)는 항상 명시적 radix와 함께 호출해야 합니다. 옛 엔진들은 앞자리 0을 8진으로 해석했고, 지금도 parseInt는 0x는 자동 감지하지만 0b나 0o는 못합니다. Number('0b101')은 세 가지 현대 접두사를 모두 이해하지만 Number('')는 0이고, parseInt는 실패하는 대신 첫 번째 잘못된 문자에서 멈춥니다 — parseInt('12px', 10)은 12인데, 이건 그날그날 기능일 수도 지뢰일 수도 있습니다. parseInt는 또 인자를 먼저 문자열로 변환하므로 아주 작은 float에서 유명한 넌센스를 만듭니다(parseInt(0.0000005)는 문자열 '5e-7'을 거쳐 5).
출력 쪽에서는 toString(radix)이 2진법부터 36진법까지 처리하고, padStart로 고정 폭 덤프를 만듭니다: (5).toString(2).padStart(8, '0')은 00000101을 출력합니다. 64비트 수준의 비트 작업은 BigInt로 하세요 — Number 기반 비트 연산자는 32비트로 잘라내서 상위 절반을 손상시킵니다.
parseInt('08') // 8 today; 0 in pre-ES5 engines — always pass radix
parseInt('0x1F') // 31 — hex auto-detected
parseInt('0b101') // 0 — binary prefix NOT understood
Number('0b101') // 5 — all modern prefixes work
parseInt('12px', 10) // 12 — stops at first invalid char
parseInt(0.0000005) // 5 — via the string '5e-7' (!)
0o755 // 493 — modern octal literal
1_000_000 // digit separators, ignored by the engine
(255).toString(2).padStart(8, '0') // '11111111'
(0xDEADBEEFn << 8n).toString(16) // 'deadbeef00' — 64-bit-safe in BigInt실전 비트 작업: 권한, 플래그, 색상
현업 개발자가 실제로 손으로 진법을 변환하는 세 곳은 Unix 권한, 기능 플래그, 색상입니다 — 각각 몸에 익힐 가치가 있는 작은 관용구입니다.
권한: 8진수 각 자리는 읽기+쓰기+실행이며 가중치는 4-2-1입니다. 755는 7 = 4+2+1(소유자 rwx), 5 = 4+1(그룹 r-x), 5(기타 r-x)로 분해됩니다. 644는 rw-, r--, r--입니다. setuid/setgid/sticky 비트는 선택적인 네 번째 선행 자리를 이룹니다 — 4755는 setuid root 영역이므로 감사할 때 선행 4나 2가 보이면 주목해야 합니다. 심볼릭에서 8진수로의 암산은 그저 가중치 덧셈입니다.
플래그: 각 플래그를 1을 인덱스만큼 왼쪽 시프트한 값으로 정의하고, OR로 결합, AND로 검사, AND NOT으로 해제, XOR로 토글합니다. 변환기의 2진 뷰가 곧 디버깅 뷰입니다 — 플래그 값 13은 1101이므로 비트 0, 2, 3이 켜져 있습니다. 이 레이아웃은 파일 권한 마스크, Linux capability, CSS font-feature 비트필드, 수많은 네트워크 프로토콜의 플래그 정수와 동일합니다. JavaScript 주의점 하나: 플래그가 31개를 넘으면 32비트 잘림 때문에 BigInt나 워드 배열로 전환해야 합니다.
색상: CSS hex 색상은 3바이트(알파 포함 시 4바이트)를 이어 붙인 것입니다. 채널 추출은 시프트 + 마스크입니다 — 빨강은 값을 오른쪽으로 16 시프트한 뒤 0xFF와 AND. 0xFF0000이 순수한 빨강인 이유이고, 변환기의 hex 뷰와 10진 뷰 16711680이 같은 픽셀을 설명하는 이유입니다.
// permissions: weights 4-2-1 per octal digit
// 755 = rwx r-x r-x 644 = rw- r-- r-- 4755 = setuid + 755
// flags: define, set, test, clear, toggle
const READ = 1 << 0; // 1
const WRITE = 1 << 1; // 2
const EXEC = 1 << 2; // 4
let flags = READ | EXEC; // 5 = 101
(flags & WRITE) !== 0 // false
flags &= ~READ; // clear -> 100
flags ^= EXEC; // toggle -> 000
// colors: bytes packed into one integer
const c = 0xff8800; // 16746496
const r = (c >> 16) & 0xff; // 255
const g = (c >> 8) & 0xff; // 136
const b = c & 0xff; // 0