Oracle Secure Coding Guidelines for Java SE의 DOS-2 지침을 공부하면서 Execute Around Method 패턴을 이용한 자원 관리 방법을 살펴봤습니다.

이 패턴은 자원의 획득과 반환을 담당하는 메서드 안에서 외부로부터 전달받은 작업을 실행하는 방식으로, Java 8부터 지원하는 람다식을 활용하여 구현할 수 있습니다.

그런데 막상 람다식을 활용해 코드를 작성하고 설명하려니 생각보다 쉽지 않았습니다. 람다식이 무엇인지는 대충 알고 있었지만, 실제로 많이 사용해 보지는 않았습니다.

개발 실무보다는 관리 업무에 집중하면서 직접 코드를 작성하는 일이 거의 없어졌기 때문에 감이 떨어진 것도 이유일 것입니다.

보안 취약점을 공부하다가 뜻하지 않게 Java 기본기부터 다시 공부하게 되었습니다. 특히 생소하게 느껴졌던 @FunctionalInterface가 어떤 역할을 하는지, 람다식으로 전달된 코드는 언제 실행되는지 이해할 수 있었습니다.

이왕 어렵게 공부한 만큼 잊지 않고 오래 기억하고 싶어 포스팅으로 남기게 되었습니다.


1. 함수형 인터페이스(Functional Interface)란?

Java에서 함수형 인터페이스(Functional Interface)는 추상 메서드가 하나인 인터페이스를 의미합니다. 일반적인 인터페이스는 여러 개의 추상 메서드를 정의할 수 있지만, 함수형 인터페이스는 하나의 추상 메서드를 통해 수행할 작업을 정의합니다.

이러한 추상 메서드의 구현을 람다식을 이용해 간결하게 표현할 수 있다는 것이 함수형 인터페이스의 가장 큰 특징입니다.

앞서 포스팅했던 [학습기록] Java 개발자가 알아야 할 서비스 거부(DoS) 취약점: (3) 시스템 자원 반환의 중요성에서 Execute Around Method 패턴을 설명하기 위해 다음과 같은 인터페이스를 정의했었습니다.

@FunctionalInterface
interface InputStreamHandler {
    long handle(InputStream in) throws IOException;
}

위 코드에서 InputStreamHandler는 handle()이라는 추상 메서드를 하나만 가지고 있으므로 함수형 인터페이스에 해당합니다.

그렇다면 @FunctionalInterface 어노테이션은 어떤 역할을 할까요?

이 어노테이션은 해당 인터페이스가 함수형 인터페이스라는 설계 의도를 명시하고, 컴파일러가 함수형 인터페이스의 조건을 검사하도록 하는 역할을 합니다.

예를 들어 다음과 같이 추상 메서드를 두 개 선언하면 컴파일 오류가 발생합니다.

@FunctionalInterface
interface InputStreamHandler {
    long handle(InputStream in) throws IOException;

    void reset();
}

함수형 인터페이스의 조건을 만족하지 않기 때문입니다.

여기서 새롭게 알게 된 사실은 @FunctionalInterface 어노테이션이 없어도 추상 메서드가 하나라면 함수형 인터페이스로 사용할 수 있으며, 람다식으로도 구현할 수 있다는 것입니다.

interface InputStreamHandler {
    long handle(InputStream in) throws IOException;
}

즉, @FunctionalInterface는 개발자의 설계 의도를 명시하고 이후 인터페이스가 잘못 변경되는 것을 컴파일 단계에서 확인하도록 돕는 어노테이션입니다. 함수형 인터페이스를 만들어 주는 어노테이션이 아니라는 의미입니다.

참고로 default 메서드나 static 메서드는 추상 메서드가 아니므로 함수형 인터페이스에 추가할 수 있습니다.

@FunctionalInterface
interface InputStreamHandler {
    long handle(InputStream in) throws IOException;

    default void log() {
        System.out.println("Processing...");
    }
}

위 코드에서는 log() 메서드가 추가되었지만, 추상 메서드는 여전히 handle() 하나이므로 함수형 인터페이스의 조건을 만족합니다.


2. 람다식(Lambda Expression)이란?

람다식(Lambda Expression)은 메서드의 구현을 간결하게 표현할 수 있도록 Java 8부터 도입된 문법입니다.

함수형 인터페이스의 추상 메서드를 구현할 때 별도의 클래스를 작성하거나 익명 클래스를 사용하는 대신 람다식으로 코드를 간단하게 작성할 수 있습니다.

앞서 정의했던 InputStreamHandler를 다시 살펴보겠습니다.

@FunctionalInterface
interface InputStreamHandler {
    long handle(InputStream in) throws IOException;
}

handle() 메서드에 파일을 읽어 바이트값을 합산하는 로직을 구현한다고 가정하겠습니다. 람다식이 도입되기 전에는 다음과 같이 익명 클래스를 이용하여 구현할 수 있었습니다.

InputStreamHandler handler = new InputStreamHandler() {
    @Override
    public long handle(InputStream in) throws IOException {
        long current = 0;

        int b;
        while ((b = in.read()) != -1) {
            current += b;
        }

        return current;
    }
};

하지만 람다식을 사용하면 익명 클래스 선언과 메서드 선언을 생략할 수 있어 코드가 간결해집니다. 다만 람다식과 익명 클래스는 완전히 동일하지 않습니다. 예를 들어 this가 참조하는 대상이나 변수의 유효 범위 등에서 차이가 있습니다.

InputStreamHandler handler = in -> {
    long current = 0;

    int b;
    while ((b = in.read()) != -1) {
        current += b;
    }

    return current;
};

람다식의 기본 문법은 다음과 같습니다.

(매개변수) -> { 실행할 코드 }

여기서 ->를 기준으로 왼쪽에는 매개변수를, 오른쪽에는 실행할 코드를 작성합니다.

매개변수가 하나이고 타입을 생략할 수 있는 경우에는 괄호도 생략할 수 있기 때문에 (in) -> { ... } 대신 in -> { ... }로 작성할 수 있습니다.

앞선 예제의 in -> { ... }에서 in은 handle(InputStream in) 메서드의 매개변수에 해당합니다.

또한 람다식의 반환값은 함수형 인터페이스의 추상 메서드에 정의된 반환 타입과 호환되어야 합니다. 따라서 handle() 메서드의 반환 타입이 long이므로 람다식에서도 long 값을 반환하도록 작성했습니다.


3. 람다식은 언제 실행되는가?

함수형 인터페이스는 이번에 새롭게 알게 된 개념이지만, 람다식은 앞서 설명한 정도의 기본적인 문법은 알고 있었습니다.

하지만 람다식이 언제 실행되는지에 대한 이해가 잘못되어 있어서 어려움을 겪었습니다.

아래 코드는 InputStreamHandler 함수형 인터페이스를 이용하여 람다식을 readFileBuffered() 메서드의 인자로 전달하는 예제입니다.

먼저 파일 스트림을 관리하는 메서드입니다.

public long readFileBuffered(
        Path path,
        InputStreamHandler handler) throws IOException {

    try (InputStream in = new BufferedInputStream(
            Files.newInputStream(path))) {

        return handler.handle(in);
    }
}

다음은 이 메서드를 호출하면서 람다식을 전달하는 코드입니다.

long sum = readFileBuffered(path, in -> {
    long current = 0;

    int b;
    while ((b = in.read()) != -1) {
        current += b;
    }

    return current;
});

처음에는 readFileBuffered() 메서드를 호출할 때 인자로 전달하는 람다식 내부의 코드가 먼저 실행되는 것으로 알고 있었습니다.

일반적인 메서드 호출에서는 인자로 전달하는 표현식이 먼저 평가되기 때문에 람다식 내부의 코드도 먼저 실행된다고 잘못 이해하고 있었던 것입니다.

그래서 코드의 동작이 설명되지 않았습니다.

하지만 람다식을 인자로 전달하는 것과 람다식 내부의 코드를 실행하는 것은 서로 다른 작업입니다. 람다식은 실행할 작업을 정의한 것이므로, 변수에 대입하거나 메서드의 인자로 전달한다고 해서 내부 코드가 즉시 실행되지는 않습니다.

이 메서드에서는 파일 스트림을 생성한 후 handler.handle(in)을 호출하고 있습니다. 여기서 handler는 메서드의 인자로 전달받은 람다식을 InputStreamHandler 타입으로 참조하는 변수입니다.

따라서 handler.handle(in)이 호출되는 시점에 비로소 람다식 내부의 코드가 실행됩니다. 이 차이를 이해하고 나서야 비로소 코드의 동작을 설명할 수 있었습니다.

실제 코드의 실행 순서를 정리하면 다음과 같습니다.

  1. readFileBuffered()를 호출하면서 람다식을 인자로 전달합니다.
  2. readFileBuffered() 메서드 내부에서 파일 스트림을 생성합니다.
  3. handler.handle(in)을 호출합니다.
  4. 전달받은 람다식 내부의 코드가 실행되어 파일을 읽고 바이트값을 합산합니다.
  5. 람다식에서 계산한 결과를 handle() 메서드의 반환값으로 전달합니다.
  6. try-with-resources에 의해 파일 스트림을 닫은 후 readFileBuffered()의 최종 결과가 반환됩니다.

결국 람다식의 문법 자체보다 람다식을 전달하는 시점과 실제로 실행하는 시점을 구분하지 못했던 것이 어려움의 원인이었습니다.

역시 개념을 대충 알고 있다는 것과 실제 코드의 동작을 정확하게 이해하고 있다는 것은 다른 이야기인 것 같습니다.