티스토리 뷰

Programming/Java

자바 스트림 (Java Stream)

junojuno 2022. 1. 10. 20:50

1. Stream이란

스트림이란 영어의 뜻처럼 데이터의 연속적인 흐름을 말한다.

스트림이 JDK 1.8부터 등장하여 사용 방법이 다 다른 다양한 Collection Framework들을 표준화 하여 다루는것이 가능하게 해주었다. 스트림은 데이터 소스를 표준화된 방법으로 다루기 위해서 등장 하였고, 안에는 데이터를 다루는데 자주 사용되는 메소드들을 정의해 놓았다. 데이터 소스를 추상화 하여 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었고, 코드의 재사용성이 높아지는 결과를 가져오게 되었다.

예를들어,

String[] strArr = {"Apple", "Banana", "Orange"};
List<String> strList = Arrays.asList(strArr);

//출력
Arrays.sort(strArr);
Collections.sort(strList);
for (String s : strArr) {
	System.out.println(s);
}
for (String s : strList) {
	System.out.println(s);
}

 

Stream<String> stringStream = strList.stream();
Stream<String> stringStream1 = Arrays.stream(strArr);

stringStream.sorted().forEach(System.out::println);
stringStream1.sorted().forEach(System.out::println);

위 코드 둘을 비교해 본다면, 데이터 소스가 서로 다른 List와 String 배열을 정렬하고 출력하는 방법은 완전히 동일한것을 볼 수 있으며, 코드가 간결해지고 이해하기 쉬워짐을 알 수 있다.

 

2. Stream의 특징

(1) Stream은 데이터 소스를 변경하지 않으며 일회용이다.

스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐 변경하지 않는다. 그리고 한번 만들어진 스트림은 Iterator처럼 일회용이다. 최종연산을 통해 스트림이 닫히면 다시 사용 못한다. 

 

(2) Stream은 작업을 내부 반복으로 처리한다.

위의 코드에서 for each문을 forEach() 메소드로 처리할 수 있었던 이유는 '내부 반복'때문이다.

내부 반복이라는 것은 반복문을 메소드 내부에 숨길 수 있다는 것을 의미한다.

forEach()메소드 안에 반복문을 숨긴 것이다. 성능은 비효율적이지만 코드가 간결해진다.

 

(3) Stream의 연산과 지연된 연산

1) Stream의 연산

스트림이 제공하는 다양한 연산을 이용해 복잡한 작업들을 간단히 처리할 수 있는데, 스트림이 제공하는 연산은 중간 연산최종 연산으로 분류 할 수 있다.

중간 연산연산결과가 스트림을 반환하기 때문에 스트림에 연속해서 중간 연산을 할  수있다. (0 ~ n번)

최종 연산연산 결과가 스트림이 아닌 연산으로, 스트림의 요소를 소모하기 때문에 마지막에 단 한번만 사용이 가능하다. (0~1번)

stream.distinct().limit(5).sorted().forEach(System.out::println);

위의 예시에서 distinct(), limit(), sorted()와같은 것이 중간 연산이며, 마지막 스트림을 소모하여 각 스트림의 요소를 출력하는 forEach는 최종연산이다.

 

2) 지연된 연산

스트림에서 연산을 할때 한 가지 중요한 점은, 최종 연산이 수행되기 전까지는 중간 연산이 수행 되지 않는다는 점이다. 위 코드에서 distinct()나 sorted()같은 중간 연산을 호출한다고해도 즉각적인 연산이 시행되는 것이 아니다. 중간 연산을 호출하는 것은 단지 어떤 작업이 수행되어야 하는지를 지정해주는 것일 뿐이다. 최종 연산이 수행 될때야 비로소 스트림의 요소들이 중간 연산이 수행된다.

 

(4) Stream<Integer>와 IntStream

요소의 타입이 T인 스트림은 기본적으로 Stream<T>이지만, 오토박싱과 언박싱으로 인한 비효율을 줄이기 위해 데이터 소스의 요소를 primitive type으로 다루는 스트림을 제공한다. IntStream가 Stream<Integer>보다 효율적이며 유용한 메소드 들이 포함 되어 있다. IntStream, LongStream, DoubleStream이 제공된다.

Stream<Integer>에 숫자 1을 담으면 new Integer(1)이라는 오토박싱과 출력할때의 언박싱의 변환시간을 줄여준다.

 

(5) 병렬 스트림을 지원한다.

스트림으로 데이터를 다룰때의 장점 중 하나가 병렬 처리가 쉽다는 것이다.

디폴트 값으로 .Sequential()로 직렬 스트림이 설정되어 있기에 명시하지 않아도 되지만, .parallel() 메소드를 사용하여 stream을 병렬 스트림으로 만들 수 있다.

 IntStream.range(1,10).sequential().forEach(System.out::print);
 IntStream.range(1,10).parallel().forEach(System.out::print);

출력값을 보면 차이점을 알 수 있다.

 

3. Stream 생성하기

(1) Collection

Collection 인터페이스 안에는 stream()이 정의되어 있다. 따라서 List와 Set등을 구현한 컬렉션 클래스들은 모두 이 메소드로 스트림을 생성 할 수 있다.

Collection Interface안의 stream() 메소드

(2) Array

배열을 통해 스트림을 생성하는 메소드는 1) Stream 인터페이스 안의 of()메소드를 통해서 생성하거나 2) Arrays.stream()메소드를 통해서 생성할 수 있다.

Stream 인터페이서 안의 of 메소드
Arrays 클래스 안의 stream 메소드

String[] strArr = {"a", "b", "c", "d"};
Stream<String> strStream = Stream.of(strArr);
Stream<String> strStream = Arrays.stream(strArr);

위의 Arrays.stream()을 사용하면, 배열에서의 startInclusive, endExclusive argument를 추가로 넘겨 배열의 일부로 stream을 생성할 수도 있다.

 

(3) 특정범위의 정수

IntStream과 LongStream은 다음과 같이 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed()를 가지고 있다.

IntStream intStream = IntStream.range(1, 5); 	//forEach()로 출력값 : 1 2 3 4
IntStream intStream1 = IntStream.rangeClosed(1, 5);	//forEach()로 출력값 : 1 2 3 4 5

 

(4) 랜덤 수 생성

난수를 생성하는데 사용하는 Random 클래스 안에는 다음과 같은 메소드들이 포함되어 스트림을 반환한다.

ints(int begin, int end)

위와같은 메소드인 longs(), doubles() 또한 제공하며, 매개변수가 없을 시에는 크기가 정해지지 않은 무한 스트림을 반환하므로 limit()을 이용해 스트림의 크기에 제한을 주어야 한다. limit()은 스트림의 개수를 지정하는데 사용하며 무한스트림을 유한 스트림으로 만들어 준다.

IntStream intStream = new Random().ints(); //int 범위내에서의 랜덤 무한 스트림 발생
IntStream intStream1 = new Random().ints(5,10); // 5~9 범위 내에서 랜덤 무한 스트림 발생
IntStream intStream2 = new Random().ints(5); // int 범위 내에서의 5개 랜덤 정수 추출

(5) 람다식을 이용해 Stream만들기

스트림 클래스의 iterate()와 generate()는 람다식을 매개변수로 받아 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성한다.

// interate(T seed, UnaryOperator f) 단항 연산자. 하나를 넣으면 결과가 하나 나오는것.
Stream<Integer> integerStream = Stream.iterate(1, n -> n + 2);
integerStream.limit(10).forEach(System.out::println); // 1 3 5 ... 19 

// generate(Supplier s) : 주기만 하는 것. 입력이 없고 출력만 있음. 결과가 독립적.
Stream<Integer> oneStream = Stream.generate(() -> 1);
oneStream.limit(10).forEach(System.out::println); // 1 1 1 .. (10번 출력)

generate()에 정의된 매개변수 타입은 Supplier<T>이므로 매개변수가 없는 람다식만 허용된다. 

또한 한가지 주의할 점은 iterate()와 generate()에 의해 생성된 스트림을 아래와 같이 기본형 타입의 참조변수로 다룰 수없다. 아래에서 볼 수 있듯 리턴 타입이 Stream<T>밖에 없기 때문인것 같다. 굳이 필요하다면 mapToInt()와 같은 메소드를 통해 변환해야 한다.

IntStream evenStream = Stream.generate(0, n -> n+2);	// 에러

(6) 파일과 빈스트림

1) 파일

java.nio.files.Files는 파일을 다루는데 필요한 메소드를 제공하는데, list()는 지정된 디렉토리에 있는 파일의 목록을 소스로하는 스트림을 생성해 반환한다.

File클래스 안의 lines()메소드

위 파일 클래스 안의 lines()메소드를 보면, 디렉토리인 Path를 정해준다면, 파일의 한 행을 요소로 하는 스트림을 생성하는 메소드가 있다. 

 

2) 빈스트림

요소가 하나도 없는 빈 스트림은 Stream.empty()를 통해 생성할 수 있다. null은 exception을 발생할수 있으므로 empty()로 생성하는 것이 나으며 count()를 통해 요소의 개수를 확인해보면 0임을 알 수 있다.

Stream<Object> emptyStream = Stream.empty();
Stream<Object> emptyStream1 = null;

System.out.println(emptyStream.count());
System.out.println(emptyStream1.count());

Null로 설정하면 안되는 이유

3) 두 스트림 연결

Stream.concat()이라는 메소드를 통해서 같은 타입의 두 스트림을 하나로 연결 할 수 있다.

반드시 두 스트림의 요소는 같은 타입이여야 한다.

 

4. Stream의 중간연산

스트림의 연산에는 stream을 반환하여 메소드 체이닝을 통해 여러번 사용할 수 있는 중간 연산과 스트림의 요소를 소모하여 한번만 사용할 수 있는 최종연산이 있음을 위에서 확인할 수 있었다.

 

(1) 스트림 자르기 - skip(), limit()

위에서 무한 스트림의 예시를 볼수 있었다. 무한 스트림을 limit()을 통해 유한 스트림으로 잘라낼 수 있으며, skip()은 매개변수로 받은 수 만큼 요소를 건너 뛴다.

 

(2) 스트림 요소 걸러내기 - filter(), distinct()

distinct()는 스트림에서 중복된 요소를 제거하며, filter()는 주어진 조건에 맞지 않는 요소를 걸러낸다.

Stream의 filter 메소드

filter 메소드 안에는 매개변수로 Predicate를 필요로 하는데, 연산결과가 boolean인 람다식을 사용해도 괜찮다. 또한 필요하다면 다른 조건으로 여러번 사용할 수도 있다.

 

(3) 정렬 - sorted()

스트림을 정렬할 때에는 sorted()를 사용하면 된다.

정렬시에는 정렬 대상과 정렬 기준이 필요한데, 지정된 Comparator로 스트림을 정렬하며, Comparator대신 int값을 반환하는 람다식을 사용하는 것도 가능하다. Comparator를 지정하지 않으면 스트림 요소의 기본 정렬 기준 (Comparable)으로 정렬한다. (Comparable / Comparator 부분 참고)

 

(4) 변환 - map()

스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때 사용하는 것이 map()이다.

매개변수로 T타입을 R타입으로 변환해서 반환하는 함수를 지정해야 한다.

아래 File 스트림에서 파일의 이름만 뽑아서 출력하고 싶을때, 아래와 같이 map()의 매개변수로 파일의 이름을 가지고 오는 함수를 넘기면 된다. 람다식으로 표현하면 (f -> f.getName())

 Stream<File> fileStream = Stream.of(fileArr);
//map()으로 Stream<File>을 Stream<String>으로 변환
Stream<String> fileNameStream = fileStream.map(File::getName);

map()은 중간연산 이므로 아래와 같이 여러 조건에 따라 여러번 스트림을 변환 가능하다.

fileStream.map(File::getName)
                .filter(s -> s.indexOf('.') != -1)  //확장자 없는 것 제외
                .peek(s -> System.out.printf("filename=%s%n",s))    //중간에 확인할수 있음. debugging 용도
                .map(s -> s.substring(s.indexOf('.') + 1))  //확장자만 추출
                .peek(s -> System.out.printf("extension=%s%n",s))
                .map(String::toUpperCase)   //대문자로변환
                .distinct()
                .forEach(System.out::println);

 

* Stream<T> 타입을 기본형 스트림으로 변환 시키는 메소드 

: mapToInt(), mapToDouble,() mapToLong()

기본형 스트림은 average(), max(), min() 과 같은 편리한 메소드를 사용하기 때문에 기본형을 다룰때에는 기본형 스트림을 사용하는 것이 좋다. sum(), average(), max(), min()과 같은 메소드들은 최종연산이기 때문에 스트림이 닫힌다. 

하지만 summaryStatistics()라는 스트림에서 제공하는 메소드를 사용하면 IntSummaryStatistics라는 클래스로 변환하여 주고, 다양한 종류의 메소드를 제공한다.

* 반대로 기본형 스트림을 Stream<T>로 변환할때에는 mapToObj()를, Stream<Integer>로 변환 할 때에는 boxed()를 사용하면 된다.

 

(5) 조회 - peek()

peek()는 스트림의 요소를 소비하지 않는 중간연산 중 하나로 중간 작업결과를 확인할때 쓴다.

디버깅 용도로 사용할 수 있다. filter와 map의 결과를 위의 예시처럼 중간에 확인 가능하다.

 

(6) flatMap() - Stream<T[]>를 Stream<T>로 변환

map()과 거의 같지만 스트림의 스트림을 차원을 바꿔 스트림으로 변환시켜준다.

Stream<String[]> strArrStrm = Stream.of(
	new String[]{"abc", "def", "jkl"},
	new String[]{"ABC", "GHI", "JKL"}
);

예를들어 위의 코드를 각 요소의 문자열들을 합쳐 문자열이 요소인 스트림인 Stream<String>으로 만들려면 어떻게 해야 할까. 일단 스트림의 요소를 변환해야하므로 map()을 사용할 수 있다.

그려면 다음과 같은 결과가 나온다.

Stream<Stream<String>> streamStream = strArrStrm.map(Arrays::stream);

각 배열 단위로 Stream<String>으로 변환 된 것이지 전체가 하나의 스트림으로 변환되지는 못했다.

이럴경우 flatMap()을 사용하면 우리가 원하는 결과를 얻을 수 있다.

Stream<String> strStrm = strArrStrm.flatMap(Arrays::stream);

5. Stream의 최종 연산

스트림의 최종연산은 스트림의 요소를 소모하여 수행하는 연산이므로 스트림이 닫힌다.

 

(1) forEach()

forEach()는 peek()와 달리 스트림의 요소를 소모하는 최종연산이다. 

 

(2) 조건검사 - allMatch(), anyMatch(), noneMatch(), findFirst(), findAny()

스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는지, 일부가 일치하는지, 아니면 어떤 요소도 일치하지 않는지를 확인하는데 사용할 수 있는 메소드 들이다. 이 메소드는 모두 Predicate를 요고하며 boolean을 반환한다.

 

findFirst()는 조건에 일치하는 첫 번째 것을 반환하는데, 주로 filter()와 함께 사용되어 조건에 맞는 스트림 요소가 있는지 확인하는데 사용된다. 순차 스트림에 사용된다.

findAny()는 병렬 스트림에 사용된다.

** findFirst()와 findAny()의 리턴 타입은 Optional<T>이며 스트림 요소가 없을 경우에는 빈 Optional 객체를 반환한다.

(3) 통계 - count(), sum(), average(), max(), min()

IntStream과 같은 기본형 스트림에는 스트림의 요소들에 대한 통계정보를 얻을 수 있다. 기본형 스트림이 아닌 경우 count(), max(), min() 메소드 3개 뿐이다. 내용은 위에서 알아 보았다.

 

(4) reduce()

이름에서 알 수 있듯, 스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과를 반환한다.

Stream 인터페이스의 reduce 메소드

매개변수 타입이 BinaryOperator<T>이기 때문에 처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다. 이 과정에서 스트림 요소를 소모하게 되는 것이다. 

identity는 초기값으로 초기값을 주면 stream요소가 없을때 identity를 반환한다. 또한 초기값과 첫번째 요소로 연산을 시작한다. 반면 초기값 설정을 해주지 않으면 요소가 없는 스트림일 경우 null을 반환 할 수 있기 때문에 identity가 없는 accumulator만 매개변수로 받는 reduce()는 리턴타입이 Optional<T>이다.

accumulator는 수행할 연산을 입력해 주는 것이다. 

위와 같이 매개변수를 3개로 combiner가지 받을 수 있는데, combiner는 병렬 스트림에 의해 처리된 결과를 합칠 때 사용하기 위해 사용하는 것이다. 

또한 연산을 하는 count()와 sum()과 같은 메소드들은 내부적으로 모두 reduce()를 이용해서 작성된 것이다.

reduce()가 중요한 이유는 스트림의 최종연산을 다 reduce()를 이용해 만들기 때문이다.

작동하는 방식은

int a = identity;	// 초기값 a에 저장

for(int b : stream){
	a = a + b; 	// 모든 요소 a에 누적
 }

위와 동일한 것이라고 생각하면 된다.

 

(5) collect()

스트림에서 가장 복잡하면서도 유용하게 사용할 수 있는 최종연산이 collect()이다. 

collect()가 스트림의 요소를 수집하기 위해서 수집하는 방법이 정의된 것이 collector이다.

collector는 Collectors 인터페이스를 구현한 것으로, 직접 구현 할 수도 있고, 미리 작성된 것(Collectors)을 사용할 수도 있다.

Stream의 collect 메소드와 collector
Collector 인터페이스
Collectors 클래스

정리해본다면, collect()는 스트림의 최종연산으로 매개변수로 collector를 필요로 한다.

Collector는 인터페이스로 collector는 이 인터페이스를 구현 해야한다. collect에 필요한 메소드를 정의해 놓았다.

위 Collector<T, A, R>에서 볼수 있듯, T(요소)를 A에 누적한 다음 결과를 R로 반환하는 것이다. (reduce와 동일)

Collector 메소드안에는 supplier, accumulator, combiner, finisher, characteristics가 있는데, supplier는 누적해서 저장할 곳을 제공한다. T를 계속 저장해 finisher에서 R로 최종 변환을 하는 것이다. combiner()는 병렬 작업을 했을때 각각 쓰레드가 작업한 것을 합치는 것으로, 어떻게 합칠지를 구현해야하고, accumulator는 누적하는 법이며, characteristics는 특성을 지정해 줄 수 있다. 다행히도 이러한 모든 메소드를 구현해놓은 클래스가 바로 Collectors 클래스이다.

Collectors 클래스는 클래스로 static 메소드로 미리 작성된 collector를 제공한다. 이것만 잘 가져다 쓰면 된다.

 

* reduce()와 collect()의 차이점 : 둘다 거의 같은 것이지만 reducing은 전체에 대한 것이고, collect()는 그룹에 대한 reducing이 가능하다.

 

1) 스트림을 컬렉션과 배열로 변환 - toList(), toMap(), toCollection(), toArray()

스트림의 모든 요소를 컬렉션에 수집하려면 Collectors 클래스가 제공하는 위의 메소드들을 사용하면 되고, 특정 컬렉션을 지정하려면(Arraylist와 같은) toCollection(ArrayList::new)와 같이 컬렉션의 생성자 참조를 매개변수로 넣어주면 된다.

스트림을 배열로 반환하는 toArray()는 Collectors 메소드를 쓰는것이 아니라 stream의 메소드를 쓰는 것이다.

Student[] stuNames = studentStream.toArray(Student[]::new);	// OK
Student[] stuNames = studentStream.toArray();	// ERROR
Object[] stuNames = studentStream.toArray();	// OK

하지만 toArray()에 매개변수가 없으면 Object[]를 리턴하기 때문에 매개변수를 통해 리턴값을 지정해 줘야한다.

 

2) 통계 - counting(), summingInt(), averagingInt(), maxBy(), minBy()

앞서 살펴보았던 최종연산들이 쉽게 제공하는 통계 정보를 collect()로 똑같이 얻을 수 있다. collect()를 사용하지 않고도 쉽게 얻을 수 있지만, collect()를 통해서 그룹별 counting이 가능하기 때문에 사용한다.

sum()과 sumInt() 또한 같지만 그룹별 합계가 가능하기 때문에 사용한다. 

 

3) collect()를 통한 reducing()

 reducing()또한 collect()로 가능하다. Collectors가 가지고 있다. 있는 이유는 마찬가지로 그룹별 reducing이 가능하기 때문이다. 

Collectors의 reducing()

보면 reduce()와 구조가 동일한 것을 알 수 있다. op가 accumulator이다. function인 mapper가 있는 reducing도 있지만 보통 위의 reducing을 많이 쓰고 mapper를 통해서 map과 reduce가 가능하다. 

 

4) joining()

Collectors가 가지고 있는 메소드로 문자열 스트림의 모든 요소를 하나의 문자열로 연결해 반환한다. 

구분자를 지정해 줄 수 있고, 접두사 및 접미사도 가능하다.

스트림 요소가 String, StringBuffer처럼 CharSequence의 자손인 경우에만 결합이 가능하므로 스트림의 요소가 문자열이 아닌 경우 map()을 통해 요소를 문자열로 변환해야 한다. map()없이 joining()하면 스트림 요소에 toString()을 호출한 결과와 결합한다.

 

5) 그룹화와 분할 - groupingBy(), partitioningBy()

collect()가 유용한 이유는 이것 때문이다. 분할이 가능하다.

Collectors 클래스에 있으며 groupingBy()는 스트림을 n분할, partitioningBy()는 스트림을 2분할을 가능하게 한다.

groupingBy()는 스트림의 요소를 Function으로, partitioningBy()는 Predicate으로 분류한다.

메소드 정의를 보면 분류를 Function으로하냐 Predicate로 하냐 차이만 있을뿐 동등한 것을 알 수 있는데, 스트림을 두개로 나누는 것은 partitioningBy()로 분할 하는 것이 빠르다. 그외에는 groupingBy()를 사용하면 된다.

자세한 예제는 길어질 수 있어서 적지 않겠다.

하지만 이 두 메소드를 통해 분할을 할수 있고, 분할된 것에서의 통계 연산을 낼 수도 있고, collectingAndThen()메소드를 통해서 다시 Collector인터페이스를 매개변수로 받을 수 있고, 반환값인 finisher를 설정할 수 있으며 메소드 안에 계속해서 Collector 인터페이스를 받을 수 있기 때문에 메소드 안에서 계속해서 분할을 한다거나, 다른 Collectors 클래스의 메소드를 계속해서 호출할 수 있는 것을 알 수 있었다.

 

또한 개인적으로 컬렉션의 Comparable과 람다식의 Predicate, Supplier와 같은것을에 대해서 공부해 봐야겠다는 생각이 들며,  그 공부가 마친 후에는 다시 돌아와 stream을 다시한번 봐야겠다는 생각이 든다.

 

** char타입의 배열을 stream 형태로 바꾸기

보다시피, char타입 배열을 stream으로 바꾸려하니, 컴파일 에러가 발생했다.

찾아보니, 자바는 기본 문자에 대한 문자 스트림을 제공하지 않기 때문에, 위와 같은 방법으로는 char stream 생성이 불가능하다.

(1) String 객체 이용 + chars() 메소드

 - 아래 코드와 같이, String.chars()를 이용하면 IntStream을 생성한다.

public class Main {
    public static void main(String[] args) {
 
        char[] chars = {'a','b','c','1','2','3'};

        String str = new String(chars);
        IntStream charStream = str.chars();
    }
}
        IntStream charStream = str.chars();
        // character로 출력
        charStream.forEach(i -> System.out.println((char)i));
        Stream<Character> characterStream = str.chars().mapToObj(i -> (char) i);

위와 같이 mapToObj() 중간연산을 통해서 stream을 Character를 가진 것으로 전환하든가 forEach로 char로 변환해 출력 가능함.

 

(2) IntStream.range()를 통해서 char배열에 접근.

import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
 
public class Main {
    public static void main(String[] args) {
 
        char[] chars = {'a', 'b', 'c', 'd'};
        Stream<Character> charStream = IntStream.range(0, chars.length)
                .mapToObj(i -> chars[i]);
 
        System.out.println(charStream.collect(Collectors.toList()));
    }
}

Reference: 

자바의 정석 (도우출판)

https://www.techiedelight.com/ko/create-stream-of-characters-from-char-array-java/

 

최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday