ORM? Raw Query? 선택은..

선택에 따른 책임

문제 해결을 위한 아주 작은 Query Builder

ORM? Raw Query? 선택은..

Boostart.png

위 그림은 저희 프로젝트의 ERD입니다. 친구 관계, 일정과 목표 간의 관계 등 데이터 간의 다양한 관계 설정이 필요했기 때문에 관계형 데이터베이스를 선택하여 MySQL을 통해 구현했습니다. 데이터베이스도 구현했으니 이제 다음 선택은 하나! ORM을 사용할 것인가, Raw Query를 사용할 것인가에 대한 선택이었습니다. ORM의 장점은 명확했습니다. ‘DBMS에 대한 종속성을 줄일 수 있다’, ‘중복 코드를 줄일 수 있다’, ‘유지보수가 편리하다’, ‘비즈니스 로직에 집중할 수 있게 해준다’ 등등.. 많은 장점이 있고 실제로 이전의 프로젝트에서 사용해 봤던 경험을 떠올리면 편리함이 크게 향상되는 것이 사실이었습니다. 하지만 ORM을 사용했을 때 복잡한 관계에 대한 연산을 구현하기 어렵고 ORM으로 표현하기 힘든 쿼리가 존재한다는 것이 걸렸습니다. 특히 저희 프로젝트에서 달력의 각 날짜의 일정 존재 여부, 평균 목표 달성량을 구해야 하는 부분의 경우 ORM으로 구현하는 것이 힘들 수 있겠다는 생각이 들었습니다. 간단한 쿼리를 통해 계산에 필요한 결과값을 통째로 불러온 뒤 비즈니스 로직을 통해 값을 구할 수도 있겠지만 불러와야 하는 데이터의 양이 얼마나 많을지 예상할 수 없었기 때문에 결국 Raw Query의 길을 걷기로 결정했습니다.

선택에 따른 책임

Raw Query를 사용하기로 결정한 뒤 이 기능 저 기능 열심히 구현하다보니 위에서 설명한 가장 복잡한 부분을 구현해야 할 때가 왔습니다. 선택에는 항상 책임이 따르는 법… Raw Query를 선택한 만큼 책임지고 기능을 구현해야 했습니다. 아래 토글로 숨겨둔 과정을 거쳐 아래와 같은 코드가 탄생했습니다. 해당 코드로 실행되는 쿼리는 코드 아래에서 확인할 수 있습니다.

export const getAverageGoalAchievementRate = async (userIdx: number, dateSearchFormat: string) => {
  const taskLabelSql = 'select date, label_idx, amount from task_label inner join task on task_label.task_idx = task.idx where done = true and user_idx = ? and date like ?';
  const currentAmountSql = 'cast(ifnull(sum(task_label.amount), 0) as unsigned)';
  const dailyRateSql = `select goal.date, case when over then if(${currentAmountSql} / goal.amount > 1, 1, ${currentAmountSql} / goal.amount) when ${currentAmountSql} > goal.amount then 0 else 1 end as rate from goal left join (${taskLabelSql}) task_label on goal.label_idx = task_label.label_idx and goal.date = task_label.date where user_idx = ? and goal.date like ? group by idx, date`;
  return (await executeSql(`select date_format(date, "%d") as date, avg(rate) as averageRate from (${dailyRateSql}) daily_rate group by date`, [userIdx, dateSearchFormat, userIdx, dateSearchFormat])) as RowDataPacket;
};
select date_format(date, "%d") as date, convert(avg(rate), decimal(4, 3))
from (
	select date,
		case
			when over then if(cast(ifnull(sum(task_label.amount), 0) as unsigned) / goal.amount > 1, 1, cast(ifnull(sum(task_label.amount), 0) as unsigned) / goal.amount)
			when cast(ifnull(sum(task_label.amount), 0) as unsigned) > goal.amount then 0
			else 1
		end as rate
	from goal
	left join
		(select label_idx, amount
			from task_label
	      inner join task
        on task_label.task_idx = task.idx
        where done = true and user_idx = 19 and date like '2022-12-%'
		) task_label
	on goal.label_idx = task_label.label_idx
  where user_idx = 19 and date like '2022-12-%' group by idx
) daily_rate
group by date;

문제 해결을 위한 아주 작은 Query Builder

위의 기능을 구현한 뒤 이제 제게 남은 고난과 역경은 끝났다고 생각했습니다. 하지만 또 다시 지끈지끈한 고난이 찾아옵니다. 바로 동적 쿼리를 구현하는 것이었습니다. 데이터를 수정할 때 수정을 원하는 값만 입력 받아 해당 값만 수정할 수 있도록 했습니다. 컬럼의 개수가 적은 데이터를 업데이트하는 동적 쿼리를 작성하는 것은 큰 문제가 되지 않았습니다. 하지만 컬럼의 개수가 많은 데이터를 업데이트할 경우 중복되는 코드가 너무 많아지는 문제가 있었습니다. 이 문제를 해결하기 위해 update 문의 set 구문을 만들어주는 함수를 구현했습니다. 아주 작은 Query Builder를 만든 셈입니다. 코드는 아래와 같습니다.

export const setSetSyntax = (ColumnValueList: { column: string; value: any }[], values: any[]) => {
	  return ColumnValueList.reduce((setSyntax, ColumnValue) => {
	    const { column, value } = ColumnValue;
	
	    if (value === undefined) return setSyntax;
	    values.push(value);
	
	    if (setSyntax === '') setSyntax = 'set ';
	    else setSyntax += ', ';
	    setSyntax += `${column} = ?`;
	    return setSyntax;
	  }, '');
};

이 함수를 이용하여 아래의 첫 번째 코드를 두 번째 코드로 개선할 수 있었습니다. 아래의 코드는 컬럼이 적어 크게 실감이 나지는 않지만 컬럼의 개수가 많은 데이터를 수정할 경우 중복 코드를 상당히 줄일 수 있었습니다.

export const updateLabel = async (labelIdx: number, title: string, color: string) => {
	const updateValues = [];
  let updateSql = 'update label set ';
	
	if (title) {
		updateSql += 'title = ?'
		updateValues.push(title);
	}
	if (color) {
		if (title) updateSql += ', ';
		updateSql += 'color = ?'
		updateValues.push(color);
	}

	updateSql += ' where idx = ?';
  updateValues.push(labelIdx);
  await executeSql(updateSql, updateValues);
};
export const updateLabel = async (labelIdx: number, columnValueList: { column: string; value: any }[]) => {
	const updateValues = [];
  const updateSql = `update label ${setSetSyntax(columnValueList, updateValues)} where idx = ?`;
  updateValues.push(labelIdx);
  await executeSql(updateSql, updateValues);
};