제가 현업에서 Java 언어로 개발을 진행하며 가장 많이 사용하고 있는 Stream에 대해 이야기 해보려고 합니다.
Java Stream Api는 Java 8에서 추가된 기능으로 Java의 Collection 요소를 간결하고 선언적으로 처리할 수 있는 Api 입니다.
Java Stream에서 선언적 표현방식이라는 의미가 중요하게 언급되는데 여기서 선언적 표현 방식의 뜻은 무엇을 할 것이가에 집중하여 코드를 작성하는 방식을 의미한다고 합니다.
선언적 표현방식과 대립되는 개념으로 명령형 표현방식이 있는데 아래 코드를 통해 비교해보도록 하겠습니다.
아래 두 메소드는 Board Entity List를 Repository로부터 조회하여 응답 Dto로 변환하여 반환하는 메소드 입니다.
// 명령형 방식의 메소드
public List<BoardResponseDto> getBoardList() {
List<BoardResponseDto> boardResponseDtoList = new ArrayList<>();
for (Board board: boardRepository.findAll()) {
BoardResponseDto boardResponseDto = BoardResponseDto.of(board);
boardResponseDtoList.add(boardResponseDto);
}
return boardResponseDtoList;
}
// Stream을 활용한 선언형 방식의 메소드
public List<BoardResponseDto> getBoardList() {
return boardRepository.findAll()
.stream()
.map(BoardResponseDto::of)
.toList();
}
명령형 방식의 경우 로직이 명시적 이지만 코드가 다소 복잡해질 수 있고 가독성이 떨어지는 반면 선언적 표현 방식의 경우 filter, map과 같은 메소드 체이닝을 통해 무엇을 할지에만 집중하여 코드를 작성하여 어떤 의도에만 집중되어 명령형에 비해 읽기 쉬운코드가 됩니다.
Stream을 왜 사용해야하는지에 대해 설명을 짧게 마치고 제가 가장 많이 사용한 예시를 보여드리도록 하겠습니다.
Map
// BoardService.class
public List<BoardResponseDto> getBoardResponseDtoList() {
return boardRepository.findAll()
.stream()
.map(board -> {
return new BoardResponseDto(
board.getBoardId(),
board.getTitle(),
board.getAuthor(),
board.getDescription(),
board.getUpdateAt(),
board.getCreateAt()
);
}).toList();
}
Map은 1개의 입력과 1개의 출력을 가지고 있는 @FunctionalInterface를 매개변수로 입력을 받습니다. 그래서 저는 주로 기존의 객체를 다른형태의 객체로 변환할때 많이 사용했는데 대표적으로 Entity객체를 Dto로 변환하는 작업에 많이 사용해 왔습니다.
// BoardResponseDto.class
public static BoardResponseDto of(Board board) {
return BoardResponseDto.builder()
.boardId(board.getBoardId())
.title(board.getTitle())
.author(board.getAuthor())
.description(board.getDescription())
.commentCount(board.getCommentCount())
.updateAt(board.getUpdateAt())
.createAt(board.getCreateAt())
.build();
}
public List<BoardResponseDto> getBoardList() {
return boardRepository.findAll()
.stream()
.map(BoardResponseDto::of)
.toList();
}
new를 통해서 객체를 만들 수 도 있지만 팩토리 메소드를 구현하여 메소드 참조를 사용하면 좀더 깔끔하게 코드를 작성할 수 있습니다.
Filter
// BoardService.class
public List<BoardResponseDto> getBoardByIdEvenList() {
return boardRepository
.findAll()
.stream()
.filter(it -> it.getBoardId() % 2 == 0)
.map(BoardResponseDto::of)
.toList();
}
Filter는 1개의 입력과 boolean의 출력값을 가지고 있는 @FunctionInterface를 매개변수로 입력을 받습니다. 주로 특정 조건에 해당하는 목록만 추출하는 용도로 사용합니다.
위 코드는 Entity Board의 값을 데이터베이스로 부터 가져오고 Board.boardId가 짝수인값만 추출하도록 filter를 구현하였습니다.
// Entity Board class
public boolean isBoardIdEven() {
return boardId % 2 == 0;
}
public List<BoardResponseDto> getBoardByIdEvenList() {
return boardRepository
.findAll()
.stream()
.filter(Board::isBoardIdEven)
.map(BoardResponseDto::of)
.toList();
}
filter내부에 직접 람다식을 구현하는것도 좋지만 좀더 직관적이게 코드를 작성하려면 앞서 말씀드린 메소드 참조를 사용하면 좋습니다. Board 자기 자신의 Id가 짝수인지 판단하는 메소드를 구현하고 해당 메소드를 참조형식으로 filter에 사용한다면 메소드명을 통해 다른 어느 개발자가 확인을 하더라도 단번에 이해할 수 있는 로직이 될 수 있습니다.
Grouping
// BoardService.class
public Map<String, List<BoardResponseDto>> getCreateAtByBoardMap() {
return boardRepository
.findAll()
.stream()
.map(BoardResponseDto::of)
.collect(Collectors.groupingBy(it -> it.getCreateAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))));
}
제가 정말 유용하게 사용하고 있는 Grouping입니다. 내가 지정한 기준을 통해 기준이 Key값이 되고 해당 기준에 해당하는 데이터들을 value의 List 값으로 묶어줍니다. 따라서 Java의 Map 형태로 값이 리턴되게 됩니다.
위코드는 년-월-일 형태로 기준을 잡고 값을 도출해보니 같은 일자별로 게시글을 묶을 수 있었습니다.
// BoardResponseDto.class
public String groupByCreatedAtKey() {
return createAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
public Map<String, List<BoardResponseDto>> getCreateAtByBoardMap() {
return boardRepository
.findAll()
.stream()
.map(BoardResponseDto::of)
.collect(Collectors.groupingBy(BoardResponseDto::groupByCreatedAtKey));
}
Grouping에서도 메소드 참조를 활용하여 정리를 할 수 있는데 그룹핑의 기준이 되는 값을 BoardResponseDto 내부 클래스에 메소드로 정리하여 메소드참조를 활용하면 코드를 좀더 직관적으로 작성할 수 있습니다.
Reduce
// BoardService.class
public Long getBoardCommentCountSum() {
return boardRepository.findAll()
.stream()
.map(Board::getCommentCount)
.reduce(0L, (a, b) -> a + b);
}
Reduce는 초기값과, LongBinaryOperator @FunctionalInterface를 매개변수로 입력 받아 집계 연산을 수행하는데 위 코드는 Board가 가지고있는 값중 commentCount 즉 댓글 갯수를 전부다 합치는 기능을 하는 메소드 입니다.
reduce를 보면 reduce( 파라미터 1, (파라미터2, 파라미터3) -> 결과값)로 구성되어있는걸 확인할 수 있는데
파라미터1은 초기값을 지정하는 부분이고 파라미터2는 누적값 파라미터3는 다음에 누적해야할 값이 들어온다고 생각하시면 됩니다.
따라서 만약 첫번째 Stream이 실행되면 파라미터2 위치에는 초기에 지정한값 0이 들어오고 b는 commentCount 첫번째 값이 들어오고 그값의 합산이 결과값으로 리턴됩니다.
2회차에서는 합산하여 리턴한 값이 다시 파라미터 2위치에 들어오고 commentCount두번째값이 파라미터 3 위치에 들어가게됩니다.
해당 원리를 통해 누적값이 계산되게 됩니다.
// BoardService.class
public Long getBoardCommentCountSum() {
return boardRepository.findAll()
.stream()
.mapToLong(Board::getCommentCount)
.sum();
}
다만 제가 코드를 작성할때는 reduce를 사용하는것 보다는 특정 자료형태에 특화된 Stream을 주로 사용하였습니다. Long 타입 전용 LongStream, Integer 전용 IntStream 등등이 있고 해당 Stream에는 자체 집계 메소드들이 구현되어있어서 좀더 손쉽게 집계 데이터를 도출할 수 있습니다.
Reduce를 사용하는 방식보다 오버헤드가 적고 병렬처리가 되어있어서 LongStream을 활용하는게 성능면에서 유리하다고 합니다.
제가 설명한 부분 이외에 정렬, Map을 다루는 entrySet등등 정말 많은 기능들을 Java Stream에서 지원하고 있습니다.
Stream을 활용하여 좀더 비즈니스 로직에 집중하여 코드를 작성하면 좀더 비즈니스에 집중한 직관적인 코드를 누구나 이해하기 쉬운 코드를 작성할 수 있으며 명령형 코드보다는 좀더 나은 생산성을 확보할 수 있다고 생각합니다.
만약 Stream을 사용하고있지 않으신다면 적극 도입을 추천 드립니다!
'Spring > experience' 카테고리의 다른 글
SpringFramework NullPointException 방지법 (0) | 2025.01.13 |
---|---|
Spring Boot Batch 프로젝트에서 Webclient 사용시 주의점 (0) | 2024.12.05 |
Spring Framework ResponseEntity 사용에 대한 고찰 (0) | 2024.11.17 |
그럼에도 불구하고 Lombok을 도입해야하는 이유? (0) | 2024.10.27 |
스프링 서버가 우아하게 죽는법 "Graceful Shutdown" (0) | 2024.09.10 |