ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • '2021 Dev-Matching: 웹 프론트엔드 개발자(하반기)' 기출 문제 해설
    취업 이야기 2021. 11. 1. 18:26

    프로그래머스에서는 지난 2021년 9월 4일 '2021 Dev-Matching: 프론트엔드 개발자(하반기)'의 과제 테스트가 진행되었습니다. 과제 리뷰가 제공되지 않지만, 어떻게 하면 구현을 더 잘할 수 있었을까? 고민하신 분들이 많을 거로 생각해요. 과제 출제자가 작성한 해설을 보고 나의 코드를 발전시켜보세요 :)


    문제 해설

    생각해보기

    이번 데브매칭 문제는 별도의 라이브러리 없이 SPA를 구현하는 문제였습니다. SPA를 구성하는 부분에서 중요한 것이 URL 라우팅 처리인데, 대부분 라이브러리나 프레임워크 차원에서 기본적으로 지원을 많이 하기 때문에 직접 구현해볼 일이 크게 없었을 것입니다.

    이 문제를 풀기 위해 어떻게 접근해야 하는지, 어떤 방식으로 생각해보면 좋을지 한번 같이 알아볼까요?

    구현하기

    구조

    URL 라우팅 처리하기

    가장 먼저 생각해보고 고민해볼 것은, URL 라우팅 처리를 하는 방법입니다. 이를 처리할 수 있는 간단한 방법은 location.pathname을 이용해 URL별로 분기를 태우는 것입니다.

    요구사항에 보시면 URL별로 아래의 페이지를 처리하라고 되어있을 텐데,

    • / : 상품 목록 페이지
    • /products/:productId : 상품 상세 페이지
    • /cart : 장바구니 페이지

    위의 규칙을 location.name을 이용해 렌더링 해야 한다면 아래와 같은 로직을 만들 수 있습니다.

    const { pathname } = location  
    if (pathname === '/') {
      // 상품 목록 페이지 렌더링하기
    } else if (pathname.indexOf('/products/') === 0) {
      const [, , productId] = pathname.split('/')
      // 상품 상세 페이지 렌더링하기
    } else if (pathname === '/cart') {
      // 장바구니 페이지 렌더링하기
    }

    각 페이지를 렌더링 하는 컴포넌트를 만든다고 하면, 아래와 같이 3개의 컴포넌트로 표현할 수 있습니다.

    그리고 맨 앞에 App 컴포넌트를 두고 URL 라우팅에 따라 위 세 가지 컴포넌트를 렌더링 하게 하면 아래와 같은 구조가 될 것입니다.

    이렇게 하여 URL 라우팅의 책임을 App 컴포넌트가 지게 하고, 해당 책임에 따른 동작에 맞게 알맞은 페이지를 렌더링 하는 구조라면 각 페이지는 서로에 대한 의존성 없이 동작할 수 있을 것입니다.

    App 컴포넌트와 Page 컴포넌트

    위의 다이어그램을 실제 코드로 표현해보도록 하겠습니다.

    먼저 각 페이지를 렌더링하는 컴포넌트를 아래와 같이 작성합니다.

    // ProductListPage.js
    export default function ProductListPage({ $target }) {
      const $page = document.createElement('div')
      $page.className = 'ProductListPage'
    
      $page.innerHTML = '<h1>상품 목록</h1>'
    
      this.render = () => {
        $target.appendChild($page)
      }
    }

    그리고 위의 ProductListPage를 렌더링 해볼까요?

    // App.js
    
    import ProductListPage from './ProductListPage.js'
    
    export default function App({ $target }) {
      this.route = () => {
        const { pathname } = location
    
        $target.innerHTML = ''
    
        if (pathname === '/') {
          new ProductListPage({ $target }).render()
        }
      }
    
      this.route()
    }

    그리고 App 컴포넌트를 새로 만드는 index.js를 작성합니다.

    // index.js
    
    import App from './App.js'
    
    new App({ $target: document.querySelector('.App') })

    index.html은 아래와 같이 수정합니다.

    // index.html
    <html>
      <head>
        <title>커피캣 스토어</title>
        <link rel="stylesheet" href="/styles.css">
      </head>
      <body>
        <main class="App"></main>
        <script src="/src/index.js" type="module"></script>
      </body>
    </html>

    로컬 서버 실행 후 루트 경로로 접속해보면 아래와 같이 렌더링 되는 것을 확인할 수 있습니다.

    실제 페이지 내 구현하기에 앞서, 나머지 페이지도 만들어서 연결해보도록 합니다.

    // ProductDetailPage.js
    
    export default function ProductDetailPage({ $target, productId }) {
      this.state = {
        productId
      }
      const $page = document.createElement('div')
      $page.className = 'ProductDetailPage'
    
      $page.innerHTML = '<h1>상품 정보</h1>'
    
      this.render = () => {
        $target.appendChild($page)
      }
    }
    // CartPage.js
    
    export default function CartPage({ $target }) {
      const $page = document.createElement('div')
      $page.className = 'CartPage'
    
      $page.innerHTML = '<h1>장바구니</h1>'
    
      this.render = () => {
        $target.appendChild($page)
      }
    }

    이후, App 컴포넌트의 route 코드를 아래처럼 변경합니다.

    // App.js
    import ProductListPage from './ProductListPage.js'
    import ProductDetailPage from './ProductDetailPage.js'
    import CartPage from './CartPage.js'
    
    export default function App({ $target }) {
      this.route = () => {
        const { pathname } = location
    
        $target.innerHTML = ''
    
        if (pathname === '/') {
          new ProductListPage({ $target }).render()
        } else if (pathname.indexOf('/products/') === 0) {
          const [, , productId] = pathname.split('/')
          new ProductDetailPage({
            $target,
            productId
          }).render()
        } else if (pathname === '/cart') {
          new CartPage({
            $target
          }).render()
        }
      }
    
      this.route()
    }

    URL을 변경해가면서 접속하면, URL에 따라 렌더링 되는 페이지가 달라지는 것을 확인할 수 있습니다.

    우선 기본적인 routing 처리는 끝났습니다.


    상품 목록 페이지 구현하기

    각 페이지를 나누었으니 이젠 페이지별 구현을 할 차례입니다. 가장 먼저 상품 목록 페이지를 구현합니다.

    상품 목록 API 연동하기

    API 연동에 앞서, 반복되는 코드 방지와 예외처리를 위해 아래와 같이 request 함수를 만들어서 쓰도록 합시다.

    // api.js
    
    const API_END_POINT = API_END_POINT
    
    export const request = async (url, options = {}) => {
      try {
        const fullUrl = `${API_END_POINT}${url}`
        const response = await fetch(fullUrl, options)
    
        if (response.ok) {
          const json = await response.json()
          return json;
        }
        throw new Error('API 통신 실패')
      } catch (e) {
        alert(e.message);
      }
    }

    이후, ProductListPage에 아래와 같이 함수를 추가합니다.

    // ProductListPage.js
    
    import { request } from "./api.js"
    
    export default function ProductListPage({ $target }) {
      ... 이전 구현 생략
      // 
      this.setState = (nextState) => {
        this.state = nextState   
      }
    
      const fetchProducts = async () => {
        const products = await request('/products')
        this.setState(products)
      }
    
      // 페이지 생성 시 API 요청해오도록 하는 처리
      fetchProducts()
    }

    이렇게 하면, new ProductListPage를 통해 ProductListPage가 생성이 되면 fetchProducts 함수를 실행하면서 this.state 에 불러온 상품 목록을 가지고 있게 될 것입니다.

    상품 목록도 불러왔으니 이제 상품 목록을 렌더링 할 차례인데, 해당 렌더링 코드를 ProductListPage 내에서 작성해도 좋지만 ProductList라는 컴포넌트를 정의하고, 이 컴포넌트로 상품 목록을 렌더링 해보도록 합시다.

    // ProductList.js
    export default function ProductList({ $target, initialState }) {
      const $productList = document.createElement('ul')
      $target.appendChild($productList)
    
      this.state = initialState
    
      this.setState = nextState => {
        this.state = nextState
        this.render()
      }
    
      this.render = () => {
        if (!this.state) {
          return
        }
        $productList.innerHTML = `
          ${this.state.map(product =>
            `
              <li class="Product">
                <img src="${product.imageUrl}">
                <div class="Product__info">
                  <div>${product.name}</div>
                  <div>${product.price}~</div>
                </div>
              </li>
            `
        ).join('')}`
      }
    
      this.render()
    }

    그리고 ProductListPage 컴포넌트에서 ProductList 컴포넌트를 생성하도록 합니다.

    // ProductListPage.js
    import ProductList from "./ProductList.js"
    import { request } from "./api.js"
    
    export default function ProductListPage({ $target }) {
      ... 이전 코드 생략
      const fetchProducts = async () => {
        const products = await request('/products')
        this.setState(products)
      }
    
      const productList = new ProductList({
        $target: $page,
        iniitalState: this.state
      })
    
      fetchProducts()
    }

    ProductList 내에서도 다른 페이지 컴포넌트와 마찬가지로 $target을 생성자 파라미터로 받아 해당 DOM Element에 렌더링 하도록 되어있는 구조인데, 여기에 페이지 컴포넌트의 DOM Element를 넘김으로써 해당 페이지 내에 ProductList의 render 함수 내용이 렌더링 되도록 합니다.

    이제 렌더링이 잘 되는지 확인합니다.

    현재 컴포넌트는 아래와 같은 구조와 관계를 가집니다.


    페이지 이동 처리하기

    상품 목록 페이지를 만들었으니 이제 상품 상세 페이지를 만들어 볼 차례입니다.

    그러려면 상품 목록의 상품을 클릭했을 때 상품 상세 페이지로 이동이 되게 만들어야 합니다.

    가장 단순한 방법은 a 태그를 이용한 방법입니다.

    ProductList 컴포넌트의 render 함수 내에 아래와 같이 a 태그를 넣습니다.

    // ProductList.js
    export default function ProductList({ $target, initialState }) {
      ... 이전 코드 생략
      this.render = () => {
        if (!this.state) {
          return
        }
        $productList.innerHTML = `
          ${this.state.map(product =>
            `
              <li class="Product">
                <a href="/products/${product.id}">
                  <img src="${product.imageUrl}">
                  <div class="Product__info">
                    <div>${product.name}</div>
                    <div>${product.price}~</div>
                  </div>
                </a>
              </li>
            `
        ).join('')}`
      }
    
      this.render()
    }

    이것만으로 페이지 이동은 잘 동작하지만, 이렇게 하면 페이지 이동 시마다 페이지를 매번 새로 불러오게 됩니다. Single Page Application이라는 말을 잘 생각해보면, 이런 식으로 매번 페이지를 새로 불러오는 방식이 아니라 클라이언트에서 페이지가 변경되는 부분만 새로 그리도록 처리를 해야 하는 것을 떠올릴 수 있을 것입니다.

    이를 해결하기 위해서 알아야 하는 것은 HTML History API입니다. 해당 API에 대해서 잘 모르시는 분들을 위해 https://developer.mozilla.org/ko/docs/Web/API/History_API 링크를 첨부합니다.

    기본적으로 location.href 등을 이용해 URL 변경 처리를 하면, 브라우저는 해당 페이지로 이동하면서 페이지를 새로 불러오게 됩니다. history.pushState를 이용하면 URL만 업데이트하면서 웹 브라우저의 기본적인 페이지 이동 처리가 되는 것을 방지할 수 있습니다.

    이제 페이지 이동 처리 구현을 위한 준비는 끝났습니다. 화면 새로고침 없이 다른 페이지로 이동 처리를 하려면 아래 두 가지만 기억하시면 됩니다.

    1. 이동할 페이지 URL을 history.pushState를 통해 변경함
    2. App.js의 this.route 함수를 실행시킴

    pushState를 통해 URL이 변경되는 것을 감지하기 위해서는 여러 가지 방법이 있겠지만, 여기에선 커스텀 이벤트를 통해서 처리해보도록 할게요.

    // router.js
    const ROUTE_CHANGE_EVENT = 'ROUTE_CHANGE'
    
    // 커스텀 이벤트를 통해 ROUTE_CHANGE 이벤트 발생 시 onRouteChange 콜백 함수를 호출하도록 이벤트를 바인딩.
    export const init = (onRouteChange) => {
      window.addEventListener(ROUTE_CHANGE_EVENT, () => {
        onRouteChange()
      })
    }
    
    // URL을 업데이트하고 커스텀 이벤트를 발생시키는 함수
    export const routeChange = (url, params) => {
      history.pushState(null, null, url)
      window.dispatchEvent(new CustomEvent(ROUTE_CHANGE_EVENT, params ))
    }

    그리고 App.js 내에 있는 this.route 함수와 router.js의 init 함수를 연결합니다.

    // App.js
    import ProductListPage from './ProductListPage.js'
    import ProductDetailPage from './ProductDetailPage.js'
    import CartPage from './CartPage.js'
    import { init } from './router.js'
    
    export default function App({ $target }) {
      .. 이전 코드 생략 
    
      // ROUTE_CHANGE 이벤트 발생 시 마다 App의 this.route 함수가 호출되게 하는 효과
      init(this.route)
    
      this.route()
    
      .. 코드 생략
    }

    이제 ProductList에서 a 태그로 이동시키는 대신에, 아래 코드처럼 onClick 콜백 함수를 통해 처리하도록 변경합니다.

    ProductList 컴포넌트를 아래와 같이 수정합니다.

    import { routeChange } from "./router.js"
    
    export default function ProductList({ $target, initialState }) {
      .. 코드 일부 생략
      this.render = () => {
        if (!this.state) {
          return
        }
        $productList.innerHTML = `
          ${this.state.map(product =>
            `
              <li class="Product" data-product-id="${product.id}">
                <img src="${product.imageUrl}">
                <div class="Product__info">
                  <div>${product.name}</div>
                  <div>${product.price}~</div>
                </div>
              </li>
            `
        ).join('')}`
      }
    
      this.render()
    
      $productList.addEventListener('click', (e) => {
        const $li = e.target.closest('li')
        const { productId } = $li.dataset
    
        if (productId) {
          routeChange(`/products/${productId}`)
        }
      })
    }

    data-product-id라는 이름으로 custom attribute를 만들고, event delegation을 통해 productId를 뽑아와서 routeChange 함수를 통해 URL 변경을 처리합니다.

    이제 상품을 클릭하면, 화면 리로드 없이 페이지가 이동되는 것을 알 수 있습니다.

    뒤로 가기 처리

    이제 상품 목록에서 상품 상세 페이지로 이동은 잘 되지만, 뒤로 가기를 했을 때(백스페이스를 누르거나 브라우저에서 뒤로가기 버튼을 누르는 등)는 렌더링이 잘 안 되는 것을 확인할 수 있습니다.

    이런 경우, window의 popstate 이벤트를 통해 처리할 수 있습니다. 위 이벤트를 통해 뒤로 가기나 앞으로 가기 등으로 통해 브라우저의 URL이 변경된 경우를 감지할 수 있습니다.

    App.js에 아래 한 줄을 추가합니다.

    // App.js
    import ProductListPage from './ProductListPage.js'
    import ProductDetailPage from './ProductDetailPage.js'
    import CartPage from './CartPage.js'
    import { init } from './router.js'
    
    export default function App({ $target }) {
      .. 이전 코드 생략
      // 뒤로가기, 앞으로가기 발생 시 popstate 이벤트가 발생합니다.
      window.addEventListener('popstate', this.route)
    }

    이제 SPA의 URL routing 처리를 위한 기반 작업은 거의 끝났습니다. 이제 남은 작업은 남은 두 페이지를 구현하는 것입니다.


    상품 상세 페이지 구현하기

    상품 상세 페이지에선 크게 세 가지 기능을 구현해야 합니다.

    • 상품 ID에 맞는 상품을 불러오고, 상품과 옵션을 렌더링
    • 상품의 옵션을 선택할 수 있도록 처리
    • 상품을 선택하고 주문하기를 누르면, localStorage에 주문한 상품을 저장하고 장바구니 페이지로 이동

    하나하나 살펴보도록 합시다.

    상품ID에 맞는 상품을 불러오고, 상품과 옵션을 렌더링

    ProductListPage 구현 시와 마찬가지로, ProductDetailPage를 만들고 데이터를 불러오는 함수를 만들도록 합니다.

    기존 코드에서 this.state를 이미 만들었으니 여기에 product라는 키를 추가합니다.

    // ProductDetailPage.js
    import { request } from './api.js'
    export default function ProductDetailPage({ $target, productId }) {
      this.state = {
        productId,
        product: null
      }
    
      .. 일부 코드 생략
      this.setState = nextState => {
        this.state = nextState
        this.render()
      }
    
      this.render = () => {
        if (!this.state.product) {
          $target.innerHTML = 'Loading..'
        } else {
          $target.innerHTML = ''
          $target.appendChild($page)
          // ProductDetail 렌더링하기
    
        }
      }
    
      this.fetchProduct = async () => {
        const { productId } = this.state
        const product = await request(`/products/${productId}`)
        this.setState({
          ...this.state,
          product
        })
      }
      this.fetchProduct()
    }

    위의 ProductList로 분리하여 상품 목록을 렌더링을 했듯이, ProductDetail 컴포넌트를 만들어서 처리합니다.

    이렇게 할 경우, ProductDetailPage 외에도 상품 상세 화면을 그려야 할 때 쉽게 재사용할 수 있습니다.

    아래의 코드를 참고하여 ProductDetail 컴포넌트를 만듭니다.

    // ProductDetail.js
    export default function ProductDetail({ $target, initialState }) {
      const $productDetail = document.createElement('div')
      $productDetail.className = 'ProductDetail'
    
      $target.appendChild($productDetail)
    
      this.state = initialState
    
      this.setState = nextState => {
        this.state = nextState
    
        this.render()
      }
    
      this.render = () => {
        const { product } = this.state
    
        $productDetail.innerHTML = `
          <img src="${product.imageUrl}">
          <div class="ProductDetail__info">
            <h2>${product.name}</h2>
            <div class="ProductDetail__price">${product.price}원~</div>
            <select>
              <option>선택하세요.</option>
              ${product.productOptions.map(option =>
                `
                  <option value="${option.id}" ${option.stock === 0 ? 'disabled' : ''}>
                    ${option.stock === 0 ? '(품절) ' : ''}${product.name} ${option.name} ${option.price > 0 ? `(+${option.price}원)` : ''}
                  </option>
                `).join('')}
            </select>
            <div class="ProductDetail__selectedOptions"></div>
          </div>
        `
      }
    
      this.render()
    }

    상품 옵션 선택하기

    이제 상품 옵션을 선택하면 해당 상품이 선택되고, 수량을 변경하는 기능을 만들 차례입니다. ProductDetail 코드 내에서 구현을 해도 되는데 이 글에선 SelectedOptions라는 컴포넌트로 구현해보도록 하겠습니다.

    먼저 ProductDetailPage에서 ProductDetail 컴포넌트를 만드는 코드에 아래 한 줄을 추가합니다.

    // ProductDetailPage.js
    .. import 생략
    
    export default function ProductDetailPage({ $target, productId }) {
      .. 코드 생략
      this.render = () => {
        if (!this.state.product) {
          $target.innerHTML = 'Loading..'
        } else {
          $target.innerHTML = ''
          $target.appendChild($page)
    
          new ProductDetail({
            $target: $page,
            initialState: {
              product: this.state.product,
              // ProductDetail의 initialState에 선택된 상품들을 담아둘 selectedOptions 추가
              selectedOptions: []
            }
          })
        }
      }
      .. 코드 생략
    }

    먼저, 상품을 선택했을 때의 처리를 해야 합니다. ProductDetail 안에 아래의 이벤트 코드를 넣습니다.

    // ProductDetail.js
    export default function ProductDetail({ $target, initialState }) {
      .. 코드 생략
      this.render()
    
      // 이벤트 바인딩 코드
      // 이벤트 위임 기법을 이용해 이벤트 자체는 ProductDetail 최상위의 div에서 처리합니다.
      $productDetail.addEventListener('change', e => {
        // 이벤트 발생 주체가 select 태그인 경우에만
        if (e.target.tagName === 'SELECT') {
          // 상품 옵션을 나타내는 option의 value에는 optionId를 담고 있습니다.
          // 이를 가져와서 숫자값을 바꿉니다.
          const selectedOptionId = parseInt(e.target.value)
          const { product, selectedOptions } = this.state
          // 상품의 옵션 데이터에서 현재 선택한 optionId가 존재하는지 찾습니다.
          const option = product.productOptions.find(option => option.id === selectedOptionId)
          // 이미 선택한 상품인지 선택된 상품 데이터에서 찾아봅니다.
          const selectedOption = selectedOptions.find(selectedOption => selectedOption.optionId === selectedOptionId)
    
          // 존재하는 옵션이고 선택된 옵션이 아닌 경우에만 selectedOptions에 현재 선택한
          // 옵션을 추가합니다.
          if (option && !selectedOption) {
            const nextSelectedOptions = [
              ...selectedOptions,
              {
                productId: product.id,
                optionId: option.id,
                optionName: option.name,
                optionPrice: option.price,
                quantity: 1
              }
            ]
            this.setState({
              ...this.state,
              selectedOptions: nextSelectedOptions
            })
          }
        }
      })  
    }

    다음으로, ProductDetail의 state 중 selectedOptions를 그려주기 위한 컴포넌트인 SelectedOptions.js를 생성합니다.

    // SelectedOptions.js
    export default function SelectedOptions({ $target, initialState }) {
      const $component = document.createElement("div")
      $target.appendChild($component)
    
      this.state = initialState
    
      // 상품가격 총합 구하기
      this.getTotalPrice = () => {
        const { product, selectedOptions } = this.state
        const { price: productPrice } = product
    
        return selectedOptions.reduce(
          (acc, option) => acc + ((productPrice + option.optionPrice) * option.quantity), 0)
      }
    
      this.setState = (nextState) => {
        this.state = nextState
        this.render()
      }
    
      this.render = () => {
        const { product, selectedOptions = [] } = this.state
    
        if (product && selectedOptions) {
          $component.innerHTML = `
            <h3>선택된 상품</h3>
            <ul>
              ${selectedOptions.map(selectedOption => `
                <li>
                  ${selectedOption.optionName} ${product.price + selectedOption.optionPrice}원
                  <input type="text" data-optionId="${selectedOption.optionId}" value="${selectedOption.quantity}">
                </li>
              `).join('')}
            </ul>
            <div class="ProductDetail__totalPrice">${this.getTotalPrice()}원</div>
            <button class="OrderButton">주문하기</button>
          `
        }
      }
      this.render()
    }

    마지막으로 ProductDetail에서 SelectedOptions 컴포넌트를 생성하여 사용하도록 하겠습니다.

    // ProductDetail.js
    import SelectedOptions from "./SelectedOptions.js"
    
    export default function ProductDetail({ $target, initialState }) {
      .. 이전코드 생략
      // fetchProduct 이후 화면이 렌더링 되었을 때 동작할 수 있도록
      // let으로 생성만 해둡니다.
      let selectedOptions = null
    
      this.setState = nextState => {
        this.state = nextState
    
        this.render()
    
        if (selectedOptions) {
          selectedOptions.setState({
            selectedOptions: this.state.selectedOptions
          })
        }
      }
    
      this.render = () => {
        const { product } = this.state
    
        $productDetail.innerHTML = `
          <img src="${product.imageUrl}">
          <div class="ProductDetail__info">
            <h2>${product.name}</h2>
            <div class="ProductDetail__price">${product.price}원~</div>
            <select>
              <option>선택하세요.</option>
              ${product.productOptions.map(option =>
                `
                  <option value="${option.id}" ${option.stock === 0 ? 'disabled' : ''}>
                    ${option.stock === 0 ? '(품절) ' : ''}${product.name} ${option.name} ${option.price > 0 ? `(+${option.price}원)` : ''}
                  </option>
                `).join('')}
            </select>
            <div class="ProductDetail__selectedOptions"></div>
          </div>
        `
    
        selectedOptions = new SelectedOptions({
          $target: $productDetail.querySelector('.ProductDetail__selectedOptions'),
          initialState: {
            product: this.state.product,
            selectedOptions: this.state.selectedOptions
          }
        })
      }
    
      this.render()
    
      // ..이후 코드 생략
    }

    여기까지 진행했다면 이제 아래와 같은 화면을 만날 수 있습니다.

    현재 컴포넌트 간의 관계는 아래와 같습니다.

    상품 수량 변경할 수 있게 만들기

    SelectedOptions의 input 내 값을 변경하여 수량을 변경할 수 있게 만들어보도록 하겠습니다.

    이 부분 역시 이벤트 위임을 이용하면 이벤트 하나로 처리가 가능합니다.

    // SelectedOptions.js
    export default function SelectedOptions({ $target, initialState }) {
      .. 이전 코드 생략
      this.render()
    
      $component.addEventListener('change', e => {
        // 이벤트가 INPUT 태그에서 발생한 경우에만 처리
        if (e.target.tagName === 'INPUT') {
          try {
            const nextQuantity = parseInt(e.target.value)
            const nextSelectedOptions = [ ...this.state.selectedOptions ]
            // input의 값이 숫자인 경우에만 처리하기
            if (typeof nextQuantity === 'number') {
              const { product } = this.state
    
              const optionId = parseInt(e.target.dataset.optionid)
              const option = product.productOptions.find(option => option.id === optionId)
              const selectedOptionIndex = nextSelectedOptions.findIndex(selectedOption => selectedOption.optionId === optionId)
              // INPUT에 입력한 값이 재고수량을 넘을 경우 재고수량으로 입력한 것으로 바꿔버리기
              nextSelectedOptions[selectedOptionIndex].quantity = option.stock >= nextQuantity ? nextQuantity : option.stock
    
              this.setState({
                ...this.state,
                selectedOptions: nextSelectedOptions
              })
            }
          } catch (e) {
            console.log(e)
          }
        }
      })

    화면 깜빡임 방지하기

    지금 코드 구조에서는 상품을 선택할 때마다 setState가 호출이 되고, render가 계속해서 호출되면서 상품 상세 화면이 깜빡이고 있는데, 이는 플래그 변수 하나를 두어서 초기 1회에만 렌더링이 되도록 처리하면 쉽게 해결할 수 있습니다.

    // ProductDetail.js
    export default function ProductDetail({ $target, initialState }) {
      let isInitialized = false
      ... 중간 코드 생략
    
      this.render = () => {
        const { product } = this.state
    
        // 아래 코드는 1회만 실행됩니다.
        if (!isInitialized) {
          $productDetail.innerHTML = `
            .. HTML 렌더링 코드 생략
          `
          selectedOptions = new SelectedOptions({
            $target: $productDetail.querySelector('.ProductDetail__selectedOptions'),
            initialState: {
              product: this.state.product,
              selectedOptions: this.state.selectedOptions
            }
          })
          isInitialized = true
        }
      }
      .. 코드 생략
    }

    혹은 insertAdjacentHTML 등을 이용하는 방법도 있겠습니다.

    주문하기 클릭 시 동작 처리하기

    이제 상품을 선택할 수 있고 수량도 변경할 수 있으니 이제 주문하기 버튼을 눌렀을 때의 동작을 처리해줄 차례입니다.

    SelectedOptions 컴포넌트 내에 주문하기 버튼이 있으니, 해당 컴포넌트에서 처리하도록 합니다.

    먼저 localStorage를 다루기 위한 유틸리티 함수들을 만듭니다.

    localStorage는 그대로 다루었다간 에러를 만날 여러 가지 케이스가 있기 때문에, 안전을 위해 여러가지 처리를 해둔 함수 형태로 감싸서 쓰는 것이 좋습니다.

    // storage.js
    export const storage = localStorage
    
    export const getItem = (key, defaultValue) => {
      try {
        const value = storage.getItem(key)
        // key에 해당하는 값이 있다면 parsing하고, 없으면 defaultValue 리턴
        return value ? JSON.parse(value) : defaultValue
      } catch {
        // parsing 하다 에러가 생기면 defaultValue 리턴
        return defaultValue
      }
    }
    
    export const setItem = (key, value) => {
      try {
        storage.setItem(key, JSON.stringify(value))
      } catch {
        // ignore
      }
    }
    
    export const removeItem = (key) => {
      try {
        storage.removeItem(key)
      } catch {
        // ignore
      }
    }

    이제 위에서 만든 getItem과 setItem을 사용하여 localStorage에 장바구니 데이터를 만듭니다.

    // SelectedOptions.js
    import { getItem, setItem } from './storage.js'
    import { routeChange} from './router.js'
    
    export default function SelectedOptions({ $target, initialState }) {
      .. 이전 코드 생략
      $component.addEventListener('click', (e) => {
        const { selectedOptions } = this.state
        if (e.target.className === 'OrderButton') {
                // 기존에 담겨진 장바구니 데이터가 있을 수 있으므로 가져와보고 없으면 빈배열 처리
          const cartData = getItem('products_cart', [])
          // 장바구니 데이터 만들기      
          setItem('products_cart', cartData.concat(selectedOptions.map(selectedOption => ({
            productId: selectedOption.productId,
            optionId: selectedOption.optionId,
            quantity: selectedOption.quantity
          }))))
    
          routeChange('/cart')
        }
      })
    }

    이제 마지막으로 장바구니 페이지를 구현하면 완성입니다.


    장바구니 페이지 구현

    장바구니 데이터가 없을 경우 튕겨내는 처리하기

    요구사항에 보시면, 장바구니 데이터가 없을 경우 장바구니가 비어있음을 알리는 alert 출력 뒤 상품 목록 페이지로 가도록 하게 되어있습니다. 이 부분을 먼저 구현해봅니다.

    이전 기능들을 테스트하면서 장바구니에 이미 상품 데이터가 들어있는 경우에는 chrome 개발자 도구에서 삭제할 수 있습니다.

    CartPage의 코드를 아래와 같이 수정합니다.

    // CartPage.js
    import { getItem } from './storage.js'
    import { routeChange } from './router.js'
    
    export default function CartPage({ $target }) {
      const $page = document.createElement('div')
      $page.className = 'CartPage'
    
      $page.innerHTML = '<h1>장바구니</h1>'
    
      const cartData = getItem('products_cart', [])
      this.state = {
        products: null 
      }
    
      this.render = () => {
        if (cartData.length === 0) {
          alert('장바구니가 비어있습니다.')
          routeChange('/')
        } else {
          $target.appendChild($page)
        }
      }
    }

    이제 장바구니 데이터를 비운 채로 /cart 페이지에 접근하면

    이러한 alert가 뜨고, 상품 목록 화면으로 이동하는 것을 확인할 수 있습니다.

    장바구니의 상품들 불러오기

    다음으로 할 일은 local storage에서 꺼내온 상품 데이터를 통해 상품 옵션을 불러오는 일인데요. 위의 두 페이지의 경우와는 다르게 이번에는 상품 옵션의 종류만큼 API를 호출해야 합니다.

    이런 경우 Promise.all과 async, await를 잘 이용하면 간결하게 처리할 수 있습니다.

    import { request } from './api.js'
    import { getItem } from './storage.js'
    import { routeChange } from './router.js'
    
    export default function CartPage({ $target }) {
      .. 중간 코드 생략
      this.setState = (nextState) => {
        this.state = nextState
        this.render()
      }
    
      this.fetchProducts = async () => {
        const products = await Promise.all(cartData.map(async (cartItem) => {
          const product = await request(`/products/${cartItem.productId}`)
          const selectedOption = product.productOptions.find(option => option.id === cartItem.optionId)
    
          return {
            imageUrl: product.imageUrl,
            productName: product.name,
            quantity: cartItem.quantity,
            productPrice: product.price,
            optionName: selectedOption.name,
            optionPrice: selectedOption.price
          }
        }))
    
        this.setState({ products })
      }
    
      this.fetchProducts()
    }

    이제 장바구니에 그릴 데이터가 준비되었습니다.

    Cart.js 만들기

    ProductListPage, ProductDetailPage와 마찬가지로 실제 장바구니를 그리는 것은 Cart 컴포넌트에서 처리하도록 하겠습니다. 이렇게 되면 Page 역할을 하는 컴포넌트들에서는 초기 렌더링을 위한 준비와 데이터만 준비하고, 실제 렌더링은 각 하위 컴포넌트에서 진행하는 형태가 됩니다.

    // Cart.js
    export default function Cart({ $target, initialState }) {
      const $component = document.createElement('div')
      $component.className = 'Cart'
      this.state = initialState
    
      $target.appendChild($component)
    
      this.setState = nextState => {
        this.state = nextState
        this.render()
      }
    
      this.getTotalPrice = () => {
        return this.state.reduce(
            (acc, option) => acc + ((option.productPrice + option.optionPrice) * option.quantity),
          0)
      }
    
      this.render = () => {
        $component.innerHTML = `
          <ul>
            ${this.state.map(cartItem => `
              <li class="Cart__item">
                <img src="${cartItem.imageUrl}">
                <div class="Cart__itemDescription">
                  <div>${cartItem.productName} ${cartItem.optionName} ${cartItem.quantity}개</div>
                  <div>${cartItem.productPrice + cartItem.optionPrice}원</div>
                </div>
              </li>
            `).join('')}
          </ul>
          <div class="Cart__totalPrice">
            총 상품가격 ${this.getTotalPrice()}원
          </div>
          <button class="OrderButton">주문하기</button>
        `
        return $component
      }
    
      this.render()
    }

    이제 위의 컴포넌트를 CartPage에서 데이터 로딩이 끝난 이후 생성이 되게 만듭니다.

    // CartPage.js
    import { request } from './api.js'
    import { getItem } from './storage.js'
    import { routeChange } from './router.js'
    import Cart from './Cart.js'
    
    export default function CartPage({ $target }) {
      .. 코드 생략
      let cartComponent = null
    
      this.render = () => {
        if (cartData.length === 0) {
          alert('장바구니가 비어있습니다.')
          routeChange('/')
        } else {
          $target.appendChild($page)
          // Cart 컴포넌트 생성
          if (this.state.products && !cartComponent) {
            cartComponent = new Cart({
              $target: $page,
              initialState: this.state.products
            })
          }
        }
      }
      .. 코드 생략
    }

    이제 장바구니의 아이템 렌더링까지 끝났습니다.

    현재 구현에선 여러 종류의 상품을 담을 수 있기 때문에, 다른 상품 페이지로 이동해서 장바구니에 담아두면 이렇게 여러 종류의 상품과 해당 옵션들이 렌더링 됩니다.

    주문하기 처리

    상품 상세 페이지의 주문하기 버튼을 눌렀을 때 처리와 마찬가지로, Cart.js 내에 아래의 이벤트 코드를 추가합니다.

    // Cart.js
    export default function Cart({ $target, initialState }) {
      ... 이전 코드 생략
    
      $component.addEventListener('click', e => {
        if (e.target.className === 'OrderButton') {
          alert('주문 되었습니다!')
          removeItem('products_cart')
          routeChange('/')
        }
      })
    }

    이제 주문하기를 클릭해볼까요?

    이렇게 alert가 뜬 이후에

    상품 목록 페이지로 이동합니다.

    localStorage의 장바구니 데이터를 비워줬기 때문에, /cart 페이지로 강제로 진입할 경우

    이런 메시지를 만날 수 있습니다.

    현재까지 작업된 전체적인 컴포넌트의 구조는 아래와 같습니다.

    맺으며

    그동안 SPA 방식의 개발이 유행하면서 다양한 라이브러리와 프레임워크들을 통해 사용을 해보셨을 텐데, 이를 다른 도구들의 도움 없이 직접 구현해보는 것도 나름대로 재미있는 경험이 되셨을 것이라 생각합니다.

    제가 이 문서를 통해 설명한 구조가 정답은 아니며, 더 좋은 구조와 더 간결한 구조의 코드가 있을 수 있습니다.

    읽어주셔서 감사합니다.


    프론트엔드 데브매칭 문제 다시 풀기

    프로그래머스 사이트 내에'실력 체크' > '과제관'>[프론트엔드] 쇼핑몰 SPA를 클릭하면 2021 Dev-Matching: 프론트엔드 개발자(하반기)에 출제된 과제를 풀 수 있습니다. 앱 과제 이외에도 '과제관'에는 내 실력을 체크할 수 있는 다양한 문제가 준비되어있어요!

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

    2021 하반기 프론트엔드 데브매칭이 궁금하다면?

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

    댓글 1

    • grepp 2021.11.02 14:47 신고

      안녕하세요! 블로그 관리자입니다. :) 문제 풀이 과정에 대해 댓글에서 자유롭게 이야기를 나눌 수 있습니다. 다만, 추가적인 궁금증에 대해 프로그래머스에서 공식 답변을 드리지는 않습니다.

Programmers