C++ 4가지 Casting

윤형·2026년 2월 21일

C++ Game Server

목록 보기
10/10
post-thumbnail

서론

c++을 사용해 코딩을 하다보면 캐스팅을 참 다양한 방법으로 한다는 사실을 알 수 있다. 그동안 c#을 사용할때는 타입 안정성을 알아서 해줘서 몰랐는데 c++은 유연하기 때문에 어떤 캐스팅을 쓰는지 중요한 것이다.

Casting

캐스팅을 특정 타입을 다른 타입으로 변환하는 과정이라고 이해해도 좋지만 좀 더 본질적으로는 컴파일러에게 "이 데이터가 특정 타입으로 변환되어도 괜찮다.(안전하다)"라고 알리는 것이라고 생각하면 좋다.

  • c++의 4가지 캐스팅을 알기 전에 먼저 간단하게 업캐스팅과 다운캐스팅에 대해서 명확히 이해를 하고 넘어가면 도움이 될 것이다.

Up Casting

자식->부모 클래스로 변환하는 것을 의미한다. 좀 더 풀어서 설명하면 자식 클래스를 부모 클래스 타입으로 바라보게 만드는 것이다.
주로 다형성을 사용하기 위해서 자주 사용하게 된다.

⚠️ 자식을 부모로 바라보는 것이기 때문에 자식 멤버 함수나 변수는 접근을 할 수 없다. 사용하기 위해서는 나중에 "다운 캐스팅"을 해서 사용하면 된다.

Down Casting

부모->자식 클래스로 변환하는 것을 의미한다. 그래서 다형성을 사용하다가 특정 파생 클래스의 기능을 사용하기 위해서는 무조건 다운캐스팅을 하게 되는데, 이때 명시적으로 작성해주는 것이다.

class Base
{
public:
    Base(int id) : id(id) {}
    virtual void print()
    {
        cout << "Base ID: " << id << endl;
    }

    void displayBase()
    {
        cout << "Base Display ID: " << id << endl;
    }
private:
    int id;
};

class Derived : public Base
{
public:
    Derived(int id, const string& name) : Base(id), name(name) {}
    
    void print() override
    {
        Base::print();
        cout << "Derived Name: " << name << endl;
    }

    void displayDerived()
    {
        cout << "Derived Display Name: " << name << endl;
    }
private:
    string name;
};


int main()
{
    Base* basePtr = new Derived(1, "Derived Object");
    basePtr->print(); // Base의 print() 호출
    //basePtr->displayDerived(); // 컴파일 에러: Base 클래스에는 displayDerived()가 없음
    Derived* derivedPtr = (Derived*)basePtr;
    if (derivedPtr)
    {
        derivedPtr->displayDerived(); // Derived 클래스의 displayDerived() 호출
    }
    else
    {
        cout << "Downcasting failed." << endl;
    }
}
  • 이런식으로 업캐스팅을 하면 자식의 메서드를 실행할 수 없기 때문에 다운캐스팅을 하는 것이다.
  • 대부분의 캐스팅 오류는 전부다 다운캐스팅에서 일어나기 때문에 신경써서 관리해 주는 것이 중요하다.

Casting 4가지

이제 본격적으로 c++ 캐스팅에 대해서 알아보도록 하겠다.
c++에서는 핵심적인 4가지 캐스팅이 존재한다.

const_cast<T>()

const 캐스트 같은 경우는 포인터나 레퍼런스의 상수성(const)를 잠시 제거해 주는 캐스팅이다.

const int x = 10;
int* ptr = const_cast<int*>(&x);
*ptr = 20;  // 컴파일 OK
cout << x;
  • 이런식으로 const가 붙어있지만 이걸 강제로 잠깐 수정할 수 있게 해준다는 것이다.
  • 하지만 이렇게 사용하면 UB문제가 생기게 된다.
  • UB(Undefined Behavior)는 한마디로 컴파일러가 판단해서 이 부분이 문제가 있다고 생각하면 동작을 안할 "가능성"이 있다는 것이다.
  • 즉 어떤 컴파일러에서는 값이 잘 바뀌지만 어떤거는 이거를 무시할 수 있다는 것이다.
  • 따라서 그냥 크래쉬 상황보다 원래 더 안좋다고 보면 된다.

🤔 그렇다면 const_cast는 왜 사용하는 걸까? 어차피 UB문제가 생기는거 아닌가?

✅ 진짜 const를 바꿀때는 위험하지만 가짜 const는 바꿔도 되기 때문이다.

  • 여기서 말하는 진짜와 가짜는 무엇을 의미하는 것일까?
  • 진짜 const: 객체 자체가 const인 경우.
  • 가짜 const: 객체는 mutable이고, 포인터 타입만 const인 경우.
int main()
{
    const int x = 10;
    int* ptr = const_cast<int*>(&x);
    *ptr = 20;
    cout << "Value of x: " << x << endl; // Undefined behavior
}
  • 이렇게 값 자체에 const가 붙어있는 경우는 const_cast를 하면 결과가 이상하게 나올 것이다.
// 가짜 const: 실제 객체는 mutable
int realData = 10;           // 수정 가능한 객체
const int* constPtr = &realData;  // 포인터만 const로 제한

int* mutablePtr = const_cast<int*>(constPtr);
*mutablePtr = 20;           // 안전! realData도 20으로 변경
cout << realData << endl;   // 20 출력
  • 이런식으로 원래 자료형은 그대로 내버려두고 그거에 대한 포인터를 const로 한 경우에 const_cast를 사용하면 const의 개념은 사용하면서 UB문제는 해결 할 수 있는 것이다.

🤔그럼 이러한 const_cast는 어느 상황에서 주로 사용될까? 정답은 바로 레거시 C 라이브러리들을 연동할때 많이 사용된다. c스타일은 기본적으로 실제 데이터에는 mutuable하게 하고 포인터에 const를 하는것을 설계원칙으로 했기 때문에 필요한 순간에 const_cast를 이용해 연동을 쉽게 할 수 있는 것이다.

dynamic_cast<T>()

down casting을 하는 과정에서 안전하게 처리할 수 있도록 하기 위해서 사용된다. 런타임에 실제 객체 타입을 확인한다.

  • 여기서 핵심적인 말이 바로 런타임에 실제 객체 타입을 확인한다는 것이다.

🤔 어떻게 이게 가능하지?
✅ 정답은 바로 vtable에 있다. 클래스가 vtable이 생기게 되면 RTTI라는 것도 같이 가지고 있게 된다. 이는 런타임에 실제 타입을 알려주는 메타 데이터이고 무조건 vtable이 있어야 여기에 저장이 된다. 따라서 클래스가 vtable이 없다면 RTTI도 없는 것이고 결국 dynamic_cast도 오류가 나는 것이다.

그래서 핵심은 부모 클래스에 virtual키워드의 함수가 존재해야 한다는 것이다. 그래야지만 vtable이 생겨서 여기서 RTTI가 생기고 이를 통해 타입을 확인할 수 있기 때문이다.

class Base { 
    virtual ~Base() {}  // RTTI 활성화!
};

class Player : public Base {};
class Monster : public Base {};

Base* obj = new Player();  // 실제 Player 객체


int main()
{
	Player* player = dynamic_cast<Player*>(obj);
	if (player) 
    {
		player->playerOnlyFunc();  // 안전!
	} 
    else 
    {
		cout << "Not a Player!" << endl;
	}
}
  • 이런식으로 사용하면 된다.

❌ 하지만 dynamic_cast는 vtable을 런타임에 봐야하는 성질 때문에 성능이 오버헤드 된다는 큰 단점이 존재한다. 따라서 4가지 casting중에서 가장 인기가 없다고 할 수 있다.

static_cast<T>()

static_cast는 컴파일 타임에 안전성을 검사하는 명시적 변환이다. 형 변환 시점이 컴파일이라 static이라는 키워드가 붙는다고 생각하면 된다.

  • 쉽게 생각하면 c스타일로 우리가 (int)3.14 이런식으로 강제 형변환 하는 부분에서 컴파일 시간에 안전성을 검사하는 기능이 살짝 들어갔다고 생각하면 된다.

한번 정리를 해보자

<허용하는 포인터 변환>
1. void* → T* (C API 연동)
2. 배열 → 포인터 (arr → arr[0])
3. Derived* → Base* (업캐스트)
4. Base* → Derived* (다운캐스트 - 위험!)
5. 0 → T* (nullptr 변환)

<차단하는 변환>
1. char* → int* 
2. float* → int*
3. 무관련 클래스*

1. void* -> T*

// Win32 AcceptEx 콜백에서!
void WINAPI AcceptExProc(
    void* buffer,        // void* 출력버퍼
    DWORD bytesReceived
) {
    PacketHeader* pkt = static_cast<PacketHeader*>(buffer);
    processPacket(pkt);
}

2. arr -> arr

char recvBuf[4096];           // 패킷 버퍼
int32_t* coords = static_cast<int32_t*>(recvBuf + 8); 
coords[0] = 100;              // recvBuf[8] 쓰기
coords[1] = 200;              // recvBuf[12] 쓰기

3. 자식* -> 부모*

class Packet { virtual ~Packet() {} };
class MovePacket : public Packet {
    int x, y;
};

MovePacket mp{100, 200};
Packet* base = static_cast<Packet*>(&mp);  // 완벽 안전!
packetQueue.push(base);                    // 기반 클래스 큐

4. 부모* -> 자식*

Packet* pkt = packetQueue.front();
MovePacket* move = static_cast<MovePacket*>(pkt);  // 컴파일 OK!

// 하지만 "확신할 때만" 사용 (대부분 dynamic_cast를 사용해 안전하게 처리함.)
move->x = 300;  // pkt가 실제 MovePacket 아니면 UB!

5. 0 → T*

class IOCPSession {
    Packet* currentPacket = static_cast<Packet*>(nullptr);  // 명시적.
    
    void reset() {
        currentPacket = static_cast<Packet*>(0);  // 같은 의미!
    }
};

이런식으로 static_cast를 사용할 수 있다.
반면에 차단하는 방식도 존재한다.

1. char* → int*

char recvBuf[64];                    // 패킷 버퍼
int* bad_coords = static_cast<int*>(recvBuf);  // 컴파일 에러!
  • 이걸 차단하는 이유는 char[1] 연산과 int[1]의 연산 처리 방식이 다르기 때문이다.

2. float* -> int*

float position[4] = {1.0f, 2.0f, 3.0f, 4.0f};
int* bad_ints = static_cast<int*>(position); 

3. 무관한 클래스*

class Packet {};
class Player {};

Packet* pkt = new Packet();
Player* bad_player = static_cast<Player*>(pkt);
  • 이런식으로 상속관계가 없는 아예 무관한 클래스면 변환을 차단한다.

reinterpret_cast<T>()

reinterpret는 일단 "재해석 하다." 라는 뜻을 가지고 있다.
바로 비트 패턴을 재해석하고 실제 변환은 하지 않는 캐스팅을 의미한다.
참고로 cast 4대장 중 가장 위험한 놈이다.

조금 더 쉽게 말해보자면 해석 방식만 바꾸기 때문에 전혀 상관없는 타입끼리도 강제로 변환이 가능해서 해석만 다르게 한다는 것이다.

🤔 그렇다면 이러한 비트 재해석은 어디에서 사용이 될까?
✅ 주로 메모리 구조를 직접 다뤄야 하는 하드웨어 접근, 네트워크 패킷 해석, 파일 포맷 처리 등등에서 사용이 된다. 타입이 다른데 메모리 구조가 동일한 경우, 혹은 비트 단위 해석이 필요한 경우에 사용이 된다.

☝️참고로 여기서 말하고 있는 비트 해석이란 메모리에 저장된 실제 0과 1의 나열을 의미하는 것이다.

int a = 65;
char* c = reinterpret_cast<char*>(&a);
  • 이렇게 간단한 예시를 확인해보면, a에는 65라는 값이 들어있고 이를 비트로 확인해보면 01000001로 저장이 된다. 이 주소를 재해석 해서 char로 바꿔서 읽겠다는 의미이다. 따라서 결과는 a가 될것이다.
struct Header
{
    uint16_t type;
    uint16_t length;
    uint32_t id;
};

int main()
{
    char buffer[2];
    Header* hdr = reinterpret_cast<Header*>(buffer);
    hdr->id = 123;

    cout << "Header ID: " << hdr->id << endl;
}
  • 자 그러면 이제 reinterpret_cast의 매우 위험한 상황에 대해서 알아보자.
  • buffer같은 경우는 2바이트를 할당했지만 이거를 강제로 Header로 변환을 해서 멤버변수 id에 접근하고 있는 상태다.
  • 원래라면 id에 접근하기 위해서는 type(2바이트), length(2바이트), id(4바이트)해서 여유공간이 8바이트는 존재해야 하지만 그렇지 않다.
  • 따라서 이렇게 접근하면 오류가 날 것이다.

⚠️ 하지만 위에 있는 코드를 실행해보면 정상적으로 Header ID: 123이라고 출력이 되게 된다. 여기서 매우 위험한 동작이 일어나게 된다.

struct Header
{
    uint16_t type;
    uint32_t id;
};

struct LayoutTest {
    char buffer[2];
    volatile int value;
};

int main()
{
    LayoutTest test = {};
    test.buffer[0] = 0;
    Header* hdr = reinterpret_cast<Header*>(test.buffer);
    hdr->id = 123;

    cout << "Header ID: " << hdr->id << endl; // 123
    cout << "Value: " << test.value << endl; // 123
}
  • 자 이렇게 데이터들이 존재한다고 가정해보자. 지금 우리는 범위를 벗어나게 test.buffer에서 Header로 캐스팅을 했고 그 id값을 수정했다.
  • 하지만 놀랍게도 우리가 건드리지 않은 test.value의 값도 123으로 수정이 된 것을 확인할 수 있다.
  • 컴파일러가 정상적으로 동작했는데 이런 미친 버그가 런타임에서 일어나게 된다는 것이다. 만약에 이게 value가 아니라 특정 유저의 유료성 재화였다고 생각해보고 buffer가 디버프의 수였다고 생각해보자.
  • 개발자가 의도하진 않았지만 이 게임은 디버프를 많이 받게 되면 재화가 가끔씩 늘어나기도 하고 늘어나지 않기도 하는(컴파일러마다 처리가 다르기 때문) 정말 문제를 찾기도 어려운 미친 게임이 될 것이다.

🤔 그렇다면 왜 이러한 문제가 발생하는 것일까? 애초에 접근하는 범위가 처음 할당된 것을 넘게 되면 오버플로우 문제가 발생해야 하는 것 아닌가?
✅ 여기서는 페이징이라는 개념이 추가적으로 등장하게 된다. 페이징이라는 것은 쉽게 말해서 어떤 함수가 호출이 되면 4KB의 메모리 공간을 할당해준다. 정해진 크기를 할당해주는 이유는 외부 파편화를 해결하기 위해서 그런것이다.

페이지를 할당받으면 컴파일러가 변수들을 이 페이지 내에 배치 시키게 된다. 그래서 이 범위 안에서는 자유롭게 읽고 쓰고가 가능한데, 특정 변수의 크기를 벗어나서 수정을 해도 OS는 페이지 경계를 넘지 않았기 때문에 문제를 식별하지 못한다. 따라서 다른 변수의 일정 부분의 비트를 수정할 수 있는 것이다. 그리고 이것은 의도치 않은 값의 변경으로 파생되는 문제가 발생하게 되는 것이다.

이걸 해결하기 위한 방법으로 미리 버퍼의 사이즈와 캐스팅하려고 하는 Header의 사이즈를 비교하는 방법이나, 경계를 넣어서 검사하는 방법등이 있을 것이다.

bit_cast

사실 bit_cast는 c++언어에 일부인 다른 캐스들과는 달리 <bit>헤더에 정의되어 있다. reinterpret_cast와 비슷하지만 주어진 타겟 타입의 새로운 객체를 생성하고 원본 객체를 비트로 복사하는 것이다.
따라서 bit_cast를 사용하기 위해서는 둘 다 복사 가능이여야 하고, 크기도 같아야 한다.

struct Header
{
    uint16_t type;
    uint32_t id;
};

struct LayoutTest {
    char buffer[2];
    volatile int value;
};

int main()
{
    LayoutTest test = {};
    test.buffer[0] = 0;
    Header* hdr = bit_cast<Header*>(test.buffer);
    hdr->id = 123;

    cout << "Header ID: " << hdr->id << endl; // 123
}
  • 만약에 이렇게 작성을 하면 캐스트를 하는 부분에서 애초에 크래시가 날 것이다.
  • id는 접근을 못하기 때문에
    uint16_t type = std::bit_cast<uint16_t>(test.buffer); 이런식으로 type에만 접근하는 방식으로 사용할 수 도 있다.
profile
제가 관심있고 공부하고 싶은걸 정리하는 저만의 노트입니다.

1개의 댓글

comment-user-thumbnail
2026년 7월 28일

좋은 글 감사합니다

답글 달기