Stream과 Optional 활용 기준
한 줄 정의
Stream은 컬렉션 데이터를 선언적으로 변환하고 집계하는 파이프라인이고, Optional은 값이 없을 수 있는 상황을 명시적으로 표현하는 컨테이너다.
실무에서 왜 중요한가
Stream과 Optional은 Java 8 이후 가장 많이 쓰이는 기능이지만, 남용하면 오히려 코드 품질이 떨어진다.
- Stream을 과도하게 체이닝해서 디버깅이 어려워진다.
Optional을 필드나 파라미터에 사용해서 의도와 다르게 복잡해진다.Optional.get()을 검사 없이 호출해서NoSuchElementException이 발생한다.- Stream 안에서 외부 상태를 변경해서 동시성 문제가 생긴다.
- 단순 for문이면 충분한 로직을 Stream으로 바꿔서 가독성이 나빠진다.
Stream 실무 패턴
적절한 사용
// 주문 목록에서 특정 상태의 주문 금액 합계
long totalAmount = orders.stream()
.filter(order -> order.getStatus() == OrderStatus.COMPLETED)
.mapToLong(Order::getAmount)
.sum();// Entity -> DTO 변환
List<UserResponse> responses = users.stream()
.map(UserResponse::from)
.toList();필터링, 변환, 집계처럼 데이터 흐름이 명확한 경우에 Stream이 읽기 좋다.
남용 사례
// Stream 안에서 외부 상태 변경 - 위험
List<String> results = new ArrayList<>();
items.stream()
.filter(item -> item.isValid())
.forEach(item -> results.add(item.getName())); // side effectforEach 안에서 외부 리스트를 변경하는 것은 Stream의 의도에 맞지 않고, parallelStream 사용 시 동시성 문제가 생긴다.
// 올바른 방식
List<String> results = items.stream()
.filter(Item::isValid)
.map(Item::getName)
.toList();Stream을 쓰지 않는 것이 나은 경우
- 단순 for문으로 충분한 한두 줄 로직
- 반복 중 인덱스가 필요한 경우
- 중간에 break나 continue가 필요한 경우
- 예외 처리가 복잡한 경우
- Stream 체이닝이 5단계 이상으로 길어지는 경우
Optional 실무 패턴
적절한 사용
// 메서드 반환 타입으로 사용
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
// 호출부에서 처리
User user = userService.findByEmail(email)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));잘못된 사용
// 필드로 사용 - 금지
public class User {
private Optional<String> nickname; // 직렬화 문제, 불필요한 복잡성
}
// 파라미터로 사용 - 금지
public void updateUser(Optional<String> nickname) { // 호출부가 불편
}
// get()을 검사 없이 호출
Optional<User> user = findById(id);
user.get(); // NoSuchElementException 가능Optional 처리 메서드 선택
| 메서드 | 사용 시점 |
|---|---|
orElseThrow() | 값이 없으면 예외를 던져야 할 때 |
orElse(기본값) | 기본값이 항상 준비되어 있을 때 |
orElseGet(() -> ...) | 기본값 생성 비용이 클 때 (lazy) |
ifPresent(consumer) | 값이 있을 때만 처리하고 없으면 무시할 때 |
map() / flatMap() | 값을 변환할 때 |
주의: orElse()는 값이 있어도 인자가 항상 실행된다. 비용이 큰 연산은 orElseGet()을 써야 한다.
// orElse - createDefault()가 항상 실행됨
User user = findById(id).orElse(createDefault());
// orElseGet - 값이 없을 때만 실행됨
User user = findById(id).orElseGet(() -> createDefault());자주 나는 실수
- Stream에서 외부 상태를 변경한다.
Optional.get()을 검사 없이 호출한다.Optional을 필드, 파라미터, 컬렉션 원소로 사용한다.orElse()와orElseGet()의 차이를 모르고 사용한다.- 단순 null 체크를
Optional로 감싸서 코드만 늘린다. - Stream 체이닝이 길어져서 어디서 문제가 생기는지 추적이 어렵다.
parallelStream()을 성능 개선 목적으로 무분별하게 사용한다.
핵심 요약
Stream은 컬렉션 데이터를 선언적으로 필터링, 변환, 집계하는 파이프라인입니다. 가독성이 좋지만, 외부 상태를 변경하는 side effect를 넣으면 안 되고, 단순 로직에서는 for문이 나을 수 있습니다.
Optional은 메서드 반환 타입에서 값이 없을 수 있음을 명시적으로 표현할 때 사용합니다.
필드나 파라미터에는 사용하지 않고, get() 대신 orElseThrow()나 orElseGet()을 사용하는 것이 안전합니다.
주의할 점으로 orElse()는 값이 있어도 인자가 항상 실행되므로, 비용이 큰 연산은 orElseGet()을 써야 합니다.
꼬리 질문
Stream에서 side effect가 문제가 되는 구체적인 상황은?
forEach안에서 외부 리스트를 수정하면parallelStream사용 시 동시성 문제가 생깁니다.map()이나collect()로 결과를 반환하는 것이 안전합니다.
orElse()와orElseGet()의 차이를 코드로 설명할 수 있는가?
orElse(createDefault())는 값이 있어도createDefault()가 항상 실행됩니다.orElseGet(() -> createDefault())는 값이 없을 때만 실행됩니다. 비용이 큰 연산은orElseGet()을 써야 합니다.
parallelStream()은 언제 성능이 나빠지는가?데이터가 적거나, I/O 작업이 섞여 있거나, 공유 ForkJoinPool에서 다른 작업과 경합할 때 오히려 느려질 수 있습니다.
Optional을 필드로 사용하면 안 되는 이유는?
Optional은Serializable하지 않아 직렬화 문제가 생기고, 필드마다 wrapping하면 메모리 오버헤드와 코드 복잡성이 늘어납니다.
Stream의 lazy evaluation은 어떻게 동작하는가?
filter(),map()같은 중간 연산은 즉시 실행되지 않고,collect(),toList()같은 최종 연산이 호출될 때 파이프라인 전체가 실행됩니다.
점검 퀴즈
아래 문항은 개념을 실제로 설명할 수 있는지 점검하기 위한 것이다. 선택지를 누르면 정답 여부와 이유가 표시된다.
객관식orElse()와 orElseGet()의 핵심 차이는?
OXOptional을 Entity 필드나 DTO 필드 타입으로 쓰는 것은 일반적으로 권장된다.
객관식parallelStream()이 오히려 느려질 수 있는 대표적인 경우는?