[뭐라도 개발해보자] React를 배워보자 3

hjboom·2025년 4월 29일

뭐라도 개발하기

목록 보기
3/3
post-thumbnail

카테고리 리스트 만들기

어제까지 유튜브의 헤더부분을 완성했고, 오늘은 그 아래에 있는 category를 가로로 나열하고 화살표를 눌러 좌우로 이동하는 것을 만들어 볼 것이다. 처음 만들어보는 것이라 보기보다 공수가 많이 들어간다는 사실에 놀랐다.

어제까지 만든 것

export default function App() {
  return (
    <div className='max-h-screen flex flex-col'>
      <PageHeader />
      <div>2</div>
    </div>
  );
}

어제 기본 화면에서 PageHeader layout을 완성했고, 이제 다음 부분을 만들어 볼 차레이다. 유튜브는 그 아래부분을 보았을 때 크게 왼쪽의 사이드바, 오른쪽에 영상 표시 부분으로 나눌 수 있다. 그래서 우선 div를 나눠준다. 이러한 구조에는 grid가 더 편하다고 한다. 찾아보니 flex로도 비슷한 구조를 만들 수는 있는데, flex는 일렬로 나열하는 데에 더 강점이 있기 때문에 이 상황에는 어울리지 않는 것 같다.

여튼, 그래서 className을 하나씩 뜯어보자면

grid grid layout으로 설정하고

grid-cols-[auto,1fr] column을 두 개로 설정. 사이드바는 내용 크기만큼만 차지할 것이기에 auto로 설정하고, 영상 표시 부분은 나머지 공간을 모두 차지할 것이라 1fr로 설정한다.

flex-grow 이거는 유튜브 페이지를 늘리면 한 행과 열에 들어가는 영상이 남는 공간에 맞추어 늘어났다가 줄이면 줄어드는 현상을 유발하기 위해서 사용하는 듯

overflow-auto 얘는 content의 내부 내용이 너무 많아지면 스크롤이 생기도록 한다.

export default function App() {
  return (
    <div className='max-h-screen flex flex-col'>
      <PageHeader />
      <div className='grid grid-cols-[auto,1fr] flex-grow overflow-auto'>
        <div>Sidebar</div>
        <div>videos</div>
      </div>
    </div>
  );
}

부모와 자식의 관계

여기서 궁금해지는게 oveflow-auto로 부모 div에 속성을 걸어주면 이 속성이 어디까지 영향을 미치는가? 이다. 찾아보니 직접적으로 영향을 주는건 아니고, 부모 div가 영향을 줄 수 있는 건 자신의 직계 자식의 배치와 흐름에만 영향을 줄 수 있는 것 같다. 위의 grid는 sidebar, videos의 위치와 흐름을 관리할 수는 있지만 직계자식 안에 들어올 다른 자식들의 배치는 영향을 주지 못한다.

category를 만들어보자

오른쪽 videos 부분은 가장 위에 달려 있는 category 부분과 아래 영상 부분으로 다시 나눠지게 된다. 그리고 실제 유튜브 페이지에서는 카테고리에도, 영상에도 가로 방향 스크롤이 생기지 않는다. 그러므로 가로 방향 컨텐츠들은 overflow 발생 시 숨겨질 수 있도록 overflow-x-hidden 을 설정한다.

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState(categories[0]);

  return (
    <div className='max-h-screen flex flex-col'>
      <PageHeader />
      <div className='grid grid-cols-[auto,1fr] flex-grow overflow-auto'>
        <div>Sidebar</div>
        <div className='overflow-x-hidden px-8 pb-4'>
					<div>Category</div>
					<div>Videos</div>        
        </div>
      </div>
    </div>
  );
}

이제 category 부분을 만들건데, 이 클론 코딩에서는 backend server를 만들어서 api를 받아오는 것이 아니기 때문에, data를 하드 코딩해야 하는데 직접 코드에 넣는 것은 불편함으로 data 디렉토리를 만들어서 넣어주었다.

export const categories = [
  "All",
  "Javascript",
  "Typescript",
  "Programming",
  "Wegiht Lifting",
  "Bowling",
  "Hiking",
  "React",
  "Next.js",
  "Functional Programming",
  "Object Oriented Programming",
  "Frontend Web Development",
  "Backend Web Development",
  "Web Development",
  "Coding",
]

이제 Category 부분에 넣을 컴포넌트를 만들어보자. category string배열을 받아서 이를 flex layout으로 가로 방향으로 주욱 나열하면 된다. react에서는 javascript 함수를 사용하는 것이 간편하기 때문에 배열의 map을 활용하여 button을 여러개 찍어 줄 수 있다.

export default function App() {
  return (
    <div className='max-h-screen flex flex-col'>
      <PageHeader />
      <div className='grid grid-cols-[auto,1fr] flex-grow overflow-auto'>
        <div>Sidebar</div>
        <div className='overflow-x-hidden px-8 pb-4'>
          <div className='sticky top-0 bg-white z-10 pb-4'>
            <CategoryPills
              categories={categories}
            />
          </div>
        </div>
      </div>
    </div>
  );
}
type CategoryPillProps = {
  categories: string[];
  selectedCategory: string;
  onSelect: (category: string) => void;
};

export function CategoryPills({
  categories,
}: CategoryPillProps) {
  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
            key={category}
            className='py-1 px-3 rounded-lg whitespace-nowrap'
          >
            {category}
          </Button>
        ))}
      </div>
    </div>
  );
}

key?

key는 처음보는 것이었는데, react가 list를 randering 할 때 각각의 요소를 구별할 수 있게 해주는 값이라고 한다. 이게 없어도 동작은 동일하게 하는데, 어떤 요소가 변경되었을 때 리스트 값 중 어느 값인지 알 수 없어서 리스트 전체를 다시 랜더링하는 불편함이 발생한다고 한다. 실제로 안 쓰면 console에서 이렇게 사용하지 말라고 조언해준다. 상당히 친절한걸?

버튼을 누르면 색이 바뀌면 좋겠는데

이건 바로 어제 배운 것이라서 간단했는데, 한 가지 신기한 점은 함수를 인자로 넘겨서 작업을 한다는 것이다. 나에게 그다지 익숙한 방식은 아니라서 신기했다. 예전에 운영체제 과제에서 thread에 해야할 일을 주어주기 위해서 함수 포인터를 넘겨준 것은 해보았는데, 개발에서 사용되는 것은 처음봤다.

우선 메인 페이지에 state 변수를 선언해준다. 가장 처음에는 categories의 첫번째 인자인 ‘ALL’을 가리키고 있어야 해서 그것으로 초기화 한다.

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState(categories[0]);

  return (
  
			...
			
  );
}

그리고 이 state변수, setState 함수 모두 categoryPills의 인자로 넘겨준다.

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState(categories[0]);

  return (
    <div className='max-h-screen flex flex-col'>
      <PageHeader />
      <div className='grid grid-cols-[auto,1fr] flex-grow overflow-auto'>
        <div>Sidebar</div>
        <div className='overflow-x-hidden px-8 pb-4'>
          <div className='sticky top-0 bg-white z-10 pb-4'>
            <CategoryPills
              categories={categories}
              selectedCategory={selectedCategory}
              onSelect={setSelectedCategory}
            />
          </div>
        </div>
      </div>
    </div>
  );
}

그리고 어제 배운 state 변수와 함수를 이용한 rerendering 기법을 그대로 적용해준다.

type CategoryPillProps = {
  categories: string[];
  selectedCategory: string;
  onSelect: (category: string) => void;
};

export function CategoryPills({
  categories,
  selectedCategory,
  onSelect,
}: CategoryPillProps) {
  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
            key={category}
            onClick={() => onSelect(category)}
            variant={selectedCategory === category ? 'dark' : 'default'}
            className='py-1 px-3 rounded-lg whitespace-nowrap'
          >
            {category}
          </Button>
        ))}
      </div>
    </div>
  );
}

몇가지 궁금증

component와 layout의 차이는 무엇인가?

어떻게 보면 category 부분은 layout이라고 볼수도 있지 않을까 라는 생각이 들었다.

  • layout: 페이지 전체의 구조나 뼈대를 담당하는 덩어리
  • component: 페이지 안의 작은 기능/조각을 담당하는 재사용 가능한 단위

특히 저 ‘재사용’이라는 말이 이 categoryPills와 어울리지 않아서 그런 의문이 계속 들었다. 이건 하기 나름인 것 같은데, 프론트엔드를 배우면서 전부터 많이 들었던 ‘프론트엔드는 탑을 쌓는 과정이다’ 라는 말이 이해가 되고 있어서 이런 조각 하나하나를 처음에 어떻게 설계하는 지가 나중에 지대한 영향을 끼칠 것 같아 궁금했다. 시원한 결론을 내리지 못해 아쉬웠다.

state 변수의 선언은 어디에 해야하는가?

보면 state변수와 함수를 인자로 넘겨주는데, 왜 직접 안에 설정하지 않고 인자로 넘겨줄까? 이것은 component는 재사용성을 고려하기 때문에 다른 곳에서 이 component가 사용되면 영향을 받지 않도록 하는 것 같다. 이건 어느정도 스스로 결론을 내렸다.

양옆으로 움직이는 버튼 만들기

이제 카테고리 리스트를 만들었으니까 양옆으로 움직일 수 있는 버튼만 만들면된다. 우선 버튼을 하나 만들어서 넣어보자.

  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
						...
          >
            {category}
          </Button>
        ))}
      </div>
      <div>
        <Button variant='ghost' size='icon'>
          <ChevronLeft />
        </Button>
      </div>
    </div>
  );

귀여운 왼쪽 버튼이 하나 생겼다. flex나 grid layout을 사용하지 않으면 기본 layout인 block layout이 사용되는데, 이 때 기본 나열 방식이 위에서 아래로 하나씩 요소를 배치하는 것이라 이 위치에 생성되었다. 나는 이 화살표가 카테고리 리스트에 겹쳤으면 좋겠고, 겹친 부분은 가려졌으면 좋겠다.

겹치는 버튼 만들기

우선 겹치게 유도를 해보겠다. 그럴라면 부모 div의 왼쪽 끝에 붙이고 1/2만큼 움직이면 되겠다. 어.. 부모 기준으로 말이지..? 그래서 부모를 기준으로 하도록 relative를 부모에 달아줬다.

  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
						...
          >
            {category}
          </Button>
        ))}
      </div>
      <div className='left-0 top-1/2'>
        <Button variant='ghost' size='icon'>
          <ChevronLeft />
        </Button>
      </div>
    </div>
  );

하지만 동작하지 않는다. 이는 button이 속한 div에게 기준이 부모라는 것을 알려줄 키워드가 필요하기 때문이다. 이럴 때 absolute를 넣으면 된다.

  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
						...
          >
            {category}
          </Button>
        ))}
      </div>
      <div className='absolute left-0 top-1/2'>
        <Button variant='ghost' size='icon'>
          <ChevronLeft />
        </Button>
      </div>
    </div>
  );

하지만 어림도 없다. 그도 그럴게 내려가는건 이 div 상자가 내려가는 것이니까 원래 위치에서 상자의 가운데 점을 기준으로 움직이기 때문이다. 그래서 이를 제대로 위치시키려면 ‘자신의 높이의 절반만큼 위로’ 올라가면 된다.

  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
						...
          >
            {category}
          </Button>
        ))}
      </div>
      <div className='left-0 top-1/2 -translate-y-1/2'>
        <Button variant='ghost' size='icon'>
          <ChevronLeft />
        </Button>
      </div>
    </div>
  );

우여곡절 끝에 버튼이 잘 겹쳤다.

겹친 부분 가리기

이건 쉬울 것 같다. 그라데이션 색을 div전체적으로 발라주면 될 것 같다. 이러려고 버튼을 div에 넣은 것 같다.

  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
						...
          >
            {category}
          </Button>
        ))}
      </div>
      <div className='absolute left-0 top-1/2 -translate-y-1/2 bg-gradient-to-r from-white from-50% to-transparent'>
        <Button variant='ghost' size='icon'>
          <ChevronLeft />
        </Button>
      </div>
    </div>
  );부모가 flex가 아니면

이거보다 더 길게 가리고 싶으면 div 부분의 길이를 늘리면 된다.

  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
						...
          >
            {category}
          </Button>
        ))}
      </div>
      <div className='absolute left-0 top-1/2 -translate-y-1/2 bg-gradient-to-r from-white from-50% to-transparent w-24'>
        <Button variant='ghost' size='icon'>
          <ChevronLeft />
        </Button>
      </div>
    </div>
  );부모가 flex가 아니면

근데 아래와 같이 그라데이션 div가 겹칠 곳은 category를 나열한 부분이다. 이것과 사이즈를 맞춰 주어야 이상하게 보이지 않을 것이므로 이를 조정하려한다.

그런데 나는 지금 억지로 div를 움직여서 겹친 것이라 이를 어떻게 맞춰야 하지? 라는 생각이 들었다. div는 element를 추가해보니까 기본적으론 추가되는 element만큼 사이즈가 증가하는 것 같다. 아래는 추가한 element div의 크기이고 그 아래는 부모 div의 크기이다.

그래서, 이미 div의 위치를 적절히 잘 움직여 두었기 때문에 부모 높이와 동일하게 맞추면 이 div는 내가 가리고 싶은 div 영역에 꼭 맞게된다. 이를 위해 h-full 을 주면 부모의 높이와 동일하게 div를 맞춰준다.

  return (
    <div className='overflow-x-hidden relative'>
      <div
        className='flex whitespace-nowrap gap-3 transition-transform w-[max-content]'
      >
        {categories.map((category) => (
          <Button
						...
          >
            {category}
          </Button>
        ))}
      </div>
      <div className='absolute left-0 top-1/2 -translate-y-1/2 bg-gradient-to-r from-white from-50% to-transparent w-24 h-full'>
        <Button variant='ghost' size='icon'>
          <ChevronLeft />
        </Button>
      </div>
    </div>
  );부모가 flex가 아니면

어… 그런데 살짝 버튼이 내려간 것 같은 기분이 들었다. 상당히 기분이 나빴는데 왜 이런 결과가 나온걸까? 버튼은 다른 값을 주지 않았기 때문에 기본 셋팅인 div 왼쪽 위에 정확하게 위치한다. 그런데 원래 div가 위 아래로 튀어나와 있었기 때문에 이를 맞춰주는 과정에서 button이 아래로 살짝 밀리게 된 것이다. 잘 생각해보면 이 버튼을 담은 div도 button을 넣었기 때문에 기본 사이즈가 그렇게 맞춰져 있었고 그래서 div 크기를 따로 설정해주기 전까지는 가운데 정렬이 잘 맞았으나, button 크기 기준이 아닌 가릴 요소를 기준으로 크기를 재조정하면서 가운데 정렬이 깨진 것이다. 이를 위해 button component의 값을 적절히 조정해주면 된다.

h-full 을 button에게 주면 아무래도 버튼 사이즈가 위와 같이 조금 줄어들기 때문에, 그리고 좌우 대칭이 맞지 않기 때문에 이를 재조정 해주는 작업을 진행하면 된다.

        <Button
          variant='ghost'
          size='icon'
          className='h-full aspect-square'
        >
          <ChevronLeft />
        </Button>

이 뒤에 왼쪽 오른쪽으로 움직이게 만드는 것도 진행했는데, 다른 할일들도 해야하기에 오늘은 여기까지.

0개의 댓글