JUNSEOK
01 · 수학과 자료구조·18분·4개 레슨

수 표현과 비트 연산

진법 변환, 2의 보수, IEEE 754, 비트마스크 실전.

컴퓨터가 숫자를 저장하는 실제 방식. 0.1 + 0.2 !== 0.3을 내부 비트 수준에서 설명할 수 있으면 통과.


0. 시작하기 전에 — 컴퓨터와 "숫자"

0-1. 컴퓨터는 사실 숫자를 직접 다루지 않는다

"컴퓨터 = 숫자 계산하는 기계"라고 알고 있지만, 그 내부에서는 오직 전기 신호 on / off 만 존재한다. 켜짐을 1, 꺼짐을 0으로 약속하고, 이 신호들을 묶어서 숫자로 "해석" 할 뿐이다.

즉 메모리에 저장된 01000001이라는 8개의 켜짐/꺼짐 패턴은,

  • 숫자로 해석하면: 65
  • 문자로 해석하면: A (ASCII 코드 65)
  • 이미지의 픽셀로 해석하면: 어떤 색상의 일부

같은 비트 패턴도 "어떻게 해석하느냐"에 따라 완전히 달라진다. 이 장이 다루는 "수 표현"은 바로 이 해석 규칙에 관한 이야기다.

0-2. 단위 정리

단위크기
bit1 (0 또는 1)1
byte8 bit01000001
KB (킬로바이트)1024 byte작은 이미지 1장
MB1024 KBMP3 1곡
GB1024 MB영화 1편

0-3. 프론트엔드에서 실제로 부딪히는 문제들

이 장에서 답을 얻게 되는 실제 상황들:

  • 0.1 + 0.2가 왜 0.3이 아니라 0.30000000000000004로 나오는가
  • #FF6B6B 같은 CSS 색상 코드가 왜 저렇게 생겼는가
  • 게시판에서 "9999999999999"를 넘기면 왜 숫자가 깨지나 (Number.MAX_SAFE_INTEGER)
  • 권한 플래그를 0b0101 같은 비트마스크로 쓰는 이유
  • 이미지나 파일 크기가 왜 2의 거듭제곱으로 설정되나

0-4. 이 장의 4가지 축

  1. 진법 — 2진수, 10진수, 16진수를 서로 변환
  2. 정수 표현 — 음수는 어떻게 표현하나 (2의 보수)
  3. 부동소수점 — 실수 3.14는 어떻게 저장되나
  4. 비트 연산&, |, ^, <<, >>의 실전 쓰임

1. 왜 2진수부터 시작하는가

컴퓨터의 모든 연산은 결국 비트(bit) — 0과 1 단위로 처리된다. 변수에 42를 넣어도 메모리에는 00101010이 저장된다. JS에서 Number를 다룰 때도 내부적으로는 64비트 부동소수점이고, 비트 연산 시에는 32비트 정수로 변환된다.

이 내부 표현을 이해하지 못하면 부동소수점 오차, 정수 오버플로, 권한 비트마스크, RGB 색 합성 같은 실무 상황에서 계속 막힌다.


2. 진법 변환

2-1. 10진수 ↔ 2진수

10진수 13을 2진수로 변환한다. 2로 나눠가며 나머지를 역순으로 읽는다.

13 ÷ 2 = 6  나머지 1
 6 ÷ 2 = 3  나머지 0
 3 ÷ 2 = 1  나머지 1
 1 ÷ 2 = 0  나머지 1
1101

2진수 1101을 10진수로는 이렇게 읽는다.

1×2³ + 1×2² + 0×2¹ + 1×2= 8 + 4 + 0 + 1 = 13

JS에서 확인한다.

(13).toString(2);    // "1101"
parseInt("1101", 2); // 13

2-2. 16진수를 왜 쓰는가

2진수는 길어서 읽기 힘들다. 16진수는 4비트 = 1자리이므로 2진수와 1:1 매핑된다.

2진수 1101 1010 0101 1111
16진수    D    A    5    F

색상 #FF5733, 메모리 주소 0x7FFE, UUID 등이 전부 16진수를 쓰는 이유다.

(255).toString(16);  // "ff"
parseInt("ff", 16);  // 255
0xFF;                // 255 (JS 리터럴)

3. 음수 표현: 2의 보수

3-1. 왜 2의 보수인가

음수를 표현하는 방법은 세 가지가 있다.

  1. 부호-크기: 최상위 비트를 부호로 사용 (1000 0001 = -1)
  2. 1의 보수: 모든 비트를 반전 (1111 1110 = -1)
  3. 2의 보수: 1의 보수 + 1 (1111 1111 = -1)

앞의 두 방식은 +0-0이 따로 존재하고, 덧셈 회로가 복잡하다. 2의 보수는 0이 하나뿐이고, 뺄셈을 덧셈 회로로 그대로 처리할 수 있다 — 그래서 모든 현대 CPU가 2의 보수를 쓴다.

3-2. 2의 보수 구하기

8비트에서 -5를 표현하려면:

  5 = 0000 0101
비트 반전 = 1111 1010  (1의 보수)
  + 1     = 1111 1011  (2의 보수, = -5)

검증: 5 + (-5)가 0이 되어야 한다.

  0000 0101
+ 1111 1011
-----------
 10000 0000  ← 9비트째는 버림 → 0000 0000

3-3. JS의 32비트 정수

JS의 Number는 64비트 double이지만, 비트 연산 시에는 32비트 signed integer로 변환된다.

~0;           // -1  (모든 비트 반전: 0000...0 → 1111...1 = -1)
~5;           // -6
(~~3.7);      // 3   (double-tilde 트릭: 32비트 정수로 절삭)
2 ** 31;      // 2147483648
2 ** 31 | 0;  // -2147483648 (32비트 오버플로)

⚠️ 함정: 1 << 312147483648이 될 것 같지만, 32비트 signed 범위를 넘어 -2147483648이 된다. 큰 비트 시프트가 필요하면 Math.pow(2, n) 또는 BigInt를 쓴다.


4. IEEE 754 부동소수점

4-1. 64비트 double 구조

JS의 모든 숫자(BigInt 제외)는 IEEE 754 double-precision 포맷이다.

┌─┬────────────┬─────────────────────────────────────────────────┐
S지수 (11) │                   가수 (52)                     │
└─┴────────────┴─────────────────────────────────────────────────┘
 1
  • 부호 (S): 1비트. 0이면 양수, 1이면 음수.
  • 지수 (E): 11비트. 바이어스 1023을 빼서 실제 지수를 구함.
  • 가수 (M): 52비트. 암시적 선두 1을 포함해 53비트 정밀도.

실제 값 = (-1)^S × 1.M × 2^(E-1023)

4-2. 0.1 + 0.2 !== 0.3의 진짜 이유

0.1을 2진수로 표현하려고 하면 무한 반복된다.

0.1₁₀ = 0.0001100110011001100...₂  (0011이 무한 반복)

52비트까지만 저장하므로 정밀도 손실이 발생한다. 0.1로 저장된 실제 값을 17자리까지 출력하면:

(0.1).toPrecision(17);  // "0.10000000000000001"
(0.2).toPrecision(17);  // "0.20000000000000001"
(0.1 + 0.2).toPrecision(17);  // "0.30000000000000004"
0.1 + 0.2 === 0.3;  // false

이것은 JS 버그가 아니라 IEEE 754의 본질적 한계다. 파이썬, Java, C도 똑같이 동작한다.

4-3. Number.EPSILON으로 비교

부동소수점 비교는 === 대신 오차 허용치를 쓴다.

function nearlyEqual(a, b, epsilon = Number.EPSILON) {
  return Math.abs(a - b) < epsilon;
}

nearlyEqual(0.1 + 0.2, 0.3);  // true

실무에서는 절대 오차가 아닌 상대 오차가 필요한 경우가 많다.

function relativelyEqual(a, b, epsilon = 1e-9) {
  return Math.abs(a - b) <= epsilon * Math.max(Math.abs(a), Math.abs(b));
}

4-4. 안전한 정수 범위

가수가 52비트 + 암시적 1비트 = 53비트 정밀도. 이 범위를 넘으면 인접한 정수가 표현되지 않는다.

Number.MAX_SAFE_INTEGER;       // 9007199254740991 (= 2^53 - 1)
Number.MAX_SAFE_INTEGER + 1;   // 9007199254740992
Number.MAX_SAFE_INTEGER + 2;   // 9007199254740992 ← 같다!
9007199254740993 === 9007199254740992;  // true (!)

서버에서 큰 ID(예: Twitter snowflake, bigint PK)를 받을 때 JSON 파싱 단계에서 정밀도가 날아간다. 서버가 숫자 ID를 문자열로 내려주는 것이 표준 관례가 된 이유다.

// 서버가 { id: 9007199254740993 }로 보내면 ↓
JSON.parse('{"id": 9007199254740993}');  // { id: 9007199254740992 } ← 망가짐

// 문자열로 받아서 BigInt로 파싱
BigInt("9007199254740993");  // 9007199254740993n

5. 비트 연산자

5-1. 기본 연산자

연산자이름동작
&AND둘 다 1일 때만 1
|OR하나라도 1이면 1
^XOR서로 다를 때 1
~NOT비트 반전
<<왼쪽 시프트n배
>>부호 있는 오른쪽 시프트n으로 나눈 몫
>>>부호 없는 오른쪽 시프트최상위 비트를 0으로 채움
  0b1100
& 0b1010
= 0b1000  // 12 & 10 === 8

  0b1100
| 0b1010
= 0b1110  // 12 | 10 === 14

  0b1100
^ 0b1010
= 0b0110  // 12 ^ 10 === 6

5-2. 관용구

// 짝수/홀수 판정
n & 1;  // 1이면 홀수, 0이면 짝수
5 & 1;  // 1
4 & 1;  // 0

// 2의 거듭제곱 판정
function isPowerOfTwo(n) {
  return n > 0 && (n & (n - 1)) === 0;
}
// 8 = 1000, 7 = 0111, 8 & 7 = 0000 → 거듭제곱
// 6 = 0110, 5 = 0101, 6 & 5 = 0100 → 아님

// 두 수 교환 (XOR 트릭)
let a = 5, b = 3;
a ^= b;
b ^= a;
a ^= b;
// a=3, b=5

5-3. ⚠️ 함정: >>>>>의 차이

-1 >> 1;    // -1  (부호 유지: 11111111 → 11111111)
-1 >>> 1;   // 2147483647  (0으로 채움: 11111111 → 01111111)

>>>는 UInt 변환이 필요할 때 쓴다. 배열 길이 비교 시 음수를 방지하는 관용구로도 쓰인다.

// jQuery 스타일: length를 uint32로 정규화
const len = arr.length >>> 0;

6. FE 실전 연결

6-1. 권한 비트마스크 (RBAC)

권한을 bool 필드로 여러 개 두는 대신 하나의 정수에 비트로 압축한다.

const READ    = 1 << 0;  // 0001
const WRITE   = 1 << 1;  // 0010
const DELETE  = 1 << 2;  // 0100
const ADMIN   = 1 << 3;  // 1000

// 권한 조합
let userPerm = READ | WRITE;       // 0011

// 권한 확인
const canWrite = (userPerm & WRITE) !== 0;  // true
const canDelete = (userPerm & DELETE) !== 0; // false

// 권한 추가
userPerm |= DELETE;                // 0111

// 권한 제거
userPerm &= ~DELETE;               // 0011

// 권한 토글
userPerm ^= ADMIN;                 // 1011

실무 규칙: 권한이 많아 JS의 32비트 int 범위(31개)를 넘는다면 BigInt를 쓰거나 여러 필드로 분할한다.

6-2. RGB 색상 합성

const r = 0xFF, g = 0x57, b = 0x33;

// 한 정수로 합치기
const color = (r << 16) | (g << 8) | b;  // 0xFF5733

// 다시 추출
const extractedR = (color >> 16) & 0xFF;  // 255
const extractedG = (color >> 8) & 0xFF;   // 87
const extractedB = color & 0xFF;          // 51

// CSS에 쓰기
`#${color.toString(16).padStart(6, '0')}`;  // "#ff5733"

6-3. 성능 트릭

n << 1;  // n * 2
n >> 1;  // Math.floor(n / 2)  (양수일 때)

⚠️ 함정: 현대 엔진은 n * 2를 이미 n << 1로 최적화한다. 가독성을 해치면서까지 쓰지 말 것. 단, 코드가 명백히 비트를 다루는 맥락(마스킹, 인덱싱, 해시)이면 비트 연산자가 더 명확하다.


연습 문제

  1. 0.1 + 0.2의 실제 결과를 17자리까지 출력하고, IEEE 754 비트 배치로 왜 그런지 설명하라.
  2. 정수 n이 2의 거듭제곱인지 판정하는 함수를 비트 연산으로 작성하라. n = 0일 때 어떻게 처리해야 하는가?
  3. 32비트 정수 n에서 켜져 있는 비트(1인 비트)의 개수를 세는 함수를 작성하라. (Hamming Weight)
  4. RGBA 색상을 하나의 32비트 정수로 압축하고 다시 추출하는 함수를 작성하라.
  5. -1 >> 1-1 >>> 1의 결과가 다른 이유를 2의 보수로 설명하라.

연습 문제 정답

1. 0.1 + 0.2

(0.1 + 0.2).toPrecision(17);  // "0.30000000000000004"

0.10.2는 모두 2진수로 무한 반복되는 소수다. 52비트로 잘리면서 실제 저장값은 각각 0.1000000000000000055..., 0.2000000000000000111.... 더하면 0.3000000000000000444...가 되고, 0.3의 double 표현(0.2999999999999999889...)과 다르기 때문에 ===false를 반환한다.

2. 2의 거듭제곱 판정

function isPowerOfTwo(n) {
  return n > 0 && (n & (n - 1)) === 0;
}

n > 0 체크가 없으면 n = 0일 때 0 & -1 === 0이 참이 되어 오답이다.

3. Hamming Weight

function popcount(n) {
  let count = 0;
  while (n !== 0) {
    n &= (n - 1);  // 가장 낮은 1 비트를 0으로
    count++;
  }
  return count;
}
popcount(0b1011);  // 3

n & (n-1)은 가장 낮은 1비트를 지우는 연산이다. 1비트 수만큼만 반복한다.

4. RGBA 압축/추출

function pack(r, g, b, a) {
  return ((r & 0xFF) << 24) | ((g & 0xFF) << 16) | ((b & 0xFF) << 8) | (a & 0xFF);
}
function unpack(rgba) {
  return {
    r: (rgba >>> 24) & 0xFF,
    g: (rgba >>> 16) & 0xFF,
    b: (rgba >>> 8) & 0xFF,
    a: rgba & 0xFF,
  };
}

⚠️ r이 0x80 이상이면 32비트 signed int의 부호 비트를 차지하므로 >>>로 꺼내야 한다.

5. -1 >> 1 vs -1 >>> 1

-1의 32비트 2의 보수 표현은 11111111 11111111 11111111 11111111이다.

  • >>산술 시프트: 부호 비트를 유지한다. 1로 채워지므로 결과는 여전히 -1.
  • >>>논리 시프트: 0으로 채운다. 01111111... = 2147483647 (2³¹ - 1).

체크리스트

  • 10진수 ↔ 2진수 ↔ 16진수 변환을 손으로 할 수 있다
  • 2의 보수로 음수를 표현하는 방법을 설명할 수 있다
  • IEEE 754 double의 부호/지수/가수 비트 배치를 안다
  • 0.1 + 0.2 !== 0.3을 내부 비트 수준에서 설명할 수 있다
  • Number.MAX_SAFE_INTEGER의 값과 이유를 안다
  • 큰 숫자 ID를 서버에서 문자열로 내려주는 이유를 안다
  • & | ^ ~ << >> >>> 각각의 동작을 즉답할 수 있다
  • n & (n - 1) === 0으로 2의 거듭제곱을 판정할 수 있다
  • 권한 비트마스크 add/remove/toggle 관용구를 안다
  • >>>>>의 차이를 음수 예시로 설명할 수 있다

진도 체크시작 전
NEXT · 1-2

이산수학 핵심

명제 논리, 집합, 순열·조합, 귀납법.

이어서 학습하기 →