Java Thread 5부 - Lock, Atomic, volatile로 동시성 제어 확장하기

청록비
2 read

지난 4부에서는 Thread가 대기 및 기다리는 방법을 알아봤다.

이번 편은 동시성 제어 심화 편이다.

synchronized는 Java에서 가장 기본적인 동기화 방법이다.

하지만 실무에서는 상황에 따라 더 세밀한 제어가 필요한 경우도 있다.

예를 들면 이런 상황이다.

잠금을 얻지 못하면 기다리지 않고 포기하고 싶다.
잠금을 기다리는 중에 interrupt를 받고 싶다.
단순 숫자 증가만 안전하게 처리하고 싶다.
값 변경을 다른 Thread가 바로 보게 하고 싶다.

이럴 때 사용하는 도구들이 있다.

Lock
ReentrantLock
Atomic
volatile

이번 글에서는 synchronized 다음에 알아야 할 실무형 동시성 도구들을 정리해보자.


1. synchronized의 한계

먼저 synchronized를 간단히 떠올려보자.

class Counter {
    private int count = 0;

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

    public int getCount() {
        return count;
    }
}

이 코드는 안전하다.

여러 Thread가 동시에 increase()를 호출해도 한 번에 하나의 Thread만 들어올 수 있다.

하지만 synchronized는 제어가 단순하다.

잠금을 얻을 때까지 기다린다.
잠금을 직접 해제할 수 없다.
잠금 획득 실패 처리를 하기 어렵다.
기다리는 중에 세밀한 제어를 하기 어렵다.

단순한 동기화에는 좋지만, 복잡한 상황에서는 부족할 수 있다.

그래서 등장하는 게 Lock이다.


2. Lock

Lock은 잠금을 직접 다루기 위한 인터페이스다.

대표 구현체는 ReentrantLock이다.

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 increase() {
        lock.lock();

        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        return count;
    }
}

synchronized와 다르게 Lock은 직접 잠그고 직접 풀어야 한다.

lock.lock();

try {
    count++;
} finally {
    lock.unlock();
}

여기서 가장 중요한 건 finally다.

작업 도중 예외가 발생해도 반드시 unlock()이 실행되어야 한다.

그렇지 않으면 잠금이 풀리지 않고, 다른 Thread들이 계속 기다리게 된다.


Lock의 장점

Locksynchronized보다 코드가 조금 복잡하다.

대신 더 세밀한 제어가 가능하다.

대표적으로 이런 기능들이 있다.

tryLock()
lockInterruptibly()
공정성 설정
Condition 사용

이번 글에서는 입문 단계에서 꼭 알아야 할 tryLock()ReentrantLock 중심으로 보자.


3. tryLock()

synchronized는 잠금을 얻을 때까지 기다린다.

하지만 실무에서는 이런 경우가 있다.

잠금을 얻을 수 있으면 작업한다.
잠금을 얻지 못하면 기다리지 않고 다른 처리를 한다.

이럴 때 tryLock()을 사용한다.

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 increase() {
        if (lock.tryLock()) {
            try {
                count++;
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("잠금을 얻지 못해서 작업을 건너뜁니다.");
        }
    }
}

tryLock()은 잠금을 얻을 수 있으면 true, 얻을 수 없으면 false를 반환한다.

즉, 무조건 기다리지 않는다. 이 점이 synchronized와 다르다.


시간을 정해서 기다릴 수도 있다

잠금을 무한정 기다리지 않고 정해진 시간만 기다리게 할 수도 있다.

import java.util.concurrent.TimeUnit;
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 increase() throws InterruptedException {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                count++;
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("1초 동안 기다렸지만 잠금을 얻지 못했습니다.");
        }
    }
}

이런 방식은 장애 상황에서 유용하다.

무한히 기다리는 대신 일정 시간 뒤 실패 처리할 수 있기 때문이다.


4. ReentrantLock

ReentrantLock에서 Reentrant재진입 가능하다는 뜻이다.

같은 Thread가 이미 가진 잠금을 다시 얻을 수 있다는 의미다.

import java.util.concurrent.locks.ReentrantLock;

class Service {
    private final ReentrantLock lock = new ReentrantLock();

    public void outer() {
        lock.lock();

        try {
            System.out.println("outer 실행");
            inner();
        } finally {
            lock.unlock();
        }
    }

    public void inner() {
        lock.lock();

        try {
            System.out.println("inner 실행");
        } finally {
            lock.unlock();
        }
    }
}

  1. outer()에서 이미 lock을 얻었다.

  2. 그 상태에서 inner()를 호출한다.

  3. inner()도 같은 lock을 다시 얻는다.

이게 가능한 이유는 ReentrantLock이 같은 Thread의 재진입을 허용하기 때문이다.


잠근 횟수만큼 풀어야 한다

중요한 점이 있다.

재진입이 가능하다는 건 잠금을 여러 번 얻을 수 있다는 뜻이다.

그러면 반드시 얻은 횟수만큼 풀어야 한다.

lock() 2번 호출
→ unlock()도 2번 호출

위 예제에서는 outer()에서 한 번, inner()에서 한 번 잠금을 얻는다.

그래서 각각의 finally에서 unlock()을 호출해야 한다.


5. 공정성 옵션

ReentrantLock은 공정성 옵션을 줄 수 있다.

private final ReentrantLock lock = new ReentrantLock(true);

true를 주면 오래 기다린 Thread가 먼저 잠금을 얻을 가능성이 높아진다.

new ReentrantLock()
- 기본값
- 성능에 유리할 수 있음
- 잠금 획득 순서를 보장하지 않음

new ReentrantLock(true)
- 공정 모드
- 오래 기다린 Thread에게 기회를 줌
- 대신 성능은 떨어질 수 있음

대부분은 기본값을 사용한다.

공정성이 정말 중요한 경우에만 true를 고려하면 된다.


6. Atomic

이번에는 Atomic을 보자.

Atomic은 단일 값에 대한 연산을 안전하게 처리하기 위한 클래스다.

대표적으로 AtomicInteger가 있다.

먼저 위험한 코드를 다시 보자.

public class Main {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

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

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

        System.out.println(count);
    }
}

우리는 20000을 기대한다.

하지만 실제로는 더 작은 값이 나올 수 있다.

count++가 한 번에 실행되는 작업이 아니기 때문이다.

1. count 값을 읽는다
2. 1을 더한다
3. 다시 저장한다

여러 Thread가 이 과정을 동시에 실행하면 값이 꼬일 수 있다.


AtomicInteger 사용하기

AtomicInteger를 사용하면 숫자 증가를 안전하게 처리할 수 있다.

import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count.incrementAndGet();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count.incrementAndGet();
            }
        });

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

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

        System.out.println(count.get());
    }
}

이제 결과는 안정적으로 나온다.

20000

incrementAndGet()은 값을 1 증가시키고 증가된 값을 반환한다.

비슷한 메서드도 있다.

incrementAndGet()
1 증가 후 결과 반환

getAndIncrement()
현재 값 반환 후 1 증가

decrementAndGet()
1 감소 후 결과 반환

addAndGet(10)
10 더한 후 결과 반환

compareAndSet(expected, update)
현재 값이 expected와 같으면 update로 변경

7. Atomic은 언제 쓸까?

Atomic은 단순한 상태값이나 카운터에 좋다.

AtomicInteger count = new AtomicInteger(0);
AtomicLong sequence = new AtomicLong(0);
AtomicBoolean running = new AtomicBoolean(true);

예를 들어 방문자 수 카운터는 AtomicInteger로 간단하게 만들 수 있다.

import java.util.concurrent.atomic.AtomicInteger;

class VisitCounter {
    private final AtomicInteger count = new AtomicInteger(0);

    public int increase() {
        return count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

단일 값을 안전하게 증가시키는 정도라면 synchronized보다 코드가 간단하다.


Atomic도 만능은 아니다

Atomic은 단일 연산에는 강하다.

하지만 여러 단계를 묶어야 하는 로직에는 조심해야 한다.

예를 들어 재고 차감 코드를 보자.

import java.util.concurrent.atomic.AtomicInteger;

class Stock {
    private final AtomicInteger stock = new AtomicInteger(1);

    public void decrease() {
        if (stock.get() > 0) {
            stock.decrementAndGet();
        }
    }
}

겉보기에는 괜찮아 보인다.

하지만 완전히 안전하다고 보기 어렵다.

이 코드는 두 단계로 나뉜다.

1. stock.get()으로 재고 확인
2. stock.decrementAndGet()으로 재고 감소

두 단계 사이에 다른 Thread가 끼어들 수 있다.

이런 경우에는 compareAndSet()을 사용하거나,
전체 로직을 Lock이나 synchronized로 묶는 방식이 더 안전하다.

입문 단계에서는 이렇게 기억하면 된다.

단순 증가, 감소, 변경 → Atomic
조건 확인 후 여러 작업 처리 → synchronized 또는 Lock 고려

8. volatile

이번에는 volatile이다.

멀티스레드 문제는동시에 값을 수정할 때만 생기는 게 아니다.

한 Thread가 값을 바꿨는데 다른 Thread가 그 변경을 보지 못하는 문제도 있다.

이걸 가시성 문제라고 한다.


가시성 문제 예제

public class Main {
    static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (running) {
                // 작업 중
            }

            System.out.println("작업 종료");
        });

        worker.start();

        Thread.sleep(1000);

        running = false;
        System.out.println("running = false 변경");
    }
}

우리는 이렇게 기대한다.

1초 뒤 running이 false가 된다.
worker Thread가 while문을 빠져나온다.
"작업 종료"가 출력된다.

하지만 실제로는 worker Thread가 종료되지 않을 수 있다.

왜냐하면 worker Thread가 변경된 running 값을 보지 못할 수 있기 때문이다.


volatile로 해결하기

이럴 때 volatile을 사용한다.

public class Main {
    static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (running) {
                // 작업 중
            }

            System.out.println("작업 종료");
        });

        worker.start();

        Thread.sleep(1000);

        running = false;
        System.out.println("running = false 변경");
    }
}

차이는 이 한 줄이다.

static volatile boolean running = true;

volatile은 한 Thread가 변경한 값을 다른 Thread가 볼 수 있게 해준다.

즉, 가시성을 보장한다.


9. volatile은 무엇을 보장할까?

volatile의 핵심은 이거다.

변경된 값을 다른 Thread가 볼 수 있게 한다.

그래서 이런 상태 플래그에 자주 사용된다.

private volatile boolean running = true;
private volatile boolean closed = false;
private volatile boolean initialized = false;

이런 값들은 보통 복잡한 계산을 하지 않는다.

그냥 상태를 켜고 끄는 용도다.

running = false;
closed = true;
initialized = true;

이런 단순 상태값에는 volatile이 잘 맞는다.


volatile은 count++를 안전하게 만들지 않는다

여기서 많이 헷갈린다.

static volatile int count = 0;

이렇게 하면 count++가 안전해질까?

아니다.

volatile은 변경된 값을 보이게 해줄 뿐이다.

count++의 여러 단계를 하나의 안전한 작업으로 묶어주지는 않는다.

1. count 읽기
2. 1 더하기
3. count 저장하기

그래서 아래 코드는 여전히 안전하지 않다.

public class Main {
    static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

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

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

        System.out.println(count);
    }
}

volatile을 붙였지만 결과가 20000보다 작게 나올 수 있다.

이 경우에는 AtomicInteger, synchronized, Lock 중 하나를 써야 한다.


10. Lock, Atomic, volatile 비교

세 도구의 역할은 다르다.

Lock / ReentrantLock
- 여러 Thread가 동시에 접근하면 안 되는 영역을 보호한다.
- synchronized보다 세밀한 제어가 가능하다.
- tryLock(), 공정성 설정 등을 사용할 수 있다.

Atomic
- 단일 값의 원자적 연산에 적합하다.
- 카운터, 시퀀스, 단순 상태 변경에 좋다.
- 여러 조건과 여러 값을 함께 다루는 로직에는 부족할 수 있다.

volatile
- 값의 변경을 다른 Thread가 볼 수 있게 한다.
- 상태 플래그에 적합하다.
- count++ 같은 복합 연산은 안전하게 만들지 못한다.

11. 무엇을 언제 써야 할까?

실무 기준으로 단순하게 정리하면 이렇다.

공유 데이터를 수정하는 코드 영역을 보호해야 한다
→ synchronized 또는 Lock

잠금 획득 실패 처리나 시간 제한이 필요하다
→ ReentrantLock, tryLock()

단순 숫자 증가, 감소, 카운팅이 필요하다
→ AtomicInteger, AtomicLong

true / false 상태 변경을 다른 Thread가 바로 봐야 한다
→ volatile 또는 AtomicBoolean

조건 확인 후 여러 작업을 함께 처리해야 한다
→ synchronized 또는 Lock

예를 들어보자.

방문자 수 증가라면 AtomicInteger가 좋다.

private final AtomicInteger visitCount = new AtomicInteger(0);

public void visit() {
    visitCount.incrementAndGet();
}

작업 실행 여부를 제어하는 플래그라면 volatile이 잘 맞는다.

private volatile boolean running = true;

public void stop() {
    running = false;
}

여러 값을 함께 수정해야 한다면 Lock이나 synchronized가 더 적합하다.

lock.lock();

try {
    accountA.withdraw(1000);
    accountB.deposit(1000);
} finally {
    lock.unlock();
}

12. 정리

  1. synchronized는 Java 동기화의 기본이다.

  2. 하지만 실무에서는 그 외에도 상황에 맞는 도구를 선택해야 한다.

  3. Lock은 더 세밀하게 잠금을 제어할 수 있다.

  4. ReentrantLock은 재진입 가능한 잠금이며, tryLock() 같은 기능으로 무한 대기를 피할 수 있다.

  5. Atomic은 단일 값의 안전한 연산에 적합하다.

  6. 카운터, 시퀀스 번호, 단순 상태값처럼 작은 단위의 값 처리에 좋다.

  7. volatile은 가시성 문제를 해결한다.

  8. 한 Thread가 바꾼 값을 다른 Thread가 볼 수 있게 해준다.

  9. 하지만 volatile은 복합 연산을 안전하게 만들지는 못한다.

마지막으로 이렇게 기억하자.

Lock은 영역을 보호한다.
Atomic은 단일 연산을 안전하게 만든다.
volatile은 변경된 값을 보이게 한다.

동시성 제어는 도구를 많이 아는 것보다 각 도구가 해결하는 문제가 무엇인지 구분하는 게 중요하다.

Thread를 안전하게 다룬다는 건
결국 공유 데이터를 어떤 방식으로 지킬지 결정하는 일이다.

#Cloud#Infrastructure#Serverless#Tech2024