[C언어 50강] 15강. 매개변수 전달: 값 전달, 지역/전역 변수, 함수 설계

[C언어 50강] 15강. 매개변수 전달: 값 전달, 지역/전역 변수, 함수 설계

C언어에서 함수는 단순히 코드를 분리하는 문법이 아니라, 데이터가 어디서 만들어지고 어디로 전달되며 누가 변경 권한을 가지는지를 결정하는 경계입니다. 오늘은 매개변수 전달(값 전달), 지역/전역 변수, 함수 설계 원칙을 한 흐름으로 묶어 이해해보겠습니다. 핵심은 문법 자체보다 “왜 이렇게 설계해야 버그가 줄어드는가”를 이해하는 것입니다.


핵심 개념

  • C언어의 함수 인자 전달은 기본적으로 값 전달(call by value) 이다. 즉, 함수는 원본 변수 자체가 아니라 복사된 값을 받는다.
  • 지역 변수는 수명과 범위가 명확해 예측 가능성이 높고, 전역 변수는 편해 보이지만 결합도와 사이드이펙트를 키운다.
  • 좋은 함수 설계는 “입력 명시, 출력 명시, 상태 변경 최소화”를 지키며, 이를 통해 디버깅과 테스트 비용을 줄인다.

개념 먼저 이해하기

초보 단계에서 가장 자주 생기는 오해 중 하나가 “함수에 변수를 넘겼는데 왜 원본이 안 바뀌지?”입니다. 이 질문의 핵심은 C의 기본 전달 방식이 값 전달이라는 사실에 있습니다. 값 전달이란, 호출자가 가진 값이 함수의 매개변수로 “복사”되어 들어간다는 뜻입니다. 따라서 함수 내부 매개변수의 값을 바꿔도 호출자 쪽 원본 변수는 그대로입니다. 이 동작은 처음에는 답답하게 느껴질 수 있지만, 사실 매우 강력한 안전장치입니다. 함수가 외부 상태를 함부로 바꾸지 못하게 막아주기 때문입니다.

그렇다면 원본을 바꾸려면 어떻게 해야 할까요? C에서는 주소를 넘겨서(포인터 매개변수) 간접적으로 원본에 접근합니다. 중요한 포인트는 “C가 갑자기 참조 전달을 지원한다”가 아니라, 여전히 값 전달인데 복사된 값이 주소값이라는 점입니다. 즉, 포인터도 결국 값으로 전달됩니다. 다만 그 값이 메모리 주소라서, 그 주소를 역참조하면 원본 데이터를 바꿀 수 있는 것입니다. 이 차이를 정확히 이해하면 포인터를 배울 때 혼동이 크게 줄어듭니다.

다음으로 지역 변수와 전역 변수를 함께 봐야 합니다. 지역 변수(local variable)는 함수 블록 안에서 만들어지고 함수가 끝나면 사라집니다. 범위(scope)와 수명(lifetime)이 짧아, 상태 추적이 쉽고 버그 전파 범위가 작습니다. 반면 전역 변수(global variable)는 프로그램 전역에서 접근 가능해 편리해 보입니다. 하지만 편리함 뒤에는 비용이 숨어 있습니다. 어떤 함수가 어떤 전역 상태를 읽고/쓰는지 파악하기 어려워지고, 코드 한 부분의 수정이 다른 부분의 동작을 예기치 않게 바꿉니다. 특히 멀티파일 프로젝트나 팀 개발에서는 전역 상태가 디버깅 시간을 급격히 늘리는 원인이 됩니다.

함수 설계를 잘한다는 것은 문법을 예쁘게 쓰는 것이 아니라 “상태 변화의 경로를 제어”하는 것입니다. 좋은 기본 원칙은 세 가지입니다. 첫째, 입력을 함수 시그니처에서 명시합니다. 둘째, 출력은 반환값 또는 명시적 out-parameter로 표현합니다. 셋째, 숨은 상태(전역 변수 의존)를 최소화합니다. 이 원칙을 지키면 함수 호출만 보고도 동작을 추론할 수 있습니다. 반대로 전역 변수에 몰래 의존하는 함수는 호출부만으로는 결과를 예측하기 어렵습니다.

또 하나 중요한 관점은 테스트 가능성입니다. 지역 변수와 명시적 매개변수 중심으로 설계된 함수는 같은 입력에 같은 출력을 내므로 단위 테스트가 쉽습니다. 그러나 전역 상태를 읽고 쓰는 함수는 테스트 전에 환경 초기화가 필요하고, 테스트 순서에 따라 결과가 달라질 수 있습니다. 결국 “설계의 편의” 문제처럼 보였던 지역/전역 변수 선택은 장기적으로 품질과 개발 속도를 좌우합니다.

정리하면, C의 값 전달은 제한이 아니라 기본 안전 모델입니다. 원본 변경이 필요할 때만 의도적으로 주소를 전달하고, 변수의 수명과 범위를 좁게 관리하며, 함수 인터페이스에서 데이터 흐름을 드러내는 것이 실전에서 통하는 방식입니다. 이 사고방식을 익혀두면 이후 포인터, 동적 메모리, 모듈화 단계에서 훨씬 덜 흔들립니다.

기본 사용

예제 1) 값 전달: 매개변수를 바꿔도 원본은 바뀌지 않는다

#include <stdio.h>

void increase(int x) {
    x += 10;
    printf("[함수 내부] x = %d\n", x);
}

int main(void) {
    int value = 5;
    increase(value);
    printf("[main] value = %d\n", value);
    return 0;
}

설명:

  • increasevalue의 복사본을 받으므로 함수 내부에서 x를 바꿔도 원본 value는 변하지 않습니다.
  • 출력 결과가 [함수 내부] 15, [main] 5로 갈리는 이유가 바로 값 전달입니다.
  • 이 동작 덕분에 함수가 외부 상태를 무심코 망가뜨릴 가능성이 줄어듭니다.

예제 2) 원본 변경이 필요할 때: 주소(포인터) 값을 전달

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

bool increase_inplace(int *x, int delta) {
    if (x == NULL) {
        return false;
    }
    *x += delta;
    return true;
}

int main(void) {
    int score = 70;

    if (increase_inplace(&score, 5)) {
        printf("업데이트 후 score = %d\n", score);
    }

    if (!increase_inplace(NULL, 3)) {
        printf("NULL 포인터 방어 성공\n");
    }
    return 0;
}

설명:

  • 여전히 값 전달이지만, 전달된 값이 주소값(&score)이므로 역참조(*x)로 원본을 바꿀 수 있습니다.
  • 포인터 매개변수 함수는 NULL 방어를 기본 습관으로 가져가야 런타임 크래시를 줄일 수 있습니다.
  • 원본 변경 의도가 분명한 인터페이스(int *x)는 코드 리뷰에서도 의미가 명확합니다.

예제 3) 전역 변수 의존 vs 명시적 인자 전달 비교

#include <stdio.h>

int g_discount = 10; // 전역 상태

int calc_price_global(int base) {
    return base - g_discount;
}

int calc_price_explicit(int base, int discount) {
    return base - discount;
}

int main(void) {
    int base = 100;

    printf("global 방식: %d\n", calc_price_global(base));

    g_discount = 30; // 다른 코드가 전역 상태 변경
    printf("global 방식(상태 변경 후): %d\n", calc_price_global(base));

    printf("explicit 방식(10): %d\n", calc_price_explicit(base, 10));
    printf("explicit 방식(30): %d\n", calc_price_explicit(base, 30));
    return 0;
}

설명:

  • 전역 변수 방식은 호출부가 같아도 외부 상태에 따라 결과가 달라집니다.
  • 명시적 인자 방식은 입력이 보이면 결과를 예측할 수 있어 테스트와 디버깅이 쉽습니다.
  • 실무에서는 “숨은 입력(전역 상태)”을 줄이는 방향이 유지보수에 유리합니다.

자주 하는 실수

실수 1) 값 전달 함수를 써놓고 원본이 바뀌길 기대함

  • 원인: 매개변수와 원본 변수를 동일한 대상으로 착각함.
  • 해결: 원본 변경이 필요하면 포인터 인자 설계로 바꾸고, 함수 이름에도 의도를 드러냅니다(set_, update_, inplace_ 등).

실수 2) 포인터 인자를 받는데 NULL 체크를 생략함

  • 원인: “항상 정상 값이 들어온다”는 낙관 가정.
  • 해결: 함수 시작부에서 if (ptr == NULL) 방어 후 실패 반환값을 정의합니다.

실수 3) 전역 변수로 상태를 빠르게 공유하다가 의존성이 폭발함

  • 원인: 초기 개발 속도만 보고 장기 비용을 과소평가함.
  • 해결: 상태를 구조체/매개변수로 묶어 전달하고, 전역은 상수성 설정이나 진짜 공유 자원에만 제한적으로 사용합니다.

실수 4) 함수가 입력 검증·계산·출력·로그를 한꺼번에 처리함

  • 원인: 편의를 위해 책임을 한곳에 몰아넣음.
  • 해결: 계산 함수와 I/O 함수를 분리해 테스트 가능한 단위로 쪼갭니다.

실무 패턴

  • 함수 시그니처로 데이터 흐름 문서화: 무엇을 읽고 무엇을 바꾸는지 타입과 이름으로 드러냅니다.
  • 원본 변경 함수는 규칙 통일: 포인터 매개변수 + 성공/실패 반환 패턴(bool 또는 에러 코드)으로 일관성 유지.
  • 전역 상태 최소화: 설정값은 가능하면 const로 고정하거나 초기화 단계에서 구조체에 모아 주입합니다.
  • 지역 변수 우선 원칙: 값의 유효 범위를 좁히면 실수 가능 구간도 함께 줄어듭니다.
  • 테스트 가능한 함수 분리: 순수 계산 함수(입력→출력)와 부수효과 함수(I/O, 파일, 네트워크)를 구분합니다.

오늘의 결론

한 줄 요약: C 함수 설계의 핵심은 값 전달의 기본 원리를 이해하고, 원본 변경은 의도적으로(포인터), 상태 공유는 최소한으로(지역 변수 우선) 관리하는 것입니다.

연습문제

  1. void swap_wrong(int a, int b)void swap_right(int *a, int *b)를 각각 작성해 값 전달/주소 전달 차이를 출력으로 비교하세요.
  2. 전역 변수 g_count를 증가시키는 함수와, int *count를 받아 증가시키는 함수를 각각 만든 뒤 테스트 난이도를 비교 설명하세요.
  3. 사용자 점수 배열의 평균을 구하는 함수를 설계하세요. 입력 검증 실패 시 어떻게 실패를 반환할지(반환값 vs out-parameter)도 함께 결정하세요.

이전 강의 정답

지난 14강(함수 기초: 선언/정의/호출, 반환값, 프로토타입) 연습문제 예시 정답입니다.

  1. max2 구현 포인트
  • 시그니처: int max2(int a, int b)
  • 로직: return (a > b) ? a : b;
  • 호출부에서 두 값 조합을 여러 케이스로 검증합니다.
  1. read_positive 설계 포인트
  • 시그니처: bool read_positive(int input, int *out)
  • out == NULL이면 즉시 false
  • input > 0일 때만 *out = input, 그 외 false 반환
  1. 선언/정의 불일치 실험 결론
  • 선언과 정의의 타입/인자 개수가 다르면 컴파일러가 경고 또는 오류를 냅니다.
  • 이 불일치를 방치하면 호출 규약이 깨져 런타임 문제(UB)로 이어질 수 있습니다.
  • 따라서 -Wall -Wextra -Werror로 조기 차단하는 습관이 중요합니다.

참고 코드:

#include <stdio.h>

int max2(int a, int b);

int main(void) {
    printf("max=%d\n", max2(3, 7));
    return 0;
}

int max2(int a, int b) {
    return (a > b) ? a : b;
}

실습 환경/재현 정보

  • 컴파일러: Apple clang 17.x 또는 gcc 13+
  • 컴파일 옵션: -Wall -Wextra -Werror -std=c11
  • 실행 환경: macOS(arm64), Linux x86_64 터미널
  • 재현 체크:
    • 값 전달 함수에서 원본이 바뀌지 않는지 출력으로 확인
    • 포인터 전달 함수에서 NULL 방어 분기 동작 확인
    • 전역 변수 변경 전/후 동일 호출 결과가 달라지는지 비교