김영한의 실전 자바 - 기본편 강의 내용을 정리한 글이다.
1. 다형성이 없을 때의 문제
다형성의 장점을 이해하려면 다형성 없이 코드를 작성했을 때 어떤 문제가 생기는지를 먼저 봐야 한다. 가장 고전적인 예제인 동물 울음 소리를 통해 살펴보자.
Dog, Cat, Caw(소) 세 클래스가 있고, 각자 sound() 메서드를 가진다. 이들 사이에는 아무런 상속 관계가 없다.
세 클래스는 서로 완전히 독립적이다. 이 상태에서 동물 소리 테스트 코드를 작성하면 다음과 같은 중복이 발생한다.
새로운 동물이 추가될 때마다 같은 패턴의 코드가 계속 늘어난다. 이 중복을 제거하려면 메서드나 배열을 사용하면 되지만, 둘 다 타입이 하나여야 한다는 제약이 있다.
- 메서드로 중복 제거 시도: 매개변수 타입을
Dog,Cat,Caw중 하나로 정해야 하므로, 나머지 두 타입은 인수로 사용할 수 없다. - 배열로 중복 제거 시도:
Dog[],Cat[],Caw[]중 하나의 배열만 만들 수 있으므로, 세 타입을 하나의 배열에 담는 것이 불가능하다.
문제의 핵심은 타입이 다르다는 점이다. 세 클래스가 모두 같은 타입을 사용할 수 있다면, 메서드와 배열을 활용해서 중복을 제거할 수 있다. 다형성의 핵심인 다형적 참조와 메서드 오버라이딩이 바로 이 문제를 해결한다.
2. 다형성 활용 — 상속으로 타입 통일
다형성을 사용하려면 상속 관계가 필요하다. Animal이라는 부모 클래스를 만들고, Dog, Cat, Caw가 이를 상속받아 sound()를 오버라이딩하도록 구조를 바꾼다.
이제 Dog, Cat, Caw는 모두 Animal 타입이다. 다형적 참조 덕분에 부모 타입 변수에 자식 인스턴스를 담을 수 있고, 메서드 오버라이딩 덕분에 animal.sound()를 호출하면 각 자식의 오버라이딩된 메서드가 실행된다.
메서드로 중복 제거
soundAnimal(Animal animal) 메서드는 매개변수 타입이 Animal이다. 다형적 참조 덕분에 Animal의 자식인 Dog, Cat, Caw 인스턴스를 모두 인수로 전달할 수 있다. 메서드 내부에서 animal.sound()를 호출하면, Dog 인스턴스가 들어왔을 때는 Dog.sound()가, Cat이 들어왔을 때는 Cat.sound()가 실행된다.
실행 흐름을 정리하면 다음과 같다.
soundAnimal(dog)를 호출하면Animal animal = dog가 된다. 부모 타입은 자식 인스턴스를 참조할 수 있다.- 메서드 내부에서
animal.sound()를 호출한다. animal의 타입은Animal이므로Animal.sound()를 먼저 찾는다.- 그런데 하위 클래스인
Dog에서sound()를 오버라이딩했으므로, 오버라이딩한 메서드가 우선권을 가진다. - 결과적으로
Dog.sound()가 실행되어 "멍멍"이 출력된다.
배열과 for문으로 한층 더 간결하게
세 클래스가 모두 Animal 타입이므로 Animal[] 배열에 담을 수 있다.
새로운 동물이 추가되어도 soundAnimal() 메서드의 코드는 전혀 변경할 필요가 없다. 변하는 부분은 main()에서 새 동물 인스턴스를 생성하는 코드뿐이다. 이처럼 변하는 부분과 변하지 않는 부분을 명확히 구분하는 것이 잘 작성된 코드다.
새로운 기능이 추가될 때 변하는 부분을 최소화하는 것이 좋은 설계다. 변하지 않는 부분에 코드가 집중될수록 유지보수가 쉬워진다.
3. 남은 문제와 추상 클래스의 필요성
앞서 살펴본 다형성 활용 코드에는 두 가지 문제가 남아 있다.
| 문제 | 내용 |
|---|---|
| Animal 클래스를 직접 생성 가능 | new Animal()이 문법상 허용되지만, "동물"이라는 추상적 개념이 실체로 존재하는 것은 의미가 없다. 생성된 인스턴스는 제대로 된 기능을 수행하지 않는다. |
| 자식 클래스에서 sound() 오버라이딩을 빠뜨릴 수 있음 | 예를 들어 Pig 클래스를 만들 때 sound()를 오버라이딩하지 않아도 컴파일 오류가 발생하지 않는다. 실행하면 부모의 Animal.sound()가 호출되어 "동물 울음 소리"라는 의도치 않은 결과가 나온다. |
추상 클래스와 추상 메서드를 사용하면 이 두 가지 문제를 컴파일 시점에 원천 차단할 수 있다.
4. 추상 클래스
실체 인스턴스가 존재하지 않고, 상속을 목적으로 부모 클래스 역할만 담당하는 클래스를 추상 클래스라 한다. 클래스 선언 앞에 abstract 키워드를 붙이면 된다.
추상 클래스의 특징
new AbstractAnimal()로 직접 인스턴스를 생성할 수 없다. 시도하면 컴파일 오류가 발생한다.- 추상 메서드가 하나라도 있는 클래스는 반드시 추상 클래스로 선언해야 한다.
- 추상 클래스를 상속받는 자식 클래스는 추상 메서드를 반드시 오버라이딩해야 한다. 하지 않으면 자식도 추상 클래스가 되어야 한다.
- 추상 메서드가 아닌 일반 메서드(
move()등)는 그대로 상속되며, 자식이 오버라이딩하지 않아도 된다. - 추상 클래스는 제약이 추가된 클래스일 뿐이다. 메모리 구조와 실행 방식은 일반 클래스와 완전히 동일하다.
추상 메서드의 특징
추상 메서드는 메서드 선언만 있고 바디(구현부)가 없는 메서드다. 메서드 앞에 abstract 키워드를 붙이고, 중괄호 대신 세미콜론으로 끝낸다.
추상 메서드는 "이 메서드는 자식이 반드시 구현해야 한다"는 강제 계약이다. 자식 클래스에서 오버라이딩하지 않으면 컴파일 오류가 발생하므로, 실수로 오버라이딩을 빠뜨리는 문제를 원천 방지할 수 있다.
컴파일 오류 예시
5. 순수 추상 클래스와 인터페이스
순수 추상 클래스
모든 메서드가 추상 메서드인 클래스를 순수 추상 클래스라 한다. 실행 로직이 전혀 없고, 다형성을 위한 부모 타입으로써 껍데기 역할만 한다.
순수 추상 클래스는 마치 USB 인터페이스 규격처럼 느껴진다. USB 규격에 맞춰 키보드, 마우스를 개발해야 연결되듯, 순수 추상 클래스에 맞춰 자식 클래스를 구현해야 한다. 자바는 이 개념을 더 편리하게 사용할 수 있도록 인터페이스라는 문법을 제공한다.
인터페이스
인터페이스는 class 대신 interface 키워드로 선언한다. 인터페이스의 모든 메서드는 자동으로 public abstract이므로 이 키워드를 생략하는 것이 관례다.
인터페이스의 멤버 변수는 자동으로 public static final이 적용된다. 즉, 인터페이스에 선언하는 변수는 모두 상수다.
인터페이스 구현
인터페이스를 받을 때는 extends 대신 implements를 사용한다. 이 관계를 "상속"이 아닌 "구현"이라고 부른다. 이유는 상속이 부모의 기능을 물려받는 것이 목적인 반면, 인터페이스는 모든 메서드가 추상 메서드이므로 물려받을 기능이 없고, 자식이 모든 메서드를 직접 구현해야 하기 때문이다.
UML에서 클래스 상속 관계는 실선으로, 인터페이스 구현 관계는 점선으로 표현한다.
6. 인터페이스를 사용해야 하는 이유
모든 메서드가 추상 메서드인 경우 순수 추상 클래스를 만들어도 되고, 인터페이스를 만들어도 된다. 그런데 인터페이스를 선호하는 이유가 있다.
| 이유 | 설명 |
|---|---|
| 강제적인 제약 | 순수 추상 클래스는 미래에 누군가 실행 가능한 메서드를 추가할 수 있다. 그렇게 되면 더는 순수 추상 클래스가 아니게 되고, 자식 클래스가 그 메서드를 구현하지 않아도 되는 문제가 생긴다. 인터페이스는 모든 메서드가 추상 메서드임이 보장되므로, 이런 문제를 원천 차단한다. |
| 다중 구현 지원 | 자바에서 클래스 상속은 부모를 하나만 지정할 수 있다. 반면 인터페이스는 여러 개를 동시에 구현(다중 구현)할 수 있다. |
좋은 프로그램은 제약이 있는 프로그램이다. 인터페이스는 구현하는 쪽이 반드시 해당 메서드를 구현해야 한다는 명확한 계약을 강제한다.
자바8에서 등장한 default 메서드를 사용하면 인터페이스에도 구현부를 가진 메서드를 추가할 수 있다. 하지만 이는 예외적인 경우에만 사용하는 기능이며, 기본적으로는 인터페이스의 모든 메서드는 추상 메서드라고 이해하면 된다.
7. 인터페이스 다중 구현
자바가 클래스 다중 상속을 지원하지 않는 이유
자바는 클래스의 다중 상속을 지원하지 않는다. 그 이유는 다이아몬드 문제 때문이다. 아래 상황을 보자.
AirplaneCar가 Airplane과 Car를 모두 상속받으면, move()를 호출할 때 어느 부모의 move()를 사용해야 할지 애매해진다. 이것이 다이아몬드 문제다. 자바는 이 문제를 원천적으로 피하기 위해 클래스의 다중 상속을 금지한다.
인터페이스는 다중 구현이 가능한 이유
인터페이스는 모든 메서드가 추상 메서드다. 구현부가 없으므로 두 인터페이스에 같은 이름의 메서드가 있어도, 실제 구현은 자식 클래스가 하나만 제공하면 된다. 어느 부모의 것을 쓸지 고민할 필요가 없다. 따라서 인터페이스의 다중 구현은 허용된다.
methodCommon()은 두 인터페이스 모두에 있지만, Child에서 구현을 하나만 제공하면 된다. 오버라이딩에 의해 어차피 Child의 구현이 호출되므로 다이아몬드 문제가 발생하지 않는다.
8. 클래스 상속과 인터페이스 구현의 조합
클래스 상속과 인터페이스 구현을 함께 사용할 수 있다. 클래스 상속은 하나만 가능하지만, 인터페이스 구현은 여러 개가 가능하다. 둘을 함께 사용할 때는 extends가 implements보다 먼저 나와야 한다.
이 구조의 핵심은 메서드의 매개변수 타입이다. soundAnimal(AbstractAnimal animal)에는 AbstractAnimal을 상속받은 Dog, Bird, Chicken을 모두 전달할 수 있다. flyAnimal(Fly fly)에는 Fly 인터페이스를 구현한 Bird, Chicken만 전달할 수 있다. Dog는 Fly를 구현하지 않았으므로 전달할 수 없다.
9. 정리
다형성2에서 다룬 핵심 개념을 한눈에 비교하면 다음과 같다.
| 구분 | 일반 클래스 | 추상 클래스 | 인터페이스 |
|---|---|---|---|
| 선언 | class |
abstract class |
interface |
| 인스턴스 생성 | 가능 | 불가능 | 불가능 |
| 메서드 구현 | 모두 구현 | 일반 + 추상 혼용 | 모두 추상(구현 없음) |
| 상속/구현 키워드 | extends |
extends |
implements |
| 다중 상속/구현 | 불가 | 불가 | 가능 |
| 멤버 변수 | 일반 변수 | 일반 변수 | 상수(public static final) |
다형성이 해결하는 문제
- 다형적 참조: 부모 타입 변수 하나로 모든 자식 인스턴스를 참조할 수 있다. 서로 다른 타입의 객체를 하나의 배열이나 메서드로 처리할 수 있게 된다.
- 메서드 오버라이딩: 부모 타입으로 메서드를 호출해도 실제 인스턴스에 맞는 오버라이딩된 메서드가 실행된다. 코드를 변경하지 않아도 새로운 자식 클래스에 맞는 동작이 자동으로 이루어진다.
- 추상 클래스/인터페이스: 잘못된 인스턴스 생성과 메서드 미구현이라는 실수를 컴파일 시점에 잡아준다. 구현 규칙을 강제함으로써 프로그램의 안정성을 높인다.
다형성의 핵심은 "변하지 않는 코드"를 만드는 것이다. 새로운 동물이 추가되어도
soundAnimal()메서드는 수정하지 않아도 된다. 이 메서드는 구체적인 클래스가 아닌 추상적인 부모(Animal혹은 인터페이스)를 참조하기 때문이다. 이것이 다형성이 가져다주는 핵심 가치다.
'Study > Java' 카테고리의 다른 글
| [Java] 다형성 Part.1 (1) | 2026.04.09 |
|---|---|
| [Java] 상속 (0) | 2026.04.09 |
| [Java] final (0) | 2026.04.07 |
| [Java] 자바의 메모리 구조 (0) | 2026.04.06 |
| [Java] 접근 제한자 (0) | 2026.04.06 |
