Post

대용량 엑셀 업로드 비동기 파이프라인 구축기

Intro

동기 방식의 엑셀 업로드를 비동기 파이프라인으로 전환하여 API 응답 시간, DB 부하, 메모리 문제를 개선한 과정을 소개합니다.

✅ 결론

비동기 아키텍처 + SAX 파싱 + JDBC Batch INSERT를 도입하여 기존 동기 방식의 핵심 문제들을 개선했습니다.

  • API 응답 시간 ~243초540ms로 개선 (즉시 반환)

  • 1,000건 처리 시간 ~243초~7초로 약 97% 단축

  • Apache POI DOM 방식의 OOM 위험을 SAX 스트리밍으로 해소

  • 동시 DB 커넥션을 워커 풀 크기로 고정하여 DB 커넥션 고갈 방지

  • Redis 분산 락으로 중복 데이터 생성 차단

✅ 기존 방식

기존 엑셀 업로드는 API 요청 스레드가 파싱 → 검증 → INSERT를 모두 동기적으로 처리하는 구조였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
클라이언트
  │
  ▼  POST /api/v2/item/excel/upload
┌──────────────────────────────────────┐
│  Tomcat Thread (blocked)             │
│                                      │
│  1. MultipartFile 수신               │
│  2. DOM 파싱 (전체 메모리 로드)      │
│     ⚠ 대용량 파일 → OOM 위험        │
│  3. Row별 검증 + 1건씩 DB INSERT     │
│  4. 에러 파일 생성                   │
│                                      │
│  ⏱ 1,000건 기준: ~243초             │
│  🔌 DB 커넥션: 요청당 1개 점유       │
└──────────────────────────────────────┘
  │
  ▼  ~243초 후 응답
클라이언트

1️⃣ Tomcat 스레드 + DB 커넥션 장시간 점유

API 스레드가 처리 완료까지 블로킹됩니다. 1,000건 기준 ~243초 동안 Tomcat 스레드 1개 + DB 커넥션 1개가 점유됩니다. 동시 업로드 사용자가 늘어나면 Tomcat 스레드와 DB 커넥션이 고갈되어 일반 API까지 마비됩니다.

1
2
3
4
20명 동시 업로드 → Write 커넥션 20개 × 100초+ 점유
50명 동시 업로드 → Write 커넥션 50개 × 100초+ 점유 (경합으로 더 느려짐)
→ 일반 API (주문 처리, 재고 확인 등)가 커넥션 대기 상태 진입
→ HikariPool connection timeout (30초 기본) → 500 에러

2️⃣ DOM 파싱으로 인한 OOM

Apache POI DOM 방식은 엑셀 전체를 메모리에 트리 구조로 로드합니다. 1,000건 기준 ~19MB를 점유하며, 대용량 파일 업로드 시 OutOfMemoryError가 발생합니다.

3️⃣ JPA 단건 INSERT 성능

Row마다 REQUIRES_NEW 트랜잭션으로 1건씩 JPA INSERT를 수행합니다. 1,000건에 ~243초가 소요되며, 행별로 DB 조회 + INSERT가 반복되어 비효율적입니다.

4️⃣ 동시성 환경에서 중복 검증 불가

SKU와 바코드는 셀러별로 유니크해야 하지만, 기존 테이블에 중복 데이터가 존재하여 DB UNIQUE 제약을 걸 수 없는 상황이었습니다. 애플리케이션 레벨에서 중복 검증을 수행하지만, 동시 요청 시 검증과 INSERT 사이의 타이밍 갭으로 중복 데이터가 생성될 수 있었습니다.

✅ 개선 과정

1️⃣ 동기 → 비동기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
클라이언트
  │
  ▼  POST /api/v2/item/excel/upload
┌────────────────────────────────┐
│  Tomcat Thread (즉시 반환)     │
│                                │
│  1. S3 업로드 (파일 저장)      │
│  2. Job 테이블 INSERT          │
│  3. jobId 응답                 │
│                                │
│  ⏱ ~540ms                     │
└────────────────────────────────┘
  │
  ▼  540ms 후 jobId 응답
클라이언트 (폴링 시작)

┌─────────────────────────────────────┐
│  Scheduler (3초 폴링)               │
│  → Worker Pool (5 스레드)          │
│                                     │
│  1. S3 다운로드                     │
│  2. SAX 스트리밍 파싱               │
│  3. Phase 1 + 1.5 검증              │
│  4. Phase 2 JDBC Batch INSERT       │
│  5. 에러 파일 S3 업로드             │
│  6. Job 상태 업데이트               │
└─────────────────────────────────────┘

API 요청 시에는 파일을 S3에 저장하고 Job만 등록한 뒤 즉시 응답합니다. 실제 파싱/검증/INSERT는 백그라운드 워커 스레드풀에서 처리합니다. 클라이언트는 폴링 API로 처리 상태를 확인합니다.

2️⃣ SKIP LOCKED를 활용한 Job 큐 구현

스케줄러가 3초마다 폴링하면서 같은 Job을 여러 번 가져가 중복 처리하는 문제가 발생할 수 있습니다. 일반적인 SELECT FOR UPDATE는 다른 트랜잭션이 같은 행을 잠그고 있으면 대기(blocking)합니다. 스케줄러가 3초마다 폴링하는 구조에서 이전 폴링이 아직 처리 중이면 다음 폴링이 블로킹되어 전체 시스템이 정체될 수 있습니다.

SKIP LOCKED는 이미 잠긴 행을 건너뛰고 나머지만 가져옵니다.

1
2
3
4
5
6
7
8
9
10
11
-- 셀러별 가장 오래된 PENDING Job 1건씩, 최대 5건
SELECT j.* FROM excel_upload_job j
INNER JOIN (
    SELECT MIN(job_id) AS job_id
    FROM excel_upload_job
    WHERE status = 'PENDING'
    GROUP BY seller_id
    ORDER BY MIN(cre_dt) ASC
    LIMIT 5
) t ON j.job_id = t.job_id
FOR UPDATE SKIP LOCKED;

GROUP BY seller_id로 셀러당 1건만 가져오게 합니다. 셀러A가 5건, 셀러B가 2건 대기 중이어도 각 셀러에서 가장 오래된 1건씩만 가져와 서로 다른 셀러의 Job을 병렬 처리합니다. 같은 셀러의 Job이 동시에 처리되어 중복 검증이 무력화되는 것을 원천 차단합니다.

3️⃣ Redis 분산 락

GROUP BY seller_id만으로는 완벽하지 않습니다. 분산 환경(CGS 인스턴스 3대)에서 각 인스턴스의 스케줄러가 동시에 같은 셀러의 Job을 가져갈 수 있습니다.

SKU와 바코드는 셀러별로 유니크해야 하지만, 소프트 삭제(delete_yn) 패턴과 기존 중복 데이터로 인해 DB UNIQUE 제약을 걸 수 없는 상황이었습니다. 따라서 애플리케이션 레벨에서 중복 검증을 수행하되, 동시 요청 시 검증과 INSERT 사이의 타이밍 갭으로 중복이 발생하는 것을 막기 위해 Redis 분산 락을 도입했습니다.

구체적으로는 셀러ID Lock-Key로 사용하여, 같은 셀러의 Job은 반드시 순차 처리되도록 보장하였습니다.

1
2
1차 방어: GROUP BY seller_id → 같은 인스턴스 내 셀러별 1건 처리
2차 방어: Redis SETNX + TTL  → 분산 환경에서 셀러별 순차 처리 보장

워커 스레드가 비정상 종료되어 unlock()이 호출되지 않더라도, TTL(10분)이 만료되면 Redis 키가 자동 삭제되어 다른 스레드가 락을 획득할 수 있습니다.

4️⃣ SAX 스트리밍 파서 — OOM 해소

Apache POI DOM 방식에서 SAX 스트리밍 방식으로 전환했습니다.

방식메모리설명
DOM (XSSFWorkbook)~19MB (1,000건)엑셀 전체를 메모리에 트리 구조로 로드
SAX (SaxUploadRow)~700B × row 수row당 셀 문자열만 보관

DOM 방식 힙덤프 (1,000건)

DOM 방식 힙덤프

XSSFSheet 하나가 Retained Size 19.32MB를 차지합니다. 엑셀 전체를 트리 구조로 메모리에 올리기 때문에 행 수에 비례하여 메모리가 급증합니다.

SAX 방식 힙덤프 (1,000건)

SAX 방식 힙덤프

SaxUploadRow 객체 하나당 Retained Size가 696~752B에 불과합니다. row별로 셀 문자열만 보관하므로 메모리 사용량이 일정하게 유지됩니다.

1,000건 기준 메모리 사용량:

1
2
3
SaxUploadRow 1,000개 × ~700B  =  ~700 KB
컨테이너 배열                   =  ~409 KB
합계                           ≈  ~1.1 MB

19.32MB -> 1.1MB DOM 방식 대비 약 1/17 수준으로 메모리 사용량이 줄었습니다.

5️⃣ JDBC Batch INSERT — 성능 최적화

JPA 단건 INSERT에서 JDBC Batch INSERT로 전환했습니다. 100건 단위 chunk로 item_mst, item_uom_dtl, item_barcode_dtl 3개 테이블을 일괄 저장합니다.

1
2
3
4
5
6
7
Phase 1.5 검증: SELECT 1회 (전체 1,000건 한번에)

Phase 2 INSERT:
  chunk 1 (100건) → INSERT item_mst + UPDATE item_cd + INSERT item_uom_dtl + INSERT item_barcode_dtl = 4 쿼리
  chunk 2 (100건) → 4 쿼리
  ...
  chunk 10 (100건) → 4 쿼리

단일 트랜잭션으로 감싸서 실패 시 전체 rollback합니다.

✅ 부하 테스트

k6로 10명 동시 요청(VU 10)으로 테스트했습니다. 각 VU마다 다른 셀러 ID를 할당하여 바코드 중복을 방지했습니다.

1,000 Row x 10 동시 요청

항목결과
API 응답 시간 (평균)354ms (min 323ms / max 388ms)
전체 처리 시간 (평균)13.3s (min 6.9s / max 22.1s)
업로드 성공률100% (10/10)
최대 동시 DB 커넥션5개 (워커 풀 크기로 제한)
배치셀러 수완료 시간Job당 처리 시간
1차4~5개 동시~7초~7초
2차3~5개~15~17초~9~11초
3차1개~22초~5초
k6 원본 출력
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
INFO[0000] [VU 10] 셀러=30 Job 등록: jobId=54, 323ms         source=console
INFO[0000] [VU 9] 셀러=29 Job 등록: jobId=52, 323ms          source=console
INFO[0000] [VU 6] 셀러=26 Job 등록: jobId=53, 330ms          source=console
INFO[0000] [VU 7] 셀러=27 Job 등록: jobId=55, 330ms          source=console
INFO[0000] [VU 8] 셀러=28 Job 등록: jobId=56, 330ms          source=console
INFO[0000] [VU 5] 셀러=25 Job 등록: jobId=59, 374ms          source=console
INFO[0000] [VU 2] 셀러=22 Job 등록: jobId=57, 375ms          source=console
INFO[0000] [VU 4] 셀러=24 Job 등록: jobId=61, 387ms          source=console
INFO[0000] [VU 1] 셀러=21 Job 등록: jobId=58, 387ms          source=console
INFO[0000] [VU 3] 셀러=23 Job 등록: jobId=60, 388ms          source=console
INFO[0006] [VU 6] 셀러=26 완료: submit=330ms, total=6923ms, success=700, error=300  source=console
INFO[0006] [VU 8] 셀러=28 완료: submit=330ms, total=6931ms, success=700, error=300  source=console
INFO[0006] [VU 9] 셀러=29 완료: submit=323ms, total=6931ms, success=700, error=300  source=console
INFO[0006] [VU 7] 셀러=27 완료: submit=330ms, total=6931ms, success=700, error=300  source=console
INFO[0015] [VU 10] 셀러=30 완료: submit=323ms, total=15542ms, success=700, error=300  source=console
INFO[0015] [VU 2] 셀러=22 완료: submit=375ms, total=15916ms, success=700, error=300  source=console
INFO[0015] [VU 1] 셀러=21 완료: submit=387ms, total=15933ms, success=700, error=300  source=console
INFO[0017] [VU 5] 셀러=25 완료: submit=374ms, total=17796ms, success=700, error=300  source=console
INFO[0017] [VU 3] 셀러=23 완료: submit=388ms, total=17834ms, success=700, error=300  source=console
INFO[0022] [VU 4] 셀러=24 완료: submit=387ms, total=22091ms, success=700, error=300  source=console

  █ THRESHOLDS

    http_req_duration
    ✓ 'p(95)<120000' p(95)=384.94ms

    http_req_failed
    ✓ 'rate<0.1' rate=0.00%

  █ TOTAL RESULTS

    checks_total.......: 10      0.452642/s
    checks_succeeded...: 100.00% 10 out of 10
    checks_failed......: 0.00%   0 out of 10

    ✓ upload status is 200

    CUSTOM
    submit_duration................: avg=354.7ms  min=323ms   med=352ms    max=388ms    p(90)=387.1ms  p(95)=387.55ms
    total_duration.................: avg=13.28s   min=6.92s   med=15.72s   max=22.09s   p(90)=18.25s   p(95)=20.17s
    upload_success.................: 10     0.452642/s

    HTTP
    http_req_duration..............: avg=213.65ms min=69.76ms med=170.71ms max=456.76ms p(90)=378.33ms p(95)=384.94ms
      { expected_response:true }...: avg=213.65ms min=69.76ms med=170.71ms max=456.76ms p(90)=378.33ms p(95)=384.94ms
    http_req_failed................: 0.00%  0 out of 69
    http_reqs......................: 69     3.123228/s

    EXECUTION
    iteration_duration.............: avg=13.28s   min=6.92s   med=15.72s   max=22.09s   p(90)=18.25s   p(95)=20.17s
    iterations.....................: 10     0.452642/s
    vus............................: 1      min=1       max=10
    vus_max........................: 10     min=10      max=10

    NETWORK
    data_received..................: 30 kB  1.4 kB/s
    data_sent......................: 1.4 MB 61 kB/s

running (00m22.1s), 00/10 VUs, 10 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs  00m22.1s/10m0s  10/10 shared iters
This post is licensed under CC BY 4.0 by the author.