Java 8의 Stream은 2014년에 공식적으로 등장해서 2023년인 현재 10년 째를 맞이하고 있다.
이전에도 Stream에 관한 글을 작성했었지만 이번엔 좀 더 실무적인 관점에서 설명을 해보려고 한다.
2024년 9월 추가 - 롬복의 @With를 사용하면 함수형 프로그래밍을 유지할 수 있다는 사실..!
대다수 놓치고 있는 사실
Stream을 한번이라도 써본 사람은 코드가 간결해진다는 장점은 알고 있을 것이다.
하지만 Stream을 간결하게만 사용할 뿐 쉽게 놓치고 있는 부분이 하나가 있다.
그건 바로 함수형 프로그래밍의 원칙이다.
이 원칙이 무엇인지 알기 위해 일단 Stream 공식 문서를 확인해보자.
Stream 공식 가이드 문서
공식 문서를 물론 다 읽으면 좋지만 대신 읽어보고 조금은 핵심이라고 파악한 문장들을 가져와봤다.
streams do not provide a means to directly access or manipulate their elements, and are instead concerned with declaratively describing their source and the computational operations which will be performed in aggregate on that source.
스트림은 요소에 직접적으로 접근하거나 조작하는 방법을 제공하지 않으며, 대신 원본 데이터 소스와 그 소스에 대해 수행될 계산 작업을 선언적으로 설명하는 데 관심이 있다.
Non-interference - preventing interference means ensuring that the data source is not modified at all during the execution of the stream pipeliduring
간섭 방지 - 간섭 방지는 데이터 소스가 스트림 파이프라인 실행 중에 전혀 수정되지 않도록 보장하는 것을 의미한다.
Stateless behaviors - The best approach is to avoid stateful behavioral parameters to stream operations entirely
무상태 동작 - 가장 좋은 접근 방식은 스트림 작업에 stateful한 매개변수를 완전히 피하는 것입니다.
(Stateless는 상태를 가지지 않는다는 것을 의미하며, 모든 연산이 현재 입력에만 의존하는 방식으로 동작한다는 뜻이다. 반대로 Stateful은 입력에만 의존하지 않고 내부에서도 조작을 한다는 의미가 된다.)
공식 문서에는 위와 같은 내용이 포함되어 있다.
Stream을 사용할 때 데이터의 조작을 하지 않는 것을 권장한다는 내용이다.
이 뜻은 곧 함수형 프로그래밍의 불변성과 순수성을 이야기 한다.
불변성 (Immutability): 불변성은 객체가 생성된 후에 내부 상태가 변경되지 않는 것을 의미합니다.
순수성 (Purity): 순수성은 함수의 결과가 오로지 입력에만 의존하고, 외부 상태를 변경하지 않는 것을 의미합니다.
위의 문장 외에도 공식 가이드 문서에는 여러번에 걸쳐 데이터 조작을 하지 말라는 불변성에 대한 내용은 여러번 언급된다.
다시 말해 우리가 사용하는 Stream에서는 불변성과 순수성을 지키는 코드로 만들어줘야 한다.
이러한 함수를 순수 함수(Pure Function)라 부른다.
사실 개발자는 이 원칙을 지키지 않아도 개발이 가능하긴 하지만 이 원칙을 지키면 읽기 쉽고 디자인적으로 더 좋은 코드를 만들 수 있게 된다.
그럼 이제 우리가 쉽게 이 가이드라인을 어기면서 작성할 수 있는 코드를 알아보자.
주의사항
사실 함수형 프로그래밍의 근간이 되는 기술은 Stream이 아니라 람다 함수다.
오히려 Stream은 람다 함수보다는 간결함에만 초점이 맞춰져있는 기능이다.
그럼에도 불구하고 Stream에서는 함수형 프로그래밍을 지향하자고 설명하고 있다.
반면에, Stream을 쓰지 않고 람다 함수만을 쓰는 기능은 오히려 함수형 프로그래밍을 지향하지 않는 경우도 있다.
위와 같은 케이스에 대해 아래 글들을 읽고 천천히 살펴보자.
잘못된 코드와 리팩토링
@Data
public class StreamDto {
private Long id;
private String name;
public StreamDto(Long id, String name) {
this.id = id;
this.name = name;
}
}
StreamDto streamDto1 = new StreamDto(1L, "철수");
StreamDto streamDto2 = new StreamDto(2L, "짱구");
StreamDto streamDto3 = new StreamDto(3L, "훈이");
ist<StreamDto> streamDtoList = new ArrayList<>();
streamDtoList.add(streamDto1);
streamDtoList.add(streamDto2);
streamDtoList.add(streamDto3);
streamDtoList.stream().map(streamDto -> {
streamDto.setName("유리");
return streamDto;
}).toList();
forEach를 사용해서 List를 내부적으로 순회하면서 streamDto의 name 속성을 전부 바꿔주는 코드이다.
이런 코드는 위에서 설명한 함수형 프로그래밍의 불변성과 순수성을 해치는 코드를 의미한다.
위의 코드는 내부적으로 dto를 순회하면서 수정하는 코드를 예시로 든 것이지만 당연히 외부에 선언된 변수도 수정을 해서는 안된다.
리팩토링 코드 1
for (StreamDto streamDto : streamDtoList) {
streamDto.setName("유리");
}
위 코드를 리팩토링 하려면 stream 기능을 이용하지 않고 향상된 for문을 이용해볼 수 있다.
리팩토링 코드 2
streamDtoList.forEach(streamDto -> {
streamDto.setName("유리");
});
일단 마찬가지로 stream().forEach()를 이용하지 않고 Collection.forEach()를 이용하면 된다.
Collection.forEach()는 Stream의 기능이 아니다. 오히려 Colleciton.forEach()는 위의 리팩토링 코드 1과 동일한 기능을 가지고 있다.
Collection의 forEach도 람다식을 쓰기 때문에 함수형 프로그래밍을 사용하는 것 같지만 Collection.forEach()는 내부적으로 Stream과는 다르게 Iterator가 돌아가기 때문에(hasNext() 메소드 동작) 병렬 프로그래밍에 안전하다고 본다.
그래서 일반적으로 내부에서 Setter 기능을 사용해도 안전한 것이다.
물론 람다식 자체만 놓고보면 함수형 프로그래밍의 원칙을 지키지 않는 코드라고도 볼 수 있다.
Stream은 병렬 프로그래밍을 위해 .parellelStream() 으로 명시적으로 기능이 존재하지만 Collection.forEach()는 그런 기능도 없기 때문에 오히려 더 안전하다고 보는 것이다.
리팩토링 코드 3
streamDtoList.stream().
map(streamDto -> {
StreamDto copiedStreamDto = new StreamDto(streamDto);
copiedStreamDto.setName("유리");
}).toList;
혹은 위와 같이 깊은 복사(Deep Copy)를 이용해서 수정하는 방법도 있다.
함수형 프로그래밍에서 깊은 복사는 상태 변경 없이 값을 조작하거나 새로운 값을 생성하기 위한 효과적인 방법 중 하나다.
위와 같은 방식이 메모리를 더 차지하지 않는가라는 의문이 생길 수도 있다.
위의 글을 참고하면 함수형 프로그래밍에서 사용하는 자료구조로 인해 메모리 낭비가 크지않다는 것을 알 수 있다.
Single Linked List를 사용하면 Copy할 때도 크게 문제가 없다.
그래서 보통 함수형 프로그래밍에서는 배열을 사용하진 않는다.
연결 리스트를 사용해서 아래와 같이 Prepend를 사용한다
Prepend(앞에 추가): Prepend 작업은 새로운 요소를 리스트의 맨 앞에 추가하는 것을 의미합니다.
이 작업은 상수 시간(시간 복잡도 O(1))에 수행됩니다.
새로운 요소를 추가할 때는 단순히 새로운 노드를 생성하고,
이 노드가 현재의 head 노드를 가리키도록 포인터를 조정하면 됩니다.
Append(뒤에 추가): Append 작업은 새로운 요소를 리스트의 맨 뒤에 추가하는 것을 의미합니다.
이 작업은 리스트의 끝까지 이동해야 하므로 리스트의 크기에 비례한 시간이 걸립니다.
따라서 시간 복잡도는 최악의 경우에는 선형 시간(O(n))이 됩니다.
주의사항
Jpa Entity를 사용할 때는 Copy 방식을 어설프게 사용하면 안된다.
무슨 말이냐면 Copy를 할거면 위 코드처럼 하나의 객체를 완벽하게 Copy 해야하는 방식으로 구현해야하는데, 그렇지 않고 일부만 Copy 하는 방식으로 작동하면 오작동 할 수 있다.
JPA는 id값을 수동으로 추가 후에 엔티티를 만들어서(준영속 객체 상태) save하는 경우에 save() 메소드 동작이 persist()가 아닌 merge()인 update 방식으로 동작하게 된다.
이 때 merge 특성상 하나의 컬럼이라도 놓치는 부분이 있다면 해당 컬럼은 null로 바뀌어버리는 문제가 존재하기 때문이다.
예를 들어 기존 객체를 클라이언트에서 넘어온 객체로 덮어쓰기 하려고 할 때 merge의 특성을 이용해서 기존 객체를 덮어쓰기 하지 않고 넘어온 객체를 그대로 save()해도 동작은 잘 되지만 코드 자체가 추후에 위험해질 수 있다는 뜻이다.
리팩토링 코드 4
List<StreamDtoList> streamDtoList = new CopyOnWriteArrayList<>();
streamDtoList.stream().forEach(streamDto -> streamDto.setName("유리"));
List가 동시성을 위해 설계된 경우에 한해서 setter를 사용해도 된다.
리팩토링 코드 5
streamDtoList.stream().forEach(streamDto -> { streamDto.setName("수진"); });
stream()을 다시 그대로 이용한 방식이다.
A small number of stream operations, such as forEach() and peek(), can operate only via side-effects; these should be used with care.
forEach() 와 peek() 같은 일부 스트림 작업은 부작용(side-effect)을 통해서만 동작할 수 있습니다. 이러한 작업은 주의하여 사용해야 합니다.
공식 문서에는 위와 같은 내용이 있다.
forEach는 아예 부작용이 없으면 쓸모가 없는 메소드라는 뜻이다.
그래서 이 방법은 유일하게 논쟁의 여지가 존재하는 방식이다.
공식 가이드 문서에서는 Stream의 forEach 메소드의 설명은 아래와 같다.
“The behavior of this operation is explicitly nondeterministic”
해당 메소드 이용 방법에 대해서 비결정적(nondeterministic)이라는 표현을 사용하는데, 이는 결과가 항상 동일하지는 않다라는 뜻이다.
즉, 함수형 프로그래밍을 지키지 않는 대표적인 메소드라고 볼 수 있다.
그럼에도 불구하고 바로 뒷문장에서는 다른 Stream 메소드와 같이 병렬 프로그래밍에 안전하지 않다라고 설명하고 있다.
그래서 결론적으로 Stream의 forEach는 함수형 프로그래밍을 지키지 않아도 되지만 불안전하다라는 뜻이다.
이거 쓰라는거야 말라는거야? 라고 생각이 들 수 밖에 없는 부분이다.
위 글에서는 “Whether we can call a function with side effects stateless, is debatable.” 이런 댓글도 있다.
Side Effect가 존재하는 함수를 사용할 것이냐 말 것이냐에 대한 부분은 논쟁의 여지가 있는 부분이다.
그 외에 댓글로는 공식 문서에 Pure Function이란 내용이 없는데 수정을 왜 못하냐라는 의견도 있는데 그에 대한 또 다른 반박으로는 거기다가 이론 개념을 왜 적냐면서 따지는 얘기도 있다.
향상된 for문(enhanced for loop) 주의해서 사용하기
The for-each loop hides the iterator, so you cannot call remove. Therefore, the for-each loop is not usable for filtering. Similarly it is not usable for loops where you need to replace elements in a list or array as you traverse it.
루프는 반복자(iterator)를 숨기기 때문에 remove를 호출할 수 없습니다. 따라서 for-each 루프는 필터링에 사용할 수 없습니다. 마찬가지로 for-each는 리스트나 배열을 순회하면서 요소를 대체해야 하는 경우에는 사용할 수 없습니다.
forEach에서는 remove나 요소를 대체하는 경우(덮어쓰는 경우)에 ConcurrentModificationException이 발생할 수 있다.
왜냐면 forEach는 컬렉션을 순차 탐색하는 용도로 쓰이며 내부적으로 Iterator를 사용하는데 도중에 한개가 remove 되어버리면 Iterator의 hasNext() 메소드가 올바르게 작동하지 않기 때문이다.
더불어 "it is not usable for loops where you need to replace elements"라는 문장이 개인적으로는 꽤 난해했었다. 마치 향상된 for문 안에서 setter 메소드와 같은 replace 기능을 쓰지 말하는건가 싶었기 때문이다.
근데 이건 그냥 너무 단순한 개념이었다. 왜냐면 이 또한 remove처럼 반복자(iterator)를 숨기기 때문에 list나 array는 특정 index를 바라봐야만 수정이 가능해서 index가 필요한 경우에는 향상된 for문을 쓰지 말라는 이야기다.
List<String> list = Arrays.asList("a", "b", "c")
int i=0;
for (String s : list) {
list.set(i, "hi");
i++;
}
System.out.println(list);
다시 말해 이렇게 쓰지 말라는 이야기다.
아래와 같이 쓰는건 상관이 없다.
for (StreamDto streamDto : streamDtoList) {
streamDto.setName("유리");
}
결국에 위와 같이 특정 index가 필요로 하지않는 방식(streamDtoList를 직접 수정하는게 아닌 방식)에서는 set, add을 사용함에 있어서는 문제가 없다.
그러니 오로지 향상된 for문은 삭제(remove)하는 경우에만 문제가 생기니 그 부분만 유의해서 코딩하면 된다.
그럼 삭제를 해야하는 경우는 어떻게 해야하는가?
Iterator를 사용하거나 for(int i=0 과 같은 일반 for문으로 사용해야 한다고 한다.
중요한 것은 디자인
사실 set을 하기 위해 stream 보다는 for문을 이용하자라든가 그런 결과보다도 우선적으로 생각해야 될 부분은 구조적으로 set을 하지 않는 방향으로 가는 것이 더 중요하다라는 의미다.
설계적으로 함수형 프로그래밍을 지향하고 코드의 일관된 처리를 하는 것이 개발에 있어 더 중요하다.
그래서?
한편으로는 람다식이 함수형 프로그래밍이란건 알겠는데 왜 원칙을 지켜줘야 하는데? 병렬 처리 안하면 되잖아? 라고 생각이 들 수 있다.
그도 그럴법한게 공식 문서에는 대부분 Side Effect를 병렬 처리에 문제가 발생한다라고 주로 설명하기 때문이다.
하지만 그 외에도 순수 함수를 지키지 않았을 때라든가 Stream 자체에 대한 총체적인 단점은 아래와 같다.
- 디버깅 어렵다(예외 발생 시 단순 for문 보다 로그가 더 길게 나온다)
- for문보다 stream이 더 느리다.
- 오히려 stream이 편해져서 stream만 사용하다보면 코드가 몇배는 더 더러워진다.
- 병렬 처리 어렵다(여러 개의 스레드나 프로세스가 동일한 객체의 상태를 변경하려고 할 때, 동기화 문제가 발생)
- 유지보수 어렵다.
- 예측 불가(팀원들이 순수 함수라고 판단하지 못하기 때문에 생산성 저하 및 테스트 코드 작성 어려움)
물론 디버깅, 유지보수도 나만하고 병렬 처리도 안할 계획이라면 상관 없지만 우리가 실무에 있어서는 누군가는 함수형 프로그래밍을 순수 함수로서 잘 인지하고 작성하고 있다면 협업에 굉장히 문제가 생길 수 있다.
그렇기에 협업 시에 서로에게 예측 가능한 코드를 제공하기 위해서라도 공식 문서에 나와있는 개념에 따라 올바르게 사용하는 것이 중요하다.
stream 메소드 의미적으로 올바르게 사용하기
사실 이건 stream의 너무 기본적인 이야기지만 간과하고 아무 메소드나 쓰는 경우가 있다.
- 복잡한 경우 foreach보단 stream을 쓴다
- 간단한 경우 stream보단 foreach를 쓴다
- Collection의 forEach는 수정용으로 써도된다.
- peek은 훔쳐보다라는 뜻의 중간 연산으로, 스트림의 각 요소를 처리하면서 스트림의 내용을 변경하지 않으며 주로 디버깅 목적으로 사용되거나, 각 요소를 확인하거나 로그를 남기는 데 활용된다. 즉, map과 같은 용도로 대체하면 안된다.
- map은 mapper의 줄임말이라고 볼 수도 있으며 말 그대로 매퍼 기능을 의미한다. 한번 더 강조하자면 map 메소드 내에서도 set등의 수정을 하면 안된다.
'☕ Java' 카테고리의 다른 글
[Java] Apache Commons Library에서의 올바른 압축 해제 방법 (0) | 2024.03.15 |
---|---|
[Kotlin] Companion Object (0) | 2024.02.02 |
커스텀 어노테이션과 리플렉션 (0) | 2023.07.30 |
[Kotlin] Optional vs Kotlin Nullable 문법 비교하기 (0) | 2023.06.21 |
java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter (0) | 2023.06.21 |