본문 바로가기
그룹 스터디 공부(IT 서적)/모던 자바 인 액션

chapter 6 스트림으로 데이터 수집 01

by hanyugyeong 2023. 7. 22.
반응형
SMALL

# 6.1 컬렉터란 무엇인가?
Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

리스트를 만들기위해 toList를 Collector 인터페이스의 구현으로 사용하거나 groupingBy를 이용해서 각 키 버킷에 대응하는 요소 별로 맵을 만들 수도 있다.

## 6.1.1 고급 리듀싱 기능을 수행하는 컬렉터
컬렉터의 최대 강점은 collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이다.

스트림에서 collect를 호출하면 collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 수행한다.
보통 함수를 요소로 변환할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다.

## 6.1.2 미리 정의된 컬렉터
Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 메서드를 제공한다.

Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분된다.
* 스트림 요소를 하나의 값으로 리듀스하고 요약
* 요소 그룹화
* 요소 분할

# 6.2 리듀싱과 요약
컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다.

첫 번째 예제로 counting() 팩토리 메서드가 반환하는 컬렉터를 사용해보자.
```java
long howManyDishs = menu.stream().collect(Collectors.counting());

long howManyDishs = menu.stream().count();
```
## 6.2.1 스트림 값에서 최댓값과 최솟값 검색
Collectors.maxBy, Collectors.minBy 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다.

두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.
```java
Comparator<Dish> dishCaloriesComparator =
        Comparator.comparingInt(Dish::getCalories);

Optional<Dish> mostCaloriesDish = menu.stream().collect(maxBy(dishCaloriesComparator));
```
스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라 부른다.

## 6.2.2 요약 연산
Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다.

summingInt는 객체를 int로 매핑하는 함수를 인수로 받으며, 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

다음은 메뉴 리스트의 총 칼로리를 계산하는 코드다.

```java
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
```
Collectors.summingLong과 Collectors.summingDouble 매서드는 같은 방식으로 동작하며 각각 long 또는 double 형식의 데이터로 요약한다는 점만 다르다.

평균값 계산 등의 연산도 요약 기능으로 제공된다. 즉, Collectors.averagingInt, averagingLong, averagingDouble 등으로 다양한 형식으로 이루어진 숫자 집합의 평균을 계산할 수 있다. 

```java
 double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));
```
이러한 단순 합계 외에 평균값 계산 등의 연산도 요약 기능으로 제공된다.

만약 두개 이상의 연산이 한번에 수행되어야 한다면 summarizingInt가 반환하는 컬렉터를 사용할 수 있다.

```java
 IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
```
결과 
IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}

마찬가지로 int뿐만 아니라 long이나 double에 대응하는 summarizingLong,summarizingDouble 매서드와 관련된 LongSummaryStatistics,DoubleSummaryStatistics 클래스도 있다.

#### 6.2.3 문자열 연결 
컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.
```java
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
```
결과
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon

연결된 두 요소 간에 구분 문자열을 넣을 수 있도록 오버로드된 joining 팩토리 메서드도 있다.

```java
String shortMenu = menu.stream().map(Dish::getName).collect(joining(","));
```
pork,beef,chicken,french fries,rice,season fruit,pizza,prawns,salmon

## 6.2.4 범용 리듀싱 요약 연산 
지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.

예를 들어 다음 코드처럼 reducing 메서드로 만들어진 컬렉터로도 메뉴의 모든 칼로리 합계를 게산할 수 있다.
```java
 int totalCalrories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
```
* 첫 번째 인수는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다. (숫자 합계에서는 인수가 없을 때 반환값으로 0이 적합하다)
* 두 번째 인수는 요리를 칼로리 정수로 변환할 때 사용한 변환 함수다. 
* 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator다. 예제에서는 두 개의 int가 사용되었다. 

한 개의 인수를 가진 reducing 버전을 이용해서 가장 칼로리가 높은 요리를 찾는 방법
```java
Optional<Dish> mostCalorieDish =
        menu.stream().collect(reducing(
            (d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2
        ));
```
한 개의 인수를 갖는 reducing 팩토리 메서드는 세 개의 인수를 갖는 reducing 매서드에서 스트림의 첫 번째 요소를 시작 요소, 즉 첫 번째 인수로 받으며, 자신을 그대로 반환하는 항등 함수를 두전 째 인수로 받는 상황에 해당한다. 

## collect vs reduce
collect와 reduce는 다른 메서드이지만 같은 기능을 구현할 수 있다. 이 메서드들의 차이는 의미론적인 부분과 실용성 부분에서 이야기해볼 수 있다.
```java
Stream<Integer> stream = Arrays.asList(1, 2, 3, 4, 5, 6).stream();

// collect로 만든 리스트
List<Integer> collectedList = stream.collect(toList());

// reduce로 만든 리스트
List<Integer> reducedList = stream.reduce(
                new ArrayList<>(), 
(List<Integer> l, Integer e) -> { // 누적자
                    l.add(e);
                    return l;},
(List<Integer> l1, List<Integer> l2) -> { // 결합자
                    l1.addAll(l2);
                    return l1;});
```
* collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드다. 반면 reduce는 두 값을 하나로 도출하려는 불변형 연산이라는 점에서 의미론적인 문제가 일어난다. → 위 예제에서 reduce 메서드는 누적자로 사용된 리스트를 변환시키므로 reduce를 잘못 활용한 예에 해당
* 여러 스레드가 동시에 같은 데이터 구조체를 고치면 리스트 자체가 망가져버리므로 리듀싱 연산을 병렬로 수행할 수 없다는 실용적인 문제가 일어난다.         

## 컬렉션 프레임워크 유연성: 같은 연산도 다양한 방식으로 수행할 수 있다!

```java
// 1. reduce 메소드만 사용
int totalCalories = menu.stream().collect(reducing(0, //초깃값
                                      Dish::getCalories, //변환함수
                                      Integer::sum //합계함수
                                      ));
// 2. Optional<int> 사용
int totalCalories = menu.stream().map(Dish::getCalories).reduce(Integer::sum).get();
 
// 3. intStream 사용
int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();
```
위의 코드는 totalCalories를 구하는 예제로 결과값은 동일하지만 각각의 특성을 고려하여 사용해보자
* reduce만 사용해서 값을 구할 수 있다.
* map을 사용하여 Optional을 사용해서 예외 처리를 할 수 있다. orElse, orElseGet 등을 이용해서 Optional의 값을 얻어오는 것이 좋다.
* intStream으로 autoboxing을 내부적으로 하지 사용하지 않고 합계를 구할 수 있다.

## 자신의 상황에 맞는 최적의 해법 선택

함수형 프로그래밍(특히 자바8의 컬렉션 프레임워크에 추가된 함수형 원칙에 기반한 새로운 API)에서는 하나의 연산을 다양한 방법으로 해결할 수 있다.

스트림 인터페이스에서 직접 제공하는 메서드를 이용하는 것에 비해 컬렉터를 이용하는 코드가 더 복잡하다는 사실도 보여준다. 

코드가 좀 더 복잡한 대신 제사용성과 커스터마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있다. 


-> 문제를 해결할 수 있는 다양한 해결 방법을 확인한 다음에 가장 일반적으로 문제에 특화된 해결책을 고르는 것이 바람직하다. 이렇게 해서 가독성과 성능이라는 두 마리 토끼를 잡을 수 있다.

예제에서는 IntStream을 사용한 가장 마지막에 확인한 해결 방법이 가독성이 가장 좋고 간결하다. 또한 IntStream 덕분에 자동 언박싱 연산을 수행하거나 Integer를 int로 변환하는 과정을 피할 수 있으므로 성능까지 좋다. 

## 퀴즈 6-1. 리듀싱으로 문자열 연결하기 
```java
String shortMenu = menu.stream().map(Dish::getName).collect(joining());

1. String shortMenu = menu.stream().map(Dish::getName)
                      .collect(reducing((s1,s2)->s1+s2)).get();
2.String shortMenu = menu.stream()
        .collect(reducing((d1,d2)->d1.getName()+d2.getName())).get();
3. String shortMenu = menu.stream().collect(reducing("",Dish::getName,(s1,s2)->s1+s2));
```
1번 3번이 정답이며, 2번은 컴파일되지 않은 코드다.

1. 원래의 joining 컬렉터처럼 각 요리를 요리명으로 변환한 다음에 문자열을 누적자로 사용해서 문자열 스트림을 리듀스하면서 요리명을 하나씩 연결한다. 
2.reducing은 ```BinaryOperator<T>```, 즉 ```BiFunction<T,T,T>```를 인수로 받는다. 즉, reducing은 두 인수를 받아 같은 형식을 반환하는 함수를 인수로 받는다. 하지만 2번 람다 표현식은 두 개의 요리를 인수로 받아 문자열로 반환한다. 
3. 빈 문자열을 포함하는 누적자를 이용해서 리듀싱 과정을 시작하며, 스트림의 요리를 방문하면서 각 요리를 요리명으로 변환한 다음에 누적자로 추가한다.이전에도 설명한 것처럼 세 개의 인수를 갖는 reducing은 누적자 초깃값을 설정할 수 있으므로 Optional을 반환할 필요가 없다.

* 범용 reducing으로 joining을 구현할 수 있음을 보여주는 예제이며 실무에서는 joining을 사용하는 것이 가독성과 성능에 좋다.


## 그룹화 
데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다. 명령형으로 그룹화하려면 까다롭고, 에러도 많이 발생하지만, 자바 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다.

```java
Map<Dish.Type,List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
```
스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 매서드로 전달했다. 이 함수를 기준으로 스트림이 그룹화되므로 이를 **분류함수** 라고 부른다.
각 키에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 맵이 반환된다. 메뉴 그룹화 예제에서 키는 요리 종류고, 값은 해당 종류에 포함되는 모든 요리다.

단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.

```java
 Map<CaloricLevel,List<Dish>> dishesByCaloricLevel = menu.stream().collect(
          groupingBy(dish -> {
              if(dish.getCalories() <= 400) return CaloricLevel.DIET;
              else if(dish.getCalories()<=700) return CaloricLevel.NORMAL;
              else return CaloricLevel.FAT;
          }));
```
```
{DIET=[chicken, rice, season fruit, prawns], NORMAL=[beef, french fries, pizza, salmon], FAT=[pork]}
```
## 그룹화된 요소 조작 
### Collectors.filtering()
* 그룹화를 하기 전에 프레디케이트로 필터를 적용해 문제를 해결할 수 있다고 생각할 수 있다. 

```java
Map<Dish.Type, List<Dish>> caloricDishesByType =
        menu.stream().filter(dish->dish.getCalories() > 500)
                    .collect(groupingBy(Dish::getType));
```
```
{OTHER=[french fries, pizza], MEAT=[pork, beef]}
```
위 코드의 문제점은 filter의 프레디케이트를 만족하는 요소가 없는 경우에는 Map에 해당 Key가 자체가 사라진다는 것!

Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결한다.

```java
Map<Dish.Type, List<Dish>> caloricDishesByType =
        menu.stream().collect(groupingBy(Dish::getType,
                                filtering(dish -> dish.getCalories()> 500, toList())));
```
```
{FISH=[], MEAT=[pork, beef], OTHER=[french fries, pizza]}
```
 filteirng 메소드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 프레디케이트를 인수로 받는다. (java9에 등장) 이 프레디케이트로 각 그룹의 요소와 필터링 된 요소를 재그룹화 한다. 목록이 비어있는 key도 추가가 된다.
 
### Collectors.mapping()
그룹화된 항목을 조작하는 다른 유용한 기능 중 또 다른 하나로 매핑 함수를 이용해 요소를 변환하는 작업이 있다. filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.

```java
Map<Dish.Type,List<String>> dishNamesByType =
        menu.stream()
            .collect(groupingBy(Dish::getType,mapping(Dish::getName, toList())));
```
→ 결과 맵의 각 그룹은 Dish가 아니라 String 리스트다.
##### Collectors.flatMapping()
groupingBy와 연계해 flatMapping 컬렉터를 사용해서 일반 맵이 아닌 flatMap 변환을 수행할 수 있다.
```java
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 컬렉터를 이용하면 각 형식의 요리의 태그를 간편하게 추출할 수 있다.

```java
Map<Dish.Type, Set<String>> dishNamesByType = menu.stream()
       .collect(groupingBy(Dish::getType,
           flatMapping(dish -> dishTags.get(dish.getName()).stream(),toSet())));
```
```
{OTHER=[salty, greasy, natural, light, tasty, fresh, fried], FISH=[roasted, tasty, fresh, delicious], MEAT=[salty, greasy, roasted, fried, crisp]}
```
→ 두 수준의 리스트를 한 수준으로 평면화하기 위해 flatMapping을 이용. flatMapping 연산 결과를 수집해서 리스트가 아니라 집합으로 그룹화해 중복 태그를 제거함.

## 다수준 그룹화
그룹화하는데 두 가지 이상의 기준을 동시에 효과적으로 조합할 수 있다는 것이 그룹화의 장점이다. 두 인수(일반적인 분류 함수, 컬렉터)를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화할 수 있다.

바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.

```java
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;
              })
          )
     );
```
그룹화의 결과 
```
{OTHER={DIET=[rice, season fruit], NORMAL=[french fries, pizza]}, FISH={DIET=[prawns], NORMAL=[salmon]}, MEAT={DIET=[chicken], FAT=[pork], NORMAL=[beef]}}
```
그룹화의 결과로 두 수준의 맵이 만들어진다.

* 외부 맵은 첫 번째 수준의 분류 함수에서 분류한 키값을 갖는다. → Dish.Type
* 그리고 외부 맵의 값은 두 번째 수준의 분류 함수의 기준을 키값으로 갖는다. → CaloricLevel
* 최종적으로 두 수준의 맵은 첫 번째 키와 두 번째 키의 기준에 부합하는 요소 리스트를 값으로 갖는다.

n수준 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 된다.

보통 groupingBy의 연산을 ‘버킷(bucket, 물건을 담을 수 있는 양동이)’ 개념으로 생각하면 쉽다.

## 서브그룹으로 데이터 수집 : Collectors.groupingBy(f, XXX)
앞절에서 첫번째(바깥쪽) groupingBy에는 또다른 groupingBy를 넘겨주었다. 사실, groupingBy에 두 번째 인자로 들어오는 컬렉터 형식은 제한이 없다. 또한, 분류 함수 한 개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축약형이다.

## counting()

```java
// groupingBy의 두번째 인수로 counting 컬렉터를 전달해서 메뉴에서 요리의 수를 종류별로 계산
Map<Dish.Type, Long> typesCount = menu.stream().collect(
          groupingBy(Dish::getType, counting()));
```
결과
```
{OTHER=4, FISH=2, MEAT=3}
```
## maxBy()

```java
// 요리의 종류를 분류하는 컬렉터로 메뉴에서 가장 높은 칼로리를 가진 요리를 찾는 코드
      Map<Dish.Type, Optional<Dish>> mostCaloricByType = 
          menu.stream()
          .collect(groupingBy(Dish::getType,
                            maxBy(Comparator.comparingInt(Dish::getCalories))));
```
결과 
```
{FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}
```
## 컬렉터 결과를 다른 형식에 적용 : Collectors.collectingAndThen()
즉, 팩토리 메서드 Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.
```java
 //각 서브그룹에서 가장 칼로리가 높은 요리 찾기
      Map<Dish.Type, Dish> mostCaloricByType =
          menu.stream()
                .collect(groupingBy(Dish::getType, //분류함수
                    collectingAndThen(
                        maxBy(Comparator.comparingInt(Dish::getCalories)), //감싸인 컬렉터
                        Optional::get))); //변환함수
```
```
{OTHER=pizza, FISH=salmon, MEAT=pork}
```
팩토리 메서드 collectingAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다. 반환되는 컬렉터는 기존 컬렉터의 래퍼 역활을 하며 collect의 마지막 과정에서 변환함수로 자신이 반환하는 값을 매핑한다.

## 중첩 컬렉터의 작동 과정
* groupingBy는 가장 바깥쪽에 위치하면서 요리의 종류에 따라 메뉴 스트림을 세 개의 서브스트림으로 그룹화한다.
* groupingBy 컬렉터는 collectingAndThen 컬렉터를 감싼다. 따라서 두 번째 컬렉터는 그룹화된 세 개의 서브스트림에 적용된다.
* collectingAndThen 컬렉터는 세 번째 컬렉터 maxBy를 감싼다
* 리듀싱 컬렉터가 서브스트림에 연산을 수행한 결과에 collectingAndthen의 Optional::get 변환 함수가 적용된다.
* groupingBy 컬렉터가 반환하는 맵의 분류 키에 대응하는 세 값이 각각의 요리 형식에서 가장 높은 칼로리다.

## groupingBy와 함께 사용하는 다른 컬렉터 예제 
일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 사용한다.

### summingInt()
모든 요리의 칼로리 합계를 구하려고 만든 컬렉터를 재사용할 수 있다.
```java
//각 서브그룹에서 가장 칼로리가 높은 요리 찾기
Map<Dish.Type, Integer> totalCaloriecByType =
     menu.stream().collect(groupingBy(Dish::getType,
     summingInt(Dish::getCalories)));
```
```
{MEAT=1900, FISH=850, OTHER=1550}
```
### mapping()
mapping 메서드는 스트림의 인수를 변환하는 함수와 변환 함수의 결과 객체를 누적하는 컬렉터를 인수로 받는다. mapping은 입력 요소를 누적하기 전에 매핑 함수를 적용해서 다양한 형식의 객체를 주어진 형식의 컬렉터에 맞게 변환하는 역할을 한다.

```java
//각 요리 형식에 존재하는 모든 CaloricLevel값을 알고 싶다
      Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
          menu.stream().collect(
              groupingBy(Dish::getType,mapping(dish -> {
                  if(dish.getCalories() <= 400) return CaloricLevel.DIET;
                  else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                  else return CaloricLevel.FAT;
              },toSet())));
```
CaloricLevel 결과 스트림은 toSet 컬렉터로 전달되면서 리스트가 아닌 집합으로 스트림의 요소가 누적된다(따라서 중복된 값은 저장되지 않는다)
```
{OTHER=[NORMAL, DIET], FISH=[NORMAL, DIET], MEAT=[NORMAL, DIET, FAT]}
```
toCollection을 이용하면 원하는 방식으로 결과를 제어할 수 있다. 예를 들어 다음처럼 매서드 참조 HashSet::new를 toCollection에 전달할 수 있다. 

```java
//각 요리 형식에 존재하는 모든 CaloricLevel값을 알고 싶다
      Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
          menu.stream().collect(
              groupingBy(Dish::getType,mapping(dish -> {
                  if(dish.getCalories() <= 400) return CaloricLevel.DIET;
                  else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                  else return CaloricLevel.FAT;
              },toCollection(HashSet::new))));
```
결과값
```
{FISH=[NORMAL, DIET], OTHER=[NORMAL, DIET], MEAT=[FAT, NORMAL, DIET]}
```

반응형
LIST