[C언어 50강] 34강. 구조체 struct: 선언/초기화/멤버 접근

[C언어 50강] 34강. 구조체 struct: 선언/초기화/멤버 접근

핵심 개념

구조체(struct)는 서로 다른 타입의 데이터를 하나의 논리 단위로 묶는 사용자 정의 타입입니다. 배열이 "같은 타입의 연속 집합"이라면, 구조체는 "의미가 다른 필드들의 묶음"에 가깝습니다. 예를 들어 학생 정보를 다룰 때 이름(문자 배열), 학번(int), 평점(double)을 각각 따로 관리하면 서로 엮인 데이터라는 사실이 코드에서 잘 드러나지 않습니다. 구조체를 쓰면 이 세 값이 하나의 학생 레코드라는 뜻이 타입 자체에 담깁니다.

이번 강의의 목표는 세 가지입니다.

  1. 구조체 선언 문법을 이해한다.
  2. 구조체 초기화 방식(정적/부분/지정 초기화)을 익힌다.
  3. 멤버 접근 연산자(.)를 안전하게 사용한다.

구조체를 잘 쓰기 시작하면 코드가 갑자기 "데이터 중심"으로 읽히기 시작합니다. 함수 인자도 난잡한 원시 타입 묶음 대신 Student 같은 의미 있는 타입으로 바뀌고, 버그 포인트도 줄어듭니다.

개념 먼저 이해하기

초보자가 구조체를 처음 볼 때 자주 하는 오해가 있습니다. struct를 클래스처럼 생각하거나, 반대로 그냥 변수 묶음 정도로만 여기는 것입니다. C에서 구조체는 객체지향 언어의 클래스와 다르게 메서드나 접근제어를 내장하지 않지만, 데이터 모델링 측면에서는 매우 강력합니다. "어떤 데이터가 함께 움직여야 하는가"를 코드로 고정해 주기 때문입니다.

예를 들어 계좌 프로그램을 만든다고 해 봅시다. balance, owner_name, account_no를 따로 배열이나 변수로 들고 다니면 어느 값이 어느 계좌의 것인지 계속 신경 써야 합니다. 인덱스가 틀어지는 순간 데이터가 섞입니다. 반면 struct Account로 묶으면 한 계좌의 데이터가 하나의 단위로 취급됩니다. 이 차이는 유지보수에서 치명적일 만큼 큽니다. 구조체를 쓰면 함수 시그니처도 바뀝니다. void print_account(char name[], int no, long balance) 대신 void print_account(struct Account a) 또는 포인터 버전으로 바뀌면서, 인자 순서 실수 같은 인간적인 오류가 크게 줄어듭니다.

또 하나 중요한 지점은 메모리 관점입니다. 구조체는 멤버를 선언 순서대로 배치하되, CPU 정렬(alignment) 요구에 따라 중간에 패딩 바이트가 삽입될 수 있습니다. 즉, sizeof(struct X)는 멤버 sizeof의 단순 합과 다를 수 있습니다. 이건 단지 시험문제가 아니라 파일 포맷, 네트워크 패킷, 임베디드 레지스터 매핑처럼 바이트 단위가 중요한 영역에서 실제 버그로 이어집니다. 따라서 "구조체는 논리 모델"이면서 동시에 "메모리 레이아웃을 가진 물리 객체"라는 이중 관점을 가져야 합니다.

초기화에서도 개념이 중요합니다. C는 선언과 동시에 값을 채우지 않으면 쓰레기값(자동 저장기간 변수 기준)이 들어갈 수 있습니다. 구조체도 예외가 아닙니다. 그래서 실무에서는 struct User u = {0};처럼 0 초기화를 먼저 하고 필요한 필드를 채우거나, 지정 초기화(.name = ...)를 사용해 의도를 분명히 합니다. 지정 초기화는 특히 멤버가 많아질 때 순서 의존성을 제거해 주므로 유지보수성이 크게 좋아집니다. "이번에 age 필드가 중간에 추가됐는데 왜 기존 코드가 전부 깨지지?" 같은 상황을 줄여 줍니다.

정리하면 구조체를 배운다는 건 단순 문법 학습이 아닙니다. 데이터를 의미 단위로 묶는 사고, 메모리 배치에 대한 감각, 초기화와 접근의 안전 습관을 동시에 익히는 과정입니다. 이 감각이 잡히면 이후의 구조체 배열, 포인터, 파일 저장, ADT 구현이 훨씬 자연스러워집니다.

기본 사용

1) 선언과 변수 생성

#include <stdio.h>

struct Student {
    int id;
    char name[32];
    double gpa;
};

int main(void) {
    struct Student s1; // 아직 초기화하지 않음

    s1.id = 2026001;
    s1.gpa = 4.12;

    // name은 문자열 복사 필요 (다음 강에서 상세)
    snprintf(s1.name, sizeof(s1.name), "Kim");

    printf("id=%d, name=%s, gpa=%.2f\n", s1.id, s1.name, s1.gpa);
    return 0;
}

핵심: struct Student는 타입 선언, s1은 그 타입의 변수입니다. 멤버 접근은 . 연산자를 사용합니다.

2) 선언과 동시에 초기화

#include <stdio.h>

struct Point {
    int x;
    int y;
};

int main(void) {
    struct Point p1 = {10, 20};          // 순서 기반 초기화
    struct Point p2 = {.y = 7, .x = 3};  // 지정 초기화(C99)
    struct Point p3 = {0};               // 전체 0 초기화

    printf("p1=(%d,%d)\n", p1.x, p1.y);
    printf("p2=(%d,%d)\n", p2.x, p2.y);
    printf("p3=(%d,%d)\n", p3.x, p3.y);
    return 0;
}

실무에서는 멤버가 늘어날 가능성이 있다면 지정 초기화를 우선 고려하세요.

3) 함수와 함께 사용

#include <stdio.h>

struct Rect {
    int width;
    int height;
};

int area(struct Rect r) {
    return r.width * r.height;
}

int main(void) {
    struct Rect box = {.width = 8, .height = 5};
    printf("area=%d\n", area(box));
    return 0;
}

값 전달로 구조체를 넘기면 복사가 일어납니다. 작은 구조체는 부담이 적지만, 큰 구조체는 포인터 전달이 보통 더 효율적입니다(35강에서 심화).

자주 하는 실수

실수 1) 초기화 없이 멤버 읽기

struct Student s;
printf("%d\n", s.id); // 정의되지 않은 값

왜 문제인가: 자동 저장기간 지역변수는 기본 초기화되지 않습니다. 읽는 순간 미정의 동작 위험이 생깁니다.

개선: struct Student s = {0}; 또는 지정 초기화 사용.

실수 2) 문자열 멤버에 직접 대입 시도

struct Student s = {0};
// s.name = "Park"; // 컴파일 에러

왜 문제인가: 배열은 대입 연산으로 통째 교체할 수 없습니다.

개선: snprintf, strncpy 등으로 복사.

실수 3) 멤버 순서 초기화에 과신

struct User { int id; int age; };
struct User u = {30, 1001}; // 의도는 id=1001, age=30일 수 있음

왜 문제인가: 순서가 바뀌면 의미가 뒤집힙니다.

개선: struct User u = {.id = 1001, .age = 30};

실수 4) sizeof를 잘못 계산

struct A { char c; int n; };
printf("%zu\n", sizeof(struct A)); // 5라고 단정하면 오답 가능

왜 문제인가: 패딩 때문에 8 등 더 큰 값이 나올 수 있습니다.

개선: 구조체 크기는 컴파일러/ABI 결과로 확인하고, 바이너리 직렬화 시 명시적으로 포맷 설계.

실무 패턴

  1. 도메인 단위로 타입 이름 붙이기
    struct Data1, struct Temp 같은 임시 이름보다 struct Student, struct Order처럼 의미를 드러내세요.

  2. 생성 규칙을 함수로 감싸기
    초기화가 복잡해지면 make_student(...) 같은 팩토리 함수(반환 또는 out-parameter)로 초기화 실수를 줄입니다.

  3. 지정 초기화 기본값 전략
    struct Config cfg = {.timeout_ms = 3000, .retry = 2};처럼 중요한 필드만 명시해 의도를 남기고, 나머지는 0 초기화에 기대는 패턴이 유용합니다.

  4. 출력/검증 함수를 붙여 디버깅 비용 줄이기
    구조체가 생기면 거의 항상 print_*, validate_* 함수도 같이 만드는 습관이 좋습니다.

#include <stdio.h>
#include <stdbool.h>

struct Config {
    int timeout_ms;
    int retry;
    int enable_log;
};

bool validate_config(struct Config c) {
    if (c.timeout_ms <= 0) return false;
    if (c.retry < 0 || c.retry > 10) return false;
    return true;
}

void print_config(struct Config c) {
    printf("timeout_ms=%d, retry=%d, enable_log=%d\n",
           c.timeout_ms, c.retry, c.enable_log);
}

int main(void) {
    struct Config cfg = {.timeout_ms = 3000, .retry = 3, .enable_log = 1};

    if (!validate_config(cfg)) {
        fprintf(stderr, "invalid config\n");
        return 1;
    }

    print_config(cfg);
    return 0;
}

오늘의 결론

구조체의 핵심은 문법이 아니라 모델링입니다. 관련된 값을 하나의 타입으로 묶으면 코드가 읽히고, 함수 인터페이스가 단순해지고, 버그가 줄어듭니다. 여기에 초기화 습관(특히 지정 초기화)과 메모리 레이아웃 감각(sizeof, 패딩)을 더하면, C에서 중규모 프로그램을 설계할 기반이 마련됩니다. 다음 강의에서는 구조체 배열과 구조체 포인터, -> 연산자로 확장해 실제 데이터 컬렉션을 다루는 패턴으로 넘어갑니다.

연습문제

  1. struct Book { char title[64]; int price; double rating; };를 선언하고, 지정 초기화로 두 권의 책을 만들어 출력하세요.
  2. struct Point { int x; int y; };를 인자로 받아 원점으로부터의 맨해튼 거리(abs(x)+abs(y))를 반환하는 함수를 작성하세요.
  3. struct Config를 만들고 timeout_ms > 0, retry 범위(0~5)를 검사하는 validate_config 함수를 작성하세요.
  4. 패딩 관찰: struct A { char c; int n; };, struct B { int n; char c; };sizeof를 출력하고 차이를 설명하세요.

이전 강의 정답

지난 33강(포인터로 함수 설계)의 핵심은 "여러 결과를 반환해야 할 때 out-parameter를 사용한다"였습니다.

예시 정답:

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

int min_max(const int *arr, size_t len, int *out_min, int *out_max) {
    if (!arr || len == 0 || !out_min || !out_max) return 0;

    int mn = arr[0], mx = arr[0];
    for (size_t i = 1; i < len; ++i) {
        if (arr[i] < mn) mn = arr[i];
        if (arr[i] > mx) mx = arr[i];
    }
    *out_min = mn;
    *out_max = mx;
    return 1;
}

int main(void) {
    int data[] = {7, 3, 9, -2, 5};
    int mn, mx;

    if (min_max(data, sizeof(data)/sizeof(data[0]), &mn, &mx)) {
        printf("min=%d, max=%d\n", mn, mx);
    }
    return 0;
}

해설 포인트:

  • 실패 가능성이 있는 함수는 성공/실패 반환값을 분리한다.
  • 실제 결과 데이터는 포인터로 전달된 출력 인자에 기록한다.
  • NULL과 길이 0 검사를 먼저 수행해 계약을 명확히 한다.

실습 환경/재현 정보

  • OS: macOS (Apple Silicon)
  • 컴파일러: clang 또는 gcc (C11 권장)
  • 컴파일 예시:
    • clang -std=c11 -Wall -Wextra -O2 struct_intro.c -o struct_intro
    • gcc -std=c11 -Wall -Wextra -O2 struct_intro.c -o struct_intro
  • 실행: ./struct_intro
  • 권장 습관:
    • 경고 옵션(-Wall -Wextra)을 항상 켜고 경고 0개 유지
    • 구조체는 선언 직후 초기화
    • 멤버가 많으면 지정 초기화 우선