Search

멀티 스레드와 동시성 (1) 스레드의 생성과 실행 / 생명 주기와 제어 / 체크 예외

Tags
Study
Java
Last edited time
2025/09/19 06:49
2 more properties
Search
멀티 스레드와 동시성 (5) 자바 동기화 메커니즘: 스레드 락 / 대기 집합 / 임계영역
Study
Java
멀티 스레드와 동시성 (5) 자바 동기화 메커니즘: 스레드 락 / 대기 집합 / 임계영역
Study
Java

1. 스레드의 생성과 실행

1.1. 스레드의 생성 (Thread와 Runnable)

자바에서 멀티스레드를 만드는 대표적인 방법
1.
Thread 클래스를 직접 상속받는 방법
2.
Runnable 인터페이스를 구현하는 방법
Thread 상속 방식
class MyThread extends Thread 형태로 정의하고 run() 메서드를 오버라이드.
코드가 단순, 곧바로 new MyThread().start()로 실행 가능.
자바는 단일 상속만 허용 → 이미 다른 클래스를 상속 중이라면 사용할 수 없음.
즉, Thread를 상속하면 유연성이 떨어짐.
스레드 자체와 작업 로직이 강하게 결합되어 재사용성이 낮음.
Runnable 구현 방식
Runnable 인터페이스의 run() 메서드 안에 작업 로직을 정의.
Thread 객체를 생성할 때 new Thread(new MyRunnable())와 같이 실행 환경과 작업을 분리.
다른 클래스를 상속하면서도 병행 가능.
작업(Task)과 실행(Thread)을 분리하여 구조가 명확해짐.
같은 작업 인스턴스를 여러 스레드에서 공유할 수도 있음.
실무에서는 대부분 이 방식을 사용하며, ExecutorService 같은 스레드 풀과 함께 사용하면 더 강력함.
예시 코드
// Thread 상속 class MyThread extends Thread { @Override public void run() { System.out.println("Thread 상속 방식 실행"); } } // Runnable 구현 class MyRunnable implements Runnable { @Override public void run() { System.out.println("Runnable 구현 방식 실행"); } } public class ThreadVsRunnable { public static void main(String[] args) throws InterruptedException { Thread t1 = new MyThread(); // Thread 상속 Thread t2 = new Thread(new MyRunnable()); // Runnable 구현 t1.start(); t2.start(); t1.join(); t2.join(); } }
Java
복사

1.2. 스레드의 실행 (start 메서드와 run 메서드)

run()
단순 메서드 호출. 멀티 스레드 실행과는 무관함.
따라서 호출한 스레드(main 등)에서 실행됨. 멀티스레드가 되지 않음.
start()
새로운 OS 수준의 스레드를 생성하고, 그 안에서 run() 메서드를 실행시킴.
이게 진짜 멀티스레드 실행을 의미함.
즉, 멀티스레드를 원한다면 반드시 start()를 호출해야 함.
sequenceDiagram
  participant M as main
  participant T as Thread

  Note over M: run() 호출
  M->>T: t.run()
  Note over T: 실제 실행은 main 스레드에서 처리

  Note over M: start() 호출
  M->>T: t.start()
  T-->>T: (새 스레드 생성 후 run() 실행)
Mermaid
복사
예시 코드
class Task implements Runnable { @Override public void run() { System.out.println("실행 스레드: " + Thread.currentThread().getName()); } } public class StartVsRun { public static void main(String[] args) { Thread thread = new Thread(new Task(), "WorkerThread"); thread.run(); // main 스레드에서 실행 thread.start(); // WorkerThread 스레드에서 실행 } }
Java
복사

2. 스레드 생명 주기와 제어

2.1. 스레드의 생명주기

NEW (생성)
new Thread(...) 로 스레드 객체를 만들었지만, 아직 시작하지 않은 상태.
start()를 호출해야 다음 상태로 진행.
RUNNABLE (실행 대기)
start()가 호출되면 스레드가 실행 대기 큐에 들어감.
CPU 스케줄러가 언제 실행할지는 OS와 JVM이 결정.
실제 실행 중(RUNNING)과 대기 상태를 통칭해서 RUNNABLE로 부름.
자바의 Thread.State에서 RUNNABLE은 실행 대기와 실행 중을 구분하지 않고 포함하는 개념임.
RUNNING이라는 상태명은 공식적으로 없지만, 이해를 돕기 위해 흔히 구분해서 설명.
RUNNING (실행 중)
CPU를 점유하고 run() 메서드를 실행하는 상태.
여러 스레드가 번갈아가며 RUNNING 상태가 됨(시분할).
WAITING (무기한 대기)
다른 스레드가 깨워줄 때까지 영원히 기다리는 상태.
Object.wait() 등이 원인.
TIMED_WAITING (시간 제한 대기)
일정 시간 동안만 대기.
Thread.sleep(ms), join(ms), wait(ms) 같은 메서드 호출 시.
BLOCKED (블로킹)
모니터 락(lock)을 얻기 위해 대기하는 상태.
동기화 블록(synchronized)에 진입하려는데 이미 다른 스레드가 점유 중일 때 발생.
TERMINATED (종료)
run() 메서드가 끝나면 스레드는 더 이상 실행되지 않음.
종료된 스레드는 다시 시작할 수 없음(IllegalThreadStateException 발생).
stateDiagram-v2
    [*] --> NEW
    NEW --> RUNNABLE: start()
    RUNNABLE --> RUNNING: 스케줄러 선택
    RUNNING --> RUNNABLE: yield / 스케줄러 전환
    RUNNING --> TIMED_WAITING: sleep(ms) / join(ms)
    RUNNING --> WAITING: wait()
    RUNNING --> BLOCKED: lock 획득 대기
    WAITING --> RUNNABLE: notify()
    TIMED_WAITING --> RUNNABLE: 시간만료
    BLOCKED --> RUNNABLE: lock 획득
    RUNNING --> TERMINATED: run() 종료
    TERMINATED --> [*]

Mermaid
복사
예시 코드
public class ThreadLifecycleDemo { public static void main(String[] args) throws InterruptedException { Thread t = new Thread(() -> { try { System.out.println("RUNNING: 작업 시작"); Thread.sleep(1000); // TIMED_WAITING synchronized (ThreadLifecycleDemo.class) { System.out.println("BLOCKED/WAITING 예시 준비"); } } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("RUNNING → TERMINATED"); }); System.out.println("NEW: 스레드 생성됨"); System.out.println("t state = " + t.getState()); t.start(); System.out.println("RUNNABLE: start() 호출 후"); System.out.println("t state = " + t.getState()); Thread.sleep(500); System.out.println("중간 상태: " + t.getState()); // RUNNABLE or TIMED_WAITING t.join(); System.out.println("TERMINATED: 종료 후"); System.out.println("t state = " + t.getState()); } }
Java
복사

2.2. Thread의 join의 역할

기본적으로 main 스레드는 하위 스레드를 기다리지 않음.
따라서 하위 스레드가 아직 작업 중인데도 main은 종료될 수 있음
join()은 호출된 스레드가 완전히 끝날 때까지 현재 스레드를 대기 상태로 만듬
join()을 사용하면 main이 하위 스레드의 작업을 기다린 뒤 안전하게 결과를 사용할 수 있다.
예시 코드
class SumTask implements Runnable { int result = 0; @Override public void run() { for (int i = 1; i <= 5; i++) { result += i; try { Thread.sleep(200); } catch (InterruptedException ignored) {} } } } public class JoinExample { public static void main(String[] args) throws InterruptedException { SumTask task1 = new SumTask(); SumTask task2 = new SumTask(); Thread t1 = new Thread(task1, "t1"); Thread t2 = new Thread(task2, "t2"); t1.start(); t2.start(); // main이 하위 스레드 종료까지 기다림 t1.join(); t2.join(); System.out.println("task1.result = " + task1.result); System.out.println("task2.result = " + task2.result); System.out.println("총합 = " + (task1.result + task2.result)); } }
Java
복사

2.3. interrupt가 하는 역할

역할
interrupt()는 스레드를 즉시 강제 종료하지 않는다.
대신 스레드의 인터럽트 플래그를 true로 바꿈
일반적인 예시
스레드가 sleep(), wait(), join() 같은 대기 상태일 때 → InterruptedException 발생.
스레드가 루프 작업 중이라면 isInterrupted()를 체크해서 중단할 수 있음
올바른 패턴
catch (InterruptedException e) 블록에서 Thread.currentThread().interrupt()를 호출해 상태를 복원하고, 루프를 종료하거나 자원 정리를 하는 것.
InterruptedException이 발생하면 인터럽트 플래그가 자동으로 내려가기 때문에, 다시 interrupt()를 호출해 플래그를 true로 세팅해줘야 이후 상위 로직이 “아직 인터럽트 상태구나”를 알 수 있음.
예시 코드
class MyTask implements Runnable { @Override public void run() { try { while (!Thread.currentThread().isInterrupted()) { System.out.println("작업 중..."); Thread.sleep(1000); // 여기서 interrupt되면 예외 발생 } } catch (InterruptedException e) { System.out.println("대기 중 인터럽트 감지"); Thread.currentThread().interrupt(); // 상태 복원 } finally { System.out.println("리소스 정리 후 종료"); } } } public class InterruptExample { public static void main(String[] args) throws InterruptedException { Thread t = new Thread(new MyTask(), "worker"); t.start(); Thread.sleep(3000); System.out.println("메인: interrupt() 호출"); t.interrupt(); t.join(); System.out.println("메인 종료"); } }
Java
복사

3. 스레드와 체크 예외

3.1. 체크 예외와 언체크 에외

체크 예외 (Checked Exception)
Exception을 상속하지만 RuntimeException은 아님.
컴파일러가 예외 처리 강제 → try-catch 또는 throws 선언 필요.
파일, DB, 네트워크처럼 외부 환경 요인으로 실패할 수 있는 경우 대표적.
예: IOException, SQLException
public void readFile() throws IOException { FileReader fr = new FileReader("없는파일.txt"); // IOException 발생 가능 fr.read(); }
Java
복사
IOException은 체크 예외이므로 반드시 처리하거나 throws로 던져야 함.
언체크 예외 (Unchecked Exception)
RuntimeException을 상속한 예외들.
처리 강제 없음 → 개발자 코드의 실수에서 발생하는 경우 많음.
예: NullPointerException, IllegalArgumentException
public void divide(int a, int b) { int result = a / b; // b가 0이면 ArithmeticException 발생 System.out.println(result); }
Java
복사
ArithmeticException은 언체크 예외이므로, 따로 처리하지 않아도 컴파일 에러 없음.

3.2. Thread.sleep()과 체크 예외

sleep()은 외부 요인에 의해 깨질 수 있음
Thread.sleep(ms)는 현재 스레드를 지정한 시간 동안 멈추게 하지만, 이건 보장된 대기가 아님.
Thread.sleep(ms)는 지정한 시간 동안 스레드를 중단시키지만, 실제 깨어나는 시점은 OS 스케줄러에 따라 달라질 수 있어 “정확한 대기 보장”은 아님.
다른 스레드가 interrupt()를 호출하면 → sleep() 중이던 스레드가 즉시 깨어남
즉, “외부 환경(다른 스레드)”이 영향을 주는 상황이라서, 반드시 대비 코드가 필요.
이런 “외부 요인”에 대한 대비는 체크 예외로 설계필요
public class SleepCheckedExample { public static void main(String[] args) { Thread t = new Thread(() -> { try { System.out.println("작업 시작: 3초 대기"); Thread.sleep(3000); // 체크 예외 발생 가능 System.out.println("정상적으로 3초 대기 완료"); } catch (InterruptedException e) { // 다른 스레드가 interrupt() 호출 시 여기로 진입 System.out.println("sleep 중 인터럽트 발생: " + e); Thread.currentThread().interrupt(); // 상태 복원 (권장) } }); t.start(); // 1초 뒤에 강제로 깨우기 try { Thread.sleep(1000); } catch (InterruptedException ignored) {} System.out.println("메인: interrupt() 호출"); t.interrupt(); } }
Java
복사
작업 시작: 3초 대기 메인: interrupt() 호출 sleep 중 인터럽트 발생: java.lang.InterruptedException: sleep interrupted
Bash
복사

3.3. InterruptedException과 체크 예외

자바 설계자들은 스레드 인터럽트 신호를 무시하지 말라는 메시지를 주고 싶었던 것.
만약 InterruptedException언체크 예외였다면?
→ 개발자가 아무 대비 없이 코드를 짜더라도 컴파일러가 경고하지 않음.
→ 그러면 중요한 인터럽트 신호를 놓치고, 시스템 안정성이 깨질 수 있음.
체크 예외로 강제함으로써, 개발자가 반드시 catch 블록에서 인터럽트 처리를 고려하도록 유도.
public class InterruptedExceptionExample { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { try { while (true) { System.out.println("작업 중..."); Thread.sleep(500); // 인터럽트 되면 InterruptedException 발생 } } catch (InterruptedException e) { // 체크 예외이므로 반드시 처리 필요 System.out.println("InterruptedException 처리: " + e.getMessage()); Thread.currentThread().interrupt(); // 상태 복원 } }); worker.start(); Thread.sleep(1500); // 잠시 기다렸다가 System.out.println("메인: interrupt() 호출"); worker.interrupt(); worker.join(); System.out.println("메인 종료"); } }
Java
복사
작업 중... 작업 중... 작업 중... 메인: interrupt() 호출 InterruptedException 처리: sleep interrupted 메인 종료
Bash
복사

3.4. Runnable.run()의 제약

Runnable.run() 선언부에 throws가 없음 → 체크 예외를 밖으로 던질 수 없음.
따라서 반드시 try-catch로 내부에서 처리하거나, 언체크 예외로 감싸서 던져야 함.
class CheckedTask implements Runnable { @Override public void run() { try { throw new java.io.IOException("체크 예외 발생"); } catch (java.io.IOException e) { System.out.println("run() 내부에서 처리: " + e.getMessage()); } } }
Java
복사