이번 과제는 분산락과 캐싱을 적용해보고, 보고서를 쓰는 것이었다.
이번에 처음 jmeter를 써 봤는데 생각보다 직관적이고 쓰기 편해서 놀랬다.
그럼 바로 리뷰해보자
캐싱
일단 내가 만든 프로젝트에서 캐싱을 적용할 부분이 거의 없었다. 조회하는 API 자체가 딱 두개밖에 없었다.
회원의 포인트 조회랑, 예약 가능 좌석 조회.
포인트 조회는... 캐싱되어야 할 이유가 없을것 같다. 그냥 RDB에서 컬럼 하나 가져오면 되는 문제다
예약 가능 좌석 조회는 먼저 생각해보자. 예약 가능 좌석 조회에서 캐싱을 조회해두면 어떻게 될까? 만약 인기있는 콘서트가 열려서 한순간에 트래픽이 몰린다면... 캐시의 기능이 제대로 동작할까? 이런 생각이 계속 떠올랐지만 어쩌겠는가. 캐싱을 적용하라고 과제가 내려왔고, 나는 그걸 해야만 하는데
현재 임시 예약 좌석은 Redis에 존재하고 예약 확정 좌석은 RDB에 있어서 예약 가능 좌석 조회는 두 DB를 모두 건드리고 있다
// controller
fun getAvailableSeatNumbers(
@RequestParam date: LocalDate
): ResponseEntity<AvailableSeatsResponse> {
val list = reservationService.getAvailableSeat(date)
return ResponseEntity.ok(
AvailableSeatsResponse(
date = date,
seats = list
)
)
}
// service
fun getAvailableSeat(date: LocalDate): List<Int> {
val seatInPersistent = reservationRepository.getReservedSeatNumber(date).toSet()
val seatInTemp = tempReservationService.getTempReservation(date).toSet()
val availableSeat = (1..50).filter { !seatInPersistent.contains(it) && !seatInTemp.contains(it) }
return availableSeat
}
기존은 이렇게 가져오고 있었다. 애초에 어떤 좌석들이 예약되어있는지 모두 가져오고 필터링해서 예약 가능한 좌석만 살리는 식으로.
getReservedSeatNumber는 내부적으로 인덱스를 설정해두어서 그렇게 크게 부하가 걸리지는 않는다.
프로젝트에서 이미 Redis를 쓰고 있었으니까, redis를 이용해서 캐싱을 해보자
// RedisConfig
@Bean("cacheManager")
fun redisCacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
val config: RedisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig() // 데이터 직렬화 설정 (JSON 형태로 저장)
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer<String?>(
StringRedisSerializer()
)
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer<Any?>(
GenericJackson2JsonRedisSerializer()
)
) // 캐시 만료 시간(TTL) 설정 (예: 10분)
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
.cacheDefaults(config)
.build()
}
이렇게 설정을 해두면 캐시 설정이 완료된다.
key는 String 형태로, value는 Any 형태로 직렬화하도록 했고 TTL은 10분으로 설정했다.
캐시 메모리에 무한으로 캐싱되면 당연히 안되니까 TTL을 설정했다.
그리고 @Cacheable을 위에서 봤던 getAvailableSeat 에 적용해주자
@Cacheable(value = ["availableSeats"], key = "#date.toString()")
fun getAvailableSeat(date: LocalDate): List<Int>
이렇게 사용하면 캐싱이 된다 !!
@Cacheable 은 @Transactional 처럼 AOP를 사용한다.
@Cacheable 이 붙은 클래스를 프록시로 만들어서 이 클래스에 대한 요청을 가로채는 것이다.
@Cacheable 에 붙은 인자 value, key를 조합해서 Redis key를 만들어 redis에서 조회를 실행하고, 있다면 캐시 hit로 실제 클래스를 호출하지 않고 바로 hit된 데이터를 반환한다.
만약 캐시 miss가 발생하면 실제 클래스를 호출해서 결과값을 캐싱해둔 다음 결과값을 반환한다.
그렇다면 이 또한 자가호출 문제에서 자유롭지 못하다. 이 클래스 내부에서 이 메소드를 쓰는 메소드는 예약을 만드는 make 함수이다.
@Transactional
fun make(dto: ReservationRequest) : Reservation {
if (!getAvailableSeat(dto.date).contains(dto.seatNumber)) {
throw DuplicateResourceException("이미 예약되어있는 좌석입니다.")
}
...
같은 클래스에서 AOP가 적용된 메소드를 불러봤자 프록시가 호출을 가로채지 못하기 때문에 getAvailableSeat 를 다른 클래스로 분리하자.
interface SeatFinder {
fun getAvailableSeat(date: LocalDate) : List<Int>
}
...
@Component
class SeatFinderImpl(
private val reservationRepository: ReservationRepository,
private val tempReservationService: TempReservationPort
) : SeatFinder {
@Cacheable(value = ["availableSeats"], key = "#date.toString()")
override fun getAvailableSeat(date: LocalDate): List<Int> {
val seatInPersistent = reservationRepository.getReservedSeatNumber(date).toSet()
val seatInTemp = tempReservationService.getTempReservation(date).toSet()
val availableSeat = (1..50).filter { !seatInPersistent.contains(it) && !seatInTemp.contains(it) }
return availableSeat
}
}
프로젝트는 클린 아키텍처를 표방하고 있기에 이렇게 인터페이스로 분리한 이후 구현체를 만들어 주었다.
이제 캐싱된 데이터가 교체되어야 할 때를 생각해보자. 예약이 만들어지거나 없어질 때 캐시 데이터를 만료시켜주면 될 것이다
결제는 예약 상태만 바뀌는 것이지 차지한 좌석이 풀리는건 아니니까 상관없다
예약이 만들어지는 건 make 메소드, 예약이 없어지는건 cleanupExpiredReservation 이다
@Transactional
@CacheEvict(value = ["availableSeats"], key = "#dto.date.toString()")
fun make(dto: ReservationRequest) : Reservation {
make 메소드에는 이렇게 @CacheEvict를 걸어주었다. 그러면 이 메소드가 실행될 때 캐시가 만료될 것이다.
하지만 cleanupExpiredReservation 에서는 이렇게 걸수가 없다. date 정보 자체를 받지 못하기 때문이다.
왜 받지 못하는가? AOP니까!! AOP 프록시에서는 대상 클래스에 전달되는 요청을 가로채서 어떤 파라미터가 들어오는지 알수 있지만, 대상 클래스 내에서 만들어지는 데이터는 접근할수 없다. 따라서 @CacheEvict 를 쓸수 없기에 직접 CacheManager를 불러야 한다.
@Component
class TempReservationAdaptor(
...
private val cacheManager: CacheManager
) : TempReservationPort {
...
@Transactional
override fun cleanupExpiredReservation(reservationId: Long) {
// RDB에서 예약 정보 조회
val reservation = reservationJpaRepository.findById(reservationId).orElseThrow {
throw EntityNotFoundException("${reservationId}를 가진 예약 정보를 조회할 수 없습니다.")
}
...
// 캐시 무효화
cacheManager.getCache("availableSeats")?.evict(reservation.date.toString())
}
이런 식으로 CacheManger를 주입받고, 직접 evict를 호출해야 한다
그럼 이제 Jmeter를 사용해서 부하 테스트를 하면 된다
테스트에 사용된 task는 링크에서 직접 받아볼수 있다
스레드는 100개, 요청은 30번으로 설정했다
캐싱 적용 전과 후의 비교는 다음과 같다
### 캐싱 적용 전
- Min : 42ms
- Average : 374ms
- Max : 948ms
- Throughput : 236.5/sec
### 캐싱 적용 후
- Min : 13ms
- Average : 288ms
- Max : 814ms
- Throughput : 324.0/sec
최소 응답 속도는 약 70퍼센트 정도 단축되었다. 초당 처리량은 37% 향상되었다.
평균 시간은 크게 개선되지 않았는데 요청 시에 토큰 검증 시간이 대부분의 오버헤드를 차지하고 있기 때문이라고 추정된다.
(이 프로젝트에는 스프링 시큐리티가 적용되어 대부분의 요청을 검증한다)
최대 응답 속도는 cold start 때문에 캐싱 적용 전과 비슷한 것으로 추정된다
분산 락
이 프로젝트에서 예약을 생성 할 때는 위에서 봤던 make 함수로 아웃박스 패턴으로 redis에 임시 예약을 만들고 RDB에는 pending 상태의 예약을 만들어둔다
그래서 이 함수만으로는... 분산 락을 사용할 환경은 딱히 아닌것 같지만, 만약 여러 개의 인스턴스로 동시에 특정 콘서트의 자리에 예약을 시도한다면 적용할 만한 것 같다
이전 포스팅에서 분산 락의 종류를 설명해뒀는데, 이 중 pub/sub 기반의 락을 쓰기 위해 Redisson을 쓰기로 했다.
implementation("org.redisson:redisson-spring-boot-starter:3.24.3")
Redisson 라이브러리를 먼저 추가하자
@Configuration
class RedissonConfig(
@param:Value("\${spring.data.redis.host}") private val host: String,
@param:Value("\${spring.data.redis.port}") private val port: Int
) {
@Bean
fun redissonClient(): RedissonClient {
val config = Config()
config.useSingleServer().address = "redis://$host:$port"
config.codec = StringCodec.INSTANCE
return Redisson.create(config)
}
}
이후 설정 클래스를 만들어주자
codec을 StringCodec으로 만들어주었는데, 이는 원래 key 같은것을 직렬화할 때 바이너리로 저장하는데, 이를 직접 String으로 저장하도록 코덱을 설정해주었다
이후에 다음과 같이 redisTemplate를 직접 사용하는 메소드들을 모두 Redisson으로 바꿔주었다
//Before
override fun getMyRank(userId: String): Long? {
return redisTemplate.opsForZSet().rank(WaitingQueueConstant.ZSET_WAIT_KEY, userId)
}
//After
private fun getWaitZSet() = redissonClient.getScoredSortedSet<String>(WaitingQueueConstant.ZSET_WAIT_KEY)
override fun getMyRank(userId: String): Long? {
// Redisson의 rank는 0부터 시작합니다. (순위가 없으면 null 반환)
val rank = getWaitZSet().rank(userId)
return rank?.toLong()
}
redisson으로 바꾸니까 좀 직관적인가? 잘 모르겠다
일단 분산 락을 걸어줄 클래스를 하나 만들어주자
interface DistributeLockManager {
fun<T> runWithLock(lockKey: String, task: Supplier<T>) : T
}
...
@Component
class DistributeLockManagerImpl(
private val redissonClient: RedissonClient,
) : DistributeLockManager {
private val log = KotlinLogging.logger { }
override fun <T> runWithLock(lockKey: String, task: Supplier<T>): T {
val lock: RLock = redissonClient.getLock(lockKey)
try {
// 획득을 5초 기다리고, 획득 후 2초 지나고 자동으로 해제됨
val available = lock.tryLock(5, 2, TimeUnit.SECONDS)
if (!available) {
log.error("분산 락 획득 실패")
throw LockException("현재 이용자가 많아 대기 중입니다")
}
return task.get()
} catch (e: InterruptedException) {
log.error("Lock 획득 중 인터럽트 발생 ", e)
throw LockException("인터럽트가 발생하여 예약이 취소되었습니다. 처음부터 다시 예약해주세요")
} finally {
if (lock.isHeldByCurrentThread) lock.unlock()
}
}
}
이렇게 lockKey와 Supplier를 사용해서 실행할 함수를 미리 받아두고, 락을 획득했다면 실행하도록 로직을 작성하였다.
Supplier가 뭔지 모른다면 다음을 참고
여기서는 tryLock 에 주목해야 한다
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
(자바로 작성된 라이브러리)
tryLock의 함수 원형인데, waitTime은 락을 획득하기 위해 대기하는 최대 시간이다. 이 시간이 지나면 false를 반환한다
leaseTime은 락을 획득하고 점유하는 시간이다. 이 시간이 지나면 알아서 락이 해제된다
unit은 이 함수에서 사용할 시간의 단위이다
이 함수의 동작 원리는 다음과 같다
- Lua 스크립트를 사용해서 락 획득을 시도하고, 성공하면 바로 true를 반환
- 획득에 실패할 경우 해당 락을 관리하는 redis 채널을 구독하고 대기 상태로 들어감
- 락을 가졌던 다른 스레드가 락을 해제(lock.unlock 또는 자동 해제)하면, redis가 구독 중인 대기자들에게 메세지를 보냄
- 알림을 받은 스레드가 깨어나 다시 락 획득을 시도
따라서 폴링 방식으로 계속 cpu 자원을 쓸 필요가 없다. 마치 세마포어와 같달까
이제 make 함수를 LockManager에 보내줄 클래스도 필요하다
@Component
class ReservationFacade(
private val reservationService: ReservationService,
private val lockManager: DistributeLockManager
) {
fun makeWithLock(dto: ReservationRequest): Reservation {
// 날짜와 좌석번호로 유니크한 키 생성
val lockKey = "LOCK:RESERVATION:${dto.date}:${dto.seatNumber}"
return lockManager.runWithLock(lockKey) {
reservationService.make(dto)
}
}
}
마지막 리턴문에 runWithLock 의 원형에 맞지않게 보낸것 같지만 코틀린의 문법 특징이다
마지막 파라미터가 함수 타입일 경우 (이 경우에선 Supplier) 해당 람다를 괄호 밖으로 빼내어 작성할 수 있다
이렇게 만들어 두면 분산락이 필요한 케이스가 얼마든지 생겨도 LockManager 하나로 분산 락을 적용할 수 있다
이렇게 분산 락과 캐싱을 적용해 두었다. 이번에 캐싱과 분산 락을 조금 억지로 적용한 감이 있긴 한것 같은데... 이것도 경험이고 내 마이그레이션 프로젝트에 적용할 양분이 되었다 생각한다
'항해 Lite' 카테고리의 다른 글
| 이벤트 기반 아키텍처 (1) (1) | 2026.01.30 |
|---|---|
| Redis를 활용한 캐싱 - 과제 리뷰 (0) | 2026.01.23 |
| 분산락과 캐싱으로 트래픽 대응 (1) (0) | 2026.01.01 |
| 동시성 문제(2) - 과제 리뷰 (1) | 2025.12.27 |
| 동시성 문제(1) (0) | 2025.12.21 |