1. 동시성 문제란?

멀티스레드를 사용할 때 가장 주의해야 할 점은 여러 스레드가 공유 자원에 동시에 접근하는 것으로 인해 발생하는 동시성 문제입니다.

대표적인 공유 자원은 인스턴스 필드(멤버 변수)입니다.
만약 여러 스레드가 동시에 공유 자원을 읽고 수정한다면, 데이터 불일치나 예상치 못한 동작이 발생할 수 있습니다.

2. 예제 코드

아래는 은행 계좌에서 출금을 처리하는 BankAccountV1BankAccountV2 클래스입니다.

(1) 동시성 문제 발생 코드: BankAccountV1

public class BankAccountV1 implements BankAccount {
    private int balance;

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

    @Override
    public boolean widthdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());
        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }
        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000); // 출금 처리 시간
        balance = balance - amount;
        log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        return balance;
    }
}

(2) 문제 발생 시나리오

아래처럼 두 개의 스레드(t1, t2)가 동시에 출금을 시도하면 문제가 발생할 수 있습니다.

public static void main(String[] args) throws InterruptedException {
    BankAccount 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());
}

(3) 문제 설명

  1. t1t2가 거의 동시에 balance를 확인합니다. (balance = 1000)
  2. t1은 800원을 출금하기 위해 검증을 통과합니다.
  3. t2도 같은 시점에서 800원을 출금할 수 있다고 판단합니다.
  4. t1이 출금을 완료하여 잔액이 200원이 됩니다.
  5. t2도 출금을 시도하여 잔액이 -600원이 되어버립니다. ❌(비정상 동작)

3. 해결 방법: synchronized 키워드 사용

멀티스레드 환경에서 한 번에 하나의 스레드만 실행하도록 하려면 임계 영역(critical section)을 보호해야 합니다.

Java에서는 synchronized 키워드를 사용하여 공유 자원에 대한 동기화를 적용할 수 있습니다.

(1) 동기화 적용 코드: BankAccountV2

public class BankAccountV2 implements BankAccount {
    private int balance;

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

    @Override
    public synchronized boolean widthdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());
        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }
        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000); // 출금 처리 시간
        balance = balance - amount;
        log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        return balance;
    }
}

(2) synchronized가 하는 일

  • synchronized 키워드를 메서드에 추가하면 한 번에 하나의 스레드만 해당 메서드를 실행할 수 있습니다.
  • 즉, widthdraw() 메서드가 실행 중이면 다른 스레드는 해당 메서드에 접근할 수 없습니다.
  • 덕분에 balance 값이 변경되는 과정이 안전하게 보호됩니다.

(3) 동기화 적용 후 결과

  • t1balance를 확인하고 출금을 시작하면, t2는 대기합니다.
  • t1이 출금을 완료한 후 t2가 실행되므로, t2는 출금 불가(잔액 부족) 상태를 올바르게 인식합니다.
  • 결과적으로 데이터 불일치 문제가 해결됩니다.

4. 정리

해결 방법동시성 문제 발생 여부
BankAccountV1 (동기화 X)✅ 발생 (출금 중간에 다른 스레드 개입 가능)
BankAccountV2 (synchronized 사용)❌ 해결 (한 번에 하나의 스레드만 실행)

멀티스레드 환경에서 공유 자원에 대한 동기화는 필수입니다. synchronized를 사용하면 임계 영역을 보호하여 데이터 불일치를 방지할 수 있습니다. 🚀

김영한님의 강의를 참고해서 만들었습니다.

profile
배움을 추구하는 개발자

0개의 댓글