본문 바로가기

(8)

Chap 12 - 새로운 날짜와 시간 API

자바 API는 복잡한 애플리케이션을 만드는 데 필요한 여러 가지 유용한 컴포넌트를 제공한다. 하지만 자바 API도 완벽하지는 않다. 대부분의 자바 개발자가 지금까지의 날짜와 시간 관련 기능에 만족하지 못했다. 지금까지의 날짜와 시간 문제를 개선하는 새로운 날짜와 시간 API를 제공한다. 자바 1.0에서는 java.util.Date 클래스 하나로 날짜와 시간 관련 기능을 제공했다. 날짜를 의미하는 Date라는 클래스의 이름과는 달리 Date클래스는 특정 시점을 날짜가 아닌 밀리초(ms) 단위로 표현한다. 게다가 1900년을 기준으로 하는 오프셋과 달이 0에서 시작하는 모호한 설계로 유용성이 떨어졌다. 또한 Date 클래스의 toString으로는 반환되는 문자열을 추가로 활용하기가 어렵다. 자바 1.0의 D..

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

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

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

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

Chap05. 스트림 활용

필터링 프레디케이트로 필터링 프레디케이트(불리언을 반환하는 함수)를 인수로 받아서 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다. 예제 List vegetarianMenu = menu.stream() .filter(Dish::isVegatarian) .collect(toList()); 고유 요소 필터링 스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct 메서드도 지원한다. (고유 여부는 스트림에서 만든 객체의 hashCode, equals로 결정된다). List numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4); numbers.stream() .filter(i -> i % 2 == 0) .distinct() .forEach(System.out::..

Chap04. 스트림 소개

스트림이란 무엇인가? 스트림stream은 자바 8 API에 새로 추가된 기능이다. 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다. 또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다(단순히 stream()을 paralleStream() 로 변경하면..). 스트림의 새로운 기능은 다음과 같은 다양한 이득을 준다는 사실을 기억하자. 선언형으로 코드를 구현할 수 있다. 즉, 선언형 코드와 동작 파라미터화를 활용하면 변하는 요구사항에 쉽게 대응할 수 있다. filter, sorted, map, collect 같은 여러 빌등 블록 연산을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있다. 여러 연산을 파이프라인으로 연결해도 여전히 가독성과 명확성이..

Chap03. 람다 표현식

익명 클래스로 다양한 동작을 구현할 수 있지만 만족할 만큼 코드가 깔끔하지는 않았다. 깔끔하지 않은 코드는 동작 파라미터를 실전에 적용하는 것을 막는 요소다. 3장에서는 더 깔끔한 코드로 동작을 구현하고 전달하는 자바 8의 새로운 기능인 람다 표현식을 설명한다. 람다 표현식은 익명 클래스처럼 이름이 없는 함수이고 메서드를 인수로 전달할 수 있으므로 일.단.은 익명 클래스와 비슷하다고 생각하자(람다 표현식은 정확히는 조금 특수한 익명클래스이다). 람다란 무엇인가? 람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 표현할 수 있다. 람다 표현식에는 이름은 없지만 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다. 람다의 특징을 하나씩 자세히 살펴보자. 익명..

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

Chap 12 - 새로운 날짜와 시간 API

728x90

자바 API는 복잡한 애플리케이션을 만드는 데 필요한 여러 가지 유용한 컴포넌트를 제공한다. 하지만 자바 API도 완벽하지는 않다. 대부분의 자바 개발자가 지금까지의 날짜와 시간 관련 기능에 만족하지 못했다.

지금까지의 날짜와 시간 문제를 개선하는 새로운 날짜와 시간 API를 제공한다.

자바 1.0에서는 java.util.Date 클래스 하나로 날짜와 시간 관련 기능을 제공했다. 날짜를 의미하는 Date라는 클래스의 이름과는 달리 Date클래스는 특정 시점을 날짜가 아닌 밀리초(ms) 단위로 표현한다. 게다가 1900년을 기준으로 하는 오프셋과 달이 0에서 시작하는 모호한 설계로 유용성이 떨어졌다. 또한 Date 클래스의 toString으로는 반환되는 문자열을 추가로 활용하기가 어렵다.

자바 1.0의 Date 클래스에 문제가 있다는 점에서는 의문의 여지가 없었지만 과거 버전과 호환성을 깨뜨리지 않으면서 이를 해결할 수 있는 방법이 없었다. 결과적으로 자바 1.1에서는 Date 클래스의 여러 메서드를 deprecated 시키고 java.util.Calendar라는 클래스를 대안으로 제공했다. 안타깝게도 Calendar 클래스 역시 쉽게 에러를 일으키는 설계 문제를 갖고 있었다. 여전히 달의 인덱스가 0부터 시작했다는 것은 변함이 없었고, 심지어 Calendar의 등장으로 개발자들은 Date와 Calendar 사이에서 혼란이 가중된 것이다. 또한 DateFormat 같은 일부 기능은 Date 클래스에만 작동했다.

DateFormat은 Thread safe하지 않다는 문제점도 있었다. 즉, 두 스레드가 동시에 하나의 포매터로 날짜를 파싱할 때 예기치 못한 결과가 일어날 수 있다.

마지막으로 Date와 Calendar는 모두 가변 클래스다. 가변 클래스라는 설계는 유지보수가 어렵게하는 요소이다.

부실한 날짜와 시간 라이브러리 때문에 많은 개발자는 Joda-Time 같인 서드파티 날짜, 시간 라이브러리를 사용했다. 오라클은 좀 더 훌륭한 날짜와 시간 API를 제공하기로 정했다. 결국 자바8에서는 Joda-Time의 많은 기능을 java.time 패키지로 추가했다.

LocalDate, LocalTime, Instant, Duration, Period 클래스

먼저 간단한 날짜와 시간 간격을 정의해보자. java.time 패키지는 LocalDate, LocalTime, LocalDateTime, Instant, Duration, Period 등 새로운 클래스를 제공한다.

LocalDate와 LocalTime 사용

새로운 날짜와 시간 API를 사용할 때 처음 접하게 되는 것이 LocalDate다. LocalDate 인스턴스는 시간을 제외한 날짜를 표현하는 불변 객체다. 특히 LocalDate 객체는 어떤 시간대 정보도 포함하지 않는다.

정적 팩토리 메서드 of로 LocalDate 인스턴스를 만들 수 있다. LocalDate 인스턴스를 만들 수 있다.

아래는 LocalDate 인스턴스의 사용 예시다.

LocalDate date = LocalDate.of(2017, 9, 21); // 2017-09-21
int year = date.getYear(); // 2017
Month month = date.getMonth(); // SEPTEMBER
int day = date.getDayOfMonth(); // 21
DayOfWeek dow = date.getDayOfWeek(); // THURSDAY 
int len = date.lengthOfMonth(); // 31 (3월의 일 수)
boolean leap = date.isLeapYear(); // false (윤년이 아님) 

팩토리 메서드 now는 시스템 시계의 정보를 이용해서 현재 날짜 정보를 얻는다.

LocalDate today = LocalDate.now();

get 메서드에 TemporalField를 전달해서 정보를 얻는 방법도 있다. TemporalField는 시간 관련 객체에서 어떤 필드의 값에 접근할지 정의하는 인터페이스다. Enum 타입인 ChronoField는 TemporalField 인터페이스를 정의하므로 다음 코드에서 보여주는 것처럼 ChornoField의 열거자 요소를 이용해서 원하는 정보를 쉽게 얻을 수 있다.

int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronField.DAY_OF_MONTH);

다음처럼 내장 메서드 getYear(), getMonthValue(), getDayOfMonth() 등을 이용해 가독성을 높일 수 있다.

마찬가지로 13:45:20 같은 시간 정보는 LocalTime 클래스로 표현할 수 있다. 오버로드 버전의 두 가지 정적 메서드 of 로 LocalTime 인스턴스를 만들 수 있다. 즉, 시간과 분을 인수로 받는 of 메서드와 시간과 분, 초를 인수로 받는 of 메서드가 있다. LocalDate 클래스처럼 LocalTime 클래스는 다음과 같은 게터 메서드를 제공한다.

LocalTime = LocalTime.of(13, 45, 20); // 13:45:20
int hour = time.getHour(); // 13
int monute = time.getMinute();
int second = time.getSecond();

parse 메서드에 DateTimeFormatter를 전달할 수도 있다. DateTimeFormatter의 인스턴스는 날짜, 시간 객체의 형식을 지정한다. DateTimeFormatter는 이전에 설명했던 DateFormat 클래스를 대체하는 클래스다. 문자열을 LocalDate나 LocalTime으로 파싱할 수 없을 때 parse 메서드는 DateTimeParseException (RuntimeException을 상속받은 예외)을 일으킨다.

날짜와 시간 조합

LocalDateTime은 LocalDate + LocalTime 이다. LocalDateTime은 날짜와 시간을 모두 표현할 수 있으며 다음 코드에서 보여주는 것처럼 직접 LocalDateTime을 만드는 방법도 있고 날짜와 시간을 조합하는 방법도 있다.

// 2017-09-21T13:45:20
LocalDateTime dt1 = LocalDateTime.of(2017, Month.SEPTEMBER, 21, 13, 45, 20);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(13, 45, 20);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(date);

LocalDate의 atTime 메서드로 시간을 제공하거나 LocalTime에 atDate로 날짜를 제공해서 LocalDateTime을 만드는 방법도 있다. LocalDateTime의 toLocalDatetoLocalTime 메서드로 LocalDate나 LocalTime 인스턴스를 추출할 수 있다.

LocalDate date1 = dt1.toLocalDate();
LocalTime time1 = dt1.toLocalTime();

Instant 클래스 : 기계의 날짜와 시간

기계의 관점에서는 연속된 시간에서 특정 지점을 하나의 큰 수로 표현하는 것이 가장 자연스러운 시간 표현 방법이다. Instant 클래스는 유닉스 에포크 시간(1970sus 1월 1일 0시 0분 0초 UTC)을 기준으로 특정 지점까지의 시간을 초로 표현한다.

팩토리 메서드 ofEpochSecond에 초를 넘겨줘서 Instant 클래스 인스턴스를 만들 수 있다. Instant 클래스는 나노초(10억분의 1초)의 정밀도를 제공한다. 또한 오버로드된 ofEpochSecond 메서드 버전에서는 두 번째 인수를 이용해서 나노초 단위로 시간을 보정할 수 있다. 두 번째 인수에는 0 에서 999,999,999 사이의 값을 지정할 수 있다. 따라서 다음 네 가지 ofEpochSecond 호출 코드는 같은 Instant를 반환한다.

Instant.ofEpochSecond(3);
Instant.ofEpochSecond(3, 0);
Instant.ofEpochSecond(2, 1_000_000_000);
Instant.ofEpochSecond(4, -1_000_000_000);

Duration과 Period 정의

이전까지 설명한 모든 클래스는 Temporal 인터페이스를 구현하는데, Temporal 인터페이스는 특정 시간을 모델링하는 객체의 값을 어떻게 읽고 조작할지 정의한다.

이번에는 두 시간 객체 사이의지속시간 duration을 만들어볼 차례다. Duration 클래스의 정적 팩토리 메서드 between으로 두 시간 객체 사이의 지속시간을 만들 수 있다. 다음 코드에서 보여주는 것처럼 두 개의 LocalTime, 두 개의 LocalDateTime, 또는 두 개의 Instant로 Duration을 만들 수 있다.

Duration d1 = Duration.between(time1, time2); 
Duration d2 = Duration.between(dateTime1, dateTime2);
Duration d3 = Duration.between(instant1, instant2);

LocalDateTime은 사람이 사용하도록, Instant는 기계가 사용하도록 만들어진 클래스로 두 인스턴스는 서로 혼합할 수 없다. 또한 Duration 클래스를 초와 나노초로 시간 단위를 표현하므로 between 메서드에 LocalDate를 전달할 수 없다. 년, 월, 일로 시간을 표현할 때는 Period 클래스를 사용한다. 즉, Period 클래스의 팩토리 메서드 between을 이용하면 LocalDate의 차이를 확인할 수 있다.

Duration threeMinutes = Duration.ofMinutes(3);
Duration threeMinutes = Duration.of(3, ChronoUnit.MINUTES);

Period tenDays = Period.ofDays(10);
Period threeWeeks = Period.ofWeeks(3);
Period twoYearsSixMonthsOnDay = Period.of(2, 6, 1);
메서드 정적 설명
between yes 두 시간 사이의 간격을 생성함
from yes 시간 단위로 간격을 생성함
of yes 주어진 구성 요소에서 간격 인스턴스를 생성함
parse yes 문자열을 파싱해서 간격 인스턴스를 생성함
addTo no 현재값의 복사본을 생성한 다음에 지정된 Temporal 객체에 추가함
get no 현재 간격 정보값을 읽음
isNegative no 간격이 음수인지 확인함
isZero no 간격이 0인지 확인함
minus no 현재값에서 주어진 시간을 뺀 복사본을 생성함
multipliedBy no 현재값에 주어진 값을 곱한 복사본을 생성함
negated no 주어진 값의 부호를 반전한 복사본을 생성함
plus no 현재값에 주어진 시간을 더한 복사본을 생성함
subtractFrom no 지정된 Temporal 객체에서 간격을 뺌

지금까지 다룬 모든 클래스는 불변이다. 불변 클래스는 함수형 프로그래밍(FP) 그리고 스레드 안전성과 도메인 모델의 연관성을 유지하는 데 좋은 특징이다. 하지만 새로운 날짜와 시간 API에서는 변경된 객체 버전을 만들 수 있는 메서드를 제공한다.

날짜 조정, 파싱, 포매터

withAttribute 메서드로 기존의 LocalDate를 바꾼 버전을 직접 간단하게 만들 수 있다.

아래는 바뀐 속성을 포함하는 새로운 객체를 반환하는 메서드를 보여준다.

LocalDate date1 = LocalDate.of(2017, 9, 21);
LocalDate date2 = date1.withYear(2011);
LocalDate date3 = date2.withDayOfMonth(25);
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 2);

위의 예제의 마지막 행처럼 첫 번째 인수로 TemporalField를 갖는 메서드를 사용하면서 좀 더 범용적으로 메서드를 활용할 수 있다. 마지막 with 메서드는 get 메서드와 쌍을 이룬다. 이들 두 메서드는 날짜와 시간 API의 모든 클래스가 구현하는 Temporal 인터페이스에 정의되어 있다. Temporal 인터페이스는 LocalDate, LocalTime, LocalDateTime, Instant처럼 특정 시간을 정의한다. get과 with 메서드로 Temporal 객체의 필드값을 읽거나 고칠 수 있다(정확히는 기존의 Temporal 객체를 바꾸는 것이 아니라 필드를 갱신한 복사본을 만든다. = 함수형 갱신). 어떤 Temporal 객체가 지정된 필드를 지원하지 않으면 UnsupportedTemporalTypeException이 발생한다.

선언형으로 LocalDate를 사용하는 방법도 있다.

LocalDate date1 = LocalDate.of(2017, 9, 21); // 2017-09-21
LocalDate date2 = date1.plusWeek(1); 2017-09-28
LocalDate date3 = date2.minusYear(6); // 2011-09-28
LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS); // 2012-03-28

특정 시점을 표현하는 날짜, 시간 클래스의 공통 메서드

메서드 정적 설명
from yes 주어진 Temporal 객체를 이용해서 클래스의 인스턴스를 생성함
noew yes 시스템 시계로 Temporal 객체를 생성함
of yes 주어진 구성 요소에서 Temporal 객체의 인스턴스를 생성함
parse yes 문자열을 파싱해서 Temporal 객체를 생성함
atOffset no 시간대 오프셋과 Temporal 객체를 합침
atZone no 시간대 오프셋과 Temporal 객체를 합침
format no 지정된 포매터를 이용해서 Tempral 객체를 문자열로 변환함
get no Temporal 객체의 상태를 읽음
minus no 특정 시간을 뺀 Temporal 객체의 복사본을 생성함
plus no 현특정 시간을 더한 Temporal 객체의 복사본을 생성함
with no 일부 상태를 바꾼 Temporal 객체의 복사본을 생성함

TemporalAdjusters 사용하기

복잡한 날짜 조정기능이 필요한 경우 오버로드된 버전의 with 메서드에서 좀 더 다양한 동작을 수행할 수 있도록 해주는 TemporalAdjuster를 전달하는 방법으로 문제를 해결할 수 있다. 날짜와 시간 API는 다양한 상황에서 사용할 수 있도록 다양한 TemporalAdjuster를 제공한다.

import static java.time.temporal.TemporalAdjusters.*;

LocalDate date1 = LocalDate.of(2014, 3, 18);
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY));
LocalDate date3 = date2.with(lastDayOfMonth());

|메서드 | 설명|
|---|---|---|
|dayOfWeekInMonth|서수 요일에 해당하는 날짜를 반환하는 TemporalAdjuster를 반환함|
|firstDayOfMonth|현재 달의 첫 번째 날짜를 반환|
|firstDayOfNextMonth|다음 달의 첫 번째 날짜를 반환|
|firstDayOfNextYear|내년의 첫 번째 날짜를 반환|
|firstInMonth|올해의 첫 번째 날짜를 반환|
|lastDayOfMonth|현재 달의 마지막 날짜를 반환|
|lastDayOfNextMonth|다음 달의 마지막 날짜를 반환|
|lastDayOfNextYear|내년의 마지막 날짜를 반환|
|lastDayOfYear|올해의 마지막 날짜를 반환|
|lastInMonth|현재 달의 마지막 요일에 해당하는 날짜를 반환|
|next previous|현재 달에서 현재 날짜 이후로 지정한 요일이 청므으로 나타나는 날짜를 반환하는 TemporalAdjuster를 반환함|
|nextOrSame|현재 날짜 이후로 지정한 요일이 처음으로 나타나는 날짜를 반환하는 TemporalAdjuster를 반환함|
|previousOrSame|현재 날짜 이후로 지정한 요일이 이전으로 나타나는 날짜를 반환하는 TemporalAdjuster를 반환함|

@FunctionalInterface
public interface TemporalAdjuster {
    Temporal adjustInto(Temporal temporal);
}

TemporalAdjuster 인터페이스 구현은 Temporal 객체를 어떻게 다른 Temporal 객체로 변환할지 정의한다. 즉, TemporalAdjuster 인터페이스는 UnaryOperator<Temporal>과 같은 형식으로 간주할 수 있다.

날짜와 시간 객체 출력과 파싱

날짜와 시간 관련 작업에서 포매팅과 파싱은 서로 떨어질 수 없는 관계다. 포매팅과 파싱 전용 패키지인 java.time.format이 새로 추가되었을 정도이다. 정적 팩토리 메서드와 상수를 이용해서 손쉽게 포매터를 만들 수 있다. DateTimeFormatter 클래스는 BASIC_ISO_DATE와 ISO_LOCAL_DATE등의 상수를 미리 정의하고 있다. DateTimeFormatter를 이용해서 날짜나 시간을 특정 형식의 문자열로 만들 수 있다.

LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE);
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE);

기존의 java.util.DateFormat 클래스와 달리 모든 DateTimeFormatter는 스레드에서 안전하게 사용할 수 있는 클래스다. 또한 다음 예제에서 보여주는 것처럼 DateTimeFormatter 클래스는 특정 패턴으로 포매터를 만들 수 있는 정적 팩토리 메서드도 제공한다.

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);

LocalDate의 format 메서드는 요청 형식의 패턴에 해당하는 문자열을 생성한다. 그리고 정적 메서드 parse는 같은 포매터를 적용해서 생성된 문자열을 파싱함으로써 다시 날짜를 생성한다. 다음 코드에서 보여주는 것처럼 ofPattern 메서드도 Locale로 포매터를 만들 수 있도록 오버로드된 메서드를 제공한다.

DateTimeFormatter italianFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date.format(italianFormatter); // 18. marzo 2014
LocalDate date2 = LocalDate.parse(formattedDate, italianFormatter);

DateTimeFormatterBuilder 클래스로 복합적인 포매터를 정의해서 좀 더 세부적으로 포매터를 제어할 수 있다. 즉, DateTimeFormatterBuilder 클래스로 대소문자를 구분하는 파싱, 관대한 규칙을 적용하는 파싱, 패딩, 포매터의 선택사항 등을 활용할 수 있다.

다양한 시간대와 캘린더 활용 방법

지금까지 살펴본 모든 클래스에는 시간대와 관련된 정보가 없었다. 새로운 날짜와 시간 API의 큰 편리함 중 하나는 시간대를 간단하게 처리할 수 있다는 것이다. 기존의 java.util.TimeZone을 대체할 수 있는 java.time.ZoneId 클래스가 새롭게 등장했다. 새로운 클래스를 이용하면 서머타임 같은 복잡한 사항이 자동으로 처리된다. 날짜와 시간 API에서 제공하는 다른 클래스와 마찬가지로 ZoneId는 불변 클래스다.

시간대 사용하기

표준 시간이 같은 지역을 묶어서 시간대(time zone) 규칙 집합을 정의한다. ZoneRules 클래스에는 약 40개 정도의 시간대가 들어있다. ZoneId의 getRules()를 이용해서 해당 시간대의 규정을 획득할 수 있다. 다음처럼 지역 ID로 특정 ZoneId를 구분한다.

ZoneId romeZone = ZoneId.of("Europe/Rome");

ZoneId 객체를 얻은 다음에는 LocalDate, LocalDateTime, Instant를 이용해서 ZonedDateTime 인스턴스로 변환할 수 있다. ZonedDateTime은 지정한 시간대에 상대적인 시점을 표현한다.

ZoneId를 이용해 LocalDateTime을 Instant로 바꾸는 방법도 있다.

Instant instant = Instant.now();
LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, romeZone);

UTC/Greenwich 기준의 고정 오프셋

때로는 UTC(Universal Time Coordinated, 협정 시계시)/ GMT(Greenwich Mean Time, 그니니치 표준시)를 기준으로 시간대를 표현하기도 한다.

생략...

이슬람력

자바 8에 추가된 새로운 캘릭더 중 HijrahDate(이슬람력)가 가장 복잡한데 이슬람력에는 변형이 있기 때문이다.
자바 8에서는 HijrahDate의 표준 변형 방법으로 UmmAl-Qura를 제공한다.

728x90

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

Chap09. 리팩터링, 테스팅, 디버깅  (0) 2021.03.14
Chap06. 스트림으로 데이터 수집  (0) 2021.03.05
Chap05. 스트림 활용  (0) 2021.03.05
Chap04. 스트림 소개  (0) 2021.02.26
Chap03. 람다 표현식  (0) 2021.02.25
개발서적/모던 자바 인 액션

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

728x90

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

코드 유연성 개선

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

함수형 인터페이스 적용

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

조건부 연기 실행

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

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

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

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

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

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

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

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

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

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

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

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

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

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

실행 어라운드

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

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

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

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

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

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

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

전략

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

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

image

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

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

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

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

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

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

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

public class Validator {
    private final ValidationStrategy strategy;

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

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

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

람다 표현식 사용

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

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

템플릿 메서드

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

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

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

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

람다 표현식 사용

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

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

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

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

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

옵저버

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

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

image

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

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

interface Observer {
    void notify(String tweet);
}

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

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

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

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

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

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

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

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

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

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

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

람다 표현식 사용하기

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

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

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

의무 체인

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

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

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

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

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

    abstract protected T handleWork(T input);
}

image

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

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

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

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

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

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

람다 표현식 사용

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

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

팩토리

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

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

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

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

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

람다 표현식 사용

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

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

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

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

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

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

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

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

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

람다 테스팅

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

고차원 함수 테스팅

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

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

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

디버깅

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

  • 스택 트레이스
  • 로깅

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

람다와 스택 트레이스

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

import java.util.*;

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

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

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

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

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

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

정보 로깅

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

728x90

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

Chap 12 - 새로운 날짜와 시간 API  (0) 2021.03.19
Chap06. 스트림으로 데이터 수집  (0) 2021.03.05
Chap05. 스트림 활용  (0) 2021.03.05
Chap04. 스트림 소개  (0) 2021.02.26
Chap03. 람다 표현식  (0) 2021.02.25
개발서적/모던 자바 인 액션

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

728x90

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

컬렉터란?

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

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

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

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

미리 정의된 컬렉터

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

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

리듀싱과 요약

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

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

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

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

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

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

요약 연산

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

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

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

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

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

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

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

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

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

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

문자열 연결

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

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

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

그룹화

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

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

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

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


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

그룹화된 요소 조작

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

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

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

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

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

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

다수준 그룹화

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

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

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

서브그룹으로 데이터 수집

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

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

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

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

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

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

분할

partitioningBy

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

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

분할의 장점

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

Collector 인터페이스

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

Collector 인터페이스

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

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

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

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

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

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

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

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

Chap05. 스트림 활용

728x90

필터링

프레디케이트로 필터링

프레디케이트(불리언을 반환하는 함수)를 인수로 받아서 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다.

예제

List<Dish> vegetarianMenu = menu.stream()
                                .filter(Dish::isVegatarian)
                                .collect(toList());

고유 요소 필터링

스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct 메서드도 지원한다. (고유 여부는 스트림에서 만든 객체의 hashCode, equals로 결정된다).

List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
numbers.stream()
       .filter(i -> i % 2 == 0)
       .distinct()
       .forEach(System.out::println);

스트림 슬라이싱

스트림의 요소를 선택하거나 스킵하는 다양한 방법을 설명한다.

TAKEWHILE 활용

takeWhile을 이용하면 무한스트림을 포함한 모든 스트림에 프레디케이트를 적용해 스트림을 슬라이스할 수 있다.

List<Dish> sliceMenu = specialMenu.stream()
                                  .takeWhile(dish -> dish.getCalories() < 320)
                                  .collect(toList());

DROPWHILE 활용

나머지 요소를 선택하려면 어떻게 해야 할까?
dropWhile을 이용해 이 작업을 완료할 수 있다.

List<Dish> sliceMenu = specialMenu.stream()
                                  .dropWhile(dish -> dish.getCalories() < 320)
                                  .collect(toList());

dropWhile은 takeWhile과 정반대의 작업을 수행한다. dropWhile은 프레디케이트가 처음으로 거짓이 되는 지점까지 발견된 요소를 버린다. dropWhile은 무한한 남은 요소를 가진 무한 스트림에서도 동작한다.

스트림 축소

스트림은 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환하는 limin(n) 메서드를 지원한다.

List<Dish> dishes = specialMenu.stream()
                               .filter(dish -> dish.getCalories() > 300)
                               .limit(3)
                               .collect(toList());

요소 건너뛰기

스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드를 지원한다.

매핑

특정 객체에서 특정 데이터를 선택하는 작업은 데이터 처리 과정에서 자주 수행되는 연산이다.
스트림 API의 map, flatMap 메서드는 특정 데이터를 선택하는 기능을 제공한다.

스트림의 각 요소에 함수 적용하기

스트림은 함수를 인자로 받는 map 메서드를 지원한다. 인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑된다.

List<String> dishNames = menu.stream()
                             .map(Dish::getName)
                             .collect(toList());

스트림 평면화

flatMap은 각 배열 스트림이 아니라 스트림의 콘텐츠로 매핑한다. 즉, map(Arrays.stream)과 달리 flatMap은 하나의 평면화된 스트림을 반환한다.

image

검색과 매칭

특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리도 자주 사용된다. 스트림 API는 allMatch, anyMatch, noneMatch, findFirst, findAny 등 다양한 유틸리티 메서드를 제공한다.

프레디케이트가 적어도 한 요소와 일치하는지 확인

프레디케이트가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인할 때 anyMatch 메서드를 이용한다.
anyMatch는 boolean을 반환하므로 최종 연산이다.

프레디케이트가 모든 요소와 일치하는지 검사

allMatch 메서드는 anyMatch와 달리 스트림의 모든 요소가 주어진 프레디케이트와 일치하는지 검사한다.

NONEMATCH

noneMatch는 allMatch와 반대 연산을 수행한다. 주어진 프레디케이트와 일치하는 요소가 없는지 확인한다.

anyMatch, allMatch, noneMatch 새 메서드들은 쇼트서킷연산을 활용한다.

요소 검색

findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메서드를 다른 스트림연산과 연결해서 사용할 수 있다.

Optional은 값의 존재나 부재 여부를 표현하는 컨테이너 클래스다. null 처리를 위한 방식으로 자바 8에 추가되었다. 뒷장에서 배우니 생략한다.

Optional에는 아래의 기능들이 존재한다.

  • isPresent() : Optional이 값을 포함하면 true를 반환하고, 값을 포함하지 않으면 false를 반환한다.
  • ifPresent(Consumer<T> block)은 값이 있으면 주어진 블록을 실행한다.
  • T get() : 값이 존재하면 값을 반환하고, 값이 없으면 NoSuchElementException을 일으킨다.
  • T orElse() : 값이 있으면 값을 반환하고, 값이 없으면 기본값을 반환한다.

첫 번째 요소 찾기

리스트 또는 정렬된 연속 데이터로부터 생성된 스트림처럼 일부 스트림에는 논리적인 아이템 순서가 정해져 있을 수 있다. 이런 스트림에서 첫 번째 요소를 찾으려면 어떻게 해야할까?

findFirst, findAny는 언제 사용할까?

병렬 프로그래밍에서는 첫 번째 요소를 찾기가 어렵다. 따라서 요소의 반환 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.

리듀싱

리듀스 연산은 스트림 요소를 조합해서 더 복잡한 질의를 표현하는 방법을 설명한다. 이러한 질의를 수행하려면 Integer 같은 결과가 나올 때까지 스트림의 모든 요소를 반복적으로 처리해야 한다. 이런 질의를 리듀싱 연산이라고 한다. 함수형 프로그래밍 언어 용어로는 이 과정이 마치 종이를 작은 조각이 될 때까지 반복해서 접는 것과 비슷하다는 의미로 폴드라고 부른다.

요소의 합

int product = numbers.stream()
                     .reduce(1, (a,b) -> a * b);
int sum = numbers.stream()
                 .reduce(0, Integer::sum);

초깃값 없음

초깃값을 받지 않도록 오버로드된 reduce도 있다. 그러나 이 reduce는 Optional 객체를 반환한다.

Optional<Integer> sum = numbers.stream()
                               .reduce((a, b) -> (a + b));

왜 Optional<Integer>를 받을까? 그 이유는 스트림에 아무 요소도 없는 상황을 가정했을 때, 이런 상황이라면 초깃값이 없으므로 합계를 반환할 수 없다. 따라서 합계가 없음을 가질 수 있도록 Optional 객체로 감싼 결과를 반환한다.

최댓값과 최솟값

최댓값과 최솟값을 찾을 때도 reduce를 활용할 수 있다. reduce를 이용해서 스트림에서 최댓값과 최솟값을 찾는 방법을 살펴보자. reduce는 두 인수를 받는다.

  • 초깃값
  • 스트림의 두 요소를 합쳐서 하나의 값으로 만드는 데 사용할 람다
Optional<Integer> max = numbers.stream()
                               .reduce(Integer::max);

Integer.max 대신 Integer.min을 reduce로 넘겨주면 최솟값을 찾을 수 있다.
(가급적이면 메서드 참조 표현을 사용해서 가독성을 높이자!)

숫자형 스트림

이전의 reduce로 연산을 하는 행위는 박싱 비용이 숨어있다. 내부적으로 합계를 계산하기 전에 Integer를 기본형으로 언박싱해야 한다.
다행히도 스트림 API 숫자 스트림을 효율적으로 처리할 수 있는 기본형 특화 스트림을 제공한다.

기본형 특화 스트림

자바 8에서는 세 가지 기본형 특화 스트림을 제공한다. IntStream, DoubleStream, LongStream 을 제공한다. 각각의 인터페이스는 숫자 스트림의 합계를 계산하는 sum, 최댓값 요소를 검색하는 max 같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다. (오직 박싱 과정에서 일어나는 효율성과 관련 있으며 스트림에 추가 기능을 제공하지는 않는다)

숫자 스트림으로 매핑

스트림을 특화 스트림으로 변환할 때는 mapToInt, mapToDouble, mapToLong 세 가지 메서드를 가장 많이 사용한다. 이들 메서드는 map과 정확히 같은 기능을 수행하지만, Stream<T> 대신 특화된 스트림(ex. IntStream..)을 반환한다.

객체 스트림으로 복원하기

boxed 메서드를 이용해서 특화 스트림을 일반 스트림으로 변환할 수 있다.

IntStream intStream = menu.stream()
                          .mapToInt(Dish::getCalories);
Stream<Integer> stream = IntStream.boxed();

숫자 범위

IntStream과 LongStream에서는 rangerangeClosed라는 두 가지 정적 메서드를 제공한다. 두 메서드는 모두 첫 번째 인수로 시작값을 두 번째 인수로 종료값을 갖는다. range는 끝을 포함하지 않고, rangeClosed는 끝을 포함한다.

스트림 만들기

다양한 방식으로 스트림을 만들 수 있다. 이 절에서는 일련의 값, 배열, 파일, 심지어 함수를 이용한 무한 스트림 만들기 등 다양한 방식으로 스트림을 만드는 방법을 설명한다.

값으로 스트림 만들기

임의의 수를 인수로 받는 정적 메서드 Stream.of를 이용해서 스트림을 만들 수 있다.

Stream<String> stream = Stream.of("Modern", "Java", "In", "Action");
stream.map(String::toUpperCase).forEach(System.out::println);

다음처럼 empty 메서드를 이용해서 스트림을 비울 수 있다.

Stream<String> emptyStream = Stream.empty();

null이 될 수 있는 객체로 스트림 만들기

자바 9에서는 null이 될 수 있는 개체를 스트림으로 만들 수 있는 새로운 메서드가 추가되었다. 때로는 null이 될 수 있는 객체를 스트림으로 만들어야 할 수 있다. null이 나올 수 도 있는 값을 Stream.ofNullable( ) 로 감싸서 구현할 수 있다.

``java
Stream values = Stream.of("config", "home", "user")
.flatMap(key -> Stream.ofNullable(System.getProperty(key)));


### 배열로 스트림 만들기 

배열을 인수로 받는 정적 메서드 ``Arrays.stream``을 이용해서 스트림을 만들 수 있다. 예를 들어 다음처럼 기본형 int로 이루어진 배열을 IntStream으로 변환할 수 있다. 

```java
int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum(); 

파일로 스트림 만들기

파일을 처리하는 등의 I/O 연산에 사용하는 자바의 NIO API(Non Blocking Input/Output)도 스트림 API를 활용할 수 있도록 업데이트되었다. java.nio.file.Files의 많은 정적 메서드가 스트림을 반환한다. 예를 들어 Files.lines는 주어진 파일의 행 스트림을 문자열로 반환한다. 지금까지 배운 스트림 연산을 활용하면 다음 코드처럼 파일에서 고유한 단어 수를 찾는 프로그램을 만들 수 있다.

long uniqueWords = 0;
try(Stream<String> lines = Files.lines(Paths.get("data.txt"), Charset.defaultCharset())) {
                                          uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
                                                             .distinct()
                                                             .count();
                                       } catch (IOException e) {
                                           //
                                       }

Files.lines로 파일의 각 행 요소를 반환하는 스트림을 얻을 수 있다. 스트림의 소스가 I/O 자원이므로 이 메서드를 try/catch 블록으로 감쌌고 메모리 누수를 막으려면 자원을 닫아야 한다. 기존에는 finally 블록에서 자원을 닫았다. Stream 인터페이스는 AutoCloseable 인터페이스를 구현한다. 따라서 try 블록 내의 자원은 자동으로 관리된다. line에 split 메서드를 호출해서 각 행의 단어를 분리할 수 있다. 각 행의 단어를 여러 스트림으로 만드는 것이 flatMap으로 스트림을 하나로 평면화했다. 마지막으로 distinct와 count를 연결해서 스트림의 고유 단어 수를 계산한다.

함수로 무한 스트림 만들기

스트림 API는 함수에서 스트림을 만들 수 있는 두 정적 메서드 Stream.iterate와 Stream.generate를 제공한다. 두 연산을 이용해서 무한 스트림, 즉 고정된 컬렉션에서 고정된 크기로 스트림을 만들었던 것과는 달리 크기가 고정되지 않은 스트림을 만들 수 있다.

iterate 메서드

Stream.iterate(0,  n -> n + 2)
      .limit(10)
      .forEach(System.out::println);

iterate 메서드는 초깃값과 람다를 인수로 받아서 새로운 값을 끊임없이 생산할 수 있다. iterate는 요청할 때마다 값을 생산할 수 있으며 끝이 없으므로 무한 스트림을 만든다. 이러한 스트림을 언바운드 스트림이라고 표현한다.

generate 메서드

iterate와 비슷하게 generate도 요구할 때 값을 계산하는 무한 스트림을 만들 수 있다. 하지만 iterate와 달리 generate는 생산된 각 값을 연속적으로 계산하지 않는다 generate는 Supplier<T>를 인수로 받아서 새로운 값을 생산한다.

Stream.generate(Math::random)
      .limit(5)
      .forEach(System.out::println);
728x90
개발서적/모던 자바 인 액션

Chap04. 스트림 소개

728x90

스트림이란 무엇인가?

스트림stream은 자바 8 API에 새로 추가된 기능이다. 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다. 또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다(단순히 stream()을 paralleStream() 로 변경하면..).

스트림의 새로운 기능은 다음과 같은 다양한 이득을 준다는 사실을 기억하자.

  • 선언형으로 코드를 구현할 수 있다. 즉, 선언형 코드와 동작 파라미터화를 활용하면 변하는 요구사항에 쉽게 대응할 수 있다.
  • filter, sorted, map, collect 같은 여러 빌등 블록 연산을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있다. 여러 연산을 파이프라인으로 연결해도 여전히 가독성과 명확성이 유지된다.

filter, sorted, map, collect 같은 연산은 고수준 빌딩 블록으로 이루어져 있으므로 특정 스레딩 모델에 제한되지 않고 자유롭게 어떤 상황에서든 사용할 수 있다. 결과적으로 우리는 데이터 처리과정을 병렬화하면서 스레드와 락을 걱정할 필요가 없다.

image

자바 8의 스트림 API의 특징은 다음처럼 요약할 수 있다.

  • 선언형 : 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음 : 유연성이 더 좋아진다.
  • 병렬화 : 성능이 좋아진다.

스트림 시작하기

간단한 스트림 작업인 컬렉션 스트림부터 살펴보자. 자바 8 컬렉션에는 스트림을 반환하는 stream 메서드가 추가됐다.

스트림이란 무엇일까?

스트림이란 '데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소'로 정의할 수 있다.

  • 연속된 요소 : 컬렉션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다. 컬렉션의 주는 데이터고 스트림의 주제는 계산이다.
  • 소스 : 스트림은 컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비한다. 정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지된다. 즉, 리스트로 스트림을 만들면 스트림의 요소는 리스트의 요소와 같은 순서를 유지한다.
  • 데이터 처리 연산 : 스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산을 지원한다. 예를 들어 filter, map, reduce, find, match, sort 등으로 데이터를 조작할 수 있다. 스트림 연산은 순차적으로 또는 병렬로 실행할 수 있다.

또한, 스트림에는 두 가지 중요한 특징이 있다.

  • 파이프라이닝Pipelining : 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이브라인을 구성할 수 있도록 스트림 자신을 반환한다. 그 덕분에 게으름, 쇼트서킷같은 최적화도 얻을 수 있다. 연산 파이프라인은 데이터 소스에 적용하는 질의와 비슷하다.

  • 내부 반복: 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.

filter, map, limit, collect는 각각 다음 작업을 수행한다.

  • filter : 람다를 인수로 받아 스트림에서 특정 요소를 제외시킨다.
  • map : 람다를 이용해서 한 요소를 다른 요소로 변환하거나 정보를 추출한다.
  • limit : 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 스트림 크기를 축소 truncate(=길이를 줄이다)한다.
  • collect : 스트림을 다른 형식으로 변환한다. collect는 다양한 변환 방법을 인수로 받아 스트림에 누적된 요소를 특정 결과로 변환시키는 기능을 수행할 수 있다.

자바 8 이전의 방식으로 구현했을 때보다 더 선언형으로 데이터를 처리할 수 있고, 스트림 라이브러리에서 피렅링, 추출, 축소 기능을 제공하므로 직접 이 기능을 구현할 필요가 없었다. 결과적으로 스트림 API는 파이프라인을 더 최적화할 수 있는 유연성을 제공한다.

스트림과 컬렉션

자바의 기초 컬렉션과 새로운 스트림 모두 연속된 요소 형식의 값을 저장하는 자료구조의 인터페이스를 제공한다. 여기서 연속된 이라는 표현은 순서와 상관없이 아무 값에나 접속하는 것이 아닌 순차적ㅇ으로 값에 접근한다는 것을 의미한다.

데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이다.

컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조다. 즉, 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 한다.

반면 스트림은 이론적으로 요청할 때만 요소를 계산 하는 고정된 자료구조다. 사용자가 요청하는 값만 스트림에서 추출한다는 것이 핵심이다. 사용자 입장에서는 이러한 변화를 알 수 없다. 결과적으로 스트림은 생산자와 소비자 관계를 형성한다. 또한 스트림은 게으르게 만들어지는 컬렉션과 같다. 즉, 사용자가 데이터를 요청할 때만 값을 계산한다.

반면 컬렉션은 적극적으로 생성된다.

딱 한번만 탐색할 수 있다.

반복자와 마찬가지로 스트림도 딱 한번만 탐색할 수 있다. 즉, 탐색된 스트림의 요소는 소비된다. 한 번 탐색한 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.

외부 반복과 내부 반복

컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 한다. 이를 외부 반복이라고 한다. 반면 스트림 라이브러리는 알아서 처리하고 결과 스트림값을 어딘가에 저장해주는 내부 반복을 사용한다. 함수에 어떤 작업을 수행할지만 지정하면 모든 것이 알아서 처리된다.

아래의 예는 스트림의 내부 반복의 특징을 보여준다.

List<String> names = menu.stream()
                     .map(Dish::getName)
                     .collect(toList());

컬렉션은 외부적으로 반복, 즉 명시적으로 컬렉션 항목을 하나씩 가져와서 처리한다. 이에 비해 내부반복은 작업을 투명하게 병렬로 처리하거나 더 최적화된 다양한 순서로 처리할 수 있다는 장점이 있다. 기존 자바에서처럼 컬렉션을 외부 반복으로 처리한다면 이와 같은 최적화를 달성하기 어렵다.

하지만 내부 반복뿐 아니라 자바 8에서 스트림을 제공하는 더 다양한 이유가 있다. 스트림 라이브러리의 내부 반복은 데이터 표현과 하드웨어를 활용한 병렬성 구현을 자동으로 선택한다. 반면 for-each를 이용하는 외부 반복에서는 병렬성을 스스로 관리해야 한다. 자바 8에서는 컬렉션 인터페이스와 비슷하면서도 반복자가 없는 무엇이 절실했으며, 결국 스트림이 탄생했다.

스트림은 내부 반복을 사용하므로 반복 과정을 우리가 신경 쓰지 않아도 된다. 하지만 이와 같은 이점을 누리려면 반복을 숨겨주는 연산 리스트가 미리 정의되어 있어야 한다. 반복을 숨겨주는 대부분의 연산은 람다 표현식을 인수로 받으므로 3장에서 배운 동작 파라미터화를 활용할 수 있다.

스트림 연산

Stream 인터페이스는 많은 연산을 정의한다 스트림 인터페이스의 연산을 크게 두 가지로 구분할 수 있다.

연결할 수 있는 스트림 연산을 중간 연산intermediate operation 이라고 하며, 스트림을 닫는 연산을 최종 연산termainal operation 이라고 한다. 왜 스트림의 연산을 두 가지로 구분하는 것일까?

중간 연산

filter나 sorted 같은 중간 연산은 다른 스트림을 반환한다. 따라서 여러 중간 연산을 연결해서 질의를 만들 수 있다. 중간 연산의 중요한 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않는다는 것, 즉 게으르다lazy는 것이다. 중간 연산을 합친 다음에 합쳐진 중간 연산을 최종 연산으로 한 번에 처리하기 때문이다.

스트림의 게으른 특성 덕분에 몇 가지 최적화 효과를 얻을 수 있다. 이는 limit 연산 그리고 쇼트서킷(A&&B에서 A가 거짓이면 거짓, A||B에서 A가 참이면 다음 조건식이 생략되는 현상)이라 불리는 기법 덕분이다. 둘째 filterdhk map은 서로 다른 연산이지만 한과정으로 병합되었다. 이 기법을 루프 퓨전이라고 하는데 여러 루프를 단일 루프로 대체하는 컴파일러 최적화 및 루프 변환이다.

최종 연산

최종 연산은 스트림 파이프라인에서 결과를 도출한다. 보통 최종 연산에 의해 List, Integer, void 등 스트림 이외의 결과가 반환된다.

스트림 이용하기

스트림 이용 과정은 다음과 같이 세 가지로 요약할 수 있다.

  • 질의를 수행할 (컬렉션과 같은..) 데이터 소스
  • 스트림 파이프라인을 구성할 중간 연산 연결
  • 스트림 파이프라인을 실행하고 결과를 만들 최종 연산

스트림 파이프라인의 개념은 빌더 패턴과 비슷하다. 빌더 패턴에서는 호출을 연결해서 설정을 만든다. 그리고 준비된 설정에 build 메서드를 호출한다.

중간 연산에는 filter, map, limit, sorted, distinct가 있고, 최종 연산에는 forEach, count, collect가 있다.

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

Chap03. 람다 표현식

728x90

익명 클래스로 다양한 동작을 구현할 수 있지만 만족할 만큼 코드가 깔끔하지는 않았다. 깔끔하지 않은 코드는 동작 파라미터를 실전에 적용하는 것을 막는 요소다. 3장에서는 더 깔끔한 코드로 동작을 구현하고 전달하는 자바 8의 새로운 기능인 람다 표현식을 설명한다. 람다 표현식은 익명 클래스처럼 이름이 없는 함수이고 메서드를 인수로 전달할 수 있으므로 일.단.은 익명 클래스와 비슷하다고 생각하자(람다 표현식은 정확히는 조금 특수한 익명클래스이다).

람다란 무엇인가?

람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 표현할 수 있다. 람다 표현식에는 이름은 없지만 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다. 람다의 특징을 하나씩 자세히 살펴보자.

  • 익명
    보통의 메서드와 달리 이름이 없어 익명이라 표현한다. 구현해야 할 코드에 대한 걱정거리가 줄어든다.

  • 함수
    람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부른다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.

  • 전달
    람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.

  • 간결성
    익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.

람다 표현식을 이용하면 메서드의 바디를 직접 전달하는 것처럼 코드를 전달할 수 있다.

예를 들어, Comparator의 람다 표현식은 아래와 같다.

(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

  • 파라미터 리스트
    Comparator의 compare 메서드 파라미터(사과 두 개)

  • 화살표
    화살표(->)는 람다의 파라미터 리스트와 바디를 구분한다.

  • 람다 바디
    두 사과의 무게를 비교한다. 람다의 반환값에 해당하는 표현식이다.

자바 설계자는 이미 C#이나 스칼라 같은 비슷한 기능을 가진 다른 언어와 비슷한 문법을 자바에 적용하기로 했다. 다음은 표현식 스타일expression style 람다라고 알려진 람다의 기본 문법이다.

(parameters) -> expression

또는 다음처럼 표현할 수 있다(블록 스타일block-style)

(parameters) -> { statements; }

어디에, 어떻게 람다를 사용할까?

이전 예제에서 Comparator 형식의 변수에 람다를 할당했다. 앞장에서 구현했던 필터 메서드에도 람다를 활용할 수 있다.

List<Apple> greenApples = 
    filter(inventory, (Apple a) -> a.getColor() == GREEN);

람다 표현식은 함수형 인터페이스라는 문맥에서 사용할 수 있다. 위 예제에서는 함수형 인터페이스 Predicate를 기대하는 filter 메서드의 두 번째 인수로 람다 표현식을 전달했다.

함수형 인터페이스

함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스다. 지금까지 살펴본 자바 API의 함수형 인터페이스로 Comparator, Runnable 등이 있다.

인터페이스는 디폴트 메서드를 포함할 수 있다. 많은 디폴트 메서드가 있더라도 추상 메서드가 오직 하나면 함수형 인터페이스다.

함수형 인터페이스로 뭘 할 수 있을까? 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급(기술적으로 따지면 함수형 인터페이스를 구현한 클래스의 인스턴스)할 수 있다. 함수형 인터페이스보다는 덜 깔끔하지만 익명 내부 클래스로도 같은 기능을 구현할 수 있다.

함수 디스크립터

함수형 인터페이스의 추상 메서드 시그니처signature는 람다 표현식의 시그니처를 가리킨다. 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터function descriptor라고 부른다.

'왜 함수형 인터페이스를 인수로 받는 메서드에만 람다 표현식을 사용할 수 있을까?'라는 의문이 생길 수 있다. 언어 설계자들은 자바에 함수 형식을 추가하는 방법도 대안으로 고려했지만 언어를 더 복잡하게 만들지 않는 현재 방법을 선택했다. 또한 대부분의 자바 프로그래머가 하나의 추상 메서드를 갖는 인터페이스에 이미 익숙하다는 점도 고려했다.

람다 활용: 실행 어라운드 패턴

자원 처리에 사용하는 순환 패턴recurrent pattern은 자원을 열고, 처리한 다음에, 자원을 닫는 순서로 이루어진다. 설정setup과 정리cleanup 과정은 대부분 비슷하다. 즉, 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖는다. 이런 형식의 코드를 실행 어라운드 패턴(execute around pattern)이라고 부른다.

함수형 인터페이스 사용

다양한 람다 표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다. 이미 자바 API는 Comparable, Runnabe, Callable 등의 다양한 함수형 인터페이스를 포함하고 있다.

자바 8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 새로운 함수형 인터페이스를 제공한다. 이 절에서는 Predicate, Consumer, Function 인터페이스를 설명하며 표에서는 더 다양한 함수형 인터페이스를 소개한다.

함수형 인터페이스 함수 디스크립터 기본형 특화
Predicate T -> boolean IntPredicate, LongPredicate, DoublePredicate
Consumer T -> void IntConsumer, LongConsumer, DoubleConsumer
Function<T, R> T -> R IntFunction, IntToDoubleFunction, IntToLongFunction, LongFunction,
Supplier () -> T BooleanSupplier, IntSupplier, LongSupplier, DoubleSupplier
UnaryOperator T -> T IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator
BinaryOperator (T, T) -> T IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator
BiPredicate<L, R> (T, U) -> boolean
BiConsumer<T, U> (T, U) -> void ObjIntConsumer, ObjLongConsumer, ObjDoubleConsumer
BiFunction<T, U, R> (T, U) -> R ToIntBiFunction<T, U>, ToLongBiFunction<T, U>, ToDoubleBiFunction<T, U>

형식 검사, 형식 추론, 제약

람다 표현식을 처음 설명할 때 람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 언급했다. 람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않다. 따라서 람다 표현식을 더 제대로 이해하려면 람다의 실제 형식을 파악해야 한다.

형식 검사

람다가 사용되는 콘텍스트를 이용해서 람다의 형식을 추론할 수 있다. 어떤 콘텍스트context를 이용해서 람다의 형식type을 추론할 수 있다. 어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식target type이라고 부른다. 람다 표현식을 사용할 때 실제 어떤 일이 일어나는지 보여주는 예제를 확인하자.

List<Apple> heavierThan150g = filter(inventory, (Applea apple) -> apple.getWeight() > 150);
  1. filter 메서드의 선언을 확인한다.
  2. filter 메서드는 두 번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대한다.
  3. Predicate<Apple>은 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스다.
  4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.
  5. filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.

같은 람다, 다른 함수형 인터페이스

대상 형식target typing이라는 특징 때문에 같은 람다 표현식이더라도 호환되는 추상 메서드를 가진 다른 함수형 인터페이스로 사용될 수 있다.

형식 추론

자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다. 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있다. 결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다. 즉, 자바 컴파일러는 람다 파라미터 형식을 추론할 수 있다.

지역 변수 사용

람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수free variable(파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수)를 활용할 수 있다. 이와 같은 동작을 람다 캡처링capturing lambda이라고 부른다.

하지만 자유 변수에도 제약이 있다. 람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처할 수 있다. 하지만 그러려면 지역 변수는 명시적으로 final로 선언되어 있어야 하거나 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다. 즉, 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡처할 수 있다.

지역 변수의 제약

인스턴스 변수는 힙에 저장되는 반면 지역 변수는 스택에 위치한다. 람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. 따라서 자바 구현에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공한다. 따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴 것이다.

또한 지역 변수의 제약 때문에 외부 변수를 변화시키는 일반적인 명령형 프로그래밍 패턴(병렬화를 방해하는 요소)에 제동을 걸 수 있다.

클로저

클로저closure라는 단어를 들어본 독자라면 람다가 클로저의 정의에 부합하는지 궁금할 것이다. 원칙적으로 클로저란 함수의 비지역 변수를 자유롭게 참조할 수 있는 함수의 인스턴스를 가리킨다. (예를 들면 클로저를 다른 함수에 인수로 전달할 수 있다. 클로저는 클로저 외부에 정의된 변수의 값에 접근하고, 값을 바꿀 수 있다) 자바 8의 람다와 익명 클래스는 클로저와 비슷한 동작을 수행한다. 람다와 익명 클래스 모두 메서드의 인수로 전달될 수 있으며 자신의 외부 영역의 변수에 접근할 수 있다. 다만 람다와 익명 클래스는 람다가 정의된 메서드의 지역 변수의 값은 바꿀 수 없다. 람다가 정의된 메서드의 지역 변수값은 final 변수여야 한다. 덕분에 람다는 변수가 아닌 값에 국한되어 어떤 동작을 수행한다는 사실이 명확해진다. 지역 변수값은 스택에 존재하므로 자신을 정의한 스레드와 생존을 같이 해야 하며 따라서 지역 변수는 final이어야 한다. 가변 지역 변수를 새로운 스레드에서 캡처할 수 있다면 안전하지 않은 동작을 수행할 가능성이 생긴다(인스턴스 변수는 스레드가 공유하는 힙에 존재하므로 특별한 제약이 없다).

메서드 참조

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다. 때로는 람다 표현식보다 메서드 참조를 사용하는 것이 더 가독성이 좋으며 자연스러울 수 있다.

메서드 참조는 아래와 같이 사용할 수 있다.

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
inventory.sort(comparing(Apple::getWeight));

메서드 참조를 만드는 방법

메서드 참조는 세 가지 유형으로 구분할 수 있다.

  • 정적 메서드 참조
  • 다양한 형식의 인스턴스 메서드 참조
  • 기존 객체의 인스턴스 메서드 참조

생성자 참조

Class::new 처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다. 이것은 정적 메서드의 참조를 만드는 방법과 비슷하다. (혹시나 이전까지 다루지 않은 경우(ex. 인수가 세 개)에 생성자의 생성자 참조를 사용하려면 이런 특수한 시그니처를 갖는 함수형 인터페이스가 없기 때문에 직접 정의해서 사용해야한다)

람다, 메서드 참조 활용하기

람다 표현식을 조합할 수 있는 유용한 메서드

자바 8 API의 몇몇 함수형 인터페이스는 다양한 유틸리티 메서드를 포함한다. 이것은 무슨 의미일까? 간단한 여러 개의 람다 표현식을 조합해서 복잡한 람다 표현식을 만들 수 있다는 것이다. 예를 들어 두 프레디케이트를 조합해서 두 프레디케이트의 or 연산을 수행하는 커다란 프레디케이트를 만들 수 있다. 또한 함수의 결과가 다른 함수의 입력이 되도록 두 함수를 조합할 수 도 있다. 도대체 함수형 인터페이스에서는 어떤 메서드를 제공하기에 이런 일이 가능한지 궁금할 것이다. 여기서 등장하는 것이 바로 디폴트 메서드(default method)다.

Comparator 조합

이전에도 보았듯이, 정적 메서드 Comparator.comparing을 이용해서 비교에 사용할 키를 추출하는 Function 기반의 Comparator를 반환할 수 있다.

역정렬

사과의 무게를 내림차순으로 정렬하고 싶다면 어떻게 해야 할까? 다른 Comparator 인스턴스를 만들 필요가 없다. 인터페이스 자체에서 주어진 비교자의 순서를 뒤바꾸는 reverse라는 디폴트 메서드를 제공하기 때문이다. 따라서 다음 코드처럼 처음 비교자 구현을 그대로 재사용해서 사과의 무게를 기준으로 역정렬할 수 있다.

inventory.sort(comparing(Apple::getWeight).reversed());

Comparator 연결

정렬이 잘 동작하는 것 같다. 하지만 무게가 같은 두 사과가 존재한다면 어떻게 해야 할까? 정렬된 리스트에서 어떤 사과를 먼저 나열해야 할까? 이럴 땐 비교 결과를 더 다듬을 수 있는 두번째 Comparator를 만들 수 있다. thenComparing 메서드로 두 번째 비교자를 만들 수 있다. thenComparing은 (comparing 메서드처럼) 함수를 인수로 받아 첫 번째 비교자를 이용해서 두 객체가 같다고 판단되면 두 번째 비교자에 객체를 전달한다. 즉, 다음처럼 문제를 해결할 수 있다.

inventory.sort(comparing(Apple::getWeight)
         .reversed()
         .thenComparing(Apple::getCountry));

Predicate 조합

Predicate 인터페이스는 복잡한 프레디케이트를 만들 수 있도록 negate, and, or 세 가지 메서드를 제공한다. 예를 들어 '빨간색이 아닌 사과'처럼 특정 프레디케이트를 반전시킬 때 negate 메서드를 사용할 수 있다.

Predicate<Apple> notRedApple = redApple.negate();

또한 and 메서드를 이용해서 빨간색이면서 무거운 사과를 선택하도록 두 람다를 조합할 수 있다.

Predicate<Apple> redAndHeavyApple = 
    redApple.and(apple -> apple.getWeight() > 150);

그뿐만 아니라 or을 이용해서 '빨간색이면 무거운 사과 또는 그냥 녹색 사과'등 다양한 조건을 만들 수 있다.

이것이 대단한 이유는 단순한 람다 표현식을 조합해서 더 복잡한 람다 표현식을 만들 수 있기 때문이다. 심지어 람다 표현식을 조합해도 코드 자체가 문제를 잘 설명한다는 점 또한 변치않는다. 여기서 소개한 and, or 등은 왼쪽에서 오른쪽으로 연결되었다. 즉, a.or(b).and(c)는 (a||b) && c와 같다.

Function 조합

마지막으로 Function 인터페이스에서 제공하는 람다 표현식 또한 조합할 수 있다. Function 인터페이스는 Function 인스턴스를 반환하는 andThen, compose 두 가지 디폴트 메서드를 제공한다.

andThen 메서드는 주어진 함수를 먼저 적용한 결과를 다른 함수의 입력으로 전달하는 함수를 반환한다. 예를 들어 숫자를 증가 (x -> x + 1) 시키는 f라는 함수가 있고, 숫자에 2를 곱하는 g라는 함수가 있다고 가정하자. 이제 다음처럼 f와 g를 조립해서 숫자를 증가시킨 뒤 결과에 2를 곱하는 h라는 함수를 만들 수 있다.

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g);
int result = h.apply(1);

compose 메서드는 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공한다. 즉, f.andThen(g)에서 andThen 대신에 compose를 사용하면 g(f(x))가 아니라 f(g(x))라는 수식이 된다.

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.compose(g);
int result = h.apply(1);
728x90