C++ 캐스팅 4가지: static_cast·dynamic_cast·const_cast·reinterpret_cast 언제 쓰나

이 글의 핵심

C++는 C 스타일의 (T)x 캐스트 하나 대신 static_cast, dynamic_cast, const_cast, reinterpret_cast 네 가지로 캐스트를 용도별로 분리합니다. 각각 위험도와 검사 시점(컴파일 타임/런타임)이 다르며, 어떤 캐스트를 쓰느냐만 봐도 코드 리뷰어가 그 변환의 의도와 위험성을 즉시 파악할 수 있다는 것이 C 스타일 캐스트 대비 가장 큰 실무적 이점입니다.

static_cast

용도: 컴파일 타임에 타입 변환

// 기본 타입 변환
int x = 10;
double y = static_cast<double>(x);

// 포인터 업캐스팅 (안전)
class Base {};
class Derived : public Base {};

Derived* d = new Derived();
Base* b = static_cast<Base*>(d);  // OK

// 포인터 다운캐스팅 (위험)
Base* b2 = new Base();
Derived* d2 = static_cast<Derived*>(b2);  // 컴파일은 되지만 위험!

static_cast가 “컴파일 타임” 캐스트라고 불리는 이유는, 이 변환의 타당성을 컴파일러가 타입 정보만으로 판단하고 런타임 검사를 전혀 추가하지 않기 때문입니다. 그래서 마지막 줄의 다운캐스팅처럼 실제로는 b2가 Derived 객체가 아닌데도 컴파일이 그대로 통과합니다 — 컴파일러는 “타입 관계상 가능한 변환”이라는 것만 확인할 뿐, “지금 이 포인터가 실제로 그 타입인가”는 전혀 확인하지 않습니다. 이 다운캐스트 결과로 얻은 d2의 멤버에 접근하는 순간 정의되지 않은 동작이 발생합니다.

언제 사용:

  • 기본 타입 변환
  • 명시적 타입 변환
  • 업캐스팅

숫자 변환에서도 static_cast는 값을 “맞게” 바꿔 주는 것이 아니라 정해진 규칙대로 바꿀 뿐입니다. static_cast<int>(3.9)는 반올림이 아니라 소수점 아래를 버려 3이 되고, static_cast<int>(-3.9)는 -3입니다. 더 위험한 것은 범위를 벗어나는 경우입니다. static_cast<int>(1e20)처럼 int로 표현할 수 없는 double을 변환하면 미정의 동작이며, x86에서는 대개 INT_MIN에 해당하는 값이 나오지만 컴파일러가 최적화 과정에서 다른 결과를 만들 수도 있습니다. 반대로 static_cast<unsigned>(-1)처럼 정수 사이의 변환은 모듈러 연산으로 정의되어 4294967295가 됩니다. 캐스트는 컴파일러 경고(-Wconversion)를 끄는 효과도 있으므로, “경고가 귀찮아서” 붙인 static_cast가 실제 버그를 숨기는 경우가 적지 않습니다.

static_cast는 접근 제어도 존중합니다. class Derived : private Base처럼 비공개로 상속한 경우 static_cast<Base*>(d)는 'Base' is an inaccessible base of 'Derived' 에러가 나지만, C 스타일 (Base*)d는 이 검사를 건너뛰고 변환해 버립니다. C 스타일 캐스트를 지양해야 하는 또 하나의 이유입니다.

dynamic_cast

용도: 런타임에 안전한 다운캐스팅

class Base {
public:
    virtual ~Base() {}  // 가상 함수 필요!
};

class Derived : public Base {
public:
    void derivedMethod() {
        cout << "Derived 메서드" << endl;
    }
};

int main() {
    Base* b = new Derived();

    // 안전한 다운캐스팅
    Derived* d = dynamic_cast<Derived*>(b);
    if (d) {
        d->derivedMethod();  // 성공
    } else {
        cout << "캐스팅 실패" << endl;
    }

    // 실패 예시
    Base* b2 = new Base();
    Derived* d2 = dynamic_cast<Derived*>(b2);
    if (!d2) {
        cout << "캐스팅 실패" << endl;  // 출력됨
    }
}

dynamic_cast가 static_cast와 근본적으로 다른 점은 실제 객체의 동적 타입을 RTTI(Run-Time Type Information)로 확인한 뒤, 변환이 유효하지 않으면 (포인터의 경우) nullptr을 반환한다는 것입니다. 이게 가능하려면 Base에 최소 하나의 가상 함수가 있어야 하는데, vtable이 있어야만 컴파일러가 그 객체의 실제 타입 정보를 런타임에 조회할 수 있기 때문입니다 — 가상 함수가 하나도 없는 클래스 계층에는 dynamic_cast로 다운캐스트할 수 없고, GCC는 cannot 'dynamic_cast' ... (source type is not polymorphic) 에러를 냅니다. 소멸자를 virtual로 두는 것만으로 충분하며, 기반 클래스 포인터로 파생 객체를 delete하는 설계라면 어차피 가상 소멸자가 필요합니다.

참조로 캐스팅하면 실패를 알리는 방식이 달라집니다. 참조에는 nullptr 같은 “없음” 값이 없으므로, dynamic_cast<Derived&>(*b2)가 실패하면 std::bad_cast 예외를 던집니다. “이 객체는 반드시 Derived여야 하고, 아니라면 프로그램 오류”인 상황에서는 참조 버전이 실패를 조용히 넘기지 않아 오히려 안전합니다. 또 게임 엔진이나 임베디드 프로젝트처럼 바이너리 크기 때문에 -fno-rtti로 빌드하는 환경에서는 dynamic_cast와 typeid를 아예 쓸 수 없어 'dynamic_cast' not permitted with '-fno-rtti' 에러가 납니다. 이런 코드베이스는 기반 클래스에 virtual Kind kind() const 같은 타입 태그를 두고 확인한 뒤 static_cast하는 방식을 씁니다.

언제 사용:

  • 다운캐스팅
  • 타입 확인이 필요할 때
  • RTTI(Run-Time Type Information) 필요 시

const_cast

용도: const 속성 제거/추가

void legacyFunction(char* str) {
    // const 없는 레거시 함수
}

void modernFunction(const char* str) {
    // const 제거 (위험!)
    legacyFunction(const_cast<char*>(str));
}

// const 추가
int main() {
    int x = 10;
    const int* ptr = const_cast<const int*>(&x);
}

주의: 원래 const인 객체를 수정하면 정의되지 않은 동작!

const_cast가 실무에서 정당화되는 거의 유일한 경우가 위 예제처럼 “레거시 C API가 const를 받지 않는데, 실제로는 인자를 수정하지 않는다는 것을 호출하는 쪽이 알고 있는” 상황입니다. 이런 경우가 아니라 진짜 const로 선언된 객체(const int x = 10;처럼)를 가리키는 포인터에 const_cast를 써서 값을 수정하려 하면, 컴파일은 되지만 실행 시점에 정의되지 않은 동작으로 이어집니다. 전역 상수나 문자열 리터럴처럼 읽기 전용 영역에 놓인 객체라면 크래시가 나고, 지역 상수라면 크래시 없이 “값을 바꿨는데 출력은 그대로”인 더 헷갈리는 결과가 나오기도 합니다(뒤의 문제 2 참고).

정당한 용도가 하나 더 있습니다. 같은 로직을 가진 const/비const 멤버 함수 쌍에서 코드 중복을 없애는 패턴입니다. 비const 버전이 const 버전을 호출한 뒤 결과의 const를 떼어 내는 방식으로, *this가 원래 비const 객체라는 것이 호출 시점에 보장되므로 안전합니다.

class Buffer {
    std::vector<char> data_;
public:
    const char& at(size_t i) const {
        if (i >= data_.size()) throw std::out_of_range("index");
        return data_[i];
    }
    char& at(size_t i) {
        // 비const 객체에서만 호출되므로 const를 떼어도 안전
        return const_cast<char&>(std::as_const(*this).at(i));
    }
};

반대 방향(비const 버전을 const 버전에서 호출)은 원본이 진짜 const일 수 있어 위험하므로 쓰지 않습니다. C++23의 deducing this(template<class Self> auto& at(this Self& self, size_t i))를 쓰면 이 패턴 자체가 필요 없어집니다.

reinterpret_cast

용도: 포인터를 다른 타입으로 재해석

int x = 42;
int* ptr = &x;

// 포인터를 정수로
uintptr_t addr = reinterpret_cast<uintptr_t>(ptr);
cout << "주소: " << addr << endl;

// 정수를 포인터로
int* ptr2 = reinterpret_cast<int*>(addr);

// 포인터 타입 변환 (위험!)
double* dptr = reinterpret_cast<double*>(ptr);

reinterpret_cast는 네 가지 캐스트 중 가장 위험한데, 대상 타입이 원본과 완전히 무관해도 컴파일러가 막지 않기 때문입니다. 마지막 줄처럼 int*를 double*로 재해석하면 컴파일은 통과하지만, int(보통 4바이트)와 double(보통 8바이트)은 크기와 비트 표현이 전혀 다르므로 *dptr로 역참조하는 순간 의미 없는 값을 읽거나 정렬(alignment) 위반으로 크래시가 날 수 있습니다.

크기와 정렬이 맞더라도 문제는 남습니다. C++의 엄격한 별칭 규칙(strict aliasing)은 한 객체를 원래 타입과 무관한 타입의 포인터로 읽고 쓰는 것을 미정의 동작으로 정합니다(예외는 char, unsigned char, std::byte와 부호만 다른 정수 타입). 컴파일러는 이 규칙을 근거로 “float*로 쓴 값은 int*로 읽는 값에 영향을 주지 않는다”고 가정하고 메모리 읽기를 재배치하거나 생략합니다. 그래서 float의 비트 패턴을 보려고 *reinterpret_cast<uint32_t*>(&f)처럼 쓴 코드가 -O0에서는 맞게 동작하다가 -O2에서 엉뚱한 값을 내는 일이 실제로 일어납니다. 값의 비트를 다른 타입으로 해석하고 싶다면 memcpy로 복사하거나 C++20의 std::bit_cast<uint32_t>(f)를 쓰는 것이 표준적인 방법이며, 최적화 빌드에서는 둘 다 레지스터 이동 한 번으로 컴파일됩니다.

언제 사용:

  • 저수준 프로그래밍
  • 하드웨어 접근
  • 직렬화/역직렬화

C 스타일 캐스트 vs C++ 캐스트

// ❌ C 스타일 (위험)
int x = 10;
double y = (double)x;

Base* b = new Derived();
Derived* d = (Derived*)b;  // 어떤 캐스트인지 불명확

// ✅ C++ 스타일 (명확)
double y2 = static_cast<double>(x);
Derived* d2 = dynamic_cast<Derived*>(b);

(Derived*)b라는 C 스타일 캐스트는 컴파일러가 상황에 따라 static_cast, const_cast, reinterpret_cast 중 하나(또는 그 조합)로 알아서 해석합니다. 문제는 코드를 읽는 사람도, grep으로 위험한 캐스트를 찾으려는 사람도 이 한 줄만 보고는 실제로 어떤 변환이 일어나는지 알 수 없다는 것입니다. C++ 스타일 캐스트는 이름 자체가 검색 키워드가 되므로, 예를 들어 reinterpret_cast만 골라서 리뷰하는 것이 C 스타일 캐스트로는 사실상 불가능한 반면 C++ 스타일에서는 grep reinterpret_cast 한 줄로 끝납니다.

C 스타일 캐스트가 실제로 사고를 내는 대표적인 경로는 헤더를 include하지 않은 경우입니다. Derived의 정의가 보이지 않고 전방 선언(class Derived;)만 있는 파일에서 (Derived*)b를 쓰면, 컴파일러는 상속 관계를 알 수 없으므로 static_cast가 아니라 reinterpret_cast로 해석합니다. 다중 상속이라 기반 클래스 부분이 객체의 앞쪽에 있지 않다면, 포인터 주소 보정이 빠진 채로 변환되어 엉뚱한 메모리를 가리키게 됩니다. 같은 코드를 static_cast로 쓰면 “불완전한 타입”이라는 컴파일 에러가 나서 문제를 바로 알 수 있습니다. GCC와 Clang의 -Wold-style-cast 경고를 켜 두면 코드베이스에 남은 C 스타일 캐스트를 찾아 줍니다.

실전 예시

예시 1: 다형성과 dynamic_cast

class Shape {
public:
    virtual void draw() = 0;
    virtual ~Shape() {}
};

class Circle : public Shape {
public:
    void draw() override { cout << "원" << endl; }
    double getRadius() { return 5.0; }
};

class Rectangle : public Shape {
public:
    void draw() override { cout << "사각형" << endl; }
    double getWidth() { return 10.0; }
};

void processShape(Shape* shape) {
    shape->draw();

    // Circle인지 확인
    if (Circle* circle = dynamic_cast<Circle*>(shape)) {
        cout << "반지름: " << circle->getRadius() << endl;
    }

    // Rectangle인지 확인
    if (Rectangle* rect = dynamic_cast<Rectangle*>(shape)) {
        cout << "너비: " << rect->getWidth() << endl;
    }
}

int main() {
    Shape* shapes[] = {
        new Circle(),
        new Rectangle()
    };

    for (Shape* shape : shapes) {
        processShape(shape);
        delete shape;
    }
}

processShape가 Circle인지 Rectangle인지 매번 dynamic_cast로 확인하는 이 패턴은 동작은 하지만, 도형 종류가 늘어날수록 if 체인이 계속 길어진다는 확장성 문제가 있습니다. 이런 “타입별로 다른 처리”가 반복적으로 필요하다면, dynamic_cast 체인 대신 각 도형 클래스에 필요한 동작을 가상 함수로 넣는 편이 장기적으로 더 깔끔한 경우가 많습니다.

예시 2: 직렬화

#include <cstring>

struct Data {
    int id;
    double value;
};

void serialize(const Data& data, char* buffer) {
    // 구조체를 바이트 배열로
    memcpy(buffer, &data, sizeof(Data));
}

Data deserialize(const char* buffer) {
    Data data;
    memcpy(&data, buffer, sizeof(Data));
    return data;
}

int main() {
    Data original = {42, 3.14};
    char buffer[sizeof(Data)];

    serialize(original, buffer);
    Data restored = deserialize(buffer);

    cout << restored.id << ", " << restored.value << endl;
}

이 예제가 reinterpret_cast 대신 memcpy를 쓰는 것은 우연이 아닙니다. memcpy로 바이트를 복사하는 것은 엄격한 별칭 규칙(strict aliasing rule)을 위반하지 않는 표준적인 방법인 반면, reinterpret_cast<Data*>(buffer)로 캐스팅한 뒤 그 포인터를 직접 역참조하면 컴파일러 최적화에 따라 정의되지 않은 동작이 될 수 있습니다. 다만 이 방식도 C++ 파일 입출력에서 다룬 것처럼 구조체 패딩이 플랫폼마다 다를 수 있다는 이식성 문제는 그대로 남아 있습니다.

예시 3: 플러그인 시스템

class Plugin {
public:
    virtual void execute() = 0;
    virtual ~Plugin() {}
};

class AudioPlugin : public Plugin {
public:
    void execute() override { cout << "오디오 처리" << endl; }
    void setVolume(int v) { volume = v; }
private:
    int volume = 100;
};

void configurePlugin(Plugin* plugin) {
    // AudioPlugin인지 확인하고 볼륨 설정
    if (AudioPlugin* audio = dynamic_cast<AudioPlugin*>(plugin)) {
        audio->setVolume(80);
    }
}

플러그인 시스템처럼 여러 종류의 구현체가 공통 인터페이스(Plugin) 뒤에 숨어 있는 구조에서는, 특정 구현체에만 있는 기능(setVolume)에 접근하기 위해 dynamic_cast로 “혹시 이 타입이면”을 확인하는 패턴이 자주 쓰입니다. configurePlugin이 AudioPlugin이 아닌 다른 플러그인을 받아도 dynamic_cast가 조용히 nullptr을 반환할 뿐 크래시가 나지 않는다는 점이 이 패턴을 안전하게 만듭니다.

자주 발생하는 문제

문제 1: dynamic_cast 실패 무시

// ❌ 위험
Base* b = new Base();
Derived* d = dynamic_cast<Derived*>(b);
d->derivedMethod();  // 크래시! (d는 nullptr)

// ✅ 체크
if (Derived* d = dynamic_cast<Derived*>(b)) {
    d->derivedMethod();
} else {
    cout << "캐스팅 실패" << endl;
}

dynamic_cast가 실패를 예외가 아니라 nullptr로 조용히 알려준다는 점(포인터 버전에 한해)이 이 실수를 유발합니다. 반환값을 확인하지 않으면 컴파일러도 경고해주지 않고 그대로 통과되어, nullptr 역참조 크래시가 dynamic_cast 호출 지점이 아니라 d->derivedMethod()에서 발생하기 때문에 처음 보면 원인을 캐스팅에서 찾기 어려울 수 있습니다.

문제 2: const_cast 남용

// ❌ 정의되지 않은 동작
const int x = 10;
int* ptr = const_cast<int*>(&x);
*ptr = 20;  // 위험!

// ✅ 원래 non-const인 경우만
int y = 10;
const int* cptr = &y;
int* ptr2 = const_cast<int*>(cptr);
*ptr2 = 20;  // OK

두 코드의 차이는 const_cast 자체가 아니라 원본 객체가 진짜 const로 선언되었는가에 있습니다. y는 원래 비-const 변수이므로 const int* cptr를 거쳐 다시 비-const로 되돌리는 것이 안전하지만, x는 애초에 const int로 선언되어 있어 컴파일러가 읽기 전용 메모리에 배치했을 가능성이 있고, 그 경우 *ptr = 20은 진짜로 읽기 전용 메모리에 쓰기를 시도하는 것이 됩니다.

지역 변수 const int x = 10;은 스택에 있어 크래시가 나지 않는 경우가 많은데, 그래서 더 혼란스럽습니다. 컴파일러는 x가 절대 바뀌지 않는다고 믿고 x를 쓰는 곳에 상수 10을 직접 박아 넣으므로, *ptr = 20 뒤에 std::cout << x를 해도 10이 출력되고 *ptr는 20을 보여 줍니다. 같은 주소의 값이 두 가지로 보이는 이 현상은 미정의 동작의 전형적인 모습이며, 최적화 수준에 따라 결과가 바뀝니다.

문제 3: reinterpret_cast 오용

// ❌ 정렬 문제
int x = 42;
double* dptr = reinterpret_cast<double*>(&x);
// *dptr;  // 크래시 가능!

// ✅ 같은 크기, 같은 정렬
uint32_t u = 42;
int32_t* iptr = reinterpret_cast<int32_t*>(&u);  // OK

두 번째 예제가 안전한 이유는 uint32_t와 int32_t가 크기(4바이트)와 정렬 요구사항이 같을 뿐 아니라, 표준의 별칭 규칙이 “부호만 다른 정수 타입”끼리의 접근을 명시적으로 허용하기 때문입니다. 크기와 정렬이 같아도 float와 int32_t처럼 무관한 타입이라면 여전히 미정의 동작입니다. 반면 int(보통 4바이트)를 double(보통 8바이트, 더 엄격한 정렬 요구)로 재해석하면 크기 불일치로 인해 할당되지 않은 메모리까지 읽게 되고, 플랫폼에 따라 정렬 위반으로 하드웨어 예외가 발생할 수도 있습니다.

캐스팅 선택 가이드

타입 변환이 필요한가?
├─ 기본 타입 변환? → static_cast
├─ 다운캐스팅?
│  ├─ 안전성 필요? → dynamic_cast
│  └─ 성능 중요? → static_cast (주의!)
├─ const 제거? → const_cast (주의!)
└─ 포인터 재해석? → reinterpret_cast (위험!)

성능 비교

// static_cast: 컴파일 타임, 오버헤드 없음
Derived* d1 = static_cast<Derived*>(base);

// dynamic_cast: 런타임 타입 체크, 약간 느림
Derived* d2 = dynamic_cast<Derived*>(base);

dynamic_cast의 런타임 비용은 대부분 vtable을 거슬러 올라가며 타입 정보를 비교하는 과정에서 나오며, 클래스 계층이 깊을수록(다중 상속이 섞이면 더욱) 비용이 커질 수 있습니다. 다운캐스팅이 코드 구조상 반드시 성공한다는 것이 보장된다면(예: 팩토리 함수가 항상 특정 파생 타입만 만드는 경우) static_cast로 이 비용을 없앨 수 있지만, 그 보장이 나중에 깨지면 dynamic_cast와 달리 아무 경고 없이 조용히 잘못된 동작을 하게 된다는 트레이드오프를 감수하는 것입니다.

FAQ

Q1: 어떤 캐스트를 사용해야 하나요?

A:

  1. 먼저 캐스트 없이 해결 시도
  2. static_cast (가장 일반적)
  3. dynamic_cast (다운캐스팅)
  4. const_cast (레거시 코드)
  5. reinterpret_cast (저수준)

Q2: C 스타일 캐스트는 왜 나쁜가요?

A:

  • 어떤 캐스트인지 불명확
  • 검색하기 어려움
  • 의도하지 않은 변환 가능

Q3: dynamic_cast는 얼마나 느린가요?

A: 구현에 따라 다르지만, 대상 타입의 type_info를 객체의 실제 타입 정보와 비교하며 상속 트리를 탐색하므로 가상 함수 호출 한 번보다 훨씬 비쌉니다. 특히 여러 공유 라이브러리에 걸친 타입은 이름 문자열 비교로 떨어지기도 합니다. 루프 안에서 매번 호출하는 경우가 아니라면 대개 문제가 되지 않으며, 프로파일러에서 __dynamic_cast가 상위에 보일 때 설계를 바꾸는 것이 순서입니다.

Q4: 가상 함수가 없는 클래스에도 dynamic_cast를 쓸 수 있나요?

A: 업캐스트(파생 → 기반)는 static_cast와 같은 동작이라 가능하지만, 다운캐스트와 교차 캐스트는 “source type is not polymorphic” 컴파일 에러가 납니다. 런타임에 실제 타입을 확인하려면 vtable에 연결된 타입 정보가 필요하기 때문이며, 가상 소멸자 하나만 추가하면 됩니다.

Q5: 캐스팅 자체를 줄이려면 어떻게 하나요?

A: dynamic_cast로 타입을 확인한 뒤 분기하는 코드가 여러 곳에 반복된다면, 그 동작을 기반 클래스의 가상 함수로 옮기는 것이 첫 번째 방법입니다. 타입 종류가 고정돼 있고 동작이 자주 추가된다면 std::variant와 std::visit이 캐스트 없이 모든 경우를 처리하게 해 주며, 새 타입을 추가했을 때 처리가 빠진 곳을 컴파일러가 알려 준다는 장점도 있습니다. 숫자 변환 캐스트는 애초에 변수 타입을 맞춰 선언하는 것으로 상당 부분 없앨 수 있습니다.


같이 보면 좋은 글