지난 편에서는 함수의 기본 구조와 선언, 정의, 호출 방법을 배웠습니다. 함수를 한 번 만들어두면 필요할 때마다 불러 쓸 수 있다는 것까지 이해했다면, 이제는 함수를 조금 더 깊이 있게 다뤄볼 차례입니다. 오늘은 함수가 자기 자신을 호출하는 “재귀 함수”, 변수가 어디까지 보이고 언제 사라지는지를 정하는 “스코프(범위)”, 그리고 코드가 커졌을 때 여러 파일로 나누어 관리하는 방법까지 함께 알아보겠습니다.

재귀 함수란 무엇인가

재귀 함수는 함수 내부에서 자기 자신을 다시 호출하는 함수를 말합니다. 얼핏 들으면 함수가 스스로를 부르면 끝없이 반복될 것 같지만, 반드시 “언젠가는 멈추는 조건”을 넣어주기 때문에 정상적으로 종료됩니다. 이 멈추는 조건을 보통 “기저 조건(base case)”이라고 부릅니다.

대표적인 예로 팩토리얼(계승) 계산을 들 수 있습니다. 5의 팩토리얼(5!)은 5 × 4 × 3 × 2 × 1로 계산하는데, 이를 재귀로 표현하면 “5! = 5 × 4!”이고, “4! = 4 × 3!”인 식으로 계속 작아지다가 결국 “1! = 1”에서 멈추게 됩니다.

int factorial(int n) {
    if (n <= 1) {       // 기저 조건: 더 이상 쪼개지 않고 바로 값을 돌려줌
        return 1;
    }
    return n * factorial(n - 1); // 재귀 호출: 자기 자신을 더 작은 문제로 부름
}

기저 조건이 없다면 함수 호출이 끝없이 이어지다가 결국 스택 메모리가 가득 차서 프로그램이 강제로 종료되는 “스택 오버플로우”가 발생합니다. 그래서 재귀 함수를 만들 때는 항상 “이 값이면 더 쪼개지 않고 바로 답을 준다”는 조건을 가장 먼저 챙겨야 합니다.

변수의 스코프(범위)

스코프란 변수가 코드 안에서 유효하게 사용될 수 있는 범위를 말합니다. C언어에서는 크게 지역 변수와 전역 변수로 나뉩니다.

  • 지역 변수: 함수나 중괄호 블록 안에서 선언된 변수로, 그 블록을 벗어나면 사라집니다. 다른 함수에서는 같은 이름을 써도 서로 전혀 영향을 주지 않습니다.
  • 전역 변수: 함수 바깥, 즉 파일의 최상단에 선언된 변수로, 프로그램이 실행되는 동안 계속 존재하며 모든 함수에서 접근할 수 있습니다.
#include <stdio.h>

int counter = 0; // 전역 변수: 어디서든 접근 가능

void increase(void) {
    int step = 1;   // 지역 변수: increase 함수 안에서만 유효
    counter += step;
}

int main(void) {
    increase();
    increase();
    printf("counter = %d\n", counter);
    // printf("%d", step); // 오류! step은 increase 함수 밖에서는 보이지 않음
    return 0;
}

전역 변수는 여러 함수에서 값을 공유할 수 있어 편리해 보이지만, 아무 함수에서나 값을 바꿀 수 있다 보니 코드가 커질수록 어디서 값이 바뀌었는지 추적하기 어려워지는 단점이 있습니다. 그래서 꼭 필요한 경우가 아니라면 지역 변수와 매개변수, 반환값을 활용해서 함수끼리 값을 주고받는 방식을 우선적으로 고려하는 것이 좋습니다.

여러 파일로 코드 나누기

프로그램이 점점 커지면 하나의 .c 파일에 모든 코드를 몰아넣기보다, 기능별로 파일을 나누어 관리하는 것이 훨씬 편합니다. 이때 자주 쓰는 함수의 선언을 담은 헤더 파일(.h)을 만들고, 이를 #include로 가져와 사용합니다.

math_utils.h (함수 선언만 모아둔 헤더 파일)

#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int factorial(int n);
int getMax(int a, int b);

#endif

math_utils.c (실제 함수 구현)

#include "math_utils.h"

int factorial(int n) {
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

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

main.c (헤더를 포함해서 함수 사용)

#include <stdio.h>
#include "math_utils.h"

int main(void) {
    printf("5! = %d\n", factorial(5));
    printf("더 큰 값: %d\n", getMax(10, 24));
    return 0;
}

#ifndef, #define, #endif로 감싸는 부분은 “헤더 가드”라고 부르는데, 같은 헤더 파일이 실수로 여러 번 포함되었을 때 중복 선언 오류가 나는 것을 막아줍니다. 이렇게 나눈 파일들은 다음과 같이 함께 컴파일합니다.

gcc main.c math_utils.c -o program

하나로 합쳐서 실행해보기

파일을 나누기 전, 개념 확인을 위해 하나의 파일 안에서 재귀와 스코프를 함께 써보는 예제를 살펴보겠습니다.

#include <stdio.h>

int total_calls = 0; // 전역 변수: 재귀 호출 횟수를 기록

int factorial(int n) {
    total_calls++;      // 호출될 때마다 전역 변수 값 증가
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(void) {
    int number = 5;
    int result = factorial(number);

    printf("%d! = %d\n", number, result);
    printf("factorial 함수 호출 횟수: %d\n", total_calls);

    return 0;
}

코드 동작 설명 및 실행 결과

  • factorial(5)가 호출되면 내부에서 factorial(4)를 호출하고, 이 함수는 다시 factorial(3)을 호출하는 식으로 이어지다가 n이 1이 되는 순간 더 이상 자신을 호출하지 않고 1을 돌려줍니다.
  • 그 이후에는 반환된 값들이 차례대로 곱해지면서 호출했던 곳으로 거슬러 올라갑니다. 즉 factorial(1)이 1을 돌려주면 factorial(2)는 2 * 1 = 2를, factorial(3)은 3 * 2 = 6을 돌려주는 식으로 계산이 완성됩니다.
  • total_calls는 전역 변수이므로 factorial 함수가 호출될 때마다 값이 누적되어, main 함수에서도 최종 호출 횟수를 확인할 수 있습니다.
  • 이 코드를 gcc recursion.c -o recursion으로 컴파일하고 실행하면 다음과 같이 출력됩니다.
5! = 120
factorial 함수 호출 횟수: 5

핵심 요약

  • 재귀 함수는 자기 자신을 호출하는 함수이며, 반드시 재귀 호출을 멈추는 기저 조건이 있어야 합니다.
  • 지역 변수는 선언된 블록 안에서만 유효하고, 전역 변수는 프로그램 전체에서 접근할 수 있지만 남용하면 코드 관리가 어려워집니다.
  • 코드가 커지면 헤더 파일(.h)에 함수 선언을 모아두고 소스 파일(.c)에서 구현을 분리해 여러 파일로 나누어 관리할 수 있습니다.
  • 헤더 파일에는 #ifndef, #define, #endif로 이루어진 헤더 가드를 넣어 중복 포함 문제를 방지합니다.

다음 편에서는 C언어의 핵심 개념 중 하나인 “포인터 기초”를 주제로, 변수의 메모리 주소를 직접 다루는 포인터의 개념과 사용법을 하나씩 짚어보겠습니다.