Spring Boot - Cache!

Spring Data redis

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

application.properties 에 아래와 같이 설정 한 후 Bean 객체 생성이 필요하다.

redis.host=localhost
redis.port=6379
redis.timeout=0
@Slf4j
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
    @Value("${redis.host}")
    private String host;
    @Value("${redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }
}

redis 서버와 연결을 위한 객체를 생성하는 RedisConnectionFactory,

Spring Data Redis 프로젝트를 사용해서 CrudRepository 구현체를 통해
redis 에 저장할 도메인 객체를 정의할 수 있다.

아래와 같이 @RedisHash 어노테이션을 사용한다.

@AllArgsConstructor
@ToString
@RedisHash("point")
public class Point {
    @Id
    private String id;
    private Long amount;
    private LocalDateTime refreshTime;

    public void refresh(Long amount, LocalDateTime refreshTime) {
        if (refreshTime.isAfter(this.refreshTime)) {
            this.amount = amount;
            this.refreshTime = refreshTime;
        }
    }
}
public interface PointRedisRepository extends CrudRepository<Point, String> {
}
@Slf4j
@RestController
@RequestMapping("/repository")
@RequiredArgsConstructor
public class RedisRepositoryController {

    private final PointRedisRepository repository;

    @GetMapping("/{id}")
    public Point getPointById(@PathVariable String id) {
        return repository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("invalid id:" + id));
    }

    @PostMapping
    public Point addPoint(@RequestBody Point point) {
        return repository.save(point);
    }

    @DeleteMapping("/{id}")
    public void removePoint(@PathVariable String id) {
        repository.deleteById(id);
    }

    @PatchMapping("/{id}")
    public Point updatePoint(@PathVariable String id, @RequestBody Point point) {
        Point entity = repository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("invalid id:" + id));
        entity.update(point);
        entity= repository.save(entity);
        return entity;
    }
}

CrudRepository 통해 객체타입의 데이터를 redis 에 저장하면 hashset 데이터구조로 관리한다.

아래와 같이 curl 로 2개의 point 를 저장하고 redis-cli 로 내부값을 살펴보면
2개의 hash 타입 데이터, 1개의 set 타입 데이터가 저장된 것을 확인할 수 있다.

curl --location 'http://127.0.0.1:8080/repository' \
--header 'Content-Type: application/json' \
--data '{
    "id": "1",
    "amount": "1000",
    "refreshTime": "2023-03-22T11:11:11.000"
}'

curl --location 'http://127.0.0.1:8080/repository' \
--header 'Content-Type: application/json' \
--data '{
    "id": "2",
    "amount": "2000",
    "refreshTime": "2023-03-22T22:22:22.000"
}'
$ redis-cli

127.0.0.1:6379> keys *
# 1) "point:1"
# 2) "point"
# 3) "point:2"

127.0.0.1:6379> smembers point
# 1) "1"
# 2) "2"

127.0.0.1:6379> HGETALL point:1
# 1) "_class"
# 2) "com.example.redis.model.Point"
# 3) "amount"
# 4) "1000"
# 5) "id"
# 6) "1"
# 7) "refreshTime"
# 8) "2023-03-22T11:11:11"

127.0.0.1:6379> HGETALL point:2
# 1) "_class"
# 2) "com.example.redis.model.Point"
# 3) "amount"
# 4) "2000"
# 5) "id"
# 6) "2"
# 7) "refreshTime"
# 8) "2023-03-22T22:22:22"

RedisTemplate

위에서 생성했던 RedisConnectionFactory 빈 객체를 주입해서 RedisTemplate 구성할 수 있다.

@Bean
public RedisTemplate<?, ?> redisTemplate(@Autowired RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    return redisTemplate;
}


@Bean
public StringRedisTemplate stringRedisTemplate(@Autowired RedisConnectionFactory redisConnectionFactory) {
    StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
    stringRedisTemplate.setConnectionFactory(redisConnectionFactory);
    return stringRedisTemplate;
}

범용적으로 사용하기 위해 객체를 json 으로 직렬화 저장하는 경우가 많은데, 이 때 StringRedisTemplate 을 사용하면 좋다.

테스트를 위한 컨트롤러를 생성

@Slf4j
@Controller
@RequestMapping("/template")
public class RedisTemplateController {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @GetMapping("/sample")
    public void sample(@RequestParam(required = false) String key, Model model) {
        log.info("sample invoked");
        Set<String> keys;
        if (StringUtils.hasText(key)){
            keys = stringRedisTemplate.keys(key);
        }
        else {
            keys= stringRedisTemplate.keys("*");
        }
        model.addAttribute("keys", new ArrayList<>(keys));
    }

    @GetMapping("/insert")
    public String insert(@RequestParam String key, @RequestParam String value) {
        log.info("insert invoked, {}:{}", key, value);
        stringRedisTemplate.opsForValue().set(key, value);
        stringRedisTemplate.opsForValue().get(key);
        return "redirect:/template/sample";
    }


    @GetMapping("/deleteAll")
    public String deleteAll(Model model) {
        log.info("deleteAll called()....");
        stringRedisTemplate.delete(stringRedisTemplate.keys("*"));
        return "redirect:/template/sample";
    }
}

CrudRepository를 사용해서 redis에 데이터를 삽입해도 되지만 StringRedisTemplate 혹은 그냥 RedisTemplate의 메서드로도 충분이 삽입 가능하다.

<!DOCTYPE html>
<html class="no-js" lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    sample_radis

    <form action="/redis/insert">
        key: <input type="text" name="key" >
        value: <input type="text" name="value">
        <button type="submit">submit</button>
    </form>
    <button onclick="location.href='/redis/deleteAll'">delete all</button>

    <p th:each="p:${keys}">[[${p}]]</p>
</body>
</html>

지금까지 저장된 키 목록이 출력된다.

Redisson 분산락

https://github.com/redisson/redisson

Redis 기반 분산락 기능을 지원하는 라이브러리.

Pub/Sub 기반의 락을 사용하기에, 계속해서 CPU 자원을 사용하는 스핀락보다 효율적이다.

// 내부적으로 spring data redis 사용
implementation 'org.redisson:redisson-spring-boot-starter:3.30.0'
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://localhost:6379");
    return Redisson.create(config);
}
@Slf4j
@Service
@RequiredArgsConstructor
public class DistributedLockService {

    private final RedissonClient redissonClient;
    private Integer count = 0;

    public void executeWithLock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLocked = false;
        try {
            // 락을 획득하기 위해 100초 동안 시도하고, 10초 동안 락을 유지.
            isLocked = lock.tryLock(100, 10, TimeUnit.SECONDS);
            if (isLocked) {
                // 락을 획득한 상태에서 실행할 작업
                Thread.sleep(100);
                count += 1;
                log.info("Lock acquired. Executing protected code. counter:{}", count);
            } else {
                log.info("Could not acquire lock.");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (isLocked) {
                lock.unlock(); // TTL 을 넘길경우 unlock 호출시 IllegalMonitorStateException 발생
                log.info("Lock released.");
            }
        }
    }

    public void executeWithoutLock() {
        try {
            Thread.sleep(100);
            count += 1;
            log.info("Lock acquired. Executing protected code. counter:{}", count);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

해당 라이브러리의 주의해야할 점은 LOCK TTL 동안 임계코드 실행이 완료되지 않는다면 중복실행될 수 있다.
TTL 로 인해 Lock 이 해제되어도 어플리케이션 코드는 지속 실행되다 lock.unlock() 시점에야 TTL 을 넘겼음을 알 수 있다.

데드락 상황을 감수하고 TTL 을 무제한으로 설정한다면 확실하게 임계영역을 지킬 수 있다.

AOP 분산락

try, catch, finally 형태의 분산락 구조를 AOP 형태로 구성하는것이 대부분.

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String value();
}

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
    private final RedissonClient redissonClient;

    @Around("@annotation(DistributedLock)")
    public Object executeWithLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        DistributedLock distributedLock = signature.getMethod().getAnnotation(DistributedLock.class);
        String lockKey = distributedLock.value();  // 어노테이션에서 락 키 가져오기
        RLock lock = redissonClient.getLock(lockKey);
        log.info("Lock acquired. Executing protected code.");
        try {
            lock.lock();
            Object result = joinPoint.proceed();
            return result;
        } finally {
            lock.unlock();
            log.info("Lock released.");
        }
    }
}

@DistributedLock("executeWithAopLock")
public void executeWithAopLock() {
    // 비즈니스 로직 실행
    log.info("business code invoked");
    for (int i = 0; i < 10; i++) {
        count += 1;
    }
    log.info("business end");
}

데모코드

Redis demo https://github.com/Kouzie/spring-boot-demo/tree/main/redis-demo

카테고리:

업데이트: