1. 상속에서의 오류
상속은 OOP의 핵심이지만 초보 개발자가 가장 자주 실수하는 주제이기도 합니다. 고전적인 실수들을 살펴보고 이를 피하는 방법을 배워봅시다.
기본(베이스) 클래스 생성자 호출 누락 (super(...))
하위 클래스를 만들 때는 기본 클래스가 생성자를 통해 특정 초기화를 요구할 수 있다는 점을 기억해야 합니다. 기본 클래스에 기본 생성자(매개변수 없음)가 없다면, 하위 클래스의 생성자에서 반드시 super(...)를 사용해 기본 클래스의 생성자를 명시적으로 호출해야 합니다.
오류 예시:
class Animal {
private String name;
public Animal(String name) {
this.name = name;
}
}
class Dog extends Animal {
// 오류! Animal에는 기본 생성자가 없습니다
public Dog() {
// super(); // 컴파일러가 super()를 자동으로 넣지만, 그런 생성자가 없습니다!
}
}
해결 방법:
class Dog extends Animal {
public Dog(String name) {
super(name); // 정상입니다!
}
}
설명:
기본 클래스에 매개변수가 있는 생성자만 있는 경우, 컴파일러는 기본 생성자를 자동으로 추가하지 않습니다. 이는 컴파일 오류의 흔한 원인입니다.
final 클래스 상속 또는 final 메서드 재정의 시도
Java에서는 클래스나 메서드를 final로 선언할 수 있습니다. 이는 다음을 의미합니다:
- 클래스를 상속할 수 없습니다.
- 하위 클래스에서 메서드를 재정의(override)할 수 없습니다.
오류 예시:
final class Cat {}
// 컴파일 오류!
class Tiger extends Cat {
// ...
}
class Animal {
public final void sleep() {
System.out.println("Zzz...");
}
}
class Dog extends Animal {
// 컴파일 오류!
@Override
public void sleep() {
System.out.println("Dog is sleeping...");
}
}
설명:
"cannot inherit from final", "cannot override final method" 같은 오류가 보이면, 한 번 더 한정자(modifier)를 확인하세요!
리스코프 치환 원칙 (Liskov Substitution Principle) 위반
이름은 거창하지만, 실제 의미는 이렇습니다: 하위 클래스의 객체는 기본 클래스의 객체처럼 동작해야 하며, 프로그램의 논리를 깨뜨리면 안 됩니다. 흔한 실수는 메서드를 재정의하면서 기본 클래스의 기대와 다르게 동작하도록 만드는 것입니다.
예시:
class Bird {
public void fly() {
System.out.println("나는 날아!");
}
}
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날지 않는다!");
}
}
문제는 무엇일까요?
Bird로 동작하는 코드는 모든 새가 날 수 있다고 가정합니다. 그러나 Penguin을 전달하면 프로그램이 망가질 수 있습니다.
더 나은 방법:
이런 경우에는 상속 구조를 재고하거나 인터페이스/컴포지션을 사용하는 것이 좋습니다.
2. 메서드 오버로딩 관련 오류 (overloading)
오버로딩은 한 클래스 안에 동일한 이름이지만 매개변수가 다른 메서드가 여러 개 있는 경우를 말합니다. 단순해 보이지만, 여기에도 함정이 있습니다.
재정의 대신 오버로딩(시그니처 오류)
초보자는 기본 클래스의 메서드를 재정의(override)하고 싶다가도 매개변수를 실수로 바꾸곤 합니다. 그 결과 재정의가 아니라 오버로딩이 되며, 다형성이 동작하지 않습니다!
오류 예시:
class Animal {
public void makeSound() {
System.out.println("Some sound");
}
}
class Dog extends Animal {
// 재정의하려다가 오버로딩이 되어버렸습니다!
public void makeSound(String extra) {
System.out.println("Bark! " + extra);
}
}
문제:
dog.makeSound() 호출은 새로 만든 메서드가 아니라 부모의 메서드를 호출합니다.
dog.makeSound("loudly") 호출은 오버로드된 메서드를 호출하지만, 다형성은 동작하지 않습니다!
Best practice:
@Override 애노테이션을 사용하세요. 시그니처를 틀리면 컴파일러가 즉시 알려줍니다.
@Override
public void makeSound() { /* ... */ }
오버로딩 시 비직관적 동작(자동 형 변환, 호출 모호성)
Java는 매개변수가 여러 오버로드와 일치할 때, 여러분이 기대하지 않은 메서드를 “선택”할 수 있습니다.
public class OverloadDemo {
public void print(int x) {
System.out.println("int: " + x);
}
public void print(double x) {
System.out.println("double: " + x);
}
}
OverloadDemo demo = new OverloadDemo();
demo.print(5); // int: 5
demo.print(5.0); // double: 5.0
demo.print(5L); // long -> double: double: 5.0
문제:
demo.print(5L)을 호출하면 Java는 print(double x)를 선택합니다(long은 int보다 double로의 변환이 더 적합하다고 보기 때문).
매개변수가 Object, Integer, int인 메서드가 함께 있으면, null로 호출할 때 컴파일 오류 “reference to print is ambiguous”가 발생할 수 있습니다.
반환 타입만 다른 동일한 메서드 이름 사용(컴파일 오류)
Java에서는 동일한 이름과 매개변수 목록을 가지면서 반환 타입만 다른 두 메서드를 선언할 수 없습니다!
public class Demo {
// 컴파일 오류!
public int foo() { return 1; }
public String foo() { return "hello"; }
}
설명:
오버로딩에서 메서드의 시그니처는 이름 + 매개변수입니다. 반환 타입은 시그니처에 포함되지 않습니다. 따라서 컴파일러는 어느 메서드를 호출해야 하는지 알 수 없습니다.
3. 모범 사례
상속과 오버로딩에서 실수하지 않으려면 다음 권장 사항을 따르세요.
재정의하는 메서드에는 항상 @Override 애노테이션을 사용하세요
가독성을 높여줄 뿐만 아니라 시그니처 오류를 막아줍니다. 매개변수나 메서드 이름을 실수로 바꾸면 컴파일러가 즉시 알려줍니다.
@Override
public void makeSound() {
System.out.println("Bark!");
}
오버로딩과 오버라이딩을 명확히 구분하세요
- 오버라이딩(override): 부모 메서드의 동작을 바꿉니다 — 시그니처가 일치해야 합니다.
- 오버로딩(overload): 같은 이름이지만 매개변수가 다른 새로운 메서드를 추가합니다.
비교 표:
| 오버로딩 (overloading) | 오버라이딩 (overriding) | |
|---|---|---|
| 어디서 | 같은 클래스/계층 내 | 하위 클래스 |
| 메서드 이름 | 동일 | 동일 |
| 매개변수 | 다름 | 동일 |
| 반환 타입 | 달라도 됨 | 동일/공변이어야 함 |
| 애노테이션 | 필수 아님 | @Override 권장 |
오버로딩을 남용하지 마세요
오버로드가 너무 많으면 코드는 읽기 어렵고 혼란스러워집니다. 경우의 수가 많다면 파라미터 객체나 Builder 패턴을 사용하는 것이 좋습니다.
4. 예시: 반려동물 관리 시스템
간단한 반려동물 관리 시스템을 만든다고 가정해봅시다. 기본 클래스 Pet과 하위 클래스 Cat, Dog가 있습니다.
public class Pet {
private String name;
public Pet(String name) {
this.name = name;
}
public void speak() {
System.out.println(name + "는 알 수 없는 소리를 냅니다.");
}
}
public class Cat extends Pet {
public Cat(String name) {
super(name);
}
@Override
public void speak() {
System.out.println(getName() + "가 말합니다: 야옹!");
}
// 오류: Pet에 getName() 메서드가 없습니다!
}
전형적인 실수:
메서드를 재정의하려다가 기본 클래스에 없는 메서드를 호출하고 있습니다. 게터를 추가하는 것이 좋습니다:
public class Pet {
private String name;
public Pet(String name) { this.name = name; }
public String getName() { return name; }
public void speak() { System.out.println(name + "는 알 수 없는 소리를 냅니다."); }
}
이제 제대로 동작하고, 다형성을 사용할 수 있습니다:
Pet myPet = new Cat("바르식");
myPet.speak(); // 바르식이 말합니다: 야옹!
5. 상속과 오버로딩에서 흔한 실수
오류 №1: super(...) 호출을 잊음.
기본 클래스 생성자에 중요한 로직이 있는데 호출하지 않으면, 프로그램이 예기치 않게 동작하거나 아예 컴파일되지 않을 수 있습니다.
오류 №2: 엉뚱한 메서드를 재정의함.
부모 메서드의 동작을 바꾸려 했지만, 비슷한 이름이나 다른 매개변수로 새로운 메서드를 추가해버렸습니다. 결과: 기존 메서드는 그대로 동작하고, 새 메서드는 아무도 호출하지 않습니다.
오류 №3: final 메서드를 재정의하려 함.
Java는 이를 허용하지 않습니다. 컴파일 오류가 보인다면 final을 확인하세요.
오류 №4: 메서드를 과도하게 오버로딩함.
calculate 메서드가 10가지나 되고, 어느 것이 호출되는지 본인도 헷갈린다면 — 리팩터링을 고민해야 할 때입니다.
오류 №5: 리스코프 원칙을 위반함.
하위 클래스가 기본 클래스의 의미를 바꿔버리면 전체 아키텍처가 흔들릴 수 있습니다. 예를 들어 Shape 클래스에 getArea()가 있고, 하위 클래스 BrokenShape가 -1을 반환한다면, 이상한 버그로 이어질 수 있습니다.
GO TO FULL VERSION