기존 엑셀 업로드에 사용하는 insert 방식은 saveAll()인데 

프로젝트를 진행하며 최대 1,000건 정도의 insert를 테스트 했기때문에 조금 느리더라도 그러려니 했다.

하지만 10만건, 30만건, 100만건을 테스트 해본 결과 실행 시간이 너무 느리게 느껴졌고

리팩토링을 하는 김에 insert방식을 최적화 해보기로 했다.

 

현재 saveAll() 방식으로 엑셀 업로드를 했을때 실행 시간을 정리한 표이다.

saveAll() 수행 시간
10만건 37초
30만건 103초
100만건 325초

 

다양한 대량의 데이터 insert 방식

  • 순수 JDBC 기반 : JDBC Batch, JdbcTemplate.batchUpdate()
  • MyBatis 기반 : for-each + Mapper, SqlSession + ExecutorType.BATCH
  • JPA 기반 : saveAll(), bulk insert (native query)
  • 네이티브 SQL 기반 : JdbcTemplate, nativeQuery in JPA
  • 외부 라이브러리, Spring Batch, jOOQ, QueryDSL (native)

많은 방식 중에서 이번 프로젝트에서 엑셀 다운로드때 집계함수를 사용해서 mybatis를 사용했기 때문에
saveAll(), for-each + Mapper,SqlSession + ExecutorType.BATCH 3개의 성능을 비교해보려고 한다.

 

 

 

 

엑셀 파일 읽는 로직

public <T> List<T> parseExcelToObject(MultipartFile file, Class<T> clazz) throws IOException, InterruptedException  {
    IOUtils.setByteArrayMaxOverride(300_000_000); // 레코드 크기 300MB까지 허용

    System.gc(); // GC 유도
    Thread.sleep(100); // GC 안정 시간

    Runtime runtime = Runtime.getRuntime();

    long maxMemory = runtime.maxMemory();          // 최대 힙 메모리
    long totalMemory = runtime.totalMemory();      // 현재 할당된 힙 메모리
    long freeMemory = runtime.freeMemory();        // 사용 가능한 힙 메모리
    long usedMemory = totalMemory - freeMemory;    // 실제 사용 중인 메모리

    System.out.printf("Max Memory: %d MB%n", maxMemory / (1024 * 1024));
    System.out.printf("Total Memory: %d MB%n", totalMemory / (1024 * 1024));
    System.out.printf("freeMemory: %d MB%n", freeMemory / (1024 * 1024));
    System.out.printf("Used Memory: %d MB%n", usedMemory / (1024 * 1024));

    long beforeUsedMem = getUsedMemory();
    System.out.printf("Before: %d MB%n", beforeUsedMem / (1024 * 1024));

    Workbook workbook = WorkbookFactory.create(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);

    System.gc();
    Thread.sleep(100); // GC 안정 시간

    long afterUsedMem = getUsedMemory();
    System.out.printf("After: %d MB%n", afterUsedMem / (1024 * 1024));

    long memoryUsedByApi = afterUsedMem - beforeUsedMem;
    System.out.printf("Memory used by API: %d MB%n", memoryUsedByApi / (1024 * 1024));

    parseHeader(sheet, clazz);
    return parseBody(sheet, clazz);
}

 

    Workbook workbook = WorkbookFactory.create(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);

 

엑셀 100만 건 업로드를 실행했을 때 위 사진과 같이 에러가 발생했다.

디버깅 결과 엑셀 파일을 읽는 로직에서 문제가 발생한 것을 확인했다.

현재 프로젝트에 할당된 메모리 내에서 업로드 가능한 엑셀 데이터의 범위를 파악하기 위해

직접 10만 건, 30만 건의 엑셀 파일을 생성해 메모리 사용량을 측정해보았다.

  Max Memory Total Memory freeMemory Memory used by API 수행 시간
10만건 2048 MB 512 MB 458 MB 403 MB 8초
30만건 2048 MB 512 MB 461 MB 1227 MB

14초
100만건 2048 MB 512 MB 4?? MB 예상 4000 MB  예상 40초

 

100만 건을 업로드할 경우 약 4,000MB 이상의 메모리를 사용할 것으로 예상되는데,
현재 프로젝트에 할당된 메모리는 2,048MB로, 이를 초과하기 때문에 문제가 발생한 것으로 보인다.

해결 방안으로는

  1. 메모리 할당량을 늘린다.
  2. 엑셀 파일을 읽는 로직을 최적화한다.

이 중 근본적인 해결을 위해서는 2번 엑셀 파싱 로직을 최적화하는 방향이 정석이라고 판단했다.

 

에러 유형

java.lang.OutOfMemoryError

발생 원인

1. 기존에 사용한 엑셀 파싱 로직은 DOM방식으로 엑셀 파일 전체를 파싱해서 메모리에 저장한다.

2. Workbook 전체를 객체로 만들어 다루기 때문에 구조 접근은 편하지만, 대용량 처리에는 취약

문제 해결 시도

DOM방식 -> SAX방식 변경

방식 Streaming 방식 (SAX 기반) DOM 방식 (전체 메모리 로딩)
라이브러리 com.monitorjbl:xlsx-streamer (Apache POI 확장) org.apache.poi.ss.usermodel.WorkbookFactory
메모리 사용량 매우 적음 (필요한 행만 메모리에 올림) 높음 (전체 엑셀을 메모리에 올림)
대용량 처리 적합 (수십만~백만 건도 가능) 부적합 (수만 건 이상에서 OOM 발생 가능)

DOM vs SAX

 

DOM - > SAX 방식으로 수정

발생한 문제 1

에러 종류 NoSuchMethodError
에러 세부내용 org.apache.poi.xssf.model.SharedStringsTable org.apache.poi.xssf.eventusermodel.XSSFReader.getSharedStringsTable()
핵심 원인 POI 버전 변경으로 인한 리턴 타입 변경
관련 클래스 XSSFReader.getSharedStringsTable()
리턴 타입 변경 SharedStringsTable(POI 4.1.2) → SharedStrings (POI 5.x부터)
해결 방법 POI 버전 통일
// 호출 부분
private SharedStringsTable sst;
this.sst = reader.getSharedStringsTable();

// poi 5.2.3
public SharedStrings getSharedStringsTable() throws IOException, InvalidFormatException {
        ...
        ...
}

// poi 4.1.2
public SharedStringsTable getSharedStringsTable() throws IOException, InvalidFormatException {
        ...
        ...
}

수정한 코드

InputStream is = file.getInputStream();
Workbook workbook = StreamingReader.builder()
        .rowCacheSize(100)  // 메모리에 유지할 행 수
        .bufferSize(4096)
        .open(is);

Sheet sheet = workbook.getSheetAt(0);
Iterator<Row> rowIterator = sheet.iterator();

// 1. 헤더 파싱
if (!rowIterator.hasNext()) {
    throw new IllegalStateException("엑셀 파일에 데이터가 없습니다.");
}
Row headerRow = rowIterator.next();
parseHeader(headerRow, clazz);

// 2. 본문 파싱
List<T> data = new ArrayList<>();
int rowIndex = 1;

while (rowIterator.hasNext()) {
    Row row = rowIterator.next();
    if (row == null || row.getPhysicalNumberOfCells() == 0) continue;

    try {
        T instance = clazz.getDeclaredConstructor().newInstance();
        Method method = clazz.getMethod("fillUpFromRow", Row.class);
        method.invoke(instance, row);
        data.add(instance);

    } catch (Exception e) {
        throw new RuntimeException("Row " + rowIndex + " 변환 중 오류", e);
    }
    rowIndex++;
}

결과

DOM 방식 Max Memory Total Memory freeMemory Memory used by API 수행 시간
10만건 2048 MB 512 MB 458 MB 403 MB 8초
30만건 2048 MB 512 MB 461 MB 1227 MB

14초
100만건 2048 MB 512 MB 4?? MB 예상 4000 MB  예상 40초

 

SAX 방식 Max Memory Total Memory freeMemory Memory used by API 수행 시간
10만건 2048 MB 512 MB 458 MB 12 MB 7초
30만건 2048 MB 512 MB 461 MB 35 MB

12초
100만건 2048 MB 512 MB 460 MB 예상 113MB  27초

 

 10만 건 기준

  • DOM 방식: 약 403MB
  • SAX 방식: 약 12MB
  • → SAX 방식이 약 97% 더 적은 메모리 사용

30만 건 기준

  • DOM 방식: 약 1227MB
  • SAX 방식: 약 35MB
  • → SAX 방식이 약 97.2% 더 효율적

100만 건 기준 (예상치)

  • DOM 방식: 약 4000MB
  • SAX 방식: 약 113MB
  • → SAX 방식이 약 97.2% 이상 메모리 절감

- 100MB 초과로 인한 엑셀 파싱 실패

org.apache.poi.util.RecordFormatException: Tried to read data but the maximum length for this record type is 100,000,000.
If the file is not corrupt and not large, please open an issue on bugzilla to request 
increasing the maximum allowable size for this record type.
You can set a higher override value with IOUtils.setByteArrayMaxOverride()

 

- 에러 유형
  - org.apache.poi.util.RecordFormatException

- 발생 원인

  - excel 파일을 읽는 도중, 허용된 최대 크기(100MB)를 초과한 데이터를 포함한 레코드를 읽으려고 함

- 제한 사항

  - Apache POI는 기본적으로 1개의 레코드에 대해 100MB(100,000,000 바이트)까지만 읽도록 설정되어 있음.

레코드란??
내부적으로는 파일을 구성하는 여러 XML 혹은 바이너리 블록 단위
ex) sheet1 , styles, drawing

 

 

- 실제 압축 크기와 압축 해제 후 크기 확인

- xl/worksheets/sheet1.xml

  - 압축 크기 : 22.05 MB

  - 압축 해제 크기 : 246.73 MB

public <T> List<T> parseExcelToObject(MultipartFile file, Class<T> clazz) throws IOException {
        // MultipartFile을 임시 파일로 저장
        File tempFile = File.createTempFile("uploaded", ".xlsx");
        file.transferTo(tempFile);

        try (ZipFile zipFile = new ZipFile(tempFile)) {
            Enumeration<? extends ZipEntry> entries = zipFile.entries();
            while (entries.hasMoreElements()) {
                ZipEntry entry = entries.nextElement();
                System.out.println("Entry: " + entry.getName()
                        + ", Compressed Size: " + entry.getCompressedSize()
                        + ", Uncompressed Size: " + entry.getSize());
            }
        } finally {
            // 작업 끝나면 임시 파일 삭제
            tempFile.delete();
        }
        Workbook workbook = WorkbookFactory.create(file.getInputStream());
        Sheet sheet = workbook.getSheetAt(0);

        parseHeader(sheet, clazz);
        return parseBody(sheet, clazz);
    }
    
Entry: xl/drawings/drawing1.xml, Compressed Size: 261, Uncompressed Size: 775
Entry: xl/worksheets/sheet1.xml, Compressed Size: 23125563, Uncompressed Size: 258712875
Entry: xl/worksheets/_rels/sheet1.xml.rels, Compressed Size: 179, Uncompressed Size: 298
Entry: xl/theme/theme1.xml, Compressed Size: 808, Uncompressed Size: 3757
Entry: xl/sharedStrings.xml, Compressed Size: 237, Uncompressed Size: 352
Entry: xl/styles.xml, Compressed Size: 575, Uncompressed Size: 2192
Entry: xl/workbook.xml, Compressed Size: 339, Uncompressed Size: 807
Entry: xl/_rels/workbook.xml.rels, Compressed Size: 234, Uncompressed Size: 697
Entry: _rels/.rels, Compressed Size: 178, Uncompressed Size: 296
Entry: [Content_Types].xml, Compressed Size: 309, Uncompressed Size: 1049

 

코드 수정

public <T> List<T> parseExcelToObject(MultipartFile file, Class<T> clazz) throws IOException {
        IOUtils.setByteArrayMaxOverride(300_000_000); // 레코드 크기 300MB까지 허용
        Workbook workbook = WorkbookFactory.create(file.getInputStream());
        Sheet sheet = workbook.getSheetAt(0);

        parseHeader(sheet, clazz);
        return parseBody(sheet, clazz);
    }

 

 

 

나중에 찾아볼 내용

xlsx 파일은 사실 ZIP 파일이다??

엑셀 크기는 22MB이지만 실제로는 더 클수있다?

깨짐 현상

 

1. build tool 설정

gradle(디폴트) -> IntelliJ IDEA로 변경후 저장

 

'인강 > 자바 ORM 표준 JPA 프로그래밍 - 기본편' 카테고리의 다른 글

연관관계 매핑  (0) 2024.09.10
엔티티 매핑  (0) 2024.09.09

중요!!

1. 연관관계의 주인은 외래 키의 위치를 기준으로 정해야함

2. 단방향 매핑 설계를 목표로 하고 필요할 때 양방향 매핑을 추가한다.

 

 

 

방향(Direction) : 단방향, 양방향

다중성(Multiplicity) : 다대일, 일대다, 일대일

연관관계의 주인(Owner) : 객체 양뱡향 연관관계는 관리 주인이 필요

 

객체를 테이블에 맞추어 모델링

@Entity
 public class Member { 
     @Id @GeneratedValue
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
     
     @Column(name = "TEAM_ID") // 참조 대신에 외래 키를 그대로 사용
     private Long teamId; 
 } 
 
 @Entity
 public class Team {
     @Id @GeneratedValue
     private Long id;
     
     private String name; 

 }

객체 지향 모델링

@Entity
 public class Member { 
     @Id @GeneratedValue
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
     
     @ManyToOne
     @JoinColumn(name = "TEAM_ID") // 객체의 참조와 테이블의 외래 키를 매핑
     private Team team;
 } 
 
 @Entity
 public class Team {
     @Id @GeneratedValue
     private Long id;
     
     private String name; 

 }

 

객체를 테이블에 맞추어 모델링 vs 객체 지향 모델링

 

1. 멤버가 소속해있는 팀을 찾을때

// 객체를 테이블에 맞추어 모델링

// 멤버 조회
Member findMember = em.find(Member.class, member.getId());

// 해당 멤버 팀 id값 저장
Long findTeamId = findMember.getTeamId();

// 팀 조회(위에 저장한 팀 id값을 이용)
Team findTeam = em.find(Team.class, team.getId());




// 객체 지향 모델링

// 멤버 조회
Member findMember = em.find(Member.class, member.getId()); 

//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();

 

'인강 > 자바 ORM 표준 JPA 프로그래밍 - 기본편' 카테고리의 다른 글

다양한 연관관계 매핑  (0) 2024.10.28
엔티티 매핑  (0) 2024.09.09

@Column

속성 설명 기본값
name 필드와 매핑할 테이블의 컬럼 이름 객체의 필드 이름
insertable
updatable
등록, 변경 가능 여부 설정 true
nullable null값 허용 여부 설정
false -> not null 제약조건 붙음
 
unique 해당 컬럼의 유니크 제약조건
이름이 랜덤하게 들어가서 사용 잘 안함
 
length 문자 길이 제약조건, String 타입에서만 사용 255

 

'인강 > 자바 ORM 표준 JPA 프로그래밍 - 기본편' 카테고리의 다른 글

다양한 연관관계 매핑  (0) 2024.10.28
연관관계 매핑  (0) 2024.09.10

- 클래스 내부 멤버변수에서 사용할 데이터 타입을 외부에서 지정하는 기법이다.
- 제네릭의 타입 인자로 기본형(int, double)은 사용할 수 없다.
- 제네릭은 extends 키워드를 붙여서 타입 매개변수를 제한하고 원하는 타입과 하위 타입들만 받도록 설정할 수 있다.
- 제네릭 메서드를 정의할 때는 메서드의 반환 타입 왼쪽에 <T>와 같이 타입 매개변수를 적어준다.
- 명확한 반환 타입이 필요할때는 제네릭 메서드 반환 타입이 없을때는 와일드카드를 사용하는게 좋다.

 

클래스 수준의 제네릭 타입 파라미터를 사용하는 메서드 vs 메서드 수준의 제네릭 타입 파라미터를 사용하는 제네릭 메서드

package genericTest;

public class Main2 {

    public static void main(String[] args) {
        GenericBox2<Integer> genericBox2 = new GenericBox2<>();

        // 클래스 수준의 제네릭 타입 파라미터를 사용하는 메서드
        genericBox2.genericTypeMethod(123); // value = 123
        //genericBox2.genericTypeMethod("123"); // !! 에러발생 !! GenericBox2객체에 Integer를 할당했기 때문에 

        // 메서드 수준의 제네릭 타입 파라미터를 사용하는 제네릭 메서드
        genericBox2.genericMethod2("kim"); // value = kim
        genericBox2.genericMethod2(123); // value = 123
        genericBox2.genericMethod2(true); // value = true
    }
}
package genericTest;

public class GenericBox2 <T>{

    public T genericTypeMethod (T value) {
        System.out.println("value = " + value);
        return value;
    }
    public <Z> Z genericMethod2 (Z value) {
        System.out.println("value = " + value);
        return value;
    }
}

 

제네릭 클래스 타입 제한 사용법

public class Generic<T extends parent> {

}

 

제네릭 메서드 사용법

public <T> T instanceMethod1 (T value) {                       
        System.out.println("value = " + value);
        return value;
}

public <S extends Number> S instanceMethod2 (S value) {         
	System.out.println("value = " + value);
	return value;
}

static <T> T genericStaticMethod1 (T value) {                 
	System.out.println("value = " + value);
	return value;
}

 

와일드카드를 사용한 메서드

public void wildCard (Box<?> value) {
	System.out.println("value.getValue() = " + value.getValue());
}

public void wildCard2 (Box<? extends Animal> value) {
	System.out.println("value.getValue() = " + value.getValue());
}

+ Recent posts