JSON-OBJECT Software Engineering Blog

Professional Senior Backend Engineer. Specializing in high volume traffic and distributed processing with Kotlin and Spring Boot as core technologies.

View on GitHub
22 March 2023

Spring Boot, Redis를 이용하여 API 중복 실행 요청 방지 로직 구현하기

by Taehyeong Lee

개요

운영체제 환경 변수 추가

SPRING_REDIS_HOST={redis-host}
SPRING_REDIS_PORT={redis-port}
SPRING_REDIS_MODE=STANDALONE

라이브러리 종속성 추가

dependencies {
    implementation("org.springframework.data:spring-data-redis:3.0.4")
    implementation("io.lettuce:lettuce-core:6.2.3.RELEASE")
}

@Configuration 클래스 작성

import io.lettuce.core.ClientOptions
import io.lettuce.core.SocketOptions
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisClusterConfiguration
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
import java.time.Duration

@Configuration
class RedisConfig(

    @Value("\${spring.redis.host}")
    private val REDIS_HOST: String,

    @Value("\${spring.redis.port}")
    private val REDIS_PORT: Int,

    @Value("\${spring.redis.mode}")
    private val REDIS_MODE: String
) {
    @Bean("lettuceConnectionFactory")
    fun lettuceConnectionFactory(): LettuceConnectionFactory {

        if (REDIS_MODE == "STANDALONE") {
            return LettuceConnectionFactory(RedisStandaloneConfiguration(REDIS_HOST, REDIS_PORT))
        }

        val clusterConfiguration = RedisClusterConfiguration().apply {
            clusterNode(REDIS_HOST, REDIS_PORT)
        }

        val clientConfiguration = LettuceClientConfiguration.builder()
            .clientOptions(
                ClientOptions.builder()
                    .socketOptions(
                        SocketOptions.builder()
                            .connectTimeout(Duration.ofSeconds(10)).build()
                    )
                    .build()
            )
            .commandTimeout(Duration.ofSeconds(10)).build()

        return LettuceConnectionFactory(clusterConfiguration, clientConfiguration)
    }

    @Bean("stringRedisTemplate")
    fun stringRedisTemplate(
        @Qualifier("lettuceConnectionFactory") lettuceConnectionFactory: LettuceConnectionFactory
    ): StringRedisTemplate {

        return StringRedisTemplate(lettuceConnectionFactory)
    }
}

RequestLockType Enum 클래스 작성

import java.time.Duration

enum class RequestLockType(val lockDuration: Duration) {

    CREATE_FOO(Duration.ofSeconds(10)),
    UPDATE_FOO(Duration.ofSeconds(10)),
    DELETE_FOO(Duration.ofSeconds(10))
}

RequestLockService 클래스 작성

import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.stereotype.Service
import java.time.Duration

@Service
class RequestLockService(
    private val stringRedisTemplate: StringRedisTemplate
) {
    fun generateLockKey(requestLockType: RequestLockType, vararg params: Any?): String {

        return "REQUEST_LOCKS/${requestLockType.name}/${params.joinToString("_#_")}"
    }

    fun ifLockedThrowExceptionElseLock(lockKey: String, lockDuration: Duration = Duration.ofMinutes(1)) {

        try {
            stringRedisTemplate
                .opsForValue()
                .setIfAbsent(lockKey, "locked", lockDuration)
                // 중복 실행 요청이 들어왔을 경우 예외 발생 로직 작성
                // 각 애플리케이션 상황에 특화된 부분이므로 상황에 맞게 작성
                ?.also { if (!it) throw CustomException(CustomErrorCode.REQUEST_LOCKED) }

        } catch (ex: Exception) {
            if (ex is CustomException) throw ex
            // Redis 오류 발생시 예외 처리 로직 작성
        }
    }

    fun unlock(key: String?) {

        key ?: return

        try {
            stringRedisTemplate.delete(key)
        } catch (ex: Exception) {
            // Redis 오류 발생시 예외 처리 로직 작성
        }
    }
}

Lock 생성 및 해제 예

// 임의에 Foo 오브젝트 업데이트 실행에 대해 Lock을 생성
// 이미 Lock이 생성되었을 경우, REQUEST_LOCKED 예외 발생
val lockKey = requestLockService
    .generateLockKey(RequestLockType.UPDATE_FOO, {foo.id})
    .also { requestLockService.ifLockedThrowExceptionElseLock(it, RequestLockType.UPDATE_FOO.lockDuration) }

try {
    // Lock 생성 대상이 되는 로직 작성
    fooService.update({foo})
}
finally {
    // 실행 종료되면 Lock 해제
    requestLockService.unlock(lockKey)
}
tags: Spring Boot - Redis