인터페이스는 간단히 말하자면 프로그램을 설계하고 조금 더 유연한 프로그램을 만드는 기법을 말합니다.
인터페이스는 추상화와 상속과 더불어 다형성이라는 객체 지향의 특징을 구현하는 핵심입니다.
인터페이스는 위키 백과에 따르면, 사용자가 기기를 쉽게 동작시키는데 도움을 주는 상호작용 시스템을 말합니다.
이러한 정의를 자바 프로그래밍에 접목해 보면, 객체의 인스턴스 메서드를 이용하는 사용자입장에서 "그 객체의 내부 구현이 어떻든 깊이 학습할 필요 없이 원하는 메서드만 호출하고 결괏값을 제대로 받게 해주는 간편한 상호작용 기능이다"라고 말할 수 있습니다.
즉, 개발자가 프레임워크를 이용하여 웹서비스를 개발하는데 있어 프레임워크의 내부 구성 학습 없이, 그저 지원해 주는 메서드를 이용하여 간편하게 프로젝트를 개발할 수 있게 해주는 것이 인터페이스의 첫 번째 역할입니다.
우리가 반복되는 코드들을 줄이기 위해 for, while 문을 사용하듯이, 인터페이스를 사용하는 목적은 설계상 이점을 위해 사용하는 클래스라고 말할 수 있습니다.
인터페이스 기본 문법
인터페이스는 필드를 선언할 수 있지만 변수가 아닌 상수( final ) 로서만 정의할 수 있습니다.
public static final, public abstract 제어자는 생략이 가능합니다. ( 편리한 기능!! )
생략된 제어자들은 컴파일 시에 컴파일러가 자동으로 추가해 줍니다.
// 기본 구조
public interface 인터페이스이름{
public static final 타입 상수이름 = 값;
public abstract 타입 메서드이름(매개변수목록);
}
// --------------------------------------------------------
// 실사용 구조
public interface TV {
int MAX_VOLUME = 10; // public static final 생략 가능
int MIN_VOLUME = 10;
void turnOn(); // public abstract 생략 가능
void turnOff();
void changeVolume(int volume);
void changeChannel(int channel);
}
인터페이스 구현
인터페이스도 추상 클래스처럼 그 자체로는 인스턴스를 생성할 수 없으며, 추상 클래스가 상속을 통해 완성되는 것처럼 인터페이스도 구현부를 만들어주는 클래스에 구현( 상속 ) 되어야 합니다.
인터페이스를 상속 받았으면, 자식 클래스에서 인터페이스가 포함하고 있는 추상 메서드를 구체적으로 구현해 주어야 합니다.
인터페이스의 가장 큰 특징은 여러개를 다중 구현(다중 상속) 이 가능하다는 것입니다.
기본적으로 메서드를 오버라이딩할때는 부모의 메서드 보다 넓은 범위의 접근제어자를 지정해야 한다는 규칙이 존재합니다. 따라서 인터페이스의 추상 메서드는 기본적으로 public abstract 가 생략된 상태이기 때문에 반드시 자식클래스의 메서드 구현부에서는 제어자를 public으로 설정을 해주어야 합니다.
@Override
안써도 되지만 사용하는 이유는 주로 코드의 가독성과 안전성을 높이기 위해 사용됩니다.
만약 메서드 이름을 잘못 입력했다거나, 시그니처가 인터페이스와 다르면 컴파일 에러가 생깁니다.
또한 코드의 의도를 더 잘 이해할 수 있습니다.
인터페이스 자체 상속
클래스끼리 상속을 통해 확장을 하듯이, 인터페이스 자체를 확장시키고 싶다면 extends를 통해 인터페이스를 상속하면 됩니다. 클래스와 달리 인터페이스끼리의 상속은 다중 상속이 가능합니다.
또한 클래스의 상속과 마찬가지로 자손 인터페이스는 조상 인터페이스에 정의된 멤버를 모두 상속받지만 필드의 경우 기본적으로 static 이기 때문에 구현체를 따라가지 않게 됩니다.
interface Changeable {
/* 채널을 바꾸는 기능의 메서드 */
void change();
}
interface Powerable {
/* 전원을 껐다 켰다 하는 메서드 */
void power(boolean b);
}
// 채널 기능과 전원 기능을 가진 인터페이스들을 하나의 인터페이스로 통합 상속
interface Controlable extends Changeable, Powerable {
// 인터페이스끼리 다중 상속하면 그대로 추상 멤버들을 물려 받음
}
// 클래스에 통합된 인터페이스를 그대로 상속
class MyObject implements Controlable {
public void change() {
System.out.println("채널을 바꾸는 기능의 메서드");
}
public void power(boolean b) {
System.out.println("전원을 껐다 켰다 하는 메서드");
}
}
// Changeable을 구현하는 새로운 클래스 추가
class ChangeableImpl implements Changeable {
@Override
public void change() {
System.out.println("채널을 바꾸는 기능의 메서드");
}
}
public class Main {
public static void main(String[] args) {
// 인터페이스 다형성 (인터페이스를 타입으로 취급해서 업캐스팅 가능)
Controlable[] o = { new MyObject(), new MyObject() };
o[0].change();
o[0].power(true);
// Changeable 인터페이스를 구현한 클래스의 객체 생성
Changeable inter1 = new ChangeableImpl(); // Changeable 인터페이스를 구현한 클래스의 객체로 생성
inter1.change();
// Powerable 인터페이스를 구현한 클래스의 객체 생성
Powerable inter2 = new MyObject(); // MyObject는 Powerable을 구현하므로 사용 가능
inter2.power(true);
}
}
인터페이스를 자료형으로
객체는 클래스가 아닌 인터페이스로 참조하라 라는 말이 있습니다.
적합한 인터페이스만 있다면 매개변수뿐 아니라 반환값, 변수, 필드를 전부 인터페이스타입으로 선언하면 좋습니다.
// 나쁜 예) 클래스를 바로 타입으로 사용했다.
LinkedHashSet<Object> s = new LinkedHashSet<>();
// 좋은 예) 인터페이스를 타입으로 사용했다.
Set<Object> s = new LinkedHashSet<>();
// 나쁜 예)
ArrayList <Object> list = new ArrayList <>();
// 좋은 예)
List <Object> list = new ArrayList <>();
이런 식으로 코드를 구현해 놓는다면, 나중에 변수에 담긴 구현 클래스를 다른 Set 자료형 클래스로 교체하고자 할 때 그저 새 클래스의 생성자를 다시 호출해주기만 하면 되어 간편해집니다.
// 그냥 인터페이스 타입의 변수에 재할당만 하면 된다
Set<Object> s = new LinkedHashSet<>();
s = new TreeSet<>();
하지만 이렇게 인터페이스 타입으로 선언하는 습관은 꼭 좋은 것만 있는 것은 아닙니다.
나쁜 예의 경우 LinkedHashSet을 HashSet으로 변환하면, LinkedHashSet과 달리 HashSet 은 반복자의 순회 순서를 보장하지 않기 때문에 나중에 로직상 문제가 될 수 있습니다.
타입 접근 제한
만일 똑같은 부모를 상속하고 있는 3개의 자식들 중, 2개의 자식 클래스 타입만 이용할 수 있는 메서드를 구현한다고 했을 때 이용됩니다. 다형성이랍시고 부모 클래스 타입을 파라미터를 받아버리면, 모든 자식 클래스가 접근이 가능하기 때문에 제한이 되지 않습니다.
다음과 같이 Marine, SCV, Tank 클래스를 만들고 이들을 공통으로 묶을 부모 클래스 GroundUnit 클래스로 상속 관계를 맺어 주었습니다. 그리고 repair() 메서드에서 중복을 줄이기 위한 다형성 기법으로 매개변수 타입을 GroundUnit 부모 클래스 타입으로 받도록 설정하였습니다.
여기서 중요한 점은 interface 가 아닌 class를 상속 관계로 맺어 주었습니다.
class GroundUnit { }
class Marine extends GroundUnit{ }
class SCV extends GroundUnit{ }
class Tank extends GroundUnit{ }
public class Main {
public static void main(String[] args) {
repair(new Marine());
}
static void repair(GroundUnit gu) {
// 마린은 기계가 아니기 때문에 수리는 불가능 하다. 하지만 상속 관계상 마린 클래스 타입이 들어와 실행될 수 있는 위험성이 존재한다.
}
}
하지만 위의 코드의 문제점은 기본적으로 repair 기능은 기계 유닛만 가능하여 SCV와 Tank 클래스 타입만 들어와야 되는데 생물 유닛인 Marine 클래스 타입도 상속 관계에 의해 들어올 수 있다는 것입니다. 아무리 타이트하게 코딩을 개발자도 결국은 사람이고, 사람은 실수를 범할 수 있기 때문에 아예 접근하지 못하도록 원천 차단하는 것이 중요합니다.
따라서 별도의 Machine이라는 인터페이스를 선언하고 SCV, Tank 클래스에 implements 시킵니다.
그렇게 3개의 자식 중 2개의 자식만 머신이라는 타입으로 형제 타입 관계를 맺어주면서 동시에 다른 타입의 접근 제한 역할도 해낸 것입니다.
interface Machine { } // SCV, Tank 클래스를 통합한 타입으로 이용하는 인터페이스
class GroundUnit { }
class Marine extends GroundUnit{ }
class SCV extends GroundUnit implements Machine{ }
class Tank extends GroundUnit implements Machine{ }
public class Main {
public static void main(String[] args) {
repair(new Marine()); // ! ERROR
}
static void repair(Machine gu) {
// SVG와 탱크 타입만 받을 수 있게 인터페이스를 타입으로 하여 다형성을 적용
}
}
메서드 접근 제한
객체에서 사용할 수 있는 메서드를 제한하는 방법도 있습니다.
예를 들어 A, B, C라는 인터페이스를 구현한 클래스를 반환할 때 A 타입으로 반환하게 되면 외부에서는 A 인터페이스의 메서드만 보이게 됩니다. 따라서 별도의 제한을 이용하지 않고도 사용할 수 있는 메서드 접근 제한과 마찬가지로 효과를 보게 하는 방법입니다.
이러한 이유로 오히려 거꾸로 클래스에 여러 가지 메서드를 만들어 둔 다음 인터페이스로 분리하는 작업을 진행하는 경우가 가끔 있습니다.
interface PlayMovie {
void play();
}
interface ViewImage {
void view();
}
interface VolumeUpDown {
void volume();
}
class MP3 implements PlayMovie, ViewImage, VolumeUpDown {
public void play() {}
public void view() {}
public void volume() {}
}
public class Main {
public static void main(String[] args) {
PlayMovie mp3 = new MP3(); // 3개의 구현한 인터페이스중 하나로 객체 선언
mp3.play(); // play() 이외의 메소드는 제한된다.
}
}
의존성 제거
클래스 간의 관계를 구성할 때 그 관계를 느슨하게 하는 것이 중요합니다.
부모 클래스가 변경되면 자식 클래스도 영향이 가기 때문입니다.
클래스의 관계를 상속(extends) 이 아닌 구현(implements)으로 인터페이스로 확장시킨다면, 반환타입이나 매개변수 타입으로 다른 객체와 소통하는 구간에 인터페이스 타입으로 사용함으로써, 객체 간 의존성이 줄어들어 자신과 소통하는 객체의 변화에 강한 클래스를 만들 수 있게 됩니다.
이러한 의존성은 MVC, MVVM pattern 등으로 승화하였습니다.
만약 ServiceLogic 클래스의 메서드 printInt()를 보면 파라미터로 MapStore 클래스 타입을 받아 MapStore 클래스의 메서드를 실행해 값을 얻고 출력하는 로직으로 구성되어 있습니다.
이러한 형태를 ServiceLogic 클래스는 MapStore 클래스에 의존적이다!라고도 말합니다.
왜냐하면 MapStore 클래스가 잘못되면 ServiceLogic 클래스의 메서드는 동작하지 않을 것이기 때문입니다.
class ServiceLogic {
public void printInt(MapStore cls) { // 지정한 클래스 타입만 받음
int num = cls.getNum() * 2;
System.out.println(num);
}
}
class MapStore {
private int num = 10;
public int getNum() {
return this.num;
}
}
따라서 이러한 의존성 관계를 없애기 위해 ClubStore 인터페이스를 만들고 MapStore 클래스에 implements 하여 구현합니다. 그러면 ServiceLogic 클래스에서 만일 MapStore 객체 데이터를 사용할 일이 생길 경우, 직접 MapStore 객체를 사용하는 것이 아닌 오로지 ClubStore 인터페이스를 이용해 통신함으로써 클래스 간의 의존성을 없앨 수 있는 것입니다.
이것을 변경에 유리한 유연한 설계라고도 합니다.
interface ClubStore {
int getNum();
}
class ServiceLogic {
public void printInt(ClubStore cls) {
int num = cls.getNum() * 2;
System.out.println(num);
}
}
class MapStore implements ClubStore{
private int num = 10;
public int getNum() {
return this.num;
}
}
// 사용
public class Main {
public static void main(String[] args) {
// MapStore 객체 생성
ClubStore store = new MapStore(); // 인터페이스 타입으로 객체를 참조 (업캐스팅)
// ServiceLogic 객체 생성
ServiceLogic service = new ServiceLogic();
// ServiceLogic의 메서드 호출
service.printInt(store); // 출력: 20
}
}
개발 시간 단축
인터페이스를 사용하면 클래스의 선언과 구현을 분리시킬 수 있기 때문에 실제 구현에 독립적인 프로그램을 작성하는 것이 가능합니다.
예를 들어 기존의 클래스와 클래스 간의 직접적인 관계를, 인터페이스를 이용해서 간접적인 관계로 변경하면, 한 클래스의 변경이 관련된 다른 클래스에 영향을 미치지 않는 독립적인 프로그래밍이 가능합니다.
따라서 메서드를 호출하는 쪽에서는 선언 부만 알면 되기 때문에 인터페이스만 가지고도 프로그램을 작성할 수 있으며, 동시에 다른 한쪽에서는 인터페이스를 구현하는 클래스를 작성하면 인터페이스의 구현을 기다리지 않고 작업이 가능합니다.
즉, A 가 B 클래스의 구성 완성을 기다리지 않고, 설계도인 인터페이스를 보고 동시에 개발을 함으로써 결과적으로 개발 시간을 단축시킬 수 있는 것입니다.
'Java' 카테고리의 다른 글
업캐스팅 & 다운캐스팅 (1) | 2024.12.11 |
---|---|
자바 공부할 때 도움이 되는 용어 설명 (0) | 2024.12.10 |
List, ArrayList, LinkedList (0) | 2024.12.10 |
제어자 (0) | 2024.12.04 |
로깅(Logging) (0) | 2024.11.30 |