본문 바로가기
Java/이것이 자바다

스트림과 병렬 처리

by k-mozzi 2023. 5. 7.
반응형
Preface

 

이번 장의 제목에 병렬 처리도 포함되어 있지만, 간단한 개념만 소개할 뿐 대부분은 스트림에 관한 내용이었다.

 

스트림의 개념과 사용 방법 등 다양한 내용을 공부했는데, 람다식과 제네릭, 참조 등 앞 장 내용에 관한 지식이 요구되는 부분이 많았다.

 

어찌저찌 이해는 할 수 있었지만, 아무래도 해당 내용들을 직접 코드에 적용하기 위해선 많은 복습과 추가적인 공부가 필요할 듯하다.

 

1권은 자바의 기본적인 사용 방법에 관한 내용들이었다면, 2권에선 조금 더 딥하고 전문적인 내용을 소개하는 것 같다.

 

2권의 첫 장인 멀티 스레드에 관한 글을 업로드하며 말했듯 모든 내용을 완벽히 이해하려고 시간을 쓰며 스트레스를 받기보단 대충 이런 내용이 있구나 정도의 느낌만 얻은 뒤 필요할 때 직접 나만의 코드를 작성해보며 경험해보는 것이 좋을 것 같다.

 

 

 

물론 책을 모두 마친 후 2권의 전체적인 복습은 반드시 필요할 듯.


 

1. 스트림 소개

 

 

- 스트림(stream): 컬렉션의 저장 요소를 하나씩 참조해서 람다식으로 처리할 수 있도록 해주는 반복자

1) stream( ) 메소드로 스트림 객체를 얻은 후 stream.forEach(변수 -> System.out.println(변수)); 메소드를 통해 컬렉션 요소를 하나씩 콘솔에 출력한다.

2) forEach( ) 메소드는 Consumer 함수적 인터페이스 타입(매개값 O, 리턴값 X)의 매개값을 가지므로 람다식으로 기술할 수 있다.

package ch16;

import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Stream;

public class IteratorVSStreamExample {

	public static void main(String[] args) {

		List<String> list = Arrays.asList("Kim", "Park", "Lee");

		// Iterator
		Iterator<String> iterator = list.iterator();
		while (iterator.hasNext()) {
			String name = iterator.next();
			System.out.println(name);
		}

		System.out.println();

		// Stream
		Stream<String> stream = list.stream();
		stream.forEach(name -> System.out.println(name));

	}

}

 

 

- 스트림의 특징

1) 람다식으로 요소 처리 코드를 제공한다.

2) 내부 반복자를 사용하므로 병렬 처리가 쉽다.

3) 중간 처리와 최종 처리 작업을 수행한다.

 

 

- Stream이 제공하는 대부분의 요소 처리 메소드는 함수적 인터페이스 타입을 가지므로 람다식 또는 메소드 참조를 통해 요소 처리 내용을 매개값으로 전달할 수 있다.

package ch16;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

class Student {
	private String name;
	private int score;

	public Student(String name, int score) {
		this.name = name;
		this.score = score;
	}

	public String getName() {
		return name;
	}

	public int getScore() {
		return score;
	}
}

public class LambdaExpressionsExample {

	public static void main(String[] args) {

		List<Student> list = Arrays.asList(new Student("Kim", 90), new Student("Park", 92));

		Stream<Student> stream = list.stream();
		stream.forEach(s -> {
			String name = s.getName();
			int score = s.getScore();
			System.out.println(name + "-" + score);
		});
	}

}

 

 

- 외부 반복자: 개발자가 코드로 직접 컬렉션의 요소를 반복해서 가져오는 코드 패턴

→ index를 사용하는 for문, Iterator를 이용하는 while문 등

 

 

- 내부 반복자: 컬렉션 내부에서 요소들을 반복시키고, 개발자는 요소당 처리해야 할 코드만 제공하는 코드 패턴

1) 코드가 간결해진다.

2) 요소의 병렬 처리가 컬렉션 내부에서 처리된다.

 

 

- 병렬(parallel) 처리: 한 가지 작업을 서브 작업으로 나눈 후 서브 작업들을 분리된 스레드에서 병렬적으로 처리하는 것

→ 병렬 처리 스트림을 이용하면 런타임 시 하나의 작업을 서브 작업으로 자동으로 나눈 후 서브 작업의 결과를 자동으로 결합해 최종 결과물을 생성한다.

package ch16;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class ParallelExample {

	public static void main(String[] args) {

		List<String> list = Arrays.asList("A", "B", "C", "D", "E");

		// 순차처리
		Stream<String> stream = list.stream();
		stream.forEach(s -> ParallelExample.print(s));	//람다식 이용
		System.out.println();

		// 병렬처리
		Stream<String> parallelStream = list.parallelStream();
		parallelStream.forEach(ParallelExample::print);	//메소드 참조 이용
	}

	public static void print(String str) {
		System.out.println(str + ": " + Thread.currentThread().getName());
	}
}

 

 

- 스트림의 중간 처리와 최종 처리

1) 중간 처리: 매핑, 필터링, 정렬 등을 수행

2) 최종 처리: 반복, 카운팅, 평균, 총합 등의 집계 처리를 수행

 


 

2. 스트림의 종류

 

 

- BaseStream 인터페이스: 모든 스트림에서 사용할 수 있는 공통 메소드들이 정의되어 있을 뿐 코드에서 직접 사용되지는 않는다.

1) Stream: 객체 요소를 처리하는 스트림

2) IntStream, LongStream, DoubleStream: 기본 타입인 int, long, double 요소를 처리하는 스트림

 

 

- 배열로부터 스트림 얻기

package ch16;

import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class FromArrayExample {

	public static void main(String[] args) {

		String strArray[] = { "Kim", "Park", "Lee" };
		Stream<String> strStream = Arrays.stream(strArray);
		strStream.forEach(a -> System.out.print(a + ","));
		System.out.println();

		int intArray[] = { 1, 2, 3, 4, 5 };
		IntStream intStream = Arrays.stream(intArray);
		intStream.forEach(a -> System.out.print(a + ","));
		System.out.println();
	}

}

 

 

- 숫자 범위로부터 스트림 얻기

1) rangeClosed( ) 메소드:  첫 번쨰 매개값에서부터 두 번째 매개값까지 순차적으로 제공하는 IntStream을 리턴

2) range( ) 메소드:  rangeClosed( ) 메소드와 동일한 IntStream을 리턴하지만, 두 번째 매개값은 포함되지 않는다.

package ch16;

import java.util.stream.IntStream;

public class FromIntRangeExample {

	public static int sum;

	public static void main(String[] args) {

//		IntStream stream = IntStream.rangeClosed(1, 100);
//		stream.forEach(new IntConsumer() {
//		    @Override
//		    public void accept(int a) {
//		        sum += a;
//		    }
//		});
//		System.out.println("total: " + sum);

		IntStream stream = IntStream.rangeClosed(1, 100);
		stream.forEach(a -> sum += a);
		System.out.println("total: " + sum);
	}

}

 


 

3. 스트림 파이프라인

 

 

- 리덕션(Reduction): 대량의 데이터를 가공해서 축소하는 것

1) 데이터의 합계, 평균값, 카운팅, 최대·최소값 등

2) 컬렉션의 요소를 리덕션의 결과물로 바로 집계할 수 없을 땐 중간 처리가 필요하다.

 

 

- 파이프라인: 여러 개의 스트림이 연결되어 있는 구조

1) 최종 처리를 제외한 스트림은 모두 중간 처리 스트림이다.

2) 중간 처리 스트림은 최종 처리가 시작되기 전까지 지연된다.

 

 

- 중간 처리 메소드는 중간 처리된 스트림을 리턴하며, 해당 스트림에서 다시 중간 처리 메소드를 호출하여 파이프라인을 형성한다.

package ch16;

import java.util.Arrays;
import java.util.List;

class Member {
	public static int MALE = 0;
	public static int FEMALE = -1;

	private String name;
	private int sex;
	private int age;

	public Member(String name, int sex, int age) {
		this.name = name;
		this.sex = sex;
		this.age = age;
	}

	public int getSex() {
		return sex;
	}

	public int getAge() {
		return age;
	}
}

public class StreamPipelinesExample {

	public static void main(String[] args) {

		List<Member> list = Arrays.asList(new Member("Kim", Member.MALE, 30), new Member("Park", Member.FEMALE, 20),
				new Member("Lee", Member.MALE, 45), new Member("Cha", Member.FEMALE, 27));

		double ageAvg = list.stream().filter(m -> m.getSex() == Member.MALE).mapToInt(Member::getAge).average()
				.getAsDouble();

		System.out.println("남자 평균 나이: " + ageAvg);
	}

}

 

 

- 리턴 타입이 스트림이라면 중간 처리 메소드, 기본 타입이거나 OptionalXXX라면 최종 처리 메소드이다.

 


 

4. 필터링

 

 

- 필터링: 중간 처리 기능으로 요소를 걸러내는 역할을 한다.

 

 

- distinct( ) 메소드: 중복을 제거

1) Stream의 경우: Object.equals(Object)가 true이면 동일한 객체로 판단하고 중복을 제거

2) IntStream, LongStream, DoubleStream의 경우: 동일값일 경우 중복을 제거

 

 

- filter( ) 메소드: 매개값으로 주어진 Predicate가 true를 리턴하는 요소만 필터링

package ch16;

import java.util.Arrays;
import java.util.List;

public class FilteringExample {

	public static void main(String[] args) {

		List<String> names = Arrays.asList("홍길동", "신용권", "감자바", "신용권", "신민철");

		names.stream().distinct().forEach(n -> System.out.println(n));
		System.out.println();

		names.stream().filter(n -> n.startsWith("신")).forEach(n -> System.out.println(n));
		System.out.println();

		names.stream().distinct().filter(n -> n.startsWith("신")).forEach(n -> System.out.println(n));

	}

}

 


 

5. 매핑

 

 

- 매핑: 중간 처리 기능으로 스트림의 요소를 다른 요소로 대체하는 작업

 

 

- flatMapXXX( ) 메소드: 요소를 대체하는 복수 개의 요소들로 구성된 새로운 스트림을 리턴

package ch16;

import java.util.Arrays;
import java.util.List;

public class FlatMapExample {

	public static void main(String[] args) {

		List<String> inputList1 = Arrays.asList("java8 lambda", "stream ampping");
		inputList1.stream().flatMap(data -> Arrays.stream(data.split(" "))).forEach(word -> System.out.println(word));

		System.out.println();

		List<String> inputList2 = Arrays.asList("10, 20, 30", "40, 50, 60");
		inputList2.stream().flatMapToInt(data -> {
			String strArr[] = data.split(",");
			int intArr[] = new int[strArr.length];
			for (int i = 0; i < strArr.length; i++) {
				intArr[i] = Integer.parseInt(strArr[i].trim());
			}
			return Arrays.stream(intArr);
		}).forEach(number -> System.out.println(number));
	}

}

 

 

- mapXXX( ) 메소드: 요소를 대체하는 요소로 구성된 새로운 스트림을 리턴

package ch16;

import java.util.Arrays;
import java.util.List;

public class MapExample {

	public static void main(String[] args) {

		List<Student> studentList = Arrays.asList(new Student("Kim", 10), new Student("Park", 20),
				new Student("Lee", 30));

		studentList.stream().mapToInt(Student::getScore).forEach(score -> System.out.println(score));
	}

}

 

 

- asDoubleStream( ) 메소드: IntStream의 int 또는 LongStream의 long 요소를 double 요소로 타입 변환해서 DoubleStream을 생성

 

 

- asLongStream( ) 메소드: IntStream의 int 요소를 long 요소로 타입 변환해서 LongStream을 생성

 

 

- boxed( ) 메소드: int, long, double 요소를 Integer, Long, Double 요소로 박싱해서 Stream을 생성

package ch16;

import java.util.Arrays;
import java.util.stream.IntStream;

public class AsDoubleStreamAndBoxedExample {

	public static void main(String[] args) {

		int intArr[] = { 1, 2, 3, 4, 5 };

		IntStream intStream = Arrays.stream(intArr);
		intStream.asDoubleStream().forEach(d -> System.out.println(d));

		System.out.println();

		intStream = Arrays.stream(intArr);
		intStream.boxed().forEach(obj -> System.out.println(obj.intValue()));
	}

}

 


 

6. 정렬

 

 

- 스트림은 요소가 최종 처리되기 전에 중간 단계에서 요소를 정렬해서 최종 처리 순서를 변경할 수 있다.

 

 

- 객체 요소일 경우에는 클래스가 Comparable을 구현하지 않으면 sorted( ) 메소드를 호출했을 때 ClassCastException이 발생하므로 Comparable을 구현한 요소에서만 sorted( ) 메소드를 호출해야 한다.

→ 사용자 정의 클래스에서도 Comparable을 구현하면 자동 정렬이 가능하다.

 

 

- 객체 요소가 Comparable을 구현한  상태에서 기본 비교 방법으로 정렬

sorted();

sorted( (a,b) -> a.compareTo(b) );

sorted( Comparator.naturalOrder() );

 

 

- 객체 요소가 Comparable을 구현하고 있지만, 기본 비교 방법과 정반대 방법으로 정렬

sorted( (a,b) -> b.compareTo(a) );

sorted( Comparator.reverseOrder() );

 

 

- 객체 요소가 Comparable을 구현하지 않은 경우엔 Comparator을 매개값으로 갖는 sorted( ) 메소드를 사용하면 된다.

sorted( (a,b) -> { ... } );
//중괄호 안에는 compare() 메소드와 같은 리턴값을 갖도록 코드를 작성

 

package ch16;

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.IntStream;

class Student1 implements Comparable<Student1> {
	private String name;
	private int score;

	public Student1(String name, int score) {
		this.name = name;
		this.score = score;
	}

	public String getName() {
		return name;
	}

	public int getScore() {
		return score;
	}

	@Override
	public int compareTo(Student1 o) {
		return Integer.compare(score, o.score);
	}
}

public class SortingExample {

	public static void main(String[] args) {

		// 숫자 요소일 경우
		IntStream intStream = Arrays.stream(new int[] { 5, 3, 2, 1, 4 });

		intStream.sorted().forEach(n -> System.out.print(n + ","));
		System.out.println();

		// 객체 요소일 경우
		List<Student1> studentList = Arrays.asList(new Student1("Kim", 30), new Student1("Park", 10),
				new Student1("Lee", 20));

		studentList.stream().sorted().forEach(s -> System.out.print(s.getScore() + ","));
		System.out.println();

		studentList.stream().sorted(Comparator.reverseOrder()).forEach(s -> System.out.print(s.getScore() + ","));

	}

}

 


 

7. 루핑

 

 

- 루핑: 요소 전체를 반복하는 것

 

 

- peek( ) 메소드: 중간 처리 단계에서 전체 요소를 루핑하면서 추가적인 작업을 하기 위해 사용한다.

→ 최종 처리 메소드가 호출되어야 동작한다.

 

 

- forEach( ) 메소드: 요소를 소비하는 최종 처리 메소드이므로 이후에 다른 최종 메소드를 호출할 수 없다.

 


 

8. 매칭

 

 

- 매칭 메소드: 최종 처리 단계에서 요소들이 특정 조건에 만족하는지 조사하는 역할

 

 

- allMatch( ) 메소드: 모든 요소들이 매개값으로 주어진 Predicate의 조건을 만족하는지 조사

 

 

- anyMatch( ) 메소드: 퇴소한 한 개의 요소가 조건을 만족하는지 조사

 

 

- noneMatch( ) 메소드: 모든 요소들이 매개값으로 주어진 조건을 만족하지 않는지 조사

package ch16;

import java.util.Arrays;

public class MatchExample {

	public static void main(String[] args) {

		int intArr[] = { 2, 4, 6 };

		boolean result = Arrays.stream(intArr).allMatch(a -> a % 2 == 0);
		System.out.println("모두 2의 배수인가?" + result);

		result = Arrays.stream(intArr).anyMatch(a -> a % 3 == 0);
		System.out.println("하나라도 3의 배수가 있는가?" + result);

		result = Arrays.stream(intArr).noneMatch(a -> a % 3 == 0);
		System.out.println("3의 배수가 없는가?" + result);
	}

}

 


 

9. 기본 집계

 

 

- 집계(Aggregate): 최종 처리 기능으로 요소들을 처리해서 하나의 값으로 산출하는 것

→ 리덕션이라고 볼 수 있다.

 

 

- 집계 메소드의 종류

리턴 타입 메소드 설명
long count( ) 요소 개수
OptionalXXX findFirst( ) 첫 번째 요소
Optional<T>
OptionalXXX
max(Comparator<T>)
max( )
최대 요소
Optional<T>
OPtionalXXX
min(Comparator<T>)
min( )
최소 요소
OptionalDouble average( ) 요소 평균
int, long, double sum( ) 요소 총합

 

 

- OptionalXXX: java.util 패키지의 Optional, OptioanlDouble, OptionalInt, OptionalLong 클래스 타입

→ 값을 얻기 위해선 get( ), getAsXXX( )를 호출하면 된다.

 

 

- Optional 클래스: 단순히 집계 값만 저장하는 것이 아니라 집계 값이 존재하지 않을 경우 디폴트 값을 설정하거나 집계 값을 처리하는 Consumer(매개값 O, 리턴값 X: accept( ) 메소드)도 등록할 수 있다.

 

 

- Optional 클래스의 메소드

리턴 타입 메소드 설명
boolean isPresent( ) 값이 저장되어 있는지 여부
T
double
int
long
orElse(XXX) 값이 저장되어 있지 않을 경우 디폴트 값 지정
void ifPresent(XXXConsumer) 값이 저장되어 있을 경우 Consumer에서 처리

 

 

- 요소가 없을 경우 NoSuchElementException 예외를 피하는 방법

1) Optional 객체를 얻어 isPresent( ) 메소드로 평균값 여부를 확인

2) orElse( ) 메소드로 디폴트 값을 세팅

3) ifPresent( ) 메소드로 평균 값이 있을 경우에만 값을 이용하는 람다식 실행

package ch16;

import java.util.ArrayList;
import java.util.List;
import java.util.OptionalDouble;

public class OptionalExample {

	public static void main(String[] args) {

		List<Integer> list = new ArrayList<>();

		OptionalDouble optional = list.stream().mapToInt(Integer::intValue).average();
		if (optional.isPresent()) {
			System.out.println("방법1_평균: " + optional.getAsDouble());
		} else {
			System.out.println("방법1_평균: 0.0");
		}

		double avg = list.stream().mapToInt(Integer::intValue).average().orElse(0.0);
		System.out.println("방법2_평균: " + avg);

		list.stream().mapToInt(Integer::intValue).average().ifPresent(a -> System.out.println("방법3_평균: " + a));
	}

}

 


 

10. 커스텀 집계

 

 

- reduce( ) 메소드: 기본 집계 메소드 대신 다양한 집계 결과물을 만들 수 있는 메소드

1) 각 인터페이스에는 매개 타입으로 XXXOperator, 리턴 타입으로 OptionalXXX, int, long, double을 가지는 reduce( ) 메소드가 오버로딩되어 있다.

2) 스트림에 요소가 없을 경우엔 디폴트 값인 identity 매개값이 리턴된다.

3) XXXOperator 매개값은 집계 처리를 위한 람다식을 대입한다.

int sum = studentList.stream().map(Student :: getScore)
	.reduce((a,b) -> a+b).get();
    
    
int sum = studentList.stream().map(Student :: getScore)
	.reduce(0, (a, b) -> a+b);

 


 

11. 수집

 

 

- collect( ) 메소드: 요소들을 필터링 또는 매핑한 후 요소들을 수집하는 최종 처리 메소드

→ 필요한 요소만 컬렉션으로 담을 수 있고, 요소들을 그룹핑한 후 집계할 수 있다.

 

 

- collect(Collector<T, A, R> collector) 메소드: 필터링 또는 매핑된 요소들을 새로운 컬렉션에 수집한 뒤 컬렉션을 리턴

1) T: 요소

2) A: 누적기(accumulator)

3) R: 요소가 저장될 컬렉션

 

 

- Collectors 클래스의 정적 메소드

메소드 설명
toList( ) T를 List에 저장
toSet( ) T를 Set에 저장
toCollection(Supplier<Collection<T>>) T를 Supplier가 제공한 Collection에 저장
toMap(
Function<T,K> keyMapper,
Function<T,U> valueMapper
)
T를 K와 U로 매핑해서 K를 키로, U를 값으로 Map에 저장
toConcurrentMap(
Function<T,K> keyMapper,
Function<T,U> valueMapper
)
T를 K와 U로 매핑해서 K를 키로, U를 값으로 ConcurrentMap에 저장

 

 

- 값을 사용자 정의 컨테이너 객체에 수집할 수도 있다.

1) 요소를 그룹핑해서 수집할 수도 있다.

2) 요소를 그룹핑 후 매핑 및 집계할 수도 있다.

package ch16;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class Student2 {
	public enum Sex {
		MAlE, FEMALE
	}

	public enum City {
		Seoul, Pusan
	}

	private String name;
	private int score;
	private Sex sex;
	private City city;

	public Student2(String name, int score, Sex sex) {
		this.name = name;
		this.score = score;
		this.sex = sex;
	}

	public Student2(String name, int score, Sex sex, City city) {
		this.name = name;
		this.score = score;
		this.sex = sex;
		this.city = city;
	}

	public String getName() {
		return name;
	}

	public int getScore() {
		return score;
	}

	public Sex getSex() {
		return sex;
	}

	public City getCity() {
		return city;
	}
}

class MaleStudent {
	private List<Student2> list;

	public MaleStudent() {
		list = new ArrayList<Student2>();
		System.out.println("[" + Thread.currentThread().getName() + "] MaleStudent()");
	}

	public void accumulate(Student2 student) {
		list.add(student);
		System.out.println("[" + Thread.currentThread().getName() + "] accumulate()");
	}

	public void combine(MaleStudent other) {
		list.addAll(other.getList());
		System.out.println("[" + Thread.currentThread().getName() + "] combine()");
	}

	public List<Student2> getList() {
		return list;
	}
}

public class MaleStudentExample {

	public static void main(String[] args) {

		List<Student2> totalList = Arrays.asList(new Student2("Kim", 10, Student2.Sex.MAlE),
				new Student2("Park", 6, Student2.Sex.FEMALE), new Student2("Lee", 10, Student2.Sex.MAlE),
				new Student2("Cha", 6, Student2.Sex.FEMALE));

		MaleStudent maleStudent = totalList.stream().filter(s -> s.getSex() == Student2.Sex.MAlE)
				.collect(MaleStudent::new, MaleStudent::accumulate, MaleStudent::combine);

		maleStudent.getList().stream().forEach(s -> System.out.println(s.getName()));
	}

}

 


 

12. 병렬 처리

 

 

- 병렬 처리(Parallel Operation): 멀티 코어 CPU 환경에서 하나의 작업을 분할해서 각각의 코어가 병렬적으로 처리하는 것

 

 

- 동시성(Concurrency): 멀티 작업을 위해 멀티 스레드가 번갈아가며 실행하는 성질

 

 

- 병렬성(Parallelism): 멀티 작업을 위해 멀티 코어를 이용해 동시에 실행하는 성질

1) 데이터 병렬성: 전체 데이터를 쪼개 서브 데이터들로 만들고 이들을 병렬 처리해서 작업을 빨리 끝내는 것

→ 하나의 작업

2) 작업 병렬성: 서로 다른 작업을 병렬 처리하는 것

→ 여러 개의 작업

 

 

- 병렬 스트림은 요소들을 병렬 처리하기 위해 포크조인(ForkJoin) 프레임워크를 사용한다.

1) 포크 단계: 전체 데이터를 서브 데이터로 분리

2) 서브 데이터를 멀티 코어에서 병렬로 처리

3) 조인 단계: 서브 결과를 결합해 최종 결과를 생성

4) 스레드풀인 ForkJoinPool을 제공한다.

 

 

- 병렬 처리를 위해 코드에서 포크조인 프레임워크를 직접 사용할 순 있지만, 병렬 스트림을 이요하는 것이 훨씬 간편하다.

1) parallelStream( ) 메소드: 컬렉션으로부터 병렬 스트림을 바로 리턴

2) parallel( ) 메소드: 순차 처리 스트림을 병렬 처리 스트림으로 변환하여 리턴

 

 

- 병렬 처리에 영향을 미치는 요인

1) 요소의 수와 요소당 처리 시간

2) 스트림 소스의 종류

3) 코어의 수

 

728x90
반응형

댓글