본문 바로가기
개발/사이드 프로젝트

피드백 반영 업데이트 - 메뉴 등록 화면 개선

by Dahna 2022. 7. 4.

안녕하세요. 이번 주에는 사이드 프로젝트 '오늘 뭐 먹지?'를 오픈하고 주변 분들께 피드백을 받는 시간을 가졌습니다. 오늘은 피드백 사항 중, 메뉴 등록 화면의 불편 사항을 개선하는 업데이트를 진행하는 과정을 담아보려고 합니다. 

 

피드백 사항

  • 등록하기 화면에서 엔터를 누를 시 무조건 폼 제출이 되어서 불편했어요. (음식점 검색 인풋이 포커스되었을 때는 엔터했을 때 음식점을 검색하게 하는 건 어떨까요?)
  • 음식점을 검색하고 지정하는 과정이 힘들었어요. (음식점을 검색했을 때 지도 영역 주변에 모든 검색 정보를 리스트로 보여주고 클릭 시 해당되는 마커를 선택할 수 있는 영역이 있으면 좋겠어요)
  • 일부 음식점이 검색 시 나오지 않아서 불편했어요. (음식점 검색을 전반적으로 자동 완성 + 수동 지정을 할 수 있으면 어떨까요?)
  • 기본 프로필 사진이 있으면 좋겠어요. (기본값이 null이라 그런지, 작은 아이콘들이 비어있고 설명도 null로 나왔어요)
  • 식사 후 기록하는 평가는 맛있다/괜찮다/별로 인데, 이에 기반한 평점이 어떻게 나오는 건지, 얼마나 유용한 지 궁금해요. (평점 대신,  [ 일주일 동안 8명이 맛있다고 해주셨어요.] 같은 요소를 카드에 넣는 건 어떤가요?)
  • "" 탭에서 버튼 클릭시 메뉴로 이동되는 기능이 있으면 좋을 같아요.
  • 카카오톡 프로필이 NULL 되어있어요 ! (혹시나 해서 말씀드려요 :D)
  • 프로필 변경시 마다 이미지를 요청해서 소모가 되는데 debounce 대한 내용을 한번 확인해보세요 ~!! 
  • "회사/주변" 탭에서 회사 정보 클릭시 바로 코드가 복사 되고 알람이 표시 되면 좋을 같아요. 

+) 이 중에서 '프로필 변경시 마다 이미지를 요청하는 부분'은 파일 인풋 컴포넌트에서 파일 url 호출 메서드를 렌더링 함수 내부에 걸었더니 렌더링될 때 마다 메서드가 호출되어 발생하는 오류로 확인되었습니다. 파일 등록 시 url을 생성하여 등록해 주는 것으로 해결하였으며, 관련 내용은 추후 다루어 보려고 합니다!

 

먼저 직접 사용해주시고 피드백 주신 선배 개발자분들께(현욱님, 동현님) 감사드립니다! 이번 글에서는 이 중에서 메뉴를 등록하는 화면을 피드백 사항을 반영해서 개선해보는 과정을 담아보려고 합니다. 기존 화면을 소개하고, 문제 사항을 어떻게 개선할지 생각해보고, 이를 어떻게 개발할지 정리해보도록 하겠습니다.

 

기존 화면

기존 화면에서는 전체 폼 태그 안에 맛집 이름 검색 인풋이 들어있고, 맛집 이름 + 회사 위치 형태로 카카오맵에 맛집 검색을 하도록 단순하게 개발되어 있습니다. 따라서 위와 같은 불편사항이 발생했습니다.

 

개선 방향

  • 맛집이름과 카카오맵 정보를 연동해 등록하는 부분과 나머지 정보(게시글 제목, 카테고리, 설명)를 등록하는 부분을 분리하여 단계별로 구성한다.
  • 맛집 이름을 등록하는 부분도 form으로 처리하여 인풋 포커스, 엔터 제출 등을 지원한다.
  • 맛집 이름을 등록할때 회사 위치를 이용할지 선택 옵션을 추가한다.
  • 맛집 이름을 검색하면 검색된 맛집 목록이 화면 상에 출력되고, 리스트에서 맛집을 선택할 수 있게 한다.
  • 맛집이 검색되지 않으면 카카오맵을 사용하지 않는 옵션을 선택할 수 있로독 활성화시킨다. 
  • 맛집 선택이 완료되면 다음 단계로 넘어갈 수 있도록 구성한다. 

개발 계획

  • 공통 컴포넌트 Stepper를 구성한다.
  • 메뉴 등록 화면의 단계를 구성한다.
  • 카카오맵 검색 컴포넌트에 맛집 이름 검색 부분을 form으로 변경한다.
  • form에 회사 위치를 이용할지 선택 옵션을 추가한다.
  • 카카오맵 검색 컴포넌트에 검색 결과 목록을 보여주고 목록에서도 선택할 수 있게 한다.
  • 검색 결과가 없으면 카카오맵을 사용하지 않는 옵션을 활성화시킨다.
  • 맛집 선택이 완료되면 다음 단계로 넘어갈 수 있도록 구성한다.

맛집에 카카오맵 관련 옵션을 추가하려면 DB 구조 변경이 필요합니다. 맛집을 관리하는 Keyword 모델에 use_kakaomap, use_team_location 필드를 추가해 줍니다.

개발 과정은 테스트 케이스 작성 -> 컴포넌트 작성 -> 리팩토링의 순서로 진행합니다.

공통 컴포넌트 Stepper 작성

테스트 케이스

stepper 컴포넌트는 내부에 step 상태를 가지고, step 상태를 조작할 수 있는 버튼을 제공합니다. step 상태에 따라 자식 step 컴포넌트 중 현재 렌더링되는 컴포넌트를 결정합니다. step 상태는 초기값을 받을 수 있습니다.

import { render, screen, fireEvent } from '@testing-library/react'
import { Stepper } from '@/components'

describe('단계 컴포넌트', () => {
  it('Stepper 컴포넌트는 조합 컴포넌트로 내부 Stepper.Step컴포넌트를 단계에 따라 렌더링한다.', () => {
    const { getByRole } = render(
      <Stepper>
        <Stepper.Step>
          <div>step1</div>
        </Stepper.Step>
        <Stepper.Step>
          <div>step2</div>
        </Stepper.Step>
        <Stepper.Step>
          <div>step3</div>
        </Stepper.Step>
        <Stepper.Button text="이전" atctionType="prev" />
        <Stepper.Button text="다음" atctionType="next" />
      </Stepper>,
    )
    expect(screen.getByText('step1')).toBeInTheDocument()
    fireEvent.click(getByRole('button', { name: '다음' }))
    expect(screen.getByText('step2')).toBeInTheDocument()
    fireEvent.click(getByRole('button', { name: '이전' }))
    expect(screen.getByText('step1')).toBeInTheDocument()
  })
  it('Stepper 컴포넌트는 초기값을 받을 수 있다.', () => {
    render(
      <Stepper step={1}>
        <Stepper.Step>
          <div>step1</div>
        </Stepper.Step>
        <Stepper.Step>
          <div>step2</div>
        </Stepper.Step>
        <Stepper.Step>
          <div>step3</div>
        </Stepper.Step>
        <Stepper.Button text="이전" type="prev" />
        <Stepper.Button text="다음" type="next" />
      </Stepper>,
    )
    expect(screen.getByText('step2')).toBeInTheDocument()
  })
})

테스트를 만족하는 컴포넌트 작성

import React, {
  createContext,
  HTMLAttributes,
  Children,
  useContext,
  useState,
} from 'react'
import { ButtonProps, Button } from '@/components'

interface StepperContextValue {
  currentStep: number
  setStep?: Function
  pageLength: number
}

const StepperContextInitialValue = {
  currentStep: 0,
  pageLength: 0,
}

const StepperContext = createContext<StepperContextValue>(
  StepperContextInitialValue,
)

export const useStepperContext = () => {
  const context = useContext(StepperContext)
  if (!context) {
    throw new Error(
      'Stepper 조합 컴포넌트는 Stepper 컴포넌트 외부에서 사용할 수 없습니다.',
    )
  }
  return context
}

interface StepperProps extends HTMLAttributes<HTMLDivElement> {
  step?: number
}

export const Stepper = ({ children, step = 0 }: StepperProps) => {
  const [currentStep, setStep] = useState(step)
  const pageLength = Children.toArray(children).filter(
    (el: any) => el.type.name === 'StepperStep',
  ).length

  const contextValue = { currentStep, setStep, pageLength }
  return (
    <div>
      <StepperContext.Provider value={contextValue}>
        {React.Children.map(children, (child: any, i) => {
          return child.type.name === 'StepperStep' && i !== currentStep
            ? null
            : child
        })}
      </StepperContext.Provider>
    </div>
  )
}

interface StepperButtonProps extends ButtonProps {
  text: string
  atctionType: 'next' | 'prev'
}

const StepperButton = ({ atctionType, ...props }: StepperButtonProps) => {
  const { setStep, currentStep, pageLength } = useStepperContext()
  const handleButton = () =>
    atctionType === 'next'
      ? setStep && setStep(currentStep + 1)
      : setStep && setStep(currentStep - 1)
  return (
    <Button
      onClick={handleButton}
      {...props}
      disabled={
        (atctionType === 'next' && currentStep === pageLength - 1) ||
        (atctionType === 'prev' && currentStep === 0)
      }
    >
      {props.text}
    </Button>
  )
}
StepperButton.displayName = 'StepperButton'

interface StepperStepProps extends HTMLAttributes<HTMLDivElement> {
  children: React.ReactChild
}

const StepperStep = ({ children }: StepperStepProps) => {
  return <div>{children}</div>
}
StepperStep.displayName = 'StepperStep'

Stepper.Button = StepperButton
Stepper.Step = StepperStep

메뉴 등록 화면 단계 구성

메뉴 등록 화면에 Stepper 컴포넌트를 적용합니다. step상태를 이 컴포넌트에서 관리해 Stepper 컴포넌트에 초기값으로 전달해 맛집을 선택하면 자동으로 다음 단계로 넘어가도록 해줍니다.

// pages/party/create

const [step, setStep] = useState(0)

  return (
    <div>
      <WhiteRoundedCard>
        <div className="text-xl font-bold">오늘의 메뉴 등록하기</div>
        <Stepper step={step}>
          <Stepper.Step>
            <div>
              <p className="mb-4 mt-1 text-sm">
                동료들과 오늘 먹고 싶은 점심 메뉴를 등록해 보세요. 맛집 이름으로
                검색해 보세요.
              </p>
              <SearchKeywordMap setKeyword={setKeyword} keyword={keyword} />
            </div>
          </Stepper.Step>
          <Stepper.Step>
            <div>
              <p className="mb-4 mt-1 text-sm">
                동료들을 만날 수 있도록 정보를 알려주세요.
              </p>
              <Form<PartyCreateValue> >
              {/* 중략 */}
              </Form>
            </div>
          </Stepper.Step>
          <Stepper.Button
            atctionType="prev"
            text="다시 선택하기"
            color="blue"
          />
          {/* next 버튼은 넣지 않고 자동으로 넘어가게 처리 */}
        </Stepper>
      </WhiteRoundedCard>
    </div>
  )

지도에서 맛집 선택 컴포넌트 개선

  • 카카오맵 검색 컴포넌트에 맛집 이름 검색 부분을 form으로 변경한다.
  • form에 회사 위치를 이용할지 선택 옵션을 추가한다.
  • 카카오맵 검색 컴포넌트에 검색 결과 목록을 보여주고 목록에서도 선택할 수 있게 한다.
  • 검색 결과가 없으면 카카오맵을 사용하지 않는 옵션을 활성화시킨다.

스타일을 제외한 변경사항이 적용된 컴포넌트입니다. 유닛 테스트를 작성하려면 함수를 비동기 함수(카카오맵 검색 부분)를 모듈화하고 상태를 최적화할 필요가 있습니다. 추후에 리팩토링을 진행하려고 합니다.

import { useState } from 'react'
import { useQuery } from 'react-query'
import { SearchIcon, CheckIcon } from '@heroicons/react/outline'
import { Map, MapMarker } from 'react-kakao-maps-sdk'

import { Marker, Team, SearchKeywordValue } from '@/type'
import { useAppSelector } from '@/utils/hooks'
import { retrieveTeam } from '@/api'
import { LoadingSpinner, Form, FormInput, FormCheckbox } from '@/components'
import classNames from 'classnames'
import * as Yup from 'yup'
import { yupResolver } from '@hookform/resolvers/yup'
import { Button } from '@/components/buttons'

type SearchKeywordMapProps = {
  setKeyword: Function
  setStep?: Function
}
enum KakaoResponseStatus {
  INITAIL = 'INITAIL',
  LOADING = 'LOADING',
  ERROR = 'ERROR',
  OK = 'OK',
  ZERO_RESULT = 'ZERO_RESULT',
}
const searchKeywordSchema = Yup.object().shape({
  keyword: Yup.string().required('맛집 이름을 입력해 주세요'),
})
const searchKeywordValues = {
  keyword: '',
  use_team_location: true,
  use_kakaomap: true,
  isSetted: false,
}
const SearchKeywordMap = ({ setKeyword, setStep }: SearchKeywordMapProps) => {
  const [markers, setMarkers] = useState<Marker[]>([])
  const [map, setMap] = useState<any>()
  const [mapStatus, setMapStatus] = useState<KakaoResponseStatus>(
    KakaoResponseStatus.INITAIL,
  )
  const [markerInfo, setMarkerInfo] = useState<Marker>()

//...중략

  const handleSubmit = (values: SearchKeywordValue) => {
    setKeyword(values)
    searchKakaoMap(values)
  }

  const searchKakaoMap = (values: SearchKeywordValue) => {
    if (map) {
      setMarkerInfo(undefined)
      const ps = new kakao.maps.services.Places()

      const location = myTeam.data && myTeam.data.location
      setMapStatus(KakaoResponseStatus.LOADING)
      ps.keywordSearch(
        `${values.keyword} ${values.use_team_location ? location : ''}`,
        (data, status, _pagination) => {
          if (status === kakao.maps.services.Status.ZERO_RESULT)
            return setMapStatus(KakaoResponseStatus.ZERO_RESULT)

          if (status === kakao.maps.services.Status.OK) {
            setMapStatus(KakaoResponseStatus.OK)
            // 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해
            // LatLngBounds 객체에 좌표를 추가합니다
            const bounds = new kakao.maps.LatLngBounds()
            let _markers = []

            for (var i = 0; i < data.length; i++) {
              // @ts-ignore
              _markers.push({
                position: {
                  lat: Number(data[i].y),
                  lng: Number(data[i].x),
                },
                content: data[i].place_name,
                place_url: data[i].place_url,
                id: data[i].id,
              })
              // @ts-ignore
              bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x))
            }
            setMarkers(_markers)

            // 검색된 장소 위치를 기준으로 지도 범위를 재설정합니다
            map.setBounds(bounds)
          }
        },
      )
    }
  }

// 맛집 선택시 부모 상태 변경
  const selectKeyword = (marker: Marker) => {
    setKeyword((prev: SearchKeywordValue) => {
      return { ...prev, keyword: marker.content, isSetted: true }
    })
    setMarkerInfo(marker)
  }

// 카카오맵 사용 안함 설정시 부모 상태 변경
  const handleUnuseKakaomap = () => {
    setKeyword((prev: SearchKeywordValue) => {
      return { ...prev, use_kakaomap: false, isSetted: true }
    })
    setStep && setStep((prev: number) => prev + 1)
  }
  return (
    <div>
      <Form<SearchKeywordValue>  // form으로 변경
        onSubmit={handleSubmit}
        options={{
          resolver: yupResolver(searchKeywordSchema),
          mode: 'onBlur',
          defaultValues: searchKeywordValues,
        }}
      >
        <FormInput<SearchKeywordValue>
          id="keyword"
          name="keyword"
          placeholder="맛집 이름을 입력해 주세요"
          className={classNames('inline-block', {
            'border-green-600': markerInfo,
          })}
        />
          <button
            type="submit"
            className="ml-2 w-10 rounded-md bg-blue-500 px-2 text-white"
          >
            <SearchIcon className="h-6 w-6" />
          </button>

        <FormCheckbox<SearchKeywordValue>
          name="use_team_location"
          type="checkbox"
          label="회사 주변에서 검색하기"
        />
      </Form>

      <div className={`relative mt-2 w-full`}>
        <div
          className={`absolute z-10 h-52 w-full bg-white text-sm ${
            mapStatus === KakaoResponseStatus.OK && 'hidden'
          }`}
        >
          {mapStatus === KakaoResponseStatus.ZERO_RESULT && (
            <div>
              <p className="text-red-600">검색결과가 없습니다.</p>
              <Button color="blue" onClick={handleUnuseKakaomap}>
                지도 없이 등록하기
              </Button>
            </div>
          )}
          {mapStatus === KakaoResponseStatus.INITAIL && (
            <p>맛집 이름을 검색해 보세요. 회사 근처 맛집을 찾아드려요.</p>
          )}
          {mapStatus === KakaoResponseStatus.LOADING && (
            <div className="flex h-20 w-full items-center justify-center p-3">
              <LoadingSpinner className="h-8 w-8" />
            </div>
          )}
        </div>
  
        <div className="sm:flex">
          <Map
            center={{ lat: 33.5563, lng: 126.79581 }}
            style={{ width: '100%', height: '13rem' }}
            onCreate={setMap}
          >
            {markers.map((marker: Marker) => (
              <MapMarker
                key={`marker-${marker.content}-${marker.position.lat},${marker.position.lng}`}
                position={marker.position}
                onClick={() => selectKeyword(marker)}
              >
                <div className="p-0.5 px-2 text-sm">{marker.content}</div>
              </MapMarker>
            ))}
          </Map>
          // 리스트에 맛집 목록 표시
          <div className="sm:ml-4 sm:w-48">
            <ul>
              {markers.map((marker: Marker) => (
                <li key={marker.id} onClick={() => selectKeyword(marker)}>
                  {marker.content}
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>
    </div>
  )
}

export { SearchKeywordMap }

스타일을 반영한 최종 결과입니다.

맛집 이름을 검색하면 맛집 선택이 가능한 목록이 표시됩니다. 목록에서 맛집 선택시 지도 중심이 해당 맛집으로 포커싱됩니다. 원하는 결과가 없을 시 지도 없이 등록하기 버튼을 통해 맛집 이름만으로 등록할 수 있습니다. 회사 주변에서 검색하기 버튼이 옵션으로 추가되었습니다. 맛집을 선택하면 다음으로 넘어가 추가 정보를 입력하고 오늘의 메뉴를 등록할 수 있습니다.

 

업데이트 소스코드입니다.

https://github.com/darae07/client-matzip/pull/12

 

Dev by darae07 · Pull Request #12 · darae07/client-matzip

개선 방향 맛집이름과 카카오맵 정보를 연동해 등록하는 부분과 나머지 정보(게시글 제목, 카테고리, 설명)를 등록하는 부분을 분리하여 단계별로 구성한다. 맛집 이름을 등록하는 부분도 form으

github.com

 

댓글