JAVA의 가장 기본이 되는 내용

다형성 (Part.2) 사용하기 ✨

cwchoiit 2024. 3. 29. 10:52
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

이제 Part.1 에서 배운  다형성 핵심 개념인 다형적 참조와 메서드 오버라이딩을 가지고 매우 강력한 기능을 사용해보자.

다형성 개념을 사용하기 전 코드를 보고 사용한 코드를 본 다음 얼마나 강력한 것인지를 체감해보자.

 

다형성을 도입하기 전

Dog

public class Dog {
	public void sound() {
    	System.out.println("멍멍");
    }
}

 

Cat

public class Cat {
	public void sound() {
    	System.out.println("냐옹");
    }
}

 

Caw

public class Caw {
	public void sound() {
    	System.out.println("음메");
    }
}

 

Main

public class Main {
	public static void main(String[] args) {
    	Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();
        
        System.out.println("강아지 소리");
        dog.sound();
        
        System.out.println("고양이 소리");
        cat.sound();
        
        System.out.println("소 소리");
        caw.sound();
    }
}

 

실행결과:

강아지 소리
멍멍
고양이 소리
냐옹
소 소리
음메

 

Main 코드를 보면 벌써 숨이 턱 막힌다. 일단 중복 코드가 발생했다는 부분이 너무 불편하다. 이것은 근데 다형성으로 완벽하게 깔끔한 코드로 변경할 수 있다. 

 

 

다형성을 도입한 후

Animal

public class Animal {
    public void sound() {
        System.out.println("동물 소리");
    }
}

 

Dog

public class Dog extends Animal {

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

 

Cat

public class Cat extends Animal {

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

 

Caw

public class Caw extends Animal {

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

 

Main

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();

        sound(dog);
        sound(cat);
        sound(caw);
    }

    private static void sound(Animal animal) {
        animal.sound();
    }
}

 

Main 코드를 보자. 너무 아름답지 않은가? 어떤 인스턴스를 만들던 Animal을 상속받는 클래스라면 그 인스턴스 참조값을 Animal 타입 변수에 넣어주면 문제 없이 업캐스팅이 된다. 그리고 이때 Animal 클래스가 가지고 있는 메서드 sound()를 실행하면 그 메서드를 오버라이딩 한 클래스라면 자기의 메서드가 무조건 우선순위를 가지기 때문에 더 고려할 것도 없이 각자 소리를 낸다.

 

실행결과:

멍멍
냥
음메

 

이게 바로 다형적 참조메서드 오버라이딩의 조합으로 이루어낸 강력한 코드이다. 그리고 여기서 주는 또다른 강력한 부분은 Animal을 상속받는 클래스가 계속해서 생겨도 저 sound()라는 메서드는 바뀔 부분이 단 한군데도 없다는 것이다.

 

더 나아가 이 코드 또한 더 깔끔하게 리팩토링 할 수도 있다. 다음은 완벽하게 리팩토링한 코드라고 볼 수 있다. 그냥 배열에 넣고 루프로 돌려버리는 것이다. 그러면 아까 따로 만든 sound() 메서드도 필요없다. 그냥 Animal 클래스의 sound()를 호출하면 각자 인스턴스 타입에 맞게 오버라이딩한 메서드가 실행될테니. 

public class Main {
    public static void main(String[] args) {
        Animal[] animals = {new Dog(), new Cat(), new Caw()};

        for (Animal animal : animals) {
            animal.sound();
        }
    }
}

 

 

완벽하다..! ... 완벽할까..? 남은 문제가 있다. 그것도 2개나.

  • Animal이라는 클래스를 만들 수 있는 문제
    • 생각해보자. Animal은 동물이다. 추상적인 개념이지 개, 고양이, 소처럼 실존하는게 아니다. 그리고 실제로 이 Animal 객체를 만들일도 없다. 근데 지금 코드에서는 만들 수 있다. 
  • Animal을 상속받은 클래스에서 sound()라는 메서드를 오버라이딩 안 할 수 있는 문제
    • 예를 들어 Duck이라는 새로운 동물 클래스를 만들었는데 개발자가 실수로 sound() 메서드를 오버라이딩을 안 할 수 있다. 충분히 가능성 높은 이야기다. 그럼 오버라이딩 메서드가 없으니 부모인 Animal의 sound() 메서드가 호출될것이다. 이런 문제가 있다.

 

그럼 이 문제를 어떻게 해결해야 할까? '추상화'라는 개념이 있다. 그 녀석을 알아보자.

 

추상 클래스

추상 클래스는 위 Animal 클래스와 같이 부모 클래스는 제공하지만, 실제 생성되면 안되는 클래스를 추상 클래스라고 한다. 이름 그대로 추상적인 개념을 제공하는 클래스이다. 따라서 실체인 인스턴스가 존재하지 않는다. 대신에 상속을 목적으로 사용되고, 부모 클래스 역할을 담당한다.

public abstract class Animal {...}
  • 추상 클래스는 클래스를 선언할 때 앞에 추상이라는 의미의 abstract 키워드를 붙여주면 된다.
  • 추상 클래스는 기존 클래스와 완전히 같다. 다만 new Animal()과 같이 직접 인스턴스를 생성하지 못하는 제약이 추가된 것이다.
    • 정확히 위 문제의 1번 "Animal이라는 클래스를 만들 수 있는 문제"를 해결한다.
  • 추상 메서드가 하나라도 있으면 추상 클래스가 되어야 한다.
  • 추상 클래스라고 할지라도 추상 메서드만 있어야 하는건 아니고 바디가 있는 메서드도 있을 수 있다. 이 목적은 상속받는 자식 클래스가 가져다가 사용할 수 있게함이다.

추상 메서드

부모 클래스를 상속 받는 자식 클래스가 반드시 오버라이딩 해야 하는 메서드를 부모 클래스에 정의할 수 있다. 이것을 추상 메서드라 한다. 추상 메서드는 이름 그대로 추상적인 개념을 제공하는 메서드이다. 따라서 실체가 존재하지 않고, 메서드 바디가 없다.

public abstract void sound();
  • 추상 메서드는 선언할 때 메서드 앞에 추상이라는 의미의 abstract 키워드를 붙여주면 된다.
  • 추상 메서드가 하나라도 있는 클래스는 추상 클래스로 선언해야 한다.
    • 그렇지 않으면 컴파일 오류가 발생한다.
    • 추상 메서드는 메서드 바디가 없다. 따라서 작동하지 않는 메서드를 가진 불완전한 클래스로 볼 수 있다. 따라서 직접 생성하지 못하도록 추상 클래스로 선언해야 한다.
  • 추상 메서드는 상속 받는 자식 클래스가 반드시 오버라이딩해서 사용해야 한다.
    • 그렇지 않으면 컴파일 오류가 발생한다.
    • 추상 메서드는 자식 클래스가 반드시 오버라이딩 해야 하기 때문에 메서드 바디 부분이 없다. 바디 부분을 만들면 컴파일 오류가 발생한다.
    • 위 문제의 2번 "Animal을 상속받은 클래스에서 sound()라는 메서드를 오버라이딩 안 할 수 있는 문제"를 해결한다.
    • 자식 클래스가 오버라이딩 하지 않으면 자식 클래스도 추상 클래스가 되어야 한다.

 

이 개념을 토대로 코드를 작성해보자.

 

Animal (추상클래스)

public abstract class Animal {
    public abstract void sound();
}

 

Cat

public class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("냐옹");
    }
}

 

Caw

public class Caw extends Animal {
    @Override
    public void sound() {
        System.out.println("음메");
    }
}

 

Dog

public class Dog extends Animal {

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

 

추상 클래스를 상속 받는 자식 클래스는 반드시 추상 클래스가 가진 추상 메서드를 구현해야 한다.

그렇지 않으면 컴파일 에러가 발생한다. 다음이 그 화면이다.

이로 인해 자식 클래스에서 오버라이딩 하는 것을 까먹는 문제를 완벽하게 해결할 수 있다.

 

 

Main

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

        Dog dog = new Dog();
        Cat cat = new Cat();
        Caw caw = new Caw();

        soundAnimal(dog);
        soundAnimal(cat);
        soundAnimal(caw);
    }

    public static void soundAnimal(Animal animal) {
        System.out.print("울음소리: ");
        animal.sound();
    }
}

 

코드는 아까와 같지만 제약덕분에 실수를 방지할 수 있게 됐다. 만약 Animal 이라는 추상 클래스에 대한 인스턴스를 만드려고 하면 에러가 발생한다. 같은 코드이지만 더 완전한 코드가 된 것이다.

 

 

인터페이스

지금까지 추상 클래스가 무엇인지 알아봤는데, 위에서 언급했지만 추상 클래스에는 추상 메서드만 있을수도 있고 바디가 있는 메서드가 있을 수도 있다고 했다. 근데 여기서 의도에 따라 달라지는데 나의 의도는 오로지 다형성을 위해 추상 클래스를 만드는 것이고 추상 클래스 내 메서드를 오버라이딩 하도록 강제하게 하는 것이다. 그럼 바디가 있는 메서드가 필요하지 않다. 그런 의도라면 아예 바디가 있는 메서드를 만들 수 없도록 하면 더더욱 확실한 안전 장치가 되지 않을까? 그게 인터페이스다.

 

Abstract Class

public abstract class Animal {
    public abstract void sound();
}

 

Interface

public interface Animal {
    void sound();
}

 

둘 간 차이는 인터페이스는 키워드가 'interface'이고 메서드에 public abstract 라는 키워드가 없어도 된다. 없는 것을 권장한다.

그리고 인터페이스에서는 바디가 있는 메서드를 만들면 다음과 같이 컴파일 에러가 난다. 

 

그래서 인터페이스라는 것을 사용하면 더더욱 확실하게 사용자의 의도를 파악할 수 있다. "아! 다형성과 메서드 오버라이딩을 사용하려는 목적이구나" 인터페이스는 이런 장점이 있다. 그리고 인터페이스는 다중 구현이 가능하다. 클래스는 상속을 하나만 받을 수 있는데 인터페이스는 여러개를 받을 수 있다. 참고로 클래스는 상속이라고 표현하고 인터페이스는 구현이라고 표현한다.

 

인터페이스에서는 멤버 변수를 선언할 수 있다. 그리고 인터페이스에선 멤버 변수에 public static final 키워드가 모두 포함되었다고 간주한다. 그리고 public static final을 생략하는 것을 권장한다. 그래서 상수를 어딘가에 정의하고 싶으면 인터페이스에 정의하면 좋다.

public interface Animal {
    double PI = 3.14;
    
    void sound();
}

 

추상 클래스 대신 인터페이스를 사용하여 위 예제를 그대로 적용해보면 다음과 같이 적용할 수 있다.

 

Animal (interface)

public interface Animal {
    void sound();
}

 

Cat

public class Cat implements Animal {

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

 

Caw

public class Caw implements Animal {

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

 

Dog

public class Dog implements Animal {

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

 

 

Main

public class Main {
    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) {
        animal.sound();
    }
}

 

똑같다. 다른것은 거의 없다. 그러나 인터페이스를 사용하므로 명확해졌다. "아! 다형성을 위함이구나." 라는 것을.

 

그럼 궁금한 것은 모든 메서드를 추상 메서드로 선언하는 추상클래스와 인터페이스 중 무엇을 사용해야 하나? 인터페이스를 사용하면 더 좋다.

 

public abstract 라는 키워드를 생략할 수 있다는 편리함 이런 차원이 아니라 다음과 같은 이유가 있다.

  • 제약: 인터페이스를 사용한다는 것 자체가 오로지 다형성과 메서드 오버라이딩을 사용하여 효율적이고 강력한 코드를 작성하기 위함이고 그 말은 바디가 있는 메서드 자체를 허용하지 않겠다라고 해석이 될 수 있다. 그러나, 추상 클래스로 만들면 시간이 지나 누군가가 이 사실을 까먹은 채 바디가 있는 메서드를 만들 가능성이 농후하다. 그 점까지도 배제하는 것이다.
  • 다중 구현: 클래스는 하나밖에 상속받을 수 없는 반면, 인터페이스는 여러개를 다중 구현할 수 있다.

 

좋은 프로그램은 제약이 있는 프로그램이다.

 

인터페이스의 다중 구현

인터페이스는 클래스와 달리 다중 구현이 가능하다. 왜 그럴까? 다음 그림을 보자.

위 그림에서 만약 다중 상속이 가능하다면 AirplaneCar 클래스가 Airplane, Car 두 클래스를 모두 상속받았다고 했을 때 과연 부모의 move() 메서드를 누구의 것으로 호출해야 할까? 알 수 없다. 방법이 없다. 이래서 안되는것이다.

 

근데 인터페이스는? 무조건 상속받으면 오버라이딩을 해야하며, 인터페이스에서 선언한 메서드는 바디가 없다. 즉, 어떤 인터페이스 타입으로 인스턴스를 만들던간에 호출한 move()라는 메서드는 결국 본인 자신것으로 호출된다. 이게 다형성의 메서드 오버라이딩 우선순위니까.

 

 

다형성의 핵심 개념 1. 다형적 참조 2. 메서드 오버라이딩 개념을 잘 이해했다면 문제 없이 이해가 될 것이다.

이 내용을 실제로 코드로 작성해보자. 어렵지 않다.

 

InterfaceA

public interface InterfaceA {
    void methodA();

    void methodCommon();
}

 

InterfaceB

public interface InterfaceB {
    void methodB();

    void methodCommon();
}

 

Child

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

    @Override
    public void methodB() {
        System.out.println("method B");
    }

    @Override
    public void methodCommon() {
        System.out.println("method common");
    }
}

Child 클래스를 보면 InterfaceA, InterfaceB를 다중 구현한다. 저 두 개의 인터페이스가 가진 메서드들을 모두 구현해야 하며 methodCommon() 같은 경우 둘 다 동일하게 가진 시그니쳐이기 때문에 하나만 구현하면 된다. 

 

 

Main

public class Main {
    public static void main(String[] args) {
        InterfaceA a = new Child();
        InterfaceB b = new Child();

        a.methodA();
        a.methodCommon();

        b.methodB();
        b.methodCommon();
    }
}

가져다가 사용하는 부분을 보면 InterfaceA 타입에 Child 인스턴스를 참조하던, InterfaceB 타입에 Child 인스턴스를 참조하던 Child 인스턴스를 생성하면 해당 참조 공간에는 InterfaceA, InterfaceB, Child 이렇게 세 개 모두 생성된다. 여기서 변수 amethodA()를 호출할 수 있고 methodCommon()도 호출 가능하다. 반면 bmethodB()를 호출할 수 있고 methodCommon()도 호출 가능하다.

 

실행결과:

method A
method common
method B
method common

 

결국 InterfaceA 타입의 변수나 InterfaceB 타입의 변수나 구현(상속)을 받았기 때문에 메서드를 호출하는 시점에 오버라이딩이 됐다면 오버라이딩 된 메서드를 호출한다. 그래서 Child 클래스에 존재하는 methodCommon()을 실행하기 때문에 어떤걸 호출해야하지? 라는 문제 자체가 없는것. 그래서 인터페이스는 다중 구현이 가능하고 클래스는 다중 상속이 불가능하다. 

 

 

클래스와 인터페이스를 병행

다음과 같은 내용들을 배웠다.

  • 추상 클래스를 이용해서 추상 메서드를 사용하기
  • 인터페이스를 이용해서 추상 메서드를 사용하기
  • 인터페이스 또는 추상 클래스를 구현(상속)하면 추상 메서드를 반드시 오버라이딩할 것
  • 다형적 참조와 메서드 오버라이딩 개념을 인터페이스에 적용해서 다형성이 주는 막강한 기능 이용해보기
  • 어떤 클래스나 인터페이스를 상속(구현)하고 메서드를 오버라이딩 했으면 타입이 부모 타입이어도 오버라이딩 메서드가 우선순위를 가진다.

이러한 여러 개념들과 메모리 구조를 잘 떠올려서 확실하게 내 것으로 만들고 가자. 이제는 진짜 다형성과 추상 클래스, 인터페이스에 대한 이해가 깊어진 느낌을 받는다. 왜 인터페이스를 사용하는지 다형성이 왜 나타난건지 이 다형성을 구현하면 어떤 이점을 주는지 전부 알 것 같다. 인터페이스를 사용하는 이유는 이 인터페이스를 어떤 방식으로 얼마나 많은 구현 클래스가 존재하던 부모 타입(인터페이스)으로 모든 하위 구현 클래스를 받을 수 있고 (InterfaceA a = new Child();) 무조건 메서드 오버라이딩이 필요하기 때문에 어떤 자식 인스턴스를 참조하던 그 녀석이 구현한 메서드가 실행된다는 것을 안다. 

 

이제 그래서 좀 더 실전으로 가보자. 클래스와 인터페이스를 모두 사용해서 만들어보자.

 

Animal

public abstract class Animal {
    public abstract void sound();
    public void move() {
        System.out.println("동물들이 움직인다.");
    }
}

추상 메서드와 바디가 있는 메서드 두 개가 있는 추상클래스다.

 

Fly

public interface Fly {
    void fly();
}

fly() 메서드를 가진 인터페이스.

 

Dog

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

Dog 클래스. 이 클래스는 Animal 추상 클래스만을 상속받는다.

 

Cat

public class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("냥냥");
    }
}

Cat 클래스. 이 클래스도 Animal 추상 클래스만을 상속받는다.

 

Bird

public class Bird extends Animal implements Fly {
    @Override
    public void sound() {
        System.out.println("짹짹");
    }

    @Override
    public void fly() {
        System.out.println("하늘을 펄펄");
    }
}

핵심이 되는 Bird 클래스. 이 클래스는 Animal 추상클래스를 상속받음과 동시에 Fly라는 인터페이스를 구현한다.

그래서 클래스와 인터페이스를 두개 다 사용할수도 있다. 뭐 간단하다. 

 

Main

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Bird bird = new Bird();
        Cat cat = new Cat();

        soundAnimal(dog);
        soundAnimal(bird);
        soundAnimal(cat);

        flyAnimal(bird);
    }

    public static void soundAnimal(Animal animal) {
        System.out.println("동물들의 소리");
        animal.sound();
    }

    public static void flyAnimal(Fly fly) {
        System.out.println("날아봐. 날 수 있다면 말이지.");
        fly.fly();
    }
}

그리고 다형성을 사용하는 Main 클래스. Dog, Bird, Cat 클래스의 인스턴스를 각각 만들고 Animal 이라는 부모 타입으로 파라미터를 받는 soundAnimal() 메서드를 만든다. 저 세개의 인스턴스는 모두 다 이 메서드를 사용할 수 있다. 인스턴스를 생성할 때 부모도 같은 참조 공간에 만들어지니까. 그리고 해당 메서드에서 sound() 메서드를 호출한다. 이 메서드는 각각의 Dog, Bird, Cat이 무조건 오버라이딩 해야하는 추상메서드이다. 그리고 그 추상메서드를 오버라이딩하면 부모 타입으로 된 변수 animal 일지라도 오버라이딩 한 메서드를 호출한다. 이게 바로 다형성.

 

flyAnimal() 메서드도 위와 일맥상통하는데 이번엔 파라미터에 인터페이스 타입을 집어넣었다. 마찬가지로 인터페이스를 구현하는 인스턴스는 이 메서드를 사용할 수 있다. 업캐스팅은 어떠한 문제도 되지 않으니까. 

 

아래 그림을 이해하면 된다.

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

java.lang 패키지와 그 첫번째 - Object  (0) 2024.03.31
OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
상속 (Part.2)  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28