본문 바로가기

Project/Hello-World (Language Exchange)

대용량 트래픽 속에서도 무거운 DB 조회 연산을 효율적으로 처리하는 방법은 무엇이 있을까요? (2)

INTRO

안녕하세요. 오늘도 다시 찾아온 Soo입니다. 지난시간에는 DB에서 무거운 연산을 좀 더 효율적으로 처리하기 위해서 프로젝트를 진행하면서 지금까지 MySQL에 대해 학습한 내용을 바탕으로 EXPLAIN을 활용해 작성한 쿼리의 실행계획을 조회하고, 비효율적인 결과가 나타나는 부분을 찾아서 쿼리를 튜닝하는 시간을 가졌습니다. 그렇지만 여전히 문제가 되는 부분이 존재하는데 바로 조인 연산이 여전히 많이 필요하다는 점입니다.  이번 글에서는 이러한 문제점을 어떻게 해결해보았는지에 대해 작성해보고자 합니다.

 

조인연산을 어떻게 줄일 수 있을까?

지난 시간에 튜닝을 끝낸 쿼리를 다시 살펴보겠습니다. 각 이름들을 매핑시키기 위해서 총 다섯 번의 조인이 사용되었습니다. 조인은 그 특성상 당연히 단일 테이블을 가지고 연산할 때보다 부하가 더 가해질 수밖에 없습니다. 때문에 쿼리 자체를 좀 더 개선할 수 있으면 좋았겠지만 이미 인덱싱과 실행 계획은 최대한으로 개선했기 때문에 남은 방법은 가능하다면 불필요한 조인을 줄이는 것이었습니다. 안타깝게도 각각의 이름을 가져오는 것은 필수 작업이었고, 때문에 조인을 없앨 수 있는 방법이 쉽게 떠오르지 않았습니다. 계속 고민을 하던 찰나에 주목할 만한 점을 한 가지 발견하게 되었습니다.

바로 조인을 필요로하는 '이름' 데이터들의 특성이었습니다. 각 국가와 국가에 속하는 도시 그리고 언어에 대한 정보를 담은 데이터는 서비스를 시작하기 전에 기본적으로 미리 정해진 채로 DB에 저장되어야 합니다. 이렇게 한 번 저장된 데이터 들은 서비스 정책이나 차후 사용자의 요구에 따라 그 수가 변경될 수는 있겠지만 그 가능성이 매우 적습니다. 이런 특성을 발견하고 나니 머릿속에 스치는 생각이 있었습니다. 

'캐싱'

캐싱이란 RAM과 같이 빠르게 액세스할 수 있는 하드웨어에 무거운 연산 결과 등을 저장해서 재사용하는 방식을 말합니다. 물론 캐싱을 적용했을 때 제대로 된 성능 향상이 이루어지기 위해서는 Cache Hit율을 높여야 합니다. 하지만 캐싱을 잘 활용한다면 아래의 그림처럼 DB 연산시 발생하는 커넥션, 디스크 I/O 및 추가적인 연산비용까지도 모두 없애게 되므로 드라마틱한 성능의 증가를 가져올 수 있는 방법이기도 합니다.  

연산속도 비교

현재 캐싱하려는 데이터는 위에서 이미 인지했듯이 거의 바뀌지않는 정보입니다. 때문에 거의 매번 Cache Hit가 이루어질 것이며, Cache Miss는 거의 고려할 필요가 없을 것입니다. 또한 맵을 활용해 캐시 저장소에 각각의 id, 이름을 <Key, Value>의 형태로 미리 담아 둔다면 DB에서의 조인 연산은 딱 한 번이면 해결할 수 있게 됩니다.  

 

글로벌 캐싱 vs 로컬 캐싱

캐싱을 위해 활용할 수 있는 방법은 두 가지가 있습니다. 바로 로컬 캐싱과 글로벌 캐싱입니다. 로컬 캐싱이란 서버 자체에 캐시한 데이터를 저장해두는 것입니다. 서버 내부에서 작동하기 때문에 외부 저장소로 연결할 필요가 없어 네트워크 IO나 지연 문제와 같은 추가적인 비용이 발생하지 않습니다. 물론 캐싱 자체가 DB에서 모든 연산을 거쳐 값을 반환 받는 것보다 빠르지만, 로컬 캐싱은 위의 이유로 더 빠른 속도를 자랑합니다. 그러나 모든 선택지가 Trade-Off를 갖고 있듯, 로컬 캐싱 또한 마찬가지로 고려할 만한 다음의 단점을 갖고 있습니다.

가장 먼저 자원관리 문제입니다. 서버 내부에 데이터를 저장한다는 말은 캐시된 데이터가 현재 구동중인 서버와 같은 heap 공간을 공유한다는 말과 일맥상통합니다. 캐싱된 데이터가 많아질수록 서버가 사용할 수 있는 메모리의 양이 줄어들기 때문에 GC의 발생횟수가 급격히 늘어날 수 있으며, 최악의 경우에는 Out Of Memory Error(OOME)가 발생해서 서버가 죽어버릴 수 있습니다. 때문에 캐싱할 데이터의 용량을 계산해보는 작업과 캐싱에 활용 될 메모리의 사용량 자체를 제한해주는 작업이 필요합니다.

다음으로는 서비스에 사용되고 있는 서버가 여러개인 경우에는 데이터의 중복된 캐싱이 발생한다는 점입니다. 각 서버는 별도의 메모리를 가지고 각각 데이터를 따로 캐싱합니다. 때문에 서버로 요청이 들어오는지와 상관 없이 캐싱한 결과를 알맞게 반환하기 위해서는 모든 서버가 같은 데이터를 캐싱해서 가지고 있어야 합니다. 마지막은 그럼에도 데이터의 동기화 문제가 발생한다는 부분입니다. 각 서버는 별도의 캐싱 공간을 가지고 있기 때문에 캐싱한 데이터에 업데이트가 발생했을 때, 해당 내역이 모든 서버에 반영된다는 보장이 없습니다. 때문에 몇몇 서버에서는 업데이트가 반영이 안된 stale한 데이터를 가지고 있어 요청이 어느 서버로 전달되는지에 따라 응답 결과가 결과가 달라질 수도 있게 됩니다.

글로벌 캐싱은 로컬 캐싱과는 다르게 외부에 캐시 저장소를 하나 두고 캐싱된 값에 대한 요청이 들어오면 이 저장소에서 결과를 찾은 뒤 반환하는 방법을 말합니다. 별도의 서버를 하나 더 활용하는 만큼 서버 자체의 제약 사항에서 성공적으로 분리될 수 있습니다. 예를 들면, 어떤 서버가 재기동될 때 해당 서버는 기본적으로 메모리에 갖고 있던 데이터들을 모두 잃게 되는데요. 캐싱된 데이터 또한 예외는 아닙니다. 이때 글로벌 캐싱은 서버 내부에 저장된 데이터와 아무런 관련이 없으므로, 여러 이유로 서버가 재시작된다고 하더라도 곧바로 원격 저장소에서 캐싱된 데이터를 가져올 수 있게 됩니다. 

또한 로컬 캐싱이 갖고있던 동시성에 대한 문제점을 손쉽게 해결할 수 있다는 장점도 있습니다. 캐싱된 데이터에 변동이 발생하더라도 이에 대한 내역을 글로벌 캐시 저장소에만 반영하면 모든 서버가 항상 똑같은 캐시 데이터를 가져올 수 있게 됩니다.  하지만 글로벌 캐싱은 로컬 캐싱에 비해 속도가 느릴 수밖에 없다는 단점이 있습니다. 외부에 있는 서버에 접속해서 데이터를 받아와야하기 때문입니다. 즉, 네트워크를 활용하기 때문에 RTT(Round Trip Time)가 중요해질 수밖에 없습니다. 또, RDB를 활용하는 것보다는 여전히 빠르겠지만 통제할 수 없는 외부요인(네트워크 지연여부)에 의해 응답속도의 저하가 발생할 수 있고, 심지어는 가져올 데이터를 직렬화하는 시간 또한 응답속도에 영향을 줄 수 있습니다. 

 

선택

위에서 언급한 두 캐싱 방법 모두 뚜렷한 장점이 있습니다. 때문에 어떤 쪽을 선택하든 모두 장단점을 갖고 있기 때문에 선택이 쉽지 않았기에, 캐싱하려는 데이터의 특징과 용량을 어림잡아 계산해보기로 했습니다. 특징은 위에서도 언급을 했지만 국가, 도시, 언어이름 같은 경우는 이미 정해져 있는 자료들이기 때문에 한 번 선택이되면 추가나 삭제가 거의 이루어지지 않을 가능성이 높습니다. 저는 이것을 서버 간 캐싱된 데이터의 정합성 문제는 크게 고려할 필요가 없겠다는 의미로 받아들였습니다. 

다음으로는 저장할 데이터의 총 용량을 계산해 보았습니다. 글의 길이상 고민해서 답변했던 내용은 사진으로 대체하고, 계산 결과만을 간단하게 언급하도록 하겠습니다. 이름을 영어로 표기하는 경우는 ASCII 문자를 사용할 수 있기 때문에 1byte, 아닌 경우는 넉넉하게 4byte 정도로 잡았습니다. 그리고 최대한 많은 국가, 도시, 언어 이름을 담을 수 있으면 좋겠지만 서버의 자원은 한정되어 있으므로 대표적인 것들만 추리고 나머지는 others 정도로 처리하기로 결정하니 전체를 캐싱해도 총합 700 ~ 800KB 정도밖에 나오지 않았습니다.  

그래서 위의 데이터를 각각 서버에 따로 저장해도 큰 무리가 없을 것이라 결론을 내리게 되었고, 프로필 조회 자체가 여러 데이터가 다소 복잡하게 이어져 있는 꽤나 무거운 연산인 만큼 네트워크 비용이라도 줄여서 조금이나마 성능의 향상을 더 가져올 수 있을 것이라 생각해서 로컬 캐싱을 선택하였습니다.  

로컬 캐싱 내에서도 여러 솔루션이 존재했습니다. 그런데, 비록 로컬 캐싱이라는 결론에 도달하기까지의 과정은 꽤나 복잡한 편이었지만 실제 캐싱할 데이터 자체는 사실 매우 단순합니다. 그래서 복잡한 솔루션이 아닌 단순한 라이브러리를 사용하는 것 만으로도 충분하다고 생각했습니다. 때문에 적용과정이 복잡하지는 않으면서도 성능이 보장되어 있는 구글의 구아바 캐시(Guava Cache)를 적용하기로 했습니다. 그러다 구아바를 토대로 만들어진 카페인 캐시(Caffeine Cache)에 대해서 알게 되었고, 스프링 자체적으로도 최근에 구아바 대신 카페인으로 전환했다는 점과 제공된 벤치마킹 결과를 보니 카페인이 성능 상의 우위를 보여줬기에 최종적으로는 카페인 캐시를 적용하기로 결정하게 되었습니다.

 

선택이 끝났으니 캐싱을 적용해 조인 연산을 줄여봅시다!

1. 카페인 캐시에 대한 의존성을 pom.xml에 추가

카페인 캐시의 의존성은 이미 spring-boot-starter-parent에 의해서 관리가 되므로 버전을 따로 명시해줄 필요 없습니다. groupId와 artifactId만 추가해주면 스프링 부트에서 자동으로 부모 pom 파일에 있는 버전을 읽어와서 적용해 줍니다. 

1
2
3
4
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
cs

 

2. 캐싱할 데이터를 DB에서 불러올 코드 작성

MyBatis를 통해 DB에서 필요한 데이터를 가져올 SQL 쿼리를 작성했습니다. countries, towns, languages 테이블 모두 id와 이름에 대한 정보만 가지고 있으므로 쿼리 자체는 매우 간단합니다.

1
2
3
4
5
6
7
8
9
10
11
<select id="fetchAllCountries" resultType="java.util.HashMap">
    SELECT id, name FROM countries
</select>
 
<select id="fetchAllTowns" resultType="java.util.HashMap">
    SELECT id, name FROM towns
</select>
 
<select id="fetchAllLanguages" resultType="java.util.HashMap">
    SELECT id, name FROM languages
</select>
cs
 
1
2
3
4
5
6
7
8
9
10
@Mapper
public interface CacheMapper {
    
    public List<Map<String, Object>> fetchAllCountries();
 
    public List<Map<String, Object>> fetchAllTowns();
 
    public List<Map<String, Object>> fetchAllLanguages();
}
 
cs

 

3. Map<Integer, String> 값을 반환할 수 있도록 변환해주는 helper 메소드 작성

불러온 데이터를 다시 id에 맞는 name 값을 조회할 수 있도록 하기 위해서는 id를 key로 하고, name을 값으로 하는 맵의 형태로 데이터의 구성이 갖춰질 필요가 있었습니다. 하지만 가져온 데이터의 형식을 보니 "column 이름, 해당 column에 있는 값" (ex. id: 1, name: South Korea) 형태로 값이 리턴되는 것을 발견하였습니다. 그래서 MyBatis가 반환해주는 값은 List<Map<String, Object>>로 하고 애플리케이션 단에서 이를 다시 변환해주기로 결정하였습니다. 따라서, 이러한 책임을 담당해줄 helper 메소드가 필요했습니다.

1
2
3
4
5
6
7
8
private Map<Integer, String> mapConverter(List<Map<String, Object>> maps) {
    Map<Integer, String> convertedMap = new HashMap<>();
 
    maps.forEach(map ->
            convertedMap.put((Integer) map.get("id"), (String) map.get("name")));
 
    return convertedMap;
}
cs

 

4. 인스턴스 변수인 cache를 추가하고, 카페인 캐시를 이용해 이를 초기화

캐시에 담겨질 데이터는 국가, 도시, 언어의 세 종류였으므로 이를 key로 하고 해당 key가 호출될 때 데이터가 이미 캐싱되어 있는 상태라면 저장되어 있는 Map을 가져올 수 있도록 했습니다. 그리고 아직 캐싱되기 전이라면 key 값에 맞는 데이터를 DB에서 가져온 뒤 mapConverter를 이용해 변환하여 캐시에 로드되도록 CacheLoader를 추가하였습니다. 설정 값으로는 캐싱이 잘 작동하고 있는지, cache hit가 잘 이루어지는지 파악할 수 있도록 recordStats를 추가하였으며, 캐싱된 데이터는 변할 확률이 거의 없기 때문에, 넉넉하게 만료 시간을 24시간으로 설정해 주었습니다.

 

결과

특정 사용자의 프로필 조회를 위해 작성한 Service 계층의 코드는 아래와 같습니다. (글을 작성하는데 있어 편의상 유저 프로필을 조회하는 메소드를 맨 밑에 두었습니다.) 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.example.proxypractice.caffeine;
import com.example.proxypractice.mapper.CacheMapper;
import com.google.common.cache.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static com.example.proxypractice.caffeine.CacheKeys.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class CacheServicePractice {

    private final CacheMapper cacheMapper;

   private final LoadingCache<String, Map<Integer, String>> cache = CacheBuilder.newBuilder()
            .expireAfterWrite(24, TimeUnit.HOURS)
            .recordStats()
            .build(new CacheLoader<String, Map<Integer, String>>() {
                @Override
                public Map<Integer, String> load(String key) throws Exception {
                    return loadData(key);
                }
            });

    private Map<Integer, String> loadData(String key) {
        switch (key) {
            case COUNTRY:
                return mapConverter(cacheMapper.fetchAllCountries());
            case TOWN:
                return mapConverter(cacheMapper.fetchAllTowns());
            case LANGUAGE:
                return mapConverter(cacheMapper.fetchAllLanguages());
            default:
                throw new IllegalArgumentException("존재하지 않는 키에 대한 데이터를 가져올 수 없습니다.");
        }
    }

    private Map<Integer, String> mapConverter(List<Map<String, Object>> maps) {
        return maps.stream().collect(
                Collectors.toMap(
                        id -> (Integer) id.get("id"),
                        name -> (String) name.get("name")
                ));
    }

    private List<LanguageBuilder> languageNameConverter(List<Language> languages, Map<Integer, String> langMap) {
        return languages.stream().map(
                language -> LanguageBuilder.builder()
                                .name(langMap.get(language.getId()))
                                .level(language.getLevel())
                                .status(language.getStatus())
                                .build())
                .collect(Collectors.toList());
    }

    public CacheStats getCacheStats() {
        return cache.stats();
    }

    public UserProfileBuilder inquireUserProfile(String userId) throws ExecutionException {
        UserProfile profile = cacheMapper.getSingleProfile(userId).orElseThrow(() -> new IllegalArgumentException("Hello Exception"));
        Map<Integer, String> cachedCountryMap = cache.get(COUNTRY);
        Map<Integer, String> cachedTownMap = cache.get(TOWN);
        Map<Integer, String> cachedLangMap = cache.get(LANGUAGE);
        log.warn(getCacheStats().toString());
        return UserProfileBuilder.create(profile, cachedCountryMap.get(profile.getOriginCountry()),
                cachedCountryMap.get(profile.getLivingCountry()), cachedTownMap.get(profile.getLivingTown()),
                languageNameConverter(profile.getLanguages(), cachedLangMap));
    }
}
 
cs

유저 프로필에 관한 정보를 먼저 받아온 뒤 추가한 캐시에서 값을 불러와 유저 프로필 데이터가 담고 있는 국가, 도시, 언어에 대한 ID 값을 매핑하고 이러한 과정이 적용된 값을 리턴함으로써 프로필 조회 시 다음과 같이 이름 데이터가 올바르게 적용될 수 있도록 했습니다. 그리고 이때 stats를 활용해 cache miss와 hit에 관한 데이터를 출력하는 로그를 추가함으로써 캐시가 올바르게 작동하고 있음을 확인할 수 있도록 했습니다. 다행히 첫 요청을 제외한 나머지가 모두 cache hit 되는 것을 체크할 수 있었고, 응답으로 받은 JSON도 캐싱 적용이 되어 국가, 도시, 언어에 숫자 값이 아닌 이름 값을 보여주는 것을 확인할 수 있었습니다.

캐싱 적용 전 응답
캐싱 적용 후 응답

 

OUTRO

캐싱을 적용함으로써 맨 처음에 복잡하고 조인 연산이 여러번 사용되는 쿼리를 다음과 같이 조인 연산을 한 번만 사용해도 같은 결과를 가져올 수 있는 더 단순한 쿼리로 변모시킬 수 있었습니다.

처음 쿼리
캐싱 적용 후 쿼리

그러나 단순하게 위의 방식으로 캐싱을 적용한다면 스프링이 추구하는 목표에서 어긋나는 문제가 발생하게 됩니다. 다음 장에서는 해당 문제가 무엇인지 알아보고 이를 어떻게 해결할 수 있는지 살펴보는 시간을 갖도록 하겠습니다.

 

Reference)

chagokx2.tistory.com/98 (사진참조)

 

Project)

github.com/f-lab-edu/Hello-World

 

f-lab-edu/Hello-World

언어교환 상대찾기 서비스. Contribute to f-lab-edu/Hello-World development by creating an account on GitHub.

github.com