Write-Back 전략을 사용한 장바구니 기능 개발
목표
- redis를 사용해 write-back을 적용한 장바구니 기능을 구현해보면서 write-back을 학습한다.
write back 이란?
write back이란 변경사항을 바로 DB에 반영하는것이 아닌, 캐시같은 임시 저장공간에 저장해뒀다가, 나중에 DB에 저장하는 전략이다. 이렇게 하면 DB에 부하를 줄일 수 있기 때문에 빈번한 수정이 일어나는 좋아요나 장바구니 기능에 적용하기 좋다.
기본적인 장바구니 기능 구현
최소기능만 구현할거라 JPA를 사용하지 않고, JdbcTemplate을 사용했다.
data.sql
CREATE TABLE IF NOT EXISTS CartItem (
userId BIGINT NOT NULL,
productId BIGINT NOT NULL,
quantity INT NOT NULL,
CONSTRAINT PK_CartItem PRIMARY KEY (userId, productId)
);
CartItem.kt
class CartItem(
val userId: Long,
val productId: Long,
quantity: Int
) {
var quantity: Int = quantity
private set
fun updateQuantity(quantity: Int) {
this.quantity = quantity
}
}
CartItemRepository.kt
class CartItemRowMapper : RowMapper<CartItem> {
override fun mapRow(rs: ResultSet, rowNum: Int): CartItem {
return CartItem(
userId = rs.getLong("userId"),
productId = rs.getLong("productId"),
quantity = rs.getInt("quantity")
)
}
}
@Repository
class CartItemRepository(
private val jdbcTemplate: JdbcTemplate
) {
fun upsert(cartItem: CartItem) {
val sql = """INSERT INTO CartItem (userId, productId, quantity) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE quantity = VALUES(quantity)"""
jdbcTemplate.update(sql, cartItem.userId, cartItem.productId, cartItem.quantity)
}
fun bulkUpsert(cartItems: List<CartItem>) {
val sql = "INSERT INTO CartItem (userId, productId, quantity) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE quantity = VALUES(quantity)"
val batchPreparedStatementSetter = object : BatchPreparedStatementSetter {
override fun setValues(ps: PreparedStatement, i: Int) {
val item = cartItems[i]
ps.setLong(1, item.userId)
ps.setLong(2, item.productId)
ps.setInt(3, item.quantity)
}
override fun getBatchSize(): Int = cartItems.size
}
jdbcTemplate.batchUpdate(sql, batchPreparedStatementSetter)
}
fun findAll(): List<CartItem> {
val sql = "SELECT * FROM cartitem"
return jdbcTemplate.query(sql, CartItemRowMapper())
}
fun findByUserId(userId: Long): List<CartItem> {
val sql = "SELECT * FROM cartitem WHERE userId = ?"
return jdbcTemplate.query(sql, CartItemRowMapper(), userId)
}
}
- CartItem은 userId, productId, quantity로 구성된다.
INSERT INTO CartItem ~ ON DUPLICATE KEY UPDATE ~
문으로 CartItem이 존재하면 quantity 값을 UPDATE, 존재하지 않으면 INSERT 한다.
DB에 바로 반영
@Service
@Transactional
class CartItemService(
private val cartItemRepository: CartItemRepository
) {
fun setCartItem(userId: Long, productId: Long, quantity: Int) {
println("실행! $quantity")
cartItemRepository.upsert(CartItem(userId, productId, quantity));
}
}
가장 간단한 방법은 DB에 변경사항을 바로 반영하는 방식이다. 이렇게 하면 변경사항이 즉시 DB에 반영된다.
@Test
fun 유저가_여러번_값을_수정() {
val requestCount = 100
for (quantity in 1..requestCount) {
cartItemService.setCartItem(1, 3, quantity = quantity)
}
val cartItems = cartItemRepository.findByUserId(1)
assertEquals(cartItems.first().quantity, 100)
}
위의 테스트에서는 DB에 100번 요청을 하게 된다.
장바구니나 좋아요 같은 기능은 버튼 하나만으로 빈번하게 수정 요청이 갈 수 있는데, 그 때마다 DB에 요청이 가는건 필요없지 않을까?
요청의 마지막 상태만 반영할 순 없을까? 라는 고민에서 write-back 전략을 도입할 수 있다.
캐시에 먼저 반영
캐시에 장바구니를 저장하기 전에, 어떤 자료구조를 사용할 것인지 고민해보았다.
장바구니 기능의 유즈 케이스를 생각해보면, 크게 3가지의 케이스가 있다.
- 장바구니에 상품을 담는다.
- 장바구니에 담은 상품의 수량을 조절한다.
- 유저의 장바구니를 조회한다.
간단하게는 key: cart_item:${userId}_${productId}
, value: quantity
로 value를 단일 값으로 할 수 있지만 이렇게 하면 3번의 유저의 장바구니를 조회할 때 유저별 장바구니 아이템을
그래서 자료구조는 hash를 사용하기로 했고, RedisTemplate을 활용한 RedisRepository를 구현했다.
redisTemplate에서 Hash를 활용하려면 opsForHash로 hash 관련 연산을 사용할 수 있다.
RedisRepository.kt
@Component
class RedisRepository(
private val redisTemplate: RedisTemplate<String, Any>
) {
fun put(key: String, field: String, value: Int) {
redisTemplate.opsForHash<String, Any>().put(key, field, value)
}
fun get(key: String, field: String): Int? {
return redisTemplate.opsForHash<String, Int?>().get(key, field)
}
fun getAllByKey(key: String): MutableMap<String, Int> {
return redisTemplate.opsForHash<String, Int?>().entries(key)
}
fun keys(pattern: String): Set<String> {
return redisTemplate.keys(pattern)
}
}
getAllByKey에서 모든 field 값들을 가져오는 HGETALL 명령어를 사용했는데, 이는 O(N)으로 동작하므로 field값이 너무 많은 경우 성능상 문제가 될 수 있다. 따라서 특정 field만 여러개 가져오는 경우라면 HMGET 을 사용하거나 HSCAN을 사용하는 방법이 있다.
기존의 서비스를 조금 수정해보자
CartItemService.kt
@Service
@Transactional
class CartItemService(
private val cartItemRepository: CartItemRepository,
private val redisRepository: RedisRepository
) {
fun setCartItem(userId: Long, productId: Long, quantity: Int) {
println("실행! $quantity")
cartItemRepository.upsert(CartItem(userId, productId, quantity));
}
fun setCartItemCache(userId: Long, productId: Long, quantity: Int) {
redisRepository.put(generateKey(userId), productId.toString(), quantity);
}
fun getCartItemsByUser(userId: Long): List<CartItem> {
val cachedCartItem = redisRepository.getAllByKey(generateKey(userId));
if (cachedCartItem.isNotEmpty()) {
return cachedCartItem.entries.map { CartItem(userId, it.key.toLong(), it.value) }
}
return cartItemRepository.findByUserId(userId)
}
private fun generateKey(userId: Long): String {
return "user_cart:${userId}"
}
}
변경 사항
- setCartItemCache 에서 DB로 요청을 보내지 않고, 캐시에 저장한다.
- getCartItemsByUser에서 캐시를 먼저 조회하고, 없으면 DB에서 값을 가져온다.
동기화
제일 중요한 문제는 캐시에 저장된 값과 DB를 어떻게 동기화 할 것이냐이다.
보통 cron을 사용해 주기적으로 동기화 하거나, 유저가 로그인 또는 결제를 했을때 같이 특정한 행동에 동기화를 한다.
테스트를 위해 3초마다 동기화를 해주는 함수를 작성해보았다.
@Scheduled(cron = "*/3 * * * * *")
fun syncByDB() {
val keys = redisRepository.keys("user_cart:*")
val items = mutableListOf<CartItem>()
for (key in keys) {
val cartItemByUser = redisRepository.getAllByKey(key)
val userId = key.substring("user_cart:".length until key.length).toLong()
items.addAll(cartItemByUser.entries.map { entry -> CartItem(userId, entry.key.toLong(), entry.value) })
}
println("keys = ${keys}")
cartItemRepository.bulkUpsert(items)
}
테스트 코드를 통해 검증해보았다.
@SpringBootTest
@EnableScheduling
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class CartItemServiceTest(
private val cartItemService: CartItemService,
private val cartItemRepository: CartItemRepository
) {
@Test
fun 유저가_여러번_값을_수정_Redis() {
val requestCount = 20
for (quantity in 1..requestCount) {
cartItemService.setCartItemCache(2, 3, quantity = quantity)
cartItemService.setCartItemCache(2, 10, quantity = quantity)
cartItemService.setCartItemCache(1, 60, quantity = quantity)
}
val cartItemsByUser = cartItemService.getCartItemsByUser(2)
assertEquals(cartItemsByUser.first().quantity, requestCount)
val cartItemsBeforeSync = cartItemRepository.findByUserId(2)
assertEquals(cartItemsBeforeSync.size, 0)
Thread.sleep(5000) // sync가 되는걸 확인하기 위해 대기
val cartItemsAfterSync = cartItemRepository.findByUserId(1)
assertEquals(cartItemsAfterSync.first().quantity, requestCount)
}
}
write-back은 동시에 여러 요청이 들어오는 상황에서 순서와는 상관없이, DB에 부하를 덜 가도록 하는 전략이다. 따라서 테스트 코드도 테스트 메인 스레드에서 여러 요청을 받는 상황으로 가정하였다.
cartItemService.setCartItemCache 가 20 + 20 + 20 총 60번의 DB hit가 아닌, 동기화 시점마다 bulkUpsert를 하여 setCartItemCache 로직 수행 직후에는 DB에는 반영이 안되어있지만, 일정 시간 대기 후 동기화가 된 것을 확인할 수 있다.
그러나 이 방식엔 치명적인 문제가 있다.
바로 keys 명령어를 사용하고 있다는 점이다.
fun keys(pattern: String): Set<String> {
return redisTemplate.keys(pattern)
}
keys명령어는 모든 key를 풀스캔하기 때문에 key가 너무 많으면 싱글 스레드인 redis에서 다른 요청이 대기해야하는 상황이 발생할 수 있다.
scan 으로 개선
keys 명령어의 문제점을 해결하기 위해 scan 명령어로 변경해보자.
redisTemplate 에서 scan 명령어를 사용하기 위해선 scanOptions 를 사용해야 하는데 사용법이 익숙하지 않아서 GPT랑 여러 블로그들을 참고하였다.
scan 명령어를 사용하면 keys 명령어와 다르게 한 번에 모든 keys들을 읽는 것이 아닌 한 번에 일부 키만 반환하고, 이어서 검색할 수 있는 반복자(iterator)를 제공하기 때문에 부하를 줄일 수 있다.
fun getAllKeys(pattern: String): Set<String> {
val keys = mutableSetOf<String>()
val scanOptions: ScanOptions = ScanOptions.scanOptions().match(pattern).count(10).build()
val connection = redisTemplate.connectionFactory?.connection ?: throw IllegalStateException()
connection.scan(scanOptions).use { cursor ->
cursor.asSequence().forEach { result ->
keys.add(String(result))
}
}
return keys
}
최종 수정한 동기화 로직은 keys 명령어가 아닌, scan 명령어로 개선해주었다.
@Scheduled(cron = "*/3 * * * * *")
fun syncByDB() {
val keys = redisRepository.getAllKeys("user_cart:*")
println("keys = ${keys}")
val items = mutableListOf<CartItem>()
for (key in keys) {
val cartItemByUser = redisRepository.getAllByKey(key)
val userId = key.substring("user_cart:".length until key.length).toLong()
items.addAll(cartItemByUser.entries.map { entry -> CartItem(userId, entry.key.toLong(), entry.value) })
}
cartItemRepository.bulkUpsert(items)
}
write-back 방식의 문제점
만약 동기화 하는 와중에 setItem 요청이 오면 어떻게 될까?
캐시에는 변경된 값이 반영되지만, DB에는 이전의 값이 저장되고, 제대로 동기화 되려면 다음 동기화 시점까지 기다려야 할 것이다.
이를 해결하기 위해서 redis로 lock을 또 적용할 수 있는데, 이건 각자 상황에 맞게 도입하면 된다.
개인적으로는 성능 개선을 목표로 write-back을 도입했는데, lock때문에 특정 시점에 느려진다면 본래의 목적과 달라지는 게 아닐까? 생각이 들었다.
또한, write-back 학습을 목표로 간단하게 모든 유저의 장바구니를 bulkUpsert 하는 방식으로 구현했는데, 실제 환경에서는 유저의 장바구니 데이터가 엄청나게 많을것이므로 이 부분도 고려해서 특정 시점 이후의 데이터만 수정하거나 하는 식으로 개선해야 할 필요가 있다.
결론
- write-back 방식을 사용해서 DB의 부하를 줄일 수 있다.
- 주기적으로 DB와 동기화 해줘야 하고, 캐시와 DB의 데이터 불일치 문제가 발생할 수 있다.
참고
https://myeongdev.tistory.com/91
https://dev-monkey-dugi.tistory.com/148