파이문

멀티쓰레드 환경에서 캐시 구현하기 본문

Java

멀티쓰레드 환경에서 캐시 구현하기

민Z 2020. 6. 27. 21:54

멀티쓰레드 환경에서 캐시 사용하기

프로그램을 개발하다 보면 캐시를 사용해야 하는 경우를 심심치 않게 발견하곤 한다. 특히 DB 에 접근하여 데이터를 가져올 때, 계속 SELECT (또는 get, scan 등) 하기 보다는 캐시를 사용하는 경우가 많다.

 

DB 에서 값을 가져오는 비용이 비싸지 않아도 IO, 네트워크 latency 비용을 최소화 하려고 하기 때문이다. 캐시는 알고리즘 문제 풀이에서도 자주 사용하는데, 이 경우엔 재 연산 (연산 비용) 을 줄이기 위해서이다.

본 게시글에선 자바를 이용한 예제를 작성하도록 하겠다.

캐시란?

캐시의 정의를 다시 한번 짚고 넘어가보자. 캐시는 동일한 input 에 대해서 같은 작업을 하지 않도록 하는 것이다. 같은 작업을 하지 않는 다는 의미는, input 에 대한 값을 미리 갖고 있어야 하는 것이다.

 

결과 값은 true, false 인 boolean 일 수도 있고 어떤 연산에 대한 결과 객체일 수도 있다. 만약 단순히 재 연산을 방지 한다면 true, false (또는 null) 을 갖게 될 것이고 수학 계산이라면 계산 결과를, DB 접근이라면 connection 객체일 수도 있다.

단일쓰레드 환경에서 캐시 사용하기

단일쓰레드에서 캐시를 사용하는 것은 어렵지 않다. 자바의 Map, Set 과 같은 자료구조를 사용하면 된다. 키는 위에서 언급한 input 인 것이고 값은 작성하는 코드 종류에 따라 달라지겠다.

public class HashMapCache {
    private Map<String, String> cache = new HashMap<>();

    private String compute(String key) {
        return key + " world!";
    }

    public String get(String key) {
        String value = cache.get(key);
        if (value == null) {
            value = compute(key);
            cache.put(key, value);
        }
        return value;
    }
}

 

하지만 멀티쓰레드에서는 문제가 된다.

 

HashMap 은 Thread-safe 하지 않기 때문에, 멀티쓰레드 환경에서 SingleThreadCache 의 run 메서드에 동시에 접근하게 된다면, 쓰레드 A 가 캐시 값을 업데이트 하였어도 쓰레드 B 는 또 연산 (compute 메서드) 을 하게 된다.

멀티쓰레드 환경에서 캐시 사용하기 절망편

위의 예제를 보고, get 연산에 synchronized 블록을 씌워보자

public class HashMapCache { 
	... 
	public synchronized String get(String key) { 
		... 
	} 
}

 

위 코드는 잘 동작한다. 코드를 실행하면 딱 한 번만 연산을 하고 나머지 쓰레드는 캐시를 사용하고 있는 것을 볼 수 있을 것이다. 그러나 성능상의 단점이 존재한다. 모든 쓰레드가 get 에 접근 하기 위해, 락을 걸고 풀고 (대기하고) 하기 때문에 속도가 느리게 된다.

 

단순히 캐시 사용이 아니라, synchronized 를 사용하는 다른 프로그램에서도 똑같은 문제가 발생한다. 이를 위해 나타난 것이 있다. 바로 ConcurrentHashMap 이다.

ConcurrentHashMap 은 Thread-safe 그리고 병렬성을 위해 HashMap 보다 더 발전된 라이브러리이다. 자세한 설명은 다른 포스트에서 해보도록 하겠다.

다시 Thread-safe 한 ConcurrentHashMap 으로 변경하도록 해보자.

private Map<String, String> cache = new ConcurrentHashMap<>();

이제 ConcurrentHashMap 이 Thread-safe 하기 때문에 compute 연산이 한번만 일어날 것이라고 생각할 것이다. 하지만 인생은 그렇게 아름답지 못하다.

 

실제로 멀티쓰레드에서 돌려보면 어쩔땐 cache 를 타고, 어쩔 땐 타지 않고 그 값이 실행할 때 마다 변경되는 것을 볼 수 있을 것이다.

 

그 이유는 ConcurrentHashMap 의 get 연산은 Thread-safe 해도 HashMapCache 클래스의 get 은 그렇지 않기 때문이다. 즉 내부에서 사용하는 라이브러리가 Thread-safe 해도 그 라이브러리의 메서드를 사용하는 바깥 메서드 (참조하는 클래스) 가 그렇지 않기 때문이다.

 

예제에선 compute 연산 후에 캐시에 값을 업데이트 하는데, 이 연산이 빨리 끝나면 선점 쓰레드가 먼저 캐시를 업데이트 하겠지만, 연산 도중에 후발 쓰레드가 같은 키로 캐시를 뒤져 보면 캐시 값은 없고(!) 후발 쓰레드도 연산을 할 것이다.

 

운이 좋으면 캐시를 타고, 운이 나쁘면 같은 연산을 여러번 하게 되니 이는 캐시가 아니다.

멀티쓰레드 환경에서 캐시 사용하기 희망편

자바의 Future 는 비동기 연산 결과를 표현하는 클래스이다. 왜 갑자기 Future 이야기를 하느냐 하면, 이 Future 를 사용하면 멀티쓰레드 환경에서 구현하고자 하는 캐시를 만들 수 있기 때문이다!

 

Future.get 은 작업이 완료될 때 까지 대기했다가, 작업이 완료된 이후 값을 리턴하는 (비동기로 실행되는) 메서드이다.

 

ConcurrentHashMap 만 사용했을 때 문제는 get 시점에 쓰레드A 가 사전에 값을 업데이트 한 후에 쓰레드B 가 값을 가져와야 하는데, 실제로는 A가 값을 업데이트 하기 전인지 아님 연산 조차 하기 전인지 모르는 불행한 상황에서 B가 캐시에 접근할 수 있는 것이였다. (길다...)

 

Future.get 을 이용하면 이 문제를 해결할 수 있다. 바로 ConcurrentHashMap 의 putIfAbsent 메서드(갑자기?!)와 value 값에 Future를 사용하는 것이다.

putIfAbsent 메서드는 put과 exist 확인을 단일 연산 처럼 사용하는 메서드이다. 이 말이 뭔 말인가 하면 값이 없으면, 넣는 메서드인데 이를 하나의 synchronized 로 묶은 것 처럼, atomic 을 보장하게 해주는 메서드라는 의미다.

절망편에서 이것을 사용하지 않은 이유는, 사용해도 결국 같은 이슈 (연산에서 발생하는 시간 차이) 가 있기 때문이다. 참고로 기본Map은 동기화를 보장하지 않고 ConcurrentHashMap 만 내부 구현으로 되어 있으므로 주의하자.

그럼 Future 를 사용한 예제를 한번 보자.

    public String get(String key) throws InterruptedException, ExecutionException {
        while (true) {
            Future<String> value = cache.get(key);
            if (value == null) {
                Callable<String> eval = () -> compute(key);
                FutureTask<String> task = new FutureTask<>(eval);
                value = cache.putIfAbsent(key, task);
                if (value == null) {
                    task.run();
                }
            } else {
                return value.get();
            }
        }
    }

 

위 예제는 제대로 동작한다! 늘 후발 쓰레드가 캐시를 타게 된다.

 

이처럼 Future.get 을 사용해 연산 결과가 나올 때 까지 대기 하고 그 값을 ConcurrentHashMap 의 putIfAbsent 를 이용하면 멀티쓰레드에서 캐시를 올바르게 사용할 수 있다.

 

물론 Future 를 사용하지 않고 putIfAbsent 만 사용하는 경우도 (중복 연산이 있을 수도 있지만...) 꽤 괜찮은 방법 중 하나다. 연산이 복잡하지 않고, 어느 정도 중복 연산을 감안한다면 putIfAbsent 만을 사용해서 코드를 더욱 단순하게 만드는 방법도 좋은 것 같다.

 

크롤러와 같이 웹 문서 다운로드 가 언제 끝날지 모르는 작업이라면 비동기가 좋은 것 같고 (MSA 에서 API 요청도 마찬가지) 그렇지 않고 연산 자체가 펀-하고 쿨-하고 섹시-하면 (??) 그냥 putIfAbsent 를 사용하면 될 것 같다.

참고

자바 병렬성 프로그래밍 책

https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/Future.html

https://stackoverflow.com/questions/5997245/why-concurrenthashmap-putifabsent-is-safe

'Java' 카테고리의 다른 글

멀티쓰레드에서 싱글톤 클래스 사용 예제  (0) 2020.10.08
JDK 14 톺아보기  (0) 2020.10.07
Mockito 를 사용하는 예제  (0) 2020.09.25
CountDownLatch vs CyclicBarrier  (0) 2020.09.24
Comments