[Java] 다형성 Part.2

Java 기본 — 다형성2

김영한의 실전 자바 - 기본편 강의 내용을 정리한 글이다.

1. 다형성이 없을 때의 문제

다형성의 장점을 이해하려면 다형성 없이 코드를 작성했을 때 어떤 문제가 생기는지를 먼저 봐야 한다. 가장 고전적인 예제인 동물 울음 소리를 통해 살펴보자.

Dog, Cat, Caw(소) 세 클래스가 있고, 각자 sound() 메서드를 가진다. 이들 사이에는 아무런 상속 관계가 없다.

sound() Dog sound() Cat sound() Caw

세 클래스는 서로 완전히 독립적이다. 이 상태에서 동물 소리 테스트 코드를 작성하면 다음과 같은 중복이 발생한다.

System.out.println("동물 소리 테스트 시작"); dog.sound(); System.out.println("동물 소리 테스트 종료"); System.out.println("동물 소리 테스트 시작"); cat.sound(); System.out.println("동물 소리 테스트 종료"); System.out.println("동물 소리 테스트 시작"); caw.sound(); System.out.println("동물 소리 테스트 종료");

새로운 동물이 추가될 때마다 같은 패턴의 코드가 계속 늘어난다. 이 중복을 제거하려면 메서드나 배열을 사용하면 되지만, 둘 다 타입이 하나여야 한다는 제약이 있다.

  • 메서드로 중복 제거 시도: 매개변수 타입을 Dog, Cat, Caw 중 하나로 정해야 하므로, 나머지 두 타입은 인수로 사용할 수 없다.
  • 배열로 중복 제거 시도: Dog[], Cat[], Caw[] 중 하나의 배열만 만들 수 있으므로, 세 타입을 하나의 배열에 담는 것이 불가능하다.

문제의 핵심은 타입이 다르다는 점이다. 세 클래스가 모두 같은 타입을 사용할 수 있다면, 메서드와 배열을 활용해서 중복을 제거할 수 있다. 다형성의 핵심인 다형적 참조와 메서드 오버라이딩이 바로 이 문제를 해결한다.


2. 다형성 활용 — 상속으로 타입 통일

다형성을 사용하려면 상속 관계가 필요하다. Animal이라는 부모 클래스를 만들고, Dog, Cat, Caw가 이를 상속받아 sound()를 오버라이딩하도록 구조를 바꾼다.

sound() Animal sound() Dog sound() Cat sound() Caw
public class Animal { public void sound() { System.out.println("동물 울음 소리"); } } public class Dog extends Animal { @Override public void sound() { System.out.println("멍멍"); } } public class Cat extends Animal { @Override public void sound() { System.out.println("나옹"); } }

이제 Dog, Cat, Caw는 모두 Animal 타입이다. 다형적 참조 덕분에 부모 타입 변수에 자식 인스턴스를 담을 수 있고, 메서드 오버라이딩 덕분에 animal.sound()를 호출하면 각 자식의 오버라이딩된 메서드가 실행된다.

메서드로 중복 제거

public class AnimalPolyMain1 { public static void main(String[] args) { Dog dog = new Dog(); Cat cat = new Cat(); Caw caw = new Caw(); soundAnimal(dog); soundAnimal(cat); soundAnimal(caw); } // 동물이 추가되어도 변하지 않는 코드 private static void soundAnimal(Animal animal) { System.out.println("동물 소리 테스트 시작"); animal.sound(); System.out.println("동물 소리 테스트 종료"); } }

soundAnimal(Animal animal) 메서드는 매개변수 타입이 Animal이다. 다형적 참조 덕분에 Animal의 자식인 Dog, Cat, Caw 인스턴스를 모두 인수로 전달할 수 있다. 메서드 내부에서 animal.sound()를 호출하면, Dog 인스턴스가 들어왔을 때는 Dog.sound()가, Cat이 들어왔을 때는 Cat.sound()가 실행된다.

x001 Animal animal sound() sound() Animal sound() Dog 오버라이딩 호출 new Dog()

실행 흐름을 정리하면 다음과 같다.

  1. soundAnimal(dog)를 호출하면 Animal animal = dog가 된다. 부모 타입은 자식 인스턴스를 참조할 수 있다.
  2. 메서드 내부에서 animal.sound()를 호출한다.
  3. animal의 타입은 Animal이므로 Animal.sound()를 먼저 찾는다.
  4. 그런데 하위 클래스인 Dog에서 sound()를 오버라이딩했으므로, 오버라이딩한 메서드가 우선권을 가진다.
  5. 결과적으로 Dog.sound()가 실행되어 "멍멍"이 출력된다.

배열과 for문으로 한층 더 간결하게

세 클래스가 모두 Animal 타입이므로 Animal[] 배열에 담을 수 있다.

Animal[] animalArr = {new Dog(), new Cat(), new Caw()}; // 변하지 않는 부분 for (Animal animal : animalArr) { System.out.println("동물 소리 테스트 시작"); animal.sound(); System.out.println("동물 소리 테스트 종료"); }

새로운 동물이 추가되어도 soundAnimal() 메서드의 코드는 전혀 변경할 필요가 없다. 변하는 부분은 main()에서 새 동물 인스턴스를 생성하는 코드뿐이다. 이처럼 변하는 부분과 변하지 않는 부분을 명확히 구분하는 것이 잘 작성된 코드다.

새로운 기능이 추가될 때 변하는 부분을 최소화하는 것이 좋은 설계다. 변하지 않는 부분에 코드가 집중될수록 유지보수가 쉬워진다.


3. 남은 문제와 추상 클래스의 필요성

앞서 살펴본 다형성 활용 코드에는 두 가지 문제가 남아 있다.

문제내용
Animal 클래스를 직접 생성 가능 new Animal()이 문법상 허용되지만, "동물"이라는 추상적 개념이 실체로 존재하는 것은 의미가 없다. 생성된 인스턴스는 제대로 된 기능을 수행하지 않는다.
자식 클래스에서 sound() 오버라이딩을 빠뜨릴 수 있음 예를 들어 Pig 클래스를 만들 때 sound()를 오버라이딩하지 않아도 컴파일 오류가 발생하지 않는다. 실행하면 부모의 Animal.sound()가 호출되어 "동물 울음 소리"라는 의도치 않은 결과가 나온다.

추상 클래스와 추상 메서드를 사용하면 이 두 가지 문제를 컴파일 시점에 원천 차단할 수 있다.


4. 추상 클래스

실체 인스턴스가 존재하지 않고, 상속을 목적으로 부모 클래스 역할만 담당하는 클래스를 추상 클래스라 한다. 클래스 선언 앞에 abstract 키워드를 붙이면 된다.

public abstract class AbstractAnimal { public abstract void sound(); // 추상 메서드: 바디 없음 public void move() { System.out.println("동물이 움직입니다."); } }

추상 클래스의 특징

  • new AbstractAnimal()로 직접 인스턴스를 생성할 수 없다. 시도하면 컴파일 오류가 발생한다.
  • 추상 메서드가 하나라도 있는 클래스는 반드시 추상 클래스로 선언해야 한다.
  • 추상 클래스를 상속받는 자식 클래스는 추상 메서드를 반드시 오버라이딩해야 한다. 하지 않으면 자식도 추상 클래스가 되어야 한다.
  • 추상 메서드가 아닌 일반 메서드(move() 등)는 그대로 상속되며, 자식이 오버라이딩하지 않아도 된다.
  • 추상 클래스는 제약이 추가된 클래스일 뿐이다. 메모리 구조와 실행 방식은 일반 클래스와 완전히 동일하다.

추상 메서드의 특징

추상 메서드는 메서드 선언만 있고 바디(구현부)가 없는 메서드다. 메서드 앞에 abstract 키워드를 붙이고, 중괄호 대신 세미콜론으로 끝낸다.

public abstract void sound(); // 바디가 없다. ; 로 끝난다.

추상 메서드는 "이 메서드는 자식이 반드시 구현해야 한다"는 강제 계약이다. 자식 클래스에서 오버라이딩하지 않으면 컴파일 오류가 발생하므로, 실수로 오버라이딩을 빠뜨리는 문제를 원천 방지할 수 있다.

abstract sound() move() AbstractAnimal sound() Dog sound() Cat sound() Caw

컴파일 오류 예시

// 추상 클래스 직접 생성 시도 → 컴파일 오류 AbstractAnimal animal = new AbstractAnimal(); // java: AbstractAnimal is abstract; cannot be instantiated // 자식에서 추상 메서드 미구현 시 → 컴파일 오류 // java: Dog is not abstract and does not override abstract method sound()

5. 순수 추상 클래스와 인터페이스

순수 추상 클래스

모든 메서드가 추상 메서드인 클래스를 순수 추상 클래스라 한다. 실행 로직이 전혀 없고, 다형성을 위한 부모 타입으로써 껍데기 역할만 한다.

public abstract class AbstractAnimal { public abstract void sound(); public abstract void move(); }

순수 추상 클래스는 마치 USB 인터페이스 규격처럼 느껴진다. USB 규격에 맞춰 키보드, 마우스를 개발해야 연결되듯, 순수 추상 클래스에 맞춰 자식 클래스를 구현해야 한다. 자바는 이 개념을 더 편리하게 사용할 수 있도록 인터페이스라는 문법을 제공한다.

인터페이스

인터페이스는 class 대신 interface 키워드로 선언한다. 인터페이스의 모든 메서드는 자동으로 public abstract이므로 이 키워드를 생략하는 것이 관례다.

public interface InterfaceAnimal { void sound(); // public abstract 생략 void move(); }

인터페이스의 멤버 변수는 자동으로 public static final이 적용된다. 즉, 인터페이스에 선언하는 변수는 모두 상수다.

public interface InterfaceAnimal { double MY_PI = 3.14; // public static final 생략. 관례상 대문자+언더스코어 }

인터페이스 구현

인터페이스를 받을 때는 extends 대신 implements를 사용한다. 이 관계를 "상속"이 아닌 "구현"이라고 부른다. 이유는 상속이 부모의 기능을 물려받는 것이 목적인 반면, 인터페이스는 모든 메서드가 추상 메서드이므로 물려받을 기능이 없고, 자식이 모든 메서드를 직접 구현해야 하기 때문이다.

public class Dog implements InterfaceAnimal { @Override public void sound() { System.out.println("멍멍"); } @Override public void move() { System.out.println("개 이동"); } }

UML에서 클래스 상속 관계는 실선으로, 인터페이스 구현 관계는 점선으로 표현한다.

+sound() +move() InterfaceAnimal +sound() +move() Dog +sound() +move() Cat +sound() +move() Caw

6. 인터페이스를 사용해야 하는 이유

모든 메서드가 추상 메서드인 경우 순수 추상 클래스를 만들어도 되고, 인터페이스를 만들어도 된다. 그런데 인터페이스를 선호하는 이유가 있다.

이유설명
강제적인 제약 순수 추상 클래스는 미래에 누군가 실행 가능한 메서드를 추가할 수 있다. 그렇게 되면 더는 순수 추상 클래스가 아니게 되고, 자식 클래스가 그 메서드를 구현하지 않아도 되는 문제가 생긴다. 인터페이스는 모든 메서드가 추상 메서드임이 보장되므로, 이런 문제를 원천 차단한다.
다중 구현 지원 자바에서 클래스 상속은 부모를 하나만 지정할 수 있다. 반면 인터페이스는 여러 개를 동시에 구현(다중 구현)할 수 있다.

좋은 프로그램은 제약이 있는 프로그램이다. 인터페이스는 구현하는 쪽이 반드시 해당 메서드를 구현해야 한다는 명확한 계약을 강제한다.

자바8에서 등장한 default 메서드를 사용하면 인터페이스에도 구현부를 가진 메서드를 추가할 수 있다. 하지만 이는 예외적인 경우에만 사용하는 기능이며, 기본적으로는 인터페이스의 모든 메서드는 추상 메서드라고 이해하면 된다.


7. 인터페이스 다중 구현

자바가 클래스 다중 상속을 지원하지 않는 이유

자바는 클래스의 다중 상속을 지원하지 않는다. 그 이유는 다이아몬드 문제 때문이다. 아래 상황을 보자.

+ move() Airplane + move() Car + charge() AirplaneCar move()를 어느 부모에서 가져올까?

AirplaneCarAirplaneCar를 모두 상속받으면, move()를 호출할 때 어느 부모의 move()를 사용해야 할지 애매해진다. 이것이 다이아몬드 문제다. 자바는 이 문제를 원천적으로 피하기 위해 클래스의 다중 상속을 금지한다.

인터페이스는 다중 구현이 가능한 이유

인터페이스는 모든 메서드가 추상 메서드다. 구현부가 없으므로 두 인터페이스에 같은 이름의 메서드가 있어도, 실제 구현은 자식 클래스가 하나만 제공하면 된다. 어느 부모의 것을 쓸지 고민할 필요가 없다. 따라서 인터페이스의 다중 구현은 허용된다.

public interface InterfaceA { void methodA(); void methodCommon(); } public interface InterfaceB { void methodB(); void methodCommon(); } public class Child implements InterfaceA, InterfaceB { @Override public void methodA() { System.out.println("Child.methodA"); } @Override public void methodB() { System.out.println("Child.methodB"); } @Override public void methodCommon() { System.out.println("Child.methodCommon"); } }

methodCommon()은 두 인터페이스 모두에 있지만, Child에서 구현을 하나만 제공하면 된다. 오버라이딩에 의해 어차피 Child의 구현이 호출되므로 다이아몬드 문제가 발생하지 않는다.

+methodA() +methodCommon() InterfaceA +methodB() +methodCommon() InterfaceB +methodA() +methodB() +methodCommon() Child

8. 클래스 상속과 인터페이스 구현의 조합

클래스 상속과 인터페이스 구현을 함께 사용할 수 있다. 클래스 상속은 하나만 가능하지만, 인터페이스 구현은 여러 개가 가능하다. 둘을 함께 사용할 때는 extendsimplements보다 먼저 나와야 한다.

public class Bird extends AbstractAnimal implements Fly { @Override public void sound() { System.out.println("짹짹"); } @Override public void fly() { System.out.println("새 날기"); } } // 여러 인터페이스를 동시에 구현하는 경우 public class Bird extends AbstractAnimal implements Fly, Swim { ... }

이 구조의 핵심은 메서드의 매개변수 타입이다. soundAnimal(AbstractAnimal animal)에는 AbstractAnimal을 상속받은 Dog, Bird, Chicken을 모두 전달할 수 있다. flyAnimal(Fly fly)에는 Fly 인터페이스를 구현한 Bird, Chicken만 전달할 수 있다. DogFly를 구현하지 않았으므로 전달할 수 없다.

abstract sound() move() AbstractAnimal +fly() Fly sound() Dog sound() +fly() Bird sound() +fly() Chicken
public class SoundFlyMain { public static void main(String[] args) { Dog dog = new Dog(); Bird bird = new Bird(); Chicken chicken = new Chicken(); soundAnimal(dog); soundAnimal(bird); soundAnimal(chicken); flyAnimal(bird); // Dog는 Fly를 구현하지 않으므로 전달 불가 flyAnimal(chicken); } // AbstractAnimal 사용 가능 private static void soundAnimal(AbstractAnimal animal) { System.out.println("동물 소리 테스트 시작"); animal.sound(); System.out.println("동물 소리 테스트 종료"); } // Fly 인터페이스가 있으면 사용 가능 private static void flyAnimal(Fly fly) { System.out.println("날기 테스트 시작"); fly.fly(); System.out.println("날기 테스트 종료"); } }

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