[C언어 50강] 21강. 배열과 함수: 배열 전달, 길이 전달 패턴
배열을 함수로 넘기는 순간, 많은 초보자가 처음으로 “C는 생각보다 엄격한 언어구나”를 체감합니다. 변수 하나를 넘길 때는 값이 복사된다는 규칙이 비교적 단순하지만, 배열은 함수 인자에서 동작 방식이 달라지기 때문입니다. 오늘은 배열 전달의 본질(배열→포인터 변환), 길이를 왜 항상 함께 넘겨야 하는지, 실무에서 안전하게 쓰는 시그니처 패턴을 개념 중심으로 정리하겠습니다.
핵심 개념
- 함수 인자에서 배열은 대부분 포인터로 변환(decay)되므로, 함수 안에서는 원본 배열의 전체 크기 정보를 자동으로 알 수 없다.
- 배열을 안전하게 다루려면 데이터 포인터와 길이(
size_t n)를 항상 쌍으로 전달해야 한다. - “읽기 전용인지/수정 가능한지”를 시그니처에서
const로 명확히 표현하면 버그와 오해를 크게 줄일 수 있다.
개념 먼저 이해하기
int a[5]를 선언하면 우리는 길이 5짜리 정수 배열을 갖습니다. 여기까지는 분명합니다. 문제는 이 배열을 함수로 전달하는 순간입니다. void f(int arr[])처럼 보이는 선언은 직관적으로 “배열을 받는 함수” 같지만, C 표준 관점에서는 사실상 void f(int *arr)와 같은 의미입니다. 즉 함수 인자에서 arr는 배열 자체가 아니라 첫 원소를 가리키는 포인터로 동작합니다. 이 지점이 핵심입니다.
이 변환 때문에 함수 내부에서 sizeof(arr)를 찍으면 배열 전체 크기가 아니라 포인터 크기(64비트 환경에서 보통 8바이트)가 나옵니다. 초보자가 가장 많이 당하는 함정이 바로 이것입니다. 호출자 쪽에서는 분명 길이 100짜리 배열을 넘겼는데, 피호출자 함수는 그 사실을 알 길이 없습니다. 그래서 “배열과 길이를 함께 전달하라”는 규칙이 C에서 거의 절대 규칙처럼 취급됩니다. sum(arr, n), find_max(arr, n), sort(arr, n)처럼 길이를 분리 전달하는 이유는 관습이 아니라 언어 특성에서 강제되는 설계입니다.
여기서 한 발 더 들어가면, 길이 타입으로 왜 int보다 size_t를 권장하는지도 이해됩니다. 길이는 음수가 될 수 없고, sizeof 결과 타입도 size_t이기 때문입니다. 즉 타입 체계까지 일관되게 맞춰야 경고가 줄고, 아주 큰 데이터에서도 안전합니다. 또한 함수가 배열을 읽기만 한다면 const int *arr 또는 const int arr[]로 선언해야 합니다. 이 const 하나가 “이 함수는 입력을 파괴하지 않는다”는 계약이 되어 코드 리뷰와 유지보수에서 큰 신뢰를 만듭니다.
배열 전달을 제대로 이해하면 설계가 훨씬 깔끔해집니다. 예를 들어 “결과를 반환”해야 할 때, 단순 타입이면 반환값으로 충분하지만, 여러 결과를 내야 하면 out-parameter(출력 포인터)를 추가합니다. 이때도 길이·버퍼 크기 계약을 함께 표현해야 합니다. int format(char *buf, size_t cap, ...)처럼 말이죠. C에서 함수 설계는 결국 “메모리 범위 계약”을 문장으로 적는 일과 같습니다. 배열 전달은 그 계약의 출발점입니다.
마지막으로 중요한 오해 하나를 정리하면, “배열을 함수에 넘기면 복사된다”는 말은 일반적으로 틀립니다. 배열 전체가 자동 복사되는 것이 아니라 첫 원소 주소만 전달됩니다. 그래서 함수에서 arr[0] = 999;를 하면 원본이 바뀝니다. 이 특성은 성능 면에서는 유리하지만, 사이드 이펙트 관리 실패로 이어지기 쉽습니다. 그래서 실무에서는 읽기 함수/수정 함수를 분리하고, 함수명에도 의도를 드러냅니다(calc_sum, normalize_inplace 등). 즉 배열 전달의 핵심은 문법이 아니라 공유 메모리를 다루는 책임 모델을 이해하는 데 있습니다.
기본 사용
예제 1) 최소 동작 예제
#include <stdio.h>
#include <stddef.h>
int sum_array(const int arr[], size_t n) {
int sum = 0;
for (size_t i = 0; i < n; i++) {
sum += arr[i];
}
return sum;
}
int main(void) {
int data[] = {10, 20, 30, 40, 50};
size_t n = sizeof(data) / sizeof(data[0]);
printf("sum = %d\n", sum_array(data, n));
return 0;
}
설명:
sum_array는 배열 포인터와 길이를 함께 받습니다. 이것이 C 배열 처리의 기본형입니다.- 호출부에서
sizeof(data)/sizeof(data[0])로 길이를 계산하는 패턴은 가장 안전하고 흔한 방식입니다. const를 붙여 함수가 데이터를 읽기만 함을 계약으로 고정했습니다.
예제 2) 실무에서 자주 맞닥뜨리는 패턴
#include <stdio.h>
#include <stdbool.h>
#include <stddef.h>
bool find_max(const int arr[], size_t n, int *out_max) {
if (arr == NULL || out_max == NULL || n == 0) {
return false;
}
int m = arr[0];
for (size_t i = 1; i < n; i++) {
if (arr[i] > m) {
m = arr[i];
}
}
*out_max = m;
return true;
}
int main(void) {
int scores[] = {72, 88, 91, 65, 99, 84};
size_t n = sizeof(scores) / sizeof(scores[0]);
int max_value = 0;
if (find_max(scores, n, &max_value)) {
printf("max = %d\n", max_value);
} else {
puts("입력이 유효하지 않습니다.");
}
return 0;
}
설명:
- 실패 가능성이 있는 함수는
bool반환으로 성공/실패를 표현하고, 실제 결과는 out-parameter로 전달하는 패턴이 실무에서 자주 쓰입니다. NULL/n==0검사를 초기에 수행해 계약 위반 입력을 빠르게 차단합니다.- “무조건 값 반환”보다 “실패를 모델링한 반환 규약”이 장기 유지보수에 훨씬 안전합니다.
예제 3) 디버깅 포인트 포함 예제
#include <stdio.h>
#include <stddef.h>
void wrong_size_demo(int arr[]) {
printf("inside sizeof(arr) = %zu\n", sizeof(arr));
}
void scale_inplace(int arr[], size_t n, int factor) {
for (size_t i = 0; i < n; i++) {
arr[i] *= factor;
}
}
int main(void) {
int nums[] = {1, 2, 3, 4};
size_t n = sizeof(nums) / sizeof(nums[0]);
printf("outside sizeof(nums) = %zu\n", sizeof(nums));
wrong_size_demo(nums);
scale_inplace(nums, n, 10);
for (size_t i = 0; i < n; i++) {
printf("%d ", nums[i]);
}
puts("");
return 0;
}
설명:
wrong_size_demo에서sizeof(arr)는 포인터 크기입니다. 이 출력 차이를 눈으로 보면 배열 decay를 확실히 기억할 수 있습니다.scale_inplace는 원본 배열을 직접 수정합니다. 즉 함수 호출 뒤 데이터가 바뀌는 사이드 이펙트를 의도적으로 보여줍니다.- 디버깅 시 “왜 값이 바뀌었지?”가 나오면, 전달 방식(값/포인터)과
const유무부터 먼저 확인하세요.
자주 하는 실수
실수 1) 함수 내부에서 sizeof(arr)로 길이 계산
- 원인:
- 배열을 전달했으니 함수 안에서도 배열 크기를 알 수 있다고 착각함.
- 해결:
- 길이는 반드시 인자로 받는다.
- 함수 시그니처를
func(const int arr[], size_t n)처럼 고정 패턴화한다.
실수 2) 길이를 int로 받고 음수/오버플로 경계 무시
- 원인:
- 작은 예제 습관이 실무 코드로 이어져 타입 안정성을 놓침.
- 해결:
- 길이는
size_t를 기본으로 사용. - 외부 입력에서 온 길이는 상한 검증 후 사용.
- 길이는
실수 3) 읽기 함수인데 const를 생략
- 원인:
- “지금은 안 바꾸니까 괜찮다”는 느슨한 태도.
- 해결:
- 읽기 전용 함수는 매개변수에
const를 반드시 붙인다. - const-correctness를 팀 코드리뷰 체크리스트에 포함한다.
- 읽기 전용 함수는 매개변수에
실무 패턴
- 배열+길이 한 쌍 고정: API 설계 시 포인터 단독 전달을 금지하고 길이 인자를 강제한다.
- 입력/출력 의도 분리: 입력은
const, 출력은 out-parameter로 명시하여 부작용을 문서화한다. - 계약 기반 방어코드:
NULL,n==0, 버퍼 용량 초과 가능성 등을 함수 시작부에서 검증한다. - 이름으로 의도 드러내기: 수정 함수는
_inplace, 읽기 함수는get/calc/find계열 이름으로 구분한다. - 호출부 길이 계산 통일:
ARRAY_LEN(x)매크로나sizeof(x)/sizeof(x[0])패턴으로 반복 실수를 줄인다.
오늘의 결론
한 줄 요약: C에서 배열 전달은 “배열을 넘긴다”가 아니라 “주소와 범위 계약을 넘긴다”로 이해해야 안전한 함수 설계가 가능하다.
연습문제
int배열의 평균을 구하는bool avg_array(const int arr[], size_t n, double *out_avg)를 작성하세요.n==0일 때 실패를 반환하세요.- 배열 원소를 오름차순 정렬하는 함수를 만들고, 왜 길이 인자가 없으면 안전하게 구현할 수 없는지 설명하세요.
- 읽기 전용 함수와 수정 함수 각각 하나씩 만들어
const가 컴파일 단계에서 어떤 실수를 막아주는지 확인해보세요.
이전 강의 정답
- 20강(배열 2차원) 연습문제 핵심 정리
b + 1은 “다음 행”으로 이동하고,&b[0][0] + 1은 정수 한 칸 이동입니다.&b[1][0]은 결과적으로b+1이 가리키는 행의 첫 원소 주소와 같은 위치 의미를 가집니다.- 열 합을 구하더라도 실제 접근은 row-major를 고려해 행 우선 순회를 유지하는 쪽이 캐시 친화적입니다(필요 시 중간 누적 배열 사용).
- 2차원 배열을 함수 인자로 받을 때 열 크기 정보가 있어야 컴파일러가
a[i][j]의 주소 계산(행 폭)을 정확히 수행할 수 있습니다.
실습 환경/재현 정보
- 컴파일러: clang 17+ 또는 gcc 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -O0 -g - 실행 환경: macOS/Linux 터미널
- 재현 체크:
- 예제 3에서
outside sizeof(nums)와inside sizeof(arr)값 차이를 확인 scale_inplace호출 후 원본 배열 값이 실제 변경되는지 확인find_max에n=0또는NULL입력 시 실패 처리 분기가 정상 동작하는지 확인
- 예제 3에서