이전 포스트에서 jQuery Table Plugin 중에 DataTables을 소개해드렸습니다. 이번 포스트에서는 DataTables를 사용하는 제가 알고 있는 가장 Best Practics한 사용법을 설명드리도록 하겠습니다.
먼저 필요한 기능들을 나열해보도록 하겠습니다.
- 헤더를 클릭하여 열 정렬이 가능해야 한다.
- Paging이 가능해야 한다. (1, 2, 3, 4 숫자로 표시되는 Paging)
- 해당 Page의 보이는 Data만을 조회하여야 한다. (성능이 떨어지면 안된다.)
- CSS를 통해서 사용자 정의 스타일이 가능해야 한다.
가장 중요한 기능은 Page를 나타내는 테이블을 구현하려고 하다 보니 성능적인 문제가 제일 문제가 되는데 이러한 것은 DataTables의 Server-Side를 호출해주는 방법으로 해결할 수 있습니다. DataTables의 Example을 살펴보면 Server-Side코드들이 php로 작성된 코드들이라 ASP.NET MVC에 맞는 코드를 Forum에서 찾아보았습니다. 여기서 한가지 알아야 할 사항이 있는데 Client에서 Sever쪽 액션 메소드(MVC)를 호출하고 액션 메소드의 결과를 Json타입으로 직렬화하여 리턴해줄 때의 Data형태를 DataTables이 알 수 있는 형태로 리턴을 해주어야 합니다. (물론 일반적인 List<T>타입을 Json타입으로 Serialize해준 후에 Client측에서 DataTables이 원하는 Data형태로 변경해줄 수도 있습니다.)
1. http://weblogs.asp.net/zowens/archive/2010/01/19/jquery-datatables-plugin-meets-c.aspx
2. http://datatables.net/forums/comments.php?DiscussionID=932&page=1#Item_0
첫 번째 방법으로 먼저 시도를 해보았는데 사용하는 방법이 쉽지만 'Caveat'부분을 보면 버그가 있다는 경고 메세지가 있고 실제로 시도해보았을 경우에 단순히 하나의 테이블에서 string의 타입만 있을 경우에는 문제가 없었지만 여러 가지 타입이나 테이블 조인의 경우에 문제가 있어 아쉽게 사용하지 못하게 되었습니다.
두 번째 방법을 살짝 Custom하게 고쳐서 사용해보도록 하겠습니다.
ORM은 Entity Framework를 사용하였으면 DB모델은 아래 그림과 같으며 Script파일을 첨부하였습니다.
Client-Side
var logTable; $(function() { $("#logList tbody").click(function(event) { $(logTable.fnSettings().aoData).each(function() { $(this.nTr).removeClass('row_selected'); }); $(event.target.parentNode).addClass('row_selected'); }); logTable = $("#logList").dataTable({ "bAutoWidth": false, "iDisplayLength": 10, "bLengthChange": false, "bInfo": false, "sPaginationType": "full_numbers", "bProcessing": true, "bServerSide": true, "aaSorting": [[0, "asc"]], "aoColumns": [ { "bVisible": true, "sClass": "Center" }, null, null ], "sAjaxSource": '<%= Url.Action("GetAllChildren", "Home") %>' }); }); <table id="logList" class="display"> <thead> <tr> <th>P_ID</th> <th>P_Name</th> <th>C_COUNT</th> </tr> </thead> <tbody></tbody> </table> |
Server-Side Helper Class
public class GridParams { public int iDisplayStart { get; set; } public int iDisplayLength { get; set; } public string sSearch { get; set; } public bool bEscapeRegex { get; set; } public int iColumns { get; set; } public int iSortingCols { get; set; } public int iSortCol_0 { get; set; } public string sSortDir_0 { get; set; } public int sEcho { get; set; } public bool bSortable_0 { get; set; } public bool bSearchable_0 { get; set; } } public class FormatedList { public int sEcho { get; set; } public int iTotalRecords { get; set; } public int iTotalDisplayRecords { get; set; } public string[][] aaData { get; set; } } public static class QuerableExtensions { public static IQueryable<T> OrderBy<T>(this IQueryable<T> source, string propertyName, bool asc) { var type = typeof(T); string methodName = asc ? "OrderBy" : "OrderByDescending"; var property = type.GetProperty(propertyName); var parameter = Expression.Parameter(type, "p"); var propertyAccess = Expression.MakeMemberAccess(parameter, property); var orderByExp = Expression.Lambda(propertyAccess, parameter); MethodCallExpression resultExp = Expression.Call(typeof(Queryable), methodName, new Type[] { type, property.PropertyType }, source.Expression, Expression.Quote(orderByExp)); return source.Provider.CreateQuery<T>(resultExp); } } public static class GridsUtils { public delegate string[] GetStringDelegate<T>(T obj); public static string[][] GetStrings<T>(this IQueryable<T> source, GetStringDelegate<T> del) { string[][] retArray = new string[source.Count()][]; int i = 0; foreach (var s in source) { retArray.SetValue(del(s), i); i++; } return retArray; } } |
Server-Side 액션 메소드
public ActionResult GetAllParent(GridParams gridParams) { using (TestEntities Context = new TestEntities()) { //data var query = Context.Parent.Include("Children").AsQueryable(); int total = query.Count(); //search whatever property you like if (!string.IsNullOrEmpty(gridParams.sSearch)) { query = from x in query where x.P_Name.Contains(gridParams.sSearch) select x; } //sort if (gridParams.iSortingCols > 0) { var propertyName = "P_ID"; switch (gridParams.iSortCol_0) { case 0: propertyName = "P_ID"; break; case 1: propertyName = "P_Name"; break; } query = query.OrderBy(propertyName, (gridParams.sSortDir_0 == "asc")); } //display int totaldisp = query.Count(); if (gridParams.iDisplayLength > 0) query = query.Skip(gridParams.iDisplayStart).Take(gridParams.iDisplayLength); return Json(new FormatedList { sEcho = gridParams.sEcho, iTotalRecords = total, iTotalDisplayRecords = totaldisp, aaData = query.GetStrings(t => new string[] { t.P_ID.ToString(), t.P_Name, t.Children.Count.ToString() }) }); } } |
그런데 아마 Page, Sort에 대해서 궁금증이 생기실 수 있습니다. 과연 얼마나 효율적으로 데이터를 가져올까요? 전체 데이터를 가져와서 asc, desc로 정렬한 이후에 가져오는 걸까요? 정답은 row_number()라는 SQL함수에 있습니다. (이전에 row_number() 대한 포스트가 있는데 참고하시기 바랍니다.)
SQL Server Profiler로 질의된 쿼리들을 살펴보도록 하겠습니다.
SELECT [Project2].[P_ID] AS [P_ID], [Project2].[P_Name] AS [P_Name], [Project2].[C1] AS [C1], [Project2].[C3] AS [C2], [Project2].[C2] AS [C3], [Project2].[C_ID] AS [C_ID], [Project2].[C_Name] AS [C_Name], [Project2].[P_ID1] AS [P_ID1] FROM ( SELECT [Limit1].[P_ID] AS [P_ID], [Limit1].[P_Name] AS [P_Name], [Limit1].[C1] AS [C1], [Extent2].[C_ID] AS [C_ID], [Extent2].[C_Name] AS [C_Name], [Extent2].[P_ID] AS [P_ID1], CASE WHEN ([Extent2].[C_ID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C2], CASE WHEN ([Extent2].[C_ID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C3] FROM (SELECT TOP (10) [Project1].[P_ID] AS [P_ID], [Project1].[P_Name] AS [P_Name], [Project1].[C1] AS [C1] FROM ( SELECT [Project1].[P_ID] AS [P_ID], [Project1].[P_Name] AS [P_Name], [Project1].[C1] AS [C1], row_number() OVER (ORDER BY [Project1].[P_ID] ASC) AS [row_number] FROM ( SELECT [Extent1].[P_ID] AS [P_ID], [Extent1].[P_Name] AS [P_Name], 1 AS [C1] FROM [dbo].[Parent] AS [Extent1] ) AS [Project1] ) AS [Project1] WHERE [Project1].[row_number] > 0 ORDER BY [Project1].[P_ID] ASC ) AS [Limit1] LEFT OUTER JOIN [dbo].[Children] AS [Extent2] ON [Limit1].[P_ID] = [Extent2].[P_ID] ) AS [Project2] ORDER BY [Project2].[P_ID] ASC, [Project2].[C3] ASC |
예를 들어 일반적인 게시판을 생각해보시면 보여지는 글의 건수를 10건이라고 하고 첫 번째 페이지를 보여준다면 쿼리는 TOP(10), row_number > 0이라는 조건절이 만들어져 질의됩니다. 만약에 세 번째 페이지를 보여준다면 TOP(10), row_number > 20이 조건절로 되는 것입니다. 마찬가지로 Order By는 해당 열을 처음 클릭하면 asc 두 번째로 클릭하면 desc으로 조건절이 만들어져 질의되는 것입니다. 실질적으로 row_number()가 어떻게 만들어져 있는지는 알 수 없지만 최소한 화면에 보이는 Data를 빠르게 가져온다는 사실만은 분명합니다. 또한 P_ID가 아닌 P_Name으로 정렬을 해보면 속도가 P_ID보다 느린 것을 알 수 있는데 이것은 row_number()가 인덱스와 상관이 있다는 것을 간접적으로 알 수 있습니다.