├── settings.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── kotlin │ │ └── com │ │ │ └── hhplus │ │ │ └── concert │ │ │ ├── common │ │ │ ├── type │ │ │ │ ├── EventStatus.kt │ │ │ │ ├── PaymentStatus.kt │ │ │ │ ├── SeatStatus.kt │ │ │ │ ├── ConcertStatus.kt │ │ │ │ ├── QueueStatus.kt │ │ │ │ ├── AlarmLevel.kt │ │ │ │ └── ReservationStatus.kt │ │ │ ├── annotation │ │ │ │ ├── ValidatedToken.kt │ │ │ │ ├── TokenRequired.kt │ │ │ │ └── DistributedSimpleLock.kt │ │ │ ├── constants │ │ │ │ └── TokenConstants.kt │ │ │ ├── error │ │ │ │ ├── exception │ │ │ │ │ └── BusinessException.kt │ │ │ │ ├── response │ │ │ │ │ └── ErrorResponse.kt │ │ │ │ └── code │ │ │ │ │ └── ErrorCode.kt │ │ │ ├── config │ │ │ │ ├── RedisConfig.kt │ │ │ │ ├── WebConfig.kt │ │ │ │ ├── SwaggerConfig.kt │ │ │ │ ├── AsyncConfig.kt │ │ │ │ ├── KafkaConfig.kt │ │ │ │ └── CacheConfig.kt │ │ │ ├── aop │ │ │ │ ├── RedisSimpleLock.kt │ │ │ │ └── DistributedSimpleLockAspect.kt │ │ │ ├── resolver │ │ │ │ └── ValidatedTokenResolver.kt │ │ │ └── util │ │ │ │ └── JwtUtil.kt │ │ │ ├── business │ │ │ ├── domain │ │ │ │ ├── manager │ │ │ │ │ ├── payment │ │ │ │ │ │ ├── event │ │ │ │ │ │ │ ├── PaymentEvent.kt │ │ │ │ │ │ │ ├── PaymentEventPublisher.kt │ │ │ │ │ │ │ └── PaymentEventOutBoxManager.kt │ │ │ │ │ │ ├── PaymentMessageSender.kt │ │ │ │ │ │ └── PaymentManager.kt │ │ │ │ │ ├── UserManager.kt │ │ │ │ │ ├── concert │ │ │ │ │ │ ├── ConcertCacheManager.kt │ │ │ │ │ │ └── ConcertManager.kt │ │ │ │ │ ├── balance │ │ │ │ │ │ ├── BalanceLockManager.kt │ │ │ │ │ │ └── BalanceManager.kt │ │ │ │ │ ├── reservation │ │ │ │ │ │ ├── ReservationLockManager.kt │ │ │ │ │ │ └── ReservationManager.kt │ │ │ │ │ └── queue │ │ │ │ │ │ └── QueueManager.kt │ │ │ │ ├── message │ │ │ │ │ ├── MessageClient.kt │ │ │ │ │ └── MessageAlarmPayload.kt │ │ │ │ ├── repository │ │ │ │ │ ├── BalanceRepository.kt │ │ │ │ │ ├── UserRepository.kt │ │ │ │ │ ├── PaymentHistoryRepository.kt │ │ │ │ │ ├── PaymentRepository.kt │ │ │ │ │ ├── ConcertRepository.kt │ │ │ │ │ ├── ConcertScheduleRepository.kt │ │ │ │ │ ├── PaymentEventOutBoxRepository.kt │ │ │ │ │ ├── SeatRepository.kt │ │ │ │ │ └── ReservationRepository.kt │ │ │ │ └── entity │ │ │ │ │ ├── User.kt │ │ │ │ │ ├── PaymentHistory.kt │ │ │ │ │ ├── Concert.kt │ │ │ │ │ ├── ConcertSchedule.kt │ │ │ │ │ ├── Balance.kt │ │ │ │ │ ├── PaymentEventOutBox.kt │ │ │ │ │ ├── Seat.kt │ │ │ │ │ ├── Queue.kt │ │ │ │ │ ├── Payment.kt │ │ │ │ │ └── Reservation.kt │ │ │ └── application │ │ │ │ ├── dto │ │ │ │ ├── BalanceServiceDto.kt │ │ │ │ ├── PaymentServiceDto.kt │ │ │ │ ├── PaymentEventOutBoxServiceDto.kt │ │ │ │ ├── QueueServiceDto.kt │ │ │ │ ├── ReservationServiceDto.kt │ │ │ │ └── ConcertServiceDto.kt │ │ │ │ └── service │ │ │ │ ├── BalanceService.kt │ │ │ │ ├── PaymentEventOutBoxService.kt │ │ │ │ ├── QueueService.kt │ │ │ │ ├── ReservationService.kt │ │ │ │ ├── ConcertService.kt │ │ │ │ └── PaymentService.kt │ │ │ ├── interfaces │ │ │ ├── presentation │ │ │ │ ├── request │ │ │ │ │ ├── BalanceRequest.kt │ │ │ │ │ ├── PaymentRequest.kt │ │ │ │ │ └── ReservationRequest.kt │ │ │ │ ├── response │ │ │ │ │ ├── BalanceResponse.kt │ │ │ │ │ ├── PaymentResponse.kt │ │ │ │ │ ├── QueueTokenResponse.kt │ │ │ │ │ ├── ReservationResponse.kt │ │ │ │ │ └── ConcertResponse.kt │ │ │ │ ├── controller │ │ │ │ │ ├── ReservationController.kt │ │ │ │ │ ├── BalanceController.kt │ │ │ │ │ ├── QueueController.kt │ │ │ │ │ ├── PaymentController.kt │ │ │ │ │ └── ConcertController.kt │ │ │ │ ├── interceptor │ │ │ │ │ └── TokenInterceptor.kt │ │ │ │ ├── exceptionhandler │ │ │ │ │ └── ApiAdviceHandler.kt │ │ │ │ └── filter │ │ │ │ │ └── LoggingFilter.kt │ │ │ ├── scheduler │ │ │ │ ├── ReservationScheduler.kt │ │ │ │ ├── QueueScheduler.kt │ │ │ │ └── PaymentEventScheduler.kt │ │ │ ├── consumer │ │ │ │ └── PaymentEventKafkaConsumer.kt │ │ │ └── event │ │ │ │ └── PaymentEventListener.kt │ │ │ ├── infrastructure │ │ │ ├── jpa │ │ │ │ ├── UserJpaRepository.kt │ │ │ │ ├── ConcertJpaRepository.kt │ │ │ │ ├── ConcertScheduleJpaRepository.kt │ │ │ │ ├── BalanceJpaRepository.kt │ │ │ │ ├── PaymentJpaRepository.kt │ │ │ │ ├── PaymentHistoryJpaRepository.kt │ │ │ │ ├── EventOutBoxJpaRepository.kt │ │ │ │ ├── SeatJpaRepository.kt │ │ │ │ └── ReservationJpaRepository.kt │ │ │ ├── impl │ │ │ │ ├── BalanceRepositoryImpl.kt │ │ │ │ ├── UserRepositoryImpl.kt │ │ │ │ ├── PaymentHistoryRepositoryImpl.kt │ │ │ │ ├── PaymentRepositoryImpl.kt │ │ │ │ ├── ConcertRepositoryImpl.kt │ │ │ │ ├── ConcertScheduleRepositoryImpl.kt │ │ │ │ ├── PaymentEventOutBoxRepositoryImpl.kt │ │ │ │ ├── SeatRepositoryImpl.kt │ │ │ │ └── ReservationRepositoryImpl.kt │ │ │ ├── publisher │ │ │ │ └── PaymentEventPublisherImpl.kt │ │ │ ├── kafka │ │ │ │ └── PaymentEventKafkaProducer.kt │ │ │ ├── client │ │ │ │ └── SlackClient.kt │ │ │ └── redis │ │ │ │ └── QueueRedisRepository.kt │ │ │ └── ConcertApplication.kt │ └── resources │ │ └── application.yml └── test │ ├── resources │ └── application.yml │ └── kotlin │ └── com │ └── hhplus │ └── concert │ ├── interfaces │ ├── kafka │ │ └── KafkaConnectionTest.kt │ ├── scheduler │ │ ├── PaymentEventSchedulerTest.kt │ │ └── QueueSchedulerIntegrationTest.kt │ └── presentation │ │ └── controller │ │ ├── QueueControllerTest.kt │ │ └── BalanceControllerIntegrationTest.kt │ ├── domain │ └── manager │ │ ├── balance │ │ └── BalanceManagerTest.kt │ │ └── payment │ │ └── PaymentEventOutBoxManagerTest.kt │ └── application │ └── facade │ ├── concurrency │ └── BalanceServiceConcurrencyTest.kt │ └── integration │ ├── PaymentEventOutBoxServiceIntegrationTest.kt │ └── BalanceServiceIntegrationTest.kt ├── docker ├── local │ ├── local.Dockerfile │ └── docker-compose-local.yml └── production │ └── prod.Dockerfile ├── k6 ├── concert │ ├── concert-available.js │ ├── concert-schedule.js │ └── concert-seat.js ├── balance │ ├── balance-get.js │ └── balance-recharge.js ├── queue │ └── queue.js ├── common │ └── test-options.js ├── payment │ └── payment.js ├── reservation │ └── reservation.js └── integration │ └── integration-test.js ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── README.md ├── docs ├── 03_ERD.md ├── 05_Swagger.md └── 01_Milestone.md └── gradlew.bat /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'concert' 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mingj7235/concert/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/type/EventStatus.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.type 2 | 3 | enum class EventStatus { 4 | INIT, 5 | PUBLISHED, 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/type/PaymentStatus.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.type 2 | 3 | enum class PaymentStatus { 4 | COMPLETED, 5 | FAILED, 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/type/SeatStatus.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.type 2 | 3 | enum class SeatStatus { 4 | AVAILABLE, 5 | UNAVAILABLE, 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/type/ConcertStatus.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.type 2 | 3 | enum class ConcertStatus { 4 | AVAILABLE, 5 | UNAVAILABLE, 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/type/QueueStatus.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.type 2 | 3 | enum class QueueStatus { 4 | WAITING, 5 | PROCESSING, 6 | COMPLETED, 7 | CANCELLED, 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/payment/event/PaymentEvent.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.payment.event 2 | 3 | data class PaymentEvent( 4 | val paymentId: Long, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/message/MessageClient.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.message 2 | 3 | interface MessageClient { 4 | fun sendMessage(alarm: MessageAlarmPayload): Any? 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/type/AlarmLevel.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.type 2 | 3 | enum class AlarmLevel { 4 | PRIMARY, 5 | INFO, 6 | SUCCESS, 7 | WARNING, 8 | DANGER, 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/annotation/ValidatedToken.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.annotation 2 | 3 | @Target(AnnotationTarget.VALUE_PARAMETER) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class ValidatedToken 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/request/BalanceRequest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.request 2 | 3 | class BalanceRequest { 4 | data class Recharge( 5 | val amount: Long, 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/constants/TokenConstants.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.constants 2 | 3 | object TokenConstants { 4 | const val QUEUE_TOKEN_HEADER = "QUEUE-TOKEN" 5 | const val VALIDATED_TOKEN = "validatedToken" 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/payment/event/PaymentEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.payment.event 2 | 3 | interface PaymentEventPublisher { 4 | fun publishPaymentEvent(event: PaymentEvent) 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/annotation/TokenRequired.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.annotation 2 | 3 | @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class TokenRequired 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/type/ReservationStatus.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.type 2 | 3 | enum class ReservationStatus { 4 | PAYMENT_PENDING, 5 | PAYMENT_COMPLETED, 6 | RESERVATION_CANCELLED, 7 | RESERVATION_FAILED, 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/request/PaymentRequest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.request 2 | 3 | class PaymentRequest { 4 | data class Detail( 5 | val reservationIds: List, 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/dto/BalanceServiceDto.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.dto 2 | 3 | class BalanceServiceDto { 4 | data class Detail( 5 | val userId: Long, 6 | val currentAmount: Long, 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/UserJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.User 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface UserJpaRepository : JpaRepository 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/ConcertJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.Concert 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface ConcertJpaRepository : JpaRepository 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/message/MessageAlarmPayload.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.message 2 | 3 | import com.hhplus.concert.common.type.AlarmLevel 4 | 5 | data class MessageAlarmPayload( 6 | val alarmLevel: AlarmLevel, 7 | val subject: String, 8 | val description: String, 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/BalanceRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.Balance 4 | 5 | interface BalanceRepository { 6 | fun findByUserId(userId: Long): Balance? 7 | 8 | fun save(balance: Balance): Balance 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.User 4 | 5 | interface UserRepository { 6 | fun save(user: User): User 7 | 8 | fun findById(userId: Long): User? 9 | 10 | fun deleteAll() 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/dto/PaymentServiceDto.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.dto 2 | 3 | import com.hhplus.concert.common.type.PaymentStatus 4 | 5 | class PaymentServiceDto { 6 | data class Result( 7 | val paymentId: Long, 8 | val amount: Int, 9 | val paymentStatus: PaymentStatus, 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/PaymentHistoryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentHistory 4 | 5 | interface PaymentHistoryRepository { 6 | fun save(paymentHistory: PaymentHistory) 7 | 8 | fun findAllByPaymentId(paymentId: Long): List 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/dto/PaymentEventOutBoxServiceDto.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.dto 2 | 3 | import com.hhplus.concert.common.type.EventStatus 4 | 5 | class PaymentEventOutBoxServiceDto { 6 | data class EventOutBox( 7 | val id: Long, 8 | val paymentId: Long, 9 | val eventStatus: EventStatus, 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/PaymentRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.Payment 4 | 5 | interface PaymentRepository { 6 | fun save(payment: Payment): Payment 7 | 8 | fun findByReservationId(reservationId: Long): Payment? 9 | 10 | fun findById(paymentId: Long): Payment? 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/ConcertScheduleJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.ConcertSchedule 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface ConcertScheduleJpaRepository : JpaRepository { 7 | fun findAllByConcertId(concertId: Long): List 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/ConcertRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.Concert 4 | 5 | interface ConcertRepository { 6 | fun findById(concertId: Long): Concert? 7 | 8 | fun findAll(): List 9 | 10 | fun save(concert: Concert): Concert 11 | 12 | fun saveAll(concerts: List) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/annotation/DistributedSimpleLock.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.annotation 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | @Target(AnnotationTarget.FUNCTION) 6 | @Retention(AnnotationRetention.RUNTIME) 7 | annotation class DistributedSimpleLock( 8 | val key: String, 9 | val waitTime: Long = 5, 10 | val leaseTime: Long = 10, 11 | val timeUnit: TimeUnit = TimeUnit.SECONDS, 12 | ) 13 | -------------------------------------------------------------------------------- /docker/local/local.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gradle:8.8.0-jdk21 AS builder 2 | 3 | COPY ../.. /usr/src 4 | WORKDIR /usr/src 5 | RUN --mount=type=cache,target=/root/.gradle gradle clean build -xtest 6 | 7 | FROM amazoncorretto:21-alpine 8 | 9 | ARG PORT 10 | ENV PORT=${PORT:-8080} 11 | 12 | WORKDIR /usr/src/ 13 | COPY --from=builder /usr/src/build/libs/*.jar /usr/src/app.jar 14 | 15 | EXPOSE ${PORT} 16 | 17 | ENTRYPOINT ["java", "-jar", "/usr/src/app.jar"] 18 | -------------------------------------------------------------------------------- /docker/production/prod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gradle:8.8.0-jdk21 AS builder 2 | 3 | COPY ../.. /usr/src 4 | WORKDIR /usr/src 5 | RUN --mount=type=cache,target=/root/.gradle gradle clean build -xtest 6 | 7 | FROM amazoncorretto:21-alpine 8 | 9 | ARG PORT 10 | ENV PORT=${PORT:-8080} 11 | 12 | WORKDIR /usr/src/ 13 | COPY --from=builder /usr/src/build/libs/*.jar /usr/src/app.jar 14 | 15 | EXPOSE ${PORT} 16 | 17 | ENTRYPOINT ["java", "-jar", "/usr/src/app.jar"] 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/dto/QueueServiceDto.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.dto 2 | 3 | import com.hhplus.concert.common.type.QueueStatus 4 | 5 | class QueueServiceDto { 6 | data class IssuedToken( 7 | val token: String, 8 | ) 9 | 10 | data class Queue( 11 | val status: QueueStatus, 12 | val remainingWaitListCount: Long, 13 | val estimatedWaitTime: Long, 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/ConcertScheduleRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.ConcertSchedule 4 | 5 | interface ConcertScheduleRepository { 6 | fun findAllByConcertId(concertId: Long): List 7 | 8 | fun findById(scheduleId: Long): ConcertSchedule? 9 | 10 | fun save(concertSchedule: ConcertSchedule): ConcertSchedule 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/BalanceJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.Balance 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | import org.springframework.data.jpa.repository.Query 6 | 7 | interface BalanceJpaRepository : JpaRepository { 8 | @Query("select b from Balance b where b.user.id = :userId") 9 | fun findByUserId(userId: Long): Balance? 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/ConcertApplication.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.cache.annotation.EnableCaching 6 | import org.springframework.scheduling.annotation.EnableScheduling 7 | 8 | @EnableCaching 9 | @EnableScheduling 10 | @SpringBootApplication 11 | class ConcertApplication 12 | 13 | fun main(args: Array) { 14 | runApplication(*args) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/PaymentJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.Payment 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | import org.springframework.data.jpa.repository.Query 6 | 7 | interface PaymentJpaRepository : JpaRepository { 8 | @Query("select payment from Payment payment where payment.reservation.id = :reservationId") 9 | fun findByReservationId(reservationId: Long): Payment? 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/PaymentHistoryJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentHistory 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | import org.springframework.data.jpa.repository.Query 6 | 7 | interface PaymentHistoryJpaRepository : JpaRepository { 8 | @Query("select paymentHistory from PaymentHistory paymentHistory where paymentHistory.payment.id = :paymentId") 9 | fun findAllByPaymentId(paymentId: Long): List 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/User.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.GeneratedValue 6 | import jakarta.persistence.GenerationType 7 | import jakarta.persistence.Id 8 | 9 | @Entity 10 | class User( 11 | name: String, 12 | ) { 13 | @Id 14 | @GeneratedValue(strategy = GenerationType.IDENTITY) 15 | val id: Long = 0 16 | 17 | @Column(name = "name", nullable = false) 18 | var name: String = name 19 | protected set 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/PaymentEventOutBoxRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 4 | import java.time.LocalDateTime 5 | 6 | interface PaymentEventOutBoxRepository { 7 | fun save(paymentEventOutBox: PaymentEventOutBox): PaymentEventOutBox 8 | 9 | fun findByPaymentId(paymentId: Long): PaymentEventOutBox? 10 | 11 | fun findAllFailedEvent(dateTime: LocalDateTime): List 12 | 13 | fun deleteAllPublishedEvent(dateTime: LocalDateTime) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/error/exception/BusinessException.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.error.exception 2 | 3 | import com.hhplus.concert.common.error.code.ErrorCode 4 | 5 | open class BusinessException( 6 | val errorCode: ErrorCode, 7 | ) : RuntimeException() { 8 | class NotFound( 9 | errorCode: ErrorCode, 10 | ) : BusinessException(errorCode) 11 | 12 | class BadRequest( 13 | errorCode: ErrorCode, 14 | ) : BusinessException(errorCode) 15 | 16 | class Duplication( 17 | errorCode: ErrorCode, 18 | ) : BusinessException(errorCode) 19 | } 20 | -------------------------------------------------------------------------------- /k6/concert/concert-available.js: -------------------------------------------------------------------------------- 1 | // available-concerts-test.js 2 | import http from 'k6/http'; 3 | import { check, sleep } from 'k6'; 4 | import { options, BASE_URL } from '../common/test-options.js'; 5 | 6 | export { options }; 7 | 8 | export default function () { 9 | const concertsResponse = http.get(`${BASE_URL}/api/v1/concerts`, { 10 | headers: { 'QUEUE-TOKEN': `token` }, 11 | }); 12 | 13 | check(concertsResponse, { 14 | 'concerts status was 200': (r) => r.status === 200, 15 | // 'response has concerts': (r) => JSON.parse(r.body).length > 0, 16 | }); 17 | 18 | sleep(1); 19 | } -------------------------------------------------------------------------------- /k6/balance/balance-get.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { options, BASE_URL } from '../common/test-options.js'; 4 | 5 | export { options }; 6 | 7 | const TEST_USER_IDS = [1, 2, 3, 4, 5]; // 테스트 데이터로 생성한 사용자 ID 8 | 9 | export default function () { 10 | const userId = TEST_USER_IDS[Math.floor(Math.random() * TEST_USER_IDS.length)]; 11 | 12 | sleep(1); 13 | 14 | const getBalanceRes = http.get(`${BASE_URL}/api/v1/balance/users/${userId}`); 15 | 16 | check(getBalanceRes, { 17 | 'getBalance status is 200': (r) => r.status === 200, 18 | }); 19 | 20 | sleep(1); 21 | } -------------------------------------------------------------------------------- /docker/local/docker-compose-local.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | image: concert:latest 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/concert 10 | - SPRING_DATASOURCE_USERNAME=root 11 | - SPRING_DATASOURCE_PASSWORD=1234 12 | restart: on-failure 13 | depends_on: 14 | - db 15 | 16 | db: 17 | image: mysql:8.0 18 | ports: 19 | - "3307:3306" 20 | environment: 21 | - MYSQL_DATABASE=concert 22 | - MYSQL_ROOT_PASSWORD=1234 23 | volumes: 24 | - mysql-data:/var/lib/mysql 25 | 26 | volumes: 27 | mysql-data: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | ### Kotlin ### 40 | .kotlin 41 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/response/BalanceResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.response 2 | 3 | import com.hhplus.concert.business.application.dto.BalanceServiceDto 4 | 5 | class BalanceResponse { 6 | data class Detail( 7 | val userId: Long, 8 | val currentAmount: Long, 9 | ) { 10 | companion object { 11 | fun from(detailDto: BalanceServiceDto.Detail): Detail = 12 | Detail( 13 | userId = detailDto.userId, 14 | currentAmount = detailDto.currentAmount, 15 | ) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /k6/concert/concert-schedule.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { options, BASE_URL } from '../common/test-options.js'; 4 | 5 | export { options }; 6 | 7 | export default function () { 8 | const concertId = Math.floor(Math.random() * 5) + 1; 9 | 10 | const schedulesResponse = http.get(`${BASE_URL}/api/v1/concerts/${concertId}/schedules`, { 11 | headers: { 'QUEUE-TOKEN': `token` }, 12 | }); 13 | 14 | check(schedulesResponse, { 15 | 'schedules status was 200': (r) => r.status === 200, 16 | // 'response has events': (r) => JSON.parse(r.body).events.length > 0, 17 | }); 18 | 19 | sleep(1); 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/UserManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager 2 | 3 | import com.hhplus.concert.business.domain.entity.User 4 | import com.hhplus.concert.business.domain.repository.UserRepository 5 | import com.hhplus.concert.common.error.code.ErrorCode 6 | import com.hhplus.concert.common.error.exception.BusinessException 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class UserManager( 11 | private val userRepository: UserRepository, 12 | ) { 13 | fun findById(userId: Long): User = userRepository.findById(userId) ?: throw BusinessException.NotFound(ErrorCode.User.NOT_FOUND) 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | active: local 4 | 5 | datasource: 6 | url: jdbc:h2:mem:test;NON_KEYWORDS=USER 7 | driverClassName: org.h2.Driver 8 | username: sa 9 | password: 10 | 11 | jpa: 12 | properties: 13 | hibernate: 14 | dialect: org.hibernate.dialect.H2Dialect 15 | hbm2ddl: 16 | auto: create-drop 17 | show_sql: true 18 | kafka: 19 | bootstrap-servers: localhost:29092 20 | 21 | jwt: 22 | secret: concert_jwt_secretconcert_jwt_secretconcert_jwt_secretconcert_jwt_secretconcert_jwt_secret 23 | expiration-days: 1 24 | 25 | slack: 26 | checkin: 27 | webhook: 28 | base_url: "https://hooks.slack.com/services/" -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/concert/ConcertCacheManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.concert 2 | 3 | import com.hhplus.concert.common.config.CacheConfig 4 | import org.springframework.cache.annotation.CacheEvict 5 | import org.springframework.stereotype.Component 6 | 7 | @Component 8 | class ConcertCacheManager { 9 | @CacheEvict( 10 | cacheNames = [CacheConfig.ONE_MIN_CACHE], 11 | key = "'available-concert'", 12 | ) 13 | fun evictConcertCache() {} 14 | 15 | @CacheEvict( 16 | cacheNames = [CacheConfig.FIVE_MIN_CACHE], 17 | key = "'concert-' + #concertId", 18 | ) 19 | fun evictConcertScheduleCache(concertId: Long) {} 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/BalanceRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.Balance 4 | import com.hhplus.concert.business.domain.repository.BalanceRepository 5 | import com.hhplus.concert.infrastructure.jpa.BalanceJpaRepository 6 | import org.springframework.stereotype.Repository 7 | 8 | @Repository 9 | class BalanceRepositoryImpl( 10 | private val balanceJpaRepository: BalanceJpaRepository, 11 | ) : BalanceRepository { 12 | override fun findByUserId(userId: Long): Balance? = balanceJpaRepository.findByUserId(userId) 13 | 14 | override fun save(balance: Balance): Balance = balanceJpaRepository.save(balance) 15 | } 16 | -------------------------------------------------------------------------------- /k6/concert/concert-seat.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { options, BASE_URL } from '../common/test-options.js'; 4 | 5 | export { options }; 6 | 7 | export default function () { 8 | const concertId = Math.floor(Math.random() * 5) + 1; 9 | const scheduleId = Math.floor(Math.random() * 28) + 1; 10 | 11 | const seatsResponse = http.get(`${BASE_URL}/api/v1/concerts/${concertId}/schedules/${scheduleId}/seats`, { 12 | headers: { 'QUEUE-TOKEN': `token` }, 13 | }); 14 | 15 | check(seatsResponse, { 16 | 'seats status was 200': (r) => r.status === 200, 17 | // 'response has seats': (r) => JSON.parse(r.body).seats.length > 0, 18 | }); 19 | 20 | sleep(1); 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/SeatRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.Seat 4 | import com.hhplus.concert.common.type.SeatStatus 5 | 6 | interface SeatRepository { 7 | fun findAllByScheduleId(scheduleId: Long): List 8 | 9 | fun findAllById(seatIds: List): List 10 | 11 | fun findAllByIdAndStatusWithPessimisticLock( 12 | seatIds: List, 13 | seatStatus: SeatStatus, 14 | ): List 15 | 16 | fun updateAllStatus( 17 | seatIds: List, 18 | status: SeatStatus, 19 | ) 20 | 21 | fun save(seat: Seat): Seat 22 | 23 | fun findById(seatId: Long): Seat? 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/scheduler/ReservationScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.scheduler 2 | 3 | import com.hhplus.concert.business.application.service.ReservationService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class ReservationScheduler( 10 | private val reservationService: ReservationService, 11 | ) { 12 | private val logger = LoggerFactory.getLogger(ReservationScheduler::class.java) 13 | 14 | @Scheduled(fixedRate = 60000) 15 | fun cancelExpiredReservations() { 16 | logger.info("Cancel Expired Reservation Scheduler Executed") 17 | reservationService.cancelUnpaidReservationsAndReleaseSeats() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/balance/BalanceLockManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.balance 2 | 3 | import com.hhplus.concert.business.domain.entity.Balance 4 | import com.hhplus.concert.common.annotation.DistributedSimpleLock 5 | import org.springframework.stereotype.Component 6 | 7 | @Component 8 | class BalanceLockManager( 9 | private val balanceManager: BalanceManager, 10 | ) { 11 | @DistributedSimpleLock( 12 | key = "'user:' + #userId", 13 | waitTime = 5, 14 | leaseTime = 10, 15 | ) 16 | fun rechargeWithLock( 17 | userId: Long, 18 | amount: Long, 19 | ): Balance = 20 | balanceManager.updateAmount( 21 | userId = userId, 22 | amount = amount, 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/response/PaymentResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.response 2 | 3 | import com.hhplus.concert.business.application.dto.PaymentServiceDto 4 | import com.hhplus.concert.common.type.PaymentStatus 5 | 6 | class PaymentResponse { 7 | data class Result( 8 | val paymentId: Long, 9 | val amount: Int, 10 | val paymentStatus: PaymentStatus, 11 | ) { 12 | companion object { 13 | fun from(resultDto: PaymentServiceDto.Result): Result = 14 | Result( 15 | paymentId = resultDto.paymentId, 16 | amount = resultDto.amount, 17 | paymentStatus = resultDto.paymentStatus, 18 | ) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/publisher/PaymentEventPublisherImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.publisher 2 | 3 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEvent 4 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEventPublisher 5 | import org.springframework.beans.factory.annotation.Qualifier 6 | import org.springframework.context.ApplicationEventPublisher 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | @Qualifier("application") 11 | class PaymentEventPublisherImpl( 12 | private val applicationEventPublisher: ApplicationEventPublisher, 13 | ) : PaymentEventPublisher { 14 | override fun publishPaymentEvent(event: PaymentEvent) { 15 | applicationEventPublisher.publishEvent(event) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/UserRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.User 4 | import com.hhplus.concert.business.domain.repository.UserRepository 5 | import com.hhplus.concert.infrastructure.jpa.UserJpaRepository 6 | import org.springframework.stereotype.Repository 7 | import kotlin.jvm.optionals.getOrNull 8 | 9 | @Repository 10 | class UserRepositoryImpl( 11 | private val userJpaRepository: UserJpaRepository, 12 | ) : UserRepository { 13 | override fun save(user: User): User = userJpaRepository.save(user) 14 | 15 | override fun findById(userId: Long): User? = userJpaRepository.findById(userId).getOrNull() 16 | 17 | override fun deleteAll() { 18 | userJpaRepository.deleteAll() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/dto/ReservationServiceDto.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.dto 2 | 3 | import com.hhplus.concert.common.type.ReservationStatus 4 | import java.time.LocalDateTime 5 | 6 | class ReservationServiceDto { 7 | data class Request( 8 | val userId: Long, 9 | val concertId: Long, 10 | val scheduleId: Long, 11 | val seatIds: List, 12 | ) 13 | 14 | data class Result( 15 | val reservationId: Long, 16 | val concertId: Long, 17 | val concertName: String, 18 | val concertAt: LocalDateTime, 19 | val seat: Seat, 20 | val reservationStatus: ReservationStatus, 21 | ) 22 | 23 | data class Seat( 24 | val seatNumber: Int, 25 | val price: Int, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /k6/balance/balance-recharge.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { options, BASE_URL } from '../common/test-options.js'; 4 | 5 | export { options }; 6 | 7 | const TEST_USER_IDS = [1, 2, 3, 4, 5]; // 테스트 데이터로 생성한 사용자 ID 8 | 9 | export default function () { 10 | const userId = TEST_USER_IDS[Math.floor(Math.random() * TEST_USER_IDS.length)]; 11 | 12 | const rechargePayload = JSON.stringify({ 13 | amount: Math.floor(Math.random() * 10000) + 1000, 14 | }); 15 | 16 | const rechargeRes = http.post(`${BASE_URL}/api/v1/balance/users/${userId}/recharge`, rechargePayload, { 17 | headers: { 'Content-Type': 'application/json' }, 18 | }); 19 | 20 | check(rechargeRes, { 21 | 'recharge status is 200': (r) => r.status === 200, 22 | }); 23 | 24 | sleep(1); 25 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/config/RedisConfig.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.config 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.data.redis.connection.RedisConnectionFactory 6 | import org.springframework.data.redis.core.RedisTemplate 7 | import org.springframework.data.redis.serializer.StringRedisSerializer 8 | 9 | @Configuration 10 | class RedisConfig { 11 | @Bean 12 | fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate = 13 | RedisTemplate().apply { 14 | setConnectionFactory(connectionFactory) 15 | keySerializer = StringRedisSerializer() 16 | valueSerializer = StringRedisSerializer() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/request/ReservationRequest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.request 2 | 3 | import com.hhplus.concert.business.application.dto.ReservationServiceDto 4 | 5 | class ReservationRequest { 6 | data class Detail( 7 | val userId: Long, 8 | val concertId: Long, 9 | val scheduleId: Long, 10 | val seatIds: List, 11 | ) { 12 | companion object { 13 | fun toDto(request: Detail): ReservationServiceDto.Request = 14 | ReservationServiceDto.Request( 15 | userId = request.userId, 16 | concertId = request.concertId, 17 | scheduleId = request.scheduleId, 18 | seatIds = request.seatIds, 19 | ) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/PaymentHistoryRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentHistory 4 | import com.hhplus.concert.business.domain.repository.PaymentHistoryRepository 5 | import com.hhplus.concert.infrastructure.jpa.PaymentHistoryJpaRepository 6 | import org.springframework.stereotype.Repository 7 | 8 | @Repository 9 | class PaymentHistoryRepositoryImpl( 10 | private val paymentHistoryJpaRepository: PaymentHistoryJpaRepository, 11 | ) : PaymentHistoryRepository { 12 | override fun save(paymentHistory: PaymentHistory) { 13 | paymentHistoryJpaRepository.save(paymentHistory) 14 | } 15 | 16 | override fun findAllByPaymentId(paymentId: Long): List = paymentHistoryJpaRepository.findAllByPaymentId(paymentId) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/repository/ReservationRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.repository 2 | 3 | import com.hhplus.concert.business.domain.entity.Reservation 4 | import com.hhplus.concert.common.type.ReservationStatus 5 | import java.time.LocalDateTime 6 | 7 | interface ReservationRepository { 8 | fun save(reservation: Reservation): Reservation 9 | 10 | fun findAll(): List 11 | 12 | fun findExpiredReservations( 13 | reservationStatus: ReservationStatus, 14 | expirationTime: LocalDateTime, 15 | ): List 16 | 17 | fun updateAllStatus( 18 | reservationIds: List, 19 | reservationStatus: ReservationStatus, 20 | ) 21 | 22 | fun findAllById(reservationIds: List): List 23 | 24 | fun findById(reservationId: Long): Reservation? 25 | } 26 | -------------------------------------------------------------------------------- /k6/queue/queue.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { SharedArray } from 'k6/data'; 4 | import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; 5 | import { options, BASE_URL } from '../common/test-options.js'; 6 | 7 | const users = new SharedArray('users', function () { 8 | return Array.from({ length: 1000 }, (_, i) => i + 1); // Assuming 1000 users 9 | }); 10 | 11 | export default function () { 12 | const userId = randomItem(users); 13 | 14 | const response = http.post(`${BASE_URL}/api/v1/queue/users/${userId}`); 15 | 16 | check(response, { 17 | 'status is 200': (r) => r.status === 200, 18 | 'response has token': (r) => { 19 | const body = JSON.parse(r.body); 20 | }, 21 | }); 22 | 23 | console.log(`User ${userId} received token: ${JSON.parse(response.body).token}`); 24 | 25 | sleep(1); 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/PaymentRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.Payment 4 | import com.hhplus.concert.business.domain.repository.PaymentRepository 5 | import com.hhplus.concert.infrastructure.jpa.PaymentJpaRepository 6 | import org.springframework.stereotype.Repository 7 | import kotlin.jvm.optionals.getOrNull 8 | 9 | @Repository 10 | class PaymentRepositoryImpl( 11 | private val paymentJpaRepository: PaymentJpaRepository, 12 | ) : PaymentRepository { 13 | override fun save(payment: Payment): Payment = paymentJpaRepository.save(payment) 14 | 15 | override fun findByReservationId(reservationId: Long): Payment? = paymentJpaRepository.findByReservationId(reservationId) 16 | 17 | override fun findById(paymentId: Long): Payment? = paymentJpaRepository.findById(paymentId).getOrNull() 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/dto/ConcertServiceDto.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.dto 2 | 3 | import com.hhplus.concert.common.type.SeatStatus 4 | import java.time.LocalDateTime 5 | 6 | class ConcertServiceDto { 7 | data class Concert( 8 | val concertId: Long, 9 | val title: String, 10 | val description: String, 11 | ) 12 | 13 | data class Schedule( 14 | val concertId: Long, 15 | val events: List, 16 | ) 17 | 18 | data class Event( 19 | val scheduleId: Long, 20 | val concertAt: LocalDateTime, 21 | val reservationAt: LocalDateTime, 22 | ) 23 | 24 | data class AvailableSeat( 25 | val concertId: Long, 26 | val seats: List, 27 | ) 28 | 29 | data class Seat( 30 | val seatId: Long, 31 | val seatNumber: Int, 32 | val seatStatus: SeatStatus, 33 | val seatPrice: Int, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/EventOutBoxJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | import org.springframework.data.jpa.repository.Modifying 6 | import org.springframework.data.jpa.repository.Query 7 | import java.time.LocalDateTime 8 | 9 | interface EventOutBoxJpaRepository : JpaRepository { 10 | fun findByPaymentId(paymentId: Long): PaymentEventOutBox? 11 | 12 | @Query("select peo from PaymentEventOutBox peo where peo.eventStatus = 'INIT' and peo.publishedAt < :dateTime") 13 | fun findAllFailedEvent(dateTime: LocalDateTime): List 14 | 15 | @Modifying 16 | @Query("delete from PaymentEventOutBox peo where peo.eventStatus = 'PUBLISHED' and peo.publishedAt < :dateTime") 17 | fun deleteAllPublishedEvent(dateTime: LocalDateTime) 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/ConcertRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.Concert 4 | import com.hhplus.concert.business.domain.repository.ConcertRepository 5 | import com.hhplus.concert.infrastructure.jpa.ConcertJpaRepository 6 | import org.springframework.stereotype.Repository 7 | import kotlin.jvm.optionals.getOrNull 8 | 9 | @Repository 10 | class ConcertRepositoryImpl( 11 | private val concertJpaRepository: ConcertJpaRepository, 12 | ) : ConcertRepository { 13 | override fun findById(concertId: Long): Concert? = concertJpaRepository.findById(concertId).getOrNull() 14 | 15 | override fun findAll(): List = concertJpaRepository.findAll() 16 | 17 | override fun save(concert: Concert): Concert = concertJpaRepository.save(concert) 18 | 19 | override fun saveAll(concerts: List) { 20 | concertJpaRepository.saveAll(concerts) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | active: test 4 | 5 | datasource: 6 | driver-class-name: com.mysql.cj.jdbc.Driver 7 | url: jdbc:mysql://localhost:3306/concert?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&characterEncoding=UTF-8 8 | username: root 9 | password: 1234 10 | 11 | jpa: 12 | hibernate: 13 | ddl-auto: update 14 | database-platform: org.hibernate.dialect.MySQL8Dialect 15 | open-in-view: false 16 | show-sql: true 17 | properties: 18 | hibernate: 19 | format_sql: true 20 | kafka: 21 | bootstrap-servers: localhost:29092 22 | 23 | jwt: 24 | secret: concert_jwt_secretconcert_jwt_secretconcert_jwt_secretconcert_jwt_secretconcert_jwt_secret 25 | expiration-days: 1 26 | 27 | queue: 28 | allowed-max-size: 100 29 | 30 | springdoc: 31 | swagger-ui: 32 | path: /api-docs/ 33 | 34 | slack: 35 | checkin: 36 | webhook: 37 | base_url: "https://hooks.slack.com/services/" -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/PaymentHistory.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import jakarta.persistence.ConstraintMode 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.ForeignKey 6 | import jakarta.persistence.GeneratedValue 7 | import jakarta.persistence.GenerationType 8 | import jakarta.persistence.Id 9 | import jakarta.persistence.JoinColumn 10 | import jakarta.persistence.ManyToOne 11 | 12 | @Entity 13 | class PaymentHistory( 14 | user: User, 15 | payment: Payment, 16 | ) { 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.IDENTITY) 19 | val id: Long = 0 20 | 21 | @ManyToOne 22 | @JoinColumn(name = "user_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 23 | var user: User = user 24 | protected set 25 | 26 | @ManyToOne 27 | @JoinColumn(name = "payment_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 28 | var payment: Payment = payment 29 | protected set 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/aop/RedisSimpleLock.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.aop 2 | 3 | import org.springframework.data.redis.core.RedisTemplate 4 | import org.springframework.stereotype.Component 5 | import java.util.concurrent.TimeUnit 6 | 7 | @Component 8 | class RedisSimpleLock( 9 | private val redisTemplate: RedisTemplate, 10 | ) { 11 | fun tryLock( 12 | key: String, 13 | value: String, 14 | leaseTime: Long, 15 | timeUnit: TimeUnit, 16 | ): Boolean = 17 | redisTemplate 18 | .opsForValue() 19 | .setIfAbsent(key, value, leaseTime, timeUnit) ?: false 20 | 21 | fun releaseLock( 22 | key: String, 23 | value: String, 24 | ): Boolean { 25 | val ops = redisTemplate.opsForValue() 26 | val lockValue = ops.get(key) 27 | 28 | if (lockValue == value) { 29 | redisTemplate.delete(key) 30 | return true 31 | } 32 | return false 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/reservation/ReservationLockManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.reservation 2 | 3 | import com.hhplus.concert.business.application.dto.ReservationServiceDto 4 | import com.hhplus.concert.business.domain.entity.Reservation 5 | import com.hhplus.concert.common.annotation.DistributedSimpleLock 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class ReservationLockManager( 10 | private val reservationManager: ReservationManager, 11 | ) { 12 | @DistributedSimpleLock( 13 | key = 14 | "'user:' + #reservationRequest.userId + " + 15 | "'concert:' + #reservationRequest.concertId + " + 16 | "':schedule:' + #reservationRequest.scheduleId", 17 | waitTime = 5, 18 | leaseTime = 10, 19 | ) 20 | fun createReservations(reservationRequest: ReservationServiceDto.Request): List = 21 | reservationManager.createReservations(reservationRequest) 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/ConcertScheduleRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.ConcertSchedule 4 | import com.hhplus.concert.business.domain.repository.ConcertScheduleRepository 5 | import com.hhplus.concert.infrastructure.jpa.ConcertScheduleJpaRepository 6 | import org.springframework.stereotype.Repository 7 | import kotlin.jvm.optionals.getOrNull 8 | 9 | @Repository 10 | class ConcertScheduleRepositoryImpl( 11 | private val concertScheduleJpaRepository: ConcertScheduleJpaRepository, 12 | ) : ConcertScheduleRepository { 13 | override fun findAllByConcertId(concertId: Long): List = concertScheduleJpaRepository.findAllByConcertId(concertId) 14 | 15 | override fun findById(scheduleId: Long): ConcertSchedule? = concertScheduleJpaRepository.findById(scheduleId).getOrNull() 16 | 17 | override fun save(concertSchedule: ConcertSchedule): ConcertSchedule = concertScheduleJpaRepository.save(concertSchedule) 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/config/WebConfig.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.config 2 | 3 | import com.hhplus.concert.common.resolver.ValidatedTokenResolver 4 | import com.hhplus.concert.interfaces.presentation.interceptor.TokenInterceptor 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.web.method.support.HandlerMethodArgumentResolver 7 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry 8 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 9 | 10 | @Configuration 11 | class WebConfig( 12 | private val tokenInterceptor: TokenInterceptor, 13 | private val validatedTokenResolver: ValidatedTokenResolver, 14 | ) : WebMvcConfigurer { 15 | override fun addInterceptors(registry: InterceptorRegistry) { 16 | registry.addInterceptor(tokenInterceptor) 17 | } 18 | 19 | override fun addArgumentResolvers(resolvers: MutableList) { 20 | resolvers.add(validatedTokenResolver) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/config/SwaggerConfig.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.config 2 | 3 | import io.swagger.v3.oas.models.Components 4 | import io.swagger.v3.oas.models.OpenAPI 5 | import io.swagger.v3.oas.models.info.Info 6 | import io.swagger.v3.oas.models.servers.Server 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | 10 | @Configuration 11 | class SwaggerConfig { 12 | @Bean 13 | fun openAPI(): OpenAPI { 14 | val info = 15 | Info() 16 | .title("콘서트 예약 시스템 API") 17 | .description("콘서트 예약 시스템의 백엔드 API Swagger Documentation") 18 | .version("1.0.0") 19 | 20 | val servers = 21 | listOf( 22 | Server().description("local").url("http://localhost:8080"), 23 | ) 24 | return OpenAPI() 25 | .components(Components()) 26 | .servers(servers) 27 | .tags(ArrayList()) 28 | .info(info) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /k6/common/test-options.js: -------------------------------------------------------------------------------- 1 | // common-options.js 2 | export const options = { 3 | scenarios: { 4 | load_test: { 5 | executor: 'ramping-vus', 6 | startVUs: 0, 7 | stages: [ 8 | { duration: '10s', target: 1000 }, 9 | { duration: '30s', target: 1000 }, 10 | { duration: '10s', target: 3000 }, 11 | { duration: '30s', target: 3000 }, 12 | { duration: '10s', target: 0 }, 13 | ], 14 | }, 15 | peak_test: { 16 | executor: 'ramping-arrival-rate', 17 | startRate: 10, 18 | timeUnit: '1s', 19 | preAllocatedVUs: 200, 20 | maxVUs: 500, 21 | stages: [ 22 | { duration: '10s', target: 1000 }, 23 | { duration: '20s', target: 5000 }, 24 | { duration: '30s', target: 1000 }, 25 | { duration: '10s', target: 5000 }, 26 | ], 27 | }, 28 | }, 29 | }; 30 | 31 | export const BASE_URL = 'http://localhost:8080'; -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/response/QueueTokenResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.response 2 | 3 | import com.hhplus.concert.business.application.dto.QueueServiceDto 4 | import com.hhplus.concert.common.type.QueueStatus 5 | 6 | class QueueTokenResponse { 7 | data class Token( 8 | val token: String, 9 | ) { 10 | companion object { 11 | fun from(issuedTokenDto: QueueServiceDto.IssuedToken): Token = 12 | Token( 13 | token = issuedTokenDto.token, 14 | ) 15 | } 16 | } 17 | 18 | data class Queue( 19 | val status: QueueStatus, 20 | val remainingWaitListCount: Long, 21 | val estimatedWaitTime: Long, 22 | ) { 23 | companion object { 24 | fun from(queueDto: QueueServiceDto.Queue): Queue = 25 | Queue( 26 | status = queueDto.status, 27 | remainingWaitListCount = queueDto.remainingWaitListCount, 28 | estimatedWaitTime = queueDto.estimatedWaitTime, 29 | ) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/scheduler/QueueScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.scheduler 2 | 3 | import com.hhplus.concert.business.application.service.QueueService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class QueueScheduler( 10 | private val queueService: QueueService, 11 | ) { 12 | private val logger = LoggerFactory.getLogger(QueueScheduler::class.java) 13 | 14 | @Scheduled(fixedRate = 60000) 15 | fun updateToProcessingTokens() { 16 | logger.info("Maintain Processing Scheduler Executed") 17 | queueService.updateToProcessingTokens() 18 | } 19 | 20 | @Scheduled(fixedRate = 60000) 21 | fun cancelExpiredWaitingQueue() { 22 | logger.info("Cancel Expired Waiting Queue Executed") 23 | queueService.cancelExpiredWaitingQueue() 24 | } 25 | 26 | @Scheduled(fixedRate = 60000) 27 | fun removeExpiredProcessingQueue() { 28 | logger.info("Remove Expired Processing Queue Executed") 29 | queueService.removeExpiredProcessingQueue() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up JDK 21 22 | uses: actions/setup-java@v4 23 | with: 24 | java-version: '21' 25 | distribution: 'temurin' 26 | 27 | - name: Build with Gradle 28 | run: ./gradlew clean build -x test 29 | 30 | - name: Login to GitHub Container Registry 31 | uses: docker/login-action@v3 32 | with: 33 | registry: ${{ env.REGISTRY }} 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.AUTH_TOKEN }} 36 | 37 | - name: Build and push Docker image 38 | uses: docker/build-push-action@v6 39 | with: 40 | context: . 41 | file: ./docker/production/prod.Dockerfile 42 | push: true 43 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} 44 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/Concert.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import com.hhplus.concert.common.type.ConcertStatus 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.EnumType 7 | import jakarta.persistence.Enumerated 8 | import jakarta.persistence.GeneratedValue 9 | import jakarta.persistence.GenerationType 10 | import jakarta.persistence.Id 11 | 12 | @Entity 13 | class Concert( 14 | title: String, 15 | description: String, 16 | concertStatus: ConcertStatus, 17 | ) { 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.IDENTITY) 20 | val id: Long = 0 21 | 22 | @Column(name = "title", nullable = false) 23 | var title: String = title 24 | protected set 25 | 26 | @Column(name = "description") 27 | var description: String = description 28 | protected set 29 | 30 | @Column(name = "status") 31 | @Enumerated(EnumType.STRING) 32 | var concertStatus: ConcertStatus = concertStatus 33 | protected set 34 | 35 | fun updateStatus(status: ConcertStatus) { 36 | this.concertStatus = status 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/PaymentEventOutBoxRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 4 | import com.hhplus.concert.business.domain.repository.PaymentEventOutBoxRepository 5 | import com.hhplus.concert.infrastructure.jpa.EventOutBoxJpaRepository 6 | import org.springframework.stereotype.Repository 7 | import java.time.LocalDateTime 8 | 9 | @Repository 10 | class PaymentEventOutBoxRepositoryImpl( 11 | private val eventOutBoxJpaRepository: EventOutBoxJpaRepository, 12 | ) : PaymentEventOutBoxRepository { 13 | override fun save(paymentEventOutBox: PaymentEventOutBox): PaymentEventOutBox = eventOutBoxJpaRepository.save(paymentEventOutBox) 14 | 15 | override fun findByPaymentId(paymentId: Long): PaymentEventOutBox? = eventOutBoxJpaRepository.findByPaymentId(paymentId) 16 | 17 | override fun findAllFailedEvent(dateTime: LocalDateTime): List = 18 | eventOutBoxJpaRepository.findAllFailedEvent(dateTime) 19 | 20 | override fun deleteAllPublishedEvent(dateTime: LocalDateTime) { 21 | eventOutBoxJpaRepository.deleteAllPublishedEvent(dateTime) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/consumer/PaymentEventKafkaConsumer.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.consumer 2 | 3 | import com.hhplus.concert.business.application.service.PaymentService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.kafka.annotation.KafkaListener 6 | import org.springframework.scheduling.annotation.Async 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class PaymentEventKafkaConsumer( 11 | private val paymentService: PaymentService, 12 | ) { 13 | var receivedMessage: String? = null 14 | 15 | /** 16 | * Kafka 가 이벤트를 잘 Consume 하는 지 테스트 하기 위한 메서드 17 | */ 18 | @KafkaListener(topics = ["test_topic"], groupId = "test-group") 19 | fun consume(message: String) { 20 | println("Received message: $message") 21 | receivedMessage = message 22 | } 23 | 24 | @Async 25 | @KafkaListener(topics = ["payment-event"], groupId = "payment-group") 26 | fun handleSendMessageKafkaEvent(paymentId: String) { 27 | logger.info("KafkaEvent received") 28 | paymentService.sendPaymentEventMessage(paymentId.toLong()) 29 | } 30 | 31 | private val logger = LoggerFactory.getLogger(this::class.java) 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/resolver/ValidatedTokenResolver.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.resolver 2 | 3 | import com.hhplus.concert.common.annotation.ValidatedToken 4 | import com.hhplus.concert.common.constants.TokenConstants.VALIDATED_TOKEN 5 | import org.springframework.core.MethodParameter 6 | import org.springframework.stereotype.Component 7 | import org.springframework.web.bind.support.WebDataBinderFactory 8 | import org.springframework.web.context.request.NativeWebRequest 9 | import org.springframework.web.context.request.RequestAttributes 10 | import org.springframework.web.method.support.HandlerMethodArgumentResolver 11 | import org.springframework.web.method.support.ModelAndViewContainer 12 | 13 | @Component 14 | class ValidatedTokenResolver : HandlerMethodArgumentResolver { 15 | override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.hasParameterAnnotation(ValidatedToken::class.java) 16 | 17 | override fun resolveArgument( 18 | parameter: MethodParameter, 19 | mavContainer: ModelAndViewContainer?, 20 | webRequest: NativeWebRequest, 21 | binderFactory: WebDataBinderFactory?, 22 | ): Any? = webRequest.getAttribute(VALIDATED_TOKEN, RequestAttributes.SCOPE_REQUEST) 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/SeatJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.Seat 4 | import com.hhplus.concert.common.type.SeatStatus 5 | import jakarta.persistence.LockModeType 6 | import org.springframework.data.jpa.repository.JpaRepository 7 | import org.springframework.data.jpa.repository.Lock 8 | import org.springframework.data.jpa.repository.Modifying 9 | import org.springframework.data.jpa.repository.Query 10 | 11 | interface SeatJpaRepository : JpaRepository { 12 | @Query("select seat from Seat seat where seat.concertSchedule.id = :scheduleId") 13 | fun findAllByScheduleId(scheduleId: Long): List 14 | 15 | @Lock(LockModeType.PESSIMISTIC_READ) 16 | @Query("SELECT s FROM Seat s WHERE s.id IN :seatIds and s.seatStatus = :seatStatus") 17 | fun findAllByIdAndStatusWithPessimisticLock( 18 | seatIds: List, 19 | seatStatus: SeatStatus, 20 | ): List 21 | 22 | @Modifying(clearAutomatically = true) 23 | @Query("update Seat seat set seat.seatStatus = :status where seat.id in :seatIds") 24 | fun updateAllStatus( 25 | seatIds: List, 26 | status: SeatStatus, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/ConcertSchedule.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.ConstraintMode 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.ForeignKey 7 | import jakarta.persistence.GeneratedValue 8 | import jakarta.persistence.GenerationType 9 | import jakarta.persistence.Id 10 | import jakarta.persistence.JoinColumn 11 | import jakarta.persistence.ManyToOne 12 | import java.time.LocalDateTime 13 | 14 | @Entity 15 | class ConcertSchedule( 16 | concert: Concert, 17 | concertAt: LocalDateTime, 18 | reservationAvailableAt: LocalDateTime, 19 | ) { 20 | @Id 21 | @GeneratedValue(strategy = GenerationType.IDENTITY) 22 | val id: Long = 0 23 | 24 | @ManyToOne 25 | @JoinColumn(name = "concert_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 26 | var concert: Concert = concert 27 | protected set 28 | 29 | @Column(name = "concert_at", nullable = false) 30 | var concertAt: LocalDateTime = concertAt 31 | protected set 32 | 33 | @Column(name = "reservation_available_at", nullable = false) 34 | var reservationAvailableAt: LocalDateTime = reservationAvailableAt 35 | protected set 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 콘서트 예약 서비스 2 | 3 | ## Docs 4 | ### [1. 마일스톤](https://github.com/mingj7235/concert/blob/main/docs/01_Milestone.md) 5 | ### [2. 이벤트 시퀀스 다이어그램](https://github.com/mingj7235/concert/blob/main/docs/02_EventSequence.md) 6 | ### [3. ERD](https://github.com/mingj7235/concert/blob/main/docs/03_ERD.md) 7 | ### [4. API 명세 문서](https://github.com/mingj7235/concert/blob/main/docs/04_API_document.md) 8 | ### [5. Swagger](https://github.com/mingj7235/concert/blob/main/docs/05_Swagger.md) 9 | ### [6. 동시성 이슈제어 분석 보고서](https://github.com/mingj7235/concert/blob/main/docs/06_ConcurrencyReport.md) 10 | ### [7. Cache 도입과 대기열 Redis 이관 보고서 ](https://github.com/mingj7235/concert/blob/main/docs/07_Cache_%26_Redis_Queue.md) 11 | ### [8. 콘서트 대기열 시스템의 인덱스 적용과 차후 적용할 MSA 관점에서의 설계 ](https://github.com/mingj7235/concert/blob/main/docs/08_Index_%26_msa_blueprint.md) 12 | ### [9. 외부 연동 API를 위한 Kafka와 Transactional Outbox Pattern 구현 보고서 ](https://github.com/mingj7235/concert/blob/main/docs/09_kafka_transactional_outbox_pattern.md) 13 | ### [10. API 부하 테스트 분석과 가상 장애 대응 방안에 관한 보고서](https://github.com/mingj7235/concert/blob/main/docs/10_Incident_Response.md) 14 | 15 | ## Mock API 16 | ### [1. Mock API Controller](https://github.com/mingj7235/concert/blob/main/src/main/kotlin/com/hhplus/concert/presentation/ConcertReservationMockController.kt) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/Balance.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.ConstraintMode 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.ForeignKey 7 | import jakarta.persistence.GeneratedValue 8 | import jakarta.persistence.GenerationType 9 | import jakarta.persistence.Id 10 | import jakarta.persistence.JoinColumn 11 | import jakarta.persistence.OneToOne 12 | import java.time.LocalDateTime 13 | 14 | @Entity 15 | class Balance( 16 | user: User, 17 | amount: Long, 18 | lastUpdatedAt: LocalDateTime, 19 | ) { 20 | @Id 21 | @GeneratedValue(strategy = GenerationType.IDENTITY) 22 | val id: Long = 0 23 | 24 | @OneToOne 25 | @JoinColumn(name = "user_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 26 | var user: User = user 27 | protected set 28 | 29 | @Column(name = "amount", nullable = false) 30 | var amount: Long = amount 31 | protected set 32 | 33 | @Column(name = "last_update_at", nullable = false) 34 | var lastUpdatedAt: LocalDateTime = lastUpdatedAt 35 | protected set 36 | 37 | fun updateAmount(amount: Long) { 38 | this.amount += amount 39 | this.lastUpdatedAt = LocalDateTime.now() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/scheduler/PaymentEventScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.scheduler 2 | 3 | import com.hhplus.concert.business.application.service.PaymentEventOutBoxService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class PaymentEventScheduler( 10 | private val paymentEventOutBoxService: PaymentEventOutBoxService, 11 | ) { 12 | private val logger = LoggerFactory.getLogger(this::class.java) 13 | 14 | /** 15 | * 발행이 실패 이벤트를 다시 재시도한다. 16 | * 조건 > 17 | * - Init 상태인 이벤트 18 | * - publishedAt 이 현재 시간 기준으로 5분 이상 넘어간 이벤트 19 | */ 20 | @Scheduled(fixedRate = 60000) 21 | fun retryFailedPaymentEvent() { 22 | logger.info("Retry Failed Payment Event Scheduler Executed") 23 | paymentEventOutBoxService.retryFailedPaymentEvent() 24 | } 25 | 26 | /** 27 | * 발행이 완료된 이벤트를 삭제한다. 28 | * 조건 > 29 | * - PUBLISHED 상태인 이벤트 30 | * - publishedAt 이 현재 시간 기준으로 7일 이상 넘어간 이벤트 31 | */ 32 | @Scheduled(fixedRate = 60000) 33 | fun deletePublishedPaymentEvent() { 34 | logger.info("Delete Publish Payment Event Scheduler Executed") 35 | paymentEventOutBoxService.deletePublishedPaymentEvent() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/PaymentEventOutBox.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import com.hhplus.concert.common.type.EventStatus 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.EnumType 7 | import jakarta.persistence.Enumerated 8 | import jakarta.persistence.GeneratedValue 9 | import jakarta.persistence.GenerationType 10 | import jakarta.persistence.Id 11 | import java.time.LocalDateTime 12 | 13 | /** 14 | * EventOutBox Entity 15 | */ 16 | @Entity 17 | class PaymentEventOutBox( 18 | paymentId: Long, 19 | eventStatus: EventStatus, 20 | publishedAt: LocalDateTime = LocalDateTime.now(), 21 | ) { 22 | @Id 23 | @GeneratedValue(strategy = GenerationType.IDENTITY) 24 | val id: Long = 0 25 | 26 | @Column(name = "payment_id", nullable = false) 27 | var paymentId: Long = paymentId 28 | protected set 29 | 30 | @Column(name = "event_status", nullable = false) 31 | @Enumerated(EnumType.STRING) 32 | var eventStatus: EventStatus = eventStatus 33 | protected set 34 | 35 | @Column(name = "published_at", nullable = false) 36 | var publishedAt: LocalDateTime = publishedAt 37 | protected set 38 | 39 | fun updateEventStatus(eventStatus: EventStatus) { 40 | this.eventStatus = eventStatus 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/jpa/ReservationJpaRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.jpa 2 | 3 | import com.hhplus.concert.business.domain.entity.Reservation 4 | import com.hhplus.concert.common.type.ReservationStatus 5 | import org.springframework.data.jpa.repository.JpaRepository 6 | import org.springframework.data.jpa.repository.Modifying 7 | import org.springframework.data.jpa.repository.Query 8 | import java.time.LocalDateTime 9 | 10 | interface ReservationJpaRepository : JpaRepository { 11 | @Query("select r from Reservation r where r.reservationStatus = :reservationStatus and r.createdAt < :expirationTime") 12 | fun findExpiredReservations( 13 | reservationStatus: ReservationStatus, 14 | expirationTime: LocalDateTime, 15 | ): List 16 | 17 | @Modifying(clearAutomatically = true) 18 | @Query("update Reservation r set r.reservationStatus = :reservationStatus where r.id in :reservationIds") 19 | fun updateAllStatus( 20 | reservationIds: List, 21 | reservationStatus: ReservationStatus, 22 | ) 23 | 24 | @Query( 25 | "SELECT r FROM Reservation r " + 26 | "JOIN FETCH r.seat s " + 27 | "JOIN FETCH s.concertSchedule cs " + 28 | "WHERE r.id IN :reservationIds", 29 | ) 30 | fun findAllByIdFetchSeat(reservationIds: List): List 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/config/AsyncConfig.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.config 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.scheduling.annotation.AsyncConfigurer 7 | import org.springframework.scheduling.annotation.EnableAsync 8 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor 9 | import java.util.concurrent.Executor 10 | 11 | @Configuration 12 | @EnableAsync 13 | class AsyncConfig : AsyncConfigurer { 14 | override fun getAsyncExecutor(): Executor? = 15 | ThreadPoolTaskExecutor().apply { 16 | corePoolSize = 5 17 | maxPoolSize = 10 18 | queueCapacity = 25 19 | setThreadNamePrefix(THREAD_NAME_PREFIX) 20 | initialize() 21 | } 22 | 23 | override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler? = 24 | AsyncUncaughtExceptionHandler { throwable, method, params -> 25 | logger.error("Async method '${method.name}' threw an exception", throwable) 26 | logger.error("Method parameters: ${params.joinToString()}") 27 | } 28 | 29 | companion object { 30 | private val logger = LoggerFactory.getLogger(this::class.java) 31 | const val THREAD_NAME_PREFIX = "ConcertAsync" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/SeatRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.Seat 4 | import com.hhplus.concert.business.domain.repository.SeatRepository 5 | import com.hhplus.concert.common.type.SeatStatus 6 | import com.hhplus.concert.infrastructure.jpa.SeatJpaRepository 7 | import org.springframework.stereotype.Repository 8 | import kotlin.jvm.optionals.getOrNull 9 | 10 | @Repository 11 | class SeatRepositoryImpl( 12 | private val seatJpaRepository: SeatJpaRepository, 13 | ) : SeatRepository { 14 | override fun findAllByScheduleId(scheduleId: Long): List = seatJpaRepository.findAllByScheduleId(scheduleId) 15 | 16 | override fun findAllById(seatIds: List): List = seatJpaRepository.findAllById(seatIds) 17 | 18 | override fun findAllByIdAndStatusWithPessimisticLock( 19 | seatIds: List, 20 | seatStatus: SeatStatus, 21 | ): List = seatJpaRepository.findAllByIdAndStatusWithPessimisticLock(seatIds, seatStatus) 22 | 23 | override fun updateAllStatus( 24 | seatIds: List, 25 | status: SeatStatus, 26 | ) { 27 | seatJpaRepository.updateAllStatus(seatIds, status) 28 | } 29 | 30 | override fun save(seat: Seat): Seat = seatJpaRepository.save(seat) 31 | 32 | override fun findById(seatId: Long): Seat? = seatJpaRepository.findById(seatId).getOrNull() 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/controller/ReservationController.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.controller 2 | 3 | import com.hhplus.concert.business.application.service.ReservationService 4 | import com.hhplus.concert.common.annotation.TokenRequired 5 | import com.hhplus.concert.common.annotation.ValidatedToken 6 | import com.hhplus.concert.interfaces.presentation.request.ReservationRequest 7 | import com.hhplus.concert.interfaces.presentation.response.ReservationResponse 8 | import org.springframework.web.bind.annotation.PostMapping 9 | import org.springframework.web.bind.annotation.RequestBody 10 | import org.springframework.web.bind.annotation.RequestMapping 11 | import org.springframework.web.bind.annotation.RestController 12 | 13 | @RestController 14 | @RequestMapping("/api/v1") 15 | class ReservationController( 16 | private val reservationService: ReservationService, 17 | ) { 18 | // 좌석을 예약한다. 19 | @TokenRequired 20 | @PostMapping("/reservations") 21 | fun createReservations( 22 | @ValidatedToken token: String, 23 | @RequestBody reservationRequest: ReservationRequest.Detail, 24 | ): List = 25 | reservationService 26 | .createReservations( 27 | token = token, 28 | reservationRequest = ReservationRequest.Detail.toDto(reservationRequest), 29 | ).map { 30 | ReservationResponse.Result.from(it) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/Seat.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import com.hhplus.concert.common.type.SeatStatus 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.ConstraintMode 6 | import jakarta.persistence.Entity 7 | import jakarta.persistence.EnumType 8 | import jakarta.persistence.Enumerated 9 | import jakarta.persistence.ForeignKey 10 | import jakarta.persistence.GeneratedValue 11 | import jakarta.persistence.GenerationType 12 | import jakarta.persistence.Id 13 | import jakarta.persistence.JoinColumn 14 | import jakarta.persistence.ManyToOne 15 | 16 | @Entity 17 | class Seat( 18 | concertSchedule: ConcertSchedule, 19 | seatNumber: Int, 20 | seatStatus: SeatStatus, 21 | seatPrice: Int, 22 | ) { 23 | @Id 24 | @GeneratedValue(strategy = GenerationType.IDENTITY) 25 | val id: Long = 0 26 | 27 | @ManyToOne 28 | @JoinColumn(name = "concert_schedule_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 29 | var concertSchedule: ConcertSchedule = concertSchedule 30 | protected set 31 | 32 | @Column(name = "seat_number", nullable = false) 33 | var seatNumber: Int = seatNumber 34 | protected set 35 | 36 | @Column(name = "status", nullable = false) 37 | @Enumerated(EnumType.STRING) 38 | var seatStatus: SeatStatus = seatStatus 39 | protected set 40 | 41 | @Column(name = "seat_price", nullable = false) 42 | var seatPrice: Int = seatPrice 43 | protected set 44 | } 45 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/interfaces/kafka/KafkaConnectionTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.kafka 2 | 3 | import com.hhplus.concert.infrastructure.kafka.PaymentEventKafkaProducer 4 | import com.hhplus.concert.interfaces.consumer.PaymentEventKafkaConsumer 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.awaitility.Awaitility.await 7 | import org.junit.jupiter.api.Test 8 | import org.springframework.beans.factory.annotation.Autowired 9 | import org.springframework.boot.test.context.SpringBootTest 10 | import org.springframework.kafka.test.context.EmbeddedKafka 11 | import org.springframework.test.annotation.DirtiesContext 12 | import java.util.concurrent.TimeUnit 13 | 14 | @SpringBootTest 15 | @EmbeddedKafka(partitions = 1, brokerProperties = ["listeners=PLAINTEXT://localhost:29092"], ports = [29092]) 16 | @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) 17 | class KafkaConnectionTest { 18 | @Autowired 19 | private lateinit var paymentEventKafkaProducer: PaymentEventKafkaProducer 20 | 21 | @Autowired 22 | private lateinit var paymentEventKafkaConsumer: PaymentEventKafkaConsumer 23 | 24 | @Test 25 | fun `카프카 연결 테스트`() { 26 | val topic = "test_topic" 27 | val message = "testMessage" 28 | 29 | paymentEventKafkaProducer.send(topic, message) 30 | 31 | await().atMost(10, TimeUnit.SECONDS).untilAsserted { 32 | assertThat(paymentEventKafkaConsumer.receivedMessage).isEqualTo(message) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/balance/BalanceManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.balance 2 | 3 | import com.hhplus.concert.business.domain.entity.Balance 4 | import com.hhplus.concert.business.domain.repository.BalanceRepository 5 | import com.hhplus.concert.business.domain.repository.UserRepository 6 | import com.hhplus.concert.common.error.code.ErrorCode 7 | import com.hhplus.concert.common.error.exception.BusinessException 8 | import org.springframework.stereotype.Component 9 | import org.springframework.transaction.annotation.Transactional 10 | import java.time.LocalDateTime 11 | 12 | @Component 13 | class BalanceManager( 14 | private val userRepository: UserRepository, 15 | private val balanceRepository: BalanceRepository, 16 | ) { 17 | @Transactional 18 | fun updateAmount( 19 | userId: Long, 20 | amount: Long, 21 | ): Balance { 22 | val user = userRepository.findById(userId) ?: throw BusinessException.NotFound(ErrorCode.User.NOT_FOUND) 23 | return balanceRepository.findByUserId(user.id)?.apply { 24 | updateAmount(amount) 25 | } ?: balanceRepository.save( 26 | Balance( 27 | user = user, 28 | amount = amount, 29 | lastUpdatedAt = LocalDateTime.now(), 30 | ), 31 | ) 32 | } 33 | 34 | fun getBalanceByUserId(userId: Long): Balance = 35 | balanceRepository.findByUserId(userId) ?: throw BusinessException.NotFound(ErrorCode.Balance.NOT_FOUND) 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/controller/BalanceController.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.controller 2 | 3 | import com.hhplus.concert.business.application.service.BalanceService 4 | import com.hhplus.concert.interfaces.presentation.request.BalanceRequest 5 | import com.hhplus.concert.interfaces.presentation.response.BalanceResponse 6 | import org.springframework.web.bind.annotation.GetMapping 7 | import org.springframework.web.bind.annotation.PathVariable 8 | import org.springframework.web.bind.annotation.PostMapping 9 | import org.springframework.web.bind.annotation.RequestBody 10 | import org.springframework.web.bind.annotation.RequestMapping 11 | import org.springframework.web.bind.annotation.RestController 12 | 13 | @RestController 14 | @RequestMapping("/api/v1/balance") 15 | class BalanceController( 16 | private val balanceService: BalanceService, 17 | ) { 18 | // 잔액을 충전한다. 19 | @PostMapping("/users/{userId}/recharge") 20 | fun recharge( 21 | @PathVariable userId: Long, 22 | @RequestBody rechargeRequest: BalanceRequest.Recharge, 23 | ): BalanceResponse.Detail = 24 | BalanceResponse.Detail.from( 25 | balanceService.recharge(userId, rechargeRequest.amount), 26 | ) 27 | 28 | // 잔액을 조회한다. 29 | @GetMapping("/users/{userId}") 30 | fun getBalance( 31 | @PathVariable userId: Long, 32 | ): BalanceResponse.Detail = 33 | BalanceResponse.Detail.from( 34 | balanceService.getBalanceByUserId(userId), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/response/ReservationResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.response 2 | 3 | import com.hhplus.concert.business.application.dto.ReservationServiceDto 4 | import com.hhplus.concert.common.type.ReservationStatus 5 | import java.time.LocalDateTime 6 | 7 | class ReservationResponse { 8 | data class Result( 9 | val reservationId: Long, 10 | val concertId: Long, 11 | val concertName: String, 12 | val concertAt: LocalDateTime, 13 | val seat: Seat, 14 | val reservationStatus: ReservationStatus, 15 | ) { 16 | companion object { 17 | fun from(resultDto: ReservationServiceDto.Result): Result = 18 | Result( 19 | reservationId = resultDto.reservationId, 20 | concertId = resultDto.concertId, 21 | concertName = resultDto.concertName, 22 | concertAt = resultDto.concertAt, 23 | seat = Seat.from(resultDto.seat), 24 | reservationStatus = resultDto.reservationStatus, 25 | ) 26 | } 27 | } 28 | 29 | data class Seat( 30 | val seatNumber: Int, 31 | val price: Int, 32 | ) { 33 | companion object { 34 | fun from(seatDto: ReservationServiceDto.Seat): Seat = 35 | Seat( 36 | seatNumber = seatDto.seatNumber, 37 | price = seatDto.price, 38 | ) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/controller/QueueController.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.controller 2 | 3 | import com.hhplus.concert.business.application.service.QueueService 4 | import com.hhplus.concert.common.annotation.TokenRequired 5 | import com.hhplus.concert.common.annotation.ValidatedToken 6 | import com.hhplus.concert.interfaces.presentation.response.QueueTokenResponse 7 | import org.springframework.web.bind.annotation.GetMapping 8 | import org.springframework.web.bind.annotation.PathVariable 9 | import org.springframework.web.bind.annotation.PostMapping 10 | import org.springframework.web.bind.annotation.RequestMapping 11 | import org.springframework.web.bind.annotation.RestController 12 | 13 | @RestController 14 | @RequestMapping("/api/v1/queue") 15 | class QueueController( 16 | private val queueService: QueueService, 17 | ) { 18 | /** 19 | * Queue 에 저장하고, token 을 발급한다. 20 | */ 21 | @PostMapping("/users/{userId}") 22 | fun issueQueueToken( 23 | @PathVariable userId: Long, 24 | ): QueueTokenResponse.Token = QueueTokenResponse.Token.from(queueService.issueQueueToken(userId)) 25 | 26 | /** 27 | * 토큰 정보와, userId 로 queue 의 상태를 확인한다. 28 | * 이 api 는 client 에서 주기적인 poling 을 통해 조회 한다고 가정한다. 29 | * userId 는 resolver 에서 header 로 받은 jwt 토큰으로 인증 받아 가져온다. 30 | */ 31 | @TokenRequired 32 | @GetMapping("/users") 33 | fun getQueueStatus( 34 | @ValidatedToken token: String, 35 | ): QueueTokenResponse.Queue = QueueTokenResponse.Queue.from(queueService.findQueueByToken(token)) 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/Queue.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import com.hhplus.concert.common.type.QueueStatus 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.ConstraintMode 6 | import jakarta.persistence.Entity 7 | import jakarta.persistence.EnumType 8 | import jakarta.persistence.Enumerated 9 | import jakarta.persistence.ForeignKey 10 | import jakarta.persistence.GeneratedValue 11 | import jakarta.persistence.GenerationType 12 | import jakarta.persistence.Id 13 | import jakarta.persistence.JoinColumn 14 | import jakarta.persistence.ManyToOne 15 | import java.time.LocalDateTime 16 | 17 | @Entity 18 | class Queue( 19 | user: User, 20 | token: String, 21 | joinedAt: LocalDateTime, 22 | queueStatus: QueueStatus, 23 | ) { 24 | @Id 25 | @GeneratedValue(strategy = GenerationType.IDENTITY) 26 | val id: Long = 0 27 | 28 | @ManyToOne 29 | @JoinColumn(name = "user_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 30 | var user: User = user 31 | protected set 32 | 33 | @Column(name = "token", nullable = false) 34 | var token: String = token 35 | protected set 36 | 37 | @Column(name = "joined_at", nullable = false) 38 | var joinedAt: LocalDateTime = joinedAt 39 | protected set 40 | 41 | @Column(name = "status", nullable = false) 42 | @Enumerated(EnumType.STRING) 43 | var queueStatus: QueueStatus = queueStatus 44 | protected set 45 | 46 | fun updateStatus(queueStatus: QueueStatus) { 47 | this.queueStatus = queueStatus 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/event/PaymentEventListener.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.event 2 | 3 | import com.hhplus.concert.business.application.service.PaymentEventOutBoxService 4 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEvent 5 | import com.hhplus.concert.common.type.EventStatus 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.scheduling.annotation.Async 8 | import org.springframework.stereotype.Component 9 | import org.springframework.transaction.event.TransactionPhase 10 | import org.springframework.transaction.event.TransactionalEventListener 11 | 12 | @Component 13 | class PaymentEventListener( 14 | private val paymentEventOutBoxService: PaymentEventOutBoxService, 15 | ) { 16 | /** 17 | * 커밋 전 (Before commit) 에 Outbox 를 저장한다. 18 | */ 19 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 20 | fun saveEventOutBoxForPaymentCompleted(event: PaymentEvent) { 21 | logger.info("EventOutBox 저장 - Payment Id : ${event.paymentId}") 22 | paymentEventOutBoxService.saveEventOutBox( 23 | domainId = event.paymentId, 24 | eventStatus = EventStatus.INIT, 25 | ) 26 | } 27 | 28 | /** 29 | * 커밋 이후 (After commit) 에는 다음과 같은 일을 수행한다. 30 | * 1. kafka event 를 발행한다. 31 | */ 32 | @Async 33 | @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 34 | fun publishReservationEvent(event: PaymentEvent) { 35 | paymentEventOutBoxService.publishPaymentEvent(event) 36 | } 37 | 38 | private val logger = LoggerFactory.getLogger(this::class.java) 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/impl/ReservationRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.impl 2 | 3 | import com.hhplus.concert.business.domain.entity.Reservation 4 | import com.hhplus.concert.business.domain.repository.ReservationRepository 5 | import com.hhplus.concert.common.type.ReservationStatus 6 | import com.hhplus.concert.infrastructure.jpa.ReservationJpaRepository 7 | import org.springframework.stereotype.Repository 8 | import java.time.LocalDateTime 9 | import kotlin.jvm.optionals.getOrNull 10 | 11 | @Repository 12 | class ReservationRepositoryImpl( 13 | private val reservationJpaRepository: ReservationJpaRepository, 14 | ) : ReservationRepository { 15 | override fun save(reservation: Reservation): Reservation = reservationJpaRepository.save(reservation) 16 | 17 | override fun findAll(): List = reservationJpaRepository.findAll() 18 | 19 | override fun findExpiredReservations( 20 | reservationStatus: ReservationStatus, 21 | expirationTime: LocalDateTime, 22 | ): List = reservationJpaRepository.findExpiredReservations(reservationStatus, expirationTime) 23 | 24 | override fun updateAllStatus( 25 | reservationIds: List, 26 | reservationStatus: ReservationStatus, 27 | ) { 28 | reservationJpaRepository.updateAllStatus(reservationIds, reservationStatus) 29 | } 30 | 31 | override fun findAllById(reservationIds: List): List = reservationJpaRepository.findAllByIdFetchSeat(reservationIds) 32 | 33 | override fun findById(reservationId: Long): Reservation? = reservationJpaRepository.findById(reservationId).getOrNull() 34 | } 35 | -------------------------------------------------------------------------------- /k6/payment/payment.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { SharedArray } from 'k6/data'; 4 | import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; 5 | import { options, BASE_URL } from '../common/test-options.js'; 6 | 7 | export { options }; 8 | 9 | const users = new SharedArray('users', function () { 10 | return Array.from({ length: 5 }, (_, i) => i + 1); 11 | }); 12 | 13 | const reservationIds = new SharedArray('reservationIds', function () { 14 | return Array.from({ length: 1000 }, (_, i) => i + 1); 15 | }); 16 | 17 | function getRandomReservationIds(count) { 18 | const selectedIds = new Set(); 19 | while (selectedIds.size < count) { 20 | selectedIds.add(randomItem(reservationIds)); 21 | } 22 | return Array.from(selectedIds); 23 | } 24 | 25 | export default function () { 26 | const userId = randomItem(users); 27 | const selectedReservationIds = getRandomReservationIds(Math.floor(Math.random() * 3) + 1); 28 | 29 | const payload = JSON.stringify({ 30 | reservationIds: selectedReservationIds, 31 | }); 32 | 33 | const params = { 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | 'QUEUE-TOKEN': 'token', 37 | }, 38 | }; 39 | 40 | const response = http.post(`${BASE_URL}/api/v1/payment/payments/users/${userId}`, payload, params); 41 | 42 | check(response, { 43 | 'status is 200': (r) => r.status === 200, 44 | 'response has payment results': (r) => { 45 | const body = JSON.parse(r.body); 46 | return Array.isArray(body) && body.length > 0; 47 | }, 48 | }); 49 | 50 | sleep(1); 51 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/service/BalanceService.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.service 2 | 3 | import com.hhplus.concert.business.application.dto.BalanceServiceDto 4 | import com.hhplus.concert.business.domain.manager.balance.BalanceLockManager 5 | import com.hhplus.concert.business.domain.manager.balance.BalanceManager 6 | import com.hhplus.concert.common.error.code.ErrorCode 7 | import com.hhplus.concert.common.error.exception.BusinessException 8 | import org.springframework.stereotype.Service 9 | 10 | @Service 11 | class BalanceService( 12 | private val balanceManager: BalanceManager, 13 | private val balanceLockManager: BalanceLockManager, 14 | ) { 15 | /** 16 | * 잔액을 충전한다. 17 | * - 존재하는 user 인지 검증한다. 18 | * - user 는 존재하지만 balance 가 없다면 생성하고 충전한다. 19 | * - balance 가 존재한다면, 현재 금액에 요청된 금액을 더한다. 20 | */ 21 | fun recharge( 22 | userId: Long, 23 | amount: Long, 24 | ): BalanceServiceDto.Detail { 25 | if (amount < 0) throw BusinessException.BadRequest(ErrorCode.Balance.BAD_RECHARGE_REQUEST) 26 | 27 | val rechargedBalance = 28 | balanceLockManager.rechargeWithLock(userId, amount) 29 | 30 | return BalanceServiceDto.Detail( 31 | userId = userId, 32 | currentAmount = rechargedBalance.amount, 33 | ) 34 | } 35 | 36 | /** 37 | * 잔액을 조회한다. 38 | */ 39 | fun getBalanceByUserId(userId: Long): BalanceServiceDto.Detail { 40 | val balance = balanceManager.getBalanceByUserId(userId) 41 | return BalanceServiceDto.Detail( 42 | userId = userId, 43 | currentAmount = balance.amount, 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/aop/DistributedSimpleLockAspect.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.aop 2 | 3 | import com.hhplus.concert.common.annotation.DistributedSimpleLock 4 | import com.hhplus.concert.common.error.code.ErrorCode 5 | import com.hhplus.concert.common.error.exception.BusinessException 6 | import org.aspectj.lang.ProceedingJoinPoint 7 | import org.aspectj.lang.annotation.Around 8 | import org.aspectj.lang.annotation.Aspect 9 | import org.aspectj.lang.reflect.MethodSignature 10 | import org.springframework.stereotype.Component 11 | import java.util.UUID 12 | 13 | @Aspect 14 | @Component 15 | class DistributedSimpleLockAspect( 16 | private val redisSimpleLock: RedisSimpleLock, 17 | ) { 18 | @Around("@annotation(com.hhplus.concert.common.annotation.DistributedSimpleLock)") 19 | fun around(joinPoint: ProceedingJoinPoint): Any? { 20 | val signature = joinPoint.signature as MethodSignature 21 | val method = signature.method 22 | val distributedLock = method.getAnnotation(DistributedSimpleLock::class.java) 23 | 24 | val lockKey = distributedLock.key 25 | val lockValue = UUID.randomUUID().toString() 26 | 27 | try { 28 | val acquired = 29 | redisSimpleLock.tryLock( 30 | lockKey, 31 | lockValue, 32 | distributedLock.leaseTime, 33 | distributedLock.timeUnit, 34 | ) 35 | if (!acquired) { 36 | throw BusinessException.BadRequest(ErrorCode.Common.BAD_REQUEST) 37 | } 38 | return joinPoint.proceed() 39 | } finally { 40 | redisSimpleLock.releaseLock(lockKey, lockValue) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/Payment.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import com.hhplus.concert.common.type.PaymentStatus 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.ConstraintMode 6 | import jakarta.persistence.Entity 7 | import jakarta.persistence.EnumType 8 | import jakarta.persistence.Enumerated 9 | import jakarta.persistence.ForeignKey 10 | import jakarta.persistence.GeneratedValue 11 | import jakarta.persistence.GenerationType 12 | import jakarta.persistence.Id 13 | import jakarta.persistence.JoinColumn 14 | import jakarta.persistence.ManyToOne 15 | import java.time.LocalDateTime 16 | 17 | @Entity 18 | class Payment( 19 | user: User, 20 | reservation: Reservation, 21 | amount: Int, 22 | executedAt: LocalDateTime, 23 | paymentStatus: PaymentStatus, 24 | ) { 25 | @Id 26 | @GeneratedValue(strategy = GenerationType.IDENTITY) 27 | val id: Long = 0 28 | 29 | @ManyToOne 30 | @JoinColumn(name = "user_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 31 | var user: User = user 32 | protected set 33 | 34 | @ManyToOne 35 | @JoinColumn(name = "reservation_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 36 | var reservation: Reservation = reservation 37 | protected set 38 | 39 | @Column(name = "amount", nullable = false) 40 | var amount: Int = amount 41 | protected set 42 | 43 | @Column(name = "executed_at", nullable = false) 44 | var executedAt: LocalDateTime = executedAt 45 | protected set 46 | 47 | @Column(name = "status", nullable = false) 48 | @Enumerated(EnumType.STRING) 49 | var paymentStatus: PaymentStatus = paymentStatus 50 | protected set 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/controller/PaymentController.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.controller 2 | 3 | import com.hhplus.concert.business.application.service.PaymentService 4 | import com.hhplus.concert.common.annotation.TokenRequired 5 | import com.hhplus.concert.common.annotation.ValidatedToken 6 | import com.hhplus.concert.interfaces.presentation.request.PaymentRequest 7 | import com.hhplus.concert.interfaces.presentation.response.PaymentResponse 8 | import org.springframework.web.bind.annotation.PathVariable 9 | import org.springframework.web.bind.annotation.PostMapping 10 | import org.springframework.web.bind.annotation.RequestBody 11 | import org.springframework.web.bind.annotation.RequestMapping 12 | import org.springframework.web.bind.annotation.RestController 13 | 14 | @RestController 15 | @RequestMapping("/api/v1/payment") 16 | class PaymentController( 17 | private val paymentService: PaymentService, 18 | ) { 19 | /** 20 | * 결제를 진행한다. 21 | * 1. reservation 의 user 와, payment 를 요청하는 user 가 일치하는지 검증 22 | * 2. payment 수행하고 paymentHistory 에 저장 23 | * 3. reservation 상태 변경 24 | * 4. 토큰의 상태 변경 -> completed 25 | */ 26 | @TokenRequired 27 | @PostMapping("/payments/users/{userId}") 28 | fun executePayment( 29 | @ValidatedToken token: String, 30 | @PathVariable userId: Long, 31 | @RequestBody paymentRequest: PaymentRequest.Detail, 32 | ): List = 33 | paymentService 34 | .executePayment( 35 | token = token, 36 | userId = userId, 37 | reservationIds = paymentRequest.reservationIds, 38 | ).map { 39 | PaymentResponse.Result.from(it) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/kafka/PaymentEventKafkaProducer.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.kafka 2 | 3 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEvent 4 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEventOutBoxManager 5 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEventPublisher 6 | import com.hhplus.concert.common.type.EventStatus 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.beans.factory.annotation.Qualifier 9 | import org.springframework.kafka.core.KafkaTemplate 10 | import org.springframework.stereotype.Component 11 | 12 | @Component 13 | @Qualifier("kafka") 14 | class PaymentEventKafkaProducer( 15 | private val kafkaTemplate: KafkaTemplate, 16 | private val paymentEventOutBoxManager: PaymentEventOutBoxManager, 17 | ) : PaymentEventPublisher { 18 | private val logger = LoggerFactory.getLogger(this::class.java) 19 | 20 | /** 21 | * Kafka Template 을 통해 Kafka 에게 message 를 전송하는지 확인하는 메서드 22 | */ 23 | fun send( 24 | topic: String, 25 | message: String, 26 | ) { 27 | kafkaTemplate.send(topic, message) 28 | } 29 | 30 | /** 31 | * 카프카 이벤트를 발행한다. 32 | * - 카프카 발행이 성공한다면 (WhenComplete), OutBox의 상태를 PUBLISHED 로 변경한다. 33 | */ 34 | override fun publishPaymentEvent(event: PaymentEvent) { 35 | kafkaTemplate 36 | .send("payment-event", event.paymentId.toString()) 37 | .whenComplete { _, error -> 38 | if (error != null) { 39 | logger.info("Kafka payment event published") 40 | paymentEventOutBoxManager.updateEventStatus(event.paymentId, EventStatus.PUBLISHED) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /k6/reservation/reservation.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { SharedArray } from 'k6/data'; 4 | import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; 5 | import { options, BASE_URL } from '../common/test-options.js'; 6 | 7 | export { options }; 8 | 9 | const users = new SharedArray('users', function () { 10 | return Array.from({ length: 5 }, (_, i) => i + 1); 11 | }); 12 | 13 | const concerts = new SharedArray('concerts', function () { 14 | return Array.from({ length: 5 }, (_, i) => i + 1); 15 | }); 16 | 17 | const schedules = new SharedArray('schedules', function () { 18 | return Array.from({ length: 28 }, (_, i) => i + 1); 19 | }); 20 | 21 | // Helper function to get random seats 22 | function getRandomSeats(count) { 23 | const seats = new Set(); 24 | while (seats.size < count) { 25 | seats.add(Math.floor(Math.random() * 672) + 1); 26 | } 27 | return Array.from(seats); 28 | } 29 | 30 | export default function () { 31 | const userId = randomItem(users); 32 | const concertId = randomItem(concerts); 33 | const scheduleId = randomItem(schedules); 34 | const seatIds = getRandomSeats(Math.floor(Math.random() * 4) + 1); // 1 to 4 random seats 35 | 36 | const payload = JSON.stringify({ 37 | userId: userId, 38 | concertId: concertId, 39 | scheduleId: scheduleId, 40 | seatIds: seatIds, 41 | }); 42 | 43 | const params = { 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | 'QUEUE-TOKEN': 'token', 47 | }, 48 | }; 49 | 50 | const response = http.post(`${BASE_URL}/api/v1/reservations`, payload, params); 51 | 52 | check(response, { 53 | 'status is 200': (r) => r.status === 200, 54 | }); 55 | 56 | sleep(1); 57 | } -------------------------------------------------------------------------------- /docs/03_ERD.md: -------------------------------------------------------------------------------- 1 | 2 | ```mermaid 3 | erDiagram 4 | User ||--o{ Reservation : makes 5 | User ||--o{ Payment : makes 6 | User ||--|| Balance : has 7 | User ||--o{ Queue : joins 8 | Concert ||--|{ ConcertSchedule : has 9 | Concert ||--|{ Seat : has 10 | Concert ||--o{ Reservation : for 11 | ConcertSchedule ||--|{ Seat : has 12 | Reservation ||--|| Seat : reserves 13 | Reservation ||--o| Payment : has 14 | Payment ||--o{ PaymentHistory : has 15 | 16 | User { 17 | bigint id PK 18 | string name 19 | } 20 | 21 | Concert { 22 | bigint id PK 23 | string title 24 | string description 25 | } 26 | 27 | ConcertSchedule { 28 | bigint id PK 29 | bigint concert_id FK 30 | date reservation_available_at 31 | date concert_at 32 | } 33 | 34 | Seat { 35 | bigint id PK 36 | bigint concert_schedule_id FK 37 | int seat_number 38 | string status 39 | int seat_price 40 | } 41 | 42 | Reservation { 43 | bigint id PK 44 | bigint user_id FK 45 | bigint seat_id FK 46 | string status 47 | date reservation_at 48 | } 49 | 50 | Balance { 51 | bigint id PK 52 | bigint user_id FK 53 | bigint amount 54 | datetime last_updated_at 55 | } 56 | 57 | Queue { 58 | bigint id PK 59 | bigint user_id FK 60 | string token 61 | datetime joined_at 62 | string status 63 | } 64 | 65 | Payment { 66 | bigint id PK 67 | bigint user_id FK 68 | bigint reservation_id FK 69 | bigint amount 70 | datetime executed_at 71 | string status 72 | } 73 | 74 | PaymentHistory { 75 | bigint id PK 76 | bigint payment_id FK 77 | bigint amount 78 | datetime created_at 79 | string status 80 | } 81 | ``` 82 | 83 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/domain/manager/balance/BalanceManagerTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.domain.manager.balance 2 | 3 | import com.hhplus.concert.business.domain.entity.Balance 4 | import com.hhplus.concert.business.domain.entity.User 5 | import com.hhplus.concert.business.domain.manager.balance.BalanceManager 6 | import com.hhplus.concert.business.domain.repository.BalanceRepository 7 | import com.hhplus.concert.business.domain.repository.UserRepository 8 | import org.junit.jupiter.api.Assertions.assertEquals 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import org.mockito.InjectMocks 12 | import org.mockito.Mock 13 | import org.mockito.Mockito.`when` 14 | import org.mockito.MockitoAnnotations 15 | import java.time.LocalDateTime 16 | 17 | class BalanceManagerTest { 18 | @Mock 19 | private lateinit var userRepository: UserRepository 20 | 21 | @Mock 22 | private lateinit var balanceRepository: BalanceRepository 23 | 24 | @InjectMocks 25 | private lateinit var balanceManager: BalanceManager 26 | 27 | @BeforeEach 28 | fun setUp() { 29 | MockitoAnnotations.openMocks(this) 30 | } 31 | 32 | @Test 33 | fun `기존 잔액이 있는 사용자의 잔액 충전 테스트`() { 34 | // Given 35 | val userId = 0L 36 | val initialAmount = 1000L 37 | val rechargeAmount = 500L 38 | 39 | val user = User("user") 40 | val existingBalance = 41 | Balance( 42 | user = user, 43 | amount = initialAmount, 44 | lastUpdatedAt = LocalDateTime.now(), 45 | ) 46 | 47 | `when`(userRepository.findById(userId)).thenReturn(user) 48 | `when`(balanceRepository.findByUserId(userId)).thenReturn(existingBalance) 49 | 50 | // When 51 | val result = balanceManager.updateAmount(userId, rechargeAmount) 52 | 53 | // Then 54 | assertEquals(initialAmount + rechargeAmount, result.amount) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/interceptor/TokenInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.interceptor 2 | 3 | import com.hhplus.concert.common.annotation.TokenRequired 4 | import com.hhplus.concert.common.constants.TokenConstants.QUEUE_TOKEN_HEADER 5 | import com.hhplus.concert.common.constants.TokenConstants.VALIDATED_TOKEN 6 | import com.hhplus.concert.common.util.JwtUtil 7 | import jakarta.servlet.http.HttpServletRequest 8 | import jakarta.servlet.http.HttpServletResponse 9 | import org.springframework.stereotype.Component 10 | import org.springframework.web.method.HandlerMethod 11 | import org.springframework.web.servlet.HandlerInterceptor 12 | 13 | @Component 14 | class TokenInterceptor( 15 | private val jwtUtil: JwtUtil, 16 | ) : HandlerInterceptor { 17 | override fun preHandle( 18 | request: HttpServletRequest, 19 | response: HttpServletResponse, 20 | handler: Any, 21 | ): Boolean { 22 | if (handler is HandlerMethod) { 23 | val requireToken = 24 | handler.hasMethodAnnotation(TokenRequired::class.java) || 25 | handler.beanType.isAnnotationPresent(TokenRequired::class.java) 26 | 27 | if (!requireToken) { 28 | return true 29 | } 30 | 31 | val token = request.getHeader(QUEUE_TOKEN_HEADER) 32 | if (token == null) { 33 | response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "$QUEUE_TOKEN_HEADER is missing") 34 | return false 35 | } 36 | 37 | if (!isValidToken(token)) { 38 | response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid $QUEUE_TOKEN_HEADER") 39 | return false 40 | } 41 | 42 | request.setAttribute(VALIDATED_TOKEN, token) 43 | } 44 | return true 45 | } 46 | 47 | private fun isValidToken(token: String): Boolean = jwtUtil.validateToken(token) 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/exceptionhandler/ApiAdviceHandler.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.exceptionhandler 2 | 3 | import com.hhplus.concert.common.error.code.ErrorCode 4 | import com.hhplus.concert.common.error.exception.BusinessException 5 | import com.hhplus.concert.common.error.response.ErrorResponse 6 | import jakarta.servlet.http.HttpServletRequest 7 | import jakarta.validation.ConstraintViolationException 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.annotation.ExceptionHandler 10 | import org.springframework.web.bind.annotation.RestControllerAdvice 11 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler 12 | 13 | @RestControllerAdvice 14 | class ApiAdviceHandler : ResponseEntityExceptionHandler() { 15 | @ExceptionHandler(ConstraintViolationException::class) 16 | fun handleConstraintViolationException( 17 | e: ConstraintViolationException, 18 | request: HttpServletRequest, 19 | ): ResponseEntity { 20 | logger.error( 21 | "[Constraint Violation Exception] " + 22 | "- http status code : ${ErrorCode.Common.BAD_REQUEST.httpStatus}" + 23 | "- error message : ${e.message}", 24 | ) 25 | return ErrorResponse.toResponseEntity(e.constraintViolations, request.requestURI) 26 | } 27 | 28 | @ExceptionHandler(BusinessException::class) 29 | fun handleBusinessException( 30 | e: BusinessException, 31 | request: HttpServletRequest, 32 | ): ResponseEntity { 33 | logger.error( 34 | "[Business Exception] " + 35 | "- http status code : ${e.errorCode.httpStatus}" + 36 | "- custom error code : ${e.errorCode.errorCode} " + 37 | "- message : ${e.errorCode.message}", 38 | ) 39 | return ErrorResponse.toResponseEntity(e.errorCode, request.requestURI) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/util/JwtUtil.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.util 2 | 3 | import io.jsonwebtoken.Jwts 4 | import io.jsonwebtoken.SignatureAlgorithm 5 | import io.jsonwebtoken.security.Keys 6 | import org.springframework.beans.factory.annotation.Value 7 | import org.springframework.stereotype.Component 8 | import java.time.Instant 9 | import java.time.temporal.ChronoUnit 10 | import java.util.Date 11 | 12 | @Component 13 | class JwtUtil( 14 | @Value("\${jwt.secret}") private val secret: String, 15 | @Value("\${jwt.expiration-days}") private val expirationDays: Long, 16 | ) { 17 | fun generateToken(userId: Long): String { 18 | val claims = Jwts.claims().setSubject(userId.toString()) 19 | val now = Instant.now() 20 | val expirationDate = now.plus(expirationDays, ChronoUnit.DAYS) 21 | 22 | return Jwts 23 | .builder() 24 | .setClaims(claims) 25 | .setIssuedAt(Date.from(now)) 26 | .setExpiration(Date.from(expirationDate)) 27 | .signWith(Keys.hmacShaKeyFor(secret.toByteArray()), SignatureAlgorithm.HS512) 28 | .compact() 29 | } 30 | 31 | fun getUserIdFromToken(token: String): Long? = 32 | try { 33 | val claims = 34 | Jwts 35 | .parserBuilder() 36 | .setSigningKey(Keys.hmacShaKeyFor(secret.toByteArray())) 37 | .build() 38 | .parseClaimsJws(token) 39 | .body 40 | 41 | claims.subject.toLong() 42 | } catch (e: Exception) { 43 | null 44 | } 45 | 46 | fun validateToken(token: String): Boolean = 47 | try { 48 | Jwts 49 | .parserBuilder() 50 | .setSigningKey(Keys.hmacShaKeyFor(secret.toByteArray())) 51 | .build() 52 | .parseClaimsJws(token) 53 | true 54 | } catch (e: Exception) { 55 | false 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/05_Swagger.md: -------------------------------------------------------------------------------- 1 | # Swagger API 문서 2 | 3 | ## 전체 Swagger 문서 화면 4 | 5 | 스크린샷 2024-07-12 오전 2 23 20 6 | 7 | ## Reservation API (예약 요청 API) 8 | 스크린샷 2024-07-12 오전 2 37 14 9 | 10 | ## Queue API (대기열 토큰 API) 11 | 12 | ### 대기열을 저장하고 토큰을 발급한다. 13 | 스크린샷 2024-07-12 오전 2 37 51 14 | 15 | ### 현재 대기열의 상태를 조회한다. 16 | 스크린샷 2024-07-12 오전 2 37 57 17 | 18 | ## Payment API (결제 API) 19 | 스크린샷 2024-07-12 오전 2 39 41 20 | 21 | ## Balance API (잔액 API) 22 | ### 잔액을 충전한다. 23 | 스크린샷 2024-07-12 오전 2 39 49 24 | 25 | ### 현재 잔액을 조회한다. 26 | 스크린샷 2024-07-12 오전 2 39 56 27 | 28 | ## Concert API (콘서트 API) 29 | 30 | ### 현재 예약 가능한 콘서트들을 조회한다. 31 | 스크린샷 2024-07-12 오전 2 40 02 32 | 33 | ### 선택한 콘서트의 예약 가능한 날짜를 조회한다. 34 | 스크린샷 2024-07-12 오전 2 40 08 35 | 36 | ### 선택한 콘서트와 날짜의 예약 가능한 좌석을 조회한다. 37 | 스크린샷 2024-07-12 오전 2 40 14 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/error/response/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.error.response 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude 4 | import com.hhplus.concert.common.error.code.ErrorCode 5 | import jakarta.validation.ConstraintViolation 6 | import org.springframework.http.HttpStatus 7 | import org.springframework.http.ResponseEntity 8 | 9 | @JsonInclude(JsonInclude.Include.NON_NULL) 10 | data class ErrorResponse private constructor( 11 | val status: Int = 0, 12 | val error: String? = null, 13 | val code: String? = null, 14 | val message: String? = null, 15 | val errors: Map? = null, 16 | val path: String? = null, 17 | ) { 18 | private constructor( 19 | errorCode: ErrorCode, 20 | requestURI: String, 21 | ) : this( 22 | status = errorCode.httpStatus.value(), 23 | error = errorCode.httpStatus.reasonPhrase, 24 | code = errorCode.errorCode, 25 | message = errorCode.message, 26 | path = requestURI, 27 | ) 28 | 29 | private constructor( 30 | errors: Set>, 31 | requestURI: String, 32 | ) : this( 33 | status = HttpStatus.BAD_REQUEST.value(), 34 | error = HttpStatus.BAD_REQUEST.reasonPhrase, 35 | path = requestURI, 36 | errors = errors.associateBy({ it.propertyPath.toString() }, { it.message }), 37 | ) 38 | 39 | companion object { 40 | fun toResponseEntity( 41 | errorCode: ErrorCode, 42 | requestURI: String, 43 | ): ResponseEntity = 44 | ResponseEntity 45 | .status(errorCode.httpStatus) 46 | .body(ErrorResponse(errorCode, requestURI)) 47 | 48 | fun toResponseEntity( 49 | errors: Set>, 50 | requestURI: String, 51 | ): ResponseEntity = 52 | ResponseEntity 53 | .badRequest() 54 | .body(ErrorResponse(errors, requestURI)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/entity/Reservation.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.entity 2 | 3 | import com.hhplus.concert.common.type.ReservationStatus 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.ConstraintMode 6 | import jakarta.persistence.Entity 7 | import jakarta.persistence.EnumType 8 | import jakarta.persistence.Enumerated 9 | import jakarta.persistence.ForeignKey 10 | import jakarta.persistence.GeneratedValue 11 | import jakarta.persistence.GenerationType 12 | import jakarta.persistence.Id 13 | import jakarta.persistence.JoinColumn 14 | import jakarta.persistence.ManyToOne 15 | import jakarta.persistence.OneToOne 16 | import java.time.LocalDateTime 17 | 18 | @Entity 19 | class Reservation( 20 | user: User, 21 | seat: Seat, 22 | concertTitle: String, 23 | concertAt: LocalDateTime, 24 | reservationStatus: ReservationStatus, 25 | createdAt: LocalDateTime, 26 | ) { 27 | @Id 28 | @GeneratedValue(strategy = GenerationType.IDENTITY) 29 | val id: Long = 0 30 | 31 | @ManyToOne 32 | @JoinColumn(name = "user_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 33 | var user: User = user 34 | protected set 35 | 36 | @Column(name = "concert_title", nullable = false) 37 | var concertTitle: String = concertTitle 38 | protected set 39 | 40 | @Column(name = "concert_at", nullable = false) 41 | var concertAt: LocalDateTime = concertAt 42 | protected set 43 | 44 | @OneToOne 45 | @JoinColumn(name = "seat_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) 46 | var seat: Seat = seat 47 | protected set 48 | 49 | @Column(name = "status", nullable = false) 50 | @Enumerated(EnumType.STRING) 51 | var reservationStatus: ReservationStatus = reservationStatus 52 | protected set 53 | 54 | @Column(name = "created_at", nullable = false) 55 | var createdAt: LocalDateTime = createdAt 56 | protected set 57 | 58 | fun updateStatus(status: ReservationStatus) { 59 | this.reservationStatus = status 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/payment/event/PaymentEventOutBoxManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.payment.event 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 4 | import com.hhplus.concert.business.domain.repository.PaymentEventOutBoxRepository 5 | import com.hhplus.concert.common.error.code.ErrorCode 6 | import com.hhplus.concert.common.error.exception.BusinessException 7 | import com.hhplus.concert.common.type.EventStatus 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.stereotype.Component 10 | import org.springframework.transaction.annotation.Transactional 11 | import java.time.LocalDateTime 12 | 13 | @Component 14 | class PaymentEventOutBoxManager( 15 | private val paymentEventOutBoxRepository: PaymentEventOutBoxRepository, 16 | ) { 17 | private val logger = LoggerFactory.getLogger(this::class.java) 18 | 19 | fun saveEventOutBox( 20 | domainId: Long, 21 | eventStatus: EventStatus, 22 | ): PaymentEventOutBox = 23 | paymentEventOutBoxRepository.save( 24 | PaymentEventOutBox( 25 | paymentId = domainId, 26 | eventStatus = eventStatus, 27 | ), 28 | ) 29 | 30 | fun findEventByPaymentId(paymentId: Long): PaymentEventOutBox = 31 | paymentEventOutBoxRepository.findByPaymentId(paymentId) ?: throw BusinessException.NotFound(ErrorCode.Event.BAD_REQUEST) 32 | 33 | fun updateEventStatus( 34 | paymentId: Long, 35 | eventStatus: EventStatus, 36 | ) { 37 | paymentEventOutBoxRepository.findByPaymentId(paymentId)?.updateEventStatus(eventStatus) 38 | } 39 | 40 | fun retryFailedPaymentEvent(): List = 41 | paymentEventOutBoxRepository.findAllFailedEvent(LocalDateTime.now().minusMinutes(5)) 42 | 43 | @Transactional 44 | fun deletePublishedPaymentEvent() { 45 | runCatching { 46 | paymentEventOutBoxRepository.deleteAllPublishedEvent(LocalDateTime.now().minusDays(7)) 47 | }.onFailure { 48 | throw BusinessException.BadRequest(ErrorCode.Event.BAD_REQUEST) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/service/PaymentEventOutBoxService.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.service 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 4 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEvent 5 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEventOutBoxManager 6 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEventPublisher 7 | import com.hhplus.concert.common.type.EventStatus 8 | import org.springframework.beans.factory.annotation.Qualifier 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | class PaymentEventOutBoxService( 13 | private val paymentEventOutBoxManager: PaymentEventOutBoxManager, 14 | @Qualifier("kafka") private val paymentEventPublisher: PaymentEventPublisher, 15 | ) { 16 | /** 17 | * outbox 를 저장한다. 18 | */ 19 | fun saveEventOutBox( 20 | domainId: Long, 21 | eventStatus: EventStatus, 22 | ): PaymentEventOutBox = 23 | paymentEventOutBoxManager.saveEventOutBox( 24 | domainId = domainId, 25 | eventStatus = eventStatus, 26 | ) 27 | 28 | /** 29 | * outbox 의 상태를 변경한다. 30 | */ 31 | fun updateEventStatus( 32 | paymentId: Long, 33 | eventStatus: EventStatus, 34 | ) { 35 | paymentEventOutBoxManager.updateEventStatus( 36 | paymentId = paymentId, 37 | eventStatus = eventStatus, 38 | ) 39 | } 40 | 41 | /** 42 | * kafka 이벤트를 발행한다. 43 | */ 44 | fun publishPaymentEvent(event: PaymentEvent) { 45 | paymentEventPublisher.publishPaymentEvent(event) 46 | } 47 | 48 | /** 49 | * 실패로 간주하는 이벤트를 재시도한다. 50 | * 실패 간주 조건 : 5분이 지났음에도 여전히 상태가 INIT 인 Event 51 | */ 52 | fun retryFailedPaymentEvent() { 53 | paymentEventOutBoxManager.retryFailedPaymentEvent().forEach { 54 | paymentEventPublisher.publishPaymentEvent(PaymentEvent(it.paymentId)) 55 | } 56 | } 57 | 58 | /** 59 | * 발행된 후 일주일이 지난 EventOutBox 를 삭제한다. 60 | */ 61 | fun deletePublishedPaymentEvent() { 62 | paymentEventOutBoxManager.deletePublishedPaymentEvent() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/payment/PaymentMessageSender.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.payment 2 | 3 | import com.hhplus.concert.business.domain.message.MessageAlarmPayload 4 | import com.hhplus.concert.business.domain.message.MessageClient 5 | import com.hhplus.concert.business.domain.repository.PaymentRepository 6 | import com.hhplus.concert.common.type.AlarmLevel 7 | import com.hhplus.concert.common.type.PaymentStatus 8 | import org.springframework.stereotype.Component 9 | 10 | /** 11 | * Message Client 를 통해 Event Message 를 담당하는 컴포넌트. 12 | */ 13 | @Component 14 | class PaymentMessageSender( 15 | private val messageClient: MessageClient, 16 | private val paymentRepository: PaymentRepository, 17 | ) { 18 | /** 19 | * 외부 API 를 통해 Payment Event 메세지를 전송한다. 20 | */ 21 | fun sendPaymentEventMessage(paymentId: Long) { 22 | val payment = paymentRepository.findById(paymentId) ?: return 23 | 24 | messageClient.sendMessage( 25 | MessageAlarmPayload( 26 | alarmLevel = getAlarmLevel(payment.paymentStatus), 27 | subject = getSubject(payment.id, payment.paymentStatus), 28 | description = getDescription(payment.amount, payment.paymentStatus), 29 | ), 30 | ) 31 | } 32 | 33 | private fun getAlarmLevel(paymentStatus: PaymentStatus): AlarmLevel = 34 | when (paymentStatus) { 35 | PaymentStatus.COMPLETED -> AlarmLevel.SUCCESS 36 | PaymentStatus.FAILED -> AlarmLevel.DANGER 37 | } 38 | 39 | private fun getSubject( 40 | paymentId: Long, 41 | paymentStatus: PaymentStatus, 42 | ): String = 43 | when (paymentStatus) { 44 | PaymentStatus.COMPLETED -> 45 | "Payment Completed 💰 - payment id : $paymentId" 46 | PaymentStatus.FAILED -> 47 | "Payment Failed 😇 - payment id : $paymentId" 48 | } 49 | 50 | private fun getDescription( 51 | amount: Int, 52 | paymentStatus: PaymentStatus, 53 | ): String = 54 | when (paymentStatus) { 55 | PaymentStatus.COMPLETED -> 56 | "Payment amount : $amount" 57 | PaymentStatus.FAILED -> 58 | "Payment requested amount: $amount" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/controller/ConcertController.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.controller 2 | 3 | import com.hhplus.concert.business.application.service.ConcertService 4 | import com.hhplus.concert.common.annotation.TokenRequired 5 | import com.hhplus.concert.common.annotation.ValidatedToken 6 | import com.hhplus.concert.interfaces.presentation.response.ConcertResponse 7 | import org.springframework.web.bind.annotation.GetMapping 8 | import org.springframework.web.bind.annotation.PathVariable 9 | import org.springframework.web.bind.annotation.RequestMapping 10 | import org.springframework.web.bind.annotation.RestController 11 | 12 | @RestController 13 | @RequestMapping("/api/v1") 14 | class ConcertController( 15 | private val concertService: ConcertService, 16 | ) { 17 | /** 18 | * 현재 예약 가능한 콘서트의 목록을 조회한다. 19 | */ 20 | @TokenRequired 21 | @GetMapping("/concerts") 22 | fun getAvailableConcerts( 23 | @ValidatedToken token: String, 24 | ): List = 25 | concertService 26 | .getAvailableConcerts(token) 27 | .map { 28 | ConcertResponse.Concert.from(it) 29 | } 30 | 31 | /** 32 | * 콘서트 예약 가능 날짜 목록을 조회한다. 33 | */ 34 | @TokenRequired 35 | @GetMapping("/concerts/{concertId}/schedules") 36 | fun getConcertSchedules( 37 | @ValidatedToken token: String, 38 | @PathVariable concertId: Long, 39 | ): ConcertResponse.Schedule = 40 | ConcertResponse.Schedule.from( 41 | concertService.getConcertSchedules( 42 | token = token, 43 | concertId = concertId, 44 | ), 45 | ) 46 | 47 | /** 48 | * 콘서트 해당 날짜의 좌석을 조회한다. 49 | */ 50 | @TokenRequired 51 | @GetMapping("/concerts/{concertId}/schedules/{scheduleId}/seats") 52 | fun getAvailableSeats( 53 | @ValidatedToken token: String, 54 | @PathVariable concertId: Long, 55 | @PathVariable scheduleId: Long, 56 | ): ConcertResponse.AvailableSeat = 57 | ConcertResponse.AvailableSeat.from( 58 | concertService.getAvailableSeats( 59 | token = token, 60 | concertId = concertId, 61 | scheduleId = scheduleId, 62 | ), 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /docs/01_Milestone.md: -------------------------------------------------------------------------------- 1 | # Milestone 2 | 3 | ## WEEK 1 : 프로젝트 설계 및 Mock API 작성을 완료한다. 4 | 5 | ### TODO LIST 6 | 7 | - [ ] 주차 별 마일스톤을 설정한다. 8 | - [ ] 요구사항에 대한 분석을 완료한다. 9 | - [ ] 이벤트 시퀀스 다이어그램을 작성한다. 10 | - [ ] ERD 설계를 완료한다. 11 | - [ ] API 명세 문서를 작성한다. 12 | - [ ] Mock API 를 구현한다. 13 | 14 | | | 작업 | 예상 시간 (시간) | 15 | |-----|------|----------------| 16 | | 1 | **WEEK 1: 프로젝트 설계 및 Mock API 작성** | **40** | 17 | | 1.1 | 주차 별 마일스톤 설정 | 2 | 18 | | 1.2 | 요구사항 분석 | 6 | 19 | | 1.3 | 이벤트 시퀀스 다이어그램 작성 | 8 | 20 | | 1.4 | ERD 설계 | 8 | 21 | | 1.5 | API 명세 문서 작성 | 8 | 22 | | 1.6 | Mock API 구현 | 8 | 23 | 24 |
25 | 26 | ## WEEK 2 : 유저 대기열기능 및 예약 기능을 개발한다. 27 | 28 | ### TODO LIST 29 | - 유저 대기열 토큰 기능 개발을 완료한다. 30 | - [ ] 토큰 발급 API 개발 31 | - [ ] 대기열 확인 API 개발 32 | 33 | 34 | - 예약 가능 날짜 및 좌석을 조회하는 기능 개발을 완료한다. 35 | - [ ] 콘서트의 예약 가능한 날짜 목록 조회 API 개발 36 | - [ ] 선택한 날짜의 좌석 조회 API 개발 37 | 38 | 39 | - 좌석 예약을 수행하는 기능 개발을 완료한다. 40 | - [ ] 좌석 예약 API 개발 41 | - [ ] 좌석 배정 해제 스케줄러 개발 42 | 43 | 44 | | | 작업 | 예상 시간 (시간) | 45 | |--|------|------------| 46 | | 2 | **WEEK 2: TDD로 프로젝트 API 구현** | **40** | 47 | | 2.1 | 유저 대기열 토큰 기능 개발 | | 48 | | 2.1.1 | 토큰 발급 API 개발 | 8 | 49 | | 2.1.2 | 대기열 확인 API 개발 | 8 | 50 | | 2.2 | 예약 가능 날짜 및 좌석 조회 기능 개발 | | 51 | | 2.2.1 | 콘서트의 예약 가능한 날짜 목록 조회 API 개발 | 8 | 52 | | 2.2.2 | 선택한 날짜의 좌석 조회 API 개발 | 4 | 53 | | 2.3 | 좌석 예약 기능 개발 | | 54 | | 2.3.1 | 좌석 예약 API 개발 | 6 | 55 | | 2.3.2 | 좌석 배정 해제 스케줄러 개발 | 4 | 56 | 57 |
58 | 59 | ## WEEK 3 : 잔액과 결제 기능을 개발하고 코드 리팩토링 및 테스트를 진행한다. 60 | 61 | ### TODO LIST 62 | - 잔액 관련 기능 개발을 완료한다. 63 | - [ ] 잔액 충전 API 개발 64 | - [ ] 잔액 조회 API 개발 65 | 66 | 67 | - 결제 기능 개발을 완료한다. 68 | - [ ] 결제 처리 API 개발 69 | - [ ] 결제 내역 조회 API 개발 70 | 71 | 72 | - [ ] 구현된 전체 로직의 동작 테스트를 완료한다. 73 | 74 | 75 | - [ ] 동시성 이슈에 대한 문제 해결이 적절한지 확인하고 코드를 수정한다. 76 | 77 | 78 | - [ ] 코드 리펙토링을 진행한다. 79 | 80 | | | 작업 | 예상 시간 (시간) | 81 | |----|------|------------| 82 | | 3 | **WEEK 3: 코드 리팩토링 및 테스트** | **40** | 83 | | 3.1 | 구현된 전체 로직의 동작 테스트 | 8 | 84 | | 3.2 | 동시성 이슈 검토 및 코드 수정 | 12 | 85 | | 3.3 | 코드 리팩토링 | 6 | 86 | | 3.4 | 잔액 관련 기능 개발 | | 87 | | 3.4.1 | 잔액 충전 API 개발 | 3 | 88 | | 3.4.2 | 잔액 조회 API 개발 | 3 | 89 | | 3.5 | 결제 기능 개발 | | 90 | | 3.5.1 | 결제 처리 API 개발 | 5 | 91 | | 3.5.2 | 결제 내역 조회 API 개발 | 3 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/config/KafkaConfig.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.config 2 | 3 | import org.apache.kafka.clients.consumer.ConsumerConfig 4 | import org.apache.kafka.clients.producer.ProducerConfig 5 | import org.apache.kafka.common.serialization.StringDeserializer 6 | import org.apache.kafka.common.serialization.StringSerializer 7 | import org.springframework.beans.factory.annotation.Value 8 | import org.springframework.context.annotation.Bean 9 | import org.springframework.context.annotation.Configuration 10 | import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory 11 | import org.springframework.kafka.core.ConsumerFactory 12 | import org.springframework.kafka.core.DefaultKafkaConsumerFactory 13 | import org.springframework.kafka.core.DefaultKafkaProducerFactory 14 | import org.springframework.kafka.core.KafkaTemplate 15 | import org.springframework.kafka.core.ProducerFactory 16 | 17 | @Configuration 18 | class KafkaConfig( 19 | @Value("\${spring.kafka.bootstrap-servers}") private val bootstrapServers: String, 20 | ) { 21 | private fun producerProps() = 22 | mapOf( 23 | ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, 24 | ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, 25 | ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, 26 | ) 27 | 28 | private fun consumerProps() = 29 | mapOf( 30 | ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, 31 | ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, 32 | ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, 33 | ) 34 | 35 | @Bean 36 | fun producerFactory(): ProducerFactory = DefaultKafkaProducerFactory(producerProps()) 37 | 38 | @Bean 39 | fun consumerFactory(): ConsumerFactory = DefaultKafkaConsumerFactory(consumerProps()) 40 | 41 | @Bean 42 | fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = 43 | ConcurrentKafkaListenerContainerFactory().apply { 44 | consumerFactory = consumerFactory() 45 | } 46 | 47 | @Bean 48 | fun kafkaTemplate(): KafkaTemplate = KafkaTemplate(producerFactory()) 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/filter/LoggingFilter.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.filter 2 | 3 | import jakarta.servlet.FilterChain 4 | import jakarta.servlet.http.HttpServletRequest 5 | import jakarta.servlet.http.HttpServletResponse 6 | import org.springframework.stereotype.Component 7 | import org.springframework.web.filter.OncePerRequestFilter 8 | import org.springframework.web.util.ContentCachingRequestWrapper 9 | import org.springframework.web.util.ContentCachingResponseWrapper 10 | 11 | @Component 12 | class LoggingFilter : OncePerRequestFilter() { 13 | override fun doFilterInternal( 14 | request: HttpServletRequest, 15 | response: HttpServletResponse, 16 | filterChain: FilterChain, 17 | ) { 18 | val requestWrapper = ContentCachingRequestWrapper(request) 19 | val responseWrapper = ContentCachingResponseWrapper(response) 20 | 21 | logger.info(getRequestLog(requestWrapper)) 22 | filterChain.doFilter(request, responseWrapper) 23 | logger.info(getResponseLog(responseWrapper)) 24 | } 25 | 26 | private fun getRequestLog(request: ContentCachingRequestWrapper): String { 27 | val requestBody = String(request.contentAsByteArray) 28 | return """ 29 | |=== REQUEST === 30 | |Method: ${request.method} 31 | |URL: ${request.requestURL} 32 | |Headers: ${getHeadersAsString(request)} 33 | |Body: $requestBody 34 | |================ 35 | """.trimMargin() 36 | } 37 | 38 | private fun getResponseLog(response: ContentCachingResponseWrapper): String { 39 | val responseBody = String(response.contentAsByteArray) 40 | return """ 41 | |=== RESPONSE === 42 | |Status: ${response.status} 43 | |Headers: ${getHeadersAsString(response)} 44 | |Body: $responseBody 45 | |================= 46 | """.trimMargin() 47 | } 48 | 49 | private fun getHeadersAsString(request: HttpServletRequest): String = 50 | request.headerNames.toList().joinToString(", ") { 51 | "$it: ${request.getHeader(it)}" 52 | } 53 | 54 | private fun getHeadersAsString(response: HttpServletResponse): String = 55 | response.headerNames.joinToString(", ") { 56 | "$it: ${response.getHeader(it)}" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/service/QueueService.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.service 2 | 3 | import com.hhplus.concert.business.application.dto.QueueServiceDto 4 | import com.hhplus.concert.business.domain.manager.UserManager 5 | import com.hhplus.concert.business.domain.manager.queue.QueueManager 6 | import com.hhplus.concert.common.type.QueueStatus 7 | import org.springframework.stereotype.Service 8 | import org.springframework.transaction.annotation.Transactional 9 | 10 | @Service 11 | class QueueService( 12 | private val queueManager: QueueManager, 13 | private val userManager: UserManager, 14 | ) { 15 | /** 16 | * userId 를 통해 user 를 찾아온다. 17 | * waiting 상태로 queue 저장 및 token 발급 18 | */ 19 | @Transactional 20 | fun issueQueueToken(userId: Long): QueueServiceDto.IssuedToken { 21 | val user = userManager.findById(userId) 22 | 23 | return QueueServiceDto.IssuedToken( 24 | token = queueManager.enqueueAndIssueToken(user.id), 25 | ) 26 | } 27 | 28 | /** 29 | * token 을 통해 queue 의 상태를 조회한다. 30 | * 현재 queue 상태가 waiting 상태라면 현재 대기열이 얼마나 남았는지를 계산하여 반환한다. 31 | * 그 밖의 상태라면, 얼마나 대기를 해야하는지 알 필요가 없으므로 0 을 반환한다. 32 | */ 33 | fun findQueueByToken(token: String): QueueServiceDto.Queue { 34 | val status = queueManager.getQueueStatus(token) 35 | val isWaiting = status == QueueStatus.WAITING 36 | 37 | val position = if (isWaiting) queueManager.getPositionInWaitingStatus(token) else NO_REMAINING_WAIT 38 | val estimatedWaitTime = if (isWaiting) queueManager.calculateEstimatedWaitSeconds(position) else NO_REMAINING_WAIT 39 | 40 | return QueueServiceDto.Queue( 41 | status = status, 42 | remainingWaitListCount = position, 43 | estimatedWaitTime = estimatedWaitTime, 44 | ) 45 | } 46 | 47 | /** 48 | * 스케쥴러를 통해 WAITING 상태의 대기열을 PROCESSING 상태로 변경한다. 49 | */ 50 | fun updateToProcessingTokens() { 51 | queueManager.updateToProcessingTokens() 52 | } 53 | 54 | /** 55 | * 스케쥴러를 통해 만료 시간이 지났지만 여전히 WAITING 상태인 대기열을 삭제한다. 56 | */ 57 | fun cancelExpiredWaitingQueue() { 58 | queueManager.removeExpiredWaitingQueue() 59 | } 60 | 61 | fun removeExpiredProcessingQueue() { 62 | queueManager.removeExpiredProcessingQueue() 63 | } 64 | 65 | companion object { 66 | const val NO_REMAINING_WAIT = 0L 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/interfaces/presentation/response/ConcertResponse.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.response 2 | 3 | import com.hhplus.concert.business.application.dto.ConcertServiceDto 4 | import com.hhplus.concert.common.type.SeatStatus 5 | import java.time.LocalDateTime 6 | 7 | class ConcertResponse { 8 | data class Concert( 9 | val concertId: Long, 10 | val title: String, 11 | val description: String, 12 | ) { 13 | companion object { 14 | fun from(concertDto: ConcertServiceDto.Concert): Concert = 15 | Concert( 16 | concertId = concertDto.concertId, 17 | title = concertDto.title, 18 | description = concertDto.description, 19 | ) 20 | } 21 | } 22 | 23 | data class Schedule( 24 | val concertId: Long, 25 | val events: List, 26 | ) { 27 | companion object { 28 | fun from(scheduleDto: ConcertServiceDto.Schedule): Schedule = 29 | Schedule( 30 | concertId = scheduleDto.concertId, 31 | events = scheduleDto.events.map { Event.from(it) }, 32 | ) 33 | } 34 | } 35 | 36 | data class Event( 37 | val scheduleId: Long, 38 | val concertAt: LocalDateTime, 39 | val reservationAt: LocalDateTime, 40 | ) { 41 | companion object { 42 | fun from(eventDto: ConcertServiceDto.Event): Event = 43 | Event( 44 | scheduleId = eventDto.scheduleId, 45 | concertAt = eventDto.concertAt, 46 | reservationAt = eventDto.reservationAt, 47 | ) 48 | } 49 | } 50 | 51 | data class AvailableSeat( 52 | val concertId: Long, 53 | val seats: List, 54 | ) { 55 | companion object { 56 | fun from(availableSeatDto: ConcertServiceDto.AvailableSeat): AvailableSeat = 57 | AvailableSeat( 58 | concertId = availableSeatDto.concertId, 59 | seats = 60 | availableSeatDto.seats.map { 61 | Seat.from(it) 62 | }, 63 | ) 64 | } 65 | } 66 | 67 | data class Seat( 68 | val seatId: Long, 69 | val seatNumber: Int, 70 | val seatStatus: SeatStatus, 71 | val seatPrice: Int, 72 | ) { 73 | companion object { 74 | fun from(seatDto: ConcertServiceDto.Seat): Seat = 75 | Seat( 76 | seatId = seatDto.seatId, 77 | seatNumber = seatDto.seatNumber, 78 | seatStatus = seatDto.seatStatus, 79 | seatPrice = seatDto.seatPrice, 80 | ) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/error/code/ErrorCode.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.error.code 2 | 3 | import org.springframework.http.HttpStatus 4 | 5 | sealed interface ErrorCode { 6 | val httpStatus: HttpStatus 7 | val errorCode: String 8 | val message: String 9 | 10 | enum class Common( 11 | override val httpStatus: HttpStatus, 12 | override val errorCode: String, 13 | override val message: String, 14 | ) : ErrorCode { 15 | NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON001", "찾을 수 없습니다."), 16 | BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON002", "잘못된 요청입니다."), 17 | INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON003", "서버에 문제가 발생했습니다."), 18 | } 19 | 20 | enum class Concert( 21 | override val httpStatus: HttpStatus, 22 | override val errorCode: String, 23 | override val message: String, 24 | ) : ErrorCode { 25 | NOT_FOUND(HttpStatus.NOT_FOUND, "CONCERT001", "해당 콘서트를 찾을 수 없습니다."), 26 | UNAVAILABLE(HttpStatus.BAD_REQUEST, "CONCERT002", "해당 콘서트는 이용이 불가능합니다."), 27 | SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "CONCERT003", "해당 콘서트의 일정을 찾을 수 없습니다."), 28 | } 29 | 30 | enum class Balance( 31 | override val httpStatus: HttpStatus, 32 | override val errorCode: String, 33 | override val message: String, 34 | ) : ErrorCode { 35 | NOT_FOUND(HttpStatus.NOT_FOUND, "BALANCE001", "해당 잔액 정보를 찾을 수 없습니다."), 36 | BAD_RECHARGE_REQUEST(HttpStatus.BAD_REQUEST, "BALANCE002", "잘못된 잔액 충전 요청입니다."), 37 | } 38 | 39 | enum class Payment( 40 | override val httpStatus: HttpStatus, 41 | override val errorCode: String, 42 | override val message: String, 43 | ) : ErrorCode { 44 | NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT001", "결제 정보를 찾을 수 없습니다."), 45 | BAD_REQUEST(HttpStatus.BAD_REQUEST, "PAYMENT002", "잘못된 결제 정보 요청입니다."), 46 | } 47 | 48 | enum class User( 49 | override val httpStatus: HttpStatus, 50 | override val errorCode: String, 51 | override val message: String, 52 | ) : ErrorCode { 53 | NOT_FOUND(HttpStatus.NOT_FOUND, "USER001", "해당 유저를 찾을 수 없습니다."), 54 | } 55 | 56 | enum class Queue( 57 | override val httpStatus: HttpStatus, 58 | override val errorCode: String, 59 | override val message: String, 60 | ) : ErrorCode { 61 | NOT_FOUND(HttpStatus.NOT_FOUND, "QUEUE001", "해당 대기열을 찾을 수 없습니다."), 62 | NOT_ALLOWED(HttpStatus.BAD_REQUEST, "QUEUE002", "해당 대기열은 허용되지 않습니다."), 63 | } 64 | 65 | enum class Event( 66 | override val httpStatus: HttpStatus, 67 | override val errorCode: String, 68 | override val message: String, 69 | ) : ErrorCode { 70 | BAD_REQUEST(HttpStatus.BAD_REQUEST, "EVENT001", "잘못된 이벤트 요청입니다."), 71 | NOT_FOUND(HttpStatus.NOT_FOUND, "EVENT002", "해당 이벤트를 찾을 수 없습니다."), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/domain/manager/payment/PaymentEventOutBoxManagerTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.domain.manager.payment 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 4 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEventOutBoxManager 5 | import com.hhplus.concert.business.domain.repository.PaymentEventOutBoxRepository 6 | import com.hhplus.concert.common.type.EventStatus 7 | import org.junit.jupiter.api.BeforeEach 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.extension.ExtendWith 10 | import org.mockito.InjectMocks 11 | import org.mockito.Mock 12 | import org.mockito.Mockito.verify 13 | import org.mockito.Mockito.`when` 14 | import org.mockito.junit.jupiter.MockitoExtension 15 | 16 | @ExtendWith(MockitoExtension::class) 17 | class PaymentEventOutBoxManagerTest { 18 | @Mock 19 | private lateinit var paymentEventOutBoxRepository: PaymentEventOutBoxRepository 20 | 21 | @InjectMocks 22 | private lateinit var paymentEventOutBoxManager: PaymentEventOutBoxManager 23 | 24 | @BeforeEach 25 | fun setUp() { 26 | paymentEventOutBoxManager = PaymentEventOutBoxManager(paymentEventOutBoxRepository) 27 | } 28 | 29 | @Test 30 | fun `이벤트 아웃박스 저장 테스트`() { 31 | // Given 32 | val paymentId = 1L 33 | val eventStatus = EventStatus.INIT 34 | val expectedOutBox = PaymentEventOutBox(paymentId, eventStatus) 35 | 36 | `when`( 37 | paymentEventOutBoxRepository.save( 38 | PaymentEventOutBox(paymentId, eventStatus), 39 | ), 40 | ).thenReturn(expectedOutBox) 41 | 42 | // When 43 | val result = paymentEventOutBoxManager.saveEventOutBox(paymentId, eventStatus) 44 | 45 | // Then 46 | assert(result.paymentId == paymentId) 47 | } 48 | 49 | @Test 50 | fun `결제 ID로 이벤트 찾기 테스트`() { 51 | // Given 52 | val paymentId = 1L 53 | val expectedOutBox = PaymentEventOutBox(paymentId, EventStatus.INIT) 54 | 55 | `when`(paymentEventOutBoxRepository.findByPaymentId(paymentId)).thenReturn(expectedOutBox) 56 | 57 | // When 58 | val result = paymentEventOutBoxManager.findEventByPaymentId(paymentId) 59 | 60 | // Then 61 | assert(result == expectedOutBox) 62 | } 63 | 64 | @Test 65 | fun `이벤트 상태 업데이트 테스트`() { 66 | // Given 67 | val paymentId = 1L 68 | val newEventStatus = EventStatus.PUBLISHED 69 | val existingOutBox = PaymentEventOutBox(paymentId, EventStatus.INIT) 70 | 71 | `when`(paymentEventOutBoxRepository.findByPaymentId(paymentId)).thenReturn(existingOutBox) 72 | 73 | // When 74 | paymentEventOutBoxManager.updateEventStatus(paymentId, newEventStatus) 75 | 76 | // Then 77 | verify(paymentEventOutBoxRepository).findByPaymentId(paymentId) 78 | assert(existingOutBox.eventStatus == newEventStatus) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/interfaces/scheduler/PaymentEventSchedulerTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.scheduler 2 | 3 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 4 | import com.hhplus.concert.business.domain.repository.PaymentEventOutBoxRepository 5 | import com.hhplus.concert.common.type.EventStatus 6 | import org.assertj.core.api.Assertions.assertThat 7 | import org.junit.jupiter.api.Assertions.assertEquals 8 | import org.junit.jupiter.api.Test 9 | import org.springframework.beans.factory.annotation.Autowired 10 | import org.springframework.boot.test.context.SpringBootTest 11 | import org.springframework.test.context.ActiveProfiles 12 | import org.springframework.transaction.annotation.Transactional 13 | import java.time.LocalDateTime 14 | 15 | @SpringBootTest 16 | @ActiveProfiles("test") 17 | @Transactional 18 | class PaymentEventSchedulerTest { 19 | @Autowired 20 | private lateinit var paymentEventScheduler: PaymentEventScheduler 21 | 22 | @Autowired 23 | private lateinit var paymentEventOutBoxRepository: PaymentEventOutBoxRepository 24 | 25 | @Test 26 | fun `5분_이상_지난_INIT_상태의_이벤트를_재시도한다`() { 27 | // Given 28 | val oldEvent = PaymentEventOutBox(paymentId = 1, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusMinutes(6)) 29 | val recentEvent = 30 | PaymentEventOutBox(paymentId = 2, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusMinutes(4)) 31 | paymentEventOutBoxRepository.save(oldEvent) 32 | paymentEventOutBoxRepository.save(recentEvent) 33 | 34 | // When 35 | paymentEventScheduler.retryFailedPaymentEvent() 36 | 37 | // Then 38 | val updatedOldEvent = paymentEventOutBoxRepository.findByPaymentId(oldEvent.paymentId) 39 | val updatedRecentEvent = paymentEventOutBoxRepository.findByPaymentId(recentEvent.paymentId) 40 | 41 | assertEquals("PUBLISHED", updatedOldEvent!!.eventStatus) 42 | assertEquals("INIT", updatedRecentEvent!!.eventStatus) 43 | } 44 | 45 | @Test 46 | fun `7일_이상_지난_PUBLISHED_상태의_이벤트를_삭제한다`() { 47 | // Given 48 | val oldPublishedEvent = 49 | PaymentEventOutBox(paymentId = 1, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusDays(8)) 50 | val recentPublishedEvent = 51 | PaymentEventOutBox(paymentId = 2, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusDays(6)) 52 | paymentEventOutBoxRepository.save(oldPublishedEvent) 53 | paymentEventOutBoxRepository.save(recentPublishedEvent) 54 | 55 | // When 56 | paymentEventScheduler.deletePublishedPaymentEvent() 57 | 58 | // Then 59 | assertThat(paymentEventOutBoxRepository.findByPaymentId(oldPublishedEvent.paymentId)).isNull() 60 | assertThat(paymentEventOutBoxRepository.findByPaymentId(recentPublishedEvent.paymentId)!!.paymentId).isEqualTo(2L) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/client/SlackClient.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.client 2 | 3 | import com.hhplus.concert.business.domain.message.MessageAlarmPayload 4 | import com.hhplus.concert.business.domain.message.MessageClient 5 | import com.hhplus.concert.common.type.AlarmLevel 6 | import com.slack.api.Slack 7 | import com.slack.api.model.Attachments.asAttachments 8 | import com.slack.api.model.Attachments.attachment 9 | import com.slack.api.model.kotlin_extension.block.withBlocks 10 | import com.slack.api.webhook.Payload 11 | import com.slack.api.webhook.WebhookPayloads 12 | import com.slack.api.webhook.WebhookResponse 13 | import org.springframework.beans.factory.annotation.Value 14 | import org.springframework.stereotype.Component 15 | 16 | @Component 17 | class SlackClient( 18 | @Value("\${slack.checkin.webhook.base_url}") private val baseUrl: String, 19 | @Value("\${spring.profiles.active}") private val profile: String, 20 | ) : MessageClient { 21 | override fun sendMessage(alarm: MessageAlarmPayload): WebhookResponse? = Slack.getInstance().send(baseUrl, getSlackPayload(alarm)) 22 | 23 | fun getSlackPayload(alarm: MessageAlarmPayload): Payload { 24 | val blocks = 25 | withBlocks { 26 | context { 27 | elements { 28 | markdownText(text = "*Profile*") 29 | plainText(text = profile) 30 | } 31 | elements { 32 | markdownText(text = "*Alarm Level*") 33 | plainText(text = alarm.alarmLevel.name) 34 | } 35 | elements { 36 | markdownText(text = "*Description*") 37 | plainText(text = alarm.description) 38 | } 39 | } 40 | } 41 | val color = 42 | when (alarm.alarmLevel) { 43 | AlarmLevel.PRIMARY -> Color.PRIMARY 44 | AlarmLevel.INFO -> Color.INFO 45 | AlarmLevel.SUCCESS -> Color.SUCCESS 46 | AlarmLevel.WARNING -> Color.WARNING 47 | AlarmLevel.DANGER -> Color.DANGER 48 | } 49 | 50 | return WebhookPayloads.payload { payload -> 51 | payload 52 | .text(alarm.subject) 53 | .attachments( 54 | asAttachments( 55 | attachment { 56 | it 57 | .color(color.code) 58 | .blocks(blocks) 59 | }, 60 | ), 61 | ) 62 | } 63 | } 64 | 65 | companion object { 66 | enum class Color( 67 | val code: String, 68 | ) { 69 | PRIMARY(code = "#007bff"), 70 | INFO(code = "#17a2b8"), 71 | SUCCESS(code = "#28a745"), 72 | WARNING(code = "#ffc107"), 73 | DANGER(code = "#dc3545"), 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/application/facade/concurrency/BalanceServiceConcurrencyTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.application.facade.concurrency 2 | 3 | import com.hhplus.concert.business.application.service.BalanceService 4 | import com.hhplus.concert.business.domain.entity.Balance 5 | import com.hhplus.concert.business.domain.entity.User 6 | import com.hhplus.concert.business.domain.repository.BalanceRepository 7 | import com.hhplus.concert.business.domain.repository.UserRepository 8 | import org.junit.jupiter.api.Assertions.assertEquals 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.boot.test.context.SpringBootTest 13 | import java.time.Duration 14 | import java.time.LocalDateTime 15 | import java.util.concurrent.Executors 16 | import java.util.concurrent.TimeUnit 17 | import java.util.concurrent.atomic.AtomicInteger 18 | 19 | @SpringBootTest 20 | class BalanceServiceConcurrencyTest { 21 | @Autowired 22 | private lateinit var balanceService: BalanceService 23 | 24 | @Autowired 25 | private lateinit var balanceRepository: BalanceRepository 26 | 27 | @Autowired 28 | private lateinit var userRepository: UserRepository 29 | 30 | private lateinit var testUser: User 31 | 32 | @BeforeEach 33 | fun setup() { 34 | // 테스트 사용자 생성 35 | testUser = userRepository.save(User(name = "Test User")) 36 | 37 | // 초기 잔액 설정 38 | balanceRepository.save(Balance(user = testUser, amount = 0, lastUpdatedAt = LocalDateTime.now())) 39 | } 40 | 41 | @Test 42 | fun `동시에 여러 충전 요청이 들어와도 한 번만 충전되어야 한다`() { 43 | val startTime = System.nanoTime() 44 | // Given 45 | val userId = 1L 46 | val rechargeAmount = 1000L 47 | val numberOfThreads = 1000 48 | 49 | // 초기 잔액 설정 50 | 51 | val executorService = Executors.newFixedThreadPool(numberOfThreads) 52 | val successfulRecharges = AtomicInteger(0) 53 | val failedRecharges = AtomicInteger(0) 54 | 55 | // When 56 | repeat(numberOfThreads) { 57 | executorService.submit { 58 | try { 59 | balanceService.recharge(testUser.id, rechargeAmount) 60 | successfulRecharges.incrementAndGet() 61 | } catch (e: Exception) { 62 | failedRecharges.incrementAndGet() 63 | } 64 | } 65 | } 66 | 67 | executorService.shutdown() 68 | executorService.awaitTermination(10, TimeUnit.SECONDS) 69 | val endTime = System.nanoTime() 70 | val duration = Duration.ofNanos(endTime - startTime) 71 | 72 | // Then 73 | val finalBalance = balanceRepository.findByUserId(userId)?.amount ?: 0L 74 | 75 | assertEquals(1, successfulRecharges.get(), "오직 한 번의 충전만 성공해야 한다.") 76 | assertEquals(999, failedRecharges.get(), "나머지는 모두 실패한다.") 77 | assertEquals(rechargeAmount, finalBalance, "최종 잔액은 초기 잔액 + 1회 충전액이어야 한다.") 78 | println("최종 잔액 : $finalBalance") 79 | println("테스트 실행 시간: ${duration.toMillis()} 밀리초") 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/payment/PaymentManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.payment 2 | 3 | import com.hhplus.concert.business.domain.entity.Payment 4 | import com.hhplus.concert.business.domain.entity.PaymentHistory 5 | import com.hhplus.concert.business.domain.entity.Reservation 6 | import com.hhplus.concert.business.domain.entity.User 7 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEvent 8 | import com.hhplus.concert.business.domain.manager.payment.event.PaymentEventPublisher 9 | import com.hhplus.concert.business.domain.repository.PaymentHistoryRepository 10 | import com.hhplus.concert.business.domain.repository.PaymentRepository 11 | import com.hhplus.concert.common.type.PaymentStatus 12 | import org.springframework.beans.factory.annotation.Qualifier 13 | import org.springframework.stereotype.Component 14 | import java.time.LocalDateTime 15 | 16 | @Component 17 | class PaymentManager( 18 | private val paymentRepository: PaymentRepository, 19 | private val paymentHistoryRepository: PaymentHistoryRepository, 20 | @Qualifier("application") private val paymentEventPublisher: PaymentEventPublisher, 21 | ) { 22 | /** 23 | * 결제를 실행한다. 24 | * - payment 를 저장한다. 25 | * - 예상치 못한 예외 발생 시, 결제 실패로 저장한다. 26 | */ 27 | fun executeAndSaveHistory( 28 | user: User, 29 | requestReservations: List, 30 | ): List { 31 | val payments = 32 | requestReservations.map { reservation -> 33 | runCatching { 34 | Payment( 35 | user = user, 36 | reservation = reservation, 37 | amount = reservation.seat.seatPrice, 38 | executedAt = LocalDateTime.now(), 39 | paymentStatus = PaymentStatus.COMPLETED, 40 | ).let { paymentRepository.save(it) } 41 | .also { 42 | paymentEventPublisher.publishPaymentEvent( 43 | PaymentEvent(it.id), 44 | ) 45 | } 46 | }.getOrElse { 47 | Payment( 48 | user = user, 49 | reservation = reservation, 50 | amount = reservation.seat.seatPrice, 51 | executedAt = LocalDateTime.now(), 52 | paymentStatus = PaymentStatus.FAILED, 53 | ).let { paymentRepository.save(it) } 54 | .also { 55 | paymentEventPublisher.publishPaymentEvent( 56 | PaymentEvent(it.id), 57 | ) 58 | } 59 | } 60 | } 61 | 62 | saveHistory(user, payments) 63 | 64 | return payments 65 | } 66 | 67 | private fun saveHistory( 68 | user: User, 69 | payments: List, 70 | ) { 71 | payments.forEach { payment -> 72 | paymentHistoryRepository.save( 73 | PaymentHistory( 74 | user = user, 75 | payment = payment, 76 | ), 77 | ) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/queue/QueueManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.queue 2 | 3 | import com.hhplus.concert.common.type.QueueStatus 4 | import com.hhplus.concert.common.util.JwtUtil 5 | import com.hhplus.concert.infrastructure.redis.QueueRedisRepository 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class QueueManager( 10 | private val queueRedisRepository: QueueRedisRepository, 11 | private val jwtUtil: JwtUtil, 12 | ) { 13 | // JWT Token 을 userId 로 생성하고, QUEUE 를 생성한다. 14 | fun enqueueAndIssueToken(userId: Long): String { 15 | val token = jwtUtil.generateToken(userId) 16 | val score = System.currentTimeMillis() 17 | 18 | queueRedisRepository.addToWaitingQueue(token, score) 19 | return token 20 | } 21 | 22 | fun getQueueStatus(token: String): QueueStatus { 23 | val userId = jwtUtil.getUserIdFromToken(token) 24 | 25 | return when { 26 | queueRedisRepository.isProcessingQueue(token) -> QueueStatus.PROCESSING 27 | queueRedisRepository.getWaitingQueuePosition(token, userId.toString()) > 0L -> QueueStatus.WAITING 28 | else -> QueueStatus.CANCELLED 29 | } 30 | } 31 | 32 | fun getPositionInWaitingStatus(token: String): Long { 33 | val userId = jwtUtil.getUserIdFromToken(token) 34 | return queueRedisRepository.getWaitingQueuePosition(token, userId.toString()) 35 | } 36 | 37 | fun updateToProcessingTokens() { 38 | val availableProcessingRoom = calculateAvailableProcessingRoom() 39 | if (availableProcessingRoom <= 0) return 40 | 41 | val tokensNeedToUpdateToProcessing = 42 | queueRedisRepository.getWaitingQueueNeedToUpdateToProcessing(availableProcessingRoom.toInt()) 43 | 44 | tokensNeedToUpdateToProcessing.forEach { token -> 45 | queueRedisRepository.updateToProcessingQueue( 46 | token = token, 47 | expirationTime = System.currentTimeMillis() + TOKEN_EXPIRATION_TIME, 48 | ) 49 | } 50 | } 51 | 52 | private fun calculateAvailableProcessingRoom(): Long { 53 | val currentProcessingCount = queueRedisRepository.getProcessingQueueCount() 54 | return (ALLOWED_PROCESSING_TOKEN_COUNT_LIMIT - currentProcessingCount).coerceAtLeast(0) 55 | } 56 | 57 | fun removeExpiredWaitingQueue() { 58 | queueRedisRepository.removeExpiredWaitingQueue(System.currentTimeMillis()) 59 | } 60 | 61 | fun removeExpiredProcessingQueue() { 62 | queueRedisRepository.removeExpiredProcessingQueue(System.currentTimeMillis()) 63 | } 64 | 65 | fun completeProcessingToken(token: String) { 66 | queueRedisRepository.removeProcessingToken(token) 67 | } 68 | 69 | /** 70 | * BatchSize : 서버가 한 번에 1000명의 대기자를 처리할 수 있다고 가정한다. 71 | * BatchInterval : 1000명의 대기자를 처리하는 데 약 5분이 걸린다고 가정한다. (대략 콘서트 예약을 완료하는 데 5분 이 걸린다고 가정) 72 | * Batches : 주어진 position (조회한 WAITING 상태의 대기열의 현재 위치) 이 처리되기까지 필요한 batch 수 73 | * 결과 값 : 필요한 batch 수에 각 배치 처리시간을 곱하여 예상되는 대기 시간을 계산한다. 74 | */ 75 | fun calculateEstimatedWaitSeconds(position: Long): Long { 76 | val batchSize = 1000L 77 | val batchInterval = 60L * 5 // 5 minutes 78 | val batches = position / batchSize 79 | return batches * batchInterval 80 | } 81 | 82 | companion object { 83 | const val ALLOWED_PROCESSING_TOKEN_COUNT_LIMIT = 1000 84 | const val TOKEN_EXPIRATION_TIME = 15L * 60L * 1000 // 15 minutes 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/common/config/CacheConfig.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.common.config 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator 6 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 7 | import com.fasterxml.jackson.module.kotlin.KotlinModule 8 | import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer 9 | import org.springframework.cache.annotation.EnableCaching 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | import org.springframework.data.redis.cache.RedisCacheConfiguration 13 | import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer 14 | import org.springframework.data.redis.serializer.RedisSerializationContext 15 | import org.springframework.data.redis.serializer.StringRedisSerializer 16 | import java.time.Duration 17 | 18 | @Configuration 19 | @EnableCaching 20 | class CacheConfig( 21 | val objectMapper: ObjectMapper, 22 | ) { 23 | @Bean 24 | fun redisCacheManagerBuilderCustomizer(): RedisCacheManagerBuilderCustomizer { 25 | val objectMapper = customizeObjectMapperForRedisCache() 26 | 27 | return RedisCacheManagerBuilderCustomizer { 28 | it 29 | .withCacheConfiguration( 30 | ONE_MIN_CACHE, 31 | redisCacheConfigurationByTtl(objectMapper, TTL_ONE_MINUTE), 32 | ).withCacheConfiguration( 33 | FIVE_MIN_CACHE, 34 | redisCacheConfigurationByTtl(objectMapper, TTL_FIVE_MINUTE), 35 | ) 36 | } 37 | } 38 | 39 | private fun redisCacheConfigurationByTtl( 40 | objectMapper: ObjectMapper, 41 | ttlInMin: Long, 42 | ): RedisCacheConfiguration = 43 | RedisCacheConfiguration 44 | .defaultCacheConfig() 45 | .computePrefixWith { "$it::" } 46 | .entryTtl(Duration.ofMinutes(ttlInMin)) 47 | .disableCachingNullValues() 48 | .serializeKeysWith( 49 | RedisSerializationContext.SerializationPair.fromSerializer( 50 | StringRedisSerializer(), 51 | ), 52 | ).serializeValuesWith( 53 | RedisSerializationContext.SerializationPair.fromSerializer( 54 | GenericJackson2JsonRedisSerializer(objectMapper), 55 | ), 56 | ) 57 | 58 | private fun customizeObjectMapperForRedisCache(): ObjectMapper = 59 | objectMapper.copy().apply { 60 | // Enable default typing for all non-final types 61 | activateDefaultTyping( 62 | BasicPolymorphicTypeValidator 63 | .builder() 64 | .allowIfBaseType(Any::class.java) 65 | .build(), 66 | ObjectMapper.DefaultTyping.EVERYTHING, 67 | ) 68 | // Configure to ignore unknown properties 69 | configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 70 | // Register module to handle Kotlin-specific serialization 71 | registerModule(KotlinModule.Builder().build()) 72 | registerModule(JavaTimeModule()) 73 | } 74 | 75 | companion object { 76 | const val ONE_MIN_CACHE = "one-min-cache" 77 | const val TTL_ONE_MINUTE = 1L 78 | const val FIVE_MIN_CACHE = "five-min-cache" 79 | const val TTL_FIVE_MINUTE = 5L 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/interfaces/scheduler/QueueSchedulerIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.scheduler 2 | 3 | import com.hhplus.concert.business.domain.manager.queue.QueueManager 4 | import com.hhplus.concert.infrastructure.redis.QueueRedisRepository 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.BeforeEach 7 | import org.junit.jupiter.api.DisplayName 8 | import org.junit.jupiter.api.Nested 9 | import org.junit.jupiter.api.Test 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 12 | import org.springframework.boot.test.context.SpringBootTest 13 | import org.springframework.data.redis.core.RedisTemplate 14 | 15 | @SpringBootTest 16 | @AutoConfigureMockMvc 17 | class QueueSchedulerIntegrationTest { 18 | @Autowired 19 | private lateinit var queueScheduler: QueueScheduler 20 | 21 | @Autowired 22 | private lateinit var redisTemplate: RedisTemplate 23 | 24 | @Autowired 25 | private lateinit var queueManager: QueueManager 26 | 27 | @Autowired 28 | private lateinit var queueRedisRepository: QueueRedisRepository 29 | 30 | @BeforeEach 31 | fun setup() { 32 | redisTemplate.execute { connection -> 33 | connection.flushAll() 34 | } 35 | } 36 | 37 | @Nested 38 | @DisplayName("[activateWaitingTokens] 테스트") 39 | inner class ActivateWaitingTokensTest { 40 | @Test 41 | fun `스케줄러가 WAITING 상태의 토큰을 PROCESSING 상태로 전환해야 한다`() { 42 | // given 43 | val waitingCount = 1050 44 | createTokens(waitingCount) 45 | 46 | // when 47 | queueScheduler.updateToProcessingTokens() 48 | 49 | // then 50 | val updatedProcessingCount = queueRedisRepository.getProcessingQueueCount() 51 | val updatedWaitingCount = queueRedisRepository.getWaitingQueueSize() 52 | 53 | assertEquals(QueueManager.ALLOWED_PROCESSING_TOKEN_COUNT_LIMIT.toLong(), updatedProcessingCount) 54 | assertEquals(50, updatedWaitingCount) 55 | } 56 | 57 | @Test 58 | fun `PROCESSING 상태의 토큰이 이미 최대 개수일 때 스케줄러가 아무 작업도 수행하지 않아야 한다`() { 59 | // given 60 | createTokens(QueueManager.ALLOWED_PROCESSING_TOKEN_COUNT_LIMIT) 61 | createTokens(5) 62 | 63 | // when 64 | queueScheduler.updateToProcessingTokens() 65 | 66 | // then 67 | val updatedProcessingCount = queueRedisRepository.getProcessingQueueCount() 68 | val updatedWaitingCount = queueRedisRepository.getWaitingQueueSize() 69 | 70 | assertEquals(QueueManager.ALLOWED_PROCESSING_TOKEN_COUNT_LIMIT.toLong(), updatedProcessingCount) 71 | assertEquals(5, updatedWaitingCount) 72 | } 73 | 74 | @Test 75 | fun `WAITING 상태의 토큰이 없을 때 스케줄러가 아무 작업도 수행하지 않아야 한다`() { 76 | // given 77 | val processingCount = 3 78 | createTokens(processingCount) 79 | 80 | // when 81 | queueScheduler.updateToProcessingTokens() 82 | 83 | // then 84 | val updatedProcessingCount = queueRedisRepository.getProcessingQueueCount() 85 | val updatedWaitingCount = queueRedisRepository.getWaitingQueueSize() 86 | 87 | assertEquals(processingCount.toLong(), updatedProcessingCount) 88 | assertEquals(0, updatedWaitingCount) 89 | } 90 | 91 | private fun createTokens(count: Int) { 92 | repeat(count) { 93 | val userId = it 94 | queueManager.enqueueAndIssueToken(userId.toLong()) 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/concert/ConcertManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.concert 2 | 3 | import com.hhplus.concert.business.domain.entity.Concert 4 | import com.hhplus.concert.business.domain.entity.ConcertSchedule 5 | import com.hhplus.concert.business.domain.entity.Seat 6 | import com.hhplus.concert.business.domain.repository.ConcertRepository 7 | import com.hhplus.concert.business.domain.repository.ConcertScheduleRepository 8 | import com.hhplus.concert.business.domain.repository.SeatRepository 9 | import com.hhplus.concert.common.error.code.ErrorCode 10 | import com.hhplus.concert.common.error.exception.BusinessException 11 | import com.hhplus.concert.common.type.ConcertStatus 12 | import com.hhplus.concert.common.type.SeatStatus 13 | import org.springframework.stereotype.Component 14 | import java.time.LocalDateTime 15 | 16 | @Component 17 | class ConcertManager( 18 | private val concertRepository: ConcertRepository, 19 | private val concertScheduleRepository: ConcertScheduleRepository, 20 | private val seatRepository: SeatRepository, 21 | ) { 22 | /** 23 | * 예약 가능한 Concert 리스트를 조회한다. 24 | */ 25 | fun getAvailableConcerts(): List = 26 | concertRepository 27 | .findAll() 28 | .filter { it.concertStatus == ConcertStatus.AVAILABLE } 29 | 30 | /** 31 | * 예약 가능한 Concert 의 스케쥴 리스트를 조회한다. 32 | * 각 스케쥴이 예약가능한 시간인 스케쥴만 리턴한다. 33 | */ 34 | fun getAvailableConcertSchedules(concertId: Long): List { 35 | validateConcertStatus(concertId) 36 | 37 | return concertScheduleRepository 38 | .findAllByConcertId(concertId) 39 | .filter { 40 | validateScheduleReservationTime( 41 | reservationAvailableAt = it.reservationAvailableAt, 42 | concertAt = it.concertAt, 43 | ) 44 | } 45 | } 46 | 47 | /** 48 | * concertId 와 scheduleId 로 Seat 리스트를 조회한다. 49 | * concert 는 예약 가능해야한다. 50 | * concertSchedule 은 예약 가능해야한다. 51 | */ 52 | fun getAvailableSeats( 53 | concertId: Long, 54 | scheduleId: Long, 55 | ): List { 56 | validateConcertStatus(concertId) 57 | 58 | val concertSchedule = 59 | concertScheduleRepository.findById(scheduleId) 60 | ?: throw BusinessException.NotFound(ErrorCode.Concert.SCHEDULE_NOT_FOUND) 61 | if (!validateScheduleReservationTime( 62 | reservationAvailableAt = concertSchedule.reservationAvailableAt, 63 | concertAt = concertSchedule.concertAt, 64 | ) 65 | ) { 66 | throw BusinessException.BadRequest(ErrorCode.Concert.UNAVAILABLE) 67 | } 68 | 69 | return seatRepository 70 | .findAllByScheduleId(scheduleId) 71 | .filter { it.seatStatus == SeatStatus.AVAILABLE } 72 | } 73 | 74 | private fun validateConcertStatus(concertId: Long) { 75 | val concert = concertRepository.findById(concertId) ?: throw BusinessException.NotFound(ErrorCode.Concert.NOT_FOUND) 76 | if (concert.concertStatus == ConcertStatus.UNAVAILABLE) throw BusinessException.BadRequest(ErrorCode.Concert.UNAVAILABLE) 77 | } 78 | 79 | private fun validateScheduleReservationTime( 80 | reservationAvailableAt: LocalDateTime, 81 | concertAt: LocalDateTime, 82 | ): Boolean { 83 | val now = LocalDateTime.now() 84 | return now.isAfter(reservationAvailableAt) && now.isBefore(concertAt) 85 | } 86 | 87 | fun findAllByScheduleId(scheduleId: Long): List = seatRepository.findAllByScheduleId(scheduleId) 88 | 89 | fun updateStatus( 90 | concert: Concert, 91 | status: ConcertStatus, 92 | ) { 93 | concert.updateStatus(status) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/service/ReservationService.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.service 2 | 3 | import com.hhplus.concert.business.application.dto.ReservationServiceDto 4 | import com.hhplus.concert.business.domain.manager.UserManager 5 | import com.hhplus.concert.business.domain.manager.concert.ConcertManager 6 | import com.hhplus.concert.business.domain.manager.queue.QueueManager 7 | import com.hhplus.concert.business.domain.manager.reservation.ReservationLockManager 8 | import com.hhplus.concert.business.domain.manager.reservation.ReservationManager 9 | import com.hhplus.concert.common.error.code.ErrorCode 10 | import com.hhplus.concert.common.error.exception.BusinessException 11 | import com.hhplus.concert.common.type.QueueStatus 12 | import org.springframework.stereotype.Service 13 | import org.springframework.transaction.annotation.Transactional 14 | 15 | @Service 16 | class ReservationService( 17 | private val userManager: UserManager, 18 | private val queueManager: QueueManager, 19 | private val concertManager: ConcertManager, 20 | private val reservationManager: ReservationManager, 21 | private val reservationLockManager: ReservationLockManager, 22 | ) { 23 | /** 24 | * 1. token 을 통해 queue 를 검증한다. (Processing 상태인지 확인한다.) 25 | * 2. queue 에서 user 를 추출한다. 26 | * 3. user, concert, schedule, seat 들의 존재 여부를 검증한다. 27 | * 3. reservation 을 생성한다. 28 | * 4. 예약이 완료되면 좌석의 상태를 UNAVAILABLE 로 변경한다. 29 | */ 30 | fun createReservations( 31 | token: String, 32 | reservationRequest: ReservationServiceDto.Request, 33 | ): List { 34 | validateQueueStatus(token) 35 | 36 | userManager.findById(reservationRequest.userId) 37 | 38 | validateReservationRequest( 39 | requestConcertId = reservationRequest.concertId, 40 | requestScheduleId = reservationRequest.scheduleId, 41 | requestSeatIds = reservationRequest.seatIds, 42 | ) 43 | 44 | return reservationLockManager 45 | .createReservations(reservationRequest) 46 | .map { 47 | ReservationServiceDto.Result( 48 | reservationId = it.id, 49 | concertId = reservationRequest.concertId, 50 | concertName = it.concertTitle, 51 | concertAt = it.concertAt, 52 | seat = 53 | ReservationServiceDto.Seat( 54 | seatNumber = it.seat.seatNumber, 55 | price = it.seat.seatPrice, 56 | ), 57 | reservationStatus = it.reservationStatus, 58 | ) 59 | } 60 | } 61 | 62 | /** 63 | * 스케쥴러를 통해 1분 간격으로 5분이 지나도 결제가 완료되지 않은 예약건의 상태를 변경한다. 64 | * - Reservation 은 RESERVATION_CANCELLED 으로 변경한다. 65 | * - Seat 은 AVAILABLE 변경한다. 66 | */ 67 | @Transactional 68 | fun cancelUnpaidReservationsAndReleaseSeats() { 69 | reservationManager.cancelReservations() 70 | } 71 | 72 | private fun validateQueueStatus(token: String) { 73 | if (queueManager.getQueueStatus(token) != QueueStatus.PROCESSING) throw BusinessException.BadRequest(ErrorCode.Queue.NOT_ALLOWED) 74 | } 75 | 76 | private fun validateReservationRequest( 77 | requestConcertId: Long, 78 | requestScheduleId: Long, 79 | requestSeatIds: List, 80 | ) { 81 | val filteredAvailableSeatsCount = 82 | concertManager 83 | .getAvailableSeats( 84 | concertId = requestConcertId, 85 | scheduleId = requestScheduleId, 86 | ).filter { it.id in requestSeatIds } 87 | .size 88 | 89 | if (requestSeatIds.size != filteredAvailableSeatsCount) throw BusinessException.BadRequest(ErrorCode.Concert.UNAVAILABLE) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/infrastructure/redis/QueueRedisRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.infrastructure.redis 2 | 3 | import org.springframework.data.redis.core.RedisTemplate 4 | import org.springframework.stereotype.Repository 5 | 6 | @Repository 7 | class QueueRedisRepository( 8 | private val redisTemplate: RedisTemplate, 9 | ) { 10 | /** 11 | * 대기열을 등록한다. 12 | */ 13 | fun addToWaitingQueue( 14 | token: String, 15 | expirationTime: Long, 16 | ) { 17 | redisTemplate.opsForZSet().add(WAITING_QUEUE_KEY, token, expirationTime.toDouble()) 18 | } 19 | 20 | fun findWaitingQueueTokenByUserId(userId: String): String? { 21 | val pattern = "*-$userId" 22 | return redisTemplate 23 | .opsForZSet() 24 | .rangeByScore(WAITING_QUEUE_KEY, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY) 25 | ?.find { it.endsWith(pattern) } 26 | ?.split("-") 27 | ?.firstOrNull() 28 | } 29 | 30 | fun removeFromWaitingQueue( 31 | token: String, 32 | userId: String, 33 | ) { 34 | redisTemplate.opsForZSet().remove(WAITING_QUEUE_KEY, "$token-$userId") 35 | } 36 | 37 | /** 38 | * WAITING 상태의 현재 대기열을 삭제하고, PROCESSING 상태를 등록한다. 39 | */ 40 | fun updateToProcessingQueue( 41 | token: String, 42 | expirationTime: Long, 43 | ) { 44 | redisTemplate.opsForZSet().remove(WAITING_QUEUE_KEY, token) 45 | redisTemplate.opsForZSet().add(PROCESSING_QUEUE_KEY, token, expirationTime.toDouble()) 46 | } 47 | 48 | /** 49 | * 조회한 Token 의 대기열이 PROCESSING 상태인지 확인한다. 50 | */ 51 | fun isProcessingQueue(token: String): Boolean { 52 | val score = redisTemplate.opsForZSet().score(PROCESSING_QUEUE_KEY, token) 53 | return score != null 54 | } 55 | 56 | /** 57 | * 현재 WAITING 상태의 대기열이 몇번째인지 순서를 리턴한다. 58 | */ 59 | fun getWaitingQueuePosition( 60 | token: String, 61 | userId: String, 62 | ): Long = redisTemplate.opsForZSet().rank(WAITING_QUEUE_KEY, token) ?: -1 63 | 64 | /** 65 | * 현재 WAITING 상태의 대기열이 총 몇개인지 확인한다. 66 | */ 67 | fun getWaitingQueueSize(): Long = redisTemplate.opsForZSet().size(WAITING_QUEUE_KEY) ?: 0 68 | 69 | /** 70 | * 현재 PROCESSING 상태의 대기열이 총 몇개인지 확인한다. 71 | */ 72 | fun getProcessingQueueCount(): Long = redisTemplate.opsForZSet().size(PROCESSING_QUEUE_KEY) ?: 0 73 | 74 | /** 75 | * WAITING 상태의 대기열 중 PROCESSING 상태로 변경 할 수 있는 수만큼의 WAITING 상태의 대기열을 가지고 온다. 76 | */ 77 | fun getWaitingQueueNeedToUpdateToProcessing(needToUpdateCount: Int): Set = 78 | redisTemplate 79 | .opsForZSet() 80 | .range(WAITING_QUEUE_KEY, 0, needToUpdateCount.toLong() - 1) 81 | ?.toSet() 82 | .orEmpty() 83 | 84 | /** 85 | * 현재 WAITING 상태의 대기열 중, 만료된 (ExpirationTime 이 현재시간보다 이전인) 대기열을 삭제한다. 86 | */ 87 | fun removeExpiredWaitingQueue(currentTime: Long) { 88 | redisTemplate.opsForZSet().removeRangeByScore(WAITING_QUEUE_KEY, Double.NEGATIVE_INFINITY, currentTime.toDouble()) 89 | } 90 | 91 | fun removeExpiredProcessingQueue(currentTime: Long) { 92 | redisTemplate.opsForZSet().removeRangeByScore(PROCESSING_QUEUE_KEY, Double.NEGATIVE_INFINITY, currentTime.toDouble()) 93 | } 94 | 95 | /** 96 | * 취소되거나 완료된 상태의 PROCESSING 대기열을 삭제한다. 97 | */ 98 | fun removeProcessingToken(token: String) { 99 | redisTemplate.opsForSet().members(PROCESSING_QUEUE_KEY)?.find { it.startsWith(token) }?.let { 100 | redisTemplate.opsForSet().remove(PROCESSING_QUEUE_KEY, it) 101 | } 102 | } 103 | 104 | companion object { 105 | const val WAITING_QUEUE_KEY = "waiting_queue" 106 | const val PROCESSING_QUEUE_KEY = "processing_queue" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/application/facade/integration/PaymentEventOutBoxServiceIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.application.facade.integration 2 | 3 | import com.hhplus.concert.business.application.service.PaymentEventOutBoxService 4 | import com.hhplus.concert.business.domain.entity.PaymentEventOutBox 5 | import com.hhplus.concert.business.domain.repository.PaymentEventOutBoxRepository 6 | import com.hhplus.concert.common.type.EventStatus 7 | import org.assertj.core.api.Assertions.assertThat 8 | import org.junit.jupiter.api.Assertions.* 9 | import org.junit.jupiter.api.Test 10 | import org.mockito.Mockito.* 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.boot.test.context.SpringBootTest 13 | import org.springframework.boot.test.mock.mockito.MockBean 14 | import org.springframework.kafka.core.KafkaTemplate 15 | import org.springframework.kafka.support.SendResult 16 | import org.springframework.test.context.ActiveProfiles 17 | import org.springframework.transaction.annotation.Transactional 18 | import java.time.LocalDateTime 19 | import java.util.concurrent.CompletableFuture 20 | 21 | @SpringBootTest 22 | @ActiveProfiles("test") 23 | @Transactional 24 | class PaymentEventOutBoxServiceIntegrationTest { 25 | @Autowired 26 | private lateinit var paymentEventOutBoxService: PaymentEventOutBoxService 27 | 28 | @Autowired 29 | private lateinit var paymentEventOutBoxRepository: PaymentEventOutBoxRepository 30 | 31 | @MockBean 32 | private lateinit var kafkaTemplate: KafkaTemplate 33 | 34 | @Test 35 | fun `5분_이상_지난_INIT_상태의_이벤트를_재시도하고_Kafka에_발행한다`() { 36 | // Given 37 | val oldEvent = PaymentEventOutBox(paymentId = 1, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusMinutes(6)) 38 | val recentEvent = 39 | PaymentEventOutBox(paymentId = 2, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusMinutes(4)) 40 | paymentEventOutBoxRepository.save(oldEvent) 41 | paymentEventOutBoxRepository.save(recentEvent) 42 | 43 | val mockSendResultFuture = mock(CompletableFuture::class.java) as CompletableFuture> 44 | `when`(kafkaTemplate.send(anyString(), anyString())).thenReturn(mockSendResultFuture) 45 | 46 | `when`(mockSendResultFuture.whenComplete(any())).thenAnswer { 47 | val callback = it.arguments[0] as (SendResult?, Throwable?) -> Unit 48 | callback.invoke(mock(SendResult::class.java) as SendResult, null) 49 | mockSendResultFuture 50 | } 51 | 52 | // When 53 | paymentEventOutBoxService.retryFailedPaymentEvent() 54 | 55 | // Then 56 | val updatedOldEvent = paymentEventOutBoxRepository.findByPaymentId(oldEvent.paymentId) 57 | val updatedRecentEvent = paymentEventOutBoxRepository.findByPaymentId(recentEvent.paymentId) 58 | 59 | assertEquals("PUBLISHED", updatedOldEvent!!.eventStatus) 60 | assertEquals("INIT", updatedRecentEvent!!.eventStatus) 61 | verify(kafkaTemplate, times(1)).send("payment-event", "payment1") 62 | verify(kafkaTemplate, never()).send("payment-event", "payment2") 63 | } 64 | 65 | @Test 66 | fun `7일_이상_지난_PUBLISHED_상태의_이벤트를_삭제한다`() { 67 | // Given 68 | val oldPublishedEvent = 69 | PaymentEventOutBox(paymentId = 1, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusDays(8)) 70 | val recentPublishedEvent = 71 | PaymentEventOutBox(paymentId = 2, eventStatus = EventStatus.INIT, publishedAt = LocalDateTime.now().minusDays(6)) 72 | paymentEventOutBoxRepository.save(oldPublishedEvent) 73 | paymentEventOutBoxRepository.save(recentPublishedEvent) 74 | 75 | // When 76 | paymentEventOutBoxService.deletePublishedPaymentEvent() 77 | 78 | // Then 79 | assertThat(paymentEventOutBoxRepository.findByPaymentId(oldPublishedEvent.paymentId)).isNull() 80 | assertThat(paymentEventOutBoxRepository.findByPaymentId(recentPublishedEvent.paymentId)!!.paymentId).isEqualTo(2L) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/service/ConcertService.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.service 2 | 3 | import com.hhplus.concert.business.application.dto.ConcertServiceDto 4 | import com.hhplus.concert.business.domain.manager.concert.ConcertManager 5 | import com.hhplus.concert.business.domain.manager.queue.QueueManager 6 | import com.hhplus.concert.common.config.CacheConfig 7 | import com.hhplus.concert.common.error.code.ErrorCode 8 | import com.hhplus.concert.common.error.exception.BusinessException 9 | import com.hhplus.concert.common.type.QueueStatus 10 | import org.springframework.cache.annotation.Cacheable 11 | import org.springframework.stereotype.Service 12 | 13 | @Service 14 | class ConcertService( 15 | private val concertManager: ConcertManager, 16 | private val queueManager: QueueManager, 17 | ) { 18 | /** 19 | * 1. token 을 통해 queue 상태를 검증한다. (processing 상태여야한다.) 20 | * 2. 현재 예약이 가능한 전체 concert 리스트를 dto 로 변환하여 리턴한다. 21 | */ 22 | @Cacheable( 23 | cacheNames = [CacheConfig.FIVE_MIN_CACHE], 24 | key = "'available-concert'", 25 | condition = "#token != null", 26 | sync = true, 27 | ) 28 | fun getAvailableConcerts(token: String): List { 29 | validateQueueStatus(token) 30 | return concertManager 31 | .getAvailableConcerts() 32 | .map { 33 | ConcertServiceDto.Concert( 34 | concertId = it.id, 35 | title = it.title, 36 | description = it.description, 37 | ) 38 | } 39 | } 40 | 41 | /** 42 | * 1. token 을 통해 queue 상태를 검증한다. (processing 상태여야한다.) 43 | * 2. 현재 예약 가능한 concert 인지 확인하고, 해당 concertSchedule 을 조회한다. 44 | * 3. dto 형태로 변환하여 리턴한다. 45 | */ 46 | @Cacheable( 47 | cacheNames = [CacheConfig.ONE_MIN_CACHE], 48 | key = "'concert-' + #concertId", 49 | condition = "#token != null && #concertId != null", 50 | sync = true, 51 | ) 52 | fun getConcertSchedules( 53 | token: String, 54 | concertId: Long, 55 | ): ConcertServiceDto.Schedule { 56 | validateQueueStatus(token) 57 | return ConcertServiceDto.Schedule( 58 | concertId = concertId, 59 | events = 60 | concertManager 61 | .getAvailableConcertSchedules(concertId) 62 | .map { 63 | ConcertServiceDto.Event( 64 | scheduleId = it.id, 65 | concertAt = it.concertAt, 66 | reservationAt = it.reservationAvailableAt, 67 | ) 68 | }, 69 | ) 70 | } 71 | 72 | /** 73 | * 1. token 을 통해 queue 상태를 검증한다. (processing 상태여야한다.) 74 | * 2. 현재 예약 가능한 concert 인지 확인하고, 해당 스케쥴의 예약 가능한 좌석을 조회한다. 75 | * 3. dto 형태로 변환하여 리턴한다. 76 | */ 77 | fun getAvailableSeats( 78 | token: String, 79 | concertId: Long, 80 | scheduleId: Long, 81 | ): ConcertServiceDto.AvailableSeat { 82 | validateQueueStatus(token) 83 | return ConcertServiceDto.AvailableSeat( 84 | concertId = concertId, 85 | seats = 86 | concertManager 87 | .getAvailableSeats( 88 | concertId = concertId, 89 | scheduleId = scheduleId, 90 | ).map { 91 | ConcertServiceDto.Seat( 92 | seatId = it.id, 93 | seatStatus = it.seatStatus, 94 | seatNumber = it.seatNumber, 95 | seatPrice = it.seatPrice, 96 | ) 97 | }, 98 | ) 99 | } 100 | 101 | private fun validateQueueStatus(token: String) { 102 | if (queueManager.getQueueStatus(token) != QueueStatus.PROCESSING) throw BusinessException.BadRequest(ErrorCode.Queue.NOT_ALLOWED) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /k6/integration/integration-test.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, sleep } from 'k6'; 3 | import { SharedArray } from 'k6/data'; 4 | import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; 5 | import { options, BASE_URL } from '../common/test-options.js'; 6 | 7 | export { options }; 8 | 9 | const users = new SharedArray('users', function () { 10 | return Array.from({ length: 1000 }, (_, i) => i + 1); 11 | }); 12 | 13 | const concerts = new SharedArray('concerts', function () { 14 | return Array.from({ length: 5 }, (_, i) => i + 1); 15 | }); 16 | 17 | const schedules = new SharedArray('schedules', function () { 18 | return Array.from({ length: 28 }, (_, i) => i + 1); 19 | }); 20 | 21 | function getRandomSeats(count) { 22 | const seats = new Set(); 23 | while (seats.size < count) { 24 | seats.add(Math.floor(Math.random() * 672) + 1); 25 | } 26 | return Array.from(seats); 27 | } 28 | 29 | export default function () { 30 | const userId = randomItem(users); 31 | 32 | // Step 1: 토큰을 얻는다. 33 | let queueResponse = http.post(`${BASE_URL}/api/v1/queue/users/${userId}`); 34 | check(queueResponse, { 'Queue token received': (r) => r.status === 200 }); 35 | let queueToken = JSON.parse(queueResponse.body).token; 36 | 37 | sleep(1); 38 | 39 | // Step 2: 콘서트가 예약 가능한지 확인한다. 40 | let availableConcerts = JSON.parse(concertsResponse.body).filter(concert => concert.available); 41 | if (availableConcerts.length === 0) { 42 | console.log('No available concerts'); 43 | return; 44 | } 45 | 46 | let selectedConcert = randomItem(availableConcerts); 47 | let concertId = selectedConcert.id; 48 | 49 | sleep(1); 50 | 51 | // Step 3: Check concert schedules availability 52 | let schedulesResponse = http.get(`${BASE_URL}/api/v1/concerts/${concertId}/schedules`, { 53 | headers: { 'QUEUE-TOKEN': queueToken }, 54 | }); 55 | check(schedulesResponse, { 'Concert schedules checked': (r) => r.status === 200 }); 56 | 57 | let availableSchedules = JSON.parse(schedulesResponse.body).filter(schedule => schedule.available); 58 | if (availableSchedules.length === 0) { 59 | console.log('No available schedules for concert ' + concertId); 60 | return; 61 | } 62 | 63 | let selectedSchedule = randomItem(availableSchedules); 64 | let scheduleId = selectedSchedule.id; 65 | 66 | sleep(1); 67 | 68 | // Step 4: Check seat availability 69 | let seatsResponse = http.get(`${BASE_URL}/api/v1/concerts/${concertId}/schedules/${scheduleId}/seats`, { 70 | headers: { 'QUEUE-TOKEN': queueToken }, 71 | }); 72 | check(seatsResponse, { 'Seats availability checked': (r) => r.status === 200 }); 73 | 74 | let availableSeats = JSON.parse(seatsResponse.body).filter(seat => seat.available); 75 | if (availableSeats.length === 0) { 76 | console.log('No available seats for concert ' + concertId + ' and schedule ' + scheduleId); 77 | return; 78 | } 79 | 80 | // Step 3: 예약을 시도한다. 81 | const seatIds = getRandomSeats(Math.floor(Math.random() * 4) + 1); // 1 to 4 random seats 82 | let reservationPayload = JSON.stringify({ 83 | userId: userId, 84 | concertId: concertId, 85 | scheduleId: scheduleId, 86 | seatIds: seatIds, 87 | }); 88 | 89 | let reservationResponse = http.post(`${BASE_URL}/api/v1/reservations`, reservationPayload, { 90 | headers: { 91 | 'Content-Type': 'application/json', 92 | 'QUEUE-TOKEN': queueToken, 93 | }, 94 | }); 95 | check(reservationResponse, { 'Reservation made': (r) => r.status === 200 }); 96 | 97 | let reservationIds = JSON.parse(reservationResponse.body).map(res => res.id); 98 | 99 | sleep(1); 100 | 101 | // Step 4: Make payment 102 | let paymentPayload = JSON.stringify({ 103 | reservationIds: reservationIds, 104 | }); 105 | 106 | let paymentResponse = http.post(`${BASE_URL}/api/v1/payment/payments/users/${userId}`, paymentPayload, { 107 | headers: { 108 | 'Content-Type': 'application/json', 109 | 'QUEUE-TOKEN': queueToken, 110 | }, 111 | }); 112 | check(paymentResponse, { 'Payment completed': (r) => r.status === 200 }); 113 | 114 | sleep(1); 115 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/interfaces/presentation/controller/QueueControllerTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.controller 2 | 3 | import com.hhplus.concert.business.domain.entity.User 4 | import com.hhplus.concert.business.domain.repository.UserRepository 5 | import com.hhplus.concert.common.constants.TokenConstants.QUEUE_TOKEN_HEADER 6 | import com.hhplus.concert.common.util.JwtUtil 7 | import com.hhplus.concert.infrastructure.redis.QueueRedisRepository 8 | import org.junit.jupiter.api.DisplayName 9 | import org.junit.jupiter.api.Nested 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 13 | import org.springframework.boot.test.context.SpringBootTest 14 | import org.springframework.http.MediaType 15 | import org.springframework.test.web.servlet.MockMvc 16 | import org.springframework.test.web.servlet.get 17 | import org.springframework.test.web.servlet.post 18 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath 19 | import org.springframework.transaction.annotation.Transactional 20 | 21 | @SpringBootTest 22 | @AutoConfigureMockMvc 23 | @Transactional 24 | class QueueControllerTest { 25 | @Autowired 26 | private lateinit var mockMvc: MockMvc 27 | 28 | @Autowired 29 | private lateinit var userRepository: UserRepository 30 | 31 | @Autowired 32 | private lateinit var queueRedisRepository: QueueRedisRepository 33 | 34 | @Autowired 35 | private lateinit var jwtUtil: JwtUtil 36 | 37 | @Nested 38 | @DisplayName("[issueQueueToken] 테스트") 39 | inner class IssueQueueTokenTest { 40 | @Test 41 | fun `유효한 사용자에 대해 새로운 토큰을 생성해야 한다`() { 42 | // given 43 | val user = userRepository.save(User(name = "User")) 44 | 45 | // when 46 | val result = 47 | mockMvc.post("/api/v1/queue/users/${user.id}") { 48 | contentType = MediaType.APPLICATION_JSON 49 | } 50 | 51 | // then 52 | result.andExpect { 53 | status { isOk() } 54 | jsonPath("$.token").exists() 55 | jsonPath("$.createdAt").exists() 56 | } 57 | } 58 | 59 | @Test 60 | fun `존재하지 않는 사용자 ID에 대해 에러를 반환해야 한다`() { 61 | // given 62 | val nonExistentUserId = 99999L 63 | 64 | // when & then 65 | mockMvc 66 | .post("/api/v1/queue/users/$nonExistentUserId") { 67 | contentType = MediaType.APPLICATION_JSON 68 | }.andExpect { 69 | status { isNotFound() } 70 | } 71 | } 72 | 73 | @Test 74 | fun `잘못된 형식의 사용자 ID를 처리해야 한다`() { 75 | mockMvc 76 | .post("/api/v1/queue/users/invalid-id") { 77 | contentType = MediaType.APPLICATION_JSON 78 | }.andExpect { 79 | status { isBadRequest() } 80 | } 81 | } 82 | } 83 | 84 | @Nested 85 | @DisplayName("[getQueueStatus] 테스트") 86 | inner class GetQueueStatusTest { 87 | @Test 88 | fun `유효하지 않은 토큰으로 요청시 인증 에러를 반환해야 한다`() { 89 | // given 90 | val invalidToken = "invalid_token" 91 | 92 | // when & then 93 | mockMvc 94 | .get("/api/v1/queue/users") { 95 | header(QUEUE_TOKEN_HEADER, invalidToken) 96 | }.andExpect { 97 | status { isUnauthorized() } 98 | } 99 | } 100 | 101 | @Test 102 | fun `토큰이 없는 요청에 대해 인증 에러를 반환해야 한다`() { 103 | // when & then 104 | mockMvc.get("/api/v1/queue/users").andExpect { 105 | status { isUnauthorized() } 106 | } 107 | } 108 | 109 | @Test 110 | fun `존재하지 않는 큐에 대한 요청시 Not Found 에러를 반환해야 한다`() { 111 | // given 112 | val user = userRepository.save(User(name = "Test User")) 113 | val token = jwtUtil.generateToken(user.id) 114 | 115 | // when & then 116 | mockMvc 117 | .get("/api/v1/queue/users") { 118 | header(QUEUE_TOKEN_HEADER, token) 119 | }.andExpect { 120 | status { isNotFound() } 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/domain/manager/reservation/ReservationManager.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.domain.manager.reservation 2 | 3 | import com.hhplus.concert.business.application.dto.ReservationServiceDto 4 | import com.hhplus.concert.business.domain.entity.Reservation 5 | import com.hhplus.concert.business.domain.repository.ConcertRepository 6 | import com.hhplus.concert.business.domain.repository.ConcertScheduleRepository 7 | import com.hhplus.concert.business.domain.repository.ReservationRepository 8 | import com.hhplus.concert.business.domain.repository.SeatRepository 9 | import com.hhplus.concert.business.domain.repository.UserRepository 10 | import com.hhplus.concert.common.error.code.ErrorCode 11 | import com.hhplus.concert.common.error.exception.BusinessException 12 | import com.hhplus.concert.common.type.ReservationStatus 13 | import com.hhplus.concert.common.type.SeatStatus 14 | import org.springframework.stereotype.Component 15 | import org.springframework.transaction.annotation.Transactional 16 | import java.time.LocalDateTime 17 | 18 | @Component 19 | class ReservationManager( 20 | private val reservationRepository: ReservationRepository, 21 | private val userRepository: UserRepository, 22 | private val concertRepository: ConcertRepository, 23 | private val concertScheduleRepository: ConcertScheduleRepository, 24 | private val seatRepository: SeatRepository, 25 | ) { 26 | /** 27 | * 1. Reservation 을 PaymentPending 상태로 생성한다. 28 | * 2. 좌석 상태를 Unavailable 로 변경한다. 29 | */ 30 | @Transactional 31 | fun createReservations(reservationRequest: ReservationServiceDto.Request): List { 32 | val user = 33 | userRepository.findById(reservationRequest.userId) 34 | ?: throw BusinessException.NotFound(ErrorCode.User.NOT_FOUND) 35 | val concert = 36 | concertRepository.findById(reservationRequest.concertId) 37 | ?: throw BusinessException.NotFound(ErrorCode.Concert.NOT_FOUND) 38 | val concertSchedule = 39 | concertScheduleRepository.findById(reservationRequest.scheduleId) 40 | ?: throw BusinessException.NotFound(ErrorCode.Concert.SCHEDULE_NOT_FOUND) 41 | val seats = seatRepository.findAllByIdAndStatusWithPessimisticLock(reservationRequest.seatIds, SeatStatus.AVAILABLE) 42 | 43 | val reservations = 44 | seats.map { seat -> 45 | val reservation = 46 | Reservation( 47 | user = user, 48 | concertTitle = concert.title, 49 | concertAt = concertSchedule.concertAt, 50 | seat = seat, 51 | reservationStatus = ReservationStatus.PAYMENT_PENDING, 52 | createdAt = LocalDateTime.now(), 53 | ) 54 | reservationRepository.save(reservation) 55 | } 56 | 57 | seatRepository.updateAllStatus(reservationRequest.seatIds, SeatStatus.UNAVAILABLE) 58 | 59 | return reservations 60 | } 61 | 62 | /** 63 | * 1. 만기된 (예약후 5분 이내에 결제가 완료되지 않은) 예약건들을 조회한다. 64 | * 2. 조회된 예약건들의 상태를 변경한다. 65 | * 3. 조회된 좌석의 상태를 변경한다. 66 | */ 67 | fun cancelReservations() { 68 | val expirationTime = LocalDateTime.now().minusMinutes(RESERVATION_EXPIRATION_MINUTE) 69 | val unpaidReservations = 70 | reservationRepository.findExpiredReservations( 71 | ReservationStatus.PAYMENT_PENDING, 72 | expirationTime, 73 | ) 74 | 75 | if (unpaidReservations.isEmpty()) return 76 | 77 | reservationRepository.updateAllStatus( 78 | reservationIds = unpaidReservations.map { it.id }, 79 | reservationStatus = ReservationStatus.RESERVATION_CANCELLED, 80 | ) 81 | 82 | seatRepository.updateAllStatus( 83 | seatIds = unpaidReservations.map { it.seat.id }, 84 | status = SeatStatus.AVAILABLE, 85 | ) 86 | } 87 | 88 | fun findAllById(reservationIds: List): List = reservationRepository.findAllById(reservationIds) 89 | 90 | fun complete(requestReservations: List) { 91 | reservationRepository.updateAllStatus( 92 | reservationIds = requestReservations.map { it.id }, 93 | ReservationStatus.PAYMENT_COMPLETED, 94 | ) 95 | } 96 | 97 | companion object { 98 | const val RESERVATION_EXPIRATION_MINUTE = 5L 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/kotlin/com/hhplus/concert/business/application/service/PaymentService.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.business.application.service 2 | 3 | import com.hhplus.concert.business.application.dto.PaymentServiceDto 4 | import com.hhplus.concert.business.domain.entity.Reservation 5 | import com.hhplus.concert.business.domain.manager.UserManager 6 | import com.hhplus.concert.business.domain.manager.concert.ConcertCacheManager 7 | import com.hhplus.concert.business.domain.manager.concert.ConcertManager 8 | import com.hhplus.concert.business.domain.manager.payment.PaymentManager 9 | import com.hhplus.concert.business.domain.manager.payment.PaymentMessageSender 10 | import com.hhplus.concert.business.domain.manager.queue.QueueManager 11 | import com.hhplus.concert.business.domain.manager.reservation.ReservationManager 12 | import com.hhplus.concert.common.error.code.ErrorCode 13 | import com.hhplus.concert.common.error.exception.BusinessException 14 | import com.hhplus.concert.common.type.ConcertStatus 15 | import com.hhplus.concert.common.type.SeatStatus 16 | import org.springframework.stereotype.Service 17 | import org.springframework.transaction.annotation.Transactional 18 | 19 | @Service 20 | class PaymentService( 21 | private val userManager: UserManager, 22 | private val reservationManager: ReservationManager, 23 | private val paymentManager: PaymentManager, 24 | private val paymentMessageSender: PaymentMessageSender, 25 | private val queueManager: QueueManager, 26 | private val concertManager: ConcertManager, 27 | private val concertCacheManager: ConcertCacheManager, 28 | ) { 29 | /** 30 | * 결제를 진행한다. 31 | * 1. reservation 의 user 와, payment 를 요청하는 user 가 일치하는지 검증 32 | * 2. payment 수행하고 paymentHistory 에 저장 33 | * 3. reservation 상태 변경 34 | * 4. 토큰의 상태 변경 -> completed 35 | */ 36 | @Transactional 37 | fun executePayment( 38 | token: String, 39 | userId: Long, 40 | reservationIds: List, 41 | ): List { 42 | val user = userManager.findById(userId) 43 | val requestReservations = reservationManager.findAllById(reservationIds) 44 | 45 | validateReservations(userId, requestReservations) 46 | 47 | // 결제를 하고, 성공하면 결제 내역을 저장한다. 48 | val executedPayments = 49 | paymentManager.executeAndSaveHistory( 50 | user, 51 | requestReservations, 52 | ) 53 | 54 | // reservation 상태를 PAYMENT_COMPLETED 로 변경한다. 55 | reservationManager.complete(requestReservations) 56 | 57 | // queue 를 완료 시킨다. 58 | queueManager.completeProcessingToken(token) 59 | 60 | // 결제 완료 후, 해당 Concert 의 좌석이 모두 매진이라면, Concert 의 상태를 UNAVAILABLE 로 변경한다. 61 | updateConcertStatusToUnavailable(requestReservations) 62 | 63 | // 결과를 반환한다. 64 | return executedPayments.map { 65 | PaymentServiceDto.Result( 66 | paymentId = it.id, 67 | amount = it.amount, 68 | paymentStatus = it.paymentStatus, 69 | ) 70 | } 71 | } 72 | 73 | private fun validateReservations( 74 | userId: Long, 75 | reservations: List, 76 | ) { 77 | if (reservations.isEmpty()) { 78 | throw BusinessException.BadRequest(ErrorCode.Payment.NOT_FOUND) 79 | } 80 | 81 | // 결제 요청을 시도하는 user 와 예악한 목록의 user 가 일치하는지 확인한다. 82 | if (reservations.any { it.user.id != userId }) { 83 | throw BusinessException.BadRequest(ErrorCode.Payment.BAD_REQUEST) 84 | } 85 | } 86 | 87 | // 예약정보에 있는 콘서트의 좌석이 모두 UNAVAILABLE 일 경우, 콘서트의 상태를 UNAVAILABLE 으로 변경한다. 88 | private fun updateConcertStatusToUnavailable(reservations: List) { 89 | val concertSchedules = reservations.map { it.seat.concertSchedule }.distinct() 90 | 91 | for (schedule in concertSchedules) { 92 | val allSeats = concertManager.findAllByScheduleId(schedule.id) 93 | if (allSeats.all { it.seatStatus == SeatStatus.UNAVAILABLE }) { 94 | val concert = schedule.concert 95 | concertManager.updateStatus(concert, ConcertStatus.UNAVAILABLE) 96 | concertCacheManager.evictConcertCache() 97 | concertCacheManager.evictConcertScheduleCache(concert.id) 98 | } 99 | } 100 | } 101 | 102 | @Transactional 103 | fun sendPaymentEventMessage(paymentId: Long) { 104 | paymentMessageSender.sendPaymentEventMessage(paymentId) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/application/facade/integration/BalanceServiceIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.application.facade.integration 2 | 3 | import com.hhplus.concert.business.application.service.BalanceService 4 | import com.hhplus.concert.business.domain.entity.Balance 5 | import com.hhplus.concert.business.domain.entity.User 6 | import com.hhplus.concert.business.domain.repository.BalanceRepository 7 | import com.hhplus.concert.business.domain.repository.UserRepository 8 | import com.hhplus.concert.common.error.exception.BusinessException 9 | import org.junit.jupiter.api.Assertions.assertEquals 10 | import org.junit.jupiter.api.Assertions.assertNotNull 11 | import org.junit.jupiter.api.Test 12 | import org.junit.jupiter.api.assertThrows 13 | import org.springframework.beans.factory.annotation.Autowired 14 | import org.springframework.boot.test.context.SpringBootTest 15 | import org.springframework.transaction.annotation.Transactional 16 | import java.time.LocalDateTime 17 | 18 | @SpringBootTest 19 | @Transactional 20 | class BalanceServiceIntegrationTest { 21 | @Autowired 22 | private lateinit var balanceService: BalanceService 23 | 24 | @Autowired 25 | private lateinit var userRepository: UserRepository 26 | 27 | @Autowired 28 | private lateinit var balanceRepository: BalanceRepository 29 | 30 | @Test 31 | fun `잔액 충전 - 새로운 Balance 생성`() { 32 | // given 33 | val user = userRepository.save(User(name = "Test User")) 34 | 35 | // when 36 | val result = balanceService.recharge(user.id, 10000) 37 | 38 | // then 39 | assertEquals(user.id, result.userId) 40 | assertEquals(10000L, result.currentAmount) 41 | 42 | val savedBalance = balanceRepository.findByUserId(user.id) 43 | assertNotNull(savedBalance) 44 | assertEquals(10000L, savedBalance?.amount) 45 | } 46 | 47 | @Test 48 | fun `잔액 충전 - 기존 Balance 업데이트`() { 49 | // given 50 | val user = userRepository.save(User(name = "Test User")) 51 | balanceRepository.save(Balance(user = user, amount = 5000, lastUpdatedAt = LocalDateTime.now())) 52 | 53 | // when 54 | val result = balanceService.recharge(user.id, 3000) 55 | 56 | // then 57 | assertEquals(user.id, result.userId) 58 | assertEquals(8000L, result.currentAmount) 59 | 60 | val updatedBalance = balanceRepository.findByUserId(user.id) 61 | assertNotNull(updatedBalance) 62 | assertEquals(8000L, updatedBalance?.amount) 63 | } 64 | 65 | @Test 66 | fun `잔액 충전 - 존재하지 않는 사용자`() { 67 | // given 68 | val nonExistentUserId = 9999L 69 | 70 | // when & then 71 | assertThrows { 72 | balanceService.recharge(nonExistentUserId, 10000) 73 | } 74 | } 75 | 76 | @Test 77 | fun `잔액 충전 - 0원 충전`() { 78 | // given 79 | val user = userRepository.save(User(name = "Test User")) 80 | 81 | // when 82 | val result = balanceService.recharge(user.id, 0) 83 | 84 | // then 85 | assertEquals(user.id, result.userId) 86 | assertEquals(0L, result.currentAmount) 87 | 88 | val savedBalance = balanceRepository.findByUserId(user.id) 89 | assertNotNull(savedBalance) 90 | assertEquals(0L, savedBalance?.amount) 91 | } 92 | 93 | @Test 94 | fun `잔액 충전 - 대량 충전`() { 95 | // given 96 | val user = userRepository.save(User(name = "Test User")) 97 | 98 | // when 99 | val result = balanceService.recharge(user.id, 1_000_000_000) // 10억원 100 | 101 | // then 102 | assertEquals(user.id, result.userId) 103 | assertEquals(1_000_000_000L, result.currentAmount) 104 | 105 | val savedBalance = balanceRepository.findByUserId(user.id) 106 | assertNotNull(savedBalance) 107 | assertEquals(1_000_000_000L, savedBalance?.amount) 108 | } 109 | 110 | @Test 111 | fun `잔액 충전 - 여러 번 충전`() { 112 | // given 113 | val user = userRepository.save(User(name = "Test User")) 114 | 115 | // when 116 | balanceService.recharge(user.id, 1000) 117 | balanceService.recharge(user.id, 2000) 118 | val result = balanceService.recharge(user.id, 3000) 119 | 120 | // then 121 | assertEquals(user.id, result.userId) 122 | assertEquals(6000L, result.currentAmount) 123 | 124 | val savedBalance = balanceRepository.findByUserId(user.id) 125 | assertNotNull(savedBalance) 126 | assertEquals(6000L, savedBalance?.amount) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/test/kotlin/com/hhplus/concert/interfaces/presentation/controller/BalanceControllerIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.hhplus.concert.interfaces.presentation.controller 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import com.hhplus.concert.business.domain.entity.Balance 5 | import com.hhplus.concert.business.domain.entity.User 6 | import com.hhplus.concert.business.domain.repository.BalanceRepository 7 | import com.hhplus.concert.business.domain.repository.UserRepository 8 | import com.hhplus.concert.interfaces.presentation.request.BalanceRequest 9 | import org.junit.jupiter.api.Assertions.assertEquals 10 | import org.junit.jupiter.api.Assertions.assertNotNull 11 | import org.junit.jupiter.api.Test 12 | import org.springframework.beans.factory.annotation.Autowired 13 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 14 | import org.springframework.boot.test.context.SpringBootTest 15 | import org.springframework.http.MediaType 16 | import org.springframework.test.web.servlet.MockMvc 17 | import org.springframework.test.web.servlet.post 18 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath 19 | import org.springframework.transaction.annotation.Transactional 20 | import java.time.LocalDateTime 21 | 22 | @SpringBootTest 23 | @AutoConfigureMockMvc 24 | @Transactional 25 | class BalanceControllerIntegrationTest { 26 | @Autowired 27 | private lateinit var mockMvc: MockMvc 28 | 29 | @Autowired 30 | private lateinit var userRepository: UserRepository 31 | 32 | @Autowired 33 | private lateinit var balanceRepository: BalanceRepository 34 | 35 | @Autowired 36 | private lateinit var objectMapper: ObjectMapper 37 | 38 | @Test 39 | fun `잔액 충전 - 성공`() { 40 | // given 41 | val user = userRepository.save(User(name = "Test User")) 42 | val rechargeRequest = BalanceRequest.Recharge(amount = 10000) 43 | 44 | // when 45 | val result = 46 | mockMvc.post("/api/v1/balance/users/${user.id}/recharge") { 47 | contentType = MediaType.APPLICATION_JSON 48 | content = objectMapper.writeValueAsString(rechargeRequest) 49 | } 50 | 51 | // then 52 | result.andExpect { 53 | status { isOk() } 54 | jsonPath("$.userId").value(user.id) 55 | jsonPath("$.currentAmount").value(10000) 56 | } 57 | 58 | val updatedBalance = balanceRepository.findByUserId(user.id) 59 | assertNotNull(updatedBalance) 60 | assertEquals(10000L, updatedBalance?.amount) 61 | } 62 | 63 | @Test 64 | fun `잔액 충전 - 존재하지 않는 사용자`() { 65 | // given 66 | val nonExistentUserId = 9999L 67 | val rechargeRequest = BalanceRequest.Recharge(amount = 10000) 68 | 69 | // when 70 | val result = 71 | mockMvc.post("/api/v1/balance/users/$nonExistentUserId/recharge") { 72 | contentType = MediaType.APPLICATION_JSON 73 | content = objectMapper.writeValueAsString(rechargeRequest) 74 | } 75 | 76 | // then 77 | result.andExpect { 78 | status { isNotFound() } 79 | } 80 | } 81 | 82 | @Test 83 | fun `잔액 충전 - 잘못된 금액`() { 84 | // given 85 | val user = userRepository.save(User(name = "Test User")) 86 | val rechargeRequest = BalanceRequest.Recharge(amount = -1000) 87 | 88 | // when 89 | val result = 90 | mockMvc.post("/api/v1/balance/users/${user.id}/recharge") { 91 | contentType = MediaType.APPLICATION_JSON 92 | content = objectMapper.writeValueAsString(rechargeRequest) 93 | } 94 | 95 | // then 96 | result.andExpect { 97 | status { isBadRequest() } 98 | } 99 | } 100 | 101 | @Test 102 | fun `잔액 충전 - 기존 잔액이 있는 경우`() { 103 | // given 104 | val user = userRepository.save(User(name = "Test User")) 105 | balanceRepository.save(Balance(user = user, amount = 5000, lastUpdatedAt = LocalDateTime.now())) 106 | val rechargeRequest = BalanceRequest.Recharge(amount = 3000) 107 | 108 | // when 109 | val result = 110 | mockMvc.post("/api/v1/balance/users/${user.id}/recharge") { 111 | contentType = MediaType.APPLICATION_JSON 112 | content = objectMapper.writeValueAsString(rechargeRequest) 113 | } 114 | 115 | // then 116 | result.andExpect { 117 | status { isOk() } 118 | jsonPath("$.userId").value(user.id) 119 | jsonPath("$.currentAmount").value(8000) 120 | } 121 | 122 | val updatedBalance = balanceRepository.findByUserId(user.id) 123 | assertNotNull(updatedBalance) 124 | assertEquals(8000L, updatedBalance?.amount) 125 | } 126 | } 127 | --------------------------------------------------------------------------------