람다 문법과 실행 시점
매개변수 추론, 식 본문, 블록 반환문, 반환값 없는 문장, 검사 예외 경계를 구분하고 긴 람다를 이름 있는 단계로 리팩터링합니다.
람다 문법은 (parameter) -> body로 짧지만 본문 모양에 따라 반환 규칙이 달라집니다.
값을 반환하는 대상 타입에서는 식 본문의 값을 결과로 사용합니다. void 대상에서는 메서드 호출 같은 문장 식의 결과를 버릴 수 있습니다.
블록 본문은 값을 돌려주는 모든 정상 경로에 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에서 드러나는지 살핍니다.
이 예제의 delayed는 get()을 호출할 때마다 label.apply를 호출합니다. Supplier라는 타입 자체는 새 결과 생성이나 캐시 정책을 보장하지 않습니다.
캐시가 필요한 경우 메모이제이션을 명시적으로 추가하고 스레드 안전성과 실패 캐시 여부를 정합니다.
Supplier 자체가 지연 실행된다는 사실과 결과가 한 번만 계산된다는 사실은 별개입니다.
검사 예외와 함수형 계약
Function<T, R>.apply()는 검사 예외를 선언하지 않습니다.
파일 읽기나 파싱 콜백이 IOException을 던진다면 람다 안에서 처리하거나 검사 예외를 가진 별도 함수형 인터페이스를 정의해야 합니다.
모든 예외를 RuntimeException으로 감싸면 호출자가 복구 가능한 실패를 구별하기 어렵습니다.
다음 예제는 ThrowingFunction을 사용해 검사 예외를 Result 값으로 올립니다.
어댑터는 잡은 예외의 간단한 타입 이름과 메시지를 Failure에 담습니다. 원래 예외 객체·원인·스택 추적을 보존하는 형태는 아닙니다.
Error는 잡지 않지만 RuntimeException도 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"));
}
}OrderPipelineSolution의 세 고정 입력을 parse·validate·price에 대응시킵니다.
| main의 입력 | 처리되는 단계 | 코드가 만드는 결과 |
|---|---|---|
| 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 미호출 |
- 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의 고정 입력을 코드 분기로 풀이한 표이며 실행 기록이 아닙니다.
오류를 값으로 만드는 책임은 각 단계에 있습니다. 예를 들어 parse(null)의 null 참조 오류는 이 Result 파이프라인이 자동으로 실패 값으로 바꾸지 않습니다.