람다 문법과 실행 시점
매개변수 추론, 식 본문, 블록 반환문, 반환값 없는 문장, 검사 예외 경계를 구분하고 긴 람다를 이름 있는 단계로 리팩터링합니다.
람다 문법은 (parameter) -> body로 짧지만 본문 모양에 따라 반환 규칙이 달라집니다.
식 본문은 식의 값을 반환합니다.
블록 본문은 값을 돌려주는 모든 정상 경로에 return이 필요합니다.
반환값이 없는 함수 시그니처에는 문장 식이나 void 블록을 사용합니다.
컴파일러는 대상 함수형 인터페이스의 매개변수와 return 타입을 기준으로 이 호환성을 검사합니다.
람다를 선언했다고 본문이 즉시 실행되지는 않습니다.
함수 객체가 만들어지고, 호출자가 단일 추상 메서드를 호출할 때 본문이 평가됩니다.
Supplier를 전달해 값 생성을 늦추는 패턴과 supplier.get() 결과를 미리 전달하는 패턴은 비용·예외 시점·관찰 결과가 다릅니다.
과도한 람다 압축
다음 코드는 문법상 유효하지만 한 람다에 trim, 파싱, 검증, 출력, 센티널 반환을 섞었습니다.
-1이 실제 값인지 실패 표시인지 알기 어렵고 NumberFormatException 정책도 호출자가 통제하지 못합니다.
짧은 줄 수가 단순한 책임을 뜻하지 않습니다.
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 ", " 7 ").stream().map(parse).toList());
}
}람다 매개변수의 괄호는 매개변수가 하나이고 타입을 쓰지 않을 때만 생략할 수 있습니다.
(String value) -> value.length()와 value -> value.length()는 가능하지만 (var value) ->처럼 var를 쓰면 모든 매개변수에 일관되게 써야 합니다.
애노테이션을 매개변수에 붙일 필요가 있을 때 var가 유용합니다.
블록 본문에서 return과 반환 없이 끝나는 경로가 섞이면 값 반환 호환 함수 시그니처가 될 수 없습니다.
컴파일러 오류를 억지 형변환으로 해결하기보다 각 경로가 어떤 결과를 만드는지 다시 모델링합니다.
실패 가능성이 정상 입력의 일부라면 Optional이나 Result처럼 명시적 합타입을 고려합니다.
식 본문과 블록 본문
아래 카탈로그는 네 문법을 같은 프로그램 안에서 비교합니다.
식 람다는 계산식이 자명할 때 읽기 좋습니다.
여러 검증과 지역 계산이 필요하면 블록으로 펼치고 의미 있는 변수 이름을 둡니다.
Consumer처럼 void 함수 시그니처는 식의 값을 버릴 수 있는 문장 식과 호환됩니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
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);
Supplier<Label> delayed = () -> label.apply(" lambda ");
System.out.println(length.apply("java"));
System.out.println(maximum.apply(4, 9));
record.accept("created");
System.out.println("before get=" + events);
System.out.println(delayed.get());
System.out.println("after get=" + events);
}
}events.add(event)는 boolean을 반환하지만 Consumer 함수 시그니처는 void입니다.
Java는 메서드 호출이나 대입 같은 문장 식의 결과를 버리는 형태를 void 호환하게 허용합니다.
임의 산술식 value -> value + 1은 Consumer 본문으로 사용할 수 없습니다.
코드 리뷰에서는 이런 세부 규칙보다 부수 효과가 이름과 API에서 드러나는지 살핍니다.
Supplier는 매번 호출할 때 본문을 다시 평가합니다.
캐시가 필요한 경우 메모이제이션을 명시적으로 추가하고 스레드 안전성과 실패 캐시 여부를 정합니다.
Supplier 자체가 지연 실행된다는 사실과 결과가 한 번만 계산된다는 사실은 별개입니다.
검사 예외와 함수형 계약
Function<T, R>.apply()는 검사 예외를 선언하지 않습니다.
파일 읽기나 파싱 콜백이 IOException을 던진다면 람다 안에서 처리하거나 검사 예외를 가진 별도 함수형 인터페이스를 정의해야 합니다.
모든 예외를 RuntimeException으로 감싸면 호출자가 복구 가능한 실패를 구별하기 어렵습니다.
다음 예제는 ThrowingFunction을 사용해 검사 예외를 Result 값으로 올립니다.
어댑터는 예외를 숨기지 않고 성공과 실패를 모두 반환합니다.
Error까지 잡지 않도록 Exception만 처리합니다.
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);
}
}Result가 늘 최선은 아닙니다.
호출자가 실패 시 즉시 중단해야 하고 복구 책임이 위쪽에 있다면 검사 예외를 유지한 반복문이 더 직접적입니다.
스트림 파이프라인 안에 넣기 위해 모든 오류를 값으로 바꾸면 성공 목록과 오류 정책이 뒤섞일 수 있습니다.
문법 편의를 위해 도메인의 실패 의미를 왜곡하지 않습니다.
긴 람다의 분해
람다 본문이 입력 정규화, 검증, 변환, 로그를 모두 수행하면 재사용 여부와 상관없이 추출할 가치가 있습니다.
OrderCommand::parse 같은 메서드 참조는 이름을 통해 역할을 설명하고 별도 단위로 호출할 수 있습니다.
하지만 단지 긴 코드를 비공개 메서드로 옮기는 것만으로 결합이 사라지지는 않습니다.
매개변수와 반환 모델을 먼저 좁힙니다.
파이프라인 단계 사이에 Optional과 null을 섞지 않습니다.
파싱 실패, 정책 거절, 저장 실패가 다른 의미라면 하나의 boolean으로 합치지 않습니다.
함수 합성은 출력 타입이 다음 입력 타입과 맞는다는 구조만 보장하며 업무상 올바른 순서까지 보장하지 않습니다.
연습 문제
"id,quantity" 문자열을 파싱하고 quantity가 1부터 10인지 검증한 뒤 총액을 계산합니다.
각 단계는 작은 함수로 만들고 첫 실패에서 멈춥니다.
예외나 null 대신 Result<T>를 사용하며 정상과 오류 입력을 모두 실행합니다.
해답 보기
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"));
}
}해답은 세 단계의 입출력 타입을 분리하고 flatMap으로 실패를 그대로 전달합니다.
Java의 switch 패턴을 사용했지만 핵심은 람다 길이가 아니라 오류 흐름을 타입으로 드러낸 점입니다.