Skip to main content

스코프와 수명

Views 8약 11분생성형 AI 활용
Summary

변수는 이름표일 뿐, 공간은 따로 있다

앞 장에서 우리는 함수를 배웠습니다. 함수는 코드를 작게 나누는 도구였습니다. 그런데 함수를 쓰기 시작하면 곧 이런 질문을 만나게 됩니다.

"이 변수는 어디에서 쓸 수 있지?"

"함수가 끝난 뒤에도 이 값은 남아 있을까?"

"분명 같은 이름인데 왜 값이 다르지?"

C언어에서 변수는 단순한 이름이 아닙니다. 변수는 메모리 어딘가에 있는 값을 사람이 부르기 쉽게 붙인 이름입니다. 그런데 이름이 보이는 범위와 값이 메모리에 살아 있는 기간은 항상 같지 않습니다. 이 둘을 구분하지 못하면 프로그램이 조금만 커져도 코드가 미로처럼 느껴집니다.

이번 장에서는 두 가지 개념을 분리해서 보겠습니다.

  • 스코프(scope): 변수 이름을 사용할 수 있는 범위

  • 수명(lifetime): 변수가 메모리에 존재하는 기간

  • 저장 방식(storage duration): 변수가 언제 만들어지고 언제 사라지는지에 대한 규칙

쉽게 말해 스코프는 "이 이름이 보이는가?"의 문제이고, 수명은 "이 공간이 아직 살아 있는가?"의 문제입니다.

같은 이름의 변수라도 어디에서 선언했는지에 따라 전혀 다른 존재가 됩니다. C언어는 이 점을 아주 정직하게 드러내는 언어입니다.

스코프와 수명

지역 변수: 중괄호 안에서 태어나고 사라지는 변수

가장 먼저 익숙한 지역 변수부터 보겠습니다.

#include <stdio.h>

void printNumber(void)
{
    int number = 10;
    printf("printNumber: %dn", number);
}

int main(void)
{
    int number = 20;
    printf("main: %dn", number);

    printNumber();

    return 0;
}

실행 결과는 다음과 같습니다.

main: 20
printNumber: 10

main 함수 안에도 number가 있고, printNumber 함수 안에도 number가 있습니다. 이름은 같지만 둘은 완전히 다른 변수입니다. 마치 같은 이름을 가진 사람이 서로 다른 집에 사는 것과 같습니다.

main의 number는 main 함수의 중괄호 안에서만 사용할 수 있습니다.

int main(void)
{
    int number = 20;

    return 0;
}

여기서 number의 스코프는 대략 선언된 지점부터 main 함수의 닫는 중괄호 }까지입니다. printNumber 함수는 main 안의 number를 직접 볼 수 없습니다.

반대로 printNumber 안의 number도 main에서 볼 수 없습니다.

void printNumber(void)
{
    int number = 10;
}

int main(void)
{
    printf("%dn", number); // 오류: number를 알 수 없음
    return 0;
}

컴파일러 입장에서는 main 안에서 number라는 이름을 찾으려고 했지만, 그 이름이 현재 범위에 없으므로 오류를 냅니다.

지역 변수는 함수 안에서 선언된 변수만 의미하지 않습니다. 더 정확히 말하면 블록 안에서 선언된 변수입니다. 블록은 중괄호 { }로 묶인 영역입니다.

#include <stdio.h>

int main(void)
{
    int a = 10;

    if (a > 0)
    {
        int b = 20;
        printf("a = %dn", a);
        printf("b = %dn", b);
    }

    printf("a = %dn", a);
    // printf("b = %dn", b); // 오류: b는 if 블록 안에서만 보임

    return 0;
}

a는 main 함수 블록에서 선언되었으므로 if 블록 안에서도 사용할 수 있습니다. 바깥 블록에서 선언한 변수는 안쪽 블록에서 보입니다. 하지만 b는 if 블록 안에서 선언되었으므로 그 블록이 끝나면 이름을 사용할 수 없습니다.

이 규칙은 반복문에서도 똑같이 적용됩니다.

#include <stdio.h>

int main(void)
{
    for (int i = 0; i < 3; i++)
    {
        printf("i = %dn", i);
    }

    // printf("%dn", i); // 오류: i는 for문 안에서만 보임

    return 0;
}

for문 안에서 선언한 i는 그 반복문 안에서만 사용할 수 있습니다. 반복문이 끝난 뒤에는 i라는 이름이 더 이상 보이지 않습니다.

처음에는 불편하게 느껴질 수 있습니다. 하지만 사실 이것은 좋은 규칙입니다. 반복문에서 잠깐 쓰는 i가 프로그램의 다른 부분에 남아 있으면 오히려 혼란을 만듭니다. 필요한 곳에서만 보이고, 필요가 끝나면 사라지는 변수가 좋은 변수입니다.

같은 이름의 변수: 가려짐을 조심하자

C에서는 안쪽 블록에서 바깥쪽 변수와 같은 이름의 변수를 새로 선언할 수 있습니다.

#include <stdio.h>

int main(void)
{
    int score = 100;

    printf("outer score = %dn", score);

    {
        int score = 50;
        printf("inner score = %dn", score);
    }

    printf("outer score = %dn", score);

    return 0;
}

실행 결과는 다음과 같습니다.

outer score = 100
inner score = 50
outer score = 100

안쪽 블록의 score는 바깥쪽 블록의 score를 잠시 가립니다. 이것을 보통 섀도잉(shadowing), 즉 이름 가리기라고 부릅니다. 안쪽 블록 안에서는 score라고 쓰면 안쪽의 score를 뜻합니다. 바깥쪽 score는 사라진 것이 아니라 잠시 이름으로 접근하기 어려워졌을 뿐입니다.

작은 예제에서는 재미있어 보이지만 실제 코드에서는 위험할 수 있습니다.

#include <stdio.h>

int main(void)
{
    int count = 10;

    if (count > 0)
    {
        int count = 0;
        count++;
    }

    printf("count = %dn", count);

    return 0;
}

이 코드를 처음 보면 count++ 때문에 마지막 출력이 11일 것 같지만 결과는 10입니다. if 블록 안에서 새로 만든 count만 1 증가했기 때문입니다. 바깥의 count는 그대로입니다.

따라서 같은 이름의 변수를 안쪽 블록에서 다시 선언하는 습관은 피하는 편이 좋습니다. 특별한 이유가 없다면 변수 이름은 헷갈리지 않게 다르게 짓는 것이 안전합니다.

함수 호출과 스택 프레임

이제 변수의 수명을 살펴보겠습니다. 다음 코드를 보겠습니다.

#include <stdio.h>

void countOnce(void)
{
    int count = 0;
    count++;
    printf("count = %dn", count);
}

int main(void)
{
    countOnce();
    countOnce();
    countOnce();

    return 0;
}

결과는 어떻게 될까요?

count = 1
count = 1
count = 1

countOnce를 세 번 호출했으니 1, 2, 3이 나올 것 같지만 그렇지 않습니다. 함수가 호출될 때마다 count는 새로 만들어지고, 함수가 끝나면 사라집니다. 다음 호출 때는 다시 int count = 0;부터 시작합니다.

이것을 이해하려면 스택 프레임(stack frame)이라는 개념을 알면 좋습니다.

프로그램이 함수를 호출하면 C 실행 환경은 그 함수가 일하는 데 필요한 임시 작업 공간을 마련합니다. 이 작업 공간에는 보통 함수의 매개변수, 지역 변수, 함수가 끝난 뒤 돌아갈 위치 같은 정보가 들어갑니다. 이 한 덩어리의 작업 공간을 스택 프레임이라고 생각하면 됩니다.

예를 들어 main에서 countOnce를 호출하면 흐름은 대략 이렇습니다.

  1. main 함수의 작업 공간이 있다.

  2. countOnce를 호출하면 countOnce용 작업 공간이 새로 쌓인다.

  3. 그 안에 지역 변수 count가 만들어진다.

  4. countOnce가 끝나면 그 작업 공간이 제거된다.

  5. 다시 main으로 돌아온다.

스택은 접시를 쌓는 방식과 비슷합니다. 마지막에 올린 접시를 먼저 치웁니다. 함수 호출도 비슷합니다. 나중에 호출된 함수가 먼저 끝나고, 그 함수의 지역 변수도 함께 사라집니다.

countOnce 호출 중

┌──────────────────┐
│ countOnce 프레임  │  ← count가 여기 있음
├──────────────────┤
│ main 프레임       │
└──────────────────┘

countOnce 종료 후

┌──────────────────┐
│ main 프레임       │
└──────────────────┘

그래서 지역 변수는 함수가 끝난 뒤에 더 이상 사용할 수 없습니다. 이름만 안 보이는 것이 아니라, 그 변수를 위한 작업 공간 자체가 정리됩니다.

이 규칙은 앞으로 포인터를 배울 때 매우 중요해집니다. 지역 변수의 주소를 함수 밖으로 가지고 나가서 사용하려 하면 이미 철거된 집의 주소를 들고 찾아가는 것과 비슷한 일이 벌어질 수 있습니다.

매개변수도 지역 변수다

함수의 매개변수도 지역 변수처럼 동작합니다.

#include <stdio.h>

void change(int x)
{
    x = 100;
    printf("change 안의 x = %dn", x);
}

int main(void)
{
    int number = 10;

    change(number);
    printf("main 안의 number = %dn", number);

    return 0;
}

실행 결과는 다음과 같습니다.

change 안의 x = 100
main 안의 number = 10

앞 장에서 배운 것처럼 C는 기본적으로 값을 복사해서 함수에 전달합니다. change(number)를 호출하면 number의 값인 10이 x에 복사됩니다. 그 뒤 x를 100으로 바꾸어도 main의 number는 바뀌지 않습니다.

여기서 x는 change 함수의 지역 변수입니다. change가 호출될 때 만들어지고, change가 끝나면 사라집니다.

main의 number  ──값 복사──▶  change의 x
      10                       10 → 100

두 변수는 서로 다른 공간입니다. 이름이 다를 뿐 아니라 메모리 위치도 다릅니다. 나중에 포인터를 배우면 함수 밖의 원본 값을 바꾸는 방법을 알게 됩니다. 하지만 지금 단계에서는 이렇게 기억하면 됩니다.

함수에 값을 넘긴다는 것은 원본을 넘기는 것이 아니라 복사본을 넘기는 것입니다.

전역 변수: 파일 전체에서 보이는 변수

이번에는 함수 밖에서 변수를 선언해 보겠습니다.

#include <stdio.h>

int score = 0;

void addScore(int point)
{
    score += point;
}

void printScore(void)
{
    printf("score = %dn", score);
}

int main(void)
{
    addScore(10);
    addScore(20);
    printScore();

    return 0;
}

score는 어떤 함수 안에도 들어 있지 않습니다. 이런 변수를 전역 변수(global variable)라고 부릅니다. 전역 변수는 함수 밖에서 선언되며, 같은 파일 안의 여러 함수에서 사용할 수 있습니다.

위 코드에서 addScore와 printScore는 모두 score를 직접 사용합니다. 이 점은 편리합니다. 매번 함수에 score를 매개변수로 넘기지 않아도 됩니다.

하지만 편리함에는 대가가 있습니다.

전역 변수는 많은 함수가 함께 만지는 공용 칠판과 같습니다. 누가 언제 무엇을 썼는지 분명하지 않으면 금방 혼란이 생깁니다.

다음 예제를 보겠습니다.

#include <stdio.h>

int balance = 10000;

void deposit(int amount)
{
    balance += amount;
}

void withdraw(int amount)
{
    balance -= amount;
}

void applyFee(void)
{
    balance -= 1000;
}

int main(void)
{
    deposit(5000);
    withdraw(3000);
    applyFee();

    printf("balance = %dn", balance);

    return 0;
}

이 정도는 아직 괜찮아 보입니다. 하지만 기능이 조금만 늘어나면 문제가 됩니다.

#include <stdio.h>

int balance = 10000;
int isVip = 0;

void deposit(int amount)
{
    balance += amount;

    if (balance >= 100000)
    {
        isVip = 1;
    }
}

void withdraw(int amount)
{
    balance -= amount;

    if (balance < 100000)
    {
        isVip = 0;
    }
}

void printAccount(void)
{
    printf("balance = %dn", balance);
    printf("VIP = %dn", isVip);
}

int main(void)
{
    deposit(95000);
    withdraw(10000);
    printAccount();

    return 0;
}

이제 balance와 isVip가 서로 연결되기 시작했습니다. deposit도 isVip를 바꾸고, withdraw도 isVip를 바꿉니다. 나중에 transfer, refund, cancelPayment 같은 함수가 추가되면 어떨까요? 어디에서 balance가 바뀌고, 어디에서 isVip가 바뀌는지 추적하기 어려워집니다.

전역 변수의 가장 큰 문제는 의존성이 숨어 버린다는 것입니다.

void printAccount(void)
{
    printf("balance = %dn", balance);
    printf("VIP = %dn", isVip);
}

이 함수의 매개변수 목록만 보면 아무 값도 받지 않는 것처럼 보입니다. 하지만 실제로는 전역 변수 balance와 isVip에 강하게 의존합니다. 함수 선언만 보고는 이 함수가 어떤 데이터를 필요로 하는지 알기 어렵습니다.

반면 매개변수로 필요한 값을 명시하면 관계가 훨씬 분명해집니다.

#include <stdio.h>

void printAccount(int balance, int isVip)
{
    printf("balance = %dn", balance);
    printf("VIP = %dn", isVip);
}

int main(void)
{
    int balance = 10000;
    int isVip = 0;

    printAccount(balance, isVip);

    return 0;
}

이제 printAccount가 무엇을 필요로 하는지 함수의 머리만 봐도 알 수 있습니다.

void printAccount(int balance, int isVip)

좋은 함수는 가능한 한 필요한 값을 매개변수로 받고, 결과를 반환값으로 돌려줍니다. 그래야 함수가 외부 세계에 덜 의존하고, 따로 떼어 테스트하기도 쉬워집니다.

전역 변수는 언제 만들어지고 언제 사라질까?

지역 변수는 함수 호출 때 만들어지고 함수가 끝나면 사라졌습니다. 그렇다면 전역 변수는 어떨까요?

전역 변수는 프로그램이 시작될 때 준비되고, 프로그램이 끝날 때까지 살아 있습니다. 그래서 여러 함수가 같은 전역 변수를 계속 공유할 수 있습니다.

#include <stdio.h>

int count = 0;

void increase(void)
{
    count++;
    printf("count = %dn", count);
}

int main(void)
{
    increase();
    increase();
    increase();

    return 0;
}

실행 결과는 다음과 같습니다.

count = 1
count = 2
count = 3

전역 변수 count는 increase 함수가 끝나도 사라지지 않습니다. 프로그램 전체가 끝날 때까지 계속 남아 있습니다.

또 하나의 차이가 있습니다. 전역 변수는 초기값을 직접 쓰지 않으면 자동으로 0으로 초기화됩니다.

#include <stdio.h>

int globalNumber;

int main(void)
{
    printf("globalNumber = %dn", globalNumber);
    return 0;
}

이 코드는 보통 다음처럼 출력됩니다.

globalNumber = 0

반면 지역 변수는 초기화하지 않고 사용하면 안 됩니다.

#include <stdio.h>

int main(void)
{
    int localNumber;

    printf("localNumber = %dn", localNumber); // 위험

    return 0;
}

지역 변수 localNumber에는 어떤 값이 들어 있을지 알 수 없습니다. C에서는 이것을 "쓰레기값이 들어 있다"고 표현하기도 합니다. 정확히는 초기화되지 않은 지역 변수의 값은 예측할 수 없습니다. 따라서 지역 변수는 선언할 때 초기값을 주는 습관을 들이는 것이 좋습니다.

int localNumber = 0;

파일 단위 스코프와 선언 위치

전역 변수는 파일 전체에서 보인다고 말했지만, 조금 더 정확히 말하면 선언된 위치 이후부터 같은 파일의 끝까지 보입니다.

#include <stdio.h>

void printNumber(void)
{
    printf("%dn", number); // 오류: 아직 number를 모름
}

int number = 10;

int main(void)
{
    printNumber();
    return 0;
}

number는 함수 밖에서 선언되었지만, printNumber보다 아래에 있습니다. C 컴파일러는 위에서 아래로 코드를 읽기 때문에 printNumber를 해석하는 시점에는 아직 number를 모릅니다.

다음처럼 전역 변수를 함수보다 위에 두면 사용할 수 있습니다.

#include <stdio.h>

int number = 10;

void printNumber(void)
{
    printf("%dn", number);
}

int main(void)
{
    printNumber();
    return 0;
}

물론 전역 변수를 많이 쓰라는 뜻은 아닙니다. 다만 전역 변수의 스코프가 중괄호 블록이 아니라 파일 단위로 넓어진다는 점을 이해해야 합니다.

나중에 여러 .c 파일로 프로그램을 나누면 전역 변수 이야기는 더 복잡해집니다. 어떤 전역 변수는 다른 파일에서도 보이게 할 수 있고, 어떤 전역 변수는 현재 파일 안에만 숨길 수 있습니다. 이 내용은 여러 파일로 프로그램을 나누는 장에서 다시 다루겠습니다.

지금은 이렇게 정리하면 충분합니다.

지역 변수는 중괄호 블록을 기준으로 보입니다. 전역 변수는 함수 밖에 선언되며, 같은 파일의 여러 함수에서 사용할 수 있습니다.

static 지역 변수: 보이는 곳은 좁고, 수명은 길다

이제 조금 특별한 변수를 만나 보겠습니다. 바로 static 지역 변수입니다.

먼저 일반 지역 변수 예제를 다시 보겠습니다.

#include <stdio.h>

void countOnce(void)
{
    int count = 0;
    count++;
    printf("count = %dn", count);
}

int main(void)
{
    countOnce();
    countOnce();
    countOnce();

    return 0;
}

결과는 다음과 같습니다.

count = 1
count = 1
count = 1

이번에는 count 앞에 static을 붙여 보겠습니다.

#include <stdio.h>

void countMany(void)
{
    static int count = 0;
    count++;
    printf("count = %dn", count);
}

int main(void)
{
    countMany();
    countMany();
    countMany();

    return 0;
}

실행 결과는 달라집니다.

count = 1
count = 2
count = 3

static int count = 0;은 함수 안에 선언되어 있으므로 이름은 countMany 함수 안에서만 보입니다. 즉, 스코프는 지역 변수처럼 좁습니다.

하지만 수명은 일반 지역 변수와 다릅니다. static 지역 변수는 프로그램이 시작될 때 준비되고, 프로그램이 끝날 때까지 살아 있습니다. 함수 호출이 끝나도 값이 사라지지 않습니다. 다음 호출 때 이전 값을 그대로 기억합니다.

이 점이 static 지역 변수의 핵심입니다.

일반 지역 변수
- 함수 안에서만 보임
- 함수 호출 때 만들어짐
- 함수가 끝나면 사라짐

static 지역 변수
- 함수 안에서만 보임
- 프로그램이 끝날 때까지 살아 있음
- 함수가 끝나도 값을 기억함

static 지역 변수는 "밖에서는 건드릴 수 없지만, 함수는 자기만의 기억을 가지고 싶을 때" 유용합니다.

예를 들어 함수가 몇 번 호출되었는지 세고 싶다면 다음처럼 쓸 수 있습니다.

#include <stdio.h>

void printCallCount(void)
{
    static int callCount = 0;

    callCount++;
    printf("이 함수는 %d번 호출되었습니다.n", callCount);
}

int main(void)
{
    printCallCount();
    printCallCount();
    printCallCount();

    return 0;
}

결과는 다음과 같습니다.

이 함수는 1번 호출되었습니다.
이 함수는 2번 호출되었습니다.
이 함수는 3번 호출되었습니다.

전역 변수로도 비슷하게 만들 수 있습니다.

#include <stdio.h>

int callCount = 0;

void printCallCount(void)
{
    callCount++;
    printf("이 함수는 %d번 호출되었습니다.n", callCount);
}

하지만 이렇게 하면 callCount를 다른 함수에서도 마음대로 바꿀 수 있습니다.

void resetCount(void)
{
    callCount = 0;
}

이 함수가 정말 필요한 함수라면 괜찮습니다. 하지만 실수로 값을 바꾸는 함수가 생겨도 막기 어렵습니다. 반면 static 지역 변수는 함수 밖에서 이름이 보이지 않으므로 외부에서 직접 건드릴 수 없습니다.

void printCallCount(void)
{
    static int callCount = 0;
    callCount++;
    printf("%dn", callCount);
}

int main(void)
{
    // callCount = 0; // 오류: 여기서는 callCount가 보이지 않음
    printCallCount();
    return 0;
}

이처럼 static 지역 변수는 전역 변수와 지역 변수의 중간 성격을 갖습니다. 수명은 전역 변수처럼 길고, 스코프는 지역 변수처럼 좁습니다.

static을 쓸 때 조심할 점

static 지역 변수는 편리하지만 남용하면 함수의 동작을 예측하기 어려워질 수 있습니다.

다음 함수를 보겠습니다.

#include <stdio.h>

int nextId(void)
{
    static int id = 0;
    id++;
    return id;
}

int main(void)
{
    printf("%dn", nextId());
    printf("%dn", nextId());
    printf("%dn", nextId());

    return 0;
}

이 함수는 호출할 때마다 새로운 번호를 줍니다.

1
2
3

이런 경우에는 static이 꽤 자연스럽습니다. 함수가 자기 내부에 작은 상태를 기억하고 있기 때문입니다.

하지만 다음처럼 계산 함수 안에 static 상태가 숨어 있으면 이야기가 달라집니다.

#include <stdio.h>

int addWithMemory(int x)
{
    static int total = 0;
    total += x;
    return total;
}

int main(void)
{
    printf("%dn", addWithMemory(10));
    printf("%dn", addWithMemory(10));
    printf("%dn", addWithMemory(10));

    return 0;
}

이 함수는 이름만 보면 x를 더한 값을 반환할 것 같지만, 실제로는 이전 호출의 결과를 계속 기억합니다. 그래서 같은 인수 10을 넣어도 결과가 매번 달라집니다.

10
20
30

이런 함수는 사용할 때 조심해야 합니다. 입력이 같으면 결과도 같을 것이라고 기대했는데, 함수 안에 숨은 상태 때문에 결과가 달라질 수 있기 때문입니다.

static 지역 변수는 "정말 함수가 값을 기억해야 하는가?"를 생각한 뒤 사용해야 합니다. 단순 계산이라면 static 없이 매개변수와 반환값만 사용하는 편이 더 좋습니다.

전역 변수의 편리함이 버그가 되는 순간

작은 프로젝트 예제를 하나 보겠습니다. 간단한 게임 점수 프로그램입니다.

#include <stdio.h>

int score = 0;
int combo = 0;

void hitEnemy(void)
{
    combo++;
    score += 100 * combo;
}

void missAttack(void)
{
    combo = 0;
}

void printStatus(void)
{
    printf("score = %d, combo = %dn", score, combo);
}

int main(void)
{
    hitEnemy();
    hitEnemy();
    printStatus();

    missAttack();
    hitEnemy();
    printStatus();

    return 0;
}

이 코드는 전역 변수 score와 combo를 사용합니다. 처음에는 단순합니다. 적을 맞히면 콤보가 오르고, 점수도 증가합니다. 공격이 빗나가면 콤보가 0으로 초기화됩니다.

그런데 게임에 아이템 기능을 추가한다고 해 봅시다.

void useBonusItem(void)
{
    score += 1000;
    combo = 0;
}

보너스 아이템은 점수를 올리는 기능입니다. 그런데 누군가 실수로 combo = 0;도 넣었습니다. 이제 보너스 아이템을 쓰면 콤보가 끊깁니다. 만약 이 사실을 모르는 사람이 useBonusItem을 호출하면 예상치 못한 버그가 생깁니다.

전역 변수의 문제는 여기서 드러납니다. 전역 변수는 너무 많은 함수가 쉽게 접근할 수 있습니다. 그래서 어떤 함수가 어떤 값을 바꿨는지 추적하기 어려워집니다.

조금 더 나은 방식은 상태를 함수에 명시적으로 전달하는 것입니다. 아직 구조체를 배우지 않았으므로 완벽한 형태는 아니지만, 방향은 볼 수 있습니다.

#include <stdio.h>

int hitEnemy(int score, int combo)
{
    return score + 100 * combo;
}

int main(void)
{
    int score = 0;
    int combo = 0;

    combo++;
    score = hitEnemy(score, combo);

    combo++;
    score = hitEnemy(score, combo);

    printf("score = %d, combo = %dn", score, combo);

    return 0;
}

이 코드는 조금 번거롭지만, hitEnemy가 어떤 값을 받아 어떤 값을 돌려주는지 더 분명합니다. 나중에 구조체를 배우면 score와 combo처럼 함께 움직이는 값을 하나의 묶음으로 다룰 수 있습니다.

// 나중에 배울 구조체의 느낌만 미리 보기
// Player player;
// hitEnemy(&player);

지금 중요한 것은 이것입니다. 전역 변수는 코드를 짧게 만들 수 있지만, 프로그램의 관계를 숨깁니다. 코드가 커질수록 숨은 관계는 버그가 되기 쉽습니다.

변수 범위를 좁게 유지해야 하는 이유

좋은 C 코드를 쓰는 사람은 변수를 아무 데나 크게 열어 두지 않습니다. 가능한 한 필요한 곳에서만 보이게 만듭니다. 이것은 단순한 취향이 아니라 유지보수의 문제입니다.

예를 들어 다음 코드를 보겠습니다.

#include <stdio.h>

int main(void)
{
    int i;
    int sum;
    int input;

    sum = 0;

    for (i = 0; i < 5; i++)
    {
        printf("숫자 입력: ");
        scanf("%d", &input);
        sum += input;
    }

    printf("합계: %dn", sum);

    return 0;
}

동작은 문제없습니다. 하지만 모든 변수를 함수 시작 부분에 몰아서 선언하면 각 변수가 어디에서 필요한지 한눈에 보기 어렵습니다.

다음처럼 필요한 곳 가까이에 선언하면 읽기 쉬워집니다.

#include <stdio.h>

int main(void)
{
    int sum = 0;

    for (int i = 0; i < 5; i++)
    {
        int input = 0;

        printf("숫자 입력: ");
        scanf("%d", &input);
        sum += input;
    }

    printf("합계: %dn", sum);

    return 0;
}

sum은 반복 전체에서 필요하므로 for문 밖에 있습니다. i는 반복문 안에서만 필요하므로 for문 안에서 선언했습니다. input은 각 반복에서 입력받을 때만 필요하므로 반복문 블록 안에 선언했습니다.

이렇게 하면 변수를 읽을 때 자연스럽게 의도를 알 수 있습니다.

  • sum은 반복이 끝날 때까지 누적되어야 한다.

  • i는 반복 횟수를 세기 위한 임시 변수다.

  • input은 한 번의 반복에서 입력받는 임시 값이다.

변수의 범위를 좁히면 실수로 엉뚱한 곳에서 값을 바꾸는 일을 줄일 수 있습니다. 변수 이름을 재사용할 때도 충돌 가능성이 낮아집니다. 코드를 읽는 사람은 "이 변수가 어디까지 영향을 주지?"를 덜 고민해도 됩니다.

C89라고 부르는 오래된 C 표준에서는 지역 변수를 블록의 시작 부분에 몰아서 선언해야 했습니다. 하지만 현대적인 C 코드에서는 대체로 변수를 필요한 위치 가까이에 선언할 수 있습니다. 이 책에서는 처음 배우는 독자가 이해하기 쉽도록 변수를 사용하는 곳과 가까운 위치에 선언하는 방식을 주로 사용하겠습니다.

나쁜 전역 변수를 줄이는 습관

전역 변수를 무조건 쓰지 말라는 뜻은 아닙니다. 프로그램 전체에서 공유해야 하는 설정값이나, 아주 작은 예제에서 상태를 단순히 보여 주기 위한 변수는 전역으로 둘 수도 있습니다. 하지만 처음부터 전역 변수에 기대는 습관이 들면 함수 설계가 흐려집니다.

전역 변수를 줄이고 싶다면 다음 기준을 떠올리면 좋습니다.

  • 함수가 필요한 값은 가능하면 매개변수로 받는다.

  • 함수가 계산한 결과는 가능하면 반환값으로 돌려준다.

  • 여러 함수가 함께 바꾸는 값은 정말 공유 상태가 맞는지 먼저 의심한다.

  • 변수는 가능한 한 작은 중괄호 블록 안에서 선언한다.

  • 전역 상수와 전역 변수는 구분해서 생각한다.

여기서 마지막 항목은 조금 설명이 필요합니다. 다음과 같은 값은 프로그램 전체에서 의미가 변하지 않는 상수입니다.

#include <stdio.h>

const int MAX_RETRY = 3;

int main(void)
{
    printf("최대 재시도 횟수: %dn", MAX_RETRY);
    return 0;
}

MAX_RETRY처럼 변하지 않는 값은 전역으로 두어도 비교적 안전합니다. 물론 나중에 전처리기와 헤더 파일을 배우면 상수를 더 체계적으로 관리하는 방법도 다루게 됩니다.

반면 다음과 같은 전역 변수는 조심해야 합니다.

int currentUserId;
int errorCode;
int totalPrice;

이런 변수들은 프로그램 실행 중 값이 계속 바뀔 가능성이 큽니다. 여러 함수가 마음대로 바꾸기 시작하면 버그를 추적하기 어려워집니다.

좋은 질문은 이것입니다.

"이 변수를 꼭 모두가 알아야 할까?"

대부분의 경우 답은 "아니요"입니다. 그 함수 안에서만 필요하다면 지역 변수로 두면 됩니다. 반복문 안에서만 필요하다면 반복문 안에 두면 됩니다. 함수 몇 개가 함께 다루어야 한다면 나중에 배울 구조체로 묶어 전달할 수 있습니다.

스코프와 수명을 함께 보기

이제 지금까지 배운 내용을 한눈에 정리해 보겠습니다.

#include <stdio.h>

int globalCount = 0;

void function(void)
{
    int localCount = 0;
    static int staticCount = 0;

    globalCount++;
    localCount++;
    staticCount++;

    printf("globalCount = %dn", globalCount);
    printf("localCount = %dn", localCount);
    printf("staticCount = %dn", staticCount);
    printf("---n");
}

int main(void)
{
    function();
    function();
    function();

    return 0;
}

실행 결과는 다음과 같습니다.

globalCount = 1
localCount = 1
staticCount = 1
---
globalCount = 2
localCount = 1
staticCount = 2
---
globalCount = 3
localCount = 1
staticCount = 3
---

세 변수의 차이를 살펴봅시다.

globalCount는 전역 변수입니다. 함수 밖에서 선언되었고, 프로그램이 끝날 때까지 살아 있습니다. function이 여러 번 호출되어도 값이 계속 유지됩니다.

localCount는 일반 지역 변수입니다. function이 호출될 때마다 새로 만들어지고, 함수가 끝나면 사라집니다. 그래서 매번 0에서 시작해 1이 됩니다.

staticCount는 static 지역 변수입니다. 이름은 function 안에서만 보이지만, 값은 프로그램이 끝날 때까지 유지됩니다. 그래서 호출할 때마다 1, 2, 3으로 증가합니다.

표로 정리하면 다음과 같습니다.

종류선언 위치이름이 보이는 범위값이 살아 있는 기간
지역 변수함수 또는 블록 안선언된 블록 안블록 실행 중, 보통 함수 호출 중
매개변수함수의 괄호 안해당 함수 안함수 호출 중
전역 변수함수 밖같은 파일의 선언 이후프로그램 시작부터 종료까지
static 지역 변수함수 또는 블록 안선언된 블록 안프로그램 시작부터 종료까지

이 표에서 가장 중요한 줄은 static 지역 변수입니다. 스코프와 수명이 반드시 함께 움직이는 것은 아니라는 점을 보여 주기 때문입니다. staticCount는 이름은 좁게 보이지만 값은 오래 살아 있습니다.

작은 실습: 호출 횟수 세기

이번에는 직접 생각해 봅시다. 다음 프로그램의 출력은 무엇일까요?

#include <stdio.h>

void printNumber(void)
{
    int a = 0;
    static int b = 0;

    a++;
    b++;

    printf("a = %d, b = %dn", a, b);
}

int main(void)
{
    printNumber();
    printNumber();
    printNumber();

    return 0;
}

정답은 다음과 같습니다.

a = 1, b = 1
a = 1, b = 2
a = 1, b = 3

a는 일반 지역 변수이므로 함수 호출 때마다 새로 만들어집니다. 그래서 매번 1입니다. b는 static 지역 변수이므로 이전 값을 기억합니다. 그래서 1, 2, 3으로 증가합니다.

이제 코드를 조금 바꿔 보겠습니다.

#include <stdio.h>

int number = 100;

void printNumber(void)
{
    int number = 10;
    printf("function number = %dn", number);
}

int main(void)
{
    printNumber();
    printf("global number = %dn", number);

    return 0;
}

출력은 다음과 같습니다.

function number = 10
global number = 100

printNumber 안의 number는 전역 변수 number를 가립니다. 함수 안에서 number라고 쓰면 지역 변수를 뜻합니다. 함수 밖의 main에서 number라고 쓰면 전역 변수를 뜻합니다.

이 예제는 중요한 경고를 담고 있습니다. 같은 이름을 여러 범위에서 쓰면 컴파일은 될 수 있지만, 사람이 헷갈립니다. C 프로그램에서 사람이 헷갈리는 코드는 결국 버그가 됩니다.

이번 장의 핵심 정리

스코프와 수명은 C언어를 단순한 문법이 아니라 메모리 위에서 실행되는 프로그램으로 이해하게 해 주는 중요한 개념입니다.

스코프는 변수 이름을 사용할 수 있는 범위입니다. 지역 변수는 선언된 중괄호 블록 안에서만 보입니다. 전역 변수는 함수 밖에서 선언되며, 같은 파일의 여러 함수에서 사용할 수 있습니다. 안쪽 블록에서 같은 이름의 변수를 선언하면 바깥쪽 변수가 가려질 수 있으므로 조심해야 합니다.

수명은 변수가 메모리에 살아 있는 기간입니다. 일반 지역 변수와 매개변수는 함수 호출 중에 만들어지고 함수가 끝나면 사라집니다. 이때 함수마다 스택 프레임이라는 임시 작업 공간이 생기고 제거된다고 생각하면 이해하기 쉽습니다. 전역 변수와 static 지역 변수는 프로그램이 끝날 때까지 살아 있습니다.

전역 변수는 편리하지만 의존성을 숨깁니다. 함수가 어떤 값을 사용하는지 매개변수만 보고 알 수 없게 만들고, 여러 함수가 같은 값을 마음대로 바꾸면서 버그를 만들기 쉽습니다. 코드가 커질수록 변수의 범위는 좁게 유지하는 것이 좋습니다.

기억해야 할 문장은 하나입니다.

변수는 필요한 곳에서만 보이게 하고, 필요한 동안만 살아 있게 하자.

다음 장에서는 함수가 자기 자신을 호출하는 특별한 구조인 재귀 함수를 배웁니다. 재귀를 이해하려면 이번 장에서 배운 스택 프레임의 감각이 매우 중요합니다. 함수가 호출될 때마다 새로운 작업 공간이 생긴다는 사실을 기억해 두면, 재귀가 훨씬 덜 무섭게 느껴질 것입니다.

책 목록

처음 만나는 C언어: 메모리와 포인터로 배우는 컴퓨터의 원리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기