본문 바로가기

서버 구조 설계(3) - 콘서트 예약 서비스 만들기 리뷰 (1)

@정소민fan2025. 11. 20. 23:54

2번째 스텝인 서버 구조 설계의 과제로는 총 두 가지가 나왔다. e-커머스 서비스와 콘서트 예약 서비스.

나는 콘서트 예약 서비스를 선택했다.

깃허브 링크

 

주된 요구 사항은 다음과 같다.

1. 대기열 구현하기

2. 임시 예약 구현하기

3. 포인트 충전/조회 등 단순한 CRUD

 

대기열이나 임시 예약 같은 TTL이 필요한 사항을 위해 Redis를 쓰기로 했다. 

하 근데 진짜 이 Redis 때문에 얼마나 시간을 잡아먹었는지... 거지같다 !!!!!!!!!

먼저 이렇게 본격적으로 Redis를 사용한 건 처음이었던 점도 있고, Redis를 쓰게 되니까 외부 모듈을 사용한 코드를 단위 테스트 하기가 너무 힘들었다. 요리조리 몸 비틀면서 테스트를 짜다가 한 강의를 보게 됐는데 이것이 나의 구원이 되었다.

링크

단순히 의존성 역전이 클래스를 쉽게 갈아 끼우기 위한 것뿐만 아니라 테스트를 쉽게 한다는 것도 깨달았다. 꼭 한번 들어보길 바란다. 진짜 강추

제 소원입니다

 

다 하나하나 리뷰할 수는 없고... Redis를 사용한 부분과 의존성 역전을 사용한 부분만 집중적으로 보겠다.

 

Redis를 쓰기 전에, Redis의 자료구조에는 어떤 것이 있는지 알아야 한다. 이 포스트에서 자세하게 확인할 수 있다.

그러면 스프링에서는 어떻게 쓸까?

Redis를 Spring에서 사용하자

먼저 라이브러리를 추가해 주자

implementation("org.springframework.boot:spring-boot-starter-data-redis")

그다음, 레디스 환경설정이 필요하다.

@Configuration
class RedisConfig(
    @param:Value("\${spring.data.redis.host}")
    private val host: String,
    @param:Value("\${spring.data.redis.port}")
    private val port: Int
) {

    @Bean
    fun redisJsonTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<String, Any> {
        val template = RedisTemplate<String, Any>()
        template.connectionFactory = connectionFactory

        template.keySerializer = StringRedisSerializer()
        template.valueSerializer = GenericJackson2JsonRedisSerializer()

        template.hashKeySerializer = StringRedisSerializer()
        template.hashValueSerializer = GenericJackson2JsonRedisSerializer()

        template.afterPropertiesSet()
        return template
    }

    @Bean
    fun redisMessageListenerContainer(
        connectionFactory: RedisConnectionFactory // Spring Boot가 자동 등록한 연결 팩토리를 주입받음
    ): RedisMessageListenerContainer {
        val container = RedisMessageListenerContainer()
        container.setConnectionFactory(connectionFactory)
        return container
    }

}

application.yml에서 host와 port를 주입받아 사용하도록 했다.

음... 이 프로젝트에서는 <String, Any> 형식의 사실 레디스 템플릿은 필요가 없을 것 같다. 처음에 쓴다고 만들어뒀는데... 안 썼다.

RedisTemplate

ReidsTemplate는 스프링 애플리케이션과 레디스 사이의 연결을 중계해 주는 역할을 한다. 직렬화와 역직렬화를 해준다고 보면 될 것 같다. 위 코드에서는 String을 key로 삼고 모든 형식, 즉 아무 클래스를 value로 삼을 수 있는 RedisTemplate를 등록해 뒀다.

이렇게 사용하면 내가 만든 임의의 클래스를 key를 통해 담아둘 수 있다.

그런데 keySerializer와 hashKeySerializer가 있다. 이 둘은 어떻게 다른 건가?

 

위에 링크해 둔 포스트에서 보다시피, 레디스는 key-value 쌍으로 저장이 가능한데 value 쪽에 들어갈 수 있는 자료구조에는 Hash도 있다. 이는 자바의 HashMap가 같다고 보면 되겠다.

그런데 생각해 보면 HashMap도 key-value 쌍이다. 즉 그냥 keySerializer는 자료구조를 구분할 key의 직렬화 방식이고 hashKeySerializer는 Hash 자료구조의 key의 직렬화 방식인 것이다.

RedisMessageListenerContainer

Redis에서는 pub/sub도 지원한다. 나는 이를 이용해서 특정 key가 만료되면 그것을 감지해서 특정 행동을 하게 구현했다.

이를 위해서는 위처럼 빈으로 등록하고 사용해야 한다.

예약 기능 구현하기

예약의 요구 사항은 다음과 같다.

1. 5분 동안 임시 예약을 해둘 것

2. 5분 내에 결제를 시행하면 예약 확정되도록 할 것

3. 날짜를 주면 그 날짜에 예약 가능한 자리들을 반환할 것

4. 동시성 제어가 가능토록 할 것

일단 TTL이 기본으로 설정되어 있어야 하기 때문에 Redis를 사용하기로 했다.

Redis의 어떤 자료구조가 필요할까? 일단 입력받은 날짜의 예약 가능한 날짜를 반환해야 한다. 여기서 임시 예약된 자리들은 당연히 예약 불가능해야 한다. 그러면 각 날짜를 key로 갖고 value로는 임시 예약된 자리 번호를 넣어주면 될 것 같다. 그러면 set 자료구조가 딱이다.

하지만 문제가 있다. set 자료구조를 사용하면 내부의 원소들 하나하나에 TTL을 걸 수가 없다. 이러면 5분 동안 임시 예약을 걸어야 한다는 요구사항을 충족하지 못한다.

그래서 자료구조를 한 개 더 쓰기로 했다. key 하나에 하나의 임시 예약 정보가 담겨있어야 하기에 string 자료구조가 최적일 것 같다. 여기의 value에는 RDB의 예약 엔티티 아이디가 들어가면 될 것 같다.

예약 엔티티의 정보는 다음과 같다.

@Entity
class Reservation(

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    @Column(name = "reservation_date")
    var date: LocalDate,
    @Column(name = "seat_num")
    var seatNumber: Int,
    @Column(name = "reservation_status")
    @Enumerated(EnumType.STRING)
    var status: ReservationStatus,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    var reserver: Member?
) : BaseEntity()


enum class ReservationStatus{

    PENDING, RESERVE, CANCEL

}

맨 처음 생성될 때는 PENDING 상태로 있다가 결제가 완료되면 RESERVE, TTL이 만료되면 CANCEL로 상태를 바꾸면 될 것 같다.

그럼 예약 기능의 플로우는 다음과 같이 될 것 같다.

 

대강 야매로 만든 순서도이니 틀린 부분은 넘어가자.

이제 임시 예약부터 만들어볼까? 테스트를 편하게 하기 위해 Redis와 직접 통시하는 클래스는 따로 인터페이스를 만들어두었다,

interface RedisReservationOperations {

    /**
     * 임시 예약 정보를 Redis에 저장
     * - seatListKey: 만료 시간이 있는 좌석 정보
     * - reserveKey: 날짜별 예약된 좌석들
     */
    fun saveTempReservation(
        seatListKey: String,
        reserveKey: String,
        seatNumber: Int,
        timeoutSeconds: Long
    )

    /**
     * 특정 날짜의 임시 예약된 좌석 목록 조회
     */
    fun getTempReservedSeats(reserveKey: String): List<Int>

    /**
     * Redis에서 예약 정보 삭제
     */
    fun deleteReservation(seatListKey: String): Boolean

    /**
     * Redis에서 예약이 유효한지 확인
     */
    fun isReservationExists(seatListKey: String): Boolean

    /**
     * Redis Set에서 특정 좌석 제거
     */
    fun removeFromReserveSet(reserveKey: String, seatNumber: Int)
}

뭐가 많은데 가장 중요한 메소드는 save이니 이것만 보자.

override fun saveTempReservation(
    seatListKey: String,
    reserveKey: String,
    seatNumber: Int,
    timeoutSeconds: Long
) {
    redisTemplate.executePipelined { connection ->
        val seatListKeyBytes = seatListKey.toByteArray()
        val reserveKeyBytes = reserveKey.toByteArray()
        val seatNumberBytes = seatNumber.toString().toByteArray()

        connection.stringCommands().setEx(
            seatListKeyBytes,
            timeoutSeconds,
            seatNumberBytes
        )

        connection.setCommands().sAdd(
            reserveKeyBytes,
            seatNumberBytes
        )

        null
    }
}

간단하게 opsForValue나 opsForSet을 사용할 수도 있었지만, 두 자료구조는 반드시 함께 생성되어야 한다. 그래서 위처럼 트랜잭션으로 묶어서 만들었다. seatListKey는 TTL을 위해 만들어졌고, reserveKey는 현재 날짜의 임시 예약된 자리를 찾기 위해서 만들어진 키다. 그럼 이 키의 구조는 어떻게 될까?

object TempReservationConstant {
    const val TEMP_RESERVATIONS = "temp:reservations:"
    const val CHECK_SEAT = "check:seat:"
}

...

private fun getReservedKey(date: LocalDate): String =
        "${TempReservationConstant.CHECK_SEAT}$date"

private fun getSeatListKey(reservationId: Long): String =
    "${TempReservationConstant.TEMP_RESERVATIONS}$reservationId"

간단하다. TTL을 걸 키는 "temp:reservations:{id}" 형식이고, 특정 날짜의 임시 예약된 자리들의 set 키는 "check:seat:YYYY-MM-DD" 로 되어있다. 왜 TTL을 걸 키에는 temp:reservations:{id}로 id를 추가했는가? 그냥 value로 id를 추가하면 되는 거 아닌가? 싶지만, id를 value로 추가하면 temp:reservations: 라는 키에 계속 value가 추가되어서 값이 덮어씌워질 것이다. 따라서 키가 겹치면 안 되기 때문에 중복이 없는 id를 키에 넣어버린 것이다. 이 때는 의미없는 쓰레기 값을 value에 추가해 주면 된다. 더해서 다른 이유가 있는데, 후술하겠다.

그럼 "temp:reservations:{id}" 가 만료된 것을 감지하면 어떻게 하면 될까?

@Component
class TempReservationListener(
    container: RedisMessageListenerContainer,
    private val tempReservationAdaptor: TempReservationPort
) : KeyExpirationEventMessageListener(container) {

    private val log = KotlinLogging.logger {  }

    override fun onMessage(message: Message, pattern: ByteArray?) {
        val expireKey = String(message.body)
        if (expireKey.startsWith(TempReservationConstant.TEMP_RESERVATIONS)) {
            try {
                val reservationId = expireKey.split(":")[2].toLong()
                tempReservationAdaptor.cleanupExpiredReservation(reservationId)
            } catch (e: Exception) {
                log.info { "예외 발생 !!" }
                throw e
            }
        }
    }
}

 

이 코드는 레디스에서 만료된 키를 감지하는 코드이다. 그런데 message.body에서 가져온 값은 항상 key일까? 그렇다. 

레디스의 만료 메세지 동작 순서는 다음과 같다.

 

  • TTL(시간)이 다 됨.
  • Redis가 데이터를 삭제함.
  • 삭제가 완료되면 "야, 방금 이거 지웠어!" 하고 이벤트(Event)를 발행
  • 리스너(onMessage)가 이벤트를 받음.

따라서 리스너가 이벤트를 받았을 때는 이미 해당 value가 Redis에 남아있지 않기 때문에 어떤 짓을 해도 value값은 가져올 수가 없다. 위에서 말했다시피 키에 RDB의 id값을 넣은 이유가 이것도 해당된다.

 

만료된 키 값은 "temp:reservations:{id}" 형식이고, 우리가 필요한 값은 id이다. 이를 ':' 로 split하여 가져오자.

@Transactional
override fun cleanupExpiredReservation(reservationId: Long) {
    // 1. RDB에서 예약 정보 조회
    val reservation = reservationRepository.findById(reservationId).orElseThrow {
        throw EntityNotFoundException("${reservationId}를 가진 예약 정보를 조회할 수 없습니다.")
    }

    // 2. Redis Set에서 좌석 제거
    val reserveKey = getReservedKey(reservation.date)
    redisOperations.removeFromReserveSet(reserveKey, reservation.seatNumber)

    // 3. RDB 예약 상태 변경
    reservation.status = ReservationStatus.CANCEL
}

 

그리고 이렇게 id를 통해 RDB에서 Reservation 엔티티를 가져오고, date 값을 가져온 다음, "check:seat:YYYY-MM-DD"  형식의 키를 만들어서 set 자료구조를 찾고, 그 자료구조에서 Reservation의 좌석 번호만 없애주면 된다 !!

그리고 TTL이 만료되었다는 건 시간 내에 결제를 완료하지 못했다는 뜻이니까 Reservation의 상태를 CANCEL로 둔다.

override fun removeFromReserveSet(reserveKey: String, seatNumber: Int) {
    redisTemplate.opsForSet().remove(reserveKey, seatNumber.toString())
}

removeFromRserveSet은 그냥 redisTemplate에서 remove를 중계해 주는 역할이다. 왜 seatNumber를 toString으로 바꾸었냐면 StringRedisTemplate를 사용하기 때문이다. 위에서 만든 RedisTemplate는 <String, Any>인데, StringRedisTemplate 는 <String, String> 형식으로 미리 만들어진 RedisTemplate이다.

사실 이 코드에서 seatNumber의 타입을 String으로 제한하면 더 좋았을 것 같다. remove 메소드는 어떤 값이 들어오든 타입 체크를 하지 않기 때문에 실수로 toString()을 하지 않는다면 에러가 발생할수도 있을것 같다.

테스트하기

그러면 이제 단위 테스트를 만들어보자. 위에서 interface RedisReservationOperations 를 만든 이유를 보일 때다. 이 인터페이스를 구현한 RedisReservationOperationsImpl 는 Redis와 직접 소통한다. 그럼 이 클래스는 단위 테스트가 가능할까? 그리고 굳이 해야할까?

뭐 하는 편이 좋겠지만... spring data redis를 사용한 마당에 굳이 그래야할까 싶다. 충분히 잘 만든 라이브러리이고 그러면 이 라이브러리를 만든 사람들이 수도 없이 테스트를 이미 진행하지 않았을까? 그래서 그냥 넘어가겠다.

그러면 이 인터페이스에 의존하는 TempReservationAdaptor는 어떻게 단위 테스트를 하면 될까?

@Component
class TempReservationAdaptor(
    private val redisOperations: RedisReservationOperations,
    private val reservationRepository: ReservationRepository,
) : TempReservationPort {
....

의존하는 것은 모두 인터페이스이다. 위 인터페이스를 구현하는 가짜 객체만 만들어서 넘겨주면 된다. 그러면 우리는 DB에 연결하거나 Redis에 연결할 필요가 전혀 없다 !! 만약 구체 클래스인 RedisReservationOperaionImpl에 직접 의존했다면.... 테스트를 돌릴 때마다 Redis에 연결해야 했을테고 시간이 오래 걸리거니와 복잡도도 증가했을 것이다.

 

@ExtendWith(MockitoExtension::class)
class TempReservationAdaptorTest {

    @Mock
    private lateinit var redisOperations: RedisReservationOperations

    @Mock
    private lateinit var reservationRepository: ReservationRepository

    @InjectMocks
    private lateinit var adaptor: TempReservationAdaptor
    ...

이렇게 만들어주기만 하면 된다. 그리고 각 Mock 객체의 동작 방식을 만들어주기만 하면 끝!! 예시만 하나 보자.

@Test
fun `cleanupExpiredReservation 성공`() {
    // Given
    val reservation = Reservation(
        id = reservationId,
        date = date,
        seatNumber = seatNumber,
        status = ReservationStatus.PENDING,
        reserver = null
    )

    given(reservationRepository.findById(reservationId))
        .willReturn(Optional.of(reservation))
    given(reservationRepository.save(any()))
        .willReturn(reservation)

    // When
    adaptor.cleanupExpiredReservation(reservationId)

    // Then
    verify(redisOperations, times(1))
        .removeFromReserveSet("check:seat:$date", seatNumber)

    verify(reservationRepository, times(1))
        .save(any())

    assertThat(reservation.status)
        .isNotNull
        .isEqualTo(ReservationStatus.CANCEL)
}

cleanupExpiredReservation 메소드를 테스트하는 테스트 메소드이다. 이 메소드는 의존하는 인터페이스의 메소드 딱 두개를 쓴다. findByIdremoveFromReserveSet. 이 두개의 기대값만 모킹해주면 되는 것이다.

그리고 전체 테스트를 돌려봤을 때 시간을 한번 볼까?

ㅋㅋ

ㅋㅋㅋㅋㅋ

ㅋㅋ

ㅋㅋㅋ

테스트가 빠른 것은 중요하다. 만약 저기에서 Redis 모듈을 강결합시키고 @SpringBootTest를 쓰고 했다면.... Spring을 띄우고 Redis와 연결하고 이 시간만 해도 10초씩은 넘게 걸릴 것이다.

이런 테스트가 계속해서 많아진다면? 테스트를 한번 돌리면 몇분씩 손을 놔버려야 할 때도 종종 있다고 한다. 그럼 테스트를 돌리기에 부담이 커질 것이다.

 

다음에는 대기열 구현을 포스팅 해보겠다. 근데 Redis를 사용한 거의 같은 느낌이라... 귀찮으면 걍 넘기고

정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차