본문 바로가기

C#

C# Operator Overloading으로 사용자 정의 연산 구현

연산자 오버로딩은 값 의미를 가진 타입에 자연어처럼 읽히는 연산을 부여해 가독성과 도메인 표현력을 높이는 기능입니다. 예를 들어 벡터의 덧셈, 분수의 사칙연산, 금액 합산 등에서 메서드 호출보다 직관적인 코드가 됩니다.

1. 기본 문법 한눈에 보기

연산자 메서드는 반드시 해당 타입 내부에 public static으로 선언하며, 반환형과 매개변수는 연산 의미에 맞게 정의합니다. 단항 연산자는 매개변수 1개, 이항 연산자는 2개를 받습니다.

public readonly struct Vector2
{
    public double X { get; }
    public double Y { get; }
    public Vector2(double x, double y) { X = x; Y = y; }

    // 이항 연산자: 벡터 덧셈
    public static Vector2 operator +(Vector2 a, Vector2 b)
        => new Vector2(a.X + b.X, a.Y + b.Y);

    // 단항 연산자: 부호 반전
    public static Vector2 operator -(Vector2 v)
        => new Vector2(-v.X, -v.Y);
}

규칙 요약입니다. 연산자는 새로 만들 수 없고, 언어가 허용하는 목록 내에서만 오버로드합니다. 의미상 짝을 이루는 연산자(==/!=, </>, <=/>=)는 함께 구현하는 것이 좋습니다.

2. 실전: 유리수 Fraction 값 객체

도메인에 자주 등장하는 “분수” 예제로 사칙연산, 비교, 변환 연산자를 구현합니다. 불변(immutable) 값 객체로 만들고, 약분을 통해 정규화합니다.

using System;

public readonly struct Fraction : IEquatable<Fraction>, IComparable<Fraction>
{
    public long Numerator { get; }
    public long Denominator { get; }

    public Fraction(long numerator, long denominator)
    {
        if (denominator == 0)
            throw new DivideByZeroException("Denominator cannot be 0.");

        // 음수는 분자에만 모읍니다.
        if (denominator < 0)
        {
            numerator = -numerator;
            denominator = -denominator;
        }

        var g = Gcd(Math.Abs(numerator), Math.Abs(denominator));
        Numerator = numerator / g;
        Denominator = denominator / g;
    }

    private static long Gcd(long a, long b)
    {
        while (b != 0)
        {
            var t = a % b;
            a = b;
            b = t;
        }
        return Math.Abs(a);
    }

    // 사칙연산
    public static Fraction operator +(Fraction a, Fraction b)
        => new Fraction(checked(a.Numerator * b.Denominator + b.Numerator * a.Denominator),
                        checked(a.Denominator * b.Denominator));

    public static Fraction operator -(Fraction a, Fraction b)
        => new Fraction(checked(a.Numerator * b.Denominator - b.Numerator * a.Denominator),
                        checked(a.Denominator * b.Denominator));

    public static Fraction operator *(Fraction a, Fraction b)
        => new Fraction(checked(a.Numerator * b.Numerator),
                        checked(a.Denominator * b.Denominator));

    public static Fraction operator /(Fraction a, Fraction b)
    {
        if (b.Numerator == 0)
            throw new DivideByZeroException("Division by zero fraction.");
        return new Fraction(checked(a.Numerator * b.Denominator),
                            checked(a.Denominator * b.Numerator));
    }

    // 단항 부호 반전
    public static Fraction operator -(Fraction v)
        => new Fraction(-v.Numerator, v.Denominator);

    // 동등성 및 비교 연산자
    public static bool operator ==(Fraction a, Fraction b) => a.Equals(b);
    public static bool operator !=(Fraction a, Fraction b) => !a.Equals(b);

    public int CompareTo(Fraction other)
    {
        long left = checked(Numerator * other.Denominator);
        long right = checked(other.Numerator * Denominator);
        return left.CompareTo(right);
    }

    public static bool operator <(Fraction a, Fraction b) => a.CompareTo(b) < 0;
    public static bool operator >(Fraction a, Fraction b) => a.CompareTo(b) > 0;
    public static bool operator <=(Fraction a, Fraction b) => a.CompareTo(b) <= 0;
    public static bool operator >=(Fraction a, Fraction b) => a.CompareTo(b) >= 0;

    // 변환 연산자
    public static implicit operator Fraction(int value) => new Fraction(value, 1);
    public static explicit operator double(Fraction f) => (double)f.Numerator / f.Denominator;

    // 표준 메서드
    public override string ToString()
        => Denominator == 1 ? Numerator.ToString() : $"{Numerator}/{Denominator}";

    public bool Equals(Fraction other)
        => Numerator == other.Numerator && Denominator == other.Denominator;

    public override bool Equals(object? obj)
        => obj is Fraction f && Equals(f);

    public override int GetHashCode()
        => HashCode.Combine(Numerator, Denominator);
}

3. 사용 예

암시적/명시적 변환, 동등성, 정렬 비교 등을 확인합니다.

var a = new Fraction(1, 3);
var b = new Fraction(2, 5);
var sum = a + b;            // 1/3 + 2/5 = 11/15
var mul = a * b;            // 2/15
var neg = -a;               // -1/3

Console.WriteLine($"{a} + {b} = {sum}");
Console.WriteLine($"{a} * {b} = {mul}");
Console.WriteLine($"-({a}) = {neg}");

Console.WriteLine(sum == new Fraction(11, 15));  // True
Console.WriteLine(a < b);                        // True

Fraction c = 2;                // implicit: 2/1
double d = (double)(a / b);    // explicit: 5/6 ≈ 0.8333...
Console.WriteLine($"c = {c}, d ≈ {d}");

4. 베스트 프랙티스

의미가 분명한 값 타입에만 오버로딩합니다. 코드가 더 읽히지 않는다면 메서드가 낫습니다.

짝 연산자는 반드시 함께 구현합니다. ==와 !=, <와 >, <=와 >=가 일관되게 동작해야 합니다.

값 의미라면 불변으로 설계합니다. readonly struct 또는 읽기 전용 속성으로 외부에서 상태가 바뀌지 않게 합니다.

Equals, GetHashCode, IEquatable, IComparable을 함께 구현해 컬렉션과 정렬에서 일관된 동작을 보장합니다.

암시적/명시적 변환은 신중히 사용합니다. 손실 없는 변환만 implicit로 두고, 정보 손실이나 비용이 큰 변환은 explicit로 둡니다.

오버플로와 도메인 제약을 확인합니다. 분모 0, 범위 초과 등은 예외로 방어하며, 필요 시 checked 컨텍스트를 사용합니다.

5. 무엇을 오버로드할 수 있나요?

허용: +, -, !, ~, ++, --, *, /, %, &, |, ^, <<, >>, ==, !=, <, >, <=, >=, true, false, 그리고 explicit/implicit 변환 연산자입니다. &&, ||는 직접 오버로드하지 못하고 true/false 및 &, |를 통해 단락 평가 의미를 제어합니다.

불가: =, ?:, ., new, sizeof, typeof, nameof, as, is, checked/unchecked, => 등은 오버로드할 수 없습니다.

6. 마치며

연산자 오버로딩은 “사용자 정의 연산”을 통해 모델을 더 자연스럽게 표현하도록 돕습니다. 도메인 의미가 명확하고 불변/일관성 규칙을 지킨다면, 간결하면서도 안전한 API를 만들 수 있습니다. 위 Fraction 예제를 자신의 값 객체(금액, 좌표, 단위 등)에 맞게 응용해 보시기 바랍니다.