DevLog ๐ถ
[Redis] Redis๋ ์ธ์ ํ์ฉํ ์ ์์๊น? 1ํธ - ๋ถ์ฐ๋ฝ ๊ตฌํํ๊ธฐ (1) ๋ณธ๋ฌธ
[Redis] Redis๋ ์ธ์ ํ์ฉํ ์ ์์๊น? 1ํธ - ๋ถ์ฐ๋ฝ ๊ตฌํํ๊ธฐ (1)
dolmeng2 2023. 10. 17. 09:23๐ฑ ๋ค์ด๊ฐ๊ธฐ ์
์กฐ๊ธ ์ค๋๋๊ธด ํ์ง๋ง ์ด์ ํฌ์คํ
์์ ๋ค์๋๋ฝ์ ํตํ ๋ถ์ฐ๋ฝ ๊ตฌํ์ ์งํํ์๋ค. ๋ง์ง๋ง ํฌ์คํ
์์ ๋ ๋์ค๋ฅผ ํ์ฉํ๋ค๊ณ ๋งํ์๋๋ฐ, ๊ณ์ ๋ฏธ๋ฃจ๋ค๊ฐ ์ด๋ฒ์ ๋ ๋์ค ๊ณต๋ถ๋ฅผ ํ๋ฉด์ ๊ฐ๋จํ๊ฒ ๊ตฌํ์ ์งํํด๋ณด์๋ค. ๋ถ์ฐ๋ฝ์ ๋ํ ๊ฐ๋
์ ์ด์ ํฌ์คํ
์์ ๋ค๋ฃจ์๊ธฐ ๋๋ฌธ์, ๋ ๋์ค๋ฅผ ์ด์ฉํด์ ๋ฐ๋ก ๋ถ์ฐ๋ฝ์ ๊ตฌํํด๋ณด์. ๋ํ, ์ง๋ ํฌ์คํ
์์๋ Jmeter๋ฅผ ํ์ฉํ์์ง๋ง, ์ด๋ฒ ํฌ์คํ
์์๋ E2E ํ
์คํธ๋ฅผ ํตํด ์ง์ ๊ตฌํ์ ํด๋ณด๊ณ ์ ํ๋ค.
๐ฑ ๊ตฌํ ์ํฉ
์ ๋ง ๊ฐ๋จํ๊ฒ ์์ก์ ์๋ฏธํ๋ 'Balance'๋ผ๋ ๊ฐ์, 2๊ฐ์ ์์ฒญ์ด ๋์์ ๋ค์ด์์ ๋ ๋ ๋์ค๋ฅผ ํตํ ๋ถ์ฐ๋ฝ์ ๊ตฌํํ์ฌ ์ ์ดํด๋ณด์.
์์ ๊ฒฐ๊ณผ์์ ์ต์
์ ์ํฉ์ด๋ผ๋ฉด Request 1, 2๋ฒ ๋ชจ๋ 80์ด๋ผ๋ ๊ฐ์ ๋ฐํํ์ฌ ์ ์์ ์ผ๋ก ์์ก์ด ๊ฐฑ์ ๋์ง ์์ ์ ์๋ค.
์ฐ๋ฆฌ๋ ์๋์ ๊ฐ์ ํ๋ก์ฐ๋ฅผ ๊ตฌํํ ์์ ์ด๋ค.
1. ๊ณต์ ์์์ ํํํ ์ ์๋ ์ด๋ฆ์ ์์ฑํ์ฌ ๋ฝ์ ํค๋ฅผ ๊ฒฐ์ ํ๊ธฐ
2. NX ์ต์ ์ ํตํด์ ํด๋น ํค๊ฐ ์กด์ฌํ์ง ์๋ ๊ฒฝ์ฐ์๋ง ๋ฝ์ ํ๋ํ๊ธฐ
3. ์ด๋ฏธ ๋ฝ์ ํ๋ ํ ๊ฒฝ์ฐ nil์ ๋ฐํํ์ฌ ํ๋ก์ธ์ค ๋๊ธฐํ๊ธฐ
4. ์์์ ์์ ์ด ๋๋๋ฉด DEL์ ํตํด ๋ฝ ์ ๊ฑฐํ๊ธฐ
์ฌ๊ธฐ์ NX์ DEL์ ๋ ๋์ค์์ ์ฌ์ฉํ๋ ๋ช
๋ น์ด๋ค.
NX๋ 'IF NOT EXISTS SET'๊ณผ ๊ฐ์ด, ํค๊ฐ ์กด์ฌํ์ง ์์ ๊ฒฝ์ฐ์๋ง SET์ ํด์ค๋ค. ์ฑ๊ธ ์ค๋ ๋๋ก ๋์ํ๋ ๋ ๋์ค์์ ํค๊ฐ ์กด์ฌํ๋์ง ํ๋จํ๊ธฐ ์ํด์๋ GET์ผ๋ก ํ์ธ ํ SET์ ํด์ผ ํ๋ค. ํ์ง๋ง ๋ฉํฐ ์ค๋ ๋ ํ๊ฒฝ์ธ ์คํ๋ง ๊ฐ์ ๊ณณ์์ ์ฌ์ฉํ ๋ ์ ํฉ์ฑ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์๊ธฐ ๋๋ฌธ์, ํ ๋ฒ์ ์ค๋ณต์ ์ฒดํฌํ๊ณ ์ฝ์
๊น์ง ์งํํ ์ ์๋๋ก ๋ง๋ค์ด์ง ๋ช
๋ น์ด๋ผ๊ณ ์๊ฐํ๋ฉด ์ดํดํ๊ธฐ ์ฝ๋ค.
DEL์ ๊ฒฝ์ฐ ์ฝ๊ฒ ์์ํ ์ ์๋ฏ, 'DELETE'๋ฅผ ์๋ฏธํ๋ ํค ์ญ์ ๊ธฐ๋ฅ์ ์๋ฏธํ๋ค.
์ฐ๋ฆฌ๋ ์คํ ๋ฝ (Spin Lock) ๋ฐฉ์์ ํตํด ๋ฝ์ ์ ์ ํ๊ณ ์๋์ง ๊ณ์ ํ์ธํ๋ฉด์, ๋ฝ์ ์ก์ ์ ์๋ค๋ฉด ๋ฝ์ ์ก๊ณ ์๋๋ฉด ๋๊ธฐํ๋ ๋ฐฉ์์ผ๋ก ๊ตฌํํ๊ณ ์ ํ๋ค. ๋ฝ์ ๋ํ ํค๋ฅผ ๊ณ์ ์ ์ ํ์ง ์๋๋ก ํค๋ ์๊ณ ์์ญ์ ๋น ์ ธ๋์จ ํ ์ ๊ฑฐ๋ฅผ ํด์ค์ผ ํ๋ฉฐ, ํค ์ค์ ์ TTL๋ ํจ๊ป ๊ฑธ์ด์ฃผ๋ ๊ฒ์ด ์ข๋ค.
๐ฑ ๊ตฌํํ๊ธฐ - ํ๊ฒฝ ๊ตฌ์ถ
ํ๋จ์ ๋์จ ์ค์ ๋ค์ ์์ผ๋ก redis ๊ด๋ จ ํฌ์คํ ์ ํ ๋ ๊ณ์ ์ฌ์ฉํ ์์ ์ด๊ธฐ ๋๋ฌธ์, ์ด๋ฒ ํฌ์คํ ์์๋ง ํ๊ฒฝ ๊ตฌ์ถ์ ๋ํด ์์ฑํ๊ณ ์ ํ๋ค.
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("com.h2database:h2")
testImplementation("io.rest-assured:rest-assured")
๋จผ์ , ๊ธฐ๋ณธ ๊ตฌํ์ ์ํด ํ์ํ dependency๋ค์ด๋ค. (์ธ์ด๋ Kotlin์ผ๋ก ์์ฑํ ์์ ์ด๋ค.)
์คํ๋ง์์ ์ ๊ณตํ๋ redis ๋ฐ jpa, ํ
์คํธ ํ๊ฒฝ ๊ตฌ์ถ์ ์ํ h2์ RestAssured๋ฅผ ์ถ๊ฐํด์ฃผ์.
@Configuration
class RedisConfig {
@Bean
fun redisConnectionFactory(): RedisConnectionFactory =
LettuceConnectionFactory()
@Bean
fun redisTemplate(): RedisTemplate<String, String> {
return RedisTemplate<String, String>().apply {
connectionFactory = redisConnectionFactory()
keySerializer = StringRedisSerializer()
valueSerializer = StringRedisSerializer()
}
}
}
์ด๋ฒ์ ๋ ๋์ค ๊ด๋ จ ์ค์ ํด๋์ค๋ฅผ ๋ง๋ค์ด์ฃผ์.
๋ ๋์ค ํด๋ผ์ด์ธํธ ์ค ์๋ฐ ๊ธฐ๋ฐ ํ๊ฒฝ์์ ๋ํ์ ์ผ๋ก ๋ฝํ๋ Lettuce๋ฅผ ์ฌ์ฉํ์์ผ๋ฉฐ, ๋ ๋์ค์ ์ ์ฅ๋ key, value๋ ํ์ฌ ์คํ์์ ๋ชจ๋ String์ ์ฌ์ฉํ๋ฉด ๋๊ธฐ ๋๋ฌธ์ StringRedisSerializer()๋ฅผ ์ฌ์ฉํด์ฃผ์๋ค. ์ทจํฅ์ ๋ง๊ฒ ์ปค์คํ
์ ํด์ฃผ๋ฉด ๋ ๊ฒ ๊ฐ๋ค.
class RedisService(
private val redisTemplate: RedisTemplate<String, String>
) : CacheService {
override fun delete(key: String) {
redisTemplate.delete(key)
}
}
๋ํ, ์บ์์ ํค๋ฅผ ์ ๊ฑฐํด์ฃผ๋ ํจ์๋ฅผ ๋ฏธ๋ฆฌ ์์ฑํด๋์๋ค. (์์ฃผ ์ฌ์ฉ๋ ์์ ์ด๋ผ ์ ์ผ ์๋จ์ผ๋ก ๋นผ๋์๋ค.)
๐ฑ ๊ตฌํํ๊ธฐ - ์๋น์ค ๋ก์ง
๋จผ์ , ๊ณ์ข ์ ๋ณด๋ฅผ ์ ์ฅํ๊ธฐ ์ํด ๊ฐ๋จํ ๋๋ฉ์ธ ๊ฐ์ฒด๋ฅผ ๋ง๋ค์ด๋ณด์.
@Entity
class Account(
@Id
val id: Long = 0L
) {
var balance: Int = 0
fun addBalance(amount: Int) {
balance += amount
}
fun subtractBalance(amount: Int) {
balance -= amount
}
}
๊ณ์ข์ ์์ก์ ์ถ๊ฐํด์ฃผ๋ ๋ฉ์๋์, ์ ๊ฑฐํด์ฃผ๋ ๋ฉ์๋ 2๊ฐ์ง๋ฅผ ๋ง๋ค์๋ค.
import org.springframework.data.jpa.repository.JpaRepository
fun AccountRepository.getAccountById(id: Long): Account {
return findAccountById(id) ?: throw IllegalArgumentException("์กด์ฌํ์ง ์๋ ๊ณ์ข์
๋๋ค")
}
interface AccountRepository : JpaRepository<Account, Long> {
fun findAccountById(id: Long): Account?
}
๊ทธ๋ฆฌ๊ณ ๊ฐ๋จํ๊ฒ ์์ด๋๋ฅผ ํตํด ๊ณ์ข๋ฅผ ์กฐํํด์ฃผ๋ ํ์ฅ ํจ์๋ฅผ ๋ง๋ค์๋ค.
๊ฐ์ธ์ ์ผ๋ก ํ์ฅ ํจ์๋ฅผ ํ์ฉํด์ ์์ ๊ฐ์ด ์์ธ๋ฅผ ํฐํธ๋ฆฌ๋ ๋ฐฉ์์ ์ ํธํ๋๋ฐ, ์ด๋ค ์ํคํ ์ฒ๋ฅผ ์ฌ์ฉํ๋์ง์ ๋ฐ๋ผ์ ์์ธ๋ฅผ ๋ฐ์์ํค๋ ๋ถ๋ถ๋ ๋ฌ๋ผ์ง ๊ฒ ๊ฐ๋ค. (์์ฆ์ ์๊ฐ์ด ๋ฐ๋์ด์, ์๋ง ๋ค๋ฅด๊ฒ ๊ตฌํํ์ง ์์๊น ์ถ๋ค)
interface KeyGenerator {
fun generateAccountKey(accountId: Long): String
}
@Component
class KeyGeneratorImpl : KeyGenerator {
override fun generateAccountKey(accountId: Long): String {
return "$accountId:account"
}
}
๋ค์์ผ๋ก, ๋ ๋์ค์ ํค๋ฅผ ์์ฑํด์ฃผ๋ ๋ฉ์๋๋ฅผ ์ถ๊ฐํด์ฃผ์๋ค.
ํค๋ ๊ฒน์น์ง ์๋ ๊ฒ์ด ์ ์ผ ์ค์ํ๋ฐ, ์ฌ๊ธฐ์๋ ๊ณ์ข์ ๋ํ ๊ณ ์ ์์ด๋ (PK) ๊ฐ๊ณผ 'account'๋ฅผ ์๋ ค์ฃผ๋ String์ ์ถ๊ฐํด์ ๋ง๋ค์ด์ฃผ์๋ค. ์ค์ ๋ก๋ ์ด๋ค ๊ธฐ๋ฅ์ ๊ฐ๋ฐํ ์ง์ ๋ฐ๋ผ์ ํค๋ฅผ ๋ง๋๋ ๋ฐฉ๋ฒ๋ ๋ค์ํ๊ธฐ ๋๋ฌธ์ ์ ์ํด์ผ ๋๋ค.
@RestController
@RequestMapping("/accounts")
class AccountController(
private val accountService: AccountService
) {
@PostMapping("/create")
fun createAccount(): ResponseEntity<AccountResponse> {
val result = accountService.create()
return ResponseEntity.ok(result)
}
@PostMapping("/deposit")
fun deposit(@RequestBody request: DepositRequest): ResponseEntity<BalanceResponse> {
val result = accountService.deposit(request)
return ResponseEntity.ok(result)
}
@GetMapping("/balance/{accountId}")
fun getBalance(@PathVariable accountId: Long): ResponseEntity<BalanceResponse> {
val result = accountService.getBalance(accountId)
return ResponseEntity.ok(result)
}
@PostMapping("/withdraw")
fun withdraw(@RequestBody request: WithdrawRequest): ResponseEntity<BalanceResponse> {
val result = accountService.withdraw(request)
return ResponseEntity.ok(result)
}
}
data class DepositRequest(
val accountId: Long,
val amount: Int
)
data class WithdrawRequest(
val accountId: Long,
val amount: Int
)
data class AccountResponse(
val accountId: Long
)
data class BalanceResponse(
val accountId: Long,
val balance: Int
)
class AccountService(
private val accountRepository: AccountRepository,
private val keyGenerator: KeyGenerator,
private val cacheService: CacheService
) {
@Transactional
fun create(): AccountResponse {
val account = Account()
val savedAccount = accountRepository.save(account)
return AccountResponse(savedAccount.id)
}
@Transactional
fun deposit(request: DepositRequest): BalanceResponse {
val accountId = request.accountId
val account = accountRepository.getAccountById(accountId)
account.addBalance(request.amount)
return BalanceResponse(account.id, account.balance)
}
@Transactional(readOnly = true)
fun getBalance(accountId: Long): BalanceResponse {
val account = accountRepository.getAccountById(accountId)
return BalanceResponse(account.id, account.balance)
}
@Transactional
fun withdraw(request: WithdrawRequest): BalanceResponse {
val accountId = request.accountId
val account = accountRepository.getAccountById(accountId)
account.subtractBalance(request.amount)
return BalanceResponse(account.id, account.balance)
}
}
๊ทธ๋ ๋ค๋ฉด, ์ค์ API ํธ์ถ์ ์ํด ๊ฐ๋จํ ์ปจํธ๋กค๋ฌ + ์๋น์ค ์ฝ๋๋ฅผ ๋ง๋ค์ด์ฃผ์๋ค.
๊ณ์ข๋ฅผ ์์ฑํ๊ณ , ์ ๊ธํ๊ณ , ์ถ๊ธํ๊ณ , ์กฐํํ๋ ๊ธฐ๋ฅ์ด๋ค.
์ด ๊ธฐ๋ณธ ์ฝ๋๋ฅผ ๋ฐํ์ผ๋ก ๋ถ์ฐ๋ฝ ์์ด ํ ๋ฒ ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํด๋ณด์.
โ Case 1 - ์๋ฌด ๋ฝ๋ ๊ฑธ์ง ์์์ ๋
class AccountControllerTest : IntegrationTestHelper() {
@Test
fun ๋ฝ_์์ด_๋์_์ถ๊ธ์์ฒญ() {
// given
val accountId = ๊ณ์ข_์์ฑ()
์ด๊ธฐ_์์ก_์ค์ (accountId, 100)
val requestCount = 2
// when
// ๋์์ 20์ฉ ์ถ๊ธ์ ์์ฒญํจ
val executor = Executors.newFixedThreadPool(requestCount)
val latch = CountDownLatch(requestCount)
repeat(requestCount) {
executor.submit {
์ถ๊ธ_์์ฒญ(accountId, 20)
latch.countDown()
}
}
latch.await()
// then
val balanceResponse = ์์ก_์กฐํ(accountId)
// ์ํ๋ ๊ฒฐ๊ณผ๋ 60์ด๊ฒ ์ง๋ง, ๋ค๋ฅธ ๊ฐ์ด ๋์ด
assertThat(balanceResponse.balance)
.isNotEqualTo(60)
}
private fun ๊ณ์ข_์์ฑ(): Long {
return RestAssured.given()
.log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.`when`()
.post("/accounts/create")
.then()
.log().all()
.extract()
.`as`(object : TypeRef<AccountResponse>() {})
.accountId
}
private fun ์ด๊ธฐ_์์ก_์ค์ (accountId: Long, amount: Int): BalanceResponse {
val depositRequest = DepositRequest(accountId, amount)
return RestAssured.given()
.log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.`when`()
.body(depositRequest)
.post("/accounts/deposit")
.then()
.log().all()
.extract()
.`as`(object : TypeRef<BalanceResponse>() {})
}
private fun ์ถ๊ธ_์์ฒญ(accountId: Long, amount: Int): BalanceResponse {
val withdrawRequest = WithdrawRequest(accountId, amount)
return RestAssured.given()
.log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.`when`()
.body(withdrawRequest)
.post("/accounts/withdraw")
.then()
.log().all()
.extract()
.`as`(object : TypeRef<BalanceResponse>() {})
}
private fun ์์ก_์กฐํ(accountId: Long): BalanceResponse {
return RestAssured.given()
.log().all()
.`when`()
.get("/accounts/balance/$accountId")
.then()
.log().all()
.extract()
.`as`(object : TypeRef<BalanceResponse>() {})
}
}
์ฐ์ , ์๋ฌด ๋ฝ๋ ๊ฑธ์ง ์์ ์ํ๋ก ์์ฒญ์ ํ๋ค๋ฉด ์ด๋ป๊ฒ ๋ ๊น? ์ ํ ์คํธ ์๋๋ฆฌ์ค๋ ๋ค์๊ณผ ๊ฐ๋ค.
1. ์ด๊ธฐ ์์ก์ 100์ผ๋ก ์ธํ ํ๋ค.
2. 2๊ฐ์ ์์ฒญ์ด ๋์์ 20์ฉ ์ถ๊ธ ์์ฒญ์ ์งํํ๋ค.
3. ์์ก์ ์กฐํํ์ ๋ 100 - 20 - 20 = 60์ด ๋์ค๊ธฐ๋ฅผ ๊ธฐ๋ํ๋ค.
2๊ฐ์ ์์ฒญ์ด ๋์์ ์ค๊ณ ์์ผ๋ฉฐ, ํ ์คํธ ์ฝ๋๊ฐ ๋จผ์ ๋๋๋ ๊ฒ์ ๋ฐฉ์งํ๊ธฐ ์ํด ๋ชจ๋ ์์ฒญ์ด ์ข ๋ฃ๋ ๋๊น์ง await()์ผ๋ก ๋๊ธฐํ ์ ์๋๋ก ๋ง๋ค์๋ค. ํ์ง๋ง, ํ ์คํธ ์ฝ๋๋ฅผ ๋ณด๋ฉด isNotEqualTo(60)์ผ๋ก, 60์ด๋ผ๋ ๊ฐ์ด ๋์ค์ง ์์ ๊ฒ์ ๋ณผ ์ ์๋ค.
์ด๋, ๋์์ฑ ๋ฌธ์ ๋ก ์ธํด์ '20 ์ถ๊ธํ๋ผ'๋ผ๋ ์์ฒญ์ด ๋์์ ์ค๋ฉด์ ์ฒซ ๋ฒ์งธ ์์ฒญ, ๋ ๋ฒ์งธ ์์ฒญ ๋ชจ๋ 100 -> 80์ผ๋ก ์ถ๊ธ์ ์งํํ๊ธฐ ๋๋ฌธ์ ๋ฐ์ํ ๋ฌธ์ ์๋ค.
โ Case 2 - ๋ ๋์ค์ get - set ํ์ฉํ๊ธฐ
๊ทธ๋ ๋ค๋ฉด, ๋ ๋์ค๋ฅผ ํ์ฉํด ๋ณด๋ฉด ์ด๋จ๊น?
์ฐ๋ฆฌ๋ ์ด๊ธฐ์ ๊ตฌํ ์๋๋ฆฌ์ค์์ 'ํค๊ฐ ์กด์ฌํ์ง ์์ ๊ฒฝ์ฐ ๋ฝ์ ํ๋ํ๊ณ , ์กด์ฌํ๋ฉด ๋ฝ์ ํ๋ํ ๋๊น์ง ๋๊ธฐ'ํ๋๋ก ํ๋ก์ฐ๋ฅผ ๊ตฌ์ฑํ๊ณ ์ ํ๋ค. ๊ฐ๋จํ๊ฒ ๋ ๋์ค๋ฅผ ํ์ฉํด์ ํค๊ฐ ์กด์ฌํ๋์ง ํ์ธํ๊ธฐ ์ํด get์ผ๋ก ๊ฐ์ ธ์ค๊ณ , ์กด์ฌํ์ง ์๋ ๊ฒฝ์ฐ์ set์ ํตํด ๋ฝ์ ํ๋ํ๋ ๋ก์ง์ ์์ฑํด๋ณด์.
interface KeyGenerator {
fun generateAccountKey(accountId: Long): String
}
@Component
class KeyGeneratorImpl : KeyGenerator {
override fun generateAccountKey(accountId: Long): String {
return "$accountId:account"
}
}
๋จผ์ , ๋ ๋์ค์ ํค๋ฅผ ์์ฑํด์ฃผ๋ ๋ฉ์๋๋ฅผ ์ถ๊ฐํด์ฃผ์๋ค.
ํค๋ ๊ฒน์น์ง ์๋ ๊ฒ์ด ์ ์ผ ์ค์ํ๋ฐ, ์ฌ๊ธฐ์๋ ๊ณ์ข์ ๋ํ ๊ณ ์ ์์ด๋ (PK) ๊ฐ๊ณผ 'account'๋ฅผ ์๋ ค์ฃผ๋ String์ ์ถ๊ฐํด์ ๋ง๋ค์ด์ฃผ์๋ค. ์ค์ ๋ก๋ ์ด๋ค ๊ธฐ๋ฅ์ ๊ฐ๋ฐํ ์ง์ ๋ฐ๋ผ์ ํค๋ฅผ ๋ง๋๋ ๋ฐฉ๋ฒ๋ ๋ค์ํ๊ธฐ ๋๋ฌธ์ ์ ์ํด์ผ ๋๋ค.
@Component
class RedisService(
private val redisTemplate: RedisTemplate<String, String>
) : CacheService {
override fun get(key: String): String? {
return redisTemplate.opsForValue().get(key)
}
override fun set(key: String, value: String, duration: Duration) {
redisTemplate.opsForValue().set(key, value, duration)
}
}
๋ค์์ผ๋ก, redisTemplate์ ํ์ฉํ์ฌ String ๊ฐ์ ์กฐํํ๊ณ ์ค์ ํ๋ ํจ์๋ฅผ ์ถ๊ฐํ์๋ค.
๋ ๋ชจ๋ opsForValues().set()๊ณผ opsForValues().get()์ ํ์ฉํ๋ฉด ๊ฐ๋จํ๊ฒ ๊ตฌํํ ์ ์๋ค.
set์ ๊ฒฝ์ฐ ํด๋น ํค๊ฐ ์ธ์ ๊น์ง ์ ํจํ๋๋ก ๋ง๋ค์ง TTL ๊ฐ์ ์ค์ ํด์ค ์ ์๋๋ฐ, Duration์ ์ฌ์ฉํ์ฌ ์ํ๋ ๋งํผ ์ง์ ํด์ค ์ ์๋ค. ๋ณดํต ์ค๋ ๊ฑธ๋ฆฌ๋ ๋ก์ง์ด ์๋๋ผ๋ฉด 30์ด ~ 1๋ถ ์ ๋๋ก ์ค์ ํ๋ ๊ฒฝ์ฐ๋ฅผ ๋ง์ด ๋ณธ ๊ฒ ๊ฐ๋ค.
@PostMapping("/withdraw-non-atomic-lock")
fun withdrawUsingNonAtomicLock(@RequestBody request: WithdrawRequest): ResponseEntity<BalanceResponse> {
val result = accountService.withdrawUsingNonAtomicLock(request)
return ResponseEntity.ok(result)
}
@Transactional
fun withdrawUsingNonAtomicLock(request: WithdrawRequest): BalanceResponse? {
val accountId = request.accountId
val accountKey = keyGenerator.generateAccountKey(accountId)
while (true) {
if (cacheService.get(accountKey) == null) {
cacheService.set(accountKey, "account-withdraw", Duration.ofSeconds(5))
break;
}
Thread.sleep(1000)
}
val account = accountRepository.getAccountById(accountId)
account.subtractBalance(request.amount)
// ์บ์ ์ ๊ฑฐ - TTL์ ์งง๊ฒ ์ก๊ธฐ๋ ํ์ง๋ง ๋ช
์์ ์ผ๋ก ์ ๊ฑฐํจ
cacheService.delete(accountKey)
return BalanceResponse(account.id, account.balance)
}
๊ทธ๋ฆฌ๊ณ , ์ค์ง์ ์ธ ๋น์ฆ๋์ค ์ฝ๋๋ฅผ ์์ฑํด์ฃผ์๋ค.
๋จผ์ , get()์ ํตํด์ ํค๊ฐ ์กด์ฌํ๋์ง ํ์ธํ ๋ค์, ์กด์ฌํ์ง ์๋๋ค๋ฉด set์ ํด์ฃผ๊ณ , ์กด์ฌํ๋ ๊ฒฝ์ฐ 1์ด์ฉ ๋๊ธฐํ๋ฉฐ ์ฌ์๋๋ฅผ ์งํํ๋ค.
์ฐธ๊ณ ๋ก, set์ ํตํด ์ธํ ํด์ฃผ๋ value ๊ฐ์ ์์ ๋กญ๊ฒ ์ค์ ํด์ฃผ์ด๋ ๋๋ค. (์๋ฏธ์๋ String ๊ฐ๋ ์๊ด์๋ค.)
์ค์ํ ๊ฑด, ๋ค๋ฅธ ๋น์ฆ๋์ค ๋ก์ง๊ณผ ํค๋ง ๊ฒน์น์ง ์๋๋ก ๋ง๋ค์ด ์ฃผ๋ ๊ฒ์ด ์ค์ํ๋ค.
์ถ๊ฐ์ ์ผ๋ก, Facade ํจํด ๋ฑ์ ๋์ ํด์ ๋ฝ์ ์ป๊ณ ๋ฐํํ๋ ๋ก์ง์ ์ถ์ํํ ์ ์๋ค.
์ด๋ฒ์๋ ๋ ๋์ค๋ฅผ ์ค์ฌ์ผ๋ก ์ ์ฉํ๋ค ๋ณด๋ ์ด์ ๋ํด ๋ฐ๋ก ๊ณ ๋ คํ์ง ์์์ง๋ง, ์ง๋ ํฌ์คํ ์์ ํผ์ฌ๋ ํจํด์ ํ์ฉํ๊ธฐ ๋๋ฌธ์ ํด๋น ๋ด์ฉ์ ์ฐธ๊ณ ํ์ฌ ๋ฆฌํฉํฐ๋ง ํด๋ ์ข์ ๊ฒ ๊ฐ๋ค๋ ์๊ฐ์ด ๋ ๋ค.
์ฐ๋ฆฌ๊ฐ ์ํ๋ ๋ก์ง๋๋ก ์์ฑํ๊ธฐ ๋๋ฌธ์, ์ด์ ๊ฒฐ๊ณผ๊ฐ์ผ๋ก 60์ด ๋์ค์ง ์์๊น?
@Test
fun ๋จ์_๋ฝ์_์ด์ฉํ_๋์_์ถ๊ธ์์ฒญ_์์์ฑ์๋ณด์ฅํ์ง์์() {
// given
val accountId = ๊ณ์ข_์์ฑ()
์ด๊ธฐ_์์ก_์ค์ (accountId, 100)
val requestCount = 2
// when
// ๋์์ 20์ฉ ์ถ๊ธ์ ์์ฒญํจ
// ์บ์๋ฅผ ์ฌ์ฉํ๊ธด ํ์ง๋ง ์์ ํ ์์์ฑ์ ๋ณด์ฅํ์ง ์๋ ๋ก์ง
val executor = Executors.newFixedThreadPool(requestCount)
val latch = CountDownLatch(requestCount)
repeat(requestCount) {
executor.submit {
๋ฝ์_ํตํ_์ถ๊ธ_์์ฒญ(accountId, 20)
latch.countDown()
}
}
latch.await()
// then
val balanceResponse = ์์ก_์กฐํ(accountId)
// ๋ง์ฐฌ๊ฐ์ง๋ก ์ํ๋ ๊ฒฐ๊ณผ๋ 60์ด๊ฒ ์ง๋ง, ๋ค๋ฅธ ๊ฐ์ด ๋์ด
assertThat(balanceResponse.balance)
.isNotEqualTo(60)
}
private fun ๋ฝ์_ํตํ_์ถ๊ธ_์์ฒญ(accountId: Long, amount: Int): BalanceResponse {
val withdrawRequest = WithdrawRequest(accountId, amount)
return RestAssured.given()
.log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.`when`()
.body(withdrawRequest)
.post("/accounts/withdraw-non-atomic-lock")
.then()
.log().all()
.extract()
.`as`(object : TypeRef<BalanceResponse>() {})
}
ํ์ง๋ง, ์์๊ณผ ๋ค๋ฅด๊ฒ ์ฌ์ ํ isNotEqualTo()๋ฅผ ํตํด 60์ด ๋์ค์ง ์์ ๊ฒ์ ๋ณผ ์ ์๋ค. ์ ์ด๋ด๊น?
์ฌ์ค, ์์์ ์ด๋ฏธ ๋ต์ ๋๋ค.
์ฐ๋ฆฌ์ ์คํ๋ง ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ฉํฐ ์ค๋ ๋ ๊ธฐ๋ฐ์ด๊ธฐ ๋๋ฌธ์, ๋ ๋์ค๊ฐ ์ฑ๊ธ ์ค๋ ๋๋ก ๋์ํ๋ค๊ณ ํด๋ ํ๋์ ์ค๋ ๋๊ฐ get - set์ ํ๋ ์ค๊ฐ์ ๋ค๋ฅธ ์ค๋ ๋๊ฐ get์ ์ถฉ๋ถํ ํ ์ ์๋ ์ํฉ์ด ๋ง๋ค์ด์ก๊ธฐ ๋๋ฌธ์ด๋ค.
๊ทธ๋ฆผ์ผ๋ก ๋ณด๋ฉด ๋ค์๊ณผ ๊ฐ์ ์ํฉ์ด๋ค. ๋ ๋์ค ๋ด๋ถ์์ ์ฑ๊ธ ์ค๋ ๋๋ก ์ด์ฌํ ๋ช ๋ น์ด๋ฅผ ์ฒ๋ฆฌํ๋๋ผ๋, ์ ํ๋ฆฌ์ผ์ด์ ๋ ๋ฒจ์์ ๋ค์ด์ค๋ ์์ฒญ๋ค์ ์ฌ๋ฌ ์ค๋ ๋๊ฐ ๋์์ ๋ค์ด์ค๊ธฐ ๋๋ฌธ์ 20, 20์ฉ ์ถ๊ธํ๋ผ๋ ์์ฒญ์ด ์ ์ฉ๋์ง ์์๋ ๊ฒ์ด๋ค.
๊ทธ๋ ๋ค๋ฉด, GET - SET์ ๋์์ ํ๋๋ก = ์์์ฑ์ ๋ณด์ฅํ๋๋ก ๋ง๋ค ์ ์์๊น?
๋ฐ๋ก, ์์ ๋งํ๋ ๊ฒ์ฒ๋ผ NX์ ์ฌ์ฉํ๋ฉด ๋๋ค.
โ Case 3 - ๋ ๋์ค์ NX ํ์ฉํ๊ธฐ
override fun setNX(key: String, value: String, duration: Duration): Boolean {
return redisTemplate.opsForValue().setIfAbsent(key, value, duration) ?: false
}
NX์ ๊ฒฝ์ฐ redisTemplate์์ ์ ๊ณตํ๋ setIfAbsent()๋ฅผ ์ฌ์ฉํ๋ฉด ๋๋ค.
์ฐ๋ฆฌ๊ฐ ์ ํ๋ฆฌ์ผ์ด์ ๋ ๋ฒจ์์ GET์ ํตํด ํค๊ฐ ์กด์ฌํ๋์ง ํ์ธํ๊ณ , SET์ ํตํด์ ๊ฐ์ ์ธํ ํด์ฃผ๋ ๊ณผ์ ์ ๋ ๋์ค ๋ด์์ ๋ง๋ ๋ช ๋ น์ด๋ผ๊ณ ์๊ฐํ๋ฉด ๋๋ค. ๋ง์ฝ ํค๊ฐ ์ด๋ฏธ ์กด์ฌํด์ ๊ฐ์ ์ธํ ํ์ง ๋ชปํ๋ค๋ฉด false๋ฅผ, ํค๊ฐ ์กด์ฌํ์ง ์์์ ๊ฐ์ ์ธํ ํ๋ค๋ฉด true๋ฅผ ๋ฐํํ๋ค.
@PostMapping("/withdraw-atomic-lock")
fun withdrawUsingAtomicLock(@RequestBody request: WithdrawRequest): ResponseEntity<BalanceResponse> {
val result = accountService.withdrawUsingAtomicLock(request)
return ResponseEntity.ok(result)
}
@Transactional
fun withdrawUsingAtomicLock(request: WithdrawRequest): BalanceResponse {
val accountId = request.accountId
val accountKey = keyGenerator.generateAccountKey(accountId)
while (!cacheService.setNX(accountKey, "account-withdraw", Duration.ofSeconds(5))) {
Thread.sleep(1000)
}
val account = accountRepository.getAccountById(accountId)
account.subtractBalance(request.amount)
// ์บ์ ์ ๊ฑฐ
cacheService.delete(accountKey)
return BalanceResponse(account.id, account.balance)
}
์ค์ ๋น์ฆ๋์ค ๋ก์ง์ ์ ์ฉํด๋ณด์. setNX๋ฅผ ํตํด true, false ๊ฐ์ ๋ฐํํ๊ธฐ ๋๋ฌธ์ ๊ฐ์ ์ธํ ํ์ง ๋ชปํ์ ๊ฒฝ์ฐ 1์ด์ฉ ๋๊ธฐํ๋๋ก while๋ฌธ์ ํตํด ์ฝ๊ฒ ์ ์ดํ ์ ์๊ฒ ๋์๋ค.
๊ทธ๋ ๋ค๋ฉด, ํ ์คํธ ์ฝ๋๋ฅผ ํตํด ๊ฒฐ๊ณผ๋ฅผ ํ์ธํด๋ณด์.
@Test
fun ๋ถ์ฐ๋ฝ์_ํตํ_๋์_์ถ๊ธ์์ฒญ() {
// given
val accountId = ๊ณ์ข_์์ฑ()
์ด๊ธฐ_์์ก_์ค์ (accountId, 100)
val requestCount = 2
// when
// ๋์์ 20์ฉ ์ถ๊ธ์ ์์ฒญํจ
val executor = Executors.newFixedThreadPool(requestCount)
val latch = CountDownLatch(requestCount)
repeat(requestCount) {
executor.submit {
๋ถ์ฐ๋ฝ์_ํตํ_์ถ๊ธ_์์ฒญ(accountId, 20)
latch.countDown()
}
}
latch.await()
// then
val balanceResponse = ์์ก_์กฐํ(accountId)
// ๋ฝ์ผ๋ก ์ธํด์ ์์ฐจ์ ์ผ๋ก ์ฒ๋ฆฌ๋๊ธฐ ๋๋ฌธ์ ์ ์์ ์ผ๋ก 60 ๋ฐํ
assertThat(balanceResponse.balance)
.isEqualTo(60)
}
private fun ๋ถ์ฐ๋ฝ์_ํตํ_์ถ๊ธ_์์ฒญ(accountId: Long, amount: Int): BalanceResponse {
val withdrawRequest = WithdrawRequest(accountId, amount)
return RestAssured.given()
.log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.`when`()
.body(withdrawRequest)
.post("/accounts/withdraw-atomic-lock")
.then()
.log().all()
.extract()
.`as`(object : TypeRef<BalanceResponse>() {})
}
๋๋์ด ์ฐ๋ฆฌ๊ฐ ์ํ๋๋๋ก 60์ ๋ฐํํ๋ ๊ฒ์ ํ์ธํ ์ ์๋ค!
ํ์ง๋ง ์ด๋๋ก ๋๋ด๊ธฐ์๋ ๋ญ๊ฐ ์์ฝ๊ธฐ ๋๋ฌธ์, ์กฐ๊ธ ๋ ๊ท์ฐฎ์ ๋ฐฉ๋ฒ์ผ๋ก ์์์ฑ์ ๋ณด์ฅํ๋ ๋ฐฉ๋ฒ์ ์๊ฐํด๋ณด๊ณ ์ ํ๋ค.
๊ธ์ด ์๊ฐ๋ณด๋ค ๋๋ฌด ๊ธธ์ด์ ธ์ 1, 2๋ถ๋ก ๋๋์ด์ ์์ฑํ์์ต๋๋ค.
๋ค์ ํฌ์คํ : https://cl8d.tistory.com/125