컬렉션(Collection), 컬렉터(Collector), collect를 주의하며 학습하자
6.1 컬렉터란 무엇인가?
명령형 프로그래밍과 함수형 프로그래밍의 차이
명령형 프로그래밍
private static void groupImperatively() {
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
for (Transaction transaction : transactions) {
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction);
}
System.out.println(transactionsByCurrencies);
}
함수형 프로그래밍
'무엇'을 원하는지 직접 명시할 수 있어 어떤 방식으로 이를 얻을지는 신경 쓸 필요가 없다.
private static void groupFunctionally() {
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream()
.collect(groupingBy(Transaction::getCurrency));
System.out.println(transactionsByCurrencies);
}
다수준(multilevel)으로 그룹화를 수행할 때 명령어 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러진다.
- 명령어 프로그래밍에서 문제를 해결하는 과정에서 다중 루프와 조건문을 추가하면서 가독성과 유지보수성이 크게 떨어진다.
- 함수형 프로그래밍에서는 필요한 컬렉터를 쉽게 추가할 수 있다.
고급 리듀싱 기능을 수행하는 컬렉터
스트림에 collect를 호출하면 스트림의 요소에(컬렉터로 파라미터화된) 리듀싱 연산이 수행된다.
- 중요한 점은 Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라 스트림에 어떤 리듀싱 연산을 수행할지 결정된다.
Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다.
미리 정의된 컬렉터
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.
- 스트림 요소를 하나의 값으로 리듀스하고 요약
- 요소 그룹화
- 요소 분활
6.2 리듀싱과 요약
Stream.collect 메서드 인수인 Collector로 스트림의 항목을 컬렉션으로 재구성할 수 있다.
- 스트림의 모든 항목을 하나의 결과로 합칠 수 있다.
import static java.util.stream.Collectors.*;
Collector<Object, ?, Long> counting = counting();
Long howManyDishes = menu.stream().collect(counting());
long howManyDishes = menu.stream().count();
counting()이라는 팩토리 메서드가 반환하는 컬렉터로 요리 수를 계산할 수 있다.
스트림값에서 최댓값과 최솟값 검색
Collectors.maxBy, Collectors.minBy 두 개의 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다.
Comparator<Dish> dishCalories = Comparator.comparing(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream()
.collect(Collectors.maxBy(dishCalories));
maxBy,minBy는 스트림의 요소를 비교하는데 사용할 Comparator를 인수로 받는다.
public static <T> Collector<T, ?, Optional<T>>
maxBy(Comparator<? super T> comparator) {
return reducing(BinaryOperator.maxBy(comparator));
}
public static <T> Collector<T, ?, Optional<T>>
minBy(Comparator<? super T> comparator) {
return reducing(BinaryOperator.minBy(comparator));
}
요약 연산
스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용되는데 이러한 연산을 요약(summarization)연산이라 부른다.
Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다.
- summingDouble, summingLong 메서드는 같은 방식으로 동작하며 Long, double 형식의 데이터로 요약한다는 점만 다르다.
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다.
- TointFunction을 인수로 받는다.
public static <T> Collector<T, ?, Integer>
summingInt(ToIntFunction<? super T> mapper) {
return new CollectorImpl<>(
() -> new int[1],
(a, t) -> { a[0] += mapper.applyAsInt(t); },
(a, b) -> { a[0] += b[0]; return a; },
a -> a[0], CH_NOID);
}
summingInt를 사용하면 중간 연산은 사용하지 않고 최종 연산인 collect만으로 total값을 뽑을 수 있으니까 좋아보이는데 반환값을 보면 제네릭에 감싸져 있어서 래퍼클래스인 Integer가 반환이 되서 int로 뽑으면 언박싱 작업이 생긴다.
int sum = menu.stream().mapToInt(Dish::getCalories).sum();
mapToInt으로 숫자형 스트림을 이용하면 박싱작업이 안 일어나며 가독성에서도 더 좋다.
평균값 요약 연산
- 다양한 형식으로 이루어진 숫자 집합의 평균을 계산할 수 있다. (int, long, double)
Double avgCalories = menu.stream().collect(Collectors.averagingInt(Dish::getCalories));
두 개 이상의 요약 연산을 한번에 수행할 때
IntSummaryStatistics menuStatistics =
menu.stream().collect(Collectors.summarizingInt(Dish::getCalories));
System.out.println(menuStatistics);
IntSummaryStatistics 클래스로 모든 정보가 수집된다.
IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}
int, long, double에 대응하는 다양한 클래스들이 있다.
문자열 연결
컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.
String shortMenu = menu.stream().map(Dish::getName).collect(Collectors.joining());
특정 구분자를 이용해서 연결할 수 있다.
String shortMenu = menu.stream().map(Dish::getName).collect(joining(","));
joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다.
public static Collector<CharSequence, ?, String> joining() {
return new CollectorImpl<CharSequence, StringBuilder, String>(
StringBuilder::new, StringBuilder::append,
(r1, r2) -> { r1.append(r2); return r1; },
StringBuilder::toString, CH_NOID);
}
범용 리듀싱 요약 연산
지금까지 살펴본 모든 리듀싱을 Collectors.reducing 팩토리 메서드가 제공하는 범용 리듀싱 컬렉터로 구현할 수 있다.
- 웬만하면 특화된 컬렉터를 사용하자 편의성⬆️ 가독성⬆️
- 물론 상황에 따라서 문제를 해결하기 위해서 사용해도 상관없다.
Integer totalCalorie = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
- 첫 번째 인수: 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때 반환 값(sum에서는 반환값 0이 적절하다.)
- 두 번째 인수: 정수로 변환할 때 사용한 변환 함수
- 세 번째 인수: 정수의 값을 하나의 값으로 더하는 BinaryOperator
//최대값 구하는 로직에서 값이 없을 때 반환값 0은 X
Stream<Object> emptyMenu = Stream.of();
Integer maxValue = emptyMenu.collect(reducing(0, i -> (int)i, (i, j) -> i > j? i : j));
//Optional을 반환하자
Stream<Object> emptyMenu = Stream.of();
Optional<Object> maxValue = emptyMenu.collect(reducing((d1, d2) -> (int) d1 > (int) d2 ? d1 : d2));
reducing의 인자 한 개인 BinaryOperator를 구현할 때 Optional을 반환하는 이유에 대해서는 설명했었다.
menu.stream().map(Dish::getName).collect(reducing((s1,s2)->s1+s2)); // Optional<String>반환
menu.stream().collect(reducing((d1,d2)->d1.getName()+d1.getName())); //컴파일 에러
menu.stream().collect(reducing("",Dish::getName,((s1,s2)->s1+s2))); // Optional<String>반환
BinaryOperator<T>는 T 2개를 받아서 T를 반환한다. (Dish 2개를 받아서 Dish를 반환)
- (Dish,Dish) -> String을 반환하기 때문에 컴파일 에러가 발생한다.
6.3 그룹화
데이터베이스의 Group By처럼 자바 8의 함수형을 사용해서 Collectors.groupingBy를 통해 쉽게 그룹화할 수 있다.
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream().collect(groupingBy(Dish::getType));
Map에 포함된 결과
- 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수(classifcation function)라고 부른다.
- 이름만 출력된건 toString()에 name만 출력하라고 작성했기 때문이다.
칼로리에 따라서 Level을 기준으로 그룹화하고 싶을 때
enum CaloricLevel { DIET, NORMAL, FAT }
필터에 만족하는 FISH 종류 요리가 없어 결과 Map에서 해당 키 자체가 사라진다.
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream()
.filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getType));
분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해 이문제를 해결한다.
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream()
.collect(groupingBy(Dish::getType,
Collectors.filtering(dish -> dish.getCalories() > 500,Collectors.toList())));
- filtering 메서드의 Predicate로 각 그룹의 요소와 필터링 된 요소를 재그룹화 하기 때문에 목록이 비어있는 FISH 항목으로 추가된다.
그룹화된 항목에서 매핑 함수를 이용해 요소를 변환할 수 있다.
Map<Dish.Type, List<String>> dishesByType =
menu.stream()
.collect(groupingBy(Dish::getType,
Collectors.mapping(Dish::getName,Collectors.toList())));
- filtering 컬렉터와 동일한 이유로 Collectors.mapping 메서드를 이용해서 각 요리를 관련 이름 목록으로 변활할 수 있다.
태그 목록을 가진 각 요리로 구성된 맵이 있다.
public static final Map<String, List<String>> dishTags = new HashMap<>();
static {
dishTags.put("pork", asList("greasy", "salty"));
dishTags.put("beef", asList("salty", "roasted"));
dishTags.put("chicken", asList("fried", "crisp"));
dishTags.put("french fries", asList("greasy", "fried"));
dishTags.put("rice", asList("light", "natural"));
dishTags.put("season fruit", asList("fresh", "natural"));
dishTags.put("pizza", asList("tasty", "salty"));
dishTags.put("prawns", asList("tasty", "roasted"));
dishTags.put("salmon", asList("delicious", "fresh"));
}
flatMapping을 이용하면 평면화하여 각 형식의 요리의 태그를 간편하게 추출할 수 있다.
Map<Dish.Type, Set<List<String>>> dishesByType1 = menu.stream().collect(groupingBy(Dish::getType,
mapping(dish -> dishTags.get(dish.getName()), toSet())));
Map<Dish.Type, Set<String>> dishesByType2 = menu.stream().collect(groupingBy(Dish::getType,
flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));
자료구조 Set으로 그룹화하였기 때문에 중복 태그를 제거하여 추출된다.
Collectors.toCollection을 이용하면 원하는 방식으로 결과를 제어할 수 있다. (HashSet, ArrayList등)
- 메서드 참조를 통해서 toCollection에 전달할 수 있다.
Map<Dish.Type, HashSet<String>> dishesByType3 = menu.stream().collect(groupingBy(Dish::getType,
flatMapping(dish -> dishTags.get(dish.getName()).stream(),
Collectors.toCollection(HashSet::new))));
다수준 그룹화
두 가지 이상의 기준을 동시에 적용하여 그룹화 하기
Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 두 번째 인수로 받을 수 있다.
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
menu.stream().collect(
groupingBy(Dish::getType,
groupingBy(dish -> {
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
})
)
);
두 수준의 Map이 만들어 진다.
{
OTHER={DIET=[rice, season fruit], NORMAL=[french fries, pizza]},
FISH={NORMAL=[salmon], DIET=[prawns]},
MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]}
}
groupingBy 연산을 '버킷' 개념으로 생각하면 첫 번째 groupingBy는 각 키의 버킷을 만들고 준비된 각각의 버킷을 서브스트림 컬렉터로 반복해서 채워간다.
칼로리\종류 | OTHER | FISH | MEAT |
DIET | rice, season fruit | prawns | chicken |
NORMAL | french fries, pizza | salmon | beek |
FAT | pork |
groupingBy 두 번째 인수로 다양한 컬렉터를 전달할 수 있다.
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType, maxBy(comparing(Dish::getCalories))));
각 요리의 종류를 기준으로 요리의 수, 가장 높은 칼로리 등을 그룹화 할 수 있다.
Optional<Dish>를 생각해보자
groupingBy는 첫 번째로 자신에게 맞는 value를 찾았을 때 해당하는 key를 추가한다. (Lazy)
- 존재하지 않는 요소의 키는 맵에 추가되지 않는다. (filtering 메서드 사용해야 추가된다.)
Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.
- 마지막 그룹화 연산에서 Map의 모든 값을 Optional로 감쌀 필요가 없다.
Map<Dish.Type, Dish> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType, //분류 함수
Collectors.collectingAndThen(
maxBy(comparing(Dish::getCalories)), // Optional로 감싸있다.
Optional::get))); //Optional 제거(변환 함수)
- 리듀싱 컬렉터는 절대로 Optional.empty()를 반환하지 않기 때문에 안전하게 Optional을 제거할 수 있다.
6.4 분할
분할은 분할 함수(partitioning function)라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다.
- 분할 할수는 boolean을 반환하는 map의 key 형식이다.
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType =
menu.stream().collect(
partitioningBy(Dish::isVegetarian, //분할 함수
groupingBy(Dish::getType))); //두 번째 컬렉터
분할 함수가 반환하는 true, false 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할의 장점이다.
{
false={
FISH=[prawns, salmon],
MEAT=[pork, beef, chicken]},
true= {
OTHER=[french fries, rice, season fruit, pizza]}
}
채식요리와 채식요리가 아닌것들 중에서 collectingAndThen 메서드를 이용해서 Optional이 제거된 요리를 추출할 수 있다.
Map<Boolean, Dish> caloricPartitionedByVegetarian = menu.stream().collect(
partitioningBy(Dish::isVegetarian,
collectingAndThen(minBy(comparingInt(Dish::getCalories)),
Optional::get))); //{false=chicken, true=season fruit}
- partitioningBy에는 Predicate가 요구되기 때문에 Dish::isVegetarian 메서드 참조가 가능하다.
6.5 Collector 인터페이스
Collector 인터페이스는 리듀싱 연산을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다.
- Collector 인터페이스를 직접 구현해서 더 효율적으로 문제를 해결하는 컬렉터를 만드는 방법을 살펴보자.
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
BinaryOperator<A> combiner();
Function<A, R> finisher();
Set<Characteristics> characteristics();
}
- T는 수집될 항목의 제네릭 형식
- A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식
- R은 수집 연산 결과 객체의 형식
Stream의 모든 요소를 List로 수집하는 ToListCollector라는 클래스를 구현해보자
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
supplier 메서드 : 새로운 결과 컨테이너 만들기
supplier 메서드는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수이다.
public Supplier<List<T>> supplier() {
return () -> new ArrayList<T>();
}
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
accumulator 메서드 : 결과 컨테이너에 요소 추가하기
accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다.
- 누적자(스트림의 첫 n-1개 항목을 수집한 상태)와 n번째 요소를 함수에 적용한다 (제네릭 형식도 <A, T>)
public BiConsumer<List<T>, T> accumulator() {
return (list, item) -> list.add(item);
}
public BiConsumer<List<T>, T> accumulator() {
return List::add;
}
finisher 메서드 : 최종 변환값을 결과 컨테이너로 적용하기
finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 반환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야한다.
- 누적자 객체가 이미 최종 결과인 상황도 있다. 이럴경우 finisher함수는 항등 함수를 반환한다.
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
순차 리듀싱 과정의 논리적 순서
- supplier를 통해 누적할 빈 컨테이너를 공급받는다.
- accumulator를 통해 스트림의 요소를 컨테이너에 누적한다.
- 더 이상 누적할 스트림 요소가 없다면 finisher를 통해 최종 변환값을 결과 컨테이너로 적용한다.
combiner 메서드 : 두 결과 컨테이너 병합
combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다
- 스트림의 두 번째 서브파트에서 수집한 항목 리스트를 첫 번째 서브파트 결과 리스트의 뒤에 추가
public BinaryOperator<List<T>> combiner() { //(T , T)->T
return (list1, list2) -> {
list1.addAll(list2);
return list1;
};
}
- 병렬 처리할 때는 자바 7의 포크/조인 프레임워크, Spliterator를 사용한다.
병렬화 리듀싱 과정에서 combiner 메서드 활용
- 스트림을 분할해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 원래 스트림을 재귀적으로 여러 서브 파트로 분할 한다.
- 분할된 서브 파트로 대하여 순차 리듀싱 과정의 변환과정을 처리한다.
- 완료된 서브 파트에 대하여 combiner를 통해 결과 컨테이너를 병합한다.
- combiner를 통해 완성된 최종 컨테이너를 finisher를 통해 결과 컨테이너로 적용한다.
👻 분산된 작업의 크기가 너무 작아지면 병렬 수행의 속도는 순차 수행의 속도보다 느려진다. (병렬 수행의 효과가 상쇄)
- 일반적으로 프로세싱 코어의 개수를 초과하는 병렬 작업은 효율적이지 않다.
characteristics 메서드
characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다.
Collector.Characteristics에서는 스트림을 병렬로 리듀스할 것인지, 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.
enum Characteristics {
CONCURRENT,
UNORDERED,
IDENTITY_FINISH
}
- UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
- CONCURRENT : 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
- IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.
ToList에서는..?
Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}
- ArrayList::new ➡️ supplier
- List::add ➡️ accumulator
- (left, right) -> { left.addAll(right); return left;} ➡️ combiner
toList()는 누적자 객체가 최종 객체라서 변환 과정이 필요하지 않다. (finisher 사용안하는거 같지만...?)
- 인자가 4개인 CollerctorsImpl을 사용한다.
CollectorImpl(Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Set<Characteristics> characteristics) {
this(supplier, accumulator, combiner, castingIdentity(), characteristics);
}
castingIdentity()메서드는 자기 자신을 반환하는 함수 (finisher)
@SuppressWarnings("unchecked")
private static <I, R> Function<I, R> castingIdentity() {
return i -> (R) i;
}
toList()에서는 CH_ID가 characteristics에 들어간다.
static final Set<Collector.Characteristics> CH_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
- CH_ID 내부에는 IDENTITY_FINISH가 들어간다. 즉, 누적하는데 사용하는 리스트가 최종 리스트가 된다는 것이다.
Collectors 클래스에서는 이러한 힌트를 사용해 최적화 하고 있었다.
public final class Collectors {
static final Set<Collector.Characteristics> CH_CONCURRENT_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED,
Collector.Characteristics.IDENTITY_FINISH));
static final Set<Collector.Characteristics> CH_CONCURRENT_NOID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED));
static final Set<Collector.Characteristics> CH_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
static final Set<Collector.Characteristics> CH_UNORDERED_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.UNORDERED,
Collector.Characteristics.IDENTITY_FINISH));
static final Set<Collector.Characteristics> CH_NOID = Collections.emptySet();
static final Set<Collector.Characteristics> CH_UNORDERED_NOID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.UNORDERED));
컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기
ArrayList<Dish> dishes = menu.stream().collect(
ArrayList::new, //발행 supplier
List::add, //누적 accumulator
List::addAll //병합 combiner
);
Stream은 세 함수(발행, 누적, 병합)를 인수로 받는 collect 메서드를 오버로드하며 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.
'책 > 모던 자바 인 액션' 카테고리의 다른 글
8. 컬렉션 API 개선 (0) | 2023.07.28 |
---|---|
7. 병렬 데이터 처리와 성능 (0) | 2023.07.26 |
5. 스트림 활용 (0) | 2023.07.17 |
4. 스트림 소개 (0) | 2023.07.14 |
3. 람다 표현 (0) | 2023.07.14 |