Notice
Recent Posts
Recent Comments
Link
«   2026/03   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
Tags more
Archives
Today
Total
관리 메뉴

프로그래밍 공부

[자바] #26. 쓰레드 (Thread) 본문

자바

[자바] #26. 쓰레드 (Thread)

하 냥 2025. 3. 3. 23:41

🔍 쓰레드(Thread)란?

쓰레드는 하나의 프로그램(프로세스) 내에서 독립적으로 실행되는 작은 실행 단위이다.

Java 프로그램은 기본적으로 단일 쓰레드(main thread)로 실행되지만,

여러 개의 쓰레드를 생성하면 병렬(병행) 처리가 가능하다.

 

 

🤔 프로세스 vs 쓰레드

구분 프로세스 (Process) 쓰레드 (Thread)
실행 단위 독립적인 실행 단위 프로세스 내부의 실행 단위
메모리 공유 X (독립적) O (같은 프로세스 내에서 공유)
생성 비용 높음 (OS가 새로운 프로세스를 생성) 낮음 (프로세스 내에서 생성)
통신 방식 IPC (Inter-Process Communication) 필요 공유 메모리 활용

🎀 자바에서 쓰레드 생성 방법

(1) Thread 클래스를 상속받는 방법

class MyThread extends Thread {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " 실행 중...");
        }
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        
        t1.start(); // 쓰레드 실행
        t2.start();
    }
}

📌 start()를 호출해야 쓰레드가 실행되며, run()을 직접 호출하면 일반 메서드처럼 실행된다.


 

(2) Runnable 인터페이스 구현 방법 (권장)

class MyRunnable implements Runnable {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " 실행 중...");
        }
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(new MyRunnable());
        Thread t2 = new Thread(new MyRunnable());

        t1.start();
        t2.start();
    }
}

✔️ Runnable 방식이 더 유연하며, 다른 클래스를 상속받을 수도 있기 때문에 실무에서 더 많이 사용된다.


(3) 람다식으로 간단히 생성

public class LambdaThread {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + " 실행 중...");
            }
        });

        t1.start();
    }
}

✔️ 익명 클래스 대신 람다 표현식을 사용하여 간결하게 구현할 수 있다.


🍗 쓰레드의 주요 메서드

메서드 설명
start() 쓰레드를 시작하고 run() 메서드를 호출
run() 쓰레드가 실행할 코드 (직접 호출하지 않음)
sleep(ms) 특정 시간 동안 쓰레드 일시 정지
join() 해당 쓰레드가 종료될 때까지 기다림
interrupt() 실행 중인 쓰레드를 중단 요청
isAlive() 쓰레드가 실행 중인지 확인

 

예제: join()을 사용하여 쓰레드 종료 대기

public class JoinExample {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + " 실행 중...");
            }
        });

        t1.start();
        t1.join(); // t1이 끝날 때까지 main 쓰레드 대기

        System.out.println("모든 작업 종료");
    }
}
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
모든 작업 종료

t1.join();을 호출하면 메인 쓰레드는 t1이 끝날 때까지 대기한다.

즉, t1이 실행을 마칠 때까지 main 쓰레드는 멈춘 상태가 된다.

t1이 실행되면서 for 루프를 돌며 5번 출력하고, t1이 종료되면 join()이 해제된다.

t1.join();이 끝나면 main 쓰레드가 다시 진행하여 "모든 작업 종료"를 출력하고 프로그램이 종료된다.

 

 

t1.join()을 사용하지 않는다면?

만약 t1.join()을 제거하면 메인 쓰레드와 t1이 동시에 실행되므로 다음과 같이 출력될 수도 있다.

Thread-0 실행 중...
모든 작업 종료
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...

즉, "모든 작업 종료"가 먼저 출력될 수도 있으며, t1이 실행되는 동안 main 쓰레드가 종료될 수도 있다.

t1.join()을 사용하면 main 쓰레드는 t1이 종료될 때까지 기다리므로 "모든 작업 종료"가 항상 t1 실행 후에 출력된다.


⚡ 동기화 (Synchronization)

멀티쓰레드는 공유 자원을 사용할 때 데이터 충돌이 발생할 수 있다.

이를 방지하기 위해 동기화(Synchronized)를 사용한다.

 

동기화 블록 (synchronized 키워드)

class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class SyncExample {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter.increment();
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter.increment();
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("최종 카운트: " + counter.getCount()); // 2000 예상
    }
}
최종 카운트: 2000

synchronized를 사용했기 때문에, 두 개의 쓰레드가 동시에 접근해도 count 값이 안전하게 증가한다.

 

 

synchronized를 사용하지 않은 경우,

최종 카운트: 1923
최종 카운트: 1792
최종 카운트: 1850

이처럼 실행할 때마다 최종 값이 2000보다 작아질 가능성이 높다.

이유는 두 개의 쓰레드가 동시에 count++를 수행할 때,

연산이 덮어쓰기(레이스 컨디션, Race Condition)로 인해 일부 증가가 손실되기 때문이다.


💫 레이스 컨디션(Race Condition) 예시

올바른 실행 흐름

쓰레드 1: count 읽기 (100) -> 증가 (101) -> 저장
쓰레드 2: count 읽기 (101) -> 증가 (102) -> 저장

결과: count = 102 (정상 증가)

 

 

잘못된 실행 흐름 (경합 발생)

쓰레드 1: count 읽기 (100) -> 증가 (101) -> (저장 안 함)
쓰레드 2: count 읽기 (100) -> 증가 (101) -> 저장
쓰레드 1: (이제야 저장) count = 101 (잘못된 값!)

🚨 결과: count = 101 (1이 손실됨!)

 

이런 문제 때문에 synchronized를 사용하면 한 번에 하나의 쓰레드만 접근하도록 보호하여,

값의 손실을 방지할 수 있다.


🎂 쓰레드 풀(Thread Pool)이란?

쓰레드 풀(Thread Pool)이란 미리 일정 개수의 쓰레드를 생성해 두고, 필요할 때 재사용하는 방식이다.
새로운 쓰레드를 매번 생성하는 대신, 기존의 쓰레드를 재사용하여 성능을 최적화할 수 있다.


🤨 쓰레드 풀이 필요한 이유

(1) 쓰레드 생성 비용 절감

  • 쓰레드를 매번 생성하면 CPU 및 메모리 사용량이 증가하고, 시스템이 느려질 수 있음
  • 미리 쓰레드를 생성해 두면 불필요한 쓰레드 생성 비용을 줄일 수 있음

(2) 최적의 쓰레드 개수 유지

  • 너무 많은 쓰레드를 생성하면 **CPU 컨텍스트 스위칭(Context Switching)**이 증가하여 성능이 저하됨
  • 쓰레드 풀을 사용하면 적절한 개수의 쓰레드만 유지할 수 있음

(3) 관리가 편리함

  • ExecutorService를 사용하면 쓰레드 생성, 실행, 종료까지 자동으로 관리할 수 있음

🎨 자바에서 쓰레드 풀 사용 방법

Executors 클래스 활용

Java에서는 java.util.concurrent.Executors 클래스를 사용하여 쉽게 쓰레드 풀을 생성할 수 있다.

쓰레드 풀 종류 설명
newFixedThreadPool(n) 고정된 개수(n)의 쓰레드를 유지
newCachedThreadPool() 필요할 때 새 쓰레드를 생성하고, 사용하지 않으면 삭제
newSingleThreadExecutor() 단일 쓰레드만 사용
newScheduledThreadPool(n) 일정 시간 간격으로 실행되는 작업 지원

newFixedThreadPool() 예제 (고정된 개수의 쓰레드)

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

public class FixedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3); // 3개의 쓰레드 풀 생성

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " 실행 중... (쓰레드: " + Thread.currentThread().getName() + ")");
                try {
                    Thread.sleep(1000); // 작업 시간 시뮬레이션
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Task " + taskId + " 완료!");
            });
        }

        executor.shutdown(); // 모든 작업이 끝나면 종료
    }
}
Task 1 실행 중... (쓰레드: pool-1-thread-1)
Task 2 실행 중... (쓰레드: pool-1-thread-2)
Task 3 실행 중... (쓰레드: pool-1-thread-3)
Task 4 실행 중... (쓰레드: pool-1-thread-1)
Task 5 실행 중... (쓰레드: pool-1-thread-2)
Task 1 완료!
Task 2 완료!
Task 3 완료!
Task 4 완료!
Task 5 완료!

✔️ 3개의 쓰레드만 생성되며, 순차적으로 작업을 처리하는 것을 확인할 수 있다.


newCachedThreadPool() 예제 (동적 쓰레드 생성)

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

public class CachedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool(); // 필요할 때만 쓰레드 생성

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " 실행 중... (쓰레드: " + Thread.currentThread().getName() + ")");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        executor.shutdown(); // 모든 작업이 끝나면 종료
    }
}
Task 1 실행 중... (쓰레드: pool-1-thread-1)
Task 2 실행 중... (쓰레드: pool-1-thread-2)
Task 3 실행 중... (쓰레드: pool-1-thread-3)
Task 4 실행 중... (쓰레드: pool-1-thread-4)
Task 5 실행 중... (쓰레드: pool-1-thread-5)

✔️ 작업이 많으면 동적으로 쓰레드를 생성하여 빠르게 처리하고, 일정 시간이 지나면 자동으로 삭제된다.


newSingleThreadExecutor() 예제 (단일 쓰레드)

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

public class SingleThreadExecutorExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor(); // 단일 쓰레드

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " 실행 중... (쓰레드: " + Thread.currentThread().getName() + ")");
            });
        }

        executor.shutdown();
    }
}
Task 1 실행 중... (쓰레드: pool-1-thread-1)
Task 2 실행 중... (쓰레드: pool-1-thread-1)
Task 3 실행 중... (쓰레드: pool-1-thread-1)
Task 4 실행 중... (쓰레드: pool-1-thread-1)
Task 5 실행 중... (쓰레드: pool-1-thread-1)

✔️ 단일 쓰레드(pool-1-thread-1)가 순차적으로 모든 작업을 처리한다.


newScheduledThreadPool() 예제 (주기적 실행)

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

        // 3초 뒤에 실행
        scheduler.schedule(() -> System.out.println("3초 뒤 실행!"), 3, TimeUnit.SECONDS);

        // 2초마다 반복 실행
        scheduler.scheduleAtFixedRate(() -> System.out.println("2초마다 실행!"), 0, 2, TimeUnit.SECONDS);

        // 4초마다 반복 실행 (이전 작업이 끝난 후 4초 뒤 실행)
        scheduler.scheduleWithFixedDelay(() -> System.out.println("4초마다 실행!"), 0, 4, TimeUnit.SECONDS);
    }
}

✔️ 주기적인 작업을 수행할 때 ScheduledThreadPool을 사용하면 편리하다.

 


shutdown()과 shutdownNow() 차이

메서드 설명
shutdown() 현재 진행 중인 작업을 완료한 후 종료
shutdownNow() 모든 작업을 즉시 중단하고 종료
executor.shutdown(); // 정상 종료
executor.shutdownNow(); // 강제 종료

👀 synchronized 대신 ReeantrantLock

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++; // 한 쓰레드에 의해선 실행되는 영역
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        return count;
    }
}

✔️ synchronized보다 더 세밀한 제어가 가능하며, try-finally 블록을 사용하여 데드락 방지가 가능하다.