TECH 으로 돌아가기
TECH HACKER NEWS 2026.10.30 21:59 8분 읽기 32 READS

C에서 배열을 거꾸로 돌면 왜 버그가 날까? size_t와 unsigned의 함정 정리

거꾸로 도는 반복문은 생각보다 위험해요

배열을 뒤에서부터 순회하는 일은 정말 자주 있어요. 스택처럼 마지막 원소부터 처리할 때, 배열 중간의 원소를 지우면서 돌 때, 문자열 끝의 공백을 잘라낼 때 같은 경우요. 앞에서부터 도는 건 for (size_t i = 0; i < n; i++)로 쓰면 고민할 게 없어요. 그런데 거꾸로 돌려고 하면 의외로 많은 개발자가 버그를 만들어요. David Priver의 'Iterating Backwards in C'는 이 사소해 보이는 문제를 붙잡고 여러 해법을 비교하는 글이에요. C 개발자뿐 아니라 C++로 코딩 테스트를 준비하는 분에게도 필요한 이야기라서 정리해봤어요.

문제의 원인: size_t에는 음수가 없어요

C에서는 배열 길이와 인덱스에 보통 size_t를 써요. sizeof와 strlen이 돌려주는 타입이기도 하죠. 그런데 size_t는 부호 없는(unsigned) 정수예요. 0보다 작은 값을 표현할 수 없다는 뜻이에요. 0에서 1을 빼면 -1이 되지 않고 가장 큰 값(SIZE_MAX)으로 넘어가 버려요. 자동차 주행거리계가 000000에서 거꾸로 돌면 999999가 되는 것과 비슷해요.

그래서 아래 코드는 무한 루프가 돼요.

for (size_t i = n - 1; i >= 0; i--) {
process(arr[i]);
}

unsigned 값은 항상 0 이상이라서 i >= 0은 언제나 참이에요. i가 0인 상태에서 i--를 하면 거대한 값이 되고, arr[i]는 엉뚱한 메모리를 읽어요. 게다가 n이 0이면 시작값 n - 1부터 SIZE_MAX라서 첫 바퀴에서 바로 문제가 생겨요. 다행히 GCC에서 -Wextra를 켜면 '이 비교는 항상 참'이라고 경고해줘요. 컴파일러 경고 옵션은 꼭 켜두세요.

해법 1: 부호 있는 정수 쓰기

가장 직관적인 방법은 ptrdiff_t처럼 부호 있는(signed) 타입을 쓰는 거예요.

for (ptrdiff_t i = (ptrdiff_t)n - 1; i >= 0; i--) { ... }

읽기는 쉽지만 캐스팅이 필요하고, -Wsign-compare 경고와 씨름하는 일이 자주 생겨요. 이론적으로는 n이 signed 타입의 범위를 넘을 만큼 크면 문제가 될 수도 있고요.

해법 2: 먼저 검사하고 나서 줄이기 (i-- > 0)

C 커뮤니티에서 유명한 관용구가 있어요.

for (size_t i = n; i-- > 0; ) { ... }

조건식에서 지금 i가 0보다 큰지 먼저 확인하고, 그다음에 1을 빼요. 그래서 반복문 본문에서는 n-1부터 0까지 정확히 돌고, n이 0이면 아예 실행되지 않아요. 띄어쓰기를 바꿔 i --> 0이라고 쓰면 'i가 0으로 간다'는 화살표 연산자처럼 보여서, 농담 삼아 'goes to 연산자'라고 부르기도 해요. 물론 그런 연산자는 없고, --와 >를 붙여 쓴 것뿐이에요.

해법 3: 값이 한 바퀴 도는 성질을 이용하기

for (size_t i = n - 1; i < n; i--) { ... }

조금 영리한 방법이에요. i가 0에서 한 번 더 줄어들면 SIZE_MAX가 되니까 i < n이 거짓이 돼서 루프가 끝나요. n이 0일 때도 시작값이 SIZE_MAX라서 한 번도 실행되지 않아요. 이 방법이 안전한 이유는 C 표준이 unsigned 오버플로의 동작을 정의해두었기 때문이에요(값이 한 바퀴 돈다고 보장해요). 반대로 signed 오버플로는 정의되지 않은 동작(UB) 이라서 signed 타입으로 이런 트릭을 쓰면 안 돼요. 동작은 정확하지만 처음 보는 사람은 '이게 왜 끝나지?' 하고 헷갈릴 수 있으니 주석을 달아두면 좋아요.

해법 4: 포인터로 돌 때 주의할 점

for (int *p = arr + n; p != arr; ) {
--p;
process(*p);
}

포인터로 거꾸로 돌 때는 arr - 1 같은 주소를 만들면 안 돼요. C 표준은 배열 범위 안과 배열 끝 바로 다음 위치(one past the end)까지만 포인터 연산을 허용하거든요. 그래서 p >= arr 조건으로 도는 코드는 대부분의 환경에서 돌아가더라도 표준상으로는 UB예요. 위 코드처럼 끝 다음 위치에서 시작해서 '먼저 줄이고 나서 쓰는' 패턴이 안전해요.

업계 맥락: 다른 언어는 어떻게 해결했을까

이 문제는 '인덱스 타입을 무엇으로 할까'라는 오래된 논쟁과 이어져 있어요. Java에는 unsigned 정수가 아예 없어서 이런 함정은 없지만, 아주 큰 배열 길이를 다루는 데 제약이 있어요. Rust의 usize도 unsigned라서 같은 실수를 할 수 있는데, (0..n).rev() 같은 반복자로 깔끔하게 풀 수 있고 디버그 빌드에서는 오버플로가 나면 패닉을 내서 실수를 바로 잡아줘요. C++은 rbegin()/rend() 역방향 반복자와 C++20의 std::views::reverse를 제공해요. 현대 언어들은 '인덱스를 직접 다루지 말고 반복자에게 맡기자'는 방향으로 가고 있는 셈이에요.

한국 개발자에게 주는 시사점

임베디드, 펌웨어, 시스템 프로그래밍을 한다면 이 패턴들은 기본기예요. C++로 알고리즘 문제를 푸는 분도 조심해야 해요. 벡터가 비어 있으면 v.size() - 1은 거대한 수가 되거든요. for (int i = v.size() - 1; i >= 0; i--)가 보통 문제없이 돌아가는 건, 큰 unsigned 값이 int로 변환되면서 -1로 바뀌기 때문이에요. 이 변환은 C++20 전까지 컴파일러 구현에 맡겨진 동작이었어요. (int)v.size() - 1처럼 명시적으로 캐스팅하는 습관을 들이면 안전해요.

마무리

unsigned 인덱스로 거꾸로 돌 때는 값이 0 아래로 내려가는 순간을 절대 만들지 않는 게 핵심이에요. 여러분은 어떤 스타일을 쓰세요? i-- > 0 관용구인가요, 아니면 signed 인덱스로 단순하게 가나요? 팀 코딩 컨벤션에 정해둔 규칙이 있다면 같이 이야기해봐요.


🔗 출처: Hacker News

SOURCE · HACKER NEWS
원문 전체 보기 → https://www.davidpriver.com/Iterating-backwards-in-C.html
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...