스프링부트로 엑셀 만들기

허준기·2025년 2월 2일
6

BCSD

목록 보기
9/9
post-thumbnail

1차 개발

동아리 내에서 개발중인 서비스에서 학교 영양사님과의 협업으로 식단 관련 기능을 운영하고 있다.
영양사님께서 시작 날짜와 종료 날짜를 입력하면 해당 기간 내에 있는 식단들을 엑셀로 변환하여 다운로드 할 수 있는 기능을 요청하셨고 개발을 하게 되었다.

그렇게 1차 개발을 했는데, 당시 영양사님이 따로 요청하신 엑셀 디자인 형식이 없어서 일단은 임의로 개발을 하게 되었고 해당 디자인은 이렇다

굉장히 투박한 디자인..
나는 디자인 감각이 없다!

그런데 이번에 영양사님과의 미팅 결과 영양사님께서 원하는 디자인이 있다고 하셨고 그 디자인을 받아봤다!!

기존 디자인은 날짜가 아래로 내려가는 형식인데, 영양사님이 새롭게 원하신 디자인은 가로로 늘어나는 형식이다....

기존 코드를 갈아엎어야 하는 상황!!!
이렇게 된 이상 아예 설계부터 다시 해봐야겠다

설계

기존에 개발해두었던 것과의 하위호환성을 위해 파라미터는 그대로 가야한다

기존에는 startDate, endDate, isCafeteria 라는 값을 받아 엑셀을 반환해줬다
이번에도 그렇다

  • isCafeteriatrue2캠퍼스학생식당을 제외한 A,B,C 코너만 반환
  • isCafeteriafalse → 모든 식단 반환
  • startDateendDate에 해당하는 식단 반환

그리고 가장 크게 달라진 부분이 있는데 기존에는 아침A코너인 식단을 한 행에 넣고 다음행으로 넘어가면 됐지만, 바뀐 디자인은 동일한 날짜에 있는 식단은 동일한 열에 저장하기 때문에 완전히 다른 방식으로 구현해야 했다.

private void addDiningDataToSheet(List<Dining> dinings, SXSSFSheet sheet, CellStyle commonStyle) {
        AtomicInteger rowIndex = new AtomicInteger(1);
        dinings.forEach(dining -> {
            Row row = sheet.createRow(rowIndex.getAndIncrement());
            fillDiningRow(dining, row, commonStyle);
        });

        for (int i = 0; i < EXCEL_COLUMN_COUNT; i++) {
            sheet.setColumnWidth(i, 6000);
        }
    }

기존의 코드를 보면 이런식으로 dining 를 받아와서 Row 한개를 생성하고, 해당 Row를 채워주는 형식으로 진행했다. 기존 디자인은 이렇게 코드가 복잡하지 않고 간단하게 구현할 수 있었다.

하지만 바뀐 코드는 달랐다.. 우선 날짜별로 식단을 모아줘야 한다. 그리고 알맞은 위치에 알맞은 식단을 그려주기 위해서 해당 식단들은 자신의 위치를 가지고 있어야 한다. 이 부분에서 많은 고민을 하다가 자료구조 중 하나인 Map 을 사용했다. Key 값이 LocalDate로 해당 식단의 날짜이고, Value 값은 해당 날짜의 Dining 들을 List로 갖고 있는 방식으로 구현했다.

List<Dining> dinings = fetchDiningData(startDate, endDate, isCafeteria);       

Map<LocalDate, List<Dining>> dateDinings = dinings.stream()
    .collect(Collectors.groupingBy(
        Dining::getDate, 
        TreeMap::new,    
        Collectors.toList() 
    ));

설계 얘기하다가 구현으로 잠깐 빠져버렸다..

이 부분은 초기에 변경할 때부터 생각한 부분이고 구현 부분에서 엄청난 시간을 쓰게 되었다.
그 이유는 뒤에 설명..

아무튼 완성 후 전체적인 로직 순서를 대략적으로 설명해보자

순서

  1. startDateendDate를 기준으로 식단을 받아온다
  2. isCafeteria를 기준으로 코너를 정한다
  3. 초기에 행을 미리 만들어둔다
  4. meal 부분과 corner 부분을 병합한다
  5. 날짜별로 데이터를 삽입한다

이렇게 크게 5가지 순서로 나눌 수 있다
이제 각 단계별로 코드 설명과 함께 겪었던 어려움에 대해서 알아보자....

구현

위의 순서에 있는 1번은 기존 디자인의 코드와 유사해서 크게 건드릴게 없었다.
그래도 기존과 바뀐 부분이 있는데 그게 위에서 말한 Map을 통한 필터링이었다.

private void addDiningDataToSheet(List<Dining> dinings, SXSSFSheet sheet, CellStyle commonStyle) {
        AtomicInteger rowIndex = new AtomicInteger(1);
        dinings.forEach(dining -> {
            Row row = sheet.createRow(rowIndex.getAndIncrement());
            fillDiningRow(dining, row, commonStyle);
        });

        for (int i = 0; i < EXCEL_COLUMN_COUNT; i++) {
            sheet.setColumnWidth(i, 6000);
        }
    }

이 부분인데 이렇게 날짜별로 나눠도 엑셀에 데이터를 바로 집어 넣을때 쓰지는 못한다.

해당 사진과 같이 알맞은 시간(조식,중식,석식)에 데이터가 들어가야 하고, 그 안에서도 코너별로 데이터가 들어가야 한다. 다행히도 Dining 테이블에 해당 부분을 다루는 컬럼이 있어 해당 값들로 엑셀 내의 데이터를 지정해줘야한다.

이 부분을 어떻게 구현할지 고민하다가 일단 엑셀 먼저 그려보고 생각해보려고 순서는 고려 안하고 병합 부분에 대한 구현부터 시작했다.

셀 병합

셀 병합부터 해야 식단 데이터를 넣기 수월할 것 같아 시작을 했다.
셀 병합 부분에서 가장 문제였던 부분이 isCafeteria의 값에 영향을 받는다는 것이었다.

영양사님이 초기 엑셀 다운로드 요구사항을 주실때, 학식당에 속하는 A코너, B코너, C코너 만 받을 수 있는 기능을 원하셨고 해당 부분을 isCafeteria 라는 Boolean 값을 통해 해당 코너만 보여줄지, 능수관, 2캠퍼스 까지 보여줄지 정할 수 있었다.

그래서 기존 디자인에서는 해당 값을 데이터를 불러올때만 사용하면 됐지만 변경된 디자인에서는 셀 병합을 해줘야한다.

그래서 isCafeteria 값에 따라서 어떤 List<String> corners를 선택할지 정하는 코드를 통해 병합을 해줬다

List<String> corners = isCafeteria ? cafeteriaPlaceFilters : allPlaceFilters;

isCafeteriafalse 인 경우

isCafeteriatrue 인 경우

데이터 삽입

셀 병합 문제를 해결하고 가장 큰 문제인 데이터 집어넣기를 시작했다...
굉장히 많은 시행착오가 있어서 오래 걸렸다

데이터가 사라져요

Map에 날짜별로 식단을 저장해 놓아서 한 행씩 내려가면서 식단 데이터를 넣어주면 됐다
물론 위치도 지정해줘야 했지만, 우선 모든 데이터를 엑셀에 집어넣는 것이 우선적으로 되어야 위치를 지정할 수 있기 때문에 모든 데이터를 넣기 위해 개발을 해봤다

그런데.....

위의 사진처럼 식단 데이터를 집어 넣으면 코너 병합한 부분(조식, A코너, B코너 ...)의 데이터가 사라지는 상황이 발생했다!
그리고 메뉴 부분을 보면 해당 셀에 있는것이 아니라 옆 셀로 삐져나가는 것을 볼 수 있다...
분명히 String.join을 통해서 개행을 줬지만 저런식으로 데이터가 들어갔다

왜 이런 현상이 발생했을까?

데이터가 이제 안사라져요

지금은 코드로 남아있지는 않지만 해당 상황을 발생시킨 코드부분에서 문제가 발생한것이었다
날짜별로 돌아가면서 새로운 Row를 생성하면서 식단 데이터를 삽입해줬는데 이 새로운 Row를 생성 하는 과정에서 기존 데이터가 들어가 있던 Row가 새로 덮어씌워지면서 사라지는 문제였다!

이 부분은 데이터를 삽입할때마다 새로운 Row를 생성해주는 방식이 아니라, 처음에 Row 들을 만들어놓고 꺼내 쓰는 방식으로 해결을 해주었다.

 private Map<Integer, Row> initializeRowCache(Sheet sheet, int numRows) {
        Map<Integer, Row> rowCache = new HashMap<>();
        for (int i = 0; i < numRows; i++) {
            rowCache.put(i, sheet.createRow(i));
        }

        return rowCache;
    }

다만 지금은 동적으로 생성해주는게 아니라 고정값을 넣어 생성해주고 있어 추후에 필요한 행의 개수만큼만 생성해주도록 하는 최적화가 필요하다

Row menuRow = rowCache.get(startPosition);
Cell menuCell = menuRow.createCell(colIndex);
menuCell.setCellValue(formatMenu(dining.getMenu()));
menuCell.setCellStyle(cellStyle);

위에서 만들어진 rowCache 에서 해당하는 알맞은 Row를 가져와서 데이터를 삽입해주는 방식이다

그렇게 집 나간 코너들을 데려왔지만 여전히 옆의 공간을 침범하는 일이 일어났는데 이 부분은 셀의 데이터를 정렬해주는 코드를 적용해주니 해결이 됐다!

가운데 정렬을 적용해 준 모습
위의 코드에서 menuCell.setCellStyle(cellStyle) 부분이 해당된다

알맞은 위치에 식단 데이터 넣어주기

이제 모든 데이터를 엑셀에 넣는것은 성공했지만 어떤 시간대의 어떤 코너인지 알 수 없다

이 부분에 대해서 어떻게 할지 많은 고민을 했다
Map<LocalDate, List<Dining>> 에서 List<Dining> 부분에 대한 정렬을 진행해줘야 할지? → 이 방법이 괜찮다고 생각했지만 정렬을 할 마땅한 방법이 없다..

id로 정렬하기?

하지만 db를 보면 id 순으로 정렬하게 되어도 같은 시간대별로 묶이지 않는 것을 볼 수 있다
그리고 위치를 알맞게 지정해 줄 수도 없다

그래서 생각하게 된 방법이 식단의 타입(시간대)와 코너를 인자로 받아 위치를 지정해주는 방식이었다

Enum으로 위치 지정

위에서 말한 것처럼 식단의 타입코너를 받아와 위치를 반환해주기 위한 Enum 을 하나 만들어주었다

이 부분에서 고려해야할 것은 isCafeteria 값에 따라 위치가 변한다는 점이다

isCafeteriatrue 이면 각 식단의 타입A코너, B코너, C코너 만 그려주면 되지만 false 일 경우 3코너에 추가로 능수관, 2캠퍼스를 더 그려줘야 하기 때문에 위치를 따로 관리해줘야 한다

게다가 영양사님이 관리하시는 식당들은 메뉴, 이미지 URL, 칼로리, 품절 여부를 엑셀에 넣어야 하지만 그렇지 않은 능수관, 2캠퍼스메뉴칼로리 만 넣어야 하기 때문에 위치를 지정해주기 어려웠다

결국 Enum에 위치를 2개 저장해주었다

public enum ExcelDiningPosition {
    BREAKFAST_A_CORNER(BREAKFAST, "A코너", getFirstPosition(), getFirstPosition()),
    BREAKFAST_B_CORNER(BREAKFAST, "B코너", getFirstPosition() + 4, getFirstPosition() + 4),
    BREAKFAST_C_CORNER(BREAKFAST, "C코너", getFirstPosition() + 8, getFirstPosition() + 8),
    BREAKFAST_WELFARE(BREAKFAST, "능수관", 0, getFirstPosition() + 12),
    BREAKFAST_CAMPUS2(BREAKFAST, "2캠퍼스", 0, getFirstPosition() + 14),
    LUNCH_A_CORNER(LUNCH, "A코너", getFirstPosition() + 12, getFirstPosition() + 16),
    LUNCH_B_CORNER(LUNCH, "B코너", getFirstPosition() + 16, getFirstPosition() + 20),
    LUNCH_C_CORNER(LUNCH, "C코너", getFirstPosition() + 20, getFirstPosition() + 24),
    LUNCH_WELFARE(LUNCH, "능수관", 0, getFirstPosition() + 28),
    LUNCH_CAMPUS2(LUNCH, "2캠퍼스", 0, getFirstPosition() + 30),
    DINNER_A_CORNER(DINNER, "A코너", getFirstPosition() + 24, getFirstPosition() + 32),
    DINNER_B_CORNER(DINNER, "B코너", getFirstPosition() + 28, getFirstPosition() + 36),
    DINNER_C_CORNER(DINNER, "C코너", getFirstPosition() + 32, getFirstPosition() + 40),
    DINNER_WELFARE(DINNER, "능수관", 0, getFirstPosition() + 44),
    DINNER_CAMPUS2(DINNER, "2캠퍼스", 0, getFirstPosition() + 46),
    ;

    private final DiningType diningType;
    private final String place;
    private final int startPositionOnlyCafeteria;
    private final int startPositionAllPlace;

    private static int getFirstPosition() {
        return 1;
    }

    ExcelDiningPosition(
        DiningType diningType,
        String place,
        int startPositionOnlyCafeteria,
        int startPositionAllPlace
    ) {
        this.diningType = diningType;
        this.place = place;
        this.startPositionOnlyCafeteria = startPositionOnlyCafeteria;
        this.startPositionAllPlace = startPositionAllPlace;
    }

    public static ExcelDiningPosition from(DiningType diningType, String place) {
        return Arrays.stream(values())
            .filter(position -> position.diningType == diningType && position.place.equals(place))
            .findFirst()
            .orElseThrow(() -> new ExcelDiningPositionNotFoundException(
                "유효하지 않은 ExcelDiningPosition: " + diningType + " and Place: " + place));
    }

    public int getStartPositionOnlyCafeteria(){
        return startPositionOnlyCafeteria;
    }

    public int getStartPositionAllPlace(){
        return startPositionAllPlace;
    }
}

위치에 대해서 +4 이런식으로 좀 하드코딩을 해놨는데 이 부분을 어떻게 편하게 관리할지에 대한 개선도 필요하다

이 부분은 좀 더 고민을 해봐야겠다...

리팩토링

우선 1차적으로 모든 요구사항을 만족하도록 1차적으로 코드를 짰더니 너무 더럽고 최적화가 거의 되어 있지 않아 리팩토링을 해야했다

그래도 리팩토링을 하는데 유의미한 성능 개선이 있으면 좋을 것 같아 시간을 측정해보고 얼마나 개선이 되는지 측정을 해봤다

리팩토링 하기 전에 4번 측정해서 평균이 779.75ms 이 나오고 리팩토링을 한 뒤에 측정을 다시 해봤다!

하루가 차이 나긴 하지만 큰 차이는 나지 않으니 3번 정도 측정을 해보고 평균을 내 본 결과 635.33ms 가 나왔다!!

18.5% 정도의 성능이 개선되긴 했지만 큰 차이는 나지 않아 약간 아쉽다

그래도 좀 더 성능을 개선할 부분이 있어 좀 더 개선을 해봐야겠다!!

마무리

처음 개발한 디자인과 완전히 바뀌어서 코드를 거의 처음부터 다시 짰다..
그 과정에서 시행착오도 많이 겪고 원하는 대로 되지 않아 힘들었는데 그래도 결국 완성을 한 것 같아서 다행이다

백엔드 개발은 검은 화면에 흰 글씨만 나와서 심심한데 엑셀 작업은 내가 짠 코드들이 바로 반영되는게 눈에 보이니까 재미는 있었지만, 그 과정에서 어긋난 부분들도 바로 반영돼서 보이니까 스트레스도 받았다...
나는 역시 백엔드가 맞는 것 같다 1픽셀 차이나 셀 하나 차이로 어긋나는게 보이니까 화가 난다
검은 화면에 흰 글씨가 더 재밌다!

처음에 영양사님의 디자인 요구사항이 없어서 임의로 하다가 요구사항이 생겨 바꾸게 되니 힘들었지만 재미는 있었다. 그래도 요구되는 디자인이 있어서 그걸 따라가면 되니까 편했다..?
암튼 목표가 있어서 그래도 괜찮았던 것 같다

그리고 이런식으로 나중에 요구사항이 변경될수도 있으니 코드의 구조를 좀 더 잘 구성해야겠다는 생각도 들었다

내가 만든 엑셀로 마무리.. 잘 만들긴 한 것 같다 후후

끝!!

profile
나는 허준기

0개의 댓글

관련 채용 정보