1. 명령형 접근의 문제
클래식한 문제로 시작해 봅시다. 사용자 목록이 있다고 가정해 보세요:
List<User> users = ...; // 이미 채워져 있음
예를 들어, 성인(18세 이상) 사용자들의 이름 목록이 필요하다고 합시다. 예전에는 어떻게 했을까요?
List<String> names = new ArrayList<>();
for (User user : users) {
if (user.getAge() >= 18) {
names.add(user.getName());
}
}
겉보기엔 단순하지만 ‘기술적인’ 단계들에 주목해 보세요: 결과용 리스트 선언, 모든 요소 순회, 조건 검사, 결과 추가.
문제가 복잡해지면 — 예를 들어, 이름이 'A'로 시작하는 모든 사용자의 중복 없는 이메일을 알파벳 순으로 정렬된 목록으로 얻어야 한다면 — 코드는 급격히 비대해집니다. 필터링, 필드 추출, 중복 제거, 정렬, 필요하면 이메일 형식 변환 등등. 그 결과 짧고 명료한 로직 대신 읽고 유지보수하기 어려운 보일러플레이트가 산더미처럼 생깁니다.
이런 접근의 단점을 한마디로 요약하면 다음과 같습니다: 반복되는 코드가 많고, 실수할 확률이 높으며(정렬을 빼먹거나 중복을 잘못 처리하는 등), 연산을 조합하기가 어렵습니다. 이것이 바로 명령형 스타일입니다. 컴퓨터에게 여러분이 원하는 것이 아니라, 어떻게 수행할지를 단계별로 지시하는 방식이죠.
2. Stream API — 선언형 스타일
Java 8에서 Stream API가 등장했습니다 — 컬렉션을 “무엇을 할지” 관점에서 다루게 해 주는 도구로, “어떻게 정확히 할지”가 아닙니다. 이런 접근을 선언형이라고 부릅니다.
그렇다면 Stream은 정확히 무엇일까요?
이는 별도의 컬렉션이 아니라 데이터의 흐름에 가깝습니다. 요소들이 필터링, 변환, 정렬, 결과 수집 등 일련의 연산 체인을 통과하는 연속입니다. 스트림은 아무것도 저장하지 않고, 데이터를 파이프라인을 통해 “흘려보내기”만 합니다. 연산은 LEGO 블록처럼 거의 마음대로 조합할 수 있습니다. 체인을 만들고 실행하면 됩니다.
예시:
List<String> names = users.stream() // 사용자 리스트에서 스트림 생성
.filter(u -> u.getAge() >= 18) // 18세 이상만 남김
.map(User::getName) // User → String으로 변환(이름 추출)
.collect(Collectors.toList()); // 결과를 리스트로 수집
이게 전부입니다 — 한 줄로 전체 작업을 설명했습니다: “사용자를 가져오고, 나이로 필터링하고, 이름을 꺼내서 리스트로 모아라.” 직접 루프를 작성하고, 임시 변수를 만들고, 빠뜨린 게 없는지 신경 쓸 필요가 없습니다.
Stream API는 공장 컨베이어처럼 동작합니다. 여러분은 처리 단계를 지정하고, 데이터는 그 단계를 스스로 통과합니다. 간결하고 아름다우며, 가독성이 훨씬 좋아집니다.
3. Stream API의 장점
- 간결함과 가독성. 같은 문제를 명령형과 선언형으로 각각 풀어 보세요. 어느 쪽이 읽고 유지보수하기 쉬운지 금세 드러납니다.
- 연산의 손쉬운 조합. 연산을 체인으로 쉽게 “쌓을” 수 있습니다: filter, map, sorted, collect — 모두 하나의 일관된 흐름 안에 담깁니다.
- 오류 감소. Stream API는 반복 작업을 덜어 줍니다. 중간 컬렉션과 루프를 수동으로 관리하지 않아도 되니, 단계를 빠뜨리거나 인덱싱 오류를 낼 가능성이 줄어듭니다.
- 병렬화 가능성. stream() 대신 parallelStream()만 호출하면 손쉽게 멀티코어로 확장할 수 있습니다. 자세한 내용은 뒤에서 다룹니다.
- 현대적 프로그래밍 스타일. 함수형 프로그래밍의 아이디어(함수 합성, 명시적 루프의 부재)는 표현력을 높여 주며 업계에서도 높은 가치를 인정받습니다.
Stream API의 간단한 역사
Stream API는 Java 8(2014)에 도입되어 언어에 질적인 도약을 가져왔습니다. 그 전에는 컬렉션에 대한 비트는 작업도 많은 보조 코드가 필요했지만, 다른 플랫폼들은 이미 선언형 접근(map, filter, reduce 등)을 제공하고 있었습니다.
그때부터 Stream API는 Java에서 컬렉션을 처리하는 사실상의 표준이 되었습니다. 최신 Java 코드를 작성하고 싶다면 빼놓을 수 없습니다.
4. Stream API 적용 영역
- 필터링: 필요한 요소만 선택(예: 18세 이상 사용자 모두).
- 변환: 필드 추출 또는 새 객체 생성(예: 사용자 이름 목록).
- 집계: 합계, 평균, 개수, 최댓값/최솟값 계산.
- 정렬: 원하는 기준으로 요소 정렬.
- 그룹화: 요소를 범주별로 묶기.
- 결과 수집: 리스트, 집합, 맵, 문자열 등으로 모으기.
예제 과제:
- 이름이 'A'로 시작하는 모든 사용자의 이메일 목록을 얻기.
- 사용자들의 평균 나이 계산.
- 특정 이메일을 가진 첫 번째 사용자 찾기.
- 모든 고유한 거주 도시를 하나의 집합으로 모으기.
5. 유용한 팁
스트림은 이렇게 동작합니다
[User1] --\
[User2] ---|--> [ filter ] --> [ map ] --> [ collect ] --> List<String>
[User3] --/
1. filter — 조건을 만족하는 사용자만 통과시킵니다.
2. map — 사용자를 예컨대 이메일 같은 값으로 변환합니다.
3. collect — 이메일을 원하는 컬렉션으로 모읍니다.
문법: 스트림 생성 방법
- 컬렉션에서: list.stream() — 컬렉션 요소의 스트림.
- 배열에서: Arrays.stream(array)
- 개별 값에서: Stream.of("a", "b", "c")
6. Stream API로 전환할 때 흔한 실수
실수 1: 스트림 내부에서 컬렉션을 변경하려는 시도. Stream API는 원본 컬렉션을 수정하도록 설계되지 않았습니다(예: forEach 내부에서 list.remove()를 호출하지 마세요). 삭제가 필요하다면 처리 전후에 컬렉션의 removeIf 등을 사용하세요.
실수 2: 컬렉션과 스트림을 혼동. 스트림은 컬렉션이 아닙니다! 스트림은 요소를 한 번만 “흘러가게” 하며, 그 후에는 닫힙니다. 데이터를 다시 처리하려면 새 stream()을 생성하세요.
실수 3: 지나치게 복잡한 체인. 한 표현식을 열여 개가 넘는 연산으로 만든 “괴물”로 변환하지 마세요. 체인이 길어지면 가독성과 디버깅을 위해 몇 개의 단계로 나누고 중간 변수를 사용하세요.
GO TO FULL VERSION