[C언어 50강] 18강. 저장 클래스: static/extern 개념, 전역 관리 포인트
저장 클래스(storage class)는 변수의 “타입”이 아니라, 그 변수가 어디에 살아 있고(수명, lifetime), 어디서 보이며(가시성, scope/linkage), 기본적으로 어떻게 연결되는지를 결정하는 규칙입니다. 오늘 다룰 static과 extern은 문법은 짧지만 프로젝트가 커질수록 품질을 좌우하는 핵심 도구입니다. 특히 전역 변수 관리가 헝클어지기 시작할 때, 저장 클래스를 이해했는지 여부가 코드베이스의 안정성을 크게 갈라놓습니다.
핵심 개념
static은 문맥에 따라 의미가 달라집니다. 함수 내부에서는 “값을 호출 사이에 유지”하고, 파일 전역에서는 “해당 번역 단위(.c 파일) 내부로 심볼을 숨김” 역할을 합니다.extern은 “이 변수/함수 정의는 다른 파일에 있다”는 선언입니다. 소유권을 옮기는 것이 아니라 참조 경로를 명시합니다.- 전역 상태를 관리할 때는 “누가 정의(define)하고 누가 선언(declare)하는지”를 분리해야 링크 에러와 의도치 않은 결합을 막을 수 있습니다.
개념 먼저 이해하기
저장 클래스를 실무에서 어렵게 느끼는 이유는, 코드 한 줄이 컴파일러 단계·링커 단계·런타임 단계에 서로 다른 영향을 주기 때문입니다. 예를 들어 int g;를 파일 전역에 쓰면 이건 단순 선언처럼 보여도 실제로는 “정의”가 될 수 있고, 다른 파일에서 같은 이름의 전역을 또 정의하면 링크 단계에서 충돌이 납니다. 반대로 extern int g;는 저장 공간을 만들지 않고 “다른 데 있는 g를 쓰겠다”는 약속만 남깁니다. 이 차이를 놓치면 컴파일은 되는데 링크에서 터지는 상황을 반복하게 됩니다.
static은 더 자주 오해됩니다. 함수 안에서 static int count = 0;라고 하면, 지역 변수처럼 보이지만 호출이 끝나도 값이 사라지지 않습니다. 즉 스택에 잠깐 생겼다 없어지는 자동 변수(auto)가 아니라, 프로그램 수명 동안 살아있는 정적 저장 영역에 놓입니다. 그래서 함수가 여러 번 호출되어도 이전 값이 유지됩니다. 카운터나 캐시처럼 상태 유지가 필요한 곳에서 유용하지만, 테스트 독립성을 해치거나 멀티스레드 안전성을 깨뜨릴 수 있어 의도적으로 써야 합니다.
파일 전역에서 static을 붙이는 경우는 의미가 완전히 달라집니다. 이번에는 수명보다 링케이지(linkage) 가 핵심입니다. static int internal_flag;는 같은 파일 내부에서만 보이는 전역이 됩니다. 즉 다른 .c 파일에서 extern int internal_flag;로 접근할 수 없습니다. 이건 “은닉(encapsulation)” 도구입니다. 모듈 내부 상태를 숨기고 외부에는 함수 API만 노출하는 C 스타일 설계의 기본입니다.
extern은 남발하면 편해 보이지만, 사실상 전역 결합도를 올리는 지름길입니다. 어느 파일에서나 extern으로 상태를 가져다 쓰기 시작하면 데이터 흐름이 흐려지고, 변경 영향 범위를 예측하기 어렵습니다. 그래서 실무에서는 보통 다음 원칙을 둡니다. (1) 전역 변수 정의는 단 한 파일에서만 한다. (2) 헤더에는 필요한 최소한의 extern 선언만 둔다. (3) 가능하면 전역 변수 대신 접근 함수(get/set 또는 domain API)를 노출한다. 이렇게 하면 링커 에러를 줄이는 수준을 넘어, 설계 자체가 명확해집니다.
또 한 가지 중요한 포인트는 초기화 시점입니다. 정적 저장 기간 변수(static 지역 변수 포함)는 프로그램 시작 시 0으로 초기화됩니다. 반면 자동 변수는 초기화하지 않으면 쓰레기 값을 가질 수 있습니다. 이 차이는 버그 재현성에도 영향을 줍니다. “내 PC에서는 우연히 동작”하는 코드가 배포 환경에서 깨지는 대표 원인 중 하나가 자동 변수 미초기화입니다.
결론적으로 저장 클래스는 문법 트릭이 아니라, 프로젝트의 경계를 정의하는 설계 언어입니다. static은 감출 것을 감추고 유지할 것을 유지하게 만들고, extern은 필요한 연결만 허용해야 합니다. 이 두 도구를 제대로 쓰면 C 프로젝트가 “어디서든 다 만질 수 있는 전역 덩어리”에서 “모듈 책임이 분리된 시스템”으로 바뀝니다.
기본 사용
예제 1) 함수 내부 static: 호출 사이 상태 유지
#include <stdio.h>
void visit_counter(void) {
static int count = 0; // 프로그램 시작 시 0 초기화, 호출 간 값 유지
count++;
printf("visit count = %d\n", count);
}
int main(void) {
visit_counter();
visit_counter();
visit_counter();
return 0;
}
설명:
count는 지역 스코프를 가지지만 자동 변수가 아니라 정적 저장 기간입니다.- 함수가 끝나도 값이 유지되므로 누적 카운트가 가능합니다.
- 테스트 코드에서는 이전 실행 상태가 남아 오해를 부를 수 있으니, 필요하면 reset API를 별도로 둬야 합니다.
예제 2) 파일 전역 static + extern: 모듈 경계 만들기
/* counter.h */
#ifndef COUNTER_H
#define COUNTER_H
void counter_inc(void);
int counter_get(void);
void counter_reset(void);
#endif
/* counter.c */
#include "counter.h"
static int g_counter = 0; // 이 파일 내부에서만 접근 가능
void counter_inc(void) { g_counter++; }
int counter_get(void) { return g_counter; }
void counter_reset(void) { g_counter = 0; }
/* main.c */
#include <stdio.h>
#include "counter.h"
int main(void) {
counter_inc();
counter_inc();
printf("counter = %d\n", counter_get());
counter_reset();
printf("counter = %d\n", counter_get());
return 0;
}
설명:
- 전역 상태
g_counter를static으로 숨겨 외부에서 직접 변경하지 못하게 했습니다. - 외부 파일은 오직 API(
counter_inc/get/reset)를 통해서만 상태에 접근합니다. - 전역 변수는 남아 있지만 “통제 가능한 전역”이 되어 유지보수성이 크게 좋아집니다.
예제 3) extern 사용의 정석: 정의 1개, 선언 여러 개
/* config.h */
#ifndef CONFIG_H
#define CONFIG_H
extern int g_port; // 선언(declaration)
extern const char *g_host; // 선언(declaration)
#endif
/* config.c */
#include "config.h"
int g_port = 8080; // 정의(definition) - 단 한 곳
const char *g_host = "127.0.0.1";
/* app.c */
#include <stdio.h>
#include "config.h"
int main(void) {
printf("connect to %s:%d\n", g_host, g_port);
return 0;
}
설명:
extern선언은 헤더에 모으고, 실제 정의는 반드시 한 파일에서만 수행합니다.- 같은 심볼을 둘 이상의
.c에서 정의하면 링크 충돌(duplicate symbol)로 실패합니다. - 전역 설정은 편리하지만 변경 지점이 많아지면 추적이 어려우므로, 규모가 커지면 구조체 설정 객체 + 초기화 함수 패턴으로 확장하는 것이 좋습니다.
자주 하는 실수
실수 1) 헤더에 전역 변수 “정의”를 넣어버림
- 원인:
extern없이int g_value = 0;를 헤더에 작성하고 여러.c에서 include함. - 해결: 헤더는 선언만(
extern int g_value;), 정의는 한.c로 고정합니다.
실수 2) 함수 내부 static을 남용해 숨은 상태를 만듦
- 원인: 편해서
static지역 변수를 계속 추가하다 보니 함수가 입력 없이도 동작이 달라짐. - 해결: 상태 유지가 필요하면 명시적 상태 객체를 전달하거나 reset 경로를 제공해 테스트 가능성을 확보합니다.
실수 3) 파일 전역 static과 extern 개념을 혼동
- 원인: “static이면 어디서나 오래 산다”만 기억하고 링크 가시성을 놓침.
- 해결:
static의 두 의미(수명 유지 vs 내부 링케이지)를 문맥별로 구분해 리뷰 체크리스트에 넣습니다.
실수 4) 전역 변수 쓰기 권한을 무제한으로 열어둠
- 원인: 어디서나
extern으로 가져와 읽고 쓰도록 방치. - 해결: 읽기 전용은
const를 붙이고, 쓰기가 필요한 값은 setter 함수로 검증 경로를 강제합니다.
실무 패턴
- One Definition Rule 습관화(C 버전): 전역 심볼 정의는 정확히 한 파일.
- 헤더 최소화: 헤더에는 타입/함수 선언 중심, 전역 변수는 꼭 필요할 때만
extern노출. - 은닉 우선: 외부에서 직접 만질 필요 없는 전역은 파일 전역
static으로 숨기기. - 테스트 친화 설계: static 상태를 쓰면 reset 함수와 상태 조회 함수를 함께 제공.
- 리팩터링 기준:
extern심볼이 늘어나기 시작하면 모듈 경계가 무너지는 신호로 보고 구조 재설계.
오늘의 결론
한 줄 요약: static과 extern은 변수 보관법이 아니라 모듈 경계와 상태 책임을 설계하는 도구이며, 전역 관리는 “정의 1개·노출 최소·접근 통제”가 기본 원칙입니다.
연습문제
logger.c에만 보이는 파일 전역static int log_level을 만들고,logger_set_level,logger_get_levelAPI를 작성해보세요.settings.h에는extern선언만 두고,settings.c에서만 실제 전역 설정 값을 정의해 링크 오류 없이 동작하게 구성해보세요.- 함수 내부
static카운터를 사용한 함수와, 상태 구조체를 인자로 받는 함수 두 버전을 만들고 테스트 용이성을 비교해보세요.
이전 강의 정답
지난 17강(재귀 함수) 연습문제 예시 정답입니다.
power(base, exp)재귀 구현 (exp < 0 정책: 실패 시 0.0 반환)
#include <stdio.h>
double power_recursive(double base, int exp) {
if (exp < 0) return 0.0; // 정책: 음수 지수는 이번 버전에서 실패 처리
if (exp == 0) return 1.0;
return base * power_recursive(base, exp - 1);
}
- 재귀로 배열 최댓값 찾기 (빈 배열 정책: INT_MIN 반환)
#include <limits.h>
int max_recursive(const int *arr, int n) {
if (arr == NULL || n <= 0) return INT_MIN;
if (n == 1) return arr[0];
int prev = max_recursive(arr, n - 1);
return (arr[n - 1] > prev) ? arr[n - 1] : prev;
}
- 재귀 회문 판별 (대소문자 구분 안 함)
#include <ctype.h>
#include <string.h>
int is_pal_recursive_ci(const char *s, int left, int right) {
if (left >= right) return 1;
char a = (char)tolower((unsigned char)s[left]);
char b = (char)tolower((unsigned char)s[right]);
if (a != b) return 0;
return is_pal_recursive_ci(s, left + 1, right - 1);
}
int is_palindrome_ci(const char *s) {
if (s == NULL) return 0;
int len = (int)strlen(s);
if (len == 0) return 1;
return is_pal_recursive_ci(s, 0, len - 1);
}
실습 환경/재현 정보
- 컴파일러: Apple clang 17.x 이상 또는 gcc 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -Werror -O0 - 실행 환경: macOS(arm64), Linux(x86_64) 공통 재현 가능
- 재현 체크:
- 함수 내부
static카운터가 호출 횟수만큼 누적되는지 확인 extern선언/정의 분리 시 링크가 정상 완료되는지 확인- 헤더에 전역 정의를 넣어 의도적으로 duplicate symbol 오류를 재현해보고 원인을 설명할 수 있는지 확인
- 함수 내부