JAVA · 심층 가이드
Java 함수형/Stream 완전 정리
지연 평가로 동작하는 Stream 파이프라인의 실행 원리와 Collectors 조립, Optional 체이닝, 부수 효과 관리까지 함수형 Java를 14개 주제로 다룹니다.
Stream은 컬렉션이 아니라 연산 계획서에 가깝습니다. filter나 map을 아무리 이어 붙여도 그 시점에는 아무 일도 일어나지 않고, collect나 forEach 같은 최종 연산이 붙는 순간 원소 하나가 파이프라인 전체를 통과하는 방식으로 실행됩니다. 이 지연 평가 덕분에 findFirst나 limit가 앞쪽 원소만 처리하고 멈출 수 있습니다. 또 하나, 스트림은 일회용이라 소비된 스트림을 다시 쓰면 예외가 납니다.
스트림 파이프라인 원리에서 중간 연산과 최종 연산의 경계를 확실히 잡는 게 출발점입니다. 그 뒤 Collectors 고급 & Gatherers로 그룹핑과 분할, 다운스트림 수집기 중첩을 보고, 표준 수집기로 안 되면 커스텀 Collector와 함수형 집계에서 세 부품을 직접 조립하면 됩니다. 값 하나를 다루는 흐름은 Optional 체이닝이 담당하고, 부수 효과 관리는 파이프라인이 병렬로 돌 때 안전한 이유를 설명합니다.
실무에서 제일 자주 터지는 건 Collectors.toMap입니다. 키가 중복되면 IllegalStateException이 나므로 병합 함수를 세 번째 인자로 넘겨야 하고, 값이 null이어도 예외가 납니다. HashMap.put은 둘 다 조용히 받아 주기 때문에 기존 루프를 스트림으로 옮기다가 처음 만나는 경우가 많습니다. peek도 조심할 대상입니다. 디버깅용으로 설계된 연산이라 최종 연산이 원소를 전부 소비하지 않으면 호출되지 않을 수 있어서, 여기에 로직을 넣으면 실행 여부가 파이프라인 모양에 따라 달라집니다.
01Stream API — 완전 정복
Java 8의 Stream API로 컬렉션을 함수형으로 처리합니다. 중간 연산과 최종 연산을 마스터합니다.
Java code
import java.util.*;
import java.util.stream.*;
record Product(String name, int price, String category) {}
public class StreamDemo {
public static void main(String[] args) {
var products = List.of(
new Product("Kotlin Book", 35000, "IT"),
new Product("Java Book", 30000, "IT"),
new Product("Python Guide", 28000, "IT"),
new Product("Novel", 15000, "문학"),
new Product("Manga", 12000, "문학")
);
// 중간 연산 체이닝
List<String> result = products.stream()
.filter(p -> p.price() >= 20000)
.sorted(Comparator.comparing(Product::price).reversed())
.map(p -> "%s: %,d원".formatted(p.name(), p.price()))
.collect(Collectors.toList());
result.forEach(System.out::println);
// Collectors.groupingBy
Map<String, List<Product>> byCategory =
products.stream()
.collect(Collectors.groupingBy(Product::category));
// Collectors.groupingBy + downstream
Map<String, IntSummaryStatistics> stats =
products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.summarizingInt(Product::price)));
stats.forEach((cat, s) ->
System.out.printf("%s: 평균 %,.0f원%n", cat, s.getAverage()));
// partitioningBy — true/false 그룹
Map<Boolean, List<Product>> partition =
products.stream()
.collect(Collectors.partitioningBy(p -> p.price() > 20000));
// reduce
int totalPrice = products.stream()
.mapToInt(Product::price)
.sum();
OptionalInt maxPrice = products.stream()
.mapToInt(Product::price)
.max();
// flatMap
List<List<Integer>> nested = List.of(List.of(1,2), List.of(3,4));
List<Integer> flat = nested.stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
System.out.println("합계: " + totalPrice);
System.out.println("최고가: " + maxPrice.orElse(0));
System.out.println("평탄화: " + flat);
}
}Stream은 lazy evaluation을 사용합니다. 중간 연산은 최종 연산이 호출될 때까지 실행되지 않습니다. parallelStream()으로 병렬 처리가 가능하지만 항상 빠른 건 아닙니다.
Stream은 한 번 소비하면 재사용할 수 없습니다. IllegalStateException: stream has already been operated upon or closed가 나면 새 Stream을 생성하세요.
02Collectors 고급 & Gatherers (Java 22+)
Collectors의 고급 기능과 Java 22의 Gatherers API.
Java code
import java.util.*;
import java.util.stream.*;
public class CollectorsAdvanced {
record Employee(String name, String dept, int salary) {}
public static void main(String[] args) {
var employees = List.of(
new Employee("Alice", "Engineering", 8000000),
new Employee("Bob", "Engineering", 7000000),
new Employee("Carol", "Marketing", 6000000),
new Employee("Dave", "Marketing", 5500000),
new Employee("Eve", "Engineering", 9000000)
);
// toMap
Map<String, Integer> nameSalary = employees.stream()
.collect(Collectors.toMap(
Employee::name,
Employee::salary,
Integer::sum // 중복 키 처리
));
// counting, averagingInt, summingInt
Map<String, Long> countByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::dept,
Collectors.counting()));
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::dept,
Collectors.averagingInt(Employee::salary)));
// joining
String names = employees.stream()
.map(Employee::name)
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(names); // [Alice, Bob, Carol, Dave, Eve]
// teeing (Java 12+) — 두 Collector를 동시에
var result = employees.stream()
.collect(Collectors.teeing(
Collectors.minBy(Comparator.comparing(Employee::salary)),
Collectors.maxBy(Comparator.comparing(Employee::salary)),
(min, max) -> "최저: %s, 최고: %s".formatted(
min.map(Employee::name).orElse(""),
max.map(Employee::name).orElse(""))
));
System.out.println(result);
// collectingAndThen — 마지막에 변환
List<String> topEarners = employees.stream()
.filter(e -> e.salary() > 7000000)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
avgSalaryByDept.forEach((dept, avg) ->
System.out.printf("%s: %,.0f원%n", dept, avg));
}
}Collectors.teeing()(Java 12+)은 동일한 스트림에서 두 가지 집계를 동시에 수행합니다. min과 max를 한 번의 순회로 구할 때 유용합니다.
Collectors.toMap()에서 중복 키가 있으면 IllegalStateException이 발생합니다. 세 번째 인자로 merge function을 반드시 제공하세요.
03불변 컬렉션 & record 조합
Java 16+ Record와 불변 컬렉션으로 함수형 스타일의 안전한 데이터 파이프라인을 구축합니다.
Java code
import java.util.*;
import java.util.stream.*;
// Record + 불변 컬렉션
record Order(int id, String customer, List<String> items, double total) {
// compact constructor
public Order {
items = List.copyOf(items); // 방어적 복사 → 불변
if (total < 0) throw new IllegalArgumentException("합계는 양수");
}
// 복사 & 수정 패턴 (with-er)
public Order withTotal(double newTotal) {
return new Order(id, customer, items, newTotal);
}
}
public class FunctionalStyle {
public static void main(String[] args) {
var orders = List.of(
new Order(1, "Alice", List.of("책", "펜"), 45000),
new Order(2, "Bob", List.of("노트북"), 1200000),
new Order(3, "Alice", List.of("마우스", "키보드"), 150000),
new Order(4, "Carol", List.of("모니터"), 800000)
);
// 고객별 총 구매액
Map<String, Double> byCustomer = orders.stream()
.collect(Collectors.groupingBy(
Order::customer,
Collectors.summingDouble(Order::total)));
// 100만원 이상 고객
byCustomer.entrySet().stream()
.filter(e -> e.getValue() >= 1_000_000)
.sorted(Map.Entry.<String,Double>comparingByValue().reversed())
.forEach(e -> System.out.printf("%s: %,.0f원%n",
e.getKey(), e.getValue()));
// 모든 아이템 평탄화
List<String> allItems = orders.stream()
.flatMap(o -> o.items().stream())
.distinct()
.sorted()
.collect(Collectors.toUnmodifiableList());
System.out.println("전체 상품: " + allItems);
// Record copy-with 패턴
var discounted = orders.stream()
.map(o -> o.withTotal(o.total() * 0.9))
.collect(Collectors.toList());
discounted.forEach(o ->
System.out.printf("주문 #%d: %,.0f원%n", o.id(), o.total()));
}
}List.copyOf()는 원본 컬렉션의 불변 복사본을 반환합니다. Record의 compact constructor에서 사용하면 외부에서 내부 컬렉션을 수정할 수 없게 됩니다.
List.of()는 null 원소를 허용하지 않습니다. null이 가능한 컬렉션은 Collections.unmodifiableList(new ArrayList())를 사용하세요.
04Foreign Memory API 활용
네이티브 메모리를 안전하게 다루는 MemorySegment
Java code
<span class="cm">// Foreign Memory API 활용 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Foreign Memory API 활용") }JAVA 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
05스트림 파이프라인 원리
스트림의 지연 평가(Lazy Evaluation)와 중간/최종 연산의 동작을 이해합니다.
Java code
import java.util.*;
import java.util.stream.*;
public class StreamPipeline {
public static void main(String[] args) {
List<String> names = List.of("Alice", "Bob", "Charlie", "Dave", "Eve");
// 지연 평가 — 최종 연산 전까지 실행 안 됨
Stream<String> stream = names.stream()
.filter(n -> {
System.out.println("filter: " + n);
return n.length() > 3;
})
.map(n -> {
System.out.println("map: " + n);
return n.toUpperCase();
});
// 여기까지 아무것도 출력 안 됨!
// 최종 연산이 파이프라인 실행
List<String> result = stream.toList();
// filter: Alice -> map: Alice
// filter: Bob (통과 못 함)
// filter: Charlie -> map: Charlie
// filter: Dave -> map: Dave
// filter: Eve (통과 못 함)
// 단축 연산 — 필요한 만큼만 처리
Optional<String> first = names.stream()
.filter(n -> n.startsWith("C"))
.findFirst(); // Charlie 찾으면 즉시 중단
System.out.println(first.orElse("없음"));
// 스트림은 일회용!
// stream.toList(); // IllegalStateException
}
}findFirst(), anyMatch() 등의 단축 연산은 조건 충족 시 나머지 원소를 처리하지 않습니다. 성능 최적화에 활용하세요.
스트림은 한 번만 소비할 수 있습니다. 재사용이 필요하면 Supplier<Stream>으로 감싸거나 컬렉션에 먼저 수집하세요.
06Optional 체이닝
Optional을 함수형으로 체이닝하여 null 안전한 코드를 작성합니다.
Java code
import java.util.*;
public class OptionalChaining {
record Address(String city) {}
record Company(Address address) {}
record User(String name, Company company) {}
static Optional<User> findUser(String id) {
if ("1".equals(id)) {
return Optional.of(new User("홍길동",
new Company(new Address("서울"))));
}
if ("2".equals(id)) {
return Optional.of(new User("김철수", null));
}
return Optional.empty();
}
public static void main(String[] args) {
// flatMap 체이닝으로 null 안전 탐색
String city = findUser("1")
.map(User::company)
.map(Company::address)
.map(Address::city)
.orElse("알 수 없음");
System.out.println(city); // 서울
// company가 null인 경우
String city2 = findUser("2")
.map(User::company) // Optional.empty() (null이므로)
.map(Company::address)
.map(Address::city)
.orElse("알 수 없음");
System.out.println(city2); // 알 수 없음
// or()로 대체 소스 제공
User user = findUser("999")
.or(() -> findUser("1"))
.orElseThrow();
System.out.println(user.name()); // 홍길동
// stream()으로 변환 (Java 9+)
List<String> cities = List.of("1", "2", "3").stream()
.map(id -> findUser(id))
.flatMap(Optional::stream) // 비어있으면 제외
.map(User::name)
.toList();
System.out.println(cities); // [홍길동, 김철수]
}
}Optional.stream()(Java 9+)을 사용하면 filter(Optional::isPresent).map(Optional::get) 패턴을 깔끔하게 대체할 수 있습니다.
map()은 값이 null이면 자동으로 Optional.empty()를 반환합니다. flatMap()은 반환값이 이미 Optional인 경우에 사용하세요.
07함수 합성과 파이프라인
여러 함수를 합성하여 데이터 변환 파이프라인을 구축합니다.
Java code
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
public class FunctionPipeline {
// 파이프라인 빌더
static class Pipeline<T> {
private final List<Function<T, T>> steps = new ArrayList<>();
Pipeline<T> addStep(Function<T, T> step) {
steps.add(step);
return this;
}
Function<T, T> build() {
return steps.stream()
.reduce(Function.identity(), Function::andThen);
}
}
public static void main(String[] args) {
// 문자열 처리 파이프라인
Pipeline<String> textPipeline = new Pipeline<String>()
.addStep(String::trim)
.addStep(String::toLowerCase)
.addStep(s -> s.replaceAll("[^a-z0-9 ]", ""))
.addStep(s -> s.replaceAll("\\s+", "-"));
Function<String, String> slugify = textPipeline.build();
System.out.println(slugify.apply(" Hello, World! 123 "));
// hello-world-123
// 숫자 변환 파이프라인
Function<Integer, Integer> mathPipe =
((Function<Integer, Integer>) n -> n * 2)
.andThen(n -> n + 10)
.andThen(n -> n * n);
System.out.println(mathPipe.apply(5)); // (5*2+10)^2 = 400
// 검증 파이프라인
List<Predicate<String>> validators = List.of(
s -> !s.isEmpty(),
s -> s.length() <= 50,
s -> s.matches("[a-zA-Z0-9 ]+")
);
Predicate<String> allValid = validators.stream()
.reduce(Predicate::and)
.orElse(s -> true);
System.out.println(allValid.test("Hello123")); // true
System.out.println(allValid.test("")); // false
}
}Function.identity()는 항등 함수로, reduce의 초기값으로 사용하면 빈 파이프라인도 안전하게 처리됩니다.
함수 합성 순서를 혼동하지 마세요. f.andThen(g)는 f를 먼저 적용하고, f.compose(g)는 g를 먼저 적용합니다.
08커스텀 Collector와 함수형 집계
함수형 프로그래밍 관점에서 Collector를 활용한 데이터 집계를 다룹니다.
Java code
import java.util.*;
import java.util.stream.*;
public class FPCollector {
record Transaction(String type, double amount) {}
public static void main(String[] args) {
List<Transaction> txns = List.of(
new Transaction("입금", 50000),
new Transaction("출금", 20000),
new Transaction("입금", 30000),
new Transaction("출금", 10000),
new Transaction("입금", 45000)
);
// 타입별 합계를 한 번의 스트림으로
Map<String, DoubleSummaryStatistics> summary = txns.stream()
.collect(Collectors.groupingBy(
Transaction::type,
Collectors.summarizingDouble(Transaction::amount)));
summary.forEach((type, stats) ->
System.out.printf("%s: 합계=%.0f, 평균=%.0f, 건수=%d%n",
type, stats.getSum(), stats.getAverage(), stats.getCount()));
// teeing — 두 Collector 결과 합치기 (Java 12+)
String report = txns.stream().collect(
Collectors.teeing(
Collectors.filtering(t -> t.type().equals("입금"),
Collectors.summingDouble(Transaction::amount)),
Collectors.filtering(t -> t.type().equals("출금"),
Collectors.summingDouble(Transaction::amount)),
(income, expense) -> String.format(
"입금: %.0f, 출금: %.0f, 잔액: %.0f",
income, expense, income - expense)
));
System.out.println(report);
}
}Collectors.teeing()(Java 12+)은 하나의 스트림에서 두 가지 집계를 동시에 수행할 때 유용합니다.
Collectors.filtering()은 Java 9+에서 추가되었습니다. 이전 버전에서는 stream().filter().collect()를 사용하세요.
09리액티브 프로그래밍 소개
Java 9의 Flow API로 리액티브 스트림의 기본 개념을 이해합니다.
Java code
import java.util.concurrent.*;
import java.util.concurrent.Flow.*;
public class ReactiveIntro {
// 간단한 Publisher
static class SimplePublisher implements Publisher<String> {
@Override
public void subscribe(Subscriber<? super String> subscriber) {
subscriber.onSubscribe(new Subscription() {
private boolean cancelled = false;
@Override
public void request(long n) {
if (cancelled) return;
for (int i = 0; i < n && !cancelled; i++) {
subscriber.onNext("데이터-" + i);
}
if (!cancelled) subscriber.onComplete();
}
@Override
public void cancel() { cancelled = true; }
});
}
}
// 간단한 Subscriber
static class SimpleSubscriber implements Subscriber<String> {
private Subscription subscription;
@Override public void onSubscribe(Subscription s) {
this.subscription = s;
s.request(3); // 3개 요청
}
@Override public void onNext(String item) {
System.out.println("수신: " + item);
}
@Override public void onError(Throwable t) {
System.err.println("에러: " + t.getMessage());
}
@Override public void onComplete() {
System.out.println("완료!");
}
}
public static void main(String[] args) {
new SimplePublisher().subscribe(new SimpleSubscriber());
}
}Flow API는 리액티브 스트림의 표준 인터페이스입니다. 실제 구현은 Project Reactor(Spring WebFlux)나 RxJava를 사용하세요.
request(n)으로 배압(backpressure)을 제어합니다. 무한히 요청하면 메모리 부족이 발생할 수 있습니다.
10불변 설계와 함수형 패턴
불변 데이터 구조와 with 패턴으로 상태 변경을 안전하게 처리합니다.
Java code
import java.util.*;
public class ImmutableDesign {
// 불변 record + with 패턴
record Account(String id, String owner, long balance) {
Account deposit(long amount) {
return new Account(id, owner, balance + amount);
}
Account withdraw(long amount) {
if (amount > balance) {
throw new IllegalStateException("잔액 부족");
}
return new Account(id, owner, balance - amount);
}
Account withOwner(String newOwner) {
return new Account(id, newOwner, balance);
}
}
// 불변 컬렉션 래퍼
record ImmutableStack<T>(List<T> elements) {
ImmutableStack() { this(List.of()); }
ImmutableStack<T> push(T item) {
var newList = new ArrayList<>(elements);
newList.add(item);
return new ImmutableStack<>(List.copyOf(newList));
}
T peek() { return elements.get(elements.size() - 1); }
ImmutableStack<T> pop() {
return new ImmutableStack<>(
List.copyOf(elements.subList(0, elements.size() - 1)));
}
}
public static void main(String[] args) {
Account acc = new Account("A001", "홍길동", 10000);
Account acc2 = acc.deposit(5000).withdraw(3000);
System.out.println(acc.balance()); // 10000 (원본 불변)
System.out.println(acc2.balance()); // 12000
}
}with 패턴은 record의 한 필드만 변경한 새 인스턴스를 반환합니다. JDK에서 공식 with 표현식이 논의 중입니다.
불변 컬렉션을 매번 복사하면 성능이 떨어질 수 있습니다. 대량 데이터에는 Vavr의 영속적(persistent) 자료구조를 고려하세요.
11순수 함수와 참조 투명성
순수 함수의 조건과 참조 투명성이 코드 품질에 미치는 영향을 알아봅니다.
Java code
import java.util.*;
import java.util.function.*;
public class PureFunction {
// 순수 함수: 같은 입력 -> 항상 같은 출력, 부수 효과 없음
static int add(int a, int b) { return a + b; }
static String greet(String name) { return "Hello, " + name; }
// 비순수 함수: 외부 상태에 의존
static int counter = 0;
static int impureCount() { return ++counter; } // 부수 효과!
// 순수 함수로 변환: 상태를 인자로 전달
static int pureCount(int current) { return current + 1; }
// 참조 투명성: 함수 호출을 결과값으로 대체 가능
// add(2, 3) -> 항상 5로 대체 가능 (참조 투명)
// impureCount() -> 대체 불가 (참조 불투명)
// 실전: 순수 함수 파이프라인
static List<String> processNames(List<String> names) {
return names.stream()
.map(String::trim) // 순수
.filter(s -> !s.isEmpty()) // 순수
.map(String::toUpperCase) // 순수
.sorted() // 순수
.toList(); // 순수
}
public static void main(String[] args) {
List<String> input = List.of(" Bob ", "Alice", "", "Charlie ");
List<String> result = processNames(input);
System.out.println(result); // [ALICE, BOB, CHARLIE]
System.out.println(input); // 원본 불변
}
}순수 함수는 테스트하기 쉽고, 캐싱(메모이제이션)이 가능하며, 병렬 실행에 안전합니다. 가능하면 순수 함수로 작성하세요.
스트림의 forEach에서 외부 변수를 수정하는 것은 순수하지 않습니다. collect나 reduce로 결과를 모으세요.
12부수 효과 관리
I/O 등의 부수 효과를 순수 로직과 분리하는 전략을 배웁니다.
Java code
import java.util.*;
import java.util.function.*;
public class SideEffectManagement {
record LogEntry(String level, String message) {}
// 1. 부수 효과를 경계로 밀어내기
// 순수 로직: 검증
static List<String> validate(List<String> inputs) {
return inputs.stream()
.filter(s -> s != null && !s.isBlank())
.map(String::trim)
.filter(s -> s.length() >= 3)
.toList();
}
// 부수 효과: I/O는 가장 바깥에서
static void processAndSave(List<String> inputs) {
List<String> valid = validate(inputs); // 순수
valid.forEach(s -> System.out.println("저장: " + s)); // 부수 효과
}
// 2. 효과를 데이터로 표현
static List<LogEntry> process(List<String> items) {
List<LogEntry> logs = new ArrayList<>();
List<String> results = new ArrayList<>();
for (String item : items) {
if (item.length() > 5) {
results.add(item);
logs.add(new LogEntry("INFO", "처리: " + item));
} else {
logs.add(new LogEntry("WARN", "스킵: " + item));
}
}
return logs; // 로그를 데이터로 반환 (출력은 호출자가)
}
public static void main(String[] args) {
var inputs = List.of("Hi", "Hello World", "", "Java 21", "ab");
processAndSave(inputs);
var logs = process(List.of("short", "longer text"));
logs.forEach(l -> System.out.printf("[%s] %s%n", l.level(), l.message()));
}
}함수형 핵심(Functional Core), 명령형 셸(Imperative Shell) 패턴으로 순수 로직과 부수 효과를 분리하면 테스트가 쉬워집니다.
순수 함수 내부에서 System.out.println()을 호출하면 더 이상 순수하지 않습니다. 로깅은 반환값으로 표현하거나 바깥에서 처리하세요.
13패턴 매칭과 대수적 데이터 타입
sealed 인터페이스와 record를 조합한 대수적 데이터 타입(ADT)과 패턴 매칭입니다.
Java code
// 대수적 데이터 타입 (ADT) 정의
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr inner) implements Expr {}
public class PatternMatchingADT {
// 패턴 매칭으로 재귀적 평가
static double eval(Expr expr) {
return switch (expr) {
case Num n -> n.value();
case Add a -> eval(a.left()) + eval(a.right());
case Mul m -> eval(m.left()) * eval(m.right());
case Neg n -> -eval(n.inner());
};
}
// 패턴 매칭으로 문자열 변환
static String format(Expr expr) {
return switch (expr) {
case Num n -> String.valueOf(n.value());
case Add a -> "(" + format(a.left()) + " + " + format(a.right()) + ")";
case Mul m -> format(m.left()) + " * " + format(m.right());
case Neg n -> "-" + format(n.inner());
};
}
public static void main(String[] args) {
// (3 + 4) * 2 = 14
Expr expr = new Mul(
new Add(new Num(3), new Num(4)),
new Num(2));
System.out.println(format(expr) + " = " + eval(expr));
// -(5 + 3) = -8
Expr neg = new Neg(new Add(new Num(5), new Num(3)));
System.out.println(format(neg) + " = " + eval(neg));
}
}sealed + record + switch 패턴 매칭 조합은 Java에서 함수형 스타일의 ADT를 깔끔하게 표현합니다. 새 타입 추가 시 모든 switch가 컴파일 에러를 냅니다.
sealed 계층에 새 구현을 추가하면 모든 switch 문을 수정해야 합니다. 이것은 장점(완전성 보장)이지 단점이 아닙니다.
14Vavr 라이브러리 소개
Vavr(구 Javaslang)의 함수형 자료구조와 유틸리티를 소개합니다.
Java code
// Vavr 의존성: io.vavr:vavr:0.10.4
// 아래는 Vavr의 주요 기능을 의사코드로 소개합니다.
// 1. 불변 컬렉션
// io.vavr.collection.List (영속적 연결 리스트)
// var list = io.vavr.collection.List.of(1, 2, 3);
// var newList = list.prepend(0); // [0, 1, 2, 3]
// list는 여전히 [1, 2, 3] — 구조 공유
// 2. Option (Optional 대체)
// var opt = Option.of(value);
// var result = opt.map(v -> v * 2).getOrElse(0);
// 3. Try (예외를 값으로)
// var result = Try.of(() -> riskyOperation())
// .recover(Exception.class, e -> fallback)
// .getOrElse(defaultValue);
// 4. Either (왼쪽=에러, 오른쪽=성공)
// Either<String, Integer> parse(String s) {
// try { return Either.right(Integer.parseInt(s)); }
// catch (Exception e) { return Either.left("파싱 실패"); }
// }
// 5. Pattern Matching
// import static io.vavr.API.*;
// String result = Match(value).of(
// Case($(1), "one"),
// Case($(2), "two"),
// Case($(), "other")
// );
// Java 표준으로 유사하게 구현
import java.util.function.*;
sealed interface Either<L, R> {
record Left<L, R>(L value) implements Either<L, R> {}
record Right<L, R>(R value) implements Either<L, R> {}
static <L, R> Either<L, R> right(R value) { return new Right<>(value); }
static <L, R> Either<L, R> left(L value) { return new Left<>(value); }
}
class Main {
static Either<String, Integer> parseInt(String s) {
try { return Either.right(Integer.parseInt(s)); }
catch (Exception e) { return Either.left("파싱 실패: " + s); }
}
public static void main(String[] args) {
var result = parseInt("42");
switch (result) {
case Either.Right<String, Integer> r -> System.out.println("성공: " + r.value());
case Either.Left<String, Integer> l -> System.out.println("실패: " + l.value());
}
}
}Vavr는 Java에 부족한 함수형 자료구조를 제공합니다. 하지만 Java 21+의 sealed/record/패턴 매칭으로 많은 부분을 대체할 수 있습니다.
Vavr 컬렉션과 Java 표준 컬렉션은 호환되지 않습니다. .toJavaList()로 변환해야 하므로 경계를 명확히 하세요.
정리하며
- 최종 연산이 없으면 중간 연산은 한 번도 실행되지 않습니다. 스트림은 계획서일 뿐입니다
- Collectors.toMap은 키 중복과 null 값에서 예외를 던지므로 병합 함수를 함께 넘깁니다
- peek에는 로직을 넣지 않습니다. 파이프라인 형태에 따라 호출이 생략될 수 있습니다
- 병렬 스트림은 공용 풀을 공유하므로 요소 수와 연산 비용이 충분할 때만 사용합니다
더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.