정리
쉽게 말해 멀티코어, 병렬, 간결한 코드라는 요구사항 스트림 API를 도입해서 해결한다.
이는 동작 파라미터화와 인터페이스의 디폴트 메서드를 기반으로 가능
어떻게 병렬처리? 쉽게 말하면, 라이브러리 내부에서 멀티 CPU를 이용해서 병렬 처리 걱정되는 부분: 데이터 가변성 문제 ⇒ 공유된 데이터에 접근 x
현대 애플리케이션 개발 환경은 예전과는 많이 달라졌는데, 특히 두 가지 큰 변화
1. 멀티코어 CPU의 대중화
→ 성능을 더 끌어올리기 위해 병렬처리가 점점 중요해졌다.
2. 간결하고 유지보수하기 쉬운 코드의 필요성 증가
→ 반복문, 조건문 등으로 장황하게 짜던 로직을 더 선언적으로 표현하고 싶다.
이런 흐름 속에서 자바 8은 "더 간결한 코드", "멀티코어를 쉽게 활용하는 병렬성"이라는 목표를 가지고 스트림 API를 도입했다.
자바 8 설계의 밑바탕을 이루는 세가지 프로그래밍 개념
간단히 말하면, 데이터를 조립 라인처럼 처리할 수 있게 도와주는 도구
for-each
) 대신 내부 반복을 사용하여 코드를 훨씬 간결하게 만든다.parallelStream()
을 사용하면 병렬 처리도 손쉽게 가능기존 방식 (자바 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));
스트림의 진짜 강점 중 하나는 병렬 처리를 직접 스레드를 다루지 않고도 가능하게 해준다는 점
synchronized
같은 동기화 코드를 직접 작성할 필요가 없다.cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3
.filter()
, .map()
, .sorted()
등으로 연결함스트림은 자바 8에서 도입된 함수형 프로그래밍 기능들과 연관된다.
메서드를 값처럼 전달할 수 있게 됐다. 스트림 안에서 .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()
같은 람다 표현식도 쓸 수 있다.스트림에서 흔히 쓰는 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) 를 조심해야 한다.
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란?
"왜 이런 방식이 필요한지", "기존 코드에서 어떤 문제를 해결하려고 했는지"
Predicate
와 람다 표현식은 동작 파라미터화를 손쉽게 구현할 수 있도록 도와준다.어떤 기능을 구현할 때, "어떻게 실행할 것인가?" 를 미리 정하지 않고 나중에 결정하도록 코드를 작성하는 방식
동작 파라미터화 패턴은 동작을 캡슐화한 다음에 메서드로 전달해서 메서드의 동작을 파라미터화
쉽게 말해, 실행될 행동(동작)을 파라미터처럼 전달할 수 있게 만드는 것. 변하는 요구사항에 유연하게 대처할 수 있도록 도와준다.
동작을 "데이터처럼 전달"할 수 있는 구조, 즉 동작을 추상화
초기에는 하나의 동작만 처리해도 충분했을 수 있지만, 시간이 지나고 요구사항이 바뀌면서 더 많은 조건이 추가된다. 이때마다 새로운 메서드를 만들거나 코드를 복사해서 붙이면 유지보수가 어렵고 코드가 지저분해진다.
이렇게 하면, 메서드는 "무엇을 실행할지" 에 대해 알 필요 없이, 전달받은 동작(전략)을 수행하기만 하면 된다. 이게 바로 전략 패턴 (Strategy Pattern)
Java 8부터는 이를 더 간결하게 처리할 수 있도록 Predicate
함수형 인터페이스가 도입되었다. Predicate
는 입력값을 받아 boolean을 반환하는 조건식을 의미. 주로 컬렉션의 항목에 대한 필터링, 검증, 조건적인 처리 등에 사용된다.
=> 조건에 따라 filter가 다르게 동작 => 이러한 전략 패턴은 객체의 행위를 동적으로 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장할 수 있도록 한다.
public List<Apple> filterGreenApples() { /*...*/ }
public List<Apple> filterHeavyApples() { /*...*/ }
public List<Apple> filterApples(Color color, int weight) { /*...*/ }
interface ApplePredicate { boolean test(Apple apple); }
class HeavyApplePredicate implements ApplePredicate { /*...*/ }
filterApples(new ApplePredicate() {
@Override public boolean test(Apple a) { return a.weight > 150; }
});
`filterApples(a -> a.weight > 150 && a.color == RED);`
`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;
}
특징
예시 설명
// 기존 익명 클래스 방식
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)
이러한 공통 패턴을 일반화하면 다음과 같이 표현할 수 있다:
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)은 코드를 더욱 간결하고 유연하게 만들어 주는 기능이다. 특히, 함수형 인터페이스와 함께 사용할 때 그 진가를 발휘하는데, 자바에서 람다 표현식을 어디에, 어떻게 사용하면 좋은지, 그리고 그 배경 개념인 함수 디스크립터, 실행 어라운드 패턴, 형식 검사와 추론까지 함께 정리한다.
람다 표현식은 함수형 인터페이스의 구현을 간단하게 표현하는 문법입니다.
@FunctionalInterface
어노테이션을 사용할 수 있습니다.람다 표현식을 사용하면 이 함수형 인터페이스의 추상 메서드 구현을 익명 객체처럼 간단하게 작성할 수 있습니다. 람다 표현식은 이 인터페이스의 구현체처럼 사용되며, 해당 추상 메서드의 시그니처(파라미터와 반환 타입)를 기준으로 컴파일러가 타입을 추론합니다. 이를 함수 디스크립터(Function Descriptor) 라고 부릅니다.
Runnable r = () -> System.out.println("Hello, world!");
람다 표현식이 어떤 함수형 인터페이스에 사용될 수 있는지를 결정하는 중요한 개념이 **함수 디스크립터
// 자바의 `Predicate<T>` 인터페이스는 다음과 같은 추상 메서드 시그니처를 가진다.
boolean test(T t);
// `Predicate<String>` 타입의 람다 표현식은 `(String s) -> boolean` 형태여야 하며, 이 시그니처가 일치하지 않으면 컴파일 오류가 발생
Predicate<String> isNotEmpty = s -> !s.isEmpty();
예를 들어, 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)
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)
// 명시적 타입 지정
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
가 있기 때문
람다의 바디(구현부)에는 파라미터를 제외하고도 바디 외부에 있는 변수를 참조할 수 있습니다.
이렇게 람다 시그니처의 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수를 자유 변수(Free Variable)
이라고 부릅니다.이런 자유 변수를 참조하는 행위를 람다 캡쳐링(Lambda Capturing)
이라고 합니다.
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"
에러 메시지를 확인할 수도 있습니다.
람다 표현식에서 외부 변수를 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
제약으로 이를 방지합니다.