본문 바로가기
Technical Issue

DB I/O 최소화를 위한 사용자 정보 캐싱

by jooany 2023. 10. 25.

캐싱의 필요성

토이 프로젝트의 API를 개발하고 테스트를 하는 과정에서 거의 모든 요청마다 사용자를 조회하는 쿼리가 호출되는 것을 확인했습니다. 이는 아래와 같이 사용자 인증/인가를 위해 회원가입과 로그인을 제외한 모든 API 요청에서 사용자를 조회하여 인증하고 있기 때문이었습니다. (사용자 인증/인가를 다루었던 이전 글에서 더 자세한 코드를 확인하실 수 있습니다.)

@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    // ... 생략 ...
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //... 생략 ...
        // 사용자가 회원이면 객체 생성
        UserDto userDto = userService.loadUserByUserName(userName);
        //... 생략 ...
}
@Service
@RequiredArgsConstructor
public class UserService {
    //... 생략 ...
    public UserDto loadUserByUserName(String userName){
        return userRepository.findByUserName(userName).map(UserDto::from).orElseThrow(() ->
                        new LetsDealAppException(ErrorCode.USER_NOT_FOUND, String.format("%s 는(은) 존재하지 않는 사용자입니다.", userName))
        );
    }
}

 

매 요청마다 DB에서 사용자 정보를 조회하는 것은 성능 저하와 DB 서버의 부하를 초래할 수 있음으로, 이를 최소화하기 위해 캐싱을 도입했습니다.

캐싱이란?

캐싱은 하드웨어 측면에서 발생한 개념으로, 이를 먼저 설명하겠습니다.

 

CPU는 보조 메모리로부터 데이터를 가져옵니다. 근데, 보조 메모리는 CPU에 비해 용량이 크지만 느립니다. 따라서, 메모리와 CPU 간의 속도 차이를 극복하기 위해, CPU 내부의 캐시 라는 작고 빠른 임시 저장 메모리를 두어, 처음 디스크에 접근하여 가져온 데이터를 저장하고, 그 데이터를 다시 요청할 때, 캐시를 먼저 확인하고 캐시에 없다면 디스크에 접근하도록 하는 메커니즘인 캐싱을 사용합니다. 여기서 캐싱은 CPU와 보조 메모리의 관계로만 국한되는 것이 아니라, 하드웨어와 하드웨어의 데이터 교환 관계에서 속도 차이를 극복하기 위한 개념입니다.

 

소프트웨어에서도 캐싱은 중요한 역할을 합니다. 예를 들어, DB로부터 데이터를 가져오는 웹 애플리케이션에서는 가볍고 빠른 캐시를 사용하여 데이터에 더 빠르게 접근할 수 있습니다. 이는 응답 시간을 단축시키고, 서버의 부하를 줄여 전체적인 성능을 향상시킵니다.

캐싱 구현 고민

사용자 정보를 한 번 캐싱하면, 웹 애플리케이션이 재시작되어도 캐싱을 유지할 수 있도록 지속성을 가진 캐시 도구가 필요합니다. 이미 RefreshToken을 Redis에 저장하여 사용하고 있었기 때문에, Redis를 캐시 도구를 활용하기로 했습니다. 

 

구현

이미 이전 글에서 Redis의 설정을 자세하게 다뤘기 때문에 설정 부분은 생략하고, 추가 및 수정한 부분의 코드만 첨부합니다.

 

UserCacheRepository 생성

// Redis에 사용자 정보를 저장하는 Repository 클래스
@Slf4j
@Repository
@RequiredArgsConstructor
public class UserCacheRepository {

    // ( key : userName포함키, value : userDto) 형태로 redis에 저장
    private final RedisTemplate<String, UserDto> userRedisTemplate;
    private final static Duration USER_CACHE_TTL = Duration.ofDays(3);

    // Redis에 사용자 정보 저장
    public void setUser(UserDto userDto){
        String key = getKey(userDto.getUsername());
        log.info("Set User to Redis, ( Key: {}, UserDto: {} )", key, userDto);
        userRedisTemplate.opsForValue().setIfAbsent(key, userDto, USER_CACHE_TTL);
    }

    // Redis에서 사용자 정보 조회
    public Optional<UserDto> getUserDto(String userName){
        String key = getKey(userName);
        UserDto userDto = userRedisTemplate.opsForValue().get(key);
        log.info("Get User from Redis, ( Key: {}, UserDto: {} )",key, userDto);
        return Optional.ofNullable(userDto);
    }

    // Redis에서 사용자 정보 삭제
    public void deleteUser(String userName){
        String key = getKey(userName);
        userRedisTemplate.delete(key);
        log.info("Delete User from Redis, ( Key: {} )",key);
    }

    // 키 생성
    private String getKey(String userName){
        return "USER:" + userName;
    }
}

조회한 UserDto 객체를 Redis에 저장하기 위한 Repository를 생성합니다. 

 

RedisConfig - 사용자 레디스 템플릿 추가

@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class RedisConfig {
    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, UserDto> userRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, UserDto> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<UserDto>(UserDto.class));
        return redisTemplate;
    }

    @Bean
    public RedisTemplate<String, String> refreshTokenRedisTemplate(RedisConnectionFactory redisConnectionFactory) {...}
}

RedisConfig에서 키는 String, 값은 UserDto 타입으로 담는 userRedisTemplate 빈을 정의합니다.

 

UserService - 사용자 정보 조회, 로그인, 회원 탈퇴, 닉네임 변경 메소드 수정

캐싱 로직을 적용하고, 데이터 무결성을 위해 사용자 정보의 변화에 따라 Redis의 데이터를 관리하는 로직을 추가하였습니다.

@Service
@RequiredArgsConstructor
public class UserService {
    //... 생략 ...
    
    // 1. 사용자 정보 조회
    public UserDto loadUserByUserName(String userName){
        return userCacheRepository.getUserDto(userName).orElseGet(() ->
                userRepository.findByUserName(userName).map(UserDto::from).orElseThrow(() ->
                        new LetsDealAppException(ErrorCode.USER_NOT_FOUND, String.format("%s 는(은) 존재하지 않는 사용자입니다.", userName)))
        );
    }

    // 2. 로그인
    @Transactional
    public AuthTokens login(String userName, String password) {
        UserDto userDto = loadUserByUserName(userName);
        if(!encoder.matches(password,userDto.getPassword())){
            throw new LetsDealAppException(ErrorCode.INVALID_PASSWORD);
        }
        AuthTokens authTokens = generateTokens(userName);

        // 사용자 정보 캐시 저장
        userCacheRepository.setUser(userDto);

        return authTokens;
    }

    // 3. 회원 탈퇴
    @Transactional
    public void delete(String userName) {
        User user = getUserOrException(userName);
        userRepository.delete(user);
        
        // 사용자 정보 캐시 삭제
        userCacheRepository.deleteUser(userName);
        
        refreshTokenRepository.deleteUser(userName);
        messageService.deleteWithdrawnUserMessages(user.getId());
    }

    // 4. 닉네임 변경
    @Transactional
    public void updateNick(String nickname, String userName){
        User user = getUserOrException(userName);
        checkDuplicateNickname(nickname);
        user.updateNick(nickname);
        entityManager.flush();

        // 변경된 사용자 캐시 덮어씌우기
        userCacheRepository.setUser(UserDto.from(user));
    }
}

 

1. 사용자 정보 조회 - DB에서 바로 사용자를 조회하기 전에, Redis에서 먼저 조회하도록 수정해주었습니다. 만약 Redis에 해당 userName을 키로 가지는 데이터가 없다면, DB로 사용자를 조회합니다. 

2. 로그인 - 인증이 완료되면 Redis에 사용자 정보를 저장하도록  추가했습니다.

3. 회원 탈퇴 - 회원 탈퇴할 때, Redis에 저장된 사용자 정보를 삭제하도록 추가했습니다.

4. 닉네임 변경 - 사용자 정보가 변경되었기 때문에, Redis에 저장된 사용자 정보를 삭제하고, 새로 추가해야 합니다.

 

닉네임 변경 API에서 flush()를 사용하지 않고 구현한다면, 예외 발생 시, 사용자 캐시는 업데이트 되고 DB는 롤백되는 데이터 정합성 문제가 발생할 수 있습니다. 따라서, 경된 user엔티티를 바로 DB에 커밋해주는  flush()메소드를 사용했습니다. 

 

결과

로그인 

1. Redis에서 사용자를 조회합니다.

2. 가져온 데이터의 값이 null이기 때문에 사용자 조회 쿼리를 실행합니다.

3. 발급한 리프레쉬 토큰과, 사용자 정보를 Redis에 저장합니다.

로그인 요청 처리 시, 콘솔 로그

로그인 재시도

1. Redis에서 사용자를 조회하여 사용자 정보 객체를 가지고 옵니다.

로그인 요청 처리 시, 콘솔 로그

2. 발급한 리프레쉬 토큰과, 사용자 정보를 Redis에 저장합니다.

 

 

처음 로그인을 할 때는 DB에서 사용자를 조회하고, 이후 로그인에서는 Redis에서 사용자를 조회하여 성공적으로 캐싱이 적용된 것을 확인했습니다. 또한, 다른 API 요청에서도 한 번 캐시가 저장된 후엔 사용자 조회 쿼리를 실행하지 않는 것을 확인했습니다.

 

이로써 Redis를 활용한 사용자 정보의 캐싱을 통해 성공적으로 DB I/O를 최소화하여 애플리케이션의 성능을 향상시켰습니다.