Spring Boot Stream 적용

최민길(Gale)·2023년 6월 29일
1

Spring Boot 적용기

목록 보기
33/46

안녕하세요 오늘은 기존 반복문 코드를 스트림으로 변환하는 작업에 대해 포스팅해보도록 하겠습니다.

우선 스트림에 대해 먼저 알아보겠습니다. 스트림이란 람다식과 같이 자바8부터 도입된 기능으로, 컬렉션이나 배열 등의 데이터 요소를 처리하는 연산을 지원합니다. 스트림 파이프라인을 이용해서 소스 스트림부터 시작해 종단 연산으로 끝나고, 그 사이에 여러 중간 연산들을 통해 원하는 로직을 처리합니다. 이 때 지연 평가되어 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않습니다. 쉽게 말하면 기존 컬렉션 또는 배열을 사용할 때 반복문을 이용해서 로직을 처리하는 방식을 스트림을 이용해서 가독성 좋게 처리합니다.

스트림은 코드를 간결하게 짤 수 있고 병렬 계산을 통해 성능 향상도 이끌어낼 수 있으나, 잘못 사용할 경우 오히려 읽기 어려워지고 성능 또한 하락할 수 있습니다. 따라서 스트림을 남발하지 않고 적당한 상황에서 사용하는 것을 권장합니다. 다음은 스트림을 사용하기 좋은 상황입니다.

  • 원소들의 시퀀스를 일관되게 변환
  • 원소들의 시퀀스를 필터링
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합
  • 원소들의 시퀀스를 컬렉션에 추가
  • 원소들의 시퀀스에서 특정 조건 만족하는 원소 찾기

쉽게 말하면 컬렉션 또는 배열 내의 원소들을 필터링하거나 조건에 맞게 변환하는 로직을 수행할 경우 스트림이 적합하나, 그 외의 경우 스트림을 사용하면 오히려 좋지 않은 상황이 발생합니다. 대표적으로 스트림 및 스트림 관련 객체에 건네지는 함수가 순수 함수가 아닐 때입니다. 순수 함수란 입력만이 결과에 영향을 주는 함수로, 다른 가변 상태를 참조하지 않으며 함수 스스로 상태를 변경하지도 않는 함수입니다. 즉 쉽게 말해, 외부의 함수 등의 영향을 받는 로직을 스트림 내부에 작성하면 단순 반복문을 사용할 때보다 오히려 코드가 복잡해질 수 있습니다.

또한 스트림의 병렬화의 경우 주의해서 적용해야 합니다. 스트림의 소스가 ArrayList, HashMap, HashSet 등의 인스턴스거나 배열, int 및 long 범위일 때, 즉 이웃한 원소들의 참조들이 메모리에 연속해서 저장되어 있는 특징인 참조 지역성이 높은 요소들을 사용했을 때 병렬화의 성능이 가장 좋습니다. 하지만 참조들이 가리키는 실제 객체자 메모리에서 서로 떨어져 있을 수 있기 때문에, 무턱대로 병렬화를 진행했다가 오히려 성능이 하락하는 이슈가 발생합니다. 따라서 스트림 병렬화는 계산도 올바로 수행하고 성능도 빨라질 것이라는 확신 없이는 되도록이면 지양하는 것이 좋습니다. 저는 아직 이런 확신을 얻지 못해 더 공부한 후 스트림 병렬화를 진행해보려고 합니다.

그럼 스트림을 적용한 예시를 살펴보겠습니다. 아래 예시는 스트림을 적용하기 전의 코드입니다. 코드를 보시면 반복문 내에서 만족하는 조건에 따라 StringBuilder에 문자열을 추가하고 있습니다. 이는 위에서 언급한 원소들의 시퀀스에서 특정 조건 만족하는 원소 찾기에 부합하는 상황으로 스트림을 적용하면 좋을 것임을 예상할 수 있습니다.

// 스트림 적용 전
        StringBuilder sb = new StringBuilder();
        sb.append(request.getMethod());
        sb.append("_");
        String[] arr = request.getRequestURI().toUpperCase().trim().split("/");
        for (int i=1;i< arr.length;i++){
            if(RegularExpressions.NUMBER.getPattern().matcher(arr[i]).matches()) sb.append("NUMBER");
            else sb.append(arr[i]);
            if(i<arr.length-1) sb.append("_");
        }
        return sb.toString();

아래는 스트림을 적용한 코드입니다. list.stream()이 소스 스트림, .collect()가 종단 연산이고 사이에 있는 로직들이 중간 연산들입니다. list의 경우 컬렉션이기 때문에 스트림을 다음과 같이 사용할 수 있으며, 로직 상 첫 번째 원소를 계산하지 않아야 하기 때문에 .skip(1)으로 건너뜁니다. 또한 .map을 이용해서 람다식으로 내부 로직을 처리하여 결과를 리턴합니다. 이렇게 얻어진 결과는 .collect에서 Collectors.joining을 이용해 스트림의 요소를 문자열로 연결하여 반환합니다. 즉 StringBuilder에서 _ 문자를 추가하는 로직을 쉽게 구현합니다.

// 스트림 적용 후
        List<String> list = new ArrayList<>(List.of(request.getRequestURI().toUpperCase().trim().split("/")));
        list.add(1,request.getMethod());
        return list.stream()
                .skip(1)
                .map(item -> {
                    if (RegularExpressions.NUMBER.getPattern().matcher(item).matches()) return "NUMBER";
                    else return item;
                })
                .collect(Collectors.joining("_", "", ""));

배열의 경우 컬렉션과 다른 방식으로 스트림을 생성합니다. 아래는 배열을 이용한 스트림 적용 전 예시입니다. parameterNames의 값과 args의 값과 비교해서 같을 경우 로직을 수행합니다. 이 경우 역시 원소들의 시퀀스에서 특정 조건 만족하는 원소 찾기 케이스에 부합하기 때문에 스트림을 효과적으로 사용할 수 있습니다.

// 스트림 적용 전
        final String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
        final List<Object> args = new ArrayList<>(Arrays.asList(joinPoint.getArgs()));
        for (int i=0; i<parameterNames.length; i++) {
            if (parameterNames[i].equals(paramName)) {
                if(c.isInstance(args.get(i))){
                    @SuppressWarnings("unchecked") T res = (T) args.get(i);
                    return res;
                }
                else throw new ValidationException("WRONG_TYPE_EXCEPTION");
            }
        }

        throw new ValidationException("SET_BODY_TO_MDC_EXCEPTION");

아래는 스트림 적용 후의 코드입니다. 이전 코드와 차이점은 컬렉션을 소스 스트림으로 사용할 때에는 list.stream()으로 사용할 수 있었던 반면 배열은 IntStream, LongStream 등 특정 값들의 시퀀스를 처리하기 위한 스트림 객체를 이용합니다. 아래의 코드는 위의 반복문의 i값의 스트림을 처리하기 위해 IntStream 객체를 호출하고 반복 범위를 설정합니다. 그 후 .filter를 통해 로직을 수행할 조건을 필터링한 후, .mapToObj를 이용해 로직을 수행하여 값을 리턴합니다.

아래 코드에서 크게 2가지를 살펴볼 수 있습니다. 먼저 IntStream에서는 .map()이 아닌 .mapToObj()라는 특수 버전을 사용합니다. 이는 스트림의 각 기본 요소를 지정된 유형의 객체로 변환하는 역할을 하며, IntStream의 경우 int값을 지정된 유형의 객체로 변환합니다. 즉 .map()의 경우 스트림의 요소를 변환하기 위한 일반 메서드로 조금 더 자유롭게 사용이 가능하지만 .mapToObj()의 경우 기본 스트림을 매핑하기 위한 목적으로 사용되어 상대적으로 한정된 사용이 가능하지만 특성상 기본 스트림만을 사용하기 때문에 오히려 안정적으로 사용할 수 있습니다.

아래 보시면 .findFirst()라는 문구가 존재합니다. 이는 .findAny()와 유사한 기능을 가지는데, 둘 다 직렬 상황에서는 넘어온 스트림 원소들 중 가장 앞의 1개만 가져옵니다. 하지만 병렬 환경의 경우 .findFirst()의 경우 여러 요소가 조건에 부합해도 스트림의 순서 중 가장 앞에 있는 요소를 리턴하는 반면, .findAny()의 경우 가장 먼저 찾은 요소를 리턴하기 때문에 상대적으로 뒤에 있는 요소가 멀티 스레드 환경에서 먼저 실행될 경우 먼저 실행된 값을 출력합니다. 아래 로직에서는 원하는 키 네임은 유일하기 때문에 큰 차이가 없지만, 조금이라도 꼬일 염려가 적은 findFirst()를 사용했습니다.

// 스트림 적용 후
        final String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
        final List<Object> args = new ArrayList<>(Arrays.asList(joinPoint.getArgs()));
                return IntStream.range(0, parameterNames.length)
                .filter(i -> parameterNames[i].equals(paramName))
                .mapToObj(i -> {
                    if (c.isInstance(args.get(i))) return (T) args.get(i);
                    else throw new ValidationException("WRONG_TYPE_EXCEPTION");
                })
                .findFirst()
                .orElseThrow(() -> new ValidationException("SET_BODY_TO_MDC_EXCEPTION"));
profile
저는 상황에 맞는 최적의 솔루션을 깊고 정확한 개념의 이해를 통한 다양한 방식으로 해결해오면서 지난 3년 동안 신규 서비스를 20만 회원 서비스로 성장시킨 Software Developer 최민길입니다.

0개의 댓글