한 줄 목표
현재 최대 허용치인 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화
예시:
SELECT LOWER(display_name)
FROM worker
WHERE company_id = ?
AND LOWER(display_name) IN (...);
PostgreSQL parameter 수와 query plan을 고려한 chunking은 허용합니다.
2. Validate 결과 batch UPDATE
3. 상태 count aggregate query
4. Mapping reset set-based 처리
예시:
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
6. Commit bulk 저장
구현 원칙
이번 작업은 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 제거를 먼저 수행합니다.
검토 순서:
- N+1 제거
- 대표 Worker cardinality fixture 준비
EXPLAIN (ANALYZE, BUFFERS) 확인
- 실제 plan에서 필요할 경우 expression composite index 추가
후보:
CREATE INDEX ...
ON worker (company_id, LOWER(display_name));
성능 검증
최적화 전후를 동일한 조건에서 반복 측정합니다.
동일한 1,000행 XLSX/CSV fixture
동일한 PostgreSQL major version
동일한 JVM
동일한 Spring profile
동일한 DB connection pool 설정
동일한 머신 또는 runner
동일한 측정 방식
baseline commit과 optimized commit SHA를 각각 기록합니다.
각 benchmark iteration은 동일한 DB 초기 상태에서 시작합니다.
아래 표의 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:
절대 DB 호출 횟수는 구현 세부사항과 batch/chunk 크기에 따라 달라질 수 있으므로
핵심 계약은 **"row 수에 비례하는 동일한 개별 JDBC 실행으로 회귀하지 않는다"**로 둡니다.
PostgreSQL·Transaction 검증
완료 조건
기능 회귀
성능 검증
배포 후 Smoke Test
- 소규모 정상 CSV/XLSX 업로드
- 1,000행 XLSX 또는 CSV 업로드
- Mapping 저장
- Validate 결과와 응답시간 확인
- 오류 1행 수정 후 Retry
- 정상 행 Commit
- 최종 Worker 수와 ImportRow 상태 확인
- 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 실행 패턴으로 판단합니다.
한 줄 목표
현재 최대 허용치인 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 측정값은 다음과 같습니다.
이번 이슈는 timeout이나 connection pool 값을 키우는 대신,
현재 허용 상한인 1,000행에서 행 수에 비례하는 개별 DB round-trip과 non-batch JDBC 실행을 줄이는 것을 목표로 합니다.
현재
main에서 확인된 병목1. ImportRow 생성이 per-row INSERT
JdbcWorkerImportRepository.insert(...)는 ImportJob 저장 후각 ImportRow마다
jdbcTemplate.update(...)를 호출합니다.2. Mapping 저장이 전체 조회 + 행별 UPDATE
현재 흐름:
매핑 변경에 따른 validation reset은 동일한 상태 전환이므로
set-based UPDATE로 처리할 수 있습니다.
3. Validate의 Worker 중복 조회 N+1
각
display_name마다 기존 Worker 중복 여부를 조회합니다.1,000행 검증에서 이 조회가 행 수에 비례해 반복될 수 있습니다.
4. Validate 결과가 per-row UPDATE
각 행 검증 후 다음 값을 개별 UPDATE합니다.
검증 계산은 Java에서 유지하더라도 DB 반영은 batch로 묶을 수 있습니다.
5. 상태 count를 위해 전체 ImportRow를 다시 조회
현재 여러 경로에서:
형태로 count만 필요해도 전체 ImportRow와 JSON 컬럼을 다시 읽습니다.
다음 aggregate query로 대체할 수 있습니다.
이 항목은 주된 성능 개선이라기보다,
다른 bulk 최적화와 함께 불필요한 전체 row load를 제거하는 정리입니다.
6. Retry도 동일한 per-row 검증 비용을 반복
현재 Retry는 별도 부분 검증 경로가 아니라
validate(..., retry=true)를 통해 미확정 행을 다시 검증합니다.이번 이슈에서는 Retry의 검증 의미를 변경하지 않습니다.
먼저 Validate 자체의:
를 제거해 Retry도 같은 bulk 경로를 사용하도록 합니다.
최적화 후에도 전체 재검증 자체가 실제 병목으로 남는다면
dependency-aware 부분 재검증은 별도 후속 이슈에서 검토합니다.
7. Commit의 Worker 저장이 per-row persist + flush
현재 Import commit은 VALID 후보를 순회하면서:
를 반복합니다.
또한 현재
JpaWorkerRepository.insert()는persist()직후EntityManager.flush()를 호출하므로 Worker마다 flush가 발생합니다.1,000행 commit에서 batch 적용을 방해할 수 있으므로
Import 전용 bulk 저장 경로 또는 동등한 방식으로 개선합니다.
구현 범위
1. Validate의 Worker 중복 조회 bulk화
display_name을 기존 규칙과 동일하게 정규화합니다.Set/Map으로 구성해 각 행의 충돌 여부를 판정합니다.existsWorkerByDisplayName()을 호출하는 구조를 제거합니다.company_id로 제한합니다.예시:
PostgreSQL parameter 수와 query plan을 고려한 chunking은 허용합니다.
2. Validate 결과 batch UPDATE
jdbcTemplate.update()로 실행하지 않습니다.JdbcTemplate.batchUpdate()또는 동등한 batch 방식을 사용합니다.3. 상태 count aggregate query
VALID,INVALID,EXCLUDED,COMMITTED개수를 DB aggregate로 계산합니다.4. Mapping reset set-based 처리
saveMappings()의 per-row reset을 제거합니다.COMMITTED,EXCLUDED행은 변경하지 않습니다.예시:
5. ImportRow 생성 batch INSERT
6. Commit bulk 저장
persist()+flush()반복을 제거합니다.insertAll(...)또는 동등한 bulk 저장 경로를 사용합니다.COMMITTED상태 갱신도 batch 처리합니다.구현 원칙
이번 작업은 Worker Import의 DB 접근 방식만 최적화합니다.
다음 계약은 변경하지 않습니다.
display_name중복 판정display_name중복 후보 판정company_idtenant isolation다음 방식으로 우회하지 않습니다.
statement_timeout/lock_timeout증가Issue #64의 Runtime timeout guard는 안전선으로 유지하며,
이번 이슈의 query 최적화를 대신하지 않습니다.
인덱스 검토
Worker duplicate lookup은 다음 predicate를 사용합니다.
이번 작업에서는 N+1 제거를 먼저 수행합니다.
검토 순서:
EXPLAIN (ANALYZE, BUFFERS)확인후보:
성능 검증
최적화 전후를 동일한 조건에서 반복 측정합니다.
baseline commit과 optimized commit SHA를 각각 기록합니다.
각 benchmark iteration은 동일한 DB 초기 상태에서 시작합니다.
commit결과가 다음 iteration의 Worker 수와 duplicate lookup 조건에 영향을 주지 않도록 fixture를 reset합니다.아래 표의
DB Calls는 논리 SQL row 수가 아니라, 개별 JDBC execution / batch execution 등 애플리케이션에서 발생한 DB 호출 패턴을 Before/After로 비교하기 위한 값입니다.Batch 내부에서 처리되는 row 수는 별도로 기록할 수 있습니다.
권장 수동 benchmark:
특정 speed-up 수치를 사전에 완료 조건으로 두지 않습니다.
SQL 실행 패턴 회귀 테스트
CI에서는 절대 응답시간보다
행 단위 SQL로 회귀하지 않는지를 중심으로 검증합니다.
가능하면 PostgreSQL 통합 테스트에서 실제 JDBC statement/batch execution을 관측합니다.
핵심 invariant:
persist()+flush()로 실행되지 않습니다.COMMITTED갱신도 행마다 개별 execution되지 않습니다.절대 DB 호출 횟수는 구현 세부사항과 batch/chunk 크기에 따라 달라질 수 있으므로
핵심 계약은 **"row 수에 비례하는 동일한 개별 JDBC 실행으로 회귀하지 않는다"**로 둡니다.
PostgreSQL·Transaction 검증
완료 조건
기능 회귀
./gradlew clean test통과성능 검증
배포 후 Smoke Test
이번 이슈에서 하지 않는 것
관계
이번 이슈는 #14의 기능 계약을 변경하는 작업이 아니라,
이미 구현된 Worker Import가 현재 허용 상한인 1,000행에서도 불필요한 행 단위 DB round-trip을 반복하지 않도록 보강하는 후속 성능 개선입니다.
성과는 특정 배수로 미리 약속하지 않고,
최종 PR의 동일 조건 Before/After benchmark와 SQL 실행 패턴으로 판단합니다.