[Java 동시성] 동시성 문제 해결 방안

Java에서 멀티스레드 환경의 동시성 문제를 해결하기 위한 메커니즘을 설명합니다.
다양한 방안이 있지만 그 중 synchronized, database lock(비관적, 낙관적 락) 을 설명 + volatile, atomic



synchronized 키워드

synchronized를 명시해주면 하나의 스레드만 접근 가능
현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터 접근을 막아 순차적으로 데이터에 접근할 수 있도록 해줌

단, 하나의 프로세스 안에서만 보장되기 때문에 서버가 여러대(여러JVM) 일 경우에는 여전히 문제 => DB Lock으로 해결


메소드 동기화

public class Counter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}


블록 동기화

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



Database Lock

Database Lock이란 데이터베이스에서 한 프로세스가 데이터베이스 자원(데이터)를 잠그면 다른 다른 프로세스는 잠긴 데이터를 변경할 수 없다.

  • 여러 JVM 간의 동시성 제어 가능
  • 트랜잭션 격리 수준 제공
  • 데이터 무결성 보장
    • 무결성 : 데이터베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제 값일치하는 정확성
    • 무결성 제약 조건(Constraint)은 데이터베이스에 저장된 데이터의 정확성을 보장하기 위해 정확하지 않은 데이터가 데이터베이스 내에 저장되는 것을 방지하기 위한 제약조건
      1. NULL 무결성
        • 특정 속성 값이 NULL이 될 수 없도록 제한.
      2. 고유(Unique) 무결성
        • 특정 속성 값이 중복되지 않도록 제한.
        • 예: 고객번호 속성은 각 튜플에서 고유.
      3. 도메인(Domain) 무결성
        • 속성 값은 정의된 도메인 내 범위에서만 가지도록 제한.
        • 예: 성별 속성은 ‘남’ 또는 ‘여’만 가능.
      4. 키(Key) 무결성
        • 하나의 릴레이션에 적어도 하나의 키가 반드시 존재.
      5. 관계(Relationship) 무결성
        • 튜플 삽입 가능 여부나 릴레이션 간의 관계 적절성을 제한.
      6. 참조(Referential) 무결성
        • 외래키는 NULL이거나 참조 릴레이션의 기본키와 동일해야 함.
      7. 개체(Entity) 무결성
        • 기본키를 구성하는 속성 값은 NULL이 될 수 없음.



크게 2가지 - 비관적 락, 낙관적 락

크게 2가지 “비관적 락, 낙관적 락” 사용

  • 비관적 락: FOR UPDATE는 해당 레코드를 즉시 락으로 걸어 다른 트랜잭션이 수정하지 못하도록 막음
  • 낙관적 락: 데이터 레코드에 버전(version)이라는 필드를 추가하여 충돌을 감지.
    예로 트랜잭션 시작 시 읽어온 버전 값(예: version = 0)을 조건으로, 데이터 수정 시 버전이 변경되지 않았는지 확인
-- 예시: 비관적 락 -> select...for update
SELECT * FROM users WHERE id = 1 FOR UPDATE;

-- 예시: 낙관적 락
UPDATE users SET version = version + 1 
WHERE id = 1 AND version = 0;


차이와 실생활 예시

  • 비관적 락 - 사전 예약 시스템: 한 공연의 좌석을 동시에 예약할 경우, 누군가 특정 좌석을 선택하면 다른 사람은 그 좌석에 접근할 수 없게 락을 거는 방식
  • 낙관적 락 - 협업 문서 편집: 여러 사용자가 동시에 문서를 편집할 때, 각 사용자가 편집 후 저장할 때 충돌 여부를 확인
특징 비관적 락 (FOR UPDATE) 낙관적 락 (version)
락 사용 여부 트랜잭션 동안 데이터에 락을 걸어 충돌 방지 락 없이 충돌 발생 시 처리
데이터 충돌 충돌 가능성을 사전에 방지 충돌 발생 후 이를 감지하여 처리
성능 다른 트랜잭션이 대기하므로 성능 저하 가능 락이 없어 성능 우수
사용 시나리오 충돌 가능성이 높은 경우 (동시 수정이 빈번) 충돌 가능성이 낮은 경우 (수정 빈도 낮음)



volatile 키워드와 Atomic 클래스

특징 volatile Atomic 클래스
목적 변수의 가시성 보장 변수의 가시성원자성 보장
복합 작업 지원 지원하지 않음 지원 (예: 증가, 감소 등)
락 사용 여부 없음 없음 (CAS 알고리즘 사용)
주요 클래스/키워드 volatile 키워드 AtomicInteger, AtomicLong
사용 예 플래그 변수, 단순 상태 체크 카운터, 복합 데이터 연산



volatile

volatile변수의 값을 여러 스레드가 메모리 간 동기화하여 읽고 쓰도록 보장

volatile 변수를 읽거나 쓸 때, CPU 캐시(스레드 스택) 대신 메인 메모리에서 값을 사용

class VolatileExample {
    private static volatile boolean running = true;

    public static void main(String[] args) {
        Thread worker = new Thread(() -> {
            while (running) {  // running 값이 메모리에서 항상 최신 값으로 읽힘
                System.out.println("Working...");
            }
            System.out.println("Stopped.");
        });

        worker.start(); //main thread는 바로 아래 단계로 지속.

        try {
            Thread.sleep(1000); // 1초 후에 running을 false로 설정
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        running = false;  // 메인 메모리에서 모든 스레드가 최신 값을 읽음
    }
}



AtomicXXX

Atomic 클래스는 멀티스레드 환경에서 원자적(Atomic) 연산을 보장

내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용해 Lock 없이 동시성을 처리

  • CAS: 스레드 내의 스택(캐시)에 저장된 값과 메인 메모리에 저장된 값을 “비교한 후 같다면 새로운 값으로 치환” 하고, 값이 다르다면 계속 재시도
    • Lock을 걸지 않으니까 비용이 작다.
    • volatile 처럼 메인 메모리도 사용한다. (실제로 CAS의 내부 구현에서 메인 메모리의 값을 가져오는 용도로 volatile을 사용)
    • 읽기 쓰기 연산만 지원하는 volatile과 다르게 Atomic은 복합 연산(증,감) 가능 => 원자성 보장
    • 참고: ConcurrentHashMap 클래스에서도 사용되고, 빈 버킷에 값을 넣을 때 CAS 알고리즘을 사용함으로써 빠르게 처리. (이미 값이 들어있으면 synchronized를 사용)
import java.util.concurrent.atomic.AtomicInteger;

class AtomicExample {
    private static AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread incrementer1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.incrementAndGet();  // 원자적으로 증가
            }
        });

        Thread incrementer2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.incrementAndGet();  // 원자적으로 증가
            }
        });

        incrementer1.start();
        incrementer2.start();

        incrementer1.join();  // 스레드 종료 대기
        incrementer2.join();

        System.out.println("Final Counter Value: " + counter.get());
        //출력: Final Counter Value: 2000
    }
}



volatile와 Atomic의 차이점 이해 예제

잘못된 volatile 사용 예

class VolatileCounter {
    private static volatile int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++;  // 복합 작업 (읽기 + 쓰기) → 원자성이 깨짐
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++;  // 복합 작업 -> counter = counter + 1;
            }
        });

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

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

        System.out.println("Final Counter Value: " + counter);
    }
}


Final Counter Value는 2000이 아닐 수 있다.
이는 counter++읽기 → 수정 → 쓰기로 이루어진 복합 작업이기 때문. volatile은 원자성을 보장하지 않으므로 데이터 경합이 발생 가능한 것


💡 동시성 제어 선택 기준

  • 단일 JVM: synchronized 사용
  • 분산 환경: Database Lock 사용
  • 성능 중요: volatile 또는 atomic 클래스 고려

적절한 동시성 제어 메커니즘 선택은 시스템의 안정성과 성능에 큰 영향을 미칩니다.

댓글남기기