Vue.js 3 & NodeJS/NodeJS

Node.js 레스트 API 조회 필터 - Node.js REST API Filtering, Multi-Column Filter

carrotweb 2022. 4. 18. 01:42
728x90
반응형

Filtering(필터링)

Database(데이터베이스)에서 지정된 칼럼을 비교문으로 필터링(쿼리 구문에서 Where 절) 하여 데이터를 반환합니다.

레스트 API에서 필터링(Filtering)을 비교문으로 처리하기 위해서 Query String(쿼리 스트링)으로 URL 주소 뒤에 붙여서 사용합니다.

REST API Multi-Column Filter(레스트 API 멀티 칼럼 필터)

지정된 칼럼을 비교 값으로 비교합니다.

http://localhost:9000/boards?writer=eq:tester1 -> 작성자가 'tester1'인 게시물만

 

필터는 다음과 같이 비교 대상 칼럼명과 비교문, 비교 값으로 구성됩니다.

칼럼명=비교문:비교값

 

조건문은 다음과 같습니다.

숫자, 날짜 비교문

= : eq(equal)

< : lt(little)

<= le(little or equal)

> : gt(greater)

>= : ge(greater or equal)

!= : ne(not equal)

http://localhost:9000/boards?views=gt:10 -> 조회수가 10보다 큰 게시물
http://localhost:9000/boards?views=ge:10&views=lt:100 -> 조회수가 10보다 크거나 같고 100보다 작은 게시물
http://localhost:9000/boards?category=eq:life -> 카테고리가 'life'인 게시물

 

SQL 쿼리문으로 처리하면 다음과 같습니다.

SELECT *
  FROM boards
 WHERE views >= 10 and views < 100

 

문자 비교문

= : eq(equal)

= : ei(equal ignorecase)

!= : ne(not equal)

http://localhost:9000/boards?writer=eq:tester1 -> 작성자가 'tester1'인 게시물만
http://localhost:9000/boards?writer=ne:tester1 -> 작성자가 'tester1'이 아닌 게시물만

 

SQL 쿼리문으로 처리하면 다음과 같습니다.

SELECT *
  FROM boards
 WHERE writer = 'tester'

 

여러 개의 비교문을 AND, OR, NOT으로 처리하는 복잡한 비교문은 레스트 API로 처리하기에는 적합하지 않습니다. 그래서 복잡한 비교문은 SQL 쿼리문에서 처리할 수 있게 하고 레스트 API에서는 단순하게 비교할 수 있게만 하는 것이 좋습니다.

다음에 SQL 쿼리문과 Mybatis를 사용하는 방법을 설명하도록 하겠습니다.

그럼 게시판 REST API에 필터를 추가하겠습니다.

 

게시판 REST API에 Multi-Column Filter(멀티 칼럼 필터) 추가

1. C:\workspaces\nodeserver\testrestapi\boardapi.js 파일을 오픈하여 router.get('/') 라우터 위에 filtering() 함수들을 추가합니다. 필터 처리된 배열 객체를 리턴하기 위해서 배열 객체를 생성합니다.

// 게시물 필터
function filtering(req, boardList) {
	// 필터된 배열
	var filtered = [];
	
	return filtered;
}

 

레스트 API에서 사용하는 페이지네이션과 정렬 관련 Query String(쿼리 스트링)들은 필터에서 제외하기 위해 배열에 키로 등록시켜 제외 처리합니다. (Query String(쿼리 스트링)의 키(Key)를 소문자로 변환하여 처리합니다.)

// 게시물 필터
function filtering(req, boardList) {
	// 필터된 배열
	var filtered = [];
	
	// 필터 제외
	const excludeFilter = ["countperpage", "pageno", "pagesize", "sortby"];
	// 필터
	for (queryName in req.query) {
		if (!excludeFilter.includes(queryName.toLowerCase())) {
		}
	}
	
	return filtered;
};

 

제외 처리한 후 Query String(쿼리 스트링)에서 칼럼명, 비교문, 비교 값으로 분리합니다. Query String(쿼리 스트링) 값에 :(콜론)이 포함되어 있지 않으면 기본적으로 비교문을 eq로 처리하게 합니다.

// 게시물 필터
function filtering(req, boardList) {
	// 필터된 배열
	var filtered = [];
	
	// 필터 제외
	const excludeFilter = ["countperpage", "pageno", "pagesize", "sortby"];
	// 필터
	for (queryName in req.query) {
		if (!excludeFilter.includes(queryName.toLowerCase())) {
			// 대상 칼럼 명
			var columnName = queryName;
			// 비교문
			var compareCondition = "eq";
			// 비교값
			var compareValue = req.query[queryName];
			var pos = compareValue.indexOf(":");
			if (pos > 0) { 
				compareCondition = compareValue.substr(0, pos);
				compareValue = compareValue.substring(pos+1);
			}
		}
	}
	
	return filtered;
};

 

Query String(쿼리 스트링) 값이 문자열이 아닌 배열이 오는 경우도 있습니다. 배열이 오는 경우는 Query String(쿼리 스트링)의 키(Key)가 동일할 때입니다.

views=ge:10&views=lt:100
--> [ 'ge:10', 'lt:100' ]

그래서 문자열 처리와 배열 처리로 나눕니다.

// 게시물 필터
function filtering(req, boardList) {
	// 필터된 배열
	var filtered = [];
	
	// 필터 제외
	const excludeFilter = ["countperpage", "pageno", "pagesize", "sortby"];
	// 필터
	for (queryName in req.query) {
		if (!excludeFilter.includes(queryName.toLowerCase())) {
			// 대상 칼럼 명
			var columnName = queryName;
			// 비교문
			var compareCondition = "eq";
			// 비교값
			var compareValue = req.query[queryName];
			if (typeof compareValue == "string") {
				var pos = compareValue.indexOf(":");
				if (pos > 0) {
					compareCondition = compareValue.substr(0, pos);
					compareValue = compareValue.substring(pos+1);
				}
			} else if (Array.isArray(compareValue)) {
				for (var index = 0; index < compareValue.length; index++) {
					// 비교문
					var arCompareCondition = "eq";
					// 비교값
					var arCompareValue = compareValue[index];
					var pos = arCompareValue.indexOf(":");
					if (pos > 0) {
						arCompareCondition = arCompareValue.substr(0, pos);
						arCompareValue = arCompareValue.substring(pos+1);
					}
				}
			}
		}
	}
	
	return filtered;
};

 

현재 DB를 사용하지 않고 있어서 배열로 데이터를 관리하고 있습니다. 그래서 쿼리문이 아닌 Array 객체의 filter() 메서드를 사용하여 처리하겠습니다.

Array Filter(배열 필터)

Array 객체의 filter() 메서드 구문입니다.

arr.filter(callback(element[, index[, array]])[, thisArg]);

callback 함수로 배열에 있는 element를 파라미터로 전달합니다.
callback 함수의 리턴 값이 true 이면 필터 처리되고 false 이면 필터 되지 않습니다.
리턴 값으로 필터 된 (callback 함수에 리턴 값이 true인 element) element의 배열이 반환됩니다.

Array 객체의 filter() 메서드를 사용하여 첫 글자가 'M'인 월만 필터링하겠습니다.

const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
// 첫 글짜가 'M'인 월만 추출합니다.
var filtered = months.filter(function(element) {
	return element.substr(0, 1) == 'M';
});

console.log(filtered);
--> [ 'March', 'May' ]

 

callback 함수인 filter()를 생성합니다.
파라미터로 전달되는 element는 배열의 element, columnName은 대상 칼럼명, compareCondition은 비교문, compareValue은 비교값입니다.
element 객체에서 대상 칼럼(columnName)를 찾고 대상 칼럼 값의 typrof가 string 이면 문자열 비교로 number 이면 숫자 비교로 처리합니다.

// 게시물 필터
function filter(element, columnName, compareCondition, compareValue) {
	var result = false;
	if (typeof element[columnName] == "string") {
		// 문자열 비교
		if (compareCondition == "eq") {
			result = (element[columnName] == compareValue);
		} else if (compareCondition == "ei") {
			result = (element[columnName].toUpperCase() == compareValue.toUpperCase());
		} else if (compareCondition == "ne") {
			result = (element[columnName] != compareValue);
		}
	} else if (typeof element[columnName] == "number") {
		// 숫자 비교
		if (compareCondition == "eq") {
			result = (element[columnName] == compareValue);
		} else if (compareCondition == "lt") {
			result = (element[columnName] < compareValue);
		} else if (compareCondition == "le") {
			result = (element[columnName] <= compareValue);
		} else if (compareCondition == "gt") {
			result = (element[columnName] > compareValue);
		} else if (compareCondition == "ge") {
			result = (element[columnName] >= compareValue);
		} else if (compareCondition == "ne") {
			result = (element[columnName] != compareValue);
		}
	}
	return result;
};

 

배열의 filter() 메서드에 callback 함수로 생성한 filter()를 적용시킵니다.
필터 대상이 없을 경우 파라미터로 전달되는 boardList를 리턴하고 필터 대상이 하나 이상일 경우 for() 문으로 반복 처리합니다. 첫 번째는 파라미터로 전달되는 boardList를 필터 처리하고 두 번째부터는 필터 된 배열로 처리되게 합니다. 그래야 AND 조건으로 연속해서 필터 처리할 수 있습니다. 그리고 반복 처리 중에 필터 된 배열이 없으면 바로 리턴 처리합니다.

// 게시물 필터
function filtering(req, boardList) {
	// 필터된 배열
	var filtered = [];
	// 반복 카운트
	var loopCount = 0;
	
	// 필터 제외
	const excludeFilter = ["countperpage", "pageno", "pagesize", "sortby"];
	// 필터
	for (queryName in req.query) {
		if (!excludeFilter.includes(queryName.toLowerCase())) {
			// 대상 칼럼 명
			var columnName = queryName;
			// 비교문
			var compareCondition = "eq";
			// 비교값
			var compareValue = req.query[queryName];
			if (typeof compareValue == "string") {
				var pos = compareValue.indexOf(":");
				if (pos > 0) {
					compareCondition = compareValue.substr(0, pos);
					compareValue = compareValue.substring(pos+1);
				}
				if (loopCount == 0) {
					filtered = boardList.filter(function(element) {
						return filter(element, columnName, compareCondition, compareValue);
					});
				} else {
					filtered = filtered.filter(function(element) {
						return filter(element, columnName, compareCondition, compareValue);
					});
				}
				loopCount++;
			} else if (Array.isArray(compareValue)) {
				for (var index = 0; index < compareValue.length; index++) {
					// 비교문
					var arCompareCondition = "eq";
					// 비교값
					var arCompareValue = compareValue[index];
					var pos = arCompareValue.indexOf(":");
					if (pos > 0) {
						arCompareCondition = arCompareValue.substr(0, pos);
						arCompareValue = arCompareValue.substring(pos+1);
					}
					if (loopCount == 0) {
						filtered = boardList.filter(function(element) {
							return filter(element, columnName, arCompareCondition, arCompareValue);
						});
					} else {
						filtered = filtered.filter(function(element) {
							return filter(element, columnName, arCompareCondition, arCompareValue);
						});
					}
					loopCount++;
				}
			}
			if (filtered.length == 0) {
				break;
			}
		}
	}
	
	if (loopCount == 0) {
		filtered = boardList;
	}
	
	return filtered;
};

 

 

2. router.get('/') 라우터에 filtering() 함수를 추가합니다. 그리고 필터 된 배열이 없다면 기존 boardList 배열 객체를 사용합니다.

// 필터된 게시판
var filteredBoardList = filtering(req, boardList);

그리고 하단에 있는 정렬과 페이지네이션 처리에 사용된 boardList 배열 객체를 filteredBoardList 배열 객체로 변경합니다.

전체 소스입니다.

router.get('/', function(req, res, next) {
	console.log("REST API Get Method - Read All");
	
	// 필터된 게시판
	var filteredBoardList = filtering(req, boardList);
	
	// 정렬
	var sortby = req.query.sortby;
	// 정렬 배열
	var arSortby = null;
	
	if (sortby == undefined || typeof sortby == "undefined" || sortby == null) {
		arSortby = [];
	} else {
		arSortby = sortby.split(",");
	}
	
	if (arSortby.length > 0) {
		filteredBoardList.sort(function(comp1, comp2) {
			var result = 0;
			for (var index = 0; index < arSortby.length; index++) {
				if (hashmapSortby[arSortby[index]] != null) {
					result = hashmapSortby[arSortby[index]](comp1, comp2);
					if (result == 0) {
						continue;
					} else {
						break;
					}
				}
			}
			return result;
		});
	}
	
	// 페이지 크기
	var countPerPage = req.query.countperpage;
	// 페이지 번호
	var pageNo = req.query.pageno;
	// 페이지 사이즈
	var pageSize = req.query.pagesize;
	
	if (countPerPage == undefined || typeof countPerPage == "undefined" || countPerPage == null) {
		countPerPage = 10;
	} else {
		countPerPage = parseInt(countPerPage);
	}
	if (pageSize == undefined || typeof pageSize == "undefined" || pageSize == null) {
		pageSize = 10;
	} else {
		pageSize = parseInt(pageSize);
	}
	if (pageNo == undefined || typeof pageNo == "undefined" || pageNo == null) {
		pageNo = 0;
	} else {
		pageNo = parseInt(pageNo);
	}
	
	if (pageNo > 0) {
		// 전체 크기
		var totalCount = filteredBoardList.length;
		// 마지막 페이지 번호(전체 페이지 크기)
		var lastPageNo = Math.floor(totalCount / countPerPage) + (totalCount % countPerPage== 0 ? 0 : 1);
		// 시작 페이지 번호
		var startPageNo = 1;
		// 페이지 사이즈로 페이지 번호를 나눈 몫만큼 페이지 시작 번호 변경
		var start = Math.floor(pageNo / pageSize);
		if (start >= 1) {
			// 그렇지만 나머지가 없으면 현재 페이지 번호가 마지막 페이지 번호와 같아 감소
			if (pageNo % pageSize == 0){
				start--;
			}
			startPageNo = (start * pageSize) + 1;
		}
		// 종료 페이지 번호
		var endPageNo = (startPageNo - 1) + pageSize;
		// 그렇지만 종료 페이지 번호가 마지막 페이지 번호보다 크면 마지막 페이지 번호로 변경
		if (endPageNo > lastPageNo) {
			endPageNo = lastPageNo;
		}
		// 이전 페이지 번호 활성화 여부
		var enablePrevPageNo = true;
		if ((pageNo - 1) == 0) {
			enablePrevPageNo = false;
		}
		// 다음 페이지 번호 활성화 여부
		var enableNextPageNo = true;
		if ((pageNo + 1) >= lastPageNo) {
			enableNextPageNo = false;
		}
		// 이전 페이지 사이즈 번호
		var prevPageSizeNo = startPageNo - 1;
		// 이전 페이지 사이즈 번호 활성화 여부
		var enablePrevPageSizeNO = true;
		if (prevPageSizeNo == 0) {
			enablePrevPageSizeNO = false;
		}
		// 다음 페이지 사이즈 번호
		var nextPageSizeNo = endPageNo + 1;
		// 다음 페이지 사이즈 번호 활성화 여부
		var enableNextPageSizeNO = true;
		if (nextPageSizeNo > lastPageNo) {
			enableNextPageSizeNO = false;
		}
		// 시작 번호
		var startItemNo = ((pageNo - 1) * countPerPage);
		// 종료 번호
		var endItemNo = (pageNo * countPerPage) - 1;
		// 종료 번호가 전체 크기보다 크면 전체 크기로 변경
		if (endItemNo > (totalCount - 1)) {
			endItemNo = totalCount - 1;
		}
		var boardPageList = [];
		if (startItemNo < totalCount) {
			for (var index = startItemNo; index <= endItemNo; index++) {
				boardPageList.push(filteredBoardList[index]);
			}
		}
		// 페이지네이션 정보
		var paginationInfo = {};
		paginationInfo.totalCount = totalCount;
		paginationInfo.countPerPage = countPerPage;
		paginationInfo.pageSize = pageSize;
		paginationInfo.startPageNo = startPageNo;
		paginationInfo.endPageNo = endPageNo;
		paginationInfo.lastPageNo = lastPageNo;
		paginationInfo.pageNo = pageNo;
		paginationInfo.enablePrevPageNo = enablePrevPageNo;
		paginationInfo.enableNextPageNo = enableNextPageNo;
		paginationInfo.prevPageSizeNo = prevPageSizeNo;
		paginationInfo.enablePrevPageSizeNO = enablePrevPageSizeNO;
		paginationInfo.nextPageSizeNo = nextPageSizeNo;
		paginationInfo.enableNextPageSizeNO = enableNextPageSizeNO;
		res.json({success:true, data:boardPageList, pagination:paginationInfo});
	} else {
		res.json({success:true, data:filteredBoardList});
	}
});

 

3. 콘솔을 실행하고 Node.js 레스트 API 서버 프로젝트가 있는 C:\workspaces\nodeserver\testrestapi 폴더로 이동합니다. 그리고 npm run 명령어를 실행합니다.

npm run start

 

4. Postman(포스트맨)를 실행하여 테스트합니다.

 

반응형

Postman(포스트맨)를 이용하여 REST API Filter 테스트

작성자가 testid1인 게시물만 필터 되게 처리해보겠습니다. 그리고 이전 Sort에서 테스트한 내용도 함께 적용해서 테스트하겠습니다.

Get Method - filter 테스트

http://localhost:9000/boards?writer=eq:testid1&countperpage=3&pageno=1&sortby=writedate.desc

작성자가 testid1인 게시물 중 작성 날짜를 내림차순으로 정렬되는 것을 확인할 수 있습니다.

{
    "success": true,
    "data": [
        {
            "no": 13,
            "subject": "테스트 제목13",
            "content": "테스트 내용13",
            "writer": "testid1",
            "writedate": "2022-03-27 00:30:00"
        },
        {
            "no": 10,
            "subject": "테스트 제목10",
            "content": "테스트 내용10",
            "writer": "testid1",
            "writedate": "2022-03-26 21:00:00"
        },
        {
            "no": 7,
            "subject": "테스트 제목7",
            "content": "테스트 내용7",
            "writer": "testid1",
            "writedate": "2022-03-26 17:30:00"
        }
    ],
    "pagination": {
        "totalCount": 5,
        "countPerPage": 3,
        "pageSize": 10,
        "startPageNo": 1,
        "endPageNo": 2,
        "lastPageNo": 2,
        "pageNo": 1,
        "enablePrevPageNo": false,
        "enableNextPageNo": false,
        "prevPageSizeNo": 0,
        "enablePrevPageSizeNO": false,
        "nextPageSizeNo": 3,
        "enableNextPageSizeNO": false
    }
}

 

 

보안 이슈 - SQL Injection(인젝션)

REST API에서 필터 처리 시 데이터베이스의 필드 명과 동일하게 사용하시지 않는 것이 좋습니다. 그리고 SQL 쿼리문에 직접 전달된 파라미터를 사용하시면 안 됩니다. 반드시 조건문 처리하고 SQL 쿼리문안에서 조건에 따라 WHERE 절이 동작되도록 처리하시기 바랍니다.

향후 Database 연동 시 설명하도록 하겠습니다.

728x90
반응형