본문 바로가기

개발/Basics

[Java/Basics] 자바 기초 스터디 3주차: 상속과 다형성

먼저 상속과 다형성의 개념과 필요성을 이해하고, 자바에서 이를 어떻게 구현하는지 코드 예제로 확인한 후, 실무에서 어떻게 활용할 수 있는지 예제와 함께 정리하고자 한다.

1. 상속(Inheritance)이란?

  • 기존 클래스를 기반으로 새로운 클래스를 만들고 코드를 재사용하는 기법.
  • 자식(하위) 클래스가 부모(상위) 클래스의 속성과 메서드를 물려받는다.

1-1. 상속이 필요한 이유

  • 코드의 재사용성 증가
    • 중복 코드를 줄이고 유지보수성을 높일 수 있음.
  • 확장성
    • 새로운 기능을 추가할 때 기존 클래스를 수정하지 않고 확장 가능.
  • 객체 간의 계층 구조 형성
    • 현실 세계를 더 직관적으로 모델링 가능
    • 예: 동물 -> 개, 고양이

1-2. 상속 구현 방법(예제 코드)

// 부모 클래스 (상위 클래스)
public class Animal {
    public void eat() {
        System.out.println("This animal eats food.");
    }
}

// 자식 클래스 (하위 클래스)
public class Dog extends Animal {
    public void bark() {
        System.out.println("The dog barks.");
    }
}

// 실행 코드
public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        dog.eat();  // 부모 클래스의 메서드 사용
        dog.bark(); // 자식 클래스의 메서드 사용
    }
}

결과

This animal eats food.
The dog barks.
  • Dog 클래스는 Animal 클래스를 상속받아 eat() 메서드를 사용 가능.
  • Dog 클래스에서 bark() 메서드를 추가하여 새로운 기능을 확장.

1-4. 상속의 한계(오버라이딩과 오버로딩)

  • 단순 상속만으로는 부모 클래스의 기능을 수정할 수 없다.
    • 부모 클래스의 기능을 변경하고 싶다면? 오버라이딩(Overriding)
    • 동일한 메서드 이름을 유지하면서 다양한 입력을 처리하고 싶다면? 오버로딩(Overloading)

(1) 메서드 오버라이딩(Overriding)

  • 부모클래스의 메서드를 자식 클래스에서 재정의하는 기법.
  • @Override 어노테이션을 사용하여 가독성 높일 수 있음
public class Animal {
    public void makeSound() {
        System.out.println("Animal makes a sound");
    }
}

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

public class Main {
    public static void main(String[] args) {
        Animal myDog = new Dog();
        myDog.makeSound();  // Dog barks
    }
}
  • 부모 클래스의 makeSound()를 자식 클래스에서 재정의함.
  • 부모 타입으로 선언했지만 오버라이딩된 메서드가 실행됨.(다형성 적용)

(2) 메서드 오버로딩(Overloading)

  • 같은 이름의 메서드를 다른 매개변수로 정의하여 유연성을 제공.
public class MathUtil {
    public int add(int a, int b) {
        return a + b;
    }

    public double add(double a, double b) {
        return a + b;
    }
}

public class Main {
    public static void main(String[] args) {
        MathUtil util = new MathUtil();
        System.out.println(util.add(3, 5));      // 8
        System.out.println(util.add(2.5, 3.5));  // 6.0
    }
}
  • 메서드 이름은 같지만 매개벼수 타입이 다르면 서로 다른 메서드로 인식됨.

1-5. 상속의 단점

  • 부모 클래스가 변경되면, 모든 자식 클래스가 영향을 받음(강한 결합 문제)
  • 다중 상속을 지원하지 않아 유연성이 떨어짐
  • 해결책: 인터페이스를 사용하여 유연성 확보 가능
// 상속 대신 인터페이스 활용
public interface SoundMaker {
    void makeSound();
}

public class Dog implements SoundMaker {
    @Override
    public void makeSound() {
        System.out.println("Dog barks");
    }
}

public class Main {
    public static void main(String[] args) {
        SoundMaker dog = new Dog();
        dog.makeSound();
    }
}
  • 인터페이스를 활용하면 클래스 간 결합도 낮출 수 있음

2. 다형성(Polymorphism)이란?

  • 같은 메서드를 다른 객체에서 다르게 동작하도록 하는 원리.
  • 부모 클래스 타입으로 여러 자식 클래스 객체를 다룰 수 있음.
  • 유연성과 확장성이 증가하여 유지보수가 쉬워짐.

2-1. 다형성 구현 방법

public class Main {
    public static void main(String[] args) {
        Animal myAnimal = new Dog(); // 부모 타입으로 자식 객체 참조
        myAnimal.makeSound();  // "Dog barks"
    }
}
  • Animal 타입 변수에 Dog 객체를 대입 -> 업캐스팅(Upcasting)
  • makeSound()를 호출했을 때, 오버라이딩된 자식 클래스의 메서드가 실행됨.

2-2. 다형성의 장점

  • 유연한 코드 작성 가능
    • 같은 메서드를 호출하더라도 다른 객체에서 다르게 동작
  • 확장성이 뛰어남
    • 새로운 객체(클래스)를 추가해도 기존 코드 수정이 거의 필요 없음
  • 코드 유지보수성 증가
    • 부모 타입을 사용하면 새로운 기능을 추가해도 기존 코드 수정 최소화.

2-3. 다형성 활용 예제


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

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

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

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

        for (Animal animal : animals) {
            animal.makeSound(); // 다형성 적용
        }
    }
}
  • Animal 배열을 통해 다양한 객체를 한 번에 처리할 수 있음.
  • 출력 결과
    Dog barks
    Cat meows

2-4. 다형성의 실무 활용 사례

다형성은 유연한 코드 작성과 유지보수성을 높이는 핵심 원리이다. 실무에서는 다형성을 활용하여 다양한 객체를 동일한 방식으로 처리할 수 있으며, 특히 프레임워크(Spring)에서 의존성 주입(DI)과 함께 사용된다.

다형성을 활용한 알림 서비스 예제

  • 이메일과 SNS 알림을 전송하는 시스템을 만들 때, NotificationService 인터페이스를 사용하면 새로운 알림 유형을 쉽게 추가할 수 있음.
// 1. 공통 인터페이스 정의
public interface NotificationService {
    void sendNotification(String message);
}

// 2. 이메일 알림 서비스 구현
public class EmailNotification implements NotificationService {
    @Override
    public void sendNotification(String message) {
        System.out.println("Email sent: " + message);
    }
}

// 3. SMS 알림 서비스 구현
public class SMSNotification implements NotificationService {
    @Override
    public void sendNotification(String message) {
        System.out.println("SMS sent: " + message);
    }
}

// 4. 다형성을 활용한 알림 전송 클래스
public class NotificationSender {
    private NotificationService notificationService;

    public NotificationSender(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void send(String message) {
        notificationService.sendNotification(message);
    }
}

// 5. 실행 예제
public class Main {
    public static void main(String[] args) {
        NotificationSender emailSender = new NotificationSender(new EmailNotification());
        emailSender.send("Hello via Email!");

        NotificationSender smsSender = new NotificationSender(new SMSNotification());
        smsSender.send("Hello via SMS!");
    }
}

예시 정리

  • 확장성 증가: 새로운 알림 방식(예: 카카오톡 알림) 추가 시 기존 코드 수정 없이 확장 가능.
  • 유지보수 용이: 특정 알림 방식의 내부 구현을 변경해도 NotificationSender 클래스에는 영향을 주지 않음.
  • 스프링(Spring)과 같은 프레임워크에서 활용 가능:
    • NotificationService를 Spring Bean으로 등록하고 의존성 주입(DI)을 통해 동적으로 객체를 주입받을 수 있음.

2-5. 다형성과 제네릭(Generic) 활용

다형성과 제네릭을 함께 사용하면 더 유연한 코드를 작성할 수 있다.
제네릭을 활용하면, 타입을 유연하게 유지하면서 코드의 중복을 줄이고 다양한 객체에 대해 동일한 로직을 적용할 수 있음.

제네릭을 활용한 프린터 클래스 예제

// 제네릭을 활용한 다형성 구현
public class Printer<T> {
    private T item;

    public Printer(T item) {
        this.item = item;
    }

    public void print() {
        System.out.println(item);
    }
}

public class Main {
    public static void main(String[] args) {
        Printer<Integer> intPrinter = new Printer<>(123);
        Printer<String> stringPrinter = new Printer<>("Hello OOP!");

        intPrinter.print();  // 123 출력
        stringPrinter.print(); // Hello OOP! 출력
    }
}

예제 정리

  • 코드 중복 제거: Printer 클래스 하나만 만들어두면, 다양한 타입의 데이터를 처리 가능.
  • 유지보수성 증가: 데이터 타입을 바꿀 필요 없이 제네릭을 활용해 타입을 유연하게 설정 가능.
  • 컬렉션과 함께 활용 가능: List, Map<K, V> 등의 컬렉션 API와 함께 사용하면 더욱 강력함.

제네릭과 다형성을 활용한 리스트 처리

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Animal> animals = new ArrayList<>();
        animals.add(new Dog());
        animals.add(new Cat());

        for (Animal animal : animals) {
            animal.makeSound(); // 다형성 적용
        }
    }
}
  • List을 사용하여 다양한 동물을 한 번에 처리할 수 있음.
  • 새로운 동물 클래스를 추가해도 기존 코드를 수정할 필요가 없음.

정리

✔︎ 상속(Inheritance)

  • 코드 재사용성과 유지보수성을 높인다.
  • 부모 클래스의 기능을 물려받아 새로운 클래스 쉽게 생성 가능

✔︎ 다형성(Polymorphism)

  • 같은 메서드 호출이 객체에 따라 다르게 동작.
  • 유지보수와 확장성이 뛰어나고, 다양한 객체를 쉽게 다룰 수 있음.
  • 제네릭과 함께 사용하면 코드 중복을 줄이고 다양한 타입을 처리할 수 있음.

✔︎ 실무 적용

  • 스프링(Spring) 같은 프레임워크에서 의존성 주입(DI) 및 인터페이스 기반 개발에 활용됨.
  • List animals = new ArrayList<>(); → 다양한 구현체를 다룰 수 있음.