3일 이상에서 3.8시간으로: SQL Server용 .NET CSV 임포터 확장
Source: Dev.to
“충분히 좋은” 해결책이 아니었던 경우
모든 프로젝트에는 하나의 작업이 있습니다: “이 거대한 CSV 파일을 한 번만 데이터베이스에 로드하면 끝이다.”
그것이 나의 시작점이었습니다. 나는 40 GB CSV 파일을 가지고 있었는데, 그 안에는 복잡한 중첩 JSON 구조(모델, 다이어그램, 핫스팟, 요소)들이 포함되어 있었고, 이를 파싱하여 정규화된 SQL Server 스키마에 저장해야 했습니다.
첫 번째 버전의 임포터는 간단했습니다:
- 행을 읽는다.
- JSON을 파싱한다.
- 엔티티를 생성한다.
- DB에 저장한다.
동작은 했지만 완료하는 데 **3일 이상 (~92시간)**이 걸렸습니다. 일회성 마이그레이션이라면 고통스럽지만 감당할 수 있었습니다—금요일에 실행하고 월요일까지 끝나길 기대하는 식이었죠.
그때 요구사항이 바뀌었다
비즈니스 측에서는 이것이 일회성 작업이 아니라고 판단했습니다. 비슷한 크기의 파일을 몇 개 더 로드하고 정기적으로 업데이트할 필요가 생겼습니다. 갑자기 3일이라는 실행 시간이 장애물이 되었습니다. 파일 큐를 로드하는 데 몇 주가 걸리면 분석과 개발이 마비됩니다. 순차적인 단순 임포터는 이제 단순히 느린 것이 아니라, 새로운 워크플로우에 사용할 수 없게 되었습니다.
도전 과제: 왜 느렸을까?
데이터를 파싱하고 삽입하는 것은 간단해 보이지만, 규모가 (40 GB, 수백만 개의 복잡한 객체) 로 커지면 “표준” 접근 방식은 한계에 부딪힙니다:
- Sequential processing – 줄을 하나씩 읽고 JSON을 파싱하면 CPU가 DB를 기다리는 동안 유휴 상태가 되고, 반대로도 마찬가지였습니다.
- Database round‑trips – 엔티티를 개별적으로(또는 아주 작은 그룹으로) 저장하면 막대한 오버헤드가 발생했습니다. DB는 실제 데이터를 저장하는 것보다 트랜잭션 및 네트워크 호출을 관리하는 데 더 많은 시간을 소비했습니다.
- Memory pressure – 각 행마다 전체
JsonDocument객체를 로드하면 거대한 GC 압력이 발생했습니다. - Fragility – 2일간 처리한 후 단 한 번의 오류가 전체 파이프라인을 크래시시켜 재시작을 강요할 수 있었습니다.
Source: …
솔루션: 고성능 아키텍처
새로운 “멀티‑파일” 요구사항을 충족하기 위해 시스템을 병렬, 배치, 복원력 있게 재설계했습니다.
1. SemaphoreSlim을 이용한 제어된 병렬성
단일 스레드 대신 SemaphoreSlim을 사용해 8개의 병렬 작업자로 동시성을 제한하는 생산자‑소비자 패턴을 구현했습니다.
- 이유: CPU와 DB 연결 풀을 충분히 포화시켜 빠르게 동작하면서 서버가 과부하되는 것을 방지합니다. 무제한 병렬(
Parallel.ForEach)은 데이터베이스 성능을 크게 저하시켰을 것입니다. - 안전성: 각 작업자는
IDbContextFactory를 통해 자체DbContext를 받아 사용하므로 락 경합 없이 스레드 안전성을 확보합니다.
2. EF Core를 활용한 배치 삽입 (핵심 성공 요인)
가장 중요한 변화였습니다. 루프 안에서 context.Add(entity); context.SaveChanges(); 를 수행하던 방식을 버리고, 새로운 시스템은 엔티티를 메모리에 누적한 뒤 100개 이상씩 배치로 플러시합니다.
- 영향: 네트워크 왕복 횟수가 약 100배 감소하고, 트랜잭션 로그 부하가 크게 줄어듭니다.
3. 아키텍처 및 SOLID 원칙
코드 유지보수를 용이하게 하기 위해 파싱 로직을 독립적인 Processor들로 분리했습니다. 각 Processor는 JSON의 특정 부분(ModelProcessor, DiagramProcessor 등)을 담당합니다.
- SRP (단일 책임 원칙): 각 Processor는 도메인의 자신이 담당하는 영역만 처리합니다.
- DIP (의존성 역전 원칙): 고수준 서비스는 추상(
IEntityFactory,IUnitOfWork)에 의존하므로 시스템을 테스트하고 확장하기 쉽습니다.
4. 신뢰성 기능
- 재시도 정책: 일시적인 DB 오류(데드락, 타임아웃) 발생 시 최대 25회 재시도합니다.
- 우아한 강등: 하나의 Processor가 잘못된 데이터로 실패하더라도 오류를 로그에 남기고 전체 가져오기 작업을 중단하지 않고 계속 진행합니다.
- 최적화된 파싱:
JsonElement와TryGetProperty를 사용해 할당량이 적고 빠른 JSON 탐색을 구현했습니다.
결과: 24배 빠름
| 메트릭 | 원본 버전 | 최적화 버전 | 개선 정도 |
|---|---|---|---|
| 총 시간 (40 GB) | ~92 시간 (3.8 일) | ~3.8 시간 | ~24배 |
| 처리량 | 8–12 행/초 | 192–300 행/초 | ~25배 |
| 1 000행당 시간 | 83–125 초 | 3–5 초 | ~25배 |
| 병렬성 | 1 스레드 | 8 워커 | 8배 |
| 메모리 사용량 | 2 GB+ | ~400 MB | ~5배 |

주요 요점
- 맥락이 중요합니다: 3일짜리 스크립트는 한 번만 실행하면 괜찮지만, 반복해서 실행해야 한다면 치명적입니다. 항상 “우리는 이 작업을 얼마나 자주 실행할까요?” 라고 물어보세요.
- 배치가 최고입니다: EF Core에서 단일 삽입을 배치로 전환하는 것이 가장 효과적인 성능 향상 중 하나입니다.
- 병렬 처리에는 제한이 필요합니다: SQL Server에 100개의 스레드를 투입하면 오히려 속도가 느려집니다. “최적점”(예: 워커 8개)을 찾는 것이 핵심입니다.
- 탄력성은 기능입니다: 몇 시간 동안 실행하면 네트워크가 잠깐 끊기고 교착 상태가 발생합니다. 재시도 정책을 사용하면 충돌을 작은 로그 경고로 전환할 수 있습니다.
Future Plans
- 포괄적인 테스트(xUnit + Moq)를 추가하고 모든 프로세서에 대해 85 %+ 커버리지를 달성합니다.
- 개별 파이프라인 단계의 프로파일링을 수행하여 다음 병목 현상을 찾습니다(아마도 JSON 파싱 CPU 시간).
- 구성(배치 크기, 스레드 수)을 노출하여 다양한 서버 사양에 동적으로 맞출 수 있게 합니다.
코드 저장소
코드를 확인하세요: Link to GitHub Repository
[GitHub repository](https://github.com/belochka1-04/ParsCsvSaveInDb) 