프로젝트

[Kotlin][SpringBoot Excel] 엑셀 업로드 공통 서비스 가이드

nineDeveloper 2021. 8. 3.
728x90

엑셀 업로드 공통 서비스

클래스 파일 경로: com.project.component.excel.service

ExcelReader

엑셀 파일로 부터 데이터를 읽어 들여 요청한 객체 타입에 맞게 데이터를 맵핑 한다음 요청 객체 리스트로 리턴 해준다

필수 셋팅

  • 엑셀 업로드용 객체를 생성할때 엑셀 업로드용 임을 알 수 있도록 반드시 suffixExcelUpload 를 붙여서 생성한다
    • ex) SampleExcelUpload

Sample 객체 소스코드 from 메서드

엑셀 업로드 처리를 위해 ExcelUtilsfrom 메서드를 사용하면 된다

class ExcelUtils {

    companion object {
        /**
         * 엑셀 업로드 처리를 위한 객체
         * Generic 타입의 객채를 생성하여 업로드된 엑셀 데이터를 객체에 맵핑한다
         * @param row Row
         * @return SampleExcel
         */
        inline fun <reified T: Any> from(row: Row): T = setObjectMapping(T::class.createInstance(), row)
    }
}

사용법

companion object 메서드이므로 그냥 바로 호출해서 사용 하면된다

사용예시:

val dataList: List<SampleExcelUpload> = ExcelReader.getObjectList(file, { ExcelUtils.from(it) }, 1);
  • 요청 데이터
    • multipartFile타입의 엑셀 파일
    • ExcelUtils.from(it) 메서드
    • 헤더를 제외한 데이터 시작 ROW(만약 헤더가 있다면 시작 ROW는 1)

시작 ROW 가 1이라면 기본값이므로 아래와 같이 넘겨주지 않아도 무방하다

val dataList: List<SampleExcelDownload>  = ExcelReader.getObjectList(file) { ExcelUtils.from(it) }

Sample 코드

엑셀 업로드 로직 구현 시 Sample 코드를 참조해 비지니스 로직을 작성 하면 된다

Sample 엑셀 업로드 객체

Validation 체크를 위해서 Validation 체크가 필요한 값은 반드시 Validation 어노테이션을 붙여줘야 한다
엑셀 업로드시 해당 Validation을 체크하여 검증을 통과하지 못하면 Validation message를 리턴한다

※Validation 어노테이션 추가시 @NotEmpty, @NotNull에 대해서는 별도의 메세지 처리를 하지 말아야 한다
별도의 메세지 처리시 Validation 검증을 할 수 없다

package com.project.api.sample.domain

import io.swagger.v3.oas.annotations.media.Schema
import java.math.BigDecimal
import javax.validation.constraints.NotEmpty
import javax.validation.constraints.NotNull
import javax.validation.constraints.Pattern
import javax.validation.constraints.Size

/**
 * Created by KMS on 2021/07/30.
 */
@Schema(title = "Sample 엑셀 업로드 객체", hidden = true)
class SampleExcelUpload(
    @field:NotEmpty
    @field:Schema(title = "이름")
    var name: String? = null,
    @field:NotEmpty
    @field:Schema(title = "이메일")
    var email: String? = null,
    @field:NotEmpty
    @field:Schema(title = "전화번호")
    var phone: String? = null,
    @field:NotEmpty
    @field:Schema(title = "소속부서")
    var dept: String? = null,
    @field:NotNull
    @field:Schema(title = "업무코드")
    var workCode: Int? = null,
    @field:NotNull
    @field:Schema(title = "부서코드")
    var deptCode: Int? = null,
    @field:Size(min = 1, max = 500)
    @field:Schema(title = "내용")
    var content: String? = null,
    @field:Size(max = 15)
    @field:Pattern(regexp="(\\d{1,2}|1\\d\\d|2[0-4]\\d|25[0-5])\\.(\\d{1,2}|1\\d\\d|2[0-4]\\d|25[0-5])\\.(\\d{1,2}|1\\d\\d|2[0-4]\\d|25[0-5])\\.(\\d{1,2}|1\\d\\d|2[0-4]\\d|25[0-5])", message = "IP 주소 형식이 올바르지 않습니다")
    @field:Schema(title = "IP")
    var ip: String? = null,
    @field:Schema(title = "소수")
    var percent: BigDecimal? = null,
    @field:Schema(title = "날짜")
    var createDate: String? = null,
    @field:Schema(title = "일시")
    var updateDatetime: String? = null,
    @field:Schema(title = "빈데이터")
    var empty: String? = null,
) {

    override fun toString(): String {
        return "SampleExcelUpload(name=$name, email=$email, phone=$phone, dept=$dept, workCode=$workCode, deptCode=$deptCode, content=$content, ip=$ip, percent=$percent, createDate=$createDate, updateDatetime=$updateDatetime, empty=$empty)"
    }
}

SampleExcelController 소스코드

엑셀 업로드 테스트를 해볼 수 있는 Sample 컨트롤러
Swagger에서 Sample 엑셀 양식을 다운로드 해서 Sample 엑셀 업로드 테스트를 해볼 수 있다

package com.project.api.sample.controller

import com.project.api.sample.service.SampleExcelService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.core.io.Resource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.io.IOException

@RestController
@RequestMapping("/sample")
@Tag(name = "[Sample] 공통 Excel", description = "SampleExcelController")
class SampleExcelController(
    val sampleExcelService: SampleExcelService) {

    @Operation(summary = "Sample 엑셀 업로드", description = "# Sample 엑셀 업로드를 테스트 해볼 수 있는 API\n" +
            "## 엑셀 업로드 공통 서비스 가이드\n" +
            "---\n" +
            "## 사용예시\n" +
            "---\n" +
            "`var dataList: List<SampleExcel> = ExcelReader.getObjectList(file) { SampleExcel() }`")
    @ApiResponses(
            ApiResponse(responseCode = "4010", description = "읽을 수 없는 엑셀 파일 입니다 (DRM 적용 또는 다른이유)"),
            ApiResponse(responseCode = "4011", description = "엑셀 업로드 FIELD ERROR :: 입력 데이터를 확인하세요\n" +
                    "## Sample Data\n" +
                    "### TYPE\n" +
                    "___\n" +
                    "`TYPE`: 잘못된 데이터 타입\n" +
                    "`EMPTY`: 필수 입력값 누락\n" +
                    "`VALID`: 유효성 검증 실패\n" +
                    "`UNKNOWN`: 알수 없는 에러\n" +
                    "```json" +
                    "" +
                    "'[\n" +
                    "[\n"+
                    "  {\n" +
                    "    \"type\": \"EMPTY\",\n" +
                    "    \"row\": 2,\n" +
                    "    \"field\": \"name\",\n" +
                    "    \"fieldHeader\": \"이름\",\n" +
                    "    \"inputData\": \"\",\n" +
                    "    \"message\": \"필수 입력값 누락\",\n" +
                    "    \"exceptionMessage\": \"이름은 필수 입력값입니다\"\n" +
                    "  }\n" +
                    "]"+
                    "")
    )
    @PostMapping("/excel")
    @Throws(Exception::class)
    fun insertEquipment(
        @Parameter(description = "업로드 엑셀파일 - 업무에 해당하는 엑셀 업로드 양식을 다운 받아 양식에 맞게 작성한뒤 업로드해야함")
        @RequestPart(value = "file") file: MultipartFile) = sampleExcelService.excelUpload(file)
}

SampleExcelService 소스코드

package com.project.api.sample.service

import com.project.api.sample.domain.SampleExcelUpload
import com.project.common.domain.response.CommonResponse
import com.project.component.excel.service.ExcelReader
import com.project.component.excel.service.ExcelService
import com.project.component.excel.utils.ExcelUtils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile

@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
@Service
class SampleExcelService(
    val excelService: ExcelService
) {

    val log: Logger = LoggerFactory.getLogger(this::class.java)

    /**
     * Sample 엑셀 업로드 테스트
     * @param file
     * @return
     * @throws Exception
     */
    @Throws(Exception::class)
    fun excelUpload(file: MultipartFile): CommonResponse<List<SampleExcelUpload>> {
        val dataList: List<SampleExcelUpload> = ExcelReader.getObjectList(file) { ExcelUtils.from(it) }
        for (dto in dataList) {
            log.info("Excel Upload Sample Data = {}", dto)
        }
        return CommonResponse(dataList)
    }

}

SampleExcelControllerTest 테스트 소스코드

Sample 엑셀 업로드 API 를 테스트 할 수 있는 소스코드

package com.project.api.sample.controller

import com.project.api.sample.domain.SampleExcelUpload
import com.project.common.BaseMvcTest
import com.project.common.domain.response.CommonResponse
import com.project.common.exception.domain.enums.ErrorCode
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import java.util.*
import javax.validation.ConstraintViolation
import javax.validation.Validator

/**
 * 엑셀 공통 서비스 테스트
 */
class SampleExcelControllerTest(val validator: Validator): BaseMvcTest() {

    @BeforeEach
    fun beforeSetUp() {
        applyEncodingFilter()
    }

    @Test
    fun `Sample 엑셀 업로드 성공 테스트`() {
        val builder = MockMvcRequestBuilders.multipart("/sample/excel")
            .file(getMultipartFile("sampleUpload.xlsx", "file"))

        val result = mockMvc.perform(builder)
            .andExpect(status().isOk)
            .andExpect(jsonPath("code").value(HttpStatus.OK.value()))
            .andExpect(jsonPath("message").value(HttpStatus.OK.reasonPhrase))
            .andDo(print())

        val responseBody = result.andReturn().response.contentAsString
        val response: CommonResponse<*> = mapper.readValue(responseBody, CommonResponse::class.java)
        val sampleExcelList = response.content as List<Map<String, Any>>
        for (dto in sampleExcelList) {
            println(dto)
        }
    }

    @Test
    fun `Sample 엑셀 업로드 실패 테스트`() {
        val builder = MockMvcRequestBuilders.multipart("/sample/excel")
            .file(getMultipartFile("sampleUpload_error.xlsx", "file"))

        val result = mockMvc.perform(builder)
            .andExpect(status().isOk)
            .andExpect(jsonPath("code").value(ErrorCode.EXCEL_READER_FIELD_ERROR.code))
            .andExpect(jsonPath("message").value(ErrorCode.EXCEL_READER_FIELD_ERROR.message))
            .andDo(print())

        val responseBody = result.andReturn().response.contentAsString
        val response: CommonResponse<*> = mapper.readValue(responseBody, CommonResponse::class.java)
        val sampleExcelList = response.content as List<Map<String, Any>>
        for (dto in sampleExcelList) {
            println(dto)
        }
    }
}

테스트 코드 공통 클래스

package com.project.common

import com.fasterxml.jackson.databind.ObjectMapper
import org.apache.commons.lang3.StringUtils
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.mock.web.MockMultipartFile
import org.springframework.test.context.TestConstructor
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import org.springframework.web.filter.CharacterEncodingFilter
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

@SpringBootTest
@AutoConfigureMockMvc
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class BaseMvcTest {

    @Autowired
    lateinit var mapper: ObjectMapper

    @Autowired
    lateinit var mockMvc: MockMvc

    @Autowired
    lateinit var ctx: WebApplicationContext

    fun applyEncodingFilter() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.ctx)
            .addFilters<DefaultMockMvcBuilder>(
                CharacterEncodingFilter("UTF-8", true),
            )
            .alwaysDo<DefaultMockMvcBuilder>(print())
            .build()
    }

    /**
     * 파일 업로드 데이터 생성
     * @param originalFileName 원본파일명
     * @param reqFileName 요청파일명(API에서 받는 이름)
     * @return
     */
    fun getMultipartFile(originalFileName: String, reqFileName: String): MockMultipartFile {
        return getMultipartFile(null, originalFileName, reqFileName)
    }

    /**
     * 파일 업로드 데이터 생성
     * @param originalFileName 원본파일명
     * @param reqFileName 요청파일명(API에서 받는 이름)
     * @return
     */
    fun getMultipartFile(
        fileMiddlePath: String?,
        originalFileName: String?,
        reqFileName: String?
    ): MockMultipartFile {
        var filePath = Paths.get("src", "test", "resources", "file")
        if (StringUtils.isNotEmpty(fileMiddlePath)) filePath = Path.of(filePath.toString(), fileMiddlePath)
        filePath = Paths.get(filePath.toString(), originalFileName)
        return MockMultipartFile(reqFileName!!, originalFileName, null, Files.readAllBytes(filePath))
    }
}

엑셀 업로드 소스코드

ExcelReader 소스 코드

  • getObjectList: 엑셀 파일의 데이터를 읽어서 요청한 오브젝트 타입 리스트에 값을 순서대로 담아 준다
    처리 중 에러가 발생하거나 처리 후 errorFieldListField 에러 내역이 있을 경우 에러 내역을 리턴한다
  • isPass: ROW 데이터를 체크해서 데이터가 모두 비어있으면 Trash 데이터라고 판단하고 PASS 한다
  • getHeader: 엑셀 파일의 데이터 중 가장 첫번 째 ROWHeader 데이터를 가져온다
  • getTitle: 엑셀 파일의 데이터 중 TITLE 데이터가 있다면 가져온다
package com.project.component.excel.service

import com.project.common.exception.domain.enums.ErrorCode
import com.project.component.excel.domain.model.ExcelReaderErrorField
import com.project.component.excel.exception.ExcelReaderException
import com.project.component.excel.exception.ExcelReaderFieldException
import com.project.component.excel.exception.ExcelReaderFileException
import com.project.component.excel.exception.ExcelReaderFileExtentionException
import com.project.component.excel.utils.ExcelUtils
import org.apache.commons.collections4.ListUtils
import org.apache.commons.lang3.StringUtils
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.usermodel.WorkbookFactory
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.util.*
import java.util.function.Function
import java.util.stream.Collectors
import java.util.stream.IntStream

/**
 * Created by KMS on 2021/05/25.
 */
class ExcelReader {

    companion object {
        val log: Logger = LoggerFactory.getLogger(this::class.java)

        /** 엑셀 업로드 에러 필드 리스트  */
        var errorFieldList: MutableList<ExcelReaderErrorField> = arrayListOf()

        /** 엑셀 업로드 HEADER 리스트  */
        var headerList: MutableList<String> = arrayListOf()

        /**
         * 엑셀 파일의 데이터를 읽어서 요청한 오브젝트 타입 리스트에 담아 준다
         * @param multipartFile 엑셀 파일 데이터
         * @param rowFunc cell 데이터를 객체에 셋팅해주는 함수
         * @param <T> 요청 객체 타입
         * @return List<T> 요청 객체 타입의 리스트로 리턴
         * @throws IOException
        </T></T> */
        @Throws(Exception::class)
        fun <T> getObjectList(
            file: File,
            rowFunc: Function<Row, T>
        ): List<T> {
            //헤더 데이터가 ROW가 0에 있고 실제 데이터의 시작 ROW가 1번째 부터인 것으로 판단
            return getObjectList(file, rowFunc, 1)
        }

        /**
         * 엑셀 파일의 데이터를 읽어서 요청한 오브젝트 타입 리스트에 담아 준다
         * @param multipartFile 엑셀 파일 데이터
         * @param rowFunc cell 데이터를 객체에 셋팅해주는 함수
         * @param <T> 요청 객체 타입
         * @return List<T> 요청 객체 타입의 리스트로 리턴
         * @throws IOException
        </T></T> */
        @Throws(Exception::class)
        fun <T> getObjectList(
            multipartFile: MultipartFile,
            rowFunc: Function<Row, T>
        ): List<T> {
            // String tempDir = System.getProperty(property);
            val convertFile = File(Objects.requireNonNull<String>(multipartFile.originalFilename))
            multipartFile.transferTo(convertFile)
            //헤더 데이터가 ROW가 0에 있고 실제 데이터의 시작 ROW가 1번째 부터인 것으로 판단
            return getObjectList(convertFile, rowFunc, 1)
        }

        /**
         * 엑셀 파일의 데이터를 읽어서 요청한 오브젝트 타입 리스트에 담아 준다
         * @param multipartFile 엑셀 파일 데이터
         * @param rowFunc cell 데이터를 객체에 셋팅해주는 함수
         * @param startRowParam 데이터 시작 ROW (Default: 1)
         * @param <T> 요청 객체 타입
         * @return List<T> 요청 객체 타입의 리스트로 리턴
         * @throws IOException
        </T></T> */
        @Throws(Exception::class)
        fun <T> getObjectList(
            multipartFile: MultipartFile,
            rowFunc: Function<Row, T>, startRowParam: Int = 1,
        ): List<T> {
            // String tempDir = System.getProperty(property);
            val convertFile = File(Objects.requireNonNull<String>(multipartFile.originalFilename))
            multipartFile.transferTo(convertFile)
            //헤더 데이터가 ROW가 0에 있고 실제 데이터의 시작 ROW가 1번째 부터인 것으로 판단
            return getObjectList(convertFile, rowFunc, startRowParam)
        }

        /**
         * 엑셀 파일의 데이터를 읽어서 요청한 오브젝트 타입 리스트에 담아 준다
         * @param multipartFile 엑셀 파일 데이터
         * @param rowFunc cell 데이터를 객체에 셋팅해주는 함수
         * @param startRowParam 데이터 시작 ROW (Default: 1)
         * @param <T> 요청 객체 타입
         * @return List<T> 요청 객체 타입의 리스트로 리턴
         * @throws IOException
        </T></T> */
        private fun <T> getObjectList(
            file: File,
            rowFunc: Function<Row, T>, startRowParam: Int = 1,
        ): List<T> {
            var startRow = startRowParam
            errorFieldList = ArrayList<ExcelReaderErrorField>()
            headerList = ArrayList()
            if (!file.exists()) throw ExcelReaderFileException("처리할 파일이 없습니다")
            if (Objects.isNull(rowFunc)) throw ExcelReaderException("처리할 ROW 함수가 없습니다")
            try {
                log.info(
                    "Excel Upload fileInfo :: fileName: {}, fileSize: {} Byte, {} MB",
                    file.name,
                    file.length(),
                    file.length() / 1024 / 1024
                )
            } catch (e: Exception) {
                log.info(
                    "Excel Upload fileInfo :: fileName: {}, fileSize: {}",
                    file.name,
                    "비정상 파일 - 파일 사이즈 측정 불가"
                )
            }
            val originalFileName = file.name
            val originalFileExtension = originalFileName.substring(originalFileName.lastIndexOf(".") + 1)
            if (!(originalFileExtension == "xlsx" || originalFileExtension == "xls")) throw ExcelReaderFileExtentionException(
                "엑셀 파일 확장자가 아닙니다 :: originalFileName = $originalFileName, originalFileExtension = $originalFileExtension"
            )

            // rownum 이 입력되지 않으면 default로 1 번째 라인을 데이터 시작 ROW로 판단
            if (Objects.isNull(startRow)) startRow = 1

            // 엑셀 파일을 Workbook에 담는다
            val workbook: Workbook = try {
                WorkbookFactory.create(file.inputStream())
            } catch (e: IOException) {
                throw ExcelReaderFileException(e.message, e)
            }
            // 시트 수 (첫번째에만 존재시 0)
            val sheet = workbook.getSheetAt(0)
            // 전체 행 수
            val rowCount = sheet.physicalNumberOfRows
            // log.debug("## rowCount = "+rowCount);
            // 헤더 셋팅
            headerList = getHeader(file)
            val objectList =
                (startRow until rowCount)
                    .filter { isPass(sheet.getRow(it)) }
                    .map { rowFunc.apply(sheet.getRow(it)) }
                    .toList()
            if (ListUtils.emptyIfNull(errorFieldList).isNotEmpty())
                throw ExcelReaderFieldException(ErrorCode.EXCEL_READER_FIELD_ERROR.message)
            return objectList
        }

/**
 * 해당 ROW에 있는 데이터가 모두 비어있으면 빈 ROW로 판단하고 해당 ROW는 PASS 시킨다
 * @param row
 * @return
 */
private fun isPass(row: Row): Boolean {
    var isPass = false

    (0 until row.physicalNumberOfCells).forEachIndexed { i, it ->
        if (StringUtils.isNotEmpty(ExcelUtils.getValue(row.getCell(i)))) isPass = true
    }
    // log.debug("## row.getPhysicalNumberOfCells() = {}, isPass = {}",row.getPhysicalNumberOfCells(), isPass);
    return isPass
}


        /**
         * 헤더 가져오기
         * 가장 상단에 헤더가 있다면 헤더 정보를 List<String> 에 담아준다
         * @param file 엑셀파일
         * @return List<String> 헤더 리스트
         * @throws IOException
        </String></String> */
        private fun getHeader(file: File): MutableList<String> {
            return getHeader(file, 0) //헤더가 가장 첫번째 라인에 있다고 판단함
        }

        /**
         * 헤더 가져오기
         * 가장 상단에 헤더가 있다면 헤더 정보를 List<String> 에 담아준다
         * @param file 엑셀파일
         * @param rownumParam 헤더가 있는 row number 값
         * @return List<String> 헤더 리스트
         * @throws IOException
        </String></String> */
        private fun getHeader(file: File, rownumParam: Int): MutableList<String> {

            // rownum 이 입력되지 않으면 default로 0 번째 라인을 header 로 판단
            var rownum = rownumParam
            if (Objects.isNull(rownum)) rownum = 0
            val workbook: Workbook = try {
                WorkbookFactory.create(FileInputStream(file))
            } catch (e: IOException) {
                throw ExcelReaderFileException(e.message, e)
            }
            // 시트 수 (첫번째에만 존재시 0)
            val sheet = workbook.getSheetAt(0)

            // 타이틀 가져오기
            val title = sheet.getRow(rownum)
            return IntStream
                .range(0, title.physicalNumberOfCells)
                .mapToObj { cellIndex: Int ->
                    title.getCell(cellIndex).stringCellValue
                }
                .collect(Collectors.toList())
        }

        /**
         * 타이틀 가져오기
         * 가장 상단에 타이틀이 있다면 타이틀 정보를 List<String> 에 담아준다
         * @param file 엑셀파일
         * @return List<String> 타이틀 리스트
         * @throws IOException
        </String></String> */
        fun getTitle(file: File): List<String>? {
            val workbook: Workbook = try {
                WorkbookFactory.create(FileInputStream(file))
            } catch (e: IOException) {
                throw ExcelReaderFileException(e.message, e)
            }
            // 시트 수 (첫번째에만 존재시 0)
            val sheet = workbook.getSheetAt(0)

            // 타이틀 가져오기
            val title = sheet.getRow(0)
            return IntStream
                .range(0, title.physicalNumberOfCells)
                .mapToObj { cellIndex: Int ->
                    title.getCell(cellIndex).stringCellValue
                }
                .collect(Collectors.toList())
        }
    }
}

ExcelUtils 소스코드

  • getValue: Cell 데이터를 타입에 맞게 받아와서 String 값으로 변환하여 리턴
  • setObjectMapping: 객체와 엑셀의 ROW 데이터를 받아서 ROW 데이터의 값을 객체에 셋팅 해준다
    처리 중 Field 에 대한 에러가 발생하거나 Validation 검증을 통과 하지 못할 경우 내역을 errorFieldList에 담는다
  • checkValidation: 객체의 Validation을 검증해서 검증을 통과 하지 못한 값을 에러 리스트에 담는다
package com.project.component.excel.utils

import com.github.drapostolos.typeparser.TypeParser
import com.github.drapostolos.typeparser.TypeParserException
import com.project.common.utils.ValidationUtils
import com.project.component.excel.constant.ExcelReaderFieldError
import com.project.component.excel.domain.model.ExcelReaderErrorField
import com.project.component.excel.service.ExcelReader
import eu.bitwalker.useragentutils.Browser
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.poi.ss.formula.eval.ErrorEval
import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.ss.usermodel.DateUtil
import org.apache.poi.ss.usermodel.Row
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.UnsupportedEncodingException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.*
import java.util.function.Function
import java.util.function.UnaryOperator
import java.util.stream.Collectors
import javax.servlet.http.HttpServletRequest
import javax.validation.ConstraintViolation
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.createInstance
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible
import kotlin.reflect.jvm.javaField

/**
 * Created by KMS on 2021/05/26.
 */
class ExcelUtils {

    companion object {
        val log: Logger = LoggerFactory.getLogger(this::class.java)

        fun getBrowser(request: HttpServletRequest): String {
            val header = request.getHeader("User-Agent")
            log.info("User-Agent = {}", header)
            return if (header != null) {
                if (header.contains("MSIE") || header.contains("Trident")) {
                    "MSIE"
                } else if (header.contains("Chrome")) {
                    "Chrome"
                } else if (header.contains("Opera")) {
                    "Opera"
                } else if (header.contains("Trident/7.0")) { //IE 11 이상 //IE 버전 별 체크 >> Trident/6.0(IE 10) , Trident/5.0(IE 9) , Trident/4.0(IE 8)
                    "MSIE"
                } else {
                    "Firefox"
                }
            } else {
                "MSIE"
            }
        }

        @Throws(Exception::class)
        fun getDisposition(filename: String, browser: String): String {
            val encodedFilename: String
            when (browser) {
                "MSIE" -> {
                    encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
                    //encodedFilename = URLEncoder.encode(filename, "UTF-8").replaceAll("\\+", "%20");
                }
                "Firefox" -> {
                    //encodedFilename = "\"" + new String(filename.getBytes("UTF-8"), "8859_1") + "\"";
                    encodedFilename = String(filename.toByteArray(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1)
                }
                "Opera" -> {
                    //encodedFilename = "\"" + new String(filename.getBytes("UTF-8"), "8859_1") + "\"";
                    encodedFilename = String(filename.toByteArray(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1)
                }
                "Chrome" -> {
                    val sb = StringBuffer()
                    for (element in filename) {
                        val c = element
                        if (c > '~') {
                            sb.append(URLEncoder.encode("" + c, "UTF-8"))
                        } else {
                            sb.append(c)
                        }
                    }
                    encodedFilename = sb.toString()
                }
                else -> {
                    encodedFilename = String(filename.toByteArray(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1)
                    //throw new RuntimeException("Not supported browser");
                }
            }
            return encodedFilename
        }

        /**
         * 파일명 Encoder
         */
        private enum class FileNameEncoder(browser: Browser, encodeOperator: UnaryOperator<String>) {
            IE(Browser.IE, UnaryOperator<String> { it: String? ->
                try {
                    return@UnaryOperator URLEncoder.encode(it, StandardCharsets.UTF_8.name())
                        .replace("\\+".toRegex(), "%20")
                } catch (e: UnsupportedEncodingException) {
                    return@UnaryOperator it
                }
            }),
            FIREFOX(Browser.FIREFOX, defaultEncodeOperator), OPERA(
                Browser.OPERA,
                defaultEncodeOperator
            ),
            CHROME(Browser.CHROME, defaultEncodeOperator), UNKNOWN(Browser.UNKNOWN, UnaryOperator.identity<String>());

            private val browser: Browser
            val encodeOperator: UnaryOperator<String>

            companion object {
                private var FILE_NAME_ENCODER_MAP: Map<Browser, Function<String, String>>? = null
                private val defaultEncodeOperator: UnaryOperator<String>
                    get() = UnaryOperator { it: String ->
                        String(
                            it.toByteArray(StandardCharsets.UTF_8),
                            StandardCharsets.ISO_8859_1
                        )
                    }

                fun encode(browser: Browser, fileName: String): String {
                    return FILE_NAME_ENCODER_MAP!![browser]!!.apply(fileName)
                }

                init {
                    FILE_NAME_ENCODER_MAP = EnumSet.allOf(FileNameEncoder::class.java).stream()
                        .collect(
                            Collectors.toMap(
                                { obj: FileNameEncoder -> obj.getBrowser() },
                                { obj: FileNameEncoder -> obj.encodeOperator })
                        )
                }
            }

            protected fun getBrowser(): Browser {
                return browser
            }

            init {
                this.browser = browser
                this.encodeOperator = encodeOperator
            }
        }

        /**
         * Cell 데이터를 Type 별로 체크 하여 String 데이터로 변환함
         * String 데이터로 우선 변환해야 함
         * @param cell 요청 엑셀 파일의 cell 데이터
         * @return String 형으로 변환된 cell 데이터
         */
        fun getValue(cell: Cell): String? {
            if (Objects.isNull(cell) || Objects.isNull(cell.cellType)) return ""
            var value: String
            when (cell.cellType) {
                CellType.STRING -> value = cell.richStringCellValue.string
                CellType.NUMERIC -> {
                    if (DateUtil.isCellDateFormatted(cell)) value = cell.localDateTimeCellValue.toString() else value =
                        cell.numericCellValue.toString()
                    if (value.endsWith(".0")) value = value.substring(0, value.length - 2)
                }
                CellType.BOOLEAN -> value = cell.booleanCellValue.toString()
                CellType.FORMULA -> value = cell.cellFormula.toString()
                CellType.ERROR -> value = ErrorEval.getText(cell.errorCellValue.toInt())
                CellType.BLANK, CellType._NONE -> value = ""
                else -> value = ""
            }
            //log.debug("## cellType = {}, value = {}",cell.getCellType(),value);
            return value
        }

        /**
         * TypeParser 로 String으로 변환된 Cell 데이터를 객체 필드 타입에 맞게 변환하여 셋팅해줌
         * @param `object` 요청 객체
         * @param <T>
         * @param row 엑셀 ROW 데이터
         * @return Cell 데이터를 맵핑한 오브젝트
        </T> */
        inline fun <reified T: Any> setObjectMapping(obj: T, row: Row): T {

            val memberProperties = T::class.memberProperties.toList()
            T::class.constructors.forEach {
                it.parameters.forEachIndexed { i, param ->

                    val field: KMutableProperty1<T, *> = memberProperties.first { p -> p.name == param.name } as KMutableProperty1<T, *>

                    var cellValue: String? = null
                    val typeParser: TypeParser = TypeParser.newBuilder().build()
                    try {
                        if (i < row.physicalNumberOfCells) { //유효한 Cell 영역 까지만
                            cellValue = getValue(row.getCell(i))
                            var setData: Any? = null
                            if (!StringUtils.isEmpty(cellValue)) setData =
                                typeParser.parseType(cellValue, field.javaField?.type)
                            field.isAccessible = true
                            field.setter.call(obj, setData)
                            checkValidation(obj, row, i, cellValue, field.name)
                        }
                    } catch (e: TypeParserException) {
                        val error: ExcelReaderFieldError = ExcelReaderFieldError.TYPE
                        ExcelReader.errorFieldList.add(
                            ExcelReaderErrorField(
                                error.name,
                                row.rowNum + 1,
                                field.name,
                                ExcelReader.headerList[i],
                                cellValue,
                                "${error.message} 데이터 필드타입 - ${field.javaField?.type?.simpleName}, 입력값 필드타입 - ${cellValue!!.javaClass.simpleName}",
                                ExceptionUtils.getRootCauseMessage(e)
                            )
                        )
                    } catch (e: Exception) {
                        val error: ExcelReaderFieldError = ExcelReaderFieldError.UNKNOWN
                        ExcelReader.errorFieldList.add(
                            ExcelReaderErrorField(
                                error.name,
                                row.rowNum + 1,
                                field.name,
                                ExcelReader.headerList[i],
                                cellValue,
                                error.message,
                                ExceptionUtils.getRootCauseMessage(e)
                            )
                        )
                    }
                }
            }
            return obj
        }

        /**
         * 객체에 대한 Validation 을 검증해서 검증을 통과 하지 못한 내역이 있을 경우 에러 리스트에 담는다
         * @param `object`
         * @param row
         * @param i
         * @param <T>
        </T> */
        fun <T> checkValidation(obj: T, row: Row, i: Int, cellValue: String?, fieldName: String) {
            val validData: ConstraintViolation<T> = ValidationUtils.getValidData(obj, fieldName) ?: return
            val fieldHeader: String = ExcelReader.headerList.get(i)
            var error: ExcelReaderFieldError = ExcelReaderFieldError.VALID
            var exceptionMessage = validData.message
            if (validData.messageTemplate.contains("NotEmpty") || validData.messageTemplate.contains("NotNull")) {
                error = ExcelReaderFieldError.EMPTY
                exceptionMessage = fieldHeader + "은 필수 입력값입니다"
            }
            ExcelReader.errorFieldList.add(
                ExcelReaderErrorField(
                    error.name,
                    row.rowNum + 1,
                    validData.propertyPath.toString(),
                    fieldHeader,
                    cellValue,
                    error.message,
                    exceptionMessage
                )
            )
        }

        /**
         * 엑셀 업로드 처리를 위한 객체
         * Generic 타입의 객채를 생성하여 업로드된 엑셀 데이터를 객체에 맵핑한다
         * @param row Row
         * @return SampleExcel
         */
        inline fun <reified T: Any> from(row: Row): T = setObjectMapping(T::class.createInstance(), row)
    }
}

ExcelReaderFieldError 소스코드

엑셀 업로드시 Field 에러에 대한 구분 상수

package com.project.component.excel.constant

/**
 * Created by KMS on 2021/05/25.
 */
enum class ExcelReaderFieldError(var message: String) {
    TYPE("잘못된 데이터 타입: "),
    EMPTY("필수 입력값 누락"),
    VALID("유효성 검증 실패"),
    UNKNOWN("알수 없는 에러")
    ;

    companion object {
        var messageToMap: MutableMap<String, ExcelReaderFieldError> = mutableMapOf()

        /**
         * 취미명으로 취미 맵으로 맵핑
         * @param code
         * @return
         */
        fun getExcelReaderErrorConstant(name: String): ExcelReaderFieldError? {
            if(messageToMap.isEmpty())
                initMapping()
            return messageToMap[name]
        }

        /**
         * 맵 초기화
         */
        private fun initMapping() {
            messageToMap = mutableMapOf()
            values().forEach {
                messageToMap[it.name] = it
            }
        }
    }
}

ExcelReaderErrorField 소스코드

엑셀 업로드 필드 에러를 리턴 해줄 때 사용하는 객체
여러 필드에 대한 에러를 아래의 내용으로 배열에 담아 한번에 리턴해준다

package com.project.component.excel.domain.model

import io.swagger.v3.oas.annotations.media.Schema

/**
 * 엑셀 업로드 에러 필드
 * Sample
[
  {
    "errorType": "TYPE",
    "errorRow": 2,
    "errorFieldName": "test",
    "errorFieldHeaderName": "테스트",
    "errorInputData": "안녕",
    "errorMessage": "잘못된 데이터 타입: 데이터 필드타입 - Integer, 입력된 필드타입 - String",
    "exceptionMessage": "NumberFormatException: For input string: \"안녕\""
  }
]
 */
@Schema(title = "엑셀 업로드 에러 필드 객체", hidden = true)
class ExcelReaderErrorField(
    @field:Schema(title = "ERROR 타입", description = "TYPE: 잘못된 데이터 타입, EMPTY: 필수 입력값 누락, VALID: 유효성 검증 실패, UNKNOWN: 알수 없는 에러")
    var type: String? = null,
    @field:Schema(title = "ERROR 행 번호")
    var row: Int? = null,
    @field:Schema(title = "ERROR 필드명")
    var field: String? = null,
    @field:Schema(title = "ERROR 필드 Header명")
    var fieldHeader: String? = null,
    @field:Schema(title = "ERROR 입력값")
    var inputData: String? = null,
    @field:Schema(title = "ERROR 메세지")
    var message: String? = null,
    @field:Schema(title = "EXCEPTION MESSAGE")
    var exceptionMessage: String? = null
) {
    override fun toString(): String {
        return "ExcelReaderErrorField(type=$type, row=$row, field=$field, fieldHeader=$fieldHeader, inputData=$inputData, message=$message, exceptionMessage=$exceptionMessage)"
    }
}
728x90

댓글

💲 추천 글