Java에서 엑셀 파일을 다루기 위해 사용하는 Apache POI 라이브러리에 대해 공부해보자 ❕
Apache POI(Poor Obfuscation Implementation)는 아파치 소프트웨어 재단에서 만든 라이브러리로 마이크로소프트 오피스 파일 포맷을 자바 언어로 읽고 쓰는 기능을 제공한다.
전에 구현했던 프로젝트에서 엑셀 파일을 읽고 파일을 생성하는 기능이 필요해 해당 라이브러리를 사용했었는데, Apache POI 라이브러리를 사용하기 전에 알아야 할 개념과 사용법을 정리해보려고 한다.
Apache POI - the Java API for Microsoft Documents
<!--+ |breadtrail +--> <!--+ |start Menu, mainarea +--> <!--+ |start Menu +--> <!--+ |end Menu +--> <!--+ |start content +--> Apache POI - the Java API for Microsoft Documents Project News 25 November 2023 - POI 5.2.5 available The Apache POI team is pleas
poi.apache.org
1. Multipart & MultipartFile
HTTP는 기본적으로 텍스트 기반의 요청과 응답을 처리하는데, form 데이터를 전송할 때 `application/x-www-form-urlencoded` 형식으로 전송된다. 이는 HTTP body에 데이터가 들어가 인코딩되어 전송되는 형태이다.
하지만 파일과 같은 이진 데이터를 전송할 때는 텍스트 형식으로 인코딩하는 것은 비효율적이고 제한이 있다. 이런 파일과 이진 데이터 등을 효율적으로 보낼 수 있게 하는 것이 멀티파트(multipart) 방식이다. 멀티파트는 데이터를 전송할 때 `multipart/form-data` 형식으로 전송되고, 이진 데이터를 인코딩하지 않고 전송할 수 있게 한다.
Multipart
멀티파트는 클라이언트와 서버 간 HTTP 통신에서 여러 종류의 데이터를 동시에 전송하기 위해 사용되는 방식으로, HTTP body에 여러 종류의 데이터(텍스트, 파일 등)를 구분자로 나누고, 기본 form 전송과는 다르게 인코딩되지 않은 그대로 전송된다.
일반적으로 파일 업로드와 관련된 데이터를 전송하는데 주로 사용된다.
- 만약
form
에서 파일에 대한<input />
과 텍스트에 대한<input />
총 2개의 데이터를 서버에 전송하는 경우, 두 데이터 간에 Content-Type이 다르니 이 때 multipart 타입을 사용해 전달할 수 있다. - 멀티파트 요청은 HTTP header에 'Content-Type’을 `multipart/form-data`로 명시해 설정할 수 있다.
- 데이터를 나눠서 전송하기 때문에 멀티파트 요청을 처리하기 위해서는 서버 측에서 멀티파트 데이터를 파싱하고 해석하는 로직이 필요하다.
- HttpServletRequest는 웹 클라이언트가 전달하는 멀티파트 데이터를 처리하기 위해 `getPart()`, `getParts()` 메서드를 제공한다. (Servlet 3.0 이전에는 별도의 라이브러리를 통해 멀티파트 데이터를 처리해야 했다)
- ^Spring에서는 멀티파트 파일을 다루기 위해 MultipartFile 인터페이스를 제공한다.^ (스프링이 Servlet 3.0의 `getPart()`, `getParts()`를 이용해 멀티파트 데이터를 처리하는 것이다)
- Spring에서는 Multipart 데이터를 처리하기 위해 MultipartResolver 구현체를 직접 Bean으로 등록해줘야 하고, SpringBoot는 자동으로 빈이 등록된다.
- multipart 타입은 body에 데이터가 들어가야 해서 POST 요청에만 가능하다.
(파일을 서버로 전송했을 때)
HTTP header의 Content-Type 필드가 `multipart/form-data`이며, HTTP body에서 데이터는 `boundary`값으로 구분된다.
MultipartFile
스프링에서 업로드한 파일을 표현할 때 사용되는 인터페이스이다. MultipartFile 인터페이스를 이용해 업로드된 파일의 이름, 데이터, 크기 등을 구할 수 있다.
클라이언트가 파일을 업로드하면 WAS(Tomcat)가 해당 파일을 임시 디렉터리에 저장한다. 임시 디렉터리에 저장된 파일은 서버의 디스크에 저장되고, 요청이 끝나면 삭제된다. 따로 설정하지 않는다면 저장 위치는 WAS가 결정한다.
매번 임시 디렉터리에 저장되는건 아니고, 업로드된 파일 크기가 특정값을 초과했을 때에만 저장된다. 파일 크기가 특정값보다 작다면 임시파일을 생성하지 않고 메모리에 할당한다.
다음은 스프링에서 multipartfile 업로드를 사용할 때 설정할 수 있는 값들이다. (각 default값으로 표시)
spring.servlet.multipart.enabled=true # 멀티파트 업로드 지원여부
spring.servlet.multipart.file-size-threshold=0B # 파일을 디스크에 저장하지 않고 메모리에 저장하는 최소 크기
spring.servlet.multipart.location=? # 업로드된 파일이 임시로 저장되는 디스크 위치
spring.servlet.multipart.max-file-size=1MB # 한 파일의 최대 사이즈
spring.servlet.multipart.max-request-size=10MB # 한 요청의 최대 사이즈
주요 메서드
메서드명 | 설명 |
getName() | 파라미터 이름 (key값) |
getOriginFilename() | 업로드한 파일의 이름 |
getContentType() | 파일의 콘텐츠 타입 |
isEmpty() | 파일 존재 여부 |
getSize() | 파일 크기 |
getBytes() | 파일 데이터 |
getInputStream() | 업로드한 파일 데이터를 읽기 위한 InputStream |
transferTo(File dest) | 업로드한 파일을 특정 파일에 저장 |
2. POI 주요 용어
- 엑셀 파일은 엑셀 파일 - 시트 - 행 - 셀 로 구성되는데, poi에서는 다음과 같이 매칭된다.
- Workbook : 하나의 엑셀 파일
- Sheet : 엑셀 파일의 시트
- Row : 시트 안에 있는 행
- Cell : 시트 안에 데이터를 쓸 수 있는 셀
- 주요 클래스들이 HSSF 또는 XSSF로 시작한다.
- HSSF - Excel 97 (.xls) 파일 포맷을 사용할 때 사용
ex) HSSFWorkbook, HSSFSheet, HSSFRow, HSSFCell - XSSF - Excel 2007 OOXML (.xlsx) 파일 포맷을 사용할 때 사용
ex) XSSFWorkbook, XSSFSheet, XSSFRow, XSSFCell
- HSSF - Excel 97 (.xls) 파일 포맷을 사용할 때 사용
3. 구현
엑셀 읽기 및 엑셀 생성하기 기능을 구현하면서 poi라이브러리 사용법에 대해 정리해보려고 한다.
구현 환경 : React + Spring
시나리오
- 클라이언트에서 엑셀 파일을 업로드하면 서버는 엑셀 데이터를 뽑아낸다. 뽑아낸 값들을 객체로 만들어 반환한다.
- 클라이언트는 반환된 값을 다시 서버로 전달해 엑셀 파일을 생성(다운로드)한다.
Apache POI 의존성 추가 - maven
poi-ooxml을 추가하면 poi 관련 라이브러리들이 추가된다.
<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.0.0</version>
</dependency>
1) 파일 읽기
- 클라이언트에서 엑셀 파일을 업로드하면 서버는 엑셀 데이터를 뽑아낸다. 뽑아낸 값들을 객체로 만들어 반환한다.
클라이언트는 반환된 값을 다시 서버로 전달해 엑셀 파일을 생성(다운로드)한다.
Client
js의 FormData
를 이용해 파일 객체를 생성하고 서버에 API를 요청했다. FormData
는 form의 key-value값을 넣을 수 있으며, form 데이터가 전송될 때 Content-Type을 직접 설정해주지 않아도 `multipart/form-data`으로 전달된다.
export default function FileUploader(props) {
const [excelData, setExcelData] = useState(null);
const handleUploadExcelFile = async (e) => {
e.preventDefault();
let addFiles = e.target.files;
let uploadedFormData = new FormData();
// 파일을 선택하지 않은 경우
if (!uploadedFiles || uploadedFiles.length === 0) {
return;
}
uploadedFormData.append('file', addFiles[0]);
await axios.post(`/api/v1/poi-test/upload`, uploadedFormData)
.then(res => {
if (res.status === 200) {
setExcelData(res?.data?.data);
}
})
.catch(err => {
let res = err.response;
alert(res?.data?.message);
})
}
return (
<div>
<input
id="file-uploader"
type="file"
accept=".xls,.xlsx"
onClick={(e) => e.target.value = ''}
onChange={(e) => handleUploadExcelFile(e)}
/>
</div>
)
}
Server
@RequestParam
으로 위에서 설정한 key값('file')으로 전달된 파일을 읽는다. 이 때 Spring에서 제공하는 MultipartFile 타입을 이용한다.
@RestController
@RequestMapping("/api/v1/poi-test")
@RequiredArgsConstructor
public class PoiTestApiController {
private final PoiTestService poiTestService;
@PostMapping("/upload")
public ResponseEntity<?> uploadExcelFile(@RequestParam("file") MultipartFile file) throws Exception {
Message message = new Message();
try{
message.setData(poiTestService.uploadExcelFile(file));
message.setStatus(HttpStatus.OK);
message.setMessage("success");
} catch (Exception e) {
throw new Exception(e.getMessage());
}
return new ResponseEntity<>(message, message.getStatus());
}
}
먼저 엑셀 데이터를 담는 `DetailDto`와 Row 별 엑셀 데이터들을 구분하기 위한 `RowDto`를 생성한다.
@Service
public class PoiTestService {
public List<RowDto> uploadExcelFile(MultipartFile file) {
Workbook workbook = null;
List<RowDto> excelDto = null;
try{
// MultipartFile을 읽어 Workbook 생성
workbook = WorkbookFactory.create(file.getInputStream());
// Workbook의 첫번째 시트를 읽는다
Sheet sheet = workbook.getSheetAt(0);
excelDto = this.getUploadedExcelData(sheet);
workbook.close();
} catch (IOException e) {
throw new IllegalArgumentException("엑셀 파일 업로드 오류");
}
return excelDto;
}
private List<RowDto> getUploadedExcelData(Sheet worksheet) {
List<RowDto> dtos = new ArrayList<>();
List<DetailDto> detailDtos = null;
// sheet의 Row 개수만큼 반복
for(int i = 0; i < worksheet.getPhysicalNumberOfRows(); i++) {
// Row 0번부터 차례대로 읽는다
Row row = worksheet.getRow(i);
detailDtos = new ArrayList<>();
if (row == null) break;
// 현재 Row의 Cell 개수만큼 반복
for(int j = 0; j < row.getLastCellNum(); j++) {
Cell cell = row.getCell(j);
Object cellObj = new Object();
// Cell 타입에 따라 값을 읽는다
if(cell == null || cell.getCellType().equals(CellType.BLANK)) {
cellObj = "";
} else if (cell.getCellType().equals(CellType.STRING)) {
cellObj = cell.getStringCellValue();
} else if (cell.getCellType().equals(CellType.NUMERIC)) {
if (DateUtil.isCellDateFormatted(cell)) {
cellObj = cell.getDateCellValue();
} else {
cellObj = cell.getNumericCellValue();
}
}
DetailDto detailDto = DetailDto.builder().colData(cellObj).cellType(cellObj.getClass().getSimpleName()).build();
detailDtos.add(detailDto);
}
// Row 별 저장할 DetailDto들을 설정한다
RowDto uploadedData = RowDto.builder().details(detailDtos).build();
dtos.add(uploadedData);
}
return dtos;
}
}
uploadExcelFile()
: MultipartFile을 읽어 Workbook을 만들고 Sheet를 읽는다.getUploadedExcelData()
: Sheet의 행 크기(getPhysicalNumberOfRows()
)를 구하고 0번째 행부터 열 크기(getLastCellNum()
)만큼 데이터를 읽는다. 이 때 Cell 타입 별로 데이터를 읽는 방법이 다르기 때문에 CellType을 검사한 후 타입에 맞는 메서드를 이용해 데이터를 읽는다.
업로드한 엑셀 샘플이다. 업로드해서 응답받은 결과를 확인해보자.
첫번째 Row(data[0])에는 엑셀의 헤더 데이터가 들어가고, 그 다음 Row(data[1])부터는 2번째 행의 데이터들이 차례로 들어가 반환되었다.
2) 엑셀 파일 생성하기
클라이언트에서 엑셀 파일을 업로드하면 서버는 엑셀 데이터를 뽑아낸다. 뽑아낸 값들을 객체로 만들어 반환한다.- 클라이언트는 반환된 값을 다시 서버로 전달해 엑셀 파일을 생성(다운로드)한다.
Client
1번을 통해 클라이언트에 반환된 값을 state값 excelData
에 저장해두고 2번을 진행한다. api가 정상적으로 완료되면 엑셀 파일이 다운로드 & 실행된다.
export default function FileUploader(props) {
const [excelData, setExcelData] = useState(null);
...
const handleTestDownload = async (e) => {
e.preventDefault();
let fileName = 'chaego_excel'
await axios.post(`/api/v1/poi-test/download`, excelData, {
responseType: 'blob'
})
.then(res => {
// 임시 URL 생성
const url = window.URL.createObjectURL(new Blob([res.data], { type: res.headers['Content-Type'] }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${fileName}.xlsx`);
document.body.appendChild(link);
link.click();
// URL을 메모리에서 제거
window.URL.revokeObjectURL(url);
});
}
return (
<div>
<div className='button-box' onClick={(e) => handleTestDownload(e)}>
<button>download</button>
</div>
</div>
)
}
window.URL.createObjectURL()
은 브라우저 메모리에 임시로 URL을 생성한다. 따라서 실제 파일 경로가 아닌 메모리 내 객체에 대한 참조를 가리키고, 브라우저 세션 동안 유효하다. 새로고침하거나 브라우저를 닫으면 자동으로 메모리에서 제거되고 `window.URL.revokeObjectURL()`로 직접 제거할 수 있다.
요청에 대한 결과로 엑셀 파일을 전달받으면 엑셀 파일 객체의 URL이 생성된다. 생성된 URL로 이동할 수 있도록 `a`태그를 만들고 다운로드 속성을 추가해 실행시키면 파일이 열리게 된다.
Server
@RestController
@RequestMapping("/api/v1/poi-test")
@RequiredArgsConstructor
public class PoiTestApiController {
private final PoiTestService poiTestService;
...
@PostMapping("/download")
public void downloadUploadedDetails(HttpServletResponse response, @RequestBody List<RowDto> rowDtos) {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment");
try{
Workbook workbook = new XSSFWorkbook();
poiTestService.downloadExcelFile(workbook, rowDtos);
workbook.write(response.getOutputStream());
workbook.close();
} catch (IOException e) {
throw new IllegalArgumentException();
}
}
}
- Content-Type : 다운로드되는 파일의 *MIME 타입을 지정한다. .xlsx의 타입은
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
이다. - Content-Disposition : 파일의 다운로드 동작을 지정한다.
attachment
값을 사용해 브라우저가 파일을 다운로드할 수 있도록 한다.
@Service
public class PoiTestService {
private static final int CELL_CHAR_MAX_SIZE = 255; // cell의 최대 글자 수
private static final int CELL_WIDTH_PER_CHAR = 256; // 한 글자당 가로 길이
...
public Workbook downloadExcelFile(Workbook workbook, List<RowDto> rowDtos) {
// Sheet 생성
Sheet sheet = workbook.createSheet("Sheet1");
Row row = null;
Cell cell = null;
int rowSize = rowDtos.size();
// Row 사이즈만큼 행 생성
for(int i = 0; i < rowSize; i++) {
row = sheet.createRow(i);
List<DetailDto> detailDtos = rowDtos.get(i).getDetails();
// 한 Row에 표시할 Cell 데이터만큼 반복
for(int j = 0; j < rowDtos.get(i).getDetails().size(); j++) {
cell = row.createCell(j);
cell.setCellValue(detailDtos.get(j).getColData().toString());
// 모든 데이터를 작성했다면 셀 사이즈를 조정해준다
if(i == rowSize - 1) {
sheet.autoSizeColumn(j);
// 엑셀 cell의 최대 가로 사이즈는 (CELL_CHAR_MAX_SIZE * CELL_WIDTH_PER_CHAR)
sheet.setColumnWidth(j, Math.min(CELL_CHAR_MAX_SIZE * CELL_WIDTH_PER_CHAR, sheet.getColumnWidth(j) + 500));
}
}
}
return workbook;
}
}
downloadExcelFile()
: Sheet 생성 > Row 생성 > Cell 생성 후setCellValue()
을 이용해 엑셀 데이터를 넣는다.- Sheet의
autoSizeColumn()
은 입력된 데이터에 맞게 자동으로 column 크기를 조정해주는데, 병목현상이 일어날 수 있어 마지막 행까지 데이터를 채워넣은 후 column 사이즈를 한 번만 조정하는 것이 좋다.
(autoSizeColumn()
을 적용해도 데이터가 약간 잘려서setColumnWidth()
를 통해 추가적으로 설정해줬다. 이 때, 엑셀 Cell의 최대 글자 수는 255인데 이를 초과하면 오류가 나므로 가로 사이즈를 적절하게 설정해줘야 한다)
위 1번 과정에서 반환된 데이터(excelData
)를 그대로 server에 넘겨 2번 과정을 진행하면 다음과 같은 엑셀파일이 다운로드된다. 처음에 업로드했었던 엑셀 파일과 동일한 엑셀 파일이 생성된 것을 확인할 수 있다.
* MIME Type (Multipurpose Internet Mail Extensions or Media Type)?
인터넷에서 데이터를 전송할 때 데이터 형식을 식별하기 위해 사용하는 표준화된 방법이다. 이를 통해 클라이언트와 서버 간에 데이터를 올바르게 해석하고 처리할 수 있다.
처음에는 텍스트만 보낼 수 있었던 SMTP의 단점을 보완하여 메세지 내부에 다른 파일을 전송할 수 있도록 하기 위한 전자메일 프로토콜로 사용됐다. 지금은 메일뿐만 아니라, 웹을 통해 여러 형태의 파일을 전송하는데 쓰인다.
MIME로 인코딩한 파일은 헤더의 Content-Type 필드를 통해 전송된 자원의 형식을 명시할 수 있다.
`type/subtype` 형태로 설정하며, 텍스트 파일의 MIME 타입은 `text/plain`으로 표시하고 바이너리 파일 중 json 데이터는 `application/json`으로 표시한다.
Q. MIME Type과 Content-Type의 차이?
MIME 타입과 Content-Type 모두 데이터의 형식을 식별하는데 사용되는 용어이다.
하지만 MIME Type은 인터넷에서 데이터를 전송하는데 사용되는 표준화된 형식 식별 방법이고 Content-Type은 HTTP 헤더의 일부로, HTTP 통신에서 사용되는 데이터 형식 식별 방법이다.
따라서 MIME 타입이 Content-Type보다 상위 개념이다. Content-Type은 HTTP 통신에서 MIME 타입을 포함하여 데이터의 형식을 식별하는 것이다.
4. 마무리
이전에 구현했던 프로젝트에서는 MultipartFile에 대한 설정값으로 `max-file-size`, `max-requset-size` 두 값만 설정해줬었는데 실무에서 사용한다면 추가로 설정해줘야 할 값들이 많아 보인다. 실제로 Spring 파일업로드 관련 글들을 찾아보니 메모리 부족이나 처리 시간 지연 등의 문제가 발생한다고 한다.
파일 크기가 특정값을 초과해서 임시 디렉터리에 저장되는 경우, 업로드 중 장애가 발생하거나 예상치 못한 경우에 삭제되지 않고 남아있을 수 있다. 그럼 별도로 작업을 해줘야 하기 때문에 직접 파일이 저장되는 위치 `location`을 설정해주는 것이 좋다.
그리고 `file-size-threshold` 값을 크게 설정해 모든 파일을 메모리에 저장해 처리한다면 파일 처리 속도는 빠를 수 있지만, 스레드가 작업을 수행하는 동안 부담이 될 수 있다. 또한 다수의 사용자로부터 동시에 요청이 들어올 경우 서버의 스레드가 소진될 위험도 있다.
만약 많은 유저들의 요청을 처리하는 서비스에서 MultiparFile을 이용한다면 `file-size-threshold`을 작게하고 `location`을 설정해 서버의 부하를 줄이고, 삭제되지 않은 파일을 알기 쉽게 해야 할 것이다.
참고 😃
https://dailylifecoding.tistory.com/entry/apache-POI-간단-사용법-1
https://www.baeldung.com/java-microsoft-excel
https://velog.io/@shin6403/HTTP-multipartform-data-란
https://techblog.woowahan.com/11392/
'Spring' 카테고리의 다른 글
[Spring] SpringBoot에서 Redis 적용하기 (+부하 테스트) (8) | 2024.11.08 |
---|---|
[Spring] Spring에서 동시성 이슈를 해결하는 방법 (1) | 2024.10.02 |
[Spring] V2. OAuth2.0으로 소셜로그인 구현하기 (spring-security-oauth2-client 사용) (0) | 2024.03.23 |
[Spring] V1. OAuth2.0으로 소셜로그인 구현하기 (0) | 2024.03.16 |
[Spring] Spring Security (스프링 시큐리티) (0) | 2024.03.13 |