본문 바로가기

WhiteShip Java Study : 자바 처음부터 멀리까지

멀티스레드 프로그래밍

선장님과 함께하는 자바 스터디입니다.

자바 스터디 Github

github.com/whiteship/live-study

 

whiteship/live-study

온라인 스터디. Contribute to whiteship/live-study development by creating an account on GitHub.

github.com

나의 Github

github.com/cmg1411/whiteShip_live_study

 

cmg1411/whiteShip_live_study

✍ 자바 스터디할래. Contribute to cmg1411/whiteShip_live_study development by creating an account on GitHub.

github.com

 

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

 

 

스레드

프로세스 안에서 돌아가고 있는 작업 흐름의 단위이다.

 

프로세스

프로세스는 다른 말로 프로그램이라 하기도 한다. 컴퓨터에서 돌리고 있는 하나하나의 프로그램들이 다 하나하나의 프로세스이다.

 

쉽게 말해 롤을 실행시키면 하나의 프로세스가 돌아가는 것이고,

롤에서 큐를 잡으면서 채팅을 치면서 챔피언을 사면서.. 하는 그 작업 흐름들이 모두 스레드인 것이다.

 

 

스레드는 프로세스 안에서 여러개 생성되어 일할 수 있는데, 이를 멀티 스레딩이라 한다. 스레드는 프로세스가 올라가있는 메모리 영역을 공유한다. 

 

 

멀티 테스킹

  • 하나의 CPU는 하나의 프로세스를 돌릴 수 있다.
  • 그렇다면 컴퓨터는 어떻게 하나의 CPU 가지고도 여러 프로그램을 돌릴까. (요즘 CPU 코어가 하나인 컴퓨터는 없다만은 .. )
  • OS는 하나의 CPU 라는 자원으로 프로세스를 이 프로세스 돌렸다가 저 프로세스 돌렸다가를 빠르게 반복한다.
  • 그 작업이 너무 빨라서 우리는 동시에 여러 프로세스가 동작하는 것처럼 느낀다.
  • 이렇게 시간적으로 CPU 자원을 분할해서 사용하는 것을 멀티 테스킹 이라 한다.

 

멀티 스레드

  • 하나의 프로세스는 하나 이상의 스레드를 가지고 있다.
  • 일반적으로는 하나의 프로세스에 하나의 스레드로 실행되지만, 멀티 스레드 프로그램에서는 하나의 프로세스에서 여러개의 스레드로 나눠서 작업을 한다.
  • 멀티 스레드는 그들의 프로세스 메모리 자원을 공유한다. 따라서 자원 사용의 효율이 좋다. (낭비가 적다) 하지만 공유라는 문제 때문에 동시성 문제, 데드락 등의 문제가 생긴다.
  • 각자의 프로세스가 OS 로부터 자원을 받아 처리하는 멀티 프로세스와는 다르다.

 

 

동시성(concurrency)과 병렬성(parallelism)

  • 자바의 멀티 스레드 프로그래밍은 동시성과 병렬성이라는 개념을 사용한다.
  • 동시성 : 스레드의 수가 CPU 코어의 수보다 많다면, 하나의 코어에서도 스레드를 한순간에 하나의 스레드를 처리하면서 번걸아가면서 처리한다. 시간적으로 독립되어 실행되는 것이다. 하지만 이 번갈아 가는 작업이 매우 빠르므로 병렬적으로 처리되는것처럼 보인다.
  • 병렬성 : 스레드 수가 CPU 코어의 수보다 적다면, 하나의 코어당 하나의 스레드를 할당하여 시간적으로 동시에 스레드를 실행한다.

 

동시성에서의 순서 결정

  • 동시성에서 스레드가 번갈아가면서 실행된다면, 순서는 어떻게 정할까. 이를 결정하는 것을 스케줄링이라 한다.
  • 스케쥴링 방식에는 두가지가 있다.
  • 우선순위 방식 : setPriority 로 코드상에서 설정 가능
  • 라운드로빈 방식 : JVM 에서 정해준다. 순환 할당방식이라고도 한다. 코드상으로 정할 수 없다.

 

스레드의 비용 - 문맥교환

  • 스레드가 동시성 적으로 실행될 때, 이 스레드에서 저 스레드로 실행이 넘어가면, 이전 스레드가 어디까지 작업했고 다음부터는 어디부터해야하고 등의 각종 정보를 저장한다.
  • 이를 문맥교환이라 한다.
  • 스레드가 많아지고, 교체하는 횟수가 많아지면 문맥교환에 필요한 공간이 많아지고 작업도 많아진다.
  • 따라서 멀티스레딩의 효율이 저하될 수 있다.
  • 즉, 멀티 스레딩이 무조건적으로 빠르고 좋은 것은 아니다.

 

Runnable, Thread

자바에는 스레드를 구현하기 위해 두가지가 존재한다. 

Runnable 인터페이스와

Thread 클래스이다.

 

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}
public class Thread implements Runnable {
    /* Make sure registerNatives is the first thing <clinit> does. */
    private static native void registerNatives();
    static {
        registerNatives();
    }

    private volatile String name;
    private int            priority;
    private Thread         threadQ;
    private long           eetop;

    /* Whether or not to single_step this thread. */
    private boolean     single_step;

    /* Whether or not the thread is a daemon thread. */
    private boolean     daemon = false;

    /* JVM state */
    private boolean     stillborn = false;

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

 

보면 알 수 있듯이, Runnable 인터페이스는 함수형 인터페이스이고

Thread 클래스는 Runnable 인터페이스의 구현체이다.

 

그 중의 run 메서드는 스레드를 구동시키는 메서드이다.

Runnable 의 run 메서드는 추상 메서드이고, Thread 의 run 메서드를 보자.

    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }

우리는 이 run 메서드를 오버라이딩해서 그 스레드가 하는 일을 정의하고 실행한다.

 

 

 

Thread, Runnable 로 스레드 만들기

Thread 상속

public class ThreadCreation extends Thread {

    // @SneakyThrows sleep 예외처리 롬복
    @Override
    public void run() {
        try {
            Thread.sleep(1000L); // long 을 쓰기 떄문에 리터럴 뒤 L 붙이는게 좋음
        } catch (InterruptedException e) { // sleep 할때 예외처리 해줘야함
            throw new RuntimeException(e);
        }
        System.out.println(Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        ThreadCreation threadCreation = new ThreadCreation();
        threadCreation.start();
    }
}

 

  • Thread 를 상속받은 클래스를 만들고, run 메서드를 재정의한다.
  • 스레드 객체를 만들고, start메서드로 실행시킨다.

 

Runnable 구현

  • Runnable 을 구현한 클래스를 만들고 run() 를 재정의 한 후, Thread 객체를 생성할 때 생성자로 넘겨준다.
public class ThreadCreation2 implements Runnable {

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        new Thread(() -> System.out.println(Thread.currentThread().getName())).start();
        new Thread(() -> System.out.println(Thread.currentThread().getName())).start();
		// 람다식으로 생성해서 스레드를 생성할 수 있다.

        Thread thread1 = new Thread(new ThreadCreation2());
        Thread thread2 = new Thread(new ThreadCreation2());
        Thread thread3 = new Thread(new ThreadCreation2());

        thread1.start();
        thread2.start();
        thread3.start();
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
    }
}

 

 

 

Runnable 구현 vs Thread 상속

  • Thread 상속은 Thread 클래스에 여러가지 메서드를 제공하므로, 그 메서드를 필요에 따라 재정의하여 사용할 것이라면 사용하면 된다.
  • 그에 반면 Runnable 구현은 인터페이스로 run() 만 재정의할 수 있다. run() 만 재정의하여 사용하면 된다 싶으면 Runnable 을 쓰면 된다.
  • 또, 인터페이스와 클래스의 차이 때문에 Thread 클래스 이외의 필요한 상속이 있다면 다중상속이 안되므로 Runnable 을 구현하도록 한다.

 

스레드가 실행되는 순서는 모른다.

  • 자바의 스레드는 독립적으로 실행되며 순서를 지정해주지 않으면 OS 스케쥴러에 의해 순서가 결정된다.
public class ThreadIsNotOrdered {
    public static void main(String[] args) {
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            threadList.add(new Thread(() -> System.out.println(Thread.currentThread().getName())));
        }

        for (Thread thread : threadList) {
            thread.start();
        }
    }
}

이 코드를 돌릴 때 마다 매번 순서가 바뀜을 볼 수 있다.

 

 

Thread 클래스의 생성자들

public Thread() {
    init(null, null, "Thread-" + nextThreadNum(), 0);
}


public Thread(Runnable target) {
    init(null, target, "Thread-" + nextThreadNum(), 0);
}


    Thread(Runnable target, AccessControlContext acc) {
    init(null, target, "Thread-" + nextThreadNum(), 0, acc, false);
}


public Thread(ThreadGroup group, Runnable target) {
    init(group, target, "Thread-" + nextThreadNum(), 0);
}


public Thread(String name) {
    init(null, null, name, 0);
}


public Thread(ThreadGroup group, String name) {
    init(group, null, name, 0);
}


public Thread(Runnable target, String name) {
    init(null, target, name, 0);
}


public Thread(ThreadGroup group, Runnable target, String name) {
    init(group, target, name, 0);
}


public Thread(ThreadGroup group, Runnable target, String name,long stackSize) {
    init(group, target, name, stackSize);
}

 

 

보면 모두 init(x, x, x, x) 로 생성하는 것을 볼 수 있다.

init 메서드의 매개변수는 순서대로

--

그룹

Runnable 인터페이스

이름

스택 크기

--

이다.

 

이름을 넣지 않으면 Thread-0 처럼 이름을 자동으로 지정해준다.

 

 

 

run() vs start()

  • 스레드를 만들 때 run() 를 오버라이딩하여 할일을 정했는데 스레드를 실행할 때에는 start() 로 시작한다.
  • 둘다 실행하는 것 같은데 뭐가 다른걸까.
  • JVM의 Runtime Data Area 를 다시 생각해보자. 스레드는 생성될 때 마다 생기고 그 안에 call Stack 이 있다고 했다.
  • main 메서드도 하나의 스레드이다. 우리는 main 메서드 하나로 프로그램을 주로 실행했으므로 싱글 스레드 프로그래밍을 한 것이다.

run 메서드는 단지 우리가 정의한 메서드를 실행하는 것이다. 스레드를 실행시키는 기능은 없다.

start 메서드가 새로운 스레드를 만들고, run 메서드를 그 새로운 스레드에서 실행시키는 것이다. 후에 스레드의 상태를 보면 이해할 수 있는 내용을 추가하자면, start 메서드가 실행된다고 바로 run 이 되는것은 아니고, 스레드의 대기 순열에 올린 후 자기의 순서가 되면 run을 실행시킨다.

 

run 을 실행한다는 것은 그냥 메인스레드에서 run 메서드를 호출해서 스택에 쌓고 실행하는 것 뿐이다.

 

반면 start 메서드의 동작과정은 이렇다.

1. main 메서드가 있는 call Stack 에 start 메서드가 쌓이고 실행된다.

2. start 메서드는 참조되어있는 만들어진 스레드의 call Stack에 run 메서드를 쌓는다.

3. main 메서드가 있는 call Stack 에서 start 가 pop 된다.

4. 그러면 이제 한 스레드가 더 생겼고, main 메서드가 있는 스레드와 새로운 스레드가 있게 되고, 번갈아가면서 실행된다.

 

 

이제는 아래에 스레드가 왜 겹쳐져 그려졌는지 알 수 있다. 

2주차 과제때 그렸던 JVM 의 Runtime data Area 의 일부분

 

 

그리고, 두 메서드 모두 하나의 스레드에서 한번만 실행 가능하다.

하나의 스레드 객체로 이 메서드들을 두번 실행하려 한다면 예외를 보게 된다.

 

 

 

 

스레드의 상태

스레드가 start 메서드에 의해 생겨나고 run 메서드가 실행되고 run 메서드가 끝나면 스레드가 종료된다.

이 과정에서 스레드는 여러가지 상태를 가지게 된다.

https://codedragon.tistory.com/3526

위 그림에서 WAITING 에 대한 상황이 빠져 있는데, wait() 함수에 의해 blocking 되고 notify() 에 의해 다시 RUNNABLE 이 된다.

 

 

 

 

 

public class ThreadStateCheck {
    public static void main(String[] args) {
        ThreadMonitor threadMonitor = new ThreadMonitor(new CustomThread());
        threadMonitor.go();
    }
}

// 돌아가는 스레드를 모니터링
class ThreadMonitor {
    private Thread thread;

    public ThreadMonitor(Thread thread) {
        this.thread = thread;
    }

    public void go() {
        while (true) {
            System.out.println("현재 스레드 : " + thread.getName() + " // 상태 : " + thread.getState());

            if (thread.getState() == Thread.State.NEW) thread.start();
            if (thread.getState() == Thread.State.TERMINATED) {
                System.out.println("현재 스레드 : " + thread.getName() + " // 상태 : " + thread.getState());
                break;
            }
        }
    }
}

// 돌아가는 스레드
class CustomThread extends Thread {

    @Override
    public void run() {
        State state = Thread.currentThread().getState();
        try {
            Thread.sleep(1L); // TIME_WAITING 을 보기 위해 sleep
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("현재 스레드 : " + Thread.currentThread().getName() + " // 상태 : " + state);
    }
}
현재 스레드 : Thread-0 // 상태 : NEW
현재 스레드 : Thread-0 // 상태 : RUNNABLE
현재 스레드 : Thread-0 // 상태 : RUNNABLE
현재 스레드 : Thread-0 // 상태 : RUNNABLE
현재 스레드 : Thread-0 // 상태 : TIMED_WAITING
현재 스레드 : Thread-0 // 상태 : TIMED_WAITING
현재 스레드 : Thread-0 // 상태 : TIMED_WAITING
현재 스레드 : Thread-0 // 상태 : TIMED_WAITING
현재 스레드 : Thread-0 // 상태 : TIMED_WAITING
현재 스레드 : Thread-0 // 상태 : TIMED_WAITING
현재 스레드 : Thread-0 // 상태 : RUNNABLE
현재 스레드 : Thread-0 // 상태 : RUNNABLE
현재 스레드 : Thread-0 // 상태 : RUNNABLE
현재 스레드 : Thread-0 // 상태 : TERMINATED

 

 

 

스레드의 우선순위

스레드는 CPU 의 스케줄링에 따라 순서가 매겨진다고 하였다. 순위가 매겨지지 않는다면.

그렇다면 자바에서 우선순위를 주는 방법은 무엇일까.

 

다음은 Thread 클래스 구현의 일부다.

    public final static int MIN_PRIORITY = 1;

   /**
     * The default priority that is assigned to a thread.
     */
    public final static int NORM_PRIORITY = 5;

    /**
     * The maximum priority that a thread can have.
     */
    public final static int MAX_PRIORITY = 10;

 

 

스레드의 우선순위는 1~10 으로 줄 수 있는 것 같다.

그리고 우선순위를 지정하지 않으면 5로 초기화되어 공평한 우선순위를 가진다.

 

우선순위가 높다는 것은 스레드가 실행할 기회를 많이 가지게 되는 것이다.

기회를 많이 가지는 것이므로 무조건적으로 높은 순위가 먼저 끝난다는 보장은 없지만 확률은 높아지게 된다.

또 요즘 컴퓨터는 CPU 가 많아서 병렬성을 가지므로 프로세스가 많아야 어느정도 영향을 받는다.

 

public class PriorityThread {

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new PriorityTestThread());

            if (i == 9) thread.setPriority(Thread.MAX_PRIORITY);
            else thread.setPriority(Thread.MIN_PRIORITY);

            thread.start();
        }
    }
}


class PriorityTestThread implements Runnable {

    @Override
    public void run() {
        for (long i = 0; i < 2000000000; i++) {}

        System.out.println(Thread.currentThread().getName()
            + "의 우선순위 : " + Thread.currentThread().getPriority());
    }
}
Thread-1의 우선순위 : 1
Thread-5의 우선순위 : 1
Thread-6의 우선순위 : 1
Thread-17의 우선순위 : 1
Thread-8의 우선순위 : 1
Thread-9의 우선순위 : 10
Thread-2의 우선순위 : 1
Thread-14의 우선순위 : 1
Thread-4의 우선순위 : 1
Thread-13의 우선순위 : 1
Thread-11의 우선순위 : 1
Thread-10의 우선순위 : 1
Thread-3의 우선순위 : 1
Thread-7의 우선순위 : 1
Thread-15의 우선순위 : 1
Thread-16의 우선순위 : 1
Thread-0의 우선순위 : 1
Thread-18의 우선순위 : 1
Thread-12의 우선순위 : 1
Thread-19의 우선순위 : 1

높은 순위로 기회를 받아 비교적 일찍 끝났다.

 

 

 

I/O Blocking

사용자 입력을 받을 때는 사용자 입력이 완료될때까지 해당 스레드가 BLOCKED일시정지 상태가 된다.

이를 I/O 블로킹이라 한다.

 

 

Main 스레드

프로세스에는 하나 이상의 스레드가 있다고 했다.

적어도 하나는 있다는 것이다.

 

자바 프로세스의 기본 스레드가 Main 스레드이다.

우리가 의식하지 않고 쓰던 main 메서드는 자바의 기본 스레드이고, 대부분 싱글 스레드 프로그래밍을 해 왔던 것이다.

 

Main 메서드 안에 선언된 작업을 다 끝내고 나면 main 메서드를 호출하는 스레드는 끝나게 된다.

 

하지만 멀티 스레드 프로그램에서 다른 스레드가 생성되어 아직 작업중이라면, main 메서드가 끝나도 프로그램은 계속 돌아가다가 모든 스레드가 작업을 마치면 프로그램이 종료된다.

 

public class MainDieButPrograming {
    public static void main(String[] args) {
        LongTimeThread longTimeThread = new LongTimeThread();
        longTimeThread.start();
        System.out.println("이 println 이 끝나면 main 은 종료");
    }
}


class LongTimeThread extends Thread {

    @Override
    public void run() {
        try {
            Thread.sleep(5000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("LongTimeThread 종료.");
    }
}

 

이 프로그램을 돌려보면, "이 println 이 끝나면 main 은 종료" 가 출력되고도 프로그램이 계속 돌아가는 것을 볼 수 있다.

 

 

 

Demon 스레드

main 메서드가 끝나면 모든 스레드를 같이 종료하고싶다면 Demon 스레드를 쓰면 된다.

setDemon(Boolean ) 으로 스레드를 데몬스레드로 지정할 수 있다.

 

Demon 스레드는 일반 스레드를 돕는 스레드이다.

따라서 그 일반 스레드가 끝나면 도울 대상이 없으므로 끝나는게 맞다.

 

아래 코드는 LongTimeThread스레드 안에서 다른 스레드 DaemonThread 를 호출시켰다.

DaemonThread 는 무한루프를 돌며 println 을 실행한다.

데몬으로 지정하지 않았을 때에는 모든 스레드가 끝나도 무한루프 스레드가 끝나지 않아 계속 출력이 나온다.

public class MainDieButPrograming {
    public static void main(String[] args) {
        DaemonThread demonThread = new DaemonThread();
        //demonThread.setDaemon(true);
        LongTimeThread longTimeThread = new LongTimeThread(demonThread);
        longTimeThread.start();
        System.out.println("이 println 이 끝나면 main 은 종료");
    }
}


class LongTimeThread extends Thread {
    private Thread daemonTread;

    public LongTimeThread() {
    }

    public LongTimeThread(Thread daemonTread) {
        this.daemonTread = daemonTread;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(5000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        daemonTread.start();
        System.out.println("LongTimeThread 종료.");
    }
}

class DaemonThread extends Thread {

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("이것은 데몬 스레드입니다.");
        }
    }
}

 

 

주석을 풀고 DaemonThread 를 데몬 스레드로 만들어보자.

public class MainDieButPrograming {
    public static void main(String[] args) {
        DaemonThread demonThread = new DaemonThread();
        demonThread.setDaemon(true); // 데몬 스레드로 만듦
        LongTimeThread longTimeThread = new LongTimeThread(demonThread);
        longTimeThread.start();
        System.out.println("이 println 이 끝나면 main 은 종료");
    }
}


class LongTimeThread extends Thread {
    private Thread daemonTread;

    public LongTimeThread() {
    }

    public LongTimeThread(Thread daemonTread) {
        this.daemonTread = daemonTread;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(5000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        daemonTread.start();
        System.out.println("LongTimeThread 종료.");
    }
}

class DaemonThread extends Thread {

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("이것은 데몬 스레드입니다.");
        }
    }
}

그러면 DeamonThread 는 데몬 스레드가 되어 start() 하는 쪽 스레드의 도움 스레드가 된다.

따라서 LongTimeThread 가 끝나면 같이 끝난다.

 

 

 

 

 

 

 

 

동기화

JVM 의 멀티 스레드에서는 스레드는 여러개지만, 하나의 힙영역을 공유자원으로 사용한다. 따라서 하나의 스레드에서 처리한 동작이 다른 스레드의 동작에 영향을 줄 수 있다.

 

다음의 공유객체 Account 가 있다.

계좌 객체이고, 잔고가 부족하면 잔고가 부족하다는 메세지를 띄운다.

public class Account {
    private int balance;

    public Account(int balance) {
        this.balance = balance;
    }

    public int draw(int money) {
        if (balance < money) {
            System.out.println("잔고가 부족합니다.");
            return 0;
        }
        balance -= money;
        System.out.println("출금 완료");
        return money;
    }
}

 

아빠, 엄마, 아들이 같은 계좌를 사용하고, 각자의 스레드를 가지고 멀티 스레딩을 한다.

그러면 문제가 발생한다.

public class Family {
    public static void main(String[] args) {
        Account account = new Account(1000);
        Thread father = new Thread(new MemberDraw(account));
        Thread mother = new Thread(new MemberDraw(account));
        Thread son = new Thread(new MemberDraw(account));

        father.start();
        mother.start();
        son.start();

//        for (int i = 0; i < 10; i++) {
//            new Thread(new MemberDraw(account)).start();
//        }
    }
}

class MemberDraw implements Runnable {
    private Account account;

    public MemberDraw(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        account.draw(1000);
    }
}

 

넣어놓은 돈은 천원인데

세명 다 1000원을 출금하는 경우가 생긴다. (돈을 복사한단 말이야)

 

fathrer 스레드가 잔고가 부족한지 검사하는 if 문을 통과한 직후 mother 스레드로 처리가 넘어가고, 아들로 넘어갔기 떄문에 이런 문제가 발생했을 것이다.

 

이렇게 하나의 공유 자원을 여러 스레드가 사용하는 것은 문제가 생길 수 있기에, 공유 자원을 한 스레드가 사용하고 있으면 다른 스레드가 사용하는 것을 막는 것이 동기화이다.

 

자바의 동기화에서는 임계영역과 잠금이라는 개념을 사용한다.

아래에서 자세히 알아보자.

 

 

synchronized

자바는 동기화가 필요한 부분을 임계 영역(critical section) 으로 지정한다.

그리고 하나의 스레드가 임계 영역을 사용하면, 잠금(lock) 을 걸어 다른 스레드가 사용하기 위해서는 잠금이 풀릴 떄 까지 기다려야 한다.

 

이러한 기능을 synchronized 라는 키워드로 할 수 있다.

 

 

synchronized 메서드

메서드에 synchronized 키워드를 붙여 메서드 전체를 임계 영역으로 지정할 수 있다.

 

위의 예에서는 draw 메서드에 붙일 수 있다.

위치는 접근 지정자 바로 다음에 붙인다.

public class Account {
    private int balance;

    public Account(int balance) {
        this.balance = balance;
    }

    public synchronized int draw(int money) {
        if (balance < money) {
            System.out.println("잔고가 부족합니다.");
            return 0;
        }
        balance -= money;
        System.out.println("출금 완료");
        return money;
    }
}

 

더 정확하게 알기 위해 횟수를 늘렸다.

 

 

synchronized 블럭

메서드 전체를 동기화 메서드로 만드는 방법 말고, 메서드의 동기화가 필요한 부분을 따로 임계 영역으로 지정할 수 있다.

 

public class Account {
    private int balance;

    public Account(int balance) {
        this.balance = balance;
    }

    public int draw(int money) {
    	... 여러 스레드가 실행 가능한 부분
        synchronized(this) {
            if (balance < money) {
                System.out.println("잔고가 부족합니다.");
                return 0;
            }
            balance -= money;
            System.out.println("출금 완료");
            return money;
        }
        ... 여러 스레드가 실행 가능한 부분
    }
}

 

synchronized 의 매개변수로 공유 객체가 들어간다.

공유 객체가 자신이라면 this 를 쓸 수 있다.

 

 

 

 

 

Critial path

스터디에서 선장님이 알면 좋은 개념으로 소개해주셨다.

 

 

세개의 스레드가 있고, 그 세 스레드가 다 끝나면 또다른 세 스레드가 있고, 그다음 세 스레드가 있다.

 

여기서 하얀색 막대가 critical path 이다.

동시에 실행하는 작업 중 가장 오래 걸리는 작업이다.

이 작업을 더 효율적으로 만들어 시간을 줄인다면, 전체 시간이 줄어 들 것이다.

 

 

 

 

 

데드락

  • 교착상태 라고도 한다.
  • 위에서 동기화를 위해 락을 건다고 설명했다.

위 그림과 같이 두 스레드에서 각각의 공유 객체를 사용하고 있어 락을 걸어뒀고, 다음 작업을 위해 서로의 공유 객체의 락이 풀리기를 기다린다.

 

결국 Thread1은 공유 객체 B를 사용하기전에는 A의 락을 풀 수 없고, Thread2는 공유 객체 A를 사용하기 전엔 락을 풀 수 없기에 무한정 서로를 기다리는 상황이 발생한다.

 

이런 상황을 데드락(Dead Lock)이라 한다.

 

 

교착상태의 조건으로는 아래의 4가지가 있다.

  • 상호배제(Mutual exclusion) : 프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구한다. = 자원을 같이 사용하지 못한다.
  • 점유대기(Hold and wait) : 프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다린다. = 자원을 가진채로 기다린다.
  • 비선점(No preemption) : 프로세스가 어떤 자원의 사용을 끝낼 때까지 그 자원을 뺏을 수 없다. = 다른 스레드의 자원을 뻇을 수 없다.
  • 순환대기(Circular wait) : 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다. = 자원을 요구하는 방향이 원이다.

이 조건 중 하나라도 만족하지 않으면 데드락은 걸리지 않는다.

 

 

 

public class DeadLock {

    static class Friend {
        private final String name;
        
        public Friend(String name) {
            this.name = name;
        }
        
        public String getName() {
            return this.name;
        }
        
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s" + "  has bowed to me!%n",
                this.name, bower.getName());
            bower.bowBack(this);
        }
        
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s" + " has bowed back to me!%n",
                this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");

        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}

 

 

다음의 코드에서 gaston 과 alphonse 는 서로 인사는 하지만 인사를 받지는 못하는 데드락을 겪게 된다.

 

 

 

 

 

 

 

 

 

Thread 상태확인 메서드

 

 

Thread 상태 제어 메서드

 

sleep()

  • static 메서드로, 매개값으로 주어진 시간만큼 호출한 곳의 스레드를 일시정지 시킨다.
  • checked Exception으로 InterruptException 을 throws 하고 있으므로 무조건 예외처리를 해줘야 한다.
  • interrupt() 때문에 무조건 처리해야 하는 checked Exception 이다.

 

 

Interrupt()

  • 이 메서드가 적용된 스레드는 일시정지 상태가 되었을 때 InterruptedException 을 발생시킨다.
public class InterruptEx {
    public static void main(String[] args) {
        Thread thread = new CustomInterrupt();
        thread.start();
        thread.interrupt();
    }
}

class CustomInterrupt extends Thread {

    @Override
    public void run() {
        try {
            for (int i = 1; i <= 20; i++) {
                System.out.println("20번까지 실행 : 현재" + i + "번쨰 실행중 (10번째에 잠시 쉼)");
                if (i == 10) {
                    Thread.sleep(1);
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("자원 정리");
        System.out.println("스레드 종료");
    }
}

 

  • interrupt 가 실행되었다고 바로 예외가 발생하는 것이 아니라, 스레드는 실행되다가 일시 정지가 되었을 때 예외가 발생한다.
  • interrupt 를 사용하면 스레드를 정상종료 할 수 있다.

 

interrupt() 는 <isInterrupted() - static 메서드> 와 <interrupted() - 인스턴스 메서드> 의 결과값이 true가 나오게 한다.

interrupt() 가 호출 되었다면 true, 아니면 false 인 것이다.

 

따라서 위의 코드를 isInterrupted() 나 interrupted() 둘중 아무거나를 사용하여 짤 수 도 있다.

 

public class InterruptEx {
    public static void main(String[] args) {
        Thread thread = new CustomInterrupt();
        thread.start();
        thread.interrupt();
    }
}

class CustomInterrupt extends Thread {

    @Override
    public void run() {
        for (int i = 1; i <= 20; i++) {
            System.out.println("20번까지 실행 : 현재" + i + "번쨰 실행중 (10번째에 잠시 쉼)");
            if (i == 10 & Thread.interrupted()) break;
        }
        System.out.println("자원 정리");
        System.out.println("스레드 종료");
    }
}

결과 화면은 같다.

 

 

스터디를 같이하는 선원분들 중 재밌는 예외가 있어서 따라해봤다. 조금 바꿔서.

package com.whiteship.white_ship_study.week10.ThreadMethods;

public class InterruptEx2 {
    public static void main(String[] args) {
        System.out.println("사냥 시작 !!");
        Thread thread = new Warrior("뽀로로");
        thread.start();

        try {
            Thread.sleep(5000L); // 5초후 사망
            thread.interrupt();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private static class Warrior extends Thread {
        private int experience = 0;

        public Warrior(String name) {
            super(name); // Thread 이름 설정
        }

        @Override
        public void run() {
            while (experience != 10) {
                farmingDelay();
                experience++;
                System.out.println(Thread.currentThread().getName() + "는 파밍중.. 경험치 : " + experience);
            }
            System.out.println("만렙 찍으셨습니다. 이제 공부하세요.");
        }

        private void farmingDelay() {
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                System.out.println("사망 !! 경험치가 2 줄어듭니다 !! 부활 대기시간 5초");
                resurrectionDelay();
                experience-=2;
            }
        }

        private void resurrectionDelay() {
            try {
                Thread.sleep(5000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

 

sleep(), wait(), join() 등 모든 일시정지 상태에서 Exception을 던진다.

 

 

 

yield()

  • 여러개의 스레드가 같은 우선순위를 가지고 자원을 할당받기를 기다리는 상태를 경쟁 상태(race condition) 라고 한다.
  • yield() 는 자신에게 주어진 실행 시간을 경쟁상태에 놓인 다른 스레드에게 양보한다.
public class YieldEx {
    public static void main(String[] args) throws InterruptedException {
        CustomYield thread1 = new CustomYield("A");
        CustomYield thread2 = new CustomYield("B");
        thread1.start();
        thread2.start();

        Thread.sleep(3000L);
        thread1.interrupt();

        Thread.sleep(3000L);
        thread2.interrupt();
        
        System.out.println("A : 나 다시 왔다 !");
        thread1.isGoHome = false;
    }
}

class CustomYield extends Thread {
    public boolean isGoHome = false;

    public CustomYield(String name) {
        super(name);
    }

    @Override
    public void run() {
        while (true) {
            if (isGoHome) {
                Thread.yield();
                continue;
            }
            System.out.println(Thread.currentThread().getName() + "가 처리중");
            delay();
        }
    }

    private void delay() {
        try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            System.out.println(Thread.currentThread().getName() + " : 난 집에간다~");
            isGoHome = true;
        }
    }
}

두 작업자가 일을 하고 있다.

3초 후에 A에 interrupt() 를 실행한다.

그럼 delay() 에서 예외를 잡고 yield() 를 실행시킨다.

그럼 A의 스레드는 계속 돌아가고 있지만, B에게 양보한다.

 

다음 코드는 스터디원들의 코드를 따라 해 봤다.

yield() 와 interrupted의 조합으로, interrupt 가 호출되면 컨디션이 바뀌어 yield() 를 실행하게 하여서

의미없는 while 문을 돌지 않고 양보를 하는 코드이다.

public class YieldEx2 {
    public static void main(String[] args) {
        Worker tomas = new Worker("Tomas");
        Worker tai = new Worker("Tai");
        Worker remi = new Worker("Remi");

        tomas.start();
        tai.start();
        remi.start();

        try {
            Thread.sleep(3000L);
            tomas.rest();
            Thread.sleep(3000L);
            tomas.workAgain();
            Thread.sleep(3000L);
            tomas.stopWorking();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class Worker implements Runnable {
    private Thread thread;
    private Boolean stopped = false;
    private Boolean rest = false;

    public Worker(String name) {
        this.thread = new Thread(this, name);
    }

    @Override
    public void run() {
        while (!stopped) {
            if (!rest) {
                try {
                    Thread.sleep(1000L);
                    System.out.println(Thread.currentThread().getName() + "는 일중입니다.");
                } catch (InterruptedException e) {
                    System.out.println(Thread.currentThread().getName() + "는 interrupted 되었습니다.");
                }
            } else {
                Thread.yield();
            }
        }

        System.out.println(Thread.currentThread().getName() + "는 terminated 되었습니다.");
    }

    public void start() {
        this.thread.start();
    }

    public void rest() {
        rest = true;
        this.thread.interrupt();
        System.out.println(thread.getName() + "는 쉬러 갑니다.");
    }

    public void workAgain() {
        rest= false;
        System.out.println(thread.getName() + "가 다시 일하러 옵니다.");
    }

    public void stopWorking() {
        stopped = true;
        this.thread.interrupt();
        System.out.println(thread.getName() + "는 퇴근합니다.");
    }
}

 

 

wait() / notify()

  • 이 두 메서드는 동기화 블럭 안에서 사용 가능하다.
  • 일시정지 메서드가 여러가지인 이유를 생각해보면, 각자는 일시정지를 하면서 다른 역할을 하기 때문이다.
  • wait(), notify() 는 동기화 블럭에서 일시정지하고 푸는 역할을 한다.
  • wait() 은 해당 동기화 블럭을 처리하고 있는 스레드를 일시정지한다. 따라서 스레드가 아닌 Object 클래스에서 정의되어 동기화블럭에서 wait() 으로 사용할 수 있다. (notify() 도 Object 클래스에 정의)
  • notify() 는 wait() 으로 일시정지한 스레드중 하나를 실행 대기상태로 올린다.
  • notifyAll() 은 wait() 으로 일시정지한 스레드 모두를 실행 대기 상태로 올린다.
  • wait() 은 동기화블럭 안에서 사용되어 동기화의 락을 반납하는 기능이 있다. 따라서 오래걸리는 작업을 동기화 때문에 한 스레드만 접근가능하여 다른 스레드들이 오래 기다리는 상황을 해결할 수 있다.
public class WorkObject { // 공유할 클래스
    public synchronized void methodA() {
        System.out.println("Thread A Method A");
        notify();

        try {
            wait();
        } catch (InterruptedException e) {
        }
    }

    public synchronized void methodB() {
        System.out.println("Thread B Method B");
        notify();

        try {
            wait();
        } catch (InterruptedException e) {
        }
    }
}
public class ThreadA extends Thread {
    private WorkObject workObject;

    public ThreadA(WorkObject workObject) {
        this.workObject = workObject;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            workObject.methodA();
        }
    }
}
public class ThreadB extends Thread {
    private WorkObject workObject;

    public ThreadB(WorkObject workObject) {
        this.workObject = workObject;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            workObject.methodB();
        }
        notify();
    }
}
public class Main {
    public static void main(String[] args) {
        WorkObject sharedObject = new WorkObject();

        Thread threadA = new ThreadA(sharedObject);
        Thread threadB = new ThreadB(sharedObject);

        threadA.start();
        threadB.start();
    }
}

 

 

 

 

Join()

  • 이 메서드 또한 일시정지 메서드이다.
  • 다른 스레드가 종료될 떄 까지 기다렸다가 실행해야 하는 스레드가 있을 수 있다.
  • 예를 들어, A 스레드는 100 까지의 계산하고, 결과를 출력하는 스레드라고치자.
  • 스레드 A 안에는 100까지의 숫자 계산을 수행하는 B 스레드가 start() 하고 있다.
  • 그럼 스레드 A는 B가 끝날떄 까지 기다렸다가 결과값을 출력해야 한다.
  • 이 때, A 스레드에서 B.join() 를 쓸 수 있다.
public class JoinEx {
    public static void main(String[] args) {
        Thread a = new A();
        a.start();
    }
}

class A extends Thread {
    private B thread;

    public A() {
        this.thread = new B();
    }

    @Override
    public void run() {
        thread.start();

        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(thread.getResult());
    }
}

class B extends Thread {
    private int result = 0;

    public int getResult() {
        return result;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            result += 1;
        }
    }
}

결과가 100이 나온다.

B 스레드가 완료될 떄 까지 기다리기 떄문이다.

 

join() 을 포함하는 try / catch 블럭을 삭제하면 값이 이상하게 나온다.

컴퓨터의 성능에 따라 달라지지만, B 스레드가 실행되는 도중에 getResult 를 해오기 때문이다.

(내 컴퓨터는 빠른가보다 0이 나온다.)

 

 

 

 

 

 

Thread Pool

스레드가 자원을 효율적으로 사용하게 하긴 하지만, 스레드가 많아지면 스레드를 생성하고 소멸시키는데 필요한 비용을 무시할 수 없다.

 

그래서 스레드 풀이라는 저장소를 만들어 스레드를 만들어 놓고, 작업들이 오면 스레드를 가져다 쓰는 방식이 스레드풀이다.

 

 

스레드를 미리 만들어 놓고, 이 스레드들은 큐에 저장된 Task 를 받아서 일을 한다.

작업이 끝난 스레드는 다음 task를 실행하는 그런 식이다.

 

 

자바에서는 스레드풀을 생성과 사용에 java.util.concurrent 에 ExecutorService 인터페이스 Executors 클래스를 제공한다.

Executors의 다양한 정적 메서드를 통해 ExecutorService 구현객체를 만들어서 사용할 수 있으며, 그것이 바로 스레드 풀이다. 

 

스레드 풀 생성

 

초기 스레드 수 : ExecutorService 객체가 생성될 때 기본적으로 생성되는 스레드 수

코어 스레드 수 : 스레드가 증가한 후 사용되지 않은 스레드를 스레드 풀에서 제거할 때 최소한으로 유지해야할 수

최대 스레드 수 : 스레드풀에서 관리하는 최대 스레드 수

 

 

1, newCachedThreadPool()

초기스레드 수, 코어스레드 수 0개 최대 스레드 수는 integer 데이터타입이 가질 수 있는 최대 값(Integer.MAX_VALUE)

스레드 개수보다 작업 개수가 많으면 새로운 스레드를 생성하여 작업을 처리한다.

만약 일 없이 60초동안 아무일을 하지않으면 스레드를 종료시키고 스레드풀에서 제거한다.

 

 

2. newFixedThreadPool(int nThreads)

초기 스레드 개수는 0개 ,코어 스레드 수와 최대 스레드 수는 매개변수 nThreads개.

이 스레드 풀은 스레드 개수보다 작업 개수가 많으면 마찬가지로 스레드를 새로 생성하여 작업을 처리한다.

만약 일 없이 놀고 있어도 스레드를 제거하지 않고 내비둔다.

 

newCachedThreadPool(),newFixedThreadPool() 메서드를 사용하지 않고 직접 스레드 개수들을 설정하고 싶다면

직접 ThreadPoolExecutor 객체를 생성하면 된다. 

 

 

 

스레드 풀 종료

 

스레드 풀에 속한 스레드는 기본적으로 데몬스레드가 아니기 때문에 main 스레드가 종료되어도 작업을 처리하기 위해 계속 실행 상태로 남아있다. 즉 main() 메서드가 실행이 끝나도 어플리케이션 프로세스는 종료되지 않는다.

어플리케이션 프로세스를 종료하기 위해선 스레드 풀을 강제로 종료시켜 스레드를 해체시켜줘야 한다. 

 

excutorService.shutdown();

 - 작업큐에 남아있는 작업까지 모두 마무리 후 종료 (오버헤드를 줄이기 위해 일반적으로 많이 사용.)

 

excutorService.shoutdownNow();

 - 작업큐 작업 잔량 상관없이 강제 종료

 

excutorService.awaitTermination(long timeout, TimeUnit unit);

 - 모든 작업 처리를 timeout 시간안에 처리하면 true 리턴 ,처리하지 못하면 작업스레드들을 interrupt()시키고 false리턴

 

 

 

Thread Pool(스레드 풀)에게 작업시키기

 

execute();

 - 작업 처리 결과를 반환하지 않는다.

 - 작업 처리 도중 예외가 발생하면 스레드가 종료되고 해당 스레드는 스레드 풀에서 제거된다. 

 - 다른 작업을 처리하기 위해 새로운 스레드를 생성한다.

 

submit();

 - 작업 처리 결과를 반환한다.

 - 작업 처리 도중 예외가 발생하더라도 스레드는 종료되지 않고 다음 작업을 위해 재사용

 - 스레드의 생성 오버헤드를 방지하기 위해서라도 submit() 을 가급적 사용한다.

 

 

 

 

 

GitHub 예제 빠르게 돌리기 (멀티 스레드)

앞 장 과제중 깃허브에서 이슈에 달린 댓글을 가져와서 각 사람들의 과제 제출율을 계산하였다.

내 과제에서는 이슈 1~5번까지만 가져오는데도 몇분이 걸렸다.

Main 스레드 하나만 사용하던 것을 멀티 스레드를 사용하여 컴퓨터 자월을 싹 쓸어서 더 빠르게 실행해보자.

 

깃허브에서 이슈를 순회하며 댓글을 조회하는 클래스이다.

public class GitIssueTracker {
    private static final int ISSUE_NUMBER_TO_SEARCH = 15;

    private final UserSubmitMap userSubmitNumber = new UserSubmitMap();

    public void getCommentByEachIssue(GHRepository repository) throws IOException, InterruptedException {
        ExecutorService service = Executors.newFixedThreadPool(8);
        CountDownLatch latch = new CountDownLatch(15);

        for (int issueNumber = 1; issueNumber <= ISSUE_NUMBER_TO_SEARCH; issueNumber++) {
            int finalIssueNumber = issueNumber;
            service.execute(new Runnable() {
                @Override
                public void run() {
                    GHIssue ghIssue = null;
                    try {
                        ghIssue = repository.getIssue(finalIssueNumber);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    List<GHIssueComment> comments = null;
                    try {
                        comments = ghIssue.getComments();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    checkComment(comments);
                    latch.countDown();
                }
            });
        }

        latch.await();
        service.shutdown();
    }

    private synchronized void checkComment(List<GHIssueComment> comments) {
        for (GHIssueComment comment : comments) {
            String commenterName = comment.getUserName();
            userSubmitNumber.addSubmitNumber(commenterName);
        }
    }

    public void printTotalIssue() {
        System.out.println("==================과제 제출 현황==================");
        userSubmitNumber.printStatement();
    }
}

 

ExecutorService service = Executors.newFixedThreadPool(8);

이 메서드로 스레드 풀을 만들었다. 내 컴퓨터의 코어는 8개이므로 8로 지정한 스레드 풀을 만들었다.

 

service.execute(new Runnable() {
                @Override
                public void run() {
                    GHIssue ghIssue = null;
                    try {
                        ghIssue = repository.getIssue(finalIssueNumber);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    List<GHIssueComment> comments = null;
                    try {
                        comments = ghIssue.getComments();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    checkComment(comments);
                    latch.countDown();
                }
            });

스레드 풀을 실행시킨다. for 문 안에서 실행시키며 각 이슈번호마다 스레드 풀의 스레드를 사용하여 작업을 멀티스레드 방식으로 처리한다.

 

service.shutdown();

스레드 풀을 종료시킨다.

 

 

 

 

CountDownLatch latch = new CountDownLatch(15);

latch.countDown();

latch.await();

 

CountDownLatch 는 스레드를 여러개 실행했을때 일정 개수의 스레드가 모두 끝날 떄 까지 기다려야 다음 스레드로 이동할 수 있거나, 다른 스레드를 실행시킬 수 있는 경우 사용한다.

해당 멀티 스레딩에서는 모든 이슈 15개를 순회를 마쳐야 다음 상황으로 갈 수 있다.

 

latch.countDown() 은 latch 를 생성했을 때 초기화시킨 15에서 1씩 감소한다. 

 

latch.await() 은 latch 가 0이 될 떄 까지 다음 작업을 하지 않고 기다리는 것이다. 코드를 보면 각 스레드 run 이 끝날 때 마다 이 메서드를 호출하므로, 15개의 순회를 마쳐야 latch 의 값이 0이 될 것이다. 이후 다음 작업으로 넘어가게 된다.

 

 

 

이렇게 멀티 스레딩을 이용하여 코드를 돌리니 5개가 아닌 18개의 이슈를 돌렸는데도 10초밖에 걸리지 않았다.

그 대신 컴퓨터는 개처럼 일을 했더라.

 

멀티 스레딩을 하자 갑자기 올라가는 그래프들.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

락 클래스, currentHashMap

sujl95.tistory.com/63

 

 

락, Executors, callable, feature

www.notion.so/ac23f351403741959ec248b00ea6870e

 

 

스레드 풀

limkydev.tistory.com/55

 

 

fork&join framework

parkadd.tistory.com/48

 

'WhiteShip Java Study : 자바 처음부터 멀리까지' 카테고리의 다른 글

Annotation  (0) 2021.02.13
enum  (0) 2021.02.08
예외 처리  (0) 2021.02.05
인터페이스  (0) 2021.02.04
패키지  (0) 2021.02.02