컴퓨터가 숫자를 저장하는 실제 방식.
0.1 + 0.2 !== 0.3을 내부 비트 수준에서 설명할 수 있으면 통과.
0. 시작하기 전에 — 컴퓨터와 "숫자"
0-1. 컴퓨터는 사실 숫자를 직접 다루지 않는다
"컴퓨터 = 숫자 계산하는 기계"라고 알고 있지만, 그 내부에서는 오직 전기 신호 on / off 만 존재한다. 켜짐을 1, 꺼짐을 0으로 약속하고, 이 신호들을 묶어서 숫자로 "해석" 할 뿐이다.
즉 메모리에 저장된 01000001이라는 8개의 켜짐/꺼짐 패턴은,
- 숫자로 해석하면: 65
- 문자로 해석하면:
A(ASCII 코드 65) - 이미지의 픽셀로 해석하면: 어떤 색상의 일부
같은 비트 패턴도 "어떻게 해석하느냐"에 따라 완전히 달라진다. 이 장이 다루는 "수 표현"은 바로 이 해석 규칙에 관한 이야기다.
0-2. 단위 정리
| 단위 | 크기 | 예 |
|---|---|---|
| bit | 1 (0 또는 1) | 1 |
| byte | 8 bit | 01000001 |
| KB (킬로바이트) | 1024 byte | 작은 이미지 1장 |
| MB | 1024 KB | MP3 1곡 |
| GB | 1024 MB | 영화 1편 |
0-3. 프론트엔드에서 실제로 부딪히는 문제들
이 장에서 답을 얻게 되는 실제 상황들:
0.1 + 0.2가 왜0.3이 아니라0.30000000000000004로 나오는가#FF6B6B같은 CSS 색상 코드가 왜 저렇게 생겼는가- 게시판에서 "9999999999999"를 넘기면 왜 숫자가 깨지나 (
Number.MAX_SAFE_INTEGER) - 권한 플래그를
0b0101같은 비트마스크로 쓰는 이유 - 이미지나 파일 크기가 왜 2의 거듭제곱으로 설정되나
0-4. 이 장의 4가지 축
- 진법 — 2진수, 10진수, 16진수를 서로 변환
- 정수 표현 — 음수는 어떻게 표현하나 (2의 보수)
- 부동소수점 — 실수
3.14는 어떻게 저장되나 - 비트 연산 —
&,|,^,<<,>>의 실전 쓰임
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의 보수인가
음수를 표현하는 방법은 세 가지가 있다.
- 부호-크기: 최상위 비트를 부호로 사용 (
1000 0001= -1) - 1의 보수: 모든 비트를 반전 (
1111 1110= -1) - 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 << 31은 2147483648이 될 것 같지만, 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로 최적화한다. 가독성을 해치면서까지 쓰지 말 것. 단, 코드가 명백히 비트를 다루는 맥락(마스킹, 인덱싱, 해시)이면 비트 연산자가 더 명확하다.
연습 문제
0.1 + 0.2의 실제 결과를 17자리까지 출력하고, IEEE 754 비트 배치로 왜 그런지 설명하라.- 정수
n이 2의 거듭제곱인지 판정하는 함수를 비트 연산으로 작성하라.n = 0일 때 어떻게 처리해야 하는가? - 32비트 정수
n에서 켜져 있는 비트(1인 비트)의 개수를 세는 함수를 작성하라. (Hamming Weight) - RGBA 색상을 하나의 32비트 정수로 압축하고 다시 추출하는 함수를 작성하라.
-1 >> 1과-1 >>> 1의 결과가 다른 이유를 2의 보수로 설명하라.
연습 문제 정답
1. 0.1 + 0.2
(0.1 + 0.2).toPrecision(17); // "0.30000000000000004"
0.1과 0.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 관용구를 안다
-
>>와>>>의 차이를 음수 예시로 설명할 수 있다