책에서는단일 스레드로 동작하던 서버를 다중 스레드로 변경하는 것과 코드를 깨끗하게 변경하는 내용을 다룬다. 우리는 다중 스레드를 적용하기 전에 어플리케이션이 어디서 시간을 보내는지 알아야 한다.
I/O - 소켓 사용, 데이터베이스 연결, 가상 메모리 스와핑 기다리기 등에 시간을 보낸다. 프로세서 - 수치계산, 정규표현식 처리, 가비지 컬렉션 등에 시간을 보낸다.
대개 시스템은 둘 다 하느라 시간을 보내지만, 특정 연산을 살펴보면 대개 하나가 지배적이다.
1. 프로그램이 주로 프로세서 연산에 시간을 보내는 경우
새로운 하드웨어를 추가해 성능을 높여 테스트를 통과하는 방식이 적합하다.프로세서 연산에 시간을 보내는 프로그램은 스레드를 늘인다고 해서 빨라지지 않는다. 왜냐하면 CPU 사이클은 한계가 있기 때문이다.
2. 주로 I/O 연산에 시간을 보내는 경우
동시성이 성능을 높여주기도 한다. 시스템 한쪽이 I/O를 기다리는 동안에 다른 쪽이 뭔가를 처리해 노는 CPU를 효과적으로 활용할 수 있다.
스레드 추가하기
책에서는 스레드를 우선 추가한다.
서버 살펴보기
스레드를 추가한 위의 서버는 테스트를 완료한다. 하지만, 단순하게 스레드를 추가하면 다소 부실하기에 새로운 문제를 일으킨다.
새 서버가 만드는 스레드 수는 몇 개일까? 코드에서 한계를 명시하지 않으므로 이론상으로 JVM이 허용하는 수까지 가능하다. 대다수 간단한 시스템은 그래도 괜찮다.하지만 공용 네트워크에 연결된 수많은 사용자를 지원하는 시스템이라면 어떨까? 너무 많은 사용자가 시스템에 몰리면 동작을 멈출지도 모른다. ( 스레드 풀이 필요한 이유 )
하지만 동작 문제는 잠시 미뤄두고, 그 외에도 깨끗한 코드와 구조라는 관점에서도 문제가 있다. 서버 코드가 지는 책임이 몇 개일까?
소켓 연결 관리
클라이언트 처리
스레드 정책
서버 종료 정책
불행히도 이 모든 책임은 이 책의 예제에서는 process 함수가 진다.아래 코드는 추상화 수준도 다양하다.
서버 프로그램은 고칠 이유가 여럿이다. 즉, 단일 책임 원칙(SRP)을 위반한다. 잘 통제된 몇 곳으로 스레드 관리를 모아야 한다.
-> 스레드를 관리하는 코드는 스레드만 관리해야 한다.
(비동시성 문제까지 뒤섞이지 않더라도 동시성 문제는 그 자체만으로 추적하기 어려움)
//스레드와 관련된 코드는 ClientScheduler에 위치
public interface ClientScheduler {
void schedule(ClientRequestProcessor requestProcessor);
}
//동시성 정책 구현
public class ThreadPerRequestScheduler implements ClientScheduler {
public void schedule(final ClientRequestProecessor requestprocessor) {
Runnablel runnable = new Runnable() {
public void run() {
requestProcessor.process();
}
};
Thread thread = new Thread(runnable);
thread.start();
}
}
위의코드는 스레드 관리를 한 곳으로 몰아서 스레드를 제어하는 동시성 정책으를 바꾸기도 쉬워진다.
Ex) 애플리케이션을 자바 5 Executor 프레임워크로 옮기는 경우 새클래스를 작성해 대체할 수 있다.
결론: 동시성은 그 자체가 복잡한 문제이므로 다중 스레드 프로그램에서는 단일 책임 원칙이 특히 중요하다.
가능한 실행 경로
incrementValue 메서드를 살펴보자. 해당 메서드는 루프나 분기가 없는, 한 줄짜리 메서드다.
public class IdGenerator {
int lastIdUsed;
public int incrementValue() {
return ++lastIdUsed;
}
}
스레드 하나가 IdGenerator 인스턴스 하나를 사용한다고 가정할 때, 가능한 실행 경로는 단 하나이며, 가능한 결과도 단 하나다.
반환값은 lastIdUsed 값과 동일하다. 두 값 모두 메서드를 호출하기 전보다 1이 크다.
만약 IdGenarator 인스턴스는 그대로지만 스레드가 두 개라면, 각 스레드가 함수를 한 번씩 호출한다면 가능한 결과는 무엇일까?
실행 경로는?
초깃값을 93으로 가정할 때 가능한 결과는 다음과 같다.
스레드 1이 94, 2가 95, lastIdUsed가 95가 된다.
스레드 1이 95, 2가 94, lastIdUsed가 95가 된다.
스레드 1이 94, 2가 94, lastIdUsed가 94가 된다.
놀랄지도 모르지만 마지막 결과도 가능하다. 이처럼 다양한 결과가 나오는 이유를 알기 위해서는 가능한 실행 경로 수와 JVM의 동작 방식을 알아야 한다.
경로
가능한 경로 수를 계산하기 위해 자바 컴파일러가 생성한 바이트 코드를 살펴보자. return ++lastIdUsed 자바 코드 한 줄은 바이트 코드 명령 8개에 해당한다. 즉, 두 스레드가 8개를 뒤섞어 실행할 가능성이 충분하다.
루프나 분기가 없는 명령 N개를 스레드 T개가 차례로 실행한다면 가능한 경로 수는 다음과 같다.
-> (NT)! / N!^T(p414 ~ 415)
즉 위의 예제는 12,870 가지의 경로 수가 존재한다. 만약 lastIdUsed가 long이라면 읽기/쓰기 명령이 한 단계가 아니라 두 단계가 된다. (32비트씩 가져오기 때문)
만약 메서드를 synchronized로 바꾼다면, 가능한 경로 수는 (스레드가 2개일 때) 2개로 줄어든다! 스레드가 N개라면 가능한 경로 수는 N! 이다.
심층 분석
앞선 94, 94, 94 시나리오를 분석해보자.중단이 불가능한 연산을 원자적 연산이라고 칭한다. lastid에 0을 할당하는 연산은 원자적이다. 하지만, long으로 바꾼다면 JVM명세에 따르게 되면, 2번에 나누어 할당하게 되고 스레드의 개입이 가능해진다.
전처리 연산 ++는 중단이 가능하다. 바이트 코드를 상세히 보기 전에 다음 정의 세 개를 확인해야 한다.
프레임 - 모든 메서드 호출에는 프레임이 필요하다. 반환 주소, 메서드로 넘어온 매개변수, 메서드가 정의하는 지역 변수를 포함한다. 호출 스택을 정의할 때 사용하는 표준 기법이다. 현대 언어는 호출 스택으로 기본 함수/메서드 호출과 재귀적 호출을 지원한다.
지역 변수 - 메서드 범위 내에 정의되는 모든 변수를 가리킨다. 정적 메서드를 제외한 모든 메서드는 this라는 지역 변수를 갖는다.현재 스레드에서 가장 최근에 메시지를 받아 메서드를 호출한 객체를 가리킨다.
피연산자 스택 - JVM이 지원하는 명령 대다수는 매개변수를 받는다. 피연산자 스택은 이런 매개변수를 저장하는 장소다. 표준 LIFO 자료 구조다.
이 단계가 결국 세부적으로 나뉘면, 앞선 결과가 나올수도 있다. 자세한 내용은 책에서 확인한다. ( p.418 )
결론: ++ 연산은 분명히 원자적 연산이 아니다. 이를 이해하기 위해서 어떤 연산이 안전하고 안전하지 못한지 파악할 만큼 메모리 모델을 이해하고 있어야 한다. 즉 아래의 내용을 알아야 한다.
공유 객체/값이 있는 곳
동시 읽기/수정 문제를 일으킬 소지가 있는 코드
동시성 문제를 방지하는 방법
라이브러리를 이해하라
Executor 프레임워크
스레드는 생성하나 스레드 풀을 사용하지 않는다면 혹은 직접 생성한 스레드 풀을 사용한다면 Executor 클래스를 고려하기 바란다.
Executor 프레임워크는 스레드 풀을 관리하고, 풀 크기를 자동으로 조정하며, 필요하다면 스레드를 재사용한다. Future도 지원한다.
Future를 사용하게 되면, get() 하는 시점에 Future가 끝나기를 기다린다.
스레드를 차단하지 않는 방법(Non Blocking)
최신 프로세서는 차단하지 않고도 안정적으로 값을 갱신한다.
현대 프로세서는 흔히 CAS(Compare and Swap)라 불리는 연산을 지원한다. CAS는 데이터베이스 분야에서 낙관적 잠금이라는 개념과 유사하다. 반면 동기화 버전은 비관적 잠금이라는 개념과 유사하다.
CAS 연산은 메서드가 공유 변수를 갱신하려 든다면, 현재 변수 값이 최종으로 알려진 값인지 확인한다. 그렇다면 변수 값을 갱신하고 아니라면 다른 스레드가 끼어들었다는 뜻이므로 변수 값을 갱신하지 않는다. 메서드는 이를 확인하고 다시 시도한다.
synchroinzed 키워드는 언제나 락을 건다. 둘째 스레드가 같은 값을 갱신하지 않더라도 무조건 락부터 건다. 자바 버전이 올라갈 때마다 내장 락의 성능이 좋아지기는 했지만 그래도 락을 거는 대가는 여전히 비싸다.
스레드를 차단하지 않는 버전은 여러 스레드가 같은 값을 수정해 문제를 일으키는 상황이 그리 잦지 않다는 가정에서 출발한다. 그래서 그런 상황이 발생했는지 효율적으로 감지해 갱신이 성공할 때까지 재차 시도한다. 많은 스레드가 경쟁하는 상황이더라도 락을 거는 쪽보다 문제를 감지하는 쪽이 더 효율적이다.
다중 스레드 환경에서 안전하기 않은 클래스
본질적으로 다중 스레드 환경에서 안전하지 않는 클래스가 있다. 몇 가지 예는 다음과 같다.
SimpleDataFormat
데이터베이스 연결
java.util 컨테이너 클래스
서블릿
Concurrent를 지원하는 클래스를 사용하거나, synchronized를 잘 사용하자.
메서드 사이에 존재하는 의존성을 조심하라
public class IntegerIterator implements Iterator<Integer> {
private Integer nextValue = 0;
public synchronized boolean hasNext() {
return nextValue < 100000;
}
public synchronized Integer next() {
if (nextValue == 100000)
throw new IteratorPastEndException();
return nextValue++;
}
public synchronized Integer getNextValue() {
return nextValue;
}
}
IntegerIterator iterator = new IntegerIterator();
while(iterator.hasNext()) {
int nextValue = iterator.next();
// nextValue로 뭔가를 한다.
}
스레드가 2개일 때, iterator.hasNext() 를 순차적으로 통과 후 next() 가 호출된다면, 100001에 도달함에도 아래의 로직을 실행하는 스레드가 존재할 수도 있다.
이에 대한 해결방안은 3가지가 있다.
실패를 용인한다
때로는 실패해도 괜찮도록 프로그램을 조정할 수 있다. 예를 들어, 위에서 클라이언트가 예외를 받아 처리해도 되겠다. 조잡한 방법이다. 한밤중에 시스템을 재부팅해 메모리 누수를 해결하는 방법과 비슷하다고 할 수 있다.
클라이언트-기반 잠금
IntegetIterator iterator = new IntegerIterator();
while(true) {
int nextValue;
synchronized (iterator) {
if(!iterator.hasNext())
break;
nextValue = iterator.next();
}
doSometingWith(nextValue);
}
서버를 사용하는 모든 프로그래머가 락을 기억해 객체에 걸었다 풀어야 하므로 다소 위험한 전략이다. 저자는 클라이언트-기반 잠금 메커니즘은 사람이 할 짓이 아니라고 말한다.
서버-기반 잠금
public class IntegerIteratorServerLocked {
private Integer nextValue = 0;
public synchronized Integer getNextOrNull() {
if (nextValue < 100000)
return nextValue++;
else
return null;
}
}
// 클라이언트 코드도 다음과 같이 변경한다
while(true) {
Integer nextValue = iterator.getNextorNull();
if (next == null)
break;
// 뭔가를 한다.
}
마치, ConcurrentHashMap의 putIfAbsent() 와 같은 역할이다.
일반적으로 서버-기반 잠금이 더 바람직하다.
코드 중복이 줄어든다.
성능이 좋아진다.
오류가 발생할 가능성이 줄어든다. 잠금을 잊어버리는 바람에 오류가 발생할 위험은 프로그래머 한 명으로 제한된다.
스레드 정책이 하나다. 서버 한곳에서 정책을 구현한다.
공유 변수 범위가 줄어든다. 모두가 서버에 숨겨진다. 문제가 생기면 살펴볼 곳이 적다.
서버 코드에 손대지 못한다면?
public class ThreadSafeIntegerIterator {
private IntegerIterator iterator = new IntegerIterator();
public synchronized Integer getNextOrNull() {
if(iterator.hasNext())
return iterator.next();
return null;
}
}
위와 같은 ADAPTER 패턴을 사용해 API를 변경하고 잠금을 추가한다.
작업 처리량 높이기
이 책에서는
페이지를 읽어오는 평균 I/O 시간 : 1초
페이지를 분석하는 평균 처리 시간 : 0.5초
처리는 CPU 100% 사용, I/O는 CPU 0% 사용
이라고 가정했다.
이때, 1개의 스레드로 N페이지를 처리한다면 총 실행 시간은 1.5 * N이다.
스레드를 3개 사용한다고 치면, 페이지를 분석하는 행위는 읽어오는 1초간 2번 처리할 수 있다.
즉, 대략적으로 1초에 2개를 처리할 수 있고, 0.5초에 1개.
즉, 3배정도 빨라진다고 볼 수 있다.
데드락
다음 네 가지 조건을 모두 만족하면 데드락이 발생한다.
상호 배제 (Mutual Exclusion)
잠금 & 대기 (Lock & Wait)
선점 불가 (No preemption)
순환 대기 (Circular Wait)
상호 배제
여러 스레드가 한 자원을 공유하나 그 자원은
여러 스레드가 동시에 사용하지 못하며
개수가 제한적이라면
상호 배제 조건을 만족한다.
데이터베이스 연결, 쓰기용 파일 열기, 레코드 락, 세마포어 등과 같은 자원등이 해당된다.
잠금 & 대기 (Lock & Wait)
일단 스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 이미 점유한 자원을 내놓지 않는다.
선점 불가 (No Preemption)
한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다.
순환 대기 (Circular Wait)
죽음의 포옹이라고도 한다. T1, T2라는 스레드가 두 개 있으며 R1, R2 라는 자원이 두 개가 있다고 가정하자. T1이 R1을 점유하고, T2가 R2를 점유한다. 또한 T1은 R2가 필요하고, T2도 R2가 필요하다.
위의 네 가지 모두를 충족해야 데드락이 발생한다. 네 조건 중 하나라도 깨버리면 데드락이 발생하지 않는다.
상호 배제 조건 깨기
동시에 사용해도 괜찮은 자원을 사용한다. 예를 들어, AtomicIntger를 사용한다.
스레드 수 이상으로 자원을 늘린다.
자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.
하지만 대다수 자원은 한정적이고 동시에 사용하기도 어렵다.
잠금 & 대기 조건 깨기
대기하지 않으면 데드락이 발생하지 않는다. 각 자원을 점유하기 전에 확인한다. 만약 어느 하다라도 점유하지 못한다면 지금까지 점유한 자원을 몽땅 내놓고 처음부터 다시 시작한다.
이 방법은 잠재적인 문제가 있다.
기아(starvation): 한 스레드가 계속해서 필요한 자원을 점유하지 못한다. (점유하려는 자원이 한꺼번에 확보하기 어려운 조합일지도 모른다).
라이브락(Livelock): 여러 스레드가 한꺼번에 잠금 단계로 진입하는 바람에 계속해서 자원을 점유했다 내놨다를 반복한다. 단순한 CPU 스케줄링 알고리즘에서 특히 쉽게 발생한다.
두 경우 모두가 자칫하면 작업 처리량을 크게 떨어뜨린다. 기아는 CPU 효율을 저하시키는 반면 라이브락은 쓸데 없이 CPU만 많이 사용한다.
선점 불가 조건 깨기
데드락을 피하는 또 다른 전략은 다른 스레드로부터 자원을 뺏어오는 방법이다. 일반적으로 간단한 요청 메커니즘으로 처리한다. 필요한 자원이 잠겼다면 자원을 소유한 스레드에게 풀어달라 한다. 소유 스레드가 다른 자원을 기다리던 중이었다면 자신이 소유한 자원을 모두 풀어주고 처음부터 다시 시작한다.
순환 대기 조건 깨기
데드락을 방지하는 가장 흔한 전략이다.
R1을 점유한 T1이 R2를 기다리고 R2를 점유한 T2가 T1을 기다리는 앞서 예제에서 T1과 T2가 자원을 똑같은 순서로 할당하게 만들면 순환 대기는 불가능하다.
좀 더 일반적으로 말해, 모든 스레드가 일정 순서에 동의하고 그 순서로만 자원을 할당한다면 데드락은 불가능하다. 그러나 이 전략도 문제를 일으킬 소지가 있다.
자원을 할당하는 순서와 자원을 사용하는 순서가 다를지도 모른다. 그래서 맨 처음 할당한 자원을 아주 나중에야 쓸지도 모른다. 즉, 자원을 꼭 필요한 이상으로 오랫동안 점유한다.
때로는 순서에 따라 자원을 할당하기 어렵다. 첫 자원을 사용한 후에야 둘때 자원 ID를 얻는다면 순서대로 할당하기란 불가능하다.
이렇게 데드락을 피하는 전략은 많다. 하지만 어떤 전략은 기아를 일으키고, 다른 전략은 CPU를 심하게 사용해 응답도를 낮춘다.
다중 스레드 코드 테스트
다중 스레드에서의 문제는 너무 드물게 발생하는 바람에 테스트로 발견하기가 어렵다.
그렇다면 이렇게 간단한 실패를 증명할 방법은 무엇일까?
다음은 몇 가지 아이디어다.
몬테 카를로 테스트 - 조율이 가능하게 유연한 테스트를 만든다. 임의로 값을 조율하면서 반복해 돌린다. 테스트가 실패한 조건은 신중하게 기록한다.
시스템을 배치할 플랫폼 전부에서 테스트를 돌린다. 반복해서 돌린다. 테스트가 실패 없이 오래 돌아갈수록 두 가지 중 하나일 확률이 높아진다.
실제 코드가 올바르다.
테스트가 부족해 문제를 드러내지 못한다.
부하가 변하는 장비에서 테스트를 돌린다. 실제 환경과 비슷하게 부하를 걸어 줄 수 있다면 그렇게 한다.
하지만, 가능성은 매우 낮다. 십억 번에 한 번씩만 일어나는 희귀한 문제가 가장 골치 아프다.
스레드 코드 테스트를 도와주는 도구
IBM은 ConTest라는 도구를 내놓았다. 스레드에 안전하지 않는 코드에 보조 코드를 더해 실패할 가능성을 높여주는 도구다.
결론:
여기서는 다중 스레드 코드를 깨끗하게 유지하는 방법을 익혔다.
이 장에서는 동시 갱신을 논했으며, 동시 갱신을 방지하는 깨끗한 동기화/잠금 기법을 소개했다. 스레드가 I/O 위주 시스템의 처리율을 높여주는 이유와 실제로 처리율을 높이는 방법을 살펴봤다. 데드락을 논했으며, 깔끔하게 데드락을 방지하는 기법도 열거했다. 마지막으로 보조 코드를 추가해 동시성 문제를 사전에 노출하는 전략을 소개했다.
원소 시퀀스, 즉 일련의 원소를 반환하는 메서드는 많다. Collection, List, Set과 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 사용했다. 자바 8에서는 스트림이 도입되면서 선택지가 복잡해졌다.
스트림은 반복(Iteration)을 지원하지 않는다. 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다. 여기서 재밌는 사실 하나는 Stream인터페이스는 Iterable 인터페이스의 추상 메서드를 모두 정의해 놓았지만Iterable을 확장하지 않았다는 문제가 있다.
예제 1 : Stream → Iterable adaptor
아래의 코드는 자바 타입 추론의 한계로 컴파일되지 않는다.
public class Item47 {
public static void main(String[] args) {
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) { // 컴파일 에러가 난다.
// 프로세스를 처리한다.
}
}
}
Stream의 iterator 메서드에 메서드 참조를 건네면 될 것 같지만 컴파일 오류가 난다.
스트림을 반복하기 위한 '끔찍한' 우회방법
public class Item47 {
public static void main(String[] args) {
for (ProcessHandle ph : (Iterable<ProcessHandle>)
ProcessHandle.allProcesses()::iterator) {
// 프로세스를 처리한다.
}
}
}
위 코드는 작동은 하지만 난잡하고 직관성이 떨어진다.
어댑터 메서드를 활용한 스트림 반복
public class Item47 {
public static void main(String[] args) {
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// 프로세스를 처리한다.
}
}
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
}
어댑터 메서드를 사용하면 자바의 타입 추론이 문맥을 잘 파악하여 어댑터 메서드 안에서 따로 형변환하지 않아도 된다.
어댑터를 사용하면 어떤 스트림도 for-each 문으로 반복할 수 있다.
예제 2 : Iterable → Stream adaptor
public class Item47 {
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
}
->객체 시퀀스를 반환하는 메서드를 작성할 때 오직 스트림 파이프라인에서만 쓰일 걸 안다면 스트림을 반환하고, 반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환하자. 하지만 공개 API에서는 2개다 지원하는게 좋다.
■ Collection VS Stream
Collection
Collection 인터페이스는 Iterable 인터페이스의 하위 타입이고 stream 메서드도 제공하니 일반적으로는 Collection을 반환하는편이 좋다.
하지만 Collection의 각 원소는 메모리에 올라가므로, 시퀀스의 크기가 크다면 고민해보는 것이 좋다.
반환할 시퀀스가 크더라도 표현을 간결하게 하여 전체 시퀀스를 메모리 위에 올리지 않고도 동작하도록 작성할 수 있다면 새 컬렉션을 구현하는 것을 생각해보자.
Stream
컬렉션의 contains와 size를 시퀀스의 내용을 확정하기 전 까지 구할 수 없는 경우(i.e. 실제 반복을 돌려보기 전 까지는 무엇이 얼마나 들어갈지 예측이 불가능한 경우)에는 Stream을 반환하는 것이 좋다.
예제 3: 전용 컬렉터 구현하기 (PowerSet을 반환하는 Collection)
PowerSet은, 각 원소의 인덱스를 비트 필드로 이용하는 아이디어다.
public class PowerSet {
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
if (src.size() > 30) // size는 < 2^31-1인데, 30이 넘어가면 size()가 작동하지 않는다.
throw new IllegalArgumentException("Set too big " + s);
return new AbstractList<Set<E>>() {
@Override public int size() {
return 1 << src.size(); // 2 to the power srcSize
}
@Override public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set)o);
}
@Override public Set<E> get(int index) {
Set<E> result = new HashSet<>();
for (int i = 0; index != 0; i++, index >>= 1)
if ((index & 1) == 1)
result.add(src.get(i));
return result;
}
};
}
}
■ 정리
Stream이나 Iterable을 리턴하는 API에는 Stream <-> Iterable로 변환할 수 있도록 어댑터 메서드가 필요하다.
어댑터는 클라이언트 코드를 어수선하게 만들고 더 느리다.
원소 시퀀스를 반환하는 메서드를 작성할 때는 Stream, Iterator를 모두 지원할 수 있게 작성하라
컬렉션을 반환할 수 있다면 컬렉션을 반환하도록 한다.
반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환하도록 한다.
Stream<Integer> integerStream = operands.stream().filter((value) -> value > 2);
3. map: 요소들을 변경하여 새로운 컨텐츠를 생성하는 기능 (ex) 소문자를 대문자로 변경
list.map(s -> s.toUpperCase());
종단연산 마지막 중간 연산의 스트림에 최후의 연산. 1개 이상의 중간연산들은 계속합쳐진 후 종단연산 시 수행된다. 즉, 스트림 파이프라인은 지연평가(lazy evaluation)된다. 종단 연산이 없는 파이프라인은 어떤 연산도 수행되지 않는다.지연평가는 무한 스트림을 다룰 수 있게 해주는 열쇠다.
4. anyMatch(), allMatch(), noneMatch() : 요소의 검사 스트림의 요소중 특정 조건을 만족하는 요소를 검사하는 메서드. 원소중 일부, 전체 혹은 일치하는 것이 없는 경우를 검사하고 boolean 값을 리턴한다. noneMatch()의 경우 일치하는 것이 하나도 없을때 true.
7. collect() : 요소의 수집 스트림의 결과를 모으기 위한 메서드로 Collectors 객체에 구현된 방법에 따라 처리하는 메서드이다. 최종 처리 후 데이터를 변환하는 경우가 많기 때문에 잘 알아 두어야한다. 용도별로 사용할 수 있는 Collectors의 메서드는 기능별로 다음과 같다. 스트림을 배열이나 컬렉션으로 변환 : toArray(), toCollection(), toList(), toSet(), toMap() 요소의 통계와 연산 메소드와 같은 동작을 수행 : counting(), maxBy(), minBy(), summingInt(), averagingInt() 등 요소의 소모와 같은 동작을 수행 : reducing(), joining() 요소의 그룹화와 분할 : groupingBy(), partitioningBy()
- 람다 매개변수의 이름은 주의해서 정해야 한다. - 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다. - 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다 스트림 파이프라인에서 훨씬 크다. 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문이다. - 자바는 기본 타입인 char용 스트림을 지원하지 않는다. char은 int 값을 갖기 때문이고, 그 덕에 int 스트림을 반환하면 헷갈릴 수 있다. 올바르게 동작하게 하려면 명시적으로 형변환을 해줘야 한다. 따라서char 값들을 처리할 때는 스트림을 삼가는 편이 낫다. - 스트림이 언제나 가독성과 유지보수 측면으로 뛰어난 것은 아니다. 스트림과 반복문을 적절히 조합하는 게 최선이다. 따라서기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하도록 한다.
반복 코드 vs 스트림 파이프라인
스트림 파이프라인은 되풀이되는 계산을 함수 객체(주로 람다나 메서드 참조)로 표현한다.
반복 코드에서는 코드 블록을 사용해 표현한다.
함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일
코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.
코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다.
또한, 메서드 선언에 명시된 검사 예외를 던질 수 있다.
람다로는 이 중 어떤 것도 할 수 없다.
계산 로직에서 이상의 일들을 수행해야 한다면 스트림과는 맞지 않는 것이다.
스트림을 사용하기 적절한 경우
원소들의 시퀀스를 일관되게 변환할 때
원소들의 시퀀스를 필터링할 때
원소들의 시퀀스를 하나의 연산을 사용해 결합할 때(더하기, 연결하기, 최솟값 구하기 등).
원소들의 시퀀스를 컬렉션에 모을때(공통된 속성을 기준으로)
원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾을 때
스트림으로 처리하기 어려운 경우
한 데이터가 여러 개의 파이프라인을 거칠 때 이 데이터의 각 단계에서의 값들에 동시에 접근하는 경우(스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문)
핵심 정리
스트림과 반복 방식은 각각에 알맞은 일이 있다.
수 많은 작업은 이 둘을 조합했을 때 가장 멋지게 해결된다.
만약 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 선택하도록 한다.
자바가 람다를 지원하면서 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴의 매력이 크게 줄었다. 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 것으로 대체할 수 있기 때문이다. 이때, 함수형 매개변수 타입을 올바르게 선택해야 한다.
ex)LinkedHashMap에서 removeEldestEntry 메서드를 재정의하는 경우 -> removeEldestEntry메서드를 재정의함으로써, 캐시로 사용할 수 있다.
public class CacheExample {
public static void main(String[] args) {
// 익명 클래스에는 <> 처럼 제네릭 타입 생략을 할 수 없다.
LinkedHashMap<String, Integer> map = new LinkedHashMap<String, Integer>() {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > 3;
}
};
map.put("a", 1); map.put("b", 2);
map.put("c", 3); map.put("d", 4);
// 결과: {b=2, c=3, d=4}
System.out.println(map);
}
}
removeEldestEntry는 잘 동작하지만람다를 이용하여 다시 구현한다면, 함수 객체를 받는 정적 팩터리나 생성자를 제공했을 것이다. 이때, 재정의한removeEldestEntry는size메서드를 호출하는데, 이는인스턴스 메서드라 가능하다.
하지만 팩터리나 생성자를 호출할 때는 Map의 인스턴스가 존재하지 않아 Map 자신도 함수 객체에 넘겨주어야 한다. 이를 함수형 인터페이스로 선언하면 아래와 같다.
(람다 표현식으로 구현이 가능한 인터페이스는 오직 추상 메서드가 1개인 인터페이스만 가능하다. 이 맥락에서 추상 메서드가 1개인 인터페이스를 함수형 인터페이스라고 한다.)
위의 인터페이스는 잘 동작하지만, 굳이 사용할 이유는 없다. 자바 표준 라이브러리에 이미같은 모양의 인터페이스가 준비되어 있기 때문이다.
필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하도록 한다.
API가 다루는 개념의 수가 줄어들어 익히기 더 쉬워질 것이다.
표준 함수형 인터페이스들은 유용한 디폴트 메서드를 많이 제공하므로 다른 코드와의 상호운용성도 좋아질 것이다.
표준 함수형 인터페이스
java.util.function
java.util.function 패키지에는 총 43개의 인터페이스가 있다. 아래는 기본 함수형 인터페이스들을 정리한 표다.
각각의 기본 인터페이스들은 기본 타입인 int, long, double용에 맞게 변형된 형태가 존재한다.
인터페이스
함수 시그니처
의미
예시
UnaryOperator<T>
T apply(T t)
반환값과 인수의 타입이 같은 함수, 인수는 1개
String::toLowerCase
BinaryOperator<T>
T apply(T t1, T t2)
반환값과 인수의 타입이 같은 함수, 인수는 2개
BigInteger::add
Predicate<T>
boolean test(T t)
한 개의 인수를 받아서 boolean을 반환하는 함수
Collection::isEmpty
Function<T,R>
R apply(T t)
인수와 반환 타입이 다른 함수
Arrays::asList
Supplier<T>
T get()
인수를 받지 않고 값을 반환, 제공하는 함수
Instant::now
Consumer<T>
void accept(T t)
한 개의 인수를 받고 반환값이 없는 함수
System.out::println
언제 표준 함수형 인터페이스를 사용해야 할까?
표준 함수형 인터페이스 대부분은 기본 타입만 지원한다. 그렇다고기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 않도록 한다.
계산량이 많을 때는 성능이 처참히 느려질 수 있다.
표준 인터페이스 중 필요한 용도에 맞는 게 없다면 직접 작성해야 하며, 구조적으로 똑같은 표준 함수형 인터페이스가 있더라도 직접 작성해야만 할 때가 있다.
인터페이스를 직접 작성해야 하는 경우
ex) Comparator<T>와 ToIntBiFunction<T, U> 비교
// Comparator
@FunctionInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
// ToIntBiFunction
@FunctionalInterface
public interface ToIntBiFunction<T, U> {
int applyAsInt(T t, U u);
}
Comparator<T> 인터페이스의 경우, 구조적으로 ToIntBiFunction<T, U>와 동일하지만 독자적인 인터페이스로 존재해야 하는 이유가 몇 개 있다. - API에서 굉장히 자주 사용되는데, 이름이 그 용도를 아주 훌륭히 설명해준다. - 구현하는 쪽에서 반드시 지켜야 할 규약을 담고 있다. - 비교자들을 변환하고 조합해주는 유용한 디폴트 메서드들을 많이 담고 있다.
Comparator의 특성을 정리하면 아래와 같다. 이 중 하나 이상을 만족한다면 전용 함수형 인터페이스를 구현해야 하는 건 아닌지 고민해보도록 해야 한다. - 자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다. - 반드시 따라야 하는 규약이 있다. - 유용한 디폴트 메서드를 제공할 수 있다.
@FunctionalInterface
이 애너테이션을 사용하는 이뉴는 @Override를 사용하는 이유와 비슷하다.
프로그래머의 의도를 명시하는 것으로, 크게 세 가지 목적이 있다.
첫 번째. 해당 클래스의 코드나 설명 문서를 읽을 이에게그 인터페이스가 람다용으로 설계된 것임을 알려준다.
두 번째. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야컴파일되게 해준다.
세 번째. 유지보수 과정에서 누군가 실수로메서드를 추가하지 못하게 막아준다.
따라서,직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하도록 한다.
함수형 인터페이스를 사용할 때 주의점
서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중정의해서는 안 된다.
클라이언트에게 모호함을 주며 문제가 발생할 소지가 많다.
public interface ExecutorService extends Executor {
// Callable<T>와 Runnable을 각각 인수로 하여 다중정의했다.
// submit 메서드를 사용할 때마다 형변환이 필요해진다.
<T> Future<T> submit(Callback<T> task);
Future<?> submit(Runnable task);
}
핵심 정리
자바 8부터 람다를 지원하기 때문에, 입력값과 반환값에 함수형 인터페이스 타입을 활용하도록 한다.
보통은 java.util.function 패키지의 표준 함수형 인터페이스를 사용하는 것이 가장 좋은 선택이다.
스트림은 그저 API가 아니라함수형 프로그래밍에 기초한패러다임이다. 그래서 이 패러다임까지 함께 받아들여야 한다.
스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성 하는 부분이다.
이때 각 변화 단계는 가능한 한 이전 단계의 결과를 받아 처리하는순수 함수여야 한다.
순수 함수란오직 입력만이 결과에 영향을 주는 함수를 말한다. 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
이렇게 하려면 (중간 단계든 종단 단계든) 스트림 연산에 건네는 함수 객체는 모두부작용(side effect)이 없어야 한다.
다양한 스트림 연산
for Each
스트림을 잘못 사용한 경우(스트림 api만 사용)
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
위의 코드는 텍스트 파일에서 단어별 수를 세어 빈도표를 만드는 코드이다. 스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 길고, 읽기 어렵고, 유지보수에도 좋지 않다. 더 큰 문제는forEach에서 일어나는데, 이때빈도표를 수정하는 람다를 실행하면서 문제가 생긴다. forEach가 그저 스트림의 연산 결과 보여주기 이상을 하면나쁜 코드일 가능성이 크다.
스트림을 잘 활용한 경우
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}
위의 코드는 이전의 코드에 비해 짧고 명확하다. forEach 연산은 종단 연산 중 기능이 가장 적고 가장 '덜' 스트림답다. 대놓고 반복적이라서 병렬화할 수도 없다.
->forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는데는 쓰지말자
Collector
Collector를 잘 활용하자
* Collector란?
축소(reduction) 전략을 캡슐화한 블랙박스 객체
reduction: 원소들을 객체 하나에 취합한다는 뜻
맵 수집기(toMap)
ㄱ. 각각 KeyMapper, ValueMapper 를 인수로하는 가장 간단한 맵 수집기. Key 가 중복되면 Exception.
@DisplayName("맵수집기 - 각 원소당 하나의 키")
@Test
void toMap_Test() {
Map<String, Operation> expectedMap = new HashMap<>();
expectedMap.put("PLUS", Operation.PLUS);
expectedMap.put("MINUS", Operation.MINUS);
expectedMap.put("DIVIDE", Operation.DIVIDE);
Map<String, Operation> collect = Stream.of(Operation.values())
.collect(toMap(Objects::toString, e -> e));
//잘못된 예
// Map<String, Operation> failCollect = Stream.of(Operation.values())
// .collect(toMap(e -> "SameKey", e -> e)); // java.lang.IllegalStateException: Duplicate key SameKey (attempted merging values PLUS and MINUS)
assertThat(collect).isEqualTo(expectedMap);
}
ㄴ. KeyMapper, ValueMapper, 두 원소가 충돌했을 때의 병합함수
@DisplayName("맵수집기 - 인수가 세개")
@Test
void toMap_Test_Merge() {
Map<String, Operation> expectedMap = new HashMap<>();
expectedMap.put("SameKey", Operation.PLUS);
Map<String, Operation> collect = Stream.of(Operation.values())
.collect(toMap(e -> "SameKey", e -> e, (a, b) -> a));
assertThat(collect).isEqualTo(expectedMap);
}
ㄷ.map의 구현체를 정할 수 있다.
@DisplayName("맵수집기 - 인수가 네개")
@Test
void toMap_Test_Four() {
Map<Operation, String> expectedMap = new EnumMap<>(Operation.class);
expectedMap.put(Operation.PLUS, "PLUS");
expectedMap.put(Operation.MINUS, "MINUS");
expectedMap.put(Operation.DIVIDE, "DIVIDE");
EnumMap<Operation, String> collect = Stream.of(Operation.values())
.collect(toMap(e -> e, Object::toString, (a, b) -> a, () -> new EnumMap<>(Operation.class)));
assertThat(collect).isEqualTo(expectedMap);
}
2. groupingBy
ㄱ. classfier(분류함수)를 받고 카테고리로 묶은 Map을 담은 수집기 반환. 값은 List
elements.stream().collect(Collectors.summingInt(a -> a * a));
// mapToInt().sum() 과 동일
IntSummaryStatistics collect = elements.stream().collect(Collectors.summarizingInt(a -> a + a));
//IntSummaryStatistics{count=6, sum=42, min=2, average=7.000000, max=12}
Integer collect = elements.stream()
.collect(Collectors.collectingAndThen(Collectors.toList(), Collection::size));
// collect한 뒤 변환까지
결론: 스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다. 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다. 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다. 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.