본문 바로가기
BackEnd/Project

[SNS] Ch04. 캐싱을 통한 DB 호출 최소화

by 개발 Blog 2024. 9. 10.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

대규모 트래픽을 처리하는 애플리케이션에서 데이터베이스(DB) 호출이 빈번하게 발생하면 성능 저하가 발생할 수 있다. 특히 자주 조회되는 데이터의 경우 매번 DB에 접근하면 응답 시간이 느려지고, 불필요한 리소스 사용이 증가한다. 이를 해결하기 위해 캐싱 기술을 활용하면 DB 접근 횟수를 줄여 애플리케이션 성능을 크게 향상시킬 수 있다.

 

Redis는 이러한 캐싱을 구현하는 대표적인 인메모리 데이터베이스로, 빠른 데이터 접근 속도와 다양한 기능을 제공한다. 이번 시간에는 Spring BootRedis를 연동해 자주 사용하는 데이터를 캐싱하고, DB 호출 수를 최소화하는 방법을 설명한다.

build.gradle에 의존성 추가

애플리케이션에서 Redis를 사용하기 위해, build.gradle 파일에 Redis 관련 의존성을 추가해야 한다. 이를 통해 Spring Boot가 Redis와 연동할 수 있다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

Heroku에서 Redis 추가

Heroku에서는 간단하게 Redis 애드온을 추가할 수 있다. Redis 애드온을 추가하면 Heroku가 자동으로 해당 인스턴스를 설정하고, 이를 애플리케이션에서 사용할 수 있는 환경변수를 제공한다.

터미널에서 Heroku 환경변수 조회

Heroku CLI를 사용하여 애플리케이션에 설정된 환경변수를 조회할 수 있다. 여기서 REDIS_URL 값을 확인하고, 해당 값을 애플리케이션 설정 파일에 반영해야 한다.

heroku config -a sns-service

application.yml에 설정 추가

조회한 REDIS_URL 값을 Spring Boot 애플리케이션의 설정 파일인 application.yml에 추가한다. 이 설정을 통해 애플리케이션이 Redis 서버에 연결할 수 있게 된다.

spring.redis.url: ${REDIS_URL}

Redis 설정 및 코드 수정

Redis와 애플리케이션을 연결하는 설정을 추가하고, 캐시를 위한 코드를 수정하는 과정이 필요하다. Redis를 활용해 데이터를 캐시하려면 RedisTemplate을 사용해 데이터를 저장하고 조회하는 방식으로 구현할 수 있다. 기존 코드에서는 데이터베이스에서 직접 사용자 데이터를 불러오고 있었으나, 캐시를 도입함으로써 DB 호출 횟수를 줄일 수 있다.

 

Redis와 연결하기 위한 설정 파일이 필요하다. Spring Boot에서 Redis를 지원하는 라이브러리와 설정을 통해 Redis 서버와 연결할 수 있다. 이를 위해 RedisConfiguration 클래스를 만들어 RedisTemplate을 설정한다.

package com.example.sns.configuration;

import com.example.sns.model.User;
import io.lettuce.core.RedisURI;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class RedisConfiguration {

    private final RedisProperties redisProperties;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisURI redisURI = RedisURI.create(redisProperties.getUrl());
        org.springframework.data.redis.connection.RedisConfiguration configuration = LettuceConnectionFactory.createRedisConfiguration(redisURI);
        LettuceConnectionFactory factory = new LettuceConnectionFactory(configuration);
        factory.afterPropertiesSet();
        return factory;
    }

    @Bean
    public RedisTemplate<String, User> userRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, User> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(User.class));


        return redisTemplate;
    }
}기존 User 모델 수정
  • RedisProperties 사용
    • RedisProperties는 Spring Boot의 설정 파일(application.yml 또는 application.properties)에 정의된 Redis 관련 설정 값을 읽어온다. 이를 통해 Redis 서버의 URL, 포트 등 설정 값을 활용할 수 있다.
  • RedisConnectionFactory 설정
    • RedisConnectionFactory는 Redis 서버와의 실제 연결을 담당한다.
    • 이 코드에서는 LettuceConnectionFactory를 사용해 Redis 서버와의 연결을 관리한다. Lettuce는 비동기 Redis 클라이언트 라이브러리로, Spring Data Redis와 함께 많이 사용된다.
    • RedisURI.create(redisProperties.getUrl())를 통해 설정 파일에 정의된 Redis URL로 연결을 설정하고, 이를 통해 Redis와의 통신이 가능해진다.
  • RedisTemplate 설정
    • RedisTemplate은 Redis와 애플리케이션 간의 데이터를 직렬화 및 역직렬화하여 송수신하는 데 사용된다.
    • KeySerializer: Redis에 저장되는 데이터의 키를 직렬화하는 방식이다. 여기서는 StringRedisSerializer를 사용하여 키를 문자열로 직렬화한다.
    • ValueSerializer: Redis에 저장되는 데이터(값)를 직렬화하는 방식이다. 여기서는 Jackson2JsonRedisSerializer를 사용하여 User 객체를 JSON 형식으로 직렬화한다. 이를 통해 User 객체를 Redis에 저장하고 조회할 때 JSON 형식으로 저장되어 쉽게 읽고 쓸 수 있다.

User 클래스 수정

기존의 User 클래스는 Spring Security의 UserDetails 인터페이스를 구현하고 있었다. 이 클래스는 사용자 인증 및 권한 부여에 사용되며, 사용자 정보와 함께 계정 상태를 관리하는 역할을 한다. 하지만 이 클래스는 직렬화가 가능하지 않아 Redis와 같은 캐싱 시스템에서 사용할 수 없었다. 또한, JSON 직렬화 및 역직렬화 처리 없이 사용되고 있었다.

package com.example.sns.model;


import com.example.sns.model.entity.UserEntity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.sql.Timestamp;
import java.util.Collection;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class User implements UserDetails {

    private Integer id;
    private String username;
    private String password;
    private UserRole userRole;
    private Timestamp registeredAt;
    private Timestamp updatedAt;
    private Timestamp deletedAt;

    public static User fromEntity(UserEntity entity) {
        return new User(
                entity.getId(),
                entity.getUserName(),
                entity.getPassword(),
                entity.getRole(),
                entity.getRegisteredAt(),
                entity.getUpdatedAt(),
                entity.getDeletedAt()
        );
    }

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(this.getUserRole().toString()));
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return this.deletedAt == null;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return this.deletedAt == null;
    }

    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return this.deletedAt == null;
    }

    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return this.deletedAt == null;
    }
}

수정 후의 User 클래스는 Redis에 저장될 수 있도록 직렬화가 가능하게끔 변경되었다. 이로써 Redis와 같은 인메모리 데이터베이스에 사용자 데이터를 저장하고, 필요 시 Redis에서 데이터를 불러올 수 있게 되었다. 이를 위해 몇 가지 수정 및 추가가 이루어졌다.

 

주요 수정 사항

  1. 직렬화 가능하도록 변경
    • @Data, @NoArgsConstructor, @AllArgsConstructor를 추가해 직렬화가 가능하게 만들었다. 직렬화 시 기본 생성자가 필요하므로, @NoArgsConstructor로 기본 생성자를 추가하였다.
    • @Data를 사용하여 gettersetter 메서드를 자동 생성하였고, @Getter를 @Data로 변경하여 전체적으로 관리하기 쉽게 수정되었다.
  2. JSON 직렬화 및 역직렬화 설정
    • @JsonIgnoreProperties(ignoreUnknown = true) 애너테이션을 추가하여, 직렬화/역직렬화 과정에서 예상치 못한 필드가 포함되어도 무시되도록 설정했다. 이를 통해 Redis에서 직렬화할 때 불필요한 정보나 무시해야 할 정보가 포함되는 것을 방지한다.
    • @JsonIgnore 애너테이션을 여러 메서드에 추가하여 Redis에 저장할 필요가 없는 필드를 직렬화에서 제외했다. 예를 들어, getAuthorities(), isAccountNonExpired()와 같은 보안 관련 메서드는 캐시에서 불필요하므로 Redis에 저장되지 않도록 처리하였다.
  3. 필드 이름 수정
    • userName 필드를 username으로 수정하였다. 이는 Spring Security의 관례에 맞추어 필드명을 일관되게 관리하기 위함이다. 이를 통해 코드의 가독성과 일관성을 높였다.
  4. fromEntity() 메서드
    • fromEntity() 메서드는 기존과 동일하게 UserEntity에서 User 객체로 변환하는 역할을 한다. 이 메서드를 사용하여 DB에서 가져온 엔티티 데이터를 애플리케이션에서 사용하는 User 도메인 객체로 변환한다. 수정 사항은 없으나, 이 변환 메서드를 통해 캐싱과 DB 데이터를 쉽게 전환할 수 있다.

Redis 캐시 저장 로직 추가

UserCacheRepository를 통해 Redis에 사용자 데이터를 저장하고, 저장된 데이터를 다시 불러오는 로직을 구현한다. Redis를 캐시로 사용하여, 데이터베이스(DB)에 접근하지 않고 빠르게 데이터를 가져오기 위한 구조이다.

package com.example.sns.repository;

import com.example.sns.model.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import java.time.Duration;
import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class UserCacheRepository {

    private final RedisTemplate<String, User> userRedisTemplate;
    private final static Duration USER_CACHE_TTL = Duration.ofDays(3);

    public void setUser(User user) {
        String key = getKey(user.getUsername());
        log.info("Set User to Redis {} : {}", key , user);
        userRedisTemplate.opsForValue().set(key, user, USER_CACHE_TTL);
    }

    public Optional<User> getUser(String userName) {
        String key = getKey(userName);
        User user = userRedisTemplate.opsForValue().get(getKey(userName));
        log.info("Get data from Redis {} , {} ", key, user);
        return Optional.ofNullable(user);
    }

    private String getKey(String userName) {
        return "USER:" + userName;
    }
}

주요 동작 

  1. RedisTemplate 사용
    • 이 클래스는 **RedisTemplate<String, User>**를 사용해 Redis에 데이터를 저장하거나 조회하는 역할을 한다.
    • RedisTemplate은 Redis에 데이터를 저장할 때, 키와 값을 적절하게 직렬화하고, 저장 및 조회하는 모든 작업을 처리해준다. 여기서 String 타입의 키와 User 객체를 직렬화하여 Redis에 저장한다.
  2. TTL(Time To Live) 설정
    • USER_CACHE_TTL은 캐시 유효 시간을 설정하는 변수다. 여기서는 3일 동안 캐시가 유지되도록 설정되었다. 캐시에 저장된 사용자 데이터는 3일이 지나면 자동으로 만료된다.
    • 캐시 만료 시간(TTL)은 Redis가 데이터 유효 기간을 설정하고, 지정된 시간이 지나면 자동으로 캐시 데이터를 제거하는 기능을 제공한다. 이를 통해 최신 데이터가 필요할 때 다시 DB에서 조회할 수 있다.

주요 메서드

  1. setUser(User user)
    • 사용자의 데이터를 Redis에 저장하는 메서드다.
    • getKey(user.getUsername()) 메서드를 통해 Redis에 저장될 를 생성한 후, 와 **값(User 객체)**을 함께 Redis에 저장한다.
    • 이때 TTL도 설정하여, 캐시에 저장된 데이터가 일정 시간 후 자동으로 만료되도록 처리된다.
    • log.info("Set User to Redis {} : {}", key , user)는 캐시 저장 작업이 수행될 때 로그를 남기며, 사용자 데이터가 Redis에 정상적으로 저장되었는지 확인할 수 있다.
  2. getUser(String userName)
    • 사용자 이름을 통해 Redis에서 캐시된 사용자 데이터를 조회하는 메서드다.
    • Redis에서 데이터를 조회할 때 를 사용하여 데이터를 가져온다. userRedisTemplate.opsForValue().get(key) 메서드를 통해 Redis에서 해당 키에 저장된 User 객체를 가져온다.
    • 조회된 결과는 **Optional<User>**로 반환되어, 조회된 데이터가 없을 경우에도 null 대신 **Optional.empty()**로 처리된다.
    • log.info("Get data from Redis {} , {} ", key, user)는 캐시에서 데이터를 조회한 후 로그를 남기며, 정상적으로 캐시된 데이터가 조회되었는지 확인할 수 있다.
  3. getKey(String userName)
    • Redis에 저장할 때 사용할 를 생성하는 메서드다.
    • "USER:"라는 문자열과 userName을 조합하여 Redis에서 사용할 고유한 키를 생성한다. 이렇게 생성된 키를 통해 Redis에 저장된 데이터를 쉽게 조회할 수 있다.

UserService 수정

UserService는 사용자의 정보를 처리하는 핵심 서비스이다. 캐싱을 적용하기 위해 UserService의 일부 로직을 수정하여, 사용자 데이터가 Redis에 있는 경우 DB를 거치지 않고 바로 Redis에서 조회할 수 있도록 했다.

이제 admin계정으로 로그인을 하면 처음에는 DB에 쿼리를 날려서 레디스에 등록한다.

package com.example.sns.service;

import com.example.sns.exception.ErrorCode;
import com.example.sns.exception.SnsApplicationException;
import com.example.sns.model.Alarm;
import com.example.sns.model.User;
import com.example.sns.model.entity.UserEntity;
import com.example.sns.repository.AlarmEntityRepository;
import com.example.sns.repository.UserCacheRepository;
import com.example.sns.repository.UserEntityRepository;
import com.example.sns.util.JwtTokenUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserEntityRepository userEntityRepository;
    private final AlarmEntityRepository alarmEntityRepository;
    private final BCryptPasswordEncoder encoder;
    private final UserCacheRepository userCacheRepository;

    @Value("${jwt.secret-key}")
    private String secretKey;

    @Value("${jwt.token.expired-time-ms}")
    private Long expiredTimeMs;

    public User loadUserByUserName(String userName) {
 	   // 수정
        return userCacheRepository.getUser(userName).orElseGet(() ->
                userEntityRepository.findByUserName(userName).map(User::fromEntity).orElseThrow(() ->
                        new SnsApplicationException(ErrorCode.USER_NOT_FOUND, String.format("%s not founded", userName)))
        );
    }

    @Transactional
    public User join(String userName, String password) {

        // 회원가입하려는 userName으로 회원가입된 user가 있는지
        userEntityRepository.findByUserName(userName).ifPresent(it -> {
            throw new SnsApplicationException(ErrorCode.DUPLICATED_USER_NAME, String.format("%s is duplicated", userName));
        });

        // 회원가입 진행 = user를 등록
        UserEntity userEntity = userEntityRepository.save(UserEntity.of(userName, encoder.encode(password)));

        return User.fromEntity(userEntity);
    }

    public String login(String userName, String password) {
    	// 수정
        // 회원가입 여부 체크 
        User user = loadUserByUserName(userName);
        userCacheRepository.setUser(user);

        // 비밀번호 체크
        if (!encoder.matches(password, user.getPassword())) {
            throw new SnsApplicationException(ErrorCode.INVALID_PASSWORD);
        }

        // 토큰 생성
        return JwtTokenUtils.generateToken(userName, secretKey, expiredTimeMs);
    }

    public Page<Alarm> alarmList(Integer userId, Pageable pageable) {
        return alarmEntityRepository.findAllByUserId(userId, pageable).map(Alarm::fromEntity);

    }
}

UserCacheRepository 추가

수정 전

  • 사용자 데이터를 조회할 때마다 데이터베이스(DB)에서 직접 조회하였다.
  • loadUserByUserName 메서드는 항상 DB에서 사용자를 찾고, 없으면 예외를 발생시켰다.

수정 후

  • UserCacheRepository를 추가하여, Redis 캐시에서 데이터를 먼저 조회하도록 변경되었다.
  • 사용자 데이터를 먼저 Redis 캐시에서 확인하고, 캐시에 데이터가 없을 경우에만 DB에서 조회한 후, 조회된 데이터를 Redis에 저장한다. 이를 통해 DB 호출을 최소화하고 성능을 높였다.

이 방식은 먼저 Redis에서 캐시된 사용자 정보를 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장하는 로직이다.

 

로그인 로직 수정

수정 전

  • 로그인 시 DB에서 사용자 정보를 불러와 비밀번호를 확인한 후, JWT 토큰을 생성하였다.

수정 후

  • 로그인 시에도 먼저 Redis에서 캐시된 사용자 정보를 가져오고, 그 이후 비밀번호를 검증한 후 토큰을 생성한다.
  • 로그인에 성공하면, 해당 사용자 정보를 Redis에 캐싱한다.

이 수정으로, 사용자가 로그인할 때마다 DB에 접근하지 않고, 이미 캐시된 사용자 정보가 있을 경우 Redis에서 바로 데이터를 가져와 성능이 향상된다.

 

UserCacheRepository.setUser(user)

로그인 성공 시, 사용자의 정보를 Redis에 캐시하도록 변경되었다. 이를 통해, 다음 번 로그인 요청에서는 Redis에서 데이터를 가져와 더 빠르게 응답할 수 있다.

테스트

포스트맨(Postman)을 사용하여 admin 계정으로 로그인 테스트를 진행한다. 캐싱이 적용되었는지 확인하기 위해 두 번의 로그인을 요청하고, 쿼리 발생 여부를 확인한다.

 

첫 번째 로그인 요청

  • 처음으로 admin 계정으로 로그인 요청을 하면, 데이터베이스(DB)에서 사용자 정보를 조회하는 쿼리가 발생한다. 이 과정에서 사용자 정보는 Redis에 저장된다.
  • 로그에서 "Set User to Redis" 메시지를 통해, 해당 사용자가 Redis에 캐시된 것을 확인할 수 있다.

두 번째 로그인 요청

  • 두 번째 로그인 요청 시, 데이터베이스에서 다시 쿼리를 발생시키지 않고, Redis에서 캐시된 데이터를 가져온다.
  • 로그에서는 "Get data from Redis" 메시지가 출력되며, Redis에서 이미 저장된 사용자 데이터를 조회했음을 알 수 있다.

이 테스트를 통해, 캐싱이 성공적으로 작동하고 있으며 첫 로그인 이후에는 DB 호출 없이 Redis에서 데이터를 불러오는 것을 확인할 수 있다.

'BackEnd > Project' 카테고리의 다른 글

[SNS] Ch04. SSE로 알람 개선  (0) 2024.09.10
[SNS] Ch04. SSE 개념  (0) 2024.09.10
[SNS] Ch04. Redis  (0) 2024.09.10
[SNS] Ch04. 코드 최적화  (0) 2024.09.10
[SNS] Ch04. 대규모 트래픽 시 문제점 분석 및 해결  (0) 2024.09.10