[모-던한 프로그래밍] 람다/스트림/Optional, 지연 평가와 flatMap
본 글은 스트림에 어느정도 익숙한 사용자를 대상으로 작성된 게시글로, IDE를 통해 어찌저찌 사용하는 수준의 개발자를 대상으로 작성된 게시글입니다.
IDE가 추천해주는 기능을 넘어서, 능동적으로 스트림을 사용하기 위한 기본 동작 구조를 살펴봅시다.
목차
- 스트림(Stream)이란?
- 람다 vs 익명 클래스
- 스트림의 Lazy Operation
- map vs flatMap
- Optional
- 기본형 특화 람다/스트림
- 그 외 주요 기능
스트림(Stream)이란?
스트림의 정의
데이터의 흐름을 추상화해서 다루는 도구로, 컬렉션 또는 배열 등의 요소들을 연산 파이프라인을 통해 연속적인 형태로 처리할 수 있게 해줍니다. 이때, 연산들의 체이닝을 적극 활용합니다.
파이프라인 (컴퓨팅) - 위키백과, 우리 모두의 백과사전
위키백과, 우리 모두의 백과사전. 컴퓨터 과학에서 파이프라인(영어: pipeline) 또는 데이터 파이프라인(data pipeline)[1]은 한 데이터 처리 단계의 출력이 다음 단계의 입력으로 이어지는 형태로 연결
ko.wikipedia.org
스트림의 특징
- 스트림은 원본 데이터 소스를 변경하지 않습니다.
이를 통해 해당 함수가 사이드 이펙트를 갖지 않도록 만들게 됩니다. - 한번 소비된 스트림은 다시 사용할 수 없으며, 필요하다면 "새로 스트림을 생성"해야 합니다.
(스트림을 한번 사용하고 재사용하려고 하면, Stream has alread been operated upon or closed 라는 메시지의 IllegalStateException이 발생합니다.) - 파이프라인으로 구성되어 있습니다.
- Lazy Operation (아래에서 다룰 예정입니다.)
- Parallel Stream을 이용해서 간단하게 병렬 처리를 사용할 수 있습니다.
(해당 내용은 ForkJoinPool 내용과도 연관되어지고, 본 글의 주제를 벗어난다고 생각하여, 별도 글에서 다룰 예정입니다.)
외부반복 vs 내부 반복
외부 반복: for문
- 아주 간단한 반복일 경우 사용합니다.
- 중간에 break, continue 등으로 분기 제어가 필요한 매우 복잡한 반복문의 경우 사용합니다.
내부 반복: forEach
- 위에서 설명한 경우의 반복문이 아닌 모든 경우, 내부 반복이 가독성이 더 좋습니다.
람다 vs 익명 클래스
람다와 익명 클래스 간의 트레이드오프를 이해해봅시다.
크게 아래와 같은 관점으로 비교가 가능합니다.
- 코드의 간결함
- 문법적 단순함
- 타입의 결정
- 상속 관계
- 호환성
- this 키워드의 의미
- 생성 방식
- 상태 관리
- 캡처링
코드의 간결함
직접 사용해보셨으니 아시겠지만, 익명 클래스는 람다에 비해 좀 더 장황한 경향이 있습니다. 따라서 모던 자바 문법을 사용하게 된다면, 일반적으로 람다를 선택하게 됩니다.
문법적 단순함
또한, 익명 클래스는 클래스 타입을 직접 지정해주어야 하기 때문에, 생각해야 하는 절차가 한단계 늘어 병목이 발생하지만, 람다의 경우에는 타입 추론기능 덕분에 타입 정보를 직접 지정해주지 않아도 됩니다.
타입의 결정
익명 클래스
익명 클래스의 경우, 객체 생성과 동일한 과정을 거치기 때문에, 타입을 미리 지정해주어야 해당 클래스를 사용할 수 있습니다.
람다
하지만 람다는, 자신이 대입될 변수의 타입에 의해 타입이 결정됩니다.
이때, 그 자체로는 타입이 결정되지 않고, 외부의 컨텍스트에 따라 타입이 추론되어 결정되며, 이는 메소드 참조에도 활용되는 람다의 핵심 기술 중 하나입니다.
타겟 타입 개념을 참고하길 바랍니다.
상속 관계
익명 클래스는 말 그대로 “클래스” 이기 때문에, 모든 종류의 인터페이스를 구현할 수 있습니다.
하지만 람다의 경우에는 함수형 패러다임에 맞춘 형태이기 때문에, 메소드를 구분하기 위해서 함수형 인터페이스만 구현이 가능합니다.
호환성
익명 클래스의 경우, 자바 1.1 이상의 버전만 지원하면 이용이 가능하므로, 호환성이 뛰어납니다. 하지만 람다의 경우, 자바 8버전부터 도입이 되었기 때문에, 자바 8 이상에서만 사용이 가능합니다.
this 키워드의 의미
우리는 코드를 작성할 때, this 키워드를 통해 자기 참조 프로그래밍 방법을 사용하곤 합니다.
이때, 익명 클래스는 인스턴스 자기 자신을 가리킬 수 있기 때문에, 해당 자기 참조 방식이 구현이 가능하지만, 람다의 경우에는 this 키워드가 자기 자신을 가리키는 것이 불가능하고, 자신을 호출한 인스턴스를 가리키게 됩니다.
생성 방식
익명 클래스
익명 클래스의 경우, 새로운 클래스를 정의하여 실제로 객체를 생성하기 때문에, 컴파일 시점에 OuterClass$1.class 와 같은 클래스 파일이 생성됩니다.
람다
람다는 런타임 시점에 동적으로 필요한 코드를 처리합니다. 따라서 컴파일 시점에 클래스 파일이 생성되진 않습니다.
이 과정에서 invokeDynamic 기능을 사용합니다.
invokeDynamic이란?
자바 7부터 도입된 바이트코드 명령어로, 동적 타입 언어 지원 및 람다 표현식 구현을 위해 등장한 명령어입니다.
코틀린의 문자열 템플릿에서도 자바 코드로 변환하는 과정에 사용되기도 하며, 자세한 내용은 다음 링크를 통해 확인하실 수 있습니다.
An Introduction to Invoke Dynamic in the JVM | Baeldung
Learn about invokedynamic and see how it can help library and language designers to implement many forms of dynamicity.
www.baeldung.com
상태 관리
익명 클래스
- 내부에 상태(필드)를 가질 수가 있다.
람다
- 내부에 상태를 가지지 않는다.
캡처링
익명 클래스와 람다 모두, 외부 인스턴스의 상태를 직접 참조하는 것을 지양하기 때문에, 외부 변수가 대입될 경우, capturing 하여 값을 대입합니다. 따라서 한번 캡처링한 값은 값의 변경이 불가능해집니다.
그래서 어떻게 쓰는데?
그래서 어떠한 기준으로 선택해서 사용하면 될까요?
- 익명 클래스
- 상태를 유지해야 하는 경우
- 다중 메소드를 구현해야 하는 경우
- 람다
- 상태를 유지할 필요가 없는 경우
- 단일 메소드만 있으면 되는 경우
스트림의 Lazy Operation
한번 예상하는 대로, 직접 스트림의 동작 방식을 구현해봅시다.
MyStream
현재 메소드 체이닝 방식을 통해 스트림과 유사하게 사용 가능하도록 구현을 간단하게 진행해 보았는데요, 현재 방식은 반복문이 여러번 호출되고 있습니다. 따라서 일반적인 반복문처럼 n번만에 처리되는 것이 아닌, 3n번만에 처리되고 있는데, 만약 이 작업이 훨씬 많아진다면, 지나치게 비효율적인 방식으로 동작할 것이라는 불안을 가질 수 있습니다.
하지만, 자바는 이 문제를 지연 연산(Lazy Operation)방식을 통해 해결합니다.
중간 연산 최적화 - 지연 연산(Lazy Operation)
자바에서 체이닝된 모든 중간 연산은 모두 다 합쳐서 한번만에 수행합니다. 그렇게 실제로 최종 연산이 실행될 때, 한번에 처리됩니다.
직접 만든 MyStream과 자바의 Stream을 비교해가며 이해해봅시다.
먼저 다음과 같은 리스트를 제시하고 시작하도록 하겠습니다.
예시 1: toList
둘 다 형식상으로는 스트림을 생성하는 부분을 제외하고는 차이가 없습니다.
그렇다면 결과는 어떤 차이를 가져오게 될까요?
먼저 직접 만든 스트림입니다.
모든 연산에 대하여, 각 스트림 중간연산 별로(1) 모든 원소를 순회(2)하며 차근차근 수행되고 있는 것을 확인할 수 있습니다.
그렇다면 Java 의 스트림은 어떠한 결과를 가져오게 될까요?
자바의 스트림의 경우, 모든 원소 별로(1) 각 스트림의 중간 연산을 합친 것들을 모두 적용(2)하며 차근차근 결과를 만들어내고 있습니다.
이는 어떠한 차이를 만들어낼지, 잠시 뒤에 알아보도록 합시다.
예시 2: 최종 연산 없이
다음은 최종 연산이 없는 경우입니다. 이 경우에는 어떠한 차이를 만들어낼까요?
먼저 MyStream의 경우입니다.
MyStream의 경우, 일단 해당 작업을 수행하고 있는 것을 확인하실 수 있습니다.
하지만, 이 연산 결과가 애초에 현재 소스코드상 쓰이지 않고, 최종 연산으로 유의미한 결과를 내고 있지 않은데, 이게 과연 큰 의미가 있을까요?
따라서, Java의 스트림은 최종 연산이 없다면, 애초에 작업을 시작하지 않습니다.
이렇게 최종 연산의 등장 시점까지 연산을 지연시키는것은 다음 예시에서 뛰어난 효과를 가져옵니다.
예시 3: findFirst()
이번엔 연산을 모두 적용한 첫번째 원소를 반환하는 과정입니다.
먼저 MyStream입니다.
MyStream의 경우, 모든 원소를 순회한 뒤 가장 앞에 존재하는 원소를 반환합니다. 이때, 값 2,3,4,5는 실제로 사용되지도 않았는데, “불필요하게 연산을 수행했다”라는 느낌을 받으시지 않으시나요?
이를 위해 Java는 단축 평가 모델을 적용합니다.
Java의 스트림은 최종 연산을 모두 만족시키면, 순회를 멈추고 즉시 결과를 반환합니다.
이를 Short-curcuit(단축 평가)라고 하며, 이 최적화는 지연 연산 + 파이프라인 방식이 모두 있어야 가능한 최적화입니다.
map vs. flatMap
개인적으로 flatMap의 기능은 상당히 혼란스럽다고 생각합니다.
따라서 시작하기 전에 다음 사항을 짚고 넘어가겠습니다.
1. 스트림의 flatMap과 Optional의 flatMap은 다른 함수입니다.
2. 하지만, 둘 다 중첩 구조를 한차원 낮춰 평탄화한다는 목적은 동일합니다.
- map
- 요소를 다른 형태로 변환
- flatMap
- 중첩 구조를 한차원 낮춰 평탄화
이렇게 보니, 정확히 이해가 가지 않고 추상화된 개념으로밖에 보이지 않네요.
좀 더 자세하게 클래스를 정의하고 파고들어봅시다.
flatMap
flatMap
의 핵심 기능은 다음과 같습니다.
- 매핑 (Mapping): 스트림이 비어있지 않다면, 컬렉션의 각 요소에 주어진 함수를 적용하여 새로운 컬렉션(또는 스트림)을 생성합니다.
- 평탄화 (Flattening): 매핑 단계에서 생성된 여러 개의 컬렉션(또는 스트림)들을 단일 컬렉션(또는 스트림)으로 결합합니다.
예제 1: 2차원 컬렉션 -> 1차원 변환
다음 코드의 동작을 분석해봅시다.
현재 코드는 다음 값들이 리스트 형태로 존재하고 있습니다.
1 | 2 | 3 |
---|---|---|
4 | 5 | 6 |
그렇다면 주어진 람다는 어떠한 과정을 거쳐서 어떠한 결과를 낼지, 하나씩 생각해봅시다.
flatMap 과정
- 리스트 [1,2,3]에 대하여, 해당 리스트에 stream() 메소드를 적용한다. 따라서 결과는 1 - 2 - 3 형태의 0차원 점의 집합(데이터 파이프라인)이 형성된다.
- 리스트 [4,5,6]에 대하여, 해당 리스트에 stream() 메소드를 적용한다. 따라서 결과는 4 - 5 - 6 형태의 0차원 점의 집합(데이터 파이프라인)이 형성된다.
- 두개의 스트림을 하나의 스트림으로 평탄화한다. 따라서 결과는 1-2-3-4-5-6 형태의 0차원 점의 집합(데이터 파이프라인)이 형성된다.
toList 과정
- 주어진 스트림 (1-2-3-4-5-6)을 하나의 리스트로 합친다.
따라서 결과는 리스트 [1, 2, 3, 4, 5, 6] 이 반환되게 됩니다.
예제 2: 3차원 컬렉션 -> 2차원 변환
조금 더 난이도를 높혀, 3차원 컬렉션 → 2차원으로의 변환 과정을 분석해봅시다.
현재 코드는 다음과 같은 값들이 리스트로 3차원을 이루며 존재하고 있습니다.
똑같은 절차를 거치면서 분석해볼까요?
flatMap 과정
- 리스트 [[1,2,3], [4,5,6], [7,8,9], [10,11,12]]에 대하여, 해당 리스트에 stream() 메소드를 적용한다. 따라서 결과는 [1,2,3]-[4,5,6]-[7,8,9]-[10,11,12] 형태의 1차원 리스트의 집합(데이터 파이프라인)이 형성된다.
- 리스트 [[101,102,103], [104,105,106], [107,108,109], [110,111,112]]에 대하여, 해당 리스트에 stream() 메소드를 적용한다. 따라서 결과는 [101,102,103]-[104,105,106]-[107,108,109]-[110,111,112] 형태의 1차원 리스트의 집합(데이터 파이프라인)이 형성된다.
- 두개의 스트림을 하나의 스트림으로 평탄화한다. 따라서 결과는 [1,2,3]-[4,5,6]-[7,8,9]-[10,11,12]-[101,102,103]-[104,105,106]-[107,108,109]-[110,111,112] 형태의 1차원 리스트의 집합(데이터 파이프라인)이 형성된다.
toList 과정
- 주어진 스트림 [1,2,3]-[4,5,6]-[7,8,9]-[10,11,12]-[101,102,103]-[104,105,106]-[107,108,109]-[110,111,112]을 하나의 리스트로 합친다.
따라서 결과는 아래와 같이 리스트 [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12], [101, 102, 103], [104, 105, 106], [107, 108, 109], [110, 111, 112]]가 반환되게 됩니다.
예제 3: 중첩 Optional 벗기기
이번에는 Optional의 flatMap에 대해 분석해봅시다.
Optional의 flatMap은 다음과 같은 순서로 동작합니다.
Optional
이 값을 가지고 있을 때 (isPresent() == true
)
Optional
내부의 값t
를 꺼냅니다.- 이 값
t
를mapper
함수에 인자로 전달하여 실행합니다. mapper
함수는Optional<U>
타입의 결과를 반환합니다.flatMap
은 이mapper
함수가 반환한Optional<U>
를 그대로 최종 결과로 반환합니다.
Optional
이 비어있을 때 (isPresent() == false
)
mapper
함수는 실행되지 않습니다.flatMap
은 즉시Optional.empty()
를 반환합니다.
- 먼저 user를 감싼 optional을 제거합니다.
- 해당 user에 대해 매핑 함수를 적용합니다.(만약 비어있다면 매핑을 수행하지 않습니다.)
- 해당 매핑함수를 수행한 결과를 그대로 반환합니다.
따라서 결과는 다음과 같습니다.
flatMap은 툭하면 CompletableFuture, Flux, Mono로 감싸지는 비동기 중첩 호출에 사용 시에 적합합니다.
Optional
Optional은 자바에서 null 타입 안정성을 위해 설계된 새로운 모델입니다.
Optional을 사용할 때 주의할 점에 대해 조금 더 깊게 알아보도록 합시다.
Optional의 사용 방식
Optional은 값이 없을 수 있다는 것을 클라이언트에게 편리하게 알리기 위한 도구입니다.
일반적으로 사전조건은 클라이언트가 지켜야 할 규칙이고, 사후조건은 개발자가 지켜야 할 규칙으로 많이 표현됩니다.
만약, 사전조건에 “클라이언트는 null일 수 있는 값을 던져야 한다” 라는 조건이 있다면, 이는 올바른 계약이라고 할 수 있을까요?
따라서, Optional은 메소드의 반환 타입으로만 사용해야 합니다.
어떻게 설계해야 하는가?
그렇다면, Optional은 어떻게 설계해야 할까요?
저는 클라이언트 입장에서 고려하는 것을 추천드립니다.항상 Optional을 반환받는 클라이언트의 입장을 고려해서 선택합시다.
다음 세가지 조건을 고려해봅시다.
이 로직은 null 을 반환할 수 있는가?
null 이 가능하다면, 호출하는 사람 입장에서 '값이 없을 수도 있다'는 사실을 명시적으로 인지할 필요가 있는가?
null 이 적절하지 않고, 예외를 던지는 게 책임 관점에서 더 적합하진 않은가?
orElse() vs. orElseGet()
이 둘은 정확히 어떠한 경우에 사용하게 되는걸까요?
- orElse(T other)
- orElseGet(Supplier<? extends T> supplier)
즉시 평가 vs 지연 평가
런타임에서 자바 인터프리터가 해당 코드를 읽었을 때 할 동작을 예상해봅시다.
- orElse(T other)
- 일단 T 객체를 만들어둔다.
- 이 객체가 사용될지 안될지는 모른다.
- orElseGet(Supplier<? extends T> supplier)
- 일단 해당 람다를 만들어둔다.
- 해당 람다가 사용될지 안될지는 모른다.
- 적어도, 이 객체가 실제로 생성되진 않았다.
따라서, orElseGet()의 경우 조금 장황하지만 성능 상의 이점이 있기 때문에, 객체 생성에 큰 오버헤드가 드는 로직에 사용 시 미리 객체를 생성하게 하지 않음으로써 성능 최적화를 가능하게 합니다.
이제부턴 조금 심화된? 최적화를 위한 몇가지 기능들을 알아봅시다.
기본형 특화 람다/스트림
제네릭은 원시 타입을 지원하지 않습니다.
따라서 불필요한 오토박싱/언박싱이 발생하게 되는데, 이 오버헤드를 제거하기 위해, 파라미터/리턴값을 기본형으로 설정해둔 함수형 인터페이스입니다.
배치 처리 등에서 수백만 건, 수천만 건의 데이터를 람다/스트림으로 다룰 때 사용합시다.
기본형 특화 기능에는 다음과 같은 세가지 종류가 있습니다.
- 기본형 특화 함수형 인터페이스
- IntToLongFunction
- 기본형 특화 스트림
- IntStream
- 기본형 특화 스트림의 경우, 성능 상의 이점보다 range(), rangeClosed()와 같은 메소드의 편의성때문에 많이 쓰입니다.
참고) 기본형 특화 Optional은 잘 쓰이지 않습니다.
Optinal은 단건 데이터를 담기 위한 구조이고, 애초에 기본형 대규모 데이터를 처리하는 구조가 아니라는 걸 기억합시다.
그 외 주요 기능
컬렉터
Collector
최종 연산을 위한 수집기 인터페이스로, 수집기의 기본 형태를 정의합니다.
Collector
인터페이스는 다음과 같은 네 가지 주요 기능을 명세합니다.
- Supplier: 새로운 결과 컨테이너를 생성합니다 (예:
ArrayList::new
). - Accumulator: 요소를 결과 컨테이너에 누적합니다 (예:
(list, element) -> list.add(element)
). - Combiner: 병렬 처리 시 여러 부분 결과를 하나로 병합합니다 (예:
(list1, list2) -> { list1.addAll(list2); return list1; }
). - Finisher: 최종 결과를 변환합니다 (선택 사항).
Collectors
정적 메소드를 통해 손쉽게 컬렉터를 획득하기 위한 클래스입니다.
일반적으로 Collector를 직접 구현하기보단, 해당 Collectors 메소드에 존재하는 메소드를 편히 사용하게 됩니다.
다운 스트림 컬렉터
- SQL의 서브쿼리같이 데이터를 추려서 사용하고 싶을 때 사용합니다.
참고로 SQL도 선언형 프로그래밍 방식 중 하나입니다.
무한 스트림 생성
Stream.generate(Math::random)
- supplier 를 사용한 무한 스트림을 생성합니다.
- 해당 스트림 사용 시, .
limit(n)
메소드로 사용 스트림 갯수를 제한해야 합니다.
함수 합성
a.compose(b)
- b 뒤에 a를 수행하는 새로운 함수 생성
a.andThen(b)
- a 뒤에 b를 수행하는 새로운 함수 생성
- 함수도 일급 시민으로 보는 함수형 프로그래밍에 사용하는 방식입니다.
출처: 김영한 자바 고급 3편 - 람다, 스트림, 함수형 프로그래밍
김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런
김영한 | , 국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만 명 돌
www.inflearn.com