[Node.js, Javascript] Todo App 개발 – (1) SPA

javascript spa todoapp

안녕하세요! 이번에는 Node.js와 Javascript를 이용하여 매우 간단한 Todo APP을 만들어볼 것입니다. 이어지는 포스트도 있으니 참고해주세요!

Todo app이란, 일종의 task list를 말합니다. 말 그대로 해야 되는 일을 관리하는 프로그램입니다. javascript를 이용하여 간단하게 UI 제작을 하고, node.js를 이용하여 api를 만들 것입니다.

  1. SPA(Single Page Application)
  2. HTML 구성
  3. 수도코드 작성
  4. 구현

들어가기 이전에, 어떤 결과물을 만드는지 확인해볼 필요가 있습니다. 그렇지 않으면 너무 추상적이거든요!

실행되는 방식은 이렇습니다.

  1. 폴더 이름 입력 후 추가
  2. 폴더 선택시 태스크 목록이 보임
  3. 폴더에 태스크 추가
  4. 태스크 선택시 활성화, 다시 선택시 비활성화

별거 없죠? 하지만 아직 개발 초보가 만들기엔 생각보다 많이 힘듭니다 ^^..


1. SPA(Single Page Application)

프로그램을 제작하기 이전에, SPA에 대해 알아보도록 하겠습니다.
SPA(Single Page Application)는 서버로부터 완전한 새로운 페이지를 불러오지 않고 현재의 페이지를 동적으로 다시 작성함으로써 사용자와 소통하는 웹 애플리케이션이나 웹사이트를 말합니다.

대표적으로 facebook, github, twitter, instagram 등 굉장히 많은 웹 사이트가 SPA로 구성되어있습니다. SPA 특징은 다음과 같습니다.

  1. Client Side Rendering
  2. 불필요한 트래픽 방지
  3. Native App과 유사한 성능 확보
  4. Restful API와 연동하여 사용
  5. SEO 불가능

그리고 흔히 들어본 vue.js, react.js, angular.js 등의 프레임워크(혹은 라이브러리)가 이러한 spa를 제작할 때 사용합니다. 하지만, javascript에 대한 이해가 부족한 상태에서 front-end framework를 이용하여 개발한다면, 프레임워크의 기능을 적극 이용할 수가 없습니다. 따라서, 먼저 native로 적용해본 다음, framework를 사용하여 적용해보고 어떤 차이점이 있는지 살펴보는 순서로 진행할 생각입니다.


2. HTML 구성

javascript를 작성하기 이전에, html을 먼저 작성해야 합니다. 그런데, SPA에서는 모든 html을 javascript로 다루기 때문에 최소한의 html만 작성해보도록 하겠습니다.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Todoapp</title>
</head>
<body>
  <div id="app">
    <section class="folder">
      <h2>Folder List</h2>
      <input type="text" class="folder_name folder-input" size="20" placeholder="폴더 이름을 입력해주세요">
      <div class="folder-list"></div>
      <section class="task-list"></section>
    </div>
  </div>
  <script>
  <!-- 이 부분에 script를 작성할 것입니다. -->
  </script>
</body>
</html>

보여지듯이, 거의 작성한 태그가 없습니다. 처음에 보여지게 될 태그만 구성한 것입니다.


3. 수도코드 작성

직접적으로 코드를 구현하기 이전에 수도코드를 작성해봅시다.

  1. 폴더 input에 내용을 입력 후 추가하면 folder data가 업데이트 된다.
  2. 업데이트 된 folder data를 기반으로 folder list를 렌더링 한다.
  3. folder list를 클릭하면 해당 folder의 제목, task input, task list, 닫기버튼이 나온다.
  4. task input에 내용을 입력 후 추가하면 task data가 업데이트 된다.
  5. 업데이트 된 task data를 기반으로 task list를 렌더링 한다.
  6. task list에서 닫기버튼을 누르면, task list가 사라진다

뭔가 장황한게 나열한 것 같지만, 다시 요약해보자면 입력 -> 추가 -> 데이터 업데이트 -> 렌더링이 2depth로 반복 하는 것입니다.

이전 포스트에서도 언급했지만, 코드를 분석할 때 중요한 것은 도메인 모델네이티브 모델을 구분하는 것입니다.

도메인 모델은 모든 언어에서 공통적으로 수행할 수 있는 "순수한 알고리즘" 이고,
네이티브 모델은 런타임 환경, 플랫폼, 운영체제 등에 따라 달라지는 것을 말합니다.

여기서 도메인 모델은 데이터가 업데이트 되는 과정입니다. 그리고 네이티브 모델은 각 태그에 대한 이벤트 추가HTML 렌더링 등이 있습니다.

그리고 도메인 모델과 네이티브 모델이 명확하게 구분 될 수록 코드는 간결해지고 쉬워집니다.


4. 구현

이제 차근차근 구현을 해보겠습니다.

1) DOM 함수와 TAG 지정

// DOM function
const one = ele => document.querySelector(ele)  // 하나의 element 선택
const all = ele => document.querySelectorAll(ele) // 여러개의 element 선택
const create = (name, attr) => {
  // element 생성
  const ele = document.createElement(name)
  for(const k in attr) {
    const v = attr[k] // k는 속성 이름, v는 값
    switch (k) {
      case 'html' : ele.innerHTML = v; break // 내용 추가
      case 'event' : // 이벤트 추가
        for (const e in v) ele.addEventListener(e, v[e])
      break;
      default : ele.setAttribute(k, v); break // 나머지는 그냥 속성 추가
    }
  }
  return ele
}

// 미리 Tag 선택
const folderAddBtn = one('.folder-add')
const folderInput = one('.folder-input')
const folderList = one('.folder-list')
const taskList = one('.task-list')

Javascript에서 가장 성능을 잡아먹는 일이 어떤걸까요? 바로 렌더링입니다. 즉, DOM과 관련된 기능들이 Javascript의 성능에 큰 비중을 차지하고 있습니다.

그래서, 이것을 줄이고자 미리 미리 만들어진 태그들을 선택하여 재사용할 수 있도록 해야합니다. 똑같은 DOM을 계속해서 선택하는 행위는 굉장히 큰 낭비라는 것을 항상 머릿속에 새겨놓읍시다.


2) Data 관리

// test를 위해 초기 값을 채워놓습니다.
let folderData = [
  {
    name: 'test1', // folder의 이름
    child: [ // folder의 task 목록
      {
         name: 'test1-1', // task의 이름
         state: true // task의 상태
      },
      {
         name: 'test1-2',
         state: true
      }
    ]
  },
  {
    name: 'test2',
    child: [
      {
         name: 'test2-1',
         state: false
      },
      {
         name: 'test2-2',
         state: false
      }
    ]
  }
]

사실, 이번 포스트의 목적은 렌더링에 있습니다. Data 관리에는 큰 비중을 두지 않을 것입니다. 그래서 folderData 라는 변수를 통해 모든 데이터를 관리 할 것입니다.


3) 폴더 렌더 코드 작성

const folderRender = () => {
  folderList.innerHTML = '' // 폴더 목록 초기화
  const ul = create('ul') // ul 태그 생성
  folderData.forEach(v => {
    ul.appendChild(create('li', {html: v.name, event: {click: taskRender(v)}})) // li 태그를 생성하여 ul 태그에 삽입
  })
  folderList.appendChild(ul) // ul 태그 삽입
}

// 사이트 로드 시 폴더를 렌더링 합니다.
window.onload = () => {
  folderRender()
}

여기서 핵심이 되는 부분은 바로 taskRender 입니다. 저 부분이 작동 되는 원리를 보면 이렇습니다.

const v = {} // 특정 데이터
const taskRender = function (data) {
  return function (e) {
    /* render 코드 실행 */
  }
}
li.onclick = taskRender(v)
// taskRender를 바로 실행하여 v라는 데이터를 넘기고
// click event가 발생하면, taskRender에서 반환한 function이 event를 받아 실행한다. 즉, closure 형태로 사용 되고 있음

이게 왜 중요하냐면, 바로 closure가 사용되었기 때문입니다. javascript에서 event가 발생될 때 넘길 수 있는 인자는 event 하나 뿐입니다. 하지만 또 다른 인자를 넘기기 위해서 closure를 사용하였고, 실제로 수행될 때는 closure 에서 반환 한 함수가 실행됩니다.

자세한 내용은 hoisting, scope, closure포스트를 참고합니다.


4) 폴더 추가 코드 작성

// 폴더 input을 입력 시, 폴더를 추가하고 렌더링 합니다.
const addFolder = e => {
  if (e.keyCode === 13) { // keyCode 13은 enter를 의미합니다.
    // 폴더에 추가될 정보는, name과 child task 입니다.
    folderData.push({name: e.target.value, child: []})
    e.target.value = '' // input 초기 후
    e.target.focus      // focusing 합니다.
    folderRender()      // 그리고 렌더링
  }
}

window.onload = () => {
  folderInput.onkeyup = addFolder // 이벤트 추가
  folderRender()
}

input에서 키보드의 enter를 누르면 폴더가 추가되는 event code를 작성하였습니다. 그리고 사이트가 로드 되는 시점에, 해당 event가 input에 등록됩니다.

event가 등록되는 시점이 항상 중요합니다. javascript를 이용하여 element(tag)를 추가할 경우, 추가 되고 나서 event가 등록되어야 합니다. 아직 element가 존재하지도 않는데 event를 추가하려고 하면 당연히 추가 되지 않겠죠? 그래서 window.onload 시점에 event를 추가하는 것입니다.


5) task render

const taskRender = data => e => {
  const title = create('h3', {html: data.name}) // 제목 생성
  const ul = create('ul') // 목록이 들어가게 될 ul 생성
  // input 생성
  const input = create('input', {class: 'task-input', size: 20, placeholder: 'task 입력',
    event: {
      keyup: inputEvent => { // keyup event를 등록함.
        if (inputEvent.keyCode === 13) {
          // data를 추가한 다음
          data.child.push({name: inputEvent.target.value, state: false})
          // e.target은 folder의 li를 의미함
          // 즉, 다시 folder의 li를 클릭하여 taskRender를 실행함
          // 아래의 코드는 다음과 같이 바꿔서 사용할 수 있음
          // taskRender(data)(e)
          e.target.click() 
          inputEvent.target.focus() 
        }
      }
    }
  })
  const close = create('button', {type: 'button', html: '닫기', event: {click: e => taskList.innerHTML = ''}})
  data.child.forEach(ele => ul.appendChild(taskChildRender(ele)))
  taskList.innerHTML = ''
  for(const ele of [title, input, ul, close]) taskList.appendChild(ele)
}

위에서 언급했듯이, taskRender는 closure입니다. data라는 매개변수를 받아오기 위해서 closure 구조로 작성했습니다. 또 핵심이 되는 부분은 e.target.click()인데, e.target의 click event를 실행하는 것입니다. 이렇게 특정 함수가 event에 등록되어 있으면, 해당 event를 호출하는 방식으로 실행할 수 있습니다.

코드가 번잡해질 것 같아서 taskChildRender를 별도로 구성했습니다.


6) taskChildRender

// task ul의 li를 구성합니다.
const taskChildRender = v => create('li', {
  html: v.name,
  style: v.state ? 'color:#09F' : '',
  event: {
    click: e => {
      // li 클릭 시 활성화됩니다. 색의 변경을 통해 표현합니다.
      e.target.style.color = (v.state = !v.state) ? '#09F' : ''
    }
  }
})

여기서 알아야 되는 것은, v라는 매개 변수를 return 하지 않는 다는 것입니다. v는 folder의 자식 task 중 하나입니다. 여기서 v를 수정하면 folder에도 저절로 반영됩니다. 이게 바로 call by reference를 이용하는 방법입니다.

자세한 내용은 해당 포스트를 참고해주세요
[javascript] Object의 참조 할당(call by reference)


7) 완성본

이제 모든 코드가 완성되었습니다. 한 번 살펴보도록 하겠습니다.

// DOM function
const one = ele => document.querySelector(ele)
const all = ele => document.querySelectorAll(ele)
// create가 없었다면.. 굉장히 더러운 코드가 되었을 것입니다.
const create = (name, attr) => {
  const ele = document.createElement(name)
  for(const k in attr) {
    const v = attr[k]
    switch (k) {
      case 'html' : ele.innerHTML = v; break
      case 'event' :
        for (const e in v) ele.addEventListener(e, v[e])
      break;
      default : ele.setAttribute(k, v); break
    }
  }
  return ele
}

// variable
const folderAddBtn = one('.folder-add')
const folderInput = one('.folder-input')
const folderList = one('.folder-list')
const taskList = one('.task-list')
let folderDataList = [/* 생략. 사실 초기 데이터는 별로 중요하지 않아요.*/]

const addFolder = e => {
  if (e.keyCode === 13) {
    const newFolder = 
    folderDataList.push({ name: e.target.value, child: []})
    e.target.value = ''
    e.target.focus
    folderRender()
  }
}

const folderRender = () => {
  folderList.innerHTML = ''
  const ul = create('ul')
  folderDataList.forEach(v => {
    ul.appendChild(create('li', {html: v.name, event: {click: taskRender(v)}}))
  })
  folderList.appendChild(ul)
}

const taskRender = data => e => {
  const title = create('h3', {html: data.name})
  const ul = create('ul')
  const input = create('input', {class: 'task-input', size: 20, placeholder: 'task 입력',
    event: {
      keyup: inputEvent => {
        if (inputEvent.keyCode === 13) {
          data.child.push({name: inputEvent.target.value, state: false})
          e.target.click()
          inputEvent.target.focus()
        }
      }
    }
  })
  const close = create('button', {type: 'button', html: '닫기', event: {click: e => taskList.innerHTML = ''}})
  data.child.forEach(ele => ul.appendChild(taskChildRender(ele)))
  taskList.innerHTML = ''
  for(const ele of [title, input, ul, close]) taskList.appendChild(ele)
}

const taskChildRender = v => create('li', {
  html: v.name,
  style: v.state ? 'color:#09F' : '',
  event: {
    click: e => {
      e.target.style.color = (v.state = !v.state) ? '#09F' : ''
    }
  }
})

window.onload = () => {
  folderInput.onkeyup = addFolder
  folderRender()
}

이 코드를 작성할 때 알아야 하는 개념들은 다음과 같습니다.

  1. es6+ 문법
  2. hoisting, scope, closure
  3. 1급시민, 1급객체, 1급함수
  4. call by reference
  5. 논리연산자에 대한 이해와 응용

제가 이전에 작성한 javascript 포스트 중, promise 빼고 다 사용되었다고 할 수 있습니다. 그리고 이러한 개념은 node.js에서도 사용 되니까 꼭 익혀주세요!

그리고, 이 코드에는 task 수정/삭제, folder 수정/삭제 부분이 빠져있습니다. 가능하다면 해당 코드도 추가해서 연습해보도록 합시다.


이어지는 포스트