모던 자바 인 액션 - 1장 기초

Daniel_Yang·2025년 4월 19일
0

Chapter 1. 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가?

정리
쉽게 말해 멀티코어, 병렬, 간결한 코드라는 요구사항 스트림 API를 도입해서 해결한다.
이는 동작 파라미터화와 인터페이스의 디폴트 메서드를 기반으로 가능
어떻게 병렬처리? 쉽게 말하면, 라이브러리 내부에서 멀티 CPU를 이용해서 병렬 처리 걱정되는 부분: 데이터 가변성 문제 ⇒ 공유된 데이터에 접근 x

🌟 자바 8에서 스트림(Stream) API가 왜 등장했을까?

현대 애플리케이션 개발 환경은 예전과는 많이 달라졌는데, 특히 두 가지 큰 변화
1. 멀티코어 CPU의 대중화
→ 성능을 더 끌어올리기 위해 병렬처리가 점점 중요해졌다.
2. 간결하고 유지보수하기 쉬운 코드의 필요성 증가
→ 반복문, 조건문 등으로 장황하게 짜던 로직을 더 선언적으로 표현하고 싶다.

이런 흐름 속에서 자바 8은 "더 간결한 코드", "멀티코어를 쉽게 활용하는 병렬성"이라는 목표를 가지고 스트림 API를 도입했다.

자바 8 설계의 밑바탕을 이루는 세가지 프로그래밍 개념

  • 스트림, 메서드 코드 전달, 인터페이스의 디폴트 메서드

🚀 스트림 API란?

간단히 말하면, 데이터를 조립 라인처럼 처리할 수 있게 도와주는 도구

  • 데이터를 하나씩 전달하면서 여러 작업(필터링, 매핑, 정렬 등)을 단계적으로 처리한다.
  • 외부 반복(for-each) 대신 내부 반복을 사용하여 코드를 훨씬 간결하게 만든다.
  • 그리고 parallelStream()을 사용하면 병렬 처리도 손쉽게 가능
    - 멀티코어 CPU를 잘 활용하고 싶은데, 멀티스레딩 코드는 너무 복잡

✅ 예시 비교

기존 방식 (자바 8 이전):
for (Transaction t : transactions) {
    if (t.getPrice() > 1000) {
        // 그룹화 처리
    }
}


자바 8 스트림 방식 → 훨씬 간결
Map<Currency, List<Transaction>> grouped =
    transactions.stream()
                .filter(t -> t.getPrice() > 1000)
                .collect(Collectors.groupingBy(Transaction::getCurrency));

🧠 병렬 처리

스트림의 진짜 강점 중 하나는 병렬 처리를 직접 스레드를 다루지 않고도 가능하게 해준다는 점

  • 라이브러리 내부에서 자동으로 스트림을 분할(fork) 해서 여러 CPU 코어가 병렬로 처리하고, 마지막에 결과를 합치는(join) 방식.
  • 개발자는 병렬화를 위해 복잡한 코드나 synchronized 같은 동기화 코드를 직접 작성할 필요가 없다.

💡 유닉스 파이프 예시로 이해하기

cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3
  • 파일 읽고 → 소문자로 변환 → 정렬 → 끝에서 3줄 출력
  • 각 단계가 파이프로 연결된 조립 라인처럼 동작함
  • 자바 스트림도 이처럼 .filter(), .map(), .sorted() 등으로 연결함

🔧 함수형 프로그래밍 기법들

스트림은 자바 8에서 도입된 함수형 프로그래밍 기능들과 연관된다.

1. 람다 표현식메서드 참조

메서드를 값처럼 전달할 수 있게 됐다. 스트림 안에서 .filter(), .map() 같은 메서드를 사용할 때, 조건이나 행동을 함수로 넘기는데 그걸 람다메서드 참조로 작성한다.

// 자바 8 이전
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
    public boolean accept(File f) {
        return f.isHidden();
    }
});

// 자바 8 이후
File[] hiddenFiles = new File(".").listFiles(File::isHidden);
  • File::isHidden은 메서드 참조이고, f -> f.isHidden() 같은 람다 표현식도 쓸 수 있다.
  • 동작을 파라미터로 넘긴다는 개념입니다.

2. Predicate, Function 등 함수형 인터페이스

스트림에서 흔히 쓰는 filter, map 같은 메서드는 함수형 인터페이스를 인자로 받는다.

inventory.stream()
         .filter(apple -> apple.getWeight() > 150) // Predicate<Apple>
         .collect(Collectors.toList());

🧩 디폴트 메서드: 인터페이스의 변화 (인터페이스에서 메서드 정의 가능)

자바 8 이전에는 인터페이스에 메서드를 추가하면 모든 구현 클래스에서 강제로 구현해야 했다. 그래서 디폴트 메서드가 생겼다.

interface MyInterface {
    default void sayHello() {
        System.out.println("Hello from default method!");
    }
}
  • 이렇게 하면 기존 구현에 영향 없이 기능을 추가할 수 있고, 대표적인 예로 List.sort()가 자바 8부터는 인터페이스 안에 들어갔다.

☢️ 병렬성과 가변 상태

자바의 병렬성에서 중요한 건 공유 가변 데이터(Shared Mutable Data) 를 조심해야 한다.

  • 병렬로 동작하는 함수들이 같은 변수에 접근하거나 수정하면, 충돌과 에러가 발생할 수 있다.
  • 그래서 순수 함수(pure function), 즉 부작용(side effect)이 없는 함수가 병렬 처리에 유리.
  • 스트림에서 사용하는 filter, map 같은 함수는 가능한 순수하게 만들어야 안전하다.

1. 공유 가변 데이터의 위험성

List<String> list1 = Arrays.asList("A1","A2","B1","B2");
List<String> list2 = new ArrayList<>(); // 비-스레드 세이프 컬렉션

list1.parallelStream().forEach(t -> list2.add(t)); 
// 50% 확률로 ConcurrentModificationException 발생
  • 원인: 여러 스레드가 동시에 add() 호출시 내부 배열 구조가 파괴됨
  • 해결책Collections.synchronizedList() 또는 CopyOnWriteArrayList 사용

2. 순수 함수 vs 상태 변경 함수

// 병렬 실행시 결과 예측 가능 (스레드 세이프)
public int pureSum(int a, int b) {
    return a + b; // 입력값만 사용, 외부 상태 변경 없음
}

// 10,000회 병렬 호출시 결과값이 9,500~10,000 사이 무작위 출력
public class Counter {
    private int value = 0;
    
    public int unsafeIncrement() {
        return ++value; // 멀티스레드 환경에서 값 덮어쓰기 발생 가능
    }
}

3. 스레드 세이프 구현 기법

동기화(Synchronization) 방식

public class SafeCounter {
    private int value = 0;
    
    public synchronized void increment() {
        value++;
    }
}

Atomic 변수 활용

public class AtomicCounter {
    private AtomicInteger value = new AtomicInteger(0);
    
    public void increment() {
        value.incrementAndGet(); // CAS(Compare-And-Swap) 연산 사용
    }
}

병렬 처리 성능 비교표

방식스레드 안정성코드 복잡도
기본 for-loop낮음간단
synchronized 블록높음복잡
AtomicInteger높음중간
병렬 스트림높음간단

결론: Java 8의 병렬 스트림은 ForkJoinPool을 활용해 작업을 자동 분할하지만, 공유 가변 데이터 사용시 명시적인 동기화가 필요하다. 함수형 프로그래밍 패러다임을 통해 상태 변경을 최소화하고, Atomic 클래스나 동시성 컬렉션을 활용하는 것 추천. 병렬 처리 성능 향상과 안정성 보장을 위해 순수 함수 설계와 불변 객체 사용을 권장


📌 정리

구분컬렉션 (for-each 등)스트림 API
반복 방식외부 반복내부 반복
병렬 처리직접 스레드 다뤄야 함parallelStream()으로 쉽게
가독성반복문 많아 장황간결한 선언형 코드
병렬 안정성synchronized 필요순수 함수 기반 병렬 가능

Predicate란?

  • 메서드의 인수로 받아 true나 false를 반환하는 함수를 프레디케이트라고 한다.

Chapter 2장 동작 파라미터화 코드 전달하기

"왜 이런 방식이 필요한지", "기존 코드에서 어떤 문제를 해결하려고 했는지"

✅ 핵심 요약

  • 동작 파라미터화는 코드를 추상화하고 재사용성을 높여준다.
  • 전략 패턴은 동작을 캡슐화해서 전달하는 가장 대표적인 방식입
  • Java 8의 Predicate람다 표현식은 동작 파라미터화를 손쉽게 구현할 수 있도록 도와준다.
  • 이 흐름을 잘 이해하고 있으면, 유연하고 유지보수하기 쉬운 코드를 작성 가능

💡 동작 파라미터화란?

어떤 기능을 구현할 때, "어떻게 실행할 것인가?" 를 미리 정하지 않고 나중에 결정하도록 코드를 작성하는 방식

동작 파라미터화 패턴은 동작을 캡슐화한 다음에 메서드로 전달해서 메서드의 동작을 파라미터화
쉽게 말해, 실행될 행동(동작)을 파라미터처럼 전달할 수 있게 만드는 것. 변하는 요구사항에 유연하게 대처할 수 있도록 도와준다.

필요성

동작을 "데이터처럼 전달"할 수 있는 구조, 즉 동작을 추상화

초기에는 하나의 동작만 처리해도 충분했을 수 있지만, 시간이 지나고 요구사항이 바뀌면서 더 많은 조건이 추가된다. 이때마다 새로운 메서드를 만들거나 코드를 복사해서 붙이면 유지보수가 어렵고 코드가 지저분해진다.


🧱 추상화를 통한 해결 방식

  1. 인터페이스를 통해 공통된 동작을 정의하고,
  2. 각 조건(전략)을 별도로 구현 클래스로 만들며,
  3. 이 구현들을 하나의 메서드에 전달하여 실행되도록 합니다.

이렇게 하면, 메서드는 "무엇을 실행할지" 에 대해 알 필요 없이, 전달받은 동작(전략)을 수행하기만 하면 된다. 이게 바로 전략 패턴 (Strategy Pattern)


🧠 전략 패턴과 Predicate

  • 전략 패턴(Strategy Pattern)은 디자인 패턴 중 하나로, 알고리즘을 정의하고 각각을 별도의 클래스로 캡슐화한 다음, 이들을 교체할 수 있게 만드는 패턴
  • 전략 패턴을 사용하면 알고리즘을 쉽게 교체하고 확장할 수 있으며, 알고리즘의 사용자와 구현체를 분리할 수 있다.
  • 용어
    • 알고리즘: 어떤 문제를 해결하기 위한 일련의 명령들의 집합. 사실상 각 전략이 알고리즘이다 ⇒ 특정 문제를 해결하는 방법 또는 특정 작업을 수행하는 방법을 정의
    • 행위: 실제로 알고리즘이나 작업을 수행하는 메서드. 여러 알고리즘 또는 전략을 캡슐화한 각 클래스가 이 행위를 구현 ex) 결제
    • 전략: 특정 행위를 구현하는 방법을 나타냅니다. 이것은 일반적으로 인터페이스 또는 추상 클래스로 정의되며, 각 알고리즘을 나타내는 구체적인 클래스가 이를 구현
      ex) 결제방법(신용카드 결제, 비트코인 결제)

Java 8부터는 이를 더 간결하게 처리할 수 있도록 Predicate 함수형 인터페이스가 도입되었다. Predicate는 입력값을 받아 boolean을 반환하는 조건식을 의미. 주로 컬렉션의 항목에 대한 필터링, 검증, 조건적인 처리 등에 사용된다.
=> 조건에 따라 filter가 다르게 동작 => 이러한 전략 패턴은 객체의 행위를 동적으로 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장할 수 있도록 한다.


🚀 동작 파라미터화 발전 단계

1. 동작 하드코딩

  • 특징: 각 요구사항마다 별도 메서드 작성
  • 문제점: 중복 코드 증가, 유지보수 어려움
public List<Apple> filterGreenApples() { /*...*/ }
public List<Apple> filterHeavyApples() { /*...*/ }

2. 조건별 메서드 분리

  • 특징: 색상/무게 등 조건을 인자로 전달
  • 문제점: 인자 조합이 복잡해지면 관리 난항
public List<Apple> filterApples(Color color, int weight) { /*...*/ }

3. 전략 패턴 적용

  • 특징: 조건을 객체화하여 유연성 확보
  • 장점: 확장성 ↑, 단일 책임 원칙 준수
interface ApplePredicate { boolean test(Apple apple); }
class HeavyApplePredicate implements ApplePredicate { /*...*/ }

4. 익명 클래스 활용

  • 특징: 클래스 선언 없이 즉석 구현
  • 단점: 코드 가독성 저하, 장황함
filterApples(new ApplePredicate() {
    @Override public boolean test(Apple a) { return a.weight > 150; }
});

5. 람다 표현식 도입

  • 특징: 간결한 함수형 프로그래밍
  • 장점: 코드 길이 감소, 의도 명확화
`filterApples(a -> a.weight > 150 && a.color == RED);`

6. 제네릭 확장 => 추상화를 통한 문제해결

  • 특징: 타입을 일반화하여 범용적 사용
  • 적용 예: 리스트, 스트림, Optional 등
`public <T> List<T> filter(List<T> list, Predicate<T> p) { /*...*/ }`

과정

// # 첫번째 시도
public static void main(String[] args) {  
	List<Apple> result = new ArrayList<>();  
	for (Apple apple : Apples){  
		if (apple.color.equals(Apple.Color.GREEN)){  
			result.add(apple);  
		}  
	}  
	result.stream().forEach(apple -> System.out.println(apple.color));  
}  

// # 두번째 시도
public static void main(String[] args) {  
	appleColorFilter(Apples,Apple.Color.GREEN).stream().forEach(apple -> System.out.println(apple.color));  
}  

// # 세번째 시도 : 무게도 인수화해서 유연하게 대처 but 중복 로직
public static List<Apple> appleWeightFilter(List<Apple> apples, int weight){
	...
}

// # 네번째 시도 : 중복 코드를 하나의 메서드로 정리
public static List<Apple> appleFilter(List<Apple> apples, Apple.Color color, int weight){}
// 이렇듯 의미없는 0이 들어 가버린다.
appleFilter(apples, Apple.Color.Green, 0)

// # 다섯번째 시도 : 동작 파라미터화 도입

// 필터에 따른 선택 조건을 결정하는 인터페이스를 정의  
interface ApplePredicate{  
	public boolean filter(Apple apple);  
}  
  
/*컬러 필터*/  
class AppleColorFilter implements ApplePredicate{  
	@Override  
	public boolean filter(Apple apple) {  
		return apple.color.equals(Apple.Color.GREEN);  
	}  
}  
/*무게 필터*/  
class AppleWeightFilter implements ApplePredicate{  
	@Override  
	public boolean filter(Apple apple) {  
		return apple.weight >= 150;  
	}  
}

// 이처럼 각 항목에 적용할 동작을 분리할 수 있는다는 것은 동작 파라미터화의 강점
public static void main(String[] args) {  
	appleFilter(Apples,new AppleColorFilter()).stream().forEach(apple -> System.out.println(apple.color));  
	appleFilter(Apples,new AppleWeightFilter()).stream().forEach(apple -> System.out.println(apple.color));  
}

// # 여섯번째 시도 : 익명 클래스를 통해 인스턴스화 과정 생략(클래스 선언과 인스턴스화를 동시에)

public static void main(String[] args){  
	appleFilter(Apples,new ApplePredicate(){ // 익명 객체 활용  
		@Override  
		public boolean filter(Apple apple){  
			return apple.color.equals(Apple.Color.GREEN);  
		}  
	}).stream().forEach(apple->System.out.println(apple.color));  
}

// # 일곱번째 시도 : 람다 표현식
public static void main(String[] args) {  
	appleFilter(Apples,(Apple apple) -> Apple.Color.GREEN.equals(apple.color));  
}

// 추상화
interface ItemPredicate<T>{  
	public boolean filter(T item);  
}  
  
----  
  
public static <T> List<T> filter(List<T> list, ItemPredicate<T> p){  
	List<T> result = new ArrayList<>();  
	for (T item : list){  
		if (p.filter(item)){  
			result.add(item);  
		}  
	}  
	return result;  
}

Chapter 3. 람다 표현식

중요한 부분

  • 람다 표현식 특징
  • 람다 표현식과 함수형 인터페이스
  • 람다 캡쳐링 및 제약조건

람다 표현식 도입 이유

  • 간결한 방식으로 익명 함수를 표현할 수 있게 한다.
  • 기존 익명 클래스의 장황함을 줄이고, 가독성을 높이기 위해 도입.
  • Java가 함수형 프로그래밍 패러다임을 지원하기 위한 핵심 요소.
  • 자바 컴파일러의 추론에 의존

특징

  • 익명성, 함수, 전달, 간결성

예시 설명

// 기존 익명 클래스 방식
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello");
    }
};

// 람다 표현식 방식
// 파라미터 리스트, 화살표, 람다 바디로 구성된다.
Runnable r2 = () -> System.out.println("Hello");
// 바디 : 람다의 반환값에 해당하는 표현식

장점

  • 코드의 간결성 - 람다를 사용하면 불필요한 반목문을 삭제할 수 있고 복잡한 식을 단순하게 표현할 수 있습니다.
  • 지연연상 수행 - 람다는 지연연상을 수행함으로써 불필요한 연산을 최소화할 수 있습니다.
  • 병렬처리 가능 - 멀티쓰레드를 활용하여 병렬처리할 수 있습니다.

단점

  • 람다식의 호출이 어렵습니다.
  • 불필요하게 너무 많이 사용하면 오히려 가독성을 떨어 뜨릴 수 있습니다.

실행 어라운드 패턴과 람다

변화가 잦은 Process 부분을 람다로 분리하여 유연하고 확장 가능한 구조를 만들 수 있다. 이것이 실행 어라운드 패턴의 핵심

  • 람다 표현식은 반복되는 설정-처리-정리 작업의 공통 구조를 간결하게 표현하는 데 유용합니다. 그 대표적인 사례가 실행 어라운드 패턴 (Execute Around Pattern)

예를 들어, 파일 처리 작업은 다음과 같은 구조를 가진다:
1. 자원 열기 (Open)
2. 작업 처리 (Process)
3. 자원 닫기 (Close)

이러한 공통 패턴을 일반화하면 다음과 같이 표현할 수 있다:

  • SetUp: 자원 준비
  • Process: 실제 처리 로직
  • CleanUp: 자원 정리

Process 부분만 다르고 나머지는 동일하다면, 이 부분만 외부에서 파라미터로 전달하면 재사용성이 높아진다. 이때 활용할 수 있는 것이 바로 람다 표현식!!!

단계

  • 동작파라미터화를 위해 함수형 인터페이스를 통해 동작 전달 -> 동작 실행 -> 람다 전달
// #전통 방식
public String processFile() throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
        return br.readLine(); // 핵심 작업 (변경 가능한 부분)
    }
}

// #함수형 인터페이스 정의
@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader br) throws IOException;
}

// #공통 로직 분리
public String executeAround(BufferedReaderProcessor processor) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
        return processor.process(br); // 람다로 핵심 작업 전달
    }
}

// #람다 실행

// 한 줄 읽기
String line1 = executeAround(br -> br.readLine());

// 두 줄 읽기
String line2 = executeAround(br -> br.readLine() + br.readLine());

// 가장 긴 줄 찾기
String longestLine = executeAround(br -> 
    br.lines().max(Comparator.comparingInt(String::length)).orElse("")
);

그런데 내가 사용하고 싶은 람다를 위해, 매번 함수형 인터페이스(시그네처)를 만들어 줘야하는걸까?

기본 제공하는 함수형 인터페이스

  • 람다 표현식을 사용하기 위해선, 그에 대응하는 함수 디스크립터가 필요하다. 그런데 람다 표현식을 사용할 때 마다 함수 디스크립터를 정의하기는 귀찮다. 그래서 자바는 java.util.function 패키지로 여러 가지 함수형 인터페이스를 제공한다
// Predicate
@FunctionalInterface
public interface Predicate<T> {

	boolean test(T t);
    
    ...
}

// Consumer
@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

	...

// Function
@FunctionalInterface
public interface Function<T, R> {

	R apply(T t);
	
    ...

// 그 외로, autoboxing을 피할 수 있는 특별한 함수형 인터페이스를 제공
함수형 인터페이스함수 디스크립터
Predict<T>T -> boolean
Consumer<T>T -> void
Function<T, R>T -> R
Supplier<T>() -> T
UnaryOperator<T>T -> T
BinaryOperator<T>(T, T) -> T
BiPredicate<L, R>(T, U) -> boolean
BiConsumer<T, U>(T, U) -> void
BiFunction<T, U, R>(T, U) -> R

어디서 어떻게 사용하나?

Java 8부터 도입된 람다 표현식(lambda expression)은 코드를 더욱 간결하고 유연하게 만들어 주는 기능이다. 특히, 함수형 인터페이스와 함께 사용할 때 그 진가를 발휘하는데, 자바에서 람다 표현식을 어디에, 어떻게 사용하면 좋은지, 그리고 그 배경 개념인 함수 디스크립터, 실행 어라운드 패턴, 형식 검사와 추론까지 함께 정리한다.

1. 함수형 인터페이스와 람다 표현식

람다 표현식은 함수형 인터페이스의 구현을 간단하게 표현하는 문법입니다.

  • 함수형 인터페이스란, 정확히 하나의 추상 메서드를 가지는 인터페이스를 말합니다.
    • 이를 명시적으로 보장하기 위해 @FunctionalInterface 어노테이션을 사용할 수 있습니다.
    • 이 어노테이션을 붙이면, 컴파일러는 해당 인터페이스에 추상 메서드가 2개 이상 정의되어 있을 경우 오류를 발생시켜 줍니다.

람다 표현식을 사용하면 이 함수형 인터페이스의 추상 메서드 구현을 익명 객체처럼 간단하게 작성할 수 있습니다. 람다 표현식은 이 인터페이스의 구현체처럼 사용되며, 해당 추상 메서드의 시그니처(파라미터와 반환 타입)를 기준으로 컴파일러가 타입을 추론합니다. 이를 함수 디스크립터(Function Descriptor) 라고 부릅니다.

Runnable r = () -> System.out.println("Hello, world!");

2. 함수 디스크립터(Function Descriptor)란?

람다 표현식이 어떤 함수형 인터페이스에 사용될 수 있는지를 결정하는 중요한 개념이 **함수 디스크립터

  • 함수 디스크립터란, 함수형 인터페이스의 추상 메서드 시그니처를 의미합니다.
  • 함수 디스크립터는 "이 함수형 인터페이스가 어떤 입력을 받아 어떤 출력을 내는가"를 간단히 표현한 것
    람다 표현식에서의 시그니처는 해당 람다 표현식이 구현하려는 함수형 인터페이스의 추상 메서드 시그니처와 반드시 일치해야 합니다.
// 자바의 `Predicate<T>` 인터페이스는 다음과 같은 추상 메서드 시그니처를 가진다.
boolean test(T t);

// `Predicate<String>` 타입의 람다 표현식은 `(String s) -> boolean` 형태여야 하며, 이 시그니처가 일치하지 않으면 컴파일 오류가 발생
Predicate<String> isNotEmpty = s -> !s.isEmpty();

3. 시그니처(Signature)와 함수 디스크립터 구분하기

  • 예를 들어, Predicate<T>boolean test(T t) 라는 하나의 추상 메서드를 가지고 있습니다.

    • 따라서 이 시그니처, 즉 T -> boolean 형태가 바로 함수 디스크립터입니다.
      ```java
      - `boolean test(T t);` // 함수 디스크립터를 결정하는 함수형 인터페이스의 추상 메서드 시그니처
      - `T -> boolean` // 이 시그니처를 람다식 형태로 요약한 함수 디스크립터
      ```
  • 시그니처란, 메서드의 이름과 매개변수 타입 정보를 말합니다. 반환 타입은 포함되지 않습니다.

  • 예시:

    	```java
    	void print(String msg)
    	void print(int number)
    	```
  • 위 두 메서드는 이름은 같지만 매개변수 타입이 다르므로 서로 다른 시그니처를 가집니다. 이는 메서드 오버로딩과 밀접한 관련이 있다. 람다 표현식에서의 시그니처는 해당 표현식이 구현할 함수형 인터페이스의 추상 메서드 시그니처와 일치해야 하며, 이를 통해 형식 검사와 추론이 가능해진다.

함수형 인터페이스의 추상 메서드 시그니처(함수 디스크립터)와 람다 표현식의 시그니처가 일치하여

  • 컴파일러는 람다 표현식이 올바른 타입인지 검사할 수 있고,
  • 매개변수 타입을 명시하지 않아도 문맥을 통해 타입을 추론할 수 있으며,
  • 코드의 안전성과 명확성이 보장됩니다.

이것이 람다 표현식에서 시그니처가 중요한 이유이며, 함수형 인터페이스와 람다 표현식 간의 타입 일치를 통해 자바의 함수형 프로그래밍이 가능해진다.


형식 검사(Type Checking)와 형식 추론(Type Inference), 제약

형식 검사 (Type Checking)

  • 람다가 어떤 인터페이스에 사용될 수 있는지 결정하기 위해, 자바는 대상 형식(Target Type) 을 기준으로 람다의 시그니처를 검사합니다.
List<Company> companies = filter(list, (Company company) -> company.hasCafeteria());
// `filter()` 메서드의 정의를 확인해보면, 두 번째 인자는 `Predicate<Company>`
// `Predicate<Company>`는 `boolean test(Company c)` 추상 메서드를 가진다.
// 람다 `(Company company) -> company.hasCafeteria()`는 `Company → boolean` 구조이므로 시그니처가 일치
// 따라서 형식 검사가 통과

형식 추론 (Type Inference)

  • 자바 컴파일러는 문맥(Context)을 활용하여 람다 표현식의 타입을 자동으로 추론할 수 있다.
// 명시적 타입 지정
filter(list, (Company company) -> company.hasCafeteria());

// 추론 가능하므로 생략 가능
filter(list, company -> company.hasCafeteria());

이처럼, 컴파일러는 대상 형식의 함수 디스크립터를 기준으로 람다의 파라미터 타입을 추론한다. 덕분에 더 깔끔한 코드 작성이 가능

람다 표현식은 자바의 코드를 더 간결하고 유연하게 만들어주는 강력한 도구가 된 것이,

  • 함수형 인터페이스와의 결합
  • 실행 어라운드 패턴을 통한 재사용성 향상
  • 형식 검사와 추론을 통한 안전성과 간결함 확보

자유변수 사용에 제약이 있다. 이는 람다 캡처링 참고!


메서드 참조

  • 메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.
  • 메서드 레퍼런스는 특정 메서드만을 호출하는 람다의 축약형이다. 메서드 레퍼런스를 새로운 기능이 아니라 하나의 메서드를 참조하는 람다를 편리하게 표현할 수 있는 문법으로 간주 할 수 있다.
람다 표현식메서드 참조 형태
(Apple a) -> a.getWeight()Apple::getWeight
() -> Thread.currentThread().dumpStack()Thread.currentThread()::dumpStack
(s) -> System.out.println(s)System.out::println

참조 유형

  • 정적 메서드: Integer::parseInt
  • 특정 객체 인스턴스 메서드: myObject::instanceMethod
  • 클래스의 임의 객체 메서드: String::length

람다, 메소드 참조 활용하기 - 코드 발전 과정 예시

class Orange {
    private Integer weight;
    public Orange(Integer weight) { this.weight = weight; }
    public Integer getWeight() { return weight; }
}

// 동작 파라미터화
oranges.sort(new OrangeComparator());

// 익명 클래스
oranges.sort(new Comparator<Orange>() {
    public int compare(Orange o1, Orange o2) {
        return o1.getWeight().compareTo(o2.getWeight());
    }
});

// 람다 표현식
oranges.sort((o1, o2) -> o1.getWeight().compareTo(o2.getWeight()));

// comparing 사용
oranges.sort(Comparator.comparing(o -> o.getWeight()));

// 메서드 참조
oranges.sort(Comparator.comparing(Orange::getWeight));

생성자 참조

  • 함수형 인터페이스를 사용하면, 정적 메서드 참조를 사용하는 것과 유사한 형식으로 생성자도 참조할 수 있다.

람다 표현식을 조합할 수 있는 유용한 메서드

자바 8 API의 몇몇 함수형 인터페이스는 다양한 유틸리티 메서드를 제공한다. 이를 통해 람다 표현식을 조합하여 복잡한 람다 표현식을 만들 수 있다. 이는 함수형 인터페이스의 default method가 있기 때문

  • Comparator 조합 : comparing, 역정렬, thenComparing
  • Predicate 조합 : negate, and, or
  • Function 조합 : andThen, compose

람다 캡쳐링

람다의 바디(구현부)에는 파라미터를 제외하고도 바디 외부에 있는 변수를 참조할 수 있습니다.

이렇게 람다 시그니처의 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수를 자유 변수(Free Variable)이라고 부릅니다.이런 자유 변수를 참조하는 행위를 람다 캡쳐링(Lambda Capturing)이라고 합니다.

람다 캡쳐링의 제약 조건

  1. 지역 변수는 final로 선언되어 있어야 한다.
  2. final로 선언되지 않은 지역변수는 final처럼 동작해야 한다.
    • 값의 재할당이 일어나면 안됩니다.

1️⃣ "지역 변수는 final로 선언되어 있어야 한다" 예시 코드

final String email = "dia0312@naver.com";
int age4 = getAge(users, user -> email.equals(user.getEmail()));

2️⃣ "final로 선언되지 않은 지역변수는 final처럼 동작해야 한다." 예시 코드

**// 값이 한번 초기화되고 재할당 되지 않음 -> final 처럼 동작함.**
String email = "dia0312@naver.com";
int age4 = getAge(users, user -> email.equals(user.getEmail()));

❌ "final로 선언되지 않은 지역변수는 final처럼 동작해야 한다." 예시 실패 코드

String email = "dia0312@naver.com";
**// 값을 재할당하였기 때문에 -> final 처럼 동작하지 않음.**
email = "sonny@naver.com";
int age4 = getAge(users, user -> email.equals(user.getEmail()));

다음과 같이 "Variable used in lambda expression should be final or effectively final"에러 메시지를 확인할 수도 있습니다.

이유(chatGPT)

람다 표현식에서 외부 변수를 final 또는 effectively final로 제한하는 이유는 다음과 같은 핵심 원리에 기반한다.

1. 스레드 안전성 보장

  • 문제점: 람다는 다른 스레드에서 실행될 수 있습니다. 변수가 변경 가능하면 한 스레드에서 변수를 수정하는 동안 다른 스레드가 이 값을 읽을 때 예측 불가능한 결과가 발생할 수 있습니다.
  • 해결책final 또는 effectively final 변수만 허용함으로써, 람다가 캡처한 값이 변경되지 않음을 보장합니다. 이는 스레드 간 데이터 경쟁을 방지하고 안정성을 확보합니다.

2. 함수형 프로그래밍의 불변성 원칙

  • 불변 데이터: 함수형 프로그래밍은 부작용을 최소화하기 위해 데이터의 불변성을 강조합니다. 람다가 외부 변수를 변경하지 않으면 코드 추론이 쉬워지고 버그 가능성이 줄어든다.
  • 순수 함수: 람다가 외부 상태에 의존하지 않도록 하여, 입력에 따른 출력이 항상 일관되게 유지된다.

3. 변수의 수명과 일관성

  • 지역 변수의 복제: 지역 변수는 스택에 저장되며, 스레드마다 별도의 스택이 존재합니다. 람다는 변수의 복사본을 생성하여 사용하므로, 원본 변수가 메서드 종료 후 소멸되더라도 복사본으로 작업할 수 있다.
`int localVar = 10; Runnable r = () -> System.out.println(localVar); // 복사본 생성`
  • 복사본의 일관성: 복사된 값이 변경되지 않아야 람다 실행 시 예상치 못한 오류를 방지할 수 있다.

4. 힙(Heap) vs 스택(Stack) 메모리 영역

구분지역 변수인스턴스/정적 변수
저장 위치스택 (스레드별 독립적)힙 (모든 스레드에서 공유)
접근 방식복사본 사용 (원본 접근 불가)직접 참조 (동기화 필요)
제약 사유복사본과 원본의 불일치 방지공유 자원 관리의 복잡성
  • 지역 변수: 람다가 다른 스레드에서 실행될 때 원본 변수의 스택 프레임이 사라질 수 있으므로, 복사본을 사용합니다. 복사본의 불변성이 필수적입니다.
  • 인스턴스 변수: 힙에 저장되어 모든 스레드가 공유하므로 final 제약이 없지만, 동시 수정 시 명시적 동기화가 필요합니다.

5. 예측 가능성과 코드 단순화

  • 동작 예측: 람다가 참조하는 변수의 값이 변하지 않으면, 실행 시점에 따른 결과 차이가 발생하지 않습니다.
  • 복잡성 감소: 가변 변수를 허용하면 락 관리나 동기화 로직이 추가되어 코드가 복잡해집니다. final 제약으로 이를 방지합니다.

0개의 댓글