sungwook

프론트엔드 인턴으로 살아남기 #2 본문

인턴으로 살아남기

프론트엔드 인턴으로 살아남기 #2

개발하는감자 2025. 2. 3. 02:03

이번 글에서는 인턴으로 다닌 2달동안 받은 피드백, 크고 작은 실수들 모음을 정리하려고 한다.

기초적인 실수도 있어 다소 민망하지만 앞으로는 다시 실수하지 않겠다는 의미에서 다 기록하려 한다.

 

그동안 완료한 이슈 갯수를 처음 세어봤는데 딱 100개 나왔다. (25년 1월 31일 기준)

많아보이지만 5분안에 처리가 가능했던 이슈도 있고 3일 걸린 이슈도 있어서 갯수는 크게 의미가 없다.

의미는 없다지만 확실한건 무언가 완료를 많이 했다는건 뿌듯하고 내가 속한 팀에 기여를 꾸준히 하고있다는게 보람차게 느껴진다.

1. useMemo, useRef 사용

초기에 아주 기초적인 실수를 했던게 아직도 기억에 남아 부끄럽지만 기록해놓으려 한다.

바보같이 useMemo를 사용해도 되는 부분에 useState와 useEffect로 처리했었다.

 

변경 전:

export const useCustomHook = ({id}: UseCustomHookProps) => {
  const [parsedData, setParsedData] = React.useState(null);
  const { data } = useDataQuery({ id });

  React.useEffect(() => {
    if (classroomData) {
      // ...
      setParsedData(/* parsed data */);
    }
  }, [data]);

  //...
}

 

변경 후:

export const useCustomHook = ({id}: UseCustomHookProps) => {
  const { data } = useDataQuery({ id });

  const parasedData = React.useMemo(() => {
    if (data) {
      return {/* parsed data */}
    }
    return null;
  }, [data]);

  //...
}

 

* 추가로 커스텀 훅을 만들 때 별로 대수롭지 않게 생각했던 부분이 있는데 바로 반환 객체다.

컴포넌트가 리렌더링될 때 커스텀 훅을 사용하고 있다면 그 안에 정의되어있는 변수나 함수들이 전부 다시 정의되는 것이다.

이를 방지하기 위해서 useMemo랑 useCallback으로 변수랑 함수가 재정의되는 것을 막고 useRef로 저장된 객체를 반환해주면 컴포넌트가 리렌더링되어도 동일한 참조를 유지할 수 있게 된다.

 

예시:

interface UserData {
  id: number;
  name: string;
  score: number;
}

interface UseUserAnalyticsReturn {
  averageScore: number;
  getHighScore: (users: UserData[]) => number;
}

export const useUserAnalytics = (users: UserData[]) => {
  // useRef로 반환 객체 관리
  const returnValue = useRef<UseUserAnalyticsReturn>({} as UseUserAnalyticsReturn);

  // useMemo로 계산된 값 저장
  const averageScore = useMemo(() => {
    return users.reduce((acc, user) => acc + user.score, 0) / users.length;
  }, [users]);

  // useCallback으로 함수 저장
  const getHighScore = useCallback((users: UserData[]) => {
    return Math.max(...users.map(user => user.score));
  }, []);

  // 반환 객체 업데이트
  returnValue.current.averageScore = averageScore;
  returnValue.current.getHighScore = getHighScore;

  return returnValue.current;
};

 

2. index.ts로 import 길이 줄이기

이 부분은 이미 많이 알려져있는 부분이라고 생각하고 나도 원래 알고 있던 부분인데 자주 실수하는 부분 중 하나다. 물론 이거는 뒤에서 얘기할 하나의 컨벤션 중 하나인데 잘 지켜져야 프로젝트 규모가 계속 커질 때 관리하기 편하다.

프로젝트 규모가 커질수록 같이 늘어나는게 있다. 바로 폴더 갯수인데 index.ts로 import를 관리하지 않으면 파일안에 정의되는 import문 갯수가 많아진다.

 

예시:

📁 folder1
├ 📄 index.ts
├ 📄 component1.tsx
└ 📁 folder2
     ├ 📄 component2.tsx
     └ 📄 index.ts
📁 folder3
└ 📄 component3.tsx

 

component3에서 component1과 component2를 import한다고 해보자. 그러면 import문이 다음과 같이 두개가 생길 것이다.

import { Component1 } from ../folder1/component1;
import { Component2 } from ../folder1/folder2/component2;

//...

 

근데 만약에 folder1과 folder2에 index.ts를 정의하고 import를 한다면 folder1에서 component1과 component2를 한꺼번에 불러올 수 있게 된다.

//folder1 하위에 위치한 index.ts
export * from ./component1;
export * from ./folder2;

//folder2 하위에 위치한 index.ts
export * from ./component2;

//component3
import { Component1, Component2 } from ../folder1;

//...

 

만약 default로 컴포넌트를 export했다면 index.ts에서는 default의 이름을 정의해주면 된다.

export { default as Component1 } from './Component1';

 

 

 

3. git merge할 때 pull 받았는지 확인하기

이 부분도 놓치기 쉬운 부분 중 하나였는데 주로 qa 브랜치에 내가 작업한 결과물을 merge할 때 실수를 했었다.

내가 작업한 브랜치를 브랜치1이라 해보자. 이 브랜치1에 작업한 내용을 스프린트 브랜치에 merge하고 sprint 브랜치를 qa 브랜치에 merge하는 식으로 작업을 했었다. 근데 sprint 브랜치에 merge할 때는 git으로 리뷰를 받은 후에 merge되기 때문에 내 IDE에는 반영이 안되어있는 경우가 생겼다. 이 상태로 qa 브랜치에 머지하면 내가 작업한 커밋 일부가 누락된 채로 merge가 되었다. (원격에만 커밋이 업데이트 되어있고 로컬에는 업데이트가 되어있지 않기 때문)

 

그래서 지금까지 주로 스프린트 브랜치가 아닌 작업한 브랜치 자체를 qa 브랜치에 올리면서 작업을 하면서 해당 실수는 많이 줄었는데 내가 혼자서만 작업한 브랜치가 아닌 경우 git pull로 해당 브랜치가 최신화되었는지 확인하고 merge하는 습관을 가지려 한다.

 

 

4. aria-*, data-*, scroll, 시멘틱 태그 (form)

웹 접근성을 고려해서 코드를 짜야한다는 말은 정말 많이 들었다. 그런데 막상 코드를 치기 시작하면 익숙한대로 코드를 짜고 웹 접근성은 우선순위가 최하위가 되기 마련이었다. 내가 코드 리뷰를 받으면서 많이 받았던 코멘트중 하나가 바로 이 웹 접근성 관련이다.

웹 접근성이란 「지능정보화기본법」에 따라 장애인이나 고령자분들이 웹 사이트에서 제공하는 정보를 비장애인과 동등하게 접근하고 이용 할 수 있도록 보장하는 것이다. 출처 : wa

가장 중요한 것은 시멘틱 태그를 잘 지키는 것이다. form 형태를 사용한다면 (특히 react-hook-form을 사용할 때)

태그를 사용하고 onSubmit 속성을 주고 버튼에 form의 id를 넘겨주는 것인데 나는 계속
태그를 쓰지 않고 button에 바로 onClick 속성으로 폼 제출 액션을 수행해서 이에 대해서 지적받았었다.

다음은 내가 제일 많이 했던 실수인데 emotion으로 css를 정의할 때 컴포넌트의 prop으로 조건을 정의하는 것이었다. 근데 이렇게 하는 것보다는 aria 속성과 data 속성을 사용하는게 좋다고 한다. 좀 더 자세한 내용을 보고 싶다면 링크된 mdn 문서를 참고하는 것을 추천한다.

예시는 다음과 같다.

 

수정 전:

interface StyledLayoutProps extends StackProps {
  selected?: boolean;
}

const StyledLayout = styled(Stack)<StyledLayoutProps>`
  background: ${({ selected, theme }) =>
    selected ? theme.color : null};
`;

const Component = ({isSelected}) => {
  return (
    <StyledLayoutProps selected={isSelected}>
      {children}
    </StyledLayoutProps>
  )
}

 

수정 후:

const StyledLayout = styled(Stack)`
  &[aria-selected='true'] {
    background: ${({ theme }) => theme.palette.primary.opacity02};
  }
`;

const Component = ({isSelected}) => {
  return (
    <StyledLayoutProps aria-selected={isSelected}>
      {children}
    </StyledLayoutProps>
  )
}

 

추가로 scroll: overflow css 옵션을 사용하면 mac에서는 상관없지만 window에서는 스크롤을 항상 보여준다고 한다. 그래서 scroll: auto 옵션을 사용하는걸 습관화해야겠다.

 

 

5. Tanstack Query

React Query라고도 알려진 Tanstack Query를 사용하면서 조금은 특이한 경우가 있어서 남겨놓으려 한다.

어느날 이슈를 받았는데 post요청을 한 뒤에 강제로 새로고침을 해야만 데이터가 업데이트된다는 이슈였다. 그래서 확인해봤는데 invalidateQuery로 해당 쿼리 키를 무효화했는데도 데이터가 업데이트되지 않는게 문제였다.

문제를 잘 몰랐을 때는 queryClient.setQueryData(...)로 데이터를 직접 주입해주었다. (이렇게 해도 해결은 된다)

그런데 찾아보니 문제의 원인은 await reactQueryClient.ensureQueryData()를 사용해서였다. ensureQueryData는 일회성으로 한번만 데이터를 불러와서 쿼리가 자동으로 inactive 상태가 된다. 그래서 invalidateQuery에도 refetchType: inactive 옵션을 줘야 데이터가 최신화되었다.

혹시나 invalidateQuery로 데이터가 최신화되지 않는다면 query를 불러온 곳을 확인하고 inactive 옵션을 추가해주자.

 

 

6. 함수 타입 오버로딩

회사 들어간지 얼마 안됐을 때 커스텀 훅을 만드는데 타입 때문에 어려움을 겪은 적이 있었다.

점수에 따라서 다른 색상을 반환해야했는데 variant('text', 'chart', 'tag')에 따라 반환 타입이 달랐었다.

조금 더 부연설명을 하자면 우리 회사는 mui기반의 컴포넌트를 사용하는데 Tag 컴포넌트에 쓰이는 색상은 타입이 TagProps 여서 variant가 'text' 또는 'chart'일 때는 string 반환, 'tag'일 때는 TagProps 타입을 반환해야했다.

물론, any 타입을 쓰면 한방에 해결된다.

초기 코드는 any를 사용했다.

 

사실, 어물쩍 넘어가려는 마음이 없었다면 거짓말이다. 타입을 30분동안 지지고 볶았는데 해결이 되지 않았었다.

interface GetAchievementColorParams {
  score: number;
  variant: 'text' | 'chart' | 'tag';
}

interface UseAchievementThemeReturn {
  getAchievementColor: (params: GetAchievementColorParams) => any;
}

export const useAchievementTheme = () => {
  const getAchievementColor = (params: GetAchievementColorParams) => {
    const { variant, score } = params;

    switch (variant) {
      case 'text':
        switch (true) {
          // return type: string | null
        }

      case 'chart':
        switch (true) {
          // return type: string | null
        }

      case 'tag':
        switch (true) {
          // return type: TagProps['color']
        }

      default:
        return null;
    }
  };

  _return.current.getAchievementColor = getAchievementColor;
  return _return.current;
};

 

당연히(?) 리뷰를 받을 때 any를 쓰지 말고 타입 오버로딩을 통해 해결하는게 좋을 것 같다는 의견을 받았고

type GetAchievementColor = ((params: {
  score: number;
  variant: 'text';
}) => string) &
  ((params: { score: number; variant: 'tag' }) => TagProps['color']);

 

이런식으로 반환 타입 오버로딩을 통해 고쳐보려했는데 설정되어있는 ESLint 에러를 피해갈 순 없었다.

여러가지 시도를 해보고 검색도 해봤는데 결국엔 함수 자체를 오버로딩해야 타입 에러를 잡을 수 있었고 고친 코드는 다음과 같다.

interface GetAchievementColorParams {
  score: number;
  variant: 'text' | 'chart' | 'tag';
}

type GetAchievementColor = {
  (params: { score: number; variant: 'text' | 'chart' }): string | null;
  (params: { score: number; variant: 'tag' }): TagProps['color'];
};

export const useAchievementTheme = () => {
  const getColor = {
    // return color
  }

  // variant: 'tag' 오버로딩
  function getAchievementColor({
    variant,
    score,
  }: {
    variant: 'tag';
    score: number;
  }): TagProps['color'];

  // variant: 'text', 'chart' 오버로딩
  function getAchievementColor({
    variant,
    score,
  }: {
    variant: 'text' | 'chart';
    score: number;
  }): string | null;

  // 실제 함수 정의
  function getAchievementColor({
    score,
    variant,
  }: GetAchievementColorParams): string | null | TagProps['color'] {
    return getColorByLevel(score, variant, cutlines);
  }

  _return.current.getAchievementColor = getAchievementColor;
  return _return.current;
};

 

 

7. MUI 디자인 시스템

프로젝트 규모가 클수록 디자인 시스템(시스템보다는 디자인 토큰이라는 말이 더 맞는 것 같기도 하다)을 적용하는게 좋은데 우리 회사는 mui를 활용해서 직접 커스텀하는 것을 지양하고 있다. 그래서 어떤 컴포넌트들이 있는지 잘 인지하고 있어야하는데 초기에는 어떤게 있는지 잘 몰라서 작업 시간이 생각한 것 보다 오래 걸렸었다.

몇가지 적어보자면 다음과 같은 컴포넌트들이 있다.

  • Paper, Card, ListItem, Accordion, LoadingButton, Breadcrumbs, ButtonGroup, Snackbar, Drawer, Table, Badge, Chip, Typography, ...

그중에서 LoadingButton은 데이터를 fetch할 때 Button 대신 잘 사용해야한다는 리뷰를 많이 받았고 공용 컴포넌트로 LineClampedTypography라 해서 n줄 넘어갈 때 ...으로 줄 수 제한을 두는 컴포넌트를 만들어 사용하고 있었는데 이걸 놓치고 일반 Typography로 사용할 때가 좀 있었다.

 

아직도 새로운 컴포넌트를 만들 때 바로바로 컴포넌트가 떠오르지 않을 때가 있는데 평상시에 mui 문서를 잘 숙지하고 있어야겠다.

 

* 작업하다가 관련되서 기억에 남는 이슈가 있는데 MenuItem이 Tooltip으로 감싸져 있으면 Tooltip이 안뜨는 현상이 있었다.

const Component = () => {
  return (
    <Select>
      <Tooltip>
        <MenuItem>
          {/* details */}
        </MenuItem>
      </Tooltip>
    </Select>
  )
}

 

이런식으로 메뉴 아이템이 툴팁으로 감싸져 있는 경우 툴팁이 뜨지 않았고 이를 해결하기 위해서 <Tooltip>과 <MenuItem> 사이에 <span> 태그를 넣어줬다.

 

수정된 코드:

const Component = () => {
  return (
    <Select>
      <Tooltip>
        <span>
          <MenuItem>
            {/* details */}
          </MenuItem>
        </span>
      </Tooltip>
    </Select>
  )
}

 

Tooltip이 제대로 동작하지 않은 이유는 mui 코드를 뜯어보니 알 수 있었다.

ref. MUI Tooltip, MUI MenuItem

 

코드를 보면 Tooltip에서는 이벤트 핸들러 새로 정의해서 바로 다음 Element에 적용하고 있다.

const Tooltip = React.forwardRef(function Tooltip(inProps, ref) {
  //...

  const childrenProps = {
    ...nameOrDescProps,
    ...other,
    ...children.props,
    className: clsx(other.className, children.props.className),
    onTouchStart: detectTouchStart,
    ref: handleRef,
    ...(followCursor ? { onMouseMove: handleMouseMove } : {}),
  };
  
  return (
    <React.Fragment>
      {React.cloneElement(children, childrenProps)}
      {/* ... */}
    </React.Fragment>
  )
}

 

근데 MenuItem은 최상위 컴포넌트가 MenuItemRoot인데 이미 hover css가 적용되어 있었다.

const MenuItemRoot = styled(ButtonBase, {
  //...
})(
  memoTheme(({ theme }) => ({
    //...
    
    '&:hover': {
      textDecoration: 'none',
      backgroundColor: (theme.vars || theme).palette.action.hover,
      // Reset on touch devices, it doesn't add specificity
      '@media (hover: none)': {
        backgroundColor: 'transparent',
      },
    },
  })),
);

 

그래서 css가 js보다 먼저 적용되니까 hover속성에 가려져 Tooltip이 뜨지 않는걸까..? 하고 css 속성을 없애봤다. 근데 여전히 Tooltip은 뜨지 않았고 나는 원인이 ButtonBase에 있다고 생각했다. 아니나 다를까 ButtonBase를 까보니 이벤트 핸들러가 엄청나게 많이 정의되어있었다. ref. MUI ButtonBase

const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
  //..
  
  const handleMouseDown = useRippleHandler(ripple, 'start', onMouseDown, disableTouchRipple);
  const handleContextMenu = useRippleHandler(ripple, 'stop', onContextMenu, disableTouchRipple);
  const handleDragLeave = useRippleHandler(ripple, 'stop', onDragLeave, disableTouchRipple);
  const handleMouseUp = useRippleHandler(ripple, 'stop', onMouseUp, disableTouchRipple);
  const handleMouseLeave = useRippleHandler(
    ripple,
    'stop',
    (event) => {
      if (focusVisible) {
        event.preventDefault();
      }
      if (onMouseLeave) {
        onMouseLeave(event);
      }
    },
    disableTouchRipple,
  );
  const handleTouchStart = useRippleHandler(ripple, 'start', onTouchStart, disableTouchRipple);
  const handleTouchEnd = useRippleHandler(ripple, 'stop', onTouchEnd, disableTouchRipple);
  const handleTouchMove = useRippleHandler(ripple, 'stop', onTouchMove, disableTouchRipple);
  
  //..
}

 

보면 useRippleHandler를 반복해서 사용하는데 그냥 물결치는 액션을 정의한거였다. ripple의 뜻을 찾아보면 잔물결이라는 뜻을 가졌는데 실제로 위에서 css hover 속성을 없애니까 MenuItem들이 요동치는걸 볼 수 있었다. 그냥 ButtonBase 정의해놓은게 있으니 재사용하려고 쓴 것 같은데 불필요한 이벤트 핸들러가 있어서 이벤트 전달이 제대로 안되는 거였다. 결론은 <span> 뿐 아니라 이벤트 전달을 받을 수 있는 어떤 컴포넌트를 중간에 사용해도 제대로 동작하지만 <span>을 사용하면 인라인 요소로 이벤트 버블링 용도로 쓸 수 있으면서 레이아웃에 영향을 주지 않기 때문에 가장 적합하다고 생각했다.

 

8. yalc

회사에서 msa 아키텍처를 사용하면서 서비스에 따라서 여러 레포로 분리시켰는데 이로 인해 패키지를 로컬에서 배포하고 설치해줘야하는 일이 종종 생겼다. 그래서 yalc를 이용하게 되었는데 이를 사용하면서 생긴 문제를 남겨놓으려 한다.

yalc를 쓰면 배포하려는 패키지 위치에 가서 yalc publish 명령어를 사용하면 된다.

단, build 명령어를 실행한 후에 publish 해줘야 한다.

build를 다시 해주지 않으면 수정한 코드가 반영이 되지 않거나 이전에 빌드했던 패키지를 저장하고 있게 된다.

yalc publish를 하면 배포된 패키지 이름과 버전이 뜨는데 이걸 복사해서 사용하려는 패키지 위치에 가서 yalc add {배포된 패키지 이름} 을 해주면 된다.

여기서 주의할 점이 있는데, 이미 yalc add한 패키지를 업데이트한 후에 다시 적용해주려면 yalc publish를 다시 하는게 아니라 build한 후 yalc publish와 yalc add를 다시 해주는게 아니라 yalc publish --push를 해주면 기존에 add한 패키지에도 전파해서 적용되게 해준다. 두번째부터는 yalc add를 해도 새로 publish한 내용이 적용되지 않는데 나는 이걸 몰라서 2시간을 버렸었다..

 

 

9. convention 잘 지키기

마지막은 모든 회사가 그렇듯 컨벤션을 잘 지키는 것이다.

컨벤션이 정말 많아서 숙지하기 꽤나 어려웠는데 이거는 계속 익숙해져야 하는 부분인 것 같다.

 

컨벤션 관련 리뷰를 받다가 알게 된 점은 컴포넌트를 조건부 렌더링할 때 data && <Component /> 처럼 data가 존재할 때 컴포넌트를 렌더링하게 조건을 줄 때가 종종 있는데 이렇게 선언하는 것보다 data ? <Component /> : null 로 사용하라는 피드백을 받았다.

 

좀 더 찾아보니 다음과 같은 문제가 있었다.

 

1) data가 number이고 0일 때

  • data && <Component /> : 0을 보여줌
  • data ? <Component /> : null : null

2) data가 string이고 ''일 때

  • data && <Component /> : 빈문자열 '' 반환
  • data ? <Component /> : null : null

'인턴으로 살아남기' 카테고리의 다른 글

프론트엔드 인턴으로 살아남기 #1  (3) 2025.01.27