Skip to content

Latest commit

ย 

History

History
745 lines (560 loc) ยท 28.1 KB

File metadata and controls

745 lines (560 loc) ยท 28.1 KB

๐Ÿ›’ Allday Project Commerce

Allday Project ์•„ํ‹ฐ์ŠคํŠธ์˜ ๊ณต์‹ ๊ตฟ์ฆˆยท์•จ๋ฒ”ยท์ด๋ฒคํŠธ ํ‹ฐ์ผ“์„ ํŒ๋งคํ•˜๋Š” ์ปค๋จธ์Šค ํ”Œ๋žซํผ
ํšŒ์›๊ฐ€์ž…ยท๋กœ๊ทธ์ธ๋ถ€ํ„ฐ ์ƒํ’ˆ ์กฐํšŒ, ์žฅ๋ฐ”๊ตฌ๋‹ˆ, ์ฃผ๋ฌธ, ๊ฒฐ์ œ ํ™•์ •, ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ์ƒ๋‹ด๊นŒ์ง€
์ปค๋จธ์Šค์˜ ์ „์ฒด ํ๋ฆ„์„ ์ง์ ‘ ์„ค๊ณ„ํ•˜๊ณ  ๊ตฌํ˜„ํ•œ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค.

ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„: 2026.04.08 ~ 2026.04.28
ํŒ€๋ช…: A.D.P
์„œ๋ฒ„ ํฌํŠธ: 8090


๐Ÿ“‹ ๋ชฉ์ฐจ

  1. ํŒ€์›๋ณ„ ์—ญํ• 
  2. ๊ธฐ์ˆ  ์Šคํƒ
  3. ํ•ต์‹ฌ ์„ค๊ณ„ ๊ฒฐ์ • ์‚ฌํ•ญ
  4. ERD ์„ค๊ณ„
  5. ํŒจํ‚ค์ง€ ๊ตฌ์กฐ
  6. API ๋ช…์„ธ
  7. ๋„์ „ ๊ตฌํ˜„ โ€” ๋™์‹œ์„ฑ ์ œ์–ด
  8. ํ•„์ˆ˜ ๊ตฌํ˜„ โ€” ์บ์‹ฑ ๋ฐ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด
  9. ๋„์ „ ๊ตฌํ˜„ โ€” ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…
  10. ๋„์ „ ๊ตฌํ˜„ โ€” ์ธ๋ฑ์Šค ์ตœ์ ํ™”
  11. ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ”Œ๋กœ์šฐ
  12. ๊ธฐ์ˆ ์  ๊ณ ๋ ค์‚ฌํ•ญ
  13. ๋กœ์ปฌ ์‹คํ–‰ ๋ฐฉ๋ฒ•

๐Ÿ‘ฅ ํŒ€์›๋ณ„ ์—ญํ• 

์—ญํ•  ์ด๋ฆ„ ๋‹ด๋‹น ์—…๋ฌด
๐Ÿ‘‘ ๋ฆฌ๋”ยท๊ฐœ๋ฐœ ์ด์žฌ๋ฏผ ๋งˆ์ผ์Šคํ†ค, ์ธ์ฆ/์ธ๊ฐ€(JWT), ๊ณตํ†ต ์ฝ”๋“œ, ์ฃผ๋ฌธ ๋„๋ฉ”์ธ, ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…, ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด
๐Ÿ’ณ ๊ฐœ๋ฐœยท๊ธฐ๋ก ๋ฌธํ˜œ๋ฆฐ ์‚ฌ์šฉ์ž ๋„๋ฉ”์ธ, ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ƒํ’ˆ ๋„๋ฉ”์ธ, ํšŒ์˜๋กยทSA ๋ฌธ์„œ, API ๋ช…์„ธ์„œ, ERD, ์ธ๋ฑ์‹ฑ
๐Ÿ“ฆ ๊ฐœ๋ฐœ ๋ฐ•๊ฒฝํ™” ์ƒํ’ˆ ๋„๋ฉ”์ธ, ์žฌ๊ณ  ๊ด€๋ฆฌ, ์ƒํ’ˆ ๊ฒ€์ƒ‰ ์บ์‹ฑ
๐Ÿ’ฐ ๊ฐœ๋ฐœ ๋ฐ•์†Œ์˜ ๊ฒฐ์ œ ๋„๋ฉ”์ธ, ์ฃผ๋ฌธ ๋ฐ ์žฌ๊ณ ์ฐจ๊ฐ ์ƒํƒœ ์ „์ด, API ๋ช…์„ธ์„œ, ERD, Docker-Compose , ๋™์‹œ์„ฑ

๐Ÿ› ๏ธ ๊ธฐ์ˆ  ์Šคํƒ

๋ถ„๋ฅ˜ ๊ธฐ์ˆ 
Language Java 21
Framework Spring Boot 3.5.13
Security Spring Security + JWT (HttpOnly ์ฟ ํ‚ค)
ORM / Query Spring Data JPA + QueryDSL 6.10.1
DB MySQL 8.0 (์šด์˜) / H2 (๋กœ์ปฌ)
Cache / Lock Redis (Lettuce + Redisson 3.51.0), Caffeine
์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… WebSocket + STOMP + Redis Pub/Sub
Frontend Thymeleaf + Vanilla JS
์ธํ”„๋ผ Docker, Docker Compose
๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ k6
ID ์ƒ์„ฑ jnanoid 2.0.0

๐Ÿ”‘ ํ•ต์‹ฌ ์„ค๊ณ„ ๊ฒฐ์ •์‚ฌํ•ญ

ํ•ญ๋ชฉ ๊ฒฐ์ • ๋‚ด์šฉ
์ธ์ฆ ๋ฐฉ์‹ JWT Access Token(30๋ถ„) + Refresh Token(7์ผ), HttpOnly ์ฟ ํ‚ค ์ €์žฅ
์ฃผ๋ฌธ ์‹๋ณ„์ž NanoId ๊ธฐ๋ฐ˜ ๋‚ ์งœ ํฌํ•จ UID โ€” ORD-YYYYMMDD-XXXXXXXX / PAY-YYYYMMDD-XXXXXXXX
์žฌ๊ณ  ์ฐจ๊ฐ ์‹œ์  ๊ฒฐ์ œ ํ™•์ •(์„œ๋ฒ„ ๊ฒ€์ฆ ์™„๋ฃŒ) ํ›„ ์ฐจ๊ฐ. ๋ณ€๊ฒฝ ์ด๋ ฅ์€ product_stock_logs์— ๊ธฐ๋ก
๋™์‹œ์„ฑ ์ฒ˜๋ฆฌ Redisson ๊ธฐ๋ฐ˜ Redis ๋ถ„์‚ฐ๋ฝ - BLOCKING Watchdog + AOP ,Pessimistic Lock
์ฃผ๋ฌธ ์Šค๋ƒ…์ƒท ๊ฒฐ์ œ ์™„๋ฃŒ ์‹œ order_products(์ƒํ’ˆ๋ช…ยท๊ฐ€๊ฒฉ) + order_users(์ฃผ๋ฌธ์ž ์ •๋ณด) ๋ณ„๋„ ์ €์žฅ
ํŽ˜์ด์ง• ๋ฐฉ์‹ ์ฃผ๋ฌธยท์žฅ๋ฐ”๊ตฌ๋‹ˆ: ์ปค์„œ ๊ธฐ๋ฐ˜ ๋ฌดํ•œ ์Šคํฌ๋กค / ์ƒํ’ˆ ๋ชฉ๋ก: Offset ํŽ˜์ด์ง• (QueryDSL)
์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด Redis ZSet ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„ โ†’ 1์‹œ๊ฐ„๋งˆ๋‹ค DB Write-back โ†’ ์ž์ • Top5 ์Šค๋ƒ…์ƒท. Caffeine L1 ์บ์‹œ
์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… WebSocket + STOMP + Redis Pub/Sub (๋‹ค์ค‘ ์„œ๋ฒ„ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ ์ง€์›)
์บ์‹œ ๊ตฌ์กฐ L1(Caffeine ๋กœ์ปฌ) + L2(Redis) CompositeCacheManager ์ด์ค‘ ์ ์šฉ
๋ฐฐ์†ก๋น„ 3,000์› ๊ณ ์ • (50,000์› ์ด์ƒ ๊ตฌ๋งค ์‹œ ๋ฌด๋ฃŒ)
์ฑ„ํŒ…๋ฐฉ ์ œํ•œ ์œ ์ €๋‹น ํ™œ์„ฑ ์ฑ„ํŒ…๋ฐฉ 1๊ฐœ. UNIQUE(user_id, active_flag) NULL ํŠธ๋ฆญ

๐Ÿ—‚๏ธ ERD ์„ค๊ณ„

<<ERD ์ „์ฒด ๋‹ค์ด์–ด๊ทธ๋žจ ์ด๋ฏธ์ง€>> img.png

๐Ÿ“ ํŒจํ‚ค์ง€ ๊ตฌ์กฐ

src/main/java/jpa/basic/alldayprojectcommerce/
โ”œโ”€โ”€ application/
โ”‚   โ”œโ”€โ”€ OrderPaymentFacade       # ๊ฒฐ์ œ ํ™•์ • ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜
โ”‚   โ””โ”€โ”€ EventOrderFacade         # ์ด๋ฒคํŠธ ์„ ์ฐฉ์ˆœ ์ฃผ๋ฌธ (๋‹ค์–‘ํ•œ ๋ฝ ์ „๋žต)
โ”‚
โ”œโ”€โ”€ common/
โ”‚   โ”œโ”€โ”€ ApiResponse              # ๊ณตํ†ต ์‘๋‹ต {success, code, data, timestamp}
โ”‚   โ”œโ”€โ”€ CursorResponse           # ์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง€๋„ค์ด์…˜ ๋ž˜ํผ
โ”‚   โ”œโ”€โ”€ RestPage                 # Redis ์ง๋ ฌํ™” ํ˜ธํ™˜ Page ๊ตฌํ˜„์ฒด
โ”‚   โ”œโ”€โ”€ cache/                   # CacheName, CacheType, CompositeCacheManager
โ”‚   โ”œโ”€โ”€ config/                  # Redis, Redisson, WebSocket, QueryDSL, Cache ์„ค์ •
โ”‚   โ”œโ”€โ”€ exception/               # GlobalExceptionHandler, ErrorCode, CustomException
โ”‚   โ”œโ”€โ”€ lock/
โ”‚   โ”‚   โ”œโ”€โ”€ annotation/          # @RedisLock, @RedissonLock
โ”‚   โ”‚   โ”œโ”€โ”€ aspect/              # RedisLockAspect, RedissonLockAspect
โ”‚   โ”‚   โ”œโ”€โ”€ enums/               # RedisLockStrategy (FAIL_FAST, RETRY, BLOCKING)
โ”‚   โ”‚   โ”œโ”€โ”€ repository/          # RedisLockRepository (SET NX + Lua Script ํ•ด์ œ)
โ”‚   โ”‚   โ””โ”€โ”€ service/             # RedisLockService, RedissonLockService
โ”‚   โ””โ”€โ”€ security/
โ”‚       โ”œโ”€โ”€ auth/                # AuthService, JWT, LoginUser ์–ด๋…ธํ…Œ์ด์…˜
โ”‚       โ”œโ”€โ”€ config/              # SecurityConfig, WebMvcConfig, RedisWarmUpRunner
โ”‚       โ”œโ”€โ”€ cookie/              # CookieUtils
โ”‚       โ””โ”€โ”€ jwt/                 # JwtTokenProvider, JwtAuthenticationFilter
โ”‚
โ””โ”€โ”€ domain/
    โ”œโ”€โ”€ user/                    # ์‚ฌ์šฉ์ž (์กฐํšŒยท์ˆ˜์ •ยท๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝยท๋งˆ์Šคํ‚น)
    โ”œโ”€โ”€ product/                 # ์ƒํ’ˆ (๋‹จ๊ฑดยท๋ชฉ๋กยท๊ฒ€์ƒ‰ยท์žฌ๊ณ  ๊ด€๋ฆฌยท์บ์‹œ ๋ฌดํšจํ™”)
    โ”œโ”€โ”€ cartProduct/             # ์žฅ๋ฐ”๊ตฌ๋‹ˆ (์ถ”๊ฐ€ยท์ˆ˜๋Ÿ‰๋ณ€๊ฒฝยท์‚ญ์ œยท๋น„์šฐ๊ธฐ)
    โ”œโ”€โ”€ order/                   # ์ฃผ๋ฌธ (์ฃผ๋ฌธ์„œ ์ƒ์„ฑยท์กฐํšŒยท์ƒ์„ธยท์ด๋ฒคํŠธ ์ฃผ๋ฌธ)
    โ”‚   โ””โ”€โ”€ service/event/       # EventOrderService (์ด๋ฒคํŠธ ์ „์šฉ ์ฃผ๋ฌธ ๋กœ์ง)
    โ”œโ”€โ”€ payment/                 # ๊ฒฐ์ œ (์ƒ์„ฑยทํ™•์ •ยท๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ)
    โ”œโ”€โ”€ keyword/                 # ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด (Redis ZSet + Write-back + Caffeine)
    โ”‚   โ””โ”€โ”€ scheduler/           # KeywordScheduler (Write-backยท์ž์ • ์ดˆ๊ธฐํ™”)
    โ”œโ”€โ”€ chat/                    # ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…
    โ”‚   โ”œโ”€โ”€ redis/               # ChatRedisPublisher, ChatRedisSubscriber
    โ”‚   โ””โ”€โ”€ scheduler/           # ChatInactivityScheduler (์ž๋™ ์ข…๋ฃŒ)
    โ””โ”€โ”€ view/                    # Thymeleaf ๋ทฐ ์ปจํŠธ๋กค๋Ÿฌ

๐Ÿ“ก API ๋ช…์„ธ

์ธ์ฆ

Method ๊ฒฝ๋กœ ์„ค๋ช… ์ธ์ฆ
POST /api/auth/signup ํšŒ์›๊ฐ€์ž… โŒ
POST /api/auth/login ๋กœ๊ทธ์ธ (์ฟ ํ‚ค ๋ฐœ๊ธ‰) โŒ
POST /api/auth/logout ๋กœ๊ทธ์•„์›ƒ (์ฟ ํ‚ค ์‚ญ์ œ) โœ…
POST /api/auth/reissue Access Token ์žฌ๋ฐœ๊ธ‰ โŒ
GET /api/auth/check-duplicate ์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ โŒ

์‚ฌ์šฉ์ž

Method ๊ฒฝ๋กœ ์„ค๋ช… ์ธ์ฆ
GET /api/users/me ๋‚ด ์ •๋ณด ์กฐํšŒ (๋งˆ์Šคํ‚น) โœ…
GET /api/users/me/unmasked ๋‚ด ์ •๋ณด ์กฐํšŒ (๋น„๋งˆ์Šคํ‚น) โœ…
PATCH /api/users/me ๋‚ด ์ •๋ณด ์ˆ˜์ • โœ…
PATCH /api/users/password ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ โœ…

์ƒํ’ˆ

Method ๊ฒฝ๋กœ ์„ค๋ช… ์ธ์ฆ
GET /api/products ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (์นดํ…Œ๊ณ ๋ฆฌยทํ‚ค์›Œ๋“œ ํ•„ํ„ฐ) โŒ
GET /api/products/{productId} ์ƒํ’ˆ ๋‹จ๊ฑด ์กฐํšŒ โŒ
GET /api/products/search/v1 ์ƒํ’ˆ ๊ฒ€์ƒ‰ (DB ์ง์ ‘ ์กฐํšŒ) โŒ
GET /api/products/search/v2 ์ƒํ’ˆ ๊ฒ€์ƒ‰ (์บ์‹œ ์ ์šฉ) โŒ
PUT /api/products/{productId} ์ƒํ’ˆ ์ˆ˜์ • (์บ์‹œ ๋ฌดํšจํ™”) โœ…

์žฅ๋ฐ”๊ตฌ๋‹ˆ

Method ๊ฒฝ๋กœ ์„ค๋ช… ์ธ์ฆ
POST /api/cart ์ƒํ’ˆ ์ถ”๊ฐ€ (๊ธฐ์กด ์ˆ˜๋Ÿ‰ ํ•ฉ์‚ฐ) โœ…
GET /api/cart ์žฅ๋ฐ”๊ตฌ๋‹ˆ ๋ชฉ๋ก (์ปค์„œ ํŽ˜์ด์ง•) โœ…
PATCH /api/cart/{cartProductId} ์ˆ˜๋Ÿ‰ ๋ณ€๊ฒฝ โœ…
DELETE /api/cart/{cartProductId} ์ƒํ’ˆ ๊ฐœ๋ณ„ ์‚ญ์ œ โœ…
DELETE /api/cart ์žฅ๋ฐ”๊ตฌ๋‹ˆ ๋น„์šฐ๊ธฐ โœ…

์ฃผ๋ฌธ / ๊ฒฐ์ œ

Method ๊ฒฝ๋กœ ์„ค๋ช… ์ธ์ฆ
POST /api/orders ์ฃผ๋ฌธ์„œ ์ƒ์„ฑ โœ…
GET /api/orders ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ โœ…
GET /api/orders/{orderUid} ์ฃผ๋ฌธ์„œ ์กฐํšŒ (๊ฒฐ์ œ ์ „) โœ…
GET /api/orders/{orderUid}/details ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ (๊ฒฐ์ œ ํ›„) โœ…
POST /api/orders/{orderUid}/payments ๊ฒฐ์ œ ์ƒ์„ฑ โœ…
POST /api/orders/{orderUid}/payments/{paymentUid}/confirm ๊ฒฐ์ œ ํ™•์ • โœ…

์ด๋ฒคํŠธ / ๊ฒ€์ƒ‰์–ด / ์ฑ„ํŒ…

Method ๊ฒฝ๋กœ ์„ค๋ช… ์ธ์ฆ
POST /api/events/products/{productId}/orders ์ด๋ฒคํŠธ ์„ ์ฐฉ์ˆœ ์ฃผ๋ฌธ โŒ
POST /api/keywords/search ๊ฒ€์ƒ‰์–ด ๊ธฐ๋ก ์„ ํƒ
GET /api/keywords/v1/top5 ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด Top5 (์‹ค์‹œ๊ฐ„) โŒ
GET /api/keywords/v2/top5 ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด Top5 (์บ์‹œ) โŒ
POST /api/chat/rooms ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ/์กฐํšŒ โœ…
GET /api/chat/rooms/my ๋‚ด ํ™œ์„ฑ ์ฑ„ํŒ…๋ฐฉ โœ…
GET /api/chat/rooms/{roomId}/messages ๋ฉ”์‹œ์ง€ ๋ชฉ๋ก (์ปค์„œ) โœ…
POST /api/chat/rooms/{roomId}/close ์ฑ„ํŒ…๋ฐฉ ์ข…๋ฃŒ โœ…
GET /api/chat/admin/rooms ์ „์ฒด ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก ADMIN
POST /api/chat/admin/rooms/{roomId}/join ์ƒ๋‹ด ์‹œ์ž‘ ADMIN

โšก ํ•„์ˆ˜ ๊ตฌํ˜„ โ€” ๋™์‹œ์„ฑ ์ œ์–ด

๋ฌธ์ œ ์ƒํ™ฉ

์ด๋ฒคํŠธ ํ‹ฐ์ผ“ ์„ ์ฐฉ์ˆœ ํŒ๋งค์ฒ˜๋Ÿผ ์ˆœ๊ฐ„์ ์œผ๋กœ ์ˆ˜์ฒœ ๋ช…์ด ๋™์‹œ์— ์š”์ฒญ์ด ์Ÿ์•„์ง€๋Š” ์ƒํ™ฉ์—์„œ ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์ด ๊นจ์ง€๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

์˜ˆ) ์žฌ๊ณ  10๊ฐœ์ธ ํ‹ฐ์ผ“์— 100๋ช…์ด ๋™์‹œ์— ์ฃผ๋ฌธ โ†’ ๋ฝ ์—†์ด๋Š” 10๊ฐœ ์ดˆ๊ณผ ํŒ๋งค ๊ฐ€๋Šฅ

๋™์‹œ์„ฑ ์ด์Šˆ ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ

EventOrderFacadeConcurrencyTest์—์„œ ExecutorService + CyclicBarrier๋ฅผ ์‚ฌ์šฉํ•ด 100๋ช… ๋™์‹œ ์š”์ฒญ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

// 100๊ฐœ ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ์ถœ๋ฐœ
CyclicBarrier startBarrier = new CyclicBarrier(100);
// ๋ฝ ์—†๋Š” ๋ฒ„์ „์€ ์ •ํ•ฉ์„ฑ์ด ๊นจ์ง€๋Š” ๊ฒƒ์ด ๋ชฉ์ , ์ฆ‰ ์ •์ƒ ๊ธฐ๋Œ€๊ฐ’(10/90/10/0)๊ณผ ๋‹ฌ๋ผ์•ผ ํ•œ๋‹ค.
boolean isExactlyCorrect =
        result.successCount() == 10 &&
                result.failCount() == 90 &&
                orderCount == 10 &&
                product.getStock() == 0;

assertThat(isExactlyCorrect).isFalse();

Redis ๋ถ„์‚ฐ ๋ฝ ๊ตฌํ˜„

Lettuce ๊ธฐ๋ฐ˜ ๋ถ„์‚ฐ ๋ฝ (RedisLockRepository)

// SET NX (์›์ž์  ๋ฝ ํš๋“)
Boolean result = redisTemplate.opsForValue()
    .setIfAbsent(key, value, Duration.ofSeconds(timeoutSeconds));

// Lua Script๋กœ ๋ณธ์ธ ๋ฝ๋งŒ ์›์ž์  ํ•ด์ œ
String script = """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    else
        return 0
    end
""";

3๊ฐ€์ง€ ๋ฝ ์ „๋žต

์ „๋žต ์„ค๋ช… ์ ์šฉ ์‹œ๋‚˜๋ฆฌ์˜ค
FAIL_FAST ๋ฝ ํš๋“ ์‹คํŒจ ์‹œ ์ฆ‰์‹œ ์˜ˆ์™ธ ๋น ๋ฅธ ์‹คํŒจ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ
RETRY ์ตœ๋Œ€ 15ํšŒ, 100ms ๊ฐ„๊ฒฉ ์žฌ์‹œ๋„ ์žฌ์‹œ๋„๊ฐ€ ์˜๋ฏธ ์žˆ๋Š” ๊ฒฝ์šฐ
BLOCKING ์ตœ๋Œ€ 5์ดˆ, 50ms ๊ฐ„๊ฒฉ ๋Œ€๊ธฐ ์ฒ˜๋ฆฌ๋Ÿ‰๋ณด๋‹ค ์ •ํ•ฉ์„ฑ ์šฐ์„ 

Redisson ๊ธฐ๋ฐ˜ ๋ถ„์‚ฐ ๋ฝ ๊ตฌํ˜„

if (leaseTimeMillis < 0) {
    locked = lock.tryLock(waitTimeMillis, TimeUnit.MILLISECONDS);
} else {
    locked = lock.tryLock(waitTimeMillis, leaseTimeMillis, TimeUnit.MILLISECONDS);
}
if (locked && lock.isHeldByCurrentThread()) {
    lock.unlock();
    log.info("[RedissonLock] ๋ฝ ํ•ด์ œ ์„ฑ๊ณต key={}", key);
}

AOP ๊ธฐ๋ฐ˜ ๋ฝ ์ ์šฉ (@RedisLock, @RedissonLock)

๋น„์ฆˆ๋‹ˆ์Šค ์ฝ”๋“œ์—์„œ ๋ฝ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ์™„์ „ํžˆ ๋ถ„๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.

@RedissonLock(
    key = "'lock:product:' + #productId",
    waitTimeMillis = 10000,
    leaseTimeMillis = -1  // Watchdog ๋ชจ๋“œ (TTL ์ž๋™ ์—ฐ์žฅ)
)
public EventOrderResponse createEventOrderWithRedissonLockAopBlockingWatchdog(
        Long productId, Long userId) {
    return eventOrderService.createEventOrder(productId, userId);
}```

SpEL ํ‘œํ˜„์‹์œผ๋กœ ๋™์  ํ‚ค ์ƒ์„ฑ:

'lock:product:' + #productId  โ†’  lock:product:4

Redisson Watchdog

leaseTimeMillis = -1 ์„ค์ • ์‹œ Watchdog์ด TTL์„ ์ž๋™ ์—ฐ์žฅํ•ฉ๋‹ˆ๋‹ค. ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ์˜ˆ์ƒ๋ณด๋‹ค ๊ธธ์–ด์ ธ๋„ ๋ฝ์ด ๋งŒ๋ฃŒ๋˜์ง€ ์•Š์•„ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.

๋น„๊ด€์  ๋ฝ

๊ฒฐ์ œ ํ™•์ • ์‹œ Order ๋ ˆ์ฝ”๋“œ, ์žฌ๊ณ  ์ฐจ๊ฐ ์‹œ Product ๋ ˆ์ฝ”๋“œ์— PESSIMISTIC_WRITE ๋ฝ์„ ์ ์šฉํ•ด DB ๋ ˆ๋ฒจ์—์„œ ๋™์‹œ ์ฒ˜๋ฆฌ๋ฅผ ์ง๋ ฌํ™”ํ•ฉ๋‹ˆ๋‹ค.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :productId")
Optional<Product> findByIdForUpdate(@Param("productId") Long productId);

๋ฝ ๋ฒ„์ „๋ณ„ ๋น„๊ต ํ…Œ์ŠคํŠธ (v1 ~ v8)

๋ฐฉ์‹ ์„ฑ๊ณต ์ˆ˜ ์ตœ์ข… ์žฌ๊ณ  ์ดˆ๊ณผ ํŒ๋งค p95 TPS timeout lock_fail_count waitTime leaseTime ๋ณ‘๋ชฉ ์œ„์น˜ ํ‰๊ฐ€
v1 ๋ฝ ์—†์Œ 998 โŒ ์ดˆ๊ณผ ํŒ๋งค โŒ ๋ฐœ์ƒ 8.31s 116 req/s 0 0 - - DB ์ •ํ•ฉ์„ฑ โŒ ํƒˆ๋ฝ
v2 DB ๋น„๊ด€๋ฝ 100 0 0 2.21s 413 req/s 0 0 - - DB row lock โœ… ๋งค์šฐ ์šฐ์ˆ˜
v3 Redis Retry (TTL) 100 0 0 7.97s 119 req/s 0 4 5s 3s Redis ๋Œ€๊ธฐ โš ๏ธ ๋А๋ฆผ
v4 Redis Blocking (TTL) 100 0 0 5.50s 169 req/s 0 0 10s 5s Redis ๋Œ€๊ธฐ โ–ณ ๊ฒฝ๊ณ„
v5 Redis FailFast 6 โŒ ์žฌ๊ณ  ๋‚จ์Œ 0 1.14s 567 req/s 0 994 0 3s lock fail โŒ ํƒˆ๋ฝ
v6 Redis Retry (Watchdog) 100 0 0 5.06s 191 req/s 0 0 5s -1 Redis ๋Œ€๊ธฐ โ–ณ ๊ฒฝ๊ณ„
v7 Redis Blocking (Watchdog) 100 0 0 3.91s 226 req/s 0 0 10s -1 Redis ์ง๋ ฌํ™” ๐Ÿ”ฅ ์ตœ๊ณ 
v8 Redis + DB ๋น„๊ด€๋ฝ 100 0 0 5.83s 164 req/s 0 0 10s -1 Redis + DB โœ… ์•ˆ์ • but ๋А๋ฆผ

์ตœ์ข… ์„ ํƒ: v7 โ€” Redisson Blocking + Watchdog

์„ ํƒ ์ด์œ :

  • Blocking ์ „๋žต์œผ๋กœ ์žฌ๊ณ  100๊ฐœ(ํ‹ฐ์ผ“ 10๊ฐœ ๊ธฐ์ค€)๋ฅผ ๋ชจ๋‘ ์†Œ์ง„
  • Watchdog์œผ๋กœ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ๊ธธ์–ด์ ธ๋„ TTL ์ž๋™ ์—ฐ์žฅ โ†’ ์•ˆ์ „
  • ๋น„๊ด€๋ฝ ์ค‘์ฒฉ(v8) ๋Œ€๋น„ ๋ถˆํ•„์š”ํ•œ DB ๋ฝ ์˜ค๋ฒ„ํ—ค๋“œ ์—†์Œ
  • ๋น„๊ด€๋ฝ ๋Œ€๋น„ DB ๋ถ€ํ•˜ ๋ถ„์‚ฐ ๊ฐ€๋Šฅ

๐Ÿ” ํ•„์ˆ˜ ๊ตฌํ˜„ โ€” ์บ์‹ฑ ๋ฐ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด

์™œ ์บ์‹ฑ์„ ์ ์šฉํ–ˆ๋‚˜?

์ƒํ’ˆ ๊ฒ€์ƒ‰๊ณผ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด๋Š” ๋ฐ˜๋ณต ์š”์ฒญ์ด ๋งŽ๊ณ  ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ๋นˆ๋„๊ฐ€ ๋‚ฎ์€ ์ „ํ˜•์ ์ธ ์บ์‹œ ์ ์šฉ ๋Œ€์ƒ์ž…๋‹ˆ๋‹ค.

  • ๊ฒ€์ƒ‰์–ด๊ฐ€ ๊ฐ™์œผ๋ฉด ๊ฒฐ๊ณผ๊ฐ€ ๊ฐ™์Œ โ†’ ๋งค๋ฒˆ DB ์กฐํšŒ ๋ถˆํ•„์š”
  • ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด๋Š” ์‹ค์‹œ๊ฐ„์ด ์•„๋‹ˆ์–ด๋„ ๋จ โ†’ 1๋ถ„ ์บ์‹œ๋กœ ์ถฉ๋ถ„

L1 + L2 ๋ณตํ•ฉ ์บ์‹œ ๊ตฌ์กฐ (CompositeCacheManager)

์š”์ฒญ โ†’ L1 Caffeine(๋กœ์ปฌ ์ธ๋ฉ”๋ชจ๋ฆฌ) โ†’ L2 Redis(๋ถ„์‚ฐ ์บ์‹œ) โ†’ DB
         โ†‘ ์—†์œผ๋ฉด L2 ์กฐํšŒ ํ›„ L1 ์ €์žฅ
// CompositeCacheCacheManager: L1 โ†’ L2 ์ˆœ์„œ๋กœ ์กฐํšŒ
// L2 ํžˆํŠธ ์‹œ L1์—๋„ ์ €์žฅ (write-back to local)
for (int i = 0; i < caches.size(); i++) {
    ValueWrapper wrapper = caches.get(i).get(key);
    if (wrapper != null) {
        if (i > 0) {
            localCacheManager.putToCache(name, key, wrapper.get()); // L1์— ์ €์žฅ
        }
        return wrapper;
    }
}

์บ์‹œ๋ณ„ ์„ค์ • (CacheName Enum)

์บ์‹œ๋ช… TTL ํƒ€์ž… ์ตœ๋Œ€ ํฌ๊ธฐ
productSearch 5๋ถ„ COMPOSITE (L1+L2) 500
productDetail 5๋ถ„ COMPOSITE (L1+L2) 500
top5Keywords 1๋ถ„ LOCAL (Caffeine๋งŒ) 10

๋กœ์ปฌ ์บ์‹œ์˜ ํ•œ๊ณ„์™€ Redis ์ „ํ™˜ ์ด์œ 

Scale-out ํ™˜๊ฒฝ์—์„œ ์„œ๋ฒ„ A์™€ ์„œ๋ฒ„ B๊ฐ€ ๊ฐ๊ฐ ๋‹ค๋ฅธ ๋กœ์ปฌ ์บ์‹œ๋ฅผ ๊ฐ€์ง€๋ฉด ๋ฐ์ดํ„ฐ ๋ถˆ์ผ์น˜๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. โ†’ Redis L2 ์บ์‹œ๋กœ ๋ชจ๋“  ์„œ๋ฒ„๊ฐ€ ๋™์ผํ•œ ์บ์‹œ๋ฅผ ๊ณต์œ 

์ƒํ’ˆ ๊ฒ€์ƒ‰ API โ€” v1 vs v2

v1 โ€” DB ์ง์ ‘ ์กฐํšŒ

GET /api/products/search/v1?keyword=๋ณผ์บก
โ†’ QueryDSL โ†’ MySQL LIKE ์ฟผ๋ฆฌ โ†’ ๋งค๋ฒˆ DB ์™•๋ณต

v2 โ€” ๋ณตํ•ฉ ์บ์‹œ ์ ์šฉ

GET /api/products/search/v2?keyword=๋ณผ์บก
โ†’ L1 Caffeine ํ™•์ธ โ†’ L2 Redis ํ™•์ธ โ†’ DB (์บ์‹œ ๋ฏธ์Šค ์‹œ๋งŒ)
@Cacheable(
    value = "productSearch",
    key = "'product:' + #searchRequest.keyword() + ':' + #pageable.pageNumber + ':' + #pageable.pageSize",
    sync = true  // ๋™์‹œ DB ์กฐํšŒ ๋ฐฉ์ง€
)
public RestPage<SearchProductResponse> searchProductsV2(
        SearchProductRequest searchRequest, Pageable pageable) { ... }

์บ์‹œ Key ์„ค๊ณ„: keyword:pageNumber:pageSize ์กฐํ•ฉ์œผ๋กœ ํ‚ค ์ถฉ๋Œ ๋ฐฉ์ง€

์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ (k6)

ํ…Œ์ŠคํŠธ ์กฐ๊ฑด: VU 50๋ช…, 30์ดˆ, ๋™์ผ ํ‚ค์›Œ๋“œ(apple) ๋ฐ˜๋ณต ์š”์ฒญ

k6 ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ โ€” v1(DB) vs v2(์บ์‹œ) ์‘๋‹ต ์‹œ๊ฐ„ ๋น„๊ตํ‘œ image

์ง€ํ‘œ v1 (DB) v2 (์บ์‹œ) ๊ฐœ์„ ์œจ
ํ‰๊ท  ์‘๋‹ต์‹œ๊ฐ„ 91.54 ms 9.29 ms 89.8% ํ–ฅ์ƒ
p95 ์‘๋‹ต์‹œ๊ฐ„ 126.61 ms 15.83 ms 87.5% ํ–ฅ์ƒ
TPS 266.72req/s 470.44req/s 76.3% ํ–ฅ์ƒ

v1 ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์‚ฌ์ง„ 1-1v1

v2 ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์‚ฌ์ง„ 1-1v2

์บ์‹œ ๋ฌดํšจํ™” (@CacheEvict)

์ƒํ’ˆ ์ˆ˜์ • ์‹œ ๊ด€๋ จ ์บ์‹œ๋ฅผ ์ฆ‰์‹œ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

@Caching(evict = {
    @CacheEvict(value = "productDetail", key = "'product:' + #productId"),
    @CacheEvict(value = "productSearch", allEntries = true)
})
public ProductUpdateResponse updateProduct(Long productId, ProductUpdateRequest request) { ... }

์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด ์•„ํ‚คํ…์ฒ˜

์ „์ฒด ํ๋ฆ„

๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ
    โ†“
์ •๊ทœํ™” (์†Œ๋ฌธ์ž, ํŠน์ˆ˜๋ฌธ์ž ์ œ๊ฑฐ, ์—ฐ์† ๊ณต๋ฐฑ ์ถ•์†Œ)
    โ†“
Redis Set์œผ๋กœ ์ค‘๋ณต ์ฒดํฌ (user:{id}:{keyword} or ip:{ip}:{keyword})
    โ†“ ์ค‘๋ณต ์•„๋‹Œ ๊ฒฝ์šฐ๋งŒ
Redis ZSet score +1 (search:rank:YYYY-MM-DD)
    โ†“
TTL = ์ž์ •๊นŒ์ง€ + 1์‹œ๊ฐ„
    โ†“
[๋งค 1์‹œ๊ฐ„] Write-back: Redis ZSet โ†’ SearchKeyword DB
    โ†“
[์ž์ •] Top5 ์Šค๋ƒ…์ƒท โ†’ popular_keywords ์ €์žฅ โ†’ Redis ์ดˆ๊ธฐํ™”
    โ†“
[์„œ๋ฒ„ ์žฌ์‹œ์ž‘] RedisWarmUpRunner: DB ๋‹น์ผ ๋ฐ์ดํ„ฐ โ†’ Redis ๋ณต์›

์ค‘๋ณต ๋ฐฉ์ง€ ์ „๋žต

  • ํšŒ์›: user:{userId}:{keyword} โ†’ Redis Set SADD๋กœ ์›์ž์  ์ค‘๋ณต ์ฒดํฌ
  • ๋น„ํšŒ์›: ip:{clientIP}:{keyword} โ†’ IP ๊ธฐ๋ฐ˜ ํ•˜๋ฃจ 1ํšŒ ์ œํ•œ

Fallback ์ „๋žต

Redis ์žฅ์•  ๋˜๋Š” ์ž์ • ์ดˆ๊ธฐํ™” ์งํ›„ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ๋•Œ:

  1. ์˜ค๋Š˜ popular_keywords ์Šค๋ƒ…์ƒท ์กฐํšŒ
  2. ์—†์œผ๋ฉด ์–ด์ œ ์Šค๋ƒ…์ƒท ์กฐํšŒ
  3. ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์‹œ RedisWarmUpRunner๋กœ DB โ†’ Redis ๋ณต์›

๐Ÿ’ฌ ๋„์ „ ๊ตฌํ˜„ โ€” ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…

์•„ํ‚คํ…์ฒ˜

ํด๋ผ์ด์–ธํŠธ (SockJS)
    โ†“ WebSocket ์—ฐ๊ฒฐ
STOMP CONNECT
    โ†“ JWT ๊ฒ€์ฆ (StompChannelInterceptor)
StompPrincipal ์„ค์ •
    โ†“
@MessageMapping("/chat/{roomId}")
    โ†“
์ฑ„ํŒ…๋ฐฉ ์ƒํƒœยท๊ถŒํ•œ ๊ฒ€์ฆ
    โ†“
chat_messages ์ €์žฅ + lastMessageAt ๊ฐฑ์‹ 
    โ†“
Redis Publish (chat:room:{roomId})
    โ†“
๋ชจ๋“  ์„œ๋ฒ„์˜ ChatRedisSubscriber ์ˆ˜์‹ 
    โ†“
STOMP /sub/chat/{roomId} ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ

<<์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ์•„ํ‚คํ…์ฒ˜ ๊ตฌ์„ฑ๋„>>

์™œ WebSocket + STOMP?

  • HTTP ํด๋ง ๋Œ€๋น„ ์‹ค์‹œ๊ฐ„ ์–‘๋ฐฉํ–ฅ ํ†ต์‹  ๊ฐ€๋Šฅ, ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ ์—†์Œ
  • ์ˆœ์ˆ˜ WebSocket๋งŒ์œผ๋กœ๋Š” ๋ฉ”์‹œ์ง€ ๋ผ์šฐํŒ…, ๊ตฌ๋…/๋ฐœํ–‰ ํŒจํ„ด ์ง์ ‘ ๊ตฌํ˜„ ํ•„์š” โ†’ STOMP๋กœ ํ•ด๊ฒฐ

Redis Pub/Sub โ€” ๋‹ค์ค‘ ์„œ๋ฒ„ ๋ฌธ์ œ ํ•ด๊ฒฐ

๋‹จ์ผ ์„œ๋ฒ„์—์„œ๋Š” WebSocket ์„ธ์…˜์ด ๊ฐ™์€ ์„œ๋ฒ„์— ์žˆ์–ด ์ง์ ‘ ์ „๋‹ฌ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„ 2๋Œ€ ์ด์ƒ์ด๋ฉด ์„œ๋ฒ„ A์— ์ ‘์†ํ•œ ์‚ฌ์šฉ์ž์™€ ์„œ๋ฒ„ B์— ์ ‘์†ํ•œ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ์ฃผ๊ณ ๋ฐ›์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ: Redis ์ฑ„๋„(chat:room:{roomId})์„ ๋งค๊ฐœ๋กœ ๋ชจ๋“  ์„œ๋ฒ„๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธํ•ฉ๋‹ˆ๋‹ค.

// Publisher: ์–ด๋А ์„œ๋ฒ„์—์„œ๋‚˜ Redis ์ฑ„๋„์— ๋ฐœํ–‰
chatRedisTemplate.convertAndSend("chat:room:" + roomId, message);

// Subscriber: ๋ชจ๋“  ์„œ๋ฒ„์—์„œ ์ˆ˜์‹  โ†’ ์ž๊ธฐ ์„œ๋ฒ„ ๊ตฌ๋…์ž์—๊ฒŒ ์ „๋‹ฌ
simpMessagingTemplate.convertAndSend("/sub/chat/" + roomId, response);

JWT ์ธ์ฆ โ€” HTTP Filter๊ฐ€ ์•„๋‹Œ ChannelInterceptor

WebSocket ์—ฐ๊ฒฐ์€ HTTP Filter๋ฅผ ํ†ตํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ StompChannelInterceptor์—์„œ CONNECT ์‹œ์ ์— JWT๋ฅผ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
    String token = extractToken(accessor); // Authorization ํ—ค๋” ๋˜๋Š” ์ฟ ํ‚ค์—์„œ ์ถ”์ถœ
    if (!jwtTokenProvider.validateToken(token)) {
        throw new CustomException(ErrorCode.CHAT_UNAUTHORIZED);
    }
    accessor.setUser(new StompPrincipal(userId, role)); // Principal ์„ค์ •
}

์ฑ„ํŒ…๋ฐฉ ๋™์‹œ ์ƒ์„ฑ ์•ˆ์ „ ์ฒ˜๋ฆฌ

// 1๋‹จ๊ณ„: ์•ฑ ๋ ˆ๋ฒจ โ€” ๊ธฐ์กด ํ™œ์„ฑ ๋ฐฉ ์กฐํšŒ
return chatRoomRepository.findByUserIdAndActiveFlag(userId, ACTIVE_FLAG)
    .orElseGet(() -> {
        try {
            // 2๋‹จ๊ณ„: REQUIRES_NEW ๋ณ„๋„ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ƒ์„ฑ
            return chatRoomCreator.createNewRoom(userId, title);
        } catch (DataIntegrityViolationException e) {
            // 3๋‹จ๊ณ„: ๋™์‹œ ์ƒ์„ฑ ์ถฉ๋Œ ์‹œ ์žฌ์กฐํšŒ
            return chatRoomRepository.findByUserIdAndActiveFlag(userId, ACTIVE_FLAG)
                .map(ChatRoomResponse::from).orElseThrow(...);
        }
    });

์œ ์ €๋‹น ํ™œ์„ฑ ๋ฐฉ 1๊ฐœ ๋ณด์žฅ: UNIQUE(user_id, active_flag) + active_flag=NULL ํŠธ๋ฆญ

  • ํ™œ์„ฑ ์ƒํƒœ: active_flag = 1 โ†’ ์œ ๋‹ˆํฌ ์ œ์•ฝ ์ ์šฉ
  • ์ข…๋ฃŒ ์ƒํƒœ: active_flag = NULL โ†’ NULL์€ ์—ฌ๋Ÿฌ ๊ฐœ ํ—ˆ์šฉ

๋น„ํ™œ์„ฑ ์ฑ„ํŒ…๋ฐฉ ์ž๋™ ์ข…๋ฃŒ ์Šค์ผ€์ค„๋Ÿฌ

1๋ถ„๋งˆ๋‹ค ์‹คํ–‰, lastMessageAt ๊ธฐ์ค€ 10๋ถ„ ๊ฒฝ๊ณผ ๋ฐฉ์„ ์ž๋™ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.

// No-Offset ๋ฐฐ์น˜ ์กฐํšŒ (OOM ๋ฐฉ์ง€)
List<ChatRoom> targets = chatRoomRepository.findInactiveRooms(cutOff, lastId, BATCH_SIZE);

// Bulk UPDATE (IN ์ฟผ๋ฆฌ๋กœ ํ•œ ๋ฒˆ์— ์ƒํƒœ ๋ณ€๊ฒฝ โ†’ DB ์ปค๋„ฅ์…˜ ์ตœ์†Œํ™”)
chatRoomRepository.bulkCompleteRooms(roomIds);

<<์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ํ™”๋ฉด ์บก์ฒ˜>>


๐Ÿ“Š ๋„์ „ ๊ตฌํ˜„ โ€” ์ธ๋ฑ์Šค ์ตœ์ ํ™”

์ธ๋ฑ์Šค ์„ค๊ณ„ ๊ธฐ์ค€

๋Œ€์šฉ๋Ÿ‰ ๋ฐ์ดํ„ฐ(5๋งŒ ๊ฑด ์ด์ƒ)์—์„œ ์ž์ฃผ ์‹คํ–‰๋˜๋Š” ์ฟผ๋ฆฌ๋ฅผ ์„ ์ •ํ•˜์—ฌ ์ธ๋ฑ์Šค๋ฅผ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

์ ์šฉ๋œ ์ธ๋ฑ์Šค

products ํ…Œ์ด๋ธ”

-- price ๋ฒ”์œ„ ๊ฒ€์ƒ‰ ์ตœ์ ํ™”
CREATE INDEX idx_products_price ON products (price);

-- name LIKE ๊ฒ€์ƒ‰ ์ตœ์ ํ™”
CREATE INDEX idx_products_name ON products (name);

-- status ํ•„ํ„ฐ + id DESC ์ปค์„œ ํŽ˜์ด์ง• ์ตœ์ ํ™” (๋ณตํ•ฉ ์ธ๋ฑ์Šค)
CREATE INDEX idx_products_status_id ON products (status, id);

-- category + status ๋™์‹œ ํ•„ํ„ฐ๋ง
CREATE INDEX idx_products_category_status ON products (category, status);
์ธ๋ฑ์Šค ์ ์šฉ

EXPLAIN ๋ถ„์„ ๊ฒฐ๊ณผ

<<EXPLAIN ์‹คํ–‰ ๊ณ„ํš Before ์‚ฌ์ง„>> ์Šคํฌ๋ฆฐ์ƒท 2026-04-23 192149

<<EXPLAIN ์‹คํ–‰ ๊ณ„ํš After ์‚ฌ์ง„>> ์ ์šฉํ–ˆ์ง€๋งŒ ์‹คํŒจ

<<EXPLAIN ์‹คํ–‰ ๊ณ„ํš best ์‚ฌ์ง„>> ์ตœ์ ์˜ ์ฟผ๋ฆฌ

์ง€ํ‘œ ์ธ๋ฑ์Šค ์ ์šฉ ์ „ ์ธ๋ฑ์Šค ์ ์šฉ ํ›„
type ALL (Full Table Scan) ref / range
key NULL ํ•ด๋‹น ์ธ๋ฑ์Šค๋ช…
rows 49680 18170
Extra Using filesort -

๐Ÿ”„ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ”Œ๋กœ์šฐ

์ฃผ๋ฌธ ๋ฐ ๊ฒฐ์ œ ํ”Œ๋กœ์šฐ

[์ฃผ๋ฌธ์„œ ์ƒ์„ฑ POST /api/orders]
์ƒํ’ˆ ํŒ๋งค ์ƒํƒœ(ON_SALE) ํ™•์ธ โ†’ ์žฌ๊ณ  ํ™•์ธ โ†’ ์ด ๊ธˆ์•ก ๊ณ„์‚ฐ
โ†’ orders(PENDING) ์ €์žฅ โ†’ order_products ์Šค๋ƒ…์ƒท ์ €์žฅ โ†’ orderUid ๋ฐ˜ํ™˜

[๊ฒฐ์ œ ์ƒ์„ฑ POST /api/orders/{orderUid}/payments]
์ฃผ๋ฌธ PENDING ์ƒํƒœ ํ™•์ธ โ†’ ์ฃผ๋ฌธ์ž ์ •๋ณด ์กด์žฌ ํ™•์ธ(name/phone/address)
โ†’ ์ค‘๋ณต SUCCESS ๊ฒฐ์ œ ์—ฌ๋ถ€ ํ™•์ธ โ†’ ๊ธˆ์•ก ๊ฒ€์ฆ
โ†’ payments(PENDING, expiresAt=+5๋ถ„) ์ €์žฅ โ†’ paymentUid ๋ฐ˜ํ™˜

[๊ฒฐ์ œ ํ™•์ • POST /api/orders/{orderUid}/payments/{paymentUid}/confirm]
๋น„๊ด€์  ๋ฝ์œผ๋กœ Order ์กฐํšŒ โ†’ ์ฃผ๋ฌธ์ž ์†Œ์œ ๊ถŒ ๊ฒ€์ฆ
โ†’ Payment PENDING ํ™•์ธ (์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ๊ฒฝ์šฐ ์ฆ‰์‹œ ๋ฐ˜ํ™˜ โ€” ๋ฉฑ๋“ฑ์„ฑ)
โ†’ ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์žฌ๊ณ  ์ฐจ๊ฐ
โ†’ order_users ์Šค๋ƒ…์ƒท ์ €์žฅ
โ†’ orders: PENDING โ†’ COMPLETED โ†’ DELIVERY_COMPLETED
โ†’ payments: PENDING โ†’ SUCCESS

ํšŒ์›๊ฐ€์ž… / ๋กœ๊ทธ์ธ ํ”Œ๋กœ์šฐ

[ํšŒ์›๊ฐ€์ž…]
์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ โ†’ BCrypt ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” โ†’ users ์ €์žฅ

[๋กœ๊ทธ์ธ]
์ด๋ฉ”์ผ ์กฐํšŒ โ†’ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ
โ†’ Access Token(30๋ถ„) + Refresh Token(7์ผ) ๋ฐœ๊ธ‰
โ†’ HttpOnly ์ฟ ํ‚ค์— ์ €์žฅ

[ํ† ํฐ ์žฌ๋ฐœ๊ธ‰]
Refresh Token ์ฟ ํ‚ค ๊ฒ€์ฆ โ†’ ์ƒˆ Access Token ๋ฐœ๊ธ‰ โ†’ ์ฟ ํ‚ค ๊ฐฑ์‹ 

๐Ÿ›ก๏ธ ๊ธฐ์ˆ ์  ๊ณ ๋ ค์‚ฌํ•ญ

๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ

  • ๊ฒฐ์ œ ํ™•์ •: payment_uid ๊ธฐ์ค€์œผ๋กœ ์ด๋ฏธ SUCCESS ์ƒํƒœ์ธ ๊ฒฐ์ œ๊ฐ€ ์žˆ์œผ๋ฉด ์ค‘๋ณต ์ฒ˜๋ฆฌ ์—†์ด ํ˜„์žฌ ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ (ConfirmPaymentResult.alreadyProcessed)
  • OrderUser ์Šค๋ƒ…์ƒท: ๊ฒฐ์ œ ์žฌ์‹œ๋„๋กœ ์ด๋ฏธ ์ €์žฅ๋œ ์Šค๋ƒ…์ƒท์ด ์žˆ์œผ๋ฉด ์ค‘๋ณต ์ €์žฅ ๋ฐฉ์ง€

๋ณด์•ˆ

  • Access Token + Refresh Token ๋ชจ๋‘ HttpOnly ์ฟ ํ‚ค๋กœ ์ „๋‹ฌ โ†’ XSS ๋ฐฉ์–ด
  • ๋งˆ์ดํŽ˜์ด์ง€ ์กฐํšŒ ์‹œ ์ด๋ฉ”์ผยท์ด๋ฆ„ยท์ „ํ™”๋ฒˆํ˜ธยท์ฃผ์†Œ ๋งˆ์Šคํ‚น ์ฒ˜๋ฆฌ (MaskingUtils)
  • JWT ์‹œํฌ๋ฆฟ ํ‚ค ์ตœ์†Œ 256bit(32๋ฐ”์ดํŠธ) ์ด์ƒ ๊ฐ•์ œ ๊ฒ€์ฆ
  • ๋น„๋ฐ€๋ฒˆํ˜ธ BCryptPasswordEncoder๋กœ ๋‹จ๋ฐฉํ–ฅ ์•”ํ˜ธํ™”

์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง•

// cursorId ์—†์œผ๋ฉด Long.MAX_VALUE๋กœ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ถ€ํ„ฐ
long cursor = (cursorId == null) ? Long.MAX_VALUE : cursorId;

// size+1๊ฐœ ์กฐํšŒ๋กœ hasNext ํŒ๋ณ„
List<Order> orders = orderRepository.findByUserIdWithCursor(loginId, cursor, size);
boolean hasNext = rawContent.size() > size;
Long nextCursor = hasNext ? getId.apply(content.getLast()) : null;

์žฌ๊ณ  ๊ด€๋ฆฌ

  • ์žฌ๊ณ  ์ฐจ๊ฐ์€ ๊ฒฐ์ œ ํ™•์ • ํ›„ ์ˆ˜ํ–‰ โ†’ ๋ฏธ๊ฒฐ์ œ ์ฃผ๋ฌธ์œผ๋กœ ์ธํ•œ ์žฌ๊ณ  ์„ ์  ๋ฐฉ์ง€
  • ์žฌ๊ณ  0 โ†’ ์ž๋™ SOLD_OUT ์ „ํ™˜ / ์žฌ๊ณ  ๋ณต๊ตฌ โ†’ ์ž๋™ ON_SALE ์ „ํ™˜
  • ๋ชจ๋“  ์žฌ๊ณ  ๋ณ€๊ฒฝ โ†’ product_stock_logs ์ด๋ ฅ ์ €์žฅ

๐Ÿš€ ๋กœ์ปฌ ์‹คํ–‰ ๋ฐฉ๋ฒ•

์‚ฌ์ „ ์š”๊ตฌ์‚ฌํ•ญ

  • Docker & Docker Compose

ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์„ค์ •

.env.example์„ ์ฐธ๊ณ ํ•˜์—ฌ .env ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

SPRING_PROFILES_ACTIVE=prod
DB_URL=jdbc:mysql://localhost:3306/allday_project_commerce
DB_USERNAME=root
DB_PASSWORD=your_password
JWT_SECRET_KEY=your_256bit_secret_key

์‹คํ–‰

# Docker Compose๋กœ MySQL + Redis + ์•ฑ ํ•œ ๋ฒˆ์— ์‹คํ–‰
docker compose up -d

# ์•ฑ ๋‹จ๋… ๋กœ์ปฌ ์‹คํ–‰ (H2 ์‚ฌ์šฉ)
./gradlew bootRun --args='--spring.profiles.active=local'

k6 ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ

# ์ด๋ฒคํŠธ ์„ ์ฐฉ์ˆœ ์ฃผ๋ฌธ ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ
PRODUCT_ID=4 VUS=1000 BASE_URL=http://app:8090 DB_PASSWORD=12345678 ./run-event-order-compare.sh

์„œ๋ฒ„ ์‹คํ–‰ ํ›„ http://localhost:8090 ์ ‘์†


๐Ÿ“ˆ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ…

1. Redis ์ง๋ ฌํ™” ๋ฌธ์ œ (LocalDateTime)

๋ฌธ์ œ: Redis์— Page<SearchProductResponse> ์ €์žฅ ์‹œ LocalDateTime ์ง๋ ฌํ™” ์˜ค๋ฅ˜

ํ•ด๊ฒฐ: ObjectMapper์— JavaTimeModule ๋“ฑ๋ก + RestPage ์ปค์Šคํ…€ ๊ตฌํ˜„

ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

2. ์ฑ„ํŒ…๋ฐฉ ๋™์‹œ ์ƒ์„ฑ Race Condition

๋ฌธ์ œ: ๋™์ผ ์œ ์ €๊ฐ€ ๋™์‹œ์— ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ ์š”์ฒญ ์‹œ DataIntegrityViolationException ๋ฐœ์ƒ

ํ•ด๊ฒฐ: REQUIRES_NEW ๋ณ„๋„ ํŠธ๋žœ์žญ์…˜ + ์˜ˆ์™ธ catch ํ›„ ์žฌ์กฐํšŒ ํŒจํ„ด

3. Caffeine ์บ์‹œ ์ตœ๋Œ€ ํฌ๊ธฐ ์ดˆ๊ณผ ์‹œ Eviction ์ง€์—ฐ

๋ฌธ์ œ: maximumSize ์ดˆ๊ณผ ํ›„ Caffeine์˜ ๋น„๋™๊ธฐ Eviction์œผ๋กœ ์‹ค์ œ ํฌ๊ธฐ๊ฐ€ ์ผ์‹œ์ ์œผ๋กœ ์ดˆ๊ณผ

ํ•ด๊ฒฐ: ํ…Œ์ŠคํŠธ์—์„œ nativeCache.cleanUp() ๋ช…์‹œ์  ํ˜ธ์ถœ๋กœ ๊ฐ•์ œ ์ •๋ฆฌ


ยฉ 2026 A.D.P Team โ€” Allday Project Commerce