C를 처음 배울 때 모두가 헷갈리는 것
C 언어를 공부하다 보면 누구나 한 번쯤 멈칫하는 순간이 있어요. 바로 "배열과 포인터는 같은 건가, 다른 건가?"라는 질문이거든요. 교과서에서는 "배열 이름은 포인터처럼 동작한다"고 가볍게 넘어가는데, 막상 코드를 짜다 보면 컴파일러가 이상한 에러를 뱉어내거나, 예상과 전혀 다르게 동작하는 경우가 한두 번이 아니에요. 최근 한 개발자가 자신의 블로그에 올린 글에서 이 "C 배열 타입의 기묘함"을 아주 꼼꼼하게 파헤쳤는데, 읽어보면 그동안 막연하게 알고 있던 부분이 명쾌해지는 느낌이 들어요.
배열 타입은 정말 별난 녀석
핵심부터 말씀드리면, C의 배열 타입은 다른 어떤 언어와도 다르게 동작하는 1급 시민이 아닌 시민 같은 존재예요. 무슨 말이냐면, 우리가 int arr[10]이라고 선언하면 분명히 int[10]이라는 타입이 만들어지긴 해요. 그런데 이 타입을 가진 변수를 거의 모든 표현식에서 사용하는 순간, 컴파일러가 자동으로 첫 번째 원소를 가리키는 포인터, 즉 int*로 슬쩍 바꿔버리거든요. 이걸 "배열-포인터 감쇠(array-to-pointer decay)"라고 불러요. 마치 마트에서 산 통조림이 계산대를 통과하는 순간 자동으로 포장이 벗겨져서 알맹이만 나오는 느낌이라고 보시면 돼요.
그런데 이 감쇠가 일어나지 않는 예외 상황이 몇 가지 있어요. 대표적으로 sizeof 연산자 안에 넣으면 진짜 배열 크기를 돌려줘요. 그래서 sizeof(arr)는 40(int가 4바이트일 때)을 주지만, 그 배열을 함수 인자로 넘긴 다음 함수 안에서 sizeof를 찍어보면 8(64비트 환경에서 포인터 크기)이 나오는 거죠. 같은 변수처럼 보이는데 결과가 완전히 다르니 초보자가 헷갈릴 수밖에 없어요. 또 하나 예외는 & 연산자로 주소를 얻을 때예요. &arr의 타입은 int**가 아니라 int (*)[10], 즉 "10개짜리 int 배열을 가리키는 포인터"라는 굉장히 어색한 타입이 나와요.
함수 매개변수의 비밀
더 재미있는 건 함수 매개변수에서 일어나는 일이에요. C에서는 void foo(int arr[10])이라고 써도, void foo(int arr[])라고 써도, 심지어 void foo(int *arr)라고 써도 컴파일러는 셋을 똑같이 취급해요. 매개변수에 적힌 배열 표기는 그냥 "문서화" 수준의 의미만 가지고 실제로는 모두 포인터로 바뀌어버리거든요. 그래서 함수 안에서는 원래 배열의 길이를 알 길이 없고, 항상 별도로 길이 인자를 받아야 하는 거예요. 이게 바로 C에서 그 수많은 버퍼 오버플로 취약점이 생기는 근본 원인 중 하나예요.
반면에 "VLA(Variable Length Array)"라는 C99에서 추가된 기능을 쓰면 조금 다른 그림이 나와요. void foo(int n, int arr[n]) 같은 식으로 길이를 변수로 받아서 매개변수에 명시할 수 있는데, 여기서 n은 컴파일 타임이 아니라 런타임에 결정돼요. 다만 C11부터는 VLA가 선택적(optional) 기능으로 바뀌어서, MSVC 같은 컴파일러는 아예 지원하지 않아요. 그래서 크로스플랫폼 코드를 짤 때는 VLA를 피하는 게 일반적이에요.
다차원 배열이라는 또 다른 함정
2차원 배열로 가면 상황이 더 복잡해져요. int matrix[3][4]라고 선언하면 메모리에는 12개의 int가 연속해서 깔리는데, 이걸 함수에 넘길 때는 int matrix[][4]처럼 두 번째 차원부터는 반드시 크기를 알려줘야 해요. 왜냐하면 컴파일러가 matrix[i][j]라는 표현을 보고 어디로 점프해야 할지 계산하려면 한 행의 길이를 알아야 하거든요. 첫 번째 차원만 빼고 나머지는 다 알려줘야 한다는 이 비대칭이 또 한 번 사람들을 헷갈리게 만들죠.
다른 언어와 비교해보면
현대 언어들은 이 문제를 깔끔하게 해결했어요. Rust는 [T; N] 타입에 길이가 타입 시스템에 박혀 있어서 컴파일러가 경계 검사를 도와줘요. Go는 슬라이스(slice)라는 개념으로 길이와 용량을 함께 들고 다니죠. Python의 리스트나 JavaScript의 배열은 아예 객체라서 .length 속성을 언제든 물어볼 수 있고요. C에서만 유독 배열이 "있는 듯 없는 듯" 어색하게 자리 잡고 있는데, 이건 1970년대 PDP-11 시절의 메모리 효율을 극단적으로 추구하면서 만들어진 설계 결정이 50년이 지난 지금까지 그대로 남아 있는 거예요.
한국 개발자에게 주는 시사점
요즘 신규 프로젝트에서 C를 처음부터 선택하는 경우는 많지 않지만, 임베디드, 커널 모듈, 게임 엔진 일부, 그리고 수많은 오픈소스 라이브러리는 여전히 C로 짜여 있어요. 안드로이드의 NDK를 만지거나, 리눅스 디바이스 드라이버를 분석하거나, FFmpeg/SQLite 같은 라이브러리의 내부를 들여다볼 일이 있다면 이 배열-포인터의 미묘한 관계를 정확히 알아두는 게 큰 도움이 돼요. 특히 sizeof로 길이를 구하는 매크로 sizeof(a)/sizeof(a[0])가 함수에 넘긴 배열에서는 절대 동작하지 않는다는 점, 이거 하나만 확실히 기억해도 디버깅 시간을 한참 줄일 수 있거든요.
또 요즘 Rust로 갈아타는 분들이 많은데, Rust의 슬라이스나 배열 타입을 처음 만났을 때 "왜 이렇게 깐깐하게 굴지?" 싶다면 C에서 겪었던 이런 모호함이 안전하지 않다는 걸 컴파일러가 정면으로 막아주려는 거라고 생각하시면 이해가 빨라요.
마무리
C의 배열은 진짜 배열이라기보다는 "포인터 산술을 편하게 쓰라고 만들어준 문법 설탕"에 가깝다고 보는 게 정확해요. 이 사실을 알고 코드를 읽으면 그동안 이해 안 됐던 수많은 C 코드가 한순간에 풀리거든요.
여러분은 C를 공부하면서 배열과 포인터 때문에 가장 크게 데인 경험이 있으신가요? 혹은 다른 언어로 넘어가면서 "아 이래서 그렇게 만들었구나" 하고 무릎을 친 순간이 있다면 댓글로 공유해주세요.
🔗 출처: Hacker News
"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"
실제 수강생 후기- 비전공자도 6개월이면 첫 수익
- 20년 경력 개발자 직강
- 자동화 프로그램 + 소스코드 제공