
최근 주문/결제 API에서 멱등성(idempotency)을 안전하게 보장하기 위해, Redis 기반의 멱등키 저장 로직을 개선하고 Artillery로 실제 부하 테스트까지 진행해봤습니다. 오늘은 그 개선 과정과 결과를 공유드려요 🙌
⸻
IdempotencyKeyInterceptor가 적용되어 있는 주문/결제 로직은 서버-클라이언트 간 요청-응답이 유기적으로 일어나야 하는 구조이고, 클라이언트에서도 일명 "따닥" 이슈라고 불리는 동시 요청 이슈에 대해 대응이 되어 있어 동시성 이슈가 발생하기 어려운 상황이었지만 서버 개발자로서 서비스의 부족한 부분을 개선하고 싶었습니다!
기존에 주문/결제 서비스 로직에서 운영하던 IdempotencyKeyInterceptor 클래스는 요청이 들어올 때마다 Redis에서 먼저 idempotency-key키를 조회한 뒤, 없으면 PENDING 상태로 저장하는 구조였어요.
✅ 기존 코드
// 1) 멱등키 기반 GET
const cacheKey = url + '-' + idempotencyKey;
const responseData = await this.redisService.get(prefix, cacheKey);
// 2) 이미 PENDING이면 409 에러
if (responseData === 'PENDING') {
throw new ConflictException('이미 처리 중인 요청입니다.');
}
// 3) 최초 요청이면 PENDING 저장
await this.redisService.set(prefix, cacheKey, 'PENDING', 600);
그런데 문제는, 요청 A, B, C가 거의 동시에 들어올 경우 Redis get()에서 모두 null을 받아버릴 수 있다는 거예요. 이로 인해 세 요청 모두가 “최초 요청”이라고 착각하고 PENDING으로 등록돼, 중복 처리가 발생할 수 있습니다. 결과적으로 동시성 이슈로 인해 멱등성이 깨져버리는 거예요 😱
⸻
“Only set the key if it does not already exist."
이 문제를 해결하기 위해 Redis의 " NX 옵션 "을 활용했어요.
기존 방식은 get()과 set()이 각각의 Redis 명령어로 분리돼 있었기 때문에, 동시성 상황에서는 레이스 컨디션이 발생할 수 있었어요.
반면 NX 옵션을 활용하면 “ 존재하지 않을 때만 set ”이 단일 명령어로 처리되므로, Redis가 중복 요청을 알아서 차단해주는 거죠.
✅ 코드 변경 사항
async setIfNotExists(key: string, value: string, ttl: number) {
await this.redis.set(key, value, 'EX', ttl, 'NX');
}
Redis set 커맨드를 이렇게 수정하니 동시에 여러 요청이 들어와도 Redis가 먼저 들어온 요청을 순서대로 처리해주게 되어 중복 요청은 알아서 걸러줍니다. 덕분에 멱등키 기반 동시성 제어가 훨씬 더 견고해졌어요.
이게 가능한 이유는 Redis가 싱글 스레드 기반 인메모리 DB이기 때문이죠!
⸻
✅ IdempotencyKeyInterceptor 부하 테스트 코드
// controller
@Post('/idempotency')
@UseInterceptors(IdempotencyKeyInterceptor) // 멱등키 인터셉터
async idempotencyTest() {
return await this.testService.idempotencyTest();
}
// service
async idempotencyTest() {
console.log('idempotencyTest - 로직 수행!');
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2초 기다림
return { resultCode: 1, data: null };
}
의도한대로 동시성 이슈를 잘 막아 내는지 확인하기 위해 위 코드를 Artillery로 부하 테스트를 한 번 돌려봤습니다. 총 2초 동안 초당 100건, 총 200건의 POST 요청을 날려봤습니다.
✅ idem-test.yml(테스트 API 요청 명세)
config:
target: 'http://127.0.0.1:3000/api/v3'
phases:
- duration: 2
arrivalRate: 100
defaults:
headers:
Content-Type: application/json
webudding-idempotency-key: abc-123-idem-key # 고정된 멱등키 (중복 유도)
scenarios:
- flow:
- post:
url: '/test/idempotency'
결과는 다음과 같았습니다!
| 📊 테스트 지표 | 🧪 기존 로직 결과값 | 🚀 NX 옵션 적용 후 결과값 |
|---|---|---|
| ✅ 성공 응답 수 (HTTP 201) | 2건 (중복 허용으로 2건 처리됨) | 1건 (정상적으로 1건만 처리됨) |
| ⚠️ 중복 차단 응답 수 (HTTP 409) | 198건 | 199건 |
| 🔄 총 요청 수 | 200건 | 200건 |
| ⏱ 전체 응답 평균 시간 | 22.6ms | 21.3ms |
| ⏱ 2xx 응답 평균 시간 (정상 처리 요청) | 2,020ms (2초 대기 포함) | 2,266ms (2초 대기 + 부하 영향) |
| ⚡ 4xx 응답 평균 시간 (중복 요청 즉시 차단) | 2.4ms (거의 실시간) | 10ms (차단 시간 소폭 증가) |
정리하자면, 기존 로직에선 2건이 중복 처리됐지만, NX 적용 후엔 1건만 정상 처리되고 나머지는 전부 빠르게 차단되며 동시성 문제를 확실하게 해결한 것을 확인할 수 있었습니다.
⸻
이렇게 Redis의 NX 옵션 한 줄로 간편하게 멱등성을 보장할 수 있게 되었습니다. 지금까지는 Redis를 단순히 자주 조회되는 데이터 캐싱 용도로만 사용해왔었는데, 이번 기회에 Redis에 유용한 커맨드와 옵션이 있다는 것을 알게 되었습니다. 또 Artillery 기반 부하 테스트도 실제 코드 개선점을 빠르게 확인할 수 있어 좋았습니다.
동시성 제어는 작은 허점 하나만 있어도 큰 사고로 이어질 수 있으니, 여러분도 개발할 때 Redis의 원자 명령을 적극 활용해 보세요!
감사합니다 😊