본문 바로가기
실습/리눅스 서버 + 스프링 부트

[JAVA] POI 라이브러리를 사용한 엑셀 파싱

by 이민우 2024. 3. 30.
728x90
반응형

직전 프로젝트 두 번째 복기를 해볼까 한다.

 

우선 간단하게 설명하자면, 나는 사용자에게 파일을 받아 이를 HTTPFS를 통해 HDFS에 저장하고, 내용을 파싱해서 DB에 저장한느 프로그램을 개발했다.

 

이 때 받은 파일은 총 두 가지 종류로, CSV 파일 혹은 엑셀 파일 (xls, xlsx) 이다.

 

그래서 이번에는 POI 라이브러리를 통해 엑셀 파일을 파싱하는 코드를 공유해볼까 한다.

 

POI 라이브러리

POI ( Poor Obfuscation Implementation)는 Apache에서 개발한 Java용 라이브러리이로 Microsoft Office 문서 형식(예: Excel, Word, PowerPoint 등)을 생성, 수정 및 읽기 위한 라이브러리이다. 해당 라이브러리를 통해 Java 애플리케이션에서 Microsoft Office 파일을 다룰 수 있다.

 

Spring에서 사용하기 위해서는 아래 Dependency를 추가해주면 된다.

<dependency>
    <groupId>org.apache.poi</groupId>
	<artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>

 

 

 

프로젝트 생성

프로젝트는 간단하게 MVC로 만들까 한다.

  • Spring Boot 3.2.7
  • JAVA 17

또한 별다른 디펜던시는 필요가 없고, poi는 위에서 작성한대로 수기로 작성할 것이기에 아래와 같이 Web과 Lombok 정도만 추가한다.

 

 

코드 작성

간단하게 아래의 로직대로 개발할까 한다.

  1. 사용자에게 파일을 전달받는다.
  2. 임시 파일로 저장한다
  3. 데이터를 파싱해서 콘솔에 출력한다.

 

정말 간단한 프로그램이므로, 아래와 같이 구성해서 작성한다.

  • CommonCode : 원래대로라면 컨트롤러와 서비스가 주고받기 위한 시그널 집합
  • UploadService : 컨트롤러에서 파일을 받아 비즈니스 로직 수행

 

우선 CommonCode 클래스는 아래와 같이 간단하게 작성했다.

 

CommonCode.java

package com.mwlee.test.common;

public class CommonCode {
	
	public static final Integer SUCCESS=1;
	public static final Integer FAIL=2;
	public static final Integer ERR_INTERNAL=3;
	public static final Integer EMPTY_FILE=4;
	public static final Integer FILE_EXISTS=5;
	public static final Integer INVALID_FILE=6;
	
}

 

다음은 Service이다. 코드 공개에 앞서 사설을 붙이자면 엑셀 파일을 읽어 workbook을 여는 방법은 아래처럼 다양하게 존재할 수 있다. 

Workbook workbook = WorkbookFactory.create(file)

 

하지만 나는 위의 방식보다는 아래의 방식으로 파일을 여는 방법을 채택했다.

OPCPackage pkg = OPCPackage.open(tmpFile);
Workbook workbook = new XSSFWorkbook(pkg);

 

우선 이유라면 간단한데, 개발한 프로그램은 윈도우 서버가 아니라 리눅스 서버에서 돌아가야하는 프로그램이었다.

 

하지만 윈도우에서는 잘 버티던 프로그램이 사양이 더 짱짱함에도 불구하고 리눅스 서버에서만 돌리면 속도가 너무 느리고, 또 파일내 데이터가 너무 커지면 해당 라이브러리 자체가 먹통이 되어 더 이상 로직이 진행되지 않는 현상이 발생했다. (윈도우 기준 1,000,000개 row 까지 버티던 게 리눅스에서는 못버팀. 500,000개가 최대였음.)

 

그래서 챗 지피티에게 문의한 결과 XSSFWorkbook을 사용하면 대용량 엑셀 파일을 처리하는데 최적화되더 있으며 메모리 사용량을 줄이고 처리 속도를 향상시킬 수 있다는 답변을 받았기 때문에 채택하게 되었다.

주고받았던 질의응답.

 

솔직히 WorkbookFactory.create 함수를 사용했다고 하더라도 어차피 xlsx 파일은 XSSFWorkbook으로 만들어진다. 게다가 위 방법은 XSSF가 아니라 SXSSF를 사용해야 한다. 고로 위 답변은 틀린 답변으로 생각이 된다.

어쨌든 xls를 사용할 일은 없고, 100만개의 데이터까지 굳이 버텨야 할 이유가 없으므로 xlsx만 받도록 개발을 진행했다.

 

바로 윗 줄에서 갑자기 xls가 나왔는데, 기본적으로 엑셀은 xls나 xlsx가 사용된다. 대충 MS Office 97-2003은 .xls, MS Office .xlsx라고 생각하면 쉽다.

그리고 .xls는 HSSFWorkbook 라이브러리를 사용해야하고, .xlsx는 XSSFWorkbook 라이브러리를 사용해야 한다. workbookFactory 사용 시 해당 라이브러리 중 하나를 자동으로 매핑해주지만, 위 코드는 XSSFWorkbook만을 사용하기에 .xls는 받을 수 없기에 한 말이다.

 

사설은 여기까지하고, 코드는 아래와 같이 작성했다.

별도의 설명은 주석으로 갈음한다.

 

UploadService.java

package com.mwlee.test.service;

import java.io.File;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;

import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import com.mwlee.test.common.CommonCode;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class UploadService {
	
	private static final String tmpDir = "C:\\files\\";
	private DecimalFormat decimalFormat = new DecimalFormat("0");
	
	/**
	 * MultiPartFile을 File로 임시저장
	 * 
	 * @param file
	 * @return
	 * @throws IOException
	 */
	public File saveAndGetTmpFile(MultipartFile file) throws IOException {
		File tmpFile = new File(tmpDir + file.getOriginalFilename());
		
		file.transferTo(tmpFile);
		
		return tmpFile;
	}
	
	/**
	 * 임시파일 삭제
	 * 
	 * @param file
	 * @return
	 */
	public boolean deleteTmpFile(File file) {
		file.delete();
		if(file.exists()) {
			return false;
		}
		return true;
	}

	/**
	 * 엑셀 파일을 업로드받아 파싱
	 * 
	 * @param file
	 * @return
	 */
	public Integer uploadExcelFile(MultipartFile file) {
		File tmpFile = null;
		try {
			tmpFile = saveAndGetTmpFile(file);
		}
		catch(IOException e) {
			log.error("@@ Error Occured While Save TmpFile : {}", e.getMessage());
			return CommonCode.ERR_INTERNAL;
		} 
		
		
		try {
			
			// 파일을 열어서 Workbook 생성
			OPCPackage pkg = OPCPackage.open(tmpFile);
			Workbook workbook = new XSSFWorkbook(pkg);
			
			// 첫 번째 시트만 연다.
			Sheet sheet = workbook.getSheetAt(0);
			
			// 한 줄씩 파싱 (keys : 맨 윗줄로 컬럼들 / values : 둘째줄부터 입력된 values들)
			List<String> keys = new ArrayList<>();
			List<List<String>> values = new ArrayList<>();
			for(Row row : sheet) {
				// 테스트용이므로 전부 String으로 받기
				List<String> arr = new ArrayList<>();
				// 뒷글에서 설명하겠지만 아래와 같이 하면 안됨.
				for(Cell cell : row) {
					switch(cell.getCellType()) {
						// POI 라이브러리는 CELL 내용의 타입에 따라 분기되어야 함.
						case STRING: 
							arr.add(cell.getStringCellValue().trim());
							break;
						case NUMERIC: 
							if (DateUtil.isCellDateFormatted(cell)) {
								// 날짜도 숫자로 입력되기에 별도 처리
			                    arr.add(cell.getDateCellValue().toString());
			                } 
							else {
			                    double numericValue = cell.getNumericCellValue();
			                    // 숫자를 일반적인 형식으로 표현 : 이렇게 안하면 3E+03 같은 게 나옴.
			                    arr.add(decimalFormat.format(numericValue));
			                }
							break;
						default :
							arr.add("");
					}
				}
                if(row.getRowNum() == 0) {
					// 첫째 줄이면 keys에 입력
					keys = arr;
				}
				else {
					values.add(arr);
				}
			}
            
			// 잘 입력됐나 출력해보기
			for(String key : keys) {
				System.out.print(key + "\t");
			}
			System.out.println();
			for(List<String> value : values) {
				for(String val : value) {
					System.out.print(val + "\t");
				}
				System.out.println();
			}
			
		} catch (InvalidFormatException e) {
			log.error("@@ Error Occured While Open OPCPackage : {}", e.getMessage());
			return CommonCode.INVALID_FILE;
		} catch (IOException e) {
			log.error("@@ Error Occured While Open Workbook : {}", e.getMessage());
			return CommonCode.INVALID_FILE;
		} finally {
			// 임시 파일 삭제
			if(!deleteTmpFile(tmpFile)) {
				log.warn("@@ Can't Delete TmpFile {}. Delete Manually.", tmpFile.getName());
			}
		}
		
		return CommonCode.SUCCESS;
	}
	
}

 

 

 

테스트

이제 테스트를 해보자 아래와 같은 엑셀 파일을 생성했다.

 

그리고 테스트 자바 파일을 작성한다.

package com.mwlee.test;

import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.util.ResourceUtils;
import org.springframework.web.multipart.MultipartFile;

import com.mwlee.test.common.CommonCode;
import com.mwlee.test.service.UploadService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@SpringBootTest
class FileparserTestApplicationTests {

	@Autowired UploadService service;
	
	@Test
	void contextLoads() {
	}
	
	@Test
	void testUploadExcel() {
		try {
			// 사용자에게 업로드되었다고 가정하기 위해 MultipartFile 전송
			File file = ResourceUtils.getFile("C://test-excel.xlsx");
			InputStream is = new FileInputStream(file);
			MultipartFile mFile = new MockMultipartFile("file", file.getName(), "application/octet-stream", is);
			
			Integer result = service.uploadExcelFile(mFile);
			
			if(result == CommonCode.SUCCESS) {
				log.info("@@ Test Scuuess.");
			}
			else if(result == CommonCode.INVALID_FILE) {
				log.info("@@ Test Fail. Invalid File");		
			}
			else if(result == CommonCode.ERR_INTERNAL) {
				log.info("@@ Test Fail. Error Internal");
			}
		}
		catch(Exception e) {
			e.printStackTrace();
		}
	}

}

 

 

테스트

 

이제 테스트를 돌려보자.

 

정상적으로 돌아감을 확인할 수 있다.

 

그런데 한 가지 의문이 생길 것이다. 서비스 코드를 자세히 읽어보면 아래와 같은 주석을 달아놓았다.

 

개발을 하던 중 중간중간 데이터가 빠진 엑셀 파일들에 대한 테스트를 진행했었다.

예시로 들면 아래와 같다.

 

만약 이렇게 데이터가 주어지면 어떻게 처리가 될까?

한 번 돌려보자.

 

보이는 것과 같이 1st_col, 2nd_col이 null이 아닌 다음 값이 삽입되어있다.

 

문제가 된 코드는 아래 코드이다.

for(Cell cell : row) {
	switch(cell.getCellType()) {
		// POI 라이브러리는 CELL 내용의 타입에 따라 분기되어야 함.
		case STRING: 
			arr.add(cell.getStringCellValue().trim());
			break;
		case NUMERIC: 
			if (DateUtil.isCellDateFormatted(cell)) {
				// 날짜도 숫자로 입력되기에 별도 처리
				arr.add(cell.getDateCellValue().toString());
			} 
			else {
				double numericValue = cell.getNumericCellValue();
				// 숫자를 일반적인 형식으로 표현 : 이렇게 안하면 3E+03 같은 게 나옴.
				arr.add(decimalFormat.format(numericValue));
			}
			break;
		default :
			arr.add("");
	}
}

 

보다싶이 row의 각 cell을 순회하는데, 만약 빈 셀이 있을 경우 이를 null로 취급하지 않고 아예 무시하고 그 다음 셀로 넘어가는 것이 문제이다.

 

이를 해결하기 위해서는 for each보다는 for 문으로 셀의 숫자를 지정하고, null일 경우에 대한 처리를 MissingCellPolicy로 지정하면 된다.

 

코드는 아래와 같이 변경되면 된다.

for(int cn=0; cn<row.getLastCellNum(); cn++) {
	// 빈 값 체크
    Cell cell = row.getCell(cn, MissingCellPolicy.CREATE_NULL_AS_BLANK);
	if(cell == null) {
		arr.add(null);
		continue;
	}
	// 빈 값이 아닐 경우 원래 로직
    switch(cell.getCellType()) {
		// POI 라이브러리는 CELL 내용의 타입에 따라 분기되어야 함.
		case STRING: 
			arr.add(cell.getStringCellValue().trim());
			break;
		case NUMERIC: 
			if (DateUtil.isCellDateFormatted(cell)) {
				// 날짜도 숫자로 입력되기에 별도 처리
				arr.add(cell.getDateCellValue().toString());
			} 
			else {
				double numericValue = cell.getNumericCellValue();
				// 숫자를 일반적인 형식으로 표현 : 이렇게 안하면 3E+03 같은 게 나옴.
				arr.add(decimalFormat.format(numericValue));
			}
			break;
		default :
			arr.add("");
	}
}

 

추가로 아래와 같은 데이터가 들어올 때, col_3같이 뒤에 있는 데이터를 null로 처리할 수 있도록 예외를 하나 더 생성한다.

col_1 col_2 col_3
1 2  
if(row.getRowNum() == 0) {
	// 첫째 줄이면 keys에 입력
	keys = arr;
}
else {
	while(arr.size() != keys.size()) {
		/**
		 * 중간 값이 비어있는건 문제가 없지만 마지막 값이 비어있는 건 문제가될 수 있음
		 * 예를 들어
		 * 
		 * col_1	col_2	col_3
		 * 1				3
		 * 
		 * 이면 (1, null, 3)이지만
		 * 
		 * col_1	col_2	col_3
		 * 1		2
		 * 이면 (1, 2)가 됨.
		 * 고로 size()가 맞지 않으면 뒤에 null들을 추가해줘야 함.
		 */
		arr.add(null);
	}
	values.add(arr);
}

 

 

이를 변경해서 다시 돌려보면 아래와 같이 빈 값에 대한 예외가 잘 처리된다.

 

그리고 이를 적용한 Service 코드는 아래와 같다.

 

UploadService.java

package com.mwlee.test.service;

import java.io.File;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;

import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Row.MissingCellPolicy;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import com.mwlee.test.common.CommonCode;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class UploadService {
	
	private static final String tmpDir = "C:\\files\\";
	private DecimalFormat decimalFormat = new DecimalFormat("0");
	
	/**
	 * MultiPartFile을 File로 임시저장
	 * 
	 * @param file
	 * @return
	 * @throws IOException
	 */
	public File saveAndGetTmpFile(MultipartFile file) throws IOException {
		File tmpFile = new File(tmpDir + file.getOriginalFilename());
		
		file.transferTo(tmpFile);
		
		return tmpFile;
	}
	
	/**
	 * 임시파일 삭제
	 * 
	 * @param file
	 * @return
	 */
	public boolean deleteTmpFile(File file) {
		file.delete();
		if(file.exists()) {
			return false;
		}
		return true;
	}

	/**
	 * 엑셀 파일을 업로드받아 파싱
	 * 
	 * @param file
	 * @return
	 */
	public Integer uploadExcelFile(MultipartFile file) {
		File tmpFile = null;
		try {
			tmpFile = saveAndGetTmpFile(file);
		}
		catch(IOException e) {
			log.error("@@ Error Occured While Save TmpFile : {}", e.getMessage());
			return CommonCode.ERR_INTERNAL;
		} 
		
		
		try {
			
			// 파일을 열어서 Workbook 생성
			OPCPackage pkg = OPCPackage.open(tmpFile);
			Workbook workbook = new XSSFWorkbook(pkg);
			
			// 첫 번째 시트만 연다.
			Sheet sheet = workbook.getSheetAt(0);
			
			// 한 줄씩 파싱 (keys : 맨 윗줄로 컬럼들 / values : 둘째줄부터 입력된 values들)
			List<String> keys = new ArrayList<>();
			List<List<String>> values = new ArrayList<>();
			for(Row row : sheet) {
				// 테스트용이므로 전부 String으로 받기
				List<String> arr = new ArrayList<>();
				for(int cn=0; cn<row.getLastCellNum(); cn++) {
					// 빈 값 체크
					Cell cell = row.getCell(cn, MissingCellPolicy.CREATE_NULL_AS_BLANK);
					if(cell == null) {
						arr.add(null);
						continue;
					}
					// 빈 값이 아닐 경우 원래 로직
					switch(cell.getCellType()) {
						// POI 라이브러리는 CELL 내용의 타입에 따라 분기되어야 함.
						case STRING: 
							arr.add(cell.getStringCellValue().trim());
							break;
						case NUMERIC: 
							if (DateUtil.isCellDateFormatted(cell)) {
								// 날짜도 숫자로 입력되기에 별도 처리
			                    arr.add(cell.getDateCellValue().toString());
			                } 
							else {
			                    double numericValue = cell.getNumericCellValue();
			                    // 숫자를 일반적인 형식으로 표현 : 이렇게 안하면 3E+03 같은 게 나옴.
			                    arr.add(decimalFormat.format(numericValue));
			                }
							break;
						default :
							arr.add(null);
					}
				}
				if(row.getRowNum() == 0) {
					// 첫째 줄이면 keys에 입력
					keys = arr;
				}
				else {
					while(arr.size() != keys.size()) {
						/**
						 * 중간 값이 비어있는건 문제가 없지만 마지막 값이 비어있는 건 문제가될 수 있음
						 * 예를 들어
						 * 
						 * col_1	col_2	col_3
						 * 1				3
						 * 
						 * 이면 (1, null, 3)이지만
						 * 
						 * col_1	col_2	col_3
						 * 1		2
						 * 이면 (1, 2)가 됨.
						 * 고로 size()가 맞지 않으면 뒤에 null들을 추가해줘야 함.
						 */
						arr.add(null);
					}
					values.add(arr);
				}
			}
			
			// 잘 입력됐나 출력해보기
			for(String key : keys) {
				System.out.print(key + "\t");
			}
			System.out.println();
			for(List<String> value : values) {
				for(String val : value) {
					System.out.print(val + "\t");
				}
				System.out.println();
			}
			
		} catch (InvalidFormatException e) {
			log.error("@@ Error Occured While Open OPCPackage : {}", e.getMessage());
			return CommonCode.INVALID_FILE;
		} catch (IOException e) {
			log.error("@@ Error Occured While Open Workbook : {}", e.getMessage());
			return CommonCode.INVALID_FILE;
		} finally {
			// 임시 파일 삭제
			if(!deleteTmpFile(tmpFile)) {
				log.warn("@@ Can't Delete TmpFile {}. Delete Manually.", tmpFile.getName());
			}
		}
		
		return CommonCode.SUCCESS;
	}
	
}
728x90
반응형