[Android] Apache POI를 이용한 엑셀 파일 읽기

김방토·2025년 2월 26일
0

Android

목록 보기
4/10

서론

현재 진행하고 있는 프로젝트는 레스토랑을 위한 협업 앱!
식당을 타겟으로 하고있는만큼 레시피를 CRUD 할 수 있게 되어있다.

생각해본적 없는 일이지만(당연함 요리와 거리가 먼 삶을 살았음) 레시피는 생각보다 길고 복잡했다.
타이핑이 과장보태 팔만대장경에 가깝다보니 모바일만으론 좀 불편하지 않을까, 생각이 들었다.
구글 스프레드 시트와 엑셀 중에서 무엇을 연동할지 고민하다가 일단은 엑셀로 결정! (요리사 출신 팀원도 당장 레시피를 엑셀로 가지고 있었다...)
강사님이 Apache POI라는 엑셀 라이브러리가 있다고 알려주셨다.

Apache POI 시작하기

아파치 포이 공식문서
당연한 얘기지만 처음 보는것을 사용할땐 공식문서부터 읽는다.

다른 말이지만 웹 프론트엔드로 기술스택을 시작했는데, 그들은 아무래도 프론트 특성(?)인지 굉장히 친절하고 예쁜 문서를 가지고 있다. 심지어 튜토리얼 캡쳐 이미지를 보면 디자인마저 예쁘다. 고작 튜토리얼인데!
그런 뇌피셜로 생각해보니 보통 백엔드에 많이 쓰이는 정적 언어들의 공식문서 상태는 처음 봤을땐 조금 당황스러웠다. 아니 지금도 꽤나 당황스럽다...

친구에게 보여줬더니, 한국에서 야후가 먹어주던 시절 웹페이지를 보는 것 같다고 했다(...)

아무튼 공식문서를 찬찬히 살펴보며 시작!

Component APIs에서 스크롤을 쭉 내리면 Component Map을 확인할 수 있다.

지금보니 H2에 border radius를 위아래 언발란스로 줘서 나름 멋을 부렸다... 정말 킹받아

우리가 확인할것은, Application type이 Excel XLS와 XLSX인 것들이다.
Maven artifactId를 확인하면 된다. poipoi-ooxml!
두개를 의존성에 추가해주면 될 것 같다.
구버전 엑셀 파일인 XLS는 기본 poi에서, 신버전 엑셀 파일인 XLSX를 열려면 poi-ooxml을 추가해줘야하는 형식인듯 하다.
Maven Central Repository에서 Gradle(Kotlin)용 의존성을 확인할 수 있다.
찾기 귀찮으실까봐 링크 걸어 둡니다!
Maven Central poi
Maven Central poi-ooxml

상단에서 버전 확인하시고,

하단에서 Snippets - Gradle(Kotlin) 확인해서 요렇게!

// module 수준 build.gradle
dependencies {
  implementation(libs.poi)
  implementation(libs.poi.ooxml)
}

// toml
[versions]
poi = "5.4.0"
poiOoxml = "5.4.0"

[libraries]
poi = { module = "org.apache.poi:poi", version.ref = "poi" }
poi-ooxml = { module = "org.apache.poi:poi-ooxml", version.ref = "poiOoxml" }

제일 최근 버전이 5.4.0이고, 검색해봤을땐 poi-ooxml에 뭔가 문제가 있었는지 3.x버전을 쓰는 글을 많이 봤는데 최근 버전으로 해보고 안되면 그때 생각하자는 마음으로 일단 최신버전으로 시도했는데 일단 파일 읽기까지 구현한 현재는 문제가 없었습니다!
(문제시 벨로그에 언제라도 추가하도록 하겠습니다,,, 왜냐면 POI 검색했을때 뭐가 넘 안나와서 당황스러웠거든요...)

엑셀 파일 읽기

~예시는 컴포즈 100%입니다~

파일 불러오기

val pickFileLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.OpenDocument(),
        onResult = { uri -> /** 여기에 파일 가져와 할 일 작성 */
)

Button(
	shape = RoundedCornerShape(16.dp),
	onClick = {
    	pickFileLauncher.launch(
			arrayOf(
            	"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // XLSX
                "application/vnd.ms-excel", // XLS
           	)
        )
	}
) {
	Text("엑셀 파일 선택")
}

compose에서 파일을 불러오기 위해서는 rememberLauncherForActivityResult라는 친구를 사용해야 한다

시스템 기능을 사용하거나 할 때 이를 실행하고 결과를 받아오는 친구로,
contract에서 사용할 기능을 받고, onResult를 통해 결과를 받아 사용하게 된다
우리는 OpenDocument, 누가봐도 문서를 여는 기능과 함께
열린 파일을 어떻게 구워삶을지 onResult 안에서 처리하게 되겠습니다!
공식문서도 한번 읽어보시라!

암튼 pickFileLauncher라는 이름으로 rememberLauncherForActivityResult 친구를 만들어줬고, 버튼의 onClick에서 pickFileLauncher를 launch해주었습니다.

OpenDocument의 launch는 input을 받는데, OpenDocument는 파일 선택기를 이용해서 하나의 파일을 선택할 수 있고, 그 파일의 형식을 launch 안에 배열로 지정해 줄 수 있다.
XLS와 XLSX를 지정해주었고, 둘 다 엑셀파일 하면 떠오르는 기본적인 확장자이다. 구버전/신버전의 차이이고, 매크로 등이 지정된 파일은 확장자가 또 다르니 주의하셔라.(그것까지 읽고싶다면 배열에 추가해주셔야 함!)
POI 내에서 매크로를 사용할 순 없지만 매크로가 포함되어있는 확장자도 순수하게 읽거나 쓰거나 할 때는 문제가 없는 모양.

암튼 버튼을 누르면 이런 파일 선택기가 등장한다.
여기서 엑셀 파일을 선택하면 위 코드에서 onResult 부분이 동작하게 되는 것이다.

onResult 람다에서 인자로 우리가 선택한 파일의 Uri를 받게 되는데, 이제 이 친구를 가지고 poi를 시작하면 된다.

POI - 엑셀 파일(한 개) 인식하기

엑셀 파일은 Workbook이라는 형태로 다루게 된다.
우리의 현재 컨텍스트와 Uri를 가지고,

WorkbookFactory.create(context.contentResolver.openInputStream(uri))

이렇게 하면, 우리가 선택한 엑셀 파일을 Workbook이라는 형식으로 만들어낸다.
WorkbookFactory에서 알아서 우리가 선택한 파일이 XLS인지 XLSX인지 구분해준다.

POI - 엑셀 파일의 시트 인식하기

Workbook이라는 형태로 엑셀 파일을 불러왔으니, 얘를 이제 하나씩 뜯어봐야 한다.
한 개의 엑셀 파일은 보통 여러개의 시트로 구성되어있다는 사실을 다들 아실텐데, 그 시트들을 어떻게 다룰지 선택할 수 있다.

특정 시트 가져오기

// n번째 시트 가져오기 ** 1번째 시트는 0번! **
WorkBook.getSheet(n)

// 시트 이름이 hello world인 시트 가져오기
WorkBook.getSheet("hello world")

이런식으로 n-1번째 시트와 hello world라는 시트를 가져올 수 있다.

모든 시트 가져오기

// 모든 시트 개수 알아내기
val allSheets = WorkBook.numberOfSheets

// 시트 반복자 만들기
val sheetIterator = excelWorkBook.sheetIterator()

모든 시트의 갯수를 알아내거나, 반복자를 만들어 루프를 돌리는 형식으로 모든 시트를 순회할 수도 있다.

나의 경우엔, 시트가 몇개인지는 별로 중요하지 않았고 어쨌든 모든 시트를 순회해서 데이터를 뽑아내야 했기 때문에 반복자를 만들어 돌렸다.

POI - 셀 내용 가져오기

// 첫번째 행의 첫번째 열 가져오기
Sheet.getRow(0).getCell(0)

// 셀 주소 생성해서 행과 열로 변환하기
val cellAddress = CellAddress("A1")
val row = sheet.getRow(cellAddress.row)
val cell = row?.getCell(cellAddress.column)

처음에 조금 헷갈렸던게, 그냥 통상적으로 엑셀에는 행이랑 열이 있으니까 Sheet에 Row와 Column이 있을 줄 알았지만, 그렇진 않았다.
시트에서 행을 먼저 세고, 그 다음에 열을 세어야 한다.
(가로로 한줄씩 쭉 읽는다고 생각하면 편하다)
Row라는 한 줄 개념이 있고, 그 하위에 Cell이라는 그 줄의 한 칸의 개념이 있다고 이해하면 된다!

예시를 들자면, 우리의 엑셀 폼은 이렇게 생겼다. 모든 시트가 다 이렇게 생겼다고 가정하고 데이터를 뽑는다.
우리는 모든 데이터를 가로가 아니라 세로로 뽑아야 한다.
그러나 poi에서는 G열에 있는 데이터 쭉 뽑아줘~ 라고 명령하면 안되고,
가로로 한줄씩 보면서, 해당 줄의 G번째에(그러니까 인덱스로 치면 1번) 있는 데이터를 하나씩 수집하자~ 라고 명령해야 한다!
요것만 잘 주의하고 꼼꼼히 체크하면 괜찮아진다.
그리고 최종적으로 셀을 가져오면 Cell 형식인데, toString으로 문자열로 바꾸어주면 셀 내부의 데이터를 문자열 형태로 반환해서 가져올수 있게 된다. (혹시나 해서 말씀드리지만 숫자데이터라고 toInt()같은게 있진 않다...)

셀 주소라는걸 만들어서 행과 열로 변환할수도 있는데, 어차피 행과 열로 가져오는 방식은 똑같기때문에 A1, B3 등과 같은 주소값으로 사용하는게 편하다면 사용하고, 아니면 그냥 행열 중심으로 코딩하는게 편할것 같다.

병합된 셀의 데이터 가져오기

간단하지만 유의해야 할 것이 있다.
예를 들면 나는 A1부터 G1까지를 병합해서 레시피의 제목으로 쓰고 있는데,
A1을 지정해서 가져와야만 셀 데이터를 가져올 수 있다.
B1~G1까지로 지정하면 빈 값이 돌아온다...! 조심하기

(빨간네모) 근데 생각해보니 엑셀 자체에서도 병합된 셀은 가장 처음의 행과 열 기준으로 읽긴 한다. 억울할게 없었다...

서식 지정하지 않은 셀에 있는 숫자 데이터

레시피의 재료 수량/무게를 입력하는 셀에 따로 서식을 걸어주지 않았는데, 숫자 데이터를 자동으로 소수점 단위를 붙여 읽어줬다.
문제는 맘대로 소수점을 붙인 다음(소수점 안 붙은 숫자였는데!) 셀을 toString 하는 과정에서 다시 그게 문자열이 되는 바람에, int -> float -> string(여기까지 poi에서 뽑아오는 과정) -> float -> int(데이터베이스에 int로 집어넣어야 하기 때문에,,,)의 극악무도한 파싱이 되고있다(...)

이미지 가져오기

fun getImageFromSheet(sheet: Sheet): ByteArray {
    val images = mutableListOf<ByteArray>()

    if (sheet is XSSFSheet) {
        val relations = sheet.getRelations()

        for (relation in relations) {
            if (relation is XSSFDrawing) {
                for (anchor in relation.getShapes()) {
                    if (anchor is XSSFPicture) {
                        val pictureData = anchor.pictureData
                        images.add(pictureData.data)
                    }
                }
            }
        }
    } else if(sheet is HSSFSheet) {
        val patriarch: HSSFPatriarch = sheet.drawingPatriarch
        val shapes = patriarch.children

        shapes.forEach { shape ->
            if (shape is HSSFPicture) {
                return shape.pictureData.data
            }
        }
    }

    return images[0]
}

이해하려면 조금 귀찮긴 한데, 어렵진 않다.
poi에서는 xls와 xlsx의 이미지 처리를 좀 다르게 해야한다.
이미지가 여러장이라면 더더욱 귀찮아지겠지만, 우리는 이미지를 한장만 넣는다고 가정하고 제일 처음에 있는 이미지만 가져오도록 한다.

일단 xlsx에서는 뭐랄까... 키보드로 뚜당길수 있는거 말고는 다 relations라는 객체로 관리하는 모양이다.
getRelations()라는 메서드를 통해 냅다 해당 시트의 relations를 다 가져오고, 하나씩 검사를 하며 XSSFDrawing이라는 이미지, 도형을 포함하는 객체인지 확인한다. (하나의 시트에 하나의 XSSF만 존재한다고 한다...)
맞다면 해당 객체의 모든 shape를 검사한다. shape는 그림, 텍스트박스, 차트 등이 포함되는 객체라고...(아오 복잡해)
암튼 그니까 대충 그림판 객체를 찾아서 그 안에 있는 그림들을 하나하나 검사해서 이미지를 찾아내는 방식인듯 하다.

xls에서는 딱히 relation이라는 개념은 없는 듯 하다. relation은 생략되었고, 드로잉부터는 xlsx와 같은 매커니즘이라고 이해하면 될듯.

이정도면 대충 간단한 데이터는 다 가져올 수 있게 되었다!

엑셀 파일로 내보내기 기능도 만들어보면 참 재밌을것 같은데, poi가 생각보다 너무나도... 복잡하고 귀찮다!
근데 poi의 잘못은 딱히 아니어보이는게... 얘들이 자꾸 xls를 HSSF라고 하길래 그게 뭔가 검색해봤는데
Horrible Spreadsheet Format의 약자였다ㅠㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ 극혐 스프레드시트 형식... 의 준말 정도로 번역하면 될까나...
애초에 엑셀 내부가 좀 복잡한듯 하다...
그러니 이만하면 아파치가 힘내줬다고 생각하고 감사하는 마음을 갖도록 하자,,,

profile
🍅 준비된 안드로이드 개발자 📱

0개의 댓글