동아리 내에서 개발중인 서비스에서 학교 영양사님과의 협업으로 식단 관련 기능을 운영하고 있다.
영양사님께서 시작 날짜와 종료 날짜를 입력하면 해당 기간 내에 있는 식단들을 엑셀로 변환하여 다운로드 할 수 있는 기능을 요청하셨고 개발을 하게 되었다.
그렇게 1차 개발을 했는데, 당시 영양사님이 따로 요청하신 엑셀 디자인 형식이 없어서 일단은 임의로 개발을 하게 되었고 해당 디자인은 이렇다
굉장히 투박한 디자인..
나는 디자인 감각이 없다!
그런데 이번에 영양사님과의 미팅 결과 영양사님께서 원하는 디자인이 있다고 하셨고 그 디자인을 받아봤다!!
기존 디자인은 날짜가 아래로 내려가는 형식인데, 영양사님이 새롭게 원하신 디자인은 가로로 늘어나는 형식이다....
기존 코드를 갈아엎어야 하는 상황!!!
이렇게 된 이상 아예 설계부터 다시 해봐야겠다
기존에 개발해두었던 것과의 하위호환성을 위해 파라미터
는 그대로 가야한다
기존에는 startDate
, endDate
, isCafeteria
라는 값을 받아 엑셀을 반환해줬다
이번에도 그렇다
isCafeteria
가 true
→ 2캠퍼스
와 학생식당
을 제외한 A,B,C 코너만 반환isCafeteria
가 false
→ 모든 식단 반환startDate
와 endDate
에 해당하는 식단 반환그리고 가장 크게 달라진 부분이 있는데 기존에는 아침
에 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()
));
설계 얘기하다가 구현으로 잠깐 빠져버렸다..
이 부분은 초기에 변경할 때부터 생각한 부분이고 구현 부분에서 엄청난 시간을 쓰게 되었다.
그 이유는 뒤에 설명..
아무튼 완성 후 전체적인 로직 순서를 대략적으로 설명해보자
startDate
와endDate
를 기준으로 식단을 받아온다isCafeteria
를 기준으로 코너를 정한다- 초기에 행을 미리 만들어둔다
meal
부분과corner
부분을 병합한다- 날짜별로 데이터를 삽입한다
이렇게 크게 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;
isCafeteria
가false
인 경우
isCafeteria
가true
인 경우
셀 병합 문제를 해결하고 가장 큰 문제인 데이터 집어넣기를 시작했다...
굉장히 많은 시행착오가 있어서 오래 걸렸다
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
을 하나 만들어주었다
이 부분에서 고려해야할 것은 isCafeteria
값에 따라 위치가 변한다는 점이다
isCafeteria
가 true
이면 각 식단의 타입
당 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픽셀 차이나 셀 하나 차이로 어긋나는게 보이니까 화가 난다
검은 화면에 흰 글씨가 더 재밌다!
처음에 영양사님의 디자인 요구사항이 없어서 임의로 하다가 요구사항이 생겨 바꾸게 되니 힘들었지만 재미는 있었다. 그래도 요구되는 디자인이 있어서 그걸 따라가면 되니까 편했다..?
암튼 목표가 있어서 그래도 괜찮았던 것 같다
그리고 이런식으로 나중에 요구사항이 변경될수도 있으니 코드의 구조를 좀 더 잘 구성해야겠다는 생각도 들었다
내가 만든 엑셀로 마무리.. 잘 만들긴 한 것 같다 후후