728x90

JVM이란 무엇인가

JVM은 Java virtual machine을 줄인 것으로 자바를 실행하기 위한 가상 컴퓨터이다.

자바로 작성된 애플리케이션은 모두 JVM에서만 실행되기 때문에, 자바 애플리케이션을 실행하기 위해서는 JVM이 반드시 필요하다. 컴파일러는 Java 파일을 .class 파일로 컴파일한 다음 해당 .class 파일을 JVM에 입력하여 클래스 파일을 로드하고 실행한다.

WORA(Write once, run anywhere)

이 과정을 통하여 Java 는 높은 이식성이라는 큰 장점을 얻을 수가 있었다. 어느 기기나 운영체제에 상관없이 JVM 이 설치 및 구동될 수 있는 환경이라면 Java 로 작성한 프로그램은 실행이 가능하기 때문에 다른 운영체제에 맞춰서 컴파일을 해줘야 하는 다른 언어보다 높은 이식성을 가질 수 있게 되었다.

단, JVM은 OS에 종속적이기 때문에 해당 OS에서 실행가능한 JVM이 필요하다.

JVM은 바이트 코드를 이해하는 것이지 자바 코드를 이해하는 것이 아니다. 코틀린 또한 코틀린 코드를 바이트 코드로 컴파일해서 JVM 위에서 동작한다.

JVM의 특성

  • 스택 기반의 가상 머신
  • 단일 상속 형태의 객체 지향 프로그래밍을 가상 머신 수준에서 구현
  • 포인터를 지원. 단, C와 같이 주소 값을 임의로 조작이 가능한 포인터 연산은 불가능
  • Garbage collection 수행
  • 플랫폼의 독립성 보장
  • Data Flow Analysis에 기반한 자바 바이트코드 검증기를 통해 문제를 실행 전에 검증하여 실행 시 안전을 보장하고 별도의 부담을 줄여줌

바이트코드란 무엇인가

바이너리 코드

CPU가 이해하기 위한 기계어는 0과 1로 구성된 바이너리 코드(이진 코드)이다. 기계어가 이진 코드로 이루어졌을 뿐 모든 이진 코드가 기계어인 것은 아니다.

바이너리 코드 != 기계어

바이트 코드

0과 1로 이루어진 이진 코드이지만 바이너리 코드와 달리 가상머신이 이해할 수 있는 코드이다. 사람에게 친숙한 고급 언어보다는 덜 추상적이지만 기계어보다는 추상적이다.

고급언어로 작성된 코드를 가상머신이 이해할 수 있도록 컴파일한 것이다. CPU에게 넘어가기 전에 실시간 번역기 또는 JIT(just-in-time) 컴파일러에 의해 바이너리 코드로 변환된다.

정리

Java는 OS와 직접적으로 대화할 수 없다. 오로지 JVM하고만 상호작용을 한다. 자바는 JVM을 거쳐야만 OS와 대화할 수 있다.

바이너리 코드와 바이트 코드 둘 다 0과 1로 이루어져 있다. 바이너리 코드는 CPU가 이해할 수 있는 언어, 바이트 코드는 가상 머신이 이해할 수 있는 언어이다.

그 중에 JVM을 위한 바이트 코드를 “자바 바이트코드”라고 한다.


JVM 구성 요소

JVM은 크게 세 가지 구성요소로 볼 수 있다.

  • Class Loader
  • Runtime Data Area
  • Execution Engine


출처: https://dzone.com/articles/jvm-architecture-explained

Class Loader

JDK 에서 개발하고, JRE 를 통해서 환경을 제공받은 JVM 은 compile 된 바이트 코드를 탑재하여 로직을 실행하게 됩니다. 그렇다면 JVM 에 Class 는 어떻게 로드되는 것일까??

바로 그 역할을 하는 것이 자바 클래스로더이다. 클래스 로더는 자바 클래스를 JVM으로 동적 로드하는 JRE(자바 런타임 환경)의 일부이다. 클래스 파일을 로드하는데 사용되는 하위 시스템이다.

Compile time이 아닌, Runtime시 처음으로 한 번만 동적으로 클래스를 로드하며, jar 파일 내에 저장된 클래스들을 JVM 위에 탑재하고 사용하지 않는 클래스들은 메모리에서 삭제한다.변환된 바이트 코드 파일(.class)을 JVM이 운영체제로부터 할당 받은 메모리 영역인 Runtime Data Area로 “적재”하는 역할을 한다.

ClassLoader 는 클래스 파일을 찾아서 탑재하는 역할뿐만이 아니라 jvm 에 관련된 다른 일들도 같이 한다.

크게 Loading, Linking, 그리고 Initialization 3가지 역할을 맡게 된다.

  • Loading 은 클래스 파일을 탑재하는 과정
  • Linking 은 클래스 파일을 사용하기 위해 검증하고, 기본 값으로 초기화하는 과정
  • Initialization 은 static field 의 값들을 정의한 값으로 초기화를 하는 과정

Runtime Data Area

이렇게 탑재하는 클래스 파일들은 JVM 에서 어떤 영역을 차지하고 있을까? JVM 의 Run-Time Data Area는 프로그램을 수행하기 위해 OS에서 할당받은 메모리 공간이며, 크게 Method Area , Heap , Java Stacks , PC registers 그리고 Native Method Stacks 가 존재한다.

출처: https://tecoble.techcourse.co.kr/post/2021-08-09-jvm-memory/

Method Area

  • Method Area 에는 인스턴스 생성을 위한 객체 구조, 생성자, 필드 등이 저장된다. Runtime Constant Pool 과 static 변수, 그리고 메소드 데이터와 같은 Class 데이터들도 이곳에서 관리 된다.
  • 즉, 정적 변수를 포함하여 모든 클래스 수준 데이터가 여기에 저장된다.
  • JVM당 하나의 메소드 영역만 있으며 공유 자원입니다.다른 스레드에서도 활용 가능한 공유자원이다.
  • 다중 스레드에 대한 메모리를 공유하므로 저장된 데이터는 스레드에 안전하지 않다

Heap

  • 모든 객체와 해당 인스턴스(instance) 변수 및 배열, String pool이 여기에 저장됩니다.
  • JVM 당 역시 하나만 생성이 되고, 해당 영역이 가진 데이터는 모든 Java Stack 영역에서 참조되어, Thread 간 공유가 됩니다.
  • 다중 스레드에 대한 메모리를 공유하므로 저장된 데이터는 스레드에 안전하지 않다
  • GC의 주 대상

Native Method Stack

  • 순수하게 Java 로 구성된 코드만을 사용할 수 없는 시스템의 자원이나 API 가 존재합니다.
  • 다른 프로그래밍 언어로 작성된 메소드들을 Native Method 라고 합니다.
  • 일반적인 메소드를 실행하는 경우 JVM Language Stack에 적재되지만, 네이티브 메소드 스택은 네이티브 라이브러리에 따라 네이티브 코드 명령(C언어와 같이 네이티브 방식으로 작성된 메소드)을 보관한다.

PC Register

  • Java 에서 Thread 는 각자의 메소드를 실행하게 됩니다. 이때, Thread 별로 동시에 실행하는 환경이 보장되어야 하므로 최근에 실행 중인 JVM 에서는 명령어 주소값을 저장할 공간이 필요합니다.
  • 이 부분을 PC Registers 영역이 관리하여 추적해주게 됩니다. Thread 들은 각각 자신만의 PC Registers 를 가지고 있습니다.
  • 만약 실행했던 메소드가 네이티브하다면 undefined 가 기록이 됩니다. 실행했던 메소드가 네이티브하지 않다면, PC Registers 는 JVM 에서 사용된 명령의 주소 값을 저장하게 됩니다.

Stack

  • 프로그램 실행과정에서 임시로 할당되었다가 메소드를 빠져나가면 바로 소멸되는 특성의 데이터를 저장하기 위한 영역이다.
  • 각종 형태의 변수나 임시 데이터, 스레드나 메소드의 정보를 저장하고 호출된 메서드의 매개변수, 지역변수, 리턴 값 및 연산 시 일어나는 값들을 임시로 저장한다.
  • 각 스레드에는 자체 JVM 스택이 있고, 스레드가 생성될 때 동시에 생성된다.
  • 각 Thread 별로 따로 할당되는 영역이므로 Heap 메모리 영역보다 비교적 빠르다는 장점이 있다. 또한, 각각의 Thread 별로 메모리를 따로 할당하기 때문에 동시성 문제에서 자유롭다.

Execution Engine

런타임 데이터 영역에 할당된 바이트코드는 실행 엔진에 의해 실행된다. Execution Engine은 바이트코드를 읽고 자바 바이트 코드를 JVM 내부에서 컴퓨터가 실행할 수 있는 형태인 바이너리 코드로 변경하며 하나씩 실행한다.

변경하는 방식은 두가지가 있는데, 인터프리터 방식과 JIT 방식이 있다.

인터프리터 방식

  • 기본 바이트 코드를 실행하는 방법은 인터프리터 방식이 기본이다. 자바 바이트 코드를 명령어 단위로 읽어서 실행하기 때문에 느리다.

JIT(just-in-time) Compiler

  • JIT 컴파일러는 인터프리터의 단점을 해결한다. 실행 엔진은 바이트 코드를 변환하는 데 인터프리터의 도움을 사용할 것이지만 반복되는 코드를 발견하면 전체 바이트코드를 컴파일하여 네이티브 코드로 변경하는 JIT 컴파일러를 사용한다. 이 네이티브 코드는 반복 메서드 호출에 직접 사용되어 시스템 성능을 향상시킨다.

Garbage Collector

  • 참조되지 않은 객체를 수집하고 제거한다. JVM의 가비지 컬렉션은 생성된 객체를 수집한다.

JVM 동작 간단 정리

JVM 구성 요소는 다음과 같다.

  1. 클래스 로더 컴파일러가 내부에 만든 .class(바이트 코드)를 런타임 데이터 공간에 “적재”한다.
  2. 런타임 데이터 공간 OS로부터 메모리를 할당받은 공간으로 스택, 힙, 메소드, 네이티브 메소드, PC 레지스터가 있다.
  3. 실행 엔진인터프리터 방식 또는 JIT 컴파일러를 이용하여 데이터 영역에 배치된 바이트 코드를 실행한다.
  • JIT 컴파일러는 바이트 코드를 바이너리 코드로 변환하는 속도가 느린 인터프리터 방식을 보완하기 위해 나온 것이다.
  • 인터프리터 방식으로 기계어 코드를 생성하면서 그 코드를 캐싱하여, 같은 함수가 여러 번 불릴 때 매번 기계어 코드를 생성하는 것을 방지한다.
  • JVM 내부에서는 자바 컴파일러가 자바 프로그램 코드를 바이트 코드로 변환시킨 후 실제 바이트 코드가 실행하는 시점에서 JIT 컴파일러를 통해 기계어로 변환한다.
  1. GC는 JVM 상에서 더 이상 사용되지 않는 데이터가 할당되어있는 메모리를 해제시킨다.

컴파일 하는 방법

컴파일이란?

컴파일러는 특정 프로그래밍 언어로 쓰여 있는 문서를 다른 프로그래밍 언어로 옮기는 프로그램을 말한다. 기존 문서를 소스 코드 혹은 원시 코드라고 부르고, 출력된 문서를 목적 코드라고 부른다.원시 코드에서 목적 코드로 옮기는 과정을 컴파일이라고 한다.

자바 컴파일 과정

  1. 소스 파일 생성한다. (Hello.java)
  2. 자바 컴파일러(javac.exe)를 사용하여 컴파일한다. $ javac Hello.java
  3. 컴파일이 정상적으로 완료되면 클래스 파일 생성된다. (Hello.class)

실행하는 방법

자바 인터프리터(java.exe)로 실행한다. $ java Hello실행 시에는 확장자를 붙이지 않는다.

내부적인 진행순서는 다음과 같다.

  1. 프로그램의 실행에 필요한 클래스(*.class파일)을 로드한다.
  2. 클래스파일을 검사한다.(파일형식, 악성코드 체크)
  3. 지정된 클래스(Hello)에서 main(String[] args)을 호출한다.

JDK와 JRE의 차이

JDK란?

JDK는 Java Development Kit으로 자바 프로그래밍 시 필요한 컴파일러 등을 포함한다. JDK는 JRE를 포함하며, 개발을 위해 필요한 도구(java, javac 등)를 포함한다.

JRE란?

JRE는 Java Runtime Enviroment로 컴파일된 자바 프로그램을 실행시킬 수 있는 자바 환경을 말한다. JRE는 JVM의 실행환경을 구현했다고 볼 수 있으며, JVM이 자바 프로그램을 동작시킬 때 필요한 라이브러리 파일들과 기타 파일들을 가지고 있다.


javac 옵션

더 자세한 javac의 standard options은 공식 문서에서 볼 수 있다.

$ javac <options> <source files>

-cp path or -classpath path

  • 컴파일러가 참조할 클래스 파일들을 찾기 위해서 컴파일 시 파일경로를 지정해주는 옵션이다.해당 옵션을 쓰지 않는 경우(classpath가 지정되지 않는 경우) 사용자 클래스 경로는 현재 디렉터리가 된다.

-d directory

  • 클래스 파일의 대상 디렉터리를 설정한다. javac가 별도의 디렉터리를 만들지 않기 때문에 디렉터리는 미리 만들어둬야 한다.

-deprecation

  • 사용되지 않는 멤버 또는 클래스의 사용 또는 오버라이드에 대한 설명을 표시한다.해당 옵션이 없는 javac는 사용되지 않는 멤버나 클래스를 사용하거나 재정의하는 소스 파일의 요약을 보여준다.

-g

  • 로컬 변수를 포함한 모든 디버깅 정보를 생성한다.g:none : 디버깅 정보를 생성하지 않는다.g:{source, lines, vars} : 소스파일 정보, 라인 정보, 지역변수의 디버깅 정보를 생성한다.

-source release

  • 소스 코드의 버전을 지정한다.

-target version

  • 가상 시스템의 지정된 릴리스를 대상으로 하는 클래스 파일을 생성한다. 클래스 파일은 지정된 대상 및 이후 릴리스에서 실행되지만 이전 릴리스의 JVM에서는 실행되지 않는다.

참고 출처

728x90
728x90

싱글턴

  • 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
    • ex) 함수와 같은 무상태 객체, 설계상 유일해야 하는 시스템 컴포넌트
  • 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.
    • 타입을 인터페이스로 정의한 다음 해당 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 가짜(mock) 구현으로 대체할 수 없기 때문이다.
  • 싱글턴 만드는 방식
    • 두 방식 모두 생성자는 private로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둔다.

1. public static 멤버가 final 필드인 방식

  • private 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 한번만 호출된다.
  • public, protected 생성자가 없으므로 Elvis 클래스가 초기화될 때 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.
    • 예외: 권한이 있는 클라이언트는 리플렉션 api인 AccessibleObject.setAccessible을 사용해 private 생성자를 호출할 수 있다.
    • 이러한 공격을 방어하려면 생성자를 수정하여 두 번째 객체가 생성되려 할 때 예외를 던지도록 한다.
  • 장점
    • 해당 클래스가 싱글턴임이 API에 명백히 드러난다
    • public static 필드가 final 이니 절대로 다른 객체를 참조할 수 없다
    • 간결하다
public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }

    public void leaveTheBuilding() { ... }
}

2. 정적 팩터리 메서드를 public static 멤버로 제공

  • Elvis.getInstance는 항상 같은 객체의 참조를 반환하므로 제2의 Elvis 인스턴스는 만들어지지 않다
    • 리플렉션을 통한 예외는 똑같이 적용
  • 장점
    • API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다.
    • 정적 팩터리를 제네릭 싱글턴 팩터리(아이템 30)를 만들 수 있다.
    • 정적 팩터리의 메서드 참조를 공급자(supplier)로 사용할 수 있다.
      • Elvis::get Instance → Supplier
    • 이런한 장점들이 굳이 필요하지 않는다면 public 필드 방식이 좋다.
public class Elvis {
         private static final Elvis INSTANCE = new Elvis();
    private Elvis() { }
    public static Elvis getInstance() { return INSTANCE; }

    public void leaveTheBuilding() {...}
}
  • 두 가지 방식으로 만든 싱글턴 클래스를 직렬화하는 경우 Serializable을 구현하는 것만으로 부족
    • 모든 인스턴스 필드를 일시적(transient)이라고 선언하고 readResolve 메서드를 제공해야 한다.
    • 그렇지 않으면 직렬화된 인스턴스를 역직렬화할 때 마다 새로운 인스턴스가 생성된다
public Object readResolve() {
    // 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
    return INSTANCE;
  }
}

Singleton은 왜 안티패턴이라 불리는가?

  • SOLID 원칙의 대부분은 인터페이스 설계와 관련이 되어있다. 의존성을 concrete class(구현 클래스)가 아닌 Interface에 두면, 실제 concrete class의 구현이 변경되어도 이를 사용한 코드는 큰 영향을 받지 않는다. 그렇기 때문에 SOLID원칙(OCP, LSP, ISP, DIP등)을 지키기 위해서는 인터페이스로 설계를 해야한다.
  • 하지만 싱글톤을 이용하는 경우 대부분 인터페이스가 아닌 콘크리트 클래스의 객체를 미리 생성해놓고 정적 메소드를 이용하여 사용하게 된다. 이는 여러 SOLID원칙을 위반할 수 있는 가능성을 열어둠과 동시에, 싱글톤을 사용하는 곳과 싱글톤 클래스 사이에 의존성이 생기게 된다. 클래스 사이에 강한 의존성, 즉 높은 결합이 생기게 되면 수정, 단위테스트의 어려움 등 다양한 문제가 발생한다.

1. private 생성자를 갖고 있어 상속이 불가능하다.

  • 싱글톤은 자신만이 객체를 생성할 수 있도록 생성자를 private으로 제한한다. 하지만 상속을 통해 다형성을 적용하기 위해서는 다른 기본생성자가 필요하므로 객체지향의 장점을 적용할 수 없다. 또한 싱글톤을 구현하기 위해서는 객체지향적이지 못한 static 필드와 static 메소드를 사용해야 한다.

2. 테스트하기 힘들다.

  • 싱글톤은 테스트하기가 힘드며 테스트 방법에 따라 불가능할 수 있다. 싱글톤은 생성 방식이 제한적이기 때문에 Mock 객체로 대체하기가 어려우며, 동적으로 객체를 주입하기도 힘들다.
  • 테스트는 개발의 핵심인데, 테스트 코드를 작성할 수 없다는 것은 큰 단점이 된다.

3. 서버 환경에서는 싱글톤이 1개만 생성됨을 보장하지 못한다.

  • 서버에서 클래스 로더를 어떻게 구성하느냐에 따라 싱글톤 클래스임에도 불구하고 1개 이상의 객체가 만들어질 수 있다. 따라서 Java 언어를 이용한 싱글톤 기법은 서버 환경에서 싱글톤이 꼭 보장된다고 볼 수 없다. 또한 여러 개의 JVM에 분산돼서 설치되는 경우에도 독립적으로 객체가 생성된다.
  • 생성자를 private하게 두었어도 reflection을 통해 하나 이상의 오브젝트가 만들어질 수 있다. 또한 여러개의 JVM에 분산돼서 설치가 되는 경우에도 각각 독립적으로 오브젝트가 생기기 때문에 싱글톤으로서의 가치가 떨어진다.

4. 전역 상태를 만들 수 있기 때문에 바람직하지 못하다.

  • 싱글톤의 스태틱 메소드를 이용하면 언제든지 해당 객체를 사용할 수 있고, 전역 상태(Global State)로 사용되기 쉽다. 아무 객체나 자유롭게 접근하고 수정하며 공유되는 전역 상태는 객체지향 프로그래밍에서 권장되지 않는다.
  • 싱글톤 패턴은 객체를 1번 생성하고 재사용할 수 있다는 장점이 있다. 하지만 다른 단점들이 너무 크기 때문에 활용이 쉽지 않았는데, Spring에서는 컨테이너를 통해 직접 객체(빈)들을 싱글톤으로 관리함으로써 객체를 재사용함과 동시에 객체지향스로운 개발을 할 수 있도록 해주었다.

3. 원소가 하나인 열거 타입을 선언

  • public 필드 방식과 비슷하지만 더 간결하고, 직렬화 가능하고 좋다
  • 단, 만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 사용 불가
public enum Elvis {
  INSTANCE;

  public void leaveTheBuilding() {...}
}

참고 출처

728x90
728x90

정적 팩터리와 생성자의 제약

  • 선택적 매개변수가 많을 때 적절히 대응하기 어려움
  • ex) 영양정보를 표현하는 클래스
    • 필수 항목: 1회 내용량, n회 제공량, 1회 제공량당 칼로리
    • 선택 항목: 총 지방, 트랜스지방, 콜레스테롤, 나트륨 등 20가지 이상
    • 대다수 제품은 선택 항목 중 대다수의 값이 0
  • 이런 클래스용 생성자 혹은 정적 팩터리는 주로 점층적 생성자 패턴을 사용해왔음

점층적 생성자 패턴

  • telescoping constructor pattern
  • 필수매개변수 생성자, 필수 + 선택 1 생성자... 형태로 선택 매개변수를 전부 다 받는 생성자까지 늘려가는 방식
  • 해당 클래스의 인스턴스를 만들려면 원하는 매개변수를 모두 포함한 생성자 중 가장 짧은 것을 골라 호출
  • 단점
    • 매개변수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다
public class NutritionFacts{
    private final int servingSize;  // 필수
    private final int servings;     // 필수
    private final int calories;     // 선택
    private final int fat;          // 선택
    private final int sodium;       // 선택
    private final int carbohydrate; // 선택

    public NutritionFacts(int servingSize, int servings){
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories){
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories,
                          int fat){
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories,
                          int fat, int sodium){
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories,
                          int fat, int sodium, int carbohydrate){
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

자바 빈즈 패턴(javaBeans pattern)

  • 두번째 대안인 자바빈즈 패턴, 매개변수가 없는 생성자로 객체를 만든 후, 세터 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식
  • 심각한 단점
    • 객체 하나를 만들려면 메서드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다
    • 이런 일관성이 무너지는 문제 때문에 클래스를 불변으로 만들 수 없으며 스레드 안정성을 얻으려면 추가 작업이 필요하다.
    • 생성이 끝난 객체를 수동으로 freezing하고 얼리기 전에는 사용할 수 없도록 하기도 한다.
      • 하지만 freeze 메서드를 확실히 호출해줬는지를 컴파일러가 보증 x, 런타임 오류 취약
class NutritionFacts{

    private int servingSize  = -1;  // 필수
    private int servings     = -1;  // 필수
    private int calories     = 0;   // 선택
    private int fat          = 0;   // 선택
    private int sodium       = 0;   // 선택
    private int carbohydrate = 0;   // 선택

    public NutritionFacts() { }

    public void setServingSize(int val) { servingSize = val; }

    public void setServings(int servings) { servings = val; }

    public void setCalories(int calories) { calories = val; }

    public void setFat(int fat) { fat = val; }

    public void setSodium(int sodium) { sodium = val; }

    public void setCarbohydrate(int carbohydrate) { carbohydrate = val; }
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

빌더 패턴(Builder pattern)

  • 앞의 두가지의 장점을 섞은 세번째 대안

    • 점층적 생성자 패턴의 안정성과 자바 빈즈 패턴의 가독성을 겸비한 패턴
  • 클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자(정적 팩토리)를 호출해 빌더 객체를 얻는다

    • 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다
    • 매개변수가 없는 build 메서드를 호출해 필요한(보통은 불변인) 객체를 얻는다.

    cf) 빌더는 생성할 클래스안에 정적 멤버 클래스로 만들어두는게 보통

  • https://devlog-wjdrbs96.tistory.com/206

class NutritionFacts{
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder{
        // 필수 매개변수
        private final int servingSize;
        private final int servings;

        // 선택 매개변수 - 기본값으로 초기화한다.
        private int calories     = 0;
        private int fat          = 0;
        private int sodium       = 0;
        private int carbohydrate = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings = servings;
        }

        public Builder calories(int val){
            this.calories = val;
            return this;
        }

        public Builder fat(int val){
            this.fat = val;
            return this;
        }

        public Builder sodium(int val){
            this.sodium = val;
            return this;
        }

        public Builder carbohydrate(int val){
            this.carbohydrate = val;
            return this;
        }

        public NutritionFacts build(){
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder){
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}
  • 계층적으로 설계된 클래스와 함게 쓰기에 좋다
    • 각 계층의 클래스에 관련 빌더를 멤버로 정의, 추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더
    • Pizzan.Builder 클래스는 재귀적 타입 한정을 이용하는 제네릭 타입
    • 추상 메서드인 self를 더해 하위 클래스에서는 형변환 하지 않고도 메서드 체이닝 지원
      • self 타입이 없는 자바를 위한 우회 방법을 시뮬레이트한 셀프 타입 관용구라 한다.
public abstract class Pizza {
    public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
    final Set<Topping> toppings;

    abstract static class Builder<T extends Builder<T>>{
        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
        public T addTopping(Topping topping){
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();

                // 하위 클래스는 이 메서드를 오버라이딩하여 this를 반환하도록 해야 한다
        protected abstract T self();
    }

    Pizza(Builder<?> builder){
        toppings = builder.toppings.clone();    // 아이템 50 참조
    }
}
// 뉴욕 피자
public class NyPizza extends Pizza {
    public enum Size { SMALL, MEDIUM, LARGE }
    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }

        @Override
        public NyPizza build() {
            return new NyPizza(this);
        }

        @Override
        protected Builder self() {
            return this;        
        }

        public NyPizza(Builder builder) {
            super(builder);
            size = builder.size;
        }
    }
}

단점

  • 빌더 생성 비용이 문제 될 수도 있다
  • 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치를 한다

결론

  • 생성자나 정적 팩터리가 처리해야할 매개변수가 많다면 빌더 패턴을 선택하는게 더 낫다.
  • 매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그렇다.
  • 빌더는 클라이언트 코드의 가독성이 좋고, 자바빈즈보다 훨씬 안전하다.
  • 이런 스타일의 빌더 패턴은 Lombok @Builder로 가능
  • https://johngrib.github.io/wiki/design-pattern/builder-pattern/
728x90
728x90

클라이언트가 클래스의 인스턴스를 얻는 수단

  • public 생성자: 전통적인 수단
  • 정적 팩터리 메서드(static factory method)
    • 해당 클래스의 인스턴스를 반환하는 정적 메서드
    • 디자인 패턴에서의 팩터리 메서드와 다름
public static Boolean valueOf(boolean b) {
        return b ? Boolean.True : Boolean.False;
}

장점


이름을 가질 수 있다.

  • 생성자에 넘기는 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명하기 어렵다
  • BigTnteger(int, int, Random) vs Biginteger.probablePrime()
  • 하나의 시그니처로는 생성자를 하나만 만들 수 있다
    • 입력 매개변수들의 순서를 다르게 한 생성자를 새로 추가하는 방식으로 회피할 수 있지만 좋지 않은 방식
    • 정적 팩터리 메서드는 생성자를 정적 팩터리 메서드로 바꾸고 각각의 차이를 잘 드러내는 네이밍

호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다

  • 이로 인해 불변 클래스는 인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 불필요한 객체 생성 피할 수 있다
  • 생성 비용이 큰 같은 객체가 자주 요청되는 상황이라면 성능 향상, 플라이웨이트 패턴과 비슷
  • 정적 팩터리 방식의 클래스 == 인스턴스 통제 클래스
    • 즉 언제 어느 인스턴스를 살아 있게 할지를 철저히 통제 가능
  • 인스턴스 통제는 플라이웨이트 패턴의 근간이 되며, 열거 타입은 인스턴스가 하나만 만들어짐을 보장한다.

반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.

  • API를 만들 때 이 유연성을 응용하면 구현 클래스를 공개하지 않고도 객체를 반환할 수 있어 api를 작게 유지 가능
  • 인터페이스를 정적 팩터리 메서드의 반환 타입으로 사용하는 인터페이스 기반 프레임워크를 만드는 핵심 기술
  • 자바8 이전에는 인터페이스에 정적 메서드를 선언할 수 없어서 정적 메서드가 필요하면 동반 클래스를 만들어서 정의하는 것이 관례였다.
    • ex) Collection <> Collections
  • 자바8 부터는 동반 클래스를 둘 이유가 별로 없고, 동반 클래스에 두었던 public 정적 멤버들 상당수를 인터페이스 자체에 두면 된다.
    • 하지만 자바9에서는 pirvate 정적 메서드까지 허용하지만 정적 필드와 정적 멤버 클래스는 여전히 public이어야 해서, 별도의 package-private 클래스에 두어야 할 수 도 있다.

입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.

  • 클라이언트는 팩터리가 건네주는 객체가 어느 클래스의 인스턴스인지 알 수도 없고 알 필요도 없다.
  • 반환 타입의 하위 타입이기만 하면 되기 때문이다.
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
     Enum<?>[] universe = getUniverse(elementType);
     if (universe == null)
         throw new ClassCastException(elementType + " not an enum");

     if (universe.length <= 64)
         return new RegularEnumSet<>(elementType, universe);
     else
         return new JumboEnumSet<>(elementType, universe);
 }

정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다

  • 이러한 유연함은 서비스 제공자 프레임워크를 만드는 근간이 된다
  • 대표적인 예시 JDBC

단점


  1. 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
    • 이 제약은 상속보다 컴포지션을 사용하도록 유도하고 불변 타입으로 만들려면 이 제약을 지켜야 한다는 점에서 오히려 장점일 수 도 있다.
  2. 정적 팩터리 메서드는 프로그래머가 찾기 어렵다
    • 생성자처럼 API 설명에 명확히 드러나지 않으니 사용자는 정적 팩터리 메서드 방식 클래스를 인스턴스화할 방법을 알아내야 한다.

정적 팩터리 메서드에 흔히 사용하는 명명 방식

from

  • 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
Date d = Date.from(instance);

of

  • 여러 매겨변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);

valueOf

  • from과 of의 더 자세한 버전

instance / getInstance

  • (매개변수를 받는다면) 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다
StackWalker luke = StackWalker.getInstance(options);

create / newInstance

  • 위의 내용과 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다

get"Type"

  • getInstace와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다.
FileStore fs = Files.getFilsStore(path);

new"Type"

  • newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다.
BufferedReader br = Files.newBufferedReader(path);

type

  • getType과 newType의 간결한 버전
List<Complaint> litany = Collections.list(legacyLitany);

참고 출처

728x90
728x90

이번 주제 키워드

  • 디폴트 메서드란?
  • 진화되는 API가 호환성을 유지하는 방법
  • 디폴트 메서드 활용 패턴
  • 해결 규칙

인터페이스를 수정해야하는 경우

  • 바이너리 호환성은 유지되지만 소스 호환성은 유지되지 않아 해당 인터페이스를 구현한 모든 클래스를 수정해야한다.
    • 따라서 공개된 자바 API를 고치는 일은 굉장히 어려운 일이었다.
  • 자바 8에서는 이러한 문제를 해결하는 두 가지 방법을 제공한다.
    1. 인터페이스 내부에 정적 메서드를 사용하는 방법
    2. 인터페이스의 기본 구현을 제공할 수 있도록 디폴트 메서드 기능을 사용하는 방법
  • 즉, 자바8부터 메서드 구현을 인터페이스를 정의할 수 있어서 기존 인터페이스를 구현하는 클래스는 자동으로 인터페이스에 추가된 새로운 메서드의 디폴트 메서드를 상속받게 된다.
  • 따라서 기존의 코드 구현을 바꾸도록 강요하지 않으면서도 인터페이스를 바꿀 수 있다.

호환성(참고)

  • 바이너리 호환성
    • 뭔가를 바꾼 이후에도 에러 없이 기존 바이너리가 실행될 수 있는 상황.
    • ex) 인터페이스에 메서드를 추가했을 때 추가된 메서드를 호출하지만 않으면 문제가 일어나지 않는 경우
  • 소스 호환성
    • 코드를 고쳐도 기존 프로그램을 성공적으로 재컴파일할 수 있는 상황.
    • ex) 마찬가지로 인터페이스에 메서드를 추가하는 경우는 소스 호환성이 아니다.
  • 동작 호환성
    • 코드를 바꾼 다음에도 같은 입력값이 주어지면 프로그램이 같은 동작을 실행하는 상황.

디폴트 메서드

  • 자바 8에서는 호환성을 유지하면서 API를 바꿀 수 있도록 새로운 기능인 디폴트 메서드(default method)를 제공한다.
  • 이제 인터페이스는 자신을 구현하는 클래스에서 메서드를 구현하지 않을 수 있는 새로운 메서드 시그니처를 제공한다.
  • 인터페이스를 구현하는 클래스에서 구현하지 않은 메서드는 디폴트 메서드를 통해 인터페이스 자체에서 기본으로 제공한다.
  • 디폴트 메서드는 default라는 키워드로 시작하며 다른 클래스에 선언된 메서드처럼 메서드 바디를 포함한다.

이렇게 되면 이미 존재하는 추상 클래스와 자바 8의 인터페이스가 무엇이 다르냐고 물어볼 수 있다.

  1. 클래스는 하나의 추상 클래스만 상속받을 수 있지만 인터페이스를 여러 개 구현할 수 있다.
  2. 추상 클래스는 인스턴스 변수(필드)로 공통 상태를 가질 수 있다. 하지만 인터페이스는 인스턴스 변수를 가질 수 없다.

디폴트 메서드 활용 패턴

  • 디폴트 메서드를 이용하는 두 가지 방식은 선택형 메서드(optional method)와 동작 다중 상속(multiple inheritance of behavoir)이다.

선택형 메서드

  • 이전의 인터페이스를 구현하는 클래스는 사용하지 않는 메서드에 대해 비어있는 메서드까지 필수적으로 구현해주어야 했다.
  • 하지만 디폴트 메서드를 이용하면 메서드의 기본 구현을 인터페이스로부터 제공받기 때문에 빈 구현을 제공할 필요가 없다.
  • 이를 통해 불필요한 코드의 양을 줄일 수 있다.
  • Iterator 인터페이스의 remove 메서드
default void remove() {
    throw new UnsupportedOperationException("remove");
}

동작 다중 상속

  • 인터페이스는 한 클래스에서 여러 개 구현할 수 있으므로 디폴트 메서드가 없더라도 다중 상속 을 활용 할 수 있다.
  • 거기에 추가로 구현을 포함하는 디폴트 메서드를 통해 동작다중 상속 을 활용할 수 있다.
  • ex) Rotatable, Moveable, Resizable 인터페이스의 조합을 통해 게임에 필요한 다양한 클래스들 구현 가능
    • 디폴트 메서드를 활용하는 구조가 마치 템플릿 메서드 패턴과 비슷해 보인다.
    • 디폴트 메서드 덕분에 인터페이스의 직접 수정도 가능하며 이를 구현한 클래스들을 오버라이딩하지 않은 이상 자동으로 상속받으니 문제 없다.
public interface Rotatable {
    void setRotationAngle(int angleInDegrees);
    int getRotationAngle();
    default void rotateBy(int angleInDegrees) { //디폴트 메서드
        setRotationAngle((getRotationAngle() + angleInDegrees) % 360);
    }
}

고민해볼 상황

  • 만약 같은 시그니처의 디폴트 메서드를 포함하는 여러 인터페이스를 구현하는 상황이라면?
  • 어떤 인터페이스의 디폴트 메서드를 사용하는 것일까!!!

해석 규칙

  • 드물지만 같은 시그니처를 갖는 디폴트 메서드를 상속받는 상황이 생길 수 있다.
  • 다중 상속을 허용하는 언어의 다이아몬드 상속 문제와 같다.
  • 어떤 메서드를 실행할까?
public interface A {
    default void hello() {
        System.out.println("Hello From A");
    }
}

public interface B extends A {
    default void hello() {
        System.out.println("Hello From B");
    }
}

public class C implements B, A {
    public static void main(String args[]){
        new C().hello();
    }
}

세가지 규칙

  1. 클래스가 항상 이긴다. 클래스나 슈퍼클래스에서 정의한 메서드가 디폴트 메서드보다 우선권을 갖는다.
  2. 1번 규칙 이외의 상황에서는 서브인터페이스가 이긴다. 상속관계를 갖는 인터페이스에서 같은 시그니처를 갖는 메서드를 정의할 때는 서브인터페이스가 이긴다.
    • 즉 B가 A를 상속받는다면 B가 이긴다.
  3. 여전히 디폴트 메서드의 우선순위가 결정되지 않았다면 여러 인터페이스를 상속받는 클래스가 명시적으로 디폴트 메서드를 오버라이드하고 호출해야 한다.

충돌 그리고 명시적인 문제해결

  • 1,2 규칙으론 해결할 수 없는 경우
public interface A {
    default void hello() { ... }
}

public interface B {
    default void hello() { ... }
}

public class C implements B, A { }
  • A와 B 인터페이스 간의 상속관계도 없어 디폴트 메서드의 우선순위가 결정되지 않았다.
  • 따라서 자바 컴파일러는 어떤 메서드를 호출해야 할지 알수 없으므로 에러를 발생시킨다.
  • 충돌해결을 위해서는 아래처럼 개발자가 직접 클래스 C에서 사용하려는 메서드를 명시적으로 선택해야 한다.
public class C implements B, A {
    void hello() {
        B.super.hello();
    }
}

다이아몬드 문제

public interface A {
    default void hello() { ... }
}

public interface B extends A { }
public interface C extends A { }

public class D implements B, C { 
    public static void main(String... args) {
        new D().hello();
    }
}
  • 다이어그램의 모양이 다이아몬드를 닮아 다이아몬드 문제라 부른다.
  • D가 구현하는 B와 C 중 선택할 수 있는 메서드는 오직 A의 디폴트 메서드 뿐이다. D는 A의 hello를 호출한다.
  • 만약 B에 같은 디폴트 메서드 hello가 있었다면 가장 하위의 인터페이스인 B의 hello가 호출될 것이다.
  • B와 C가 모두 디폴트 메서드를 정의했다면 디폴트 메서드 우선순위로 인해 에러가 발생하고 명시적인 호출이 필요하게된다.

만약 C에서 디폴트 메서드가 아닌 추상메서드 hello를 추가하면 어떻게 될까?

public interface C extends A {
    void hello();
}
  • C는 A를 상속받으므로 C의 추상 메서드 hello가 A의 디폴트 메서드 hello보다 우선권을 갖는다.
  • 따라서 B와 C중 선택하지 못하며 컴파일에러가 발생하며 어떤 hello를 사용할지 명시적으로 선택해서 에러를 해결해야 한다.
728x90
728x90

자바 8 이전의 날짜와 시간 API의 문제들

  • Date 클래스는 직관적이지 못하며 자체적으로 시간대 정보를 알고있지 않다
  • Date클래스의 여러 메서드를 deprecated 시키고 등장한 Calendar 클래스 또한 쉽게 에러를 일으키는 설계 문제를 갖고 있다
  • Date와 Calendar 두 가지 클래스가 등장하면서 개발자들에게 혼란만 가중되었다.
  • 날짜와 시간을 파싱하는데 등장한 DateFormat은 Date에만 지원되었으며, 스레드에 안전하지 못했다.
  • Date와 Calendar는 모두 가변 클래스이므로 유지보수가 아주 어렵다.

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

  • java.time 패키지는 LocalDate, LocalTime, LocalDateTime, Instant, Duration, Period 등 새로운 날짜와 시간에 관련된 클래스를 제공한다.

LocalDate와 LocalTime

LocalDate 인스턴스는 시간을 제외한 날짜를 표현하는 불변 객체다.

  • LocalDate 객체는 어떤 시간대 정보도 포함하지 않는다.
  • 정적 팩토리 메서드 of으로 LocalDate 인스턴스를 만들 수 있다.
LocalDate date = LocalDate.of(2020, 12, 22); // of
int year = date.getYear();
Month month = date.getMonth();
int day = date.getDayOfMonth();
LocalDate now = LocalDate.now(); // 시스템 시계의 정보를 이용해서 현재 날짜 정보

LocalDate가 제공하는 get 메서드에 TemporalField를 전달해서 정보를 얻는 방법도 있다.

  • TemporalField는 시간 관련 객체에서 어떤 필드의 값에 접근할지 정의하는 인터페이스다.
  • ChronoField는 TemporalField의 구현체이며 ChronoField의 열거자 요소를 이용해서 원하는 정보를 쉽게 얻을 수 있다.
// public int get(TemporalField field)
int year = date.get(ChronoField.YEAR);

시간에 대한 정보는 LocalTime 클래스로 표현할 수 있다.

  • LocalTime도 정적 메서드 of로 인스턴스를 만들 수 있다.
LocalTime time = LocalTime.of(13, 45, 20); // 13:45:20
int hour = time.getHour();
int minute = time.getMinute();
int second = time.getSecond();
  • parse 메서드를 통해 날짜와 시간 문자열로 LocalDate와 LocalTime의 인스턴스를 만들 수 있다.
LocalDate date = LocalDate.parse("2020-12-22");
LocalTime time = LocalTime.parse("13:45:20");`

날짜와 시간 조합

LocalDateTime은 LocalDate와 LocalTime을 쌍으로 갖는 복합 클래스다.

  • 즉, 날짜와 시간을 모두 표현할 수 있으며 정적 메서드 of로 인스턴스 또한 만들 수 있다.
  • atTime 메서드에 시간을 제공하거나 atDate메서드에 날짜를 제공해서 LocalDateTime을 만드는 방법도 있다.
  • toLocalDate, toLocalTime 메서드로 LocalDate, LocalTime 인스턴스 추출 가능
LocalDateTime dateTime = LocalDateTime.of(2020, Month.DECEMBER, 22, 13, 45, 20);
LocalDateTime dateTime2 = LocalDateTime.of(date, time);

LocalDateTime dateTime2 = date.atTime(13, 45, 20);
LocalDateTime dateTime2 = time.atDate(date);

LocalDate date = dateTime.toLocalDate();
LocalTime time = dateTime2.toLocalTime();

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

  • java.time.Instant 클래스에서는 기계적인 관점에서 시간을 표현한다.
  • Instant 클래스는 유닉스 에포크 시간(Unix epoch time) (1970년 1월 1일 0시 0분 0초 UTC)을 기준으로 특정 지점까지의 시간을 초로 표현한다.
  • 팩토리 메서드 ofEpochSecond에 초를 넘겨주어 인스턴스를 생성할 수 있다.
  • Instant 클래스는 나노초의 정밀도를 제공하며 오버로드된 ofEpochSecond 메서드 버전에서는 두 번째 인수를 이용해서 나노초 단위로 시간을 보정할 수 있다.
  • Instant 클래스도 사람이 확인할 수 있도록 시간을 표현해주는 정적 팩토리 메서드 now를 제공한다. 하지만 사람이 읽을 수 있는 시간정보는 제공하지 않는다.

Duration과 Period 정의

  • 지금까지 살펴본 모든 클래스는 Temporal 인터페이스를 구현하는데, Temporal 인터페이스는 특정 시간을 모델링하는 객체의 값을 어떻게 읽고 조작할지 정의한다.
  • Duration 클래스를 사용하면 두 시간 객체 사이의 지속시간을 만들 수 있다.
    • Duration.between(Temporal startInclusive, Temporal endExclusive : 정적 팩토리 메서드를 사용하면 두 시간 객체 사이의 지속시간을 만들 수 있다.
    • Duration 클래스는 초와 나노초로 시간 단위를 표현함으로 between 메서드에 LocalDate를 전달할 수 없다.
  • 년, 월, 일로 시간을 표현할 때는 Period 클래스를 사용하자.
    • Period 클래스의 팩토리 메서드 between(LocalDate startDateInclusive, LocalDate endDateExclusive 을 이용하면 두 LocalDate의 차이를 확인할 수 있다.

지금까지 살펴본 모든 클래스는 불변이다. 함수형 프로그래밍, 스레드 안정성과 도메인 모델의 일관성을 유지하는데 좋다.


날짜 조정, 파싱, 포매팅

  • 하지만 날짜나 시간 인스턴스에 시간을 더해야 하는 상황이나 시간 포맷터를 만드는 방법이 필요할 수 있다.
  • withAttribute 메서드를 사용하면 일부 속성이 수정된 상태의 새로운 객체를 반환받을 수 있다.
    • 기존 객체를 바꾸지 않는다.
LocalDate date1 = LocalDate.of(2017, 9, 21);
LocalDate date2 = date1.withYear(2011); // 2011-09-21
LocalDate date2 = date2.withDayOfMonth(25); // 2011-09-25
LocalDate date2 = date3.with(ChronoField.MONTH_OF_YEAR, 2); // 2011-02-25
  • 상재적인 방식으로 바꾸기도 가능하다.
LocalDate date1 = LocalDate.of(2017, 9, 21);
LocalDate date2 = date1.plusWeeks(1); // 2017-09-28
LocalDate date2 = date2.minusYear(6); // 2011-09-28
LocalDate date2 = date3.plus(6, ChronoUnit.MONTHS); // 2012-03-28

TemporalAdjusters 사용하기

  • 간단한 날짜 기능이 아닌 더 복잡한 날짜 조정기능이 필요할 때 with 메서드에 TemporalAdjuster를 전달하는 방법으로 문제를 해결할 수 있다.
  • 날짜와 시간 API는 다양한 상황에서 사용할 수 있도록 다양한 TemporalAdjuste 팩토리 메서드를 제공한다.
  • 필요한 기능이 존재하지 않으면 커스텀 TemporalAdjuster 를 구현하여 사용할 수 있다.
LocalDate date1 = LocalDate.of(2021, 9, 6); // (월)
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); // 2021-09-12

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

  • 날짜와 시간 관련 작업에서 포매팅과 파싱은 필수적이다. java.time.format 패키지가 이를 지원한다.
  • 가장 중요하게 알아야 할 클래스는 DateTimeFormatter이다.
  • 정적 팩토리 메서드와 상수를 이용해서 손쉽게 포매터를 만들 수 있다.
LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); // 20140318
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2014-03-18
LocalDate date1 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date1 = LocalDate.parse("2014-03-18", DateTimeFormatter.ISO_LOCAL_DATE);
  • 기존 java.util.DateFormat 클래스와 달리 모든 DateTileFormatter는 스레드에서 안전하게 사용할 수 있는 클래스이다.
  • 특정 패턴으로 포매터를 만들 수 있는 정적 팩토리 메서드도 제공한다.

DateTimeFormatter formatter = DateTimeFormatter.ofPatterm("dd/MM/yyyy");
LocalDate date = LocalDate.of(2014, 3, 18);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);
  • LocalDate의 format 메서드는 요청 형식의 패턴에 해당하는 문자열을 생성한다.
  • 그리고 정적 메서드 parse는 같은 포매터를 적용해서 생성된 문자열을 파싱함으로써 다시 날짜를 생성한다.
  • 또한, DateTimeFormatterBuilder 클래스를 이용하면 원하는 포매터를 직접 만들 수 있다.

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

  • 새로운 날짜와 시간 API의 큰 편리함 중 하나는 시간대(timezone)를 간단하게 처리할 수 있다는 점이다.
  • 기존의 java.util.TimeZone을 대체할 수 있는 java.time.ZoneId 클래스가 새롭게 등장했다.
  • ZoneId를 이용하면 서머타임 같은 복잡한 사항이 자동으로 처리된다.
  • 또한 ZoneId는 불변 클래스다.

시간대 사용하기

  • 표준이 같은 지역을 묶어서 시간대(time zone) 규칙 집합을 정의한다.
  • ZoneRules 클래스에는 약 40개 정도의 시간대가 있다.
  • ZoneId의 getRules()를 이용해서 해당 시간대의 규정을 획득할 수 있다.
ZoneId romeZone = ZoneId.of("Europe/Rome");
  • 지역 ID는 '{지역}/{도시}' 형식으로 이루어 진다.
  • 지역집합 정보는 IANA Time Zone Database에서 제공하는 정보를 사용한다.
  • getDefault() 메서드를 이용하면 기존의 TimeZone 객체를 ZoneId 객체로 변환할 수 있다.
ZoneId zoneId = TimeZone.getDefault().toZoneId();
  • ZoneId는 LocalDate, LocalTime, LocalDateTime과 같이 ZonedDateTime 인스턴스로 변환할 수 있다.
  • ZonedDateTime은 지정한 시간대에 상대적인 시점을 표현한다.
    • 즉, ZonedDateTime = LocalDateTime + 타임존/시차
LocalDate date = LocalDate.of(2014, 13, 18);
ZonedDateTime zdt = date.atStartOfDay(romeZone);`


(추가)ZoneId vs ZoneOffset

  • ZoneId은 타임존, ZoneOffset은 시차를 나타냅니다.
  • ZoneOffset는 UTC 기준으로 고정된 시간 차이를 양수나 음수로 나타내는 반면에 ZoneId는 이 시간 차이를 타임존 코드로 나타냅니다
ZoneOffset seoulZoneOffset = ZoneOffset.of("+09:00");
System.out.println("+0900 Time = " + ZonedDateTime.now(seoulZoneOffset));
ZoneId seoulZoneId = ZoneId.of("Asia/Seoul");
System.out.println("Seoul Time = " + ZonedDateTime.now(seoulZoneId));

참고 출처

728x90
728x90

이번 주제 키워드

  • null 참조의 문제점과 null을 멀리해야 하는 이유
  • null 대신 Optional
  • Optional 활용

값이 없는 상황을 어떻게 처리할까?

  • Null 참조를 반환하는 방식으로 소유하고 있지 않음을 표현하는 경우가 있다.
  • 이러한 경우는 런타임 NPE가 발생하면서 프로그램 실행이 중단된다.
  • 이러한 NPE를 피하려면 null 확인 코드를 추가해서 예외 문제를 해결하려고 할 것이고 이것은 다양한 문제를 일으킨다!!

null 때문에 발생하는 문제

  • 에러의 근원이다

  • 코드를 어지럽힌다.

  • 아무 의미가 없다.

    • null은 아무 의미도 표현하지 않으므로 값이 없음을 표현하는 방법으로는 적절하지 않다.
  • 자바 철학에 위배된다.

    • 자바는 모든 포인터를 숨겼지만 null 포인터는 예외
  • 형식 시스템에 구멍을 만든다.

    • 모든 참조 형식에 null이 할당이 가능하므로 다른 부분으로 펴졌을 때 이 null의 의미조차 알 수 없다.


Optional 클래스

  • 자바8은 하스켈가 스칼라의 영향을 받아서 java.util.Optional라는 새로운 클래스 제공한다.
  • Optional은 선택형 값을 캡슐화하는 클래스이다.
  • 이는 값이 없을 수 있음을 명시적으로 보여주는 것이다.
  • Optional 클래스를 사용하면서 모델의 의미가 더 명확해졌다
  • Optional을 최대 1개의 원소를 가지고 있는 특별한 Stream이라고 생각하시면 좋다.
    • Optional 클래스와 Stream 클래스 간에 직접적인 구현이나 상속관계는 없지만 사용 방법이나 기본 사상이 매우 유사하기 때문

출처


Optional 객체 만들기


빈 Optional

  • 정적 팩토리 메서드 Optional.empty로 빈 Optional 객체를 얻을 수 있다.
Optional<Car> optCar = Optional.empty();

private static final Optional<?> EMPTY = new Optional<>();

null이 아닌 값으로 Optional 만들기

  • 정적 팩토리 메서드 Optional.of로 null이 아닌 값을 포함하는 Optional을 만들 수 있다.
  • 인수로 넣는 값이 null이라면 즉시 NPE가 발생한다.
Optional<Car> optCar = Optional.of(car);

null 값으로 Optional 만들기

  • 마찬가지로 정적 팩토리 메서드 Optional.ofNullable로 null 값을 저장할 수 있는 Optional을 만들 수 있다.
  • 인수로 넣는 값이 null이면 빈 Optional 객체가 반환된다.
Optional<Car> optCar = Optional.ofNullable(car);

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

  • 보통 객체의 정보를 추출할 때는 Optional을 사용할 때가 많다.
String name = null;
if(insurance != null){
    name = insurance.getName();
}
  • 예를 들어 보험회사의 이름을 추출한다고 가정하자. 다음 코드처럼 이름 정보에 접근하기 전에 insurance가 null인지 확인해야 한다.
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
  • 이런 유형의 패턴에 사용할 수 있도록 Optional은 map 메서드를 지원한다.
  • Optional의 map은 스트림의 map 메서드와 개념적으로 비슷하다.
  • Optional이 값을 포함하면 map의 인수로 제공된 함수가 값을 바꾼다.
  • 비어 있으면 아무 일도 일어나지 않는다.

flatMap으로 Optional 객체 연결

public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}
  • 이런 코드는 어떻게 활용할 수 있을까??

Optional<Person> optPerson = Optional.of(Person);
Optional<String> name = optPerson.map(Person::getCar).map(Car::getInsurance).map(Insurance::getName);
  • 위의 코드는 컴파일 되지 않는다.
  • optPerson.map(Person::getCar)
    • getCar은 Optional를 반환
    • 따라서 위의 코드의 반환값은Optional<Optional> 타입이라 getInsurance 메서드를 지원하지 않기 때문이다.
    • 스트림처럼 flatMap 활용, 이차원 Optional을 일차원 Optional로 평준화
public String getCarInsuranceName(Optional<Person> person) {
    return person.flatMap(Person::getCar).flatMap(Car::getInsurance).map(Insurance::getName).orElse("Unknown");
}

도메인 모델에 Optional을 사용했을 때 데이터를 직렬화할 수 없는 이유

  • Optional 클래스는 필드 형식으로 사용할 것을 가정하지 않았으므로 Serializable 인터페이스를 구현하지 않았다.
  • 따라서 도메인 모델에 Optional을 사용한다면 직렬화 모델을 사용하는 도구나 프레임워크에서 문제가 생길 수 있다.
  • 만약 직렬화 모델이 필요하다면 변수는 일반 객체로 두되, Optional로 값을 반환받을 수 있는 메서드를 추가하는 방식이 권장된다.
public class Person {
    private Car car;
    public Optional<Car> getCarAsOptional() {
        return Optional.ofNullable(car);
    }
}

Optional 스트림 조작

  • 자바 9에서는 Optional을 포함하는 스트림을 쉽게 처리할 수 있도록 Optional에 stream()메서드를 추가했다.
  • Stream에서 가장 유용한 함수 체인의 형태는 아래와 같다.
Stream<Optional<String>> stream = ...;
Set<String> result = stream.filter(Optional::isPresent)
    .map(Optional::get)
    .collect(toSet());

디폴트 액션과 Optional 언랩

get

  • 값을 읽는 가장 간단한 메서드면서 동시에 가장 안전하지 않은 메서드이다.
  • 값이 없으면 NoSuchElementException을 발생시키므로 값이 반드시 있다고 가정할 수 있는 상황이 아니면 get 메서드를 사용하지 말자

orElse

  • Optional이 값을 포함하지 않을 때 기본값을 제공할 수 있다.

orElseGet(Supplier<? extends T> other)

  • orElse 메서드에 대응하는 게으른 버전의 메서드이다. Optional에 값이 없을 때만 Supplier가 실행된다.
  • 기본값이 필요한 상황에서 사용

orElseThrow(Supplier<? extends X> exceptionSupplier)

  • Optional이 비어있을 때 예외를 발생시킬 수 있으며, 발생시킬 예외의 종류를 정할 수 있다.

ifPresent(Consumer<? super T> consumer)

  • 값이 존재할 때 인수로 넘겨준 동작을 실행할 수 있다.
  • 값이 없으면 아무일도 일어나지 않는다.

ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)

  • 자바 9
  • Optional이 비었을 때 실행할 Runnable을 인수로 받는다

두 Optional 합치기

  • Optional에서 mapflatMap은 Optional이 비어있다면 empty Optional을 반환한다.
  • 두 Optional에 대한 연산을 map과 flatMap을 적절히 활용하여 수행할 수 있다.

필터로 특정값 거르기

  • filter 메서드는 프레디케이트를 인수로 받는다.
  • Optional 객체가 값을 가지며 프레디케이트와 일치하면 filter 메서드는 그 값을 반환
  • 그렇지 않으면 빈 Optional 객체 반환

Optional을 사용한 실용 예제


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

Object value = map.get("key");
Optional<Object> value = Optional.ofNullable(map.get("key"));
  • 참조하는 객체에 대하여 null이 될 수 있는 경우가 있다면 Optional 객체로 대체한다.

예외와 Optional 클래스

  • 자바 API에서 값을 제공할 수 없을 때 null을 반환하는 대신 예외를 발생시킬 때가 있다.
  • 해당 메서드가 Optional을 반환하도록 모델링할 수 있다.
public static Optional<Integer> stringToInt(String s) {
  try {
    return Optional.of(Integer.parseInt(s));
  } catch(NumberFormatException e) {
    return Optional.empty();
  } 
}

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

  • Optional과 함께 기본형 특화 클래스인 OptionalInt, OptionalLong, OptionalDouble이 존재한다.
  • 하지만 Optional의 최대 요소 수는 한 개이므로 성능개선이 되지 않는다.
  • 또한 map, flatMap, filter 등을 지원하지 않으르로 다른 일반 Optional과 혼용할 수 없으므로 기본현 Optional을 사용하지 않는것을 권장한다.

추가로 생각해볼 부분

728x90
728x90

이번 주제 키워드

  • 람다 표현식으로 코드 리팩터링 하기
  • 람다 표현식이 객체지향 설계 패턴에 미치는 영향
  • 람다 표현식 테스팅
  • 람다 표현식과 스트림 API 사용 코드 디버깅

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

  • 람다 표현식은 익명 클래스보다 코드를 좀 더 간결하게 만든다.
  • 그뿐만 아니라 동작 파라미터화의 형식을 지원하므로 람다 표현식을 이용한 코드는 더 큰 유연성을 갖출 수 있다.
  • 람도 표현식을 이용하여 코드를 리택터링 해보자!!

코드 가독성 개선

일반적으로 코드 가독성이 좋다는 것은 '어떤 코드를 다른 사람이 보았을 때 쉽게 이해할 수 있음'을 의미한다.

여러가지 방법들이 있지만 세 가지 리팩터링 예제를 알아보자

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

    • 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.
    • 뒤에서 확인
  2. 람다 표현식을 메서드 참조로 리팩터링하기

    • 메서드 참조의 메서드명으로 코드의 의도를 명확하게 알릴 수 있다.
  3. 명령형 데이터 처리를 스트림으로 리팩터링하기

    • 스트림 API는 데이터 처리 파이프라인의 의도를 더 명확하게 보여준다.


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

Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("hello");
    }
};

Runnable r2 = () -> System.out.println("hello");
  • 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.
  • 익명 클래스에서 this는 익명 클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.
  • 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다.
    • shadow variable
int a = 10;
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        int a = 2;
        System.out.println(a);
    }
};

Runnable r2 = () -> {
    int a = 2; // error
    System.out.println(a);
};
  • 익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라진다.

interface Task {
    void execute();
}
public static void doSomething(Runnable r) { r.run(); }
public static void doSomething(Task t) { t.execute(); }
  • 익명 클래스와 달리 람다 표현식으로 인수를 전달하면 Runnable과 Task 모두 대상 형식이 될 수 있으므로 문제가 생긴다.
  • 명시적 형변환을 이용해서 제거할 수 있다.

코드 유연성 개선

  • 람다 표현식을 이용하면 동작 파라미터화(behavior parameterzation)을 쉽게 구현할 수있다.
  • 따라서 변화하는 요구사항에 대응할 수 있는 코드 구현 가능

함수형 인터페이스 적용

  • 람다 표현식을 사용하기 위해 함수형 인터페이스 적용해야 한다.

    • 조건부 연기 실행실행 어라운드 패턴을 살펴보자


조건부 연기 실행

  • 코드 내부에 제어 흐름문이 복잡하게 얽힌 코드를 볼 수 있다.
  • 만약 클라이언트 코드에서 객체 상태를 자주 확인하거나,객체의 일부 메서드를 호출하는 상황이라면 내부적으로 객체의 상태를 확인한 다음에 메서드를 호출(람다나 메서드 참조를 인수로 사용)하도록 새로운 메서드를 구현하는 것이 좋음
  • 코드 가독성이 좋아질 뿐아니라 캡슐화도 강화됨 (객체 상태가 클라이언트 코드로 노출되지 않음)

실행 어라운드

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

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

디자인 패턴에 람다 표현식이 더해지면 색다른 기능을 발휘할 수 있다.

전략 (Strategy)

  • 전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다.
  • 전략을 구현하는 새로운 클래스를 람다 표현식을 통해 직접 전달할 수 있다.

기존 패턴

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

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

public class isNumeric implements ValidationStrategy {
    @Override
    public boolean execute(String s) {
        return s.matches("\d+");
    }
}

public class Validator {
    private final ValidationStrategy validationStrategy;

    public Validator(ValidationStrategy validationStrategy) {
        this.validationStrategy = validationStrategy;
    }

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

Validator numericValidator = new Validator(new isNumeric());
numericValidator.validate("aaa");

Validator lowerCaseValidator = new Validator(new IsAllLowerCase());
lowerCaseValidator.validate("bbbb");

람다 사용

Validator lowerCaseValidator2 = new Validator((String s) -> s.matches("[a-z]+"));
lowerCaseValidator2.validate("bbbb");

Validator numericValidator2 = new Validator((String s) -> s.matches("\d+"));
numericValidator2.validate("1234");

템플릿 메서드 (template method)

  • 알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용한다.
  • 추상 메서드로 원하는 동작을 구현하는 곳을 람다 표현식을 통해 전달할 수 있다.

기존

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

  abstract void makeCustomerHappay(Customer c);
}

람다 사용

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

new OnlineBankingLambda().processCustomer(1337, (Customer c) -> print("hello" + c.getName()));
  • 이전에 정의한 makeCustomerHappy의 메서드 시그니처와 일치하도록 Consumer 형식을 갖는 두 번째 인수를 메서드에 추가


옵저버 (observer)

  • 어떤 이벤트가 발생했을 때 한 객체(subject)가 다른 객체 리스트(observer)에 자동으로 알림을 보내야 하는 상황에서 사용하는 패턴이다.
  • 자세히 보고 싶다면? 여기


기존

interface Observer {
  void notify(String tweet);
}

public class NyTimes implements NotiObserver {
    @Override
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("monety")) {
            System.out.println("Breaking news in NY ! " + tweet);
        }
    }
}

public class Guardian implements NotiObserver {
    @Override
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("queen")) {
            System.out.println("Yet more new from London .. " + tweet);
        }
    }
}

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

public interface NotiSubject {
    void registerObserver(NotiObserver o);
    void notifyObservers(String tweet);
}

public class Feed implements NotiSubject {
    private final List<NotiObserver> observers = new ArrayList<>();
    @Override
    public void registerObserver(NotiObserver o) {
        observers.add(o);
    }

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

Feed f = new Feed();
f.registerObserver(new NyTimes());
f.registerObserver(new LeMonde());
f.registerObserver(new Guardian());
f.notifyObservers("The Queen ...")
  • Observer 인터페이스는 새로운 트윗이 있을 때 subject가 호출할 수 있도록 notify라고 하는 하나의 메서드를 제공한다.
  • Observer 인테페이스를 구현하는 클래스를 만드는 대신 람다 표현식을 직접 전달해서 실행할 동작을 지정할 수 있다.

람다

Feed feed = new Feed();
feed.registerObserver((String tweet) -> {
    if(tweet != null && tweet.contains("money")) {
        System.out.println("Breaking news in NY ! " + tweet);
    }
});

feed.registerObserver((String tweet) -> {
    if(tweet != null && tweet.contains("queen")) {
        System.out.println("Yet more new from London .." + tweet);
    }
});
  • 하지만 옵저버상태를 가지며, 여러 메서드를 정의하는 등 복잡하다면 람다 표현식보다 기존의 클래스 구현방식을 고수하는 것이 바람직할 수 있다.

의무 체인 (chain-of-responsibility)

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

*작업 처리 객체 예제 코드
*

public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor;
    public void setSuccessor(ProcessingObject<T> successor) {
        this.successor = successor;
    }

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

    abstract protected T handleWork(T input);
}
  • UML를 자세히 살펴보면 템플릿 메서드 패턴이 사용되었음을 알 수 있다.
  • handle 메서드는 일부 작업을 어떻게 처리할지 전체적으로 서술한다.
  • ProcessingObject 클래스를 상속받아 handleWork 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들수 있다.

기존

public class HandleTextProcessing extends ProcessingObject<String> {
    @Override
    protected String hadleWork(String input) {
        return "From Raoul, Mario and Alan : " + input;
    }
}

public class SpellCheckProcessing extends ProcessingObject<String> {
    @Override
    protected String hadleWork(String input) {
        return input.replaceAll("labda", "lambda");
    }
}

ProcessingObject<String> p1 = new HandleTextProcessing();
ProcessingObject<String> p2 = new SpellCheckProcessing();
p1.setSuccessor(p2);
p1.handle("Aren't ladbas really sexy?");

람다

UnaryOperator<String> headerProcessing = (String text) -> "From Raoul, Mario and Alan : " + text;
UnaryOperator<String> spellCheckProcessing = (String text) -> text.replaceAll("labda", "lambda");
Function<String, String> pipeline = headerProcessing.andThen(spellCheckProcessing);
pipeline.apply("Aren't ladbas really sexy?");
  • 이러한 패턴은 함수 체인과 비슷하다.
  • 람다 표현식을 조합하는 방식으로는 기본적으로 compose, andThen이 있다.
  • andThen메서드로 이들 함수를 조합해 체인을 만들어 보자.

팩토리 (factory)

  • 인스턴스화 로직을 클라이언트에게 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다.

기존

public class ProductFactory {
    public static Product createProduct(String name) {
        switch (name) {
            case "loan" : return new Loan();
            case "stock" : return new Stock();
            case "bond" : return new Bond();
            default: throw new RuntimeException("...");
        }
    }
}

Product p = ProductFactory.createProduct("loan");
  • createProduct 메서드는 생산된 상품을 설정하는 로직을 포함할 수 도 있지만
  • 주요 목적은 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 create 할 수 있다는 점

람다

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);
}

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);
  ...
}
  • 하지만 팩토리 메서드 createProduct가 상품 생성자로 여러 인수로 전달하는 상황에서는 인수 개수에 맞게 특별한 함수형 인터페이스를 만들어야 한다.
  • 그러면 Map 시그니처가 복잡해진다.

람다 테스팅

  • 람다에 대해서도 단위 테스팅(unit testing)이 작성되어야만 한다.
  • 하지만 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다.

보이는 람다 표현식의 동작 테스팅

  • 람다의 동작을 테스트 하기 위해 람다를 필드에 저장해서 테스트할 수 있다.

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

  • 람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록 하나의 조각으로 캡슐화 하는것이다.
  • 람다 표현식을 사용하는 메서드의 동작을 테스트 함으로서 람다 표현식을 검증 할 수 있다.

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

  • 복잡한 로직이 포함된 람다를 구현하게 된다면 로직을 분리 하거나 메서드 레퍼런스를 활용하도록 하자.
  • 그러면 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있다.

고차원 함수 테스팅

고차원 함수란 함수를 인수로 받거나 다른 함수를 반환하는 메서드 이다.

  • 메서드가 람다를 인수로 받는다면 다른 람다로 메서드의 동작을 테스트 할 수 있다.
  • 테스트해야하는 함수가 다른 함수를 반환한다면 함수형 인터페이스의 인스턴스로 간주하고 테스트 할 수 있다.

디버깅

람다 표현식과 스트림은 기존의 디버깅 기법을 무력화한다. 디버깅 방법을 살펴보자!!

스택 트레이스 확인

  • 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다.
  • 그렇기에 람다 표현식과 관련된 스택 트레이스는 이해하기 어려울 수 있다.
  • 이는 미래의 자바 컴파일러가 개선해야 할 부분이다.

정보 로깅

  • forEach를 통해 스트림 결과를 출력하거나 로깅할 수 있다. 하지만 forEach는 스트림을 소비하는 연산이다.
  • 스트림 파이프라인에 적용된 각각의 연산의 결과를 확인할 수 있다면 대신 peek라는 스트림 연산을 활용할 수 있다.
  • peek는 스트림의 각 요소를 소비한것 처럼 동작을 실행하지만, 실제로 스트림을 소비하지않고 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.

참고출처

728x90
728x90

이번 주제 키워드

  • 컬렉션 팩토리 메서드 사용하기
  • 리스트 및 집합과 사용할 새로운 관용 패턴
  • 맵과 사용할 새로운 관용 패턴

컬렉션 팩토리

자바 9 에서는 작은 컬렉션 객체를 쉽게 만들 수 있는 몇 가지 방법을 제공한다.

자바에서는 적은 요소를 포함하는 리스트를 어떻게 만들까?

List<String> friends = Arrays.asList("Raphael", "Olivia", "Thibaut");
  • 고정 크기의 리스트를 만들었으므로 요소를 갱신할 순 있지만 새 요소를 추가하거나 삭제할 수는 없다.
friends.set(0, "Richard"); // 문제 없음
friends.add("Tom");        //UnsupportedOperationException 발생
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

private static class ArrayList<E> extends AbstractList<E>
    implements RandomAccess, java.io.Serializable
{
    private final E[] a;

    ArrayList(E[] array) {
        a = Objects.requireNonNull(array);
    }
 ...
}
  • Arrays.asList()는 Arrays의 private 정적 클래스인 ArrayList를 리턴한다.
    • java.util.ArrayList 클래스와는 다른 클래스이다.
  • Arrays.asList는 내부적으로 고정된 크기의 배열로 구현되었기 때문에 이와 같은 일이 발생

그렇다면 set은??

Set<String> elems1 = new HashSet<>(Arrays.asList("e1","e2","e3"));
Set<String> elems2 = Stream.of("e1","e2","e3").collect(toSet());
  • 집합의 경우 리스트를 인수로 받는 HashSet 생성자를 사용하거나 스트림 API를 사용하는 방법이 존재했다.
  • 두 방법 모두 매끄럽지 못하며 내부적으로 불필요한 객체 할당을 필요로 한다.
  • 그리고 결과는 변환할 수 있는 집합이다.

자바 9에서 제공되는 팩토리 메서드

List.of

  • 변경할 수 없는 불변 리스트를 만든다.

Set.of

  • 변경할 수 없는 불변 집합을 만든다.
  • 중복된 요소를 제공해 집합 생성 시 IllegalArgumentException이 발생한다.

Map.of

  • 키와 값을 번갈아 제공하는 방법으로 맵을 만들 수 있다.

Map.ofEntries

  • Map.Entry<K, V> 객체를 인수로 받아 맵을 만들 수 있다.
  • 엔트리 생성은 Map.entry 팩터리 메서드를 이용해서 전달하자.

리스트 팩토리

List.of 팩토리 메소드를 이용해서 간단하게 리스트를 만들 수 있다.

List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
  • Arrays.asList 방법과 다르게 List.of 는 추가, 삭제뿐만 아니라 변경(set)도 할 수 없고 null 추가가 불가능한
    리스트로 만들어진다.

스트림 API vs 리스트 팩토리

데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면 사용하기 간편한 팩토리 메서드를 사용하면 된다 !

  • 구현이 더 단순하고 목적을 달성하는데 충분하기 때문

집합 팩토리

// OK
Set<String> friends = Set.of("Raphael", "Olivia", "Thibaut");

// 요소가 중복되어 있다는 IllegalArgumentException 발생
Set<String> friends = Set.of("Raphael", "Olivia", "Olivia");
  • List.of 와 비슷한 방법으로 바꿀 수 없는 집합을 만들 수 있다.

맵 팩토리

자바 9 에서는 두 가지 방법으로 바꿀 수 없는 맵을 만들 수 있다.

  1. Map.of 팩토리 메서드에 키와 값을 번갈아 제공하는 방법
Map<String, Integer> ageOfFriends = 
                Map.of("Raphael", 30, "Olivia", 25, "Thibaut", 26);
  • 열개 이하의 키와 값 쌍을 가진 작은 맵을 만들 경우 (오버로딩으로 10개까지 지원해둔 것)
  1. Map.Entry<K,V> 객체를 인자로 받으며 가변 인수로 구현된 Map.ofEntries 이용
import static java.util.Map.entry;

Map<String, Integer> ageOfFriends = Map.ofEntries(
        entry("Raphael", 30), 
        entry("Olivia", 25),
        entry("Thibaut", 26));
  • 10개 이상의 경우 사용하면 좋다.
  • Map.entry는 Map.Entry 객체를 만드는 팩토리 메서드

리스트와 집합 처리

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

  • removeIf
    • 프레디케이트를 만족하는 요소를 제거한다.
  • replaceAll
    • UnaryOperator 함수를 이용해 요소를 바꾼다.
    • UnaryOperator: Function(T, T), T → T
  • sort
    • List 인터페이스에서 제공하는 기능으로 리스트를 정렬한다.

그런데 이들 메서드는 호출한 컬렉션 자체를 바꾼다.

  • 새로운 결과를 만드는 스트림 동작과 달리 이들 메서드는 기존 컬렉션을 바꾼다.
  • 왜 이런 메서드가 추가 되었을까?

컬렉션을 바꾸는 동작은 에러를 유발하며 복잡함을 더하기 때문이다!!

  • 삭제 시에는 IteratorCollection 의 상태를 동기화 시켜주어야 하기 때문이다.


removeIf 메서드

// ConcurrentModificationException 발생
for (Transaction transaction : transactions){
    if(Charater.isDigit(transaction.getReferenceCode().charAt(0))){
        transactions.remove(transaction);
    }
}

// for-each 내부적으로 Iterator 객체를 사용하므로 아래와 동일
for(Iterator<Transaction> iterator = transactions.iterator();
            iterator.hasNext(); ){
    Transaction transaction = iterator.next();
    if(Charater.isDigit(transaction.getReferenceCode().charAt(0))){
            // 반복하면서 별도의 두 객체를 통해 컬렉션을 바꾸고 있음
            transactions.remove(transaction);
    }
}
  • 다음은 숫자로 시작되는 참조 코드를 가진 트랜잭션을 삭제하는 코드
  • Iterator 객체 : next(), hastNext()를 이용해 소스를 질의한다.
  • Collection 객체 자체 : remove()를 호출해 요소를 삭제한다.
  • 반복자의 상태는 컬렉션의 상태와 서로 동기화 되지 않기 때문에 에러 발생
    • 즉 반복자에서도 요소를 조작하고 컬렉션에서도 요소를 조작하기 때문에 ConcurrentModificationException 발생
    • transactions.remove(transaction) 대신iterator.remove() 사용
    • 하지만 코드가 복잡해졌다.
  • 이유를 코드로 자세히 살펴보자

java.util.ArrayList의 remove()

    protected transient int modCount = 0;

    public boolean remove(Object o) {
        final Object[] es = elementData;
        final int size = this.size;
        int i = 0;
        found: {
            if (o == null) {
                for (; i < size; i++)
                    if (es[i] == null)
                        break found;
            } else {
                for (; i < size; i++)
                    if (o.equals(es[i]))
                        break found;
            }
            return false;
        }
        fastRemove(es, i);
        return true;
    }

    private void fastRemove(Object[] es, int i) {
        modCount++;
        final int newSize;
        if ((newSize = size - 1) > i)
            System.arraycopy(es, i + 1, es, i, newSize - i);
        es[size = newSize] = null;
    }
  • 살펴보면 remove시에 modCount를 증가를 시키고, System.arraycopy를 통해 remove할 데이터가 위치한 곳에 index+1부터 마지막까지 남은 데이터를 copy하고 해당 List의 맨 끝부분의 데이터를 null로 바꾸게 된다.
  • 결국 여기서 데이터의 조작은 이미 발생한 것이다. 그리고 생각해야 되는 부분이 클래스 변수인 modCount이다.
  • 이 변수는 처음에 iterator가 생성될 때 다른 클래스 변수인 expectedModCount 와 같은 값으로 동기를 하게 되어 있다

java.util.ArrayList의 이너 클래스 Itr


    public Iterator<E> iterator() {
            return new Itr();
        }

    private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        // prevent creating a synthetic constructor
        Itr() {}

        @SuppressWarnings("unchecked")
        public E next() {
            checkForComodification();
            ...
        }

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }
  • 이 2개의 클래스 변수로 리스트의 데이터 변경 여부를 체크하게 되는 것이다
  • modCount 를 증가시키고 element가 제거되고 난 뒤 iterator에서 next() 메소드로 다음 element를 가져오려고 시도하는 순간 ConcurrentModificationException이 발생하는 것을 알 수 있다.

위의 단점을 removeIf 로 해결 가능하다.

transactions.removeIf(
    transaction -> Charater.isDigit(transaction.getReferenceCode().charAt(0))
);
  • 단순해 질 뿐 아니라 버그도 예방 가능!!
  • 삭제할 요소를 가리키는 프레디케이트를 인수로 받는다.

replaceAll 메서드

때로는 요소를 제거하는 것이 아닌 변경해야 할 상황이 있다.

스트림 API 를 사용하면 되지만 새 컬렉션을 만들기에 기존 컬렉션을를 바꾸고 싶은 경우 부적합

이때는 replaceAll 을 사용하여 데이터를 변경 가능 !

// 첫 단어만 대문자로 바꾸는 코드
referenceCodes.replaceAll(
    code -> Charater.toUpperCase(code.charAt(0)) + code.subString(1)
);

맵 처리

forEach 메서드

맵을 조회하기 위한 기존의 반복 코드

for(Map.Entry<String, Integer> entry : ageOfFriends.entrySet()){
    String friend = entry.getKey();
    Integer age = entry.getValue();
    System.out.println(friend + " is " + age + " years old");
}

forEach 를 사용한 코드

ageOfFriends.forEach(
    (friend, age) -> System.out.println(friend + " is " + age + " years old")
);
  • forEach 메서드는 BiConsumer(키와 값을 인수로 받음)를 인수로 받는다.

정렬 메서드

다음 두 개의 새로운 메서드를 이용하면 맵을 키 또는 값을 기준으로 정렬 가능

  • Entry.comparingByValue
  • Entry.comparingByKey
Map<String, String> favoriteMovies = Map.ofEntries(
        Map.entry("ljo", "Star Wars"),
        Map.entry("hsy", "Matrix"),
        Map.entry("yhh", "James Bond")
);

favoriteMovies.entrySet().stream()
        .sorted(Entry.comparingByKey())
        .forEachOrdered(System.out::println); // 키 값 순서대로
hsy=Matrix
ljo=Star Wars
yhh=James Bond

getOrDefault 메서드

기존에 찾으려는 키가 존재하지 않을 경우 NPE을 방지하기 위해 널 체크를 해야 했지만getOrDefault 를 이용하면 이를 해결 할 수 있다.

  • 첫 번째 인수로 받은 가 맵에 없으면
  • 두 번째 인수로 받은 기본값 을 반환한다.
  • 키가 존재하더라도 값이 널인 상황에서는 널을 반환할 수 있으므로 주의

계산 패턴

맵에 키가 존재하는지 여부에 따라 어떤 동작을 실행하고 결과를 저장해야 하는 상황이 필요한 때가 있다.

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

Ex) 허승연님에게 줄 영화 목록을 만든다고 가정

  • 기존 코드
    String friend = "hsy";
    List<String> movies = friendsToMovies.get(friend);
    if (movies == null){     // 초기화 확인
        movies = new ArrayList<>();
        friendsToMovies.put(friend, movies);
    }
    movies.add("Iron man"); // 영화 추가
  • 컬렉션 API 사용
    friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>)).add("Star Wars");
</br>

삭제 패턴

  • 제공된 키에 해당하는 맵 요소를 제거하는 remove 메서드는 이미 알고 있다
    • 삭제할 경우 키가 존재하는지 확인하고 값을 삭제하지만
  • 자바 8 에서는 키가 특정한 값과 연관되어 있을 때만 항목을 제거하는 오버로드 버전 메서드를 제공한다.
map.remove(key, value); 

교체 패턴

맵의 항목을 바꾸는데 사용할 수 있는 메서드들

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

합침

두 개의 맵에서 값을 합칠 때 조건을 걸고 합치려면 merge 메서드 이용

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

// merge 메서드 사용 - 조건에 따라 맵을 합치는 코드
Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> 
    everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)
);

{Raphael=Star Wars, Cristina=James Bond & Matrix, Teo=Star Wars}
  • merge 메서드는 중복된 키를 어떻게 합칠지 결정하는 BiFunction을 인수로 받는다.

개선된 ConcurrentHashMap

ConcurrentHashMap 는 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용


리듀스와 검색

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

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

또한, 다음 처럼 4가지 연산 형태를 지원

  1. 키, 값으로 연산 (forEach , reduce , search)
  2. 키로 연산 ( forEachKey, reduceKey, searchKey )
  3. 값으로 연산 ( forEachValue, reduceValue, searchValue )
  4. Map.Entry 객체로 연산 ( forEachEntry, reduceEntry, searchEntry )

위의 연산들은 ConcurrentHashMap의 상태를 잠그지 않고 연산을 수행한다.

따라서, 이들 연산에 제공한 함수는 계산이 진행되는 동안 바뀔 수 있는 객체, 값, 순서 등에 의존하지 않아야한다.

그리고 이들 연산에 병렬성 기준값(threshold) 를 지정해야한다.

  • 맵의 크기가 주어진 기준값보다 작으면 순차적으로 연산을 실행한다.

계수

  • ConcurrentHashMap 클래스는 맵의 매핑 개수를 반환하는 mappingCount 메서드 제공

집합뷰

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

참고출처

728x90
728x90

이번 주제 키워드

  • 병렬 스트림으로 데이터 병렬 처리하기
  • 병렬 스트림의 성능 분석
  • 포크/조인 프레임워크
  • Spliterator로 스트림 데이터 쪼개기

병렬 스트림

  • 스트림을 이용하면 순차 스트림을 병렬 스트림으로 자연스럽게 바꿀 수 있다.
  • 컬렉션에 parallelStream을 호출하면 병렬 스트림이 생성된다.
  • 병렬 스트림이란, 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림이다.
  • 따라서 병렬 스트림을 이용하면 모든 멀티코어 프로세서가 각각의 청크를 처리하도록 할당할 수 있다.
  • 1부터 n부터까지의 합을 구하는 코드 : 일반 스트림
public static long sequentialSum(long n) {
    return Stream.iterate(1L, i -> i + 1)   //  무한 자연수 스트림 생성
                 .limit(n)                  //  n개 이하로 제한
                 .reduce(0L, Long::sum);    //  모든 숫자를 더하는 스트림 리듀싱 연산
}
  • 1부터 n부터까지의 합을 구하는 코드 : 전통적인 자바 코드
public static long iterativeSum(long n) {
    long result = 0;
    for (long i = 1L; i<= n; i++) {
        result += i;
    }
    return result;
}

n이 엄청나게 커진다면 병렬로 처리하는 것이 좋을 것이다. 어떻게 할까??

1. 순차 스트림을 병렬 스트림으로 변환하기

public static long parallelSum(long n) {
    return Stream.iterate(1L, i -> i + 1)
                 .limit(n)
                 .parallel()  //  스트림을 병렬 스트림으로 변환
                 .reduce(0L, Long::sum);
}

  • 순차스트림에 parallel 메서드를 호출하면 리듀싱 연산이 병렬로 처리된다.
    • 하지만 스트림 자체에는 아무 변화도 없다.
    • 내부적으로 불리언 플래그가 설정될 뿐
  • 이전 코드와 다른 점은 스트림이 여러 청크로 분할되어 있다는 것이다.
    • 따라서 리듀싱 연산을 여러 청크에 병렬로 수행할 수 있다.
  • 마지막 리듀싱 연산을 통해 생성된 부분 결과를 다시 리듀싱 연산으로 합쳐 전체 스트림의 리듀싱 결과를 도출한다.
  • parallel과 sequential 메서드를 통해 어떤 연산을 병렬로 실행할지, 순차로 실행할지 제어할 수 있다.
    • parallel과 sequential 두 메서드 중 최종적으로 호출된 메서드가 전체 파이프라인에 영향을 미친다.
    • 아래 코드는 parallel이 마지막 호출되었으므로 위 파이프라인은 병렬로 실행된다.
stream.parallel()
      .filter(...)
      .sequential()
      .map(...)
      .parallel()
      .reduce();

스트림 성능 측정

  • 과연 어느것이 더 빠를까??
    • 순차스트림
    • 병렬스트림
    • 전통적인 for loop
  • JMH 라이브러릴 이용해 성능 측정!
    • https://github.com/openjdk/jmh
    • https://www.baeldung.com/java-microbenchmark-harness
    • JMH는 OpenJDK에서 개발한 성능 측정 툴이다.
    • 특정 메소드의 성능을 측정하는 식으로 사용할 수 있고 실제 테스트하기전 워밍업 과정과 실제 측정 과정을 수행하는데 각 과정의 실행 수를 제어할 수 있고, 측정 후 결과로 나오는 시간의 단위를 지정하는 기능도 제공한다.
  • 보통 for loop - 순차 스트림 - 병렬 스트림 순
    • 왜일까??
    • iterate가 박싱된 객체를 생성하므로 이를 다시 언박싱하는 과정이 필요했다.
    • 반복 작업은 병렬로 실행될 수 있도록 독립적인 청크로 분할하기 어렵다.
  • 두 번째 이유는 굉장히 큰 문제

- iterate 연산은 이전 연산의 결과에 따라 다음 함수의 입력이 달라지기 때문에 청크로 분할이 어렵다.
- 위와 같은 상황에서는 병렬 리듀싱 연산이 수행되지 않는다.
- 리듀싱 과정을 시작하는 시점에 전체 숫자 리스트가 준비되지 않았으므로 스트림을 병렬로 처리할 수 있도록 청크로 분할할 수가 없기 때문이다.
- iterate같은 경우는 스트림이 병렬로 처리되도록 지시했고 각각의 합계가 다른 thread에서 수행되었음에도 불구하고 순차처리 방식으로 처리되기 때문에 thread를 할당하는 오버헤드만 증가하게 될 뿐이다.
- 따라서 iterate와 같은 병렬과는 거리가 먼 방식을 사용하면 오히려 프로그램의 성능이 더 나빠질 수도 있다.

더 특화된 메서드 사용

  • LongStream.rangeClosed라는 메서드를 활용할 수 있다. 이는 iterate에 비해 아래와 같은 장점이 있다.
    • 기본형 long을 직접 사용하므로 박싱과 언박싱 오버헤드가 사라진다.
    • 쉽게 청크로 분할할 수 있는 숫자 범위를 생산한다. 예를 들어, 1 ~ 20의 숫자 범위를 각각 1 ~ 5, 6 ~ 10, 11 ~ 15, 16 ~ 20 범위의 숫자로 분할할 수 있다.
LongStream.rangeClosed(1, N)
          .parallel()
          .reduce(0L, Long:sum);
  • LongStream.rangeClosed를 활용하면 실질적으로 리듀싱 연산이 병렬로 수행된다.
  • 올바른 자료구조를 선택해야 병렬 실행도 최적의 성능을 발휘할 수 있다.

병렬 스트림의 올바른 사용법

  • 공유된 상태를 바꾸는 알고리즘을 사용할 때 병렬 스트림을 사용하면 문제가 발생한다.
public long sideEffectParalleSum(long n) {
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
    return accumulator.total;
}

public class Accumulator {
    public long total = 0;
    public void add(long value) { total += value; }
}
  • 10번 수행한 결과
    • 올바른 결과값조차 나오지 않는다.
    • 레이스 컨디션이 일어나기 때문

병렬 스트림 효과적으로 사용하기

  • 확신이 서지 않을 때는 직접 측정해서 사용하라.
    • 병렬 스트림이 순차 스트림보다 항상 성능이 좋은 것이 아니기 때문에 모를 때는 직접 성능 체크해보는 것이 정확하다.
  • 박싱을 주의해서 사용하라.
    • 오토박싱/언박싱은 성능을 크게 저하시킬 수 있는 요소다. 기본형 특화 스트림(ex. IntStream, LongStream, DoubleStream)을 활용하여 박싱 동작을 피할 수 있다.
  • 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산이 있음을 주의하라.
    • limit이나 findFirst같이 요소의 순서에 의존하는 연산을 병렬 스트림에 활용하게 되면 비싼 비용을 치뤄야 한다.
  • 스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라.
    • 처리해야할 요소 수가 N이고 하나의 요소를 처리하는데 드는 비용이 Q라고 하면
    • 전체 스트림 파이프라인 처리 비용을 N*Q로 예상할 수 있다.
    • Q가 높아진다는 것은 병렬 스트림으로 성능 개선의 가능성이 있다는 것을 의미
  • 소량의 데이터에서는 병렬 스트림이 도움 되지 않는다.
    • 소량의 데이터를 처리하는 상황에서는 병렬화 과정에서 생기는 부가 비용을 상쇄할 수 있을 만큼의 이득을 얻지 못한다.
  • 스트림을 구성하는 자료구조가 올바른지 확인하라.
    • 예를 들면, ArrayList가 LinkedList보다 효율적으로 분할할 수 있다.
    • LinkedList는 분할하려면 모든 요소를 탐색해야 하지만 ArrayList는 요소를 탐색하지 않고도 리스트를 분할할 수 있다.
  • 스트림의 특성과 파이프라인의 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라 분해 과정의 성능이 달라질 수 있다.
    • 예를 들어, SIZED 스트림은 정확히 같은 크기의 두 스트림으로 분할되므로 효과적으로 스트림을 병렬처리 할 수 있다
    • 반면, 필터 연산은 스트림의 길이를 예측할 수 없으므로 효과적으로 병렬 처리 할 수 있을지 알 수 없게 된다.
  • 최종 연산의 병합 과정(ex. Collector의 combiner 메서드) 비용을 살펴봐라.
    • 병합 과정의 비용이 비싸다면, 병렬 스트림으로 얻은 성능의 이익이 서브스트림의 부분 결과를 합치는 과정에서 상쇄될 수 있다.

포크/조인 프레임워크

병렬 스트림이 수행되는 내부 인프라구조는 자바7에서 추가된 포크/조인 프레임워크로 병렬 스트림이 처리된다.

  • 포크/조인 프레임워크는 병렬화할 수 있는 작업을 재귀적으로 작은 작업으로 분할한 다음에 서브태스크 각각의 결과를 합쳐서 전체 결과를 만들도록 설계되었다.
  • 포크/조인 프레임워크에서는 서브태스크를 스레드 풀(ForkJoinPool)의 작업자 스레드에 분산 할당하는 ExecutorService 인터페이스를 구현한다.
  • RecursiveAction 또는 RecursiveTask 추상 클래스를 상속받아서 구현
    • RecursiveAction: 반환값이 없을 때
    • RecursiveTask: 반환값이 있을 떄

RecursiveTask 활용

  • 스레드 풀을 이용하려면 RecursiveTask의 서브클래스를 만들어야 한다.
  • RecursiveTask를 정의하려면 추상 메서드 compute를 구현해야 한다.
  • protected abstract R compute();
  • compute 메서드는 태스크를 서브태스크로 분할하는 로직과 더 이상 분할할 수 없을 때 개별 서브태스크의 결과를 생산할 알고리즘을 정의한다.
  • compute 메서드 의사코드
if (태스크가 충분히 작거나 더 이상 분할할 수 없으면) {
    순차적으로 태스크 계산
} else {
    태스크를 두 서브태스크로 분할
    태스크가 다시 서브태스크로 분할되도록 이 메서드를 재귀적으로 호출함
    모든 서브태스크의 연산이 완료될 때까지 기다림
    각 서브태스크의 결과를 합침
}
  • 재귀적인 태스크 분할 과정
    • divide-and-conquer 알고리즘의 병렬화 버전이다.

fork & join으로 합 구하기

var pool = new ForkJoinPool(); // 쓰레드풀 생성
var task = new SumTask(1, 10); // task 생성
var resut = pool.invoke(task); // invoke() 호출로 작업 시작
System.out.println(resut);
// fork join을 이용하여 간단한 합 구하기
class SumTask extends RecursiveTask<Long> {
    private final long start;
    private final long end;
    private static final long THRESHOLD = 10;

    public SumTask(long start, long end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        long length = end - start + 1;
        if (length <= THRESHOLD) {
            return sum();
        }

        long mid = (start + end) / 2;
        SumTask leftSumTask = new SumTask(start, mid);
        SumTask rightSumTask = new SumTask(mid + 1, end);
        leftSumTask.fork(); // 스레드풀의 다른 스레드로 태스크를 비동기로 실행
                Long rightResult = rightSumTask.compute(); // 두번째 서브태스크 동기 실행
                Long leftResult = leftSumTask.join();
        return  rightResult + leftResult;
    }

    private Long sum() {
        long ret = 0L;
        for (long i = start; i < end; i++) {
            ret += i;
        }
        return ret;
    }
}
  • ForkJoinPool은 fork & join 프레임웤에서 제공하는 쓰레드 풀로, 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 한다.

fork()와 join()

  • compute()는 작업을 나누고, fork()는 작업을 큐에 넣는다. (반복)
  • join()으로 작업의 결과를 합친다. (반복)
  • fork와 join의 차이점

  • 비동기 메서드는 메서드를 호출만 할 뿐, 그 결과를 기다리지 않는다.
    • 내부적으로는 다른 쓰레드에게 작업을 수행하도록 지시만 하고 결과를 기다리지 않고 돌아오는 것

작업 훔치기

  • fork()가 호출되어 작업 큐에 추가된 작업 역시, compute()에 의해 더 이상 나눌 수 없을 때까지 반복해서 나뉘고, 자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와서 수행한다.
  • 이것을 작업 훔쳐오기라고 하며, 이 과정은 모두 쓰레드풀에 의해 자동적으로 이루어진다.

포크/조인 프레임워크를 제대로 사용하는 방법

  • join 메서드를 태스크에 호출하면 태스크가 생산하는 결과가 준비될 때 까지 호출자를 블록시킨다. 따라서 두 서브태스크가 모두 시작된 다음에 join을 호출해야 한다. 그렇지 않으면 각각의 서브태스크가 다른 태스크가 끝나길 기다리게 되면서 순차 알고리즘보다 느리고 복잡한 프로그램이 될 수 있다.
  • RecursiveTask 내에서는 ForkJoinPool의 invoke 메서드 대신 compute나 fork 메서드를 호출한다. 순차 코드에서 병렬 계산을 시작할 때만 invoke를 사용한다.
  • 두 서브태스크에서 메서드를 호출할 때는 fork와 compute를 각각 호출하는 것이 효율적이다. 그러면 두 서브태스크의 한 태스크에는 같은 스레드를 재사용할 수 있으므로 풀에서 불필요한 태스크를 할당하는 오버헤드를 피할 수 있다.
    • 즉 compute()는 새 스레드를 사용하지 않기 때문
  • 포크/조인 프레임워크를 이용하는 병렬 계산은 디버깅이 어렵다.
  • 멀티코어에서 포크/조인 프레임워크를 사용하는 것이 순차처리보다 무조건 빠른 것은 아니다. 병렬 처리로 성능을 개선하려면 태스크를 여러 독립적인 서브태스크로 분할할 수 있어야 한다.

그렇다면 스트림은 어떻게 분할 로직을 개발하지 않고도 자동으로 스트림을 분할할까?? 바로 Spliterator 기법을 이용


Spliterator

  • Spliterator는 분할할 수 있는 반복자라는 의미다.
  • Iterator 처럼 소스의 요소 탐색 기능을 제공하지만 병렬 작업에 특화돼있다.
  • 자바 8은 컬렉션 프레임워크에 포함된 모든 자료구조에 사용할 수 있는 디폴트 Spliterator 구현을 제공한다.
  • 컬렉션은 spliterator라는 메서드를 제공하는 Spliterator 인터페이스를 구현한다.
public interface Spliterator<T> {
    boolean tryAdvance(Consumer<? super T> action); //  Spliterator 의 요소를 하나씩 순차적으로 소비하면서 탐색해야 할 요소가 남아있으면 true를 반환(iterator 동작과 같다)
    Spliterator<T> trySplit();  //  Spliterator 의 일부 요소(자신이 반환한 요소)를 분할해서 두 번째 Spliterator를 생성하는 메서드
    long estimateSize();    //  탐색해야 할 요소 수 정보 제공 메서드
    int characteristics();
}

분할 과정

  • 스트림을 여러 스트림으로 분할하는 과정은 재귀적으로 일어난다.

  • 1단계: 첫 번째 Spliterator에 trySplit을 호출하면 두 번째 Spliterator가 생성된다.
  • 2단계: 두개의 Spliterator에 trySplit을 다시 호출하면 4개의 Spliterator가 생성된다.
    • 이처럼 trySplit의 결과가 null이 될 때 까지 이 과정을 반복한다.
  • 3단계: trySplit이 null을 반환했다는 것은 더 이상 자료구조를 분할할 수 없음을 의미
  • 4단계: Spliterator에 호출한 모든 trySplit의 결과가 null이면 재귀 분할 과정이 종료된다.

Spliterator의 특성

  • Spliterator의 characteristics 추상 메서드는 Spliterator 자체의 특성 집합을 int 타입으로 반환한다.
  • Spliterator 특성


정리

  • 내부 반복을 이용하여 다른 스레드를 이용하지 않고도 스트림을 병렬로 처리할 수 있다.
  • 병렬 처리 성능이 무조건 빠른 것이 아니기 때문에 성능 측정을 해보는 것이 좋다.
  • 병렬 스트림은 처리해야할 데이터가 아주 많거나 각 요소를 처리하는데 오랜 시간이 걸릴 때 성능을 높일 수 있다.
  • 기본형 특화 스트림을 이용하는 것이 병렬 처리보다 더욱 성능을 높일 수 있는 방법이다.
  • 스트림의 병렬처리는 포크/조인 프레임워크 이용하여 병렬화할 수 있는 태스크를 작은 태스크로 분할한 후, 분할된 태스크를 각각의 스레드로 실행하며 서브태스크 각각의 결과를 합쳐서 최종 결과를 생산한다.
  • Spliterator는 탐색하려는 데이터를 포함하는 스트림을 어떻게 병렬화 할 것인지를 정의한다.

참고 출처

728x90

+ Recent posts