본문 바로가기

람다 문법과 실행 시점

람다식의 매개변수를 쓰는 방법과 식 본문·블록 본문의 반환 규칙을 정리하고, 람다 본문이 실제로 실행되는 시점과 메서드 참조, 검사 예외를 던지는 코드를 람다로 넘기는 방법을 배웁니다.

지금까지 product -> product.stock() > 0이나 (label, value) -> label + "#" + (value * 2) 같은 람다를 써 왔습니다. 이렇게 짧은 람다는 읽기 쉽지만, 매개변수에 타입을 적고 싶거나 본문에 여러 문장이 필요해지면 규칙을 정확히 알아야 컴파일 오류를 이해할 수 있습니다. 이 글에서는 람다식을 매개변수, 화살표, 본문으로 나눠 문법을 정리하고, 본문 모양에 따라 달라지는 반환 규칙을 실제 오류 메시지와 함께 봅니다. 이어서 람다 본문이 실제로 언제 실행되는지, 이미 있는 메서드를 그대로 넘기는 메서드 참조, 검사 예외를 던지는 코드를 람다로 넘기는 방법을 차례로 다룹니다.

람다식의 세 부분: 매개변수, 화살표, 본문

람다식은 매개변수 -> 본문 모양입니다. 화살표 왼쪽에는 함수형 인터페이스의 메서드가 받는 매개변수를 쓰고, 오른쪽에는 그 메서드의 구현을 씁니다. 매개변수 부분은 다음 다섯 가지로 쓸 수 있습니다.

형태예쓰는 경우
매개변수 없음() -> 42Supplier, Runnable처럼 받는 값이 없을 때
하나, 타입 생략text -> text.length()가장 흔한 형태로, 괄호도 생략할 수 있음
여럿, 타입 생략(left, right) -> left + right매개변수가 둘 이상이면 괄호가 필요함
타입 명시(String text) -> text.length()타입을 코드에 드러내고 싶을 때
var 사용(var text) -> text.length()매개변수에 애노테이션을 붙일 때

타입을 생략해도 되는 것은 앞 글에서 본 대상 타입 덕분입니다. 람다가 Function<String, Integer> 자리에 쓰였다면 구현할 메서드는 Integer apply(String)이므로, 매개변수가 String이라는 것을 컴파일러가 이미 압니다. 타입을 직접 적는다면 대상 타입의 매개변수 타입과 정확히 같아야 합니다.

괄호를 생략할 수 있는 것은 매개변수가 정확히 하나이고 타입을 적지 않았을 때뿐입니다. 또 한 람다 안에서 타입을 적은 매개변수와 적지 않은 매개변수를 섞을 수 없고, var도 모든 매개변수에 쓰거나 아예 쓰지 않아야 합니다. 예를 들어 String을 반복할 횟수를 받는 BiFunction<String, Integer, String>에 첫 매개변수만 타입을 적으면 다음 오류가 납니다.

BiFunction<String, Integer, String> repeat = (String text, count) -> text.repeat(count);
MixedParams.java:5: error: invalid lambda parameter declaration
        BiFunction<String, Integer, String> repeat = (String text, count) -> text.repeat(count);
                                                     ^
  (cannot mix implicitly-typed and explicitly-typed parameters)
1 error

(var text, count)처럼 var를 하나에만 쓰면 괄호 안의 설명이 (cannot mix 'var' and implicitly-typed parameters)로 바뀐 같은 오류가 납니다. 괄호 없이 String text -> text.length()라고 쓰면 컴파일러가 람다로 읽지조차 못해서 ';' expected처럼 엉뚱해 보이는 오류를 냅니다. 타입을 적었다면 매개변수가 하나여도 괄호가 필요하다고 기억해 두면 됩니다. 람다 매개변수에 var를 쓰는 문법은 Java 11에서 추가되었습니다. 타입 이름을 길게 적지 않고도 (@Annotation var text)처럼 애노테이션을 붙일 자리를 만들 수 있다는 것이 쓰임새입니다.

이 글의 예제에는 java.util.function 패키지의 표준 함수형 인터페이스가 여럿 나옵니다. 지금은 다음 정도만 알면 충분하고, 전체 목록은 표준 함수형 인터페이스와 함수 조합에서 정리합니다.

인터페이스추상 메서드하는 일
Function<T, R>R apply(T t)T 하나를 받아 R을 돌려줌
BiFunction<T, U, R>R apply(T t, U u)T와 U를 받아 R을 돌려줌
BinaryOperator<T>T apply(T t1, T t2)같은 타입 둘을 받아 같은 타입을 돌려줌
Consumer<T>void accept(T t)T를 받아 쓰기만 하고 돌려주는 값이 없음
Supplier<T>T get()받는 값 없이 T를 돌려줌

식 본문과 블록 본문

화살표 오른쪽에는 식 하나를 쓰거나, 중괄호로 감싼 문장 블록을 씁니다. 식 하나를 쓰면 그 식의 값이 곧 반환값이므로 return을 쓰지 않습니다. 블록을 쓰면 보통 메서드 본문처럼 여러 문장을 쓰고, 값을 돌려줄 때 return을 씁니다. 다음 예제에서 두 가지를 비교해 보겠습니다.

src/LambdaSyntaxCatalog.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.function.Consumer;
import java.util.function.Function;

public final class LambdaSyntaxCatalog {
    record Label(String text, int width) {
    }

    public static void main(String[] args) {
        Function<String, Integer> length = text -> text.length();
        BinaryOperator<Integer> maximum = (left, right) -> Math.max(left, right);
        Function<String, Label> label = text -> {
            String normalized = text.trim();
            if (normalized.isEmpty()) {
                throw new IllegalArgumentException("label is blank");
            }
            return new Label(normalized, normalized.length());
        };
        List<String> events = new ArrayList<>();
        Consumer<String> record = event -> events.add(event);

        System.out.println(length.apply("java"));
        System.out.println(maximum.apply(4, 9));
        System.out.println(label.apply("  lambda  "));
        record.accept("created");
        System.out.println(events);
    }
}

출력 네 줄은 length, maximum, label의 반환값과 events 목록입니다. record는 값을 돌려주지 않는 Consumer라서 직접 출력할 것이 없으므로, 이벤트를 넣은 뒤의 목록을 찍어 결과를 확인합니다.

4
9
Label[text=lambda, width=6]
[created]

length와 maximum은 식 본문입니다. text.length()의 값 4와 Math.max(4, 9)의 값 9가 그대로 반환값이 되어 첫 두 줄에 출력됩니다.

label은 블록 본문입니다. 앞뒤 공백을 지우고, 빈 문자열이면 예외를 던지고, 아니면 Label을 만들어 돌려줍니다. " lambda "의 공백을 지우면 여섯 글자인 lambda가 남으므로 셋째 줄이 나옵니다. 여러 단계를 거치는 계산이라면 이렇게 블록으로 펼치고 중간 결과에 normalized 같은 이름을 붙이는 편이 읽기 쉽습니다.

블록 본문에는 정해진 규칙이 있습니다. 값을 돌려주는 인터페이스를 구현한다면, 정상적으로 끝나는 모든 경로가 return 값;으로 끝나야 합니다. label처럼 예외를 던지고 끝나는 경로는 정상적으로 끝나는 것이 아니므로 return이 없어도 됩니다. 어느 한 경로라도 return 없이 블록 끝에 도달하면 다음과 같은 오류가 납니다.

Function<Integer, String> sign = value -> {
    if (value > 0) {
        return "positive";
    }
    System.out.println("not positive");
};
MixedReturn.java:5: error: incompatible types: bad return type in lambda expression
        Function<Integer, String> sign = value -> {
                                         ^
    missing return value
1 error

반대 방향의 실수도 있습니다. Runnable처럼 반환 타입이 void인 인터페이스의 블록에서 return "done";처럼 값을 돌려주면 unexpected return value라는 설명이 붙은 같은 종류의 오류가 납니다. 또 return은 문장이므로 식 본문에는 쓸 수 없습니다. text -> return text.length()라고 쓰면 illegal start of expression 오류가 나고, return을 쓰고 싶다면 text -> { return text.length(); }처럼 블록으로 감싸야 합니다.

이런 오류를 형변환이나 의미 없는 return null;로 덮지 않는 것이 좋습니다. 경로마다 어떤 결과를 내야 하는지 다시 정하는 것이 먼저입니다. 실패도 정상적인 결과 가운데 하나라면, 이 글 뒤에서 만들 Result나 Optional처럼 실패를 값으로 나타내는 타입을 돌려주는 방법도 있습니다.

반환값이 없는 인터페이스와 식 본문

예제의 record를 다시 보겠습니다. events.add(event)는 boolean을 돌려주는 메서드인데, Consumer의 accept는 반환 타입이 void입니다. 그런데도 컴파일되는 것은 메서드 호출이 그 자체로 문장이 될 수 있는 식이기 때문입니다. 반환 타입이 void인 인터페이스에는 메서드 호출, 대입, ++와 --, new로 객체를 만드는 식처럼 문장으로 쓸 수 있는 식을 본문으로 쓸 수 있고, 이때 식의 값은 버려집니다. events.add가 돌려준 true도 그렇게 버려졌습니다.

반면 계산만 하는 식은 문장이 될 수 없으므로 void 인터페이스의 본문으로 쓸 수 없습니다.

Consumer<Integer> next = value -> value + 1;
ConsumerValue.java:5: error: incompatible types: lambda body is not compatible with a void functional interface
        Consumer<Integer> next = value -> value + 1;
                                                ^
    (consider using a block lambda body, or use a statement expression instead)
1 error

value + 1을 계산해 놓고 아무 데도 쓰지 않는 코드는 실수일 가능성이 높으니 컴파일러가 막는 것입니다. 실제로 하려던 일이 결과를 돌려주는 것이었다면 Consumer가 아니라 Function이나 UnaryOperator가 맞는 타입입니다.

람다를 만든다고 본문이 실행되지는 않는다

람다식을 평가하면 함수형 인터페이스를 구현한 객체가 생길 뿐, 본문은 실행되지 않습니다. 본문은 누군가 그 객체의 메서드(get, apply, accept 등)를 호출할 때 실행됩니다. 이 차이를 이용하면 비싼 계산을 정말 필요할 때까지 미룰 수 있습니다. 다음 예제는 기본 이름을 불러오는 메서드가 언제 실행되는지 출력으로 보여 줍니다.

src/LambdaTiming.java
import java.util.Objects;
import java.util.function.Supplier;

public final class LambdaTiming {
    static String loadDefaultName() {
        System.out.println("  loadDefaultName() runs");
        return "guest";
    }

    public static void main(String[] args) {
        Supplier<String> fallback = () -> loadDefaultName();
        System.out.println("supplier created");
        System.out.println("first get: " + fallback.get());
        System.out.println("second get: " + fallback.get());

        String name = "mina";
        System.out.println("requireNonNullElse");
        System.out.println("  result=" + Objects.requireNonNullElse(name, loadDefaultName()));
        System.out.println("requireNonNullElseGet");
        System.out.println("  result=" + Objects.requireNonNullElseGet(name, fallback));
    }
}

loadDefaultName()은 실행될 때마다 들여 쓴 줄을 먼저 찍습니다. 그래서 loadDefaultName() runs 줄이 어디에 몇 번 나오는지 보면 그 메서드가 언제 실행됐는지 알 수 있습니다.

supplier created
  loadDefaultName() runs
first get: guest
  loadDefaultName() runs
second get: guest
requireNonNullElse
  loadDefaultName() runs
  result=mina
requireNonNullElseGet
  result=mina

첫 줄이 supplier created라는 것은 fallback을 만드는 동안 loadDefaultName()이 실행되지 않았다는 뜻입니다. 본문은 get()을 호출할 때 처음 실행되고, get()을 다시 호출하면 또 실행됩니다. Supplier는 이전 결과를 기억하지 않기 때문입니다. "first get: " + fallback.get()에서 get()이 먼저 계산되므로 loadDefaultName() runs가 first get 줄보다 앞에 출력된다는 점도 확인해 두세요.

뒤의 두 호출은 실제 JDK API에서 이 차이가 어떻게 쓰이는지 보여 줍니다. Objects.requireNonNullElse(obj, defaultObj)는 obj가 null이 아니면 그대로, null이면 기본값을 돌려줍니다. 그런데 자바는 메서드를 호출하기 전에 인수를 모두 계산하므로, name이 null이 아니어서 기본값이 필요 없는데도 loadDefaultName()이 먼저 실행됩니다.

Java 9에서 함께 추가된 requireNonNullElseGet은 기본값 대신 Supplier를 받아, 기본값이 정말 필요할 때만 get()을 호출합니다. 그래서 마지막 호출에서는 loadDefaultName()이 실행되지 않았습니다. Optional의 orElse와 orElseGet도 같은 관계이며, 인수를 언제 계산하는지는 즉시 평가와 지연 평가에서 자세히 다룹니다.

실행 시점이 늦춰지는 것은 예외도 마찬가지입니다. 앞 예제의 label에 빈 문자열을 넘기는 Supplier<Label> blank = () -> label.apply(" ");를 만드는 것만으로는 아무 일도 일어나지 않고, blank.get()을 호출하는 순간 IllegalArgumentException이 발생합니다. 그래서 람다를 만드는 코드를 try로 감싸도 이 예외는 잡히지 않고, get()을 호출하는 쪽에서 처리해야 합니다. 반대로 get()을 여러 번 호출하면 본문도 여러 번 실행되므로, 결과를 한 번만 계산하고 싶다면 따로 저장해 두는 코드(메모이제이션)를 직접 만들어야 합니다. 여러 스레드가 동시에 처음 호출하는 경우나 계산이 실패한 경우를 어떻게 할지도 함께 정해야 하는데, 이 내용은 표준 함수형 인터페이스와 함수 조합에서 다룹니다.

이미 있는 메서드를 그대로 넘기는 메서드 참조

람다 본문이 이미 있는 메서드 하나를 호출하기만 한다면, 메서드 참조(method reference)로 더 짧게 쓸 수 있습니다. :: 왼쪽에는 클래스 이름이나 객체를, 오른쪽에는 메서드 이름을 씁니다.

메서드 참조같은 일을 하는 람다형태
Integer::parseInttext -> Integer.parseInt(text)정적 메서드
String::lengthtext -> text.length()임의 객체의 인스턴스 메서드
System.out::printlnvalue -> System.out.println(value)특정 객체의 인스턴스 메서드
ArrayList::new() -> new ArrayList<>()생성자

메서드 이름 뒤에 괄호와 인수를 쓰지 않는 것은, 메서드를 지금 호출하는 것이 아니라 '나중에 이 메서드를 호출하는 함수'를 만드는 것이기 때문입니다. 앞 글들의 Ticket::severity, 아래 예제의 CheckedExceptionAdapter::load와 System.out::println도 모두 이런 메서드 참조입니다.

무엇을 매개변수로 받아 어떻게 넘길지는 람다와 마찬가지로 대상 타입이 정합니다. 예를 들어 String::length를 Function<String, Integer> 자리에 쓰면 apply가 받은 문자열이 length()를 호출받는 객체가 됩니다. 어떤 문자열이 올지 미리 정해 두지 않으므로 '임의 객체'의 메서드 참조라고 부릅니다. 반면 Integer::parseInt를 같은 자리에 쓰면 받은 문자열이 parseInt의 인수가 됩니다.

메서드 참조가 람다와 늘 똑같이 동작하는 것은 아닙니다. System.out::println처럼 :: 왼쪽에 객체를 쓰면, 그 객체는 메서드 참조를 만드는 순간에 정해집니다. 람다 value -> System.out.println(value)가 호출될 때마다 System.out을 다시 읽는 것과 다른 점입니다. 그래서 두 함수를 만든 뒤 System.setOut으로 표준 출력을 바꾸면, 람다는 새 출력으로 쓰지만 메서드 참조는 만들 때 정해 둔 원래 출력으로 씁니다. 네 가지 형태의 자세한 규칙과 이런 평가 시점의 차이는 메서드 참조의 네 가지 형태에서 다룹니다.

검사 예외를 던지는 메서드를 람다로 넘길 때

Function<T, R>의 apply에는 throws 절이 없습니다. 람다 본문은 apply의 구현이므로, 본문에서 IOException 같은 검사 예외를 던지는 메서드를 부르면 컴파일되지 않습니다. 다음 코드에서 main에 throws IOException을 붙인 것을 눈여겨보세요.

bad/CheckedLambdaOnly.java
import java.io.IOException;
import java.util.function.Function;

public final class CheckedLambdaOnly {
    static String load(String key) throws IOException {
        if (key.equals("missing")) {
            throw new IOException("resource not found");
        }
        return "data:" + key;
    }

    public static void main(String[] args) throws IOException {
        Function<String, String> byLambda = key -> load(key);
    }
}
CheckedLambdaOnly.java:13: error: unreported exception IOException; must be caught or declared to be thrown
        Function<String, String> byLambda = key -> load(key);
                                                       ^
1 error

main이 IOException을 던질 수 있다고 선언했는데도 오류가 납니다. 람다 본문은 main의 코드처럼 보이지만, 실제로는 나중에 누군가 호출할 apply의 본문이기 때문입니다. apply는 검사 예외를 던진다고 선언하지 않았으므로, 그 안에서 IOException이 빠져나갈 길이 없습니다. 같은 메서드를 메서드 참조 CheckedLambdaOnly::load로 넘기면 메시지만 incompatible thrown types IOException in functional expression으로 바뀝니다.

해결 방법은 세 가지입니다. 첫째, 람다 안에서 예외를 잡아 처리합니다. 호출한 쪽이 이 실패를 복구할 수 없다면 JDK가 마련해 둔 비검사 예외 UncheckedIOException으로 감싸 다시 던지는 방법을 흔히 씁니다. 원래 예외는 원인(cause)으로 남으므로 나중에 꺼내 볼 수 있습니다.

Function<String, String> loadOrFail = key -> {
    try {
        return load(key);
    } catch (IOException exception) {
        throw new UncheckedIOException(exception);
    }
};

둘째, 검사 예외를 선언하는 함수형 인터페이스를 직접 만듭니다. 그 인터페이스를 받는 쪽은 예외를 처리해야 한다는 것을 컴파일러가 계속 알려 줍니다. 셋째, 실패를 예외가 아니라 값으로 바꿔 돌려줍니다. 다음 예제는 둘째와 셋째를 함께 씁니다. ThrowingFunction은 throws Exception을 선언한 함수형 인터페이스이고, lift는 이것을 받아 실패하면 Failure 값을 돌려주는 보통의 Function으로 바꿉니다.

src/CheckedExceptionAdapter.java
import java.io.IOException;
import java.util.List;
import java.util.function.Function;

public final class CheckedExceptionAdapter {
    @FunctionalInterface
    interface ThrowingFunction<T, R> {
        R apply(T value) throws Exception;
    }

    sealed interface Result<T> permits Success, Failure {
    }

    record Success<T>(T value) implements Result<T> {
    }

    record Failure<T>(String type, String message) implements Result<T> {
    }

    static <T, R> Function<T, Result<R>> lift(ThrowingFunction<T, R> operation) {
        return value -> {
            try {
                return new Success<>(operation.apply(value));
            } catch (Exception exception) {
                return new Failure<>(exception.getClass().getSimpleName(), exception.getMessage());
            }
        };
    }

    static String load(String key) throws IOException {
        if (key.equals("missing")) {
            throw new IOException("resource not found");
        }
        return "data:" + key;
    }

    public static void main(String[] args) {
        Function<String, Result<String>> safeLoad = lift(CheckedExceptionAdapter::load);
        List<Result<String>> results = List.of("one", "missing", "two").stream()
                .map(safeLoad)
                .toList();
        results.forEach(System.out::println);
    }
}

main은 세 키를 스트림으로 safeLoad에 넘기고 결과 레코드를 하나씩 찍습니다. load는 키가 missing일 때만 IOException을 던지므로, 그 키의 결과만 다른 레코드로 나옵니다.

Success[value=data:one]
Failure[type=IOException, message=resource not found]
Success[value=data:two]

Result는 sealed로 선언해 구현을 Success와 Failure 두 레코드로 제한한 타입입니다. CheckedExceptionAdapter::load는 Function 자리에는 넘길 수 없었지만, ThrowingFunction의 apply는 throws Exception을 선언하므로 여기서는 컴파일됩니다. lift가 돌려준 람다는 operation.apply를 try 안에서 호출해, 성공하면 Success로, 예외가 나면 예외의 간단한 클래스 이름과 메시지를 담은 Failure로 감쌉니다. 그래서 "missing"에서 난 IOException은 스트림을 멈추지 않고 둘째 줄의 Failure가 되었고, 나머지 두 키는 정상적으로 처리되었습니다.

이 어댑터에는 알고 써야 할 한계가 있습니다. Exception을 잡으므로 RuntimeException도 Failure가 됩니다. 그래서 코드 버그로 생긴 NullPointerException까지 평범한 실패 값처럼 보일 수 있습니다. 반면 Error는 Exception의 하위 타입이 아니므로 잡히지 않고 그대로 빠져나갑니다.

Failure에는 클래스 이름과 메시지만 남고 원래 예외 객체와 스택 트레이스는 버려집니다. InterruptedException을 던지는 연산을 감싼다면, 그 예외를 Failure로 바꾸는 순간 스레드가 인터럽트되었다는 신호가 사라지므로 Thread.currentThread().interrupt()로 인터럽트 상태를 되살려야 합니다.

실패를 값으로 바꾸는 방법이 늘 최선인 것도 아닙니다. 첫 실패에서 바로 멈춰야 하고 복구는 더 위쪽 코드가 맡는다면, 검사 예외를 그대로 둔 평범한 반복문이 더 직접적입니다. 스트림 파이프라인 안에 넣으려는 이유만으로 모든 오류를 값으로 바꾸면 성공한 결과와 실패 처리 방식이 한데 섞이기 쉽습니다. 문법이 편해지자고 실패의 의미를 바꾸지는 않아야 합니다.

긴 람다는 이름 있는 메서드로 나눈다

람다는 짧을 때 읽기 좋습니다. 다음 코드는 문법상 문제가 없지만, 람다 하나에 공백 제거, 숫자 변환, 출력, 검증, 실패 표시까지 모두 들어 있습니다.

bad/DenseLambdaPipeline.java
import java.util.List;
import java.util.function.Function;

public final class DenseLambdaPipeline {
    public static void main(String[] args) {
        Function<String, Integer> parse = text -> {
            int value = Integer.parseInt(text.trim());
            System.out.println("parsed=" + value);
            return value > 0 ? value : -1;
        };
        System.out.println(List.of(" 3 ", " -2 ").stream().map(parse).toList());
    }
}

람다 안의 println은 원소마다 한 번씩 실행되므로 parsed= 줄이 결과 목록보다 먼저 찍힙니다. 마지막 줄은 toList()가 모은 결과입니다.

parsed=3
parsed=-2
[3, -1]

결과의 -1은 입력 어디에도 없는 값입니다. 람다가 0 이하의 값을 '잘못된 값'이라는 뜻으로 -1로 바꿨기 때문인데, 결과만 받은 쪽에서는 -1이 실제 값인지 실패 표시인지 구별할 수 없습니다. 또 "x"처럼 숫자가 아닌 입력이 들어오면 Integer.parseInt가 던진 NumberFormatException이 람다 밖으로 빠져나가 파이프라인 전체가 멈추는데, 호출한 쪽은 이 처리 방식을 고를 수 없습니다. 줄 수가 적다고 하는 일이 단순한 것은 아닙니다.

이럴 때는 람다 본문을 이름 있는 메서드로 꺼내고 단계마다 나눕니다. 연습 문제 2의 OrderPipelineSolution::validate처럼 메서드 참조로 연결하면 이름이 그 단계의 역할을 설명해 주고, 단계마다 따로 테스트할 수도 있습니다. 다만 긴 코드를 private 메서드로 옮기기만 해서는 나아지지 않습니다. 각 단계가 무엇을 받아 무엇을 돌려주는지부터 정해야 합니다.

숫자 변환 실패, 업무 규칙에 맞지 않는 값, 저장 실패처럼 의미가 다른 실패를 하나의 boolean이나 -1로 뭉치지 않고, 단계 사이에서 null과 Optional을 섞어 쓰지도 않습니다. 함수를 이어 붙일 때 컴파일러가 확인하는 것은 앞 단계의 출력 타입이 다음 단계의 입력 타입과 맞는지뿐이고, 단계의 순서가 업무상 옳은지까지 확인해 주지는 않는다는 점도 기억해 두세요. 연습 문제 2에서 이런 파이프라인을 직접 만들어 봅니다.

정리

  • 람다식은 매개변수 -> 본문입니다. 괄호는 타입 없는 매개변수가 하나일 때만 생략할 수 있고, 타입을 적은 매개변수와 적지 않은 매개변수, var를 섞어 쓸 수 없습니다.
  • 식 본문은 식의 값이 반환값입니다. 블록 본문은 값을 돌려주는 경우 정상적으로 끝나는 모든 경로에 return 값;이 필요합니다. 반환 타입이 void인 인터페이스에는 메서드 호출처럼 문장이 될 수 있는 식만 식 본문으로 쓸 수 있습니다.
  • 람다를 만들 때는 본문이 실행되지 않고, 메서드를 호출할 때마다 실행됩니다. 예외도 호출할 때 발생하며, Supplier는 결과를 기억하지 않습니다.
  • 이미 있는 메서드를 호출하기만 하는 람다는 클래스::메서드나 객체::메서드 형태의 메서드 참조로 바꿀 수 있습니다.
  • 표준 함수형 인터페이스는 검사 예외를 선언하지 않습니다. 람다 안에서 처리하거나, 검사 예외를 선언한 인터페이스를 만들거나, 실패를 값으로 바꾸는 방법 가운데 실패의 의미에 맞는 것을 고릅니다.

연습 문제

1. 컴파일되는 람다 고르기

다음 네 줄 가운데 컴파일되는 것과 컴파일되지 않는 것을 고르고, 이유를 설명하세요. Supplier, Consumer, BiFunction은 java.util.function 패키지에서 가져왔다고 가정합니다.

Runnable a = () -> System.out.println("hi");
Supplier<String> b = () -> { "hi"; };
Consumer<String> c = text -> text.length();
BiFunction<Integer, Integer, Integer> d = (int left, int right) -> left + right;
풀이 보기

a와 c는 컴파일되고, b와 d는 컴파일되지 않습니다.

a는 반환 타입이 void인 Runnable에 메서드 호출 식을 쓴 것이므로 문제가 없습니다. c도 같은 이유로 컴파일됩니다. text.length()는 int를 돌려주지만 메서드 호출이라 문장이 될 수 있고, Consumer의 void 메서드에서는 그 값이 버려집니다.

b는 블록 안에 "hi";라는 문장을 쓴 것인데, 문자열 리터럴은 문장이 될 수 없어서 error: not a statement가 납니다. 블록을 쓰려면 () -> { return "hi"; }, 식 본문으로 쓰려면 () -> "hi"라고 써야 합니다. d는 매개변수 타입을 적었는데 대상 타입 BiFunction<Integer, Integer, Integer>의 매개변수 타입인 Integer와 다르므로 incompatible parameter types in lambda expression 오류가 납니다. 매개변수 타입을 적을 때는 자동 박싱도 일어나지 않고 대상 타입과 정확히 같아야 합니다. (Integer left, Integer right)로 쓰거나 타입을 생략하면 컴파일됩니다.

2. 첫 실패에서 멈추는 주문 처리 파이프라인

"id,quantity" 형식의 문자열을 받아 주문으로 파싱하고, 수량이 1 이상 10 이하인지 검증한 뒤, 개당 2500원으로 총액을 계산하는 process를 만드세요. 각 단계는 작은 메서드로 나누고, 어느 단계든 실패하면 거기서 멈춥니다. 예외나 null 대신 성공과 실패를 담는 Result<T>를 돌려주고, 정상 입력과 오류 입력을 모두 실행해 보세요.

다 만들었다면 process에 " A-9,10 ", "A-9,0", "A-9,"를 넣었을 때 각각 어느 단계까지 호출되고 무엇이 나오는지 먼저 예측해 보세요. null을 넣으면 어떻게 될까요?

풀이 보기
src/OrderPipelineSolution.java
import java.util.function.Function;

public final class OrderPipelineSolution {
    sealed interface Result<T> permits Ok, Error {
        default <R> Result<R> map(Function<? super T, ? extends R> mapper) {
            return switch (this) {
                case Ok<T> ok -> new Ok<>(mapper.apply(ok.value()));
                case Error<T> error -> new Error<>(error.message());
            };
        }

        default <R> Result<R> flatMap(Function<? super T, Result<R>> mapper) {
            return switch (this) {
                case Ok<T> ok -> mapper.apply(ok.value());
                case Error<T> error -> new Error<>(error.message());
            };
        }
    }

    record Ok<T>(T value) implements Result<T> {
    }

    record Error<T>(String message) implements Result<T> {
    }

    record Order(String id, int quantity) {
    }

    record PricedOrder(String id, int quantity, int total) {
    }

    static Result<Order> parse(String text) {
        String[] fields = text.split(",", -1);
        if (fields.length != 2 || fields[0].isBlank()) {
            return new Error<>("expected id,quantity");
        }
        try {
            return new Ok<>(new Order(fields[0].trim(), Integer.parseInt(fields[1].trim())));
        } catch (NumberFormatException exception) {
            return new Error<>("quantity must be an integer");
        }
    }

    static Result<Order> validate(Order order) {
        return order.quantity() >= 1 && order.quantity() <= 10
                ? new Ok<>(order)
                : new Error<>("quantity outside 1..10");
    }

    static PricedOrder price(Order order) {
        return new PricedOrder(order.id(), order.quantity(), order.quantity() * 2500);
    }

    static Result<PricedOrder> process(String text) {
        return parse(text)
                .flatMap(OrderPipelineSolution::validate)
                .map(OrderPipelineSolution::price);
    }

    public static void main(String[] args) {
        System.out.println(process("A-1,3"));
        System.out.println(process("A-2,many"));
        System.out.println(process("A-3,20"));
    }
}

실행 결과:

Ok[value=PricedOrder[id=A-1, quantity=3, total=7500]]
Error[message=quantity must be an integer]
Error[message=quantity outside 1..10]

세 단계는 성격이 다릅니다. parse와 validate는 실패할 수 있으므로 Result를 돌려주고, price는 검증을 통과한 주문만 받으므로 실패하지 않아 PricedOrder를 그대로 돌려줍니다. 그래서 process는 Result를 돌려주는 단계는 flatMap으로, 평범한 값을 돌려주는 단계는 map으로 연결합니다. 두 메서드 모두 자신이 Ok일 때만 넘겨받은 함수를 호출하고, Error라면 함수를 부르지 않고 같은 메시지의 Error를 그대로 넘깁니다. 첫 실패에서 멈추는 동작은 이렇게 만들어집니다.

map과 flatMap은 switch의 패턴 매칭으로 자기 자신이 Ok인지 Error인지 나눕니다. Result가 sealed라서 두 경우만 다루면 default가 필요 없다는 점은 채팅 명령 파싱과 상태 검사에서 본 것과 같습니다. 매개변수의 ? super T, ? extends R 와일드카드는 더 많은 함수를 받아들이기 위한 것으로, Function 매개변수의 와일드카드와 타입 추론에서 다룹니다. 하나 더, 이 예제의 Error 레코드는 이 클래스 안에서 java.lang.Error를 가립니다. 클래스 안에서 JVM의 Error를 가리키려면 java.lang.Error라고 전체 이름을 써야 하므로, 실제 코드에서는 Failure처럼 겹치지 않는 이름을 고르는 편이 낫습니다.

Result가 오류이면 뒤 단계의 함수를 호출하지 않는다

parse가 실패하면 validate/price를 건너뛰고 validate가 실패하면 price를 건너뜁니다. A-1,3·many·20 입력의 최초 Error와 실행되지 않는 뒤 함수를 단계별로 설명합니다.

Result가 오류이면 뒤 단계의 함수를 호출하지 않는다

OrderPipelineSolution의 세 고정 입력을 parse·validate·price에 대응시킵니다.

첫 실패에서 중단

Result가 오류이면 뒤 단계의 함수를 호출하지 않는다

parse → validate → price 순서로 연결합니다. Error 값은 그대로 전달되어 뒤 함수가 호출되지 않습니다.

각 Result가 Ok일 때만 다음 함수를 호출한다parse의Error는 validate·price를 건너뛰고 validate의Error는price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7노드7관계이다.parse문자열→OrderOk인가?validate수량 1~10Ok인가?price수량 ×2500Ok PricedOrderError 값뒤 함수 미호출예아니요예아니요
각 Result가 Ok일 때만 다음 함수를 호출한다parse의Error는 validate·price를 건너뛰고 validate의Error는price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7노드7관계이다.parse문자열→OrderOk인가?validate수량 1~10Ok인가?price수량 ×2500Ok PricedOrderError 값뒤 함수 미호출예아니요예아니요

A-1,3

처리되는 단계
parse → validate → price
코드가 만드는 결과
수량 3 · 총액 7500의 PricedOrder

A-2,many

처리되는 단계
parse에서 정수 변환 거절
코드가 만드는 결과
quantity must be an integer · 뒤 단계 미호출

A-3,20

처리되는 단계
parse 뒤 validate에서 범위 거절
코드가 만드는 결과
quantity outside 1..10 · price 미호출

main의 고정 입력을 코드 분기로 풀이한 표이며 실행 기록이 아닙니다.

각 단계의 Error 반환만 다룹니다. null 입력이나 함수가 던진 임의 예외를 자동으로 Error로 바꾸지는 않습니다.

이 예의 조건

  • Result는 함수에서 던진 모든 RuntimeException을 자동으로 포장하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2parse의 Error는 validate·price를 건너뛰고 validate의 Error는 price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7개 노드와 7개 관계이다.
원 도표의 설명과 주석
구분내용
설명parse의 Error는 validate·price를 건너뛰고 validate의 Error는 price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7개 노드와 7개 관계이다.
전체 읽기 정보Result가 오류이면 뒤 단계의 함수를 호출하지 않는다 Result가 오류이면 뒤 단계의 함수를 호출하지 않는다 OrderPipelineSolution의 세 고정 입력을 parse·validate·price에 대응시킵니다. 첫 실패에서 중단 Result가 오류이면 뒤 단계의 함수를 호출하지 않는다 parse → validate → price 순서로 연결합니다. Error 값은 그대로 전달되어 뒤 함수가 호출되지 않습니다. A-1,3 처리되는 단계 parse → validate → price 코드가 만드는 결과 수량 3 · 총액 7500의 PricedOrder A-2,many 처리되는 단계 parse에서 정수 변환 거절 코드가 만드는 결과 quantity must be an integer · 뒤 단계 미호출 A-3,20 처리되는 단계 parse 뒤 validate에서 범위 거절 코드가 만드는 결과 quantity outside 1..10 · price 미호출 main의 고정 입력을 코드 분기로 풀이한 표이며 실행 기록이 아닙니다. 각 단계의 Error 반환만 다룹니다. null 입력이나 함수가 던진 임의 예외를 자동으로 Error로 바꾸지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
parse 경계

세 원 입력의 수량을 Order로 변환합니다.

  • A-1,3: Ok 수량 3
  • A-2,many: Error, 뒤 함수 미호출
  • parse 실패 값은 validate·price를 건너뜁니다.
validate 경계

수량 1..10을 검사합니다.

  • 수량 3: Ok
  • A-3,20: Error, price 미호출
  • 성공한 parse도 업무 범위 검증을 통과해야 합니다.
가격 결과

정상 수량 3에 2500을 곱해 PricedOrder를 만듭니다.

  • 총액: 7500
  • null 입력: 별도 NPE, 자동 Error 아님
  • 앞 단계 Error의 전달과 임의 예외를 잡는 catch를 구별합니다.

Result가 오류이면 뒤 단계의 함수를 호출하지 않는다

parse가 실패하면 validate/price를 건너뛰고 validate가 실패하면 price를 건너뜁니다. A-1,3·many·20 입력의 최초 Error와 실행되지 않는 뒤 함수를 단계별로 설명합니다.

Result가 오류이면 뒤 단계의 함수를 호출하지 않는다

OrderPipelineSolution의 세 고정 입력을 parse·validate·price에 대응시킵니다.

첫 실패에서 중단

Result가 오류이면 뒤 단계의 함수를 호출하지 않는다

parse → validate → price 순서로 연결합니다. Error 값은 그대로 전달되어 뒤 함수가 호출되지 않습니다.

각 Result가 Ok일 때만 다음 함수를 호출한다parse의Error는 validate·price를 건너뛰고 validate의Error는price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7노드7관계이다.parse문자열→OrderOk인가?validate수량 1~10Ok인가?price수량 ×2500Ok PricedOrderError 값뒤 함수 미호출예아니요예아니요
각 Result가 Ok일 때만 다음 함수를 호출한다parse의Error는 validate·price를 건너뛰고 validate의Error는price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7노드7관계이다.parse문자열→OrderOk인가?validate수량 1~10Ok인가?price수량 ×2500Ok PricedOrderError 값뒤 함수 미호출예아니요예아니요

A-1,3

처리되는 단계
parse → validate → price
코드가 만드는 결과
수량 3 · 총액 7500의 PricedOrder

A-2,many

처리되는 단계
parse에서 정수 변환 거절
코드가 만드는 결과
quantity must be an integer · 뒤 단계 미호출

A-3,20

처리되는 단계
parse 뒤 validate에서 범위 거절
코드가 만드는 결과
quantity outside 1..10 · price 미호출

main의 고정 입력을 코드 분기로 풀이한 표이며 실행 기록이 아닙니다.

각 단계의 Error 반환만 다룹니다. null 입력이나 함수가 던진 임의 예외를 자동으로 Error로 바꾸지는 않습니다.

이 예의 조건

  • Result는 함수에서 던진 모든 RuntimeException을 자동으로 포장하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2parse의 Error는 validate·price를 건너뛰고 validate의 Error는 price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7개 노드와 7개 관계이다.
원 도표의 설명과 주석
구분내용
설명parse의 Error는 validate·price를 건너뛰고 validate의 Error는 price를 건너뛴다. 모든 정상 단계가 끝나면 Ok PricedOrder를 만든다. Error 노드는 이미 반환한 실패 값의 전달이며 null 입력이나 임의 RuntimeException을 잡는 catch가 아니다. 원 세 입력의 계산표를 보조로 유지한다. 모바일도 같은 7개 노드와 7개 관계이다.
전체 읽기 정보Result가 오류이면 뒤 단계의 함수를 호출하지 않는다 Result가 오류이면 뒤 단계의 함수를 호출하지 않는다 OrderPipelineSolution의 세 고정 입력을 parse·validate·price에 대응시킵니다. 첫 실패에서 중단 Result가 오류이면 뒤 단계의 함수를 호출하지 않는다 parse → validate → price 순서로 연결합니다. Error 값은 그대로 전달되어 뒤 함수가 호출되지 않습니다. A-1,3 처리되는 단계 parse → validate → price 코드가 만드는 결과 수량 3 · 총액 7500의 PricedOrder A-2,many 처리되는 단계 parse에서 정수 변환 거절 코드가 만드는 결과 quantity must be an integer · 뒤 단계 미호출 A-3,20 처리되는 단계 parse 뒤 validate에서 범위 거절 코드가 만드는 결과 quantity outside 1..10 · price 미호출 main의 고정 입력을 코드 분기로 풀이한 표이며 실행 기록이 아닙니다. 각 단계의 Error 반환만 다룹니다. null 입력이나 함수가 던진 임의 예외를 자동으로 Error로 바꾸지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
parse 경계

세 원 입력의 수량을 Order로 변환합니다.

  • A-1,3: Ok 수량 3
  • A-2,many: Error, 뒤 함수 미호출
  • parse 실패 값은 validate·price를 건너뜁니다.
validate 경계

수량 1..10을 검사합니다.

  • 수량 3: Ok
  • A-3,20: Error, price 미호출
  • 성공한 parse도 업무 범위 검증을 통과해야 합니다.
가격 결과

정상 수량 3에 2500을 곱해 PricedOrder를 만듭니다.

  • 총액: 7500
  • null 입력: 별도 NPE, 자동 Error 아님
  • 앞 단계 Error의 전달과 임의 예외를 잡는 catch를 구별합니다.

출력의 첫 줄은 세 단계를 모두 통과해 수량 3에 2500을 곱한 총액 7500이 나온 경우입니다. 둘째 줄은 "many"를 정수로 바꾸지 못해 parse에서 실패했으므로 validate와 price는 호출되지 않았습니다. 셋째 줄은 parse는 통과했지만 수량 20이 범위를 벗어나 validate에서 실패했고, price는 호출되지 않았습니다.

예측 문제의 답은 다음과 같습니다. " A-9,10 "은 쉼표로 나눈 두 칸을 각각 trim하므로 id는 A-9, 수량은 10이 됩니다. 10은 범위 안이므로 세 단계를 모두 통과해 Ok[value=PricedOrder[id=A-9, quantity=10, total=25000]]이 나옵니다. "A-9,0"은 parse는 통과하지만 validate에서 Error[message=quantity outside 1..10]이 되고 price는 건너뜁니다. "A-9,"는 split(",", -1)이 끝의 빈 칸까지 남기므로 칸은 두 개지만, 빈 문자열을 정수로 바꾸지 못해 parse에서 Error[message=quantity must be an integer]가 됩니다.

null은 이 세 경우와 다릅니다. parse의 첫 줄 text.split에서 NullPointerException이 발생해 process 밖으로 그대로 던져지고, Error 값은 만들어지지 않습니다. flatMap과 map은 이미 Error인 결과를 만나면 뒤 함수를 건너뛸 뿐, 함수 안에서 던져진 예외를 잡아 주지는 않기 때문입니다. 실패를 값으로 바꾸는 일은 parse가 NumberFormatException을 직접 잡았듯이 각 단계가 스스로 해야 합니다. null을 실패 값으로 다루고 싶다면 parse 첫머리에서 null을 검사해 Error를 돌려주면 됩니다.

다음 글

다음 글에서는 람다를 받을 수 있는 함수형 인터페이스의 조건을 정확히 살펴봅니다. 추상 메서드를 셀 때 default·static 메서드와 Object의 메서드가 어떻게 취급되는지, @FunctionalInterface가 무엇을 검사하는지, 타입 매개변수로 함수의 입력과 출력을 표현하는 방법을 다룹니다.