동시성

Yong Lee·2025년 9월 24일

🎯 동시성 제어, 어디까지 알고 계신가요? JVM & DB 레벨 락 완벽 분석!

동시성 문제 해결의 핵심 개념인 JVM 레벨 동시성 제어와 DB 레벨 동시성 제어에 대해 함께 알아보겠습니다. 이 두 가지를 정확히 이해하고 상황에 맞춰 활용하는 것이 안정적이고 효율적인 시스템을 구축하는 데 매우 중요합니다.

1. 동시성의 기본 이해

  • 정의: 여러 스레드나 사용자가 공유 자원(데이터, 메모리 등)에 동시에 접근할 때 발생하는 문제와 이를 효율적으로 처리하는 방법입니다.
  • 핵심 예시: 재고가 단 1개 남았을 때, 여러 사용자가 동시에 구매를 요청하더라도 단 한 명만이 성공적으로 구매할 수 있도록 처리하는 상황이 대표적인 동시성 문제 해결의 예시입니다.

2. JVM 레벨 동시성 제어

이 부분은 자바 애플리케이션의 단일 JVM(메모리) 내부에서 여러 스레드가 공유하는 인메모리 데이터 (변수, 객체 등)의 일관성을 보장하기 위해 사용됩니다.

  • synchronized 키워드: 특정 코드 블록이나 메서드를 한 번에 한 스레드만 실행하도록 하는 상호 배제(Mutex) 메커니즘을 제공합니다. 이는 단순한 공유 자원 접근에 유용합니다. (예: count++와 같은 원자적 연산 보호)
  • wait() / notify() / notifyAll() 메서드: synchronized 블록 내에서 스레드 간에 특정 조건이 충족될 때까지 대기하거나 깨우는 통신 메커니즘입니다. 이는 스레드 간의 협업(Cooperation)이 필요할 때 주로 사용됩니다.
    • synchronized만으로 충분한 경우 (increment() 예시)와 wait/notify가 필요한 경우 (생산자-소비자 예시)를 구분하여 살펴보았습니다.

예시: 생산자-소비자 패턴 (JVM 레벨 동시성 제어)
다음 코드는 wait()와 notifyAll()을 활용하여 생산자 스레드와 소비자 스레드가 공유 큐를 안전하게 사용하는 예시입니다. 큐가 가득 차면 생산자는 대기하고, 큐가 비면 소비자가 대기하며 서로에게 알림을 보냅니다.

import java.util.LinkedList;
import java.util.Queue;

public class Main {
    private static final int BUFFER_SIZE = 5;
    private final Queue<Integer> queue = new LinkedList<>();
    private final int maxSize;

    public Main(int maxSize) {
        this.maxSize = maxSize;
    }

    public synchronized void produce(int item) throws InterruptedException {
        // 큐가 가득 찼으면 대기
        while (queue.size() == maxSize) {
            System.out.println("큐가 가득 찼어요! 생산자 대기 중...");
            wait(); // 다른 스레드가 notify() 호출할 때까지 대기
        }

        // 아이템 추가
        queue.add(item);
        System.out.println("생산: " + item + " (큐 크기: " + queue.size() + ")");

        // 소비자에게 알림
        notifyAll();
    }

    public synchronized int consume() throws InterruptedException {
        // 큐가 비었으면 대기
        while (queue.isEmpty()) {
            System.out.println("큐가 비었어요! 소비자 대기 중...");
            wait(); // 다른 스레드가 notify() 호출할 때까지 대기
        }

        // 아이템 꺼내기
        int item = queue.poll();
        System.out.println("소비: " + item + " (큐 크기: " + queue.size() + ")");

        // 생산자에게 알림
        notifyAll();
        return item;
    }

    public static void main(String[] args) {
        Main example = new Main(BUFFER_SIZE);

        // 생산자 스레드
        Thread producer = new Thread(() -> {
            try {
                for (int i = 0; i < 20; i++) {
                    example.produce(i);
                    Thread.sleep(300); // 생산 속도 조절
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        // 소비자 스레드
        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 20; i++) {
                    example.consume();
                    Thread.sleep(100); // 소비 속도 조절 (생산보다 느림)
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();
    }
}

3. DB 레벨 동시성 제어

이 영역은 DB(데이터베이스)에 저장된 영구 데이터에 여러 서버 인스턴스 또는 여러 애플리케이션의 트랜잭션들이 동시에 접근하여 변경할 때 데이터 무결성을 보장하기 위해 사용됩니다.

비관적 락 (Pessimistic Lock)

  • 개념: "어차피 충돌이 발생할 것이니, 미리 잠가버리고 내가 먼저 처리하겠다!"는 비관적인 관점에서 접근합니다.
  • 동작: 데이터를 읽기 전에 미리 락을 걸어서 다른 트랜잭션의 접근(읽기/쓰기)을 명시적으로 차단합니다. 첫 번째 트랜잭션이 작업을 완료하고 락을 해제해야 다른 트랜잭션이 접근할 수 있습니다.
  • JPA: @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 사용하며, DB의 SELECT FOR UPDATE 쿼리로 변환됩니다.
  • 핵심: 이 락은 해당 쿼리로 선택된 특정 레코드(데이터)에만 걸립니다. (예: Stock 테이블의 id=1인 데이터에만!) 다른 id의 데이터나 다른 테이블의 데이터는 영향을 받지 않습니다. PESSIMISTIC_WRITE는 해당 레코드에 대한 모든 (읽기/쓰기) 접근을 락이 풀릴 때까지 대기시킵니다.
  • 장점: 데이터 무결성 보장이 확실하며, 구현이 비교적 간단합니다.
  • 단점: 높은 대기 시간, 전반적인 시스템 성능 저하, 데드락 발생 위험이 있습니다.

낙관적 락 (Optimistic Lock)

  • 개념: "충돌이 자주 발생하지 않을 것이니, 일단 다 같이 작업하고 문제가 생기면 그때 처리하자!"는 낙관적인 관점에서 접근합니다.
  • 동작: 락을 미리 걸지 않습니다. 데이터를 읽을 때 버전(또는 타임스탬프) 정보를 같이 가져옵니다. 이후 업데이트 시점에, 자신이 가져왔던 버전과 DB에 현재 저장된 데이터의 버전이 같은지 확인합니다. 버전이 다르면 (즉, 그 사이에 다른 트랜잭션이 데이터를 변경했다면) 충돌로 간주하고 OptimisticLockException을 발생시킵니다.
  • JPA: 엔티티에 @Version 필드를 추가하면 JPA가 자동으로 관리하며, 업데이트 시 WHERE 절에 버전 조건을 추가하여 검증합니다.
  • 핵심: 버전 정보를 통해 "내가 조회한 이후로 다른 사용자가 이 데이터를 건드렸는지"를 확인하는 방식으로 충돌을 감지합니다.
  • 장점: 높은 동시성 제공, 데드락 발생 없음, 불필요한 DB 자원 점유가 없어 효율적입니다.
  • 단점: 충돌 시 예외가 발생하므로 재시도 로직 구현이 필요합니다 (Spring Retry, 수동 루프, AOP 등을 통해 처리 가능). 경쟁이 심할 때는 계속 재시도만 하다 비효율적이 될 수 있습니다.

4. JVM vs. DB 레벨 락, 결론은?

두 가지 동시성 제어 방식을 모두 이해하고 상황에 따라 적절히 결합해야 합니다.

  • JVM 레벨 락: 주로 서버 메모리 내부의 공유 자원 보호에 사용됩니다.
  • DB 레벨 락: 주로 데이터베이스에 저장된 영구 데이터 보호에 사용됩니다.

대부분의 웹 애플리케이션에서 동시성 문제의 핵심은 DB 데이터를 여러 트랜잭션이 동시에 조작할 때 발생하기 때문에, 낙관적/비관적 락이 더 중요하고 빈번하게 사용됩니다. JVM 내부의 인메모리 데이터를 보호할 필요가 있을 때만 synchronized나 Lock 등을 추가적으로 사용하는 것이 일반적입니다.

profile
오늘은 어떤 새로운 것이 나를 즐겁게 할까?

0개의 댓글