Java

레이스 컨디션 (Race Condition) -01. @Transactional, synchronized

devgenie 2024. 4. 9. 00:12

레이스 컨디션 이란?

레이스 컨디션은 멀티 스레드 환경에서 두개 이상의 스레드가 공유 자원에 동시에 접근하려고 경합하는 현상을 이야기 한다. 이러한 상황에서 스레드간의 실행 순서가 예측이 불가능해 지고, 그에 따라 프로그램의 실행 결과가 실행시마다 달라질 가능성이 생긴다. 즉 레이스 컨디션이 발생하면 데이터의 일관성과 정확성이 손상될 가능성이 생겨 버그로 이어질 수 있다.

다음은 레이스 컨디션을 일으킬 수 있는 코드 예제이다.

 

public class Counter {
    private int count = 0;

    public void increment() {
        count++; // 이 부분에서 레이스 컨디션 발생 가능
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        // 스레드 1: 카운터 값을 증가
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        // 스레드 2: 카운터 값을 증가
        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();
        }

        // 예상 결과는 2000이지만, 레이스 컨디션으로 인해 그보다 적은 값이 출력될 수 있다
        System.out.println("Count: " + counter.getCount());
    }
}

 

왜 2000보다 적은 값이 출력될 수 있는 걸까? 레이스 컨디션이 발생하는 상황을 단계별로 살펴보면 이해가 빠를 것이다.

  1. 스레드 Acount의 현재 값을 읽는다. count의 현재 값은 100이라고 하자.
  2. 스레드 B도 거의 동시에 count의 현재 값을 읽은 상황이다. 스레드 A가 아직 새 값을 쓰기 전이므로, 스레드 B가 읽는 값 역시 100이다.
  3. 스레드 A는 읽은 값(100)에 1을 더하여 101을 계산한다.
  4. 스레드 B도 읽은 값(100)에 1을 더하여 101을 계산한다.
  5. 스레드 A가 계산한 값을 count에 썼다. 이제 count의 값은 101이 되었다.
  6. 스레드 B도 계산한 값을 count에 썼다. 이 값도 101이다.

위 과정에서 increment() 메서드가 두 번 호출되었음에도 불구하고 count의 값이 2가 아닌 1만 증가할 수 있다. 이러한 상황이 반복된다면, 실제로 increment() 메서드를 2000번 호출했더라도 count의 최종 값이 2000보다 훨씬 적게 될 수 있게 되는 것이다.

이러한 문제를 해결하려면 어떻게 해야할까?

 

 

@Transactional을 이용한 레이스 컨디션 해결?

@Transactional 어노테이션은 스프링 프레임워크에서 제공하는 어노테이션으로, 트랜잭션 관리 기법의 일부이다.
@Transactional 을 이용하여 비즈니스 로직을 포함하는 메서드의 실행을 트랜잭션의 경계로 정의할 수 있는데, 내부적으로는 사용자가 만든 클래스를 래핑한 새로운 클래스를 만들어 실행하게 되어있기 때문이다. 메서드의 실행이 정상적으로 종료되면 트랜잭션은 커밋되어 모든 데이터 변경사항이 데이터베이스에 반영되고, 만약 실행 중에 예외가 발생하면 트랜잭션은 롤백되어 메서드 실행 전 상태로 데이터베이스가 복원된다. (차후 @Transactional에 대해 좀더 알아볼 예정이다)

 

이러한 동작방식이라면 @Transactional을 사용함으로써 레이스 컨디션을 해결할 수 있을 것으로 생각했지만, 아쉽게도 그럴 수 없다. 아래의 상황을 가정해 보면 그 이유를 알 수 있다.

 

  1. 첫 번째 메서드 호출: 사용자 또는 시스템에 의해 첫 번째 @Transactional 메서드가 호출되었다. 이 호출로 인해 첫 번째 트랜잭션이 시작된다.
  2. 데이터 변경: 첫 번째 트랜잭션 내에서 데이터베이스의 특정 데이터가 변경된다. 이 변경은 아직 커밋되지 않았기 때문에, 변경사항은 데이터베이스에 반영되지 않은 상태이다.
  3. 두 번째 메서드 호출: 거의 동시에 또는 첫 번째 트랜잭션이 커밋되기 전에 두 번째 @Transactional 메서드가 호출되었다. 두 번째 트랜잭션이 시작된다.
  4. 격리된 환경의 효과: 데이터베이스 트랜잭션 격리 수준에 따라, 두 번째 트랜잭션은 첫 번째 트랜잭션에서의 변경사항을 "못볼" 수 있다. 즉, 두 번째 트랜잭션이 첫 번째 트랜잭션에 의해 변경되고 있는 데이터를 기존의 상태(첫 번째 트랜잭션에 의한 변경사항을 반영하기 전 상태)로 읽게 될 수 있다.
  5. 데이터 변경(두 번째 트랜잭션): 두 번째 트랜잭션 내에서도 해당 데이터가 변경되었다. 이 변경은 첫 번째 트랜잭션의 변경사항과 충돌할 수 있다.
  6. 첫 번째 트랜잭션 커밋: 첫 번째 트랜잭션이 완료되고, 변경사항이 데이터베이스에 커밋되었다.
  7. 두 번째 트랜잭션 커밋: 두 번째 트랜잭션 역시 완료되고, 이 트랜잭션의 변경사항이 데이터베이스에 커밋되었다. 이때, 두 번째 트랜잭션의 변경사항이 첫 번째 트랜잭션의 변경사항을 덮어쓰거나, 두 변경사항이 충돌하여 예상치 못한 결과를 초래할 수 있다.

 

그러므로 @Transactional로는 레이스 컨디션을 해결해 줄 수 없다. 그렇다면 Java의 'synchronized ' 키워드를 이용하는 방법은 어떨까?

 

 

'synchronized' 키워드를 이용한 레이스 컨디션 해결?

synchronized 는 자바에서 동시성 문제를 해결하기 위해 사용되는 키워드이다. synchronized 키워드를 사용한다면 특정 객체나 메서드에 대한 접근을 하나의 스레드로 제한할 수 있는데, 그렇게 때문에 멀티스레드 환경에서 여러 스레드가 동일한 객체의 상태를 동시에 변경하려고 해도 각 변경작업이 서로에게 영향을 미치지 않게 한다.

따라서 데이터의 일관성, 객체의 상태 등을 안전하게 유지할 수 있다. 아래 코드 예시를 보자.

 

SynchronizedCounter 클래스

public class SynchronizedCounter {
    private int count = 0;

    // 동기화된 메서드를 이용하여 카운터 값을 증가시킵니다.
    public synchronized void increment() {
        count++;
    }

    // 카운터의 현재 값을 반환합니다.
    public synchronized int getCount() {
        return count;
    }
}

 

스레드를 생성하고 실행하는 코드

public class CounterTest {
    public static void main(String[] args) {
        SynchronizedCounter counter = new SynchronizedCounter();

        // 스레드 1: 카운터 값을 증가시킵니다.
        Thread thread1 = new Thread(() -> {
            for(int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        // 스레드 2: 카운터 값을 증가시킵니다.
        Thread thread2 = new Thread(() -> {
            for(int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });

        // 스레드 시작
        thread1.start();
        thread2.start();

        // 메인 스레드에서 thread1과 thread2의 종료를 기다립니다.
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 최종 카운터 값 출력
        System.out.println("Final count: " + counter.getCount());
    }
}

 

SynchronizedCounter 클래스 객체의 increment() 메서드를 thread1thread2가 호출하는 코드를 간략하게 만들어 보았다. 이 때 increment 메서드synchronized 키워드를 갖고 있으므로 다음과 같이 실행됨을 예상할 수 있다.

 

  1. thread1이 increment() 호출을 시도: thread1이 increment() 메서드를 호출하려고 하면, JVM은 먼저 SynchronizedCounter 객체의 락(잠금)을 획득하려고 시도한다.
  2. thread1이 락을 획득: 만약 SynchronizedCounter 객체에 대한 락이 사용 가능한 상태라면, thread1은 락을 성공적으로 획득하고 increment() 메서드의 코드를 실행한다.
  3. thread2가 increment() 호출을 시도: 거의 동시에 thread2도 increment() 메서드를 호출하려고 시도했다고 가정하자. 하지만 thread1이 이미 락을 획득하고 있기 때문에, thread2는 락이 해제될 때까지 대기해야 한다.
  4. thread1이 increment() 실행을 완료: thread1이 increment() 메서드 내의 작업을 마치면, 변경된 count 값을 저장하고 메서드 실행을 종료한다. 그 후 thread1은 SynchronizedCounter 객체의 락을 해제한다.
  5. thread2가 락을 획득: thread1이 락을 해제하자마자, 대기 중이던 thread2는 이제 SynchronizedCounter 객체의 락을 획득하고 increment() 메서드의 코드를 실행한다.
  6. thread2가 increment() 실행 완료: thread2는 increment() 메서드 내의 작업을 마치고, 변경된 count 값을 저장한 후 메서드 실행을 종료한다. 이후 thread2도 락을 해제한다.

 

이러한 과정과 같이 synchronized 키워드에 의해서 increment() 메서드는 한번에 하나의 스레드에 의해 실행될 수 있도록 보장된다. 따라서 count가 정상적으로 증가되므로 레이스 컨디션이 발생하지 않는다.

즉, 위 로직에 따라 thread1thread2에 의해 increment() 메서드 호출이 총 2000번 실행되어 count 변수는 정확히 2000까지 증가하게 된다.

그렇다면 synchonized 키워드를 사용한다면 모든 레이스 컨디션을 해결할 수 있는 걸까? 안타깝지만 synchronized 가 모든 동시성 이슈를 해결할 수 없다. 왜냐하면 synchronized는 같은 JVM 내에서 실행되는 스레드들에 대한 동기화만 보장하기 때문이다. 따라서 여러개의 서버를 사용하는 경우에는 인스턴스 간의 동기화 문제가 여전히 발생할 수 있다. 각각의 서버 인스턴스는 독립적인 JVM에서 실행되기 때문이다.

그러므로 레이스 컨디션 해결을 위한 다른방법이 필요하다.

 

ChatGPT4 DALL.E : 동시성 이슈를 나타낼 수 있는 그림 그려줘