개요
"자바 인터페이스(Java Interface)는 무엇인가 ?"
이런 궁금증을 가지고 있는 Java Programmer가 많습니다. 물론 필자도 그렇습니다. 인터페이스가 어디에 어떻게 왜 쓰이는지 명확히 이해하는데에 어려움이 있었고 이를 정리하고자 글을 작성하게 되었습니다.
"객체 지향 개발 5대 원칙 - SOLID"을 만족시켜준다. 라는 부분 때문에 인터페이스를 사용한다고 생각합니다. 그렇다면 왜 SOLID를 만족시켜야 하는가? 라는 궁금증이 생길텐데요.
서비스 애플리케이션의 라이프사이클을 "설계 / 개발 / 유지보수" 세 단계로 나누게 되면, 유지보수가 소프트웨어 라이프사이클에서 가장 큰 부분을 차지한다고 합니다. 유지보수에서 인터페이스(Interface)는 SOLID를 구현하고, 객체 지향 개발을 하는데 큰 도움을 줍니다.
"설계 / 개발 / 유지보수" 측면에서 객체 지향을 설명한다면 ?
- 객체 지향을 사용하여, 대상을 추상화하고 추상화 된 대상의 행동을 묘사하면서 설계를 쉽게 할 수 있도록 합니다.
- 설계가 끝나고 개발자는 설계된 추상화 객체 단위로 쉽게 대상을 이해할 수 있습니다.
- 개발이 완료되고 유지보수를 하면 기존 코드와 로직을 그대로 두고, 새로운 기능을 유연하게 추가할 수 있습니다.
그럼 다시 "자바 인터페이스(Java Interface)는 무엇인가?" 에 초점을 두고 "객체 지향"에 어떤 도움을 주는지 알아보도록 하겠습니다.
인터페이스란 ?
자바에서 인터페이스란 상수와 추상 메소드(abstract)의 집합이며 모든 멤버가 public으로 구현되어 있습니다. 추상 메소드의 집합인만큼 클래스가 상속받아 구현하여 사용할 수 있도록 동작하며 클래스들이 구현해야하는 동작을 지정하는 용도로 사용되는 추상 자료형입니다.
[ 선언 방법 ]
[public] interface 인터페이스이름 { ... }
// 예시
public interface User { ... }
- interface는 접근 지정자로 public을 사용하면, 다른 패키지에서도 사용이 가능합니다.
- public을 사용하지 않으면, interface가 위치한 해당 패키지 내에서만 사용이 가능합니다.
- interface의 접근 지정자는 public만 가능합니다. interface는 class 설계도이기 때문에 존재 목적이 공개이기 때문이죠.
- interface는 객체로 생성할 수 없기 때문에, 생성자를 가질 수 없습니다.
- Java 7 까지는 추상 메소드로만 선언이 가능했지만, Java 8 부터는 디폴트 메소드와 정적(static) 메소드도 선언이 가능합니다.
인터페이스의 역할
인터페이스의 역할을 크게 3가지로 구분하면 다음과 같습니다.
1) 구현을 강제한다.
2) 다형성을 제공한다.
3) 결합도를 낮춘다 (의존성을 역전)
(1) 구현을 강제한다.
구현을 강제한다라는 것이 무슨 말일까요 ? 말 그대로 인터페이스에 선언된 추상 메소드는 인터페이스를 구현하고자 하는 클래스 내부에서 꼭 구현을 해주어야 한다는 것입니다.
public interface Login {
void login();
}
다음과 같이 Login이라는 인터페이스가 존재할 때, 해당 인터페이스를 구현하는 클래스는 꼭 login() 메소드를 구현해줘야 합니다. 아래의 클래스들과 같이 말이죠.
public class KakaoLogin implements Login {
@Override
public void login(){
System.out.println("Kakao 로그인 입니다.");
}
}
public class NaverLogin implements Login {
@Override
public void login(){
System.out.println("Naver 로그인 입니다.");
}
}
(2) 다형성을 제공한다
위의 구현을 강제한다. 부분에서 Login 인터페이스와 KakaoLogin , NaverLogin 클래스들을 이용해보겠습니다.
public class Poly {
public static void main(String[] args){
KakaoLogin klog = new KakaoLogin();
klog.login();
NaverLogin nlog = new NaverLogin();
nlog.login();
}
}
실행 결과
Kakao 로그인 입니다.
Naver 로그인 입니다.
여기서 문제점은, klog 객체와 nlog 객체는 각각 구현체에 의존하고 있고 그 기능이 제한되어 있어 수정에 용이하지 않다는 것이죠.
여기에 인터페이스가 지닌 다형성을 적용한다면 우리는 다음과 같은 코드를 도출해낼 수 있습니다.
public class Poly {
public static void main(String[] args){
Login log = getLogin("Naver");
log.login();
}
private Login getLogin(String loginType){
if(loginType.equals("Naver") {
return new NaverLogin();
}
return new KakaoLogin();
}
}
다형성을 이용하여 인터페이스 타입으로 선언을 하게 되면, getLogin() 메소드에서 리턴되는 타입이 NaverLogin() 이던지 KakaoLogin()이던지 상관없이 login() 메소드를 호출할 수 있게 됩니다. 이것이 바로 인터페이스의 다형성인 것이죠.
(3) 결합도를 낮춘다. (의존성을 역전)
public class LoginService implements Login{
private Login login;
public LoginService(Login login) {
this.login = login;
}
@Override
public void login() {
login.login();
}
}
다음과 같은 Login 인터페이스를 구현한 LoginService 라는 클래스가 있다고 해볼게요 !
해당 클래스는 login을 할 수 있는 기능을 가진 구현체(NaverLogin, KakaoLogin)가 들어오면 해당 구현체를 통한 login()을 수행하는 역할을 합니다. 어떤 구현체가 오던지 받아들일 수 있는 준비가 된 클래스인 셈이죠.
만약 다음과 같이 작성한다면, KakaoLogin()의 기능에만 국한되어 사용할 수 있겠죠 ?
private Login login = new KakaoLogin();
1) 우리는 의존성을 외부에 맡겼기 때문에 의존성을 낮추는 결과를 얻을 수 있게 되었습니다.
2) Login과 LoginService 간 결합성이 추상체와 결합했기 때문에 결합도가 낮아지는 결과를 얻게 되었습니다.
만약, new KakaoLogin()과 같이 구현체와 결합했다면 결합도는 굉장히 강하다고 말할 수 있습니다. 좀 더 쉬운 이해를 위해 그림으로 살펴볼게요.
구현체를 직접 의존하게 되면, 왼쪽 그림과 같은 다이어그램을 살펴볼 수 있습니다.
그런데 반대로 우리가 중간에 Login 이라는 인터페이스를 두고, 구현체에 대한 의존성이 주입되도록 하는 것을 우리가 작성한 UserService 코드에서 살펴볼 수 있었죠. 이것이 바로 의존성과 결합도를 줄이고 의존성을 주입받는다고 하여 DI(Dependency Injection) 이라고 하는 것이죠. 그 결과가 오른쪽과 같은 그림을 도출해낼 수 있습니다.
또한, SOLID 원칙중 D(Dependency Inversion Principle) 원칙을 설명하는 의존성 역전을 살펴볼 수 있습니다.
Default 메소드
기존의 인터페이스는 원래 기능에 대한 선언만 하고, 실제 로직을 구현할 수 없었습니다. (추상 메소드로만 ㅎㅎ .. )
그런데 위에서 말했듯이 Java 8 이후부터는 디폴트 메소드라는 것이 추가되어 실제 로직을 구현하는 것이 가능하게 되었죠.
메소드 선언 시 default 키워드를 명시하게 되면 로직을 작성할 수 있습니다. 여기서 default는 접근제어자의 default와는 약간 다른데, 접근 제어자의 default는 아무것도 명시하지 않은 것을 말하지만 default method는 default 키워드를 명시해줘야 합니다.
public interface Hello {
default void print() {
System.out.println("hello world");
}
}
default 메소드는 구현 클래스에서 강제적으로 구현할 필요가 없으며, 선택적으로 오버라이딩이 가능합니다 !
Default 메소드는 왜 필요할까요 ?
인터페이스의 역할은 보통 기능 구현이 아니라, 틀을 잡아 선언에 초점을 두고 구현을 강제하는데에 목적이 있는데도 불구하고 default 메소드가 왜 생겼을까요 ? 결론적으로는 하위 호환성 때문입니다.
예를 들어, 내가 오픈 소스코드를 만들어 많은 사람들이 코드를 사용하고 있다고 가정해봅시다. 소스코드 중 특정 인터페이스에 새로운 메소드를 만들어야 하는 상황이 발생했고, 추상 메소드를 만들어 새로 추가한다면 해당 오픈소스를 사용하던 모든 사람들은 오류가 발생하고 수정해야하는 문제가 생기게 됩니다.
이럴 때, default 메소드로 추가하면, 구현 클래스에 영향을 주지 않으면서 수정할 수 있게 됩니다. 즉, 기존의 인터페이스를 보완하는 과정에서 추가적으로 구현할 메소드가 있다면 이미 인터페이스를 구현하고 있는 클래스와 호환성이 떨어지게 됩니다. 이 때 default 메소드를 추가하게 되면 하위 호환성은 유지하면서 인터페이스를 보완할 수 있습니다.
주의 사항
만약, 여러 인터페이스를 구현하는 클래스가 있는데 이 인터페이스들에 똑같은 default 메소드가 정의되어 있다면 ?
public interface A {
default void print() {
System.out.println("I'm A.");
}
}
public interface B {
default void print() {
System.out.println("I'm B.");
}
}
public class C implements A,B {
@Override
default void print() {
System.out.println("I'm C.");
A.super.print();
B.super.print();
}
}
이런 경우에는 print() 메소드를 오버라이딩 해줘야 합니다. 그렇지 않으면 컴파일 에러가 발생해요 !!
Static 메소드
인스턴스 생성과 상관없이 인터페이스 타입으로 호출할 수 있는 메소드입니다. 이 static 메소드도 Java 8에 도입되어 인터페이스에 추가 가능하다. 접근제어자는 항상 public 으로 간주하고, 구현 클래스에서 오버라이딩이 불가능합니다.
public interface A {
static void print() {
System.out.println("hello world");
}
}
public class Main {
public static void main(String[] args){
A.print();
}
}
함수형 인터페이스 (Functional Interface)
함수형 인터페이스란 1개의 추상 메소드를 갖는 인터페이스를 말합니다. Java 8부터 인터페이스는 디폴트 메소드를 포함할 수 있다고 했었죠 ? 여러 개의 디폴트 메소드가 있어도 추상 메소드가 오직 하나라면 함수형 인터페이스입니다.
자바의 람다 표현식은 함수형 인터페이스로만 사용이 가능합니다.
그리고 @FunctionalInterface 어노테이션을 사용하여 해당 인터페이스가 함수형 인터페이스 조건에 맞는지를 검사할 수 있습니다. 검증과 유지보수를 위해 붙이는걸 권장합니다.
@FunctionalInterface
interface CustomInterface<T> {
// abstract method 오직 하나
T myCall();
// default method 는 존재해도 상관없음
default void printDefault() {
System.out.println("Hello Default");
}
// static method 는 존재해도 상관없음
static void printStatic() {
System.out.println("Hello Static");
}
}
실제 사용
CustomInterface<String> customInterface = () -> "Hello Custom";
// abstract method
String s = customInterface.myCall();
System.out.println(s);
// default method
customInterface.printDefault();
// static method
CustomFunctionalInterface.printStatic();
// 실행 결과
Hello Custom
Hello Default
Hello Static
메소드 레퍼런스
메소드 레퍼런스(Method Reference)는 Lambda 표현식을 더 간단하게 표현하는 방법입니다.
Consumer<String> func = text -> System.out.println(text);
func.accept("Hello");
// Output
Hello
위의 코드는, 람다식으로 Hello를 출력하는 코드입니다. (Consumer는 어떤 객체를 입력받아 void를 출력시키는 함수형 인터페이스입니다.) 해당 코드를 다음과 같이 System.out::println 라는 메소드 레퍼런스로 표현할 수 있습니다. String 인자 1개를 받아 void를 출력시키는 함수라는 의미가 생략되어 있습니다.
Consumer<String> func = System.out::println;
func.accept("Hello");
메소드 레퍼런스는 ClassName::MethodName 형식으로 입력합니다. 또한 메소드 레퍼런스를 사용하게 되면 입력값을 변경하지말고 그대로 사용하라는 "의지의 표현"이 되기 때문에 다른 개발자의 개입을 차단하여 안정성을 얻을 수 있다는 점이 있습니다.
<참고 자료>