글로벌 캐시로 redis 사용해보기 + 동시성 제어 1편

고동현·2024년 10월 11일
0

"더 깊이, 더 넓게"

목록 보기
9/12

이전 글에서 고찰부분에서 생각이 들었던 동시성문제를 해결하기 위해서 해당 포스트를 작성하였습니다.

궁금증



Aws ec2 인스턴스를 두개를 빌려서 애플리케이션 서버를 두개를 띄운다음에, 글로벌 캐시 Redis를 사용하여서 캐싱을 진행하였습니다.

/api/allProves/v4/STUDY 경로로 요청이 들어오면, Redis에 미리 올려둔, 좋아요 많은 순서대로 STUDY 태그를 가진 Prove를 100개를 가져오게 됩니다.

동일한 결과가 나옴을 확인 할 수 있습니다.

그러나 여기서 생각이 든 것은, 어떻게? 다중 서버 환경에서 데이터 일관성을 유지할 수 있지? 라는 궁금증이 들었습니다.

왜냐하면, Redis가 단일 서버에서 사용될때는(일단 Redis를 사용할 필요가 없지만) 싱글스레드 방식으로 동작하므로 한번에 하나의 명령을 수행하고 다음 명령을 수행합니다.
즉 명령들이 직렬화 되므로, 데이터의 일관성을 보장해 줍니다.

그러나 단일 서버가 아니라 다중서버로 여러 서버에서 Redis에 동시에 접근하는 과정에서 동시에 데이터를 업데이트 하면 데이터 충돌이 발생할 수 있습니다.

예시를 들어보겠습니다.

그림과 같이, UserA와 UserB가 item을 하나 구매하고 싶습니다.
만약 Redis 읽기 전략중 Read Through로 데이터를 읽어 들이려고 한다면,

애플리케이션은 항상 캐시를 통해서 데이터를 읽어옵니다. 만약 데이터가 캐시에 없다면, 캐시가 직접 데이터베이스로부터 직접 데이터를 가져오게 됩니다.

그런데 문제는, UserA와 UserB가 item 구매 요청을 거의 동시에 보냈고, Redis의 item재고 100을 가져온다음에, 99로 줄이고, Cache에 99를 업데이트하게 됩니다.

고로, 두명의 User가 구매를 하였으므로, 100에서 98로 줄어야하는데 99로 줄게되어 재고 수량이 잘못 계산된 것입니다.

동시성

동시성이란 애플리케이션이 둘 이상의 작업(요청)을 동시에 진행 중임을 뜻합니다.
CPU로 예시를 들면,
게임을 할때, 게임 어플리케이션을 시작하고, 노래를 듣기위해서 유튭 뮤직을 키고, 전적 검색을 하기위해서 사이트를 틀어놓는등

게임하나를 하는데 여러가지 작업들이 동시에 실행되어야합니다.
그러나, CPU는 한번에 하나의 작업밖에 수행하지 못합니다. 그러므로, 게임 어플리케이션을 시작하고 TTL이 끝나면 Ready상태로 바뀌고 유튭뮤직을 queue에서 꺼내와서 CPU 작업을 수행하고 다시 Ready로 바꾸고 이런 상황을 계속 반복하는 것입니다.

그런데 생각해보면, 게임을 할때 노래를 튼다고 게임이 끊기는 것이 아닙니다.
이는, CPU가 다수의 프로세스를 동시에 실행시키기 위해서 여러 프로세스를 시분할로, 즉 짧은 텀을 반복해서 전환하면서 실행시키게 됩니다.

JAVA에서도 마찬가지입니다. 애플리케이션에 요청이 100개가 들어왔다면, 100번째 요청을 보낸사람이 1부터 99번 요청이 끝나기를 기다린다면 매우 불편할 것입니다.

그러므로, 시분할로 끊어서 요청 100개를 마치 동시에 처리하는 것처럼 보여줘야 100번째 요청을 보낸 사람도 편한하게 웹을 사용할 수 있을것입니다.

아까 처럼, UserA,와 UserB가 동시에 공유변수에 접근하여서 데이터를 읽고, 수정하는 과정에서 발생하는 오류를 RaceCondition 경쟁조건이라고 합니다.

RaceCondition이란?
race condition이란 두 개 이상의 프로세스가 공통 자원을 병행적으로(concurrently) 읽거나 쓰는 동작을 할 때, 공용 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 같지 않고 달라지는 상황을 말한다.

참고로, 동시성사용하면, RaceCondition말고도, OS측면에서 DeadLock, Starvation과 같은 문제가 발생할 수 있다.
DeadLock은 두개이상의 스레드가 서로의 자원을 기다리며 영원히 대기 상태에 빠지는 것이다. 예를들어, 자원 두개를 얻어야 스레드가 실행되는 구조라고 하면, 스레드 1과 2가 각각 lock을 자원에 걸면 둘다 나머지 자원을 획득하지 못해 영원히 대기상태에 빠져 시스템이 멈추게 된다.
그런데 DeadLock문제를 SW적으로 해결하는것은 오버헤드가 너무 크게 발생하여서 그냥 아무런 처리를 하지 않는다고 한다.(심지어 DeadLock문제가 자주 발생하는것도 아님) 그래서 그냥 process를 kill하고 다시 시작한다고 한다.

Starvation에서는 특정 스레드가 지속적으로 실행 기회를 얻지 못해 작업이 계속 지연되는 상태이다. 우선순위가 높은 스레드가 자주 실행되면, 우선순위가 낮은 스레드는 실행되지 않게 되는 상황입니다. 즉 무한 대기 상태에 빠지게 됩니다. 해결방안은 대시 시간을 측정하여, 대기시간이 길어질수록 대기한 시간마다 우선순위를 한단계씩 높여주는 방식을 사용하면 됩니다.

참고.
그렇다면 요청이 100개 아니라, 10만개, 100만개가 들어오면 어떻게 될까?
CPU전략마다 다르겠지만 일정한 텀 T를 두고 일정하게 10만개를 나눈다면 T/10만, T/100만 이렇게 CPU시간을 나눈다면 아주 쪼끔쓰고, CPU반환하고 아주 쪼끔 쓰고 CPU반환하고 이러한 과정이 반복될 것입니다.

또한 요청을 처리하는 스레드를 생성하고 소멸시키고 관리하는것은 비용이 엄청 많이 듭니다.
동시에 처리할 요청을 몇개로 제한할것인지, 또한 스레드를 어떻게 관리할것인지를 자바에서는 스레드 풀을 사용하여서 관리합니다.
해당 내용에 대해서 더 자세히 알고싶으시면, 여기를 클릭해주세요

다시 돌아가서,
JAVA는 멀티 스레드 프로그래밍이 가능한 언어입니다.
Java는 동시성 문제를 어떻게 해결하고 있을까요?

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread thread1 = new Thread(counter::incrementMany);
        Thread thread2 = new Thread(counter::incrementMany);

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

        thread1.join();
        thread2.join();

        System.out.println("Final Counter Value: " + counter.getCount());
    }

    static class Counter {
        private int count = 0;

        public void increment() {
            count++;
        }

        public void incrementMany() {
            for (int i = 0; i < 10000; i++) {
                increment();
            }
        }

        public int getCount() {
            return count;
        }
    }
}

처음 해당 코드를 보면 어? 어차피 자바 code는 line-by-line으로 실행되는데 start()호출후, thread2의 start()를 호출하면 그냥 Counter의 값이 20000여야하는거 아닌가? 싶을 수 있다.

  1. 자바의 메인스레드가 thread1.start()를 호출하게 되면 새로운 스레드가 생성되어 Counter의 incrementMany()를 실행하기 시작합니다.

  2. 그러나 메인 스레드는 기다리지 않고 바로 다음라인인 thread2.start()를 실행하여 thread2도 비슷하게 비동기적으로 실행되도록 합니다.

    즉, thread1.start()가 완료될때까지 메인스레드가 멈춰서 기다리지 않습니다. 고로 join()을 통해서 스레드가 종료될때까지 메인 스레드를 기다리게 만들어야합니다. join()을 호출하지 않으면, 메인 스레드는 두 스레드의 작업이 완료되지 않은 상태에서 sout을 해버리기 떄문에 join을 사용해야합니다.

    어쩃든, 이렇게 하고 결과가 20000이어야하는데 결과는 18389이런식으로 잘못된 결과가 나옴을 확인 할 수 있습니다.

왜냐하면 두스레드가 Counter의 공유자원에 접근하여, 동기화 되지 않은 데이터의 결과를 반환했기 때문입니다.

즉, 동시성을 만족하기 위해서 스레드간 동기화를 해주어야합니다.

Java의 동기화

자바에서는 data의 thread-safe를 하기 위해서는 자바의 synchronized 키워드를 제공해 스레드간 동기화를 시켜 data의 thread-safe를 가능하게 합니다.

그러면 Synchronized키워드가 뭐냐?
여러개의 스레드가 한개의 자원을 사용하고자 할때, 현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레들은 데이터에 접근 할 수 없도록 막는 개념입니다.

하지만, Synchronized 키워드를 너무 남발하면 오히려 프로그램 성능 저하를 일으킬 수 있습니다. 왜냐하면 해당 키워드를 사용하면 자바 내부적으로 메서드나 변수에 동기화를 하기 위해서 block과 unblock을 처리하게 되는데,
이런 처리들이 너무 많아지면 오히려 프로그램 성능저하를 일으키게 됩니다.

따라서 적재적소에 Synchronized 키워드를 사용하는것이 중요합니다.

Synchronized는 Lock을 사용해 동기화를 시킵니다.

Lock은 인스턴스 단위로 Lock을 걸거나, Class 단위로 Lock을 걸거나 두가지로 나눌 수 있습니다.

  • 인스턴스 단위 Lock
    적용범위: 동일한 인스턴스 내 Synchronized 달린 메서드
    각 인스턴스 마다 내부에 Lock이 있으므로, 해당 인스턴스에만 Lock을 거는것이다.

  • Synchronized mehtod
    해당 인스턴스에다가 Lock을 건다.
    but, Synchronized적용되지 않은 메서드 접근가능, Synchronized적용된 다른 메서드와 동기화 발생(인스턴스 단위로 Lock을 걸기 때문에 인스턴스 내부에 Synchronized적용된 모든 메서드에서 Lock을 공유)

  • Synchronized Block(this)
    해당 인스턴스에 락이 걸려있으면 다른 Thread에서 해당 인스턴스 Block부분을 실행하지 못한다.
    당연히, 인스턴스에 락을 거므로 만약, Thread1에서 Block(this)로 락을 걸었다면, Thread2에서 아직 Block에 도착하지 않았음에도, 해당인스턴스의 Synchronized method를 접근하지 못한다.

  • 클래스 단위 락
    적용범위: 해당 클래스에 모든 static Synchronized mehtod, synchronized block(클래스명.class)범위에 접근하지 못함

  • Static Synchronized method
    해당 클래스의 모든 인스턴스가 해당 메서드에 접근하지 못한다.
    but, 인스턴스 단위의 Lock과 클래스 단위의 Lock은 서로 공유하지않는다.
    고로, 클래스단위의 Lock을 걸었다고해서, 클래스 내부의 모든 메서드에 접근을 못하는게 아니라 인스턴스 단위의 lock 메서드는 접근이 가능하다.

  • Synchronized Block(클래스명.class)
    Thread1이 Block에 lock을 걸었으면, 해당 클래스의 어떠한 인스턴스도 thread1이 Block을 나가기 전까지 BLock접근 못함

  • 클래스 단위의 락을 걸었으므로, 해당 클래스의 어떠한 인스턴스도 해당 클래스의 static synchronized 메서드와 synchronized Block(해당 클래스명.class) 접근 불가
  • Static Synchronized Block(클래스 명.class)
    static메서드 내부에서 Synchronized Block을 둔 상태이다.
    lock할 class를 지정할 수 있고 범위를 지정가능하다.

    이렇게 말로 쭉 풀어서 썻는데, 당연히 처음은 이해하기가 불가능하므로, 예시를 보면서 쭉 설명을 듣고, 다시 올라와서 무슨 말인지 이해해보도록 해보자.

Synchronized method

public class Temp {
    public synchronized void run(String name) {
        System.out.println(name + " lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(name + " unlock");
    }
}
public class TempMain {
    public static void main(String[] args) {

        Temp t = new Temp();
        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t.run("thread2");
        });

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

Synchronized method는 클래스의 인스턴스에 Lock을 건다.

객체 Temp를 생성하고 해당 객체를 Thread1,2에서 공유하게 됩니다.

new Tread()생성자를 통해 스레드를 생성해둡니다.
오해하면 안되는게, new Thread(() -> t.run())에서 Thread를 생성할때 run메서드가 실행되는것이 아닙니다.

이부분은 Thread 객체를 생성하는 부분이고, 람다식에 의해 스레드가 실행해야할 작업을 정의한것 뿐입니다.

실제로 start()메서드를 호출하면 run메서드가 실행됩니다.

  1. thread1.start()
  2. thread1에서 run메서드를 실행하면, synchronized에 의해 해당 스레드는 Temp객체에 Lock을 겁니다. 그 후 1초동안 대기합니다.
  3. thread2.start()
  4. thread2에서 run메서드를 실행하기 위해서 Temp객체에 접근하여도 Lock이 걸려있기때문에 대기 하게되고, Thread1이 lock을 풀면 그때 run메서드를 실행할 수 있습니다.

그렇다고해서, 인스턴스에 락을 건다는게, 인스턴스 접근 자체를 막는것은 아닙니다.


public class Temp {
    public void print(String name){
        System.out.println(name + "hi");
    }
    public synchronized void run(String name) {
        System.out.println(name + " lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(name + " unlock");
    }
}
public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t = new Temp();
        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t.print("thread2");
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}

  1. thread1.start()를 하면, lock을 Temp객체에 걸고 run을 실행합니다.
  2. thread1은 sleep을 합니다.
  3. Thread.sleep(500)을 하여서 thread1이 lock을 걸기전에 thread2가 start()되지 않게 하기 위함입니다.
    왜냐하면, main스레드가 자식스레드(분기된 스레드)를 기다리지 않기 때문입니다. 스레드의 start()메서드가 호출되면 해당 스레드는 백그라운드에서 실행됩니다. 고로, mainThread가 바로 thread2.start()를 호출하지 않기 위해서, sleep()을 걸어줍니다.
  4. thread2.start()는 print메서드를 호출하는것입니다. 분명 Thread1이 lock을 temp인스턴스에 걸었음에도 print메서드가 호출되는 것을 볼 수 있습니다.

만약, print메서드에서 lock이 걸려있다면 어떻게 될까요?

public class Temp {
    public synchronized void print(String name){
        System.out.println(name + "hi");
    }
    public synchronized void run(String name) {
        System.out.println(name + " lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(name + " unlock");
    }
}


Lock이 동기화가 되서 print메서드가 바로 실행되지 않습니다.

즉, Synchronized메서드는 인스턴스 단위로 Lock을건다. 이때, Synchronized가 적용된 모든 Object에 대해 Lock을 공유한다.
Synchronized가 적용되지 않은부분은 접근이 가능하다.

Static Synchronized method

public class Temp {
    public static synchronized void run(String name) {
        System.out.println(name + " lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(name + " unlock");
    }
}
public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t1 = new Temp();
        Temp t2 = new Temp();
        Thread thread1 = new Thread(() ->{
            t1.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t2.run("thread2");
        });

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

여기서 잠깐,
JVM은 자바프로그램이 실핻될때, 필요한 클래스를 메모리에 로드한다, 이 과정에서 JVM은 메타데이터(클래스이름, 상속관계, 메서드, 필드 등)을 메모리에 저장해놓는다.

고로, 클래스에 정의된 static필드와 static메서드도 함께 메모리에 로드가 된다.
고로, static메서드는 클래스 레벨에서 정의되므로, 해당 메서드는 인스턴스를 생성하지 않아도 호출할 수 있다. 왜냐하면, static메서드는 이미 메모리에 존재하기 때문에 바로 실행 될수있다.
또한, 모든 인스턴스가 같은 static메서드를 공유하게 된다. 즉, 클래스 인스턴스가 여러개 생성되더라도, 모든 인스턴스가 동일한 static메서드 정의를 참조하게 된다.

따라서 원래는 인스턴스.static()메서드 호출은 지양한다.
그런데, 지금 현재는 Thread를 통해서 run메서드를 부르는 예제가 필요하므로 인스턴스를 통해서 static메서드를 호출한다.

  1. Thread1.start()호출
  2. run메서드에 걸린 synchronized는 static이므로, 클래스 수준의 락이 적용된다.
  3. 고로 thread2에서 start()를 호출해서 run메서드를 호출하려고 해도, 클래스 단위로 lock이 발생하므로 thread1에서 풀기전까지는 접근이 불가능하다.

만약, static synchronized method와 synchronized method 가 섞여있다면?

public class Temp {
    public synchronized void print(String name){
        System.out.println(name + " hi");
    }
    public static synchronized void run(String name) {
        System.out.println(name + " lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(name + " unlock");
    }
}

public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t1 = new Temp();
        Temp t2 = new Temp();
        Thread thread1 = new Thread(() ->{
            t1.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t2.print("thread2");
        });

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


인스턴스 단위의 lock과 클래스 단위의 lock은 공유되지 않는다.

이 말이 무엇이지 설명을 다시 해보자면,

public class Temp {
   
    public  synchronized void run(String name) {
        System.out.println(name + " lock");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(name + " unlock");
    }
}
public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t1 = new Temp();
        Temp t2 = new Temp();
        Thread thread1 = new Thread(() ->{
            t1.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t2.run("thread2");
        });

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


Sychronized로 선언된 인스턴스 메서드의 경우, 해당 메서드는 객체의 인스턴스에 락을 걸기때문에, t1,t2는 각각 독립적인 락을 가진다 왜? 인스턴스가 서로 다르니까
고로, 둘다, t1.run,t2.run을 호출한다고해서 서로 락을 공유하지 않는다.

그러나, Synchronized로 선언된 정적 메서드의 경우, 클래스에 락을 걸기 때문에, 해당 클래스의 모든 인스턴스가 같은 락을 공유하게 된다.

고로, t1,t2로 Thread의 인스턴스가 각각 다르더라도, Temp 클래스에 대해 모든 인스턴스가 같은 락을 공유한다. 고로, Temp클래스의 static메서드의 run의 lock도 t1,t2가 공유하게 된다.

Synchronized Block
Synchronnized Block은 인스턴스의 block단위로 lock을 건다. 이때 lock객체를 지정해줘야만 한다.

public class Temp {

    public void run(String name) {
        synchronized (this) {
            System.out.println(name + " lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(name + " unlock");
        }
    }
}
public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t = new Temp();

        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t.run("thread2");
        });

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


여기서 this는 Temp객체를 의미하고, block이 method전체에 걸려있으므로 method단위로 lock을 거는것과 같다.

그러나, 위와 같이 method단위로 lock을 거는것처럼 사용하면, 그냥 method에 synchronized붙인것과 같으므로, 이점이 전혀없다.
고로, 로직사이에서 필요한 부분만 lock을 거는것이 좋다.

lock은 Synchronized block에 진입할때 획득하고 빠져나오면서 반납하므로 block으로 범위를 지정하는게 좋다.

public class Temp {

    public void run(String name) {
        //....위에 로직 있다 가정
        System.out.println(name + " logic start");
        synchronized (this) {
            System.out.println(name + " lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(name + " unlock");
        }
        System.out.println(name +" logic finish");
    }
}
public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t = new Temp();

        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t.run("thread2");
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}


현재 t라는 동일한 인스턴스를 사용하고 있기떄문에,
thread1 unlock
thread2 lock
처럼 thread1이 lock을 풀어야 thread2에서 block부분에 접근할 수 있음을 확인 할 수 있다.

그렇다면, 인스턴스에다가 락을 걸었으면 해당락을 공유할까 안할까?


public class Temp {
    public synchronized void print(String name){
        System.out.println(name + "print");
    }

    public void run(String name) {
        //....위에 로직 있다 가정
        System.out.println(name + " logic start");
        synchronized (this) {
            System.out.println(name + " lock");
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(name + " unlock");
        }
        System.out.println(name +" logic finish");
    }
}

public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t = new Temp();

        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t.print("thread2");
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}

이런식으로 thread1이 run메서드를 실행시키면, block의 범위가 this 즉 t인스턴스를 가르키므로, t에다가 lock을 걸고 1초 sleep합니다.
thread2에서 run메서드가 아니라 print메서드를 호출하려고 해도 이미 thread1이 t인스턴스에 lock을 걸어놨기 때문에, print메서드를 호출하지 못하고, lock을 풀어주면 호출하는것을 볼 수 있습니다.

이번에는 lock 객체를 인스턴스가 아니라 클래스로 사용해봅시다.


public class Temp {
    public void run(String name) {
        Temp2 temp2 = new Temp2();
        //....위에 로직 있다 가정
        synchronized (Temp2.class) {
            System.out.println(name + " lock");
            temp2.run();
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(name + " unlock");
        }

    }
}
public class Temp2 {
    public synchronized void run(){
        System.out.println("Temp2 lock");
        try{
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("Temp2 Unlock");
    }
}
public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t = new Temp();

        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t.run("thread2");
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}


결과를 보면 thread1이 run을 호출하면서 Temp2.class에 Lock을 걸게 됩니다. 그리고 Temp2의 run을 호출하여 B.class의 run메서드를 실행합니다.
Thread2에서도 run을 호출하고 run메서드를 수행하려고 해도 B.class에 이미 Lock이 걸려있기 때문에, 블럭에 들어가지 못하고 대기상태가 됩니다. thread1이 B.class의 Lock을 푼다음에 thread2가 Lock을 걸고 로직을 수행합니다.

여기서 오해하면 안되는게, Class단위로 Lock을 걸면, 해당 클래스에 존재하는 static Synchronized메서드와 Synchronized block(해당클래스 명.class)범위에만 영향을 미친다는 것입니다.

public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t = new Temp();
        Temp2 t2 = new Temp2();
        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t2.print();
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}
public class Temp {
    public void run(String name) {
        Temp2 temp2 = new Temp2();
        //....위에 로직 있다 가정
        synchronized (Temp2.class) {
            System.out.println(name + " lock");
            temp2.run();
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(name + " unlock");
        }

    }
}
public class Temp2 {
    public synchronized void print(){
        System.out.println("print method");
    }
    public synchronized void run(){
        System.out.println("Temp2 lock");
        try{
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("Temp2 Unlock");
    }
}


이렇게, Thread1이 Temp2.class에다가 클래스단위 Lock을 걸더라도, Thread2가 수행할 print메서드는 인스턴스 단위의 락이기때문에 그냥 실행되는 것을 확인 할 수 있습니다.

반대로 그럼 Thread1이 Temp2.class에다가 클래스단위 Lock을 걸고 Thread2가 Temp2.class에 있는 static Synchronized method를 실행하면 어떻게 될까요?

public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Temp t = new Temp();
        Temp2 t2 = new Temp2();
        Thread thread1 = new Thread(() ->{
            t.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            t2.print();
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}
public class Temp {
    public void run(String name) {
        //....위에 로직 있다 가정
        synchronized (Temp2.class) {
            System.out.println(name + " lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(name + " unlock");
        }

    }
}
public class Temp2 {
    public static synchronized void print(){
        System.out.println("print method");
    }
    public synchronized void run(){
        System.out.println("Temp2 lock");
        try{
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("Temp2 Unlock");
    }
}


Thread1이 run에서 Temp2.class에다가 클래스단위 Lock을 걸었기 때문에, Thread2에서 static Synchronized메서드에 접근하려고해도 Lock이 잡겨있어 Thread1이 블럭에서 나가야 실행되는것을 확인 할 수 있습니다.

Static Synchronized Block
마지막으로 Static메서드 안에 Synchronized Block을 지정할 수 있습니다. Static의 특성상 this같이 현재 객체를 가르키는 표현은 불가능 하기 때문에,
static Synchronized method와 달리, lock을 할 class와 Block으로 범위지정이 가능하다는 장점이 있습니다.
그리고 클래스 단위로 Lock을 공유한다는 점이 같습니다.

public class TempMain {
    public static void main(String[] args) throws InterruptedException{

        Thread thread1 = new Thread(() ->{
            Temp.run("thread1");
        });

        Thread thread2 = new Thread(() ->{
            Temp.run("thread2");
        });

        thread1.start();
        Thread.sleep(500);
        thread2.start();
    }
}
public class Temp {
    public static void run(String name) {
        //....위에 로직 있다 가정
        synchronized (Temp.class) {
            System.out.println(name + " lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(name + " unlock");
        }

    }
}

동기화 순서보장?
Synchronized는 Thread별 동기화 순서를 보장하지 않는다.

public class TempMain {
    public static void main(String[] args) {
        Example example = new Example();

        Thread t1 = new Thread(() -> example.syncMethod("Thread 1"));
        Thread t2 = new Thread(() -> example.syncMethod("Thread 2"));
        Thread t3 = new Thread(() -> example.syncMethod("Thread 3"));

        t1.start();
        t2.start();
        t3.start();
    }
}
public class Example {
    public synchronized void syncMethod(String name) {
        System.out.println(name + " obtained the lock.");
        try {
            Thread.sleep(1000); // Simulate some work
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(name + " released the lock.");
    }
}

정리
method와 block의 차이는 method는 해당 객체 전체에 lock을 걸고, block은 lock의 대상을 지정할 수 있으며, block으로 동기화가 적용되는 범위를 한정 시킬 수 있다.

이번시간에는, Synchronized 키워드를 통해서 동시성을 만족하는 실습을 해보았습니다.

다음글에서는 해당 Synchronized 키워드를 실제로 적용시키는 예제를 해보도록 하겠습니다.
다음글은 여기를 눌러주세요.

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글