728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

멀티 스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원이라고 한다. 대표적인 공유 자원은 인스턴스의 필드(멤버 변수)이다.

 

멀티 스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화해서 동시성 문제가 발생하지 않게 방지하는 것이 중요하다.

동시성 문제가 어떤 것인지 이해하기 위해 간단한 은행 출금 예제를 하나 만들어보자.

 

BankAccount

package thread.sync;

public interface BankAccount {
    boolean withdraw(int amount);

    int getBalance();
}
  • BankAccount 인터페이스이다. 앞으로 이 인터페이스의 구현체를 점진적으로 발전시키면서 문제를 해결할 예정이다.
  • withdraw(amount): 계좌의 돈을 출금한다. 출금할 금액을 매개변수로 받는다.
    • 계좌의 잔액이 출금할 금액보다 많다면 출금에 성공하고, true를 반환한다.
    • 계좌의 잔액이 출금할 금액보다 적다면 출금에 실패하고, false를 반환한다.
  • getBalance(): 계좌의 잔액을 반환한다.

BankAccountV1

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV1 implements BankAccount {

    private int balance;

    public BankAccountV1(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }

        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000);
        balance -= amount;
        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

        log("거래 종료:");
        return true;
    }

    @Override
    public int getBalance() {
        return balance;
    }
}
  • BankAccountV1BankAccount 인터페이스를 구현한다.
  • 생성자를 통해 계좌의 초기 잔액을 저장한다.
  • int balance: 계좌의 잔액 필드
  • withdraw(amount): 검증과 출금 2가지 단계로 나누어진다.
    • 검증 단계: 출금액과 잔액을 비교한다. 만약 출금액이 잔액보다 많다면 문제가 있으므로 검증에 실패하고 false를 반환한다.
    • 출금 단계: 검증에 통과하면 잔액이 출금액보다 많으므로 출금할 수 있다. 잔액에서 출금액을 빼고 출금을 완료하면 성공이라는 의미의 true를 반환한다.
  • getBalance(): 잔액을 반환한다.

WithdrawTask

package thread.sync;

public class WithdrawTask implements Runnable {

    private BankAccount account;
    private int amount;

    public WithdrawTask(BankAccount account, int amount) {
        this.account = account;
        this.amount = amount;
    }

    @Override
    public void run() {
        account.withdraw(amount);
    }
}
  • 츌금을 담당하는 Runnable 구현체이다. 생성 시 출금할 계좌(account)와 출금할 금액(amount)를 저장해둔다.
  • run()을 통해 스레드가 출금을 실행한다.

 

BankMain

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV1 account = new BankAccountV1(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

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

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

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

        log("최종 잔액: " + account.getBalance());
    }
}
  • new BankAccountV1(1000)을 통해 초기 잔액을 1000원으로 설정한다.
  • main 스레드는 t1, t2 스레드를 만든다. 만든 스레드들은 같은 계좌에 각각 800원의 출금을 시도한다.
  • main 스레드는 join()을 사용해서 t1, t2 스레드가 출금을 완료한 이후에 최종 잔액을 확인한다.

  • 각각의 스레드의 스택에서 run()이 실행된다.
  • t1 스레드는 WithdrawTask(x002) 인스턴스의 run()을 호출한다.
  • t2 스레드는 WithdrawTask(x003) 인스턴스의 run()을 호출한다.
  • 스택 프레임의 this에는 호출한 메서드의 인스턴스 참조가 들어있다. (쉽게 말해 각각의 스레드에 스택 하나씩 부여받는데 그 스레드들은 모두 run()을 호출한다. 그 run()의 인스턴스(WithdrawTask)를 참조로 가지고 있단 소리다.)
  • 두 스레드는 같은 계좌(x001)에 대해서 출금을 시도한다. 

  • t1 스레드의 run()에서 withdraw()를 실행한다.
  • 거의 동시에 t2 스레드의 run()에서 withdraw()를 실행한다.
  • t1, t2 스레드는 같은 BankAccount(x001) 인스턴스의 withdraw() 메서드를 호출한다.
  • 따라서 두 스레드는 같은 BankAccount(x001) 인스턴스에 접근하고 또 x001 인스턴스에 있는 잔액(balance)필드도 함께 사용한다.
  • 마찬가지로 호출한 메서드(withdraw())의 인스턴스(BankAccount(x001))를 this로 참조하고 있다.

실행결과

2024-07-19 22:56:47.129 [       T2] 거래 시작: BankAccountV1
2024-07-19 22:56:47.129 [       T1] 거래 시작: BankAccountV1
2024-07-19 22:56:47.144 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.144 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.605 [     main] t1 state: TIMED_WAITING
2024-07-19 22:56:47.605 [     main] t2 state: TIMED_WAITING
2024-07-19 22:56:48.147 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-19 22:56:48.147 [       T1] [출금 완료] 출금액: 800, 잔액: -600
2024-07-19 22:56:48.148 [       T2] 거래 종료:
2024-07-19 22:56:48.149 [       T1] 거래 종료:
2024-07-19 22:56:48.156 [     main] 최종 잔액: -600

 

동시성 문제

이 시나리오는 악의적인 사용자가 2대의 PC에서 동시에 같은 계좌의 돈을 출금한다고 가정한다.

  • t1, t2 스레드는 거의 동시에 실행되지만, 아주 약간의 차이로 t1 스레드가 먼저 실행되고, t2 스레드가 그 다음에 실행된다고 가정하겠다.
  • 처음 계좌의 잔액은 1000원이다. t1 스레드가 800원을 출금하면 잔액은 200원이 남는다.
  • 이제 계좌의 잔액은 200원이다. t2 스레드가 800원을 출금하면 잔액보다 더 많은 돈을 출금하게 되므로 출금에 실패해야 한다.

그런데 실행 결과를 보면 기대와는 다르게 t1, t2 각각 800원씩 총 1600원 출금에 성공한다. 계좌의 잔액은 -600원이 되어있고, 계좌는 예상치 못하게 마이너스 금액이 되어버렸다. 악의적인 사용자는 2대의 PC를 통해 자신의 계좌에 있는 1000원보다 더 많은 금액인 1600원 출금에 성공한다. 분명히 계좌를 출금할 때 잔고를 체크하는 로직이 있는데도 불구하고 왜 이런 문제가 발생했을까?

참고로, balance 값에 volatile을 도입하면 문제가 해결되지 않을까? 하겠지만 그렇지 않다. volatile은 한 스레드가 값을 변경했을 때 다른 스레드에서 변경된 값을 즉시 볼 수 있게 하는 메모리 가시성의 문제를 해결할 뿐이다. 예를 들어, t1 스레드가 balance의 값을 변경했을 때 t2 스레드에서 balance의 변경된 값을 즉시 확인해도 여전히 같은 문제가 발생한다. 이미 t2가 실행할 검증 로직은 지나갔으므로. 물론, 위 예제는 메모리 가시성 문제도 있지만 그것을 해결한다고 해서 동시성 문제가 해결되는것이 아니다. 

 

그럼 이 동시성 문제는 어떤 흐름으로 발생했을까? 이 문제를 어떻게 해결할까를 알기 전에 어떻게 이런 일이 일어났는지를 먼저 확인해보자.

 

위 동시성 문제의 흐름

심지어 이 예제에서 두가지 케이스의 문제가 있다.

 

t1 → t2 순서로 실행된 케이스

t1 → t2 순서로 실행됐다고 가정한다. 즉, t1이 아주 약간 빠르게 실행되는 경우를 먼저 알아보자.

  • t1이 약간 먼저 실행되면서, 출금을 시도한다.
  • t1이 출금 코드에 있는 검증 로직을 실행한다. 이때 잔액이 출금 액수보다 많은지 확인한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 검증 로직을 통과한다. 

  • t1: 출금 검증 로직을 통과해서 출금을 위해 잠시 대기중이다. 출금에 걸리는 시간이나 실제 출금하기까지 필요한 작업들을 처리하는 단계라고 생각하자.
  • t2: 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 통과한다.

바로 이 부분이 문제다! t1이 아직 잔액(balance)을 줄이지 못했기 때문에 t2는 검증 로직에서 현재 잔액을 1000원으로 확인한다. 

t1이 검증 로직을 통과하고 바로 잔액을 줄였다면 이런 문제가 발생하지 않겠지만, t1이 검증 로직을 통과하고 잔액을 줄이기도 전에 먼저 t2가 검증 로직을 확인한 것이다. 

 

"어? 그럼 sleep(1000)을 빼버리면 되지 않나요?"

→ t1이 검증 로직을 통과하고 balance = balance - amount를 계산하기 직전에 t2가 실행되면서 검증 로직을 통과 할수도 있다. sleep(1000)은 동시성 문제를 해결하는데 아무런 도움도 되지 않는다.

 

  • 결과적으로 t1, t2 모두 검증 로직을 통과하고, 출금을 위해 잠시 대기중이다. 출금에 걸리는 시간으로 생각하자.

  • t1800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원만큼 차감한다. 이제 계좌의 잔액은 200원이다.

  • t2800원을 출금하면서, 잔액 200원에서 출금 액수인 800원만큼 차감한다. 이제 잔액은 -600원이 된다.

결과적으로,

  • t1: 800원 출금 완료
  • t2: 800원 출금 완료
  • 처음 원금은 1000원이었는데, 최종 잔액은 -600원이 된다.
  • 은행 입장에서 마이너스 잔액이 있으면 안된다!

실행 결과

2024-07-19 22:56:47.129 [       T2] 거래 시작: BankAccountV1
2024-07-19 22:56:47.129 [       T1] 거래 시작: BankAccountV1
2024-07-19 22:56:47.144 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.144 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.145 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-19 22:56:47.605 [     main] t1 state: TIMED_WAITING
2024-07-19 22:56:47.605 [     main] t2 state: TIMED_WAITING
2024-07-19 22:56:48.147 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-19 22:56:48.147 [       T1] [출금 완료] 출금액: 800, 잔액: -600
2024-07-19 22:56:48.148 [       T2] 거래 종료:
2024-07-19 22:56:48.149 [       T1] 거래 종료:
2024-07-19 22:56:48.156 [     main] 최종 잔액: -600

 

 

t1 ↔  t2 동시에 실행된 케이스

t1, t2가 완전히 동시에 실행되는 상황도 있다.

  • t1, t2는 동시에 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 둘 다 통과한다.

  • 결과적으로, t1, t2 모두 검증 로직을 통과하고, 출금을 위해 잠시 대기중이다. 출금에 걸리는 시간으로 생각하자.

  • t1800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원 만큼 차감한다. 이제 잔액은 200원이 된다.
  • t2800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원 만큼 차감한다. 이제 잔액은 200원이 된다.
  • t1, t2 모두 동시에 실행되기 때문에 둘 다 잔액(balance)를 확인하는 시점에 잔액은 1000원이다.
  • t1, t2 모두 동시에 계산된 결과를 잔액에 반영하는데, 둘 다 계산 결과인 200원을 balance에 반영하므로 최종 잔액이 200원이 된다.
balance = balance - amount;

이 한줄의 코드도 사실 3단계로 나뉘어진다.

  • 계산을 위해 오른쪽에 있는 balance 값과 amount 값을 조회한다.
  • 두 값을 계산한다.
  • 계산 결과를 왼쪽의 balance 변수에 저장한다

여기서, 1번 단계의 balance 값을 조회할 때 t1, t2 두 스레드가 동시에 x001.balance의 필드값을 읽는다. 이때 값은 1000이다. 따라서 두 스레드는 모두 잔액을 1000원으로 인식한다. 2번 단계에서 두 스레드 모두 1000 - 800을 계산해서 200이라는 결과를 얻는다. 3번 단계에서 두 스레드 모두 balance = 200을 대입한다.

 

결과적으로,

  • t1: 800원 출금 완료
  • t2: 800원 출금 완료
  • 원래 원금이 1000원이었는데, 최종 잔액은 200원이 된다.
  • 은행 입장에서 보면 1600원이 빠져나갔는데, 잔액은 800원만 줄었다. 800원이 감쪽같이 어디론가 사라진 것이다.

실행결과

2024-07-21 15:33:21.635 [       T1] 거래 시작: BankAccountV1
2024-07-21 15:33:21.635 [       T2] 거래 시작: BankAccountV1
2024-07-21 15:33:21.649 [       T1] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 15:33:21.649 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 15:33:21.650 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 15:33:21.650 [       T1] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 15:33:22.105 [     main] t1 state: TIMED_WAITING
2024-07-21 15:33:22.105 [     main] t2 state: TIMED_WAITING
2024-07-21 15:33:22.652 [       T1] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 15:33:22.652 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 15:33:22.654 [       T1] 거래 종료:
2024-07-21 15:33:22.654 [       T2] 거래 종료:
2024-07-21 15:33:22.661 [     main] 최종 잔액: 200

실행 결과에서 시간이 완전히 동일하다는 사실을 통해 두 스레드가 같이 실행된 것을 대략 확인할 수 있다.

결국 이런 흐름으로 동시성 문제가 발생했다. 이 문제가 그럼 왜 발생했고 어떻게 해결할 수 있을지 알아보자!

 

임계 영역

이런 문제가 발생한 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어 사용하기 때문이다.

  • 1. 검증 단계: 잔액(balance)이 출금액(amount)보다 많은지 확인하는 단계
  • 2. 출금 단계: 잔액(balance)을 출금액(amount)만큼 줄이는 단계
출금() {
    1. 검증 단계
    2. 출금 단계
}

 

이 로직에는 하나의 큰 가정이 있다.

스레드 하나의 관점에서 출금()을 보면, 1. 검증 단계에서 확인한 잔액(balance) 1000원은 2. 출금 단계에서 계산을 끝마칠 때 까지 같은 1000원으로 유지되어야 한다. 그래야 검증 단계에서 확인한 금액으로, 출금 단계에서 정확한 잔액을 계산을 할 수 있다. 결국 여기서는 내가 사용하는 값이 중간에 변경되지 않을 것이라는 가정이 있다.

 

그런데, 만약 중간에 다른 스레드가 잔액의 값을 변경한다면? 큰 혼란이 발생한다. 1000원이라 생각한 잔액이 다른 값으로 변경되면 잔액이 전혀 다른 값으로 계산될 수 있다.

 

공유 자원

잔액(balance)은 여러 스레드가 함께 사용하는 공유 자원이다. 따라서 출금 로직을 수행하는 중간에 다른 스레드에서 이 값을 얼마든지 변경할 수 있다. 참고로 여기서는 출금() 메서드를 호출할 때만 잔액(balance)의 값이 변경된다. 따라서 다른 스레드가 출금 메서드를 호출하면서, 사용중인 balance 값을 중간에 변경해 버릴 수 있다. 

 

한 번에 하나의 스레드만 실행

만약, 출금() 이라는 메서드를 한 번에 하나의 스레드만 실행할 수 있게 제한한다면 어떻게 될까?

예를 들어, t1, t2 스레드가 함께 출금()을 호출하면 t1 스레드가 먼저 처음부터 끝까지 출금() 메서드를 완료하고, 그 다음에 t2 스레드가 처음부터 끝까지 출금() 메서드를 완료하는 것이다. 이렇게 하면 공유 자원인 balance를 한 번에 하나의 스레드만 변경할 수 있다. 따라서 계산 중간에 다른 스레드가 balance의 값을 변경하는 부분을 걱정하지 않아도 된다. (이 예제에선 출금() 메서드를 호출할때만 잔액(balance)의 값이 변경되므로)

  • 더 자세히는 출금을 진행할 때 잔액(balance)을 검증하는 단계부터 잔액의 계산을 완료할 때 까지 잔액의 값은 중간에 변하면 안된다.
  • 검증계산 두 단계는 한 번에 하나의 스레드만 실행해야 한다. 그래야 잔액(balance)이 중간에 변하지 않고, 안전하게 계산을 수행할 수 있다.

 

임계 영역 (critical section)

영어로 크리티컬 섹션이라고 한다.

  • 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 뜻한다.
  • 여러 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하거나 수정하는 부분을 의미한다. 
    • 예) 공유 변수나 공유 객체를 수정

앞서, 살펴본 출금() 로직이 바로 임계 영역이다.

더 자세히는 출금을 진행할 때 잔액(balance)을 검증하는 단계부터 잔액의 계산을 완료할 때 까지가 임계 영역이다. 여기서 balance는 여러 스레드가 동시에 접근해서는 안되는 공유 자원이다.

 

이런 임계 영역은 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 한다. 그럼 어떻게 한 번에 하나의 스레드만 접근할 수 있도록 임계 영역을 안전하게 보호할 수 있을까? 여러가지 방법이 있는데 자바는 synchronized 키워드를 통해 아주 간단하게 임계 영역을 보호할 수 있다.

 

Synchorinzed

자바의 synchronized 키워드를 사용하면 한 번에 하나의 스레드만 실행할 수 있는 코드 구간을 만들 수 있다.

BankAccountV1을 복사해서 BankAccountV2 클래스를 만들고 synchronized를 도입해보자.

 

synchronized 메서드

 

BankAccountV2

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV2 implements BankAccount {

    private int balance;

    public BankAccountV2(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public synchronized boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }

        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000);
        balance -= amount;
        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

        log("거래 종료:");
        return true;
    }

    @Override
    public synchronized int getBalance() {
        return balance;
    }
}
  • BankAccountV1과 같은데, withdraw(), getBalance() 코드에 synchronized 키워드가 추가되었다.
  • 이제 withdraw(), getBalance() 메서드는 한 번에 하나의 스레드만 실행할 수 있다.

BankMain

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV2 account = new BankAccountV2(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

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

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

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

        log("최종 잔액: " + account.getBalance());
    }
}
  • BankMain에서는 BankAccountV2를 사용하도록 코드를 변경했다.

실행결과

2024-07-21 16:24:03.274 [       T2] 거래 시작: BankAccountV2
2024-07-21 16:24:03.286 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 16:24:03.286 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 16:24:03.751 [     main] t1 state: BLOCKED
2024-07-21 16:24:03.751 [     main] t2 state: TIMED_WAITING
2024-07-21 16:24:04.289 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 16:24:04.290 [       T2] 거래 종료:
2024-07-21 16:24:04.291 [       T1] 거래 시작: BankAccountV2
2024-07-21 16:24:04.292 [       T1] [검증 시작] 출금액: 800, 잔액: 200
2024-07-21 16:24:04.293 [       T1] [검증 실패] 출금액: 800, 잔액: 200
2024-07-21 16:24:04.300 [     main] 최종 잔액: 200

 

실행 결과를 보면 t2withdraw() 메서드를 시작부터 완료까지 모두 끝내고 나서 그 다음에 t1withdraw() 메서드를 수행한 것을 확인할 수 있다. 물론 환경에 따라 t1이 먼저 할수도 있다. 

 

synchronized 분석

지금부터 자바의 synchronized가 어떻게 작동하는지 그림으로 분석해보자. 

참고로 실행 결과를 보면 t1BLOCKED 상태인데 이 상태도 확인해보자. 

 

여기서부턴 t1이 먼저 실행됐다고 가정한다.

 

  • 스레드 t1이 먼저 synchronized 키워드가 있는 withdraw() 메서드를 호출한다.
  • synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
    • 모든 인스턴스는 인스턴스마다 락이 있다. 이건 자바의 표준 규칙이다.
    • 같은 클래스의 서로 다른 인스턴스도 다 인스턴스별로 락이 있다.
  • 락이 있으므로 스레드 t1BankAccount(x001) 인스턴스에 있는 락을 획득한다.

  • 스레드 t1은 해당 인스턴스의 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
  • 스레드 t2withdraw() 메서드 호출을 시도한다. synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
  • 스레드 t2BankAccount(x001) 인스턴스에 있는 락 획득을 시도한다. 하지만 락이 없다. 이렇게 락이 없으면 t2 스레드는 락을 획득할 때 까지 BLOCKED 상태로 대기한다.
    • t2 스레드의 상태는 RUNNABLE → BLOCKED 상태로 변하고, 락을 획득할 때 까지 무한정 대기한다.
    • 참고로, BLOCKED 상태가 되면 락을 다시 획득하기 전까지는 계속 대기하고, CPU 실행 스케쥴링에 들어가지 않는다

  • t1: 출금을 위한 검증 로직을 수행한다. 조건을 만족하므로 검증 로직을 통과한다.
    • 잔액(1000)이 출금액(800)보다 많으므로 통과한다.

  • t1: 잔액 1000원에서 800원을 출금하고 계산 결과인 200원을 잔액(balance)에 반영한다.

  • t1: 메서드 호출이 끝나면 락을 반납한다.

  • t2: 인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득한다.
    • 이때 락을 획득한 스레드는 BLOCKED → RUNNABLE 상태가 되고, 다시 코드를 실행한다.

  • 스레드 t2는 해당 인스턴스의 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
  • t2: 출금을 위한 검증 로직을 수행한다. 조건을 만족하지 않으므로 false를 반환한다.
    • 이때 잔액(balance)은 200원이다. 800원을 출금해야 하므로 조건을 만족하지 않는다.

  • t2: 락을 반납하면서 return한다.

결과

  • t1: 800원 출금 완료
  • t2: 잔액 부족으로 출금 실패
  • 원금 1000원, 최종 잔액 200

t1800원 출금에 성공하지만, t2는 잔액 부족으로 출금에 실패한다. 그리고 최종 잔액은 1000원에서 200원이 되므로 정확하게 맞다. 이렇게 자바의 synchronized를 사용하면 한 번에 하나의 스레드만 실행하는 안전한 임계 영역 구간을 편리하게 만들 수 있다.

 

참고로, 락을 획득하는 순서는 보장되지 않는다. 위 실행결과에서도 봤듯 t2가 먼저 실행됐고 t1BLOCKED 상태로 변경됐다면 이 그림에서는 그 반대였다. 즉, BankAccount(x001) 인스턴스의 withdraw()를 수많은 스레드가 동시에 호출한다면, 1개의 스레드만 락을 획득하고 나머지 모두 BLOCKED 상태가 된다. 그리고 이후에 BankAccount(x001) 인스턴스에 락을 반납하면, 해당 인스턴스의 락을 기다리는 수많은 스레드 중에 하나의 스레드만 락을 획득하고, 락을 획득한 스레드만 BLOCKEDRUNNABLE 상태가 된다. 이때, 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어 있지 않다. 따라서 순서를 보장하지 않고 환경에 따라 달라질 수 있다.

 

참고로, volatile을 사용하지 않아도, synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결된다. happens-before를 생각해보자.

 

 

synchronized 코드 블럭

synchronized의 가장 큰 장점이자 단점은 한 번에 하나의 스레드만 실행할 수 있다는 점이다. 여러 스레드가 동시에 실행하지 못하기 때문에, 전체로 보면 성능이 떨어질 수 있다. 따라서 synchronized를 통해 여러 스레드를 동시에 실행할 수 없는 코드 구간은 꼭! 필요한 곳으로 한정해서 설정해야 한다. 

 

다시 말해, 메서드 전체로 synchronized를 걸어버리면 그 메서드 안에서 굳이 임계 영역이 아닌 부분까지도 한 스레드만 접근이 가능하니까 성능 저하가 생길 수 있다는 얘기다. 이전에 작성한 코드를 보자.

@Override
public synchronized boolean withdraw(int amount) {
    log("거래 시작: " + getClass().getSimpleName());

    log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
    if (balance < amount) {
        log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
        return false;
    }

    log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
    sleep(1000);
    balance -= amount;
    log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

    log("거래 종료:");
    return true;
}

처음에 로그를 출력하는 "거래 시작" 부분과 마지막에 로그를 출력하는 "거래 종료" 부분은 공유 자원을 전혀 사용하지 않는다. 이런 부분은 동시에 실행해도 아무 문제가 발생하지 않는다.

 

따라서 이 메서드에서 임계 영역은 메서드 전체가 아니라 딱 아래 (--)로 표시한 부분이다.

@Override
public synchronized boolean withdraw(int amount) {
    log("거래 시작: " + getClass().getSimpleName());

    --------------------------------------------------------------------
    log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
    if (balance < amount) {
        log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
        return false;
    }

    log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
    sleep(1000);
    balance -= amount;
    log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
    --------------------------------------------------------------------

    log("거래 종료:");
    return true;
}

 

근데, 메서드 전체에 synchronized를 걸어버리면 굳이 임계 영역이 아닌 부분까지도 한 스레드만 접근이 가능하니까 상대적으로 당연히 성능 저하가 생길 수 밖에 없다. 이 문제를 자바는 synchronized를 메서드 단위가 아니라 특정 코드 블록에 최적화해서 적용할 수 있게 제공한다. 다음 BankAccountV3를 보자.

 

BankAccountV3

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV3 implements BankAccount {

    private int balance;

    public BankAccountV3(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        synchronized (this) {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        }

        log("거래 종료:");
        return true;
    }

    @Override
    public synchronized int getBalance() {
        return balance;
    }
}
  • withdraw() 메서드 앞에 사용했던 synchronized를 제거했다.
  • synchronized (this) {...} : 안전한 임계 영역을 코드 블록으로 지정한다.
    • 이렇게 하면 꼭 필요한 코드만 안전한 임계 영역으로 만들 수 있다. 
    • 여기서 괄호 () 안에 들어가는 값은 락을 획득할 인스턴스의 참조이다. 이 예제에서는 해당 인스턴스의 락이 필요하니까 this를 넣었다.
    • 이전 메서드 전체에 synchronized를 사용할 때도 마찬가지로 같은 인스턴스 락을 사용했다. 메서드 단위로는 `(this)`가 생략됐다고 생각하면 된다.
  • getBalance() 의 경우, return balance 한 줄이라서 메서드에 거나 코드 블록으로 거나 똑같다.

 

BankMain

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccountV3 account = new BankAccountV3(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "T1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "T2");

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

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

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

        log("최종 잔액: " + account.getBalance());
    }
}
  • BankMain에서 BankAccountV3를 사용하도록 코드를 변경했다.

실행결과

2024-07-21 16:58:41.150 [       T2] 거래 시작: BankAccountV3
2024-07-21 16:58:41.150 [       T1] 거래 시작: BankAccountV3
2024-07-21 16:58:41.163 [       T2] [검증 시작] 출금액: 800, 잔액: 1000
2024-07-21 16:58:41.164 [       T2] [검증 완료] 출금액: 800, 잔액: 1000
2024-07-21 16:58:41.625 [     main] t1 state: BLOCKED
2024-07-21 16:58:41.625 [     main] t2 state: TIMED_WAITING
2024-07-21 16:58:42.166 [       T2] [출금 완료] 출금액: 800, 잔액: 200
2024-07-21 16:58:42.167 [       T2] 거래 종료:
2024-07-21 16:58:42.167 [       T1] [검증 시작] 출금액: 800, 잔액: 200
2024-07-21 16:58:42.169 [       T1] [검증 실패] 출금액: 800, 잔액: 200
2024-07-21 16:58:42.177 [     main] 최종 잔액: 200

 

synchronized 블록 기능을 사용한 덕분에 딱 필요한 부분에 임계 영역을 지정할 수 있었다. 덕분에 아주 약간이지만 여러 스레드가 동시에 수행되는 부분을 더 늘려서, 전체적으로 성능을 더 향상할 수 있었다. 지금의 예는 단순 로그 몇 줄이지만, 실제 업무에서는 더 많은 코드가 존재할 거고 그럴땐 큰 성능 차이가 발생할 것이다.

 

이 예제에서는 처음 거래 시작 로그를 찍는 부분이 t1, t2 동시에 실행된 것을 확인할 수 있다.

2024-07-21 16:58:41.150 [       T2] 거래 시작: BankAccountV3
2024-07-21 16:58:41.150 [       T1] 거래 시작: BankAccountV3

 

 

synchronized 동기화 정리

자바에서 동기화는 여러 스레드가 동시에 접근할 수 있는 자원(예: 객체, 메서드)에 대해 일관성 있고 안전한 접근을 보장하기 위한 메커니즘이다. 동기화는 주로 멀티스레드 환경에서 발생할 수 있는 문제, 예를 들어 데이터 손상이나 예기치 않은 결과를 방지하기 위해 사용된다. 

 

메서드 동기화: 메서드를 synchronized로 선언해서, 메서드에 접근하는 스레드가 하나뿐이도록 보장한다. 이 과정에서 인스턴스의 락을 사용하게 된다. 모든 인스턴스는 다 자기의 락을 가지고 있고, synchronized 키워드가 붙은 곳에 접근하려면 이 락을 스레드는 획득해야 한다!

 

블록 동기화: 코드 블록을 synchronized로 감싸서 동기화를 구현한다. 위에서 설명한 것과 마찬가지로 인스턴스 락을 사용한다.

 

이런 동기화를 사용하면 다음 문제들을 해결할 수 있다.

  • 경합 조건(Race condition): 두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제
  • 데이터 일관성: 여러 스레드가 동시에 읽고 쓰는 데이터의 일관성을 유지

동기화는 멀티 스레드 환경에서 필수적인 기능이지만, 과도하게 사용할 경우 성능 저하를 초래할 수 있으므로 꼭 필요한 곳에 적절히 사용해야 한다! 

 

자바는, 처음부터 멀티스레드를 고려하고 나온 언어이다. 그래서 자바 1.0부터 synchronized 동기화 방법을 프로그래밍 언어의 문법에 포함해서 제공한다.

 

synchronized 장점

  • 프로그래밍 언어에 문법으로 제공
  • 아주 편리함
  • 자동 잠금 해제: synchronized 메서드나 블록이 완료되면, 자동으로 락을 대기중인 다른 스레드에게 전달하고 기다리고 있던 스레드의 BLOCKED 상태가 해제된다. 개발자가 직접 특정 스레드를 깨우도록 관리해야 한다면, 매우 어렵고 번거로울 것이다.

synchronized는 매우 편리하지만, 제공하는 기능이 너무 단순하다는 단점이 있다. 시간이 점점 지나면서 멀티 스레드가 더 중요해지고 점점 더 복잡한 동시성 개발 방법들이 필요해졌다.

 

synchronized 단점

  • 무한 대기: BLOCKED 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
    • 특정 시간까지만 대기하는 타임아웃 X
    • 중간에 인터럽트 X
  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다. 

synchronized의 가장 치명적인 단점은 락을 얻기 위해 BLOCKED 상태가 되면 락을 얻을 때까지 무한 대기한다는 점이다. 예를 들어 웹 애플리케이션의 경우 고객이 어떤 요청을 했는데, 화면에 계속 요청 중만 뜨고 응답을 못 받는 것이다. 차라리 너무 오랜 시간이 지나면 시스템에 사용자가 너무 많아서 다음에 다시 시도해달라고 하는 식의 응답을 주는 것이 더 나은 선택일 것이다.

 

결국 더 유연하고, 더 세밀한 제어가 가능한 방법들이 필요하게 됐다. 이런 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent 라는 동시성 문제 해결을 위한 패키지가 추가된다.

 

이제 synchronized 까지 배워봤다. 멀티 스레드의 기본적인 것들은 얼추 배웠다고 볼 수 있다. 이제 이 기본기를 가지고 더 좋은 더 여러 기능에 대해 하나씩 알아보자!

참고로, 단순하고 편리하게 사용하기에는 synchronized가 최고이므로, 목적에 부합만 한다면 사용하는데 아무런 문제는 없다!

 

 

 

728x90
반응형
LIST

+ Recent posts