NEXT.js에서 CSS 선택하기(?) - 블로그 이사 준비 1편

hojoon·2024년 3월 19일
0

NEXT.js에서 CSS 선택하기

최근에 넥스트로 새로운 사이드 프로젝트를 시작했는데 어떤 스타일링 도구를 선택해야 잘 선택했다고 생각이 들고 나중에 후회하지 않을까 고민하면서 여러 CSS 도구들을 두고 깊게 고민해봤다.

내가 선택할 수 있는 CSS 도구들

  • css module(module.scss)
  • tailwind
  • css-in-js
  • vanilla extract
  • scss

결론부터 말하자면 테일윈드 선택했음

결론부터 말하려니까 좀 억울한데 이거 선택하는데 일주일걸렸습니다..

왜??

공식문서의 추천

일단 공식문서부터 보자. 인생의 진리임

Styling

  • Next.js supports different ways of styling your application, including:
    • Global CSS: Simple to use and familiar for those experienced with traditional CSS, but can lead to larger CSS bundles and difficulty managing styles as the application grows.
    • CSS Modules: Create locally scoped CSS classes to avoid naming conflicts and improve maintainability.
    • Tailwind CSS: A utility-first CSS framework that allows for rapid custom designs by composing utility classes.
    • Sass: A popular CSS preprocessor that extends CSS with features like variables, nested rules, and mixins.
      CSS-in-JS: Embed CSS directly in your JavaScript components, enabling dynamic and scoped styling.

넥스트에서는 이러이러한 애들을 지원해준다고 한다. 근데 하나씩 살펴보자면 CSS-in-JS에서는 한계가 있다.

공식문서설명

Warning: CSS-in-JS libraries which require runtime JavaScript are not currently supported in Server Components. Using CSS-in-JS with newer React features like Server Components and Streaming requires library authors to support the latest version of React, including concurrent rendering.

We're working with the React team on upstream APIs to handle CSS and JavaScript assets with support for React Server Components and streaming architecture.

  • If you want to style Server Components, we recommend using CSS Modules or other solutions that output CSS files, like PostCSS or Tailwind CSS.

무슨 말이냐면 css-in-js 방식은 애초에 런타임에 자바스크립트로 css를 구성하고 만들어낸다. 이런 동작원리가 컴포넌트 기반의 현대웹에서는 매우 편리하고 좋은 개발자 경험을 만들어냈겠지만 넥스트로 오면 좀 달라진다. 아니 물론 개발자에게는 아직까지 좋겠지만 여튼 넥스트에서는 서버에서 스타일을 만들어내는 서버컴포넌트가 있는데 css-in-js 방식은 태생부터가 한계를 가지고 있다.

그럼에도 불구하고 나는 우선 styled-components를 선택하는데...

왜?

    1. 클라이언트 컴포넌트랑 서버 컴포넌트를 잘 분리하면 되는거 아닌가?? 애초에 서버컴포넌트에서는 데이터를 받아오기만 하고 css를 만들어내는 컴포넌트는 클라이언트 컴포넌트로만 하자.
    1. 테일윈드는 가독성이 너무 떨어짐.. dom태그에 css 이름들이 덕지덕지 붙어서 좀만 길어져도 한눈에 안들어온다는 단점이 있었음
    1. 재사용, 유지보수를 고려할 때 이거만한 css가 있나?
    1. 나에게만 좋은 개발자 경험 ㅋㅋ

일단(?) Styled Components 선택함.

  • 사용법은 매우 쉽다. 공식문서가 매우 친절하기 때문.
  • 세팅은 건너뜀. 넥스트에 너무 친절하게 설명이 잘되어있고 이번글은 그게 중요한게 아니니까 (하라는대로만 하면 잘됨)

내가 의도한거

우선 나는 최근에 디자인 패턴, 디자인 테마, 디자인 시스템, 함수형 코딩 등등 이런(?) 것들에 관심을 가졌었다. 그래서 재사용가능하고 유지보수에 용이한 여러 컴포넌트 덩어리들을 만들고 싶었음

  • Typography 컴포넌트
  • Flex box
  • Grid box
  • Button
  • input
  • Container box

이러한 덩어리들을 만들고 as속성이나, 프로퍼티로 size, color값만 줘서 가독성과 재사용성을 높이고 싶었다.

3. 결과물

Text Component

'use client';

import React from 'react';
import { AsElementProps, ColorProps } from '../types/core';
import { theme } from '@/design-system/theme';
import styled, { CSSProperties, css } from 'styled-components';

type TextProps = AsElementProps & {
  color?: ColorProps['color'];
  fontSize?: keyof typeof theme.fontSize;
  fontWeight?: CSSProperties['fontWeight'];
  lineHeight?: CSSProperties['lineHeight'];
  textAlign?: CSSProperties['textAlign'];
  casing?: CSSProperties['textTransform'];
  decoration?: CSSProperties['textDecoration'];
  isError?: boolean;
  isSuccess?: boolean;
};

const ErrorStyle = css`
  color: ${theme.colors.red10};
  font-size: ${theme.fontSize[12]};
  font-weight: 600;
`;

const SuccessStyle = css`
  color: ${theme.colors.blue10};
  font-size: ${theme.fontSize[12]};
  font-weight: 600;
`;

const StyledText = styled.span<TextProps>`
  ${({
    fontSize,
    fontWeight,
    textAlign,
    casing,
    decoration,
    color,
    isError,
    isSuccess,
    lineHeight,
  }) => css`
    font-size: ${fontSize || theme.fontSize[16]};
    font-weight: ${fontWeight || 400};
    text-align: ${textAlign || 'left'};
    text-transform: ${casing || 'none'};
    text-decoration: ${decoration || 'none'};
    color: ${color || 'black'};
    line-height: ${lineHeight || '100%'};
    ${isError && ErrorStyle}
    ${isSuccess && SuccessStyle}
  `}
`;

const Text = React.forwardRef<HTMLElement, TextProps>(
  ({ as = 'p', ...props }, ref) => {
    return <StyledText as={as} {...props} ref={ref} />;
  },
);

Text.displayName = 'Text';
export default Text;

Heading Component

'use client';

import React from 'react';
import { AsElementProps, ColorProps } from '../types/core';
import { theme } from '@/design-system/theme';
import styled, { CSSProperties, css } from 'styled-components';

type HeadingProps = AsElementProps & {
  color?: ColorProps['color'];
  fontSize?: keyof typeof theme.fontSize;
  fontWeight?: CSSProperties['fontWeight'];
  lineHeight?: CSSProperties['lineHeight'];
  textAlign?: CSSProperties['textAlign'];
  casing?: CSSProperties['textTransform'];
  decoration?: CSSProperties['textDecoration'];
};

const StyledHeading = styled.h1<HeadingProps>`
  ${({
    fontSize,
    fontWeight,
    textAlign,
    casing,
    decoration,
    color,
    lineHeight,
  }) => css`
    font-size: ${fontSize || theme.fontSize[30]};
    font-weight: ${fontWeight || 600};
    text-align: ${textAlign || 'left'};
    text-transform: ${casing || 'none'};
    text-decoration: ${decoration || 'none'};
    color: ${color || 'black'};
    line-height: ${lineHeight || '100%'};
    font-weight:;
  `}
`;

const Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(
  ({ as = 'h1', ...props }, ref) => {
    return <StyledHeading as={as} {...props} ref={ref} />;
  },
);

Heading.displayName = 'Heading';
export default Heading;

Button Component

'use client';

import { AsElementProps, ColorProps } from '@/components/types/core';
import { theme } from '@/design-system/theme';
import React, { ReactNode } from 'react';

import styled, { CSSProperties, css } from 'styled-components';
import Icon from './Icon';

type ButtonProps = AsElementProps & {
  children: React.ReactNode;
  color?: ColorProps['color'];
  backgroundColor?: ColorProps['color'];
  //
  variant?: 'whitebg' | 'graybg' | 'redbg' | 'outline' | 'ghost';
  size?: 'small' | 'medium' | 'large';
  isDisabled?: boolean;
  isLoading?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
  //
  display?: CSSProperties['display'];
  width?: CSSProperties['width'];
  height?: CSSProperties['height'];
  minWidth?: CSSProperties['minWidth'];
  minHeight?: CSSProperties['minHeight'];
  //
  fontSize?: CSSProperties['fontSize'];
  fontWeight?: CSSProperties['fontWeight'];
  lineHeight?: CSSProperties['lineHeight'];
  textAlign?: CSSProperties['textAlign'];
  //
  padding?: CSSProperties['padding'];
  paddingTop?: CSSProperties['paddingTop'];
  paddingRight?: CSSProperties['paddingRight'];
  paddingBottom?: CSSProperties['paddingBottom'];
  paddingLeft?: CSSProperties['paddingLeft'];
  margin?: CSSProperties['margin'];
  marginTop?: CSSProperties['marginTop'];
  marginRight?: CSSProperties['marginRight'];
  marginBottom?: CSSProperties['marginBottom'];
  marginLeft?: CSSProperties['marginLeft'];
  //
  round?: string;
};

const SizeCSS = css<Pick<ButtonProps, 'size'>>`
  ${({ size }) => {
    switch (size) {
      case 'small':
        return css`
          width: 76px;
          height: 40px;
          font-size: 13px;
          word-wrap: break-word;
        `;
      case 'medium':
        return css`
          width: 116px;
          height: 54px;
          font-size: 18px;
          border-radius: 10px;
          word-wrap: break-word;
        `;
      case 'large':
        return css`
          width: 160px;
          height: 58px;
          border-radius: 14px;
          font-size: 22px;
        `;
    }
  }}
`;

// variant?: 'whitebg' | 'graybg' | 'redbg' | 'outline' | 'ghost';

const varsCSS = css<Pick<ButtonProps, 'variant'>>`
  ${({ variant }) => {
    switch (variant) {
      case 'whitebg':
        return css`
          background-color: white;
          color: black;
        `;
      case 'graybg':
        return css`
          background-color: ${theme.colors.slate12};
          color: white;
        `;
      case 'redbg':
        return css`
          background-color: ${theme.colors.red11};
          color: white;
        `;
      case 'outline':
        return css`
          background-color: black;
          color: white;
          border: 1px solid ${theme.colors.slate3};
          outline: thin;
          outline-color: white;
        `;
      case 'ghost':
        return css`
          border: none;
          background-color: transparent;
          color: ${theme.colors.gray9};
        `;
    }
  }}
`;

const StyledButton = styled.button<ButtonProps>`
  color: ${(props) => props.color || props.theme.colors.gray11};
  background-color: ${(props) => props.backgroundColor || 'white'};
  display: ${(props) => props.display || 'inline-block'};
  width: ${(props) => props.width || '110px'};
  height: ${(props) => props.height || '50px'};
  min-width: ${(props) => props.minWidth || '76px'};
  min-height: ${(props) => props.minHeight || '40px'};
  font-size: ${(props) => props.fontSize || '13px'};
  font-weight: ${(props) => props.fontWeight || 'normal'};
  text-align: ${(props) => props.textAlign || 'center'};
  padding: ${(props) => props.padding || '4px 8px'};
  padding-top: ${(props) => props.paddingTop || '4px'};
  padding-bottom: ${(props) => props.paddingBottom || '4px'};
  padding-right: ${(props) => props.paddingRight || '8px'};
  padding-left: ${(props) => props.paddingLeft || '8px'};
  margin: ${(props) => props.margin || '0'};
  margin-top: ${(props) => props.marginTop || '0'};
  margin-right: ${(props) => props.marginRight || '0'};
  margin-bottom: ${(props) => props.marginBottom || '0'};
  margin-left: ${(props) => props.marginLeft || '0'};
  border-radius: 7px;
  border: solid 2px black;
  letter-spacing: 0.4px;
  cursor: pointer;
  &:hover {
    opacity: 0.75;
  }
  ${SizeCSS}
  ${varsCSS}
`;

const Wrapper = styled.div`
  display: flex;
  align-items: center;
`;

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ as = 'button', children, ...props }, ref) => {
    return (
      <Wrapper>
        <StyledButton as={as} {...props} ref={ref}>
          <Icon name="camera" />
          {children}
        </StyledButton>
      </Wrapper>
    );
  },
);

Button.displayName = 'Button';
export default Button;

As Types

import { theme } from '@/design-system/theme';

type AsProps = {
  as?: Exclude<keyof React.JSX.IntrinsicElements, keyof SVGElement>;
};

type ElementProps = Omit<React.HtmlHTMLAttributes<HTMLElement>, 'as'>;

export type AsElementProps = AsProps & ElementProps;

export type ColorProps = {
  color: keyof typeof theme.colors;
};

여기까지가 내가 만든 컴포넌트 결과물이다.

  1. 일단은 성공했다. 고민했던 것은 text, heading 컴포넌트를 만들고 text를 쓴다면 as = "span", as = "p"로 태그 네임을 지정해주고 여러 size, color를 props로 받고 싶었는데 의도한대로 동작을 잘했다. 또 heding이라면 h1, h2, h3 등등 잘 바꿔가면서 동작하도록 의도했다.
    이 와중에 어떻게하면 내가 스타일을 입힌 컴포넌트를 만들어내고 재사용할 수 있을까 고민했었지만 forwardRef가 ref를 전달해주는거 뿐만이 아니라 컴포넌트를 만들수 있다는 것을 알게되면서 구현이 가능했다.

  2. button 컴포넌트. text,heading을 만들면서 스타일을 입힌 컴포넌트를 만드는 법을 알아냈고, htmlelement에서 제네릭으로 타입스크립트에서 어떻게 써야하는지까지 감을 잡아서 만드는건 쉬웠다. 다만 여기서 내가 스타일드 컴포넌트를 포기하고 다시 테일윈드로 돌아가게 되는데...

내가 만든 버튼은 너무 안예뻤음 ㅋㅋ

진짜로 내가 만든 컴포넌트가 너무 안예뻤다. 디자인 감각이 없어서 그런가 다른 라이브러리에서 여러 버튼들을 참고로 삼아서 만들었는데 뭔가 그냥 안예쁨 비율이나 컬러나 패딩 등등

그래서 그냥 깔끔하게 포기하고 테일윈드로 다시 돌아옴. 왜냐면 내가 고민했던 점도 직접 구현해보면서 스스로 답을 찾기도 했고 미련없이 깔끔하게 포기함 ㅋㅋ!

테일윈드 너무 싫다.

  • 아까도 말했지만 가독성이 너무 떨어짐
  • 공식문서 켜놓고 cmd + k 눌러가면서 이거 이름 뭐였떠라? 테일윈드에선 이거 어떻게 쓰냐? 이러면서 찾는게 너무 스트레스 유발임

그러나 shadcn 이라고 하는 미친 라이브러리가 있음.

  • 기본적으로 예쁘게 잘 만들어진 ui를 쓸 수 있고 설치 개념보다 코드 복사의 개념으로 나만의 컴포넌트를 가지면서, 내가 커스텀해서 자유롭게 쓸 수 있다는 장점이 있다.

테일윈드도 프로퍼티로 동적으로 스타일을 조정할 수 있고 재사용가능한 컴포넌트도 많이 만들 수 있음.!!

힌트는 shadcn 깃헙에서 찾을 수 있었는데, 일단 shadcn 공식문서를 보면 얘네는 테일윈드로 버튼 ui 만들어놓고 variant = "ghost", "outline", "destructive" 이러면 다른 버튼들이 만들어짐. 이게 딱 내가 원하던거 였는데 뭐지 얘네는 어떻게 했냐 이러면서 코드를 좀 찾아봄.

실패 사례

일단 위와 같은 동작을 만들어내기 위해서 나는 템플릿 리터럴? 빽틱?이랑 props로 적절하게 조절하면 된다고 생각했음. 무슨 말이냐면 테일윈드는 className에 string 값으로 여러가지 미리 약속된 유틸 클래스들을 주면 그에 맞는 css값들이 생성되는데 props로 내가 variant를 준다! 하면 button에서는 props를 받고

`${variant} && variant에 맞는 클래스 네임들`

이런식으로 동작하도록 의도했음.
역시 내 개발자 인생답게 당연하게도 안됨. 브라우저에서 개발자도구 켜서 클래스네임 보면 잘 들어가는데도 안됨. 이유는 테일윈드에 동작 방식때문이었다.

테일윈드는?

ml:auto, mx,my 등등 테일윈드에서 정의한 유틸 클래스네임들을 쓰고 그에 맞는 css값을 찾아서 준다.
사용되지 않는 클래스네임들은? 당연히 크기만 차지할꺼고 애플리케이션 성능에도 악영향을 주기 때문에 컴파일단계에서 삭제된다. 즉, 내가 props로 테일윈드 className을 동적으로 조절하려고 했지만 테일윈드는 사용되지 않는 클래스네임이라고 생각해서 컴파일단계에서 삭제해버렸고 나는 만들어지지 않은 유틸 클래스네임을 사용하려고 하기 때문에 문제가 생김.(횡설수설)

그럼 어떻게 해야 하나요??

  • clsx
  • twmerge
  • class-variance-authority

위 세가지 조합으로 만들 수 있음.

첫번째 핵심은 cn이라는 유틸함수다.

두번째 핵심은 cva(시바)를 활용한 프로퍼티 조작.

나도 각각 라이브러리를 깊게 들여다보고 사용한 것이 아닌 아 이렇게 하면 만들어지는구나를 보고 적용한거라 자세한 설명은 내 맘대로 생략하겠음. clsx와 twmerge를 사용해서 클래스네임을 동적으로 조작가능하게함(조건부 렌더링 등등), 그리고 cva를 써서 컴포넌트의 className들 객체를 만든다?라고 생각하면 될거 같아요 -> heading 쪽을 잘 보면 as라는 이름으로 클래스네임 오브젝트를 만들고 컴포넌트에서는 as를 받도록 했다. default는 h1이고 as가 h2, h3, h4이면 알아서 각각 맞는 클래스네임들을 가지도록 의도한거였음.

tailwind로 구현한 결과

import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Text

import { cn } from '@/lib/utils';
import { HtmlHTMLAttributes, ReactNode } from 'react';

type Combine = HtmlHTMLAttributes<HTMLParagraphElement> &
  HtmlHTMLAttributes<HTMLSpanElement>;

interface TextProps extends Combine {
  children?: ReactNode;
  addClass?: string;
  as?: string;
}

export default function Text({
  children,
  as = 'p',
  addClass,
  ...props
}: TextProps) {
  if (as === 'span') {
    return (
      <span className={cn('leading-6', addClass)} {...props}>
        {children}
      </span>
    );
  }
  return (
    <p className={cn('leading-6', addClass)} {...props}>
      {children}
    </p>
  );
}

마찬가지로

Heading

import { cn } from '@/lib/utils';
import { VariantProps, cva } from 'class-variance-authority';
import { HtmlHTMLAttributes, ReactNode } from 'react';

export const HeadingVariants = cva(`scroll-m-20`, {
  variants: {
    as: {
      default: 'text-4xl font-extrabold tracking-tight lg:text-5xl',
      h2: 'border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0',
      h3: 'scroll-m-20 text-2xl font-semibold tracking-tight',
      h4: 'text-xl font-semibold tracking-tight',
    },
  },
  defaultVariants: {
    as: 'default',
  },
});

interface HeadingTypeProps
  extends HtmlHTMLAttributes<HTMLHeadingElement>,
    VariantProps<typeof HeadingVariants> {
  children?: ReactNode;
  addClass?: string;
}

export default function Heading({
  children,
  as,
  addClass,
  ...props
}: HeadingTypeProps) {
  if (as === 'h2') {
    return (
      <h2 className={cn(HeadingVariants({ as }), addClass)} {...props}>
        {children}
      </h2>
    );
  }
  if (as === 'h3') {
    return (
      <h3 className={cn(HeadingVariants({ as }), addClass)} {...props}>
        {children}
      </h3>
    );
  }
  if (as === 'h4') {
    return (
      <h4 className={cn(HeadingVariants({ as }), addClass)} {...props}>
        {children}
      </h4>
    );
  }
  return (
    <h1 className={cn(HeadingVariants({ as }), addClass)} {...props}>
      {children}
    </h1>
  );
}

결론

  • 사람들이 많이 선택하는데는 이유가 있다.
    • 클래스네임들이 덕지덕지 붙은게 별로였지만 막상 써보니 잘 쓰면(?) 된다. 내가 고민하고 의도했던 텍스트, 헤딩, 버튼 컴포넌트 뿐만이 아니라
import SidebarHeader from './components/SidebarHeader';
import SidebarCategoryTitle from './components/SidebarCategoryTitle';
import SidebarItem from './components/SidebarItem';
import { Separator } from '../ui/separator';
import UserCard from './components/UserCard';

export default function Sidebar() {
  return (
    <aside className="fixed inset-y-0 left-0 flex-wrap items-center justify-between block w-full p-0 transition-all duration-200 ease-in-out -translate-x-full border-0 shadow-none dark:bg-slate-850 z-990 rounded-2xl xl:translate-x-0 bg-white max-w-64 overflow-y-auto">
      <SidebarHeader />
      <Separator />
      <div className="items-center block w-full h-auto grow basis-full">
        <ul className="flex flex-col pl-0 mb-0">
          <SidebarCategoryTitle title="main" />
          <SidebarItem name="home" color="text-emerald-500" title="Posts" />
          <SidebarCategoryTitle title="apps" />
          <SidebarItem
            name="layout-dashboard"
            color="text-orange-500"
            title="Dashboards"
          />
          <SidebarItem
            name="folder-kanban"
            color="text-sky-500"
            title="Projects"
          />
          <SidebarItem
            name="message-square"
            color="text-yellow-500"
            title="Chat"
          />
          <SidebarItem
            name="clapperboard"
            color="text-violet-500"
            title="Video"
          />
          <SidebarCategoryTitle title="my page" />
          <SidebarItem name="user" color="text-violet-500" title="Profile" />
          <SidebarItem
            name="credit-card"
            color="text-violet-500"
            title="Payments"
          />
          <SidebarItem
            name="list-checks"
            color="text-violet-500"
            title="Tasks"
          />
        </ul>
      </div>
      <UserCard />
    </aside>
  );
}

이렇게 컴포넌트를 잘 분리하고 잘 만들면 가독성도 꽤 괜찮아짐.

이어서 결론

  • 스타일링 도구 선택하는데 진짜 일주일 걸렸다. 작성하지는 않았지만 css module로도 해보고 vanilla extract로도 다양하게 시도해보았다. 회사였다면 진짜 욕먹었겠지만 다행히(?) 내 개인 프로젝트라 여유를 가지고 여러 스타일링 도구를 적용해보고 고민해보면서 기술을 선택하는 경험을 가져볼 수 있어서 좋았다. 또한 리액트였다면 그냥 아무거나 선택했으면 되었겠지만 넥스트라서 고민하고 생각할 거리들이 많았고 그러면서 서버 컴포넌트, 클라이언트 컴포넌트, 라우팅, 데이터 페칭 등등 넥스트에 대한 이해도도 같이 올라간거 같아서 나름 뿌듯하기도 한듯??

  • 일단 빨리 프로젝트 마무리나 해보자..!

profile
프론트? 백? 초보 개발자의 기록 공간

0개의 댓글