Welcome! Everything is fine.

[Java/Study] 김영한의 실전 자바 기본 - 스터디 5회차 본문

Java

[Java/Study] 김영한의 실전 자바 기본 - 스터디 5회차

개발곰발 2024. 11. 7.
728x90

 

인프런 강의 <김영한의 실전 자바 - 기본편>을 보고 정리한 내용입니다.

매주 모여 각자 정리한 내용을 기반으로 발표하고 질문 공유하는 스터디입니다.



다형성(Polymorphism)

객체 지향 프로그래밍의 특징 중 하나인 다형성은 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 의미한다.

다형성을 이해하기 위해서 알아야 할 2가지는 다음과 같다.

  • 다형적 참조
  • 메서드 오버라이딩

다형적 참조는 부모 타입이 자신은 물론 모든 자식 타입을 참조할 수 있는 것을 말한다.

 

다음 예시를 보자.

public class Parent {
    public void parentMethod() {
        System.out.println("Parent.parentMethod");
    }
}

 

다음 Child 클래스는 Parent 클래스를 오버라이딩하고 있다.

public class Child extends Parent {
    public void childMethod() {
        System.out.println("Child.childMethod");
    }
}

 

자식 변수가 자식 인스턴스를 참조하고, 부모 인스턴스가 부모 인스턴스를 참조하는 것은 자연스럽게 느껴진다. 그러나 3)번처럼 부모 변수가 자식 인스턴스를 참조할 수도 있다. 이것을 다형적 참조라고 한다. Child 인스턴스 내부에는 Child와 부모인 Parent 둘 다 생성 된다. 여기서 기억해야 할 것은 자바에서 부모 타입은 자식 타입을 담을 수 있다는 것!(그 반대는 불가능) 부모 타입은 자신은 물론 자신을 기준으로 모든 자식 타입을 참조할 수 있다.

public class OverridingMain {
    public static void main(String[] args) {
        // 1) 자식 변수가 자식 인스턴스 참조
        Child child = new Child();
        System.out.println("Child -> Child");
        System.out.println("value = " + child.value);
        child.method();

        // 2) 부모 변수가 부모 인스턴스 참조
        Parent parent = new Parent();
        System.out.println("Parent -> Parent");
        System.out.println("value = " + parent.value);
        parent.method();

        // 3) 부모 변수가 자식 인스턴스 참조(다형적 참조)
        Parent poly = new Child();
        System.out.println("Parent -> Child");
        System.out.println("value = " + poly.value);
        poly.method(); 
    }
}

다형성과 캐스팅

캐스팅이란 데이터 타입을 변환하는 과정이다. 주로 기본 타입 간의 변환과 객체 타입 간의 변환에 사용된다. 강의에서 공부한 것은 객체 타입의 캐스팅으로, 업캐스팅과 다운 캐스팅으로 나뉜다.

  • 업캐스팅(upcasting) : 자식 타입 → 부모 타입으로 변경하는 것, 생략 가능(권장)
  • 다운캐스팅(downcasting) : 부모 타입 → 자식 타입으로 변경하는 것, 생략 불가능

다음은 다형적 참조 예제이다. 부모 타입인 poly 변수에 자식 타입을 참조하도록 한 후, 자식 클래스의 메서드를 호출하려고 하면 컴파일 오류가 발생한다. 여기서 Parent는 최상위 부모 타입이고, 상속 관계는 부모로만 찾아서 올라갈 수 있기 때문이다.

public class CastingMain1 {
    public static void main(String[] args) {
        Parent poly = new Child();
        poly.childMethod(); // 컴파일 오류 발생

        // 다운 캐스팅
        Child child = (Child) poly;
        child.childMethod();
    }
}

 

이런 경우에, 다음과 같이 부모 타입인 poly를 강제로 자식 타입으로 바꿔서 자식 클래스의 메소드를 호출할 수 있다. 이것이 다운캐스팅이다. 부모는 자식을 담을 수 있지만, 자식은 부모를 담을 수 없기 때문에 이렇게 다운캐스팅을 해줘야 한다.

Child child = (Child) poly;

 

다운캐스팅의 실행 순서는 다음과 같다. poly에 들어가 있는 참조값을 읽은 후, 그 참조값을 복사하여 Child 타입으로 바꾼 후 child에 대입한다. 따라서 캐스팅을 한다고 해서 poly의 타입이 Child로 변하는 것은 아니다. 해당 참조값을 꺼내고 꺼낸 참조값이 Child 타입이 되는 것이다.

Child child = (Child) poly; // 다운캐스팅 후 대입 시도
Child child = (Child) x001; // 참조값을 읽은 후 자식 타입으로 지정
Child child = x001;

 

다운캐스팅을 할 때, 다운캐스팅한 결과를 변수에 따로 담아 사용할 수도 있지만 다음과 같이 일시적 다운캐스팅을 통해 인스턴스에 있는 하위 클래스의 기능을 바로 호출할 수도 있다.

((Child) poly).childMethod();

 

자식 타입을 부모 타입으로 바꾸는 업캐스팅은 다음과 같이 생략이 가능하다. 오히려 많이 쓰이기 때문에 생략이 권장된다.

Child child = new Child();
Parent parent1 = (Parent) child;
Parent parent1 = child; // 업캐스팅 생략

 

업캐스팅은 안전하고 다운캐스팅은 위험한 이유는 무엇일까? 업캐스팅은 메모리 상에 인스턴스가 모두 존재하기 때문에 항상 안전하다. 반면에 다운캐스팅은 인스턴스에 존재하지 않는 하위 타입으로 캐스팅하는 문제가 발생할 수 있다. 객체를 생성하면 부모 타입은 모두 함께 생성되지만, 자식 타입은 생성되지 않기 때문이다. 아래 parent2 예시를 보면, 애초에 인스턴스가 Parent로 생성이 되었기 때문에 메모리 상에 Child가 존재하지 않는다. 여기서 ClassCastException(= 클래스 캐스팅이 잘못됐다!)이라는 런타임 오류가 발생하는 것이다. 

public class CastingMain4 {
    public static void main(String[] args) {
        Parent parent1 = new Child();
        Child child1 = (Child) parent1;
        child1.childMethod(); // 문제 없음

        Parent parent2 = new Parent();
        Child child2 = (Child) parent2; // ClassCastException 발생
        child2.childMethod(); // 실행 불가
    }
}

 

업캐스팅은 객체를 생성하면 해당 타입의 상위 부모 타입은 모두 생성되기 때문에 문제가 발생하지 않는다. 헷갈릴 수도 있지만 공부를 하며 '자식이 있으면 당연히 부모가 있는 법..'이라고 생각하니 이해가 잘됐다. 반면 다운캐스팅은 인스턴스에 존재하지 않는 하위 타입으로 캐스팅하는 문제가 발생할 수 있다. 객체를 생성한다고 하더라도 자식 타입은 같이 생성되지 않기 때문이다. 따라서 다운캐스팅은 이런 문제를 인지해야 하므로 꼭 명시적으로 캐스팅을 해야 한다.

컴파일 오류 vs 런타임 오류
- 컴파일 오류 : 자바 프로그램을 실행하기 전에 발생하는 오류😊
- 런타임 오류 : 프로그램을 실행하는 도중 발생하는 오류☠️

instanceof

instanceof 키워드를 사용해 참조하는 인스턴스의 타입을 확인할 수 있다.

 

다음과 같이 instanceof 키워드로 parent가 Child 인스턴스일 때만 다운캐스팅을 하도록 할 수 있다. 오른쪽에 있는 타입에 왼쪽에 있는 인스턴스의 타입이 들어갈 수 있는지 대입해보면 그 결과를 예상할 수 있다.

private static void call(Parent parent) {
    parent.parentMethod();
    if (parent instanceof Child) {
        System.out.println("Child 인스턴스 맞음");
        Child child = (Child) parent;
        child.childMethod();
    }
}

 

더보기

✔️ 자바 16 - Pattern Matching for instanceof

 자바 16부터는 instanceof를 사용하면서 동시에 변수를 선언할 수 있다. 인스턴스가 맞는 경우 직접 다운캐스팅 하는 코드를 생략할 수 있다.  

private static void call(Parent parent) {
    parent.parentMethod();
    if (parent instanceof Child child) {
        System.out.println("Child 인스턴스 맞음");
        child.childMethod();
    }
}

다형성과 메서드 오버라이딩

메서드 오버라이딩에서 중요한 점은 오버라이딩 된 메서드는 항상 우선권을 가진다는 점이다. 아래 예시에서 poly는 부모타입이고, 자식 인스턴스를 참조하고 있으므로 메모리 상에 Parent와 Child가 모두 생성된다. poly.value를 출력했을 때는 부모 클래스의 값이 나오지만, poly.method()를 출력하니 자식의 메서드가 호출되었다. 이렇게 부모 클래스에 메서드가 있어도 오버라이딩 된 메서드가 있다면 그 메서드가 우선권을 가진다. 또한 더 하위 자식의 오버라이딩 된 메서드가 우선권을 가지므로, 자식에서 오버라이딩하고 손자에서도 오버라이딩을 했다면 손자의 오버라이딩 메서드가 우선권을 가진다.

Parent poly = new Child();
System.out.println("Parent -> Child");
System.out.println("value = " + poly.value); // 변수는 오버라이딩 X
poly.method(); // 메서드는 오버라이딩 O
Parent -> Child
value = parent
Child.method

다형성의 활용

다형성을 활용하면 다음과 같은 장점이 있다.

  • 중복을 제거할 수 있다.
  • 변경사항이 생겨도 코드를 크게 고치지 않고 재사용할 수 있다.

먼저 다형성을 고려하지 않은 예시를 보자. 각 동물의 울음소리를 출력하는 프로그램으로, 고양이, 소, 개 클래스를 만들었다. 이름은 같은 sound() 메서드이지만 그 안에서 출력되는 내용이 다르다.  

public class Cat {
    public void sound() {
        System.out.println("냐옹");
    }
}
public class Caw {
    public void sound() {
        System.out.println("음메");
    }
}
public class Dog {
    public void sound() {
        System.out.println("멍멍");
    }
}

 

그리고나서 각 동물 클래스 인스턴스를 하나씩 생성한 후 sound() 메서드를 호출해 동물 소리 테스트를 했다. 딱 보기에도 중복이 많고 굉장히 불편한 코드가 되었다. 배열이나 메서드로 중복을 제거하려고 해도 각각 타입(클래스)이 달라서 적용하기 어렵다. 이 상태에서 새로운 동물이 계속 추가된다면 코드는 더 길어질 것이다. 이런 문제는 동물들의 타입이 다르다는 점을 해결할 수 있다면 중복을 제거할 수 있을 것이다. 바로 이 시점에서 다형적 참조메서드 오버라이딩을 떠올리면 된다.

public class AnimalSoundMain {
    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("동물 소리 테스트 종료");

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

 

다형적 참조와 메서드 오버라이딩을 이용해 문제를 해결한 코드를 보자. 먼저 고양이, 소, 개를 하나로 묶을 수 있는 Animal이라는 클래스를 만들었다. 그 안에는 오버라이딩해서 사용할 목적으로 만든 메서드인 sound()를 만들었다.

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

 

그리고나서 각 동물들이 Animal을 상속받게 한 후 sound() 메서드를 오버라이딩해서 각 동물에 맞는 소리를 넣었다.

public class Cat extends Animal {

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

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

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

 

개선된 코드는 다음과 같다.

  • 동물들을 Animal 타입으로 통일해 배열과 for문을 이용해서  중복을 제거했다.
  • sound() 메서드를 호출하는 부분을 메서드로 빼서 중복을 제거했다.
public class AnimalPolyMain3 {
    public static void main(String[] args) {
        Animal[] animals = {new Dog(), new Cat(), new Caw()};
        for (Animal animal : animals) {
            soundAnimal(animal);
        }
    }

    // 동물이 추가 되어도 변하지 않는 부분
    private static void soundAnimal(Animal animal) {
        System.out.println("동물 소리 테스트 시작");
        animal.sound();
        System.out.println("동물 소리 테스트 종료");
    }
}

 

soundAnimal() 메서드에서는 Animal 타입을 받지만 각 동물들이 넘어오면 오버라이딩 된 메서드가 우선권을 가지기 때문에 동물에 맞는 울음소리가 출력된다.

추상 클래스

위 예시에 나온 Animal 클래스는 사실 인스턴스를 생성해서 사용할 일이 없다. (그런데 실수로 누군가가 필요 없는 Animal 인스턴스를 생성할 수도 있다.) 또 Animal 클래스를 상속받는다고 해서 sound() 메서드를 반드시 오버라이딩 하지 않을 수도 있다. 이런 문제를 해결하기 위해 추상 클래스와 추상 메서드를 사용한다. 


✔️ 추상 메서드란?

  • 추상적인 개념을 제공하는 바디가 없는 메서드
  • 메서드 앞에 abstract 키워드를 붙여 선언
  • 추상 메서드는 자식 클래스가 반드시 오버라이딩해서 사용

한 마디로, 일반 메서드와 거의 똑같지만 메서드 바디가 없고, 자식 클래스가 해당 메서드를 반드시 오버라이딩 해야 하는 메서드이다.

 

✔️ 추상 클래스란?

  • 부모 클래스는 제공하지만, 실제 생성되면 안되는 클래스
  • 상속을 목적으로 사용되며 부모 클래스 역할 담당
  • 클래스 앞에 abstract 키워드를 붙여 선언
  • 추상 메서드가 하나라도 있으면 추상 클래스로 선언 - 추상 메서드라는 불완전한 메서드를 갖고 있기 때문에 생성을 막아야 한다!

한 마디로, 일반 클래스와 거의 똑같지만 생성은 못하는 클래스이다.

 

아래는 AbstractAnimal이라는 추상 클래스와 sound()라는 추상 메서드를 보여준다. 여기서 sound()는 앞에 abstract가 붙었으므로 꼭 오버라이딩 해야 하고, move()는 추상 메서드가 아니므로 오버라이딩 하지 않아도 된다.

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

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

 

추상 클래스는 생성이 불가능하기 때문에 다음과 같이 인스턴스를 만드려고 하면 컴파일 오류가 발생한다.

AbstractAnimal animal = new AbstractAnimal();

인터페이스

클래스의 모든 메서드가 추상 메서드인 클래스를  순수 추상 클래스라고 한다. 순수 추상 메서드는 실행 로직이 없고 오직 다형성을 위한 부모 타입으로써 역할만 제공한다.

 

✔️ 순수 추상클래스란?

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

인터페이스라는 개념을 통해  순수 추상 클래스를 더 편리하게 사용할 수 있다.

 

✔️ 인터페이스란?

  • class 대신 interface 키워드를 사용, 구현 시 implements 키워드 사용
  • 메서드는 모두 public abstract  & 메서드에 public abstract 생략 권장
  • 다중 구현(다중 상속) 지원 - 자바는 다중 상속 지원X(다이아몬드 문제) 인터페이스로 구현가능!

인터페이스 자신은 구현을 가지고 있지 않기 때문에 다이아몬드 문제가 발생하지 않는다. 따라서 인터페이스의 경우 다중 구현을 허용하는 것이다. 아래는 InterfaceA와 InterfaceB를 구현한 Child 클래스이다. InterfaceA와 InterfaceB 둘 다 methodCommon()이라는 메서드가 있는 상황이지만, 구현은 Child에서 하면 되므로 문제 되지 않는다.

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

클래스와 인터페이스 활용

클래스 상속과 인터페이스 구현을 함께 사용할 수 있다.

다음과 같은 추상 클래스와 인터페이스가 있다고 했을 때, extends 키워드와 implements 키워드를 함께 쓸 수 있다.

public abstract class AbstractAnimal {
    public abstract void sound();
    public void move() {
        System.out.println("동물이 이동합니다.");
    }
}
public interface Fly {
    void fly();
}

 

클래스 상속과 인터페이스를 모두 사용한 Chicken 클래스이다. "extends AbstractAnimal implements Fly"로 작성해서 상속도 받고, 인터페이스 구현도 했다. 이렇게 모두 사용할 때는 extends가 먼저 나와야 하는데, extends를 통한 상속은 하나만 가능하고 implements를 통한 인터페이스는 다중 구현이 가능하기 때문이다.

public class Chicken extends AbstractAnimal implements Fly {
    @Override
    public void sound() {
        System.out.println("꼬끼오");
    }

    @Override
    public void fly() {
        System.out.println("닭 날기");
    }
}

 

GPT가 정리해 준 인터페이스와 추상 클래스의 차이! 기억해야 할 것들이 많아보이지만, 추상 클래스는 일반 클래스와 거의 같다고 생각하면 될 것 같다. 인터페이스로 다중 구현이 가능하다는 것과 키워드가 다르다는 것은 알아두자!


좋은 객체 지향 프로그래밍이란?

객체 지향 프로그래밍이란 컴퓨터 프로그램을 여러 개의 독립된 단위인 객체들의 모임으로 파악하고자 하는 것이다. 객체를 설계할 때 역할구현을 명확히 분리하는 것이 중요하다.

  • 역할 = 인터페이스
  • 구현 = 인터페이스를 구현한 클래스, 구현 객체

만약 운전자가 아반떼를 타다가 테슬라 모델 3으로 바꾼다면, 테슬라의 부품 하나하나를 공부하고, 운전하는 법도 새로 배워야 할까? 자동차의 종류만 바뀔 뿐, 자동차의 기본 역할은 바뀌지 않으므로 운전자는 그런 일을 할 필요가 없다. 운전자(클라이언트)와 자동차의 역할, 자동차의 구현을 분리했기 때문에 그렇다. 이렇게 역할과 구현으로 구분하면 세상이 단순해지고 유연해지며 변경도 편리해진다.

 

클라이언트는..

  • 대상의 역할(인터페이스)만 알면 된다.
  • 구현 대상의 내부 구조를 몰라도 된다.
  • 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
  • 구현 대상 자체를 변경해도 영향을 받지 않는다.

이 개념을 제대로 이해하려면 객체의 협력이라는 관계부터 생각을 해야 한다. 수많은 객체 클라이언트와 객체 서버는 서로 협력 관계를 가진다. 클라이언트는 요청하는 자, 서버는 응답하는 자를 가리킨다. 다형성의 본질을 이해하려면 객체 사의 협력에 대해 이해해야 한다.

클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있는 것이 다형성이다!

 

OCP(Open-Closed Principle) 원칙

대표적인 좋은 객체 지향 설계 원칙인 SOLID 원칙은 다음과 같다.

  • S - 단일 책임 원칙(Single Responsibility Principle)
  • O - 개방-폐쇄 원칙(Open-Closed Principle)
  • L - 리스코프 치환 원칙(Liskov Substitution Principle)
  • I - 인터페이스 분리 원칙(Interface Segregation Principle)
  • D - 의존성 역전 원칙(Dependency Inversion Principle)

그중 OCP 원칙은...

  • Open for extension : 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.
  • Closed for modification : 기존의 코드는 수정되지 않아야 한다.

 

OCP 원칙 예제는 강의에 많이 있어서 GPT에게 다른 예시를 요청해 봤다. 도형의 면적을 계산하는 클래스인데, 새로운 도형의 면적을 계산해야 한다면 계속 새로운 메서드를 추가해야 하므로 OCP 원칙을 위반한 코드라고 볼 수 있다.  

class Rectangle {
    public double width;
    public double height;
}

class Circle {
    public double radius;
}

class AreaCalculator {
    public double calculateRectangleArea(Rectangle rectangle) {
        return rectangle.width * rectangle.height;
    }

    public double calculateCircleArea(Circle circle) {
        return Math.PI * circle.radius * circle.radius;
    }
}

 

다음과 같이 Shape라는 인터페이스를 만들어 각 도형 클래스가 calculateArea() 메서드를 구현하도록 함으로써 OCP 원칙을 준수하는 방식으로 작성했다. 새로운 도형을 추가하고 싶다면, Shape 인터페이스를 구현하는 새로운 클래스를 만들면 된다. AreaCalculator 클래스를 수정할 필요가 없다.

interface Shape {
    double calculateArea();
}

class Rectangle implements Shape {
    private double width;
    private double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double calculateArea() {
        return width * height;
    }
}

class Circle implements Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

class AreaCalculator {
    public double calculateTotalArea(Shape[] shapes) {
        double totalArea = 0;
        for (Shape shape : shapes) {
            totalArea += shape.calculateArea();
        }
        return totalArea;
    }
}