|
| 1 | +--- |
| 2 | +title: Redis는 MySQL보다 항상 빠른가요? 아니요. |
| 3 | +categories: [Computer engineering, Backend engineering] |
| 4 | +tags: [backend, spring, java, redis, MySQL, autocorrect, autocomplete, refactoring, performance issue, 백엔드, 스프링, 자바, 레디스, 자동 완성, 성능 개선, 성능 이슈] |
| 5 | +--- |
| 6 | + |
| 7 | +이번 포스팅에서는 이전 포스팅에서 계속 다뤘던 검색어 자동 완성 구현을 위해 Redis와 MySQL을 각각 사용했을 때 성능을 비교하면서 어떤 것을 사용하는 것이 더 좋은지 알아보겠습니다. |
| 8 | + |
| 9 | +## Redis와 MySQL의 성능 비교를 위한 상황 |
| 10 | +**우선 Redis가 좋을지 MySQL이 좋을지 고민하게 된 배경에 대한 소개**를 드리겠습니다. 이전 세 개의 포스팅에 걸쳐 검색어 자동 완성 기능 구현 과정에 대해 공유드렸었는데, 갑작스럽게 요구 사항이 바뀌는 바람에 "어 이러면 Redis 쓰는 의미가 없지 않나?"라는 의구심이 들어 직접 성능 비교를 해보게 되었습니다. |
| 11 | + |
| 12 | +원래 기존의 검색어 자동 완성 기능에 대한 요구 사항은 사용자가 검색한 검색어를 활용해서 DB에서 맨 앞 글자부터 사용자의 검색어와 매칭되는지 여부를 검사하여 필터링하는 방식으로 구현했었는데, 이번에 요구 사항이 바뀌면서 맨 앞 글자가 아니더라도 중간에 사용자의 검색어가 포함되어 있어도 자동 완성의 대상이 되도록 변경해야만 했습니다. 즉 간단히 말하면 String의 Contains()와 같은 로직으로 변경이 됐습니다. |
| 13 | + |
| 14 | +기존에 열심히 Redis 적용시키고 Refactoring 시켰던 것이 아쉽기도해서 Redis를 사용해 Contains()와 같은 로직을 쉽게 구현할 수 있을지 검색해보다가 일단 Redis에는 scan이라는 기능이 있었는데 이를 통해 정규표현식처럼 String 매칭이 가능하다는 것을 알게 되었습니다. 여기까지만 보면 굉장히 희망적이었는데 이때 **1차 고비**가 한번 찾아옵니다. |
| 15 | + |
| 16 | +**그것은 바로 대소문자 구분 여부**입니다. 저희 프로젝트 요구 사항 상 대소문자 구분이 안되어야 하는데, 즉 **case insensitive해야 하는데 Redis 내부적으로 case insensitive와 관련된 기능 제공을 하고 있지 않다**는 것을 알게됩니다. 그래서 **어떻게 이걸 구현할 수 있을지 고민하다가 생각해낸 방법**이 다음 로직입니다. |
| 17 | + |
| 18 | +1. 검색 대상이 되는 모든 가게명을 모두 대문자 혹은 소문자로 변경하고 해당 값을 Redis내 Hash 자료구조의 Key로 저장 |
| 19 | +2. 그리고 대문자 혹은 소문자로 변경하기 전 원래 가게명은 해당 Key와 매핑되는 Value로 저장 |
| 20 | +3. 그 후 Hash Key값에 대한 scan을 통해 Contains 로직 구현 |
| 21 | +4. 조건에 맞는 Hash Key값들은 매핑 되어있는 Value(원래 가게명)을 가져와 List에 저장 |
| 22 | + |
| 23 | +위 로직 그대로 Redis의 Hash 자료 구조를 이용해 구현한 코드를 보며 설명드리겠습니다. |
| 24 | + |
| 25 | +## Redis Hash를 이용한 구현 |
| 26 | +```java |
| 27 | +@Service |
| 28 | +public class RedisHashService { |
| 29 | + private final HashOperations<String, String, String> hashOperations; |
| 30 | + private final RedisTemplate<String, String> redisTemplate; |
| 31 | + |
| 32 | + public RedisHashService(RedisTemplate<String, String> redisTemplate) { |
| 33 | + this.hashOperations = redisTemplate.opsForHash(); |
| 34 | + this.redisTemplate = redisTemplate; |
| 35 | + } |
| 36 | + |
| 37 | + private String key = "autocorrect"; //검색어 자동 완성을 위한 Redis 데이터 |
| 38 | + |
| 39 | + //Hash에 field-value 쌍을 추가하는 메서드 |
| 40 | + public void addToHash(String field, String value) { |
| 41 | + hashOperations.put(key, field, value); |
| 42 | + } |
| 43 | + |
| 44 | + public Set<String> findAllValuesContainingSearchKeyword(String searchKeyword) { |
| 45 | + //Redis에서는 case insensitive한 검색을 지원하는 내장 모듈이 없으므로 searchKeyword는 모두 소문자로 통일하여 검색하도록 구현 |
| 46 | + //당연히 초기 Redis에 field를 저장할 때도 모두 소문자로 변형하여 저장했고 원본 문자열은 value에 저장! |
| 47 | + Set<String> result = new HashSet<>(); //searchKeyword를 포함하는 원래 가게 이름들의 리스트. 최대 maxSize개까지 저장. 중복 허용하지 않고, 자동 사전순 정렬하기 위해 사용 |
| 48 | + final int maxSize = 10; //최대 검색어 자동 완성 개수 |
| 49 | + |
| 50 | + ScanOptions scanOptions = ScanOptions.scanOptions().match("*" + searchKeyword + "*").build(); //searchKeyword를 포함하는지를 검사하기 위한 scanOption |
| 51 | + Cursor<Map.Entry<String, String>> cursor = hashOperations.scan(key, scanOptions); //기존 Redis Keys 로직의 성능 이슈를 해결하기 위해 10개 단위로 끊어서 조회하는 Scan 기능 사용 |
| 52 | + |
| 53 | + while (cursor.hasNext()) { //끊어서 조회하다보니 while loop로 조회 |
| 54 | + Map.Entry<String, String> entry = cursor.next(); |
| 55 | + result.add(entry.getValue()); |
| 56 | + |
| 57 | + if(result.size() >= maxSize) //maxSize에 도달하면 scan 중단 |
| 58 | + break; |
| 59 | + } |
| 60 | + cursor.close(); |
| 61 | + return result; |
| 62 | + } |
| 63 | + |
| 64 | + public void removeAllOfHash() { |
| 65 | + redisTemplate.delete(key); |
| 66 | + } |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +우선 위 코드가 **Redis의 Hash 자료 구조를 사용**하는 서비스 로직입니다. 여기서 메인 로직은 **Case insensitive하게 Redis에서 조회해주는 findAllValuesContainingSearchKeyword 메서드**입니다. 앞서 설명드린 로직과 그대로 구현을 했고 조금 자세히 보면 좋을 부분이, 원래 **Keys라는 Redis 기능을 통해서 Key에 대한 조회를 할 수가 있는데 Redis가 싱글 스레드이다 보니 대규모 데이터에 대해서 Keys로 조회를 하게되면 해당 처리를 하는 동안 계속 Redis를 점유하고 있는 문제가 있어서 Scan이라는 기능이 추가**되었다고 합니다. **Scan은 사용자가 지정한 단위대로 Pagination해서 처리**합니다. **Default 값은 10개**로 되어있습니다. |
| 71 | + |
| 72 | +```java |
| 73 | +public List<String> autocorrect(String keyword) { //검색어 자동 완성 로직 |
| 74 | + Set<String> allValuesContainingSearchKeyword = redisHashService.findAllValuesContainingSearchKeyword(keyword); //case insensitive하게 serachKeyword를 포함하는 가게 이름 최대 10개 반환 |
| 75 | + if(allValuesContainingSearchKeyword.isEmpty()) |
| 76 | + return new ArrayList<>(); |
| 77 | + else |
| 78 | + return new ArrayList<>(allValuesContainingSearchKeyword); //자동 완성 결과가 존재하면 ArrayList로 변환하여 리턴 |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +다음 로직은 **자동 완성 로직에 대한 구현이 담긴 Service 단**의 코드입니다. 단순히 방금 전까지 살펴본 findAllValuesContainingSearchKeyword() 메서드를 통해 얻어진 최대 10개의 자동 완성 키워드를 반환해주는 기능을 합니다. 이렇게 했을 때 **약 51200개의 가게를 가지고 있는 저희 데이터에 대해 어느정도의 Response time이 나오는지 확인**해봤습니다. |
| 83 | + |
| 84 | +{: w="1000" h="800" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"} |
| 85 | + |
| 86 | +위 사진처럼 보시다시피 **Redis를 Full scan할 수 있는 searchKeyword로 검색했을 때 약 2.5초**가 나왔습니다. 여기서 **Full scan할 수 있는 searchKeyword란, 제가 자동 완성 키워드가 10개 만들어지면 조회 loop를 도중에 break하도록 구현해 놓았기 때문에 일부러 10개까지 조회되지 않는 검색어**로 검색하였습니다. |
| 87 | + |
| 88 | +그럼 이번에는 **성능 비교를 위해 MySQL의 like 쿼리를 사용해서 조회**해보겠습니다. |
| 89 | + |
| 90 | +## MySQL LIKE를 이용한 구현 |
| 91 | +```java |
| 92 | +public List<String> findAllBySearchKeyword(String searchKeyword) { |
| 93 | + return em.createQuery("select s.displayName from Store s where s.displayName like concat('%', :searchKeyword, '%')", String.class) |
| 94 | + .setParameter("searchKeyword", searchKeyword) |
| 95 | + .setMaxResults(10) |
| 96 | + .getResultList(); |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +이번에는 서비스쪽 로직은 그냥 Query 결과를 반환해주는 거 밖에 없을 정도로 간단해서 생략하고 **메인 로직인 Repository 단**의 코드를 가져왔습니다. 쿼리도 별로 어려울 것 없이 **LIKE를 활용해서 사용자의 검색어가 포함되는 가게명들을 조회하였고 like를 활용한 pattern matching을 편리하게 하기 위해 concat을 사용해서 검색어 앞뒤에 %를 붙여줬습니다.** |
| 101 | + |
| 102 | +사실 이 쿼리만 봐도 누가 빠를지 대충 예상이 되긴합니다. **MySQL에는 인덱싱을 적용할 수 있기도하고 현재 Repository 단에서부터 애초에 최대 10개로 Limit를 걸고 filtering해서 가지고 오기 때문에 추후 Service 로직에서 그 많은 모든 데이터에 대해 인스턴스화해서 가지고 있을 필요가 없습니다. 이에 따른 메모리나 성능적인 이점 또한 매우 클 것입니다. 그리고 무엇보다 직접 대문자 혹은 소문자화 해가면서 Case insensitive하게 만드는 로직을 거칠 필요도 없습니다. 왜냐하면 MySQL 쿼리 내 like 절은 애초에 Case insensitive하게 동작하기 때문**입니다. |
| 103 | + |
| 104 | +{: w="1000" h="800" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"} |
| 105 | + |
| 106 | +실제로 Response time을 체크해보면 위 사진과 같이 **약 0.5초** 밖에 걸리지 않습니다. **무려 Redis를 사용해서 걸렸던 시간의 20%** 밖에 안걸립니다. |
| 107 | + |
| 108 | +## Redis VS MySQL 결론 |
| 109 | +지금까지 살펴본 성능 분석 결과를 정리해보면 다음과 같습니다. |
| 110 | + |
| 111 | +**Case insensitive하게 특정 문자열이 포함되는 값들을 Full scan(전수 조사)해야 되는 상황**이 있었습니다. 이때, **Redis에서는 이러한 기능을 내장하여 제공하고 있지 않기 때문에 별도의 추가 로직 필요하다는 점** 그리고 **Redis의 경우 데이터를 Filtering해서 사이즈를 대폭 줄여줄 수 있는 limit 로직을 Service 단보다 더 이른 시점인 Repository 단에서 걸어줄 수 없다는 점들이 성능 저하의 요인**이 되었습니다. |
| 112 | + |
| 113 | +**특히 대용량 데이터의 Filtering 시점이 늦어짐에 따라 대량의 Repository 조회 결과를 고스란히 인스턴스화시켜서 Service 단까지 가져오고 이를 전체 조회해야 된다는 점이 성능 저하의 주된 요소**로 보여집니다. |
| 114 | + |
| 115 | +그래서 **결론은, "인메모리 DB이므로 Redis는 항상 빠를 것이다."라는 생각은 굉장히 잘못된 일반화의 오류이며 Redis의 특징인 Key-Value 자료 구조의 장점이 잘 부각될 수 있도록 캐싱이나 O(1) 조회 등이 필요한 상황에만 적용해서 사용하는 것이 바람직**할 것입니다. |
| 116 | + |
| 117 | + |
| 118 | + |
0 commit comments