SY 개발일지
article thumbnail

스레드 동기화란?

하나의 프로세스는 스레드와 데이터, 그리고 자원으로 구성됩니다. 하나의 프로세스내의 스레드들은 각자의 PC와 register를 지니고 있지만, 자원과 데이터는 공유하여 사용합니다.

싱글스레드 환경에서는 공유 데이터에 대해 하나의 스레드만 접근하여 사용하므로 동기화에 대해 문제가 발생하지 않습니다. 그러나 멀티스레드 환경에서 둘 이상의 스레드가 공유 데이터에 동시에 접근하게 되면 문제가 발생할 수도 있습니다.

이러한 문제를 막기 위해 스레드 동기화를 해야 합니다.

동기화를 하지 않는다면 ?

동기화를 하지 않는 경우를 먼저 그림을 통해 생각해보도록 하겠습니다.

다음은 예금 인출 방법입니다. 

 

가장 이상적인 예시로는 여러번의 요청이 와도 차례대로 처리가 되어 잔액이 마이너스가 되거나, 중간에 요청이 겹치는 일이 없습니다. 하지만 실제로는 그렇지 않습니다.

다음과 같이 동시에 접근을 했을 경우에 동기화가 되어 있지 않아 실제보다 많은 금액을 인출할 수 있어 잔액이 마이너스가 가능할 수도 있게 됩니다. 이러한 일이 실제 서비스에서 발생한다면 큰 위험을 초래할 수 있습니다.

 

이러한 상황을 코드로 확인해보겠습니다.

 

먼저 쓰레드가 2개인 상황에서 계좌 하나에 대해 인출 처리를 동시에 요청한다고 가정해보도록 하겠습니다.

public class BankAccount {
    private int balance;

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

    public void withdraw(int amount) {
        if (amount > balance) {
        } else {
            balance -= amount;
            System.out.println(Thread.currentThread().getName() + " withdrew "+this.balance+" dollars");
        }
    }

    public int getBalance() {
        return balance;
    }
}
public class Main {
    public static void main(String[] args) {
        BankAccount account = new BankAccount(5000);

        // 쓰레드 1: 출금
        Thread withdrawThread1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) { // 충분한 횟수로 출금을 시도
                account.withdraw(200);
                try {
                    Thread.sleep(100); // 잠깐 쉬어줌
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 쓰레드 2: 출금
        Thread withdrawThread2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) { // 충분한 횟수로 출금을 시도
                account.withdraw(200);
                try {
                    Thread.sleep(100); // 잠깐 쉬어줌
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        withdrawThread1.start();
        withdrawThread2.start();
    }
}

 

만일 동시성을 고려하지 않는다면 다음과 같이 돈을 2번 인출하였다고 하더라도 남은 잔액이 같게 나오는 경우가 발생할 수 있습니다.

 

이러한 상황을 해결하기 위해 자바에서는 동기화를 지원하고 있습니다.

 

자바의 동기화 방법

자바에서 제공하는 동기화 방법에는 총 3가지가 있습니다.

  • Synchronized 키워드
  • Atomic 클래스
  • Volatile 키워드

 

저는 이번 포스팅에서는 먼저 Synchronized 에 대해서 알아보도록 하겠습니다.

 

Synchronized 키워드

Synchronized란 ?

자바에서 지원하는 Synchronized 키워드는 여러개의 스레드가 한개의 자원을 사용하고자 할 때, 현재 데이터를 사용하고 있는 스레드를 제외하고 나머지 스레드들은 데이터에 접근할 수 없도록 막는 개념입니다. 동기화는 객체에 대한 동기화로 이루어지는데(synchronized on some object), 같은 객체에 대한 모든 동기화 블록은 한 시점에 오직 한 쓰레드만이 블록 안으로 접근하도록 합니다. 블록에 접근을 시도하는 다른 쓰레드들은 블록 안의 쓰레드가 실행을 마치고 블록을 벗어날 때까지 블록(block) 상태가 됩니다.

해당 키워드를 너무 많이 사용한다면 오히려 성능 저하를 일으킬 수 있습니다. 이는 Synchronized 키워드가 붙을 시 내부적으로 동기화를 위해 block과 unblock 처리를 하게 되는데, 이러한 작업이 많아지게 되면 오버헤드가 발생하기 때문입니다.

 

synchronized 키워드는 다음 네 가지 유형의 블록에 사용됩니다.

  1. 인스턴스 메소드
  2. 스태틱 메소드
  3. 인스턴스 메소드 코드블록
  4. 스태틱 메소드 코드블록

 

인스턴스 메소드

public synchronized void add(int value){
      this.count += value;
}

 

메서드를 통해 동기화를 하는 것은 해당 메서드를 가진 인스턴스(객체)를 기준으로 이루어집니다. 한 시점에 오직 하나의 쓰레드만이 동기화된 인스턴스 메소드를 실행할 수 있습니다. 

즉, 만일 둘 이상의 인스턴스가 있다면 한 시점에, 한 인스턴스에 대해서, 한 쓰레드만이 해당 메소드를 실행합니다.

인스턴스 당 하나의 쓰레드임을 명심해야 합니다. 

 

스태틱 메소드

public static synchronized void add(int value){
    count += value;
}

 

위와 동일하게 메서드 선언문에 synchronized 키워드를 사용합니다.

스태틱 메서드는 해당 메서드를 가진 클래스의 클래스 객체를 기준으로 이루어집니다. JVM 내에 클래스 객체는 클래스 당 하나만 존재할 수 있으므로, 동일한 클래스에 대해서 오직 한 쓰레드만 동기화된 스태틱 메소드를 실행할 수 있습니다.

 

만일 동기화된 스태틱 메서드가 다른 클래스에 각각 존재한다면, 쓰레드는 각 클래스의 메서드를 실행할 수 있습니다.

즉, 클래스(쓰레드가 어떤 스태틱 메소드를 실행했든 상관 없이) 당 하나의 쓰레드입니다.

 

인스턴스 메소드 코드블록

동기화가 반드시 메서드 전체에 대해 이루어져야 하는 것은 아닙니다. 메서드의 특정 부분에 대해서만 동기화하는 방법에 대해 알아보도록 하겠습니다.

public void add(int value){

  synchronized(this){
     this.count += value;   
  }
  
}

 

위 코드처럼 메서드 내에 동기화 블록을 따로 작성할 수 있습니다. 

 

동기화 블록이 괄호 내에 한 객체를 전달받고 있음을 알고 있어야 합니다. this 키워드를 통해 객체가 전달됨을 확인할 수 있는데, 이 객체는 add() 메서드가 호출된 객체를 의미합니다. 이렇듯 동기화 블록 안에 전달된 객체를 모니터 객체(a monitor object)라고 합니다. 위 코드는 해당 모니터 객체를 기준으로 동기화가 이루어짐을 나타내고 있습니다. 동기화된 인스턴스 메도스는 자신을 내부에 가지고 있는 객체를 모니터 객체로 사용합니다.

 

같은 모니터 객체를 기준으로, 동기화된 블록 안의 코드를 오직 한 쓰레드만이 실행할 수 있습니다.

 

다음 예제의 동기화는 동일한 기능을 수행합니다.

public class MyClass {

  public synchronized void log1(String msg1, String msg2){
     log.writeln(msg1);
     log.writeln(msg2);
  }


  public void log2(String msg1, String msg2){
     synchronized(this){
        log.writeln(msg1);
        log.writeln(msg2);
     }
  }
}

 

 

한 쓰레드는 한 시점에 두 동기화된 코드 중 하나만 실행 가능합니다. 

만일 두 번째 동기화 블록의 괄호에 this 대신 다른 객체를 전달하게 된다면, 동기화 기준이 달라지기 때문에 쓰레드는 한 시점에 각 메서드를 실행할 수 있습니다.

 

 

스태틱 메소드 코드블록

스태틱 메서드의 경우 각 메서드를 가지고 있는 클래스 객체를 동기화 기준으로 잡습니다.

public class MyClass {

  public static synchronized void log1(String msg1, String msg2){
     log.writeln(msg1);
     log.writeln(msg2);
  }


  public static void log2(String msg1, String msg2){
     synchronized(MyClass.class){
        log.writeln(msg1);
        log.writeln(msg2);  
     }
  }
}

 

같은 시점에 오직 한 쓰레드만 이 두 메소드 중 어느 것이든 실행 가능합니다. 

두 번째 동기화 블록의 괄호에 MyClass.class 가 아닌 다른 객체를 전달한다면, 쓰레드는 동시에 각 메서드를 실행할 수 있습니다.

 

예제

같은 인스턴스

Counter 클래스를 생성 후 , 두 쓰레드가 동일한 Counter 인스턴스의 add() 메서드를 호출한다고 가정해보겠습니다. 이는 한 시점에 오직 한 쓰레드만이 add() 메서드를 호출할 수 있을 것입니다. 

public class Counter{
   
   long count = 0;
  
   public synchronized void add(long value){
     this.count += value;
   }
}
public class CounterThread extends Thread{

    protected Counter counter = null;
    private int num;

    public CounterThread(int num, Counter counter){
        this.counter = counter;
        this.num = num;
    }

    @Override
    public void run() {
        for(int i=0; i<10; i++){
            counter.add(i);
            System.out.println(num+" "+counter.count);
        }
    }
}
public class Example {
 public static void main(String[] args){
     Counter counter = new Counter();
     Thread  threadA = new CounterThread(1, counter);
     Thread  threadB = new CounterThread(2, counter);

     threadA.start();
     threadB.start();
 }
}

 

두 쓰레드를 생성한 후, 같은 Counter 인스턴스를 각 쓰레드의 생성자로 전달합니다. Counter.add() 메서드는 인스턴스 메서드이기 때문에 Counter.add() 메서드는 생성자로 전달된 Counter 인스턴스를 기준으로 동기화가 됩니다. 이로써 두 쓰레드 중 한 쓰레드만이 add() 메서드를 호출할 수 있게 됩니다. 한 쓰레드가 add() 메서드를 실행하는 동안 다른 쓰레드는 해당 실행이 끝나고 실행 쓰레드가 동기화 블록을 빠져나갈 때까지 기다리게 됩니다.(block 상태)

 

따라서 다음과 같이 결곽가 잘 나오는 것을 확인할 수 있습니다.

 

(만약 synchronized 키워드를 붙이지 않는다면 아래와 같이 이상한 결과가 나오게 됩니다.)

 

 

다른 인스턴스

만약 두 쓰레드가 서로 다른 Counter 인스턴스를 전달받았다면, add() 메서드는 동시에 호출될 수 있게 됩니다. 이는 add() 메서드의 호출이 서로 다른 객체에 의해 이루어지고, add() 메서드의 동기화 조건은 동일한 인스턴스임을 조건으로 하기 때문입니다. 따라서 이러한 경우에는 쓰레드가 블록 상태에 놓이지 않습니다.

public class Example {

  public static void main(String[] args){
    Counter counterA = new Counter();
    Counter counterB = new Counter();
    Thread  threadA = new CounterThread(counterA);
    Thread  threadB = new CounterThread(counterB);

    threadA.start();
    threadB.start(); 
  }
}

 

더이상 두 쓰레드가 같은 인스턴스를 참조하지 않기 때문에 counterA에 대한 add() 메서드 호출은 counterB의 add() 메서드를 블록시키지 않습니다.

 

따라서 각자 인스턴스에 대해서 결과가 나오게 됩니다.

 

 

[ 생각해보면 좋을 문제 ]

서버가 여러대일 경우에는 이 제어자 또한 동시성을 보장하기 어렵다. synchronized는 하나의 프로세스에서만 동시성을 보장하기 때문이다.

 

참고

https://coding-start.tistory.com/68

https://parkcheolu.tistory.com/15

https://velog.io/@foureaf/JAVA-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%98%88%EC%95%BD%EA%B8%B0%EB%8A%A5%EC%97%90%EC%84%9C-%EB%A7%88%EC%A3%BC%EC%B9%9C%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-synchronized%EC%99%80concurrentHashMap

 

profile

SY 개발일지

@SY 키키

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!