C++ 사용자 정의 리터럴: 리터럴 연산자 문법, cooked·raw 오버로드, 접미사 규칙
이 글의 핵심
operator""로 10_km 같은 사용자 정의 리터럴을 만드는 문법과 언더스코어 접미사 규칙을 정리합니다. cooked·raw 오버로드가 선택되는 기준, chrono 리터럴이 동작하는 원리, 네임스페이스 간 접미사 충돌 같은 함정도 다룹니다.
사용자 정의 리터럴이란
C++11 이전에는 “5000미터”라는 값을 코드에 담으려면 5000이라는 벌거벗은 숫자를 쓰거나, toMeters(5)처럼 함수를 호출하거나, #define KM(x) ((x) * 1000) 같은 매크로에 의존해야 했습니다. 세 방법 모두 단위 정보가 타입 시스템에 남지 않는다는 공통된 약점이 있습니다. 숫자 리터럴은 그냥 숫자이고, 매크로는 전처리 단계에서 텍스트 치환만 할 뿐 타입 검사도, 네임스페이스 보호도 받지 않습니다. 사용자 정의 리터럴(User-Defined Literals, 이하 UDL)은 operator"" _suffix(...) 형태의 함수를 정의해 5_km, 1.5_pi, "hello"_upper처럼 리터럴에 접미사를 붙이는 즉시 지정한 함수가 호출되도록 만드는 기능입니다. 컴파일러 입장에서 5_km은 magic number가 아니라 operator"" _km(5ULL)라는 명시적 함수 호출로 해석되므로, 인자 타입 검사·오버로드 해석·constexpr 평가가 모두 정상적으로 적용됩니다.
이 기능이 실무에서 가장 크게 체감되는 지점은 단위 실수를 컴파일 타임에 잡아준다는 점입니다. 예를 들어 함수 시그니처가 void sleepFor(std::chrono::milliseconds)라면, sleepFor(5)처럼 정수를 직접 넘기는 코드는 (암시적 변환이 막혀 있다면) 아예 컴파일되지 않고, sleepFor(5ms)처럼 UDL을 쓰면 어떤 시간 단위인지 코드만 보고도 명확합니다. 표준 라이브러리는 이 아이디어를 <chrono>(5s, 100ms), <string>("hello"s), <complex>(2.0i)에 그대로 적용했고, Boost.Units처럼 물리 단위를 다루는 서드파티 라이브러리도 동일한 패턴을 사용합니다. 아래에서는 기본 문법부터 시작해, 실무에서 자주 걸려 넘어지는 접두사 규칙·오버로드 선택 기준·네임스페이스 충돌 문제까지 순서대로 다룹니다.
기본 문법
리터럴 연산자는 operator"" _접미사(매개변수) 형태로 선언합니다. 매개변수 타입은 아무거나 쓸 수 있는 게 아니라, 리터럴의 종류(정수/실수/문자/문자열/raw)마다 표준이 정해둔 정해진 시그니처 중 하나여야 합니다. 아래 예제의 _km, _pi는 각각 정수·실수 리터럴에 대응하는 “cooked”(가공된) 형태의 오버로드이고, _upper는 문자열 리터럴을 (const char*, size_t) 쌍으로 받는 형태입니다. 컴파일러는 소스 코드에서 5_km을 만나면 이것이 정수 리터럴 5와 접미사 _km의 결합이라는 것을 파싱 단계에서 인식하고, 현재 스코프에서 이름 조회(lookup)로 찾을 수 있는 operator"" _km 중 인자 타입이 맞는 오버로드를 호출합니다.
// 정수 리터럴
constexpr long long operator"" _km(unsigned long long km) {
return km * 1000;
}
// 실수 리터럴
constexpr long double operator"" _pi(long double x) {
return x * 3.14159265359;
}
// 문자열 리터럴
string operator"" _upper(const char* str, size_t len) {
string result(str, len);
for (char& c : result) {
c = toupper(c);
}
return result;
}
int main() {
auto distance = 5_km; // 5000
auto angle = 2.0_pi; // 6.28...
auto text = "hello"_upper; // "HELLO"
cout << distance << endl;
cout << angle << endl;
cout << text << endl;
}
여기서 눈여겨볼 부분은 _km과 _pi가 서로 다른 매개변수 타입을 받는다는 점입니다. _km은 unsigned long long을 받아 정수 리터럴(5_km)에만 반응하고, _pi는 long double을 받아 실수 리터럴(2.0_pi)에만 반응합니다. 5_pi처럼 정수 리터럴에 _pi를 붙이면 컴파일 에러가 나는데, 이는 버그가 아니라 표준이 강제하는 동작입니다. 정수 리터럴 연산자는 매개변수로 unsigned long long, long double, char, 혹은 raw 형태인 const char* 중 하나만 가질 수 있고, 이 중 어떤 시그니처가 호출될지는 리터럴이 소스 코드에 정수로 적혔는지 실수로 적혔는지에 따라 컴파일 타임에 결정됩니다. int나 long 같은 부호 있는 타입, double 같은 좁은 실수 타입은 허용되지 않습니다 — 항상 가장 넓은 타입(unsigned long long, long double)으로 받은 뒤 함수 내부에서 원하는 타입으로 좁혀야 합니다.
표준 리터럴
C++14는 std::chrono_literals, std::string_literals, std::complex_literals라는 세 개의 인라인 네임스페이스에 표준 UDL을 미리 정의해 두었습니다. 이 리터럴들은 마법이 아니라 지금까지 설명한 것과 똑같은 operator"" _suffix 함수로 구현되어 있습니다. 예를 들어 5s는 내부적으로 constexpr chrono::seconds operator""s(unsigned long long s)를, 1.5s는 constexpr chrono::duration<double> operator""s(long double s)를 호출합니다. 정수 리터럴과 실수 리터럴이 서로 다른 오버로드로 갈라지기 때문에 1.5s는 정수 초가 아니라 부동소수점 기반의 duration<double>로 표현되고, 이 값을 chrono::seconds처럼 정수 표현 타입으로 대입하려 하면 duration_cast 없이는 컴파일 에러가 납니다. 실전에서는 이 지점에서 자주 막힙니다: chrono::milliseconds timeout = 1.5s;는 정밀도 손실 가능성 때문에 암시적 변환이 거부되고, chrono::duration_cast<chrono::milliseconds>(1.5s)로 명시적으로 변환해야 합니다.
여기서 중요한 오해 하나를 짚고 넘어가야 합니다. 5s(chrono의 초)와 "hello"s(std::string)는 같은 접미사 s를 쓰지만 절대 충돌하지 않습니다. 컴파일러가 오버로드를 고르는 기준은 접미사 이름뿐 아니라 리터럴의 형태이기 때문입니다. 5s는 정수 리터럴이므로 unsigned long long을 받는 오버로드만 후보가 되고, "hello"s는 문자열 리터럴이므로 (const char*, size_t)를 받는 오버로드만 후보가 됩니다. 두 후보군이 애초에 겹치지 않으므로 using namespace std::chrono_literals;와 using namespace std::string_literals;를 동시에 열어 둬도 안전합니다. 반대로 같은 리터럴 형태(예: 둘 다 정수 리터럴을 받는 _s)를 서로 다른 라이브러리가 정의해 동시에 스코프에 들어오면, 그때는 진짜 모호한 호출(ambiguous call) 컴파일 에러가 발생합니다.
#include <chrono>
#include <string>
#include <complex>
using namespace std::chrono_literals;
using namespace std::string_literals;
using namespace std::complex_literals;
int main() {
// 시간
auto duration = 5s; // 5초
auto ms = 100ms; // 100밀리초
auto min = 2min; // 2분
// 문자열
auto str = "hello"s; // std::string
// 복소수
auto c = 1.0 + 2.0i; // complex<double>
}
이 코드가 동작하려면 반드시 using namespace std::chrono_literals;처럼 해당 네임스페이스를 명시적으로 끌어와야 합니다. 흔한 오해 중 하나가 “인자 타입을 보고 ADL(Argument-Dependent Lookup)이 알아서 찾아주지 않을까”인데, 리터럴 연산자는 ADL의 적용 대상이 아닙니다. ADL은 함수 인자의 타입이 속한 네임스페이스를 추가로 뒤져주는 규칙인데, UDL의 인자는 unsigned long long, long double, const char* 같은 기본 제공(built-in) 타입이라 애초에 연관된 사용자 네임스페이스가 없습니다. 따라서 리터럴 연산자는 일반적인 비한정 이름 조회(unqualified lookup)로만 찾아지고, 그 이름을 스코프에 들여오는 것은 전적으로 using 선언·지시자 또는 완전한 네임스페이스 한정(std::chrono_literals::operator""s(...), 실제로는 거의 쓰이지 않는 문법이지만)의 몫입니다. using namespace를 빼먹으면 “identifier not found” 류의 에러가 나므로, 커스텀 UDL 헤더를 include만 하고 using namespace를 잊어서 컴파일이 깨지는 실수는 초보자뿐 아니라 헤더를 재구성하다 보면 숙련자도 종종 저지릅니다.
실전 예시
예시 1: 단위 시스템
class Distance {
private:
double meters;
public:
constexpr Distance(double m) : meters(m) {}
constexpr double toMeters() const { return meters; }
constexpr double toKm() const { return meters / 1000; }
constexpr double toMiles() const { return meters / 1609.34; }
friend ostream& operator<<(ostream& os, const Distance& d) {
return os << d.meters << "m";
}
};
constexpr Distance operator"" _m(long double m) {
return Distance(m);
}
constexpr Distance operator"" _km(long double km) {
return Distance(km * 1000);
}
constexpr Distance operator"" _mi(long double mi) {
return Distance(mi * 1609.34);
}
int main() {
auto d1 = 100_m;
auto d2 = 1.5_km;
auto d3 = 1_mi;
cout << d1 << endl; // 100m
cout << d2 << endl; // 1500m
cout << d3 << endl; // 1609.34m
}
이 예제가 보여주는 핵심은 “강한 타입(strong type)을 리터럴로 만든다”는 패턴입니다. _m, _km, _mi가 각각 double이나 long double을 반환하는 게 아니라 모두 같은 Distance 타입을 반환하도록 통일했기 때문에, 100_m + 1.5_km처럼 서로 다른 단위 리터럴끼리 연산해도 Distance가 내부적으로 항상 미터로 정규화해 보관하므로 값이 섞일 위험이 없습니다. 반대로 만약 _m은 double을, _km은 별도의 KmType을 반환하도록 설계했다면 100_m + 1.5_km은 컴파일조차 안 되거나(타입 불일치) 암시적 변환이 뒤섞여 단위 실수를 오히려 감추게 됩니다. 실무에서 단위 리터럴 라이브러리를 설계할 때는 “리터럴 접미사가 몇 개든, 최종적으로 도달하는 타입은 하나로 수렴시킨다”는 원칙을 지키는 것이 유지보수 관점에서 훨씬 안전합니다.
예시 2: 바이트 크기
버퍼·디스크 용량처럼 2의 거듭제곱 단위를 다룰 때 10_MB, 500_GB라고 쓰면 10 * 1024 * 1024를 직접 계산해서 넣는 것보다 오타 위험이 훨씬 줄어듭니다. 다만 이 패턴에는 실전에서 자주 잊는 함정이 하나 있습니다. _GB 이상으로 올라가서 _TB, _PB까지 정의하면 unsigned long long(보통 64비트) 곱셈이 오버플로우할 수 있다는 점입니다. 예를 들어 18000000_PB처럼 비현실적으로 큰 값을 실수로 넣으면 kb * 1024 * 1024 * 1024 * 1024 * 1024가 조용히 값을 감싸고 돌아(wrap-around) 완전히 엉뚱한 작은 숫자가 나올 수 있는데, constexpr로 선언해 두면 이런 경우 대부분의 컴파일러가 상수 표현식 오버플로우를 컴파일 에러로 잡아 줍니다. 즉 여기서 constexpr는 단순히 “빠르게 하려는” 최적화가 아니라, 오버플로우를 런타임 버그가 아니라 컴파일 에러로 조기에 드러내는 안전장치 역할을 겸합니다.
constexpr size_t operator"" _KB(unsigned long long kb) {
return kb * 1024;
}
constexpr size_t operator"" _MB(unsigned long long mb) {
return mb * 1024 * 1024;
}
constexpr size_t operator"" _GB(unsigned long long gb) {
return gb * 1024 * 1024 * 1024;
}
int main() {
size_t bufferSize = 10_MB;
size_t diskSize = 500_GB;
cout << bufferSize << " bytes" << endl;
cout << diskSize << " bytes" << endl;
}
예시 3: 각도 변환
_deg와 _rad도 예시 1의 Distance와 같은 원칙을 따라 둘 다 Angle이라는 하나의 타입으로 수렴합니다. 여기서 실전에 옮길 때 주의할 부분은 M_PI입니다. M_PI는 표준 C++가 아니라 POSIX/구현체별 확장 매크로라서, MSVC에서는 <cmath>를 include하기 전에 #define _USE_MATH_DEFINES를 선언하지 않으면 M_PI가 정의되지 않아 컴파일 에러가 납니다. 이식성을 확실히 하려면 C++20부터 표준에 들어온 std::numbers::pi(<numbers> 헤더)를 쓰거나, 직접 constexpr double PI = 3.14159265358979323846; 상수를 라이브러리 안에 선언해 두는 편이 안전합니다. 각도 변환처럼 부동소수점 오차가 누적되기 쉬운 계산에서는 리터럴 하나 잘못 정의해 두면 그 오차가 호출부 전체에 퍼지므로, 단위 리터럴을 정의하는 시점에 상수의 출처를 명확히 하는 습관이 중요합니다.
#include <cmath>
class Angle {
private:
double radians;
public:
constexpr Angle(double rad) : radians(rad) {}
constexpr double toRadians() const { return radians; }
constexpr double toDegrees() const { return radians * 180 / M_PI; }
friend ostream& operator<<(ostream& os, const Angle& a) {
return os << a.toDegrees() << "°";
}
};
constexpr Angle operator"" _deg(long double deg) {
return Angle(deg * M_PI / 180);
}
constexpr Angle operator"" _rad(long double rad) {
return Angle(rad);
}
int main() {
auto a1 = 90_deg;
auto a2 = 1.57_rad;
cout << a1 << endl; // 90°
cout << a2 << endl; // ~90°
}
예시 4: 색상 리터럴
struct Color {
unsigned char r, g, b;
Color(unsigned char r, unsigned char g, unsigned char b)
: r(r), g(g), b(b) {}
};
Color operator"" _rgb(const char* str, size_t len) {
// "#FF0000" → Color(255, 0, 0)
if (len != 7 || str[0] != '#') {
throw invalid_argument("잘못된 색상 형식");
}
auto hexToDec = [](char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return 0;
};
unsigned char r = hexToDec(str[1]) * 16 + hexToDec(str[2]);
unsigned char g = hexToDec(str[3]) * 16 + hexToDec(str[4]);
unsigned char b = hexToDec(str[5]) * 16 + hexToDec(str[6]);
return Color(r, g, b);
}
int main() {
auto red = "#FF0000"_rgb;
auto green = "#00FF00"_rgb;
auto blue = "#0000FF"_rgb;
cout << "R: " << (int)red.r << endl; // 255
}
_rgb는 지금까지의 예제와 달리 constexpr가 붙어 있지 않습니다. 이 함수는 길이 검사에 실패하면 std::invalid_argument를 던지는데, 예외를 던지는 분기가 실제로 실행될 수도 있는 함수는 상수 표현식 문맥에서 항상 평가 가능하다고 보장할 수 없기 때문에 여기서는 아예 constexpr를 붙이지 않는 편이 정직한 설계입니다(C++14부터는 constexpr 함수 안에 throw 문 자체는 허용되지만, 그 분기가 실제로 상수 평가 중에 타면 컴파일 에러가 됩니다). 즉 “이 UDL이 컴파일 타임에도 쓰일 수 있는가”와 “런타임 검증이 필요한가”는 서로 상충하는 요구인 경우가 많고, 실패 가능성이 있는 파싱 로직(색상 코드, 날짜 문자열 등)을 다루는 UDL은 차라리 런타임 전용으로 설계하고 별도로 constexpr가 필요한 상수는 컴파일 타임에 형식이 고정된 좁은 문법(예: 항상 #RRGGBB 6자리)만 지원하는 raw 템플릿 오버로드로 분리하는 것이 현실적인 절충안입니다.
Raw 리터럴
지금까지 본 정수·실수 UDL은 모두 “cooked”(가공된) 형태였습니다. 즉 컴파일러가 123이라는 소스 텍스트를 이미 unsigned long long 값으로 해석한 뒤에 그 값을 UDL 함수로 넘겨줍니다. 그런데 정수·실수 리터럴에는 이것 말고 두 가지 형태가 더 있습니다. 하나는 널 종단 문자열로 원본 숫자 텍스트를 그대로 넘겨받는 operator"" _suffix(const char*)(레거시 raw 형태)이고, 다른 하나는 각 문자를 비타입 템플릿 매개변수 팩으로 받는 template<char... chars> operator"" _suffix()(템플릿 raw 형태)입니다. 컴파일러가 오버로드를 고르는 우선순위는 “cooked 형태가 있으면 무조건 cooked 형태를 먼저 쓴다”이고, cooked 오버로드가 하나도 없을 때만 raw 형태(문자열 함수 → 템플릿 순)로 넘어갑니다. 이 규칙이 중요한 이유는, raw 형태는 unsigned long long(약 1.8 × 10^19)보다 훨씬 큰 정수 리터럴이나, 부동소수점으로 표현하면 정밀도가 깨지는 리터럴을 원본 문자 그대로 받아서 직접 파싱할 수 있게 해주기 때문입니다. 임의 정밀도 정수(bignum) 리터럴이나, 64비트를 넘어가는 이진 리터럴을 지원하는 라이브러리는 거의 예외 없이 이 템플릿 raw 형태를 사용합니다.
아래 흐름도는 컴파일러가 정수·실수 리터럴 접미사를 만났을 때 어떤 순서로 오버로드를 찾는지를 정리한 것입니다. cooked 오버로드가 있으면 그 자리에서 바로 확정되고, 없을 때만 raw 형태로 넘어간다는 우선순위를 시각적으로 보면 왜 cooked와 raw를 같은 접미사에 동시에 정의해도 충돌 없이 공존할 수 있는지가 명확해집니다.
flowchart TD
A["정수/실수 리터럴 + 접미사<br/>예: 1010_bin"] --> B{"cooked 오버로드 존재?<br/>unsigned long long / long double"}
B -- "있음" --> C["cooked 오버로드 호출<br/>이미 해석된 숫자값 전달"]
B -- "없음" --> D{"raw 문자열 오버로드 존재?<br/>(const char*)"}
D -- "있음" --> E["원본 숫자 텍스트를<br/>널 종단 문자열로 전달"]
D -- "없음" --> F{"raw 템플릿 오버로드 존재?<br/>template<char...>"}
F -- "있음" --> G["각 문자를 비타입<br/>템플릿 인자 팩으로 전달"]
F -- "없음" --> H["컴파일 에러:<br/>no matching literal operator"]
다만 raw 리터럴 템플릿은 정수·부동소수점 리터럴에만 적용됩니다. 문자열 리터럴이나 문자 리터럴에는 raw 템플릿 형태가 아예 존재하지 않고, 항상 (const char*, size_t) 또는 (char) cooked 형태만 씁니다. 아래 _bin 예제처럼 template<char...> operator"" _bin()을 선언해 두면 1010_bin이라는 리터럴을 만났을 때 컴파일러는 operator"" _bin<'1','0','1','0'>()을 호출하며, 함수 본문에서는 chars... 팩을 재귀나 fold-expression으로 순회하며 직접 이진수 값을 계산해야 합니다(실제 구현에서는 ((chars - '0') << ...) 형태로 각 자리를 시프트하며 누적하는 constexpr 함수를 재귀 호출하는 방식이 일반적입니다). 이 예제의 return 0;은 실제 파싱 로직을 생략한 자리표시자이므로, 프로덕션 코드에서 그대로 가져다 쓰면 안 되고 반드시 자릿수별 파싱 로직을 채워 넣어야 합니다.
// 문자 리터럴
char operator"" _c(char c) {
return c;
}
// Raw 리터럴 (템플릿)
template<char... chars>
int operator"" _bin() {
// 이진수 파싱
return 0; // 구현 생략
}
int main() {
auto c = 'A'_c;
// auto b = 1010_bin; // 복잡한 구현 필요
}
자주 발생하는 문제
문제 1: 접미사 충돌
표준은 밑줄로 시작하지 않는 리터럴 접미사를 “미래의 표준화를 위해 예약된 이름”으로 규정합니다. 즉 operator"" s처럼 언더스코어 없는 이름을 직접 정의하면, 컴파일러가 즉시 에러를 내거나(대부분의 최신 GCC/Clang/MSVC는 에러로 처리) 최소한 강한 경고를 냅니다. 재미있는 역사적 배경이 하나 있는데, C++11 표준화 초기에는 s 접미사가 예약되어 있지 않아서 실제로 이걸 직접 정의해 쓰던 코드베이스들이 있었습니다. 그런데 C++14에서 std::string_literals::operator""s가 표준에 추가되면서 s가 표준 전용 예약 접미사로 승격되었고, 예전에 자체적으로 operator"" s를 정의해 쓰던 프로젝트들은 C++14로 컴파일러를 올리는 순간 “예약된 리터럴 접미사를 재정의했다”는 에러를 마주하게 됐습니다. 저도 오래된 C++11 코드베이스를 C++14/17로 마이그레이션하던 중에 정확히 이 문제를 겪은 적이 있는데, 원인을 몰랐을 때는 “어제까지 되던 코드가 왜 갑자기 안 되지”로 시작해서 컴파일러 버전 차이인 줄 알고 한참 헤맸습니다. 결국 원인은 표준 개정으로 예약어 목록 자체가 넓어진 것이었고, 교훈은 명확합니다 — 언더스코어 없는 접미사는 지금 당장 컴파일이 되더라도 다음 표준 개정에서 언제든 표준 라이브러리에 “선점”당할 수 있으므로, 처음부터 밑줄 접두사를 붙이는 습관을 들여야 합니다.
// ❌ 표준 접미사와 충돌
constexpr int operator"" s(unsigned long long x) { // 에러
return x;
}
// ✅ 언더스코어로 시작
constexpr int operator"" _s(unsigned long long x) {
return x;
}
문제 2: 타입 불일치
리터럴 연산자는 오버로드가 아니라 “이 리터럴 형태에는 이 시그니처만 허용한다”는 고정된 규칙에 가깝습니다. 10처럼 소수점이 없는 숫자는 항상 정수 리터럴로 파싱되므로 unsigned long long을 받는 오버로드를 찾고, 그 시그니처가 없으면 다른 시그니처(long double)가 있어도 대신 써주지 않고 그냥 컴파일 에러를 냅니다. 암시적으로 정수를 실수로 승격해서 맞는 오버로드를 찾아주는 일반 함수 오버로드 해석과는 다르게 동작한다는 점이 핵심입니다. 실무에서는 “실수만 받는 UDL을 정의해 놓고 정수 리터럴로 호출했다가 원인 모를 컴파일 에러를 만나는” 패턴으로 자주 나타나므로, 에러 메시지에 “no matching literal operator” 같은 문구가 보이면 가장 먼저 리터럴을 정수로 썼는지 실수로 썼는지부터 확인하는 것이 빠릅니다.
// ❌ 타입 불일치
constexpr int operator"" _x(long double x) { // long double
return x;
}
auto a = 10_x; // 에러: 정수 리터럴인데 long double 매개변수
// ✅ 올바른 타입
constexpr int operator"" _x(unsigned long long x) {
return x;
}
문제 3: constexpr 누락
constexpr가 빠진 리터럴 연산자는 여전히 정상적으로 동작하는 함수이지만, 그 결과를 constexpr 변수나 템플릿 비타입 매개변수, static_assert처럼 컴파일 타임 평가가 강제되는 문맥에서는 쓸 수 없습니다. 배열 크기(int arr[10_x];)나 switch의 case 레이블처럼 정수 상수 표현식이 요구되는 자리에 UDL 결과를 넣고 싶다면 constexpr는 선택이 아니라 필수입니다. 반대로 말하면, 단위 변환처럼 순수 계산만 하고 예외나 동적 메모리 할당이 없는 UDL은 웬만하면 처음부터 constexpr를 붙여 두는 것이 비용 없는 개선입니다 — 런타임에서 호출되면 그냥 일반 함수처럼 실행되고, 컴파일 타임 문맥에서 호출되면 추가 이득을 얻으므로 손해 볼 일이 없습니다.
// ❌ 컴파일 타임 계산 불가
int operator"" _x(unsigned long long x) {
return x * 2;
}
constexpr int a = 10_x; // 에러
// ✅ constexpr 추가
constexpr int operator"" _x(unsigned long long x) {
return x * 2;
}
문제 4: 네임스페이스 충돌과 짧은 접미사
리터럴 연산자는 일반 함수와 마찬가지로 스코프 규칙을 따르기 때문에, 서로 다른 헤더에서 using namespace로 끌어온 두 라이브러리가 같은 형태(둘 다 정수 리터럴, 혹은 둘 다 문자열 리터럴)의 리터럴을 같은 이름으로 정의해 두면 진짜로 “call to operator"" _x is ambiguous” 컴파일 에러가 발생합니다. 문제는 이 충돌이 그 리터럴을 정의한 헤더 두 개를 직접 include할 때만 일어나는 게 아니라, 유틸리티 헤더 체인을 타고 간접적으로 두 using namespace 지시자가 동시에 보이게 되는 순간에도 똑같이 일어난다는 점입니다. 저는 사내 코드베이스에서 물리 단위 라이브러리가 밀리초를 뜻하는 _ms 접미사를 쓰고 있었는데, 나중에 합류한 로깅 유틸리티 헤더가 using namespace std::chrono_literals;를 전역으로 걸어 두는 바람에 표준 ms(밀리초, 언더스코어 없는 예약 접미사)와 사내 _ms는 이름 자체는 달라 직접 충돌하지 않았지만, 팀원이 실수로 사내 접미사를 언더스코어 없이 ms로 리네이밍하는 리팩터링 스크립트를 돌렸다가 표준 예약 접미사와 정면으로 충돌해 빌드 전체가 깨진 적이 있습니다. 그 사고 이후로 팀에서는 “짧은 접미사·언더스코어 없는 접미사는 절대 금지, 접미사에는 항상 라이브러리 고유 프리픽스를 붙인다”(_pk_ms처럼)는 규칙을 코드 리뷰 체크리스트에 명시적으로 추가했습니다. _m이 미터인지 밀리(milli)의 약자인지, _s가 초인지 문자열인지처럼 짧은 접미사는 협업 인원이 늘어날수록 반드시 어딘가에서 충돌하거나 최소한 읽는 사람을 헷갈리게 만듭니다.
리터럴 연산자 오버로드
아래는 하나의 접미사 _suffix에 대해 표준이 허용하는 다섯 가지 시그니처를 한곳에 모은 것입니다. 실제로 다섯 개를 전부 동시에 정의하는 경우는 드물지만, 이 표를 기준으로 삼으면 “왜 내가 원하는 타입을 매개변수로 못 쓰는지”에 대한 답이 바로 나옵니다 — 표준은 정수·실수 리터럴에 대해 딱 이 시그니처들만 허용하고, 그 외의 타입(int, float, signed char 등)은 매개변수로 올 수 없습니다. 문자열과 문자 리터럴은 각각 하나의 cooked 시그니처만 존재하고 raw 형태가 없으며, raw 템플릿 형태는 정수·실수 리터럴 전용입니다. 여러 시그니처를 같은 접미사 이름으로 동시에 정의해 두면, 컴파일러는 실제 소스에 적힌 리터럴의 형태를 보고 그 중 하나만 골라 호출합니다 — 이것은 사용자가 명시적으로 오버로드를 선택하는 것이 아니라, 리터럴의 어휘적 형태(정수인지 실수인지 문자열인지)에 의해 완전히 결정되는 정적 디스패치입니다.
// 정수
constexpr T operator"" _suffix(unsigned long long);
// 실수
constexpr T operator"" _suffix(long double);
// 문자
constexpr T operator"" _suffix(char);
// 문자열
T operator"" _suffix(const char*, size_t);
// Raw 리터럴
template<char...> T operator"" _suffix();
FAQ
Q1: 사용자 정의 리터럴은 언제 사용하나요?
A:
- 단위 표현 (km, MB, 초)
- DSL 구현
- 타입 안전 상수
Q2: 성능 오버헤드는?
A: constexpr이면 컴파일 타임에 처리되어 오버헤드가 없습니다.
Q3: 표준 리터럴은?
A:
s: std::stringh,min,s,ms: chronoi,if,il: complex
Q4: 접미사 규칙은?
A: 언더스코어로 시작해야 합니다 (표준 예약).
Q5: 리터럴 연산자는 어디에 정의하나요?
A: 네임스페이스에 정의하고 using namespace로 가져옵니다.
Q6: 사용자 정의 리터럴 학습 리소스는?
A:
- cppreference.com
- “Effective Modern C++”
- “C++11/14/17 Features”
같이 보면 좋은 글
- time() 대신 std::chrono: duration·time_point, 클럭 선택, duration_cast, C++20 달력 — 이 글에서 다룬
5s,100ms표준 chrono 리터럴의 내부 구조와duration타입을 자세히 다룹니다. - C++ std::tuple: 여러 값 반환, std::tie와 구조적 바인딩, 구조체와 비교
- C++ ADL (Argument-Dependent Lookup) — 리터럴 연산자가 왜 ADL의 적용을 받지 않는지 이해하려면 ADL 자체의 동작 원리를 먼저 아는 것이 도움이 됩니다.
- C++ auto와 decltype: 타입 추론 규칙, decltype(auto), AAA 스타일