언어/JAVA

[Java] Java Stream API 완벽 가이드 - Part 4: 테스트와 디버깅, 베스트 프랙티스

shaprimanDev 2025. 1. 18. 14:19
반응형

1. Stream API 테스트 전략

1.1 단위 테스트 작성

Stream API를 사용하는 코드의 효과적인 테스트 방법을 살펴보겠습니다.

@Test
class SalesAnalyzerTest {

    public class SalesAnalyzer {
        public Map<String, SalesStats> analyzeSalesByCategory(List<Sale> sales) {
            return sales.stream()
                .collect(Collectors.groupingBy(
                    Sale::getCategory,
                    Collectors.collectingAndThen(
                        Collectors.toList(),
                        this::calculateStats
                    )
                ));
        }

        private SalesStats calculateStats(List<Sale> sales) {
            return new SalesStats(
                sales.stream()
                    .map(Sale::getAmount)
                    .reduce(BigDecimal.ZERO, BigDecimal::add),
                sales.size()
            );
        }
    }

    @Test
    @DisplayName("카테고리별 판매 통계 계산 테스트")
    void analyzeSalesByCategory() {
        // Given
        List<Sale> sales = Arrays.asList(
            new Sale("전자제품", new BigDecimal("1000000")),
            new Sale("전자제품", new BigDecimal("1500000")),
            new Sale("의류", new BigDecimal("500000")),
            new Sale("의류", new BigDecimal("300000"))
        );

        SalesAnalyzer analyzer = new SalesAnalyzer();

        // When
        Map<String, SalesStats> result = analyzer.analyzeSalesByCategory(sales);

        // Then
        assertAll(
            () -> assertEquals(2, result.size()),
            () -> assertEquals(
                new BigDecimal("2500000"), 
                result.get("전자제품").getTotalAmount()
            ),
            () -> assertEquals(
                2, 
                result.get("전자제품").getCount()
            ),
            () -> assertEquals(
                new BigDecimal("800000"), 
                result.get("의류").getTotalAmount()
            ),
            () -> assertEquals(
                2, 
                result.get("의류").getCount()
            )
        );
    }

    @Test
    @DisplayName("빈 리스트에 대한 처리 테스트")
    void analyzeSalesWithEmptyList() {
        // Given
        List<Sale> sales = Collections.emptyList();
        SalesAnalyzer analyzer = new SalesAnalyzer();

        // When
        Map<String, SalesStats> result = analyzer.analyzeSalesByCategory(sales);

        // Then
        assertTrue(result.isEmpty());
    }
}

1.2 복잡한 Stream 연산 테스트

public class ComplexStreamProcessor {
    public List<TransactionSummary> processTransactions(
            List<Transaction> transactions) {
        return transactions.stream()
            .filter(this::isValidTransaction)
            .collect(Collectors.groupingBy(
                Transaction::getCustomerId,
                Collectors.collectingAndThen(
                    Collectors.toList(),
                    this::summarizeTransactions
                )))
            .values()
            .stream()
            .sorted(Comparator.comparing(TransactionSummary::getTotalAmount)
                .reversed())
            .collect(Collectors.toList());
    }

    @Test
    @DisplayName("복잡한 거래 처리 테스트")
    void testComplexTransactionProcessing() {
        // Given
        List<Transaction> transactions = createTestTransactions();
        ComplexStreamProcessor processor = new ComplexStreamProcessor();

        // When
        List<TransactionSummary> result = processor.processTransactions(transactions);

        // Then
        assertAll(
            // 결과 크기 확인
            () -> assertEquals(3, result.size()),

            // 정렬 순서 확인
            () -> assertTrue(
                result.get(0).getTotalAmount()
                    .compareTo(result.get(1).getTotalAmount()) >= 0
            ),

            // 특정 고객의 거래 요약 확인
            () -> {
                TransactionSummary firstSummary = result.get(0);
                assertEquals("CUST001", firstSummary.getCustomerId());
                assertEquals(new BigDecimal("1500000"), 
                    firstSummary.getTotalAmount());
                assertEquals(3, firstSummary.getTransactionCount());
            }
        );
    }
}

2. 디버깅 전략

2.1 peek()를 활용한 디버깅

public class StreamDebugger {
    private static final Logger log = LoggerFactory.getLogger(StreamDebugger.class);

    public List<ProcessedData> processWithDebugLogging(List<RawData> dataList) {
        return dataList.stream()
            .peek(data -> log.debug("Before filtering: {}", data))
            .filter(this::isValid)
            .peek(data -> log.debug("After filtering: {}", data))
            .map(this::transform)
            .peek(data -> log.debug("After transformation: {}", data))
            .filter(this::meetsCriteria)
            .peek(data -> log.debug("Final result: {}", data))
            .collect(Collectors.toList());
    }

    // 디버깅을 위한 중간 결과 수집기
    public static <T> Collector<T, ?, List<T>> debugCollector(String label) {
        return Collectors.collectingAndThen(
            Collectors.toList(),
            list -> {
                log.debug("{}: {}", label, list);
                return list;
            }
        );
    }
}

2.2 일반적인 문제와 해결 방법

public class CommonStreamIssues {
    // 문제 1: 스트림 재사용 시도
    public void streamReuseIssue() {
        Stream<String> stream = getDataStream();

        // 첫 번째 사용 - 정상
        List<String> list1 = stream.collect(Collectors.toList());

        // 두 번째 사용 - IllegalStateException 발생
        // List<String> list2 = stream.collect(Collectors.toList());

        // 해결방법: 새로운 스트림 생성
        List<String> list2 = getDataStream().collect(Collectors.toList());
    }

    // 문제 2: 무한 스트림 처리
    public void infiniteStreamIssue() {
        // 잘못된 방법
        // Stream.iterate(1, n -> n + 1)
        //     .collect(Collectors.toList()); // OutOfMemoryError 발생

        // 올바른 방법
        List<Integer> numbers = Stream.iterate(1, n -> n + 1)
            .limit(100)
            .collect(Collectors.toList());
    }

    // 문제 3: 병렬 스트림에서의 상태 공유
    public void parallelStreamStateIssue() {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        // 잘못된 방법
        List<Integer> result1 = new ArrayList<>();
        numbers.parallelStream()
            .map(n -> n * 2)
            .forEach(result1::add); // 동시성 문제 발생

        // 올바른 방법
        List<Integer> result2 = numbers.parallelStream()
            .map(n -> n * 2)
            .collect(Collectors.toList());
    }
}

3. 베스트 프랙티스

3.1 코드 가독성 향상

public class StreamBestPractices {
    // 1. 적절한 메소드 추출
    public List<Employee> getHighPerformers(List<Employee> employees) {
        return employees.stream()
            .filter(this::isHighPerformer)
            .filter(this::isEligibleForPromotion)
            .sorted(this::compareByPerformance)
            .collect(Collectors.toList());
    }

    private boolean isHighPerformer(Employee emp) {
        return emp.getPerformanceScore() >= 4.5;
    }

    private boolean isEligibleForPromotion(Employee emp) {
        return emp.getYearsOfService() >= 2;
    }

    private int compareByPerformance(Employee e1, Employee e2) {
        return Double.compare(
            e2.getPerformanceScore(), 
            e1.getPerformanceScore()
        );
    }

    // 2. 복잡한 조건의 분리
    public List<Transaction> getValidTransactions(List<Transaction> transactions) {
        Predicate<Transaction> validAmount = t -> 
            t.getAmount().compareTo(BigDecimal.ZERO) > 0;

        Predicate<Transaction> validDate = t -> 
            t.getDate().isAfter(LocalDate.now().minusDays(30));

        Predicate<Transaction> validStatus = t -> 
            t.getStatus() != TransactionStatus.CANCELLED;

        return transactions.stream()
            .filter(validAmount.and(validDate).and(validStatus))
            .collect(Collectors.toList());
    }
}

3.2 성능 고려사항

public class StreamPerformanceGuidelines {
    // 1. 적절한 데이터 구조 선택
    public Map<String, List<Transaction>> groupTransactions(
            Collection<Transaction> transactions) {
        // ArrayList보다 HashSet 사용이 검색에 효율적
        return new HashSet<>(transactions).stream()
            .collect(Collectors.groupingBy(Transaction::getCustomerId));
    }

    // 2. 불필요한 박싱/언박싱 피하기
    public double calculateAverage(List<Integer> numbers) {
        // 잘못된 방법
        // double avg1 = numbers.stream()
        //     .mapToDouble(Integer::doubleValue).average().orElse(0.0);

        // 올바른 방법
        return numbers.stream()
            .mapToInt(Integer::intValue)
            .average()
            .orElse(0.0);
    }

    // 3. 적절한 병렬화 결정
    public BigDecimal calculateTotalAmount(List<Order> orders) {
        return orders.size() > 10000 ?
            orders.parallelStream()
                .map(Order::getAmount)
                .reduce(BigDecimal.ZERO, BigDecimal::add)
            :
            orders.stream()
                .map(Order::getAmount)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

4. 실전 적용 가이드라인

  1. 스트림 체인의 적절한 길이 유지
    • 너무 긴 체인은 가독성을 해침
    • 중간 결과를 의미 있는 변수로 추출 고려
  2. 명확한 네이밍
    • 스트림 연산의 목적을 명확히 표현하는 메소드명 사용
    • 중간 결과를 저장하는 변수명도 의미있게 지정
  3. 예외 처리
    • 스트림 내부의 예외는 try-catch로 감싸서 처리
    • 필요한 경우 Optional을 활용하여 null 처리
  4. 단위 테스트 작성
    • 각 스트림 연산의 결과를 검증하는 테스트 케이스 작성
    • 경계 조건에 대한 테스트 포함
  5. 성능 모니터링
    • 대용량 데이터 처리 시 성능 측정
    • 병렬 스트림 사용 여부 결정

이러한 가이드라인을 따르면서 Stream API를 사용하면, 더 유지보수하기 쉽고 효율적인 코드를 작성할 수 있습니다.

반응형