Devin.KR

정의되지 않은 동작 - 부호 있는 오버플로와 암묵 변환이 만드는 버그

개발자KR 조회 10

이 장에서 배우는 것

C 언어는 프로그래머를 전적으로 신뢰한다. 언어 차원에서 배열의 인덱스가 범위를 벗어났는지 검사하거나, 정수 연산이 한계를 초과하여 오버플로가 일어났는지 감시하는 숨겨진 코드를 강제로 삽입하지 않는다. 이러한 설계 철학 덕분에 C 언어는 하드웨어의 성능을 극한까지 끌어낼 수 있다. 하지만 이 강력한 자유에는 무거운 책임이 뒤따른다.

프로그램이 언어의 규칙을 어기는 상황에 부닥쳤을 때, C 표준은 이를 정의되지 않은 동작(Undefined Behavior)이라고 부른다. 컴파일러는 코드 안에 정의되지 않은 동작이 절대 존재하지 않는다고 가정하고 매우 공격적인 최적화를 수행한다. 그 결과, 논리적으로 완벽해 보이는 코드가 배포 환경에서 엉뚱한 결과를 내거나, 특정 조건에서만 프로그램이 강제 종료되는 까다로운 버그를 만들어낸다. 이 장에서는 실무에서 자주 마주치는 정의되지 않은 동작의 원인을 파악하고, 컴파일러 도구를 활용해 이를 사전에 차단하는 방법을 배운다.

  • 정의되지 않은 동작의 개념과 컴파일러 최적화의 관계를 이해한다.
  • 부호 있는 정수 오버플로가 유발하는 논리적 오류를 파악하고 안전한 연산을 구현한다.
  • 정수 승격과 암묵적 변환이 만들어내는 부호 비교 버그를 해결한다.
  • 시퀀스 포인트 부재, 범위 밖 접근 등 흔한 오류 패턴을 살핀다.
  • 컴파일러 경고 옵션과 미정의 동작 식별 도구를 적용하여 버그를 찾는다.

문제 상황

우리가 만들고 있는 도서관 대출 관리 프로그램이 커지면서 두 가지 심각한 버그가 보고되었다.

첫 번째는 연체료 계산 기능에서 발생했다. 수년 동안 연체료를 내지 않아 미납금이 산더미처럼 쌓인 회원이 새로운 책을 연체했을 때 벌어진 일이다. 기존 미납금에 새로운 연체료를 더했는데, 시스템은 갑자기 총 연체료가 음수로 변했다고 기록했다. 도서관이 회원에게 돈을 돌려줘야 하는 상황이 된 것이다. 코드를 디버깅하기 위해 덧셈 결과 바로 뒤에 printf 출력문을 넣고 컴파일했더니, 놀랍게도 오버플로 없이 양수 값이 정상적으로 유지되었다. 코드 자체는 바꾸지 않고 출력문만 추가했을 뿐인데 실행 결과가 달라지는 기이한 현상이었다.

두 번째 문제는 대출 기록 배열을 역순으로 탐색하는 기능에서 나타났다. 배열의 끝에서부터 처음까지 거꾸로 훑어보기 위해 인덱스 변수를 -1부터 시작하여 반복문을 작성했다. 그러나 프로그램은 배열의 처음을 지나쳐서 메모리의 엉뚱한 영역까지 계속 읽어 들이며 무한 루프에 빠졌고, 결국 운영체제에 의해 강제 종료되었다.

이 두 가지 문제는 모두 문법적인 오류가 아니라, C 언어가 규정한 정의되지 않은 동작을 무심코 건드렸기 때문에 컴파일러가 코드를 우리가 생각한 것과 다르게 번역하면서 발생한 일이다.

정의되지 않은 동작과 컴파일러 최적화

C 언어 표준은 문법을 정의할 때, 특정 연산이나 상황에 대해 "이 경우에는 어떻게 동작해야 한다"고 명시하지 않고 빈칸으로 남겨두는 영역이 있다. 이것이 정의되지 않은 동작이다. 정의되지 않은 동작이 발생하면 프로그램이 즉시 멈추는 것은 아니다. 운이 좋으면 의도한 대로 동작할 수도 있고, 엉뚱한 변수의 값이 바뀔 수도 있으며, 심지어 과거로 시간을 거슬러 올라간 것처럼 이전의 코드가 실행되지 않을 수도 있다.

왜 표준은 이러한 위험한 상태를 방치할까? 바로 성능 때문이다. 특정 CPU 아키텍처는 정수 오버플로 발생 시 처리를 하드웨어 예외로 던지지만, 다른 아키텍처는 단순히 비트를 잘라내고 무시한다. C 언어가 모든 하드웨어에서 동일하게 "오버플로 시 프로그램 종료" 같은 규칙을 강제하려면 매 연산마다 추가적인 기계어 명령(검사 코드)을 넣어야 한다. 이는 C 언어의 철학에 위배된다.

현대의 컴파일러는 여기서 한 걸음 더 나아간다. "프로그래머가 작성한 코드에는 정의되지 않은 동작이 절대 존재하지 않는다"고 가정하는 것이다. 만약 부호 있는 정수(int)를 더하는 코드 뒤에 오버플로를 검사하기 위해 if (a + b < a) 같은 방어 코드를 작성했다고 하자. 컴파일러는 "부호 있는 정수의 오버플로는 정의되지 않은 동작이다. 따라서 이 프로그램에는 오버플로가 존재하지 않는다. 오버플로가 없다면 두 양수의 합은 항상 원래의 수보다 크다. 그러므로 이 조건문은 영원히 거짓이다"라고 논리적으로 추론한다. 그 결과, 최적화 단계에서 프로그래머가 고심하여 작성한 방어 코드를 아예 삭제해버린다.

부호 있는 정수 오버플로와 안전한 검사

정수의 오버플로에 대한 규칙은 변수의 부호 유무에 따라 완전히 다르다. 부호 없는 정수(unsigned int, size_t 등)는 최대값을 넘어서면 수학적 모듈로 연산의 규칙에 따라 다시 0부터 시작한다. 이 동작은 표준에 명시되어 있으며, 안전하게 예측할 수 있다.

반면, 부호 있는 정수(int, long 등)의 오버플로는 정의되지 않은 동작이다. 앞서 설명한 것처럼 컴파일러의 최적화와 충돌하여 코드가 삭제되거나 흐름이 꼬일 수 있다.

부호 없는 정수는 순환하지만 부호 있는 정수는 오버플로 시 동작을 예측할 수 없다

따라서 덧셈이나 곱셈을 수행한 이후에 결과를 보고 오버플로를 판단하는 것은 이미 늦었다. 연산을 수행하기 이전에 한계값을 넘을 것인지 미리 검사해야 한다. 안전하게 덧셈을 검사하려면 if (INT_MAX - a < b)처럼 수식을 변형하여 어떤 연산도 최대값을 초과하지 않는 상태에서 비교해야 한다. C 언어 공식 문서나 표준 라이브러리 헤더 문서를 참고하면 각 자료형의 안전한 한계값 상수를 확인할 수 있다.

정수 승격과 암묵적 변환의 함정

도서관 배열 탐색 기능에서 무한 루프가 발생한 원인은 자료형이 섞이면서 발생하는 암묵적 변환 때문이다. C 언어는 수식 안에 서로 다른 자료형이 섞여 있으면, 계산을 위해 자료형을 일치시키는 과정을 거친다. 크기가 작은 char나 short는 모두 연산 전에 int로 바뀐다. 이를 정수 승격(Integer Promotion)이라고 부른다.

문제는 부호 있는 정수와 부호 없는 정수가 이항 연산자(비교 연산자 포함)로 만날 때 발생한다. int형 변수와 size_t형 변수를 비교하면, int형 값이 부호 없는 size_t형으로 암묵적으로 변환된다. 이때 비트 패턴은 전혀 바뀌지 않는다.

음수 -1이 부호 없는 자료형으로 변환되면 매우 큰 양수로 해석된다

만약 int형 인덱스에 음수 -1을 넣고 이를 배열의 크기와 비교한다면, -1의 비트 패턴인 0xFFFFFFFF(32비트 환경)는 부호 없는 정수로 해석되어 4,294,967,295라는 거대한 숫자가 된다. 이 거대한 숫자는 언제나 배열의 크기보다 크기 때문에, 루프의 종료 조건이 프로그래머의 의도와 정반대로 작동하여 무한 루프에 빠지게 된다.

기타 흔히 발생하는 정의되지 않은 동작

오버플로와 형변환 외에도 실무에서 흔히 겪는 정의되지 않은 동작들이 있다.

  • 시퀀스 포인트 부재: a[i] = i++; 와 같은 식을 생각해보자. 대입 연산이 먼저 일어날지, i의 증가가 먼저 일어날지 표준은 정하지 않았다. 한 수식 안에서 변수의 값을 변경하면서 동시에 그 변수를 읽는 것은 금물이다.
  • 범위 밖 메모리 접근: 크기가 10인 배열에 인덱스 10으로 접근하여 읽거나 쓰는 행위다. 주변에 할당된 다른 중요 변수의 값을 오염시키며, 이는 심각한 보안 취약점의 단골 원인이 된다.
  • 초기화되지 않은 지역 변수 사용: 지역 변수를 초기화하지 않고 읽으면 메모리에 남아있던 쓰레기 값을 읽게 되며, 이 역시 정의되지 않은 동작으로 분류되어 최적화 과정에서 예상치 못한 흐름을 낳는다.

미정의 동작을 잡는 구원투수: UBSan

이러한 문제들을 눈으로만 코드 검토하여 모두 찾아내는 것은 매우 어렵다. 다행히 최신 컴파일러(GCC, Clang)는 훌륭한 도구를 제공한다. 컴파일할 때 -Wall -Wextra 옵션을 주면 부호 있는 정수와 없는 정수의 비교를 경고해준다. 여기에 -fsanitize=undefined 옵션을 추가하면 미정의 동작 식별기(Undefined Behavior Sanitizer, UBSan)가 켜진다.

이 옵션으로 컴파일된 프로그램은 모든 덧셈, 배열 접근 등에 컴파일러가 비밀리에 검사 코드를 끼워 넣는다. 프로그램 실행 중 오버플로나 범위 이탈이 발생하는 즉시 "런타임 에러: 부호 있는 정수 오버플로" 같은 구체적인 오류 메시지와 발생한 소스 코드의 줄 번호를 출력하고 멈춘다. 실무에서는 개발 및 테스트 환경에서 반드시 이 옵션을 켜두고 코드를 작성해야 한다.

완성 코드

library.c

#include <stdio.h>
#include <limits.h>
#include <stddef.h>

struct Member {
    int member_id;
    int total_fines;
};

/* 안전하게 두 정수를 더하고, 오버플로가 발생하면 0을 반환한다. */
int safe_add(int a, int b, int *result) {
    /* 두 양수를 더할 때 한계를 넘는지 검사 */
    if (a > 0 && b > INT_MAX - a) {
        return 0;
    }
    /* 두 음수를 더할 때 한계를 넘는지 검사 */
    if (a < 0 && b < INT_MIN - a) {
        return 0; 
    }
    
    *result = a + b;
    return 1;
}

/* 회원 배열을 뒤에서부터 탐색하여 빚이 없는 회원을 찾는다. */
int find_clean_member(const struct Member *members, size_t count) {
    if (count == 0) {
        return -1;
    }
    
    /* i를 size_t로 선언하고 count부터 시작하여 i-- 후 접근한다.
       size_t는 음수가 될 수 없으므로 i >= 0 조건은 무한 루프를 유발한다. */
    for (size_t i = count; i > 0; i--) {
        if (members[i - 1].total_fines == 0) {
            return members[i - 1].member_id;
        }
    }
    return -1;
}

int main(void) {
    struct Member db[] = {
        {101, 5000},
        {102, 0},
        {103, 2147480000} /* INT_MAX(2147483647)에 가까운 값 */
    };
    size_t db_size = sizeof(db) / sizeof(db[0]);

    int new_fine = 10000;
    int updated_fine;

    printf("회원 103의 연체료 계산:\n");
    if (safe_add(db[2].total_fines, new_fine, &updated_fine)) {
        db[2].total_fines = updated_fine;
        printf("계산 완료. 새 연체료: %d\n", updated_fine);
    } else {
        printf("오류: 연체료 합계가 허용 범위를 초과했습니다.\n");
    }

    printf("\n연체료가 없는 마지막 회원 탐색:\n");
    int clean_id = find_clean_member(db, db_size);
    if (clean_id != -1) {
        printf("회원 ID: %d\n", clean_id);
    } else {
        printf("찾을 수 없음\n");
    }

    return 0;
}

줄별 해설

  • int safe_add(int a, int b, int *result): a + b 연산을 직접 실행하기 전에 양수와 음수 오버플로를 모두 검사하는 함수다. 인자로 받은 포인터 변수에 안전하게 연산된 결과를 쓴다.
  • if (a > 0 && b > INT_MAX - a): 양수 b가 INT_MAX - a보다 크다면 덧셈 시 INT_MAX를 넘어선다는 뜻이다. 덧셈을 뺄셈으로 모양을 바꾸어 오버플로 없이 한계를 측정하는 가장 정석적인 방법이다.
  • size_t i = count; i > 0; i--: 배열을 역순으로 탐색할 때 흔히 쓰이는 관용구다. i를 0부터 시작하게 하면 부호 없는 타입 특성상 0에서 1을 뺄 때 가장 큰 양수로 순환하므로 오류가 생긴다. 개수(count)부터 시작하여 0보다 클 때까지만 반복한다.
  • members[i - 1]: 실제 배열 접근 시에는 인덱스 1을 빼주어 배열의 범위를 정확히 맞춘다.
  • sizeof(db) / sizeof(db[0]): 배열이 차지하는 전체 메모리 바이트를 요소 하나의 바이트 크기로 나누어 배열 길이를 알아낸다. 이 값은 size_t 타입으로 반환된다.

실행 결과

$ cc -std=c17 -Wall -Wextra -fsanitize=undefined library.c
$ ./a.out
회원 103의 연체료 계산:
오류: 연체료 합계가 허용 범위를 초과했습니다.

연체료가 없는 마지막 회원 탐색:
회원 ID: 102

실무에서 자주 틀리는 것

배열 크기와 음수 인덱스 비교

틀린 코드:

int i = -1;
size_t length = 10;
if (i < length) { /* 암묵적 변환 발생 */
    printf("범위 안입니다.\n");
}

고친 코드:

int i = -1;
size_t length = 10;
if (i >= 0 && (size_t)i < length) {
    printf("범위 안입니다.\n");
}

해설: i가 length와 비교될 때 i는 size_t로 변환된다. 음수 -1은 매우 큰 양수가 되므로 i < length는 거짓이 된다. 조건에서 i가 양수인지 먼저 명확히 확인한 후 명시적 형변환을 거쳐 비교해야 안전하다.

뒤늦은 오버플로 검사

틀린 코드:

int sum = a + b;
if (sum < a) { /* 오버플로가 일어나면 sum이 작아질 것이라는 착각 */
    printf("오버플로 발생!\n");
}

고친 코드:

if (b > 0 && a > INT_MAX - b) {
    printf("오버플로 발생!\n");
}

해설: 이미 덧셈을 수행한 뒤에는 부호 있는 정수의 경우 정의되지 않은 동작 영역에 들어갔으므로 컴파일러가 이 검사 코드를 쓸모없다고 판단하고 지워버릴 수 있다. 항상 연산이 일어나기 전에 여유 공간을 확인해야 한다.

시퀀스 포인트를 무시한 단일 수식

틀린 코드:

int i = 0;
array[i] = i++; /* i가 언제 증가할지 알 수 없음 */

고친 코드:

int i = 0;
array[i] = i;
i++;

해설: array[i]의 인덱스를 계산하기 위해 i를 먼저 읽을지, 대입할 값을 평가하기 위해 i++를 먼저 수행할지 컴파일러 마음대로다. 복잡한 수식에서 한 변수를 여러 번 읽고 쓰는 행위는 피하고 여러 문장으로 나누어 명확히 해야 한다.

한눈에 보기

표 1. 주요 정의되지 않은 동작(UB) 요약
상황 설명 예방 및 검사 방법
부호 있는 정수 오버플로 int 등의 범위를 넘는 연산 시 결과 예측 불가, 코드 삭제 발생 연산 전 한계값(INT_MAX 등) 검사, UBSan 활용
범위 밖 메모리 접근 배열의 인덱스 초과 시 다른 변수 오염 및 프로그램 충돌 엄격한 인덱스 한계선 확인, size_t 타입 사용
시퀀스 포인트 부재 한 수식에서 같은 변수를 여러 번 변경/접근 시 평가 순서 모호 증감 연산자와 대입 연산자를 별도의 문장 줄로 분리
초기화 안 된 변수 읽기 메모리 쓰레기값 사용 시 제어 흐름 분기가 엉뚱하게 튐 변수 선언과 동시에 명시적으로 기본값 초기화
표 2. 주요 정수 암묵적 타입 변환 규칙
변환 전 자료형 조합 변환 후 자료형 주의점 및 결과
char, short (단독 수식) int 수식 내에서 쓰일 때 기본적으로 정수 승격이 일어남
int 와 unsigned int unsigned int 비교/연산 시 부호 있는 쪽이 부호 없는 쪽으로 흡수됨
int (음수 값) 와 size_t size_t 음수의 비트 패턴이 유지된 채로 아주 큰 양수로 해석됨

연습 문제

  1. 다음 함수는 두 수를 곱한 값을 반환한다. 이 코드에서 발생할 수 있는 정의되지 않은 동작의 위험성을 설명하고, 문제를 예방하도록 코드를 고치시오.
    int calculate_area(int width, int height) {
        return width * height;
    }
    
  2. 아래 코드는 문자열 버퍼를 0으로 초기화하려는 의도로 작성되었다. 논리적 오류를 찾아내고, 무한 루프나 배열 범위 초과가 발생할 수 있는 이유를 설명하시오.
    char buf[100];
    size_t len = get_input_length(); /* 0을 반환할 수 있다고 가정 */
    for (int i = len - 1; i >= 0; i--) {
        buf[i] = '\0';
    }
    
  3. 컴파일 시 -fsanitize=undefined 옵션을 주었을 때 얻을 수 있는 장점을 실무 디버깅 관점에서 한 문장으로 설명하시오.

정답과 해설

  1. 정답: width와 height를 곱한 값이 INT_MAX를 초과하면 부호 있는 정수 오버플로(정의되지 않은 동작)가 발생한다. 안전하게 처리하려면 연산 전에 한계를 검사하거나, 자료형을 더 큰 크기로 늘려야 한다.
    long long calculate_area(int width, int height) {
        return (long long)width * height;
    }
    
  2. 정답: 만약 len이 0이라면, 부호 없는 정수인 size_t에서 1을 뺄 때 언더플로가 발생하여 표현할 수 있는 가장 큰 양수(예: 4294967295)가 된다. 이 거대한 양수를 부호 있는 int i에 대입하면, 환경에 따라 i가 음수로 변환되거나 비정상적인 양수 값을 가져 잘못된 메모리에 접근하게 된다. 루프 변수를 size_t로 선언하고 역순 탐색 관용구(size_t i = len; i > 0; i--)를 써야 한다.
  3. 정답: 컴파일러가 눈에 보이지 않게 제거하거나 지나칠 수 있는 오버플로 및 범위 밖 접근을 실행 시간에 감지하여 정확한 오류 발생 위치를 알려주므로, 재현하기 힘든 버그를 즉각 발견하고 추적할 수 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.