콘텐츠로 이동

정적 및 전역 변수 (Static and Global Variables)

정적 저장 기간을 가진 객체는 자명하게 소멸 가능(trivially destructible)하지 않다면 금지됩니다. 비공식적으로 이는 멤버와 기반 클래스의 소멸자까지 고려하더라도 소멸자가 아무 일도 하지 않는다는 뜻입니다. 좀 더 공식적으로는, 그 타입에 사용자 정의 소멸자나 가상 소멸자가 없고 모든 기반 클래스와 비정적 멤버가 자명하게 소멸 가능하다는 뜻입니다. 함수 지역 정적 변수는 동적 초기화를 사용할 수 있습니다. 정적 클래스 멤버 변수나 네임스페이스 범위 변수에 동적 초기화를 사용하는 것은 권장되지 않지만, 제한된 상황에서는 허용됩니다. 자세한 내용은 아래를 참조하세요.

경험 법칙: 전역 변수의 선언을 따로 떼어놓고 보았을 때 constexpr이 될 수 있다면, 그 변수는 이 요구 사항을 충족합니다.

정의:

모든 객체에는 수명과 연관된 저장 기간(storage duration)이 있습니다. 정적 저장 기간을 가진 객체는 초기화된 시점부터 프로그램이 끝날 때까지 살아 있습니다. 이런 객체는 네임스페이스 범위의 변수("전역 변수"), 클래스의 정적 데이터 멤버, 또는 static 지정자로 선언된 함수 지역 변수의 형태로 나타납니다. 함수 지역 정적 변수는 제어가 그 선언을 처음 지날 때 초기화되고, 정적 저장 기간을 가진 그 밖의 모든 객체는 프로그램 시작 과정에서 초기화됩니다. 정적 저장 기간을 가진 모든 객체는 프로그램 종료 시에 소멸되며, 이는 조인되지 않은 스레드가 종료되기 전에 일어납니다.

초기화는 동적(dynamic)일 수 있습니다. 이는 초기화 과정에서 자명하지 않은 일이 일어난다는 뜻입니다. (예를 들어 메모리를 할당하는 생성자나, 현재 프로세스 ID로 초기화되는 변수를 떠올려 보세요.) 다른 종류의 초기화는 정적 초기화(static initialization)입니다. 다만 이 둘이 완전히 반대되는 개념은 아닙니다. 정적 초기화는 정적 저장 기간을 가진 객체에 항상 일어나며(객체를 주어진 상수로 초기화하거나, 모든 바이트가 0으로 설정된 표현으로 초기화합니다), 동적 초기화는 필요한 경우 그 뒤에 일어납니다.

장점:

전역 변수와 정적 변수는 명명된 상수, 특정 번역 단위(translation unit) 내부의 보조 자료 구조, 명령줄 플래그, 로깅, 등록 메커니즘, 백그라운드 인프라 등 아주 많은 용도에 매우 유용합니다.

단점:

동적 초기화를 사용하거나 자명하지 않은 소멸자를 가진 전역 변수와 정적 변수는 찾기 어려운 버그로 이어지기 쉬운 복잡성을 만들어 냅니다. 동적 초기화는 번역 단위 사이에 순서가 정해져 있지 않으며, 소멸도 마찬가지입니다(소멸이 초기화의 역순으로 일어난다는 점만 예외입니다). 어떤 초기화가 정적 저장 기간을 가진 다른 변수를 참조하면, 그 객체의 수명이 시작되기 전에(또는 수명이 끝난 뒤에) 객체에 접근하는 일이 생길 수 있습니다. 게다가 프로그램이 종료 시점에 조인되지 않는 스레드를 시작한다면, 소멸자가 이미 실행된 뒤에 그 스레드가 수명이 끝난 객체에 접근하려 할 수 있습니다.

결정:

소멸에 대한 결정

소멸자가 자명하다면 그 실행은 순서의 영향을 전혀 받지 않습니다(사실상 "실행"되지 않습니다). 그렇지 않다면 수명이 끝난 뒤에 객체에 접근할 위험에 노출됩니다. 따라서 정적 저장 기간을 가진 객체는 자명하게 소멸 가능할 때만 허용합니다. (포인터나 int 같은) 기본 타입은 자명하게 소멸 가능하며, 자명하게 소멸 가능한 타입의 배열도 마찬가지입니다. constexpr로 표시된 변수는 자명하게 소멸 가능하다는 점에 유의하세요.

const int kNum = 10;  // 허용

struct X { int n; };
const X kX[] = {{1}, {2}, {3}};  // 허용

void foo() {
  static const char* const kMessages[] = {"hello", "world"};  // 허용
}

// 허용: constexpr은 자명한 소멸자를 보장합니다.
constexpr std::array<int, 3> kArray = {1, 2, 3};
// 나쁨: 자명하지 않은 소멸자
const std::string kFoo = "foo";

// kBar가 참조이더라도 같은 이유로 나쁩니다(이 규칙은
// 수명이 연장된 임시 객체에도 적용됩니다).
const std::string& kBar = StrCat("a", "b", "c");

void bar() {
  // 나쁨: 자명하지 않은 소멸자.
  static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}

참조는 객체가 아니므로 소멸 가능성에 대한 제약을 받지 않는다는 점에 유의하세요. 다만 동적 초기화에 대한 제약은 그대로 적용됩니다. 특히 static T& t = *new T; 형태의 함수 지역 정적 참조는 허용됩니다.

초기화에 대한 결정

초기화는 더 복잡한 주제입니다. 클래스 생성자가 실행되는지 여부뿐 아니라 초기화식의 평가까지 함께 고려해야 하기 때문입니다.

int n = 5;    // 괜찮음
int m = f();  // ? (f에 따라 다름)
Foo x;        // ? (Foo::Foo에 따라 다름)
Bar y = g();  // ? (g와 Bar::Bar에 따라 다름)

첫 번째 문장을 제외한 나머지는 모두 초기화 순서가 정해지지 않는 상황에 우리를 노출시킵니다.

우리가 찾는 개념은 C++ 표준의 공식 용어로 상수 초기화(constant initialization)라고 합니다. 이는 초기화식이 상수 표현식이어야 하고, 객체가 생성자 호출로 초기화된다면 그 생성자도 constexpr로 지정되어 있어야 한다는 뜻입니다.

struct Foo { constexpr Foo(int) {} };

int n = 5;  // 괜찮음. 5는 상수 표현식입니다.
Foo x(2);   // 괜찮음. 2는 상수 표현식이고 선택된 생성자가 constexpr입니다.
Foo a[] = { Foo(1), Foo(2), Foo(3) };  // 괜찮음

상수 초기화는 언제나 허용됩니다. 정적 저장 기간 변수의 상수 초기화는 constexpr이나 constinit으로 표시해야 합니다. 그렇게 표시되지 않은 비지역 정적 저장 기간 변수는 동적 초기화를 한다고 간주하고 아주 신중하게 검토해야 합니다.

반대로 다음 초기화들은 문제가 됩니다.

// 아래에서 사용할 선언들입니다.
time_t time(time_t*);      // constexpr이 아닙니다!
int f();                   // constexpr이 아닙니다!
struct Bar { Bar() {} };

// 문제가 되는 초기화들입니다.
time_t m = time(nullptr);  // 초기화식이 상수 표현식이 아닙니다.
Foo y(f());                // 위와 같습니다.
Bar b;                     // 선택된 생성자 Bar::Bar()가 constexpr이 아닙니다.

비지역 변수의 동적 초기화는 권장되지 않으며, 일반적으로 금지됩니다. 다만 이 초기화가 다른 모든 초기화와 어떤 순서로 이루어지는지에 프로그램의 어떤 부분도 의존하지 않는다면 허용합니다. 이런 제약 아래에서는 초기화 순서가 관찰 가능한 차이를 만들지 않습니다. 예를 들면 다음과 같습니다.

int p = getpid();  // 허용. 단, 다른 정적 변수가 자신의 초기화에
                   // p를 사용하지 않아야 합니다.

정적 지역 변수의 동적 초기화는 허용되며, 흔히 쓰입니다.

흔한 패턴

  • 전역 문자열: 이름 있는 전역 또는 정적 문자열 상수가 필요하다면, 문자열 리터럴을 가리키는 string_view, 문자 배열, 문자 포인터의 constexpr 변수를 사용하는 것을 고려하세요. 문자열 리터럴은 이미 정적 저장 기간을 가지며 대개 그것으로 충분합니다. TotW #140을 참조하세요.
  • 맵, 세트, 그 밖의 동적 컨테이너: 검색 대상 집합이나 조회 테이블처럼 고정된 정적 컬렉션이 필요하더라도, 표준 라이브러리의 동적 컨테이너는 자명하지 않은 소멸자를 가지므로 정적 변수로 사용할 수 없습니다. 대신 자명한 타입의 단순한 배열을 고려하세요. 예를 들어 ("int에서 int로 가는 맵"이 필요하다면) int 배열의 배열이나, (int와 const char* 쌍처럼) 쌍의 배열을 쓸 수 있습니다. 작은 컬렉션이라면 선형 탐색으로 완전히 충분하며, 메모리 지역성 덕분에 효율적이기까지 합니다. 표준적인 연산에는 absl/algorithm/container.h의 기능을 사용하는 것을 고려하세요. 필요하다면 컬렉션을 정렬된 상태로 유지하고 이진 탐색 알고리즘을 사용하세요.

    표준 라이브러리의 동적 컨테이너를 정말로 쓰고 싶다면, 아래에서 설명하는 함수 지역 정적 포인터를 사용하는 것을 고려하세요.

  • 스마트 포인터(std::unique_ptr, std::shared_ptr): 스마트 포인터는 소멸 과정에서 정리 작업을 수행하므로 금지됩니다. 여러분의 사용 사례가 이 절에서 설명한 다른 패턴 중 하나에 해당하지는 않는지 살펴보세요. 간단한 해결책 하나는 동적으로 할당한 객체를 가리키는 일반 포인터를 사용하고 절대 삭제하지 않는 것입니다(마지막 항목 참조).

  • 사용자 정의 타입의 정적 변수: 직접 정의해야 하는 타입의 정적 상수 데이터가 필요하다면, 그 타입에 자명한 소멸자와 constexpr 생성자를 부여하세요.
  • 다른 모든 방법이 통하지 않는다면, 함수 지역 정적 포인터나 참조를 사용해(예: static const auto& impl = *new T(args...);) 객체를 동적으로 생성하고 절대 삭제하지 않을 수 있습니다.

옮긴이 풀이

두 개의 축: 소멸(destruction)과 초기화(initialization)

정적 저장 기간(static storage duration) 객체 — 전역 변수, 클래스의 정적 멤버, static 지역 변수 — 는 프로그램이 끝날 때까지 살아 있습니다. 이들이 문제를 일으키는 지점은 딱 두 가지입니다.

  1. 소멸 순서: 번역 단위 간 소멸 순서가 정해져 있지 않아, 종료 시 이미 파괴된 객체를 다른 객체가 참조하면 use-after-free가 발생합니다.
  2. 초기화 순서: 동적 초기화(dynamic initialization)는 번역 단위 간 순서가 보장되지 않아, 아직 초기화되지 않은 객체를 참조할 수 있습니다.

규칙 1: 소멸은 "자명하게 소멸 가능"한 것만 허용

정적 저장 기간 객체는 자명하게 소멸 가능(trivially destructible)할 때만 허용됩니다. 즉 소멸자가 실질적으로 아무 일도 하지 않아야 합니다(사용자 정의·가상 소멸자가 없고, 모든 멤버도 자명하게 소멸 가능). 이러면 소멸 순서가 아예 문제되지 않습니다.

const int kNum = 10;                              // 허용 (기본 타입)
struct X { int n; };
const X kX[] = {{1}, {2}, {3}};                   // 허용
constexpr std::array<int, 3> kArray = {1, 2, 3};  // 허용

const std::string kFoo = "foo";                   // 금지: std::string은 자명하지 않은 소멸자
const std::string& kBar = StrCat("a", "b", "c");  // 금지: 수명 연장된 임시도 동일

std::string, std::map, std::unique_ptr 같은 표준 컨테이너·스마트 포인터는 자명하지 않은 소멸자를 가지므로 전역/정적으로 둘 수 없습니다.

규칙 2: 초기화는 "상수 초기화"를 선호

가능하면 상수 초기화(constant initialization), 즉 컴파일 타임에 끝나는 초기화를 쓰세요. 정적 저장 기간 변수의 상수 초기화는 constexpr 또는 constinit로 표시해야 합니다. 그렇게 표시되지 않은 비지역 정적 변수는 동적 초기화로 간주되어 엄격한 검토 대상입니다.

struct Foo { constexpr Foo(int) {} };
int n = 5;     // OK: 5는 상수 표현식
Foo x(2);      // OK: 2는 상수 표현식, 선택된 생성자가 constexpr

time_t m = time(nullptr);  // 문제: 초기화 표현식이 상수가 아님 (동적 초기화)

예외적으로 동적 초기화가 허용되는 경우

  • 함수 지역 static 변수: 동적 초기화가 허용되며 흔히 쓰입니다(제어가 선언을 처음 지날 때 한 번 초기화).
  • 순서에 의존하지 않는 비지역 변수: 다른 어떤 초기화도 이 변수에 의존하지 않는다면 허용됩니다(예: int p = getpid();).

전역 컨테이너·문자열이 필요할 때의 대안

  • 전역 문자열 → constexpr string_view, 문자 배열, 또는 문자열 리터럴을 가리키는 문자 포인터
  • 맵·세트 → 자명한 타입의 정렬된 배열 + 이진 탐색(소규모는 선형 탐색으로 충분)
  • 정말 동적 컨테이너가 필요하면 → 함수 지역 static 포인터(static const auto& impl = *new T(...);)로 의도적으로 누수