개발놀이터

Java8 StreamAPI 본문

Java

Java8 StreamAPI

마늘냄새폴폴 2024. 4. 28. 00:29

StreamAPI는 제가 자주 사용하는 문법 중 하나인데, 정작 뭐가 어떻게 동작하는지는 잘 모르고 썼던 것 같습니다. 그래서 정리하면서 사용법까지 훑어보도록 하겠습니다. 

 

StreamAPI란?

자바8부터 지원하게 된 StreamAPI는 Collection을 함수형 인터페이스를 이용해 함수형 프로그래밍을 지원하기위한 목적을 가지고 나왔습니다. 

 

때문에 우리는 Collection 에 담겨있는 객체들을 이용해 for문을 돌리고 if문을 사용하는 것을 StreamAPI로 깔끔하게 줄일 수 있게 되었습니다. 

 

StreamAPI는 람다식을 이용해 함수형 프로그래밍을 지원합니다. 대부분 Collection에 stream() 을 붙여서 사용합니다.

 

예시 코드와 함께 보시죠. 

 

        List<Bucket> findBucket = bucketRepository.findByMemberId(findMember.getId());

        for (Bucket bucket : findBucket) {

        }
        
        ------ 위와 아래가 똑같다 ------
        
        bucketRepository.findByMemberId(findMember.getId()).stream();

 

stream()을 붙여주면 사실 위와 아래 코드가 똑같습니다. 실제로 내부적으로 for문을 돌리는 것이기 때문에 똑같은데! 핵심은 가독성입니다. 

 

어? 사실 위가 가독성이 안좋지는 않은데..? 

 

이런 코드는 어떨까요? 실제 제 프로젝트 코드 일부를 보여드리겠습니다. 

 

위의 코드는 장바구니에 담고 결제를 진행하려고 할 때 장바구니에 담긴 상품의 사이즈 (옷이라면 100, 105 / 신발이라면 270, 280) 와 실제 데이터베이스에 있는 상품의 사이즈를 비교한 다음

 

장바구니에 담은 개수 (ex. 2개) 와 데이터베이스에 남아있는 상품의 개수 (ex. 1개) 를 비교해서 장바구니에 담은 상품의 개수가 데이터베이스에 남아있는 상품의 개수보다 더 많은 경우 (즉, 실제 상품은 1개인데 2개를 주문한 경우) 재고가 남아있지 않는다는 메시지를 띄우는 코드입니다. 

 

이건 제가 만든거니까... 이해하지만 이걸 처음본다면 어떨까요? 저 코드는 들여쓰기가 세번이나 되어있습니다. 제 코드 컨벤션상 들여쓰기 세번이 되었으면 리팩토링 해야한다 주의입니다. 

 

이 코드를 StreamAPI를 이용해 읽기 쉽게 바꾸었습니다. 결과가 궁금하다면 아래의 링크를 참고해주세요!

 

https://coding-review.tistory.com/491

 

for 문과 여러개의 if 문이 중첩된 코드 StreamAPI로 정리하기

코드를 리팩토링 하는 과정에서 Collection에 담긴 내용을 if문 두개로 중첩해서 걸러내는 로직이 있었습니다.   로직을 간단하게 설명하자면 사용자가 장바구니에 담은 상품의 사이즈와 데이터

coding-review.tistory.com

 

StreamAPI는 크게 네가지 유형이 있습니다. 

 

  • Consumer 계열 : 매개값이 있고, 반환값은 없다.
  • Supplier 계열 : 매개값은 없고, 반환값은 있다. 
  • Function 계열 : 매개값이 있고, 반환값도 있다. 
  • Predicate 계열 : 매개값은 있고, 반환타입은 boolean이다. 

여기서 매개값이란 람다식을 시작할 때 선언해주어야 하는 변수이고, 반환값이란 말 그대로 함수가 void인지 반환값이 있는지 여부입니다. 매개값과 반환값에 대해선 뒤에 자세히 서술하도록 하겠습니다. 

 

StreamAPI중 제가 자주 사용했던 것을 위주로 정리해보겠습니다. 

 

filter (Predicate 계열)

사실 이친구만 잘써도 코드의 대부분이 줄어듭니다. if 문을 대신하는 인터페이스입니다. 

 

List<Bucket> findBucketByMemberId = bucketRepository.findByMemberId(1L);

findBucketBymemberId.stream().filter((bucket) -> bucket.getName().equals("상의"));

 

filter() 를 사용하기 위해선 매개값이 있어야합니다. 위의 코드에서 매개값은 bucket입니다. 이 코드의 뜻은 아래와 같습니다. 

 

List<Bucket> findBucketByMemberId = bucketRepository.findByMemberId(1L);

for (Bucket bucket : findBucketByMemberId) {
	if (findBucket.getName().equals("상의")) {
    	
    }
}

 

filter안에 들어있는 bucket이라는 매개변수는 Collection에서 for문을 돌릴 때 필요한 변수입니다. 

 

예제코드에서 알아보기쉽게 변수명을 일치시켰으니 비교하시면서 보면 됩니다. 

 

filter는 Predicate 계열인만큼 매개값 (예시에선 bucket) 이 필요하고, 반환값으로 boolean을 반환하기 때문에 true인 것들만 다음 계산으로 넘어가게 되어있습니다. 

 

예를 들어

 

        List<Bucket> findBucketByMemberId = bucketRepository.findByMemberId(1L);

        findBucketBymemberId.stream()
                .filter((bucket) -> bucket.getName().equals("상의"))
                .filter((bucket) -> bucket.getSize().equals("105"));

 

이렇게 되면 아래의 코드와 같은겁니다. 

 

        List<Bucket> findBucketByMemberId = bucketRepository.findByMemberId(1L);

        for (Bucket bucket : findBucketByMemberId) {
            if (findBucket.getName().equals("상의")) {
                if (findBucket.getSize().equals("105")) {

                }
            }
        }

 

주로 for문과 if문이 동시에 있는 경우 사용하면 좋습니다. 

 

 

map (Function 계열)

map은 자바스크립트를 해보셨다면 익숙하신 바로 그것입니다. 다른 타입으로의 변환을 주로 담당합니다. 

 

예를 들어 자바에선 JPA를 사용한다면 entity를 DTO로 변환하고 싶은 경우가 있습니다. 

 

이렇게 변환하는 이유는 entity에는 너무 많은 정보가 들어있습니다. 예를 들어 회원 정보 entity라고 한다면 회원 정보에는 이름, 나이, 성별, 로그인 아이디, 로그인 패스워드, 가입날짜, 생년월일, 주소 등등 다양한 것이 있는데 

 

이걸 프론트 단에 결괏값으로 던져주려면 OverFetching이 일어날 수 있습니다. OverFetching에 대해서는 해당 포스팅에선 다루지 않겠습니다. 쉽게 말해서 필요한 정보보다 더 많은 정보를 반환한다는 것입니다. 같은 느낌으로 필요한 정보보다 적은 정보를 반환하는 UnderFetching도 있습니다. 

 

프론트 단에서 필요한 정보는 이름, 나이, 성별인데 entity에는 너무 많은 정보가 있습니다. 때문에 우리는 이름, 나이, 성별만을 필드값으로 가지고 있는 DTO를 만들어서 이 DTO로 변환한 다음 프론트에 던져줘야할겁니다. 

 

만약 map을 안쓴다면 어떻게 변환해야할까요? 

 

@Data
public class MemberDto {

    private String name;
    private String age;
    private String birth;
}

 

        List<Member> findByName = memberRepository.findByName("폴폴");
        List<MemberDto> memberDto = new ArrayList<>();

        for (Member member : findByName) {
            MemberDto dto = new MemberDto();
            dto.setName(member.getName());
            dto.setAge(member.getAge());
            dto.setBirth(member.getBirth());

            memberDto.add(dto);
        }
        
        return ResponseEntity.ok(memberDto);

 

이렇게 해줘야합니다. 

 

하지만 map을 사용한다면?

 

        List<MemberDto> memberDto = memberRepository.findByName("폴폴").stream()
                .map((member) -> new MemberDto(member.getName(), member.getAge(), member.getBirth()));
                .collect(Collectors.toList());
        
        return ResponseEntity.ok(memberDto);

 

정말 간단해졌습니다. 

 

 

collect (Supplier 계열)

보통 StreamAPI에 마지막에 실행되는 연산으로 보통 filter나 map을 사용하게 되면 리턴값이 Stream<> 이렇게 되어있습니다. 이걸 우리가 사용하기 편한 Collection으로 다시 바꾸기 위해서 사용합니다. 

 

Supplier 계열인 만큼 filter나 map처럼 매개값이 있어야하는 건 아니고 반환값은 있습니다. 

 

사용법은 위의 map 예시처럼 collect(Collectors.toList()); 이렇게 사용해주면 됩니다. 굉장히 간단하죠?

 

 

번외

 

findAny 

얘는 아무런 계열도 아니고 반환값은 Optional입니다. 이친구는 보통 저는 filter 다음에 쓰는 편입니다. 

 

사용법은 이런식입니다. 

 

        List<Bucket> findBucketByMemberId = bucketRepository.findByMemberId(1L);

        findBucketBymemberId.stream()
                .filter((bucket) -> bucket.getName().equals("상의"))
                .filter((bucket) -> bucket.getSize().equals("105"))
                .findAny().orElseGet(Bucket::new);

 

findAny() 는 filter로 걸러진 것들 중에 하나라도 있으면 그 객체를 반환하는 것입니다. 

 

Optional로 반환되기 때문에 우리가 사용하기 위해서는 get(), orElse(), orElseGet(), orElseThrow(), or() 를 이용해서 조물조물 해줘야합니다. 

 

Optional에 대해서도 기회가되면 자세히 포스팅하도록 하겠습니다. 

 

ifPresent (Consumer 계열)

이친구는 Consumer 계열인데 왜 번외로 빠졌냐? 바로 Optional에서만 존재하는 API이기때문입니다. 

 

StreamAPI에선 Consumer 계열이 없어서 Consumer 계열을 설명하기위해 가져왔습니다. Consumer 계열답게 매개값이 있고, 반환값이 없는 계열입니다. 

 

보통 Optional에서 하나라도 존재하면 다음 연산을 진행하도록 설계할 수 있습니다. 

 

사용법은 다음과 같습니다. 

 

memberRepository.findByLogindId("ks3254").ifPresent((member) -> {
	throw new IllegalStateException("이미 존재하는 회원입니다. login id = " + member.getLoginId())
})

 

이 로직은 회원가입을 할 때 로그인 아이디의 중복검사를 하기 위해 사용하는 로직입니다. 

 

만약 ks3254라는 아이디가 있으면 예외를 터트려서 회원가입을 막는 로직이 되겠습니다. 물론 실제 사용할 때는 ks3254자리에 저렇게 하드코딩 하는 것이 아니라 중복검사를 하는 아이디값이어야겠습니다. 

 

번외2 (StreamAPI와 시간복잡도)

코드를 리팩토링 하면서 저게 과연 루프를 몇바퀴 도는걸까? 하는 궁금증이 생겼습니다. 

 

'설마 filter, map이런 함수 들어갈 때마다 루프를 돌겠어..?' 라는 생각을 막연히 가지고 있었습니다. 결론부터 말하자면 StreamAPI의 시간 복잡도는 O(n)입니다. 

 

StreamAPI에서 말 그대로 "stream" 즉, 흐름처럼 메서드를 타고 들어가는게 특징입니다. 예를 들어서

 

Collections.stream().filter().filter().map().findAny().orElseGet()

 

만약 이런 코드가 있다고 가정해볼까요? 

 

 

흐름도를 순서도로 볼까요? 예를 들어서 95는

 

  1. 0보다 큰가? yes
  2. . (dot) 이 포함되지 않았는가? yes
  3. 뒤에 점이라는 문자를 붙인다. 

이렇게 하나의 흐름이 생성되는 것입니다. 

 

첫번째 filter 에서 시간 복잡도는 O(n), 두번째 filter 에서 시간 복잡도는 O(n), 세번째 map 에서 시간 복잡도는 O(n) 즉, O(3n) 이기 때문에 O(n) 이 됩니다. 

 

마치며

StremaAPI는 제가 많이 사용하는 문법인데 정리한 내용이 자바 처음 공부할 때 아무것도 모르던 꼬꼬마 시절에 정리한 내용이라 부족한 것이 많아 다시 정리할겸 포스팅 해봤습니다. 

 

저도 StreamAPI를 익숙하게 사용하기위해 꽤 오랜 시간이 걸렸습니다. 처음엔 이해가 잘 되지 않지만 익숙해진다면 코드도 깔끔해지기 때문에 종종 사용하시면 될 것 같습니다. 

 

하지만 단점은 재사용이 불가능하기 때문에 단발적인 로직에서만 사용하시면 될 것 같습니다. 

 

 

출처

https://medium.com/javarevisited/java-8s-consumer-predicate-supplier-and-function-bbc609a29ff9

 

 

'Java' 카테고리의 다른 글

가상 스레드 (부제 : 자바의 미래)  (0) 2024.06.25
ConcurrentMap, ConcurrentHashMap  (0) 2023.02.18
상속과 super 그리고 super()  (0) 2022.09.15
final 키워드  (0) 2022.08.04
try-with-resouce  (0) 2022.08.04