여러 종류의 Cache 구현별 성능 측정

Glen·2023년 3월 11일
0

배운것

목록 보기
1/37

개요

블랙잭 미션Card 도메인에 대해 캐싱을 적용해본 적이 있었다.
Card 도메인의 특징 중 하나는 열거형처럼 지정된 종류의 카드가 정해져 있어 게임에서 존재하는 Card의 개수는 SuitRank카테시안 곱과 같다.

따라서 미리 모든 Card를 만들어 두거나, 새롭게 Card를 생성했을 때 어딘가에 보관해 다음번에 똑같은 문양의 카드를 생성할 때 미리 생성해 둔 카드를 반환하면 성능상의 이점을 챙길 수 있다. 즉 캐싱을 적용하는 것이다.

캐싱된 Card를 찾으려면 Key가 있어야 하는데, Card의 Key는 SuitRank 총 2개이다.
따라서 Key를 지정하는 방법은 여러 가지가 있는 데 우선 3가지 방법을 소개한다.


1. String으로 Key를 생성

SuitRank의 특정 문자열을 조합하여 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를 꺼내온다.

2. hashCode로 Key를 생성

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));
}

카드를 꺼내올 때 SuitRank의 hashCode로 Key를 저장한다.
이때 주의할 점은 Card에도 hashCode 메서드를 재정의해줘야 한다.

3. Map의 Value에 Map을 사용

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 영역에서 카드를 초기화해서 사용했으므로 다른 방식으로 캐싱을 구현한다면 다른 결과가 나올 수도 있다.

하지만 간단하게 캐싱을 사용했을 때의 이점만 본다면 가비지 컬렉터의 사용을 줄이고, 메모리를 적게 사용하려면 캐싱을 활용하는 것이 바람직한 것을 알 수 있었다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글