[C언어 50강] 27강. 포인터와 배열의 관계: 배열명, 포인터 인덱싱
배열과 포인터는 C언어에서 가장 많이 헷갈리는 조합입니다. 많은 입문서가 “배열은 포인터다”라고 짧게 설명하는데, 이 문장은 절반만 맞고 절반은 틀립니다. 오늘은 이 모호한 문장을 정확히 해부해 보겠습니다. 핵심은 배열명은 특정 문맥에서 포인터처럼 변환(decay)되지만, 배열 자체와 포인터 변수는 동일한 존재가 아니다라는 점입니다.
핵심 개념
- 배열명은 대부분의 식에서 첫 원소 주소로 변환되지만,
sizeof,&같은 문맥에서는 배열 자체로 취급된다. arr[i]와*(arr + i)는 같은 의미이며, 인덱싱은 포인터 산술 위에 정의된 문법 설탕(syntactic sugar)이다.- 포인터 변수는 “다른 주소를 가리키도록 재지정”할 수 있지만, 배열명은 재할당 대상이 아니다.
- 함수 인자에서
int arr[]는 실질적으로int *arr로 해석되므로, 길이 정보는 자동 전달되지 않는다.
개념 먼저 이해하기
포인터와 배열을 이해할 때 가장 먼저 버려야 할 습관은 모양만 보고 동일시하는 것입니다. int arr[5];와 int *p;는 겉보기에는 비슷해 보일 수 있지만, 컴파일러가 이 둘을 다루는 방식은 다릅니다. arr는 “정수 5개를 담는 연속 메모리 덩어리” 자체이고, p는 “어딘가를 가리키는 주소값을 담는 변수”입니다. 즉 하나는 저장공간 그 자체이고, 다른 하나는 저장공간을 가리키는 값입니다.
그런데 왜 둘이 헷갈릴까요? 배열명 arr를 식에서 사용하면 대부분 자동으로 &arr[0]로 바뀌기 때문입니다. 예를 들어 printf("%p", (void*)arr);에서 arr는 첫 원소 주소처럼 동작합니다. 이 순간만 보면 배열명이 포인터처럼 보이는 게 맞습니다. 하지만 sizeof(arr)를 호출하면 포인터 크기(보통 8바이트)가 아니라 배열 전체 크기(예: int[5]면 20바이트)가 나옵니다. 또한 &arr는 타입이 int (*)[5](“정수 5개짜리 배열의 주소”)가 되어 int*와 다릅니다. 이 차이가 실무에서 버그를 줄이는 핵심 감각입니다.
인덱싱의 본질도 같은 맥락입니다. arr[i]는 사실상 *(arr + i)입니다. 즉 컴파일러는 “기준 주소 + i * 원소크기” 위치를 계산한 뒤 그 값을 역참조합니다. 그래서 포인터 p가 유효한 배열 구간을 가리키고 있다면 p[i]도 합법입니다. 심지어 문법적으로 i[arr]도 동치입니다(실무에서는 가독성 때문에 절대 쓰지 않지만, 개념적으로는 동일합니다). 이 사실은 “인덱스 접근 = 포인터 연산 + 역참조”라는 모델을 머릿속에 심어 줍니다.
함수로 넘어가면 더 중요해집니다. void f(int arr[])라고 쓰면 초보자는 “배열 전체가 전달되겠지?”라고 오해하기 쉽습니다. 하지만 C에서는 배열 인자는 포인터로 조정됩니다. 결국 void f(int *arr)와 같습니다. 따라서 함수 안에서 sizeof(arr)를 하면 배열 길이가 아니라 포인터 크기가 나옵니다. 길이 정보가 필요하면 size_t n을 별도 인자로 반드시 전달해야 합니다. “배열은 길이를 안고 다니지 않는다”는 원칙은 C에서 가장 자주 틀리는 부분 중 하나입니다.
또 하나의 실전 포인트는 재할당 가능성입니다. p = arr;는 가능하지만 arr = p;는 불가능합니다. 포인터 변수 p는 값(주소)을 바꿀 수 있는 변수이고, 배열명 arr는 선언 시 고정된 저장공간의 식별자이기 때문입니다. 이 차이를 모르면 함수 인자, 버퍼 처리, 문자열 처리에서 “왜 이 대입은 되는데 저건 안 되지?”라는 혼란이 반복됩니다.
정리하면, 배열과 포인터는 밀접하지만 동일하지 않습니다. 배열은 연속 데이터 저장소, 포인터는 그 저장소를 가리키는 주소값 변수입니다. 다만 C 문법이 배열명을 자주 포인터처럼 변환해 주기 때문에 “사용 경험”이 비슷하게 느껴질 뿐입니다. 이 경계를 분명히 이해하면 이후 포인터 산술, 동적 메모리, 함수 인터페이스 설계가 훨씬 명확해집니다.
기본 사용
예제 1) 배열명과 포인터의 차이 확인
#include <stdio.h>
int main(void) {
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // == &arr[0]
printf("arr = %p\n", (void*)arr);
printf("&arr[0] = %p\n", (void*)&arr[0]);
printf("p = %p\n", (void*)p);
printf("sizeof(arr) = %zu\n", sizeof(arr));
printf("sizeof(p) = %zu\n", sizeof(p));
return 0;
}
설명:
- 주소 출력은 같아 보여도
arr와p의 본질은 다르다. sizeof(arr)는 배열 전체 크기,sizeof(p)는 포인터 변수 크기다.- “값이 비슷하게 보이는 순간”과 “타입/의미가 다른 순간”을 함께 확인하는 예제다.
예제 2) 인덱싱과 포인터 연산의 동치
#include <stdio.h>
int main(void) {
int arr[4] = {3, 6, 9, 12};
for (int i = 0; i < 4; i++) {
printf("arr[%d]=%d, *(arr+%d)=%d\n", i, arr[i], i, *(arr + i));
}
int *p = arr;
p[2] = 99; // arr[2] 수정과 동일
*(p + 3) = 777; // arr[3] 수정과 동일
printf("after: %d %d %d %d\n", arr[0], arr[1], arr[2], arr[3]);
return 0;
}
설명:
arr[i]와*(arr+i)가 완전히 같은 결과를 낸다.- 포인터를 통해 수정하면 원본 배열이 바뀐다(같은 메모리를 보고 있기 때문).
- 인덱싱은 “마법”이 아니라 주소 계산의 문법적 표현임을 보여준다.
예제 3) 함수 인자에서 길이를 명시적으로 전달
#include <stdio.h>
#include <stddef.h>
int sum_array(const int *arr, size_t n) {
if (arr == NULL) return 0;
int sum = 0;
for (size_t i = 0; i < n; i++) {
sum += arr[i];
}
return sum;
}
int main(void) {
int scores[] = {85, 90, 78, 100};
size_t n = sizeof(scores) / sizeof(scores[0]);
printf("sum=%d\n", sum_array(scores, n));
return 0;
}
설명:
- 함수 시그니처에서 길이
n을 함께 받는 패턴이 표준적이고 안전하다. const int *arr는 읽기 전용 의도를 드러내서 인터페이스 품질을 높인다.- 호출부에서만
sizeof(scores)로 길이를 계산할 수 있고, 함수 내부에서는 불가능하다는 점이 중요하다.
자주 하는 실수
실수 1) 함수 안에서 sizeof(arr)로 배열 길이를 구하려고 함
- 원인: 함수 매개변수의
arr[]가 실제 배열이라고 오해함. - 해결: 함수는 포인터만 받으므로 길이 인자를 별도로 전달한다. 필요하면
size_t n을 강제하는 인터페이스를 설계한다.
실수 2) 배열명에 재할당 시도
- 원인: 배열을 일반 변수처럼 생각함.
- 해결: 배열명은 대입 대상이 아니다. 주소를 바꾸고 싶다면 포인터 변수를 사용한다.
실수 3) 포인터 인덱싱 시 경계 검사 누락
- 원인:
p[i]가 간단해 보여서 배열 범위를 벗어난 접근을 놓침. - 해결: 인덱스는 항상
0 <= i < n을 만족하게 관리하고, 함수 인자에 길이를 함께 전달한다.
실무 패턴
- 배열을 읽기만 하는 함수는
const T *data, size_t len시그니처를 기본값으로 사용한다. - 반복문은
for (size_t i = 0; i < len; ++i)형태를 고정해 오프바이원(Off-by-one) 실수를 줄인다. - 포인터 연산을 직접 쓸 때도 기준 주소와 끝 주소를 명시해 코드 리뷰 가독성을 높인다.
- 디버깅 시 “값 출력 + 주소 출력 + 길이 출력”을 함께 확인해 타입/경계 문제를 빨리 좁힌다.
- 인터페이스 문서에 “소유권(누가 메모리 해제하는지)”과 “유효 길이 기준”을 반드시 남긴다.
오늘의 결론
한 줄 요약: 배열은 저장공간이고 포인터는 주소값 변수다. 배열명이 포인터처럼 보이는 순간이 많지만, 타입과 의미를 구분하는 습관이 버그를 막는다.
연습문제
print_reverse(const int *arr, size_t n)함수를 작성해 배열을 역순으로 출력하세요. 인덱싱 방식과 포인터 산술 방식 두 버전으로 각각 구현해 보세요.max_in_array(const int *arr, size_t n, int *out_max)를 작성하세요. 입력 검증(NULL, n==0)을 포함하고, 실패/성공을 반환값으로 구분하세요.- 길이 5인 정수 배열에서 짝수 원소만 2배로 만드는 함수를 작성하세요. 함수는 원본 배열을 직접 수정해야 합니다.
이전 강의 정답
지난 26강(포인터 입문) 연습문제 예시 정답:
increment(int *x)
- 정답 포인트:
if (x == NULL) return 0; (*x)++; return 1; - 핵심: 역참조 전에 NULL 검사를 먼저 수행.
max_ptr(const int *a, const int *b, const int *c)
- 정답 포인트: 세 포인터 NULL 검사 후
*a, *b, *c비교. - 반환 타입은 값(int) 또는 실패 처리를 위한 상태코드+out-parameter 조합 중 하나를 명확히 선택.
swap_int(&x, &x)방어
- 정답 포인트:
if (a == NULL || b == NULL) return 0; if (a == b) return 1;후 swap 수행. - 핵심: 같은 주소 입력은 문제 상황이 아니라 “실질적으로 no-op”으로 안정 처리.
실습 환경/재현 정보
- 컴파일러: Apple clang 17.x 또는 GCC 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -pedantic -O2 - 실행 환경: macOS (arm64) / Linux (x86_64) 터미널
- 재현 체크:
clang -std=c11 -Wall -Wextra -pedantic -O2 lesson27.c -o lesson27- 주소/크기 출력으로 배열 vs 포인터 차이 확인
- 함수 인자에서 길이 미전달 시 발생하는 오류 케이스를 의도적으로 점검