자바 8 - (1) Stream : 람다 표현식의 등장 배경
자바 역사를 통틀어 가장 획기적인 변화는 자바 8에서 일어났습니다. 물론 그 이전에도 새로운 자바 버전이 릴리즈 될 때마다 많은 변화가 있었지만 자바 8 만큼 획기적이고 생산성에 큰 영향을 줄 만큼 큰 변화는 일어나지 않았습니다. 그렇다면 자바 8에서는 어떤 변화가 생겼기에 큰 변화가 일어났다고 하는 걸까요? 그것은 바로 Stream 입니다.
Stream을 알아보기 전 선행학습이 이루어져야 하는 것이 바로 람다 표현식입니다. 이전에는 어떤 문제가 있었고, 그 문제를 해결하기 위해 왜 람다 표현식이 등장했는지 알아보기 위해 포스팅하게 되었습니다. 예제 코드는 Github에서 확인하실 수 있으며 이 포스팅은 람다 표현식에 등장 배경에만 다루기 때문에 자세한 내용은
[Java] Lambda Expression(람다 표현식)
을 참고해 주세요.
1. 변화하는 요구사항에 대응
- 동작 파라미터화(Behavior Parameterization)
1-1. 필터링 조건 파라미터화
자바 8 이전의 기존 코드 문제점을 알아보기 위해 가격('price'), 용량('capacity'), 브랜드('brands') 필드를 가지고 있는 Coffee
클래스를 구현했습니다. 또한 Coffee
'brands' 중 메가커피 브랜드를 찾기 위한 'filterMegaCoffeeList' 메서드를 추가했습니다.
@Getter
@NoArgsConstructor
public class Coffee {
private int price;
private int capacity;
private Brands brands;
public Coffee(int price, int capacity, Brands brands) {
this.price = price;
this.capacity = capacity;
this.brands = brands;
}
// 메가커피 필터링 메서드
public static List<Coffee> filterMegaCoffeeList(List<Coffee> coffeeList) {
List<Coffee> filterList = new ArrayList<>();
for (Coffee coffee : coffeeList) {
if (Brands.MEGA.equals(coffee.getBrands())) {
filterList.add(coffee);
}
}
return filterList;
}
@Getter
@AllArgsConstructor
enum Brands {
MEGA("메가커피"),
STARBUCKS("스타벅스"),
PAIKDABANG("빽다방"),
TWOSOME("투썸플레이스"),
EDIYA("이디야")
;
private String desc;
}
}
구현한 'filterMegaCoffeeList' 메서드를 이용해 메가커피 브랜드인 Coffee
들을 필터링할 수 있습니다.
@Test
void legacy_code() {
List<Coffee> coffeeList = Arrays.asList(
...
);
// 메가커피 필터링
List<Coffee> megaCoffees = Coffee.filterMegaCoffeeList(coffeeList);
Assertions.assertThat(megaCoffees.size()).isEqualTo(2);
}
위 'filterMegaCoffeeList' 메서드를 이용하여 메가커피 브랜드를 필터링할 수 있었지만 요구사항이 변경되어 메가커피 브랜드가 아닌 스타벅스 브랜드를 필터링해야 하는 경우는 어떻게 해야 할까요? 또한 추가 요구사항으로 각 커피 브랜드를 필터링해야 한다면 어떻게 해야 할까요? 아주 간단한 방법은 요구 사항에 맞는 메서드들을 추가하는 방법이 있습니다. 하지만 이 방법은 중복된 코드를 발생하는 좋지 않은 코드가 됩니다.
@Getter
@NoArgsConstructor
public class Coffee {
...
// 메가커피 필터링 메서드
public static List<Coffee> filterMegaCoffeeList(List<Coffee> coffeeList) {
List<Coffee> filterList = new ArrayList<>();
for (Coffee coffee : coffeeList) {
if (Brands.MEGA.equals(coffee.getBrands())) {
filterList.add(coffee);
}
}
return filterList;
}
// 스타벅스 필터링 메서드
public static List<Coffee> filterMegaCoffeeList(List<Coffee> coffeeList) {
List<Coffee> filterList = new ArrayList<>();
for (Coffee coffee : coffeeList) {
if (Brands.STARBUCKS.equals(coffee.getBrands())) {
filterList.add(coffee);
}
}
return filterList;
}
...
}
어떻게 하면 위와 같은 중복된 코드가 발생하지 않도록 할 수 있을까요? 답은 필터링에 필요한 'Brands'를 메서드 파라미터에 추가하면 하나의 메서드로 변화하는 요구사항에 유연하게 대응할 수 있게 됩니다.
public static List<Coffee> filterBrandsList(List<Coffee> coffeeList, Brands brands) {
List<Coffee> filterList = new ArrayList<>();
for (Coffee coffee : coffeeList) {
// Brands에 따른 필터링이 가능하다.
if (brands.equals(coffee.getBrands())) {
filterList.add(coffee);
}
}
return filterList;
}
@Test
void legacy_parameter_code() {
List<Coffee> coffeeList = Arrays.asList(
...
);
// 메가커피 필터링
List<Coffee> megaCoffeeList = Coffee.filterBrandsList(coffeeList, Coffee.Brands.MEGA);
Assertions.assertThat(megaCoffeeList.size()).isEqualTo(2);
// 스타벅스 필터링
List<Coffee> starbucksCoffeeList = Coffee.filterBrandsList(coffeeList, Coffee.Brands.STARBUCKS);
Assertions.assertThat(starbucksCoffeeList.size()).isEqualTo(3);
// 빽다방 필터링
List<Coffee> paikdabangCoffeeList = Coffee.filterBrandsList(coffeeList, Coffee.Brands.PAIKDABANG);
Assertions.assertThat(paikdabangCoffeeList.size()).isEqualTo(2);
// 투썸 필터링
List<Coffee> twosomeCoffeeList = Coffee.filterBrandsList(coffeeList, Coffee.Brands.TWOSOME);
Assertions.assertThat(twosomeCoffeeList.size()).isEqualTo(2);
// 이디야 필터링
List<Coffee> ediyaCoffeeList = Coffee.filterBrandsList(coffeeList, Coffee.Brands.EDIYA);
Assertions.assertThat(ediyaCoffeeList.size()).isEqualTo(2);
}
메서드 파라미터로 필터링에 필요한 'brands' 인자를 받음으로써 모든 문제가 해결된 것처럼 보입니다. 하지만 아직 문제점이 남아 있는데 가격과 용량에 대한 요구사항이 추가가 된다면 어떻게 될까요? 예를 들어 다음과 같은 요구사항이 추가된다고 가정해 보면 다음과 같습니다.
- 가격이 3000원 미만인 커피
public static List<Coffee> filterPriceLeList(List<Coffee> coffeeList, int price) {
List<Coffee> filterList = new ArrayList<>();
for (Coffee coffee : coffeeList) {
// if문을 제외한 코드 중복 발생
if (price >= coffee.getPrice()) {
filterList.add(coffee);
}
}
return filterList;
}
여기서 문제점이 발생하게 됩니다. if문을 제외한 대부분의 코드가 'filterBrandsList' 메서드의 코드와 중복이 발생하여 소프트웨어 공학 원칙 중 하나인 DRY(Don't repeat yourself)의 원칙을 어기게 됩니다. 따라서 중복 코드가 발생하지 않도록 기존 메서드를 수정하여 위 요구사항을 반영하는 것이 좋은 방법입니다.
public static List<Coffee> filterParameterCheckList(List<Coffee> coffeeList, Brands brands, int price) {
List<Coffee> filterList = new ArrayList<>();
boolean isBrands = !ObjectUtils.isEmpty(brands) ? true : false;
boolean isPrice = price > 0 ? true : false;
for (Coffee coffee : coffeeList) {
if (isBrands && isPrice) { // 브랜드와 가격 모두 필터링 할 경우
if (brands.equals(coffee.getBrands()) && price >= coffee.getPrice()) {
filterList.add(coffee);
}
} else if (isBrands) { // 브랜드만 필터링 할 경우
if (brands.equals(coffee.getBrands())) {
filterList.add(coffee);
}
} else if (isPrice){ // 가격만 필터링 할 경우
if (price >= coffee.getPrice()) {
filterList.add(coffee);
}
} else { // 필터링을 안할 경우
filterList.add(coffee);
}
}
return filterList;
}
@Test
void legacy_parameter_code_2() {
List<Coffee> coffeeList = Arrays.asList(
...
);
// 메가커피, 가격 2000원 미만 필터링
List<Coffee> coffeeList1 = Coffee.filterParameterCheckList(coffeeList, Coffee.Brands.MEGA, 2000);
// 가격 3000원 미만 필터링
List<Coffee> coffeeList2 = Coffee.filterParameterCheckList(coffeeList, null, 3000);
for (Coffee coffee : coffeeList1) {
log.info("filter1 : coffee = {}, {}, {}", coffee.getPrice(), coffee.getBrands().getDesc(), coffee.getCapacity());
}
for (Coffee coffee : coffeeList2) {
log.info("filter2 : coffee = {}, {}, {}", coffee.getPrice(), coffee.getBrands().getDesc(), coffee.getCapacity());
}
}
// 결과
filter1 : coffee = 2000, 메가커피, 680
filter2 : coffee = 2000, 메가커피, 680
filter2 : coffee = 3000, 메가커피, 1000
filter2 : coffee = 2000, 빽다방, 625
filter2 : coffee = 3000, 빽다방, 946
필터링에 필요한 조건들을 'filterParameterCheckList' 메서드의 파라미터로 받아 브랜드, 가격, 브랜드&가격을 필터링할 수 있게 되었습니다. 즉, 하나의 메서드로 요구사항을 모두 반영할 수 있게 되어 중복 코드의 문제를 해결할 수 있게 되었습니다. 하지만 코드의 복잡성이 증가할 뿐만 아니라 인자 값을 'null' 값으로 넘겨주는 등 좋지 않은 코드가 발생하게 됩니다. 또한 요구사항이 추가될수록 코드의 복잡성이 증가하면서 이해하기 쉽지 않게 되는 문제점이 발생하게 됩니다.
1-2. 필터링 동적 파라미터화
필터링에 필요한 조건을 메서드 파라미터에 추가하는 방법은 결국 문제를 해결할 수 없다는 것을 알 수 있었습니다. 따라서 더 유연하게 대응할 수 있는 방법이 필요하게 되었는데 그것이 바로 Predicate
입니다. Predicate
는 매개변수로 받은 값으로 논리적인 조건을 검사하여 boolean 값을 반환하는 인터페이스입니다.
// Predicate 인터페이스 구현
public interface CoffeePredicate {
boolean filter(Coffee coffee);
}
// CoffeePredicate 구현 클래스 - 3000원 미만 커피 필터링
public class CoffeeLessThanPricePredicate implements CoffeePredicate{
@Override
public boolean filter(Coffee coffee) {
return coffee.getPrice() <= 3000;
}
}
// CoffeePredicate 구현 클래스 - 메가커피 필터링
public class CoffeeMegaBrandsPredicate implements CoffeePredicate{
@Override
public boolean filter(Coffee coffee) {
return Brands.MEGA.equals(coffee.getBrands());
}
}
위 그림에서 알 수 있듯이 디자인 패턴 중 전략 디자인 패턴(Strategy Design Pattern)을 사용하는 방법입니다. 전략 디자인 패턴을 사용하면 파라미터로 Predicate
구현 객체를 받아 내부적으로 다양한 동작을 수행할 수 있게 됩니다.
public static List<Coffee> filterCoffeeList(List<Coffee> coffeeList, CoffeePredicate p) {
List<Coffee> filterList = new ArrayList<>();
for (Coffee coffee : coffeeList) {
// CoffeePredicate 구현 객체에 따른 동작 수행
if (p.filter(coffee)) {
filterList.add(coffee);
}
}
return filterList;
}
CoffeePredicate
의 구현 객체를 인자로 전달함으로써 'filterCoffeeList' 메서드의 동작을 결정할 수 있게 되어 변화하는 요구사항에 맞게 대응할 수 있는 유연한 코드를 구현할 수 있게 됩니다. 즉, 특정 필터링 조건이 필요하게 된다면 CoffeeLessThanPricePredicate
, CoffeeMegaBrandsPredicate
와 같이 CoffeePredicate
를 상속받은 구현 클래스를 만들어 인자로 전달하게 되면 원하는 필터링 동작을 수행할 수 있게 됩니다.
하지만 이 방법 또한 CoffeePredicate
를 상속받는 구현 클래스를 만들어 사용해야 하기 때문에 요구사항이 발생하면 일일이 구현 클래스를 만들어줘야 한다는 단점이 있습니다.
1-3. 익명 클래스(Anonymous Class)
익명 클래스(Anonymous Class)는 필터링 동적 파라미터화의 단점을 극복하기 위해 나온 방법으로 클래스 선언과 인스턴스화를 동시에 할 수 있는 클래스입니다. 익명 클래스에 대한 자세한 내용은 [Java] Lambda Expression(람다 표현식)을 참고하시길 바랍니다.
위에서 예시로 든 CoffeePredicate
의 구현 클래스 CoffeeMegaBrandsPredicate
를 익명 클래스로 사용하면 다음과 같이 구현할 수 있습니다.
List<Coffee> megaCoffeeList = Coffee.filterCoffeeList(coffeeList, new CoffeePredicate() {
@Override
public boolean filter(Coffee coffee) {
return Brands.MEGA.equals(coffee.getBrands());
}
});
이와 같이 구현 클래스를 만들 필요 없이 익명 클래스를 사용하여 CoffeePredicate
의 구현 객체를 인자로 넘겨줄 수 있습니다. 구현 클래스를 따로 만들 필요가 없기 때문에 생산성이 증가한다는 장점이 있지만 익명 클래스로 인해 코드의 복잡성이 증가하여 가독성이 떨어지고 유지보수가 어려워진다는 단점이 있습니다.
2. 람다 표현식의 등장
지금까지 방법들은 한 가지를 해결하면 또 다른 문제가 발생하는, 꼬리에 꼬리를 물어 완벽하게 문제점들을 해결되지 않는 것을 볼 수 있었지만 람다 표현식의 등장으로 끝을 맺을 수 있었습니다. 람다 표현식은 메서드를 하나의 표현식으로 나타내는 방법으로 익명 클래스의 객체와 동등합니다.
public interface CoffeePredicate<T> {
boolean filter(T t);
}
public static List<Coffee> lambdaFilter(List<Coffee> coffeeList, CoffeePredicate<Coffee> p) {
List<Coffee> filterList = new ArrayList<>();
for (Coffee coffee : coffeeList) {
if (p.test(coffee)) {
filterList.add(coffee);
}
}
return filterList;
}
@Test
void lambda_filter() {
List<Coffee> coffeeList = Arrays.asList(
...
);
// 람다 표현식을 이용한 인수 전달
List<Coffee> megaCoffeeList = Coffee.lambdaFilter(coffeeList, coffee -> Brands.MEGA.equals(coffee.getBrands()));
for (Coffee coffee : megaCoffeeList) {
log.info("coffee = {}, {}, {}", coffee.getPrice(), coffee.getBrands().getDesc(), coffee.getCapacity());
}
}
coffee = 2000, 메가커피, 680
coffee = 3000, 메가커피, 1000
이와 같이 람다 표현식의 등장으로 이전에 있었던 변화하는 요구사항, 코드의 복잡성, 중복 코드 등들의 문제점들을 모두 해결할 수 있게 되었습니다.
Stream은 함수형 프로그래밍인 람다 표현식을 이용하는 함수형 API로 람다 표현식에 대한 선행 학습이 이루어져야 제대로 사용할 수 있습니다. 때문에 Stream을 다루기 전 람다 표현식이 왜 등장하게 되었고 이전에 어떤 문제점이 있었는지 알아보았습니다.
관련 포스팅
'Backend > Java' 카테고리의 다른 글
[Java] 자바 8 - (3) Stream : Collectors (0) | 2023.07.24 |
---|---|
[Java] 자바 8 - (2) Stream : 데이터 처리 연산 (0) | 2023.07.23 |
[Java] Lambda Expression(람다 표현식) (0) | 2023.07.22 |
[Java] ThreadLocal (1) | 2023.07.16 |
[Java] Garbage Collector (0) | 2023.07.16 |