diff --git a/README.md b/README.md index 92ae33b..852fd83 100644 --- a/README.md +++ b/README.md @@ -1 +1,827 @@ -# Allday-Project-Commerce-Backend \ No newline at end of file +# ๐Ÿ›’ Allday Project Commerce + +> **Allday Project** ์•„ํ‹ฐ์ŠคํŠธ์˜ ๊ณต์‹ ๊ตฟ์ฆˆยท์•จ๋ฒ”ยท์ด๋ฒคํŠธ ํ‹ฐ์ผ“์„ ํŒ๋งคํ•˜๋Š” ์ปค๋จธ์Šค ํ”Œ๋žซํผ +> ํšŒ์›๊ฐ€์ž…ยท๋กœ๊ทธ์ธ๋ถ€ํ„ฐ ์ƒํ’ˆ ์กฐํšŒ, ์žฅ๋ฐ”๊ตฌ๋‹ˆ, ์ฃผ๋ฌธ, ๊ฒฐ์ œ ํ™•์ •, ํ™˜๋ถˆ, ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ์ƒ๋‹ด๊นŒ์ง€ +> ์ปค๋จธ์Šค์˜ ์ „์ฒด ํ๋ฆ„์„ ์ง์ ‘ ์„ค๊ณ„ํ•˜๊ณ  ๊ตฌํ˜„ํ•œ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค. + +**ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„:** 2026.04.08 ~ 2026.04.28 +**ํŒ€๋ช…:** A.D.P +**์„œ๋ฒ„ ํฌํŠธ:** 8090 + +--- + +## ๐Ÿ“‹ ๋ชฉ์ฐจ + +1. [ํŒ€์›๋ณ„ ์—ญํ• ](#-ํŒ€์›๋ณ„-์—ญํ• ) +2. [๊ธฐ์ˆ  ์Šคํƒ](#-๊ธฐ์ˆ -์Šคํƒ) +3. [ํ•ต์‹ฌ ์„ค๊ณ„ ๊ฒฐ์ •์‚ฌํ•ญ](#-ํ•ต์‹ฌ-์„ค๊ณ„-๊ฒฐ์ •์‚ฌํ•ญ) +4. [ERD ์„ค๊ณ„](#-erd-์„ค๊ณ„) +5. [ํŒจํ‚ค์ง€ ๊ตฌ์กฐ](#-ํŒจํ‚ค์ง€-๊ตฌ์กฐ) +6. [API ๋ช…์„ธ](#-api-๋ช…์„ธ) +7. [ํ•„์ˆ˜ ๊ตฌํ˜„ โ€” ๋™์‹œ์„ฑ ์ œ์–ด](#-ํ•„์ˆ˜-๊ตฌํ˜„--๋™์‹œ์„ฑ-์ œ์–ด) +8. [ํ•„์ˆ˜ ๊ตฌํ˜„ โ€” ์บ์‹ฑ ๋ฐ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด](#-ํ•„์ˆ˜-๊ตฌํ˜„--์บ์‹ฑ-๋ฐ-์ธ๊ธฐ-๊ฒ€์ƒ‰์–ด) +9. [๋„์ „ ๊ตฌํ˜„ โ€” ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…](#-๋„์ „-๊ตฌํ˜„--์‹ค์‹œ๊ฐ„-์ฑ„ํŒ…) +10. [๋„์ „ ๊ตฌํ˜„ โ€” ์ธ๋ฑ์Šค ์ตœ์ ํ™”](#-๋„์ „-๊ตฌํ˜„--์ธ๋ฑ์Šค-์ตœ์ ํ™”) +11. [๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ”Œ๋กœ์šฐ](#-๋น„์ฆˆ๋‹ˆ์Šค-๋กœ์ง-ํ”Œ๋กœ์šฐ) +12. [๊ธฐ์ˆ ์  ๊ณ ๋ ค์‚ฌํ•ญ](#-๊ธฐ์ˆ ์ -๊ณ ๋ ค์‚ฌํ•ญ) +13. [๋กœ์ปฌ ์‹คํ–‰ ๋ฐฉ๋ฒ•](#-๋กœ์ปฌ-์‹คํ–‰-๋ฐฉ๋ฒ•) + +--- + +## ๐Ÿ‘ฅ ํŒ€์›๋ณ„ ์—ญํ•  + +| ์—ญํ•  | ์ด๋ฆ„ | ๋‹ด๋‹น ์—…๋ฌด | +|------|------|-----------| +| ๐Ÿ‘‘ ๋ฆฌ๋”ยท๊ฐœ๋ฐœ | ์ด์žฌ๋ฏผ | ๋งˆ์ผ์Šคํ†ค, ์ธ์ฆ/์ธ๊ฐ€(JWT), ๊ณตํ†ต ์ฝ”๋“œ, ์ฃผ๋ฌธ ๋„๋ฉ”์ธ, ํ”„๋ก ํŠธ์—”๋“œ | +| ๐Ÿ’ณ ๊ฐœ๋ฐœยท๊ธฐ๋ก | ๋ฌธํ˜œ๋ฆฐ | ์‚ฌ์šฉ์ž ๋„๋ฉ”์ธ, ํšŒ์˜๋กยทSA ๋ฌธ์„œ, API ๋ช…์„ธ์„œ, ERD | +| ๐Ÿ“ฆ ๊ฐœ๋ฐœ | ๋ฐ•๊ฒฝํ™” | ์ƒํ’ˆ ๋„๋ฉ”์ธ, ํ”„๋ก ํŠธ์—”๋“œ | +| ๐Ÿ’ฐ ๊ฐœ๋ฐœ | ๋ฐ•์†Œ์˜ | ๊ฒฐ์ œยทํ™˜๋ถˆ ๋„๋ฉ”์ธ, API ๋ช…์„ธ์„œ, ERD | + +--- + +## ๐Ÿ› ๏ธ ๊ธฐ์ˆ  ์Šคํƒ + +| ๋ถ„๋ฅ˜ | ๊ธฐ์ˆ  | +|------|------| +| 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`์— ๊ธฐ๋ก | +| ๋™์‹œ์„ฑ ์ฒ˜๋ฆฌ | Lettuce ๊ธฐ๋ฐ˜ Redis ๋ถ„์‚ฐ ๋ฝ (FAIL_FAST / RETRY / BLOCKING) + Redisson (Watchdog) + AOP | +| ์ฃผ๋ฌธ ์Šค๋ƒ…์ƒท | ๊ฒฐ์ œ ์™„๋ฃŒ ์‹œ `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 ์„ค๊ณ„ + +<> + +### ์ฃผ์š” ํ…Œ์ด๋ธ” ์„ค๋ช… + +
+users โ€” ์‚ฌ์šฉ์ž + +| ์ปฌ๋Ÿผ | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| id | BIGINT PK | AUTO_INCREMENT | +| name | VARCHAR(20) | NULL ํ—ˆ์šฉ | +| email | VARCHAR(100) | UNIQUE NOT NULL | +| password | VARCHAR(255) | BCrypt ์•”ํ˜ธํ™” | +| phone | VARCHAR(100) | NULL ํ—ˆ์šฉ | +| address | VARCHAR(250) | NULL ํ—ˆ์šฉ | +| role | ENUM(ADMIN, USER) | NOT NULL DEFAULT USER | + +
+ +
+products โ€” ์ƒํ’ˆ + +| ์ปฌ๋Ÿผ | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| id | BIGINT PK | AUTO_INCREMENT | +| name | VARCHAR(100) | NOT NULL | +| price | BIGINT | NOT NULL (โ‰ฅ0) | +| stock | INT | NOT NULL (โ‰ฅ0) | +| status | ENUM | ON_SALE / SOLD_OUT / DISCONTINUED | +| category | ENUM | ALBUM / MERCH / EVENT | + +> **INDEX:** `price` / `name` / `(status, id)` / `(category, status)` + +
+ +
+orders โ€” ์ฃผ๋ฌธ / order_products โ€” ์ฃผ๋ฌธ ์Šค๋ƒ…์ƒท / order_users โ€” ์ฃผ๋ฌธ์ž ์Šค๋ƒ…์ƒท + +- `orders`: `order_uid`(NanoId ๊ธฐ๋ฐ˜ ๋น„์ฆˆ๋‹ˆ์Šค ์‹๋ณ„์ž) ์‚ฌ์šฉ, ๋‚ด๋ถ€ PK ์™ธ๋ถ€ ๋…ธ์ถœ ๋ฐฉ์ง€ +- `order_products`: ๊ฒฐ์ œ ์™„๋ฃŒ ์‹œ ์ƒํ’ˆ๋ช…ยท๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท ์ €์žฅ (์ƒํ’ˆ ์ˆ˜์ • ํ›„์—๋„ ์ฃผ๋ฌธ ๋‹น์‹œ ์ •๋ณด ๋ณด์กด) +- `order_users`: ๊ฒฐ์ œ ์™„๋ฃŒ ์‹œ ์ฃผ๋ฌธ์ž ์ •๋ณด ์Šค๋ƒ…์ƒท ์ €์žฅ + +> **INDEX:** `(user_id, id DESC)` โ€” ์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง• ์ตœ์ ํ™” + +
+ +
+payments โ€” ๊ฒฐ์ œ / refunds โ€” ํ™˜๋ถˆ + +- `payments`: `payment_uid`(PAY-YYYYMMDD-XXXXXXXX), `expires_at`(์ƒ์„ฑ ํ›„ 5๋ถ„) ๊ด€๋ฆฌ +- `refunds`: ํ™˜๋ถˆ ์š”์ฒญ(REQUESTED) โ†’ ์™„๋ฃŒ(SUCCESS) / ์‹คํŒจ(FAILED) ์ƒํƒœ ๊ด€๋ฆฌ + +
+ +
+search_keywords / popular_keywords โ€” ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด + +- `search_keywords`: `UNIQUE(keyword, search_date)` โ€” ๋‚ ์งœ๋ณ„ ๊ฒ€์ƒ‰ ํšŸ์ˆ˜ ์ง‘๊ณ„ +- `popular_keywords`: ์ž์ • Top5 ์Šค๋ƒ…์ƒท. `is_fallback=true`๋Š” ๋Œ€์ฒด ํ‚ค์›Œ๋“œ + +
+ +
+chat_rooms / chat_messages โ€” ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… + +- `chat_rooms`: `UNIQUE(user_id, active_flag)` + NULL ํŠธ๋ฆญ์œผ๋กœ ํ™œ์„ฑ ๋ฐฉ 1๊ฐœ ์ œํ•œ +- `chat_messages`: `INDEX(room_id, id)` โ€” ์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง• ์ตœ์ ํ™” + +
+ +--- + +## ๐Ÿ“ ํŒจํ‚ค์ง€ ๊ตฌ์กฐ + +``` +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 (SpEL ํ‚ค ํŒŒ์‹ฑ) +โ”‚ โ”‚ โ”œโ”€โ”€ 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/ # ๊ฒฐ์ œ (์ƒ์„ฑยทํ™•์ •ยท๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ) + โ”œโ”€โ”€ refund/ # ํ™˜๋ถˆ (์š”์ฒญยท์ƒํƒœ ๊ด€๋ฆฌ) + โ”œโ”€โ”€ 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๋ช… ๋™์‹œ ์š”์ฒญ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```java +// 100๊ฐœ ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ์ถœ๋ฐœ +CyclicBarrier startBarrier = new CyclicBarrier(100); +// ๋ฝ ์—†๋Š” ๋ฒ„์ „์€ ์ •ํ•ฉ์„ฑ์ด ๊นจ์ง€๋Š” ๊ฒƒ์„ ํ…Œ์ŠคํŠธ๋กœ ์ฆ๋ช… +assertThat(isExactlyCorrect).isFalse(); // ๋ฝ ์—†์Œ โ†’ ํ…Œ์ŠคํŠธ ์˜๋„์  ์‹คํŒจ +``` + +<<๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ ์‹คํŒจ ๊ฒฐ๊ณผ ์‚ฌ์ง„ (๋ฝ ์—†์Œ - ์žฌ๊ณ  ์ •ํ•ฉ์„ฑ ๊นจ์ง)>> + +### Redis ๋ถ„์‚ฐ ๋ฝ ๊ตฌํ˜„ + +#### Lettuce ๊ธฐ๋ฐ˜ ๋ถ„์‚ฐ ๋ฝ (`RedisLockRepository`) + +```java +// 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 ๊ฐ„๊ฒฉ ๋Œ€๊ธฐ | ์ฒ˜๋ฆฌ๋Ÿ‰๋ณด๋‹ค ์ •ํ•ฉ์„ฑ ์šฐ์„  | + +#### AOP ๊ธฐ๋ฐ˜ ๋ฝ ์ ์šฉ (`@RedisLock`, `@RedissonLock`) + +๋น„์ฆˆ๋‹ˆ์Šค ์ฝ”๋“œ์—์„œ ๋ฝ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ์™„์ „ํžˆ ๋ถ„๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```java +@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 ๋ ˆ๋ฒจ์—์„œ ๋™์‹œ ์ฒ˜๋ฆฌ๋ฅผ ์ง๋ ฌํ™”ํ•ฉ๋‹ˆ๋‹ค. + +```java +@Lock(LockModeType.PESSIMISTIC_WRITE) +@Query("SELECT p FROM Product p WHERE p.id = :productId") +Optional findByIdForUpdate(@Param("productId") Long productId); +``` + +### ๋ฝ ๋ฒ„์ „๋ณ„ ๋น„๊ต ํ…Œ์ŠคํŠธ (v1 ~ v8) + +| ๋ฒ„์ „ | ์ „๋žต | ์„ฑ๊ณต ์ˆ˜ | ์‹คํŒจ ์ˆ˜ | ์ •ํ•ฉ์„ฑ | +|------|------|---------|---------|--------| +| v1 | ๋ฝ ์—†์Œ | - | - | โŒ ๊นจ์ง | +| v2 | DB ๋น„๊ด€์  ๋ฝ๋งŒ | 10 | 90 | โœ… | +| v3 | Redisson Retry + TTL | 10 | 90 | โœ… | +| v4 | Redisson Retry + Watchdog | 10 | 90 | โœ… | +| v5 | Redisson FailFast | 0~1 | 99~100 | โœ… | +| v6 | Redisson Blocking + TTL | 10 | 90 | โœ… | +| **v7** | **Redisson Blocking + Watchdog** | **10** | **90** | **โœ… ์ตœ์ข… ์„ ํƒ** | +| v8 | Redisson Blocking + Watchdog + ๋น„๊ด€๋ฝ | 10 | 90 | โœ… | + +#### ์ตœ์ข… ์„ ํƒ: **v7 โ€” Redisson Blocking + Watchdog** + +**์„ ํƒ ์ด์œ :** +- Blocking ์ „๋žต์œผ๋กœ ์žฌ๊ณ  100๊ฐœ(ํ‹ฐ์ผ“ 10๊ฐœ ๊ธฐ์ค€)๋ฅผ ์ตœ๋Œ€ํ•œ ์†Œ์ง„ +- Watchdog์œผ๋กœ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ๊ธธ์–ด์ ธ๋„ TTL ์ž๋™ ์—ฐ์žฅ โ†’ ์•ˆ์ „ +- ๋น„๊ด€๋ฝ ์ค‘์ฒฉ(v8) ๋Œ€๋น„ ๋ถˆํ•„์š”ํ•œ DB ๋ฝ ์˜ค๋ฒ„ํ—ค๋“œ ์—†์Œ + +<<๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ ์„ฑ๊ณต ๊ฒฐ๊ณผ ์‚ฌ์ง„ (๋ฝ ์ ์šฉ ํ›„ โ€” ์žฌ๊ณ  10๊ฐœ ์ •ํ™•ํžˆ ์†Œ์ง„)>> + +<> + +--- + +## ๐Ÿ” ํ•„์ˆ˜ ๊ตฌํ˜„ โ€” ์บ์‹ฑ ๋ฐ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด + +### ์™œ ์บ์‹ฑ์„ ์ ์šฉํ–ˆ๋‚˜? + +์ƒํ’ˆ ๊ฒ€์ƒ‰๊ณผ ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด๋Š” **๋ฐ˜๋ณต ์š”์ฒญ์ด ๋งŽ๊ณ  ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ๋นˆ๋„๊ฐ€ ๋‚ฎ์€** ์ „ํ˜•์ ์ธ ์บ์‹œ ์ ์šฉ ๋Œ€์ƒ์ž…๋‹ˆ๋‹ค. +- ๊ฒ€์ƒ‰์–ด๊ฐ€ ๊ฐ™์œผ๋ฉด ๊ฒฐ๊ณผ๊ฐ€ ๊ฐ™์Œ โ†’ ๋งค๋ฒˆ DB ์กฐํšŒ ๋ถˆํ•„์š” +- ์ธ๊ธฐ ๊ฒ€์ƒ‰์–ด๋Š” ์‹ค์‹œ๊ฐ„์ด ์•„๋‹ˆ์–ด๋„ ๋จ โ†’ 1๋ถ„ ์บ์‹œ๋กœ ์ถฉ๋ถ„ + +### L1 + L2 ๋ณตํ•ฉ ์บ์‹œ ๊ตฌ์กฐ (`CompositeCacheManager`) + +``` +์š”์ฒญ โ†’ L1 Caffeine(๋กœ์ปฌ ์ธ๋ฉ”๋ชจ๋ฆฌ) โ†’ L2 Redis(๋ถ„์‚ฐ ์บ์‹œ) โ†’ DB + โ†‘ ์—†์œผ๋ฉด L2 ์กฐํšŒ ํ›„ L1 ์ €์žฅ +``` + +```java +// 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 (์บ์‹œ ๋ฏธ์Šค ์‹œ๋งŒ) +``` + +```java +@Cacheable( + value = "productSearch", + key = "'product:' + #searchRequest.keyword() + ':' + #pageable.pageNumber + ':' + #pageable.pageSize", + sync = true // ๋™์‹œ DB ์กฐํšŒ ๋ฐฉ์ง€ +) +public RestPage searchProductsV2( + SearchProductRequest searchRequest, Pageable pageable) { ... } +``` + +**์บ์‹œ Key ์„ค๊ณ„:** `keyword:pageNumber:pageSize` ์กฐํ•ฉ์œผ๋กœ ํ‚ค ์ถฉ๋Œ ๋ฐฉ์ง€ + +### ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ (k6) + +**ํ…Œ์ŠคํŠธ ์กฐ๊ฑด:** VU 50๋ช…, 30์ดˆ, ๋™์ผ ํ‚ค์›Œ๋“œ(`apple`) ๋ฐ˜๋ณต ์š”์ฒญ + +<> + +| ์ง€ํ‘œ | v1 (DB) | v2 (์บ์‹œ) | ๊ฐœ์„ ์œจ | +|------|---------|-----------|--------| +| ํ‰๊ท  ์‘๋‹ต์‹œ๊ฐ„ | <> ms | <> ms | <<๊ฐœ์„ ์œจ>>% | +| p95 ์‘๋‹ต์‹œ๊ฐ„ | <> ms | <> ms | - | +| TPS | <> | <> | - | + +<> + +<> + +### ์บ์‹œ ๋ฌดํšจํ™” (`@CacheEvict`) + +์ƒํ’ˆ ์ˆ˜์ • ์‹œ ๊ด€๋ จ ์บ์‹œ๋ฅผ ์ฆ‰์‹œ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + +```java +@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}`)์„ ๋งค๊ฐœ๋กœ ๋ชจ๋“  ์„œ๋ฒ„๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธํ•ฉ๋‹ˆ๋‹ค. + +```java +// 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๋ฅผ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. + +```java +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 ์„ค์ • +} +``` + +### ์ฑ„ํŒ…๋ฐฉ ๋™์‹œ ์ƒ์„ฑ ์•ˆ์ „ ์ฒ˜๋ฆฌ + +```java +// 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๋ถ„ ๊ฒฝ๊ณผ ๋ฐฉ์„ ์ž๋™ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค. + +```java +// No-Offset ๋ฐฐ์น˜ ์กฐํšŒ (OOM ๋ฐฉ์ง€) +List targets = chatRoomRepository.findInactiveRooms(cutOff, lastId, BATCH_SIZE); + +// Bulk UPDATE (IN ์ฟผ๋ฆฌ๋กœ ํ•œ ๋ฒˆ์— ์ƒํƒœ ๋ณ€๊ฒฝ โ†’ DB ์ปค๋„ฅ์…˜ ์ตœ์†Œํ™”) +chatRoomRepository.bulkCompleteRooms(roomIds); +``` + +<<์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ํ™”๋ฉด ์บก์ฒ˜>> + +--- + +## ๐Ÿ“Š ๋„์ „ ๊ตฌํ˜„ โ€” ์ธ๋ฑ์Šค ์ตœ์ ํ™” + +### ์ธ๋ฑ์Šค ์„ค๊ณ„ ๊ธฐ์ค€ + +๋Œ€์šฉ๋Ÿ‰ ๋ฐ์ดํ„ฐ(5๋งŒ ๊ฑด ์ด์ƒ)์—์„œ ์ž์ฃผ ์‹คํ–‰๋˜๋Š” ์ฟผ๋ฆฌ๋ฅผ ์„ ์ •ํ•˜์—ฌ ์ธ๋ฑ์Šค๋ฅผ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. + +### ์ ์šฉ๋œ ์ธ๋ฑ์Šค + +#### products ํ…Œ์ด๋ธ” + +```sql +-- 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); +``` + +#### orders ํ…Œ์ด๋ธ” + +```sql +-- ์œ ์ €๋ณ„ ์ฃผ๋ฌธ ์ปค์„œ ํŽ˜์ด์ง•: WHERE user_id = ? AND id < ? ORDER BY id DESC +CREATE INDEX idx_orders_user_id_id ON orders (user_id, id DESC); + +-- ์ƒํƒœ๋ณ„ ์กฐํšŒ +CREATE INDEX idx_orders_status ON orders (order_status); +``` + +#### order_products ํ…Œ์ด๋ธ” + +```sql +-- findByOrderId, findByOrderIdIn ์ตœ์ ํ™” +CREATE INDEX idx_order_products_order_id ON order_products (order_id); + +-- ์ด๋ฒคํŠธ ์ƒํ’ˆ ์ค‘๋ณต ์ฒดํฌ +CREATE INDEX idx_order_products_product_id ON order_products (product_id); +``` + +#### search_keywords ํ…Œ์ด๋ธ” + +```sql +-- ๋‚ ์งœ๋ณ„ ๊ฒ€์ƒ‰ ํšŸ์ˆ˜ ์ˆœ์œ„ ์กฐํšŒ +CREATE INDEX idx_search_date_count ON search_keywords (search_date, search_count); +CREATE INDEX idx_keyword ON search_keywords (keyword); +``` + +#### chat_messages ํ…Œ์ด๋ธ” + +```sql +-- ์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง•: WHERE room_id = ? AND id < ? ORDER BY id DESC +CREATE INDEX idx_chat_messages_room_cursor ON chat_messages (room_id, id); +``` + +### EXPLAIN ๋ถ„์„ ๊ฒฐ๊ณผ + +<> + +<> + +| ์ง€ํ‘œ | ์ธ๋ฑ์Šค ์ ์šฉ ์ „ | ์ธ๋ฑ์Šค ์ ์šฉ ํ›„ | +|------|---------------|---------------| +| type | ALL (Full Table Scan) | ref / range | +| key | NULL | ํ•ด๋‹น ์ธ๋ฑ์Šค๋ช… | +| rows | <<์ „์ฒด ํ–‰ ์ˆ˜>> | <<์Šค์บ” ํ–‰ ์ˆ˜>> | +| Extra | Using filesort | - | + +<<์ฟผ๋ฆฌ ์‘๋‹ต์‹œ๊ฐ„ Before/After ๋น„๊ต ๊ทธ๋ž˜ํ”„ (5๋งŒ๊ฑด ๋ฐ์ดํ„ฐ ๊ธฐ์ค€)>> + +--- + +## ๐Ÿ”„ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ”Œ๋กœ์šฐ + +### ์ฃผ๋ฌธ ๋ฐ ๊ฒฐ์ œ ํ”Œ๋กœ์šฐ + +``` +[์ฃผ๋ฌธ์„œ ์ƒ์„ฑ 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`๋กœ ๋‹จ๋ฐฉํ–ฅ ์•”ํ˜ธํ™” + +### ์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง• + +```java +// cursorId ์—†์œผ๋ฉด Long.MAX_VALUE๋กœ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ถ€ํ„ฐ +long cursor = (cursorId == null) ? Long.MAX_VALUE : cursorId; + +// size+1๊ฐœ ์กฐํšŒ๋กœ hasNext ํŒ๋ณ„ +List 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 +- Java 21 + +### ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์„ค์ • + +`.env.example`์„ ์ฐธ๊ณ ํ•˜์—ฌ `.env` ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + +```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 +``` + +### ์‹คํ–‰ + +```bash +# Docker Compose๋กœ MySQL + Redis + ์•ฑ ํ•œ ๋ฒˆ์— ์‹คํ–‰ +docker compose up -d + +# ์•ฑ ๋‹จ๋… ๋กœ์ปฌ ์‹คํ–‰ (H2 ์‚ฌ์šฉ) +./gradlew bootRun --args='--spring.profiles.active=local' +``` + +### k6 ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ + +```bash +# ์ด๋ฒคํŠธ ์„ ์ฐฉ์ˆœ ์ฃผ๋ฌธ ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ +docker compose --profile test run k6 run /scripts/event-order-load-test.js + +# ๋ฒ„์ „๋ณ„ ๋น„๊ต ํ…Œ์ŠคํŠธ (v1~v8) +bash run-event-order-compare.sh +``` + +์„œ๋ฒ„ ์‹คํ–‰ ํ›„ `http://localhost:8090` ์ ‘์† + +--- + +## ๐Ÿ“ˆ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… + +### 1. Redis ์ง๋ ฌํ™” ๋ฌธ์ œ (`LocalDateTime`) + +**๋ฌธ์ œ:** Redis์— `Page` ์ €์žฅ ์‹œ `LocalDateTime` ์ง๋ ฌํ™” ์˜ค๋ฅ˜ + +**ํ•ด๊ฒฐ:** `ObjectMapper`์— `JavaTimeModule` ๋“ฑ๋ก + `RestPage` ์ปค์Šคํ…€ ๊ตฌํ˜„ + +```java +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*