Allday Project ์ํฐ์คํธ์ ๊ณต์ ๊ตฟ์ฆยท์จ๋ฒยท์ด๋ฒคํธ ํฐ์ผ์ ํ๋งคํ๋ ์ปค๋จธ์ค ํ๋ซํผ
ํ์๊ฐ์ ยท๋ก๊ทธ์ธ๋ถํฐ ์ํ ์กฐํ, ์ฅ๋ฐ๊ตฌ๋, ์ฃผ๋ฌธ, ๊ฒฐ์ ํ์ , ์ค์๊ฐ ์ฑํ ์๋ด๊น์ง
์ปค๋จธ์ค์ ์ ์ฒด ํ๋ฆ์ ์ง์ ์ค๊ณํ๊ณ ๊ตฌํํ ํ๋ก์ ํธ์ ๋๋ค.
ํ๋ก์ ํธ ๊ธฐ๊ฐ: 2026.04.08 ~ 2026.04.28
ํ๋ช
: A.D.P
์๋ฒ ํฌํธ: 8090
- ํ์๋ณ ์ญํ
- ๊ธฐ์ ์คํ
- ํต์ฌ ์ค๊ณ ๊ฒฐ์ ์ฌํญ
- ERD ์ค๊ณ
- ํจํค์ง ๊ตฌ์กฐ
- API ๋ช ์ธ
- ๋์ ๊ตฌํ โ ๋์์ฑ ์ ์ด
- ํ์ ๊ตฌํ โ ์บ์ฑ ๋ฐ ์ธ๊ธฐ ๊ฒ์์ด
- ๋์ ๊ตฌํ โ ์ค์๊ฐ ์ฑํ
- ๋์ ๊ตฌํ โ ์ธ๋ฑ์ค ์ต์ ํ
- ๋น์ฆ๋์ค ๋ก์ง ํ๋ก์ฐ
- ๊ธฐ์ ์ ๊ณ ๋ ค์ฌํญ
- ๋ก์ปฌ ์คํ ๋ฐฉ๋ฒ
| ์ญํ | ์ด๋ฆ | ๋ด๋น ์ ๋ฌด |
|---|---|---|
| ๐ ๋ฆฌ๋ยท๊ฐ๋ฐ | ์ด์ฌ๋ฏผ | ๋ง์ผ์คํค, ์ธ์ฆ/์ธ๊ฐ(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 ์ ์ฒด ๋ค์ด์ด๊ทธ๋จ ์ด๋ฏธ์ง>>

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 ๋ทฐ ์ปจํธ๋กค๋ฌ
| 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();// 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
""";| ์ ๋ต | ์ค๋ช | ์ ์ฉ ์๋๋ฆฌ์ค |
|---|---|---|
| FAIL_FAST | ๋ฝ ํ๋ ์คํจ ์ ์ฆ์ ์์ธ | ๋น ๋ฅธ ์คํจ๊ฐ ํ์ํ ๊ฒฝ์ฐ |
| RETRY | ์ต๋ 15ํ, 100ms ๊ฐ๊ฒฉ ์ฌ์๋ | ์ฌ์๋๊ฐ ์๋ฏธ ์๋ ๊ฒฝ์ฐ |
| BLOCKING | ์ต๋ 5์ด, 50ms ๊ฐ๊ฒฉ ๋๊ธฐ | ์ฒ๋ฆฌ๋๋ณด๋ค ์ ํฉ์ฑ ์ฐ์ |
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);
}๋น์ฆ๋์ค ์ฝ๋์์ ๋ฝ ์ฒ๋ฆฌ ๋ก์ง์ ์์ ํ ๋ถ๋ฆฌํ์ต๋๋ค.
@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
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);| ๋ฐฉ์ | ์ฑ๊ณต ์ | ์ต์ข ์ฌ๊ณ | ์ด๊ณผ ํ๋งค | 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 ๋๋ฆผ |
์ ํ ์ด์ :
- Blocking ์ ๋ต์ผ๋ก ์ฌ๊ณ 100๊ฐ(ํฐ์ผ 10๊ฐ ๊ธฐ์ค)๋ฅผ ๋ชจ๋ ์์ง
- Watchdog์ผ๋ก ๋น์ฆ๋์ค ๋ก์ง์ด ๊ธธ์ด์ ธ๋ TTL ์๋ ์ฐ์ฅ โ ์์
- ๋น๊ด๋ฝ ์ค์ฒฉ(v8) ๋๋น ๋ถํ์ํ DB ๋ฝ ์ค๋ฒํค๋ ์์
- ๋น๊ด๋ฝ ๋๋น DB ๋ถํ ๋ถ์ฐ ๊ฐ๋ฅ
์ํ ๊ฒ์๊ณผ ์ธ๊ธฐ ๊ฒ์์ด๋ ๋ฐ๋ณต ์์ฒญ์ด ๋ง๊ณ ๋ฐ์ดํฐ ๋ณ๊ฒฝ ๋น๋๊ฐ ๋ฎ์ ์ ํ์ ์ธ ์บ์ ์ ์ฉ ๋์์ ๋๋ค.
- ๊ฒ์์ด๊ฐ ๊ฐ์ผ๋ฉด ๊ฒฐ๊ณผ๊ฐ ๊ฐ์ โ ๋งค๋ฒ DB ์กฐํ ๋ถํ์
- ์ธ๊ธฐ ๊ฒ์์ด๋ ์ค์๊ฐ์ด ์๋์ด๋ ๋จ โ 1๋ถ ์บ์๋ก ์ถฉ๋ถ
์์ฒญ โ 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;
}
}| ์บ์๋ช | TTL | ํ์ | ์ต๋ ํฌ๊ธฐ |
|---|---|---|---|
productSearch |
5๋ถ | COMPOSITE (L1+L2) | 500 |
productDetail |
5๋ถ | COMPOSITE (L1+L2) | 500 |
top5Keywords |
1๋ถ | LOCAL (Caffeine๋ง) | 10 |
Scale-out ํ๊ฒฝ์์ ์๋ฒ A์ ์๋ฒ B๊ฐ ๊ฐ๊ฐ ๋ค๋ฅธ ๋ก์ปฌ ์บ์๋ฅผ ๊ฐ์ง๋ฉด ๋ฐ์ดํฐ ๋ถ์ผ์น๊ฐ ๋ฐ์ํฉ๋๋ค. โ Redis L2 ์บ์๋ก ๋ชจ๋ ์๋ฒ๊ฐ ๋์ผํ ์บ์๋ฅผ ๊ณต์
GET /api/products/search/v1?keyword=๋ณผ์บก
โ QueryDSL โ MySQL LIKE ์ฟผ๋ฆฌ โ ๋งค๋ฒ DB ์๋ณต
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 ์กฐํฉ์ผ๋ก ํค ์ถฉ๋ ๋ฐฉ์ง
ํ
์คํธ ์กฐ๊ฑด: VU 50๋ช
, 30์ด, ๋์ผ ํค์๋(apple) ๋ฐ๋ณต ์์ฒญ
k6 ์ฑ๋ฅ ํ
์คํธ ๊ฒฐ๊ณผ โ v1(DB) vs v2(์บ์) ์๋ต ์๊ฐ ๋น๊ตํ

| ์งํ | 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 ์ฑ๋ฅ ํ
์คํธ ๊ฒฐ๊ณผ ์ฌ์ง

v2 ์ฑ๋ฅ ํ
์คํธ ๊ฒฐ๊ณผ ์ฌ์ง

์ํ ์์ ์ ๊ด๋ จ ์บ์๋ฅผ ์ฆ์ ์ญ์ ํฉ๋๋ค.
@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ํ ์ ํ
Redis ์ฅ์ ๋๋ ์์ ์ด๊ธฐํ ์งํ ๋ฐ์ดํฐ๊ฐ ์์ ๋:
- ์ค๋
popular_keywords์ค๋ ์ท ์กฐํ - ์์ผ๋ฉด ์ด์ ์ค๋ ์ท ์กฐํ
- ์๋ฒ ์ฌ์์ ์
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} ๋ธ๋ก๋์บ์คํธ
<<์ค์๊ฐ ์ฑํ ์ํคํ ์ฒ ๊ตฌ์ฑ๋>>
- HTTP ํด๋ง ๋๋น ์ค์๊ฐ ์๋ฐฉํฅ ํต์ ๊ฐ๋ฅ, ๋ถํ์ํ ์์ฒญ ์์
- ์์ WebSocket๋ง์ผ๋ก๋ ๋ฉ์์ง ๋ผ์ฐํ , ๊ตฌ๋ /๋ฐํ ํจํด ์ง์ ๊ตฌํ ํ์ โ STOMP๋ก ํด๊ฒฐ
๋จ์ผ ์๋ฒ์์๋ WebSocket ์ธ์ ์ด ๊ฐ์ ์๋ฒ์ ์์ด ์ง์ ์ ๋ฌ ๊ฐ๋ฅํฉ๋๋ค. ์๋ฒ 2๋ ์ด์์ด๋ฉด ์๋ฒ A์ ์ ์ํ ์ฌ์ฉ์์ ์๋ฒ B์ ์ ์ํ ์ฌ์ฉ์๊ฐ ๋ฉ์์ง๋ฅผ ์ฃผ๊ณ ๋ฐ์ ์ ์์ต๋๋ค.
ํด๊ฒฐ: Redis ์ฑ๋(chat:room:{roomId})์ ๋งค๊ฐ๋ก ๋ชจ๋ ์๋ฒ๊ฐ ๋ฉ์์ง๋ฅผ ๋ธ๋ก๋์บ์คํธํฉ๋๋ค.
// Publisher: ์ด๋ ์๋ฒ์์๋ Redis ์ฑ๋์ ๋ฐํ
chatRedisTemplate.convertAndSend("chat:room:" + roomId, message);
// Subscriber: ๋ชจ๋ ์๋ฒ์์ ์์ โ ์๊ธฐ ์๋ฒ ๊ตฌ๋
์์๊ฒ ์ ๋ฌ
simpMessagingTemplate.convertAndSend("/sub/chat/" + roomId, response);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๋ง ๊ฑด ์ด์)์์ ์์ฃผ ์คํ๋๋ ์ฟผ๋ฆฌ๋ฅผ ์ ์ ํ์ฌ ์ธ๋ฑ์ค๋ฅผ ์ ์ฉํ์ต๋๋ค.
-- 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 ์คํ ๊ณํ Before ์ฌ์ง>>

<<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'# ์ด๋ฒคํธ ์ ์ฐฉ์ ์ฃผ๋ฌธ ๋ถํ ํ
์คํธ
PRODUCT_ID=4 VUS=1000 BASE_URL=http://app:8090 DB_PASSWORD=12345678 ./run-event-order-compare.sh์๋ฒ ์คํ ํ http://localhost:8090 ์ ์
๋ฌธ์ : Redis์ Page<SearchProductResponse> ์ ์ฅ ์ LocalDateTime ์ง๋ ฌํ ์ค๋ฅ
ํด๊ฒฐ: ObjectMapper์ JavaTimeModule ๋ฑ๋ก + RestPage ์ปค์คํ
๊ตฌํ
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);๋ฌธ์ : ๋์ผ ์ ์ ๊ฐ ๋์์ ์ฑํ
๋ฐฉ ์์ฑ ์์ฒญ ์ DataIntegrityViolationException ๋ฐ์
ํด๊ฒฐ: REQUIRES_NEW ๋ณ๋ ํธ๋์ญ์
+ ์์ธ catch ํ ์ฌ์กฐํ ํจํด
๋ฌธ์ : maximumSize ์ด๊ณผ ํ Caffeine์ ๋น๋๊ธฐ Eviction์ผ๋ก ์ค์ ํฌ๊ธฐ๊ฐ ์ผ์์ ์ผ๋ก ์ด๊ณผ
ํด๊ฒฐ: ํ
์คํธ์์ nativeCache.cleanUp() ๋ช
์์ ํธ์ถ๋ก ๊ฐ์ ์ ๋ฆฌ
ยฉ 2026 A.D.P Team โ Allday Project Commerce