설계

Write-Back 전략을 사용한 장바구니 기능 개발

togeepizza 2025. 1. 7. 15:54

목표

  • 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가지의 케이스가 있다.

  1. 장바구니에 상품을 담는다.
  2. 장바구니에 담은 상품의 수량을 조절한다.
  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