본문 바로가기

(3)

Chap04. 스트림 소개

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

Chap03. 람다 표현식

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

Chap02. 동작 파라미터화 코드 전달하기

모던 자바 인 액션 책을 작년 이맘때쯤에 우형 없는 우테코에서 진행했었는데, 아이러니하게도 우형 있는 우테코(라임을 맞추고 싶은 욕구가 들끓었다)에서 다시 진행하고있다. 스터디를 할지말지 고민을 했는데 다시 공부하게 된 원인은 아무래도 처음 공부할 때 어려운 챕터들은 스킵했었고, 이해가 안됨에도 억지로 읽고 지나갔던 내용들도 꽤 많았기 때문에 이번 2회차는 확실하게 이해하고 넘어가겠다는 전제를 깔고 스터디에 임하려고한다. 동작 파라미터화 코드 전달하기 시시각각 변하는 사용자 요구사항에 어떻게 대응해야 할까? 엔지니어링적인 비용이 가장 최소화되어야 한다. 새로 추가한 기능은 쉽게 구현할 수 있어야 한다. 장기적인 관점에서 유지보수가 쉬어야 한다. 동작 파라미터화(behavior parameterizatio..

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

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
개발서적/모던 자바 인 액션

Chap02. 동작 파라미터화 코드 전달하기

728x90

모던 자바 인 액션 책을 작년 이맘때쯤에 우형 없는 우테코에서 진행했었는데, 아이러니하게도 우형 있는 우테코(라임을 맞추고 싶은 욕구가 들끓었다)에서 다시 진행하고있다. 스터디를 할지말지 고민을 했는데 다시 공부하게 된 원인은 아무래도 처음 공부할 때 어려운 챕터들은 스킵했었고, 이해가 안됨에도 억지로 읽고 지나갔던 내용들도 꽤 많았기 때문에 이번 2회차는 확실하게 이해하고 넘어가겠다는 전제를 깔고 스터디에 임하려고한다.

동작 파라미터화 코드 전달하기

시시각각 변하는 사용자 요구사항에 어떻게 대응해야 할까?

  • 엔지니어링적인 비용이 가장 최소화되어야 한다.
  • 새로 추가한 기능은 쉽게 구현할 수 있어야 한다.
  • 장기적인 관점에서 유지보수가 쉬어야 한다.

동작 파라미터화(behavior parameterization)를 이용하면 이렇게 자주 바뀌는 요구사항에 효과적으로 대응할 수 있다.

동작 파라미터화란?

동작 파라미터화란 아직은 어떻게 실행할 것인지 결정하지 않은 코드 블록을 의미한다.

이 코드 블록은 나중에 프로그램에서 호출한다. 결과적으로 코드 블럭에따라 메서드의 동작이 파라미터화된다.

변화하는 요구사항에 대응하기

먼저 전제로 사과 색을 정의하는 Color enum class가 존재한다고 가정한다.

enum Color { RED, GREEN }

녹색 사과 필터링

public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (apple.getColor() == GREEN) {
            result.add(apple);
        }
    }
    return result;
}

위의 코드는 inventory에서 녹색 사과만 추출한 결과를 얻는다.
이 코드를 농부의 변심으로 빨간 사과도 필터링한다면, 어떻게 고쳐야 할까? 빨간 사과에 대한 if 조건문을 추가하여 쉽게 구현할 수 있을 것이다. 하지만 이와 같은 방법으로 당장의 원하는 결과는 얻을 수 있겠지만, 좀 더 다양한 색으로 필터링하는 등의 변화에는 적절하게 대응할 수 없다. 이런 경우 코드를 추상화해야 한다.

색을 파라미터화

색을 파라미터화할 수 있도록 메서드에 파라미터를 추가하면 변화하는 요구사항에 좀 더 유연하게 대응하는 코드를 만들 수 있다.

public static List<Apple> filterAppleByColor(List<Apple> inventory, Color color) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (apple.getColor() == color) {
            result.add(apple);
        }
    }
    return result;
}

분명 이전보다 유연한 코드가 됐다는 것은 확실하다. 하지만 여기서 색 이외에도 가벼운 사과와 무거운 사과로 구분할 수 있도록 조건을 부여하려고 한다면, Color에 이어서 weight에 대한 정보또한 메서드 파라미터로 추가해야 할 것이다.

나쁜 방법은 아니지만 각 사과에 필터링 조건을 적용하는 부분의 코드가 색 필터링 코드와 대부분 중복된다. 반복이 발생하고 있기 때문에 이는 지양하는 것이 좋다는 것을 본능적으로(?) 알고 있을 것이다. 이를 개선하기 위해서는 한 줄이 아니라 메서드 전체 구현을 고쳐야 한다.

가능한 모든 속성으로 필터링

다음은 모든 속성을 메서드 파라미터로 추가한 모습이다.

public static List<Apple> filterApples(List<Apple> inventory, Color color, 
                                        int weight, boolean flag) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if ((flag && apple.getColor().equals(color)) ||
            (!flag && apple.getWeight() > weight)) {
            result.add(apple);
        }
    }
    return result;
}

위의 코드는 최악의 코드이다. 일단 flag의 의미가 불명확하다. 또한 요구사항이 바뀌었을 때 유연하게 대응할 수도 없다.
물론 문제가 잘 정의되어 있는 상황에서는 이 방법이 동작할 수도 있다. 하지만 filterApples에 어떤 기준으로 사과를 필터링할 것인지 효과적으로 전달할 수 있으면 더 좋을 것이다.

동작 파라미터화

앞의 내용을 통해서 파라미터를 추가하는 방법이 아닌 변화하는 요구사항에 좀 더 유연하게 대응할 수 있는 방법이 필요하다는 것을 확인했다.
우리의 선택 조건을 다음처럼 결정할 수 있다. 사과의 어떤 속성에 기초해서 불리언값을 반환하는 방법이 있다. 선택 조건을 결정하는 인터페이스를 정의하자.

참 또는 거짓을 반환하는 함수를 프레디케이트라고 한다.

public interface ApplePredicate {
    boolean test (Apple apple);
}

다음 예제처럼 다양한 선택 조건을 대표하는 여러 버전의 ApplePredicate를 정의할 수 있다.

무거운 사과만 선택하는 Predicate

public class AppleHeavyWeightPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return apple.getWeight() > 150;
    }
}

녹색 사과만 선택하는 Pedicate

public class AppleGreenPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return apple.getColor() == GREEN;
    }
}

위 조건에 따라 filter 메서드가 다르게 동작할 것이라고 예상할 수 있다. 이를 전략 패턴(Strategy Pattern)이라고 부른다. 즉 ApplePredicate는 사과 선택 전략을 캡슐화 한 것이다.

전략패턴은 각 알고리즘(=전략)을 캡슐화하는 알고리즘 패밀리를 정의해둔 다음에 런타임에 알고리즘을 선택하는 기법이다.

그렇다면 ApplePredicate는 어떻게 다양한 동작을 수행할 수 있을까? filterApples에서 ApplePredicate 객체를 받아 애플의 조건을 검사하도록 메서드를 고쳐야 한다. 이렇게 동작 파라미터화, 즉 메서드가 다양한 동작을 받아서 내부적으로 다양한 동작을 수행할 수 있다.

이제 filterApples 메서드가 ApplePredicate 객체를 인수로 받도록 고친다. 이렇게 하면 filterApples 메서드 내부에서 컬렉션을 반복하는 로직과 컬렉션의 각 요소에 적용할 동작을 분리할 수 있다는 점에서 소프트웨어 엔지니어링적으로 큰 이득을 얻는다.

추상적 조건으로 필터링

public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
    List<Apple> result = new ArrayList<>();
    for(Apple apple : inventory) {
        if(p.test(apple)) { // 프레디케이트 객체로 사과 검사 조건을 캡슐화 했다! 
            result.add(apple);
        }
    }
    return result;
}

코드/동작 전달하기

처음 코드에 비해 더 유연한 코드를 얻었으며 동시에 가독성도 좋아졌고 심지어 사용하기도 쉬워졌다. 필요한대로 다양한 ApplePredicate를 만들어서 filterApples 메서드로 전달할 수 있다. 유연성을 마음껏 누리자!

우리가 전달한 ApplePredicate 객체에 의해 filterApples 메서드의 동작이 결정된다. 즉, 우리는 filterApples 메서드의 동작을 파라미터화한 것이다. 가장 중요한 구현은 test 메서드다. filterApples 메서드의 새로운 동작을 정의하는 것이 test 메서드다. 안타깝게도 메서드는 객체만 인수로 받으므로 test 메서드를 ApplePredicate 객체로 감싸서 전달해야 한다. test 메서드를 구현하는 객체를 이용해서 불리언 표현식 등을 전달할 수 있으므로 이는 코드를 전달할 수 있는 것이다 다름없다.

한 개의 파라미터, 다양한 동작

지금까지 살펴본 것처럼 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리할 수 있다는 것이 동작 파라미터화의 강점이다. 따라서 한 메서드가 다른 동작을 수행하도록 재활용할 수 있다. 따라서 유용한 API를 만들 때 동작 파라미터화가 중요한 역할을 한다. 동작을 추상화해서 변화하는 요구사항에 대응할 수 있는 코드를 구현하는 방법을 배웠다. 하지만 여러 클래스를 구현해서 인스턴스화하는 과정이 조금은 거추장스럽게 느껴진다. 이 부분을 개선할 방법을 다음 절에서 제시한다.

복잡한 과정 간소화

지금 상태는 filterApples 메서드로 새로운 동작을 전달하려면 ApplePredicate 인터페이스를 구현하는 여러 클래스를 정의한 다음에 인스턴스화해야 하는데 이는 상당히 번거롭다. 게다가 이는 로직과 관련 없는 코드가 많이 추가되게된다.

이는 익명 클래스(anonymous class)라는 기법을 통해 개선할 수 있다. 익명 클래스를 이용하면 코드의 양을 줄일 수 있다. 하지만 익명 클래스가 모든 것을 해결하는 것은 아니다. 이는 람다 표현식으로 더 가독성 있는 코드를 구현할 수도 있다.

익명 클래스

익명 클래스는 자바의 지역 클래스(local class)와 비슷한 개념이다. 익명 클래스는 말 그대로 이름이 없는 클래스다. 익명 클래스를 이용하면 클래스 선언과 인스턴스화를 동시에 할 수 있다. 즉, 즉석에 필요한 구현을 만들어서 사용할 수 있다.

익명 클래스 사용

익명 클래스를 이용해서 ApplePredicate를 구현하는 객체를 만드는 방법으로 필터링 예제를 다시 구현한 코드이다.

List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
        public boolean test(Apple apple) {
            return apple.getColor() == RED;
        }
});

익명 클래스도 부족한 점이 있다. 1) 익명 클래스는 여전히 많은 공간을 차지한다. 2) 많은 프로그래머가 익명 클래스의 사용에 익숙치 않다.

코드의 장황함은 나쁜 특성이다. 장황한 코드는 구현하고 유지보수하는 데 시간이 오래걸릴 뿐 아니라 읽는 즐거움을 빼앗는 요소로, 개발자로부터 외면받는다. 한눈에 이해할 수 있어야 좋은 코드다. 익명 클래스로 인터페이스를 구현하는 여러 클래스를 선언하는 과정을 조금줄일 수 있지만 여전히 만족스럽지 않다. 보는 것처럼 동작 파라미터화를 이용하면 요구사항 변화에 더 유연하게 대응할 수 있으므로 모든 프로그래머가 동작 파라미터화를 사용하도록 권장한다. 람다 표현식이라는 더 간단한 코드 전달 기법을 도입하면 코드의 장황함을 없애면서도 동작 파라미터화의 장점을 활용할 수 있다.

람다 표현식의 사용

자바 8의 람다 표현식을 이용해서 위 예제 코드를 다음처럼 간단하게 재구현할 수 있다.

List<Apple> result = 
    filterApples(inventory, (Apple apple) -> apple.getColor() == RED);

이전 코드보다 훨씬 간단해졌고, 문제를 더 잘 설명하는 코드가 되었다.

리스트 형식으로 추상화

public interface Predicate<T> {
    boolean text(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p) {
    List<T> result = new ArrayList<>();
    for (T e : list) {
        if (p.test(e)) {
            result.add(e);
        }
    }
    return result;
}

이제 바나나, 오렌지, 정수, 문자열 등의 리스트에 필터 메서드를 사용할 수 있다. 다음은 람다 표현식을 사용한 예제다.

List<Apple> redApples = 
        filter(inventory, (Apple apple) -> apple.getColor() == RED);

List<Integer> evenNumbers = filter(numbers, (Integer i) -> i % 2 == 0);

이렇게 해서 유연성관 간결함이라는 두 마리 토끼를 모두 잡을 수 있다!

교재에서는 동작 파라미터의 예제로 Comparator, Runnable, Collable(생소한데.. GUI 이벤트 처리시에 사용할 수 있는 것 같다), EventHandler 등을 예로들고 있는데 이 코드의 javadoc을 보면 아래와 같다.

Comparator

image

Runnable

image

Collable

image

EventHandler

image

모두 @FunctionalInterface 라는 애너테이션이 붙은 것을 확인할 수 있다. 이 언급을 함으로써 이미 감을 잡은 사람도 있겠지만, 람다식은 FunctionalInterface 즉 함수형 인터페이스인 경우에 사용이 가능하다.

함수형 인터페이스단 1개의 추상 메서드를 가지는 인터페이스를 칭한다. 애너테이션은 이 인터페이스가 함수형 인터페이스다 라는 주석 정도로 생각하면 된다(사용하면 간지력도 올라간다😎). 단순히 주석만의 기능을 가지지는 않는다. 해당 인터페이스가 함수형 인터페이스가 아니라면 아래와 같이 컴파일 오류가 발생한다!

함수형인터페이스가 아닌 곳에 @FunctionalInterface 애너테이션이 있으면 컴파일 오류가 발생한다.

image

추상메서드를 한 개로 변경했더니 컴파일 오류가 사라진다.

image

@FunctionalInteface javadoc

image

728x90