1. 메서드 재정의
현실에서도 ‘자식’이 특별하게 행동하는 경우가 많습니다. 예를 들어 모든 동물은 소리를 내지만, 고양이는 “야옹”, 개는 “멍멍”, 프로그래머는 “어, 또 버그!”라고 하죠. 프로그래밍에서는 이런 차이를 메서드 재정의(override)로 구현합니다.
메서드 재정의는 상위 클래스에 이미 선언된 메서드에 대해 하위 클래스가 자신만의 구현을 제공하는 것을 말합니다. 즉, 기본 동작을 보다 구체적인 자신의 동작으로 ‘바꿔치기’하는 것입니다.
비유. 부모 클래스를 보르시(러시아 수프)의 레시피라고 생각해 보세요. 메서드 재정의는 할머니가 거기에 비밀 재료를 더하는 것과 같습니다. 보르시는 여전히 보르시지만, 맛은 집집마다 다릅니다.
메서드를 재정의하려면 자식 클래스에서 부모와 정확히 같은 시그니처(이름, 매개변수, 반환 타입)를 갖는 메서드를 선언해야 합니다.
예시: 동물과 그 소리
class Animal {
void makeSound() {
System.out.println("Some generic animal sound");
}
}
class Dog extends Animal {
// makeSound() 메서드를 재정의
void makeSound() {
System.out.println("Woof!");
}
}
class Cat extends Animal {
// makeSound() 메서드를 재정의
void makeSound() {
System.out.println("Meow!");
}
}
이제 Dog 객체를 생성해 makeSound()를 호출하면 "Woof!"가 출력되고, "Some generic animal sound"는 출력되지 않습니다.
코드로 데모
public class Main {
public static void main(String[] args) {
Animal generic = new Animal();
Dog dog = new Dog();
Cat cat = new Cat();
generic.makeSound(); // Some generic animal sound
dog.makeSound(); // Woof!
cat.makeSound(); // Meow!
}
}
중요: 하위 클래스에 같은 시그니처의 메서드가 없다면 부모의 메서드가 사용됩니다.
2. 애너테이션 @Override: 왜 필요하고 어떻게 쓰나
Java에서는 재정의한 메서드에 특별한 애너테이션 @Override를 붙이는 것이 관례입니다. 이것은 단순한 장식이 아니라 유용한 도구입니다:
- 컴파일러가 확인합니다. 정말로 부모의 메서드를 재정의했는지 검사하며, 이름이나 매개변수 타입, 반환 타입을 틀리면 컴파일 오류가 발생합니다.
- 코드 가독성이 향상됩니다. 다른 개발자도 곧바로 “아, 이 메서드는 부모 것을 재정의했구나”라고 알 수 있습니다.
@Override 예시
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Woof!");
}
}
만약 실수로 void makeSond()처럼(오타!) 작성하고 @Override를 붙였다면, 컴파일러가 다음과 같이 경고합니다: "Method does not override or implement a method from a supertype".
현대적인 표준. @Override를 사용하는 것은 좋은 관례이자 업계 표준입니다. 컴파일러가 요구하지 않더라도 항상 이 애너테이션을 붙이세요 — 본인도, 동료들도 더 편해집니다.
3. 재정의된 메서드는 어떻게 호출되나
하위 클래스의 객체에서 메서드를 호출하면, 변수 타입이 상위 타입으로 선언되어 있어도 하위 클래스의 구현이 사용됩니다.
예시: 다형성의 작동
Animal animal = new Dog();
animal.makeSound(); // "Woof!"가 호출됨, "Some generic animal sound"가 아님
여기서 변수의 타입은 Animal이지만 실제로는 Dog 객체가 들어 있습니다. Java는 Dog에 있는 재정의된 메서드를 호출해야 함을 ‘알아챕니다’. 이것이 바로 다형성입니다(자세한 내용은 다음 강의에서).
4. 재정의의 제한과 규칙
메서드 시그니처
- 이름, 타입, 매개변수 순서가 부모의 메서드와 동일해야 합니다.
- 반환 타입은 동일하거나 공변적(부모 반환 타입의 하위 타입)이어야 합니다. 예를 들어, 부모가 Animal을 반환하고 자식이 Dog를 반환하는 것은 허용됩니다.
접근 제한자
- 부모보다 접근 범위를 더 좁힐 수 없습니다.
- 부모 메서드가 public이면, 재정의 메서드도 public이어야 합니다.
- 부모가 protected이면, 재정의 메서드는 protected 또는 public일 수 있습니다.
이를 거꾸로 시도하면 컴파일러가 이렇게 말합니다: "Cannot reduce the visibility of the inherited method".
예외
- 재정의된 메서드는 부모 선언에 없는 새로운 checked 예외를 던질 수 없습니다.
- 부모보다 적은 예외를 던지거나, 부모가 선언한 예외의 하위 타입을 던질 수는 있습니다.
static, final, private
- static이나 final로 선언된 메서드, 그리고 private 메서드는 재정의할 수 없습니다.
- static — 재정의가 아니라 숨김(hiding)입니다.
- final — 아예 재정의할 수 없으며, Java가 이러한 메서드를 보호합니다.
- private — 하위 클래스에서 보이지 않으므로 재정의할 수 없습니다(같은 이름의 새로운 메서드를 선언할 수는 있습니다).
생성자
생성자는 상속되지도 재정의되지도 않습니다. 각 클래스는 자신의 생성자를 가집니다.
5. 학습용 애플리케이션 “동물원” 확장하기
이제 이론을 실전에 적용해 봅시다! 우리의 ‘동물원’ 애플리케이션을 계속 발전시켜 봅시다.
1단계. 기본 클래스 Animal
public class Animal {
public void makeSound() {
System.out.println("Some generic animal sound");
}
public void sleep() {
System.out.println("Zzz...");
}
}
2단계. 하위 클래스 Dog와 Cat
public class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof!");
}
// Dog에만 있는 추가 메서드
public void fetch() {
System.out.println("Dog brings the stick!");
}
}
public class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow!");
}
// Cat에만 있는 추가 메서드
public void scratch() {
System.out.println("Cat scratches the sofa!");
}
}
3단계. 재정의 활용하기
public class ZooTest {
public static void main(String[] args) {
Animal generic = new Animal();
Animal dog = new Dog();
Animal cat = new Cat();
generic.makeSound(); // Some generic animal sound
dog.makeSound(); // Woof!
cat.makeSound(); // Meow!
// dog.fetch(); // 오류! Animal 타입 변수는 fetch()를 모릅니다
// cat.scratch(); // 마찬가지
// 하지만 타입을 명시하면:
if (dog instanceof Dog) {
((Dog) dog).fetch(); // Dog brings the stick!
}
if (cat instanceof Cat) {
((Cat) cat).scratch(); // Cat scratches the sofa!
}
}
}
코멘트:
메서드 makeSound()는 다형적으로 동작하여 객체의 실제 클래스에 있는 버전이 호출됩니다. 반면, 특화된 메서드(fetch, scratch)는 명시적 캐스팅을 통해서만 사용할 수 있습니다 — 이는 상속과 재정의가 어떻게 동작하는지 이해하는 데 중요합니다.
6. 반환 타입 예시(공변성)
때로는 재정의된 메서드가 더 ‘좁은’ 타입을 반환하길 원할 때가 있습니다. 예:
class Animal {
Animal getFriend() {
return new Animal();
}
}
class Dog extends Animal {
@Override
Dog getFriend() { // 반환 타입 — Dog, Animal의 하위 타입
return new Dog();
}
}
이를 반환 타입의 공변성이라고 하며, Java(Java 5부터)에서 허용됩니다.
7. @Override를 사용하지 않으면 어떻게 될까?
메서드 이름이나 매개변수를 실수로 틀려도, @Override가 없으면 Java는 불평하지 않습니다. 그 결과 재정의가 아니라 새로운 메서드를 만들게 되고, 기대한 동작은 바뀌지 않습니다.
실수 예시
class Dog extends Animal {
// 오타: makeSound가 아니라 makeSoud
void makeSoud() {
System.out.println("Woof!");
}
}
public class Main {
public static void main(String[] args) {
Animal dog = new Dog();
dog.makeSound(); // "Some generic animal sound"를 출력함
}
}
@Override가 있었다면 컴파일러가 다음 오류를 냈을 것입니다: "Method does not override or implement a method from a supertype".
8. 메서드 재정의 시 흔한 실수
실수 1: @Override 애너테이션을 생략함.
이것이 없으면 메서드 이름이나 매개변수를 틀리기 쉽습니다. 그 결과 메서드가 재정의되지 않아 프로그램이 기대와 다르게 동작합니다.
실수 2: 접근 제한자를 더 좁게 만듦.
부모 메서드가 public인데 protected나 private으로 작성하면 컴파일 오류가 납니다.
실수 3: 시그니처 불일치.
매개변수가 타입 하나라도 다르면 재정의가 아니라 오버로딩(overloading)입니다.
실수 4: final 또는 static 메서드를 재정의하려 함.
Java는 이를 허용하지 않습니다. final은 재정의를 막고, static 메서드는 아예 재정의되지 않습니다(숨김만 가능).
실수 5: 호환되지 않는 반환 타입으로 변경.
부모의 반환 타입의 하위 타입(공변성)만 반환할 수 있으며, 전혀 다른 타입은 허용되지 않습니다.
GO TO FULL VERSION