책을 읽으며 중요하다고 생각되는 내용들을 정리하였다.
책을 정리해도 내용이 많아서 넘버링을 하였다.
11장 null 대신 Optional 클래스
12장 새로운 날짜와 시간 API
13장 디폴트 메서드
15장 CompletableFuture 와 리액티브 프로그래밍 컨셉의 기초
16장 CompletableFuture, 안정적 비동기 프로그래밍
17장 리액티브 프로그래밍
18장 함수형 관점으로 생각하기
가장 흔히 발생하는 에러인 NullPointerException
을 발생시킨다.
null
확인 코드는 코드 가독성을 떨어뜨린다.
null
은 무형식이므로 모든 참조 형식에 null
을 할당할 수 있다.
null
이 퍼졌을 때, 애초에 null
이 어떤 의미로 사용되었는지 알 수 없다.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
발생
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
에러 발생을 막는다.- 출구가 많아 유지보수가 어렵다.
<T>
클래스null
과 관련한 문제를 해결하는 클래스
선택형 값을 캡슐화하는 래퍼 클래스
- 값이 있으면 값을 감싼
Optional
클래스를 반환한다.- 값이 없으면
null
이 아니라 빈Optional
클래스를 반환한다.
null
을 참조하면 예외가 발생하지만- 빈
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
클래스로 감싼다.
값이 반드시 있어야만 하는 값에는 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;
}
}
Insurance
의name
은Optional<String>
이 아니다.- 만약
Insurance
의name
참조 시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
도 기본형으로 특화된 OptionalInt
, OptionalLong
, OptionalDouble
등이 있다.
요소가 많은 스트림의 경우 기본형 특화 스트림으로 큰 성능 향상을 이룰 수 있지만, 요소가 최대 한개인 Optional
은 성능 향상의 효과가 미미하다.
기본형 특화 스트림은 map
, flatMap
, filter
등을 지원하지 않는다.
따라서 기본형 Optional
은 사용하지 않는 것이 좋다.
Properties
와 name
을 파라미터로 받아 name
에 해당하는 프로퍼티가 존재하고, 양수인 경우엔 해당 값을 그대로 반환한다.
고전적인 방식의 명령형 코드
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 객체 반환
}
}
간결하고 보다 가독성있게 처리한다.
자바 1.0: Date
클래스
자바 1.1: Calendar
클래스
자바 8 부터는, 앞선 날짜 · 시간 API의 문제점들을 보완한 새로운 날짜 · 시간 API를 제공한다.
LocalDate
, LocalTime
, Instant
, Duration
, Period
모두 불변 클래스이다.
불변 객체이다.
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
사람이 이해하는 날짜 · 시간은 기계가 이해하기 어렵다.
기계적인 관점에서의 시간을 표현한다.
따라서 사람이 읽을 수 있는 시간정보를 제공하지 않는다.
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
.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 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 를 오버라이딩하여 새로운 동작을 정의할 수도 있고, 인터페이스에 구현된 그대로 사용할 수도 있다.
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 methodfoo()
를 재정의하였다.Yeon
클래스는Person
인터페이스의 default methodfoo()
를 구현하지 않고 그대로 사용한다.
public static void main(String[] args) {
Hyun hyun = new Hyun();
hyun.foo(); // hello!
Yeon yeon = new Yeon();
yeon.foo(); // foo!
}
hyun
은 재정의한 동작이,yeon
은 인터페이스에 정의된 동작이 수행된다.
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 구현 역시 상속받는다.
따라서 하위 구현 클래스들의 코드를 변경하지 않고도 소스 호환성을 유지할 수 있다.
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);
}
}
컴파일 에러 없이 잘 실행된다.
둘 다 추상 메서드와 구현된 메서드를 정의할 수 있다.
클래스는 하나의 추상 클래스만 상속받을 수 있지만 인터페이스는 여러개를 구현할 수 있다.
자바의 클래스는 하나의 부모 클래스를 상속 받을 수 있다.
자바의 클래스는 여러개의 인터페이스를 동시에 구현할 수 있다.
자바 8에 default method 가 추가되어, 같은 시그니처를 갖는 default method 를 여러개 상속받는 상확이 발생할 수 있다.
따라서 자바 컴파일러는 이러한 충돌을 규칙을 가지고 해결한다.
해석 규칙은 3가지이다.
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번
이외의 상황에선 서브 인터페이스가 이긴다.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, 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
}
}
운영체제 스레드를 만들고 종료하는 것은 비싼 비용이 든다.
제한적으로 지원하는 스레드 수를 초과해서 스레드를 만들게되면 애플리케이션이 크래시 될 수 있다.
여러개의 스레드를 미리 만들어 스레드 풀에 보관한다.
스레드 작업이 필요해지면, 스레드 풀에서 놀고 있는 스레드를 사용해 작업을 수행한다.
작업이 끝나면 사용한 스레드를 스레드 풀에 다시 반환한다.
작업이 끝나지 않으면 스레드를 계속해서 물고 있으므로 데드락을 주의해야 한다.
하드웨어에 맞는 적절한 스레드 수를 유지할 수 있다.
스레드 사용 시 스레드 생성 오버헤드가 없다.
스레드 풀에 작업중인 스레드가 있으면 자바 프로그램이 종료되지 않으므로, 프로그램 종료 전엔 스레드 풀을 종료해야 한다.
엄격한 포크 / 조인
부모 태스크는 하나 이상의 자식 태스크를 포크 (스레드 생성) 한다.
부모 태스크는 자식 태스크가 끝나기를 완전히 기다려야 한다.
@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
메서드를 사용해 스레드 작업을 중지하고 대기시킨다.
다른 어떤 동작을 완료하길 기다리는 블로킹 동작 역시 스레드를 대기시킨다.
Future
의 get()
메서드
네트워크, 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 작업시간
만큼 스레드를 사용한다.
기존의 Future
는 여러 Future
를 조합하는 것이 어렵다.
Future
에서 반환하는 결과값을 가지고 작업을 수행해야 한다면, 선행 작업 Future
의 get()
이후에 작성되어야 한다.
특정 작업이 오래걸려 블로킹이 발생하는 경우 자원이 낭비된다.
만약 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
를 만들 수 있다.
따라서 작업 결과물을 만드는 작업 코드를 나중에 넣어주어도 된다.
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 task1
과Future task2
를 조합한다.task1
과task2
의 작업이 끝나면 자동으로task1
의 결과물과task2
의 결과물을 입력으로 하는BiFunction
작업을 다른 스레드에서 실행한다.task1
과task2
작업이 모두 끝나기전엔 작업을 시작하지 않는다.
- 메인 스레드가
task1
작업과task2
작업이 끝나길 기다릴 필요 없다.
afterTask
작업만 끝나길 기다리면 된다.
리액티브 프로그래밍이란 변화에 반응하는 시스템을 구축하기 위한 프로그래밍 패러다임
데이터를 비동기적으로 처리한다.
이벤트 기반 구조를 통해 실시간으로 데이터 변화에 반응한다.
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
리액티브 프로그래밍에서 스레드 활용 시 중요하게 다뤄지는 개념
압력
구독자가 처리할 수 있는 양에 비해 발행자가 무수히 많은 데이터를 전달하는 상황
이러한 압력은 문제를 발생시킨다.
역압력
구독자가 처리할 수 있을때만 발행자로부터 데이터를 전달받도록 조절하는 것
정보의 흐름 속도를 구독자가 제어한다.
미래의 어느 시점에 결과를 얻는 모델을 만들기 위한 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
를 도입하였다.
최저가 상품을 찾는 비동기 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초) 만큼 블로킹된다.
- 만약 비동기 메서드에서 할당한 외부 스레드 작업 도중 에러가 발생하면 무한한 시간동안 블로킹된다.
- 따라서 블로킹 제한시간을 거는 것이 좋다.
- 또한 어디서 어떤 작업 도중 에러가 발생했는지 파악하기 어렵다.
외부 스레드에서 작업 도중 생긴 예외를 전달하기 위해 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
를 간단하게 만들 수 있다.
기본값은 ForkJoinPool
의 Executor
중 하나가 작업을 수행한다.
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 와 동일한 기능을 하는 코드이다.
- 코드량은 훨씬 줄어든다.
최저가를 검색하는 동기 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개의
shop
의getPrice
동기 메서드를 차례대로 호출- 약 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초의 작업시간을 갖는다.
- 가장 효과적으로 최적화 완료
스트림 병렬화는 I/O와 같은 기다리는 작업이 없는 계산 중심의 동작을 실행할 때 사용하는 것이 좋다.
CompletableFuture
병렬화는 I/O와 같은 기다리는 작업이 많을 때 사용하는 것이 좋다.
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
를 합치는 작업은 따로 스레드를 만들지 않는다.
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(기본값, 시간, 시간 단위)
- 작업 결과를 기다리며 블로킹하는 시간이 설정한 제한시간을 초과하면 작업 결과대신 기본값을 반환
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일 무중단 서비스
오늘날의 애플리케이션 요구사항을 만족시키기 위해 도입된 새로운 프로그래밍 패러다임이 바로 리액티브 프로그래밍이다.
리액티브 선언문
Responsive (반응성)
일정하고 예상할 수 있는 반응시간을 제공한다.
사용자가 기대치를 가진다.
사용자가 확신을 가지고 애플리케이션을 이용한다.
Resilient (회복성)
장애가 발생해도 시스템이 반응해야 한다.
Elastic (탄력성)
Message-driven (메시지 주도)
리액티브 시스템은 비동기 메시지 전달에 의존한다.
가변 데이터 구조를 여러 클래스에서 공유하며 읽고 쓰게되면 예상하지 못한 변수값을 갖게 될 수 있다.
데이터 갱신 사실을 추적하기 어렵다.
이 때문에 대규모 시스템에서 빈번하게 버그가 발생한다.
선언형 프로그래밍
작업을 어떻게 수행할 것인지에 집중한다.
작업 과정을 모두 서술한다.
함수형 프로그래밍
작업을 통해 무엇을 얻고자 하는지에 집중한다.
작업 과정은 내부적으로 알아서 처리하므로 서술하지 않는다.
함수형 프로그래밍에서의 함수
수학적인 함수와 같다.
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