From 760005c26684cb04b23429f71780dcafcca61098 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:35:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EC=8B=A0=EC=B2=AD=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=83=9D=EC=84=B1=EA=B8=B0=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/GiftApplicationNoGenerator.kt | 43 ++++++++++++ .../GiftApplicationNoGeneratorTest.kt | 68 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt new file mode 100644 index 00000000..450cc499 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +class GiftApplicationNoGenerator( + private val repository: GiftApplicationNoSequenceRepository, + transactionManager: PlatformTransactionManager +) { + private val transactionTemplate = TransactionTemplate(transactionManager) + + fun generate(now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String { + val sequenceDate = now.format(DATE_FORMATTER) + for (attempt in 1..MAX_RETRY_COUNT) { + try { + return transactionTemplate.execute { + val sequence = repository.findBySequenceDateForUpdate(sequenceDate) + ?: repository.saveAndFlush(GiftApplicationNoSequence(sequenceDate = sequenceDate, lastSequence = 0)) + sequence.lastSequence += 1 + "G$sequenceDate${sequence.lastSequence.toString().padStart(6, '0')}" + } ?: throw SodaException(messageKey = "common.error.invalid_request") + } catch (_: DataIntegrityViolationException) { + if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed") + } + } + throw SodaException(messageKey = "gift.application_no.generate_failed") + } + + companion object { + private val SEOUL_ZONE = ZoneId.of("Asia/Seoul") + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + private const val MAX_RETRY_COUNT = 3 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt new file mode 100644 index 00000000..64996948 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.transaction.PlatformTransactionManager +import java.time.LocalDateTime +import java.util.concurrent.Callable +import java.util.concurrent.Executors + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("선물 신청번호 채번 테스트") +class GiftApplicationNoGeneratorTest @Autowired constructor( + private val repository: GiftApplicationNoSequenceRepository, + private val transactionManager: PlatformTransactionManager +) { + @BeforeEach + fun setUp() { + repository.deleteAll() + } + + @Test + @DisplayName("같은 날짜 신청번호를 순서대로 발급한다") + fun shouldGenerateSequentialApplicationNoByDate() { + val generator = GiftApplicationNoGenerator(repository, transactionManager) + val now = LocalDateTime.of(2026, 9, 29, 12, 0) + + val first = generator.generate(now) + val second = generator.generate(now) + + assertEquals("G20260929000001", first) + assertEquals("G20260929000002", second) + } + + @Test + @DisplayName("병렬 발급 결과는 모두 중복되지 않는다") + fun shouldGenerateUniqueApplicationNoConcurrently() { + val generator = GiftApplicationNoGenerator(repository, transactionManager) + val now = LocalDateTime.of(2026, 9, 29, 12, 0) + val executor = Executors.newFixedThreadPool(4) + + val applicationNos = try { + executor.invokeAll( + (1..10).map { + Callable { generator.generate(now) } + } + ).map { it.get() } + } finally { + executor.shutdownNow() + } + + assertEquals(10, applicationNos.toSet().size) + assertEquals("G20260929000001", applicationNos.minOrNull()) + assertEquals("G20260929000010", applicationNos.maxOrNull()) + } +}