멀티쓰레드 프로그래밍

반응형
반응형

프로세스와 쓰레드

프로세스 : 실행중인 프로그램
쓰레드 : 프로세스안에서 작업을 수행

jvm => 조그만 os
거대 os(맥, 위도우,리눅스)안에 실행하는 프로그램(java)에서 사용하는 os
어떻게 보면 jvm이 os라 생각해도 무방하다고 생각한다.

하얀것이 프로세스 , 파란것이 쓰레드

쓰레드들은 각자의 영역을 지키면서 프로세스의 일을 함께 처리.

멀티 테스킹 vs 멀티 쓰레딩

멀티 테스킹 : 여러 가지 프로그램을 동시에 실행 하는 것을 의미한다.
멀티 쓰레딩 : 하나의 프로그램을 수 많은 쓰레드로 실행하는 것을 의미한다.

CPU의 코어 => 쓰레드의 갯수

멀티 쓰레딩의 장 단점

- cpu의 사용률을 향상 시킨다.
- 자원을 효율적으로 사용할 수 있다.

쓰레드도 어떻게 보면 하나의 프로그램이라고 할 수 있다.
하나의 프로그램이기 때문에 실행이 될수도 있고 종료 될 수 있다.
쓰레드는 욕심이 많다.
또, 쓰레드는 각자마다 속도가 다르다,
모든 쓰레드들은 할당 받은 프로세스를 종료시키고 싶어한다.
그러기 때문에, 다른 쓰레드가 어떤 일을 처리했는지 관심이 없다.
결국, 자기 일만 하게 된다.
그렇기 때문에 이미 끝난일을 또 하게 되는 이상한 상황이 발생한다.

따라서 쓰레드가 많은 것은 그렇게 도움이 되지 않을 수도 있다.
(사공이 많으면 배가 산으로 간다.)

쓰레드의 구현과 실행 

Thread 클래스 사용

public class MyThread extends Thread{

}

Runnable 인터페이스 사용

public class MyThread implements Runnable{
  @Override
  public void run() {
    
  }
}

그렇다면 이들은 어떻게 사용할 수 있는 것일까?
달랑 run만 존재하는데... 이걸로 쓰레딩을 할 수 있다고?

private static native void registerNatives();
static {
     registerNatives();
}
...

private native void setPriority0(int newPriority);
private native void stop0(Object o);
private native void suspend0();
private native void resume0();
private native void interrupt0();
private native void setNativeName(String name);

자바 소스가 아니라 c혹은 c++소스라는것을 알 수 있음,
그런데 Runnable은 뭐냐고?

/* What will be run. */
private Runnable target;

여기 보면 동작할거라 한다.

결국, c,c++소스로 스레드를 만든다는 것을 알 수 있다.

run , start

run => 쓰레드가 어떻게 구동이 될지 정의한다.
start => 쓰레드의 스위치를 on으로 만들어준다.

즉, run에서 이 쓰레드는 어떻게 구동될지 작성하고,
start에서 쓰레드를 실행시킨다.

예를 들어,

public static void main(String[] args) {
     MyThread thread = new MyThread();
     thread.start();
}

가장 먼저 main 실행이 되겠지.
그리고 나서 MyThread가 실행이 되죠.
myThread안에는

  @Override
  public void run() {
    System.out.println("hello");
  }

이러한 코드가 존재한다.
결국, MyThread가 실행이 되는 순간 hello가 등장하게 되죠.
하지만 아직 쓰레드는 켜진 상태가 아니기 때문에 hello라는 글씨는 보이지 않는다.
비로소 start()라는 메소드가 입력이 되는순간, MyThread가 켜지게 되며, hello라는 글씨가 보이게 된다.

자바를 깔았다고 해서 자바를 사용할 수 없다.
자바를 실행시켜야 비로소 자바를 사용할 수 있는 것 처럼,
스레드도 마찬가지다.
구동 준비를 마쳤다고 해서(run) 실행하지 않는다면, 아무 의미가 없는 쓰레드라 생각한다.

Main쓰레드

- main메소드를 실행하는 순간 main쓰레드가 실행된다.
- 프로세스를 구동하는데 최소한의 스레드가 필요하지 않을까?

예를 들어, 회사에 아무도 다니지 않는다면 그게 회사일까? (유령회사?)
적어도 ceo혼자라도 다녀야 회사의 의미가 있지 않을까?
쓰레드와 일꾼은 그냥 하는 비유가 아닌것 같다.

근데, 코드를 이렇게 수정하면 어떻게 될까?

MyThread thread = new MyThread();
for (int i = 0; i < 5; i++) {
   thread.start();
 }
}

쓰레드를 하나 만들고, 그것을 다섯번 실행시킨다라...
아쉽게도 쓰레드 off기능을 만들지 않았다.
그렇기 때문에 5번 쓰레드on은 말이 되지 않는다.
thread.start() 횟수가 홀수 일때 on, 짝수일때는 off로 바꾸면 좋지 않을까?
만약, 아무런 문제가 발생하지 않는다면, 좋겠지만, 문제가 발생하지 않을꺼라고 단정 지을 수 있을까?

한 번 실행하고 에러.

쓰레드가 여러개라면 어떻게 될까?

for (int i = 0; i < 5; i++) {
    MyThread thread = new MyThread();
    thread.start();
}

이렇게 되면 쓰레드를 5번 실행되기 때문에 
서로 다른 쓰레드가 실행되게 된다.
여기서 의문점은 과연 쓰레드는 순서대로 동작할까?

간단하게 숫자를 리턴하게 소스를 수정했다.

for (int i = 0; i < 5; i++) {
   MyThread thread = new MyThread(i);
   thread.start();
}

분명히 0부터 5까지 순차적으로 실행되게 코드를 작성했지만,
결과는 계속적으로 변화하고 있다.
이것으로 쓰레드의 순서는 일정하지 않는다는 것을 알 수 있다.

싱글쓰레드 vs 멀티쓰레드

싱글 쓰레드는 쓰레드가 한 개인 상태를 말하고,
멀티 쓰레드는 쓰레드가 여러개인 상태를 말한다.

만약에 같은 양을 끝낸다고 생각해보자.
그래서 공유 객체를 만들었다.

public class Share {
  public static int value = 100;
}

이제 이것을 싱글 쓰레드 부터 멀티 쓰레드부터 어떻게 동작하는지 확인해보자.

싱글 쓰레드 걸린 시간  => 123
멀티 쓰레드 걸린 시간(2) => 70 + 71 => 142??

쓰레드 하나당 걸리는 시간은 줄었지만, 오히려 이 둘을 합치니 시간이 더 커졌다는 걸 알 수 있다.
하지만 쓰레드는 각자 동작하기 때문에 총 걸린 시간은 142가 아니라 71이 걸렸다고 할 수 있다.
왜냐하면 동시가 가능하기 때문이다.

이것을 토대로 쓰레드 10개까지의 시간을 작성해보자.

3 코어 쓰레드  : 46
4 코어 쓰레드 : 38
5 코어 쓰레드 : 28
6 코어 쓰레드 : 23
7 코어 쓰레드 : 24 (이상 현상 발현)
8 코어 쓰레드 : 20
9 코어 쓰레드 : 18
10코어 쓰레드 : 15

물론 실행할때 마다 시간은 계속 변화를 한다.
근데 어째서 이러한 결과가 나타난 것일까?

솔직히 100개는 너무 많으니 갯수를 10개로 줄인다음에 다시 해보자.

 싱글  : 9 8 7 6 5 4 3 2 1 0 소요시간: 46
2 코어 : 8 8 6 7 4 4 3 2 1 0 소요시간: 37
3 코어 : 7 7 7 6 4 5 3 1 2 0 소요시간: 45
4 코어 : 9 6 7 8 5 4 3 2 1 0 소요시간: 39
5 코어 : 7 5 8 9 6 2 1 3 3 0 소요시간: 40

싱글일때는 혼자 일을 하기 때문에, 숫자가 같은 숫자는 등장하지 않았다.
하지만 멀티 코어부터 햇던 일을 또 할려고 하는 경황이 포작되었다.
2 코어를 확인해보면 "8","4"가 2번이나 등장하게 되었다.

이상 하다. 왜 이런 현상이 발생하게 된것일까? 
정확한 이유는 추후에 알아보기로 하고 지금은 각자 8번을 끝내야 한다는게 중요하다고 생각한 모양이다.

쓰레드의 우선순위

이번에는 쓰레드를 2개 만드는데,
하나는 "1"을 출력이 되게 만들고, 다른 하나는 "2"가 출력이 되게 만들어보자.
그리고 실행을 해보자.

2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 2

만약, 1번이 먼저 실행되길 원한다며 어떻게 해얄까?
1번이 먼저 실행이 될때까지 실행시키는 것도 하나의 방법이겠지만,
쓰레드의 우선순위를 결정하는 것이 더 좋아 보인다,
쓰레드의 우선순위는 1~10번까지 준비가 되어있으며,
숫자가 높을 수록 우선순위가 높다고 한다.

public static final int MIN_PRIORITY = 1;
public static final int NORM_PRIORITY = 5;
public static final int MAX_PRIORITY = 10;

기본적으로 이렇게 3개가 준비되어있다.

이런식으로 우선순위를 정해주면 된다.

쓰레드.setPriority(우선순위);

우선순위를 정하는 방법이 이것 밖에 없을까?
여기와 관련은 없지만 어노테이션으로 조작하면 우선순위를 지정해줄 수 도 있을 것 같다.
예를들어 @Order 어노테이션 처럼 말이다.
이 파트와 상관없기 때문에 넘어가자.

데몬 쓰레드

 - 다른 쓰레드의 작업을 돕는 보조적인 역할을 수행한다.

근데 왜 데몬이지?

아무튼 코드를 살펴보자.

public class ThreadEx10 implements Runnable{
  static boolean autoSave = false;

  public static void main(String[] args) {
    Thread thread = new Thread(new ThreadEx10());
    thread.setDaemon(true);
    thread.start();

    for (int i = 1; i <= 10; i++) {
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println(i);
      if (i == 5) {
        autoSave = true;
      }
    }
    System.out.println("프로그램이 종료되었습니다.");
  }
  @Override
  public void run() {
    while (true) {
      try {
        Thread.sleep(3 * 1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      if (autoSave) {
        autoSave();
      }
    }
  }

  private void autoSave() {
    System.out.println("작업이 저장되었습니다.");
  }
}

각 쓰레드는 3초마다 자동 저장이 되는지 확인하고,
5초가 되었을때 자동 저장 여부를 true로 바꾸는 코드다.

setDemon을 true로 두면 프로그램이 종료되면 데몬 쓰레드도 종료가 된다,
어째서 그럴까?
애초에 while(true)이기 때문에 종료는 되지 않는다.
그래서 프로그램이 종료가 되어질때 종료되게 만들어야 된다.
그렇기 때문에 데몬 쓰레드를 사용하는것이다.

데몬 쓰레드들은 프로그램이 실행이 되면, JVM은 가비지컬렉션, 이벤트처리, 그래픽 처리와 같이 보조적인 작업을 수행될때 사용되어진다고 한다. 그래서 프로그램이 종료가 되면 가비지컬렉션이 동작하지 않기 때문에 사용자가 종료시켜야 되는 경우가 발생하는 거다.

쓰레드의 우선 순위

NEW : 쓰레드가 생성된 상태.

Thread th = new Thread(() -> System.out.println("hello"));

이 상태에는 쓰레드가 어떻게 동작할지만 정의되어있구 동작하지 않는 상태라고 할 수 있다.

RUNNABLE : 실행  중 혹은 실행이 가능한 상태.

th.start();

이 상태에서 바로 실행이 되는 것이 아닌 실행 대기 상태다.
결국, 자기 차례가 올때까지 기다려야 한다.
순서는 일정하지 않다는 건 함정

BLOCKED: 동기화 블록에 의해서 일시정지가 된 상태

일시 정지라... WATING과 차이점은 BLOCKED은 일시정지 후에는 동작한다는 점이다.
대표적으로 sleep이 존재한다.

쓰래드를 잠시동안 일시정지상태에 빠지게 한다.

th.sleep(1000);

WAITING, TIME_WAITING : 쓰레드의 작업은 종료되지 않았지만, 실행이 가능하지 않는 상태. (식물인간?)
TIME_WAITING은 일시정지 시간이 지정된 경우를 의미. 

결국, 살아있지만, 산것이 아닌 상태인것 같다.

이런건가..

예를 들어, suspend라는 메소드가 존재하는데,
이 메소드를 실행하면 쓰레드는 일시 정지 상태에 빠지게 된다.
마침 resume메소드가 실행이 되면, 다시 실행대기 상태로 되어진다고 한다.
하지만 애매해서 TERMINATED 설명하고 메소드만 따로 설명하는것이 좋을 것 같습니다.

TERMINATED : 쓰레드의 작업이 종료가 된상태

쓰레드를 더 이상 사용할 필요가 없거나, stop메소드로 쓰레드를 죽일때 발생되는 상태.

쓰레드 메소드

interrupt() , interrupted() - 쓰레드 작업 취소

Thread thread = new Thread(() -> {
   Scanner sc = new Scanner(System.in);
   while (!Thread.interrupted()) {
      int n = sc.nextInt();
      System.out.println(n);
   }
    System.out.println("종료 되었습니다.");
  });
hread.start();
Thread.sleep(1000);
thread.interrupt();

위 코드는 계속 숫자를 입력받고,
일정 시간이 지나면 종료가 되어진다.

주의 점은 sleep과 함께 사용해야된다는 점이다. 왜냐하면, 일시정지의 상태의 쓰래드를 실행 대기 상태로 바꾸기 때문이다.

suspend(), resume(), stop()

suspend() => 쓰레드 일시 정지
resume() => suspend()로 일시 정지된 쓰레드를 다시 실행
stop() => 쓰레드 종료

Thread th1 = new Thread(getRunnable(), "*");
Thread th2 = new Thread(getRunnable(), "**");
Thread th3 = new Thread(getRunnable(), "***");
th1.start();
th2.start();
th3.start();
Thread.sleep(1000);
th1.suspend();
Thread.sleep(2000);
th2.suspend();
Thread.sleep(3000);
th1.resume();
Thread.sleep(3000);
th1.stop();
th2.stop();
Thread.sleep(2000);
th3.stop();

하지만 이들은 deprecated되었기 때문에 사용하지 않는 것이 좋다.
왜냐하면 병목현상(deadlock)이 발생할지도 모르기 때문이다.

yield() - 다른 쓰레드에게 양보한다.

쓰레드 자신에게 주워진 실행시간을 다음 차례의 쓰레드에게 양보한다.

class YThead extends Thread {
  public YThead(String name) {
    super(name);
  }
  @Override
  public void run() {
    for(int i = 1;i<=3;i++) {
      System.out.println(Thread.currentThread().getName() + ": " + i);
      if(i == Integer.parseInt(Thread.currentThread().getName())) {
        System.out.println(Thread.currentThread().getName() + "양보 :" + i);
        Thread.yield();
      }
      try {
        Thread.sleep(4000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }
}

즉, 자기 자신과 이름이 같다면, 양보를 하는 시스템이다.

2: 1
1: 1
3: 1
1양보 :1
1: 2
3: 2
2: 2
2양보 :2
2: 3
3: 3
3양보 :3
1: 3

이렇게 나눌수 있는데, 1번 쓰레드는 i가 1번일때 양보를 하게된다.
yeild가 실행될때마다 다른 스레드로 옮겨지는것 같다.

join() 다른 쓰레드의 작업을 기다린다.

쓰레드가 자신이 하던일을 멈추고 잠시 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 한다.

메소드들을 공부해보니,
어떤건 쓰레드 내부에서 정의되는 메소드 : sleep, yield, run등
외부에서 정의하는 메소드들도 있다는것을 알게 되었다. (suspend, resume, stop,start, join)

쓰레드의 동기화

혼자 작업하는 경우에는 아무런 문제가 되지 않지만, 여럿이서 작업 할 경우, 자원을 공유해서 사용하기 때문에 서로에게 영향을 줄 수 있다.
즉, A라는 쓰레드가 작업한것을 B라는 쓰레드가 작업할 수도 있다는 뜻이다.

그러면 어떻게 해야할까?

한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 '쓰레드의 동기화'라고 한다.

synchronized를 이용한 동기화

이 키워드는 임계 영역을 설정하는데 사용된다.

더보기

1. 메서드 전체를 임계 영역으로 지정
public sychronized void XXXX() {

}

2. 특정 영역을 임계 영역으로 지정
sychronized(객체의 참조 변수) {
}

* 임계영역
임계 구역( critical section) 또는 공유변수 영역은 병렬컴퓨팅에서 둘 이상의 스레드
가 동시에 접근해서는 안되는 공유 자원(자료 구조 또는 장치)을 접근하는 코드의 일부를 말한다
-출처 : https://ko.wikipedia.org/wiki/임계_구역

임계영역은 멀티 프로그램의 성능을 좌우하기 때문에 메서드 전체보다 특정 영역에 사용해서 최소한으로 지정하는 것을 노력해야한다.

public class Account {
  private int balance = 1000;

  public int getBalance() {
    return balance;
  }

  public void withDraw(int money) {
    if (balance >= money) {
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e ){}
      balance -= money;
    }
  }
}

계좌의 잔고가 1000이 존재한다.
이것을 인출하는데, 만약, 잔고가 인출 금액 보다 크다면 정상적으로 동작하고,
아닐시 에러가 발생하는 코드다(위는 에러코드를 작성하지 않았지만 ㅎ)
그리고 나서 잔고에서 인출금액을 뺀다.

앞으로 쓰레드는 이렇게 동작한다.

class AccountRunnable implements Runnable {
  private Account acc = new Account();
  @Override
  public void run() {
    while (acc.getBalance() > 0) {
      int money = (int)(Math.random() * 3 + 1) * 100;
      acc.withDraw(money);
      System.out.println(Thread.currentThread().getName() + " => balance: "+acc.getBalance());
    }
  }
}

계좌를 가져와서 계좌의 잔고가 0보다 크다면 계속 반복한다.
출금 금액을 선택되고,
인출이 된다.
마지막으로 누가 인출을 했는지 , 계좌에 남은 잔고가 얼마인지 print해줍니다.
결과,

Thread-1 => balance: 900
Thread-0 => balance: 900
Thread-0 => balance: 800
Thread-1 => balance: 800
Thread-0 => balance: 700
Thread-1 => balance: 600
Thread-0 => balance: 400
Thread-1 => balance: 500
Thread-0 => balance: 300
Thread-1 => balance: 200
Thread-0 => balance: 0
Thread-1 => balance: 0

이것을 그림으로 그려보자.

그러면 생각해보자.
파란색 스레드는 900 -> 800 -> 600 -> 500 -> 200 -> 0
초록색 스레드는 900 -> 800 -> 700 -> 400 -> 300 -> 0

스레드는 지 마음대로 동작하기 때문인가... 아무튼
이걸 정렬해보면,
900 -> 900 -> 800 -> 800 -> 700 -> 600 -> 500 -> 400 -> 300 -> 200 -> 0 -> 0
순으로 되어진다.

솔직히 잘 모르겠다. 

이번에는 임계영역을 지정해보자.
방법은 위에서 보인것 처럼 2가지다.

1.

public synchronized void withDraw(int money) {
    if (balance >= money) {
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e ){
        e.printStackTrace();
      }
      balance -= money;
    }
  }

2.

public void withDraw(int money) {
    synchronized(this) {
    if (balance >= money) {
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e ){
        e.printStackTrace();
      }
      balance -= money;
    }
    }
  }

2가지 모두 같은 결과가 나온다.
여기에서는 어차피 공유 자원이 메소드로 한정되기때문에 메소드로 지정하는것이 맞다고 생각한다.

Thread-0 => balance: 900
Thread-1 => balance: 900
Thread-0 => balance: 600
Thread-1 => balance: 700
Thread-0 => balance: 300
Thread-1 => balance: 400
Thread-1 => balance: 300
Thread-0 => balance: 100
Thread-0 => balance: 0
Thread-1 => balance: 200
Thread-1 => balance: 0

이것도 그림으로 그려보자.

위에서 한것 처럼

초록 스레드는 900 -> 600 -> 300 -> 100 -> 0
파란 스레드는 900 -> 700 -> 400 -> 300 -> 200 -> 0 
순으로 진행된다.
이것을 정렬해보면,
900 -> 900 -> 700 -> 600 -> 400 -> 300 -> 300 -> 200 -> 100 -> 0 -> 0
으로 되어진다.

책이랑 결과가 다르게 나와 당황스럽지만, synchronized를 지정하면 조금더 효율적으로 동작되는것 같다.
결과를 서로 비교해도 synchronized붙은 쪽이 훨씬 짧게 나오는것 같다.

데드 락(교착 상태)

가장 널리 사용되는 예제는 식사하는 철학자라는 문제라고 한다.

 

이 식사에는 특이한 점이 존재하는데,
양쪽에 존재하는 포크를 모두 사용해야 비로소 식사를 할 수 있다.
만약, 누군가 사용하고 있다면 일정 시간 기다려야 된다.
자세한 과정은


1. 일정 시간 생각을 한다.
2. 왼쪽 포크가 사용 가능해질 때까지 대기한다. 만약 사용 가능하다면 집어든다.
3. 오른쪽 포크가 사용 가능해질 때까지 대기한다. 만약 사용 가능하다면 집어든다.
4. 양쪽의 포크를 잡으면 일정 시간만큼 식사를 한다.
5. 오른쪽 포크를 내려놓는다.
6. 왼쪽 포크를 내려놓는다.
7. 다시 1번으로 돌아간다.
출처 : 나무위키

다음과 같다.

먼저 철학자를 만들자. 철학자는 각자 움직여야 되기 때문에 쓰레드로 만들자.
(이해를 돕기 위한 코드입니다. 실제 실행은 되지 않습니다.)
굳이 Runnable로 만들 이유는 없을 것 같아,
Thread를 상속해서 만들었다.

public class Philosopher extends Thread{

}

이제 이 쓰레드는 다음과 같은 동작을 하게 된다.

@Override
  public void run() {
    try {
      Thread.sleep(2000);
      
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }

2초간 생각에 잠기게 만들구,

if(leftPork) {
   leftPork = false;
}
if (rightPork) {
    rightPork = false;
}
      
if (leftPork && rightPork) {
        //동작
}

왼쪽 포크를 집을 수 있다면 왼쪽 포크를 집고,
오른쪽 포크를 집을 수 있다면 오른쪽 포크를 집습니다.

그리고 음식을 먹습니다.

if (leftPork && rightPork) {
    eat();
}

음식을 먹은 뒤에는 다시 포크를 내려놓습니다.

leftPork = true;
rightPork = true;

근데 생각해봅시다.

만약에 1번 철학자가 음식을 먹을 수 있다고 가정해봅시다.
그러면

이런식으로 그릴 수 있다.
근데 문제는 2번과 5번 철학자는 포크가 사용되었는지 모른다.
일정 시간이 잠긴뒤, 식사를 하려 했지만, 포크없기 때문에 식사를 할 수 없게 된다.

1번 철학자가 식사를 마쳤다.
그런데 갑자기 3번 철학자가 식사를 하기 시작했다.
그러면

사진은 이렇게 변경되었다.
그러면 2번 4번 철학자는 식사를 할 수 없다.
왜냐하면 포크 하나가 존재하지 않기 때문이다.
근데  3번 철학자가 식사를 마치자 마자 1번 철학자가 식사를 한다고 가정해보자.
그러면 2번 철학자는 영원히 식사를 할 수 없게 되며,
2번 철학자는 음식을 먹지 못하고 죽게 된다.

즉, 데드 락(교착 상태)란? 상대방의 작업이 종료되기 전까지 기다리는 상태를 뜻한다고 한다.

그러면 방법이 아애 없는것일까?
가장 간단한 방법은
홀수번째 철학자가 식사를 하고,

그들이 식사를 마치면 2,4번이 식사를 하면 된다.


즉, 홀수 -> 짝수 -> 홀수 -> 짝수 이런식으로 동작하게 되면 데드락이 해결된다.

반응형

'프로그래밍 언어 > 자바' 카테고리의 다른 글

자바9 모듈  (0) 2021.02.22
제네릭  (0) 2021.02.21
I/O  (0) 2021.02.11
어노테이션  (0) 2021.01.31
ENUM  (0) 2021.01.24

댓글

Designed by JB FACTORY