컴퓨터가 음수를 나타내는 기가막힌 방법
0과 1로만 이루어진 컴퓨터가 어떻게 음수를 표현할 수 있게 됐는지에 대해 이야기하고 이와 함께 소개되는 1의 보수, 2의 보수의 개념에 대해서 다룬다.
이 글은 아주 기초적인 프로그래밍 언어 지식과 이진법에 대한 간단한 지식을 선수로 요구합니다.
정적 타입 언어(C, Java, C# 등)를 공부해본 사람들은 모든 원시 자료형(primitive data type)에는 signed와 unsigned가 있다는 것을 알것이다. signed char, unsigned int와 같은 녀석들 말이다. signed는 음수와 양수 모두를 나타내고 unsigned는 0을 포함한 양수를 나타낸다.
signed와 unsigned가 대체 뭐고 모든 것이 0과 1로 이루어진 컴퓨터 내부에선 음수를 도대체 어떻게 나타낸다는 말일까? 그리고 컴퓨터 과학 기초를 공부하다보면 접하게 되는 ‘2의 보수’라는 말은 또 뭘까?
Signed와 Unsigned의 정의
네이버 영어 사전에 의하면 sign의 뜻은 여러가지가 있지만 그 중 하나가 바로 수학에서 사용하는 ‘부호’ 또는 ‘기호’라는 뜻을 가진다. 수 앞에 붙는 +, – 부호를 영어로는 sign이라고 부르는 것이다. 그러므로 ‘Signed’는 ‘부호가 붙은’이라는 형용사가 되는 것이고 ‘Unsigned’는 그 반대인 ‘부호가 붙지 않은’이라는 뜻을 지닌다.
그래서 C언어 같이 정적 타입 언어의 경우 signed int를 선언하면 음수와 양수를 모두 표현할 수 있는 4바이트 짜리 데이터가 생성된다. 여담이지만 모든 데이터 형은 기본이 signed이기 때문에 굳이 명시하지 않고 int만 적어도 된다. unsigned int를 선언하면 똑같이 4바이트를 차지하는 변수가 선언되지만 음수를 나타내지 못하고 0부터 2^32 – 1만큼만 표현이 가능하다.
데이터 타입에 따른 표현 가능 범위
Type | Bits (bytes) | Sign | Range |
---|---|---|---|
char | 8 (1) | signed | -128 ~ 127 |
unsigned char | 8 (1) | unsigned | 0 ~ 255 |
short | 16 (2) | signed | -32,768 ~ 32,767 |
unsigned short | 16 (2) | unsigned | 0 ~ 65,535 |
int | 32 (4) | signed | -2,147,483,648 ~ 2,147,483,647 |
unsigned int | 32 (4) | unsigned | 0 ~ 4,294,967,295 |
이 표를 보면 각 데이터 타입들은 signed이냐 unsigned이냐에 따라서 나타낼 수 있는 수의 범위가 완전히 달라진다. 가장 작은 데이터 타입은 char을 예로 들어보자.
signed char은 -128부터 127까지 표현이 가능하다. 표현 가능한 수가 0을 포함해 256개라는 걸 알 수 있다. 2의 8승과 동일한 숫자이다.
unsigned char도 0을 포함해 총 256개의 숫자 표현이 가능하지만 음수를 표현하지 못하기 때문에 표현 못한 만큼 양수의 표현 범위가 늘어난 걸 볼 수 있다.
왜 이렇게 되는 것일까?
왜 음수를 표현할 때면 해당 데이터 타입이 가질 수 있는 최댓값이 반토막 나는걸까?
이진법을 사용하는 컴퓨터
이 글에서 모든 걸 다룰 수는 없어서 간단히만 집고 넘어가자면, 컴퓨터는 우리가 실생활에서 사용하는 십진법(0 ~ 9로 수를 표현하는 방식)을 사용하지 않고 이진법(0과 1만을 이용해 수를 표현하는 방식)을 사용한다.
컴퓨터가 이진법을 사용하는 이유에는 여러가지가 있지만 수없이 많은 전기 회로로 이루어진 컴퓨터의 하드웨어 입장에서 이진수는 피할 수없는 필수 조건이다. 컴퓨터는 많은 것을 할 수 있지만 기본 원리는 계산기다. 더 복잡한 진법을 사용한다면 회로는 더 복잡해질 수 밖에 없고 그럴수록 구현하기는 더 힘들어지며 유지보수하기는 더욱 더 힘들어진다.
이진법을 사용하기 때문에 컴퓨터 용량을 자세히 보면 10으로 딱 맞아 떨이지지 않고 항상 2의 제곱수를 가진다. USB, SD카드, SSD, RAM 등을 구입할 때 용량으로 16GB, 32GB, 256GB 인 것을 보면 알 수 있다.
하나의 전기 회로(트렌지스터)가 총 2가지의 상태를 표현할 수 있기 때문이다. 켜져있거나(1) 꺼져있거나(0). 컴퓨터는 전기가 들어와있으면 1이라고 치고 안들어와있다면 0이라고 친다.
이진법이란?
전에 잠깐 언급했듯이 이진법은 0과 1로만 수를 표현하는 체계이다.
예를 들어 2진법에서의 0101은 10진법의 5와 같고, 1001은 9와 같다
우리가 흔히 쓰는 10진법에선 숫자의 한 자리당 쓸 수 있는 숫자는 총 10개(0 ~ 9)이다. 마지막 숫자인 9에서 1을 더하게되면 더이상 표현할 수가 없기 때문에 십의 자리를 하나 더 추가해 표현한다. 그래서 일의 자리는 0이 되고 새로 추가된 십의 자리는 1이 돼 10이라는 결과를 갖는다.
2진법도 사용 가능한 수가 0 ~ 9가 아닌 0과 1만 있는것을 제외하면 10진법과 다르지 않다.
컴퓨터가 음수를 나타내는 방식
정말 말 그대로 모든 것이 0과 1인 컴퓨터가 ‘+’, ‘-‘와 같은 부호를 나타내는 방법은 가장 앞에 있는 비트를 부호 비트(sign bit)로 가정하는 것이다. 가장 앞에 있는 비트는 어떠한 수의 가장 큰 수를 담당하기 때문에 최상위 비트 혹은 MSB(the Most Significant Bit)라고 부른다.
하지만 이 부호 비트를 사용하는 것 외에도 기술적, 효율성 문제 때문에 1의 보수, 2의 보수 등 더 추가적인 방법들을 함께 사용하게 됐고 오늘 날엔 부호 비트와 2의 보수를 함께 사용한다.
부호화 절대치 (Sign-Magnitude)
부호화 절대치는 이진법에서 음수를 표현하는 가장 단순하고 직관적인 방법이다. 전에 언급했듯 MSB가 0이면 양수 1이면 음수로 처리하는 것이다. 예를 들어 5는 0101이고 -5는 1101로 표현하는 식이다.
이 방식에는 여러가지 비효율적인 문제들이 있어서 1950년대쯤 초기 컴퓨터에서만 쓰였고 지금은 볼 수가 없는 방식이다. 문제점들을 간략하게 살펴보자면 다음과 같다.
- 맨 앞의 부호 비트 때문에 양수와 음수 간의 연산을 진행할 때에 하드웨어 구조 구현에 에로사항이 생긴다.
- 0을 나타내는 방식이 2개가 생긴다. +0(0000)과 -0(1000). 겨우 숫자 하나만 중복된다고 안일하게 볼 수 있지만 조금이라도 모호한 부분을 허용하면 문제가 생기는게 기계이기 때문이다.
1의 보수 (One’s Complement)
이진법에서 1의 보수는 어떠한 수보다 더 큰 자리 수를 가진 수 – 1에서 뺀 수이다. 말로 설명하면 도대체 무슨 소리인가 싶지만 예시를 보면 쉽게 알 수 있다.
예를 들어 ‘0110(6)의 1의 보수를 구하라’라고 한다면 계산 과정은 이러하다.
- 0110 보다 더 큰 자리수를 가진 수는 10000(16)이다.
- 10000(16)에서 1을 빼면 1111(15)가 나온다.
- 1111(15)에서 0110(6)을 빼면 1001(9)이 값으로 나오는데 이게 0110(6)의 1의 보수 값이다.
여기서 엄청난 규칙을 발견할 수 있다. 어떤 수의 1의 보수는 어떤 수의 모든 비트를 반전(invert)시킨 값과 똑같다. 0110의 보수는 1001이고 1100의 보수는 0011이다.
위 예시에서 1001(9)가 0110(6)의 1의 보수라고 했는데 사실 1001(9)는 컴퓨터에서 자연수 ‘9’가 아니라 최상위 비트가 음수인 -1이다. ‘보수로 음수를 표현하는 방식’이 오늘날까지도 쓰이고있는 가장 대중적이고 보편적인 방식이다.
왜 음수 표현에 직관적인 부호화 절대치 방식을 두고 굳이 복잡한 보수를 쓰는 이유는 무엇일까?
컴퓨터는 효율을 위해 뺄셈도 덧셈으로 연산한다.
전기 회로 수준으로 봤을 때 가산기(Adder)와 감산기(Subtractor)를 모두 다 가지는 것보다 가산기 하나만 가지는 것이 더 효율적인건 당연하다 (그리고 감산기가 가산기보다 더 복잡한 구조를 띤다). 보수를 이용하면 가산기 하나로 덧셈, 뺄셈 모두 만족할 수 있다.
어떻게 뺄셈을 덧셈으로 성취할 수 있을까?
숫자에 대해서 연구하는 수학의 한 분야, 정수론에서는 컴퓨터가 발달하기 전부터 보수를 이용하면 더하기를 이용한 뺄셈이 가능하다는 것이 밝혀졌다. 컴퓨터 과학자들이 이것을 이진법에 적용하여 1의 보수를 고안해낸 것이다.
뺄셈을 A – B 형태라고 생각하고 B의 보수를 Bc라고 가정하자. 그럼 이제 간단하게 A + Bc를 하게되면 A – B와 같은 결과가 나온다. 하지만 아무 숫자를 가지고 직접 시도해보면 원하는 결과가 나오지 않을텐데 그 이유는 마지막에 특별한 규칙을 시행해줘야 하기 때문이다. 이 규칙을 순환 자리 올림(end-around carry)라고 칭한다.
예시) (십진법) 보수를 이용한 덧셈으로 뺄셈하기 예시를 432 – 234로 살펴보자. 연산 과정은 아래와 같다.
- 234의 보수 구하기.
- 보수는 어떠한 수보다 더 큰 자리수를 가진 수 – 1에서 뺀 수라고 했었다. 234보다 더 큰 자리수를 가진 1000에서 1을 뺀 999에서 234를 빼면 765가 보수로 나온다
- 432 + 765를 수행한다.
- 덧셈의 결과로 1197이 나온다.
- (순환 자리 올림) 가장 큰 자리에 있는 수를 가장 작은 자리에 더한다.
이것과 똑같은 방식을 이진법에 적용해 컴퓨터는 덧셈만으로 뺄셈을 시행할 수 있다.
1의 보수 내용 정리
다시 말해, 1의 보수를 이용하면서 자연스럽게 최상위 비트를 부호 비트로 사용함으로 음수를 나타냄과 동시에 더하기만 할 줄 아는 컴퓨터 하드웨어의 한계적 상황에서 덧셈으로도 뺄셈을 시행할 수 있게 해주는 덕에 컴퓨터는 이해하기 편한 부호화 절대치를 사용하지 않고 복잡한 보수 방식을 고수하는 것이다.
2의 보수 (Two’s Complement)
바로 이전에 1의 보수에 대해 어느정도 자세히 다루었는데, ‘이만하면 됐지 2의 보수는 또 뭐란 말인가’ 생각이 들 수 있다. 하지만 1의 보수도 여전히 몇 가지의 문제점을 가지고 있다.
부호화 절대치가 가졌던 문제점인 양수 0과 음수 0이 공존하는 문제를 여전히 해결하지 못하고있고 가장 큰 문제는 (위 예시처럼) 자리 올림 수가 발생했을 때 순환 자리 올림을 수행해야 한다는 점이다. 추가적으로 1의 보수로 음수를 나타내는 경우에 발생하는 표현 가능 범위 비대칭 문제도 존재한다.
특히나 순환 자리 올림은 컴퓨터 입장에서 상당히 부담스러운 작업이기 때문에 이 작업을 최대한 줄여주는게 컴퓨터 연산 속도를 좌우한다.
1의 보수가 해결하지 못했던 두가지 단점을 2의 보수는 어떻게 보완하고있을까?
답을 먼저 밝히자면, 음수를 표현할 때에 1의 보수 값에 1을 더하면 그것이 바로 2의 보수이다. 단순히 1을 더하는 것으로 1의 보수가 가지고있는 단점들을 마법처럼 해결한다.
- 0을 표현하는 방법이 2가지인 문제
비트가 00000000 이면 양수 0, 비트가 11111111 이면 음수 0이다. 양수 0을 1의 보수로 만들면 모든 비트가 다 1로 뒤집히기 때문이다.- 2의 보수는 1의 보수에 1을 더하는 것이라고 설명했다. 11111111이 0의 1의 보수이고 2의 보수는 1의 보수에 1을 더하니 11111111 + 1 = 00000000이 돼 자연스럽게 2의 보수로 0을 나타내는 방식이 1개가 된다. 이제 더 이상 양수 0, 음수 0이 아니라 단일된 0이 되는 것이다.
- 부담스러운 순환 자리 올림 문제
사람 입장에선 아무렇지 않지만 컴퓨터에겐 상당히 속도에 영향이 가는 연산이기 때문에 이를 해결할 필요가 있다. 단지 1의 보수에 1을 더하는 것 만으로 이 문제가 해결되는 원리는 순환 자리 올림 시에 결과값에 1을 더해주는 과정을 이미 ‘음수를 표현할 때’ 하고 있기 때문이다.- 5 + (-3)를 풀어본다면 이렇다.
- 1의 보수를 이용한 풀이
- 5는 00000101이고 -3은 11111100이다.이 둘을 더해보면 1 00000001이 되고 여기서 자리 올림이 발생해서 맨 앞의 1을 맨 뒤에 더해주어 00000010이 정답이 된다.
- 2의 보수를 이용한 풀이
- 5는 00000101이고 -3은 11111101이다.이미 음수 3을 표현할 때 1을 더해놓은 체로 음수를 표현하기 때문에 특별한 처리 없이 00000010으로 정확한 정답이 나온다.
- 1의 보수를 이용한 풀이
- 5 + (-3)를 풀어본다면 이렇다.
- 1의 보수, 2의 보수를 쓸 때의 표현 가능한 수 범위가 다른 문제
이것은 2가지의 0 표현 문제를 해결하면서 부차적으로 해결된 문제이다. 1의 보수에 1을 더해주면서 0 표현 방식 2가지 중 1가지가 사라졌기 때문에 그만큼 표현가능한 수가 하나 늘어난 덕에 해결됐다.- 1의 보수 사용 시 (8비트 기준)
- 양수로는 0부터 127까지 표현 가능 (총 128가지 수 표현)
- 음수로는 -127부터 -1까지 표현 가능 (총 127가지 수 표현)
- 2의 보수 사용 시 (8비트 기준)
- 양수로는 0부터 127까지 표현 가능 (총 128가지 수 표현)
- 음수로는 -128부터 -1까지 표현 가능 (총 128가지 수 표현)
- 1의 보수 사용 시 (8비트 기준)
2의 보수 때문에 생기는 또 다른 문제
지금까지 컴퓨터가 음수를 표현하는 여러가지 방식을 소개했고 각 방법의 단점들을 2의 보수가 어떻게 해결하고 있는지도 알아보았다. 하지만 2의 보수라고 해서 완벽한 음수 표현 방식은 아니다. 지금으로썬 최선의 방식일 뿐이다.
지금까지의 대부분 문제는 컴퓨터 하드웨어의 문제였는데 지금부터 이야기할 문제는 개발자들에게 영향을 끼치는 소프트웨어적 문제라고 볼 수 있다.
위 이미지를 보자. 4비트 시스템을 예로 들고있고 unsigned 변수 하나와 signed 변수 하나가 있다. 둘 다 같은 비트 패턴을 가지고 있지만 값이 다르다. 당연하다. unsigned는 최상위 비트가 1이라도 음수로 표현하지 않고 양수로 표현하기 때문에 원래 2진수 그대로 읽히기 때문이다. 그래서 값은 10이다.
signed 변수의 비트 패턴으로 값 유추하기
반면에 signed 변수는 2의 보수 방식으로 음수를 표현하기에 1010이라는 비트 패턴을 보고 원래 값을 유추할 수 있다. 2의 보수를 구할 때에 비트를 뒤집고 1을 더했기에 이를 역으로 계산하면 된다.
- 1010 – 1 = 1001
- 1001을 뒤집는다. 결과는 0110.
- 0110은 6이다. 그러므로 1010은 6의 2의 보수이고 이는 1010이 -6이라는 의미이다.
이와 다른 직관적인 방법으로는 위 사진에 나오는 것 처럼 최상위 비트의 수의 음수 값에 그 아래의 비트 값을 더해주는 방식이 있다. 그렇게 계산하여 -8 + 2 = -6이라는 것을 알 수 있다.
변수 변환 시 발생하는 문제 (오버플로우)
여기서 집중해야 할 것은 Unsigned이냐 Signed이냐에 따라서 같은 비트를 가지고 있더라도 실제로 가지는 수는 완전히 달라진다.
1
2
unsigned char a = 200;
printf("%d", (signed char)a); // result: -56
그렇기 때문에 변수끼리 변환을 할 때에 큰 문제가 발생할 수 있다.
해당 코드처럼 나는 200을 의도했지만 변환을 시도했을 때 컴퓨터는 내 의도와는 다르게 변수 a를 -56으로 해석하는 상황이 생길 수도 있다는 의미이다.
그래서 코딩을 할 때에 예상하지 못했던 데이터 오염을 방지하기 위해 최대한 형 변환을 피하라고 지시하는 것이고 필요하다면 반드시 ‘명시적’ 방식을 이용하라는 것이다.
결론
‘부호’나 양수, 음수라는 것을 알 방도가 없는 컴퓨터가 0과 1의 조합만을 가지고 음수를 표현하는 방법과 컴퓨터는 가산기(adder)만 가지고 덧셈과 뺄셈을 모두 수행한다는 사실 등을 공부하며 1의 보수와 2의 보수에 대해 자세히 다루어 보았다.
결국 이 모든건 컴퓨터의 하드웨어 성능을 최대한 효율적으로 만들기 위함이었고 그로 인해 어쩔 수 없이 생기는 개발자가 주의해야 하는 부분도 함께 알아보았다.
실제 개발을 할 때에 이런 부분까지 신경써가면서 하는 사람들은 얼마 안되겠지만 누군가에겐 이러한 지식들을 앎으로써 디버깅 할 때에 혹은 리버스 엔지니어링을 할 때에 반드시 필요한 지식이 될 수도 있다.
참고 자료
Chemeketa University CS160Reader Binary and Its Advantages