[아이템20] 추상 클래스보다는 인터페이스를 우선하라

2024. 10. 14. 23:15·1️⃣ 백앤드/이펙티브 자바

1. 추상 클래스 vs 인터페이스

이 아이템에 들어가기 전에, 추상 클래스와 인터페이스의 차이에 대해 먼저 알아야 한다.

추상 클래스 vs 인터페이스

 

다시 말해, 인터페이스는 말 그대로 구현 클래스에서 구현한 내용을 API로 클라이언트에 제공하고, 추상 클래스는 구현 클래스와 상속 관계로써 자식 클래스에서 부모 클래스의 메서드를 확장하여 구현한 것이다. 언뜻 봐선 같은 내용이지만 구현 (implements) 과 확장 (extend) 의 의미를 생각해보면 조금 더 와닿을 것이다. 아래 예시를 통해 좀 더 자세히 살펴보자

// 추상 클래스
abstract class Animal {
    String name;

    Animal(String name) {
        this.name = name;
    }

    // 추상 메서드
    abstract void sound();

    // 일반 메서드
    void sleep() {
        System.out.println(name + " is sleeping.");
    }
}

// 추상 클래스를 상속받은 하위 클래스
class Dog extends Animal {
    Dog(String name) {
        super(name);
    }

    // 추상 메서드를 구현
    @Override
    void sound() {
        System.out.println(name + " says Woof!");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog("Buddy");
        dog.sound();  // Buddy says Woof!
        dog.sleep();  // Buddy is sleeping.
    }
}

 

Animal 이라는 추상 클래스는 공통 속성과 메서드를 포함하면서, sound 라는 특정 추상 메서드만 하위 클래스에서 구현하도록 강제하는 구조이다.

 

// 인터페이스
interface Movable {
    void move();
}

// 인터페이스 구현 클래스
class Car implements Movable {
    @Override
    public void move() {
        System.out.println("The car is moving.");
    }
}

// 다른 인터페이스 구현 클래스
class Person implements Movable {
    @Override
    public void move() {
        System.out.println("The person is walking.");
    }
}

public class Main {
    public static void main(String[] args) {
        Movable car = new Car();
        car.move();  // The car is moving.

        Movable person = new Person();
        person.move();  // The person is walking.
    }
}

 

Movable 이라는 인터페이스는 클래스가 반드시 구현해야 할 move 메서드만을 정의하며, 구현 클래스는 반드시 오버라이드 해 구현을 해야한다. 또한, 다중 구현을 허용해 다양한 클래스로 확장할 수가 있다.

기존 클래스에도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있지만, 추상 클래스를 끼워넣긴 어렵다. (상속문제)
인터페이스는 믹스인(mixin) 정의에 안성맞춤이다.

 

다시 말해, 인터페이스를 사용하면 클래스의 주된 기능에 선택적 기능을 더해 혼합(mixed in) 할 수 있다. 믹스인은 클래스에 추가적인 기능이나 동작을 섞어 넣는 것을 말하고, 예를 들어 Comparable 인터페이스를 통해 자신을 구현한 클래스의 인스턴스들끼리는 순서를 정할 수 있도록 할 수 있다.

class Person implements Comparable<Person> {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Comparable 인터페이스의 compareTo 메서드 구현
    @Override
    public int compareTo(Person other) {
        // 나이 기준으로 비교
        return Integer.compare(this.age, other.age);
    }

    @Override
    public String toString() {
        return name + " (" + age + ")";
    }
}

public class Main {
    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Alice", 30));
        people.add(new Person("Bob", 25));
        people.add(new Person("Charlie", 35));

        // Comparable 덕분에 정렬 가능
        Collections.sort(people);

        // 정렬된 결과 출력
        for (Person person : people) {
            System.out.println(person);
        }
    }
}

 

Comparable 인터페이스의 compareTo 메서드를 구현해 낸 Person 클래스에서는 나이를 기준으로, Person 클래스로부터 생성된 인스턴스들 간의 순서를 결정할 수 있게 되었다. 위에서 말한 것 처럼, 사실 나이 순 정렬이 이 클래스의 주된 기능은 아니었겠지만 선택적 기능 (나이순 정렬) 을 얻을 수 있게 되었다.

인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다.

 

다시 말해 클래스 상속처럼 계층구조를 강제하지 않고, 인터페이스를 사용해 다양한 기능을 자유롭게 섞어 쓸 수 있는 구조를 만들 수 있다는 뜻이다. 아래 예시를 통해 살펴보자.

class Animal { }  // 동물
class Mammal extends Animal { }  // 포유류
class Dog extends Mammal { }  // 개

 

추상클래스는 아니지만,  먼저 상속은 위 예시와 같이 '동물 -> 포유류 -> 개' 와 같은 계층이 있다. 이렇게 되면 상속의 구조를 꼭 따라야한다.

public interface Walkable {
    void walk();
}

public interface Swimmable {
    void swim();
}

public interface Flyable {
    void fly();
}

public interface Robot extends Walkable, Swimmable, Flyable {
    void run();
    void cry();
}

 

하지만, 인터페이스를 통해 계층 구조를 없앨 수 있다. 걸을 수 있는, 수영할 수 있는, 날 수 있는 개체는 얼마나 많은가? 그 중, 세 가지 모두를 할 수 있는 로봇이 나타났다고 하자. 모든 인터페이스를 확장해 메서드를 구현하고, 심지어는 새로운 메서드를 추가하면서 제3의 인터페이스를 정의할 수 있다.

 

2. 인터페이스의 디폴트 메서드

인터페이스는 구현체의 API 역할만 하는 것이 아니라, 구현 방법이 명확한 것이 있다면, 디폴트 메서드로 제공할 수 있다. 예시로, Collection 인터페이스의 removeIf 가 있다.

// 자바8의 Collection 인터페이스에 추가된 디폴트 메서드
default boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    boolean result = false;
    for (Iterator<E> it = iterator(); it.hasNext(); ) {
        if (filter.test(it.next())) {
            it.remove();
            result = true;
        }
    }
    return result;
}

List<Integer> numbers = new ArrayList<>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
numbers.add(4);
numbers.add(5);
numbers.add(6);

// 짝수인 요소를 제거
numbers.removeIf(n -> n % 2 == 0);

// 결과 출력 (짝수는 제거됨)
System.out.println(numbers);  // 출력: [1, 3, 5]

 

디폴트 메서드를 제공할 때는, 상속하려는 사람을 위한 설명을 @implSpec 태그를 붙여 문서화 해야한다. 하지만, 디폴트 메서드에도 제약은 있다. 인터페이스에서 equals 와 hashCode 같은 Object 메서드를 재정한 것은 디폴트 메서드로 제공해서는 안된다. 또한, 내가 만든 인터페이스가 아니라면 디폴트 메서드를 추가해선 안된다.

 

3. 인터페이스와 추상 골격 구현 클래스

인터페이스로는 타입을 정의하고, 필요 시 디폴트 메서드 몇 개를 제공한다. 그리고, 골격 구현 클래스에서는 나머지 메서드들을 구현한다. 그리고, 일반 클래스에선 골격 구현 클래스를 확장하는 것만으로, 인터페이스를 구현하는 것을 완료한다. 예시를 통해 살펴보자.

interface Shape {
    double area();  // 넓이를 구하는 메서드
    double perimeter();  // 둘레를 구하는 메서드
}


// 추상 골격 구현 클래스
abstract class AbstractShape implements Shape {
    @Override
    public double perimeter() {
        // 기본적으로 둘레는 0으로 설정 (하위 클래스에서 필요 시 재정의)
        return 0;
    }

    // 넓이는 추상 메서드로 남겨둠 (하위 클래스에서 반드시 구현)
    @Override
    public abstract double area();
}

// 클래스에서 추상 골격 구현 클래스를 확장
class Circle extends AbstractShape {
    private double radius;

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

    // 추상 메서드 area() 구현
    @Override
    public double area() {
        return Math.PI * radius * radius;  // 원의 넓이 계산
    }

    // 필요한 경우 perimeter()를 재정의
    @Override
    public double perimeter() {
        return 2 * Math.PI * radius;  // 원의 둘레 계산
    }
}

class Square extends AbstractShape {
    private double side;

    Square(double side) {
        this.side = side;
    }

    // 추상 메서드 area() 구현
    @Override
    public double area() {
        return side * side;  // 정사각형의 넓이 계산
    }

    // 필요한 경우 perimeter()를 재정의
    @Override
    public double perimeter() {
        return 4 * side;  // 정사각형의 둘레 계산
    }
}

 

넓이와 둘레를 구하는 메서드를 가지고 있는 Shape 인터페이스를 두고 (여기서는 디폴트 메서드를 구현하진 않았다), AbstractShape 이라는 추상 골격 구현 클래스를 통해 구현시켰다. 메서드 하나는 구현, 하나는 추상 메서드로 남겨놓고 하위 클래스에서 상속받아 재정의 할 수 있도록 했다. 이름은 관례상 인터페이스 앞에 Abstract 를 붙이며, AbstractCollection, AbstractSet, AbstractList, AbstractMap 각각이 핵심 컬렉션 인터페이스의 골격 구현이다.

골격 구현 클래스는 추상 클래스처럼 구현을 도와줌과 동시에, 상속의 제약에서 자유로울 수 있다.

 

3-1. 골격 구현 클래스 작성

골격 구현 클래스 작성은 상대적으로 쉽다. 인터페이스를 잘 살펴, 추상 메서드로 활용할 기반 메서드들을 선정한다. 그 다음, 기반 메서드를 사용해 직접 구현이 가능한 건 디폴트 메서드로 제공하고, 기반 메서드 또는 디폴트 메서드로 만들어 내지 못한 메서드가 남아있다면, 인터페이스를 구현하는 골격 구현 클래스를 하나 만들어, 남은 메서드들을 작성해넣는다. 좋은 예시로, Map.Entry 인터페이스의 AbstractMapEntry 골격 구현 클래스를 살펴보자.

public abstract class AbstractMapEntry<K, V> implements Map.Entry<K, V> {

    // 변경 가능한 엔트리는 이 메서드를 반드시 재정의해야 한다.
    @Override
    public V setValue(V value) {
        throw new UnsupportedOperationException();
    }

    // Map.Entry.equals의 일반 규약을 구현한다.
    @Override
    public boolean equals(Object obj) {
        if (obj == this) {
            return true;
        }
        if (!(obj instanceof Map.Entry)) {
            return false;
        }
        Map.Entry<?, ?> e = (Map.Entry) obj;
        return Objects.equals(e.getKey(), getKey()) && Objects.equals(e.getValue(), getValue());
    }

    // Map.Entry.hashCode의 일반 규약을 구현한다.
    @Override
    public int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    @Override
    public String toString() {
        return getKey() + "=" + getValue();
    }
}

 

getKey, getValue 라는 기반 메서드를 통해, equals 와 hashCode 의 동작 방식을 정의해놓았다. 이 둘은 디폴트 메서드로 제공해선 안되기 때문에, 골격 구현 클래스에서 구현해 놓았다. 또한, toString 메서드도 기반 메서드를 사용해 구현해놓았다.

골격 구현은 기본적으로, 아이템 19에서 다뤘던 상속의 설계 및 문서화 지침에 모두 따라야 한다.

'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글

[아이템22] 인터페이스는 타입을 정의하는 용도로만 사용하라  (0) 2024.10.15
[아이템21] 인터페이스는 구현하는 쪽을 생각해 설계해라  (2) 2024.10.14
[아이템19] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라.  (0) 2024.10.10
[아이템18] 상속보다는 컴포지션을 사용해라  (0) 2024.10.09
[아이템17] 변경 가능성을 최소화하라  (1) 2024.10.06
'1️⃣ 백앤드/이펙티브 자바' 카테고리의 다른 글
  • [아이템22] 인터페이스는 타입을 정의하는 용도로만 사용하라
  • [아이템21] 인터페이스는 구현하는 쪽을 생각해 설계해라
  • [아이템19] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라.
  • [아이템18] 상속보다는 컴포지션을 사용해라
HOZINU
HOZINU
주니어 백앤드 개발자의 세상만사 이모저모. 주로 개발 이야기를 다룸.
  • HOZINU
    백엔드 탐험 일지
    HOZINU
  • 전체
    오늘
    어제
  • 블로그 메뉴

    • ⛪ HOME
    • 🌍 GITHUB
    • 카테고리 (73)
      • 1️⃣ 백앤드 (72)
        • 이펙티브 자바 (72)
      • 2️⃣ CS (0)
        • 운영체제 (0)
        • 네트워크 기초 (0)
        • 네트워크 응용 (0)
        • SSL & PKI (0)
        • 기타 (0)
      • 3️⃣ 코딩테스트 (0)
      • 4️⃣ 개인공부 (0)
        • MSA (0)
        • REDIS (0)
      • 5️⃣ 일상이야기 (1)
  • 인기 글

  • 태그

    optional
    equals
    캡슐화
    finalizer
    로타입
    싱글턴
    멤버클래스
    정적 팩터리 메서드
    의존객체
    계층구조
    hashcode
    맥북
    Cleaner
    표준예외
    정보은닉
    try-with-resources
    CLONE
    컴포지션
    빌더
    Comparable
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
HOZINU
[아이템20] 추상 클래스보다는 인터페이스를 우선하라
상단으로

티스토리툴바