관리 메뉴

나만을 위한 블로그

[Kotlin] 함수형(SAM) 인터페이스란? 본문

개인 공부/Kotlin

[Kotlin] 함수형(SAM) 인터페이스란?

참깨빵위에참깨빵 2023. 9. 19. 00:59
728x90
반응형

람다를 공부하면 필연적으로 나오는 개념인 함수형 인터페이스에 대해서 정리한다. 아래는 함수형 인터페이스를 설명하는 코틀린 공식문서다.

 

https://kotlinlang.org/docs/fun-interfaces.html

 

Functional (SAM) interfaces | Kotlin

 

kotlinlang.org

추상 메서드가 하나뿐인 인터페이스를 함수형 인터페이스 또는 SAM(Single Abstract Method) 인터페이스라고 한다. 함수형 인터페이스에는 여러 비추상 멤버가 있을 수 있지만 추상 멤버는 하나만 존재할 수 있다. 코틀린에서 함수형 인터페이스를 선언하려면 fun 키워드를 사용한다
fun interface KRunnable {
    fun invoke()
}
함수형 인터페이스는 람다식을 써서 코드를 더 간결하고 읽기 쉽게 만드는 데 도움이 되는 SAM 변환을 쓸 수 있다. 함수형 인터페이스를 수동으로 구현하는 클래스를 만드는 대신 람다식을 쓸 수 있다. SAM 변환을 통해 코틀린은 인터페이스의 단일 메서드 시그니처와 일치하는 시그니처가 존재하는 모든 람다식을 코드로 바꿔서 인터페이스 구현을 동적으로 인스턴스화할 수 있다
fun interface IntPredicate {
   fun accept(i: Int): Boolean
}
위와 같은 함수형 인터페이스가 있다고 가정할 경우, SAM 변환을 사용하지 않는다면 아래와 같은 코드를 작성해야 한다
// 클래스의 인스턴스 생성
val isEven = object : IntPredicate {
   override fun accept(i: Int): Boolean {
       return i % 2 == 0
   }
}
이것을 코틀린의 SAM 변환을 사용할 경우 아래와 같은 코드로 바꿀 수 있다
// 람다를 통해 인스턴스 생성
val isEven = IntPredicate { it % 2 == 0 }
짧은 람다식은 불필요한 코드를 모두 대체할 수 있다. 자바 인터페이스에 SAM 변환을 쓸 수도 있다
fun interface IntPredicate {
   fun accept(i: Int): Boolean
}

val isEven = IntPredicate { it % 2 == 0 }

fun main() {
   println("Is 7 even? - ${isEven.accept(7)}")
}

// >> Is 7 even? - false

 

공식문서에서 말하듯 SAM은 단일 추상 메서드(Single Abstract Method)의 약자로, 인터페이스 안에 단 하나의 추상 메서드가 존재할 경우 이 인터페이스 클래스를 이르는 말이다. 이름에서 알 수 있듯 인터페이스에 둘 이상의 추상 메서드가 존재하면 그것은 SAM 인터페이스라고 부를 수 없게 된다.

SAM 인터페이스라는 말은 코틀린에서 생겨난 개념이 아니라 저 옛날 자바 8이 나타났을 때부터 존재하던 말이다. 자바 8 이전에는 람다식이 지원되지 않아서 당시 개발자들은 익명 내부 클래스를 사용해 함수를 매개변수로 전달하거나 콜백을 사용하곤 했다. 그리고 익명 내부 클래스를 사용할 때 하나의 인터페이스에 여러 메서드가 있을 경우 처리하기가 까다로워서 하나의 인터페이스에 하나의 추상 메서드만 존재하는 패턴이 사용되었다. 이후 람다식이 도입된 자바 8이 공개되면서 Runnable이나 Callable 같은 인터페이스를 SAM 인터페이스를 통해 람다로 간단히 표현할 수 있게 되었다.

아래는 자바에서 람다를 사용한 SAM의 예시다.

 

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) {
        Client client = new Client();
        client.execute();
    }
}

class Worker {
    ExecutorService executor = Executors.newSingleThreadExecutor();

    public <T> Future<T> invoke(Callable<T> runnable) {
        return executor.submit(runnable);
    }
}

class Client {
    public void execute() {
        Worker worker = new Worker();
        worker.invoke(() -> {
            System.out.println("람다식 작동!!");
            return "";
        });
    }
}

 

이 코드는 리턴되는 공백 문자열을 사용하지 않는 예시다. Worker 클래스의 invoke()에서 Callable을 submit하면 Future<T> 객체가 반환될 것이다. 자바에서 Future는 비동기 작업의 결과를 나타내는 클래스로, 결과를 가져오려면 get()을 호출해야 한다.
그러나 위 코드에선 호출하지 않았고, ExecutorService는 작업을 비동기적으로 다른 쓰레드에서 실행하기 때문에 메인 쓰레드가 빨리 종료되면 ExecutorService의 워커 쓰레드가 완료되기 전에 프로그램이 종료될 수 있다. 실제로 위 코드를 실행하면 "람다식 작동!!" 문자열만 콘솔에 출력되고 프로그램이 종료된다.

리턴되는 문자열을 사용하려면 아래와 같이 수정해야 한다.

 

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) {
        Client client = new Client();
        client.execute();
    }
}

class Worker {
    ExecutorService executor = Executors.newSingleThreadExecutor();

    public <T> Future<T> invoke(Callable<T> runnable) {
        return executor.submit(runnable);
    }
}

class Client {
    public void execute() {
        Worker worker = new Worker();
        Future<String> strFuture = worker.invoke(() -> {
            System.out.println("람다식 작동!!");
            return "result";
        });

        try {
            String result = strFuture.get();
            System.out.println("결과 : " + result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
}

 

이 코드를 실행하면 아래와 같은 결과를 콘솔에서 확인할 수 있다.

 

 

본론으로 돌아와서, 결론은 SAM 인터페이스란 용어는 코틀린에서 새로 생긴 용어가 아니라 자바 8부터 존재했던 용어다.

그럼 이걸 왜 쓸까? 개발자들이 쓸모없는 걸 만들진 않았을 것이다.

 

간결성과 타입 추론

 

공식문서에서도 말하듯 SAM 인터페이스를 사용하면 코드를 좀 더 간결하게 만들 수 있다. 자바에서 익명 내부 클래스를 사용했을 땐 꽤 긴 코드를 작성해야 했다. 자바로 안드로이드 개발을 하던 시절, 뷰의 클릭 리스너를 구현하려면 아래와 같은 코드를 작성했어야 했다.

 

button.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        // 버튼 클릭 시 호출할 로직 작성
    }
});

 

버튼 뿐 아니라 FAB나 다른 클릭 가능한 뷰가 많아질수록 저러한 코드도 많아졌다. 그러다 보니 쓸데없이 코드가 길어져서 클릭 리스너 부분을 공통 함수로 빼서 사용하는 경우도 자주 있었다.

그러나 람다와 SAM 인터페이스를 사용한다면 아래와 같이 간결하게 변경된다.

 

button.setOnClickListener(v -> {
    // 버튼 클릭 시 호출할 로직 작성
});

 

자바로 안드로이드 개발을 하고 자바 8 이상을 사용한다면, 처음 클릭 리스너 코드를 작성할 경우 IDE에서 2번째 코드처럼 바꿀 수 있다고 권장하는 걸 볼 수 있다.

이렇게 되면 컴파일러가 타입을 자동으로 추론할 수 있게 되어 타입을 명시적으로 선언할 필요가 줄어든다. 필요한 경우에만 타입을 명시할 수 있게 되는 것이다.

 

지연 실행

 

람다를 통해서 특정 코드 블록을 전달하면 그 코드의 실행을 지연시킬 수 있게 된다. 그래서 필요한 때만 코드를 실행할 수 있게 된다.

이 장점이 필요한 경우는 무거운 작업을 수행하고 결과를 반환하는 함수를 작성할 때다. DB에 접속해서 데이터를 받아야 하는 경우가 그 예시다.

아래는 예시 코드다.

 

@FunctionalInterface
public interface HeavyWork {
    String HeavyCompute();
}

public class DelayedExecutor {
    private HeavyWork heavyWork;

    public DelayedExecutor(HeavyWork heavyWork) {
        this.heavyWork = heavyWork;
    }

    public String executeWhenNeeded() {
        return heavyWork.HeavyCompute();
    }
}

public class Main {
    public static void main(String[] args) {
        DelayedExecutor executor = new DelayedExecutor(() -> {
            System.out.println("연산 중...");
            return "연산 결과";
        });

        System.out.println("다른 작업 진행 중...");

        String result = executor.executeWhenNeeded();
        System.out.println("결과 : " + result);
    }
}

// 다른 작업 진행 중...
// 연산 중...
// 결과 : 연산 결과

 

위 코드를 코틀린으로 바꾸고 fun interface를 적용하면 아래처럼 작성할 수 있다. 실행하면 위 자바 코드와 같은 결과가 콘솔에 찍힌다.

 

fun interface HeavyWork {
    fun heavyCompute(): String
}

class DelayedExecutor(private val heavyWork: HeavyWork) {
    fun executeWhenNeeded(): String = heavyWork.heavyCompute()
}

fun main() {
    val executor = DelayedExecutor {
        println("연산 중...")
        "연산 결과"
    }

    println("다른 작업 진행 중...")

    val result = executor.executeWhenNeeded()
    println("결과 : $result")
}

 

두 코드 모두에서 executeWhenNeeded()는 필요한 시점에 수행할 수 있다. 추가로 코틀린 코드는 by lazy나 lateinit var 같은 지연 초기화 기법으로 리팩토링할 수도 있다. 이 포스팅의 코드들은 어디까지나 예시 코드고, 읽어 보면 굳이 써야 하나? 생각이 들 수 있다. 당연히 무조건 해야 하는 건 아니고 필요하다면 수행하는 게 가장 좋다.

 

fun interface HeavyWork {
    fun heavyCompute(): String
}

class DelayedExecutor(heavyWork: HeavyWork) {
    private val result by lazy {
        heavyWork.heavyCompute()
    }

    fun executeWhenNeeded(): String = result
}

fun main() {
    val executor = DelayedExecutor {
        println("연산 중...")
        "연산 결과"
    }

    println("다른 작업 진행 중...")

    val result = executor.executeWhenNeeded()
    println("결과 : $result")
}
fun interface HeavyWork {
    fun heavyCompute(): String
}

class DelayedExecutor(private val heavyWork: HeavyWork) {
    private lateinit var result: String

    fun executeWhenNeeded(): String {
        if (!::result.isInitialized) {
            result = heavyWork.heavyCompute()
        }
        return result
    }
}

fun main() {
    val executor = DelayedExecutor {
        println("연산 중...")
        "연산 결과"
    }

    println("다른 작업 진행 중...")

    val result = executor.executeWhenNeeded()
    println("결과 : $result")
}

 

by lazy를 사용한 코드의 경우 heavyWork 생성자 매개변수 앞의 "private val" 키워드가 사라져 있다. 이 이유는 by lazy로 생성된 프로퍼티(result)가 초기화되는 시점은 처음 사용될 경우고, 초기화된 이후에는 항상 같은 값을 리턴하게 된다. 즉 heavyWork 람다의 결과가 result 프로퍼티에 저장된다.

때문에 heavyWork 람다는 생성자 파라미터로만 존재하게 되고, 클래스 안의 다른 곳에서 heavyWork 람다를 직접 참조하는 곳이 없기 때문에 생성자 파라미터인 heavyWork 앞에 "private val" 키워드를 사용하지 않아도 되는 것이다.

만약 DelayedExecutor 클래스 안의 다른 함수 등에서 heavyWork 람다를 직접 참조하는 곳이 생긴다면 다시 "private val" 키워드를 붙이는 걸 고려해야 할 수 있다. 이 부분은 각자 코드의 성격에 따라 달라지니 참고하자.

반응형
Comments