C++ typedef vs using: 템플릿 별칭과 함수 포인터 별칭, 별칭은 새 타입이 아니다
이 글의 핵심
typedef와 C++11 using으로 타입 별칭을 만드는 방법을 비교하고, using만 가능한 템플릿 별칭의 활용을 정리합니다. 함수 포인터 별칭의 가독성, 의존적 이름에 typename이 필요한 이유, const와 포인터 별칭이 만나는 함정, 별칭이 새 타입이 아니라서 생기는 실수도 다룹니다.
typedef와 using은 같은 일을 한다
typedef와 C++11의 using(별칭 선언, alias declaration)은 기존 타입에 다른 이름을 붙이는 기능입니다. 템플릿이 아닌 별칭에 대해서는 둘이 만드는 결과가 완전히 같습니다.
typedef unsigned long ulong; // C에서 물려받은 문법
using ulong = unsigned long; // C++11 별칭 선언
별칭이 쓸모 있는 가장 흔한 경우는 중첩된 템플릿 타입입니다. std::map과 std::vector, std::pair가 여러 겹으로 겹치면 타입 이름만으로 한 줄이 차고, 읽는 사람은 로직보다 타입 해독에 시간을 씁니다. 별칭으로 의미 있는 이름을 붙이면 이후 코드는 UserData라는 이름만으로 무엇을 담는지 드러나고, 타입을 바꿔야 할 때도 선언 한 줄만 고치면 됩니다.
std::map<std::string, std::vector<std::pair<int, std::string>>> data; // 읽기 어려움
using UserData = std::map<std::string, std::vector<std::pair<int, std::string>>>;
UserData data2;
두 문법의 차이는 세 가지로 정리됩니다. using은 템플릿 별칭을 만들 수 있고 typedef는 만들 수 없습니다. using은 항상 “이름 = 타입” 순서라 함수 포인터나 배열처럼 선언자 문법이 꼬이는 타입에서 읽기 쉽습니다. 그리고 typedef는 C 코드와 공유하는 헤더에서도 쓸 수 있지만 using은 C++ 전용입니다.
함수 포인터와 배열 별칭
차이가 가장 크게 드러나는 곳은 함수 포인터입니다. typedef는 함수 포인터 선언 문법을 그대로 따라야 하므로 별칭 이름이 괄호 한가운데 묻힙니다. using은 이름이 왼쪽에, 타입이 오른쪽에 오므로 어떤 이름에 어떤 시그니처를 붙였는지 바로 보입니다.
#include <iostream>
typedef int (*OperationT)(int, int); // 이름이 (*...) 안에 있음
using Operation = int (*)(int, int); // 이름 = 타입
int add(int a, int b) { return a + b; }
int main() {
Operation op = add;
std::cout << op(10, 5) << '\n'; // 15
}
배열 타입도 마찬가지입니다. typedef int IntArray[10];에서는 이름이 원소 타입과 크기 사이에 끼어 있지만, using IntArray = int[10];은 “10개짜리 int 배열”이라는 타입이 한 덩어리로 보입니다.
호출 대상이 일반 함수뿐 아니라 람다나 함수 객체까지 넓어진다면 함수 포인터 대신 std::function<int(int, int)>에 별칭을 붙이는 편이 맞습니다. 다만 std::function은 타입 소거 때문에 호출마다 간접 호출이 생기고, 캡처가 큰 람다를 담으면 힙 할당이 일어날 수 있다는 점은 알고 써야 합니다.
템플릿 별칭: using만 할 수 있는 것
typedef는 이미 완성된 하나의 타입에만 이름을 붙일 수 있고, 타입 매개변수를 받는 별칭은 만들 수 없습니다. C++11 이전에는 구조체 안에 typedef를 넣고 typename Vec<T>::type처럼 꺼내 쓰는 우회가 필요했습니다.
// C++03 시절의 우회
template<typename T>
struct VecOld {
typedef std::vector<T> type;
};
typename VecOld<int>::type a; // 매번 ::type과 (템플릿 안에서는) typename이 필요
// template<typename T>
// typedef std::vector<T> Vec; // 컴파일 에러: typedef는 템플릿이 될 수 없음
// C++11 템플릿 별칭
template<typename T>
using Vec = std::vector<T>;
Vec<int> numbers = {1, 2, 3};
표준 라이브러리의 std::enable_if_t, std::remove_reference_t, std::decay_t 같은 _t 별칭도 모두 이 기능으로 만든 것입니다. typename std::remove_reference<T>::type을 std::remove_reference_t<T>로 줄여 쓰는 것이 바로 템플릿 별칭의 효과입니다.
자주 쓰는 조합에 짧은 이름을 붙이는 데도 쓸 수 있습니다.
template<typename T>
using StringMap = std::map<std::string, T>;
StringMap<int> ages;
ages["Alice"] = 30;
다만 template<typename T> using Vector = std::vector<T>;처럼 표준 이름을 한 글자만 바꾼 별칭을 프로젝트 전역에 두면, 새로 합류한 사람에게는 표준 이름보다 오히려 낯섭니다. 별칭은 StringMap처럼 표준 타입에 없는 의미를 더할 때 가치가 큽니다.
템플릿 별칭에는 제약도 있습니다. 별칭 템플릿은 부분 특수화나 명시적 특수화를 할 수 없습니다. 타입에 따라 다른 결과가 필요하면 클래스 템플릿을 특수화하고, 별칭은 그 결과(::type)를 꺼내는 얇은 층으로만 두어야 합니다.
클래스 안의 중첩 타입 별칭
표준 컨테이너는 value_type, iterator, size_type 같은 관련 타입을 클래스 안에 별칭으로 노출합니다. 사용자 정의 컨테이너도 같은 이름을 제공하면 제네릭 코드가 typename C::value_type처럼 같은 방식으로 다룰 수 있습니다.
#include <cstddef>
template<typename T>
class Container {
public:
using value_type = T;
using iterator = T*;
using const_iterator = const T*;
using size_type = std::size_t;
};
Container<int>::value_type x = 10;
의존적 이름에는 typename이 필요하다
템플릿 안에서 템플릿 매개변수에 의존하는 중첩 이름(T::value_type)을 타입으로 쓰려면 앞에 typename을 붙여야 합니다. 컴파일러는 T가 정해지기 전에는 T::value_type이 타입인지 정적 멤버 변수인지 알 수 없기 때문에, 표시가 없으면 값으로 간주합니다. 별칭으로 한 번 꺼내 두면 이후 코드에서는 typename을 반복하지 않아도 됩니다.
template<typename C>
struct Wrapper {
using value_type = typename C::value_type; // 의존적 이름이므로 typename 필요
value_type first; // 이후에는 별칭만 사용
};
C++20부터는 별칭 선언의 오른쪽처럼 타입만 올 수 있는 일부 문맥에서 typename을 생략할 수 있게 완화되었지만, 이전 표준을 지원하는 코드라면 붙여 두는 편이 안전합니다.
함정 1: 별칭은 새 타입이 아니다
using UserId = int;
using OrderId = int;
void cancelOrder(OrderId id);
UserId u = 42;
cancelOrder(u); // 컴파일됨 — UserId와 OrderId는 둘 다 그냥 int
using과 typedef는 기존 타입에 다른 이름을 붙일 뿐이라, 이름을 다르게 지어도 컴파일러에게는 같은 int입니다. 위처럼 사용자 ID를 주문 취소 함수에 넘기는 실수를 타입 시스템이 막지 못하고, 인자 순서가 뒤바뀐 transfer(toId, fromId) 같은 버그도 그대로 통과합니다. 같은 이유로 UserId와 OrderId를 받는 오버로드를 따로 만들려고 하면 같은 함수를 두 번 정의한 것이 되어 컴파일 에러가 납니다. 서로 섞이면 안 되는 값이라면 별칭이 아니라 새 타입을 만들어야 합니다.
struct UserId { int value; };
struct OrderId { int value; };
// 산술 연산이 필요 없는 식별자라면 enum class도 선택지
enum class ProductId : int {};
void cancelOrder(OrderId id);
// cancelOrder(UserId{42}); // 컴파일 에러: UserId를 OrderId로 변환할 수 없음
멤버 하나짜리 구조체는 최적화가 켜지면 대부분의 플랫폼에서 int와 같은 방식으로 전달되므로 런타임 부담은 거의 없습니다. 대신 비교·해시·출력 연산자를 필요한 만큼 직접 정의해야 하므로, 모든 정수를 감싸기보다 섞이면 사고가 나는 ID나 단위(미터와 피트, 밀리초와 초) 정도에 쓰는 것이 현실적입니다.
함정 2: 포인터 별칭에 const를 붙일 때
별칭은 텍스트 치환이 아니라 하나의 타입으로 취급되므로, 포인터 별칭 앞에 const를 붙이면 가리키는 대상이 아니라 포인터 자체가 const가 됩니다.
using IntPtr = int*;
int a = 1, b = 2;
const IntPtr p = &a; // int* const — 포인터가 상수
*p = 10; // OK: 가리키는 int는 수정 가능
// p = &b; // 에러: p 자체는 바꿀 수 없음
const int* q = &a; // 가리키는 int가 상수
const IntPtr을 const int*로 읽기 쉽지만 실제로는 int* const입니다. 가리키는 값이 상수인 포인터가 필요하면 using ConstIntPtr = const int*;처럼 별칭 안에 const를 넣어야 합니다. 이 차이는 typedef에도 똑같이 적용되며, 매크로(#define IntPtr int*)였다면 const int*로 치환된다는 점에서 별칭과 매크로가 다르게 동작하는 대표적인 예입니다.
함정 3: 네임스페이스 별칭은 using이 아니라 namespace
#include <filesystem>
namespace fs = std::filesystem; // 네임스페이스 별칭
// using fs = std::filesystem; // 에러: std::filesystem은 타입이 아님
타입 별칭 문법을 네임스페이스에 쓰면 컴파일되지 않습니다. 네임스페이스 별칭은 .cpp 파일이나 함수 안에 두는 것이 좋습니다. 헤더 전역에 두면 그 헤더를 포함하는 모든 파일에 fs 같은 짧은 이름이 퍼져 다른 코드의 이름과 충돌할 수 있기 때문입니다.
함정 4: 레거시 매크로와의 충돌
오래된 코드베이스에는 #define ulong unsigned long 같은 매크로가 남아 있는 경우가 있습니다. 이 상태에서 using ulong = unsigned long;을 선언하면 전처리기가 먼저 ulong을 치환해 using unsigned long = unsigned long;이 되고, 오류 메시지는 매크로를 전혀 언급하지 않아 원인을 찾기 어렵습니다. 별칭 선언에서 이해할 수 없는 문법 오류가 나면 같은 이름의 매크로가 있는지 먼저 확인하고, 매크로 이름을 바꾸거나 #undef로 정리해야 합니다.
C 스타일 typedef struct
C에서 넘어온 typedef struct Node Node; 패턴은 C에서 struct 키워드를 매번 붙이지 않으려고 쓰는 관용구입니다. C++에서는 struct Node { ... }; 정의만으로 Node라는 이름을 바로 쓸 수 있으므로 이 별칭이 필요 없습니다. C와 C++이 함께 포함하는 헤더라면 C 쪽을 위해 typedef를 남겨 두어야 하며, 이때는 C에 없는 using을 쓸 수 없습니다.
// C와 공유하는 헤더
typedef struct Node {
int data;
struct Node* next;
} Node;
플랫폼별 타입을 한 곳에 모으기
운영체제마다 핸들의 실제 타입이 다를 때, 전처리기 분기와 별칭을 함께 쓰면 차이를 선언 한 곳에 몰아넣을 수 있습니다. 나머지 코드는 SocketHandle이라는 이름만 사용하므로 플랫폼 분기가 로직 곳곳에 흩어지지 않습니다.
#ifdef _WIN32
#include <winsock2.h>
using SocketHandle = SOCKET; // 64비트 Windows에서는 UINT_PTR(부호 없는 64비트)
#else
using SocketHandle = int; // POSIX 파일 디스크립터
#endif
SocketHandle openSocket();
이렇게 감싸도 무효값 표현은 플랫폼마다 다릅니다(Windows는 INVALID_SOCKET, POSIX는 -1). 별칭은 타입 이름만 통일할 뿐 의미까지 통일하지 않으므로, 무효값 상수나 닫기 함수도 같은 위치에 함께 정의해 두어야 추상화가 새지 않습니다.
콜백 시그니처에 이름 붙이기
이벤트 시스템이나 비동기 API처럼 같은 std::function 시그니처가 멤버, 매개변수, 반환 타입에 반복되는 코드에서는 별칭이 시그니처보다 역할을 먼저 보여 줍니다.
#include <functional>
#include <string>
#include <utility>
#include <vector>
using ErrorCode = int;
using AsyncCallback = std::function<void(ErrorCode, const std::string&)>;
class Downloader {
std::vector<AsyncCallback> listeners_;
public:
void onComplete(AsyncCallback cb) { listeners_.push_back(std::move(cb)); }
void finish(ErrorCode ec, const std::string& body) {
for (auto& cb : listeners_) cb(ec, body);
}
};
나중에 콜백에 인자를 하나 추가해야 할 때 별칭 한 줄을 바꾸면 컴파일러가 맞지 않는 호출부를 모두 찾아 줍니다. 여기서 ErrorCode는 함정 1에서 본 것처럼 그냥 int라서 다른 정수와 섞여도 막아 주지 않으므로, 오류 종류를 구분해야 한다면 std::error_code나 enum class를 쓰는 편이 낫습니다.
별칭은 컴파일 타임에만 존재하므로 typedef와 using 사이에 실행 성능이나 바이너리 차이는 없습니다. 성능 차이가 생긴다면 별칭이 가리키는 실제 타입이 다르기 때문입니다.