Skip to content

[Import][Performance] Worker Import 행 단위 DB 접근 제거와 1,000행 성능 회귀 방지 #208

Description

@krestar

한 줄 목표

현재 최대 허용치인 1,000행 Worker Import에서 행 수에 비례해 반복되는 개별 DB round-trip을 줄이고,
batch/set-based 처리로 Import 생성·매핑·검증·재처리·확정 등록의 응답 지연을 개선합니다.

성능 개선 폭은 미리 가정하지 않고, 동일 조건의 Before/After benchmark와 SQL 실행 패턴으로 확인합니다.


왜 필요한가요?

Worker Import는 CSV/XLSX를 최대 1,000행까지 허용합니다.

기능 자체는 동작하지만 현재 main의 일부 경로는 bulk workload임에도
일반 CRUD처럼 행마다 SELECT/INSERT/UPDATE를 반복합니다.

로컬 1,000행 XLSX 측정값은 다음과 같습니다.

아래 수치는 특정 로컬 환경의 baseline이며,
운영 성능이나 최적화 후 개선 폭을 보장하지 않습니다.

단계 현재 측정값
Import 생성 약 2.21s
Mapping 저장 약 2.11 ~ 3.30s
Validate 약 4.53 ~ 5.35s
오류 행 PATCH 약 0.58s
Retry 약 5.70s

이번 이슈는 timeout이나 connection pool 값을 키우는 대신,
현재 허용 상한인 1,000행에서 행 수에 비례하는 개별 DB round-trip과 non-batch JDBC 실행을 줄이는 것을 목표로 합니다.


현재 main에서 확인된 병목

1. ImportRow 생성이 per-row INSERT

JdbcWorkerImportRepository.insert(...)는 ImportJob 저장 후
각 ImportRow마다 jdbcTemplate.update(...)를 호출합니다.

1,000행
→ worker_import_row INSERT 최대 1,000회

2. Mapping 저장이 전체 조회 + 행별 UPDATE

현재 흐름:

findAllRows
→ N × updateRow
→ findAllRows
→ Java에서 status count

매핑 변경에 따른 validation reset은 동일한 상태 전환이므로
set-based UPDATE로 처리할 수 있습니다.


3. Validate의 Worker 중복 조회 N+1

display_name마다 기존 Worker 중복 여부를 조회합니다.

SELECT COUNT(*)
FROM worker
WHERE company_id = ?
  AND LOWER(display_name) = LOWER(?);

1,000행 검증에서 이 조회가 행 수에 비례해 반복될 수 있습니다.


4. Validate 결과가 per-row UPDATE

각 행 검증 후 다음 값을 개별 UPDATE합니다.

  • normalized values
  • validation errors
  • row status
  • updated_at
  • version

검증 계산은 Java에서 유지하더라도 DB 반영은 batch로 묶을 수 있습니다.


5. 상태 count를 위해 전체 ImportRow를 다시 조회

현재 여러 경로에서:

counts(repository.findAllRows(...))

형태로 count만 필요해도 전체 ImportRow와 JSON 컬럼을 다시 읽습니다.

다음 aggregate query로 대체할 수 있습니다.

SELECT status, COUNT(*)
FROM worker_import_row
WHERE company_id = ?
  AND import_id = ?
GROUP BY status;

이 항목은 주된 성능 개선이라기보다,
다른 bulk 최적화와 함께 불필요한 전체 row load를 제거하는 정리입니다.


6. Retry도 동일한 per-row 검증 비용을 반복

현재 Retry는 별도 부분 검증 경로가 아니라
validate(..., retry=true)를 통해 미확정 행을 다시 검증합니다.

이번 이슈에서는 Retry의 검증 의미를 변경하지 않습니다.

먼저 Validate 자체의:

  • Worker 중복조회 N+1
  • validation 결과 per-row UPDATE
  • count용 전체 row 재조회

를 제거해 Retry도 같은 bulk 경로를 사용하도록 합니다.

최적화 후에도 전체 재검증 자체가 실제 병목으로 남는다면
dependency-aware 부분 재검증은 별도 후속 이슈에서 검토합니다.


7. Commit의 Worker 저장이 per-row persist + flush

현재 Import commit은 VALID 후보를 순회하면서:

workerRepository.insert(worker)
→ ImportRow updateRow(...)

를 반복합니다.

또한 현재 JpaWorkerRepository.insert()persist() 직후
EntityManager.flush()를 호출하므로 Worker마다 flush가 발생합니다.

1,000행 commit에서 batch 적용을 방해할 수 있으므로
Import 전용 bulk 저장 경로 또는 동등한 방식으로 개선합니다.


구현 범위

1. Validate의 Worker 중복 조회 bulk화

  • 검증 대상 display_name을 기존 규칙과 동일하게 정규화합니다.
  • 같은 정규화 이름을 중복 query하지 않습니다.
  • 기존 Worker 이름은 한 번 또는 제한된 chunk 수의 bulk query로 조회합니다.
  • 결과를 Set/Map으로 구성해 각 행의 충돌 여부를 판정합니다.
  • 행마다 existsWorkerByDisplayName()을 호출하는 구조를 제거합니다.
  • tenant 범위는 기존과 동일하게 company_id로 제한합니다.

예시:

SELECT LOWER(display_name)
FROM worker
WHERE company_id = ?
  AND LOWER(display_name) IN (...);

PostgreSQL parameter 수와 query plan을 고려한 chunking은 허용합니다.


2. Validate 결과 batch UPDATE

  • 검증 결과를 행마다 jdbcTemplate.update()로 실행하지 않습니다.
  • JdbcTemplate.batchUpdate() 또는 동등한 batch 방식을 사용합니다.
  • normalized values, validation errors, status, version 의미는 유지합니다.
  • batch 실패 시 transaction 전체 원자성을 유지합니다.
  • batch 크기는 benchmark와 구현 단순성을 기준으로 결정합니다.

3. 상태 count aggregate query

  • status count용 repository query를 추가합니다.
  • count 계산만을 위해 전체 ImportRow JSON을 다시 조회하지 않습니다.
  • VALID, INVALID, EXCLUDED, COMMITTED 개수를 DB aggregate로 계산합니다.
  • ImportJob의 기존 count 컬럼 의미를 유지합니다.

4. Mapping reset set-based 처리

  • saveMappings()의 per-row reset을 제거합니다.
  • 가능한 경우 단일 SQL 또는 제한된 수의 set-based UPDATE로 처리합니다.
  • COMMITTED, EXCLUDED 행은 변경하지 않습니다.
  • optimistic version 의미를 유지합니다.

예시:

UPDATE worker_import_row
SET normalized_values_json = '{}',
    validation_errors_json = '[]',
    status = 'PENDING',
    updated_at = ?,
    version = version + 1
WHERE company_id = ?
  AND import_id = ?
  AND status NOT IN ('COMMITTED', 'EXCLUDED');

5. ImportRow 생성 batch INSERT

  • 1,000행 생성 시 per-row INSERT를 제거합니다.
  • JDBC batch를 사용해 ImportRow INSERT를 묶습니다.
  • ImportJob + ImportRow transaction 원자성을 유지합니다.
  • 기존 create idempotency 계약을 유지합니다.

6. Commit bulk 저장

  • Import 경로에서 Worker별 persist()+flush() 반복을 제거합니다.
  • Import 전용 insertAll(...) 또는 동등한 bulk 저장 경로를 사용합니다.
  • ImportRow COMMITTED 상태 갱신도 batch 처리합니다.
  • 일반 Worker 단건 API의 기존 persistence 의미는 변경하지 않습니다.
  • Worker INSERT와 ImportRow 상태 갱신은 같은 transaction에서 원자적으로 처리합니다.
  • audit, idempotency, optimistic version, tenant isolation 계약을 유지합니다.

구현 원칙

이번 작업은 Worker Import의 DB 접근 방식만 최적화합니다.

다음 계약은 변경하지 않습니다.

  • 최대 1,000행 제한
  • 기존 Import API endpoint와 request/response schema
  • Import 상태 흐름
  • validation 오류 코드와 의미
  • Import 내부 display_name 중복 판정
  • 기존 Worker display_name 중복 후보 판정
  • Retry의 검증 의미
  • optimistic version
  • create/commit idempotency
  • audit 의미
  • RLS 및 company_id tenant isolation
  • 원본 파일 보존 정책

다음 방식으로 우회하지 않습니다.

  • Hikari pool 크기 증가
  • statement_timeout / lock_timeout 증가
  • Client timeout 증가
  • 1,000행 제한 축소
  • 검증 규칙 또는 Worker 중복 검사 제거
  • 근거 없는 index 선추가

Issue #64의 Runtime timeout guard는 안전선으로 유지하며,
이번 이슈의 query 최적화를 대신하지 않습니다.


인덱스 검토

Worker duplicate lookup은 다음 predicate를 사용합니다.

WHERE company_id = ?
  AND LOWER(display_name) ...

이번 작업에서는 N+1 제거를 먼저 수행합니다.

검토 순서:

  1. N+1 제거
  2. 대표 Worker cardinality fixture 준비
  3. EXPLAIN (ANALYZE, BUFFERS) 확인
  4. 실제 plan에서 필요할 경우 expression composite index 추가

후보:

CREATE INDEX ...
ON worker (company_id, LOWER(display_name));
  • 실행계획 근거 없이 index를 추가하지 않습니다.
  • index를 추가한다면 additive Flyway migration으로 처리합니다.
  • Worker INSERT/UPDATE 비용 증가도 함께 확인합니다.

성능 검증

최적화 전후를 동일한 조건에서 반복 측정합니다.

동일한 1,000행 XLSX/CSV fixture
동일한 PostgreSQL major version
동일한 JVM
동일한 Spring profile
동일한 DB connection pool 설정
동일한 머신 또는 runner
동일한 측정 방식

baseline commit과 optimized commit SHA를 각각 기록합니다.

각 benchmark iteration은 동일한 DB 초기 상태에서 시작합니다.

  • 기존 Worker cardinality와 Worker Import fixture를 반복마다 동일하게 유지합니다.
  • 특히 commit 결과가 다음 iteration의 Worker 수와 duplicate lookup 조건에 영향을 주지 않도록 fixture를 reset합니다.
  • Before/After 모두 동일한 초기 데이터 상태를 사용합니다.

아래 표의 DB Calls는 논리 SQL row 수가 아니라, 개별 JDBC execution / batch execution 등 애플리케이션에서 발생한 DB 호출 패턴을 Before/After로 비교하기 위한 값입니다.
Batch 내부에서 처리되는 row 수는 별도로 기록할 수 있습니다.

Stage Rows Before median After median Improvement Speed-up DB Calls Before DB Calls After
create 1,000
mappings 1,000
validate 1,000
retry 1 modified / total 1,000
commit 1,000

권장 수동 benchmark:

warm-up 3회
measurement 20회
대표값 median
validate/retry/commit은 p95도 기록

특정 speed-up 수치를 사전에 완료 조건으로 두지 않습니다.


SQL 실행 패턴 회귀 테스트

CI에서는 절대 응답시간보다
행 단위 SQL로 회귀하지 않는지를 중심으로 검증합니다.

가능하면 PostgreSQL 통합 테스트에서 실제 JDBC statement/batch execution을 관측합니다.

핵심 invariant:

  • 1,000행 ImportRow가 1,000회의 개별 INSERT로 저장되지 않습니다.
  • Mapping reset이 ImportRow별 UPDATE로 반복되지 않습니다.
  • Validate의 Worker duplicate lookup이 행마다 반복되지 않습니다.
  • Validate 결과 저장이 행마다 개별 UPDATE되지 않습니다.
  • Retry도 위 bulk 검증 경로를 사용하며 per-row SQL로 회귀하지 않습니다.
  • Commit의 Worker 저장이 1,000회의 persist()+flush()로 실행되지 않습니다.
  • ImportRow COMMITTED 갱신도 행마다 개별 execution되지 않습니다.

절대 DB 호출 횟수는 구현 세부사항과 batch/chunk 크기에 따라 달라질 수 있으므로
핵심 계약은 **"row 수에 비례하는 동일한 개별 JDBC 실행으로 회귀하지 않는다"**로 둡니다.


PostgreSQL·Transaction 검증

  • PostgreSQL 기반 1,000행 통합 시나리오를 실행합니다.
  • 예상하지 않은 statement/lock/connection timeout이 발생하지 않습니다.
  • batch 실패 시 transaction 전체 rollback을 유지합니다.
  • 기존 optimistic conflict 계약을 유지합니다.
  • transaction 종료 후 tenant context 누수 없음
  • H2 결과만으로 성능 개선을 완료 처리하지 않습니다.

완료 조건

기능 회귀

  • ./gradlew clean test 통과
  • CSV/XLSX Import 정상 동작
  • 최대 1,000행 제한 유지
  • Mapping → Validate → Patch → Retry → Commit 상태 흐름 유지
  • Retry의 기존 검증 의미 유지
  • 오류·제외 행이 Worker로 등록되지 않음
  • 정상 행만 등록됨
  • create/commit idempotency 회귀 없음
  • optimistic version conflict 회귀 없음
  • tenant isolation 회귀 없음
  • audit event 의미 회귀 없음

성능 검증

  • baseline / optimized commit SHA 기록
  • 동일 환경 Before/After 반복 측정 완료
  • 1,000행 create/mappings/validate/commit median 기록
  • 1행 수정 retry median 기록
  • validate/retry/commit p95 기록
  • 각 단계 개선율·speed-up·DB Calls Before/After 기록
  • 핵심 per-row SQL 회귀 테스트 통과

배포 후 Smoke Test

  1. 소규모 정상 CSV/XLSX 업로드
  2. 1,000행 XLSX 또는 CSV 업로드
  3. Mapping 저장
  4. Validate 결과와 응답시간 확인
  5. 오류 1행 수정 후 Retry
  6. 정상 행 Commit
  7. 최종 Worker 수와 ImportRow 상태 확인
  8. timeout, rollback, tenant 격리 회귀 확인

이번 이슈에서 하지 않는 것

  • Worker Import 최대 행 수 확대
  • dependency-aware / incremental Retry 설계
  • Client 렌더링 최적화
  • 백그라운드 ETL 도입
  • CSV/XLSX Parser 전면 교체
  • Worker 중복 판정 의미 변경
  • RLS tenant 모델 재설계
  • DB connection pool 증설
  • timeout 증가로 성능 문제 우회
  • 모든 Repository 공통 bulk framework 도입
  • 근거 없는 index 추가
  • Worker Import 전용 Prometheus dashboard 구축

관계

이번 이슈는 #14의 기능 계약을 변경하는 작업이 아니라,
이미 구현된 Worker Import가 현재 허용 상한인 1,000행에서도 불필요한 행 단위 DB round-trip을 반복하지 않도록 보강하는 후속 성능 개선입니다.

성과는 특정 배수로 미리 약속하지 않고,
최종 PR의 동일 조건 Before/After benchmark와 SQL 실행 패턴으로 판단합니다.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:infraServer Dockerfile·DB 설정·CI hook·배포 가능성 영역; 통합 인프라 운영은 infra 저장소와 조율area:serverSpring Boot API·도메인·DB·tenant·Task Workflow 영역; Prompt·모델·Provider 구현 제외priority:P2일정에 따라 미룰 수 있는 보완 작업status:backlog해야 하지만 아직 시작 조건이 갖춰지지 않은 작업type:chore저장소 설정·의존성·유지보수 작업

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions