[Java] Java 05 Relationship between Objects

- Period 01 상속(extends) — 코드 복붙을 끝내자
- Period 02 오버라이딩(@Override) — 자식이 부모를 덮어쓰는 법
- Period 03 다형성 • 업캐스팅 • 다운캐스팅 • instanceof
- Period 04 추상 클래스 abstract — 자식에게 강제하는 약속
- Period 05 인터페이스 — 능력 약속 + 다중 구현
Period 01 상속(extends) — 코드 복붙을 끝내자
class B extends A문법으로 두 클래스를 부모-자식 관계로 만들 수 있다- 자식 클래스에서 부모의 필드와 메서드를 그대로 재사용할 수 있다
- 객체 생성 시
부모 -> 자식순서로 생성자가 호출되는 흐름을 설명할 수 있다 - 자식 생성자 첫 줄에
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) — 자식이 부모를 덮어쓰는 법
- 부모 메서드와 같은 시그니처로
@Override메서드를 작성할 수 있다 - 자식 메서드 안에서
super.메서드()로 부모 동작을 재사용할 수 있다 @Override어노테이션이 오타로 인한 “보이지 않는 버그”를 어떻게 막는지 설명할 수 있다- 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
- 업캐스팅(자동)과 다운캐스팅(명시)의 차이를 그림으로 설명할 수 있다
Person[]에 자식들을 담고 같은 메서드 호출로 각자 다른 결과를 얻는다- “그릇 타입이 보여주는 메서드만 보인다”는 원리로 컴파일 오류를 설명할 수 있다
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()는 물리적으로 존재하지 않는 것처럼 간주되어 호출할 수 없습니다.
- 업캐스팅(Upcasting)을 통해
- 🧱 컴파일러의 시선 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 — 자식에게 강제하는 약속
abstract class와abstract method를 작성할 수 있다- 자식 클래스에서 모든 abstract 메서드를 구현해 컴파일을 통과시킨다
- “추상 클래스는 왜 인스턴스화가 안 되는가”를 컴파일 오류 메시지로 설명한다
- 일반 메서드(공통 구현)와 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 인터페이스 — 능력 약속 + 다중 구현
interface를 정의하고 클래스가implements로 구현할 수 있다- 한 클래스가 여러 인터페이스를 동시에 구현해 다중 능력을 가질 수 있다
default메서드 충돌(다이아몬드)을 인식하고 명시 호출로 해결한다- 상황에 따라 추상 클래스 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);
}
}
}
