📂 목차
모든 함수는 IDE 에서 Ctrl + 클릭
이나 CMD + 좌클릭
으로 소스코드를 직접 볼 수 있기에 모든 함수의 동작 세부 사항은 생략하고 어떨때, 언제, 왜, 어디서 중요하고 쓰이는지를 설명하고 이론적인 내용을 본다.
📚 본문
배울 수 있는 내용만 적는 것이 도움이 될거 같아서 얻어갈 수 있는 것들을 요약 정리한다.
나머지의 내용들은 타 블로그나 타 수강생들이 매우 많이 적기에 굳이 따로 적지 않고
제공해준 책이나 영상으로도 충분히 독학이 가능하다.
For 문 label
for 문 안에는 continue
예약어를 써넣을 수 있다.
중첩 for 문에 대해 continue
를 하게 되면 해당 scope 내에서만
그 다음 iteration 으로 진행하게 된다.
하지만 그 밖의 for 문에게로 가고 싶을때 해당 label
을 쓰게 된다.
label 은 break
나 continue
와 함께 쓸 수 있고,
반복문 바로 위에 label 을 붙여 해당 반복문으로 이동할 것이다 라는
의미를 가지게 된다.
continue 예시
outer:
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (j == 1) {
continue outer; // outer 반복문의 다음 iteration으로 이동
}
System.out.println(i + ", " + j);
}
}
break 예시
outer: // 라벨 이름
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (i + j == 3) {
break outer; // outer 라벨이 붙은 반복문을 종료
}
System.out.println(i + ", " + j);
}
}
Collection
여러 객체(element)를 그룹화한 인터페이스 형태의 자료구조이다.
가지는 element 의 규칙에는 두 특징을 가진다:
- 중복 요소를 허용하거나 안허용하거나
- 순서가 정의되거나 안되거나
정의된 컬렉션은
SequencedCollection
인터페이스의 하위 타입
JDK는 이런 Collection
인터페이스를 직접 구현한 객체는 제공하지 않고,
대신 Set
, List
같은 구체적인 하위 인터페이스를 구현한 클래스를 제공한다.
Bag 이나 Multiset 은 이 인터페이스를 직접 구현하는 것이 좋다.
Collection 구현체 생성자 규칙
우선 표준 생성자 규칙을 먼저 보자.
표준 생성자 규칙
- 인수가 없는 생성자(
void constructor
)- 빈 컬렉션을 생성
ex) ArrayListlist = new ArrayList<>();
- 빈 컬렉션을 생성
- 단일 컬렉션 인자를 받는 생성자
- 인자로 전달된 컬렉션과 동일한 요소를 가진 새 컬렉션 생성
ex) ArrayListlist2 = new ArrayList<>(list1);
- 인자로 전달된 컬렉션과 동일한 요소를 가진 새 컬렉션 생성
어떤 컬렉션이든 복사를 하여 원하는 구현 타입으로 새 컬렉션을 만들 수 있다.
인터페이스 자체는 생성자를 가질 수 없기 때문에 이는 구현체에서 해주어야 한다.
Optional Method
컬렉션이 반드시 구현해야 하는 메서드는 아니고,
필요에 따라 구현할 수 있는 메서드의 의미이다.
만약, 특정 method 를 지원하지 않는 경우에는 해당 메서드를
UnsupportedOperationException
가 발생시키도록 정의해야 하며,
컬렉션 인터페이스의 메서드 명세에서 “optional operation”으로 표시된다.
Element Constraints
일부 구현체는 컬렉션에 들어갈 요소를 제한할 수 있다.
null
금지- 특정 타입만 허용
제약 위반 시에는 NullPointerException
혹은 ClassCastException
과 같은 unchecked
예외가 발생한다.
또한 제한 위반을 해도 조회 시에 단순히 false
를 반환하는 것도 가능하다.
Synchronization
컬렉션 자체 동기화 여부는 구현체가 결정한다.
멀티 스레드 환경에서 다른 스레드가 컬렉션을 수정 중일 때,
안전하지 않으면 정의되지 않은 동작이라는 예외(ConcurrentModificationException
)
발생이 된다.
이런 제약의 적용 범위는 다음과 같다:
- 직접 메서드 호출:
ex) 한 스레드가 list.add(x) 를 하는 중에 다른 스레드가 동시에 list.get(0) 을 실행 Collection
을 다른 메서드에 전달:
전달받은 메서드 안에서 또 다른 스레드가 수정하면 위험함- 기존
Iterator
로 탐색:for (Objevt o : list) { // 탐색 list.remove(0); // 탐색 동시에 수정 -> ConcurrentModificationException }
대부분의 컬렉션 반복자(iterator)는 fail-fast
특성을 가지고 있어,
컬렉션이 구조적으로 수정되면 즉시 ConcurrentModificationException
을 발생시킨다.
이는 컬렉션이 예상치 못한 상태에서 변경되는 것을 방지하기 위한 메커니즘이다.
fail-fast
는 컬렉션을 반복(iterate)하는 도중,
컬렉션이 구조적으로 수정(add, remove 등 요소 개수가 변하는 변경)되면,
즉시 예외(ConcurrentModificationException)를 던져서
잘못된 상태에서 계속 실행되는 걸 막는 메커니즘
따라서 이런 Thread-safety
가 필요할 때,
Collections.synchronizedList()
등의 명시적 동기화가 필요하다.
해결 방법
- Collections.synchronizedXXXXXX():
Collections.synchronizedList(new ArrayList<>())
<- 내부적으로 모든 메서드에
synchronized
블록 을 씌움, 단 반복문, 반복자 사용 시 외부에서explicitly
하게 동기화 필요List<String> list = Collections.synchronizedList(new ArrayList<>()); synchronized (list) { for (String s : list) { // Thread-safety } }
java.util.concurrent
사용ConcurrentHashMap
- 동시 접근 안전CopyOnWriteArrayList
- …
Stream API
Stream
은 데이터 흐름을 다루기 위한 API 이며, Collection
, Array
, I/O 자원
등 다양한 데이터 소스로부터 시퀀스 데이터를 처리하게된다.
SQL
의 선언형 처리 방식 처럼, 데이터에 무엇을 할지 집중 가능- 반복문을 직접 돌리는 대신
filter
,map
,reduce
등을 활용
파이프-필터 패턴
과 비슷하다고 보면 된다. 다음 특징을 가진다:
- 데이터 불변성: 원본 데이터는 변경 안됨
- 일회성: 한 번 소비하면 재서용이 불가하다 즉, 스트림 재생성 필요
- 내부 반복: 개발자가 아닌
Stream
이 내부에서 처리 lazy evaluation
: 중간 연산(map, filter 등등)은 즉시 실행되지 않으며, 최종 연산(sum, collect, 집계 연산)등이 호출될 때 실행된다.- 병렬 지원:
.parallelStream()
으로 멀티코어 활용 가능
BaseStream
Interface BaseStream<T, S extends BaseStream<T, S>>
으로 선언되며, T는 스트림 요소 유형, S는 스트림 구현 유형이다.
메서드
void close()
: 스트림을 닫는 메서드다. 모든handlers
를 닫게 된다.boolean isParallel()
Iterator<T> iterator()
: 이 스트림의 요소에 대한iterator
를 반환S parallel()
: 병렬인 유형이 동등한 스트림을 반환S sequential()
: 순차적인 자료구조의 동등한 스트림을 반환Spliterator<T> spliterator()
: 스트림 요소에 대한 분할기 반환S unorder()
: 순서가 없는 동등한 스트림 반환
여기서 parallel
, sequential
, unorder
을 먼저 보자.
Stream
Stream 은 BaseStream
을 확장하는 인터페이스이다.
깊은 이해는 했으니 활용만 잘하면 된다. 주요 메서드들을 보자.
생성 관련 메서드
Arrays.stream(array)
: 배열을Stream
으로 변환Stream.of()
: 가변인자들을 받아 Stream 을 생성
Functional Interface
@FunctionalInterface
: 추상 메서드를 딱 하나만 가지는 인터페이스이며, 함수형 인터페이스의 규칙을 보장해준다.
@FunctionalInterface
interface MyFunction {
void run(); // 추상 메서드 1개
// default 메서드 여러 개 있어도 됨
default void print() {
System.out.println("default method");
}
}
public class Main {
public static void main(String[] args) {
// 람다로 구현
MyFunction f = () -> System.out.println("Hello Lambda!");
f.run(); // 실행
}
}
위 예제는 FunctionalInterface
를 이용하는 거고, 이 애너테이션인 Predicate
를 설명하기 위해 가져왔다. 함수를 구현할 때 추상 메서드를 넣어야 하며, 위는 run()
이라는 함수를 실행하기 위해 main
스코프에서 정의하는 것을 볼 수 있다.
이 이후에 나오는 것들은 전부 Functional Interface 이다.
Predicate
여기서 Interface Predicate<T>
라는 것은 함수형 인터페이스이기 때문에 람다 표현식이나 메서드 참조에 대한 할당 대상으로 사용해도 된다.
Predicate
인터페이스는 negate
, and
, or
등의 메서드를 통해서 다른 Predicate
와 and
, or
의 논리 연산을 수행하거나, negate
를 통한 단일 논리 연산도 수행가능하다.
이렇게 만들어진 Predicate
가 함수의 인자로 들어가게 되면 내부적으로 Predicate
마다 @FunctionalInterface
의 구현 규칙에 따라 추상 메서드가 정의된 test
함수를 사용하여 맞는지, 틀린지를 보게 된다.
Function
Interface Function<T,R>
도 인터페이스이다. T는 들어가는 인자, R은 return 타입이 뭔지를 말한다.
이 또한 @FunctionalInterface
이므로 R apply(T t)
라는 추상 메서드 하나를 받아야 한다.
andThen(Function<? super R, ? extends V> after)
는 순차 실행을 하는 것이고 this
함수를 먼저 실행 후에 실행된 결과를 after
함수에 전달하게 된다. 반환되는 값도 <V> Function<T,V>
이다.
f.andThen(g) = g(f(x))
compose(Function<? super V, ? extends T> before)
는 before
함수를 먼저 실행하고 그 결과를 this
함수에 전달하게 된다. 반환되는 값도 <V> Function<T,V>
이다.
f.compose(g) = f(g(x))
당연하겠지만 andThen
에 들어가는 인자는 this
의 들어가는 T 를 신경쓰지 않는 것을 볼 수 있다. 또한 compose
에는 인자로 들어가는 함수가 더 먼저 실행되기 때문에 이를 반환하기 위한 T 라는 인자가 안에 들어가는 것을 볼 수 있다.
? 는 와일드카드로 어떤 타입인지 모르지만 제너릭타입을 넣을 자리를 채워야 할 때 쓰는 기호이다. 제한 없는 와일드 카드로 모든 타입이 올 수 있다는 것이다.
매번 저렇게 Comparator
, Function
을 구현하려면 시간 낭비이다. 이를 간소화하기 위해 anonymous function
인 lambda
를 사용하게 된다.
BiFunction
매번 인자를 하나만 받게 되기 보다는 두 개 받을 수 있는 BiFucntion
의 인터페이스까지 있다. 이는 위와 비슷하므로 넘어간다.
Consumer
단일 인수를 받고 결과를 반환하지 않는 함수형 인터페이스이다.
그냥 void
와 같다.
Supplier
Supplier<T>
간단하기에 설명은 생략한다. 공식 문서를 보자. get()
을 통해 특정 함수를 실행하여 그 결과를 get()
을 호출할 때 실행하여 반환한다.
Collectors
Interface Collector<T,A,R>
- T: 축소작업을 위한 입력 유형
- A: 축소작업을 위한 가변적 누적 유형
- R: 축소작업의 결과 유형
Collector
는 입력 요소를 변경 가능한 결과 컨테이너에 누적하는 변경 가능한 축소 연산을 지원한다.
연산이 끝난 놈들을 최종 변환하는 애라고 보면된다. 여기서 집계를 하는 함수나 형태를 변환하는 것도 될 수 있다.
이제 그 연산들을 보자:
주요 4가지 연산
- 새로운 결과 컨테이너 생성(supplier())
- 결과 컨테이너에 새 데이터 요소 통합(accumulator())
- 두 개의 결과 컨테이너를 하나로 결합(combiner())
- 컨테이너에 선택적 최종 변환 수행(finisher())
위 4개를 토대로 언어를 다루기 쉽게 다음 메서드도 추가된다.
- joining()
- mapping() / flatMapping()
- filtering()
- counting()
- minBy() / maxBy()
- summingXXX() / averagingXXX()
- reducing()
- groupingBy() / groupingByConcurrent() / partitioningBy()
- toList() / toMap() / toSet()
- toUnmodifiableXXX(): List, Map, Set 등등
위를 쉽게 다루는 것은 문제를 풀면서 다루면 되지만,
그 핵심을 찌르는 위 4가지 연산을 더 보자.
record CollectorImpl<T, A, R>(
Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Function<A, R> finisher,
Set<Characteristics> characteristics
) implements Collector<T, A, R> {
-
supplier()
: 이전에 봤던 함수를 통해 그 함수의 결과를 제공해주는 아이이다. 이는 result container 와 같은 생성하는 함수를 가지고 있고, 이를 토대로 여기에 하나하나 값들이 관리되게 된다. -
accumulator()
: 핵심적인 역할을 하며, 함수형 인터페이스로 두 개의 인자를 받아서 연산 수행 후 누적 컨테이너에 해당 요소를 통합하는 역할을 한다. 즉,supplier
에accumulator.accept
의 정의된 메서드 대로의 처리 연산을 하여 차곡차곡 넣게 된다.
그렇게 돠면 A 라는 컨테이너에 T 라는 유형의 데이터들이 차곡차곡 들어가게 것과 같다. -
combiner()
: 여러 개의Publisher
에 대해 발생하는 이벤트를 결합하여 새로운 퍼블리셔를 생성하는 데 사용된다. 즉, 복잡한 비동기 이벤트 흐름을 관리하고 여러 데이터 소스를 하나의 흐름으로 통합하기 위해 존재한다.
예시로 A라는 퍼블리셔와 B라는 퍼블리셔가 있다고 치자. 해당 A 와 B 는 어떤 이벤트를 통해 값을 생성해내서 우리에게 제공해준다고 치자. 그럼 이 제공하는 이벤트의 두 진행 상황이 항상 일정하지는 않고 A의 부산물이 없을 때, B 의 부산물이 있을 때도 있고, A 의 부산물이 있을 때, B 의 부산물이 없을 때가 있을 것이다.
이때 퍼블리셔에서 새로운 값들을 방출할 때 다른 퍼블리셔의 값이 없게 된다면 이는 데이터 처리 과정에서 제대로 원하는 값이 처리가 안된 것이다. 따라서 이 비동기 흐름을 관리하기 위한 함수형 인터페이스이다.
좀 어려울 수 있지만 정리하면 다음과 같다:
combiner()
는 여러Publisher
를 결합하여 새로운Publisher
를 생성하는 연산자- 이를 통해 복잡한 비동기 이벤트 흐름을 관리하고, 여러 데이터 소스를 하나의 흐름으로 통합 가능
CombineLatest
와 같은 연산자를 사용하여 여러Publisher
의 값을 결합 가능- 두
Publisher
중 하나가 값을 방출하지 않으면, 결합된 값을 방출하지 않으므로 다른 연산자를 고려할 수도 있음
zip, sink 등이 그 예이다.
finisher()
: 수집된 결과에 적용할 추가적인 변환 함수로, 수집된 데이터를 원하는 형태로 가공하는 데 사용된다.
collect 가 그 예
이 모든 것들을 배우는 이유는 일급 함수를 이용한 Lazy Evaluation 과 Parallelism & Concurrency 최적화, Immutability 를 통한 사이드 이펙트를 줄이며, Modularity 가 좋아진다.
Intermediate Operation
결과가 또 다른 Stream
을 반환하는
즉, Chain 형태로 이어질 수 있는 연산
filter(Predicate)
:Predicate
에 맞는 요소 걸러냄map(Function)
: 변환 (String
->Integer
)flatMap(Function)
: 중첩 스트림을 평탄화함sorted()
/sorted(Comparator)
: 정렬distinct()
: 중복 제거limit(n)
: 앞에서 n개만 가져옴skip(n)
: 앞에서 n개를 건너뜀
Terminal Operation
결과를 값이나 컬렉션으로 반환하여,
Stream
을 소비해 종료하는 연산
forEach()
sum()
,max()
,count()
,min()
,average()
collect()
: 리스트/맵 등으로 변환reduce()
: 누적 연산
Java Stream 종류
- Object Stream
타입: Stream<T>
, Object
타입을 다룸
예시로 Stream<String>
, Stream<Person>
등이 있음
- Primitive Stream
기본형에 대한 Stream
이다.
IntStream
LongStream
DoubleStream
특히 각 기본형 스트림은 summaryStatistics()
를 제공하여
합계, 평균, 최댓값, 최솟값, 개수를 한 번에 구할 수 있다.
int[] arr = {95, 87, 66, 73, 82};
IntSummaryStatistics stats = Arrays.stream(arr).summaryStatistics();
System.out.println(stats.getSum()); // 합계
System.out.println(stats.getAverage()); // 평균
System.out.println(stats.getMax()); // 최댓값
System.out.println(stats.getMin()); // 최솟값
System.out.println(stats.getCount()); // 개수
기타 얻어갈 것들
Deep Copy & Shallow Copy
얕은 복사는 객체 복사에서 객체 자체를 새로 만들긴 하지만 안의 참조 타입 필드들은 원본 객체와 동일한 참조를 공유하게 된다.
즉, 객체 구조는 새로 만들어내도, 내부의 다른 객체들은 공유가 된다. 완벽하게 copy는 되지 않고 일차원적으로 복사가 된 것이다.
깊은 복사는 객체를 복사할 때 객체 뿐 아니라 객체 내부의 참조 객체들 까지도 전부 새로 생성하여 복사가 된다.
즉, 원본과 복사본이 완전히 독립적이게 된다.
다른 복사 유틸리티 함수
private static void copy_() {
int[] arr = {1, 2, 3, 4, 5};
int[] arr1 = new int[10];
System.arraycopy(arr, 0,arr1, 2, 3);
System.out.println(Arrays.toString(arr1));
arr1 = Arrays.copyOf(arr, 3);
System.out.println(Arrays.toString(arr1));
// 4 전까지 copy
arr1 = Arrays.copyOfRange(arr, 2, 4);
System.out.println(Arrays.toString(arr1));
}