인터페이스의 기능
구현 강제
Java의 인터페이스는 모든 메서드가 추상 메서드로 존재한다. 그래서 구현체인 클래스에게 인터페이스의 메서드들을 구현하도록 강제한다. 인터페이스를 구현한 클래스가 있는데 인터페이스에서 명시되어 있는 메서드를 구현하지 않은 채로 냅두면 컴파일 에러가 뜬다.
다형성 제공
public interface MyRunnable {
void myRun();
}
public interface YourRunnable {
void yourRun();
}
public class MyClass implements MyRunnable, YourRunnable {
@Override
public void myRun() {
System.out.println("my run");
}
@Override
public void yourRun() {
System.out.println("your run");
}
}
public class Main {
public static void main(String[] args) {
MyClass myClass = new MyClass();
MyRunnable myRun = new MyClass();
YourRunnable yourRun = new MyClass();
myClass.myRun();
myClass.yourRun();
myRun.myRun();
// myRun.yourRun(); 실행 불가
yourRun.yourRun();
// yourRun.myRun(); 실행 불가
}
}
이렇게 다중의 인터페이스를 구현해 필요한 객체가 전략적으로 선택해 수행할 수 있다.
반대로 하나의 인터페이스에서 다양한 구현체를 전략적으로 이용할 수도 있다.
public interface Login {
void login();
}
public class KakaoLogin implements Login {
@Override
public void login() {
System.out.println("kakao login");
}
}
public class NaverLogin implements Login {
@Override
public void login() {
System.out.println("naver login");
}
}
로그인 기능을 구현하기 위해 로그인을 수행할 객체들을 위와 같이 만들고, 타입을 Login으로 지정한 구현체를 생성해 아래와 같이 수행 가능하다.
public class MyMain {
public static void main(String[] args) {
Login kakao = new KakaoLogin();
Login naver = new NaverLogin();
kakao.login(); // kakao login
naver.login(); // naver login
}
}
하나의 로그인 객체만을 이용해 전략적으로 로그인을 수행하기 위해 다음과 같이 추가적인 구현을 할 수 있다.
public enum LoginType {
KAKAO,
NAVER;
}
public class MyMain {
public static void main(String[] args) {
run(LoginType.KAKAO); // kakao login
}
public void run(LoginType loginType) {
Login login = getLogin(loginType);
login.login();
}
public Login getLogin(LoginType loginType) {
if (loginType == KAKAO) {
return new KakaoLogin();
}
if (loginType == NAVER) {
return new NaverLogin();
}
}
}
이렇게 선택적으로 구현체를 생성해 수행할 수 있다. 생성된 객체를 사용하는 객체는 그 구현체가 카카오인지 네이버인지 상관없이, Login 인터페이스의 기능만을 사용할 것을 명시한다. 이 상황에서 어떤 로그인으로 실행할지 결정권을 가지고 있는 것은 누구 일까? run()
에 들어갈 인자를 결정해 run()
을 호출할 객체가 결정권을 가진 호스트 코드가 된다. 그래서 main()
이 호스트 코드이다.
MyMain 안에 인자를 지정해서 넣어주었지만, 이 인자를 설정파일이나 config 등으로 지정해 넣을 수 있다면 구현체들의 코드의 수정 없이 외부 설정파일을 변경해 컴파일을 통해 선택적으로 수행할 수 있다. 만약 변수 login이 Login이 아닌 KakaoLogin, NaverLogin 같은 구체적인 객체의 타입이었다면 이러한 유연성은 불가능이다.
위의 코드에선 어떤 로그인 객체를 사용할 지 getLogin()
이라는 함수에 생성 기능을 위임한다. run()
을 수행할 main()
함수가 아닌 다른 함수에서 생성을 해서 갖다 바치는, 마치 공장 같은 형태가 된다. 이렇게 생성이나 수행의 책임을 다른 객체에게 위임해서 하청 업체의 기능을 할 존재를 만들 수도 있다.
public class UserService {
private Login login;
public UserService(LoginType loginType) {
if (loginType == KAKAO) {
this.login = new KakaoLogin();
}
if (loginType == NAVER) {
this.login == new NaverLogin();
}
}
public void signIn() {
this.login.login();
}
}
이렇게 하청 업체를 만들어 실제로 로그인을 수행할 기능을 위임할 수 있다. 이 때 하청 업체가 구동될 때 어떤 로그인을 만들지에 대한 결정은 외부에 있다. 하청 업체는 외부의 요청에 따라 해당 로그인을 만들어 요구될 때 구현된 로그인을 수행해주면 된다.
이래도 하청 업체는 불만이 있을 수도 있다. 지금 UserService라는 하청 업체는 로그인의 생성과 수행을 모두 담당하고 있는데, 요구서에 따라 수행하는 업무를 다른 업체에 배분해주고 수행만 하고싶어할 수도 있다. 그렇게 요구서를 해석해 수행할 로그인의 객체를 지정해 넣어 줄 또 다른 하청 업체를 구상할 수 있다.
public class LoginFactory {
public static Login interpret(LoginType loginType) {
if (loginType == KAKAO) {
return new KakaoLogin();
}
if (loginType == NAVER) {
return new NaverLogin();
}
return null;
}
}
최상부에서 내려진 요구서를 해석해 실제로 사용될 로그인을 결정해 UserService에게 전달하는 객체를 구상했다. 그러면 UserService는 다음과 같이 바뀔 수 있다.
public class UserService {
private Login login;
public UserService(Login login) {
this.login = login
}
public void signIn() {
this.login.login();
}
}
이제 UserService는 생성된 로그인 객체를 실행하기만 하면 되는 업무만이 주어졌다. 실제로 어떤 구현체인지, 어떤 로그인이지 자세하게 알 필요 없이 그저 넘겨받은 로그인을 수행하기만 하면 된다. 이 모든 결정은 제일 최상부의 호스트 코드가 결정해준다.
public class MyMain {
public static void main(String[] args) {
new UserService(LoginFactory.interpret(LoginType.KAKAO)); // kakao login
}
}
결합도를 낮추는 효과 (의존성을 역전)
여기서 UserService와 LoginFactory는 Login을 사용하거나 포함한다. Login에 의존하고 있다. 만약 Login이 바뀐다면, 둘 또한 수정해야 할 가능성이 있다.
만약 UserService가 구체적으로 KakaoLogin만 생성했다면, 다른 로그인은 수행하지 못한다. 의존도가 높다는 것이다. 그래서 생성자에서 인자로 LoginType을 받아서 선택적으로 생성할 수 있는 기능을 내려준다. 로그인을 수행해야하는데 여전히 UserService는 로그인에 상당히 의존하고 있었다. 만약 구글로그인이 생긴다면, 그에 대해 작업을 추가해주어야 한다. 로그인을 수행할 책임에 맞게, 어떤 로그인을 수행할지에 대한 결정을 완전히 밖으로 밀어줬다. 그렇게 의존성을 외부의 LoginFactory에 맡겼고, 이렇게 의존성을 낮췄다. 의존성에 대한 결정권을 외부에 맡길 수 있다면 의존도를 낮추는 설계가 가능하다.
의존도는 다른 말로 결합성이라고도 해석할 수 있다. 의존하는 정도가 높을 수록 해당 객체와 강하게 결합되어 있다는 의미이다. KakaoLogin, NaverLogin 등의 구상체와 결합하는게 아니라 Login 같은 추상체와 결합할 수록 결합도를 느슨하게 낮출 수 있다.
의존성을 외부에서 전달 받은 행위는 의존성을 주입 받았다고도 한다. 이것을 의존성 주입, Dependency Injection이라고 한다. 흔히들 DI라고 한다.

구상체를 직접 의존하게 되면 위와 같이 UserService에서 뻗어나가는 화살표는 여러개다.

이렇게 의존하고 있는 구상체 앞에 인터페이스 하나를 두어서 화살표를 한개로 축소시키고, 각각의 구상체 또한 인터페이스에게 하나의 화살표를 보내게끔 할 수 있다. UserService에서 KakaoLogin, NaverLogin으로 일방적으로 향하던 화살표의 방향이 역전되어 모두가 Login을 향하고 있다. 이를 의존성 역전, Dependency Inversion이라고 한다.
인터페이스는 기본적으로 활용도가 높고 흔하게 사용된다. 계속 사용하다보니 아쉬운 부분이 생긴다. 그래서 Java 8부터 기능이 강화된다.
인터페이스의 함수 기능 제공 추가
Java 8부터 추상 메서드로만 이루어져있던 인터페이스가 구현체를 가질 수 있게된다.
default method
인터페이스에서 메서드를 선언하고 구현부를 정의하려고 하면 컴파일 에러가 뜬다. 앞에 default를 붙이면 구현부를 구현할 수 있게된다.
public interface MyInterface {
default void sayHello() {
System.out.println("Hello");
}
}
그래도 여전히 Override 또한 가능하다.
public class MyMain implements MyInterface {
@Override
void sayHello() {
System.out.println("Hello from the other side");
}
}
default method가 없이 MyInterface가 다음과 같이 두 개의 메서드가 있다고 가정해보자.
public interface MyInterface {
void sayHello();
void sayBye();
}
해당 인터페이스를 구현하는 구현체에선 두 메서드를 반드시 구현해줘야한다. 내가 안 쓰더라도. Hello만을 말하고 싶은 구현체가 Bye도 구현해야하는데, 원하지 않는 메서드를 담고싶지 않다면 다음과 같이 어댑터를 중간에 껴줄 수 있다.
public class MyInterfaceAdapter implements MyInterface {
@Override
public void sayHello() {}
@Override
public void sayBye() {}
}
이렇게 인터페이스가 수신할 수 있는 메세지인 메서드가 많을 때는 어댑터를 이용해 용도에 맞게 원하는 부분만 구현할 수 있다. SWING, Java FX 등에서 많이 사용된다. 디자인 패턴의 어댑터 패턴이랑은 약간은 다른 듯하다.
이러한 어댑터의 문제는 무엇인가? 상속을 이용하기 때문에 상속을 하나 밖에 받지 못 하는 Java 같은 언어에서는 어댑터를 하나 밖에 사용하지 못 한다. 만약 단순히 인터페이스에 많은 메서드가 들어가 있다면 어댑터는 인터페이스의 모든 메서드를 기본적으로 구현해주면 된다. 그치만 다른 클래스를 상속받고 있는 객체에 어댑터를 쓰지 못 하니, MyInterface를 쓰면 결국 해당 객체의 메서드는 작동하지 않는 구현부를 작성해야하고 코드가 지저분해진다.
그래서 default method가 등장했다. 클래스는 여러 인터페이스를 implement 할 수 있으니, 어댑터의 기능을 그대로 가져오면서 어댑터의 단점을 없앴다.
이번엔 다른 예시로, 동물들의 특징을 수영, 걷기, 날기로 생각해 이를 인터페이스로 만들어보자.
public interface Flyable {
void fly();
}
public interface Swimmable {
void swim();
}
public interface Walkable {
void walk();
}
이제 이러한 행위적 특징을 가진 구체적인 동물 객체 중 오리와 백조를 만들어봤다.
public class Duck implements Swimmable, Walkable {
@Override
public void swim() {
System.out.println("swim");
}
@Override
public void walk() {
System.out.println("walk");
}
}
public class Swan implements Flyable, Walkable {
@Override
public void fly() {
System.out.println("fly");
}
@Override
public void walk() {
System.out.println("walk");
}
}
Java 8 이전엔 보시다시피 같은 메세지를 수신하더라도 중복적으로 구현해야했다. default method를 이용하면 아래와 같이 오리와 백조를 나타낼 수 있다.
public interface Flyable {
default void fly() {
System.out.println("fly");
}
}
public interface Swimmable {
default void swim() {
System.out.println("swim");
}
}
public interface Walkable {
default void walk() {
System.out.println("walk");
}
}
public class Duck implements Swimmable, Walkable {}
public class Swan implements Flyable, Walkable {}
이러한 default method의 장점은 확장이 매우 쉽다. 백조의 특징으로 수영할 수 있다는 점이 있다는 것을 깨닫게 되어 해당 인터페이스를 추가하자면 다음과 같다.
public class Swan implements Flyable, Walkable, Swimmable {}
기본 구현부가 있으니 인터페이스 추가만으로 이처럼 매우 매우 쉬운 확장을 할 수 있다. 기본적으로, default method가 하나로만 제한되는 상속의 단점을 없애준다.
static method
default method가 되고, 또 static method 또한 인터페이스가 가질 수 있게된다. 둘의 차이는 무엇일까? static method는 전역 메서드로 해당 인터페이스의 모든 구현체가 해당 메서드에 접근할 수 있다. default method는 구현부가 이미 정의된 것이다. static method는 전역 메모리에 올라 있으니 override 불가하다.
public interface MyInterface {
static void sayFromWorld() {
System.out.println("Hello, World");
}
}
기존의 인터페이스는 추상 메서드만을 담은 추상체다. 객체 간의 메세지 수신을 위해 어떤 메세지를 수신할 수 있는지 메서드만 담았던 추상체인 그릇이다. Java 8 이후로는 인터페이스가 default method와 static method로 메서드, 즉, 함수를 제공할 수 있게 되었다. 하지만 Java 8부터 인터페이스는 단순히 함수 제공자만 될 수 있는 것이 아니다. 아예 함수가 될 수 있었다.
Functional Interface
다음과 같은 인터페이스를 만들었다.
public interface MySupply {
String supply();
}
위의 인터페이스는 추상 메서드가 하나 밖에 없는 인터페이스다. 이를 자바에선 함수형 인터페이스라고 한다. 아래와 같이 애너테이션을 통해 명시적으로 표현할 수도 있다.
@FunctionalInterface
public interface MySupply {
String supply();
}
@FunctionalInterface
는 선택이다. 추상 메서드가 하나 밖에 없는 인터페이스는 해당 애너테이션이 있건 없건 함수형 인터페이스이다. 한 눈에 이해되니 무엇이든 명시해주는 것은 항상 좋다고 생각한다.
여기에 default method와 static method를 추가해보자.
@FunctionalInterface
public interface MySupply {
String supply();
default void sayHello() {
System.out.println("Hello");
}
static void sayBye() {
System.out.println("Bye");
}
}
위의 MySupply은 함수형 인터페이스일까? 맞다. 함수형 인터페이스는 추상 메서드만 하나면 된다. default method와 static method는 추상 메서드가 아니기 때문에 가능하다.
이제 MySupply의 구현체를 만들어보자.
public class Hello implements MySupply {
@Override
public String supply() {
return "Hello";
}
}
public class MyMain {
public static void main(String[] args) {
new Hello().supply(); // Hello
}
}
간단하다. 우리는 supply()
를 구현하기 위해 Hello라는 구현체를 만들어줬다. 왜냐하면, MySupply는 인터페이스이기 때문에 인스턴스를 생성해 구현을 할 수가 없고 원하는 구현이 있을 땐 위와 같이 구현체를 통해 구현을 해야한다.
함수형 인터페이스와 같이 간단하게 추상 메서드 하나만으로 이루어진 인터페이스를 상황마다 다르게 활용하려면 매번 새로운 클래스를 만들어야 한다. 물론 인터페이스의 구현체는 항상 있는 것이 좋다고 생각한다. 하지만 하나의 단순한 인터페이스가 항상 다른 클래스를 직접 만들어 구현을 쓰고 인스턴스를 생성하는 과정이 번거로울 수 있고, Java 8 이전의 개발자들이 그렇게 느꼈다.
익명 클래스
조금 다르게 생각해보자. 실행하려는 호스트 클래스와 인터페이스의 구현체인 클래스를 분리해서 생성하지말고, 한 번에 생성하면 어떨까? 아래와 같이 말이다.
public class MyMain {
public static void main(String[] args) {
new class MyObject implements MySupply {
@Override
public String supply() {
return "Hello";
}
}.supply();
}
}
괴랄한 코드처럼 보일 수도 있다. 그냥 다 합쳤다고 생각하면 된다. MySupply를 구현한 MyObject 안에 supply()
메서드를 오버라이드해서 구현하고, 그 앞에 new
를 붙여 곧바로 MyObject의 인스턴스를 생성한다고 상상했다. 그리고는 뒤에 해당 인스턴스의 메서드인 supply()
를 실행한다고 생각했다. 인터페이스 구현과 인스턴스 생성, 메서드 호출까지 한 번에 들어간 코드다. 물론 동작하는 코드는 아니다. 아무튼 어차피 단순한 함수형 인터페이스이면 이렇게 즉각적으로 실행을 하면 안될까?
이를 더 단순하게 생각해보자. 어차피 일회성 클래스인데 이름이 중요한가? 빼자. MySupply는 인터페이스인데 implements
하는 것은 당연하지 않은가? 빼자. implements
할 수 있는 대상은 class
이다. 당연한 것 아닌가? 빼자. class
, 클래스 이름, implements
가 빠진다. 아래와 같다.
public class Main {
public static void main(String[] args) {
new MySupply() { // 괄호는 그냥 그러려니 하자.
@Override
public String supply() {
return "Hello";
}
}.supply();
}
}
이렇게 이름없는 클래스를 임의로 생성한다고 가정하고 new
로 인스턴스를 생성해 해당 인스턴스의 supply()
를 실행하는 코드를 만들었다. 이는 실제로 동작하는 코드다. 이런 이름없는 클래스를 익명 클래스라고 한다.
다형성을 기억하자. 인터페이스를 구현한 클래스는 인터페이스로 타입을 지정할 수 있다. 그럼 위와 같은 코드는 아래와 같이 분리될 수 있다.
public class Main {
public static void main(String[] args) {
MySupply sup = new MySupply() {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
이렇게 하니 더욱 쉽게 이해되지 않은가? 이것이 함수형 인터페이스의 익명 클래스 활용이다. 인터페이스의 인스턴스를 만들어 구현부를 바로 정의할 수 있다.
익명 클래스는 중첩이 가능하다.
public class Main {
public static void main(String[] args) {
MySupply sup1 = new MySupply() {
@Override
public String supply() {
MySupply sup2 = MySupply() {
@Override
public String supply() {
return "Hello, Bye";
}
}
return sup2.supply();
}
};
sup1.supply();
}
}
그저 구현부 내에 같은 방식으로 익명클래스를 생성한 것이니 어찌보면 당연한 말이다. 이처럼 인터페이스를 익명클래스를 만들어 처리할 수 있다면, 메서드도 익명으로 만들 수 없을까?
Lambda Expression
함수형 인터페이스는 추상 메서드가 하나 밖에 없는 인터페이스이다. 함수형 인터페이스라면 구현해야하는 다른 함수는 없다는 사실은 분명하다. 우리가 어떤 함수형 인터페이스를 구현하면 그 구현은 한 메서드의 구현일 것이다.
익명 클래스를 다시 한 번 살펴보자.
public class Main {
public static void main(String[] args) {
MySupply sup = new MySupply() {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
당연한 사실들을 빼서 익명 클래스가 더욱 간단하게 표현될 수 있었다. 사실 여기서 더 뺄 수 있는 당연한 것들이 있다. MySupply가 타입인 익명 클래스가 구현하는 함수형 인터페이스는 당연히 타입인 MySupply이지 않은가? MySupply()
를 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = new () {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
변수에 할당할 수 있는 것은 인스턴스가 당연하니, new
도 당연하지 않은가? 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = () {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
인터페이스이니 구현이 필요하단 것은 당연한 사실 아닌가? 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = ()
@Override
public String supply() {
return "Hello";
}
sup.supply();
}
}
인터페이스의 구현체는 인터페이스의 메서드를 Override해서 구현해야하는 것이 당연하지 않은가? 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = ()
public String supply() {
return "Hello";
}
sup.supply();
}
}
함수형 인터페이스는 추상 메서드가 한 개 밖에 없는 인터페이스이다. 위에 말했던 것처럼, 함수형 인터페이스에 구현해야할 것은 하나의 메서드 밖에 없는 것은 당연하다. 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = () {
return "Hello";
}
sup.supply();
}
}
이제 남은 것은 괄호 두개 밖에 없다. 약간 구분을 해주기 위해 괄호 두개 사이에 화살표를 넣어주고, 명령이 끝나는 부분에 ;
를 넣어주자.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> {
return "Hello";
};
sup.supply();
}
}
수행해야 할 구현체의 구문이 return "Hello"
밖에 없으니 중괄호도 필요없다. 어차피 “Hello”를 return하는 것이 항상 당연한 사실인데, return도 필요 없다.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> "Hello";
sup.supply();
}
}
이것이 함수형 인터페이스, 익명 메서드를 사용해서 표현하는 방법인 람다 표현식이다. 이는 함수형 인터페이스이기 때문에 가능했다. 함수형 인터페이스가 하나의 메서드를 가진 것이 당연했기 때문이다. 메서드가 여러개인 인터페이스라면, 당연히 사용할 수 없다.
구현체 부분에 더 많은 표현을 하고 싶다면 어떻게 하면될까? 그러면 중괄호를 빼지 않고 기존의 메서드를 구현하는 방식으로 하면 된다.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> {
String world = "World";
return "Hello" + " " + world;
};
sup.supply();
}
}
매번 클래스를 생성하는 것보다 익명 클래스를 사용하는 것이, 익명 클래스를 사용하는 것보다 람다 표현식을 사용하는 것이 더욱 간결해진다. 람다 표현식을 더 살펴보기 위해 아래와 같이 인터페이스를 추가해보자
public interface MyMapper {
int map(String item);
}
public interface MyConsumer {
void consume(int item);
}
public interface MyRunnable {
void run();
}
이제 메인 함수로 가서 Supplier, Mapper, Consumer의 구현체를 람다 표현식으로 만들어보자.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> "Hello";
MyMapper map = (i) -> str.length;
MyConsumer con = (i) -> System.out.println(i);
}
}
그리고 마지막에 runnable로 이 람다 표현식들을 연쇄적으로 호출해보자.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> "Hello";
MyMapper map = (str) -> str.length;
MyConsumer con = (i) -> System.out.println(i);
MyRunnable run = () -> con.consume(map.map(sup.supply())); // 5 - Hello의 길이
}
}
이렇게 보기만해도 간단한 활용이 될 수 있지 않겠는가? 실제로 Java 8 이후에 나온 많은 기능들이 이런 람다 표현식을 굉장히 많이 쓴다. Supplier, Mapper, Consumer 등의 함수형 인터페이스도 실제로 존재하고, 다른 기본적인 함수형 인터페이스 또한 존재하니 연습해보면 람다 표현식을 더욱 자유자재로 사용할 수 있다.
Method Reference
람다 표현식을 더욱 간편하게 쓰는 방법 또한 있다. Mapper와 Consumer를 다시 보자.
public class Main {
public static void main(String[] args) {
MyMapper map = (str) -> str.length;
MyConsumer con = (i) -> System.out.println(i);
}
}
MyMapper는 인자를 받아 문자열 클래스의 length()
함수를 실행한다. MyConsumer는 인자를 받아 System.out
클래스의 println()
함수를 실행한다. 이렇게 인자를 받아 해당 인자 클래스의 함수만을 실행하거나, static 함수를 실행할 때 해당 인자만을 이용하거나 할 땐 더욱 간단하게 표현할 수 있다.
public class Main {
public static void main(String[] args) {
MyMapper map = String::length;
MyConsumer con = System.out::println;
}
}
이와 같이 람다 표현식에서 입력되는 값을 변경없이 바로 사용하는 경우, 최종으로 적용될 메서드의 레퍼런스를 지정해 주는 표현 방식이다. 변경이 일어나는 경우는 어떤 경우일까?
public class Main {
public static void main(String[] args) {
MyMapper map = String::length;
MyConsumer con = (i) -> System.out.println(i * 10);
}
}
위와 같은 경우는 인자로 들어가는 i값을 10을 곱하는 변경을 가해준다. 이럴 때는 되지 않는다.
메서드 레퍼런스를 사용하면 좋은점이 무엇일까? 메서드 레퍼런스를 사용하면 람다 표현식에 추가로 당연한 것이 더 생긴다. 입력값이 변하지 않는다는 것이 당연하다는 사실이 표시된다. 메서드 레퍼런스를 사용함으로써, 입력값을 변경하지 말라는 메세지를 줄 수도 있고, 표현 방식에 인자가 보이지 않으니 의도치 않은 인자의 변화를 차단함으로써 안정성을 더욱 올릴 수도 있다.
MySupplier는 String을 공급하는 supply()
함수를 가지고 있다. 그러다 문자열이 아닌 정수를 공급하고 싶어지면 어떡할까? MyMapper에서 String의 인자를 받아 int를 반환하는게 아닌, 반대로 int 변수를 인자로 받아 String으로 반환하고 싶으면 어떡할까? 인터페이스를 새로이 만들어야할까?
Generic
MyMapper를 다시 보자.
public interface MyMapper {
int map(String item);
}
MyMapper에는 현재 Input으로 들어오는 인자의 타입은 String이고 Output으로 반환되는 타입은 int이다. 더욱 보기 쉽게 다음과 같이 바꿀 수 있다.
public interface MyMapper {
OUT map(IN in);
}
MyMapper를 사용할 때엔 어떤 타입이 Input이 되고 Output이 될지 알려줄 방법이 있어야한다. 다음과 같이 표현해 사용할 수 있다.
public interface MyMapper<IN, OUT> {
OUT map(IN in);
}
public class Main {
public static void main(String[] args) {
MyMapper<String, Integer> map = String::length;
}
}
이런 식으로 사용될 인자를 지정하지 않고, 외부에서 정해줄 수 있게 포괄적으로 열어두는 것이 Generic이다. MyConsumer와 MySupplier도 Generic을 이용해 아래와 같이 바꿔줄 수 있다.
public interface MyConsumer<T> {
void consume(T t);
}
public interface MySupplier<T> {
T supply();
}
T는 타입의 T이다. 이외에도 Key의 K, Value의 V 등, Generic을 사용할 때 대표적으로 사용되는 의미들이 있으니 찾아보면 좋을 것 같다. 그렇다면 이 Generic을 이용한 함수형 인터페이스는 어떻게 활용할 수 있을까?
인터페이스의 원하는 구현체를 넘겨주는 역할을 하는 것은 호스트 코드이다. 구현체를 넘겨 받아 작업을 진행하는 객체는, 호스트 코드에게 의존성을 주입 받으면서 해당 인터페이스의 모든 객체들에 대한 의존성을 낮출 수 있다. 객체는 아니지만, 함수로 예를 들어보겠다.
public class Main {
public static void main(String[] args) {
System.out.println(loop(10)); // 45
}
public int loop(int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += i;
}
return sum;
}
}
위의 코드에서 loop 함수가 하는 일은, 주입 받은 n만큼 모든 수를 더해 합을 반환한다. 얼만큼 루프를 돌릴지는 main 함수가 결정을 하지만, 루프 함수 안에서 무엇을 할지는 loop 함수 내부적으로 알아야한다. 함수형 인터페이스와 람다 표현식을 이용해 이 loop 함수가 온전히 loop에 집중할 수 있도록 바꿀 수 있다.
public class Main {
public static void main(String[] args) {
int sum = 0;
loop(10, (i) -> sum + i);
System.out.println(sum);
}
public void loop(int n, MyConsumer<Integer> consumer) {
for (int i = 0; i < n; i++) {
consumer.consume(i);
}
}
}
위처럼 루프를 돌릴 때마다 어떤 업무를 수행할지 main 함수가, 호스트 코드가 결정해서 내려줄 수 있다. loop 함수는 주입 받은 n번만큼 주입 받은 consumer를 실행해주면 된다. loop 함수의 입장에서 “나는 루프를 돌리기만 할거야. 루프를 돌릴 때마다 어떤 것을 할지는 정해서 알려줘”라고 스탠스를 확실하게 정할 수 있다. 참고로 Generic이 사용된 인터페이스 등을 사용할 때는 int 같은 원시 타입을 사용하지 못하고, Integer 등의 Wrapper를 사용해야한다.
이렇게 활용할 수 있는 함수형 인터페이스가 Java 8 이후로는 많고, 너무나도 많이 활용되고 있다.
java.util.function (Java SE 17 & JDK 17)
package java.util.function Functional interfaces provide target types for lambda expressions and method references. Each functional interface has a single abstract method, called the functional method for that functional interface, to which the lambda expr
docs.oracle.com
이번엔 Java의 함수형 인터페이스를 이용해 간단한 구현을 해보자.
public class Main {
public static void main(String[] args) {
filteredNumers(
10,
i -> i % 2 == 0,
System.out::println
);
}
void filteredNumbers(int n, Predicate<Integer> predicate, Consumer<Integer> consumer) {
for (int i = 0; i < n; i++) {
if (predicate.test(i)) {
consumer.accept(i);
}
}
}
}
Predicate는 구현부의 결과가 true인지 false인지 반환한다. 0부터 9까지 반복하는데, 만약 짝수라면 그 수를 출력하는 간단한 함수이다. main 함수에선 어떤 행위를 얼마나 할 지 모든 것을 결정해서 수행하라는 메세지를 전달할 수 있고, 메세지를 받는 filteredNumbers()
는 인터페이스들의 메서드만을 수행시키면 된다. 인터페이스들의 구현이 어떠하더라도 말이다.
References
프로그래머스 데브코스 - 프레임워크를 위한 Java
'Java' 카테고리의 다른 글
자바 Collection 이야기 (0) | 2024.07.16 |
---|---|
빌드툴 없이 Java 프로젝트 빌드 해보기 (0) | 2023.11.29 |
Java, JVM, JRE, JDK (2) | 2023.11.29 |
인터페이스의 기능
구현 강제
Java의 인터페이스는 모든 메서드가 추상 메서드로 존재한다. 그래서 구현체인 클래스에게 인터페이스의 메서드들을 구현하도록 강제한다. 인터페이스를 구현한 클래스가 있는데 인터페이스에서 명시되어 있는 메서드를 구현하지 않은 채로 냅두면 컴파일 에러가 뜬다.
다형성 제공
public interface MyRunnable {
void myRun();
}
public interface YourRunnable {
void yourRun();
}
public class MyClass implements MyRunnable, YourRunnable {
@Override
public void myRun() {
System.out.println("my run");
}
@Override
public void yourRun() {
System.out.println("your run");
}
}
public class Main {
public static void main(String[] args) {
MyClass myClass = new MyClass();
MyRunnable myRun = new MyClass();
YourRunnable yourRun = new MyClass();
myClass.myRun();
myClass.yourRun();
myRun.myRun();
// myRun.yourRun(); 실행 불가
yourRun.yourRun();
// yourRun.myRun(); 실행 불가
}
}
이렇게 다중의 인터페이스를 구현해 필요한 객체가 전략적으로 선택해 수행할 수 있다.
반대로 하나의 인터페이스에서 다양한 구현체를 전략적으로 이용할 수도 있다.
public interface Login {
void login();
}
public class KakaoLogin implements Login {
@Override
public void login() {
System.out.println("kakao login");
}
}
public class NaverLogin implements Login {
@Override
public void login() {
System.out.println("naver login");
}
}
로그인 기능을 구현하기 위해 로그인을 수행할 객체들을 위와 같이 만들고, 타입을 Login으로 지정한 구현체를 생성해 아래와 같이 수행 가능하다.
public class MyMain {
public static void main(String[] args) {
Login kakao = new KakaoLogin();
Login naver = new NaverLogin();
kakao.login(); // kakao login
naver.login(); // naver login
}
}
하나의 로그인 객체만을 이용해 전략적으로 로그인을 수행하기 위해 다음과 같이 추가적인 구현을 할 수 있다.
public enum LoginType {
KAKAO,
NAVER;
}
public class MyMain {
public static void main(String[] args) {
run(LoginType.KAKAO); // kakao login
}
public void run(LoginType loginType) {
Login login = getLogin(loginType);
login.login();
}
public Login getLogin(LoginType loginType) {
if (loginType == KAKAO) {
return new KakaoLogin();
}
if (loginType == NAVER) {
return new NaverLogin();
}
}
}
이렇게 선택적으로 구현체를 생성해 수행할 수 있다. 생성된 객체를 사용하는 객체는 그 구현체가 카카오인지 네이버인지 상관없이, Login 인터페이스의 기능만을 사용할 것을 명시한다. 이 상황에서 어떤 로그인으로 실행할지 결정권을 가지고 있는 것은 누구 일까? run()
에 들어갈 인자를 결정해 run()
을 호출할 객체가 결정권을 가진 호스트 코드가 된다. 그래서 main()
이 호스트 코드이다.
MyMain 안에 인자를 지정해서 넣어주었지만, 이 인자를 설정파일이나 config 등으로 지정해 넣을 수 있다면 구현체들의 코드의 수정 없이 외부 설정파일을 변경해 컴파일을 통해 선택적으로 수행할 수 있다. 만약 변수 login이 Login이 아닌 KakaoLogin, NaverLogin 같은 구체적인 객체의 타입이었다면 이러한 유연성은 불가능이다.
위의 코드에선 어떤 로그인 객체를 사용할 지 getLogin()
이라는 함수에 생성 기능을 위임한다. run()
을 수행할 main()
함수가 아닌 다른 함수에서 생성을 해서 갖다 바치는, 마치 공장 같은 형태가 된다. 이렇게 생성이나 수행의 책임을 다른 객체에게 위임해서 하청 업체의 기능을 할 존재를 만들 수도 있다.
public class UserService {
private Login login;
public UserService(LoginType loginType) {
if (loginType == KAKAO) {
this.login = new KakaoLogin();
}
if (loginType == NAVER) {
this.login == new NaverLogin();
}
}
public void signIn() {
this.login.login();
}
}
이렇게 하청 업체를 만들어 실제로 로그인을 수행할 기능을 위임할 수 있다. 이 때 하청 업체가 구동될 때 어떤 로그인을 만들지에 대한 결정은 외부에 있다. 하청 업체는 외부의 요청에 따라 해당 로그인을 만들어 요구될 때 구현된 로그인을 수행해주면 된다.
이래도 하청 업체는 불만이 있을 수도 있다. 지금 UserService라는 하청 업체는 로그인의 생성과 수행을 모두 담당하고 있는데, 요구서에 따라 수행하는 업무를 다른 업체에 배분해주고 수행만 하고싶어할 수도 있다. 그렇게 요구서를 해석해 수행할 로그인의 객체를 지정해 넣어 줄 또 다른 하청 업체를 구상할 수 있다.
public class LoginFactory {
public static Login interpret(LoginType loginType) {
if (loginType == KAKAO) {
return new KakaoLogin();
}
if (loginType == NAVER) {
return new NaverLogin();
}
return null;
}
}
최상부에서 내려진 요구서를 해석해 실제로 사용될 로그인을 결정해 UserService에게 전달하는 객체를 구상했다. 그러면 UserService는 다음과 같이 바뀔 수 있다.
public class UserService {
private Login login;
public UserService(Login login) {
this.login = login
}
public void signIn() {
this.login.login();
}
}
이제 UserService는 생성된 로그인 객체를 실행하기만 하면 되는 업무만이 주어졌다. 실제로 어떤 구현체인지, 어떤 로그인이지 자세하게 알 필요 없이 그저 넘겨받은 로그인을 수행하기만 하면 된다. 이 모든 결정은 제일 최상부의 호스트 코드가 결정해준다.
public class MyMain {
public static void main(String[] args) {
new UserService(LoginFactory.interpret(LoginType.KAKAO)); // kakao login
}
}
결합도를 낮추는 효과 (의존성을 역전)
여기서 UserService와 LoginFactory는 Login을 사용하거나 포함한다. Login에 의존하고 있다. 만약 Login이 바뀐다면, 둘 또한 수정해야 할 가능성이 있다.
만약 UserService가 구체적으로 KakaoLogin만 생성했다면, 다른 로그인은 수행하지 못한다. 의존도가 높다는 것이다. 그래서 생성자에서 인자로 LoginType을 받아서 선택적으로 생성할 수 있는 기능을 내려준다. 로그인을 수행해야하는데 여전히 UserService는 로그인에 상당히 의존하고 있었다. 만약 구글로그인이 생긴다면, 그에 대해 작업을 추가해주어야 한다. 로그인을 수행할 책임에 맞게, 어떤 로그인을 수행할지에 대한 결정을 완전히 밖으로 밀어줬다. 그렇게 의존성을 외부의 LoginFactory에 맡겼고, 이렇게 의존성을 낮췄다. 의존성에 대한 결정권을 외부에 맡길 수 있다면 의존도를 낮추는 설계가 가능하다.
의존도는 다른 말로 결합성이라고도 해석할 수 있다. 의존하는 정도가 높을 수록 해당 객체와 강하게 결합되어 있다는 의미이다. KakaoLogin, NaverLogin 등의 구상체와 결합하는게 아니라 Login 같은 추상체와 결합할 수록 결합도를 느슨하게 낮출 수 있다.
의존성을 외부에서 전달 받은 행위는 의존성을 주입 받았다고도 한다. 이것을 의존성 주입, Dependency Injection이라고 한다. 흔히들 DI라고 한다.

구상체를 직접 의존하게 되면 위와 같이 UserService에서 뻗어나가는 화살표는 여러개다.

이렇게 의존하고 있는 구상체 앞에 인터페이스 하나를 두어서 화살표를 한개로 축소시키고, 각각의 구상체 또한 인터페이스에게 하나의 화살표를 보내게끔 할 수 있다. UserService에서 KakaoLogin, NaverLogin으로 일방적으로 향하던 화살표의 방향이 역전되어 모두가 Login을 향하고 있다. 이를 의존성 역전, Dependency Inversion이라고 한다.
인터페이스는 기본적으로 활용도가 높고 흔하게 사용된다. 계속 사용하다보니 아쉬운 부분이 생긴다. 그래서 Java 8부터 기능이 강화된다.
인터페이스의 함수 기능 제공 추가
Java 8부터 추상 메서드로만 이루어져있던 인터페이스가 구현체를 가질 수 있게된다.
default method
인터페이스에서 메서드를 선언하고 구현부를 정의하려고 하면 컴파일 에러가 뜬다. 앞에 default를 붙이면 구현부를 구현할 수 있게된다.
public interface MyInterface {
default void sayHello() {
System.out.println("Hello");
}
}
그래도 여전히 Override 또한 가능하다.
public class MyMain implements MyInterface {
@Override
void sayHello() {
System.out.println("Hello from the other side");
}
}
default method가 없이 MyInterface가 다음과 같이 두 개의 메서드가 있다고 가정해보자.
public interface MyInterface {
void sayHello();
void sayBye();
}
해당 인터페이스를 구현하는 구현체에선 두 메서드를 반드시 구현해줘야한다. 내가 안 쓰더라도. Hello만을 말하고 싶은 구현체가 Bye도 구현해야하는데, 원하지 않는 메서드를 담고싶지 않다면 다음과 같이 어댑터를 중간에 껴줄 수 있다.
public class MyInterfaceAdapter implements MyInterface {
@Override
public void sayHello() {}
@Override
public void sayBye() {}
}
이렇게 인터페이스가 수신할 수 있는 메세지인 메서드가 많을 때는 어댑터를 이용해 용도에 맞게 원하는 부분만 구현할 수 있다. SWING, Java FX 등에서 많이 사용된다. 디자인 패턴의 어댑터 패턴이랑은 약간은 다른 듯하다.
이러한 어댑터의 문제는 무엇인가? 상속을 이용하기 때문에 상속을 하나 밖에 받지 못 하는 Java 같은 언어에서는 어댑터를 하나 밖에 사용하지 못 한다. 만약 단순히 인터페이스에 많은 메서드가 들어가 있다면 어댑터는 인터페이스의 모든 메서드를 기본적으로 구현해주면 된다. 그치만 다른 클래스를 상속받고 있는 객체에 어댑터를 쓰지 못 하니, MyInterface를 쓰면 결국 해당 객체의 메서드는 작동하지 않는 구현부를 작성해야하고 코드가 지저분해진다.
그래서 default method가 등장했다. 클래스는 여러 인터페이스를 implement 할 수 있으니, 어댑터의 기능을 그대로 가져오면서 어댑터의 단점을 없앴다.
이번엔 다른 예시로, 동물들의 특징을 수영, 걷기, 날기로 생각해 이를 인터페이스로 만들어보자.
public interface Flyable {
void fly();
}
public interface Swimmable {
void swim();
}
public interface Walkable {
void walk();
}
이제 이러한 행위적 특징을 가진 구체적인 동물 객체 중 오리와 백조를 만들어봤다.
public class Duck implements Swimmable, Walkable {
@Override
public void swim() {
System.out.println("swim");
}
@Override
public void walk() {
System.out.println("walk");
}
}
public class Swan implements Flyable, Walkable {
@Override
public void fly() {
System.out.println("fly");
}
@Override
public void walk() {
System.out.println("walk");
}
}
Java 8 이전엔 보시다시피 같은 메세지를 수신하더라도 중복적으로 구현해야했다. default method를 이용하면 아래와 같이 오리와 백조를 나타낼 수 있다.
public interface Flyable {
default void fly() {
System.out.println("fly");
}
}
public interface Swimmable {
default void swim() {
System.out.println("swim");
}
}
public interface Walkable {
default void walk() {
System.out.println("walk");
}
}
public class Duck implements Swimmable, Walkable {}
public class Swan implements Flyable, Walkable {}
이러한 default method의 장점은 확장이 매우 쉽다. 백조의 특징으로 수영할 수 있다는 점이 있다는 것을 깨닫게 되어 해당 인터페이스를 추가하자면 다음과 같다.
public class Swan implements Flyable, Walkable, Swimmable {}
기본 구현부가 있으니 인터페이스 추가만으로 이처럼 매우 매우 쉬운 확장을 할 수 있다. 기본적으로, default method가 하나로만 제한되는 상속의 단점을 없애준다.
static method
default method가 되고, 또 static method 또한 인터페이스가 가질 수 있게된다. 둘의 차이는 무엇일까? static method는 전역 메서드로 해당 인터페이스의 모든 구현체가 해당 메서드에 접근할 수 있다. default method는 구현부가 이미 정의된 것이다. static method는 전역 메모리에 올라 있으니 override 불가하다.
public interface MyInterface {
static void sayFromWorld() {
System.out.println("Hello, World");
}
}
기존의 인터페이스는 추상 메서드만을 담은 추상체다. 객체 간의 메세지 수신을 위해 어떤 메세지를 수신할 수 있는지 메서드만 담았던 추상체인 그릇이다. Java 8 이후로는 인터페이스가 default method와 static method로 메서드, 즉, 함수를 제공할 수 있게 되었다. 하지만 Java 8부터 인터페이스는 단순히 함수 제공자만 될 수 있는 것이 아니다. 아예 함수가 될 수 있었다.
Functional Interface
다음과 같은 인터페이스를 만들었다.
public interface MySupply {
String supply();
}
위의 인터페이스는 추상 메서드가 하나 밖에 없는 인터페이스다. 이를 자바에선 함수형 인터페이스라고 한다. 아래와 같이 애너테이션을 통해 명시적으로 표현할 수도 있다.
@FunctionalInterface
public interface MySupply {
String supply();
}
@FunctionalInterface
는 선택이다. 추상 메서드가 하나 밖에 없는 인터페이스는 해당 애너테이션이 있건 없건 함수형 인터페이스이다. 한 눈에 이해되니 무엇이든 명시해주는 것은 항상 좋다고 생각한다.
여기에 default method와 static method를 추가해보자.
@FunctionalInterface
public interface MySupply {
String supply();
default void sayHello() {
System.out.println("Hello");
}
static void sayBye() {
System.out.println("Bye");
}
}
위의 MySupply은 함수형 인터페이스일까? 맞다. 함수형 인터페이스는 추상 메서드만 하나면 된다. default method와 static method는 추상 메서드가 아니기 때문에 가능하다.
이제 MySupply의 구현체를 만들어보자.
public class Hello implements MySupply {
@Override
public String supply() {
return "Hello";
}
}
public class MyMain {
public static void main(String[] args) {
new Hello().supply(); // Hello
}
}
간단하다. 우리는 supply()
를 구현하기 위해 Hello라는 구현체를 만들어줬다. 왜냐하면, MySupply는 인터페이스이기 때문에 인스턴스를 생성해 구현을 할 수가 없고 원하는 구현이 있을 땐 위와 같이 구현체를 통해 구현을 해야한다.
함수형 인터페이스와 같이 간단하게 추상 메서드 하나만으로 이루어진 인터페이스를 상황마다 다르게 활용하려면 매번 새로운 클래스를 만들어야 한다. 물론 인터페이스의 구현체는 항상 있는 것이 좋다고 생각한다. 하지만 하나의 단순한 인터페이스가 항상 다른 클래스를 직접 만들어 구현을 쓰고 인스턴스를 생성하는 과정이 번거로울 수 있고, Java 8 이전의 개발자들이 그렇게 느꼈다.
익명 클래스
조금 다르게 생각해보자. 실행하려는 호스트 클래스와 인터페이스의 구현체인 클래스를 분리해서 생성하지말고, 한 번에 생성하면 어떨까? 아래와 같이 말이다.
public class MyMain {
public static void main(String[] args) {
new class MyObject implements MySupply {
@Override
public String supply() {
return "Hello";
}
}.supply();
}
}
괴랄한 코드처럼 보일 수도 있다. 그냥 다 합쳤다고 생각하면 된다. MySupply를 구현한 MyObject 안에 supply()
메서드를 오버라이드해서 구현하고, 그 앞에 new
를 붙여 곧바로 MyObject의 인스턴스를 생성한다고 상상했다. 그리고는 뒤에 해당 인스턴스의 메서드인 supply()
를 실행한다고 생각했다. 인터페이스 구현과 인스턴스 생성, 메서드 호출까지 한 번에 들어간 코드다. 물론 동작하는 코드는 아니다. 아무튼 어차피 단순한 함수형 인터페이스이면 이렇게 즉각적으로 실행을 하면 안될까?
이를 더 단순하게 생각해보자. 어차피 일회성 클래스인데 이름이 중요한가? 빼자. MySupply는 인터페이스인데 implements
하는 것은 당연하지 않은가? 빼자. implements
할 수 있는 대상은 class
이다. 당연한 것 아닌가? 빼자. class
, 클래스 이름, implements
가 빠진다. 아래와 같다.
public class Main {
public static void main(String[] args) {
new MySupply() { // 괄호는 그냥 그러려니 하자.
@Override
public String supply() {
return "Hello";
}
}.supply();
}
}
이렇게 이름없는 클래스를 임의로 생성한다고 가정하고 new
로 인스턴스를 생성해 해당 인스턴스의 supply()
를 실행하는 코드를 만들었다. 이는 실제로 동작하는 코드다. 이런 이름없는 클래스를 익명 클래스라고 한다.
다형성을 기억하자. 인터페이스를 구현한 클래스는 인터페이스로 타입을 지정할 수 있다. 그럼 위와 같은 코드는 아래와 같이 분리될 수 있다.
public class Main {
public static void main(String[] args) {
MySupply sup = new MySupply() {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
이렇게 하니 더욱 쉽게 이해되지 않은가? 이것이 함수형 인터페이스의 익명 클래스 활용이다. 인터페이스의 인스턴스를 만들어 구현부를 바로 정의할 수 있다.
익명 클래스는 중첩이 가능하다.
public class Main {
public static void main(String[] args) {
MySupply sup1 = new MySupply() {
@Override
public String supply() {
MySupply sup2 = MySupply() {
@Override
public String supply() {
return "Hello, Bye";
}
}
return sup2.supply();
}
};
sup1.supply();
}
}
그저 구현부 내에 같은 방식으로 익명클래스를 생성한 것이니 어찌보면 당연한 말이다. 이처럼 인터페이스를 익명클래스를 만들어 처리할 수 있다면, 메서드도 익명으로 만들 수 없을까?
Lambda Expression
함수형 인터페이스는 추상 메서드가 하나 밖에 없는 인터페이스이다. 함수형 인터페이스라면 구현해야하는 다른 함수는 없다는 사실은 분명하다. 우리가 어떤 함수형 인터페이스를 구현하면 그 구현은 한 메서드의 구현일 것이다.
익명 클래스를 다시 한 번 살펴보자.
public class Main {
public static void main(String[] args) {
MySupply sup = new MySupply() {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
당연한 사실들을 빼서 익명 클래스가 더욱 간단하게 표현될 수 있었다. 사실 여기서 더 뺄 수 있는 당연한 것들이 있다. MySupply가 타입인 익명 클래스가 구현하는 함수형 인터페이스는 당연히 타입인 MySupply이지 않은가? MySupply()
를 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = new () {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
변수에 할당할 수 있는 것은 인스턴스가 당연하니, new
도 당연하지 않은가? 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = () {
@Override
public String supply() {
return "Hello";
}
};
sup.supply();
}
}
인터페이스이니 구현이 필요하단 것은 당연한 사실 아닌가? 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = ()
@Override
public String supply() {
return "Hello";
}
sup.supply();
}
}
인터페이스의 구현체는 인터페이스의 메서드를 Override해서 구현해야하는 것이 당연하지 않은가? 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = ()
public String supply() {
return "Hello";
}
sup.supply();
}
}
함수형 인터페이스는 추상 메서드가 한 개 밖에 없는 인터페이스이다. 위에 말했던 것처럼, 함수형 인터페이스에 구현해야할 것은 하나의 메서드 밖에 없는 것은 당연하다. 빼자.
public class Main {
public static void main(String[] args) {
MySupply sup = () {
return "Hello";
}
sup.supply();
}
}
이제 남은 것은 괄호 두개 밖에 없다. 약간 구분을 해주기 위해 괄호 두개 사이에 화살표를 넣어주고, 명령이 끝나는 부분에 ;
를 넣어주자.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> {
return "Hello";
};
sup.supply();
}
}
수행해야 할 구현체의 구문이 return "Hello"
밖에 없으니 중괄호도 필요없다. 어차피 “Hello”를 return하는 것이 항상 당연한 사실인데, return도 필요 없다.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> "Hello";
sup.supply();
}
}
이것이 함수형 인터페이스, 익명 메서드를 사용해서 표현하는 방법인 람다 표현식이다. 이는 함수형 인터페이스이기 때문에 가능했다. 함수형 인터페이스가 하나의 메서드를 가진 것이 당연했기 때문이다. 메서드가 여러개인 인터페이스라면, 당연히 사용할 수 없다.
구현체 부분에 더 많은 표현을 하고 싶다면 어떻게 하면될까? 그러면 중괄호를 빼지 않고 기존의 메서드를 구현하는 방식으로 하면 된다.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> {
String world = "World";
return "Hello" + " " + world;
};
sup.supply();
}
}
매번 클래스를 생성하는 것보다 익명 클래스를 사용하는 것이, 익명 클래스를 사용하는 것보다 람다 표현식을 사용하는 것이 더욱 간결해진다. 람다 표현식을 더 살펴보기 위해 아래와 같이 인터페이스를 추가해보자
public interface MyMapper {
int map(String item);
}
public interface MyConsumer {
void consume(int item);
}
public interface MyRunnable {
void run();
}
이제 메인 함수로 가서 Supplier, Mapper, Consumer의 구현체를 람다 표현식으로 만들어보자.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> "Hello";
MyMapper map = (i) -> str.length;
MyConsumer con = (i) -> System.out.println(i);
}
}
그리고 마지막에 runnable로 이 람다 표현식들을 연쇄적으로 호출해보자.
public class Main {
public static void main(String[] args) {
MySupply sup = () -> "Hello";
MyMapper map = (str) -> str.length;
MyConsumer con = (i) -> System.out.println(i);
MyRunnable run = () -> con.consume(map.map(sup.supply())); // 5 - Hello의 길이
}
}
이렇게 보기만해도 간단한 활용이 될 수 있지 않겠는가? 실제로 Java 8 이후에 나온 많은 기능들이 이런 람다 표현식을 굉장히 많이 쓴다. Supplier, Mapper, Consumer 등의 함수형 인터페이스도 실제로 존재하고, 다른 기본적인 함수형 인터페이스 또한 존재하니 연습해보면 람다 표현식을 더욱 자유자재로 사용할 수 있다.
Method Reference
람다 표현식을 더욱 간편하게 쓰는 방법 또한 있다. Mapper와 Consumer를 다시 보자.
public class Main {
public static void main(String[] args) {
MyMapper map = (str) -> str.length;
MyConsumer con = (i) -> System.out.println(i);
}
}
MyMapper는 인자를 받아 문자열 클래스의 length()
함수를 실행한다. MyConsumer는 인자를 받아 System.out
클래스의 println()
함수를 실행한다. 이렇게 인자를 받아 해당 인자 클래스의 함수만을 실행하거나, static 함수를 실행할 때 해당 인자만을 이용하거나 할 땐 더욱 간단하게 표현할 수 있다.
public class Main {
public static void main(String[] args) {
MyMapper map = String::length;
MyConsumer con = System.out::println;
}
}
이와 같이 람다 표현식에서 입력되는 값을 변경없이 바로 사용하는 경우, 최종으로 적용될 메서드의 레퍼런스를 지정해 주는 표현 방식이다. 변경이 일어나는 경우는 어떤 경우일까?
public class Main {
public static void main(String[] args) {
MyMapper map = String::length;
MyConsumer con = (i) -> System.out.println(i * 10);
}
}
위와 같은 경우는 인자로 들어가는 i값을 10을 곱하는 변경을 가해준다. 이럴 때는 되지 않는다.
메서드 레퍼런스를 사용하면 좋은점이 무엇일까? 메서드 레퍼런스를 사용하면 람다 표현식에 추가로 당연한 것이 더 생긴다. 입력값이 변하지 않는다는 것이 당연하다는 사실이 표시된다. 메서드 레퍼런스를 사용함으로써, 입력값을 변경하지 말라는 메세지를 줄 수도 있고, 표현 방식에 인자가 보이지 않으니 의도치 않은 인자의 변화를 차단함으로써 안정성을 더욱 올릴 수도 있다.
MySupplier는 String을 공급하는 supply()
함수를 가지고 있다. 그러다 문자열이 아닌 정수를 공급하고 싶어지면 어떡할까? MyMapper에서 String의 인자를 받아 int를 반환하는게 아닌, 반대로 int 변수를 인자로 받아 String으로 반환하고 싶으면 어떡할까? 인터페이스를 새로이 만들어야할까?
Generic
MyMapper를 다시 보자.
public interface MyMapper {
int map(String item);
}
MyMapper에는 현재 Input으로 들어오는 인자의 타입은 String이고 Output으로 반환되는 타입은 int이다. 더욱 보기 쉽게 다음과 같이 바꿀 수 있다.
public interface MyMapper {
OUT map(IN in);
}
MyMapper를 사용할 때엔 어떤 타입이 Input이 되고 Output이 될지 알려줄 방법이 있어야한다. 다음과 같이 표현해 사용할 수 있다.
public interface MyMapper<IN, OUT> {
OUT map(IN in);
}
public class Main {
public static void main(String[] args) {
MyMapper<String, Integer> map = String::length;
}
}
이런 식으로 사용될 인자를 지정하지 않고, 외부에서 정해줄 수 있게 포괄적으로 열어두는 것이 Generic이다. MyConsumer와 MySupplier도 Generic을 이용해 아래와 같이 바꿔줄 수 있다.
public interface MyConsumer<T> {
void consume(T t);
}
public interface MySupplier<T> {
T supply();
}
T는 타입의 T이다. 이외에도 Key의 K, Value의 V 등, Generic을 사용할 때 대표적으로 사용되는 의미들이 있으니 찾아보면 좋을 것 같다. 그렇다면 이 Generic을 이용한 함수형 인터페이스는 어떻게 활용할 수 있을까?
인터페이스의 원하는 구현체를 넘겨주는 역할을 하는 것은 호스트 코드이다. 구현체를 넘겨 받아 작업을 진행하는 객체는, 호스트 코드에게 의존성을 주입 받으면서 해당 인터페이스의 모든 객체들에 대한 의존성을 낮출 수 있다. 객체는 아니지만, 함수로 예를 들어보겠다.
public class Main {
public static void main(String[] args) {
System.out.println(loop(10)); // 45
}
public int loop(int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += i;
}
return sum;
}
}
위의 코드에서 loop 함수가 하는 일은, 주입 받은 n만큼 모든 수를 더해 합을 반환한다. 얼만큼 루프를 돌릴지는 main 함수가 결정을 하지만, 루프 함수 안에서 무엇을 할지는 loop 함수 내부적으로 알아야한다. 함수형 인터페이스와 람다 표현식을 이용해 이 loop 함수가 온전히 loop에 집중할 수 있도록 바꿀 수 있다.
public class Main {
public static void main(String[] args) {
int sum = 0;
loop(10, (i) -> sum + i);
System.out.println(sum);
}
public void loop(int n, MyConsumer<Integer> consumer) {
for (int i = 0; i < n; i++) {
consumer.consume(i);
}
}
}
위처럼 루프를 돌릴 때마다 어떤 업무를 수행할지 main 함수가, 호스트 코드가 결정해서 내려줄 수 있다. loop 함수는 주입 받은 n번만큼 주입 받은 consumer를 실행해주면 된다. loop 함수의 입장에서 “나는 루프를 돌리기만 할거야. 루프를 돌릴 때마다 어떤 것을 할지는 정해서 알려줘”라고 스탠스를 확실하게 정할 수 있다. 참고로 Generic이 사용된 인터페이스 등을 사용할 때는 int 같은 원시 타입을 사용하지 못하고, Integer 등의 Wrapper를 사용해야한다.
이렇게 활용할 수 있는 함수형 인터페이스가 Java 8 이후로는 많고, 너무나도 많이 활용되고 있다.
java.util.function (Java SE 17 & JDK 17)
package java.util.function Functional interfaces provide target types for lambda expressions and method references. Each functional interface has a single abstract method, called the functional method for that functional interface, to which the lambda expr
docs.oracle.com
이번엔 Java의 함수형 인터페이스를 이용해 간단한 구현을 해보자.
public class Main {
public static void main(String[] args) {
filteredNumers(
10,
i -> i % 2 == 0,
System.out::println
);
}
void filteredNumbers(int n, Predicate<Integer> predicate, Consumer<Integer> consumer) {
for (int i = 0; i < n; i++) {
if (predicate.test(i)) {
consumer.accept(i);
}
}
}
}
Predicate는 구현부의 결과가 true인지 false인지 반환한다. 0부터 9까지 반복하는데, 만약 짝수라면 그 수를 출력하는 간단한 함수이다. main 함수에선 어떤 행위를 얼마나 할 지 모든 것을 결정해서 수행하라는 메세지를 전달할 수 있고, 메세지를 받는 filteredNumbers()
는 인터페이스들의 메서드만을 수행시키면 된다. 인터페이스들의 구현이 어떠하더라도 말이다.
References
프로그래머스 데브코스 - 프레임워크를 위한 Java
'Java' 카테고리의 다른 글
자바 Collection 이야기 (0) | 2024.07.16 |
---|---|
빌드툴 없이 Java 프로젝트 빌드 해보기 (0) | 2023.11.29 |
Java, JVM, JRE, JDK (2) | 2023.11.29 |