Theme:

비슷한 클래스를 여러 개 만들다 보면 같은 코드가 반복되는 순간이 온다. "이거 복붙하고 있는데, 뭔가 더 좋은 방법이 없나?" — 이 질문의 답이 바로 상속과 다형성이다. 이번 글에서는 extends부터 시작해서 오버라이딩, 업/다운캐스팅, Object 클래스까지 한 번에 정리한다.

상속이란?

상속(Inheritance)은 기존 클래스의 필드와 메서드를 새로운 클래스가 물려받는 것이다. extends 키워드로 선언한다.

JAVA
// 부모 클래스 (상위 클래스, Super Class)
public class Animal {
    String name;

    void eat() {
        System.out.println(name + "이(가) 먹는다");
    }
}

// 자식 클래스 (하위 클래스, Sub Class)
public class Dog extends Animal {
    void bark() {
        System.out.println(name + "이(가) 짖는다");
    }
}
JAVA
Dog dog = new Dog();
dog.name = "바둑이";  // 부모의 필드를 그대로 사용
dog.eat();            // 부모의 메서드도 사용 가능
dog.bark();           // 자식 고유의 메서드

왜 상속이 필요한가?

  • 코드 재사용: 공통 로직을 부모에 한 번만 작성
  • 계층적 설계: "Dog은 Animal이다" 같은 관계를 코드로 표현
  • 유지보수: 공통 기능 수정 시 부모만 고치면 자식에 자동 반영

공부하다 보니 상속이 단순히 "코드 줄이기"가 아니라, 클래스 간의 관계를 설계하는 도구라는 걸 알게 됐다.

부모 생성자 호출 — super()

자식 객체를 만들면 부모 생성자가 먼저 호출된다. 이게 중요하다. 자식 객체 안에 부모 부분이 먼저 초기화돼야 하기 때문이다.

JAVA
public class Animal {
    String name;

    // 부모 생성자
    Animal(String name) {
        this.name = name;
        System.out.println("Animal 생성자 호출");
    }
}

public class Dog extends Animal {

    String breed;

    // 자식 생성자
    Dog(String name, String breed) {
        super(name);  // 부모 생성자 호출 (반드시 첫 줄에!)
        this.breed = breed;
        System.out.println("Dog 생성자 호출");
    }
}
JAVA
Dog dog = new Dog("바둑이", "진돗개");
// 출력:
// Animal 생성자 호출
// Dog 생성자 호출

super() 규칙

  • super()는 자식 생성자의 첫 줄에 와야 한다
  • 부모에 기본 생성자(매개변수 없는 생성자)가 있으면 super()를 생략해도 컴파일러가 자동으로 넣어준다
  • 부모에 매개변수 있는 생성자만 있으면 super(인자)반드시 명시해야 한다

여기서 헷갈렸는데, this()super()는 둘 다 생성자 첫 줄에 와야 하므로 동시에 쓸 수 없다. 둘 중 하나만 선택해야 한다.

메서드 오버라이딩

오버라이딩(Overriding)은 부모가 가진 메서드를 자식이 재정의하는 것이다.

JAVA
public class Animal {
    void sound() {
        System.out.println("...");
    }
}

public class Dog extends Animal {
    @Override  // 오버라이딩한다는 표시 (권장)
    void sound() {
        System.out.println("멍멍!");
    }
}

public class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("야옹!");
    }
}
JAVA
Dog dog = new Dog();
Cat cat = new Cat();
dog.sound();  // 멍멍!
cat.sound();  // 야옹!

@Override 어노테이션을 꼭 써야 하나?

문법적으로 필수는 아니지만, 반드시 쓰는 습관을 들이자.

  • 오타로 새 메서드를 만들어버리는 실수를 컴파일 시점에 잡아준다
  • soung() 같은 오타를 치면 "이 메서드는 부모에 없다"고 에러를 띄워준다

오버라이딩 규칙

  • 메서드 이름, 매개변수, 반환 타입이 부모와 동일해야 한다
  • 접근제어자를 더 좁게 바꿀 수 없다 (부모가 public이면 자식도 public)
  • 부모보다 더 넓은 예외를 던질 수 없다
  • final 메서드는 오버라이딩할 수 없다
  • static 메서드는 오버라이딩이 아니라 **숨기기(Hiding)**가 된다

부모 메서드 호출 — super.메서드()

오버라이딩하면서도 부모의 원래 동작을 유지하고 싶을 때 super를 쓴다.

JAVA
public class Dog extends Animal {
    @Override
    void sound() {
        super.sound();  // 부모의 sound() 먼저 실행
        System.out.println("멍멍!");
    }
}

오버라이딩 vs 오버로딩

이 둘은 이름이 비슷해서 자주 헷갈린다. 아래 비교표로 정리하자.

구분오버라이딩(Overriding)오버로딩(Overloading)
관계부모-자식 (상속 관계)같은 클래스 내
메서드 이름동일동일
매개변수동일반드시 다름
반환 타입동일 (공변 반환 허용)무관
결정 시점런타임 (동적 바인딩)컴파일 타임 (정적 바인딩)
목적부모 동작 재정의같은 이름으로 다양한 입력 처리
JAVA
public class Calculator {
    // 오버로딩 — 같은 이름, 다른 매개변수
    int add(int a, int b) { return a + b; }
    double add(double a, double b) { return a + b; }
    int add(int a, int b, int c) { return a + b + c; }
}

핵심 차이: 오버라이딩은 "무엇을 실행할지"가 런타임에 결정되고, 오버로딩은 컴파일 시점에 결정된다.

다형성

다형성(Polymorphism)은 하나의 타입으로 여러 형태의 객체를 다루는 것이다. 상속의 꽃이라고 할 수 있다.

JAVA
Animal animal1 = new Dog();   // 부모 타입으로 자식 객체 참조
Animal animal2 = new Cat();

animal1.sound();  // 멍멍! (Dog의 sound 실행)
animal2.sound();  // 야옹! (Cat의 sound 실행)

변수 타입은 Animal이지만, 실제 실행되는 메서드는 객체의 타입(Dog, Cat)에 따라 달라진다. 이게 다형성이다.

다형성이 왜 좋은가?

배열이나 리스트로 여러 자식 객체를 한 번에 다룰 수 있다.

JAVA
// 다형성이 없다면 — 타입마다 따로따로
Dog[] dogs = {new Dog(), new Dog()};
Cat[] cats = {new Cat(), new Cat()};

// 다형성 덕분에 — 하나의 배열로 관리
Animal[] animals = {new Dog(), new Cat(), new Dog()};
for (Animal a : animals) {
    a.sound();  // 각 객체의 오버라이딩된 메서드가 호출됨
}

메서드 매개변수에서도 위력을 발휘한다.

JAVA
// 부모 타입으로 받으면 어떤 자식이든 처리 가능
void feed(Animal animal) {
    System.out.println(animal.name + "에게 밥을 준다");
    animal.eat();
}

feed(new Dog());  // Dog도 OK
feed(new Cat());  // Cat도 OK

업캐스팅과 다운캐스팅

업캐스팅 (Upcasting)

자식 타입 → 부모 타입으로 변환하는 것. 자동으로 일어난다.

JAVA
Dog dog = new Dog();
Animal animal = dog;  // 업캐스팅 (자동)
// Animal animal = (Animal) dog;  // 명시적으로 써도 되지만 불필요

업캐스팅하면 부모 타입에 정의된 멤버만 접근 가능하다. Dog에만 있는 bark() 같은 메서드는 호출할 수 없다.

JAVA
Animal animal = new Dog();
animal.eat();    // OK — Animal에 정의돼 있으므로
animal.sound();  // OK — 오버라이딩된 Dog의 sound()가 실행됨
// animal.bark();  // 컴파일 에러 — Animal에 bark()가 없음

다운캐스팅 (Downcasting)

부모 타입 → 자식 타입으로 변환하는 것. 명시적 캐스팅이 필요하다.

JAVA
Animal animal = new Dog();  // 실제론 Dog 객체
Dog dog = (Dog) animal;     // 다운캐스팅 (명시적)
dog.bark();                 // 이제 bark() 호출 가능

위험한 점: 실제 객체가 해당 타입이 아니면 ClassCastException이 터진다.

JAVA
Animal animal = new Dog();
Cat cat = (Cat) animal;  // 런타임에 ClassCastException 발생!

instanceof로 안전하게 확인

다운캐스팅 전에 instanceof로 타입을 확인하는 게 안전하다.

JAVA
Animal animal = new Dog();

if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.bark();
}

if (animal instanceof Cat) {
    // 실행되지 않음 — animal은 Dog이므로
    Cat cat = (Cat) animal;
}

Java 16부터는 패턴 매칭 instanceof로 더 간결하게 쓸 수 있다.

JAVA
// Java 16+ 패턴 매칭
if (animal instanceof Dog dog) {
    // 캐스팅 없이 바로 dog 변수 사용 가능
    dog.bark();
}

Object 클래스

Java에서 모든 클래스는 Object를 상속받는다. extends를 쓰지 않아도 컴파일러가 자동으로 extends Object를 붙인다.

JAVA
// 이 두 선언은 완전히 동일하다
public class Dog { }
public class Dog extends Object { }

그래서 모든 객체에서 toString(), equals(), hashCode() 같은 메서드를 쓸 수 있는 것이다.

toString()

객체를 문자열로 표현한다. 오버라이딩하지 않으면 클래스이름@해시코드가 출력된다.

JAVA
public class Dog extends Animal {
    String breed;

    @Override
    public String toString() {
        return "Dog{name='" + name + "', breed='" + breed + "'}";
    }
}

Dog dog = new Dog("바둑이", "진돗개");
System.out.println(dog);  // Dog{name='바둑이', breed='진돗개'}
// 오버라이딩 안 했다면: Dog@1b6d3586 같은 형태

equals()와 hashCode()

equals()는 두 객체가 논리적으로 같은지 비교한다. 기본 구현은 ==과 동일(참조 비교)이므로, 내용 비교가 필요하면 오버라이딩해야 한다.

JAVA
public class Dog extends Animal {
    String breed;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                    // 같은 참조면 true
        if (o == null || getClass() != o.getClass()) return false;  // 타입 체크
        Dog dog = (Dog) o;
        return Objects.equals(name, dog.name) && Objects.equals(breed, dog.breed);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, breed);
    }
}

equals()를 오버라이딩하면 hashCode()도 반드시 같이 오버라이딩해야 한다. HashMap, HashSet 같은 컬렉션이 hashCode()를 기준으로 버킷을 나누기 때문이다. 이 규칙을 어기면 컬렉션에서 객체를 못 찾는 버그가 생긴다.

상속의 한계와 주의점

다중 상속 불가

Java는 클래스 다중 상속을 지원하지 않는다. 하나의 부모만 extends 할 수 있다.

JAVA
// 불가능!
public class FlyingDog extends Dog, Bird { }

왜 안 될까? 두 부모에 같은 이름의 메서드가 있으면 어느 쪽을 실행해야 할지 모호해지기 때문이다. 이를 **다이아몬드 문제(Diamond Problem)**라고 한다.

대신 인터페이스는 다중 구현이 가능하다 — 이건 다음 글에서 다룬다.

is-a 관계인지 확인하자

상속은 "자식 is a 부모" 관계일 때만 써야 한다.

  • Dog is a Animal → 상속 적절
  • Car is a Engine → 상속 부적절 (Car has a Engine)

"has-a" 관계라면 상속 대신 **구성(Composition)**을 써야 한다.

JAVA
// 나쁜 예 — 상속
public class Car extends Engine { }  // 자동차가 엔진이다? (X)

// 좋은 예 — 구성
public class Car {
    private Engine engine;  // 자동차가 엔진을 가지고 있다 (O)
}

상속 깊이를 깊게 만들지 말자

상속이 3단계, 4단계로 깊어지면 코드를 따라가기가 어려워진다. "이 메서드가 어디서 오버라이딩된 거지?" 하고 부모를 타고 올라가다 길을 잃기 쉽다.

실무에서는 상속보다 구성(Composition)을 선호하는 추세다.

실전 예제 — Shape 계층

지금까지 배운 걸 모아서 도형 클래스를 만들어보자.

JAVA
// 부모 클래스
public class Shape {
    String color;

    Shape(String color) {
        this.color = color;
    }

    // 넓이 계산 — 자식이 오버라이딩할 메서드
    double area() {
        return 0;
    }

    @Override
    public String toString() {
        return getClass().getSimpleName() + "{color='" + color + "'}";
    }
}
JAVA
// 원
public class Circle extends Shape {
    double radius;

    Circle(String color, double radius) {
        super(color);  // 부모 생성자 호출
        this.radius = radius;
    }

    @Override
    double area() {
        return Math.PI * radius * radius;
    }

    @Override
    public String toString() {
        return "Circle{color='" + color + "', radius=" + radius + "}";
    }
}
JAVA
// 직사각형
public class Rectangle extends Shape {
    double width;
    double height;

    Rectangle(String color, double width, double height) {
        super(color);
        this.width = width;
        this.height = height;
    }

    @Override
    double area() {
        return width * height;
    }

    @Override
    public String toString() {
        return "Rectangle{color='" + color + "', width=" + width + ", height=" + height + "}";
    }
}
JAVA
public class Main {
    public static void main(String[] args) {
        // 다형성 — Shape 배열에 다양한 도형 담기
        Shape[] shapes = {
            new Circle("빨강", 5.0),
            new Rectangle("파랑", 4.0, 6.0),
            new Circle("초록", 3.0)
        };

        // 전체 넓이 계산
        double totalArea = 0;
        for (Shape shape : shapes) {
            System.out.println(shape + " → 넓이: " + shape.area());
            totalArea += shape.area();
        }
        System.out.println("총 넓이: " + totalArea);

        // instanceof로 타입 확인
        for (Shape shape : shapes) {
            if (shape instanceof Circle circle) {
                System.out.println("원 발견! 반지름: " + circle.radius);
            }
        }
    }
}

출력 결과:

PLAINTEXT
Circle{color='빨강', radius=5.0} → 넓이: 78.53981633974483
Rectangle{color='파랑', width=4.0, height=6.0} → 넓이: 24.0
Circle{color='초록', radius=3.0} → 넓이: 28.274333882308138
총 넓이: 130.81415022205297
원 발견! 반지름: 5.0
원 발견! 반지름: 3.0

이 예제에서 볼 수 있는 포인트를 정리하면:

  • Shape 배열로 Circle과 Rectangle을 한 번에 관리 (다형성)
  • area()는 각 도형이 오버라이딩해서 자기만의 계산 로직 실행
  • toString() 오버라이딩으로 의미 있는 출력
  • instanceof 패턴 매칭으로 안전한 다운캐스팅

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

정리

개념핵심
상속extends로 부모의 필드/메서드를 물려받음
super()자식 생성자 첫 줄에서 부모 생성자 호출
오버라이딩부모 메서드를 자식이 재정의 (런타임 결정)
오버로딩같은 이름, 다른 매개변수 (컴파일 타임 결정)
다형성부모 타입으로 자식 객체를 다루는 것
업캐스팅자식 → 부모 (자동)
다운캐스팅부모 → 자식 (명시적, instanceof 필수)
Object모든 클래스의 최상위 부모

상속은 강력하지만, 아무 데나 쓰면 코드가 복잡해진다. is-a 관계인지 먼저 확인하고, 깊은 상속 계층은 피하자. 실무에서는 "상속보다 구성"이라는 원칙이 자주 언급된다.

다음 글에서는 인터페이스와 추상 클래스를 다룬다. 다중 상속이 안 되는 Java에서 유연한 설계를 가능하게 해주는 핵심 도구다.

댓글 로딩 중...