안녕하세요. 이번 주에는 사이드 프로젝트 '오늘 뭐 먹지?'를 오픈하고 주변 분들께 피드백을 받는 시간을 가졌습니다. 오늘은 피드백 사항 중, 메뉴 등록 화면의 불편 사항을 개선하는 업데이트를 진행하는 과정을 담아보려고 합니다.
피드백 사항
- 등록하기 화면에서 엔터를 누를 시 무조건 폼 제출이 되어서 불편했어요. (음식점 검색 인풋이 포커스되었을 때는 엔터했을 때 음식점을 검색하게 하는 건 어떨까요?)
- 음식점을 검색하고 지정하는 과정이 힘들었어요. (음식점을 검색했을 때 지도 영역 주변에 모든 검색 정보를 리스트로 보여주고 클릭 시 해당되는 마커를 선택할 수 있는 영역이 있으면 좋겠어요)
- 일부 음식점이 검색 시 나오지 않아서 불편했어요. (음식점 검색을 전반적으로 자동 완성 + 수동 지정을 할 수 있으면 어떨까요?)
- 기본 프로필 사진이 있으면 좋겠어요. (기본값이 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
'개발 > 사이드 프로젝트' 카테고리의 다른 글
[앱 출시, 수익화 프로젝트]1. 앱 아이템 기획 (1) | 2023.10.08 |
---|---|
[디프만 13기] next 13 server-side 환경에서 api 호출하기 (2) | 2023.08.22 |
사이드 프로젝트 오늘 뭐 먹지? 소개 (0) | 2022.06.16 |
리스트 페이지&컴포넌트 설계하기(무한 스크롤) (0) | 2022.05.26 |
모달 컴포넌트 설계하기 (0) | 2022.05.17 |
댓글