티스토리 뷰

우리 팀에서는 사용자의 조작에 따라 카프카의 특정 토픽을 구독하거나 구독 취소하는 기능을 구현하고 있다. 이 과정에서 topicList라는 리스트를 통해 구독할 토픽을 관리하고, 해당 리스트의 사이즈를 체크하여 구독할 대상이 있는 경우에만 로직을 실행하도록 개발했다. 그런데 topicList에 데이터가 하나도 없을 때 무한루프에 빠져 새로운 토픽이 추가되어도 무한루프를 빠져나오지 못하는 현상이 발생했다.

문제의 원인

아래는 대략적인 코드이다. 코드를 살펴보면, topicList의 사이즈가 0일 때 while문 안에서 continue가 호출되어 무한루프에 빠지는 것을 알 수 있다. 사실 이 코드는 애초에 잘못 짜여진 코드이다. 토픽이 없는 경우에도 무한루프에 빠지가 코드를 짜면 안된다. 하지만 그렇다고 하더라도 사용자가 새로운 토픽을 추가하면 이 조건을 통과해 다음 로직을 실행해야 할 것 같은데, 실제로는 그렇지 않았다.

@Log4j2
public class TestConsumer implements Runnable {

    private List<String> topicList = new ArrayList<>();

    @Override
    public void run() {
        log.info("[{}] start consumer!!", Thread.currentThread().getName());
        while (true) {
            if (topicList.size() == 0) {
                continue;
            }

            log.info("[{}] ======= topicList is not null! logic start! size:{} =======", Thread.currentThread().getName(), topicList.size());
            try {
                Thread.sleep(1000 * 10L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public void addTopic(String topic) {
        topicList.add(topic);
        log.info("[{}] add topic : {}", Thread.currentThread().getName(), topicList);
    }
}

 

아래의 메인 코드를 실행해보자. topic_init을 우선 추가 후 5초 이후 topic_new를 추가하는 코드이다.

public class Main {

    public static void main(String[] args) throws InterruptedException {
        TestConsumer testConsumer = new TestConsumer();
        testConsumer.addTopic("topic_init");
        Thread thread = new Thread(testConsumer, "testConsumer");
        thread.start();

        // 사용자가 topic을 추가.
        Thread.sleep(1000 * 5L);
        testConsumer.addTopic("topic_new");
    }
}

topicList 에 topic_init 이라는 토픽이 이미 존재하는 경우 실행된 로그를 확인해보면 정상적으로 이후 로직이 실행되는 것을 확인해볼 수 있다.

 

하지만 아래 코드처럼 토픽에 대한 초기화를 안하면

public class Main {

    public static void main(String[] args) throws InterruptedException {
        TestConsumer testConsumer = new TestConsumer();
//        testConsumer.addTopic("topic_init");
        Thread thread = new Thread(testConsumer, "testConsumer");
        thread.start();

        // 사용자가 topic을 추가.
        Thread.sleep(1000 * 5L);
        testConsumer.addTopic("topic_new");
    }
}

 

토픽 추가를 해도 이를 인지하지 못하고, 이후 로직이 실행되지 않는다. topicList에 topic_new 라는 토픽을 추가해줬지만 여전히 topicList 의 사이즈는 0이라고 인식하고 있는 거다.

스레드 덤프를 떠보면 더 확실히 확인할 수 있는데 토픽이 추가된 이후인데도 여전히 if(topicList.sizer() == 0) 를 빠져나가지 못하고 있는것을 확인할 수 있다. 

 

CPU 캐시

이런 현상이 나타나는 원인을 알기 위해서는 cpu캐시에 대해 알아야 한다. JVM에서는 스레드는 실행되고 있는 CPU 메모리 영역에 데이터를 캐싱 한다. (CPU Cache) 따라서 멀티 코어 프로세서에서 다수의 스레드가 변수를 공유하더라도 캐싱 된 시점에 따라 데이터가 다를 수 있으며, 서로 다른 코어의 스레드는 데이터 값이 불일치하는 문제가 생긴다.

임의로 데이터를 갱신해 주지 않는 이상 캐싱된 데이터가 언제 갱신되는지 또한 정확히 알 수 없다.

 

따라서, topicList에 새로운 토픽이 추가되더라도, 추가한 스레드의 캐시와 메인 메모리는 업데이트 되지만, 기존 스레드가 가지고 있는 캐시된 데이터가 업데이트되지 않을 수 있어 무한루프에 빠지게 되는 것이다.

해결 방법 : volatile 키워드

이런 경우 volatile 키워드를 사용하여 CPU 메모리 영역에 캐싱 된 값이 아니라 항상 최신의 값을 가지도록 메인 메모리 영역에서 값을 참조하도록 할 수 있다. 
-> 즉, 동일 시점에 모든 스레드가 동일한 값을 가지도록 동기화한다.

쓰레드가 변경한 값이 아직 메인 메모리에 기록되지 않았기 때문에 다른 쓰레드가 변수의 최신 값을 볼 수 없는 문제를 "가시성" 문제라고 한다.

그럼 topicList에 volatile 키워드를 추가 후 테스트해보자

@Log4j2
public class TestConsumer implements Runnable {
    // volatile 키워드 추가
    private volatile List<String> topicList = new ArrayList<>();

    @Override
    public void run() {
        log.info("[{}] start consumer!!", Thread.currentThread().getName());
        while (true) {
            if (topicList.size() == 0) {
                continue;
            }

            log.info("[{}] ======= topicList is not null! logic start! size:{} =======", Thread.currentThread().getName(), topicList.size());
            try {
                Thread.sleep(1000 * 10L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public void addTopic(String topic) {
        topicList.add(topic);
        log.info("[{}] add topic : {}", Thread.currentThread().getName(), topicList);
    }
}

성공이다 초기화를 해주지 않았지만 정상적으로 실행되는것을 확인할 수 있다.

 

synchronized 키워드

하지만 volatile 을 붙여주더라도 문제가 생길 여지는 있다 바로 여러 스레드에서 한번에 해당 리스트에 topic을 추가하는 경우이다. 사실 우리 서비스는 해당 영역을 개발자 혹은 담당자 한명 정도만 만지기 때문에 동시에 동일한 토픽에 대한 수정을 할 일은 극히 적지만, 만약 동시에 토픽을 수정한다고 하면 이상한 결과가 나올수도 있다

이런경우 synchronized 키워드를 사용해주면 문제는 해결된다

그럼 그냥 모든 공유 변수에 synchronized를 사용하면 되지 않을까?
-> synchronized 는 volatile 보다 성능이 좋지 않다(락을 획득해야 하기 때문)

✔ 결론 : 여러 쓰레드에서 참조하는 변수의 경우 volatile 키워드를 붙여주자.
만약 여러 쓰레드에서 해당 값을 수정한다면 synchronized 키워드를 붙여주자.

 

 

참고
https://rachel0115.tistory.com/entry/Java-%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%A0-%EC%88%98-%EC%9E%88%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95

https://programmer-chocho.tistory.com/82

 

'JAVA' 카테고리의 다른 글

[JAVA] 법정공휴일 처리 - LocalDate 이용  (3) 2021.02.15
[JAVA]Mybatis  (13) 2019.07.05
댓글