이번 step은 redis에 중점적으로 맞추어져 있다
redis를 어떻게 사용하고, 자료구조가 어떻게 이루어져 있는지에 대한 강의였다
그래서 이번 스텝 요약은 이 포스팅으로 대체하고, 바로 과제 리뷰로 넘어가겠다
과제 리뷰
이번 과제는 대기열을 redis 기반으로 리팩토링해보고, 매진 순위를 redis의 sorted set으로 만들어보는 것이었다
대기열은 이미 예전에 redis 기반으로 만들어둬서 손댈 부분은 없었다
먼저 Redisson과 직접적으로 통신할 어댑터를 하나 만들었다
interface ConcertSoldOutPort {
fun markSoldOut(concertId: Long, timestamp: Long)
fun isSoldOut(concertId: Long): Boolean
fun getTopN(n: Int): List<ConcertRanking>
fun getRank(concertId: Long): ConcertRanking
}
...
@Component
class ConcertSoldOutAdapter(
private val redissonClient: RedissonClient
) : ConcertSoldOutPort {
companion object{
const val RANKING_KEY = "concert:soldout:ranking"
}
private fun getRankingSet() =
redissonClient.getScoredSortedSet<String>(RANKING_KEY)
override fun markSoldOut(concertId: Long, timestamp: Long) {
if (!isSoldOut(concertId)) {
getRankingSet().add(timestamp.toDouble(), concertId.toString())
}
}
override fun isSoldOut(concertId: Long): Boolean {
return getRankingSet().contains(concertId.toString())
}
override fun getTopN(n: Int): List<ConcertRanking> {
return getRankingSet().entryRange(0, n - 1).map {
ConcertRanking(
it.value.toLong(),
it.score.toLong()
)
}
}
override fun getRank(concertId: Long): ConcertRanking {
val rank = getRankingSet().rank(concertId.toString())?.toLong()
return ConcertRanking(concertId, rank ?: 0)
}
}
그런데 항상 코딩하고 나면 네이밍이 문제다
어떻게 해야할까?
Redisson을 쓰고 있으니 RedissonPort라고 해야할까? 아니면 기능에 치중해서 이렇게 SoldOut이라고 지어야할까...
아키텍처 공부는 하면 할수록 어렵다 (그래도 기능에 치중하는게 맞는듯?)
timestamp를 기준으로 순위가 매겨지도록 했다
콘서트 매진이 되었는지 확인하고 markSoldOut을 호출하여 redis에 저장되도록 했다
그 다음엔 이 어댑터를 사용할 서비스를 만들었다
interface ConcertRankingPort {
fun checkAndMarkSoldOut(concertId: Long)
fun getRanking(topN: Int): List<ConcertRankingResponse>
}
...
@Service
class ConcertRankingService(
private val soldOutPort: ConcertSoldOutPort,
private val concertRepository: ConcertRepository,
private val reservationRepository: ReservationRepository
) : ConcertRankingPort {
override fun checkAndMarkSoldOut(concertId: Long) {
if (soldOutPort.isSoldOut(concertId)) {
return
}
val concert = concertRepository.findById(concertId)
val reservationCount = reservationRepository.countByConcert(concert)
if (reservationCount < concert.totalSeats) return
soldOutPort.markSoldOut(concertId, System.currentTimeMillis())
}
override fun getRanking(topN: Int): List<ConcertRankingResponse> {
return soldOutPort.getTopN(topN).mapIndexed { index, rank ->
val concert = concertRepository.findById(rank.concertId)
ConcertRankingResponse(
concertId = concert.id!!,
concertName = concert.name,
rank = (index + 1).toLong()
)
}
}
}
checkAndMarkSoldOut은 먼저 넘겨받은 concertId로 concert를 조회한 후, 이 concert에 예약되어있는 reservation의 갯수를 조회해 콘서트의 자리보다 적다면 Early Return하고, 같거나 크다면 markSoldOut을 호출하도록 했다
getRanking에서는 soldOutPort의 getTopN을 호출해서 정렬되어있는 리스트를 가져온 후, mapIndexed를 사용해 콘서트 정보를 하나하나 가져와서 Response를 만들고 그걸 리스트로 넘겨준다
근데 지금 다시보니 N+1 문제가 생기는것 같다;;
개선하려면 id 리스트를 가져오고 배치로 한번에 조회한 다음, Response의 리스트를 만드는 편이 나을것같다
@Query("SELECT c FROM Concert c WHERE c.id IN :ids")
fun findAllByIdIn(ids: List<Long>): List<Concert>
먼저 리포지토리 레이어에 이렇게 추가하고
override fun getRanking(topN: Int): List<ConcertRankingResponse> {
val rankings = soldOutPort.getTopN(topN)
val concertIds = rankings.map { it.concertId }
val concerts = concertRepository.findAllByIdIn(concertIds)
.associateBy { it.id }
/**
{
5 -> Concert(id=5, name="콘서트A"),
3 -> Concert(id=3, name="콘서트B"),
8 -> Concert(id=8, name="콘서트C")
}
**/
return rankings.mapIndexed { index, rank ->
val concert = concerts[rank.concertId]
?: throw IllegalStateException("Concert not found: ${rank.concertId}")
ConcertRankingResponse(
concertId = concert.id!!,
concertName = concert.name,
rank = (index + 1).toLong()
)
}
}
먼저 getTopN을 호출해 ConcertRanking이라는 DTO로 응답받은 후에, map을 사용해서 리스트로 만든 후, 배치로 한번에 조회한다. 이러면 N+1 문제는 발생하지 않는다
associateBy를 사용해서 id를 key로 하는 map을 먼저 만든 후에, 위에서 했던 로직을 그대로 써주면 끝
이제 ReservationService에서 예약이 만들어질 때, checkAndMarkSoldOut을 호출해주면 된다
@Transactional
@CacheEvict(value = ["availableSeats"], key = "#dto.concertId")
fun make(dto: ReservationRequest): Reservation {
...
outboxRepository.save(outboxMessage)
concertRankingPort.checkAndMarkSoldOut(concert.id!!)
return saved
}
사실 이게 끝이다
SortedSet만 잘 쓰면 끝인듯
getRanking 함수에 캐싱을 적용할수도 있겠다
랭킹같이 잘 바뀌지 않고 조회가 많은 부분에서는 캐싱이 딱이긴 하다
사실 적용해보려고 했는데 topN이 문제였다
고정된 값이 아니라 topN으로 유동적으로 랭킹을 조회하는 것 때문에 캐시 무효화가 힘들었다
랭킹 캐시 무효화는 markSoldOut에서 해야할텐데, key가 되는 topN을 알수가 없다
차라리 환경변수로 topN을 지정해뒀다면 캐싱이 수월했을텐데 하는 아쉬움이 있다
'항해 Lite' 카테고리의 다른 글
| 이벤트 기반 아키텍처(2) - 과제 리뷰 (0) | 2026.01.31 |
|---|---|
| 이벤트 기반 아키텍처 (1) (1) | 2026.01.30 |
| 분산락과 캐싱으로 트래픽 대응(2) - 과제 리뷰 (0) | 2026.01.06 |
| 분산락과 캐싱으로 트래픽 대응 (1) (0) | 2026.01.01 |
| 동시성 문제(2) - 과제 리뷰 (1) | 2025.12.27 |