우리는 한다, 개발을.

반복문 사용으로 인한 속도 이슈 해결 과정

⌛ 19 mins

이번에 홈플러스 가격 조사 웹 리포트 프로젝트의 백엔드 개발을 맡으며 제가 겪었던 반복문의 굴레 와 그 코드를 실행하는 긴 시간에서 벗어나는 과정에 대하여 정리해보았습니다.

반복문

우선 모두 아는 내용이지만 반복문이란 반복적으로 실행해야 하는 코드가 있을 때 사용하며 for, for..in, for…of, forEach, while, do…while등 다양한 종류를 상황에 따라 사용할 수 있습니다. 같은 코드를 여러 번 쓰는 대신 반복문을 사용하여 짧고 가독성 좋게 코드를 짤 수 있습니다.

이번에 제가 참여했던 홈플러스 웹 리포트의 경우 대량의 데이터를 사용하여 피벗 테이블을 구현하는 것이었는데 이 피벗테이블의 구성은 각 행과 열의 복합적인 합계 혹은 평균값 요약으로 이루어져 있었습니다. 각 행과 열은 8개, 3개의 선택 값을 조합하여 만들 수 있었기 때문에 선택 값에 따라 요약값을 구하는 횟수 등 테이블의 형태가 달라지게 됩니다.

Untitled

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
]

반환 되는 데이터는 테이블의 헤더 요소와 계산되지 않은 흰색 셀의 값들입니다.

제가 짠 로직의 순서는

  1. 필터 조건으로 aggrgate 메서드를 통해 그룹핑한 기본 데이터와 헤더 요소를 구한다.
  2. 각 열과 행의 헤더 셀 값을 2차원 배열에 테이블 형태로 넣는다. (표의 자주색, 핑크색, 연핑크색 음영 헤더)
  3. 기본 데이터를 넣는다. (표의 흰색 음영 숫자 값)
  4. 총 합계 값을 넣는다. (표의 초록 음영 숫자 값)
  5. 행 데이터에 대한 요약 값을 계산하여 넣는다 (표의 파란 음영 숫자 값)
  6. 열 데이터에 대한 요약 값을 계산하여 넣는다 (표의 회색, 자주색 음영 숫자 값)

입니다.

##

Untitled 1

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의 특징으로는

  1. 키 기반의 빠른 액세스: 키를 사용하여 빠르게 검색, 수정 가능
  2. 순서를 보장하지 않음: 내부적으로 키의 순서를 보장하지 않음 Map은 순서를 보장한다: HashMap 의 경우 순서를 보장하는 LinkedHashMap이 있다.
  3. 키의 중복 불가: 이미 존재하는 키에 값을 저장하면 기존 값이 덮어 씌워짐.
  4. null키와 null 값 저장 가능: 키 중복이 불가능 하므로 null 값은 하나만 저장 가능
  5. 어떤 객체든 키로 사용 가능

이러한 특징은 현재 상황의 문제를 해결하는 데 요구 사항에 잘 들어맞았습니다. 애초부터 배열의 반복문이 아닌 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);
  });
}

반복문을 돌리며 요소를 하나하나 비교하여 해당하는 값을 찾는 과정이 아닌 키 값으로 해당하는 값을 추출하는 방식이기 때문에 훨씬 빠른 속도로 코드를 실행 하게 되었습니다.

Untitled 2


후기

알고 있는 개념💫이지만 Map을 활용하여 코드를 짜 본 것이 오랜만이었습니다.🤔 사실 배열 반복문🌀을 돌리면서도☹️ 이런 식으로 코드를 짜면 안될 것 같다고🥺 내심 불안한 마음이 들었는데😨 실제로 하염없이 길어지는 로딩 화면을 보며😰, 그리고 한 번 서버가 죽어버린 것을 보며😱 씁쓸한 기분과 함께😒 속이 콱 막히는 느낌에🤢 답답해서 견딜 수가 없었습니다.🤮

생성한 id를 key처럼 활용해야겠다는 생각을 하면서 왜🤔 손가락은 반복문을 여러 번 돌리고 있었는지 모르겠습니다. 신주임님께서 굳어버린 제 뇌🧠에 많은 도움을 주셨습니다.🙇‍♀️🙏 글을 마치며 다시 한 번 감사하다고 말씀드립니다.🙇‍♀️🙏 빨라진 화면을 볼 때마다 제 속이 다 시원합니다.😆