블랙잭 미션
중 Card
도메인에 대해 캐싱을 적용해본 적이 있었다.
Card
도메인의 특징 중 하나는 열거형
처럼 지정된 종류의 카드가 정해져 있어 게임에서 존재하는 Card
의 개수는 Suit
와 Rank
의 카테시안 곱과 같다.
따라서 미리 모든 Card
를 만들어 두거나, 새롭게 Card
를 생성했을 때 어딘가에 보관해 다음번에 똑같은 문양의 카드를 생성할 때 미리 생성해 둔 카드를 반환하면 성능상의 이점을 챙길 수 있다. 즉 캐싱
을 적용하는 것이다.
캐싱된 Card
를 찾으려면 Key가 있어야 하는데, Card
의 Key는 Suit
와 Rank
총 2개이다.
따라서 Key를 지정하는 방법은 여러 가지가 있는 데 우선 3가지 방법을 소개한다.
Suit
와 Rank
의 특정 문자열을 조합하여 Key를 사용하는 방법이다.
코드는 다음과 같다.
private static Map<String, Card> CACHE;
static {
CACHE = Suit.stream()
.flatMap(suit -> Rank.stream()
.map(rank -> new Card(suit, rank)))
.collect(toMap(card -> toKey(card.getSuit(), card.getRank()), card -> card));
}
private static String toKey(Suit suit, Rank rank) {
return suit.getName() + rank.getName();
}
public static Card getCard(Suit suit, Rank rank) {
return CACHE.get(toKey(suit, rank));
}
static
영역에서 미리 모든 Card
의 조합을 만들어 둔 뒤 카드를 가져올 때 Suit
, Rank
의 문자열의 조합으로 Key를 만들어 Card
를 꺼내온다.
Object
의 기본 메소드 중 하나인 hashCode
를 Key로 사용하는 방법이다.
코드는 다음과 같다.
private static final Map<Integer, Card> CACHE;
static {
CACHE = Rank.stream()
.flatMap(rank -> Suit.stream()
.map(suit -> new Card(suit, rank))
).collect(toMap(Card::hashCode, Function.identity()));
}
public static Card getCard(Suit suit, Rank rank) {
return CACHE.get(Objects.hash(suit, rank));
}
카드를 꺼내올 때 Suit
와 Rank
의 hashCode로 Key를 저장한다.
이때 주의할 점은 Card
에도 hashCode
메서드를 재정의해줘야 한다.
Map의 Value에 Map을 사용하는 방법이다.
코드는 다음과 같다.
private static Map<Suit, Map<Rank, Card>> CACHE;
static {
CACHE = Suit.stream()
.flatMap(suit -> Rank.stream()
.map(rank -> new Card(suit, rank)))
.collect(groupingBy(Card::getSuit,
toMap(Card::getRank, Function.identity())));
}
public static Card getCard(Suit suit, Rank rank) {
return CACHE.get(suit).get(rank);
}
미리 Card
를 만드는 로직이 조금 더 복잡해졌다.
그리고 캐싱된 Card
를 가져올 때 get
메서드를 2번 호출해야 한다.
그렇다면 3가지 방법 중 가장 성능이 뛰어난 방법은 어떤 것일지 알아보겠다.
우선 성능 측정은 JMH
를 사용하여 측정한다.
JMH
의 설치와 사용 방법은 구글링하면 많은 자료가 있으니, 여기서 다루지는 않겠다. (Gradle 플러그인으로 사용하면 간편하다.)
사용할 JMH
설정은 다음과 같다.
jmh {
fork = 1
warmupIterations = 1
iterations = 1
profilers = ["gc"]
}
우선 벤치마크에 사용할 코드이다.
520,000개의 카드를 가져올 때 시간이 얼마나 걸리고, 메모리를 얼마나 사용하는지 알아본다.
비교를 위해 새로운 Card
를 생성했을 때와 차이점도 알아보겠다.
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Bench {
@Benchmark
public void withoutCache(Blackhole blackhole) {
getAllCard(Card::new, blackhole);
}
@Benchmark
public void hashCodeCache(Blackhole blackhole) {
getAllCard(HashCodeCache::getCard, blackhole);
}
@Benchmark
public void mapMapCache(Blackhole blackhole) {
getAllCard(MapMapCache::getCard, blackhole);
}
@Benchmark
public void stringCache(Blackhole blackhole) {
getAllCard(StringCache::getCard, blackhole);
}
public void getAllCard(BiFunction<Suit, Rank, Card> function, Blackhole blackhole) {
for (int i = 0; i < 10000; i++) {
for (Rank rank : Rank.values()) {
for (Suit suit : Suit.values()) {
blackhole.consume(function.apply(suit, rank));
}
}
}
}
}
다음은 벤치마크 결과이다.
Benchmark Mode Cnt Score Error Units
Bench.hashCodeCache avgt 11294024.030 ns/op
Bench.hashCodeCache:·gc.alloc.rate avgt 2168.357 MB/sec
Bench.hashCodeCache:·gc.alloc.rate.norm avgt 25680000.497 B/op
Bench.hashCodeCache:·gc.count avgt 98.000 counts
Bench.hashCodeCache:·gc.time avgt 44.000 ms
Bench.mapMapCache avgt 5926525.805 ns/op
Bench.mapMapCache:·gc.alloc.rate avgt 785.247 MB/sec
Bench.mapMapCache:·gc.alloc.rate.norm avgt 4880000.261 B/op
Bench.mapMapCache:·gc.count avgt 52.000 counts
Bench.mapMapCache:·gc.time avgt 26.000 ms
Bench.stringCache avgt 15659279.930 ns/op
Bench.stringCache:·gc.alloc.rate avgt 1948.775 MB/sec
Bench.stringCache:·gc.alloc.rate.norm avgt 32000000.689 B/op
Bench.stringCache:·gc.count avgt 88.000 counts
Bench.stringCache:·gc.time avgt 38.000 ms
Bench.withoutCache avgt 2298374.177 ns/op
Bench.withoutCache:·gc.alloc.rate avgt 7202.803 MB/sec
Bench.withoutCache:·gc.alloc.rate.norm avgt 17360000.101 B/op
Bench.withoutCache:·gc.count avgt 221.000 counts
Bench.withoutCache:·gc.time avgt 117.000 ms
가장 높은 성능을 보인 건 캐시를 사용하지 않고 바로 인스턴스를 생성했을 때 2,298,374ns 가 걸렸다.
하지만 메모리 사용률은 제일 높은 것을 볼 수 있다.
따라서 메모리 사용률을 고려했을 때 가장 성능이 높은 것은 3번째 방법인 Map Value에 Map을 사용
이 가장 성능이 높다고 볼 수 있겠다.
가장 낮은 성능을 보인 것은 1번째 방법인 String으로 Key를 생성
이다.
시간도 가장 오래 걸리고, 메모리 사용률도 높다.
해당 벤치마크는 ConcurrentMap
을 사용하지 않고 HashMap
을 사용하였고, 미리 static
영역에서 카드를 초기화해서 사용했으므로 다른 방식으로 캐싱을 구현한다면 다른 결과가 나올 수도 있다.
하지만 간단하게 캐싱을 사용했을 때의 이점만 본다면 가비지 컬렉터
의 사용을 줄이고, 메모리를 적게 사용하려면 캐싱을 활용하는 것이 바람직한 것을 알 수 있었다.