[Java] Java 05 Relationship between Objects


Java

Period 01 상속(extends) — 코드 복붙을 끝내자

  1. class B extends A 문법으로 두 클래스를 부모-자식 관계로 만들 수 있다
  2. 자식 클래스에서 부모의 필드와 메서드를 그대로 재사용할 수 있다
  3. 객체 생성 시 부모 -> 자식 순서로 생성자가 호출되는 흐름을 설명할 수 있다
  4. 자식 생성자 첫 줄에 super()가 숨어 있으며, 부모에 기본 생성자가 없으면 컴파일 오류가 나는 이유를 안다

extends 문법 + 관계 그림 (UML)

  • 🏗️ extends 키워드를 통한 상속 정의
    • 자바에서 상속을 구현할 때는 자식 클래스 선언 뒤에 extends 키워드를 붙이고 부모 클래스명을 지정한다. 이는 부모의 필드와 메서드를 자식이 물려받아 확장하겠다는 명시적 선언이다.
  • 🧱 UML 클래스 다이어그램의 상속 표현 규칙
    • 소프트웨어 설계도인 UML에서 상속 관계는 자식 클래스에서 부모 클래스를 향해 속이 빈 삼각형 화살표(실선)로 연결하여 표현한다. 화살표의 방향이 데이터의 흐름과 달리 자식에서 부모를 가리킨다는 점에 유의해야 한다.
  • ⚡ 단일 상속(Single Inheritance)의 원칙
    • 자바는 다중 상속을 허용하지 않으므로 extends 뒤에는 오직 단 하나의 클래스만 올 수 있다. 이를 통해 복잡한 다중 상속 구조에서 발생할 수 있는 메서드 모호성 문제를 원천적으로 차단한다.
public class Person {
  protected String name;
  protected int age;

  public void greet() {
    System.out.println("안녕하세요 " + name);
  }
}

public class Student extends Person {
  private int score;     // 자식만의 추가 필드

  public void study() {  // 자식만의 추가 메서드
    System.out.println(name + " 공부 중");
  }
}

실습 2 Person → Student 첫 상속

public class Person {
  protected String name;
  protected int age;

  public void greet() {
    System.out.println("안녕하세요 " + name);
  }
}
public class Student extends Person {
  private int score;

  public void study() {
    System.out.println(name + "이(가) 공부합니다");
  }
}
public class SchoolMain {
  public static void main(String[] args) {
    Student s = new Student();
    s.name = "홍길동";  // 부모 필드 사용
    s.age = 20;

    s.greet();   // 부모 메서드 호출
    s.study();   // 자식 메서드 호출
  }
}
안녕하세요 홍길동
홍길동이() 공부합니다

관찰: Student엔 name도 greet()도 한 줄도 없는데 그대로 사용된다. 부모가 가진 모든 것을 자식이 물려받았기 때문이다.


실습 2 Teacher를 추가해라 — 코드 중복이 사라진다

public class Teacher extends Person {
  private String subject;

  public void teach() {
    System.out.println(name + "이(가) 가르칩니다");
  }
}
public class SchoolMain {
  public static void main(String[] args) {
    Student s = new Student();
    s.name = "홍길동";
    s.greet();  s.study();

    Teacher t = new Teacher();
    t.name = "김선생";
    t.greet();  t.teach();
  }
}
안녕하세요 홍길동
홍길동이() 공부합니다
안녕하세요 김선생
김선생이() 가르칩니다

잠깐 — Person을 건드렸을 뿐인데 Student가 죽는다

  • 🏗️ 부모 클래스 변경의 연쇄 작용
    • 상속 관계에서 부모 클래스(Person)의 필드 타입이나 메서드 시그니처를 수정하면, 이를 상속받은 자식 클래스(Student)에 즉각적인 영향을 미친다. 부모를 수정했음에도 자식 클래스에서 컴파일 에러가 발생하는 ‘죽는’ 현상이 나타난다.
  • 🧱 부모에 대한 과도한 의존성 (Tight Coupling)
    • 자식 클래스가 부모 클래스의 내부 구조를 너무 깊게 알고 의존하고 있을 때 이러한 문제가 발생한다. 부모 클래스의 사소한 변화가 자식 클래스 전체의 무결성을 깨뜨리며, 시스템의 유지보수 효율을 극도로 떨어뜨리는 결합도의 부작용이다.
  • ⚡ 캡슐화로 해결하는 상속의 위기
    • 자식 클래스에게 부모의 구현 세부 사항을 직접 노출하지 않고, 메서드 등을 통해 간접적으로 접근하도록 설계해야 한다. 상속은 강력한 도구이지만, 부모의 변경이 자식에게 전이되지 않도록 적절한 캡슐화 전략이 수반되어야 안전하다.
// Person.java — 매개변수 생성자 하나 추가
public class Person {
  protected String name;
  protected int age;

  public Person(String name, int age) {
    this.name = name;
    this.age  = age;
  }
  // 기본 생성자(Person()) 자동 생성 멈춤!
}

// Student.java — 그대로 두면…
public class Student extends Person {
  private int score;
  // public Student() { super(); }  ← 컴파일러가 몰래 넣음
}
Student.java:1: error: constructor Person in class Person
cannot be applied to given types;
required: String,int
found: no arguments

자바 컴파일러의 숨은 규칙

모든 자식 생성자의 맨 첫줄super(); (인자 없는 부모 호출)가 자동으로 끼워진다. 학생이 적은 적이 없어도.

public Student() {
  super(); // ← 자동 삽입
}

실습 3 고치는 법 — super(name, age) 명시 호출

public class Person {
  protected String name;
  protected int age;

  public Person(String name, int age) {
    this.name = name;
    this.age  = age;
    System.out.println("  ▶ Person 생성: " + name);
  }
}
public class Student extends Person {
  private int score;

  public Student(String name, int age, int score) {
    super(name, age);    // ← 부모를 명시적으로 호출
    this.score = score;
    System.out.println("  ▶ Student 생성 완료");
  }
}
// new Student("홍길동", 20, 92);
▶ Person 생성: 홍길동
▶ Student 생성 완료

도전 문제 — Vehicle 가족 만들기

// 문제

부모 Vehicle(int speed)과 자식 두 종류 Car(String brand)Bicycle(int gear)을 만들어라. 각 자식은 run() 메서드로 자기 정보를 출력해야 한다.

// 조건

  • Vehicle은 매개변수 생성자 하나만 정의 (기본 생성자 X)
  • 각 자식 생성자에서 super(speed)를 명시 호출
  • main에서 Car와 Bicycle을 각각 생성해 run() 호출
public class Vehicle {
    protected int speed;
    public Vehicle(int speed) {
        this.speed = speed;
    }
}
public class Car extends Vehicle {
    private String brand;
    public Car(String brand, int speed) {
        super(speed);
        this.brand = brand;
    }
    public void run() {
        System.out.printf(
                "[Car / %s] %dkm/h 로 달립니다%n", brand, speed);
    }
}
public class Bicycle extends Vehicle {
    private int gear;
    public Bicycle(int gear, int speed) {
        super(speed);
        this.gear = gear;
    }
    public void run() {
        System.out.printf(
                "[Bicycle / %d단] %dkm/h 로 달립니다%n", gear, speed);
    }
}
public class VehicleMain {
    public static void main(String[] args) {
        Car c = new Car("현대", 120);
        Bicycle b = new Bicycle(7, 25);

        c.run(); b.run();
    }
}
[Car / 현대] 120km/h 로 달립니다
[Bicycle / 7단] 25km/h 로 달립니다

Vehicle에서 매개변수 생성자를 지우고 자식만 두면?

java: constructor Vehicle in class p1.Vehicle cannot be applied to given types;
  required: no arguments
  found:    int
  reason: actual and formal argument lists differ in length

Period 02 오버라이딩(@Override) — 자식이 부모를 덮어쓰는 법

  1. 부모 메서드와 같은 시그니처로 @Override 메서드를 작성할 수 있다
  2. 자식 메서드 안에서 super.메서드()로 부모 동작을 재사용할 수 있다
  3. @Override 어노테이션이 오타로 인한 “보이지 않는 버그”를 어떻게 막는지 설명할 수 있다
  4. Object의 toString()을 자식이 오버라이딩해 println에 활용할 수 있다

왜 오버라이딩 — 부모 그대로면 어색하다

  • 🏗️ 상속 후 발생하는 부모 메서드의 불일치
    • 부모 클래스로부터 물려받은 메서드(toString 등)가 자식 클래스의 특성이나 기대하는 동작 방식과 일치하지 않는 경우가 발생한다.
    • 부모의 코드를 그대로 사용하면 정보가 부정확하거나 부모 클래스의 사양(예: 객체 주소 출력)이 그대로 나타나기 때문에 결과가 어색해진다.
  • 🧱 오버라이딩(Overriding)의 정의
    • 부모 클래스의 메서드와 이름, 매개변수 타입, 개수가 완전히 동일한 메서드를 자식 클래스에서 다시 정의하는 것이다.
    • 자식 클래스에서 부모와 동일한 서명의 메서드를 구현하면, 자식 객체를 통해 해당 메서드를 호출할 때 부모 버전이 가려지고 자식 버전이 실행된다.
  • ⚡ 오버라이딩을 통한 다형성 구현
    • 오버라이딩은 부모 타입의 참조 변수로 자식 객체를 다룰 때도(다형성), 객체 본연의 실제 타입에 맞는 고유한 동작을 수행하도록 보장한다.
    • 올바른 오버라이딩을 위해 @Override 어노테이션을 사용하는 것이 권장되며, 이를 통해 컴파일 시점에 메서드 이름이 부모와 일치하는지 명확히 검증받을 수 있다.

오버라이딩 규칙 — 시그니처가 완전히 같아야 한다

  • 🏗️ 시그니처의 필수 구성 요소
    • 오버라이딩은 부모 클래스의 메서드를 자식 클래스에서 재정의할 때 적용되는 원칙입니다.
    • 이때 부모 메서드와 메서드 이름, 매개변수 타입, 매개변수 개수(순서 포함)가 완전히 동일해야 합니다. 이를 메서드의 ‘시그니처’라고 하며, 이 부분이 일치하지 않으면 오버라이딩이 아닌 ‘오버로딩’으로 처리되거나 컴파일 에러가 발생합니다.
  • 🧱 일치하지 않을 때 발생하는 문제
    • 시그니처의 일부(예: 매개변수의 타입이나 개수)만이라도 다르면 자바 컴파일러는 이를 새로운 메서드를 추가하는 ‘오버로딩’으로 인식합니다.
    • 결과적으로 자식 클래스에는 부모로부터 물려받은 원본 메서드와 자식이 새로 만든 메서드가 공존하게 되어, 다형성을 활용한 메서드 호출 시 의도치 않은 동작이 발생할 수 있습니다.
  • ⚡ 명확한 의도 전달을 위한 @Override
    • 실수로 시그니처를 다르게 작성하는 것을 방지하기 위해 @Override 어노테이션 사용이 강력히 권장됩니다.
    • 이 어노테이션을 붙이면 컴파일러가 부모 클래스에 동일한 시그니처의 메서드가 있는지 강제로 확인하므로, 오타나 파라미터 불일치로 인한 논리적 버그를 사전에 차단할 수 있습니다.
public class Student extends Person {

  @Override          // "나, 부모 거 덮어쓸게요"
  public void greet() {
    System.out.println("공부 중인 " + name);
  }
}

@Override“이 메서드는 부모 거를 덮어쓰는 거야”라고 컴파일러에게 알리는 표시다. 만약 시그니처가 부모와 다르면 method does not override or implement a method from a supertype오류를 띄워준다.

// 참고 — 오버라이딩 vs 오버로딩 오버라이딩(Override): 부모-자식 사이, 시그니처 동일 오버로딩(Overload): 같은 클래스 안, 이름은 같고 매개변수가 다름


실습 1 greet() 오버라이딩 — 학생과 교사를 다르게

public class Student extends Person {
  public Student(String n, int a) {
    super(n, a);
  }

  @Override
  public void greet() {
    System.out.println("공부 중인 " + name + "입니다");
  }
}
public class Teacher extends Person {
  public Teacher(String n, int a) {
    super(n, a);
  }

  @Override
  public void greet() {
    System.out.println("가르치는 " + name + "입니다");
  }
}
public class SchoolMain {
  public static void main(String[] args) {
    Student s = new Student("홍길동", 20);
    Teacher t = new Teacher("김선생", 40);

    s.greet();   // 학생 버전 실행
    t.greet();   // 교사 버전 실행
  }
}
공부 중인 홍길동입니다
가르치는 김선생입니다

관찰: 두 객체가 모두 greet()를 호출했지만, 각자 자기 버전이 실행됐다. 부모의 인사말은 가려졌다.


실습 2 super.greet() — 부모 인사를 먼저, 내 인사를 나중에

public class Student extends Person {
  public Student(String n, int a) { super(n, a); }

  @Override
  public void greet() {
    super.greet();      // 부모 인사 먼저
    System.out.println("  → 공부 중인 학생입니다");
  }
}
public class Teacher extends Person {
  public Teacher(String n, int a) { super(n, a); }

  @Override
  public void greet() {
    super.greet();
    System.out.println("  → 가르치는 선생님입니다");
  }
}
// new Student("홍길동", 20).greet();
안녕하세요 홍길동  부모
 공부 중인 학생입니다  자식

// new Teacher("김선생", 40).greet();
안녕하세요 김선생  부모
 가르치는 선생님입니다  자식

잠깐 — toString()은 영원히 호출되지 않는다

  • 🏗️ 기본 제공 toString()의 한계
    • 모든 클래스는 Object 클래스를 상속받으므로 toString() 메서드를 기본적으로 물려받지만, 그 기본 구현은 객체의 해시코드 값을 포함한 내부 주소 정보를 출력하는 데 그친다.
    • 자식 클래스에서 이 메서드를 오버라이딩하여 필드 값들을 출력하도록 재정의하지 않는다면, 프로그램에서 객체 정보를 확인하려고 할 때 의도한 데이터가 아닌 난해한 16진수 문자열만 보게 된다.
  • 🧱 메서드 호출을 위한 명시적 오버라이딩
    • System.out.println()에 객체를 직접 전달하면 자바는 내부적으로 해당 객체의 toString()을 호출한다.
    • 하지만 개발자가 자식 클래스에서 이 메서드를 오버라이딩하지 않았다면, 부모인 Object 클래스의 원본 메서드가 호출되어 객체 내부의 실제 데이터(필드 값)는 영원히 출력되지 않는다.
  • ⚡ 오버라이딩을 통한 데이터 가시성 확보
    • 객체의 상태를 올바르게 출력하려면 자식 클래스 내부에 필드 정보를 문자열로 조합하여 반환하도록 toString()을 반드시 재정의해야 한다.
    • 오버라이딩을 완료하면 자바의 다형성 원리에 의해 부모의 메서드가 아닌 자식이 정의한 출력 로직이 실행되어, 객체의 필드 정보를 정상적으로 확인할 수 있게 된다.
public class Student extends Person {
  public Student(String n) { super(n, 20); }

  // @Override 안 붙임 + 소문자 오타
  public String tostring() {   // ← 진짜 이름은 toString
    return "Student[" + name + "]";
  }
}

// main:
Student s = new Student("홍길동");
System.out.println(s);
$ javac Student.java
$ # 컴파일 성공!
$ java SchoolMain
Student@1b6d3586 # 부모의 기본 toString이 호출됨

진실 — 자바는 두 메서드를 별개로 본다

  • toString() ← 부모 Object에 존재
  • tostring() ← 학생이 새로 만든 메서드

이름이 다르므로 오버라이딩이 아니다. 단지 “Student에만 있는 메서드”가 하나 더 생긴 것. 그래서 println(s)가 부르는 toString은 여전히 부모 거.

@Override를 붙였다면?

@Override
public String tostring() { /* ... */ }
error: method does not override or implement
a method from a supertype

실습 3 toString() 정확히 — println이 자동으로 부르는 메서드

public class Person {
  protected String name;
  protected int age;
  // 생성자 생략

  @Override
  public String toString() {
    return "Person(" + name + "," + age + ")";
  }
}
public class Student extends Person {
  private int score;
  // 생성자 생략

  @Override
  public String toString() {
    return "Student[" + name + ",점수=" + score + "]";
  }
}
public class SchoolMain {
  public static void main(String[] args) {
    Person p = new Person("이서연", 35);
    Student s = new Student("홍길동", 20, 92);

    System.out.println(p);   // 자동으로 p.toString() 호출
    System.out.println(s);   // 자동으로 s.toString() 호출
  }
}
Person(이서연,35)
Student[홍길동,점수=92]

도전 문제 — Bird 가족의 비행

// 문제

부모 Bird의 fly()는 “난다”를 출력한다. 자식 Eagle·Sparrow·Penguin 세 종류를 만들어 각자 다른 비행 메시지를 출력하도록 오버라이딩하라. 펭귄은 “헤엄친다 (못 난다)” 처럼.

// 조건

  • 모든 자식 메서드에 @Override 어노테이션 필수
  • Sparrow의 fly()를 일부러 flay()로 오타내고 @Override 없이 컴파일 → 실행 결과 적어 오기
  • @Override를 붙였을 때 컴파일러가 띄우는 메시지도 캡처
public class Bird {
    public void fly() { System.out.println("난다"); }
}
public class Eagle extends Bird {
    @Override
    public void fly() { System.out.println("독수리: 하늘 높이 활공한다"); }
}
public class Sparrow extends Bird {
    @Override
    public void fly() { System.out.println("참새: 짹짹 날아간다"); }
}
public class Penguin extends Bird {
    @Override
    public void fly() { System.out.println("펭귄: 헤엄친다 (못 난다)"); }
}
public class BirdMain {
    public static void main(String[] args) {
        Eagle e = new Eagle();
        Sparrow s = new Sparrow();
        Penguin p = new Penguin();

        e.fly(); s.fly(); p.fly();
    }
}
독수리: 하늘 높이 활공한다
참새: 짹짹 날아간다
펭귄: 헤엄친다 (못 난다)

flay() 오타

java: method does not override or implement a method from a supertype

완성 후 추가 도전

Bird[] 배열에 세 자식을 섞어 담고 for-each로 fly()를 호출하면 무슨 일이 일어날지 예측해보세요 (다음 시간 다형성 예고).

public class BirdMain {
    public static void main(String[] args) {
        Eagle e = new Eagle();
        Sparrow s = new Sparrow();
        Penguin p = new Penguin();

        Bird[] birds = {e, s, p};
        for (Bird bird : birds) {
            bird.fly();
        }
    }
}
독수리: 하늘 높이 활공한다
참새: 짹짹 날아간다
펭귄: 헤엄친다 (못 난다)

Period 03 다형성 • 업캐스팅 • 다운캐스팅 • instanceof

  1. 업캐스팅(자동)과 다운캐스팅(명시)의 차이를 그림으로 설명할 수 있다
  2. Person[]에 자식들을 담고 같은 메서드 호출로 각자 다른 결과를 얻는다
  3. “그릇 타입이 보여주는 메서드만 보인다”는 원리로 컴파일 오류를 설명할 수 있다
  4. instanceof 패턴 매칭으로 ClassCastException 없이 안전 캐스팅을 할 수 있다

업캐스팅(자동) vs 다운캐스팅(명시)

  • 🏗️ 업캐스팅(Upcasting): 안전한 일반화
    • 자식 클래스 타입의 객체를 부모 클래스 타입으로 형변환하는 것입니다. 부모는 자식의 모든 기능을 포함하고 있으므로, 별도의 연산자 없이 자동으로 처리되며 항상 안전합니다.
    • 부모 타입으로 변환하면 자식만의 고유한 필드나 메서드에는 접근할 수 없게 되지만, 다형성을 활용하여 여러 종류의 자식 객체를 부모 타입의 그릇 하나에 통합하여 관리할 수 있게 됩니다.
  • 🧱 다운캐스팅(Downcasting): 명시적 구체화
    • 부모 타입으로 변환되었던 객체를 다시 자식 클래스 타입으로 되돌리는 것입니다. 부모 그릇 속에 담긴 객체가 실제로는 자식 객체임을 프로그래머가 보장해야 하므로, (자식클래스명)변수명과 같이 명시적으로 형변환을 표기해야 합니다.
    • 만약 부모 그릇에 실제로 담긴 객체가 대상 자식 타입이 아닐 경우(예: Person 그릇에 Teacher가 있는데 Student로 다운캐스팅 시도), 런타임에 ClassCastException이라는 심각한 에러가 발생합니다.
// 1. 업캐스팅 — 자동, 키워드 없음
Person p1 = new Student();    OK
Person p2 = new Teacher();    OK

// 2. 다운캐스팅 — 명시적 (Type)
Person p  = new Student();
Student s = (Student) p;      OK (실제로 Student)

// 3. 거짓말 다운캐스팅 — 컴파일은 OK
Person  q = new Teacher();
Student x = (Student) q;      CCE 런타임

실습 1 Person[]에 학생 • 교사 섞어 담기

public class SchoolMain {
  public static void main(String[] args) {
    Person[] school = new Person[4];

    // 자식 객체를 부모 그릇에 (업캐스팅 자동)
    school[0] = new Student("홍길동", 20);
    school[1] = new Teacher("김선생", 40);
    school[2] = new Student("이서연", 22);
    school[3] = new Teacher("박교수", 55);

    // 단 한 줄의 루프, 4명 모두 인사
    for (Person p : school) {
      p.greet();
    }
  }
}
공부 중인 홍길동입니다 # Student 버전
가르치는 김선생입니다 # Teacher 버전
공부 중인 이서연입니다 # Student 버전
가르치는 박교수입니다 # Teacher 버전

실습 2 instanceof — 학생일 때만 study() 호출하기

for (Person p : school) {
  p.greet();                // 다 같이

  if (p instanceof Student) {
    Student s = (Student) p;   // 다운캐스팅
    s.study();
  }
  else if (p instanceof Teacher) {
    Teacher t = (Teacher) p;
    t.teach();
  }
}
for (Person p : school) {
  p.greet();

  if (p instanceof Student s) {  // 한 줄에 캐스팅까지
    s.study();
  }
  else if (p instanceof Teacher t) {
    t.teach();
  }
}
공부 중인 홍길동입니다
 홍길동이() 공부합니다
가르치는 김선생입니다
 김선생이() 가르칩니다
공부 중인 이서연입니다
 이서연이() 공부합니다

잠깐 — 객체는 Student인데 study()를 못 부른다고?

  • 🏗️ 참조 변수 타입에 따른 접근 권한
    • 업캐스팅(Upcasting)을 통해 Student 객체를 Person 타입의 변수에 담으면, 컴파일러는 해당 객체를 오직 Person으로만 인식합니다.
    • 비록 힙(Heap) 영역에 생성된 실제 객체는 Student라 하더라도, 참조 변수가 Person 타입이라면 Student 고유의 메서드인 study()는 물리적으로 존재하지 않는 것처럼 간주되어 호출할 수 없습니다.
  • 🧱 컴파일러의 시선 vs 런타임 객체
    • 자바 컴파일러는 ‘참조 변수의 타입’을 기준으로 메서드 호출 가능 여부를 검사합니다.
    • 따라서 Person 타입 참조 변수로는 부모 클래스에 정의된 멤버만 접근이 허용됩니다. 실제 객체의 능력이 더 뛰어나더라도(즉, study() 기능이 있더라도), 컴파일러가 해당 타입을 인지하지 못하면 문법 에러가 발생하여 코드가 실행되지 않습니다.
  • ⚡ 기능 확장을 위한 캐스팅(Casting)의 필요성
    • 실제 객체의 고유 기능을 모두 사용하려면, Person 타입의 참조 변수를 다시 Student 타입으로 다운캐스팅(Downcasting)해야 합니다.
    • 명시적인 형변환을 통해 변수의 타입을 객체의 실제 타입으로 맞춰주어야만, 비로소 자식 클래스에서 정의한 study() 메서드를 정상적으로 호출할 수 있게 됩니다.
Person p = new Student("홍길동", 20);

p.greet();   // ✓ OK — Person에 있음
p.study();   // ✗ 컴파일 오류!
error: cannot find symbol
symbol: method study()
location: variable p of type Person

위험한 캐스팅 vs 안전한 캐스팅

위험 — CCE 시연

Person p = new Teacher("김선생", 40);

// 컴파일은 통과 — 그릇은 Person
Student s = (Student) p;

// 런타임에 폭발
s.study();
Exception in thread "main"
java.lang.ClassCastException:
class Teacher cannot be cast
to class Student

안전 — instanceof 안전벨트

Person[] arr = {
  new Student("홍길동", 20),
  new Teacher("김선생", 40)
};

for (Person p : arr) {
  if (p instanceof Student s) {
    s.study();   // 안전
  } else if (p instanceof Teacher t) {
    t.teach();
  }
홍길동이() 공부합니다
김선생이() 가르칩니다
# 예외 없이 끝까지 실행

도전 문제 — 도서관 자료실

// 문제

공통 부모 Item(String title)과 자식 세 종류 Book(저자)Dvd(러닝타임)Magazine(호수)를 만들어라. Item[]에 자료 5개를 섞어 담고 한 번의 for문으로 종류별 다른 정보를 출력하라.

// 조건

  • 모든 자식의 자기만의 메서드는 다운캐스팅으로 호출
  • instanceof를 안 쓰고 무작정 다운캐스팅하는 잘못된 코드도 한 번 작성해 CCE를 띄워보고 메시지 캡처
  • 그 다음 Java 16+ 패턴 매칭으로 고친 코드 작성
public class Item {
    protected String title;

    public Item(String t) {
        title = t;
    }

    public String toString() {
        return title;
    }
}

public class Book extends Item {
    private String author;

    public Book(String title, String a) {
        super(title); author = a;
    }

    public void read() {
        System.out.println(
                "[Book] " + super.toString() + " - 저자: " + author);
    }
}

public class Dvd extends Item {
    private int rtime;

    public Dvd(String t, int r) {
        super(t); rtime = r;
    }

    public void watch() {
        System.out.println(
                "[Dvd] " + super.toString() + " - " + rtime + "분");
    }
}

public class Magazine extends Item {
    private String hosu;

    public Magazine(String t, String h) {
        super(t); hosu = h;
    }

    public void read() {
        System.out.println("[Magazine] " + super.toString() + " - " + hosu + "호");
    }
}

public class LibraryMain {
    public static void main(String[] args) {
        Book b1 = new Book("코끼리는 생각하지 마", "레이코프");
        Dvd d1 = new Dvd("인터스텔라", 169);
        Magazine m1 = new Magazine("월간 자바", "2026/05");
        Book b2 = new Book("객체지향의 사실과 오해", "조영호");

        Item[] arr = {b1, d1, m1, b2};
        for (Item it : arr) {
            if (it instanceof Book b) {
                b.read();
            } else if (it instanceof Dvd d) {
                d.watch();
            } else if (it instanceof Magazine m) {
                m.read();
            } else {
                System.out.println("Book / Dvd / Magazine의 인스턴스가 아닙니다");
            }
        }
    }
}

Period 04 추상 클래스 abstract — 자식에게 강제하는 약속

  1. abstract classabstract method를 작성할 수 있다
  2. 자식 클래스에서 모든 abstract 메서드를 구현해 컴파일을 통과시킨다
  3. “추상 클래스는 왜 인스턴스화가 안 되는가”를 컴파일 오류 메시지로 설명한다
  4. 일반 메서드(공통 구현)와 abstract 메서드(강제)를 한 클래스에서 적절히 섞을 수 있다

abstract 문법 + UML 표기

  • 🏗️ abstract 키워드를 통한 추상 클래스 선언
    • 클래스 앞에 abstract 키워드를 붙이면 해당 클래스는 ‘추상 클래스’가 되며, 인스턴스(객체)를 직접 생성할 수 없게 된다.
    • 이는 그 자체로 완전한 설계도가 아니라 자식 클래스들이 공통적으로 가져야 할 필드와 메서드의 틀을 정의하고, 일부 동작(메서드)은 자식에게 구체적인 구현을 강제하기 위해 사용한다.
  • 🧱 abstract 메서드와 구현 강제
    • 클래스 내부에서 public abstract void method();와 같이 선언부만 있고 바디({ })가 없는 메서드를 ‘추상 메서드’라고 한다.
    • 추상 클래스를 상속받는 자식 클래스는 반드시 이 추상 메서드들을 오버라이딩하여 자신만의 구체적인 로직으로 구현해야 하며, 하나라도 구현하지 않으면 자식 클래스 역시 추상 클래스가 되어야 한다.
  • ⚡ UML 클래스 다이어그램의 추상화 표현
    • UML에서 추상 클래스 이름과 추상 메서드 이름은 이탤릭체(기울임꼴)로 표기하는 것이 표준이다.
    • 추상 클래스 박스는 일반 클래스와 동일하게 그리되, 이름과 추상 메서드 영역에 이탤릭체를 적용하여 해당 요소가 추상적임을 설계 단계에서부터 명확히 구분한다.
public abstract class Person {     // ① 클래스에
  protected String name;
  protected int age;

  public Person(String n, int a) {  // 생성자 OK
    this.name = n;  this.age = a;
  }

  public String info() {                   // 일반 메서드 OK
    return name + "(" + age + ")";
  }

  public abstract void work();        // ② 메서드에 (몸체 없음)
}

실습 1 abstract Person → Student / Teacher

public abstract class Person {
  protected String name;
  public Person(String n) { name = n; }

  public abstract void work();
}

public class Student extends Person {
  public Student(String n) { super(n); }

  @Override
  public void work() {
    System.out.println(name + "이(가) 공부한다");
  }
}

public class Teacher extends Person {
  public Teacher(String n) { super(n); }

  @Override
  public void work() {
	  System.out.println(name + "() 가르친다);
	}
}
public class SchoolMain {
  public static void main(String[] args) {
    Person[] arr = {
      new Student("홍길동"),
      new Teacher("김선생")
    };
    for (Person p : arr) p.work();
  }
}

관찰: Person은 abstract이지만 Person[] 그릇은 만들 수 있습니다. 인스턴스를 못 만들 뿐, 부모 타입으로 다형성 그릇 역할은 그대로 가능합니다.


실습 2 Staff 추가 — Person 코드는 한 줄도 안 바뀜

public class Staff extends Person {
  private String department;

  public Staff(String n, String dept) {
    super(n);
    this.department = dept;
  }

  @Override
  public void work() {
    System.out.println(name + "이(가) "
        + department + "에서 일한다");
  }
}
Person[] people = {
  new Student("홍길동"),
  new Teacher("김선생"),
  new Staff("이주임", "행정실")
};

System.out.println("=== 모두의 일과 ===");
for (Person p : people) {
  p.work();
}
=== 모두의 일과 ===
홍길동이(가) 공부한다
김선생이(가) 가르친다
이주임이(가) 행정실에서 일한다

잠깐 — abstract는 두 가지를 거절한다

  • 🏗️ 첫 번째 거절: 인스턴스(객체) 생성을 거부
    • abstract 클래스는 설계도 그 자체로 불완전하므로, new 연산자를 사용하여 객체를 직접 생성하는 행위를 컴파일러가 강력하게 차단한다.
    • 이는 객체화가 되면 안 되는 추상적인 개념(예: 일반적인 ‘동물’ 클래스)이 실제로 메모리에 올라와 오동작하는 논리적 오류를 미연에 방지하기 위한 안전장치이다.
  • 🧱 두 번째 거절: 불완전한 구현을 거부
    • abstract 메서드가 하나라도 포함된 클래스는, 그 자식 클래스가 추상 메서드들을 모두 오버라이딩하여 구체적인 로직을 완성하지 않으면 자식 클래스 자체의 인스턴스 생성도 거부한다.
    • 즉, 구현되지 않은 ‘빈 껍데기’ 메서드가 남은 상태로는 프로그램을 실행할 수 없게 만들어, 개발자가 강제로 상세 기능을 구현하도록 유도하는 역할을 한다.
  • ⚡ 목적: 강제성과 안전한 다형성
    • 이러한 두 가지 거절을 통해 자바는 ‘상속받는 모든 자식은 반드시 특정 기능을 구현해야 한다’는 엄격한 설계 규칙을 강제한다.
    • 결과적으로 부모 타입으로 자식 객체를 다룰 때(다형성), 자식이 어떤 타입이든 항상 해당 메서드가 안전하게 실행될 것임을 100% 보장할 수 있게 된다.

실습 3 — 일반 메서드 + 추상 메서드의 공존

public abstract class Person {
  protected String name;
  protected int age;
  public Person(String n, int a) {
    name = n; age = a;
  }

  public String info() {            // 일반 — 자식 공통
      return name + "(" + age + ")";
  }

  public abstract void work();    // 추상 — 강제
}

public class Student extends Person {
  // info()는 상속 그대로 — 다시 안 적어도 됨

  public Student(String n, int a) { super(n, a); }

  @Override
  public void work() {                  // 구현 의무
    System.out.println(info() + " 공부 중");
  }
}
Person[] people = {
  new Student("홍길동", 20),
  new Teacher("김선생", 40)
};
for (Person p : people) p.work();
홍길동(20) 공부 중
김선생(40) 가르치는 중
# info()는 부모 거 그대로 활용

도전 문제 Employee 추상 계층

// 문제

추상 Employee(name)를 만들고 abstract int salary()를 두자. 자식 세 종류 FullTime(연봉/12)PartTime(시급×시간)Contract(고정금액)를 작성하고 Employee[]에 섞어 담아 월급 총합을 출력하라.

// 조건

  • 각 자식 생성자에서 super(name) 호출
  • 일부러 한 자식에서 salary()를 누락시킨 뒤 컴파일 오류 메시지를 적어 오기
  • 월급 총합은 일반 메서드(static)로 작성
public abstract class Employee {
    protected String name;

    public Employee(String n) {
        name = n;
    }

    public abstract int salary();

    public static int totalPay(Employee[] arr) {
        int sum = 0;
        for (Employee e : arr) {
            if (e instanceof FullTime f) {
                sum += f.salary();
            } else if (e instanceof PartTime p) {
                sum += p.salary();
            } else if (e instanceof Contract c) {
                sum += c.salary();
            } else {
                System.out.println("잘못된 양식");
            }
        }
        return sum;
    }
}

public class FullTime extends Employee {
    private int annual;

    public FullTime(String n, int a) {
        super(n);
        annual = a;
    }

    @Override
    public int salary() {
        return annual / 12;
    }
}

public class PartTime extends Employee {
    private int hrate;
    private int hour;

    public PartTime(String n, int r, int h) {
        super(n);
        hrate = r;
        hour = h;
    }

    @Override
    public int salary() {
        return hrate * hour;
    }
}

public class Contract extends Employee {
    private int fix;

    public Contract(String n,  int f) {
        super(n);
        fix = f;
    }

    @Override
    public int salary() {
        return fix;
    }
}
public class PayrollMain {
    public static void main(String[] args) {
        Employee[] arr = {
                new FullTime("김정규", 54_000_000),
                new PartTime("이단기", 10_000, 80),
                new Contract("박계약", 3_000_000)
        };

        for (Employee e : arr) {
            if (e instanceof FullTime f) {
                System.out.printf(
                        "[FullTime] %s: %,d원%n", f.name, f.salary());
            } else if (e instanceof PartTime p) {
                System.out.printf(
                        "[PartTime] %s: %,d원%n", p.name, p.salary());
            } else if (e instanceof Contract c) {
                System.out.printf(
                        "[Contract] %s: %,d원%n", c.name, c.salary());
            } else {
                System.out.println("잘못된 양식");
            }
        }
        System.out.printf(
                "--- 총 지급액: %,d원 ---%n",
                Employee.totalPay(arr));
    }
}
[FullTime] 김정규: 4,500,000원
[PartTime] 이단기: 800,000원
[Contract] 박계약: 3,000,000원
--- 총 지급액: 8,300,000원 ---

Period 05 인터페이스 — 능력 약속 + 다중 구현

  1. interface를 정의하고 클래스가 implements로 구현할 수 있다
  2. 한 클래스가 여러 인터페이스를 동시에 구현해 다중 능력을 가질 수 있다
  3. default 메서드 충돌(다이아몬드)을 인식하고 명시 호출로 해결한다
  4. 상황에 따라 추상 클래스 vs 인터페이스 중 적절한 것을 선택할 수 있다

interface 문법 + default 메서드

  • 🏗️ 인터페이스(interface)의 핵심 정의와 문법
    • 인터페이스는 클래스가 구현해야 할 ‘기능 명세서’로, class 대신 interface 키워드를 사용하여 선언합니다.
    • 내부의 모든 메서드는 기본적으로 public abstract로 간주되어 바디({ }) 없이 시그니처만 정의하며, 클래스가 이를 implements하여 구체적인 로직을 완성합니다.
  • 🧱 default 메서드의 등장 배경
    • 기존 인터페이스는 모든 자식 클래스에서 메서드 구현을 강제하므로, 인터페이스에 새로운 기능을 추가하면 이를 상속받은 모든 클래스에서 컴파일 에러가 발생하여 유지보수가 매우 어려웠습니다.
    • 자바 8부터 도입된 default 메서드는 인터페이스 내부에 구현부({ })를 직접 작성할 수 있게 하여, 모든 구현 클래스가 공통된 기본 동작을 상속받으면서도 필요시 재정의할 수 있도록 유연성을 제공합니다.
  • ⚡ 다중 구현과 메서드 충돌 방지
    • 인터페이스는 다중 구현이 가능하므로, 서로 다른 인터페이스에서 동일한 시그니처를 가진 default 메서드를 상속받을 경우 충돌이 발생합니다.
    • 자바는 이를 해결하기 위해 구현 클래스에서 해당 메서드를 반드시 명시적으로 오버라이딩하여 어떤 인터페이스의 기능을 사용할지(또는 재정의할지) 결정하도록 강제합니다.
public interface Printable {
  // public abstract 생략됨 — 모든 메서드 자동
  void print();

  // Java 8+ default 메서드 — 기본 구현 제공
  default void display() {
    System.out.println("[기본 출력] ");
    print();
  }

  // 상수만 가능 (public static final 생략)
  String VERSION = "1.0";
}
public class Student extends Person
       implements Printable {

  @Override
  public void print() {                // abstract 메서드 구현 의무
    System.out.println("Student: " + name);
  }
  // display()는 default로 그대로 사용
}

실습 1 Printable 인터페이스 + 다형성 그릇

// Printable.java
public interface Printable {
  void print();
}

// Student.java
public class Student extends Person
       implements Printable {

  public Student(String n) { super(n); }

  @Override
  public void print() {
    System.out.println("[Student] " + name);
  }
}

// Teacher.java
public class Teacher extends Person
       implements Printable {

  public Teacher(String n) { super(n); }

  @Override
  public void print() {
    System.out.println("[Teacher] " + name);
  }
}
// PrintMain.java
public class PrintMain {
  public static void main(String[] args) {

    // Printable 그릇에 자식 객체 담기 (다중 타입)
    Printable[] items = {
      new Student("홍길동"),
      new Teacher("김선생"),
      new Student("이서연")
    };

    for (Printable item : items) {
      item.print();
    }
  }
}
[Student] 홍길동
[Teacher] 김선생
[Student] 이서연

실습 2 Loggable 추가 — Student는 두 능력을 가진다

// Loggable.java
public interface Loggable {
  void log(String message);

  default void logInfo(String msg) {
    log("[INFO] " + msg);
  }
}

// Student.java
public class Student extends Person
       implements Printable, Loggable {

  public Student(String n) { super(n); }

  @Override
  public void print() {
    System.out.println("학생: " + name);
  }

  @Override
  public void log(String msg) {
    System.out.println(name + " → " + msg);
  }
}
// MultiMain.java
Student s = new Student("홍길동");

// Printable 능력
s.print();

// Loggable 능력 (default 메서드 포함)
s.log("수업 시작");
s.logInfo("오늘 컨디션 최상");

// 둘 다 부모 타입으로도 다룰 수 있음
Printable p = s;   p.print();
Loggable  l = s;   l.log("end");
학생: 홍길동
홍길동  수업 시작
홍길동  [INFO] 오늘 컨디션 최상
학생: 홍길동
홍길동  end

📌 47페이지: 잠깐 — 두 default가 같은 이름이면 다이아몬드

// 충돌 시연
interface Printable {
  default void display() {
    System.out.println("[화면 출력]");
  }
}
interface Loggable {
  default void display() {
    System.out.println("[로그 기록]");
  }
}

public class Student
    implements Printable, Loggable { }
error: class Student inherits
unrelated defaults for
display() from types
Printable and Loggable
  • 🏗️ 다이아몬드 문제의 발생 원인
    • 하나의 클래스가 동일한 시그니처(이름, 매개변수)를 가진 default 메서드를 제공하는 두 개 이상의 인터페이스를 동시에 구현할 때 발생합니다.
    • 자바 컴파일러는 두 인터페이스 중 어느 쪽의 default 메서드를 우선시해야 할지 판단할 수 없으므로, 이를 ‘다이아몬드 모양의 상속 구조’에 의한 모호성 문제로 간주합니다.
  • 🧱 컴파일러의 엄격한 거부
    • 모호한 상태를 그대로 두면 프로그램의 실행 결과가 예측 불가능해지기 때문에, 자바는 이를 묵인하지 않고 즉시 컴파일 에러를 발생시켜 개발자가 해결하도록 강제합니다.
    • 이는 다중 상속(다중 구현)을 허용하면서도, 부모 간의 충돌을 명확하게 해결하게 하여 언어의 신뢰성과 안전성을 높이는 설계상의 선택입니다.
  • ⚡ 해결 방법: 개발자의 명시적 오버라이딩
    • 충돌이 발생한 클래스에서 해당 메서드를 반드시 오버라이딩하여 재정의해야 합니다.
    • 이때 인터페이스명.super.메서드명(); 구문을 사용하여 어떤 인터페이스의 기능을 사용할지 명시하거나, 아예 새로운 로직을 작성함으로써 모호성을 제거하고 개발자의 의도를 명확히 전달해야 합니다.
public class Student
    implements Printable, Loggable {

  @Override
  public void display() {
    // 방법 1: 한 쪽 명시 호출
    Printable.super.display();

    // 방법 2: 둘 다 호출도 가능
    Loggable.super.display();

    // 방법 3: 완전히 새 동작도 가능
    System.out.println("[Student] 학생 표시");
  }
}

이름: 두 부모(인터페이스)에서 내려오는 같은 메서드 → 자식이 정점 → 다이아몬드(◇) 모양. C++에선 진짜 다중 상속의 골칫거리. 자바는 인터페이스로만 허용하고 컴파일러가 강제로 해결을 요구합니다.


실습 3 언제 abstract, 언제 interface?

// 추상 클래스 — 공통 상태 (name, age)
public abstract class Person {
  protected String name;
  protected int age;
  public abstract void work();
}

// 인터페이스 — 능력 약속
public interface Printable {
  void print();
}

// 자식: 부모(상태) + 인터페이스(능력)
public class Student extends Person
       implements Printable {
  public Student(String n, int a) { super(n); age = a; }

  @Override public void work()  { ... }
  @Override public void print() { ... }
}

관찰: Person으로 상태와 work()를 받고, Printable로 능력을 추가했습니다. 한 클래스가 둘 다의 장점을 가집니다.

abstract class vs interface 비교 요약

| 비교 항목 | 추상 클래스 (abstract class) | 인터페이스 (interface) | | — | — | — | | 관계 | “is-a” (상속/확장 관계) | “has-a” (기능 구현 관계) | | 상속 개수 | 단일 상속만 가능 | 다중 구현 가능 | | 필드(변수) | 일반 필드 및 정적/상수 모두 가능 | public static final 상수만 가능 | | 생성자 | 생성자 존재 (자식 객체 초기화용) | 생성자 없음 | | 일반 메서드 | 일반 메서드 구현 가능 | default 메서드만 구현 가능 | | 추상 메서드 | 추상 메서드와 일반 메서드 혼합 가능 | 모든 메서드가 추상 메서드(기본) |

  • 🏗️ 추상 클래스의 핵심: 공통 상태 공유
    • 상태(필드)를 가지거나, 자식 클래스 간의 공통적인 기반 로직을 공유하고 싶을 때 주로 사용합니다. 상속을 통해 부모의 속성을 물려받아 객체의 계층 구조를 명확히 할 때 유리합니다.
  • 🧱 인터페이스의 핵심: 기능 명세 및 다형성
    • 클래스가 무엇을 할 수 있는지(Capability)를 정의합니다. 다중 구현이 가능하므로, 서로 다른 계층의 클래스들이 동일한 기능을 수행하도록 강제하여 다형성을 극대화할 때 강력한 도구가 됩니다.
  • ⚡ 설계 선택 가이드
    • 부모-자식 간의 긴밀한 계층 구조를 형성해야 한다면 abstract class를, 서로 관계없는 클래스들에게 특정 동작을 수행하도록 계약을 맺고 싶다면 interface를 선택하는 것이 표준 설계 원칙입니다.

도전 문제 — Rankbale로 정렬

// 문제

interface Rankable { int rank(); }를 만들고 Student(점수 기준)와 Product(가격 기준) 두 클래스가 모두 implements 하라. Rankable[]에 둘을 섞어 담고 rank 내림차순 정렬해 출력하라.

// 조건

  • 정렬 알고리즘은 3장의 선택정렬을 직접 작성 (Arrays.sort 금지)
  • 출력은 toString을 활용 (각 클래스에서 다르게)
  • Rankable[]에 들어간 Student와 Product를 instanceof로 구분해 표시
// Student.java
public class Student implements Rankable {
    private String name;
    private int score;

    public Student(String n, int s) {
        this.name = n;
        this.score = s;
    }

    public int rank() { return score; }

    public String toString() {
        return "[Student] " + name + "(" + score + "점)";
    }
}

// Product.java
public class Product implements Rankable {
    private String name;
    private int price;

    public Product(String n, int p) {
        this.name = n;
        this.price = p;
    }

    @Override
    public int rank() { return price; }

    public String toString() {
        return "[Product] " + name + "(" + price + "만원)";
    }
}
public class RankMain {
    public static void main(String[] args) {
        Rankable[] arr = {
                new Student("홍길동", 95),
                new Product("마우스", 3),
                new Product("노트북", 250)
        };

        for (int i=0; i < arr.length; i++) {
            int maxIdx = i;
            for (int j=i+1; j < arr.length; j++) {
                if (arr[j].rank() > arr[maxIdx].rank()) {
                    maxIdx = j;
                }
            }
            if (maxIdx != i) {
                Rankable temp = arr[i];
                arr[i] = arr[maxIdx];
                arr[maxIdx] = temp;
            }
        }

        for (Rankable r : arr) {
            System.out.println(r);
        }
    }
}





© 2017. by isme2n

Powered by aiden