Kotlin

표로 만든 엑셀 쉽게 읽어서 사용하기(with Apache POI)

안덕기 2024. 8. 7. 17:17

개요

자바 진영에서 엑셀을 다루는 대표적인 라이브러리는 Apache POI다. 사용법을 보니 굉장히 보기 싫다. 전능하신 ChatGPT님께 기본 사용법을 물어보니 기본적인 코드는 아래와 같다.

import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

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

public class ExcelReader {

    public static void main(String[] args) {
        String excelFilePath = "example.xlsx";
        try (InputStream inputStream = new FileInputStream(new File(excelFilePath))) {
            Workbook workbook = new XSSFWorkbook(inputStream);
            Sheet sheet = workbook.getSheetAt(0); // 첫 번째 시트를 가져옴

            for (Row row : sheet) {
                for (Cell cell : row) {
                    printCellValue(cell);
                }
                System.out.println();
            }
            workbook.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void printCellValue(Cell cell) {
        switch (cell.getCellType()) {
            case STRING:
                System.out.print(cell.getStringCellValue() + "\t");
                break;
            case NUMERIC:
                if (DateUtil.isCellDateFormatted(cell)) {
                    System.out.print(cell.getDateCellValue() + "\t");
                } else {
                    System.out.print(cell.getNumericCellValue() + "\t");
                }
                break;
            case BOOLEAN:
                System.out.print(cell.getBooleanCellValue() + "\t");
                break;
            case FORMULA:
                System.out.print(cell.getCellFormula() + "\t");
                break;
            case BLANK:
                System.out.print("\t");
                break;
            default:
                System.out.print("\t");
                break;
        }
    }
}

코드를 보면 원하는 시트를 읽어서 row에 있는 각 cell의 값을 가져와 타입이 무엇인 체크하여 콘솔에 출력하는 형태로 되어 있다.

문제점

그렇다면 우리는 또 우리가 원하는대 형태에 데이터 클래스에 값을 넣기 위해 0번은 이름, 1번은 나이 등등 다시 넣는 작업을 코드로 다시 작성해야겠지? 그 과정이 너무 비효율적인 반복이라는 생각이 들어 빠르게 Apache POI를 감싸서 사용해야겠다고 생각이 들었다. 실제로 만드는데 3시간 정도 걸린거 같다.

요구사항

내가 원했던 것은 엑셀로 아래와 같은 데이터가 있을 때

번호  이름  나이
1    김씨  20
2    이씨  51
3    최씨  44
4    민씨  32
5    안씨  34

아래와 같은 객체로 바로 매핑되면서 리스트로 만들어주는 것이었다.

data class Person (
    val no: String,    // 1
    val name: String,  // 이씨
    val age: String,   // 나이
)

코드

의존성

의존성을 아래와 같이 추가한다.

/**
 * for excel
 */
implementation("org.apache.poi:poi:5.3.0")
implementation("org.apache.poi:poi-ooxml:5.3.0")

메타데이터

엑셀에서 몇번째 시트를 사용할지 row을 몇번째부터 시작할지에 대한 값이 필요하다. 그렇기 때문에 메타 정보를 저장하고 있을 클래스가 필요하다.

/**
 * 엑셀에 대한 메타 정보
 *
 * @property skip 보통 엑셀의 row의 첫번째 줄이 각 컬럼이 어떤 값을 의미하는지를 나타낸다.
 *                그래서 그 값을 skip이 필요한데 몇번째 skip인지 알기 위한 값이다.
 * @property sheetNo 엑셀에서 몇번째 sheet에 존재하는지 알려주는 값이다.
 */

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExcelMeta(
    val skip: Int,
    val sheetNo: Int,
)

엑셀 인터페이스

수행하려는 로직이 모든 클래스가 할 수 있으면 안되니 클래스에 대한 제한이 들어가야된다. 그래서 특정 인터페이스를 지정한다.

/**
 * 엑셀을 객체로 변경할 대표적인 인터페이스
 *
 * 주의할 점
 * 1. 프로퍼티 순서가 곧 엑셀 컬럼 순서가 된다.
 * 2. 모든 프로퍼티 값은 타입을 String으로 선언해야한다.
 */

interface Excel

엑셀 클래스로 변환

코틀린 리플렉션을 사용하여 각 row의 값을 프로퍼티에 삽입할 수 있도록 코드를 구성한다.

class ExcelConverter {
    fun <T : Excel> convert(byteArray: ByteArray, kClass: KClass<T>): List<T> {
        // 프로퍼티의 수를 세서 프로퍼티 수만큼 가져온다.
        val propertyTotal = kClass.memberProperties.size

        val result = mutableListOf<T>()
        ByteArrayInputStream(byteArray).use { inputStream ->
            val excelMeta = kClass.findAnnotation<ExcelMeta>()
                ?: throw RuntimeException("not found excelMeta")
            val sheet = getSheet(inputStream, excelMeta)
            sheet.forEachIndexed { rowNo, row ->
                // 엑셀의 row을 사용할 때 skip 이상의 row만 사용하도록 설정
                if (rowNo >= excelMeta.skip) {
                    result.add(rowToClass(row, propertyTotal, kClass))
                }
            }
        }
        return result.toList()
    }

    private fun getSheet(inputStream: ByteArrayInputStream, excelMeta: ExcelMeta): Sheet {
        val workBook = WorkbookFactory.create(inputStream)
        return workBook.use {
            workBook.getSheetAt(excelMeta.sheetNo)
        }
    }

    private fun <T : Excel> rowToClass(
        row: Row,
        propertyTotal: Int,
        kClass: KClass<T>
    ): T {
        val params = mutableListOf<String>()
        row.forEachIndexed { cellNo, cell ->
            // 프로퍼티 수만큼만 row의 column 값을 가져온다.
            if (cellNo < propertyTotal) {
                params.add(cell.toString())
            }
        }
        return kClass.primaryConstructor?.call(*params.toTypedArray()) ?: throw RuntimeException("create fail excel")
    }
}

테스트

더 많은 예외 로직이 필요하겠지만 일단 되는 것만 보자면 아래와 같이 구성하면 된다.

@ExcelMeta(skip = 1, sheetNo = 0)
data class PersonExcel(
    val no: String,
    val name: String,
    val age: String,
): Excel

@Test
fun `엑셀을_클래스로_변경할_수_있다`() {
        val resource = ClassPathResource("person/person.xlsx")
        val bytes = resource.file.readBytes()
        val convert = excelConverter.convert(bytes, PersonExcel::class)
        convert.size shouldBe 3
}

결론

Apache POI 덕분에 엑셀을 다룰 수 있지만 너무 낮은 레벨의 라이브러리다보니 사용하기 불편한 점이 있다. 이런건 이렇게 간단하고 빠르게 한번 감싸서 사용하는 것이 더 편리한 것 같다.