프로그래밍언어 자바 Part-2

12. 쓰레드 프로그래밍

이번 강좌에서는 쓰레드 프로그래밍의 기본 개념을 이해하고 쓰레드 생성과 동기화, 제어 방법과 쓰레드 풀을 활용한 프로그래밍 방법을 배웁니다.

이 강의를 통해 쓰레드가 무엇인지 이해하고 자바 에서 기본적인 멀티 쓰레드 프로그램을 구현할 수 있게 됩니다.



01: 쓰레드 프로그래밍 개요

쓰레드(Thread) 이해를 위해서는 기본적으로 다중 작업에 대한 개념을 이해해야 한다. 다중 작업은 하나의 프로그램이 여러 작업을 동시에 수행하는 것을 말한다. 예를 들어, 워드 프로세서에서 문서를 작성하면서 동시에 인쇄를 하거나, 웹 브라우저에서 웹 페이지를 보면서 동시에 파일을 다운로드 받는 것이다.

지금 까지 배운 자바 프로그램은 하나의 작업을 수행하는 프로그램이었다. 즉, 프로그램이 실행되고 정해진 순서에 따라 일을 처리하고 바로 종료되는 형식이었다.

만일 중간에 while() 과 같이 반복 순환하는 코드가 있다면 반복문이 수행되는 동안 프로그램은 다른 작업을 처리할 수 없다. 예를 들어 네트워크 프로그래밍에서 배운 서버 프로그램은 클라이언트의 접속을 기다리고 연결된 클라이언트와 메시지를 주고받는 동안 다른 클라이언트의 접속을 받을 수 없다.

클라이언트도 마찬가지로 키보드로 메시지를 입력하는 동안은 서버로부터 메시지를 수신할 수 없다.

프로세스와 쓰레드

프로세스(Process)

프로세스는 실행중인 프로그램을 말한다. 프로세스는 운영체제로부터 자원을 할당받는 작업의 단위이다. 운영체제로부터 시스템 자원을 할당받는 작업의 단위이기 때문에 프로세스마다 독립된 메모리 영역을 할당받는다. 따라서, 프로세스는 각각 독립된 메모리 영역을 차지하고 있기 때문에 다른 프로세스의 자원에 접근할 수 없다.

쓰레드(Thread)

쓰레드는 프로세스가 할당받은 자원을 이용하는 실행의 단위이다. 한 프로세스는 하나 이상의 쓰레드를 가질 수 있으며, 이를 멀티 쓰레드(Multi-thread)라고 한다. 프로세스 내의 쓰레드들은 프로세스의 자원들을 공유하여 사용할 수 있다. 따라서, 쓰레드들은 같은 프로세스 내에서 동시에 동작하는 여러 실행의 흐름으로 볼 수 있다.

쓰레드는 경량 프로세스라고도 불리우며 동시 실행을 위해 프로세스를 나누는 것보다 더욱 효율적이다. 쓰레드는 프로세스 내에서 Stack만 따로 할당받고 Code, Data, Heap 영역은 공유하기 때문이다. 따라서, 쓰레드 간의 Context Switching은 Stack 영역만 처리하면 되기 때문에 프로세스 간의 Context Switching에 비해 빠르다.

쓰레드 생성

자바에서 쓰레드를 생성하는 방법은 세 가지가 있다. 첫 번째는 Thread 클래스를 상속받아 run() 메소드를 오버라이딩하는 방법이고, 두 번째는 Runnable 인터페이스를 구현하는 방법이다. Runnable 인터페이스는 run() 추상 메서드 하나만 정의하고 있기 때문에 함수형 인터페이스로 사용할 수 있으며 람다식을 이용해 구현하는 것도 가능하다. 세 가지 방법 모두 run() 메소드를 오버라이딩하여 쓰레드가 실행할 코드를 구현하게 된다.

run() 메소드에는 쓰레드로 실행할 코드가 위치한다고 이해하면 된다. 실행은 start() 메소드를 호출함으로써 시작된다. start() 메소드는 쓰레드를 실행시키기 위해 쓰레드를 준비시키고, run() 메소드를 호출한다. 따라서, run() 메소드를 직접 호출하는 것은 쓰레드를 실행시키는 것이 아니라 단순히 메소드를 호출하는 것이다.

// 방법1: Thread 클래스를 상속받아 run() 메소드를 오버라이딩하는 방법
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("MyThread");
    }
}

// 방법2: Runnable 인터페이스를 구현하는 방법
class MyThread implements Runnable {
    @Override
    public void run() {
        System.out.println("MyThread");
    }
}

// 방법3: 람다식을 이용한 방법
new Thread(() -> System.out.println("MyThread")).start();

어떤 방법을 사용할지는 상황에 따라 다르다. 만일 쓰레드가 다른 클래스를 상속받아야 한다면 Runnable 인터페이스를 구현하는 방법을 사용해야 한다. 또한, 람다식을 이용한 방법은 코드가 간결하고 쓰레드가 한 번만 사용되는 경우에 사용하면 좋다.

쓰레드 상태

기본적으로 멀티 쓰레드 프로그램을 구현하는 것은 단순히 동시에 여러 작업을 병렬로 실행하는 것으로 끝나는 문제가 아니라 쓰레드의 상태를 관리하고 제어할 수 있어야 한다. 쓰레드는 다음과 같은 상태를 가진다.

쓰레드 제어

실행중인 쓰레드는 다음의 메서드를 통해 제어할 수 있다. 쓰레드에 대한 제어는 쓰레드의 상태를 변경하는 것으로 스케쥴링에 영향을 준다.

쓰레드를 강제로 종료시키는 메서드로 stop()이 있지만 deprecated 되었기 때문에 사용하지 않는 것이 좋다. stop() 메서드는 쓰레드가 사용하고 있는 자원을 정리하지 않고 즉시 종료시키기 때문에 쓰레드가 사용하고 있던 자원이 불안정한 상태로 남게 된다. 따라서, 쓰레드를 종료시키기 위해서는 쓰레드가 종료될 때까지 기다리는 방법을 사용해야 한다.

실습개요

쓰레드 개념 이해와 구현 과정을 배우기 위한 간단한 프로그램을 만들어 본다.

프로그램은 사용자로 부터 입력을 받는 과정에서 쓰레드를 사용하여 입력을 받는 동안 다른 작업을 수행할 수 있도록 한다. 사용자로부터 입력을 받는 동안 다른 작업을 수행할 수 있도록 하기 위해서는 입력을 받는 부분을 쓰레드로 구현해야 한다.

먼저 쓰레드를 사용하지 않는 예제이다. 사용자로부터 입력을 받는 동안 다른 작업을 수행할 수 없기 때문에 입력을 받는 동안 다른 작업을 수행할 수 없다.

public class SingleThreadExam {
    public static void main(String[] args) {
        Scanner scan = new Scanner(System.in);
        System.out.print("입력 하세요: ");
        System.out.println("입력 값: "+scan.next());

        for(int i=10; i>=0; i--) {
            System.out.println("카운트: "+i);
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

다음은 쓰레드 버전으로 키보드 입력을 받는 동안 카운트를 출력하는 프로그램이다. 쓰레드를 사용하면 키보드 입력을 받는 동안 카운트를 출력할 수 있다.

        Scanner scan = new Scanner(System.in);

        new Thread(() -> {
            while(true) {
                System.out.print("입력 하세요: ");
                String input;
                if((input = scan.next()) != null) {
                    if (input.equals("quit")) {
                        System.out.println("프로그램을 종료합니다.");
                        System.exit(0);
                    } else {
                        System.out.println("입력 값: " + input);
                    }
                }
            }
        }).start();

        for(int i=10; i>=0; i--) {
            System.out.println("\n카운트: "+i);
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

실행 결과

프로그램을 실행하면 콘솔창에 입력을 받기 위해 메시지가 출력됨과 동시에 카운트도 진행이 된다. 콘솔이라는 한계로 인해 입력과 출력되는 부분이 겹쳐 보이지만 입력과 출력이 동시에 진행되고 있음을 알 수 있다.


02: 쓰레드 풀과 Executor Service

실제 멀티 쓰레드 프로그램을 작성하다 보면 쓰레드를 관리하거나 여러 작업이 동시에 시작되지만 처리 결과를 리턴받고 다른 작업과 보조를 맞춰 진행하거나 하는 등의 정밀한 작업이 필요할 수 있다.

이런 경우 Executor Service를 사용하면 쓰레드를 관리하고 작업을 처리하는데 있어서 편리하다. Executor Service는 쓰레드 풀을 관리하는 클래스이다. 쓰레드 풀은 쓰레드를 미리 생성해 놓고 작업이 있을 때 쓰레드를 사용하고 작업이 끝나면 쓰레드를 반납받아 다시 쓰레드 풀에 넣는 방식으로 쓰레드를 재사용한다.

Java의 Executor Service는 JDK에서 제공하는 프레임워크로, 비동기 모드에서 작업 실행을 단순화합니다. 일반적으로 비동기 작업은 백그라운드에서 실행되며 사용자는 작업이 완료될 때까지 기다릴 필요가 없다.

Executor Service는 스레드 풀을 관리하므로 개발자는 수동으로 스레드 생명 주기를 관리할 필요가 없으며 모든 작업이 완료되면 스레드를 종료할 수 있다.

다음은 Executor Service를 사용하는 간단한 예 이다.

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

public class Main {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        for (int i = 0; i < 10; i++) {
            Runnable worker = new WorkerThread("" + i);
            executorService.execute(worker);
        }
        executorService.shutdown();
        while (!executorService.isTerminated()) {
        }
        System.out.println("Finished all threads");
    }
}

class WorkerThread implements Runnable {
    private String command;

    public WorkerThread(String s) {
        this.command = s;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " Start. Command = " + command);
        processCommand();
        System.out.println(Thread.currentThread().getName() + " End.");
    }

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

참고 자료