C 언어 배열 이름의 포인터 퇴화(Array Decay) 메커니즘과 3가지 주요 위험 요소

C 언어를 학습할 때 가장 혼란스러운 부분 중 하나는 배열이 함수 인자로 전달될 때의 동작 방식입니다. 함수 내부에서 sizeof 연산자를 사용하여 배열의 크기를 구하려 하면 예상과 다른 결과가 나오는데, 이는 '배열 이름의 포인터 퇴화(Array Decay)'라는 특성 때문입니다.

1. 배열 이름의 퇴화 현상이란?

C 언어에서 배열의 이름은 특정 상황을 제외하고는 배열의 첫 번째 요소를 가리키는 포인터 상수로 자동 변환됩니다. 특히 배열이 함수의 매개변수로 전달될 때, 배열 전체 복사본이 아닌 첫 번째 요소의 주소값만 전달됩니다.

#include <stdio.h>

void checkMemorySize(int paramArray[], int count) {
    // 여기서 paramArray는 포인터로 취급됨
    printf("함수 내 sizeof(paramArray): %lu 바이트\n", sizeof(paramArray)); 
}

int main() {
    int numberList[] = {10, 20, 30, 40, 50, 60};
    printf("메인 내 sizeof(numberList): %lu 바이트\n", sizeof(numberList)); 
    
    checkMemorySize(numberList, 6);
    return 0;
}

위 코드에서 numberList는 메인 함수 내에서는 24바이트(int가 4바이트일 때)를 차지하는 배열이지만, checkMemorySize 함수로 넘어가는 순간 8바이트(64비트 시스템 기준 포인터 크기)의 포인터로 변합니다.

2. 포인터 퇴화의 내부 메커니즘

2.1 암시적 변환의 발생

배열 이름은 다음과 같은 상황에서 포인터로 퇴화합니다.

  • 산술 연산에 참여할 때 (예: arr + 2)
  • 함수의 인자로 배열을 전달할 때
  • 포인터 변수에 배열을 대입할 때

2.2 함수 매개변수 선언의 동일성

컴파일러 입장에서 다음 세 가지 함수 선언은 완전히 동일하게 처리됩니다.

void process(int *ptr);
void process(int ptr[]);
void process(int ptr[100]); // 배열 크기 100은 무시됨

2.3 sizeof와 & 연산자의 예외

모든 경우에 퇴화가 발생하는 것은 아닙니다. sizeof(배열명)은 배열 전체의 크기를 반환하며, &배열명은 배열 전체를 가리키는 포인터(예: int (*)[6])를 반환합니다. 이는 배열의 타입 정보가 완전히 사라지는 것이 아님을 보여줍니다.

3. 배열 퇴화가 초래하는 3가지 위험 요소

3.1 데이터 경계 인식 불능 및 버퍼 오버플로우

함수 내부에서 배열의 길이를 알 수 없으므로, 잘못된 인덱스 접근을 방어하기 어렵습니다. 이는 보안 취약점인 버퍼 오버플로우의 직접적인 원인이 됩니다.

void unsafeCopy(char *src) {
    char dest[16];
    // src의 길이를 모르기 때문에 dest의 범위를 넘어서는 복사가 발생할 수 있음
    strcpy(dest, src); 
}

3.2 다차원 배열의 정보 손실

2차원 배열을 int ** 형태로 잘못 전달하면 메모리 레이아웃 정보가 유실됩니다. 다차원 배열은 각 행의 크기 정보를 유지해야만 정확한 주소 계산이 가능합니다.

선언 방식 행 크기 유지 여부 안전한 접근
int matrix[3][5] 가능
int (*p)[5] 가능
int **p 아니오 불가능 (런타임 오류 위험)

3.3 인터페이스 설계의 모호성

포인터만으로는 이것이 단일 객체를 가리키는 것인지, 배열의 시작점을 가리키는 것인지 알 수 없습니다. 함수 설계 시 별도의 길이 매개변수를 강제하지 않으면 API 오용의 가능성이 커집니다.

4. 안전한 프로그래밍을 위한 실천 전략

4.1 배열 길이 정보 병행 전달

배열을 인자로 받을 때는 항상 그 크기를 나타내는 size_t 타입의 변수를 함께 전달하는 것이 표준적인 방법입니다.

void safeUpdate(int *data, size_t length) {
    if (!data || length == 0) return;
    for (size_t i = 0; i < length; ++i) {
        data[i] += 10;
    }
}

4.2 구조체를 활용한 캡슐화

데이터 포인터와 메타데이터(길이, 용량 등)를 하나의 구조체로 묶어 관리하면 정보 유실을 막을 수 있습니다.

typedef struct {
    int *items;
    size_t size;
} IntArray;

void processStruct(IntArray array) {
    // array.size를 통해 안전하게 접근 가능
}

4.3 가변 길이 배열(VLA) 및 컴파일 타임 검사

C99 이상에서는 가변 길이 배열을 사용하여 런타임에 크기를 지정할 수 있으며, C++의 경우 static_assert를 활용해 템플릿 기반의 엄격한 타입 및 크기 검사를 수행할 수 있습니다.

// C99 가변 길이 배열 예시
void handleVLA(int n) {
    int temp[n]; // 실행 시점에 크기 결정
    // ... 로직 수행
}

4.4 보안 함수 사용 권장

gets()와 같이 입력을 제한하지 않는 함수 대신 fgets()를 사용하고, strcpy() 대신 strncpy()strcpy_s()를 사용하여 명시적으로 버퍼 크기를 지정해야 합니다.

  • gcc -Wall 옵션으로 컴파일러 경고 확인
  • 정적 분석 도구(Cppcheck, Clang Static Analyzer) 활용
  • AddressSanitizer와 같은 런타임 오류 검출 도구 도입

태그: c-language memory-management Pointer-Decay Buffer-Overflow Software-Security

8월 22일 11:46에 게시됨