Technology/Java

Java의 멀티 스레드(Multi-Thread) 프로그래밍에 대해 알아보자!

ikjo 2022. 9. 25. 04:46

개인적으로 자바의 멀티 스레드 프로그래밍 학습에 앞서 적어도 프로세스와 스레드의 차이 그리고 멀티 태스킹과 멀티 스레드의 차이에 대해선 제대로 알고 넘어가야겠다는 생각이 들었다. 이에 대한 내용은 다음을 참고할 수 있다.

 

 

프로세스 vs 스레드 그리고 멀티 태스킹 vs 멀티 스레드

프로세스와 스레드 자바 스터디의 일환으로 자바의 멀티 스레드 프로그래밍을 학습하던 중 이를 다루기 위해서는 앞서 프로세스와 스레드의 차이 그리고 더 나아가서 멀티 태스킹과 멀티 스레

ikjo.tistory.com

 

스레드의 구현 방법

자바에서 스레드를 구현하는 방법은 Thread 클래스를 상속받는 방법Runnable 인터페이스를 구현하는 방법이 있다. 

 

Thread 클래스를 상속받는 방법

먼저 Thread 클래스를 상속받아 스레드를 구현하는 방법에 대해 간단히 알아보자.

 

class Test {

    public static void main(String[] args) {
        ThreadExample threadExample = new ThreadExample();
        threadExample.start();
        
        for (int i = 0; i < 10; i++) {
            System.out.println("나는 메인 함수!!");
        }
    }
}

class ThreadExample extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            System.out.println("나는 스레드!!");
        }
    }
}

 

위 코드를 보면, Thread 클래스를 상속받은 ThreadExample이라는 클래스에서 run 메서드를 오버라이딩하고 있는 것을 볼 수 있는데, 이 run 메서드는 스레드가 생성된 후 가장 먼저 실행되는 것으로서 마치 최초 자바 프로그램이 실행될 때 main 메서드가 실행되는 것과 같다고 볼 수 있다. 사실  main 메서드의 작업을 수행하는 것 역시 스레드인데, 이를 Main 스레드라고 한다.

 

이제 프로그램을 실행하면 main 메서드 내 for문과 ThreadExample 객체의 run 메서드 내 for문이 번갈아 가면서 실행되는 것을 확인할 수 있다. 이때 스레드의 실행순서는 OS의 스케쥴러가 작성한 스케쥴에 의해 결정된다.

 

실행 결과(중간 과정 생략)

 

Runnable 인터페이스를 구현하는 방법

이제 Runnable 인터페이스를 구현하는 방법에 대해 간단히 알아보자.

 

class Test {

    public static void main(String[] args) {
        Runnable threadExample = new ThreadExample();
        Thread thread = new Thread(threadExample);
        thread.start();

        for (int i = 0; i < 100000; i++) {
            System.out.println("나는 메인 함수!!");
        }
    }
}

class ThreadExample implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            System.out.println("나는 스레드!!");
        }
    }
}

 

앞서 다루었던 Thread 클래스를 상속받는 방법과 동일하게 ThreadExample 클래스에서 run 메서드를 구현해준다는 점에서 동일하다. 다만 Thread 클래스를 상속받는 게 아닌 Runnable 클래스를 직접적으로 구현했으며, Runnable 타입의 객체를 Thread 객체를 생성할 때 인자로 넣어주었다. 

 

참고로 run 메서드라는 것은 Runnable 인터페이스에 정의된 추상 메서드이다. Thread 클래스는 이 Runnable 인터페이스를 구현하며 기본적으로 run 메서드를 다음과 같이 구현하고 있다.

 

 

위 코드에서 target이라는 참조변수를 통해 run 메서드를 호출하고 있는 것을 볼 수 있는데, 이 target은 앞선 예시에서 Runnable 인터페이스를 구현한 ThreadExample 객체에 대한 참조변수가 된다. 실제로 Thread 클래스에 정의된 일부 생성자를 보면 다음과 같이 Runnable 타입의 객체를 매개변수로 받는다는 것을 확인할 수 있다.

 

 

이러한 사실을 미루어 보았을 때 Thread 클래스를 상속하는 방법이나 Runnable 인터페이스를 구현하는 방법이나 근본적으로는 스레드를 구현하는 방법이 같다고 볼 수 있다.

 

Thread 클래스 상속 vs Runnable 인터페이스 구현

아주 간단하게 스레드를 구현하는 두가지 방법에 대해 살펴보았다. 이처럼 자바에서 스레드를 구현한다는 것은 결국 run 메서드를 구현하는 것과도 같다.

 

그런데 앞서 다루었던 두 가지 방법간의 차이는 무엇일까? 사실 어떤 하나의 기능을 구현함에 있어서는 두 방식간 큰 차이는 없다. 오히려 Runnable 타입의 객체를 만들지 않고 바로 Thread 클래스를 상속받은 앞서 예시로 다룬 ThreadExample 객체를 바로 생성해서 사용하는 것이 더 간단해보인다.

 

하지만, Thread 객체 생성 시 Runnable 인터페이스를 구현한 객체를 외부에서 주입함으로써 상대적으로 '재사용성(reusability)'이 있다는 장점이 있다. Thread 클래스를 상속한 객체를 생성하는 것은 사용자 입장에서 선택의 여지가 없다.(해당 객체에서는 특정적으로 구현된 내용만 실행할 수 있다.) 하지만 Thread 객체를 생성하는 시점에서 Runnable 인터페이스를 구현한 객체를(의존성)을 주입한다면, 사용자 입장에서 상황에 따라 다른 구현체를 선택할 여지가 생기게 된다.

 

이외에도 Runnable 인터페이스를 구현하는 방식의 장점을 굳이 꼽자면 상속이 가능하다는 장점도 있을 수 있겠다.(Thread 클래스를 상속하면 다른 클래스를 상속할 수 없게 된다.)

 

run 메서드 vs start 메서드

참고로 지금까지 별다른 언급은 하지 않았지만 Thread 클래스를 상속한 방식이나 Runnable 인터페이스를 구현한 방식이나 run 메서드를 구현해주었지만 실제 main 메서드에서는 run 메서드가 아닌 start 메서드를 호출해주고 있다. 

 

그렇다면 이 두 메서드의 차이는 무엇일까? 사실 run 메서드는 말그대로 메서드 자체일 뿐이다. 만일 main 메서드에서 run 메서드를 호출해주었다면 그것은 멀티 스레드가 아니라 Main 스레드 호출 스택(Call stack)에 run 메서드가 추가로 호출된 것일 뿐이다.

 

반면 start 메서드를 호출한다는 것은 새로운 스레드가 작업을 실행하는데 필요한 새로운 호출 스택을 생성한 다음에 run 메서드를 호출하는 것이다. (모든 스레드는 독립적인 작업을 수행하므로 자신만의 호출스택을 필요로 한다.)

 

 

참고로 다음 코드는 Thread 클래스에 정의된 start 메서드에 대한 코드이다. 실제 세부 구현 내용은 native 메서드로 처리되고 있다.

 

    public synchronized void start() {
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

        group.add(this);

        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
            }
        }
    }

    private native void start0();

 

앞서 언급했듯이 이러한 과정은 마치 자바 프로그램이 실행되면 Main 스레드가 생성되어 해당 스레드에서 최초 main 메서드가 호출되는 것과 동일하다. 아울러 main 메서드가 종료되면 Main 스레드의 호출스택이 소멸되듯이, run 메서드의 작업이 모두 종료되면 최종적으로 해당 스레드가 사용하던 호출 스택은 소멸된다.

 

이때 주의할 점으로는 main 메서드가 종료되었더라도 다른 스레드가 아직 작업을 마치지 않은 상태(run 메서드를 실행하는 중)에서는 프로그램이 종료되지 않는다. 즉, 실행 중인 스레드가 하나라도 있을 때는 프로그램이 종료되지 않는다.

 

 

자바의 스레드로 서버 프로그램 간단하게 구현하기

앞서 다룬 스레드로 아주 간단한 서버 프로그램을 예시로서 구현해보자.

 

import java.net.ServerSocket;
import java.net.Socket;

public class WebServer {

    public void run() {
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            while (true) {
                try {
                    Socket clientSocket = serverSocket.accept();
                    new Thread(() -> handleClient(clientSocket)).start();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    private void handleClient(Socket clientSocket) {
        // 클라이언트의 요청 읽기
        // 클라이언트의 요청에 맞는 작업 수행
        // 클라이언트에 응답 전송
        // 소켓 닫기
    }
}

 

WebServer 클래스의 run 메서드가 실행되면 최초 8080번 포트로 서버 소켓(serverSocket)을 생성한 후 클라이언트의 요청을 대기한다. (accept에서 Blocking) 클라이언의 요청을 받은 이후에는 해당 클라이언트용 소켓(clientSocket)을 생성한 후 새로운 스레드를 생성하여 클라이언트의 요청을 처리 및 결과를 응답해준 후 소켓을 닫고 작업을 종료한다.

 

참고로 이 경우엔 클라이언트의 요청이 올 때마다 새로운 스레드를 생성해서 작업을 처리하지만, 실제 웹 서버는 스레드 풀을 미리 만들어 놓고 사용자의 요청을 처리한다.

 

 

스레드의 상태

앞서 스레드의 실행 순서는 OS 스케쥴러가 작성한 스케쥴에 의해 결정된다고 했는데, 이때 스레드의 스케줄링에 있어 스레드는 6가지의 상태를 갖는다.

 

NEW(생성) 상태

가장 먼저 NEW(생성) 상태로서 스레드가 생성(Thread 객체 생성)은 되었지만 아직 start 메서드가 호출되지 않은 상태를 의미한다. 즉, 스레드 자신만의 독립적인 작업 공간(호출 스택)이 존재하지 않는 상태이다.

 

RUNNABLE(실행) 상태

start 메서드가 호출된 이후 실행 중이거나 실행 대기 중인 상태이다. 이처럼 start 메서드를 호출한다고 바로 실행되는 것이 아니라 실행대기열(큐)에 저장되어 자신의 차례가 될 때까지 기다려야 하는 것(OS에 의존적)이다.

 

BLOCKED(일시 정지) 상태

스레드가 실행 중에 동기화 블럭(I/O 입출력 block 등)에 의해 일시 정지된 상태이다. 예를 들면, 사용자의 입력을 기다리는 경우(BufferedReader의 readLine 메서드 등) 등이 있는데, 이러한 경우 스레드가 일시정지 상태에 있다가 사용자가 입력을 마치면 인터럽트가 발생하여 다시 RUNNABLE(실행 대기) 상태가 된다. (보통 바로 실행되지 않고 실행대기열 맨 뒤로 가서 자신의 차례를 기다린다.)

 

WAITING(일시 정지) 상태

스레드가 실행 중에 suspend, sleep, wait, join 등의 메서드에 의해 일시 정지된 상태로 지정된 일시정지시간이 다 되거나(time-out), notify, resume, interrupt 등의 메서드가 호출되면 다시 RUNNABLE(실행 대기) 상태가 된다.

 

TERMINATED(종료) 상태

실행을 모두 마쳤거나(호출 스택이 비었거나) stop 메서드가 호출됨으로써 스레드의 작업이 종료된 상태이다.

 

스레드의 상태를 제어하는 메서드들

앞서 잠깐 언급했지만, 스레드를 일시 정지 상태로 만들거나 다시 실행 대기 상태로 만드는 메서드들이 있는데, 이에 대해 가볍게 알아보자.

 

대표적으로 sleep 메서드의 경우 지정된 시간 동안 '현재' 스레드(이 메서드를 호출한 스레드)를 일시 정지시킨다. 지정된 시간이 지나면 자동적으로 다시 실행 대기 상태가 된다.

 

join 메서드는 어떤 스레드 자기 자신이 호출한(start 메서드) 또 다른 쓰레드의 작업이 모두 마칠 때까지(또는 지정된 시간 동안) 자기 자신의 스레드를 일시 정지 상태로 만든다. 

 

interrupt 메서드는 앞서 sleep이나 join 메서드에 의해 일시 정지 상태가된 스레드를 깨워(InterruptedException 발생) 실행 대기 상태로 만든다.

 

stop 메서드는 호출되는 즉시 스레드가 종료된다.

 

suspend 메서드는 스레드를 일시 정지 상태로 만드는데, resume 메서드에 의해 다시 실행 대기 상태로 만든다.

 

※ 참고로 stop, suspend, resume 메서드는 스레드를 교착상태(dead-lock)로 만들기 쉬워 deprecated되었다.

 

마지막으로 yield 메서드는 자신에게 주어진 실행 시간을 다른 스레드에게 양보하고 자기 자신은 실행 대기 상태로 만든다.

 

 

스레드의 우선순위

Thread 클래스에는 다음과 같이 priority라는 멤버변수가 정의되어있다.

 

 

이 멤버변수는 priority 이름에 맞게 우선순위의 값을 나타내는데, 이 값에 따라 스레드의 실행 시간이 달라질 수 있다. 정확하게는 이 값이 클수록 OS 스케쥴러에 의해 좀 더 우선적으로 처리되는 것이라고 볼 수 있다. 이 값의 범위는 1 ~ 10으로 기본적으로 Main 스레드는 5의 우선순위를 갖는데, 별다른 우선순위 설정 없이 Main 스레드에서 또 다른 스레드를 생성하는 경우 해당 스레드의 우선순위는 자동적으로 5로 설정된다.

 

아래 코드는 실제 Thread 클래스에 정의된 생성자에서 구현된 일부 내용으로 기본적으로 부모 스레드의 우선순위 값으로 초기화해주는 것을 확인할 수 있다.

 

 

이때 스레드의 우선순위를 지정한 값으로 변경 시에는 setPriority 메서드를 사용하며, 쓰레드의 우선순위 값을 반환할 때는 getPriority 메서드를 사용한다.

 

 

동기화(synchronization)

Main 스레드 외로 또 다른 스레드를 생성함으로 인해 하나의 프로세스에는 여러개의 스레드들이 실행될 수 있게 되었다. 이때 스레드들이 어떤 자원을 공유하지 않거나 각자만의 독립적인 작업을 수행한다면 다행이겠지만, 실제로는 어떤 자원을 공유하거나 다른 스레드에 영향을 주는 작업을 수행하는 경우가 허다하다.

 

특정 스레드가 실행되고 있다가 작업을 다 마치지도 않았는데, 다른 스레드에 제어권이 넘어감으로써 기존 작업 중이던 작업이 의도한대로 처리되지 않는(공유하는 데이터를 변경하는 등의 이유로) 경우가 발생하는 것이다. 이러한 문제를 방지하기 위해서는 특정 스레드가 작업을 수행 중에는 다른 스레드가 해당 작업을 간섭하지 않도록 해야하는데, 이러한 것을 동기화(synchronization)이라고 한다.

 

임계 영역과 락

이때 동기화를 다룰 때 가장 중요한 개념은 '임계 영역(critical section)''락(lock)'이다. 공유 데이터를 사용함으로써 다른 스레드가 작업에 영향을 줄 수 있는 부분을 임계 영역으로 지정하고 '락'을 획득한 단 하나의 스레드만이 해당 작업을 수행할 수 있도록 하는 것이다.

 

그리고 작업을 마친 스레드는 다시 락을 반납하고 다른 스레드(작업이 끝날 때까지 기다리고 있던)가 이를 획득하여 작업을 수행하게 된다.

 

synchronized 키워드

자바에서는 synchronized 키워드를 제공하는데, 이는 앞서 언급한 임계 영역을 지정하는데 사용된다.

 

우선 별도 동기화 처리를 해주지 않은 아래 코드를 확인해보자.

 

class Market {
    private int stock = 1000;

    public void order(int x) {
        if (stock >= x) {
        /* 재고를 빼기 전에 sleep을 주면 음수가 나올 확률이 증가한다.
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        */
            stock -= x;
        }
    }

    public boolean isEnough() {
        return stock > 0;
    }

    public int getStock() {
        return stock;
    }
}

class ThreadExample implements Runnable {

    Market market = new Market();

    @Override
    public void run() {
        while (market.isEnough()) {
            market.order(100);
        }

        System.out.println(market.getStock());
    }
}

class Test {

    public static void main(String[] args) {
        Runnable threadExample = new ThreadExample();
        Thread thread1 = new Thread(threadExample);
        Thread thread2 = new Thread(threadExample);
        thread1.start();
        thread2.start();
    }
}

 

최초 main 함수에서 2개의 스레드를 생성하고 각각의 스레드들은 공유하고 있는 데이터인 Market 객체의 재고(stock)가 0 보다 클 경우 계속 100씩 빼주도록 했다. 이때 싱글 스레드 관점에서는 재고가 주문한 금액 보다 큰 경우에만 100씩 빼주므로 재고가 음수가 될 일은 없어보인다.

 

하지만 현재 두 개의 스레드가 재고라는 데이터를 공유해서 사용하고 있으므로, 해당 재고가 음수가 되는 경우가 발생할 수 있게 된다. (운이 좋게 음수가 되지 않을 수도 있다.) 이는 재고가 10일 때 한 스레드(thread1)가 10을 빼주기 직전 실행권이 다른 스레드(thread2)로 넘어가 이 스레드(thread2)가 10을 빼주고 곧 이어 처음 스레드(thread1) 10을 빼주었기 때문이다.

 

즉, 기존 스레드(thread1)의 작업을 다른 스레드(thread2)가 간섭한 것이고, 이는 근본적으로 같은 데이터를 공유해서 사용하기 때문에 발생한 것이다.

 

이제 이러한 문제를 synchronized 키워드를 사용해 해결해보자.

 

    public synchronized void order(int x) {
        if (stock >= x) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            stock -= x;
        }
    }

 

앞서 Market 클래스에 선언된 order 메서드 반환타입 앞에 synchronized 키워드를 붙여주었다. 이 경우 특정 스레드가 해당 메서드를 호출할 경우 lock을 얻는데, 이때 다른 스레드는 해당 메서드에 접근할 수 없게 된다. 메서드의 작업을 모두 마치면 lcok을 다시 반납한다. 참고로, 이러한 lock은 객체가 소유한 단 하나의 lock으로서 order 메서드 외 다른 메서드에도 synchronized 키워드가 붙었다면, order 메서드가 처리되는 동안 다른 synchronized 메서드 역시 동시 호출이 불가능해진다.

 

앞서 재고를 빼기 전에 다른 스레드로 제어권이 넘어감으로써 재고가 음수가 되는 원치 않는 결과가 발생했지만 synchronized 키워드를 붙혀준 이후로는 재고가 음수가 되는 일이 발생하지 않는다.

 

참고로 synchronized 키워드가 메서드 반환타입 앞에 선언될 경우 이는 메서드 전체를 임계 영역으로 지정하겠다는 것을 의미한다. 만일 해당 메서드 내에서 특정 부분에 대해서만 임계영역을 지정하고자할 경우에는 다음과 같이 선언할 수 있다.

 

    public synchronized void order(int x) {
        /**
         * 스레드간 간섭이 발생하지 않는 영역
         */
        
        synchronized (this) { // 참조변수 this는 락을 걸고자하는 객체를 참조
            if (stock >= x) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                stock -= x;
            }
        }

 

앞서 언급했듯이 모든 객체는 각자 자신만의 락(lock)을 하나씩만 가지고 있는데, 해당 객체의 lock을 가지고 있는 스레드만 임계 영역으로 지정한 부분의 작업을 수행할 수 있다. 따라서 불필요하게 많은 임계 영역 지정은 프로그램의 성능을 저하시키므로 위와 같이 synchronized 블럭을 이용해 필요한 부분만 임계 영역을 지정하는 것이 필요하다.

 

이처럼 synchronized로 인해 특정 스레드가 장시간 특정 객체의 락을 가진 상태를 유지할 경우 다른 스레드들이 해당 객체의 락을 기다리느라 전체 작업의 효율이 낮아지는 문제는 언제든 발생할 수 있다. 이러한 문제를 해결하기 위해 wait 메서드와 notify 메서드가 등장했는데, 이는 모든 클래스의 최상위 클래스인 Object에 정의된 메서드로 다음 글을 참고해볼 수 있다.

 

 

Java의 Object 클래스가 제공하는 기능 분석

Object 클래스란? 객체지향 프로그래밍 언어인 자바에서 모든 코드는 반드시 클래스 안에서 존재하며, 이러한 클래스는 서로 관련된 속성과 기능 간의 그룹으로 묶이게 된다. 이때 Object 클래스는

ikjo.tistory.com

 

 

교착 상태(dead-lock)란?

동기화 처리를 해주다보면(잠금을 사용하다 보면) 아사 현상, 경쟁 상태에 놓이는 문제(위 Object 클래스 관련 글에서 wait, notify 메서드를 다룰 때 언급)도 있지만, 교착 상태(dead-lock) 역시 만만치 않은 문제이다.

 

마지막으로 교착 상태에 대한 개념을 간단하게 다루면서 마무리하고자 한다.

 

우선 교착 상태란 2개 이상의 스레드가 서로간의 작업이 끝나기만을 기다림으로 인해 작업이 '더이상 진행되지 못하는 상태'를 말한다. 이러한 교착 상태는 여러 스레드들이 잠금을 사용하면서 작업을 수행하는 경우 발생할 수 있는 자연적인 문제로서 이를 감시(모니터링)하다가 교착상태가 발생하면 강압적인 방법으로 해결해줄 필요가 있다.

 

교착 상태 발생 조건

이러한 교착 상태는 '상호 배제', '비선점', '점유와 대기', '원형 대기'의 조건을 모두 충족한 경우에 발생하는데, 이 중 하나라도 충족되지 않을 경우 교착상태는 발생하지 않는다.

 

여기서 '상호 배제'란 서로 다른 스레드간 공유할 수 없는 배타적인(임계 영역으로 지정된) 자원을 사용하는 경우이며, '비선점'이란 한 스레드가 사용 중인 자원을 다른 스레드가 빼앗을 수 없는 비선점 자원을 사용하는 경우이다.

 

그리고 '점유와 대기'는 한 스레드가 어떤 자원을 사용하는 상태에서 다른 자원을 기다리는 상태여야한다는 것이고, 마지막으로 '원형 대기'는 점유와 대기를 하는 스레드 간의 관계가 원을 이루어야 한다는 것이다.

 

교착 상태 해결 방법

교착 상태를 해결하는 방법에는 '교착 상태 예방', '교착 상태 회피', '교착 상태 검출과 회복'이 있다.

 

우선 '교착 상태 예방'이란 앞서 언급한 4개의 교착 상태 발생 조건 중 하나를 무력화하는 방식이다. 하지만 이 방법의 경우 자원을 보호할 수 없게 되거나 프로세스 작업 방식을 제한하고 자원을 낭비하게 되는 등의 문제가 있어 사용되지 않는다.

 

그리고 '교착 상태 회피'란 자원 할당량을 조절하여 교착 상태를 해결하는 방식으로, 자원을 할당하다가 교착 상태를 유발할 가능성이 있다고 판단되면 자원 할당을 중단하는 방식이다. 하지만 이 방법 역시 시스템 전체 자원 수가 고정적이어야 하거나 자원이 낭비되는 등의 문제가 있어 사용되지 않는다.

 

마지막으로 '교착 상태 검출과 회복'은 자원 할당 그래프를 모니터링하면서 교착 상태가 발생하는지 살펴 보는 방식으로 만일 교착 상태가 발생하면 교착 상태 회복 단계(교착 상태를 유발한 스레드 강제 종료)가 진행된다. 해당 방법 역시 완벽한 것은 아니지만 앞서 언급한 '교착 상태 예방'과 '교착 상태 회피' 대비 가장 현실적인 방법이다.

 

 

참고자료

  • 도우출판 "자바의 정석"
  • 한빛미디어 "쉽게 배우는 운영체제"
  • 에이콘 "React.js, 스프링 부트, AWS로 배우는 웹 개발 101"