(Java) 멀티스레드 동기화와 동시성 문제 #1

BaekGwa·2024년 8월 16일
0

✔️ Java

목록 보기
3/12

동시성 문제에 대해서 다루지만, DB의 Locking 전략등을 다루지 않고, 순수한 Java 코드로 동시성 문제를 해결하는 내용입니다.

동기화와 동시성 문제

  • 이전에는 가시성 문제에 대해서 알아보며, volatile을 사용해 회피하는 방법에 대해서 알아 보았다.

동시성 문제란?

여러 스레드 혹은 프로세스가 동시에 같은 자원에 접근하거나 수정할려고 할 때, 예상치 못한 결과를 발생하는 문제를 말합니다.

  • 동시성 문제에서 가장 유명한 예제는 은행 출금 예제 일 것이다.
  • 이 예시를 코드로 작성하고, 문제를 회피하는 동기화에 대해서 알아 보도록 한다!

은행 출금 예제 구현

  • 요구사항
    • 하나의 계좌에 두개의 출금 요청이 접수 될 것.
    • 출금액보다 보유 금액이 적을 경우, 출금이 불가능 하다.
package bank;

public interface Account {
    boolean deposit(int amount);

    boolean withdraw(int amount);

    int getMoney();
}
package bank;

public class AccountImpl implements Account{

    private int money;

    public AccountImpl(int initMoney) {
        this.money = initMoney;
    }

    /**
     * 입금
     * @param amount
     */
    @Override
    public boolean deposit(int amount) {
        this.money = money + amount;
        return true;
    }

    /**
     * 출금
     * @param amount
     * @return
     */
    @Override
    public boolean withdraw(int amount) {
        System.out.println(Thread.currentThread().getName() + " : " + "withdraw() start");

        //검증 로직
        System.out.println(Thread.currentThread().getName() + " : " + "[검증] 검증 로직 실행");
        if(amount > this.money){
            System.out.println(Thread.currentThread().getName() + " : " + "[검증실패] 출금액 > 보유금액" + "현재 계좌:" + amount);
            return false;
        }
        System.out.println(Thread.currentThread().getName() + " : " + "[검증성공] 검증 성공!");

        //출금 로직
        System.out.println(Thread.currentThread().getName() + " : " + "[출금] 출금 실행");
        this.money = money - amount;
        System.out.println(Thread.currentThread().getName() + " : " + "[출금] 출금 완료, 현재 계좌:" + money);

        return true;
    }

    /**
     * 현재 가진 돈 return
     * @return
     */
    @Override
    public int getMoney() {
        return this.money;
    }
}
package bank;

public class main {

    public static void main(String[] args) {
        Account account = new AccountImpl(10000);

        WithdrawThread withdrawThread1 = new WithdrawThread(account);
        WithdrawThread withdrawThread2 = new WithdrawThread(account);

        Thread t1 = new Thread(withdrawThread1);
        Thread t2 = new Thread(withdrawThread2);

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

    private static class WithdrawThread implements Runnable {

        private final Account account;

        public WithdrawThread(Account account) {
            this.account = account;
        }

        @Override
        public void run() {
            //8000원 출금 진행.
            account.withdraw(8000);
        }
    }

}
  • 해당 코드는 10,000\이 있는 계좌에 8000\을 출금하는 스레드가 두개 동시에 실행되는 코드이다.
  • 정상적인 결과로는 둘중 먼저 실행되는 계좌에서만 출금이 진행되고 잔액이 2000원이 되며, 두번째 출금은 정상 처리 되지 않고 거래 실패가 되어야 한다.

결과는?

두 출금 로직 모두 완료가 되고, 현재 계좌는 마이너스 계좌가 되어버렸다...
왜 이런 결과가 나온 것일까?

문제 현상 분석

  • 문제는 두개의 스레드가 동시에 검증 로직을 실행 하였고, 동시에 같은 값을 읽어왔더니, 10000\이 있어서 출금이 가능한 상태로 검증 로직을 통과한 것이다.
  • 결국, 이 두개의 로직은 동시에 실행되어서는 안된다는 것이다.

volatile로 회피는 안될까?

  • 메모리 가시성 문제 해결을 위해 volatile을 사용 하여 회피한다.
  • 이 문제는 메모리 가시성 문제또한 존재하지만, 위의 이슈는 동시성 문제로 인해 발생한 것이다.
  • 따라서, volatile을 적용하여도 해당 문제는 해결되지 않는다.

동시성 문제 해결법

synchronized (메서드) 동기화 사용

  • 가장 쉽고 간단한 방법이다.
  • 동시성 문제가 발생할 메서드에 synchronized 키워드를 사용하는 것이다.
  • 현재 코드에서는 withdraw, deposit, getMoney 부분이 문제가 발생할 것이다. 따라서 다음과 같이 수정한다.
package bank;

public class AccountImplV2 implements Account{

    private int money;

    public AccountImplV2(int initMoney) {
        this.money = initMoney;
    }

    /**
     * 입금
     * @param amount
     */
    @Override
    public synchronized boolean deposit(int amount) {
        this.money = money + amount;
        return true;
    }

    /**
     * 출금
     * @param amount
     * @return
     */
    @Override
    public synchronized boolean withdraw(int amount) {
        System.out.println(Thread.currentThread().getName() + " : " + "withdraw() start");

        //검증 로직
        System.out.println(Thread.currentThread().getName() + " : " + "[검증] 검증 로직 실행");
        if(amount > this.money){
            System.out.println(Thread.currentThread().getName() + " : " + "[검증실패] 출금액 > 보유금액" + "현재 계좌:" + amount);
            return false;
        }
        System.out.println(Thread.currentThread().getName() + " : " + "[검증성공] 검증 성공!");

        //출금 로직
        System.out.println(Thread.currentThread().getName() + " : " + "[출금] 출금 실행");
        this.money = money - amount;
        System.out.println(Thread.currentThread().getName() + " : " + "[출금] 출금 완료, 현재 계좌:" + money);

        return true;
    }

    /**
     * 현재 가진 돈 return
     * @return
     */
    @Override
    public synchronized int getMoney() {
        return this.money;
    }
}

  • 정상적으로, 원하는 두번째 출금은 이뤄지지 않는 모습을 확인 할 수 있다.
  • 그렇다면, synchronized는 어떻게 동작하는 것일까?

synchronized의 내부 동작과 monitor lock

  • 모든 객체(인스턴스)는 내부에 자신만의 락(lock)을 가지고 있다.
  • 이 락은, 열쇠와도 같은 역할을 한다. 메서드에 접근 할 수 있는 열쇠.
  • synchronized 키워드가 있는 메서드는, 이 모니터 락이 있어야만, 메서드에 진입이 가능하고, 없을 경우에는 접근이 불가능하며, stateblock 상태로 변경한다.
  • 그림으로 알아보면 이런 느낌이다.


Thread-0 먼저 접근한다고 가정을 진행하면, 다음과 같은 순서로 이뤄진다.

Step1) Thread-0가 실행되면서, run() 메서드의 Account의 withdraw()를 실행시키기 위해 Account의 모니터 락 획득. 결과=성공

Step2) 동시에 Thread-1가 실행되면서, 같은 작업을 반복하여 Account 모니터락 획득 시도. 결과 = 실패

Step3) Thread-0의 synchronized 키워드 매서드가 완료되어 Lock 반납. Thread-1이 Lock 획득을 시도. 결과 = 성공

  • 가장 중요하게 생각할 포인트는, 멀티 스레드 환경에서, 동시에 접근해서 처리가 되면 안될 부분에, synchronized를 통해 동기화를 시켜줘야 하는 점이다.

문제점? 딴지 걸어 보기.

  • 만약, 대부분의 thread 실행 로직이 동시성 이슈가 발생 할 것 같아, synchronized 키워드를 도배했다고 생각하자.
  • 그럼 대부분의 로직이 병렬적으로 작업되지 않고, 직렬적으로 실행 될 것이다.
  • 의문점이 하나 들 것이다. 멀티스레드를 쓰는 의미가 없는데?
  • 따라서, 이 문제를 해결하기 위해 다음에는 아래의 내용을 다뤄볼 생각이다.
    사실 대부분의 로직이 동기화가 필요하다면, 아래의 내용을 써도 큰 이점이 없긴하다.

1. 임계 영역
2. synchronized (코드 블럭)
3. Lock (ReentrantLock)

profile
현재 블로그 이전 중입니다. https://blog.baekgwa.site/

0개의 댓글