람다
- 프로그래밍 언어/자바
- 2021. 3. 1. 03:16
람다는 왜 만들어졌을까?
람다를 사용하게 되면 굉장히 코드가 단순해진다는 것을 느낄 수 있다.
런타임시에는 익명 함수로 사용하든 람다식으로 고쳐서 사용하든 상관없다는 뜻이 된다.
최대한 간결하게 만들어서 실수를 방지 하는것이 좋지 않을까?
예를 들어,
Hello hello = new Hello() {
@Override
public void write(String writer) {
System.out.println(writer);
}
};
다음과 같은 익명 함수가 존재한다고 해보자.
이것을 람다로 사용하게 된다면,
Hello hello = writer -> { System.out.println(writer); };
단, 한줄로 코드가 변경되었다.
여기서 의문인것이 어노테이션, enum,제네릭과 달리 이전 소스코드와 호환성을 충분히 지킬 수 도 있었을 텐데,
이 기능을 1.8에 들어서야 만들었을까?
람다와 같은 방식을 지닌 언어를 함수형 언어라고 한다.(그렇다고 자바는 함수형 언어는 아니다.)
자바는 함수형 방식에 적대적이다.
이 당시에 함수형 프로그래밍(FP)가 급상승했던 시기 였던것 같다.
그래서 람다를 추가한게 아닌가 라는 조심스러운 추측을 해본다.
그렇다고 람다를 추가했다고 해서 자바 생태계가 변하는 것은 아무것도 없다.
람다는 어디까지나 익명 함수를 예쁘게 만드는 역할에 지니지 않기 때문이다.
이것이 익명 함수(익명 클래스)로 작성 했을때의 결과이며,
public class study/whiteship/homework16/Main {
// compiled from: Main.java
NESTMEMBER study/whiteship/homework16/Main$1
// access flags 0x0
INNERCLASS study/whiteship/homework16/Main$1 null null
// access flags 0x9
public static main([Ljava/lang/String;)V
// parameter args
L0
LINENUMBER 5 L0
NEW study/whiteship/homework16/Main$1
DUP
INVOKESPECIAL study/whiteship/homework16/Main$1.<init> ()V
ASTORE 1
L1
LINENUMBER 12 L1
RETURN
L2
LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
LOCALVARIABLE hello Lstudy/whiteship/homework16/Hello; L1 L2 1
MAXSTACK = 2
MAXLOCALS = 2
}
이것이 람다로 작성했을 때의 결과이다.
public class study/whiteship/homework16/Main {
// compiled from: Main.java
// access flags 0x19
public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup
// access flags 0x9
public static main([Ljava/lang/String;)V
// parameter args
L0
LINENUMBER 5 L0
INVOKEDYNAMIC write()Lstudy/whiteship/homework16/Hello; [
// handle kind 0x6 : INVOKESTATIC
java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
// arguments:
(Ljava/lang/String;)V,
// handle kind 0x6 : INVOKESTATIC
study/whiteship/homework16/Main.lambda$main$0(Ljava/lang/String;)V,
(Ljava/lang/String;)V
]
ASTORE 1
L1
LINENUMBER 6 L1
RETURN
L2
LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
LOCALVARIABLE hello Lstudy/whiteship/homework16/Hello; L1 L2 1
MAXSTACK = 1
MAXLOCALS = 2
// access flags 0x100A
private static synthetic lambda$main$0(Ljava/lang/String;)V
L0
LINENUMBER 5 L0
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 0
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
RETURN
L1
LOCALVARIABLE writer Ljava/lang/String; L0 L1 0
MAXSTACK = 2
MAXLOCALS = 1
}
바이트 코드로 만들때, 주의할점이 있는데,
절대로 포커스를 익명 클래스에 놓고 바이트코드로 만들면 안된다.
그러면 바이트 코드가 예상과 다르게 나온다.
class study/whiteship/homework16/Main$1 implements study/whiteship/homework16/Hello {
...
}
얼추 비슷하지만, 마냥 람다가 익명 클래스로 변환했다는 사실이 아니라는걸 알 수 있다.
람다식은 컴파일 타임에 클래스가 정의되지 않고 런타임에 JVM이 하도록 떠넘긴다고 한다.
컴파일이 아니라 런타임때 정의된다는 사실은 나에게 충격이다.
왜냐하면 익명 클래스를 -> 로 이용해서 바로 람다로 변환이 될 줄알았는데... 이런 복잡한 과정이 있다는 사실은 이번에 처음 알았다.
위에서 람다는 익명클래스는 예쁘게 보이기 위함에 지나지 않다고 했었는데,
그건 아닌것 같다. 우리눈에 그렇게 보인다고 해서 람다가 어떻게 동작할것인지는 생각하지 않았던 것 같다.
그렇다고 해도 익명 클래스를 람다로 변환된다는 사실은 변함이 없다.
사실 익명 == 람다라고 생각해도 크게 문제될것 까지는 없다고 생각한다.
그러면 익명클래스를 람다로 어떻게 변환이 되어질까?
익명 클래스 -> 람다 변환
Hello hello = new Hello() {
@Override
public void write(String writer) {
}
};
IDE도움을 받으면 편하지만, 스스로 구현한다고 한다면...
고민을 할 필요가 있다.
하나로는 부족한것 같다.
하나더 만들자.
Hello hello = new Hello() {
@Override
public void write(String writer) {
}
};
Hello hello2 = new Hello() {
@Override
public void write(String writer) {
}
};
프로그래밍에서 중요한것 중하나가 중복을 제거하는 것이라고 들었다.
new Hello(), @Override, public void wirte(String writer) 이렇게 중복이 된다.
하지만 메소드의 변수는 서로 다른 값이 들어 올 수 있으므로 완벽하게 중복이 된다고 말하기는 어렵다.
1. new Hello()를 지운다.
Hello hello = {
@Override
public void write(String writer) {
}
};
2. public void write를 지운다.(메소드 매개변수빼고)
Hello hello = { (String writer) {
}
};
3. 메소드 매개변수와 {} 사이에 ->로 연결 시켜준다.
Hello hello = { (String writer) -> {
}
};
4. new Hello()를 만들기 위해 사용되었던 {}를 지운다. (가장 바깥쪽에 존재하는 { })
Hello hello = (String writer) -> { };
완성.
추가+) 인자 생략 가능
Hello hello = (writer) -> { };
왜냐하면 String인지 알 고 있기 때문.
추가+) 모든 괄호 생략 가능. ()는 매개변수가 하나일때만 , {}는 1줄일때만 생략 가능
Hello hello = writer -> ;
문제는 람다식으로 바로 만들 수 없다는 점이다.
람다식으로 만드는 전재조건은 그 인터페이스의 메소드에 존재하는 매개변수를 정확히 무엇이 존재하는지 알고 있어야 만들 수 있다.
그러니 람다식으로 바로 만들 생각을 하지 말고, 익명 클래스로 인스턴스로 생성한뒤, IDE도움 혹은 스스로 람다식으로 만드는 것이 더 좋다고 생각한다.
람다를 이렇게 사용할일은 없겠지만
이렇게도 사용할 수 있다.
*재미로 봐주세요.
Start start = pizza -> account -> tiger -> ring -> end -> end;
람다로 끝말잇기 하기.
(개인적으로 end하나만 하고 싶었는데... ..)
람다의 특징
- 자바의 람다는 메소드가 하나만 존재해야 된다.
왜냐하면 메소드가 2개 이상이라면 어떤 메소드로 사용을 해야되는지 애매하기 때문이다.
예를 들어, 다음과 같은 코드가 존재한다고 해보자.
public interface Great {
void hello();
void hi();
}
자 일단, 이것을 익명 클래스로 만들어보자.
Great great = new Great() {
@Override
public void hello() {
}
@Override
public void hi() {
}
};
그리고 람다로 만들려고 했는데..
고민이 생겼다. hello로 만들어야 할지, hi로 만들어야 할지 애매해진다.
과연 나는 람다를 무엇으로 만들려고 하였을까?
자바에서는 람다를 만들기위해 인터페이스를 정의할때 메소드는 단 하나만 존재해야 된다.
2개 이상 메소드를 만드는 방법이 존재하는데, 그건 static, default등 인터페이스 자체에서 구현이 된다면 메소드는 여러개여도 상관없다.
즉, 아직 구체화가 되지 않는 메소드는 하나만 존재해야 된다.
만약에 어떤 특정한 기능을 무조건 람다로 만들고 싶다고 가정해보자.
이럴때 @FunctionalInterface를 붙여주면 된다.
@FunctionalInterface
public interface Great {
void hello();
}
이것의 역할은 메소드를 하나로 고정시켜서 함수형 인터페이스로 설정하는 역할을 하게 된다.
자바에서는 이미 정의된 람다식을 제공하고 있다.
- variable capture
- 람다식이 둘러싸는 범위의 지역변수를 사용하면 variable capture라고 하는 특수한 상황이 발생한다.
- 람다식은 효과적으로 final인 지역 변수만 사용할 수 있습니다.
- 효과적인 final 변수는 처음 할당된 후 값이 변경되지 않는 변수입니다.
public class Main {
int data = 100;
public static void main(String[] args) {
Main main = new Main();
MyInterface myi = () -> {
System.out.println("data : " + main.data);
main.data += 100;
System.out.println("data : "+main.data);
};
myi.myFunction();
main.data+=100;
System.out.println("data : "+main.data);
}
}
여기서 알수 있는 점은
main.data의 값이 증가하는데로 증가되는 것을 알 수 있다.
그러면 다음의 코드를 보자.
public static void main(String[] args) {
int data = 100;
MyInterface myi = () -> {
System.out.println("data : " + data);
};
myi.myFunction();
data += 100;
System.out.println("data : " + data);
}
이 코드는 마치 MyInterface가 클래스 변수?를 가지게 만들어져있다.
그러면 원하는 결과는
100
200
일 것이다. 하지만,
Variable used in lambda expression should be final or effectively final
이런 컴파일 에러가 발생한다.
해석 하자면, 변수는 람다 표현안에서 final 또는 effectively final으로 사용해야 된다.
라는 것 같다.
그러면 effectively final은 도대체 뭘까?
effectively final은 final변수는 아니지만, 마치 final처럼 만들어지는 변수를 뜻한다.
final변수의 특징은 값이 변하면 안되는 특징을 가지고 있다.
결국 data는 final변수이기 때문에
증감이 되지 않는다.
만약에 람다를 익명 클래스로 바꾼다면 어떻게 될까?
Inner 클래스, 익명(anonymous) 클래스 내부에서는 외부의 final 변수만 접근이 가능합니다. Java8에서 final이 붙지 않았지만, 값이 변하지 않는 변수를 Effectively final라고 하고, 이 변수들은 익명 클래스 내부에서 접근할 수 있습니다.
https://codechacha.com/ko/java-effectively-final-vs-final/
이것을 배열로 저장하면 이것을 무시하는 척 할 수 있다.
왜냐하면 배열에 값을 저장하게 되면 더 이상, 값이 변수 자체(배열)가 아닌
배열안의 값이 증가됨이 아닐까 조용히 추측해본다.
public class Main {
public static void main(String[] args) {
final int[] data = {100};
MyInterface myi = () -> {
data[0] += 100;
System.out.println("data : " + data[0]);
};
myi.myFunction();
data[0] +=100;
System.out.println("data : " + data[0]);
}
}
자바API에서 제공하는 함수형 인터페이스
다른 API도 많이 제공하고 있지만, 인터페이스 4개(Consumer,Function,Predicate,Suppliers)를 가져왔다.
호주에서 프로그래머 활동을 하시는 kevin-lee님께서 이들에 별명을 붙이셨다.(이분이 최초인지는 상관없다.)
Function
별칭 : 트랜스포머(변신 로봇)
이유: 값을 변환하기 때문에
왜 이런 별명이 붙여졌는지 생각해보자.
@FunctionalInterface
public interface Function<T, R> {
/**
* Applies this function to the given argument.
*
* @param t the function argument
* @return the function result
*/
R apply(T t);
...
}
이것을 해석하면 T 가 R이 된다는 뜻이 된다.
예를들어,
Function<Integer,Double> function = i -> ?;
이렇다고 했을때, Integer가 Double으로 변환된다는 뜻이다.
아무 조치 하지 않으면,
에러가 발생한다. 이 함수형의 목적은 Integer가 Double로 바꿔주는 것이 아니라, 이렇게 변경 할 수 있음을 암시하기 위함이다.=
Function<Integer,Double> function = integer -> Double.valueOf(integer);
Double apply = function.apply(50);
System.out.println(apply);
double형이므로 50이 아닌
Double.valueOf(integer) * 100;
이런식으로 만들어도상관없다.
Double형으로만 만들어주면 된다.
Consumer
별칭 : Spartan (스파트탄)
이유 : "모든걸 빼앗고 아무것도 내주지 마라!"
@FunctionalInterface
public interface Consumer<T> {
/**
* Performs this operation on the given argument.
*
* @param t the input argument
*/
void accept(T t);
}
값은 입력받는데.. 리턴하는게 없다.
이는 무조건 void메소드만 사용하라는 것 같다.
void메소드로 대표적으로 println이 존재한다.
Consumer<String> consumer = s -> {
System.out.println(s);
};
consumer.accept("hello");
}
Predicate
별칭 : 판사
이유 : 참과 거짓으로 판단하기 때문에
@FunctionalInterface
public interface Predicate<T> {
/**
* Evaluates this predicate on the given argument.
*
* @param t the input argument
* @return {@code true} if the input argument matches the predicate,
* otherwise {@code false}
*/
boolean test(T t);
}
예를 들어,
Predicate<Integer> predicate = i -> i < 10;
boolean test = predicate.test(5);
System.out.println(test);
i가 10보다 작으면 true, 그게 아니라면 false를 호출한다.
Suppliers
별칭 : 게으른 공급자.
이유 : 입력값이 존재하지 않는데, 내가 원하는 것을 미리 준비하기 때문.
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
매개변수 가 존재하지 않는데.. 리턴이 된다.
뭘 원하는지 몰라서 다 준비했어 이런것 같다.
Supplier<String> supplier = () -> "안녕하세요.";
String s = supplier.get();
System.out.println(s);
함수형API는 위에서 설명한것 말고 다른것들도 존재한다.
메소드 생성자 레퍼런스(참조)
위에서 작성한
Hello hello = new Hello() {
@Override
public void write(String writer) {
}
};
이 코드를 다시 가져왔다.
이것을
람다로 바꾸면 이런식으로 변경할 수 있다.
Hello hello = writer -> {};
{}안에 메소드를 넣어보자.
메소드 대표로 System.out.println()를 넣고 writer를 출력되는 형태로 만들어 보자.
Hello hello = writer -> System.out.println(writer);
사실 여기서 더 줄이는 방법이 존재한다.
그것이 바로 메소드 레퍼런스라고 한다.
Hello hello = System.out::println;
이것을 사용하러면 전재조건이 하나 필요하다.
매개변수는 단 한개만 존재해야된다. 왜냐하면
Hello hello = (writer, index) -> System.out.println(index);
어떤것을 생략할지 모르기 때문이다.
그러면 생성자 래퍼런스는 무엇일까?
메소드와 마찬가지로 무언가를 생략한 형태를 뜻한다.
객체는 어떻게 만들까? new를 이용해서 만든다.
Hello hello = Korea::new;
자 아까도 바이트코드를 열어서 확인했다.
이번에도 확인해보자.
메서드 래퍼런스가 사용하지 않을 때,
// class version 55.0 (55)
// access flags 0x21
public class study/whiteship/homework16/Main {
// access flags 0x9
public static main([Ljava/lang/String;)V
// parameter args
L0
LINENUMBER 6 L0
INVOKEDYNAMIC write()Lstudy/whiteship/homework16/Hello; [
// handle kind 0x6 : INVOKESTATIC
java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
// arguments:
(Ljava/lang/String;)V,
// handle kind 0x6 : INVOKESTATIC
study/whiteship/homework16/Main.lambda$main$0(Ljava/lang/String;)V,
(Ljava/lang/String;)V
]
ASTORE 1
L1
LINENUMBER 10 L1
RETURN
L2
LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
LOCALVARIABLE hello Lstudy/whiteship/homework16/Hello; L1 L2 1
MAXSTACK = 1
MAXLOCALS = 2
// access flags 0x100A
private static synthetic lambda$main$0(Ljava/lang/String;)V
L0
LINENUMBER 7 L0
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 0
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L1
LINENUMBER 8 L1
RETURN
L2
LOCALVARIABLE writer Ljava/lang/String; L0 L2 0
MAXSTACK = 2
MAXLOCALS = 1
}
메서드 래퍼런스를 사용할때
public class study/whiteship/homework16/Main {
// access flags 0x9
public static main([Ljava/lang/String;)V
// parameter args
L0
LINENUMBER 6 L0
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
DUP
INVOKESTATIC java/util/Objects.requireNonNull (Ljava/lang/Object;)Ljava/lang/Object;
POP
INVOKEDYNAMIC write(Ljava/io/PrintStream;)Lstudy/whiteship/homework16/Hello; [
// handle kind 0x6 : INVOKESTATIC
java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
// arguments:
(Ljava/lang/String;)V,
// handle kind 0x5 : INVOKEVIRTUAL
java/io/PrintStream.println(Ljava/lang/String;)V,
(Ljava/lang/String;)V
]
ASTORE 1
L1
LINENUMBER 8 L1
RETURN
L2
LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
LOCALVARIABLE hello Lstudy/whiteship/homework16/Hello; L1 L2 1
MAXSTACK = 2
MAXLOCALS = 2
}
이상하다. 왜 람다가 없지...
이거
// access flags 0x100A
private static synthetic lambda$main$0(Ljava/lang/String;)V
L0
LINENUMBER 7 L0
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 0
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L1
LINENUMBER 8 L1
RETURN
L2
LOCALVARIABLE writer Ljava/lang/String; L0 L2 0
MAXSTACK = 2
MAXLOCALS = 1
다른 부분은
L0
LINENUMBER 6 L0
INVOKEDYNAMIC write()Lstudy/whiteship/homework16/Hello; [
// handle kind 0x6 : INVOKESTATIC
java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
// arguments:
(Ljava/lang/String;)V,
// handle kind 0x6 : INVOKESTATIC
study/whiteship/homework16/Main.lambda$main$0(Ljava/lang/String;)V,
(Ljava/lang/String;)V
]
ASTORE 1
vs
L0
LINENUMBER 6 L0
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
DUP
INVOKESTATIC java/util/Objects.requireNonNull (Ljava/lang/Object;)Ljava/lang/Object;
POP
INVOKEDYNAMIC write(Ljava/io/PrintStream;)Lstudy/whiteship/homework16/Hello; [
// handle kind 0x6 : INVOKESTATIC
java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
// arguments:
(Ljava/lang/String;)V,
// handle kind 0x5 : INVOKEVIRTUAL
java/io/PrintStream.println(Ljava/lang/String;)V,
(Ljava/lang/String;)V
]
ASTORE 1
요게 핵심인듯 싶다.
DUP
INVOKESTATIC java/util/Objects.requireNonNull (Ljava/lang/Object;)Ljava/lang/Object;
뭔지는 모르겠지만, 메서드 레퍼런스를 사용하게 되면, null이 아닌게 증명이 되는것 같다.
또 다른건 L2의 MaxStack이 다르다.
그렇다면 메소드 레퍼런스가 추가되면서 어떤 이점을 얻을 수 있을까?
이제 메소드도 일급 객체로 사용할 수 있다고 한다.
일급 객체 (First-class citizen)
일급 객체로 사용하기 위해서는 다음의 조건을 만족해야 된다.
- 파라미터로 전달 할 수 있어야 한다.
String a = "good";
Korea korea = new Korea();
korea.things(a);
- 반환값으로 사용 할 수 있다.
String a = "good";
Korea korea = new Korea();
String s = korea.things(a);
System.out.println(s);
- 변수나 데이터 구조 안에 담을 수 있다.
List<String> list = new ArrayList<>();
list.add(a);
- 할당에 사용된 이름과 관계없이 고유한 구별이 가능하다.
a라는 이름으로 구별이 가능하다.
따라서 자바의 일급 객체는 객체라고 할 수 있다.
그런데, 모던 자바에서는 메소드(함수)도 일급 객체로 만들 수 있다고 한다.
어디 해보자.
public class Event {
public void action(Hello hello) {
}
}
- 파라미터로 전달 할 수 있어야 한다.
Event event = new Event();
event.action(w -> System.out.println(w));
전달 할 수 있다. 더욱이 이상태에서 메소드 레퍼런스로 바꿀수 있다.
event.action(System.out::println);
- 반환값으로 사용 할 수 있다.
public class Event {
public Hello action(Hello hello) {
return w -> System.out.println(w);
}
}
사용이 가능하네..
메소드 레퍼런스
public class Event {
public Hello action(Hello hello) {
return System.out::println;
}
}
- 변수나 데이터 구조 안에 담을 수 있다.
List<Hello> list = new ArrayList<>();
list.add(w -> System.out.println(w));
메서드 레퍼런스
List<Hello> list = new ArrayList<>();
list.add(System.out::println);
와 이게 되는구나
- 할당에 사용된 이름과 관계없이 고유한 구별이 가능하다.
Hello hello = System.out::println;
변수 지정 가능!
출처
isooo.github.io/etc/2019/11/13/일급객체.html
www.youtube.com/watch?v=mu9XfJofm8U&list=PLRIMoAKN8c6O8_VHOyBOhzBCeN7ShyJ27
hajsoftutorial.com/java-variable-capture/
codechacha.com/ko/java-effectively-final-vs-final/
아직 공부할게 많다는게 스터디를 통해 느꼈다.
나중에 자바를 다시 공부할때는 더 깊게 공부 해야 될것 같다.
공부하면서 새로운 사실을 많이 알게 되었다.
'프로그래밍 언어 > 자바' 카테고리의 다른 글
SOLID (0) | 2021.09.10 |
---|---|
LocalDateTime (미 정재 .ver) (0) | 2021.09.04 |
자바9 모듈 (0) | 2021.02.22 |
제네릭 (0) | 2021.02.21 |
멀티쓰레드 프로그래밍 (0) | 2021.02.13 |