[C언어 50강] 35강. 구조체 심화: 구조체 배열, 구조체 포인터, -> 연산자

[C언어 50강] 35강. 구조체 심화: 구조체 배열, 구조체 포인터, -> 연산자

핵심 개념

  • 구조체 배열은 "같은 레코드 타입의 연속 저장소"이며, 인덱스로 개별 레코드에 접근한다.
  • 구조체 포인터는 큰 구조체 복사 비용을 줄이고, 함수가 원본 데이터를 수정할 수 있게 해 준다.
  • -> 연산자는 "포인터가 가리키는 구조체의 멤버"에 접근하는 문법 설탕이며, (*ptr).member와 동일하다.

개념 먼저 이해하기

34강에서 구조체를 "관련된 값을 하나의 타입으로 묶는 모델링 도구"로 배웠다면, 이번 강의는 그 구조체를 여러 개 다루는 방법에 집중합니다. 실무 코드에서 구조체 변수 하나만 쓰는 경우는 드뭅니다. 대부분은 학생 목록, 주문 목록, 센서 샘플 목록처럼 "동일한 형태의 데이터가 여러 개" 존재합니다. 이때 가장 먼저 만나는 도구가 구조체 배열입니다. struct Student students[100];처럼 선언하면 메모리에는 Student 레코드 100개가 연속으로 놓입니다. 즉, 배열의 장점(빠른 인덱싱)과 구조체의 장점(명확한 필드 의미)을 동시에 가져갑니다.

여기서 중요한 관점은 "배열의 원소 타입이 int에서 struct로 바뀌었을 뿐"이라는 점입니다. students[i]는 이제 정수가 아니라 구조체 한 개입니다. 그래서 students[i].id, students[i].name처럼 멤버를 한 단계 더 들어가서 접근합니다. 이 계층적 접근은 처음엔 길어 보이지만, 코드 의미가 훨씬 또렷해집니다. names[i], scores[i], ages[i]를 따로 관리하던 방식보다 데이터 불일치 위험이 크게 줄어듭니다.

다음으로 구조체 포인터가 왜 필요한지 봅시다. C에서 함수 인자는 기본적으로 값 전달입니다. void update(struct Student s)라고 쓰면 호출 시 구조체 전체가 복사됩니다. 구조체가 작을 때는 부담이 작지만, 필드가 많거나 배열을 내부에 가진 큰 구조체는 복사 비용이 무시되지 않습니다. 또한 복사본을 수정해도 원본은 바뀌지 않습니다. 반대로 void update(struct Student *s)처럼 포인터를 받으면 함수는 원본 메모리를 직접 다룰 수 있습니다. 이때 멤버 접근을 s->id처럼 쓰는데, 이 문법이 바로 이번 강의의 핵심인 ->입니다.

->를 암기형 문법으로만 외우면 금방 헷갈립니다. 본질은 간단합니다. 포인터 p가 구조체를 가리킬 때:

  • (*p).id : 먼저 역참조해서 구조체 값으로 만든 뒤, 그 구조체의 id 멤버 접근
  • p->id : 위 표현의 축약형

둘은 완전히 같습니다. 단지 *.의 우선순위 때문에 괄호가 필요하고, 그 불편함을 줄이려고 ->가 생겼습니다. 그래서 *p.id 같은 코드는 의도와 다르게 해석되어 컴파일 에러를 만들기 쉽습니다. "포인터로 멤버 접근이면 무조건 ->"라는 습관이 초반엔 안전합니다.

또 하나의 실무 포인트는 "배열을 함수에 넘길 때 거의 항상 포인터처럼 동작한다"는 사실입니다. void print_all(struct Student arr[], size_t n)void print_all(struct Student *arr, size_t n)는 함수 매개변수 문맥에서 거의 같은 의미입니다. 즉, 배열 이름은 첫 원소의 주소로 decay(변환)됩니다. 그래서 길이 정보가 자동으로 같이 가지 않습니다. 호출 측에서 반드시 n을 별도로 넘겨야 하며, 함수 내부에서 sizeof(arr)로 원소 수를 계산하면 대부분 틀립니다. 이 실수는 구조체 배열에서도 그대로 반복됩니다.

정리하면 이번 주제는 "문법 세 개"가 아니라 "데이터 집합을 다루는 사고"입니다. 구조체 배열로 레코드 컬렉션을 만들고, 포인터로 효율과 수정 가능성을 확보하고, ->로 가독성과 정확성을 얻습니다. 이 감각이 잡히면 다음 강의의 typedef/enum/union 같은 타입 설계 도구를 더 자연스럽게 받아들일 수 있습니다.

기본 사용

예제 1) 구조체 배열 순회와 집계

#include <stdio.h>

struct Student {
    int id;
    int score;
};

int main(void) {
    struct Student students[3] = {
        {.id = 101, .score = 87},
        {.id = 102, .score = 92},
        {.id = 103, .score = 76}
    };

    int sum = 0;
    for (int i = 0; i < 3; ++i) {
        sum += students[i].score;
        printf("id=%d, score=%d\n", students[i].id, students[i].score);
    }

    printf("avg=%.2f\n", sum / 3.0);
    return 0;
}

설명:

  • students[i]는 구조체 한 개이므로 멤버 접근은 .를 쓴다.
  • 구조체 배열은 메모리에 연속 저장되므로 순차 순회가 자연스럽고 캐시 친화적이다.
  • 병렬 배열(아이디 배열, 점수 배열 분리)보다 데이터 일관성을 유지하기 쉽다.

예제 2) 구조체 포인터 + ->로 원본 수정

#include <stdio.h>

struct Account {
    int id;
    long balance;
};

void deposit(struct Account *acc, long amount) {
    if (acc == NULL || amount <= 0) return;
    acc->balance += amount; // (*acc).balance += amount; 와 동일
}

int main(void) {
    struct Account a = {.id = 7, .balance = 1000};

    deposit(&a, 250);
    printf("id=%d, balance=%ld\n", a.id, a.balance);
    return 0;
}

설명:

  • 함수가 struct Account *를 받아 원본 데이터 변경 가능.
  • 널 포인터 방어 코드를 초기에 넣으면 런타임 크래시를 줄일 수 있다.
  • 포인터 매개변수는 "이 함수가 상태를 바꿀 수 있다"는 의도 전달에도 유리하다.

예제 3) 구조체 배열을 함수로 전달할 때 길이 계약

#include <stdio.h>
#include <stddef.h>

struct Item {
    int code;
    int qty;
};

int total_qty(const struct Item *items, size_t n) {
    if (items == NULL) return 0;

    int total = 0;
    for (size_t i = 0; i < n; ++i) {
        total += items[i].qty;
    }
    return total;
}

int main(void) {
    struct Item items[] = {
        {.code = 1, .qty = 3},
        {.code = 2, .qty = 5},
        {.code = 3, .qty = 2}
    };

    size_t n = sizeof(items) / sizeof(items[0]);
    printf("total=%d\n", total_qty(items, n));
    return 0;
}

설명:

  • 함수 파라미터의 items[]는 포인터와 동일하게 취급되므로 원소 수를 자동으로 알 수 없다.
  • 호출부에서 n을 계산해 함께 전달하는 패턴이 표준적이다.
  • 읽기 전용이면 const struct Item *를 써서 의도를 명시한다.

자주 하는 실수

실수 1) .->를 혼동해서 컴파일 에러 발생

  • 원인: 포인터 변수인데 p.member로 접근하거나, 일반 변수인데 p->member를 사용.
  • 해결: 값이면 . / 포인터면 -> 규칙을 고정하고, 필요하면 타입 선언을 바로 위에 두어 가독성을 높인다.

실수 2) *p.member처럼 우선순위 실수

  • 원인: . 연산자가 *보다 우선순위가 높다는 사실을 놓침.
  • 해결: (*p).member 또는 더 안전하게 p->member를 사용한다.

실수 3) 구조체 배열 길이를 함수 내부에서 sizeof(arr)로 계산

  • 원인: 배열이 함수 인자로 전달되면 포인터로 decay된다는 규칙을 잊음.
  • 해결: 길이 n을 별도 인자로 받는 함수 시그니처를 강제한다.

실수 4) 포인터 인자를 NULL 검사 없이 역참조

  • 원인: 호출 계약이 항상 정상이라고 낙관.
  • 해결: 경계에서 방어적 검사(if (!ptr) return ...)를 두고, 실패 정책을 문서화한다.

실무 패턴

  • 레코드 목록은 구조체 배열 + 길이로 다룬다.
    • struct X *arr, size_t n 시그니처를 팀 공통 규약으로 정하면 일관성이 올라간다.
  • 수정 여부를 시그니처에서 드러낸다.
    • 읽기 전용: const struct X *
    • 수정 가능: struct X *
  • 검색/집계/변환 함수를 작게 쪼갠다.
    • find_by_id, sum_score, sort_by_score처럼 역할을 분리하면 테스트가 쉬워진다.
  • 인덱스 범위와 널 체크를 습관화한다.
    • 구조체 자체보다 "배열 경계"에서 사고가 더 자주 난다.

간단한 패턴 예시:

#include <stdio.h>
#include <stddef.h>

struct User {
    int id;
    int active;
};

struct User* find_user_by_id(struct User *users, size_t n, int id) {
    if (!users) return NULL;
    for (size_t i = 0; i < n; ++i) {
        if (users[i].id == id) return &users[i];
    }
    return NULL;
}

int main(void) {
    struct User users[] = {
        {.id = 1, .active = 0},
        {.id = 2, .active = 0}
    };

    struct User *u = find_user_by_id(users, 2, 2);
    if (u) {
        u->active = 1;
        printf("user %d active=%d\n", u->id, u->active);
    }
    return 0;
}

오늘의 결론

한 줄 요약: 구조체 배열은 데이터를 모아 관리하는 그릇이고, 구조체 포인터와 ->는 그 데이터를 효율적이고 안전하게 조작하는 손잡이입니다.

students[i].scorestudent_ptr->score를 상황에 맞게 구분해 쓰기 시작하면, C 코드가 훨씬 덜 헷갈리고 버그도 줄어듭니다. 특히 함수 설계에서 "복사할 것인가, 참조할 것인가"를 의식적으로 선택하는 습관이 중요합니다.

연습문제

  1. struct Product { int id; int price; int stock; }; 배열 5개를 만들고 총 재고 수량을 구하는 함수를 작성하세요.
  2. void apply_discount(struct Product *p, int percent)를 작성해 가격을 할인하고, 음수 가격이 되지 않게 방어 코드를 넣으세요.
  3. find_by_id 함수를 만들어 상품 ID로 구조체 포인터를 반환하세요. 못 찾으면 NULL을 반환하게 하세요.
  4. (*p).memberp->member가 동일함을 확인하는 짧은 검증 코드를 작성하고, 왜 ->가 더 자주 쓰이는지 설명해 보세요.

이전 강의 정답

지난 34강의 핵심 연습 중 하나는 "구조체 선언/지정 초기화/멤버 접근"이었습니다. 아래는 대표 정답 예시입니다.

#include <stdio.h>

struct Book {
    char title[64];
    int price;
    double rating;
};

int main(void) {
    struct Book b1 = {.title = "C Primer", .price = 32000, .rating = 4.5};
    struct Book b2 = {.title = "System C", .price = 28000, .rating = 4.2};

    printf("%s / %d / %.1f\n", b1.title, b1.price, b1.rating);
    printf("%s / %d / %.1f\n", b2.title, b2.price, b2.rating);
    return 0;
}

해설 포인트:

  • 구조체 필드가 많아질수록 지정 초기화가 의도를 잘 보여 준다.
  • 문자열 필드는 배열이므로 선언 시 리터럴로 초기화하는 방식이 간결하다.
  • 출력 시 타입에 맞는 포맷 지정자(%d, %.1f, %s)를 지켜야 한다.

실습 환경/재현 정보

  • 컴파일러: clang 17+ 또는 gcc 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -O2
  • 실행 환경: macOS / Linux 터미널
  • 재현 체크:
    • 모든 예제가 경고 없이 컴파일되는지 확인
    • 포인터 인자 함수에서 NULL 방어가 있는지 확인
    • 배열 전달 함수가 길이 인자(n)를 반드시 받는지 확인