티스토리 뷰

개념

  • 스트림은 파라미터로 제공되는 함수(Function<T, R>관련된 함수형 인터페이스)를 적용해서 기존 요소를 새로운 요소로 매핑시키는 map이라는 메서드를 제공한다.
  • 기존 값을 변경한다는 개념보다는 새로운 값을 만든다는 개념이므로 변환(transforming)매핑(mapping) 이라는 단어를 사용한다.

  • map의 기본 개념은 위의 이미지와 같다.

  • 또한 map은 반환하는 형태에 따라 여러 메서드가 존재한다.

    • mapToInt, mapToLong, mapToDouble, mapToObj가 다음에 해당한다.
    • 일반 스트림과 기본형 특화 스트림은 위의 메서드 중에서 타입에 따라 3가지씩 가지고 있고 없는 메서드는 결국 자기 자신 타입의 스트림을 반환하는 것이기 때문에 map 메서드가 그 역할을 하는 것이다.
    • 예를 들어, IntStream의 경우 map, mapToLong, mapToDouble, mapToObj가 존재하며 map 자체가 IntStream을 반환하고 Stream의 경우 map, mapToInt, mapToLong, mapToDouble가 존재하며 map 자체가 Stream을 반환한다.

map 메서드

  • 위에서 설명한 가장 기본적인 형태의 map 메서드이다.
  • 일반 스트림과 기본형 특화 스트림 모두에서 제공하는 메서드이다.
  • 스트림 별로 map의 파라미터를 살펴보면 다음과 같다.

Streammap 메서드

IntStreammap 메서드

LongStreammap 메서드

DoubleStreammap 메서드

  • map 메서드의 경우 결국 자기 자신 타입의 스트림을 반환하는 것을 알 수 있다.

  • 일반 스트림에서는 Function<T, R>이 사용되고 있는데 T가 변환 전 타입 R이 변환 후 타입이다.

    • TR의 타입이 같을 수도 있다.
    • 필자의 경우도 마찬가지였지만 스트림을 처음 공부하는 사람들이 이런 부분을 놓치기 쉽고 응용을 어려워한다.
  • 기본형 특화 스트림에서는 타입의 변화가 없기 때문에 각각에 맞는 특화 타입의 UnaryOperator가 사용되는 것은 어찌보면 당연하다.

mapToInt 메서드

  • 스트림을 IntStream으로 변환해주는 메서드다.
  • IntStream을 제외한 모든 스트림에서 동일하게 제공하는 메서드다.

  • 위의 이미지처럼 일반 스트림에서는 map 메서드와 다르게 ToIntFunction<? super T> mapper를 파라미터로 전달한다.

    • ToIntFunction라는 인터페이스의 이름에서 알 수 있듯이 기본 Function<T, R>에서 T만 제네릭 타입으로 전달받고 R은 int로 정해져있다.
    • 다른 기본형 특화 스트림에서는 LongToIntFunctionDoubleToIntFunction을 파라미터로 전달한다.
    • 인터페이스의 이름에서부터 알 수 있듯이 기본 Function<T, R>에서 T는 long과 double R은 int로 정해져있기 때문에 제네릭 타입을 받지 않는다.

mapToLong 메서드

  • 스트림을 LongStream으로 변환해주는 메서드다.
  • LongStream을 제외한 모든 스트림에서 동일하게 제공하는 메서드다.

  • mapToInt와 이하 내용은 유사하다.

mapToDouble 메서드

  • 스트림을 DoubleStream으로 변환해주는 메서드다.
  • DoubleStream을 제외한 모든 스트림에서 동일하게 제공하는 메서드다.

  • mapToInt와 이하 내용은 유사하다.

mapToObj 메서드

  • 기본형 특화 스트림을 Stream으로 변환해주는 메서드다.

  • 위의 이미지는 IntStream에서 mapToObj를 살펴본 내용인데 IntFunction<U>를 파라미터로 받는다.

    • 기본형 특화 스트림의 종류에 따라 LongFunction<U>, DoubleFunction<U>이 사용된다.
    • 인터페이스의 이름에서 알 수 있듯이 기본 Function<T, R>에서 T가 int, long, double이고 RU가 된다.

예제

  • 아래 예제에서 사용되는 내용들은 다양한 상황을 연출하기 위해서 임의로 만들어진 내용이므로 특정 속성에 대해서 왜 저 타입이 사용되었는지 의문을 가지지 말자!

  • 예제 코드에서 사용되고 있는 스펙

    1. Java 15 preview (record라는 새로운 클래스 개념을 사용하기 위해서 해당 프리뷰 버전을 사용. 하위 버전의 경우는 일반 클래스를 생성한 후 getter를 만들고 사용하면 됨) - 참고
    2. JUnit 5
    3. Gradle 6.7
  • 소스 코드

기본 데이터 생성

public record Sports(int no, String name, int burnedCalories, boolean isIndoor) {
}
public record ProductEntity(Long id, String name, String category, LocalDate createdAt) {
}
public record Car(Long id, String name, LocalDate createdAt) {
}
private final MapUsage mapUsage = new MapUsage();
private final List<Sports> sports = List.of(
        new Sports(1, "걷기", 40, false),
        new Sports(2, "스쿼시", 126, true),
        new Sports(3, "런닝머신", 110, true),
        new Sports(4, "등산", 84, false));
private final List<ProductEntity> productEntities = List.of(
        new ProductEntity(1L, "Aventador", "CAR", LocalDate.of(2018, 3, 28)),
        new ProductEntity(2L, "Ramen", "FOOD", LocalDate.of(2001, 4, 11)),
        new ProductEntity(3L, "Lego", "TOY", LocalDate.of(1980, 11, 5)),
        new ProductEntity(4L, "Veyron", "CAR", LocalDate.of(2009, 12, 9)),
        new ProductEntity(5L, "Modern Java in Action", "BOOK", LocalDate.of(2019, 8, 1)));

문자열을 문자열 길이로 변환(String -> Integer)

  • String 타입의 데이터를 그 길이로 변환하는 메서드를 만들어보자.
public List<Integer> getLength(List<String> strings) {
    return strings.stream()
            .map(String::length)
            .collect(Collectors.toList());
}
  • 주어진 단어들의 길이를 반환해보자.
@Test
@DisplayName("문자열을 문자열 길이로 변환(String -> Integer)")
void getLengthTest() {
    final var strings = List.of("banana", "apple", "orange");
    final var expected = List.of(6, 5, 6);
    final var result = mapUsage.getLength(strings);
    assertIterableEquals(expected, result);
}
  • 테스트가 성공한다.

정수를 문자열로 변환(int -> String)

  • int 기본 타입을 주어진 문자열과 합쳐서 반환하는 메서드를 만들어보자.
public List<String> generateStrings(String prefix, int size) {
    return IntStream.range(0, size)
            .mapToObj(i -> prefix + i)
            .collect(Collectors.toList());
}
  • 여기서 0부터 size까지의 정수 스트림을 만들기 위해 IntStream.range() 메서드를 사용했다.
  • 기본형 특화 스트림(IntStream)을 일반 스트림으로 변환하기 위해 mapToObj() 메서드가 사용되었다.
  • waiting- 이라는 문자열에 0부터 size까지의 숫자를 붙인 문자열을 반환해보자.
@Test
@DisplayName("정수를 문자열로 변환(int -> String)")
void generateStringsTest() {
    final var expected = List.of("waiting-0", "waiting-1", "waiting-2");
    final var result = mapUsage.generateStrings("waiting-", 3);
    assertIterableEquals(expected, result);
}
  • 테스트가 성공한다.

실외 스포츠들의 이름 가져오기(Sports -> String)

  • Sports 타입의 리스트를 String 타입으로 변환하는 메서드를 만들어보자.
public List<String> getSportsNamesByIndoor(List<Sports> sports, boolean isIndoor) {
    return sports.stream()
            .filter(s -> s.isIndoor() == isIndoor)
            .map(Sports::name)
            .collect(Collectors.toList());
}
  • 실외 스포츠들의 이름을 조회해보자.
@Test
@DisplayName("실외 스포츠들의 이름 가져오기(Sports -> String)")
void getSportsNamesByIndoorTest() {
    final var expected = List.of("걷기", "등산");
    final var result = mapUsage.getSportsNamesByIndoor(sports, false);
    assertIterableEquals(expected, result);
}
  • 테스트가 성공한다.

스포츠들의 칼로리 평균 구하기(Sports -> int)

  • Sports 타입의 리스트를 기본 정수 자료형(int)으로 변환하는 메서드를 만들어보자.
public OptionalDouble getAverage(List<Sports> sports) {
    return sports.stream()
            .mapToInt(Sports::burnedCalories)
            .average();
}
  • 위 코드에서 mapToInt() 메서드를 통해서 레퍼런스 타입의 일반 스트림을 기본형 특화 스트림인 IntStream으로 변환했다.

  • IntStream의 종단 연산 중 하나인 average() 메서드를 사용해서 평균을 구했다.

  • average() 메서드의 경우에는 종단 연산에 대한 글을 쓸 때 다시 언급할 예정이다. 간단하게 평균을 구하는 메서드이고 반환 타입이 OptionalDouble인데 이것은 기본형 특화 Optional이다.

    • sports가 빈 리스트일 경우에 평균을 구하는 로직에서 Division by Zero가 발생할 수 있으므로 이 경우에 결괏값이 없을 수 있다는 것을 명시적으로 알려주기 위해서 OptionalDouble 형태로 반환해주는 것 같다.
  • 주어진 스포츠들의 칼로리 평균을 구해보자.

@Test
@DisplayName("스포츠들의 칼로리 평균 구하기(Sports -> int)")
void getAverageTest() {
    final var expected = 90.0;
    final var result = mapUsage.getAverage(sports);
    assertTrue(result.isPresent());
    assertEquals(expected, result.getAsDouble());
}
  • 테스트가 성공한다.
  • getAverage(sports)의 반환형이 OptionalDouble이기 때문에 먼저 result가 비어있는지 체크하기 위해 result.isPresent()를 사용했고 이후에 result.getAsDouble() 메서드를 사용해서 실제값을 가져오도록 했다.

문자열 대문자로 변환(String -> String)

  • String 타입을 같은 String 타입으로 변환해보자.
public List<String> getUpperStrings(List<String> strings) {
    return strings.stream()
            .map(String::toUpperCase)
            .collect(Collectors.toList());
}
  • 스트림의 특성상 원본 strings의 데이터는 변하지 않는다.
  • 주어진 문자열들을 대문자로 변환해보자.
@Test
@DisplayName("문자열 대문자로 변환(String -> String)")
void getUpperStringsTest() {
    final var strings = List.of("banana", "apple", "orange");
    final var expected = List.of("BANANA", "APPLE", "ORANGE");
    final var result = mapUsage.getUpperStrings(strings);
    assertIterableEquals(expected, result);
}
  • 테스트가 성공한다.

상품(Entity)에서 자동차(CAR)를 분류한 후 DTO로 변환(ProductEntity -> Car)

  • 좀 더 실무에서 사용될 법한 간단한 예시를 만들어보자.
public List<Car> getCarsFromProductEntities(List<ProductEntity> productEntities) {
    return productEntities.stream()
            .filter(p -> "CAR".equals(p.category()))
            .map(p -> new Car(p.id(), p.name(), p.createdAt()))
            .collect(Collectors.toList());
}
  • 상품(Entity)을 데이터베이스에서 가져왔다고 하고 다른 레이어에서 사용할 수 있도록 DTO로 변환해보자.
@Test
@DisplayName("상품(Entity)에서 자동차(CAR)를 분류한 후 DTO로 변환(ProductEntity -> Car)")
void getCarsFromProductEntitiesTest() {
    final var expected = List.of(
            new Car(1L, "Aventador", LocalDate.of(2018, 3, 28)),
            new Car(4L, "Veyron", LocalDate.of(2009, 12, 9)));
    final var result = mapUsage.getCarsFromProductEntities(productEntities);
    assertIterableEquals(expected, result);
}
  • 테스트가 성공한다.

이상 Stream API에서 사용하는 중간 연산 중 가장 중요하고 많이 사용되는 map 관련 메서드에 대해 살펴보았다.

댓글