본문 바로가기

Java/기본

[Java] 스레드(Thread) 활용하기 - 데몬스레드, 스레드 그룹, 스레드 풀

데몬(daemon) 스레드

데몬 스레드는 주 스레드의 작업을 돕는 보조 스레드이다. 보통 사용자가 직접 제어하지 않고 백그라운드에서 실행된다. 주 스레드가 종료되면 데몬 스레드는 자동으로 종료된다.

 

코딩 방법

주 스레드에서 setDaemon() 메서드를 호출하여 데몬을 설정한다. start() 호출하여 스레드가 실행되기 전에 setDaemon()을 호출해야 정상적으로 설정된다. isDaemoni() 메서드로 데몬여부를 확인할 수 있다.

 

 

아래 코드를 보자.

public class DaemonTh extends Thread{
    void whileDaemonRunning(){
        System.out.println("데몬스레드 실행중");
    }
    void ifInterrupted(){
        System.out.println("데몬스레드 interrupted");
    }
    void ifDaemonGone(){
        System.out.println("데몬스레드 종료");
    }

    @Override
    public void run() {
        while(true){
            try{
                Thread.sleep(200);
                whileDaemonRunning();
            } catch (InterruptedException ie){
                ifInterrupted();
                ie.getMessage();
                break;
            }
        }
        ifDaemonGone();
    }
}

 

먼저 interrupt() 메서드로 데몬 스레드를 중지시켜보자

public class Execute {
    public static void main(String[] args) {
        DaemonTh dm = new DaemonTh();
        dm.setDaemon(true);
        dm.start();

        System.out.println("데몬여부 : "+ dm.isDaemon());

        try {
            Thread.sleep(1000);
            dm.interrupt();
        } catch (InterruptedException ie){
            ie.getMessage();
        }

        System.out.println("end");
    }
}

 

실행 결과

-데몬스레드가 중지되고 이어서 데몬스레드가 종료된다.

데몬여부 : true
데몬스레드 실행중
데몬스레드 실행중
데몬스레드 실행중
데몬스레드 실행중
end
데몬스레드 interrupted
데몬스레드 종료

 

 

이번에는 interrupt() 코드를 주석처리해 실행해보자

public class Execute {
    public static void main(String[] args) {
        DaemonTh dm = new DaemonTh();
        dm.setDaemon(true);
        dm.start();

        System.out.println("데몬여부 : "+ dm.isDaemon());

        try {
            Thread.sleep(1000);
//            dm.interrupt();
        } catch (InterruptedException ie){
            ie.getMessage();
        }

        System.out.println("end");
    }
}

 

실행 결과

-데몬스레드가 중지되는 코드는 출력되지 않는다

-대신 메인스레드가 종료되었기 때문에 자동으로 데몬스레드도 실행되지 않는다

데몬여부 : true
데몬스레드 실행중
데몬스레드 실행중
데몬스레드 실행중
데몬스레드 실행중
end
데몬스레드 실행중

 

 


스레드 그룹(ThreadGroup)

스레드 그룹은 유사한 기능을 수행하는 스레드를 한데 묶어 관리할 때 사용한다. 스레드는 스레드 그룹에 포함되는데, 그룹을 명시적으로 선언하지 않는다면 자동으로 main 스레드에 속하게 된다.

스레드 그룹을 활용하면 스레드가 여러개인 경우 개별적으로 처리하는 대신 일괄적으로 처리할 수 있게 된다.

 

아래는 스레드그룹을 생성하고 스레드를 일괄적으로 관리하는 코드이다.

public class Employee extends Thread{
    //생성자 인자값으로 스레드 그룹 생성하기
    Employee(ThreadGroup threadGroup, String threadName){
        super(threadGroup, threadName);
    }

    @Override
    public void run() {
        while (true){   //무한반복
            try {
                //interrupt() 메서드는 일시정지 상태에서 호출되기 때문에
                //일부러 sleep 호출
                Thread.sleep(100);
            } catch(InterruptedException ie){
                System.out.println(getName() + " is interrupted");
                ie.getMessage();
                break;
            }
        }
    }
}

 

list() 호출하여 스레드 그룹에 대한 정보를 확인할 수 있다.

스레드 그룹에 대해 interrupt() 호출시 그룹에 속한 모든 스레드가 종료된다.

public class Execute {
    public static void main(String[] args) {
        ThreadGroup teamAlpha = new ThreadGroup("groupA");
        ThreadGroup teamDeci = new ThreadGroup("groupB");

        Employee empA = new Employee(teamAlpha, "홍길동");
        Employee empB = new Employee(teamAlpha, "이순신");
        Employee empC = new Employee(teamAlpha, "김유신");
        Employee emp1 = new Employee(teamDeci, "120");
        Employee emp2 = new Employee(teamDeci, "119");

        empA.start();
        empB.start();
        empC.start();
        emp1.start();
        emp2.start();

        ThreadGroup company = Thread.currentThread().getThreadGroup();
        
        //현재 스레드 그룹의 이름과 최대우선순위 정보를 출력하는 메서드
        company.list();

        String alphaName = teamAlpha.getName();
        String deciName = teamDeci.getName();
        System.out.println("--teamAlpha.getName : "+ alphaName);
        System.out.println("--teamDeci.getName : "+ deciName);

        ThreadGroup threadGroup = teamAlpha.getParent();
        System.out.println("--parentGroup : "+ threadGroup.getName());

        //SecurityException 예외가 발생하지 않는다 -> 그룹변경 권한이 있음
        teamAlpha.checkAccess();

        //스레드 그룹의 interrupt() 호출해 중지시키기
        teamAlpha.interrupt();
    }
}

 

실행 결과

java.lang.ThreadGroup[name=main,maxpri=10]
    Thread[main,5,main]
    Thread[Monitor Ctrl-Break,5,main]
    java.lang.ThreadGroup[name=groupA,maxpri=10]
        Thread[홍길동,5,groupA]
        Thread[이순신,5,groupA]
        Thread[김유신,5,groupA]
    java.lang.ThreadGroup[name=groupB,maxpri=10]
        Thread[120,5,groupB]
        Thread[119,5,groupB]
--teamAlpha.getName : groupA
--teamDeci.getName : groupB
--parentGroup : main
홍길동 is interrupted
이순신 is interrupted
김유신 is interrupted

 


스레드 풀(Thread Pool)

스레드 풀은 제한된 개수만큼 스레드를 생성해 풀(pool)에 마련해두고, 작업요청시 풀에 대기중인 스레드가 작업을 맡아 처리하는 방식이다. 작업요청은 큐(queue) 방식, 즉 선입선출 방식으로 들어오게 된다. 스레드 풀 방식을 사용하면 작업 요청이 갑자기 많이 들어와도 스레드가 폭증하지 않기 때문에 서버가 마비되는 사태를 막을 수 있다.

 

예를들어 작업요청이 동시에 1,000개가 들어온다. 스레드 풀을 사용하지 않는 경우 스레드가 폭증하면서 시스템 자원이 들어온 작업처리에 집중된다. 시스템의 다른 활동에 제약이 발생할 수 있고, 이로 인해 시스템 성능이 저하될 수 있다.

반대로 스레드 풀에 스레드가 100개로 제한을 걸었다면, 동시에 처리할 수 있는 처리개수는 100개로 제한된다. 작업처리시간은 그만큼 길어질 수 있겠지만, 갑작스런 작업요청으로 시스템이 마비되는 사태는 예방할 수 있다.

 

쇼핑몰에서 계산줄이 길다고 해서 쇼핑몰 전직원이 계산대로 지원나가지 않는 것과 같다.

 


스레드풀 생성

스레드풀을 생성하는 방법은 3가지이다. newCashedThreadPool(), newFixedThreadPool() 는 메서드를 이용해 생성하는 방법이고, ThreadPoolExecutor 는 직접 객체를 생성해 스레드풀을 마련하는 방법이다. newCashedThreadPool()와 newFixedThreadPool() 도 내부적으로 ThreadPoolExecutor 객체를 생성하므로 사실상 동일한 방법이라고 볼 수 있다.

 

제공된 메서드를 호출하면 ExecutorService 객체가 생성되는데 이것이 스레드풀이다.

 

 

 

1. newCashedThreadPool 메서드로 생성하기

  • 초기 스레드 개수와 코어 스레드 개수는 0
  • 스레드 개수보다 작업개수가 많으면 새 스레드를 생성하여 작업을 처리
  • 1개 이상의 스레드가 추가될 경우 60초간 스레드가 작업을 하지 않는 경우 추가된 스레드를 종료하고 풀에서 제거
  • 작업이 끝나면 스레드를 제거하므로 스레드풀이 자원소비를 하지 않는다
ExecutorService service1 = Executors.newCachedThreadPool();

 

 

2. newFixedThreadPool 메서드로 생성하기

  • 인자값으로 최대 코어스레드 개수를 할당
  • 스레드 개수 보다 작업 개수가 많으면 새 스레드를 생성
  • 인자값으로 준 개수 이상 스레드가 증가하지 않기 때문에, 최대 코어스레드 개수보다 작업이 많으면 작업이 대기 큐(queue)에 쌓인다
  • 작업이 없더라도 생성한 스레드가 삭제되지 않는다
ExecutorService service2 = Executors.newFixedThreadPool(nThreads);

 

코어개수 값으로 Runtime.getRuntime().availableProcessors() 을 주면 시스템 프로세서(CPU코어) 개수만큼 스레드가 생성된다.

ExecutorService service3 = Executors.newFixedThreadPool(
	Runtime.getRuntime().availableProcessors()
);

 

 

메서드 초기 스레드 개수 코어 스레드 개수 최대 스레드 개수
newCashedThreadPool 0 0 Integer.MAX_VALUE
newFixedThreadPool(int nThreads) 0 nThreads nThreads
  • 초기 스레드 : ExecutorService 객체가 생성되면서 기본적으로 생성되는 스레드 개수
  • 코어 스레드 : 작업이 없을 경우 스레드풀에 남겨둘 스레드 개수
  • 최대 스레드 : 스레드풀에 유지하는 스레드 최대 개수

 

3. ThreadPoolExecutor 객체로 생성하기

-인자값으로는 아래 내용을 할당하게 된다.

  • int corePoolSize : 코어 스레드 개수
  • int maximumPoolSize : 최대 스레드 개수
  • long keepAliveTime : 스레드 미작업시 유지할 시간
  • TimeUnit unit : 시간 단위
  • BlockingQueue<Runnable> workQueue : 작업 큐
ExecutorService executorService = new ThreadPoolExecutor(
		3, 100, 3, TimeUnit.MINUTES, new SynchronousQueue<>()
);

 


스레드풀 종료

메인 스레드가 종료되더라도 스레드풀의 스레드는 자동으로 종료되지 않는다. 때문에 명시적으로 스레드풀을 종료시키는 코드 작성이 필요하다.

 

메서드 내용
void  shutdown() 작업 큐에 남아있는 작업을 처리한 뒤에 안정적으로 종료
List<Runnable>  shutdownNow() -작업 큐에 남아있는 작업을 처리하지 않고 즉시 종료
-스레드를 interrupt하여 작업이 중지되므로 미처리된 작업목록을 리턴함
boolean  awaitTermination(long timeout, TimeUnit unit) -작업 마무리할 시간을 제공하는 방식
-시간내에 끝내면 true, 못 끝내면 false

 


스레드풀 작업

1. 작업 생성

스레드 풀의 작업은 Runnable 인터페이스 및 Callable 인터페이스를 구현하여 생성한다. 두 인터페이스는 동일한 역할을 수행하며, 리턴값의 유무에만 차이가 있다. 작업이 들어오면 스레드풀의 스레드는 큐에서 처리할 작업(=Runnable 객체 혹은 Callable 객체)을 가져와서 각각 run(), call()을 수행한다.

 

Runnable 구현하여 작업생성하기

public class Test1 implements Runnable{
    @Override
    public void run() {
        //스레드 처리
    }
}

 

Callable 구현하여 작업생성하기

import java.util.concurrent.Callable;

public class Test2 implements Callable {
    @Override
    public Object call() throws Exception {
        Object object = null;
        //스레드 처리
        return object;
    }
}

 

2. 작업 처리

메서드 내용
void execute(Runnable command) -Executor 인터페이스의 메서드
-Runnable을 작업큐에 저장함
-작업결과를 받지 못함
-작업도중 예외가 발생하면 스레드 종료, 해당 스레드는 스레드 풀에서 제거됨. 스레드풀은 새로운 스레드를 다시 생성함
 (예외가 많을시 생성 오버헤드 발생 우려)
<T> Future<T>  submit(Callable<T> task) -Runnable 또는 Callable을 작업큐에 저장함
-작업결과를 리턴함
-리턴타입 : Future 인터페이스 (java.util.concurrent)
-작업도중 예외가 발생하더라도 해당 스레드를 제거하지 않고 재사용함
 (예외가 많을시에도 오버헤드 우려 적음)
Future<?>  submit(Runnable task)
<T> Future<T>  submit(Runnable task, T result)

 

Future는 지연완료객체라고 하는 인터페이스이다. 작업결과를 담고 있고 있고, get() 메서드로 결과를 가져올 수 있다.

참고로 여기서 말하는 작업결과는 작업이 잘 수행되었는지에 대한 값을 말한다.

자세한 내용은 [3.작업 통보]에서 알아보자

 

 

아래 코드를 보자.

Runnable 에 작업을 부여하고, 스레드의 execute(), submit() 메서드로 실행하는 예제이다.

 

1) Runnable 구현 클래스

public class Todo implements Runnable {
    @Override
    public void run() {
        try{
            System.out.println(Thread.currentThread().getName()+ " | 작업");
            Thread.sleep(200);
        } catch (InterruptedException ie){
            ie.getMessage();
        }
    }
}

 

2-1) execute() 메서드로 작업처리하는 코드

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Execute {
    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newFixedThreadPool(4);

        for(int i=0; i<8; i++){
            threadPool.execute(new Todo());
        }

        threadPool.shutdown();
    }
}

 

2-2) submit() 메서드로 작업처리하는 코드

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Execute {
    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newFixedThreadPool(4);

        for(int i=0; i<8; i++){
            threadPool.submit(new Todo());
        }

        threadPool.shutdown();
    }
}

 

실행 결과

pool-1-thread-1 | 작업
pool-1-thread-3 | 작업
pool-1-thread-4 | 작업
pool-1-thread-2 | 작업
pool-1-thread-1 | 작업
pool-1-thread-2 | 작업
pool-1-thread-3 | 작업
pool-1-thread-4 | 작업

 

 

아래 코드를 보자.

Runnable 에 일부러 예외를 발생시켜 execute() 와 submit() 의 스레드 생성여부 및 예외처리방식을 확인해보자.

 

1) Runnable 구현 객체 - 숫자로 변환시 예외(NumberFormatException)을 발생시키는 코드

public class Todo implements Runnable {

    String st = null;
    Todo(String st){
        this.st = st;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()+ " run 실행");
        int changed = changeInteger(st);
        System.out.println("리턴 : "+ changed);
    }

    //예외 발생시키는 코드
    int changeInteger(String str){
        return Integer.parseInt(str);
    }
}

 

2-1) execute() 로 실행하기

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Execute {
    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newFixedThreadPool(4);

	for(int i=0; i<8; i++){
           threadPool.execute(new Todo("홍길동"));
        }

        threadPool.shutdown();
    }
}

 

실행 결과

...
java.lang.NumberFormatException: For input string: "홍길동"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:580)
at java.lang.Integer.parseInt(Integer.java:615)
at thread.ex401_threadPool.ex02_스레드풀작업처리.test04_예외일으키기.Todo.changeInteger(Todo.java:20)
at thread.ex401_threadPool.ex02_스레드풀작업처리.test04_예외일으키기.Todo.run(Todo.java:14)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)

 

2-2) submit() 으로 실행하기

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Execute {
    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newFixedThreadPool(4);

        for(int i=0; i<16; i++){
            threadPool.submit(new Todo("홍길동"));
        }

        threadPool.shutdown();
    }
}

 

실행 결과

pool-1-thread-1 run 실행
pool-1-thread-4 run 실행
pool-1-thread-2 run 실행
pool-1-thread-3 run 실행
pool-1-thread-1 run 실행
pool-1-thread-1 run 실행
pool-1-thread-1 run 실행
pool-1-thread-2 run 실행

 

 


3. 작업완료 통보

스레드의 작업을 부여한 뒤에 잘 완료되었는지 확인할 필요가 있다. 작업스레드에 작업결과를 보고하라고 지정할 수 있는데 이를 통보라고 한다. 통보 방식은 아래와 같이 분류할 수 있다. 블로킹 방식은 다시 여러 방법으로 구분할 수 있다.

 

  • 블로킹(blocking) 방식 : 작업완료될 때까지 블로킹(실행중지)되었다가 작업끝나면 결과를 알려주는 방식
    • 리턴값이 없는 작업완료 통보
    • 리턴값이 있는 작업완료 통보
    • 작업결과를 외부 객체에 저장
  • 콜백(callback) 방식 : 작업 후에 호출될 함수를 설정해 작업완료되면 자동으로 메서드가 실행되는 방식

-블로킹 방식은 작업이 요청된 뒤 완료될 때까지 실행중지된다.

-콜백 방식은 작업이 요청된 뒤 완료에 상관없이 다른 기능을 수행할 수 있다.

 

블로킹 방식 - 메서드

메서드 내용
Future<?> submit(Runnable task) -Runnable  및 Callable을 작업 큐에 저장
-리턴된 Future 를 통해 작업처리 결과를 얻는다
Future<V> submit(Runnable task, V result)
Future<V> submit(Callable<V> task)

 

Future<T> 인터페이스

submit() 메서드를 실행하면 Future 값이 반환된다. Future는 지연완료객체라고 한다. Future 객체에서 알아두어야 할 점은, Future 는 스레드 실행결과 값이 아니라는 점이다. 스레드가 정상적으로 실행되고 정상적으로 종료되었는지에 대한 객체이다.

 

Future 객체의 get() 메서드를 호출하면 스레드의 작업이 완료되기까지 기다렸다가 실행결과를 가져온다. 스레드 메서드 중 join()이 호출되면 작업이 종료될 때까지 기다리게 된다. 마치 join() 처럼, get()가 호출되면 작업완료까지 블로킹 되었다가 실행결과를 가져오게 된다. 이 때문에 get()을 처리하는 스레드는 작업완료까지 다른 작업을 수행할 수가 없다.

 

get() 결과는 아래와 같다

  • 정상처리되면 null 리턴
  • 작업 중 interrupt 되면 InterruptedException 예외발생
  • 작업 중 예외가 발생하면 ExecutionException 예외발생

 

3-1-1) 블로킹 - 리턴값이 없는 작업완료 통보

스레드 인자값으로 Runnable 을 구현코드를 넣어준다. Runnable 은 리턴값이 없다.

 

아래 코드를 보자

숫자 인자값을 넣으면 그 제곱값을 계산하는 예제이다.

public class Test {

    int num = 1;
    Test(){
        this(10);
    }
    Test(int num){
        this.num = num;
    }

    Runnable runTodo = new Runnable() {
        @Override
        public void run() {
            int answer = 0;
            answer = num*num;
            System.out.println("answer : "+ answer);
        }
    };

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(4);
        Future future = service.submit(new Test(5).runTodo);

        try{
            future.get();
            System.out.println("future.isDone() : "+ future.isDone());
        } catch (InterruptedException ie){
            ie.getMessage();
        } catch (ExecutionException ee){
            ee.getMessage();
        } catch (Exception e){
            e.getMessage();
        }
        service.shutdown();
    }
}

 

실행 결과

answer : 25
future.isDone() : true

 

 


3-1-2) 블로킹 - 리턴값이 있는 작업완료 통보

스레드 인자값으로 Callable을 구현코드를 넣어준다. Callable 은 리턴값이 있다.

 

 

아래 코드를 보자

똑같이 숫자 인자값을 넣으면 그 제곱값을 계산하는 예제이다.

public class Test {

    int num = 1;
    Test(){
        this(10);
    }
    Test(int num){
        this.num = num;
    }

    Callable runTodo = new Callable() {
        @Override
        public Integer call() throws Exception {
            int answer = 0;
            answer = num*num;
            return answer;
        }
    };

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(16);
        Future<Integer> future = service.submit(new Test(5).runTodo);

        try{
            int sum = future.get();
            System.out.println("sum : "+ sum);
        } catch (InterruptedException ie){
            ie.getMessage();
        } catch (ExecutionException ee){
            ee.getMessage();
        } catch (Exception e){
            e.getMessage();
        }
        service.shutdown();
    }
}

 

실행 결과

sum : 25

 


3-1-3) 블로킹 - 작업처리결과 외부객체에 저장하기

스레드가 실행한 결과를 외부 공유객체에 저장하는 방식이다. 스레드에서 외부 공유객체에 접근할 수 있도록 코드를 구성해야한다.

 

아래 코드를 보자

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Test01 {
    class Result{
        int accumValue;
        synchronized void addValue(int value){
            accumValue += value;
        }
    }

    class Task implements Runnable{
        Result result;

        Task(Result result){
            this.result = result;
        }
        @Override
        public void run() {
            int sum = 0;
            for(int i=1; i<=10; i++){
                sum+=i;
            }
            result.addValue(sum);
        }
    }

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(4);
        Test01 t = new Test01();

        Result result = t.new Result();
        Runnable task1 = t.new Task(result);
        Runnable task2 = t.new Task(result);
        Runnable task3 = t.new Task(result);
        Runnable task4 = t.new Task(result);
        Runnable task5 = t.new Task(result);

        Future<Result> future1 = service.submit(task1, result);
        Future<Result> future2 = service.submit(task2, result);
        Future<Result> future3 = service.submit(task3, result);
        Future<Result> future4 = service.submit(task4, result);
        Future<Result> future5 = service.submit(task5, result);

        try{
            result = future1.get();
            result = future2.get();
            result = future3.get();
            result = future4.get();
            result = future5.get();
        } catch (InterruptedException ie){
            ie.getMessage();
        } catch (ExecutionException ee){
            ee.getMessage();
        } catch (Exception e){
            e.getMessage();
        }

        System.out.println("처리결과 : "+ result.accumValue);
        service.shutdown();
    }
}

 

 

 


3-2) 콜백

콜백은 스레드가 요청된 작업을 완료하면 특정 메서드를 자동으로 실행하는 방식이다. 자동실행되는 메서드를 콜백 메서드라고 한다.

 

import java.nio.channels.CompletionHandler;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CallbackEx {

    private ExecutorService executorService;

    public CallbackEx(){
        executorService = Executors.newFixedThreadPool(8);
    }

    private CompletionHandler<Integer, Void> callback =
            new CompletionHandler<Integer, Void>() {
                @Override
                public void completed(Integer result, Void attachment) {
                    System.out.println("completed - "+ result);
                }

                @Override
                public void failed(Throwable exc, Void attachment) {
                    System.out.println("failed - "+ exc.toString());
                }
            };

    public void doWork(final String x, final String y){

        Runnable task = new Runnable() {
            @Override
            public void run() {
                try{
                    int intX = Integer.parseInt(x);
                    int intY = Integer.parseInt(y);
                    int result = intX + intY;
                    callback.completed(result, null);
                } catch (NumberFormatException nf){
                    callback.failed(nf, null);
                } catch (Exception e){
                    e.getMessage();
                }
            }
        };
        executorService.submit(task);
    }

    public void finish(){
        executorService.shutdown();
        boolean isShut = executorService.isShutdown();
        System.out.println("executorService 종료여부 : "+ isShut);
    }

    public static void main(String[] args) {
        CallbackEx instance = new CallbackEx();
//        instance.doWork("3", "5");
        instance.doWork("7", "홍길동");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException ie){
            ie.getMessage();
        }
        instance.finish();
    }
}