본문 바로가기
Project/DelFood

[이슈 #7] 서버 부하를 줄이기 위한 캐싱 적용

by EricJeong 2019. 11. 21.

부하 증가 고려

저번 이슈에서 주소 데이터 조회 속도를 인덱스를 적용하여 개선하였습니다.

하지만 주소 조회를 계속해서 진행할수록 DB성능이 점점 떨어지는 것이 느껴졌고 이를 개선하기 위하여 주소 캐시를 적용하기로 하였습니다.

 

인덱스 적용 포스팅

https://deveric.tistory.com/68

 

[이슈 #6] 주소 데이터의 빠른 조회를 위해 인덱스 설정하기

얼마 전 주소데이터를 DB에서 관리하도록 변경하였는데, 데이터가 100,000,00건정도 되어서 검색이 정말 느렸습니다. 인덱스를 걸지 않은 상태로 조회를 하면 검색에만 10초가 넘게 걸리는 무시무시한 상황이었습..

deveric.tistory.com

 

캐싱 전략별 특징

Local Cache와 Global Cache중 어떤 전략을 사용할지 생각해보았습니다.

 

Local Cache의 특징

 

 

- 서버마다 캐시를 따로 저장합니다.

- 로컬 서버의 리소스를 사용합니다.

- 서버 내에서 작동하기 때문에 속도가 빠릅니다.

- 다른 서버의 캐시를 참조하기 어렵습니다.

 

 

Global Cache의 특징

 

- 여러 서버에서 캐시 서버를 참조합니다.

- 네트워크 트래픽을 사용해야 해서 로컬 캐시보다는 느립니다.

- 서버간 데이터 공유가 쉽습니다.

 

캐싱 전략 선택

서버간 캐시 데이터를 통합하여 사용하기 위해 Global Cach 전략을 사용하기로하였습니다.

 

 

 

마침 DelFood에서는 세션 저장을 위한 Redis서버를 사용하고 있었습니다. Redis서버를 로그인 세션 저장에만 사용하기보다는 글로벌 캐싱에도 적용하여 사용하는 것이 자원을 효율적으로 사용할 수 있을것이라 판단하였습니다.

 

캐싱 데이터는 지연로딩과 TTL을 사용하여 데이터의 생명 주기를 관리하는 것이 좋을것 같습니다.

 

 

캐싱을 적용할 대상 탐색

캐시를 적용하기에 적합한 데이터는 다음과 같습니다.

  • 반복적이고 동일한 결과가 나오는 기능의 반환값
  • 업데이트가 자주 발생하지 않는 데이터
  • 자주 조회되는 데이터
  • 입력값과 출력값이 일정한 데이터

캐싱된 데이터는 데이터 갱신으로 인해 DB와 불일치가 발생할 수 있습니다. 그렇기 때문에 데이터 Update가 잦게 일어나거나 데이터 불일치시 비즈니스 로직 상 문제가 발생할 수 있는 기능은 캐싱 대상으로 적합하지 않습니다.

 

Delfood에서 최우선 캐싱 대상은 '주소 검색'입니다. 데이터양이 방대하여 DB서버에 부하를 줄 수 있고, 데이터 update또한 거의 일어나지 않습니다. 같은 검색조건이라면 같은 결과가 반환됩니다.

 

캐싱 적용을 위한 설정

Spring boot를 실행하는 상위 클래스에 @EnableCaching 어노테이션을 적용해야합니다. 해당 어노테이션을 적용하면 Spring에서 Cache에 관련한 어노테이션을 스캔하여 캐싱 적용을 진행합니다.

 

@EnableCaching 적용

@EnableCaching // Spring에서 Caching을 사용하겠다고 선언한다.
public class FoodDeliveryApplication {

  public static void main(String[] args) {
    SpringApplication.run(FoodDeliveryApplication.class, args);
  }

}

 

 

 

SpringCacheManager에 RedisCacheManager 주입

RedisCacheManager는 SpringFramework의 CacheManager를 구현합니다. Spring에서는 RedisCacheManager를 Bean으로 등록하면 기본 CacheManager를 RedisCacheManager로 사용합니다.

 

RedisCacheManager Bean 등록

  /**
   * Redis Cache를 사용하기 위한 cache manager 등록.<br>
   * 커스텀 설정을 적용하기 위해 RedisCacheConfiguration을 먼저 생성한다.<br>
   * 이후 RadisCacheManager를 생성할 때 cacheDefaults의 인자로 configuration을 주면 해당 설정이 적용된다.<br>
   * RedisCacheConfiguration 설정<br>
   * disableCachingNullValues - null값이 캐싱될 수 없도록 설정한다. null값 캐싱이 시도될 경우 에러를 발생시킨다.<br>
   * entryTtl - 캐시의 TTL(Time To Live)를 설정한다. Duraction class로 설정할 수 있다.<br>
   * serializeKeysWith - 캐시 Key를 직렬화-역직렬화 하는데 사용하는 Pair를 지정한다.<br>
   * serializeValuesWith - 캐시 Value를 직렬화-역직렬화 하는데 사용하는 Pair를 지정한다. 
   * Value는 다양한 자료구조가 올 수 있기 때문에 GenericJackson2JsonRedisSerializer를 사용한다.
   * 
   * @author jun
   * @param redisConnectionFactory Redis와의 연결을 담당한다.
   * @return
   */
@Bean
  public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory,
      ObjectMapper objectMapper) {
    RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
        .disableCachingNullValues()
        .entryTtl(Duration.ofSeconds(defaultExpireSecond))
        .serializeKeysWith(
            RedisSerializationContext.SerializationPair
            .fromSerializer(new StringRedisSerializer()))
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair
            .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)));
    
    return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory)
        .cacheDefaults(configuration).build();
  }

 

캐시 어노테이션

어노테이션을 적용하여 메서드의 반환값을 캐시에 저장하거나 삭제할 수 있습니다. @Cacheable, @CacheEvict 를 사용하여 캐시 설정을 할 수 있습니다.

 

@Cacheable

- 메서드의 반환값을 캐시에 저장합니다.

 

@Cacheable은 조회 메서드에 적용할 수 있습니다. 한번 캐시에 저장된다면 해당 메서드를 실행하기 전 캐시 저장소를 확인합니다. 캐싱 데이터가 있다면 메서드를 실행하지 않고 캐시 데이터를 반환합니다.

 

 

@CacheEvict

- 해당 어노테이션이 지정하는 키에 해당하는 캐시를 제거합니다.

 

@CacheEvict는 데이터 변경이 일어날 때 적용하는 것이 좋습니다. 캐싱 데이터와 DB의 불일치성이 발생할 수 있기 때문에 캐싱 데이터의 update, delete가 발생할 경우 해당 어노테이션을 적용하여 캐싱 데이터를 제거해야합니다.

 

캐시를 적용한 데이터의 update, delete, insert등 변경이 발생할 때 반드시 CacheEvict를 모두 적용해주어야합니다. 하나라도 적용되지 않은 상태로 캐싱이 이루어진다면 정보의 불일치가 발생하여 비즈니스 오류가 날 수 있으니 주의해야합니다.

 

캐싱 적용

우선적으로 주소 검색 로직에 캐싱을 적용해 보았습니다. 주소 검색은 도로명 주소 검색, 지번 주소 검색으로 나뉩니다.

 

지번 검색시 받는 데이터

  1. 동 이름
  2. 건물 본번
  3. 건물 부번
  4. 건물 이름
  5. 마지막으로 검색한 건물번호(페이징)

도로명 검색시 받는 데이터

  1. 도로명 이름
  2. 건물 본번
  3. 건물 부번
  4. 건물 이름
  5. 마지막으로 검색한 건물번호(페이징)

 

지번과 도로명 검색시 받는 데이터중 중복되는 2~5번 데이터를 모아 부모 클래스를 제작하였습니다.

package com.delfood.controller.reqeust;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public abstract class GetAddressRequestBase {
  protected Integer buildingNumber;
  protected Integer buildingSideNumber;
  protected String buildingNameForCity;
  protected String lastSearchBuildingManagementNumber;
  
  protected abstract String generateKey();
  
  /**
   * 캐싱을 진행할 키를 제작한다.
   * @author jun
   * @return
   */
  public String getKey() {
    return "buildingNumber:" + this.buildingNumber + "/"
        + "buildingSideNumber:" + this.buildingSideNumber + "/"
        + "buildingNameForCity" + this.buildingNameForCity + "/"
        + "lastSearchBuildingManagementNumber" + this.lastSearchBuildingManagementNumber + "/"
        + generateKey();
  }
}

 

 

해당 추상클래스를 상속받는 도로명, 지번 검색 Request Class를 제작합니다.

@Getter
@Setter
public class GetAddressByZipRequest extends GetAddressRequestBase {
  @NotNull
  private String townName;
  
  
  protected String generateKey() {
    return "townName:" + this.townName;
  }
}
@Getter
@Setter
public class GetAddressesByRoadRequest extends GetAddressRequestBase {
  @NotNull
  private String roadName;
  
  protected String generateKey() {
    return "roadName" + roadName;
  }
}

 

 

해당 주소 검색을 받는 Controller를 제작합니다.

  /**
   * 도로명 주소를 검색한다.
   * 
   * @author jun
   * @param requestInfo 검색할  도로명 주소 정보.
   * @return
   */
  @GetMapping("address/road")
  public List<AddressDTO> getAddressByRoadInfo(
      GetAddressesByRoadRequest requestInfo) {
    List<AddressDTO> addresses = addressService.getAddressByRoadName(requestInfo);
    return addresses;
  }
  
  
  /**
   * 지번 주소를 검색한다.
   * 
   * @author jun
   * @param requestInfo 검색할 지번 주소 정보.
   * @return
   */
  @GetMapping("address/zip")
  public List<AddressDTO> getAddressByZipInfo(
      GetAddressByZipRequest requestInfo) {
    List<AddressDTO> addresses = addressService.getAddressByZipAddress(requestInfo);
    return addresses;
  }

 

 

마지막으로 서비스 메서드에 @Cacheable을 적용하였습니다.

  @Cacheable(value = "ADDRESS_SERCH_ZIP", key = "#searchInfo.getKey()")
  public List<AddressDTO> getAddressByZipAddress(GetAddressByZipRequest searchInfo) {
    return addressMapper.findByZipName(searchInfo);
  }

  @Cacheable(value = "ADDRESS_SERCH_ROAD", key = "#searchInfo.getKey()")
  public List<AddressDTO> getAddressByRoadName(GetAddressesByRoadRequest searchInfo) {
    return addressMapper.findByRoadName(searchInfo);
  }

 

 

이제 해당 로직에 캐싱 적용이 되는지 확인할 차례입니다. 통합테스트 코드를 작성해서 테스트를 진행하고 싶지만, JUnit에 관한 내용을 아직 공부중이라 Postman, Spring Log로 테스트를 대체하였습니다.

 

Postman 검색

 

 

 

Spring Log

.....
|-----------|----------|------------------|----------|---------------|----------|----------------|---------------------|---------|---------------------------|-----------------------|-------------------------|-------------------------|-----------------------------------|---------------------|-----------------------------------|-----------------------------------|------------------|------------------|
|town_code  |city_name |city_country_name |town_name |road_name_code |road_name |building_number |building_side_number |zip_code |building_management_number |building_name_for_city |administrative_town_code |administrative_town_name |classification_apartment_buildings |detail_building_name |building_center_point_x_coordinate |building_center_point_y_coordinate |exit_x_coordinate |exit_y_coordinate |
|-----------|----------|------------------|----------|---------------|----------|----------------|---------------------|---------|---------------------------|-----------------------|-------------------------|-------------------------|-----------------------------------|---------------------|-----------------------------------|-----------------------------------|------------------|------------------|
|1120011400 |서울특별시     |성동구               |성수동1가     |112004109323   |서울숲길      |34              |0                    |04768    |1120011400106760009027880  |현대아파트                  |1120066000               |성수1가제2동                  |1                                  |103동                 |959451.025966                      |1949922.563283                     |959468.189953     |1949937.992466    |
|1120011400 |서울특별시     |성동구               |성수동1가     |112004109323   |서울숲길      |34              |0                    |04768    |1120011400106760009027881  |현대아파트                  |1120066000               |성수1가제2동                  |0                                  |관리동                  |959478.961402                      |1949920.639369                     |959468.189953     |1949937.992466    |
|-----------|----------|------------------|----------|---------------|----------|----------------|---------------------|---------|---------------------------|-----------------------|-------------------------|-------------------------|-----------------------------------|---------------------|-----------------------------------|-----------------------------------|------------------|------------------|

INFO  19-11-22 14:10:50 [log4j2:197] - 1. ResultSet.next() returned false
DEBUG 19-11-22 14:10:50 [findByRoadName:143] - <==      Total: 2
INFO  19-11-22 14:10:50 [log4j2:197] - 1. ResultSet.close() returned void
INFO  19-11-22 14:10:50 [log4j2:197] - 1. Connection.getMetaData() returned org.mariadb.jdbc.MariaDbDatabaseMetaData@6253611e
INFO  19-11-22 14:10:50 [log4j2:197] - 1. PreparedStatement.close() returned 
INFO  19-11-22 14:10:50 [log4j2:197] - 1. Connection.clearWarnings() returned 

 

그리고 Spring Log를 모두 Clear한 후 로그가 찍히는지 다시 확인해보았습니다.

 

 

Postman 재요청

응답시간이 매우 빨라졌습니다. 130ms -> 8ms로 바뀌었네요

 

 

Spring Log

 

그 어떤 로그도 찍히지 않았습니다.

 

 

Redis cli

도로명 주소 검색으로 정확히 캐시가 저장된 것을 확인할 수 있었습니다. 한글이 깨지는 것은 인코딩 문제인것같은데 추후 이 이슈를 해결해야 할것 같습니다.

 

 

 

References

amazon docs

https://docs.aws.amazon.com/ko_kr/AmazonElastiCache/latest/red-ug/Strategies.html

 

캐싱 전략 - Redis용 Amazon ElastiCache

캐싱 전략 다음 항목에서는 캐시를 채우고 유지 관리하기 위한 전략을 확인할 수 있습니다. 캐시를 채우고 유지 관리하기 위해 구현하려는 전략은 캐싱되는 데이터의 유형과 해당 데이터에 대한 액세스 패턴에 따라 달라집니다. 예를 들어, 게임 사이트와 새 이야기 추세의 상위 10개 리더보드에 대해 동일한 전략을 사용하려고 하지 않을 수 있습니다. 이 단원의 나머지 부분에서는 일반적인 캐시 유지 관리 전략, 이에 대한 장점 및 단점에 대해 살펴봅니다. 주제 지연

docs.aws.amazon.com

 

Spring redis guide

https://spring.io/guides/gs/spring-data-reactive-redis/

 

Accessing Data Reactively with Redis

this guide is designed to get you productive as quickly as possible and using the latest Spring project releases and techniques as recommended by the Spring team

spring.io

 

 

댓글