본문으로 건너뛰기

안동민 개발노트

본문 시작

연산자 오버로딩 기초

사용자 정의 타입에 산술·입출력·대입·증감 연산의 의미를 부여하고 일관된 연산자 인터페이스를 설계합니다.

C++ 객체 지향 프로그래밍에서 연산자 의미를 타입에 맞게 확장하는 연산자 오버로딩(Operator Overloading)을 다룹니다.

연산자 오버로딩은 사용자 정의 타입(클래스 객체)에도 +, -, *, /와 같은 기존 연산자를 사용할 수 있도록 확장하는 기능입니다.

이를 통해 코드의 가독성을 높이고, 객체 간의 연산을 더욱 직관적으로 만들 수 있습니다.

연산자 후보는 멤버와 비멤버 경계에서 모인다

C++ · ARCHITECTURE

연산자 후보는 멤버와 비멤버 경계에서 모인다

표현식이 멤버 후보와 관련 이름 공간의 비멤버 후보를 찾고 계약에 맞는 함수를 선택한다.

연산자 후보는 멤버와 비멤버 경계에서 모인다표현식이 멤버 후보와 관련 이름 공간의 비멤버 후보를 찾고 계약에 맞는 함수를 선택한다. OPERANDSCONTRACT피연산자·표현식왼쪽 타입과 변환OPERANDS후보 집합멤버 · 비멤버 · ADLLOOKUP선택된 연산반환 · const · noexceptCONTRACT
  1. 피연산자·표현식

    왼쪽 타입과 변환

  2. 후보 집합

    멤버 · 비멤버 · ADL

  3. 선택된 연산

    반환 · const · noexcept

표현식이 멤버 후보와 관련 이름 공간의 비멤버 후보를 찾고 계약에 맞는 함수를 선택한다.


연산자 오버로딩이란?

일반적으로 C++의 연산자(예: +, -, *, /, ==, [], <<, >> 등)는 기본 데이터 타입(int, double 등)에 대해서만 미리 정의된 동작을 수행합니다.

예를 들어 두 개의 int 값을 더하는 a + b는 잘 작동합니다.

하지만 우리가 정의한 PointVector와 같은 클래스 객체를 + 연산자로 직접 더하려고 하면 어떻게 될까요?

Point 클래스 + 연산?
Point p1(1, 2);
Point p2(3, 4);
// Point p3 = p1 + p2; // 컴파일 오류! Point 타입에 대한 + 연산자가 정의되어 있지 않음

이런 연산은 컴파일 오류를 발생시킵니다.

왜냐하면 컴파일러는 Point 객체에 대해 + 연산자를 어떻게 처리해야 할지 모르기 때문입니다.

연산자 오버로딩은 바로 이 문제를 해결합니다.

클래스에 대해 특정 연산자가 사용될 때, 해당 연산자가 어떤 작업을 수행할지 우리가 직접 정의하는 것입니다.

이를 통해 사용자 정의 타입도 마치 기본 타입처럼 연산자를 사용하여 처리할 수 있게 됩니다.

장점
  • 가독성 향상: p1.add(p2) 대신 p1 + p2처럼 직관적인 코드를 작성할 수 있습니다.
  • 직관적인 표현: 수학적 또는 논리적인 개념을 코드에 더 자연스럽게 반영할 수 있습니다.

연산자 오버로딩의 기본 형식

연산자 오버로딩은 operator 키워드를 사용하여 멤버 함수 또는 비멤버 함수(전역 함수)로 정의할 수 있습니다.

연산자 오버로딩 형식
// 멤버 함수로 오버로딩
반환타입 operator연산자(매개변수1, 매개변수2, ...) {
    // 연산자 동작 정의
}

// 비멤버 함수 (전역 함수)로 오버로딩
반환타입 operator연산자(매개변수1, 매개변수2, ...) {
    // 연산자 동작 정의
}
  • operator 키워드 뒤에 오버로딩할 연산자 기호(예: +, -, ==, [] 등)를 붙입니다.
  • 매개변수의 개수와 타입은 오버로딩할 연산자의 종류(단항 연산자, 이항 연산자)에 따라 달라집니다.

이항 연산자 오버로딩 (+ 연산자 예시)

두 개의 피연산자를 가지는 이항 연산자(예: +, -, *, /, ==, !=)를 오버로딩하는 가장 흔한 방법은 다음과 같습니다.

  • 멤버 함수로 오버로딩
    • 좌측 피연산자가 호출되는 객체(암시적 this 포인터)가 됩니다.
    • 우측 피연산자 하나를 매개변수로 받습니다.
    • 반환 타입은 연산 결과의 타입입니다.
  • 비멤버 함수 (전역 함수)로 오버로딩
    • 두 피연산자를 모두 매개변수로 받습니다.
    • 클래스의 private 멤버에 접근해야 하는 경우, friend 키워드를 사용하여 프렌드 함수(Friend Function)로 선언해야 합니다. (friend 문법 자체는 ‘프렌드 함수와 프렌드 클래스’에서 독립적으로 다룹니다.)
예시 1: Point 클래스에 + 연산자 오버로딩
Point.h
#pragma once
#include <iostream>

class Point {
private:
    int x;
    int y;

public:
    Point(int _x = 0, int _y = 0) : x(_x), y(_y) {} // 기본값 인자를 가진 생성자

    // Getter 함수 (const 멤버 함수)
    int getX() const { return x; }
    int getY() const { return y; }

    // 1. 멤버 함수로 + 연산자 오버로딩
    // Point 객체를 더하면 새로운 Point 객체를 반환
    Point operator+(const Point& other) const; // const는 현재 객체의 상태를 변경하지 않음을 의미

    // 2. << 연산자 오버로딩을 위한 friend 선언 (뒤에서 다시 다룸)
    friend std::ostream& operator<<(std::ostream& os, const Point& p);
};
Point.cpp
#include "Point.h"

// 1. 멤버 함수로 + 연산자 오버로딩 정의
// 현재 Point 객체(this)와 other Point 객체를 더하여 새로운 Point 객체를 반환
Point Point::operator+(const Point& other) const {
    // 새로운 Point 객체를 생성하여 x, y 좌표를 더한 값으로 초기화
    return Point(this->x + other.x, this->y + other.y);
}

// 2. << 연산자 오버로딩 정의 (friend 함수이므로 클래스 외부에서 정의)
// Point::operator<< 가 아님!
std::ostream& operator<<(std::ostream& os, const Point& p) {
    os << "(" << p.x << ", " << p.y << ")"; // private 멤버 x, y에 직접 접근 가능 (friend라서)
    return os;
}
main.cpp
#include "Point.h" // Point 클래스 헤더 포함

int main() {
    Point p1(1, 2);
    Point p2(3, 4);

    // + 연산자 오버로딩 사용
    Point p3 = p1 + p2; // p1.operator+(p2) 와 동일

    std::cout << "p1: " << p1 << std::endl; // 출력: p1: (1, 2)
    std::cout << "p2: " << p2 << std::endl; // 출력: p2: (3, 4)
    std::cout << "p1 + p2 = p3: " << p3 << std::endl; // 출력: p1 + p2 = p3: (4, 6)

    Point p4 = p3 + Point(10, 20); // 임시 객체와도 연산 가능
    std::cout << "p3 + (10,20) = p4: " << p4 << std::endl; // 출력: p3 + (10,20) = p4: (14, 26)

    return 0;
}

단항 연산자 오버로딩 (- (부정) 연산자 예시)

하나의 피연산자를 가지는 단항 연산자(예: +, -, !, ++, --)는 멤버 함수로 오버로딩할 때 매개변수가 필요 없습니다.

예시 2: Point 클래스에 단항 - (부정) 연산자 오버로딩
Point.h
// ... (기존 Point 클래스 내용) ...
class Point {
    // ... (기존 멤버 변수 및 생성자, getter) ...
public:
    // 멤버 함수로 + 연산자 오버로딩
    Point operator+(const Point& other) const;

    // 단항 - 연산자 오버로딩 (멤버 함수)
    Point operator-() const; // 매개변수 없음

    friend std::ostream& operator<<(std::ostream& os, const Point& p);
};
Point.cpp
#include "Point.h"

// 단항 - 연산자 오버로딩 정의
// 현재 Point 객체의 각 좌표를 음수로 바꾸어 새로운 Point 객체를 반환
Point Point::operator-() const {
    return Point(-this->x, -this->y);
}

// ... (기존 + 연산자 및 << 연산자 정의) ...
main.cpp
#include "Point.h"

int main() {
    Point p1(1, 2);
    std::cout << "p1: " << p1 << std::endl; // 출력: p1: (1, 2)

    Point p_neg = -p1; // 단항 - 연산자 오버로딩 사용 (p1.operator-() 와 동일)
    std::cout << "-p1: " << p_neg << std::endl; // 출력: -p1: (-1, -2)

    return 0;
}

입출력 연산자 오버로딩 (<<, >> 연산자)

스트림 입출력 연산자 (<< 출력, >> 입력)는 특별한 경우입니다.

이 연산자들은 항상 비멤버 함수(전역 함수)로 오버로딩되어야 합니다.

그 이유는 좌측 피연산자가 std::ostream (for <<) 또는 std::istream (for >>) 객체이기 때문입니다.

멤버 함수로 오버로딩하면 좌측 피연산자가 항상 클래스 객체 자신(this)이 되어야 하므로 std::cout << myObject와 같은 문법을 지원할 수 없습니다.

<< 연산자 오버로딩은 일반적으로 friend 함수로 선언하여 클래스의 private 멤버에 접근할 수 있도록 합니다.

연산자를 멤버 함수로 둘지 전역 함수로 둘지는 좌측 피연산자가 어떤 타입인지부터 보면 빠르게 결정할 수 있습니다.

입출력 연산자 오버로딩 형식
// << (출력) 연산자 오버로딩
std::ostream& operator<<(std::ostream& os, const 클래스이름& 객체) {
    // os에 객체의 내용을 출력
    return os; // 연속적인 출력을 위해 os 참조자를 반환
}

// >> (입력) 연산자 오버로딩
std::istream& operator>>(std::istream& is, 클래스이름& 객체) {
    // is에서 데이터를 읽어 객체에 저장
    return is; // 연속적인 입력을 위해 is 참조자를 반환
}

Point 클래스의 Point.hPoint.cpp에 이미 << 연산자 오버로딩이 구현되어 있습니다.


연산자 오버로딩 시 주의사항

  • 모든 연산자를 오버로딩할 수 있는 것은 아닙니다. (. 멤버 접근, :: 스코프 결정, sizeof, ?: 삼항 연산자 등은 오버로딩 불가)
  • 연산자의 피연산자 개수(arity)를 변경할 수 없습니다. 예를 들어, 이항 연산자를 단항 연산자로 오버로딩할 수 없습니다.
  • 연산자의 우선순위나 결합 법칙을 변경할 수 없습니다.
  • 의미를 명확히 유지: 연산자를 오버로딩할 때는 원래 연산자가 가지는 일반적인 의미를 벗어나지 않도록 주의해야 합니다. 예를 들어, + 연산자가 두 수를 더하는 의미가 아니라, 두 객체를 전혀 관계없는 방식으로 빼기를 수행하도록 오버로딩하면 코드의 가독성과 이해도를 해칠 수 있습니다.
  • 일관성 유지: 연관된 연산자들(예: ++=, ==!=)은 일관성 있게 동작하도록 구현해야 합니다.
  • 성능 고려: 불필요한 객체 복사를 피하기 위해 인자를 const & (상수 참조)로 받고, 불필요한 오버로딩은 피하는 것이 좋습니다.

복합 대입 연산자와 이항 연산자의 일관성 (+=+)

연산자 오버로딩 기초는 객체 경계, 접근 권한, 생성·소멸 책임을 기준으로 확인합니다.

연산의 의미가 구현 위치를 결정한다

C++ · FLOWCHART

연산의 의미가 구현 위치를 결정한다

변경 여부와 대칭 변환, 공개 API만으로 구현 가능한지를 차례로 판정한다.

연산의 의미가 구현 위치를 결정한다왼쪽 피연산자를 변경하면 멤버 T& 반환으로, 변경하지 않으면 대칭성과 접근 경계를 검토해 일반 비멤버 또는 필요한 함수만 friend로 분기한다. 아니요 아니요 OP연산 의미MUTATE왼쪽을 바꾸는가SYMMETRY양쪽 변환이 필요한가ACCESS비공개 접근이 필요한가FRIEND필요한 함수만 friend멤버 T& 반환예: 복합 대입일반 비멤버비공개 접근 불필요
  1. 연산 의미

    값 변경 여부를 먼저 정한다.

  2. 왼쪽을 바꾸는가

    예: 복합 대입은 멤버로 두고 T&를 반환한다.

  3. 양쪽 변환이 필요한가

    예: 새 값을 만드는 대칭 연산은 비멤버를 검토한다.

  4. 비공개 접근이 필요한가

    아니요면 일반 비멤버, 예라면 해당 함수만 friend로 둔다.

  5. 일관된 계약

    참조·새 값·const·noexcept를 실제 연산에 맞춘다.

변경 여부와 대칭 변환, 공개 API만으로 구현 가능한지를 차례로 판정한다.

실전에서는 +를 직접 구현하기보다, +=를 먼저 구현하고 +는 이를 재사용하도록 만드는 패턴이 많이 사용됩니다.

이 패턴의 장점은 다음과 같습니다.

  • 로직 중복 감소
  • ++=의 동작 일관성 유지
  • 유지보수 편의성 향상
+= 기반으로 + 구현하기
#include <iostream>

class Vec2 {
private:
    int x;
    int y;

public:
    Vec2(int _x = 0, int _y = 0) : x(_x), y(_y) {}

    // 핵심 로직은 +=에 모은다
    Vec2& operator+=(const Vec2& rhs) {
        x += rhs.x;
        y += rhs.y;
        return *this; // 연쇄 대입 지원
    }

    // +는 복사본을 만든 뒤 +=를 호출
    Vec2 operator+(const Vec2& rhs) const {
        Vec2 temp(*this);
        temp += rhs;
        return temp;
    }

    friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
        os << "(" << v.x << ", " << v.y << ")";
        return os;
    }
};

int main() {
    Vec2 a(1, 2), b(3, 4);
    Vec2 c = a + b; // (4, 6)
    a += b;         // a도 (4, 6)

    std::cout << "c = " << c << "\n";
    std::cout << "a = " << a << "\n";
}

대입 연산자 오버로딩 (operator=)

operator=는 클래스가 자원을 직접 소유할 때 특히 중요합니다.

  • 복사 대입 연산자: 깊은 복사(Deep Copy) 정책이 필요할 수 있습니다.
  • 이동 대입 연산자: 불필요한 복사를 줄여 성능을 높일 수 있습니다.
복사/이동 대입 연산자 형식
class MyType {
public:
    MyType& operator=(const MyType& rhs);      // Copy Assignment
    MyType& operator=(MyType&& rhs) noexcept;  // Move Assignment
};

대입 연산자는 자기 자신에 대한 대입(self-assignment)을 안전하게 처리해야 하며, 이동 대입은 가능한 경우 noexcept를 붙이는 것이 권장됩니다.

복사/이동 생성자와 대입 연산자의 전체 설계는 ‘Rule of Three, Five, Zero 실전’에서 구체적으로 다룹니다.


전위/후위 증감 연산자 오버로딩 (++obj, obj++)

전위와 후위는 시그니처가 다릅니다.

  • 전위 ++obj: operator++() (매개변수 없음)
  • 후위 obj++: operator++(int) (구분용 더미 int)
전위/후위 ++ 오버로딩 예시
#include <iostream>

class Counter {
private:
    int value;

public:
    Counter(int v = 0) : value(v) {}

    // 전위 ++ : 증가 후 자신 반환
    Counter& operator++() {
        ++value;
        return *this;
    }

    // 후위 ++ : 이전 값 복사본 반환
    Counter operator++(int) {
        Counter old(*this);
        ++value;
        return old;
    }

    int get() const { return value; }
};

int main() {
    Counter c(10);

    Counter a = ++c; // c=11, a=11
    Counter b = c++; // c=12, b=11

    std::cout << "a: " << a.get() << ", b: " << b.get() << ", c: " << c.get() << "\n";
}

constnoexcept 설계 기준

연산자 오버로딩에서는 시그니처 설계가 품질을 좌우합니다.

  • 읽기 전용 연산 (+, -, ==, < 등)은 보통 const 멤버 함수로 선언합니다.
  • 자원을 이동만 하는 연산은 가능하면 noexcept를 붙여 컨테이너 최적화에 유리하게 만듭니다.
  • 반환 타입은 관례를 따르는 것이 좋습니다.
    • +=, =, ++(전위): T&
    • +, -, ++(후위): T (값 반환)
const와 noexcept를 적용한 시그니처 예시
class Value {
public:
    Value operator+(const Value& rhs) const;
    Value& operator+=(const Value& rhs);
    bool operator==(const Value& rhs) const noexcept;
    Value& operator=(Value&& rhs) noexcept;
};

연산자 오버로딩은 문법 자체보다 일관된 의미와 예측 가능한 동작이 더 중요합니다.


심화 연결: 다형성과 템플릿 분기

연산자 오버로딩 시그니처를 설계할 때는 단순 문법뿐 아니라 다형성 계층에서의 값/참조 전달 정책도 함께 고려해야 합니다.

  • 다형성이 필요한 기반 타입을 값으로 전달하면 객체 슬라이싱이 발생할 수 있습니다.
  • 따라서 연산자 매개변수는 const T& 형태를 기본 선택으로 두고, 정말 필요한 경우에만 값 전달을 사용합니다.

이 부분은 10장의 ‘다형성과 가상 함수’에서 다루는 객체 슬라이싱 내용과 직접 연결됩니다.

또한 템플릿 기반 유틸리티 연산에서는 if constexpr를 활용한 컴파일 타임 분기 설계가 유용합니다.

관련 내용은 14장의 ‘C++ 20 기능 소개’에서 확인할 수 있습니다.

연산자 오버로딩은 짧은 문법 뒤에 의미, 비용, 예외 가능성을 숨길 수 있으므로 기존 연산자의 기대와 맞는지 검토해야 합니다.

연산자 오버로딩은 operator 함수, const, noexcept, 복합 대입의 의미 일관성을 함께 정해야 합니다.