개발 수학

2진수와 16진수: 0xFF는 왜 255일까

자릿값, 2의 거듭제곱, 16진수, 비트 연산

1장 · Unit 1 · 읽는 시간 약 16분

목차 · 진행 0%

    글자는 저장되거나 네트워크로 갈 때 UTF-8이라는 규칙에 따라 바이트로 바뀐다. 그 변환 코드를 보자(『한입 CS』 1장에서 한 줄씩 따라가는 코드다). 유니코드가 글자마다 붙인 번호인 코드 포인트(code point)를 2진수로 놓고, 오른쪽에서 6비트씩 떼어 바이트를 하나씩 만드는 코드다. rest는 아직 바이트로 옮기지 않은 코드 포인트의 남은 비트, encoded는 만든 바이트를 앞에 끼워 모으는 목록이다. 그 코드 가운데 이런 줄이 있다.

    encoded.unshift(0x80 | (rest & 0x3F))
    rest = rest >> 6

    CSS에서 늘 보던 #ff8800도, 해시 값 2cf24dba…도 같은 종류의 글자로 적혀 있다. 0x80은 얼마이고, &와 |는 &&·||와 무엇이 다르며, 왜 하필 6만큼 미는 걸까? 이 줄을 읽으려면 세 가지가 필요하다. 수를 2진수(binary)와 16진수(hexadecimal)로 읽는 법, 2를 거듭 곱한 수의 크기 감각, 그리고 수를 2진수 자리 단위로 계산하는 비트 연산(bitwise operation)이다. 이 Unit은 이 세 가지를 차례로 푼다.

    자릿값: 10진수와 2진수

    먼저 말 두 개를 정한다. 같은 수를 여러 번 곱한 것을 거듭제곱(power)이라 하고, 몇 번 곱했는지를 위에 작게 쓴 수를 지수(exponent)라 한다. 25=2×2×2×2×2=322^5 = 2 \times 2 \times 2 \times 2 \times 2 = 32. "2의 5제곱은 32"라고 읽고, 2를 다섯 번 곱했다는 뜻이다. 위의 5가 지수다. 20=12^0 = 1도 기억해 두자. 2를 한 번도 곱하지 않았으니 처음 값 1 그대로다. 23=82^3 = 8에서 지수를 하나 줄일 때마다 2로 나누면 4, 2, 1이 되는 것과 같다.

    우리가 쓰는 10진수(decimal)는 한 자리에 0~9 열 개의 숫자를 쓴다. 자리마다 정해진 값을 자릿값(place value)이라 한다. 오른쪽부터 1, 10, 100, 곧 10의 거듭제곱이다. 305=3×102+0×101+5×100305 = 3 \times 10^2 + 0 \times 10^1 + 5 \times 10^0. 305는 100이 3개, 10이 0개, 1이 5개라는 뜻이다.

    2진수는 한 자리에 0과 1 두 개만 쓴다. 2진수 한 자리, 곧 0 또는 1 하나를 비트(bit)라 한다. 규칙은 같고 자릿값만 2의 거듭제곱이 된다. 오른쪽부터 1, 2, 4, 8, 16이다. 한 자리 왼쪽으로 갈 때마다 자릿값이 두 배가 된다.

    자리(왼쪽 → 오른쪽)넷째셋째둘째첫째
    자릿값2³ = 82² = 42¹ = 22⁰ = 1
    1101의 숫자1101
    예측먼저 답해 보세요

    2진수 1101은 10진수로 얼마일까? (오른쪽 자리부터 자릿값은 1, 2, 4, 8)

    선택지와 해설 보기
    실험 2진수를 10진수로: 자리마다 곱해 더하기
    실험2진수를 10진수로: 자리마다 곱해 더하기

    맨 오른쪽 자리부터 자릿값(place)을 곱해 total에 더합니다. 한 자리 왼쪽으로 갈 때마다 자릿값이 두 배가 됩니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const s = '1101'let total = 0let place = 1for (let i = s.length - 1; i >= 0; i--) {  const digit = Number(s[i])  total += digit * place  place *= 2}console.log(total)
    변수
    s
    '1101'
    출력
     
    자리
    1. 1
    2. 1
    3. 0
    4. 1
    자릿값
    1. 8
    2. 4
    3. 2
    4. 1
    1/20단계.
    • 해볼 것
    • 1101을 끝까지 실행해 본다
    • 11111111(비트 8개가 모두 1)을 끝까지 실행해 본다

    1101을 끝까지 돌리면 total이 13이다.

    0인 자리는 0을 곱하니 더하는 값이 없고, 1인 자리만 그 자릿값을 더한다. 식으로 쓰면 11012=1×23+1×22+0×21+1×20=131101_2 = 1 \times 2^3 + 1 \times 2^2 + 0 \times 2^1 + 1 \times 2^0 = 13이다. 아래 작게 붙은 2는 "2진수로 적은 수"라는 표시다. 8 + 4 + 0 + 1을 더하면 13이라고 읽는다. 같은 방법으로 11111111은 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255다. 이 255가 이 Unit 제목의 답이다. 4절에서 16진수로 다시 만난다.

    거꾸로 10진수를 2진수로 바꿀 때는 2로 나눈 나머지를 모은다. 10진수에서 305를 10으로 나누면 나머지가 5, 곧 맨 끝 자리이고, 몫 30은 끝 자리를 떼어 낸 수다. 2진수도 같다. 2로 나눈 나머지(0 또는 1)가 맨 끝 자리이고, 몫은 끝 자리를 떼어 낸 수다. 몫이 0이 될 때까지 되풀이하고, 나온 나머지를 아래에서 위로 읽는다.

    나눌 수÷ 2의 몫나머지(이번에 얻은 자리)
    1361 (첫째 자리)
    630 (둘째 자리)
    311 (셋째 자리)
    101 (넷째 자리)

    나머지를 아래에서 위로 읽으면 1101이다. 앞에서 1101이 13이었으니 맞게 돌아왔다. 코드에서는 직접 나누지 않고 내장 함수를 쓴다.

    (13).toString(2)        // '1101'
    parseInt('1101', 2)     // 13
    0b1101                  // 13 (앞에 0b를 붙이면 2진수로 적은 수)
    (5).toString(2).padStart(8, '0') // '00000101'
    bin(13)            # '0b1101'
    int('1101', 2)     # 13
    0b1101             # 13
    format(5, '08b')   # '00000101'

    toString(2)는 앞에 0b를 붙이지 않고, Python의 bin은 붙인다(MDN: Number.prototype.toString, MDN: parseInt, Python 문서: bin, Python 문서: int). 자리 수를 맞춰 보여 주려면 padStart나 format으로 앞을 0으로 채운다.

    2의 거듭제곱 감각표: 비트 n개로 2ⁿ가지

    비트 8개를 묶은 것을 바이트(byte)라 한다. 비트 n개로는 값을 2n2^n가지 나타낼 수 있다. "2의 n제곱 가지"라고 읽는다. 비트 하나는 0, 1 두 가지다. 비트를 하나 더 붙이면 기존 경우마다 0을 붙인 것과 1을 붙인 것이 생기니 가짓수가 두 배가 된다. 비트 2개면 00, 01, 10, 11 네 가지, 3개면 여덟 가지다.

    예측먼저 답해 보세요

    바이트 하나(비트 8개)에 담을 수 있는 가장 큰 수는? (0부터 센다)

    선택지와 해설 보기

    비트 8개가 모두 1인 11111111이 가장 크고, 1절에서 셈한 대로 255다. 가짓수는 256인데 가장 큰 수가 255인 까닭은 0도 한 가지로 세기 때문이다. 일반적으로 비트 n개가 담는 가장 큰 수는 2n−12^n - 1이다. 비트 8개면 256 − 1 = 255다.

    자주 나오는 2의 거듭제곱은 값만 외우기보다 어디서 보는지와 묶어 기억해 두면 오래 남는다.

    거듭제곱값어디서 보나
    2⁴1616진수 한 자리(4절)
    2⁸256바이트 하나가 담는 가짓수, 0~255
    2¹⁰1,0241,024 ≈ 1,000. 1,024바이트를 흔히 1KB라 부른다
    2¹⁶65,536바이트 2개. 『한입 CS』 1장의 코드 포인트(code point, 유니코드가 글자마다 붙인 번호) U+FFFF가 2¹⁶ − 1
    2²⁰1,048,576약 100만(2¹⁰을 두 번 곱한 수)
    2³⁰1,073,741,824약 10억. Unit 2의 "10억 개에서 30번"이 여기서 나온다
    2³²4,294,967,296약 43억. JavaScript 비트 연산이 다루는 32비트(6절)
    2⁵³9,007,199,254,740,992약 9천조. JavaScript number가 정수를 빈틈없이 담는 한계(표 아래, 『한입 CS』 0장)

    JavaScript number는 정수를 2⁵³까지는 정확히 담지만 2⁵³ + 1은 담을 수 없어 2⁵³으로 반올림된다. 그래서 바로 옆 정수와 구별된다고 보장되는 안전한 정수(safe integer, Number.MAX_SAFE_INTEGER)는 2⁵³ − 1까지다. 2⁵³을 넘는다고 모든 수가 근삿값이 되는 것은 아니다. 2⁵³ 위에서는 담을 수 있는 수 사이 간격이 2, 4, …로 벌어질 뿐, 그 간격 위에 놓인 수(2⁵³ + 2처럼)는 정확히 저장된다.

    표준 단위에서 킬로(k)는 1,000이라, 1,024바이트를 구별해 적을 때는 IEC가 정한 1KiB(키비바이트)를 쓴다(NIST: 이진 접두어).

    표를 읽는 요령이 하나 있다. 210≈10002^{10} \approx 1000이므로 지수가 10 늘 때마다 값이 약 1,000배가 된다. ≈는 "거의 같다"는 뜻이다. 그래서 2²⁰은 약 100만, 2³⁰은 약 10억이고, 2³²는 2³⁰에 4를 곱해 약 43억이다.

    음수 지수와 2진 소수

    1절에서 2³ = 8의 지수를 하나 줄일 때마다 2로 나누면 4, 2, 1이 됐다. 거기서 멈추지 않고 계속 줄이면 어떻게 될까? 1을 2로 또 나누면 1/2, 다시 나누면 1/4이다. 지수가 음수라는 것은 바로 이것, "곱하는 대신 나눈다"는 뜻이다.

    2−4=124=116=0.06252^{-4} = \frac{1}{2^4} = \frac{1}{16} = 0.0625

    "2의 −4제곱은 1을 2로 네 번 나눈 값, 곧 1/16"이라고 읽는다. 2⁻¹ = 1/2, 2⁻² = 1/4, 2⁻³ = 1/8이다. 10진수도 같아서 10⁻² = 1/100 = 0.01이다.

    소수점 아래 자리도 자릿값으로 읽는다. 10진수 0.25는 1/10이 2개, 1/100이 5개다. 2진수에서는 소수점 오른쪽 첫째 자리가 1/2, 그다음이 1/4, 1/8, 1/16으로 자리마다 절반이 된다.

    소수점 아래 자리첫째둘째셋째넷째
    자릿값2⁻¹ = 1/22⁻² = 1/42⁻³ = 1/82⁻⁴ = 1/16
    0.011의 숫자011—

    1인 자리의 자릿값만 더하면 2진수 0.011은 1/4 + 1/8 = 0.375다. 정수 부분과 규칙이 같고, 자릿값이 2배씩 커지는 대신 절반씩 작아질 뿐이다.

    여기서 『한입 CS』 0장이 0.1 + 0.2가 0.30000000000000004인 이유로 든 "0.1은 2진수로 끝나지 않는다"가 풀린다. 10진수에서 1/3을 소수로 쓰면 0.333…으로 끝나지 않는다. 1/3을 1/10, 1/100, 1/1000의 합으로 딱 맞게 쓸 수 없기 때문이다. 2진수에서는 0.1 = 1/10이 그런 수다. 1/10을 1/2, 1/4, 1/8, …의 합으로 딱 맞게 쓸 수 없어서 0.0001100110011…처럼 같은 자리가 끝없이 되풀이되고, 컴퓨터는 어딘가에서 잘라 저장한다. 『한입 CS』 0장의 식 0.1 = 1.6 × 2⁻⁴는 "1.6을 2로 네 번 나눈 값, 곧 1.6 ÷ 16 = 0.1"이라고 읽는다.

    16진수: 한 자리 = 비트 4개

    2진수는 길다. 255도 11111111 여덟 자리다. 그래서 비트를 사람에게 보여 줄 때는 16진수를 쓴다. 16진수는 한 자리에 16가지를 쓴다. 0~9 다음에 a~f를 이어 써서 a는 10, f는 15다. 자릿값은 오른쪽부터 1, 16, 256, 곧 16의 거듭제곱이다. 코드에서는 앞에 0x를 붙여 16진수임을 밝힌다.

    16진수가 비트를 적기에 좋은 까닭은 24=162^4 = 16이라서다. 비트 4개가 나타내는 16가지가 16진수 한 자리 16가지와 딱 맞는다. 그래서 2진수를 오른쪽부터 4자리씩 끊으면 묶음 하나가 16진수 한 자리가 된다.

    10진수2진수16진수
    910019
    101010a
    151111f
    1361000 100088
    2551111 1111ff

    이제 제목의 답을 식으로 쓸 수 있다. 0xFF=15×161+15×160=240+15=255\text{0xFF} = 15 \times 16^1 + 15 \times 16^0 = 240 + 15 = 255. f가 15이니 "16이 15개, 1이 15개"라고 읽는다. 2진수로 놓고 보면 1111 1111, 비트 8개가 모두 1인 수다. 바이트 하나는 비트 8개 = 4비트 묶음 두 개이므로 16진수로 꼭 두 자리다. 0x00부터 0xff까지가 바이트 하나가 담는 0~255다.

    255, 0xff, 0b11111111은 같은 수를 다르게 적은 것이다. 코드가 실행되면 셋 다 비트 1111 1111 하나가 되고, 어느 진법으로 적었는지는 남지 않는다(255 === 0xff는 true). 10진수로도 같은 값을 적을 수 있지만, 240만 보고 1111 0000이 바로 떠오르지는 않는다. 16진수는 4비트씩 끊어 읽기만 하면 되니 계산 없이 비트 모양이 보인다.

    CSS 색 #ff8800은 바이트 세 개를 16진수 두 자리씩 이어 적은 것이다. 앞에서부터 빨강 ff = 255, 초록 88 = 8 × 16 + 8 = 136, 파랑 00 = 0이다. rgb(255 136 0)과 같은 색이다. 『한입 CS』 1장의 0x80은 8×16=1288 \times 16 = 128, 2진수로 1000 0000이고 0x3F는 3 × 16 + 15 = 63, 2진수로 0011 1111이다. 모양을 기억해 두자. 5절에서 쓴다.

    (255).toString(16)   // 'ff'
    parseInt('ff', 16)   // 255
    0xff                 // 255
    (8).toString(16).padStart(2, '0') // '08'
    hex(255)          # '0xff'
    int('ff', 16)     # 255
    0xff              # 255
    format(8, '02x')  # '08'

    마지막 줄처럼 바이트 하나는 두 자리로 맞춰 적는다. 8을 그냥 toString(16)하면 '8' 한 자리가 나온다. 색이나 해시처럼 여러 바이트를 이어 적을 때 한 자리짜리가 끼면 뒤 바이트가 앞으로 당겨져 다른 값이 된다(Python 문서: hex, Python 문서: format).

    비트 연산: 자리마다 따로 계산한다

    비트 연산은 두 수를 2진수로 나란히 놓고 자리마다 따로 계산한다. 덧셈처럼 옆 자리로 올림이 넘어가지 않는다. 연산은 여섯 가지만 알면 된다.

    연산자리마다의 규칙예(2진수)주로 쓰는 곳
    AND &둘 다 1이면 11100 & 1010 = 1000원하는 자리만 남기기(마스크)
    OR |하나라도 1이면 11100 | 1010 = 1110자리 켜기
    XOR ^서로 다르면 11100 ^ 1010 = 0110자리 뒤집기
    NOT ~수 하나의 모든 자리를 뒤집는다(0 ↔ 1)~0101 = 1010끌 자리만 0인 마스크 만들기
    왼쪽 시프트 <<모든 자리를 왼쪽으로 민다0011 << 1 = 01102배(n칸이면 2ⁿ배)
    오른쪽 시프트 >>모든 자리를 오른쪽으로 민다0110 >> 1 = 0011반(남는 자리는 버림)

    시프트가 2배·반이 되는 까닭은 1절의 자릿값에 있다. 모든 자리가 한 칸 왼쪽으로 가면 각 자리의 자릿값이 두 배가 되니 수 전체가 두 배다. 10진수에서 뒤에 0을 붙이면 10배가 되는 것과 같다. 오른쪽으로 밀면 맨 끝 자리가 떨어져 나가므로 2로 나눈 몫만 남는다. 13은 1101이고 13 >> 1은 110, 곧 6이다. ~만 두 수가 아니라 수 하나를 받는다. 뒤집을 자리를 고르는 XOR와 달리 모든 자리를 한꺼번에 뒤집는다.

    비트 연산이 쓸모 있는 까닭은 수 하나에 여러 값을 칸칸이 나눠 담기 때문이다. CSS 색 0xff8800은 수 하나에 빨강·초록·파랑 8비트 칸 세 개를 담았고(4절), 아래에서 볼 권한 값은 읽기·쓰기·실행 1비트 칸 세 개를 담는다. 그중 한 칸만 보려면 나머지 칸을 지워야 한다. 이때 남길 자리만 1로 채운 수를 마스크(mask)라 하고, 그 수와 AND한다.

    결과를 매번 진리표로 계산하지 않고 바로 떠올리려면, 연산하는 두 수 중 한쪽을 "자리마다의 지시"로 읽는다.

    연산지시하는 수의 자리가 1이면0이면
    x & mx의 그 자리를 남긴다지운다(0)
    x | m켠다(1)x의 그 자리를 그대로 둔다
    x >> n오른쪽 n자리를 버리고 나머지를 아래로 내린다

    >>로 원하는 칸을 맨 아래로 내리고 &로 그 칸만 남기는 것, 이 두 동작이 칸 하나를 꺼내는 방법이다. 4비트 단위로 끊기는 수는 16진수 그대로 풀린다. 16진수 한 자리가 비트 4개이니 >> 8은 16진수 두 자리를 버리는 것이고, & 0xff는 마지막 두 자리만 남기는 것이다. 그래서 (0xff8800 >> 8) & 0xff는 0xff8800 → 0xff88 → 0x88, 곧 초록 136이다.

    이제 이 Unit 첫머리에 적은 『한입 CS』 1장의 줄로 돌아간다. 코드 포인트(유니코드가 글자마다 붙인 번호)를 오른쪽에서 6비트씩 떼어 UTF-8 바이트를 하나씩 만드는 반복문의 두 줄이다.

    encoded.unshift(0x80 | (rest & 0x3F))
    rest = rest >> 6

    여기서 칸은 6비트다. 이어지는 바이트는 8비트 중 앞 2자리를 10이 차지하니 담을 수 있는 건 8 − 2 = 6비트, 그래서 코드 포인트를 6비트씩 떼고 rest를 6씩 민다. 6비트 칸은 16진수 자리와 맞지 않는다. 2진수로 펼쳐 위아래로 맞춰 본다. 한의 코드 포인트 U+D55C(1101 0101 0101 1100)로 첫 바퀴를 돌리면 이렇다.

    ① rest & 0x3F : 0x3F = 0011 1111, 아래 6자리만 남긴다
      rest    1101 0101 01|01 1100
      0x3F    0000 0000 00|11 1111
      결과                 01 1100   = 0x1C
    
    ② 0x80 | 0x1C : 0x80 = 1000 0000, 맨 위 자리를 켠다
      0x1C    0001 1100   ← ①에서 위 2자리가 00으로 비었다
      0x80    1000 0000
      결과    1001 1100   = 0x9C   (10xxxxxx 모양)
    
    ③ rest >> 6 : 방금 쓴 6자리를 버리고 다음 6자리를 아래로 내린다
      1101 0101 01|01 1100  →  11 0101 0101   = 0x355

    ②는 10을 앞에 덧붙이는 것이 아니다. ①의 마스크가 바이트의 위 2자리를 00으로 비워 두었기 때문에, OR로 그 빈자리를 10으로 채울 수 있다. 이어지는 바이트가 10으로 시작하는 것은 UTF-8의 규칙이다(『한입 CS』 1장). 둘째 바퀴도 같아서 0x355의 아래 6자리 010101이 0x95가 된다. 반복문이 끝나면 남은 1101은 3바이트 글자의 첫 바이트 모양 1110xxxx에 들어가 0xED가 되고, 한은 ED 95 9C 세 바이트가 된다.

    비트 하나에 켜짐·꺼짐 하나를 담는 값을 플래그(flag)라 한다. Unix 파일 권한처럼 읽기 = 4, 쓰기 = 2, 실행 = 1로 정하면 세 값이 서로 다른 자리 하나씩(100, 010, 001)을 차지해서 한 수에 세 권한을 함께 담을 수 있다. rwx를 왼쪽부터 한 자리씩 놓은 것이라, chmod 755의 7은 111(읽기·쓰기·실행), 5는 101(읽기·실행)이다.

    예측먼저 답해 보세요

    읽기 = 4, 쓰기 = 2, 실행 = 1이다. perm = 읽기 | 실행에서 시작해 perm | 쓰기, perm & ~실행, perm ^ 읽기를 차례로 하면 남는 권한은? (~는 모든 자리를 뒤집는다)

    선택지와 해설 보기
    실험 권한 플래그를 켜고, 끄고, 뒤집고, 확인하기
    실험권한 플래그를 켜고, 끄고, 뒤집고, 확인하기

    읽기·쓰기·실행 권한을 비트 하나씩에 담았습니다. 그림의 세 칸이 perm의 2진수 세 자리입니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const READ = 4const WRITE = 2const EXEC = 1let perm = READ | EXECperm = perm | WRITEperm = perm & ~EXECperm = perm ^ READconst flags = [['READ', READ], ['WRITE', WRITE], ['EXEC', EXEC]]for (const [name, bit] of flags) {  const on = (perm & bit) !== 0  console.log(name, on ? '켜짐' : '꺼짐')}
    변수
    READ
    4
    출력
     
    1/17단계.
    • 해볼 것
    • 읽기 | 실행에서 시작해 끝까지 실행해 본다
    • 실행만으로 시작해 끝까지 실행하고 남은 권한을 견줘 본다

    첫 입력을 끝까지 돌리면 READ 꺼짐, WRITE 켜짐, EXEC 꺼짐이 찍힌다.

    101에서 쓰기를 켜면 111, 실행을 끄면 110, 읽기를 뒤집으면 010이라 쓰기만 남는다. 네 연산이 하는 일은 이렇게 정리된다.

    • 켜기: perm | WRITE. 쓰기 자리만 1인 수와 OR하니 그 자리만 1이 되고 나머지는 그대로다.
    • 끄기: perm & ~EXEC. ~EXEC는 실행 자리만 0이고 나머지가 모두 1인 마스크다. AND하면 실행 자리만 0이 된다.
    • 뒤집기: perm ^ READ. 읽기 자리가 1이면 0으로, 0이면 1로 바뀐다. 실행만으로 시작했다면 꺼져 있던 읽기가 이 줄에서 켜진다.
    • 확인: (perm & bit) !== 0. 그 자리만 남겼을 때 0이 아니면 켜져 있다. 결과는 1이 아니라 그 자리의 값(쓰기면 2)이라서 === 1로 견주면 틀린다.
    수학 다리&는 &&가 아니다
    수학에서"그리고"는 참·거짓 하나와 참·거짓 하나를 받아 참·거짓 하나를 냅니다. 둘 다 참일 때만 참입니다.
    코드에서a & b는 수 두 개를 받아 2진수 자리마다 "그리고"를 한 번씩 해서 수 하나를 냅니다. |는 자리마다 "또는"입니다.
    같은 점자리 하나만 보면 규칙이 같습니다. 1을 참, 0을 거짓으로 읽으면 &의 표가 "그리고"의 표, |의 표가 "또는"의 표입니다.
    다른 점&&·||는 값 하나를 보고 앞에서 답이 나오면 뒤를 계산하지 않습니다. &·|는 늘 양쪽을 다 계산하고 모든 자리를 한꺼번에 처리합니다. 5 && 2는 2, 5 & 2는 0입니다(101과 010은 겹치는 1이 없습니다). 참·거짓 계산은 Unit 9에서 다룹니다.

    JavaScript의 32비트 함정과 Python

    여기까지는 두 언어가 같다. 수가 커지면 갈라진다. JavaScript의 비트 연산은 계산하기 전에 연산하는 두 수(피연산자)를 32비트 부호 있는 정수(signed integer)로 바꾼다. 32비트 밖의 자리는 버리고, 맨 위 2³¹ 자리는 부호로 읽는다(MDN: 왼쪽 시프트, ECMAScript 명세: ToInt32). 부호로 읽는다는 것은 맨 위 자리의 자릿값을 +231+2^{31}이 아니라 −231-2^{31}로 센다는 뜻이다. 2³¹ = 2,147,483,648이니 맨 위 자리 하나만 켜지면 −2,147,483,648이다. 그래서 32비트 부호 있는 정수가 담는 범위는 −2³¹ ~ 2³¹ − 1이다. 같은 규칙을 4비트로 줄여 보면 이렇다.

    4비트자릿값 그대로(0~15)맨 위 자리를 −8로(−8~7)
    011177
    10008−8
    111115−8 + 4 + 2 + 1 = −1

    맨 위 자리만 −8로 세면 0~15 열여섯 가지가 −8~7로 바뀐다. 32비트는 맨 위 자릿값이 −2³¹일 뿐 같은 규칙이다. Python의 정수는 크기 제한이 없고, 비트 연산도 그 값 그대로 계산한다(Python 문서: 정수형, Python 문서: 정수의 비트 연산). 값이 커지면 메모리를 더 써서 자릿수를 이어 붙이는 임의 정밀도(arbitrary precision) 정수라서, 한계는 메모리뿐이다. 음수의 비트 연산은 맨 위 자리 왼쪽으로 1이 끝없이 이어진 2진수처럼 계산한다. 다만 자리 수가 정해진 곳과 만나면 Python도 자른다. 32비트 정수를 쓰는 C 함수에 값을 넘기는 ctypes에서 ctypes.c_int32(1 << 31).value는 JavaScript와 같은 −2147483648이고(Python 문서: ctypes 기본 자료형), NumPy의 int32 배열도 32비트 안에서 계산한다.

    예측먼저 답해 보세요

    JavaScript에서 x = 2 ** 30을 한 칸 왼쪽으로 민 x << 1의 값은? (Python이면 2147483648)

    선택지와 해설 보기
    실험 왼쪽으로 밀다가 2³¹을 넘으면
    실험왼쪽으로 밀다가 2³¹을 넘으면

    x를 한 칸씩 왼쪽으로 밉니다. TypeScript와 Python을 바꿔 가며 출력을 견줘 보세요. x는 두 언어에서 값이 달라 변수 칸 대신 그림에 비트를 그렸습니다. 32비트 안의 비트는 두 언어가 같고, '32비트 밖' 칸은 Python만 그대로 두고 JS는 버립니다. JS는 맨 위 32번째 자리를 부호로 읽습니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    let x = 2 ** 29for (let i = 30; i <= 33; i++) {  x = x << 1  console.log(i, x)}const y = 2 ** 32 + 5console.log(y | 0)
    변수

    아직 변수가 없습니다.

    출력
     
    위쪽 자리
    1. 32비트 밖(40~33)0000 0000
    2. 32~250010 0000
    3. 24~170000 0000
    아래쪽 자리
    1. 16~90000 0000
    2. 8~10000 0000
    1/15단계: x는 2를 29번 곱한 수다.
    • 해볼 것
    • TypeScript로 2²⁹에서 시작해 끝까지 실행해 본다
    • 2²⁷에서 시작해 끝까지 실행해 본다

    TypeScript로 2²⁹에서 시작하면 출력이 30 1073741824, 31 -2147483648, 32 0, 33 0이다.

    1이 2³¹ 자리에 들어가면 부호 자리라 음수가 된다. −2147483648은 비트로 보면 맨 위 자리 하나만 켜진 1000 … 0000이고, 음수인 것은 JavaScript가 그 자리를 −2³¹로 세기 때문일 뿐이다. 여기서 한 칸 더 밀면 하나뿐인 1이 33번째 자리로 가서 버려지고, 32자리가 모두 0이 된다. 위의 4비트 표로 줄이면 이렇다.

    4비트비트맨 위 자리를 −8로 읽은 값
    시작01004
    << 11000−8(1이 맨 위 자리에 들어감)
    << 100000(1이 4비트 밖으로 밀려나 버려짐)

    한 번 0이 되면 더 밀어도 0이다. Python에서는 같은 계산이 2147483648, 4294967296, 8589934592로 계속 두 배가 된다. | 0도 갈린다. 0과 OR하면 값이 그대로여야 할 것 같지만 JavaScript는 2 ** 32 + 5의 32비트 밖 자리를 버리고 5만 남긴다. 같은 이유로 2 ** 32 | 0은 0이다. 2³²는 33번째 자리 하나만 켜진 수라 32비트 안에는 1이 없다. 이렇게 정해진 비트 수를 넘은 자리가 버려지는 일은 Unit 5에서 오버플로라는 이름으로 다시 본다.

    정리하면 이렇다.

    코드JavaScriptPython
    1 << 31-21474836482147483648
    2 ** 32 | 004294967296
    (2 ** 32 + 5) | 054294967301
    ~4-5-5

    ~4는 두 언어가 같다. 둘 다 ~x를 −x − 1로 계산하기 때문이다. ~(NOT)는 5절 표의 모든 자리 뒤집기다. 어떤 수와 그 수를 뒤집은 수를 자리마다 더하면 올림 없이 모든 자리가 1인 수가 된다(0111 + 1000 = 1111). 맨 위 자리를 −8로 읽으면 1111은 −8 + 4 + 2 + 1 = −1이다. 그래서 x + ~x = −1이고, 옮기면 ~x = −x − 1이다. 위의 4비트 표로 보면 ~0111(7)은 모든 자리를 뒤집은 1000, 곧 −8 = −7 − 1이다. 32비트에서도 모든 자리가 1인 수는 −1이라 같은 식이 선다. JavaScript에서 부호 없이 0 ~ 2³² − 1로 읽고 싶으면 부호 없는 오른쪽 시프트 >>> 0을 붙인다. -1 >>> 0은 4294967295다. 실무에서 기억할 것은 하나다. JavaScript에서 2³¹ 이상이 될 수 있는 수(타임스탬프, 큰 id, 바이트 수)에는 |·&·<<·>>를 쓰지 않는다. 소수점을 버리려면 x | 0 대신 Math.trunc(x), 반으로 나누려면 x >> 1 대신 Math.floor(x / 2)를 쓴다(MDN: Math.trunc). 정말 큰 정수를 비트로 다뤄야 하면 BigInt를 쓴다. BigInt끼리의 비트 연산은 32비트로 자르지 않는다. 다만 BigInt에는 >>>가 없다(MDN: BigInt).

    함정 찾기 AI가 짠 권한·색·시간 함수, 어느 줄이 틀렸을까?
    함정 찾기AI가 짠 권한·색·시간 함수, 어느 줄이 틀렸을까?
    내가 AI에게 한 요청
    “권한 플래그 세 개(읽기·쓰기·실행)와 쓰기 권한 확인 함수, RGB를 #rrggbb로 바꾸는 함수, 밀리초 타임스탬프의 소수점을 버리는 함수를 JavaScript로 짜 줘.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 4개입니다.

    확인 문제 연습
    확인 문제연습

    1. 2진수 1010은 10진수로 얼마일까?

    2. 비트 10개로 나타낼 수 있는 값은 몇 가지일까?

    3. 16진수 0x0F & 0xAB의 결과는? (0x0F는 2진수 0000 1111, 0xAB는 1010 1011)

    4. CSS 색 #ff8800에서 초록(가운데 두 자리 88)은 10진수로 얼마일까?

    5. JavaScript에서 1 << 31이 음수가 되는 이유는?

    6. Python에서 1 << 31의 값은?

    이 Unit이 쓰이는 곳

    • 『한입 CS』 0장: 숫자는 비트다: 2진 소수의 자릿값(1/2, 1/4, 1/8)으로 0.1이 왜 끝나지 않는지 보고, 64비트를 부호·지수·가수로 나눈다. 안전한 정수가 2⁵³ − 1까지인 이유(2⁵³까지는 정확하지만 2⁵³ + 1은 2⁵³으로 반올림되어 옆 정수와 구별이 안 된다)도 "비트 n개로 2ⁿ가지"에서 나온다. 개념 정리는 부동소수점 노트에 있다.
    • 『한입 CS』 1장: 문자는 바이트다: 코드 포인트를 U+D55C처럼 16진수로 적고, UTF-8 코드 추적이 rest & 0x3F로 6비트를 꺼내 0x80 |로 10xxxxxx 바이트를 만든 뒤 rest >> 6으로 다음 6비트를 내린다. 바이트는 16진수 두 자리로 보여 준다.
    • 『한입 CS』 10장: 해시, 암호화, 인코딩: Base64가 바이트 3개(24비트)를 6비트씩(2⁶ = 64가지) 잘라 글자 4개로 바꾸고, SHA-256 해시 값 256비트를 16진수 64글자로 적는다.
    • 『한입 알고리즘』 6장: 이분 탐색: (lo + hi) >> 1이 반으로 나누는 시프트이고, >>가 32비트 정수로 바꿔 계산해서 2³¹ 이상이면 음수가 된다는 함정을 다룬다.
    더 풀어 보기(선택) · n & (n - 1) 한 번으로 2의 거듭제곱 가려내기
    보너스 문제n & (n - 1) 한 번으로 2의 거듭제곱 가려내기

    양의 정수 n이 2의 거듭제곱(1, 2, 4, 8, …)이면 n & (n - 1)이 늘 0이고, 아니면 0이 아니다. n = 8과 n = 12를 2진수로 적어 직접 확인하고, 왜 그런지 설명해 보자. 또 n = 0일 때 이 검사가 틀리는 이유도 찾아보자.

    n = 8  → 8 & 7  = ?
    n = 12 → 12 & 11 = ?
    n = 0  → 0 & -1 = ?
    다음Unit 2. 로그: 10억 개에서 30번 만에 찾는 이유2진수·16진수와 비트 연산을 마쳤습니다. Unit 2는 로그를 다룹니다. 1부터 10억 사이 숫자를 맞히는 업다운 게임이 왜 30번이면 끝나는지에서 출발해 봅니다.

    『개발 수학』 Unit 1. 2진수와 16진수: 0xFF는 왜 255일까 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션