2진수·8진수·16진수 변환: 원리와 계산법, 색상 코드·메모리 주소·파일 권한에서 쓰이는 곳

이 글의 핵심

CSS 색상 코드의 FF나 chmod 755가 무엇을 뜻하는지 모르면 설정 하나에도 막히게 됩니다. 이 글은 진법의 공통 원리인 자릿값에서 출발해 나눗셈을 이용한 변환 방법, 16진수 한 자리가 2진수 네 자리와 대응하는 이유를 설명하고, C/C++에서 010이 8로 해석되는 것 같은 함정도 짚습니다.

들어가며

진법(Number System)은 숫자를 표현하는 방법입니다. 프로그래밍에서는 2진수, 8진수, 10진수, 16진수를 주로 사용합니다. 비유로 말씀드리면, 진법은 숫자를 세는 방법입니다. 10진수는 손가락 10개로 세는 것이며, 2진수는 전구 켜짐/꺼짐으로 세는 것이며, 16진수는 16가지 기호로 세는 것입니다.

중요한 점은 진법이 값이 아니라 표기법이라는 것입니다. 메모리에 들어 있는 것은 언제나 비트의 나열이고, 25, 0b11001, 0x19, 031은 모두 같은 값을 다르게 적은 것뿐입니다. 컴퓨터가 2진수를 쓰는 이유는 회로가 전압이 높음/낮음 두 상태를 가장 안정적으로 구분하기 때문이고, 사람이 16진수를 쓰는 이유는 2진수가 너무 길어 읽기 어려운데 10진수로 바꾸면 비트 경계가 보이지 않기 때문입니다. 즉 16진수는 “사람이 비트를 읽기 위한 압축 표기”이고, 8진수는 비트를 3개씩 묶는 같은 아이디어의 옛 형태입니다. 이 관점을 잡아 두면 “어떤 진법으로 저장되나요?” 같은 질문이 성립하지 않는다는 것도 자연스럽게 이해됩니다.


진법 기초

4가지 주요 진법

진법기수사용 숫자접두사예시
2진수20, 10b0b1101
8진수80-70015
10진수100-9없음13
16진수160-9, A-F0x0xD

표의 8진수 접두사 0은 C/C++ 기준이라는 점에 주의하세요. 언어마다 다릅니다. Python 3은 0o15처럼 0o를 써야 하고, 015라고 쓰면 “SyntaxError: leading zeros in decimal integer literals are not permitted”가 납니다. JavaScript도 strict 모드와 모듈에서는 015 같은 옛 8진수 리터럴을 금지하고 0o15를 씁니다. 0b 2진수 리터럴은 C++14, C23부터 표준이고, 그 이전의 C 컴파일러에서는 GCC 확장으로만 지원되었습니다.

자릿값 계산

10진수 (익숙한 방법):

1234 = 1×10³ + 2×10² + 3×10¹ + 4×10⁰
     = 1×1000 + 2×100 + 3×10 + 4×1
     = 1234

2진수 (같은 원리):

1101₂ = 1×2³ + 1×2² + 0×2¹ + 1×2⁰
      = 1×8 + 1×4 + 0×2 + 1×1
      = 13₁₀

모든 진법이 이 한 가지 규칙으로 설명됩니다. 오른쪽 끝 자리부터 기수의 0제곱, 1제곱, 2제곱… 의 무게를 가집니다. 10진수에서 왼쪽으로 한 칸 가면 10배가 되듯, 2진수에서는 2배, 16진수에서는 16배가 됩니다. 그래서 2진수 숫자 끝에 0을 하나 붙이면 값이 2배가 되고(101 = 5 → 1010 = 10), 이것이 비트를 왼쪽으로 한 칸 미는 시프트 연산 x << 1이 곱하기 2와 같은 이유입니다. 기수가 b인 진법에서 쓸 수 있는 숫자가 0부터 b-1까지인 것도 같은 이유로, b가 되는 순간 다음 자리로 올림이 일어나기 때문입니다.


2진수

2진수 표현

10진수 → 2진수
0  = 0000
1  = 0001
2  = 0010
3  = 0011
4  = 0100
5  = 0101
6  = 0110
7  = 0111
8  = 1000
9  = 1001
10 = 1010

2진수 → 10진수

1011₂ = ?
자릿수:  2³  2²  2¹  2⁰
값:      8   4   2   1
비트:    1   0   1   1
계산: 1×8 + 0×4 + 1×2 + 1×1 = 11₁₀

10진수 → 2진수

25를 2진수로:
25 ÷ 2 = 12 ... 1  ↑
12 ÷ 2 = 6  ... 0  |
 6 ÷ 2 = 3  ... 0  |
 3 ÷ 2 = 1  ... 1  |
 1 ÷ 2 = 0  ... 1  |
아래에서 위로: 11001₂

나머지를 아래에서 위로 읽는 이유는, 처음 나온 나머지가 가장 낮은 자리(2⁰)이기 때문입니다. 25를 2로 나눈 나머지 1은 “25가 홀수”라는 뜻, 즉 1의 자리 비트입니다. 몫 12를 다시 2로 나누는 것은 오른쪽으로 한 칸 시프트한 뒤 다음 자리 비트를 보는 것과 같습니다. 거꾸로 읽는 것을 잊고 위에서 아래로 적으면 10011(19)이 되는데, 손으로 변환할 때 가장 흔한 실수입니다. 검산은 간단합니다. 결과를 다시 자릿값으로 계산해 원래 수가 나오는지 확인하면 됩니다(16 + 8 + 1 = 25).

작은 수라면 나눗셈 대신 큰 2의 거듭제곱부터 빼는 방법이 더 빠릅니다. 25에서 16을 빼면 9, 8을 빼면 1, 4와 2는 건너뛰고 1을 빼면 0이므로 16·8·1 자리가 1인 11001입니다. 2의 거듭제곱(1, 2, 4, 8, …, 1024)을 외워 두면 대부분의 변환을 암산으로 할 수 있습니다.

프로그래밍 예시

// C++
#include <bitset>
#include <iostream>
int x = 0b11001;  // 25
std::cout << std::bitset<8>(25);  // "00011001"
# Python
x = 0b11001  # 25
print(bin(25))  # "0b11001"
print(f"{25:08b}")  # "00011001"

bin()은 앞의 0을 붙이지 않으므로 자릿수를 맞추려면 :08b 같은 형식 지정자를 씁니다. 음수는 주의가 필요합니다. Python의 bin(-5)는 '-0b101'처럼 부호를 붙여 보여 줄 뿐 실제 비트 패턴이 아니고, C++의 std::bitset<8>(-5)는 2의 보수 표현인 11111011을 보여 줍니다. Python에서 8비트 2의 보수를 보고 싶다면 f"{-5 & 0xFF:08b}"처럼 마스크를 씌워야 합니다. 음수 표현과 2의 보수는 비트 연산 글에서 더 다룹니다.


8진수

8진수 표현

0-7까지 8개 숫자 사용:

10진수 → 8진수
0  = 0
7  = 7
8  = 10
9  = 11
10 = 12
15 = 17
16 = 20
64 = 100

8진수 → 10진수

157₈ = ?
자릿수:  8²  8¹  8⁰
값:      64  8   1
숫자:    1   5   7
계산: 1×64 + 5×8 + 7×1 = 111₁₀

10진수 → 8진수

83을 8진수로:
83 ÷ 8 = 10 ... 3  ↑
10 ÷ 8 = 1  ... 2  |
 1 ÷ 8 = 0  ... 1  |
아래에서 위로: 123₈

실전 활용: Unix 파일 권한

# chmod 755 file.txt
# 755₈ = rwxr-xr-x
7 = 111₂ = rwx (소유자: 읽기+쓰기+실행)
5 = 101₂ = r-x (그룹: 읽기+실행)
5 = 101₂ = r-x (기타: 읽기+실행)
// C++
int mode = 0755;  // 8진수 (앞에 0)
// mode = 493 (10진수)

파일 권한이 8진수와 궁합이 좋은 이유는 권한 비트가 정확히 3개씩 묶여 있기 때문입니다. 읽기(4), 쓰기(2), 실행(1) 세 비트가 소유자·그룹·기타 사용자마다 한 묶음이고, 8진수 한 자리가 정확히 3비트를 표현하므로 755의 각 자리가 각 사용자 그룹의 권한과 1:1로 대응합니다. 16진수로 쓰면 0x1ED가 되어 비트 경계가 어긋나 읽을 수 없습니다. chmod 644는 소유자 읽기+쓰기(4+2), 나머지는 읽기만(4)이라는 뜻이고, 실행 파일이나 디렉터리처럼 실행(탐색) 권한이 필요한 경우 755를 씁니다.

코드에서 권한을 다룰 때 가장 흔한 실수가 앞의 0을 빠뜨리는 것입니다. C에서 chmod(path, 755)라고 쓰면 10진수 755, 즉 8진수 1363이 전달되어 전혀 다른 권한(sticky 비트 포함)이 설정됩니다. Python에서도 os.chmod(path, 755) 대신 os.chmod(path, 0o755)로 써야 합니다. 에러 없이 이상한 권한이 설정되므로, 파일이 갑자기 읽히지 않는다는 증상으로 뒤늦게 발견되곤 합니다.


16진수

16진수 표현

0-9, A-F까지 16개 기호 사용:

10진수 → 16진수
0  = 0
9  = 9
10 = A
11 = B
12 = C
13 = D
14 = E
15 = F
16 = 10
255 = FF

16진수 → 10진수

1A3₁₆ = ?
자릿수:  16²  16¹  16⁰
값:      256  16   1
숫자:    1    A    3
        (1)  (10)  (3)
계산: 1×256 + 10×16 + 3×1 = 419₁₀

10진수 → 16진수

255를 16진수로:
255 ÷ 16 = 15 ... 15  ↑
 15 ÷ 16 = 0  ... 15  |
15 = F
아래에서 위로: FF₁₆

16진수와 2진수 관계

16진수 1자리 = 2진수 4자리:

16진수 → 2진수
0x1 = 0001
0x2 = 0010
0x3 = 0011
0x4 = 0100
0x5 = 0101
0x6 = 0110
0x7 = 0111
0x8 = 1000
0x9 = 1001
0xA = 1010
0xB = 1011
0xC = 1100
0xD = 1101
0xE = 1110
0xF = 1111

변환 예시:

0x3A7 → 2진수
3 = 0011
A = 1010
7 = 0111
0x3A7 = 001110100111₂

16진수 한 자리가 정확히 2진수 네 자리인 이유는 16 = 2⁴이기 때문입니다. 4비트로 만들 수 있는 조합이 0000부터 1111까지 정확히 16가지이니, 16진수 기호 하나가 4비트 조합 하나에 빠짐없이 대응합니다. 이 덕분에 16진수와 2진수 사이의 변환은 계산 없이 표를 보고 치환만 하면 됩니다. 반대로 2진수를 16진수로 바꿀 때는 오른쪽부터 4자리씩 끊어야 합니다. 왼쪽부터 끊으면 자리가 모자란 마지막 묶음이 잘못 해석됩니다(110100111은 1 1010 0111로 끊어 0x1A7).

1바이트가 8비트이므로 16진수 두 자리가 정확히 1바이트라는 점도 실무에서 매우 유용합니다. 헥스 에디터나 네트워크 패킷 덤프가 바이트마다 89 50 4E 47처럼 두 자리씩 끊어 보여 주는 이유입니다. 10진수로는 한 바이트가 1자리에서 3자리까지 들쭉날쭉해 이런 정렬된 표시가 불가능합니다.


실전 활용

RGB 색상 코드

색상: #FF5733
FF = 255 (빨강)
57 = 87  (초록)
33 = 51  (파랑)
RGB(255, 87, 51)

변환:

int color = 0xFF5733;
int red   = (color >> 16) & 0xFF;  // 255
int green = (color >> 8) & 0xFF;   // 87
int blue  = color & 0xFF;          // 51

색상 코드의 각 채널이 16진수 두 자리인 것은 채널 하나가 1바이트(0~255)이기 때문입니다. >> 16은 빨강 바이트를 맨 아래로 내리고, & 0xFF는 아래 8비트만 남기는 마스크입니다. 반투명 색상이라면 #FF573380처럼 알파 채널 두 자리가 붙는데, CSS는 #RRGGBBAA 순서를 쓰지만 Android의 색상 정수와 일부 그래픽 API는 0xAARRGGBB 순서를 써서 같은 8자리 값이 전혀 다른 색이 됩니다. 색이 이상하게 나오면 채널 순서부터 확인해 보세요.

메모리 주소

메모리 주소: 0x7FFF5FBFF8A0
이유:
- 16진수는 2진수를 간결하게 표현
- 64비트 주소 = 16자리 16진수
- 2진수로 쓰면 64자리 (읽기 어려움)

예시 주소가 16자리가 아니라 12자리인 것은 오타가 아닙니다. 64비트 포인터라도 현재 x86-64 CPU는 가상 주소의 하위 48비트만 실제로 사용하고, 사용자 공간 주소는 0x00007FFF... 이하라서 앞자리 0을 생략하면 12자리 정도로 보입니다. 디버거에서 주소를 볼 때 16진수에 익숙하면 유용한 단서를 얻을 수 있습니다. 두 주소의 차이로 객체 크기나 배열 간격을 바로 계산할 수 있고, 0xCDCDCDCD(MSVC 디버그 힙의 초기화되지 않은 메모리)나 0xDDDDDDDD(해제된 메모리), 0xDEADBEEF 같은 특수 패턴은 초기화 안 된 포인터나 해제 후 사용을 알려 주는 신호입니다.

IP 주소

IP: 192.168.1.1
16진수: C0.A8.01.01
2진수: 11000000.10101000.00000001.00000001
변환:
192 = 0xC0 = 11000000
168 = 0xA8 = 10101000
  1 = 0x01 = 00000001

파일 시그니처 (Magic Number)

파일 형식 확인:
PNG: 89 50 4E 47 (16진수, 전체 시그니처는 89 50 4E 47 0D 0A 1A 0A 8바이트)
JPEG: FF D8 FF (네 번째 바이트는 E0, E1 등 형식에 따라 다름)
GIF: 47 49 46 38
ZIP: 50 4B 03 04

시그니처를 16진수로 보면 재미있는 점이 보입니다. PNG의 50 4E 47은 ASCII로 “PNG”, GIF의 47 49 46 38은 “GIF8”, ZIP의 50 4B는 형식을 만든 Phil Katz의 이니셜 “PK”입니다. 파일 확장자는 사용자가 마음대로 바꿀 수 있지만 첫 몇 바이트는 형식 자체에 들어 있으므로, 업로드된 파일의 실제 형식을 검증할 때 확장자보다 시그니처를 확인하는 것이 더 믿을 만합니다. 참고로 docx, xlsx, jar, apk도 내부적으로 ZIP이라 모두 50 4B로 시작합니다.

// C++로 파일 형식 확인
bool isPNG(const char* filename) {
    FILE* f = fopen(filename, "rb");
    unsigned char header[4];
    fread(header, 1, 4, f);
    fclose(f);
    
    return header[0] == 0x89 &&
           header[1] == 0x50 &&
           header[2] == 0x4E &&
           header[3] == 0x47;
}

이 예제는 설명을 위해 단순화한 코드라 실제로 쓰려면 두 가지를 고쳐야 합니다. fopen이 실패하면 nullptr이 반환되는데 그대로 fread에 넘기면 크래시하므로 먼저 검사해야 하고, 파일이 4바이트보다 짧으면 fread가 4보다 작은 값을 반환하는데 이를 확인하지 않아 초기화되지 않은 header를 비교하게 됩니다. 버퍼를 unsigned char로 선언한 것은 올바른 선택입니다. char로 선언하면 char가 부호 있는 플랫폼에서 0x89가 음수 -119로 읽혀 header[0] == 0x89가 항상 거짓이 됩니다. 16진수 리터럴과 바이트를 비교할 때 가장 자주 만나는 함정입니다.


트러블슈팅

16진수 색상 코드 오류

문제:

/* CSS */
color: #FFF;  /* 흰색? */

원인:

  • #FFF는 #FFFFFF의 축약형
  • F = FF (각 채널) 해결:
#FFF = #FFFFFF = RGB(255, 255, 255) = 흰색
#F00 = #FF0000 = RGB(255, 0, 0) = 빨강
#0F0 = #00FF00 = RGB(0, 255, 0) = 초록
#00F = #0000FF = RGB(0, 0, 255) = 파랑

8진수 실수

문제:

int x = 010;  // 10이 아님!
cout << x;    // 8 출력

원인:

  • 앞에 0이 붙으면 8진수
  • 010₈ = 8₁₀ 해결:
int x = 10;   // 10진수
int y = 0x10; // 16진수 (16)
int z = 0b10; // 2진수 (2)

이 함정은 숫자를 정렬해 보이려고 앞에 0을 채울 때 주로 생깁니다. 월 테이블을 {01, 02, ..., 07, 08, 09}처럼 적으면 08과 09에서 “invalid digit ‘8’ in octal constant” 컴파일 에러가 나서 그나마 발견되지만, 010, 011처럼 8진수로 유효한 숫자는 에러 없이 8, 9로 들어갑니다. 우편번호나 전화번호처럼 앞자리 0이 의미 있는 값은 애초에 숫자가 아니라 문자열로 다뤄야 합니다. 문자열을 정수로 바꿀 때도 같은 함정이 있어, C의 strtol("010", nullptr, 0)처럼 진법 인자를 0으로 주면 접두사를 보고 8진수로 해석합니다. 10진수만 받으려면 진법을 10으로 명시하세요.

16진수 대소문자

문제:

0xFF와 0xff는 같나요?

답:

  • 같습니다!
  • 16진수는 대소문자 구분 안함
  • 0xFF = 0xff = 255 관례:
  • 언어가 강제하지는 않고, 팀이나 프로젝트 스타일을 따릅니다. 접두사는 소문자 0x에 숫자는 대문자(0xFF)가 흔하고, Python의 hex()와 Chrome 개발자 도구의 색상 표시는 소문자를 씁니다.
  • 대소문자가 섞이면 문자열 비교에서 문제가 됩니다. 해시값이나 색상 코드를 문자열로 비교할 때 "FF5733" == "ff5733"은 거짓이므로, 비교 전에 한쪽으로 정규화해야 합니다.

마무리

진법 변환은 프로그래머의 필수 기초 지식입니다. 핵심 요약:

진법기수접두사용도
2진수20b비트 연산, 플래그
8진수80Unix 권한
10진수10없음일반 계산
16진수160x메모리, 색상

변환 공식:

  • n진수 → 10진수: 자릿값 × 기수^위치
  • 10진수 → n진수: 기수로 나눈 나머지
  • 16진수 ↔ 2진수: 1자리 = 4비트 실전 활용:
  • 색상 코드 (#FF5733)
  • 메모리 주소 (0x7FFF...)
  • 파일 권한 (0755)
  • 네트워크 프로토콜 다음 단계:
  • 비트 연산 가이드
  • 데이터 단위 가이드
  • 메모리 구조 진법 변환에 익숙해지면 메모리 덤프, 패킷, 파일 권한, 비트 플래그를 읽는 일이 훨씬 쉬워집니다.

자주 묻는 질문 (FAQ)

Q. C/C++에서 int x = 010;이 10이 아니라 8이 되는 이유는 무엇인가요?

A. C 계열 언어에서 0으로 시작하는 정수 리터럴은 8진수로 해석되므로 010은 8진수 10, 즉 10진수 8입니다. 자릿수를 맞추려고 앞에 0을 붙이면 의도와 다른 값이 들어가고, 08처럼 8진수에 없는 숫자를 쓰면 컴파일 에러가 납니다. 8진수를 의도한 경우가 아니라면 앞자리 0을 붙이지 말고, 파일 권한처럼 8진수가 필요한 곳에서만 0755처럼 명시적으로 사용합니다.


같이 보면 좋은 글