지난 편에서 포인터의 기본 개념, 즉 “주소를 저장하는 변수”라는 것을 배웠습니다. 이번 편에서는 그 포인터가 배열, 그리고 함수와 만났을 때 어떤 일이 벌어지는지 살펴보겠습니다. C언어를 어느 정도 익힌 사람도 “배열 이름이 곧 포인터다”라는 말을 들으면 헷갈려하는데, 오늘 예제를 하나씩 따라가다 보면 자연스럽게 이해가 될 것입니다.

배열 이름은 사실 주소다

배열을 선언하면 C언어는 메모리 상에 연속된 공간을 확보합니다. 그런데 재미있는 사실은, 배열의 이름 자체가 배열의 첫 번째 요소의 주소로 취급된다는 점입니다. 즉 arr이라는 배열이 있다면, arr은 &arr[0]과 같은 의미를 가집니다.

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};

    printf("arr의 주소: %p\n", (void *)arr);
    printf("&arr[0]의 주소: %p\n", (void *)&arr[0]);

    return 0;
}

이 코드를 실행하면 두 줄에 찍히는 주소값이 완전히 똑같이 나옵니다. 배열 이름을 그대로 쓰면 컴파일러가 알아서 “첫 요소의 주소”로 취급해주기 때문입니다.

포인터로 배열 순회하기

배열 이름이 주소라는 사실을 알면, 포인터를 이용해서 배열 요소에 접근하는 것도 가능해집니다. 이때 핵심은 “포인터 연산”입니다. 포인터에 1을 더하면 단순히 주소값에 1이 더해지는 것이 아니라, 그 포인터가 가리키는 자료형의 크기만큼 이동합니다.

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    int *ptr = arr; // 배열 이름을 포인터에 그대로 대입

    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d, *(ptr + %d) = %d\n", i, arr[i], i, *(ptr + i));
    }

    return 0;
}

이 코드를 컴파일하고 실행하면 다음과 같은 결과를 볼 수 있습니다.

arr[0] = 10, *(ptr + 0) = 10
arr[1] = 20, *(ptr + 1) = 20
arr[2] = 30, *(ptr + 2) = 30
arr[3] = 40, *(ptr + 3) = 40
arr[4] = 50, *(ptr + 4) = 50

ptr은 int형 포인터이기 때문에, ptr + 1은 실제 메모리 주소로는 4바이트(대부분의 환경에서 int 크기)만큼 뒤로 이동합니다. 그래서 *(ptr + i)가 arr[i]와 정확히 같은 값을 가리키게 되는 것이죠. 사실 C언어 내부적으로는 arr[i]라는 표현 자체가 *(arr + i)로 변환되어 동작합니다. 즉 배열 인덱싱과 포인터 연산은 결국 같은 원리입니다.

배열을 함수로 넘길 때 벌어지는 일

배열을 함수의 인자로 넘길 때는 조금 특별한 일이 벌어집니다. 배열 전체가 복사되는 것이 아니라, 배열의 첫 주소만 함수로 전달됩니다. 그래서 함수 안에서 배열의 요소를 바꾸면, 원본 배열도 함께 바뀝니다.

#include <stdio.h>

void doubleAll(int *arr, int size) {
    for (int i = 0; i < size; i++) {
        arr[i] = arr[i] * 2;
    }
}

int main(void) {
    int numbers[5] = {1, 2, 3, 4, 5};

    doubleAll(numbers, 5);

    for (int i = 0; i < 5; i++) {
        printf("numbers[%d] = %d\n", i, numbers[i]);
    }

    return 0;
}

이 코드를 실행하면 다음과 같은 결과가 나옵니다.

numbers[0] = 2
numbers[1] = 4
numbers[2] = 6
numbers[3] = 8
numbers[4] = 10

doubleAll 함수의 매개변수는 int *arr로 선언되어 있지만, void doubleAll(int arr[], int size)처럼 배열 형태로 써도 완전히 동일하게 동작합니다. 컴파일러 입장에서는 두 표기법이 사실상 같은 의미이기 때문입니다. 다만 함수 안에서 sizeof(arr)을 해봐도 배열 전체의 크기가 아니라 포인터 하나의 크기(보통 8바이트)만 나온다는 점은 꼭 기억해두어야 합니다. 그래서 배열을 함수로 넘길 때는 반드시 위 예제처럼 크기 정보를 별도의 인자로 함께 넘겨줘야 합니다.

코드 동작 설명 및 예상 실행 결과

  • int *ptr = arr;처럼 배열 이름을 포인터에 대입하면, ptr은 배열의 첫 번째 요소를 가리키게 됩니다.
  • *(ptr + i)는 ptr이 가리키는 위치에서 i칸만큼 뒤로 이동한 곳의 값을 의미하며, 이는 arr[i]와 완전히 같은 결과를 냅니다.
  • doubleAll(numbers, 5)처럼 배열을 함수에 넘기면, 실제로는 배열의 시작 주소만 전달되므로 함수 안에서 요소를 수정하면 원본 배열에도 그대로 반영됩니다.
  • 위 세 예제 모두 gcc 파일명.c -o 실행파일로 컴파일한 뒤 실행하면 본문에 제시된 결과와 동일하게(주소값 제외) 출력됩니다.

핵심 요약

  • 배열의 이름은 그 배열 첫 번째 요소의 주소로 취급되며, &arr[0]과 값이 같습니다.
  • arr[i]와 *(ptr + i)는 원리적으로 동일한 연산이며, 포인터 연산은 가리키는 자료형의 크기만큼 이동합니다.
  • 배열을 함수 인자로 넘기면 배열 전체가 복사되지 않고 시작 주소만 전달되므로, 함수 안에서 배열을 수정하면 원본에도 영향을 줍니다.
  • 함수 안에서는 배열의 전체 크기를 알 수 없으므로, 배열의 길이는 반드시 별도의 인자로 함께 전달해야 합니다.

다음 편에서는 서로 다른 자료형의 데이터를 하나로 묶어서 관리할 수 있게 해주는 “구조체와 공용체”를 주제로, 여러 정보를 하나의 단위로 다루는 방법을 알아보겠습니다.