동시에 하나의 데이터에 여러명의 사용자가 접근하는 경우, 문제가 생길 여지가 있습니다. 이러한 문제의 대표적인 예시가 싱글톤에서 상태를 가지고 있는 경우입니다.

하나의 싱글톤 인스턴스를 동시에 여러 사용자가 상태를 변경하게 되면, 정합성이 맞지 않는 경우가 존재합니다. 다음 예시를 살펴보겠습니다.

class Wallet {

    public static final Wallet INSTANCE = new Wallet();

    private long bill;

    private Wallet() {
        this.bill = 0;
    }

    public void putInto(long bill) {
        this.bill += bill;
    }
}

두명의 사용자가 동시에 각각 putInto 메소드를 실행시킨다고 가정해 봅시다. 결과적으로 bill 필드의 값은 두 사용자의 putInto 메소드로 인한 변화중 하나만 적용되게 될 것입니다. 왜냐하면 따로 동기화 처리가 되어있지 않기 때문에 메소드를 실행시키는 시점엔 같은 필드값을 읽어들이기 때문입니다.

 

동시성 문제

 

테스트

그렇다면, 이러한 테스트는 어떻게 해볼 수 있을까요? 두개의 쓰레드를 동시에 putInto() 메소드를 실행하도록 하면 될 것 같습니다. 코드는 다음과 같습니다.

@DisplayName("동시성 테스트")
@Test
void 동시성_테스트() throws InterruptedException {
    final Wallet wallet = Wallet.INSTANCE;

    Thread threadPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드1 종료");
    });

    Thread threadAnotherPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드2 종료");
    });

    threadPutInto.start();
    threadAnotherPutInto.start();

    assertThat(wallet.getBill()).isEqualTo(3000);
}

예상과는 다르게, 3000원이 나오지 않고 0원이 나오는 문제가 발생했습니다. 무엇이 문제였을까요? 테스트에서 assertThat을 호출하는 시점이 쓰레드의 작업이 모두 끝난 시점이 아닐수도 있기 때문입니다. 따라서 쓰레드의 작업이 모두 끝난 다음에 테스트를 하도록 바꿔주어야 합니다.

 

그렇다면 이러한 문제는 어떻게 해결할 수 있을까요? 이 때 사용하는 것이 CountdownLatch입니다. CountdownLatch의 countDown() 메서드와 await() 메서드를 사용하여 위의 문제를 간단하게 해결할 수 있습니다.

 

CountdownLatch

생성자에 원하는 count를 명시하고, countDown() 메서드로 count를 하나씩 감소시킵니다. await() 메서드는 CountdownLatch가 0이 되기만을 기다리는 메소드입니다. 따라서 각각의 쓰레드에서 countdownLatch를 하나씩 감소시키고, 테스트 쓰레드(Test worker)에서는 countdownLatch가 0이 되기를 기다리도록 작성하면 될 것 같습니다.

 

그럼 CountdownLatch를 사용하여 원하는 결과가 나오도록 테스트를 수정해 보겠습니다.

@DisplayName("동시성 테스트")
@Test
void 동시성_테스트() throws InterruptedException {
    final Wallet wallet = Wallet.INSTANCE;
    final CountDownLatch countDownLatch = new CountDownLatch(2);
    System.out.println(Thread.currentThread().getName());
    Thread threadPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드1 종료");
        countDownLatch.countDown();
    });

    Thread threadAnotherPutInto = new Thread(() -> {
        wallet.putInto(3000);
        System.out.println("돈 입금 쓰레드2 종료");
        countDownLatch.countDown();
    });

    threadPutInto.start();
    threadAnotherPutInto.start();

    countDownLatch.await();
    assertThat(wallet.getBill()).isEqualTo(3000);
}

음.. 예기치 못한 결과가 발생했네요..ㅠ 문제없이 순차적으로 진행되어 버렸습니다. 아무래도 너무 메서드 자체가 간단해서 순차적으로 실행된 것 같습니다. putInto 메서드에 어느정도 딜레이를 줘 보겠습니다.

 

public void putInto(long bill) {
    long current = this.bill;

    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    this.bill = current + bill;
}

 

다시 테스트를 돌려보니, 예상한 대로 동시성 문제가 발생하는 것을 볼 수 있습니다.

 

마치며

동시성 테스트를 작성할 때는 위와 같이 여러개의 쓰레드를 사용하고, CountdownLatch를 사용하면 원하는 대로 쉽게 테스트를 작성할 수 있습니다.

간단한 메소드에 대한 동시성 테스트는 생각보다 어렵지만, (메소드 실행 시간을 거의 동일하게 맞추어 주어도 순차적으로 실행되어 버립니다) 트랜잭션 단위의 메소드에 대한 동시성 테스트는 앞의 예제보다는 훨씬 잘 될 것이라고 생각합니다.

'Language > Java' 카테고리의 다른 글

Java의 동시성 제어  (0) 2022.11.10
Collection 얕게 알아보기 (1)  (0) 2022.11.08
Final 키워드만 써도 성능 향상이 된다고?  (2) 2022.10.31
JVM의 동작원리  (0) 2022.10.22
lambda 예외 핸들링  (0) 2022.10.02

+ Recent posts