ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • '2021 Dev-Matching: 웹 프론트엔드 개발자(상반기)' 기출 문제 해설
    취업 이야기/데브매칭 문제 해설 2021. 4. 15. 19:03

    'Dev-Matching 웹 프론트엔드 개발자'의 과제 테스트는 어떠셨나요? 내가 무엇을 잘못하였고, 무엇을 잘했는지 궁금하시지 않으셨나요? 우리 모두 해설을 보고 한번 점검하는 시간을 가지도록 해요. 만약 내가 Dev-Matching 웹 프론트엔드에 참여하시지 않았다면, 과제 테스트를 한 번 풀어보고 해설을 보시는걸 추천드립니다.

    문제를 다시 풀어보려면?

    프로그래머스 사이트 내에 '실력 체크' > '과제관'> [프론트엔드] 고양이 사진첩 애플리케이션을 클릭하면 2021 Dev-Matching: 웹 프론트엔드 개발자에 출제된 과제를 풀어 보실 수 있습니다. 뿐만 아니라 '과제관'에서 2020에 출제된 다른 프론트엔드 과제도 풀어 보실 수 있으니 확인해보세요!

    >> 과제관으로 바로가기 <<

    그럼 문제 해설 시작하겠습니다.


    문제해설

    안녕하세요. 고양이 사진첩 문제 출제자입니다.

    이 글에서는 고양이 사진첩 만들기에 대해 어떤 관점으로 접근해야하고, 또 출제자의 의도는 무엇이었는지에 대해 설명해보고자 합니다.

    생각해보기

    고양이 사진첩 만들기, 다들 재밌으셨나요?

    간단하면서도 신경써야하는 부분, 그리고 놓치기 쉬운 부분들이 있는 문제입니다.

    한번 같이 살펴보도록 합시다.

    구조

    API와 화면 구조

    화면의 구성을 보시면, 전형적인 트리 형태의 데이터임을 알 수 있습니다. API의 경우, root 데이터를 조회를 하는 경우나 특정 디렉토리에 속한 데이터를 조회하는 경우에나 API의 response에 패턴이 있다는 것을 눈치챌 수 있죠. 아래와 같은 형태의 데이터가 배열로 들어있게 됩니다.

    [
        {
            "id": "5",
            "name": "2021/04",
            "type": "DIRECTORY",
            "filePath": null,
            "parent": {
                "id": "1"
            }
        },
        {
            "id": "19",
            "name": "물 마시는 사진",
            "type": "FILE",
            "filePath": "/images/a2i.jpg",
            "parent": {
                "id": "1"
            }
        }
    ]

    이를 통해, 해당 형태의 데이터가 주어지면 화면 요구사항에 맞는 Directory와 File을 렌더링하도록 하는 무언가를 만드는 것이 핵심입니다.

    아래에서부턴 위의 데이터 구조를 Node 데이터라고 칭하겠습니다

    File과 Directory의 파일 구조의 차이

    File과 Directory의 경우 type을 통해 구분하게 되어있습니다. 이를 통해 위에서 설명한 Node 데이터를 그릴 때, type에 따라 File 형태로 렌더링 할지 Directory로 렌더링 할지 결정할 수 있습니다.

    Node 데이터 렌더링하기

    위의 내용을 토대로 애플리케이션의 간략한 흐름은 아래와 같을겁니다.

    1. 애플리케이션 기동시, root 경로에 대한 Node 데이터 불러오기
    2. 1을 기반으로 Node 데이터 렌더링
      1. Node 데이터를 loop하면서, type이 DIRECTORY 인 경우 Directory 렌더링
      2. Node 데이터를 loop하면서, type이 FILE 인 경우 File 렌더링
    3. 디렉토리를 클릭한 경우, 해당 디렉토리에 속한 Node 데이터를 불러온 뒤
    4. 3을 기반으로 Node 데이터 렌더링

    이 순서가 반복해서 이루어집니다. 그러므로 Node 데이터가 주어지면 알맞게 화면을 렌더링 하는 무언가를 만드는 것이 중요하고, UI 인터랙션에 따라서 새 Node 데이터를 얻어온 뒤 화면을 업데이트하는 방법에 대해 생각해볼 필요가 있지요.

    구현

    출제자의 경우 보통 아래와 같은 방식으로 구현합니다.

    구현에는 정답이 없습니다. 아래의 코드는 참고만 하시기 바랍니다.

    이 문서에서는 아래의 문법을 기본으로 사용합니다. 만약 해당 문법이 익숙하지 않다면 아래 MDN 링크를 읽어보시는 것을 추천합니다.

    선언적 프로그래밍과 컴포넌트 추상화

    문제의 지문 중 가급적 컴포넌트 형태로 추상화 하여 작성하라는 지문이 있습니다.

    이는 DOM을 접근하는 부분을 최소화하고, 명령형 프로그래밍 방식보다는 선언적인 프로그래밍 방식으로 접근하는 것을 이야기 합니다.

    Node 데이터를 화면에 그리는 것을 기준으로 설명을 하면, 명령형 프로그래밍 방식으로 할 경우 아래와 같을 겁니다.

    // 명령형 프로그래밍 방식
    // DOM을 직접 접근하는 것에 제한과 규칙이 없으며, 재사용이 쉽지 않음
    function renderNodes(nodes) {
      const $container = document.querySelector('.container')
      nodes.forEach(node => {
        const $node = document.createElement('div')
        ....
        ....
        $container.appendChild($node)
      })
    }

    node를 파라메터로 받아서 DOM에 직접 접근해 update하는 함수입니다.

    위의 코드가 크게 문제가 되는 것은 아닙니다. 다만 이 경우 아래와 같은 잠재적 문제점을 가지고 있습니다.

    • DOM에 접근하고 업데이트하는 시점에 대한 명확한 기준점이 없기 때문에, 코드가 거대해지고 UI의 업데이트가 많아질 경우 어느 지점에서 어느 시점에 DOM을 업데이트 했느냐를 추적하기가 점점 힘들어집니다.

    위의 코드를 아래처럼 어떠한 상태를 기준으로 렌더링하도록 만들어보면 어떨까요?

    // Nodes 컴포넌트 - function 문법 버전
    // 생성된 DOM을 어디에 append 할지를 $app 파라메터로 받기
    // 파라메터는 구조 분해 할당 방식으로 처리
    // https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment
    function Nodes({ $app, initialState }) {
      this.state = initialState
    
      // Nodes 컴포넌트를 렌더링 할 DOM을 this.$target 이라는 이름으로 생성
        this.$target = document.createElement('ul')
      $app.appendChild(this.$target)
    
      // state를 받아서 현재 컴포넌트의 state를 변경하고 다시 렌더링하는 함수
      this.setState = (nextState) => {
        this.state = nextState
        // render 함수 내에서 this.state 기준으로 렌더링을 하기 때문에,
        // 단순히 이렇게만 해주어도 상태가 변경되면 화면이 알아서 바뀜
        this.render()
      }
      // 파라메터가 없는 Nodes의 render 함수.
      // 현재 상태(this.state) 기준으로 렌더링 합니다.
      this.render = () => {
        this.$target.innerHTML = this.state.nodes.map(node => 
          `<li>${node.name}</li>`
        )    
      }
    
      // 인스턴스화 이후 바로 render 함수를 실행하며 new로 생성하자마자 렌더링 되도록 할 수 있음
      this.render()
    }
    
    // Nodes 컴포넌트 - class 문법 버전
    class Nodes {
      constructor({ $app, initialState )) {
        this.state = initialState
          this.$target = document.createElement('ul')
        $app.appendChild(this.$target)
    
        this.render()
      }
    
      setState(nextState) {
        this.state = nextState
        this.render()
      }
      render() {
        this.$target.innerHTML = this.state.nodes.map(node => 
          `<li>${node.name}</li>`
        )
      }
    }

    위 function(혹은 class 구조)에서 주목해야할 부분은 크게 세 가지입니다.

    • constructor - new 키워드를 통해 해당 컴포넌트가 생성되는 시점에 실행됩니다. 해당 컴포넌트가 표현될 element를 생성하고, 파라메터로 받은 $app(DOM 변수)에 렌더링하도록 합니다.
    • render - 해당 컴포넌트의 state를 기준으로 자신의 element에 렌더링합니다. 자신의 상태(state)를 기준으로 렌더링해야하기 때문에, 별도의 파라메터를 받지 않아야 합니다.
    • setState - 해당 컴포넌트의 state를 갱신합니다. render 함수가 별도의 파라메터 없이 자신의 상태를 기준으로 렌더링하도록 작성이 되어있기 때문에, state를 변경하고 다시 render 함수를 부르도록 함으로써 업데이트된 상태를 화면에 반영할 수 있게 됩니다.

    이런 식으로 작성하면, 실제 DOM을 직접 제어하는 부분을 컴포넌트가 인스턴스화 되는 시점, 그리고 render 함수가 다시 호출 되는 시점으로 제한할 수 있습니다.

    이렇게 만들어진 컴포넌트는 아래와 같이 사용할 수 있습니다.

    const $app = document.querySelector('.app')
    // 테스트를 위한 dummy data 혹은 api를 통해 받아온 data
    const initialState = {
      nodes: []
    }
    const nodes = new Nodes({
      $app,
      initialState
    })
    
    // 이후 nodes를 갱신할 일이 있다면 nodes.setState를 호출
    const nextState = {
      nodes: [
        {
           ....
        }
      ]
    }
    nodes.setState(nextState)

    위와 같은 방식으로 Breadcrumb를 정의하면 아래와 같습니다.

    function Breadcrumb({ $app, initialState }) {
      this.state = initialState
    
      this.$target = document.createElement('nav')
      this.$target.className = 'Breadcrumb'
      $app.appendChild(this.$target)
    
      this.setState = nextState => {
        this.state = nextState
        this.render()
      }
    
      this.render = () => {
        this.$target.innerHTML = `<div class="nav-item">root</div>${
          this.state.map(
            (node, index) => `<div class="nav-item" data-index="${index}">${node.name}</div>`).join('')}`
      }
    }

    이후 코드에서는 컴포넌트 선언시 function 방식으로 선언하도록 합니다.

    컴포넌트간의 의존도를 줄이기

    Nodes, Breadcrumb 컴포넌트 코드는 위의 설명으로 작성하면 될겁니다. 그런데 위의 코드는 단순히 상태를 기준으로 렌더링만 하는 코드이고, 실제 UI 인터랙션에 따라서 state를 변경해야하는 부분이 요구사항에 있는데 대략 아래와 같습니다.

    • Nodes의 요소를 클릭하면 해당 요소에 따라 동작
      • File인 경우 해당 File 이미지 보기
      • Directory인 경우 해당 Directory로 이동
        • 하위 Directory로 이동한 경우 Breadcrumb에도 해당 Directory path 추가
      • 뒤로가기를 클릭한 경우 이전 Directory로 이동

    결국 Nodes에서 일어나는 어떤 인터랙션에 의해 Breadcrumb에도 영향을 주어야 합니다.

    이때, Nodes 코드 내에서 Breadcrumb을 직접 다루거나 업데이트 하도록 코드를 작성하게 되면 Nodes 컴포넌트를 독립적으로 사용할 수 없게 됩니다. Breadcrumb에 의존성이 생기기 때문이죠. Breadcrumb 필요없이 Nodes만 필요한 화면에서는 쓸 수가 없게 됩니다.

    이런 경우 일반적으로 두 컴포넌트를 조율하는 더 상위의 컴포넌트를 만들고, 콜백 함수를 통해 느슨하게 결합합니다.

    위에서 설명했던 Nodes 코드의 파라메터로 onClick 이벤트 핸들러를 받고, render 함수를 수정합니다.

    // onClick은 함수이며, 클릭한 Node의 type과 id를 파라메터로 넘겨받도록 함
    function Nodes({ $app, initialState, onClick }) {
      // 기존 코드 생략
    
      this.onClick = onClick
    
      this.render = () => {
        if (this.state.nodes) {
          const nodesTemplate = this.state.nodes.map(node => {
            const iconPath = node.type === 'FILE' ? './assets/file.png' : './assets/directory.png'
    
            return `
              <div class="Node" data-node-id="${node.id}">
                <img src="${iconPath}" />
                <div>${node.name}</div>
              </div>
            `
          }).join('')
    
          this.$target.innerHTML = !this.state.isRoot ? `<div class="Node"><img src="/assets/prev.png"></div>${nodesTemplate}` : nodesTemplate
        }  
    
          // 렌더링된 이후 클릭 가능한 모든 요소에 click 이벤트 걸기
          this.$target.querySelectorAll('.Node').forEach($node => {
            $node.addEventListener('click', (e) => {
              // dataset을 통해 data-로 시작하는 attribute를 꺼내올 수 있음
              const { nodeId } = e.target.dataset
              const selectedNode = this.state.nodes.find(node => node.id === nodeId)
    
              if (selectedNode) {
                this.onClick(selectedNode)
              }
            })
          })
      }
    }
    

    이제 Nodes를 위에서 조율하기 위한 App 컴포넌트를 작성합니다.

    function App($app) {
      this.state = {
        isRoot: false,
        nodes: [],
        depth: []
      }
    
      const breadcrumb = new Breadcrumb({
        $app,
        initialState: this.state.depth
      })
      const nodes = new Nodes({
        $app,
        initialState: {
          isRoot: this.state.isRoot,
          nodes: this.state.nodes
        },
        // 함수를 파라메터로 던지고, Nodes 내에서 click 발생시 이 함수를 호출하게 함.
        // 이러면 Nodes 내에선 click 후 어떤 로직이 일어날지 알아야 할 필요가 없음.
        onClick: (node) => {
          if (node.type === 'DIRECTORY') {
            // DIRECTORY인 경우 처리
            // 여기에서 Breadcrumb 관련 처리를 하게 되면, Nodes에서는 Breadcrumb를 몰라도 됨.
          } else if(node.type === 'FILE') {
            // FILE인 경우 처리
          }
        }
      })
    }

    이렇게 하면 App이 두 컴포넌트(Breadcrumb, Nodes)를 조율하는 형태가 되며, 두 컴포넌트는 독립적으로 동작하고 또 다른 곳에 쉽게 재활용 할 수 있는 구조가 됩니다.

    App 컴포넌트는 index.js 를 통해 아래처럼 생성하여 사용합니다.

    // index.js
    new App(document.querySelector('.app'))

    fetch 함수로 데이터 불러오기

    이제 위에서 컴포넌트의 구조를 잡았으니 실제로 데이터를 불러오는 처리를 해보도록 합시다.

    문제에서는 가급적 fetch 함수를 사용하라고 적어두었습니다.

    fetch 함수는 기본적으로 url을 파라메터로 받고 Promise 형태로 처리합니다. 즉, fetch를 능숙하게 다루기 위해선 Promise에 대한 이해가 필수적입니다.

    fetch('http://example.com/movies.json')
      .then((response) => {
        return response.json();
      })
      .then((myJson) => {
        console.log(JSON.stringify(myJson));
      });

    한 가지 중요한 부분은, fetch()로 부터 반환되는 Promise 객체는 HTTP error 상태를 reject하지 않습니다. 그렇기 때문에 http 요청 중 에러가 발생했더라도 Promise의 catch로 떨어지지 않는다는 이야기입니다. 그렇기에 요청이 정말로 성공했는지를 체크하려면 response의 ok를 체크해야 합니다.

    fetch('http://example.com/movies.json')
      .then((response) => {
        if (!response.ok) {
          throw new Error('http 오류')
        }
        return response.json();    
      })
      .then((myJson) => {
        console.log(JSON.stringify(myJson));
      })
      .catch(e => {
        alert(e.message) 
      })

    그 외에 fetch에 대한 자세한 내용은 아래의 MDN 문서를 참고합니다.

    api 호출하는 부분을 별도로 분리하기

    실제 fetch 함수를 호출하는 부분을 컴포넌트 내에 코딩하지 않고, 별도의 유틸리티 함수로 분리하는 것이 좋습니다. 이는 각 컴포넌트가 데이터를 어떤 방식으로 불러올지는 해당 컴포넌트의 관심사가 아니기 때문입니다.

    // api.js
    
    // api end point를 상수처리 해두면 나중에 변경 되었을 경우 처리하기 쉬움
    const API_END_POINT = '...' 
    
    const request = (nodeId) => {
      // nodeId 유무에 따라 root directory를 조회할지 특정 directory를 조회할지 처리
      fetch(`${API_END_POINT}/${nodeId ? nodeId : ''}`)
        .then((response) => {
          if (!response.ok) {
            throw new Error('서버의 상태가 이상합니다!')
          }
          return response.json()
        })
        .catch((e) => {
          throw new Error(`무언가 잘못 되었습니다! ${e.message}`)
        })
    }

    async ~ await 사용하기

    async 문법은 Promise 형태의 응답을 반환하는 함수의 경우, 동기식 문법으로 작성할 수 있도록 도와주는 문법입니다. 과거에는 babel 등의 도구를 이용했어야 했지만, 최근에는 모던 브라우저에서 기본적으로 지원합니다.

    위의 request 함수를 async ~ await로 바꿔볼까요?

    const request = (nodeId) => {
     try {
        const res = await fetch(`${API_END_POINT}/${nodeId ? nodeId : ''}`)
    
        if (!res.ok) {
          throw new Error('서버의 상태가 이상합니다!')
        }
    
        return await res.json()
      } catch(e) {
        throw new Error(`무언가 잘못 되었습니다! ${e.message}`)
      }
    }

    함수가 체이닝 되는 형태가 사라지면서 좀 더 이해하기 쉬운 코드 형태가 되었습니다.

    async ~ await는 결국 Promise의 사용을 편하게 해주는 것이기 때문에, 이를 잘 쓰려면 Promise에 대한 이해가 필수적입니다. Promise와 async에 대해서는 아래 MDN 문서를 참고하는 것이 도움이 됩니다.

    App에서 데이터 불러오도록 처리하기

    이제 fetch 함수를 넣었으니 App 컴포넌트에서 초기 데이터를 불러오게 하고, 이를 Nodes 컴포넌트에 적용하도록 코드를 수정해보겠습니다.

    // App.js
    function App($app) {
      this.state = {
        isRoot: false,
        nodes: [],
        depth: []
      }
    
      // 파라메터 코드 생략
      const breadcrumb = new Breadcrumb({...})
      const nodes = new Nodes({...})
    
      // App 컴포넌트에도 setState 함수 정의하기
      this.setState = (nextState) => {
        this.state = nextState
        breadcrumb.setState(this.state.depth)
        nodes.setState({
          isRoot: this.state.isRoot,
          nodes: this.state.nodes
        })
      }
    
      const init = async = () => {
        try {
          const rootNodes = await request()
          this.setState({
            ...this.state,
            isRoot: true,
            nodes: rootNodes
          })
        } catch(e) {
          // 에러처리 하기
        }
      }
    
      init()
    }

    이제 최초 앱 실행 시 root directory의 데이터를 불러오는 부분이 처리가 되었습니다.

    Nodes 컴포넌트 완성하기

    Directory 클릭 시 데이터 불러와서 렌더링 하도록 처리하기

    위에서 비워두었던 Nodes 컴포넌트의 onClick 시 처리할 동작들에 대해서 구현을 해볼까요?

    onClick 이벤트 핸들러를 아래와 같이 구현합니다.

    // App.js
    
    // 이전 코드 생략
    const nodes = new Nodes({
      $app,
      initialState: [],
      onClick: async (node) => {
        try {
          if (node.type === 'DIRECTORY') {
            const nextNodes = await request(node.id)
            this.setState({
              ...this.state,
              depth: [...this.state.depth, node],
              nodes: nextNodes
            })
          } else if (node.type === 'FILE') {
            // 이미지 보기 처리하기
          } catch(e) {
            // 에러처리하기
          }
        }
      })

    File 이미지 보는 처리하기

    문제지에 보면, 아래와 같은 마크업을 이용해서 ImageView를 처리하라고 안내되어 있습니다.

    <div class="ImageViewer">
      <div class="content">
        <img src="https://fe-dev-matching-2021-03-serverlessdeploymentbuck-t3kpj3way537.s3.ap-northeast-2.amazonaws.com/public/images/a2i.jpg">
      </div>
    </div>

    이 부분도 아래와 같이 컴포넌트로 만들어봅시다.

    // 상수 처리
    const IMAGE_PATH_PREFIX = 'https://fe-dev-matching-2021-03-serverlessdeploymentbuck-t3kpj3way537.s3.ap-northeast-2.amazonaws.com/public'
    
    function ImageView({ $app, initialState }) {
      // image url
      this.state = initialState
      this.$target = document.createElement('div')
      this.$target.className = 'Modal ImageView'
    
      $app.appendChild(this.$target)
    
      this.setState = (nextState) => {
        this.state = nextState
        this.render()
      }
    
      this.render = () => {
        this.$target.innerHTML = `<div class="content">${this.state ? `<img src="${IMAGE_PATH_PREFIX}${this.state}">` : ''}</div>`
    
        this.$target.style.display = this.state ? 'block' : 'none'
      }
    
      this.render()
    }

    App 컴포넌트 내에서 ImageView를 생성하고, Nodes에서 File을 클릭 시 ImageView에 image url을 넘기는 방식으로 처리하면 됩니다.

    // App.js
    this.state = {
      isRoot: true,
      nodes: [],
      depth: [],
      selectedFilePath: null
    }
    const imageView = new ImageView({
      $app,
      initialState: this.state.selectedNodeImage
    })
    
    this.setState = (nextState) => {
      this.state = nextState
      breadcrumb.setState(this.state.depth)
      nodes.setState({
        isRoot: this.state.isRoot,
        nodes: this.state.nodes
      })
      imageView.setState(this.state.selectedFilePath)
    }
    
    // 이전 코드 생략
    const nodes = new Nodes({
      $app,
      initialState: [],
      onClick: async (node) => {
        try {
          if (node.type === 'DIRECTORY') {
            // Directory 처리 코드 생략
          } else if (node.type === 'FILE') {
            this.setState({
              ...this.state,
              selectedFilePath: node.filePath
            })
          } catch(e) {
            // 에러처리하기
          }
        }
      })

    뒤로가기 처리하기

    Nodes에서 root directory가 아닌 경우 뒤로가기가 렌더링 되게 되어있습니다. 이 뒤로가기를 클릭할 경우 이전 경로로 돌아가는 처리를 하도록 되어있지요.

    위 App의 state 부분과 directory 클릭 시 발생하는 이벤트 핸들러를 잘 보면 depth 라는 이름으로 현재까지 탐색된 directory 를 넣고 있는데, 이를 이용하여 처리하면 됩니다.

    • 하위 Directory로 이동하는 경우 depth에 현재 directory node를 추가
    • 이전 Directory로 이동하는 경우 depth 마지막 요소를 제거하고, 이전 요소로 이동

    명시적으로 뒤로가기를 눌렀을 때를 onBackClick 이라는 이름의 이벤트 핸들러로 처리하도록 Nodes와 App을 고쳐보도록 하겠습니다.

    // Nodes.js
    function Nodes({ $app, initialState, onClick, onBackClick }) {
      // 기존 코드 생략
    
      this.onBackClick = onBackClick
    
      this.render = () => {
        if (this.state.nodes) {
          const nodesTemplate = this.state.nodes.map(node => {
            const iconPath = node.type === 'FILE' ? './assets/file.png' : './assets/directory.png'
    
            return `
              <div class="Node" data-node-id="${node.id}">
                <img src="${iconPath}" />
                <div>${node.name}</div>
              </div>
            `
          }).join('')
    
          // root directory 렌더링이 아닌 경우 뒤로가기를 렌더링
          // 뒤로가기의 경우 data-node-id attribute를 렌더링하지 않음.
          this.$target.innerHTML = !this.state.isRoot ? `<div class="Node"><img src="/assets/prev.png"></div>${nodesTemplate}` : nodesTemplate
        }
      }
    
      this.$target.querySelectorAll('.Node').forEach($node => {
        $node.addEventListener('click', (e) => {
          // dataset을 통해 data-로 시작하는 attribute를 꺼내올 수 있음
          const { nodeId } = e.target.dataset
          // nodeId가 없는 경우 뒤로가기를 누른 케이스
          if (!nodeId) {
            this.onBackClick()
          }
          const selectedNode = this.state.nodes.find(node => node.id === nodeId)
    
          if (selectedNode) {
            this.onClick(selectedNode)
          }
        })
      }) 
    }

    이제 App.js에서 onBackClick을 구현합니다.

    // App.js
    
    // 이전 코드 생략
    const nodes = new Nodes({
      $app,
      initialState: [],
      onClick: async (node) => {
        // 코드 생략
      }),
      onBackClick: async () => {
        try {
          // 이전 state를 복사하여 처리
          const nextState = { ...this.state }
          nextState.depth.pop()
    
          const prevNodeId = nextState.depth.length === 0 ? null : nextState.depth[nextState.depth.length - 1].id
    
          // root로 온 경우이므로 root 처리
          if (prevNodeId === null) {
            const rootNodes = await request()
            this.setState({
              ...nextState,
              isRoot: true,
              nodes: rootNodes
            })
          } else {
            const prevNodes = await request(prevNodeId)
    
              this.setState({
                ...nextNodes,
                isRoot: false,
                nodes: prevNodes,
              })
          }       
        } catch(e) {
          // 에러처리
        }
      })
    })

    onBackClick에서 this.state의 depth를 변경하고, Breadcrumb의 경우 app의 state의 depth 상태를 내려받으므로 자동으로 Breadcrumb 부분도 갱신이 됩니다.

    그외에

    import, export 사용하기

    es6의 import, export 기능을 이용하면 index.html에서 모듈 의존 순서에 맞게 script src로 스크립트를 불러올 필요 없이, index.js만 불러오게 하고 나머지는 각 컴포넌트에서 필요한 스크립트만 import 해서 쓸 수 있습니다. 이 기능도 async처럼 최근의 모던 브라우저에서는 기본으로 지원하기 때문에, webpack 등의 도움 없이 사용 가능합니다.

    먼저 index.js를 정의합니다.

    // 확장자까지 모두 써주어야 함
    import App from './App.js'
    
    new App(document.querySelector('.app'))

    그리고 index.html에 스크립트를 로딩하는 부분을 아래처럼 수정합니다.

    // index.html
    <html>
      <head>
        <title>고양이 사진첩!</title>
        <link rel="stylesheet" href="./src/styles/style.css">
        <!-- type="module" 을 추가한다 -->
        <script src="./src/index.js" type="module"></script>
      </head>
      <body>
        <h1>고양이 사진첩</h1>
        <main class="App">
        </main>
      </body>
    </html>

    이후 다른 코드들에는 import로 불러올 수 있도록 export 키워드를 추가합니다.

    // App.js
    export default function App($app) {
    ...
    }
    
    // Nodes.js
    export default function Nodes(....) { ... }
    }
    
    // Breadcrumb.js
    export default function Breadcrumb(...){ ... }
    
    // ImageView.js
    export default function ImageView(...){ ... }

    api.js의 경우 default 없이 request 함수만 export 해도 되겠죠?

    export const request = async (nodeId) => {
    ...
    }

    이제 각 파일의 의존성에 맞게 import문으로 불러오도록 하면 됩니다.

    // App.js
    import ImageView from './ImageView.js'
    import Breadcrumb from './Breadcrumb.js'
    import Nodes from './Nodes.js'
    import { request } from './api.js'
    
    export default App($app) {
    ...
    }

    import / export에 대한 자세한 내용은 https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/import 내용을 참고합니다.

    로딩 중 처리하기

    데이터를 불러오는 도중에는 UI인터랙션을 차단해야합니다. 그렇지 않으면 directory를 마구 클릭시, 예기치 못한 동작이 일어날 수 있습니다.

    로딩 처리의 경우 아래와 같은 마크업으로 index.html에 적어놓고, 주석처리를 해두어서 요 마크업으로 로딩처리를 하도록 유도를 하였습니다.

    <div class="Loading">
      <div class="content">
        <img src="./assets/nyan-cat.gif">
      </div>
    </div>

    로딩도 ImageView처럼 별도의 컴포넌트로 만들어보도록 하겠습니다.

    // Loading.js
    export default function Loading({ $app, initialState }) {
      this.state = initialState
      this.$target = document.createElement('div')
      this.$target.className = "Loading Modal"
    
      $app.appendChild(this.$target)
    
      this.setState = (nextState) => {
        this.state = nextState
        this.render()
      }
    
      this.render = () => {
        this.$target.innerHTML = `<div class="content"><img src="./assets/nyan-cat.gif"></div>`
    
        this.$target.style.display = this.state ? 'block' : 'none'
      }
    
      this.render()
    }

    그리고 App.js의 state에 isLoading을 추가합니다.

    // App.js
    this.state = {
      isRoot: true,
      nodes: [],
      depth: [],
      selectedFilePath: null
    }

    App.js 에서 loading 컴포넌트를 생성하고, setState에서 this.state의 isLoading을 loading 컴포넌트로 내려주도록 합니다.

    const loading = new Loading(this.state.isLoading)
    
    this.setState = (nextState) => {
      this.state = nextState
      breadcrumb.setState(this.state.depth)
      nodes.setState({
        isRoot: this.state.isRoot,
        nodes: this.state.nodes
      })
      imageView.setState(this.state.selectedFilePath)
      loading.setState(this.state.isLoading)
    }
    

    이후 request 함수를 호출하기 전과 호출한 후에 app의 state의 isLoading 값을 토글하는 것으로 로딩 중 표시를 하거나 숨길 수 있게 됩니다.

    const init = async = () => {
      try {
        this.setState({
           ...this.state,
           isLoading: true
        })
        const rootNodes = await request()
        this.setState({
          ...this.state,
          isRoot: true,
          nodes: rootNodes
        })
      } catch(e) {
        // 에러처리 하기
      } finally {
        this.setState({
          ...this.state,
          isLoading: false
        })
      }
    }

    매번 isLoading 값을 토글하는 것이 불편하다면 혹은 App.js 내에 request를 한번 더 래핑하여 호출 전후로 isLoading를 토글하도록 하는 함수를 만드는 것도 방법입니다.

    중요한 것은 api.js 내에서 로딩 중 화면을 보여주거나 숨기거나 하는 처리를 하지 않는 것입니다. 그것은 api.js의 관심사도 아니고 역할도 아니기 때문이죠.

    캐싱 구현하기

    옵션 구현 중 한번 불러온 데이터는 캐시해서 다시 요청 시 캐시된 데이터를 불러오도록 하는 것이 있습니다.

    현재 모든 데이터는 App 컴포넌트가 중앙제어 하고 있기 때문에, App 컴포넌트에 캐시를 위한 object 하나를 만들어서 쓰는 것으로 간단하게 처리할 수 있습니다.

    // App.js
    // nodeId: nodes 형태로 데이터를 불러올 때마다 이곳에 데이터를 쌓는다.
    const cache = {}
    
    export default function App($app) {
      const nodes = new Nodes({
          $app,
          initialState: [],
          onClick: async (node) => {
            try {
              if (node.type === 'DIRECTORY') {
              if (cache[node.id]) {
                  this.setState({
                      ...this.state,
                      depth: [...this.state.depth, node],
                      nodes: nextNodes
                    })
              } else {
                    const nextNodes = await request(node.id)
                    this.setState({
                      ...this.state,
                      depth: [...this.state.depth, node],
                      nodes: nextNodes
                    })
                // cache update
                cache[node.id] = nextNodes
                        }
              } else if (node.type === 'FILE') {
                // File 처리 코드 생략
            }
          } catch(e) {
            // 에러처리하기      
            }
          }),
        onBackClick: async() => {
            try {
              const nextState = { ...this.state }
              nextState.depth.pop()
    
              const prevNodeId = nextState.depth.length === 0 ? null : nextState.depth[nextState.depth.length - 1].id
    
            // 현재 구현된 코드에서는 불러오는 모든 데이터를 cache에 넣고 있으므로
            // 이전으로 돌아가는 경우 이전 데이터가 cache에 있어야 정상임
              if (prevNodeId === null) {
                const rootNodes = await request()
                this.setState({
                  ...nextState,
                  isRoot: true,
                  nodes: cache.rootNodes
                })
              } else {
                      this.setState({
                    ...nextNodes,
                    isRoot: false,
                    nodes: cache[prevNodes],
                  })
              }       
            } catch(e) {
              // 에러처리
            }
        }
      })
      const init = async () => {
        this.setState({
          ...this.state,
          isLoading: true
        })
    
        try {
          const rootNodes = await request()
          this.setState({
            ...this.state,
            isLoading: false,
            isRoot: true,
            nodes: rootNodes
          })
          // 캐시에 추가
          cache.root = rootNodes
    
        } catch(e) {
          this.onError(e)
        }
      }
    }

    간략하게 구현해본 cache입니다. 이 부분도 별도의 util 함수 등으로 분리하거나 request 함수를 래핑하여 만들어 볼 수 있겠죠?

    이벤트 최적화

    Nodes.js의 경우 렌더링이 일어날 때마다 모든 Node 요소에 click 이벤트를 다시 걸어주고 있습니다.

    이벤트 버블링 이라는 이야기를 들어보셨나요? 클릭 등의 이벤트가 발생했을 때 클릭한 요소로부터 해당 이벤트가 계속 상위로 전파되는 특성을 이야기합니다. 이 특성을 이용한 기법이 바로 이벤트 위임(event delegation) 이라는 기법입니다. 이 기법을 이용하면 Node 마다 걸던 클릭 이벤트를 단 하나의 이벤트로 걸어서 처리할 수 있습니다. render를 다시 한 후 이벤트를 다시 걸지 않아도 되는 잇점도 있지요.

    // Nodes.js
    export const Nodes({ $app, onClick, onBackClick }) {
      ...
    
      this.render = () => {
        if (this.state.nodes) {
          const nodesTemplate = this.state.nodes.map(node => {
            const iconPath = node.type === 'FILE' ? './assets/file.png' : './assets/directory.png'
    
            return `
              <div class="Node" data-node-id="${node.id}">
                <img src="${iconPath}" />
                <div>${node.name}</div>
              </div>
            `
          }).join('')
    
          this.$target.innerHTML = !this.state.isRoot ? `<div class="Node"><img src="/assets/prev.png"></div>${nodesTemplate}` : nodesTemplate
        }  
        // 기존 이벤트 바인딩 코드 제거
      }
    
      this.$target.addEventListener('click', (e) => {
        // $target 하위에 있는 HTML 요소 클릭시 이벤트가 상위로 계속 전파 되면서
        // $target까지 오게 됨. 이 특성을 이용한 기법.
    
        // closest를 이용하면 현재 클릭한 요소와 제일 인접한 요소를 가져올 수 있음.
        const $node = e.target.closest('.Node')
    
        if ($node) {
          const { nodeId } = $node.dataset
    
          if (!nodeId) {
            this.onBackClick()
            return
          }
    
          const selectedNode = this.state.nodes.find(node => node.id === nodeId)
    
          if (selectedNode) {
            this.onClick(selectedNode)
          }
        }
      })
    }

    이벤트 위임에 대해서는 https://ko.javascript.info/event-delegation 를 참고합니다.

    Breadcrumb을 클릭하여 이전 path로 돌아가기

    현재 탐색한 경로가 그려는 Breadcrumb를 클릭하여 해당 경로로 이동하는 옵션 구현이 있었습니다.

    Nodes 컴포넌트에 onClick 이벤트를 구현한 방식과 마찬가지로 Breadcrumb에도 onClick 이벤트 핸들러를 넣고, Breadcrumb에서는 클릭이 일어나면 클릭이 일어난 path의 index를 onClick 이벤트 핸들러에 넘겨주면 됩니다.

    실제 처리는 App.js에서 하고요.

    // Breadcrumb.js
    export default function Breadcrumb({ $app, initialState = [], onClick }) {
      // 기존 코드   
      this.onClick = onClick
    
      this.render = () => {
        this.$target.innerHTML = `<div class="nav-item">root</div>${
          this.state.map((node, index) => 
            `<div class="nav-item" data-index="${index}">${node.name}</div>`).join('')
        }`
      }
    
      // 여기서도 이벤트 위임을 이용합니다.
      this.$target.addEventListener('click', (e) => {
        const $navItem = e.target.closest('.nav-item')
    
        if ($navItem) {
          const { index } = $navItem.dataset
          this.onClick(index ? parseInt(index, 10) : null)
        }
      })
    
      this.render()
    }

    App.js에는 이렇게 처리하면 되겠죠?

    // App.js
    
    const breadcrumb = new Breadcrumb({
      $app,
      initialState: [],
      onClick: (index) => {
        if (index === null) {
          this.setState({
            ...this.state,
            depth: [],
            nodes: cache.root
          })
          return
        }
    
        // breadcrumb에서 현재 위치를 누른 경우는 무시함
        if (index === this.state.depth.length - 1) {
          return
        }
    
        const nextState = { ...this.state }
        const nextDepth = this.state.depth.slice(0, index + 1)
    
        this.setState({
          ...nextState,
          depth: nextDepth,
          nodes: cache[nextDepth[nextDepth.length - 1].id]
        })
      }
    })

    정리

    위의 구현을 통해 생성된 JS 파일들은 아래와 같습니다.

    • index.js : 애플리케이션의 시작점이며 App을 생성
    • App.js: App 컴포넌트
    • Nodes.js: Nodes 컴포넌트
    • Breadcrumb.js: Breadcrumb 컴포넌트
    • ImageView.js: ImageView 컴포넌트
    • Loading: Loading 컴포넌트
    • api.js: fetch 관련 유틸리티 함수

    맺으며

    간단해보이는 문제이지만, 이처럼 생각해볼 부분이 많은 문제입니다. 애플리케이션의 상태를 추상화하고, 이 상태를 기반으로 애플리케이션의 전반적인 부분을 렌더링하면서 상태의 변경으로 렌더링이 변경되는 방식으로 표현했습니다.

    눈치 채신 분들도 계시겠지만, 최근 유행하고 있는 React, Vue.js, Angular 등이 요러한 방식으로 화면을 표현합니다. 화면을 직접 렌더링하는 게 아니라, 어떠한 상태값을 정의하고 상태가 변경이 될 때마다 UI가 업데이트 되는 방식이지요. 이 흐름을 잘 이해하고 계신 분이라면 위에서 열거한 라이브러리/프레임워크를 쉽게 이해하고 쓰실 수 있게 되실 것입니다.

    Dev-Mathing은 개발자들과 유수의 기업을 이어주는 프로그래머스의 채용 프로그램입니다. 지원자는 하나의 이력서로 최대 5개의 관심 있는 포지션을 선택하여 지원할 수 있으며, 이력서와 테스트 점수가 함께 기업에 전달되어 지원자를 더욱 주목받게 도와주는 프로그램입니다. 
    자세한 내용이 궁금하시다면 지난 2021 Dev-Matching 웹 프론트엔드(상반기) 내용을 한번 확인해보세요!

    >> 지난 2021 Dev-Matching 웹 프론트엔드(상반기) 내용 보러가기 <<

     

    댓글

Programmers