자바 함수형 마스터 노트 시리즈 2편. 익명 내부 클래스 → 람다로의 변환 흐름, 람다 4단계 간소화, @FunctionalInterface 어노테이션의 의미, 함수형 프로그래밍 3개념(일급 객체·고차 함수·순수 함수), 동작 파라미터화·지연 평가, effectively final 규칙, 메서드 참조 4종(static·instance·object·constructor)까지.
이 글은 자바 함수형 마스터 노트 시리즈의 두 번째 편입니다. 1편(기초)에서 다형성을 다졌다면, 이번엔 자바 8 함수형 프로그래밍의 출발점 — 람다 표현식.
람다는 단순한 문법 단축이 아닙니다. 함수를 값처럼 다루는 패러다임 전환. 이걸 이해하면 3편 함수형 인터페이스도 4편 Stream도 한 흐름으로 흡수됩니다.
처음 람다가 어렵게 느껴지는 이유
처음 강의를 들을 때 람다 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, (x) -> x + 1 같은 기호 조합이 갑자기 등장해서. 어느 부분이 입력이고 어디가 본문인지 헷갈립니다. 둘째, "왜 굳이 람다인가"가 안 보여서. 그냥 메서드 쓰면 안 되나? 같은 의문이 자꾸 생깁니다.
해결법은 한 가지예요. 람다를 **"이름 없는 메서드"**로 보는 것. 메서드를 변수에 담고, 인자로 넘기고, 반환할 수 있는 도구. 이 그림이 잡히면 4단계 간소화 규칙은 자연스럽게 따라옵니다.
익명 내부 클래스 → 람다로의 변환
람다의 출발점은 자바 8 이전의 익명 내부 클래스입니다.
// 자바 8 이전 — 정렬 비교자
Collections.sort(list, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});
핵심 로직은 a.length() - b.length() 한 줄. 그런데 그걸 감싸는 보일러플레이트가 6줄. 이걸 모두 잘라낸 게 람다:
// 자바 8+
Collections.sort(list, (a, b) -> a.length() - b.length());
같은 동작, 1줄. 보일러플레이트 6줄이 사라진 자리에 핵심만 남았습니다.
여기서 정말 중요한 시험 함정 — 람다는 함수형 인터페이스의 인스턴스. 즉 Comparator처럼 추상 메서드 1개인 인터페이스의 구현. 추상 메서드 2개인 인터페이스에는 람다 못 씁니다.
람다 기본 문법
(파라미터) -> { 실행문 }
4단계 간소화
자바는 명확하면 생략 가능. 단계별로 줄여 갑니다.
1단계 — 풀어 쓴 형태
(int a, int b) -> { return a + b; }
2단계 — 타입 생략 (타입 추론)
(a, b) -> { return a + b; }
컴파일러가 함수형 인터페이스 시그니처에서 타입을 유추.
3단계 — 한 줄이면 {}·return 생략
(a, b) -> a + b
본문이 단일 표현식이면 {}·return 둘 다 빠집니다.
4단계 — 파라미터 1개면 () 생략
x -> x * 2
여기서 시험 함정이 하나 있어요. 파라미터 0개나 2개+이면 () 필수. 1개일 때만 생략 가능.
() -> "Hello" // 0개 — () 필수
x -> x * 2 // 1개 — () 생략 OK
(a, b) -> a + b // 2개 — () 필수
또 한 가지 — 타입 명시 시 () 필수:
(int x) -> x * 2 // OK
int x -> x * 2 // 컴파일 에러
@FunctionalInterface 어노테이션
람다를 받는 인터페이스에 명시.
@FunctionalInterface
public interface Calculator {
int calculate(int a, int b); // 추상 메서드 1개
}
역할 — 컴파일 타임 검증. 추상 메서드가 2개 이상이면 컴파일 에러:
@FunctionalInterface
public interface Wrong {
void method1();
void method2(); // 에러: Multiple non-overriding abstract methods
}
여기서 시험 함정이 하나 있어요. @FunctionalInterface 없어도 람다 사용 가능. 다만 어노테이션이 있으면 실수로 추상 메서드를 추가했을 때 즉시 에러로 잡힙니다. 권장 패턴.
default·static 메서드는 OK
@FunctionalInterface
public interface Calculator {
int calculate(int a, int b); // 추상 1개
default int doubleResult(int a, int b) { // default OK
return calculate(a, b) * 2;
}
static Calculator add() { // static OK
return (a, b) -> a + b;
}
}
추상 메서드만 1개면 default·static 몇 개든 상관없음. 1편에서 본 자바 8 인터페이스 변화가 여기서 빛납니다.
함수형 프로그래밍 3개념
1. 일급 객체 (First-Class Citizen)
함수를 값처럼 다룸 — 변수 할당·인자 전달·반환.
// 변수 할당
Function<Integer, Integer> square = x -> x * x;
// 인자 전달
list.stream().filter(x -> x > 5).count();
// 반환
Function<Integer, Integer> doubler() {
return x -> x * 2;
}
2. 고차 함수 (Higher-Order Function)
함수를 인자로 받거나 반환하는 함수.
// 받는 예
Stream.filter(Predicate)
Stream.map(Function)
// 반환하는 예
Function<Integer, Integer> compose(Function<Integer, Integer> f, Function<Integer, Integer> g) {
return x -> f.apply(g.apply(x));
}
3. 순수 함수 (Pure Function)
같은 입력 → 같은 출력 + 외부 부작용 X.
// 순수 함수
int add(int a, int b) {
return a + b;
}
// 비순수 (외부 상태 변경)
int counter = 0;
int increment(int x) {
counter++; // 부작용
return x + counter;
}
여기서 정말 중요한 시험 함정 — 병렬 스트림이 안전한 이유 = 순수 함수. 부작용 있는 람다를 병렬에 넣으면 race condition 발생. 함수형 프로그래밍의 안전성 = 불변 + 순수.
동작 파라미터화 (Behavior Parameterization)
행위 자체를 인자로 넘기는 패턴.
// Before — 조건마다 메서드 따로
List<Apple> filterRedApples(List<Apple> apples) { ... }
List<Apple> filterHeavyApples(List<Apple> apples) { ... }
// After — Predicate를 인자로
List<Apple> filter(List<Apple> apples, Predicate<Apple> p) {
return apples.stream().filter(p).collect(toList());
}
// 호출
filter(apples, a -> a.getColor().equals(RED));
filter(apples, a -> a.getWeight() > 150);
같은 메서드, 다른 동작. DRY 원칙의 함수형 버전.
지연 평가 (Lazy Evaluation)
필요한 시점에만 평가.
list.stream()
.filter(x -> {
System.out.println("filter: " + x);
return x > 5;
})
.map(x -> {
System.out.println("map: " + x);
return x * 2;
})
.findFirst(); // 최종 연산
전체 리스트가 아닌 첫 번째 매치만 평가됨. 4편 Stream에서 핵심.
여기서 시험 함정이 하나 있어요. 중간 연산만 있고 최종 연산 없으면 아무것도 안 일어남. 람다는 "정의"일 뿐, 실행은 최종 연산이 트리거.
effectively final 규칙
람다 안에서 외부 지역 변수를 참조할 때.
int factor = 3;
Function<Integer, Integer> multiply = x -> x * factor; // OK
factor = 5; // 람다 만든 후 변경 → 컴파일 에러
람다 내부에서 참조한 외부 변수는 사실상 최종(effectively final). 한 번 할당 후 변경 X.
// 자바 7까지 — final 키워드 명시 필수
final int factor = 3;
// 자바 8+ — effectively final이면 OK
int factor = 3; // final 안 써도 OK
여기서 정말 중요한 시험 함정 — 인스턴스 변수는 effectively final 룰 X. 클래스 필드는 자유롭게 변경 가능. 지역 변수에만 적용.
public class Counter {
private int count = 0; // 인스턴스 변수
public void increment() {
Runnable r = () -> count++; // OK (인스턴스 변수)
}
}
이유 — 람다는 별도 스레드에서 실행될 수 있는데, 지역 변수는 스택에 있어 스레드 종료 후 사라짐. 그래서 람다는 복사본을 캡처하고, 복사본의 일관성 위해 변경 금지.
메서드 참조 4종
람다를 더 짧게 쓰는 도구. 람다가 단순히 기존 메서드를 호출만 한다면 메서드 참조로 치환.
// 람다
list.forEach(x -> System.out.println(x));
// 메서드 참조
list.forEach(System.out::println);
1. 정적 메서드 참조
ClassName::staticMethod
// 람다
Function<String, Integer> parser = s -> Integer.parseInt(s);
// 메서드 참조
Function<String, Integer> parser = Integer::parseInt;
2. 인스턴스 메서드 참조 (특정 타입)
ClassName::instanceMethod — 첫 인자가 호출 대상.
// 람다
Function<String, String> upper = s -> s.toUpperCase();
// 메서드 참조
Function<String, String> upper = String::toUpperCase;
String::toUpperCase = "어떤 String이든, 그것의 toUpperCase()를".
3. 인스턴스 메서드 참조 (특정 객체)
instance::instanceMethod
List<String> list = new ArrayList<>();
// 람다
Consumer<String> add = s -> list.add(s);
// 메서드 참조
Consumer<String> add = list::add;
이미 있는 객체의 메서드를 그대로 참조.
4. 생성자 참조
ClassName::new
// 람다
Supplier<ArrayList<String>> factory = () -> new ArrayList<>();
// 메서드 참조
Supplier<ArrayList<String>> factory = ArrayList::new;
// 인자 있는 생성자
Function<String, Person> personFactory = Person::new;
// 내부적으로 (s) -> new Person(s)
여기서 시험 함정이 하나 있어요. 메서드 참조 종류 4가지를 시그니처로 구분. 컴파일러가 어떤 종류인지 자동 판단. 외울 때 패턴으로 묶는 게 도움됩니다 — 클래스+정적 / 클래스+인스턴스 / 객체+인스턴스 / 클래스+new.
람다 vs 익명 클래스 — this의 차이
public class Outer {
public void test() {
Runnable anonymous = new Runnable() {
@Override
public void run() {
System.out.println(this); // 익명 클래스 자신
}
};
Runnable lambda = () -> {
System.out.println(this); // Outer 인스턴스!
};
}
}
여기서 정말 중요한 시험 함정 — 람다 안 this = 둘러싼 클래스. 익명 클래스의 this = 익명 클래스 자신. 이 차이가 디버깅에서 종종 함정.
람다는 어떻게 컴파일되나
람다는 새 클래스 파일을 만들지 않습니다. invokedynamic 명령으로 동적 생성.
익명 클래스 → Outer$1.class 파일 생성 (각 익명 클래스마다)
람다 → invokedynamic으로 런타임 생성 (파일 X)
이래서 람다가 익명 클래스보다 메모리 효율적. 대량 람다 사용 시 클래스로딩 비용 차이 큼.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 2편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 람다 = 이름 없는 메서드, 함수형 인터페이스의 인스턴스
- 익명 클래스의 보일러플레이트 제거판
- 문법 —
(파라미터) -> { 실행문 } - 4단계 간소화 — 풀어쓰기·타입 생략·{}·return 생략·() 생략
- 파라미터 1개일 때만 () 생략
- 타입 명시 시 () 필수
@FunctionalInterface= 컴파일 타임 검증, 추상 메서드 1개 보장- default·static 메서드는 몇 개든 OK
- 함수형 프로그래밍 3 개념 — 일급 객체 / 고차 함수 / 순수 함수
- 일급 객체 = 함수를 변수·인자·반환값으로
- 고차 함수 = 함수를 받거나 반환
- 순수 함수 = 같은 입력 → 같은 출력 + 부작용 X
- 병렬 스트림 안전성 = 순수 함수
- 동작 파라미터화 = 행위 자체를 인자로
- 지연 평가 = 최종 연산 전엔 실행 X
- 중간 연산만 있고 최종 연산 없으면 아무 일 안 일어남
- effectively final = 람다가 캡처한 외부 지역 변수, 변경 X
- final 명시 안 해도 OK (자바 8+)
- 인스턴스 변수는 effectively final 룰 X — 자유 변경
- 메서드 참조 4종 — static / 인스턴스(타입) / 인스턴스(객체) / 생성자
Integer::parseInt(static)String::toUpperCase(인스턴스 타입)list::add(인스턴스 객체)ArrayList::new(생성자)- 람다 안
this= 둘러싼 클래스 (익명 클래스와 다름) - 람다는
invokedynamic으로 동적 생성 — 익명 클래스보다 효율적
시리즈 다른 편
- 1편 — JVM·OOP·컬렉션 기초
- 2편 — 람다 표현식 (현재 글)
- 3편 — 함수형 인터페이스
- 4편 — Stream API
- 5편 — Modern Java (9~17)
- 6편 — Java 21 가상 스레드
공식 문서: Oracle Lambda Expressions Tutorial 에서 더 깊이.
다음 글(3편)에서는 함수형 인터페이스 12종 — Supplier·Consumer·Predicate·Function·Bi*·UnaryOperator·BinaryOperator·Runnable·Callable까지 풀어 갑니다.