[C 시리즈 9편] 배열과 포인터의 관계 이해하기

English version

8편에서 포인터 기초와 주소 감각을 익혔다면, 이제는 배열과 포인터가 실제로 어떻게 연결되는지 볼 차례입니다. C에서 배열 이름은 많은 상황에서 첫 번째 요소의 주소처럼 동작합니다. 그래서 배열을 이해하려면 포인터를 알아야 하고, 포인터를 실전에서 쓰려면 배열을 함께 봐야 합니다.

이번 글에서 새로 나오는 용어

  1. 배열 디케이 (Array Decay): 배열 이름이 식 안에서 첫 요소를 가리키는 포인터처럼 바뀌는 현상
  2. 포인터 산술 (Pointer Arithmetic): 포인터에 정수를 더하거나 빼서 다음 요소 주소로 이동하는 연산
  3. 배열 인자 (Array Parameter): 함수에서 배열처럼 보이지만 실제로는 포인터처럼 전달되는 매개변수
  4. 길이 정보 (Length Information): 배열 전체를 다루기 위해 별도로 함께 전달해야 하는 원소 개수

핵심 개념

학습 메모

  • 소요 시간: 60~70분
  • 준비물: 배열 반복문과 포인터 선언/역참조 경험
  • 학습 목표: 배열 이름과 포인터의 관계를 설명하고, 배열을 함수에 안전하게 전달하기

이번 글의 핵심은 네 가지입니다.

  • 배열 이름은 많은 표현식에서 첫 요소의 주소처럼 동작한다.
  • arr[i]*(arr + i)는 같은 요소를 가리킨다.
  • 포인터 산술은 자료형 크기를 고려해 이동한다.
  • 함수에 배열을 넘길 때는 길이 정보를 함께 주는 습관이 중요하다.

코드로 따라하기

배열 이름과 첫 요소 주소 비교

#include <stdio.h>

int main(void) {
    int numbers[3] = {10, 20, 30};

    printf("numbers = %p\n", (void *)numbers);
    printf("&numbers[0] = %p\n", (void *)&numbers[0]);
    printf("sizeof(numbers) = %zu\n", sizeof(numbers));
    printf("sizeof(&numbers[0]) = %zu\n", sizeof(&numbers[0]));
    return 0;
}

출력되는 주소값은 같게 보일 수 있습니다. 하지만 sizeof(numbers)는 배열 전체 크기이고, sizeof(&numbers[0])는 포인터 크기입니다. 즉, 배열 이름이 포인터처럼 동작하는 순간이 많더라도 배열 자체와 포인터가 완전히 같은 것은 아닙니다. 더 정확히 말하면, 배열 이름은 많은 식에서 첫 원소를 가리키는 포인터처럼 변환되지만 배열 타입 자체가 포인터 타입으로 바뀌는 것은 아닙니다.

arr[i]*(arr + i)

#include <stdio.h>

int main(void) {
    int numbers[4] = {3, 6, 9, 12};
    int i;

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

    return 0;
}

numbers + ii번째 요소의 주소를 가리키고, 그 앞에 *를 붙이면 실제 값을 읽습니다. 그래서 numbers[i]*(numbers + i)는 결과가 같습니다.

포인터 산술은 자료형 크기를 고려한다

#include <stdio.h>

int main(void) {
    int values[3] = {100, 200, 300};
    int *ptr = values;

    printf("ptr = %p\n", (void *)ptr);
    printf("ptr + 1 = %p\n", (void *)(ptr + 1));
    printf("*(ptr + 1) = %d\n", *(ptr + 1));
    return 0;
}

ptr + 1은 주소 숫자를 1만큼 더하는 것이 아니라, int 한 칸만큼 다음 요소로 이동합니다. 그래서 포인터 산술은 자료형과 연결해서 읽어야 합니다. 그리고 이런 이동은 같은 배열 범위 안에서 다룰 때만 의미가 있습니다.

함수에 배열 넘기기

배열을 함수 인자로 넘기면, 함수 쪽에서는 보통 포인터처럼 받게 됩니다.

#include <stdio.h>

void print_scores(int scores[], int length) {
    int i;

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

int main(void) {
    int scores[4] = {95, 82, 74, 63};

    print_scores(scores, 4);
    return 0;
}

여기서 scores[]처럼 적었지만, 함수 안에서는 첫 요소 주소를 받는 것과 비슷하게 동작합니다. 그래서 길이 정보 4를 따로 함께 넘겨 주는 습관이 중요합니다. 배열 값은 따라오지만 배열 전체 길이 정보는 자동으로 따라오지 않는다고 생각하면 이해하기 쉽습니다.

읽기 전용 배열 인자 만들기

함수가 배열 값을 바꾸지 않을 것이라면 const를 붙여 의도를 더 분명히 할 수 있습니다.

#include <stdio.h>

void print_total(const int numbers[], int length) {
    int i;
    int total = 0;

    for (i = 0; i < length; i++) {
        total += numbers[i];
    }

    printf("total = %d\n", total);
}

int main(void) {
    int numbers[3] = {5, 10, 15};

    print_total(numbers, 3);
    return 0;
}

const는 이 함수 안에서 배열 값을 바꾸지 않겠다는 약속입니다. 이렇게 의도를 적어 두면 실수를 줄이는 데 도움이 됩니다. 다만 const가 붙는다고 배열 길이 정보가 보존되는 것은 아니므로, 길이는 여전히 따로 넘겨야 합니다.

실전 예시: 최고 점수 찾기

#include <stdio.h>

int find_max(const int values[], int length) {
    int i;
    int max = values[0];

    for (i = 1; i < length; i++) {
        if (values[i] > max) {
            max = values[i];
        }
    }

    return max;
}

int main(void) {
    int scores[5] = {72, 88, 91, 67, 85};

    printf("max = %d\n", find_max(scores, 5));
    return 0;
}

이 예제는 배열을 함수로 넘기고, 반복문과 조건문을 함께 써서 최고 점수를 찾습니다. 지금까지 배운 여러 주제가 한 흐름으로 연결된다는 점을 확인하기 좋은 예제입니다.

왜 중요한가

배열과 포인터의 관계를 이해해야 함수 인자 전달, 문자열 처리, 동적 메모리 할당을 자연스럽게 배울 수 있습니다. 여기서 막히면 이후의 C 주제가 모두 따로따로 보이기 쉽습니다.

이번 글에서 꼭 남겨야 할 핵심은 네 가지입니다.

  • 배열 이름은 많은 상황에서 첫 요소 주소처럼 동작한다.
  • [] 표기와 포인터 산술은 연결되어 있다.
  • 배열을 함수에 넘길 때는 길이 정보를 따로 줘야 한다.
  • const를 이용하면 읽기 전용 의도를 더 분명하게 표현할 수 있다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. starter를 fork한 뒤 hello.c 같은 실습 파일을 만든다
  2. 본문 코드를 붙여 넣고 clang 또는 gcc가 있으면 직접 컴파일한다
  3. 코드를 고친 뒤 다시 빌드하고 실행 결과를 비교한다

C는 브라우저 미리보기보다 터미널 빌드 흐름이 핵심입니다. Universal starter의 컴파일러 구성이 환경에 따라 다를 수 있으니, 먼저 clang이나 gcc 사용 가능 여부부터 확인하세요.

실습

  • 따라 하기: 본문 예제를 실행해 numbers&numbers[0]의 주소 출력이 어떻게 보이는지 확인합니다.
  • 확장하기: find_max 함수를 바꿔 최고 점수 대신 최저 점수를 찾는 find_min 함수를 만들어 봅니다.
  • 디버깅: 함수에 배열 길이를 일부러 잘못 넘겨 보고, 왜 출력이나 결과가 이상해질 수 있는지 설명해 봅니다.
  • 완료 기준: 배열 이름, 포인터 산술, 길이 정보 전달의 의미를 각각 설명하고, 배열을 함수에 넘기는 코드를 직접 작성할 수 있으면 됩니다.

마무리

이번 글에서는 배열과 포인터가 왜 늘 함께 등장하는지 살펴봤습니다. 중요한 것은 둘을 완전히 같은 것으로 외우는 것이 아니라, "배열 이름이 많은 상황에서 첫 요소 주소처럼 동작한다"는 관계를 이해하는 것입니다. 다음 글에서는 이렇게 여러 값을 한 덩어리로 묶어 더 의미 있는 데이터 구조를 만드는 struct, enum, typedef를 살펴보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요