본문 바로가기
Programming

JAVA 쓰레드와 동시성

by 나무수피아는 지식의 가지를 뻗어가는 공간입니다. 2025. 11. 24.
반응형

📘 쓰레드와 동시성 (Thread & Concurrency)

이번 강의에서는 Thread, Runnable, ExecutorService를 통한 멀티스레드 처리, 그리고 synchronized 키워드, wait/notify를 사용한 동기화 기법을 소개합니다. 자바에서 멀티스레딩은 고성능 애플리케이션 개발에 필수적인 기술이며, 올바른 동기화 없이는 데이터 무결성과 프로그램의 안정성을 보장할 수 없습니다.


📌 목차

  1. Thread와 Runnable
  2. ExecutorService로 스레드 관리
  3. synchronized와 Lock
  4. wait(), notify(), notifyAll()

🔹 1. Thread와 Runnable 사용

Java에서 스레드를 구현하는 가장 기본적인 방법은 Thread 클래스를 상속하거나 Runnable 인터페이스를 구현하는 것입니다. Thread를 상속하는 방식은 직접 스레드 클래스를 만들고 run() 메서드를 오버라이드하여 실행할 코드를 작성합니다.

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread 실행 중...");
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start(); // run() 직접 호출 아님, 새 스레드 시작
    }
}

이처럼 start() 메서드를 호출하면 새로운 스레드가 생성되고, 내부적으로 run()이 실행됩니다. 직접 run()을 호출하면 단순 메서드 호출일 뿐 멀티스레드가 동작하지 않습니다.

반면 Runnable 인터페이스를 구현하면, 스레드 클래스를 상속하지 않아도 여러 클래스를 동시에 확장할 수 있어 유연합니다.

public class RunnableExample {
    public static void main(String[] args) {
        Runnable task = () -> System.out.println("Runnable 실행 중...");
        new Thread(task).start();
    }
}

람다 표현식을 사용해 간단히 Runnable을 구현했으며, 실제 스레드 생성과 실행은 Thread 객체에서 처리합니다.


🔹 2. ExecutorService로 스레드 풀 관리

직접 스레드를 생성해 관리하면 비효율적이고, 너무 많은 스레드가 생성될 경우 시스템에 부담이 큽니다. 이를 해결하기 위해 자바는 스레드 풀을 지원하는 ExecutorService API를 제공합니다. 스레드 풀은 제한된 수의 스레드를 미리 생성해 두고 작업을 큐에 넣으면 스레드가 순차적으로 작업을 처리합니다.

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

public class ExecutorExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2); // 스레드 2개 풀 생성
        executor.submit(() -> System.out.println("스레드 풀 작업 1 실행"));
        executor.submit(() -> System.out.println("스레드 풀 작업 2 실행"));
        executor.shutdown(); // 작업 완료 후 종료 요청
    }
}

스레드 풀을 활용하면 스레드 생성 비용을 줄이고, 효율적인 작업 분배가 가능해 대규모 동시 작업 처리에 매우 효과적입니다.

참고: shutdown() 호출 후에도 기존 제출된 작업은 모두 실행되지만, 새로운 작업 제출은 거부됩니다. 완전 종료를 위해 awaitTermination() 메서드로 대기할 수 있습니다.


🔹 3. 동기화: synchronized와 Lock

synchronized 키워드는 자바에서 가장 기본적인 동기화 방법으로, 하나의 스레드만 특정 코드 블록이나 메서드에 접근하도록 보장합니다. 공유 자원을 다룰 때 데이터 일관성을 유지하기 위해 필수적입니다.

class Counter {
    private int count = 0;

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

    public int getCount() {
        return count;
    }
}

위 예제에서 increment() 메서드는 synchronized가 붙어있어, 여러 스레드가 동시에 호출해도 count 변수를 안전하게 증가시킵니다.

더 복잡한 동기화가 필요할 경우 java.util.concurrent.locks.ReentrantLock 클래스를 사용할 수 있습니다. 이 방식은 잠금을 명시적으로 얻고 해제하는 형태로 유연한 락 관리가 가능합니다.

import java.util.concurrent.locks.ReentrantLock;

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

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // 반드시 해제 필요
        }
    }

    public int getCount() {
        return count;
    }
}

락을 직접 획득하고 해제하는 구조라 synchronized보다 정교한 제어가 가능하며, 조건 변수, 공정성 설정 등 다양한 기능을 제공합니다.


🔹 4. wait(), notify(), notifyAll()

스레드 간 협업을 위해 자바는 Object 클래스에 wait(), notify(), notifyAll() 메서드를 제공합니다. 이들은 동기화된 블록 안에서만 호출할 수 있으며, 스레드가 특정 조건을 기다리거나 다른 스레드를 깨우는 역할을 합니다.

class Shared {
    private boolean ready = false;

    public synchronized void waitForSignal() throws InterruptedException {
        while (!ready) {
            wait(); // 조건 만족 전까지 기다림
        }
        System.out.println("신호 받고 작업 수행");
    }

    public synchronized void sendSignal() {
        ready = true;
        notify(); // 대기 중인 하나의 스레드 깨움
    }
}

public class WaitNotifyExample {
    public static void main(String[] args) {
        Shared shared = new Shared();

        Thread waitingThread = new Thread(() -> {
            try {
                shared.waitForSignal();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread signalingThread = new Thread(() -> {
            try {
                Thread.sleep(1000); // 1초 대기 후 신호 전송
                shared.sendSignal();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        waitingThread.start();
        signalingThread.start();
    }
}

위 코드에서 waitingThreadwait()로 신호를 기다리고, signalingThread가 1초 후 notify()로 깨워줍니다. 이처럼 wait()/notify()는 생산자-소비자 문제, 이벤트 대기 등에서 많이 활용됩니다.

notify()는 대기 중인 스레드 하나를 깨우고, notifyAll()은 모두 깨웁니다. 복잡한 동기화에서는 notifyAll()이 안전할 때가 많지만 성능 저하가 발생할 수 있습니다.


📝 마무리

이번 강의에서는 자바의 멀티스레드 기본 구현 방법부터 고급 동기화 기법까지 자세히 살펴보았습니다. 스레드는 CPU 자원을 효율적으로 활용하는 중요한 수단이지만, 잘못 사용하면 데이터 경쟁(Race Condition), 데드락(Deadlock) 등의 문제를 일으킵니다. 따라서 synchronized, Lock, wait/notify 같은 동기화 도구들을 적절히 사용해 안전한 동시성 프로그래밍을 해야 합니다.

또한 ExecutorService 같은 스레드 풀 관리 도구를 활용하면, 스레드 생성/소멸 비용을 줄이고 효율적인 작업 분배가 가능하므로, 현대 자바 애플리케이션에서 필수적으로 익혀야 할 기술입니다.

앞으로 실무에서는 CompletableFuture, Reactive Programming 등 비동기 프로그래밍 패턴도 중요해지지만, 기본적인 쓰레드와 동기화 메커니즘에 대한 이해가 먼저 선행되어야 합니다.

관련 참고자료

 

반응형

'Programming' 카테고리의 다른 글

JAVA 디자인 패턴  (40) 2025.11.26
JAVA 네트워크 프로그래밍  (43) 2025.11.25
JAVA 클래스 고급  (40) 2025.11.23
JAVA 날짜와 시간 처리  (35) 2025.11.22
JAVA 파일 입출력 (File I/O)  (54) 2025.11.21