HOWTO · C
C에서 unsigned Int와 Signed Int의 차이점
다음 기사에서는 C 프로그래밍 언어에서 signed int와 unsigned int의 차이점을 살펴봅니다.
이 페이지의 내용
signed int는 음수와 양수를 모두 표현할 수 있지만, unsigned int는 음수가 아닌 값만 표현합니다. C에서 int는 기본적으로 signed이므로 signed 정수에는 int를 쓰고, unsigned 정수가 필요하면 unsigned int라고 명시합니다. 어떤 타입을 쓸지는 프로그램이 다루는 값과 필요한 산술 연산에 따라 달라집니다. 차이는 작아 보입니다. 한 타입은 마이너스 부호를 허용하고 다른 타입은 허용하지 않는다는 점뿐인 것 같지만, 값의 범위, 그 범위의 한계에서 일어나는 일, 그리고 두 타입이 섞인 식의 동작까지 달라집니다.
C에서 signed와 unsigned int의 차이
두 타입 모두 정수 타입이지만 표현할 수 있는 범위가 다릅니다. C 표준은 int가 최소한 -32767부터 32767까지, unsigned int가 최소한 0부터 65535까지 표현할 수 있도록 보장합니다. 이는 최소 보장 범위이지 모든 시스템에서의 정확한 한계는 아닙니다. limits.h를 포함한 뒤 INT_MIN, INT_MAX, UINT_MAX를 확인하면 컴파일에 사용하는 구현의 한계를 알 수 있습니다.
| 타입 | 표현하는 값 | 이식 가능한 최소 범위 |
|---|---|---|
int |
음수와 음수가 아닌 값 | -32767부터 32767까지 |
unsigned int |
음수가 아닌 값 | 0부터 65535까지 |
두 타입은 보통 같은 저장 크기를 차지하므로, 둘 중 무엇을 고른다고 전체 공간이 늘어나지는 않습니다. 그 공간을 어디에 쓸지가 정해질 뿐입니다. signed 타입은 값을 음수와 양수에 나눠 쓰는 반면, unsigned 타입은 범위 전체를 음수가 아닌 값에 쓰므로 같은 구현에서는 최댓값이 대략 두 배가 됩니다. 이 기사의 예제에 사용한 64비트 Linux 시스템에서는 두 타입 모두 4바이트를 차지하고, 한계값을 출력하면 익숙한 32비트 범위가 나옵니다.
#include <limits.h>
#include <stdio.h>
int main(void) {
printf("sizeof(int) = %zu\n", sizeof(int));
printf("sizeof(unsigned int) = %zu\n", sizeof(unsigned int));
printf("int range: %d to %d\n", INT_MIN, INT_MAX);
printf("unsigned int range: 0 to %u\n", UINT_MAX);
return 0;
}
sizeof(int) = 4
sizeof(unsigned int) = 4
int range: -2147483648 to 2147483647
unsigned int range: 0 to 4294967295
이 정확한 숫자들은 언어의 속성이 아니라 이 구현의 속성으로 취급하세요. 어디에서든 동작해야 하는 코드는 32비트 한계를 하드코딩하는 대신 INT_MIN, INT_MAX, UINT_MAX와 비교해야 합니다.
값의 범위와 변환
실제 값의 범위와 저장 크기는 구현에 따라 달라집니다. sizeof(int)와 sizeof(unsigned int)는 반드시 4바이트일 필요가 없으며, C는 int가 32비트라고 보장하지 않습니다. signed 정수를 이식성 있게 설명할 때 맨 왼쪽 비트를 부호용으로 예약한다고 단정할 수는 없습니다. 표현 방식은 구현에 따라 다릅니다.
음수 값을 unsigned 타입으로 변환하면 결과는 UINT_MAX + 1을 법으로 했을 때 원래 값과 합동인 값이 됩니다. 따라서 unsigned 객체에는 음수가 아닌 값이 들어가며, 나중에 읽었을 때 음수 값이 복원되지는 않습니다. unsigned 산술 연산도 UINT_MAX + 1을 법으로 하여 순환합니다. 다음 예제는 이 규칙을 세 가지로 적용합니다. -1의 변환, 0 아래로의 뺄셈, 최댓값을 넘는 덧셈입니다.
#include <limits.h>
#include <stdio.h>
int main(void) {
unsigned int converted = -1;
unsigned int wrapped_down = 0u - 1u;
unsigned int wrapped_up = UINT_MAX + 1u;
printf("converted -1: %u\n", converted);
printf("0u - 1u: %u\n", wrapped_down);
printf("UINT_MAX + 1u: %u\n", wrapped_up);
return 0;
}
converted -1: 4294967295
0u - 1u: 4294967295
UINT_MAX + 1u: 0
이 구현에서는 -1이 4294967295, 즉 정확히 UINT_MAX가 됩니다. 어떤 구현에서든 결과는 UINT_MAX 그 자체입니다. 모듈러 산술에서 -1은 언제나 0보다 한 단계 아래이기 때문입니다. 0에서 1을 빼면 -1이 되지 않고 같은 최댓값으로 순환하고, 최댓값에 1을 더하면 0으로 돌아옵니다. 이 연산 중 어느 것도 오류가 아닙니다. 순환이야말로 unsigned 산술에 정의된 동작입니다.
아래 완전한 예제는 int에 %d를, unsigned int에 %u를 사용합니다. 예제와 실행 결과의 레이블은 바이트 단위로 원문을 보존하므로 영어 그대로 표시됩니다. 아래 출력은 제공된 실행에서 나온 결과입니다.
#include <stdio.h>
int main(void) {
int temperature_change = -7;
unsigned int item_count = 42;
printf("Temperature change: %d\n", temperature_change);
printf("Item count: %u\n", item_count);
return 0;
}
Temperature change: -7
Item count: 42
음수를 unsigned에 대입하는 경우에는 앞서 설명한 변환 규칙을 적용할 수 있으며, 정확한 결과값은 UINT_MAX에 따라 달라집니다. unsigned int를 %u 대신 %d로 printf에 전달하면 형식 불일치로 정의되지 않은 동작이 발생하므로, 출력값을 신뢰할 수 없습니다.
오버플로: unsigned에서는 정의되고, signed에서는 정의되지 않음
위의 순환에는 signed에 대응하는 동작이 없습니다. signed 정수 오버플로, 즉 INT_MAX보다 크거나 INT_MIN보다 작은 결과를 계산하는 것은 C에서 정의되지 않은 동작이며, 순환이 보장되지 않습니다. 컴파일러는 오버플로가 절대 일어나지 않는다고 가정하고 최적화하므로, x + 1이 x보다 작은지를 검사하는 방식으로는 signed 오버플로를 사후에 안정적으로 탐지할 수 없습니다. 계산이 한계에 가까워질 수 있다면 계산하기 전에 검사하세요. 예를 들어 y를 x에 더하기 전에 x > INT_MAX - y를 테스트하거나, 더 넓은 타입을 사용합니다. 이 비대칭은 일반 산술에서 signed 타입을 선호할 실용적인 이유입니다. unsigned 값의 실수는 틀렸지만 그럴듯해 보이는 숫자로 조용히 순환하는 반면, signed 코드는 한계를 실제로 초과하기 전까지 기대한 값을 유지합니다.
하나의 식에서 signed와 unsigned 섞기
가장 놀라운 차이는 두 타입이 하나의 식에서 만날 때 나타납니다. 일반적인 산술 변환(usual arithmetic conversions)에 따르면, 같은 순위의 int와 unsigned int를 결합하면 연산이 수행되기 전에 int 피연산자가 unsigned int로 변환됩니다. 따라서 한쪽이 음수로 시작했더라도 비교나 연산은 unsigned로 수행됩니다.
#include <stdio.h>
int main(void) {
printf("-1 < 1: %d\n", -1 < 1);
printf("-1 < 1u: %d\n", -1 < 1u);
printf("-1 == 4294967295u: %d\n", -1 == 4294967295u);
return 0;
}
-1 < 1: 1
-1 < 1u: 0
-1 == 4294967295u: 1
수학으로 읽으면 -1 < 1은 참이며 1을 출력합니다. 접미사 u를 붙이면 답이 0으로 바뀝니다. -1이 먼저 4294967295로 변환되고, 그 값은 1보다 작지 않기 때문입니다. 같은 이유로 -1은 가장 큰 unsigned 값과 같아집니다. GCC는 바로 이 패턴에 대해 -Wsign-compare로 경고하는데, 이 경고는 상시 켜둘 가치가 있습니다. 산술도 같은 규칙을 따르므로, 음수 조정값이 작은 음수 결과가 아니라 거대한 양수 결과가 될 수 있습니다.
#include <stdio.h>
int main(void) {
unsigned int count = 10u;
int delta = -20;
int signed_total = 10 + delta;
printf("unsigned total: %u\n", count + delta);
printf("signed total: %d\n", signed_total);
return 0;
}
unsigned total: 4294967286
signed total: -10
여기서는 unsigned 타입이 전혀 오버플로하지 않았습니다. 변환만으로 4294967286이 만들어졌습니다. 두 개수의 차이, 잔액, -1 같은 오류 센티넬처럼 값이 음수가 될 수 있다면, 음수가 아닌 값이 보장되는 지점까지는 signed 타입에 유지하세요.
sizeof 함정과 unsigned 루프 카운터
섞임은 캐스트나 접미사가 보이지 않는 곳에서도 일어납니다. sizeof 연산자는 unsigned 타입인 size_t를 만들므로, signed 변수를 sizeof 결과와 비교하면 같은 변환이 일어납니다.
#include <stdio.h>
int main(void) {
int index = -1;
printf("sizeof(int): %zu\n", sizeof(int));
printf("index < (int)sizeof(int): %d\n", index < (int)sizeof(int));
printf("index < sizeof(int): %d\n", index < sizeof(int));
return 0;
}
sizeof(int): 4
index < (int)sizeof(int): 1
index < sizeof(int): 0
마지막 비교는 참이어야 합니다. -1은 실제로 4보다 작습니다. 그러나 unsigned 비교는 반대를 말합니다. sizeof 결과를, 그 값이 들어간다는 것을 아는 경우에 int로 변환하면 기대한 답이 돌아옵니다. 관련된 또 다른 함정은 for (unsigned int i = count - 1; i >= 0; --i)처럼 쓰는 감소 루프입니다. unsigned 값은 항상 0 이상이므로 조건이 거짓이 되는 일이 없습니다. 카운터가 0에 도달해 감소하면 UINT_MAX로 순환하고 — 변환 예제가 0u - 1u에 대해 출력한 바로 그 순환입니다 — 루프는 거대한 인덱스로 계속 돕니다. signed 타입으로 거꾸로 세거나, 감소하기 전에 이전 값을 검사하는 for (unsigned int i = count; i-- > 0; ) 관용구를 사용하세요.
signed와 unsigned int 선택하기
0보다 작거나 클 수 있는 변화량처럼 음수가 의미 있는 경우에는 int를 선택합니다. 값의 범위가 음수가 아니고, 타입의 최댓값을 넘으면 순환하는 동작이 적절한 경우에는 unsigned int를 선택할 수 있습니다. 예를 들어 연산 결과가 최댓값에서 순환해야 하는 카운트가 이에 해당합니다. unsigned가 더 큰 양수를 안전하게 저장하는 일반적인 방법인 것은 아닙니다. 표현 범위는 구현에 따라 다르며, signed와 unsigned 값을 변환하거나 함께 연산하면 비교 및 산술 결과가 달라질 수 있습니다. 프로그램이 실제로 다루는 값의 범위와 연산 방식에 맞는 타입을 사용하세요.
실제로 이는 몇 가지 믿을 만한 기본 선택으로 이어집니다. 일반 산술, 차이, 루프 카운터에는 signed 타입을 사용하세요. 음수가 아닌 값으로 시작하는 양이라도 마찬가지입니다. 중간 결과가 0 아래로 내려가야 하는 경우가 자주 있기 때문입니다. unsigned 타입은 비트 패턴과 마스크, 해시 값과 하드웨어 레지스터처럼 순환이 의도적인 값, 그리고 이미 unsigned 타입이나 size_t를 사용하는 인터페이스에 맞추는 경우를 위해 남겨두세요. 값이 절대 음수가 되어서는 안 된다는 것을 문서화하려는 목적으로만 unsigned를 고르지 마세요. 뺄셈으로 그 값이 생기는 것을 막지 못하고, 그저 아주 큰 양수로 이름만 바꿀 뿐입니다. 마지막으로, 진짜 문제가 범위라면, unsigned 타입이 제공하는 것처럼 보이는 여유 공간에 기대지 말고 long long 같은 더 넓은 타입으로 옮겨가세요.
이 타입들을 둘러싼 혼란의 대부분은 세 가지 오해에서 생깁니다. 첫째는 unsigned int가 단순히 양의 범위가 더 큰 int라는 생각입니다. 실제로 두 타입은 범위는 물론 오버플로 동작, 변환, 비교에서도 다릅니다. 둘째는 음수 값을 unsigned 변수에 저장하면 음수 값이 어떤 식으로든 기억된다는 생각입니다. 위의 예제들이 보여주듯 변환이 즉시 값을 대체합니다. 셋째는 unsigned 산술이 음수가 될 수 없어서 더 안전하다는 생각입니다. 음수 결과를 큰 양수 결과로 조용히 바꿔놓기 때문에, 막으려던 문제보다 디버깅하기 어려운 경우가 많습니다.