코드 한 줄의 기록

Java 멀티스레드의 악몽, 데드락·라이블락·스타베이션 완벽 정리 본문

JAVA

Java 멀티스레드의 악몽, 데드락·라이블락·스타베이션 완벽 정리

CodeByJin 2025. 12. 6. 16:22
반응형

안녕하세요. 오늘은 Java 멀티스레드 환경에서 발생할 수 있는 세 가지 심각한 동시성 문제인 데드락(Deadlock), 라이블락(Livelock), 스타베이션(Starvation)에 대해 깊이 있게 살펴보겠습니다. 12년 동안 PHP를 다루고 최근에 Java를 본격적으로 배우면서 겪은 경험을 바탕으로, 이 세 가지 문제의 발생 원리부터 실제 해결책까지 정리해봤습니다.

데드락(Deadlock) - 서로의 자원을 기다리며 무한 대기

데드락이란?

데드락은 두 개 이상의 스레드가 서로 다른 자원을 점유하고 있으면서, 동시에 상대방이 가진 자원을 필요로 할 때 발생합니다. 마치 교차로에서 두 대의 차가 서로 마주 보고 있으면서 진행하지 못하는 상황처럼, 스레드들이 서로를 기다리며 무한 대기 상태에 빠지게 되는 것이죠.


제가 처음 데드락을 경험했을 때는 정말 답답했습니다. 애플리케이션이 갑자기 응답하지 않고, 로그에는 특별한 에러가 없는데 그냥 멈춰 있는 거예요. 재시작하면 다시 정상인데... 이게 바로 데드락이었습니다.

 

데드락 발생 조건(필요충분조건)

데드락이 발생하려면 다음 네 가지 조건이 모두 충족되어야 합니다.


1) 상호 배제(Mutual Exclusion)
- 한 번에 하나의 스레드만 자원을 점유할 수 있음
- synchronized나 ReentrantLock으로 자원을 보호할 때 발생


2) 점유와 대기(Hold and Wait)
- 스레드가 한 자원을 점유한 채로 다른 자원을 기다림
- 자원을 모두 획득한 후 작업하지 않고, 부분적으로 획득한 상태에서 추가 자원을 요청


3) 비선점(Non-Preemptive)
- 다른 스레드의 자원을 강제로 빼앗을 수 없음
- synchronized 블록에 들어간 스레드는 나올 때까지 강제 해제 불가


4) 순환 대기(Circular Wait)
- 스레드 A는 B의 자원을 기다리고, B는 C의 자원을 기다리고, C는 A의 자원을 기다리는 상황
- 보통은 두 스레드만 마주치지만, 세 개 이상의 스레드가 연쇄적으로 대기할 수도 있음
이 네 조건이 모두 만족되면 데드락이 발생할 수 있습니다. 역으로 말하면, 이 중 하나라도 제거하면 데드락을 예방할 수 있다는 뜻이죠.

 

데드락 문제 코드

class Account {
    private int balance; 

    public Account(int balance) {
        this.balance = balance;
    }

    public void transfer(Account other, int amount) {
        synchronized(this) {
            synchronized(other) {
                if (this.balance >= amount) {
                    this.balance -= amount;
                    other.balance += amount;
                }
            }
        }
    }
}

// 메인 함수에서
public static void main(String[] args) throws InterruptedException {
    Account accountA = new Account(1000);
    Account accountB = new Account(1000);    

    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
            accountA.transfer(accountB, 10);  // A에서 B로 이체
        }
    });    

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
            accountB.transfer(accountA, 10);  // B에서 A로 이체
        }
    });
    
    t1.start();
    t2.start();
    t1.join();
    t2.join();

    System.out.println("완료!");

}

위 코드를 실행하면 시간이 지나면서 어느 순간 "완료!"가 출력되지 않을 가능성이 높습니다. 왜일까요?


시나리오
- 스레드 t1이 accountA를 잠그고 accountB를 기다림
- 동시에 스레드 t2가 accountB를 잠그고 accountA를 기다림
- 둘 다 상대방의 자원을 영원히 기다리는 데드락 발생!


이 문제가 특히 심각한 이유는 충돌 없이 동작할 때도 있다는 것입니다. 타이밍 문제이기 때문에 프로덕션 환경에서 매우 드물게 발생하기도 합니다. 하지만 동시성이 높아지면 결국 터집니다.

 

데드락 진단하기

실제로 데드락이 발생했을 때 어떻게 찾을까요? 제가 자주 쓰는 방법을 소개합니다.


jstack을 사용한 스레드 덤프 분석

# 1. 실행 중인 Java 프로세스 확인
jps

# 2. 특정 프로세스의 스레드 덤프 생성 (PID는 jps 결과에서 확인)
jstack -l 12345 > thread_dump.txt

# 3. 덤프 파일에서 BLOCKED 상태의 스레드 찾기
grep -A 5 "BLOCKED" thread_dump.txt

스레드 덤프를 보면 각 스레드가 어느 락을 기다리고 있는지 명확하게 보입니다.

예를 들어

"Thread-0" #11 prio=5 os_prio=0 tid=0x00007f8b2d0e0000 nid=0x2710 waiting for monitor entry
  at Account.transfer(Test.java:15)
  - waiting to lock <0x00000000d73b5738> (a Account)

"Thread-1" #12 prio=5 os_prio=0 tid=0x00007f8b2d0e1000 nid=0x2711 waiting for monitor entry
  at Account.transfer(Test.java:15)
  - waiting to lock <0x00000000d73b5730> (a Account)

이런 식으로 "waiting to lock" 패턴이 순환적으로 나타나면 거의 100% 데드락입니다.

 

데드락 해결 방법

방법 1) 락 순서 정하기 (Lock Ordering)
가장 실용적인 방법입니다. 모든 스레드가 자원에 접근할 때 동일한 순서로 락을 획득하도록 강제합니다.

class Account {
    private int id;  // 계좌 번호
    private int balance;

    public Account(int id, int balance) {
        this.id = id;
        this.balance = balance;
    }

    public void transfer(Account other, int amount) {
        // 항상 id가 작은 계좌부터 잠금
        Account first = this.id < other.id ? this : other;
        Account second = this.id < other.id ? other : this;

        synchronized(first) {
            synchronized(second) {
                if (this.balance >= amount) {
                    this.balance -= amount;
                    other.balance += amount;
                }
            }
        }
    }
}

이렇게 하면 어떤 스레드든 항상 id가 작은 계좌부터 잠그므로, 순환 대기 조건이 제거됩니다.


방법 2) 타임아웃 설정하기 (Timeout)
ReentrantLock의 tryLock()을 사용하면 일정 시간만 대기하고 포기할 수 있습니다.

Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();

public void transferWithTimeout(int amount) {
    boolean acquired1 = false;
    boolean acquired2 = false;

    try {
        acquired1 = lock1.tryLock(1, TimeUnit.SECONDS);
        if (!acquired1) {
            System.out.println("lock1을 획득하지 못했습니다");
            return;
        }

        acquired2 = lock2.tryLock(1, TimeUnit.SECONDS);
        if (!acquired2) {
            System.out.println("lock2을 획득하지 못했습니다");
            return;
        }

        // 임계 영역
        System.out.println("안전하게 작업 수행");

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();

    } finally {
        if (acquired2) lock2.unlock();
        if (acquired1) lock1.unlock();
    }
}

 

방법 3) 공정성(Fairness) 설정

Lock fairLock = new ReentrantLock(true);  // 공정성 활성화

라이블락(Livelock) - 바쁘게 움직이지만 진행하지 못함

라이블락이란?

라이블락은 데드락보다 더 교활한 문제입니다. 스레드들이 서로 응답하면서 바쁘게 작동하지만, 결국 아무것도 진행하지 못하는 상태입니다.


생활 속 예시를 들자면, 복도에서 다른 사람과 마주쳤을 때 "왼쪽으로 갈래?", "오른쪽으로 갈래?" 하면서 계속 방향을 바꾸는 상황과 비슷합니다. 둘 다 움직이고 있는데 결국 지나가지 못하는 거죠.

 

라이블락 코드 예시

class LivelockExample {
    static class Person {
        private String name;
        private Spoon spoon;
        private Person otherPerson;


        public Person(String name) {
            this.name = name;
        }


        public void eat() {
            if (spoon != null) {
                if (otherPerson.spoon != null) {
                    System.out.println(name + ": 당신이 먼저 드세요");
                    spoon = null;  // 스푼을 내려놓음
                    return;
                }
                System.out.println(name + ": " + spoon + "로 밥을 먹는다");
            }
        }

        public void setSpoon(Spoon spoon) {
            this.spoon = spoon;
        }

        public void setOtherPerson(Person otherPerson) {
            this.otherPerson = otherPerson;
        }
    }

    static class Spoon {
        private String name;

        public Spoon(String name) {
            this.name = name;
        }

        public String toString() {
            return name;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Spoon spoon = new Spoon("숟가락");
        Person person1 = new Person("철수");
        Person person2 = new Person("영희");

        person1.setSpoon(spoon);
        person2.setOtherPerson(person1);
        person1.setOtherPerson(person2);

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                person1.eat();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                person2.eat();
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

이 코드를 실행하면 "철수: 당신이 먼저 드세요", "영희: 당신이 먼저 드세요"가 계속 반복됩니다. 누구도 밥을 못 먹으면서 CPU는 계속 돌고 있는 라이블락 상태입니다.

 

라이블락 vs 데드락

데드락과 라이블락의 핵심 차이
- 데드락: 스레드가 완전히 멈춤 (BLOCKED 상태), CPU 사용 안 함
- 라이블락: 스레드가 계속 실행 (RUNNABLE 상태), CPU 사용함
따라서 라이블락은 발견하기가 더 어렵습니다. CPU 사용량이 높은데 진행이 없는 이상한 상황으로 나타나니까요.

 

라이블락 해결 방법

방법 1) 백오프(Backoff) 전략
재시도할 때 일정한 지연을 두거나, 무작위 대기 시간을 도입합니다.

public void eat() {
    if (spoon != null) {
        if (otherPerson.spoon != null) {
            System.out.println(name + ": 당신이 먼저 드세요");
            spoon = null;


            // 무작위 대기 추가
            try {
                Thread.sleep((long) (Math.random() * 100));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return;
        }
        
        System.out.println(name + ": " + spoon + "로 밥을 먹는다");
    }
}

스타베이션(Starvation) - 우선순위가 높은 스레드에게 밀려남

스타베이션이란?

스타베이션은 CPU 자원이나 락을 획득할 기회를 얻지 못해서 스레드가 영원히 실행되지 않는 상태입니다. 데드락처럼 완전히 멈춘 건 아니지만, 스케줄러가 자신을 선택해주지 않아 진행하지 못합니다.


한국말로는 "기아 상태"라고도 부르는데, 마치 음식을 못 얻어 굶는 것처럼, 스레드가 CPU 시간을 못 얻어 작업하지 못하는 상황을 표현합니다.

 

스타베이션 해결 방법

방법 1) 스레드 우선순위 조정하지 않기
자바에서는 스레드 우선순위를 변경할 수 있지만, 권장되지 않습니다. 대부분의 경우 기본 우선순위(5)를 사용하는 것이 안전합니다.

// 하지 마세요!
thread.setPriority(Thread.MAX_PRIORITY);  // 위험함

// 기본값 유지 (권장)
thread.setPriority(Thread.NORM_PRIORITY);

 

방법 2) ReentrantLock의 공정성 옵션

Lock fairLock = new ReentrantLock(true);  // 공정한 락
public void criticalSection() {
    fairLock.lock();
    
    try {
        // 임계 영역
        System.out.println(Thread.currentThread().getName() + ": 작업 수행");

    } finally {
        fairLock.unlock();
    }
}

종합 비교 및 실제 활용 팁

세 가지 문제 한눈에 비교

발생 원인 순환 대기 자원 양보 반복 우선순위 차이
스레드 상태 BLOCKED RUNNABLE RUNNABLE
CPU 사용 X O X (자신은)

실제 프로덕션 환경에서의 팁

1) 항상 모니터링 하기
데드락은 개발 환경에서 재현되지 않을 수 있습니다. 프로덕션에서는 응답 시간 모니터링, CPU 사용량 추이 관찰, 스레드 덤프 주기적 분석

 

2) synchronized 남발하지 않기
synchronized를 많이 쓸수록 데드락 위험이 증가합니다. 필요한 부분만 동기화, 큰 범위의 락보다 작은 범위 선호, 가능하면 불변 객체 사용

 

3) ReentrantLock 고려하기
복잡한 동기화가 필요하면 synchronized보다 ReentrantLock이 낫습니다. tryLock()으로 타임아웃 설정 가능, 공정성 옵션 지원, Condition으로 더 세밀한 제어 가능

 

4) 테스트 도구 활용
데드락 가능성을 테스트할 때 Thread.sleep() 또는 Thread.yield() 삽입으로 타이밍 변화, 부하 테스트로 동시성 높이기, jstack 정기적 분석

 

Java 멀티스레드 개발은 분명 어렵습니다. 저도 PHP에서 오면서 적응하는 데 시간이 걸렸어요. 하지만 데드락, 라이블락, 스타베이션의 원인과 해결책을 이해하면 대부분의 동시성 문제를 예방하고 해결할 수 있습니다.


핵심을 정리하면
- 데드락: 락 순서 일관성 유지와 타임아웃 설정
- 라이블락: 백오프 전략과 명확한 역할 분담
- 스타베이션: 공정성 옵션과 우선순위 사용 자제


이 세 가지만 기억하고 프로젝트에 적용하면, 여러분도 안정적인 멀티스레드 애플리케이션을 만들 수 있을 거예요.

 

 

Java Executors, Future, Callable의 개념과 활용법을 완벽히 이해하자

안녕하세요. 오늘은 Java의 동시성(Concurrency) 관련해서 많은 개발자들이 헷갈려하는 Executors, Future, Callable에 대해 자세히 설명하려고 합니다. 저도 처음 이 주제를 공부할 때는 복잡해 보였지만,

byteandbit.tistory.com

 

반응형