모던 자바 인 액션을 읽고 (2)

Hyun·2023년 10월 11일
0

공부한거 정리

목록 보기
17/20
post-thumbnail

모던 자바 인 액션을 읽고

  • 책을 읽으며 중요하다고 생각되는 내용들을 정리하였다.

  • 책을 정리해도 내용이 많아서 넘버링을 하였다.


정리한 Chapter

  • 11장 null 대신 Optional 클래스

  • 12장 새로운 날짜와 시간 API

  • 13장 디폴트 메서드

  • 15장 CompletableFuture 와 리액티브 프로그래밍 컨셉의 기초

  • 16장 CompletableFuture, 안정적 비동기 프로그래밍

  • 17장 리액티브 프로그래밍

  • 18장 함수형 관점으로 생각하기


null 대신 Optional 클래스

null 의 문제점

  • 가장 흔히 발생하는 에러인 NullPointerException 을 발생시킨다.

  • null 확인 코드는 코드 가독성을 떨어뜨린다.

  • null 은 무형식이므로 모든 참조 형식에 null 을 할당할 수 있다.

    • 다른 부분으로 null 이 퍼졌을 때, 애초에 null 이 어떤 의미로 사용되었는지 알 수 없다.

NullPointerException

  • null 참조의 어떤 객체 필드를 사용하려고 하면 발생하는 예외
public class Person {

    private Car car;

    public Car getCar() {
        return car;
    }

    public void setCar(Car car) {
        this.car = car;
    }
}

public class Car {

    private Insurance insurance;

    public Insurance getInsurance() {
        return insurance;
    }

    public void setInsurance(Insurance insurance) {
        this.insurance = insurance;
    }
}

public class Insurance {

    private String name;

    public Insurance(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

사람이 자동차를 갖고, 자동차는 보험을 갖는 예제


public class Main {

    public static void main(String[] args) {

        Insurance insurance1 = new Insurance("황현자동차보험");
        Car car1 = new Car();
        car1.setInsurance(insurance1);
        Person person1 = new Person();
        person1.setCar(car1);

        System.out.println(getCarInsuranceName(person1));           // 황현자동차보험

        Person person2 = new Person();

        System.out.println(getCarInsuranceName(person2));           // NullPointerException 발생
    }

    public static String getCarInsuranceName(Person person) {
        return person.getCar().getInsurance().getName();
    }
}
  • person2 의 경우 자동차가 없다.
  • getCarInsuranceName(person2) 메서드 호출 시, person.getCar() 의 반환값이 null 이 된다.
    • null.getInsurance() 메서드 호출 시, NullPointerException 발생

고전적인 방식의 NullPointerException 피하기

  • 필요한 곳에 다양한 null 확인 코드를 추가한다.
    public static void main(String[] args) {
        Insurance insurance1 = new Insurance("황현자동차보험");
        Car car1 = new Car();
        car1.setInsurance(insurance1);
        Person person1 = new Person();
        person1.setCar(car1);

        System.out.println(getCarInsuranceName(person1));           // 황현자동차보험

        Person person2 = new Person();

        System.out.println(getCarInsuranceName(person2));           // UnKnown
    }


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

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

        Insurance insurance = car.getInsurance();
        if (insurance == null) {
            return "UnKnown";
        }

        return insurance.getName();
    }
  • null 변수가 잇으면 즉시 "UnKnown" 을 반환하여 NullPointerException 에러 발생을 막는다.
  • 출구가 많아 유지보수가 어렵다.

Optional<T> 클래스

  • null 과 관련한 문제를 해결하는 클래스

  • 선택형 값을 캡슐화하는 래퍼 클래스

  • 값이 있으면 값을 감싼 Optional 클래스를 반환한다.
  • 값이 없으면 null 이 아니라 빈 Optional 클래스를 반환한다.
    • null 을 참조하면 예외가 발생하지만
    • Optional 클래스는 Optional 객체이므로 다양한 방식으로 활용할 수 있다.

  • 값이 있을 수도 있고 없을 수도 있음을 명시한다.

Optional 연산

  • Optional.empty()

    • 정적 팩토리 메서드를 사용하여 빈 Optional 객체를 얻는다.
        Optional<Integer> optionalInteger = Optional.empty();
        System.out.println(optionalInteger);        // Optional.empty

  • Optional.of(객체)

    • 정적 팩토리 메서드를 사용하여 객체를 Optional 로 감싼다.

    • null 이 아닌 객체만 감쌀 수 있다.

      • null 이라면 예외 발생
        Optional<Integer> optInt = Optional.of(13);
        System.out.println(optInt);         // Optional[13]

        Optional<Object> optNull = Optional.of(null);
        System.out.println(optNull);        // NullPointerException 발생

  • .map(Function)

    • Optional 객체는 요소의 개수가 0개거나 1개인 스트림으로 생각할 수 있다.

    • 따라서 map 연산을 통해 Optional 내부의 요소를 변환할 수 있다.

    • Optional 객체 내부가 비었다면 빈 Optional 객체 반환

        Optional<String> optName = Optional.of("hyun");
        Optional<Integer> optNameLength = optName.map(String::length);

        System.out.println(optNameLength);  // Optional[4]

  • .flatMap(Function)

    • Optional 의 내부 요소에 Function 을 적용해 변환한 뒤

    • n차원 Optional 을 n-1차원으로 평준화한다.

    • Optional 객체 내부가 비었다면 빈 Optional 객체 반환

    public class Parent {
        private Optional<Child> child;

        public Optional<Child> getChild() {
            return child;
        }

        public void setChild(Optional<Child> child) {
            this.child = child;
        }
    }

    public class Child {
        private String name;

        public Child(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }
    }



        Optional<String> childNameError = optParent.map(Parent::getChild)
                                                   .map(c -> c.getName);     // 컴파일 에러

        Optional<String> childName = optParent.flatMap(Parent::getChild)
                                              .map(Child::getName);

        System.out.println(childName);      // Optional[hyun]
  • Optional<String> childNameError
    • optParent.map(Parent::getChild) 의 변환 결과는 Optional[Optional[Child]] 이다.
    • 내부 요소가 Child 가 아닌 Optional[Child] 이므로 .map(c -> c.getName) 연산을 적용할 수 없다.

  • Optional<String> childName
    • optParent.flatMap(Parent::getChild) 의 변환 결과는 Optional[Child] 이다.
    • 내부 요소가 Child 이므로 .map(c -> c.getName) 연산을 적용할 수 있다.

  • .filter(Predicate)

    • Optional 내부 요소가 Predicate 를 만족하면 Optional 객체를 그대로 반환한다.

    • Optional 내부 요소가 Predicate 를 만족시키지 못하면 빈 Optional 객체를 반환한다.

    • Optional 객체 내부가 비었다면 빈 Optional 객체를 반환한다.

        Optional<Integer> result = Optional.of(10)
                .filter(optInt -> optInt > 5);

        System.out.println(result);     // Optional[10]


        Optional<Integer> result2 = Optional.of(3)
                .filter(optInt -> optInt > 5);

        System.out.println(result2);    // Optional.empty

  • .get()

    • Optional 내부에 객체가 있으면 해당 객체를 반환한다.

    • Optional 내부에 객체가 없으면 NoSuchElementException 을 발생시킨다.

    • 값이 반드시 있다고 가정할 수 있는 상황이 아니면 사용하지 않는 것이 좋다.


  • .orElse(기본 값)

    • Optional 내부에 객체가 있으면 해당 객체를 반환한다.

    • Optional 내부에 객체가 없으면 기본값을 반환한다.

    • orElse 파라미터로 메서드를 사용한다면 Optional 내부의 객체 존재여부와 상관없이 해당 메서드는 항상 실행된다.


  • .orElseGet(Supplier)

    • Optional 내부에 객체가 있으면 해당 객체를 반환한다.

    • Optional 내부에 객체가 없으면 Supplier 를 실행해 얻은 결과 값을 반환한다.

    • Optional 내부의 객체가 없는 경우에만 Supplier 를 실행한다.


  • .orElseThrow(exceptionSupplier)

    • Optional 내부에 객체가 있으면 해당 객체를 반환한다.

    • Optional 내부에 객체가 없으면 exceptionSupplier 를 실행해 얻은 예외를 발생시킨다.


  • .ifPresent(Consumer)

    • Optional 내부에 객체가 있으면 해당 객체를 Consumer 에 넘겨주어 특정 동작을 수행한다.

    • Optional 내부에 객체가 없으면 아무일도 일어나지 않는다.


Optional 클래스를 사용하여 NullPointerException 피하기

  • 값이 있을 수도 있고 없을 수도 있는 선택형의 값은 Optional 클래스로 감싼다.

  • 값이 반드시 있어야만 하는 값에는 Optional 클래스를 사용하지 않는다.

    • 그래야 NullPointerException 발생 시 명확하게 버그가 발생했음을 파악할 수 있다.
public class Person {

    private Optional<Car> car = Optional.empty();          // 사람이 차를 소유했을 수도 있고, 소유하지 않았을 수도 있음을 명시하는 Optional

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

    public void setCar(Car car) {
        this.car = Optional.of(car);    // Optional 객체로 만든다.
    }
}


public class Car {

    private Optional<Insurance> insurance = Optional.empty();          // 차가 보험이 있을 수도 있고, 보험이 없을 수도 있음을 명시하는 Optional

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

    public void setInsurance(Insurance insurance) {
        this.insurance = Optional.of(insurance);    // Optional 객체로 만든다.
    }
}


public class Insurance {

    private String name;                // Optional 이 아니다. 따라서 보험회사는 반드시 이름이 있어야 한다.

    public Insurance(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}
  • InsurancenameOptional<String> 이 아니다.
  • 만약 Insurancename 참조 시 NullPointerException 이 발생한다면, name 이 없는 이유가 무엇인지 밝혀서 문제를 해결해야 한다.
    • Optional 이나 null 예외처리로 문제를 가려선 안된다.

public class Main {

    public static void main(String[] args) {
        Insurance insurance1 = new Insurance("황현자동차보험");
        Car car1 = new Car();
        car1.setInsurance(insurance1);
        Person person1 = new Person();
        person1.setCar(car1);

        System.out.println(getCarInsuranceName(Optional.of(person1)));           // 황현자동차보험

        Person person2 = new Person();

        System.out.println(getCarInsuranceName(Optional.of(person2)));           // UnKnown
    }

    public static String getCarInsuranceName(Optional<Person> person) {
        return person.flatMap(Person::getCar)       // Optional<Person> -> Optional<Car>
                .flatMap(Car::getInsurance)         // Optional<Car> -> Optional<Insurance>
                .map(Insurance::getName)            // Optional<Insurance> -> Optional<String>
                .orElse("UnKnown");           // 최종 Optional 객체의 내부가 비어있는 경우
    }
}

깔끔하게 NullPointerException 을 피할 수 있다.


public class Main {

    public static void main(String[] args) {
        Insurance insurance1 = new Insurance("황현자동차보험");
        Car car1 = new Car();
        car1.setInsurance(insurance1);
        Person person1 = new Person();
        person1.setCar(car1);

        Person person2 = new Person();

        Insurance insurance2 = new Insurance("서연자동차보험");
        Car car2 = new Car();
        car2.setInsurance(insurance2);
        Person person3 = new Person();
        person3.setCar(car2);

        Set<String> carInsuranceNames = getCarInsuranceNames(List.of(person1, person2, person3));
        System.out.println(carInsuranceNames);      // [황현자동차보험, 서연자동차보험]
    }

    public static Set<String> getCarInsuranceNames(List<Person> persons) {
        return persons.stream()
                .map(Person::getCar)                                            // Stream<Person> -> Stream<Optional<Car>>
                .map(optCar -> optCar.flatMap(Car::getInsurance))               // Stream<Optional<Car>> -> Stream<Optional<Insurance>>
                .map(optInsurance -> optInsurance.map(Insurance::getName))      // Stream<Optional<Insurance>> -> Stream<Optional<String>>
                .filter(Optional::isPresent)                                    // Optional.empty 모두 제거
                .map(Optional::get)                                             // Optional unwrap, Stream<Optional<String>> -> Stream<String>
                .collect(Collectors.toSet());
    }
}

다음과 같이, 모든 사람의 자동차 보험 이름을 뽑아낼 수도 있다.


public class Main {

    public static void main(String[] args) {
        Insurance insurance1 = new Insurance("황현자동차보험");
        Car car1 = new Car();
        car1.setInsurance(insurance1);
        Person person1 = new Person();
        person1.setCar(car1);
        person1.setAge(22);

        String person1CarInsuranceName = getCarInsuranceName(Optional.of(person1), 20);
        System.out.println(person1CarInsuranceName);        // 황현자동차보험



        Insurance insurance2 = new Insurance("서연자동차보험");
        Car car2 = new Car();
        car2.setInsurance(insurance2);
        Person person2 = new Person();
        person2.setCar(car1);
        person2.setAge(19);

        String person2CarInsuranceName = getCarInsuranceName(Optional.of(person2), 20);
        System.out.println(person2CarInsuranceName);        // UnKnown
    }



    public static String getCarInsuranceName(Optional<Person> person, int minAge) {
        return person.filter(optPerson -> optPerson.getAge() >= minAge)		
                .flatMap(Person::getCar)
                .flatMap(Car::getInsurance)
                .map(Insurance::getName)
                .orElse("UnKnown");
    }
}

다음과 같이, 최소 나이 조건을 만족하는 사람의 자동차 보험 이름을 뽑아낼 수도 있다.


기본형 Optional

  • 스트림과 유사하게 Optional 도 기본형으로 특화된 OptionalInt, OptionalLong, OptionalDouble 등이 있다.

  • 요소가 많은 스트림의 경우 기본형 특화 스트림으로 큰 성능 향상을 이룰 수 있지만, 요소가 최대 한개인 Optional 은 성능 향상의 효과가 미미하다.

  • 기본형 특화 스트림은 map, flatMap, filter 등을 지원하지 않는다.

  • 따라서 기본형 Optional 은 사용하지 않는 것이 좋다.


Optional 응용 예제

  • Propertiesname 을 파라미터로 받아 name 에 해당하는 프로퍼티가 존재하고, 양수인 경우엔 해당 값을 그대로 반환한다.

    • 그 이외의 모든 경우엔 0을 반환한다.
  • 고전적인 방식의 명령형 코드

    public static void main(String[] args) {
        Properties props = new Properties();
        props.setProperty("a", "5");
        props.setProperty("b", "true");
        props.setProperty("c", "-3");

        System.out.println(readDurationImperative(props, "a"));         // 5
        System.out.println(readDurationImperative(props, "b"));         // 0
        System.out.println(readDurationImperative(props, "c"));         // 0
        System.out.println(readDurationImperative(props, "d"));         // 0
    }


    public static int readDurationImperative(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 e) {}
        }

        return 0;
    }

  • Optional 을 사용한 코드
    public static void main(String[] args) {
        Properties props = new Properties();
        props.setProperty("a", "5");
        props.setProperty("b", "true");
        props.setProperty("c", "-3");

        System.out.println(readDurationWithOptional(props, "a"));       // 5
        System.out.println(readDurationWithOptional(props, "b"));       // 0
        System.out.println(readDurationWithOptional(props, "c"));       // 0
        System.out.println(readDurationWithOptional(props, "d"));       // 0
    }


    public static int readDurationWithOptional(Properties props, String name) {
        return Optional.ofNullable(props.getProperty(name))
                .flatMap(ReadPositiveIntParam::stringToInteger) 	// Optional<String> -> Optional<Optional<Integer>> -> Optional<Integer>
                .filter(i -> i > 0)
                .orElse(0);
    }

    private static Optional<Integer> stringToInteger(String s) {     // 문자열을 정수로 변환하는 메서드
        try {
            return Optional.of(Integer.parseInt(s));        // 숫자인 경우, Optional 객체 내부에 넣어 반환
        } catch (NumberFormatException e) {
            return Optional.empty();                        // 숫자가 아닌 경우, 빈 Optional 객체 반환
        }
    }

간결하고 보다 가독성있게 처리한다.


새로운 날짜와 시간 API

  • 자바 1.0: Date 클래스

    • 모호한 설계로 유용성이 떨어진다.
  • 자바 1.1: Calendar 클래스

    • 가변 클래스라서 쉽게 에러를 일으켰다.
  • 자바 8 부터는, 앞선 날짜 · 시간 API의 문제점들을 보완한 새로운 날짜 · 시간 API를 제공한다.

    • LocalDate, LocalTime, Instant, Duration, Period

    • 모두 불변 클래스이다.


LocalDate, LocalTime, LocalDateTime 클래스

  • 불변 객체이다.

  • LocalDate ➔ 날짜만 표현
    LocalTime ➔ 시간만 표현
    LocalDateTime ➔ 날짜 + 시간 표현

  • .of(...)

    • 정적 팩토리 메서드 of를 사용해 원하는 날짜 · 시간 인스턴스를 만든다.
    LocalDate date = LocalDate.of(2023, 10, 11);
    LocalTime time = LocalTime.of(20, 53, 41);
    LocalDateTime dateTime = LocalDateTime.of(2023, 10, 11, 20, 53, 41);

    System.out.println(date);       // 2023-10-11
    System.out.println(time);       // 20:53:41
    System.out.println(dateTime);   // 2023-10-11T20:53:41

  • .now()

    • 정적 팩토리 메서드 now를 사용해 현재 날짜 · 시간 정보로 인스턴스를 만든다.
    LocalDate date = LocalDate.now();
    LocalTime time = LocalTime.now();
    LocalDateTime dateTime = LocalDateTime.now();

    System.out.println(date);       // 2023-10-11
    System.out.println(time);       // 20:57:34.850065800
    System.out.println(dateTime);   // 2023-10-11T20:57:34.850065800

  • .getXX()

    • 날짜 · 시간 인스턴스에 getXX 메서드를 사용해 해당 인스턴스의 특정 단위 정보를 얻을 수 있다.
    LocalDateTime dateTime = LocalDateTime.of(2023, 10, 11, 20, 53, 41);
    int year = dateTime.getYear();
    int month = dateTime.getMonthValue();
    int day = dateTime.getDayOfMonth();
    int hour = dateTime.getHour();

    System.out.println(year);       // 2023
    System.out.println(month);      // 10
    System.out.println(day);        // 11
    System.out.println(hour);       // 20

  • .parse(날짜, 시간 정보 문자열)

    • 정적 팩토리 메서드 parse를 사용해 문자열의 날짜 · 시간 정보를 파싱하여 날짜 · 시간 인스턴스를 만든다.
    LocalDate date = LocalDate.parse("2023-10-11");
    LocalTime time = LocalTime.parse("21:10:45");
    LocalDateTime dateTime = LocalDateTime.parse("2023-10-11T21:10:45");

    System.out.println(date);
    System.out.println(time);
    System.out.println(dateTime);

  • 날짜와 시간 조합

    • atTime 또는 atDate 를 사용해 날짜와 시간을 포함한 LocalDateTime 인스턴스를 만든다.
    LocalDate date = LocalDate.of(2023, 10, 11);
    LocalTime time = LocalTime.of(20, 53, 41);

    LocalDateTime dateTime1 = date.atTime(time);
    LocalDateTime dateTime2 = time.atDate(date);

    System.out.println(dateTime1);                          // 2023-10-11T21:10:45
    System.out.println(dateTime2);                          // 2023-10-11T21:10:45
    System.out.println(dateTime1.equals(dateTime2));        // true

  • 날짜와 시간 추출

    • toLocalDate, toLocalTime 을 사용해 LocalDateTime 인스턴스에서 날짜, 시간 정보를 추출할 수 있다.
    LocalDateTime dateTime = LocalDateTime.of(2023, 10, 11, 20, 53, 41);
    LocalDate date = dateTime.toLocalDate();
    LocalTime time = dateTime.toLocalTime();

    System.out.println(date);       // 2023-10-11
    System.out.println(time);       // 20:53:41

Instant 클래스

  • 사람이 이해하는 날짜 · 시간은 기계가 이해하기 어렵다.

    • 기계는 시간을 하나의 큰 수로 표현하는 것이 자연스럽고 쉽다.
  • 기계적인 관점에서의 시간을 표현한다.

    • 따라서 사람이 읽을 수 있는 시간정보를 제공하지 않는다.

    • ex) getYear(), getHour() ...

  • Unix epoch time (1970년 1월 1일 0시 0분 0초 UTC) 을 기준으로 특정 지점까지의 시간을 초로 표현한다.

  • .ofEpochSecond(초, 나노초)

    • 정적 팩토리 메서드 ofEpochSecond 를 사용해 원하는 시간의 Instant 인스턴스를 만든다.
    Instant instant1 = Instant.ofEpochSecond(3);
    Instant instant2 = Instant.ofEpochSecond(2, 10_0000_0000);      // 2초 + 10억 나노 초 (1초)
    Instant instant3 = Instant.ofEpochSecond(4, -10_0000_0000);     // 4초 - 10억 나노 초 (1초)

    System.out.println(instant1.equals(instant2));      // true
    System.out.println(instant2.equals(instant3));      // true
    System.out.println(instant1);                       // 1970-01-01T00:00:03Z (3초)

Duration, Period 클래스

  • 두 시간 객체 사이의 차이값을 나타내는 Duration

  • 두 날짜 객체 사이의 차이값을 나타내는 Period

  • .between(객체 1, 객체 2)

    • 정적 팩토리 메서드 between 을 사용해 두 객체 사이의 차이값을 나타내는 인스턴스를 만든다.

    • 객체 2에서 객체 1을 뺀 값을 반환한다.

    LocalTime time1 = LocalTime.of(21, 40, 0);
    LocalTime time2 = LocalTime.of(21, 57, 0);
    Duration duration = Duration.between(time1, time2);

    System.out.println(duration);       // PT17M	(17분 차이)


    LocalDate date1 = LocalDate.of(2023, 10, 11);
    LocalDate date2 = LocalDate.of(2023, 10, 13);
    Period period = Period.between(date1, date2);

    System.out.println(period);         // P2D		(2일 차이)

간단한 날짜 조정

  • .withXX(변경할 값)

    • 날짜 · 시간 인스턴스에 특정 필드만 변경한 새로운 날짜 · 시간 인스턴스를 만들어 반환한다.

    • 기존 날짜 · 시간 인스턴스를 바꾸지 않는다.

    LocalDateTime dateTime = LocalDateTime.of(2023, 10, 11, 20, 53, 41);
    LocalDateTime dt1 = dateTime.withYear(2024);
    LocalDateTime dt2 = dateTime.withMonth(2);
    LocalDateTime dt3 = dateTime.withDayOfMonth(20);
    LocalDateTime dt4 = dateTime.withHour(7);

    System.out.println(dt1);        // 2024-10-11T20:53:41
    System.out.println(dt2);        // 2023-02-11T20:53:41
    System.out.println(dt3);        // 2023-10-20T20:53:41
    System.out.println(dt4);        // 2023-10-11T07:53:41

  • .plusXX(더할 값), .minusXX(뺄 값)

    • 날짜 · 시간 인스턴스에 특정 필드만 더하거나 뺀 새로운 날짜 · 시간 인스턴스를 만들어 반환한다.

    • 기존 날짜 · 시간 인스턴스를 바꾸지 않는다.

    LocalDate date = LocalDate.of(2013, 3, 18);
    date = date.plusYears(2).minusDays(10);

    System.out.println(date);       // 2015-3-8

Temporal Adjusters 를 이용한 복잡한 날짜 조정

  • 다음주 일요일, 돌아오는 평일, 어떤 달의 마지막 날과 같은 복잡한 날짜 조정 기능이 필요할 때, Temporal Adjuster 인터페이스를 with 의 파라미터로 넘겨준다.

  • TemporalAdjusters 는 다양한 TemporalAdjuster 구현체를 정적 팩토리 메서드로 제공한다.

    LocalDate date = LocalDate.of(2023, 10, 11);
    LocalDate nextSunday = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));	// 돌아오는 일요일
    System.out.println(nextSunday);     // 2023-10-15

    LocalDate lastDay = date.with(TemporalAdjusters.lastDayOfMonth());	// 이번달의 마지막 날짜
    System.out.println(lastDay);        // 2023-10-31

TemporalAdjusters API : https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/time/temporal/TemporalAdjusters.html


  • 커스텀 TemporalAdjuster 를 구현할 수도 있다.
public class NextWorkingDay implements TemporalAdjuster {

    @Override
    public Temporal adjustInto(Temporal temporal) {
        DayOfWeek dayOfWeek = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));      // 현재 날짜 읽기

        int dayToAdd = 1;                                           // 보통은 하루 추가
        if (dayOfWeek == DayOfWeek.FRIDAY) dayToAdd = 3;            // 오늘이 금요일이면 3일 추가
        else if (dayOfWeek == DayOfWeek.SATURDAY) dayToAdd = 2;     // 오늘이 토요일이면 2일 추가
        return temporal.plus(dayToAdd, ChronoUnit.DAYS);            // dayToAdd 만큼 추가된 날짜를 반환
    }
}


    LocalDate thursday = LocalDate.of(2023, 10, 12);
    LocalDate nextWorkingDay1 = thursday.with(new NextWorkingDay());

    System.out.println(nextWorkingDay1);        // 2023-10-13


    LocalDate friday = LocalDate.of(2023, 10, 13);
    LocalDate nextWorkingDay2 = friday.with(new NextWorkingDay());

    System.out.println(nextWorkingDay2);        // 2023-10-16


    LocalDate saturday = LocalDate.of(2023, 10, 14);
    LocalDate nextWorkingDay3 = saturday.with(new NextWorkingDay());

    System.out.println(nextWorkingDay3);        // 2023-10-16
  • 다음 근무일자를 반환하는 NextWorkingDay 클래스
    • TemporalAdjuster 인터페이스를 구현한다.

날짜 · 시간 객체의 포매팅과 파싱

  • formatting

    • 객체 ➔ 문자열
  • parsing

    • 문자열 ➔ 객체
  • DateTimeFormatter 클래스를 사용해 formatter 를 만든다.

    • .format(포매터) 를 사용해 날짜 · 시간 객체를 문자열로 변환한다.
    LocalDate date = LocalDate.of(2023, 10, 11);
    String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE);
    String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE);

    System.out.println(s1);     // 20231011
    System.out.println(s2);     // 2023-10-11

  • .parse(문자열, 포매터) 을 사용해 문자열을 날짜 · 시간 객체로 만든다.
    LocalDate date1 = LocalDate.parse("20231011", DateTimeFormatter.BASIC_ISO_DATE);
    LocalDate date2 = LocalDate.parse("2023-10-11", DateTimeFormatter.ISO_LOCAL_DATE);

    System.out.println(date1);      // 2023-10-11
    System.out.println(date2);      // 2023-10-11

  • 특정 패턴으로 Custom formatter 를 만들 수 있다.

    • 정적 팩토리 메서드인 .ofPattern(패턴) 을 사용한다.
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");        // 커스텀 포매터 생성
    LocalDate date = LocalDate.of(2023, 10, 11);
    String formattedDate = date.format(formatter);                                  // 날짜 -> 문자열로 포맷팅

    System.out.println(formattedDate);                      // 11/10/2023

    LocalDate parsedDate = LocalDate.parse(formattedDate, formatter);               // 문자열 -> 날짜로 파싱

    System.out.println(parsedDate.equals(date));            // true
  • LocalDate.parse(formattedDate, formatter)
    • 첫 번째 파라미터로 날짜 문자열을
    • 두 번째 파라미터로 포매터를 넘겨주어 파싱을 수행한다.

디폴트 메서드

  • 인터페이스를 구현하는 클래스는 인터페이스에서 정의하는 모든 메서드를 구현해야 한다.

  • 따라서 인터페이스에 정의된 메서드가 변경되면 해당 인터페이스를 구현한 모든 클래스들도 변경해야 한다.

  • 이러한 변경의 파급효과를 막기 위해 자바 8에서 default method 를 도입하였다.

    • 메서드 앞에 default 키워드를 붙여 사용한다.

    • 메서드 구현을 포함하는 인터페이스를 정의할 수 있다.

      • 이때, 이미 구현된 메서드를 default method 라 한다.

      • 구현 클래스는 인터페이스에 이미 구현된 메서드인 default method 를 오버라이딩하여 새로운 동작을 정의할 수도 있고, 인터페이스에 구현된 그대로 사용할 수도 있다.

        • 구현 클래스는 인터페이스의 default method 를 반드시 구현하지 않아도 된다.
public interface Person {

    default void foo() {
        System.out.println("foo!");
    }
}


public class Hyun implements Person {

    @Override
    public void foo() {
        System.out.println("hello!");
    }
}

public class Yeon implements Person{
}
  • Hyun 클래스는 Person 인터페이스의 default method foo() 를 재정의하였다.
  • Yeon 클래스는 Person 인터페이스의 default method foo() 를 구현하지 않고 그대로 사용한다.

    public static void main(String[] args) {
        Hyun hyun = new Hyun();
        hyun.foo();                 // hello!

        Yeon yeon = new Yeon();
        yeon.foo();                 // foo!
    }

hyun 은 재정의한 동작이, yeon 은 인터페이스에 정의된 동작이 수행된다.


  • default method 를 사용해 인터페이스에 자유롭게 메서드를 추가할 수 있다.


  • default method 는 추상 메서드가 아니므로 함수형 인터페이스에 여러개 추가할 수 있다.

고전적인 방식의 인터페이스

  • 모양의 크기를 조절하기 위한 메서드를 정의하는 Resizable 인터페이스가 있다 가정한다.
public interface Drawable {

    void draw();
}

public interface Resizable extends Drawable{

    int getWidth();

    int getHeight();

    void setWidth(int width);

    void setHeight(int height);

    void setAbsoluteSize(int width, int height);
}

Resizable 인터페이스


public class Triangle implements Resizable{

    @Override
    public void draw() {}

    @Override
    public int getWidth() {
        return 0;
    }

    @Override
    public int getHeight() {
        return 0;
    }

    @Override
    public void setWidth(int width) {}

    @Override
    public void setHeight(int height) {}

    @Override
    public void setAbsoluteSize(int width, int height) {}
}


public class Square implements Resizable{

    @Override
    public void draw() {}

    @Override
    public int getWidth() {
        return 0;
    }

    @Override
    public int getHeight() {
        return 0;
    }

    @Override
    public void setWidth(int width) {}

    @Override
    public void setHeight(int height) {}

    @Override
    public void setAbsoluteSize(int width, int height) {}
}


public class Ellipse implements Resizable {

    @Override
    public void draw() {}

    @Override
    public int getWidth() {
        return 0;
    }

    @Override
    public int getHeight() {
        return 0;
    }

    @Override
    public void setWidth(int width) {}

    @Override
    public void setHeight(int height) {}

    @Override
    public void setAbsoluteSize(int width, int height) {}
}

public class Utils {

    public static void paint(List<Resizable> l) {
        l.forEach(r -> {
            r.setAbsoluteSize(42, 42);
            r.draw();
        });
    }
}

public class Game {

    public static void main(String[] args) {
        List<Resizable> resizableShapes = Arrays.asList(new Square(), new Triangle(), new Ellipse());
        Utils.paint(resizableShapes);
    }
}

Resizable 인터페이스를 구현하는 클래스들과 이를 사용하는 프로그램


  • 만약 Resizable 인터페이스에 setRelativeSize 메서드를 추가한다면 컴파일 에러가 발생한다.
public interface Resizable extends Drawable{

    int getWidth();

    int getHeight();

    void setWidth(int width);

    void setHeight(int height);

    void setAbsoluteSize(int width, int height);

    void setRelativeSize(int wFactor, int hFactor);     // 메서드 추가
}

하위 구현 클래스들은 setRelativeSize 를 구현하지 않았으므로 컴파일 에러 발생


  • 고전적인 방식에선 인터페이스에 기능이 추가될 때마다 구현 클래스들의 추가적인 메서드 구현이 필요하므로 불편하다.

호환성

  • 바이너리 호환성

    • 특정 자바파일을 바꾼 이후에도 에러 없이 기존 바이너리가 실행될 수 있으면 바이너리 호환성을 갖는다.

Resizable 인터페이스 변경 전, 잘 실행된다.


Resizable 인터페이스 변경


변경 후에도 잘 실행된다.

  • Resizable 인터페이스에 메서드를 추가했음에도 기존 프로그램이 추가된 메서드를 호출하지 않아 문제가 발생하지 않는다.

  • 소스 호환성

    • 특정 자바파일을 바꾼 이후에도 기존 프로그램을 성공적으로 재컴파일 할 수 있으면 소스 호환성을 갖는다.

Resizable 인터페이스 변경 후, 소스 호환성을 갖지 못한다.

  • 하위 구현 클래스들을 컴파일할 수 없다.

  • 동작 호환성

    • 특정 자바파일을 바꾼 이후에도 같은 입력에 대해 같은 동작을 실행하면 동작 호환성을 갖는다.

default method 를 사용하는 인터페이스

  • 하위 구현 클래스들은 인터페이스의 default method 구현 역시 상속받는다.

  • 따라서 하위 구현 클래스들의 코드를 변경하지 않고도 소스 호환성을 유지할 수 있다.

public interface Resizable extends Drawable {

    int getWidth();

    int getHeight();

    void setWidth(int width);

    void setHeight(int height);

    void setAbsoluteSize(int width, int height);

    default void setRelativeSize(int wFactor, int hFactor) {    // default 메서드 추가
        setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
    }
}

Resizable 인터페이스에 default 메서드를 추가한다.


public class Game {

    public static void main(String[] args) {
        List<Resizable> resizableShapes = Arrays.asList(new Square(), new Triangle(), new Ellipse());
        Utils.paint(resizableShapes);
    }
}

컴파일 에러 없이 잘 실행된다.


추상 클래스 vs 자바 8 인터페이스

  • 둘 다 추상 메서드와 구현된 메서드를 정의할 수 있다.

  • 클래스는 하나의 추상 클래스만 상속받을 수 있지만 인터페이스는 여러개를 구현할 수 있다.

    • 이를 통해 동작 다중 상속이 가능하다.


  • 추상 클래스는 인스턴스 변수 (필드) 를 공통 상태로 가질 수 있지만 인터페이스는 인스턴스 변수를 가질 수 없다.

해석 규칙

  • 자바의 클래스는 하나의 부모 클래스를 상속 받을 수 있다.

  • 자바의 클래스는 여러개의 인터페이스를 동시에 구현할 수 있다.

    • 자바 8에 default method 가 추가되어, 같은 시그니처를 갖는 default method 를 여러개 상속받는 상확이 발생할 수 있다.

    • 따라서 자바 컴파일러는 이러한 충돌을 규칙을 가지고 해결한다.

  • 해석 규칙은 3가지이다.

  1. 클래스가 항상 이긴다.
    • 클래스나 슈퍼 클래스에 정의한 메서드가 우선권을 갖는다.
public interface A {

    default void hello() {
        System.out.println("Hello from A");
    }
}


public interface B extends A{

    @Override
    default void hello() {
        System.out.println("Hello from B");
    }
}


public class C implements A, B{

    @Override
    public void hello() {
        System.out.println("Hello from C");
    }

    public static void main(String[] args) {
        new C().hello();        // Hello from C
    }
}

  1. 1번 이외의 상황에선 서브 인터페이스가 이긴다.
    • 구현 클래스와 가장 가까운 인터페이스의 default method 가 우선권을 갖는다.
public interface A {

    default void hello() {
        System.out.println("Hello from A");
    }
}


public interface B extends A{

    @Override
    default void hello() {
        System.out.println("Hello from B");
    }
}


public class C implements A, B{

    public static void main(String[] args) {
        new C().hello();        // Hello from B
    }
}

  1. 1, 2 번 이외의 상황에선 우선순위가 결정되지 않으므로 구현 클래스가 명시적으로 default method 를 오버라이딩해야 한다.
public interface A {

    default void hello() {
        System.out.println("Hello from A");
    }
}


public interface B{

    default void hello() {
        System.out.println("Hello from B");
    }
}


public class C implements A, B{

    public static void main(String[] args) {
        new C().hello();        // 컴파일 에러 발생, types A and B are incompatible;
    }
}

public class C implements A, B{

    @Override
    public void hello() {
        B.super.hello();   	// B의 default method 를 사용함을 명시
    }

    public static void main(String[] args) {
        new C().hello();        // Hello from B
    }
}

CompletableFuture 와 리액티브 프로그래밍 컨셉의 기초

스레드 직접 사용의 문제점

  • 운영체제 스레드를 만들고 종료하는 것은 비싼 비용이 든다.

  • 제한적으로 지원하는 스레드 수를 초과해서 스레드를 만들게되면 애플리케이션이 크래시 될 수 있다.


스레드 풀

  • 여러개의 스레드를 미리 만들어 스레드 풀에 보관한다.

  • 스레드 작업이 필요해지면, 스레드 풀에서 놀고 있는 스레드를 사용해 작업을 수행한다.

    • 작업이 끝나면 사용한 스레드를 스레드 풀에 다시 반환한다.

    • 작업이 끝나지 않으면 스레드를 계속해서 물고 있으므로 데드락을 주의해야 한다.

      • 자거나 이벤트를 기다리는 블록 태스크는 스레드 풀을 사용하지 않는 것이 좋다.
  • 하드웨어에 맞는 적절한 스레드 수를 유지할 수 있다.

  • 스레드 사용 시 스레드 생성 오버헤드가 없다.

  • 스레드 풀에 작업중인 스레드가 있으면 자바 프로그램이 종료되지 않으므로, 프로그램 종료 전엔 스레드 풀을 종료해야 한다.


포크 / 조인의 종류

  • 엄격한 포크 / 조인

    • 부모 태스크는 하나 이상의 자식 태스크를 포크 (스레드 생성) 한다.

      • 각 태스크들은 병렬로 수행된다.
    • 부모 태스크는 자식 태스크가 끝나기를 완전히 기다려야 한다.

      • 자식 태스크 작업이 끝나면 조인 후 메서드를 종료한다.

    @Override
    protected Long compute() {
        int length = end - start;
        if (length <= THRESHOLD) {              // 서브 태스크가 작아지면, 더 이상 쪼개지 않고 순차 실행
            return computeSequentially();
        }
        ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers, start, start + length / 2);
        leftTask.fork();                        // ForkJoinPool 의 다른 스레드로 비동기 실행

        ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers, start + length / 2, end);
        Long rightResult = rightTask.compute();     // 현재 스레드로 동기 실행

        Long leftResult = leftTask.join();      // 왼쪽 서브 태스크의 결과를 읽어온다. (없으면 기다림)

        return leftResult + rightResult;        // 왼쪽, 오른쪽 서브 태스크의 결과를 합쳐서 반환한다.
    }
  • 엄격한 포크 / 조인 코드
  • fork()join() 이 메서드 내에서 한 쌍을 이룬다.
    • 시작한 태스크를 메서드 내부에서 종료한다.

  • 여유로운 포크 / 조인

    • 시작된 태스크를 메서드 내부가 아니라 외부에서 종료하는 방식

  • 메서드 호출에 의해 스레드가 생성되지만, 메서드가 종료되어도 스레드는 계속 실행된다.
    • 이를 비동기 메서드라 한다.

동기, 비동기 작업 예제

수학적 작업 f(x), g(x) 의 두 결과를 합치는 예제

public class Functions {

    public static int f(int x) {
        return x * 2;
    }

    public static int g(int x) {
        return x + 1;
    }
}

수학적 작업을 정의한 클래스


  • 동기 작업
    int x = 1000;

    int result1 = f(x);     // 2000
    int result2 = g(x);     // 1001

    System.out.println(result1 + result2);  // 3001
  • 순차적으로 수학적 작업을 수행한다.
  • 하나의 스레드로 작업을 수행한다.
    • 두 작업의 시간이 오래 걸린다면 비효율적이다.

  • 스레드를 직접 만들어 비동기 작업
public class ThreadExample {

    public static void main(String[] args) throws InterruptedException {
        int x = 1000;

        Result result = new Result();

        Thread t1 = new Thread(() -> {
            result.left = f(x);             // 2000
        });

        Thread t2 = new Thread(() -> {
            result.right = g(x);            // 1001
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(result.left + result.right);     // 3001
    }

    private static class Result {

        private int left;
        private int right;
    }
}
  • Result 클래스는 각 스레드 작업의 결과물들을 담기 위한 클래스이다.
  • 코드가 복잡하다.

  • Future API 를 사용한 비동기 작업
    int x = 1000;

    ExecutorService executorService = Executors.newFixedThreadPool(2);  // 스레드 풀 생성 (총 스레드는 2개)
    Future<Integer> task1 = executorService.submit(() -> f(x));
    Future<Integer> task2 = executorService.submit(() -> g(x));

    System.out.println(task1.get() + task2.get());

    executorService.shutdown();     // 스레드 풀 종료
  • ExecutorService executorService = Executors.newFixedThreadPool(n);
    • n 개의 스레드를 갖는 스레드 풀을 만든다.

  • executorService.submit(Runnable task)
    • 스레드 풀의 스레드를 꺼내 작업을 수행한 뒤, 작업 결과물을 반환한다.

  • Future<Integer> task1 = executorService.submit(() -> f(x));
    • 작업 결과물을 Future 에 담는다.

  • submit 메서드 호출 같은 비즈니스 로직과 관계없는 코드 호출이 발생한다.

잠자기와 블로킹

  • 애플리케이션이 사람과 상호작용하거나, 일정 대기시간이 필요한 경우 sleep 메서드를 사용해 스레드 작업을 중지하고 대기시킨다.

  • 다른 어떤 동작을 완료하길 기다리는 블로킹 동작 역시 스레드를 대기시킨다.

    • Futureget() 메서드

    • 네트워크, DB 와의 상호작용 등

  • 이렇게 스레드 작업이 일시정지되어 대기 상태가 되어도 스레드 풀에 스레드를 반환하지 않고 해당 작업이 스레드를 점유한다.

    • 즉, 작업이 대기 상태더라도 자원을 소비한다.
  • 따라서 태스크 작업 중 다른 작업의 완료를 기다리는 상황을 만들지 않는 것이 좋다.

    work1();
    Thread.sleep(10000);
    work2();
  • 스레드의 대기가 발생하는 비효율적인 코드
  • work1 작업시간 + 10초 + work2 작업시간 만큼 스레드를 사용한다.

    ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);

    work1();
    // work2 를 실행하기 위해 10초간의 대기 시간이 필요한 경우, 일단 스레드를 반납한다.
    scheduledExecutorService.schedule(() -> work2(), 10, TimeUnit.SECONDS);     // 10초 뒤에 스레드를 가져와 work2 작업을 수행한다.

    scheduledExecutorService.shutdown();
  • 스레드의 대기가 발생하지 않는 효율적인 코드

  • ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(n);
    • 스케줄링이 가능한 스레드 풀 생성

  • work1 작업 을 처리한 후 work2 작업 전에 10초의 대기시간이 필요하다면
    • work1 작업 후 스레드를 반납한다.
    • 10초 뒤 스레드를 다시 가져와 work2 작업 을 수행한다.

  • work1 작업시간 + work2 작업시간 만큼 스레드를 사용한다.

Completable Future

  • 기존의 Future 는 여러 Future 를 조합하는 것이 어렵다.

    • Future 에서 반환하는 결과값을 가지고 작업을 수행해야 한다면, 선행 작업 Futureget() 이후에 작성되어야 한다.

    • 특정 작업이 오래걸려 블로킹이 발생하는 경우 자원이 낭비된다.

  • 만약 f(x), g(x) 비동기 작업의 결과물을 합치는 비동기 작업을 만들고 싶다면 다음과 같이 코드를 작성해야 한다.

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        int x = 1000;

        ExecutorService executorService = Executors.newFixedThreadPool(2);
        Future<Integer> task1 = executorService.submit(() -> f(x));
        Future<Integer> task2 = executorService.submit(() -> g(x));

        Integer result1 = task1.get();
        Integer result2 = task2.get();                                  // 선행작업이 모두 끝날때까지 블로킹

        Future<Integer> afterTask = executorService.submit(() -> result1 + result2);        // 선행작업이 끝나야 후행작업을 시작할 수 있다.

        System.out.println(afterTask.get());        // 3001

        executorService.shutdown();
    }
  • task1, task2 의 모든 작업이 끝나길 기다려야 한다.
    • 만약 task1 이 100초, task2 가 1초 걸린다면 메인 스레드는 다른 작업을 하지 못하고 100초동안 대기해야 한다.

  • CompletableFuture 는 당장의 실행 코드없이 Future 를 만들 수 있다.

    • 따라서 작업 결과물을 만드는 작업 코드를 나중에 넣어주어도 된다.

      • 작업 코드 ex) executorService.submit(() -> f(x))
    • 예를 들면, 외부에서 작업을 완료한 후 complete 메서드를 사용해 작업 결과물을 CompletableFuture 에 넣어줄 수 있다.

  • CompletableFuture 를 사용해 Future 를 조합할 수 있다.

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        int x = 1000;

        CompletableFuture<Integer> task1 = new CompletableFuture<>();
        CompletableFuture<Integer> task2 = new CompletableFuture<>();

        // task1, task2 선행작업이 모두 끝나면 스레드를 사용해 afterTask 작업을 수행한다.
        CompletableFuture<Integer> afterTask = task1.thenCombine(task2, (result1, result2) -> result1 + result2);   
        
        executorService.submit(() -> task1.complete(f(x)));     // task1 의 작업 결과물이 f(x)
        executorService.submit(() -> task2.complete(g(x)));     // task2 의 작업 결과물이 g(x)

        System.out.println(afterTask.get());        // 메인 스레드는 task1, task2 의 작업시간동안 대기할 필요 없이, afterTask 의 작업이 끝나기만을 기다리면 된다.

        executorService.shutdown();
    }
  • CompletableFuture<Integer> task1 = new CompletableFuture<>();
    • 작업 실행 코드없이 Future 를 만든다.

  • executorService.submit(() -> task1.complete(f(x)));
    • 외부 스레드에서 작업을 처리한 뒤, 작업 결과물을 CompletableFuture 에 넣어준다.
    • task1.complete(작업결과물) 을 통해 CompletableFuture task1 은 작업이 끝났음을 알 수 있다.

  • CompletableFuture<Integer> afterTask = task1.thenCombine(task2, (r1, r2) -> r1 + r2);
    • Future task1Future task2 를 조합한다.
    • task1task2 의 작업이 끝나면 자동으로 task1 의 결과물과 task2 의 결과물을 입력으로 하는 BiFunction 작업을 다른 스레드에서 실행한다.
    • task1task2 작업이 모두 끝나기전엔 작업을 시작하지 않는다.

  • 메인 스레드가 task1 작업과 task2 작업이 끝나길 기다릴 필요 없다.
    • afterTask 작업만 끝나길 기다리면 된다.


Pub-Sub 리액티브 프로그래밍

  • 리액티브 프로그래밍이란 변화에 반응하는 시스템을 구축하기 위한 프로그래밍 패러다임

    • 데이터를 비동기적으로 처리한다.

    • 이벤트 기반 구조를 통해 실시간으로 데이터 변화에 반응한다.

  • java.util.concurrent.Flow API 의 Pub-Sub 프로토콜을 적용해 리액티브 프로그래밍을 구현할 수 있다.


  • 발행자의 value 가 바뀌면 구독자의 value 도 바뀌는 예제를 리액티브 프로그래밍 해본다.

    • c1 값이 갱신되면 c3 가 변화에 반응하여 값을 갱신한다.
public class SimpleCell implements Publisher<Integer>, Subscriber<Integer> {

    private int value = 0;
    private String name;
    private List<Subscriber> subscribers = new ArrayList<>();       // subscriber 들 관리

    public SimpleCell(String name) {
        this.name = name;
    }

    // Publisher 인터페이스 구현

    @Override
    public void subscribe(Subscriber<? super Integer> subscriber) {     // subscriber 등록
        subscribers.add(subscriber);
    }

    // Subscriber 인터페이스 구현

    @Override
    public void onSubscribe(Subscription subscription) {}

    @Override
    public void onNext(Integer newValue) {      // publisher 에게 받은 데이터를 처리한다.
        this.value = newValue;
        System.out.println(this.name + ":" + this.value);

        // subscriber 들의 value 값을 전부 publisher 와 똑같게 변경
        subscribers.forEach(subscriber -> subscriber.onNext(this.value));
    }

    @Override
    public void onError(Throwable throwable) {
        throwable.printStackTrace();
    }

    @Override
    public void onComplete() {}
}
  • 발행자의 value가 바뀌면 구독자의 value 도 동일하게 바꿔주는 SimpleCell 클래스
    • Publisher<T>, Subscriber<T> 인터페이스를 구현한다.
    • <T>onNext() 에서 처리하는 데이터 타입이다.

  • Publisher 인터페이스 구현
    • subscribe(Subscriber<? super Integer> subscriber)
      - 해당 발행자가 관리하는 구독자들 목록에 구독자를 추가한다.

  • Subscriber 인터페이스 구현
    • onNext(Integer newValue)
      - 발행자가 발행한 데이터를 해당 구독자가 받아서 처리한다.

  • subscribers.forEach(subscriber -> subscriber.onNext(this.value))
    • 구독자들의 value 값을 발행자의 value 값과 동일하게 변경한다.
    • 즉, 모든 구독자들에게 메시지를 발행한다.

    public static void main(String[] args) {

        SimpleCell c1 = new SimpleCell("C1");
        SimpleCell c3 = new SimpleCell("C3");

        c1.subscribe(c3);           // c3 가 c1을 구독

        c1.onNext(10);      // c1 값을 10으로 갱신 시 -> c3 값도 10으로 갱신
    }

실행결과

C1:10
C3:10

압력 & 역압력

  • 리액티브 프로그래밍에서 스레드 활용 시 중요하게 다뤄지는 개념

  • 압력

    • 구독자가 처리할 수 있는 양에 비해 발행자가 무수히 많은 데이터를 전달하는 상황

    • 이러한 압력은 문제를 발생시킨다.

  • 역압력

    • 구독자가 처리할 수 있을때만 발행자로부터 데이터를 전달받도록 조절하는 것

    • 정보의 흐름 속도를 구독자가 제어한다.


CompletableFuture, 안정적 비동기 프로그래밍

Future

  • 미래의 어느 시점에 결과를 얻는 모델을 만들기 위한 API

  • 계산이 끝났을 때 결과에 접근할 수 있는 참조를 제공한다.

  • 시간이 걸리는 작업을 다른 스레드에서 수행시키고, 결과만 현재 스레드에서 Future 로 받을 수 있다.

public class FutureExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException {
        ExecutorService executor = Executors.newCachedThreadPool();

        Future<Double> future = executor.submit(new Callable<Double>() {
            @Override
            public Double call() throws Exception {
                return doSomeLongComputation();     // 시간이 오래 걸리는 작업을 다른 스레드에서 비동기적으로 실행
            }
        });

        doSomethingElse();  // 현재 스레드에선 다른 작업을 수행한다.

        Double result = future.get(1, TimeUnit.SECONDS);   // 다른 스레드에서 수행한 비동기 작업의 결과를 가져온다.
                                                           // 1초동안 블로킹된다. 1초가 지나면 TimeoutException 발생
    }
}
  • executor.submit(Callable 객체)
    • 외부 스레드에서 수행하고 싶은 작업을 Callable 객체로 감싸 스레드 풀에 제출하면 Future 를 반환한다.

  • Double result = future.get(1, TimeUnit.SECONDS)
    • get 메서드를 통해 future 객체가 참조하는 작업의 결과값을 가져온다.
    • 작업이 아직 안끝나 결과가 준비되지 않았다면 결과를 얻을때까지 기다린다.
      • 블로킹 메서드
      • 예제처럼 블로킹 제한시간을 걸 수 있다.
      • 제한시간이 지나면 TimeoutException 발생

    public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException {
        ExecutorService executor = Executors.newCachedThreadPool();

        Future<Double> future = executor.submit(() -> doSomeLongComputation());     // 시간이 오래 걸리는 작업을 다른 스레드에서 비동기적으로 실행

        doSomethingElse();  // 현재 스레드에선 다른 작업을 수행한다.

        Double result = future.get(1, TimeUnit.SECONDS);   // 다른 스레드에서 수행한 비동기 작업의 결과를 가져온다.
                                                           // 1초동안 블로킹된다. 1초가 지나면 TimeoutException 발생
    }
  • Future<Double> future = executor.submit(() -> doSomeLongComputation())
    • Callable 은 Functional Interface 이므로 다음과 같이 람다로 표현 가능하다.

  • 여러개의 Future 를 조합하는 것이 어렵다.

    • 여러개의 Future 결과가 있을 때, 이들의 의존관계를 표현하기 어렵다.

    • 이를 해결하기 위해 자바 8에서 CompletableFuture 를 도입하였다.


CompletableFuture 사용예제 (1)

  • 최저가 상품을 찾는 비동기 API를 제공하는 예제를 만들어본다.

  • 점진적으로 예제를 개선해 나간다.

  • Version 1

public class Shop {

    private final String name;
    private final Random random;

    public Shop(String name) {
        this.name = name;
        random = new Random(name.charAt(0) * name.charAt(1) * name.charAt(2));
    }

    public double getPrice(String product) {            // 동기 메서드
        return calculatePrice(product);
    }

    public Future<Double> getPriceAsync(String product) {       // 비동기 메서드
        CompletableFuture<Double> futurePrice = new CompletableFuture<>();

        new Thread(() -> {      // 외부 스레드에서 작업 수행
            double price = calculatePrice(product);
            futurePrice.complete(price);            // 작업이 완료되면 CompletableFuture 의 작업을 완료시키고,
            										// 작업의 결과물을 price로 설정한다.
        }).start();

        return futurePrice;     // 실제 결과값이 아니라 future 를 반환
    }

    private double calculatePrice(String product) {
        delay();        // 외부 API 요청에 의해 걸리는 작업시간 1초
        return random.nextDouble() * product.charAt(0) + product.charAt(1);
    }
}
  • getPriceAsync(String product) 는 비동기 메서드로, 실제 최저가 계산을 통해 결과값을 반환하지 않고 Future 를 바로 반환한다.
    • 시간이 오래걸리는 작업인 calculatePrice(String product) 메서드를 외부 스레드에서 수행한다.
    • 외부 스레드에서의 작업이 끝나면 미리 반환한 Future 가 작업의 결과값을 참조한다.

public class Main {

    public static void main(String[] args) {
        Shop shop = new Shop("BestShop");
        long start = System.nanoTime();
        Future<Double> futurePrice = shop.getPriceAsync("my favorite product");     // 작업 완료여부와 상관없이 비동기 메서드는 바로 future 를 반환한다.
        long invocationTime = (System.nanoTime() - start) / 1_000_000;
        System.out.println("Invocation returned after " + invocationTime + " msecs");

        doSomethingElse();      // 현재 스레드 작업

        try {
            double price = futurePrice.get();               // 작업이 완료되지 않았다면, 완료되어 결과를 반환하기까지 블로킹된다.
            System.out.printf("Price is %.2f\n", price);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        long retrievalTime = (System.nanoTime() - start) / 1_000_000;
        System.out.println("Price returned after " + retrievalTime + " msecs");
    }
}

실행결과

Invocation returned after 2 msecs
현재 스레드에서 다른 작업 중 ...
Price is 123.26
Price returned after 1029 msecs
  • 비동기 메서드는 Future 를 바로 반환한다.

  • double price = futurePrice.get() 시점에 Future futurePrice 가 참조하는 작업이 아직 끝나지 않았으므로 calculatePrice(String product) 작업시간 (약 1초) 만큼 블로킹된다.
    • 만약 비동기 메서드에서 할당한 외부 스레드 작업 도중 에러가 발생하면 무한한 시간동안 블로킹된다.
      • 따라서 블로킹 제한시간을 거는 것이 좋다.
    • 또한 어디서 어떤 작업 도중 에러가 발생했는지 파악하기 어렵다.

  • Version 2
    • 외부 스레드에서 작업 도중 생긴 예외를 전달하기 위해 completeExceptionally 메서드를 사용할 수 있다.

      • Future 가 작업 도중 발생한 예외를 참조하며, 작업이 비정상 종료되므로 future객체.get() 메서드의 블로킹이 해제된다.

      • 어디서 어떤 작업 도중 에러가 발생했는지 정확히 알 수 있다>

public class Shop {

    private final String name;
    private final Random random;

    public Shop(String name) {
        this.name = name;
        random = new Random(name.charAt(0) * name.charAt(1) * name.charAt(2));
    }

    public double getPrice(String product) {
        return calculatePrice(product);
    }

    public Future<Double> getPriceAsync(String product) {
        CompletableFuture<Double> futurePrice = new CompletableFuture<>();

        new Thread(() -> {
            try {
                double price = calculatePrice(product);
                futurePrice.complete(price);
            } catch (Exception ex) {
                futurePrice.completeExceptionally(ex);      // 작업 도중 문제가 발생하면, 발생한 에러를 포함시켜 Future 를 종료
            }
        }).start();

        return futurePrice;
    }

    private double calculatePrice(String product) {
        delay();
        throw new RuntimeException("존재하지 않는 상품입니다.");       // 작업 도중 예외를 발생시키도록 코드 작성
//        return random.nextDouble() * product.charAt(0) + product.charAt(1);
    }
}
  • futurePrice.completeExceptionally(ex)
    • Future 가 작업 도중 발생한 예외를 참조하도록 설정하고 Future 작업 종료

  • 실행결과

  • Version 3

    • 팩토리 메서드를 사용하여 CompletableFuture 를 간단하게 만들 수 있다.

    • 기본값은 ForkJoinPoolExecutor 중 하나가 작업을 수행한다.

public class Shop {

    private final String name;
    private final Random random;

    public Shop(String name) {
        this.name = name;
        random = new Random(name.charAt(0) * name.charAt(1) * name.charAt(2));
    }

    public double getPrice(String product) {
        return calculatePrice(product);
    }

    public Future<Double> getPriceAsync(String product) {
        return CompletableFuture.supplyAsync(() -> calculatePrice(product));     // ForkJoinPool 의 Executor 중 하나가 작업을 수행한다.
    }

    private double calculatePrice(String product) {
        delay();
        return random.nextDouble() * product.charAt(0) + product.charAt(1);
    }
}
  • CompletableFuture.supplyAsync(() -> calculatePrice(product))
    • 팩토리 메서드를 사용하여 간단하게 CompletableFuture 생성
  • Version 2 와 동일한 기능을 하는 코드이다.
    • 코드량은 훨씬 줄어든다.

CompletableFuture 사용예제 (2)

  • 최저가를 검색하는 동기 API를 활용하여 효율적인 검색 애플리케이션을 만들어 본다.

  • 여러개의 상점에서의 최저가를 검색한다.

  • 마찬가지로 점진적으로 개선해간다.

  • Version 1

    • 순차적으로 동기 메서드를 호출한다.
public class Shop {

    private final String name;
    private final Random random;

    public Shop(String name) {
        this.name = name;
        random = new Random(name.charAt(0) * name.charAt(1) * name.charAt(2));
    }

    public double getPrice(String product) {            // 동기 메서드
        return calculatePrice(product);
    }

    public String getName() {
        return name;
    }

    private double calculatePrice(String product) {
        delay();        // 외부 API 요청에 의해 걸리는 작업시간 1초
        return random.nextDouble() * product.charAt(0) + product.charAt(1);
    }
}
  • 동기 메서드 getPrice(String product) 를 가진 Shop 클래스
    • 메서드 작업 완료시간은 1초이다.

public class Finder {

    private final List<Shop> shops = List.of(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShop"),
            new Shop("BuyItAll")
    );

    public List<String> findPricesSequential(String product) {      // 4개의 shop 에 대해서 1초씩 걸리는 동기 메서드를 차례대로 호출
        return shops.stream()
                .map(shop -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)))
                .collect(Collectors.toList());
    }
}
  • 여러 상점들에서 최저가 상품을 찾는 클래스
  • findPricesSequential(String product)
    • 4개의 shopgetPrice 동기 메서드를 차례대로 호출
    • 약 4초의 작업시간을 갖는다.

  • Version 2

    • 병렬적으로 동기 메서드를 호출한다.
public class Finder {

    private final List<Shop> shops = List.of(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShop"),
            new Shop("BuyItAll")
    );

    public List<String> findPricesParallel(String product) {    // 4개의 shop을 병렬로 나눠 1초씩 걸리는 동기 메서드를 호출
        return shops.parallelStream()
                .map(shop -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)))
                .collect(Collectors.toList());
    }
}
  • 여러 상점들에서 최저가 상품을 찾는 클래스
  • findPricesParallel(String product)
    • 4개의 shop 을 병렬 스트림으로 나눈뒤 getPrice 동기 메서드 호출
    • 약 1초의 작업시간을 갖는다.

public class Finder {

    private final List<Shop> shops = List.of(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShop"),
            new Shop("BuyItAll"),
            new Shop("ShopEasy")
    );

    public List<String> findPricesParallel(String product) {    // 5개의 shop을 병렬로 나눠 1초씩 걸리는 동기 메서드를 호출
        return shops.parallelStream()
                .map(shop -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)))
                .collect(Collectors.toList());
    }
}
  • 5개의 shop 에 대해 똑같은 동작을 수행하면 약 2초의 작업시간을 갖는다.

  • 일반적으로 스레드 풀에서 제공하는 스레드 수가 4개이기 때문이다.

  • shop 이 9개면 약 3초의 작업시간을 갖게 된다.

  • Version 3

    • 동기 메서드 작업을 각각의 외부 스레드에서 실행시키는 CompletableFuture 리스트로 만든다.

    • 그 후 CompletableFuture 리스트를 돌면서 작업 결과를 한번에 가져온다.

public class Finder {

    private final List<Shop> shops = List.of(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShop"),
            new Shop("BuyItAll"),
            new Shop("ShopEasy")
    );

    public List<String> findPricesFuture(String product) {      // CompletableFuture 리스트로 만들고, CompletableFuture 리스트의 모든 작업 결과를 가져와 반환한다.
        List<CompletableFuture<String>> priceFutures = shops.stream()
                .map(shop -> CompletableFuture.supplyAsync(
                                () -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product))
                        )
                )
                .collect(Collectors.toList());

        return priceFutures.stream()
                .map((completableFuture) -> completableFuture.join())       // future.get() 과 유사하나, 예외를 발생시키지 않는다.
                .collect(Collectors.toList());
    }
}
  • 여러 상점들에서 최저가 상품을 찾는 클래스
  • findPricesFuture(String product)
    • 스트림 연산의 게으른 특성때문에 하나의 스트림 파이프라인으로 처리하면 모든 요청 동작이 동기적 · 순차적으로 수행된다.
    • 따라서 두 개의 스트림 파이프라인으로 동작을 처리한다.


  • 4개의 shop 에 대해 약 2초의 작업시간을 갖는다.

  • 5개의 shop 에 대해 약 2초의 작업시간을 갖는다.

  • 7개의 shop 에 대해 약 3초의 작업시간을 갖는다.

  • 1개의 Main 스레드가 실행중이고, 3개의 외부 스레드가 동작을 하므로 3개 단위로 shop 을 할당한다.
    • Version 2 의 4개 단위 shop 할당보다 성능이 떨어진다.
    • 따라서, 스레드 풀 설정을 통해 더 최적화해야 한다.

public class Finder {

    private final List<Shop> shops = List.of(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShop"),
            new Shop("BuyItAll"),
            new Shop("ShopEasy"),
            new Shop("ShopEasy1"),
            new Shop("ShopEasy2"),
            new Shop("ShopEasy3"),
            new Shop("ShopEasy4")
    );

    private final Executor executor = Executors.newFixedThreadPool(shops.size(), (Runnable r) -> {  
        Thread thread = new Thread(r);
        thread.setDaemon(true);             // 프로그램 종료시 실행중인 스레드가 종료되도록 데몬스레드로 설정
        return thread;
    });

    public List<String> findPricesFuture(String product) {      // CompletableFuture 리스트로 만들고, CompletableFuture리스트의 모든 작업 결과를 가져와 반환한다.
        List<CompletableFuture<String>> priceFutures = shops.stream()
                .map(shop -> CompletableFuture.supplyAsync(
                                () -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)),
                                executor		// 스레드 풀 설정
                        )
                )
                .collect(Collectors.toList());

        return priceFutures.stream()
                .map((completableFuture) -> completableFuture.join())       // future.get() 과 유사하나, 예외를 발생시키지 않는다.
                .collect(Collectors.toList());
    }
}
  • Executors.newFixedThreadPool(스레드 개수, 스레드 팩토리)

  • 스레드가 너무 많으면 서버가 크래시되고, 스레드가 너무 적으면 CPU 활용율이 떨어지므로 적절한 개수 선택이 중요하다.
    • 애플리케이션의 calculatePrice 메서드는 대부분이 외부 API (상점) 의 응답을 기다리는 시간이다.
    • 그러므로 상점의 개수만큼 스레드 개수를 설정하여 CPU 활용비율을 높인다.

  • 스레드 팩토리에서 스레드 생성 로직을 설정한다.
    • 프로그램 종료 시 실행 중인 스레드가 종료되도록 데몬 스레드로 설정한다.

  • CompletableFuture.supplyAsync(작업, Executor 객체)
    • Executor 를 파라미터로 넘겨주어 작업을 처리할 스레드 풀을 지정할 수 있다.

  • 상점이 몇 개이든 상관 없이 약 1초의 작업시간을 갖는다.
    • 가장 효과적으로 최적화 완료

스트림 병렬화 vs CompletableFuture 병렬화

  • 스트림 병렬화는 I/O와 같은 기다리는 작업이 없는 계산 중심의 동작을 실행할 때 사용하는 것이 좋다.

    • 모든 스레드가 대기하지 않고 계산작업을 하므로 스레드 수가 프로세서 코어 수 이상으로 많아질 필요가 없다.
  • CompletableFuture 병렬화는 I/O와 같은 기다리는 작업이 많을 때 사용하는 것이 좋다.

    • 대기 시간과 계산 시간 비율에 적합한 스레드 수를 설정하여 CPU 활용율을 높일 수 있다.

CompletableFuture 조합 활용

  • CompletableFuture 가 제공하는 메서드를 활용하여 Future 를 조합할 수 있다.
public class Finder {

    private final List<Shop> shops = List.of(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShop"),
            new Shop("BuyItAll"),
            new Shop("ShopEasy")
    );

    public List<String> findPricesSequential(String product) {
        return shops.stream()
                .map(shop -> shop.getPrice(product))            // shop 객체 -> 가격과 상품정보 (문자열), 1초가 걸리는 작업
                .map(priceStr -> Quote.parse(priceStr))         // 가격 문자열 -> quote 객체
                .map(quote -> Discount.applyDiscount(quote))    // quote 객체 -> 가격 (double), 1초가 걸리는 작업
                .collect(Collectors.toList());
    }
}
  • 순차적으로 동기 메서드를 호출하는 방식

  • shop.getPrice(product), Discount.applyDiscount(quote)
    • 두 메서드는 외부 API 호출때문에 각각 1초의 대기시간이 필요하다.

  • 따라서 5개의 shop 이 있는 경우 findPricesSequential(String product) 메서드는 약 10초의 작업시간이 필요하다.

public class Finder {

    private final List<Shop> shops = List.of(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShop"),
            new Shop("BuyItAll"),
            new Shop("ShopEasy")
    );

    private final Executor executor = Executors.newFixedThreadPool(shops.size(), (Runnable r) -> {
        Thread thread = new Thread(r);
        thread.setDaemon(true);            
        return thread;
    });


    public List<String> findPrices(String product) {
        List<CompletableFuture<String>> priceFutures = shops.stream()
                .map(shop -> CompletableFuture.supplyAsync(         // shop 객체 -> 가격과 상품정보 (문자열)을 계산하는 작업들 생성
                                () -> shop.getPrice(product),
                                executor
                        )
                )
                .map(future -> future.thenApply(priceStr -> Quote.parse(priceStr))) // 가격과 상품정보 (문자열) 작업이 완료되면 -> 결과물로 quote 객체 생성
                .map(future -> future.thenCompose(quote ->                          // quote 객체 결과물 생성이 완료되면 -> 결과물을 가지고 할인가격 (double)
                                CompletableFuture.supplyAsync(                      //                                  을 계산하는 작업들 생성
                                        () -> Discount.applyDiscount(quote),
                                        executor
                                )
                        )
                )
                .collect(Collectors.toList());

        return priceFutures.stream()
                .map(CompletableFuture::join)
                .collect(Collectors.toList());
    }
}
  • CompletableFuture 병렬화와 조합으로 처리하는 방식

  • 선행future.thenApply(Function)
    • 선행작업이 끝나면 선행작업의 결과물을 Function 에 입력으로 전달한다.
    • Function 의 출력을 Future 로 반환한다.
    • 이때 Function 의 출력은 Future 가 아니다. (map 과 유사한 동작)

  • 선행future.thenCompose(Function)
    • 선행작업이 끝나면 선행작업의 결과물을 Function 에 입력으로 전달한다.
    • Function 의 출력이 Future 이다.
    • Function 이 출력한 Future 그 자체가 반환하는 Future 가 된다. (flatMap 과 유사한 동작)




  • 몇 개의 shop 이 있든 약 2초의 작업시간이 필요하다.

public class Finder {

    // ...

    public Future<Double> findPriceInUSD(String product) {
        Shop shop = new Shop("BestMart");

        // 가격 계산, 1초 소요
        CompletableFuture<Double> futurePrice = CompletableFuture.supplyAsync(() -> shop.getPrice(product));

        // 환율 계산, 1초 소요
        CompletableFuture<Double> futureRate = CompletableFuture.supplyAsync(() -> ExchangeService.getRate(ExchangeService.Money.EUR, ExchangeService.Money.USD));

        // 가격 계산 작업과 환율 계산 작업이 끝나면 두 결과물을 가지고 작업 수행
        return futurePrice.thenCombine(futureRate, (price, rate) -> price * rate);
    }
}
  • future1.thenCombine(future2, BiFunction)
    • future1, future2 가 참조하는 작업이 모두 끝나면, 두 작업 결과물을 입력으로 BiFunction 수행
    • BiFunction 의 출력을 Future 로 반환한다.

  • thenCombineAsync 가 아닌 thenCombine 이기 때문에 두 future 를 합치는 작업은 따로 스레드를 만들지 않는다.

CompletableFuture 의 타임아웃

  • Future 의 작업이 끝나기를 기다리며 무한정 블로킹하는 것은 좋지 않다.

    • 블로킹 제한시간을 거는 것이 좋다.
    CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> {
                System.out.println("엄청 오래걸리는 작업 중...");
                delay();
                delay();
                delay();					// 3초 동안 작업
                return "작업 성공적 완료";
            })
            .orTimeout(1, TimeUnit.SECONDS);    // 결과를 기다리는 시간이 1초가 지나면 TimeoutException 발생

    System.out.println(completableFuture.get());
  • completableFuture객체.orTimeout(시간, 시간 단위)
    • 작업 결과를 기다리며 블로킹하는 시간이 설정한 제한시간을 초과하면 TimeoutException 발생

    CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> {
                System.out.println("엄청 오래걸리는 작업 중...");
                delay();
                delay();
                delay();
                return "작업 성공적 완료";
            })
            .completeOnTimeout("작업 부분적 완료 ㅠ", 500, TimeUnit.MILLISECONDS); // 결과를 기다리는 시간이 0.5초가 지나면 기본값 반환

    System.out.println(completableFuture.get());
  • completableFuture객체.orCompleteOnTimeout(기본값, 시간, 시간 단위)
    • 작업 결과를 기다리며 블로킹하는 시간이 설정한 제한시간을 초과하면 작업 결과대신 기본값을 반환

CompletableFuture 의 종료에 대응하는 방법

  • future객체.thenAccept(Consumer)

    • CompletableFuture 작업이 끝나면 작업의 결과물을 Consumer 에 입력으로 전달하여 작업 결과를 소비한다.

    • CompletableFuture 의 작업 결과를 어떻게 소비할지 미리 정했으므로 메서드의 반환값은 CompletableFuture<Void> 이다.

  • CompletableFuture.allOf(CompletableFuture 배열)

    • CompletableFuture<Void> 를 반환한다.

    • 파라미터로 전달된 모든 CompletableFuture 가 완료되면 앞서 반환한 CompletableFuture<Void> 작업도 완료된다.

    • join() 을 함께 사용해 모든 CompletableFuture 의 실행완료를 기다릴 수 있다.

    CompletableFuture[] futures = shops.stream()
            .map(shop -> CompletableFuture.supplyAsync(
                            () -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)),
                            executor                                                                            
                    )
            )
            .map(future -> future.thenAccept(System.out::println))      
            .toArray(size -> new CompletableFuture[size]);
    
    CompletableFuture.allOf(futures).join();

  • CompletableFuture.anyOf(CompletableFuture 배열)

    • CompletableFuture<Void> 를 반환한다.

    • 파라미터로 전달된 모든 CompletableFuture 중 하나라도 작업이 끝나면 앞서 반환한 CompletableFuture<Void> 작업이 완료된다.


  • 사용 예제 코드
public class Util {

	// ...

    private static final Random random = new Random();

    public static void randomDelay() {				// 랜덤 시간 지연을 만들어낸다.
        int delay = 500 + random.nextInt(2000);
        try {
            Thread.sleep(delay);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 실제 외부 API 호출은 응답 시간이 균일하지 않고 제각각이다.
  • 이를 표현하기 위한 randomDelay 메서드는 0.5 ~ 2.5초의 지연 시간을 만든다.

    private Stream<CompletableFuture<String>> findPricesStream(String product) {
        return shops.stream()
                .map(shop -> CompletableFuture.supplyAsync(         // shop 객체 -> 가격과 상품정보 (문자열)을 계산하는 작업들 생성
                                () -> shop.getPriceRandom(product),
                                executor
                        )
                )
                .map(future -> future.thenApply(priceStr -> Quote.parse(priceStr))) // 가격과 상품정보 (문자열) 작업이 완료되면 -> 결과물로 quote 객체 생성
                .map(future -> future.thenCompose(quote ->                          // quote 객체 결과물 생성이 완료되면 -> 결과물을 가지고 할인가격 (double)
                                CompletableFuture.supplyAsync(                      //                                  을 계산하는 작업들 생성
                                        () -> Discount.applyDiscount(quote),
                                        executor
                                )
                        )
                );
    }

최저가 찾는 작업을 스트림으로 반환하는 findPricesStream(String product) 메서드


    public void findPricesAccept(String product) {
        long start = System.nanoTime();

        CompletableFuture[] futures = findPricesStream(product)		// 앞서 만든 스트림 반환 메서드 호출
                .map(future -> future.thenAccept(
                        priceTag -> System.out.println(priceTag + " done in " + (System.nanoTime() - start) / 1_000_000 + " msecs")
                ))
                .toArray(size -> new CompletableFuture[size]);

        CompletableFuture.allOf(futures).join();

        System.out.println("All shops have now responded in " + (System.nanoTime() - start) / 1_000_000 + " msecs");
    }
  • 앞서 만든 최저가를 찾는 작업 스트림을 반환하는 메서드를 호출해서 로직을 이어나간다.

  • 실행결과
LetsSaveBig price is 135.58 done in 1559 msecs
BestPrice price is 110.93 done in 1708 msecs
ShopEasy price is 167.28 done in 2012 msecs
MyFavoriteShop price is 192.72 done in 3358 msecs
BuyItAll price is 184.74 done in 3417 msecs
All shops have now responded in 3417 msecs
  • 시간이 적게 걸리는 작업의 결과를 미리 확인할 수 있다.
  • 만약 thenAccept 메서드를 사용하지 않았다면, 시간이 적게 걸리는 작업의 결과도, 모든 작업이 끝난 이후 얻을 수 있었다.

CompletableFuture API: https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html

  • Async 키워드가 메서드 끝에 붙으면 메서드를 호출한 스레드와 다른 스레드에서 작업을 실행한다.

    • 스레드 전환 오버헤드가 발생하고, 스레드가 없으면 대기해야 할 수 있으므로 주의해서 아요해야 한다.
  • Async 키워드가 메서드 끝에 없으면 메서드를 호출한 스레드에서 계속해서 작업을 실행한다.


리액티브 프로그래밍

  • 고전적인 대규모 애플리케이션의 특징

    • 기가바이트 데이터

    • 초 단위 응답시간

    • 몇 시간의 유지보수 시간

  • 오늘날의 대규모 애플리케이션의 특징

    • 페타바이트 단위 빅데이터

    • 모바일 환경부터 고성능의 컴퓨터까지 다양한 환경에서 애플리케이션 사용

    • 밀리초 단위 응답시간

    • 365일 무중단 서비스

  • 오늘날의 애플리케이션 요구사항을 만족시키기 위해 도입된 새로운 프로그래밍 패러다임이 바로 리액티브 프로그래밍이다.


Reactive Manifesto

  • 리액티브 선언문

    • 리액티브 애플리케이션과 시스템의 핵심 원칙을 정의한다.
  • Responsive (반응성)

    • 일정하고 예상할 수 있는 반응시간을 제공한다.

      • 사용자가 기대치를 가진다.

      • 사용자가 확신을 가지고 애플리케이션을 이용한다.

  • Resilient (회복성)

    • 장애가 발생해도 시스템이 반응해야 한다.

      • 발생한 장애를 고립시켜 장애의 전파를 막는다.
  • Elastic (탄력성)

    • 애플리케이션의 특정 컴포넌트가 작업 부하를 받게되면 자동으로 해당 컴포넌트에 할당된 자원수를 늘린다.
  • Message-driven (메시지 주도)

    • 리액티브 시스템은 비동기 메시지 전달에 의존한다.

      • 각 컴포넌트는 느슨하게 결합되며, 역압력을 가해 메시지의 흐름을 제어할 수 있다.

함수형 관점으로 생각하기

  • 가변 데이터 구조를 여러 클래스에서 공유하며 읽고 쓰게되면 예상하지 못한 변수값을 갖게 될 수 있다.

    • 데이터 갱신 사실을 추적하기 어렵다.

    • 이 때문에 대규모 시스템에서 빈번하게 버그가 발생한다.


  • 시스템의 어떤 자료구조도 바꾸지 않는 함수형 프로그래밍을 통해 버그를 줄일 수 있다.

선언형 프로그래밍 vs 함수형 프로그래밍

  • 선언형 프로그래밍

    • 작업을 어떻게 수행할 것인지에 집중한다.

    • 작업 과정을 모두 서술한다.

  • 함수형 프로그래밍

    • 작업을 통해 무엇을 얻고자 하는지에 집중한다.

    • 작업 과정은 내부적으로 알아서 처리하므로 서술하지 않는다.

      • 내부 반복 방식

함수형 프로그래밍 용어

  • 함수형 프로그래밍에서의 함수

    • 수학적인 함수와 같다.

    • 0개 이상의 입력을 가진다.

    • 1개 이상의 출력을 가진다.

    • 부작용이 없어야 한다.

선언형 프로그래밍에서의 함수 (메서드)


함수형 프로그래밍에서의 함수


  • 부작용 (side-effect)

    • 함수 내에 포함되지 않고 시스템의 다른 부분에 영향을 미치는 기능을 말한다.

    • 예를 들면 다음과 같다.

      • 외부의 자료구조를 고치거나 객체 필드에 값을 할당 & 변경

      • 예외 발생

      • I/O 동작 수행

  • 참조 투명성

    • 같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환하는 함수는 참조 투명성을 만족한다.

    • Random 유틸리티를 사용하는 메서드는 참조 투명성을 위배한다.

    • 다른 클래스에서 값을 바꿀 수 있는 가변 데이터 구조를 사용하는 메서드는 참조 투명성을 위배한다.

부작용이 없고, 참조 투명성을 만족하는 함수를 순수함수라 한다.


  • 일급 함수

    • 일반 값처럼 취급할 수 있는 함수를 일급 함수라 한다.

    • 함수를 파라미터로 전달할 수 있고

    • 함수를 메서드의 결과값으로 반환할 수 있고

    • 함수를 자료구조에 저장할 수 있다.

함수를 인자로 받거나, 함수를 결과로 반환하는 함수를 고차 함수 라 한다.


함수형 프로그래밍

  • 부작용이 없는 순수 함수를 사용하는 프로그래밍 패러다임이다.

    • 시스템의 다른 부분에 영향을 주지 않으므로 유지보수가 쉽다.

    • 메서드가 서로 간섭하지 않으므로 lock 을 사용하지 않고 쉽게 멀티스레드로 동작할 수 있다.

  • 함수나 메서드는 지역 변수만을 변경해야 한다.

    • 참조하는 객체는 상태를 바꿀 수 없는 불변 객체여야 한다.

    • 어떤 예외도 발생해선 안된다.

    • 메서드 내에서 생성한 객체나 자료구조의 변경은 가능하다.


출처

모던 자바 인 액션 (라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트)


엄격한 포크 / 조인 이미지 (ehdrms2034 님)
https://velog.io/@ehdrms2034/Java-8-CompletableFuture%EC%99%80-%EB%A6%AC%EC%95%A1%ED%8B%B0%EB%B8%8C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%BB%A8%EC%85%89%EC%9D%98-%EA%B8%B0%EC%B4%88


CompletableFuture (Kangworld 님)
https://kangworld.tistory.com/218


리액티브 프로그래밍 (매직 님)
https://devocean.sk.com/blog/techBoardDetail.do?ID=165099&boardType=techBlog


pub-sub 프로토콜 이미지 (alachisoft)
https://www.alachisoft.com/ko/resources/docs/ncache-5-0/prog-guide/publish-subscribe-ncache.html


압력 & 역압력 (geonyeongkim 님)
https://geonyeongkim-development.tistory.com/61

0개의 댓글