์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ๋ฅผ ์ ์ฉํ ํ์ฅ ๊ฐ๋ฅํ ์ด์ปค๋จธ์ค ํ๋ซํผ์ ๋๋ค.
Spring Boot 3 ๊ธฐ๋ฐ์ ํ๋ก๋์ ๋ ๋ฒจ ์ด์ปค๋จธ์ค API ํ๋ซํผ์ผ๋ก, MSA(Microservices Architecture) ์ ํ์ ์ํ ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ค๊ณ๋ฅผ ์ ์ฉํ์ต๋๋ค.
- โ ํฅ์ฌ๊ณ ๋ ์ํคํ ์ฒ - Ports & Adapters ํจํด์ผ๋ก ๋๋ฉ์ธ๊ณผ ์ธํ๋ผ ๊ณ์ธต ๋ถ๋ฆฌ
- โ ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ค๊ณ - Kafka๋ฅผ ํ์ฉํ ๋น๋๊ธฐ ๋ฉ์์ง ๋ฐ ์ด๋ฒคํธ ์คํธ๋ฆฌ๋ฐ
- โ ๋ถ์ฐ ์์คํ ํจํด - Outbox ํจํด, Saga ํจํด, ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ
- โ ๋์์ฑ ์ ์ด - Redis ๋ถ์ฐ ๋ฝ, ๋๊ด์ /๋น๊ด์ ๋ฝ
- โ ๋ค์ธต ์บ์ฑ - Redis ์บ์๋ฅผ ํ์ฉํ ์ฑ๋ฅ ์ต์ ํ
- โ ํ์ฅ ๊ฐ๋ฅํ ์ค๊ณ - ์ปค์ ๊ธฐ๋ฐ ํ์ด์ง, Kafka ํํฐ์ ๋
graph TB
subgraph "Client Layer"
Client[ํด๋ผ์ด์ธํธ<br/>Web/Mobile]
end
subgraph "Application Layer"
API[REST API<br/>Controllers]
Service[Application<br/>Services]
Domain[Domain<br/>Models]
end
subgraph "Infrastructure Layer"
JPA[JPA<br/>Adapters]
RedisAdapter[Redis<br/>Client]
KafkaAdapter[Kafka<br/>Producer]
end
subgraph "Data Layer"
MySQL[(MySQL 8.0<br/>์ฃผ๋ฌธ/์ํ/์ง๊ฐ)]
Redis[(Redis 7.0<br/>์บ์/๋ฝ/๋ญํน)]
end
subgraph "Messaging Layer"
Kafka[Apache Kafka<br/>3-Broker Cluster]
Consumer1[Ranking<br/>Updater]
Consumer2[Data<br/>Platform]
Consumer3[Coupon<br/>Issuer]
end
Client --> API
API --> Service
Service --> Domain
Domain --> JPA
Domain --> RedisAdapter
Domain --> KafkaAdapter
JPA --> MySQL
RedisAdapter --> Redis
KafkaAdapter --> Kafka
Kafka --> Consumer1
Kafka --> Consumer2
Kafka --> Consumer3
Consumer1 --> Redis
Consumer3 --> MySQL
style Client fill:#e1f5ff
style API fill:#fff4e1
style Service fill:#ffe1f5
style Domain fill:#f0e1ff
style MySQL fill:#e1ffe1
style Redis fill:#ffe1e1
style Kafka fill:#fff9e1
graph LR
subgraph "Inbound Adapters"
REST[REST<br/>Controllers]
Kafka_In[Kafka<br/>Consumers]
end
subgraph "Application Core"
Ports_In[Inbound<br/>Ports]
Services[Application<br/>Services]
Domain[Domain<br/>Models]
Ports_Out[Outbound<br/>Ports]
end
subgraph "Outbound Adapters"
JPA[JPA<br/>Repositories]
Redis[Redis<br/>Client]
Kafka_Out[Kafka<br/>Producers]
end
REST --> Ports_In
Kafka_In --> Ports_In
Ports_In --> Services
Services --> Domain
Services --> Ports_Out
Ports_Out --> JPA
Ports_Out --> Redis
Ports_Out --> Kafka_Out
style REST fill:#e1f5ff
style Kafka_In fill:#e1f5ff
style Services fill:#ffe1f5
style Domain fill:#f0e1ff
style JPA fill:#e1ffe1
style Redis fill:#ffe1e1
style Kafka_Out fill:#fff9e1
| ๋ฉ์๋ | ์๋ํฌ์ธํธ | ์ค๋ช | ์ฃผ์ ๊ธฐ๋ฅ |
|---|---|---|---|
| GET | /api/v1/products |
์ํ ๋ชฉ๋ก ์กฐํ | ์ปค์ ๊ธฐ๋ฐ ํ์ด์ง, ๊ฒ์, Redis ์บ์ฑ (3๋ถ) |
| GET | /api/v1/products/{id} |
์ํ ์์ธ ์กฐํ | Redis ์บ์ฑ (10๋ถ) |
| POST | /api/v1/wallets/{userId}/topups |
์ง๊ฐ ์ถฉ์ | ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ, ๋น๊ด์ ๋ฝ |
| POST | /api/v1/orders |
์ฃผ๋ฌธ ์์ฑ | ์ฌ๊ณ ๊ฒ์ฆ, ์ฟ ํฐ ์ ์ฉ, ๋ถ์ฐ ๋ฝ |
| PATCH | /api/v1/orders/{orderId}/complete |
์ฃผ๋ฌธ ์๋ฃ | ์ด๋ฒคํธ ๋ฐํ (Kafka) |
| POST | /coupons/{id}/issue |
์ฟ ํฐ ๋ฐ๊ธ (๋๊ธฐ) | ๋ถ์ฐ ๋ฝ, Redis ์์์ ์นด์ดํฐ |
| POST | /coupons/{id}/issue-async |
์ฟ ํฐ ๋ฐ๊ธ (๋น๋๊ธฐ) | Kafka ๊ธฐ๋ฐ, Lua ์คํฌ๋ฆฝํธ |
| GET | /api/v1/rankings/products |
์ํ ๋ญํน ์กฐํ | Redis Sorted Set, ์ค์๊ฐ/์ผ๋ณ/์ฃผ๊ฐ |
๊ฐ API์ ์์ธํ ์ฒ๋ฆฌ ํ๋ฆ์ ํ์ธํ๋ ค๋ฉด ์๋ ํญ๋ชฉ์ ํด๋ฆญํ์ธ์.
๐ฆ ์ํ ๋ชฉ๋ก ์กฐํ API - GET /api/v1/products
- Redis ์บ์ ์ฐ์ ์กฐํ (3๋ถ TTL)
- ์บ์ ๋ฏธ์ค ์ DB ์กฐํ ํ ์บ์ฑ
- ์ปค์ ๊ธฐ๋ฐ ํ์ด์ง์ผ๋ก ๋์ฉ๋ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as ProductsController
participant Service as ProductService
participant Redis as Redis Cache
participant DB as MySQL
Client->>Controller: GET /api/v1/products?q=๊ฒ์์ด&limit=20
Controller->>Service: getProducts(query, limit, cursor)
Service->>Redis: ์บ์ ์กฐํ (products:key)
alt ์บ์ ํํธ
Redis-->>Service: ์บ์๋ ๋ฐ์ดํฐ ๋ฐํ
else ์บ์ ๋ฏธ์ค
Service->>DB: SELECT * FROM products WHERE...
DB-->>Service: ์ํ ๋ชฉ๋ก
Service->>Redis: ์บ์ ์ ์ฅ (TTL: 3๋ถ)
end
Service-->>Controller: ProductListResponse
Controller-->>Client: 200 OK + ์ํ ๋ชฉ๋ก
๐ฆ ์ํ ์์ธ ์กฐํ API - GET /api/v1/products/{id}
- Redis ์บ์ ์ฐ์ ์กฐํ (10๋ถ TTL)
- ์ํ ์ ๋ณด + ๊ฐ๊ฒฉ ์ ๋ณด ํฌํจ
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as ProductsController
participant Service as ProductService
participant Redis as Redis Cache
participant DB as MySQL
Client->>Controller: GET /api/v1/products/{productId}
Controller->>Service: getProductDetail(productId)
Service->>Redis: ์บ์ ์กฐํ (product-detail:{id})
alt ์บ์ ํํธ
Redis-->>Service: ์บ์๋ ์ํ ์ ๋ณด
else ์บ์ ๋ฏธ์ค
Service->>DB: SELECT * FROM products WHERE id=?
DB-->>Service: ์ํ ์์ธ ์ ๋ณด
Service->>Redis: ์บ์ ์ ์ฅ (TTL: 10๋ถ)
end
Service-->>Controller: ProductDetailResponse
Controller-->>Client: 200 OK + ์ํ ์์ธ
๐ฐ ์ง๊ฐ ์ถฉ์ API - POST /api/v1/wallets/{userId}/topups
- ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ (Idempotency-Key ํค๋)
- ๋น๊ด์ ๋ฝ(SELECT FOR UPDATE)์ ํตํ ๋์์ฑ ์ ์ด
- ๊ฑฐ๋ ๋ด์ญ ์ถ์ ๋ฐ ์์ก ์ค๋ฒํ๋ก์ฐ ๋ฐฉ์ง
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as WalletController
participant Service as WalletService
participant DB as MySQL
Client->>Controller: POST /api/v1/wallets/{userId}/topups<br/>Header: Idempotency-Key<br/>Body: {amount, refType, refId}
Controller->>Service: topup(userId, amount, idempotencyKey)
Service->>DB: ๋ฉฑ๋ฑ์ฑ ์ฒดํฌ<br/>SELECT * FROM wallet_transactions<br/>WHERE user_id=? AND idempotency_key=?
alt ์ด๋ฏธ ์ฒ๋ฆฌ๋ ์์ฒญ
DB-->>Service: ๊ธฐ์กด ๊ฑฐ๋ ๋ด์ญ
Service-->>Controller: {idempotent: true, ๊ธฐ์กด ๊ฒฐ๊ณผ}
else ์ ๊ท ์์ฒญ
Service->>DB: BEGIN TRANSACTION
Service->>DB: ๋น๊ด์ ๋ฝ ํ๋<br/>SELECT * FROM wallets<br/>WHERE user_id=? FOR UPDATE
DB-->>Service: Wallet ์ ๋ณด
Service->>Service: ์์ก ๊ณ์ฐ (Math.addExact)<br/>์ค๋ฒํ๋ก์ฐ ๊ฒ์ฆ
Service->>DB: INSERT INTO wallet_transactions
Service->>DB: UPDATE wallets SET balance = balance + ?
Service->>DB: COMMIT
DB-->>Service: ์
๋ฐ์ดํธ ์๋ฃ
Service-->>Controller: {idempotent: false, ์ ์์ก}
end
Controller-->>Client: 200 OK + ๊ฑฐ๋ ๊ฒฐ๊ณผ
๐ ์ฃผ๋ฌธ ์์ฑ API - POST /api/v1/orders
- ๋ณ๋ ฌ ๊ฒ์ฆ: ์ฌ๊ณ , ์ฟ ํฐ, ์ง๊ฐ ์์ก
- Redis ๋ถ์ฐ ๋ฝ ๊ธฐ๋ฐ ์ฌ๊ณ ์์ฝ
- ์คํจ ์ ๋ณด์ ํธ๋์ญ์ ์คํ (์ฌ๊ณ ๋ณต์, ์ฟ ํฐ ํด์ , ์ง๊ฐ ํ๋ถ)
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as OrderController
participant OrderService as OrderService
participant InventoryService as InventoryService
participant CouponService as CouponService
participant WalletService as WalletService
participant Redis as Redis
participant DB as MySQL
Client->>Controller: POST /api/v1/orders<br/>Header: Idempotency-Key<br/>Body: {userId, items, couponCode, expectedTotal}
Controller->>OrderService: createOrder(request)
OrderService->>DB: ๋ฉฑ๋ฑ์ฑ ์ฒดํฌ<br/>SELECT * FROM orders WHERE request_key=?
alt ์ด๋ฏธ ์ฒ๋ฆฌ๋ ์ฃผ๋ฌธ
DB-->>OrderService: ๊ธฐ์กด ์ฃผ๋ฌธ ์ ๋ณด
OrderService-->>Controller: ๊ธฐ์กด ์ฃผ๋ฌธ ๋ฐํ
else ์ ๊ท ์ฃผ๋ฌธ
par ๋ณ๋ ฌ ๊ฒ์ฆ
OrderService->>InventoryService: validateStock(items)
InventoryService->>Redis: ์ฌ๊ณ ์บ์ ์กฐํ
InventoryService->>DB: SELECT stock FROM inventory
InventoryService-->>OrderService: ์ฌ๊ณ OK
and
OrderService->>CouponService: validateCoupon(couponCode, userId)
CouponService->>DB: ์ฟ ํฐ ์ ํจ์ฑ ๊ฒ์ฆ
CouponService-->>OrderService: ์ฟ ํฐ ์ ๋ณด + ํ ์ธ์ก
and
OrderService->>WalletService: getBalance(userId)
WalletService->>DB: SELECT balance FROM wallets
WalletService-->>OrderService: ์์ก ์ ๋ณด
end
OrderService->>OrderService: ์ด์ก ๊ณ์ฐ ๋ฐ ๊ฒ์ฆ<br/>(expectedTotal ๋งค์นญ)
OrderService->>DB: BEGIN TRANSACTION
loop ๊ฐ ์ํ๋ณ
OrderService->>Redis: ๋ถ์ฐ ๋ฝ ํ๋<br/>product:{id}:order:lock
OrderService->>InventoryService: reserve(productId, qty)
InventoryService->>DB: UPDATE inventory<br/>SET stock = stock - ?<br/>WHERE version = ? (๋๊ด์ ๋ฝ)
alt ๋๊ด์ ๋ฝ ์ถฉ๋
DB-->>InventoryService: ์
๋ฐ์ดํธ ์คํจ
InventoryService->>InventoryService: ์ฌ์๋ (3ํ, ์ง์ ๋ฐฑ์คํ)
end
OrderService->>Redis: ๋ฝ ํด์
end
OrderService->>CouponService: useCoupon(couponCode, userId)
CouponService->>DB: UPDATE coupon_issuances SET used=true
OrderService->>WalletService: debit(userId, total)
WalletService->>DB: SELECT * FROM wallets FOR UPDATE
WalletService->>DB: UPDATE wallets SET balance = balance - ?
OrderService->>DB: INSERT INTO orders
OrderService->>DB: INSERT INTO order_items
OrderService->>DB: COMMIT
OrderService-->>Controller: ์ฃผ๋ฌธ ์์ฑ ์๋ฃ
end
Controller-->>Client: 201 Created + ์ฃผ๋ฌธ ์ ๋ณด
Note over Client,DB: ์คํจ ์ ๋ณด์ ํธ๋์ญ์
์คํ<br/>(์ฌ๊ณ ๋ณต์, ์ฟ ํฐ ํด์ , ์ง๊ฐ ํ๋ถ)
โ ์ฃผ๋ฌธ ์๋ฃ API - PATCH /api/v1/orders/{orderId}/complete
- Spring Application Event๋ก ์ฆ์ ๋ฐํ
- Outbox ํจํด์ผ๋ก ์ด๋ฒคํธ ์์์ฑ ๋ณด์ฅ
- Kafka๋ก ๋ค์ค ์ปจ์๋จธ์๊ฒ ์ ๋ฌ (๋ญํน ์ ๋ฐ์ดํธ, ๋ฐ์ดํฐ ํ๋ซํผ)
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as OrderController
participant OrderService as OrderService
participant DB as MySQL
participant EventBus as Spring Event Bus
participant RankingListener as RankingListener
participant DataListener as DataListener
participant OutboxDispatcher as OutboxDispatcher
participant Kafka as Kafka
Client->>Controller: PATCH /api/v1/orders/{orderId}/complete
Controller->>OrderService: complete(orderId)
OrderService->>DB: BEGIN TRANSACTION
OrderService->>DB: UPDATE orders<br/>SET status='COMPLETED', completed_at=NOW()<br/>WHERE order_id=? AND status='RESERVED'
OrderService->>DB: INSERT INTO outbox_events<br/>(event_type, payload, status='PENDING')
OrderService->>DB: COMMIT
OrderService->>EventBus: publish(OrderCompletedDomainEvent)
par ์ด๋ฒคํธ ๋ฆฌ์ค๋ ๋ณ๋ ฌ ์ฒ๋ฆฌ
EventBus->>RankingListener: onOrderCompleted(event)
RankingListener->>Kafka: produce(ecommerce.order.events)<br/>Key: orderId
Kafka-->>RankingListener: ACK
and
EventBus->>DataListener: onOrderCompleted(event)
DataListener->>Kafka: produce(ecommerce.order.events)<br/>๋ฐ์ดํฐ ํ๋ซํผ์ฉ
Kafka-->>DataListener: ACK
and
EventBus->>OutboxDispatcher: onOrderCompleted(event)
Note over OutboxDispatcher: ์ด๋ฏธ DB์ ์ ์ฅ๋จ<br/>์ค์ผ์ค๋ฌ๊ฐ 5์ด๋ง๋ค ํด๋ง
end
OrderService-->>Controller: ์๋ฃ ์๋ต
Controller-->>Client: 200 OK + ์ฃผ๋ฌธ ์๋ฃ ์ ๋ณด
Note over OutboxDispatcher,Kafka: ๋ณ๋ ์ค์ผ์ค๋ฌ (5์ด๋ง๋ค)
OutboxDispatcher->>DB: SELECT * FROM outbox_events<br/>WHERE status='PENDING' LIMIT 20
DB-->>OutboxDispatcher: ๋๊ธฐ ์ค์ธ ์ด๋ฒคํธ๋ค
loop ๊ฐ ์ด๋ฒคํธ (๋ฐฐ์น 20๊ฐ)
OutboxDispatcher->>Kafka: produce(ํ ํฝ, ์ด๋ฒคํธ)
alt Kafka ๋ฐํ ์ฑ๊ณต
Kafka-->>OutboxDispatcher: ACK
OutboxDispatcher->>DB: UPDATE outbox_events SET status='SENT'
else Kafka ๋ฐํ ์คํจ
Kafka-->>OutboxDispatcher: ERROR
OutboxDispatcher->>DB: UPDATE retry_count++, next_retry_at=...
end
end
๐๏ธ ์ฟ ํฐ ๋ฐ๊ธ API (๋๊ธฐ) - POST /coupons/{id}/issue
- Redis ๋ถ์ฐ ๋ฝ์ผ๋ก ๋์ ์์ฒญ ์ ์ด
- Redis INCR๋ก ์์์ ์นด์ดํฐ ์ฆ๊ฐ
- ์ ์ฐฉ์ ์ ํ ๊ฒ์ฆ ๋ฐ DB ์ ์ฅ
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as CouponController
participant Service as CouponService
participant Redis as Redis
participant DB as MySQL
Client->>Controller: POST /coupons/{couponId}/issue?userId={userId}
Controller->>Service: issueCoupon(couponId, userId)
Service->>Redis: ๋ถ์ฐ ๋ฝ ํ๋<br/>coupon:{couponId}:lock<br/>waitTime: 2s, leaseTime: 5s
alt ๋ฝ ํ๋ ์คํจ
Redis-->>Service: ๋ฝ ํ์์์
Service-->>Controller: 429 Too Many Requests
Controller-->>Client: ์ ์ ํ ๋ค์ ์๋
else ๋ฝ ํ๋ ์ฑ๊ณต
Service->>Redis: INCR coupon:{couponId}:issued
Redis-->>Service: ํ์ฌ ๋ฐ๊ธ ์
Service->>Service: ๋ฐ๊ธ ํ๋ ๊ฒ์ฆ<br/>(issued <= maxIssuance)
alt ๋ฐ๊ธ ํ๋ ์ด๊ณผ
Service->>Redis: DECR coupon:{couponId}:issued
Service->>Redis: ๋ฝ ํด์
Service-->>Controller: 409 Conflict - ์ฟ ํฐ ์์ง
else ๋ฐ๊ธ ๊ฐ๋ฅ
Service->>DB: BEGIN TRANSACTION
Service->>DB: SELECT * FROM coupons WHERE coupon_id=?
DB-->>Service: ์ฟ ํฐ ์ ๋ณด
Service->>Service: ์ ํจ์ฑ ๊ฒ์ฆ<br/>(์ ํจ ๊ธฐ๊ฐ, ์ค๋ณต ๋ฐ๊ธ ์ฒดํฌ)
Service->>DB: INSERT INTO coupon_issuances<br/>(coupon_id, user_id, issued_at)
Service->>DB: COMMIT
Service->>Redis: ๋ฝ ํด์
Service-->>Controller: ๋ฐ๊ธ ์ฑ๊ณต
end
end
Controller-->>Client: 200 OK + ์ฟ ํฐ ๋ฐ๊ธ ์ ๋ณด
๐๏ธ ์ฟ ํฐ ๋ฐ๊ธ API (๋น๋๊ธฐ) - POST /coupons/{id}/issue-async
- Kafka Producer๋ก ์ฆ์ ์๋ต (202 Accepted)
- Kafka Consumer๊ฐ ๋น๋๊ธฐ๋ก ์ฒ๋ฆฌ
- Redis Lua Script๋ก ์์์ ๊ฒ์ฆ ๋ฐ ์ฆ๊ฐ
- ๊ฒฐ๊ณผ๋ ๋ณ๋ ํ ํฝ์ผ๋ก ์ ์ก
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as CouponController
participant Producer as CouponKafkaProducer
participant Redis as Redis
participant Kafka as Kafka
participant Consumer as CouponKafkaConsumer
participant Service as AsyncCouponService
participant DB as MySQL
Client->>Controller: POST /coupons/{couponId}/issue-async?userId={userId}
Controller->>Producer: sendIssueRequest(couponId, userId)
Producer->>Producer: requestId ์์ฑ (UUID)
Producer->>Kafka: produce(coupon.issue.requests)<br/>Key: couponId<br/>Value: {requestId, couponId, userId}
Kafka-->>Producer: ACK (Idempotent Producer)
Producer-->>Controller: requestId ๋ฐํ
Controller-->>Client: 202 Accepted<br/>{requestId, message: "์ฒ๋ฆฌ ์ค"}
Note over Kafka,Consumer: Kafka Consumer (3 threads)
Kafka->>Consumer: poll(coupon.issue.requests)
Consumer->>Service: processIssueRequest(couponId, userId)
Service->>Redis: Lua Script ์คํ<br/>์์์ ๊ฒ์ฆ ๋ฐ INCR
alt Lua Script - ๋ฐ๊ธ ๊ฐ๋ฅ
Redis-->>Service: SUCCESS + issued count
Service->>DB: BEGIN TRANSACTION
Service->>DB: INSERT INTO coupon_issuances<br/>(coupon_id, user_id, request_id, ...)
Service->>DB: COMMIT
Service->>Kafka: produce(coupon.issue.results)<br/>{requestId, status: SUCCESS}
Service->>Consumer: manual ACK
Consumer->>Kafka: commit offset
else Lua Script - ๋ฐ๊ธ ๋ถ๊ฐ
Redis-->>Service: FAIL - ๋ฐ๊ธ ํ๋ ์ด๊ณผ
Service->>Kafka: produce(coupon.issue.results)<br/>{requestId, status: FAILED, reason: SOLD_OUT}
Service->>Consumer: manual ACK
Consumer->>Kafka: commit offset
end
Note over Client: ํด๋ผ์ด์ธํธ๋ ํด๋ง ๋๋<br/>WebSocket์ผ๋ก ๊ฒฐ๊ณผ ์์
๐ ์ํ ๋ญํน ์กฐํ API - GET /api/v1/rankings/products
- Redis Sorted Set ์กฐํ (O(log n) ์ฑ๋ฅ)
- ์ค์๊ฐ/์ผ๋ณ/์ฃผ๊ฐ ๋ญํน ์ง์
- Graceful degradation (Redis ์ฅ์ ์ ๋น ๊ฒฐ๊ณผ)
sequenceDiagram
participant Client as ํด๋ผ์ด์ธํธ
participant Controller as RankingController
participant Service as ProductRankingService
participant Redis as Redis Sorted Set
participant DB as MySQL
Client->>Controller: GET /api/v1/rankings/products?period=DAILY&limit=10
Controller->>Service: getTopProducts(period, referenceDate, limit)
Service->>Service: ranking key ์์ฑ<br/>์: ranking:daily:2025-12-14
Service->>Redis: ZREVRANGE ranking:daily:2025-12-14 0 9<br/>WITHSCORES
alt Redis ๋ฐ์ดํฐ ์กด์ฌ
Redis-->>Service: [(productId, score), ...]
Service->>DB: SELECT id, name, thumbnail_url, unit_price<br/>FROM products WHERE id IN (?, ?, ...)
DB-->>Service: ์ํ ์ ๋ณด ๋ชฉ๋ก
Service->>Service: score์ ์ํ ์ ๋ณด ๋งคํ
Service-->>Controller: RankingResponse<br/>{items: [{productId, name, score}, ...]}
else Redis ๋ฐ์ดํฐ ์์
Redis-->>Service: ๋น ๋ฐฐ์ด
Service-->>Controller: ๋น ๋ญํน (graceful degradation)
end
Controller-->>Client: 200 OK + ๋ญํน ์ ๋ณด
Note over Redis: ๋ญํน ์
๋ฐ์ดํธ๋<br/>์ฃผ๋ฌธ ์๋ฃ ์ด๋ฒคํธ ์์ ์<br/>ZINCRBY๋ก ์คํ
๐ ERD (Entity Relationship Diagram)
erDiagram
USERS ||--|| WALLETS : has
USERS ||--o{ WALLET_TRANSACTIONS : creates
USERS ||--o{ ORDERS : places
USERS ||--o{ COUPON_ISSUANCES : receives
PRODUCTS ||--|| INVENTORY : has
PRODUCTS ||--o{ ORDER_ITEMS : contains
PRODUCTS ||--o{ STOCK_MOVEMENTS : tracks
ORDERS ||--|{ ORDER_ITEMS : contains
ORDERS ||--o| PAYMENTS : has
COUPONS ||--o{ COUPON_ISSUANCES : issues
USERS {
bigint user_id PK
string username
string email
timestamp created_at
}
WALLETS {
bigint wallet_id PK
bigint user_id FK
bigint balance
timestamp updated_at
}
WALLET_TRANSACTIONS {
bigint transaction_id PK
bigint user_id FK
string transaction_type
bigint amount
bigint balance_after
string idempotency_key UK
timestamp created_at
}
PRODUCTS {
bigint product_id PK
string sku UK
string name
decimal unit_price
string thumbnail_url
timestamp created_at
}
INVENTORY {
bigint inventory_id PK
bigint product_id FK
int stock
int version
timestamp updated_at
}
STOCK_MOVEMENTS {
bigint movement_id PK
bigint product_id FK
string movement_type
int quantity
string reference_type
bigint reference_id
timestamp created_at
}
ORDERS {
bigint order_id PK
bigint user_id FK
string status
decimal subtotal
decimal discount
decimal total
string request_key UK
timestamp completed_at
timestamp created_at
}
ORDER_ITEMS {
bigint order_item_id PK
bigint order_id FK
bigint product_id FK
string product_name
decimal unit_price
int quantity
decimal line_total
}
PAYMENTS {
bigint payment_id PK
bigint order_id FK
string payment_method
decimal amount
string status
timestamp created_at
}
COUPONS {
bigint coupon_id PK
string code UK
string discount_type
decimal discount_value
decimal min_purchase_amount
decimal max_discount_amount
int max_issuance
timestamp starts_at
timestamp ends_at
}
COUPON_ISSUANCES {
bigint issuance_id PK
bigint coupon_id FK
bigint user_id FK
boolean used
timestamp issued_at
timestamp used_at
}
- USERS: ์ฌ์ฉ์ ๊ณ์ ์ ๋ณด
- WALLETS: ์ฌ์ฉ์๋ณ ์ง๊ฐ ์์ก (1:1)
- WALLET_TRANSACTIONS: ์ง๊ฐ ๊ฑฐ๋ ๋ด์ญ (์ถฉ์ /์ฐจ๊ฐ), ๋ฉฑ๋ฑ์ฑ ํค ํฌํจ
- PRODUCTS: ์ํ ์นดํ๋ก๊ทธ
- INVENTORY: ์ฌ๊ณ ๊ด๋ฆฌ (๋๊ด์ ๋ฝ version ํ๋)
- STOCK_MOVEMENTS: ์ฌ๊ณ ๋ณ๋ ์ด๋ ฅ (๊ฐ์ฌ ์ถ์ )
- ORDERS: ์ฃผ๋ฌธ ์ ๋ณด (request_key๋ก ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ)
- ORDER_ITEMS: ์ฃผ๋ฌธ ์ํ ๋ชฉ๋ก
- COUPONS: ์ฟ ํฐ ์ ์ (ํ ์ธ์จ, ํ๋, ์ ํจ๊ธฐ๊ฐ)
- COUPON_ISSUANCES: ์ฌ์ฉ์๋ณ ์ฟ ํฐ ๋ฐ๊ธ ๋ด์ญ
๐ Kafka ํ ํฝ ๋ฐ ์ปจ์๋จธ ๊ตฌ์กฐ
graph TB
subgraph "Producers"
OrderService[OrderService<br/>์ฃผ๋ฌธ ์๋ฃ]
OutboxDispatcher[OutboxDispatcher<br/>Outbox ํด๋ง]
CouponProducer[CouponKafkaProducer<br/>์ฟ ํฐ ๋ฐ๊ธ ์์ฒญ]
end
subgraph "Kafka Cluster (3 Brokers)"
Topic1[ecommerce.order.events<br/>ํํฐ์
: 3๊ฐ<br/>๋ฆฌํ๋ฆฌ์ผ์ด์
: 1]
Topic2[coupon.issue.requests<br/>ํํฐ์
: 3๊ฐ<br/>๋ฆฌํ๋ฆฌ์ผ์ด์
: 1]
Topic3[coupon.issue.results<br/>ํํฐ์
: 3๊ฐ<br/>๋ฆฌํ๋ฆฌ์ผ์ด์
: 1]
end
subgraph "Consumer Groups"
RankingConsumer[Ranking Updater<br/>๊ทธ๋ฃน: ranking-updater<br/>์ค๋ ๋: 3๊ฐ]
DataConsumer[Data Platform<br/>๊ทธ๋ฃน: data-platform<br/>์ค๋ ๋: 3๊ฐ]
CouponConsumer[Coupon Issuer<br/>๊ทธ๋ฃน: coupon-issuer<br/>์ค๋ ๋: 3๊ฐ]
end
subgraph "Processing"
Redis[(Redis<br/>๋ญํน ์
๋ฐ์ดํธ)]
Analytics[(Analytics<br/>๋ฐ์ดํฐ ๋ถ์)]
MySQL[(MySQL<br/>์ฟ ํฐ ๋ฐ๊ธ)]
end
OrderService --> Topic1
OutboxDispatcher --> Topic1
CouponProducer --> Topic2
Topic1 --> RankingConsumer
Topic1 --> DataConsumer
Topic2 --> CouponConsumer
RankingConsumer --> Redis
DataConsumer --> Analytics
CouponConsumer --> MySQL
CouponConsumer --> Topic3
style Topic1 fill:#fff9e1
style Topic2 fill:#e1f5ff
style Topic3 fill:#f0e1ff
style RankingConsumer fill:#e1ffe1
style DataConsumer fill:#ffe1e1
style CouponConsumer fill:#ffe1f5
Producer ์ค์ :
- Acks:
all(๋ชจ๋ ๋ณต์ ๋ณธ ํ์ธ) - Idempotence:
enabled(์ค๋ณต ๋ฐฉ์ง) - Compression:
snappy(์์ถ) - Retries:
3
Consumer ์ค์ :
- Auto Offset Reset:
earliest - Enable Auto Commit:
false(์๋ ACK) - Max Poll Records:
500 - Concurrency:
3(๊ฐ ์ปจ์๋จธ ๊ทธ๋ฃน๋น)
โก Redis ์บ์ฑ ์ ๋ต (Cache-Aside Pattern)
flowchart TD
Start([API ์์ฒญ]) --> CheckCache{Redis<br/>์บ์ ํ์ธ}
CheckCache -->|์บ์ ํํธ| ReturnCache[์บ์ ๋ฐ์ดํฐ ๋ฐํ]
CheckCache -->|์บ์ ๋ฏธ์ค| QueryDB[MySQL DB ์กฐํ]
QueryDB --> SaveCache[Redis์<br/>๋ฐ์ดํฐ ์ ์ฅ<br/>TTL ์ค์ ]
SaveCache --> ReturnDB[DB ๋ฐ์ดํฐ ๋ฐํ]
ReturnCache --> End([์๋ต])
ReturnDB --> End
style CheckCache fill:#fff9e1
style ReturnCache fill:#e1ffe1
style QueryDB fill:#ffe1e1
style SaveCache fill:#e1f5ff
| ์บ์ ์์ญ | ํค ํจํด | TTL | ์ฉ๋ |
|---|---|---|---|
products |
products:* |
3๋ถ | ์ํ ๋ชฉ๋ก |
product-detail |
product-detail:{id} |
10๋ถ | ์ํ ์์ธ |
coupon-info |
coupon-info:{id} |
5๋ถ | ์ฟ ํฐ ์ ๋ณด |
user-orders |
user-orders:{userId} |
10๋ถ | ์ฌ์ฉ์ ์ฃผ๋ฌธ ๋ชฉ๋ก |
product:{id}:stock |
- | 30์ด | ์ค์๊ฐ ์ฌ๊ณ |
ranking:* |
ranking:{period}:{date} |
1์๊ฐ | ์ํ ๋ญํน (Sorted Set) |
| ๋ฝ ํ์ | ํค ํจํด | Wait Time | Lease Time |
|---|---|---|---|
| ์ฟ ํฐ ๋ฐ๊ธ | coupon:{id}:lock |
2์ด | 5์ด |
| ์ฌ๊ณ ์์ฝ | product:{id}:order:lock |
2์ด | 10์ด |
- Spring Boot 3.4.1
- Spring Cloud 2024.0.0
- Java 17
- MySQL 8.0 - ์ฃผ๋ฌธ, ์ํ, ์ฌ์ฉ์ ๋ฐ์ดํฐ
- Redis 7.0 - ์บ์ฑ, ๋ถ์ฐ ๋ฝ, ๋ญํน
- Apache Kafka 7.6.0 - ์ด๋ฒคํธ ์คํธ๋ฆฌ๋ฐ
- 3-broker ํด๋ฌ์คํฐ
- 4๊ฐ ํ ํฝ (์ฃผ๋ฌธ ์ด๋ฒคํธ, ์ฟ ํฐ ๋ฐ๊ธ ์์ฒญ/๊ฒฐ๊ณผ)
- Spring Data JPA - ORM
- Redisson - ๋ถ์ฐ ๋ฝ
- Lettuce - Redis ํด๋ผ์ด์ธํธ
- Spring Kafka - Kafka ํตํฉ
- SpringDoc OpenAPI - API ๋ฌธ์ (Swagger UI)
- Testcontainers - ํตํฉ ํ ์คํธ
- Lombok - ๋ณด์ผ๋ฌํ๋ ์ดํธ ์ฝ๋ ์ ๊ฑฐ
- โ ์ปค์ ๊ธฐ๋ฐ ํ์ด์ง (๋์ฉ๋ ๋ฐ์ดํฐ ์ต์ ํ)
- โ Redis ์บ์ฑ (3๋ถ TTL)
- โ ์ ์ฒด ํ ์คํธ ๊ฒ์ ๋ฐ ์นดํ ๊ณ ๋ฆฌ ํํฐ๋ง
- โ ์ ๋ ฌ ์ต์ (ID, ์ด๋ฆ, ๊ฐ๊ฒฉ ๋ฑ)
- โ ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ (Idempotency-Key ํค๋)
- โ ๋น๊ด์ ๋ฝ(SELECT FOR UPDATE)์ ํตํ ๋์์ฑ ์ ์ด
- โ ๊ฑฐ๋ ๋ด์ญ ์ถ์
- โ ์ค๋ฒํ๋ก์ฐ ๋ฐฉ์ง (Math.addExact)
- โ ํ๋ถ ๋ฉ์ปค๋์ฆ (๋ณด์ ํธ๋์ญ์ )
- โ
ํฌ๊ด์ ์ธ ๊ฒ์ฆ:
- ์ฌ๊ณ ๊ฐ์ฉ์ฑ ์ฒดํฌ (๋๊ด์ ๋ฝ)
- ์ฟ ํฐ ์ ์ฉ ๊ฐ๋ฅ ์ฌ๋ถ ๊ฒ์ฆ
- ์ง๊ฐ ์์ก ํ์ธ
- ์ด์ก ๋งค์นญ (์ถฉ๋ ๊ฐ์ง)
- โ ์ฃผ๋ฌธ ์๋ฃ ์ ์ด๋ฒคํธ ๋ฐํ
- โ ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ (request_key)
- โ ๋ค์ค ์ํ ์ฃผ๋ฌธ ์ง์
- โ ํ ์ธ ๊ณ์ฐ (๋น์จ/๊ณ ์ ๊ธ์ก)
- โ ์ฟ ํฐ ์ฌ์ฉ ์ถ์
- โ
๋ ๊ฐ์ง ๋ฐ๊ธ ๋ฐฉ์:
- ๋๊ธฐ: ์ฆ์ ๋ฐ๊ธ (๋ถ์ฐ ๋ฝ ์ฌ์ฉ)
- ๋น๋๊ธฐ: Kafka ๊ธฐ๋ฐ (Lua ์คํฌ๋ฆฝํธ ๊ฒ์ฆ)
- โ
๊ธฐ๋ฅ:
- ๋น์จ ํ ์ธ ๋ฐ ๊ณ ์ ๊ธ์ก ํ ์ธ
- ์ต์ ๊ตฌ๋งค ๊ธ์ก ์กฐ๊ฑด
- ์ต๋ ํ ์ธ ๊ธ์ก ์ ํ
- ์ ํจ ๊ธฐ๊ฐ (์์์ผ/์ข ๋ฃ์ผ)
- ๋ฐ๊ธ ํ๋ (์ ์ฐฉ์)
- Redis ์์์ ์นด์ดํฐ๋ก ๋์์ฑ ์ ์ด
- ์ฟ ํฐ ํด์ (๋ณด์ ์ฒ๋ฆฌ)
- โ
์ด์ค ์ ์ฅ์:
- MySQL (์๊ตฌ ์ ์ฅ)
- Redis (๋น ๋ฅธ ์กฐํ)
- โ ๋๊ด์ ๋ฝ (version ํ๋)์ผ๋ก DB ์ถฉ๋ ๋ฐฉ์ง
- โ ์ฌ์๋ ๋ฉ์ปค๋์ฆ (3ํ, ์ง์ ๋ฐฑ์คํ)
- โ ์ฌ๊ณ ๋ณ๋ ๊ฐ์ฌ ์ถ์
- โ ์ํ๋ณ ๋ถ์ฐ ๋ฝ์ผ๋ก ์์ ํ ์์ฝ
- โ Cache-Aside ํจํด์ผ๋ก ๋น ๋ฅธ ์ฝ๊ธฐ
- โ ์ฃผ๋ฌธ ์ทจ์ ์ ์ฌ๊ณ ๋ณต์
- โ
๋ค์ค ๋ญํน ๊ธฐ๊ฐ:
- REALTIME - ์ฃผ๋ฌธ ์๋ฃ ์ ์ฆ์ ์ ๋ฐ์ดํธ
- DAILY - ์ผ๋ณ ์ง๊ณ ๋ญํน
- WEEKLY - ์ฃผ๊ฐ ์ง๊ณ ๋ญํน
- โ Redis Sorted Set์ผ๋ก O(log n) ์ฑ๋ฅ
- โ Spring Application Events + Kafka๋ก ์ ๋ฐ์ดํธ
- โ
์ ์ ๊ธฐ์ค:
- ์ฃผ๋ฌธ ์๋
- ํ๋งค ๊ธ์ก
- ๊ณ ๊ฐ ์ฐธ์ฌ ์งํ
- โ ๊ณผ๊ฑฐ ๋ ์ง ์กฐํ ์ง์
- โ Graceful degradation (Redis ์ฅ์ ์ ๋น ๊ฒฐ๊ณผ)
| ๊ธฐ๋ฅ | ์ ์ด ๋ฐฉ์ | ์ค๋ช |
|---|---|---|
| ์ง๊ฐ ์ถฉ์ /์ฐจ๊ฐ | ๋น๊ด์ ๋ฝ | SELECT ... FOR UPDATE๋ก ํธ๋์ญ์
๊ฒฉ๋ฆฌ |
| ์ฌ๊ณ ์์ฝ | ๋ถ์ฐ ๋ฝ (Redisson) | product:{id}:order:lock์ผ๋ก ์ํ๋ณ ์ง๋ ฌํ |
| ์ฟ ํฐ ๋ฐ๊ธ | ๋ถ์ฐ ๋ฝ + Redis INCR | coupon:{id}:lock + ์์์ ์นด์ดํฐ |
| ์ฌ๊ณ ์ ๋ฐ์ดํธ | ๋๊ด์ ๋ฝ | version ํ๋๋ก ์ถฉ๋ ๊ฐ์ง ๋ฐ ์ฌ์๋ |
| ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ | DB Unique ์ ์ฝ | (user_id, idempotency_key) ๋ณตํฉ ์ ๋ํฌ ํค |
- ์ ๋ ฌ๋ ์์๋ก ๋ฝ ํ๋ (product_id ์ค๋ฆ์ฐจ์)
- Timeout ์ค์ (2์ด wait, 5-10์ด lease)
- ์คํจ ์ ๋น ๋ฅธ ์คํจ ์ ๋ต (Fail-Fast)
- Inbound Ports: ์ ์ฆ์ผ์ด์ค ์ธํฐํ์ด์ค (
application.port.in) - Outbound Ports: ๋๋ฉ์ธ ํฌํธ ์ธํฐํ์ด์ค (
domain.port.out) - Adapters: JPA ๋ฆฌํฌ์งํ ๋ฆฌ๊ฐ Outbound Ports ๊ตฌํ
- ์ฝ๊ธฐ ์์ ๋ถ๋ฆฌ (ProductReadPort, ProductDetailReadPort)
- ์ฐ๊ธฐ ์์ ๋ถ๋ฆฌ (OrderWritePort, WalletReadWritePort)
- ๋๋ฉ์ธ ์ด๋ฒคํธ๋ฅผ ์๋น์ค์์ ๋ฐํ
- ๋ค์ค ์ด๋ฒคํธ ๋ฆฌ์ค๋๊ฐ ๋ ๋ฆฝ์ ์ผ๋ก ์ฒ๋ฆฌ
- Outbox ํจํด์ผ๋ก ๋ณด์ฅ๋ ์ ๋ฌ
- Kafka๋ก ๋ถ์ฐ ์ด๋ฒคํธ ์คํธ๋ฆฌ๋ฐ
- ์ฃผ๋ฌธ ์์ฑ ์คํจ โ ์ง๊ฐ ํ๋ถ
- ์ฟ ํฐ ๋ฐ๊ธ ์คํจ โ ๋ฐ๊ธ ๋ด์ญ ํด์
- ์ฌ๊ณ ์์ฝ ์คํจ โ ์ฌ๊ณ ๋ณต์
Idempotency-Keyํค๋๋ก API ์์ฒญ ์๋ณ- DB Unique ์ ์ฝ
(user_id, idempotency_key) - ์ฌ์๋ ์ ๊ธฐ์กด ๊ฒฐ๊ณผ ๋ฐํ
- Redis ์บ์ ๋จผ์ ํ์ธ
- ๋ฏธ์ค ์ DB ์กฐํ
- TTL ๊ธฐ๋ฐ ์บ์ ๋ง๋ฃ
- ์ํ, ์ฟ ํฐ, ์ฌ๊ณ ์ ์ ์ฉ
- ์ด๋ฒคํธ๋ฅผ DB์ ํธ๋์ญ์ ๋ด ์ ์ฅ
- ์ค์ผ์ค๋ฌ๊ฐ 5์ด๋ง๋ค ํด๋ง
- ๋ฐฐ์น ์ฒ๋ฆฌ (20๊ฐ์ฉ)
- ์ง์ ๋ฐฑ์คํ ์ฌ์๋ (30์ด * retry_count)
- ์ต์ข ์ํ: PENDING, SENT, FAILED
Docker Compose๋ก MySQL, Redis, Kafka๋ฅผ ์คํํฉ๋๋ค.
docker-compose up -d์คํ๋๋ ์๋น์ค:
- MySQL 8.0 (ํฌํธ: 3306)
- Redis 7.0 (ํฌํธ: 6379)
- Apache Kafka 3-broker ํด๋ฌ์คํฐ (ํฌํธ: 9092, 9093, 9094)
- Zookeeper (ํฌํธ: 2181)
./gradlew bootRunlocalํ๋กํ๋ก ์คํ (๊ธฐ๋ณธ๊ฐ)- ์๋ ์คํค๋ง ์์ฑ ๋ฐ ๋ฐ์ดํฐ ์๋ (
schema.sql,data.sql) - ์ ํ๋ฆฌ์ผ์ด์
ํฌํธ:
8080
http://localhost:8080/swagger-ui.html
localํ๋กํ์์๋ง ํ์ฑํ- ๋ชจ๋ API ์๋ํฌ์ธํธ ํ ์คํธ ๊ฐ๋ฅ
./gradlew test- Testcontainers๋ก ๊ฒฉ๋ฆฌ๋ MySQL ์ปจํ ์ด๋ ์คํ
- ํตํฉ ํ ์คํธ ์๋ ์คํ
UserFlowIntegrationTest๋ก ์ ์ฒด ์ฌ์ฉ์ ํ๋ก์ฐ ๊ฒ์ฆ
src/
โโโ main
โ โโโ java/kr/hhplus/be/server
โ โ โโโ interfaces/web # REST Controllers
โ โ โโโ application/service # Application Services (์ ์ฆ์ผ์ด์ค)
โ โ โโโ domain/model # Domain Models (๋ถ๋ณ Records)
โ โ โโโ infrastructure # Persistence, Cache, Kafka Adapters
โ โ โโโ persistence/adapter # JPA Adapters
โ โ โโโ kafka/ # Kafka Producers/Consumers
โ โ โโโ lock/ # Distributed Lock
โ โ โโโ outbox/ # Outbox Event Dispatcher
โ โ โโโ config/ # Configuration Classes
โ โโโ resources
โ โโโ application.yml # Spring ์ค์
โ โโโ schema.sql # DDL (ํ
์ด๋ธ ์์ฑ)
โ โโโ data.sql # ์๋ ๋ฐ์ดํฐ
โโโ test
โโโ java/kr/hhplus/be/server
โโโ interfaces/web # ํตํฉ ํ
์คํธ (Testcontainers)
- API ๋ช ์ธ์ - ์์ธ API ๊ณ์ฝ์
- ERD - ์ํฐํฐ ๊ด๊ณ ๋ค์ด์ด๊ทธ๋จ
- ์ธํ๋ผ ๊ตฌ์ฑ๋ - ์ธํ๋ผ ํ ํด๋ก์ง ๋ฐ ์ปดํฌ๋ํธ ์ฑ ์
- ๋์์ฑ ์ ์ด ์ค๊ณ - ๋ฝ ์ ๋ต, Redis ์ฌ์ฉ๋ฒ, ํ ์คํธ ๊ณํ
# 1. ๋ฆฌํฌ์งํ ๋ฆฌ ํด๋ก ๋ฐ Java 17 ์ค์น
git clone <repository-url>
cd ecommerce-platform
# 2. ์ธํ๋ผ ์คํ (MySQL, Redis, Kafka)
docker-compose up -d
# 3. ์ ํ๋ฆฌ์ผ์ด์
์คํ
./gradlew bootRun
# 4. Swagger UI์์ API ํ
์คํธ
open http://localhost:8080/swagger-ui.html
# 5. ํ
์คํธ ์คํ
./gradlew test์ด์๋ ๊ฐ์ ์ ์์ GitHub Issues๋ฅผ ํตํด ๋ฑ๋กํด์ฃผ์ธ์.
Built with โค๏ธ using Spring Boot 3, Kafka, Redis, and MySQL