1. 왜 타입에 operator==가 필요한가
만약 아직 operator==를 “보기 좋게 해주는 장식”쯤으로 생각하고 있다면, 당신만 그런 건 아닙니다: 대부분의 사람들이 그렇습니다. 하지만 코드가 조금 더 실제적이 되면 상황이 바뀝니다. std::vector에 객체가 들어가면 요소를 찾고, 유일성 검사를 하고, 특정 엔티티를 제거하고, 두 결과를 비교하는 등의 “일반적인” 연산을 자연스럽게 하길 원하게 됩니다.
내장 타입(int, std::string)은 “상자에서 꺼내 바로” 비교할 수 있지만, 당신의 class Task는 기본적으로 그렇지 않습니다. C++는 여기서 정직합니다: 무엇을 동일하다고 보는지는 알아서 추측하지 않습니다. 동등성은 타입의 공개 인터페이스의 일부이고, 그 의미를 정의하는 것은 전적으로 당신의 몫입니다.
작은 미니‑태스크 트래커 앱을 상상해 보세요: 각 할 일에는 id, 제목, 완료 여부 플래그가 있습니다. 사용자 입장에서는 “작업 №17”이 제목을 바꾸거나 완료로 표시해도 동일한 작업으로 여겨집니다. 하지만 operator==이 없다면 컴파일러에게는 그저 메모리상의 서로 다른 두 객체일 뿐입니다.
2. operator==는 함수다
처음 operator==를 보면 컴파일러가 마법을 부리는 것처럼 보일 수 있습니다. 실제로는 마법이 거의 없습니다: 특수한 이름을 가진 하나의 함수일 뿐이며, 그래서 여러분은 a == b 를 a.equals(b) 또는 equals(a, b) 대신 작성할 수 있습니다.
기억해야 할 간단한 생각: 오버로드된 연산자는 당신의 공개 계약입니다. 이상한 동등성을 정의하면 프로그램은 “동작”할지 몰라도, 사람들(그리고 일주일 후의 당신)은 코드를 읽고 난처해질 것입니다. 프로그래밍에서 난처함은 보통 버그로 이어집니다.
작은 앱 일부를 예로 시작해 봅시다. 타입 Task가 있다고 합시다:
#include <string>
#include <utility>
class Task {
public:
Task(int id, std::string title)
: id_(id), title_(std::move(title)) {}
private:
int id_{};
std::string title_;
};
이 상태에서는 C++가 무엇이 Task끼리 “동일”한지 모르기 때문에 Task a == Task b처럼 정상적으로 비교할 수 없습니다. 그리고 그게 오히려 좋은 일입니다: 자동으로 메모리 주소나 바이트 단위, 컴파일러의 임의 규칙 또는 달의 위상에 따라 비교해 버렸다면 골치 아플 겁니다(농담이지만 거의 맞는 말이기도 합니다).
3. 동등성의 계약
연산자 ==는 단순히 true/false를 반환하는 것이 아닙니다. 올바른 동등성에는 사람들이 당연하게 여기는 기대되는 성질들이 있습니다. 이를 어기면 코드가 마치 머릿속에 작은 혼돈의 그렘린을 품은 것처럼 동작할 수 있습니다.
성질들을 이렇게 설명할 수 있습니다:
| 성질 | 사람 말로 설명하면 | 간단한 예 |
|---|---|---|
| 반사성 | 객체는 자기 자신과 동등하다 | a == a 는 항상 true |
| 대칭성 | 만약 a == b라면 b == a도 성립한다 | 동등성은 “양방향”이다 |
| 추이성 | 만약 a == b이고 b == c라면 a == c이다 | 동등성이 “사슬에서 깨지지 않는다” |
또한 교과서에는 잘 쓰이지 않지만 실무에서 매우 중요한 실용적 “성질”이 하나 더 있습니다: 대체 가능성. 만약 a == b라면, 프로그램의 대부분 위치에서 a를 b로 교체해도 의미가 변하지 않을 것으로 기대합니다. 항상 100%는 아니더라도, 설계의 지침으로서 매우 유용합니다.
우리의 Task 관점에서는: 두 작업이 “동일”하다고 하면 애플리케이션 관점에서 그것이 같은 작업이라고 기대합니다. 따라서 무엇이 “같은 작업”을 만드는지 명확히 정해야 합니다.
4. 식별성(identity)과 값 기반 동등성(value equality)
초보자가 자주 하는 실수는 동등성이 항상 “모든 필드가 일치한다”는 의미라고 생각하는 것입니다. 경우에 따라 그렇기도 하지만, 대부분의 경우 그렇지 않습니다.
객체의 동등성에는 보통 두 가지 의미가 있습니다:
첫 번째 — 식별성(identity): 도메인 상 동일한 실체입니다. 예를 들어 id=42인 작업은 제목이 바뀌어도 작업 №42로 남습니다. 이 경우 동등성은 안정적인 식별자에 의해 정의됩니다.
두 번째 — 값 기반 동등성(value equality): 데이터가 동일합니다. 예를 들어 평면상의 두 점은 좌표 x와 y가 같으면 동일합니다. 이 경우 “모든 필드”를 비교하는 것이 논리적입니다.
우리의 미니‑태스크 트래커에서는 대체로 id에 따른 식별성이 더 유리합니다. 작업은 시간이 지나면서 이름이 바뀌고 완료 상태가 달라지며 우선순위가 바뀔 수 있지만, 실체는 동일하게 남기 때문입니다.
비유를 들면(약간 관료적이지만): 여권. 머리를 자르고 안경을 바꾸고 좋아하는 글꼴을 바꿔도 여권 번호는 같은 사람을 식별합니다. 비슷하게 TaskId가 작업을 식별합니다.
5. 예: Task와 id 기반 비교
이제 코드로 들어갑니다. 아이디어는 간단합니다: operator==는 보통 객체를 변경해서는 안 되므로 거의 항상 const입니다. 또한 동등성의 의미로 선택한 것을 비교해야 합니다.
간단한 공개 인터페이스를 가진 Task를 만들고 id로 비교를 구현해 봅시다:
#include <string>
#include <utility>
class Task {
public:
Task(int id, std::string title)
: id_(id), title_(std::move(title)) {}
int id() const { return id_; }
const std::string& title() const { return title_; }
bool operator==(const Task& other) const {
return id_ == other.id_;
}
private:
int id_{};
std::string title_;
};
중요한 세부사항에 주의하세요. other를 const Task&로 받는 이유는 객체를 복사하지 않기 위해서이며(const인 Task과도 비교할 수 있게 하기 위해), 메서드 자체를 const로 표시한 이유는 비교가 this를 변경해서는 안 되기 때문입니다. 여기서는 id_만 비교합니다. 왜냐하면 그것이 “같은 작업인지”를 결정하기 때문입니다.
“그렇다면 Task{2, "빵 사기"}가 Task{2, "인터넷 삭제"}와 같다면 이상하지 않냐”고 생각했다면, 당신은 정확히 좋은 질문을 한 것입니다. 이것은 operator==의 버그가 아니라 당신이 식별성 기반의 동등성을 선택했다는 신호입니다. 엔티티의 경우 이것이 종종 바람직한 선택입니다.
6. 구현 옵션과 실용적 결과
멤버 메서드 versus 자유 함수
때로는 연산자를 멤버 함수로 만들기보다 외부의 일반 함수로 만드는 것이 더 편리합니다. 특히 타입을 다른 것과 비교하거나 연산자에게 private 접근 권한을 주고 싶지 않을 때, 공개 메서드를 통해 비교하는 편이 좋을 수 있습니다.
다음은 Task에 멤버 operator==가 없고, 외부에서 id()로 비교를 정의한 예입니다:
#include <string>
#include <utility>
class Task {
public:
Task(int id, std::string title)
: id_(id), title_(std::move(title)) {}
int id() const { return id_; }
private:
int id_{};
std::string title_;
};
bool operator==(const Task& a, const Task& b) {
return a.id() == b.id();
}
이 방식은 전혀 문제없이 작동합니다. 다만 여기서도 동등성 계약을 설정하는 것은 동일하며, 구현이 클래스 내부가 아닌 외부에 있을 뿐입니다. 타입의 API를 최소화하고 편의 기능을 외부에서 추가하고 싶을 때 유용한 패턴입니다.
operator!=와 단일 진실의 출처
operator==를 정의하면 곧 operator!=도 정의하고 싶어집니다. 과거의 C++에서는 둘 다 직접 작성해야 했고, 그렇지 않으면 a != b가 컴파일되지 않았습니다. 현대의 C++(C++20/23)에서는 많은 경우 컴파일러가 ==을 기반으로 자동으로 !=를 생성해 줍니다.
그러나 규칙을 깊게 알지 못한다면 실용적인 접근법은 간단합니다: 로직을 중복하지 마라. 동일한 로직이 두 군데 있으면 언젠가 하나만 바뀌어 동기화가 깨집니다.
직접 !=를 작성해야 한다면 다음처럼 ==의 논리적 부정으로 구현하세요:
bool operator!=(const Task& a, const Task& b) {
return !(a == b);
}
이렇게 하면 ==의 의미를 바꾸면 !=도 자동으로 따라 바뀌므로 안전합니다.
operator== = default;
어떤 타입은 실제로 “값 타입”이며, 동등성의 의미가 “모든 필드가 같다”인 경우가 있습니다. 이런 경우 C++에게 operator==를 자동으로 생성해 달라고 요청할 수 있습니다.
예:
#include <string>
struct Point {
int x{};
int y{};
bool operator==(const Point&) const = default;
};
컴파일러는 선언된 필드 순서대로 x와 y를 비교합니다. 읽기 쉽고 수동 코드를 줄여 실수할 가능성을 낮춰 줍니다.
하지만 한 가지 상식적인 규칙이 있습니다: = default는 필드들이 실제로 동등성의 의미를 완전히 반영할 때에만 적절합니다. 캐시, 호출 카운터, 기술적 플래그 같은 “보조” 필드가 있으면 기본 비교가 의미상 동등한 객체를 다르게 판단할 수 있습니다.
우리의 Task에서는 식별성을 id로 본다면 모든 필드에 대한 기본 비교는 잘못된 결과를 낳을 수 있습니다. 그러므로 = default는 훌륭한 도구이지만 만능은 아닙니다.
operator==가 std::find에 미치는 영향
좋은 operator==가 있으면 코드의 많은 부분이 “교과서적”으로 보이기 시작합니다. 즉, 직접 루프를 돌며 ID를 찾는 수고를 덜고 표준 알고리즘을 더 자연스럽게 사용할 수 있습니다.
std::find 예를 보겠습니다. 이 함수는 범위에서 요소를 찾아 ==로 비교합니다:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks = {
Task{1, "Read docs"},
Task{2, "Write code"}
};
auto it = std::find(tasks.begin(), tasks.end(), Task{2, "???"});
std::cout << (it != tasks.end()) << '\n'; // 1
}
이 예는 약간 도발적입니다: 우리는 Task{2, "???"}를 찾지만 실제로는 Task{2, "Write code"}를 찾습니다. 왜일까요? 우리는 Task의 동등성을 id로 정의했기 때문입니다. std::find에게 이것은 “같은 id를 가진 작업을 찾았다”는 의미입니다.
이는 매우 강력한 효과입니다: 한 번 계약을 정의하면 모든 곳에서 일관되게 동작합니다. 따라서 ==를 임의로 정의하면 비교가 사용되는 모든 장소에서 놀라운 결과를 얻을 수 있으니 주의해야 합니다.
이를 확인하기 위해 “얇은 main 스타일”의 작은 함수를 추가해 보겠습니다: 리스트에 작업이 있는지 검사합니다.
#include <algorithm>
#include <vector>
bool containsTask(const std::vector<Task>& tasks, const Task& t) {
return std::find(tasks.begin(), tasks.end(), t) != tasks.end();
}
코드는 짧고 읽기 쉽습니다. 다만 이 가독성은 operator==가 “같은 작업”의 의미를 정직하게 표현한다는 전제 위에 성립합니다.
엄격한 동등성과 “유사성”: 문자열과 double
실무에서는 int뿐 아니라 문자열과 부동소수점도 비교하고 싶어집니다. 여기서 operator==를 너무 “똑똑하게” 만들면 의외의 문제가 생길 수 있습니다.
문자열의 흔한 함정은 사용자가 "Ann"과 "ann"을 같은 이름으로 생각하는 경우입니다. std::string은 이를 다른 문자열로 봅니다. 해결책은 두 가지입니다. 첫째, 데이터를 정규화(예: 항상 소문자로 저장)하면 기본 ==가 올바르게 작동합니다. 둘째, operator==를 건드리지 않고 “대소문자 무시 비교” 같은 별도의 함수를 만들어 ==의 기대를 깨지 않게 하는 것입니다.
double의 경우는 더 까다롭습니다. 부동소수점 표현 때문에 0.1 + 0.2가 정확히 0.3이 아닐 수 있습니다. 따라서 double을 보관하는 타입에서 기본적으로 “있는 그대로” 비교하는 operator==를 만드는 것은 종종 좋지 않은 선택입니다. 하지만 epsilon을 이용한 비교를 operator== 안에 직접 넣는 것도 논쟁의 여지가 있습니다. epsilon은 설정값이기 때문에 어떤 상황에서는 1e-6이 적절하고 다른 상황에서는 1e-12가 필요할 수 있습니다.
따라서 “유사성”은 별도 함수로 두는 것이 좋습니다:
#include <cmath>
bool almostEqual(double a, double b) {
const double eps = 1e-9;
return std::abs(a - b) < eps;
}
아이디어는 단순합니다: operator==는 엄격한 계약으로 두고, “비슷함”은 이름이 명확한 별도 동작으로 표현하세요. 그러면 코드는 if (almostEqual(a, b))처럼 솔직하게 읽히고, if (a == b)에 마법이 숨겨져 있지 않습니다.
7. operator== 설계 및 사용 시 흔한 실수
실수 #1: 동등성에 변경 가능한 “보조” 필드를 포함시킨다.
때때로 operator==에 캐시된 값, 카운터, 디버그 플래그 같은 필드를 넣어 버립니다. 결과적으로 객체는 몇 번의 연산 후에 자기 자신과 같지 않게 되거나 논리적으로 동일한 두 객체가 다르게 판정됩니다. 해결책은 계약을 미리 말로 명확히 정의하고 그 핵심만 비교하는 것입니다.
실수 #2: 식별성과 데이터 일치성 혼동.
엔티티(작업, 사용자, 주문)의 경우 동등성은 보통 식별자 기반이고, 값 타입(점, 크기, 구간)의 경우 필드 기반입니다. 이를 혼동하면 이상한 결과가 납니다: 예를 들어 std::find가 “틀린 객체”를 찾거나 정작 찾아야 할 객체를 찾지 못할 수 있습니다.
실수 #3: operator==에 const를 빼먹는다.
비교는 거의 절대 객체를 변경하지 않아야 합니다. const를 빼먹으면 const Task나 컨테이너 요소를 비교하는 상황에서 컴파일 에러가 발생합니다. 해결은 대개 간단합니다: 메서드를 const로 만들고 인자를 const T&로 받도록 합니다.
실수 #4: ==와 !=를 따로 작성해 동기화가 깨진다.
오늘 a == b가 “같은 id”를 의미하는데, a != b를 실수로 “이름이 다르다”로 남겨두면 컴파일러가 구해주는 보장이 없습니다. 안전하게 하려면 ==을 정의하고 !=는 !(a == b)로 구현하거나, 컴파일러에게 자동 생성을 맡기세요.
실수 #5: operator==를 너무 “똑똑하게” 만든다.
대소문자 무시, 양끝 공백 무시, epsilon을 쓴 double 비교, 특정 필드 무시 등 유혹이 큽니다. 때로는 필요한 경우도 있지만, 대개는 ==를 놀라움 생성기로 만들어 버립니다. 더 현실적인 방법은 ==를 엄격하고 예측 가능하게 유지하고, “유사성”은 의미가 분명한 별도의 함수로 구현하는 것입니다.
GO TO FULL VERSION