추상화는 객체 지향 프로그래밍의 핵심적인 특징입니다. 추상화를 통해 간단한 인터페이스를 제공하고, 복잡한 구현은 뒤로 감춰줍니다.
Java에서는 인터페이스와 추상 클래스를 통해 추상화를 달성합니다.
이 둘은 어떤 차이점이 있을까요?
인터페이스에 선언 가능한 것들을 살펴보도록 하겠습니다.
public interface Animal {
// 추상 메서드
void makeSound();
void move();
}
추상 메서드는 구현부({ }
)가 없는 메서드입니다. 오직 메서드 이름과 파라미터만 가지며, abstract 키워드를 붙여 정의합니다.
인터페이스에 구현부가 없는 메서드를 정의하면, 컴파일러가 abstract 키워드를 자동으로 붙여줍니다.
이때 추상 메서드의 접근 제어자는 오직 public으로만 선언할 수 있습니다.
private, protected 등으로 선언 시 위와 같이 컴파일 에러가 발생합니다.
public interface Animal {
// 상수
int MAX_AGE = 100;
}
인터페이스에서 변수를 선언할 때는 항상 public
, static
, final
로만 선언할 수 있습니다. 즉, 인터페이스에서 선언된 모든 변수는 상수로 취급되며, 변경할 수 없습니다.
자바의 인터페이스는 인스턴스화(=객체화)할 수 없습니다. 그렇기에 객체가 생성될 때 만들어지는 인스턴스 변수도 가질 수 없습니다.
Animal animal = new Animal(); // 컴파일 에러
따라서, 인터페이스에서는 정적 컨텍스트에서 공유될 수 있는 상수만 정의할 수 있습니다.
int animalMaxAge = Animal.MAX_AGE;
인터페이스에서 정의한 변수는 인터페이스 이름과 함께 사용할 수 있습니다.
🙋♂️ 참고) private
static
final
은 사용 불가
Java8 이전에는 인터페이스가 구현 메서드를 가지지 않았습니다. Java8 이후, 인터페이스에 static과 default 메서드가 추가되며 구현 메서드를 제공하고 있습니다.
앞서 제시한 Animal
인터페이스를 구현한 Cow
, Lion
클래스를 만들었습니다. 인터페이스에 선언한 makeSound()
, move()
메서드를 각 구현체에서 구현한 모습입니다.
public class Cow implements Animal{
@Override
public void makeSound() {
System.out.println("음메에에에");
}
@Override
public void move() {
System.out.println("아주 느리게 한 칸..");
}
}
public class Lion implements Animal{
@Override
public void makeSound() {
System.out.println("어흥");
}
@Override
public void move() {
System.out.println("아주 빠르게 열 칸!!!!");
}
}
그런데 이런 클래스가 100개가 있다고 가정해보겠습니다. (양, 고양이, 돼지, 말, 얼룩말, ...)
개발 도중에 요구사항이 바뀌어 몇몇 동물들은 이제 소리를 내고, 움직이는 것 외에도 잠을 잘 수 있게 되었습니다!! Animal
인터페이스에 sleep()
추상 메서드를 추가했습니다.
public interface Animal {
// 기존 추상 메서드
void makeSound();
void move();
// 새롭게 추가된 추상 메서드
void sleep();
}
그럼 이제 100개의 동물 클래스에서 이런 에러를 만나게 됩니다. 새로 추가된 sleep()
메서드를 구현하라는 내용입니다.
그런데 대부분의 동물들은 동일한 패턴으로 잠에 듭니다. 따라서 동물별로 sleep()
메서드를 다르게 구현할 필요가 없습니다. 또 하나의 메서드를 추가했을 뿐인데, 100개의 클래스를 일일이 수정하는 것도 번거롭습니다.
이러한 상황을 해결하고자, Java8은 default 메서드를 소개합니다.
기존 인터페이스에 새로운 default 메서드를 추가하더라도, 구현체를 변경할 필요가 없습니다. 즉, 구현체에서 default 메서드를 별도로 구현하지 않더라도, 인터페이스에 정의된 default 메서드를 곧바로 사용할 수 있습니다.
💡 Java8은 인터페이스에 왜 default 메서드를 도입했을까?
인터페이스에 새로운 default 메서드를 추가하더라도, 기존의 구현체들에서 이 새로운 메서드를 구현하도록 강제하지 않아도 된다.
default 메서드는 인터페이스에서 바로 구현하며, 하위 클래스에서 별도로 구현하지 않고 바로 사용할 수 있습니다. 또한 default 메서드는 하위 클래스에서 선택적으로 재정의할 수 있습니다.
Animal 인터페이스에 sleep()
메서드를 추가했습니다. 이때 default
키워드를 붙여 default 메서드임을 명시해줍니다.
public interface Animal {
// 추상 메서드
void makeSound();
void move();
// default 메서드
default void sleep() {
System.out.println("Zzz... 동물이 잠을 잡니다.");
}
}
소(Cow
)와 사자(Lion
)는 sleep()
메서드에 대한 별다른 구현을 하지 않더라도 sleep()
을 호출할 수 있습니다.
Animal cow = new Cow();
Animal lion = new Lion();
cow.sleep();
lion.sleep();
이처럼 default 메서드는 하위 클래스에서 별도로 구현하지 않고도 사용할 수 있습니다. 기존의 구현체들을 변경하지 않고 인터페이스에 새로운 기능을 추가할 수 있다는 점이 큰 장점인데요!
위의 예시처럼 인터페이스에 정의한 default 메서드를 그대로 사용할 수도 있지만, 하위 클래스에서 재정의하여 사용할 수도 있습니다.
예를 들어, 거꾸로 매달려서 잠을 자는 박쥐를 생각해보겠습니다.
일반적인 동물들과는 다른 방식으로 잠을 자는 박쥐는 구현체에서 default 메서드(sleep()
)를 재정의하려고 합니다.
public class Bat implements Animal{
@Override
public void makeSound() {
System.out.println("대롱대롱");
}
@Override
public void move() {
System.out.println("하늘에서 날기");
}
// default 메서드 재정의 - 선택적
@Override
public void sleep() {
System.out.println("Zzz... 박쥐는 매달려서 잠을 잡니다.");
}
}
소, 사자, 박쥐가 잠을 자는 코드를 실행해보겠습니다.
Animal cow = new Cow();
Animal lion = new Lion();
Animal bat = new Bat();
cow.sleep();
lion.sleep();
bat.sleep();
Cow와 Lion은 별도로 default 메서드를 재정의하지 않았기 때문에, 인터페이스에 정의한 내용이 출력됩니다. Bat은 구현체에서 default 메서드를 재정의했기 때문에, 구현체에서 오버라이딩한 내용이 출력됩니다.
자바는 인터페이스의 다중 상속을 허용합니다. 소는 동물이면서, 일을 할 수 있습니다. Workable
인터페이스를 정의하고, 소가 Animal
과 Workable
을 모두 구현하도록 했습니다. 이때 일을 할 때도 잠시 잠을 자며 휴식할 수 있기 때문에 sleep()
default 메서드를 정의했습니다.
public interface Workable {
void work();
default void sleep() {
System.out.println("Zzz... 일하다가 잠시 잠에 듭니다.");
}
}
public class Cow implements Animal, Workable{
@Override
public void makeSound() {
System.out.println("음메에에에");
}
@Override
public void move() {
System.out.println("아주 느리게 한 칸..");
}
@Override
public void work() {
System.out.println("소는 우유를 만듭니다.");
}
}
위 코드에서는 아래와 같은 에러 메시지를 발견할 수 있습니다.
Cow
는 Animal
과 Workable
인터페이스의 sleep()
메서드를 둘 다 상속 받기 때문에, 충돌이 발생한 것입니다. 따라서 하위 클래스에서 default 메서드(sleep()
)를 재정의하여 어느 것을 호출해야 할지 명시적으로 정해줘야 합니다.
@Override
public void sleep() {
Animal.super.sleep();
/* 별개로 구현해도 됨 */
}
Java8부터 인터페이스에서 default 메서드와 함께 static 메서드도 정의할 수 있게 되었습니다. static 메서드는 특정 인스턴스에 종속되지 않기에, 메서드 이름 앞에 인터페이스 이름을 붙여 호출합니다.
public interface Animal {
/* 생략 */
// static 메서드
static String getCode() {
return "ANIMAL";
}
}
System.out.println(Animal.getCode()); // ANIMAL 출력
이와 더불어, static 메서드는 다른 static 메서드 또는 default 메서드에서도 호출될 수 있습니다. 또한, public
및 private
접근 제어자로 선언할 수 있습니다.
public interface Animal {
/* 생략 */
default void eat(String food) {
// default 메서드 내에서 static 메서드 호출
if (isEdible(food)) {
System.out.println("동물이 " + food + "을(를) 먹습니다.");
} else {
System.out.println("이 음식은 먹을 수 없습니다!");
}
}
private static boolean isEdible(String food) {
// 음식이 먹을 수 있는지 검증하는 로직
return !food.equals("돌") && !food.equals("흙");
}
}
Java9 이후 인터페이스에 private 구현 메서드를 정의할 수 있습니다. Java8에서 추가된 default 메서드와 static 메서드의 코드를 캡슐화하기 위해 등장했습니다.
⭐️ 인터페이스 요약 ⭐️
(1) 인터페이스를 인스턴스화할 수 없다. (구현체가 필요)
(2) 인터페이스에서 정의하는 변수는 모두 public, static, final한 상수이다.
(3) 추상/구현 메서드에서 final 키워드를 사용할 수 없다.
(4) 추상/구현 메서드의 접근 제어자가 protected가 될 수 없다.
(5) 추상 메서드의 접근 제어자는 오직 public이다.
(6) Java8부터 default, static 구현 메서드가 제공된다. default 메서드의 접근 제어자는 public이며, static 메서드는 public/private 둘 다 사용 가능하다.
(7) Java9부터 private 구현 메서드가 제공된다.
Java8부터 인터페이스도 구현 메서드를 제공하게 되면서 인터페이스와 추상 클래스의 경계가 조금 흐릿해졌습니다.
그럼에도 불구하고 여전히 유의미한 차이가 존재하며, 그 중 객체 지향적 관점에서 추상 클래스가 필요한 이유를 설명해보려고 합니다.
인터페이스에 추상 메서드를 선언 시 오직 public 접근 제어자만 사용할 수 있습니다.
반면, 추상 클래스의 경우 public과 더불어 protected 추상 메서드를 가질 수 있습니다.
이것이 왜 유효한 차이인지 헐리우드 원칙과 템플릿 메서드 패턴을 소개하며 설명을 이어가보도록 하겠습니다.
📞 "Don't Call Us, We'll Call You"
헐리우드 원칙의 핵심은 "자꾸 연락하지마 내가 연락할게"입니다. 여기서 연락을 하는 주체는 고수준 컴포넌트이며, 연락을 받는 대상은 저수준 컴포넌트입니다.
☝️ Low-Level Components Are Passive
저수준 컴포넌트는 고수준 컴포넌트를 호출하지 않습니다. 저수준 컴포넌트는 수동적인 존재로, 고수준 컴포넌트의 호출이 있을 때까지 기다립니다.
✌️ High-Level Components Control the Flow
고수준 컴포넌트는 애플리케이션 실행 흐름에 대한 제어권을 갖고 있습니다. 저수준 컴포넌트가 언제, 어떻게 행동해야 할지를 결정합니다. 즉 고수준 컴포넌트가 저수준 컴포넌트르 호출하여, 특정 변화 또는 이벤트가 있을 때 저수준 컴포넌트가 적절하게 반응할 수 있도록 시스템의 흐름을 제어합니다.
헐리우드 원칙은 관찰자 패턴(Observer Pattern), 템플릿 메서드 패턴(Template Method Pattern), 이벤트 기반 아키텍처(Event Driven Architecture) 등에서 사용됩니다.
아래와 같은 요구사항을 떠올리며 Animal 추상 클래스를 정의해보겠습니다.
(1) 동물들은 밥을 먹습니다.
(2) 밥을 먹으려면 사냥을 해야 합니다.
(3) 사냥 방법은 동물마다 다릅니다.
(4) 그 외의 밥 먹는 순서는 동물마다 동일합니다.
Animal 추상 클래스 안에 eat()
메서드를 선언하고, 구현부를 정의했습니다. 이때 eat()
메서드 내부에서 추상 메서드인 hunt()
를 호출하고 있습니다.
public abstract class Animal {
public final void eat() {
System.out.println("사냥을 해야 밥을 먹을 수 있어요.");
hunt();
System.out.println("잡아온 밥을 먹어요.");
}
protected abstract void hunt();
}
이제 Animal 클래스를 상속 받은 고양이(Cat
)와 사자(Lion
) 클래스를 만들어보겠습니다. 각 클래스에서 hunt()
메서드를 구현합니다.
public class Cat extends Animal {
@Override
protected void hunt() {
System.out.println("냐옹~ 쥐를 잡았어요.");
}
}
public class Lion extends Animal {
@Override
protected void hunt() {
System.out.println("어흥! 토끼를 잡았어요.");
}
}
이제 두 가지 동물 객체를 만들고, eat()
메서드를 실행해보도록 하겠습니다.
Animal cat = new Cat();
Animal lion = new Lion();
cat.eat();
lion.eat();
고양이와 사자가 각각 다른 방법으로 사냥을 해서 밥을 먹습니다. 주목할 점은, 외부에서 hunt()
를 따로 호출하지 않았지만, 자식 클래스에서 재정의한 hunt()
메서드도 함께 호출되었다는 점입니다.
Animal은 추상 클래스, Cat과 Lion은 Animal을 상속받은 구체 클래스입니다. 이때 추상 클래스인 Animal은 상위(고수준) 컴포넌트라 불리며, 구체 클래스인 Cat과 Lion은 하위(저수준) 컴포넌트라 불립니다.
추상 클래스 → 상위(고수준) 컴포넌트
구체 클래스 → 하위(저수준) 컴포넌트
eat()
메서드가 동작할 때, 각 구현체에서 정의한 hunt()
메서드가 호출됩니다.
구체 클래스에서 구현한 hunt()
메서드는 추상 클래스의 eat()
메서드가 호출할 때까지 수동적으로 기다립니다.
즉, 추상 클래스(고수준)가 구체 클래스(저수준)를 호출하여 프로그램의 흐름을 제어하고, 반대로 구체 클래스는 추상 클래스에 있는 메서드를 호출하지 않습니다. 또한 구체 클래스의 hunt()
메서드는 본인이 언제 호출되는지를 모르며, 수동적인 태도로 호출되기를 기다립니다.
템플릿 메서드(Template Method) 패턴은 헐리우드 원칙을 준수하는 디자인 패턴입니다.
여러 클래스에서 공통으로 사용하는 메서드를 템플릿화하여 상위 클래스에서 정의하고, 하위 클래스마다 세부 동작 사항을 다르게 구현합니다.
즉, 변하지 않는 기능(템플릿)은 상위 클래스에 만들어두고 자주 변경되며 확장할 기능은 하위 클래스에서 만들도록 하여, 세부 실행 내용을 다양화할 수 있습니다.
💡 Tip
디자인 패턴에서의 템플릿은 변하지 않는 것을 의미한다.
public abstract class Animal {
public final void eat() {
System.out.println("사냥을 해야 밥을 먹을 수 있어요."); // 1
hunt(); // 2
System.out.println("잡아온 밥을 먹어요."); // 3
}
protected abstract void hunt();
}
Animal 추상 클래스의 eat() 메서드에서 1과 3의 내용은 모든 구현체에서 변하지 않는 내용입니다. 반면 2의 경우 하위 클래스에서 오버라이딩하여 구현체마다 변경될 수 있는 부분입니다.
이렇게 상위 클래스에서는 뼈대를 만들어두고, 템플릿 메서드에 포함된 메서드들의 구체적인 구현은 하위 클래스가 담당하게 하는 것을 템플릿 메서드 패턴이라고 합니다.
public abstract class Animal {
public final void eat() {
System.out.println("사냥을 해야 밥을 먹을 수 있어요.");
hunt();
System.out.println("잡아온 밥을 먹어요.");
}
protected abstract void hunt();
}
Animal 추상 클래스를 다시 살펴보면 두 가지 특징이 있습니다.
1️⃣ 템플릿 메서드(
eat()
)의 final 키워드
템플릿 메서드 eat()
은 final
로 선언되어 있어 자식 클래스에서 오버라이드할 수 없습니다.
→ 🌟 추상 클래스 속 템플릿 메서드는 final
로 선언되어 오버라이드되지 않아야 합니다.
2️⃣ 추상 메서드(
hunt()
)의 protected 접근 제어자
추상 메서드 hunt()
의 접근제어자를 protected
로 선언함으로써 캡슐화를 보장하고, 템플릿 메서드 패턴의 의도를 지킬 수 있습니다.
아래 세 가지의 목적을 달성하기 위해 protected 추상 메서드가 필요합니다 ❗️
💊 캡슐화
hunt()
는 eat()
의 내부적인 동작이므로 외부에서 hunt()
를 직접 호출할 일이 없습니다. 만약 public으로 선언 시 불필요하게 구현 세부사항이 노출됩니다.
🎥 헐리우드 원칙 준수
고수준인 추상 클래스 속 eat()
메서드가 저수준인 구체 클래스 속 hunt()
가 언제 호출될지를 결정합니다.
만약 public으로 선언 시 바깥에서 hunt()
를 직접 호출할 수 있게 됩니다. 그럼 "먼저 연락하지 마세요. 저희가 연락 드리겠습니다"는 의미를 갖는 헐리우드 원칙을 깨뜨리게 됩니다.
✍️ 명시적으로 템플릿 메서드 패턴의 의도를 드러냄
hunt()
추상 메서드를 protected
접근 제어자로 선언함으로써, hunt()
는 외부에서 호출 가능한 public API가 아니라, 하위 클래스에서 구현만 하면 된다는 점을 명시합니다.
Animal 추상 클래스의 이 두 가지 특징은 인터페이스에서는 볼 수 없는 특징입니다!
인터페이스는 메서드 앞에 final
키워드를 붙일 수 없으며, protected
접근 제어자도 사용할 수 없습니다.
Java8 이후 인터페이스도 구현 메서드를 제공할 수 있게 되면서, 위와 비슷하게 코드를 작성할 수 있게 되었습니다. default 메서드 속에서 추상 메서드를 호출하고, 추상 메서드는 각 구현체에서 정의합니다.
public interface Animal {
default void eat() {
System.out.println("사냥을 해야 밥을 먹을 수 있어요.");
hunt();
System.out.println("잡아온 밥을 먹어요.");
}
abstract void hunt();
}
그러나, default
메서드의 경우 하위 클래스에서 재정의가 가능합니다. (🚨→ 캡슐화 위반) 또한, hunt()
추상 메서드의 접근 제어자는 public
만 사용할 수 있기 때문에 외부에서의 호출을 막을 수 없습니다. (🚨→ 헐리우드 원칙 위반)
이러한 이유로, 보통 템플릿 메서드 패턴은 추상 클래스를 이용해 구현합니다. 인터페이스로 구현할 수 있는 대표적인 디자인 패턴으로는 전략 패턴이 있습니다.
헐리우드 원칙의 핵심은 "자꾸 연락하지마 내가 연락할게"입니다. 이는 프레임워크에서 자주 사용되는 개념입니다.
스프링 프레임워크와 개발자가 작성하는 애플리케이션을 생각해보면, 개발자는 프레임워크의 동작에 대해 아무것도 알지 못하고 참견하지도 못합니다. 코드를 작성하기만 하면, 스프링 프레임워크는 개발자가 작성한 코드를 호출합니다.
웹에서 /velog
로 HTTP 요청이 들어오는 경우를 생각해보겠습니다. 개발자는 컨트롤러에 @GetMapping(/velog)
어노테이션이 붙은 메서드를 작성합니다. 다만, 개발자는 이 메서드가 언제 어디서 호출되는지 알지 않습니다. 그 결정은 고수준 컴포넌트인 스프링이 알아서 해주고 있습니다.
모든 웹 요청의 진입점 역할을 하는 DispatcherServlet
클래스를 살펴보겠습니다. DispatcherServlet
은 FrameworkServlet
추상 클래스를 상속 받고 있습니다. processRequest()
라는 메서드는 HTTP 요청을 처리하는 역할을 수행하며, 템플릿 메서드 패턴으로 구현되어 있습니다.
public abstract class FrameworkServlet extends HttpServletBean implements ApplicationContextAware {
protected final void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
try {
this.doService(request, response);
} /* 생략 */
}
protected abstract void doService(HttpServletRequest request, HttpServletResponse response) throws Exception;
/* 생략 */
}
FrameworkServlet
의 processRequest()
라는 메서드는 템플릿 메서드입니다. 변하지 않는다는 의미를 final 키워드를 통해 확인할 수 있습니다. processRequest()
는 웹에서 요청이 들어왔을 때 처리해야 하는 작업들의 뼈대를 정의하고 있으며, 추상 메서드인 doService()
를 내부에서 호출하여 세부적인 내용은 구현체가 만들도록 합니다.
doService()
는 하위 클래스인 DispatcherServlet
에서 아래와 같이 정의하고 있습니다.
public class DispatcherServlet extends FrameworkServlet {
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
/* 생략 */
}
}
🌟 요약 🌟
(1) 추상 클래스는 public이 아닌 추상 메서드를 가질 수 있다. (인터페이스는 onlypublic
)
(2) 헐리우드 원칙을 준수한 설계를 하기 위해 protected 추상 메서드가 필요하다.
(3) 헐리우드 원칙: "자꾸 연락하지마 내가 연락할게"
(4) 템플릿 메서드 패턴은 헐리우드 원칙을 준수하는 대표적인 디자인 패턴이며, 프레임워크에서 주로 사용된다.
(5) 인터페이스는protected
접근 제어자 및final
키워드를 사용할 수 없다. 템플릿 메서드 패턴을 구현 시 이 두 가지가 필요하다.
종류 | 추상클래스 | 인터페이스 |
---|---|---|
사용 키워드 | abstract | interface |
사용 가능 변수 | 제한 없음 | public static final (상수) |
추상 메서드 접근 제어자 | 제한 없음 (public, private, protected, default) | public |
구현 메서드 접근 제어자 | 제한 없음 (public, private, protected, default) | public, private (default 메서드는 public으로만 선언 가능) |
사용 가능 메소드 | 제한 없음 | abstract method, default method, static method, private method |
상속 키워드 | extends | implements |
다중 상속 가능 여부 | 불가능 | 가능 (클래스에 다중 구현, 인터페이스끼리 다중 상속) |
기억해볼 만한 내용은, 인터페이스에는 오직 상수만 정의할 수 있지만 추상 클래스에는 모든 종류의 변수를 선언할 수 있다는 것입니다.
또한, 다중 상속이 필요한 경우 인터페이스를 고려해야 합니다.
인터페이스와 추상 클래스를 정리하며, 그 둘의 차이점을 알아봤습니다. 인터페이스와 추상 클래스는 객체 지향 프로그래밍에서 추상화를 달성하기 위해 Java에서 제공하는 주요 메커니즘입니다. 이 두 가지를 적절히 활용하면 코드의 유연성과 재사용성을 높일 수 있습니다. 템플릿 메서드 패턴에서 protected 추상 메서드가 필요하다는 점을 통해 인터페이스와 추상 클래스의 차이점을 설명해보았습니다. 긴 글 읽어주셔서 감사합니다!
Why are Interface Variables Static and Final - StackOverflow
Static Final Variables - Baeldung
Jvm Static Storage - Baeldung
Static and Default Methods in Interfaces - Baeldung
Hollywood principal - Medium
Template Method - Refactoring.Guru
Abstract Methods and Classes - Oracle
Template Method Pattern - dzone
좋은글 잘 봤습니다! 추상클래스랑 인터페이스를 잘 구분해서 써야겠어요