안녕하세요. 금요일이라 행복한 개발자 stark입니다!
최근 stream에 대한 글을 자주 적고 있습니다. 왜냐하면 제가 실무에서 너무나도 많이 사용하기 때문입니다. 많은 데이터를 조립해서 가공하는 로직을 만들면서 느낀 점들이 굉장히 많다 보니 계속 기본적인 글을 적게 되는 것 같습니다.
특히 저희 팀의 직속선배님께서 제가 forEach문을 사용해서 작성한 비즈니스 로직을 보시더니 stream의 anyMatch를 사용해서 코드가 개선되는 모습을 보여주셨습니다.
저는 stream에서 이런 다양한 메서드를 지원한다는 것은 알았지만 어째서인지 잘 사용하지 않았고 항상 filter와 findFirst 같은 메서드만으로 모든 필터링을 하고 있었습니다. 그래서 이번 기회에 다양한 필터 방식과 왜 Stream이 forEach보다 좋게 느껴지는지 정리해 봤습니다.
기존의 forEach로 필터링 코드 작성하기
forEach 메서드는 반복적으로 컬렉션의 모든 요소를 순회하며 조건에 따라 필터링하는 데 사용할 수 있습니다. 하지만 실제 비즈니스 로직에서 사용할 경우 코드가 더 복잡해지고 최적화하기가 어렵습니다. 지금부터 보게 될 예제는 쇼핑몰 주문 처리에 대한 복잡한 시나리오입니다. 이 시나리오에서는 특정 조건을 만족하는 주문을 찾아 승인하고, 추가적인 조건을 처리하는 복잡한 비즈니스 로직을 구현했습니다.
여기에 주문(Order) 클래스가 있습니다.
class Order {
String orderId;
String customerName;
double orderAmount;
boolean isPaid;
String status;
public Order(String orderId, String customerName, double orderAmount, boolean isPaid, String status) {
this.orderId = orderId;
this.customerName = customerName;
this.orderAmount = orderAmount;
this.isPaid = isPaid;
this.status = status;
}
public boolean isPaid() {
return isPaid;
}
public double getOrderAmount() {
return orderAmount;
}
public String getStatus() {
return status;
}
public void changeStatus(String status) {
this.status = status;
}
}
다음으로 forEach를 사용하는 주문 비즈니스 로직이 있습니다.
class OrderService {
public void processOrders(List<Order> orders) {
orders.forEach(order -> {
if (order.isPaid() && order.getOrderAmount() > 1000 && "PENDING".equals(order.getStatus())) {
// 주문 승인 로직
order.changeStatus("APPROVED");
System.out.println("주문 승인됨: " + order.orderId);
// 추가적인 조건 처리 (예: VIP 고객에 대한 특별 할인 적용)
if (order.getOrderAmount() > 5000) {
System.out.println("VIP 고객 주문, 특별 할인을 적용합니다: " + order.orderId);
}
}
});
}
}
이 예시코드는 forEach로 작성되었습니다. stream을 사용하지 않더라도 코드를 한 줄씩 천천히 읽어보면 이 코드가 그렇게 복잡하고 이해하기 어렵다는 생각이 들지는 않습니다. 그러나 우리가 실무에서 작업할 때는 이런 류의 간단한 코드만 보는 것이 아닙니다. 또한 이 코드에 문제가 생겼을 때 개발자 본인이 아닌 전혀 다른 비즈니스 로직을 개발하던 분이 이 로직을 확인해서 수정해야만 하는 일이 발생할 수도 있습니다.
이런 현실적인 상황을 생각해 보면 지금처럼 forEach 내부에서 if문으로 분기처리를 많이 하는 코드의 흐름을 머릿속에 하나씩 저장하며 단계별로 나아가면서 작업하는 것이 그렇게 쉽지만은 않습니다. (지금 예시는 간단해서 그렇지 수도 없이 많은 분기가 존재하는 비즈니스가 정말 많습니다.)
만약 stream을 사용하면 지금보다 코드가 이해하기 쉽고 작업하기도 수월해질까요? 직접 확인해 봅시다.
Stream을 사용하여 forEach 필터링 개선하기
자바의 Stream API 사용해서 위에서 작성했던 forEach문을 사용한 코드를 개선해 봤습니다.
이렇게 코드를 작성하다 보면 작성하는 과정이 너무 순조로워서 놀라움이 느껴집니다.
orders.stream()
.filter(Order::isPaid)
.filter(order -> order.getOrderAmount() > 1000)
.filter(order -> "PENDING".equals(order.getStatus()))
.peek(order -> {
order.setStatus("APPROVED");
System.out.println("주문 승인됨: " + order.orderId);
})
.filter(order -> order.getOrderAmount() > 5000)
.forEach(order -> System.out.println("VIP 고객 주문, 특별 할인을 적용합니다: " + order.orderId));
개선된 코드는 각 조건이 개별 filter()로 나누어져 있어 의도가 명확해지고 쉽게 읽을 수 있습니다. 처음에는 filter() 메서드의 개념이 헷갈릴 수 있지만, if 문과 동일하게 생각하면 더 수월하게 이해할 수 있습니다.
filter()는 말 그대로 현실 세계의 필터와 비슷한 역할을 합니다. 예를 들어, 정수기의 필터는 물의 나쁜 성분을 걸러내듯이, Stream의 filter()는 필요 없는 데이터를 걸러냅니다. 이 메서드는 조건이 true인 경우 해당 요소를 다음 단계로 전달하고, false인 경우 해당 요소를 스트림에서 제외합니다.
여기서 이런 궁금증이 생길 수도 있습니다. '여러 조건을 하나의 filter()에 &&로 결합하면 안 될까?'
당연히 가능합니다. 하지만 단일 filter()에 여러 조건을 &&로 결합하면 수정 시 실수할 가능성이 높아집니다. 반면, 각 조건을 개별 filter()로 나누면 코드의 각 조건이 독립적이어서 가독성이 높아지고, 수정 시 실수할 가능성도 줄어듭니다.
개인적인 의견을 조금 작성해 봅니다.
물론, 두뇌 회전이 빠르고 복잡한 코드를 직관적으로 이해하는 사람이라면 어떤 방법으로 작성하든 큰 차이가 없을 수 있습니다. 그러나 저처럼 한 가지 일에 집중하고 하나씩 순차적으로 작업하는 사람에게는, 위 예시처럼 filter()를 순차적으로 나눠 작성한 코드가 비즈니스 로직을 이해하기에 더 쉬울 수 있다고 생각합니다. (이것은 개인적인 의견이며, 모든 사람에게 동일하지 않을 수 있습니다.)
복합 로직: 필터링, 그룹화, 집계
이전에 작성된 forEach예시보다 조금 더 복잡한 비즈니스 로직을 처리해 봅시다. 예를 들어, 모든 주문을 처리하고 승인된 주문을 고객별로 그룹화한 다음 총 주문 금액을 계산하는 로직은 다음과 같습니다.
class OrderService {
public void processAndSummarizeOrders(List<Order> orders) {
Map<String, Double> customerOrderSummary = new HashMap<>();
// forEach를 사용하여 각 주문을 처리
orders.forEach(order -> {
if (order.isPaid() && order.getOrderAmount() > 1000 && "PENDING".equals(order.getStatus())) {
// 주문 승인 로직
order.setStatus("APPROVED");
System.out.println("주문 승인됨: " + order.orderId);
// 고객별 주문 금액 합산
customerOrderSummary.put(order.customerName,
customerOrderSummary.getOrDefault(order.customerName, 0.0) + order.getOrderAmount());
}
});
// 고객별 총 주문 금액 출력
customerOrderSummary.forEach((customer, totalAmount) -> {
System.out.println(customer + "님의 총 주문 금액: " + totalAmount);
});
}
}
이 코드는 forEach 메서드를 사용하여 모든 주문을 순회하면서 조건에 맞는 주문을 승인하고, 고객별로 주문 금액을 합산하는 로직을 보여줍니다. 이 방식은 직관적이지만, 코드가 길어지고 로직의 단계가 많을수록 복잡해집니다. 실무에서 이 코드를 유지보수하는 개발자는 코드의 흐름을 하나씩 따라가야 하므로 머릿속에서 모든 단계를 기억하며 작업해야 합니다.
그럼 이제 Stream을 사용해서 코드를 개선해 봅시다.
class OrderService {
public void processAndSummarizeOrders(List<Order> orders) {
orders.stream()
.filter(order -> order.isPaid() && order.getOrderAmount() > 1000 && "PENDING".equals(order.getStatus()))
.peek(order -> {
order.setStatus("APPROVED");
System.out.println("주문 승인됨: " + order.orderId);
})
.collect(Collectors.groupingBy(order -> order.customerName, Collectors.summingDouble(Order::getOrderAmount)))
.forEach((customer, totalAmount) -> {
System.out.println(customer + "님의 총 주문 금액: " + totalAmount);
});
}
}
Stream을 사용했더니 기존 코드보다 가독성이 훨씬 좋아졌습니다. 하나씩 살펴봅시다.
filter()는 if 문과 같은 역할을 합니다. 조건을 만족하는 요소만 스트림에 남겨서 처리할 수 있습니다. 이전 forEach 코드에서 우리가 조건문 안에 여러 조건을 확인하며 코드를 작성했던 것과 달리, filter()는 스트림에서 조건을 만족하지 않는 요소를 깔끔하게 걸러냅니다. 이로 인해 코드의 각 단계가 명확하게 나뉘고 가독성이 좋아집니다.
peek()는 중간 연산으로, 스트림의 요소를 조작하거나 부수 효과를 추가할 때 유용합니다. 예를 들어, 지금 작성한 것처럼 주문을 승인하고 로그를 출력하는 작업을 peek()에서 수행할 수 있습니다. 이전 forEach 코드에서는 이런 작업을 코드의 흐름 안에서 일일이 작성했지만, peek()는 스트림 내부에서 요소를 간편하게 처리하고 로깅 같은 부수 효과를 넣을 수 있어 코드를 더 직관적으로 만들 수 있습니다.
collect()는 데이터를 그룹화하고 집계합니다. 여기서 우리는 고객 이름을 기준으로 주문을 그룹화하고, 각 그룹에 대해 총 주문 금액을 합산하는 작업을 한 번에 수행합니다. 이전 forEach 코드에서는 이러한 작업을 위해 추가적으로 새로운 Map객체를 지역변수로 선언하고 수동으로 합산해야 했습니다. 그러나 collect()는 이러한 단계를 스트림 내에서 깔끔하게 처리해 코드의 길이를 줄이고 명확하게 만들었습니다.
마지막으로, stream내부의 forEach()는 최종 연산으로, 스트림에서 결과를 출력하거나 소비하는 데 사용됩니다. 그룹화된 결과를 간단히 순회하며 출력할 수 있습니다. forEach 코드에서는 두 개의 루프(처리 루프와 출력 루프)를 사용해 별도로 출력했지만, Stream API에서는 forEach()를 통해 간결하게 결과를 처리할 수 있습니다.
제가 너무 설명을 장황하게 한 것 같지만 코드만 봐도 Stream이 더 직관적이라는 것이 느껴집니다. 단순히 코드를 한 줄씩 읽어 내려가기만 해도 어느 정도 비즈니스가 이해가 됩니다.
anyMatch 사용하기
코드를 작성하다 보면 특정 조건을 만족하는지를 확인하기 위해 for문을 자주 사용합니다. 하지만, Stream의 anyMatch 메서드를 활용하면 코드를 더 간결하고 효율적으로 작성할 수 있습니다. anyMatch는 스트림 내에서 주어진 조건을 만족하는 요소가 하나라도 있으면 true를 반환하고, 즉시 스트림 처리를 종료합니다. 이 덕분에 불필요한 순회를 피할 수 있어 성능 면에서 큰 이점을 얻을 수 있습니다.
먼저, 기존에 많이 사용하는 forEach를 활용한 예제를 보겠습니다. 이 코드는 특정 금액 이상의 주문이 있는지 확인하고 조건을 만족하는 경우 주문 상태를 업데이트하는 로직입니다.
class OrderService {
public void checkForHighValueOrders(List<Order> orders, double threshold) {
final boolean[] hasHighValueOrder = {false}; // 외부 변수 사용
// forEach를 사용하여 각 주문을 순회하면서 조건 확인
orders.forEach(order -> {
if (order.isPaid() && order.getOrderAmount() > 1000 && "PENDING".equals(order.getStatus())) {
// 주문 승인 로직
order.setStatus("APPROVED");
System.out.println("주문 승인됨: " + order.getOrderId());
// 특정 금액 이상의 주문이 있는지 확인
if (order.getOrderAmount() > threshold) {
hasHighValueOrder[0] = true;
}
}
});
// 결과 출력
if (hasHighValueOrder[0]) {
System.out.println("고액 주문이 존재합니다.");
} else {
System.out.println("고액 주문이 없습니다.");
}
}
}
위 코드는 forEach를 사용해 각 주문을 순회하면서 조건을 검사합니다. 조건을 만족하면 외부 변수인 hasHighValueOrder의 값을 true로 변경하고, 결과를 출력합니다. 이 방식의 문제점은 무엇일까요? 코드가 비교적 길어지고, 복잡한 조건이 많아질수록 가독성이 떨어진다는 점입니다.
또한 forEach는 루프 중간에 break를 사용할 수 없어서 조건에 맞는 요소를 찾더라도 루프를 끝까지 실행합니다. 즉, 조건을 만족하는 요소를 발견해도 나머지 요소를 계속 순회합니다. 이 때문에 성능상으로 비효율적일 수 있습니다.
Stream의 anyMatch를 사용하면 이 부분을 개선해 봅시다.
anyMatch는 스트림 내에 주어진 조건을 만족하는 요소가 하나라도 있는지 확인할 때 사용됩니다. 조건을 만족하는 요소를 찾으면 즉시 true를 반환하며, 스트림 처리를 종료하기 때문에 더 효율적입니다. (성능적인 이점이 확실히 드러납니다.)
class OrderService {
public void checkForHighValueOrders(List<Order> orders, double threshold) {
// 특정 금액 이상의 주문이 존재하는지 확인
boolean hasHighValueOrder = orders.stream()
.filter(order -> order.isPaid() && order.getOrderAmount() > 1000 && "PENDING".equals(order.getStatus()))
.peek(order -> {
order.setStatus("APPROVED");
System.out.println("주문 승인됨: " + order.getOrderId());
})
.anyMatch(order -> order.getOrderAmount() > threshold);
// 결과 출력
if (hasHighValueOrder) {
System.out.println("고액 주문이 존재합니다.");
} else {
System.out.println("고액 주문이 없습니다.");
}
}
}
어떤가요? anyMatch를 사용하니 코드가 좀 더 깔끔하고 보기 쉽게 느껴지지 않나요?
우선, filter와 anyMatch를 사용하면 조건 검사 로직이 훨씬 간결해지고 한 줄로 표현되니까 코드가 직관적이고 이해하기 쉽습니다. 복잡한 조건일수록 이 장점이 두드러지게 나타납니다. 그리고 가장 매력적인 부분이 있는데 바로 조기 종료입니다. anyMatch는 조건을 만족하는 요소를 찾으면 바로 true를 반환하고, 스트림 처리를 멈춥니다. 이 덕분에 리스트의 중간에 조건을 만족하는 요소가 있으면 나머지를 다 확인할 필요 없이 바로 멈출 수 있어 성능이 좋아지게 됩니다.
noneMatch 사용하기
이제 noneMatch를 살펴봅시다. 이 메서드는 스트림 내 모든 요소가 특정 조건을 만족하지 않을 때 true를 반환합니다. 다시 말해, 조건에 맞지 않는 요소가 하나라도 발견되면 즉시 false를 반환하고 처리를 멈추게 됩니다. 이 기능 덕분에 리스트를 전부 순회하지 않아도 되니, 성능상 상당한 이점을 얻을 수 있습니다. (anyMatch와는 정반대 되는 기능입니다.)
먼저, 기존에 많이 사용하는 forEach를 활용한 예제를 보겠습니다. 이 코드는 각 주문을 순회하면서 특정 조건을 확인하고, 조건에 맞으면 주문 상태를 업데이트하며 특정 조건을 만족하는지 여부를 외부 변수에 기록하는 방식입니다.
class OrderService {
public void checkForNoLowValueOrders(List<Order> orders, double threshold) {
final boolean[] noLowValueOrders = {true}; // 외부 변수 사용
// forEach를 사용하여 각 주문을 순회하면서 조건 확인
orders.forEach(order -> {
if (order.isPaid() && order.getOrderAmount() > 1000 && "PENDING".equals(order.getStatus())) {
// 주문 승인 로직
order.setStatus("APPROVED");
System.out.println("주문 승인됨: " + order.getOrderId());
// 특정 금액 이하의 주문이 있는지 확인
if (order.getOrderAmount() < threshold) {
noLowValueOrders[0] = false;
}
}
});
// 결과 출력
if (noLowValueOrders[0]) {
System.out.println("모든 주문이 최소 금액 이상입니다.");
} else {
System.out.println("일부 주문이 최소 금액에 미치지 못합니다.");
}
}
}
이 코드에서는 forEach를 사용해 각 주문을 순회하면서 조건을 확인합니다. 조건을 만족하지 않는 주문이 있으면 noLowValueOrders 변수를 false로 변경하고, 나머지 주문들도 끝까지 확인합니다. 이 과정에서 외부 변수를 사용해 상태를 관리하다 보니 코드가 다소 복잡해지고, 모든 주문을 끝까지 확인해야 한다는 단점이 있습니다.
이제 Stream의 noneMatch를 사용해 더 효율적이고 간결한 코드로 개선해 보겠습니다.
class OrderService {
public void checkForNoLowValueOrders(List<Order> orders, double threshold) {
// 특정 금액 이하의 주문이 없는지 확인
boolean noLowValueOrders = orders.stream()
.filter(order -> order.isPaid() && order.getOrderAmount() > 1000 && "PENDING".equals(order.getStatus()))
.peek(order -> {
order.setStatus("APPROVED");
System.out.println("주문 승인됨: " + order.getOrderId());
})
.noneMatch(order -> order.getOrderAmount() < threshold);
// 결과 출력
if (noLowValueOrders) {
System.out.println("모든 주문이 최소 금액 이상입니다.");
} else {
System.out.println("일부 주문이 최소 금액에 미치지 못합니다.");
}
}
}
이 방식도 for 루프를 사용할 때보다 코드가 훨씬 간결하고 가독성이 좋습니다. 특히 noneMatch는 조건에 맞지 않는 요소가 하나라도 발견되면 즉시 처리를 멈추기 때문에 성능상으로도 상당한 이점이 있습니다. 예를 들어, 리스트 중간에 조건을 만족하지 않는 주문이 있다면 바로 false를 반환하기 때문에 나머지 주문을 굳이 확인하지 않아도 돼요. 이 조기 종료 기능 덕분에 대규모 데이터셋을 다룰 때도 효율적으로 처리할 수 있습니다.
'JAVA' 카테고리의 다른 글
[Java] ReentrantLock으로 티켓팅 시스템 동시성 문제 해결하기 (0) | 2024.11.09 |
---|---|
화살표 if문을 DDD로 우아하게 리팩토링하기 (1) | 2024.10.29 |
Java 클래스 상속의 자유도와 주의점 (1) | 2024.10.27 |
Java Stream 제대로 이해하기 (0) | 2024.10.26 |
전략 패턴(Strategy Pattern)이란? (2) | 2024.10.06 |