Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Step10 #48

Open
wants to merge 13 commits into
base: step09
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:mysql")
testImplementation("io.rest-assured:rest-assured:5.3.0")
testImplementation("io.rest-assured:json-path:5.3.0")
testImplementation("io.rest-assured:xml-path:5.3.0")

// Kotest 의존성
testImplementation("io.kotest:kotest-runner-junit5:5.7.2")
Expand Down
Binary file added docs/integration-test/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/integration-test/img_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions docs/integration-test/통합테스트.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 테스트 결과

## summary
![img.png](img.png)

## 진행방식

### happy case와 예외사항을 각 케이스 별로 주고 테스트 시나리오 작성

1. domain test
2. service layer test
3. usecase layer test
4. rest assured를 이용한 e2e test

- 좌석 결제에 대한 테스트 시나리오 예시

![img_1.png](img_1.png)

### multi instance 라는 가정하에 동시성 이슈가 발생할 있는 3개의 구간에서 동시성 테스트 진행
1. user 잔고 충전
2. 좌석 예약
3. 예약된 좌석 결제

26 changes: 26 additions & 0 deletions docs/retrospect/1차회고.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 1차 회고록

## 요구사항 분석 및 설계
줄글로 되어있는 요구사항에 대하여 요구사항 정의서을 작성하며 기능및 비기능 요구사항을 구체화해보았습니다.

그 다음엔 요구사항에 맞게 각 시나리오들에 대하여 sequence-diagram을 작성하였습니다. 그러고 나서 erd, api 명세서를 작성하였으며 리뷰를 받으면서 진행하였습니다.

그러나 문제가 발생하였는데, 요구사항에 대해 뾰족하게 정의하지않아 erd를 바꾸고, 다시 sequence-diagram을 변경하고, 다시 요구사항 정의서를 수정하는 등 작업을 반복하였습니다..

설계 문서를 작성하면서 느낀점은 요구사항 분석을 철저히 진행하며, 시나리오를 명확히 해야하는 연습을 좀더 해보아야겠습니다.

## 요구사항 구현
요구사항 분석을 토대로 구현하는것은 크게 어렵지 않았습니다.

다만, 대기열을 어떻게 선점하지? 이런 생각을 오래했던 것 같습니다.

그리고 `DIP(Dependency Inversion Principle)`를 지켜가는 방법, 인프라가 변경되더라도 순수 비즈니스 로직을 바꾸지 않게 하는 방법등을 터득하였습니다.

기능을 구현하면서 kotlin 이라는 언어에 좀더 친숙해질 수 있었습니다.

## 프로젝트 고도화
필터와 인터셉터를 공부하였으며, 목적을 정하고 적용하였습니다.

`rest-assured`를 활용하여 e2e 테스트를 구현해보았습니다. `rest-assured` 처음으로 사용해보고 익혔습니다.

단순히 test coverage를 높히는 것이 아닌 핵심 부분(동시성 이슈, 비즈니스 규칙)을 테스트하며, 예외 시나리오 및 상황에 대해 충분히 고민할 수 있는 시간이였습니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package io.hhplus.concertreservationservice.application.job

import io.hhplus.concertreservationservice.domain.DateRange
import io.hhplus.concertreservationservice.domain.balance.Money
import io.hhplus.concertreservationservice.domain.concert.Concert
import io.hhplus.concertreservationservice.domain.concert.Place
import io.hhplus.concertreservationservice.domain.concert.Schedule
import io.hhplus.concertreservationservice.domain.concert.ScheduleSeat
import io.hhplus.concertreservationservice.domain.concert.Seat
import io.hhplus.concertreservationservice.domain.concert.SeatType
import io.hhplus.concertreservationservice.domain.reservation.ReservationStatus
import io.hhplus.concertreservationservice.domain.reservation.SeatReservation
import io.hhplus.concertreservationservice.domain.user.User
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.ConcertJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.PlaceJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.ScheduleJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.ScheduleSeatJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.SeatJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.SeatReservationJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.UserJpaRepository
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.time.LocalDate
import java.time.LocalDateTime

@ActiveProfiles("integration-test")
@SpringBootTest
class ReservationExpireJobTest(
private val reservationExpireJob: ReservationExpireJob,
private val reservationRepository: SeatReservationJpaRepository,
private val seatJpaRepository: SeatJpaRepository,
private val placeJpaRepository: PlaceJpaRepository,
private val userJpaRepository: UserJpaRepository,
private val concertJpaRepository: ConcertJpaRepository,
private val scheduleSeatJpaRepository: ScheduleSeatJpaRepository,
private val scheduleJpaRepository: ScheduleJpaRepository,
) : BehaviorSpec({

afterEach {
reservationRepository.deleteAll()
seatJpaRepository.deleteAll()
scheduleSeatJpaRepository.deleteAll()
scheduleJpaRepository.deleteAll()
concertJpaRepository.deleteAll()
placeJpaRepository.deleteAll()
userJpaRepository.deleteAll()
}

given("만료된 예약과 활성 상태 예약이 존재할 때") {
val users = mutableListOf<User>()
for (i in 1..100) {
users.add(User(id = i.toLong(), name = "test$i"))
}
userJpaRepository.saveAll(users)

val place1 = Place(name = "상암월드컵경기장", availableSeatCount = 1000)
val place2 = Place(name = "세종문화회관", availableSeatCount = 3000)
placeJpaRepository.saveAll(listOf(place1, place2))

val concert1 = Concert(title = "싸이 흠뻑쇼")
val concert2 = Concert(title = "성시경 콘서트")
val concert3 = Concert(title = "아이유 콘서트")
concertJpaRepository.saveAll(listOf(concert1, concert2, concert3))

val concertSchedule1 =
Schedule(
performanceDate = LocalDate.of(2024, 1, 1),
performanceTime = 300,
reservationPeriod =
DateRange(
start = LocalDate.of(2023, 12, 1),
end = LocalDate.of(2023, 12, 31),
),
concert = concert1,
place = place1,
)
scheduleJpaRepository.saveAll(listOf(concertSchedule1))

val scheduleSeat1 =
ScheduleSeat(
type = SeatType.UNDEFINED,
price = Money(50000),
seatCount = 50,
schedule = concertSchedule1,
)
scheduleSeatJpaRepository.saveAll(listOf(scheduleSeat1))

val seats1 = (1..50).map { no -> Seat(no = no, scheduleSeat = scheduleSeat1) }
seatJpaRepository.saveAll(seats1)

val expiredReservation =
SeatReservation(
status = ReservationStatus.RESERVED,
user = users[0],
seat = seats1[0],
paymentId = null,
reservationExpiredAt = LocalDateTime.now().minusDays(1),
)
val activeReservation =
SeatReservation(
status = ReservationStatus.RESERVED,
user = users[1],
seat = seats1[1],
paymentId = null,
reservationExpiredAt = LocalDateTime.now().plusDays(1),
)
val reservations = listOf(expiredReservation, activeReservation)

reservationRepository.saveAll(reservations)
`when`("예약 만료 작업을 실행하면") {
reservationExpireJob.expireReservation(LocalDateTime.now())

then("만료된 예약이 삭제된다") {
val foundReservations = reservationRepository.findAll()
foundReservations.size shouldBe reservations.size - 1
// foundReservations.none { it.id == reservations[0].id } shouldBe true
}
}
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package io.hhplus.concertreservationservice.application.job

import io.hhplus.concertreservationservice.domain.token.ReservationToken
import io.hhplus.concertreservationservice.domain.token.ReservationTokenConstants.MAX_TOKEN_COUNT
import io.hhplus.concertreservationservice.domain.token.TokenStatus
import io.hhplus.concertreservationservice.domain.user.User
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.ReservationTokenJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.UserJpaRepository
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.time.LocalDateTime

@ActiveProfiles("integration-test")
@SpringBootTest
class TokenActivationJobTest(
private val tokenActivationJob: TokenActivationJob,
private val reservationTokenJpaRepository: ReservationTokenJpaRepository,
private val userJpaRepository: UserJpaRepository,
) : BehaviorSpec({

afterEach {
reservationTokenJpaRepository.deleteAllInBatch()
userJpaRepository.deleteAllInBatch()
}

given("user 5명에 토큰이 발급 되었을때") {
val issueTokenCount = 5

val users = mutableListOf<User>()
for (i in 1..issueTokenCount) {
users.add(
User(name = "Test User-$i"),
)
}

val savedUsers = userJpaRepository.saveAll(users)

val tokens = mutableListOf<ReservationToken>()
savedUsers.forEachIndexed { index, _ ->
tokens.add(
ReservationToken(
userId = savedUsers[index].id,
token = "test-token-$index",
expiredAt = LocalDateTime.now().plusDays(1),
),
)
}

val savedTokens = reservationTokenJpaRepository.saveAll(tokens)

`when`("토큰 활성화 작업이 실행되면") {
tokenActivationJob.activateTokens(LocalDateTime.now())

then("대기 중인 토큰이 활성화 상태로 전부 변경된다") {
val allTokens = reservationTokenJpaRepository.findAll()
val activeCount = allTokens.count { it.status == TokenStatus.ACTIVE }
val waitingCount = allTokens.count { it.status == TokenStatus.WAITING }

activeCount shouldBe issueTokenCount
waitingCount shouldBe 0
}
}
}

given("user 110 명에 토큰이 발급 되었을때") {
val issueTokenCount = 110

val users = mutableListOf<User>()
for (i in 1..issueTokenCount) {
users.add(
User(name = "Test User-$i"),
)
}

val savedUsers = userJpaRepository.saveAll(users)

val tokens = mutableListOf<ReservationToken>()
savedUsers.forEachIndexed { index, _ ->
tokens.add(
ReservationToken(
userId = savedUsers[index].id,
token = "test-token-$index",
expiredAt = LocalDateTime.now().plusDays(1),
),
)
}

val savedTokens = reservationTokenJpaRepository.saveAll(tokens)

`when`("토큰 활성화 작업이 실행되면") {
tokenActivationJob.activateTokens(LocalDateTime.now())

then("max값인 100개만 활성화 되고 나머지 토큰은 대기 상태로 된다.") {
val allTokens = reservationTokenJpaRepository.findAll()
val activeCount = allTokens.count { it.status == TokenStatus.ACTIVE }
val waitingCount = allTokens.count { it.status == TokenStatus.WAITING }

activeCount shouldBe MAX_TOKEN_COUNT
waitingCount shouldBe issueTokenCount - MAX_TOKEN_COUNT
}
}
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package io.hhplus.concertreservationservice.application.job

import io.hhplus.concertreservationservice.domain.token.ReservationToken
import io.hhplus.concertreservationservice.domain.user.User
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.ReservationTokenJpaRepository
import io.hhplus.concertreservationservice.infrastructure.persistence.jpa.UserJpaRepository
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.time.LocalDateTime

@ActiveProfiles("integration-test")
@SpringBootTest
class TokenDeactivationJobTest(
private val tokenDeactivationJob: TokenDeactivationJob,
private val reservationTokenJpaRepository: ReservationTokenJpaRepository,
private val userJpaRepository: UserJpaRepository,
) : BehaviorSpec({
afterEach {
reservationTokenJpaRepository.deleteAllInBatch()
userJpaRepository.deleteAllInBatch()
}

given("user 5명에 만료된 토큰이 존재할때") {
val issueTokenCount = 5

val users = mutableListOf<User>()
for (i in 1..issueTokenCount) {
users.add(
User(name = "Test User-$i"),
)
}

val savedUsers = userJpaRepository.saveAll(users)

val tokens = mutableListOf<ReservationToken>()
savedUsers.forEachIndexed { index, _ ->
tokens.add(
ReservationToken(
userId = savedUsers[index].id,
token = "test-token-$index",
expiredAt = LocalDateTime.now().minusDays(1),
),
)
}

val savedTokens = reservationTokenJpaRepository.saveAll(tokens)

`when`("토큰 비활성화 작업이 실행되면") {
tokenDeactivationJob.deactivateTokens(LocalDateTime.now())

then("대기 중인 토큰이 전부 삭제된다.") {
val allTokens = reservationTokenJpaRepository.findAll()

allTokens.size shouldBe 0
}
}
}

given("user 20 명중 만료된 토큰은 5개, 유효한 토큰은 15개일때") {
val validTokenCount = 15
val expiredTokenCount = 5

val users = mutableListOf<User>()
for (i in 1..validTokenCount + expiredTokenCount) {
users.add(
User(name = "Test User-$i"),
)
}

val savedUsers = userJpaRepository.saveAll(users)

val tokens = mutableListOf<ReservationToken>()
savedUsers.forEachIndexed { index, user ->
val expirationTime =
if (index < validTokenCount) {
LocalDateTime.now().plusDays(1)
} else {
LocalDateTime.now().minusDays(1)
}

tokens.add(
ReservationToken(
userId = user.id,
token = "test-token-$index",
expiredAt = expirationTime,
),
)
}

val savedTokens = reservationTokenJpaRepository.saveAll(tokens)

`when`("토큰 비활성화 작업이 실행되면") {
tokenDeactivationJob.deactivateTokens(LocalDateTime.now())

then("5개의 만료된 토큰은 delete된다.") {
val allTokens = reservationTokenJpaRepository.findAll()

allTokens.size shouldBe validTokenCount
}
}
}
})
Loading