라이브 커머스 주문 시스템 개선기 - 테스트용 서버 사양도 확인하자

라이브커머스의 오픈런 상황을 검증하기 위해 k6로 부하테스트를 진행했습니다.

테스트 시나리오는 다음과 같았습니다.

  • 방송 시청자 4,000명
  • 구매자 1,200명
  • 30초 동안 주문 집중
  • 목표 주문 응답 시간 p95 3초 미만
  • 주문 성공률 70% 이상
  • 5xx 오류 10건 미만

기능적인 오류 없이 주문을 처리하는 것도 중요했지만, 사용자가 몰리는 상황에서도 3초 이내에 응답하는 것을 목표로 했습니다.

그런데 테스트 결과가 목표를 아슬아슬하게 넘었습니다.

주문은 모두 정상적으로 처리됐고 서버 오류도 발생하지 않았습니다. 문제는 응답 시간이었습니다.

처음에는 당연히 애플리케이션 서버의 성능 문제라고 생각했습니다.

하지만 원인은 예상과 다른 곳에 있었습니다.



(http_req_duration은 테스트 중 발생한 전체 HTTP 요청의 응답시간이며, openrun_duration_ms는 주문 요청의 응답시간만 별도로 집계한 커스텀 지표입니다.)

기능은 정상인데 응답 시간만 느렸다

당시 k6 테스트 결과는 다음과 같았습니다.

█ THRESHOLDS

http_req_failed
✓ 'rate<0.20' rate=0.00%

openrun_5xx_count
✓ 'count<10' count=0

openrun_duration_ms
✗ 'p(95)<3000' p(95)=3.03s

openrun_success_rate
✓ 'rate>0.70' rate=100.00%

poller_api_success
✓ 'rate>0.95' rate=100.00%

█ TOTAL RESULTS

checks_total.......: 66809   262.289122/s
checks_succeeded...: 100.00% 66809 out of 66809
checks_failed......: 0.00%   0 out of 66809

✓ [S3 poller] 200
✓ login 200
✓ login has token
✓ [S3] 주문 성공 또는 정상 품절
✓ [S3] 서버 에러 없음

CUSTOM
openrun_5xx_count..............: 0       0/s
openrun_duration_ms............: avg=1.44s    min=16ms    med=1.26s  max=5.3s   p(90)=2.68s  p(95)=3.03s
openrun_success_rate...........: 100.00% 7822 out of 7822
poller_api_success.............: 100.00% 48765 out of 48765
sold_out_skip..................: 851     3.340988/s

HTTP
http_req_duration..............: avg=543.49ms min=1.78ms  med=4.39ms max=5.3s   p(90)=2.05s  p(95)=2.46s
  { expected_response:true }...: avg=543.49ms min=1.78ms  med=4.39ms max=5.3s   p(90)=2.05s  p(95)=2.46s
http_req_failed................: 0.00%   0 out of 67313
http_reqs......................: 67313   264.267804/s

EXECUTION
iteration_duration.............: avg=11.66s   min=59.06ms med=12.34s max=19.64s p(90)=14.72s p(95)=15.05s
iterations.....................: 57438   225.498999/s
vus............................: 2       min=0              max=4367
vus_max........................: 5200    min=1580           max=5200

NETWORK
data_received..................: 322 MB  1.3 MB/s
data_sent......................: 14 MB   55 kB/s

기능적으로는 정상적이었습니다.

  • 주문 성공률 100% (성공 또는 정상 품절, 재고 부족 포함)
  • 비정상 응답, 5xx 오류 0건
  • HTTP 요청 실패 0건

하지만 주문 응답 p95가 3.03초로 목표였던 3초를 초과했습니다.


처음 의심했던 부분

처음에는 애플리케이션 내부에 병목이 있다고 생각했습니다.

다음 항목들을 우선 확인했습니다.

  • MySQL 커넥션 풀 부족 여부
  • Bull Queue 워커의 concurrency 설정
  • Redis Lua Script 실행 시간
  • Redis 네트워크 지연
  • 주문 처리 로직의 불필요한 DB 조회
  • PM2 프로세스의 CPU 및 메모리 사용량

하지만 애플리케이션 로직에서는 응답 시간을 3초까지 지연시킬 만한 명확한 병목을 찾지 못했습니다.

특히 주문 성공률이 100%이고 5xx 오류도 없다는 점이 이상했습니다.

애플리케이션이나 데이터베이스가 한계에 도달했다면 다음과 같은 현상이 함께 나타날 가능성이 높았습니다.

  • DB 커넥션 타임아웃
  • Redis 요청 실패
  • Queue 적체
  • 5xx 오류 증가
  • 주문 성공률 하락

그러나 실제 결과에서는 응답 시간만 느려졌습니다.

그래서 테스트 대상 서버가 아닌, 부하를 생성하는 환경을 확인했습니다.


부하생성기 자체가 병목이었다

부하테스트는 운영 애플리케이션 서버와 분리된 T3.large 인스턴스에서 실행하고 있었습니다.

문제는 해당 서버에서 k6와 Artillery를 동시에 실행하고 있었다는 점입니다.

  • k6: HTTP API 부하 생성
  • Artillery: WebSocket 연결 부하 생성
  • 최대 k6 VU: 5,200
  • k6와 Artillery가 동일한 서버의 CPU와 메모리 사용

테스트 도중 top으로 부하생성기 상태를 확인했습니다.

  • CPU idle 0% - CPU가 쉬지 않고 모두 사용되고 있었습니다. 부하생성기가 추가 요청을 안정적으로 생성하고 응답을 처리할 여유가 없었다는 의미입니다.
  • 사용자·커널 연산 비중 증가 - 사용자 프로세스가 CPU의 78.1%, 네트워크와 소켓 등을 처리하는 커널 작업이 13.6%를 사용했습니다. 소프트 인터럽트도 7.8%로, 대량의 네트워크 요청을 처리하는 부담이 컸습니다.
  • I/O wait는 0.5% - 디스크 I/O 대기는 거의 없었습니다. 따라서 이 상황의 핵심은 디스크나 메모리 고갈이 아니라 부하생성기의 CPU 포화 및 네트워크·소켓 처리 부담 증가 였습니다.

결국 k6와 Artillery를 함께 실행하던 부하생성기가 먼저 CPU 한계에 도달했고, 요청 생성과 응답 측정이 지연되면서 부하생성기의 CPU 포화가 요청 생성과 응답 처리에 영향을 주면서, 기존 응답시간 측정값에 부하생성 환경의 병목이 크게 반영된 것으로 판단했습니다.



다른 환경에서 동일 시나리오 재실행

부하생성기 병목이라는 가설을 검증하기 위해, 리소스 여유가 있는 로컬 윈도우 환경에서 동일한 스크립트로 테스트를 다시 실행했습니다.

테스트 대상 애플리케이션과 시나리오는 변경하지 않고 부하생성 환경만 변경했습니다.

재실행 결과는 다음과 같았습니다.

█ THRESHOLDS

http_req_failed
✓ 'rate<0.20' rate=0.00%

openrun_5xx_count
✓ 'count<10' count=0

openrun_duration_ms
✓ 'p(95)<3000' p(95)=316ms

openrun_success_rate
✓ 'rate>0.70' rate=100.00%

poller_api_success

█ TOTAL RESULTS

checks_total.......: 93049   367.248128/s
checks_succeeded...: 100.00% 93049 out of 93049
checks_failed......: 0.00%   0 out of 93049

✓ [S3 poller] 200
✓ login 200
✓ login has token
✓ [S3] 주문 성공 또는 정상 품절
✓ [S3] 서버 에러 없음

CUSTOM
openrun_5xx_count..............: 0       0/s
openrun_duration_ms............: avg=49.49ms min=10ms    med=15ms   max=635ms   p(90)=102ms   p(95)=316ms
openrun_success_rate...........: 100.00% 20347 out of 20347
poller_api_success.............: 100.00% 49955 out of 49955
sold_out_skip..................: 2259    8.915878/s

HTTP
http_req_duration..............: avg=33.69ms min=7.58ms  med=12ms   max=634.9ms p(90)=46.19ms p(95)=177.08ms
  { expected_response:true }...: avg=33.69ms min=7.58ms  med=12ms   max=634.9ms p(90)=46.19ms p(95)=177.08ms
http_req_failed................: 0.00%   0 out of 96369
http_reqs......................: 96369   380.351587/s

EXECUTION
iteration_duration.............: avg=9.21s   min=20.75ms med=11.38s max=15.77s  p(90)=14.28s  p(95)=14.65s
iterations.....................: 72561   286.385575/s
vus............................: 1       min=1              max=4366
vus_max........................: 5200    min=5200           max=5200

NETWORK
data_received..................: 762 MB  3.0 MB/s
data_sent......................: 18 MB   71 kB/s

 

주문 응답 p95는 3.03초에서 316ms로 감소했고, 전체 HTTP 응답 p95도 2.46초에서 177.08ms로 감소했습니다.

반면 주문 성공률과 오류 건수는 이전과 동일했습니다.

따라서 애플리케이션의 성능이 갑자기 향상된 것이 아니라, 기존 측정 결과에 부하생성기의 리소스 병목이 크게 반영되고 있었다고 판단했습니다.


지표 T3.large 로컬 환경
전체 HTTP 요청 67,313건 96,369건
HTTP 처리량 264.27 req/s 380.35 req/s
openrun 측정 건수 7,822건 20,347건
전체 실행 시간 약 254.7초 약 253.3초

동일한 k6 스크립트와 VU 구성을 사용했지만, 부하생성 환경에 따라 실제 완료된 요청량에는 차이가 발생했습니다. 로컬 환경에서는 더 많은 요청을 처리하면서도 응답 시간이 크게 감소했습니다.

 


이번 테스트에서는 주문 성공률이 100% (성공 또는 정상 품절, 재고 부족 포함) 이고 5xx 오류도 없었지만, 주문 응답 p95가 3.03초로 측정됐습니다.

처음에는 애플리케이션과 데이터베이스의 병목을 의심했지만, 실제 원인은 k6와 Artillery를 동시에 실행하던 T3.large 부하생성기의 CPU 포화와 네트워크·소켓 처리 부담 증가 고갈이었습니다.

동일한 시나리오를 리소스 여유가 있는 환경에서 다시 실행한 결과는 다음과 같았습니다.

  • 주문 응답 p95: 3.03초 → 316ms
  • 전체 HTTP 응답 p95: 2.46초 → 177.08ms
  • 주문 성공률: 100% 유지
  • 5xx 오류: 0건 유지

부하테스트의 결과를 신뢰하려면 무엇을 테스트할지만큼 어디에서 어떻게 부하를 생성하는지도 중요합니다.

성능 테스트 도구 역시 자원을 소비하는 하나의 애플리케이션입니다. 부하생성기가 먼저 병목에 걸린다면 측정 결과는 대상 서버의 성능이 아니라 테스트 환경의 한계를 보여주게 됩니다.

이번 경험을 통해 단순히 응답 시간을 줄이는 데서 그치지 않고, 이후의 성능 테스트 결과를 반복적으로 신뢰할 수 있도록 테스트 환경 자체를 개선할 수 있었습니다.

라이브커머스 주문 시스템 개선기 - 튜닝 & 장애조치

이번 단계의 목표는 오픈런과 같은 순간적으로 트래픽이 몰리는 상황에서도 주문 API 응답을 3초 이내로 유지하는 것이었다.

앞선 테스트에서 Redis Lua Script와 Bull Queue를 적용해 재고 정합성은 확보했지만, 주문 요청이 몰리는 구간에서는 여전히 응답시간이 3초를 초과하는 경우가 발생했다.

따라서 이번에는 Bull Queue Worker의 concurrency 값을 조정하며 처리량과 응답시간 변화를 측정하였다.


worker의 concurrency 조정 :

  • 5

█ THRESHOLDS

http_req_failed
✓ 'rate<0.20' rate=0.00%

openrun_5xx_count
✓ 'count<10' count=0

openrun_duration_ms
✗ 'p(95)<3000' p(95)=5.43s

openrun_success_rate
✓ 'rate>0.70' rate=100.00%

poller_api_success
✓ 'rate>0.95' rate=100.00%

█ TOTAL RESULTS

checks_total.......: 61637   243.26439/s
checks_succeeded...: 100.00% 61637 out of 61637
checks_failed......: 0.00%   0 out of 61637

✓ [S3 poller] 200
✓ login 200
✓ login has token
✓ [S3] 주문 성공 또는 정상 품절
✓ [S3] 서버 에러 없음

CUSTOM
openrun_5xx_count..............: 0       0/s
openrun_duration_ms............: avg=2.15s    min=7ms    med=1.45s  max=8.22s  p(90)=5.09s  p(95)=5.43s
openrun_success_rate...........: 100.00% 5451 out of 5451
poller_api_success.............: 100.00% 48335 out of 48335

HTTP
http_req_duration..............: avg=703.33ms min=2.28ms med=4.73ms max=7.99s  p(90)=2.86s  p(95)=4.43s
  { expected_response:true }...: avg=703.33ms min=2.28ms med=4.73ms max=7.99s  p(90)=2.86s  p(95)=4.43s
http_req_failed................: 0.00%   0 out of 61023
http_reqs......................: 61023   240.8411/s

EXECUTION
iteration_duration.............: avg=12.42s   min=1.15s  med=12.52s max=23.33s p(90)=14.84s p(95)=15.93s
iterations.....................: 53786   212.278639/s
vus............................: 6       min=0              max=4387
vus_max........................: 5200    min=1275           max=5200

NETWORK
data_received..................: 220 MB  867 kB/s
data_sent......................: 13 MB   51 kB/s
  • 10

█ THRESHOLDS

http_req_failed
✓ 'rate<0.20' rate=0.00%

openrun_5xx_count
✓ 'count<10' count=0

openrun_duration_ms
✗ 'p(95)<3000' p(95)=3.01s

openrun_success_rate
✓ 'rate>0.70' rate=100.00%

poller_api_success
✓ 'rate>0.95' rate=100.00%

█ TOTAL RESULTS

checks_total.......: 68721   270.463971/s
checks_succeeded...: 100.00% 68721 out of 68721
checks_failed......: 0.00%   0 out of 68721

✓ [S3 poller] 200
✓ login 200
✓ login has token
✓ [S3] 주문 성공 또는 정상 품절
✓ [S3] 서버 에러 없음

CUSTOM
openrun_5xx_count..............: 0       0/s
openrun_duration_ms............: avg=1.23s    min=8ms    med=971ms  max=4.7s   p(90)=2.75s  p(95)=3.01s
openrun_success_rate...........: 100.00% 8731 out of 8731
poller_api_success.............: 100.00% 48859 out of 48859

HTTP
http_req_duration..............: avg=480.83ms min=2.29ms med=4.75ms max=4.7s   p(90)=1.99s  p(95)=2.49s
  { expected_response:true }...: avg=480.83ms min=2.29ms med=4.75ms max=4.7s   p(90)=1.99s  p(95)=2.49s
http_req_failed................: 0.00%   0 out of 68400
http_reqs......................: 68400   269.200617/s

EXECUTION
iteration_duration.............: avg=11.61s   min=1.08s  med=12.29s max=19.92s p(90)=14.67s p(95)=14.97s
iterations.....................: 57590   226.6559/s
vus............................: 2       min=0              max=4461
vus_max........................: 5200    min=1461           max=5200

NETWORK
data_received..................: 324 MB  1.3 MB/s
data_sent......................: 14 MB   55 kB/s
  • 15

█ THRESHOLDS

http_req_failed
✓ 'rate<0.20' rate=0.00%

openrun_5xx_count
✓ 'count<10' count=0

openrun_duration_ms
✗ 'p(95)<3000' p(95)=7.13s

openrun_success_rate
✓ 'rate>0.70' rate=100.00%

poller_api_success
✓ 'rate>0.95' rate=100.00%

█ TOTAL RESULTS

checks_total.......: 57265   226.085699/s
checks_succeeded...: 100.00% 57265 out of 57265
checks_failed......: 0.00%   0 out of 57265

✓ [S3 poller] 200
✓ login 200
✓ login has token
✓ [S3] 주문 성공 또는 정상 품절
✓ [S3] 서버 에러 없음

CUSTOM
openrun_5xx_count..............: 0       0/s
openrun_duration_ms............: avg=2.97s    min=7ms    med=2.45s  max=10.88s p(90)=5.5s   p(95)=7.13s
openrun_success_rate...........: 100.00% 3656 out of 3656
poller_api_success.............: 100.00% 47553 out of 47553

HTTP
http_req_duration..............: avg=745.95ms min=2.34ms med=4.61ms max=10.74s p(90)=2.86s  p(95)=4.69s
  { expected_response:true }...: avg=745.95ms min=2.34ms med=4.61ms max=10.74s p(90)=2.86s  p(95)=4.69s
http_req_failed................: 0.00%   0 out of 56460
http_reqs......................: 56460   222.90751/s

EXECUTION
iteration_duration.............: avg=13.02s   min=1.05s  med=12.77s max=33.78s p(90)=15.17s p(95)=18.17s
iterations.....................: 51209   202.176243/s
vus............................: 5       min=0              max=4400
vus_max........................: 5200    min=903            max=5200

NETWORK
data_received..................: 162 MB  641 kB/s
data_sent......................: 12 MB   49 kB/s
  • 20

█ THRESHOLDS

http_req_failed
✓ 'rate<0.20' rate=0.00%

openrun_5xx_count
✓ 'count<10' count=0

openrun_duration_ms
✗ 'p(95)<3000' p(95)=7.23s

openrun_success_rate
✓ 'rate>0.70' rate=100.00%

poller_api_success
✓ 'rate>0.95' rate=100.00%

█ TOTAL RESULTS

checks_total.......: 58996   232.082915/s
checks_succeeded...: 100.00% 58996 out of 58996
checks_failed......: 0.00%   0 out of 58996

✓ [S3 poller] 200
✓ login 200
✓ login has token
✓ [S3] 주문 성공 또는 정상 품절
✓ [S3] 서버 에러 없음

CUSTOM
openrun_5xx_count..............: 0       0/s
openrun_duration_ms............: avg=2.91s    min=10ms   med=2.25s  max=10.28s p(90)=6.22s  p(95)=7.23s
openrun_success_rate...........: 100.00% 4599 out of 4599
poller_api_success.............: 100.00% 47398 out of 47398

HTTP
http_req_duration..............: avg=956.62ms min=2.33ms med=4.95ms max=10.36s p(90)=3.81s  p(95)=5.55s
  { expected_response:true }...: avg=956.62ms min=2.33ms med=4.95ms max=10.36s p(90)=3.81s  p(95)=5.55s
http_req_failed................: 0.00%   0 out of 58274
http_reqs......................: 58274   229.242657/s

EXECUTION
iteration_duration.............: avg=12.97s   min=1.38s  med=12.77s max=27.91s p(90)=15.54s p(95)=18.04s
iterations.....................: 51997   204.549721/s
vus............................: 1       min=0              max=4453
vus_max........................: 5200    min=1246           max=5200

NETWORK
data_received..................: 192 MB  756 kB/s
data_sent......................: 13 MB   50 kB/s

이번 테스트를 통해 단순히 Worker 수를 늘리는 것이 항상 성능 향상으로 이어지는 것은 아니라는 점을 확인하였다.

Concurrency 5 구간에서는 Queue 처리 속도가 병목으로 작용했지만, Concurrency를 10까지 증가시키면서 Queue 대기시간이 크게 감소하였다.

반면 Concurrency를 15 이상으로 증가시키자 DB 동시 접근이 급격히 증가하면서 새로운 병목이 발생하였다.

특히 주문 처리 과정에서 수행되는 재고 차감, 주문 생성, 주문 상세 저장, 결제 금액 계산 등의 작업이 동시에 수행되면서 DB 경합 비용이 증가한 것으로 판단된다.

결과적으로 현재 구조에서는 concurrency = 10이 가장 안정적인 설정값이었으며, 오픈런 상황에서도 P95 3.01초 수준의 응답시간을 확보할 수 있었다.



high-stock 테스트 / Queue Drain

  • concurrency = 10 high-stock 테스트 실행중

  • Queue Drain 완료

  • 확인 코드
// Queue backlog 감시 — 30초마다 적체 여부 확인
    // PM2 인스턴스 0번만 실행 (중복 로그 방지)
    @Interval(30000)
    async handleBacklogCheck() {
        if (process.env.NODE_APP_INSTANCE !== '0') return;

        const [waiting, active, delayed] = await Promise.all([
            this.orderQueue.getWaitingCount(),
            this.orderQueue.getActiveCount(),
            this.orderQueue.getDelayedCount(),
        ]);

        const total = waiting + active + delayed;

        if (total >= BACKLOG_WARN_THRESHOLD) {
            if (!this.backlogStartedAt) {
                this.backlogStartedAt = new Date();
                this.logger.warn(

                    `[Queue Backlog] 적체 — ` +
                    `waiting:${waiting} active:${active} delayed:${delayed} total:${total}`
                );
            } else {
                const elapsedSec = Math.round((Date.now() - this.backlogStartedAt.getTime()) / 1000);
                this.logger.warn(
                    `[Queue Backlog] 적체 지속 ${elapsedSec}초 — ` +
                    `waiting:${waiting} active:${active} delayed:${delayed} total:${total}`
                );
            }
        } else if (this.backlogStartedAt) {
            const elapsedSec = Math.round((Date.now() - this.backlogStartedAt.getTime()) / 1000);
            this.backlogStartedAt = null;
            this.logger.log(
                `[Queue Backlog] 해소 완료 — 소요시간: ${elapsedSec}초 | ` +
                `waiting:${waiting} active:${active} delayed:${delayed}`
            );
        }
    }
  • 외부 모니터링을 사용해서 확인하는 것이 좋으나, 현실적인 문제로 30초 보다 Queue 적체가 50건이상일때 로그를 찍게 해놓았다.

테스트 결과

█ THRESHOLDS

http_req_duration{endpoint:live_status}
✓ 'p(95)<3000' p(95)=1.98s

http_req_duration{endpoint:order}
✓ 'p(95)<3000' p(95)=2.2s

http_req_failed
✓ 'rate<0.05' rate=0.00%

order_duration_ms
✓ 'p(95)<3000' p(95)=2.26s

order_fast_rate
✓ 'rate>0.85' rate=99.18%

order_success_rate
✓ 'rate>0.95' rate=99.99%

poller_api_success
✓ 'rate>0.99' rate=100.00%

█ TOTAL RESULTS

checks_total.......: 353605 576.712695/s
checks_succeeded...: 99.72% 352626 out of 353605
checks_failed......: 0.27%  979 out of 353605

✓ [S5 poller] 200
✓ login 200
✓ login has token
✗ [S5] 주문 성공
  ↳  99% — ✓ 118902 / ✗ 7
✗ [S5] 주문 3초 이내
  ↳  99% — ✓ 117937 / ✗ 972

CUSTOM
order_duration_ms..............: avg=813.36ms min=5ms    med=596ms    max=4.04s p(90)=1.99s  p(95)=2.26s
order_fast_count...............: 117937  192.349557/s
order_fast_ms..................: avg=791.61ms min=5ms    med=582ms    max=2.99s p(90)=1.95s  p(95)=2.21s
order_fast_rate................: 99.18%  117937 out of 118909
order_slow_count...............: 972     1.585285/s
order_slow_ms..................: avg=3.45s    min=3s     med=3.45s    max=4.04s p(90)=3.78s  p(95)=3.89s
order_slow_rate................: 0.81%   972 out of 118909
order_success_rate.............: 99.99%  118902 out of 118909
order_tps......................: 118902  193.923425/s
poller_api_success.............: 100.00% 113387 out of 113387

HTTP
http_req_duration..............: avg=813.22ms min=2.35ms med=686.61ms max=4.04s p(90)=1.88s  p(95)=2.05s
  { endpoint:live_status }.....: avg=813.18ms min=2.35ms med=732.89ms max=3.94s p(90)=1.85s  p(95)=1.98s
  { endpoint:order }...........: avg=782.07ms min=5.55ms med=559.43ms max=4.04s p(90)=1.93s  p(95)=2.2s
  { expected_response:true }...: avg=813.21ms min=2.35ms med=686.61ms max=4.04s p(90)=1.88s  p(95)=2.05s
http_req_failed................: 0.00%   7 out of 364745
http_reqs......................: 364745  594.881497/s

EXECUTION
iteration_duration.............: avg=9.36s    min=2.01s  med=8.74s    max=19.8s p(90)=14.92s p(95)=15.69s
iterations.....................: 232296  378.863568/s
vus............................: 4       min=0                max=4000
vus_max........................: 4000    min=1435             max=4000

NETWORK
data_received..................: 3.9 GB  6.3 MB/s
data_sent......................: 45 MB   73 kB/s
  • concurrency = 15 high-stock 테스트 실행중

█ THRESHOLDS

http_req_duration{endpoint:live_status}
✗ 'p(95)<3000' p(95)=3.83s

http_req_duration{endpoint:order}
✗ 'p(95)<3000' p(95)=3.91s

http_req_failed
✓ 'rate<0.05' rate=0.00%

order_duration_ms
✗ 'p(95)<3000' p(95)=4.03s

order_fast_rate
✗ 'rate>0.85' rate=83.90%

order_success_rate
✓ 'rate>0.95' rate=99.99%

poller_api_success
✓ 'rate>0.99' rate=100.00%

█ TOTAL RESULTS

checks_total.......: 282513 459.881617/s
checks_succeeded...: 95.11% 268714 out of 282513
checks_failed......: 4.88%  13799 out of 282513

✓ [S5 poller] 200
✓ login 200
✓ login has token
✗ [S5] 주문 성공
  ↳  99% — ✓ 85677 / ✗ 5
✗ [S5] 주문 3초 이내
  ↳  83% — ✓ 71888 / ✗ 13794

CUSTOM
order_duration_ms..............: avg=1.51s  min=4ms    med=1.15s    max=12.13s p(90)=3.5s   p(95)=4.03s
order_fast_count...............: 71888   117.021056/s
order_fast_ms..................: avg=1.05s  min=4ms    med=874.5ms  max=2.99s  p(90)=2.43s  p(95)=2.69s
order_fast_rate................: 83.90%  71888 out of 85682
order_slow_count...............: 13794   22.454213/s
order_slow_ms..................: avg=3.91s  min=3s     med=3.68s    max=12.13s p(90)=4.85s  p(95)=5.54s
order_slow_rate................: 16.09%  13794 out of 85682
order_success_rate.............: 99.99%  85677 out of 85682
order_tps......................: 85677   139.46713/s
poller_api_success.............: 100.00% 108749 out of 108749

HTTP
http_req_duration..............: avg=1.44s  min=1.87ms med=1.01s    max=11.88s p(90)=3.5s   p(95)=3.94s
  { endpoint:live_status }.....: avg=1.24s  min=1.87ms med=731.18ms max=11.88s p(90)=3.33s  p(95)=3.83s
  { endpoint:order }...........: avg=1.42s  min=3.63ms med=1.07s    max=11.35s p(90)=3.35s  p(95)=3.91s
  { expected_response:true }...: avg=1.44s  min=1.87ms med=1.01s    max=11.88s p(90)=3.5s   p(95)=3.94s
http_req_failed................: 0.00%   5 out of 290143
http_reqs......................: 290143  472.301919/s

EXECUTION
iteration_duration.............: avg=11.13s min=2.01s  med=11.64s   max=26.25s p(90)=15.94s p(95)=16.97s
iterations.....................: 194431  316.499569/s
vus............................: 1       min=0                max=4000
vus_max........................: 4000    min=668              max=4000

NETWORK
data_received..................: 2.8 GB  4.5 MB/s
data_sent......................: 35 MB   58 kB/s

Worker Concurrency를 5, 10, 15 으로 변경하며 반복 측정한 결과, Concurrency 10에서 가장 높은 처리량(TPS)과 가장 낮은 응답시간을 기록하였다.

이후 Concurrency를 15 이상으로 증가시키자 오히려 TPS는 감소하고 응답시간은 증가하였다.

Bull Worker가 동시에 더 많은 Job을 수행하면서 동시에 실행되는 Transaction 수도 증가하였다.

이로 인해 MySQL Connection Pool 사용량 증가, Row Lock 경쟁 증가, Commit 대기 증가로 이어져 결과적으로 Queue 대기시간은 줄었지만 DB 처리시간이 증가하여 전체 응답시간은 오히려 악화되었다.

이는 단순히 Worker 수를 늘리는 것이 성능 향상으로 이어지지 않으며, DB Connection 경쟁과 Transaction 처리 비용 증가로 인해 오히려 병목이 발생할 수 있음을 보여준다.

따라서 현재 c7i.xlarge 환경에서는 Concurrency 10이 최적의 설정값으로 판단하였다.


Redis Reconnect : 테스트 실행 중 임의로 레디스의 연결을 끝어 낸 후, 재연결

 

“프라이머리 장애조치 항목” 로 재연결 테스트를 진행했다.

 

각 노드 에서 레디스 연결 오류 발생

레디스의 재연결을 감지하고 재고 키 를 복구 한다

image.png

Redis 재연결 시 재고 복구 로직이 Queue 처리보다 먼저 실행되면서 Redis와 DB 재고가 일시적으로 불일치했다.

image.png

 

Queue의 적체 시작 및 해소 되었다

image.png

 

job 획득에 최종 실패 및 oversell 리스크 감지

image.png

 

Queue가 아직 DB 재고를 차감 중인데 복구 함수가 Redis를 DB 값으로 덮어썼다.

수정 후

재고 복구 함수 내에서 기존의 실행 및 대기중이던 Queue가 완전히 소진 될 대까지 대기 후 → DB 의 값이 정확 해진 뒤에 Redis의 재고를 복구한다.

waitForQueueDrain() 으로 모든 Queue가 소진된 후 Redis 재고를 복구하도록 수정하였다.

image.pngimage.png

//redisService

   // 재연결 시 실행할 핸들러 등록 (ProductService에서 호출)
    registerReconnectHandler(handler: () => Promise<void>) {
        this._reconnectHandlers.push(handler);
    }


 // close: TCP 연결 끊김 (Redis 재시작 또는 네트워크 단절)
        this.client.on('close', () => {
            this._wasDisconnected = true;
            this.logger.warn('Redis 연결 끊김 — 재연결 시 재고 복구 예정');
        });

        // ready: 재연결 완료
        this.client.on('ready', async () => {
            // Redis 재시작 시 SHA 초기화 → 항상 재로드
            await this.loadLuaScript().catch(e =>
                this.logger.error(`Lua 스크립트 재로드 실패: ${e.message}`)
            );

            if (!this._wasDisconnected) return;  // 최초 연결이면 여기서 종료

            this._wasDisconnected = false;
            this.logger.warn('Redis 재연결 감지 → 재고 복구 핸들러 실행');

            for (const handler of this._reconnectHandlers) {
                //restoreStockForActiveLives() 실행됨
                await handler().catch(e =>
                    this.logger.error(`재고 복구 실패: ${e.message}`)
                );
            }
        });
 // Redis 재연결 시 재고 복구 핸들러 등록
    onModuleInit() {
        //콜백
        this.redisService.registerReconnectHandler(() => this.restoreStockForActiveLives());
    }



      async restoreStockForActiveLives(): Promise<void> {
        // Queue 적체가 남아있으면 DB가 아직 최신값이 아님 → 소진 후 복구
        await this.waitForQueueDrain();

        //복구 로직 및 예외처리 작성

        }




worker kill : 테스트 실행 중, 임의로 pm2 restart

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks(); //애플리케이션이 종료될 때 종료 이벤트 캐치
  }
      //ecosystem.config.js
      kill_timeout: 10000, //pm2 가 종료 신호 보낸 뒤 최대 10초 기다림 
// 주문 모듈
BullModule.registerQueue({
      name: 워커 이름,
      settings: {
      //"주문 Job의 최대 처리시간 × 2~3배
        lockDuration: 30000,     // Job 처리 최대 시간 (30초) — 초과 시 stalled 처리

        stalledInterval: 5000,   // stalled job 체크 주기 (5초)
        maxStalledCount: 1,      // stalled 허용 횟수 — 초과 시 failed 처리
      },
    }),

재시작

image.pngimage.pngimage.png

pid=35955 msg=failed to kill - retrying in 100ms ← kill_timeout 작동 중

2|| ERROR [CRITICAL] Job 최종 실패 — attempts:3/3
error:Connection is not established with mysql database

2|| ERROR [verifyStock 실패] live:77 Connection is not established with mysql database

App [:2] exited with code [0] via signal [SIGINT] ← 정상 종료

분석 결과 TypeORM이 먼저 종료되면서 DB Connection이 닫혔지만, Bull Queue는 아직 처리 중인 Job을 계속 수행하려고 시도하고 있었다.

이 경우 Redis 재고는 이미 차감되었지만 DB 저장은 실패할 수 있으며, 주문 유실 가능성이 존재했다.

종료 순서가 TypeORM 종료 → Bull Queue 처리 중 → DB 저장 실패

즉, Queue보다 DB가 먼저 종료되고 있었다. Worker Kill 시점에 in-flight 상태였던 주문은 유실된다


NestJS의 BeforeApplicationShutdown훅을 이용하여 Queue를 먼저 종료하도록 수정하였다.


@Processor()
export class Processor implements BeforeApplicationShutdown{

    constructor(
        @InjectQueue(QUEUE) private readonly queue: Queue,
    ) { }

     async beforeApplicationShutdown(signal?: string) {
        this.logger.warn(`[Shutdown] ${signal} 수신 → Queue 종료 대기 (진행 중인 Job 완료 후 종료)`);
        try {
            await this.orderQueue.close();
            this.logger.warn('[Shutdown] Queue 정상 종료 완료');
        } catch (e) {
            this.logger.error(`[Shutdown] Queue 종료 실패: ${e.message}`);
        }
    }

    @Process()
    async processOrder() {

    }

    }
1. onModuleDestroy
2. beforeApplicationShutdown ← queue.close() (TypeORM보다 먼저)
3. onApplicationShutdown ← TypeORM 종료

image.pngimage.pngimage.png

beforeApplicationShutdown이 TypeORM보다 먼저 실행되어, DB가 닫히기 전에 in-flight Job이 모두 완료 후, 정상적으로 Queue가 종료 된것을 확인 할 수 있다



결과

이번 개선의 목적은 단순히 주문 처리 속도를 높이는 것이 아니었다.

라이브커머스 특성상 특정 순간에 수백~수천 명의 사용자가 동시에 주문을 시도할 수 있으며, 이 과정에서 과매도(Oversell), 재고 불일치, Queue 적체, Worker 장애와 같은 문제가 발생할 수 있다.

Redis Lua Script를 이용한 원자적 재고 선점, Bull Queue 기반 비동기 처리, Idempotency 적용을 통해 대량 동시 주문 환경에서도 재고 정합성을 유지할 수 있는 구조를 구축하였다.

또한 Redis 재연결, Queue Drain, Worker 강제 종료와 같은 장애 상황을 직접 재현하며 운영 환경에서 발생할 수 있는 문제를 검증하고 보완하였다.

특히 Concurrency 튜닝 과정에서는 Worker 수를 단순히 증가시키는 것이 항상 성능 향상으로 이어지지 않는다는 점을 확인하였으며, 현재 c7i.xlarge 환경에서는 Concurrency 10이 가장 안정적인 설정값임을 확인하였다.

결과적으로 이번 개선을 통해 주문 처리 성능뿐 아니라 장애 상황에서도 정합성과 안정성을 유지할 수 있는 운영 가능한 구조를 구축할 수 있었다.

부하 테스트 결과 Redis와 RDS는 비교적 여유가 있었지만, EC2 CPU 사용률은 지속적으로 높은 상태를 유지했다.

특히 Socket 연결 유지, Polling 요청 처리, 주문 API 처리 등이 모두 애플리케이션 서버에 집중되면서 CPU 사용률이 병목 구간에 진입하는 현상을 확인할 수 있었다.

초기에는 동일한 범용 인스턴스 계열(m5.xlarge)로의 증설도 고려했지만, 병목 원인이 CPU 성능이라고 판단하여 Compute Optimized 계열인 C7i를 우선 검토하였다.

먼저 c7i.xlarge 환경에서 동일한 부하 테스트를 수행하였다.

모든 환경은 동일

EC2 m5.large → c7i.xlarge 변경

시나리오

  • Polling User 90%
  • Ordering User 10%

동시 접속

  • 1000명
  • 2000명
  • 3000명
  • 4000명

C7i.xlarge 신버전 1000명 테스트

  • artillery 소켓연결 1000명 이후 k6 실행 후 7분 30초 경과

1000명 규모에서 충분히 안정적인 성능을 보였다.


C7i.xlarge 신버전 2000명 테스트

  • artillery 소켓연결 2000명 이후 k6 실행 후 7분 30초 경과

2000명 규모에서 충분히 안정적인 성능을 보였다.


C7i.xlarge 신버전 3000명 테스트

  • artillery 소켓연결 3000명 이후 k6 실행 후 7분 30초 경과

3000명 규모에서 충분히 안정적인 성능을 보였다.


C7i.xlarge 신버전 4000명 테스트

  • artillery 소켓연결 4000명 이후 k6 실행 후 7분 30초 경과

 

4000명 규모에서도 충분히 안정적인 성능 보였다 .


신버전 5000명 테스트

  • artillery 소켓연결 5000명 이후 k6 실행 후 7분 30초 경과

구조 개선 후 c7i.xlarge 환경에서 5000명 규모까지 안정적으로 처리할 수 있었다.

 

-결과-



테스팅 목표 4000명 동시 시청 방송중 극한의 상황 에서도 3초 이내에 응답하는 환경 만들기


오픈런 테스트 (극한상황에서 집중 테스트)

4000명이 방송을 보는 도중 갑자기 1200명이 30초 안에 동시에 주문 버튼 연속으로 누르면 서버가 버티는가?

(Redis Lua / Bull Queue / DB 한계 확인)

테스트 시나리오

00:00 ~ 01:00 - 시청자 4,000명 접속 - 상품 상태 주기 조회

01:00 - 오픈런 시작 - 구매자 1,200명 동시 진입

01:00 ~ 01:30 - 상품 조회 - 주문 요청 집중 발생

01:35 - 오픈런 종료

04:00 - 테스트 종료

  • 시청자 그룹 : 상품 목록 및 상태를 10~15초 간격으로 반복 조회하며, 오픈런이 발생하는 순간에도 지속적으로 API를 호출하도록 구성하였다.
  • 구매자 그룹: 각 사용자는 로그인 후 상품 목록을 조회하고, 별도의 대기 시간 없이 즉시 주문을 시도하였다. 또한 1~3초 간격으로 주문을 재시도하여 실제 사용자가 반복적으로 구매 버튼을 누르는 상황을 재현하였다. 특히 sleep 시간을 최소화하여 서버가 감당할 수 있는 최대 처리량을 확인하는 데 초점을 맞추었다.

서버가 장애 없이 모든 요청을 처리했다

Redis Lua Script 기반 재고 선점 로직 역시 정상적으로 동작하였으며, 과매도(Oversell) 현상 없이 모든 주문 요청이 처리되었다.

다만 주문 응답 시간(P95)은 4.77초로 측정되어 목표 기준인 3초 이내를 만족하지 못하였다.



솔드 아웃 테스트

품절(Sold Out) 테스트는 동시성 환경에서 재고 정합성이 올바르게 유지되는지를 검증하기 위한 시나리오이다.

2,800명의 시청자와 1,200명의 구매자가 동시에 접속하는 상황을 구성하였다.

구매자는 상품 조회 과정을 생략하고 즉시 주문을 시도하도록 설정하였으며, 재고가 소진된 이후에도 1~3초 간격으로 지속적으로 주문을 재시도하였다.

0:00 - 폴러 2,800명 진입 - 쇼퍼 1,200명 진입

00:10 - 총 4,000명 부하 유지 - 주문 집중 발생

00:10 ~ 03:10 - 구매자 지속 주문 시도 - 재고 소진 후에도 반복 요청

03:10 - 테스트 종료

재고 수량만큼만 정확하게 판매하고 나머지는 정상적으로 거절하는 것

 

█ TOTAL RESULTS

checks_total.......: 268639  1253.502114/s
checks_succeeded...: 100.00% 268639 out of 268639
checks_failed......: 0.00%   0 out of 268639

✓ login 200
✓ login has token
✓ [S4] 성공/품절/중복차단
✓ [S4] 서버 에러 없음
✓ [S4 poller] 200

CUSTOM
error_count....................: 0       0/s
order_duration_ms..............: avg=54.41ms min=2ms    med=16ms    max=1.15s    p(90)=130ms    p(95)=180ms
order_success_rate.............: 0.26%   300 out of 111268
oversell_detected..............: 0       0/s
poller_api_success.............: 100.00% 43703 out of 43703
soldout_409_count..............: 53870   251.363945/s
success_count..................: 300     1.399836/s

HTTP
http_req_duration..............: avg=53.2ms  min=2.19ms med=14.83ms max=979.02ms p(90)=129.24ms p(95)=180.06ms
  { expected_response:true }...: avg=50.95ms min=2.19ms med=13.23ms max=979.02ms p(90)=128.4ms  p(95)=180.5ms
http_req_failed................: 71.05%  110968 out of 156172
http_reqs......................: 156172  728.717469/s

EXECUTION
iteration_duration.............: avg=5.02s   min=1s     med=2.45s   max=16.83s   p(90)=13.3s    p(95)=14.19s
iterations.....................: 154971  723.113457/s
vus............................: 3       min=0                max=4000
vus_max........................: 4000    min=1068             max=4000

NETWORK
data_received..................: 72 MB   338 kB/s
data_sent......................: 30 MB   138 kB/s

HTTP 실패율은 71.05%로 높게 보이지만, 이는 재고 소진 이후 발생한 409 품절 응답이 k6 기준에서 실패로 집계되었기 때문이다.

결과적으로 주문 성공 건수는 300건으로 제한되었고, 과매도 감지 건수는 0건, 서버 오류 역시 0건으로 측정되었다.

이는 Redis Lua Script 기반 재고 선점 로직이 대량 동시 주문 상황에서도 재고를 정확하게 보호했음을 의미한다.

또한 주문 응답 시간 P95는 180ms로 측정되어, 품절 상황에서도 응답 지연 없이 빠르게 정상 거절이 이루어졌음을 확인할 수 있었다.



High Stock 테스트

오픈런 테스트가 서버의 생존성을 검증하고, 품절 테스트가 재고 정합성을 검증하는 시나리오였다면, High Stock 테스트는 서버가 실제로 초당 몇 건의 주문을 처리할 수 있는지 측정하기 위한 시나리오이다.

이번 테스트에서는 품절로 인한 409 응답이 발생하지 않도록 충분한 재고를 준비한 상태에서 부하를 발생시켰다.

즉, 재고 부족이라는 변수를 제거하고 순수하게 주문 처리 성능(TPS)을 측정하는 데 목적이 있다.

00:00 ~ 01:00 - 폴러 2,800명 접속 - 쇼퍼 1,200명 접속

01:00 ~ 09:00 - 총 4,000명 유지 - 지속적인 조회 및 주문 발생

09:00 - 테스트 종료

  • 시청자 그룹: 상품 상태 API를 10~15초 간격으로 반복 호출하여 실제 서비스에서 발생하는 조회 트래픽을 재현하였다.
  • 구매자 그룹:

오픈런 테스트와 달리 주문 직후 즉시 재시도하지 않고 2~5초의 대기 시간을 두었다.

상품 목록 조회
↓
상품 선택
↓
옵션 조회 (옵션 상품인 경우)
↓
주문
↓
2~5초 대기
↓
반복

테스트 7분 30초 후 서버 상태

 

█ TOTAL RESULTS

checks_total.......: 280572 457.801329/s
checks_succeeded...: 94.24% 264425 out of 280572
checks_failed......: 5.75%  16147 out of 280572

✓ [S5 poller] 200
✓ login 200
✓ login has token
✗ [S5] 주문 성공
  ↳  99% — ✓ 84315 / ✗ 3
✗ [S5] 주문 3초 이내
  ↳  80% — ✓ 68174 / ✗ 16144

CUSTOM
order_duration_ms..............: avg=1.57s min=5ms    med=1.17s    max=8.68s  p(90)=3.73s  p(95)=4.2s
order_fast_count...............: 68174   111.237571/s
order_fast_ms..................: avg=1.03s min=5ms    med=779ms    max=2.99s  p(90)=2.44s  p(95)=2.71s
order_fast_rate................: 80.85%  68174 out of 84318
order_slow_count...............: 16144   26.341704/s
order_slow_ms..................: avg=3.88s min=3s     med=3.76s    max=8.68s  p(90)=4.76s  p(95)=5.08s
order_slow_rate................: 19.14%  16144 out of 84318
order_success_rate.............: 99.99%  84315 out of 84318
order_tps......................: 84315   137.57438/s
poller_api_success.............: 100.00% 109536 out of 109536

HTTP
http_req_duration..............: avg=1.5s  min=2.27ms med=984.93ms max=9.17s  p(90)=3.76s  p(95)=4.17s
  { endpoint:live_status }.....: avg=1.23s min=2.27ms med=626.33ms max=7.5s   p(90)=3.47s  p(95)=3.99s
  { endpoint:order }...........: avg=1.51s min=5.67ms med=1.11s    max=8.68s  p(90)=3.66s  p(95)=4.15s
  { expected_response:true }...: avg=1.5s  min=2.27ms med=984.91ms max=9.17s  p(90)=3.76s  p(95)=4.17s
http_req_failed................: 0.00%   3 out of 288077
http_reqs......................: 288077  470.047024/s

EXECUTION
iteration_duration.............: avg=11.2s min=2.01s  med=11.7s    max=23.51s p(90)=15.85s p(95)=16.92s
iterations.....................: 193854  316.306042/s
vus............................: 2       min=0                max=4000
vus_max........................: 4000    min=920              max=4000

NETWORK
data_received..................: 2.8 GB  4.5 MB/s
data_sent......................: 35 MB   57 kB/s

이번 테스트를 통해 현재 주문 시스템은 약 138 TPS 수준의 처리량을 안정적으로 처리할 수 있음을 확인하였다.

주문 성공률은 99.99%, 서버 오류율은 0%로 측정되었으며, 약 84,000건 이상의 주문이 정상 처리되었다.

반면 응답시간 측면에서는 개선이 필요한 것으로 확인되었다.

주문 응답시간 P95는 약 4.2초로 측정되었으며, 전체 주문의 약 19%는 3초를 초과하였다.

흥미로운 점은 서버 CPU, Redis CPU 사용률이 포화 상태가 아니었음에도 응답시간이 증가했다는 점이다.

이는 단순한 CPU 부족 문제가 아니라 Queue 처리 대기, DB 처리 대기, 네트워크 지연 등 시스템 내부의 대기시간이 누적된 결과로 판단된다.

따라서 본 테스트는 현재 구조가 높은 처리량을 확보했음을 확인하는 동시에, 향후 응답시간 개선을 위해 추가적인 병목 분석이 필요함을 보여주었다.


 


종합 현실 시나리오

앞선 테스트들은 각각 하나의 목적에 집중하였다.

하지만 실제 라이브커머스 방송은 단일 상황만 발생하지 않는다.

시청자가 방송을 시청하고, 일반 구매가 발생하고, 특정 시점에는 오픈런 이벤트가 열리며, 방송 종료 직전에는 마감 특가 구매가 몰리는 등 다양한 트래픽 패턴이 연속적으로 발생한다.

따라서 마지막 단계에서는 실제 방송 15분을 그대로 재현한 종합 시나리오를 구성하여 테스트를 수행하였다.

  • 방송 중 지속적인 조회 트래픽 처리
  • 일반 구매자의 반복 주문 처리
  • 오픈런 이벤트 발생 시 재고 정합성 유지
  • 마감 특가 이벤트 처리
  • 전체 방송 구간 동안 서비스 안정성 유지

시나리오 타임라인

00:00 ~ 01:00
방송 시작
시청자 4,000명 점진 진입

01:00 ~ 03:00
일반 구매 시작
600명 구매자 활동

03:00 ~ 03:35
오픈런 이벤트
500명 동시 진입
한정 상품 300개 판매

03:35 ~ 11:00
일반 방송 지속

11:00 ~ 13:30
마감 특가
300명 추가 구매

13:30 ~ 15:00
방송 종료

시청자 (Poller)

시청자는 실제 방송을 시청하는 사용자로 가정하였다.

10~15초 간격으로 상품 상태 API를 조회하며 지속적으로 조회 트래픽을 발생시킨다.

구매자 수가 증가하는 구간에서는 전체 접속자 수가 4,000명을 유지하도록 시청자 수를 조정하였다.

일반 구매자

일반 구매자는 실제 쇼핑 행동과 유사하게 구성하였다.

상품 목록 조회 → 상품 선택 → 주문 과정을 수행하며,

30~60초 간격으로 반복 구매한다.

오픈런 상품은 제외하여 일반 방송 구매 행동만 측정하였다.

오픈런 구매자

오픈런 구매자는 특정 한정 상품에 집중하도록 구성하였다.

500명이 5초 이내에 동시에 진입하며,

30초 동안 집중적으로 구매를 시도한다.

재고 300개가 모두 소진되면 이후 요청은 409(품절) 응답을 반환한다.

이를 통해 Redis Lua Script 기반 재고 차감 로직이 실제 오픈런 상황에서도 과매도 없이 동작하는지 검증하였다.

마감 특가 구매자

마감 특가 구매자는 방송 종료 직전 구매를 서두르는 사용자를 가정하였다.

15~30초 간격으로 반복 구매하며 일반 구매자보다 높은 구매 빈도를 가진다.

이를 통해 방송 종료 직전 발생하는 트래픽 집중 현상을 재현하였다.

 



최종 결과

Redis Lua Script 기반 재고 선점과 Bull Queue 기반 비동기 주문 처리 구조를 적용한 후 다양한 부하 테스트를 수행하였다.

단순 TPS 측정이나 스트레스 테스트에 그치지 않고,

  • 오픈런 상황(S3)
  • 품절 및 재고 정합성 검증(S4)
  • 최대 처리량 측정(S5)
  • 실제 방송 시뮬레이션(S7)

을 각각 분리하여 검증하였다.

특히 S7 종합 현실 시나리오에서는 4,000명의 시청자와 1,400명의 구매자가 참여하는 15분 방송을 재현하였다.

테스트 결과

  • 일반 구매 8,300건 처리
  • 오픈런 상품 300건 판매
  • 품절 응답 4,131건 정상 처리
  • 마감 특가 2,140건 처리
  • 과매도 0건
  • 서버 오류 0건

을 기록하였다.

또한 Redis Lua Script 기반 재고 선점 구조를 통해 대량 동시 주문 환경에서도 재고 정합성을 유지할 수 있음을 확인하였고, Bull Queue 기반 비동기 처리 구조를 통해 높은 처리량에서도 안정적인 주문 처리가 가능함을 검증하였다.

다만 오픈런(S3) 및 High Stock(S5) 시나리오에서는 주문 응답시간 P95가 약 4~5초 수준으로 측정되어 목표로 설정한 3초 이내 응답시간에는 도달하지 못하였다.

이는 서버 자원 부족보다는 Queue 처리 대기, DB 처리 대기 등의 내부 병목 가능성을 시사하며, 향후 Worker Concurrency 조정, 주문 처리 로직 최적화, Queue 처리 구조 개선 등을 통해 추가적인 성능 개선이 가능할 것으로 판단된다.

이번 개선을 통해 단순히 성능을 높이는 것을 넘어, 대규모 동시 접속 환경에서도 정합성과 안정성을 유지할 수 있는 주문 처리 구조를 구축할 수 있었다.

Redis Cache, Redis Lua Script, Bull Queue 를 적용한 이후 DB 병목은 상당 부분 해소되었다.

하지만 예상과 달리 2000명 환경부터 응답시간이 다시 급격히 증가하였다.

처음에는 Bull Queue 또는 Redis가 새로운 병목이 된 것으로 생각하였다.

신 버전 2000명 ~ 3000명 테스트 도중에 아래와 같은 결과가 나왔다.


신버전

  • artillery 소켓연결 2000명 이후 k6 실행 후 7분 30초 경과 (서버상태)

신버전 2000명 테스트 에서 Redis 나 Bull Queue가 병목이 아니라 EC2 CPU가 먼저 죽었다.

  • Polling과 Order가 느려졌다는 것이 핵심!

Redis / Queue/ DB 문제라면 : Pollding 빠름 , Order 느림 이여야 하지만, 실제로는 거의 동일하다.

Redis Cache, Redis Lua Script, Bull Queue 적용 이후 DB 병목은 상당 부분 해소되었다.

하지만 동시접속자 2000명 환경에서는 EC2 CPU 사용률이 100%에 도달하였고, 시스템 Load Average는 155%를 기록하였다.

주문 API와 상품 조회 API가 동시에 느려진 점을 고려하면, 해당 시점의 병목은 DB가 아닌 애플리케이션 서버 자체의 CPU 자원 부족으로 판단된다.

즉, 구조 개선을 통해 병목 지점이 DB에서 애플리케이션 서버로 이동한 것을 확인할 수 있었다.

 

또한 신버전 3000명 테스트에서

polling 성공률 99.99%

order 성공률 97.85%

로 기능은 살아있지만 응답시간이 50초이다.

Redis / Bull / DB 특정 계층이 병목이면 Order만 느려져야 하지만, 거의 모든 API가 느려졌다.

로그 및 Grafana 분석 결과, Redis 또는 Queue 계층의 병목보다는 애플리케이션 서버 CPU 포화로 인한 전체 응답 지연 현상으로 판단되었다.

실제로 주문 API와 조회 API가 거의 동일한 응답시간을 보였으며, 이는 특정 기능이 아닌 서버 전체가 처리 능력 한계에 도달했음을 의미한다.


 


 

진짜 병목 찾기!

 

Bull Queue 가 진짜 병목인지 확인

worker 에 실행시간 로그 추가

 

 

주문 앞단의 실행 되는 API (상품리스트 불러오기 캐싱하기)

await this.redis.set(cacheKey, JSON.stringify(products), 'EX', 10);

  • Cache Stampede 문제 발생

처음 캐싱을 이렇게 시도 했었는데, TTL 10초가 완료된 후 1800명이 10초마다 호출하는 상황에서 캐시가 만료되는 상황에 수백 명이 동시에 Redis Miss 발생 하면서 모든 요청이 DB 로 가는 현상이 발생했다.

// 1차: 캐시 히트
  const cached = await this.redis.get(cacheKey);
        if (cached !== null) {
            if (cached === '__NONE__') throw new NotFoundException('정보없음');
            return JSON.parse(cached);
        }

// 캐시 미스 → 락 획득 시도 (NX: 없을 때만 SET, PX: 3초 자동 해제)
        if (lock !== 'OK') {
            for (let i = 0; i < 20; i++) {
                await new Promise(r => setTimeout(r, 100));
                const retried = await this.redis.get(cacheKey);
                if (retried !== null) {
                    if (retried === '__NONE__') throw new NotFoundException('정보없음');
                    return JSON.parse(retried);
                }
            }
            throw new NotFoundException('정보없음');
        }

// 락 보우자면  DB 조회

 


적용 후 결과

█ THRESHOLDS

http_req_duration{endpoint:live_status}

✗ 'p(95)<500' p(95)=8s

http_req_duration{endpoint:order}

✗ 'p(95)<2000' p(95)=7.87s

http_req_failed

✓ 'rate<0.05' rate=0.17%

order_duration_ms

✗ 'p(95)<2000' p(95)=7.87s

order_success_rate

✓ 'rate>0.90' rate=98.41%

poller_api_success

✓ 'rate>0.99' rate=99.99%

 

캐시 스탬피드 문제는 존재했고 어느 정도 해결 되었지만 목표치에는 함잠 멀었다.

Polling = 8s / Order = 7.87s 거의 똑같았다. 즉 서버 전체가 느려지는 상황이였다.


Redis Hit Rate, Queue 상태 확인

상품리스트 캐싱 부분에

console.log([product list] HIT redis=${redisGetMs}ms total=${Date.now() - t0}ms);

 

console.log([product list] MISS→SET db=${dbMs}ms total=${Date.now() - t0}ms);

 

테스트 시나리오 실행중 Queue

{ "waiting": 0,

"active": 4,

"completed": 1000,

"failed": 0,

"delayed": 0,

"paused": 0 }

 

로 확인결과 Redis , Queue, DB 모두 정상임을 확인 할 수 있었다.


NEST API 

주문 API 에

   async orderService(매개변수) {
        const _t0 = Date.now();
        console.log(`[ORDER] ENTER`);
    }

Bull 넣기 직전에

  console.log(`[ORDER] BEFORE_QUEUE elapsed=${Date.now() - _t0}ms`);

응답 직전에

  console.log(`[ORDER] RESPONSE elapsed=${Date.now() - _t0}ms`);

 

█ THRESHOLDS

http_req_duration{endpoint:live_status}

✗ 'p(95)<500' p(95)=7.34s

http_req_duration{endpoint:order}

✗ 'p(95)<2000' p(95)=7.3s

http_req_failed

✓ 'rate<0.05' rate=0.17%

order_duration_ms

✗ 'p(95)<2000' p(95)=7.3s

order_success_rate

✓ 'rate>0.90' rate=98.18%

poller_api_success

✓ 'rate>0.99' rate=99.96%

 

k6 P95 = 7~8 초, 서버 로그는 ORDER RESPONSE = 5ms ~ 800ms 임을 확인 할 수 있었다.

 

즉, Nest 서버는 느리지 않고 , BullQueue 도 적체가 없고, Redis 도 문제가 없었다.

그러나 k6 http_req_duration p95 = 7.3초 가 나온다

 

K6 의 7.3초는 DNS+TCP연결+TLS+대기+응답 등 모두 포함된 시간이다.


pm2 monit 그리고 top

 

PM2 monit를 통해 Event Loop Latency, Active Handle 수, Active Request 수를 확인하였다.

Event Loop Latency는 약 250ms 수준이었고 Active Request 역시 1~2건 수준으로 유지되고 있었다. 반면 Active Handle 수는 4700개 이상으로 증가해 있었으며, 이는 대량의 WebSocket 연결이 유지되고 있음을 의미했다.

즉 애플리케이션 내부 로직이나 Queue 적체보다는 Socket 연결 유지에 따른 시스템 자원 사용을 의심하게 되었다.

 

 

 

반면, 정상적인 서버는 si 1~3% 정도인데 현재 si 10.3% 엄청 높다는 것을 볼 수 있다.

즉 CPU 가 Nest, Redis, Queue 를 계산 하는게 아니라 TCP 패킷, 소켓처리, Nginx, 네크워크 인터럽트를 처리하느라 시간을 쓰고 있다는 것을 확인 할 수 있었다.

 


현재 테스트는 Artillery 로 2000개의 소켓 연결 후, K6로 테스트 하는 구조이다.

Artillery(소켓 연결) 없이 k6만 테스트 했을때 결과가

 

 

로, 즉 실시간 socket 연결이 애플케이션 서버 CPU를 점유 하고있다는 사실을 확인 할 수 있었다.


결론 : CPU 업그레이드 필요하다

 

처음에는 DB Lock과 재고 차감 로직이 병목의 원인이라고 생각했다.

그래서 Redis Lua Script를 이용한 원자적 재고 차감과 Bull Queue 기반 비동기 주문 처리 구조를 도입하였다.

실제로 적용 후 DB 부하는 크게 감소했고, 오버셀 문제 역시 안정적으로 제어할 수 있었다.

하지만 기대와 달리 대규모 부하 테스트에서는 응답 시간이 여전히 증가하는 현상이 발생했다.

Redis, Queue, DB를 하나씩 의심하며 로그와 모니터링을 추가했고, Cache Stampede 문제도 발견하여 개선하였다.

그럼에도 전체 응답 시간이 느려지는 현상은 계속 발생했다.

 

결국 Artillery를 이용한 대량 Socket 연결과 k6 HTTP 부하를 동시에 수행한 환경에서 EC2 CPU 사용률이 100%에 도달하고 있었음을 확인하였다.

 

흥미로운 점은 이 과정에서 병목 지점이 이동했다는 것이다.

기존 구조에서는 DB가 먼저 한계에 도달했지만, Redis Cache, Lua Script, Bull Queue를 적용한 이후에는 DB 병목이 상당 부분 해소되었고, 오히려 애플리케이션 서버의 CPU가 새로운 한계점으로 나타났다.

이번 개선 과정에서 얻은 가장 큰 교훈은 단순히 특정 기술을 도입하는 것이 아니라,

  • 어디에서 경쟁이 발생하는지
  • 어떤 계층이 트래픽을 흡수해야 하는지
  • 병목이 제거되면 다음 병목은 어디로 이동하는지

를 지속적으로 확인하는 것이 중요하다는 점이었다.

성능 최적화는 하나의 기술로 해결되는 문제가 아니라, 병목을 찾아가며 시스템 전체를 이해하는 과정에 더 가깝다는 것을 배울 수 있었다.

라이브커머스 프로젝트의 기존 구조는 캐시를 사용하지 않고, 재고 조회, 주문 등등 모든것을 DB로 사용하는 구조였다.

구조를 수정 후 Redis LuaScript 와 BullQueue 를 사용하여 상품의 재고 정합성과,

비동기로 주문을 처리하는 구조로 DB앞단의 Buffer 를 만들어 안정화 시켰다.

 

테스트는 artillery 로 목표 명수인 n명 분의 소켓을 발생시킨 후 유지, K6 파일을 사용하여 n명 유저 수 와 주문수(10%) 다르게 했고, 다른 조건은 모두 똑같이 진행했다.

 

 

구버전 테스트 환경

- Backend: NestJS

- DB: MySQL

- Redis: 없음 (구버전)

- 서버: EC2 m5.large

- DB: RDS MySQL

- 테스트 도구: k6(API) / artillery (socket)

 

시나리오

- Polling User 90%

- Ordering User 10%

 

동시 접속

- 1000명

- 2000명

- 3000명

- 4000명

 

 

구버전 1000명

- artillery 소켓연결 1000명 이후 k6 실행 후 7분 30초 경과 (서버상태)

█ THRESHOLDS

http_req_duration{endpoint:live_status}

✗ 'p(95)<500' p(95)=3.12s

http_req_duration{endpoint:order}

✗ 'p(95)<2000' p(95)=3.73s

http_req_failed

✓ 'rate<0.05' rate=0.04%

order_duration_ms

✗ 'p(95)<2000' p(95)=3.73s

order_success_rate

✓ 'rate>0.90' rate=99.41%

poller_api_success

✓ 'rate>0.99' rate=100.00%

 

동시 시청자 1000명 환경에서는 주문 성공률 99.41%를 기록하며 기능적으로는 안정적으로 동작했다.

다만 상품 상태 조회 API p95 응답시간은 3.12초, 주문 API p95 응답시간은 3.73초로 목표치(500ms, 2s)를 크게 초과하였다.

특히 실시간 상품 상태 조회 트래픽이 DB에 직접 집중되면서 조회 지연이 발생하였고,

주문 처리 역시 재고 조회 및 차감 과정에서 DB 락 경합 및 커넥션 대기의 영향을 받는 것으로 추정된다.

즉, 1000명 규모에서는 서비스는 유지되지만 그 이상의 트래픽 증가에 대응할 수 있는 확장성은 부족한 상태였다.

 

 

 

구버전 2000명

- artillery 소켓연결 2000명 이후 k6 실행 후 7분 30초 경과 (서버상태)

█ THRESHOLDS

http_req_duration{endpoint:live_status}

✗ 'p(95)<500' p(95)=18.17s

http_req_duration{endpoint:order}

✗ 'p(95)<2000' p(95)=19.31s

http_req_failed

✓ 'rate<0.05' rate=0.06%

order_duration_ms

✗ 'p(95)<2000' p(95)=19.31s

order_success_rate

✓ 'rate>0.90' rate=98.89%

poller_api_success

✓ 'rate>0.99' rate=100.00%

 

동시 접속자 2000명 환경에서는 주문 API p95가 19.31초, 상품 조회 API p95가 18.17초까지 증가하였다.

기능적으로는 동작하였으나, 실시간 서비스로 보기 어려운 수준의 응답 지연이 발생하였다.

 

 

 

구버전 3000명

- artillery 소켓연결 3000명 이후 k6 실행 후 7분 30초 경과 (서버상태)

█ THRESHOLDS

http_req_duration{endpoint:live_status}

✗ 'p(95)<500' p(95)=42.05s

http_req_duration{endpoint:order}

✗ 'p(95)<2000' p(95)=43.08s

http_req_failed

✓ 'rate<0.05' rate=0.07%

order_duration_ms

✗ 'p(95)<2000' p(95)=43.08s

order_success_rate

✓ 'rate>0.90' rate=98.49%

poller_api_success

✓ 'rate>0.99' rate=100.00%

 

주문 성공률은 98.49% 이지만 응답시간이 약 43초 이다, 즉 주문 버튼을 클릭하면 — 43초 뒤에 응답이 온다.

DB가 처리 가능한 처리량을 초과하여 락 대기와 커넥션 대기가 누적된 결과로 판단된다.

 

 

 

구버전 4000명

- artillery 소켓연결 4000명 이후 k6 실행 후 7분 30초 경과(서버상태)

█ THRESHOLDS

http_req_duration{endpoint:live_status}

✗ 'p(95)<500' p(95)=1m0s

http_req_duration{endpoint:order}

✗ 'p(95)<2000' p(95)=1m0s

http_req_failed

✗ 'rate<0.05' rate=19.89%

order_duration_ms

✗ 'p(95)<2000' p(95)=1m0s

order_success_rate

✗ 'rate>0.90' rate=76.43%

poller_api_success

✗ 'rate>0.99' rate=79.91%

 

4000명 환경에서는 요청 타임아웃이 다수 발생했고, 성공률은 76.43% 까지 하락하였다.

모든 조회/주문/재고처리 등 로직이 DB에 직접 의존하는 구조로 커넥션 풀과 락 경합이 누적된 결과로 분석된다.

더 이상 서비스 운영이 어려운 수준의 장애 상태가 확인되었다.

 

 

 

구버전 구조의 정리

1000명 환경에서는 기능적으로 서비스가 동작하였다.

2000명 환경부터 응답시간이 급격히 증가하기 시작하였으며,

3000명 환경에서는 주문 API p95가 43초를 초과하였다.

4000명 환경에서는 대량의 timeout이 발생하였고, 주문 성공률은 76.43%까지 감소하였다.

모든 요청이 DB에 직접 의존하는 구조로 인하여, 동시 접속자가 증가하면서

- DB Connection Pool 대기

- Lock 경합

- Transaction 대기

가 누적 되었고, 결과적으로 응답시간이 급격하게 증가하였다.

 

 

 

신버전 테스트 환경

- Backend: NestJS

- DB: MySQL

- Redis: 없음 (구버전)

- 서버: EC2 m5.large

- DB: RDS MySQL

-Redis cache + LuaScript + BullQueue 

- 테스트 도구: k6(API) / artillery (socket)

 

시나리오

- Polling User 90%

- Ordering User 10%

 

동시 접속

- 1000명

- 2000명

- 3000명

 

 

---

신버전 1000명

- artillery 소켓연결 1000명 이후 k6 실행 후 7분 30초 경과 (서버상태)

 

동일한 1000명 환경에서도 성능이 크게 개선되었다.

상품 상태 조회 API p95는 3.12초에서 142ms로, 주문 API p95는 3.73초에서 193ms로 감소하였다.

특히 주문 API 평균 응답시간은 1.69초에서 53ms까지 감소하여 약 32배 이상의 개선 효과를 확인할 수 있었다.

동일한 부하 환경에서도 모든 Threshold를 만족하였으며, Redis를 활용하여 조회 트래픽을 DB로부터 분리한 효과가 크게 나타났다.

 

 

 

신버전 2000명

- artillery 소켓연결 2000명 이후 k6 실행 후 7분 30초 경과 (서버상태)

 

 

 

구버전과 비교하면, 성능은 좋아졌지만 신버전의 경우 1000명 → 2000명의 결과가 193ms → 13s 로 뛰어버렸다.

Redis Cache, Redis Lua Script, Bull Queue를 적용한 이후에도 2000명 환경에서는 응답시간 증가가 발생하였다.

다만 Grafana 확인 결과 CPU 사용률이 100%에 도달하였고 System Load가 155%를 기록하였다.

Polling API와 Order API가 유사한 응답시간을 보인 점을 고려하면, 해당 시점의 병목은 Redis 또는 DB가 아니라 애플리케이션 서버의 CPU 자원 부족으로 판단된다.

 

만약 Redis 또는 DB 가 병목 이었다면 특정 API만 응답시간이 증가하는 경향을 보였을 것이다.

그러나 Polling API와 Order API가 동시에 지연되었고,Grafana 상 CPU 사용률이 100%에 도달한 점을 고려하면 애플리케이션 서버가 처리 가능한 Request 수 자체를 초과한 것으로 판단된다. 이는 Redis Cache, Lua Script, Bull Queue 적용을 통해 기존의 DB 병목이 상당 부분 해소되었음을 의미한다.

 

즉, 기존 구조의 DB 병목이 상당 부분 해소되었으며 병목 지점이 애플리케이션 서버로 이동하였다고 예상해 볼 수 있다.

 

 

 

신버전 3000명

- artillery 소켓연결 3000명 이후 k6 실행 후 7분 30초 경과 (서버상태)

 

처음에는 Redis Lua Script와 Queue를 적용하면 동시성 문제와 성능 문제가 모두 해결될 것으로 생각했다.

 

그러나 실제 부하 테스트를 진행하면서 병목은 사라지는 것이 아니라 다른 계층으로 이동한다는 사실을 확인할 수 있었다.

기존 구조에서는 DB Lock과 Connection Pool이 한계였지만, 개선된 구조에서는 CPU가 새로운 병목 지점으로 나타났다.

이를 통해 성능 개선은 특정 기술을 도입하는 것이 아니라, 시스템 전체의 병목을 지속적으로 찾아가는 과정이라는 점을 체감할 수 있었다.

 

 

결과

이번 개선의 핵심은 단순히 응답시간을 줄인 것이 아니었다.

기존 구조는 모든 요청이 DB에 직접 접근하는 구조였기 때문에 트래픽 증가 시 DB가 가장 먼저 한계에 도달했다.

반면 개선된 구조는

- Redis Cache로 조회 트래픽 분산

- Lua Script로 원자적 재고 차감

- Bull Queue로 DB Write Buffer 구성

을 통해 DB 부하를 크게 감소시켰다.

그 결과 병목 지점은 DB에서 애플리케이션 서버의 CPU로 이동하였다.

이는 시스템이 처리할 수 있는 최대 트래픽이 증가했음을 의미하며, 실제 서비스 운영 환경에서도 훨씬 안정적인 구조를 확보할 수 있었다.

이번 경험을 통해 동시성 문제는 단순히 Lock 하나로 해결되는 것이 아니라

- 어디서 경쟁이 발생하는지

- 어떤 계층에서 트래픽을 흡수할지

- 장애 상황에서도 정합성을 어떻게 보장할지

를 함께 설계해야 한다는 점을 배울 수 있었다.

 

기존 주문 시스템은 주문 요청이 들어올 때마다.

곧바로 DB의 재고를 조회하고, 차감하고, 주문 테이블에 바로 업데이트 하는 구조였다.

평소에는 문제가 없었지만,

동시에 주문요청이 몰리기 시작하자 다음과 같은 문제가 발생했다.

  • DB connection Pool 로 모든 요청이 DB로 몰려 커넥션을 잡고 놓아 주질 않았고
  • 락(Lock)경합으로 인해 전체 처리량이 저하되어 트랜잭션 대기 시간이 증가 되었고
  • 순간 트래픽 응답 지연으로 이어졌다.

lock: { mode: 'pessimistic_write' }

사용으로 동일 재고에 대한 동시 수정은 어느 정도 방어하고 있었지만,

트랜잭션 대시 시간 증가로, 전체 처리량이 급격히 떨어지는 문제가 있었다.

특히 동시 시청자 수를 기존 1000명에서 5000명까지 늘려야 하는 상황에서,

기존 로직 : [주문요청] → DB 재고 조회 → DB 재고 차감 → DB 주문 생성

은 한계가 명확했기에, 다음 구조를 적용했다.

  • Redis Cooldown
  • Lua Script 기반 원자적 재고 차감
  • Bull Queue 기반 비동기 주문 처리

기존 로직의 문제점은 다음과 같았다.

네트워크 재시도나, 사용자의 연속 클릭으로 인해 동일한 주문 요청이 여러번 들어와 불필요한 DB부하를 가중 시켰다.

→ 라이브 커머스 특성상 재고가 한정된 상품을 구매하기 위해 자주 일어 나는 상황 이였다.

모든 요청이 DB로 직접 들어가면서 Connection pool 고갈 현상이 발생했고,

거의 동시에 들어온 요청이 같은 재고를 읽어가는 race condition 가능성이 있었고,

사용자의 주문 버튼을 연속으로 클릭하거나, 네트워크 재시도로 동일 요청이 여러번 올 수 있었다.

이 문제를 해결하기 위해

Redis: 단순 캐시 용도가 아닌, 실시간 재고 처리용 메모리 계층으로 사용했다.

  • 메모리 기반으로 매우 빠른 처리 가능
  • 단일 스레드 기반으로 원자성 보장
  • Lua Script를 통한 원자적 연산 가능
  • DB 앞단에서 트래픽 완충 가능

*원자성: 어떤 작업이 전부 실행되거나, 전혀 실행되지 않거나 둘 중 하나임을 보장



1. Cooldown 구조

 const lock = await (this.redis.set as any)(
            key,
            '1',
            'NX',
            'EX',
            3
        );

예를 들어 사용자가 1초 동안 결제 버튼을 10번 누르면, 그 자체만으로 불필요한 부하가 발생한다.

Redis SET NX EX 를 사용하여 3초 동안 짧은 cooldown lock 을 적용 하여 불필요한 중복 요청을 차단했다.



2. Lua Script

Redis를 사용하더라도 GET(조회) 후 DECR(차감) 사이에 다른 요청이 끼어 들 수 있고, 이로 인하여 Oversell(초과 판매) 문제가 발생 할 수 있다.

Redis는 싱글 스레드로 동작하지만, 여러 명령어가 순차적으로 실행되는 동안, 다른 클라이언트의 명령이 끼어들 수 있기 때문이다.

예를 들어:

  1. 요청 A → 재고 조회 (1)
  2. 요청 B → 재고 조회 (1)
  3. 요청 A → 차감 (재고 0)
  4. 요청 B → 차감 (재고 -1)

이를 해결 하기 위하여, 조회와 차감연산을 하나로 묶어 실행하는 Lua Script를 도입했다.

Redis는 LuaScript가 실행 되는 동안 하나의 원자적(Atomic) 연산으로 취급하므로, 싱글 스레드의 장점을 극대화 하여, 동시성 문제를 해결 할 수 있다.

local qty = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', KEYS[1]))

-- 재고 없음
if not stock then
    return -1
end

-- 재고 부족
if stock < qty then
    return 0
~~end~~

-- 원자적 차감
local remaining = redis.call('DECRBY', KEYS[1], qty)

return remaining

실제 운영 코드에서는 추가로:

  • 옵션 상품 처리
  • 멱등성 (IdempotencyKey)
  • Redis 캐시 miss 처리
  • 음수 방어 rollback
  • TTL 기본 Lock관리

등을 함께 처리했다.



3. Queue

기존에는 주문 요청이 들어올 때마다 DB에서 연산을 했다면 Lua Script 로 Redis에서 안전하게 재고를 선점(확보) 후, Bull Queue를 통해 비동기 처리 하도록 하였다.

이로 인하여 Queue처리 중에도 동일 재고의 중복 차감(oversell)을 방지 할 수 있었다.

 const job = await this.orderQueue.add(JOB, payload, {
                attempts: 3,
                removeOnComplete:
                ...
                })

Nest JS 에서는 @nestjs/bull 패키지를 통해 Redis 기반의 큐를 다룰 수 있다.

npm install @nestjs/bull bull ioredis
npm install -D @types/bull

장점

  • 순간 트래픽 완충
  • DB write Burst 완화
  • retry 처리 가능
  • 장애 복구 전략 수립 가능

단점

  • 실시간 직결보다 응답 속도가 조금 느려질 수 있음
  • 큐 관리 및 상태 추적을 위한 복잡도 증가

  1. 클라이언트로 부터 주문 요청 수신
  2. 요청을 즉시 처리하지 않고 (DB 접근X) Redis Queue에 Job으로 넘긴다.
  3. -Bull : Redis와 연결을 관리하며 Queue 기능을 제공한다.
  4. Queue 저장소 역활을 하며, 요청 받은 Job을 FIFO(firts in , first out) 방식으로 관리한다.
  5. Worker(processor) 가 Queue에서 Job을 가져와 실제 주문 로직 (DB처리) 을 수행한다.
  6. Queue Event가 Job의 완료/실패 상태를 감지하여 후처리를 수행한다.

 

 특히 서비스가 PM2 cluster로 운영 중이기 때문에, Redis를 Queue 저장소로 사용하면 모든 인스턴스가 단일 Queue를 공유하므로 Job 중복 처리나 누락 없이 Worker를 수평 확장할 수 있다.



4. 장애/실패 처리

Redis 로 Lua Script + Queue 구조로 변경하면서 동시성 문제는 어느 정도 해결 할 수 있었다.

하지만 구조가 복잡해진 만큼, 장애 상황에서 어떻게 데이터 정합성을 유지할 것인지도 중요했다.

장애 상황

  • Worker 장애
  • Redis 재시작
  • Queue 적체
  • DB insert 실패
  • 재고 rollback 실패

이를 위해 retry, idempotency, 재동기화 전략 등을 함께 구성했다.


Woker 장애 및 retry 처리

→ BullQuque 는 Worker 장애 또는 예외 상황 시 자동 retry 기능을 제공한다.

retry 처리 시 주의해야 할 점은, 재고 복구는 “마지막 retry 실패 시점” 에서 만 수행 하도록 해야 한다.

예를 들어

1차 실패 → 재고 복구

2차 retry → Lua가 다시 차감

3차 실패 → 또 복구

=> 재고 불일치 가능성

const isLastAttempt =
  job.attemptsMade >= (job.opts.attempts ?? 1) - 1;

if (isLastAttempt) {
    pipeline.incrby(stockKey, order_cnt);
    pipeline.del(idemKey);
}
//idemkey 를 삭제하지 않으면, 재시도 기회가 끝났음에도 키다 남아서 해당 사용자가 다시
//주문을 시도할 때도 차단 된다.

retry 중간 단계에서는 재고 상태를 유지하고, 최종 실패 시에만 rollback 하도록 구성했다.


Redis 재시작 대응

→ Redis는 메모리 기반이기 때문에, 재시작 시 데이터가 유실 될 수 있고, 따라서 재고 상태를 어떻게 복구 할지도 중요하다.

Redis reconnect 후 ready 이벤트를 감지하여 DB 기준으로 재고를 다시 preload 하도록 구성했다.

this.client.on('ready', async () => {
    // 루아스크립트 로드
    await this.loadLuaScript();

        //재연결 확인 플래그
    if (!this._wasDisconnected) return;

        //데이터 베이스 기준 재고 복구 실행
    for (const handler of this._reconnectHandlers) {
        await handler();
    }
});

최종 재고 기준은 DB로 유지 했으며, reconnect 시마다 Lua Script SHA 를 다시 등록하여 NOSCRIPT 에러도 함께 방어 했다.


중복 요청 방지

→ 사용자의 연속 클릭이나 네트워크 재시도로 인해 동일 요청이 여러 번 들어 올 수 있었다.

  • Redis idempotency key
  • DB unique constraint

를 사용하여, 이를 방지 했다.

Redis 에서는 Lua Script단계에서 먼저 중복 요청을 차단하고,

if redis.call('EXISTS', idem_key) == 1 then
    return {-2, 0}
end

Worker 단계에서는 DB unique constraint 및 idempotencyKey 기반 조회로 한 번 더 검증했다.

 const existing = await queryRunner.manager.findOne(table, {
                where: { idempotencyKey: idempotencyKey },
            });

Redis의 멱등성 키는 TTL이 있어 일시적이지만, DB의 idempotencyKey 는 영구적이기 때문에

Redis가 만료된 후에도 Worker retry 나 stalled job 에 의한 이중 저장을 막을 수 있었다.


PM2 장애 및 stalled job

→ Worker 프로세스 crash 상황도 고려했다.

Woker가 job을 가져가면 Bull은 내부적으로 Lock을 건다.

[waiting] → [active + lock 획득] → [completed/failed]

Worker는 처리하는 동안 주기적으로 락을 갱신한다.

  • PM2 restart
  • 프로세스 강제종료
  • 서버장애

등으로 인해 Worker가 job 처리 중 중단 될 수 있다.

  1. Job active 상태로 Worker가 처리 중

    PM2 재시작 (OOM, 크래시, 배포 등)
  2. Worker 프로세스 강제 종료
  3. 락 갱신 중단
  4. lockDuration (기본 30초) 초과
  5. Bull이 감지: "이 Job은 처리 중인데 Worker가 응답 없음 → stalled"
  6. Job을 waiting 상태로 되돌림
  7. PM2가 재시작한 새 Worker가 Job을 다시 가져감

여기서 중요한 점은 “재처리 시 중복 저장이 발생되지 않아야 한다” 는 것이다.

PM2 재시작 전에 DB 커밋까지 완료됐을 수도 있다.

예를들어

  • 케이스 A: DB 커밋 전에 크래시
    stalled → 재처리 → DB에 idempotencyKey 없음 → 정상 저장
  • 케이스 B: DB 커밋 후에 크래시
    stalled → 재처리 → DB에 idempotencyKey 이미 있음
    → duplicated: true 반환 → 중복 저장 없음

Worker 단계의 idempotencyKey 가 이 케이스 B를 막아주어, stalled job 재처리시 멱등성 검증 으로 이중 저장을 막아 줍니다.



마무리

동시성 문제는 단순히 Lock 하나로 해결되지 않았다.

  • 어디서 경쟁이 발생하는지
  • 어떤 계층에서 트래픽을 흡수하는지
  • 여러 장애 상황에서 어떻게 정합성을 유지 할지

를 함께 설계 하는 것이였다.

특히 실시간 주문 시스템에서는 단순히 빠른 처리 보다도

  • 데이터 정합성
  • 장애 복구
  • 중복처리 방지
  • 트래픽 완충

이 훨씬 중요하다는 것을 경함 할 수 있었다.

또한 Redis를 단순 캐시로만 사용하는 것이 아닌, DB 앞단에서 트래픽을 흡수하고,실시간 재고를 제어하는 메모리 계층으로 활용하면서,

“어떤 계층에서 어떤 문제를 해결할 것인가”

를 구조적으로 고민하는 경험이 되었다.

현재 운영 중인 라이브 커머스 서비스는 약 1000명 동시 시청까지 안정적으로 운영되고 있다.

하지만 5000명 정도까지 확장해야 하는 요구 사항이 발생하면서,

WebSocket 연결 증가와 주문 요청이 폭주하는 상황에서 기존 구조로 감당 가능한지 검증이 필요 했고,

실제 사용자 흐름을 기반으로 부하 테스트 환경 구축 하여 병목 지점을 확인하는 작업을 진행 하였다.

 

라이브 커머스 서비스(1)

  • EC2 (nestJs) / RDS (mysql) / socket.io/ IVS / IVS-Chat / nginx / pm2

라이브 커머스 서비스(2)

  • EC2 (nestJs) / RDS (mysql) / socket.io/ IVS / IVS-Chat / nginx / pm2(cluster) / Redis

 

테스트 도구

  • K6 (API) 
    • HTTP 기반 API 엔드 포인트의 응답 시간 및 처리량 검증
  • Artillery (socket)
    • WebSocket 연결을 실제 사용자처럼 생성하고 유지
    • 동시 접속자 수 증가에 따른 연결 안정성 검증
  • Grafana Agent / CloudWatch (monitor)
    • CPU, 메모리, 네트워크, 소켓연결 수 등 서버 리소스 확인
    • 병목 지점 및 장애 발생 구간 추적

 

설치 / 확인 / 검증

k6

sudo yum install -y https://dl.k6.io/rpm/repo.rpm

sudo yum install -y k6

k6 version

k6 run - <<EOFimport http from 'k6/http';export default function () { http.get('https://test.k6.io');}EOF

artillery

npm install -g artillery

artillery -V

artillery quick --count 10 -n 20 http://example.com

** 운영서버 소켓 연결수 간단 보기 명령어

watch -n 2 "
echo '=== CPU/MEM (Node.js) ===';
ps aux | grep node | grep -v grep | awk '{printf "CPU: %s%% MEM: %s%% RSS: %sMB\n", $3, $4, $6/1024}';
echo '';

echo '=== 소켓 연결 수 (:3033) ===';
ss -an | grep :3033 | grep ESTAB | wc -l;
echo '';

echo '=== 전체 메모리 ===';
free -m | awk 'NR==2{printf "사용: %sMB / 전체: %sMB (%.1f%%)\n", $3, $2, $3*100/$2}'"

 

일정 수 이상의 WebSocket 연결이 더 이상 생성되지 않는 문제가 발생했다.

 

-OS의 openfiles 제한에 도달하여 연결 차단

cat /proc/$(pgrep -f "node" | head -1)/limits | grep "open files” 명령어로 확인하면

임시방편

  • 서버 재시작하면 다시 1024로 돌아감
  • PM2가 새 프로세스 생성할 때마다 1024로 시작
  • 매번 수동으로 실행해야 함

모든 node 프로세스 PID 확인

pgrep -f "node"

모든 PID에 직접 적용

for pid in $(pgrep -f "node"); do
sudo prlimit --nofile=65535:65535 --pid $pid && echo "PID $pid → 65535 적용"
done

Nginx 설정 변경

운영 환경에서는 OS 레벨의 ulimit 및 systemd 설정을 통해 지속적으로 적용되도록 구성 필요

vi /etc/security/limits.conf

  • soft nofile 65535 (수정)
  • hard nofile 65535 (수정)

  • 로그인 세션 재시작

 

vi /etc/systemd/system.conf

  • DefaultLimitNOFILE=65535

  • pm2 재시작

65535인 이유

  • 리눅스의 기본 상한 구조
    • 파일 디스크럽터는 커널 내부에서 관리
    • 일반적으로 유저 레벨에서 가장 많이 쓰는 최댓값이65535 (2^16-1)
    • 관례적으로 사용
  • 더 높게 안 하는 이유
    • OS 리소스
    • 튜닝 필요

limit이 1024 인데 2040 이 찍히는 이유

  • pm2 Cluster 모드에서 Node 2개 / 각각 FD limit = 1024
  • FD 는 소켓만 사용하는게 아님
    • 내부 pipe
    • 로그파일
    • redis/DB 연결
    • 기타 시스템 FD

WebSocket 기반 서비스에서는 CPU, 메모리 보다 파일 디스크럽터 제한이 병목이 될 수 있다.

서버 성능이 충분하더라도 OS 레벨 하나로 서비스가 제한 될 수 있다.

모니터링

Prometheus (수집)

다운로드

wget https://github.com/prometheus/prometheus/releases/download/v2.45.0/prometheus-2.45.0.linux-amd64.tar.gz

tar -xf prometheus-2.45.0.linux-amd64.tar.gz
sudo mv prometheus-2.45.0.linux-amd64/prometheus /usr/local/bin/
sudo mv prometheus-2.45.0.linux-amd64/promtool /usr/local/bin/

설정 파일 (운영 서버 IP 지정)

sudo mkdir -p /etc/prometheus
sudo bash -c 'cat > /etc/prometheus/prometheus.yml << EOF
global:
scrape_interval: 5s

scrape_configs:

  • job_name: "운영서버"
    static_configs:
    • targets: ["172.00.0.000:9100"] # 운영서버 내부 IP
      EOF'

서비스 등록

sudo bash -c 'cat > /etc/systemd/system/prometheus.service << EOF
[Unit]
Description=Prometheus
After=network.target

[Service]
ExecStart=/usr/local/bin/prometheus --config.file=/etc/prometheus/prometheus.yml
Restart=always

[Install]
WantedBy=multi-user.target
EOF'

sudo systemctl daemon-reload
sudo systemctl enable prometheus
sudo systemctl start prometheus

확인

sudo systemctl status prometheus

Grafana (시각화)

sudo bash -c 'cat > /etc/yum.repos.d/grafana.repo << EOF
[grafana]
name=grafana
baseurl=https://rpm.grafana.com
repo_gpgcheck=1
enabled=1
gpgcheck=1
gpgkey=https://rpm.grafana.com/gpg.key
EOF'

sudo dnf install -y grafana
sudo systemctl enable grafana-server
sudo systemctl start grafana-server

확인

sudo systemctl status grafana-server

운영서버 포트
포트: 9100
소스: 테스트 서버 IP

테스트서버 포트
포트: 3000 ← Grafana
소스: 내 IP

Grafana 접속

브라우저: http://<테스트서버 공인IP>:3000
ID: admin
PW: admin

설정 순서:

  1. Data Sources → Prometheus → URL: http://localhost:9090
  2. Dashboards → Import → 1860 입력 (Node Exporter Full)

<운영서버 설치>

  1. Node Exporter 설치 (서버 메트릭 수집)

다운로드

wget https://github.com/prometheus/node_exporter/releases/download/v1.7.0/node_exporter-1.7.0.linux-amd64.tar.gz

압축 해제

tar -xf node_exporter-1.7.0.linux-amd64.tar.gz

실행 파일 이동

sudo mv node_exporter-1.7.0.linux-amd64/node_exporter /usr/local/bin/

서비스 등록

sudo bash -c 'cat > /etc/systemd/system/node_exporter.service << EOF
[Unit]
Description=Node Exporter
After=network.target

[Service]
ExecStart=/usr/local/bin/node_exporter
Restart=always

[Install]
WantedBy=multi-user.target
EOF'

시작

sudo systemctl daemon-reload
sudo systemctl enable node_exporter
sudo systemctl start node_exporter

확인

curl http://localhost:9100/metrics | head -5

테스트 서버에 

A서비스가 PM2로 실행되고 있고,

B서비스를 개발 및 수정하기 위해 npm run start:dev로 API 작업을 시작했더니 Socket.io 가 연결되지 않았다. 

 

서버 구성

- EC2 테스트용 서버 1대

- 단일 Nginx

- Nginx 하위 경로 기반의 프록시 구성

 

서비스 A (기존)

 - NestJS + React

 - pm2로 실행 중

 - 프론트 개발자가 API 및 Socket 연결 작업 중

 

서비스 B (추가)

 - 신규 개발 및 수정을 위한 Nest + React 서비스

 - npm run start:dev 로 실행

- Socket.io를 사용

 

 B를 추가하여 환경 설정 중에 Socket.io가 연결이 안되는 문제가 발생했다. 

 

B서비스의 API 호출은 정상적으로 실행이 되었고, 프론트 페이지도 정상적으로 로드 되었으나 Socket.io 만 연결이 되지 않았다.

같은서버, 같은 nginx 환경이었고, A 서비스의 Socket.io 는 정상적으로 작동 되었다.

 

 

# 기존 nginx 설정

 


#이미지 로드
location ^~ /test_B/image/ {
    alias /var/www/nginx/test_A/back/image/;
   # try_files $request_filename =404;
}

 

#프론트 페이지 서빙
location /test_B {
    alias /var/www/nginx/test_A/front/build/;
    index index.html;
    try_files $uri $uri/ /test_A/index.html;

}

 

#백엔드 API 프록시 
location /test_B/back/ {
    proxy_pass http://localhost:3000/;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;

    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

 

 

#socket.io
location /socket.io/ {
    proxy_pass http://localhost:3033;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 60s;
    proxy_connect_timeout 60s;
    proxy_set_header Origin "";
    proxy_cache_bypass $http_upgrade;
}

 

 

 

해결방법

#socket.io
location /test_B/socket.io/ {
    proxy_pass http://localhost:3033;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 60s;
    proxy_connect_timeout 60s;
    proxy_set_header Origin "";
    proxy_cache_bypass $http_upgrade;
}

 

PM2로 실행중인 A서비스는 이미 포트를 점유하고 있는 상황이고,

B서비스는 개발을 편하게 하기 위해 별도의 포트에서 start:dev 로 실행되면서 같은 nginx의 하위 경로 구조를 공유하는 상황이 되었던 것이다.

 

nginx에서 B서비스의 Socket.io 블럭에 "/test_B/" 라는 경로를 명시적으로 추가 하는 것이였다.

즉, socket.io가 접근하는 실제 경로와 nginx가 해석하는 경로를 정확히 일치시키는 것이였다. 

 

 

-REST 는 경로자 조금 어긋나더라도 동작 할 수 있지만, WebSocket은 엄격하게 경로매칭에 의존한다.

-Socket.io 는 path를 명시적으로 관리해야 한다. 

 

구분 REST(HTTP) WebSocket
통신 방식 요청 - 응답 지속 연경
경로 역활 라우팅 연결 식별
프록시 rewitre 가능 불가
경로 오차 높음 거의 없음
실패시 에러 404 / 500 연결 자체 실패

 

 

 

 

---추가---

1. REST가 경로가 조금 어긋나도 동작하는 이유

- http는 요청마다 독립적이다.

 (매 요청마다 URL, Method, Header, Query/Body  를 보고 그때그때 라우팅 한다)

 

2. 서버 프레임워크의 관대한 매칭

  GET /api/user
  GET /api/user/
  GET /api/user?id=1

-> 내부적으로는 모두 같은 컨트롤러로 매칭 되기도 안다.

 

 

1. WebSocket이 경로에 엄격한 이유

 - WebSocket은 http가 아니라 `연결`이다.

 ( WebSocket은 처음만 HTTP고, 이후는 완전히 다른 프로토콜이다. )

 

HTTP Handshake (Upgrade)
↓ 성공하면
Persistent TCP Connection (WebSocket)

-> path 는 `연결 식별자` 역할을 한다.

 

2. Handshake 단계에서 경로가 `딱 맞아야 함`

WebSocket은 이런 요청으로 시작된다

[

GET /socket.io/?EIO=4&transport=websocket
Upgrade: websocket

]

 

-> 서버는 이시점에 경로/헤더/업그레이드 조건을 정확히 검사하며, 이 단계에서 실패시, 연결이 되지 않는다.

 

 

 3. WebSocket은 라우팅이 아니라 `엔드포인트 바인딩` 이다.

HTTP             :  /api/user -> 컨트롤러 선택                              경로 = 함수 선택

WebSocket    : /ws/chat -> 해당 소켓 서버에 직접 바인딩         경로 = 서버 자체

 

한대의 EC2 위에 n개의 서비스를 운영하는 것은 해보았고, 현재도 진행중이다.

그런데 이번에 LLM 서비스를 운영해야 하는 상황에 기존에 nest 도 pm2로 운영하고 있었고  파이썬으로 가상환경을 만들고 어쩌고 하는 상황이 왔다.

원래 Docker(도커)를 많이들 쓴다는데, 한번도 사용해보지 않은 기술이기도 하고, nest와 Fast를 따로 운영하느니 이번기회에 도커로 운영하는게 효율이 나오겠다 싶어 도커를 도입하게 되었다.

 

- Docker 

-  Nginx (리버스 프록시)

- React / NestJS (웹 서비스)

- React / FastAPI (llm을 이용한 채팅 서비스)

 

각각의 컨데이너로 독립 운영하기

 

 

-Docker

애플리케이션을 "컨테이너"라는 격리된 공간에서 실행하도록 만들어 주는 플랫폼

어디서 실행하든 "똑같이" 동작하도록 만드는 기술 

 - 개발 환경 = 배포 환경 100% 동일 

 - 라이브러리 충돌 없음

 - 하나의 서버에 여러개의 서비스를 "독립적" 으로 실행할 수 있음

 

 

-Doker Compose 

여러개의 Docker 컨테이너를 "하나의 설정 파일"로 묶어서 실행, 관리 하는 도구

 - NestJS 서비스 (web API)

 - FastAPI 서비스 (LLM)

 

환경을 yml로 관리

 - 팀원 누구나 docker-compose.yml만 있으면 동일 환경 구축 가능

 - 서버를 새로 만들어도 파일만 복사하면 끝 

 - 재현성 보장 (로컬 docker 환경과 서버 docker 환경 일치)

 

 

각 서비스 간 네트워크 자동 구축

 

 

 

 

MySQL에서 날짜를 처리 할때,

 

 

-DATE : 날짜 부분에 사용 된다

   '***YYYY-MM-DD***' '1000-01-01' '9999-12-31'

 

 

 

-DATETIME : 날짜와 시간 부분을 모두 포함 한다.

   ('***YYYY-MM-DD hh:mm:ss***' '1000-01-01 00:00:00' '9999-12-31 23:59:59')

  • 문자형
  • 8byte
  • MySQL 설정에서 time_zone에 의존 하지 않는다. - 저장한 값을 그대로 출력한다.

   

 

 

-TIMESTAMP : 날짜와 시간 부분을 모두 포함한다. *UCT에서 UTC timestamp 범위 까지 있다.

 ('***YYYY-MM-DD hh:mm:ss***' '1970-01-01 00:00:01' '2038-01-19 03:14:07') 

  • 숫자형
  • 4byte
  • MySQL 설정에서 time_zone에 의존 한다. - 입력된 시간을 기반으로 그 정보를 *UTC로 변환해서 저장 한다.

 

(*UTC란?

Coordinated Universal Time (협정세계시) 국제적인 표준 시간의 기준으로 쓰이는 시각이다.

한국은 UTC+9시간대 이므로, UTC 시간에 +9해주면 한국 환산 시간이 나온다.)

 

 

 

“DATETIME” 과 “TIMESTAMP”현재 날짜와 시간에 자동 초기화 및 업데이트를 제공한다.

CREATE TABLE 테이블명(
DT DATETIME DEFAULT CURRRNT_TIMESTAMP,
TS TIMESTAMP DEFAULT CURRRNT_TIMESTAMP
);

 

 

DATETIME TIMESTAMP
용량이 크다
문자로 처리할때
서비스 타겟이 "한국"

아마도? 빠른 속도가 필요할때
(저장값 그대로 읽음)
용량이 작다
숫자로 처리할때
서비스 타겟이 "외국"

 

+ Recent posts