C++20 삼원 비교 연산자 <=>: strong·weak·partial_ordering 고르는 기준과 = default

이 글의 핵심

C++20 삼중 비교 연산자(<=>, spaceship)가 반환하는 strong/weak/partial_ordering의 차이와, = default로 비교 연산자를 한 번에 생성하는 방법을 정리합니다. 직접 구현이 필요한 경우와 == 연산자가 따로 생성되는 규칙 같은 함정도 다룹니다.

Spaceship Operator란?

C++20의 삼원 비교 연산자 <=> (일명 “spaceship operator”)는 하나의 함수로 <, <=, >, >=, 그리고 조건부로 ==, !=까지 생성해 주는 언어 기능이다. 이 연산자가 도입된 이유는 단순히 타이핑을 줄이기 위해서가 아니다. C++17까지는 값 비교가 가능한 타입을 만들려면 6개의 연산자를 모두 직접 작성해야 했고, 그 과정에서 실수가 발생하기 쉬웠다. 예를 들어 operator<는 멤버 x와 y를 모두 고려해 정의했는데 operator<=는 실수로 x만 비교하도록 작성하면, 정렬 결과와 비교 연산자의 의미가 서로 어긋나는 버그가 생긴다. 이런 불일치는 컴파일러가 잡아주지 못하기 때문에 런타임에서야 발견되는 경우가 많았고, std::set이나 std::sort처럼 엄격 약순서(strict weak ordering)를 요구하는 알고리즘에 넣었을 때 원소가 사라지거나 정렬이 꼬이는 형태로 드러났다.

<=> 연산자 하나만 정의하면 나머지 관계 연산자는 컴파일러가 이 하나의 결과를 재작성(rewriting)해서 유도한다. 즉 a < b라는 식을 만나면 컴파일러는 먼저 operator<가 있는지 찾고, 없으면 (a <=> b) < 0으로 다시 써서 평가한다. 이 재작성 규칙 덕분에 개발자는 “이 타입에서 순서란 무엇인가”라는 질문에 단 한 번만 답하면 되고, 그 답이 모든 비교 연산자에 일관되게 반영된다.

struct Point {
    int x, y;
    
    // C++20 이전: 6개 연산자 필요
    bool operator==(const Point& other) const {
        return x == other.x && y == other.y;
    }
    bool operator!=(const Point& other) const {
        return !(*this == other);
    }
    bool operator<(const Point& other) const {
        if (x != other.x) return x < other.x;
        return y < other.y;
    }
    // <=, >, >= 생략...
    
    // C++20: 1개로 모두 생성
    auto operator<=>(const Point&) const = default;
};

위 예제에서 C++20 이전 코드는 operator<만 보여주지만 실제로는 <=, >, >=까지 합쳐 총 6개를 작성해야 완전한 비교 인터페이스가 된다. = default로 위임한 operator<=>는 멤버를 선언 순서대로(x 다음 y) 사전식(lexicographic) 비교하며, 이 하나의 선언이 <, <=, >, >=, ==, != 여섯 개 연산을 모두 대체한다. 실무에서는 좌표, 버전 번호, 복합 키((category, name, price) 같은)처럼 “멤버를 순서대로 비교하면 그게 곧 올바른 순서”인 타입에 이 패턴을 쓴다. 반대로 멤버 중 일부만 비교에 참여해야 하거나, 대소문자 무시 비교처럼 값 자체가 아니라 변환된 값을 비교해야 한다면 = default 대신 직접 구현해야 한다.

반환 타입

operator<=>가 무엇을 반환하느냐는 단순한 타입 선택이 아니라 “이 타입에 어떤 종류의 순서가 성립하는가”에 대한 명시적 선언이다. C++20은 <compare> 헤더에서 세 가지 비교 카테고리를 제공하며, 각각이 보장하는 의미가 다르다.

std::strong_ordering은 완전 순서를 의미한다. 동치인 두 값은 실질적으로 구별할 수 없어야 한다는 “대체 가능성(substitutability)” 원칙이 성립한다. 정수, 문자열의 사전식 비교, 완전히 동일한 멤버로만 구성된 값 타입이 여기 해당한다. 예를 들어 두 Point{1, 2}가 강한 순서 하에서 동등하다면 프로그램의 어떤 관찰 가능한 동작으로도 둘을 구별할 수 없어야 한다.

std::weak_ordering은 순서는 완전하지만 동치와 동일함(identity)이 다를 수 있는 경우다. 대소문자를 무시하는 문자열 비교가 대표적인 예시다. "Hello"와 "hello"는 순서상 동등(equivalent)하지만, 두 문자열 객체는 여전히 서로 다른 내용을 담고 있으므로 완전히 같다고 할 수는 없다. 이런 타입을 strong_ordering으로 잘못 선언하면, “동등하면 구별 불가능하다”는 계약을 어기게 되어 이후 이 타입을 사용하는 제네릭 코드(예: 캐시 키로 사용하거나 중복 제거 로직)가 잘못된 가정을 하게 만들 수 있다.

std::partial_ordering은 일부 값 쌍이 아예 순서를 정할 수 없는 경우다. IEEE 754 부동소수점의 NaN이 전형적인 예다. NaN은 자기 자신을 포함해 어떤 값과 비교해도 “크다/작다/같다” 중 어느 것도 성립하지 않고 partial_ordering::unordered가 된다.

#include <compare>

// strong_ordering: 완전 순서
struct Point {
    int x, y;
    auto operator<=>(const Point&) const = default;
    // <, <=, >, >=, ==, != 모두 생성
};

// weak_ordering: 동등하지만 구별 가능
struct CaseInsensitiveString {
    string str;
    
    weak_ordering operator<=>(const CaseInsensitiveString& other) const {
        // 대소문자 무시 비교
        return strcasecmp(str.c_str(), other.str.c_str()) <=> 0;
    }
};

// partial_ordering: 부분 순서 (NaN 등)
struct Float {
    double value;
    
    partial_ordering operator<=>(const Float& other) const {
        return value <=> other.value;
    }
};

여기서 실무에 직접 영향을 주는 규칙이 하나 있다. 클래스에 double이나 float 멤버가 하나라도 있고 = default로 비교를 합성하면, 컴파일러는 자동으로 common_comparison_category를 계산해서 가장 약한 카테고리를 선택한다. 즉 auto operator<=>(...) const = default;라고 써 놓아도 그 안에 부동소수점 멤버가 섞여 있으면 실제 반환 타입은 partial_ordering으로 강제된다. 이건 컴파일러 버그가 아니라 의도된 동작이다. IEEE 754 부동소수점은 NaN이 존재하는 한 완전 순서를 수학적으로 보장할 수 없기 때문에, 언어 차원에서 “부동소수점이 섞이면 순서가 부분적일 수 있다”는 사실을 타입 시스템에 정직하게 반영하는 것이다. 문제는 이 사실이 코드를 눈으로만 봐서는 잘 드러나지 않는다는 점이다. auto로 선언했으니 당연히 strong_ordering이겠거니 생각하고 그 결과를 std::strong_order 같은 API에 넘기면 컴파일 에러가 나거나, <, > 연산자를 정렬 알고리즘에 넘겼을 때 NaN이 섞인 데이터에서 조용히 잘못된 정렬 결과를 만들어낸다.

default 비교

= default는 편리하지만 “무엇을 비교하는가”를 코드에서 명시적으로 드러내지 않는다는 대가가 있다. 컴파일러는 멤버를 선언된 순서 그대로 사전식으로 비교하는데, 이 순서는 리팩터링 과정에서 아무 의도 없이 바뀌기 쉬운 부분이다.

struct Person {
    string name;
    int age;
    
    // 모든 멤버를 순서대로 비교
    auto operator<=>(const Person&) const = default;
};

Person p1{"Alice", 30};
Person p2{"Bob", 25};

cout << (p1 < p2) << endl;   // name 비교
cout << (p1 == p2) << endl;  // name과 age 모두 비교

Person은 name이 먼저 선언되었기 때문에 p1 < p2는 사실상 "Alice" < "Bob"과 같은 의미가 된다. 나이는 이름이 같을 때만 타이브레이커로 작동한다. 만약 나중에 누군가 “나이순으로 먼저 정렬하는 게 더 자연스럽겠다”고 판단해서 멤버 선언 순서를 int age; string name;으로 바꾼다면, = default가 만들어내는 비교의 의미도 조용히 함께 바뀐다. 헤더 파일의 필드 순서를 바꾸는 것은 리팩터링 도구나 코드 정리 과정에서 아무런 경고 없이 일어날 수 있는 일인데, 그 결과로 이미 정렬되어 저장된 std::set<Person>이나 직렬화된 정렬 순서, 혹은 DB 인덱스와 매핑된 비교 로직이 전부 의미상 달라져 버린다.

실제로 나는 게임 서버의 랭킹 시스템에서 이 문제를 겪은 적이 있다. Score 구조체가 int points; std::string playerId; 순서로 선언되어 있었고 = default 비교로 리더보드용 std::multiset을 정렬하고 있었다. 이후 다른 팀원이 구조체에 로깅용 필드를 추가하면서 기존 필드 순서를 “논리적으로 더 보기 좋게” std::string playerId; int points;로 바꿔버렸다. 컴파일은 문제없이 통과했고 테스트도 대부분 통과했는데, 실서비스에 배포한 뒤에야 리더보드가 점수순이 아니라 플레이어 ID의 사전순으로 정렬되어 있다는 버그 리포트가 들어왔다. 원인을 추적하는 데 꽤 시간이 걸렸던 이유는, = default를 쓴 코드에는 “무엇을 기준으로 비교하는지”에 대한 어떤 텍스트 단서도 남지 않기 때문이었다. 그 이후로 나는 비교 순서가 비즈니스 로직상 중요한 타입에는 = default를 쓰더라도 반드시 멤버 선언 바로 위에 // 비교 순서: points -> playerId (변경 금지) 같은 주석을 남기거나, 아예 명시적으로 <=>를 구현해서 비교 기준을 코드에 드러내는 쪽을 선택한다.

실전 예시

예시 1: 기본 사용

struct Student {
    string name;
    int score;
    
    auto operator<=>(const Student&) const = default;
};

int main() {
    Student s1{"Alice", 90};
    Student s2{"Bob", 85};
    
    cout << (s1 < s2) << endl;   // 0 (Alice < Bob는 false)
    cout << (s1 > s2) << endl;   // 1
    cout << (s1 == s2) << endl;  // 0
}

Student도 name이 먼저 선언되어 있으므로 s1 < s2는 이름을 기준으로 비교된다. "Alice"가 "Bob"보다 사전순으로 앞서기 때문에 s1 < s2는 false(0)이고, s1 > s2는 true(1)다. 이 예제만 보면 당연해 보이지만, 만약 이 구조체를 “성적순 정렬”에 쓸 의도였다면 이는 명백한 버그다. = default는 “이름 우선, 점수는 부차적”이라는 순서를 만들어내는데, 코드를 처음 읽는 사람은 변수명이 Student이고 필드에 score가 있다는 것만으로 성적순 정렬을 기대하기 쉽다. 이런 간극이 바로 = default 비교의 가장 흔한 함정이다.

예시 2: 커스텀 비교

struct Student {
    string name;
    int score;
    
    // score로만 비교
    strong_ordering operator<=>(const Student& other) const {
        return score <=> other.score;
    }
    
    // == 는 별도 정의 필요
    bool operator==(const Student& other) const {
        return score == other.score;
    }
};

int main() {
    Student s1{"Alice", 90};
    Student s2{"Bob", 85};
    
    cout << (s1 > s2) << endl;   // 1 (90 > 85)
    
    vector<Student> students = {
        {"Charlie", 75},
        {"Alice", 90},
        {"Bob", 85}
    };
    
    sort(students.begin(), students.end());
    
    for (const auto& s : students) {
        cout << s.name << ": " << s.score << endl;
    }
    // Charlie: 75
    // Bob: 85
    // Alice: 90
}

이 버전은 operator<=>를 직접 작성해서 score만 비교에 참여시킨다. = default와 달리 여기서는 “이 타입의 순서는 점수 하나로 결정된다”는 의도가 코드에 명시적으로 드러난다. 다만 여기서 반환 타입으로 strong_ordering을 선택한 것은 신중히 봐야 할 부분이다. score가 같은 두 Student(예: 같은 점수를 받은 서로 다른 학생)는 이름이 다름에도 강한 순서 관점에서 “동등”으로 취급된다. 강한 순서의 정의상 이는 “구별 불가능”을 의미해야 하는데, 실제로는 이름이라는 관찰 가능한 차이가 존재한다. 엄밀하게는 이 타입에 weak_ordering을 쓰는 것이 이론적으로 더 정확하지만, 실무에서는 “정렬 기준”만 명확하면 strong_ordering을 관행적으로 쓰는 경우도 흔하다. 다만 이 선택이 캐시 키나 중복 제거 로직처럼 “동등하면 정말로 같은 것으로 취급해도 되는가”가 중요한 곳에 쓰인다면 반드시 재검토해야 한다.

예시 3: 복합 비교

struct Product {
    string category;
    string name;
    double price;
    
    // category -> name -> price 순으로 비교
    auto operator<=>(const Product& other) const {
        if (auto cmp = category <=> other.category; cmp != 0)
            return cmp;
        if (auto cmp = name <=> other.name; cmp != 0)
            return cmp;
        return price <=> other.price;
    }
    
    bool operator==(const Product& other) const = default;
};

이 패턴은 if (auto cmp = ...; cmp != 0) return cmp; 형태의 조기 반환을 연쇄해서 다단계 정렬 키를 구현한다. category가 다르면 그 결과를 바로 반환하고, 같으면 name을 비교하고, 그것도 같으면 마지막으로 price를 비교하는 식이다. 실무에서 상품 목록, 로그 엔트리, 복합 인덱스 키 등을 정렬할 때 자주 쓰는 관용구다. 다만 여기서 주의할 점은 반환 타입을 auto로 선언했다는 것이다. category와 name은 std::string이라 strong_ordering을 반환하지만 마지막 비교 대상인 price는 double이므로 partial_ordering을 반환한다. 이 함수의 세 개의 return 문이 서로 다른 비교 카테고리를 반환하면, 컴파일러는 공통 카테고리(가장 약한 것, 즉 partial_ordering)로 자동 변환한다. 결과적으로 이 operator<=>는 auto라고만 적혀 있어도 실제로는 partial_ordering을 반환하며, price에 NaN이 들어오는 순간 전체 비교가 “순서 없음” 상태가 될 수 있다는 점을 기억해야 한다.

예시 4: 대소문자 무시

struct CaseInsensitiveString {
    string str;
    
    weak_ordering operator<=>(const CaseInsensitiveString& other) const {
        auto toLower = [](string s) {
            transform(s.begin(), s.end(), s.begin(), ::tolower);
            return s;
        };
        
        return toLower(str) <=> toLower(other.str);
    }
    
    bool operator==(const CaseInsensitiveString& other) const {
        return (*this <=> other) == 0;
    }
};

int main() {
    CaseInsensitiveString s1{"Hello"};
    CaseInsensitiveString s2{"hello"};
    
    cout << (s1 == s2) << endl;  // 1
}

이 예제가 weak_ordering을 선택한 이유가 앞서 설명한 원칙과 정확히 맞아떨어진다. "Hello"와 "hello"는 정렬 관점에서는 동등하지만, 객체가 담고 있는 실제 문자열 데이터는 다르다. 이걸 strong_ordering으로 선언했다면 “동등한 두 값은 구별 불가능하다”는 계약을 위반하는 셈이 되고, 이후 이 값을 대소문자를 보존해야 하는 로직(예: 원본 문자열을 그대로 화면에 출력하는 UI 코드)에 넘겼을 때 예상치 못한 동작을 유발할 수 있다. 다만 실무 성능 관점에서 이 구현에는 아쉬운 점이 있는데, 비교할 때마다 toLower가 새로운 std::string을 두 번(비교당) 힙에 할당한다는 것이다. 비교가 정렬의 내부 루프처럼 자주 호출되는 경로라면 std::string_view와 std::tolower를 문자 단위로 순회하며 비교하는 방식으로 바꿔 불필요한 할당을 없애는 편이 낫다.

비교 카테고리

세 카테고리 사이의 관계를 코드 레벨에서 보면 다음과 같다.

// strong_ordering: 완전 순서
// a < b, a == b, a > b 중 정확히 하나
int a = 1, b = 2;
auto cmp1 = a <=> b;  // strong_ordering::less

// weak_ordering: 동등하지만 구별 가능
// "Hello"와 "hello"는 동등하지만 다름
weak_ordering cmp2 = weak_ordering::equivalent;

// partial_ordering: 부분 순서
// NaN은 어떤 값과도 비교 불가
double x = 1.0, y = NAN;
auto cmp3 = x <=> y;  // partial_ordering::unordered

여기서 중요한 실무 규칙 하나는 세 카테고리 사이의 변환 방향이 한쪽으로만 열려 있다는 점이다. strong_ordering은 암묵적으로 weak_ordering이나 partial_ordering으로 변환될 수 있지만, 반대 방향은 불가능하다. 즉 “더 강한 보장”에서 “더 약한 보장”으로는 자연스럽게 넘어갈 수 있어도, partial_ordering을 strong_ordering으로 마음대로 승격시킬 수는 없다. 나는 이 규칙 때문에 실제로 컴파일 에러를 만난 적이 있는데, 제네릭 정렬 유틸리티 함수를 작성하면서 반환 타입을 std::strong_ordering으로 못 박아 두고 그 안에서 double 멤버를 비교했다가 “partial_ordering을 strong_ordering으로 변환할 수 없다”는 에러를 받았다. 처음에는 컴파일러가 지나치게 깐깐하다고 생각했지만, 돌이켜 보면 이 에러가 실제로 막아준 것은 “NaN이 섞여도 항상 완전한 순서가 존재한다”는 잘못된 전제였다. 만약 그 변환이 암묵적으로 허용되었다면 NaN을 포함한 데이터가 std::set에 들어갔을 때 트리 불변식이 깨지는, 훨씬 찾기 어려운 버그로 이어졌을 것이다. 이 경험 이후로는 부분 순서가 나올 수 있는 타입을 다룰 때 반환 타입을 처음부터 강하게 못 박지 않고 auto로 열어 두거나, partial_ordering임을 명시적으로 인정한 뒤 NaN 케이스를 어떻게 처리할지(예외를 던질지, 정렬 전에 필터링할지) 별도로 결정하는 방식을 선호한다.

자주 발생하는 문제

이 섹션에서 다루는 세 가지 문제는 모두 “컴파일은 되지만 기대와 다르게 동작하는” 유형이라는 공통점이 있다. <=> 관련 버그가 특히 위험한 이유는, 비교 연산자가 잘못되어도 코드는 대개 정상적으로 컴파일되고 실행되기 때문이다. 잘못된 결과는 정렬 순서가 미묘하게 틀리거나, 컨테이너에서 원소가 사라지거나, 중복 검사가 실패하는 형태로 늦게 드러난다.

문제 1: == 생성 안 됨

// ❌ == 자동 생성 안 됨
struct Point {
    int x, y;
    
    auto operator<=>(const Point& other) const {
        return x <=> other.x;  // y 무시
    }
};

Point p1{1, 2}, p2{1, 3};
// p1 == p2;  // 컴파일 에러

// ✅ == 명시적 정의
struct Point {
    int x, y;
    
    auto operator<=>(const Point& other) const {
        return x <=> other.x;
    }
    
    bool operator==(const Point& other) const = default;
};

C++20의 규칙은 이렇다. operator<=>를 = default로 선언하면 컴파일러가 operator==도 함께 암묵적으로 defaulted 상태로 선언해 준다. 하지만 위 코드처럼 본문을 직접 작성한(non-defaulted) operator<=>는 ==를 자동으로 만들어주지 않는다. 언뜻 비일관적으로 보이지만 여기에는 분명한 설계 의도가 있다. 직접 작성한 <=>는 “순서”만 정의하겠다는 의도일 수도 있고(예: x만으로 순서를 정하되 y가 다르면 별개의 객체로 취급하고 싶을 수도 있다), 컴파일러 입장에서는 “순서 비교 로직을 그대로 재사용해서 동치 비교를 정의해도 되는지”를 판단할 근거가 없다. 그래서 언어는 안전한 기본값(자동 생성 안 함) 쪽을 택하고, 개발자가 명시적으로 = default를 추가하거나 별도의 ==를 작성하도록 강제한다. 실무에서 이 에러 메시지를 처음 만나면 “<=>를 만들었는데 왜 ==가 안 되지”라며 당황하기 쉬운데, 원인은 대부분 <=>를 직접 구현하면서 == 선언을 깜빡한 경우다.

문제 2: 타입 불일치

// ❌ 다른 타입 비교
struct A {
    int value;
    auto operator<=>(const A&) const = default;
};

struct B {
    int value;
    auto operator<=>(const B&) const = default;
};

A a{1};
B b{1};
// a == b;  // 컴파일 에러

// ✅ 변환 연산자 또는 명시적 비교

A와 B가 구조적으로 동일해 보여도 C++는 이름 기반 타입 시스템이므로 서로 다른 타입 간의 비교는 애초에 정의되어 있지 않다. <=>가 생겼다고 해서 구조적으로 같은 타입끼리 자동으로 비교 가능해지는 것은 아니다. 이 문제는 특히 서로 다른 라이브러리에서 가져온 “같은 의미인데 이름이 다른” 값 타입(예: 두 개의 서로 다른 서드파티 라이브러리가 각자 정의한 Point나 Vector2)을 섞어 쓸 때 자주 부딪힌다. 해결책은 명시적 변환 연산자를 추가하거나, 이종(heterogeneous) 비교를 위한 프리 함수 operator<=>(const A&, const B&)를 별도로 작성하는 것이다. 다만 이런 이종 비교 연산자를 추가할 때는 ADL(인자 종속 탐색) 규칙에 따라 두 타입 중 적어도 하나와 같은 네임스페이스에 선언해야 컴파일러가 찾아낼 수 있다는 점도 함께 신경 써야 한다.

문제 3: 포인터 비교

// ❌ 포인터 주소 비교
struct Node {
    int value;
    Node* next;
    
    auto operator<=>(const Node&) const = default;
    // next는 주소로 비교됨
};

// ✅ 커스텀 비교
struct Node {
    int value;
    Node* next;
    
    auto operator<=>(const Node& other) const {
        return value <=> other.value;
    }
};

이 문제는 링크드 리스트나 트리처럼 포인터로 다음 노드를 가리키는 자료구조에서 = default를 습관적으로 붙였을 때 특히 잘 드러난다. 포인터 타입의 <=>는 가리키는 객체의 값이 아니라 포인터 자체의 주소값을 비교한다. 즉 value가 완전히 동일한 두 Node라도 next가 서로 다른 메모리 주소를 가리키면(설령 그 주소가 가리키는 내용이 같더라도) 두 Node는 다르다고 판정된다. 더 나쁜 경우는 이 비교 결과가 실행할 때마다 달라질 수 있다는 점이다. 동적 할당은 실행 시점의 메모리 배치에 따라 주소가 달라지므로, 오늘 테스트에서 통과한 동등성 비교가 다음 실행에서는 실패할 수 있다. 이런 종류의 비결정성은 디버깅하기 매우 까다로운데, 재현이 안 되는 테스트 실패로 처음 나타나는 경우가 많기 때문이다. 값 기반 비교가 필요한 노드 타입이라면 포인터가 가리키는 내용을 재귀적으로 비교하거나, 애초에 비교에서 next를 제외하는 쪽을 선택해야 한다.

default vs 커스텀

// default: 모든 멤버 비교
struct Point {
    int x, y;
    auto operator<=>(const Point&) const = default;
};

// 커스텀: 특정 멤버만 비교
struct Point {
    int x, y;
    
    auto operator<=>(const Point& other) const {
        return x <=> other.x;  // x만 비교
    }
    
    bool operator==(const Point&) const = default;
};

= default와 커스텀 구현 중 어느 쪽을 선택할지는 결국 “이 타입의 정체성(identity)이 무엇인가”에 대한 질문이다. 모든 멤버가 정체성에 동등하게 기여한다면 = default가 가장 안전하고 유지보수하기 쉽다. 컴파일러가 생성하는 비교는 사람이 손으로 짠 것보다 실수할 여지가 적고, 멤버를 추가하거나 제거해도 비교 로직이 자동으로 따라온다. 반대로 특정 멤버는 캐시나 메타데이터 성격이라 비교에서 제외해야 하거나, 여러 멤버 사이에 우선순위를 둬야 한다면 커스텀 구현이 필요하다. 레거시 코드베이스를 C++20으로 마이그레이션하는 실무에서는, 기존에 operator<가 있던 클래스를 무작정 = default로 바꾸기 전에 반드시 기존 operator<의 실제 구현을 확인해야 한다. 기존 코드가 멤버 선언 순서와 다른 기준으로 비교하고 있었다면(흔한 사례로, 헤더에는 id, name, createdAt 순으로 필드가 있지만 실제 operator<는 createdAt을 최우선으로 비교하는 경우) = default로 교체하는 순간 정렬 의미가 바뀐다. 또한 기존 코드가 암묵적 타입 변환에 의존해서 operator<(const Base&, int)처럼 이종 비교를 지원하고 있었다면, <=> 기반 재작성 규칙이 개입하면서 오버로드 해석이 모호해지거나(같은 비교를 만족하는 후보가 여러 개 생겨) 컴파일 에러로 이어지는 경우도 실제로 발생한다. 특히 어떤 라이브러리가 operator<=를 순서 비교가 아닌 다른 용도(예: 스트림 체이닝이나 빌더 패턴의 문법적 트릭)로 오버로드해 놓았던 코드베이스라면, C++20으로 올리면서 <=>를 추가하는 순간 재작성된 후보와 기존 오버로드가 충돌해 모호성 에러가 새로 발생할 수 있다는 점도 마이그레이션 체크리스트에 반드시 포함해야 한다.

상속 계층에서 <=>를 쓸 때도 주의할 점이 있다. 기본 클래스에 operator<=>를 virtual로 선언하지 않은 상태에서 파생 클래스 객체를 기본 클래스 참조나 값으로 다루면, 흔히 말하는 객체 슬라이싱(slicing)이 발생해 파생 클래스가 추가한 멤버는 비교에서 완전히 누락된다. 나는 과거에 Shape 기반 클래스와 Circle, Rectangle 파생 클래스를 담은 컨테이너를 정렬하는 코드에서 이 문제를 겪은 적이 있다. std::vector<Shape>에 파생 클래스 객체를 값으로 담고 있었는데(포인터나 참조가 아니라), = default로 만든 Shape::operator<=>가 슬라이싱된 기본 클래스 부분만 비교하다 보니 서로 다른 도형이라도 공통 멤버(예: area)만 같으면 동일한 것으로 정렬되는 문제가 있었다. 다형적 비교가 필요한 계층 구조라면 애초에 값 타입 컨테이너에 파생 클래스를 직접 담지 말고 포인터나 std::variant로 다루는 편이 슬라이싱과 비교 문제를 동시에 피하는 방법이다.

FAQ

Q1: Spaceship Operator는 언제 사용하나요?

A:

  • 정렬 가능한 타입
  • 비교 연산자 간소화
  • C++20 이상

Q2: 모든 연산자 생성?

A: <, <=, >, >= 생성. ==는 별도 정의 필요 (default 가능).

Q3: 성능은?

A: 수동 구현과 동일. 컴파일러 최적화 가능.

Q4: C++20 이전에는?

A: 6개 연산자 수동 구현.

Q5: 반환 타입 선택은?

A:

  • strong_ordering: 완전 순서
  • weak_ordering: 동등 관계
  • partial_ordering: 부분 순서

Q6: Spaceship Operator 학습 리소스는?

A:

  • “C++20 The Complete Guide”
  • cppreference.com
  • “Effective Modern C++“

관련 글