ํฐ์ผํ ํ๊ฒฝ์์ ๋ฐ์ํ๋ ์ด๊ณผ ์๋งคยท์ค๋ณต ๊ฒฐ์ ยท๋ณด์ ๋๋ฝยท๋ฐฉ์น ์์ฝ ๋ฌธ์ ๋ฅผ ์ง์ ์ฌํํ๊ณ ๋จ๊ณ์ ์ผ๋ก ํด๊ฒฐํ ํ๋ก์ ํธ์ ๋๋ค.
- ํ๋ก์ ํธ ์๊ฐ
- ๊ธฐ์ ์คํ
- ๋ฐฐํฌ ๊ตฌ์กฐ
- ํด๊ฒฐํ ๋ฌธ์
- ์๋งค ํ๋ก์ฐ
- ํต์ฌ ๊ธฐ์ ์ ํ ์ด์
- ์ฑ๋ฅ ์ธก์ ๊ฒฐ๊ณผ
- ํธ๋ฌ๋ธ์ํ
- DB ์ธ๋ฑ์ค ์ต์ ํ
- ํ์ ๊ฐ์ ๋ฐฉํฅ
- ์ฐธ์กฐ ๋ฌธ์
ํฐ์ผํ ์๋น์ค์์ "์ข์์ด 1๊ฐ ๋จ์๋๋ฐ 2๋ช ์ด ์๋งค๋๋ค", "๊ฒฐ์ ๊ฐ ์คํจํ๋๋ฐ ์ข์์ด ๋์์ค์ง ์์๋ค" ๊ฐ์ ๋ฌธ์ ๋ ์ ์๊ธฐ๋ ๊ฑธ๊น. ์ด ํ๋ก์ ํธ๋ ๊ทธ ์ง๋ฌธ์์ ์ถ๋ฐํ์ต๋๋ค.
์ฒ์์๋ SELECT ... FOR UPDATE๋ก ์์ฌ์์ ์ง๋ ฌํํ๋ฉด ์ถฉ๋ถํ๋ค๊ณ ๋ดค์ต๋๋ค. ์ ํฉ์ฑ์ ์ง์ผ์ก์ง๋ง 500๋ช
๋์ ์์ฒญ์์ ํ๊ท ์๋ต ์๊ฐ์ด 3.7์ด๊น์ง ์ฌ๋ผ๊ฐ์ต๋๋ค. Redis๋ก ์ข์ ์ ์ด๋ฅผ ์ฎ๊ฒผ๋๋ ์ด๋ฒ์ ์์์น ๋ชปํ ์์น์์ deadlock์ด ๋ฐ์ํ์ต๋๋ค. "๊ฒฐ์ ์คํจ ์งํ ์ฆ์ ๋ณด์"์ด ์ง๊ด์ ์ด๋ผ๊ณ ์๊ฐํ๋๋ฐ, ์คํจ ์ฃผ์
์คํ์์ ๋ณด์ ํธ์ถ ์์ฒด๋ ๊ฐ์ ๋น์จ๋ก ์ ์ค๋์ต๋๋ค.
๋งค ๋จ๊ณ๋ง๋ค "์ด๋ ๊ฒ ํ๋ฉด ๋ ๊ฒ ๊ฐ๋ค"๋ ๊ฐ์ค์ด ํ๋ ธ๊ณ , ๋ถํ ํ ์คํธ์ ์คํ ์ฝ๋๋ก ์ง์ ํ์ธํ ๋ค์์ผ ๋ค์ ๊ตฌ์กฐ๋ก ๋์ด๊ฐ์ต๋๋ค.
์ด ํ๋ก์ ํธ์์ ์ค์ ์ ์ผ๋ก ๊ณ ๋ฏผํ ๊ฒ์ ์ธ ๊ฐ์ง์ ๋๋ค.
- ์ ํฉ์ฑ vs ์ฒ๋ฆฌ๋ โ Lock ๋ฒ์๋ฅผ ์ด๋๊น์ง ์ก์์ผ ์ด๊ณผ ์๋งค๋ฅผ ๋ง์ผ๋ฉด์๋ ์๋ต ์๊ฐ์ด ๊ฒฌ๋ ๋งํ๊ฐ
- ๋ฉฑ๋ฑ์ฑ์ ๋ฒ์ โ "์ค๋ณต ์์ฒญ ์ฐจ๋จ"๊ณผ "๋์ผ ๊ฒฐ๊ณผ ๋ฐํ"์ ๊ฐ์ ๋ฌธ์ ๊ฐ ์๋๋ค. ์ ์ ์ฌ์๋์ ๋์ ์ค๋ณต ํด๋ฆญ์ ์๋ก ๋ค๋ฅธ ๋ฐฉ์ด์ ์ด ํ์ํ๋ค
- ๋ณด์์ ์๊ฒฐ์ฑ โ ๊ฒฐ์ ์คํจ ์ฌ์ค์ DB์ ๊ธฐ๋กํ๋ ๊ฒ๊ณผ ๋ณด์ ๋์์ ๊ธฐ๋กํ๋ ๊ฒ์ ๋ณ๊ฐ์ ์์ ์ด๋ค. ๊ฐ์ ํธ๋์ญ์ ์ ๋ฌถ์ด์ง ์์ผ๋ฉด ์ฑ์ด ์ฌ์์ํ ๋ ๋ณด์ ๋์ ์ ๋ณด๊ฐ ์ฌ๋ผ์ง๋ค
์๋๋ฆฌ์ค ๊ฐ์ : DAU 15,000๋ช , ์ ๋ 8์ ํฐ์ผ ์คํ, 5๋ถ ์ด๋ด์ 10,000๋ช ์ด ๋๊ธฐ์ด์ ์ง์ ํฉ๋๋ค.
์ด ์๋๋ฆฌ์ค๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋ถํ๋ฅผ ์ธ ๊ฐ์ง๋ก ๋๋ ์ ์ ๊ทผํ์ต๋๋ค.
| ์์ | ๋ถํ ์ ํ | ์๋ํฌ์ธํธ | ๊ณ์ฐ |
|---|---|---|---|
| 1์์ | ๋๊ธฐ์ด ์ง์ spike | POST /queue |
10,000๋ช / 300์ด โ 33 req/s (๋จ์๊ฐ ์ง์ค) |
| 2์์ | ๋๊ธฐ์ด polling ์ง์ ๋ถํ | GET /queue/me |
10,000๋ช ร 1/5s = 2,000 req/s (๊ณ ์ polling ๊ธฐ์ค) |
| 3์์ | ์๋งค ๋์์ฑ | POST /booking |
์ ์ฅ๊ถ ๋ฐ๊ธ ํ ๋๊ธฐ์๋ค์ด ์ง์ค์ ์ผ๋ก ์๋งค ์์ฒญ (์ ํฉ์ฑ ์ค์ฌ) |
polling์ด ์ง์ง ๋ณ๋ชฉ์
๋๋ค. ๋๊ธฐ์ด ์ง์
์ 33 req/s์ ๋ถ๊ณผํ์ง๋ง, ์ง์
ํ ๋ชจ๋๊ฐ 5์ด๋ง๋ค ์๋ฒ์ ์กฐํํ๋ฉด ์๋ฒ๋ 2,000 req/s๋ฅผ ๊ณ์ ๋ฐ์ต๋๋ค. Adaptive Polling(position > 1,000์ด๋ฉด 10์ด ๊ฐ๊ฒฉ)์ ์ ์ฉํ๋ฉด ์ด๋ก ์ 1,100 req/s๋ก ์ค์ด๋ญ๋๋ค.
์ด ์์น๋ฅผ ๋ชฉํ ๊ธฐ์ค์ผ๋ก ์ก๊ณ AWS EC2 2๋ โ GCP 3๋ + GCP LB๋ก ๋จ๊ณ์ ์ผ๋ก ๊ฒ์ฆํ์ต๋๋ค.
default.MOV
์ฌ์ฉ์ ๋ธ๋ผ์ฐ์
โ
โผ
Vercel (React ํ๋ก ํธ์๋)
โ
โผ
GCP HTTP(S) Load Balancer
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โผ โผ โผ
Spring Boot app-1 Spring Boot app-2 Spring Boot app-3
(Server1) (Server2) (Server3)
โ
โโโ MySQL
โโโ Redis
โโโ Prometheus
โโโ Grafana
- Server1: Spring Boot app + MySQL + Redis + Prometheus + Grafana
- Server2/3: Spring Boot app only
- ๋๊ธฐ์ด ์ฑ๋ฅ ์ธก์ ์ ์ด GCP Load Balancer + app 3๋ ๊ตฌ์ฑ์ ๊ธฐ์ค์ผ๋ก ์งํํ์ต๋๋ค.
์ฒ์์๋ AWS EC2 2๋ + Nginx ๋ก๋๋ฐธ๋ฐ์๋ก ์์ํ์ต๋๋ค. ํ๋ฆฌํฐ์ด์ ์์ VM ์คํ์์ ์ด๋๊น์ง ๋ฒํธ ์ ์๋์ง ๋จผ์ ํ์ธํ๊ณ , ์ดํ ๋ ํฐ ๊ท๋ชจ์ ๋ถํ ์คํ๊ณผ ์ค์ผ์ผ์ /์ค์ผ์ผ์์ ๋น๊ต๋ฅผ ์ํด GCP๋ก ์ด์ ํ์ต๋๋ค.
์ฌ์ฉ์ ๋ธ๋ผ์ฐ์
โ
โผ
Vercel (React ํ๋ก ํธ์๋)
โ /api/* ์์ฒญ โ Vercel rewrites๋ก EC2๋ก ํ๋ก์ (CORS ํด๊ฒฐ)
โผ
Nginx (EC2 #1, ํฌํธ 80 โ ๋ก๋ ๋ฐธ๋ฐ์)
โ Round-Robin
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โผ โผ
Spring Boot app-1 Spring Boot app-2
(EC2 #1, Docker) (EC2 #2, Docker)
โ โ
โโโโโโโโโโโโฌโโโโโโโโโโโโโโโโ
โผ
MySQL + Redis
(EC2 #1, Docker, ๊ณต์ )
์ด๋ฏธ์ง(๊ณต์ฐ ํฌ์คํฐ ๋ฑ)๋ Cloudinary CDN์ ํตํด ์ ๊ณต๋ฉ๋๋ค.
CI/CD๋ GitHub Actions โ Docker ์ด๋ฏธ์ง ๋น๋ โ EC2 ๋ฐฐํฌ ์์ผ๋ก ์๋ํ๋์ด ์์ต๋๋ค.
| ๋ฌธ์ | ์์ธ | ํด๊ฒฐ ๋ฐฉํฅ |
|---|---|---|
| ์ด๊ณผ ์๋งค | ์์ฌ์ ํ์ธ๊ณผ INSERT ์ฌ์ด ํ์ด๋ฐ ์ฐจ์ด | Redis DECR ์์ ์ฐ์ฐ์ผ๋ก ์ข์ ์ ์ ์ ๊ฒฐ์ |
| ์ค๋ณต ๊ฒฐ์ | ์ ์ ์ฌ์๋์ ๋์ ์ค๋ณต ์์ฒญ์ ์๋ก ๋ค๋ฅธ ์คํจ ์ง์ ์ ๊ฐ์ง | DB ์ฌ์ ์กฐํ + Redis SETNX + DB unique key 3๊ณ์ธต ๋ถ๋ฆฌ |
| ๋ณด์ ๋๋ฝ | ๊ฒฐ์ ์คํจ ํ ๋ณด์ ํธ์ถ์ด ์ ์ค๋๋ฉด ์ข์์ด ๋ณต๊ตฌ๋์ง ์์ | Transactional Outbox๋ก ๋ณด์ ๋์์ ๊ฐ์ ํธ๋์ญ์ ์ ๊ธฐ๋ก |
| ๋ฐฉ์น ์์ฝ | ๋ฏธ๊ฒฐ์ ์์ฝ์ด ์ข์์ ์ฅ๊ธฐ ์ ์ | 30๋ถ ์ด๊ณผ PENDING_PAYMENT ์๋ ์ทจ์ + Redis ์ข์ ๋ณต์ |
| ๋๊ธฐ์ด ์ง์ spike | ํฐ์ผ ์คํ ์๊ฐ ๋๋์ queue ๋ฑ๋ก ์์ฒญ์ด ์งง์ ์๊ฐ์ ์ง์ค | Redis ๊ธฐ๋ฐ enqueue ๊ตฌ์กฐ๋ก 10,000๋ช
์ฑ์ฐ๊ธฐ ์๋๋ฆฌ์ค๊น์ง 0% ์๋ฌ๋ก ์ฒ๋ฆฌ |
| ๋๊ธฐ์ด polling ์ง์ ๋ถํ | ๋๊ธฐ ์ธ์์ด ๋ง์์ง์๋ก /queue/me ์์ฒญ ์๊ฐ ์ ํ ์ฆ๊ฐ |
3๋ ์ค์ผ์ผ์์๊ณผ Adaptive Polling์ผ๋ก ํ๊ท ์กฐํ ๋ถํ๋ฅผ ๋ฎ์ถ๋ ๋ฐฉํฅ์ผ๋ก ๊ฐ์ |
๊ฐ ๋ฌธ์ ๋ฅผ experiments/ ํจํค์ง์์ ์ง์ ์ฌํํ๊ณ , ๋ก๊ทธ์ ๋ถํ ํ
์คํธ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ์์ธ์ ์ขํ๊ฐ๋ฉฐ ๊ตฌ์กฐ๋ฅผ ๋ฐ๊ฟจ์ต๋๋ค.
MVP์์๋ DB row lock์ผ๋ก ์ข์์ ์ ์ดํ์ผ๋, ๋ถํ ํ ์คํธ์์ hot row ๋ณ๋ชฉ์ ํ์ธํ ๋ค "์ค์๊ฐ ์ ์ด๋ Redis, ์์ ์ํ ์ ์ฅ์ DB"๋ก ์ญํ ์ ๋ถ๋ฆฌํ์ต๋๋ค.
์ด๊ธฐ ์ํคํ ์ฒ โ ๋จ์ผ ์๋ฒ (AWS Elastic Beanstalk โ EC2)
์ด๊ธฐ ๋ฐฐํฌ๋ AWS Elastic Beanstalk์ผ๋ก ์์ํ์ผ๋, ๋น์ฉ ์ ๊ฐ๊ณผ ์ง์ ์ ์ด๋ฅผ ์ํด EC2 + Docker + Nginx ๊ตฌ์ฑ์ผ๋ก ์ ํํ์ต๋๋ค.
sequenceDiagram
actor User
participant Redis
participant BookingService
participant DB
User->>Redis: โ ๋๊ธฐ์ด ๋ฑ๋ก (ZADD, score=timestamp)
loop ์๋ฒ ํด๋ง (GET /queue/me)
User->>Redis: ZRANK
Redis-->>User: ํ์ฌ ์๋ฒ
end
Note over Redis: ConcertQueueScheduler (1s ์ฃผ๊ธฐ)<br/>Lua: ZRANGE โ ZREM โ SETEX (์์ 100๋ช
์
์ฅ๊ถ ๋ฐ๊ธ, TTL 600s)<br/>๋๊ธฐ์ด์์ ์ ๊ฑฐ๋๋ฉด ZRANK = null โ ์
์ฅ ๊ฐ๋ฅ
User->>BookingService: โก ์๋งค ์์ฒญ
BookingService->>Redis: Lua CLAIM_ADMITTED (EXISTS โ TTL โ DEL, ์์์ )
BookingService->>Redis: DECR seats:concert:{id}
Note over BookingService: ์
์ฅ๊ถ ์์ผ๋ฉด ์๋งค ๊ฑฐ๋ถ
BookingService->>Redis: GET concert:status:{id} (์บ์ ํํธ ์ DB ์๋ต)
BookingService->>DB: SELECT Concert (์บ์ ๋ฏธ์ค ์์๋ง)
BookingService->>DB: INSERT Booking (PENDING_PAYMENT)
Note over BookingService: bookedCount๋ Redis remaining ๊ธฐ๋ฐ ํ์๊ฐ์ผ๋ก ์กฐํ ์ ๊ณ์ฐ
BookingService-->>User: ์๋งค ์๋ฃ
User->>BookingService: โข ๊ฒฐ์ ์์ฒญ (idempotencyKey)
BookingService->>DB: SELECT Payment (๋ฉฑ๋ฑ์ฑ ์ฌ์ ์กฐํ)
BookingService->>Redis: SETNX payment:idempotency:{key} (TTL 30s)
BookingService->>DB: INSERT Payment (PENDING) โ Mock PG ์ฒ๋ฆฌ
BookingService->>DB: Payment (COMPLETED) + Booking (CONFIRMED)
BookingService-->>User: ๊ฒฐ์ ์๋ฃ
Note over BookingService: finally: Redis ๋ฉฑ๋ฑ์ฑ ํค ์ญ์
๋ณด์์ "๊ฒฐ์ ์คํจ ์งํ ์ฆ์ ํธ์ถ"๋ก ๋จผ์ ๊ตฌํํ์ง๋ง, ์คํจ ์ฃผ์ ์คํ์์ ๋ณด์ ํธ์ถ ์์ฒด๋ ๊ฐ์ ๋น์จ๋ก ์ ์ค๋์ต๋๋ค. ํ์ฌ๋ ๋ณด์ ์ฌ์ค์ Outbox์ ๋จ๊ธฐ๊ณ , ์ค์ผ์ค๋ฌ๊ฐ ์ข์ ๋ณต๊ตฌ์ ์ํ ์ ํ์ ์ฑ ์์ง๋๋ค.
sequenceDiagram
participant PaymentService
participant DB
participant Redis
participant Scheduler
Note over PaymentService: Mock PG ๊ฒฐ์ ์คํจ
PaymentService->>DB: Payment.fail() + INSERT Outbox(PENDING)<br/>โ ๋์ผ ํธ๋์ญ์
์์์ ์ปค๋ฐ
PaymentService-->>User: ๊ฒฐ์ ์คํจ ์๋ต
Note over Scheduler: PaymentCompensationScheduler (10s ์ฃผ๊ธฐ, ShedLock)
Scheduler->>DB: SELECT Outbox WHERE status = PENDING
loop ๊ฑด๋ณ REQUIRES_NEW ํธ๋์ญ์
Scheduler->>DB: SELECT Booking
alt ์ด๋ฏธ CANCELLED (๋ฉฑ๋ฑ ์ฒ๋ฆฌ)
Scheduler->>DB: Outbox โ PUBLISHED (์ข์ ๋ณต์ ์์)
else PENDING_PAYMENT
Scheduler->>DB: Booking โ CANCELLED
Scheduler->>Redis: restoreSeat(concertId)
Scheduler->>DB: SOLD_OUT ์ด๋ฉด OPEN ์ผ๋ก ์ํ ๋ณต๊ตฌ
Scheduler->>DB: Outbox โ PUBLISHED
end
end
Redis Sorted Set + Lua Script โ ๋๊ธฐ์ด์ ์ Redis๋ก ์ฎ๊ฒผ๊ณ , ์ Lua๊ฐ ํ์ํ๋
DB ํ
์ด๋ธ ํ๋๋ก ๋๊ธฐ์ด์ ๊ตฌํํด๋ ์ถฉ๋ถํ๋ค๊ณ ๋ดค์ต๋๋ค. INSERT INTO queue (user_id, created_at) ํ COUNT(*) ๋ก ์๋ฒ์ ๊ณ์ฐํ๋ฉด ๋จ์ํ๊ณ ์ดํดํ๊ธฐ ์ฝ์ต๋๋ค.
ํฐ์ผํ
์คํ ์๊ฐ์๋ ๋ฑ๋ก๋ณด๋ค ์กฐํ๊ฐ ํจ์ฌ ๋ง์ต๋๋ค. ์๋ฐฑ ๋ช
์ด ์งง์ ์ฃผ๊ธฐ๋ก ์๋ฒ์ ํด๋งํ๋ฉด COUNT(*) ์ฟผ๋ฆฌ๊ฐ ๋ฐ๋ณต๋๊ณ , ๋๊ธฐ์ด ์ฝ๊ธฐ ๋น์ฉ์ด ๊ทธ๋๋ก DB์ ์์์ต๋๋ค.
DB ํ ์ด๋ธ: ๊ตฌํ์ด ๋จ์ํ์ง๋ง, ์๋ฒ ์กฐํ ์ฟผ๋ฆฌ๊ฐ ํด๋ง๋ง๋ค ๋ฐ๋ณต๋ผ ๊ณ ๋์์ฑ์์ DB ๋ถํ๊ฐ ์ง์ค๋ฉ๋๋ค.
Redis List: LPUSH/LINDEX๋ก ์๋ฒ ์กฐํ๋ ๊ฐ๋ฅํ์ง๋ง, ์ค๊ฐ ์ญ์ (์ ์ฅ ์ฒ๋ฆฌ ์ LREM)๊ฐ O(N)์ ๋๋ค. ๋๊ธฐ์ด ๊ท๋ชจ๊ฐ ์ปค์ง์๋ก ์ ์ฅ ์ฒ๋ฆฌ ๋น์ฉ์ด ์ ํ์ผ๋ก ๋์ด๋ฉ๋๋ค.
Redis Sorted Set: ZADD O(log N), ZRANK O(log N)์ผ๋ก ์๋ฒ ์กฐํ, ZREM O(log N)์ผ๋ก ์ญ์ . score๋ฅผ ๋ฑ๋ก ํ์์คํฌํ๋ก ์ค์ ํ๋ฉด ์ ์ฐฉ์ ์์๊ฐ ์๋์ผ๋ก ์ ์ง๋๊ณ , ๋ชจ๋ ์ฃผ์ ์ฐ์ฐ์ด O(log N)์ ๋๋ค.
Sorted Set๋ ๋จ์ ์ด ์์ต๋๋ค. List๋ณด๋ค ๋ฉ๋ชจ๋ฆฌ๋ฅผ ๋ ์๋๋ค. ์๋ฐฑ๋ง ๋ช ๊ท๋ชจ๋ผ๋ฉด skip list ์ค๋ฒํค๋๊ฐ ๋ฌด์ํ ์ ์์ต๋๋ค. ๊ทธ๋ฌ๋ ํฐ์ผํ ๋๊ธฐ์ด์ ์ด๋ฒคํธ ์คํ ์งํ ์๋ฐฑ~์์ฒ ๋ช ์์ค์ด๊ณ , ์ ์ฅ ์ฒ๋ฆฌ ํ ๋น ๋ฅด๊ฒ ์ค์ด๋๋ ํน์ฑ์ด ์์ต๋๋ค. ์ด ๊ท๋ชจ์์๋ ์กฐํ ์ฑ๋ฅ ์ด์ ์ด ๋ฉ๋ชจ๋ฆฌ ํธ๋ ์ด๋์คํ๋ฅผ ์์ํฉ๋๋ค.
Sorted Set์ ์ ํํ ๋ค ์ ๋ฌธ์ ๊ฐ ์๊ฒผ์ต๋๋ค. ์
์ฅ๊ถ ๋ฐ๊ธ์ด ZRANGE โ ZREM โ SETEX ์ธ ๋จ๊ณ์ธ๋ฐ, ์ด ์ฌ์ด์ ์ค์ผ์ค๋ฌ๊ฐ ๋ ๋ฒ ์คํ๋๊ฑฐ๋ ํ๋ก์ธ์ค๊ฐ ์ฃฝ์ผ๋ฉด ์ด๋ป๊ฒ ๋ ๊น์.
- ZREM์ ๋๋๋ฐ SETEX ์ ์ ํ๋ก์ธ์ค๊ฐ ์ฃฝ์ผ๋ฉด โ ๋๊ธฐ์ด์์ ๋น ์ก์ง๋ง ์ ์ฅ๊ถ์ด ์๋ ์ฌ์ฉ์ ๋ฐ์
- ์ค์ผ์ค๋ฌ๊ฐ ๊ฑฐ์ ๋์์ ๋ ๋ฒ ์คํ๋๋ฉด โ ๊ฐ์ ์ฌ์ฉ์์๊ฒ ์ ์ฅ๊ถ ์ค๋ณต ๋ฐ๊ธ
์์์ฑ์ ๋ณด์ฅํ๋ ๋ฐฉ๋ฒ์ ์ธ ๊ฐ์ง ๋น๊ตํ์ต๋๋ค.
Java ์์ฐจ ํธ์ถ: ๊ตฌํ์ด ๊ฐ์ฅ ๋จ์ํ์ง๋ง, ์ธ ๋ช ๋ น ์ฌ์ด์ ๋ค๋ฅธ ์์ฒญ์ด ๋ผ์ด๋ค ์ ์์ต๋๋ค. ์์์ฑ์ด ์์ต๋๋ค.
Redis MULTI/EXEC with WATCH: WATCH๋ก ๊ฐ์ํ๋ ํค๊ฐ ๋ณ๊ฒฝ๋๋ฉด EXEC๊ฐ ๋ฌดํจํ๋๊ณ ์ฌ์๋๊ฐ ํ์ํฉ๋๋ค. ๋์ ์์ฒญ์ด ๋ง์ ๊ตฌ๊ฐ์์ WATCH ์คํจ๊ฐ ์์ฃผ ๋ฐ์ํ๋ฉด ์ฌ์๋ ๋ฃจํ๊ฐ ํ์ํด์ง๊ณ , "๋จ์ํ ์์ ์ฐ์ฐ"์ด "์ฌ์๋๋ฅผ ์ ์ดํ๋ ๋ณต์กํ ์ฝ๋"๊ฐ ๋ฉ๋๋ค.
Lua Script: Redis ์๋ฒ ๋ด๋ถ์์ ๋จ์ผ ์ค๋ ๋๋ก ์คํ๋์ด ์ฌ์๋ ์์ด ์์์ฑ์ด ๋ณด์ฅ๋ฉ๋๋ค. ์คํฌ๋ฆฝํธ ์ค๋ฅ ์ ๋๋ฒ๊น ์ด ์ด๋ ต๊ณ , ๋ณต์กํ ์คํฌ๋ฆฝํธ๋ ์๋ฒ ์๋ต์ ๋ธ๋กํนํ ์ ์์ต๋๋ค. ๊ทธ๋ฌ๋ ์ธ ์ค์ง๋ฆฌ ์ฐ์ฐ์ด๋ผ๋ฉด ์ด ํธ๋ ์ด๋์คํ๊ฐ ๋ฉ๋๋ฉ๋๋ค.
-- POP_AND_GRANT: ๋๊ธฐ์ด ์์ N๋ช
์ ๊บผ๋ด ์
์ฅ๊ถ ๋ฐ๊ธ (์์์ )
local users = redis.call('ZRANGE', queueKey, 0, count - 1)
if (#users == 0) then return users end
redis.call('ZREM', queueKey, unpack(users))
for i = 1, #users do
redis.call('SETEX', admittedPrefix .. users[i], ttl, '1')
end
return users์
์ฅ๊ถ ์๋น(EXISTS โ TTL โ DEL)๋ ๊ฐ์ ์ด์ ๋ก Lua Script๋ฅผ ์ฌ์ฉํฉ๋๋ค. ๋ฐํ๊ฐ์ผ๋ก ๋จ์ TTL์ ๋ฐ์, DB ์ฒ๋ฆฌ ์คํจ ์ ์
์ฅ๊ถ์ ์๋ ์์ฌ TTL๋ก ๋ณต์ํ๋ ๋ฐ๋ ํ์ฉํ์ต๋๋ค.
-- CLAIM_ADMITTED: ์
์ฅ๊ถ ํ์ธ + ์์์ ์๋น, ๋จ์ TTL ๋ฐํ
if redis.call('EXISTS', key) == 0 then return -1 end
local ttl = redis.call('TTL', key)
redis.call('DEL', key)
return ttl๋๊ธฐ์ด ์๋ฒ ์กฐํ๊ฐ DB polling์ด ์๋ Redis ZRANK ๋จ์ผ ๋ช ๋ น์ผ๋ก ์ฒ๋ฆฌ๋ฉ๋๋ค. ์ ์ฅ๊ถ ๋ฐ๊ธ๊ณผ ์๋น๋ Lua Script๋ก ์์์ฑ์ ๋ณด์ฅํด ์ค๋ณต ๋ฐ๊ธ๊ณผ TOCTOU ๋ฌธ์ ๋ฅผ ๋ฐฉ์งํ์ต๋๋ค.
DB Lock โ ๋๊ด์ ๋ฝ๊ณผ ๋น๊ด์ ๋ฝ ์ค ์ด๋ ์ชฝ์ด ํฐ์ผํ ์ ๋ง๋๊ฐ
Optimistic Lock๊ณผ Pessimistic Lock ์ค ํ๋๋ฅผ ๊ณ ๋ฅด๋ฉด ํด๊ฒฐ๋ ๊ฑฐ๋ผ๊ณ ์๊ฐํ์ต๋๋ค. ์ถฉ๋์ด ๋ง์ผ๋ฉด ๋น๊ด์ ๋ฝ, ๋๋ฌผ๋ฉด ๋๊ด์ ๋ฝ์ด๋ผ๋ ํต๋ ์์ ์์ํ๊ณ , "๋๊ด์ ๋ฝ์ด ์ฒ๋ฆฌ๋์ด ๋๋ค"๋ ์ค๋ช ์ ๋ณด๊ณ ์ฒ์์ ๋๊ด์ ๋ฝ์ ๋จผ์ ๊ณ ๋ คํ์ต๋๋ค.
๋๊ด์ ๋ฝ์ ์ถฉ๋์ด ๋๋ฌธ ํ๊ฒฝ์์ ๋น๊ด์ ๋ฝ๋ณด๋ค ์ฒ๋ฆฌ๋์ด ๋์ต๋๋ค. DB ์์์ ์ปค๋ฐ ์์ ์๋ง ์ก๊ธฐ ๋๋ฌธ์ ๋๋ค. ๊ทธ๋ฌ๋ ์ด ์ด์ ์ "์ถฉ๋์ด ๋๋ฌธ ํ๊ฒฝ"์ด๋ผ๋ ์ ์ ์์๋ง ์ฑ๋ฆฝํฉ๋๋ค.
ํฐ์ผํ ์คํ ์งํ๋ "๊ฑฐ์ ๋ชจ๋ ์์ฒญ์ด ๊ฐ์ ์ข์ row๋ฅผ ์ฝ๊ณ UPDATEํ๋ ๊ตฌ๊ฐ"์ ๋๋ค. ์ถฉ๋์ ์์ธ๊ฐ ์๋๋ผ ์ ์ ์ํ์ ๋๋ค.
[๋๊ด์ ๋ฝ ์๋๋ฆฌ์ค]
50๊ฐ ์ค๋ ๋ ๋์ ์์ฒญ โ 1๊ฐ๋ง ์ปค๋ฐ ์ฑ๊ณต โ 49๊ฐ OptimisticLockException
โ ์ฌ์๋ โ ๋ ์ถฉ๋ โ ์ฌ์๋ ๋ฌดํ ๋ฐ๋ณต
โ ์๋ต ์ง์ฐ ๋์ , DB ์ปค๋ฅ์
์ ์ ์ฆ๊ฐ
์ฌ์๋ ํ์๋ฅผ ์ ํํด๋, ์คํจํ ์์ฒญ๋ค์ด ์๋ฌ๋ก ๋๋๋ ๊ฑด ๋์ผํฉ๋๋ค. ๋๊ด์ ๋ฝ์ ์ฅ์ ์ ์ด ๊ตฌ๊ฐ์์ ๋ฐํ๋์ง ์์ต๋๋ค.
๋น๊ด์ ๋ฝ์ ๋ฝ์ ์ก์ ํธ๋์ญ์ ์ด ์๋ฃํ ๋๊น์ง ๋ค๋ฅธ ํธ๋์ญ์ ์ด ๋๊ธฐํฉ๋๋ค. ์ฌ์๋ ์์ด ์์ฐจ ์ฒ๋ฆฌ๋ฉ๋๋ค. ํฌ๋ฆฌํฐ์ปฌ ์น์ ์ด ๊ธธ๋ฉด ๋๊ธฐ ์๊ฐ์ด ๋์ ๋๋ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.
๊ทธ๋ฌ๋ ์์ฌ์ ์ฐจ๊ฐ์ ๋จ์ UPDATE ์ฐ์ฐ์ ๋๋ค. ๋ฝ ์ ์ง ์๊ฐ์ด ์งง์ ๋๊ธฐ ๋น์ฉ์ด ํฌ์ง ์์ต๋๋ค. ๋ํด์, Redis ๋๊ธฐ์ด์ด ์ด๋ฏธ ์ง์ ์ธ์์ 50๋ช ๋จ์๋ก ๋๋ ์ ์ฅ์ํค๊ธฐ ๋๋ฌธ์ ์ค์ ๋์์ ์ข์์ ์ฐจ๊ฐํ๋ ์ธ์๋ ์ ํ๋ฉ๋๋ค. ๋ฝ ๊ฒฝํฉ ์์ฒด๊ฐ ํฌ์ง ์๋ค๋ ์ ๋ ๋น๊ด์ ๋ฝ์ ์ ํํ๋ ๋ฐ ์ ๋ฆฌํ๊ฒ ์์ฉํ์ต๋๋ค.
์ด๊ณผ ์๋งค๋ฅผ ๋ง์์ต๋๋ค. ๋ถํ ํ ์คํธ์์ "์ ํฉ์ฑ์ ํ๋ณด๋์ง๋ง ๊ณ ๋์์ฑ ์ฒ๋ฆฌ๋์ ๋ถ์กฑํ ๊ตฌ์กฐ"๋ผ๋ ๋ค์ ๋ฌธ์ ๋ฅผ ํ์ธํ๊ณ , ์ดํ Redis ์ข์ ์ฐจ๊ฐ์ผ๋ก ๊ฐ์ ํ์ต๋๋ค.
Redis ์ข์ ์ฐจ๊ฐ + ํ์ bookedCount โ deadlock ์์ธ์ด ์์ ๋ฐ์ ์์๋ค
DB ๋ฝ ๊ตฌ์กฐ์์ ๋ฒ์ด๋๋ฉด ์๋ต ์๊ฐ์ด ์ข์์ง ๊ฒ ๊ฐ์, ์ข์ ์ ์ด๋ฅผ Redis decrementSeat๋ก ์ฎ๊ฒผ์ต๋๋ค. ์ด๋๋ ์์ฝ ์ฑ๊ณต ์๋ฅผ ๋ฐ๋ก ํ์ธํ๊ณ ์ถ์ด์ concert.booked_count = booked_count + 1 ์
๋ฐ์ดํธ๋ ์ ์งํ์ต๋๋ค.
JMeter ๋์ ์์ฝ ํ ์คํธ์์ ์ผ๋ถ ์์ฒญ์ด 500์ผ๋ก ์คํจํ๊ณ , ๋ก๊ทธ์ ์๋ ์์ธ๊ฐ ๋จ์์ต๋๋ค.
MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
Redis๋ก ์ข์ ์ ์ด๋ฅผ ์ฎ๊ฒผ๋๋ฐ ์ DB deadlock์ด ์๊ธธ๊น์. BookingService.book() ํ๋ฆ์ ๋ฐ๋ผ๊ฐ๋ดค์ต๋๋ค. booking INSERT ์งํ incrementBookedCount()๋ฅผ ํธ์ถํด concert.booked_count๋ฅผ ์ฆ์ UPDATEํ๊ณ ์์์ต๋๋ค. ์ข์ ์ ์ ๋ Redis๊ฐ ์ฒ๋ฆฌํ์ง๋ง, ๋ชจ๋ ์ฑ๊ณต ์์ฒญ์ด ์ฌ์ ํ ๊ฐ์ concert ํ์ UPDATEํ๊ณ ์์์ต๋๋ค. booking INSERT๊ฐ ์ก๋ ์ธ๋ฑ์ค/์ธ๋ํค ๋ฝ๊ณผ concert UPDATE ๋ฝ์ด ๊ฐ์ ํธ๋์ญ์
์์์ ์์ด๋ฉด์ deadlock์ด ๋ฐ์ํ์ต๋๋ค.
๋ฐฉ๋ฒ 1: concert UPDATE๋ฅผ ๋ณ๋ ํธ๋์ญ์
์ผ๋ก ๋ถ๋ฆฌ
@Transactional(REQUIRES_NEW)๋ก booked_count๋ง ๋ฐ๋ก UPDATEํ๋ฉด ๋ ๋ฝ์ด ๊ฐ์ ํธ๋์ญ์
์์ ์ถฉ๋ํ์ง ์์ต๋๋ค. deadlock์ ํด์๋์ง๋ง ๋ชจ๋ ์์ฒญ์ด ๊ฐ์ concert ํ์ UPDATEํ๋ hot row ๋ฌธ์ ๋ ๊ทธ๋๋ก ๋จ์ต๋๋ค. ์๋ต ์๊ฐ ๋ณ๋ชฉ์ด ํํ๋ง ๋ฐ๋๋๋ค.
๋ฐฉ๋ฒ 2: booked_count๋ฅผ ๋น๋๊ธฐ๋ก ๊ฐฑ์
@Async๋ ์ด๋ฒคํธ๋ก booked_count ๊ฐฑ์ ์ ๋ค๋ก ๋ฏธ๋ฃจ๋ฉด ๋๊ธฐ ๋ณ๋ชฉ์ ์ค์ผ ์ ์์ต๋๋ค. ํ์ง๋ง ๊ฐฑ์ ์ด ์ง์ฐ๋๋ ๊ตฌ๊ฐ์ ์กฐํํ๋ฉด staleํ ๊ฐ์ด ๋ฐํ๋ฉ๋๋ค. ์ ํฉ์ฑ๋ณด๋ค ์กฐํ ์ฑ๋ฅ์ ์ฐ์ ํ๋ ์ค๊ณ์
๋๋ค.
๋ฐฉ๋ฒ 3: bookedCount๋ฅผ ํ์๊ฐ์ผ๋ก ์ ํ
booked_count UPDATE๋ฅผ ์์ ํ ์ ๊ฑฐํ๊ณ , ์กฐํ ์ totalSeats - Redis remainingSeats๋ก ๊ณ์ฐํฉ๋๋ค. ์ฆ์ ๊ฐฑ์ ํ ํ์๊ฐ ์์ด hot row ์์ฒด๊ฐ ์ฌ๋ผ์ง๋๋ค.
๋จ์ ์ด ์์ต๋๋ค. Redis๊ฐ ๋ด๋ ค๊ฐ๋ฉด ์ค์๊ฐ ์์ฌ์ ๊ฐ์ ๊ตฌํ ์ ์์ต๋๋ค. ์ด ๊ฒฝ์ฐ fallback์ผ๋ก DB์ ์ ์ฅ๋ ๋ง์ง๋ง bookedCount๋ฅผ ์ฌ์ฉํ๊ณ ๊ฒฝ๊ณ ๋ก๊ทธ๋ฅผ ๋จ๊ธฐ๋ ๋ฐฉ์์ผ๋ก ์ํํ์ต๋๋ค. Redis๋ฅผ ์ค์๊ฐ ์ ์ด ๋๊ตฌ๋ก ์ฐ๋ ์ด์ Redis ๊ฐ์ฉ์ฑ์ ๋ํ ์์กด์ด ์๊ธฐ๋ ๊ฑด ํผํ ์ ์๊ณ , fallback์ ๋ช
์์ ์ผ๋ก ์ค๊ณํ๋ ๊ฒ์ด ๋ ๋์ ์ ๊ทผ์ด๋ผ๊ณ ํ๋จํ์ต๋๋ค.
๋ฐฉ๋ฒ 1์ hot row๋ฅผ ๋จ๊ฒจ๋๊ณ , ๋ฐฉ๋ฒ 2๋ stale ์กฐํ๋ฅผ ํ์ฉํฉ๋๋ค. ๋ฐฉ๋ฒ 3์ ์ ํํ์ต๋๋ค.
concert ํ์ ๋งค ์์ฒญ๋ง๋ค ๊ฐฑ์ ํ๋ hot row update๊ฐ ์ฌ๋ผ์ก๊ณ , ๋์ผ ์๋๋ฆฌ์ค์์ deadlock ์์ด ๋ณ๋ ฌ ์ฒ๋ฆฌ๊ฐ ๊ฐ๋ฅํด์ก์ต๋๋ค.
3๊ณ์ธต ๋ฉฑ๋ฑ์ฑ โ "unique key ํ๋๋ฉด ๋์ง ์๋"์์ ์์ํ ๊ณ ๋ฏผ
DB์ uk_payment_idempotency_key unique constraint ํ๋๋ฅผ ๋๋ฉด ์ถฉ๋ถํ๋ค๊ณ ์๊ฐํ์ต๋๋ค. ์ค๋ณต ์์ฒญ์ด ๋ค์ด์ค๋ฉด INSERT๊ฐ ์คํจํ๊ณ ํธ์ถ์๊ฐ ์ด๋ฏธ ์ฒ๋ฆฌ๋๋ค๋ ๊ฑธ ์ ์ ์์ผ๋๊น์.
experiments/e3์์ ์ธ ์ ๋ต์ ๊ฐ๊ฐ ์คํํ๋ฉด์ ์ด ์๊ฐ์ด ํ๋ ธ๋ค๋ ๊ฑธ ํ์ธํ์ต๋๋ค.
์ ๋ต A โ DB unique key๋ง ์ฌ์ฉ
์ค๋ณต ์์ฒญ์ด ๊ฑฐ์ ๋์์ ๋ค์ด์ค๋ฉด ๋ ์์ฒญ ๋ชจ๋ ๋น์ฆ๋์ค ๋ก์ง ์ด๋ฐ์ ํต๊ณผํฉ๋๋ค. INSERT ์์ ์์ผ ์ถฉ๋์ด ๋๋ฌ๋๋๋ฐ, ๊ทธ ์ ์ ์ด๋ฏธ ๊ฒฐ์ ๊ด๋ จ ๋ก์ง์ด ์คํ๋ ์ํ์ ๋๋ค. ์ถฉ๋์ ์๋จ์์ ์ฐจ๋จํ์ง ๋ชปํฉ๋๋ค.
๋ ๋ฒ์งธ ๋ฌธ์ ๊ฐ ์์ต๋๋ค. ์ด๋ฏธ ์ฑ๊ณตํ ๊ฒฐ์ ๋ฅผ ์ฌ์ฉ์๊ฐ ์ฌ์๋ํ๋ ๊ฒฝ์ฐ, unique constraint ์์ธ๋ก ์ฒ๋ฆฌํ๋ฉด ํด๋ผ์ด์ธํธ ์ ์ฅ์์๋ "๊ฒฐ์ ์คํจ"์ฒ๋ผ ๋ณด์ ๋๋ค. ๋ฉฑ๋ฑ์ฑ์ ๋ณธ๋ ์๋ฏธ๋ "๊ฐ์ ์์ฒญ์ด ๋ค์ด์์ ๋ ๊ธฐ์กด ๊ฒฐ๊ณผ๋ฅผ ๊ทธ๋๋ก ๋ฐํํ๋ ๊ฒ"์ธ๋ฐ, unique key๋ง์ผ๋ก๋ ๊ทธ ๋ฐํ ์ฒ๋ฆฌ๋ฅผ ํ ์ ์์ต๋๋ค.
์ ๋ต B โ SELECT EXISTS โ INSERT
์ด๋ฏธ ์ฒ๋ฆฌ๋ ์ฌ์๋๋ ๋น ๋ฅด๊ฒ ๊ฑธ๋ฌ๋ผ ์ ์๊ณ , ๊ธฐ์กด ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํ๊ธฐ๋ ์ฝ์ต๋๋ค. ๊ทธ๋ฌ๋ SELECT์ INSERT ์ฌ์ด์ ๋์ ์์ฒญ์ด ๋ผ์ด๋ค๋ฉด ๋ ๋ค SELECT์์ "์์"์ ๋ณด๊ณ INSERT๋ก ์ง์ ํฉ๋๋ค. ๋์ ์ค๋ณต ํด๋ฆญ์ ์ทจ์ฝํฉ๋๋ค.
์ ๋ต C โ Redis SETNX๋ง ์ฌ์ฉ
๋์ ์์ฒญ ์ฐจ๋จ์๋ ํจ๊ณผ์ ์ ๋๋ค. ๊ทธ๋ฌ๋ Redis๊ฐ ์ฅ์ ์ํ๋ฉด ๋ฐฉ์ด์ ์ด ํต์งธ๋ก ์ฌ๋ผ์ง๋๋ค.
์คํ์ ํตํด ๊ฐ ์ ๋ต์ด ๋ด๋นํ ์ ์๋ ์ํฉ์ด ๋ค๋ฅด๋ค๋ ๊ฒ์ ํ์ธํ์ต๋๋ค. ์ธ ๊ณ์ธต์ ์กฐํฉํ๋ฉด ๊ฐ๊ฐ์ ์ทจ์ฝ์ ์ ์๋ก ๋ณด์ํฉ๋๋ค.
| ๊ณ์ธต | ์ญํ | ๋์ ์ํฉ |
|---|---|---|
| DB ์ฌ์ ์กฐํ | ์ด๋ฏธ ์ฒ๋ฆฌ๋ ์์ฒญ์ด๋ฉด ๊ธฐ์กด ๊ฒฐ๊ณผ ์ฆ์ ๋ฐํ | ์ ์ ์ฌ์๋ |
| Redis SETNX (TTL 30s) | ๋์ผ ํค์ ๋์ ์์ฒญ์ ์๋จ์์ ์ฐจ๋จ | ๋์ ์ค๋ณต ํด๋ฆญ |
| DB unique key | Redis ์ฅ์ ์ ์ตํ ๋ฐฉ์ด์ | Redis ์ฅ์ |
3๊ณ์ธต์ ๊ตฌํ ๋ณต์ก๋๋ฅผ ๋์ ๋๋ค. ๊ณ์ธต์ด ๋ง์์๋ก ๋ฒ๊ทธ๊ฐ ์จ์ ์๋ฆฌ๋ ๋ง์์ง๋๋ค. ๊ทธ๋ฌ๋ ๊ฐ ๊ณ์ธต์ด ์๋ก ๋ค๋ฅธ ์คํจ ๋ชจ๋๋ฅผ ๋ด๋นํ๊ณ , ํ๋๊ฐ ๋์ํ์ง ์์๋ ๋๋จธ์ง๊ฐ ๋ณด์ํ๋ ๊ตฌ์กฐ์ด๊ธฐ ๋๋ฌธ์ ๊ฒฐ์ ๋ผ๋ ๋๋ฉ์ธ์์๋ ์ด ๋ณต์ก๋๊ฐ ๋ฉ๋๋ฉ๋๋ค.
PaymentServiceFailRateTest โ ๋์ผ idempotencyKey๋ก ์ฌ์์ฒญ ์ ๊ธฐ์กด ๊ฒฐ๊ณผ๊ฐ ๋ฐํ๋จ์ ๊ฒ์ฆํฉ๋๋ค. Grafana ๋์๋ณด๋์์ MOCK/CACHED ๋ถํฌ๋ก ์ค๋ณต ๊ฒฐ์ ๊ฐ ์ค์ ๋ก ์ฐจ๋จ๋๊ณ ๋์ผ ๊ฒฐ๊ณผ๊ฐ ๋ฐํ๋์์ ํ์ธํ์ต๋๋ค.
Transactional Outbox โ Kafka๊ฐ ์๋ Outbox๋ฅผ ์ ํํ ์ด์
๊ฒฐ์ ์คํจ ์ ์ฆ์ ๋ณด์ ๋ก์ง์ ํธ์ถํ๋ ๋ฐฉ์(Fire-and-forget)์ผ๋ก ๋จผ์ ๊ตฌํํ์ต๋๋ค. paymentService.fail() ์ดํ ๋ฐ๋ก bookingService.cancel()์ ํธ์ถํ๋ฉด ๋ฉ๋๋ค. ์ง๊ด์ ์ด๊ณ ๋จ์ํฉ๋๋ค.
experiments/e4์์ N๋ฒ์งธ ํธ์ถ๋ง๋ค ์์ธ๋ฅผ ์ฃผ์
ํ๋ ๋ฐฉ์์ผ๋ก ์คํจ์จ์ ์๋ฎฌ๋ ์ด์
ํ์ต๋๋ค. ์ผ์ ๋น์จ์ ๊ฒฐ์ ์คํจ๋ฅผ ์ฃผ์
ํ์, ๋ณด์ ํธ์ถ ์์ฒด๋ ๊ฐ์ ๋น์จ๋ก ์คํจํ์ต๋๋ค. ๊ฒฐ์ ๋ ์คํจํ๋๋ฐ ์ข์ ๋ณต์์ ์ ๋ ์ผ์ด์ค๊ฐ ๋ฐ์ํ์ต๋๋ค.
๊ทผ๋ณธ ์์ธ์ "๊ฒฐ์ ์คํจ"์ "๋ณด์ ๋์ ๊ธฐ๋ก"์ด ๋ณ๊ฐ์ ์์ ์ด๋ผ๋ ์ ์ด์์ต๋๋ค. ๊ฒฐ์ ์คํจ๋ DB์ ๋จ๋๋ฐ, ๋ณด์ ๋์์ ๋ฉ๋ชจ๋ฆฌ์ ๋น๋๊ธฐ ํธ์ถ ํ๋ฆ ์์๋ง ์์ต๋๋ค. ์ฑ์ด ์ฌ์์๋๋ฉด ๋ณด์ ๋์ ์ ๋ณด๊ฐ ์ฌ๋ผ์ง๋๋ค.
Fire-and-forget: ๋จ์ํ์ง๋ง ๋ณด์ ๋์์ด ๋ฉ๋ชจ๋ฆฌ์๋ง ์กด์ฌํฉ๋๋ค. ์ฑ ์ฌ์์, ๋คํธ์ํฌ ์๋จ, ์์ธ ๋ฐ์ โ ๋ณด์ ํธ์ถ์ด ๋๊ธธ ์ ์๋ ์ง์ ์ด ๋๋ฌด ๋ง์ต๋๋ค.
@Async + ์ฌ์๋: Fire-and-forget๋ณด๋ค๋ ๋ซ์ง๋ง ์ฌ์์ ์ ๋๊ธฐ ์ค์ธ ์์ ์ด ์ฌ๋ผ์ง๋ ๊ฑด ๋์ผํฉ๋๋ค. ์์์ฑ์ด ์์ต๋๋ค.
Kafka / ๋ฉ์์ง ๋ธ๋ก์ปค: ๋ฐํ-๊ตฌ๋ ๊ตฌ์กฐ๋ก ๋ณด์ ์ฒ๋ฆฌ๋ฅผ ์์ ํ ๋ถ๋ฆฌํ ์ ์๊ณ , ์ฌ์ฒ๋ฆฌยท์์ ๋ณด์ฅยทํ์ฅ์ฑ ๋ฉด์์ ๊ฐ์ฅ ๊ฐ๋ ฅํ ์ ํ์ง์ ๋๋ค. ๊ทธ๋ฌ๋ Kafka ํด๋ฌ์คํฐ ์ด์, ํ ํฝ ๊ด๋ฆฌ, Consumer ์ค๊ณ๊ฐ ์ถ๊ฐ๋ฉ๋๋ค. ์ง๊ธ ํด๊ฒฐํ๋ ค๋ ๋ฌธ์ ๋ "๊ฒฐ์ ์คํจ ํ ๋ณด์์ด ์ ์ค๋์ง ์๊ฒ"์ ๋๋ค. ์ด ๋ฒ์์์๋ ์ธํ๋ผ ๋ณต์ก๋๊ฐ ๋๋ฌด ๋์ต๋๋ค.
Transactional Outbox: "๊ฒฐ์ ์คํจ"์ "๋ณด์ํด์ผ ํ๋ค๋ ์ฌ์ค"์ ๊ฐ์ ํธ๋์ญ์ ์ ์ ์ฅํฉ๋๋ค. ๊ฒฐ์ ์คํจ๊ฐ DB์ ๋จ์์์ผ๋ฉด ๋ณด์ ๋์ Outbox๋ ๋ฐ๋์ ๋จ์์์ต๋๋ค. ๋จ์ ์ DB ํด๋ง ์ค๋ฒํค๋(10์ด๋ง๋ค)๊ฐ ์๊ธฐ๊ณ , ๋ณด์์ด ์ฆ์๊ฐ ์๋ ์ต๋ 10์ด ์ง์ฐ๋๋ค๋ ์ ์ ๋๋ค. ๊ทธ๋ฌ๋ ์ด ์ง์ฐ์ด ํ์ฉ๋๋ ๋ฒ์์ด๊ณ , ๋์ค์ Kafka๋ก ์ ํํ๋๋ผ๋ Outbox ํ ์ด๋ธ ๊ตฌ์กฐ๋ฅผ ๊ทธ๋๋ก ์ ์งํ ์ ์์ต๋๋ค.
Fire-and-forget:
๊ฒฐ์ ์คํจ (DB์ ๊ธฐ๋ก) โ ๋ณด์ ํธ์ถ โ ์ฑ ์ข
๋ฃ โ ์ด๋ฒคํธ ์ ์ค
์ฌ์์ ํ: ๊ฒฐ์ ์คํจ ๊ธฐ๋ก์ ์์ง๋ง, ๋ณด์ํด์ผ ํ๋ค๋ ์ ๋ณด๊ฐ ์์
Outbox ํจํด:
[๊ฐ์ ํธ๋์ญ์
] Payment.fail() + INSERT Outbox(PENDING)
์ฑ ์ฌ์์ ํ์๋ Outbox๊ฐ ๋จ์ Scheduler๊ฐ ์ฌ์ฒ๋ฆฌ
๋์ผํ ์คํจ์จ์ ์ฃผ์
ํ์ ๋, Outbox ๋ฐฉ์์ ์ค์ผ์ค๋ฌ ์ฌ์ฒ๋ฆฌ๋ฅผ ํตํด ์ต์ข
100% ๋ณด์ ์ฑ๊ณต์ ํ์ธํ์ต๋๋ค. ๊ฐ Outbox๋ REQUIRES_NEW ํธ๋์ญ์
์ผ๋ก ๋
๋ฆฝ ์ฒ๋ฆฌ๋๋ฉฐ, ์ต๋ 3ํ ์ฌ์๋ ํ FAILED๋ก ์ ํ๋์ด Grafana ์๋ฆผ์ ๋ฐ์กํฉ๋๋ค.
๋์์ฑ ์ ์ด ๊ตฌ์กฐ๋ฅผ ๋จผ์ ์ ๋ฆฌํ๊ณ , ๊ทธ ๋ค์ ์๋ฒ๋ฅผ ๋๋ฆฌ๋ ์์๋ก ์งํํ์ต๋๋ค.
| ๋จ๊ณ | ํ๊ฒฝ | ํต์ฌ ๊ฒฐ๊ณผ |
|---|---|---|
| Phase 1 | ๋ก์ปฌ ๋จ์ผ ์๋ฒ | DB Lock โ Redis ๊ฐ์ . ์๋ต์๊ฐ 62% โ, deadlock ์ ๊ฑฐ |
| Phase 2 | AWS EC2 2๋ + Nginx | ์ํ ํ์ฅ์ผ๋ก ์๋ต์๊ฐ 9๋ฐฐ ๊ฐ์ (13.8s โ 1.5s) |
| Phase 3 | GCP e2-medium 2~3๋ + GCP LB | ๋๊ธฐ์ด ์ง์ 10,000๋ช 0% ์๋ฌ. ์๋งค ๋์์ฑ Redis ์บ์ + pool ํ๋์ผ๋ก 74.1 TPS. 1,000 req/s polling ์์ ํ. Adaptive Polling์ผ๋ก 2,000 โ 1,100 req/s |
Phase 1 โ ๋ก์ปฌ ๋จ์ผ ์๋ฒ: DB Lock โ Redis ๊ตฌ์กฐ ๊ฐ์ ๊ณผ์
์ฒ์ ๊ตฌ์กฐ์์๋ ์ด๊ณผ ์๋งค๋ฅผ ๋ง๋ ๋ฐ ์ง์คํ์ต๋๋ค. ์ค์ ๋ก ์ ํฉ์ฑ์ ์ ์ง์ผ์ก์ง๋ง, ์ข์ ์์ ๋์ ์์ฒญ ์๊ฐ ๋์๋ก ์ง๋ ฌํ ๋น์ฉ์ด ๋น ๋ฅด๊ฒ ์ปค์ก์ต๋๋ค.
| ์๋๋ฆฌ์ค | ํ๊ท ์๋ต ์๊ฐ | p95 | ์ฒ๋ฆฌ๋ | ๊ฒฐ๊ณผ ํด์ |
|---|---|---|---|---|
| 100์ / 100๋ช | 162ms | 257ms | 334.4/sec | ๋ฎ์ ์ง์ฐ ์๊ฐ์ผ๋ก ์์ ์ฒ๋ฆฌ |
| 100์ / 300๋ช | 1310ms | 1609ms | 133.9/sec | ์คํจ์จ 66.67%, ์ข์ ์ด๊ณผ ์์ฒญ์ ์ ์์ ์ผ๋ก ๋ง๊ฐ ์ฒ๋ฆฌ |
| 300์ / 300๋ช | 1146ms | 1941ms | 143.8/sec | ์ ๋ถ ์ฑ๊ณต, ๋ค๋ง ์๋ต ์ง์ฐ์ด ์ปค์ง |
| 500์ / 500๋ช | 3778ms | 6822ms | 69.8/sec | ์ ๋ถ ์ฑ๊ณต, ์ ํฉ์ฑ์ ์ ์ง๋์ง๋ง ์ง๋ ฌ ์ฒ๋ฆฌ ํ๊ณ ํ์ธ |
DB Lock ๊ตฌ์กฐ์์ ์๋ต ์๊ฐ์ด ๊ธธ์ด์ง๋ ๊ฑธ ๋ณด๊ณ , ์ฒ์ ๋ ์๊ฐ์ "์ปค๋ฅ์ ํ์ด ๋ถ์กฑํด์ ์์ฒญ์ด ๋๊ธฐํ๋ ๊ฑด ์๋๊น?"์์ต๋๋ค. Hikari pool ์ฌ์ด์ฆ๋ฅผ 10์์ 50์ผ๋ก ๋๋ฆฌ๋ฉด ๋ ๋ง์ ์์ฒญ์ด ๋์์ ์ฒ๋ฆฌ๋ ๊ฒ์ด๋ผ๊ณ ์์ํ์ต๋๋ค.
๊ฒฐ๊ณผ๋ ์์๊ณผ ๋ฐ๋์์ต๋๋ค.
| ์งํ | pool=10 | pool=50 |
|---|---|---|
| ํ๊ท ์๋ต ์๊ฐ | 3778ms | 5656ms |
| p95 | 6822ms | 9650ms |
| TPS | 69.8/sec | 49.8/sec |
| ์๋ฌ์จ | 0% | 0% |
์ปค๋ฅ์ ํ์ 5๋ฐฐ ๋๋ ธ๋๋ ์๋ต ์๊ฐ์ด ์ฝ 50% ๋ ๋์ด๋ฌ์ต๋๋ค.
์ ๋ ๋๋น ์ก๋์ง InnoDB lock ํต๊ณ๋ก ํ์ธํ์ต๋๋ค.
ํ ์คํธ ์ ํ๋ก MySQL InnoDB row lock ๋์ ์งํ๋ฅผ ์์งํ์ต๋๋ค.
| ์งํ | pool=10 ์ดํ | pool=50 ์ดํ | ์ฆ๊ฐ๋ |
|---|---|---|---|
Innodb_row_lock_waits |
5,586 | 6,085 | +499 |
Innodb_row_lock_time (ms) |
201,222 | 594,108 | +392,886ms |
| ํ๊ท ๋ฝ ๋๊ธฐ ์๊ฐ | โ | โ | 392,886 / 499 โ 787ms |
pool=50 ์กฐ๊ฑด์์ row lock ํ ๋ฒ ๋๊ธฐ์ ํ๊ท 787ms๊ฐ ์์๋์ต๋๋ค.
์์ธ ํด์: ์ปค๋ฅ์
ํ์ ๋๋ฆฌ๋ฉด ๋ ๋ง์ ํธ๋์ญ์
์ด ๋์์ DB์ ์ง์
ํ ์ ์์ต๋๋ค. ๊ทธ๋ฐ๋ฐ ์ด ๊ตฌ์กฐ์์๋ ๋ชจ๋ ์ฑ๊ณต ์์ฒญ์ด ๊ฐ์ concert ํ์ Pessimistic Lock์ ์ก์ผ๋ ค ํฉ๋๋ค. ๋์์ ์ง์
ํ๋ ํธ๋์ญ์
์ด ๋ง์์ง์๋ก ๊ฐ์ row๋ฅผ ๋๊ณ ๊ฒฝํฉํ๋ ์๊ฐ ๋์ด๋๊ณ , lock ๋๊ธฐ ์ค์ด ๊ธธ์ด์ง๋๋ค. ์ปค๋ฅ์
ํ ๋ถ์กฑ์ด ๋ณ๋ชฉ์ด ์๋์์ต๋๋ค. ๋ ๋ง์ ์์ฒญ์ ๋์์ DB๋ก ๋ฐ์ด ๋ฃ์์๋ก lock ๊ฒฝํฉ์ด ์ฌํด์ง๋ ๊ตฌ์กฐ๊ฐ ๋ฌธ์ ์์ต๋๋ค.
์ด ์คํ์ ํตํด "์๋ฒ ์์ ์ฆ์ค๋ณด๋ค ๊ตฌ์กฐ ๋ณ๊ฒฝ์ด ๋จผ์ "๋ผ๋ ํ๋จ์ ๊ทผ๊ฑฐ๋ฅผ ์ง์ ํ์ธํ ์ ์์์ต๋๋ค.
์ข์ ์ ์ด๋ฅผ Redis decrementSeat๋ก ์ฎ๊ธฐ๋ฉด ๋ณ๋ ฌ์ฑ์ด ์ข์์ง ๊ฑฐ๋ผ๊ณ ํ๋จํ์ต๋๋ค. ์ค์ ๋ก DB row lock ๋ณ๋ชฉ์ ์ค์์ง๋ง, ์์ฝ ์ฑ๊ณต ์งํ concert.booked_count๋ฅผ ์ฆ์ UPDATEํ๋ ๋ก์ง์ด ์๋ก์ด ๋ฌธ์ ๋ฅผ ๋ง๋ค์์ต๋๋ค.
MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
booking INSERT ์ดํ ๊ฐ์ ํธ๋์ญ์
์์์ concert.booked_count๋ฅผ ๊ฐฑ์ ํ๋ฉด์ ๋์ผ ๊ณต์ฐ ํ์ด hot row๊ฐ ๋๊ณ , ์ผ๋ถ ์์ฒญ์ด 500์ผ๋ก ์คํจํ์ต๋๋ค.
- ์ข์ ์ ํฉ์ฑ: Redis
decrementSeat/restoreSeat - DB ์ญํ :
booking INSERT, ์ํ ์ ์ฅ, ๋ณด์ ์ด๋ ฅ ์ ์ฅ bookedCount:totalSeats - remainingSeat๋ก ์กฐํ ์ ๊ณ์ฐ
์ดํ ๊ฐ์ 500์ / 500๋ช ์๋๋ฆฌ์ค๋ฅผ ๋ค์ ์ธก์ ํ์ต๋๋ค.
| ์๋๋ฆฌ์ค | ํ๊ท ์๋ต ์๊ฐ | p95 | ์ฒ๋ฆฌ๋ | ์๋ฌ์จ |
|---|---|---|---|---|
| 500์ / 500๋ช | 1445ms | 2518ms | 186.6/sec | 0% |
| ์งํ | ๊ฐ์ ์ | ๊ฐ์ ํ | ๊ฐ์ ์จ |
|---|---|---|---|
| Avg ์๋ต ์๊ฐ | 3778ms | 1445ms | ์ฝ 62% ๊ฐ์ |
| p95 | 6822ms | 2518ms | ์ฝ 63% ๊ฐ์ |
| TPS | 69.8/sec | 186.6/sec | ์ฝ 167% ์ฆ๊ฐ |
| Error | ์ผ๋ถ 500 + deadlock | 0% | ์์ ์ฑ ํ๋ณด |
๊ฐ์ ์ ์๋ ์ข์ ์ ํฉ์ฑ์ ์ ์ง๋์ง๋ง, ์์ฒญ์ด ๋ชฐ๋ฆด์๋ก DB row lock ๋๊ธฐ๋ก ์๋ต ์๊ฐ์ด ๊ธ๊ฒฉํ ์ฆ๊ฐํ์ต๋๋ค.
bookedCount๋ฅผ ํ์๊ฐ์ผ๋ก ์ ํํ ๋ค์๋ deadlock ์์ด ๋์ผ ์๋๋ฆฌ์ค๋ฅผ ์ฒ๋ฆฌํ๊ณ , ํ๊ท ์๋ต ์๊ฐ๊ณผ ์ฒ๋ฆฌ๋์ด ํจ๊ป ๊ฐ์ ๋์ต๋๋ค.
์์ฝ ๊ฒฐ๊ณผ ๋ถํฌ์ ๋ณด์ ์ฒ๋ฆฌ ์ํ๋ฅผ ํตํด, ๋จ์ํ ๋นจ๋ผ์ก๋์ง๋ง ๋ณธ ๊ฒ์ด ์๋๋ผ ์คํจ ์ ํ๊ณผ ์ ํฉ์ฑ๊น์ง ํจ๊ป ํ์ธํ์ต๋๋ค.
| ๋ถํฌ ํญ๋ชฉ | ์๋ฏธ |
|---|---|
BOOKED |
์๋งค ์ฑ๊ณต |
ALREADY_BOOKED |
์ค๋ณต ์๋งค ์๋ โ ์ฐจ๋จ๋จ |
SOLD_OUT |
์์ฌ์ ์์ |
MOCK/COMPLETED |
๊ฒฐ์ ์ฑ๊ณต |
MOCK/CACHED |
๋ฉฑ๋ฑ์ฑ ํค๋ก ์ฌ๋ฐํ (์ค๋ณต ๊ฒฐ์ ์ฐจ๋จ) |
Phase 2 โ EC2 ์ค์๋ฒ: ์ํ ํ์ฅ ํจ๊ณผ ๊ฒ์ฆ
๊ตฌ์กฐ ๊ฐ์ ์ดํ ์ค์ EC2 ํ๊ฒฝ์์ ๋จ์ผ ์๋ฒ์ 2์๋ฒ ๊ตฌ์ฑ์ ๋์ผ ์กฐ๊ฑด(300๋ช ๋์ ์์ฒญ)์ผ๋ก ๋น๊ตํ์ต๋๋ค.
ํ ์คํธ ํ๊ฒฝ
- EC2 #1: Nginx(๋ก๋ ๋ฐธ๋ฐ์) + app + MySQL + Redis (t3.micro)
- EC2 #2: app only, EC2 #1์ MySQLยทRedis ๊ณต์ (t3.micro)
- JMeter: SyncTimer 300๋ช ๋์ ์ถ๋ฐ, 300์ ์ฝ์ํธ
| ๊ตฌ์ฑ | ํ๊ท ์๋ต ์๊ฐ | p95 | TPS | ์๋ฌ์จ |
|---|---|---|---|---|
| EC2 1์๋ฒ | 13,822ms | 14,903ms | 20/sec | 0% |
| EC2 2์๋ฒ (Nginx LB) | 1,525ms | 2,341ms | 110.7/sec | 0% |
| ๊ฐ์ ์จ | 9๋ฐฐ ๋น ๋ฆ | 6.4๋ฐฐ ๋น ๋ฆ | 5.5๋ฐฐ ํฅ์ | ์ ์ง |
t3.micro ๋จ์ผ ์๋ฒ๋ 300๋ช ๋์ ์์ฒญ์์ CPU ํฌ๋ ๋ง ์์ง๊ณผ JVM GC ์๋ฐ์ด ๊ฒน์ณ 13์ด๋ ์๋ต์ด ๋ฐ์ํ์ต๋๋ค. ์๋ฒ๋ฅผ 1๋ ์ถ๊ฐํด Nginx๊ฐ ์์ฒญ์ ์ ๋ฐ์ฉ ๋ถ์ฐํ์ ๊ฐ ์๋ฒ์ ๋ถํ๊ฐ ์ค์ด ์๋ต ์๊ฐ์ด 9๋ฐฐ ๊ฐ์ ๋์ต๋๋ค.
์๋ฌ์จ์ ๋ ๊ตฌ์ฑ ๋ชจ๋ 0%์ ๋๋ค. Redis ์์์ ์ข์ ์ฐจ๊ฐ์ด 2์๋ฒ ํ๊ฒฝ์์๋ ์ค๋ณต ์๋งค๋ฅผ ์์ ํ ์ฐจ๋จํ์ต๋๋ค.
๋จ, MySQL์ ์ฌ์ ํ EC2 #1 ๋จ์ผ ์ธ์คํด์ค๋ฅผ ๊ณต์ ํฉ๋๋ค. ์์ฒญ์ด ๋ ๋์ด๋๋ฉด DB๊ฐ ๋ค์ ๋ณ๋ชฉ์ด ๋ ๊ฒ์ผ๋ก ์์๋ฉ๋๋ค.
Phase 3 โ GCP ํ๊ฒฝ: ์ค์ ์กฐ์ , ์ค์ผ์ผ์์, polling ๋ธ๋ ์ดํฌํฌ์ธํธ ํ์
AWS EC2 2๋์์๋ VM ์คํ ํ๊ณ๋ก ๋ ํฐ ์คํ์ด ์ด๋ ค์ ์ต๋๋ค. GCP e2-medium์ผ๋ก ์ด์ ํด ์๋ฒ ์์ polling ์ ์ฑ ์ ๋จ๊ณ์ ์ผ๋ก ๋ฐ๊พธ๋ฉฐ ์ธก์ ํ์ต๋๋ค.
k6๋ก 10,000๋ช ์ ramp-up ๋ฐฉ์์ผ๋ก ๋๊ธฐ์ด์ ์ฑ์ ์ต๋๋ค.
| ์๋๋ฆฌ์ค | ๋ชฉํ ์ ์ | ์ค์ TPS | ์ด ์์ฒญ ์ | ํ๊ท ์๋ต์๊ฐ | p95 | ์๋ฌ์จ |
|---|---|---|---|---|---|---|
| 1,000๋ช / 30์ด | ์ฝ 33 TPS |
33.02 |
991 |
8.29ms |
11.21ms |
0.00% |
| 3,000๋ช / 30์ด | ์ฝ 100 TPS |
99.99 |
3000 |
6.93ms |
9.43ms |
0.00% |
| 6,000๋ช / 60์ด | ์ฝ 100 TPS |
100.00 |
6001 |
6.17ms |
7.84ms |
0.00% |
| 10,000๋ช / 300์ด | ์ฝ 33 TPS |
33.00 |
9900 |
6.24ms |
7.39ms |
0.00% |
ramping ๋ฐฉ์ ํน์ฑ์ ์ด ์์ฒญ ์๋ ์๋๋ฆฌ์ค์ ๋ชฉํ ์ธ์๊ณผ ์ ํํ ์ผ์นํ์ง ์๊ณ ,
9,900 ~ 10,000๋ฒ์๋ก ๊ด์ธก๋ ์ ์์ต๋๋ค.
๋๊ธฐ์ด ์ง์ ์์ฒด๋ Redis Sorted Set ๊ธฐ๋ฐ enqueue ๊ตฌ์กฐ์์ ์ถฉ๋ถํ ์ฒ๋ฆฌ๋์ต๋๋ค. ๋ณ๋ชฉ์ ์ง์ ์ด ์๋ ์ง์ ํ ์ง์๋๋ polling ์ชฝ์์ ํ์ธํ์ต๋๋ค.
GCP๋ก ์ด์ ํ ๋ค์๋ ์๋ฒ ์๋ฅผ ๋ฐ๋ก ๋๋ฆฌ๊ธฐ๋ณด๋ค, polling ๋ถํ์์ ์ด๋ค ์ค์ ์ด ์ค์ ๋ก ์ํฅ์ ์ฃผ๋์ง ๋จผ์ ํ์ธํ์ต๋๋ค.
threads.max=100max-connections=2000accept-count=500- app ์ปจํ
์ด๋
mem_limit=1g JAVA_OPTS=-Xms256m -Xmx800m- Prometheus / Grafana๋ฅผ ๋ถ์ฌ ์ธ์คํด์ค๋ณ TPS, p95๋ฅผ ๊ด์ฐฐ ๊ฐ๋ฅํ ์ํ๋ก ์ ๋ฆฌ
์ด ๋จ๊ณ์์ ํ์ธํ ๊ฑด "VM์ ํค์ฐ๋ ๊ฒ"๋ณด๋ค, Tomcat ์ฐ๊ฒฐ ์ยท๋๊ธฐ์ดยทJVM ๋ฉ๋ชจ๋ฆฌยท๊ด์ธก ๊ฐ๋ฅ์ฑ์ ๋จผ์ ๋ง์ถฐ์ผ ๊ฒฐ๊ณผ๋ฅผ ํด์ํ ์ ์๋ค๋ ์ ์ด์์ต๋๋ค.
| ๊ตฌ์ฑ | ๋ถํ | ์คํจ์จ | p95 | ๊ฒฐ๊ณผ |
|---|---|---|---|---|
| app 2๋ | 500 req/s |
0% | 209.87ms | ์ฑ๊ณต |
| app 2๋ | 1000 req/s |
41.77% | 4.5s | ์คํจ |
| app 3๋ | 500 req/s |
0% | 19.2ms | ์ฑ๊ณต |
| app 3๋ | 1000 req/s |
0% | 91.86ms | ์ฑ๊ณต |
| app 3๋ | 1500 req/s |
5.61% ~ 21.91% | 1.06s ~ 1.2s | ์คํจ |
3๋๋ก ๋๋ฆฐ ๋ค 1,000 req/s๋ ์์ ํ๋์ง๋ง 1,500 req/s๋ ์ฌ์ ํ ์คํจ. ํ์ฌ ๋ธ๋ ์ดํฌํฌ์ธํธ๋ 1,000 ~ 1,500 req/s ์ฌ์ด์ ๋๋ค.
๊ณ ์ 5์ด polling ๊ธฐ์ค์ผ๋ก 10,000๋ช
์ด ๋ง๋ค์ด๋ด๋ 2,000 req/s๋ฅผ ์ค์ด๊ธฐ ์ํด ์๋ฒ๊ฐ nextPollMs๋ฅผ ๋ฐํํ๋๋ก ๋ณ๊ฒฝํ์ต๋๋ค.
position <= 1,000โ5,000ms(์์๋ฒ: ๋น ๋ฅธ ์กฐํ)position > 1,000โ10,000ms(๋ท์๋ฒ: ๋๋ฆฐ ์กฐํ)- ๊ธฐ๋ ๋ถํ: 1,000 ร 1/5s + 9,000 ร 1/10s = 1,100 req/s (45% ๊ฐ์)
์ ์ฑ ์์ฒด๋ ์๋๋๋ก ๋์ํ์ง๋ง, 10,000 VU closed-model๋ก ๊ทธ๋๋ก ๊ฒ์ฆํ๋ ค ํ์ ๋ถํ๋ฐ์๊ธฐ์ thundering herd ๋ฌธ์ ๊ฐ ๋จผ์ ๋๋ฌ๋ฌ์ต๋๋ค. "์๋ฒ ํ๊ณ ํ ์คํธ(constant-arrival-rate)"์ "์ด์ ์ ์ฑ ํจ๊ณผ ๊ฒ์ฆ(VU ๊ธฐ๋ฐ)"์ ๋ถ๋ฆฌํด์ ๋ด์ผ ํ๋ค๋ ์ ์ ์ ๋ฆฌํ์ต๋๋ค.
polling ๋ธ๋ ์ดํฌํฌ์ธํธ๋ฅผ ํ์ธํ ๋ค์๋, ๋ณ๊ฐ ์ถ์ผ๋ก booking API ์์ฒด๊ฐ ๋์์ ๋ชฐ๋ ค๋ ์ ํฉ์ฑ๊ณผ ์ฒ๋ฆฌ๋์ ์ ์งํ๋์ง๋ฅผ ๋ค์ ๊ฒ์ฆํ์ต๋๋ค.
๋๊ธฐ์ด ํ ์คํธ์ ๋ณ๊ฐ๋ก, GCP ์ด์ ํ ์๋งค ์์ฒด์ ์ฒ๋ฆฌ๋์ ํ์ธํ์ต๋๋ค. JMeter SyncTimer๋ก 300๋ช ์ด ๋์์ ์ถ๋ฐํ๋ ์กฐ๊ฑด์์ 300์ ์ฝ์ํธ๋ฅผ ์๋งคํ๋ ์๋๋ฆฌ์ค์ ๋๋ค.
ํ ์คํธ ํ๊ฒฝ: GCP e2-medium 2๋, JMeter SyncTimer 300๋ช ๋์ ์ถ๋ฐ, 300์ ์ฝ์ํธ
์ ์ฉํ ์ต์ ํ ๋ชฉ๋ก
| ๋ณ๊ฒฝ ํญ๋ชฉ | ๋ณ๊ฒฝ ๋ด์ฉ | ์ด์ |
|---|---|---|
| HikariCP pool-size | 50 โ 20 | pool=50์์ hot-row contention ์ฌํ ํ์ธ (Phase 1 ์คํ ๋์ผ ์์ธ) |
| Redis ์ํ ์บ์ | ์์ โ concert:status:{id} TTL 30s |
์๋งค๋ง๋ค SELECT Concert DB ์กฐํ ์ ๊ฑฐ |
getReferenceById() |
findById() โ ํ๋ก์ ์ฐธ์กฐ |
์บ์ ํํธ ์ DB ์กฐํ ์์ด FK ์ฐ๊ด๋ง ์ค์ |
| ์บ์ ๋ฌดํจํ | ์์ โ ์ฝ์ํธ ์ญ์ ยท์์ ์ ์๋ ์ ๊ฑฐ | ์บ์ TTL ๋ง๋ฃ ์ ์ ํฉ์ฑ ๋ณด์ฅ |
์ธก์ ๊ฒฐ๊ณผ ๋น๊ต
| ๊ตฌ์ฑ | TPS | ํ๊ท ์๋ต ์๊ฐ | ์ค์๊ฐ | p90 | p95 | ์๋ฌ์จ |
|---|---|---|---|---|---|---|
| ๋ฐฐ์น ํ๋๋ง ์ ์ฉ | 47.7/sec | 2,775ms | 2,088ms | 5,761ms | 6,049ms | ์์ |
๋๊ด์ ๋ฝ (pool=10) |
59.8/sec | 2,786ms | 2,632ms | 4,429ms | 4,723ms | 0% |
HikariCP pool=50 |
61.3/sec | 3,576ms | 3,468ms | 4,633ms | 4,807ms | 0% |
ํ์ฌ ์ฝ๋ ๋ณ๊ฒฝ ํ (pool=20 + Redis ์ํ ์บ์) |
74.1/sec | 2,993ms | 2,974ms | 3,808ms | 3,945ms | 0% |
Redis ์ข์ ์ฐจ๊ฐ์ ์ด๋ฏธ ์์์ ์ด๋ผ ๋์์ฑ ์ค๋ฅ ์์ด 0% ์๋ฌ๋ฅผ ์ ์งํ์ต๋๋ค. pool ๊ณผ๋ค ์ค์ ์ด hot-row contention์ ํค์ด๋ค๋ Phase 1์ ๊ฒฐ๋ก ์ด GCP ํ๊ฒฝ์์๋ ๋์ผํ๊ฒ ์ฌํ์ธ๋์ต๋๋ค.
์ ์ฅ๊ถ ์๋น ์ค TTL ๋ง๋ฃ ํ์ด๋ฐ ์ถฉ๋ โ EXISTS์ DEL ์ฌ์ด์ ํ
์ด๊ธฐ ๊ตฌํ์์ ์
์ฅ๊ถ ์๋น๋ฅผ EXISTS ํ์ธ ํ DEL๋ก ์ฒ๋ฆฌํ์ต๋๋ค. ์ด ๋ฐฉ์์์ ์
์ฅ๊ถ์ด ์๋ ์ฌ์ฉ์๊ฐ ์๋งค๋ฅผ ํต๊ณผํ๋ ์ผ์ด์ค๊ฐ ๋ฐ์ํ์ต๋๋ค.
EXISTS์ DEL์ ๋ณ๊ฐ์ Redis ๋ช
๋ น์
๋๋ค. Java์์ ์์ฐจ ํธ์ถํ๋ฉด ๋ ๋ช
๋ น ์ฌ์ด์ ์๊ฐ ๊ฐ๊ฒฉ์ด ์์ต๋๋ค.
EXISTS โ true
... ์ด ์ฌ์ด์ TTL ๋ง๋ฃ ...
DEL โ "์ฑ๊ณต" (์๋ ํค๋ฅผ DELํด๋ Redis๋ ์๋ฌ๋ฅผ ๋ฐํํ์ง ์์)
โ ์๋งค ์งํ โ ์
์ฅ๊ถ์ด ์๋ ์ํ์์ ํต๊ณผ
DEL์ ๋ฐํ๊ฐ์ด 0(์ญ์ ํ ํค ์์)์ด์ด๋ ์ฝ๋์์ ์ด๋ฅผ ํ์ธํ์ง ์์์ต๋๋ค.
๋ฐฉ๋ฒ 1: DEL ๋ฐํ๊ฐ ํ์ธ DEL์ด 0์ ๋ฐํํ๋ฉด ์ด๋ฏธ ๋ง๋ฃ๋ ๊ฒ์ผ๋ก ์ฒ๋ฆฌํฉ๋๋ค. EXISTS + DEL ๋ ๋ช ๋ น์ด ๋ฐ๋ก ์คํ๋๋ ๊ฑด ๋์ผํด์ EXISTS์ DEL ์ฌ์ด์ ๋ง๋ฃ๊ฐ ๋ฐ์ํ๋ฉด EXISTS๋ true๋ฅผ ๋ฐํํ์ง๋ง DEL ๊ฒฐ๊ณผ๋ 0์ด ๋ฉ๋๋ค. ์ด ๊ฒฝ์ฐ๋ ์ก์ ์ ์์ต๋๋ค. ํ์ง๋ง DEL ์ดํ ๋ง๋ฃ๊ฐ ๋ฐ์ํ๋ ๊ฒฝ์ฐ๋ ์ก์ง ๋ชปํฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ๊ทผ๋ณธ์ ์ผ๋ก ๋ ๋ช ๋ น ์ฌ์ด์ ํ์ด๋ฐ ์์กด์ฑ์ด ๋จ์ต๋๋ค.
๋ฐฉ๋ฒ 2: SET NX EX ๋ฎคํ ์ค ๋ฐฉ์ ์์ ์ ์ฅ๊ถ ์๋น๋ฅผ ๋ณ๋ ํค ์ ์ ๋ฐฉ์์ผ๋ก ๊ต์ฒดํ ์ ์์ต๋๋ค. ๊ตฌ์กฐ๊ฐ ๋ฌ๋ผ์ ธ ๊ธฐ์กด TTL์ ๋ณต์ํ๋ ๋ก์ง์ ๋ง๋ค๊ธฐ ์ด๋ ต๊ณ , ์ค๊ณ ๋ณต์ก๋๊ฐ ์ฌ๋ผ๊ฐ๋๋ค.
๋ฐฉ๋ฒ 3: Lua Script EXISTS โ TTL โ DEL์ Redis ์๋ฒ์์ ์์์ ์ผ๋ก ์คํํฉ๋๋ค. ์ธ ๋ช ๋ น์ด ๋๊ธฐ์ง ์๊ณ ๋จ์ ์คํ๋๊ธฐ ๋๋ฌธ์ TOCTOU๊ฐ ๋ฐ์ํ์ง ์์ต๋๋ค. ์ถ๊ฐ๋ก TTL ๋ฐํ๊ฐ์ ํ์ฉํด DB ์ฒ๋ฆฌ ์คํจ ์ ์ ์ฅ๊ถ์ ์๋ ์์ฌ TTL๋ก ๋ณต์ํ๋ ๊ธฐ๋ฅ๋ ์ป์์ต๋๋ค.
Lua Script์ ๋จ์ (๋๋ฒ๊น ๋์ด๋)์ ์์ง๋ง, ์ธ ์ค์ง๋ฆฌ ์คํฌ๋ฆฝํธ์ด๊ณ ๋์์ด ๋ช ํํฉ๋๋ค. ๋ฐฉ๋ฒ 1์ ํ์ด๋ฐ ์์กด์ฑ์ ์์ ํ ์ ๊ฑฐํ์ง ๋ชปํ๊ณ , ๋ฐฉ๋ฒ 2๋ ๊ธฐ์กด ๊ตฌ์กฐ๋ฅผ ๋๋ฌด ํฌ๊ฒ ๋ฐ๊ฟ๋๋ค.
-- CLAIM_ADMITTED: EXISTS โ TTL โ DEL ์์์ ์ฒ๋ฆฌ, ์์ผ๋ฉด -1 ๋ฐํ
if redis.call('EXISTS', key) == 0 then return -1 end
local ttl = redis.call('TTL', key)
redis.call('DEL', key)
return ttl์ ์ฅ๊ถ์ด ์๊ฑฐ๋ ๋ง๋ฃ๋ ์ํ์์ ์๋งค ์๋๊ฐ ์ ํํ ์ฐจ๋จ๋ฉ๋๋ค. DB ์ฒ๋ฆฌ ์คํจ ์ ๋ฐํ๋ฐ์ TTL๋ก ์ ์ฅ๊ถ์ ๋ณต์ํด ์ฌ์๋๋ฅผ ํ์ฉํ ์ ์๊ฒ ๋์ต๋๋ค.
Outbox ์ํ๊ฐ ์ฒ๋ฆฌ ํ์๋ PENDING์ผ๋ก ๋จ์๋ค โ detached ์ํฐํฐ ํจ์
๋ณด์ ์ฒ๋ฆฌ ๋ก์ง์ด ์คํ๋๋๋ฐ๋ PaymentCompensationOutbox ์ํ๊ฐ PUBLISHED๋ FAILED๋ก ๋ฐ๋์ง ์์์ต๋๋ค. ๊ฐ์ Outbox๊ฐ ์ค์ผ์ค๋ฌ์ ์ํด ๋ฐ๋ณต ์กฐํ๋์ด ๋ณด์ ์ฒ๋ฆฌ๊ฐ ์ค๋ณต ์๋๋์ต๋๋ค.
๋ก๊ทธ์๋ outbox.markPublished() ํธ์ถ์ด ๋ถ๋ช
ํ ์ฐํ์ต๋๋ค. ์์ธ๋ ์์์ต๋๋ค. ๊ทธ๋ฐ๋ฐ DB๋ฅผ ์ง์ ์กฐํํด๋ณด๋ฉด ์ํ๊ฐ ๊ทธ๋๋ก์
๋๋ค.
JPA dirty checking์ด ๋์ํ๋ ค๋ฉด ์ํฐํฐ๊ฐ ํ์ฌ ์์์ฑ ์ปจํ
์คํธ์์ ๊ด๋ฆฌ๋๋ managed ์ํ์ฌ์ผ ํฉ๋๋ค. ์ค์ผ์ค๋ฌ ๋ฉ์๋์๋ @Transactional์ด ์์์ต๋๋ค. findByStatus()๋ฅผ ํธ์ถํ๋ฉด Spring Data JPA๊ฐ ๋ด๋ถ์ ์ผ๋ก ์งง์ ํธ๋์ญ์
์ ์ด๊ณ ๋ฐ๋ก ๋ซ์ต๋๋ค. ์ด ์๊ฐ ๋ฐํ๋ ์ํฐํฐ๋ detached ์ํ๊ฐ ๋ฉ๋๋ค.
์ด detached ์ํฐํฐ๋ฅผ REQUIRES_NEW ํธ๋์ญ์
์ ๊ฐ์ง Processor๋ก ๊ทธ๋๋ก ๋๊ธฐ๋ฉด, ์ ์์์ฑ ์ปจํ
์คํธ๋ ์ด ์ํฐํฐ๋ฅผ ๊ด๋ฆฌํ์ง ์์ต๋๋ค. ์๋ฌด๋ฆฌ markPublished()๋ฅผ ํธ์ถํด๋ ์๋ฐ ๊ฐ์ฒด์ ํ๋๊ฐ๋ง ๋ฐ๋ ๋ฟ, DB UPDATE๋ ๋ฐ์ํ์ง ์์ต๋๋ค.
๋ฐฉ๋ฒ 1: entityManager.merge(outbox) detached ์ํฐํฐ๋ฅผ ์์์ฑ ์ปจํ ์คํธ์ ์ฌ๋ฑ๋กํฉ๋๋ค. ๋์ํ์ง๋ง, merge๋ ์ ๋ฌ๋ ์ํฐํฐ์ ํ์ฌ ์ํ๋ฅผ DB์ ๊ทธ๋๋ก ๋ฐ์ํฉ๋๋ค. ์ํฐํฐ๊ฐ ์กฐํ๋ ์์ ์ดํ๋ก ๋ค๋ฅธ ํธ๋์ญ์ ์์ ์ํ๊ฐ ๋ฐ๋ ๊ฒ ์๋ค๋ฉด stale ๋ฐ์ดํฐ๋ก ๋ฎ์ด์ธ ์ํ์ด ์์ต๋๋ค.
๋ฐฉ๋ฒ 2: ์ค์ผ์ค๋ฌ์ @Transactional ์ถ๊ฐ ์ ์ฒด Outbox ์ฒ๋ฆฌ๋ฅผ ํ๋์ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ์ผ๋ฉด ์ํฐํฐ๊ฐ managed ์ํ๋ฅผ ์ ์งํฉ๋๋ค. ๊ทธ๋ฌ๋ Outbox A ์ฒ๋ฆฌ ์ค ์์ธ๊ฐ ๋ฐ์ํ๋ฉด Outbox B๊น์ง ํจ๊ป ๋กค๋ฐฑ๋ฉ๋๋ค. Outbox๋ณ ๋ ๋ฆฝ ์ฒ๋ฆฌ๋ผ๋ ํจํด์ ์๋ฏธ๊ฐ ์ฌ๋ผ์ง๋๋ค.
๋ฐฉ๋ฒ 3: ID๋ง ์ ๋ฌํ๊ณ Processor ์์์ ์ฌ์กฐํ
REQUIRES_NEW ํธ๋์ญ์
๋ด๋ถ์์ ์ํฐํฐ๋ฅผ ์๋ก ์กฐํํ๋ฉด ํญ์ managed ์ํ๊ฐ ๋ณด์ฅ๋ฉ๋๋ค. ํญ์ ์ต์ DB ์ํ๋ฅผ ์ฝ์ด์ค๊ธฐ ๋๋ฌธ์ stale ๋ฐ์ดํฐ ๋ฎ์ด์ฐ๊ธฐ ์ํ๋ ์์ต๋๋ค.
// Before: detached ์ํฐํฐ๋ฅผ ๊ทธ๋๋ก ์ ๋ฌ
outboxProcessor.process(outbox);
// After: ID๋ง ์ ๋ฌ โ REQUIRES_NEW ํธ๋์ญ์
๋ด๋ถ์์ ์ฌ์กฐํ
outboxProcessor.process(outbox.getId());Outbox ์ํ๊ฐ ์ ์์ ์ผ๋ก PUBLISHED/FAILED๋ก ๋ฐ์๋์ต๋๋ค. @Transactional์ ๋ถ์ด๋ ๊ฒ๋งํผ์ด๋, ์ด๋ค ํธ๋์ญ์
์์ ๋ก๋๋ ์ํฐํฐ์ธ์ง๋ฅผ ์ ๊ฒฝ ์จ์ผ ํ๋ค๋ ๊ฑธ ๋ฐฐ์ ์ต๋๋ค.
ํน์ Outbox ์คํจ๊ฐ ๋ค๋ฅธ Outbox ์ฒ๋ฆฌ์ ์ํฅ์ ์คฌ๋ค โ Spring AOP self-invocation
PENDING ์ํ์ Outbox๊ฐ ์ฌ๋ฌ ๊ฑด์ผ ๋, ํน์ ๊ฑด์์ ์์ธ๊ฐ ๋ฐ์ํ๋ฉด ์์ ์ฒ๋ฆฌ๋ Booking/Concert ์ํ ๋ณ๊ฒฝ์ด ์๋์น ์๊ฒ ์ปค๋ฐ๋๊ฑฐ๋ ๋กค๋ฐฑ๋์ต๋๋ค. Outbox A์ ์คํจ๊ฐ Outbox B ์ฒ๋ฆฌ ๊ฒฐ๊ณผ์ ์ํฅ์ ์คฌ์ต๋๋ค.
์ด๊ธฐ ์ค์ผ์ค๋ฌ๋ ์ฌ๋ฌ Outbox๋ฅผ ํ๋์ ํธ๋์ญ์ ์์ ์์ฐจ ์ฒ๋ฆฌํ์ต๋๋ค. ์ค๊ฐ์ ์์ธ๊ฐ ๋๋ฉด ์ ์ฒด๊ฐ ๋กค๋ฐฑ๋ฉ๋๋ค. Outbox๋ณ ๋ ๋ฆฝ์ฑ์ด ์์์ต๋๋ค.
๊ฐ์ ํด๋์ค ์์ @Transactional(REQUIRES_NEW) ๋ฉ์๋๋ฅผ ๋ง๋ค์ด ๊ฐ Outbox๋ฅผ ์ฒ๋ฆฌํ๋ ๋ฐฉ๋ฒ์ ๋จผ์ ์๋ํ์ต๋๋ค. ๊ทธ๋ฐ๋ฐ Spring AOP๋ ๊ฐ์ ํด๋์ค ๋ด๋ถ์ ๋ฉ์๋ ํธ์ถ(self-invocation)์ ํ๋ก์๋ก ๊ฐ์ธ์ง ์์ต๋๋ค. REQUIRES_NEW๋ฅผ ๋ถ์ฌ๋ ์ค์ ๋ก๋ ์ ์ฉ๋์ง ์์ต๋๋ค.
๋ฐฉ๋ฒ 1: ๊ฐ์ ํด๋์ค ๋ด @Transactional(REQUIRES_NEW) ์์ ์ค๋ช ํ ๋๋ก Spring AOP self-invocation ๋ฌธ์ ๋ก ๋์ํ์ง ์์ต๋๋ค.
๋ฐฉ๋ฒ 2: ApplicationContext.getBean()์ผ๋ก ์๊ธฐ ์์ ์ฃผ์ Self-invocation์ ์ฐํํ๋ ๋ฐฉ๋ฒ์ด์ง๋ง, ๋น์ด ์๊ธฐ ์์ ์ ApplicationContext์์ ๊บผ๋ด๋ ๊ฑด Spring์ ์์กด์ฑ ์ฃผ์ ์์น์ ์ด๊ธ๋ฉ๋๋ค. ์ฝ๋ ์๋๋ฅผ ์ฝ๊ธฐ ์ด๋ ต๊ฒ ๋ง๋ค๊ณ , ์ ์ง๋ณด์ ์ ํผ๋์ ์ค๋๋ค.
๋ฐฉ๋ฒ 3: TransactionTemplate ์ง์ ์ฌ์ฉ
ํ๋ก๊ทธ๋๋งคํฑํ๊ฒ ํธ๋์ญ์
๊ฒฝ๊ณ๋ฅผ ์ ์ดํ ์ ์์ต๋๋ค. ๋์ํ์ง๋ง, ์ ์ธ์ @Transactional๋ณด๋ค ์ฝ๋๊ฐ ์ฅํฉํด์ง๊ณ ๋งค๋ฒ try-catch ํจํด์ ๋ฐ๋ณตํด์ผ ํฉ๋๋ค.
๋ฐฉ๋ฒ 4: ๋ณ๋ @Component Bean์ผ๋ก ๋ถ๋ฆฌ
PaymentCompensationOutboxProcessor๋ฅผ ๋ณ๋ ๋น์ผ๋ก ๋ง๋ค๋ฉด ์ค์ผ์ค๋ฌ์์ ์ธ๋ถ ๋น์ ํธ์ถํ๋ ๊ตฌ์กฐ๊ฐ ๋ฉ๋๋ค. Spring AOP ํ๋ก์๊ฐ ์ ์ ์ ์ฉ๋๊ณ , ์ฝ๋ ๊ตฌ์กฐ๋ "์ค์ผ์ค๋ฌ๋ ๋ชฉ๋ก ์กฐํ์ ์์, Processor๋ ์ฒ๋ฆฌ ์ฑ
์"์ผ๋ก ๋ช
ํํ๊ฒ ๋ถ๋ฆฌ๋ฉ๋๋ค.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void process(Long outboxId) { ... }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markRetry(Long outboxId, Exception cause) { ... }Outbox A ์ฒ๋ฆฌ ์ค ์์ธ๊ฐ ๋ฐ์ํด๋ Outbox B ์ฒ๋ฆฌ์ ์ํฅ์ ์ฃผ์ง ์์ต๋๋ค. Outbox ํจํด์ ํ ์ด๋ธ ๊ตฌ์กฐ๋งํผ์ด๋ ํธ๋์ญ์ ๊ฒฝ๊ณ ์ค๊ณ๊ฐ ์ค์ํ๋ค๋ ๊ฑธ ์ค๊ฐํ์ต๋๋ค.
Toss API ์ฅ์ ๊ฐ ๋ด๋ถ ์๋น์ค๋ก ์ ํ๋๋ค โ ํ์์์๊ณผ Circuit Breaker
์ธ๋ถ PG API ์๋ต์ด ์ง์ฐ๋๋ฉด ๊ฒฐ์ ์๋น์ค ์ ์ฒด๊ฐ ๋ฉ์ถ๋ ๊ฒ์ฒ๋ผ ๋ณด์์ต๋๋ค. ์ด๊ธฐ ๊ตฌ์กฐ์์๋ PaymentService ์์์ ์ง์ Toss API HTTP ํธ์ถ์ ํ๊ณ , ์๋ต์ด ์ค์ง ์์ผ๋ฉด ์ค๋ ๋๊ฐ ๊ทธ๋๋ก ๋ธ๋กํน๋์ต๋๋ค.
์ธ๋ถ API ํธ์ถ ํ์์์ ์ค์ ์ด ์์์ต๋๋ค. ํ์์์์ด ์์ผ๋ฉด ์๋ต์ ๋ฐ์ ๋๊น์ง ๋ฌดํ ๋๊ธฐํฉ๋๋ค. ๋์์ ์ฌ๋ฌ ์์ฒญ์ด ๋ค์ด์ค๋ฉด ์ค๋ ๋ ํ์ด ์์๊ฐ์ ๊ณ ๊ฐ๋ ์ ์์ต๋๋ค. ์ธ๋ถ ์์คํ ์ ์ฅ์ ๊ฐ ๋ด ์๋น์ค ์ ์ฒด๋ก ์ ํ๋๋ ๊ตฌ์กฐ์์ต๋๋ค.
๋ฐฉ๋ฒ 1: ํ์์์๋ง ์ค์ ์ค๋ ๋ ์ ์ ์๊ฐ์ ์ ํํ ์ ์์ด "ํ ์์ฒญ์ด ๋ฌดํ ๋ธ๋กํน"ํ๋ ๋ฌธ์ ๋ ํด๊ฒฐ๋ฉ๋๋ค. ๊ทธ๋ฌ๋ ์ธ๋ถ API๊ฐ ์์ ํ ์ฅ์ ์ํ์ธ ๊ฒฝ์ฐ, ์์ฒญ์ด ๋ค์ด์ฌ ๋๋ง๋ค ๋งค๋ฒ ํ์์์์ ๊ธฐ๋ค๋ฆฐ ๋ค ์คํจํฉ๋๋ค. ์ด๋ฏธ ์ฅ์ ์ํ์ธ ์ธ๋ถ ์์คํ ์ ๊ณ์ ์์ฒญ์ ๋ณด๋ด๋ ๋ญ๋น๊ฐ ์๊ณ , ์๋น์ค ์๋ต ์๋๋ ํ์์์ ์๊ฐ๋งํผ ๋๋ ค์ง๋๋ค.
๋ฐฉ๋ฒ 2: Bulkhead (์ค๋ ๋ ํ ๊ฒฉ๋ฆฌ) ์ธ๋ถ API ํธ์ถ ์ ์ฉ ์ค๋ ๋ ํ์ ๋ถ๋ฆฌํ๋ฉด ์ธ๋ถ ์ฅ์ ๊ฐ ๋ด๋ถ ์๋น์ค ์ค๋ ๋ ํ์ ๊ณ ๊ฐ์ํค์ง ๋ชปํฉ๋๋ค. ๊ฐํ ๊ฒฉ๋ฆฌ ์๋จ์ด์ง๋ง Resilience4j Bulkhead ์ค์ ๊ณผ ์ ์ฉ ์ค๋ ๋ ํ ๊ด๋ฆฌ๊ฐ ์ถ๊ฐ๋ฉ๋๋ค.
๋ฐฉ๋ฒ 3: ํ์์์ + Circuit Breaker ํ์์์์ผ๋ก ๊ฐ๋ณ ์์ฒญ ๋ธ๋กํน์ ์ ํํ๊ณ , ์ฐ์ ์คํจ๊ฐ ์๊ณ์น๋ฅผ ๋์ผ๋ฉด Circuit์ Openํด ์ธ๋ถ API ํธ์ถ ์์ฒด๋ฅผ ์ฐจ๋จํฉ๋๋ค. ์ฅ์ ์ํ์ธ ์ธ๋ถ ์์คํ ์ ๊ณ์ ์์ฒญ์ ๋ณด๋ด๋ ๋ญ๋น๋ฅผ ์ค์ด๊ณ , ๋น ๋ฅธ ์คํจ(fail-fast)๋ก ์๋ต ์๋๋ ์ ์ง๋ฉ๋๋ค.
๋ณ๋ fallback์ ๋ง๋ค์ง ์์์ต๋๋ค. Circuit Breaker ์์ธ๋ timeout ์์ธ๋ ๊ธฐ์กด catch ๋ธ๋ก์ payment.fail() + Outbox ์ ์ฅ ๊ฒฝ๋ก๋ก ํฉ๋ฅํ๋๋ก ์ค๊ณํ์ต๋๋ค. ์ฅ์ ๋ณดํธ ๋ก์ง์ ์ถ๊ฐํ๋ฉด์ ๊ธฐ์กด ๋ณด์ ํ๋ฆ์ ๊ทธ๋๋ก ์ ์งํ์ต๋๋ค.
- ํ์์์: connect 3s, read 3s. ์ค๋ ๋ ์ ์ ์๊ฐ์ ๋ช ์์ ์ผ๋ก ์ ํ
- Circuit Breaker: ์ฐ์ ์คํจ ์๊ณ์น ์ด๊ณผ ์ ํ๋ก ์คํ, ์ดํ ์์ฒญ์ ์ฆ์ ์คํจ ๋ฐํ
์ธ๋ถ PG ์ง์ฐ ์ ํ์์์์ด ๋น ๋ฅด๊ฒ ๋ฐ์ํ๊ณ , ์ฐ์ ์คํจ ์ Circuit Open์ผ๋ก ์ ํ๋ฉ๋๋ค. ์ธ๋ถ API ์ฐ๋์์ "์ฑ๊ณต ๊ฒฝ๋ก"๋งํผ์ด๋ "์ฅ์ ๋ฅผ ๋ด ์๋น์ค ์์ผ๋ก ์ผ๋ง๋ ๋ ๊ฐ์ ธ์ค๊ฒ ์ค๊ณํ๋๋"๊ฐ ์ค์ํ๋ค๋ ๊ฑธ ๋ฐฐ์ ์ต๋๋ค.
๋ก๊ทธ์ธ ํ ๊ณต์ฐ ๋ชฉ๋ก ์กฐํ ์ CORS ์๋ฌ โ ์ค์ ์ด ์๋ ๊ตฌ์กฐ ๋ฌธ์ ์๋ค
๋ฐฐํฌ ํ ๋ก๊ทธ์ธ ์ ์๋ ๊ณต์ฐ ๋ชฉ๋ก์ด ์ ์ ์กฐํ๋์ง๋ง, ๋ก๊ทธ์ธ ํ์๋ ๋ชฉ๋ก์ด ๋ณด์ด์ง ์์์ต๋๋ค. ๋ธ๋ผ์ฐ์ Network ํญ์๋ CORS error๊ฐ ํ์๋๊ณ Provisional headers are shown ๋ฉ์์ง๊ฐ ํจ๊ป ๋ํ๋ฌ์ต๋๋ค.
์ฒ์์๋ Spring Security๋ Spring CORS ์ค์ ์ด ๋น ์ง ๊ฒ์ผ๋ก ๋ดค์ต๋๋ค. ๊ทธ๋ฐ๋ฐ ๋ก๊ทธ์ธ ์ ํ ์์ฒญ ํ๋ฆ์ ๋น๊ตํด๋ณด๋ ์ฐจ์ด๊ฐ ์์์ต๋๋ค.
- ๋ก๊ทธ์ธ ์ : ๋จ์ GET ์์ฒญ (Simple Request) โ Preflight ์์
- ๋ก๊ทธ์ธ ํ:
Authorizationํค๋ ํฌํจ โ ๋ธ๋ผ์ฐ์ ๊ฐ Preflight(OPTIONS) ์์ฒญ์ ๋จผ์ ๋ฐ์ก
Spring Security๊ฐ OPTIONS ์์ฒญ์ ์ธ์ฆ ์์ด ํ์ฉํ์ง ์์ Preflight๊ฐ 401๋ก ์ฐจ๋จ๋์ต๋๋ค. ์๋ ์ค์ ์ ์ถ๊ฐํ์ต๋๋ค.
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()Preflight๋ ํต๊ณผํ์ง๋ง ์ฌ์ ํ CORS ์๋ฌ๊ฐ ๋ฐ์ํ์ต๋๋ค. Network ํญ์์ ์ค์ ์์ฒญ URL์ ํ์ธํด๋ณด๋ https://acornpost.cloud/api/concerts๋ก, ํ๋ก ํธ์๋๊ฐ EC2๋ฅผ ์ง์ ํธ์ถํ๊ณ ์์์ต๋๋ค. Nginx์ Spring Boot์ ํ์ฉ Origin ์ค์ ์ด ์์ ํ ์ผ์นํ์ง ์์ ๋ธ๋ผ์ฐ์ ์์ ์ฐจ๋จ๋๋ ๊ตฌ์กฐ์์ต๋๋ค.
๊ฒฐ๊ตญ "์ด๋ค Origin์ ํ์ฉํ ์ง"๋ฅผ ๋ง์ถ๋ ๋ฌธ์ ๊ฐ ์๋๋ผ, ํ๋ก ํธ์๋๊ฐ ๋ค๋ฅธ ์ถ์ฒ ์๋ฒ๋ฅผ ์ง์ ํธ์ถํ๋ค๋ ๊ตฌ์กฐ ์์ฒด๊ฐ ๋ฌธ์ ์์ต๋๋ค.
๋ฐฉ๋ฒ 1: Nginx + Spring Boot CORS ์ค์ ์ ์ ๋ฐํ๊ฒ ๋ง์ถ๊ธฐ
Nginx์์ Access-Control-Allow-Origin, Allow-Headers๋ฅผ ์ค์ ํ๊ณ , Spring Boot์์๋ ๋์ผํ Origin์ ํ์ฉํ๋ฉด ํด๊ฒฐ๋ฉ๋๋ค. ๊ทธ๋ฌ๋ ๋ ๊ณ์ธต์ ์ค์ ์ด ์กฐ๊ธ์ด๋ผ๋ ์ด๊ธ๋๋ฉด ๋ค์ ์๋ฌ๊ฐ ๋ฐ์ํฉ๋๋ค. ํ๊ฒฝ(Vercel ๋๋ฉ์ธ, ๋ก์ปฌ, EC2)์ ๋ฐ๋ผ ํ์ฉ Origin์ด ๋ฌ๋ผ์ง๊ณ , ๋ก๊ทธ์ธ ํ Authorization ํค๋๊ฐ ๋ถ์ผ๋ฉด Preflight๊น์ง ์ถ๊ฐ๋ก ๊ณ ๋ คํด์ผ ํฉ๋๋ค. ์ค์ ์ ์์กดํ๋ ๊ตฌ์กฐ๋ผ ์์ ์ฑ์ด ๋ฎ์์ต๋๋ค.
๋ฐฉ๋ฒ 2: Vercel rewrite๋ก ํ๋ก์ ๊ตฌ์ฑ (์ ํ)
Vercel์ rewrites๋ฅผ ์ด์ฉํด /api/* ์์ฒญ์ ์๋ฒ์ฌ์ด๋์์ EC2๋ก ์ ๋ฌํฉ๋๋ค. ๋ธ๋ผ์ฐ์ ์
์ฅ์์๋ Vercel ๋๋ฉ์ธ ์์์ ์์ฒญ์ด ์๊ฒฐ๋์ด CORS ์์ฒด๊ฐ ๋ฐ์ํ์ง ์์ต๋๋ค. ์ค์ ์ด vercel.json ํ ๊ณณ์๋ง ์กด์ฌํ๊ณ , Nginx์ Spring Boot์ CORS ์ค์ ์์กด์ฑ์ด ์ฌ๋ผ์ง๋๋ค.
{
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://acornpost.cloud/api/:path*"
}
]
}ํ๋ก ํธ์๋ ํ๊ฒฝ๋ณ์๋ EC2 ์ง์ ์ฃผ์์์ ์๋ ๊ฒฝ๋ก๋ก ๋ณ๊ฒฝํ์ต๋๋ค.
VITE_API_BASE_URL=/api
CORS๋ฅผ "์ค์ ์ผ๋ก ํด๊ฒฐ"ํ๋ ๋์ , ์ ์ด์ ๋ฐ์ํ์ง ์๋ ๊ตฌ์กฐ๋ก ๋ฐ๊พธ๋ ๊ฒ์ด ๋ ์์ ์ ์ด๋ผ๊ณ ํ๋จํ์ต๋๋ค.
์์ฒญ ํ๋ฆ์ด ๋ธ๋ผ์ฐ์ โ Vercel(/api) โ Vercel ์๋ฒ โ EC2๋ก ๋ฐ๋์ด, ๋ธ๋ผ์ฐ์ ๊ธฐ์ค ๋์ผ ์ถ์ฒ ์์ฒญ์ด ๋ฉ๋๋ค. CORS ์๋ฌ ์์ด ๋ก๊ทธ์ธ ์ ํ ๋ชจ๋ ์ ์ ๋์ํฉ๋๋ค. CORS ๋ฌธ์ ๋ ํ์ฉ ์ค์ ์ ๋ง์ถ๋ ๋ฌธ์ ๊ฐ ์๋๋ผ ์์ฒญ ๊ตฌ์กฐ ์ค๊ณ์ ๋ฌธ์ ์ผ ์ ์์ต๋๋ค. Preflight๊ฐ ๋ฐ์ํ๋ ๋งฅ๋ฝ(ํค๋ ํฌํจ ์ฌ๋ถ)์ ๋จผ์ ํ์
ํ๋ฉด ์์ธ์ ๋น ๋ฅด๊ฒ ์ขํ ์ ์์ต๋๋ค.
- ํผํฌ ์๊ฐ ๋์์ฉ ์ค์ผ์ค๋ ์ค์ผ์ผ๋ง / ์คํ ์ค์ผ์ผ๋ง: ํ์์์๋ ์ ์ ์๋ฒ ์๋ก ์ด์ํ๊ณ , ํฐ์ผ ์คํ ์๊ฐ๋์๋ app ์ธ์คํด์ค๋ฅผ ํ์ฅํ๋ ์ ๋ต ๊ฒํ
- Polling ์ ์ฑ ์ถ๊ฐ ์ต์ ํ: ํ์ฌ Adaptive Polling์ ๋ ์ธ๋ถํํ๊ฑฐ๋, jitter์ ๊ตฌ๊ฐ ์ ์ฑ ์ ์กฐ์ ํด ํ๊ท ์กฐํ ๋ถํ๋ฅผ ์ถ๊ฐ๋ก ๋ฎ์ถ๋ ๋ฐฉํฅ ๊ฒํ
์ค์ผ์ค๋ฌ ์ฟผ๋ฆฌ๊ฐ 10๋ง ๊ฑด ๊ธฐ์ค Full Table Scan โ ๋ณตํฉ ์ธ๋ฑ์ค๋ก ์ฝ 22๋ฐฐ ๊ฐ์
BookingExpiryScheduler๋ 60์ด๋ง๋ค ์๋ ์ฟผ๋ฆฌ๋ฅผ ์คํํฉ๋๋ค.
SELECT * FROM booking
WHERE status = 'PENDING_PAYMENT'
AND created_at < NOW() - INTERVAL 30 MINUTE
ORDER BY created_at ASC
LIMIT 1000;์ฒ์์๋ "์ค์ผ์ค๋ฌ ์ฟผ๋ฆฌ๋๊น ์๋ต ์๊ฐ์ด ์ข ๊ธธ์ด๋ ๊ด์ฐฎ๊ฒ ์ง"๋ผ๊ณ ์๊ฐํ์ต๋๋ค. ๊ทธ๋ฐ๋ฐ ๋ฐ์ดํฐ๊ฐ ์์ผ์๋ก ์ด ์ฟผ๋ฆฌ๊ฐ ๋ถ๋ด์ด ๋๋ค๋ ๊ฑธ ํ์ธํ๊ณ ์ถ์์ต๋๋ค. 10๋ง ๊ฑด ๋ฐ์ดํฐ๋ฅผ ๋ฃ๊ณ EXPLAIN์ ์คํํ์ต๋๋ค.
Before โ EXPLAIN
type=ALL, key=NULL โ ์ธ๋ฑ์ค๋ฅผ ์ ํ ์ฌ์ฉํ์ง ์๊ณ 10๋ง ๊ฑด ์ ์ฒด๋ฅผ ์ฝ์ ๋ค ์กฐ๊ฑด ํํฐ๋ง์ ํฉ๋๋ค.
Before โ ์คํ ์๊ฐ (5ํ ํ๊ท )
ํ๊ท 45~50ms.
status ๋จ๋
์ธ๋ฑ์ค์ (status, created_at) ๋ณตํฉ ์ธ๋ฑ์ค ๋ ๊ฐ์ง๋ฅผ ๊ณ ๋ คํ์ต๋๋ค.
status ๋จ๋
์ธ๋ฑ์ค๋ PENDING_PAYMENT ๊ฑด๋ง ๊ณจ๋ผ๋ผ ์ ์์ง๋ง, ๊ทธ ์ดํ created_at ๋ฒ์ ํํฐ๋ ์ฌ์ ํ ๋ณ๋ ํํฐ๋ง์ด ํ์ํฉ๋๋ค. ๋ณตํฉ ์ธ๋ฑ์ค (status, created_at)๋ status๋ก ๋จผ์ ๋ฒ์๋ฅผ ์ขํ ๋ค created_at ์์๋ก ๋ฐ๋ก ์ฝ์ด๋๊ฐ ์ ์์ต๋๋ค.
์ฟผ๋ฆฌ์์ status = ์กฐ๊ฑด์ด ๋ฑ์น ๋น๊ต์ด๊ณ created_at <๊ฐ ๋ฒ์ ์กฐ๊ฑด์ด๋ผ ๋ณตํฉ ์ธ๋ฑ์ค์ ์ปฌ๋ผ ์์๊ฐ ์ค์ํฉ๋๋ค. status๋ฅผ ์์ ๋๋ฉด created_at ๋ฒ์ ์ค์บ์ ์ธ๋ฑ์ค ์์์ ์ฒ๋ฆฌํ ์ ์์ต๋๋ค.
CREATE INDEX idx_booking_status_created_at ON booking(status, created_at);After โ EXPLAIN
type=range, key=idx_booking_test_status_created_at, Using index condition โ Index Range Scan์ผ๋ก ์ ํ๋ฉ๋๋ค.
After โ ์คํ ์๊ฐ (5ํ ํ๊ท )
ํ๊ท 1.7~2.2ms.
| ํญ๋ชฉ | Before | After |
|---|---|---|
| ์คํ ๊ณํ | type=ALL (Full Table Scan) |
type=range (Index Range Scan) |
| ์ฌ์ฉ ์ธ๋ฑ์ค | NULL |
idx_booking_status_created_at |
| ํ๊ท ์คํ ์๊ฐ | ~45ms | ~2ms |
๋ง๋ฃ ์กฐํ ์ฟผ๋ฆฌ(
created_at < X)๋ ์กฐ๊ฑด ๋ฒ์๊ฐ ๋์ด ์ธ๋ฑ์ค ํ์๋ ์ฝ๋ ๋ฐ์ดํฐ๊ฐ ์ด๋ ์ ๋ ์์ต๋๋ค.
์์ชฝ ๋ฒ์๋ก ์ ํ๋ ์ฟผ๋ฆฌ์์๋ ์ฝ 22๋ฐฐ ๊ฐ์ ๋ ํ์ธํ์ต๋๋ค.
docs/architecture.mdโ ํจํค์ง ๊ตฌ์กฐ, Redis ํค ๋ช ์ธ, Lua Script, Scheduler ์์ธdocs/flow.mdโ ์๋งค/๋ณด์/๋ง๋ฃ ์ํ์ค ๋ค์ด์ด๊ทธ๋จ, ์ํ ์ ์ดdocs/api.mdโ ์ ์ฒด REST ์๋ํฌ์ธํธ ๋ชฉ๋กdocs/testing.mdโ ํ ์คํธ ์ ๋ต, ํด๋์ค๋ณ ์ค๋ช , ์คํ ํ๊ฒฝ












