diff --git a/README.md b/README.md new file mode 100644 index 00000000..5cb46a50 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# ๐ŸŽซ ์ถ•์ œ์˜ ๋ฏผ์กฑ + +## ๐Ÿ“Œ ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +์ด ํ”„๋กœ์ ํŠธ๋Š” ์ œํ•œ๋œ ๋ฆฌ์†Œ์Šค ํ™˜๊ฒฝ(AWS t3.small EC2 ์ธ์Šคํ„ด์Šค 2๋Œ€, RDS, Redis)์—์„œ ์•ˆ์ •์ ์ธ ์šด์˜์„ ๋ชฉํ‘œ๋กœ ํ•˜๋Š” ๊ณ ์„ฑ๋Šฅ ํŽ˜์Šคํ‹ฐ๋ฒŒ ํ‹ฐ์ผ“ ์˜ˆ๋งค ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค. ์ˆœ๊ฐ„์ ์ธ ๋Œ€๊ทœ๋ชจ ํŠธ๋ž˜ํ”ฝ ์ƒํ™ฉ์—์„œ๋„ +์•ˆ์ •์ ์œผ๋กœ ๋™์ž‘ํ•˜๋„๋ก ์„ค๊ณ„๋˜์—ˆ์œผ๋ฉฐ, ํšจ์œจ์ ์ธ ๋Œ€๊ธฐ์—ด ๊ด€๋ฆฌ์™€ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์ตœ์ ํ™”ํ•ฉ๋‹ˆ๋‹ค. + +### ํ”„๋กœ์ ํŠธ ๋ชฉํ‘œ + +- ์ดˆ๋‹น 1,000๊ฑด ์ด์ƒ์˜ ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ +- ํ‰๊ท  ์‘๋‹ต ์‹œ๊ฐ„ 100ms ์ดํ•˜ +- ์‹œ์Šคํ…œ ๊ฐ€์šฉ์„ฑ 99.9% ์ด์ƒ + +## ๐Ÿš€ ํ•ต์‹ฌ ๊ธฐ๋Šฅ + +1. **์‹ค์‹œ๊ฐ„ ๋Œ€๊ธฐ์—ด ์‹œ์Šคํ…œ**: Redis ๊ธฐ๋ฐ˜์˜ ๊ณต์ •ํ•œ ๋Œ€๊ธฐ์—ด ๊ด€๋ฆฌ +2. **์•ˆ์ •์ ์ธ ๊ฒฐ์ œ ์ฒ˜๋ฆฌ**: ๋น„๋™๊ธฐ ๋ฐฉ์‹์˜ ๊ฒฐ์ œ ์ฒ˜๋ฆฌ ๋ฐ ์‹ค์‹œ๊ฐ„ ์ƒํƒœ ์กฐํšŒ +3. **ํšจ์œจ์ ์ธ ์žฌ๊ณ  ๊ด€๋ฆฌ**: Redis๋ฅผ ํ™œ์šฉํ•œ ์‹ค์‹œ๊ฐ„ ์žฌ๊ณ  ์ถ”์  +4. **๋น„๋™๊ธฐ ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ**: ์ธ๋ฉ”๋ชจ๋ฆฌ ํ ์‹œ์Šคํ…œ์„ ํ†ตํ•œ ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์ตœ์ ํ™” + +## ๐Ÿ›  ๊ธฐ์ˆ  ์Šคํƒ + +

+ Java + Spring + MySQL + Redis + Gradle + Grafana + Prometheus + k6 +

+ +## ๐Ÿ“ ์•„ํ‚คํ…์ฒ˜ + +![์•„ํ‚คํ…์ฒ˜.png](img.png) + +## ๐Ÿ”ง ํ•ต์‹ฌ ์ปดํฌ๋„ŒํŠธ + +### 1. ๋Œ€๊ธฐ์—ด ์‹œ์Šคํ…œ (WaitOrderService) + +- ์›๋ฆฌ: ์ฒญํฌ ๊ธฐ๋ฐ˜์˜ ๋Œ€๊ธฐ์—ด ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +- ๋Œ€๊ธฐ์—ด ๊ด€๋ฆฌ: + - Redis Set์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ ํ‹ฐ์ผ“์— ๋Œ€ํ•œ ๋Œ€๊ธฐ์—ด ์œ ์ง€ + - ์‚ฌ์šฉ์ž ์ง„์ž… ์‹œ ํ˜„์žฌ ๋Œ€๊ธฐ์—ด์˜ ํฌ๊ธฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋Œ€๊ธฐ ์ˆœ์„œ ํ• ๋‹น +- ์ž…์žฅ ๊ด€๋ฆฌ: + - passChunkSize ๋‹จ์œ„๋กœ ์ž…์žฅ ๊ฐ€๋Šฅํ•œ ๋ฒ”์œ„ ๊ด€๋ฆฌ + - ์ฃผ๊ธฐ์ ์œผ๋กœ ์ž…์žฅ ๊ฐ€๋Šฅ ๋ฒ”์œ„๋ฅผ passChunkSize๋งŒํผ ์ฆ๊ฐ€์‹œ์ผœ ์ˆœ์ฐจ์  ์ž…์žฅ ํ—ˆ์šฉ +- ์ž…์žฅ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ: + - ์‚ฌ์šฉ์ž์˜ ๋Œ€๊ธฐ ์ˆœ์„œ๊ฐ€ ํ˜„์žฌ ์ž…์žฅ ๊ฐ€๋Šฅ ๋ฒ”์œ„ ๋‚ด์— ์žˆ๋Š”์ง€ ํ™•์ธ + - ์žฌ๊ณ  ์—ฌ๋ถ€๋„ ํ•จ๊ป˜ ์ฒดํฌํ•˜์—ฌ ์ž…์žฅ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๊ฒฐ์ • + +### 2. ๊ฒฐ์ œ ์‹œ์Šคํ…œ (PurchaseFacadeService, PaymentService) + +- ์›๋ฆฌ: ๋น„๋™๊ธฐ ๊ฒฐ์ œ ์ฒ˜๋ฆฌ ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ +- ๊ฒฐ์ œ ํ”„๋กœ์„ธ์Šค: + - ๊ฒฐ์ œ ์š”์ฒญ ์‹œ UUID ๊ธฐ๋ฐ˜์˜ ๊ฒฐ์ œ ID ์ƒ์„ฑ ํ›„ ๋น„๋™๊ธฐ๋กœ ๊ฒฐ์ œ ์ฒ˜๋ฆฌ ์‹œ์ž‘ + - CompletableFuture๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋น„๋™๊ธฐ ๊ฒฐ์ œ ์ฒ˜๋ฆฌ ๊ตฌํ˜„ +- ์ƒํƒœ ๊ด€๋ฆฌ: + - Caffeine ์บ์‹œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒฐ์ œ ์ƒํƒœ ๊ด€๋ฆฌ + - ๊ฒฐ์ œ ID๋ฅผ ํ‚ค๋กœ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒฐ์ œ ์ •๋ณด ๋ฐ ์ƒํƒœ ์ €์žฅ +- ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„ ์ฒ˜๋ฆฌ: + - ๊ฒฐ์ œ ์„ฑ๊ณต ์‹œ QueueService๋ฅผ ํ†ตํ•ด ๊ตฌ๋งค ์ •๋ณด ์ฒ˜๋ฆฌ + - ๊ฒฐ์ œ ์‹คํŒจ ์‹œ CompensationService๋ฅผ ํ†ตํ•ด ์žฌ๊ณ  ๋ฐ ์ƒํƒœ ๋กค๋ฐฑ + +### 3. ์žฌ๊ณ  ๊ด€๋ฆฌ (TicketStockCountRedisRepository) + +- ์›๋ฆฌ: Redis๋ฅผ ํ™œ์šฉํ•œ ์‹ค์‹œ๊ฐ„ ์žฌ๊ณ  ๊ด€๋ฆฌ +- ์žฌ๊ณ  ๊ด€๋ฆฌ: + - Redis์— ๊ฐ ํ‹ฐ์ผ“์˜ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰ ์ €์žฅ + - ์›์ž์  ๊ฐ์†Œ ์—ฐ์‚ฐ์„ ์‚ฌ์šฉํ•˜์—ฌ ๋™์‹œ์„ฑ ๋ฌธ์ œ ํ•ด๊ฒฐ +- ์žฌ๊ณ  ๋™๊ธฐํ™”: + - TicketScheduleService๋ฅผ ํ†ตํ•ด ์ฃผ๊ธฐ์ ์œผ๋กœ Redis์˜ ์žฌ๊ณ  ์ •๋ณด ์—…๋ฐ์ดํŠธ + - ํŒ๋งค ์‹œ์ž‘ ์ „ ๋˜๋Š” ์ง„ํ–‰ ์ค‘์ธ ํ‹ฐ์ผ“์˜ ์žฌ๊ณ  ์ •๋ณด๋งŒ Redis์— ์œ ์ง€ + +### 4. ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ (QueueService) + +- ์›๋ฆฌ: ์ธ๋ฉ”๋ชจ๋ฆฌ ํ๋ฅผ ํ™œ์šฉํ•œ ๋น„๋™๊ธฐ ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ +- ์ฃผ๋ฌธ ์ ‘์ˆ˜: + - InMemoryQueue์— ๊ตฌ๋งค ๋ฐ์ดํ„ฐ(PurchaseData) ์ €์žฅ + - ํ๊ฐ€ ๊ฐ€๋“ ์ฐผ์„ ๊ฒฝ์šฐ ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„๋ฅผ ์ ์šฉํ•œ ์žฌ์‹œ๋„ ๋กœ์ง ๊ตฌํ˜„ +- ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ: + - ์ฃผ๊ธฐ์ ์œผ๋กœ(5์ดˆ๋งˆ๋‹ค) ํ์—์„œ ๋ฐฐ์น˜ ๋‹จ์œ„๋กœ ์ฃผ๋ฌธ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ + - ๋ฐฐ์น˜ ํฌ๊ธฐ๋Š” ํ์˜ ํ˜„์žฌ ํฌ๊ธฐ์— ๋”ฐ๋ผ ๋™์ ์œผ๋กœ ์กฐ์ • +- ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ: + - ์ฃผ๋ฌธ ์ •๋ณด์™€ ์ฒดํฌ์ธ ์ •๋ณด๋ฅผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฌถ์–ด ์ผ๊ด„ ์ฒ˜๋ฆฌ + - JDBC batch update๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ์‚ฐ ์ตœ์ ํ™” +- ์žฅ์•  ๋ณต๊ตฌ: + - ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์‹œ ๋กœ๊ทธ ํŒŒ์ผ์„ ๋ถ„์„ํ•˜์—ฌ ๋ฏธ์ฒ˜๋ฆฌ๋œ ์ฃผ๋ฌธ ๋ณต๊ตฌ + - ์ฒ˜๋ฆฌ ์‹คํŒจํ•œ ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ์žฌ์‹œ๋„ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ๊ตฌํ˜„ + +## ๐Ÿ“ˆ ์„ฑ๋Šฅ ์ตœ์ ํ™” ์ „๋žต + +1. **์ธ๋ฉ”๋ชจ๋ฆฌ ์บ์‹ฑ ๋ฐ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ** + - Redis๋ฅผ ํ™œ์šฉํ•œ ๋Œ€๊ธฐ์—ด, ์žฌ๊ณ , ๊ฒฐ์ œ ์ƒํƒœ ๊ด€๋ฆฌ + - Caffeine ์บ์‹œ๋ฅผ ์ด์šฉํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ ˆ๋ฒจ ์บ์‹ฑ + - ์บ์‹œ ์›Œ๋ฐ์œผ๋กœ ์ฝœ๋“œ ์Šคํƒ€ํŠธ ๋ฌธ์ œ ํ•ด๊ฒฐ ๋ฐ ์ดˆ๊ธฐ ์‘๋‹ต ์‹œ๊ฐ„ ๊ฐœ์„  + +2. **๋น„๋™๊ธฐ ๋ฐ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ** + - CompletableFuture๋ฅผ ์ด์šฉํ•œ ๋น„๋™๊ธฐ ๊ฒฐ์ œ ์ฒ˜๋ฆฌ + - ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ์ปค์Šคํ…€ ์ธ๋ฉ”๋ชจ๋ฆฌ ํ ๊ตฌํ˜„ + - ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋ฅผ ํ†ตํ•œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์“ฐ๊ธฐ ์ตœ์ ํ™” + +3. **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ตœ์ ํ™”** + - Skip Lock ์ ์šฉ์œผ๋กœ ๋™์‹œ์„ฑ ์ œ์–ด ๋ฐ ์„ฑ๋Šฅ ํ–ฅ์ƒ (`SELECT ... FOR UPDATE SKIP LOCKED`) + - ์ปค๋„ฅ์…˜ ํ’€ ์ตœ์ ํ™” (HikariCP ํŠœ๋‹) + - ์ธ๋ฑ์Šค ์ตœ์ ํ™” ๋ฐ ์ฟผ๋ฆฌ ํŠœ๋‹ + - JDBC ๋ฐฐ์น˜ ์—…๋ฐ์ดํŠธ๋ฅผ ํ†ตํ•œ ๋ฒŒํฌ ์—ฐ์‚ฐ ํšจ์œจํ™” + +4. **๋Œ€๊ธฐ์—ด ์‹œ์Šคํ…œ ์„ค๊ณ„** + - Redis Sorted Set์„ ํ™œ์šฉํ•œ ๊ณต์ •ํ•œ ๋Œ€๊ธฐ์—ด ๊ด€๋ฆฌ + - ์ฒญํฌ ๋‹จ์œ„์˜ ์ž…์žฅ ์ฒ˜๋ฆฌ๋กœ ์‹œ์Šคํ…œ ๋ถ€ํ•˜ ๋ถ„์‚ฐ + - ๋™์  ๋Œ€๊ธฐ์—ด ํฌ๊ธฐ ์กฐ์ •์œผ๋กœ ๋ฆฌ์†Œ์Šค ํ™œ์šฉ ์ตœ์ ํ™” + +5. **๋„คํŠธ์›Œํฌ ๋ฐ ์‘๋‹ต ์ตœ์ ํ™”** + - JSON ํŽ˜์ด๋กœ๋“œ ์ตœ์†Œํ™”๋กœ ๋„คํŠธ์›Œํฌ ๋ถ€ํ•˜ ๊ฐ์†Œ + - ์‘๋‹ต ๋ฐ์ดํ„ฐ ์••์ถ• ์ ์šฉ + - Keep-Alive ์—ฐ๊ฒฐ ํ™œ์šฉ์œผ๋กœ ์—ฐ๊ฒฐ ๋น„์šฉ ๊ฐ์†Œ + +6. **์‹œ์Šคํ…œ ์•ˆ์ •์„ฑ ๋ฐ ๋ณต๊ตฌ ์ „๋žต** + - ์ ์ ˆํ•œ ํƒ€์ž„์•„์›ƒ ์„ค์ • ๋ฐ ์„œํ‚ท ๋ธŒ๋ ˆ์ด์ปค ํŒจํ„ด ์ ์šฉ + - ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„๋ฅผ ํ™œ์šฉํ•œ ์žฌ์‹œ๋„ ๋กœ์ง ๊ตฌํ˜„ + - ์žฅ์•  ์ƒํ™ฉ ๋Œ€๋น„ ๋ณต๊ตฌ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ๊ตฌํ˜„ (๋กœ๊ทธ ๊ธฐ๋ฐ˜ ๋ณต๊ตฌ) + +7. **๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐ ์„ฑ๋Šฅ ๋ถ„์„** + - Grafana์™€ Prometheus๋ฅผ ํ™œ์šฉํ•œ ์‹ค์‹œ๊ฐ„ ์‹œ์Šคํ…œ ๋ชจ๋‹ˆํ„ฐ๋ง + - ์‚ฌ์šฉ์ž ์ •์˜ ๋ฉ”ํŠธ๋ฆญ์„ ํ†ตํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ชจ๋‹ˆํ„ฐ๋ง + - ์„ฑ๋Šฅ ๋ณ‘๋ชฉ ์ง€์  ์‹๋ณ„ ๋ฐ ์ง€์†์ ์ธ ๊ฐœ์„  + +## ๐Ÿ‘ฅ ํŒ€ ์†Œ๊ฐœ + +์ €ํฌ๋Š” **ํŒ€ twoDari**์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž์™€ ์ถ•์ œ๋ฅผ ์ด์–ด์ฃผ๋Š” ๋‹ค๋ฆฌ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. + +| ์ด๋ฆ„ | ์—ญํ•  | ์ฃผ์š” ๊ธฐ์—ฌ | GitHub | +|-----|-----|--------------------------------------|--------------------------------------------------| +| ๊น€ํ˜„์ข… | ๋ฐฑ์—”๋“œ | MySQL๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ, ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ, ํ”„๋ก ํŠธ์—”๋“œ ํ™”๋ฉด ๊ตฌ์„ฑ | [@bellringstar](https://github.com/bellringstar) | +| ๊น€๊ทœ์› | ๋ฐฑ์—”๋“œ | ์บ์‹ฑ์ „๋žต, Redis๋ฅผ ์‚ฌ์šฉํ•œ ๋Œ€๊ธฐ์—ด, ํ”„๋ก ํŠธ์—”๋“œ ํ™”๋ฉด ๊ตฌ์„ฑ | [@kkyu0718](https://github.com/kkyu0718) | +| ๊น€ํ˜„์ค€ | ๋ฐฑ์—”๋“œ | devops, ์Šค์ผ€์ฅด๋ง ์ž‘์—…, ๋‹ค์–‘ํ•œ ๋ชจ๋“ˆ ๊ฐ„ ์—ฐ๊ณ„ ๋ฐ ์„ฑ๋Šฅ ๊ฐœ์„  | [@HyeonJun0530](https://github.com/HyeonJun0530) | +| ๋ฐ•๋ฏผ์ง€ | ๋ฐฑ์—”๋“œ | Redis๋ฅผ ์‚ฌ์šฉํ•œ ๋Œ€๊ธฐ์—ด ๋กœ์ง, ํ”„๋กœ์ ํŠธ ๋ชจ๋“ˆํ™” | [@minnim1010](https://github.com/minnim1010) | + +## ๐Ÿ“Š ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ + +![test_result.png](img_1.png) \ No newline at end of file diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index e3ccfd35..00000000 --- a/backend/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# ์ถ•์ œ์˜ ๋ฏผ์กฑ - -### 1. ๐ŸŽŠ ์„œ๋น„์Šค ์†Œ๊ฐœ -์ถ•์ œ์˜ ๋ฏผ์กฑ์€ ์„ ์ฐฉ์ˆœ ํ‹ฐ์ผ“ํŒ… ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ๊ฐœ์ตœ ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค. - ---- - -### 2. ๐Ÿ‘ฌ ํŒ€ ์†Œ๊ฐœ - -์•ˆ๋…•ํ•˜์„ธ์š”, ์‚ฌ์šฉ์ž์™€ ์ถ•์ œ๋ฅผ ์ด์–ด์ฃผ๋Š” ๋‹ค๋ฆฌ, ํŒ€ Dari์ž…๋‹ˆ๋‹ค! - -- ๊น€ํ˜„์ข… -- ๊น€๊ทœ์› -- ๊น€ํ˜„์ค€ -- ๋ฐ•๋ฏผ์ง€ - ---- - -### 3. ๐Ÿ’ป ํ•ต์‹ฌ ๊ธฐ๋Šฅ - -- **๋‹ค์–‘ํ•œ ์ถ•์ œ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.** -- **์ถ•์ œ ๊ด€๋ฆฌ ์กฐ์ง์„ ๋งŒ๋“ค์–ด ์ƒˆ๋กœ์šด ์ถ•์ œ๋ฅผ ๊ฐœ์ตœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.** -- **์ƒˆ๋กœ์šด ์ถ•์ œ์˜ ์ฐธ์—ฌ์ž๋“ค์„ ์„ ์ฐฉ์ˆœ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ชจ์ง‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.** - ---- - -### 4. ๐ŸŽฎ ๊ธฐ์ˆ  ์Šคํƒ -- **Spring Boot** -- **Spring MVC** -- **Spring Data JPA** -- **queryDSL** -- **Spring REST docs** - -- **AWS S3** -- **AWS EC2** -- **AWS RDS** - ---- - ---- diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/dto/FestivalCreateRequest.java b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/dto/FestivalCreateRequest.java index ba0cd87d..a743b5f0 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/dto/FestivalCreateRequest.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/dto/FestivalCreateRequest.java @@ -14,10 +14,8 @@ import static com.wootecam.festivals.domain.festival.util.FestivalValidConstant.TITLE_BLANK_MESSAGE; import static com.wootecam.festivals.domain.festival.util.FestivalValidConstant.TITLE_SIZE_MESSAGE; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.wootecam.festivals.domain.festival.entity.Festival; import com.wootecam.festivals.domain.member.entity.Member; -import com.wootecam.festivals.global.utils.CustomLocalDateTimeSerializer; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotBlank; @@ -33,12 +31,10 @@ public record FestivalCreateRequest(@NotBlank(message = TITLE_BLANK_MESSAGE) @Size(max = MAX_DESCRIPTION_LENGTH, message = DESCRIPTION_SIZE_MESSAGE) String description, - @JsonSerialize(using = CustomLocalDateTimeSerializer.class) @NotNull(message = START_TIME_NULL_MESSAGE) @Future(message = START_TIME_FUTURE_MESSAGE) LocalDateTime startTime, - @JsonSerialize(using = CustomLocalDateTimeSerializer.class) @NotNull(message = END_TIME_NULL_MESSAGE) @Future(message = END_TIME_FUTURE_MESSAGE) LocalDateTime endTime) { diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/entity/Festival.java b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/entity/Festival.java index dc979930..198c4411 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/entity/Festival.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/entity/Festival.java @@ -3,7 +3,6 @@ import com.wootecam.festivals.domain.festival.util.FestivalValidator; import com.wootecam.festivals.domain.member.entity.Member; import com.wootecam.festivals.global.audit.BaseEntity; -import com.wootecam.festivals.global.utils.DateTimeUtils; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -74,8 +73,8 @@ private Festival(Member admin, String title, String description, String festival this.title = title; this.description = description; this.festivalImg = festivalImg; - this.startTime = DateTimeUtils.normalizeDateTime(startTime); - this.endTime = DateTimeUtils.normalizeDateTime(endTime); + this.startTime = startTime; + this.endTime = endTime; this.festivalPublicationStatus = festivalPublicationStatus == null ? FestivalPublicationStatus.PUBLISHED : festivalPublicationStatus; this.festivalProgressStatus = diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/repository/FestivalRepository.java b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/repository/FestivalRepository.java index 8f13364e..2c051bc6 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/repository/FestivalRepository.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/repository/FestivalRepository.java @@ -32,11 +32,11 @@ public interface FestivalRepository extends JpaRepository { ) FROM Festival f JOIN f.admin a - WHERE (f.startTime > :startTime OR (f.startTime = :startTime AND f.id < :id)) + WHERE (f.startTime > :startTime OR (f.startTime = :startTime AND f.id > :id)) AND f.isDeleted = false AND f.festivalPublicationStatus != 'DRAFT' AND f.startTime > :now - ORDER BY f.startTime ASC, f.id ASC + ORDER BY f.startTime ASC, f.id DESC """) List findUpcomingFestivalsBeforeCursor(@Param("startTime") LocalDateTime startTime, @Param("id") long id, diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/service/FestivalService.java b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/service/FestivalService.java index 05204493..cb1d0223 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/service/FestivalService.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/domain/festival/service/FestivalService.java @@ -18,6 +18,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -44,6 +45,7 @@ public class FestivalService { * @return ์ƒ์„ฑ๋œ ์ถ•์ œ์˜ ID๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต DTO */ @Transactional + @CacheEvict(value = "festivalsFirstPage") public FestivalIdResponse createFestival(FestivalCreateRequest requestDto, Long adminId) { Member admin = memberRepository.findById(adminId) .orElseThrow(() -> new ApiException(GlobalErrorCode.INVALID_REQUEST_PARAMETER, "์œ ํšจํ•˜์ง€ ์•Š๋Š” ๋ฉค๋ฒ„์ž…๋‹ˆ๋‹ค.")); @@ -99,7 +101,8 @@ public FestivalResponse getFestivalDetail(Long festivalId) { value = "festivalsFirstPage", key = "#cursorTime + '_' + #cursorId + '_' + #pageSize", condition = "#cursorTime == null && #cursorId == null && #pageSize > 0" - ) public KeySetPageResponse getFestivals(LocalDateTime cursorTime, + ) + public KeySetPageResponse getFestivals(LocalDateTime cursorTime, Long cursorId, int pageSize) { LocalDateTime now = DateTimeUtils.normalizeDateTime(LocalDateTime.now()); @@ -107,21 +110,19 @@ public FestivalResponse getFestivalDetail(Long festivalId) { List festivals = festivalRepository.findUpcomingFestivalsBeforeCursor( cursorTime != null ? cursorTime : now, - cursorId != null ? cursorId : Long.MAX_VALUE, + cursorId != null ? cursorId : 0L, // ๋ณ€๊ฒฝ: Long.MAX_VALUE ๋Œ€์‹  0L ์‚ฌ์šฉ now, pageRequest); boolean hasNext = festivals.size() > pageSize; List pageContent = hasNext ? festivals.subList(0, pageSize) : festivals; - LocalDateTime nextCursorTime = null; - Long nextCursorId = null; + Cursor nextCursor = null; if (hasNext) { FestivalListResponse lastFestival = pageContent.get(pageContent.size() - 1); - nextCursorTime = lastFestival.startTime(); - nextCursorId = lastFestival.festivalId(); + nextCursor = new Cursor(lastFestival.startTime(), lastFestival.festivalId()); } - return new KeySetPageResponse<>(pageContent, new Cursor(nextCursorTime, nextCursorId), hasNext); + return new KeySetPageResponse<>(pageContent, nextCursor, hasNext); } } diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/entity/Ticket.java b/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/entity/Ticket.java index 0d7f1a5b..8b7b84b0 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/entity/Ticket.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/entity/Ticket.java @@ -100,4 +100,9 @@ public Purchase createPurchase(Member member) { .purchaseStatus(PurchaseStatus.PURCHASED) .build(); } + + public boolean isSaleOnTime(LocalDateTime now) { + return (startSaleTime.isEqual(now) || startSaleTime.isBefore(now)) && ((endSaleTime.isEqual(now) + || endSaleTime.isAfter(now))); + } } diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/repository/TicketStockJdbcRepository.java b/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/repository/TicketStockJdbcRepository.java index 710ed8d5..f9d44fc9 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/repository/TicketStockJdbcRepository.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/repository/TicketStockJdbcRepository.java @@ -3,6 +3,7 @@ import com.wootecam.festivals.domain.ticket.entity.TicketStock; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.BatchPreparedStatementSetter; @@ -16,13 +17,16 @@ public class TicketStockJdbcRepository { private final JdbcTemplate jdbcTemplate; public void saveTicketStocks(List ticketStocks) { - String sql = "INSERT INTO ticket_stock (ticket_id) VALUES (?)"; + String sql = "INSERT INTO ticket_stock (ticket_id, created_at, updated_at) VALUES (?, ?, ?)"; + Timestamp now = new Timestamp(System.currentTimeMillis()); jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int index) throws SQLException { TicketStock ticketStock = ticketStocks.get(index); ps.setLong(1, ticketStock.getTicket().getId()); + ps.setTimestamp(2, now); + ps.setTimestamp(3, now); } @Override diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/service/TicketService.java b/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/service/TicketService.java index f651f3e6..bf8f1c60 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/service/TicketService.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/domain/ticket/service/TicketService.java @@ -9,12 +9,18 @@ import com.wootecam.festivals.domain.ticket.dto.TicketResponse; import com.wootecam.festivals.domain.ticket.entity.Ticket; import com.wootecam.festivals.domain.ticket.entity.TicketStock; +import com.wootecam.festivals.domain.ticket.repository.CurrentTicketWaitRedisRepository; +import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository; import com.wootecam.festivals.domain.ticket.repository.TicketRepository; +import com.wootecam.festivals.domain.ticket.repository.TicketStockCountRedisRepository; import com.wootecam.festivals.domain.ticket.repository.TicketStockJdbcRepository; import com.wootecam.festivals.global.exception.type.ApiException; +import com.wootecam.festivals.global.utils.TimeProvider; +import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +38,12 @@ public class TicketService { private final FestivalRepository festivalRepository; private final TicketCacheService ticketCacheService; + private final TicketInfoRedisRepository ticketInfoRedisRepository; + private final TicketStockCountRedisRepository ticketStockCountRedisRepository; + private final CurrentTicketWaitRedisRepository currentTicketWaitRedisRepository; + + private final TimeProvider timeProvider; + /** * ํ‹ฐ์ผ“ ์ƒ์„ฑ * @@ -40,6 +52,7 @@ public class TicketService { * @return ์ƒ์„ฑ๋œ ํ‹ฐ์ผ“์˜ ID */ @Transactional + @CacheEvict(value = "ticketList", key = "#festivalId") public TicketIdResponse createTicket(Long festivalId, TicketCreateRequest request) { log.debug("ํ‹ฐ์ผ“ ์ƒ์„ฑ ์š”์ฒญ - ์ถ•์ œ ID: {}", festivalId); @@ -60,6 +73,17 @@ public TicketIdResponse createTicket(Long festivalId, TicketCreateRequest reques TicketIdResponse response = new TicketIdResponse(newTicket.getId()); log.debug("ํ‹ฐ์ผ“ ์ƒ์„ฑ ์™„๋ฃŒ - ํ‹ฐ์ผ“ ID: {}", response.ticketId()); + LocalDateTime now = timeProvider.getCurrentTime(); + if (newTicket.isSaleOnTime(now) || now.plusMinutes(10).isAfter(newTicket.getStartSaleTime()) + || now.minusMinutes(1).isAfter(newTicket.getStartSaleTime())) { + ticketInfoRedisRepository.setTicketInfo(newTicket.getId(), newTicket.getStartSaleTime(), + newTicket.getEndSaleTime()); + currentTicketWaitRedisRepository.addCurrentTicketWait(newTicket.getId()); + ticketStockCountRedisRepository.setTicketStockCount(newTicket.getId(), (long) newTicket.getQuantity()); + + log.debug("ํ‹ฐ์ผ“ ์ •๋ณด redis ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ - ํ‹ฐ์ผ“ ID: {}, ํŒ๋งค ์‹œ์ž‘ ์‹œ๊ฐ: {}, ํŒ๋งค ์ข…๋ฃŒ ์‹œ๊ฐ: {}", newTicket.getId(), + newTicket.getStartSaleTime(), newTicket.getEndSaleTime()); + } return response; } diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/global/config/RedisConfig.java b/backend/api-server/src/main/java/com/wootecam/festivals/global/config/RedisConfig.java deleted file mode 100644 index d8de043f..00000000 --- a/backend/api-server/src/main/java/com/wootecam/festivals/global/config/RedisConfig.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.wootecam.festivals.global.config; - - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -@Configuration -public class RedisConfig { - - @Value("${spring.data.redis.host}") - private String redisHost; - - @Value("${spring.data.redis.port}") - private int redisPort; - - @Bean - public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(redisHost, redisPort); - } - - @Bean - public RedisTemplate redisTemplate() { - RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); - redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new StringRedisSerializer()); - return redisTemplate; - } -} diff --git a/backend/api-server/src/main/java/com/wootecam/festivals/global/queue/service/QueueService.java b/backend/api-server/src/main/java/com/wootecam/festivals/global/queue/service/QueueService.java index b2615171..e54ca0e8 100644 --- a/backend/api-server/src/main/java/com/wootecam/festivals/global/queue/service/QueueService.java +++ b/backend/api-server/src/main/java/com/wootecam/festivals/global/queue/service/QueueService.java @@ -14,7 +14,6 @@ import com.wootecam.festivals.global.queue.dto.PurchaseData; import com.wootecam.festivals.global.queue.exception.QueueFullException; import com.wootecam.festivals.global.utils.TimeProvider; -import jakarta.annotation.PostConstruct; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; @@ -95,7 +94,7 @@ public void addPurchase(PurchaseData purchaseData) { } // ์ฃผ๊ธฐ์ ์œผ๋กœ ํ์˜ ๊ตฌ๋งค ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฉ”์„œ๋“œ - @Scheduled(fixedRate = 5000) // 5์ดˆ๋งˆ๋‹ค ์‹คํ–‰ + @Scheduled(fixedRate = 3000) // 3์ดˆ๋งˆ๋‹ค ์‹คํ–‰ public void processPurchases() { int batchSize = calculateOptimalBatchSize(); List batch = queue.pollBatch(batchSize); @@ -277,7 +276,7 @@ private void synchronizeTicketStock() { } - @PostConstruct + // @PostConstruct public void recoverQueue() { log.debug("Starting queue recovery process"); Set addedPurchases = new HashSet<>(); diff --git a/backend/api-server/src/test/java/com/wootecam/festivals/domain/ticket/service/TicketServiceTest.java b/backend/api-server/src/test/java/com/wootecam/festivals/domain/ticket/service/TicketServiceTest.java index ddcd1edc..4dec8a69 100644 --- a/backend/api-server/src/test/java/com/wootecam/festivals/domain/ticket/service/TicketServiceTest.java +++ b/backend/api-server/src/test/java/com/wootecam/festivals/domain/ticket/service/TicketServiceTest.java @@ -12,8 +12,12 @@ import com.wootecam.festivals.domain.ticket.dto.TicketIdResponse; import com.wootecam.festivals.domain.ticket.dto.TicketListResponse; import com.wootecam.festivals.domain.ticket.entity.Ticket; +import com.wootecam.festivals.domain.ticket.entity.TicketInfo; import com.wootecam.festivals.domain.ticket.entity.TicketStock; +import com.wootecam.festivals.domain.ticket.repository.CurrentTicketWaitRedisRepository; +import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository; import com.wootecam.festivals.domain.ticket.repository.TicketRepository; +import com.wootecam.festivals.domain.ticket.repository.TicketStockCountRedisRepository; import com.wootecam.festivals.domain.ticket.repository.TicketStockRepository; import com.wootecam.festivals.global.exception.type.ApiException; import com.wootecam.festivals.utils.SpringBootTestConfig; @@ -25,6 +29,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; @SpringBootTest class TicketServiceTest extends SpringBootTestConfig { @@ -44,9 +49,22 @@ class TicketServiceTest extends SpringBootTestConfig { @Autowired private TicketStockRepository ticketStockRepository; + @Autowired + private TicketInfoRedisRepository ticketInfoRedisRepository; + + @Autowired + private TicketStockCountRedisRepository ticketStockCountRedisRepository; + + @Autowired + private CurrentTicketWaitRedisRepository currentTicketWaitRedisRepository; + + @Autowired + private RedisTemplate redisTemplate; + @BeforeEach void setUp() { clear(); + redisTemplate.getConnectionFactory().getConnection().flushAll(); } @Nested @@ -82,11 +100,65 @@ void it_returns_ticket_id_when_ticket_is_created() { TicketIdResponse ticketIdResponse = ticketService.createTicket(saveFestival.getId(), ticketCreateRequest); Optional findTicket = ticketRepository.findById(ticketIdResponse.ticketId()); // Then + Long newTicketId = ticketIdResponse.ticketId(); + assertAll( () -> assertThat(ticketIdResponse).isNotNull(), () -> assertThat(findTicket.isPresent()).isTrue(), - () -> assertThat(ticketStockRepository.findAll()).hasSize(findTicket.get().getQuantity()) - ); + () -> assertThat(ticketStockRepository.findAll()).hasSize(findTicket.get().getQuantity()), + () -> { + TicketInfo ticketInfo = ticketInfoRedisRepository.getTicketInfo(newTicketId); + assertThat(ticketInfo.startSaleTime()).isEqualTo(findTicket.get().getStartSaleTime()); + assertThat(ticketInfo.endSaleTime()).isEqualTo(findTicket.get().getEndSaleTime()); + }, + () -> assertThat(ticketStockCountRedisRepository.getTicketStockCount(newTicketId)).isEqualTo( + findTicket.get().getQuantity()), + () -> assertThat(currentTicketWaitRedisRepository.getCurrentTicketWait()).contains(newTicketId)); + } + + @Test + @DisplayName("์•ž์œผ๋กœ 10๋ถ„ ์ด๋‚ด ํŒ๋งค๋˜๋Š” ํ‹ฐ์ผ“ ์ƒ์„ฑ์— ์„ฑ๊ณตํ•˜๋ฉด redis์— ์ •๋ณด๋ฅผ ์—…๋ฐ์ดํŠธํ•œ๋‹ค.") + void it_returns_ticket_id_when_ticket_in_sale_10_min_is_created_() { + // Given + LocalDateTime now = LocalDateTime.now(); + + Member admin = Member.builder() + .name("๊ด€๋ฆฌ์ž") + .profileImg("๊ธฐ๊ด€ ์ด๋ฏธ์ง€") + .email("eamil@emai.com") + .build(); + + Festival festival = Festival.builder() + .admin(admin) + .title("ํŽ˜์Šคํ‹ฐ๋ฒŒ ์ด๋ฆ„") + .description("ํŽ˜์Šคํ‹ฐ๋ฒŒ ์„ค๋ช…") + .startTime(now.plusDays(3)) + .endTime(now.plusDays(7)) + .build(); + memberRepository.save(admin); + Festival saveFestival = festivalRepository.save(festival); + + TicketCreateRequest ticketCreateRequest = new TicketCreateRequest("ํ‹ฐ์ผ“ ์ด๋ฆ„", "ํ‹ฐ์ผ“ ์„ค๋ช…", 10000L, 100, + now.plusMinutes(10), now.plusDays(6), now.plusDays(10)); + + // When + TicketIdResponse ticketIdResponse = ticketService.createTicket(saveFestival.getId(), ticketCreateRequest); + Optional findTicket = ticketRepository.findById(ticketIdResponse.ticketId()); + // Then + Long newTicketId = ticketIdResponse.ticketId(); + + assertAll( + () -> assertThat(ticketIdResponse).isNotNull(), + () -> assertThat(findTicket.isPresent()).isTrue(), + () -> assertThat(ticketStockRepository.findAll()).hasSize(findTicket.get().getQuantity()), + () -> { + TicketInfo ticketInfo = ticketInfoRedisRepository.getTicketInfo(newTicketId); + assertThat(ticketInfo.startSaleTime()).isEqualTo(findTicket.get().getStartSaleTime()); + assertThat(ticketInfo.endSaleTime()).isEqualTo(findTicket.get().getEndSaleTime()); + }, + () -> assertThat(ticketStockCountRedisRepository.getTicketStockCount(newTicketId)).isEqualTo( + findTicket.get().getQuantity()), + () -> assertThat(currentTicketWaitRedisRepository.getCurrentTicketWait()).contains(newTicketId)); } @Test diff --git a/frontend/src/pages/admin/FestivalManagement.js b/frontend/src/pages/admin/FestivalManagement.js index 37c9cdb4..e0218a45 100644 --- a/frontend/src/pages/admin/FestivalManagement.js +++ b/frontend/src/pages/admin/FestivalManagement.js @@ -100,7 +100,6 @@ const FestivalManagement = () => { } const response = await apiClient.patch(`/festivals/${festivalId}/tickets/${scannedData.ticketId}/checkins/${scannedData.checkinId}`); setModalMessage('์ฒดํฌ์ธ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); - setActiveTab('purchasers'); // ์ฒดํฌ์ธ ํ›„ ๊ตฌ๋งค์ž ๋ชฉ๋ก์œผ๋กœ ์ด๋™ } catch (error) { if (error.response && error.response.data.errorCode === 'CI-0002') { setModalMessage('์ด๋ฏธ ์ฒดํฌ์ธ๋œ ํ‹ฐ์ผ“์ž…๋‹ˆ๋‹ค.'); diff --git a/frontend/src/pages/admin/TicketManagement.js b/frontend/src/pages/admin/TicketManagement.js index d3f28341..4895b2d4 100644 --- a/frontend/src/pages/admin/TicketManagement.js +++ b/frontend/src/pages/admin/TicketManagement.js @@ -169,8 +169,8 @@ const TicketManagement = ({ festivalId }) => { fetchTickets(); // ํ‹ฐ์ผ“ ๋ชฉ๋ก ๊ฐฑ์‹  setError(''); } catch (error) { - console.error('ํ‹ฐ์ผ“ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค:', error); - setError('ํ‹ฐ์ผ“ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + console.error('ํ‹ฐ์ผ“ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค:', error.response.data.message); + setError(error.response.data.message); } }; diff --git a/frontend/src/pages/festival/FestivalList.js b/frontend/src/pages/festival/FestivalList.js index f4222b2f..b4ae4c01 100644 --- a/frontend/src/pages/festival/FestivalList.js +++ b/frontend/src/pages/festival/FestivalList.js @@ -48,12 +48,19 @@ const useFestivals = () => { try { const params = { pageSize: '4', - ...(cursor && !isInitialLoad ? { time: cursor.time, id: cursor.id.toString() } : {}) + ...(cursor && !isInitialLoad ? { + time: cursor.time, + id: cursor.id.toString() + } : {}) }; - const response = await apiClient.get(`/festivals`, {params}); + console.log('Fetching festivals with params:', params); + + const response = await apiClient.get(`/festivals`, { params }); const { data } = response.data; + console.log('API response:', data); + setFestivals((prev) => { const newFestivals = isInitialLoad ? data.content : [...prev, ...data.content]; return newFestivals.filter((festival, index, self) => @@ -107,6 +114,7 @@ export default function FestivalList() { useEffect(() => { if (inView && hasMore && !isLoading && retryCount < 3) { + console.log('Triggering load more', { inView, hasMore, isLoading, retryCount }); debouncedLoadMore(); } }, [inView, hasMore, isLoading, debouncedLoadMore, retryCount]); diff --git a/frontend/src/pages/festival/TicketQueuePage.js b/frontend/src/pages/festival/TicketQueuePage.js index e9ae6672..991c7fa6 100644 --- a/frontend/src/pages/festival/TicketQueuePage.js +++ b/frontend/src/pages/festival/TicketQueuePage.js @@ -1,9 +1,10 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Loader2 } from 'lucide-react'; +import { Loader2, AlertCircle } from 'lucide-react'; import waitClient from '../../utils/waitClient' import { useRecoilState } from 'recoil'; import { waitOrdersState } from '../../utils/atoms'; +import { Button } from '../../components/ui/button'; const TicketQueuePage = () => { const { festivalId, ticketId } = useParams(); @@ -11,11 +12,27 @@ const TicketQueuePage = () => { const [waitOrders, setWaitOrders] = useRecoilState(waitOrdersState); const [relativeWaitOrder, setRelativeWaitOrder] = useState(null); const [error, setError] = useState(null); + const [isSoldOut, setIsSoldOut] = useState(false); + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ํ‚ค ์ƒ์„ฑ ํ•จ์ˆ˜ + const getStorageKey = useCallback((ticketId) => `waitOrder_${ticketId}`, []); + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์—์„œ ๋Œ€๊ธฐ ์ˆœ์„œ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜ + const getWaitOrder = useCallback((ticketId) => { + const key = getStorageKey(ticketId); + return localStorage.getItem(key); + }, [getStorageKey]); + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์— ๋Œ€๊ธฐ ์ˆœ์„œ๋ฅผ ์„ค์ •ํ•˜๋Š” ํ•จ์ˆ˜ + const setWaitOrder = useCallback((ticketId, order) => { + const key = getStorageKey(ticketId); + localStorage.setItem(key, order); + }, [getStorageKey]); const checkQueueStatus = useCallback(async () => { try { const url = `/festivals/${festivalId}/tickets/${ticketId}/purchase/wait`; - const currentWaitOrder = waitOrders[ticketId]; + const currentWaitOrder = getWaitOrder(ticketId); const fullUrl = currentWaitOrder ? `${url}?waitOrder=${currentWaitOrder}` : url; const response = await waitClient.get(fullUrl); @@ -26,20 +43,21 @@ const TicketQueuePage = () => { setRelativeWaitOrder(relativeWaitOrder); if (!currentWaitOrder) { - setWaitOrders(prev => ({ - ...prev, - [ticketId]: absoluteWaitOrder - })); + setWaitOrder(ticketId, absoluteWaitOrder); } if (purchasable) { navigate(`/festivals/${festivalId}/tickets/${ticketId}/purchase`); } } catch (err) { - setError('๋Œ€๊ธฐ์—ด ์ƒํƒœ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); - console.error('๋Œ€๊ธฐ์—ด ์ƒํƒœ ํ™•์ธ ์˜ค๋ฅ˜:', err); + if (err.response && err.response.data.errorCode === 'WT-0005') { + setIsSoldOut(true); + } else { + setError('๋Œ€๊ธฐ์—ด ์ƒํƒœ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + console.error('๋Œ€๊ธฐ์—ด ์ƒํƒœ ํ™•์ธ ์˜ค๋ฅ˜:', err); + } } - }, [festivalId, ticketId, navigate, waitOrders, setWaitOrders]); + }, [festivalId, ticketId, navigate, getWaitOrder, setWaitOrder]); useEffect(() => { checkQueueStatus(); @@ -47,6 +65,35 @@ const TicketQueuePage = () => { return () => clearInterval(intervalId); }, [checkQueueStatus]); + useEffect(() => { + console.log("waitOrders", waitOrders) + }, [waitOrders]); + + + if (isSoldOut) { + return ( +
+
+
+
+ +
+
+

ํ‹ฐ์ผ“ ๋งค์ง„

+

์ฃ„์†กํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ ์ด ํ‹ฐ์ผ“์€ ๋งค์ง„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

+
+
+
+ +
+ ); + } + if (error) { return (
diff --git a/frontend/src/pages/my/PurchasedTickets.js b/frontend/src/pages/my/PurchasedTickets.js index a2bfab76..d5560c64 100644 --- a/frontend/src/pages/my/PurchasedTickets.js +++ b/frontend/src/pages/my/PurchasedTickets.js @@ -46,9 +46,9 @@ export default function PurchasedTickets() { } }, [inView, hasMore, isLoading, loadMoreTickets]); - const openTicketDetail = async (purchaseId) => { + const openTicketDetail = async (ticketId) => { try { - const response = await apiClient.get(`/member/tickets/${purchaseId}`); + const response = await apiClient.get(`/member/tickets/${ticketId}`); setSelectedTicket(response.data.data); setIsOpen(true); } catch (error) { @@ -110,13 +110,11 @@ export default function PurchasedTickets() {

{new Date(ticket.startTime).toLocaleDateString()} ~ {new Date(ticket.endTime).toLocaleDateString()}

-

- {ticket.isCheckin ? '์ฒดํฌ์ธ ์™„๋ฃŒ' : '๋ฏธ์ฒดํฌ์ธ'} -

+