티스토리 뷰

C++/General

static을 이해하자

엘키 2008. 1. 10. 17:25
Preface 프로그래밍을 하다 보면 static만큼 다양한 곳에서 다양한 의미로 많이 쓰이는 키워드가 없는 것 같습니다.

C/C++과 같은 정적인 언어 뿐만 아니라 JAVA와 같이 동적 바인딩을 기본으로 하는 언어에서조차 static이 사용되는 것을 보면 옛 속담처럼 '귀에 걸면 귀걸이, 코에 걸면 코걸이'가 되는 것이 static이라는 키워드인 것 같습니다. 그러나 유감스럽게도 이렇게 자주 쓰이고 중요하게 사용되는 static을 잘못 이해하고 있거나 많은 특징들을 모르고 있는 사람들이 의외로 많습니다.

특히 시중에 판매되는 대다수의 프로그래밍 입문 서적들이 static에 대해서 정확하고 자세하게 설명하고 있지 않다라는 사실이 이렇게 부족하게 나마 static에 대한 글을 쓰게 된 이유라 할 수 있습니다.

static 용어의 개념 및 성질 제가 그 동안 공부를 하면서 가장 절실하게 느낀 점이 하나 있다면 바로 어떤 개념을 익히기 위해서는 우선 용어의 뜻을 바로 이해하는 것이 가장 중요하다라는 것입니다.

특히 컴퓨터 분야와 같이 정말 많고 다양한 용어와 약어들이 판을 치는 분야에서 용어의 뜻을 정확하게 이해하는 것은 말 그대로 '반은 먹고 들어가는 것'입니다.

그런 의미에서 우리가 가장 처음 할 일은 static이라는 용어에 대한 사전적 뜻을 알아보는 것이 아닐까 생각합니다.

static
1 정지(靜止)하고 있는, 변화하지 않는; 정적인, 움직임이 없는(반:dynamic). (또는 statical)
2〈물리〉 정적인, 정압(靜壓)의. ~ pressure 정압. 3 〈전기〉 정전(靜電)(기(氣))의; 공전(空電)의.
[출처 : 야후 영어사전]

야후에 물어보니 static이 위와 같은 뜻이라고 하는군요...

프로그래밍 언어에서만이 아니라 영어 자체에서도 생각보다 다양한 뜻을 가지고 있는 것 같습니다.('정전기'라는 뜻을 가지고 있다는 사실을 전 오늘 처음 알았습니다.)

어쨌든 대충 static이라는 단어가 풍기는 이미지는 '불변성, 고요함' 정도로 생각되며 실제 앞으로 설명할 static의 여러 가지 성질들은 대체로 이러한 의미들과 관련이 있습니다.

그럼 C/C++에서 static이 갖는 성질들을 제가 아는 대로 한번 나열해 보겠습니다.(참고로 이제부터 제가 사용하는 '객체'라는 용어는 클래스 객체뿐만이 아니라 C++컴파일러가 제공하는 built-in type 변수까지 포함하는 추상적인 개념으로 이해하시기 바랍니다.

왜냐하면 실제 static의 성질은 그 타입에 상관없이 동일하게 적용되기 때문입니다. 때에 따라서 객체라는 용어와 변수라는 용어를 혼용하더라도 양해하여 주시기 바랍니다.)

1. 정적 지속성(static storage duration) : static 객체들은 일단 한번 생성이 되면 프로그램이 종료될 때까지(C++표준에서는 main()함수에서 리턴 될 때 혹은 exit()를 호출했을 때 소멸된다고 명시되어있습니다.) 유지됩니다. 즉, 객체의 소멸 시점이 scope에 영향 받지 않고 항상 일정합니다.

2. 유일성(singleton) : 어떤 모듈 단위(function, class, file)에서든지 static 객체는 단 한번만 생성됩니다.

3. 내부 연결성(internal linkage) : 전역 static 객체나 함수는 link 단계에서 외부 바인딩이 일어나지 않습니다. 즉, 외부 파일에서는 내부 전역 static 객체/함수를 참조하거나 호출할 수 없습니다. 그렇다면 왜 static 키워드가 붙은 변수나 함수가 이런 성질을 가지게 되는지에 대해서 차근차근 알아보도록 하겠습니다.

binding(바인딩) static에 대해서 이해하기 위해서는 우선 binding이라는 개념을 이해하여야 합니다. 바인딩이라는 단어 역시 다양한 의미로 사용되는 용어인데 프로그래밍 언어에서 말하는 바인딩이란 어떤 심볼(변수나 함수)의 속성을 결정짓는 것을 의미합니다.
int main()
{
	int x;
	x = 3;
	return 0;
}
위와 같은 C 소스가 있을 때 이것을 컴파일러가 컴파일 하게 되면 우선 컴파일러는 int x; 라는 선언문을 보고서 x라는 이름의 변수를 자신의 심볼 테이블에 기록을 하며 이 때 그 변수의 속성(type, scope, value 등)을 저장하게 됩니다.

그리고 그 다음에 있는 x = 3; 이라는 구문에서 x라는 변수가 자신의 심볼 테이블에 있는지 살펴보고, 있으면 그 속성을 참고하여 해당 구문의 타입 체크와 같은 문법 검사를 수행하게 됩니다.(물론 x가 심볼 테이블에 없다면 그런 변수를 찾을 수 없다는 컴파일 에러를 발생시킵니다.)

이제 좀 더 복잡한 예를 살펴보겠습니다.
 
/// test1.cpp
#include 

int global; // ---> 1)

void extern_fun(int a);

int main()
{
	global = 5; // ---> 2)

	extern_fun(3);

	std::cout << global << '\n';

	return 0;
}

/// test2.cpp
extern int global;  // ---> 3)

void extern_fun(int a)
{
	global += a;      // ---> 4)
}
위 소스에서는 global이라는 전역 변수를 두 개의 파일에서 참조하고 있습니다.

위에서 언급했듯이 컴파일러는 변수 선언문이 나타나면 해당 변수를 심볼 테이블에 등록하고 이후에 변수를 사용하는 구문을 만날 때마다 해당 변수가 자신의 심볼 테이블에 등록되어 있는지 살펴보고 그 정보에 따라 문법 검사를 수행하게 됩니다.

그런데 컴파일러는 항상 파일 단위로 컴파일을 수행하며 새로운 파일을 컴파일 할 때 이전에 이미 컴파일 한 파일에 대한 정보 같은 것들은 따로 기록하지 않습니다. 즉, 항상 새로운 마음가짐으로 각각의 파일들을 독립적으로 컴파일 하게 됩니다.

그래서 위의 경우 test1.cpp를 먼저 컴파일 했다고 해서 test2.cpp에서 global변수를 사용하는 구문을 컴파일 할 때 test1.cpp에서 만든 심볼 테이블을 참조하지는 않습니다.

때문에 두 개 이상의 파일에서 같은 전역 변수를 참조하게 되면 그런 사실을 미리 소스 코드를 통해 컴파일러에게 알려줘야 합니다.

만약 그렇지 않으면 test2.cpp에서 4)구문을 컴파일 할 때 global이라는 변수가 선언되어 있지 않다 라고 에러를 발생시키거나 혹은 test1.cpp와 test2.cpp의 global변수를 별개의 변수로 취급하게 될 것입니다.

그러므로 프로그래머는 사전에 test1.cpp와 test2.cpp에서 사용하는 global 변수가 동일한 것이다라는 사실을 컴파일러에게 알려주게 되는데 위의 소스에서 3)에 있는 extern은 바로 그런 역할을 수행하는 키워드입니다.

extern의 의미는 '해당 변수는 외부에서 참조하여 사용하겠다'라는 뜻입니다.

만약 3)이 없다면 test2.cpp를 컴파일 할 때 4)에서 global이라는 변수를 찾을 수 없다는 에러가 발생할 것이며, 3)에서 extern이 없다면 test1.cpp와 test2.cpp의 global은 별개의 변수로 취급되어 프로그램 결과가 8이 아니라 5가 나오게 될 것입니다.

그렇다면 컴파일러는 각각의 파일을 컴파일 할 때 다른 파일의 심볼 값들을 참조하지 않음에도 불구하고 어떻게 전역 변수를 공유할 수 있을까요? 그것은 바로 링크 과정이 있기 때문에 가능합니다.

컴파일 시 컴파일러는 전역 변수나 함수에 대한 참조/호출을 직접적인 어셈블리어 구문으로 변환하는 것이 아니라(다시 말하면 해당 변수의 메모리 주소나 함수의 코드 주소로 바로 변환하지 않고) 특정한 이름을 부여하여 해당 이름의 전역 변수나 함수에 대한 참조/호출 구문으로 바뀌게 되는 것입니다.

예를 들어 위의 소스의 경우에 test2.cpp에서 참조하는 global이 외부에서 참조되는 변수이다라는 사실을 컴파일 한 목적 코드(object code)에 기록해 두면 링커는 해당 변수와 동일한 이름으로 정의된 파일을 검색하고 test1.cpp에서 일치된 이름을 발견하면 해당 전역 변수 참조 구문들을 올바른 속성(상대 주소)값으로 바꾸게 됩니다.

이렇게 어떤 변수나 함수의 속성값을 결정짓는 과정을 바인딩이라고 합니다.

그리고 위의 경우처럼 링크 과정에서 그런 속성값을 결정짓는 것을 링크 바인딩(link binding), 혹은 동적 바인딩(dynamic binding)이라 합니다. 위의 야후 사전에서도 나와 있듯이 컴퓨터 분야에서 dynamic에 반대되는 용어는 static이며 거의 항상 이 두 존재는 양립합니다.

즉, 앞에 dynamic이라는 단어가 붙는 어떤 용어가 있다면 거의 항상 static이 앞에 붙는 반대되는 의미의 용어가 존재합니다. 바인딩 역시 예외가 아니어서 동적 바인딩에 반대되는 개념으로 정적 바인딩(static binding)이 있습니다.

이것은 바인딩이 컴파일 시점에 이루어 지는 것을 의미합니다. 즉, 해당 변수나 함수의 속성이 컴파일 과정에서 결정됩니다.

정적 바인딩에 의해 정의되는 변수나 함수는 링크 과정 이전에 이미 속성이 결정되기 때문에 아래와 같은 특징을 가지게 됩니다.

1. 정적 바인딩 변수는 extern 키워드를 통해 외부 파일에서 참조가 불가능하다.
2. 정적 바인딩 함수는 외부 파일에서 호출이 불가능하다. static 전역 변수나 함수를 사용해 보신 분들은 아시겠지만 위의 특징은 바로 static 전역 변수와 함수의 특징과 일치합니다. 즉, static 키워드는 해당 전역 변수나 함수를 컴파일러가 정적으로 바인딩을 하도록 프로그래머가 '지시'하는 역할을 합니다.

컴파일 타임 바인딩을 정적(static) 바인딩이라고 말하는 이유는 아마도 한번 컴파일 시에 속성이 결정되고 나면 '변하지 않는다' 라고 하는 불변성 때문이 아닌가 추측됩니다. 어쨌든 이런 특징을 가지고 있기에 static 변수는 독특한 성질을 지니고 있습니다. 아래의 예제는 초보자가 흔히 하게 되는 실수입니다.
 
/// header.h
int global;


/// a.cpp
#include 
#include "header.h"

void b_func();

int main()
{
	global = 3;

	b_func();

	std::cout << global << std::endl;

	return 0;
}



/// b.cpp

#include "header.h"

void b_func()
{
	global = 5;
}

위 소스는 정상적으로 컴파일 됩니다.

앞에서 언급했듯이 컴파일러는 파일 별로 독립적으로 컴파일을 수행합니다. 따라서 a.cpp 컴파일 시 header.h에 있는 global 선언문을 통해 global 변수를 하나 생성하여 3을 할당합니다.

또한 b.cpp 역시 header.h에 있는 global 선언문을 통해 global 변수를 하나 생성하여 5를 할당합니다. 따라서 둘 다 적법한 소스입니다. 하지만 링크 과정에서 링커는 a.cpp와 b.cpp가 동일한 이름(global)의 전역 변수를 두 개 생성한 것을 보고 링크 에러를 발생시킵니다.(이 때 발생하는 에러는 redefinition error입니다.

아마 초보자들이 가장 많이 접하는 에러 중 하나일 것입니다.)
그러면 위 소스를 아래와 같이 수정해 보겠습니다.
// header.h
static int global; /// int global을 static 전역 변수로 수정

 
// a.cpp
///원 소스와 동일...
 
// b.cpp
/// 원 소스와 동일....
이렇게 수정하면 정상적으로 컴파일 및 링크가 수행됩니다.

왜냐하면 static 전역 변수는 컴파일 시점에 바인딩이 완료되고 따라서 링크 과정에서 해당 변수에 대한 바인딩 처리를 하지 않으므로 중복 정의를 검사하지 않기 때문입니다.

그러나 대신 a.cpp와 b.cpp에서 사용하는 global 변수는 완전히 별도의 객체로 처리됩니다.

따라서 위 프로그램에서 a.cpp가 출력하는 global값은 - b_func()함수를 호출함으로써 바뀌는 값- 5가 아니라 - 원래 a.cpp에서 할당한 값 - 3이 됩니다.

즉, a.cpp와 b.cpp에서 사용하는 global 전역 변수는 사실 이름만 똑같을 뿐 별개로 취급되는 다른 변수가 됩니다. (따라서 이런 식의 사용은 프로그래머에게 혼란을 줄 뿐이며 버그를 야기시키는 좋지 않은 코딩 방식입니다.

그러므로 static 전역 변수를 선언할 때는 항상 header 파일이 아닌 c/cpp 파일에 해줘야 합니다.) 어쨌든 static 전역 변수는 항상 해당 변수가 선언된 파일 내부에서만 참조가 가능합니다.

그리고 이것은 static 전역 함수 역시 마찬가지이며 static으로 선언된 전역 함수는 외부 파일에서는 사용이 불가능합니다.(만약 외부에서 호출하려고 하면 링크 에러가 발생합니다.)

때문에 보통 C프로그래머들은 외부 파일에서 사용할 필요가 없는 혹은 외부에서 사용하기를 원치 않는 함수들은 static으로 정의하곤 합니다. 이렇게 함으로써 이름 중복에 의한 혼란 등을 피할 수 있는 부수적인 장점을 얻을 수도 있습니다.(C++에서는 namespace와 class가 생기면서 이런 장점이 많이 사라졌습니다.)

그리고 이렇게 파일 내부에서만 참조 가능한 static 의 성질을 internal linkage(내부 연결성)이라고 부릅니다. storage duration and scope 다 아시는 이야기 하나 해보겠습니다. 변수는 관점에 따라 다양하게 분류가 될 수 있습니다.

우선 공간(scope)범위에 따라 전역 변수와 지역 변수로 분류가 가능합니다. 그리고 메모리 할당 주체에 따라 정적 할당 변수와 동적 할당 변수로 분류할 수도 있습니다.

또한 메모리 할당 위치에 따라 스택(stack) 변수와 정적 영역 변수, 동적 영역(heap) 변수로 나눌 수 있으며 그 외에도 정적(static)변수, 상수(const)변수, 포인터 변수 등등 여러 종류로 구분이 가능합니다. 게다가 이러한 분류는 복합적으로 정의될 수 있습니다.

가령, 정적 전역 변수(static global variable), 정적 지역 변수(static local variable), 전역 상수 변수(global const variable) 등등이 있습니다. 심지어 어떤 분류 정의는 서로 밀접한 관계(혹은 동등한 의미)를 가집니다.

스택 변수와 비정적(non-static) 지역 변수는 사실 같은 의미를 가지고 있으며, 전역(global) 변수와 정적(static) 변수는 정적 영역 변수에 해당합니다. 이렇게 변수의 종류가 다양하게 분류되는 것은 프로그래밍 시 여러 가지 상황이 발생하게 되고 이때 이런 상황에 맞는 변수 설정을 위한 여러 가지 개념들이 필요했기 때문이며 이러한 여러 개념들이 서로 복합적인 관계를 맺으며 사용되는 것은 그러한 개념들을 구체화 시키는 과정에서 구현 상의 이유로 만들어진 부수효과(side-effect)라 할 수 있습니다.

가령 포트란 같은 경우 모든 변수가 전역 변수로 지정됩니다(참고로 저는 포트란을 직접 사용해 본적은 없습니다.). 즉, 지역 변수라는 개념이 없습니다. 따라서 파라미터 전달이나 스택 변수 생성에 따른 함수 호출 오버헤드가 없기 때문에 간단한 프로그래밍 시 좋은 성능을 보여줍니다.

그러나 대신 일회성 변수를 사용하더라도 지속적인 변수 유지가 필요하기 때문에 이름 짓기(naming) 문제나 혹은 같은 변수를 다른 용도로 계속 재사용하게 되고 따라서 복잡한 프로그래밍 시 코드가 난해해지는 단점이 있습니다.

그래서 C와 같은 프로그래밍 언어에서는 지역 변수라는 개념을 도입했으며 그에 따라 scope라는 개념이 생겨났습니다.

그리고 이런 scope라는 개념이 들어가면서 동시에 storage duration이라는 개념이 생겨났습니다.

더 이상 참조가 필요 없는, 다시 말하면 scope를 벗어난 지역 변수에 대해서 프로그래머가 일일이 지정하지 않아도 자동적으로 해당 지역 변수의 메모리를 컴파일러가 알아서 해제해줌으로써 보다 추상화된 프로그래밍이 가능하게 되는 것입니다.

그래서 이런 지역 변수의 특징을 구체화하는 과정에서 가장 손쉽고 오버헤드가 적게 드는 기법을 구현한 것이 바로 스택을 통한 지역 변수 관리 기법인 것입니다.(따라서 스택 변수는 곧 비정적 지역 변수가 됩니다.)

어쨌든 점점 프로그래밍이 고난이도 작업이 되고 그에 따라 요구 조건이 까다로워지면서 보다 다양한 개념의 메모리 및 변수 관리가 필요하였고 그에 따라 C/C++와 같은 언어에서는 직접 사용자가 메모리를 관리하는 포인터 및 동적 할당 개념이 생겨났습니다.

그런 와중에 '특정 scope를 가지면서 해당 변수의 storage duration은 scope에 상관없이 프로그램 실행 시간 내내 유지 될 수 있는' 그런 기법이 필요하게 되었습니다.

전역 변수는 프로그램 실행 시간 내내 유지되지만 대신 다른 블럭이나 모듈, 파일들에서 해당 변수를 참조할 수 있게 되고 그러면 프로그램의 안정성이 떨어질 수 있기 때문에, 가급적 참조 범위를 최소화하는 것을 미덕으로 여기는 프로그래밍 세계에서는 다른 대안을 필요로 한 것입니다. 한편, static 변수는 앞서 말한 바와 같이 컴파일 시점에 바인딩이 되는 변수입니다.

그런데 이 변수를 전역이 아닌 지역 변수로 선언을 한다면 뭔가 비 논리적인 상황이 발생합니다.

왜냐하면 지역 변수는 스택을 통해 관리되므로 그 속성(여러 속성들이 있겠지만 여기서는 메모리 주소값)이 수시로 바뀌게 되는데 이는 static의 원칙에 어긋나는 동작입니다.

결국 static 지역 변수는 허용을 하지 말거나 혹은 지역 변수와는 독립적인 처리가 필요하게 되었습니다. 그리고 C/C++ 에서는 후자를 선택하였습니다. 그 이유는 아마도 앞서 언급한 필요성 때문이 아닐까 생각합니다.(사실 위의 이야기들은 다 제가 추측한 이야기입니다. 실제로 어떤 이유로 static 지역 변수가 생겨났는지는 K&R만이 알겠죠...)

따라서 static으로 선언된 지역 변수는 다른 지역 변수와 달리 스택이 아니라 정적 영역에 할당이 됨으로써 해당 scope를 벗어나 스택이 해제되더라도 그 상태를 유지할 수 있게 되었습니다.

어차피 static 전역 변수 역시 정적 영역에 할당되므로 구현 상으로도 통일된 처리가 가능하여 이는 충분히 합리적인 결정이라 생각됩니다. 어쨌든 결국 static 지역 변수는 지역 변수의 일부 특성(scope)과 전역 변수의 일부 특성(static storage duration)을 갖는 독특한 존재가 됐습니다.

예를 들자면,
#include 

int sum(int a)
{
	static int x = 0; /// 최초 sum 호출 시 한번 만 생성&초기화됨
	x += a;
	return x;         /// sum()함수 종료 시에도 소멸 안됨
}

/* 1부터 100까지 합을 출력하는 프로그램*/
int main()
{
	int i = 0;
	for (; i < 100; ++i)
		sum(i);

	std::cout << sum(100) << '\n';
	return 0;       /// main()함수 종료 시 sum()함수 내에 있는 x 변수 소멸
}
이런 식의 사용이 가능합니다. 또,
#include 

int* local_fun(int a)
{
	int x = 0;
	x += a;
	return &x;
}

int* static_fun(int a)
{
	static int x = 0;
	x += a;
	return &x;
}

int main()
{
	int* p = local_fun(3);
	std::cout << *p << '\n';   /// 비정상적인 값 출력
	p = static_fun(3);
	std::cout << *p << '\n';   /// 정상값(3) 출력
	return 0;
}
이처럼 지역 변수의 경우 스택을 통해 메모리가 관리되므로 외부 참조가 불가능하지만 static 지역 변수의 경우 정적 영역에서 메모리가 관리되므로 포인터 혹은 레퍼런스를 통한 참조가 가능합니다.

여기서 scope에 대한 제약은 어떤 실행 메카니즘에 의한 것이 아니라 단지 컴파일러에 의한 문법 검사에 의한 것임을 알 수 있습니다.(이런 특징 때문에 static 객체는 singleton 패턴을 구현하는데 사용되기도 합니다.)

어쨋든 이렇게 static 객체는 전역 객체로 선언되면 internal linkage 특성을 가지지만 지역 객체로 선언될 경우 static storage duration 특성을 가지게 됩니다. 그리고 이런 특성이 모두 static이라고 하는 용어가 가진 개념에 의해 발생된 것임을 알 수 있습니다.

C++에서의 static C에서 static이 가진 의미는 위에서 말한 두 가지가 전부입니다. 그러나 C++에서는 객체지향 패러다임이 도입되었고 그에 따라 클래스라고 하는 개념이 도입되었습니다.

클래스는 다 아시다시피 '무언가 공통된 책임을 수행하기 위해 같이 모아 놓으면 좋을 만한 변수와 함수들을 하나의 모듈로 캡슐화시킨 사용자 정의 타입'입니다.

그리고 이런 클래스 타입에 의해 생성된 변수들을 '객체(Object)'라고 합니다. 사실 이런 클래스나 객체라는 것은 이름은 그럴 듯 하지만 막상 그 세부 구조를 밑바닥까지 파헤쳐 보면 C에서 사용하는 구조체(structure)와 큰 차이가 없습니다.

멤버 변수들은 단지 구조체 멤버에 사용자 권한이라고 하는 컴파일 타임 제약 사항만을 추가한 것에 불과하며, 멤버 함수라고 하는 것은 실상 해당 클래스 객체의 포인터를 파라미터로 자동 추가해 주는 편이성 높은 함수에 불과합니다.

즉,
class CppClass
{
public:
	CppClass() : x_(0) {}

	void SetX(int a) { x_ = a; }

	int x_;
}
위의 클래스를 C언어로 바꾸게 되면
struct CStruct
{
	int x_;
}
 

void CStruct_ctor(CStruct* this)
{
	this->x_ = 0;
}

 

void CStruct_SetX(CStruct* this, int a)
{
	this->x_ = a;
}
이렇게 됩니다. 그래서 실제 사용시에
CStruct tmp;

CStruct_ctor(&tmp);

CStruct_SetX(&tmp, 3);
이렇게 사용할 것을 클래스를 이용해서
CppClass tmp;

tmp.SetX(3);
이런 식으로 사용함으로써 생성자나 SetX()와 같은 멤버 함수가 CppClass라는 클래스의 객체에 밀접하게 관련되었다는 것을 프로그래머가 보기에 보다 직관적이 될 수 있도록 해준 장치에 불과합니다.

물론 이렇게 맥 빠지게 말을 하였지만 C++, JAVA, Smalltalk들과 같은 객체 지향 언어들은 객체 지향 패러다임을 통해 상속, 다형성, 캡슐화라는 다양한 장치를 제공하여 현재 가장 널리 사용되는 프로그래밍 언어인 것은 틀림없습니다.(적어도 이제 사람들이 더 이상 C를 반드시 배우려고 하지는 않습니다.) 어쨋든 객체 지향 언어들은 모든 프로그래밍을 객체 단위로 구현하고 조직화하게 되며 따라서 모든 변수들은 자신이 속한 객체와 그 생명 주기(life time)를 같이 합니다.

다시 말하면 멤버 변수의 scope와 storage duration은 자신이 속한 객체에 영향을 받는다는 뜻입니다. 그런데 여기서도 지역 변수에서와 유사한 문제가 발생합니다. 즉, 클래스 멤버 변수 선언 시에 static을 앞에 붙히게 되면 어떻게 되느냐 하는 것입니다.

클래스 멤버 변수는 객체가 생성될 때 같이 생성되고 객체가 소멸될 때 같이 소멸됩니다.

게다가 객체가 지역 변수로 선언되면 멤버 역시 스택에 위치하게 되며 객체가 동적 할당되면 멤버 역시 동적 영역에 위치합니다.

그런데 앞서 언급했듯이 static은 지역 변수든 전역 변수든 상관없이 static storage duration을 갖습니다.

따라서 아무리 객체 지향이라 하더라도 이러한 기존의 법칙을 거스르는 행동을 하는 것은 논리적이지 못합니다. 따라서 이것 역시 두 가지 선택이 필요하게 되었습니다.

멤버 변수에 대한 static을 허용하느냐, 그렇지 않느냐...그리고 C++은 전자를 선택하였습니다. 단 이렇게 하고 나니 한 가지 문제가 발생하였습니다.

static 멤버 변수를 허용하게 되면 기존의 static 특성을 따르기 위해서 static storage duration을 가져야 하고 그러려면 이 변수는 정적 영역에 위치하여야 하는데 객체는 반드시 정적 영역에 위치하리라는 보장이 없기 때문입니다.

결국 이런 모순을 해결하기 위해서는 static 멤버 변수를 객체에서 분리하여야 합니다. 즉, 객체의 부분 집합으로써가 아니라 단지 클래스의 이름 공간에 한정 받는 전역 변수처럼 취급이 되는 것입니다.

다행스럽게도 이것은 기존의 static 정의에서 크게 벗어나지 않는 개념입니다.

즉, 1. static 변수는 한 번 생성되면 프로그램 종료 시까지 계속 유지된다.(static storage duration) 2. static 변수는 scope에 영향을 받는다. - 전역 객체는 정의된 파일 scope에 한정되어 참조 가능하며(internal linkage), 지역 객체는 정의된 local scope에 한정되어 참조 가능하다.(no linkage) static 클래스 멤버 변수는 위의 정의에서 1번과 일치하며 2번의 경우 영향 받는 scope를 클래스 이름공간(namespace)라는 것으로 확장하여 생각하면 역시 문제될 것이 없습니다.

결국 static 클래스 멤버 변수는 객체와 상관없이 클래스 범위 연산자(::)를 통해서 참조가 가능한 독특한 특징을 가진 전역 객체가 되었으며 동시에 '객체가 몇 개가 생성되든 오직 하나만 존재함(singleton)'이라는 성질을 가지게 되었습니다.

실제 프로그래밍에서 static 멤버 변수는 어떤 클래스에 관련되지만 특정 객체에 영향을 받지 않는 성질을 가진 데이터를 취급하는데 아주 유용하게 사용됩니다. reference counter가 그 대표적인 예라 할 수 있습니다.

한편 클래스 멤버 함수는 다른 경우가 됩니다. 함수라는 것은 어차피 storage duration이라는 것이 존재하지 않기 때문에 일반 함수의 경우 static, non-static의 구분이 'internal linkage인가 아니면 external linkage인가'로 결정이 됩니다.

여기서 클래스 멤버 함수의 경우는 internal linkage라는 것이 큰 의미가 없습니다. 어차피 private이나 protected와 같은 권한 지정 키워드가 있기 때문에 얼마든지 사용 범위에 제한을 가할 수 있기 때문입니다.

따라서 이것 역시 두 가지 선택 사항이 존재하게 됩니다. 멤버 함수에 대해서 static을 허용하느냐 그렇지 않느냐...C++는 역시 허용하는 쪽에 손을 들어 줍니다. 사실 static이라는 키워드는 기본적으로 모든 선언문 형식에 들어갈 수 있는 storage class specifier라고 하는 지정자의 일종이기 때문에(C에서 그렇게 정의했기 때문에) 자꾸 이런 저런 제약을 가하는 것은 C의 자유로운 문법을 계승한 C++ 입장에서 그다지 바람직하지 않다라고 생각했을 것입니다.

어쨌든 멤버 함수에 static을 허용하였고 그에 따른 어떤 의미 부여가 필요하였습니다.(앞서 말한 대로 internal linkage라는 성질은 큰 의미가 없으므로...) 여기서 C++은 멤버 변수가 객체에 영향을 받지 않는 클래스 이름 공간에 속한 전역 객체로써 취급되었듯이 멤버 함수 역시 동일한 성질을 부여하게 됩니다.

즉, static 멤버 함수는 객체가 없어도 호출이 가능하며 단지 클래스 이름 공간에 한정을 받는 함수가 된 것입니다. 따라서 static 멤버 함수는 다른 멤버 함수처럼 this 포인터를 암시적으로 받는 특성이 없이 일반 함수와 똑같은 호출 구조를 가지게 되었습니다.

결국
class StaticClass
{
public:
	static int x_;

	static int func() {};
}
이것은
 
namespace gimmesilver
{
	extern int x;
	int func() {};
}
이것과 거의 동일한 특성을 가집니다.

따라서 non-static 멤버 함수들은 암묵적으로 넘겨받은 this 포인터를 통해서 해당 객체의 멤버 변수나 다른 non-static 멤버 함수를 참조/호출할 수 있지만 static 멤버 함수들은 그러한 참조할 만한 객체 포인터가 없으므로 객체의 멤버 변수나 non-static 멤버 함수를 참조/호출할 수 없는 것입니다.(이것 역시 초보자들이 흔히 하는 실수입니다.)

Post face 정리 들어갑니다...

static 변수
1. static storage duration을 갖는다.
2. scope의 영향을 받는다.

- 전역 변수 : file scope의 영향을 받으며 internal linkage라고 한다.
- 지역 변수 : block scope의 영향을 받는다.
- 클래스 멤버 변수 : 클래스 이름공간의 영향을 받는 전역 객체이다.

static 함수
1. 일반 함수 : internal linkage를 갖는다. 즉, 외부 파일에서 해당 함수를 호출하지 못한다.
2. 클래스 멤버 함수 : this 포인터를 갖지 않는다. 따라서 객체의 멤버 변수나 멤버 함수를 직접 참조/호출할 수 없다.
단, 같은 클래스의 static 멤버 변수나 static 멤버 함수는 직접 참조/호출이 가능하다.

쓰다 보니 주저리 주저리 글이 너무 길어졌습니다.

부디 이 글을 통해서 static에 대한 개념 이해에 도움이 되셨으면 좋겠습니다. 혹시 내용 중에 잘못된 내용이나 이상한 부분이 있으면 귀찮으시더라도 메일 주시면 감사 드리겠습니다.

글쓴이: 이은조(gimmesilver@hanmail.net)
댓글