Java & Spring

Spring Data Repository 를 사용하여 Redis에 캐싱할 경우 Connection을 사용하는 문제

ju_young 2024. 8. 2. 20:02
728x90

Repository로 Redis를 사용?

Redis Repositories에서 CrudRepository를 상속받아 JpaRepository를 사용하여 DB에 데이터를 조작하는 것처럼 사용할 수 있다고 한다.

예시로 다음과 같이 Repository 인터페이스를 정의할 수 있다.

interface UserRepository extends CrudRepository<User, Long> { 
    long countByLastname(String lastname); 
}

 

id를 key 값으로 lastname이라는 value가 저장되는 것이다. 만약 객체 자체를 저장하게될 경우 JSON 형식이 아닌 HSET 명령어로 저장하는 것과 같은 결과로 저장된다.

 

그리고 실제로 동작할 때, 구체 클래스는 SimpleKeyValueRepository가 사용된다.

 

이 방법을 유저 정보 캐싱하는 기능에 적용해보자.

유저 정보 캐싱

먼저 다음과 같은 순서로 동작하도록 구현하는 것이 목표이다.

  1. 로그인
  2. 토큰 발급
  3. 유저 정보 캐싱

Repository 정의

Id를 key로 MemberPrincipal이라는 객체를 캐싱해주는 Repository를 정의했다.

public interface MemberCacheRepository extends CrudRepository<MemberPrincipal, Long> {}

MemberPrincipal

캐싱하는 대상 객체 MemberPrincipal 클래스에 @RedisHash 어노테이션을 적용하여 캐싱할 것이라고 알려주어야한다. 이때 파라미터로 value, timeToLive에 값을 지정해주었는데, 각각 다음과 같은 의미를 가진다.

  • value: Redis에서 key에 사용되며, id가 1일 경우 Member:1이 key가 된다.
  • timeToLive: 캐싱 유효 기간을 의미한다. 아래에서는 24시간(1일)로 설정해주었다.

그리고 id는 @Id 어노테이션(org.springframework.data.annotation.Id)을 사용하여 명시해주어야한다.

@Builder  
@RedisHash(value = "Member", timeToLive = 24 * 60 * 60)  
public record MemberPrincipal(  
        @Id Long id,  
        String email,  
        @JsonIgnore String password,  
        @JsonIgnore Collection<? extends GrantedAuthority> authorities,  
        String nickname,  
        double rating,  
        String fileName,  
        Address address,  
        @JsonIgnore Map<String, Object> oAuth2Attributes)  
        implements UserDetails, OAuth2User {  

    @Override  
    public Map<String, Object> getAttributes() {  
        return oAuth2Attributes;  
    }  

    @Override  
    public String getName() {  
        return email;  
    }  

    @Override  
    public Collection<? extends GrantedAuthority> getAuthorities() {  
        return authorities;  
    }  

    @Override  
    public String getPassword() {  
        return password;  
    }  

    @Override  
    public String getUsername() {  
        return email;  
    }  

    @Override  
    public boolean isAccountNonExpired() {  
        return true;  
    }  

    @Override  
    public boolean isAccountNonLocked() {  
        return true;  
    }  

    @Override  
    public boolean isCredentialsNonExpired() {  
        return true;  
    }  

    @Override  
    public boolean isEnabled() {  
        return true;  
    }  
}

로그인 구현

@Service  
@RequiredArgsConstructor  
public class MemberService {  
    private final MemberRepository memberRepository;  
    private final MemberCacheRepository memberCacheRepository;  
    private final PasswordEncoder encoder;  
    private final TokenProvider tokenProvider;  
    private final FileService fileService;  

    public Member getMemberByEmail(String email) {  
        return memberRepository.findByEmail(email).orElseThrow(() -> new MemberException(ErrorCode.NOT_FOUND_MEMBER));  
    }  

    public MemberPrincipal getMemberCache(Long memberId) {  
        return memberCacheRepository  
                .findById(memberId)  
                .orElseGet(() -> MemberPrincipal.fromEntity(getMemberById(memberId)));  
    }  

    /**  
     * 로그인  
     */
    public LoginResponse signIn(String email, String password) {  
        // 유저 정보 조회
        Member member = getMemberByEmail(email); 
        // 비밀번호 일치 확인 
        checkPassword(password, member.getPassword());  
        // 토큰 발급  
        String accessToken = tokenProvider.generateAccessToken(member.getId());
        // 유저 정보 캐싱  
        memberCacheRepository.save(MemberPrincipal.fromEntity(member));  

        return LoginResponse.of(accessToken);  
    }  

    private void checkPassword(String inputPassword, String existPassword) {  
        if (!encoder.matches(inputPassword, existPassword)) {  
            throw new AuthException("not matched password");  
        }  
    }  
}

모니터링

Jmeter를 사용하여 간단하게 게시글 상세 조회 요청을 1000번 요청해보았고, 그라파나를 통해서 모니터링을 수행해보았다.

 

결과를 확인하기 전, 게시글 상세 조회할 때 DB에 쿼리가 실행되는 부분을 확인해보면 다음과 같다.

  1. 유저 정보 조회 (유저 정보를 캐싱하지 않았을 경우)
  2. 조회수 업데이트
  3. 게시글 조회

기대한 결과는 캐싱했을 경우이기 때문에, 캐싱 후 게시글을 조회할 때마다 쿼리가 2번만 수행되어야한다.

 

이제 모니터링 결과를 확인해보면 다음과 같이 나오는 것을 확인할 수 있다. Hikaricp Connection 부분만 캡쳐했고, 총 Connection 획득 수를 확인했다.

 

처음 Spring Boot 애플리케이션을 수행하면 4번 Connection을 획득하기 때문에, 1000번의 요청을 수행하고 난 후 3004번의 Connection을 획득했다는 것은 요청 당 3번의 Connection을 획득했다는 의미라고 볼 수 있다. 즉, 유저 정보 조회 부분에서 캐싱을 적용했음에도 Connection을 획득하여 데이터를 조회하는 것이다.

어디서 Connection 가져올까?

RedisTemplate 클래스의 execute 메소드에서 RedisConnectionUtils.getConnection()으로 RedisConnection이라는 객체를 가져오는 것을 확인할 수 있었다.

RedisCacheManager

그러면 Repository를 사용하지 않고 RedisCacheManager를 사용하여 @Cacheable, CacheEvict 어노테이션을 적용하여 캐싱해주면 되지 않을까?

 

바로 코드를 수정해보자.

RedisConfig

먼저 RedisCacheManager를 빈으로 등록해주어야한다.

@Bean  
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {  

    RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()  
            .disableCachingNullValues()  // null 값은 캐싱하지 않는다.
            .entryTtl(Duration.ofDays(1L))  // TTL을 1일로 설정
            .computePrefixWith(CacheKeyPrefix.simple())   // "::"을 구분자로 key값 을 구성
            .serializeKeysWith(  
                    RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))  // key를 String 형식으로 지정
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(  
                    new GenericJackson2JsonRedisSerializer())); // value는 JSON 형식으로 지정  

    Map<String, RedisCacheConfiguration> redisCacheConfigurationMap = new HashMap<>();  
    // memberCache라는 이름을 가지는 캐시에 설정한 부분을 적용
    redisCacheConfigurationMap.put("memberCache", configuration);  

    // RedisCacheManager 생성 후 반환
    return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory)  
            .cacheDefaults(configuration)  
            .withInitialCacheConfigurations(redisCacheConfigurationMap)  
            .build();  
}

@EnableCaching

@EnableCaching 어노테이션도 뺴먹지 말고 추가해주어야한다. 아래에서는 Application에 어노테이션을 적용해주었지만, 위 RedisConfig에 적용해도 괜찮다. Application에 적용하는 이유는 @Enable~ 형태의 어노테이션을 한 곳에 모두 추가해주는 것이 관리하기 편하기 때문이다.

@EnableCaching  
@SpringBootApplication  
public class Application {  

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

MemberPrincipal

MemberPrincipal은 사용하지 않는 필드를 모두 @JsonIgnore 어노테이션을 적용하여 제외시켜준다. 이때, authorities은 deserialize가 안되기 때문에 userRole이라는 필드를 추가하여 대신 값을 추가해주었다.

@Builder  
public record MemberPrincipal(  
        Long id,  
        String email,  
        @JsonIgnore String password,  
        @JsonIgnore Collection<? extends GrantedAuthority> authorities,  
        UserRole userRole,  
        String nickname,  
        double rating,  
        String fileName,  
        Address address,  
        @JsonIgnore Map<String, Object> oAuth2Attributes)  
        implements UserDetails, OAuth2User {  


    @JsonIgnore  
    @Override public Map<String, Object> getAttributes() {  
        return oAuth2Attributes;  
    }  

    @JsonIgnore  
    @Override public String getName() {  
        return email;  
    }  

    @Override  
    public Collection<? extends GrantedAuthority> getAuthorities() {  
        return Objects.isNull(authorities) || authorities.isEmpty()  
                ? Set.of(new SimpleGrantedAuthority(userRole.getName()))  
                : authorities;  
    }  

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

    @JsonIgnore  
    @Override public String getUsername() {  
        return email;  
    }  

    @JsonIgnore  
    @Override public boolean isAccountNonExpired() {  
        return true;  
    }  

    @JsonIgnore  
    @Override public boolean isAccountNonLocked() {  
        return true;  
    }  

    @JsonIgnore  
    @Override public boolean isCredentialsNonExpired() {  
        return true;  
    }  

    @JsonIgnore  
    @Override public boolean isEnabled() {  
        return true;  
    }  
}

유저 정보 캐시 조회

이제 로그인 후 유저 정보를 캐싱하는 것이 아니라 발급한 토큰을 사용하여 API 요청을 수행했을 때, 캐싱하도록 구현한다.

 

우선 HTTP의 Authorization 헤더에 토큰을 전달했을 때, Filter를 통해 처리하는 로직에서 캐싱을 조회하도록 한다.

 

편의상 불필요한 부분의 코드는 제외하고 사용되는 부분만 공유하면 다음과 같다.

private void setAuthentication(HttpServletRequest request, String accessToken) {  
    MemberPrincipal memberPrincipal = getMemberPrincipal(accessToken);  
    UsernamePasswordAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(  
            memberPrincipal, accessToken, memberPrincipal.getAuthorities());  
    authenticated.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));  
    SecurityContextHolder.getContext().setAuthentication(authenticated);  
}  

private MemberPrincipal getMemberPrincipal(String accessToken) {  
    Long memberId = tokenProvider.getMemberIdFromToken(accessToken); 
    // 유저 정보 캐시 조회 
    return memberService.getMemberCache(memberId);  
}

getMemberCache() 메소드는 다음과 같이 간단하게 @Cacheable을 적용해주면 된다.

@Cacheable(value = "memberCache", key = "#memberId")  
public MemberPrincipal getMemberCache(Long memberId) {  
    return MemberPrincipal.fromEntity(getMemberById(memberId));  
}

public Member getMemberById(Long memberId) {  
    return memberRepository.findById(memberId).orElseThrow(() -> new MemberException(ErrorCode.NOT_FOUND_MEMBER));  
}

그러면 처음 요청 시에 DB에서 유저 정보를 조회하고 "memberCache::{memberId}" 형식으로 캐시가 저장된다.

모니터링 결과

이번에는 간단하게 Postman을 사용하여 100번 요청을 수행했고, 205번 Connection을 획득한다는 사실을 확인할 수 있었다. 초기 실행 시 획득한 4번을 제외하면 201번(100 * 2 + 1) Connection을 획득하는 것이다. 처음 요청 시 DB에서 유저 정보를 조회하기 때문에 200번이 아니라 201번이 되는 모습이다.

정리

Spring Data Repository는 RDBMS 뿐만 아니라 Redis와 같은 NoSQL를 사용할 때도 Connection을 획득한다는 사실을 알 수 있었다. 이로써 매번 API 요청 시 DB에 유저 정보를 조회할 때 DB Connection을 획득하여 비효율적으로 동작하던 부분을 개선할 수 있었다.

하지만 응답 속도는 큰 차이 없는데, 그 이유는 PK로 DB에서 조회하고 PK는 이미 인덱싱이 적용되어있으며 하나의 데이터만 fetching하기 때문이다. 만약 응답 속도를 개선시키려면 Qeury Cache를 적용해보는 것은 어떨까라는 생각을 해보았다.

728x90