본문 바로가기
  • 개발하는 곰돌이
Programming Language/Kotlin & Java

[Java/Kotlin] 자바 Stream을 통해 리스트의 요소를 특정 key 기준으로 grouping하여 다른 객체로 합치기 및 코틀린으로 변경해보기

by 개발하는 곰돌이 2023. 2. 9.

목차

    Stream이란?

    자바의 Stream은 자바 8버전에 새로 추가된 기능으로, 컬렉션 객체에서 요소들을 순회하면서 람다 함수를 통해 이 요소들을 가공하거나 요소의 특정 필드를 추출, 또는 컬렉션 객체의 요소들을 토대로 한 새로운 객체를 생성하는 것 외에도 다양한 방법으로 활용할 수 있는 강력한 기능이다. 다양한 기능들이 있지만 map()filter()정도만 해도 다채롭게 사용할 수 있다.

     

    이 포스트에서는 업무 중 마주했던, 리스트의 요소들을 특정 key를 기준으로 한 새로운 객체로 병합하여 이 새로운 객체들의 리스트로 변환한 내용과 스트림을 통해 이 코드들을 개선해나간 내용, 나아가서 이렇게 개선된 코드를 코틀린으로 표현하면 어떻게 되는지에 대해 정리할 것이다.


    문제의 시작

    DB에 위와 같은 형태의 데이터가 있었다. 문제는 이러한 형태의 데이터를 날짜별로 하나로 묶은 객체의 리스트로 반환해줘야 했다. 대충 아래와 같은 형태로 데이터를 반환하고 싶었다.

    [
        {
            "date": "2023-01-01",
            "고길동": {
                "count": 54,
                "amount": 756
            },
            "둘리": {
                "count": 13,
                "amount": 182
            },
            "마이콜": {
                "count": 101,
                "amount": 1414
            }
        },
        {
            "date": "2023-01-02",
            "도우너": {
                "count": 2,
                "amount": 28
            },
            "둘리": {
                "count": 19,
                "amount": 266
            },
            "희동이": {
                "count": 10,
                "amount": 140
            }
        },
        ...
        ...
        ...
    ]

    첫번째 코드

    계속 고민을 하다가 내린 결론은 일단 DB에서 값을 모두 리스트로 받아와서 가공하는 것이었다. 그래서 다음과 같은 방법으로 접근해봤다.

    1. DB에서 받아온 리스트에서 날짜만 별도로 추출한다.
    2. 날짜를 기준으로 for문을 돌리면서 해당 날짜에 해당하는 데이터만 추출한다.
    3. 사용자의 이름을 key로 하고, count와 amount를 필드로 갖는 객체를 value로 하는 Map을 생성한다.
    4. 2번에서 추출된 데이터의 리스트를 순회하면서 맵에 데이터를 담는다.
    5. 결과 리스트에 날짜와 3번의 Map을 필드로 갖는 객체를 추가한다.
    6. 모든 날짜에 대해 2번부터 5번까지 반복

    그 결과 아래의 첫번째 코드가 탄생했다.

    public List<UsageByDate> getUsageByDateList(String month) {
        List<UsageByDate> result = new ArrayList<>();
    
        List<UserUsage> usageList = usageMapper.findByDate(month);
        Set<String> dateSet = usageList.stream()
                .map(UserUsage::getDate)
                .collect(Collectors.toCollection(TreeSet::new));
        for (String date : dateSet) {
            List<UserUsage> dayUsages = usageList.stream()
                    .filter(it -> it.getDate().equals(date))
                    .collect(Collectors.toList());
            Map<String, Usage> usages = new HashMap<>();
            for (UserUsage userUsage : dayUsages) {
                usages.put(userUsage.getUser(), Usage.from(userUsage));
            }
            result.add(new UsageByDate(date, usages));
        }
        
        return result;
    }

    5번째 줄에서 map()을 이용하여 usageList에 포함된 각 객체의 날짜만 추출했다. 그리고 날짜의 중복을 방지하고 순서대로 출력하기 위해 TreeSet으로 반환하였다.

    Stream의 map() 메소드는 컬렉션 객체를 파라미터로 하는 람다 함수를 실행하여 람다 함수의 결과가 담긴 스트림을 반환한다. 위 코드에서는 메소드 레퍼런스가 사용되었는데, 람다 함수로 나타내면 (it -> it.getDate())가 된다.

     

    7번째 줄에선 filter()를 통해 usageList에서 날짜가 date와 일치하는 요소들만 추출한 리스트를 반환하였다. 이후 추가 연산을 통해 목표로 한 객체들을 담는 리스트를 반환하는 메소드를 만들었다.

    Stream의 filter() 메소드는 컬렉션 객체를 파라미터로 하고 boolean을 반환하는 람다 함수를 실행하여 람다 함수의 결과가 true인 객체들만 담긴 스트림을 반환한다. 위 코드에서는 UserUsage 객체의 date가 dateSet의 date와 같은 객체들만 남은 스트림을 반환하게 된다.

    두번째 코드

    그런데 첫번째 코드는 for문이 이중으로 들어가서 뭔가 지저분한 느낌이 들었다. 그래서 팀원과 이야기를 해본 결과 첫번째 코드의 7번째 줄의 스트림을 조금 손보면 for문을 줄일 수 있겠다는 결론을 내리게 되었다. 기존에

    filter()의 결과를 리스트로 반환하고 이 리스트를 순회하면서 Map에 데이터를 삽입하던 것을 아예 Map을 반환하면 되지 않을까 하는 의견이 있었고, 그 결과 아래의 두번째 코드가 탄생했다.

    public List<UsageByDate> getUsageByDateList(String month) {
        List<UsageByDate> result = new ArrayList<>();
    
        List<UserUsage> usageList = usageMapper.findByDate(month);
        Set<String> dateSet = usageList.stream()
                .map(UserUsage::getDate)
                .collect(Collectors.toCollection(TreeSet::new));
        for (String date : dateSet) {
            Map<String, Usage> usages = usageList.stream()
                    .filter(it -> it.getDate().equals(date))
                    .collect(Collectors.toMap(it -> it.getUser(), Usage::from));
            
            result.add(new UsageByDate(date, usages));
        }
        
        return result;
    }

    기존 첫번째 코드에서 filter()에서 걸러진 리스트를 반환하던 것이 걸러진 객체들을 토대로 생성된 Map을 반환하게 되었다. 그러면서 이중으로 있던 for문도 하나로 줄어드는 효과도 얻었다.


    최종 코드

    이렇게 for문을 하나로 줄였지만 이조차도 뭔가 찜찜해서 더 나은 방법이 없을까 생각하게 되었다. 이미 스트림으로

    for문 하나를 없애는 결과를 얻다보니 스트림을 잘 이용하면 for문 없이도 원하는 결과를 얻을 수 있을 것 같았기 때문이다. 그러다가 최종적으로는 스트림 안에서 다시 스트림을 이용해 보기로 했다. 아래의 코드는 for문 없이 스트림만으로 위의 두 코드와 동일한 동작을 수행하는 코드다.

    public List<UsageByDate> getUsageByDateList(String month) {
        List<UserUsage> usageList = usageMapper.findByDate(month);
        return usageList.stream()
                .map(UserUsage::getDate)
                .distinct()
                .map(date -> {
                    Map<String, Usage> usages = usageList.stream()
                    .filter(it -> it.getDate().equals(date))
                    .collect(Collectors.toMap(it -> it.getUser(), Usage::from));
            
                    return new UsageByDate(date, usages);
                })
                .collect(Collectors.toList());
    }

    리스트에 존재하는 객체들의 날짜를 TreeSet으로 반환하던 것을 날짜만 추출한 스트림에 distinct()로 중복을 제거하는 것으로 바뀌었다. 그리고 여기에 다시 한번 map()을 사용하여 람다 함수의 내부에 for문에 있던 내용이 들어가서 리스트에 삽입하던 UsageByDate 객체의 스트림을 생성하게 되었고, 이 스트림을 리스트로 반환하게 되었다. 결과적으로

    for문을 이중으로 사용하던 메소드가 스트림 한줄로 바뀐 코드를 얻었다.


    위의 세 코드를 Kotlin으로 바꿔본다면?

    코틀린에는 스트림이 존재하지 않지만(물론 자바의 스트림을 사용할 수는 있다.) 표준 라이브러리에서 자바의 스트림에서 사용하는 각종 메소드와 유사한 메소드 뿐만 아니라 자바의 스트림에는 없는 메소드까지 제공한다. 이렇게 코틀린에서 스트림의 역할을 하는 함수들은 내부적으로 스트림을 사용하지 않기 때문에 엄밀히 말하면 스트림 함수가 아니긴 하지만 유사한 동작을 수행한다.

     

    이 스트림 함수들은 모두 확장 함수이며 결과를 리스트로 반환하게 되는데, 코틀린에서는 컬렉션 객체들끼리의 변환이 매우 쉽기 때문에 문제가 되진 않는다.

     

    첫번째 코드

    fun getUsageByDateList(String month): List<UsageByDate> {
        val result = ArrayList<UsageByDate>()
        
        val usageList = usageMapper.findByDate(month)
        val dateSet = usageList.map(UserUsage::date).toSortedSet()
        for (date in dateSet) {
            val dayUsages = usageList.filter { it.date == date }
            val usages = HashMap<String, Usage>()
            for (userUsage in dayUsages) {
                usages[userUsage.user] = Usage.from(dto)
            }
            result.add(UsageByDate(date, usages))
        }
    
        return result
    }

     

    dateSetdateUsages를 추출할 때 스트림을 사용하지 않고 usageList에 직접 map()filter() 사용하는것을 볼 수 있다. 또한 TreeSet 대신 toSortedSet()을 통해 리스트를 바로 정렬된 Set으로 변환하였다.

     

    두번째 코드

    fun getUsageByDateList(String month): List<UsageByDate> {
        val result = ArrayList<UsageByDate>()
        
        val usageList = usageMapper.findByDate(month)
        val dateSet = usageList.map(UserUsage::date).toSortedSet()
        for (date in dateSet) {
            val usages = usageList.filter { it.date == date }
                .associate { it.user to Usage.from(it) }
            
            result.add(UsageByDate(date, usages))
        }
    
        return result
    }

    filter()로 걸러낸 내용을 Map으로 만들 때 collect(Collectors.toMap()) 대신 associate()를 사용한 것을 볼 수 있다.

     

    최종 코드

    fun getUsageByDateList(String month): List<UsageByDate> {
        val usageList = usageMapper.findByDate(month)
        return usageList.map(UserUsage::date)
            .distinct()
            .map { date ->
                val usages = usageList.filter { it.date == date }
                    .associate { it.user to Usage.from(it) }
            
                UsageByDate(date, usages)
            }
    }

    코틀린의 람다 함수는 람다 블록 내부에 직접적으로 함수처럼 코드를 작성할 수 있으며 람다의 마지막 줄의 객체는 return을 사용하지 않고 반환된다는 점, map()이 곧바로 리스트를 반환한다는 점으로 인해 코드가 상당히 짧아졌다.


    최종 코드를 더 개선한 Kotlin 코드

    사실 코틀린에는 리스트의 요소들을 특정 키를 기준으로 묶어주는 groupBy()라는 표준 라이브러리 함수가 존재한다.

    groupBy() 역시 람다 함수를 파라미터로 받아서 동작하는 확장 함수인데, 람다 함수의 반환값을 key로 갖고, key가 동일한 요소들이 포함된 리스트를 value로 갖는 Map을 반환하는 함수이다.

     

    최종 코드도 사실 두번째 map() 내부에 두 줄의 동작이 들어가서 그다지 만족스럽지 않은데, 이 groupBy()를 사용하면 더욱 간단하게 줄일 수 있다.

     

    최종의 최종 코틀린 코드

    fun getUsageByDateList(String month) = usageMapper.findByDate(month)
            .groupBy { it.date }
            .map { (date, usages) -> UsageByDate(date, usages.associate { it.user to Usage.from(it) }) }

    장황했던 코드가 모두 사라지고 한 줄의 코드로 모든 것을 표현할 수 있게 되었다. 그 결과 return도 불필요해져서 블록을 해제하고 등호로 반환값을 표현하게 되었다.

     

    여기서 groupBy()를 통해 날짜를 key로, 해당 날짜에 해당하는 객체들을 value로 갖는 Map을 생성했다. 이후, map()을 통해 key인 날짜와 value인 사용량 리스트를 사용자별로 구분한 Map을 프로퍼티로 갖는 UsageByDate 객체의 리스트를 반환하게 된다.


    자바 최종 코드를 최종의 최종 코틀린 코드처럼 리팩토링 해본다면?

    여기까지 하고 보니 자바의 최종 코드도 map() 내부에 있는 두 줄의 동작이 마음에 들지 않았다. 그래서 비슷한 방식으로 리팩토링 해보고자 했다.

    public List<UsageByDate> getUsageByDateList(String month) {
        return usageMapper.findByDate(month)
                .stream()
                .collect(Collectors.groupingBy(UserUsage::getDate, LinkedHashMap::new, Collectors.toList()))
                .entrySet()
                .stream()
                .map(entry -> new UsageByDate(
                    entry.getKey(),
                    entry.getValue().stream().collect(
                        Collectors.toMap(it -> it.getUser(), Usage::from)
                    )
                ))
                .collect(Collectors.toList());
    }

    코틀린 코드와 같이 한줄로 표현이 되긴 했는데 자바의 한계 때문인지 뭔가 더 복잡해진 느낌이다. 자바에서는 Map에 스트림을 사용할 수 없으니 엔트리Set으로 변환해서 다시 한 번 스트림을 생성해야 하고, groupBy()의 기본 반환타입이LinkedHashMap이어서 리스트의 날짜 순서대로 알아서 정렬되던 코틀린과 달리 자바의 groupingBy()는 LinkedHashMap으로 반환하기 위해서는 추가적인 파라미터가 필요하다. 생각만큼 깔끔한 코드가 되진 않은 것 같고 조금 애매한 느낌이 든다.


    결론

    기본적으로 자바의 스트림을 사용하면 리스트 내의 객체들에서 공통된 key값을 매개로 하여 하나의 새로운 객체로 병합하는 작업을 수월하게 할 수 있다. 또한, 스트림을 잘 활용하면 이러한 일련의 동작을 반복문 없이 표현할 수 있기 때문에 가독성을 높일 수도 있다.

     

    물론 두번째 코드와 최종 코드 중 어느것이 더 이해하기 쉬운지는 개인에 따라 다르겠지만 코드가 간결해진다는 점에서 충분히 도전해볼 가치가 있다고 생각한다.

     

    다만, 자바의 스트림은 for문에 비해 성능이 떨어진다는 단점이 있다고 한다. 물론 이는 원시 타입 배열을 다루는 것이 아니라면 큰 차이가 나지 않고, 함수의 비용이 증가할수록 for문과 스트림의 속도 차이는 점점 줄어들기 때문에 가독성과 생산성을 고려하여 두 방법을 잘 조율하면서 사용하는게 좋을 것이다.

     

    코틀린의 스트림 함수(실제론 스트림을 쓰지 않지만)들은 내부적으로 for문을 사용하기 때문에 자바와 달리 스트림으로 인한 속도 저하를 걱정할 필요가 없으니 편한 방법을 사용하면 될 것 같다.


    참조 링크

     

    Java Stream API는 왜 for-loop보다 느릴까?

    The Korean Commentary on ‘The Performance Model of Streams in Java 8" by Angelika Langer

    sigridjin.medium.com

     

    댓글