본문 바로가기

Java/JAVA

자바 동기화, 어설프게 아는 사람이 더 무섭다(java synchronized에 대한 착각, thread-safe)

반응형

'동기화'문제로 고민한 썰

동기화 문제

이펙티브 자바를 읽던 중, 아이템 78에 있는 자바 동기화 문제로 다양한 상상(?)을 했던 썰을 풀려고 합니다.

import java.util.concurrent.TimeUnit;

public class Main {
    private static boolean stopRequested;
    public static void main(String[] args) throws InterruptedException{
        System.out.println("hello world!");
        Thread backgroundThread = new Thread(() -> {
            int i=0;
            while(!stopRequested){
                i++;
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

다짜고짜 코드부터 나와서 당황할 수 있지만, 위 코드는 동기화 문제로 종료되지 않는 코드입니다.

왜 이 코드가 동기화 처리를 하지 않았기 때문에 종료되지 않는지 단 번에 맞추실 수 있는 사람은 뒤로 가기를 누르는 게 좋습니다.

저는 이 코드가 왜 동기화 문제인지에 대해서 이해하지 못했습니다.

심지어 동기화 문제로 적절한 예시가 맞는지에 대한 의심도 했습니다.

왜냐하면 자바 동기화라 함은 멀티 스레드 환경에서 공유되는 변수에 동시에 접근하려고 할 때 즉, 스레드끼리 경쟁하는 상황에서, 공유되는 데이터의 정합성(?)이 맞지 않는 문제를 처리하는 방법으로 알고 있었기 때문입니다. (동기화를 아예 모르는 상태는 아니었습니다...)

그래서 스레드를 여러 개 만들고 공유되는 Integer값에 1씩 더하면서 총합(SUM)을 계산하는 그런 예시가 더 적절하지 않나 생각했습니다.

그런 생각을 하던 상태이기 때문에 위의 코드가 왜 멈추지 않는지 이해를 못했습니다.

그 이유가 동기화 처리를 하지 않았다는 이유로 말입니다.

main thread가 stopRequested의 값을 true 로 만들 때, backgroundThread 가 설령 동기화 처리를 안 한 변수(stopRequested)에 먼저 접근해서 false 값을 가져오더라도 다음 반복문에서 true 로 바뀐 것을 알아챌 수 있다고 생각했습니다.

결국에는 아래처럼 코드를 돌려보고 고민이 시작되었습니다.

실제로 끝나지 않는 모습을 확인할 수 있습니다. 왼쪽 하단에 22분이 넘도록 돌고 있고, 오른쪽 상단에 중지 버튼이 활성화되어 있는 것을 확인할 수 있습니다.


콘솔을 찍어보자

정말로 안 끝나고 있는 게 맞는지 콘솔을 찍어봤습니다.

public class Main {
    private static boolean stopRequested;
    public static void main(String[] args) throws InterruptedException{
        System.out.println("hello world!");
        Thread backgroundThread = new Thread(() -> {
            int i=0;
            while(!stopRequested){
                i++;
            }
            System.out.println("exit loop");
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
        System.out.println("main end");
    }
}

main thread 가 정상적으로 끝나는지도 찍어보고, backgroundThread 가 반복문을 빠져나오는지도 찍어봤습니다.

역시나 backgroundThead 가 종료되지 않고 계속 실행되었습니다.

다음으로 반복문 내부에서도 계속 실행되는지 콘솔을 찍어봤습니다.

그런데 이제는 어느정도 수행하다가 종료가 되었습니다. 왜?

System.out.println() 하나 추가되었다고 몇 번 찍다가 변숫값의 변경을 알아채는 게 아주 의아했습니다.

이제는 멘탈이 깨져서 System.out.println() 에 무슨 문제가 있나? 하기도 했습니다.

항상 똑같은 시기에 스레드(backgoundThread)가 종료되지도 않고 왔다 갔다 하니까 더 헷갈렸습니다.

정답

여러 고민과 테스트 코드를 작성하던 중 책을 조금 더 읽다가 알아냈습니다. 정답은 캐시에 있었습니다.

CPU에는 L1, L2, L3 캐시가 존재합니다. '최근에 쓰인 데이터는 금방 다시 쓰일 확률이 높다'라는 이론(?)을 바탕으로 데이터를 캐시 하는 곳입니다. (캐시 전략은 다양하게 존재할 수 있습니다.)

바로 이 곳에 캐시된 공유 변수(stopRequested)를 동기화해주지 않았기 때문에, 다른 스레드에서 실질적으로 메모리에 값을 true로 변경하였더라도, 반복문에 쓰이고 있는 CPU의 스레드에서는 L1, L2와 같은 곳에 캐시 된 값을 참고하고 있어서 동기화 문제였던 것이었습니다.

충격적인 것이, 스프링 공부를 하면서도 @Cacheable 로 캐시 하고 데이터가 변경되면 @CacheEvict 로 캐시를 제거해줘야지! 즉, 데이터 동기화를 해야지 이런 내용들을 알고 있었음에도 위 문제에서 동기화를 생각해내지 못했던 것입니다.

AtomicInteger 니, ConcurrentHashMap 이런 것도 다 알고 있었으나 이런 부분에서 동기화를 이해하지 못한 것에 대한 아쉬움이 남습니다...


책에서도 정확하게는 그렇게 쓰여있습니다. backgoundThread언제 종료될지 예측할 수 없다고.

즉, 캐시가 언제 비워져서 변경된 값을 언제 참조할지를 모르는 것입니다.

System.out.println() 메소드를 호출할 때는 왜 캐시가 비워지는지 모르겠습니다.

뭘 호출하느냐에 따라 다른가? 하고 System.out.println(stopRequested) 도 해봤는데요, 느낌상(?) 이미 캐시 된 stopRequested 의 경우에 계속 호출하더라도 캐시가 지워지지 않아야 하지 않나? 했는데 중간에 변경된 값을 참조하더라고요. i값, 고정된 문자열(ex. static final String = "hello")을 찍어도 캐시가 지워지지 않아야 하지 않나? 했는데 말이죠.

그냥 빈 System.out.println 만 해도 스레드가 종료되는 것으로 보아 메서드 프린트 메서드만해도 뭔가 캐시에 새로운 게 쌓이는 구나하고 말았습니다... (정확한 정보는 알려주실 수 있는 분이 댓글로 남겨주시면 아주 감사하겠습니다.)


volatile

volatile 키워드가 자바에 있습니다.

이 키워드를 적용한 변수는 L1, L2등에 캐시를 참고하지 않고 직접 메모리를 참조하도록합니다.

private static volatile boolean stopRequested;

따라서 위 코드처럼 변수에 volatile 키워드를 적용하면, 캐시 때문에 동기화가 이뤄지지 않는 문제는 얼추 해결되는 듯 보입니다.

하지만 완전히 동기화 문제가 해결되는 것은 아닙니다.

메모리를 직접 참고하더라도 스레드 간의 접근에 의한(?) 동기화 문제는 남아있기 때문입니다.

이럴 때는 락(lock)을 이용한 동기화 처리를 해줘야 합니다.

synchronized 키워드가 여기서 사용되는 것입니다.


Synchronized 주의 사항

synchronized 키워드에 대해서 설명하는 포스트는 아니기 때문에 이번에 새로 알게 된 주의할 점만 간단하게 설명하도록 하겠습니다.

서비스 애플리케이션을 개발할 때는 주로 DB, NoSQL등에 의존한? 동기화 처리를 사용하고, 굳이 사용하는 경우가 있더라도 ConcurrentHashMap이나 AtomicInteger와 같이 자바에서 제공하는 안전한 변수를 이용할 텐데요.

혹시라도 synchronized를 사용한다면 다음을 주의해야 합니다.

public class MyAtomicInteger {
    private int value = 0;
    public void plus(){
        synchronized (this) {
            value++;
        }
    }
    public int get(){
        synchronized (this) {
            return value;
        }
    }
    //... 많은 메소드들이 있다고 가정...
    public int plus2() {
        return value++;
    }
}

임의로 만든 클래스(MyAtomicInteger)입니다.

synchronized를 열심히 작성하여 동기화된 메소드 들을 만들어서 Thread safe 하게 해 놨다고 칩시다.

그러면 value 에 대해서 항상 Thread safe라고 말할 수 있을까요?

아닙니다. 만약 다른 동료가 개발할 때 해당 변수에 대해서 동기화 처리하지 않으면 MyAtomicInteger 클래스의 변수 value에 대해서 동기화를 보장하지 않게 됩니다.

위의 예시에서는 plus2()와 같은 메소드를 만들면 동기화가 깨집니다.

굳이 value를 수정하는 것이 아니라 get과 같이 변수를 변경하지 않더라도 synchronized 처리를 하지 않으면 동기화가 깨집니다. 위에서 한참 설명한 캐시와 같은 경우에 말이죠.

따라서 synchronized 키워드를 사용할 때는 같이 개발하는 모든 개발자가 알 수 있도록 해야 하고 항상 관리되어야 하는 위험이 있습니다.

그것을 주의하여 코딩해야 하는 것을 이번 기회에 배웠습니다.

반응형