프레임워크/Spring

@Transactional, @Cacheable 및 Redis 호환 문제 해결

김민석(갈레, 페퍼) 2022. 9. 4. 17:05
반응형

<그림1> 스프링 & redis

✍️ 문제 상황

  • 통합 테스트 구동 시 회원가입이 돼있지 않은 코드가 로그인에 성공하는 문제가 있었습니다. 로그인 하기 전에 유저 정보가 저장돼있는지 확인하는 절차까지 진행했지만 문제 상황은 여전했습니다. 이에 유저 로그인 로직에 문제가 있다고 파악하여 로그인 로직에 로그를 남겼고 @Transactional 상황에서 @Cacheable이 적용된 데이터가 rollback 되지 않는게 문제의 원인이라고 판단했습니다.
@DisplayName("로그인, 실패 시나리오")
@Test
void login_failed() throws Exception {
    // given
    // 가입되지 않은 사용자
    // userService.createUser(userDTO);

    // when
    // 유스케이스/로그인/Fail path
    ResultActions resultActions = mockMvc.perform(post("/login")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(loginInfo))
            .with(csrf()));

    // then
    // 1. 서비스 이용자 인증이 실패할 시 실패 메세지를 반환한다.
    // 응답 코드 확인
		// ** 문제 상황 ** : 성공을 반환함!
    resultActions.andExpect(status().isUnauthorized());
    // 실패 메세지 확인
    CustomAssertionUtils.assertStandardResponseBodyContainsMessage(objectMapper, resultActions, messageSourceAccessor.getMessage("jwt.invalid.user"));
}

 

✍️ 접근 방법과 해결 과정

@Transactional과 @Cacheable

  • 문제 상황에서 원인 발생 범위를 좁힌 결과, @Transactional 상황에서 @Cacheable이 적용된 데이터가 rollback 되지 않는게 문제의 원인이였습니다. @Transactional과 @Cacheable은 모두 동적 프록시로 동작하므로 두개가 기본적으론 서로 독립적으로 동작한다고 가설을 세웠습니다. 이에 이를 해결할 수 있는 방법이 있는지 검색했고, TransactionAwareCacheManagerProxy가 이를 해결할 수 있다고 판단했습니다. docs.spring.io에 따르면 TransactionAwareCacheManagerProxy는 Transaction이 commit할 때 실질적으로 put을 합니다. 결국 @Transactional이 붙은 테스트 클래스에선 캐싱이 저장될 일이 없기 때문에 문제가 해결될 것입니다.
@Bean
public CacheManager redisCacheManager() {
    CacheManager cacheManager = RedisCacheManager.RedisCacheManagerBuilder
    return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisCacheConnectionFactory())
            .cacheDefaults(defaultRedisCacheConfiguration())
            .build();

		// 프록시 코드 추가!!
    return new TransactionAwareCacheManagerProxy(cacheManager);
}

 

TransactionAwareCacheManagerProxy 적용

  • TransactionAwareCacheManagerProxy를 적용하면 다른 테스트 혹은 구현에 영향이 갈지 생각했습니다. Cache 정보는 Redis에 저장되기 때문에 ‘Redis에 데이터가 저장되는지 확인하는 통합 테스트’에 문제가 될 거라고 판단했고, 테스트한 결과 해당 통합 테스트가 실패하는 side effect가 발생했습니다. 제가 작성 했던 통합 테스트 코드는 Transaction 내부에서 실제로 데이터가 redis에 저장됐는지 확인하는 코드였기 때문입니다. 이에 해당 테스트 메소드의 @Transactional을 지웠습니다. 이후 해당 테스트 클래스의 매 테스트 메소드 마다 redisCacheTemplate.delete(redisCacheTemplate.keys("*"));를 호출하는 코드를 작성하여 redis의 멱등성을 유지했습니다.(현재 문단에서 하는 추론 과정에는 오류가 있습니다. 해당 오류들은 아래 문단들에서 해결될 예정입니다.)
@DisplayName("Redis cache 업로드 테스트")
@Test
@Transactional
public void testRedisCacheUploaded() {
    checkRedisOperationWorks(redisCacheTemplate);
}

void checkRedisOperationWorks(RedisTemplate redisTemplate) {
    String targetKey = "testKey";
    String targetValue = "testValue";
    Object storedValue = redisTemplate.opsForValue().get(targetKey);
    assertThat(storedValue).isNull();

    redisTemplate.opsForValue().set(targetKey, targetValue);
    storedValue = redisTemplate.opsForValue().get(targetKey);

		// 에러 발생!! commit이 안됐기 때문에 get의 결과는 Null이기 때문.
    assertThat(storedValue).isNotNull();
}

 

@Transactional과 Redis의 호환 문제

  •  Cache를 위한 Redis는 멱등성 문제가 해결됐지만, Session을 위한 Redis에서 멱등성 문제가 있었습니다. 이에 현재 문제 발생 원인이 @Cacheable과 @Transactional의 호환 문제가 아니라 @Transactional과 Redis의 호환 문제인 것으로 파악했습니다.
  •  문제 재정의 후 @Transactional의 작동 원리에 대해 학습했습니다.@Transactional은 내부에서 PlatformTransactionManager에 의해 작동 되고 있었고, spring boot는 기본적으로 DatasourceTransactionManager를 PlatformTransactionManager의 구현체로 사용하고 있었습니다. DatasourceTransactionManager는 동적 프록시 방식으로 datasource를 사용하여 commit과 rollback을 제어하고 있던 것이죠.

참조 : https://docs.spring.io/spring-framework/docs/4.2.x/spring-framework-reference/html/transaction.html

 

16. Transaction Management

16.2 Advantages of the Spring Framework’s transaction support model Traditionally, Java EE developers have had two choices for transaction management: global or local transactions, both of which have profound limitations. Global and local transaction ma

docs.spring.io

 

Spring Data Redis 분석

  • Spring Data Redis에 의하면 Spring Data Redis는 PlatformTransactionManager의 구현체를 함께 제공하지 않습니다. 하지만 Spring은 Transaction 관리를 PlatforomTransactionManager로 하고 있기 때문에 Redis는 해당 관리자에게 참여 요청만 하면 됩니다. 결국 RedisTemplate은 default론 managed spring transactions에 참여하지 않기 spring transactions에 참여시키기 위해 몇가지 설정을 따로 해줘야 합니다. 이중 1,3,4번은 Spring boot가 AutoConfigure에서 대신 해주기 때문에 현재 프로젝트에서는 따로 할 필요가 없었습니다. 이에 2번만 새로 코드를 추가했습니다.
    1. @EnableTransactionManagement 선언
    2. template.setEnableTransactionSupport(true);
    3. DataSourceTransactionManager 빈 등록
    4. DataSource 빈 등록

참조 : https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#tx.spring

 

Spring Data Redis

Some commands (such as SINTER and SUNION) can only be processed on the server side when all involved keys map to the same slot. Otherwise, computation has to be done on client side. Therefore, it is useful to pin keyspaces to a single slot, which lets make

docs.spring.io

 

리팩토링

  •  저는 이전에 문제를 재정의 했습니다. @Cacheable과 @Transactional의 호환 문제 → @Transactional과 Redis의 호환 문제로 말이죠. 그렇기 때문에 문제를 전자로 정의했을 때 작성했던 TransactionAwareCacheManagerProxy 코드를 삭제해도 좋다고 판단 후 이를 삭제했습니다.
Q. Cache와 Redis는 다른 모듈이기 때문에 해당 코드를 유지해도 되지 않나요?
답변1 : 해당 프록시는 트랜잭션의 rollback과 commit을 cache가 비슷하게 작동되도록 만듭니다. 저희는 위에서 redisTemplate.setEnableTransactionSupport(true);를 사용했기 때문에 이미 Transaction의 작동에 redis 연산이 종속적입니다. 따라서 따로 프록시를 둘 필요가 없죠.
답변2 : 물론 redisTemplate.setEnableTransactionSupport(true);는 redis에 적용한 것이기 때문에 cache에 Transaction을 따로 적용할려면 TransactionAwareCacheManagerProxy를 사용하는게 좋습니다. 하지만 현 애플리케이션은 redisCacheManager가 cacheManager와 강한 결합을 하고 있기 때문에 이중으로 Transaction 연결성을 만들 필요는 없다고 판단했습니다.

 

Error 발생 & RedisCacheManager 작동 원리 분석

  • 삭제 후에 로컬 환경에서 유닛&통합 테스트를 진행했으며 모두 성공했습니다. 이에 PR를 하여 CI 내 Ubuntu 환경에서도 테스트를 진행했습니다. 하지만 Ubuntu 환경에서 처음에 발생했던 Login 부분에서 문제가 발생하여 테스트가 실패했습니다. 윈도우 환경에서는 성공하고 우분투 환경에서는 실패했기에 운영체제가 원인일 수도 있지만 현재 작성한 코드는 운영체제의 종류와 큰 상관이 없기 때문에 테스트간 독립성 유지 실패가 주 요인이라고 판단했습니다.

    이후 코드를 다시 분석한 후, Redis와 redistemplate을 동일시한 것이 문제의 원인일 수도 있다고 생각했습니다. 실제로 저는 Redis의 연산이 @transactional에 의존하기 위해 redisTemplate.setEnableTransactionSupport(true); 설정을 했었습니다. 해당 설정은 redisTemplate의 연산이 @transactional에 의존하게 만들죠. 하지만 cacheManager가 redisTemplate을 사용하지 않는다면 @transactional에 의존하지 않는 상태일 가능성이 있었습니다. 이에 cacheManager의 작동 과정을 살펴봤습니다.

     RedisCacheManager는 RedisCacheWriter cacheWriter를 이용해서 캐시에 데이터를 사용합니다. RedisCacheManage는 redisTemplate을 사용하지 않는거죠. 따라서 redisTemplate을 spring's managed transaction에 의존성을 설정해줘야할 뿐 아니라, RedisCacheManager에도 종속성 설정을 해줘야했습니다.

 

✍️ 결과

  • @Trasactional과 @Cacheable, @Transactional과 Redis 호환 문제를 겪으며 Spring이 관리하는 트랜잭션, Redis와 추상화된 캐시의 작동 원리를 파악한 후 통합 테스트를 위한 DB 멱등성 유지할 수 있는 설정을 완료했습니다.

 

📙 레퍼런스

반응형