F-LAB JAVA · 3주차 · Phase 10 · 함수형 프로그래밍
🚀 Phase 10 시작 — 3주차 마지막 Phase 진입
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
@FunctionalInterface 의 역할은?this 키워드 의 의미 (람다 vs 익명 클래스)는?람다 표현식 (Lambda Expression) 은 Java 8+ 의 핵심 기능으로, 함수형 인터페이스 (단 하나의 추상 메서드를 가진 인터페이스) 의 구현을 간결한 표현식 으로 작성하는 방법이다.
(매개변수) -> { 본문 }형식으로, 익명 클래스의 보일러플레이트를 제거 하고 함수를 일급 시민 처럼 다룰 수 있게 한다.
java.util.function패키지의 4가지 핵심 (Function, Consumer, Supplier, Predicate) 이 대부분의 시나리오를 커버.
메서드 참조 (String::length) 는 람다의 더 간결한 표현, 4가지 종류 (정적/인스턴스/특정/생성자).
변수 캡처 는 effectively final (실질적 final) 만 가능,this는 람다 외부의this(익명 클래스와 정반대).
내부적으로 invokedynamic 으로 구현되어 익명 클래스보다 가볍고 빠름.
익명 클래스 (옛):
"이 일을 처리할 사람" 을 매번 만들기
- 회사 이름 + 부서 + 직급 + 이름 + 일 설명
- 매우 형식적
- 길고 복잡
람다 (Java 8+):
단순 위임장: "이 일을 해주세요"
- 일 설명만
- 간결
- 핵심에 집중
함수형 인터페이스:
"1가지 일만 위임" 의 약속
- SAM (Single Abstract Method)
- 1가지 일 = 람다로 표현
메서드 참조:
이미 있는 사람 가리키기
- "저기 김 부장한테 부탁"
- 새로 만들 필요 X
→ 람다 = 함수형 인터페이스 + 간결.
1. 람다 표현식의 정의와 문법
2. 함수형 인터페이스
3. java.util.function 의 4가지 핵심
4. 메서드 참조의 4가지 종류
5. 람다 vs 익명 클래스
6. this 키워드의 의미
7. 변수 캡처와 effectively final
8. 람다의 내부 구현과 성능
9. 면접 + 자기 점검
람다 표현식 (Lambda Expression):
익명 함수의 간결한 표현.
함수형 인터페이스의 구현을 표현식으로.
Java 8+ (2014) 도입.
문법:
(매개변수) -> { 본문 }
3가지 부분:
1. 매개변수 (파라미터)
2. 화살표 (->)
3. 본문 (식 또는 블록)
람다 등장 전 (Java 7 이하):
// 익명 클래스로 인터페이스 구현
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
// 문제:
// - 너무 길다
// - 보일러플레이트 (의식적 코드)
// - 핵심은 "Hello 출력" 뿐
// - 익명 클래스 = "객체 만들기" 강조
// - 의도는 "동작 전달"
람다 등장 후 (Java 8+):
Runnable r = () -> System.out.println("Hello");
// 간결
// 핵심에 집중
// 동작 전달의 의도 명확
// 매개변수 0개
Runnable r1 = () -> System.out.println("Hello");
// 매개변수 1개 (괄호 생략 가능)
Consumer<String> c1 = s -> System.out.println(s);
Consumer<String> c2 = (s) -> System.out.println(s); // 같음
// 매개변수 여러 개
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
// 본문 1개 식 (return 생략)
Function<Integer, Integer> square = x -> x * x;
// 같은 표현:
Function<Integer, Integer> square2 = x -> { return x * x; };
// 본문 여러 줄 (중괄호 + return 필요)
Function<Integer, Integer> complex = x -> {
int y = x * 2;
int z = y + 1;
return z * z;
};
// 매개변수 타입 명시 (필요시)
BiFunction<Integer, Integer, Integer> add2 = (Integer a, Integer b) -> a + b;
// 보통 추론 가능 → 생략
괄호 규칙:
매개변수 0개 → () 필수
매개변수 1개 + 타입 생략 → () 생략 가능
매개변수 1개 + 타입 명시 → () 필수
매개변수 여러 개 → () 필수
중괄호 규칙:
본문 1개 식 → {} 생략 가능 (return 도 생략)
본문 여러 줄 → {} 필수 (return 도 명시)
타입 추론:
- 매개변수 타입 보통 생략 가능
- 컨텍스트에서 추론
변환 과정:
옛 (익명 클래스):
Comparator<String> comp = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
};
→ 익명 클래스 골격 제거:
Comparator<String> comp = (String a, String b) -> {
return a.length() - b.length();
};
→ 타입 추론:
Comparator<String> comp = (a, b) -> {
return a.length() - b.length();
};
→ 본문 단순화:
Comparator<String> comp = (a, b) -> a.length() - b.length();
같은 동작, 80% 짧음
// 1. 리스트 정렬
List<String> names = new ArrayList<>(List.of("Bob", "Alice", "Charlie"));
names.sort((a, b) -> a.compareTo(b));
// 또는 메서드 참조
names.sort(String::compareTo);
// 2. forEach
names.forEach(name -> System.out.println(name));
names.forEach(System.out::println);
// 3. Thread
new Thread(() -> {
System.out.println("Running");
}).start();
// 4. Stream
List<Integer> doubled = numbers.stream()
.map(n -> n * 2)
.collect(Collectors.toList());
// 5. 함수형 인터페이스
Function<Integer, String> intToStr = i -> "Value: " + i;
public class ShipmentExamples {
private final List<Shipment> shipments = new ArrayList<>();
// 1. 정렬
public void sortByWeight() {
shipments.sort((a, b) -> a.getWeight().compareTo(b.getWeight()));
// 또는
shipments.sort(Comparator.comparing(Shipment::getWeight));
}
// 2. 필터링
public List<Shipment> findUrgent() {
return shipments.stream()
.filter(s -> s.isUrgent())
.toList();
}
// 3. 변환
public List<String> extractBlNos() {
return shipments.stream()
.map(s -> s.getBlNo())
.toList();
}
// 4. 콜백
public void processAsync(Runnable callback) {
// 비동기 처리 후
callback.run();
}
public void useCallback() {
processAsync(() -> {
System.out.println("Done");
});
}
// 5. 이벤트
public void registerListener() {
shipmentRepository.onSave(s -> {
log.info("Saved: {}", s.getId());
});
}
}
람다 표현식의 정의와 문법은?
답:
1. 정의:
문법:
(매개변수) -> { 본문 }문법 규칙:
목적:
활용:
함수형 인터페이스 (Functional Interface):
단 하나의 추상 메서드 (SAM) 를 가진 인터페이스.
SAM (Single Abstract Method)
조건:
- 추상 메서드 정확히 1개
- default 메서드는 여러 개 OK
- static 메서드는 여러 개 OK
- Object 의 메서드 (equals, hashCode 등) 는 제외
Java 8+ 도입.
@FunctionalInterface
public interface MyFunction {
void execute(); // 유일한 추상 메서드
// default 메서드 OK
default void log() {
System.out.println("Executed");
}
// static 메서드 OK
static MyFunction nothing() {
return () -> {};
}
}
// @FunctionalInterface 의 역할:
// 1. 의도 명시 ("함수형 인터페이스")
// 2. 컴파일러 검증 (SAM 위반 시 에러)
// 3. 문서화
// Java 표준 함수형 인터페이스 (Java 1.0+ 부터)
public interface Runnable {
void run();
}
public interface Comparable<T> {
int compareTo(T o);
}
public interface Comparator<T> {
int compare(T o1, T o2);
}
public interface Callable<V> {
V call() throws Exception;
}
public interface ActionListener {
void actionPerformed(ActionEvent e);
}
// 모두 SAM
// 람다로 구현 가능
// 람다는 함수형 인터페이스의 인스턴스
Runnable r = () -> System.out.println("Hello");
// r 의 타입: Runnable
// 메서드 매개변수
public void runAsync(Runnable task) {
new Thread(task).start();
}
runAsync(() -> System.out.println("Async"));
// 반환
public Runnable createTask(String msg) {
return () -> System.out.println(msg);
}
Runnable t = createTask("Hello");
t.run(); // "Hello"
// 같은 람다, 다른 인터페이스 타입
Runnable r = () -> System.out.println("Hello");
// 타입: Runnable
ActionListener al = () -> System.out.println("Hello");
// ❌ ActionListener.actionPerformed 는 매개변수 1개
// 람다의 타입은 컨텍스트로 결정
Object obj = () -> System.out.println("Hello");
// ❌ Object 는 함수형 인터페이스 X
// "target type X" 컴파일 에러
// 명시적 캐스팅
Object obj = (Runnable) () -> System.out.println("Hello");
// OK
// 사용자 정의 함수형 인터페이스
@FunctionalInterface
public interface ShipmentValidator {
boolean isValid(Shipment shipment);
// default 메서드 추가 OK
default ShipmentValidator and(ShipmentValidator other) {
return s -> this.isValid(s) && other.isValid(s);
}
}
// 활용
ShipmentValidator hasBlNo = s -> s.getBlNo() != null;
ShipmentValidator hasWeight = s -> s.getWeight() != null;
ShipmentValidator complete = hasBlNo.and(hasWeight);
if (complete.isValid(shipment)) {
// 처리
}
java.util.function 패키지:
기본:
- Function<T, R>
- Consumer<T>
- Supplier<T>
- Predicate<T>
다중 매개변수:
- BiFunction<T, U, R>
- BiConsumer<T, U>
- BiPredicate<T, U>
기본 타입 (boxing 방지):
- IntFunction, IntConsumer 등
- LongFunction 등
- DoubleFunction 등
특수:
- UnaryOperator<T> = Function<T, T>
- BinaryOperator<T> = BiFunction<T, T, T>
다음 섹션에서 정밀.
// 1. 사용자 정의 함수형 인터페이스
@FunctionalInterface
public interface ShipmentPriceCalculator {
BigDecimal calculate(Shipment shipment);
default ShipmentPriceCalculator withDiscount(BigDecimal rate) {
return s -> this.calculate(s).multiply(BigDecimal.ONE.subtract(rate));
}
}
// 활용
ShipmentPriceCalculator basePrice = s ->
s.getWeight().multiply(BigDecimal.valueOf(1000));
ShipmentPriceCalculator vipPrice = basePrice.withDiscount(BigDecimal.valueOf(0.1));
BigDecimal price = vipPrice.calculate(shipment);
// 2. 콜백
@FunctionalInterface
public interface ShipmentEventListener {
void onEvent(ShipmentEvent event);
}
public void registerListener(ShipmentEventListener listener) {
listeners.add(listener);
}
// 사용
registerListener(event -> log.info("Event: {}", event));
함수형 인터페이스의 정의는?
답:
1. 정의:
@FunctionalInterface:
자주 만들어진:
람다와 관계:
표준 (java.util.function):
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) { ... }
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) { ... }
static <T> Function<T, T> identity() { ... }
}
// 활용
Function<String, Integer> length = s -> s.length();
length.apply("Hello"); // 5
Function<Integer, Integer> square = x -> x * x;
square.apply(5); // 25
// 합성
Function<String, Integer> lengthSquared = length.andThen(square);
lengthSquared.apply("Hello"); // 25 (length 5 → square 25)
// identity
Function<String, String> id = Function.identity();
id.apply("Hello"); // "Hello"
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
default Consumer<T> andThen(Consumer<? super T> after) { ... }
}
// 활용
Consumer<String> printer = s -> System.out.println(s);
printer.accept("Hello");
Consumer<Integer> doubler = x -> System.out.println(x * 2);
// 합성
Consumer<String> both = printer.andThen(s -> System.out.println("done"));
both.accept("Hello");
// 출력:
// Hello
// done
// forEach 에서 활용
List.of("A", "B", "C").forEach(printer);
// A
// B
// C
@FunctionalInterface
public interface Supplier<T> {
T get();
}
// 활용
Supplier<String> hello = () -> "Hello";
hello.get(); // "Hello"
Supplier<List<String>> emptyList = () -> new ArrayList<>();
List<String> list = emptyList.get();
// 지연 평가
public void log(Supplier<String> messageSupplier) {
if (isDebugEnabled()) {
System.out.println(messageSupplier.get());
// 실제 호출 시점에만 메시지 생성
}
}
log(() -> "Debug: " + expensiveOperation());
// expensiveOperation 은 isDebugEnabled 가 true 일 때만
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
default Predicate<T> and(Predicate<? super T> other) { ... }
default Predicate<T> or(Predicate<? super T> other) { ... }
default Predicate<T> negate() { ... }
static <T> Predicate<T> isEqual(Object target) { ... }
static <T> Predicate<T> not(Predicate<? super T> target) { ... }
}
// 활용
Predicate<String> isEmpty = s -> s.isEmpty();
isEmpty.test(""); // true
isEmpty.test("Hello"); // false
Predicate<Integer> isPositive = x -> x > 0;
Predicate<Integer> isEven = x -> x % 2 == 0;
// 결합
Predicate<Integer> isPositiveEven = isPositive.and(isEven);
isPositiveEven.test(4); // true
isPositiveEven.test(-4); // false (음수)
Predicate<Integer> isNotPositive = isPositive.negate();
isNotPositive.test(-5); // true
// Stream 에서 활용
List<Integer> positives = numbers.stream()
.filter(isPositive)
.toList();
// BiFunction<T, U, R>
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
add.apply(2, 3); // 5
// BiConsumer<T, U>
BiConsumer<String, Integer> printer = (name, age) ->
System.out.println(name + ": " + age);
printer.accept("Alice", 30);
// BiPredicate<T, U>
BiPredicate<String, String> startsWith = (s, prefix) -> s.startsWith(prefix);
startsWith.test("Hello", "He"); // true
// Boxing 회피를 위한 특수화
// IntFunction<R>
IntFunction<String> intToStr = i -> "Number: " + i;
intToStr.apply(42);
// IntConsumer
IntConsumer printer = i -> System.out.println(i);
printer.accept(42);
// IntSupplier
IntSupplier random = () -> ThreadLocalRandom.current().nextInt();
random.getAsInt();
// IntPredicate
IntPredicate isPositive = i -> i > 0;
isPositive.test(5);
// 비슷한 Long, Double 도 있음
// 장점:
// - 기본 타입 직접 (boxing X)
// - 성능 ↑
// - Stream API 에서 활용 (IntStream 등)
| 인터페이스 | 입력 | 출력 | 메서드 | 의미 |
|---|---|---|---|---|
| Function<T,R> | T | R | apply | 변환 |
| Consumer | T | void | accept | 소비 |
| Supplier | 없음 | T | get | 생성 |
| Predicate | T | boolean | test | 판단 |
// UnaryOperator<T> extends Function<T, T>
UnaryOperator<Integer> doubler = x -> x * 2;
doubler.apply(5); // 10
// BinaryOperator<T> extends BiFunction<T, T, T>
BinaryOperator<Integer> add = (a, b) -> a + b;
add.apply(2, 3); // 5
// 활용
List.of(1, 2, 3).stream().reduce(0, add); // 6
// 같은 타입 일 때 더 명확
public class ShipmentFunctional {
private final List<Shipment> shipments = new ArrayList<>();
// 1. Function — 변환
Function<Shipment, String> toBlNo = s -> s.getBlNo();
Function<Shipment, BigDecimal> toWeight = s -> s.getWeight();
public List<String> getBlNos() {
return shipments.stream().map(toBlNo).toList();
}
// 2. Consumer — 처리
Consumer<Shipment> logger = s -> log.info("Shipment: {}", s.getId());
Consumer<Shipment> notifier = s -> notificationService.send(s);
public void processShipment(Shipment s) {
logger.andThen(notifier).accept(s);
}
// 3. Supplier — 생성
Supplier<Shipment> emptyShipment = () -> Shipment.builder().build();
Supplier<LocalDateTime> now = LocalDateTime::now; // 메서드 참조
public Shipment createDefault() {
return emptyShipment.get();
}
// 4. Predicate — 필터
Predicate<Shipment> isUrgent = s -> s.isUrgent();
Predicate<Shipment> isHeavy = s -> s.getWeight().compareTo(BigDecimal.valueOf(1000)) > 0;
Predicate<Shipment> isUrgentAndHeavy = isUrgent.and(isHeavy);
public List<Shipment> findUrgentHeavy() {
return shipments.stream()
.filter(isUrgentAndHeavy)
.toList();
}
// 5. BiFunction — 두 객체 비교
BiFunction<Shipment, Shipment, Integer> compareByWeight =
(a, b) -> a.getWeight().compareTo(b.getWeight());
public void sortByWeight() {
shipments.sort(compareByWeight::apply);
// Comparator 와 BiFunction 비슷
}
}
java.util.function 의 4가지 핵심은?
답:
1. Function<T, R>:
Consumer:
Supplier:
Predicate:
확장:
메서드 참조 (Method Reference):
이미 존재하는 메서드를 람다로 사용하는 방법.
더 간결한 표현.
문법: ClassName::methodName
4가지 종류:
1. 정적 메서드 참조
2. 인스턴스 메서드 참조 (특정 객체)
3. 인스턴스 메서드 참조 (임의 객체)
4. 생성자 참조
// 람다:
Function<Integer, String> intToStr = i -> Integer.toString(i);
// 메서드 참조:
Function<Integer, String> intToStr2 = Integer::toString;
// 같은 동작, 더 간결
// 호출:
intToStr2.apply(42); // "42"
// 활용:
List<Integer> nums = List.of(1, 2, 3);
List<String> strs = nums.stream()
.map(Integer::toString)
.toList();
// 람다:
String s = "Hello";
Supplier<Integer> getLength = () -> s.length();
// 메서드 참조:
Supplier<Integer> getLength2 = s::length;
// s 의 length 메서드 참조
// 호출:
getLength2.get(); // 5
// 활용:
PrintStream out = System.out;
Consumer<String> printer = out::println;
// out.println 의 참조
List.of("A", "B").forEach(printer);
// 출력:
// A
// B
// 람다:
Function<String, Integer> length = s -> s.length();
// 메서드 참조:
Function<String, Integer> length2 = String::length;
// String 클래스의 length 메서드
// 받은 객체에서 호출
// 호출:
length2.apply("Hello"); // 5
// 차이:
// - 특정 객체: s::length (s 의 length)
// - 임의 객체: String::length (받은 String 의 length)
// 활용:
List<String> names = List.of("Alice", "Bob", "Charlie");
names.stream()
.map(String::length) // 임의 String 의 length
.forEach(System.out::println);
// 람다:
Supplier<List<String>> newList = () -> new ArrayList<>();
// 생성자 참조:
Supplier<List<String>> newList2 = ArrayList::new;
// ArrayList 의 생성자 참조
// 호출:
List<String> list = newList2.get();
// 매개변수가 있는 생성자
Function<Integer, ArrayList<String>> newListSize = ArrayList::new;
List<String> list2 = newListSize.apply(100);
// new ArrayList<>(100)
// 활용:
Stream.of("A", "B", "C")
.collect(Collectors.toCollection(ArrayList::new));
// 새 ArrayList 생성하여 수집
| 종류 | 문법 | 람다 등가 | 활용 |
|---|---|---|---|
| 정적 메서드 | ClassName::staticMethod | (args) -> ClassName.staticMethod(args) | Integer::parseInt |
| 인스턴스 (특정) | instance::method | (args) -> instance.method(args) | System.out::println |
| 인스턴스 (임의) | ClassName::instanceMethod | (obj, args) -> obj.instanceMethod(args) | String::length |
| 생성자 | ClassName::new | (args) -> new ClassName(args) | ArrayList::new |
// 1. Stream 정렬
List<Person> people = ...;
people.sort(Comparator.comparing(Person::getAge));
// vs
people.sort((a, b) -> Integer.compare(a.getAge(), b.getAge()));
// 2. Stream 변환
List<String> names = people.stream()
.map(Person::getName) // 임의 객체의 메서드
.toList();
// 3. forEach
list.forEach(System.out::println); // 특정 객체
// 4. 컬렉터
Map<String, List<Person>> byCity = people.stream()
.collect(Collectors.groupingBy(Person::getCity));
// 5. 생성자
Stream.generate(ArrayList::new)
.limit(10)
.toList();
// 6. 정적 메서드
List<Integer> nums = strs.stream()
.map(Integer::parseInt)
.toList();
람다 권장:
✓ 추가 로직 있음
✓ 메서드 호출 외 작업
✓ 짧은 인라인 코드
메서드 참조 권장:
✓ 단순 메서드 호출
✓ 의미 명확
✓ 더 간결
예:
x -> x.toString() → Object::toString
s -> s.toUpperCase().trim() → 람다 (체이닝)
x -> System.out.println(x) → System.out::println
(a, b) -> a + b → 람다 (연산자, 메서드 X)
Integer::sum → 메서드 참조 (Integer 에 sum 정적 메서드)
public class ShipmentMethodRefs {
private final List<Shipment> shipments = new ArrayList<>();
// 1. 정적 메서드
public List<Long> parseIds(List<String> strIds) {
return strIds.stream()
.map(Long::parseLong) // Long.parseLong
.toList();
}
// 2. 인스턴스 (특정 객체)
public void logAll() {
Logger logger = LoggerFactory.getLogger(getClass());
shipments.forEach(logger::info); // logger 의 info
// 또는
shipments.stream()
.map(Shipment::toString)
.forEach(System.out::println); // System.out 의 println
}
// 3. 인스턴스 (임의 객체)
public List<String> getBlNos() {
return shipments.stream()
.map(Shipment::getBlNo) // 임의 Shipment 의 getBlNo
.toList();
}
public List<Shipment> sortByDate() {
return shipments.stream()
.sorted(Comparator.comparing(Shipment::getCreatedAt))
.toList();
}
// 4. 생성자
public List<Shipment> createBatch(List<String> blNos) {
return blNos.stream()
.map(blNo -> {
Shipment s = new Shipment();
s.setBlNo(blNo);
return s;
})
.toList();
}
// 5. 컬렉터 + 생성자
public TreeSet<Shipment> getUniqueShipments() {
return shipments.stream()
.collect(Collectors.toCollection(TreeSet::new));
// TreeSet::new 가 생성자 참조
}
}
메서드 참조의 4가지 종류는?
답:
1. 정적 메서드 참조:
ClassName::staticMethodInteger::parseInt인스턴스 메서드 (특정 객체):
instance::methodSystem.out::println인스턴스 메서드 (임의 객체):
ClassName::instanceMethodString::length생성자 참조:
ClassName::newArrayList::new선택:
// 익명 클래스
Runnable r1 = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
// 람다
Runnable r2 = () -> System.out.println("Hello");
// 같은 동작?
// → 표면적으로는 그렇게 보이지만 내부는 다름
public class Demo {
private String name = "Demo";
public void test() {
// 익명 클래스의 this — 익명 클래스 자신
Runnable r1 = new Runnable() {
String name = "Anonymous";
@Override
public void run() {
System.out.println(this.name); // "Anonymous"
System.out.println(Demo.this.name); // "Demo"
}
};
// 람다의 this — 람다 외부의 this
Runnable r2 = () -> {
System.out.println(this.name); // "Demo"
// 람다는 자신만의 this 없음
};
}
}
public void test() {
int x = 10;
// 익명 클래스 — 자체 변수 가능
Runnable r1 = new Runnable() {
@Override
public void run() {
int x = 20; // OK (그림자)
System.out.println(x); // 20
}
};
// 람다 — 외부 변수와 같은 이름 X
Runnable r2 = () -> {
// int x = 20; // ❌ 컴파일 에러
System.out.println(x); // 10 (외부)
};
}
익명 클래스:
- 별도 .class 파일 생성
- Demo$1.class, Demo$2.class 등
- 각 익명 클래스마다 하나
- 디스크/메모리 사용 ↑
람다:
- .class 파일 추가 X
- invokedynamic 명령어
- 런타임에 동적 처리
- 더 가볍고 빠름
// 익명 클래스 — 매번 새 인스턴스
for (int i = 0; i < 1000; i++) {
Runnable r = new Runnable() {
@Override
public void run() { }
};
// 매 반복마다 새 인스턴스
}
// 람다 — 캡처 변수 없으면 같은 인스턴스 재사용
for (int i = 0; i < 1000; i++) {
Runnable r = () -> System.out.println("Hello");
// JVM 이 같은 인스턴스 재사용 (보통)
}
// 캡처 변수 있는 람다:
for (int i = 0; i < 1000; i++) {
int x = i;
Runnable r = () -> System.out.println(x);
// 매번 새 인스턴스 (x 가 다르니)
}
// 익명 클래스 — SAM 아닌 인터페이스도 가능
ListIterator<String> iter = new ListIterator<String>() {
@Override public boolean hasNext() { return false; }
@Override public String next() { return null; }
@Override public boolean hasPrevious() { return false; }
@Override public String previous() { return null; }
// ... 여러 메서드
};
// 람다 — SAM 만
Runnable r = () -> {}; // OK (SAM)
// ListIterator<String> iter = () -> {}; // ❌ SAM 아님
| 항목 | 익명 클래스 | 람다 |
|---|---|---|
| 등장 | Java 1.1 | Java 8 |
| 문법 | 길음 | 간결 |
| this | 자신 | 외부 |
| 변수 그림자 | 가능 | X |
| .class 파일 | 추가됨 | X |
| 구현 | 일반 객체 | invokedynamic |
| 인스턴스 재사용 | X | 가능 (캡처 X 시) |
| SAM 강제 | X | O |
| 성능 | ↓ | ↑ |
람다 권장:
✓ 함수형 인터페이스 (SAM)
✓ 간결함 우선
✓ 거의 모든 경우
익명 클래스 권장:
✓ 여러 메서드 구현 (SAM 아님)
✓ 상태 (필드) 필요
✓ 자신의 this 필요
✓ 옛 인터페이스 (변경 안 됨)
예시:
Runnable, Comparator, Consumer 등 → 람다
ListIterator, MouseListener (여러 메서드) → 익명 클래스
복잡한 상태 + 메서드 → 익명 클래스
public class CallbackExamples {
// 1. 단순 콜백 — 람다
public void onShipmentSaved(Shipment s, Consumer<Shipment> callback) {
repository.save(s);
callback.accept(s);
}
public void usage1() {
onShipmentSaved(shipment, s -> log.info("Saved: {}", s.getId()));
}
// 2. 복잡한 상태 — 익명 클래스
public void registerComplexListener() {
repository.onSave(new ShipmentListener() {
private int count = 0;
@Override
public void onSave(Shipment s) {
count++;
if (count > 100) {
log.warn("Too many saves: {}", count);
}
}
@Override
public void onError(Exception e) {
log.error("Error", e);
count = 0;
}
});
}
// 3. SAM 콜백 — 람다 또는 메서드 참조
public void registerSimple() {
eventBus.register(event -> log.info("Event: {}", event));
// 또는
eventBus.register(this::handleEvent);
}
private void handleEvent(ShipmentEvent event) {
log.info("Event: {}", event);
}
}
interface ShipmentListener {
void onSave(Shipment s);
void onError(Exception e);
// SAM 아님 → 람다 X
}
람다 vs 익명 클래스의 차이는?
답:
1. this:
컴파일:
인스턴스:
변수:
SAM:
권장:
public class Demo {
private String name = "Demo";
public void test() {
Runnable r = new Runnable() {
String name = "Anonymous";
@Override
public void run() {
// 익명 클래스의 this — 자기 자신
System.out.println(this.name); // "Anonymous"
System.out.println(this.getClass().getName()); // Demo$1
// 외부 this 는 OuterClass.this
System.out.println(Demo.this.name); // "Demo"
}
};
r.run();
}
}
public class Demo {
private String name = "Demo";
public void test() {
Runnable r = () -> {
// 람다의 this — 외부 (Demo)
System.out.println(this.name); // "Demo"
System.out.println(this.getClass().getName()); // Demo (Demo$1 아님)
// 람다는 자체 this 없음
// 람다는 익명 객체 X (개념적으로)
};
r.run();
}
}
익명 클래스의 this:
Demo 객체
├── name: "Demo"
└── 메서드 안에서 익명 클래스 생성
├── Demo$1 객체 (별도)
│ ├── name: "Anonymous"
│ └── this → Demo$1
└── Demo.this → Demo
람다의 this:
Demo 객체
├── name: "Demo"
└── 메서드 안에서 람다
└── 람다 (별도 객체 X 같은 개념)
└── this → Demo (외부)
public class Service {
private String prefix = "[Service] ";
public void doSomething() {
// 익명 클래스
executor.submit(new Runnable() {
@Override
public void run() {
log("익명 클래스"); // ❌ Service.this.log() 필요
// 또는
Service.this.log("익명 클래스");
}
});
// 람다
executor.submit(() -> {
log("람다"); // ✓ 외부 메서드 그대로
// this 가 Service 이니
// 또는
this.log("람다");
});
}
private void log(String msg) {
System.out.println(prefix + msg);
}
}
public class Demo {
String name = "Demo";
public void test() {
Runnable r = () -> {
// 람다 안의 익명 클래스
Runnable inner = new Runnable() {
@Override
public void run() {
// 익명 클래스의 this
System.out.println(this.getClass().getName()); // Demo$1
System.out.println(Demo.this.name); // "Demo"
}
};
inner.run();
// 람다의 this
System.out.println(this.name); // "Demo"
};
r.run();
}
}
| 위치 | this 의 의미 |
|---|---|
| 일반 메서드 | 이 객체 |
| 익명 클래스 (안) | 익명 클래스 객체 |
| 익명 클래스 (안) + Outer.this | 외부 객체 |
| 람다 (안) | 외부 메서드의 this (외부 객체) |
| 람다 (안) + Outer.this | 같은 효과 (외부) |
| static 메서드 | this 사용 X |
// 옛 익명 클래스의 위험
public class MemoryLeakRisk {
private byte[] hugeData = new byte[100_000_000]; // 100MB
public Runnable getTask() {
return new Runnable() {
@Override
public void run() {
System.out.println("Hello");
// hugeData 사용 X
// 하지만 익명 클래스가 외부 인스턴스 참조
// → MemoryLeakRisk 인스턴스 GC X
// → 100MB 메모리 누수 가능
}
};
}
// 람다도 비슷한 문제
public Runnable getTaskLambda() {
return () -> {
System.out.println("Hello");
// 외부 변수 캡처 시 this 도 캡처
// hugeData 참조하면 큰 메모리 잡고 있음
};
}
// 해결:
public Runnable getTaskStatic() {
// 정적 메서드 또는 별도 클래스
return MemoryLeakRisk::staticTask;
}
private static void staticTask() {
System.out.println("Hello");
}
}
@Component
public class ShipmentProcessor {
private final String workerName = "shipment-worker";
private int processedCount = 0;
// 1. 람다 안에서 외부 메서드/필드 접근
public void processAll(List<Shipment> shipments) {
shipments.forEach(s -> {
processSingle(s); // ✓ this.processSingle (this = ShipmentProcessor)
processedCount++; // ✓ this.processedCount
log("Processed " + s.getId());
});
}
private void processSingle(Shipment s) {
// 처리
}
private void log(String msg) {
System.out.println(workerName + ": " + msg);
}
// 2. 익명 클래스 — Outer.this 필요
public void processAllAnonymous(List<Shipment> shipments) {
for (Shipment s : shipments) {
new Runnable() {
@Override
public void run() {
ShipmentProcessor.this.processSingle(s);
ShipmentProcessor.this.processedCount++;
}
}.run();
}
}
// 3. 콜백 — this 활용
public CompletableFuture<Void> processAsync(Shipment s) {
return CompletableFuture.runAsync(() -> {
this.processSingle(s);
this.processedCount++;
});
}
}
람다와 익명 클래스의 this 차이는?
답:
1. 익명 클래스:
람다:
이유:
활용:
주의:
변수 캡처 (Variable Capture):
람다 (또는 익명 클래스) 가 외부의 변수를 사용하는 것.
종류:
1. 지역 변수 (메서드 내 변수)
2. 인스턴스 변수 (필드)
3. static 변수 (클래스 변수)
규칙:
- 인스턴스/static 변수: 자유롭게 (변경 가능)
- 지역 변수: effectively final 만
effectively final:
실질적으로 final 인 변수.
- final 키워드 없어도
- 선언 후 값 변경 X
- 컴파일러가 "사실상 final" 로 판단
Java 8+ 부터 람다가 지역 변수 캡처 시 요구.
public void test() {
int x = 10;
// 1. final 명시 (옛 방식)
final int y = 20;
// 2. effectively final (Java 8+)
int z = 30;
// z 를 어디서도 변경 X
// → effectively final
Runnable r = () -> {
System.out.println(x); // OK (effectively final)
System.out.println(y); // OK (final)
System.out.println(z); // OK (effectively final)
};
// ❌ z 변경 시 캡처 X
// z = 40;
// → "Variable used in lambda should be effectively final"
}
이유:
1. 동시성 안전
- 람다가 다른 스레드에서 실행 가능
- 지역 변수가 변경되면 race condition
2. 람다의 본질
- 람다 = 함수
- 함수는 호출 시점의 값으로 동작
- 호출 후 변경 무의미
3. JVM 구현
- 람다는 캡처된 값을 자신의 필드로
- "복사" 의 의미
- 원본 변경해도 람다 안의 값 변경 X
4. 가독성
- 람다 안의 변수 = 외부와 같음 보장
- 디버깅 용이
// 함정 1: 반복문에서 변수 변경
List<Runnable> tasks = new ArrayList<>();
for (int i = 0; i < 10; i++) {
// ❌ i 가 변경됨 → effectively final X
tasks.add(() -> System.out.println(i));
}
// 해결: 새 변수
for (int i = 0; i < 10; i++) {
int captured = i; // ★ 새 변수 (effectively final)
tasks.add(() -> System.out.println(captured));
}
// 또는 for-each
for (int i : numbers) {
// i 는 each loop 마다 새 변수
tasks.add(() -> System.out.println(i)); // OK
}
// 또는 IntStream
IntStream.range(0, 10).forEach(i ->
tasks.add(() -> System.out.println(i)));
public class Counter {
private int count = 0; // 인스턴스 변수
public Runnable getIncrementer() {
return () -> {
count++; // ✓ 인스턴스 변수는 OK
// 외부 this 의 count
};
}
public void test() {
Runnable r = getIncrementer();
r.run(); // count = 1
r.run(); // count = 2
r.run(); // count = 3
}
}
// 이유:
// - 인스턴스 변수는 this 의 필드
// - 람다가 this 캡처
// - this 의 필드는 변경 가능 (this 자체는 final)
// 지역 변수가 컬렉션이면?
public void test() {
List<Integer> nums = new ArrayList<>();
Runnable r = () -> {
nums.add(42); // ✓ 가능!
// nums 변수 자체는 변경 X (effectively final)
// nums 가 가리키는 객체는 변경 가능
};
r.run();
System.out.println(nums); // [42]
}
// 주의:
// - nums = new ArrayList<>(); ❌ 변수 재할당 X
// - nums.add(...); ✓ 객체 변경 OK
// 캡처 X — 가벼움
Runnable r1 = () -> System.out.println("Hello");
// 캡처할 변수 없음
// JVM 이 같은 인스턴스 재사용 가능
// 캡처 O — 약간 비용
String msg = "Hello";
Runnable r2 = () -> System.out.println(msg);
// msg 가 람다의 필드로
// 매번 새 인스턴스 (보통)
// 많은 변수 캡처
String prefix = "...";
int count = 10;
double price = 99.9;
Runnable r3 = () -> {
System.out.println(prefix + count + price);
};
// 3개 변수 모두 캡처
// 람다 객체에 3개 필드
// 성능 영향:
// - 일반적으로 무시할 수준
// - 빈번한 람다 생성 시만 주의
public class ShipmentProcessor {
private int totalProcessed = 0;
// 1. 지역 변수 캡처
public void process(List<Shipment> shipments) {
LocalDateTime start = LocalDateTime.now(); // effectively final
shipments.forEach(s -> {
log.info("Processing at {}: {}", start, s.getId());
});
}
// 2. 반복문 — 새 변수
public List<Runnable> createTasks(List<Shipment> shipments) {
List<Runnable> tasks = new ArrayList<>();
for (int i = 0; i < shipments.size(); i++) {
int index = i; // ★ 새 변수
Shipment s = shipments.get(i); // 새 변수 (반복마다)
tasks.add(() -> {
log.info("Task {}: {}", index, s.getId());
});
}
return tasks;
}
// 3. 인스턴스 변수
public Runnable getCounter() {
return () -> {
totalProcessed++; // ✓ 인스턴스 변수 자유
log.info("Total: {}", totalProcessed);
};
}
// 4. 컬렉션 활용
public Function<Shipment, Void> getAccumulator() {
List<Shipment> processed = new ArrayList<>();
return s -> {
processed.add(s); // ✓ 컬렉션 변경 OK (변수 재할당 X)
return null;
};
}
}
변수 캡처와 effectively final 의 의미는?
답:
1. effectively final:
지역 변수:
인스턴스 변수:
이유:
흔한 함정:
람다의 구현:
Java 7+ 부터 도입된 invokedynamic 명령어 활용.
람다 컴파일 결과:
- .class 파일 추가 X
- invokedynamic 명령어 + LambdaMetafactory
- 런타임에 동적으로 클래스 생성
장점:
- 가벼움
- JIT 최적화 용이
- JVM 구현 자유 (변경 가능)
익명 클래스의 컴파일:
- Demo$1.class 같은 별도 클래스 생성
- 디스크/메모리 사용 ↑
- 클래스 로딩 비용
람다의 컴파일:
- 클래스 파일 추가 X
- LambdaMetafactory.metafactory 호출
- 런타임에 lambda 인스턴스 생성
- 더 가벼움
성능:
- 람다가 약간 빠름
- 작은 차이 (대부분 경우 무관)
// 람다의 런타임 클래스 확인
Runnable r = () -> System.out.println("Hello");
System.out.println(r.getClass());
// 예: class Demo$$Lambda$1/0x000000080003d440
// 익명 클래스의 클래스
Runnable r2 = new Runnable() {
@Override
public void run() { }
};
System.out.println(r2.getClass());
// 예: class Demo$1
// 차이:
// - 람다: 동적 생성된 이름 (런타임마다 다를 수 있음)
// - 익명: 컴파일 시 결정된 이름
// 캡처 변수 없는 람다 — 인스턴스 재사용
for (int i = 0; i < 1000; i++) {
Runnable r = () -> System.out.println("Hello");
// JVM 이 보통 같은 인스턴스 반환
}
// 캡처 변수 있는 람다 — 매번 새 인스턴스
for (int i = 0; i < 1000; i++) {
int x = i;
Runnable r = () -> System.out.println(x);
// 매번 새 인스턴스 (x 가 다르니)
}
// 익명 클래스 — 항상 새 인스턴스
for (int i = 0; i < 1000; i++) {
Runnable r = new Runnable() {
@Override
public void run() { System.out.println("Hello"); }
};
// 매 반복마다 새 인스턴스 (캡처 무관)
}
public class LambdaBenchmark {
@Benchmark
public Runnable lambdaNoCapture() {
return () -> {};
}
@Benchmark
public Runnable lambdaWithCapture() {
int x = 10;
return () -> System.out.println(x);
}
@Benchmark
public Runnable anonymousClass() {
return new Runnable() {
@Override
public void run() { }
};
}
}
// 예상 결과 (대략):
// lambdaNoCapture: ~3 ns/op (인스턴스 재사용)
// lambdaWithCapture: ~10 ns/op
// anonymousClass: ~15 ns/op
// 차이는 매우 작음
// 일반적 코드는 무관
// 빈번한 람다 생성 시만 영향
// 람다
Function<String, Integer> length1 = s -> s.length();
// 메서드 참조
Function<String, Integer> length2 = String::length;
// 성능:
// - 둘이 거의 같음
// - 메서드 참조가 약간 더 효율 (JIT 인라인)
// - 차이는 무시할 수준
// 일반 for-loop
long sum = 0;
for (int i = 0; i < list.size(); i++) {
sum += list.get(i);
}
// Stream + 람다
long sum2 = list.stream().mapToInt(Integer::intValue).sum();
// 성능:
// - Stream: 약간 느림 (객체 생성 등)
// - 큰 데이터: 차이 작음
// - 작은 데이터: 차이 큼 (오버헤드)
// - 가독성: Stream 승
// 권장:
// - 일반적: Stream
// - 핫 루프 (성능 우선): for
// - 병렬: parallelStream
// 박싱 비용
Function<Integer, Integer> doubler = x -> x * 2;
// Integer (객체) ↔ int (기본 타입) 변환 = boxing
doubler.apply(5); // 5 박싱 → Integer 반환 (boxing)
// 더 효율적: IntFunction
IntFunction<Integer> doubler2 = x -> x * 2;
// int → Integer 반환 (한 번만 boxing)
// 가장 효율적: IntUnaryOperator
IntUnaryOperator doubler3 = x -> x * 2;
// int → int (boxing X)
// 많은 호출 시 차이 큼
// Stream API 의 IntStream 활용 권장
public class ShipmentPerformance {
// 1. 인스턴스 재사용 (캡처 X)
private static final Predicate<Shipment> IS_URGENT = s -> s.isUrgent();
// 한 번 생성, 재사용
public List<Shipment> filterUrgent(List<Shipment> shipments) {
return shipments.stream()
.filter(IS_URGENT) // 재사용
.toList();
}
// 2. 빈번한 호출 시 IntStream
public long totalWeight(List<Shipment> shipments) {
return shipments.stream()
.mapToLong(s -> s.getWeight().longValue()) // IntStream/LongStream
.sum();
// boxing 회피
}
// 3. 메서드 참조 (약간 더 효율)
public List<String> extractBlNos(List<Shipment> shipments) {
return shipments.stream()
.map(Shipment::getBlNo) // 메서드 참조
.toList();
}
// 4. 정적 람다 상수 (재사용)
public static final Comparator<Shipment> BY_WEIGHT =
Comparator.comparing(Shipment::getWeight);
public List<Shipment> sortByWeight(List<Shipment> shipments) {
return shipments.stream()
.sorted(BY_WEIGHT)
.toList();
}
}
람다의 내부 구현과 성능은?
답:
1. invokedynamic:
vs 익명 클래스:
인스턴스 재사용:
성능 차이:
권장:
| Q | 핵심 답변 |
|---|---|
| 람다 표현식? | 함수형 인터페이스의 간결한 구현 |
| 함수형 인터페이스? | SAM (Single Abstract Method) |
| @FunctionalInterface? | 컴파일러 검증 + 의도 명시 |
| Function/Consumer/Supplier/Predicate? | apply/accept/get/test |
| 메서드 참조 4가지? | 정적/특정 인스턴스/임의 인스턴스/생성자 |
| 람다 vs 익명 클래스 this? | 외부 vs 자신 |
| 람다의 컴파일? | invokedynamic |
| effectively final? | 실질적으로 변경 X |
| 지역 변수 캡처 조건? | effectively final |
| 인스턴스 변수? | 자유롭게 (this 캡처) |
| 람다 성능? | 익명 클래스보다 약간 빠름 |
| boxing 회피? | IntStream, IntFunction 등 |
답:
답:
// ❌ checked exception 던지면 컴파일 에러
Runnable r = () -> {
Thread.sleep(1000); // InterruptedException
// → 컴파일 에러
};
// 해결:
// 1. try-catch
Runnable r2 = () -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
// 2. 사용자 정의 함수형 인터페이스
@FunctionalInterface
interface ThrowingRunnable {
void run() throws Exception;
}
ThrowingRunnable r3 = () -> Thread.sleep(1000); // OK
답:
답:
// 람다 안에서 메서드 참조 사용
Function<List<String>, Stream<Integer>> fn = list ->
list.stream().map(String::length);
// OK
답:
1. 람다 등장
2. 핵심 4가지
3. 메서드 참조 + 캡처
이번 Unit에서 람다를 봤다면, 다음은 Stream API.
🚀 Phase 10 — 함수형 프로그래밍
✅ Unit 10.1 람다 표현식 ← 여기
⏭ Unit 10.2 Stream API
⏭ Unit 10.3 Collectors와 reduce (★ 마스터)
⏭ Unit 10.4 Optional
✅ Phase 1 ~ 9 완주 (42 Unit)
🚀 Phase 10 — 함수형 프로그래밍 (1/4 진행)
총: 43/43 Unit (3주차 거의 완주)
🚀 Phase 10 시작 — 함수형 프로그래밍 진입