C++ 타입 변환: 암시적 변환, static_cast·dynamic_cast·const_cast·reinterpret_cast, explicit

이 글의 핵심

암시적 변환이 일어나는 규칙, 네 가지 캐스트 연산자를 언제 써야 하는지, 변환 생성자와 explicit, 사용자 정의 변환이 만드는 함정을 정리합니다.

암시적 변환

컴파일러가 자동으로 수행하는 변환

암시적 변환이 존재하는 이유는 C++가 값을 요구하는 자리에 “정확히 같은 타입은 아니지만 안전하게 변환 가능한” 값이 오면 편의를 위해 자동으로 맞춰주기 때문입니다. int x = 10; double y = x;는 값 손실 없이 안전한 방향(정수 → 부동소수점, 확장 변환)이라 문제가 없지만, int z = f;(float → int)는 소수점 이하가 조용히 잘려나가는 축소 변환이라는 점이 다릅니다 — 두 경우 모두 컴파일러가 경고 없이 자동으로 처리하지만, 후자는 값을 잃어버린다는 사실을 코드만 보고는 알아채기 어렵습니다. bool b = 42;처럼 0이 아닌 어떤 정수든 true로 변환되는 규칙도 마찬가지로, 편리하지만 “0과 42와 -1이 모두 같은 true로 취급된다”는 정보 손실을 동반합니다. 이런 암묵적 손실이 실무에서 버그로 이어지는 경우가 많기 때문에, 뒤에서 다룰 explicit 키워드가 사용자 정의 타입에 대해서는 이런 조용한 변환을 원천 차단하는 도구로 존재합니다.

int x = 10;
double y = x;  // int -> double (암시적)
float f = 3.14;
int z = f;  // float -> int (암시적, 소수점 버림)
bool b = 42;  // int -> bool (0이 아니면 true)

명시적 변환 (캐스팅)

(int)d라는 C 스타일 캐스트의 근본적인 문제는 그 한 문법이 실제로는 static_cast, const_cast, reinterpret_cast, 심지어 이 셋의 조합까지 상황에 따라 다르게 동작한다는 것입니다 — 코드를 읽는 사람은 (int)d만 보고는 컴파일러가 실제로 어떤 종류의 변환을 수행했는지 알 수 없고, 코드에서 검색으로 “위험한 캐스트가 어디 있는지” 찾아내는 것도 사실상 불가능합니다. static_cast<int>(d)는 이 모호함을 없앱니다 — 이름 자체가 “컴파일 타임에 검증 가능한 안전한 변환만 수행한다”는 의도를 명시하고, 만약 그 변환이 실제로 위험한 종류(예: 관련 없는 포인터 타입 간 변환)라면 컴파일러가 애초에 거부합니다. 이후 나올 dynamic_cast, const_cast, reinterpret_cast도 각자의 이름이 정확히 그 의도를 드러내며, 이 네 가지로 나뉜 것 자체가 “코드에 등장하는 캐스트의 종류만 보고도 그것이 얼마나 위험한지 즉시 판단할 수 있게 하자”는 설계 철학의 결과입니다.

double d = 3.14;
// C 스타일 캐스트
int x = (int)d;
// C++ 스타일 캐스트
int y = static_cast<int>(d);

변환 생성자

단일 인자(또는 나머지 인자가 모두 기본값을 가진) 생성자는 특별한 역할을 하나 더 갖습니다 — 컴파일러는 그 생성자를 “그 인자 타입에서 클래스 타입으로 가는 암시적 변환 경로”로도 취급합니다. printDistance(100.5)가 컴파일되는 이유가 바로 이것입니다 — printDistance는 Distance를 요구하는데 double이 왔으므로, 컴파일러가 Distance(double) 생성자를 자동으로 찾아 100.5를 Distance(100.5)로 암묵적으로 감싸 호출을 성립시킵니다. 편리해 보이지만, 이는 호출자가 의도했든 안 했든 발생하는 자동 변환이라는 점에서 위험을 안고 있으며, 다음 절의 explicit가 정확히 이 자동 변환을 막기 위한 장치입니다.

class Distance {
private:
    double meters;
    
public:
    // 변환 생성자
    Distance(double m) : meters(m) {}
    
    double getMeters() const {
        return meters;
    }
};
void printDistance(Distance d) {
    cout << d.getMeters() << "m" << endl;
}
int main() {
    printDistance(100.5);  // double -> Distance (암시적)
    printDistance(Distance(50.0));  // 명시적
}

explicit 키워드

explicit를 생성자 앞에 붙이면, 그 생성자는 여전히 Distance(100.5)처럼 명시적으로 호출할 수는 있지만 앞서 본 printDistance(100.5)처럼 컴파일러가 알아서 끼워 넣는 암시적 변환 경로로는 더 이상 쓰이지 못합니다. 이 하나의 키워드가 막는 것은 “숫자 하나를 실수로 넘겼는데 컴파일러가 조용히 다른 타입으로 바꿔치기해 컴파일을 통과시켜 버리는” 상황입니다 — 타입이 안 맞으면 즉시 컴파일 에러로 드러나야, 그 실수를 작성 시점에 바로 알아챌 수 있습니다. 실무에서는 단일 인자 생성자를 작성할 때 “이 타입으로의 암시적 변환이 실제로 유용하고 안전한가”를 먼저 따져보고, 확신이 없다면 기본적으로 explicit를 붙이는 것이 안전한 기본값으로 여겨집니다.

class Distance {
private:
    double meters;
    
public:
    // explicit: 암시적 변환 금지
    explicit Distance(double m) : meters(m) {}
    
    double getMeters() const {
        return meters;
    }
};
void printDistance(Distance d) {
    cout << d.getMeters() << "m" << endl;
}
int main() {
    // printDistance(100.5);  // 에러: 암시적 변환 불가
    printDistance(Distance(100.5));  // OK: 명시적
}

변환 연산자

변환 생성자가 “다른 타입 → 내 타입” 방향이라면, operator double() const처럼 정의하는 변환 연산자는 정반대 방향(“내 타입 → 다른 타입”)을 표현합니다. double x = d;가 컴파일되는 것은 x가 double이어야 하는데 Distance 값 d가 왔으므로, 컴파일러가 Distance에 정의된 operator double()을 자동으로 호출해 그 결과를 대입하기 때문입니다. 이 역시 변환 생성자와 마찬가지로 암시적으로 작동하는 편의 기능이며, 뒤의 “변환 연산자 남용” 사례에서 보듯 이런 암시적 변환 연산자가 예상치 못한 연산(n + 10처럼)에 개입해 혼란을 주는 경우가 실무에서 자주 문제가 되므로, 대부분의 최신 코드는 이 연산자에도 explicit를 붙이는 쪽을 선호합니다.

class Distance {
private:
    double meters;
    
public:
    Distance(double m) : meters(m) {}
    
    // 변환 연산자: Distance -> double
    operator double() const {
        return meters;
    }
};
int main() {
    Distance d(100.5);
    double x = d;  // Distance -> double (암시적)
    
    cout << x << endl;  // 100.5
}

실전 예시

예시 1: 문자열 래퍼

이 클래스가 흥미로운 이유는 같은 클래스 안에 암시적 변환 생성자(String(const char*))와 명시적 변환 생성자(explicit String(const string&))를 의도적으로 다르게 선택했다는 점입니다 — const char*로부터의 변환은 문자열 리터럴을 그대로 String으로 다루고 싶은 흔한 사용 패턴이라 암시적으로 허용했지만, 이미 std::string을 가진 코드가 String으로 바뀌는 것은 좀 더 의도적인 선택이어야 한다고 판단해 explicit로 막아둔 것입니다. operator string()과 operator const char*()처럼 변환 연산자를 두 개나 두면, 실제로는 str = s(String → string)와 cstr = s(String → const char*)라는 두 개의 서로 다른 대상 타입 중 컴파일러가 문맥에 맞는 쪽을 알아서 골라 호출하는데, 이렇게 변환 연산자가 여러 개 있으면 오버로드 해석이 모호해지는 경우도 실무에서 종종 발생해 신중한 설계가 필요합니다.

class String {
private:
    string data;
    
public:
    String(const char* str) : data(str) {}
    
    explicit String(const string& str) : data(str) {}
    
    // string으로 변환
    operator string() const {
        return data;
    }
    
    // const char*로 변환
    operator const char*() const {
        return data.c_str();
    }
    
    size_t length() const {
        return data.length();
    }
};
int main() {
    String s("Hello");
    
    string str = s;  // String -> string
    const char* cstr = s;  // String -> const char*
    
    cout << str << endl;
    cout << cstr << endl;
}

예시 2: 스마트 포인터

explicit operator bool() const는 실제 std::unique_ptr/std::shared_ptr가 채택한 것과 정확히 같은 패턴입니다 — if (p1)처럼 if 조건문의 문맥에서는 explicit가 붙은 변환 연산자도 예외적으로 암시적 변환이 허용되지만(이를 “contextual conversion”이라 부릅니다), int x = p1 + 1처럼 일반적인 산술 문맥에서는 이 변환이 적용되지 않아 컴파일 에러가 됩니다. 이 절묘한 절충이 왜 필요한지는 explicit를 붙이지 않았을 때를 상상해 보면 명확해집니다 — 만약 bool 변환이 완전히 암시적이었다면, SmartPtr<int> a, b; int sum = a + b;처럼 bool이 자동으로 정수로 승격되어 1 + 1을 계산하는 등 스마트 포인터에 대해 전혀 말이 안 되는 산술 연산이 조용히 컴파일되어 버렸을 것입니다 — explicit는 이런 사고를 막으면서도 if/while처럼 “참인지 거짓인지만 알면 되는” 자연스러운 문맥은 그대로 허용합니다.

template<typename T>
class SmartPtr {
private:
    T* ptr;
    
public:
    explicit SmartPtr(T* p = nullptr) : ptr(p) {}
    
    ~SmartPtr() {
        delete ptr;
    }
    
    // bool로 변환 (nullptr 체크)
    explicit operator bool() const {
        return ptr != nullptr;
    }
    
    T& operator*() const {
        return *ptr;
    }
    
    T* operator->() const {
        return ptr;
    }
};
int main() {
    SmartPtr<int> p1(new int(42));
    SmartPtr<int> p2;
    
    if (p1) {  // bool로 변환
        cout << *p1 << endl;
    }
    
    if (!p2) {
        cout << "p2는 nullptr" << endl;
    }
}

예시 3: 온도 클래스

Fahrenheit(const Celsius& c)는 변환 생성자의 또 다른 흔한 용도, 즉 “서로 다른 두 사용자 정의 타입 사이의 변환”을 보여줍니다 — 이 생성자에는 explicit가 없으므로, Celsius가 필요한 자리에 실수로 Fahrenheit를 넘기는 것은 여전히 막히지만(반대 방향 변환이 없으므로), Fahrenheit f = c;처럼 Celsius에서 Fahrenheit로의 변환은 자동으로 이루어집니다. 온도 단위 변환처럼 “이 변환에 정보 손실이 없고 항상 명확한 의미를 갖는” 경우라면 암시적 변환을 허용해도 실용적인 위험이 적지만, 만약 두 단위 사이의 변환이 근사치이거나 손실이 있다면(예: 두 가지 다른 정밀도의 화폐 단위) 이런 자동 변환은 오히려 숨겨진 정밀도 손실을 만들 수 있으므로 이 판단은 변환의 성격에 따라 신중히 내려야 합니다.

class Celsius {
private:
    double temp;
    
public:
    explicit Celsius(double t) : temp(t) {}
    
    double get() const {
        return temp;
    }
};
class Fahrenheit {
private:
    double temp;
    
public:
    explicit Fahrenheit(double t) : temp(t) {}
    
    // Celsius로 변환
    Fahrenheit(const Celsius& c) 
        : temp(c.get() * 9.0 / 5.0 + 32.0) {}
    
    double get() const {
        return temp;
    }
};
int main() {
    Celsius c(100.0);
    Fahrenheit f = Fahrenheit(c);  // Celsius -> Fahrenheit
    
    cout << "섭씨 " << c.get() << "도" << endl;
    cout << "화씨 " << f.get() << "도" << endl;
}

예시 4: 분수 클래스

Fraction이 operator double()과 operator int()를 둘 다 explicit로 선언한 것에 주목할 만합니다 — 분수를 실수나 정수로 “바꿔 보는” 것은 유용한 연산이지만(3/4를 0.75로, 정수부만 0으로), 이 변환에는 명백히 정밀도 손실(int 변환은 소수부를 완전히 버림)이 있으므로 저자는 이것이 호출자의 명시적 요청 없이 조용히 일어나서는 안 된다고 판단한 것입니다. static_cast<double>(f)와 static_cast<int>(f)처럼 매번 명시적으로 캐스트를 요구하게 만들면, 코드를 읽는 사람이 “여기서 분수가 실수나 정수로 바뀌면서 정보가 손실될 수 있다”는 것을 캐스트 표현 자체에서 즉시 알아챌 수 있습니다 — 이는 앞선 온도 클래스 예시와 정반대의 판단이며, 그 차이는 정확히 “변환에 정보 손실이 있는가”에서 나옵니다.

class Fraction {
private:
    int numerator;
    int denominator;
    
public:
    Fraction(int n, int d = 1) 
        : numerator(n), denominator(d) {}
    
    // double로 변환
    explicit operator double() const {
        return static_cast<double>(numerator) / denominator;
    }
    
    // int로 변환 (정수 부분만)
    explicit operator int() const {
        return numerator / denominator;
    }
    
    void print() const {
        cout << numerator << "/" << denominator;
    }
};
int main() {
    Fraction f(3, 4);
    
    double d = static_cast<double>(f);  // 0.75
    int i = static_cast<int>(f);        // 0
    
    cout << "분수: ";
    f.print();
    cout << endl;
    cout << "실수: " << d << endl;
    cout << "정수: " << i << endl;
}

표준 변환 순서

이 다섯 가지는 컴파일러가 오버로드 해석 시 “어떤 변환이 더 나은 후보인가”를 판단할 때 실제로 참고하는 표준 변환의 범주입니다 — 예를 들어 char에서 int로 가는 정수 승격은 표준이 정의한 가장 “저렴한” 변환 축에 속하고, 사용자 정의 변환(앞서 다룬 변환 생성자·연산자)은 이보다 “비싼” 변환으로 취급됩니다. 이 순서가 실무에서 중요한 이유는, 여러 오버로드가 동시에 후보가 될 때 컴파일러가 “어떤 표준 변환이 더 적은 단계로 일치하는가”를 기준으로 승자를 결정하기 때문입니다 — 예를 들어 func(int)와 func(double)이 모두 있는 상태에서 char를 넘기면, char → int(정수 승격)가 char → double(부동소수점 변환)보다 우선순위가 높아 func(int)가 선택됩니다. 이런 우선순위 규칙을 몰라도 대부분의 코드는 문제없이 동작하지만, 오버로드 해석이 예상과 다르게 동작하는 버그를 진단할 때는 이 변환 등급 개념이 정확한 원인 파악에 도움이 됩니다.

// 1. 정수 승격
char c = 'A';
int x = c;  // char -> int
// 2. 정수 변환
long l = 100;
int y = l;  // long -> int
// 3. 부동소수점 변환
float f = 3.14f;
double d = f;  // float -> double
// 4. 포인터 변환
int* p = nullptr;  // nullptr -> int*
// 5. bool 변환
bool b = 42;  // int -> bool

사용자 정의 변환 규칙

int → A → B처럼 사용자 정의 변환이 연쇄적으로 필요한 경우 컴파일러가 이를 자동으로 이어 붙여 주지 않는다는 것이 이 규칙의 핵심입니다 — 표준은 하나의 암시적 변환 시퀀스 안에서 최대 한 번의 사용자 정의 변환(변환 생성자 또는 변환 연산자)만 허용합니다. B b = 42;가 실패하는 이유는 이 한 줄이 실제로는 int → A(첫 번째 사용자 정의 변환)와 A → B(두 번째 사용자 정의 변환) 두 단계를 요구하기 때문이며, 컴파일러는 이 두 단계를 자동으로 연결하지 않습니다. 이 제약이 존재하는 이유는 만약 여러 단계의 사용자 정의 변환을 무제한으로 허용한다면, 서로 무관해 보이는 타입들 사이에 개발자도 예측하지 못한 긴 변환 경로가 우연히 성립해 코드가 어떤 타입으로 실제로 변환되고 있는지 추적하기 매우 어려워지기 때문입니다 — A a = 42; B b = a;처럼 각 단계를 명시적으로 나눠 쓰면 그 경로가 코드에 그대로 드러나 훨씬 읽기 쉬워집니다.

class A {
public:
    A(int x) {}  // int -> A
};
class B {
public:
    B(const A& a) {}  // A -> B
};
int main() {
    // int -> A -> B (최대 1번의 사용자 정의 변환)
    // B b = 42;  // 에러: 2번의 변환 필요
    
    A a = 42;  // OK: int -> A
    B b = a;   // OK: A -> B
}

자주 발생하는 문제

문제 1: 의도하지 않은 변환

func(10)이 Array(size_t)라는 완전히 다른 의미의 생성자를 통해 조용히 컴파일되는 것이 이 문제의 핵심입니다 — 호출자는 func에 정수 10을 넘긴다고 생각했겠지만, 실제로는 “크기가 10인 배열을 새로 만들어서” 넘기는 훨씬 무거운 연산이 암묵적으로 일어납니다. 이런 종류의 실수는 컴파일 에러도 경고도 없이 통과되므로, 코드 리뷰에서도 발견하기 어렵고 런타임에 예상보다 느리거나 메모리를 많이 쓰는 이유를 찾다가 뒤늦게 발견되는 경우가 많습니다. explicit를 붙이면 func(10) 자체가 컴파일 에러가 되어, 호출자가 정말 배열을 만들 의도였다면 func(Array(10))처럼 그 의도를 코드에 명시하도록 강제합니다.

class Array {
private:
    int* data;
    size_t size;
    
public:
    // ❌ 암시적 변환 허용
    Array(size_t s) : size(s), data(new int[s]) {}
    
    ~Array() {
        delete[] data;
    }
};
void func(Array arr) {}
int main() {
    func(10);  // 의도하지 않은 변환
}
// ✅ explicit 사용
class Array {
public:
    explicit Array(size_t s) : size(s), data(new int[s]) {}
    // ...
};
// func(10);  // 에러
func(Array(10));  // OK

문제 2: 변환 연산자 남용

n + 10이 컴파일된다는 것 자체가 문제입니다 — Number는 애초에 operator+를 정의하지 않았는데, operator int()가 암시적이기 때문에 컴파일러가 “Number를 int로 바꾸면 +가 성립하겠다”고 스스로 판단해 조용히 변환을 끼워 넣습니다. 이는 Number 타입이 산술 연산에 어떻게 참여해야 하는지에 대해 저자가 전혀 설계하지 않았는데도, 그 설계되지 않은 동작이 우연히 성립해 버리는 상황입니다 — 나중에 Number에 진짜 operator+를 추가하려 하면, 기존에 이 암시적 변환을 통해 성립하던 산술식들과 오버로드 해석이 충돌하거나 미묘하게 다른 동작을 낼 위험까지 있습니다. explicit를 붙이면 n + 10은 애초에 컴파일되지 않으므로, static_cast<int>(n) + 10처럼 변환 의도를 명시한 코드만 성립합니다 — 이렇게 하면 코드를 읽는 사람이 “이 자리에서 Number가 int로 취급된다”는 것을 캐스트 표현에서 바로 알 수 있습니다.

class Number {
private:
    int value;
    
public:
    Number(int v) : value(v) {}
    
    // ❌ 암시적 변환 (혼란 야기)
    operator int() const {
        return value;
    }
};
Number n(42);
int x = n + 10;  // 혼란스러움
// ✅ explicit 사용
class Number {
public:
    explicit operator int() const {
        return value;
    }
};
int x = static_cast<int>(n) + 10;  // 명확

문제 3: 변환 체인

이는 앞서 “사용자 정의 변환 규칙” 절에서 설명한 제약이 실제 컴파일 에러로 나타나는 지점입니다 — B b = 42;를 시도하면 컴파일러는 “int에서 B로 가는 변환이 없다”는 에러를 내는데, 처음 보면 왜 안 되는지 의아할 수 있습니다(A(int)도 있고 B(const A&)도 분명히 있으니까요). 원인은 이 한 줄이 두 번의 사용자 정의 변환을 요구하기 때문이며, 표준은 정확히 이런 “숨겨진 다단계 변환”을 금지해 어떤 타입이 실제로 어떻게 변환되고 있는지 코드만 보고 예측 가능하게 만듭니다. 해결책은 그 두 단계를 코드에 명시적으로 드러내는 것뿐입니다 — A a = 42; B b = a;처럼 나눠 쓰거나, B b = B(A(42));처럼 중첩 생성자 호출로 명시적으로 이어 붙이면 컴파일러는 이제 각 단계가 정확히 한 번의 사용자 정의 변환만 요구한다는 것을 확인할 수 있습니다.

class A {
public:
    A(int x) {}
};
class B {
public:
    B(const A& a) {}
};
// ❌ 2번의 변환 (불가)
// B b = 42;  // int -> A -> B
// ✅ 명시적 변환
A a = 42;
B b = a;
// 또는
B b = B(A(42));

explicit(bool) (C++20)

C++20 이전에는 explicit가 켜져 있거나 꺼져 있거나 둘 중 하나만 가능했지만, explicit(조건식)은 그 켜짐/꺼짐 여부 자체를 컴파일 타임 불리언 표현식으로 결정할 수 있게 해 줍니다. 이 Optional<T> 예시가 보여주는 것은, 템플릿 생성자가 받는 타입 U가 T로 암시적으로 변환 가능한 타입(is_convertible_v<U, T>가 참)이라면 explicit를 끄고 편리한 암시적 변환을 허용하고, 반대로 U가 T로 암시적 변환이 안 되는 타입이라면 explicit를 켜서 명시적 호출을 강제하는 조건부 설계입니다 — !is_convertible_v<U, T>라는 조건식 자체가 “T와 U 사이의 변환이 이미 안전하다고 표준이 판단한 경우에만 explicit를 끈다”는 논리를 표현합니다. 이런 조건부 explicit는 표준 라이브러리의 std::optional, std::variant 같은 제네릭 래퍼 타입이 실제로 채택한 기법이며, 감싸는 타입 T가 무엇이냐에 따라 그 래퍼의 변환 동작이 자동으로 적절하게 조정되도록 만듭니다.

template<typename T>
class Optional {
private:
    T value;
    bool hasValue;
    
public:
    Optional() : hasValue(false) {}
    Optional(const T& v) : value(v), hasValue(true) {}
    
    // 조건부 explicit
    template<typename U>
    explicit(!is_convertible_v<U, T>)
    Optional(const U& v) : value(v), hasValue(true) {}
    
    explicit operator bool() const {
        return hasValue;
    }
};

FAQ

Q1: explicit은 언제 사용?

A:

  • 단일 인자 생성자
  • 변환 연산자
  • 의도하지 않은 변환 방지

Q2: 변환 생성자 vs 변환 연산자?

A:

  • 변환 생성자: 다른 타입 → 내 타입
  • 변환 연산자: 내 타입 → 다른 타입

Q3: C 스타일 vs C++ 스타일 캐스트?

A: C++ 스타일 권장 (명확, 안전).

Q4: 암시적 변환은 나쁜가?

A: 편리하지만 버그 유발 가능. explicit으로 제어.

Q5: 변환 체인은?

A: 최대 1번의 사용자 정의 변환만 허용.

Q6: 타입 변환 학습 리소스는?

A:

  • “Effective C++”
  • cppreference.com
  • “C++ Primer”

같이 보면 좋은 글