이번에 홈플러스 가격 조사 웹 리포트 프로젝트의 백엔드 개발을 맡으며 제가 겪었던 반복문의 굴레 와 그 코드를 실행하는 긴 시간에서 벗어나는 과정에 대하여 정리해보았습니다.
반복문
우선 모두 아는 내용이지만 반복문이란 반복적으로 실행해야 하는 코드가 있을 때 사용하며 for, for..in, for…of, forEach, while, do…while등 다양한 종류를 상황에 따라 사용할 수 있습니다. 같은 코드를 여러 번 쓰는 대신 반복문을 사용하여 짧고 가독성 좋게 코드를 짤 수 있습니다.
이번에 제가 참여했던 홈플러스 웹 리포트의 경우 대량의 데이터를 사용하여 피벗 테이블을 구현하는 것이었는데 이 피벗테이블의 구성은 각 행과 열의 복합적인 합계 혹은 평균값 요약으로 이루어져 있었습니다. 각 행과 열은 8개, 3개의 선택 값을 조합하여 만들 수 있었기 때문에 선택 값에 따라 요약값을 구하는 횟수 등 테이블의 형태가 달라지게 됩니다.
12만개가 넘는 대량의 데이터들을 활용하여 각 행과 열 조건에 맞는 값을 DB에서 조회한 후 그 값으로 각각 요약 값을 계산해야 하는 로직을 짜야 했습니다.
요약의 요약의 요약의 요약…
저는 테이블 전체를 하나의 2차원 배열로 만들어 행과 열의 요약 값, 그리고 그 요약 값의 요약 값까지 계산해 넣은 뒤 프론트에 전달하기로 했습니다.
db.getCollection("raw-data").aggregate([
{
$match:{
Year: { '$in': [ '2425' ] },
Same_Similar: { '$in': [ '1' ] },
Care_Vegetable: { '$in': [ '0', '1' ] },
R_HM: { '$in': [ '미행사', '비전단', '전단' ] },
R_EM: { '$in': [ '미행사', '비전단', '전단' ] }
}
},
{
$project: {
Price: 1,
Year: { '$ifNull': [ '$Year', '10. (비어 있음)' ] },
Category: { '$ifNull': [ '$Category', '10. (비어 있음)' ] },
Month: { '$ifNull': [ '$Month', '(비어 있음)' ] },
Team: { '$ifNull': [ '$Team', '(비어 있음)' ] },
Week: { '$ifNull': [ '$Week', '10. (비어 있음)' ] },
Class: { '$ifNull': [ '$Class', '(비어 있음)' ] }
}
},
{
$group: {
_id: {
column: {
Category: '$Category',
Team: '$Team',
Class: '$Class'
},
row: {
Year: '$Year',
Month: '$Month',
Week: '$Week'
}
},
value: { '$sum': '$Price' },
sum: { '$sum': '$Price' },
count: { '$count': {} }
}
},
{
$sort: {
'_id.column.Category': 1,
'_id.column.Team': 1,
'_id.column.Class': 1,
'_id.row.Year': 1,
'_id.row.Month': 1,
'_id.row.Week': 1
}
}
])
우선 _id에 row와 column 정보를 넣고 그룹화하여 데이터를 조회합니다.
// hyperData =
[{
"_id" : {
"column" : {
"Category" : "Category A",
"Team" : "Team 1",
"Class" : "Class A"
},
"row" : {
"Year" : "2024",
"Month" : "May",
"Week" : "W01"
}
},
"value" : NumberInt(5580),
"sum" : NumberInt(5580),
"count" : NumberInt(2)
},
{
"_id" : {
"column" : {
"Category" : "Category A",
"Team" : "Team 1",
"Class" : "Class A"
},
"row" : {
"Year" : "2024",
"Month" : "May",
"Week" : "W03"
}
},
"value" : NumberInt(5580),
"sum" : NumberInt(5580),
"count" : NumberInt(2)
},
{
"_id" : {
"column" : {
"Category" : "Category A",
"Team" : "Team 1",
"Class" : "Class A"
},
"row" : {
"Year" : "2024",
"Month" : "May",
"Week" : "W06"
}
},
"value" : NumberInt(5580),
"sum" : NumberInt(5580),
"count" : NumberInt(2)
},
{
"_id" : {
"column" : {
"Category" : "Category A",
"Team" : "12 Vegetable",
"Class" : "Class A"
},
"row" : {
"Year" : "2024",
"Month" : "June",
"Week" : "W08"
}
},
"value" : NumberInt(5980),
"sum" : NumberInt(5980),
"count" : NumberInt(2)
},
{
"_id" : {
"column" : {
"Category" : "Category A",
"Team" : "Team 1",
"Class" : "Class B"
},
"row" : {
"Year" : "2024",
"Month" : "June",
"Week" : "W01"
}
},
"value" : NumberInt(20970),
"sum" : NumberInt(20970),
"count" : NumberInt(3)
},
{
"_id" : {
"column" : {
"Category" : "Category A",
"Team" : "Team 1",
"Class" : "Class B"
},
"row" : {
"Year" : "2024",
"Month" : "June",
"Week" : "W03"
}
},
"value" : NumberInt(12480),
"sum" : NumberInt(12480),
"count" : NumberInt(2)
},
{
"_id" : {
"column" : {
"Category" : "Category A",
"Team" : "Team 1",
"Class" : "Class B"
},
"row" : {
"Year" : "2024",
"Month" : "June",
"Week" : "W06"
}
},
"value" : NumberInt(5990),
"sum" : NumberInt(5990),
"count" : NumberInt(1)
},
...more
]
반환 되는 데이터는 테이블의 헤더 요소와 계산되지 않은 흰색 셀의 값들입니다.
제가 짠 로직의 순서는
- 필터 조건으로 aggrgate 메서드를 통해 그룹핑한 기본 데이터와 헤더 요소를 구한다.
- 각 열과 행의 헤더 셀 값을 2차원 배열에 테이블 형태로 넣는다. (표의 자주색, 핑크색, 연핑크색 음영 헤더)
- 기본 데이터를 넣는다. (표의 흰색 음영 숫자 값)
- 총 합계 값을 넣는다. (표의 초록 음영 숫자 값)
- 행 데이터에 대한 요약 값을 계산하여 넣는다 (표의 파란 음영 숫자 값)
- 열 데이터에 대한 요약 값을 계산하여 넣는다 (표의 회색, 자주색 음영 숫자 값)
입니다.
##
2차원 배열에 각각 값을 넣어야 했기 때문에 반복문이 여러 번 쓰였는데 문제는 행, 열의 선택 값이 많을 경우 로직 처리 시간이 매우 길어졌다는 것입니다. console.time으로 걸리는 시간을 확인해보니 2번과 3번 과정에서 60초 정도의 너무나 긴 시간이 소요되고 있었습니다.
기존의 로직은 DB에서 조회한 배열 데이터 요소의 _id에 있는 row 값으로 헤더를 생성하고 id의 column 값들로 반복문을 돌려 각 row 값의 헤더에 맞는 숫자 값을 2차원 배열에 넣는 방식입니다.
// [2] 테이블 헤더에 들어가는 내용을 각각 행, 열을 나누어 배열에 넣음.
const headerRows = [];
const headerColumn = [];
// hyperData: DB에서 조회한 데이터
hyperData.forEach((data) => {
const { _id } = data;
const { row, column } = _id;
// id에서 해당 값의 row, column 정보를 확인
let labelR = '';
Object.values(row).forEach((r) => {
labelR += `${r}/&/`;
});
if (!headerRows.includes(labelR)) headerRows.push(labelR);
let labelC = '';
Object.values(column).forEach((c) => {
labelC += `${c}/&/`;
});
if (!headerColumn.includes(labelC)) headerColumn.push(labelC);
});
headerRows.sort();
const rowArray = [[]];
// 열 헤더 생성.
rows.forEach((r) => {
rowArray[0].push({
rowspan: 1,
colspan: 1,
value: `${r}`,
});
rowArray.push([]);
});
headerRows.forEach((ro) => {
const roArr = ro.split('/&/');
for (let i = 1; i < rows.length + 1; i++) {
rowArray[i].push({
rowspan: 1,
colspan: 1,
value: roArr[i - 1],
});
}
});
//열 헤더 갯수만큼 반복문
for (let i = 0; i < rowArray[rows.length].length; i++) {
const idObj = { row: {}, column: {} };
headerColumn.forEach((hc, hcI) => {
// idObj 객체 생성
const hcArr = hc.split('/&/');
hcArr.pop();
hcArr.forEach((hcItem, ii) => {
idObj.column[columns[ii]] = hcItem;
});
rows.forEach((rr, ii) => {
idObj.row[rr] = rowArray[ii + 1][i].value;
});
let pushItem: {
rowspan: number;
colspan: number;
value: number | string;
valSum?: number;
valCnt?: number;
} = {
rowspan: 1,
colspan: 1,
value: ''
};
// [3] idObj에 해당하는 숫자 값 넣기 -> 많은 시간 소요
hyperData.forEach((hypers) => {
const { _id, value, count } = hypers;
const { row: rId, column: cId } = _id;
//idObj의 row, column 값과 같은 값을 찾기 위해 hyperData 전체를 다시 반복문 돌려 검사
if (
Object.entries(idObj.row).toString() ===
Object.entries(rId).toString() &&
Object.entries(idObj.column).toString() ===
Object.entries(cId).toString()
) {
pushItem = {
rowspan: 1,
colspan: 1,
value: Math.round(value),
valSum: value,
valCnt: count
};
}
});
if (rowArray.length < hcI + rows.length + 2) rowArray.push([]);
rowArray[hcI + rows.length + 1].push(pushItem);
});
}
DB에서 조회한 배열 데이터의 길이는 최대 5000이 넘으며 반복문 두 개를 돌려 행과 열 조건을 확인하여 2 차원 배열에 넣고 있었는데 이 때문에 전체 반복문이 도는 횟수는 5000*5000 회가 되는 일이 발생하게 된 것입니다. 이 프로젝트의 경우 주마다 데이터가 계속해서 추가되기 때문에 속도 문제를 우선적으로 해결해야 했습니다.
[3] 에서 hyperData로 전체 반복문을 한번 더 돌리는 것이 속도 저하의 큰 원인이라는 것을 알게 되어 반복문을 돌리지 않은 채 id값을 비교해서 숫자 값을 찾아 배열에 넣는 방법으로 바꿔야 했습니다. 반복문 대신 id객체 내용을 포함하는 key값을 만들어 hashmap을 사용하는 방법입니다.
HashMap
배열(Array)과 맵(Map)에 관해서 자료구조를 처음 공부 할 때 배웠던 기억이 납니다. HashMap은 키(Key)와 값(Value) 쌍을 저장하는 자료구조입니다. 각 키는 고유하며 키를 사용하여 해당하는 값을 빠르게 검색할 수 있는 특징을 갖고 있습니다. 분명 알고 있는 개념이지만 프로젝트에서 제대로 써 본 기억이 없었던 것 같습니다.
이 밖에 HashMap의 특징으로는
- 키 기반의 빠른 액세스: 키를 사용하여 빠르게 검색, 수정 가능
순서를 보장하지 않음: 내부적으로 키의 순서를 보장하지 않음Map은 순서를 보장한다: HashMap 의 경우 순서를 보장하는 LinkedHashMap이 있다.- 키의 중복 불가: 이미 존재하는 키에 값을 저장하면 기존 값이 덮어 씌워짐.
- null키와 null 값 저장 가능: 키 중복이 불가능 하므로 null 값은 하나만 저장 가능
- 어떤 객체든 키로 사용 가능
이러한 특징은 현재 상황의 문제를 해결하는 데 요구 사항에 잘 들어맞았습니다. 애초부터 배열의 반복문이 아닌 HashMap을 사용하여 코드를 작성해야 했던 것입니다.
for (let i = 0; i < rowArray[rows.length].length; i++) {
const idObj = { row: {}, column: {} };
const hyperDataHash = new Map();
hyperData.forEach(hypers => {
const { _id, value, count, sum } = hypers;
const { row: rId, column: cId } = _id;
// _id 객체의 row, column 객체를 key로 가지는 HashMap 구조 생성
hyperDataHash.set(
`${Object.entries(rId).toString()}_${Object.entries(cId).toString()}`,
{ value, count, sum },
);
});
// idObj 객체 생성
headerColumn.forEach((hc, hcI) => {
const hcArr = hc.split('/&/');
hcArr.pop();
hcArr.forEach((hcItem, ii) => {
idObj.column[columns[ii]] = hcItem;
});
rows.forEach((rr, ii) => {
idObj.row[rr] = rowArray[ii + 1][i].value;
});
let pushItem: {
rowspan: number;
colspan: number;
value: number | string;
valSum?: number;
valCnt?: number;
} = {
rowspan: 1,
colspan: 1,
value: '',
};
//HashMap 에서 idObj 정보로 Key를 조회하여 value 추출
const hashData = hyperDataHash.get(
`${Object.entries(idObj.row).toString()}_${Object.entries(
idObj.column,
).toString()}`,
);
if (hashData) {
const { value, count, sum } = hashData;
pushItem = {
rowspan: 1,
colspan: 1,
value: Math.round(value),
valSum: sum,
valCnt: count,
};
}
if (rowArray.length < hcI + rows.length + 2) rowArray.push([]);
rowArray[hcI + rows.length + 1].push(pushItem);
});
}
반복문을 돌리며 요소를 하나하나 비교하여 해당하는 값을 찾는 과정이 아닌 키 값으로 해당하는 값을 추출하는 방식이기 때문에 훨씬 빠른 속도로 코드를 실행 하게 되었습니다.
후기
알고 있는 개념💫이지만 Map을 활용하여 코드를 짜 본 것이 오랜만이었습니다.🤔 사실 배열 반복문🌀을 돌리면서도☹️ 이런 식으로 코드를 짜면 안될 것 같다고🥺 내심 불안한 마음이 들었는데😨 실제로 하염없이 길어지는 로딩 화면을 보며😰, 그리고 한 번 서버가 죽어버린 것을 보며😱 씁쓸한 기분과 함께😒 속이 콱 막히는 느낌에🤢 답답해서 견딜 수가 없었습니다.🤮
생성한 id를 key처럼 활용해야겠다는 생각을 하면서 왜🤔 손가락은 반복문을 여러 번 돌리고 있었는지 모르겠습니다. 신주임님께서 굳어버린 제 뇌🧠에 많은 도움을 주셨습니다.🙇♀️🙏 글을 마치며 다시 한 번 감사하다고 말씀드립니다.🙇♀️🙏 빨라진 화면을 볼 때마다 제 속이 다 시원합니다.😆