

FrontEnd 에선 다양한 스타일링 기법이 있는데
CSS-in-JS 방식의 Styled Components 와
유틸리티 퍼스트 접근 방식의 tailWind CSS 두가지 방식이 있다.
그중 tailWind CSS 를 선택한 이유는 두가지이다.
@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;

입력 필드 역시 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;
게시판이기 때문에, 글/댓글 관련 활동들에서 모두 사용자의 로그인 여부와 사용자 정보를 확인해야했다.
상태관리 없이 구현했다면 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)

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를 표출하는데 더더욱 쉽고 간단하게 구현할 수 있었다.
사실 책에서 배우기도 했고 한번 써봤던 Redux로 할까 고민을 했었다.
하지만 관리해야하는 상태가, 사용자의 로그인 정보 상태 딱 하나라는 점에서
복잡한 상태관리가 필요 없기에, 간단하게 사용할 수 있는 상태관리 라이브러리를 쓰고 싶었다.
Zustand 같은 경우
Provider 같은 태그나 useSelector, useDispatch 같은 함수 없이
그냥 getter/setter 설정하고
use~Auth 함수 설정해서 쉽게 상태 변화를 업데이트 할 수 있다는
장점이 있어서 채택하였다.
그리고 이번 일주일은 관심은 갖고 있었지만 써보지 않았던 기술들을 사용해보고 싶었다.
다 구현해보고 찾아보니 인프런에서도 이 친구를 쓴다고 합니다.😅
원래는 react-quill 이라는 에디터를 쓰려고 했으나

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

아이콘을 집어넣어서 가독성을 높이고
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을 구현하였다.
Props의 타입을 일일히 다 지정하다보니, 이 타입 오류 때문에 애먹은 시간이 꽤 많다.
또한 제네릭을 활용하여 커스텀 훅을 만드는데,
이해가 부족해서 결국 Gemini의 힘을 빌렸다.
/* 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,
};
};\
혼자 하는 작업이지만 화면 기능 명세서, API 문서를 작성해보았고
이대로 작업을 진행하고자 했다.
게시글의 작성자 정보를 불러올때 명세에는 중첩된 구조가 아니었는데
필요한 정보를 가져오려고 BE 함수를 수정하고 FE 에서 이를 받는 함수를 수정하다 보니
어느새 중첩 구조로 받고 있었다.
이걸 간과하고 계속 다른데서 원인을 찾다가 시간을 낭비한 것이 아쉬운점.
기본적인 수정/삭제 버튼 디자인/기능,
대댓글 디자인을 빼먹어서 머릿속으로 그리면서 하다보니 뭔가 에러도 많이 났고
큰그림을 그리지 못한채로 기능을 구현했던 것 같다.

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

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

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

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

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

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

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

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