[C언어 50강] 14강. 함수 기초: 선언/정의/호출, 반환값, 프로토타입

[C언어 50강] 14강. 함수 기초: 선언/정의/호출, 반환값, 프로토타입

C언어에서 함수는 “코드를 예쁘게 나누는 문법” 정도가 아니라, 프로그램의 구조와 안정성을 결정하는 가장 기본 단위입니다. 특히 함수 선언, 정의, 호출, 반환값, 프로토타입의 관계를 정확히 이해하면 이후 포인터, 모듈화, 에러 처리, 테스트 가능한 코드 설계까지 한 번에 연결됩니다. 이번 강의는 문법 암기보다 왜 함수 경계가 필요하고, 컴파일러가 무엇을 검사하며, 어떤 설계 습관이 실수를 줄이는지에 집중합니다.


핵심 개념

  • 함수는 “입력(매개변수) → 처리(본문) → 출력(반환값)” 계약(contract)을 가지는 코드 단위다.
  • 프로토타입(함수 선언)은 컴파일러에게 함수의 인터페이스를 미리 알려 타입 검사를 가능하게 한다.
  • 반환값 설계와 함수 책임 분리는 가독성뿐 아니라 디버깅 비용, 유지보수 비용을 크게 좌우한다.

개념 먼저 이해하기

함수를 배우는 초반에는 보통 “중복 코드를 줄인다”는 장점만 강조됩니다. 물론 맞는 말입니다. 하지만 실무 기준에서는 더 중요한 이유가 있습니다. 함수는 단순 재사용 도구가 아니라 경계를 만드는 도구입니다. 경계를 만든다는 말은, 이 코드가 무엇을 받아 무엇을 내보내는지 명확하게 약속한다는 뜻입니다. 이 약속이 있어야 호출하는 쪽은 내부 구현을 몰라도 사용할 수 있고, 구현하는 쪽은 입력/출력 규약만 지키면서 내부를 개선할 수 있습니다.

C언어에서 이 경계는 특히 중요합니다. C는 동적 타입 언어가 아니기 때문에 컴파일 시점에 타입 정보를 정확히 맞추는 것이 매우 큰 안전장치가 됩니다. 여기서 프로토타입이 등장합니다. 프로토타입은 “이 함수는 어떤 인자를 몇 개 받고, 어떤 타입을 반환한다”를 컴파일러에 미리 등록하는 선언입니다. 예전 C 문법에서는 프로토타입 없이도 컴파일이 되던 시대가 있었지만, 현대 C 개발에서는 사실상 금지에 가깝습니다. 프로토타입이 없으면 컴파일러가 호출 지점에서 정확한 타입 검사를 못 하고, 경고가 약해지거나 UB(정의되지 않은 동작)로 이어질 가능성이 커집니다.

선언(declaration)과 정의(definition)를 분리해서 생각하는 것도 핵심입니다. 선언은 “이런 함수가 있다”는 약속이고, 정의는 “실제로 이렇게 동작한다”는 구현입니다. 선언은 주로 헤더(.h)에, 정의는 소스(.c)에 둡니다. 이 습관은 14강 시점엔 다소 형식적으로 느껴질 수 있지만, 파일이 10개만 넘어가도 유지보수 생존력에 직접 영향을 줍니다. 선언과 정의를 구분하지 않으면 의존성이 꼬이고, 나중에 링크 단계에서 “undefined reference” 같은 오류를 자주 만나게 됩니다.

또 하나 자주 놓치는 부분은 반환값의 의미 설계입니다. 반환값은 단순히 숫자 하나를 돌려주는 문법이 아니라, 호출자에게 상태를 전달하는 채널입니다. 예를 들어 계산 함수라면 계산 결과를 반환하는 게 자연스럽고, I/O 함수라면 성공/실패 코드를 반환하는 설계가 더 낫습니다. 초보 단계에서 흔히 “void로 만들어 놓고 전역 변수 바꾸기” 패턴을 쓰는데, 이는 결합도를 높여 디버깅을 어렵게 만듭니다. 가능하면 함수는 입력을 명시적으로 받고, 결과는 반환값(또는 명시적 out-parameter)으로 돌려주는 쪽이 안전합니다.

함수 호출 비용에 대한 오해도 정리해야 합니다. “함수로 나누면 느리다”는 말을 자주 듣지만, 현대 컴파일러는 인라이닝 최적화 등으로 작은 함수 비용을 상당 부분 줄입니다. 반면 함수로 안 나눠서 생기는 유지보수 비용은 실제 프로젝트에서 훨씬 큽니다. 특히 버그 수정 시 영향 범위를 좁힐 수 있다는 점에서 함수 분리는 성능 논쟁보다 우선되는 경우가 많습니다. 물론 매우 저수준 성능 최적화 구간에서는 별도 판단이 필요하지만, 기본 원칙은 먼저 명확하게 설계하고, 이후 측정 기반으로 최적화입니다.

마지막으로, 함수 하나에 책임이 너무 많아지는 순간 문제는 폭발합니다. 입력 검증, 계산, 출력, 에러 로그, 상태 변경을 한 함수에 몰아넣으면 테스트하기도 어렵고 재사용도 거의 불가능해집니다. 좋은 함수는 보통 “한 문장으로 설명 가능한 역할”을 가집니다. 예: parse_int, is_valid_score, calc_average, print_report. 이 방식은 코드를 읽는 사람에게 흐름을 문장처럼 보여주고, 각 단계의 오류를 분리해 추적하게 해줍니다. 함수 기초는 결국 문법이 아니라 설계 언어입니다.

기본 사용

예제 1) 선언-정의-호출의 최소 구조

#include <stdio.h>

int add(int a, int b);  // 프로토타입(선언)

int main(void) {
    int result = add(10, 20);  // 호출
    printf("합계: %d\n", result);
    return 0;
}

int add(int a, int b) {        // 정의
    return a + b;
}

설명:

  • int add(int a, int b);가 있어 호출 시점에서 타입 검사가 가능합니다.
  • 반환형 int, 매개변수 2개가 선언/정의/호출에서 모두 일치해야 합니다.
  • 단순 예제라도 이 구조를 습관화하면 파일 분리 시 자연스럽게 확장됩니다.

예제 2) 반환값으로 성공/실패를 분리하는 패턴

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

bool divide_int(int a, int b, int *out) {
    if (out == NULL) {
        return false;
    }
    if (b == 0) {
        return false;
    }
    *out = a / b;
    return true;
}

int main(void) {
    int q = 0;
    if (divide_int(20, 5, &q)) {
        printf("몫: %d\n", q);
    } else {
        printf("나눗셈 실패(0으로 나누기 또는 잘못된 포인터)\n");
    }

    if (!divide_int(20, 0, &q)) {
        printf("예상된 실패 케이스 처리 완료\n");
    }
    return 0;
}

설명:

  • 계산 결과(q)와 함수 수행 성공 여부(bool)를 분리하면 호출부 제어가 명확해집니다.
  • 실패 가능성이 있는 함수에서 무조건 결과값만 반환하면 오류 원인이 숨겨질 수 있습니다.
  • out-parameter는 포인터 안전성(NULL 체크)과 함께 다루어야 합니다.

예제 3) 프로토타입 불일치가 만드는 버그 감지

#include <stdio.h>

double calc_tax(double price, double rate); // 선언

int main(void) {
    double total = calc_tax(10000.0, 0.1);
    printf("세금 포함 금액: %.2f\n", total);
    return 0;
}

double calc_tax(double price, double rate) { // 정의
    return price + (price * rate);
}

설명:

  • 선언과 정의 시그니처가 다르면 컴파일 경고/오류로 빠르게 잡을 수 있습니다.
  • 프로토타입이 없으면 잘못된 호출이 뒤늦게 런타임 문제로 이어질 수 있습니다.
  • 함수 시그니처를 인터페이스로 보고 먼저 고정하면 구현 변경이 쉬워집니다.

자주 하는 실수

실수 1) 프로토타입 없이 함수를 먼저 호출

  • 원인: 작은 파일에서는 “돌아가니까 괜찮다”는 습관이 생김.
  • 해결: 모든 비표준 함수는 호출 전에 선언하거나 헤더를 include해서 컴파일러 검사를 활성화합니다.

실수 2) 선언과 정의의 타입/매개변수 개수가 다름

  • 원인: 함수 수정 후 한쪽만 고치고 다른 쪽을 놓침.
  • 해결: 시그니처 변경 시 선언/정의/호출 지점을 함께 수정하고 -Wall -Wextra -Werror로 즉시 검출합니다.

실수 3) 실패 가능한 함수를 void로 설계해 오류 전파가 불가능

  • 원인: 반환값 설계를 생략하고 전역 상태에 의존함.
  • 해결: 성공/실패를 반환형으로 명시하고, 필요한 경우 결과값은 out-parameter로 분리합니다.

실수 4) 함수 하나에 입력검증·계산·출력·저장까지 몰아넣음

  • 원인: “한 번에 끝내자”는 구현 우선 접근.
  • 해결: 책임을 분리해 테스트 가능한 작은 함수들로 나누고, 상위 함수는 흐름만 조합하게 만듭니다.

실무 패턴

  • 인터페이스 먼저 작성: 함수 본문보다 프로토타입(입력/출력/오류 규약)을 먼저 확정하면 설계 품질이 올라갑니다.
  • 이름에 의도를 담기: doWork 같은 추상 이름보다 parse_csv_line, save_user_record처럼 동작이 드러나는 이름을 씁니다.
  • 한 함수 한 책임: 함수 설명을 한 문장으로 못 하면 책임 분리가 부족한 신호입니다.
  • 반환값 체크 강제: 실패 가능 함수 호출 뒤 반환값 무시를 금지하고, 코드리뷰 체크리스트에 넣습니다.
  • 헤더-소스 분리 습관: 선언(.h)과 구현(.c)을 분리해 컴파일 단위를 명확히 하면 프로젝트 확장성이 크게 좋아집니다.

오늘의 결론

함수는 코드를 나누는 기술이 아니라 프로그램의 계약을 설계하는 기술입니다. 선언/정의/호출/반환값/프로토타입의 연결을 정확히 잡아두면, 이후 배우는 모듈화·포인터·에러 처리의 난이도가 확 내려갑니다.

연습문제

  1. int max2(int a, int b) 함수를 선언/정의/호출까지 포함해 작성하고, 두 수 중 큰 값을 반환하도록 구현하세요.
  2. bool read_positive(int input, int *out) 함수를 만들어 입력이 양수일 때만 out에 저장하고 true를 반환하세요. 실패 케이스도 호출부에서 처리하세요.
  3. 선언과 정의의 매개변수 타입이 다를 때 컴파일러가 어떤 경고/오류를 내는지 직접 실험하고, 왜 위험한지 설명하세요.

이전 강의 정답

지난 13강(반복문 2: do-while, break/continue, goto(개념)) 연습문제 예시 정답입니다.

  1. 1~9 범위 재입력 (do-while)
  • 최소 한 번은 입력을 받아야 하므로 do-while이 자연스럽습니다.
  • 범위를 벗어나면 안내 메시지를 출력하고 반복합니다.
  1. 첫 번째 음수 탐색 후 즉시 종료
  • 배열 순회 중 arr[i] < 0이면 인덱스를 저장하고 break로 탈출하면 됩니다.
  1. 정리 경로 단일화 비교
  • 자원 해제 코드를 분기마다 중복 작성하면 누락 위험이 커집니다.
  • goto cleanup 방식은 일반 로직 점프가 아니라 해제 루틴 일원화 목적일 때 실수를 줄이는 장점이 있습니다.

참고 코드:

#include <stdio.h>

int main(void) {
    int x;
    do {
        printf("1~9 숫자 입력: ");
        if (scanf("%d", &x) != 1) return 1;
    } while (x < 1 || x > 9);

    printf("입력 완료: %d\n", x);
    return 0;
}

실습 환경/재현 정보

  • 컴파일러: Apple clang 17.x 또는 gcc 13+
  • 컴파일 옵션: -Wall -Wextra -Werror -std=c11
  • 실행 환경: macOS(arm64), Linux x86_64 터미널
  • 재현 체크:
    • 프로토타입을 일부러 제거해 경고/오류 변화를 확인
    • 선언/정의 시그니처를 다르게 만들어 컴파일 실패 메시지 확인
    • 반환값 체크를 생략했을 때 오류 처리 누락이 어떻게 생기는지 점검