OOP in Java (Inheritance 심화)

김민욱·2025년 12월 9일

Polymorphism

다른 타입의 클래스 객체들이 같은 superclass 타입으로 다뤄질 수 있는 성질.

  • Dynamic Binding
    메소드는 상속 체인을 통해 여러 클래스에서 구현될 수 있다(오버라이딩). 어떤 메소드를 호출할지 런타임에 실제 객체 타입을 기준으로 결정된다.

Dynamic Binding workflow

객체 o는 클래스 C1,C2,...,CnC_1, C_2, ..., C_n의 인스턴스이다.
C1C_1C2C_2의 서브클래스,
C2C_2C3C_3의 서브클래스,
...,
Cn1C_{n-1}CnCn의 서브클래스.

즉, CnC_n이 가장 범용적인 클래스(general class)다.
-> 자바에서는 Object 클래스.

o에서 메소드 p를 호출하면, JVM은 C1C_1 부터 CnC_n까지 순서대로 구현된 p를 탐색한다.
구현된 p를 찾으면 탐색을 중지하고 처음으로 발견된 p를 호출한다.

다음 예제를 살펴보자.

public class Main {
  public static void main(String[] args) {
    m(new GraduateStudent());
    m(new Student());
    m(new Person());
    m(new Object());

  }

  public static void m(Object x) {
    System.out.println(x.toString());
  }
}

class GraduateStudent extends Student {
  public GraduateStudent() {}
}

class Student extends Person {
  @Override
  public String toString() { return "Student"; }
}

class Person extends Object {
  @Override
  public String toString() { return "Person"; }
}

현재 상속 구조는 다음과 같다.
GraduateStudent \sub Student \sub Person \sub Object

Main 클래스의 m() 메소드는 인자로 받은 객체의 toString() 메소드를 호출한다.
GraduateStudent에는 toString() 메소드가 없으므로 상위 클래스를 탐색한다. Student에서 toString()이 최초로 발견되었으므로 StudenttoString()을 호출한다.
StudentPerson은 각자 오버라이딩 한 toString() 메소드를 호출한다.
Object 객체는 원래 toString()을 가지고 있으므로 그 것을 호출한다.
출력 결과는 아래와 같다.

Student
Student
Person
java.lang.Object@4f023edb

Casting Object

한 객체 레퍼런스는 다른 객체 레퍼런스로 typecast 될 수 있다. 이를 casting object라고 부른다.

위 코드의 Main 클래스를 살펴보자.

public class Main {
  public static void main(String[] args) {
    m(new GraduateStudent());
    m(new Student());
    m(new Person());
    m(new Object());

  }

  public static void m(Object x) {
    System.out.println(x.toString());
  }
}

여기서

m(new Student());

이와 같은 구문은 Object 타입인 파라미터 x에 할당한다. 이는 아래 코드와 같다.

Object o = new Student();
m(o);

묵시적 형 변환(implicit casting)이라고 알려진 이 구문은 Student의 인스턴스(instance of Student)가 Object의 인스턴스(instance of Object)이기 때문이다.

이제는 당신이 객체 레퍼런스 oStudent 타입 변수에 할당하려고 한다고 하자.

Student b = o;

이런 경우에는 컴파일러 에러가 발생될 것이다. 왜 Object o = new Student();는 작동하지만 Student b = o;는 작동하지 않는 것일까?
그 이유는 Student 객체는 항상 Object 객체의 인스턴스(instance of Object)이지만, Object 객체가 반드시 Student의 인스턴스인 것은 아니기 때문이다.
우리가 보기에는 Student b = o;에서 oStudent 객체처럼 보이지만, 컴파일러는 그만큼 똑똑하지 않다.
oStudent 객체임을 컴파일러에게 알리기 위해서는 명시적 형 변환(explicit casting)이 필요하다.

Student b = (Student)o;

instanceof 연산자
만약 superclass 객체가 subclass 객체의 인스턴스가 아니라면, 실행 시간에 ClassCastException이 발생한다.
예를 들어, 만약 어떤 객체가 Student의 인스턴스가 아니라면, 그 객체는 Student 클래스 변수로 캐스팅 될 수 없다.

그러므로, casting 이전에 그 객체가 casting 할 객체의 인스턴스임을 보장해줘야 한다. 그 것을 용이하게 하는 연산자가 바로 instanceof 연산자이다.

Object myObject = new Circle();
...
if (myObject instanceof Circle) {
	System.out.println("The circle diameter is " + 
    ((Circle)myObject).getDiameter());
    ...
}

위 코드에서 Casting이 필수적인 이유
myObject 변수는 Object로 선언되었다. 선언 타입(declared type)은 컴파일 타임에 해당 객체와 어떤 메소드를 연결시킬 지 결정한다. myObject.getDiameter()를 사용하는 것은 컴파일 에러를 발생시킨다. Object 객체는 getDiameter() 메소드를 갖고 있지 않기 때문이다. 그러므로, 명시적으로 myObject 객체를 Circle 타입으로 변환하는 과정이 반드시 필요하다.

Abstract Class

superclass는 관련된 subclass들의 공통된 동작을 정의한다. 정수 배열이나 문자열 정렬을 위한 java.util.Arrays.sort 메소드가 있다. 이 메소드를 geometric 객체를 담은 배열을 정렬하는 데에도 사용할 수 있는가? 그런 코드를 작성하기 위해서 우리는 interface에 대해 알아야 한다. interface를 다루기 전에 먼저 이와 연관된 주제인 추상 클래스(abstract class)에 대해 알아보자.

상속 구조에서, 클래스는 superclass에서 subclass에서 갈수록 더 구체적이고, subclass에서 superclass로 갈 수록 더 범용적이다. 클래스 설계는 반드시 superclass가 subclass의 공통적인 기능을 포함하고 있음을 보장해야한다.
어떤 superclass는 너무 추상적(abstract)이라서 인스턴스를 만드는 데 사용할 수 없는데, 이런 클래스들은 추상 클래스(abstract class)라고 불린다.

// 추상 클래스
abstract class Animal {
  // 추상 메소드
  public abstract String howToEat();
}

class Chicken extends Animal {

  @Override
  public String howToEat() {
    return "Fry it";
  }
}

class Duck extends Animal {

  @Override
  public String howToEat() {
    return "Roast it";
  }
}

public class Main {

  public static void main(String[] args) {
    Animal a1 = new Chicken();
    Animal a2 = new Duck();
    System.out.println(a1.howToEat());
    System.out.println(a2.howToEat());
  }
}

추상 메소드(abstract method)는 추상 클래스가 아닌 클래스에 포함될 수 없다. 만약 추상 superclass의 subclass가 superclass의 모든 추상 메소드를 내부에서 구현하지 않는다면 그 subclass는 abstract로 정의된다.
즉, 추상 클래스를 상속한 비추상 subclass는 반드시 superclass의 모든 추상 메소드를 구현해야 한다. 또한 추상 메소드는 static이 아님도 주목하라.

추상 클래스는 new 연산자를 이용해 인스턴스화 할 수 없다.

Animal a3 = new Animal(); // 불가능

하지만 생성자는 정의할 수 있는데, 이는 subclass의 생성자에서 호출된다. 예를 들어, Animal의 생성자는 ChickenDuck의 생성자에서 호출된다.

추상 메소드를 가진 클래스는 반드시 추상 클래스여야 하지만, 어떠한 추상 메소드도 가지지 않는 추상 클래스도 정의할 수 있다. 그렇다 하더라도 인스턴스화 하는 것은 불가능하다. 이 클래스는 subclass를 정의하는 base class로 사용되어야 한다.

subclass는 superclass에서 구현한 메소드를 오버라이딩 하여 추상으로 정의 할 수 있다. 이는 보편적이지 않은 경우지만 subclass에서 superclass의 구현이 무효화 되는 경우 유용하다.

superclass가 구체화 된 클래스라고 하더라도 subclass가 추상 클래스일 수 있다. 그 예시로, Object 클래스는 구체화 된 클래스지만 이를 상속하는 Animal 클래스는 추상 클래스이다.

추상 클래스가 인스턴스화 되는 것은 불가능하지만, 자료형으로는 사용될 수 있다.

Animal[] objects = new Animal[10];
objects[0] = new Chicken();

Interface

인터페이스(interface)는 오직 상수와 추상 메소드만을 포함하는 클래스같은 구조이다.
주의: Java 8부터는 인터페이스도 구현체(default 키워드)를 가질 수 있다.

추상 클래스와 비슷하지만, 의도는 조금 다르다. 인터페이스는 관련 클래스 뿐만 아니라 관련 없는 클래스의 공통된 동작까지 지정하는 용도로 사용된다.
예를 들어, 위에서 사용한 Animal 추상 클래스는 Chicken이나 Duck 같은 식용 동물들에게는 hotToEat()이라는 추상 메소드를 제공하기 적절하지만, Broccoli 같은 식용 식물의 구현에 사용하기에는 부적절하다.

인터페이스와 클래스를 구분짓기 위해 Java는 다음과 같은 문법을 제공한다.

modifier interface InterfaceName {
  /* 상수 */
  /* 추상 메소드 시그니쳐 */
}

다음 예제를 살펴보자.

public class Main {
  public static void main(String[] args) {
    Object[] objects = {new Tiger(), new Chicken(), new Apple(), new Orange()};
    for (int i=0; i<objects.length; i++) {
      if (objects[i] instanceof Edible)
        System.out.println(((Edible)objects[i]).howToEat());

      if (objects[i] instanceof Animal)
        System.out.println(((Animal)objects[i]).sound());
    }
  }
}
interface Edible {
  public abstract String howToEat();
}

abstract class Animal {
  public abstract String sound();
}

abstract class Fruit implements Edible {

}

class Chicken extends Animal implements Edible{

  @Override
  public String sound() {
    return "cock-a-doodle-doo";
  }

  @Override
  public String howToEat() {
    return "Fry it";
  }
}

class Tiger extends Animal {

  @Override
  public String sound() {
    return "RROOAARR";
  }
}

class Apple extends Fruit {

  @Override
  public String howToEat() {
    return "Make apple cider";
  }
}

class Orange extends Fruit {

  @Override
  public String howToEat() {
    return "Make orange juice";
  }
}
RROOAARR
Fry it
cock-a-doodle-doo
Make apple cider
Make orange juice

EdibleChickenFruit의 supertype이고, FruitAppleOrange의 supertype이다. 모든 동물이 식용 가능한 것은 아니기 때문에 AnimalEdible을 supertype으로 갖지 않는다.

Comparable Interface
다시 이 토픽의 첫 질문으로 돌아가자.
정수 배열이나 문자열 정렬을 위한 java.util.Arrays.sort 메소드를 다른 객체를 담은 배열을 정렬하는 데에도 사용할 수 있는가?
Comparable 인터페이스를 구현하면 가능하다.

public class Main {
  public static void main(String[] args) {
    Rectangle[] rectangles = {
        new Rectangle(3.4, 5.4),
        new Rectangle(13.24, 55.4),
        new Rectangle(7.4, 35.4),
        new Rectangle(1.4, 25.4)
    };
    // 바로 이 메소드
    java.util.Arrays.sort(rectangles);
    
    for (Rectangle rectangle : rectangles) {
      System.out.print(rectangle + " ");
      System.out.println();
    }
  }
}

class Rectangle implements Comparable<Rectangle> {
  private double width;
  private double height;

  public Rectangle(double width, double height) {
    this.width = width;
    this.height = height;
  }

  public double getWidth() { return width; }
  public double getHeight() { return height; }
  public double getArea()  { return width*height; }

  // 비교 기준을 정해준다.
  @Override
  public int compareTo(Rectangle o) {
    if (getArea() > o.getArea()) return 1;
    else if (getArea() < o.getArea()) return -1;
    else return 0;
  }

  @Override
  public String toString() {
    return "Width: " + getWidth() +" Height: " + getHeight() + " Area: " + getArea();
  }
}
Width: 3.4 Height: 5.4 Area: 18.36 
Width: 1.4 Height: 25.4 Area: 35.559999999999995 
Width: 7.4 Height: 35.4 Area: 261.96 
Width: 13.24 Height: 55.4 Area: 733.496

Cloneable Interface
우리는 종종 한 객체의 복제본을 생성하고자 한다. Cloneable 인터페이스를 이해한다면 clone 메소드를 사용해 간편하게 이 작업을 수행할 수 있다.

public class Main {

  public static void main(String[] args) {
    Calendar calendar = new GregorianCalendar(2013, 2, 1);
    Calendar calendar1 = calendar; // copy starting address of heap
    Calendar calendar2 = (Calendar) calendar.clone();
    
    // true; shallow copy, compare address.
    System.out.println(calendar == calendar1);
    // false; deep copy, compare address.
    System.out.println(calendar == calendar2);
    // true; compare value.
    System.out.println(calendar.equals(calendar2));
  }
}
true
false
true

이 토픽은 깊은 복사(deep copy), 얕은 복사(shallow copy)와 연관이 있다.

Calendar calendar1 = calendar;

이 복사는 얕은 복사이다. 단순히 calendar 객체의 참조를 복사한 것에 불과하다. 즉, calendar 객체와 calendar1 객체는 동일한 Calendar 객체를 가리키고 있는 것이다.
(주석에서는 이해하기 쉽게 주소라고 적어놨지만, 엄밀히 따지면 자바는 주소 연산을 제공하지 않으므로 참조라는 표현이 더 적절할 듯 하다.)

Calendar calendar2 = (Calendar) calendar.clone();

이 복사는 깊은 복사이다. calendar의 복제본인 새로운 Calendar 객체를 생성하고 거기에 calendar2라는 참조를 할당했다. 이 것이 가능한 이유는 Calendar 클래스가 Cloneable 인터페이스를 구현하여 clone() 메소드를 사용할 수 있기 때문이다.

여기서 흥미로운 점은 Cloneable 내부에는 clone() 메소드가 정의되어있지 않다.

Cloneable은 단지 복제 가능하다는 표식(Mark) 역할만 하는 것이다. 이런 인터페이스를 마커 인터페이스(Marker Interface)라고 부른다.

이제 Cloneable 인터페이스와 Comparable 인터페이스를 복합적으로 구현한 클래스를 살펴보자.

import java.util.Calendar;
import java.util.GregorianCalendar;

class House implements Cloneable, Comparable<House> {
  public int id;
  public double area;
  public java.util.Date whenBuilt;
  public House(int id, double area) {
    this.id = id;
    this.area = area;
    whenBuilt = new java.util.Date();
    // class only contains value type -> shallow copy is enough
    // class contain reference type(e.g. address) -> we need deep copy
  }
  
  @Override
  public int compareTo(House o) {
    if (area > o.area) return 1;
    else if (area < o.area) return -1;
    else return 0;
  }

  @Override
  public Object clone() {
    try {
      //return super.clone(); // Shallow Copy
      // shallow copy because it just copied whenBuilt's reference.
      House h = (House) super.clone();
      // deep copy
      h.whenBuilt = (java.util.Date) (whenBuilt.clone());
      return h;
    } catch (CloneNotSupportedException e) {
      throw new RuntimeException(e);
    }
  }
}

public class Main {

  public static void main(String[] args) {
    House house1 = new House(1, 200);
    House house2 = (House) house1.clone();
    System.out.println(house2.whenBuilt == house1.whenBuilt); // true; shallow copy // false; if deep copy
  }
}

이 코드에서 흥미롭게 볼 수 있는 부분은 House 클래스의 clone() 메소드다.

  @Override
  public Object clone() {
    try {
      //return super.clone(); // Shallow Copy
      // shallow copy because it just copied whenBuilt's reference.
      House h = (House) super.clone();
      // deep copy
      h.whenBuilt = (java.util.Date) (whenBuilt.clone());
      return h;
    } catch (CloneNotSupportedException e) {
      throw new RuntimeException(e);
    }
  }

만약 이 클래스의 멤버 변수가 primitive type만 담고 있다면 super.clone()만으로 충분할 것이다.
문제는 whenBuilt 멤버 객체이다.

public java.util.Date whenBuilt;

단순 super.clone()만 하게 되면 whenBuilt 객체의 참조가 복사되는 얕은 복사가 발생한다.
따라서 진짜 깊은 복사를 위해서는 다음과 같은 추가적인 clone()이 필요하다.

House h = (House) super.clone();
h.whenBuilt = (java.util.Date) (whenBuilt.clone());

위와 같은 구문이 있으면 아래 코드의 출력 결과는 false가 된다.

System.out.println(house2.whenBuilt == house1.whenBuilt);

추상 클래스 vs 인터페이스

추상 클래스인터페이스
목적관련성 높은 클래스 간의 코드 공유 및 확장서로 다른 클래스들의 common feature 정의
관계IS-ACAN-DO
다중 상속불가능가능
변수제한 없음상수와 public static final 변수만 가능
생성자subclass의 생성자에서 호출됨생성자 없음

클래스는 다수의 인터페이스를 구현하는 것은 가능하지만, 오직 하나의 superclass만을 가질 수 있다.

인터페이스가 다른 인터페이스를 상속하는 것도 가능하다. 그런 인터페이스는 subinterfacef라고 부른다.

public interface NewInterface extends Interface1, ..., InterfaceN {
  // ...
}

모든 클래스는 Object라는 공통된 뿌리 클래스를 가진다. 그러나 인터페이스들은 각자 다른 뿌리를 가진다.

클래스처럼 인터페이스도 타입을 정의할 수 있다. 인터페이스 타입 변수는 그 인터페이스를 구현한 다른 클래스의 인스턴스를 참조할 수 있다. 클래스가 어떤 인터페이스를 구현하면, 그 인터페이스는 해당 클래스의 uperclass처럼 취급된다. 인터페이스를 데이터 타입처럼 사용하는 것도 가능하고, 인터페이스 타입 변수를 그 subclass로 형변환 하는 것도 가능하다. (그 반대도 가능).

클래서의 정체성을 정의하는 것은 추상 클래스, 능력을 정의하는 것은 인터페이스라고 생각하면 될 듯 하다.


<참고자료>
탁성우, "플랫폼기반프로그래밍", 부산대학교

0개의 댓글