[week14/개인과제]게시판 Full Stack 도전기 - FE Part

CHO WanGi·2025년 6월 18일

KRAFTON JUNGLE 8th

목록 보기
72/89

아키텍처

기술 스택

  • FE : React, TypeScript, tailwind CSS
  • BE : node.js, express
  • DB : MySQL

완성 화면

FrontEnd

tailwind CSS 선택 이유

FrontEnd 에선 다양한 스타일링 기법이 있는데
CSS-in-JS 방식의 Styled Components 와
유틸리티 퍼스트 접근 방식의 tailWind CSS 두가지 방식이 있다.

그중 tailWind CSS 를 선택한 이유는 두가지이다.

  1. 퍼포먼스 최적화
    작은 게시판 프로젝트긴 하나, PurgeCSS 같은 라이브러리와 함께 최종 CSS 파일 크기를
    최소화해서 최적화된 성능을 제공할 수 있다.
  1. 일관된 디자인
    게시판은 동일한 디자인의 게시물 요약 컴포넌트,
    댓글 컴포넌트 동일한 디자인의 컴포넌트들이 반복되는 경우가 많다.
    tailwindCSS는 미리 정의된 색상 팔레트, 간격, 너비를 제공하기에
    일관된 디자인을 유지할 수 있는 장점이 있어서 선택하였다.
  • theme 정의
@import "tailwindcss";

/* @tailwind base;
@tailwind components;
@tailwind utilities; */

@theme {
  --color-primary-blue: #0642ab;
  --color-text-gray: #6c6c6c;
}

@font-face {
  font-family: "Pretendard-Regular";
  src: url("https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff")
    format("woff");
  font-weight: 400;
  font-style: normal;
}

버튼 컴포넌트 재사용

  • 로그인/회원가입 선택 버튼
  • 댓글 등록 버튼

버튼들이 디자인은 동일한데 안에 색상, 사이즈만 달라서 이걸 Props로 내려주어서
이 컴포넌트를 재사용하고자 하였다.

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
  variant?: "primary" | "secondary" | "danger" | "no_background";
  size?: "sm" | "md" | "lg";
  fullWidth?: boolean;
}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      variant = "primary",
      size = "md",
      fullWidth = false,
      className = "",
      ...props
    },
    ref
  ) => {
    const baseStyle =
      "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";

    const variantStyles = {
      primary: "text-white font-bold bg-primary-blue focus:ring-blue-500",
      secondary:
        "bg-primary-blue/20 font-bold text-primary-blue focus:ring-primary/50",
      no_background:
        "bg-transparent text-text-gray border-solid border-[0.50px]",
      danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
    };

    // sizeStyles 부분을 고정 크기로 변경
    const sizeStyles = {
      sm: "w-[80px] h-[40px] text-xs", // 80px * 40px
      md: "w-[140px] h-[40px] text-sm", // 140px * 40px
      lg: "w-[297px] h-[40px] text-base", // 297px * 40px
    };

    // fullWidth가 true일 경우, size로 지정된 width를 덮어씁니다.
    const widthStyle = fullWidth ? "w-full" : sizeStyles[size].split(" ")[0];
    const heightAndTextStyle = sizeStyles[size].substring(
      sizeStyles[size].indexOf(" ") + 1
    );

    const combinedClassName = [
      baseStyle,
      variantStyles[variant],
      heightAndTextStyle,
      widthStyle,
      className,
    ]
      .filter(Boolean)
      .join(" ");

    return (
      <button ref={ref} className={combinedClassName} {...props}>
        {children}
      </button>
    );
  }
);

Button.displayName = "Button";

export default Button;

입력 필드 컴포넌트 재사용

  • 회원가입 입력 field

입력 필드 역시 Label 명, ErrorText 처리만 다르고 동일한 디자인, 동일한 용도여서
역시나 재사용가능 하도록 구현하였다.

import React, { forwardRef } from "react";

// 공통 InputField 컴포넌트
interface InputFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string; // 라벨 텍스트
  helperText?: string; // 입력창 아래에 표시될 도움말 텍스트
  errorMessage?: string; // 에러 발생 시 표시될 텍스트
}

const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
  ({ label, helperText, errorMessage, className = "", ...props }, ref) => {
    const hasError = !!errorMessage;
    const labelColor = hasError ? "text-red-500" : "text-primary-blue";
    const borderColor = hasError ? "border-red-500" : "border-black";
    const helperTextColor = hasError ? "text-red-500" : "text-primary-blue";

    return (
      <div className={`flex flex-col gap-2 ${className}`}>
        <label
          htmlFor={props.id || props.name}
          className={`text-base font-bold font-['Pretendard'] ${labelColor}`}
        >
          {label}
        </label>

        <input
          ref={ref}
          className={`w-full border-b pb-2 text-base font-light font-['Pretendard'] bg-transparent placeholder-neutral-400 focus:outline-none focus:border-blue-800 ${borderColor}`}
          {...props}
        />

        {(helperText || errorMessage) && (
          <p
            className={`text-xs font-bold font-['Pretendard'] ${helperTextColor}`}
          >
            {errorMessage || helperText}
          </p>
        )}
      </div>
    );
  }
);

InputField.displayName = "InputField";

export default InputField;

Zustand

게시판이기 때문에, 글/댓글 관련 활동들에서 모두 사용자의 로그인 여부와 사용자 정보를 확인해야했다.

BEFORE

상태관리 없이 구현했다면 user라는 정보를 다양한 컴포넌트에 props로 한단계씩 뿌려주어야 했다.
(React는 데이터 흐림이 부모 -> 자식 단방향으로 흐르기 때문)

App.tsx
├─ 인증 상태 관리 (useState)
│  ├─ isLoggedIn, user, login, logout
│
└─ Router.tsx (props: isLoggedIn, user, login, logout)
   │
   └─ DefaultLayout.tsx (props: isLoggedIn, user, login, logout)
      ├─ Header.tsx (props: isLoggedIn, user, logout)
      │
      └─ 페이지 컴포넌트들
         ├─ MainPage.tsx (props: isLoggedIn, user)
         │
         ├─ BoardPage.tsx (props: isLoggedIn, user)
         │  └─ PostList.tsx (props: user)
         │     └─ PostItem.tsx (props: user)
         │
         ├─ WritePage.tsx (props: isLoggedIn, user)
         │  └─ PostForm.tsx (props: user)
         │
         └─ PostDetailPage.tsx (props: isLoggedIn, user)
            ├─ PostContent.tsx (props: user)
            │  └─ PostActions.tsx (props: user)
            │
            └─ CommentSection.tsx (props: isLoggedIn, user)
               ├─ CommentForm.tsx (props: isLoggedIn, user)
               │
               └─ CommentList.tsx (props: user)
                  └─ CommentItem.tsx (props: user)
                     └─ CommentActions.tsx (props: user)

AFTER

  • Local Storage에 저장된 AT
import { create } from "zustand";
import { persist } from "zustand/middleware"; // 영구적 저장용 미들웨어 import

// 사용자 인증상태 인터페이스 정의
interface AuthState {
  accessToken: string | null;
  user: {
    userId: number;
    nickname: string;
  } | null;
  isLoggedIn: boolean;
  login: (token: string) => void;
  logout: () => void;
  _hasHydrated: boolean;
  setHasHydrated: (state: boolean) => void;
}

// 스토어를 생성
const useAuthStore = create(
  persist<AuthState>(
    (set) => ({
      // 초기 상태
      accessToken: null,
      user: null,
      isLoggedIn: false,
      _hasHydrated: false,

      // 로그인 액션: 토큰을 받아서 사용자 로그인 상태 업데이트
      login: (token) => {
        // token decoding으로 User 정보 얻어내기
        const base64Url = token.split(".")[1];
        const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");

        const decodedString = atob(base64);

        const bytes = new Uint8Array(decodedString.length);
        for (let i = 0; i < decodedString.length; i++) {
          bytes[i] = decodedString.charCodeAt(i);
        }
        const jsonPayload = new TextDecoder("utf-8").decode(bytes);

        const payload = JSON.parse(jsonPayload);

        // 얻어낸 정보 Zustand Store에 저장
        set({
          accessToken: token,
          user: {
            userId: payload.userId,
            nickname: payload.nickname,
          },
          isLoggedIn: true,
        });
      },

      // Logout시 모두 초기화
      logout: () => {
        set({
          accessToken: null,
          user: null,
          isLoggedIn: false,
        });
      },
      // persisted state로 저장 => 로그인 여부를 기억
      setHasHydrated: (state) => {
        set({ _hasHydrated: state });
      },
    }),
    {
      // 로컬 스토리지에 저장
      name: "auth-storage",
      onRehydrateStorage: () => (state) => {
        state?.setHasHydrated?.(true);
      },
    }
  )
);

export default useAuthStore;

Zustand 상태관리 도구를 통해서 사용자의 로그인 여부를 Store에 저장.
필요할때마다 이 Store에서 불러왔다.

  const { isLoggedIn, user } = useAuthStore();

isLoggedIn 의 여부에따라 다른 UI를 표출하는데 더더욱 쉽고 간단하게 구현할 수 있었다.

Zustand를 선택한 이유

사실 책에서 배우기도 했고 한번 써봤던 Redux로 할까 고민을 했었다.
하지만 관리해야하는 상태가, 사용자의 로그인 정보 상태 딱 하나라는 점에서
복잡한 상태관리가 필요 없기에, 간단하게 사용할 수 있는 상태관리 라이브러리를 쓰고 싶었다.

Zustand 같은 경우
Provider 같은 태그나 useSelector, useDispatch 같은 함수 없이
그냥 getter/setter 설정하고
use~Auth 함수 설정해서 쉽게 상태 변화를 업데이트 할 수 있다는
장점이 있어서 채택하였다.

그리고 이번 일주일은 관심은 갖고 있었지만 써보지 않았던 기술들을 사용해보고 싶었다.

tiptap Text Editor

다 구현해보고 찾아보니 인프런에서도 이 친구를 쓴다고 합니다.😅

https://story.inflab.com/%EC%9D%B8%ED%94%84%EB%9F%B0-%EC%97%90%EB%94%94%ED%84%B0-%EA%B0%9C%ED%8E%B8-tinymce-tiptap-editor/

선택의 이유

원래는 react-quill 이라는 에디터를 쓰려고 했으나

React 19 버전과 맞지 않아서 찾아보니 TipTap이라는 에디터가 커스텀도 편하고
다양한 기능을 제공한다고 하여 도입해보았다.

tiptap

https://tiptap.dev/

아이콘을 집어넣어서 가독성을 높이고
Undo 와 Redo 기능, 이미지 업로드 등 다양한 기능을 모듈로 제공해서
쉽게 구현할 수 있었다.

어쩌면 게시판의 핵심 기능인, 사용자가 보이는 대로 게시물이 올라가야하는데
(WYSIWYG, What You See Is What You Get 이라고도 한다)
가장 중요한 부분이라고 생각했다.

{
    "post_id": 13,
    "title": "Siuuuuuuuuuuuuuuuuuuu",
    "content": "<p>No.7 Cristiano Ronaldo<br><br>수정 TEST</p><p></p><img src=\"http://localhost:3000/uploads/image-1750137298007-603170785.jpeg\">",
    "view_count": 0,
    "created_at": "2025-06-17T05:14:59.000Z",
    "updated_at": "2025-06-18T10:28:32.000Z",
    "author": {
        "id": 2,
        "nickname": "새로운사용자"
    },

이런식으로 html tag를 활용해서 브라우저에서 렌더링 될때 사용자가 보는 그대로
보일 수 있도록 WYSIWYG을 구현하였다.

아쉬운 점

1. 부족한 TypeScript 이해

Props의 타입을 일일히 다 지정하다보니, 이 타입 오류 때문에 애먹은 시간이 꽤 많다.
또한 제네릭을 활용하여 커스텀 훅을 만드는데,
이해가 부족해서 결국 Gemini의 힘을 빌렸다.

  • useFormValidate
/* eslint-disable prefer-const */
import { useState, useCallback } from "react";

// 유효성 검사 규칙을 정의하는 validate
const validate = (name: string, value: string) => {
  switch (name) {
    case "email": {
      const emailRegex = new RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
      if (!value || !emailRegex.test(value)) {
        return "이메일 형식으로 작성해주세요."; // 명세서 요구사항
      }
      return "";
    }

    case "password": {
      // 비밀번호 형식: 영문, 숫자를 혼합하여 8자 이상
      const passwordRegex = new RegExp(
        /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/
      );
      if (!value || !passwordRegex.test(value)) {
        return "영문, 숫자, 특수문자를 혼합하여 8자 이상으로 입력해주세요."; // 명세서 요구사항
      }
      return "";
    }

    case "none": {
      return "";
    }
    default:
      return "";
  }
};

// 커스텀 훅 정의
// 제네릭 개념 활용, T로 타입을 받아서 기록
export const useFormValidation = <T extends Record<string, string>>(
  initialState: T
) => {
  const [values, setValues] = useState(initialState);
  const [errors, setErrors] = useState(initialState);

  // 입력값이 변경될 때마다 상태와 에러를 업데이트하는 핸들러 함수
  // e.target에서 값 추출
  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target; // e.target에서 값 추출

    // 값 업데이트
    setValues((prevValues) => ({
      ...prevValues,
      [name]: value,
    }));

    // 유효성 검사 및 에러 메시지 업데이트
    const errorMessage = validate(name, value);
    setErrors((prevErrors) => ({
      ...prevErrors,
      [name]: errorMessage,
    }));
  }, []);

  // 폼 제출 시 전체 유효성을 다시 한번 검사하는 함수
  const validateForm = () => {
    let newErrors: Record<string, string> = {};
    let isValid = true;

    for (const key in values) {
      const errorMessage = validate(key, values[key]);
      if (errorMessage) {
        isValid = false;
        newErrors[key] = errorMessage;
      }
    }
    setErrors(newErrors as T);
    return isValid;
  };
  // 반환값
  return {
    values,
    errors,
    handleChange,
    validateForm,
    setValues,
  };
};\

2. 업데이트를 명세화 하지 않았음.

혼자 하는 작업이지만 화면 기능 명세서, API 문서를 작성해보았고
이대로 작업을 진행하고자 했다.

게시글의 작성자 정보를 불러올때 명세에는 중첩된 구조가 아니었는데
필요한 정보를 가져오려고 BE 함수를 수정하고 FE 에서 이를 받는 함수를 수정하다 보니
어느새 중첩 구조로 받고 있었다.

이걸 간과하고 계속 다른데서 원인을 찾다가 시간을 낭비한 것이 아쉬운점.

3. 화면 기능 명세시 꼼꼼하지 못했다.

기본적인 수정/삭제 버튼 디자인/기능,
대댓글 디자인을 빼먹어서 머릿속으로 그리면서 하다보니 뭔가 에러도 많이 났고
큰그림을 그리지 못한채로 기능을 구현했던 것 같다.

Page 별 기능

1. 로그인 페이지

JWT 토큰을 활용하는 기본 로그인과 Google 계정을 활용한 로그인 방식, 두 가지를 구현했다.

토큰이 만료된 경우 위와 같이 세션 만료 안내 Alert와 함께 로그인 페이지로 다시 Redirect.

2. 회원가입 페이지

이메일 형식이나 비밀번호 형식에 맞지 않으면 오류 메시지(ErrorMsg)를 표시하여 사용자가 형식에 맞게 제출하도록 유도한다. 물론, 유효성 검사도 수행한다.

3. 메인 페이지

이 페이지는 배너 이미지와 가장 최근에 올라온 두 개의 글을 보여주는 LatestPostSection으로 구성됩니다. 또한, 로그인 여부에 따라 로그인/로그아웃 버튼이 변하도록 구현.

또한 로그인 여부에 따라 로그인/로그아웃 버튼 변화도 구현하였다.

4. 게시물 목록 확인 페이지

게시물 목록과 글을 작성할 수 있는 글쓰기 버튼이 배치되어 있습니다. 만약 로그아웃 상태에서 글쓰기를 시도하면,

위 사진과 같이 알림(alert)을 통해 로그인이 필요함을 알리고 로그인 페이지로 리다이렉트됩니다.

5. 게시물 상세 페이지

로그아웃 상태에서는 게시물 확인은 가능하지만 댓글 작성은 불가능합니다. 로그인 후 댓글 작성이 가능하도록 구현했다.

대댓글의 경우 CommentItem을 재귀적으로 구성하여 대댓글 기능을 구현,
백엔드(BE)에서는 CommentList 형태로 댓글들을 담았다.

profile
제 Velog에 오신 모든 분들이 작더라도 인사이트를 얻어가셨으면 좋겠습니다 :)

0개의 댓글