[코드잇 스프린트] 고급 프로젝트 1

이언덕·2025년 6월 11일
post-thumbnail

1️⃣ 새로 해보는 것

이번 고급 프로젝트를 하면서 새로 해보는 것들이 있다.
간단하게 새로 해보는 것에 대해서 알아보고 가자!


Tailwind

이번 프로젝트에서는 Tailwind를 사용하게 되었다.
Tailwind에 대해 간단하게 알아보자.

🎨 Tailwind CSS란 무엇인가?

Tailwind CSS는 유틸리티 퍼스트(Utility-First) 방식의 CSS 프레임워크이다.
기존의 HTML처럼 직접 클래스를 만들고 스타일을 정의하는 방식이 아니라, 미리 정의된 유틸리티 클래스를 조합하여 스타일을 구성하는 방식이다.

<!-- 예시 -->
<button class="bg-blue-500 text-white px-4 py-2 rounded">
  버튼
</button>

위와 같이 class에 여러 유틸리티 클래스를 조합해 하나의 UI를 완성할 수 있다.



🔧 유틸리티 클래스란?

유틸리티 클래스는 하나의 스타일 속성만을 담당하는 단일 기능 클래스이다.
예를 들어,

  • bg-blue-500 : 배경색을 파란색으로 지정
  • text-white : 텍스트 색상을 흰색으로 설정
  • px-4 py-2 : 좌우, 상하 패딩을 설정
  • rounded : 테두리를 둥글게 처리

이처럼 여러 유틸리티 클래스를 조합해서 원하는 UI를 구성할 수 있다.



Tailwind CSS의 장점

  • 빠른 개발 속도 HTML 안에서 바로 스타일을 입힐 수 있어 CSS 파일을 별도로 작성하지 않아도 된다.

  • 일관된 디자인 시스템
    클래스가 체계적으로 정리되어 있어서 디자인의 일관성을 유지하기 좋다.

  • 작은 CSS 번들 사이즈
    실제 사용하는 클래스만 CSS에 포함되기 때문에 최종 번들 크기가 작아진다.



🚫 Tailwind CSS의 단점

  • HTML 가독성 저하
    클래스가 많아지면 HTML 코드가 복잡해 보일 수 있다.

  • 처음에는 낯선 클래스 명
    클래스 이름이 직관적이지 않다고 느낄 수 있어서 익숙해지는 데 시간이 걸릴 수 있다.



💡 Tailwind CSS는 이런 경우에 추천

  • 디자인 시스템을 일관되게 유지하고 싶은 팀
  • 빠르게 프로토타입을 만들어야 하는 프로젝트
  • 컴포넌트 중심 UI 개발을 선호하는 프론트엔드 개발자

📚 참고 자료




App router

🚀 Next.js App Router란?

Next.jsApp Routerpages 디렉토리 기반의 기존 라우팅 시스템을 대체하는 새로운 파일 기반 라우팅 시스템이다.
버전 13부터 도입되었으며, 더 유연한 구조와 향상된 기능을 제공한다.



📁 디렉토리 기반 라우팅

기존의 pages 디렉토리 대신, app 디렉토리를 사용하여 라우팅을 구성한다.

  • app/page.tsx → 루트 경로(/)
  • app/about/page.tsx/about 경로
  • app/blog/[slug]/page.tsx → 동적 라우팅(/blog/123)



🧱 주요 구성 요소

  • page.tsx : 해당 경로의 페이지 컴포넌트
  • layout.tsx : 공통 레이아웃 정의 (헤더, 푸터 등)
  • loading.tsx : 해당 경로 로딩 시 보여줄 UI
  • error.tsx : 에러 발생 시 보여줄 UI
  • template.tsx : 레이아웃을 공유하지 않고 새로 마운트할 때 사용



🔄 서버 컴포넌트 기반

App Router는 기본적으로 React Server Components를 사용한다.
클라이언트와 서버의 역할을 구분해 성능 최적화가 가능하다.

  • 서버에서 데이터를 미리 불러오고 렌더링 → 빠른 응답
  • 클라이언트 컴포넌트는 "use client" 지시어로 지정



⚙️ 기타 특징

  • ✅ 경로마다 자체적인 layout 구성 가능
  • ✅ 중첩 라우팅(Nested Routes) 지원
  • ✅ 폴더 구조만으로 명확한 라우팅 설정 가능
  • ✅ 파일 기반 로딩, 에러 처리 내장



📚 참고 자료




StoryBook

📘 Storybook이란?

Storybook은 컴포넌트 기반 개발을 위한 UI 개발 환경 도구이다.
React, Vue, Svelte, Angular 등 다양한 프레임워크에서 사용할 수 있으며, 컴포넌트를 독립적으로 개발하고 테스트할 수 있게 도와준다.



🎯 주요 목적

  • 컴포넌트를 독립적인 상태에서 확인하고 테스트할 수 있다.
  • 디자인 시스템이나 컴포넌트 라이브러리 문서화에 적합하다.
  • 개발자, 디자이너, QA가 함께 협업하기 쉬운 환경을 제공한다.



🧱 핵심 개념

  • Stories : 각 컴포넌트의 다양한 상태를 보여주는 단위 파일 (Button.stories.tsx)
  • Canvas : 컴포넌트를 시각적으로 확인하고 상호작용할 수 있는 화면
  • Docs : 자동 생성되는 컴포넌트 설명 문서 탭
  • Controls : props 값을 조작할 수 있는 UI 패널

⚙️ 사용 예시

// Button.stories.tsx
import { Button } from './Button';
//
export default {
  title: 'Components/Button',
  component: Button,
};
//
export const Primary = () => <Button label="Primary" variant="primary" />;
export const Secondary = () => <Button label="Secondary" variant="secondary" />;

위처럼 각 상태를 스토리로 정의하면 Canvas에서 다양한 모습의 컴포넌트를 확인할 수 있다.



✅ 장점

  • UI 개발 시 빠른 피드백 루프 제공
  • 디자인 시스템 문서화, 테스트 자동화, 비주얼 회귀 테스트 등 다양한 확장 기능
  • <span style="color:skyblue">컴포넌트 재사용성과 일관성</span> 강화



📚 참고 자료




2️⃣ 공통 컴포넌트

🧩 Button

GlobalNomad 프로젝트에서 작성한 Button 컴포넌트는 다양한 상황에서 재사용할 수 있도록 사이즈와 스타일을 조합 가능하게 만든 유연한 버튼 컴포넌트이다.



🏗 컴포넌트 구조 및 코드 분석

/components/button/Button.tsx

버튼 컴포넌트의 핵심 파일로, 주요 동작 방식은 아래와 같다.

import { ButtonProps, SizeVariant } from "@/types/button";
//
const sizeStyles: Record<SizeVariant, string> = {
  calendar:
    "mobile:w-[327px] mobile:h-[50px] mobile:text-14-m tablet:w-[695px] tablet:h-[50px] tablet:text-16-m desktop:w-[135px] desktop:h-[50px] desktop:text-16-m",
  chooseModal:
    "mobile:w-[113px] mobile:h-[41px] mobile:text-14-m tablet:w-[135px] tablet:h-[47px] tablet:text-16-m desktop:w-[135px] desktop:h-[47px] desktop:text-16-m",
  empty:
    "mobile:w-[182px] mobile:h-[19px] mobile:text-16-m tablet:w-[182px] tablet:h-[19px] tablet:text-16-m desktop:w-[182px] desktop:h-[19px] desktop:text-16-m",
  experienceRegister:
    "mobile:w-[138px] mobile:h-[48px] mobile:text-16-m tablet:w-[138px] tablet:h-[48px] tablet:text-16-m desktop:w-[138px] desktop:h-[48px] desktop:text-16-m",
  experienceRegister2:
    "mobile:w-[120px] mobile:h-[41px] mobile:text-14-m tablet:w-[120px] tablet:h-[41px] tablet:text-14-m desktop:w-[120px] desktop:h-[41px] desktop:text-14-m",
  modal:
    "mobile:w-[180px] mobile:h-[41px] mobile:text-14-m tablet:w-[200px] tablet:h-[47px] tablet:text-16-m desktop:w-[200px] desktop:h-[47px] desktop:text-16-m",
  myInfo:
    "mobile:w-[157.5px] mobile:h-[48px] mobile:text-14-m tablet:w-[120px] tablet:h-[41px] tablet:text-16-m desktop:w-[120px] desktop:h-[41px] desktop:text-16-m",
  reservation:
    "mobile:w-[157.5px] mobile:h-[37px] mobile:text-14-m tablet:w-[232px] tablet:h-[37px] tablet:text-14-m desktop:w-[71px] desktop:h-[29px] desktop:text-14-m",
  review:
    "mobile:w-[309px] mobile:h-[37px] mobile:text-14-m tablet:w-[476px] tablet:h-[37px] tablet:text-14-m desktop:w-[71px] desktop:h-[29px] desktop:text-14-m",
  reviewModal:
    "mobile:w-[279px] mobile:h-[41px] mobile:text-14-m tablet:w-[325px] tablet:h-[54px] tablet:text-16-m desktop:w-[325px] desktop:h-[54px] desktop:text-16-m",
  search:
    "mobile:w-[85px] mobile:h-[41px] mobile:text-14-m tablet:w-[120px] tablet:h-[50px] tablet:text-16-m desktop:w-[120px] desktop:h-[50px] desktop:text-16-m",
  sign: "mobile:w-[328px] mobile:h-[54px] mobile:text-16-m tablet:w-[640px] tablet:h-[54px] tablet:text-16-m desktop:w-[640px] desktop:h-[54px] desktop:text-16-m",
};
//
const variantStyles = {
  primary: "bg-brand-500 text-white",
  secondary: "bg-gray-200 text-[#EDEEF2]",
  outline: "bg-white text-[#707177] border border-[#C6C8CF]",
} as const;
//
function mergeClasses(...classes: (string | false | undefined)[]) {
  return classes.filter(Boolean).join(" ");
}
//
export default function Button({
  children,
  onClick,
  disabled = false,
  size = "sign",
  variant = "primary",
  fullWidth = false,
  rounded = true,
  className = "",
  type = "button",
}: ButtonProps) {
  const baseStyle =
    "font-medium transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed";
//
  const finalClassName = mergeClasses(
    baseStyle,
    sizeStyles[size],
    variantStyles[variant],
    fullWidth && "w-full",
    rounded && "rounded",
    className,
  );
//
  return (
    <button
      type={type}
      onClick={onClick}
      className={finalClassName}
      disabled={disabled}
      style={rounded ? { borderRadius: "16px" } : undefined}

      {children}
    </button>
  );
}

✅ 전체 흐름 요약

  • ButtonProps 타입을 기반으로 다양한 props를 전달받아 버튼을 유연하게 조작할 수 있다.
    • size: 버튼의 크기와 폰트 크기 (sizeStyles로 지정)
    • variant: 색상 종류 (variantStyles로 지정)
    • fullWidth: 버튼 너비를 100%로 만들지 여부
    • rounded: 테두리 둥글게 처리 여부 (border-radius: 16px)
    • className: 사용자 추가 클래스
    • type, disabled, onClick, children: HTML 기본 속성

모든 스타일은 mergeClasses() 함수를 통해 결합되어 최종 Tailwind 클래스 문자열을 만들어 <button>에 넣어준다.



🧩 주요 코드 설명

1. sizeStyles

const sizeStyles: Record<SizeVariant, string> = {
  ...
  sign: "mobile:w-[328px] mobile:h-[54px] mobile:text-16-m tablet:w-[640px] tablet:h-[54px] ...",
};
  • 버튼의 크기를 정의하는 부분
  • mobile:, tablet:, desktop: prefixTailwind의 반응형 구문이다
  • SizeVariant 타입을 기준으로 key들을 정의했기 때문에 타입 안정성이 있다

2. variantStyles

const variantStyles = {
  primary: "bg-brand-500 text-white",
  secondary: "bg-gray-200 text-[#EDEEF2]",
  outline: "bg-white text-[#707177] border border-[#C6C8CF]",
} as const;
  • 버튼 색상(배경 + 글자색)을 정의하는 곳
  • variant="primary"처럼 쓰면 이 스타일이 적용된다

3. mergeClasses 함수

function mergeClasses(...classes: (string | false | undefined)[]) {
  return classes.filter(Boolean).join(" ");
}
  • undefined, false, 빈 값 등을 제거하고 유효한 Tailwind 클래스만 결합
  • 가령 fullWidth && "w-full" 같은 조건 클래스가 있을 때 유용함

4. finalClassName 조합

const finalClassName = mergeClasses(
  baseStyle,
  sizeStyles[size],
  variantStyles[variant],
  fullWidth && "w-full",
  rounded && "rounded",
  className,
);
  • 여기서 size, variant, 사용자 정의 클래스가 한 줄로 합쳐짐
  • roundedtrueTailwindrounded 클래스가 붙고
  • fullWidthtruew-full이 추가됨



🧾 ButtonProps 타입 정의 (/types/button.ts)

export type SizeVariant =
  | "calendar" | "chooseModal" | "empty" | "experienceRegister"
  | "experienceRegister2" | "modal" | "myInfo" | "reservation"
  | "review" | "reviewModal" | "search" | "sign";
//
export type ButtonProps = {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  size?: SizeVariant;
  variant?: "primary" | "secondary" | "outline";
  fullWidth?: boolean;
  rounded?: boolean;
  className?: string;
  type?: "button" | "submit" | "reset";
};
  • 총 13가지 사이즈 옵션을 가진 SizeVariant 타입을 제공
  • variantprimary, secondary, outline 중 선택 가능
  • fullWidth, rounded, disabled 등 다양한 UI 옵션 제공



🧪 사용 예시

<Button size="review" variant="primary">리뷰 작성</Button>
//
<Button size="sign" variant="secondary" fullWidth>회원가입</Button>
//
<Button size="modal" variant="outline" onClick={handleClick}>닫기</Button>
  • 여러 사이즈와 여러 스타일을 적용한 버튼



🔍 스토리북 설정 분석

📄 /stories/button/Button.stories.tsx

const meta: Meta<ButtonProps> = {
  title: "Components/Button",
  component: Button,
  tags: ["autodocs"],
  argTypes: {
    size: {
      control: "select",
      options: [
        "calendar", "chooseModal", "empty", "experienceRegister",
        "experienceRegister2", "modal", "myInfo", "reservation",
        "review", "reviewModal", "search", "sign"
      ]
    },
    variant: {
      control: "select",
      options: ["primary", "secondary", "outline"]
    },
    fullWidth: { control: "boolean" },
    rounded: { control: "boolean" },
    disabled: { control: "boolean" },
  },
};
  • Storybook에서 드롭다운, 토글 스위치로 컴포넌트의 다양한 prop을 직접 조작해볼 수 있도록 설정되어 있다.
  • argTypes를 활용해 select, boolean 타입으로 컨트롤 UI를 자동 생성한다.
export const Default: Story = {
  args: {
    children: "버튼",
    size: "sign",
    variant: "primary",
    fullWidth: false,
    rounded: true,
    disabled: false,
  },
};
  • Default 스토리를 통해 기본 버튼 예제를 시각적으로 확인 가능
  • children, size, variant 등을 설정하여 버튼을 렌더링한다.

🧾 Storybook 화면 예시

위는 Button 컴포넌트의 Storybook 화면이다.

  • 상단에는 실제 버튼이 렌더링된 모습을 확인할 수 있고,
  • 하단 표에서는 각 props에 대한 타입, 기본값, 제어 컨트롤을 UI로 조작할 수 있도록 구성되어 있다.
  • children(필수), size, variant, type, disabled, rounded, fullWidth 등을 직접 조작하며 테스트해볼 수 있다.



🛠️ Tailwind 기반 버튼 컴포넌트 리팩토링 기록

팀원의 피드백이 있었기 때문에 코드를 수정해야했다.
어떤 것을 바꿔야하는지 살펴보자.

✅ 피드백 내용

1. style 속성 사용 → Tailwind 클래스로 교체

  • style={{ borderRadius: "16px" }} 사용 중이었음
  • ✅ Tailwind 클래스인 rounded-[16px]으로 대체
    📷 Before
style={rounded ? { borderRadius: "16px" } : undefined}

📷 After

{rounded && "rounded-[16px]"}

.



2. className 병합 함수 통일: tMergecn

  • ❌ 기존에 사용하던 병합 함수가 tailwind-merge 기반이 아님
  • cn 유틸 함수로 병합 로직 일원화 → props.className도 정상 반영

📷 Before

const finalClassName = mergeClasses(...)

📷 After

import { cn } from "@/utils/classNames";
const finalClassName = cn(baseStyle, variantStyles[type], sizeStyles[size], ...)

.



3. variant 스타일 수정 (Figma 기준 색상 반영)

  • ❌ 버튼의 색깔이 1가지 더 있었기 때문에 버튼 색깔을 더 추가해주었다.
  • ✅ 실제 Figma에서는 text-gray-600이 기준
    📷 Before
secondary: "bg-gray-200 text-[#EDEEF2]"

📷 After

secondary1: "bg-gray-200 text-[#EDEEF2]"
secondary2: "bg-gray-50 text-gray-600"

.



💬 회고

  • 단순 스타일 개선을 넘어 팀 내 공통 UI 컴포넌트의 확장성재사용성을 높인 점이 핵심
  • Tailwind의 조건부 클래스 구성 패턴에 더 익숙해짐
  • 디자이너 피그마 기준을 기반으로 컴포넌트 스타일을 맞추는 협업이 중요함을 다시 체감했다.


GlobalNomad 프로젝트의 Footer 컴포넌트는 페이지 하단에 들어가는 공통 요소로, 소셜 아이콘, 회사 정보, 링크 메뉴 등을 포함한 반응형 레이아웃을 제공한다.



✅ 전체 구조 요약

  • ©codeit - 2023 : 좌측 하단 표시
  • Privacy Policy, FAQ : 가운데 정렬된 링크 메뉴
  • Facebook, Instagram, YouTube, X : 우측 소셜 아이콘

모든 콘텐츠는 Tailwind CSSgrid + flex 조합으로 반응형 배치되며, sm 브레이크포인트 이상에서 데스크탑 레이아웃으로 자동 전환된다.



🧩 주요 코드 설명

1. 소셜 아이콘 배열

const socialIcons = [
  { href: "https://www.facebook.com/", src: FacebookIcon, alt: "Facebook" },
  { href: "https://www.instagram.com/", src: InstagramIcon, alt: "Instagram" },
  { href: "https://www.youtube.com/", src: YoutubeIcon, alt: "YouTube" },
  { href: "https://x.com/", src: XIcon, alt: "X" },
];
  • 각 소셜 링크와 SVG 아이콘을 미리 정의한 배열
  • .map()을 통해 반복 렌더링하며 접근성과 key 속성을 포함

2. 그리드 레이아웃 구조

<div className="
  w-[90%] mx-auto
  grid grid-cols-3 grid-rows-2
  sm:grid-rows-1 sm:grid-cols-3
  items-center gap-2
  text-center sm:text-left
">
  • 기본 2행 3열 grid에서 sm 사이즈 이상에서는 1행 3열로 변경
  • 가운데 링크, 좌측 카피라이트, 우측 소셜 아이콘이 자연스럽게 배치됨

3. 각 영역 설명

📌 링크 영역

<div className="
  row-start-1 col-span-3
  flex justify-center gap-2
  sm:col-span-1 sm:col-start-2 sm:row-start-1
">
  • 모바일에선 첫 번째 줄 가운데 정렬
  • 데스크탑에선 가운데 열(col-start-2)에 배치

📌 카피라이트 영역

<div className="
  row-start-2 col-start-1
  flex justify-start
  sm:row-start-1 sm:col-start-1
">
  • 모바일에선 두 번째 줄 왼쪽 정렬
  • 데스크탑에선 첫 번째 열로 이동

📌 소셜 영역

<div className="
  row-start-2 col-start-3
  flex justify-end gap-1
  sm:row-start-1 sm:col-start-3
">
  • 모바일에선 두 번째 줄 오른쪽 정렬
  • 데스크탑에선 세 번째 열로 이동



import Footer from "@/components/footer/Footer";
//
export default function Page() {
  return (
    <>
      {/* 본문 콘텐츠 */}
      <Footer />
    </>
  );
}

.

🧾 Storybook 화면 예시

위는 Footer 컴포넌트의 Storybook에서의 미리보기 화면이다.

  • 좌측에 ©codeit - 2023
  • 가운데에 Privacy Policy, FAQ 링크
  • 우측에 소셜 아이콘이 배치됨
  • 반응형이 적용되어 sm 이하 해상도에서는 상하 2줄로 구성됨

처음에는 전체 레이아웃을 flex로 구현하려고 했다.
데스크탑 환경에서는 flex를 활용해 각 요소들을 수평으로 잘 정렬할 수 있었지만, 모바일 환경에선 문제가 발생했다.

  • 링크 메뉴가 위로 올라가거나,
  • 저작권 문구와 소셜 아이콘이 예상치 못한 위치로 쏠리는 등,

화면 크기에 따라 요소들의 순서와 정렬이 완전히 무너지는 현상이 나타났다.


이 문제를 해결하기 위해 flex 대신 grid 레이아웃으로 전환했다.
gridrow-start, col-start 속성을 활용해 각 요소가 어떤 화면 크기에서도 예상한 위치에 정확히 배치되도록 조절했다.

결과적으로 grid를 사용함으로써 모바일과 데스크탑 환경 모두에서 안정적인 레이아웃 구조를 유지할 수 있게 되었다.



목 디스크 부상으로 인해 일상생활이 불가능해 다른 팀원에게 넘겼다,,,,, 😭

0개의 댓글