본문 바로가기
인프런/김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성

[인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 6. 동기화 - synchronized

by hxxyeoniii 2025. 3. 13.

출금 예제

계좌에 잔액이 1000원 있고 두개의 스레드가 각각 800원을 호출하고자 했을 경우

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());
}

 

실행 결과

-> t2 스레드가 800원을 출금하면 잔액보다 많은 돈을 출금하게 되므로 출금에 실패해야 하지만, -200원이 계좌에 남아버리는 문제 발생

 

 

 

 

> t1 스레드는 WithdrawTask(x002)의 인스턴스인 run() 호출

> t2 스레드는 WithdrawTask(x003)의 인스턴스인 run() 호출

> 스택 프레임의 this에는 호출한 메서드의 인스턴스 참조가 들어있음

> 두 스레드는 같은 계좌(account)에 대해 출금 시도

 

 

 

 

> 두 스레드는 같은 BankAccount 인스턴스에 접근하고, 인스턴스에 있는 잔액 필드도 함께 사용

 

 

 

동시성 문제

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

-> balance에 volatile 도입?

volatile은 한 스레드가 값을 변경했을 때, 다른 스레드에서 변경된 값을 즉시 볼 수 있게 하는 메모리 가시성 문제를 해결할 뿐


동시성 문제

t1이 t2보다 아주 조금 먼저 실행되었다고 가정

 

> t1 : 출금 검증 로직을 통과해 출금을 위해 대기중

> t2 : 검증 로직(잔액이 출금 금액보다 많은지 확인) 실행, t1이 아직 출금 전이므로 통과하게 됨

 

=> t1이 아직 잔액을 줄이지 못한 상태에서 t2가 검증 로직을 수행하며 문제가 생기는 것!


임계 영역

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

 

1) 검증 단계 : 잔액 확인

2) 출금 단계 : 잔액 감소

 

위 로직에는 하나의 큰 가정이 있음 = 내가 사용하고 있는 값(잔액)이 중간에 변경되지 않을 것이라는 것

 

 

공유 자원

: 잔액은 여러 스레드가 함께 사용하는 공유 자원

: 위 예제에서는 출금 메서드 호출 시에만 잔액의 값이 변경됨

 

 

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

: 출금 메서드를 한 번에 하나의 스레드만 실행할 수 있게 제한한다면?

: t1 스레드가 처음부터 끝까지 출금 메서드를 완료하고, 다음에 t2 스레드가 출금 메서드를 완료하도록

 

-> 이렇게 하면 공유 자원인 잔액을 한 번에 하나의 스레드만 변경할 수 있다.

 

 

임계 영역(critical section)

: 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 뜻함

: 여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하거나 수정하는 부분 의미

 

-> 위에서 살펴본 출금 메서드가 바로 임계 영역

-> 더 자세히는, 잔액을 검증하는 단계부터 잔액의 계산을 완료할 때 까지가 임계 영역


synchronized 메서드

synchronized 키워드로 임계 영역을 보호할 수 있다.

 

기존 출금 함수 앞에 'synchronized' 추가

public synchronized boolean withdraw(int amount) {
    ...
}

-> 이렇게 수정할 경우, t1이 withdraw 메서드를 처음부터 끝까지 모두 끝내고 나서, t2가 withdraw 메서드를 수행함

 

 

 

synchronized 분석

"모든 객체(인스턴스)는 내부에 자신만의 락(lock)을 가지고 있다"

-> 모니터 락이라고도 부르며 객체 내부에 있어 우리가 확인하기는 어려움

 

> 스레드가 synchronized 키워드가 있는 메서드에 진입하려면 해당 인스턴스의 락이 있어야 한다.

> BankAccount(x001) 인스턴스의 synchronized withdraw()을 호출하므로 락이 필요하다.

 

 

 

 

> t1이 먼저 실행된다고 가정하면 t1이 먼저 BackAccount(x001) 인스턴스의 락을 획득한다.

 

 

 

 

> 이후 t2가 락 획득을 시도하지만 락이 없다.

> 락이 없으면 락을 획득할 때 까지, BLOCKED 상태로 대기한다.

> RUNNABLE에서 BLOCKED 상태로 변하고, 락을 획득할 때 까지 무한정 대기한다.

 

-> 이후 t1 메서드 호출이 끝나고 락을 반납하면, t2가 락을 획득해 로직을 수행한다.

 

 

 

락을 획득하는 순서는 보장되지 않는다.

만약 BankAccount(x001) 인스턴스의 withdraw()를 수많은 스레드가 동시에 호출한다면, 1개의 스레드만 락을 획득하고 나머지는 BLOCKED 상태가 된다. 이후 락이 반납되면, 락을 기다리는 수많은 스레드 중 하나만 다시 락을 획득한다.

어떤 순서로 락을 획득하는지는 자바 표준에 정의되어 있지 않다.

 

* volatile을 사용하지 않아도 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결된다!


synchronized 코드 블럭

synchronized의 가장 큰 장점이자 단점은 한 번에 하나의 스레드만 실행할 수 있다는 점이다.

여러 스레드가 동시에 실행하지 못하기에 전체로 보면 성능이 떨어질 수 있다.

따라서, synchronized를 통해 여러 스레드를 동시에 실행할 수 없는 코드 구간은 꼭! 필요한 곳으로 한정해 설정해야 한다.

 

 

 

(-) 기존 메서드 단위의 synchronized

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 = balance - amount;
    log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance); 
    // ==임계 영역 종료==
    
    log("거래 종료");
    return true;
}

 

 

 

(+) 메서드 단위가 아닌 특정 코드 블럭에 특정할 수 있는 기능

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 = balance - amount;
        log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
    }
    
    log("거래 종료");
    return true;
}

-> synchronized (this) {} : 안전한 임계 영역을 코드 블록으로 지정

-> 괄호 안의 this는 락을 획득할 인스턴스의 참조

 

 

=> 동기화는 멀티스레드 환경에서 필수적인 기능이나, 과도하게 사용할 경우 성능 저하를 초래할 수 있어 꼭 필요한 곳에 적절히 사용해야 한다.


지역변수 - 예제

package thread.sync.test;

import static util.MyLogger.log;

public class SyncTest2Main {

    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();

        Runnable task = new Runnable() {
            @Override
            public void run() {
                myCounter.count();
            }
        };

        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");

        thread1.start();
        thread2.start();

    }

    static class MyCounter {
        public void count() {
            int localValue = 0;
            for(int i=0; i<1000; i++) {
                localValue += 1;
            }
            log("결과 : " + localValue);
        }
    }
}

 

지역변수는 동시성 문제가 발생하지 않음!!

지역변수는 스택 프레임 안에 생기므로 스레드 간 공유하지 않기 때문


정리

자바는 처음부터 멀티스레드를 고려하고 나온 언어

 

synchronized의 장단점

(+) 프로그래밍 언어에 문법으로 제공

(+) 편리, 단순

(+) 자동 잠금 해제 : synchronized 블록이 완료되면 자동으로 락을 대기중인 다른 스레드의 잠금이 해제됨

 

(-) 무한 대기 : BLOCKED 상태 스레드는 락이 풀릴 때 까지 무한 대기

(-) 공정성 : 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있음

 

 

 

=> 결국 더 유연하고, 세밀한 제어가 가능한 방법이 필요하게 되었다.

=> 이를 위해 자바 1.5부터 java.util.concurrent라는 동시성 문제 해결을 위한 패키지가 추가된다.