52. 다중정의는 신중히 사용하라

📝 아이템 52: 다중정의는 신중히 사용하라

🔹 핵심 요약

✅ 다중정의(오버로딩)는 컴파일 타임에 어떤 메서드를 호출할지 결정됨 ✅ 재정의(오버라이딩)는 런타임에 인스턴스 타입에 따라 결정됨 ✅ 다중정의는 혼란을 일으키기 쉬우므로 신중하게 사용해야 함 ✅ 매개변수 개수가 같은 다중정의는 피하는 것이 좋음


📚 필수 개념 정리

💡 다중정의(Overloading)

  • 같은 이름의 메서드를 여러 개 정의하는 것

  • 매개변수 타입이나 개수가 달라야 함

  • 컴파일 타임에 어떤 메서드를 호출할지 결정됨(정적 바인딩)

💡 재정의(Overriding)

  • 상위 클래스의 메서드를 하위 클래스에서 새로 정의하는 것

  • 메서드 시그니처(이름, 매개변수 타입, 반환 타입)가 동일해야 함

  • 실행 시점에 객체의 실제 타입에 따라 메서드가 결정됨(동적 바인딩)

🧩 Java의 바인딩 정리: 오버로딩, 오버라이딩, static, 필드 접근

바인딩(binding)코드 속의 이름(변수, 함수 등) 이 실제로 어떤 대상(값, 객체, 메서드 구현 등) 과 연결되는 과정

이 연결이 결정되는 시점에 따라 정적 바인딩과 동적 바인딩으로 나뉨

구분
기준이 되는 것
설명

오버로딩

참조 변수의 타입

같은 이름의 메서드가 여러 개 있을 때, 컴파일 시점에 참조 변수 타입을 기준으로 결정됨

오버라이딩

실제 객체의 타입

상속 관계에서 메서드를 재정의하면, 실행 시점에 실제 객체의 타입을 기준으로 결정됨

필드 접근

참조 변수의 타입

필드는 오버라이딩되지 않으며, 컴파일 시점에 참조 변수 타입에 따라 바인딩됨

static 메서드

참조 변수의 타입

static 메서드는 클래스에 귀속되므로 오버라이딩되지 않고, 컴파일 시점에 타입으로 결정됨

class Parent {
    void greet() {
        System.out.println("👋 Hello from Parent");
    }

    void talk(String msg) {
        System.out.println("💬 Parent says: " + msg);
    }
}

class Child extends Parent {
    @Override
    void greet() {
        System.out.println("👋 Hello from Child");
    }

    // 오버로딩된 메서드
    void talk(int count) {
        System.out.println("💬 Child talks " + count + " times");
    }
}

public class Main {
    public static void main(String[] args) {
        Parent p = new Child();  // p는 Parent 타입 참조
        Child c = new Child();   // c는 Child 타입 참조

        // 오버라이딩 → 동적 바인딩, 실제 객체 타입(Child)에 따라 메서드 결정
        p.greet();  // Child의 greet()
        c.greet();  // Child의 greet()

        // 오버로딩 → 정적 바인딩, 참조 변수 타입(Parent, Child)에 따라 메서드 결정
        p.talk("Hi");     // Parent의 talk(String)
        // p.talk(3);     // 컴파일 오류! Parent 타입엔 talk(int) 없음
        c.talk("Hello");  // Parent의 talk(String)
        c.talk(3);        // Child의 talk(int)
    }
}

🔑 다중정의와 재정의의 차이

  • 재정의된 메서드는 런타임에 실제 객체 타입에 의해 결정됨

  • 다중정의된 메서드는 컴파일 타임의 참조 타입에 의해 결정됨

  • 이로 인해 다중정의는 직관과 다른 동작을 할 수 있음


🚨 다중정의의 위험성

이 코드의 결과는?

public class CollectionClassifier {
    // 다중정의된 메서드들
    public static String classify(Set<?> s) {
        return "집합";
    }

    public static String classify(List<?> l) {
        return "리스트";
    }

    public static String classify(Collection<?> c) {
        return "그 외";
    }

    public static void main(String[] args) {
        Collection<?>[] collections = {
            new HashSet<String>(),
            new ArrayList<BigInteger>(),
            new HashMap<String, String>().values()
        };

        for (Collection<?> c : collections)
            System.out.println(classify(c)); // 결과는?
    }
}

→ 결과는 그 외 그 외 그 외

👉 왜 이런 결과가 나올까?

  • 다중정의된 메서드는 컴파일 타임의 참조 타입에 의해 결정됨

  • for문에서 c의 컴파일 타임 타입은 항상 Collection<?>

  • 따라서 항상 classify(Collection<?>) 메서드가 호출됨

🔍 List 인터페이스의 remove 메서드와 자동 형변환의 영향

public class ListRemoveExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);

        int num1 = 1;
        Integer num2 = 1;

        // 다음 두 remove는 다르게 동작
        numbers.remove(num1);      // 인덱스 1의 요소를 제거
        numbers.remove(num2);      // 값이 1인 요소를 제거
        System.out.print(numbers); // 결과는?
    }
}

👉 [3] : 오토박싱 및 언박싱은 다중정의 해석을 더 복잡하게 만듦 ⚠️ List 인터페이스에는 remove() 메서드가 다중 정의(overloading)

E remove(int index);         // 인덱스를 받아 해당 위치의 요소를 제거
boolean remove(Object o);    // 객체를 받아 같은 값을 가진 요소 제거
  • remove(num1) → int 타입 → 인덱스로 동작 (1번 위치의 요소 제거)

  • remove(num2) → Integer → Object로 인식되어 값이 1인 요소 제거

🔥 람다와 다중정의 문제

// 다중정의된 메서드
public static void execute(Runnable r) { r.run(); }
public static void execute(Callable<String> c) throws Exception { c.call(); }

// 호출 예시
execute(() -> { System.out.println("무엇이 실행될까요?"); }); // 모호함

👉 람다 표현식은 여러 함수형 인터페이스에 적용될 수 있어 오버로딩된 메서드에서는 애매함 발생 ⚠️ 이 경우 컴파일 에러가 발생하므로, 명시적 캐스팅이 필요함

execute((Runnable) () -> { System.out.println("Runnable입니다!"); });

🛠️ 메서드 참조와 다중정의 문제

// 다중정의된 메서드
static class Processor {
    public static int process(Object obj) { return 1;}
    public static int process(String s) { return 2; }
}

// 호출 예시
Function<String, Integer> func = Processor::process; // 어떤 메서드가 참조될까?
System.out.println(func.apply("hello")); // 몇이 출력될까요?

👉 메서드 참조 역시 다중정의 시 예측하기 어려운 동작 가능성


🧪 해결책

🔧 메서드 하나로 통합

모두 Collection 타입으로 배열에 담겼기 때문에 classify(c)는 항상 Collection 버전만 호출되던 문제

instanceof를 사용해서 런타임 시점에 실제 객체 타입을 검사, 동적 바인딩처럼 동작하게 유도

public static String classifyBetter(Collection<?> c) {
    if (c instanceof Set)
        return "집합";
    else if (c instanceof List)
        return "리스트";
    else
        return "그 외";
}

🔒 안전한 다중정의 방법

  1. 매개변수 수가 확연히 다르게 하기

  2. 매개변수 타입이 근본적으로 다르게 하기

    • 두 타입의 값이 서로 형변환 불가능해야 함

    • 예: String과 StringBuilder

  3. 메서드 이름을 다르게 하기

    • 예: ObjectOutputStream의 writeBoolean, writeInt, writeLong 등

🧪 다중정의 대신 메서드 이름 구분하기

// 메서드 이름으로 구분하는 예
public class ObjectStreamMethods {
    // 다중정의 대신 이름으로 구분
    public void writeBoolean(boolean b) { /* ... */ }
    public void writeInt(int i) { /* ... */ }
    public void writeLong(long l) { /* ... */ }
}

🎯 결론

📍 매개변수 수가 같은 다중정의는 가능한 피하자

📍 다중정의하는 대신 메서드 이름을 다르게 하는 것이 안전함

📍 생성자는 이름을 다르게 할 수 없으므로 정적 팩터리 메서드 활용

📍 다중정의 시 각 메서드가 완전히 독립적으로 동작하도록 구현


💭 느낀 점

💡 이번 아이템을 통해 단순히 문법적으로 다중정의랑 재정의를 구분하는 걸 넘어서, 실제 코드가 어떤 방식으로 바인딩되고 실행되는지를 조금 더 명확히 이해

💡 다중정의(overloading)는 자바가 정적 바인딩을 택하는 특성 때문에, 개발자의 직관과 다른 동작을 할 가능성이 있다는 점에서 매우 위험할 수 있음을 체감

💡 단순히 "오류 없이 잘 작동하는 코드"를 쓰는 게 중요한 게 아니라, 읽는 사람이 직관적으로 이해할 수 있는지, 그리고 실제 동작이 그 기대를 배신하지 않는지가 더 중요하다는 사실을 다시 확인

Last updated