인터페이스의 사전적 의미
Inter는 ”~ 사이의”이라는 의미고, face는 “면”이라는 뜻이다. 즉, 인터페이스는 말 그대로 ‘두 공간 사이의 경계’다. 자바에서 이 개념은 클래스들 간에 어떤 계약을 정의하는 역할을 한다. 인터페이스는 두 가지 다른 공간을 이어주는 다리라고 보면 된다.
실제 동작은 구현 공간에서 일어난다. 이 공간은 구현자가 잘 아는 공간이지만, 사용자는 잘 알지 못하는 공간이다. 사용자가 익숙한 공간에서는 어떤 일이 일어나는지 알지만, 그 일이 구체적으로 어떻게 처리되는지는 모른다. 구현 공간에서는 반대로 그 ‘어떻게’가 중요해지는데, 이 공간은 구현자가 잘 아는 부분이다.
함수 호출도 인터페이스와 비슷하다. 호출자는 함수 내부 동작을 알 필요가 없다. 함수 이름과 반환형만 보면 무슨 일이 일어나는지 알 수 있고, 필요한 매개변수를 전달해 원하는 결과를 얻으면 끝이다.
함수 포인터와 클래스 매개변수
함수 포인터는 어떤 함수든 특정 시그내처만 맞으면 전달할 수 있는 구조다.
#include <stdio.h>
// 함수 시그내처 정의
void greet() {
printf("안녕하세요!\n");
}
void execute(void (*func)()) {
func(); // 함수 포인터를 통해 함수 호출
}
int main() {
// 함수 포인터를 사용하여 greet 함수를 호출
execute(greet); // 안녕하세요!
return 0;
}
반면 클래스 매개변수는 다소 복잡하다. 부모 클래스를 상속한 클래스들만 전달할 수 있고, 그 중 다형적 메서드 하나를 호출한다. 결국 함수 포인터처럼 동작하긴 하지만, 클래스 매개변수 방식은 배보다 배꼽이 더 큰 방식이라고 할 수 있다.
// 부모 클래스 정의
class Animal {
void sound() {
System.out.println("동물 소리");
}
}
// 자식 클래스 정의
class Dog extends Animal {
@Override
void sound() {
System.out.println("멍멍");
}
}
class Cat extends Animal {
@Override
void sound() {
System.out.println("야옹");
}
}
// 메서드 정의
public class Main {
public static void makeSound(Animal animal) {
animal.sound(); // 다형적 메서드 호출
}
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
makeSound(dog); // 멍멍
makeSound(cat); // 야옹
}
}
다시 정리하면, 함수 포인터 매개변수는 매개변수의 함수 시그내처와 같다면, 어떤 함수 구현도 받아준다. 반대로 클래스 매개변수는 부모 클래스를 상속한 클래스면 함수를 다 받아준다. 그 중에서 다형적 메서드 하나를 호출하는 방식이다. 실질적으로는 C의 함수 포인터처럼 작동하지만, 배보다 배꼽이 더 크다.
인터페이스
구조체는 데이터만 모아 놓은 것이고, 클래스는 데이터와 동작을 모아 놓은 것이다. 순수 추상 클래스는 동작만 모아 놓은 것이다.
여기서 순수(구현을 제외한) 추상클래스는 OO에서 인터페이스라고 부른다.
// 인터페이스 정의
interface Animal {
void sound(); // 메서드 시그내처만 정의
}
// 인터페이스를 구현하는 클래스
class Dog implements Animal {
@Override
public void sound() {
System.out.println("멍멍!");
}
}
인터페이스는 Java와 C# 등에서 interface란 키워드를 지원한다. (C++은 별도의 키워드가 없어 추상 클래스를 사용해야 함) 인터페이스는 클래스를 구성하는 특수한 형태로, 몇 가지 독특한 특징을 가지고 있다. 인터페이스는 어떤 상태도 갖지 않으며, 동작의 구현도 없다. 오직 동작의 시그내처만을 가지고 있어서 클래스와는 약간 다른 규칙을 따르는 것이 특징이다.
인터페이스 미구현과 컴파일 오류
// Animal 인터페이스의 인스턴스 생성 (오류 발생)
Animal animal = new Animal(); // 컴파일 오류
추상 메서드를 구현하지 않으면 컴파일 오류가 발생한다. 이는 인터페이스에서도 마찬가지로 적용된다. 만약 구현체를 추상 메서드로 만들 경우, 해당 메서드를 구현하지 않아도 되지만, 그 구현체의 자식 클래스에서 반드시 구현해야 한다. 이때 주의할 점은 구현체의 인스턴스를 직접 생성할 수 없다는 점이다.
// 인터페이스를 구현하는 또 다른 클래스
class Cat implements Animal {
@Override
public void sound() {
System.out.println("야옹!");
}
}
// 인터페이스를 구현하는 클래스
class Dog implements Animal {
@Override
public void suond() {
System.out.println("멍멍!");
}
}
// 메인 클래스
public class Main {
public static void main(String[] args) {
Animal dog = new Dog(); // 컴파일 오류
Animal cat = new Cat();
dog.sound(); // 컴파일 오류
cat.sound(); // 출력: 야옹!
}
}
실무에서 흔히 저지르는 실수 중 하나는 인터페이스를 사용하지 않을 때 발생한다. 예를 들어, 상속받은 메서드를 구현할 때 메서드 이름에 오타를 내는 경우가 많다. 또는 부모 클래스의 메서드 이름만 바꾸고 자식 클래스는 손을 대지 않는 경우도 있다. 이런 상황에서는 아무 문제 없이 컴파일이 되지만, 실제로는 문제가 발생할 수 있다.
자식 클래스에서 그 의도를 명백히 적어줄 수 있다면 오타를 잡는 것은 가능하다. 부모 클래스의 메서드를 오버라이딩하거나 인터페이스의 메서드 시그내처를 구현하는 경우, 컴파일러가 그 의도에 맞는 메서드 시그내처를 찾지 못하면 컴파일 오류가 발생한다. C#, C++ 등은 언어에서 지원하는 정식 키워드를 사용하며, Java에서는 어노테이션(annotation)을 통해 이 과정을 처리한다.
Java 어노테이션
// 자바의 내장 어노테이션 예시
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test {
}
class SampleTest {
@Test
public void testMethod() {
System.out.println("테스트 메서드가 실행되었습니다.");
}
}
어노테이션은 프로그램에 대한 메타데이터를 제공하는 역할을 한다. 이들은 프로그램의 일부가 아니기 때문에 코드 실행에는 아무 영향을 미치지 않는다. 어노테이션의 용도는 다양하다. 예를 들어, 컴파일러에게 정보를 제공하거나, 컴파일 또는 배포 중에 어노테이션을 기반으로 특정 처리를 수행할 수 있다. 실행 중에도 어노테이션을 기반으로 다양한 처리를 할 수 있다.
인터페이스는 왜 public 메서드만 가능할까?
인터페이스의 모든 메서드는 public으로 선언되어야 한다. 이는 누구라도 인터페이스를 보고 명령할 수 있는 동작을 해야 하기 때문이다. 물론 이 점에 대해 다른 의견도 존재하지만, 주류 객체지향 언어에서는 public으로 강제하므로 이에 대한 이의는 받아들여지지 않는다. 이를 C의 헤더 파일과 비교하면 이해가 좀 더 쉬워진다. C 헤더 파일에 들어 있는 것은 거의 모든 전역 함수이기 때문이다.
패키지 범위의 interface도 메서드는 public이다. 대신 인터페이스형 그 자체를 외부 패키지에서 사용을 못한다.
여담: 인터페이스 이름 구별
인터페이스는 클래스와 구별하기 위해 대문자 ‘I’를 붙이는 경우가 많다. 예를 들어, ILogable
이라는 이름을 가질 수 있다. 또한 인터페이스 이름 뒤에 ‘-able’이 붙기도 한다. 이는 ‘~할 수 있다’라는 의미의 접미사로, 인터페이스는 ‘무언가를 할 수 있다’는 표현이기 때문이다. Java 자체에서 제공하는 인터페이스들은 보통 ‘-able’만 사용하는 경향이 있다.
인터페이스는 다중상속이 가능하다.
인터페이스는 두 실체가 없기 때문에 메서드 시그내처가 중복되어도 문제가 없다. 하지만 상속받은 클래스가 한 메서드 구현만 제공하면 된다. 이때 주의할 점은, 반환형만 다른 경우에는 컴파일 오류가 발생한다.
public interface ISavable {
void save(String filename);
}
public interface ILoggable {
void log(String message);
}
public interface IFileStorage {
boolean save(String filename);
void load(String filename);
}
public final class ConsoleLogger implements ILoggable, ISavable, IFileStorage {
@Override
public void save(String filename) {
// 파일에 로그를 저장한다.
}
@Override
public boolean save(String filename) {
// 컴파일 오류 발생: 메서드 중복 정의
return true;
}
@Override
public void log(String message) {
// 로그 메시지를 처리한다.
}
@Override
public void load(String filename) {
// 파일을 로드한다.
}
}
두 함수를 다 구현할 방법이 없는 이유는 반환형만 다른 경우는 올바른 함수 오버로딩이 아니기 때문이다.
인터페이스는 다중 상속의 해결법
많은 객체지향 언어에서 다중 상속을 지원하지 않는 이유는 클래스 간의 복잡성을 줄이기 위해서이다. 하지만 인터페이스를 사용하면 다중 상속을 흉내낼 수 있으며, 이로 인해 더 유연한 프로그래밍이 가능해진다. 인터페이스는 클래스 설계에 있어 강력한 도구가 되며, 이를 잘 활용하면 코드의 재사용성과 가독성을 높일 수 있다.
다형성과 인터페이스
실무에서 인터페이스의 가장 핵심적인 용도는 다형성이다. 인터페이스는 다양한 객체들을 하나의 방식으로 사용할 수 있게 해주며, 이 덕분에 자바는 다중 상속을 지원하지 않으면서도 여러 클래스를 한 번에 다루는 유연성을 제공한다. 인터페이스가 없다면 함수 포인터처럼 각각의 함수를 직접 넘겨야 했겠지만, 인터페이스는 그 역할을 깔끔하게 처리한다.
public class AnimalSound {
public static void makeSound(Animal animal) {
animal.sound(); // 다양한 동물의 소리를 출력
}
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
makeSound(dog); // 멍멍
makeSound(cat); // 야옹
}
}
자바의 어노테이션도 여기에 중요한 역할을 한다. @Override
같은 어노테이션을 사용하면 메서드 시그내처가 정확한지 검증할 수 있어서, 오타나 실수를 방지할 수 있다.
클론과 객체 복제(Object.clone())
자바의 Object.clone()
메서드는 객체를 복제할 때 사용된다. 하지만 바로 사용할 수 있는 메서드는 아니다. Cloneable
인터페이스를 상속받아 구현해야만 clone()
메서드를 제대로 사용할 수 있다. 그냥 Object
의 clone()
을 오버라이딩하면 CloneNotSupportedException
이 발생할 수 있다.
public final class Robot implements Cloneable {
private int hp;
public Robot(int initialHp) {
this.hp = initialHp;
}
public int getHp() {
return this.hp;
}
public void damage(int amount) {
this.hp = Math.max(0, this.hp - amount);
}
@Override
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
클론 사용 시 주의할 점
clone()
메서드를 제대로 사용하려면 super.clone()
을 호출해야 한다. 이 메서드는 얕은 복사를 수행하므로, 복제된 객체는 원본 객체와 다른 주소를 가지지만 내부 값은 동일하게 복사된다. 예를 들어, Robot
클래스를 복제하면 hp 값은 그대로 복사되지만 두 객체의 주소는 다르다.
// super.clone() 호출
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
public class Main {
public static void main(String[] args) {
Robot robot = new Robot(300);
robot.damage(50);
Robot savePoint = (Robot) robot.clone();
robot.damage(50);
System.out.println(savePoint.getHp()); // 출력: 250
System.out.println(savePoint == robot); // 출력: false
}
}
출처
- 개체지향 프로그래밍 및 설계 - 유데미 강의