Java의 멀티스레딩과 동시성 프로그래밍

Sony·2025년 2월 9일
0

☕️ JAVA

목록 보기
3/4
post-thumbnail

Java에서 멀티스레딩(Multithreading)과 동시성 프로그래밍(Concurrency)은 성능 최적화와 병렬 작업을 수행하는 데 필수적인 개념이다. 하지만 잘못 사용하면 데드락(Deadlock), 레이스 컨디션(Race Condition) 같은 문제를 초래할 수 있다. 이번 포스팅에서는 멀티스레딩의 기본 개념, 스레드 생성 방법, 동기화 문제와 해결책을 다뤄보자.


1. 멀티스레딩이란?

멀티스레딩은 하나의 프로세스에서 여러 개의 스레드(Thread)가 동시에 실행되는 개념이다.
이를 통해 CPU 사용률을 극대화하고, 프로그램이 병렬로 실행되도록 할 수 있다.

싱글 스레드 vs 멀티스레드


2. Java에서 스레드 생성 방법

Java에서는 스레드를 생성하는 방법이 여러 가지가 있다.

1) Thread 클래스 상속

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread 실행 중: " + Thread.currentThread().getName());
    }
}

public class ThreadExample {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // 스레드 시작
    }
}

Thread 클래스를 상속하면 run() 메서드를 오버라이딩하여 실행할 작업을 정의할 수 있다.

2) Runnable 인터페이스 구현

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable 실행 중: " + Thread.currentThread().getName());
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start();
    }
}

Runnable 인터페이스를 구현하면 Thread 클래스를 직접 상속하지 않고도 멀티스레딩을 사용할 수 있다. 자바는 다중 상속을 지원하지 않기 때문에, Runnable 방식이 더 유연하다.

3) Executor Framework 사용

Java 5부터는 ExecutorService를 사용해 스레드를 효율적으로 관리할 수 있다.

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

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

        for (int i = 0; i < 5; i++) {
            executor.execute(() -> {
                System.out.println("Executor 실행 중: " + Thread.currentThread().getName());
            });
        }

        executor.shutdown(); // 스레드 종료
    }
}

Executor는 스레드 풀(Thread Pool)을 사용해 스레드를 재사용하기 때문에, 성능 최적화에 유리하다.


3. 동기화 문제와 해결 방법

멀티스레딩 환경에서는 여러 스레드가 동시에 같은 데이터에 접근하면 문제가 발생할 수 있다. 대표적인 동기화 문제와 해결 방법을 살펴보자.

1) 레이스 컨디션(Race Condition)

여러 스레드가 동시에 공유 자원을 변경할 때 발생하는 문제로, 실행 순서에 따라 결과가 달라질 수 있다.

class Counter {
    private int count = 0;

    public void increment() {
        count++; // 여러 스레드가 동시에 접근하면 문제 발생
    }

    public int getCount() {
        return count;
    }
}

public class RaceConditionExample {
    public static void main(String[] args) {
        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();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("최종 count 값: " + counter.getCount()); // 예상: 2000, 실제: 랜덤 값
    }
}

여러 스레드가 increment() 메서드를 동시에 실행하면 count 값이 올바르게 증가하지 않는다.

2) synchronized 키워드로 해결

synchronized 키워드를 사용하면 메서드가 한 번에 하나의 스레드만 접근 가능하도록 만든다.

class SyncCounter {
    private int count = 0;

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

    public int getCount() {
        return count;
    }
}

synchronized 를 추가하면 increment() 메서드가 한 번에 하나의 스레드만 실행할 수 있어, Race Condition 문제가 해결된다.

3) ReentrantLock 사용

synchronized 대신 ReentrantLock 을 사용할 수도 있다.

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

class LockCounter {
    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;
    }
}

ReentrantLocktry-finally 블록을 사용하여 lock을 명시적으로 해제할 수 있는 장점이 있다.


4. 데드락(Deadlock) 문제

데드락은 두 개 이상의 스레드가 서로의 락을 기다리며 영원히 멈추는 상태를 말한다.

데드락 발생 예제

class Resource {
    public synchronized void methodA(Resource other) {
        System.out.println(Thread.currentThread().getName() + " methodA 실행");
        other.methodB(this);
    }

    public synchronized void methodB(Resource other) {
        System.out.println(Thread.currentThread().getName() + " methodB 실행");
        other.methodA(this);
    }
}

public class DeadlockExample {
    public static void main(String[] args) {
        Resource r1 = new Resource();
        Resource r2 = new Resource();

        Thread t1 = new Thread(() -> r1.methodA(r2));
        Thread t2 = new Thread(() -> r2.methodA(r1));

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

위 코드에서는 t1t2가 서로의 락을 기다리면서 무한 대기 상태가 발생한다.

데드락 방지 방법

  • 락을 획득하는 순서를 정해 Lock Ordering 을 적용한다.
  • tryLock() 을 사용해 일정 시간이 지나면 락을 포기하도록 한다.

5. 결론

  • Java의 멀티스레딩을 사용하면 여러 작업을 병렬로 수행할 수 있다.
  • 동기화 문제(레이스 컨디션, 데드락 등)를 방지하기 위해 synchronized, ReentrantLock, Executor 등을 적절히 활용해야 한다.
  • 스레드를 직접 생성하는 것보다 ExecutorService 를 활용하면 더 효율적인 멀티스레딩 환경을 구축할 수 있다.

멀티스레딩은 잘 사용하면 성능을 극대화할 수 있지만, 잘못 사용하면 치명적인 동기화 문제를 초래할 수 있다. 실무에서는 Executor, synchronized, ReentrantLock 등을 적절히 활용하여 안전한 멀티스레딩을 구현해야 한다.

profile
Bamboo Tree 🎋 : 대나무처럼 성장하고 싶은 개발자, Sony입니다.

0개의 댓글

관련 채용 정보