Загружаем каталог…
Загружаем каталог…
Java로 코딩테스트 문제를 풀다 보면 Stream을 사용하면 상당히 간결하게 작성할 수 있는 코드가 많다. 예를 들어 int[] 을 Integer[] 로 변환해야 한다면 다음처럼 작성할 수 있다. Integer[] arr = Arrays.stream(numlist) .boxed() .toArray(Integer[]::new); 반대로 Integer[] 을 int[] 으로 변환하는 것도 간단하다. int[] result = Arrays.stream(arr) .mapToInt(Integer::intValue) .toArray(); 코드만 보면 반복문보다 훨씬 간결하다. 하지만 코딩테스트에서 직접 여러 방식으로 제출해 보면 Stream 대신 반복문을 사용했을 때 실행 시간이 더 짧게 나오는 경우가 있다. 그래서 이런 의문이 생겼다. Stream이 느리다면 왜 Java 실무 코드에서는 Stream을 많이 사용할까? 이를 이해하려면 먼저 Stream이 어떤 문제를 해결하기 위해 만들어졌는지, 그리고 Stream을 사용할 때 어떤 비용이 발생하는지를 구분해서 볼 필요가 있다. 1. 반복문과 Stream은 같은 일을 다른 방식으로 표현한다 먼저 간단한 예제를 살펴보자. 숫자 목록에서 짝수만 골라 2배로 만든다고 해보자. 반복문을 사용하면 다음과 같이 작성할 수 있다. List<Integer> result = new ArrayList<>(); for (Integer number : numbers) { if (number % 2 == 0) { result.add(number * 2); } } Stream을 사용하면 다음과 같다. List<Integer> result = numbers.stream() .filter(number -> number % 2 == 0) .map(number -> number * 2) .toList(); 결과는 같다. 하지만 코드가 표현하는 방식에는 차이가 있다. 반복문은 다음 과정을 직접 작성한다. numbers를 하나씩 순회한다. → 짝수인지 검사한다. → 2를 곱한다. → result에 추가한다. 반면 Stream에서는 다음과 같이 읽을 수 있다. numbers에서 → 짝수만 남기고 → 각각 2배로 변환한 뒤 → List로 만든다. 전자는 어떻게 처리할 것인가 를 자세히 작성하고, 후자는 어떤 결과를 만들 것인가 에 집중한다. 이 차이가 Stream을 사용하는 가장 큰 이유 중 하나다. 2. 명령형과 선언형 반복문 방식은 보통 명령형(Imperative) 스타일이라고 한다. 개발자가 처리 순서를 직접 제어한다. List<String> names = new ArrayList<>(); for (User user : users) { if (user.isActive()) { names.add(user.getName()); } } 여기에는 다음 정보가 모두 들어 있다. 컬렉션 생성 반복 조건 검사 값 추출 결과 추가 Stream은 보다 선언형(Declarative) 인 코드에 가깝다. List<String> names = users.stream() .filter(User::isActive) .map(User::getName) .toList(); 코드를 읽으면 처리 의도가 바로 보인다. 활성 사용자만 골라서 → 이름으로 변환하고 → List로 만든다. Stream의 핵심적인 장점은 단순히 코드를 짧게 만드는 것이 아니다. 데이터 처리 과정을 높은 수준의 연산으로 표현할 수 있다는 것이다. 3. 그렇다면 Stream은 왜 반복문보다 느릴 수 있을까? 단순 반복문은 JVM 입장에서 매우 단순하다. for (int i = 0; i < arr.length; i++) { result[i] = arr[i] * 2; } 해야 할 일은 사실상 다음뿐이다. 배열 접근 → 연산 → 배열 저장 → 다음 원소 반면 Stream을 사용하면 Arrays.stream(arr) .map(value -> value * 2) .toArray(); 개발자가 직접 반복문을 작성하지 않는 대신 Stream이 데이터 처리 과정을 관리한다. 개념적으로 보면 다음과 같은 구조가 추가된다. Stream 생성 ↓ 중간 연산 구성 ↓ 람다 실행 ↓ Stream pipeline 처리 ↓ 최종 연산 실행 JVM이 이런 코드를 상당 부분 최적화해 주지만, 단순 반복문과 비교하면 추가적인 추상화 계층이 존재한다. 따라서 작은 연산을 매우 많이 반복하는 상황에서는 그 추가 비용이 눈에 띌 수 있다. 4. Stream의 문제와 Boxing의 문제는 구분해야 한다 코딩테스트에서 Stream이 느리다고 느끼게 만드는 대표적인 원인 중 하나가 Boxing/Unboxing 이다. 예를 들어 다음 배열이 있다고 하자. int[] numbers = {1, 2, 3, 4, 5}; 이를 Integer[] 로 변환하려고 다음과 같이 작성했다. Integer[] arr = Arrays.stream(numbers) .boxed() .toArray(Integer[]::new); 여기서 중요한 부분은 .boxed() 다. 원래 배열의 값은 primitive 타입인 int 다. 하지만 boxed() 이후에는 각각 Integer 객체로 취급된다. 즉 int → Integer 라는 Boxing이 발생한다. 반대로 Arrays.stream(arr) .mapToInt(Integer::intValue) .toArray(); 를 수행하면 Integer → int 형태의 Unboxing이 필요하다. 따라서 다음 코드는 단순히 "Stream이라서 느리다"라고만 해석하면 정확하지 않다. Arrays.stream(numbers) .boxed() .sorted(...) .mapToInt(Integer::intValue) .toArray(); 여기에는 Stream 처리 비용 + Boxing + 객체 기반 정렬 + Unboxing 이 함께 포함되어 있다. 5. 반복문으로 변환해도 Boxing 자체는 사라지지 않는다 그렇다면 다음과 같이 반복문을 사용하면 Boxing이 없어질까? Integer[] arr = new Integer[numbers.length]; for (int i = 0; i < numbers.length; i++) { arr[i] = numbers[i]; } 그렇지는 않다. 왼쪽은 Integer , 오른쪽은 int 이기 때문에 이 코드에서도 Auto Boxing이 발생한다. int → Integer 즉 Stream 버전과 반복문 버전의 차이를 Stream → Boxing 발생 for문 → Boxing 없음 으로 이해하면 잘못된 것이다. 둘 다 Integer[] 을 만드는 이상 Boxing 자체는 필요하다. 반복문이 줄이는 것은 주로 배열을 변환하기 위한 Stream pipeline의 추가적인 처리 비용 이다. 6. 모든 Stream이 Boxing을 발생시키는 것은 아니다 Java에는 primitive 타입을 위한 별도의 Stream이 있다. 대표적으로 IntStream LongStream DoubleStream 이다. 예를 들어 다음 코드는 int[] numbers = {1, 2, 3, 4, 5}; int sum = Arrays.stream(numbers) .filter(number -> number % 2 == 0) .sum(); Stream<Integer> 가 아니다. Arrays.stream(int[]) 의 반환형은 IntStream 이다. 따라서 각 숫자를 Integer 객체로 변환하지 않고 primitive int 상태로 처리할 수 있다. 이 차이는 중요하다. IntStream → int를 그대로 처리 Stream<Integer> → Integer 객체를 처리 따라서 Arrays.stream(numbers) 와 Arrays.stream(numbers).boxed() 를 성능 관점에서 똑같이 취급해서는 안 된다. 7. Stream은 중간 연산마다 새로운 컬렉션을 만들까? 처음 Stream을 접하면 다음 코드가 numbers.stream() .filter(...) .map(...) .filter(...) .toList(); 마치 다음처럼 동작한다고 생각하기 쉽다. filter 결과 List 생성 ↓ map 결과 List 생성 ↓ filter 결과 List 생성 ↓ 최종 List 생성 하지만 Stream은 기본적으로 그렇게 동작하지 않는다. Stream의 중간 연산은 지연 평가(Lazy Evaluation) 된다. 대표적인 중간 연산은 filter map sorted distinct limit 등이다. 이들은 호출되는 순간 데이터를 전부 처리하는 것이 아니라 처리 방법을 pipeline에 등록한다. 실제 처리는 toList() collect() sum() count() forEach() 같은 최종 연산(Terminal Operation)이 호출되었을 때 시작된다. 예를 들어 numbers.stream() .filter(n -> n > 10) .map(n -> n * 2) .toList(); 는 개념적으로 각 원소가 원소 하나 ↓ filter ↓ map ↓ 결과 를 통과하는 방식으로 처리된다. 중간 단계마다 새로운 List를 만드는 것은 아니다. 8. 그렇다면 실무에서는 왜 Stream을 많이 사용할까? 실무에서는 실행 시간만큼이나 중요한 것이 있다. 가독성 유지보수성 코드의 의도 변경 용이성 버그 발생 가능성 예를 들어 Spring 애플리케이션에서 Entity 목록을 Response DTO 목록으로 변환하는 코드를 생각해 보자. 반복문으로 작성하면 다음과 같다. List<UserResponse> responses = new ArrayList<>(); for (User user : users) { responses.add(UserResponse.from(user)); } Stream을 사용하면 List<UserResponse> responses = users.stream() .map(UserResponse::from) .toList(); 로 표현할 수 있다. 여기에 활성 사용자만 반환한다는 조건이 추가되면 List<UserResponse> responses = users.stream() .filter(User::isActive) .map(UserResponse::from) .toList(); 처럼 자연스럽게 확장된다. Stream 코드의 장점은 반복한다 새로운 리스트를 만든다 리스트에 추가한다 같은 구현 세부사항이 사라지고 활성 사용자만 선택 → Response로 변환 → List 생성 이라는 비즈니스 로직이 강조된다는 것이다. 9. 실무에서 Stream과 잘 어울리는 작업 Stream은 특히 컬렉션을 다른 형태의 데이터로 변환하는 작업 과 잘 맞는다. 필터링 List<User> activeUsers = users.stream() .filter(User::isActive) .toList(); 변환 List<UserResponse> responses = users.stream() .map(UserResponse::from) .toList(); 특정 값 추출 List<Long> userIds = users.stream() .map(User::getId) .toList(); 합계 계산 long totalPrice = orders.stream() .mapToLong(Order::getPrice) .sum(); 그룹핑 Map<String, List<User>> usersByDepartment = users.stream() .collect(Collectors.groupingBy(User::getDepartment)); 이런 코드는 Stream이 무엇을 하려는 코드인지 쉽게 드러난다. 10. Stream을 사용하지 않는 편이 나은 경우도 있다 Stream이 항상 더 좋은 코드는 아니다. 특히 상태를 계속 변경해야 하는 로직에서는 오히려 반복문이 읽기 쉽다. 예를 들어 다음처럼 Stream 내부에서 여러 Side Effect를 발생시키는 코드는 좋지 않다. users.stream() .filter(user -> { count++; user.setActive(true); log.info("user={}", user); return user.getAge() >= 20; }) .forEach(...); filter() 는 원래 조건을 만족하는 데이터를 선택한다. 라는 의미를 가진다. 그 안에서 count 증가 객체 변경 로그 출력 조건 검사 를 모두 수행하면 Stream의 선언적인 장점이 사라진다. 이런 경우에는 차라리 반복문이 명확하다. for (User user : users) { count++; user.setActive(true); log.info("user={}", user); if (user.getAge() >= 20) { // ... } } 11. 성능이 중요한 반복 작업이라면 반복문이 더 적합할 수 있다 다음과 같이 매우 많은 primitive 값을 반복 처리해야 한다고 해보자. for (int i = 0; i < 100_000_000; i++) { // 단순 계산 } 이런 코드에서는 각 연산 자체가 매우 작기 때문에 Stream pipeline의 추가 비용이 전체 실행 시간에서 차지하는 비중이 커질 수 있다. 특히 코딩테스트에서는 시간 제한 메모리 제한 이 명확하게 존재한다. 따라서 단순 반복문으로 쉽게 해결할 수 있다면 Stream을 굳이 사용하는 것이 이득이 아닐 수 있다. 12. 코딩테스트와 실무에서는 최적화 기준이 다르다 코딩테스트에서는 보통 다음을 중요하게 생각한다. 정답 ↓ 시간 제한 ↓ 메모리 제한 ↓ 구현 안정성 코드가 한 번 실행되고 정답과 실행 시간이 평가된다. 반면 실무에서는 코드가 한 번 작성된 뒤 오랜 기간 유지된다. 그래서 다음 요소의 중요성이 훨씬 커진다. 정확성 가독성 유지보수성 변경 용이성 적절한 성능 예를 들어 두 방식의 처리 시간이 다음과 같다고 가정해 보자. for문 : 0.03ms Stream : 0.05ms 수치만 보면 Stream은 꽤 느리다. 하지만 웹 요청 하나를 처리하는 전체 과정이 다음과 같다면 어떨까? DB 조회 25ms 외부 API 호출 100ms JSON 직렬화 2ms 비즈니스 로직 0.05ms 여기에서 0.02ms 를 줄이는 것보다 코드의 의미를 명확하게 만드는 것이 더 가치 있을 수 있다. 즉 실무에서의 성능 최적화는 무조건 가장 빠른 문법을 선택한다. 가 아니라 실제 병목이 되는 부분을 측정하고 필요한 곳을 최적화한다. 에 가깝다. 13. 온라인 코딩테스트의 실행 시간만으로 Java 성능을 단정하면 안 된다 코딩테스트 사이트에 같은 코드를 여러 번 제출하다 보면 실행 시간이 다르게 나오는 경우도 있다. 따라서 A 풀이: 0.4ms B 풀이: 0.7ms 가 한두 번 나왔다고 해서 A는 언제나 B보다 정확히 이만큼 빠르다. 라고 일반화하면 안 된다. JVM에서는 JIT 컴파일 GC 실행 환경 워밍업 여부 측정 방식 등 여러 요소가 영향을 줄 수 있다. Java 코드의 미세한 성능 차이를 제대로 비교하려면 일반적으로 JMH(Java Microbenchmark Harness) 같은 벤치마크 도구를 사용하는 것이 적절하다. 코딩테스트 결과는 이 문제와 이 채점 환경에서 어떤 구현이 더 빠르게 측정되었는가 정도로 받아들이는 것이 좋다. 14. 코딩테스트에서는 어떻게 선택할까? 개인적으로는 다음 정도의 기준을 가져갈 수 있을 것 같다. 단순 반복 작업 for (...) 반복문을 우선 고려한다. 특히 배열의 인덱스를 직접 사용하거나 상태를 갱신해야 한다면 반복문이 자연스럽다. 컬렉션을 단순 변환하는 문제 입력 크기가 작고 Stream이 훨씬 간단하다면 Stream을 사용해도 된다. list.stream() .filter(...) .map(...) .toList(); primitive 연산 Stream을 사용한다면 가능하면 IntStream LongStream DoubleStream 을 활용해 불필요한 Boxing을 피할 수 있는지 확인한다. .boxed() 가 필요한 경우 .boxed() 가 등장한다면 정말 객체 Stream이 필요한지를 한 번 더 생각해 볼 수 있다. 15. 실무에서는 어떻게 선택할까? 실무에서는 기준이 조금 달라진다. 다음처럼 Stream으로 데이터 처리 의도를 명확하게 표현할 수 있다면 좋은 선택이 될 수 있다. List<OrderResponse> responses = orders.stream() .filter(Order::isCompleted) .map(OrderResponse::from) .toList(); 반대로 Stream 내부에서 상태를 계속 변경해야 한다. 복잡한 분기문이 많다. 여러 개의 외부 변수를 수정한다. 성능이 실제 병목으로 확인됐다. 와 같은 상황이라면 반복문이 더 적절할 수 있다. 결국 for문 vs Stream 중 하나가 항상 우월한 것이 아니다. 두 방식이 표현하기 좋은 문제가 서로 다르다. 정리 처음에는 단순히 Stream을 사용하면 코딩테스트에서 느리다. 정도로 생각했다. 하지만 실제로 살펴보면 성능 차이에는 여러 요소가 섞여 있다. Stream pipeline의 추가 비용 Boxing / Unboxing 객체 생성 primitive와 Wrapper의 차이 정렬이나 Collection 생성 비용 JVM 최적화 그리고 Stream을 평가할 때 성능만 보면 Stream이 만들어진 이유를 놓치게 된다. Stream의 가장 큰 가치는 데이터 처리 과정을 선언적으로 표현할 수 있다는 것 에 있다. 따라서 선택 기준은 다음처럼 정리할 수 있다. 코딩테스트 → 단순하고 성능이 중요한 반복은 for문을 우선 고려 → Stream이 훨씬 명확하고 입력이 작다면 사용할 수 있음 실무 → 데이터 변환 pipeline이라면 Stream이 매우 유용 → 상태 변경이 많거나 성능 병목이라면 반복문 고려 그리고 무엇보다 중요한 것은 Stream은 느리니까 쓰지 않는다 혹은 Stream이 코드가 짧으니까 항상 사용한다 와 같이 한쪽으로 결론 내리지 않는 것이다. 코드가 무엇을 표현하려는지, 데이터 크기는 어느 정도인지, 성능이 실제로 중요한 부분인지에 따라 적절한 방식을 선택하는 것이 더 중요하다.
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
Java Stream은 왜 코딩테스트에서는 느리고 실무에서는 많이 사용할까?. Java로 코딩테스트 문제를 풀다 보면 Stream을 사용하면 상당히 간결하게 작성할 수 있는 코드가 많다. 예를 들어 int[] 을 Integer[] 로 변환해야 한다면 다음처럼 작성할 수 있다. Integer[] arr = Arrays.stream(numlist) .boxed() .toArray(Integer[]::new); 반대로 Integer[] 을 int[] 으로 변환하는 것도 간단하다. int[] result = Arrays.stream(arr) .mapToInt(Integer::intValue) .toArray(); 코드만 보면 반복문보다 훨씬 간결하다. 하지만 코딩테스트에서 직접 여러 방식으로 제출해 보면…
Открыть источник