이 글에서 얻는 것

  • Stream API로 컬렉션을 필터링/변환/집계하는 선언적 코드를 작성할 수 있습니다.
  • 중간 연산(filter/map)과 최종 연산(collect/reduce)의 차이를 이해하고 효율적으로 조합합니다.
  • Optional로 null 체크를 안전하게 처리하고, “null 반환"을 피하는 습관을 갖습니다.
  • Stream/Optional의 흔한 실수(무한 스트림, 과도한 Optional 사용)를 예방합니다.

0) Stream과 Optional은 “null과 반복문"을 더 안전하게 만든다

Java 8 이전:

// ❌ 명령형 스타일: 어떻게(how) 할지 명시
List<String> result = new ArrayList<>();
for (Order order : orders) {
    if (order.getStatus() == OrderStatus.COMPLETED) {
        result.add(order.getCustomerName());
    }
}

Java 8 이후:

// ✅ 선언형 스타일: 무엇을(what) 할지 명시
List<String> result = orders.stream()
    .filter(order -> order.getStatus() == OrderStatus.COMPLETED)
    .map(Order::getCustomerName)
    .collect(Collectors.toList());

핵심 이점:

  • 코드가 짧고 의도가 명확
  • 병렬 처리 가능 (parallelStream)
  • 중간 연산 최적화 (lazy evaluation)

1) Stream API 기초

1-1) Stream 생성

// 컬렉션에서 생성
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();

// 배열에서 생성
String[] array = {"a", "b", "c"};
Stream<String> streamFromArray = Arrays.stream(array);

// 직접 생성
Stream<String> streamOf = Stream.of("a", "b", "c");

// 빈 스트림
Stream<String> empty = Stream.empty();

// 무한 스트림 (limit 필수!)
Stream<Integer> infinite = Stream.iterate(0, n -> n + 1)
    .limit(10);  // [0, 1, 2, ..., 9]

Stream<Double> random = Stream.generate(Math::random)
    .limit(5);   // 랜덤 5개

// 범위 생성
IntStream range = IntStream.range(1, 5);      // [1, 2, 3, 4]
IntStream rangeClosed = IntStream.rangeClosed(1, 5);  // [1, 2, 3, 4, 5]

1-2) 중간 연산 (Intermediate Operations)

중간 연산은 lazy 평가됩니다 (최종 연산이 호출되기 전까지 실행 안 됨).

// filter: 조건에 맞는 요소만 선택
List<Order> completed = orders.stream()
    .filter(order -> order.getStatus() == OrderStatus.COMPLETED)
    .collect(Collectors.toList());

// map: 각 요소를 변환
List<String> names = users.stream()
    .map(User::getName)
    .collect(Collectors.toList());

// flatMap: 중첩된 구조를 평탄화
List<List<String>> nested = Arrays.asList(
    Arrays.asList("a", "b"),
    Arrays.asList("c", "d")
);
List<String> flat = nested.stream()
    .flatMap(List::stream)
    .collect(Collectors.toList());  // [a, b, c, d]

// distinct: 중복 제거
List<Integer> unique = numbers.stream()
    .distinct()
    .collect(Collectors.toList());

// sorted: 정렬
List<String> sorted = names.stream()
    .sorted()  // 기본 정렬
    .collect(Collectors.toList());

List<User> sortedByAge = users.stream()
    .sorted(Comparator.comparing(User::getAge))  // 나이순
    .collect(Collectors.toList());

// limit/skip: 개수 제한/건너뛰기
List<String> firstThree = list.stream()
    .limit(3)
    .collect(Collectors.toList());

List<String> skipTwo = list.stream()
    .skip(2)
    .collect(Collectors.toList());

// peek: 중간 확인 (디버깅용)
List<String> result = list.stream()
    .peek(s -> System.out.println("Processing: " + s))
    .filter(s -> s.startsWith("A"))
    .collect(Collectors.toList());

1-3) 최종 연산 (Terminal Operations)

최종 연산이 호출되어야 실제로 실행됩니다.

// collect: 리스트/셋/맵으로 수집
List<String> list = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());
Map<Long, User> map = users.stream()
    .collect(Collectors.toMap(User::getId, u -> u));

// forEach: 각 요소에 대해 작업
users.forEach(user -> System.out.println(user.getName()));

// count: 개수
long count = orders.stream()
    .filter(order -> order.getStatus() == OrderStatus.COMPLETED)
    .count();

// anyMatch/allMatch/noneMatch: 조건 검사
boolean hasCompleted = orders.stream()
    .anyMatch(order -> order.getStatus() == OrderStatus.COMPLETED);

boolean allCompleted = orders.stream()
    .allMatch(order -> order.getStatus() == OrderStatus.COMPLETED);

// findFirst/findAny: 첫 번째 요소 찾기
Optional<Order> first = orders.stream()
    .filter(order -> order.getAmount() > 1000)
    .findFirst();

// reduce: 집계 (합계, 최댓값 등)
int sum = numbers.stream()
    .reduce(0, Integer::sum);  // 초깃값 0, 합산

Optional<Integer> max = numbers.stream()
    .reduce(Integer::max);

// min/max: 최솟값/최댓값
Optional<Order> mostExpensive = orders.stream()
    .max(Comparator.comparing(Order::getAmount));

2) Stream 실전 패턴

2-1) 그룹핑 (Grouping)

// 상태별 주문 그룹화
Map<OrderStatus, List<Order>> byStatus = orders.stream()
    .collect(Collectors.groupingBy(Order::getStatus));

// 고객별 주문 개수
Map<String, Long> orderCountByCustomer = orders.stream()
    .collect(Collectors.groupingBy(
        Order::getCustomerId,
        Collectors.counting()
    ));

// 고객별 총 금액
Map<String, Integer> totalAmountByCustomer = orders.stream()
    .collect(Collectors.groupingBy(
        Order::getCustomerId,
        Collectors.summingInt(Order::getAmount)
    ));

2-2) 파티셔닝 (Partitioning)

// 금액 기준으로 두 그룹으로 분할
Map<Boolean, List<Order>> partitioned = orders.stream()
    .collect(Collectors.partitioningBy(
        order -> order.getAmount() > 1000
    ));

List<Order> expensive = partitioned.get(true);   // 1000 초과
List<Order> cheap = partitioned.get(false);      // 1000 이하

2-3) 조인 (Joining)

// 문자열 연결
String names = users.stream()
    .map(User::getName)
    .collect(Collectors.joining(", "));  // "Alice, Bob, Charlie"

String withPrefix = users.stream()
    .map(User::getName)
    .collect(Collectors.joining(", ", "[", "]"));  // "[Alice, Bob, Charlie]"

2-4) 통계 (Statistics)

// 숫자 통계
IntSummaryStatistics stats = orders.stream()
    .mapToInt(Order::getAmount)
    .summaryStatistics();

System.out.println("Count: " + stats.getCount());
System.out.println("Sum: " + stats.getSum());
System.out.println("Min: " + stats.getMin());
System.out.println("Max: " + stats.getMax());
System.out.println("Average: " + stats.getAverage());

2-5) 복잡한 변환

// 주문 → DTO 변환
List<OrderDTO> dtos = orders.stream()
    .filter(order -> order.getStatus() == OrderStatus.COMPLETED)
    .map(order -> new OrderDTO(
        order.getId(),
        order.getCustomerName(),
        order.getAmount()
    ))
    .collect(Collectors.toList());

// flatMap으로 중첩 구조 평탄화
List<String> allProductNames = orders.stream()
    .flatMap(order -> order.getItems().stream())
    .map(OrderItem::getProductName)
    .distinct()
    .collect(Collectors.toList());

3) Optional: null 안전성 확보

3-1) Optional 기본 사용법

// Optional 생성
Optional<String> present = Optional.of("value");           // null이면 NullPointerException
Optional<String> nullable = Optional.ofNullable(maybeNull); // null 허용
Optional<String> empty = Optional.empty();                 // 빈 Optional

// 값 확인
if (optional.isPresent()) {
    String value = optional.get();  // 값 존재 시 반환
}

// ✅ 권장: ifPresent 사용
optional.ifPresent(value -> System.out.println(value));

// ✅ 권장: orElse/orElseGet 사용
String result = optional.orElse("default");           // 값이 없으면 기본값
String result = optional.orElseGet(() -> getDefault()); // 값이 없으면 함수 실행 (lazy)
String result = optional.orElseThrow(() -> new IllegalStateException()); // 예외 발생

3-2) Optional 변환

// map: 값 변환
Optional<String> upperCase = optional.map(String::toUpperCase);

// flatMap: 중첩 Optional 평탄화
Optional<User> user = findUserById(userId);
Optional<String> email = user.flatMap(User::getEmail);  // User::getEmail이 Optional<String> 반환

// filter: 조건 필터링
Optional<String> filtered = optional.filter(s -> s.length() > 5);

3-3) Optional 실전 패턴

// ❌ 나쁜 예: get() 직접 호출
Optional<User> user = findUser(id);
if (user.isPresent()) {
    User u = user.get();  // 위험: isPresent 없으면 NoSuchElementException
}

// ✅ 좋은 예: orElse/orElseThrow 사용
User user = findUser(id)
    .orElseThrow(() -> new UserNotFoundException(id));

// ✅ 좋은 예: ifPresentOrElse (Java 9+)
findUser(id).ifPresentOrElse(
    user -> System.out.println("Found: " + user),
    () -> System.out.println("Not found")
);

// ❌ 나쁜 예: Optional을 필드로 사용
class User {
    private Optional<String> email;  // ❌ 직렬화 문제, 불필요한 복잡도
}

// ✅ 좋은 예: 필드는 nullable, 메서드만 Optional 반환
class User {
    private String email;  // nullable

    public Optional<String> getEmail() {
        return Optional.ofNullable(email);
    }
}

// ❌ 나쁜 예: Optional을 파라미터로 사용
void updateUser(Optional<String> name) { }  // ❌ 복잡도만 증가

// ✅ 좋은 예: nullable 파라미터 또는 오버로딩
void updateUser(String name) { }  // name이 null일 수 있음을 문서화

3-4) Optional 체이닝

// 중첩된 null 체크를 Optional로 간결하게
// Before (Java 7)
String city = null;
if (user != null) {
    Address address = user.getAddress();
    if (address != null) {
        city = address.getCity();
    }
}

// After (Java 8+)
String city = Optional.ofNullable(user)
    .flatMap(User::getAddress)
    .map(Address::getCity)
    .orElse("Unknown");

4) 자주 하는 실수

4-1) Stream 재사용

// ❌ Stream은 한 번만 사용 가능
Stream<String> stream = list.stream();
stream.filter(s -> s.startsWith("A")).collect(Collectors.toList());
stream.filter(s -> s.startsWith("B")).collect(Collectors.toList());  // IllegalStateException!

// ✅ 새로운 Stream 생성
list.stream().filter(s -> s.startsWith("A")).collect(Collectors.toList());
list.stream().filter(s -> s.startsWith("B")).collect(Collectors.toList());

4-2) Optional.get() 남용

// ❌ isPresent + get 조합 (null 체크와 동일)
if (optional.isPresent()) {
    return optional.get();
} else {
    return "default";
}

// ✅ orElse 사용
return optional.orElse("default");

4-3) Stream에서 부수 효과 (Side Effects)

// ❌ Stream 안에서 외부 상태 변경
List<String> results = new ArrayList<>();
stream.forEach(s -> results.add(s.toUpperCase()));  // 부수 효과!

// ✅ collect 사용
List<String> results = stream
    .map(String::toUpperCase)
    .collect(Collectors.toList());

4-4) 불필요한 박싱/언박싱

// ❌ 박싱 오버헤드
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
    .reduce(0, Integer::sum);  // Integer → int 변환 반복

// ✅ 기본형 Stream 사용
int sum = numbers.stream()
    .mapToInt(Integer::intValue)  // IntStream으로 변환
    .sum();

// 더 간단한 방법
int sum = numbers.stream()
    .mapToInt(i -> i)
    .sum();

5) 병렬 Stream

// 순차 Stream
long count = list.stream()
    .filter(s -> s.length() > 5)
    .count();

// 병렬 Stream (멀티스레드 활용)
long count = list.parallelStream()
    .filter(s -> s.length() > 5)
    .count();

// 주의: 병렬 Stream은 항상 빠르지 않음
// - 데이터가 적으면 오버헤드가 더 큼
// - 순서가 중요하면 사용 불가
// - 상태 공유 시 동기화 문제 발생

병렬 Stream 사용 기준:

  • 데이터가 충분히 큼 (수천 개 이상)
  • CPU 집약적 작업
  • 순서가 중요하지 않음
  • 부수 효과가 없음

6) 실전 예제

// 예제 1: 주문 통계
class OrderService {
    public OrderStatistics getStatistics(List<Order> orders) {
        Map<OrderStatus, Long> countByStatus = orders.stream()
            .collect(Collectors.groupingBy(Order::getStatus, Collectors.counting()));

        Map<String, Integer> totalByCustomer = orders.stream()
            .collect(Collectors.groupingBy(
                Order::getCustomerId,
                Collectors.summingInt(Order::getAmount)
            ));

        Optional<Order> mostExpensive = orders.stream()
            .max(Comparator.comparing(Order::getAmount));

        return new OrderStatistics(countByStatus, totalByCustomer, mostExpensive);
    }
}

// 예제 2: 유저 검색
class UserService {
    public Optional<User> findActiveUserByEmail(String email) {
        return userRepository.findByEmail(email)
            .filter(User::isActive);
    }

    public List<UserDTO> searchUsers(String keyword) {
        return userRepository.findAll().stream()
            .filter(user -> user.getName().contains(keyword) ||
                           user.getEmail().contains(keyword))
            .map(user -> new UserDTO(user.getId(), user.getName(), user.getEmail()))
            .collect(Collectors.toList());
    }
}

// 예제 3: 복잡한 집계
class ReportService {
    public Map<String, List<OrderSummary>> getDailyReport(LocalDate date) {
        List<Order> orders = orderRepository.findByDate(date);

        return orders.stream()
            .collect(Collectors.groupingBy(
                order -> order.getCreatedAt().getHour() + ":00",  // 시간대별
                Collectors.mapping(
                    order -> new OrderSummary(order.getId(), order.getAmount()),
                    Collectors.toList()
                )
            ));
    }
}

연습 (추천)

  1. 기존 반복문 코드를 Stream으로 리팩터링

    • for 루프 → filter/map/collect
    • null 체크 → Optional
  2. 성능 비교

    • Stream vs for 루프 (작은 데이터 vs 큰 데이터)
    • 순차 Stream vs 병렬 Stream
  3. 복잡한 데이터 변환 연습

    • 중첩된 리스트 평탄화 (flatMap)
    • 그룹핑 + 집계 (groupingBy + summingInt)

요약: 스스로 점검할 것

  • Stream의 중간 연산과 최종 연산의 차이를 설명할 수 있다
  • filter/map/flatMap/collect를 실무에서 활용할 수 있다
  • Optional로 null 안전성을 확보하고, get() 대신 orElse/orElseThrow를 사용한다
  • Stream에서 부수 효과를 피하고, 순수 함수형 스타일로 작성한다
  • 병렬 Stream의 적절한 사용 시점을 판단할 수 있다

다음 단계

  • Java 동시성 기초: /learning/deep-dive/deep-dive-java-concurrency-basics/
  • 객체지향 설계 원칙(SOLID): /learning/deep-dive/deep-dive-oop-solid-principles/
  • 자료구조 복잡도: /learning/deep-dive/deep-dive-data-structure-complexity/