sungwook
프론트엔드 인턴으로 살아남기 #1 본문
인턴으로 일한지 벌써 2달이 지났다.
그동안 계속 내가 한걸 정리해야지 말만 하고 시간만 흘려보냈는데
황금연휴기간을 갖게 된 지금에서야 정리해본다..
회사에 들어간 첫날부터 스프린트를 뛰고 지금까지 정신없이 일을 해왔는데
그렇게 배운 것도 많고 시행착오도 많이 겪었다.
그 중에서 제일 기억에 남는 일부터 정리해보려 한다.
인턴으로 일한지 1달 조금 넘었을까 나에게 신규기능을 개발하는 작업이 주어졌다.
메타 정보를 관리하는 컴포넌트로 사이트내에 백엔드에서 관리하는 용어들이 있는데 언어별로 다른 값을 보여주고 사용할지 여부와 하위값을 중복해서 선택할 수 있는지 여부를 선택할 수 있는 UI를 만드는 일이었다.
좀 쉽게 말하면 해시태그를 등록하는거랑 비슷하다고 보면 된다.
강의를 선택할 때 그 강의에 대한 태그 정보를 관리하는 것이다.
대충 요약하면 요구사항은 위 사진과 같다.
요약은 내가 만든 것이고 실제로는 피그마로 30장 정도 되는 요구사항과 3일만에 끝내라는 기한을 받았다.
결론부터 말하면 3일동안 끝내지 못하고 5일 풀로 야근하면서 완성했다. (중간에 다른 업무도 병행해야 했다)
우리 회사는 QA기간이 따로 있어서 일정이 늦춰지는 바람에 QA기간이 늦춰졌지만 다행히도 큰 문제는 없어서 전체 일정은 시간내에 끝마칠 수 있었다. 설계를 잘못했다면 QA 받은 요구사항을 절대 시간내에 못 끝냈을 것이란 생각이 들었다.
다시 돌아와서, 위의 요구사항을 받은 나의 심정은 두려움 반, 설레임 반이었던 것 같다. 3일은 너무 짧지 않나? 라는 생각과 함께 이런 요구사항은 처음 받아보는데 '재밌겠다.' 드디어 본격적으로 뭔갈 만들어보는구나 라는 생각이 공존하고 있었다. (요구사항 중에는 form 제출 형식과 드래그/드랍 구현이 있었는데 나는 react-hook-form을 제대로 사용할 줄 몰랐고 드래그/드랍은 구현해본 적 조차 없었다.)
이 컴포넌트를 보면서 가장 먼저 든 생각은 하위 유형 값들을 어떻게 관리하지? 라는 생각이었다.
요구사항을 봤을 때 배경색상을 보면 선택된 유형의 부모, 자식 유형들을 알아야한다는게 보였고 어떻게 직관적으로 알 수 있을까 고민하다가 자식 유형들은 무조건 부모 유형을 선택해야 볼 수 있으니 유형을 선택할 때마다 하나의 배열에 넣어 관리해야겠다고 생각했다. 이를테면 useState로 하나의 상태에 ['카테고리 유형', 'depth 1 값 유형', 'depth 2 값 유형']의 배열로 관리하는 것이다. 이렇게 관리하면 배열의 길이가 현재 선택된 유형의 depth를 알려주고 선택된 부모 유형들의 id값을 바로 찾을 수 있다고 생각했다. (이때까지만 해도 백엔드에서는 depth값을 보내주지 않았고 프론트에서 유추해야 했다.) 이렇게 관리했을 때 하나의 문제가 있었다. 나는 가운데 패널에 보이는 Accordion 컴포넌트가 무조건 하나만 열려있을 수 있다고 생각했다. 사실, 요구사항 자체는 하나만 열려있는지 여러개가 열려있을 수 있는지에 대한 언급 자체가 없었다. 하지만 여러개가 동시에 열려있을 수 있다 했을 때 임의로 저 상태 하나만 보고 판단했을 때는 값이 관리가 되지 않는 것이다. depth1인 값 유형이 무조건 하나만 열려있는 상태, 반응형, 그리고 드래그/드랍을 제외하곤 어느정도 완성이었다고 생각했는데 좋지 못한 설계를 했었고 그렇게 나는 상태관리를 갈아엎고 다시 시작했다.
이번에는 컴포넌트를 큰 단위로 나눠서 다시 생각했다.
우선 카테고리 유형부터 끝내자. 그리고 하위 유형을 따로 관리해보자.
이때가 시작한지 삼일차되었을 때다. 이제 막 백엔드 api가 완성되서 목데이터로 되어있는 부분을 실제 api요청으로 갈아끼우고 카테고리 유형만 생각해서 카테고리 생성/수정/삭제를 완성시켰다. 여기에 꼬박 하루가 소요되었고 값 유형을 붙이는데 하루, 반응형과 드래그/드랍을 붙이는데 또 하루가 소요되었다. 구현의 핵심 요소는 크게 3가지로 상태관리 Provider, 유형 아이템 관리, Form 관리로 나뉜다.
1. 상태관리 Provider
우선 Context Provider를 두개를 만들어 하나는 카테고리 유형 목록, 값 유형 목록, 선택된 유형 아이템, 드래그 상태, 유형 생성중인 상태 등을 관리하고 나머지 하나는 react-hook-form을 사용하는 상태들(다이얼로그, 스낵바, 폼 제출, 등)을 관리했다. 요구 사항 중에는 수정중인 상태일 때 저장/삭제를 제외한 어떤 동작을 하더라도 다이얼로그를 보여주어 동작을 실행할지 취소할지 고를 수 있어야하는 조건이 있다. 이상적인 방법은 event listener를 두어 저장/삭제 버튼 클릭을 제외한 컴포넌트가 눌렸을 때 관리하는 것이라고 생각했다. 하지만 이렇게 할 경우 어딜 눌러도 다이얼로그가 떠서 불편하고 정확히 원할 때 다이얼로그를 띄우기 힘들었다. 이후에는 드래그/드랍을 구현할 때 event listener를 사용했는데 아마 이대로 했으면 listener 조건이 꼬여서 드래그할 때 다이얼로그가 뜨는걸 관리하기 힘들었을 것 같다. 그래서 handleMetadataItemClick 이라는 함수를 만들어 유형이 눌렸을 때, 검색 keyword가 바뀔 때, 그리고 url이 변경될 때 각각 다이얼로그가 뜨는 조건을 만들어주었다.
interface MetadataDialogState {
isOpen: boolean;
title: string;
detail: string;
pendingAction: (() => void) | null;
}
const [dialog, setDialog] = React.useState<MetadataDialogState>({
isOpen: false,
title: '',
detail: '',
pendingAction: null,
});
다이얼로그 상태를 위와 같이 만들어서 pendingAction에 현재 실행되어야하는 액션을 넣고 다이얼로그에서 확인을 누르면 pendingAction을 실행, 취소를 누르면 실행하지 않고 다이얼로그만 닫게끔 구현했다.
url이 변경될 때는 react-router에 <Prompt>라는 컴포넌트가 있는데
<FormContext.Provider>
<Prompt when={formState.isDirty} message={handleBlockNavigation} />
{...}
</FormContext.Provider>
이런식으로 만들고 handleBlockNavigation함수를 만들면 isDirty 상태일 때 navigate 될 때를 감지해서 block할 수 있다. 나는 react-router 버전을 5로 사용중이어서 <Prompt> 컴포넌트를 사용했지만 최신버전에는 useBlocker라는 훅을 사용할 수 있는 것으로 보인다.
2. 유형 아이템 관리
유형 아이템들은 단순해보이지만 요구사항이 꽤 많았다.
1. 배경색
2. 드래그/드랍
3. 태블릿 또는 모바일일 때 화살표를 누르면 Drawer가 뜨지 않고 그 외 공간을 누를때는 Drawer가 떠야한다.
여기서 드래그/드랍을 구현할 때 문제가 있었는데 클릭과 드래그를 구분해야되는 문제, 3번 요구사항에서 쓰인 이벤트 버블링을 막았었는데 드래그/드랍을 구현하면서 event listener를 새로 정의하면서 화살표 버튼이 눌리지 않는 문제가 있었다. 클릭할 때 이벤트 버블링을 막기위해 stop.propagation()을 사용했었는데 상위 컴포넌트에 이벤트 리스너를 덮어씌워서 하위 컴포넌트를 클릭할 수 없었다. 아무리 검색해봐도 해결 방법을 못찾아서 ref로 하위 컴포넌트가 클릭된걸 감지할 수 있지 않을까 하다가 ref까지도 필요없이 데이터 속성을 사용하면 더 쉽겠다 생각해서 data-icon-type 속성을 부여했는데 왠걸 하위 아이템이 클릭된걸 감지할 수 있었다. 구현한 코드는 아래와 같다. (꽤나 복잡해보여 스타일 속성들은 대부분 지웠다)
드래그/드랍은 dnd-kit 라이브러리를 사용했고 modifiers의 restrictToParentElement와 restrictToVerticalAxis 속성을 사용해서 움직임 범위에 제한을 주었다. 제한을 주지 않으면 움직였을 때 다른 컴포넌트 위로 움직여지는게 아니라 부모 컴포넌트(dnd context로 감싸진 컴포넌트)안에 갇혀서 움직인다. 글로 보면 잘 이해안될 수도 있는데 Tooltip을 portal을 쓰지 않고 구현했을 때처럼 overflow되어서 여백 공간이 보이게 된다.
const LibraryMetadataEditorSortableListItem: React.FC<
LibraryMetadataEditorSortableListItemProps
> = ({ item, selectedId, getBackgroundColor }) => {
const searchQuery = useSearchParam('category');
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: item.id, disabled: Boolean(searchQuery) });
const { setMetadataItemDragging } = useLibraryMetadataEditorContext();
const { handleClickMetadataItem } =
useLibraryMetadataFormContext();
const [isDragStarted, setIsDragStarted] = useState(false);
const timerRef = useRef<number | null>(null);
useUnmount(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
setMetadataItemDragging(false);
});
//
//
//
const handleMouseDown = (e: React.MouseEvent) => {
timerRef.current = window.setTimeout(() => {
if (!searchQuery) {
setIsDragStarted(true);
setMetadataItemDragging(true);
}
}, METADATA_DRAG_CLICK_THRESHOLD);
};
const handleMouseUp = (e: React.MouseEvent) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (!isDragStarted || searchQuery) {
const isChevronClicked = (e.target as HTMLElement).closest(
'[data-icon-type="chevron"]'
);
if (isChevronClicked) {
handleClickMetadataItem({
item,
buttonType: 'chevron',
});
} else {
handleClickMetadataItem({ item });
}
}
setIsDragStarted(false);
setMetadataItemDragging(false);
};
const combinedListeners = {
...listeners,
onMouseDown: (e: React.MouseEvent) => {
listeners?.onMouseDown?.(e);
handleMouseDown(e);
},
onMouseUp: (e: React.MouseEvent) => {
listeners?.onMouseUp?.(e);
handleMouseUp(e);
},
};
//
//
//
return (
<ListItemButton
ref={setNodeRef}
selected={item.id === selectedId}
sx={{
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
transition,
cursor: isDragStarted ? 'grabbing' : 'pointer',
opacity: item.isPublic === false ? 0.5 : 1,
backgroundColor: getBackgroundColor(item),
}}
{...attributes}
{...combinedListeners}
>
<ListItemText
primary={
<Stack>
<Stack>
<LineClampTypography lineClamp={2} variant="body2">
{item.localeName.ko}
</LineClampTypography>
{item.childrenMetaDataCount > 0 ? (
<Typography variant="body2">
({item.childrenMetaDataCount})
</Typography>
) : null}
</Stack>
<ChevronIcon
data-icon-type="chevron"
onClick={e => {
handleClickMetadataItem({
item,
buttonType: 'chevron',
});
}}
/>
</Stack>
}
/>
</ListItemButton>
);
};
3. Form 관리
react-hook-form을 쓰게 되면 Controller라는 컴포넌트를 사용해서 비제어 컴포넌트로 관리할 수 있고 (React의 hook을 사용할 필요가 없어서 상태가 바뀔 때마다 불필요한 리렌더링을 일으키지 않는다) 로딩, 에러 관리가 엄청 쉬워진다. 여기서 form의 값이 바뀌면 formState.isDirty 상태가 true로 되는데 이걸 통해 폼을 제출하지 않은 상태로 이동하려할시 막을 수 있었다. 이외에도 폼을 제출중일 때 상태도 알 수 있어 loading상태를 표현할 수 있고 useWatch를 이용해 값을 감지해 리렌더링도 강제로 일으킬 수 있는 등 여러가지 기능이 많아서 알고만 있다면 폼을 만들 때 매우 유용해 보였다.
text input 예시:
const { control, formState } = useForm();
const [isDisabled, setDisabled] = React.useState(false);
<Controller
control={control}
name={"name"}
rules={rules}
render={({ field, fieldState: { error } }) => (
<TextField
{...field}
disabled={isDisabled}
value={field.value}
label={"label"}
error={!!error}
helperText={error?.message}
required
/>
)}
/>
radio input 예시:
const { control, formState } = useForm();
const [isDisabled, setDisabled] = React.useState(false);
<Controller
control={control}
name="name"
render={({ field }) => (
<RadioGroup
{...field}
value={String(field.value)}
onChange={(_, value) => field.onChange(value === 'true')}
>
<FormControlLabel
disabled={isDisabled}
value="true"
label={"label1"}
control={<Radio />}
/>
<FormControlLabel
disabled={isDisabled}
value="false"
label={"label2"}
control={<Radio />}
/>
</RadioGroup>
)}
/>
이 컴포넌트를 구현하면서 느낀점은
1. 무조건 설계를 잘해야한다. 요구사항을 매번 완벽히 파악하는 것은 어렵겠지만 바뀌는 요구사항도 반영할 수 있도록 설계해야한다.
2. ai를 잘 활용하면 좋다. 요구사항이 간단하고 명확한 지점은 ai한테 물어보면 정말 잘 알려준다. 나는 드래그/드랍 구현할 때 ai가 다 해줘서 할게 없었다(물론 라이브러리가 잘 만들어져있는게 가장 컸겠지만). event listener로 생긴 문제도 접근 방식을 다르게 해서 시도해달라고 ai한테 부탁하니 잘 대답해주었다. 아마 다른 접근 방식을 생각해내지 못했다면 영원히 답을 얻지 못했을 것이다.
3. 아직 ai에는 한계가 있다. 이번 작업은 사람이 전부 완벽하게 이해하기에도 벅찼는데 이런 저런 상태가 막 얽혀있는 상황에서는 ai도 큰 도움이 되지 못했다. 앞으로는 어떻게 될지 모르겠지만 세부 요구사항이 많을 때 그걸 정확하게 다 입력한다는 가정하에 똑같이 만들 수 있다. 근데 이렇게 복잡한 요구사항을 어린아이도 이해하기 쉽게 잘 요구하면 모르겠지만 아직까지는 불가능해 보였다. 단계를 나누어 하나씩 막히는 구간에서 사용하긴 좋았지만. "요구사항 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 맞춰서 이거 구현해줘"는 먼 미래가 되지 않을까 싶다.
'인턴으로 살아남기' 카테고리의 다른 글
프론트엔드 인턴으로 살아남기 #2 (0) | 2025.02.03 |
---|