본문 바로가기

(4)

Chap11. null 대신 Optional

Null Pointer Exception (이제부터는 NPE라고 부르겠다.)은 자바 개발자라면 한번쯤은( 한번만 마주쳤겠는가? ) 마주쳤을 예외이다. NPE는 모든 자바 개발자를 괴롭히는 예외긴 하지만 null이라는 표현을 사용하면서 치러야 할 당연한 대가가 아닐까? 명령형 프로그래밍 세계라면 이러한 의견이 당연한것처럼 들릴 수도 있다. 하지만 거시적인 프로그래밍 관점에서 null을 다르게 접근한다. 최초로 null을 도입한 토니 호어는 null 및 예외를 만든 결정을 가리켜 '십억 달러짜리 실수'라고 표현했다. 자바를 포함해서 최근 수십 년간 탄생한 대부분의 언어 설계에는 null 참조 개념을 포함한다. 예전 언어와 호환성을 유지하려는 목적도 있었겠지만 호어가 말한 것처럼 '구현하기 쉬웠기 때문에' n..

Chap09. 리팩터링, 테스팅, 디버깅

람다 표현식을 이용해 가독성과 유연성을 높이려면 기존 코드를 어떻게 리팩터링해야 하는지 설명한다. 또한 람다 표현식으로 전략, 템플릿 메서드, 옵저버, 의무 체인, 팩토리 등의 객체지향 디자인 패턴을 어떻게 간소화할 수 있는지도 살펴본다. 마지막으로 람다 표현식과 스트림 API를 사용하는 코드를 테스트하고 디버깅하는 방법을 설명한다. 가독성과 유연성을 개선하는 리팩터링 람다 표현식을 이용한 코드는 다양한 요구사항 변화에 대응할 수 있도록 동작을 파라미터화한다. 코드의 가독성을 높이려면 코드의 무선화를 잘하고, 표준 코딩 규칙을 준수하는 등의 노력을 기울여야 한다. 자바 8의 새 기능을 이용해 코드의 가독성을 높일 수 있다. 코드를 간결하고 이해하기 쉽게 만들 수 있다. 또한 메서드 참조와 스트림 API를..

Chap08. 컬렉션 API 개선

컬렉션 팩토리 기존에는 Arrays.asList() 팩토리 메서드르 이용하여 간단한 코드로 쉽게 List 요소를 생성했다. 이렇게 생성된 리스트는 크기가 고정된다. 즉 요소를 갱신할 수는 있지만 새 요소를 추가하거나 요소를 삭제할 순 없다. 만약 요소를 추가 및 삭제하는 작업을 수행하게 되면 UnsupportedOperationException 예외가 발생한다(필자의 경우에도 Arrays.asList의 크기가 고정된다는 사실을 까먹어서 테스트 코드가 깨지면 왜 깨지는지 오류를 못 찾고는 했다). 아쉽게도 Set 혹은 Map을 바로 생성할 수 있는 방법은 없다. 리스트를 인수로 받는 HashSet 생성자를 통해 그.나.마 집합을 바로 생성할 수도 있긴 하다. 이는 스트림 API를 통해 아래와 같이도 만들 ..

Chap06. 스트림으로 데이터 수집

중간연산은 스트림 파이프라인을 구성하며, 스트림의 요소를 소비하지 않는다. 반면 최종 연산은 스트림의 요소를 소비해서 최종 결과를 도출한다. reduce가 그랬던 것처럼 collect 역시 다양한 요소 누적 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있다. 다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있다. 지금부터 컬렉션, 컬렉터, collect를 헷갈리지 않도록 주의해야한다. 컬렉터란? 함수형 프로그래밍에서는 '무엇'을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다. 이전 예제에서 collect 메서드로 Collector 인터페이스 구현을 전달했다. Collector 인터페이스 구현은 스트림의 요소..

카테고리 없음

Chap11. null 대신 Optional

728x90

Null Pointer Exception (이제부터는 NPE라고 부르겠다.)은 자바 개발자라면 한번쯤은(

한번만 마주쳤겠는가?

) 마주쳤을 예외이다. NPE는 모든 자바 개발자를 괴롭히는 예외긴 하지만 null이라는 표현을 사용하면서 치러야 할 당연한 대가가 아닐까?

명령형 프로그래밍 세계라면 이러한 의견이 당연한것처럼 들릴 수도 있다. 하지만 거시적인 프로그래밍 관점에서 null을 다르게 접근한다.

최초로 null을 도입한 토니 호어는 null 및 예외를 만든 결정을 가리켜 '십억 달러짜리 실수'라고 표현했다.

자바를 포함해서 최근 수십 년간 탄생한 대부분의 언어 설계에는 null 참조 개념을 포함한다. 예전 언어와 호환성을 유지하려는 목적도 있었겠지만 호어가 말한 것처럼 '구현하기 쉬웠기 때문에' null 참조 개념을 포함했을 것이다.

보수적인 자세로 NullPointerException 줄이기

대부분의 프로그래머는 필요한 곳에 다양한 null 확인 코드를 추가해서 null 예외 문제를 해결하려 할 것이다.

public String getCarInsuranceName(Person person) {
    if (person != null) {
        Car car = person.getCar();
        if (car != null) {
            Insurance insurance = car.getInsurance();
            if (insurance != null) {
                return insurance.getName();
            }
        }
    }
    return "Unknown";
}

위 코드에서는 변수를 참조할 때마다 null을 확인하며 중간 과정에서 하나라도 null 참조가 있으면 "Unknown"이라는 문자열을 반환한다. 우리가 확실히 알고 있는 영역을 모델링할 때는 이런 지식을 활용해서 null 확인을 생략할 수 있지만, 데이터를 자바 클래스로 모델링할 때는 이 같은 사실을 단정하기가 어렵다.

위의 예제는 메서드에서 모든 변수가 null인지 의심하므로 변수를 접근할 때마다 중첩된 if가 추가되면서 코드 들여쓰기 수준이 증가한다. 따라서 이와 같은 반복 패턴 코드를 '깊은 의심'이라고 부른다. 즉, 변수가 null인지 의심되어 중첩 if 블록을 추가하면 코드 들여쓰기 수준이 증가한다. 이를 반복하다보면 코드의 구조가 엉망이 되고 가독성도 떨어진다. 다른 해결방법이 필요하다. 다음 예제는 다른 방법으로 이 문제를 해결하는 코드다.

public String getCarInsuranceName(Person person) {
    if (person == null) {
        return "Unknown";
    }

    Car car = person.getCar();
    if (car == null) {
        return "Unknown";
    }

    Insurance insurance = car.getInstance();
    if (insurance == null) {
        return "Unknown";
    }
    return insurance.getName();
}

위 코드는 조금 다른 방법으로 중첩 if 블록을 없앴다. 즉, null 변수가 있으면 즉시 "Unknown" 을 반환한다. 하지만 이 예제도 그렇게 좋은 코드는 아니다. 메서드에 네 개의 출구가 생겼기 때문이다. 출구 때문에 유지보수가 어려워진다. 앞의 코드는 쉽게 에러를 일으킬 수 있다. 따라서 값이 있거나 없음을 표현할 수 있는 좋은 방법이 필요하다.

null 때문에 발생하는 문제

  • 에러의 근원이다.
  • 코드를 어지럽힌다.
  • 아무 의미가 없다.
  • 자바 철학에 위배된다.
    자바는 개발자로부터 모든 포인터를 숨겼다. 하지만 예외가 있는데 그것이 바로 null 포인터이다.
  • 형식 시스템에 구멍을 만든다.
    null은 무형식이며 정보를 포함하고 있지 않으므로 모든 참조 형식에 null을 할당할 수 있다. 이런식으로 null이 할당되기 시작하면서 시스템의 다른 부분으로 null이 퍼졌을 때 애초에 null이 어떤 의미로 사용되었는지 알 수 없다.

다른 프로그래밍 언어에서는 null 참조를 어떻게 해결하는지 살펴보면서 null 참조 문제 해결 방법의 실마리를 찾아보자.

다른 언어는 null 대신 무얼 사용하나?

그루비에서는 안전 내비게이션 연산자(?.)를 도입해서 null문제를 해결했다.

하스켈, 스칼라 등의 함수형 언어는 아예 다른 관점에서 null 문제를 접근한다. 하스켈은 선택형값(Optional value)을 저장할 수 있는 Maybe라는 형식을 제공한다. Maybe는 주어진 형식의 값을 갖거나 아니면 아무 값도 갖지 않을 수 있다. 따라서 null 참조 개념은 자연스럽게 사라진다. 스칼라도 T 형식의 값을 갖거나 아무 값도 갖지 않을 수 있는 Option[T]라는 구조를 제공한다. 그리고 Option 형식에서 제공하는 연산을 사용해서 값이 있는지 여부를 명시적으로 확인해야 한다. 형식 시스템에서 이를 강제하므로 null과 관련한 문제가 일어날 가능성이 줄어든다.

자바 8은 '선택형 값' 개념의 영향ㅇ을 받아서 java.util.Optional<T>라는 새로운 클래스를 제공한다. 이 장에서는 java.util.Optioanl<T>를 이용해서 값이 없는 상황을 모델링하는 방법을 설명한다. 또한 null을 Optional로 바꿀 때 우리 도메인 모델에서 선택형 값에 접근하고, 새로운 기능을 효과적으로 사용하는 방법을 보여주는 몇 가지 예제를 소개한다. 궁극적으로 더 좋은 API를 설계하는 방법을 취득하게 된다. 즉, 우리 API 사용자는 메서드의 시그니처만 보고도 선택형값을 기대해야 하는지 판단할 수 있다.

Optional 클래스 소개

자바 8은 하스켈, 스칼라의 영향을 받아 java.util.Optional<T> 라는 새로운 클래스를 제공한다. Optioanl은 선택형값을 캡슐화하는 클래스다.

값이 있으면 Optioanl 클래스는 값을 감싼다 반면 값이 없으면 Optioanl.empty 메서드로 Optional을 반환한다. Optional.empty는 Optional의 특별한 싱글턴 인스턴스를 반환하는 정적 팩토리 메서드다. null 참조와 Optioanl.empty()는 서로 무엇이 다른지 궁금할 것이다. 의미상으론 둘이 비슷하지만 실제로는 차이점이 많다. null을 참조하려 하면 NPE가 발생하지만 Optional.empty()는 Optional 객체이므로 이를 다양한 방식으로 활용할 수 있다.

null 대신 Optioanl을 사용하면서 Car 형식이 Optional<Car>로 바뀌었다. 이는 값이 없을 수 있음을 명시적으로 보여준다. 반면 Car 형식을 사용하면 Car에 null 참조가 할당될 수 있는데 이것이 올바른 값인지 아니면 잘못된 값인지 판단할 아무 정보도 없다.

이제 Optioanl을 이용해서 앞의 예제 코드를 고칠 수 없다.

public class Person {
    private Optional<Car> car;

    public Optional<Car> getCar() {
        return car;
    }
}

public class Car {
    private Optional<Insurance> insurance;

    public Optional<Insurance> getInsurance() {
        return insurance;
    }
}

public class Insurance {
    private String name;

    public String getName() {
        return name;
    }
}

Optional 클래스를 사용하면서 모델의 의미 semantic가 더 명확해졌음을 확인할 수 있다. 사람은 자동차를 소유했을 수도 아닐 수도 있으며, 자동차는 보험에 가입되어 있을 수도 아닐 수도 있음을 명확히 설명한다. 또한 보험회사는 반드시 이름을 가져야 함을 보여준다. 따라서 보험회사 이름을 참조할 때 NPE가 발생할 수도 있다는 정보를 확인할 수 있다. 하지만 보험회사 이름이 null인지 확인하는 코드를 추가할 필요는 없다. 오히려 고쳐야 할 문제를 감추는 꼴이 되기 때문이다.

Optional을 이용하면 값이 없는 상황이 우리 데이터에 문제가 있는 것인지 아니면 알고리즘의 버그인지 명확하게 구분할 수 있다. 모든 null 참조를 Optional로 대치하는 것은 바람직하지 않다. Optional의 역할은 더 이해하기 쉬운 API를 설계하도록 돕는 것이다. 즉, 메서드의 시그니처만 보고도 선택형값인지 여부를 구별할 수 있다. Optioanl이 등장하면 이를 언랩해서 값이 없을 수 있는 상황에 적절하게 대응하도록 강제하는 효과가 있다.

Optioanl 적용 패턴

Optional 객체 만들기

Optioanl을 사용하려면 Optional 객체를 만들어야 한다. 다양한 방법으로 Optional 객체를 만들 수 있다.

빈 Optioanl

정적 팩토리 메서드 Optional.empty로 빈 Optional 객체를 얻을 수 있다.

Optional<Car> optCar = Optional.empty();

이제 car가 null이라면 즉시 NullPointerException이 발생한다. Optional을 사용하지 않았다면 car의 프로퍼티에 접근하려 할 때 에러가 발생했을 것이다.

null값으로 Optional 만들기

마지막으로 정적 팩토리 메서드 Optional.ofNullable로 null값을 저장할 수 있는 Optional을 만들 수 있다.

Optional<Car> optCar = Optional.ofNullable(car);

car가 null이면 빈 Optioanl 객체가 반환된다.

그런데 Optional에서 어떻게 값을 가져오는지는 아직 살펴보지 않았다. get 메서드를 이용해서 Optional의 값을 가져올 수 있는데, 이는 곧 살펴볼 것이다. 그런데 Optional이 비어있으면 get을 호출했을 때 예외가 발생한다. 즉, Optional을 잘못 사용하면 결국 null을 사용했을 때와 같은 문제를 겪을 수 있다. 따라서 먼저 Optional로 명시적인 검사를 제거할 수 있는 방법을 살펴본다. 곧 Optional에서 제공하는 기능이 스트림 연산에서 영감을 받았음을 알게 될 것이다.

맵으로 Optional의 값을 추출하고 변환하기

보통 객체의 정보를 추출할 때는 Optional을 사용할 때가 많다. 다음 코드처럼 이름 정보에 접근하기 전에 insurance가 null인지 확인해야 한다.

String name = null;
if(insurance != null) {
    name = insurance.getName();
}

이런 유형의 패턴에 사용할 수 있도록 Optional은 map 메서드를 지원한다.

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);

Optional의 map 메서드는 스트림의 map 메서드와 개념적으로 비슷하다. 스트림의 map은 스트림의 각 요소에 제공된 함수를 적용하는 연산이다. 여기서 Optional 객체를 최대 요소의 개수가 한 개 이하인 데이터 컬렉션으로 생각할 수 있다. Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다. Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다. Optional이 비어있으면 아무 일도 일어나지 않는다.

public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}

그러면 여러 메서드를 안전하게 호출하는데, 이 코드를 어떻게 활용할 수 있을까?
이제 flatMap이라는 Optional의 또 다른 메서드를 살펴보자!

flatMap으로 Optional 객체 연결

map을 사용하는 방법을 배웠으므로 다음처럼 map을 이용해서 코드를 재구현할 수 있다.

Optional<Person> optPerson = Optional.of(person);
Optional<String> name = optPerson.map(Person::getCar)
                                 .map(Car::getInsurance)
                                 .map(Insurance::getName);

안타깝게도 위 코드는 컴파일되지 않는다. 변수 optPeople의 형식을 Optional<People>이므로 map 메서드를 호출할 수 있다. 하지만 getCar는 Optional<Car> 형식의 객체를 반환한다. 즉, map 연산의 결과는 Optional<Optional<Car>> 형식의 객체다. getInsurance는 또 다른 Optional 객체를 반환하므로 getInsurance 메서드를 지원하지 않는다. 이와 같은 중첩 Optional 객체 구조를 보여준다.

하지만 flatMap은 인수를 받은 함수를 적용해서 생성된 각각의 스트림에서 콘텐츠만 남긴다. 즉, 함수를 적용해서 생성된 모든 스트림이 하나의 스트림으로 병합되어 평준화된다. 우리도 이차원 Optional을 일차원 Optional로 평준화해야 한다.

map 메서드였다면 Optional 내부에 다른 Optional 그리고 그 내부에 삼각형이 저장되었겠지만 flatMap 메서드 덕분에 이차원 Optional이 하나의 삼각형을 포함하는 하나의 Optional로 바뀐다.

Optional로 자동차의 보험회사 이름 찾기

Optional의 map과 flatMap을 살펴봤으니 이제 이를 실제로 사용해보자!

public String getCarInsuranceName(Optional<Person> person) {
    return person.flatMap(Person::getCar)
                 .flatMap(Car::getInsurance)
                 .map(Insurance::getName)
                 .orElse("Unknown");
}

Optional을 이용해서 값이 없는 상황을 처리하는 것이 어떤 장점을 제공하는지 확인할 수 있다. 즉, null을 확인하느라 조건 분기문을 추가해서 코드를 복잡하게 만들지 않으면서도 쉽게 이해할 수 있는 코드를 완성했다.

주어진 조건에 해당하는 사람이 없을 수 있기 때문에 따라서 Person 대신 Optional<Person>을 사용하도록 메서드 인수 형식을 바꿨다.
또한 Optional을 사용하므로 도메인 모델과 관련한 암묵적인 지식에 의존하지 않고 명시적으로 형식 시스템을 정의할 수 있었다. 정확한 정보 전달은 언어의 가장 큰 목표 중 하나다. Optional을 인수로 받거나 Optional을 반환하는 메서드를 정의한다면 결과적으로 이 메서드를 사용하는 모든 사람에게 이 메서드가 빈 값을 받거나 빈 결과를 반환할 수 있음을 잘 문서화해서 제공하는 것과 같다.

Optional을 이용한 Person / Car / Insurance 참조 체인

Person을 Optional로 감싼 다음에 flatMap(Person::getCar)를 호출했다. 이미 설명한 것처럼 이 호출을 두 단계의 논리적 과정으로 생각할 수 있다. 첫 번째 단계에서는 Optional 내부의 Person에 Function을 적용한다. 여기서는 Persondml getCar 메서드가 Function이다. getCar 메서드는 Optional<Car>를 반환하므로 Optional 내부의 Person이 Optional<Car>로 변환되면서 중첩 Optional이 생성된다. 따라서 flatMap 연산으로 Optional을 평준화한다. 평준화 과정이란 이론적으로 두 Optional을 합치는 기능을 수행하면서 둘 중 하나라도 null이면 빈 Optional을 생성하는 연산이다. flatMap을 빈 Optional에 호출하면 아무 일도 일어나지 않고 그대로 반환된다. 반면 Optional이 Person을 감싸고 있다면 flatMap에 전달된 Function이 Person에 적용된다. Function을 적용한 결과가 이미 Optional이므로 flatMap 메서드는 그대로 반환할 수 있다.

이어서 Optional<Car>를 Optional<Car>를 Optional<Insurance>로 변환한다. Optional<Insurance>를 Optional<String>으로 변환한다. 세 번째 단계에서 Insurance.getName()은 String을 반환하므로 flatMap을 사용할 필요가 없다. 호출 체인 중 어떤 메서드가 빈 Optional을 반환한다면 전체 결과로 빈 Optional을 반환하고 아니면 관련 보험회사의 이름을 포함하는 Optional을 반환한다. 이제 반환된 Optional의 값을 어떻게 읽을 수 있을까? 호출 체인의 결과로 Optional<String>이 반환되는데 여기에 회사 이름이 저장되어 없을 수도 있다. Optional이 비어있을 때 기본값(default value)을 제공하는 orElse라는 메서드를 사용했다. Optional은 기본값을 제공하거나 Optional을 언랩(unwrap)하는 다양한 메서드를 제공한다. 이제 Optional에서 제공하는 다양한 기능을 자세히 살펴보자.

Optioanl 스트림 조작

자바 9에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional에 stream() 메서드를 추가했다. Optional 스트림을 값을 가진 스트림으로 변환할 때 이 기능을 유용하게 활용할 수 있다.

아래의 예제는 List<Person>을 인수로 받아 자동차를 소유한 사람들이 가입한 보험 회사의 이름을 포함하는 Set<String>을 반환하도록 메서드를 구현해야 한다.

public Set<String> getCarInsuranceNames(List<Person> persons) {
    return persons.stream()
                  .map(Person::getCar) // Optional<Car> 
                  .map(optCar -> optCar.flatMap(Car::getInsurance)) // Optional<Insurance>
                  .map(optIns -> optIns.map(Insurance::getName)) // Optional<String>으로 매핑 
                  .flatMap(Optional::stream) // String 
                  .collect(toSet());

보통 스트림 요소를 조작하려면 변화, 필터 등의 일련의 여러 긴 체인이 필요한데 이 예제는 Optional로 값이 감싸져있으므로 이 과정이 조금 더 복잡해졌다.

Optional 덕분에 이런 종류의 연산을 널 걱정없이 안전하게 처리할 수 있지만 마지막 결과를 얻으려면 빈 Optional을 제거하고 값을 언랩해야 한다는 것이 문제다.

Stream<Optional<String>> stream = ...
Set<String> result = stream.filter(Optional::isPresent)
                           .map(Optional::get)
                           .collect(toSet());

Optional 클래스의 stream() 메서드를 이용하면 한 번의 연산으로 같은 결과를 얻을 수 있다. 이 메서드는 각 Optional이 비어있는지 아닌지에 따라 Optional을 0개 이상의 항목을 포함하는 스트림으로 변환한다. 따라서 이 메서드의 참조를 스트림의 한 요소에서 다른 스트림으로 적용하는 함수로 볼 수 있으며 이를 원래 스트림에 호출하는 flatMap 메서드로 전달할 수 있다. 지금까지 배운것처럼 이런 방법으로 스트림의 요소를 두 수준인 스트림의 스트림으로 변환하고 다시 한 수준인 평면 스트림으로 바꿀 수 있다. 이 기법을 이용하면 한 단계의 연산으로 값을 포함하는 Optional을 언랩하고 비어있는 Optional을 건너뛸 수 있다.

디폴트 액션과 Optional 언랩

빈 Optional인 상황에서 기본값을 반환하도록 orElse로 Optional을 읽었다. Optional 클래스는 이 외에도 Optional 인스턴스에 포함된 값을 읽는 다양한 방법을 제공한다.

  • get()은 값을 읽는 가장 간단한 메서드면서 동시에 가장 안전하지 않은 메서드다.
  • orElse 메서드를 이용하면 Optional이 값을 포함하지 않을 때 기본값을 제공할 수 있다.
  • orElseGet(Supplier<? extends T> other)는 orElse 메서드에 대응하는 게으른 버전의 메서드다. Optional에 값이 없을 때만 Supplier가 실행되기 때문이다.
  • orElseThrow(Suppler<? extends X> exceptionSupplier)는 Optional이 비어있을 때 예외를 발생시킨다.
  • ifPresent(Consumer<? super T> consumer)를 이용하면 값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다.
  • ifPresnetOrElse(Consumer<? super T> action, Runnable emptyAction) 이 메서드는 Optional이 비었을 때 실행할 수 있는 Runnable을 인수로 받는다는 점만 ifPresnet와 다르다.

두 Optional 합치기

이제 Person과 Car 정보를 이용해서 가장 저렴한 보험료를 제공하는 보험회사를 찾는 몇몇 복잡한 비즈니스 로직을 구현한 외부서비스가 있다고 가정하자.

이제 두 Optional을 인수로 받아서 Optional<Insurance>를 반환하는 null 안전 버전의 메서드를 구현해야 한다고 가정하자. 인수로 전달한 값 중 하나라도 비어있으면 빈 Optional<Insurance>를 반환한다. Optional 클래스는 Optional이 값을 포함하는지 여부를 알려주는 isPresent라는 메서드도 제공한다. 따라서 isPresent를 이용해서 다음처럼 코드를 구현할 수 있다.

public Optional<Insurance> nullSafeFindCheapestInsurance {
        Optional<Person> person, Optional<Car> car) {
    if (person.isPresent() && car.isPresent()) {
        return Optional.of(findCheapesInsurance(person.get(), car.get()));
    } else {
        return Optional.empty();
    }
}

이 메서드의 장점은 person과 car의 시그니처만으로 둘 다 아무값도 변환하지 않을 수 있다는 정보를 명시적으로 보여준다는 것이다. 안타깝게도 구현 코드는 null 확인 코드와 크게 다른점이 없다. Optional 클래스에서 제공하는 기능을 이용해서 이 코드를 더 자연스럽게 개선해볼 수 없을까?

map과 flatMap 메서드를 이용해서 기존의 nullSafeFindCheapestInsurance() 메서드를 한 줄의 코드로 변경할 수 있다.

public Optional<Insurance> nullSafeFindCheapestInsurance(
        Optional<Person> person, Optional<Car> car) {
    return person.flatMap(p -> car.map(c -> findCheapestInsurance(p, c)));
}

필터로 특정값 거르기

종종 객체의 메서드를 호출해서 어떤 프로퍼티를 확인해야 할 때가 있다.

이는 stream과 유사하게 filter 메서드를 이용해서 필터를 할 수 있고, filter 메서드는 프레디케이트를 인수로 받는다. Optional 객체가 값을 가지며 프레디케이트와 일치하면 filter 메서드는 그 값을 반환하고 그렇지 않으면 빈 Optional 객체를 반환한다. Optional은 최대 한 개의 요소를 포함할 수 있는 스트림과 같다는 관점에서 쉽게 이해할 수 있을 것이다.

Optional을 사용한 실용 예제

새 Optional 클래스를 효과적으로 이용하려면 잠재적으로 존재하지 않는 값의 처리 방법을 바꿔야 한다. 즉, 네이티브 자바 API의 상호작용하는 방식도 바꿔야 한다. 기존 자바 API는 Optional을 적절하게 활용하지 못하고 있다. 그렇다고 기존 API에서 Optional을 사용할 수 없는 것은 아니다. Optioanl 기능을 활용할 수 있도록 우리 코드에 작은 유틸리티 메서드를 추가하는 방식으로 이 문제를 해결할 수 있다. 지금부터 몇 가지 실용 예제를 살펴보자.

잠재적으로 null이 될 수 있는 대상을 Optional로 감싸기

기존의 자바 API에서는 null을 반환하면서 요청한 값이 없거나 어떤 문제로 계산에 실패했음을 알린다. 지금까지 살펴본 것처럼 null을 반환하는 것보다는 Optional을 반환하는 것이 더 바람직하다. get 메서드의 시그니처는 우리가 고칠 수 없지만 get 메서드의 반환값은 Optional로 감쌀 수 있다. Map<String, Object> 형식의 맵이 있는데, 다음처럼 key로 값에 접근한다고 가정하자.

Object value = map.get("key");

문자열 'key'에 해당하는 값이 없으면 null이 반환될 것이다. map에서 반환하는 값을 Optional로 감싸서 이를 개선할 수 있다. 코드가 복잡하기는 하지만 기존처럼 if-then-else를 추가하거나, 아니면 아래와 같이 깔끔하게 Optional.ofNullable을 이용하는 두 가지 방법이 있다.

Optional<Object> value = Optional.ofNullable(map.get("key"));

이와 같은 코드를 이용해서 null일 수 있는 값을 Optional로 안전하게 변환할 수 있다.

예외와 Optional 클래스

자바 API는 어떤 이유에서 값을 제공할 수 없을 때 null을 반환하는 대신 예외를 발생시킬 때도 있다. 이것에 대한 전형적인 예가 문자열을 정수로 변환하는 정적 메서드 Integer.parseInt(String)다. 이 메서드는 문자열을 정수로 바꾸지 못할 때 NumberFormatException을 발생시킨다. 즉, 문자열이 숫자가 아니라는 사실을 예외로 알리는 것이다. 기존에 값이 null일 수 있을떄는 if문으로 null 여부를 확인했지만 예외를 발생시키는 메서드에서는 try/catch 블록을 사용해야 한다는 점이 다르다.

정수로 변환할 수 없는 문자열 문제를 빈 Optional로 해결할 수 있다. 즉, parseInt가 Optional을 반환하도록 모델링할 수 있다. 물론 기존 자바 메서드 parseInt를 직접 고칠 수 없지만 다음 코드처럼 parseInt를 감싸는 작은 유틸리티 메서드를 구현해서 Optional을 반환할 수 있다.

public static Optional<Integer> stringToInt(String s) {
    try {
        return Optional.of(Integer.parseInt(s)); // 문자열을 정수로 변환할 수 있으면 정수로 변환된 값을 포함하는 Optioanl을 반환한다. 
    } catch (NumberFormatException e) {
        return Optional.empty(); // 그렇지 않으면 빈 Optional을 반환한다. 
    }
}

위와 같은 메서드를 포함하는 유틸리티 클래스 OptionalUtility를 만들기를 바란다. 그러면 필요할 때 OptionalUtility.stringToInt를 이용해서 문자열을 Optional<Integer>로 변환할 수 있다. 기존처럼 거추장스러운 try/catch 로직을 사용할 필요가 없다.

기본형 Optional을 사용하지 말아야 하는 이유

스트림처럼 Optional도 기본형으로 특화된 OptionalInt, OptionalLong, OptionalDouble 등의 클래스를 제공한다. 스트림을 배울때에는 스트림이 많은 요소를 가질 때는 기본형 특화 스트림을 이용해서 성능을 향상시킬 수 있다고 설명했다. 하지만 Optional의 최대 요소 수는 한 개이므로 Optional에서는 기본형 특화 클래스로 성능을 개선할 수 없다.

기본형 특화 Optional은 map, flatMap, filter 등을 지원하지 않으므로 기본형 특화 Optional을 사용할 것을 권장하지 않는다. 게다가 스트림과 마찬가지로 기본형 특봐 Optional로 생성한 결과는 다른 일반 Optional과 혼용할 수 없다.

응용

Optional 클래스의 메서드를 실제 업무에서 어떻게 활용할 수 있는지 살펴보자.
예를 들어 프로그램의 설정 인수로 Properties를 전달하고자 가정하자. 그리고 다음과 같은 Properties로 우리가 만든 코드를 테스트할 것이다.

Properties prop = new Properties();
props.setProperty("a", "5");
props.setProperty("b", "true");
props.setProperty("c", "-3");

이제 프로그램에서는 Properties를 읽어서 값을 초 단위의 지속 시간으로 해석한다. 다음과 같은 메서드 시그니처로 지속 시간을 읽는다.

public int readDuration(Properties props, String name)

지속 시간은 양수여야 하므로 문자열이 양의 정수를 가리키면 해당 정수를 반환하지만 그 외에는 0을 반환한다. 이를 다음과 같이 테스팅할 수 있다.

assertEquals(5, readDuration(param, "a"));
assertEquals(0, readDuration(param, "b"));
assertEquals(0, readDuration(param, "c"));
assertEquals(0, readDuration(param, "d"));

이들 어설션은 다음과 같은 의미를 갖는다. 프로퍼티 'a'는 양수로 변환할 수 있는 문자열을 포함하므로 readDuration 메서드는 5를 반환하다. 프로퍼티 'b'는 숫자로 변환할 수 없는 문자열을 포함하므로 0을 반환한다. 프로퍼티 'c'는 음수 문자열을 포함하므로 0을 반환한다. 'd'라는 이름의 프로퍼티는 없으므로 0을 반환한다.

public int readDuration(Properties props, String name) {
    String value = props.getProperty(name);
    if (value != null) {
        try {
            int i = Integer.parseInt(value);
            if (i > 0) { // 결과 숫자가 양수인지 확인한다. 
                return i;
            }
        } catch (NumberFormatException nfe) { }
    }
    return 0; //하나의 조건이라도 실패하면 0을 반환한다.  
}

예상대로 if문과 try/catch 블록이 중첩되면서 구현 코드가 복잡해졌고 가독성도 나빠졌다.

이는 아래와 같이 유틸리티 메서드를 이용해서 유연한 코드로 구현할 수 있다.

public int readDuration(Properties props, String name) {
    return Optional.ofNullable(props.getProperty(name))
                   .flatMap(OptionalUtility::stringToInt)
                   .filter(i -> i > 0)
                   .orElse(0);
}

Optional과 스트림에서 사용한 방식은 여러 연산이 서로 연결되는 데이터베이스 질의문과 비슷한 형식을 갖는다.

728x90
개발서적/모던 자바 인 액션

Chap09. 리팩터링, 테스팅, 디버깅

728x90

람다 표현식을 이용해 가독성과 유연성을 높이려면 기존 코드를 어떻게 리팩터링해야 하는지 설명한다. 또한 람다 표현식으로 전략, 템플릿 메서드, 옵저버, 의무 체인, 팩토리 등의 객체지향 디자인 패턴을 어떻게 간소화할 수 있는지도 살펴본다. 마지막으로 람다 표현식과 스트림 API를 사용하는 코드를 테스트하고 디버깅하는 방법을 설명한다.

가독성과 유연성을 개선하는 리팩터링

람다 표현식을 이용한 코드는 다양한 요구사항 변화에 대응할 수 있도록 동작을 파라미터화한다.

코드의 가독성을 높이려면 코드의 무선화를 잘하고, 표준 코딩 규칙을 준수하는 등의 노력을 기울여야 한다.

자바 8의 새 기능을 이용해 코드의 가독성을 높일 수 있다. 코드를 간결하고 이해하기 쉽게 만들 수 있다. 또한 메서드 참조와 스트림 API를 이용해 코드의 의도를 명확하게 보여줄 수 있다.

람다, 메서드 참조, 스트림을 활용해서 코드 가독성을 개선할 수 있는 간단한 세 가지 리팩터링 예제를 소개한다.

  • 익명 클래스 to 람다 표현식으로 리팩터링
  • 람다 표현식 to 메서드 참조로 리팩터링
  • 명령형 데이터 처리 to 스트림으로 리팩터링

익명 클래스를 람다 표현식으로 리팩터링하고

익명 클래스는 람다 표현식을 이용해서 간결하고, 가독성이 좋은 코드로 구현할 수 있었다. 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.

  1. 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미를 갖는다.
    익명 클래스에서 this는 익명클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.
  2. 익명 클래스는 감싸고 있는 클래스의 변수를 가리킬 수 있지만 람다 표현식으로는 변수를 가릴 수 없다. (섀도 변수shadow variable)
  3. 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래될 수 있다.
    익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라지기 때문이다. 이런 문제는 명시적 형변환 (타입) 람다식를 이용해서 모호함을 제거할 수 있다.

대부분의 IDE는 똑똑하게 이런 문제점들을 자동으로 해결해주니 너무 걱정하지는 말자! (갓텔리제이 💪)

람다 표현식을 메서드 참조로 리팩터링하기

람다 표현식은 쉽게 전달할 수 있는 짧은 코드다. 하지만 람다 표현식 대신 메서드 참조를 이용하면 가독성을 높일 수 있다. 메서드 참조의 메서드명으로 코드의 의도를 명확하게 알릴 수 있기 때문이다.

또한 comparing과 maxBy 같은 정적 헬퍼 메서드를 활용하는 것도 좋다. 이들은 메서드 참조와 조화를 이루도록 설계되었다.
sum, maximum 등 자주 사용하는 리듀싱 연산은 메서드 참조와 함께 사용할 수 있는 내장 헬퍼 메서드를 제공한다. 예를 들어 최댓값이나 합계를 계산할 때 람다 표현식과 저수준 리듀싱 연산을 조합하는 것보다 Collectors.API를 사용하면 코드의 의도가 더 명확해진다.

내장 컬렉터를 이용하면 코드 자체로 문제를 더 명확하게 설명할 수 있다. 다음 코드에서는 컬렉터 summingInt를 사용했다.

명령형 데이터 처리를 스트림으로 리팩터링하기

이론적으로는 반복자를 이용한 기존의 모든 컬렉션 처리 코드를 스트림 API로 바꿔야 한다. 스트림 API는 데이터 처리 파이프라인의 의도를 더 명확하게 보여준다. 스트림은 쇼트서킷게으름이라는 강력한 최적화뿐 아니라 멀티코어 아키텍처를 활용할 수 있는 지름길을 제공한다.

코드 유연성 개선

람다 표현식을 이용하면 동작 파라미터화를 쉽게 구현할 수 있음을 살펴봤다. 즉, 다양한 람다를 전달해서 다양한 동작을 표현할 수 있다. 따라서 변화하는 요구사항에 대응할 수 있는 코드를 구현할 수 있다.

함수형 인터페이스 적용

람다 표현식을 이용하려면 함수형 인터페이스가 필요하다. 따라서 함수형 인터페이스를 코드에 추가해야 한다. 이번에는 조건부 연기 실행실행 어라운드, 즉 두 가지 자주 사용하는 패턴으로 람다 표현식 리팩터링을 살펴본다.

조건부 연기 실행

실제 작업을 처리하는 코드 내부에 제어 흐름문이 복잡하게 얽힌 코드를 흔히 볼 수 있다. 흔히 보안 검사나 로깅 관련 코드가 이처럼 사용이된다. 다음은 내장 자바 Logger 클래스를 사용하는 예제다.

if (logger.isLoggable(Log.FINER) {
    logger.finer("Problem: " + generateDiagnostic());
}

위 코드는 다음과 같은 사항에 문제가 생긴다.

  • logger의 상태가 isLoggable이라는 메서드에 의해 클라이언트 코드로 노출된다.
  • 메시지를 로깅할 때마다 logger 객체의 상태를 매번 확인해야 할까? 이들은 코드를 어지럽힐 뿐이다.

다음처럼 메시지를 로깅하기 전에 logger 객체가 적절한 수준으로 설정되었는지 내부적으로 확인하는 log 메서드를 사용하는 것이 바람직하다.

logger.log(Lever.FINER, "Problem: " + generateDiagnostic());

덕분에 불필요한 if문을 제거할 수 있고 logger의 상태를 클라이언트에 노출할 필요도 없으므로 위 코드가 더 바람직한 구현이다. 하지만 위 코드도 완전하지는 않다. 인수로 전달된 메시지 수준에서 logger가 활성화되어 있지 않더라도 항상 로깅 메시지를 평가하게 된다.

람다를 이용하면 이 문제를 쉽게 해결할 수 있다. 특정 조건에서만 메시지가 생성될 수 있도록 메시지 생성 과정을 연기defer 할 수 있어야 한다. 자바 8 API 설계자는 이와 같은 logger 문제를 해결할 수 있도록 Supplier를 인수로 갖는 오버로드된 log메서드를 제공했다. 다음은 새로 추가된 log 메서드의 시그니처다.

public void log(Level level, Supplier<String> msgSupplier)

다음처럼 log 메서드를 호출할 수 있다.

logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());

log 메서드는 logger의 수준이 적절하게 설정되어 있을 때만 인수로 넘겨진 람다를 내부적으로 실행한다. 다음은 log 메서드의 내부 구현 코드다.

public void log(Level level, Supplier<String> msgSupplier) {
    if(logger.isLoggable(level)) {
        log(level, msgSupplier.get()); // 람다 실행
    }
}

이 기법으로 어떤 문제를 해결할 수 있을까? 만일 클라이언트 코드에서 객체 상태를 자주 확인하거나 객체의 일부 메서드를 호출하는 상황이라면 내부적으로 객체의 상태를 확인한 다음에 메서드를 호출하도록 새로운 메서드를 구현하는 것이 좋다. 그러면 코드 가독성이 좋아질 뿐 아니라 캡슐화도 강화된다.

실행 어라운드

매번 같은 준비, 종료 과정을 반복적으로 수행하는 코드가 있다면 이를 람다로 변환할 수 있다. 준비, 종료 과정을 처리하는 로직을 재사용함으로써 코드 중복을 줄일 수 있다.

람다로 객체지향 디자인 패턴 리팩토링하기

언어에 새로운 기능이 추가되면서 기존 코드 패턴이나 관용코드의 인기가 식기도 한다. 다양한 패턴을 유형별로 정리한 것이 디자인 패턴(design pattern)이다. 디자인 패턴은 공통적인 소프트웨어 문제를 설계할 때 재사용할 수 있는, 검증된 청사진을 제공한다.

디자인 패턴에 람다 표현식이 더해지면 색다른 기능을 발휘할 수 있다. 즉, 람다를 이용하면 이전에 디자인 패턴으로 해결하던 문제를 더 쉽고 간단하게 해결할 수 있다. 또한 람다 표현식으로 기존의 많은 객체지향 디자인 패턴을 제거하거나 간결하게 재구현할 수 있다.

이 절에서는 다음 다섯 가지 패턴을 살펴본다.

  • 전략(strategy)
  • 템플릿 메서드(template method)
  • 옵저버(observer)
  • 의무 체인(chain of responsibility)
  • 팩토리(factory)

각 디자인 패턴에서 람다를 어떻게 활용할 수 있는지 설명한다.

전략

전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다.

아래의 그림에서 보여주는 것처럼 전략 패턴은 세 부분으로 구성된다.

image

  • 알고리즘을 나타내는 인터페이스 (Strategy 인터페이스)
  • 다양한 알고리즘을 나타내는 한 개 이상의 인터페이스 구현(ConcreteStrategyA, ConcreteStrategyB 같은 구체적인 구현 클래스)
  • 전략 객체를 사용하는 한 개 이상의 클라이언트

오직 소문자 또는 숫자로 이루어져야 하는 등 텍스트 입력이 다양한 조건에 맞게 포맷되어 있는지 검증한다고 가정하자. 먼저 String 문자열을 검증하는 인터페이스부터 구현한다.

public interface ValidationStrategy {
    boolean execute(String s);
}

이번에는 위에서 정의한 인터페이스를 구현하는 클래스를 하나 이상 정의한다.

public class IsAllLowerCase implements ValidationStrategy {
    public boolean execute(String s) {
        return s.matches("[a-z]+");
    }
}

public class IsNumeric implements ValidationStrategy {
    public boolean execute(String s) {
        return s.metches("\\d+");
    }
}

지금까지 구현한 클래스를 다양한 검증 전략으로 활용할 수 있다.

public class Validator {
    private final ValidationStrategy strategy;

    public Validator(ValidationStrategy v) {
        this.strategy = v;
    }

    public boolean validate(String s) {
        return strategy.execute(s);
    }
}

Validator numericValidator = new Validator(new IsNumeric());
boolean b1 = numericValidator.validate("aaaa");
Validator lowerCaseValidator = new Validator(new IsAllLowerCase());
boolean b2 = lowerCaseValidator.validate("bbbb");

람다 표현식 사용

ValidationStrategy는 함수형 인터페이스며 Predicate<String>과 같은 함수 디스크립터를 갖고 있음을 파악했을 것이다. 따라서 다양한 전략을 구현하는 새로운 클래스를 구현할 필요없이 람다 표현식을 직접 전달하면 코드가 간결해진다.

Validator numericValidator = new Validator((String s) -> s.matches("[a-z]+")); // 람다를 직접 전달
boolean b1 = numericValidator.validate("aaaa");
Validator lowerCaseValidator = new Validator((String s) -> s.matches("\\d+")); // 람다를 직접 전달 
boolean b2 = lowerCaseValidator.validate("bbbb");

템플릿 메서드

알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용한다. 템플릿 메서드는 이 알고리즘을 사용하고 싶은데 그대로는 안 되고 조금 고쳐야 하는 상황에 적합하다.

예를 들어 간단한 온라인 뱅킹 애플리케이션을 구현한다고 가정하자. 사용자가 고객 ID를 애플리케이션에 입력하면 은행 데이터베이스에서 고객 정보를 가져오고 고객이 원하는 서비스를 제공할 수 있다. 은행마다 다양한 온라인 뱅킹 애플리케이션을 사용하며 동작 방법도 다르다. 다음은 온라인 뱅킹 애플리케이션의 동작을 정의하는 추상 클래스다.

abstract class OnlineBanking {
    public void processCustomer(int id) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy(c);
    }
    abstract void makeCustomerHappy(Customer c);
}

processCustomer 메서드는 온라인 뱅킹 알고리즘이 해야 할 일을 보여준다. 우선 주어진 고객 ID를 이용해서 고객을 만족시켜야 한다. 각각의 지점은 OnlineBanking 클래스를 상속받아 makeCustomerHappy 메서드가 원하는 동작을 수행하도록 구현할 수 있다.

람다 표현식 사용

람다를 이용해서 문제를 해결할 수 있다. 람다나 메서드 참조로 알고리즘에 추가 할 다양한 컴포넌트를 구현할 수 있다. 이전에 정의한 makeCustomerHappy의 메서드 시그니처와 일치하도록 Consumer<Customer> 형식을 갖는 두 번째 인수를 processCustomer에 추가한다.

public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
    Customer c = Database.getCustomerWithId(id);
    makeCustomerHappy.accept(c);
}

이제 onlineBanking 클래스를 상속받지 않고 직접 람다 표현식을 전달해서 다양한 동작을 추가할 수 있다.

new OnlineBankingLambda().processCustomer(1337, (Customer c) -> System.out.println("Hello " + c.getName());

람다 표현식을 이용하면 템플릿 메서드 디자인 패턴에서 발생하는 자잘한 코드도 제거할 수 있다.

옵저버

어떤 이벤트가 발생했을 때 한 객체는 다른 객체(주제라 불리는) 리스트(옵저버라 불리는)에 자동으로 알림을 보내야 하는 상황에서 옵저버 디자인 패턴을 사용한다. GUI 애플리케이션에 옵저버 패턴이 자주 등장한다.

예를 들어 주식의 가격 변동에 반응하는 다수의 거래자 예제에서도 옵저버 패턴을 사용할 수 있다. 아래의 그림은 옵저버 패턴의 UML 다이어그램을 보여준다.

image

실제 코드로 옵저버 패턴이 어떻게 동작하는지 살펴보자. 옵저버 패턴으로 트위터 같은 커스터마이즈된 알림 시스템을 설계하고 구현할 수 있다.

우선 다양한 옵저버를 그룹화할 Observer 인터페이스가 필요하다. Observer 인터페이스는 새로운 트윗이 있을 때 주제가 호출할 수 있도록 notify라고 하는 하나의 메서드를 제공한다.

interface Observer {
    void notify(String tweet);
}

이제 트윗에 포함된 다양한 키워드에 다른 동작을 수행할 수 있는 여러 옵저버를 정의할 수 있다.

class NYTimes implements Observer {
    public void notify(String tweet) {
        if(tweet != null && tweet.contains("money")) {
            System.out.println("Breaking news in NY! " + tweet);
        }
    }
}

class Guardian implements Observer {
    public void notify(String tweet) {
        if(tweet != null && tweet.contains("queen")) {
            System.out.println("Today cheese, wine and news"! " + tweet);
        }
    }
}

그리고 주제도 구현해야 한다. 다음은 Subject 인터페이스의 정의다.

interface Subject {
    void registerObserver(Observer o);
    void notifyObservers(String tweet);
}

주제는 registerObserver 메서드로 새로운 옵저버를 등록한 다음에 notifyObservers 메서드로 트윗의 옵저버에 이를 알린다.

class Feed implements Subject {
    private final List<Observer> observers = new ArrayList<>();

    public void registerObserver(Observer o) {
        this.observers.add(o);
    }

    public void notifyObservers(String tweet) {
        observers.forEach(o -> o.notify(tweet));
    }
}

구현은 간단하다. Feed는 트윗을 받았을 때 알림을 보낼 옵저버 리스트를 유지한다. 이제 주제와 옵저버를 연결하는 데모 애플리케이션을 만들 수 있다.

Feed f = new Feed();
f.registerObserver(new NYTimes());
f.registerObserver(new Guardian());
f.registerObserver(new LeMonde());
f.notifyObservers("The queen said her favourite book is Moder Java in Action!");

람다 표현식 사용하기

여기서 Observer 인터페이스를 구현하는 모든 클래스는 하나의 메서드 notify()를 구현했다. 즉, 트윗이 도착했을 때 어떤 동작을 수행할 것인지 감싸는 코드를 구현한 것이다. 지금까지 살펴본 것처럼 람다는 불필요한 감싸는 코드 제거를 제거해준다. 즉, 세 개의 옵저버를 명시적으로 인스턴스화하지 않고 람다 표현식을 직접 전달해서 실행할 동작을 지정할 수 있다.

f.registerObserver((String tweet) -> {
    if(tweet != null && tweet.contains("money")) {
        System.out.prinln(" Breaking news in NY! " + tweet);
    }
});
f.registerObserver((String tweet) -> {
    if(tweet != null && tweet.contains("queen")) {
        System.out.prinln(" Yet more news from London... + " + tweet);
    }
});

그렇다면 항상 람다 표현식을 사용해야 할까? 물론 아니다. 만약 옵저버가 상태를 가지며, 여러 메서드를 정의하는 등 복잡하다면 람다 표현식보다 기존의 클래스 구현방식을 고수하는 것이 바람직할 수도 있다.

의무 체인

작업 처리 객체의 체인을 만들 때는 의무 체인 패턴을 사용한다. 한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달하는 식이다. 일반적으로 다음으로 처리할 객체 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성한다. 작업 처리 객체가 자신의 작업을 끝냈으면 다음 작업 처리 객체로 결과를 전달한다.

다음은 작업 처리 객체 예제 코드다.

public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor;

    public void setSuccessor(ProcessingObject<T> successor) {
        this.sucessor != null;
    }

    public T handle(T input) {
        T r = handleWork(input);
        if(successor != null) {
            return successor.handle(r);
        }
        return r;
    }

    abstract protected T handleWork(T input);
}

image

위의 그림을 자세히 보면 이전에 다루었던, 템플릿 메서드 디자인 패턴이 사용되었음을 알 수 있다. handle 메서드는 일부 작업을 어떻게 처리해야 할지 전체적으로 기술한다. ProcessingObject 클래스를 상속받아 handleWork 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들 수 있다.

이 패턴을 어떻게 활용할 수 있는지 실질적인 예제를 살펴보자. 다음의 두 작업 처리 객체는 텍스트를 처리하는 예제이다.

public class HeaderTextProcessing extends ProcessingObject<String> {
    public String handleWork(String text) {
        return "From Raoul, Mario and Alan: " + text;
    }
}

public class SpellCheckerProcessing extends ProcessingObject<String> {
    public String handleWork(String text) {
        return text.replaceAll("labda", "lambda");
    }
}

두 작업 처리 객체를 연결해서 작업 체인을 만들 수 있다.

ProcessingObject<String> p1 = new HeaderTextProcessing();
ProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSuccessor(p2); // 두 작업 처리 객체를 연결한다. 
String result = p1.handle("Aren't labdas really sexy?!!");
System.out.println(result);

람다 표현식 사용

이 패턴은 함수 체인과 비슷하다! 작업 처리 객체를 Function<String, String>, 더 정확히 말하자면 UnaryOperator<String> 형식의 인스턴스로 표현할 수 있다. andThen 메서드로 이들 함수를 조합해서 체인을 만들 수 있다.

UnaryOperator<String> headerProcessing = 
    (String text) -> "From Raoul, Mario and Alan: " + text;
UnaryOperator<String> spellCheckerProcessing = 
    (String text) -> text.replaceAll("labda", "lambda");
Function<String, String> pipeline = 
    headerProcessing.andThen(spellCheckerProcessing); // 동작 체인으로 두 함수를 조합한다. 
String result = pipeline.apply("Aren't labdas really sexy?!!");

팩토리

인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다. 예를 들어 은행에서 취급하는 대출, 채권, 주식 등 다양한 상품을 만들어야 한다고 가정하자.

다음 코드에서 보여주는 것처럼 다양한 상품을 만드는 Factory 클래스가 필요하다.

public class ProductFactory {
    switch(name) {
        case "loan" : return new Load();
        case "stock" : return new Stock();
        case "bond" : return new Bond();
        default: throw new RuntimeException("No such product " + name):
    }
}

여기서 createProduct 메서드는 생상된 상품을 설정하는 로직을 포함할 수 있다. 이는 부가적인 기능일 뿐 위 코드의 진짜 장점은 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 생산할 수 있다는 것이다.

Product p = ProductFactory.createProduct("load");

람다 표현식 사용

예를 들어 다음은 Loan 생성자를 사용하는 코드다.

Supplier<Product> loanSupplier = Loan::new; Loan loan = loanSupplier.get();

이제 다음 코드처럼 상품명을 생성자로 연결하는 Map을 만들어서 코드를 구현할 수 있다.

final static Map<String, Supplier<Product>> map = new HashMap<>();

static {
    map.put("loan", Loan::new);
    map.put("stock", Stock::new);
    map.put("bond", Bond::new);
}

이제 Map을 이용해 팩토리 디자인 패턴에서 했던 것처럼 다양한 상품을 인스턴스화할 수 있다.

public static Product createProduct(String name) {
    Supplier<Product> p = map.get(name);
    if(p != null) return p.get();
    throw new IllegalArgumentException("No such product + " + name);
}

팩토리 패턴이 수행하던 작업을 자바 8의 새로운 기능으로 깔끔하게 정리했다. 하지만 팩토리 메서드 createProduct가 상품 생성자로 여러 인수를 전달하는 상황에서는 이 기법을 적용하기 어렵다. 단순한 Supplier 함수형 인터페이스로는 이 문제를 해결할 수 없다.

예를 들어 세 인수를 받는 경우에는 세 인수를 지원하기위해 TriFunction이라는 특별한 함수형 인터페이스를 만들어야 한다. 결국 Map의 시그니처가 복잡해진다.

람다 테스팅

개발자의 최종 엄무 목표는 제대로 작동하는 코드를 구현하는 것이지 깔끔한 코들르 구현하는 것이 아니다.

일반적으로 좋은 소프트웨어 공학자라면 프로그램이 의도대로 동작하는지 확인할 수 있는 단위 테스팅을 진행한다. 우리는 소스 코드의 일부가 예상된 결과를 도출할 것이다. 단언하는 테스트 케이스를 구현한다. 예를 들어 다음처럼 그래픽 애플리케이션의 일부인 Point 클래스가 있다고 가정하자.

public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }
    public Point moveRightBy(int x) {
        return new Point(this.x + x, this.y);
    }
}

다음은 moveRightBy 메서드가 의도한 대로 동작하는지 확인하는 단위 테스트이다.

@Test
public void testMoveRightBy() throws Exception {
    Point p1 = new Point(5, 5);
    Point p2 = p1.moveRightBy(10);
    assertEquals(15, p2.getX());
    assertEquals(5, p2.gety());
}

이 테스트는 문제없이 작동한다. 하지만 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 하지만 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 따라서 필요하다면 람다를 필드에 저장해서 재사용할 수 있으며 람다의 로직을 테스트할 수 있다.

람다 표현식은 함수형 인터페이스의 인스턴스를 생성한다는 사실을 기억하자. 따라서 생성된 인스턴스의 동작으로 람다 표현식을 테스트할 수 있다. 다음은 Comparator 객체 compareByXAndThenY 에 다양한 인수로 compare 메서드를 호출하면서 예상대로 동작하는지 테스트하는 코드다.

@Test
public void testComparingTwoPoints() throws Exception {
    Point p1 = new Point(10, 15);
    Point p2 = new Point(10, 20);
    int result = Point.compareByXAndThenY.compare(p1, p2);
    assertTrue(result < 0);
}

람다를 사용하는 메서드의 동작에 집중하라

람다의 목표는 정해진 동작을 다른 메서드에 사용할 수 있도록 하나의 조각으로 캡슐화하는 것이다. 그러려면 세부 구현을 포함하는 람다 표현식을 공개하지 말아야 한다. 람다 표현식을 사용하는 메서드의 동작을 테스트함으로써 람다를 공개하지 않으면서도 람다 표현식을 검증할 수 있다.

public static List<Point> moveAllPointsRightBy(List<Point> points, int x) {
    return points.stream()
                .map(p -> new Point(p.getX() + x, p.getY())
                .collect(toList());
}

위 코드에 람다 표현식 p -> new Point(p.getX() + x, p.getY()); 를 테스트하는 부분은 없다. 그냥 moveAllPointsRightBy 메서드를 구현한 코드일 뿐이다. 이제 moveAllPointsRightBy 메서드의 동작을 확인할 수 있다.

@Test
public void testMoveAllPointsRightBy() throws Exception {
    List<Point> points = Arrays.asList(new Point(5, 5), new Point(10, 5));
    List<Point> expectedPoints = Arrays.asList(new Point(15, 5), new Point(20, 5));
    List<Point> newPoints = Point.moveAllPointsRightBy(points,10);
    assertEquals(expectedPoints, newPoints);
}

위 단위 테스트에서 보여주는 것처럼 Point 클래스의 equals 메서드는 중요한 메서드다. 따라서 Object의 기본적인 equals 구현을 그대로 사용하지 않으려면 equals 메서드를 적절하게 구현해야 한다.

복잡한 람다를 개별 메서드로 분할하기

복잡한 람다 표현식을 접할 수 있다. 그런데 테스트 코드에서 람다 표현식을 참조할 수 없는데, 복잡한 람다 표현식을 어떻게 테스트할 것인가? 한 가지 해결책은 람다 표현식을 메서드 참조로 바꾸는 것이다. 그러면 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있다.

고차원 함수 테스팅

함수를 인수로 받거나 다른 함수를 반환하는 메서드는 좀 더 사용하기 어렵다. 메서드가 람다를 인수로 받는다면 다른 람다로 메서드의 동작을 테스트할 수 있다.

@Test
public void testFilter() throws Exception {
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
    List<Integer> even = filter(numbers, i -> i % 2 == 0);
    List<Integer> smallerThanThree = filter(numbers, i -> i < 3);
    assertEquals(Arrays.asList(2, 4), even);
    assertEquals(Arrays.asList(1, 2), smallerThanThree);
}

테스트해야 할 메서드가 다른 함수를 반환한다면 어떻게 해야 할까? 이때는 Comparator에서 살펴봤던 것러럼 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트할 수 있다.

디버깅

문제가 발생한 코드를 디버깅할 때 개발자는 다음 두 가지를 가장 먼저 확인한다.

  • 스택 트레이스
  • 로깅

하지만 람다 표현식과 스트림은 기존의 디버깅 기법을 무력화한다.

람다와 스택 트레이스

유감 스럽게도 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다. 다음은 고의적으로 문제를 일으키도록 구현한 간단한 코드다.

import java.util.*;

public class Debugging {
    public static void main(String[] args) {
        List<Point> points = Arrays.asList(new Point(12, 2), null);
        points.stream().map(p -> p.getX()).forEach(System.out::println);
    }
}

프로그램을 실행하면 다음과 같은 스택 트레이스가 출력된다.

Exception in thread "main" java.lang.NullPointerException
at Debugging.lambda$0(Debugging.java:6)
at Debugging$$Lambda$5/284720968.apply(Unknow Source)
at java.util.stream.ReferencePipeline$3$1 .accept(ReferencePipeline.java:193)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)

points 리스트의 둘째 인수가 null이므로 프로그램의 실행이 멈췄다. 스트림파이프라인에서 에러가 발생했으므로 스트림 파이프라인 작업과 관련된 전체 메서드 호출 리스트가 출력되었다. 이와 같은 이상한 문자는 람다 표현식 내부에서 에러가 발생했음을 가리킨다. 람다 표현식은 이름이 없으므로 컴파일러가 람다를 참조하는 이름을 만들어낸 것이다. lambda$main$0는 다소 생소한 이름이다. 클래스에 여러 람다 표현식이 있을 때는 골치 아픈 일이 벌어진다.

메서드 참조를 사용해도 스택 트레이스에는 메서드명이 나타나지 않는다. 메서드 참조를 사용하는 클래스와 같은 곳에 선언되어 있는 메서드를 참조할 때는 메서드 참조 이름이 스택 트레이스에 나타난다.

따라서 람다 표현식과 관련한 스택 트레이스는 이해하기 어려울 수 있다는 점을 인지하자!

정보 로깅

스트림의 파이프라인 연산을 디버깅한다고 가정했을 때 무엇을 할 수 있을까? 스트림은 최종연산 구문을 호출하는 순간 전체 스트림이 바로 소비된다. 스트림 파이프라인에 적용된 각각의 연산(map, filter, limit)이 어떤 결과를 도출하는지 확인할 수 있다면 좋을 것 같다. 바로 이때, peek이라는 스트림 연산을 활용할 수 있다. peek은 스트림의 각 요소를 소비할 것처럼 동작을 실행한다. 하지만 forEach처럼 실제로 스트림의 요소를 소비하지는 않는다. peek는 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.

728x90

'개발서적 > 모던 자바 인 액션' 카테고리의 다른 글

Chap 12 - 새로운 날짜와 시간 API  (0) 2021.03.19
Chap06. 스트림으로 데이터 수집  (0) 2021.03.05
Chap05. 스트림 활용  (0) 2021.03.05
Chap04. 스트림 소개  (0) 2021.02.26
Chap03. 람다 표현식  (0) 2021.02.25
카테고리 없음

Chap08. 컬렉션 API 개선

728x90

컬렉션 팩토리

기존에는 Arrays.asList() 팩토리 메서드르 이용하여 간단한 코드로 쉽게 List 요소를 생성했다. 이렇게 생성된 리스트는 크기가 고정된다. 즉 요소를 갱신할 수는 있지만 새 요소를 추가하거나 요소를 삭제할 순 없다. 만약 요소를 추가 및 삭제하는 작업을 수행하게 되면 UnsupportedOperationException 예외가 발생한다(필자의 경우에도 Arrays.asList의 크기가 고정된다는 사실을 까먹어서 테스트 코드가 깨지면 왜 깨지는지 오류를 못 찾고는 했다).

아쉽게도 Set 혹은 Map을 바로 생성할 수 있는 방법은 없다. 리스트를 인수로 받는 HashSet 생성자를 통해 그.나.마 집합을 바로 생성할 수도 있긴 하다. 이는 스트림 API를 통해 아래와 같이도 만들 수 있다.

Set<String> friends = Stream.of("Raphael", "Olivia", "Thibaut")
                            .collect(Collectors.toSet());

하지만 두 방법 모두 매끄럽지 못하고 불필요한 객체 할당을 필요로 한다. 그리고 결과는 변환할 수 있는 집합이라는 것 또한 문제이다.

하지만 자바 9에서는 이러한 문제를 해결해줄 수 있는 팩토리 메서드를 제공한다.

리스트 팩토리

List.of 팩토리 메서드를 이용해서 간단하게 리스트를 만들 수 있다. 팩토리 메서드로 생성한 List는 add로 요소를 추가하거나 set과같이 기존의 요소를 변경하는 작업을 시도하게 되면, UnsupportedOperationException 예외가 발생한다.

컬렉션이 의도치 않게 변하는 것을 막을 수 있기 때문에 이는 꼭 나쁜 제약이라고 볼 수 없다. 하지만 요소 자체가 변하는 것을 막을 수 있는 방법은 없다.

이전에 스트림 API를 통해 리스트를 만드는 방법을 배웠었는데, 그렇다면 언제 이 방식과 새로운 방식을 적재적소에 사용해야하는지 기준이 궁금할 것이다.

간단하다 데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면 간편한 팩토리 메서드를 이용할 것을 권장한다. 그 반대의 상황에서는 스트림 API를 활용하면 된다.

집합 팩토리

Set.of 팩토리 메서드를 통해 역시 간단하게 집합을 만들 수 있다. 만약 중복된 요소를 제공해 집합을 만드려고 하면 중복되는 특정 요소가 중복되어 있다는 설명과 함께 IllegalArgumentException 이 발생한다.

맵 팩토리

맵을 만드는 팩토리 메서드는 키와 값이 있어야 하기 때문에 집합과 리스트를 만드는 것에 비해서는 조금 복잡한 팩토리 메서드 구조를 가진다.

Map.of 팩토리 메서드에 을 번갈아 제공하는 방법으로 맵을 만들 수 있다.

java Map<String, Integer> ageOfFriends = Map.of("Raphael", 30, "Olivia", 25, "Thibaut", 26);

열 개 이하의 키와 값 쌍을 가진 작은 맵을 만들 때는 이 메서드가 유용하다. 하지만 그 이상의 맵에서는 Map.Entry<K, V> 객체를 인수로 받으며 가변 인수로 구현된 Map.ofEntries 팩토리 메서드를 이용하는 것이 좋다. 이 메서드는 키와 값을 감쌀 추가 객체 할당을 필요로한다.

import static java.util.Map.entry;

Map<String, Integer> ageOfFriends = Map.ofEntries(entry("Raphael", 30),
                                            entry("Olivia", 25),
                                            entry("Thibaut", 26));

Map.entry는 Map.Entry 객체를 만드는 새로운 팩토리 메서드다.

리스트와 집합 처리

자바 8에서는 List, Set 인터페이스에 다음과 같은 메서드를 추가했다.

  • removeIf: 프레디케이트를 만족하는 요소를 제거한다. List나 Set을 구현하거나 그 구현을 상속받은 모든 클래스에서 이용할 수 있다.

  • replaceAll: 리스트에서 이용할 수 있는 기능으로 UnaryOperator 함수를 이용해 요소를 바꾼다.

  • sort: List 인터페이스에서 제공하는 기능으로 리스트를 정렬한다.

위의 메서드들은 호출한 컬렉션 자체를 바꾼다. 이런 메서드가 추가된 이유는 컬렉션을 바꾸는 동작은 에러를 유발하며 복잡함을 더한다. 자바 8에서 removeIfreplaceAll를 추가한 이유가 바로 이 때문이다.

맵 처리

자바 8에서는 Map 인터페이스에 몇 가지 디폴트 메서드를 추가했다.

forEach 메서드

자바 8에서부터는 Map 인터페이스는 BiConsumer(키와 값을 인수로 받음)를 인수로 받는 forEach 메서드를 지원하므로 코드를 조금 더 간단하게 구현할 수 있다.

ageOfFriends.forEach((friend, age) -> System.out.println(firend + " is " + age + " years old"));

정렬 메서드

다음 두 개의 새로운 유틸리티를 이용하면 맵의 항목을 값 또는 키를 기준으로 정렬할 수 있다.

  • Entry.comparingByValue
  • Entry.comparingByKey

favouriteMovies는 Map<String, String> 타입의 값이다.

favouriteMoves
    .entrySet()
    .stream()
    .sorted(Entry.comparingByKey())
    .forEachOrdered(System.out::println);

요청한 키가 맵에 존재하지 않을 때는 getOrDefault 메서드를 이용하여 해결할 수 있다.

getOrDefault 메서드

기존에는 찾으려는 키가 존재하지 않으면 null이 반환되므로 NullPointerException을 방지하려면 null check가 필수적이었다. 하지만 기본값을 반환하는 방식으로 이 문제를 해결할 수 있다. getOrDefault 메서드를 이용하면 쉽게 이 문제를 해결할 수 있다. 이 메서드는 첫 번째 인수로 키를, 두 번째 인수로 기본값을 받으며 맵에 키가 존재하지 않으면 두 번째 인수로 받은 기본값을 반환한다.

키가 존재하더라도 값이 null인 상황에서는 getOrDefault가 널을 반환할 수 있다는 사실을 알고있어야 한다. 오직 키의 존재 여부만 가지고 두 번째 인수가 반환될지 아닐지가 결정된다.

계산 패턴

맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황이 필요한 때가 있다. 다음의 세 가지 연산이 이런 상황에서 도움을 준다.

  • computeIfAbsent: 제공된 키에 해당하는 값이 없으면 (값이 없거나 null), 키를 이용해 새 값을 계산하고 맵에 추가한다.
  • computeIfPresent: 제공된 키가 존재하면 새 값을 계산하고 맵에 추가한다.
  • compute: 제공된 키로 새 값을 계산하고 맵에 저장한다.

| computeIfAbset에 값을 만드는 함수로 null을 반환하면 현재 매핑을 맵에서 제거한다. 하지만 삭제는 remove 메서드를 오버라이드하는 것이 더 적합하다.

삭제 패턴

제공된 키에 해당하는 맵 항목을 제거하는 remove 메서드는 이미 알고 있다. 자바 8에서는 키가 특정한 값과 연관되었을 때만 항목을 제거하는 오버로드 된 메서드를 제공한다.

만약 특정 맵에서 삭제할 키, 밸류를 모두 알고있다면, 그 요소를 제거할 수 있다.

favouriteMovies.remove(key, value);

교체 패턴

맵의 항목을 바꾸는 데 사용할 수 있는 두 개의 메서드가 맵에 추가되었다.

  • replaceAll: BiFunction을 적용한 결과로 각 항목의 값을 교체한다. 이 메서드는 이전에 살펴본 List의 replaceAll과 비슷한 동작을 수행한다.
  • replace: 키가 존재하면 맵의 값을 바꾼다. 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전도 있다.

favouriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());

지금까지 배운 replace 패턴은 한 개의 맵에만 적용할 수 있다. 만약 두 개의 맵에서 값을 합쳐서 바꿔야 한다면 어떻게 해야할까? 새로운 merge 메서드를 이용하면 이 문제를 해결할 수 있다.

합침

두 그룹의 연락처를 포함하는 두 개의 맵을 합친다고 가정했을 때, 다음처럼 putAll을 사용할 수 있다.

Map<String, String> family = Map.ofEntries(
    entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
    entry("Raphael", "Star Wars"));
Map<String, String> everyone = new HashMap<>(family);
everyone.putAll(friends);

중복된 키가 없다면 위 코드는 잘 동작한다. 값을 좀 더 유연하게 합쳐야 한다면 새로운 merge 메서드를 이용할 수 있다. 이 메서드는 중복된 키를 어떻게 합칠지 결정하는 BiFunction을 인수로 받는다. family와 friends 두 맵 모두에 Cristina가 다른 영화 값으로 존재한다고 하자.

Map<String, String> family = Map.ofEntries(
    entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
    entry("Raphael", "Star Wars"), entry("Cristina", "Matrix"));

Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> 
    everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)); // 중복된 키가 있으면 두 값을 연결 

자바독에서 설명하는 것처럼 merge 메서드는 null값과 관련된 복잡한 상황도 처리한다.

지정된 키와 연관된 값이 없거나 값이 널이면 merge는 키를 null이 아닌 값과 연결한다. 아니면 merge는 연결된 값을 주어진 매핑 함수의 결과 값으로 대치하거나 항목을 제거한다.

moviesToCount.merge(movieName, 1L, (key, count) -> count + 1L);

위 코드에서 merge의 두 번째 인수는 1L이다. 이 인수는 "키와 연관된 기존 값에 합쳐질 널이 아닌 값 또는 값이 없거나 키에 널 값이 연관되어 있다면 이 값을 키와 연결"하는데 사용된다. 처음에는 키의 반환값이 널이므로 1이 사용된다. 그 다음부터는 값이 1로 초기화되어 있으므로 BiFunction을 적용해 값이 증가된다.

개선된 ConcurrentHashMap

ConcurrentHashMap 클래스는 동시성 친화적이며 최신 기술을 반영한 HashMap 버전이다. CocurrnetHashMap은 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용한다. 따라서 동기화된 HashTable 버전에 비해 읽기 쓰기 연산 성능이 월등하다.

리듀스와 검색

ConcurrentHashMap은 스트림에서 봤던 것과 비슷한 종류의 세 가지 새로운 연산을 지원한다.

  • forEach: 각 (키, 값) 쌍에 주어진 액션을 실행
  • reduce: 모든 (키, 값) 쌍을 제공된 리듀스 함수를 이용해 결과로 합침
  • search: 널이 아닌 값을 반환할 때까지 각 (키, 값) 쌍에 함수를 적용

위의 연산들과 키, 값, entry, (키,값) 인수를 이용하여 네가지 연산 형태를 지원한다.

이들 연산은 ConcurrentHashMap의 상태를 잠그지 않고 연산을 수행한다는 점을 주목하자. 따라서 이들 연산에 제공된 함수는 계산이 진행되는 동안 바뀔 수 있는 객체, 값, 순서 등에 의존하지 않아야 한다.

또한 이들 연산에 병렬성 기준값을 지정해야 한다. 맵의 크기가 주어진 기준값보다 작으면 순차적으로 연산을 실행한다. 기준값을 1로 지정하면 공통 스레드 풀을 이용해 병렬성을 극대화한다. Long.MAX_VALUE를 기준값으로 설정하면 한 개의 스레드로 연산을 실행한다. 우리의 소프트웨어 아키텍처가 고급 수준의 자원 활용 최적화를 사용하고 있지 않다면 기준값 규칙을 따르는 것이 좋다.

계수

ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 maapingCount 메서드를 제공한다. 기존의 size 메서드 대신 새 코드에서는 int를 반환하는 mappingCount 메서드를 사용하는 것이 좋다. 그래야 매핑의 개수가 int의 범위를 넘어서는 이후의 상황을 대처할 수 있기 때문이다.

집합뷰

ConcurrentHashMap 클래스는 ConcurrentHashMap을 집합 뷰로 반환하는 keySet이라는 새 메서드를 제공한다. 맵을 바꾸면 집합도 바뀌고 반대로 집합을 바꾸면 맵도 영향을 받는다. newKeySet이라는 새 메서드를 이용해 ConcurrentHashMap으로 유지되는 집합을 만들 수도 있다.

728x90
개발서적/모던 자바 인 액션

Chap06. 스트림으로 데이터 수집

728x90

중간연산은 스트림 파이프라인을 구성하며, 스트림의 요소를 소비하지 않는다. 반면 최종 연산은 스트림의 요소를 소비해서 최종 결과를 도출한다. reduce가 그랬던 것처럼 collect 역시 다양한 요소 누적 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있다. 다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있다. 지금부터 컬렉션, 컬렉터, collect를 헷갈리지 않도록 주의해야한다.

컬렉터란?

함수형 프로그래밍에서는 '무엇'을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다. 이전 예제에서 collect 메서드로 Collector 인터페이스 구현을 전달했다. Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

다수준으로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러진다. 명령형 코드에서는 문제를 해결하는 과정에서 다중 루프와 조건문을 추가하여 가독성과 유지보수성이 크게 떨어진다. 반면 함수형 프로그래밍에서는 필요한 컬렉터를 쉽게 추가할 수 있다.

고급 리듀싱 기능을 수행하는 컬렉터

함수형 API의 또 다른 장점으로 높은 수준의 조합성과 재사용성을 꼽을 수 있다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다. 스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다. collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다. 보통 함수를 요소로 변환할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다. Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라, 스트림에 어떤 리듀싱 연산을 수행할지 결정된다. Collectors 유티리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다. (ex. toList)

미리 정의된 컬렉터

groupingBy와 같이 Collectors 클래스에서 제공하는 팩토리 메서드의 기능을 설명한다. Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분될 수 있다.

  • 스트림 요소를 하나의 값으로 리듀스하고 요약
  • 요소 그룹화
  • 요소 분할

리듀싱과 요약

  • counting() : 개수를 계산한다. (collect를 통해 호출하지 않고 바로 최종연산에서 바로 count() 호출로도 가능하다)

스트림 값에서 최댓값과 최솟값 검색

Collectors.maxBy, Collectors.minBy 두 개의 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다. 두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.

Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

Optional<Dish> mostCalorieDish = menu.stream()
                                     .collect(maxBy(dishCaloriesComparator));

또한 스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라 부른다.

요약 연산

Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다. summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. summingInt의 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

int totalCalories = menu.stream()
                        .collect(summingInt(Dish::getCalories));

여기서 초기값은 자동으로 '0'으로 설정되고 누적자에 칼로리를 더한다.

Collectros.summingLong, Collectors.summingDouble 메서드도 같은 방식으로 각각 long, double 형식의 데이터로 요약한다.

단순 합계 외에도 평균값 계산 등의 연산도 제공된다. Collectors.averagingInt, averagingLong, averagingDouble 등으로 다양한 형식으로 이루어진 평균을 계산할 수 있다.

또한, summarazingInt로 요소 수, 요소 합계, 평균, 최댓값, 최솟값 등을 반환하는 콜렉터를 얻을 수도 있다.

IntSummaryStatistics menuStatistics = menu.stream()
                                          .collect(summarizingInt(Dish::getCalories));

IntSummaryStatistics는 아래의 정보를 가지고 있다.

IntSummaryStatistics{count=?, sum=?, min=?, average=?, max=?}

마찬가지로 int뿐 아니라 long이나 double에 대응하는 summarizingLong, summarizingDouble 메서드도 있다.

문자열 연결

컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 개체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.

joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다.

오버로드 된 joining 팩토리 메서드도 있다. joinging의 파라미터로 받은 문자열을 각 사이에 추가해준다.

그룹화

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다. 트랜잭션 통화 그룹화 예제에서 확인했듯이 명령형으로 그룹화를 구현하려면 까다롭고, 할일이 많으면, 에러도 많이 발생한다. 하지만 자바 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다.

Map<Dish.Type, List<Dish>> dishesByType = menu.stream()
                                              .collect(groupingBy(Dish::getType));

groupingBy와 같은 함수를 분류 함수라고 부른다.

단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다. 따라서 다음 예제에서 보여주는 것 처럼 메서드 참조 대신 람다 표현식으로 피룡한 로직을 구현할 수 있다.


Map<CaloricLevel, List<Dish>> dishesByCaloricLevel 
    = menu.stream()
          .collect(groupingBy(dish -> {
              if (dish.getCalories() <= 400) return CaloricLevel.DIET;
              else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
              else return CaloricLevel.FAT;
          }));

그룹화된 요소 조작

요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.

그룹핑을 할 때, filter로 인해 요소가 필터링되면서 특정 key의 요소가 하나도 없어지게 되면, key값 까지 없어지는 경우가 있다. 이런 경우에는 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해서 이 문제를 해결한다.
중간연산자의 필터 프레디케이트 조건을 Collector 안으로 이동함으로써 문제를 해결할 수 있다.

Map<Dish.Type, List<Dish>> caloricDishesByType = 
    menu.stream()
        .collect(groupingBy(Dish::getType, 
                 filtering(dish -> dish.getCalories() > 500, toList())));

filtering 메서드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 프레디케이트를 인수로 받는다. 이 프레디케이트로 각 그룹의 요소와 필터링 된 요소를 재그룹화 한다.

그룹화된 항목을 조작하는 다른 유용한 기능 중 또 다른 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다. filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.

Map<Dish.Type, List<String>> dishNamesByType = 
    menu.stream()
        .collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));

다수준 그룹화

두 인수를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화할 수 있다. Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다. 즉 바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.

Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = 
    menu.stream()
        .collect(
            groupingBy(Dish::getType,
                groupingBy(dish -> {
                    if (dish.getCalories() <= 400)
                        return CaloricLevel.DIET;
                    else if (dish.getCalories() <= 700)
                        return CaloricLevel.NORMAL;
                    else return CaloricLevel.FAT;
                })
            }
        };

최종적으로 두 수준의 맵은 첫 번째 키와 두 번째 키의 기준에 부합하는 요소 리스트를 값으로 갖는다. 다수준 그룹화 연산은 다양한 수준으로 확장할 수 있다. 즉 n수준 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 된다.

서브그룹으로 데이터 수집

마지마그 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. CollectorAndThen으로 컬렉터라 반환한 결과를 다른 형식으로 활용할 수 있다.

Map<Dish.Type, Dish> mostCaloricByType = 
    menu.stream()
        .collect(groupingBy(Dish::getType, 
            maxBy(comparingInt(Dish::getCalories)),
        Optional::get)));

팩토리 메서드 collectAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다. 반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하며 collect의 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다. (리듀싱 컬렉터는 절대 Optional.empty()를 반환하지 않으므로 안전한 코드다)

groupingBy와 함께 사용하는 다른 컬렉터 예제

일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 사용한다.

Map<Dish.Type, Integer> totalCaloriesByType = 
    menu.stream().collect(groupingBy(Dish::getType,
        summingInt(Dish::getCalories)));

분할

partitioningBy

분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다. 결과적으로 그룹화 맵은 최대 두 개의 그룹으로 분류된다.

Map<Boolean, List<Dish>> partitionMenu = 
    menu.stream().collect(partitioningBy(Dish::isVegetarian));

분할의 장점

분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할의 장점이다.

Collector 인터페이스

Collector 인터페이스는 리듀싱 연상을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다.

Collector 인터페이스

public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    Function<A, R> finisher();
    BinaryOperator<A> combiner();
    Set<Characteristics> characteristics();
}

Collector 인터페이스의 메서드 살펴보기

  • supplier 메서드: 새로운 결과 컨테이너 만들기
  • accumulator 메서드: 결과 컨테이너에 요소 추가하기
  • finisher 메서드: 최종 변환값을 결과 컨테이너로 적용하기
  • combiner 메서드: 두 결과 컨테이너 병합
  • Characteristics 메서드
    • UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
    • CONCURRENT : 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다.
    • IDENTITY_FINISH : 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.

컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기

IDENTITY_FINISH 수집 연산에서는 Collector 인터페이스를 완전히 새로 구현하지 않고도 같은 결과를 얻을 수 있다. Stream은 세 함수를 인수로 받는 colect 메서드를 오버로드하며 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.

List<Dish> dishes = menuStream.collect(
        ArrayList::new,
        List::add,
        List::addAll);
728x90

'개발서적 > 모던 자바 인 액션' 카테고리의 다른 글

Chap 12 - 새로운 날짜와 시간 API  (0) 2021.03.19
Chap09. 리팩터링, 테스팅, 디버깅  (0) 2021.03.14
Chap05. 스트림 활용  (0) 2021.03.05
Chap04. 스트림 소개  (0) 2021.02.26
Chap03. 람다 표현식  (0) 2021.02.25