2의 보수: 뺄셈을 덧셈으로 바꾸는 정수 표현 방식
2의 보수(2's complement)는 컴퓨터가 음수를 표현하고 뺄셈을 덧셈 회로만으로 처리할 수 있게 해주는 정수 인코딩 방식이다. 부호 비트를 따로 처리하지 않아도 되고, 0이 단 하나만 존재한다는 점에서 1의 보수나 부호-크기(sign-magnitude) 방식보다 하드웨어 구현이 단순하다. 오늘날 거의 모든 CPU의 정수 연산 회로가 이 방식을 기본
1. 개념
2의 보수는 고정된 비트 수(예: 8비트, 32비트) 안에서 음수를 표현하는 방법이다. 어떤 수 N의 2의 보수는 "모든 비트를 반전(1의 보수)한 뒤 1을 더한 값"으로 정의한다.
예를 들어 8비트 체계에서 5를 음수로 바꾸면:
5 = 0000 0101
반전 = 1111 1010 (1의 보수)
+1 = 1111 1011 (2의 보수, -5)1111 1011이 -5를 표현하는 비트 패턴이다. 이 비트 패턴을 다시 부호 없는 정수로 읽으면 251이지만, 2의 보수 체계에서는 최상위 비트(MSB)를 부호 비트로 해석해서 -5로 읽는다.
2. 왜 사용하는가
정수를 음수로 표현하는 방법은 역사적으로 세 가지가 있었다.
- 부호-크기(sign-magnitude): 최상위 비트로 부호만 표시.
+0과-0이 따로 존재해서 비교·연산 로직이 복잡해진다. - 1의 보수(1's complement): 모든 비트를 반전. 이것도
+0과-0이 둘 다 존재한다. - 2의 보수(2's complement): 0이 단 하나만 존재하고, 덧셈 회로 하나로 덧셈과 뺄셈을 모두 처리할 수 있다.
CPU 설계 관점에서 가장 큰 이점은 뺄셈을 위한 별도의 회로가 필요 없다는 것이다. A - B는 A + (-B)로 바꿀 수 있고, -B는 B의 2의 보수이므로 결국 덧셈기(adder) 하나로 덧셈과 뺄셈을 모두 처리한다. 회로가 단순해지고 트랜지스터 수와 전력 소비가 줄어든다.
3. 동작 원리
n비트 2의 보수 체계에서 표현 범위는 다음과 같다.
최솟값: -2^(n-1)
최댓값: 2^(n-1) - 18비트 기준으로는 -128 ~ 127이다. 음수가 하나 더 많은 이유는 최상위 비트가 1이고 나머지가 0인 패턴(1000 0000)이 자기 자신의 2의 보수와 같아서 별도의 양수 짝이 없기 때문이다.
뺄셈이 덧셈으로 바뀌는 원리를 8비트로 확인해보자. 10 - 3을 계산한다고 하면:
10 = 0000 1010
-3(2의보수) = 1111 1101
------------------------
합 = 1 0000 0111 ← 9비트 결과, 최상위 캐리 비트는 버림
결과 = 0000 0111 = 7정확히 7이 나온다. 캐리(carry)가 넘친 최상위 비트는 무시하는 것이 2의 보수 연산의 규칙이다.
부호 확장(sign extension)도 중요한 원리다. 8비트 -5(1111 1011)를 16비트로 확장하려면 부호 비트인 1을 왼쪽에 채운다.
8비트: 1111 1011
16비트: 1111 1111 1111 1011부호 비트로 채우기 때문에 값이 그대로 -5로 유지된다.
4. 코드 예제
파이썬은 내부적으로 임의 정밀도 정수를 사용해서 2의 보수를 직접 보여주지 않는다. 비트 패턴을 눈으로 확인하려면 마스킹 작업이 필요하다.
def to_twos_complement(value: int, bits: int) -> str:
"""정수를 n비트 2의 보수 비트 문자열로 변환"""
if value < 0:
value = (1 << bits) + value # 2^n을 더해서 보수 표현으로 변환
return format(value, f'0{bits}b')
def from_twos_complement(bit_str: str) -> int:
"""2의 보수 비트 문자열을 정수로 해석"""
bits = len(bit_str)
value = int(bit_str, 2)
if bit_str[0] == '1': # 최상위 비트가 1이면 음수
value -= (1 << bits)
return value
# 5 -> -5 변환 확인
print(to_twos_complement(-5, 8)) # 11111011
print(from_twos_complement('11111011')) # -5
# 뺄셈을 덧셈으로 처리하는 예시 (8비트, 오버플로우는 자름)
def add_8bit(a: int, b: int) -> int:
mask = 0xFF
result = (a + b) & mask
if result & 0x80: # 최상위 비트가 1이면 음수로 해석
result -= 0x100
return result
a = to_twos_complement(10, 8)
b = to_twos_complement(-3, 8)
print(add_8bit(int(a, 2), int(b, 2))) # 7C 계열 언어에서는 정수 오버플로우가 실제로 어떻게 wrap-around 되는지 직접 볼 수 있다.
import ctypes
# int8_t 오버플로우 시뮬레이션
x = ctypes.c_int8(127)
x.value += 1
print(x.value) # -128, 오버플로우로 인해 최솟값으로 wrap-around5. 시간 복잡도 또는 성능 특성
2의 보수는 알고리즘이 아니라 표현 방식이므로 시간 복잡도 개념은 적용되지 않는다. 대신 하드웨어 관점의 성능 특성을 짚어야 한다.
- 덧셈과 뺄셈이 동일한 가산기(adder) 회로를 공유하므로 별도의 감산기(subtractor) 회로가 필요 없다. 이는 CPU의 ALU(산술논리장치) 게이트 수를 줄이고 다이(die) 면적과 전력 소비를 낮춘다.
- 비교 연산(
<,>)도 부호 비트만 확인하면 되므로 부호-크기 방식보다 로직이 단순하다. - 모든 연산이 O(1) 하드웨어 사이클 내에서 처리되며, 비트 폭에 관계없이 회로 지연(propagation delay)만 고려하면 된다.
6. 실무 사용 사례
- 정수 오버플로우 버그:
int32_t최댓값(2147483647)에 1을 더하면 2의 보수 규칙에 따라 -2147483648로 wrap-around 된다. 게임 점수 계산이나 타임스탬프 연산에서 실제로 발생하는 버그 유형이다. - 비트 마스크와 시프트 연산:
~x + 1이-x와 동일하다는 성질은 로우레벨 최적화나 비트 트릭에서 자주 쓰인다. - 네트워크 프로토콜과 파일 포맷 파싱: TCP 체크섬 계산이나 바이너리 포맷을 다룰 때 부호 있는 정수와 부호 없는 정수를 명확히 구분해야 하며, 이 과정에서 2의 보수 이해가 필수적이다.
- 임베디드 시스템: 8비트, 16비트 마이크로컨트롤러에서 정수 범위를 정확히 계산해야 오버플로우로 인한 하드웨어 오작동을 막을 수 있다.
-- PostgreSQL에서 smallint(16비트) 오버플로우 확인
SELECT 32767::smallint + 1::smallint;
-- ERROR: smallint out of range
-- PostgreSQL은 오버플로우 시 wrap-around 대신 예외를 던진다 (C와 다른 정책)7. 주의할 점
- 언어마다 오버플로우 정책이 다르다. C/C++의 부호 없는 정수는 표준적으로 wrap-around 하지만, 부호 있는 정수의 오버플로우는 정의되지 않은 동작(undefined behavior)이다. 반면 PostgreSQL의
smallint,integer타입은 오버플로우 시 예외를 던진다. - 파이썬의
int는 임의 정밀도(arbitrary precision)를 지원해서 2의 보수 오버플로우가 발생하지 않는다. 다만ctypes나numpy의 고정 폭 타입(int8,int32등)을 쓸 때는 여전히 wrap-around가 발생한다. - 비트 시프트 연산 시 부호 있는 정수의 오른쪽 시프트(
>>)는 언어에 따라 산술 시프트(부호 비트 유지)와 논리 시프트(0으로 채움)가 다르게 동작할 수 있다. -2^(n-1)의 절댓값을 취하면 다시 자기 자신이 나오는 특이 케이스가 있다. 8비트에서abs(-128)은 128이 되어야 하지만 8비트 범위(-128~127)를 벗어나므로 오버플로우가 발생한다.
8. 핵심 정리
2의 보수는 "반전 후 1을 더한다"는 단순한 규칙으로 음수를 표현하면서, 덧셈 회로 하나로 뺄셈까지 처리할 수 있게 만드는 방식이다. 0이 하나만 존재하고 하드웨어 구현이 단순하다는 점 때문에 부호-크기나 1의 보수 방식을 대체하고 사실상의 표준이 되었다. n비트에서 표현 범위가 -2^(n-1) ~ 2^(n-1)-1로 비대칭이라는 점과, 오버플로우 시 wrap-around가 언어·플랫폼마다 다르게 처리된다는 점을 실무에서 반드시 확인해야 한다.