
16진수(Hexadecimal): 긴 이진수를 짧게
컬러 코드(#FFFFFF)가 왜 문자와 숫자가 섞여 있을까요? 16진수가 개발자에게 주는 최고의 선물은 '가독성'입니다.

컬러 코드(#FFFFFF)가 왜 문자와 숫자가 섞여 있을까요? 16진수가 개발자에게 주는 최고의 선물은 '가독성'입니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

처음 크래시 덤프를 열었을 때, 나는 0xDEADBEEF라는 주소를 보고 얼어붙었다. 이게 진짜 주소야, 농담이야? 숫자도 아니고 단어도 아닌 이 괴상한 조합이 뭔지 몰라서 한참을 검색했다. 그러다 알게 됐다. 이건 16진수고, 누군가 의도적으로 심어놓은 "마커"였다. DEAD BEEF. 죽은 소고기. 개발자들의 은밀한 유머였던 거다.
그때부터 궁금해졌다. 왜 하필 16진수일까? 왜 숫자와 알파벳을 섞어 쓰는 걸까? CSS에서 색깔을 #FFFFFF로 쓰는 것도, 메모리 주소가 0x7ffee4b3c8d0 같은 식으로 나오는 것도 전부 16진수 때문이었다. 나는 이 시스템을 제대로 이해하고 싶었다.
컴퓨터는 전기 신호로 작동한다. 켜짐(1) 아니면 꺼짐(0). 그래서 모든 데이터를 2진수로 저장한다. 논리적으로는 완벽하다. 하지만 사람 눈에는 재앙이다.
예를 들어, 10진수 255를 2진수로 쓰면 11111111이다. 8자리. 괜찮아 보인다. 하지만 65535는 어떨까?
1111111111111111
16자리. 이걸 읽으려면 네 자리씩 끊어서 세어야 한다. "1111, 1111, 1111, 1111... 아 몰라." 이런 식이다.
32비트 메모리 주소는 어떨까?
11010101111010101010101010101010
32자리. 이건 그냥 암호다. 누가 봐도 무슨 주소인지 알 수 없다. 디버깅할 때 이런 주소를 비교해야 한다면? 지옥이 따로 없다.
컴퓨터는 이진수가 편하다. 하지만 우리는 사람이다. 우리에게는 요약본이 필요했다. 그래서 나온 게 16진수다.
16진수의 핵심은 단순하다. 2진수 4자리를 딱 1글자로 압축한다.
왜 4자리냐고? 2의 4승이 16이니까. 16진수는 0부터 15까지를 한 자리로 표현한다. 2진수로 15를 쓰면 1111 (4비트). 딱 맞아떨어진다.
이 매핑 표를 보고 나는 "아, 이래서구나" 했다.
| 2진수 | 10진수 | 16진수 |
|---|---|---|
| 0000 | 0 | 0 |
| 0001 | 1 | 1 |
| 0010 | 2 | 2 |
| 0011 | 3 | 3 |
| 0100 | 4 | 4 |
| 0101 | 5 | 5 |
| 0110 | 6 | 6 |
| 0111 | 7 | 7 |
| 1000 | 8 | 8 |
| 1001 | 9 | 9 |
| 1010 | 10 | A |
| 1011 | 11 | B |
| 1100 | 12 | C |
| 1101 | 13 | D |
| 1110 | 14 | E |
| 1111 | 15 | F |
10진수에는 숫자가 09까지 10개밖에 없다. 그래서 10부터는 두 자리로 넘어간다. 하지만 16진수는 15까지를 한 자리로 표현해야 하니까 알파벳 AF를 빌려왔다. 이게 처음엔 이상해 보였는데, 지금은 너무나 자연스럽다.
이제 아까 그 32자리 2진수를 16진수로 바꿔보자.
2진수: 11010101 11101010 10101010 10101010
16진수: D5 EA AA AA
결과: 0xD5EAAAAA
32자리가 8자리로 줄었다. 4배 압축이다. 이제 눈에 들어온다. 패턴도 보인다. AA가 반복되네? 이런 걸 2진수에서는 절대 못 봤을 거다.
가장 쉽다. 오른쪽부터 4비트씩 끊어서 위 표를 보고 바꾸면 끝.
예: 11111010을 16진수로?
1111 1010
F A
= 0xFA
비트 수가 4의 배수가 아니면? 왼쪽에 0을 채운다.
예: 1010111을 16진수로?
0101 0111
5 7
= 0x57
역순. 각 16진수 글자를 4비트 2진수로 바꾸면 끝.
예: 0x3C를 2진수로?
3 C
0011 1100
= 00111100
16으로 계속 나눠서 나머지를 거꾸로 읽는다.
예: 255를 16진수로?
255 ÷ 16 = 15 나머지 15 (F)
15 ÷ 16 = 0 나머지 15 (F)
거꾸로 읽으면: 0xFF
이 방법은 손으로 하면 번거롭다. 그래서 코드로 하는 게 훨씬 빠르다.
각 자리에 16의 거듭제곱을 곱해서 더한다.
예: 0x2F를 10진수로?
2 × 16¹ + F × 16⁰
= 2 × 16 + 15 × 1
= 32 + 15
= 47
손으로 하긴 귀찮다. JavaScript의 parseInt()가 있다.
나는 코드로 변환을 직접 해보고 나서야 완전히 이해했다.
// 10진수 → 16진수
const decimal = 255;
const hex = decimal.toString(16);
console.log(hex); // "ff"
console.log('0x' + hex.toUpperCase()); // "0xFF"
// 16진수 → 10진수
const hexValue = 'FF';
const decimalValue = parseInt(hexValue, 16);
console.log(decimalValue); // 255
// 2진수 → 16진수 (via 10진수)
const binary = '11111111';
const decFromBin = parseInt(binary, 2);
const hexFromBin = decFromBin.toString(16);
console.log(hexFromBin); // "ff"
// 색깔 코드 분해하기
const color = '#FF5733';
const r = parseInt(color.slice(1, 3), 16); // FF → 255
const g = parseInt(color.slice(3, 5), 16); // 57 → 87
const b = parseInt(color.slice(5, 7), 16); // 33 → 51
console.log(`RGB(${r}, ${g}, ${b})`); // "RGB(255, 87, 51)"
// 0x 표기법으로 직접 쓰기
const address = 0xDEADBEEF;
console.log(address); // 3735928559 (10진수로 출력됨)
console.log(address.toString(16)); // "deadbeef"
이 코드를 돌려보니까 "아, 내부적으론 전부 똑같은 숫자구나" 하는 게 와닿았다. 16진수든 2진수든 10진수든 전부 같은 값을 다르게 표기하는 것뿐이다.
이건 내가 제일 자주 쓴다. CSS에서 색깔 지정할 때.
.button {
background-color: #FF5733; /* 주황색 계열 */
color: #FFFFFF; /* 흰색 */
border: 1px solid #000000; /* 검은색 */
}
#FF5733의 의미:
빨강이 강하고 초록이 약간 섞여서 주황색 계열이 나온다. 16진수로 쓰니까 딱 6글자. rgb(255, 87, 51)보다 훨씬 짧다.
CSS는 단축 표기도 지원한다.
.box {
background: #FFF; /* #FFFFFF와 동일 */
color: #000; /* #000000과 동일 */
}
#FFF는 #FF FF FF를 줄인 거다. 각 채널이 동일한 두 글자일 때만 가능하다.
디버거를 켜면 메모리 주소가 전부 16진수다.
0x7ffee4b3c8d0
0x00007fff5fc01000
0x0000000100003f20
왜 16진수로 표기할까? 메모리는 바이트 단위로 정렬되고, 1바이트는 8비트 = 16진수 2자리로 딱 표현된다. 그래서 메모리 덤프를 볼 때 주소가 0x1000, 0x1001, 0x1002 이런 식으로 규칙적이면 "아, 연속된 메모리구나"라고 바로 알 수 있다.
네트워크 카드의 고유 주소.
00:1A:2B:3C:4D:5E
콜론으로 구분된 6쌍의 16진수. 각 쌍이 1바이트 (8비트). 총 48비트.
왜 16진수? 00000000:00011010:00101011:... 이렇게 쓰면 비효율적이니까.
이모지나 특수 문자의 Unicode 값도 16진수로 표기한다.
// 💀 (skull emoji)
console.log('💀'.codePointAt(0).toString(16)); // "1f480"
// JavaScript에서 Unicode escape
const skull = '\u{1F480}';
console.log(skull); // 💀
파일 타입을 식별하는 첫 몇 바이트를 "매직 넘버"라고 부른다. Hex 에디터로 보면 이렇게 생겼다.
PNG: 89 50 4E 47
JPEG: FF D8 FF
GIF: 47 49 46 38
ZIP: 50 4B 03 04
89 50 4E 47을 ASCII로 읽으면 .PNG가 나온다. 이런 헤더를 보고 "아, 이건 PNG 파일이구나" 판단한다.
바이너리 파일을 분석할 때 Hex 덤프를 쓴다.
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0300 3e00 0100 0000 5010 0000 0000 0000 ..>.....P.......
왼쪽은 주소(16진수), 가운데는 데이터(16진수), 오른쪽은 ASCII 변환. 2진수로 봤으면 아무것도 못 읽었을 거다.
8진수(Octal)도 있다. 0~7까지. Unix 권한 설정에서 쓴다.
chmod 755 file.sh
# 7 = 111 (rwx)
# 5 = 101 (r-x)
# 5 = 101 (r-x)
2진수 3비트를 1자리로 압축한다. 하지만 1바이트는 8비트인데, 3으로 나누면 안 떨어진다. 그래서 메모리 주소나 색깔 코드엔 안 맞다. 16진수가 압도적으로 유리하다. 4비트 = 16진수 1자리. 8비트 = 16진수 2자리. 딱딱 떨어진다.
그래서 요즘은 8진수를 거의 안 쓴다. Unix 권한 정도만 남았다.
코드에서 16진수를 쓸 때 앞에 0x를 붙인다.
const num = 0xFF; // 255
const addr = 0xDEADBEEF;
왜 0x일까? C 언어에서 유래했다. 0으로 시작하면 8진수, 0x로 시작하면 16진수. 이 관습이 그대로 내려왔다.
CSS에서는 #을 쓴다.
color: #FF5733;
URL 인코딩에서는 %를 쓴다.
https://example.com/search?q=Hello%20World
%20은 공백(스페이스)의 ASCII 코드 32를 16진수로 쓴 거다.
JavaScript에서 16진수 리터럴을 쓸 때 주의할 점.
const a = 0xFF; // 정상: 255
const b = 0XFF; // 정상: 255 (대문자 X도 됨)
const c = 0xff; // 정상: 255 (소문자 f도 됨)
// 하지만 문자열은 다르다
parseInt('FF', 16); // 255
parseInt('0xFF', 16); // 255
parseInt('FF', 10); // NaN (10진수로 해석 불가)
parseInt()는 두 번째 인자로 진법을 명시해야 한다. 안 쓰면 10진수로 간주한다.
parseInt('FF'); // NaN
parseInt('10'); // 10 (10진수)
parseInt('10', 16); // 16 (16진수)
개발자들은 디버깅 마커로 의미 있는 16진수를 쓴다.
0xDEADBEEF: "죽은 소고기" - 메모리 해제 마커0xCAFEBABE: Java 클래스 파일 매직 넘버0xFEEDFACE: macOS Mach-O 파일 헤더0xBADC0FFE: "나쁜 커피" - 에러 코드0xDEADC0DE: "죽은 코드" - 미사용 코드 마커이런 걸 보면 슬쩍 웃게 된다. 16진수가 주는 특권이다. 10진수로는 절대 못 만드는 단어들.
내가 만든 간단한 Hex Dump 함수.
function hexDump(buffer) {
const bytes = new Uint8Array(buffer);
let output = '';
for (let i = 0; i < bytes.length; i += 16) {
// 주소 (8자리 16진수)
const addr = i.toString(16).padStart(8, '0');
output += addr + ': ';
// 16바이트씩 출력
for (let j = 0; j < 16; j++) {
if (i + j < bytes.length) {
const byte = bytes[i + j].toString(16).padStart(2, '0');
output += byte + ' ';
} else {
output += ' '; // 빈 공간
}
if (j === 7) output += ' '; // 중간 구분
}
// ASCII 변환
output += ' |';
for (let j = 0; j < 16; j++) {
if (i + j < bytes.length) {
const byte = bytes[i + j];
// 출력 가능한 ASCII만 표시
output += (byte >= 32 && byte < 127)
? String.fromCharCode(byte)
: '.';
}
}
output += '|\n';
}
return output;
}
// 사용 예
const data = new TextEncoder().encode('Hello, Hex World!');
console.log(hexDump(data));
// 출력:
// 00000000: 48 65 6c 6c 6f 2c 20 48 65 78 20 57 6f 72 6c 64 |Hello, Hex World|
// 00000010: 21 |!|
이걸 만들어보고 나서 "아, 그래서 바이너리 에디터가 이렇게 생겼구나" 이해했다.
16진수는 컴퓨터를 위한 게 아니다. 사람을 위한 번역기다. 컴퓨터는 여전히 0과 1만 쓴다. 하지만 우리가 그 긴 0과 1의 나열을 보고 눈이 돌아가지 않도록, 누군가 만들어준 친절한 요약본이다.
#FFFFFF를 보면 "아, 흰색이구나" 바로 안다. 0xDEADBEEF를 보면 "누군가 농담을 심었구나" 알아챈다. 0xFF를 보면 "255구나, 최댓값이구나" 느낀다. 이 모든 게 16진수가 주는 가독성의 선물이다.
나는 이제 16진수를 보면 반갑다. 길고 지저분한 2진수를 4분의 1로 압축해서, 패턴을 보여주고, 의미를 담을 수 있게 해주는 도구. 개발자가 되면서 얻은 새로운 언어 같은 거다. 그리고 이 언어로 나는 메모리를 읽고, 색깔을 조합하고, 파일 헤더를 분석한다. 16진수 없이는 상상도 못 할 일들이다.