5 분 소요

정적 멤버 변수

정적 멤버 변수는 클래스의 바깥에 선언되어 있지만 클래스에 속하며 객체별로 할당되지 않고 모든 객체가 공유하는 멤버이다. 개별 객체와는 직접적인 상관이 없고 객체 전체를 대표하는 클래스와 관련된 정보를 저장하는 좀 특수한 멤버이다. 정의가 좀 복잡해 보이는데 이런 멤버 변수가 왜 필요한지 문제 하나를 풀어 보면서 차근 차근히 생각해 보자.

다음 예제는 정적 멤버 변수의 필요성과 동작을 설명하기 위한 가장 전형적인 예제이다. Count라는 이름의 클래스를 선언하여 사용하는데 main에서 Count형 객체가 몇 개나 생성되었는지 그 개수를 관리하고자 한다. 첫 번째 예제는 다음과 같다.

int num = 0;

class Count
{
private:
    int value;
 
public:
    Count() { num++; }
    ~Count() { num--; }

    void OutNum() 
    {
        printf("현재 객체 개수 = %d\n", num);
    }
};

void main()
{
    Count c,*pC;
    c.OutNum();

    pC=new Count;
    pC->OutNum();

    delete pC;
    c.OutNum();

    printf("크기 = %d\n",sizeof(c));
}

Count 클래스에는 객체의 고유한 정보를 저장하기 위해 Value라는 멤버 변수가 선언되어 있다. 이 예제에서는 Value를 사용하지 않지만 나중에 객체 크기를 점검해 보기 위한 용도로 포함된 것이다. 생성된 객체의 개수를 저장하기 위해 프로그램 선두에 전역변수 Num을 선언하고 0으로 초기화했다. Count 클래스의 생성자에서 Num을 1 증가시키고 파괴자에서 Num을 1감소시킴으로써 이 변수는 생성된 객체의 수를 정확하게 기억한다.

OutNum 멤버 함수는 단순히 Num 전역변수의 값을 화면으로 출력하여 현재 몇 개의 객체가 만들어져 있는지를 확인시켜 준다. main에서 Count 클래스의 객체를 정적으로 선언하기도 하고 동적으로 생성하기도 하면서 OutNum을 호출했다. 실행 결과는 다음과 같다.

현재 객체 개수 = 1
현재 객체 개수 = 2
현재 객체 개수 = 1
크기 = 4

프로그램이 실행된 직후에 전역변수 Num은 0으로 초기화될 것이다. main 함수가 시작되기 전에 지역 객체 C가 생성되며 이때 C의 생성자에서 Num을 1증가시키므로 Num은 1이 된다. new 연산자로 Count 클래스의 객체를 동적으로 생성하면 이때도 생성자가 호출되어 Num은 2가 되며 delete 연산자로 이 객체를 파괴하면 파괴자가 호출되어 Num은 다시 1이 될 것이다. main 함수가 종료되면 지역 객체 C가 파괴되므로 Num은 최초의 상태인 0으로 돌아간다.

정적이든 동적이든 객체가 생성, 파괴될 때는 생성자와 파괴자가 호출되며 이 함수들이 Num을 관리하고 있으므로 Num은 항상 생성된 객체의 개수를 정확하게 유지한다. 디버거로 한 줄씩 실행해 가면서 Num 변수의 값을 관찰해 보면 이 변수가 생성된 객체수를 정확하게 세고 있음을 확인할 수 있다. 애초에 원하는 목적은 달성했지만 이 예제는 전혀 객체 지향적이지 못하다. 전역변수는 세 가지 면에서 문제가 있다.

  1. 클래스와 관련된 중요한 정보를 왜 클래스 바깥의 전역변수로 선언하는가가 일단 불만이다. 자신의 정보를 완전히 캡슐화하지 못했으므로 이 클래스는 독립적인 부품으로 동작할 수 없다.

  2. 전역변수가 있어야만 동작할 수 있으므로 재사용하고자 할 경우 항상 전역변수와 함께 배포해야 한다. 클래스만 배포해서는 제대로 동작하지 않는다.

  3. 전역변수는 은폐할 방법이 없기 때문에 외부에서 누구나 마음대로 집적거릴 수 있다. 어떤 코드에서 고의든 실수든 Num=1234; 라고 대입해 버리면 생성된 객체수가 1234개라고 오판하게 될 것이다.

객체가 외부의 전역변수와 연관되는 것은 캡슐화, 정보 은폐, 추상성 등 모든 OOP 원칙에 맞지 않다. 전역변수는 심지어 구조적 프로그래밍 기법에서도 사용을 꺼리는 대상인데 하물며 객체 지향 프로그래밍 기법에서야 오죽하겠는가? 일단 문제는 해결했지만 객체 지향적인 요건에 맞추려면 무슨 수를 쓰든지 Num을 Count 클래스안에 캡슐화해야 한다. 다음과 같이 Count 클래스를 수정해 보자.

class Count
{
private:

    int value;
    int num;

public:

    Count() { num++; }

    ~Count() { num--; }

    void OutNum() 
    {
        printf("현재 객체 개수 = %d\n", num);
    }
};

Num을 Count클래스의 멤버 변수로 포함시켰으며 생성자에서 증가, 파괴자에서 감소시키고 있다. 일단 클래스의 멤버로 포함시키는데는 성공했지만 막상 실행해 보면 이 예제는 제대로 동작하지 않으며 문제가 아주 많다. 적어도 다음 두 가지 큰 문제가 있다.

우선 Num은 전혀 초기화되지 않으므로 쓰레기값을 가지게 되며 어느 누구도 Num을 초기화할 수 없다. Num이 개수를 저장하려면 최초 0으로 초기화되어야 하는데 초기화할 주체가 없는 것이다. 언뜻 생성자에서 Num을 초기화할 수 있을 것 같지만 이건 말도 안된다. 객체의 개수를 헤아리는 Num을 객체가 생성될 때마다 0으로 만들어 버린다면 이 값은 결코 0보다 커질 수 없다. 누군가가 0으로 초기화해 놓고 생성자는 증가, 파괴자는 감소만 해야 개수가 제대로 유지되는데 초기화해 줄 적절한 “누구”를 도저히 찾을 수 없는 것이다.

또 다른 문제는 Num을 객체마다 개별적으로 가진다는 점이다. C나 pC 객체는 모두 각각의 Num을 가져 필요없는 메모리를 낭비할 뿐만 아니라 자신과 똑같은 타입의 객체가 몇 개나 있는지를 자신이 가진다는 것도 논리적으로 합당하지 않다. 도대체 어떤 객체가 가진 Num이 진짜 개수인지 판단하기도 어렵다. Num은 객체 자체의 정보가 아니라 객체들을 관리하는 값이며 따라서 객체보다는 더 상위의 개념인 클래스에 포함되어야 한다. 그래야 Num이 오직 하나만 존재하게 된다.

이 문제를 풀려면 Num은 클래스의 멤버이면서 클래스로부터 생성되는 모든 객체가 공유하는 변수여야 한다. 이것이 바로 정적 멤버 변수의 정의이며 이 문제를 풀 수 있는 유일한 해결책이다. Count 클래스를 다음과 같이 한 번 더 수정해 보자.

class Count
{
private:
    int value;
    static int num;
 
public:
    Count() { num++; }

    ~Count() { num--; }

    void OutNum() 
    {
       printf("현재 객체 개수 = %d\n", num);
    }
};

int Count::num=0;

Num은 여전히 Count 클래스 내부에 선언되어 있되 static 키워드를 붙여 정적 멤버임을 명시했다. 클래스 선언문에 있는 int Num; 선언은 어디까지나 이 멤버가 Count의 멤버라는 것을 알릴 뿐이지 메모리를 할당하지는 않는다. 그래서 정적 멤버 변수는 외부에서 별도로 선언및 초기화해야 한다. Count 클래스 선언문 뒤에 Num 변수를 다시 정의했는데 이때 반드시 어떤 클래스 소속인지 :: 연산자와 함께 소속을 밝혀야 한다.

클래스 내부의 선언은 Num이 Count 클래스 소속이며 정수형의 정적 멤버 변수라는 것을 밝히고 외부의 정의는 Count에 속한 정적 멤버 Num을 생성하고 0으로 초기화한다는 뜻이다. 외부 정의에 의해 메모리가 할당되며 이때 초기값을 줄 수 있다. 관습에 따라 클래스를 헤더 파일에 선언하고 멤버 함수를 구현 파일에 작성할 때 정적 멤버에 대한 외부 정의는 통상 클래스 구현 파일(*.cpp)에 작성한다

Num은 Count 클래스에 소속되며 외부 정의에서 지정한 초기값으로 딱 한 번만 초기화된다. Count형의 객체 A,B,C가 생성되었다면 각 객체는 자신의 고유한 멤버 Value를 개별적으로 가지며 정적 멤버 변수 Num은 모든 객체가 공유한다. 그래서 각 객체의 생성자에서 증가, 파괴자에서 감소하는 대상은 공유된 변수 Num이며 한 변수값을 모든 객체가 같이 관리하므로 Num은 생성된 객체의 정확한 개수를 유지할 수 있다.

정적 멤버 변수는 객체와 논리적으로 연결되어 있지만 객체 내부에 있지는 않다. 정적 멤버 변수를 소유하는 주체는 객체가 아니라 클래스이다. 그래서 객체 크기에 정적 멤버의 크기는 포함되지 않으며 sizeof(C) = sizeof(Count)는 객체의 고유 멤버 Value의 크기값인 4가 된다.

정적 멤버의 액세스 지정은 일반 멤버와 똑같은 방식으로 적용된다. 위 예제의 경우 Num은 private 영역에 선언되었으므로 외부에서 액세스할 수 없다. main에서 이 값을 함부로 변경할 수 없으며 오로지 Count 클래스의 멤버 함수(이 예제의 경우 생성자와 파괴자, OutNum)에서만 Num값을 액세스할 수 있다. 정적 멤버도 분명히 클래스 소속이므로 클래스에 속한 멤버 함수들은 액세스 속성에 상관없이 이름만으로 이 멤버를 참조할 수 있다.

단 외부에서 정적 멤버 변수를 정의할 때는 예외적으로 액세스 속성에 상관없이 초기값을 줄 수 있다. 초기식은 대입과는 다르므로 액세스 속성의 영향을 받지 않는다. 정적 멤버 변수를 외부에서도 참조할 수 있도록 공개하려면 클래스 선언부의 public영역에 선언해야 한다. 외부에서 정적 멤버를 액세스할 때는 반드시 소속을 밝혀야 하는데 두 가지 방법으로 소속을 밝힐 수 있다.

Count c;
Count::num = 3; // 클래스 소속
c.Num++; // 객체 소속

객체의 멤버들은 통상 객체.멤버 식으로 소속을 밝히지만 정적 멤버 변수는 객체와 직접적인 연관이 없기 때문에 보통 클래스의 이름과 범위 연산자로 소속을 밝힌다. Count::Num이라는 표현은 Count 클래스에 속한 정적 멤버 변수 Num이라는 뜻이다. 그래서 객체가 전혀 생성되지 않은 상태에서도 클래스의 이름만으로 정적 멤버를 참조할 수 있다. 만약 main에서 최초 Num을 10으로 대입하고 싶다면 객체가 생성되기 전에 Class::Num=10; 으로 대입하면 된다.

원한다면 C.Num처럼 객체.멤버 식으로 객체의 소속인 것처럼 표현할 수도 있다. 이 때 C객체의 이름은 별다른 의미는 없으며 C객체가 소속된 클래스를 밝히는 역할만 한다. 정적 멤버에 대해 객체의 소속으로 액세스하는 것은 일단 가능하지만 일반적이지 않으며 바람직하지도 않다. 정적 멤버는 논리적으로 클래스 소속이므로 가급적이면 클래스::멤버 식으로 액세스하는 것이 합당하다. 단, 어디까지나 논리적으로 소속되는 것 뿐이지 클래스는 실체가 아니므로 클래스 안에 정적 멤버가 배치되는 것은 아니다.