Redis Cache를 적용해 성능 개선하기

alsdl0629·2024년 4월 2일
0

기술 적용

목록 보기
6/6
post-thumbnail

이번 글에서는 실제 운영 중인 프로젝트에 Redis Cache를 적용해 성능 개선한 경험을 정리해 보려고 합니다.

Cache란?

사용자의 요청을 빠르게 처리할 수 있도록 자주 사용되는 데이터를 보관해 두는 저장소입니다.
읽기 호출이 많은 서비스 일수록 Cache를 사용하면 DB 부하를 줄이고, 응답 속도를 높일 수 있습니다.


파레토의 법칙처럼 캐시를 사용해 자주 사용되는 데이터(20%)를 보관해두면 80%의 작업을 효율적으로 처리할 수 있습니다.

Cache를 적용한 이유

성능 개선

애플리케이션이 DB와 통신하는 과정에서 Connection Pool 관리, 네트워크 통신, Disk I/O 등 많은 작업이 필요합니다.

즉, 애플리케이션의 성능은 데이터베이스를 얼마나 자주 호출하는지에 따라 크게 좌우됩니다.

읽기 요청이 많은 프로젝트

다른 프로젝트와 마찬가지로 저희 프로젝트도 대부분 CRUD 기능 중 Read 기능이 많습니다. 그래서 사용자가 요청할 때마다 한 번 이상 DB와 통신을 하기 때문에 사용자 요청이 몰리게 되면 문제가 발생할 것으로 예상했습니다.

Cache를 적용하면 이러한 문제들을 해결할 수 있을 것 같아 적용하게 되었습니다.


Local Cache VS Global Cache

Local Cache

  • 서버 내에서 동작하기 때문에 속도가 빠름
  • 휘발성 메모리여서 애플리케이션이 종료되면 데이터도 같이 삭제
  • 서버가 여러 대일 경우 데이터 불일치 발생(확장성 ⬇️)
  • 메모리를 공유하기 때문에 부족할 수 있음

Global Cache

  • 캐시 서버가 분리되어 서버간 데이터 공유가 쉬움
  • 확장성이 좋음(Replication, Sharding)
  • 네트워크 통신이 필요하기 때문에 Local Cache에 비해 속도가 느림

Global Cache를 선택한 이유

실제 운영 중인 프로젝트이고, 프로젝트 특성상 사용자에게 정확한 데이터를 제공해야 하기 때문에 데이터의 일관성이 깨지게 되면 리스크가 클 것이라고 생각했습니다.

그래서 서버간 데이터 공유를 지원하는 Global Cache를 사용해 속도를 높이고, 데이터 일관성이 보장되도록 했습니다.

또한 Global Cahce는 확장성이 좋기 때문에 애플리케이션 Scale Out, Replication 등 다양한 기술을 안전하게 적용할 수 있는 장점이 있습니다.


Cache 설정하기

먼저 의존성을 추가해줍니다.
Redis 관련 설정은 따로 다루진 않겠습니다.

// 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache'
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        // 날짜 타입을 직렬화/역질렬화 하기 위해
        BasicPolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
                .allowIfSubType(Object.class)
                .build();
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
        GenericJackson2JsonRedisSerializer redisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);

		// 기본적인 캐시 세팅
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer));

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
    	// Cache Name마다 ttl을 다르게 하기 위해 커스텀 세팅
        return builder -> builder
                .withCacheConfiguration(CODE, RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(10))
                        .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                        .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                );
    }
}

API 응답 정보 캐싱

@CacheConfig(cacheNames = CacheName.COMPANY)
public class CompanyController {	
    
    @Cacheable
    @GetMapping
    public StudentQueryCompaniesResponse studentQueryCompanies(
            @RequestParam(required = false) @Positive Long page,
            @RequestParam(required = false) String name
    ) {
        return studentQueryCompaniesService.execute(page, name);
    }
    
    @CacheEvict(allEntries = true)
    @PostMapping
    public TokenResponse register(@RequestBody @Valid RegisterCompanyRequest request) {
        return registerCompanyService.execute(request.toDomainRequest());
    }
}

Cache가 적용된 API를 처음 호출하면 위와 같이 저장됩니다.
이후의 요청에서 쿼리가 발생하지 않는 것을 확인할 수 있습니다!

@CacheConfig

  • 클래스에 붙여서 해당 클래스 안에 캐시 기능을 묶는 역할을 합니다.
  • CacheEvict 시 cacheNames 속성에 선언한 데이터만 삭제됩니다.

@Cacheable

  • 데이터를 캐시에 저장합니다.
  • 데이터가 없다면 메서드를 수행 후 결과 값을 저장합니다.
  • condition 속성으로 원하는 조건일 때만 데이터를 캐싱할 수 있습니다.

@CacheEvict

  • 캐시에 있는 데이터를 삭제합니다.

@CacheEvict에서 allEntries = true를 사용한 이유
리스트 데이터를 캐싱했을 때 각 요소가 변경될 때마다 리스트에 있는 요소를 함께 변경하기 어려워서 관련 데이터들만 삭제되도록 했습니다.

@Caching(
            evict = {
                    @CacheEvict(cacheNames = COMPANY, allEntries = true),
                    @CacheEvict(cacheNames = COMPANY_USER, allEntries = true)
            }
    )

@Caching

  • @Cacheable, @CacheEvict 등 여러 개를 사용할 수 있도록 해줍니다.

인증 정보 캐싱

JWT를 이용한 사용자 인가 과정에서 유저 정보를 가져오기 위해 쿼리가 발생합니다.

사용자가 서비스를 사용할 때 여러 요청을 보내게 되는데 인증 정보 쿼리가 매번 발생 하는게 비효율적이라고 생각했습니다.

인가(Authorization)
요청을 한 유저가 기능을 사용할 수 있는지 확인하는 과정

JWT를 이용한 사용자 인가 과정 요약
1. 사용자로부터 access token을 받음
2. access token을 복호화하여 유저 정보를 가져옴
3. 복호화한 유저 정보와 db에 저장된 유저 정보를 비교
4. 정보가 일치하면 인가 성공, 일치하지 않거나 중간에 문제가 발생하면 실패

cache를 적용해 3번에서 쿼리가 나가는 것을 방지했습니다.

@CacheConfig(cacheNames = STUDENT_USER)
@Component
@RequiredArgsConstructor
public class StudentDetailsService implements UserDetailsService {

    private final StudentJpaRepository studentJpaRepository;

    @Cacheable
    @Override
    public UserDetails loadUserByUsername(String studentId) throws UsernameNotFoundException {
        StudentEntity studentEntity = studentJpaRepository.findById(studentId)
        .orElseThrow(() -> InvalidTokenException.EXCEPTION);

        return new StudentDetails(studentEntity);
    }
}
@Getter
@NoArgsConstructor(force = true)
@RequiredArgsConstructor
public class StudentDetails implements UserDetails {

    private final Student student;

    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority(STUDENT.name()));
    }

    @JsonIgnore
    @Override
    public String getPassword() {
        return null;
    }
    
    ...
}

사용하지 않는 것들은 @JsonIgnore로 제외시켰습니다.

인가가 필요한 요청을 보낼 때도 첫 번째 요청 이후에는 쿼리가 발생하지 않는 것을 확인할 수 있습니다.


발생한 문제

엔티티 직렬화 시 오류 발생

인증 정보 캐싱에서 UserDetails를 구현한 StudentDetails를 캐싱했습니다.
StudentDetails은 필드로 Student 엔티티(JPA 객체)를 가지고 있고, Student 엔티티는 다양한 엔티티와 연관 관계가 맺어져 있습니다.

@Entity
public class StudentEntity {

    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "student_id", nullable = false)
    private User user;
    
    ...
}

Student 엔티티와 연관 관계가 맺어진 엔티티들을 캐싱하기 위해 직렬화할 때 영속성 컨텍스트가 존재하지 않아 SerializationException: Could not write JSON: could not initialize proxy 에러가 발생했습니다.

해결

@Entity
public class StudentEntity {

    @Id
    private Long id;

	@JsonIgnore
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "student_id", nullable = false)
    private User user;
    
    ...
}

@JsonIgnore를 사용해 연관된 엔티티를 직렬화 과정에서 제외시켰습니다.


다른 방법으로는 @Transactional을 사용해서 해결할 수 있습니다.
JPA는 기본 전략으로 트랜잭션 범위의 영속성 컨텍스트 전략을 사용하기 때문입니다.

하지만 해당 기능은 조회만 담당하기 때문에 @Transactional을 사용할 이유가 없고, @Transactional을 사용하면 관련 부가 작업(DB 커넥션 획득, 트랜잭션 시작/종료)이 필요하기 때문에 첫 번째 방법을 사용했습니다.


느낀점

프로젝트의 성능을 향상시키기 위해 캐시를 적용하며, 캐시의 장점을 직접 경험하고, 이해할 수 있었습니다. 또한 캐시를 적용함으로써, 로컬 캐시와 글로벌 캐시 각각의 장단점을 파악하고, 데이터의 직렬화와 같은 중요한 개념들에 대해 학습하는 기회가 되었습니다.

다음에는 성능 최적화 과정에서 자주 언급되는 DB 인덱스에 대해 알아보고, 적용해보면 좋을 것 같습니다.


참고 자료 🙇🙇🙇

https://pamyferret.tistory.com/8
https://bcp0109.tistory.com/385
https://velog.io/@bagt/Redis-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%82%BD%EC%A7%88%EA%B8%B0-feat.-RedisSerializer#-1-jackson2jsonredisserializer
https://velog.io/@hkyo96/Spring-JWT-%EC%9D%B8%EC%A6%9D-%EC%A0%95%EB%B3%B4-%EC%BA%90%EC%8B%B1

profile
인풋보다 아웃풋

0개의 댓글