본문 바로가기
Develop/Java

[Java] 동시성 이슈 해결하기 (1)

by 연로그 2022. 12. 9.
반응형

같은 계좌를 이용하는 A와 B라는 이용자가 있다고 가정한다. 동일한 시간에 A는 카드를 이용해 상품을 결제했고, B는 은행 어플을 통해 계좌이체를 했다. 대략의 플로우를 상상해보면 아래와 같다.

 

 

A와 B는 같은 시간에 잔액을 조회했다. A는 40,000원을 결제하여 계좌에 남은 잔액인 60,000원을 반영하였다. 같은 시각, B는 20,000원을 계좌 이체하였다. B의 처리 속도가 약간 늦어져 A가 계산된 금액 반영을 한 뒤에야 B의 계산된 금액을 반영했다. 한 계좌에서 각각 40,000원과 20,000원이 결제되었으니 잔액은 40,000원이 남아야한다. 하지만 현재 80,000원이 남은 상태임을 확인할 수 있다.

 

이는 계좌라는 같은 자원에 여러 사람이 동시에 접근하기 때문에 발생하는 문제이다. 다양한 해결방법이 있을 수 있겠지만 하나의 자원에 한 사람만 접근 가능하게 한다면 간단하게 해결된다. 어떻게 상호 배제를 할 수 있을까?? 코드를 작성하며 살펴보기 위해 테스트 코드를 작성해보았다. (테스트 코드에서 작성된 ExecutorService 클래스에 대해 알고 싶다면 이 포스팅을 참고 바란다.)

 

class AccountTest {

    private long account = 100_000L; // 계좌 잔고 100,000원

    @Test
    void pay() throws InterruptedException {
        // given
        ExecutorService executor = Executors.newFixedThreadPool(2);
        CountDownLatch countDownLatch = new CountDownLatch(2);

        // when
        executor.submit(() -> execute("A", 40_000L, countDownLatch)); // A가 40,000원 결제
        executor.submit(() -> execute("B", 20_000L, countDownLatch)); // B가 20,000원 결제
        countDownLatch.await();

        // then
        assertThat(account).isEqualTo(40_000L); // 60,000원을 사용했으므로 예상 잔액은 40,000원
    }

    private void execute(String username, long money, CountDownLatch countDownLatch) {
        try {
            pay(username, money);
        } finally {
            countDownLatch.countDown();
        }
    }

    private void pay(String username, long money) {
        long result = account - money;
        System.out.printf("%s가 %d원을 사용한 뒤 남은 금액은 %d 입니다.\n", username, money, result);
        account = result;
    }
}

테스트 실행 결과

 

 


1. synchronized ✨

 

위 코드에서 계산하는 메서드에 synchronized를 추가하는 방법이다.

private synchronized void pay(String username, long money) { ... }

테스트 결과


한 스레드가 synchronized 메서드를 호출하면 해당 메서드의 작업이 끝날때까지 다른 스레드에서는 synchronized 메서드를 호출하지 못한다. 추가로, 이미 생성된 객체의 접근을 제한하는 것이기 때문에 생성자에 synchronized를 붙이지는 못한다. (객체를 생성할여면 생성자를 이용해야하기 때문)

 

 

❓ synchronized의 상호 배제

해당 내용의 원본글은 oracle docs에서 확인하실 수 있습니다.

Java의 모든 인스턴스는 각각의 고유한 lock을 갖고 있다. 스레드가 synchronized 메서드를 실행하는 경우 자동으로 lock을 획득하고, 메서드가 반환될 때 lock을 해제하는 과정을 거친다. lock은 인스턴스 단위로 적용되며 한 스레드가 lock을 획득한 상태에서 다른 스레드가 해당 lock을 획득하는 것은 불가능하다. (synchronized는 static 메서드 등에도 적용할 수 있는데, 이 경우에는 인스턴스가 아닌 Class object에 적용된다.)

 

 

❓ synchronized와 성능

 하지만 모든 동시성을 제어하기 위해 synchronized를 모든 메서드에 다는 행위는 성능에 심각한 영향을 미칠 수 있다. synchronized의 상호 배제에서 설명했듯 lock을 획득하는 단위는 '인스턴스'이다. 한 인스턴스 내에 synchronized 메서드가 여러개가 있다고 가정해보겠다. 한 스레드에서 synchronized 메서드를 호출하고 있다면 다른 스레드들에서는 해당 인스턴스의 모든 synchronized 메서드를 호출하기 위해 모두 대기하는 상황이 오게 된다.

 

 아래 그림을 보면 Thread 1은 a(), Thread 2는 b(), Thread 3은 c() 메서드를 호출해야한다고 가정하자. 하지만 Thread 1이 lock을 획득한 순간 Thread 2, Thread 3, ...는 모두 lock이 해제될 때까지 기다려야 한다.

 

 


2. Atomic, Concurrent ✨

 

long이었던 account를 AtomicLong으로 사용하면 된다. Collection의 경우 ConcurrentHashMap 같은 클래스들이 제공된다.

class AccountTest {

    private final AtomicLong account = new AtomicLong(100_000L);

    // ...
    
    private void pay(String username, long money) {
        long result = account.updateAndGet((value) -> value - money);
        System.out.printf("%s가 %d원을 사용한 뒤 남은 금액은 %d 입니다.\n", username, money, result);
    }
}

테스트 결과

 

❓ Atomic의 상호 배제

Atomic과 Concurrent 클래스들은 상호 배제를 위해 CAS 알고리즘volatile 키워드를 이용해 구현되었다.

 

CAS는 Compare-And-Swap의 줄임말로 말 그대로 비교하고 변경한다. 어떤 값을 변경하려고 한다고 가정해보자. 내가 갖고 있는 값과 메모리에 위치한 값이 일치하는지 비교한다. 만약 두 값이 일치한다면 내가 변경을 원하는 값으로 변경한다. 이전 문장에서 '내가 갖고 있는 값'이 뭘 의미하는지 모르겠다면 아래 그림을 다시 떠올려보면 좋을 것 같다. A와 B는 동시에 잔액을 조회해서 갖고 있다. 각자 계산을 하고 계좌 잔액을 반영한다. B가 계좌 이체를 할 때의 상황을 생각해보면 B가 갖고 있던 계좌 금액(=내가 갖고 있는 값)과 계좌의 실제 잔액(=메모리에 위치한 값)이 다르다. CAS 알고리즘을 적용했다면 현재 내가 가진 계좌 잔액이 실제 계좌 잔액과 동일한지 검증하는 로직이 들어가게 되었을 것이다.

 

 

volatile은 값을 캐싱하지 않고 메인 메모리에서 직접 가져올 수 있게 해준다. (자세한 설명은 이 포스팅을 참고 바란다.)

 

[Java] volatile 키워드 (feat. Atomic & Concurrent)

😵 서론 멀티 스레드로 인한 동시성 문제를 해결하기 위해 Java는 synchronized 키워드와 Atomic 타입이 있다고 알려져있다. 그런데 Atomic 타입은 왜 Thread-safe하다고 할까? 이 이유에 대해서 찾다가 volat

yeonyeon.tistory.com

 

 

❓ synchronized vs Atomic 성능 차이

위의 테스트 코드를 기준으로 스레드의 개수를 늘리며 테스트해보았다. 테스트할 때마다 속도가 조금씩 달라지지만 Atomic이 더 빠르고 스레드가 많으면 많을수록 편차는 더 커졌다.

스레드 개수 synchronized Atomic
10개 16 15
10,000개 3886 2735

 

 


문제점 🤔

 

하지만 여전히 문제점은 남아있다. 서버의 인스턴스가 여러개라면? 위 방법은 서버가 단일 인스턴스로 동작한다는 가정 하에만 적용할 수 있는 방법이다. 이후 글에서는 다른 방법으로 동시성 이슈를 해결하는 방법에 대해 포스팅 하겠다.

 


참고

반응형