C++ inline namespace로 API 버전 관리하기: 기본 버전 전환과 ABI 불일치 검출
이 글의 핵심
라이브러리가 자료 구조를 바꾸면 이전 버전으로 빌드된 코드와 섞여 조용히 메모리가 깨질 수 있는데, inline namespace는 버전 이름을 심볼에 넣어 이런 혼용을 링크 에러로 바꿔 줍니다. 표준 라이브러리 구현이 이 기법을 쓰는 방식, 실험적 기능과 플랫폼별 구현을 분리하는 예제, 버전 전환 시 생기는 이름 충돌과 문서화 문제를 짚고 라이브러리 설계 체크리스트로 정리했습니다.
inline namespace란?
inline namespace는 안에 선언된 이름들을 마치 바깥 네임스페이스에 직접 선언된 것처럼 보이게 만드는 C++11 기능입니다. 일반 네임스페이스와 달리 안쪽 이름을 감싸는 껍데기를 하나 더 붙여도, 사용자는 그 껍데기의 존재를 몰라도 바깥 이름만으로 접근할 수 있다는 점이 핵심입니다. 이 성질 덕분에 라이브러리 작성자는 내부적으로 버전이나 구현을 구분해두면서도, 사용자에게는 항상 하나의 안정적인 진입점만 노출할 수 있습니다. 아래 예제에서 MyLib::func()와 MyLib::v2::func()가 완전히 동일하게 동작하는 것이 이 기능의 가장 기본적인 효과입니다.
namespace MyLib {
inline namespace v2 {
void func() {
std::cout << "v2" << std::endl;
}
}
}
// 두 방법 모두 가능
MyLib::func(); // v2 호출
MyLib::v2::func(); // v2 호출
using namespace v2; 지시문과 비슷해 보이지만 결정적인 차이가 있습니다. using 지시문은 이름 검색에서만 안쪽 이름을 보이게 할 뿐 선언의 “소속”은 여전히 v2인 반면, inline namespace의 멤버는 언어 차원에서 바깥 네임스페이스의 멤버로도 취급됩니다. 그래서 바깥 이름으로 템플릿 특수화를 선언할 수 있고(template<> struct MyLib::Traits<int>처럼 v2를 적지 않아도 됨), ADL도 바깥 네임스페이스를 기준으로 동작합니다. C++11 이전에 using namespace로 같은 효과를 흉내 내던 라이브러리들이 특수화 문제로 고생한 것이 이 기능이 표준에 들어온 이유 중 하나입니다.
한 가지 문법 규칙도 알아 둬야 합니다. 네임스페이스는 처음 정의할 때 inline을 붙여야 하고, 이후 다른 헤더에서 다시 열 때는 inline을 생략해도 inline으로 유지됩니다. 반대로 처음에 일반 네임스페이스로 연 뒤 나중에 inline을 붙이면 GCC는 inline namespace must be specified at initial definition 에러를 냅니다. 여러 헤더에 흩어진 라이브러리라면 버전 네임스페이스를 여는 매크로를 하나 정의해 모든 헤더가 같은 방식으로 열게 하는 것이 안전합니다.
버전 관리
inline namespace가 가장 자주 쓰이는 곳이 바로 이 지점입니다. 같은 이름의 클래스나 함수를 버전별로 다른 네임스페이스에 두고, 그중 현재 기본으로 삼고 싶은 버전에만 inline 키워드를 붙이면 됩니다. 아래 예제에서 v1::Widget과 v2::Widget은 데이터 멤버 구성이 다른 별개의 타입이지만, v2가 inline으로 선언되어 있어 MyLib::Widget이라고만 써도 자동으로 v2::Widget을 가리킵니다. 기존 코드가 굳이 옛 버전을 써야 한다면 MyLib::v1::Widget처럼 전체 경로를 명시해 명확히 선택할 수 있어, 하위 호환성과 최신 기본값 제공이라는 두 요구를 동시에 만족시킬 수 있습니다.
namespace MyLib {
namespace v1 {
class Widget {
int data;
};
}
inline namespace v2 {
class Widget {
int data;
std::string name;
};
}
}
// 기본적으로 v2 사용
MyLib::Widget w; // v2::Widget
// 명시적으로 v1 사용
MyLib::v1::Widget w1; // v1::Widget
실전 예시
이론만으로는 감이 잘 오지 않는 개념이므로, 실무에서 inline namespace를 도입하는 네 가지 대표적인 상황을 코드로 살펴보겠습니다. API 버전 관리, ABI 호환성 유지, 실험적 기능 분리, 플랫폼별 구현 전환이 그것입니다.
예시 1: API 버전 관리
라이브러리가 성숙해지면서 함수 시그니처를 바꿔야 하는 상황이 자주 생깁니다. 아래 예제는 v1::process(int)와 v2::process(int, int = 0)를 함께 유지하면서 v2만 inline으로 선언해, 새 코드는 자연스럽게 개선된 시그니처를 기본으로 쓰게 하고 예전 코드는 MyAPI::v1::process로 명시적으로 접근해 기존 동작을 그대로 유지하게 합니다. 이렇게 하면 라이브러리 사용자가 한꺼번에 마이그레이션하지 않아도 새 버전과 구버전이 한 바이너리 안에서 공존할 수 있습니다.
namespace MyAPI {
namespace v1 {
void process(int x) {
std::cout << "v1: " << x << std::endl;
}
}
inline namespace v2 {
void process(int x, int y = 0) {
std::cout << "v2: " << x << ", " << y << std::endl;
}
}
}
int main() {
MyAPI::process(10); // v2
MyAPI::v1::process(10); // v1
}
예시 2: ABI 호환성
전처리기 매크로와 inline namespace를 조합하면 컴파일 시점에 어떤 버전이 기본이 될지 선택할 수 있습니다. 아래 예제는 MYLIB_VERSION 매크로 값에 따라 v1 또는 v2 중 하나만 inline으로 컴파일되도록 만들어, 같은 소스 코드베이스에서 여러 배포 버전의 바이너리를 만들어낼 수 있게 합니다. 이런 패턴은 특히 여러 프로젝트가 서로 다른 시점에 라이브러리를 링크하는 대규모 코드베이스에서, 소스 호환성은 유지하면서 바이너리 레이아웃 변경을 안전하게 관리하는 데 사용됩니다.
이 기법이 ABI 문제를 “링크 에러로 바꿔 준다”는 원리는 이름 맹글링에 있습니다. MyLib::v2::Data를 인자로 받는 함수의 심볼에는 v2가 들어가므로(Itanium ABI 기준 _ZN5MyLib2v24DataE 같은 형태), MYLIB_VERSION=1로 빌드된 오브젝트와 2로 빌드된 라이브러리를 섞으면 심볼 이름이 달라 undefined reference로 멈춥니다. inline namespace가 없었다면 두 Data의 심볼 이름이 같아 링크는 성공하고, 크기가 다른 객체를 서로 다른 레이아웃으로 해석하는 메모리 손상이 런타임에 조용히 일어났을 것입니다. 링크 에러는 불편하지만, 원인을 찾기 어려운 크래시보다 훨씬 싸게 먹히는 실패입니다.
가장 유명한 실제 사례가 GCC 5의 std::__cxx11입니다. C++11이 std::string의 copy-on-write 구현을 금지하면서 libstdc++는 std::string의 레이아웃을 바꿔야 했는데, 기존 바이너리와의 호환을 위해 새 구현을 inline namespace __cxx11 안에 넣고 _GLIBCXX_USE_CXX11_ABI 매크로로 어느 쪽을 쓸지 고르게 했습니다. 그래서 이 매크로 값이 다르게 빌드된 라이브러리를 섞으면 undefined reference to 'foo(std::__cxx11::basic_string<char, ...>)' 같은 에러가 납니다. 이 에러를 처음 보면 당황스럽지만, 원인은 거의 항상 “구 ABI로 빌드된 서드파티 바이너리”이므로 그 라이브러리를 같은 설정으로 다시 빌드하면 해결됩니다. LLVM의 libc++도 std::__1 inline namespace로 같은 목적을 달성합니다.
namespace MyLib {
#if MYLIB_VERSION >= 2
inline namespace v2 {
#else
inline namespace v1 {
#endif
class Data {
// 버전별 구현
};
#if MYLIB_VERSION >= 2
}
#else
}
#endif
}
예시 3: 실험적 기능
새로 추가하는 실험적 기능이 안정 버전 API와 섞이지 않도록 구분하고 싶을 때도 inline namespace가 유용합니다. 아래 예제에서 검증된 기능이 담긴 stable 네임스페이스만 inline으로 선언해 기본 경로로 노출하고, 아직 API가 바뀔 수 있는 experimental 네임스페이스는 명시적으로 경로를 적어야만 접근할 수 있게 합니다. 이렇게 하면 실험적 기능을 실제 프로젝트에 통합해 테스트해볼 수 있으면서도, 사용자가 실수로 불안정한 API에 의존하게 되는 상황을 방지할 수 있습니다.
namespace MyLib {
inline namespace stable {
void reliableFunc() {
std::cout << "안정 버전" << std::endl;
}
}
namespace experimental {
void newFunc() {
std::cout << "실험 버전" << std::endl;
}
}
}
int main() {
MyLib::reliableFunc(); // 기본
MyLib::experimental::newFunc(); // 명시적
}
예시 4: 플랫폼별 구현
크로스 플랫폼 라이브러리를 작성하다 보면 같은 함수라도 Windows와 POSIX 시스템에서 완전히 다른 구현이 필요한 경우가 많습니다. 아래 예제는 #ifdef _WIN32로 컴파일 대상 플랫폼을 구분한 뒤, 해당 플랫폼의 네임스페이스만 inline으로 선언해 호출부 코드는 MyLib::platformFunc()라는 동일한 이름만 알면 되도록 만듭니다. 플랫폼 차이를 네임스페이스 뒤로 숨기는 이 패턴은 조건부 컴파일 매크로가 코드 전반에 흩어지는 것을 막고, 플랫폼별 구현을 한 곳에 모아 유지보수하기 쉽게 해줍니다.
namespace MyLib {
#ifdef _WIN32
inline namespace windows {
void platformFunc() {
std::cout << "Windows" << std::endl;
}
}
#else
inline namespace posix {
void platformFunc() {
std::cout << "POSIX" << std::endl;
}
}
#endif
}
int main() {
MyLib::platformFunc(); // 플랫폼별 자동 선택
}
중첩 inline namespace
inline namespace는 여러 겹으로 중첩할 수도 있습니다. 각 계층이 모두 inline으로 선언되어 있으면, 그 효과가 계단식으로 전파되어 가장 안쪽 이름까지 최상위 네임스페이스에서 직접 접근할 수 있게 됩니다. 아래 예제에서 Outer::func(), Outer::Middle::func(), Outer::Middle::Inner::func()가 모두 같은 함수를 가리키는 이유가 바로 이것입니다. 다만 중첩 단계가 늘어날수록 컴파일러 에러 메시지와 심볼 탐색이 복잡해지므로, 실무에서는 버전이나 실험적 기능처럼 명확한 목적이 있을 때만 한두 단계로 제한해서 쓰는 것이 좋습니다.
namespace Outer {
inline namespace Middle {
inline namespace Inner {
void func() {
std::cout << "Inner" << std::endl;
}
}
}
}
// 모두 가능
Outer::func();
Outer::Middle::func();
Outer::Middle::Inner::func();
자주 발생하는 문제
inline namespace는 개념 자체는 단순하지만, 실제로 적용하다 보면 컴파일러가 잡아주지 않는 미묘한 실수들이 있습니다. 아래 네 가지는 실무에서 가장 흔히 마주치는 함정입니다.
문제 1: 이름 충돌
한 네임스페이스 안에 inline namespace를 여러 개 두는 것 자체는 합법입니다(뒤에서 볼 표준 라이브러리의 std::literals가 그런 예입니다). 문제는 그 안의 이름이 모두 바깥 스코프로 노출되기 때문에, 서로 다른 inline namespace에 같은 이름이 있으면 바깥 이름으로 부를 때 어느 쪽인지 정할 수 없다는 것입니다. 아래 예제처럼 v1과 v2가 둘 다 inline이고 둘 다 func()를 정의하고 있으면, 선언까지는 컴파일되지만 MyLib::func()를 호출하는 순간 call of overloaded 'func()' is ambiguous 에러가 납니다. 버전 관리 용도라면 같은 이름을 가진 버전이 여럿이므로 “한 시점에 하나의 버전만 inline”이라는 규칙을 지켜야 합니다.
namespace MyLib {
inline namespace v1 {
void func() {}
}
inline namespace v2 {
void func() {} // 선언은 가능
}
}
MyLib::func(); // 에러: 모호함 (v1::func vs v2::func)
MyLib::v2::func(); // OK: 경로를 명시하면 문제없음
문제 2: 버전 전환
새 버전을 추가할 때 실수로 이전 버전의 inline 키워드를 지우지 않고 새 버전에도 inline을 붙이면, 앞서 본 이름 충돌 문제가 그대로 재현됩니다. 반대 방향 실수도 있습니다. 이전 버전의 inline만 지우고 새 버전에 붙이는 것을 잊으면, 바깥 이름으로 부르던 모든 코드가 'Widget' is not a member of 'MyLib' 에러를 내며 한꺼번에 깨집니다. 버전을 전환하는 올바른 절차는 항상 “새 버전에 inline을 추가하는 동시에 이전 버전에서는 반드시 제거한다”는 원칙을 지키는 것입니다. 아래 대비되는 두 코드처럼, 한 네임스페이스 아래에는 어떤 시점이든 inline으로 선언된 자식이 정확히 하나만 존재해야 합니다.
// ❌ 여러 inline
namespace MyLib {
inline namespace v1 {}
inline namespace v2 {} // 충돌
}
// ✅ 하나만 inline
namespace MyLib {
namespace v1 {}
inline namespace v2 {}
}
문제 3: ADL 영향
inline namespace 안에 정의된 타입과 그 타입을 인자로 받는 함수는, 인자 의존 탐색(ADL, Argument-Dependent Lookup)에서 마치 바깥 네임스페이스에 있는 것처럼 취급됩니다. 아래 예제에서 Data는 MyLib::v2 안에 정의되어 있지만, func(d)를 호출할 때 using namespace 선언 없이도 ADL이 MyLib::v2::func를 자동으로 찾아냅니다. 이 자체는 편리한 동작이지만, 라이브러리에 연산자 오버로드나 swap 같은 ADL 후보 함수를 설계할 때는 어느 네임스페이스의 무엇이 검색 범위에 들어오는지 명확히 인지하고 있어야 의도치 않은 오버로드 선택 문제를 피할 수 있습니다.
namespace MyLib {
inline namespace v2 {
struct Data {};
void func(Data) {}
}
}
MyLib::Data d;
func(d); // ADL로 MyLib::v2::func 찾음
실무에서 더 자주 부딪히는 문제는 사용자 쪽의 전방 선언입니다. 사용자가 헤더 포함을 줄이려고 namespace MyLib { class Widget; }처럼 전방 선언하면, 이는 MyLib::v2::Widget이 아니라 MyLib 바로 아래의 새로운 Widget을 선언한 것이 됩니다. 이후 실제 헤더를 포함하면 MyLib::Widget이라는 이름이 두 선언을 가리켜 reference to 'Widget' is ambiguous 에러가 나거나, 함수 시그니처에 서로 다른 타입이 섞여 링크 에러가 납니다. 표준이 std 네임스페이스에 사용자가 선언을 추가하는 것을 금지하는 이유 중 하나가 이것입니다. std::string을 namespace std { class string; }처럼 전방 선언하면 libstdc++에서는 실제로 std::__cxx11::basic_string의 별칭이라 전혀 다른 것을 선언하게 됩니다. 라이브러리를 만든다면 이런 문제를 막기 위해 mylib_fwd.hpp처럼 올바른 inline namespace 안에서 전방 선언을 해 주는 헤더를 함께 제공하는 것이 좋습니다.
문제 4: 문서화
inline namespace의 가장 큰 함정은 사람이 코드만 보고는 “지금 기본으로 쓰이는 버전이 무엇인지”를 파악하기 어렵다는 점입니다. 특히 여러 버전이 파일 곳곳에 흩어져 있으면, 어느 것이 inline으로 선언되어 있는지 확인하려고 헤더 전체를 뒤져야 하는 상황이 생깁니다. 아래 예제처럼 각 버전 위에 Doxygen 스타일 주석으로 “최신 안정 버전”인지 “레거시 버전”인지 명시해두면, 코드를 읽는 사람과 IDE의 문서 툴팁 모두가 현재 기본 경로를 즉시 파악할 수 있습니다.
// ✅ 버전 명시
namespace MyLib {
/// @brief 최신 안정 버전
inline namespace v2 {
void func();
}
/// @brief 레거시 버전
namespace v1 {
void func();
}
}
표준 라이브러리 사용
C++ 표준 라이브러리 자체도 inline namespace를 사용자 정의 리터럴(user-defined literal)을 조직화하는 데 활용합니다. 정확한 구조는 std 안에 inline namespace literals가 있고, 그 안에 다시 inline namespace string_literals, inline namespace chrono_literals 등이 들어 있는 형태입니다. 한 부모 아래 inline namespace가 여러 개인 합법적인 예이며, 리터럴 연산자 이름(operator""s)이 서로 다른 인자 타입으로 오버로드되어 있어 충돌하지 않습니다. 이 구조 덕분에 using namespace std::literals;처럼 최상위 네임스페이스만 가져와도 그 안의 모든 리터럴 연산자가 자연스럽게 사용 가능해집니다. 아래 예제의 "hello"s와 5s가 각각 std::string과 std::chrono::seconds로 해석되는 것은, 이 리터럴 연산자들이 inline namespace를 통해 눈에 보이지 않게 최상위로 끌어올려져 있기 때문입니다.
// std::literals
using namespace std::literals;
auto s = "hello"s; // std::string
// std::chrono_literals
using namespace std::chrono_literals;
auto duration = 5s; // 5 seconds
버전 관리 전략 (API 진화)
inline namespace는 “기본 진입점”을 한 번에 옮기되, 구버전 심볼은 이름으로 고정해 두는 데 사용됩니다.
- 신규 메이저:
v2를inline으로 바꾸고,v1은MyLib::v1::접두로만 쓰이게 문서화합니다. - 호환 깨짐이 있는 변경: 타입 레이아웃·함수 시그니처가 바뀌면 새 네임스페이스에 두며, 마이그레이션 가이드를 제공합니다.
- 점진적 폐기(deprecated): 구버전에
[[deprecated]]를 붙이며, 기본 경로는 새 구현으로 연결합니다.
버전 관리 용도에서는 한 부모 아래 동시에 둘 이상의 버전을 inline으로 두지 않습니다. 언어가 금지하는 것은 아니지만, 같은 이름을 가진 버전이 둘 다 노출되면 호출이 모호해지므로 한 시점의 “기본”은 하나만 있어야 합니다.
ABI와의 관계
ABI(Application Binary Interface)는 이름 장식·호출 규약·객체 레이아웃 등이 맞물린 결과입니다.
- 네임스페이스 이름은 링커가 보는 심볼에 영향을 줄 수 있어, 라이브러리 소비자가 어떤
v1/v2타입을 링크했는지가 바이너리 호환과 연결됩니다. - 헤더만 바꾸고 바이너리는 옛날 같은 상황에서,
inline namespace로 기본 심볼이 바뀌면 링크 오류나 ODR 위반이 드러나기도 합니다. 배포 시 헤더·바이너리 세트를 함께 관리해야 합니다. - 플랫폼별
inline namespace: Windows vs POSIX 구현을 갈라 넣으면, 같은 API 이름으로 소스 호환을 유지하면서 플랫폼 ABI 차이를 구현 쪽에 가둘 수 있습니다.
실전 라이브러리 설계 체크리스트
- 공개 최상위 네임스페이스 하나 아래에
inline namespace detail보다는 inline namespace vN처럼 의미 있는 버전 태그를 권장합니다(detail은 보통 inline이 아님). - ADL:
inline namespace안의 타입은 바깥 이름으로도 보이므로, 연산자·swap 등 ADL 후보가 의도대로 찾아지는지 확인합니다. - 문서: “
MyLib::Widget은 현재v2::Widget이다”를 릴리스 노트에 명시합니다. - 테스트:
v1고정 경로와 기본 경로 둘 다 빌드 테스트합니다.
C++17 inline 변수와의 구분
- inline namespace: 이름 공간을 겹쳐 보이게 하는 기능(버전·플랫폼별 별칭).
inline변수(C++17): 여러 번역 단위에 동일 정의를 헤더에 둘 수 있게 하는 ODR 규칙.inline constexpr과 함께 헤더 상수·std::atomic전역 등에 사용됩니다.
둘 다 “inline”이지만 해결하는 문제가 다릅니다. 네임스페이스 설계와 변수 ODR은 문맥에 맞게 따로 선택합니다.
FAQ
Q1: 한 네임스페이스 안에 inline namespace를 여러 개 둘 수 있나요?
A: 문법상 가능합니다. std::literals 안의 string_literals, chrono_literals가 모두 inline인 것이 표준의 예입니다. 다만 같은 이름이 여러 inline namespace에 있으면 바깥 이름으로 부를 때 모호해지므로, 버전 관리처럼 같은 이름을 버전별로 두는 용도에서는 한 시점에 하나만 inline으로 둡니다.
Q2: 런타임 성능에 영향이 있나요?
A: 없습니다. 이름 검색과 심볼 이름만 바뀌는 컴파일 타임 기능이라 생성되는 코드는 같습니다. 심볼 이름이 조금 길어지는 것이 유일한 차이입니다.
Q3: 기본 버전을 v1에서 v2로 바꾸면 기존 바이너리는 어떻게 되나요?
A: 소스는 그대로 두고 다시 빌드하면 v2를 쓰게 됩니다. 다시 빌드하지 않은 기존 바이너리는 v1 심볼을 참조하므로, 라이브러리가 v1 구현도 계속 제공하는 한 그대로 동작합니다. v1 구현을 라이브러리에서 제거하면 기존 바이너리는 실행 시점이나 링크 시점에 심볼을 찾지 못해 실패하므로, 구버전 제거는 메이저 버전 변경과 함께 공지해야 합니다.
같이 보면 좋은 글
- C++ namespace 심화
- C++ constexpr 함수: 컴파일 타임 평가 조건과 C++11·14·17 제약 변화
- C++ 사용자 정의 리터럴: 리터럴 연산자 문법, cooked·raw 오버로드, 접미사 규칙
- C++ auto 타입 추론 | 복잡한 타입을 컴파일러에 맡기기