Language/Java

[Java] 인터페이스 (Interface) + abstract class와의 차이

Ho-home 2024. 10. 12. 00:07

 

 

지난 시간에는 추상메소드에 대해 알아보았습니다.

이번 포스팅에서는 interface에 관한 내용이 주되긴 하지만, 추상 클래스(abstract class)와의 비교를 포함하기도 하였으니, abstract class에 대한 이해가 부족하신 분들은 아래의 링크를 통해 간단하게라도 훑어보시시는것을 추천드립니다.

 

https://hohome-develop.tistory.com/11

 

이번에 알아볼 Java에서의 '인터페이스'는 Java에서 다형성과 코드의 결합도를 낮추기 위해 중요한 역할을 합니다.

 

이번 글에서는 인터페이스의 개념과 사용 방법, 그리고 활용 예시를 중심으로 인터페이스에 대해 알아보도록 하겠습니다.

 

인터페이스(Interface)


 

인터페이스(Interface)는 Java에서 클래스가 구현해야 하는 메소드의 집합을 정의하는 일종의 계약(혹은 규약)입니다.

인터페이스는 클래스와 달리 구현을 포함하지 않으며, 메소드의 시그니처만을 정의하는데, 이를 통해 인터페이스를 구현하는 클래스는 해당 메소드를 반드시 구현하도록 강제됩니다.

 

인터페이스는 다음과 같은 특징을 가지고 있습니다.

  1. 추상 메소드만 포함: 인터페이스는 일반적으로 구현되지 않은 추상 메소드만 포함합니다. 모든 메소드는 기본적으로 public이며, abstract 키워드를 생략해도 됩니다.
  2. 다중 상속 지원: 클래스는 단일 상속만 지원하지만, 인터페이스는 다중 상속을 지원합니다. 이는 Java에서 다형성을 제공하는 강력한 도구입니다.
  3. 상수 필드 포함: 인터페이스는 상수 필드를 가질 수 있습니다. 모든 필드는 public static final로 자동으로 선언됩니다.

아래는 인터페이스의 예시 코드입니다.

public interface Animal {
    void sound(); // 추상 메소드
    void run();   // 추상 메소드
}

 

위 코드에서 Animal 인터페이스는 sound()run()이라는 두 개의 추상 메소드를 정의하고 있습니다.

이 인터페이스를 구현하는 클래스는 이 메소드들을  implements 키워드를 사용하여 구현해야 합니다.

 

아래는 Animal 인터페이스를 구현한 Cat 클래스의 예시입니다:

public class Cat implements Animal {
    @Override
    public void sound() {
        System.out.println("Meow Meow!");
    }

    @Override
    public void run() {
        System.out.println("Cat is Running!");
    }
}

class Main {
    public static void main(String[] args) {
        Animal cat = new Cat();
        cat.sound(); // result: Meow Meow!
        cat.run();   // result: Cat is Running!
    }
}

위 코드에서 Cat 클래스는 Animal 인터페이스를 구현하고 있습니다.

Cat 클래스는 sound()run() 메소드를 각각 구현하여 "Meow Meow!"와 "Cat is Running!"을 출력합니다.

 

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

    @Override
    public void run() {
        System.out.println("Dog is Running!");
    }
}

class Main {
    public static void main(String[] args) {
        Animal Dog = new Dog();
        dog.sound(); // result: Woof Woof!
        dog.run();   // result: Dog is Running!
    }
}

또한, 위 코드는 Animal인터페이스를 Dog클래스로 구현한 코드입니다.

이렇듯, 인터페이스를 통해 우리는 동일한 인터페이스를 구현한 다양한 클래스들을 하나의 타입으로 다룰 수 있게 됩니다.

이를 통해 다형성을 효과적으로 구현할 수 있으며, 코드의 유연성과 확장성을 크게 높일 수 있습니다.

 

 

 

인터페이스의 다중 상속


 

 

다중 인터페이스 구현 클래스는 여러 인터페이스를 동시에 구현할 수 있으며, 이를 통해 다양한 기능을 조합하여 클래스를 설계할 수 있습니다.

 

아래 예시는 Pet Animal 두 개의 인터페이스를 구현하는 Dog 클래스를 보여줍니다:

public interface Pet {
    void play();
}

public class Dog implements Animal, Pet {
    @Override
    public void sound() {
        System.out.println("Woof Woof!");
    }

    @Override
    public void run() {
        System.out.println("Dog is Running!");
    }

    @Override
    public void play() {
        System.out.println("Dog is Playing!");
    }
}

class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
        dog.sound(); // result: Woof Woof!
        dog.run();   // result: Dog is Running!
        dog.play();  // result: Dog is Playing!
    }
}

위 코드에서 Dog 클래스는 Animal Pet 인터페이스를 동시에 구현하고 있습니다.

이를 통해 Dog 클래스는 두 인터페이스의 모든 메소드를 구현해야 하며, sound(), run(), play() 메소드를 제공하게 됩니다.

 

다중 인터페이스 구현을 통해 클래스는 여러 역할을 수행할 수 있으며 이를 통해 코드의 재사용성을 극대화할 수 있습니다.

 

클래스는 단일 상속만을 지원하지만, 인터페이스는 다중 상속을 지원합니다.

다중 상속은 여러 부모 클래스로부터 상속받는 것을 의미하며, Java에서는 클래스 간의 다중 상속을 허용하지 않습니다.

 

그 이유는 Diamond Problem라고 불리는 문제 때문인데, Diamond Problem은 두 부모 클래스가 동일한 메소드를 가질 때 어떤 부모의 메소드를 상속받아야 할지 모호해지는 문제를 말합니다.

이를 해결하기 위해 Java는 클래스 간의 다중 상속을 금지하고, 대신 인터페이스를 통한 다중 구현을 허용하였습니다.

 

인터페이스를 사용하면 다중 상속과 유사한 효과를 얻을 수 있으면서도 Diamond Problem을 회피할 수 있습니다.

인터페이스는 메소드의 구현을 포함하지 않기 때문에 다중 상속에서 발생할 수 있는 모호성을 피할 수 있습니다.

 

default 메소드와 static 메소드


Java 8부터는 인터페이스에 default 메소드와 static 메소드가 추가되었습니다. default 메소드는 인터페이스에서 구현을 제공할 수 있는 메소드이며, 이를 통해 기존의 인터페이스에 새로운 기능을 추가하더라도 하위 호환성을 유지할 수 있습니다.

아래 예시는 default 메소드와 static 메소드를 사용하는 인터페이스의 예시입니다:

public interface Animal {
    void sound();
    void run();

    default void eat() {
        System.out.println("This animal is eating.");
    }

    static void sleep() {
        System.out.println("This animal is sleeping.");
    }
}

public class Cat implements Animal {
    @Override
    public void sound() {
        System.out.println("Meow Meow!");
    }

    @Override
    public void run() {
        System.out.println("Cat is Running!");
    }
}

class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();
        cat.sound(); // result: Meow Meow!
        cat.run();   // result: Cat is Running!
        cat.eat();   // result: This animal is eating.

        Animal.sleep(); // result: This animal is sleeping.
    }
}

위 코드에서 Animal 인터페이스는 eat()이라는 default 메소드와 sleep()이라는 static 메소드를 가지고 있습니다.

Cat 클래스는 eat() 메소드를 오버라이드하지 않았기 때문에, default 메소드의 구현이 사용됩니다.

또한, static 메소드는 인터페이스 이름을 통해 호출할 수 있습니다 (생성자를 통해 호출하지않고, 정적변수를 부르듯 호출할 수 있습니다).

 

default 메소드는 기존 인터페이스에 새로운 기능을 추가하면서도 기존 구현체들과의 호환성을 유지할 수 있게 해주며, static 메소드는 인터페이스 레벨에서 공통 기능을 제공하는 데 유용합니다.

 

 

추상 클래스와 인터페이스의 차이


 

 

인터페이스와 추상 클래스는 모두 추상 메소드를 포함할 수 있지만, 그 목적과 사용 방식에는 몇 가지 차이점이 있습니다.

  1. 다중 상속 가능 여부: 인터페이스는 다중 구현이 가능하지만, 추상 클래스는 단일 상속만 가능합니다. 즉, 한 클래스가 여러 인터페이스를 구현할 수 있지만, 단 하나의 추상 클래스만 상속받을 수 있습니다.
  2. 구현 여부: 추상 클래스는 구현된 메소드를 포함할 수 있지만, 인터페이스는 기본적으로 모든 메소드가 추상적입니다. 다만, Java 8 이후로 인터페이스도 default 메소드를 통해 일부 구현을 포함할 수 있게 되었습니다.
  3. 필드: 추상 클래스는 인스턴스 변수와 생성자를 가질 수 있지만, 인터페이스는 상수(public static final)만 가질 수 있습니다. 추상 클래스는 상태를 가질 수 있는 반면, 인터페이스는 상태를 가지지 않습니다.
  4. 사용 목적: 추상 클래스는 클래스 간의 공통된 동작을 제공하기 위한 용도로 사용되며, 상속 관계에서 코드의 재사용을 목적으로 합니다. 반면 인터페이스는 클래스가 특정 행동을 하도록 강제하기 위한 계약의 역할을 합니다.

아래는 추상 클래스와 인터페이스의 차이를 보여주는 예시입니다.

public abstract class Vehicle {
    private String name;

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

    public String getName() {
        return name;
    }

    public abstract void move(); // 추상 메소드
}

public interface Flyable {
    void fly();
}

public class Airplane extends Vehicle implements Flyable {
    public Airplane(String name) {
        super(name);
    }

    @Override
    public void move() {
        System.out.println(getName() + " is moving on the runway.");
    }

    @Override
    public void fly() {
        System.out.println(getName() + " is flying in the sky.");
    }
}

class Main {
    public static void main(String[] args) {
        Airplane airplane = new Airplane("Boeing 747");
        airplane.move(); // result: Boeing 747 is moving on the runway.
        airplane.fly();  // result: Boeing 747 is flying in the sky.
    }
}

위 코드에서 Vehicle은 추상 클래스이며, Flyable은 인터페이스입니다. Airplane 클래스는 추상 클래스 Vehicle을 상속받고, 인터페이스 Flyable을 구현합니다.

추상 클래스는 공통된 동작(move())을 제공하고, 인터페이스는 특정 기능(fly())을 강제합니다.

또한, 인터페이스를 사용하면 코드의 결합도를 낮출 수 있습니다.

결합도는 두 클래스 간의 의존성을 의미하며, 결합도가 낮을수록 코드의 유지보수가 쉬워지는데, 인터페이스는 클래스 간의 의존성을 인터페이스로 바꾸어 결합도를 줄이는 데 중요한 역할을 합니다.

 

아래는 인터페이스를 통해 결합도를 낮춘 예시입니다.

public interface Printer {
    void print(String message);
}

public class ConsolePrinter implements Printer {
    @Override
    public void print(String message) {
        System.out.println(message);
    }
}

public class Report {
    private Printer printer;

    public Report(Printer printer) {
        this.printer = printer;
    }

    public void generate(String content) {
        printer.print(content);
    }
}

class Main {
    public static void main(String[] args) {
        Printer consolePrinter = new ConsolePrinter();
        Report report = new Report(consolePrinter);
        report.generate("This is a report."); // result: This is a report.
    }
}

위 코드에서 Report 클래스는 Printer 인터페이스에 의존하고 있으며, ConsolePrinter 클래스는 Printer 인터페이스를 구현하고 있습니다. Report 클래스는 Printer 인터페이스만 알고 있으므로, Printer의 구현체가 변경되더라도 Report 클래스의 코드는 수정할 필요가 없습니다. 이를 통해 코드의 결합도가 낮아지고, 유지보수와 확장이 용이해집니다.

 

인터페이스의 활용

인터페이스는 다양한 곳에서 사용될 수 있으며, 특히 아래와 같은 경우에 많이 활용됩니다:

  1. 다형성 구현: 인터페이스를 사용하면 동일한 인터페이스를 구현한 여러 클래스를 동일한 타입으로 다룰 수 있으므로, 다형성을 구현할 수 있습니다. 예를 들어, List 인터페이스를 통해 ArrayListLinkedList와 같은 구현체를 다룰 수 있습니다.
  2. 전략 패턴: 전략 패턴에서 인터페이스는 알고리즘군을 정의하는 데 사용됩니다. 각기 다른 알고리즘을 인터페이스로 정의하고, 런타임에 적절한 알고리즘을 선택하여 사용할 수 있습니다.
  3. 의존성 주입(DI): 의존성 주입에서 인터페이스는 구현체를 유연하게 바꿀 수 있도록 도와줍니다. 이를 통해 객체 간의 결합도를 낮추고 테스트 용이성을 높일 수 있습니다.

 

후기


이번 포스팅에서는 인터페이스에 대해 다뤄보았습니다.

인터페이스는 객체지향 프로그래밍에서 다형성과 결합도 감소, 코드의 유연성 등을 제공하며, 프로젝트 전반의 규약을 설정하는데에 아주 중요한 역할을 합니다.

이러한 인터페이스를 잘 활용하면 코드의 유지보수성과 확장성을 크게 향상시킬 수 있음을 알 수 있습니다.

 

실제로, 이러한 인터페이스를 저의 프로젝트에 최대한 활용할 수 있도록 많은 노력을 하고있는데, 인터페이스를 DI하여 사용하면 내부 구현체를 바꿔도 해당 구현체에 문제점이 있는것이 아닌 한, 확장성과 유지보수성(구현체만 변경시키고 나머지 코드는 변경을 시킬 필요가 없음)이 증대하는것을 몸소 깨닫게되었습니다.

 

아무리 이론적으로 학습하여도 역시 직접 몸소 체험하는것이 컴퓨터공학에서 어떠한 개념을 익힐때에 아주 중요한 역할을 하는 것 같습니다. 

이번 포스팅은 여기까지 하도록 하겠습니다.

혹여나 글에서 다뤄야 할 중요한 부분이 빠졌거나, 잘못된 내용이 있다면 언제든지 알려주시기 바랍니다.

긴 글 읽어주셔서 감사합니다.