[Java] 상속과 구현을 통한 다형성 활용하기

🌩 es·2023년 8월 17일
0

자바 언어에서 인터페이스와 상속을 통해 객체지향 프로그래밍의 특징 중 하나인 다형성 Polymorphism을 구현할 수 있다.


상속을 활용한 다형성

상속

상속이란 클래스가 가지고 있는 멤버를 다른 클래스에 계승시키는 것이다. 부모 클래스에 정의된 멤버 변수과 멤버 함수를 자식 클래스에서 재사용하여 중복을 줄일 수 있다.

클래스 상속으로 다형성 구현

부모 클래스의 참조 변수로 하위 클래스의 객체를 다룰 수 있어서, 실행 시점에 유연하게 어떤 하위 클래스의 메서드를 호출할지 결정할 수 있다.

예시를 보자.
아래와 같이 부모 클래스 Parent, 이를 상속받은 자식 클래스 Child 가 있다.

class Parent {
    public String name;

    public Parent(String name) {
        this.name = name;
    }

    public void say() {
        System.out.println("부모 클래스 메서드입니다.");
    }
}

class Child extends Parent {
    public int age;

    public Child(String name, int age) {
        super(name);
        this.age = age;
    }

    @Override
    public void say() {
        System.out.println("자식 클래스 메서드입니다.");
    }
}

아래와 같이 실행해본다.

public class Main {
    public static void main(String[] args) {
        Parent obj1 = new Parent("엘사");
        System.out.println(obj1.name);
        obj1.say();

        System.out.println("=================");

		// 업 캐스팅
        Parent obj2 = new Child("안나", 10);
        System.out.println(obj2.name);
        // System.out.println(obj2.age);  // 컴파일 에러 발생
        obj2.say();

        // 다운 캐스팅
        if (obj2 instanceof Child) {
            Child downObj2 = (Child) obj2;
            System.out.println("다운 캐스팅 : " + downObj2.age);
        }

        System.out.println("=================");

        Child obj3 = new Child("올라프", 2);
        System.out.println(obj3.name);
        System.out.println(obj3.age);
        obj3.say();

		// Child obj4 = new Parent();  // 컴파일 에러 발생
    }
}

Output

엘사
부모 클래스 메서드입니다.
=================
안나
자식 클래스 메서드입니다.
다운 캐스팅 : 10
=================
올라프
2
자식 클래스 메서드입니다.

위 예제에서 4가지 경우로 클래스형 변수를 만들어서 테스트 해보았다.

  1. Parent obj1 = new Parent();
  2. Parent obj2 = new Child();
  3. Child obj3 = new Child();
  4. Child obj4 = new Parent();

1번 Parent obj1 = new Parent(); 의 경우, 당연히 Parent 클래스의 멤버변수와 멤버함수에 접근할 수 있다.

3번 Child obj3 = new Child(); 의 경우, 1번과 마찬가지로 Child 클래스의 멤버변수와 멤버함수에 접근할 수 있다.

2번 Parent obj2 = new Child(); 의 경우, 업 캐스팅Up-casting이 발생한다. 업 캐스팅이란 두 클래스가 상속 관계에 있을 때 부모 클래스 타입의 참조 변수로 자식 클래스의 객체를 참조하도록 하는 것이다.

이런 일이 가능한 이유는 JVM의 메모리 구조를 보면 알 수 있다.

자식 클래스 설계도에 따라 생성된 실제 객체는 힙 heap 영역에 있다. 그러나 부모 클래스 설계도에 따라 생성된 부모 클래스형 변수는 스택 stack 영역에 있다. 따라서 부모 클래스형 변수는 자식 클래스에만 있는 멤버변수나 멤버함수를 알 수가 없다. 위 예시에서 obj2.age에 접근할 수 없다.

하지만 업캐스팅된 객체를 다시 Child 타입으로 다운캐스팅하면 age에 접근할 수 있다. 이때 instanceof 연산자를 사용하여 객체의 타입을 확인한 후 다운캐스팅을 시도하면 안전하게 다운캐스팅할 수 있다.

자식 클래스에서 메서드 오버라이딩을 한 경우, 메서드 호출시 자식 클래스의 메서드가 호출된다. 이때는 JVM의 동적 바인딩(Dynamic Binding) 매커니즘에 따라 실행시간(runtime)에 동적으로 메서드가 호출된다. JVM이 실제 객체의 메서드를 찾아서 실행한다.

4번 Child obj4 = new Parent(); 의 경우, 컴파일 에러가 발생한다. 부모 클래스 설계도에 따라 생성된 실제 객체는 힙 영역에 있고, 자식 클래스 설계도에 따라 생성된 자식 클래스형 변수는 스택 영역에 있다. 따라서 자식 클래스에만 있는 멤버변수, 멤버함수에 접근하여 실제 객체를 참조하려고 할 때 반드시 에러가 발생할 것이다. 자바 컴파일러 입장에서 아예 이런 상황을 허용하지 않는다.


구현을 활용한 다형성

구현

구현이란 클래스에서 인터페이스 안의 추상 메소드를 구체화하는 것이다. 인터페이스는 다른 클래스를 작성할 때 기본이 되는 틀을 제공하면서, 다른 클래스 사이의 중간 매개 역할까지 담당한다.

인터페이스 구현으로 다형성 구현

같은 인터페이스로 동일한 이름으로 여러 클래스가 여러가지 구현을 만들 수 있고, 인터페이스를 구현한 객체를 실행시점에 유연하게 변경할 수 있다.

예시를 보자.
Animal이라는 인터페이스가 있고 이를 Dog, Cat 두 개의 클래스가 다르게 구현하였다.

interface Animal {
    void makeSound();
}

class Dog implements Animal {
    @Override
    public void makeSound() {
        System.out.println("멍멍!");
    }
}

class Cat implements Animal {
    @Override
    public void makeSound() {
        System.out.println("야옹!");
    }
}

실행시점에 서로 다르게 실제 객체의 makeSound()를 찾아서 실행한다.

public class Main {
    public static void main(String[] args) {
        Animal dog = new Dog();
        Animal cat = new Cat();
        
        dog.makeSound();
        cat.makeSound();
    }
}

Output

멍멍!
야옹!

좀 더 실용적인 예시를 한 번 생각해보자.

MemberRepository라는 인터페이스가 있고, 내부에 save()라는 새로운 멤버를 저장하는 추상 메소드가 있다고 해보자(*public abstract 는 생략할 수 있음).

public interface MemberRepository {
    void save(Member member);
}

정책상 DB 기술을 어떤 걸 쓸지 아직 정해지지 않았다고 해보자. 그래도 데드라인까지 개발은 진행되어야 하기에 MemoryMemberRepository 클래스로 인터페이스를 구현한다.

public class MemoryMemberRepository implements MemberRepository {
    private static Map<Long, Member> store = new HashMap<>();

    @Override
    public void save(Member member) {
        store.put(member.getId(), member);
    }
}

MemberService라는 인터페이스를 구현한 MemberServiceImpl 클래스에서 MemberRepository 형 변수에 MemoryMemberRepository 객체를 대입해서 사용할 것이다.

public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    @Override
    public void join(Member member) {
        memberRepository.save(member);
    }
}

추후에 DB 기술과 정책이 정해지고 나면, 다른 부분은 건드리지 않고, MemoryMemberRepository를 새로 구현한 JdbcMemberRepository로 갈아끼우면 된다.

public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository = new JdbcMemberRepository();

    @Override
    public void join(Member member) {
        memberRepository.save(member);
    }
}

이처럼 상속과 구현을 통해 다형성을 활용하면 코드를 유연하게 변경하고 재사용할 수 있다.


Reference

  1. 강의 - 스프링 핵심원리 기본편(김영한)
  2. 도서 - 이재환의 자바 프로그래밍 입문(이재환)
  3. JVM의 메모리 구조 및 할당과정
profile
완벽주의가 아닌 완성주의(블로그 이동 중...)

2개의 댓글

comment-user-thumbnail
2023년 8월 17일

개발자로서 배울 점이 많은 글이었습니다. 감사합니다.

1개의 답글

관련 채용 정보