🔬 다형성의 활용

개, 고양이, 소의 울음 소리를 테스트하는 간단한 프로그램을 먼저 다형성을 사용하지 않고 설계해보자.

package poly.ex1;

public class Dog {

    public void sound() {
        System.out.println("멍멍");
    }
}
package poly.ex1;

public class Cat {

    public void sound() {
        System.out.println("야옹야옹");
    }
}
package poly.ex1;

public class Cow {

    public void sound() {
        System.out.println("음매음매");
    }
}
package poly.ex1;

public class AnimalSoundMain {
    public static void main(String[] args) {

        Dog dog = new Dog();
        Cat cat = new Cat();
        Cow cow = new Cow();

        System.out.println("동물 소리 프로그램 실행!");
        dog.sound();
        System.out.println("동물 소리 프로그램 종료...");

        System.out.println("동물 소리 프로그램 실행!");
        cat.sound();
        System.out.println("동물 소리 프로그램 종료...");

        System.out.println("동물 소리 프로그램 실행!");
        cow.sound();
        System.out.println("동물 소리 프로그램 종료...");
    }
}

/*
동물 소리 프로그램 실행!
멍멍
동물 소리 프로그램 종료...
동물 소리 프로그램 실행!
야옹야옹
동물 소리 프로그램 종료...
동물 소리 프로그램 실행!
음매음매
동물 소리 프로그램 종료...
*/

여기서 새로운 동물 인스턴스를 추가하려면 인스턴스를 또 생성하고, 메서드 호출 및 출력하는 코드도 작성해줘야 한다. 중복의 냄새가 물씬 풍긴다. 지금까지는 메서드를 사용하거나, 배열과 for문을 사용해서 중복을 없애왔다. 하지만, 지금은 Dog, Cat, Cow는 서로 완전히 다른 클래스라는 것이 문제다.

 

package poly.ex1;

public class AnimalSoundMain {
    public static void main(String[] args) {

        Dog dog = new Dog();
        Cat cat = new Cat();
        Cow cow = new Cow();

        // incompatible types: poly.ex1.Dog cannot be converted to poly.ex1.Cow
        // sound(dog);

        // incompatible types: poly.ex1.Dog cannot be converted to poly.ex1.Cow
        // sound(cat);

        sound(cow);
    }

    private static void sound(Cow cow) {
        System.out.println("동물 소리 테스트 실행!");
        cow.sound();
        System.out.println("동물 소리 테스트 종료...");
    }

}

실제로 메서드를 만들어서 확인해보면, 타입이 맞지 않는다면서 컴파일 오류가 발생한다. sound() 메서드 안에 호출하는 메서드도 맞지 않고, 매개 변수의 타입 자체도 Cow 타입 하나만 받을 수 있으니 오류가 생기는 것이 당연하다. 그렇다고 각각에 맞는 메서드를 만들어준다? 코드를 줄이려고 하다가 더 늘리는 꼴이다.

배열을 사용해봐도 마찬가지다. 배열의 타입을 3개 중에 하나로 설정해야 하는데, 나머지는 타입이 달라 배열에 쑤셔 넣을 수가 없다. 새로운 동물이 추가될 때마다 더 많은 중복 코드가 발생하는 상황인 것이다.

지금 핵심은 “타입이 다르다” 는 점이다.

Dog, Cat, Cow가 모두 같은 타입을 사용할 수만 있다면 위의 문제가 해결될 것이다. 그게 뭐였지? 바로 다형적 참조메서드 오버라이딩을 이용하는 것이다…

다형성을 이용하기 위해서는 상속 관계를 이용해야 한다. 부모 클래스(Animal)을 만들고, 자식 클래스에서 오버라이딩 할 목적으로 sound() 메서드를 정의한다.

package poly.ex2;

public class Animal {

    public void sound() {
        System.out.println("동물 울음 소리");
    }
}
package poly.ex2;

public class Dog extends Animal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}
package poly.ex2;

public class Cat extends Animal {

    @Override
    public void sound() {
        System.out.println("야옹야옹");

    }
}
package poly.ex2;

public class Cow extends Animal{

    @Override
    public void sound() {
        System.out.println("음매음매");
    }
}
package poly.ex2;

public class AnimalPolyMain1 {
    public static void main(String[] args) {

        Dog dog = new Dog();
        Cat cat = new Cat();
        Cow cow = new Cow();

        Animal[] animals = new Animal[]{dog, cat, cow};
        
        for (Animal animal : animals) {
            soundAnimal(animal);
        }
    }

    private static void soundAnimal(Animal animal) {
        System.out.println("동물 소리 테스트 실행!");
        animal.sound();
        System.out.println("동물 소리 테스트 종료...");
    }
}

/*
동물 소리 테스트 실행!
멍멍
동물 소리 테스트 종료...
동물 소리 테스트 실행!
야옹야옹
동물 소리 테스트 종료...
동물 소리 테스트 실행!
음매음매
동물 소리 테스트 종료...
*/

의도대로 잘 호출되는 것을 볼 수 있다. 코드를 간단히 분석해보자면, soundAnimal(dog)를 호출하면 soundAnimal() 메서드의 매개 변수에 Dog 인스턴스 참조값이 전달된다. Dog 타입은 Animal 타입의 자식 타입이기 때문에 매개 변수로 담길 수 있는 것이다. 그리고 나서 메서드 안에서 우선권을 가진 오버라이딩 된 자식 타입의 sound() 메서드가 호출되는 것이다. 아래 그림을 참고하자.

Animal animal으로 코드를 수정했기 때문에 가능한 일이었다. 다형성 참조 덕분에 자식 인스턴스를 참조하고, 메서드 오버라이딩 덕분에 각각의 인스턴스의 sound() 메서드를 호출할 수 있는 것이다. 그러지 않았으면 부모(Animal)의 sound() 메서드가 호출되었을 것이다.

 

조금 더 개선해보자면 아래와 같은 코드가 완성될 것이다.

package poly.ex2;

public class AnimalPolyMain2 {
    public static void main(String[] args) {

        Animal[] animals = {new Dog(), new Cat(), new Cow()};

        for (Animal animal : animals) {
            soundAnimal(animal);
        }
    }

    private static void soundAnimal(Animal animal) {
        System.out.println("동물 소리 테스트 실행!");
        animal.sound();
        System.out.println("동물 소리 테스트 종료...");
    }
}

/*
동물 소리 테스트 실행!
멍멍
동물 소리 테스트 종료...
동물 소리 테스트 실행!
야옹야옹
동물 소리 테스트 종료...
동물 소리 테스트 실행!
음매음매
동물 소리 테스트 종료...
*/

근데 사실 지금까지 작업한 코드에는 2가지 문제점이 있다. 하나는 Animal 클래스를 생성할 수 있는 문제Animal 클래스를 상속 받는 곳에서 sound() 메서드 오버라이딩을 하지 않을 가능성이다.

 

개발을 하면서 위에서 살펴본 개, 고양이, 소처럼 구체적인 클래스는 당연히 이용하지만, 동물이라는 추상적인 클래스로부터 인스턴스를 생성할 일이 있을까? Animal 클래스를 왜 만들었는지 목적을 떠올려 보면 다형성을 이용하기 위한 것이지, 직접 사용하기 위해 만든 것은 아니다. 하지만, 추상적인 클래스라도 클래스이기 때문에 누군가 인스턴스를 생성해서 사용해도 전혀 문제가 되지는 않는다.

그리고 실수로 개발하다가 구체적인 클래스에서 메서드를 오버라이딩 하는 것을 까먹었다고 한다면, 그 인스턴스의 메서드를 호출할 때, 부모 클래스의 메서드가 호출되는 문제가 생길 수 있다. 이 문제들을 추상 클래스와 추상 메서드를 통해 해결해보자.


🖼 추상 클래스

위의 동물(Animal)과 같이 부모 클래스는 제공하지만, 실제 생성되면 안 되는 클래스를 추상 클래스라 한다. 대신, 상속을 목적으로 사용되고 부모 클래스 역할을 담당한다.

추상 클래스를 선언할 때는 abstract 키워드를 붙여주면 된다. 추상 클래스는 기존 클래스와 완전히 같지만, 직접 인스턴스를 생성하지 못하는 제약이 추가된 것이다.

추상 메서드를 사용하면 부모 클래스를 상속 받는 자식 클래스가 반드시 오버라이딩 해야 하는 메서드를 부모 클래스에 정의할 수 있다. 실체가 존재하지 않고, 메서드 바디가 없다. 추상 메서드도 마찬가지로 앞에 abstract 키워드를 붙여주면 된다.

 

<주의할 점>

  • 추상 메서드가 하나라도 있는 클래스는 추상 클래스로 선언해야 한다.
  • 추상 메서드는 상속 받는 자식 클래스가 반드시 오버라이딩 해서 사용해야 한다.
    • 만약 오버라이딩 하지 않으면 자식도 추상 클래스가 되어야 한다.

 

간단하게 예제로 살펴보자.

package poly.ex3;

public abstract class AbstractAnimal {

    // Abstract methods cannot have a body
    public abstract void sound();

    public void move() {
        System.out.println("동물이 움직입니다.");
    }
}
package poly.ex3;

public class Dog extends AbstractAnimal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}
package poly.ex3;

public class Cat extends AbstractAnimal {
    
    @Override
    public void sound() {
        System.out.println("야옹야옹");
    }
}
package poly.ex3;

public class Cow extends AbstractAnimal {

    @Override
    public void sound() {
        System.out.println("음매음매");
    }
}
package poly.ex3;

public class AbstractMain {
    public static void main(String[] args) {

        // 'AbstractAnimal' is abstract; cannot be instantiated
        // AbstractAnimal animal = new AbstractAnimal();

        Dog dog = new Dog();
        Cat cat = new Cat();
        Cow cow = new Cow();

        dog.move();
        dog.sound();

        soundAnimal(dog);
        soundAnimal(cat);
        soundAnimal(cow);
    }

    private static void soundAnimal(AbstractAnimal animal) {
        System.out.println("동물 소리 테스트 실행!");
        animal.sound();
        System.out.println("동물 소리 테스트 종료...");
    }
}

/*
동물이 움직입니다.
멍멍
동물 소리 테스트 실행!
멍멍
동물 소리 테스트 종료...
동물 소리 테스트 실행!
야옹야옹
동물 소리 테스트 종료...
동물 소리 테스트 실행!
음매음매
동물 소리 테스트 종료...
*/

이처럼 추상 클래스는 제약이 추가된 클래스일 뿐이다. 이제 추상 클래스 덕분에 실수로 Animal 인스턴스를 생성할 문제를 근본적으로 방지해주고, 새로운 동물 자식 클래스를 만들 때, 실수로 sound() 메서드를 오버라이딩 하지 않을 문제를 방지해준다.

 

그리고 순수 추상 클래스 라는 것도 있다. 이건 뭐냐면, 모든 메서드가 추상 메서드인 추상 클래스다. 위에서 만든 AbstractAnimal 클래스에서 move()도 추상 메서드로 만들어 넣어보자.

package poly.ex4;

public abstract class AbstractAnimal {

	// 본인이 가지는 기능이 하나도 없는 완전한 추상
    public abstract void sound();
    public abstract void move();
}
package poly.ex4;

public class Dog extends AbstractAnimal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }

    @Override
    public void move() {
        System.out.println("강아지가 이동합니다.");
    }
}
package poly.ex4;

public class Cat extends AbstractAnimal {

    @Override
    public void sound() {
        System.out.println("야옹야옹");
    }

    @Override
    public void move() {
        System.out.println("고양이가 이동합니다.");
    }
}
package poly.ex4;

public class Cow extends AbstractAnimal {

    @Override
    public void sound() {
        System.out.println("음매음매");
    }

    @Override
    public void move() {
        System.out.println("소가 이동합니다.");
    }
}
package poly.ex4;

public class AbstractMain {
    public static void main(String[] args) {

        Dog dog = new Dog();
        Cat cat = new Cat();
        Cow cow = new Cow();

        soundAnimal(dog);
        soundAnimal(cat);
        soundAnimal(cow);

        moveAnimal(dog);
        moveAnimal(cat);
        moveAnimal(cow);
    }

    private static void soundAnimal(AbstractAnimal animal) {
        System.out.println("동물 소리 테스트 실행!");
        animal.sound();
        System.out.println("동물 소리 테스트 종료...");
    }

    private static void moveAnimal(AbstractAnimal animal) {
        System.out.println("동물 이동 테스트 실행!");
        animal.sound();
        System.out.println("동물 이동 테스트 종료...");
    }
}

/*
동물 소리 테스트 실행!
멍멍
동물 소리 테스트 종료...
동물 소리 테스트 실행!
야옹야옹
동물 소리 테스트 종료...
동물 소리 테스트 실행!
음매음매
동물 소리 테스트 종료...
동물 이동 테스트 실행!
멍멍
동물 이동 테스트 종료...
동물 이동 테스트 실행!
야옹야옹
동물 이동 테스트 종료...
동물 이동 테스트 실행!
음매음매
동물 이동 테스트 종료...
*/

위의 코드를 보다시피, 그냥 부모의 기능이 없다. 심지어 본인이 정해 놓은 기능을 만들라고 자식한테 강요까지 한다. 쉽게 말해, 상속 받은 자식은 추상 클래스의 모든 메서드를 싹 다 오버라이딩 해야 된다는 것이다. 근데… 마치 인터페이스 느낌이 떠오르는 건 그냥 기분 탓인가..?


📐인터페이스

자바에서는 순수 추상 클래스를 더 편리하게 사용할 수 있도록 인터페이스라는 기능을 제공한다. 인터페이스는 class가 아니라 interface 키워드를 사용하면 된다.

 

원래 순수 추상 클래스의 특징은 아래와 같았다.

  • 인스턴스를 생성할 수 없다.
  • 상속 시 모든 메서드를 오버라이딩 해야 한다.
  • 주로 다형성을 위해 사용한다.

 

하지만, 인터페이스는 순수 추상 클래스에 약간의 편의 기능이 더 추가된 것이다.

  • 인터페이스의 메서드는 모두 public, abstract이다.
  • 메서드에 public abstract를 생략할 수 있고, 권장된다.
  • 인터페이스는 다중 구현(다중 상속)을 지원한다.

 

그리고 인터페이스에서 멤버 변수를 사용할 수 있다.

public interface InterfaceAnimal {
	public static final int MY_PI = 3.14;
}

 

근데 생각해보면, 인터페이스는 어차피 상속이 목적이 아니다. 그냥 이런 것들을 구현해야 한다고 정해주기만 하는 건데, 멤버 변수를 가지고 있다? 그럼 그 멤버 변수를 자식이 상속 받아서 쓸 수도 있다는 말인데… 결론부터 말하면, 인터페이스에서 멤버 변수를 사용할 수 있긴 한데 public, static, final이 모두 포함되었다고 간주된다. 그래서 코드를 생략하는 것을 권장한다.

public interface InterfaceAnimal {
	int MY_PI = 3.14;  // 이런 식으로...
}

 

코드를 다시 최적화 해보도록 하자.

package poly.ex5;

public interface InterfaceAnimal {
    void sound();
    void move();
}
package poly.ex5;

// InterfaceAnimal 인터페이스 구현
public class Dog implements InterfaceAnimal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }

    @Override
    public void move() {
        System.out.println("강아지가 이동합니다.");
    }
}
package poly.ex5;

// InterfaceAnimal 인터페이스 구현
public class Cat implements InterfaceAnimal {

    @Override
    public void sound() {
        System.out.println("야옹야옹");
    }

    @Override
    public void move() {
        System.out.println("고양이가 이동합니다.");
    }
}
package poly.ex5;

// InterfaceAnimal 인터페이스 구현
public class Cow implements InterfaceAnimal {

    @Override
    public void sound() {
        System.out.println("음매음매");
    }

    @Override
    public void move() {
        System.out.println("소가 이동합니다.");
    }
}
package poly.ex5;

public class InterfaceMain {
    public static void main(String[] args) {

        // 'InterfaceAnimal' is abstract; cannot be instantiated
        // InterfaceAnimal interfaceAnimal = new InterfaceAnimal();

        Dog dog = new Dog();
        Cat cat = new Cat();
        Cow cow = new Cow();

        soundAnimal(dog);
        soundAnimal(cat);
        soundAnimal(cow);

    }

    private static void soundAnimal(InterfaceAnimal animal) {
        System.out.println("동물 소리 테스트 실행!");
        animal.sound();
        System.out.println("동물 소리 테스트 종료...");
    }
}

/*
동물 소리 테스트 실행!
멍멍
동물 소리 테스트 종료...
동물 소리 테스트 실행!
야옹야옹
동물 소리 테스트 종료...
동물 소리 테스트 실행!
음매음매
동물 소리 테스트 종료...
*/

결국 클래스, 추상 클래스, 인터페이스는 프로그램 코드, 메모리 구조 모두 똑같다. 모두 컴파일하면 .class로 다루어진다.

 

🤔 상속 vs 구현

부모 클래스의 기능을 자식 클래스가 다 상속 받는다고 하면, “클래스는 상속을 받는다” 고 표현한다. 하지만, 부모 인터페이스의 기능을 자식이 상속 받을 때는 “인터페이스를 구현한다” 고 표현한다. 왜 이렇게 구분할까? 상속은 이름 그대로 부모의 기능을 물려 받는 것 자체가 목적이다. 반면, 인터페이스는 모든 메서드가 추상 메서드이기 때문에 물려받을 기능이 없고 인터페이스에 정의한 모든 메서드를 자식이 오버라이딩 해서 기능을 구현해야 한다. 따라서 구현한다고 표현한다. 인터페이스는 메서드 이름만 정해져 있는 “설계도” 이고, 이 설계도가 실제 어떻게 작동하는지는 하위 클래스에서 전부 구현해야 하는 것이다. 결국 상속과 구현은 사람이 표현하는 단어만 다를 뿐, 자바 입장에서는 동일하다.

 

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

모든 메서드가 추상 메서드인 경우, 순수 추상 클래스를 만들어도 되고, 인터페이스를 만들어도 된다. 근데도 인터페이스를 사용해야 하는 이유는, 첫 번째로 제약이다. 인터페이스를 만드는 이유를 생각해보면 인터페이스를 구현하는 곳에서 인터페이스의 메서드를 반드시 구현하라는 제약을 주는 것이다. 그런데 순수 추상 클래스의 경우, 나중에 누군가 그곳에 실행 가능한 메서드를 충분히 끼워 넣을 수 있다. 이러면 추가된 기능을 자식 클래스에서 구현하지 않을 수도 있고, 더는 추상 클래스가 아니게 된다. 인터페이스는 모든 메서드가 추상 메서드이기 때문에 이런 문제를 원천 차단할 수 있다. 두 번째는 인터페이스를 사용하면 다중 구현이 가능하다.

 

👨‍👩‍👧‍👦 인터페이스 - 다중 구현

근데 전에 자바는 다중 상속을 지원하지 않는다고 했다. 그래서 extends의 대상은 하나만 선택할 수 있었다. 근데 인터페이스는 다중 구현을 허용한다. 인터페이스는 모두 추상 메서드로 이루어져 있기 때문이다.

ChildInterfaceAInterfaceB 둘 다 구현한다고 해보자. 상속 관계의 경우, ChildmethodCommon()을 호출한다면, InterfaceAInterfaceB 중 어떤 부모의 메서드를 호출할지 결정해야 하는 문제가 발생한다. 반면, 다시 한번 말하지만 인터페이스 기능은 자식에만 있다. 따라서 오버라이딩에 의해 어차피 Child에 있는 methodCommon()이 호출된다.

 

코드로 확인해보자.

package poly.diamond;

public interface InterfaceA {
    void methodA();
    void methodCommon();
}
package poly.diamond;

public interface InterfaceB {
    void methodB();
    void methodCommon();
}
package poly.diamond;

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");
    }
}
package poly.diamond;

public class DiamondMain {
    public static void main(String[] args) {

        InterfaceA a = new Child();
        a.methodA();
        a.methodCommon();

        InterfaceB b = new Child();
        b.methodB();
        b.methodCommon();
    }
}

/*
Child.methodA
Child.methodCommon
Child.methodB
Child.methodCommon
*/

여기서 InterfaceA a = new Child(), InterfaceB b = new Child()를 보면 인터페이스로 인스턴스를 생성한 것이 아니라, 인터페이스 타입의 참조 변수를 그 인터페이스를 구현한 객체를 참조하도록 한 것이다. 쉽게 말해, ChildInterfaceAInterfaceB를 구현했으니까 Child 객체는 InterfaceA, InterfaceB 타입으로 다룰 수 있다는 말이다.


🌈 클래스와 인터페이스 활용

이제 클래스와 인터페이스 구현을 함께 적용해보자.

위의 그림처럼 추상 클래스(AbstractAnimal)이 있고, 그 안에 추상 메서드(sound())가 존재하고 동물의 이동을 표현하기 위한 메서드인 move()가 있다. move() 메서드는 상속을 목적으로 사용된다. 그리고 Fly는 인터페이스고, 날 수 있는 동물은 이 인터페이스를 통해 구현할 수 있다.

// 내가 푼 풀이
package poly.ex6;

public abstract class AbstractAnimal {

    public abstract void sound();

    public void move() {
        System.out.println("동물이 이동합니다.");
    }
}
package poly.ex6;

public class Dog extends AbstractAnimal {

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}
package poly.ex6;

public class Bird extends AbstractAnimal implements Fly {

    @Override
    public void sound() {
        System.out.println("짹짹");
    }

    @Override
    public void fly() {
        System.out.println("새가 비행합니다.");
    }
}
package poly.ex6;

public class Chicken extends AbstractAnimal implements Fly {

    @Override
    public void sound() {
        System.out.println("꼬꼬댁");
    }

    @Override
    public void fly() {
        System.out.println("닭이 잠깐 비행합니다.");
    }
}
package poly.ex6;

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);

        // Dog cannot be converted to poly.ex6.Fly
        // flyAnimal(dog);

        flyAnimal(bird);
        flyAnimal(chicken);
    }

    private static void soundAnimal(AbstractAnimal animal) {
        System.out.println("동물 소리 테스트 시작!");
        animal.sound();
        System.out.println("동물 소리 테스트 종료...");
    }

    private static void flyAnimal(Fly fly) {
        System.out.println("비행 테스트 시작!");
        fly.fly();
        System.out.println("비행 테스트 종료...");
    }
}

/*
동물 소리 테스트 시작!
멍멍
동물 소리 테스트 종료...
동물 소리 테스트 시작!
짹짹
동물 소리 테스트 종료...
동물 소리 테스트 시작!
꼬꼬댁
동물 소리 테스트 종료...
비행 테스트 시작!
새가 비행합니다.
비행 테스트 종료...
비행 테스트 시작!
닭이 잠깐 비행합니다.
비행 테스트 종료...
*/
profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글