[C언어 50강] 19강. 배열 1차원: 선언/초기화/순회, 경계(인덱스)

[C언어 50강] 19강. 배열 1차원: 선언/초기화/순회, 경계(인덱스)

배열은 C언어에서 “같은 타입의 값을 연속된 메모리 공간에 저장”하는 가장 기본적인 자료구조입니다. 문법은 단순해 보이지만, 실제로는 메모리 배치·인덱스 경계·함수 전달 규칙이 엮여 있어 초급에서 중급으로 넘어가는 분기점이 됩니다. 오늘은 1차원 배열을 단순 문법 암기 대상으로 보지 않고, 왜 배열이 그렇게 동작하는지부터 잡아보겠습니다.


핵심 개념

  • 1차원 배열은 같은 타입 원소를 연속된 메모리 블록에 보관한다.
  • 인덱스는 0부터 시작하며, 유효 범위는 0부터 길이-1까지다.
  • 배열명은 많은 문맥에서 “첫 원소의 주소”처럼 동작하지만, 배열 그 자체와 포인터는 완전히 동일한 개념이 아니다.
  • 배열을 함수에 전달할 때는 길이 정보가 자동으로 전달되지 않으므로, 길이를 함께 넘기는 습관이 필수다.

개념 먼저 이해하기

배열을 잘 쓰는 사람과 그렇지 못한 사람의 차이는, arr[i]를 단순히 “i번째 값”으로만 보느냐, 아니면 “기준 주소 + 오프셋 연산”으로 이해하느냐에서 갈립니다. C 컴파일러 관점에서 배열은 결국 연속된 바이트 구간입니다. 예를 들어 int arr[5];라면 int가 4바이트인 환경에서 총 20바이트가 연속으로 배치됩니다. arr[0]은 시작 주소, arr[1]은 시작 주소에서 4바이트 뒤, arr[2]는 8바이트 뒤를 가리킵니다. 즉 배열 접근은 사실상 주소 계산입니다.

이 관점이 중요한 이유는 경계 오류를 이해하게 만들기 때문입니다. arr[5] 같은 접근이 왜 위험한지 문법 규칙으로만 외우면 실수합니다. 길이가 5인 배열의 유효 인덱스는 0~4이므로 arr[5]는 배열 바깥 메모리를 읽거나 쓰게 됩니다. C는 이런 접근을 자동으로 막아주지 않기 때문에 프로그램이 조용히 잘못된 값을 만들거나, 운이 나쁘면 세그멘테이션 오류로 즉시 종료됩니다. 더 무서운 건 “바로 안 터지는” 경우입니다. 테스트에서는 지나가지만 배포 후 특정 입력에서만 깨지는 전형적인 잠복 버그가 됩니다.

또 하나 자주 헷갈리는 지점은 배열명과 포인터 관계입니다. int arr[5];에서 arr는 많은 식(expression)에서 int*처럼 변환(decay)되어 첫 원소 주소로 취급됩니다. 그래서 arr[i]*(arr + i)가 같은 결과를 냅니다. 하지만 배열은 컴파일 시점에 크기가 정해진 객체이고, 포인터는 “주소 값을 담는 변수”입니다. 배열에는 대입(arr = other)이 불가능하지만 포인터는 대입이 가능합니다. 이 차이를 이해하지 못하면 함수 인자에서 “왜 배열 크기를 잃어버렸지?” 같은 혼란이 생깁니다.

초보자가 배열에서 막히는 세 번째 이유는 “초기화 규칙”을 일관되게 잡지 못하기 때문입니다. 자동 저장 기간(local) 배열은 명시적으로 초기화하지 않으면 쓰레기 값이 들어갑니다. 반면 int arr[5] = {0};처럼 초기화하면 남는 원소까지 0으로 채워집니다. 실제 실무에서는 미초기화 배열 때문에 조건문이 랜덤하게 분기되고, 로그를 찍어도 재현이 들쑥날쑥한 문제를 자주 만납니다. 따라서 배열을 선언할 때는 “초기화 정책”을 먼저 정해야 합니다. 데이터 수집용인지, 누적 계산용인지, 임시 버퍼인지에 따라 초기값 전략이 달라집니다.

마지막으로 함수 설계 관점에서 배열은 늘 “데이터 + 길이”의 쌍으로 다뤄야 합니다. C 함수는 인자로 넘어온 포인터가 가리키는 메모리 길이를 알 수 없습니다. 즉 sum(int arr[])처럼 받으면 함수 내부에서 sizeof(arr)는 배열 전체 크기가 아니라 포인터 크기를 반환합니다. 그래서 sum(const int *arr, size_t n)처럼 인터페이스를 설계해야 안전합니다. 이 원칙 하나만 지켜도 배열 관련 버그의 상당수를 예방할 수 있습니다.

기본 사용

예제 1) 선언/초기화/순회의 기본

#include <stdio.h>

int main(void) {
    int scores[5] = {90, 85, 100, 76, 88};
    int i;
    int total = 0;

    for (i = 0; i < 5; i++) {
        total += scores[i];
    }

    printf("total=%d, avg=%.2f\n", total, total / 5.0);
    return 0;
}

설명:

  • 배열 길이가 5이므로 반복문 조건은 반드시 i < 5여야 합니다.
  • i <= 4도 동작은 같지만, 실무에서는 길이 상수를 직접 쓰는 방식보다 n 변수를 둬 재사용성을 높입니다.
  • 누적 연산에서는 초기값(total = 0)이 필수입니다. 초기화 누락은 배열 문제와 결합해 디버깅 난이도를 크게 올립니다.

예제 2) 경계 안전한 입력 저장 패턴

#include <stdio.h>

int main(void) {
    int values[10] = {0};
    int n, i;

    printf("입력할 개수(0~10): ");
    if (scanf("%d", &n) != 1) {
        printf("잘못된 입력입니다.\n");
        return 1;
    }

    if (n < 0 || n > 10) {
        printf("범위를 벗어났습니다.\n");
        return 1;
    }

    for (i = 0; i < n; i++) {
        printf("values[%d] = ", i);
        if (scanf("%d", &values[i]) != 1) {
            printf("숫자 입력 실패\n");
            return 1;
        }
    }

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

설명:

  • 사용자 입력은 신뢰하지 않는다는 전제로, 길이부터 검증합니다.
  • 배열 크기(10)와 사용자 요청 개수(n)를 분리해 관리하면 오버런을 차단할 수 있습니다.
  • 실무에서 가장 흔한 패턴은 “최대 크기 버퍼 + 실제 사용 길이”입니다.

예제 3) 함수에 배열 전달하기: 길이와 함께

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

int max_value(const int *arr, size_t n) {
    size_t i;
    int max;

    if (arr == NULL || n == 0) {
        return 0; // 정책: 빈 입력은 0 반환
    }

    max = arr[0];
    for (i = 1; i < n; i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }
    return max;
}

int main(void) {
    int data[] = {12, 7, 25, 3, 18};
    size_t n = sizeof(data) / sizeof(data[0]);

    printf("max=%d\n", max_value(data, n));
    return 0;
}

설명:

  • 함수 시그니처에서 길이 n을 분리 전달해 경계 제어를 명시합니다.
  • sizeof(data) / sizeof(data[0])는 배열이 “현재 스코프에서 진짜 배열일 때만” 유효합니다.
  • 함수 내부의 arr는 포인터이므로 같은 계산을 하면 잘못된 값이 나옵니다.

자주 하는 실수

실수 1) 반복문 조건을 <= 길이로 작성

  • 원인: 마지막 인덱스를 포함해야 한다고 착각하여 i <= n을 사용.
  • 해결: 인덱스는 0부터 시작하므로 반복 조건은 기본적으로 i < n 패턴으로 고정합니다.

실수 2) 함수 내부에서 sizeof(arr)로 길이 계산

  • 원인: 배열이 함수 인자에서 포인터로 변환된다는 사실을 놓침.
  • 해결: 호출자가 길이를 계산해 전달하고, 함수는 전달받은 길이만 신뢰합니다.

실수 3) 초기화하지 않은 배열을 바로 사용

  • 원인: 지역 배열이 자동으로 0 초기화될 거라고 오해.
  • 해결: int buf[SIZE] = {0}; 또는 명시적 루프로 초기화 정책을 코드에 드러냅니다.

실수 4) 배열 경계 검증 없이 사용자 입력을 저장

  • 원인: 입력 개수와 배열 크기를 분리하지 않음.
  • 해결: 입력 전 범위 검사(0 <= n <= CAPACITY)를 반드시 수행하고, 실패 시 즉시 중단합니다.

실무 패턴

  • CAPACITY와 LENGTH 분리: int arr[100]; size_t len = 0; 형태로 “최대 크기”와 “현재 데이터 길이”를 분리 관리합니다.
  • 경계 우선 코드리뷰: 배열 루프는 비즈니스 로직보다 먼저 조건식을 검토합니다. (<인지 <=인지)
  • 읽기 전용 전달: 수정이 필요 없는 배열 인자는 const int *arr로 선언해 의도를 고정합니다.
  • 도우미 매크로/함수 최소화: 배열 길이 매크로를 무분별하게 확장하기보다, 사용하는 스코프에서 명시적으로 계산해 가독성을 유지합니다.
  • 테스트 케이스 기준: 빈 배열, 길이 1 배열, 최대 길이 배열, 경계값 입력(0/최대치) 케이스를 기본 세트로 둡니다.

오늘의 결론

한 줄 요약: 배열은 “값 묶음”이 아니라 “연속 메모리 + 경계 계약”이며, C에서 안전한 배열 코드는 항상 길이 관리와 인덱스 검증에서 시작됩니다.

연습문제

  1. 길이 8짜리 정수 배열을 만들고, 짝수 인덱스 원소의 합과 홀수 인덱스 원소의 합을 각각 계산해 출력하세요.
  2. 사용자에게 개수 n(1~20)을 입력받아 배열에 값을 저장한 뒤, 최솟값/최댓값/평균을 계산하는 프로그램을 작성하세요.
  3. int find_first(const int *arr, size_t n, int target) 함수를 만들어 target의 첫 인덱스를 반환하고, 없으면 -1을 반환하도록 구현하세요.

이전 강의 정답

지난 18강(저장 클래스: static/extern) 연습문제 예시 정답입니다.

  1. logger.c에만 보이는 static int log_level + 접근 API 구현
/* logger.h */
#ifndef LOGGER_H
#define LOGGER_H

void logger_set_level(int level);
int logger_get_level(void);

#endif

/* logger.c */
#include "logger.h"

static int log_level = 1;

void logger_set_level(int level) {
    if (level < 0) level = 0;
    if (level > 5) level = 5;
    log_level = level;
}

int logger_get_level(void) {
    return log_level;
}
  1. settings.h에는 extern 선언만, settings.c에서 정의
/* settings.h */
#ifndef SETTINGS_H
#define SETTINGS_H

extern int g_timeout_ms;
extern int g_retry_count;

#endif

/* settings.c */
#include "settings.h"

int g_timeout_ms = 3000;
int g_retry_count = 3;
  1. 함수 내부 static 카운터 vs 상태 구조체 전달 방식 비교
#include <stdio.h>

typedef struct {
    int count;
} CounterState;

void count_with_static(void) {
    static int c = 0;
    c++;
    printf("static count=%d\n", c);
}

void count_with_state(CounterState *s) {
    if (!s) return;
    s->count++;
    printf("state count=%d\n", s->count);
}

설명:

  • static 방식은 간단하지만 테스트 간 상태 격리가 어렵습니다.
  • 상태 구조체 방식은 호출자가 생명주기를 제어할 수 있어 테스트와 재사용에 유리합니다.

실습 환경/재현 정보

  • 컴파일러: Apple clang 17.x 이상 또는 GCC 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Werror -O0
  • 실행 환경: macOS(arm64), Linux(x86_64)
  • 재현 체크:
    • 배열 순회 루프가 i < n 조건을 지키는지 확인
    • 함수 인자 배열에서 sizeof 길이 계산을 시도했을 때 왜 틀리는지 설명 가능해야 함
    • 경계값 입력(n=0, n=CAPACITY, n=CAPACITY+1)에서 안전하게 동작하는지 테스트