자바 8부터 도입된 Stream API를 처음 접하게 되면 가장 먼저 드는 의문이 있습니다.
"이미 배열(Array)이나 리스트(List)로 데이터를 잘 다루고 있었는데, 왜 굳이 스트림(Stream)이라는 것을 또 써야 할까? 둘 다 여러 개의 데이터를 다루는 것 아닌가?"
이 두 가지 개념을 헷갈리는 이유는 '데이터를 다루는 목적'이 완전히 다르다는 점을 간과하기 때문입니다. 결론부터 말씀드리면, 배열은 데이터의 '저장소'이며 스트림은 데이터의 '가공 파이프라인'입니다. 오늘은 이 두 가지의 명확한 차이점과 상호보완적인 관계를 자바의 동작 원리를 통해 기술적으로 정리해 보겠습니다.
배열(Array)이나 자바 컬렉션 프레임워크(List, Set, Map)의 근본적인 목적은 메모리 상에 데이터를 안전하게 구조화하여 '저장'하고 '유지'하는 것입니다.
반면 스트림(Stream)은 데이터를 물리적으로 저장하는 자료구조가 아닙니다. 배열이나 컬렉션에 저장된 데이터를 가져와서 개발자가 원하는 대로 조작(필터링, 매핑, 정렬 등)하기 위한 '연산의 흐름'을 제공하는 API입니다.
배열과 스트림은 경쟁 관계나 대체제가 아닙니다. "배열(소스)을 기반으로 스트림(연산)을 생성하여 데이터를 가공한다"는 완벽한 협력 관계를 맺고 있습니다.
이를 가장 명확하게 보여주는 것이 반복 방식의 차이입니다.
기존의 배열 처리 방식입니다. 개발자가 직접 for문이나 Iterator를 사용하여 배열의 요소를 하나씩 꺼내오고(Pull), 그 안에서 데이터를 지지고 볶는 로직을 모두 구현해야 합니다.
// 원본 배열(소스)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> evenNumbers = new ArrayList<>();
// 외부 반복: 개발자가 직접 배열을 순회하며 제어함
for (Integer num : numbers) {
if (num % 2 == 0) {
evenNumbers.add(num);
}
}
스트림을 사용한 방식입니다. 개발자는 데이터 가공 로직(조건식)만 제공하고, 반복과 처리는 스트림 내부에서 알아서 수행합니다(Push)
// 원본 배열(소스)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// 내부 반복: 배열에서 스트림을 생성하여 데이터 가공을 위임함
List<Integer> evenNumbers = numbers.stream()
.filter(num -> num % 2 == 0)
.toList(); // (Java 16+)
| 구분 | 배열 및 컬렉션 (Array / Collection) | 스트림 (Stream) |
|---|---|---|
| 목적 | 데이터의 저장 및 상태 관리 | 데이터의 연산 및 가공 |
| 데이터 공간 | 물리적 메모리 공간을 차지함 | 저장 공간을 가지지 않음 |
| 원본 변경 | 요소를 직접 추가/삭제/수정할 수 있음 | 원본 데이터를 절대 변경하지 않음 (Read-only) |
| 반복 방식 | for, while 등을 이용한 외부 반복 | 스트림 API 내부에서 처리하는 내부 반복 |
| 수명 | 참조가 끊어지기 전까지 영구적 | 한 번 소비되면 끝나는 일회용 |
배열과 스트림을 혼동하지 않는 가장 좋은 방법은 역할을 완벽히 분리하는 것입니다.
데이터를 '담아두고 관리'해야 할 때는 배열을 고민하고, 담겨있는 데이터를 조건에 맞게 '뽑아내거나 변환'해야 할 때는 주저 없이 스트림 파이프라인을 구축하세요. 이 두 가지를 적절히 조화시킬 때 자바 프로그래밍의 효율성과 코드의 가독성은 극대화됩니다.