[Node.js, Javascript] Todo App 개발 – (3) SPA + API 연동

single page application

앞서 올린 포스트들에 이어서

  1. Todo App 개발 (1) - SPA
  2. Todo App 개발 (2) - REST API

이제 SPA와 REST API를 연동해보도록 하겠습니다.

client-side에서는 spa를 개발하고, server-side에서는 rest api를 개발합니다. 그리고 client와 server가 ajax를 통하여 데이터를 주고받고 반영합니다. ajax를 이용하는 방법에 대해 소개합니다.

순서는 다음과 같습니다.

  1. 뼈대 구성
  2. ajax
  3. custom ajax 만들기
  4. model 구성
  5. renderer 구성

Project 구성은 Todo App 개발 (2) - REST API 에 이어서 사용하도록 하겠습니다.


1. 뼈대 구성

1) /index.js

index.html을 http://127.0.0.1:3000/ 에 출력시키기 위해 라우팅을 설정해줍시다.

app.get('/', (req, res) => {
  res.render('index') // index.html render
})


2) /public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta http-equiv="Content-Type" content="text/html; 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>
    </section>
  </div>
  <script src="/js/app.js"></script>
</body>
</html>

이번에는 javascript를 외부에서 불러오도록 하겠습니다. 이렇게 까지 했으면, index.html이 출력되나 확인해봐야겠죠? 먼저 nodemon을 통해 서버를 실행해줍시다.

> nodemon index.js

그리고 http://127.0.0.1:3000 으로 접근하여 확인해봅시다. 정상적으로 뜬다면, 다음으로 넘어가세요


2) /public/js/app.js

이번에는 javascript를 외부에서 불러오도록 하겠습니다. app.js는 Todo App 개발 (1) - SPA 에서 만든 것을 조금 수정할 것입니다.

// DOM function
const one = ele => document.querySelector(ele)
const all = ele => document.querySelectorAll(ele)
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
}

class Ajax {}
class Model {}
class Renderer {}

window.onload = async _ => {
  const model = new Model()
  const renderer = new Renderer(model)
  const folder = await model.getFolder()
  renderer.folderRender(folder)
}

render function들을 class로 만들 것이며, ajax와 model이 추가되었습니다. 코드를 구성하기 전에 ajax에 대해 짚어보고 가겠습니다.


2. ajax

Ajax(Asynchronous JavaScript and XML, 에이잭스)는 비동기적인 웹 애플리케이션의 제작을 위해 아래와 같은 조합을 이용하는 웹 개발 기법이다.

여기서 말하는 "비동기적"이란, javascript를 통해 특정 페이지에 접근해서 데이터를 받아오는 행위를 말합니다. 즉, 페이지 이동 없이 데이터를 주고 받을 수 있는 것입니다.

프로젝트를 실행해본 후, http://127.0.0.1:3000 페이지 내에서 크롬 콘솔에 다음과 같이 입력해봅시다.

fetch('/api/folder').then(res => res.json()).then(json => console.log(json))

그럼 다음과 같은 결과를 확인해볼 수 있을 것입니다.

ajax test

이렇게, javascript의 ajax를 이용하면 페이지 이동 없이(즉, 비동기적으로) /api/folder에 접근하여 데이터를 가져올 수 있습니다.

(참고) fetch는 chrome에서 제공하는 ajax api입니다. 자세한 사항은 MDN에서 확인해보세요! https://developer.mozilla.org/ko/docs/Web/API/Fetch_API

이렇게 fetch를 이용하면 손쉽게 ajax를 사용할 수 있습니다.


3. custom ajax 만들기

// myAjax
class Ajax {
  static async get (url) {
    const json = await fetch(url).then(res => res.json())
    if (!json.success) throw json.err
    return json.data
  }
  static async set (url, data, method = 'post') {
    const headers = { 'Content-Type': 'application/json' }
    const params = { method, headers, body: JSON.stringify(data) }
    const json = await fetch(url, params).then(res => res.json())
    if (!json.success) throw json.err
    return json.data
  }
}

Promise를 이용하여 조금 더 간단하게 사용할 수 있도록 구성해봤습니다. 직전에 작성한 테스트코드를 다음과 같이 사용할 수 있습니다.

const json = await Ajax.get('/api/folder')
console.log(json)

훨씬 간결하죠?


4. model 구성

model은 보통 data를 관리하는 것을 의미합니다.

// DataBase Model
class Model {
  async getFolder () { return await Ajax.get('/api/folder')}
  async setFolder (folder) { await Ajax.set('/api/folder', { folder })}
  async getTask (parent) { return await Ajax.get('/api/task/' + parent) || []}
  async addTask (task, parent) { await Ajax.set('/api/task/' + parent, { task })}
  async setTask (task, idx) { await Ajax.set('/api/task/' + idx, { task }, 'put')}
}

folder 추가,수정 / task 추가,수정,삭제 등으로 구성되어있습니다. 단순하게 Ajax class만 사용해도 되지만, 이왕 작성하는 김에 최대한 모듈화 시키는 게 좋습니다.


5. renderer 구성

Render를 할 때 신경써야 하는 점은 바로 "데이터 동기화" 입니다.
서버에서 관리되는 자원과 브라우저 관리되는 자원이 동기화 되어야 합니다.

// Renderer
class Renderer {
  constructor (model) {
    this.model = model
    this.folderInput = one('.folder-input')
    this.folderList = one('.folder-list')
    this.taskList = one('.task-list')
    this.folderInput.onkeyup = this.addFolder()
  }

  addFolder () {
    const $this = this
    return async function (e) {
      if (e.keyCode === 13) {
        const folder = await $this.model.getFolder()
        folder.push({ name: e.target.value })
        await $this.model.setFolder(folder)
        $this.folderRender(folder)
        e.target.value = ''
        e.target.focus
      }
    }
  }

  addTask (folderEvent, parent) {
    const $this = this
    return async inputEvent => {
      if (inputEvent.keyCode === 13) {
        await $this.model.addTask({name: inputEvent.target.value, state: false}, parent)
        folderEvent.target.click()
      }
    }
  }

  setTask (v) {
    const $this = this
    return async e => {
      v.state = !v.state
      await $this.model.setTask(v, v.idx)
      e.target.style.color = v.state ? '#09F' : ''
    }
  }

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

  taskRender (folderName, parent) {
    const $this = this
    return async e => {
      const title = create('h3', {html: folderName})
      const ul = create('ul')
      const task = await $this.model.getTask(parent)
      const input = create('input', {class: 'task-input', size: 20, placeholder: 'task 입력', event: { keyup: $this.addTask(e, parent) } })
      const close = create('button', {type: 'button', html: '닫기', event: {click: e => $this.taskList.innerHTML = ''}})
      task.forEach(v => ul.appendChild($this.taskChildRender(v)))
      $this.taskList.innerHTML = ''
      for(const ele of [title, input, ul, close]) $this.taskList.appendChild(ele)      
    }
  }

  taskChildRender (v) {
    const $this = this
    return create('li', {
      html: v.name,
      style: v.state ? 'color:#09F' : '',
      event: { click: $this.setTask(v) }
    })
  }
}

event를 사용할 때, this 때문에 (꼭 this 때문이 아니더라도) closure로 구성하였습니다.

app.js 최종본

// DOM function
const one = ele => document.querySelector(ele)
const all = ele => document.querySelectorAll(ele)
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
}

// myAjax
class Ajax {
  static async get (url) {
    const json = await fetch(url).then(res => res.json())
    if (!json.success) throw json.err
    return json.data
  }
  static async set (url, data, method = 'post') {
    const headers = { 'Content-Type': 'application/json' }
    const params = { method, headers, body: JSON.stringify(data) }
    const json = await fetch(url, params).then(res => res.json())
    if (!json.success) throw json.err
    return json.data
  }
}

// DataBase
class Model {
  async getFolder () { return await Ajax.get('/api/folder')}
  async setFolder (folder) { await Ajax.set('/api/folder', { folder })}
  async getTask (parent) { return await Ajax.get('/api/task/' + parent) || []}
  async addTask (task, parent) { await Ajax.set('/api/task/' + parent, { task })}
  async setTask (task, idx) { await Ajax.set('/api/task/' + idx, { task }, 'put')}
}

// Renderer
class Renderer {
  constructor (model) {
    this.model = model
    this.folderInput = one('.folder-input')
    this.folderList = one('.folder-list')
    this.taskList = one('.task-list')
    this.folderInput.onkeyup = this.addFolder()
  }

  addFolder () {
    const $this = this
    return async function (e) {
      if (e.keyCode === 13) {
        const folder = await $this.model.getFolder()
        folder.push({ name: e.target.value })
        await $this.model.setFolder(folder)
        $this.folderRender(folder)
        e.target.value = ''
        e.target.focus
      }
    }
  }

  addTask (folderEvent, parent) {
    const $this = this
    return async inputEvent => {
      if (inputEvent.keyCode === 13) {
        await $this.model.addTask({name: inputEvent.target.value, state: false}, parent)
        folderEvent.target.click()
      }
    }
  }

  setTask (v) {
    const $this = this
    return async e => {
      v.state = !v.state
      await $this.model.setTask(v, v.idx)
      e.target.style.color = v.state ? '#09F' : ''
    }
  }

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

  taskRender (folderName, parent) {
    const $this = this
    return async e => {
      const title = create('h3', {html: folderName})
      const ul = create('ul')
      const task = await $this.model.getTask(parent)
      const input = create('input', {class: 'task-input', size: 20, placeholder: 'task 입력', event: { keyup: $this.addTask(e, parent) } })
      const close = create('button', {type: 'button', html: '닫기', event: {click: e => $this.taskList.innerHTML = ''}})
      task.forEach(v => ul.appendChild($this.taskChildRender(v)))
      $this.taskList.innerHTML = ''
      for(const ele of [title, input, ul, close]) $this.taskList.appendChild(ele)      
    }
  }

  taskChildRender (v) {
    const $this = this
    return create('li', {
      html: v.name,
      style: v.state ? 'color:#09F' : '',
      event: { click: $this.setTask(v) }
    })
  }
}

window.onload = async _ => {
  const model = new Model()
  const renderer = new Renderer(model)
  const folder = await model.getFolder()
  renderer.folderRender(folder)
}

REST API를 포함한 최종 결과물 소스는 github에 올려놨습니다.
https://github.com/JunilHwang/sihs-lecture/tree/master/todoapp

참고자료

  1. es6+
  2. hoisting, scope, closure
  3. 1급시민, 1급객체, 1급함수
  4. Promise, aysnc, await
  5. Call By Reference