CLIENT SIDE RENDERING

using angular.js

ITWISE Consulting

Engineering center

선임 엔지니어 김태희

과거의 WEB APP

  • 그다지 동적이지 않은 화면
    • form 전송을 통한 화면 이동 기반
    • 화면에 rendering 해야하는 데이터가 바뀌면 새로고침으로 처리
  • 그다지 많지 않은 view logic들
    • 초기 rendering 이후 화면이 동적으로 바뀌는 부분이 그다지 없이 때문에 jstl 등으로도 충분히 처리 가능

그러나 요즘 WEB APP들은...

  • 굉장히 동적인 화면
    • ajax의 등장
    • 점점 화려해지는 UI
    • 늘어나는 사용자 인터랙션
    • 수많은 이벤트 처리와 그에 따른 렌더링 처리
    • 나타내고...숨기고...띄우고...없애고... 등등
  • 화면에서 처리할 것이 많아짐
    • ​수많은 view logic들
    • 이걸 SERVER SIDE RENDERING으로 처리가 되나?
    • 처리가 되도 그걸로 처리하는 것이 맞나?

대표적으로..

혼돈의 chaos....

  • Server Side에선 처리할 수 없는 것들이 많아짐
    •  대표적으로 ajax 처리
    • Client Side Rendering 지점이 늘어남
  • Server Side와 혼재
    • 초기 데이터 렌더링은 Server Side에서 하고.. 이후 추가 데이터 렌더링은 Client Side에서 하고...
/* 
 * 어디는 이렇게 Server Side로 제목을 렌더링
 * ex) EL Exp
 */
<h3>${title}</h3>



/*
 * 어디는 이렇게 처리하고...
 * ex) scripting
 */ 
<h3 class="title"></h3>

// scripting
$('.title').text('title text');

혼돈의 CHAOS....

<c:forEach items="${articles}" var="article">
  <article>
    <div class="article-header">${article.header}</div>
    <div class="article-content">${article.content}</div>
    <!-- 엘리먼트 노출 여부를 Server Side Rendering에서 결정 -->
    <c:if test="${article.commentCount > 0}">
      <button class="comment-load-button">댓글 불러오기</button>
    </c:if>
    <!-- ajax 로딩 후 아래 영역에 html append -->
    <div class="article-comments"></div>
  </article>
</c:forEach>


  • article 목록은 Server Side에서 처리
  • 댓글 불러오기 버튼 유무도 Server Side에서 처리
  • 실제 댓글 불러오는 부분은 Client Side에서 처리

그냥 rendering을 Client에 맡기는 건 어떨까?

  • 극적으로 향상된 브라우저들의 성능
  • RESTFUL API Service의 발전

일단 만들어봅시다.

  • '추가' 버튼 누르면 할 일이 추가

  • 해야 할 일의 해당 항목을 클릭하면 완료된 일로 이동

  • 해야 할 일의 삭제 버튼을 누르면 해당 일 삭제

  • 할 일 갯수의 변화가 있을 때 마다 맨 아래 할 일 갯수 영역이 갱신되어야 한다.

  • 현재는 localStorage 사용, 추후 RESTful API 연동 처리

HTML MARKUP

<!DOCTYPE html>
<html>
  <head>
    <title>Sample Todo App</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="container">
      <header class="todo-header">
        <h3>Sample Todo App</h3>
      </header>
      <section class="panel">
        <h3>할 일 추가하기</h3>
        <form class="new-todo">
          <input type="text" id="todo-title" placeholder="할 일을 입력하세요!"> 
          <button class="btn btn-add pull-right">추가</button>
        </form>
      </section>
      <section class="panel">
        <h3>해야 할 일</h3>
        <ul class="todos-inprogress">
          <!-- 반복되는 부분 -->
          <li class="panel todo">
            <label><input type="checkbox">http 공부하기</label> 
            <button class="btn btn-remove pull-right">삭제</button>
          </li>
          <li class="panel todo">
            <label><input type="checkbox">ionic 정리</label> 
            <button class="btn btn-remove pull-right">삭제</button>
          </li>
        </ul>
      </section>
      <section class="panel">
        <h3>완료한 일<button class="btn btn-default inline pull-right todo-complete-clear-button">비우기</button></h3>
        <ul class="todos todos-completed">
          <!-- 반복되는 부분 -->
          <li class="panel todo">발표자료 준비</li>
        </ul>
      </section>
      <aside class="panel todo-info">
        <strong class="todo-total-count">3</strong>개 중 <strong class="todo-complete-count">2</strong>개를 완료했습니다.
      </aside>
    </div>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>

javascript code

'use strict';
(function($){
  var todos = [];
  
  // localStorage에서 저장된 todos 로딩
  if(window.localStorage && window.localStorage.todos !== undefined){
    todos = JSON.parse(window.localStorage.todos);
    console.log(localStorage.todos);
    console.log(todos.length)
  }
  
  function sync(){
    window.localStorage.todos = JSON.stringify(todos);  
    console.log(localStorage.todos);
  }
  
  function updateTodoInfo(){
    var todoCompleteCount = 0;
    for(var i = 0; i < todos.length; i++){
      if(todos[i].isComplete){
        todoCompleteCount = todoCompleteCount + 1;
      }
    }
    
    // dom 접근해서 값 변경
    $('.todo-complete-count').text(todoCompleteCount);
    $('.todo-total-count').text(todos.length);
  }
  
  $(document).ready(function(){
    // todos render
    for(var i = 0; i < todos.length; i++){
      if(todos[i].isComplete){
        $('.todos-completed').append(
        '<li class="panel todo" id="todo-' + todos[i].id + '">' +
          todos[i].title + 
        '</li>'
        );
      }else{
        $('.todos-inprogress').append(
          '<li class="panel todo" id="todo-' + todos[i].id + '">' +
            '<label>' +
              '<input type="checkbox" class="todo-complete">' + todos[i].title + 
            '</label>' +
            '<button class="btn btn-remove pull-right">삭제</button>' +
          '</li>'
          );  
      } 
    }
    updateTodoInfo();
    
    // add event binding
    $('.new-todo').on('submit', function(event){
      var $todoTitle = $('#todo-title');
      var todoTitle = $todoTitle.val();
      var todo = {
        title: todoTitle, 
        id: new Date().getTime(),
        isComplete: false
      };
      $('.todos-inprogress').append(
        '<li class="panel todo" id="todo-' + todo.id + '">' +
          '<label>' +
            '<input type="checkbox" class="todo-complete">' + todo.title + 
          '</label>' +
          '<button class="btn btn-remove pull-right">삭제</button>' +
        '</li>'  
      );
      $todoTitle.val('');
      
      todos.push(todo);
      sync();
      updateTodoInfo();
      
      event.preventDefault();
    });
    
    // todo complete event
    $('.todos-inprogress').on('click', '.todo-complete', function(){
      var $completeTodo = $(this).parents('.todo');
      var todoId = $completeTodo.attr('id').replace('todo-', '');
      var todo, i;
      
      // 완료 처리
      for(i = 0; i < todos.length; i++){
        todo = todos[i];
        if(todo.id === parseInt(todoId)){
          todo.isComplete = true;
          break;
        }
      }
      
      $completeTodo.find('input[type=checkbox]').remove();
      $completeTodo.find('.btn-remove').remove();
      $('.todos-completed').append($completeTodo);
      
      sync();
      updateTodoInfo();
    });
    
    // remove event binding
    $('.todos-inprogress').on('click', '.btn-remove', function(){
      var $deleteTodo = $(this).parents('.todo');
      var todoId = $deleteTodo.attr('id').replace('todo-', '');
      $(this).parents('.todo').remove();
      
      for(i = 0; i < todos.length; i++){
        todo = todos[i];
        if(todo.id === parseInt(todoId)){
          todos.splice(i, 1);
          break;
        }
      }
      
      sync();
      updateTodoInfo();
    });
   
    $('.todo').on('click', '.btn-remove', function(){
      var $removeTargetTodo = $(this).parents('.todo');
      todos.splice($removeTargetTodo.index(), 1);
      $removeTargetTodo.remove();
      
      sync();
      updateTodoInfo();      
    });
    // completed remove all event
    $('.todo-complete-clear-button').on('click', function(){
      var inprogressTodos = [], i;
      for(i = 0; i < todos.length; i++){
        if(!todos[i].isComplete){
          inprogressTodos.push(todos[i]);
        }
      }
      
      todos = inprogressTodos;
      
      // html 비우기
      $('.todos-completed').html('');
      
      sync();
      updateTodoInfo();
    });
  });
})(jQuery);

example 1

  • JAVASCRIPT에 HTML markup이 하드코딩 되어있음
    • '와 " 처리
    • markup을 고쳐야 하면 JAVASCRIPT 코드를 고쳐야 함
  • 화면 조작에 따라 매번 DOM을 세세하게 조작해야 함

example 1의 문제점

하드코딩 된 markup

public class HelloWorldExample extends HttpServlet {
   @Override
   public void doGet(HttpServletRequest request, HttpServletResponse response)
               throws IOException, ServletException {
      // Set the response message's MIME type.
      response.setContentType("text/html;charset=UTF-8");
      // Allocate a output writer to write the response message into the network socket.
      PrintWriter out = response.getWriter();
 
      // Write the response message, in an HTML document.
      try {
         out.println("<!DOCTYPE html>");  // HTML 5
         out.println("<html><head>");
         out.println("<meta http-equiv='Content-Type' content='text/html; charset=UTF-8'>");
         String title = "helloworld.title";
         out.println("<title>" + title + "</title></head>");
         out.println("<body>");
         out.println("<h1>" + title + "</h1>");  // Prints "Hello, world!"

         out.println("<a href='" + request.getRequestURI() + "'><img src='images/return.gif'></a>");
         out.println("</body></html>");
      } finally {
         out.close();  // Always close the output writer
      }
   }
}

하드코딩 된

markup을 분리하자!

markup custom TEMPLATE

<!-- templates -->
<!-- todo complete template -->
<script type="text/x-custom-template" id="todo-complete">
  <li class="panel todo" id="todo-{id}">
      ✓ {title}
  </li>
</script>

<!-- todo inprogress template -->
<script type="text/x-custom-template" id="todo-inprogress">
  <li class="panel todo" id="todo-{id}">
    <label>
      <input type="checkbox" class="todo-complete">{title}
    </label>
    <button class="btn btn-remove pull-right">삭제</button>
  </li>
</script>

<!-- todo info template -->
<script type="text/x-custom-template" id="todo-info">
  <strong class="todo-total-count">{totalCount}</strong>개 중 
  <strong class="todo-complete-count">{completeCount}</strong>개를 완료했습니다.
</script>

template util

var templateUtil = {
  mapper: {},
  init: function(){
    // script element 중 type이 text/x-custom-template인 것을 template으로 등록
    var that = this;
    $('script').each(function(){
      if($(this).attr('type') === 'text/x-custom-template'){
        var id = $(this).attr('id');
        that.mapper[id] = $(this).text();
      }
    });
  },
  render: function(id, data){
    // 정규표현식을 이용해 {key} 형태의 string을 value로 치환
    var template = this.mapper[id];
    
    if(template !== undefined){
      for(var key in data){
        var binding = new RegExp('{' + key +'}', 'g');
        template = template.replace(binding, data[key]);
      }  
      return template;
    }else{
      throw new Error(id + ' template은 존재하지 않습니다.');
    }
  }
};

tempalte util 적용

render 구간 분리

if(window.localStorage && window.localStorage.todos !== undefined){
  todos = JSON.parse(window.localStorage.todos);
}

function sync(){
  window.localStorage.todos = JSON.stringify(todos);  
}
  
function render(){
  var i;
  var todoCompleteCount = 0;
  
  // init html
  $('.todos-completed').html('');
  $('.todos-inprogress').html('');
    
  // todos render
  for(i = 0; i < todos.length; i++){
    if(todos[i].isComplete){
      todoCompleteCount = todoCompleteCount + 1;
      $('.todos-completed').append(
        templateUtil.render('todo-complete', todos[i])
      );
    }else{
      $('.todos-inprogress').append(
        templateUtil.render('todo-inprogress', todos[i])
      );  
    } 
  }
    
  $('.todo-info').html(templateUtil.render('todo-info', {
    totalCount: todos.length,
    completeCount: todoCompleteCount
  }));
}
  
function syncAndRender(){
  sync();
  render();
}
  
$(document).ready(function(){
  // template parsing
  templateUtil.init();
    
  render();
    
  // add event binding
  $('.new-todo').on('submit', function(event){
    var $todoTitle = $('#todo-title');
    var todoTitle = $todoTitle.val();
    var todo = {
      title: todoTitle, 
      id: new Date().getTime(),
      isComplete: false
    };
      
    $todoTitle.val('');
      
    todos.push(todo);
    
    syncAndRender();

    event.preventDefault();
  });
    
  // todo complete event
  $('.todos-inprogress').on('click', '.todo-complete', function(){
    var $completeTodo = $(this).parents('.todo');
    var todoId = $completeTodo.attr('id').replace('todo-', '');
    var todo, i;
      
    // 완료 처리
    for(i = 0; i < todos.length; i++){
      todo = todos[i];
      if(todo.id === parseInt(todoId)){
        todo.isComplete = true;
        break;
      }
    }
      
    syncAndRender();
  });
    
  // remove event binding
  $('.todos-inprogress').on('click', '.btn-remove', function(){
    var $deleteTodo = $(this).parents('.todo');
    var todoId = $deleteTodo.attr('id').replace('todo-', '');
    
    var todo, i;
    for(i = 0; i < todos.length; i++){
      todo = todos[i];
      if(todo.id === parseInt(todoId)){
        todos.splice(i, 1);
        break;
      }
    }
      
    syncAndRender();
  });
});
  • js 구간에서 문자열을 조합하고 더했던 부분이 사라짐
  • 데이터 기반으로 화면을 렌더링 하도록 변경
  • 렌더링 구간을 한 곳에 집중함으로써 역할 분리
  • 이벤트 처리 구간과 렌더링 구간을 분리
    • 이벤트 처리하는 구간에서 DOM을 직접 조작하여 렌더링하는 부분을 제거

무엇이 달라졌나?

example 2

view tempalte library

  • JSON Data 기반 화면 바인딩
  • VIEW LOGIC 처리를 위한 제어문, 반복문 등 제공
  • markup을 잘게 쪼개서 재사용 가능

...and many more...

아직 남아있는 문제

  • data의 변동이 있을 때마다 매번 rendering을 직접 해야한다.
    • todos의 실제 데이터와 화면 상 rendering 결과가 다를 수 있음
  • 화면 조작을 통해 todos가 바뀌는 경우도 매번 추적해서 변경해줘야 한다.
  • 화면이 복잡해질수록 이벤트 처리
    및 렌더링 처리가 복잡해짐
var todos = JSON.parse(window.localStorage.todos);
var localStorage = window.localStorage;

// 데이터를 추가해도
todos.push(todo);
localStorage.todos = JSON.stringify(todos);

// 데이터를 삭제해도
todos.splice(todos, 1);
localStorage.todos = JSON.stringify(todos);

// 데이터를 수정해도
todos[3].title = '변경';

// 새로고침하면 수정한 데이터 날라감!

그래서 나온 MV* Framework

...and many more...

angular.js

  • two-way-data binding
    • rendering 된 json value가 변경되면 화면에도 자동반영
    • 화면에 바인딩 된 값이 바뀌면 실제 바인딩 된 json 값도 같이 바뀜
  • directive
    • element, attribute, class 등의 지시어를 이용해 custom element를 만들 수 있음
  • dependency injection
    • test code 작성이 용이

Two-Way Data Biniding

Two-Way Data Biniding

Two-Way Data Biniding

angular.js ARCHITECTURE

angular template

<!DOCTYPE html>
<html data-ng-app="todoApp">
  <head>
    <title>Sample Todo App</title>
    <link rel="stylesheet" href="style.css" />
  </head>

  <body>
    <div class="container" data-ng-controller="todoController" data-ng-cloak>
      <header class="todo-header">
        <h3>Sample Todo App</h3>
      </header>
      <div class="panel">
        <h3>할 일 추가하기</h3>
        <form data-ng-submit="add($event)">
          <input type="text" id="todo-title" placeholder="할 일을 입력하세요!" 
            data-ng-model="todoTitle"/>
          <button class="btn btn-add pull-right">추가</button>
        </form>
      </div>
      <div class="panel">
        <h3>해야 할 일</h3>
        <ul class="todos todos-inprogress">
          <li class="panel todo" 
            data-ng-repeat="todo in todos"
            data-ng-if="!todo.isComplete">
            <label>
              <input type="checkbox" data-ng-click="complete(todo)">{{todo.title}}
            </label>  
            <button class="btn btn-remove pull-right" 
              data-ng-click="remove($index)">삭제</button>
          </li>
        </ul>
      </div>
      <div class="panel">
        <h3>완료한 일<button class="btn" data-ng-click="removeCompleteAll()">비우기</button></h3>
        <ul class="todos todos-completed">
          <li class="panel todo"
            data-ng-repeat="todo in todos"
            data-ng-if="todo.isComplete">
              {{todo.title}}
            </li>
        </ul>
      </div>
      <div class="panel todo-info">
        <strong class="todo-total-count">{{todoTotalCount}}</strong>개 중 
        <strong class="todo-complete-count">{{todoCompleteCount}}</strong>개를 완료했습니다.
      </div>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.7/angular.js"></script>
    <script src="script.js"></script>
  </body>
</html>

angular js code

'use strict';

var todoApp = angular.module('todoApp', []);

todoApp.controller('todoController', function($scope){
  $scope.todos = [];
  
  if(window.localStorage && window.localStorage.todos !== undefined){
    $scope.todos = JSON.parse(window.localStorage.todos);    
  }
  
  $scope.todoTotalCount = $scope.todos.length;
  $scope.todoCompleteCount = 0;
  
  // todos 데이터 변경을 감지하여 총 갯수 알아내고 localStorage에 갱신시키기
  $scope.$watch('todos', function(){
    $scope.todoTotalCount = $scope.todos.length;
    var todoCompleteCount = 0;
    var i;
    for(i = 0; i < $scope.todos.length; i++){
      if($scope.todos[i].isComplete){
        todoCompleteCount = todoCompleteCount + 1;
      }
    }  
    $scope.todoCompleteCount = todoCompleteCount;
    
    window.localStorage.todos = angular.toJson($scope.todos);
  }, true);
  
  $scope.todoTitle = '';
  
  // todo functions
  $scope.add = function(){
    $scope.todos.push({
      id: new Date().getTime(),
      title: $scope.todoTitle
    });
    
    $scope.todoTitle = '';
  };
  
  $scope.complete = function(todo){
    todo.isComplete = true;
  };
  
  $scope.remove = function($index){
    $scope.todos.splice($index, 1);
  };

  $scope.removeCompleteAll = function(){
    var inprogressTodos = [];
    for(var i = 0; i < $scope.todos.length; i++){
      if(!$scope.todos[i].isComplete){
        inprogressTodos.push($scope.todos[i]);
      }
    }
    $scope.todos = inprogressTodos;
  };
});
  

angular js code

with service & dependency injection

'use strict';

var todoApp = angular.module('todoApp', []);

// todoStore service 정의
todoApp.service('todoStore', function(){
  return {
    save: function(todos){
      window.localStorage.todos = JSON.stringify(todos);                
    },    
    findAll: function(){
      if(window.localStorage && window.localStorage.todos !== undefined){
        return JSON.parse(window.localStorage.todos);    
      }else{
        return [];
      }    
    }
  };
});

// 위에서 정의한 todoStore를 주입받는다.
// 이걸로 직접 localStorage에 접근하는 코드를 제거
todoApp.controller('todoController', function($scope, todoStore){
  $scope.todos = todoStore.findAll();
  
  $scope.todoTotalCount = $scope.todos.length;
  $scope.todoCompleteCount = 0;
  
  // todos 데이터 변경을 감지하여 총 갯수 알아내고 localStorage에 갱신시키기
  $scope.$watch('todos', function(){
    $scope.todoTotalCount = $scope.todos.length;
    var todoCompleteCount = 0;
    var i;
    for(i = 0; i < $scope.todos.length; i++){
      if($scope.todos[i].isComplete){
        todoCompleteCount = todoCompleteCount + 1;
      }
    }  
    $scope.todoCompleteCount = todoCompleteCount;
    
    todoStore.save(angular.toJson($scope.todos));
  }, true);
  
  $scope.todoTitle = '';
  
  // todo functions
  $scope.add = function(){
    $scope.todos.push({
      id: new Date().getTime(),
      title: $scope.todoTitle
    });
    
    $scope.todoTitle = '';
  };
  
  $scope.complete = function(todo){
    todo.isComplete = true;
  };
  
  $scope.remove = function($index){
    $scope.todos.splice($index, 1);
  };

  $scope.removeCompleteAll = function(){
    var inprogressTodos = [];
    for(var i = 0; i < $scope.todos.length; i++){
      if(!$scope.todos[i].isComplete){
        inprogressTodos.push($scope.todos[i]);
      }
    }
    $scope.todos = inprogressTodos;
  };
});

  

무엇이 좋아졌나?

  • 데이터가 바뀌어도 화면이 자동 Rendering
    • 데이터와 화면의 일치에 대해 개발자가 신경 써야 하는 부분이 대폭 감소
    • 개발 속도가 엄청 빨라짐
  • 화면 조작에 따른 데이터 변동시키기가 쉬워짐
    • UI 처리가 간단해짐
  • DOM을 직접 핸들링 하는 부분이 사라짐

염두해야하는 점

  • javascript에 대한 깊은 이해 필요
    • functional scope, closure, modularization

Q&A

감사합니다!