자바 공부 기록 2회독(10) - 2024.1.28

동준·2024년 1월 28일
0

개인공부(자바)

목록 보기
12/16

9. 멀티 스레드

이제부터 자바의 기본 문법을 넘어서 개발에 있어 실용적으로 쓸 수 있는 개념들이 와다다다 나올 예정이다.아 벌써부터 두려으허언흐어헣

생각해보니 프론트엔드 공부할 때도 기본 문법의 최대 고비가 비동기였지... 그놈의 Promise 때문에 정말 너무너무너무너무너무 힘들었던 기억이 난다. 뭔가 내가 알던 범주를 넘어서는 작업의 처리여서 당시에는 그림 직접 그려가며 눈물을 흘려가며 공부했던 기억이...

1회독 당시에 이 비동기와 상당히 유사하다는 느낌을 받은 개념이 자바에도 있었다. 물론 유사하다는 것이 똑같은 로직과 과정으로 이뤄진 것은 아니고, 코드는 윗줄에서부터 차례대로 읽혀져 내려오며 하나의 작업을 이루는데 그 작업을 처리하지 않고 잠시 미뤄두는(?) 느낌이 들었다. 바로 멀티 스레드였다.

물론 미뤄드는 것이라기 보다는 순서를 조정하는 것이 더 정확하고, 이것은 스레드 관련 기능들 중에서 일부에 불과하지만... 일단은 두려운 마음을 달래며ㅠ 들어가야지...

1) 멀티 스레드 이전에 스레드

스레드(thread)는 어떤 프로그램 내에서, 정확히는 프로세스 내에서 실행되는 흐름의 단위라고 한다. 스레드 개념을 파악하려면 프로세스 개념을 먼저 파악해야 된다.

(1) 프로세스? 스레드?

아까 스레드 정의를 찾으면서 나왔는데, 프로세스 내에서 실행되는 흐름이라고 했다. 이 말은 곧, 프로세스 내부에 스레드가 있다는 뜻.

운영체제는 실행 중인 프로그램을 프로세스로 관리하게 된다. 프로세스란 간단하게 말해서 실행 중인 프로그램의 인스턴스다. 만약 여러 개의 작업을 수행하려면 여러 개의 프로세스를 생성해서 처리하게 되는데, 이 프로세스들을 멀티 프로세스라고 부른다.

각각의 프로세스 내부에는 개별적으로 단위화된 작업을 맡아서 처리하는 스레드가 존재하며, 하나의 프로세스가 두 가지 이상의 작업을 처리할 수도 있는데 이것은 그 프로세스 내부에 두 개 이상의 스레드가 존재하기 때문이다. 이런 여러 스레드를 멀티 스레드라고 부른다.

이 멀티 스레드는 스케줄러에 의해 독립적으로 관리된다. 아래 그림과 링크를 참조할 것.

https://www.javatpoint.com/process-vs-thread

프로세스의 정확한 동작과 의미는 컴퓨터 지식으로 넘어가기에 우선은 자바 문법 공부의 취지에 맞춰서 스레드 개념 파악에 조금 집중하는 걸로!

(2) 왜 목차 제목이 스레드가 아닌 멀티 스레드?

좀 엉뚱한 고찰일 수도 있지만, 왜 목차가 스레드가 아닌 멀티 스레드라고 제목을 지었을까... 생각을 해봤다.

멀티 프로세스의 예시를 생각해보면... 메신저 프로그램의 경우, 채팅 기능파일 전송 기능을 동시에 수행하므로 멀티 프로세스로 처리된다고 말할 수 있겠다. 근데 여기서 파일 전송 기능이 오류가 났다고 해서 채팅 기능이 안 되는 경우(...가 있었나?)는 드물다. 이 말인 즉슨, 프로세스들은 서로 독립적이므로 어느 프로세스가 오류가 발생해도 다른 프로세스에게는 오류가 발생하지 않는다.

하지만, 멀티 스레드의 경우는 조금 다르다. 결국 멀티 스레드는 하나의 프로세스 내부에서 다발적으로 생성되는 것이기 때문에 만약 멀티 프로세스가 아닌 멀티 스레드로 동작하는 메신저 프로세스를 가정할 경우, 파일 전송 기능이 오류가 나면(즉, 예외가 발생하면) 메신저 프로세스 자체가 종료되므로 멀쩡히 돌아가는 다른 스레드에도 영향을 끼치게 된다.

(3) 메인 스레드와 메인 메소드

자바에서 엄청 많이 보는 main(String[] args), 즉 메인 메소드는 자바 프로그램의 시작점이자 종료점이 된다. 이 메인 메소드를 실행시키는 것이 메인 스레드다. 메인 스레드가 메인 메소드의 첫 코드부터 실행해서 마지막 코드(혹은 return문)에서 실행을 종료시킨다.

메인 스레드에서 필요에 따라 추가 작업용 스레드를 생성해서 실행이 가능하다. 이 부분이 멀티 스레드 개념이 되겠다. 여기서 중요한 부분이 있다.

멀티 스레드를 채택한 프로세스의 시작과 종료는 메인 스레드에만 좌우되지 않는다. 즉, 멀티 스레드가 생성한 이상 설령 메인 스레드가 종료되었다고 한들, 작업 스레드가 실행 중이면 프로세스는 종료되지 않는다.

다른 프로그래밍 언어(자바스크립트)의 스레드 개념에 대해서는 아래 링크 참조!

https://velog.io/@jaehyeon23/Javascript-%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9CThread

2) 스레드 기본

자바의 멀티 스레드 역시 클래스로 관리한다(클래스 만능주의).

다만, 메인 스레드는 프로그램 동작 특성 상 무조건 존재해야만 하므로 메인 스레드까지 클래스로 관리할 필요는 없다. 메인 메소드에 작성한 코드가 곧 메인 스레드의 작업 내용이 되니까...

클래스로 관리할 부분은 바로 추가되는 작업 수만큼의 스레드가 되겠다. 이걸 맡는 클래스가 Thread 클래스

(1) Thread 클래스

Thread 클래스를 활용하려면 Runnable 인터페이스를 구현해야 된다.

Runnable 인터페이스에는 run() 메소드가 정의되어 있어서, run() 메소드 내부의 실행 블록에 해당 스레드가 실행할 코드를 작성해야 된다.

class Task implements Runnable {
	@Override
    public void run() {
    	// 스레드 실행용 코드
    }
}

Runnable 인터페이스를 바탕으로 run() 메소드를 정의한 Task라는 클래스를 작성했다. 해당 클래스를 참조하는 객체 참조변수를 활용해서 Thread 클래스의 생성자 매개변수에 담는다.

Runnable task = new Task();
// 객체 참조변수

Thread thread = new Thread(task);
// Thread 생성자 매개변수에 담기

사실 이건 내 이해를 위한 작성 연습이었고, 익명 구현 객체를 매개값으로 사용하는 것이 더 보편적이다.

Thread thread = new Thread(new Runnable() {
	@Override
    public void run() {
    	// 스레드 실행용 코드
    }
});

혹은 Thread 클래스를 상속받는 자식 클래스를 작성해서, run() 메소드를 재정의해서 스레드 실행 코드를 작성하는 것 또한 방법이 될 수 있다.

public class WorkderTrhead extends Thread {
	@Override
    public void run() {
    	// 스레드 실행 코드
    }
}

Thread thread = new WorkderThread();

아니면 아까 익명 구현 객체를 활용한 것처럼 이것 역시 익명 자식 객체를 사용하는 것이 더 보편적이다.

Thread thread = new Thread() {
	@Override
    public void run() {
    	// 스레드 실행 코드
    }
}

이렇게 작업용 스레드가 작성됐다고 알아서 호출이 되는 것은 아니고, Thread 클래스의 객체 참조변수로부터 start() 메소드를 호출해야 작업 스레드가 본인의 역할을 시작한다.

thread.start();

(2) 동시 작업 처리로써의 의의

Thread 클래스를 활용하는 것이 아닌, 그냥 메인 스레드에 모든 코드를 때려박으면 되는 거 아닌가...? 라는 생각이 처음에 들었는데, 예제 작성을 통해 단박에 이해했다.

import java.awt.Toolkit;

public class BeepPrintExample {
    public static void main(String[] args) {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 5; i++) {
            toolkit.beep();
            try { Thread.sleep(500);
                // sleep 메소드는 Thread 클래스의 정적 메소드
            } catch(Exception e) {}

        } // 뾱 뾱 뾱 뾱 뾱 들린 다음에...

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try { Thread.sleep(500); } catch(Exception e) {}
        } // 띵 띵 띵 띵 띵 비동기마냥 짠하고 뜸
    }
}

Thread.sleep() 메소드는 스레드를 잠시 일시정지 시키는 정적 메소드다. 위의 코드에서 사실 내가 작성하고 싶었던 것은 비프음과 메시지 출력이 동시에 이뤄지는 것인데, 위의 코드는 메인 스레드가 위에서부터 코드를 순차적으로 읽어내리기 때문에 먼저 작성된 비프음 로직이 먼저 작동한 다음에, 아래에 작성된 출력 로직이 다음 순서로 작동되는 것이다.

이제 이것을 Thread 클래스를 활용해서 리팩토링해보자.
아래 내용은 익명 구현 객체를 활용한 코드다.

import java.awt.Toolkit;

// 익명 구현 객체 활용
public class BeepPrintExample {
    public static void main(String[] args) {
    	// 익명 구현 객체를 Thread 생성자의 매개값으로 전달
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                Toolkit toolkit = Toolkit.getDefaultToolkit();
                for (int i = 0; i < 5; i++) {
                    toolkit.beep();
                    try { Thread.sleep(500); } catch(Exception e) {}
                } // 작업 스레드가 실행하는 코드
            }
        }); // 작업 스레드 생성

        thread.start(); // 작업 스레드 실행

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try { Thread.sleep(500); } catch(Exception e) {}
        } // 메인 스레드가 코드를 실행하면서 "띱"과 "띵"이 동시에
    }
}

// 어떤 동작 이전에 스레드 생성부터 먼저하면 
// 새롭게 생성된 스레드와 메인 스레드가 같은 출발선에서 코드를 실행하기 때문에 동시에 실행

혹은 다음과 같이 익명 자식 객체를 활용할 수도 있다.

import java.awt.Toolkit;

// 익명 자식 객체 활용
public class BeepPrintExample {
    public static void main(String[] args) {
    	// 익명 자식 객체로 곧바로 run() 메소드 오버라이딩
        Thread thread = new Thread() {
            @Override
            public void run() {
                Toolkit toolkit = Toolkit.getDefaultToolkit();
                for (int i = 0; i < 5; i++) {
                    toolkit.beep();
                    try { Thread.sleep(500); } catch(Exception e) {}
                }
            }
        }; 

        thread.start();

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try { Thread.sleep(500); } catch(Exception e) {}
        }
    }
}

(3) 스레드 명칭

메인 스레드는 main이라는 명칭을 지니고 있고, 작업 스레드는 Thread-n이라는 명칭을 지니게 된다. 물론, 고정된 건 아니고 바꾸고 싶으면 Thread 클래스의 인스턴스 메소드인 setName("스레드 명칭")을 활용하면 된다.

현재 처리되는 작업을 맡는 스레드의 명칭을 조회하려면 정적 메소드인 currentThread()로 스레드 객체의 참조를 얻고, getName() 인스턴스 메소드로 조회한다.

Thread thread = Thread.currentThread();
// 스레드 객체 참조변수
// 현재 해당 코드를 실행하는 스레드의 객체 참조를 얻는다.

thread.setName("THREAD_NAME");
// 스레드 명칭 재설정

System.out.println(thread.getName());
// 스레드 명칭 조회 및 출력

3) 스레드 상태 메소드

역시나, 외우는 것에 중점을 둘 것이 아닌 코드를 직접 작성하면서 손에 언능언능 익숙해지는 것을 주 목표로 삼아봅시다아아아아

스레드 상태의 자세한 매커니즘은

  1. Thread 객체 생성(NEW)
  2. start() 메소드 호출
  3. 실행 대기 상태(RUNNABLE) 진입
  4. CPU 스케줄링에 따른 run() 메소드 실행(RUNNING)
  5. CPU 스케줄링에 따른 실행 대기 상태 복귀 및 다른 스레드 실행 상태 진입
  6. 4번 과정과 5번 과정 반복하면서 run() 메소드 조금씩 실행
    스레드가 실행 불능 상태면 실행 상태에서 일시 정지 상태로 진입. 후에 다시 실행하려면 실행 대기 상태로 돌아가야 함.
  7. 실행 상태에서 run() 메소드가 종료되면 종료 상태(TERMINATED) 진입

여기서 중요한 것은, 실행 대기 상태에서 실행 상태로 갔다가 다시 실행 대기 상태로 복귀하는 과정이 있을 수 있고, 또한 실행 상태에서 일시 정지 상태로 갔다가 다시 실행하기 위해서 실행 대기 상태로 돌아가는 과정이 있을 수 있다. 이것들을 제어하는 Thread 클래스의 메소드들에 대해 알아보자.

(1) sleep(long millis) : 스레드 일시 정지

밀리세컨드 단위로 실행 상태인 스레드를 일시 정지 상태로 보내기 위해서는 sleep(long millis) 정적 메소드를 사용한다. 시간이 지나면 자동으로 실행 대기 상태로 돌아간다.

일시 정지 상태에서는 InterruptedException 예외가 발생할 수 있기 때문에 sleep(long millis) 메소드는 예외 처리가 필요하다.

try {
	Thread.sleep(1000);
} catch (InterruptedException e) {
	// interrupt() 메소드가 호출되면 실행
}

(2) join() : 다른 스레드의 종료 기다림

나는 처음 이 메소드를 봤을 때, 스레드끼리 순서를 조정하는 것처럼 느껴졌다. A 스레드는 B 스레드가 종료될 때까지 기다렸다가 실행해야 되는 경우도 있다. B 스레드의 join() 메소드를 호출한 A 스레드는 일시 정지 상태가 되며, 다시 실행 대기 상태로 돌아가기 위해서는 B 스레드가 종료되어야 한다.

public class JoinExample {
    public static void main(String[] args) {
        SumThread sumThread = new SumThread();
        sumThread.start();
        // start() 메소드는 새로운 스레드를 생성하고, 
        // 해당 스레드에서 run() 메소드를 실행하기 위한 메소드
        try {
            sumThread.join();
            // sumThread 스레드가 호출돼서 종료될 때까지 메인 메소드 실행 일시 정지
        } catch (InterruptedException e) {
        }
        // join()을 호출한 sumThread 스레드가 종료 상태가 되면 
        // 메인 메소드가 다시 대기 상태로 돌아감
        System.out.println("1~100 합 : " + sumThread.getSum());
    }
}

위의 그림에서도 메인 스레드가 sumThread 스레드의 join() 메소드를 호출함으로써 자기 자신은 일시 정지 상태로 돌아가서 sumThread 스레드가 종료될 때까지 대기하게 된다.

(3) yield() : 다른 스레드에게 실행 양보

실행 양보를 하는 이유는 무의미한 반복을 막기 위해서다. 만약 boolean 변수에 의해 지속적인 작업의 처리 여부가 결정되는 로직이 작성된 스레드가 존재할 때, 이 변수가 false인데도 스레드를 실행 상태로 두는 것은 프로그램 성능 측면에서 낭비되는 부분이라고 볼 수 있다.

이럴 때는 다른 스레드에게 실행을 양보하고 자신은 실행 대기 상태로 회귀하는 것이 프로그램 성능을 절약할 수 있는데, 이 역할을 맡는 메소드가 yield() 정적 메소드다.

정확히 말하자면, 실행 상태(run() 메소드 작동)에서 yield() 메소드가 작동하면, 본인이 실행 대기 상태로 회귀하면서 자연스럽게 실행 대기 상태에서 대기하던 스레드가 실행할 수 있게 됨으로써 흡사 양보의 양상을 보이는 것이다.

public class WorkThread extends Thread{
    public boolean work = true;

    public WorkThread(String name) {
        setName(name);
    }

    @Override
    public void run() {
        while(true) {
            if (work) {
                System.out.println(getName() + ": 작업처리");
            } else {
                Thread.yield();
                // 다른 스레드를 실행 상태로 양보하고
                // 본인은 실행 대기 상태로 회귀한다
            }
        }
    }
}

이런 식으로 작성한 WorkThread 클래스가 존재한다. work라는 boolean 변수에 의해 지속적인 작업 처리가 결정되는 과정에서, 조건부로 yield() 메소드 작동을 추가했다.

public class YieldExample {
    public static void main(String[] args) {
        WorkThread threadA = new WorkThread("A");
        WorkThread threadB = new WorkThread("B");

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

        try { Thread.sleep(5000); } catch (InterruptedException e) {} 
        // 5초 간 메인 스레드 중단
        
        threadA.work = false; 
        // B만 작업처리 주르륵 될 거임
        // (A의 yield() 메소드 실행으로 실행 대기 상태로 복귀)

        try { Thread.sleep(10000); } catch (InterruptedException e) {} 
        // 10초 간 메인 스레드 중단
        
        threadA.work = true; 
        // 다시 A와 B 번갈아가면서 작업처리 주르륵
    }
}

threadAthreadB는 서로 번갈아가면서 작업을 수행하게 된다. 그러다threadA가 실행 대기 상태로 회귀하면서threadB만 작업을 하는 상황이 지속되고, 이후 다시 서로 번갈아가면서 작업을 수행한다.

참고로 메인 스레드의 역할은 threadA.work 변수에 값을 할당함으로써 양보 여부를 제어한다. 이 제어의 시간 간격을 위해서 메인 스레드를 일시 정지 상태로 진입시키려고 sleep(long millis) 메소드를 작동시키는 것이다.


멀티 스레드는 공부할 게 엄청 많아서...
다음 파트에서 스레드 동기화, 스레드 종료, 데몬 스레드, 스레드풀에 대해 정리할 예정

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글