컴퓨터의 값 표현 - 비트 폭과 정수 범위·부동소수·UTF-8 문자 경계 (CS 기초 1장)
이 장에서 배우는 것
이 책의 첫 장이므로 연결할 직전 장은 없다. 선수지식은 Python 변수와 덧셈, 문자열의 기초다. 코드를 실행하는 명령보다 값이 저장 경계를 지날 때 어떤 계약을 따르는지에 초점을 둔다.
- 비트 폭과 부호에 따라 정수 범위가 달라지는 이유를 설명한다.
- 부동소수의 표현 오차와 십진 계산 요구를 구분한다.
- 바이트 수·코드포인트 수·보이는 글자 수를 구분한다.
- 금액 오차와 이모지 길이 오류를 자료 표현의 층위별로 진단한다.
개념
정산 화면의 합계가 기대와 다르고, 같은 길이로 보이는 이름 중 하나만 저장에 실패한다. 둘 다 업무 규칙의 오류처럼 보이지만 저장 단위를 혼동해도 생기는 증상이다. 계산식 앞에서 값이 어떤 표현으로 바뀌었는지 추적해야 한다. 화면에 보이는 문자열과 메모리의 바이트를 같은 것으로 취급하면 재현 조건을 놓친다.
비트 폭은 값의 경계를 만든다
비트 하나는 두 상태를 구분한다. 비트를 여덟 개 묶으면 256개의 조합이 생기지만, 각 조합에 어떤 수를 붙일지는 표현 규칙에 달려 있다. 부호 없는 8비트 정수는 0부터 255까지이고, 2의 보수 부호 있는 8비트 정수는 -128부터 127까지다. 비트열 11111111은 전자에서 255이고 후자에서 -1이므로 비트만 보고 업무 의미를 결정할 수 없다.
폭을 넘어서는 결과가 생겼을 때의 동작도 언어와 연산 규칙의 일부다. 여기서는 하위 8비트만 남기는 모형으로 순환을 관찰한다. 이것을 모든 언어의 오버플로 동작이라고 일반화하면 안 된다. 특히 Python 정수의 덧셈은 고정된 8비트 상자에서 실행되지 않으며, 메모리가 허용하는 범위에서 자릿수가 커진다.
| 표현 규칙 | 8비트 예시 | 판단할 것 |
|---|---|---|
| 부호 없음 | 11111111 → 255 | 음수를 받는가 |
| 2의 보수 | 11111111 → -1 | 최솟값·최댓값 |
| 하위 비트 절단 | 256 → 00000000 | 누가 절단을 수행하는가 |
소수의 표시는 저장값과 다를 수 있다
십진법의 0.1은 이진 소수로 유한하게 끝나지 않는다. 저장 공간이 유한하면 가까운 값을 택하게 되고, 덧셈에서도 결과를 다시 표현 가능한 값으로 맞춘다. 화면에 소수 한 자리만 보여 주는 일은 표시 규칙을 바꿀 뿐 저장값을 바꾸지 않는다. 따라서 표시된 두 숫자가 같아도 비교 연산에서 다른 결과가 나올 수 있다.
정수 원 단위로 기록하는 금액과 오차 허용 범위가 있는 센서 측정값은 서로 다른 표현이 필요하다. 전자는 최소 화폐 단위를 정한 정수나 십진 계산 규칙을 사용할 수 있고, 후자는 허용 오차를 정한 근사 비교가 알맞을 수 있다. Decimal을 선택해도 반올림 시점과 단위를 정하지 않으면 요구사항은 여전히 모호하다. 자료형 선택보다 먼저 단위와 허용 오차를 정한다.
바이트와 코드포인트와 보이는 글자
문자열을 전송하려면 문자에 대응하는 수를 바이트로 바꾸는 인코딩이 필요하다. UTF-8에서 A는 한 바이트이고 가는 세 바이트다. Python 문자열의 len은 코드포인트 수를 세며, UTF-8로 인코딩한 bytes의 len은 바이트 수를 센다. 화면에서 한 글자로 보이는 결합 문자나 이모지 조합은 여러 코드포인트일 수 있어 이 두 길이만으로 사용자 인식 글자 수를 알 수 없다.
예를 들어 미리 조합된 é와 e 뒤에 결합 악센트를 붙인 문자열은 비슷하게 보이지만 코드포인트 배열이 다르다. 정규화는 정해진 동등성 규칙에 따라 표현을 맞추는 작업이며, 모든 시각적 유사성을 동일하게 만드는 기능은 아니다. 저장 전에 어떤 정규화 형식을 사용할지 정하고 검색과 비교에서도 같은 계약을 유지해야 한다. 바이트 제한은 최종 인코딩 뒤에, 화면 글자 제한은 별도의 글자 경계 규칙에 따라 검사한다.
| 대상 | Python 관찰 | 용도 |
|---|---|---|
| 코드포인트 수 | len(text) | 문자열 내부 길이 |
| UTF-8 바이트 수 | len(text.encode("utf-8")) | 전송·저장 용량 |
| 사용자 인식 글자 | 결합과 글자 경계 고려 | 커서·화면 길이 제한 |
같은 8비트는 해석 규칙에 따라 255 또는 -1이다. UTF-8의 가는 ea b0 80 세 바이트로 이어진다.
직접 확인하기
실습은 Python 3 표준 라이브러리만 사용한다. 각 코드 블록을 별도 파일로 저장해 python3 파일명.py로 실행할 수 있다. 블록 사이에 공유하는 변수나 파일은 없다.
폭과 십진 계산을 분리해서 실행한다
value = 255
wrapped = (value + 1) & 0xff
signed = 0xff - 256
print(value + 1, wrapped, signed)
try:
(256).to_bytes(1, "big", signed=False)
except OverflowError:
print("한 바이트 범위 초과")실행 결과다.
결과
----
256 0 -1
한 바이트 범위 초과첫 값 256은 Python 덧셈 결과이고 두 번째 0은 코드에서 명시한 마스크의 결과다. 마지막 -1은 2의 보수 해석을 산술로 적은 것이다. 한 바이트 직렬화는 범위를 초과하면 예외를 내므로 같은 입력이라도 표현 경계마다 다른 결과가 나온다. 전송 실패를 조사할 때는 연산 성공과 직렬화 성공을 따로 확인한다.
from decimal import Decimal
import math
print(0.1 + 0.2 == 0.3)
print(math.isclose(0.1 + 0.2, 0.3))
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))
print(125 * 3)실행 결과다.
결과
----
False
True
True
375False는 십진 산술 법칙이 틀렸다는 뜻이 아니다. 입력값을 이진 근사값으로 바꾸는 단계가 이미 계산에 들어가 있다는 뜻이다. Decimal에는 문자열로 십진 입력을 전달해 이진 근사값을 먼저 만들지 않았다. 마지막 375는 최소 단위를 정수로 나타낸 예시이며, 세금처럼 나눗셈이 필요한 경우에는 별도 반올림 규칙을 추가해야 한다.
문자 경계를 보존하면서 바이트를 본다
import unicodedata
for text in ["A", "가", "é", "e\u0301", "👩\u200d💻"]:
data = text.encode("utf-8")
print(repr(text), len(text), len(data), data.hex())
print(unicodedata.normalize("NFC", "e\u0301") == "é")실행 결과다.
결과
----
'A' 1 1 41
'가' 1 3 eab080
'é' 1 2 c3a9
'é' 2 3 65cc81
'👩\u200d💻' 3 11 f09f91a9e2808df09f92bb
True가의 바이트는 ea b0 80이다. 첫 바이트와 뒤따르는 바이트를 함께 해석해야 코드포인트 하나가 복원된다. 결합 악센트 문자열은 코드포인트 두 개이고 미리 조합된 문자는 한 개지만 NFC 정규화 뒤에는 같아진다. 이모지 예시는 코드포인트 세 개이며, 표시 모양은 글꼴과 렌더러에 따라 달라질 수 있다.
data = "가A".encode("utf-8")
for size in [1, 2, 3, 4]:
try:
print(size, data[:size].decode("utf-8"))
except UnicodeDecodeError:
print(size, "문자 중간에서 잘림")실행 결과다.
결과
----
1 문자 중간에서 잘림
2 문자 중간에서 잘림
3 가
4 가A바이트 두 개까지 잘라 전송하면 가의 표현이 완성되지 않는다. decode 오류를 무시하면 실패가 없어지는 대신 데이터가 사라질 수 있으므로 복구 정책을 따로 정해야 한다. 파일이나 소켓을 여러 번 읽을 때는 읽기 경계와 문자 경계가 일치한다는 보장도 없다. 실제 스트리밍 처리에서는 미완성 바이트를 다음 입력과 이어서 해석하는 디코더가 필요하다.
환경별 차이
| 환경 | 같은 점 | 확인할 차이 |
|---|---|---|
| Linux·macOS | 명시적 UTF-8 인코딩의 결과 | 터미널 표시와 기본 파일 인코딩 |
| 컨테이너 | 정수·문자열 표현 규칙 | 이미지의 로캘·Python 버전 |
| 다른 언어와 교환 | 전송 형식의 바이트 계약 | 정수 폭·부호·문자열 길이 단위 |
예제는 기본 인코딩을 추측하지 않고 utf-8을 명시한다. 운영체제가 같아도 프로그램 경계에서 다른 인코딩을 선택하면 결과가 달라진다. 정수 전송에서는 바이트 순서까지 합의해야 하므로 예제의 big도 계약의 일부다. 한쪽에서 숫자 256을 두 바이트로 보내고 다른 쪽에서 한 바이트만 읽으면 자료형 이름이 같아도 통신이 깨진다.
이 장의 수치 예제는 특정 CPU 지연이나 메모리 크기에 기대지 않는다. Python 구현별 객체 크기나 내부 문자열 저장 방식은 다룬 바이트 수와 다른 지표다. len(data)는 전송할 내용의 길이이고 객체 자체의 관리 비용까지 합한 메모리 사용량이 아니다. 저장 공간을 견적낼 때는 내용과 관리 비용을 분리한다. 문자 오류를 재현할 때는 눈에 보이는 입력만 복사하는 대신 원래 코드포인트와 인코딩 바이트도 함께 남긴다. 같은 모양의 문자를 다시 타이핑하면 원래의 결합 방식이 바뀌어 문제가 사라질 수 있다.
실무에서 자주 틀리는 것
합계가 틀리면 반올림부터 추가한다
증상은 정산 합계의 작은 차이다. 흔한 오진은 출력 자릿수가 부족하다는 판단이다. 실제 원인은 십진 입력을 이진 근사값으로 만든 뒤 여러 번 계산한 것일 수 있다. 입력 형식과 단위, 반올림 시점을 기록하고 문자열 Decimal 계산 또는 정수 최소 단위 계산과 대조한다. 단순히 최종 표시만 반올림해서는 중간 판정의 차이를 되돌리지 못한다.
Python에서 성공하면 다른 시스템에도 들어간다
증상은 애플리케이션 덧셈은 성공했지만 전송 단계가 실패하는 것이다. 흔한 오진은 통신 장애라는 판단이다. 실제 원인은 대상 필드의 비트 폭이나 부호 조건을 벗어난 수일 수 있다. 경계의 최댓값과 그 다음 값을 직렬화해 보고 수신 형식과 비교한다. 조용히 하위 비트만 남기는 처리와 예외로 거부하는 처리를 같은 것으로 취급하지 않는다.
한 글자는 한 바이트라고 센다
증상은 영문 이름은 저장되고 한글이나 이모지는 거부되는 것이다. 흔한 오진은 글꼴이 지원되지 않는다는 판단이다. 실제 원인은 바이트 제한과 화면 글자 제한이 혼용된 것일 수 있다. 인코딩 전후 길이를 함께 기록하고 결합 문자 입력을 따로 재현한다. 문자열 길이는 반드시 단위와 함께 기록한다.
스스로 확인하기
문제와 해설
- len("가A")가 2이고 UTF-8 바이트 길이가 4다. 3바이트 제한에서 전체 문자열을 허용할 수 있는지 판단하라.
- 8비트 부호 없는 값 255에 1을 더했다. Python 결과와 하위 8비트만 보존한 결과를 각각 설명하라.
- 십진 금액을 Decimal로 옮기면서 float 입력을 그대로 전달해도 되는지 설명하라.
해설 1 바이트 제한이면 허용할 수 없다. 가는 3바이트이고 A는 1바이트라 합계 4다. 화면 글자 수 제한이라면 별도 계약을 확인해야 한다.
해설 2 Python 정수 결과는 256이다. 명시적으로 마스크하면 0이며, 이는 Python 정수의 자동 오버플로가 아니다.
해설 3 이미 만들어진 이진 근사값이 넘어갈 수 있다. 십진 원문 문자열이나 정수 단위를 기준으로 입력하고 반올림 규칙을 따로 정한다.
참고 자료: Python 부동소수 설명 · Unicode UTF 인코딩 FAQ · Python 내장 자료형. 공식 자료는 사실 확인에만 사용했다. 설명·예제·문제·도식은 이 원고를 위해 새로 작성했다.
다음 장은 이 값들을 CPU가 어떤 흐름으로 처리하는지 다룬다. 같은 덧셈 코드라도 클록 수와 처리량이 단순히 비례하지 않는 이유로 이어진다.