블랙잭 미션 중 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 영역에서 카드를 초기화해서 사용했으므로 다른 방식으로 캐싱을 구현한다면 다른 결과가 나올 수도 있다.
하지만 간단하게 캐싱을 사용했을 때의 이점만 본다면 가비지 컬렉터의 사용을 줄이고, 메모리를 적게 사용하려면 캐싱을 활용하는 것이 바람직한 것을 알 수 있었다.