Java 인터페이스 vs 추상클래스, 왜 헷갈릴까요?
Java를 배우다 보면 ‘인터페이스’와 ‘추상클래스’라는 두 가지 개념을 만나게 됩니다. 둘 다 ‘완전하지 않은’ 클래스라는 점에서 비슷해 보이기 때문에, 초보자 입장에서는 무엇이 어떻게 다른지, 언제 무엇을 써야 하는지 헷갈리기 쉽습니다. 마치 비슷한 이름의 두 친구 때문에 누가 누구인지 혼동하는 것처럼 말이죠.
하지만 이 둘의 차이를 명확히 이해하는 것은 Java 객체 지향 프로그래밍(OOP)의 핵심을 파악하는 데 매우 중요합니다. 이 차이를 알면 코드를 더 유연하고 확장 가능하게 만들 수 있으며, 설계 실수를 줄이는 데 큰 도움이 됩니다.
이 글에서는 Java 인터페이스와 추상클래스의 차이점을 초보자도 쉽게 이해할 수 있도록 하나하나 짚어드릴 겁니다. 단순한 정의 나열이 아니라, 실제 코드 예시와 함께 언제 어떤 것을 선택해야 하는지 명확한 가이드라인을 제시해 드릴 테니, 끝까지 집중해 주세요!
왜 둘 다 ‘완전하지 않은’ 클래스일까요?
먼저 왜 이 둘이 ‘완전하지 않은’ 클래스라고 불리는지 간단히 짚고 넘어가겠습니다.
-
추상클래스 (Abstract Class):
abstract키워드를 붙여 선언하며, 추상 메소드(abstract method)와 일반 메소드(concrete method)를 모두 가질 수 있습니다. 추상 메소드는 구현부가 없고, 일반 메소드는 일반 클래스의 메소드처럼 구현부를 가집니다. -
인터페이스 (Interface):
interface키워드로 선언하며, 오직 추상 메소드만을 가질 수 있었습니다. (Java 8부터는default메소드와static메소드도 가능해졌지만, 근본적인 역할은 추상 메소드 선언에 있습니다.)
쉽게 말해, 추상클래스는 “이런 기능을 가진 클래스들을 만들 건데, 일부 기능은 자식 클래스에서 직접 구현해야 해. 하지만 공통적인 기본 기능은 내가 제공해 줄게!”라고 말하는 것과 같습니다. 반면 인터페이스는 “이런 기능들을 반드시 구현해야 하는 클래스들을 만들 거야. 구체적인 구현 방법은 너희에게 맡길게!”라고 강제하는 것에 가깝습니다.
이 둘의 차이점을 더 깊이 파고들어 봅시다.
Java 인터페이스와 추상클래스의 핵심 차이점
가장 명확한 차이점들을 중심으로 비교해 보겠습니다.
1. 다중 상속 가능 여부
이것이 가장 중요하고 근본적인 차이점입니다.
- 추상클래스: Java는 단일 상속만 지원합니다. 즉, 클래스는 하나의 부모 클래스만 상속받을 수 있습니다. 따라서 추상클래스 역시 하나의 클래스만 상속할 수 있습니다.
// 추상클래스 상속 (단일 상속)
class MyConcreteClass extends MyAbstractClass {
// ... 구현 ...
}
- 인터페이스: Java는 다중 구현을 지원합니다. 클래스는 여러 개의 인터페이스를 구현할 수 있습니다.
// 인터페이스 다중 구현
class MyConcreteClass implements MyInterface1, MyInterface2 {
// ... 구현 ...
}
왜 이런 차이가 중요할까요?
만약 어떤 클래스가 여러 종류의 ‘행위’나 ‘역할’을 동시에 수행해야 한다면, 여러 인터페이스를 구현하는 것이 훨씬 유연합니다. 예를 들어, Bird 클래스가 Flyable 인터페이스와 Singable 인터페이스를 모두 구현할 수 있다면, 이 새는 날 수도 있고 노래할 수도 있다는 것을 명확히 표현할 수 있습니다.
하지만 추상클래스로 이 역할을 하려고 하면 문제가 생깁니다. 만약 FlyingThing이라는 추상클래스와 SingingThing이라는 추상클래스가 있다면, Bird 클래스는 둘 중 하나만 상속받을 수밖에 없습니다. 나머지 하나는 상속받지 못하게 되는 것이죠.
2. 멤버 변수 (필드)
- 추상클래스: 일반 클래스처럼 다양한 종류의 멤버 변수를 가질 수 있습니다.
public,protected,private접근 제어자를 사용할 수 있고,static변수나final변수도 선언 가능합니다.
abstract class Animal {
String name; // 일반 인스턴스 변수
static int count = 0; // static 변수
final int MAX_SPEED = 100; // final 상수
}
- 인터페이스: 오직
public static final상수만을 가질 수 있습니다.public static final은 생략해도 컴파일러가 자동으로 붙여줍니다. 즉, 인터페이스에 선언된 모든 변수는 상수이며, 모든 객체가 공유합니다.
interface Constants {
public static final int MAX_VALUE = 100; // public static final 생략 가능
// int MIN_VALUE = 0; // public static final int MIN_VALUE = 0; 와 동일
}
이것이 의미하는 바는?
추상클래스는 상태(state)를 가질 수 있습니다. 즉, 객체의 속성이나 데이터를 가질 수 있다는 뜻입니다. 반면 인터페이스는 상태를 가지기보다는 ‘규격’이나 ‘약속’을 정의하는 데 더 적합합니다.
3. 메소드 구현
- 추상클래스: 추상 메소드(구현부가 없는 메소드)와 일반 메소드(구현부가 있는 메소드)를 모두 가질 수 있습니다.
abstract class Shape {
abstract void draw(); // 추상 메소드
void printInfo() { // 일반 메소드
System.out.println("This is a shape.");
}
}
-
인터페이스:
-
Java 8 이전: 오직 추상 메소드만 가질 수 있었습니다. (모두
public abstract로 간주됩니다.) -
Java 8 이후:
default메소드와static메소드를 추가할 수 있게 되었습니다. -
default메소드: 구현부를 가집니다. 인터페이스를 구현하는 클래스에서 상속받아 사용하거나 오버라이드할 수 있습니다. -
static메소드: 구현부를 가집니다. 인터페이스 이름으로 직접 호출할 수 있으며, 구현 클래스와는 관련이 없습니다.
interface MyInterface {
void abstractMethod(); // public abstract void abstractMethod();
default void defaultMethod() { // 구현부 가짐
System.out.println("This is a default method.");
}
static void staticMethod() { // 구현부 가짐
System.out.println("This is a static method.");
}
}
이 차이는 왜 중요할까요?
추상클래스는 공통적인 기능의 일부 구현을 제공할 수 있습니다. 예를 들어, 모든 동물이 가져야 할 기본적인 움직임 패턴은 추상클래스에 미리 구현해 놓고, 구체적인 움직임(걷기, 헤엄치기 등)은 자식 클래스에서 구현하도록 할 수 있습니다.
인터페이스의 default 메소드는 이미 존재하는 인터페이스에 새로운 메소드를 추가할 때, 기존 구현 클래스들에 영향을 주지 않으면서 기능을 확장할 수 있게 해줍니다. static 메소드는 유틸리티 성격의 메소드를 인터페이스 내부에 배치할 때 유용합니다.
4. 생성자
- 추상클래스: 생성자를 가질 수 있습니다. 하지만 추상클래스 자체로는 객체를 생성할 수 없기 때문에, 이 생성자는 오직 자식 클래스에서
super()를 통해 호출되어 부모 클래스의 초기화를 담당하는 용도로 사용됩니다.
abstract class Vehicle {
String model;
Vehicle(String model) { // 생성자
this.model = model;
}
}
class Car extends Vehicle {
Car(String model) {
super(model); // 부모 클래스의 생성자 호출
}
}
- 인터페이스: 생성자를 가질 수 없습니다. 인터페이스는 객체를 생성하는 대상이 아니라, 객체가 ‘무엇을 할 수 있는지’에 대한 규격을 정의하기 때문입니다.
5. 상속 vs 구현
-
추상클래스:
extends키워드를 사용하여 상속받습니다. “is-a” 관계를 나타냅니다. (예:Dogis aAnimal) -
인터페이스:
implements키워드를 사용하여 구현합니다. “can-do” 또는 “has-a” (능력) 관계를 나타냅니다. (예:CarcanMoveable,MP3Playerhas aPlayablefunctionality)
언제 무엇을 사용해야 할까요? (실용 가이드)
이제 가장 중요한 질문입니다. “그래서 이걸 언제 써야 하는데요?”
추상클래스를 사용해야 할 때
-
클래스 간의 ‘is-a’ 관계가 명확할 때:
-
어떤 클래스가 다른 클래스의 하위 타입이고, 공통된 속성이나 메서드 구현을 공유해야 할 때 사용합니다.
-
예시:
Animal이라는 추상클래스를 만들고,Dog,Cat클래스가 이를 상속받도록 합니다.Animal클래스에name필드나eat()메소드 같은 공통적인 부분을 미리 구현해둘 수 있습니다. -
공통된 구현을 제공하고 싶을 때:
-
자식 클래스들이 공통적으로 사용할 수 있는 기본적인 기능이나 상태를 제공해야 할 때 유용합니다.
-
예시:
AbstractDao클래스를 만들어 데이터베이스 연결이나 기본 CRUD(Create, Read, Update, Delete) 메소드의 일부를 구현하고,UserDao,ProductDao같은 구체적인 DAO 클래스가 이를 상속받아 각 엔티티에 맞게 구현하도록 합니다. -
상태(State)를 공유해야 할 때:
-
인스턴스 변수(필드)를 통해 객체의 상태를 관리해야 하는 경우 추상클래스를 사용합니다.
-
예시:
AbstractGameCharacter클래스에health나mana같은 공통 속성을 두고,Warrior,Mage클래스가 이를 상속받아 사용합니다. -
final이 아닌 메소드를 추가하고 싶을 때: -
자식 클래스에서 오버라이드할 수 있는 메소드를 제공하고 싶을 때 추상클래스가 적합합니다.
인터페이스를 사용해야 할 때
-
클래스 간의 ‘can-do’ 또는 ‘has-a’ (능력) 관계를 정의할 때:
-
어떤 클래스가 특정 행위나 역할을 수행할 수 있음을 명시하고 싶을 때 사용합니다.
-
예시:
Runnable인터페이스 (실행 가능한),Serializable인터페이스 (직렬화 가능한),Comparable인터페이스 (비교 가능한) 등이 있습니다.Thread클래스는Runnable을 구현하여 실행될 수 있습니다. -
다중 상속의 필요성을 충족해야 할 때:
-
하나의 클래스가 여러 가지 다른 ‘규격’을 만족해야 할 때 인터페이스를 사용합니다.
-
예시:
MyGUIApp클래스가ActionListener(이벤트 처리)와WindowListener(창 관리) 인터페이스를 모두 구현하여 GUI 이벤트와 창 이벤트를 모두 처리할 수 있습니다. -
느슨한 결합(Loose Coupling)을 구현해야 할 때:
-
구현체에 대한 의존성을 줄이고, 특정 인터페이스에만 의존하도록 코드를 작성할 때 인터페이스가 핵심적인 역할을 합니다.
-
예시:
PaymentProcessor인터페이스를 정의하고,CreditCardProcessor,BankAccountProcessor등의 구현체를 만듭니다. 실제 결제 로직에서는PaymentProcessor타입으로 변수를 선언하고, 필요에 따라 실제 구현체를 주입받아 사용하면, 새로운 결제 방식을 추가해도 기존 코드를 수정할 필요가 없습니다. -
API의 ‘계약(Contract)’을 정의할 때:
-
라이브러리나 프레임워크를 개발할 때, 외부 개발자들이 따라야 할 표준 규격을 인터페이스로 정의하는 것이 일반적입니다.
-
Java 8 이상의
default메소드를 활용하여 기능 확장 시: -
기존 인터페이스에 새로운 메소드를 추가해야 할 때,
default메소드를 사용하면 이미 해당 인터페이스를 구현하고 있는 클래스들에 영향을 주지 않고 기능을 확장할 수 있습니다.
예시를 통한 이해: Shape와 Drawable
어떤 도형을 그리는 프로그램을 만든다고 가정해 봅시다.
추상클래스 사용 시나리오:
// 추상클래스: Shape
abstract class Shape {
String color; // 공통 속성
Shape(String color) { // 공통 생성자
this.color = color;
}
abstract void draw(); // 도형마다 다르게 그려야 하는 메소드 (구현 강제)
void printColor() { // 공통 메소드
System.out.println("Color: " + color);
}
}
// 구체 클래스: Circle
class Circle extends Shape {
double radius;
Circle(String color, double radius) {
super(color); // 부모 생성자 호출
this.radius = radius;
}
@Override
void draw() {
System.out.println("Drawing a circle with radius " + radius);
}
}
// 구체 클래스: Rectangle
class Rectangle extends Shape {
double width, height;
Rectangle(String color, double width, double height) {
super(color);
this.width = width;
this.height = height;
}
@Override
void draw() {
System.out.println("Drawing a rectangle with width " + width + " and height " + height);
}
}
public class ShapeDemo {
public static void main(String[] args) {
Shape[] shapes = {
new Circle("Red", 5.0),
new Rectangle("Blue", 10.0, 20.0)
};
for (Shape s : shapes) {
s.printColor(); // 공통 메소드 호출
s.draw(); // 추상 메소드 호출 (각자 구현된 대로 실행)
}
}
}
이 시나리오에서 Shape는 도형들의 공통적인 특성(color)과 공통적인 동작(printColor)을 제공합니다. 하지만 ‘그려지는 방식'(draw)은 도형마다 다르므로 추상 메소드로 남겨둡니다. Circle과 Rectangle은 Shape의 하위 타입으로서 ‘is-a’ 관계를 가집니다.
인터페이스 사용 시나리오:
// 인터페이스: Drawable
interface Drawable {
void draw(); // 반드시 구현해야 하는 메소드 (계약)
}
// 인터페이스: Colored
interface Colored {
void setColor(String color);
String getColor();
}
// 구체 클래스: Circle (Drawable, Colored 구현)
class Circle implements Drawable, Colored {
private String color;
private double radius;
Circle(String color, double radius) {
this.color = color;
this.radius = radius;
}
@Override
public void draw() {
System.out.println("Drawing a circle with radius " + radius);
}
@Override
public void setColor(String color) {
this.color = color;
}
@Override
public String getColor() {
return color;
}
}
// 구체 클래스: Square (Drawable, Colored 구현)
class Square implements Drawable, Colored {
private String color;
private double side;
Square(String color, double side) {
this.color = color;
this.side = side;
}
@Override
public void draw() {
System.out.println("Drawing a square with side " + side);
}
@Override
public void setColor(String color) {
this.color = color;
}
@Override
public String getColor() {
return color;
}
}
public class DrawingApp {
public static void main(String[] args) {
Drawable[] drawables = {
new Circle("Red", 5.0),
new Square("Green", 10.0)
};
for (Drawable d : drawables) {
d.draw();
}
Colored[] coloredObjects = {
new Circle("Blue", 3.0),
new Square("Yellow", 7.0)
};
for (Colored c : coloredObjects) {
System.out.println("Object color: " + c.getColor());
c.setColor("Purple"); // 색상 변경
}
}
}
이 시나리오에서는 ‘그릴 수 있다(Drawable)’는 행위와 ‘색상을 가질 수 있다(Colored)’는 능력을 각각 인터페이스로 정의했습니다. Circle과 Square는 이 두 가지 능력을 모두 구현합니다. 이 경우, Drawable 타입의 배열에는 Circle과 Square뿐만 아니라 앞으로 ‘그릴 수 있는’ 모든 객체가 들어갈 수 있습니다. Colored 타입의 배열에도 마찬가지입니다. 이는 유연성과 확장성을 높여줍니다.
흔한 실수와 주의사항
-
무조건 인터페이스가 좋다는 생각: 인터페이스가 다중 구현을 지원하고 유연성이 높다고 해서 모든 상황에서 추상클래스보다 좋은 것은 아닙니다. ‘is-a’ 관계가 명확하고 공통 구현이 필요하다면 추상클래스가 더 적합할 수 있습니다.
-
Java 8 이전의 인터페이스 이해: Java 8 이전에는 인터페이스에
default나static메소드가 없었기 때문에, 현재의 인터페이스와는 역할에 약간의 차이가 있었습니다. 하지만 근본적인 ‘계약’ 정의의 역할은 동일합니다. -
과도한 추상화: 모든 것을 인터페이스로 만들려고 하면 오히려 코드가 복잡해지고 이해하기 어려워질 수 있습니다. 상황에 맞는 적절한 추상화 수준을 선택하는 것이 중요합니다.
-
abstract키워드의 오남용: 추상클래스나 추상 메소드는 반드시 필요한 경우에만 사용해야 합니다. 모든 것을 추상화하면 오히려 구현하는 개발자에게 부담을 줄 수 있습니다.
결론: 상황에 맞는 도구를 선택하세요!
Java의 인터페이스와 추상클래스는 객체 지향 프로그래밍에서 매우 강력한 도구입니다. 이 둘의 차이점을 명확히 이해하고, ‘is-a’ 관계인지, ‘can-do’ 관계인지, 공통 구현이 필요한지, 아니면 단순히 계약만 정의하면 되는지를 기준으로 적절한 것을 선택하는 것이 중요합니다.
-
추상클래스: ‘is-a’ 관계, 공통 속성/메소드 구현 제공, 상태 관리 필요 시.
-
인터페이스: ‘can-do’ 관계, 다중 구현 필요, 느슨한 결합, API 계약 정의 시.
이 글을 통해 Java 인터페이스와 추상클래스의 차이점을 명확히 이해하고, 앞으로 더 나은 설계와 코드를 작성하는 데 도움이 되기를 바랍니다.
INTERNAL_LINKS: (유사한 게시글 입력)
EXTERNAL_LINKS: Java Abstract Classes vs Interfaces – GeeksforGeeks, Abstract Classes vs Interfaces in Java – Baeldung, Java Interface Tutorial – Tutorialspoint
