์ฃผ๋ฌธ ์๋น์ค์ CQRS ๊ฒฝ๋ก๋ง ๋จ์ผ ๋ชจ๋๋ก ๊ตฌ์ฑํ ํ์ต ํ๋ก์ ํธ๋ค. ์ฃผ๋ฌธ์ ์ํ๋ฅผ ํ ์ด๋ธ์ ์ ์ฅํ์ง ์๊ณ , ์ผ์ด๋ ์ผ (์ด๋ฒคํธ)๋ง ์์ ๋ค ์ฝ์ ๋ ๋ค์ ์ฌ์ (replay) ํ๋ค.
| Command (์ฐ๊ธฐ) | Query (์ฝ๊ธฐ) | |
|---|---|---|
| ์ง์ ์ | OrderCommandController โ POST PATCH |
OrderQueryController โ GET |
| ์๋น์ค | EventCommandService |
EventQueryService |
| ํ๋ ์ผ | ๋ถ๋ณ์ ๊ฒ์ฌ โ ์ด๋ฒคํธ append โ ๋ฐํ | ์ด๋ฒคํธ replay โ OrderDto ๋ณต์ |
| ์ ์ฅ์ ์ ๊ทผ | save() + ๊ฒ์ฆ์ฉ ์คํธ๋ฆผ ๋ก๋ |
SELECT๋ง |
| ๊ฒฐ๊ณผ | 201/200 ๋น ๋ฐ๋ |
OrderDto / List<OrderDto> |
๋ ๊ฒฝ๋ก๋ ์ปจํธ๋กค๋ฌยท์๋น์ค ์์ค์์ ์์ ํ ๊ฐ๋ผ์ ธ ์๊ณ , order_event ํ
์ด๋ธ ํ๋๋ก๋ง ๋ง๋๋ค.
CQRS (Command Query Responsibility Segregation)๋ "์ํ๋ฅผ ๋ฐ๊พธ๋ ์์ฒญ"๊ณผ "์ํ๋ฅผ ์ฝ๋ ์์ฒญ"์ ์๋ก ๋ค๋ฅธ ๋ชจ๋ธยท๊ฒฝ๋ก๋ก ์ฒ๋ฆฌํ๋ ๋ฐฉ์์ด๋ค. ์ด ํ๋ก์ ํธ์์๋ ๋ค์๊ณผ ๊ฐ์ด ์ ์ฉํ๋ค.
- Command๋ ์กฐํ ์๋ต์ ๋ง๋ค์ง ์๋๋ค.
POST/PATCH๋ ์ด๋ฒคํธ๋ฅผ ์๊ณ ์ํ ์ฝ๋๋ง ๋๋ ค์ค๋ค. ๋ฐ๋ ๊ฒฐ๊ณผ๊ฐ ๊ถ๊ธํ๋ฉดGET์ผ๋ก ๋ค์ ๋ฌป๋๋ค. - Query๋ ์ ์ฅํ์ง ์๋๋ค.
EventQueryService์๋save()๊ฐ ์๋ค. ์ฝ๊ธฐ ๋ชจ๋ธ (OrderDto)์ ์์ฒญ๋ง๋ค ์ด๋ฒคํธ์์ ๋ณต์๋๋ ์ผํ์ฑ ๊ฒฐ๊ณผ๋ฌผ์ด๋ฉฐ ์ด๋์๋ ์์ํ์ง ์๋๋ค. - Command๊ฐ ์ด๋ฒคํธ๋ฅผ ์ฝ๋ ๊ฒ์ Query๊ฐ ์๋๋ค.
EventCommandService.updateUser()๋ ์ด๋ฒคํธ๋ฅผ ์๊ธฐ ์ ์ ํด๋น ์ฃผ๋ฌธ์ ์คํธ๋ฆผ์ ๋ก๋ํด "์ฃผ๋ฌธ์ด ์กด์ฌํ๋๊ฐ", "์์ฒญ์๊ฐ ์์ ์์ธ๊ฐ"๋ฅผ ๊ฒ์ฌํ๋ค. ์ด๋ ๋ถ๋ณ์ ๊ฒ์ฌ๋ฅผ ์ํ ์ ๊ทธ๋ฆฌ๊ฑฐํธ ๋ก๋ฉ์ด๊ณ , ํด๋ผ์ด์ธํธ์๊ฒ ๋๋ ค์ค ์กฐํ ์๋ต์ ๋ง๋๋ ๊ฒ ์๋๋ค. ์ด ๊ตฌ๋ถ์ ๋์น๋ฉด ์ด ํธ์ถ์ "CQRS ์๋ฐ"์ผ๋ก ์คํดํ๊ณ ๋๋๋ฆฌ๊ธฐ ์ฝ๋ค.
eventId | orderId | userId | eventType | payload (JSON) | timestamp
--------+---------+--------+---------------+--------------------------------------------------+----------
1 | a1 | u1 | EVENT_CREATED | {"productId":"P1","qty":2,"unitPrice":1000,"totalPrice":2000} | โฆ
2 | a1 | u1 | EVENT_UPDATED | {"productId":"P1","qty":5,"unitPrice":1000,"totalPrice":5000} | โฆ
3 | b2 | u1 | EVENT_CREATED | {โฆ} | โฆ
- append-only. ํ์
update/deleteํ๋ ์ฝ๋๊ฐ ์๋ค. ์ํ ๋ณ๊ฒฝ์ ํญ์ ์ ์ด๋ฒคํธ๋ค. userIdยทorderId๋ ์ปฌ๋ผ, ์ฃผ๋ฌธ ๋ด์ฉ์ payload (JSON, Gson) ์ ๋ค์ด๊ฐ๋ค.- ํ์ฌ ์ํ๋ฅผ ๋ด๋
ordersํ ์ด๋ธ์ ์๋์ ์ผ๋ก ๋์ง ์์๋ค. ์ํ๋ฅผ ์บ์ํ๋ ํ ์ด๋ธ์ด ์์ผ๋ฏ๋ก, ์ฝ๊ธฐ๋ ๋ฐ๋์ replay๋ฅผ ๊ฑฐ์น๋ค.
RequestOrder โโ@Validโโโถ OrderDto โโโถ EventCommandService
โโ (PATCH) ์คํธ๋ฆผ ๋ก๋ โ ์์ผ๋ฉด 404 / ์์ ์ ๋ค๋ฅด๋ฉด 403
โโ OrderEvent ์์ฑ, payload = Gson(EventPayload)
โโ save() โ append
โโ publishEvent() โ ์ปค๋ฐ ๋ค OrderEventListener ์คํ
createOrder/updateUser๋@Transactional.save์publishEvent๊ฐ ํ ํธ๋์ญ์ ์ ๋ฌถ์ธ๋ค.OrderEventListener๋@TransactionalEventListener(AFTER_COMMIT)๋ผ ์ปค๋ฐ๋ ๋ค์๋ง ์คํ๋๊ณ , ๋กค๋ฐฑ๋๋ฉด ๋ถ์ํจ๊ณผ๋ ์ผ์ด๋์ง ์๋๋ค. ๋ฆฌ์ค๋ ์์ธ๋ ์ผํจ๋ค โ ์ปค๋ฐ ํ์ ๋์ง๋ฉด "์ ์ฅ์ ๋๋๋ฐ 500 โ ํด๋ผ์ด์ธํธ ์ฌ์๋ โ ์ค๋ณต ์ด๋ฒคํธ"๊ฐ ๋๊ธฐ ๋๋ฌธ์ด๋ค.
List<OrderEvent> events = repo.findByOrderIdOrderByTimestampAscEventIdAsc(orderId);
OrderDto order = new OrderDto();
events.
forEach(order::apply); // CREATED โ ์ ์ฒด ์ฑ์, UPDATED โ ๋ฎ์ด์, DELETED โ deleted=true- ์ ๋ ฌ์
timestampโeventId(์ํ์ค). ๊ฐ์ ๋ฐ๋ฆฌ์ด์ ๋ ์ด๋ฒคํธ๊ฐ ๋ค์ด์๋ ์ฝ์ ์์๋ฅผ ๋ฐ๋ฅธ๋ค. - ๋ชฉ๋ก (
GET /{userId}/orders)์ userId์ orderId๋ฅผ ์ฒซ ์ด๋ฒคํธ (์์ฑ) ์๊ฐ ์์ผ๋ก ๋์ดํ ๋ค ๊ฐ๊ฐ replayํ๋ค. OrderDto๋apply()๋ก๋ง ์ํ๊ฐ ๋ฐ๋๋ค. setter๋ก ์ง์ ์ฑ์ฐ๋ ์กฐํ ์ฝ๋๋ ์๋ค.
EventType์ ์์ ์ถ๊ฐEventCommandService์ ์ด๋ฒคํธ๋ฅผ ๋ง๋ค์ดsave+publishEventํ๋ ๋ฉ์๋ ์ถ๊ฐOrderDto.apply()์case+applyXxx()์ถ๊ฐ
1โ3 ์ง์ด ์ ๋ง์ผ๋ฉด replay ์ IllegalArgumentException์ด๋ค. ์ปดํ์ผ์ ํต๊ณผํ๋ ์ฃผ์ํ๋ค. payload ํ๋๋ช
(EventPayload)๊ณผ ์ฝ๊ธฐ ํด๋์ค
(OrderCreatedData/OrderUpdatedData) ํ๋๋ช
์ด ๋ค๋ฅด๋ฉด Gson์ด ์กฐ์ฉํ null ์ ๋ฃ๋๋ค.
| Method | Path | ๋์ | ์ฑ๊ณต | ์คํจ |
|---|---|---|---|---|
POST |
/{userId}/orders |
EVENT_CREATED append |
201 |
400 ๊ฒ์ฆ ์คํจยท์ค๋ฒํ๋ก |
PATCH |
/{userId}/orders/{orderId} |
EVENT_UPDATED append |
200 |
400 / 404 ์๋ ์ฃผ๋ฌธ / 403 ํ์ธ ์ฃผ๋ฌธ |
GET |
/{userId}/orders |
userId์ ์ฃผ๋ฌธ ์ ๋ถ replay | 200 [] ๊ฐ๋ฅ |
โ |
GET |
/orders/{orderId} |
์ฃผ๋ฌธ ํ๋ replay | 200 |
404 ์๋ ์ฃผ๋ฌธ |
์์ฒญ ๋ฐ๋ (RequestOrder):
{
"productId": "P1",
"qty": 2,
"unitPrice": 1000
}productId ํ์, qty โฅ 1, unitPrice โฅ 0. totalPrice = qty ร unitPrice๋ ์ปจํธ๋กค๋ฌ๊ฐ ๊ณ์ฐํ๋ฉฐ int ๋ฒ์๋ฅผ ๋์ผ๋ฉด 400.
EVENT_DELETED๋ OrderDto.apply()๊ฐ ์ฒ๋ฆฌํ ์ ์์ง๋ง ๋์ํ๋ ์๋ํฌ์ธํธ๋ ์์ง ์๋ค.
com.project.msa
โโโ controller/ OrderCommandController (POST/PATCH) ยท OrderQueryController (GET)
โโโ event/
โ โโโ entity/ OrderEvent (Event Store ํ) ยท EventType ยท EventPayload (payload ์คํค๋ง)
โ โโโ repository/ OrderEventRepository
โ โโโ service/ EventCommandService (์ฐ๊ธฐ) ยท EventQueryService (์ฝ๊ธฐ/replay)
โ โโโ consumer/ OrderEventListener (@TransactionalEventListener)
โโโ dto/ OrderDto (์ฝ๊ธฐ ๋ชจ๋ธ, apply๋ก replay) ยท OrderCreatedData ยท OrderUpdatedData (payload ์ญ์ง๋ ฌํ)
โโโ vo/ RequestOrder ยท ResponseOrder (HTTP ๊ฒฝ๊ณ)
โโโ exception/ OrderNotFoundException (404) ยท OrderAccessDeniedException (403) ยท GlobalExceptionHandler
๋ ์ด์ด ๋ฐฉํฅ์ controller โ event.service โ event.repository ๋จ๋ฐฉํฅ์ด๋ค.
./gradlew bootRun # :8082curl -X POST localhost:8082/u1/orders -H 'Content-Type: application/json' -d '{"productId":"P1","qty":2,"unitPrice":1000}'
curl localhost:8082/u1/orders
curl -X PATCH localhost:8082/u1/orders/{orderId} -H 'Content-Type: application/json' -d '{"productId":"P1","qty":5,"unitPrice":1000}'
curl localhost:8082/orders/{orderId}์ด๋ฒคํธ ์คํ ์ด๋ฅผ ์ง์ ๋ณด๋ ค๋ฉด H2 ์ฝ์: http://localhost:8082/h2-console
(JDBC URL jdbc:h2:mem:testdb, ์ฌ์ฉ์ sa, ๋น๋ฐ๋ฒํธ ์์) โ SELECT * FROM ORDER_EVENT ORDER BY EVENT_ID.
| ์คํ | |
|---|---|
| Java 17 ยท Spring Boot 4.1.1 (Spring MVC) | Spring Data JPA + H2 in-memory |
| Gson โ payload ์ง๋ ฌํ | Jackson โ HTTP ์๋ต ์ง๋ ฌํ |
| Lombok ยท ModelMapper | Bean Validation |
H2๋ ์ธ๋ฉ๋ชจ๋ฆฌ๋ผ ์ฌ๊ธฐ๋ํ๋ฉด ์ด๋ฒคํธ๊ฐ ์ฌ๋ผ์ง๋ค. ์์ DB๋ก ๋ฐ๊พธ๋ ์๊ฐ "์ด๋ฏธ ์ ์ฅ๋ ์ด๋ฒคํธ๊ฐ ์ applyXxx()๋ก replay๋๋๊ฐ"๊ฐ ์ค์ ๋ฌธ์ ๊ฐ ๋๋ค โ payload ๊ตฌ์กฐ๋ฅผ ๋ฐ๊ฟ ๋ ๊ฐ์ฅ ๋จผ์ ๋ด์ผ
ํ ์ง์ ์ด๋ค.
- ์ค๋ ์ทยทํ๋ก์ ์ ํ ์ด๋ธ โ ์กฐํ ๋๋ง๋ค ์ ์ฒด replayํ๋ค. ์ด๋ฒคํธ๊ฐ ๋ง์์ง๋ฉด ๋๋ ค์ง์ง๋ง ํ์ต ๋ฒ์ ๋ฐ.
- ๋ณด์ ์ด๋ฒคํธ โ ์๋ชป ์์ธ ์ด๋ฒคํธ๋ฅผ ๋๋๋ฆฌ๋ ๊ฒฝ๋ก๊ฐ ์๋ค. ๊ทธ๋์ ์๊ธฐ ์ ๊ฒ์ฆ (404/403/400)์ด ์ค์ํ๋ค.
- ํธ์ถ ์ธก ์๋น์ค โ ๋ณ๋๋ก ๋์ง ์๋๋ค. curl๋ก ์ง์ ํธ์ถํ๋ค.
- ์ธ์ฆ โ ๊ฒฝ๋ก์
{userId}๋ฅผ ๊ทธ๋๋ก ์ ๋ขฐํ๋ค. ์์ ์ ๊ฒ์ฌ๋ "๊ฒฝ๋ก userId == ์ฒซ ์ด๋ฒคํธ userId"๊น์ง๋ง.
