Repository: codeit-bootcamp-frontend/13-Sprint-Mission
Pull request #86: Next 유선향 sprint10
Author: login: grimza99
Labels: name: 매운맛🔥
description: 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다.
Created: 2025-04-06T20:10:50Z | Merged: 2025-04-08T09:17:08Z

## 요구사항

### 기본

- [x] 상품 등록 페이지 주소는 "/addboard" 입니다.
- [x] 게시판 이미지는 최대 한개 업로드가 가능합니다.
- [x] 각 input의 placeholder 값을 정확히 입력해주세요.
- [x] 이미지를 제외하고 input 에 모든 값을 입력하면 '등록' 버튼이 활성화 됩니다.
- [x] 상품 상세 페이지 주소는 "/board/{id}" 입니다.
- [x] 댓글 input 값을 입력하면 '등록' 버튼이 활성화 됩니다.
- [x] 활성화된 '등록' 버튼을 누르면 댓글이 등록됩니다

### 심화

- [x] 상품 등록 페이지
- [x] 회원가입, 로그인 api를 사용하여 받은accessToken을 사용하여 게시물 등록을 합니다.
- [x] '등록' 버튼을 누르면 게시물 상세 페이지로 이동합니다.

## 주요 변경사항
-

## 스크린샷

## 멘토에게
- 셀프 코드 리뷰를 통해 질문 이어가겠습니다.



Diff:
diff --git a/components/Button.tsx b/components/Button.tsx
index 883c7213..94bb8c88 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -1,16 +1,28 @@
+import clsx from "clsx";
 import { ReactNode } from "react";
 //
 interface Props {
-  onClick: onClick;
+  onClick?: onClick;
   children: ReactNode;
   disabled?: boolean;
+  className?: string;
 }
-export default function Button({ onClick, children, ...props }: Props) {
+export default function Button({
+  onClick,
+  children,
+  className,
+  disabled,
+  ...props
+}: Props) {
   return (
     <>
       <button
         onClick={onClick}
-        className="flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer"
+        className={clsx(
+          "flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer",
+          disabled ? "bg-gray-400" : "",
+          className
+        )}
       >
         {children}
       </button>
diff --git a/components/Card.tsx b/components/Card.tsx
index a6fc4e65..ac986618 100644
--- a/components/Card.tsx
+++ b/components/Card.tsx
@@ -4,9 +4,11 @@ import ProfileImg from "@/public/assets/icons/default.profile.icon.svg";
 import { useFormatDate } from "@/hooks/useFormatting";
 
 import BtnHeart from "./BtnHeart";
+import Profile from "./common/Profile";
 //
 interface Props {
   article: Article;
+  isDetailArticle?: boolean;
 }
 export function BestArticle({ article }: Props) {
   const formattedDate = useFormatDate(article.createdAt);
@@ -41,27 +43,23 @@ export function BestArticle({ article }: Props) {
   );
 }
 
-export function Articles({ article }: Props) {
-  const formattedDate = useFormatDate(article.createdAt);
+export function Articles({ article, isDetailArticle }: Props) {
   const articleImg = article.image || ProfileImg;
 
   return (
     <div className=" flex flex-col gap-4 w-full h-[138px] bg-light-gray px-6 border-b border-gray-200">
       <div className="flex justify-between font-Pretendard text-H3Bold">
         <p>{article.content}</p>
-        <img
-          className="w-[72px] h-[72px] text-H8"
-          src={articleImg}
-          alt="상품이미지"
-        />
+        {!isDetailArticle && (
+          <img
+            className="w-[72px] h-[72px] text-H8"
+            src={article.image || ProfileImg}
+            alt="상품이미지"
+          />
+        )}
       </div>
       <div className="flex justify-between text-gray-500 font-Pretendard text-H7Regular ">
-        <div className="flex gap-2">
-          <Image src={ProfileImg} width={24} height={24} alt="프로필" />
-
-          <div>{article.writer.nickname}</div>
-          {formattedDate}
-        </div>
+        <Profile value={article} />
         <BtnHeart value={article.likeCount} />
       </div>
     </div>
diff --git a/components/Input.tsx b/components/Input.tsx
index ef448c09..fad66dd4 100644
--- a/components/Input.tsx
+++ b/components/Input.tsx
@@ -2,11 +2,13 @@ import PlusIcon from "@/public/assets/icons/plusIcon.svg";
 import DeleteIcon from "@/public/assets/icons/DeleteIcon.svg";
 import SearchIcon from "@/public/assets/icons/search.icon.svg";
 import Image from "next/image";
-import { ChangeEvent, useRef, useState } from "react";
+import { useRef, useState } from "react";
+import { uploadImage } from "@/lib/image";
+import { renameFile } from "@/hooks/useRenameFile";
 //
 
 interface Props {
-  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
+  onChange: (value: string) => void;
   label?: string;
   placeholder?: string;
   name: string;
@@ -17,8 +19,16 @@ interface Props {
   $edit?: boolean;
   onKeyUp?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
   type?: "text" | "number";
+  isAsterisk?: boolean;
 }
-export function Input({ onChange, type = "text", ...props }: Props) {
+
+export function Input({
+  onChange,
+  placeholder,
+  type = "text",
+  isAsterisk,
+  ...props
+}: Props) {
   const { label, onKeyUp, $edit, $textArea, $comment } = props;
 
   return (
@@ -28,6 +38,7 @@ export function Input({ onChange, type = "text", ...props }: Props) {
           $comment && "text-H5Bold"
         } w-full h-[26px] font-Pretendard text-gray-800 text-H4Bold`}
       >
+        {isAsterisk && <span>*</span>}
         {label}
       </div>
       <input
@@ -37,78 +48,89 @@ export function Input({ onChange, type = "text", ...props }: Props) {
           $edit && "text-H7Regular h-[80px]"
         }relative w-full h-[56px] bg-gray-100 font-Pretendard text-H6Regular border-none rounded-xl text-gray-800 px-4 py-6 placeholder-gray-400 placeholder:text-H5Regular placeholder:absolute focus:outline-none`}
         type={type}
-        onChange={onChange}
+        onChange={(e) => {
+          if (e.nativeEvent.composed) return onChange(e.target.value);
+        }}
         onKeyUp={onKeyUp || undefined}
+        placeholder={placeholder}
       />
     </div>
   );
 }
+
 //
 interface ImgProps extends Omit<Props, "onChange"> {
-  onChange: (value: File | string) => void;
+  onChange: (value: null | string) => void;
 }
-export function ImgInput({ onChange, ...props }: ImgProps) {
+export function ImgInput({ onChange, label, ...props }: ImgProps) {
   const imgRef = useRef<HTMLInputElement>(null);
   const [imgPreview, setImgPreview] = useState<string>("");
 
   const handlePreviewImg = () => {
     if (!imgRef.current || !imgRef.current.files?.length) return;
     const file = imgRef.current.files[0];
+
+    const renamedFile = renameFile(file);
+
     const reader = new FileReader();
-    reader.readAsDataURL(file);
-    reader.onloadend = () => {
+    reader.readAsDataURL(renamedFile);
+
+    reader.onloadend = async () => {
       if (typeof reader.result === "string") {
         setImgPreview(reader.result);
-        onChange(file);
+        const formattedImage = await uploadImage(renamedFile);
+        onChange(formattedImage);
       }
     };
   };
-  //
+
   const handleClickImgDelete = () => {
     setImgPreview("");
-    onChange("");
+    onChange(null);
   };
+
   return (
-    <div className="flex gap-6 mobile:gap-[10px] ">
-      <div className="mobile:w-[168px]  w-[282px] h-[282px] bg-gray-100 rounded-xl  border-none text-gray-800">
-        <input
-          className="relative w-full h-full bg-gray-100 font-Pretendard text-H6Regular border-none rounded-xl text-gray-800 px-4 py-6 
+    <div className="flex flex-col gap-6 mobile:gap-[10px] ">
+      <p className="text-[18px] font-Pretendard text-H4Bold">{label}</p>
+      <div className="relative overflow-hidden gap-6 w-[282px] mobile:w-[168px] mobile:h-[168px] h-[282px] bg-gray-100 rounded-xl  border-none text-gray-800">
+        {!imgPreview ? (
+          <>
+            <input
+              className="hidden w-full h-full  font-Pretendard text-H6Regular border-none rounded-xl text-gray-800 px-4 py-6 
           placeholder-gray-400 placeholder:text-H5Regular placeholder:top-[10px] placeholder:text-left placeholder:absolute focus:outline-none"
-          type="file"
-          id="fileUpload"
-          accept="image/*"
-          ref={imgRef}
-          onChange={handlePreviewImg}
-          {...props}
-        />
-        <label
-          htmlFor="fileUpload"
-          className="absolute text-center text-gray-400 -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2 text-H5Regular"
-        >
-          <Image width={48} height={48} src={PlusIcon} alt="추가" />
-          <p>{props.placeholder}</p>
-        </label>
+              type="file"
+              id="fileUpload"
+              accept="image/*"
+              ref={imgRef}
+              onChange={handlePreviewImg}
+              {...props}
+            />
+            <label
+              htmlFor="fileUpload"
+              className="absolute flex flex-col items-center justify-center text-gray-400 -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2 text-H5Regular"
+            >
+              <Image width={48} height={48} src={PlusIcon} alt="추가" />
+              <p className="text-center">{props.placeholder}</p>
+            </label>
+          </>
+        ) : (
+          <div className="relative">
+            <img
+              className="z-10 w-full rounded-[6px] object-fill aspect-square"
+              src={imgPreview}
+              alt="이미지 미리보기"
+            />
+            <Image
+              className="absolute top-[15px] right-[15px]"
+              src={DeleteIcon}
+              width={22}
+              height={24}
+              alt="x"
+              onClick={handleClickImgDelete}
+            />
+          </div>
+        )}
       </div>
-
-      {imgPreview && (
-        <div className="relative">
-          <Image
-            className=" aspect-square w-[282px] h-[282px] mobile:w-[168px] "
-            src={imgPreview}
-            width={282}
-            height={282}
-            alt="이미지 미리보기"
-          />
-          <Image
-            className="absolute top-[15px] right-[15px]"
-            src={DeleteIcon}
-            width={22}
-            height={24}
-            alt="x"
-            onClick={handleClickImgDelete}
-          />
-        </div>
-      )}
     </div>
   );
 }
@@ -117,7 +139,9 @@ export function SearchInput({ onChange, ...props }: Props) {
   return (
     <div className="relative w-full">
       <input
-        onChange={onChange}
+        onChange={(e) => {
+          onChange(e.target.value);
+        }}
         className=" 
       rounded-xl border-none px-[44px] py-[16px] w-full h-[42px] bg-gray-100 
       font-Pretendard text-gray-800 text-H5Regular
diff --git a/components/Nav.tsx b/components/Nav.tsx
index 70ebc934..623347c8 100644
--- a/components/Nav.tsx
+++ b/components/Nav.tsx
@@ -3,9 +3,22 @@ import pandaLogo from "@/public/assets/Logo/pandaLogo.svg";
 import textLogo from "@/public/assets/Logo/textLogo.svg";
 import myLogo from "@/public/assets/icons/default.profile.icon.svg";
 import Image from "next/image";
-import useWindowSize from "@/hooks/useWindowSize";
+import { useEffect, useState } from "react";
+import Button from "./Button";
+import { signIn } from "@/lib/auth";
+
 export default function Nav() {
-  const device: string = useWindowSize();
+  const [isLogin, setIsLogin] = useState(false);
+  useEffect(() => {
+    const hasAccessToken = Boolean(localStorage.getItem("accessToken"));
+    setIsLogin(hasAccessToken);
+  }, []);
+
+  const handleClickLogin = async () => {
+    await signIn();
+    setIsLogin(true);
+  };
+
   return (
     <div className="fixed top-0 left-0 w-screen h-[70px] bg-white py-[10px] px-[200px] NavPadding:px-[24px] NavPadding:py-[10px]  mobile:px-[15px] mobile:py-[15px] z-50">
       <div className="flex justify-between w-screen max-w-full ">
@@ -27,11 +40,15 @@ export default function Nav() {
             />
           </div>
           <div className="flex w-auto gap-7 mobile:gap-2 font-Pretendard text-H4Bold">
-            <Link href="/docs">자유게시판</Link>
-            <Link href="/items">중고마켓</Link>
+            <Link href="/">자유게시판</Link>
+            <Link href="/">중고마켓</Link>
           </div>
         </div>
-        <Link href="/mypage">
+        {!isLogin ? (
+          <div className="w-[128px] h-12">
+            <Button onClick={() => handleClickLogin()}>로그인</Button>
+          </div>
+        ) : (
           <Image
             className="w-10 h-10"
             src={myLogo}
@@ -39,7 +56,7 @@ export default function Nav() {
             width={40}
             height={40}
           />
-        </Link>
+        )}
       </div>
     </div>
   );
diff --git a/components/Select.tsx b/components/Select.tsx
index 52ca1a99..a02c78ad 100644
--- a/components/Select.tsx
+++ b/components/Select.tsx
@@ -80,7 +80,7 @@ export function EditSelect({ onChange, ...props }: Props) {
           onClick={() => setIsOpen(!isOpen)}
           className="h-6 bg-white border-none cursor-pointer"
         >
-          <img src={kebabIcon} alt="케밥" />
+          <Image src={kebabIcon} width={24} height={24} alt="케밥" />
         </div>
         <ul
           className={`${
diff --git a/components/board/comment.tsx b/components/board/comment.tsx
new file mode 100644
index 00000000..047c8436
--- /dev/null
+++ b/components/board/comment.tsx
@@ -0,0 +1,55 @@
+import { KeyboardEvent, useState } from "react";
+import Profile from "../common/Profile";
+import { EditSelect } from "../Select";
+import { deleteArticleComment, editArticleComment } from "@/lib/comments.api";
+
+interface Props {
+  comment: Comment;
+}
+export default function Comment({ comment }: Props) {
+  const [isEdit, setIsEdit] = useState(false);
+  const [currentValue, setCurrentValue] = useState(comment.content);
+  const handleSelect = async (option: string) => {
+    switch (option) {
+      case "수정하기": {
+        setIsEdit(true);
+        break;
+      }
+      case "삭제하기": {
+        await deleteArticleComment(comment.id);
+        break;
+      }
+    }
+  };
+  const handleChange = async (e: KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === "Enter") {
+      await editArticleComment(comment.id, currentValue);
+      setCurrentValue(currentValue);
+    }
+  };
+  return (
+    <>
+      <div className="flex flex-col w-full gap-6 mb-3 h-fit">
+        <div className="flex justify-between w-full">
+          {isEdit ? (
+            <input
+              className="w-full"
+              value={currentValue}
+              onChange={(e) => {
+                setCurrentValue(e.currentTarget.value);
+              }}
+              onKeyDown={(e) => handleChange(e)}
+            />
+          ) : (
+            <p>{currentValue}</p>
+          )}
+          <EditSelect onChange={(option) => handleSelect(option)} />
+        </div>
+        <div>
+          <Profile isComment value={comment} />
+        </div>
+      </div>
+      <div className="w-full h-px bg-gray-300" />
+    </>
+  );
+}
diff --git a/components/common/Profile.tsx b/components/common/Profile.tsx
new file mode 100644
index 00000000..17d5e70d
--- /dev/null
+++ b/components/common/Profile.tsx
@@ -0,0 +1,26 @@
+import Image from "next/image";
+import ProfileImg from "@/public/assets/icons/default.profile.icon.svg";
+import { useFormatDate, useFormatUpDate } from "@/hooks/useFormatting";
+import clsx from "clsx";
+interface Props {
+  value: {
+    writer: { nickname: string };
+    createdAt: string;
+  };
+  isComment?: boolean;
+}
+export default function Profile({ value, isComment }: Props) {
+  const formattedDate = isComment
+    ? useFormatUpDate(value.createdAt)
+    : useFormatDate(value.createdAt);
+
+  return (
+    <div className="flex gap-2">
+      <Image src={ProfileImg} width={24} height={24} alt="프로필" />
+      <div className={clsx("flex", isComment ? "flex-col gap-1" : "flex-row")}>
+        <div className="text-gray-600">{value.writer?.nickname}</div>
+        <p className="text-gray-400">{formattedDate}</p>
+      </div>
+    </div>
+  );
+}
diff --git a/hooks/useRenameFile.ts b/hooks/useRenameFile.ts
new file mode 100644
index 00000000..70243f42
--- /dev/null
+++ b/hooks/useRenameFile.ts
@@ -0,0 +1,6 @@
+export const renameFile = (file: File) => {
+  const extension = file.name.split(".").pop();
+  return new File([file], `upload_${Date.now()}.${extension}`, {
+    type: file.type,
+  });
+};
diff --git a/lib/Articles.tsx b/lib/Articles.tsx
index 1e3a70cf..dc66f7c3 100644
--- a/lib/Articles.tsx
+++ b/lib/Articles.tsx
@@ -31,3 +31,28 @@ export async function getArticles({
     throw new Error(" 게시글 불러오기 실패");
   }
 }
+
+export async function getArticleDetail(articleId: number) {
+  const res = await instance.get(`/articles/${articleId}`);
+  return res.data;
+}
+
+export async function deleteArticle(articleId: number) {
+  await instance.delete(`/articles/${articleId}`);
+}
+
+interface articleEditedRequestParams {
+  articleId: number;
+  articleData: ArticleData;
+}
+export async function editArticle({
+  articleId,
+  articleData,
+}: articleEditedRequestParams) {
+  await instance.delete(`/articles/${articleId}`);
+}
+
+export async function createArticle(articleData: ArticleData) {
+  console.log(articleData);
+  const res = await instance.post(`/articles`, { ...articleData });
+}
diff --git a/lib/api.tsx b/lib/api.tsx
index 76c18f34..7b9f4669 100644
--- a/lib/api.tsx
+++ b/lib/api.tsx
@@ -1,4 +1,4 @@
-import axios from "axios";
+import axios, { AxiosResponse, InternalAxiosRequestConfig } from "axios";
 
 const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;
 
@@ -6,7 +6,28 @@ const instance = axios.create({
   baseURL: BASE_URL,
   headers: {
     "Content-Type": "application/json",
+    withCredentials: true,
   },
 });
 
 export default instance;
+
+instance.interceptors.request.use(
+  (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
+    if (typeof window !== "undefined") {
+      const accessToken = localStorage.getItem("accessToken");
+      if (!accessToken) return config;
+      config.headers.set("Authorization", `Bearer ${accessToken}`);
+    }
+    return config;
+  }
+);
+
+instance.interceptors.response.use((response: AxiosResponse) => {
+  const accessToken = response.data.accessToken;
+  if (typeof window !== "undefined" && accessToken) {
+    localStorage.setItem("accessToken", accessToken);
+    localStorage.setItem("refreshToken", response.data.refreshToken);
+  }
+  return response;
+});
diff --git a/lib/auth.tsx b/lib/auth.tsx
new file mode 100644
index 00000000..aecb3d37
--- /dev/null
+++ b/lib/auth.tsx
@@ -0,0 +1,25 @@
+import instance from "./api";
+import Cookies from "js-cookie";
+
+export const setAccessToken = (token: string) => {
+  Cookies.set("accessToken", token, {
+    path: "/",
+    expires: 7,
+    sameSite: "Strict",
+  });
+};
+
+export const removeAccessToken = () => {
+  Cookies.remove("accessToken", { path: "/" });
+};
+
+const TEST_EMAIL = "test98@email.com";
+const TEST_PASSWORD = "12345678";
+
+export async function signIn() {
+  const res = await instance.post(`/auth/signIn`, {
+    email: TEST_EMAIL,
+    password: TEST_PASSWORD,
+  });
+  setAccessToken(res.data.accessToken);
+}
diff --git a/lib/comments.api.tsx b/lib/comments.api.tsx
new file mode 100644
index 00000000..92ec62cb
--- /dev/null
+++ b/lib/comments.api.tsx
@@ -0,0 +1,29 @@
+import instance from "./api";
+
+export async function getArticleComment(
+  articleId: number,
+  limit: number = 5,
+  cursor: number
+) {
+  const res = await instance.get(`/articles/${articleId}/comments`, {
+    params: { limit: limit, cursor: cursor },
+  });
+  return res.data;
+}
+
+export async function postArticleComment(articleId: number, content: string) {
+  const res = await instance.post(`/articles/${articleId}/comments`, {
+    content,
+  });
+  return res.data;
+}
+
+export async function editArticleComment(commentId: number, content: string) {
+  const res = await instance.patch(`/comments/${commentId}`, { content });
+  return res.data.content;
+}
+
+export async function deleteArticleComment(commentId: number) {
+  const res = await instance.delete(`/comments/${commentId}`, {});
+  return res.data.content;
+}
diff --git a/lib/image.tsx b/lib/image.tsx
new file mode 100644
index 00000000..67489535
--- /dev/null
+++ b/lib/image.tsx
@@ -0,0 +1,12 @@
+import { headers } from "next/headers";
+import instance from "./api";
+
+export async function uploadImage(image: File) {
+  const formData = new FormData();
+  formData.append("image", image);
+
+  const res = await instance.post(`/images/upload`, formData, {
+    headers: { " Content-Type": "multipart/form-data" },
+  });
+  return res.data.url;
+}
diff --git a/package-lock.json b/package-lock.json
index 30cf419a..f696d0b8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,13 +9,16 @@
       "version": "0.1.0",
       "dependencies": {
         "axios": "^1.8.1",
+        "clsx": "^2.1.1",
+        "js-cookie": "^3.0.5",
         "lodash.throttle": "^4.1.1",
         "next": "^15.2.1",
+        "nookies": "^2.5.2",
         "react": "^18",
-        "react-dom": "^18",
-        "styled-components": "^6.1.15"
+        "react-dom": "^18"
       },
       "devDependencies": {
+        "@types/js-cookie": "^3.0.6",
         "@types/node": "^20",
         "@types/react": "^18",
         "@types/react-dom": "^18",
@@ -48,24 +51,6 @@
         "tslib": "^2.4.0"
       }
     },
-    "node_modules/@emotion/is-prop-valid": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
-      "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
-      "dependencies": {
-        "@emotion/memoize": "^0.8.1"
-      }
-    },
-    "node_modules/@emotion/memoize": {
-      "version": "0.8.1",
-      "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
-      "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
-    },
-    "node_modules/@emotion/unitless": {
-      "version": "0.8.1",
-      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
-      "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
-    },
     "node_modules/@eslint-community/eslint-utils": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
@@ -807,6 +792,13 @@
         "tslib": "^2.8.0"
       }
     },
+    "node_modules/@types/js-cookie": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
+      "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/json5": {
       "version": "0.0.29",
       "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -847,11 +839,6 @@
         "@types/react": "^18.0.0"
       }
     },
-    "node_modules/@types/stylis": {
-      "version": "4.2.5",
-      "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz",
-      "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="
-    },
     "node_modules/@typescript-eslint/parser": {
       "version": "6.21.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
@@ -1491,14 +1478,6 @@
         "node": ">= 6"
       }
     },
-    "node_modules/camelize": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
-      "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/caniuse-lite": {
       "version": "1.0.30001702",
       "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz",
@@ -1575,6 +1554,15 @@
       "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
       "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
     },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/color": {
       "version": "4.2.3",
       "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -1642,6 +1630,15 @@
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
       "dev": true
     },
+    "node_modules/cookie": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
+      "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1656,24 +1653,6 @@
         "node": ">= 8"
       }
     },
-    "node_modules/css-color-keywords": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
-      "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/css-to-react-native": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
-      "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
-      "dependencies": {
-        "camelize": "^1.0.0",
-        "css-color-keywords": "^1.0.0",
-        "postcss-value-parser": "^4.0.2"
-      }
-    },
     "node_modules/cssesc": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1689,7 +1668,8 @@
     "node_modules/csstype": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
-      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+      "dev": true
     },
     "node_modules/damerau-levenshtein": {
       "version": "1.0.8",
@@ -3529,6 +3509,15 @@
         "jiti": "bin/jiti.js"
       }
     },
+    "node_modules/js-cookie": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+      "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3894,6 +3883,16 @@
       "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
       "dev": true
     },
+    "node_modules/nookies": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/nookies/-/nookies-2.5.2.tgz",
+      "integrity": "sha512-x0TRSaosAEonNKyCrShoUaJ5rrT5KHRNZ5DwPCuizjgrnkpE5DRf3VL7AyyQin4htict92X1EQ7ejDbaHDVdYA==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "^0.4.1",
+        "set-cookie-parser": "^2.4.6"
+      }
+    },
     "node_modules/normalize-path": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4380,7 +4379,8 @@
     "node_modules/postcss-value-parser": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
-      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true
     },
     "node_modules/prelude-ls": {
       "version": "1.2.1",
@@ -4687,6 +4687,12 @@
         "node": ">=10"
       }
     },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
+      "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+      "license": "MIT"
+    },
     "node_modules/set-function-length": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -4733,11 +4739,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/shallowequal": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
-      "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
-    },
     "node_modules/sharp": {
       "version": "0.33.5",
       "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
@@ -5140,65 +5141,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/styled-components": {
-      "version": "6.1.15",
-      "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.15.tgz",
-      "integrity": "sha512-PpOTEztW87Ua2xbmLa7yssjNyUF9vE7wdldRfn1I2E6RTkqknkBYpj771OxM/xrvRGinLy2oysa7GOd7NcZZIA==",
-      "dependencies": {
-        "@emotion/is-prop-valid": "1.2.2",
-        "@emotion/unitless": "0.8.1",
-        "@types/stylis": "4.2.5",
-        "css-to-react-native": "3.2.0",
-        "csstype": "3.1.3",
-        "postcss": "8.4.49",
-        "shallowequal": "1.1.0",
-        "stylis": "4.3.2",
-        "tslib": "2.6.2"
-      },
-      "engines": {
-        "node": ">= 16"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/styled-components"
-      },
-      "peerDependencies": {
-        "react": ">= 16.8.0",
-        "react-dom": ">= 16.8.0"
-      }
-    },
-    "node_modules/styled-components/node_modules/postcss": {
-      "version": "8.4.49",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
-      "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/postcss/"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/postcss"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "dependencies": {
-        "nanoid": "^3.3.7",
-        "picocolors": "^1.1.1",
-        "source-map-js": "^1.2.1"
-      },
-      "engines": {
-        "node": "^10 || ^12 || >=14"
-      }
-    },
-    "node_modules/styled-components/node_modules/tslib": {
-      "version": "2.6.2",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
-      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
-    },
     "node_modules/styled-jsx": {
       "version": "5.1.6",
       "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -5221,11 +5163,6 @@
         }
       }
     },
-    "node_modules/stylis": {
-      "version": "4.3.2",
-      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz",
-      "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="
-    },
     "node_modules/sucrase": {
       "version": "3.35.0",
       "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
diff --git a/package.json b/package.json
index 8a2f5344..a8863ccd 100644
--- a/package.json
+++ b/package.json
@@ -10,13 +10,16 @@
   },
   "dependencies": {
     "axios": "^1.8.1",
+    "clsx": "^2.1.1",
+    "js-cookie": "^3.0.5",
     "lodash.throttle": "^4.1.1",
     "next": "^15.2.1",
+    "nookies": "^2.5.2",
     "react": "^18",
-    "react-dom": "^18",
-    "styled-components": "^6.1.15"
+    "react-dom": "^18"
   },
   "devDependencies": {
+    "@types/js-cookie": "^3.0.6",
     "@types/node": "^20",
     "@types/react": "^18",
     "@types/react-dom": "^18",
diff --git a/pages/_app.tsx b/pages/_app.tsx
index 5c8e310f..1e7e0445 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -6,7 +6,7 @@ export default function App({ Component, pageProps }: AppProps) {
     <>
       <Nav />
       <div className="w-full h-screen mx-auto">
-        <div className="max-w-[1200px] mx-auto h-screen mt-[70px] px-6">
+        <div className="max-w-[1200px] px-[15px] tablet:px-6 mx-auto h-screen mt-[70px] py-6">
           <Component {...pageProps} />
         </div>
       </div>
diff --git a/pages/addboard.tsx b/pages/addboard.tsx
new file mode 100644
index 00000000..3fb0333b
--- /dev/null
+++ b/pages/addboard.tsx
@@ -0,0 +1,120 @@
+import Button from "@/components/Button";
+import { ImgInput, Input } from "@/components/Input";
+import instance from "@/lib/api";
+import { createArticle } from "@/lib/Articles";
+import { GetServerSideProps } from "next";
+import { parseCookies, setCookie } from "nookies";
+import { useState } from "react";
+
+const InputAttributes = [
+  {
+    name: "title",
+    value: "",
+    placeholder: "제목을 입력해주세요",
+    label: "제목",
+    validation: "",
+    key: 1,
+  },
+  {
+    name: "content",
+    value: "",
+    placeholder: "내용을 입력해주세요",
+    label: "내용",
+    validation: "",
+    key: 2,
+  },
+];
+
+export const getServerSideProps: GetServerSideProps = async (ctx) => {
+  const cookies = parseCookies(ctx);
+  const accessToken = cookies.accessToken;
+  const refreshToken = cookies.refreshToken;
+  if (!accessToken) {
+    return {
+      redirect: {
+        destination: "/",
+      },
+      props: {},
+    };
+  }
+  try {
+    await instance.get(`/user/me`);
+  } catch (err: any) {
+    if (err.response?.status === 401) {
+      try {
+        const refreshRes = await instance.post(
+          `/auth/refresh-token`,
+          {},
+          { headers: { Cookie: `refreshToken=${refreshToken}` } }
+        );
+        const newAccessToken = refreshRes.data.accessToken;
+        setCookie(ctx, "accessToken", newAccessToken, {
+          path: "/",
+          httpOnly: true,
+          sameSite: "lax",
+        });
+      } catch {
+        return {
+          redirect: { destination: "/" },
+          props: {},
+        };
+      }
+    }
+  }
+  return { props: {} };
+};
+
+export default function AddBoard() {
+  const [formData, setFormData] = useState({
+    title: "",
+    content: "",
+    image: "",
+  });
+
+  const handleChange = (key: string, value: unknown) => {
+    setFormData((prev) => ({ ...prev, [key]: value }));
+  };
+
+  const handleSubmit = async () => {
+    await createArticle(formData);
+  };
+
+  return (
+    <div className="flex flex-col gap-8">
+      <div className="w-full flex h-[42px] items-center justify-between">
+        <p className="font-bold text-[20px]">게시글 쓰기 </p>
+        <div className="w-[74px] h-[42px]">
+          <Button
+            onClick={() => {
+              handleSubmit();
+            }}
+          >
+            등록
+          </Button>
+        </div>
+      </div>
+
+      <div className="flex flex-col gap-6">
+        {InputAttributes.map((input) => {
+          return (
+            <Input
+              key={input.key}
+              onChange={(value: string) => handleChange(input.name, value)}
+              name={input.name}
+              value={input.value}
+              label={input.label}
+              placeholder={input.placeholder}
+            />
+          );
+        })}
+
+        <ImgInput
+          onChange={(value) => handleChange("image", value)}
+          name="image"
+          label="이미지"
+          placeholder="이미지 등록"
+        />
+      </div>
+    </div>
+  );
+}
diff --git a/pages/board/[articleId].tsx b/pages/board/[articleId].tsx
new file mode 100644
index 00000000..e93ac420
--- /dev/null
+++ b/pages/board/[articleId].tsx
@@ -0,0 +1,135 @@
+import Image from "next/image";
+import Previous from "@/public/assets/icons/previous.icon.svg";
+import Button from "@/components/Button";
+import Link from "next/link";
+import { EditSelect } from "@/components/Select";
+import { Articles } from "@/components/Card";
+import { GetServerSideProps } from "next";
+import { deleteArticle, editArticle, getArticleDetail } from "@/lib/Articles";
+import { Input } from "@/components/Input";
+import { ChangeEvent, useState } from "react";
+import { getArticleComment, postArticleComment } from "@/lib/comments.api";
+import Comment from "@/components/board/comment";
+import { signIn } from "@/lib/auth";
+import noCommentImg from "@/public/assets/noComment.svg";
+//
+interface Props {
+  articleId: string;
+  detailArticle: Article;
+  comments: Comment[];
+}
+const LIMIT = 5;
+
+export const getServerSideProps: GetServerSideProps = async (context) => {
+  const { articleId } = context.query as {
+    articleId: string;
+  };
+  let cursor = 0;
+  const detailArticle = await getArticleDetail(Number(articleId));
+  const data = await getArticleComment(Number(articleId), LIMIT, cursor);
+  cursor = data.cursor;
+  return {
+    props: { articleId, detailArticle, comments: data.list },
+  };
+};
+
+export default function DetailArticle({
+  articleId,
+  detailArticle,
+  comments,
+}: Props) {
+  const [commentValue, setCommentValue] = useState("");
+  const [currentComments, setCurrentComments] = useState(comments);
+  const [isEditArticle, setIsEditArticle] = useState(false);
+  const [currentArticle, setCurrentArticle] = useState({
+    title: detailArticle.title,
+    content: detailArticle.content,
+    image: detailArticle.image,
+  });
+
+  const handleSelect = async (option: string) => {
+    switch (option) {
+      case "수정하기": {
+        setIsEditArticle(true);
+        break;
+        // await editArticle({ articleId, articleData: currentArticle });
+      }
+      case "삭제하기": {
+        await deleteArticle(Number(articleId));
+        break;
+      }
+    }
+  };
+
+  const handleChange = (value: string) => {
+    setCommentValue(value);
+  };
+
+  const handleSubmit = async () => {
+    const newComment = await postArticleComment(
+      Number(articleId),
+      commentValue
+    );
+
+    setCurrentComments((prev) => [newComment, ...prev]);
+    setCommentValue("");
+  };
+
+  return (
+    <div className="flex flex-col items-center w-full gap-8">
+      <div className="flex flex-col w-full gap-6">
+        <div className="relative">
+          <Articles article={detailArticle} isDetailArticle />
+          <div className="absolute top-0 right-0">
+            <EditSelect onChange={(option) => handleSelect(option)} />
+          </div>
+        </div>
+        <p>{detailArticle.content}</p>
+      </div>
+      <div className="flex flex-col items-end w-full gap-4">
+        <Input
+          type="text"
+          label="댓글달기"
+          name="comment"
+          placeholder="댓글을 입력해주세요."
+          value={commentValue}
+          onChange={(value) => handleChange(value)}
+        />
+        <div className="w-[74px] h-[42px]">
+          <Button disabled={Boolean(!commentValue)} onClick={handleSubmit}>
+            등록
+          </Button>
+        </div>
+      </div>
+      <div className="flex flex-col w-full gap-6">
+        {currentComments.length < 1 ? (
+          <div className="flex flex-col items-center justify-center w-full gap-4">
+            <Image
+              src={noCommentImg}
+              width={140}
+              height={140}
+              alt="코멘트 없음 "
+            />
+            <p className="text-gray-400 text-lg-regular">
+              아직 댓글이 없어요,
+              <br />
+              지금 댓글을 달아보세요!
+            </p>
+          </div>
+        ) : (
+          currentComments.map((comment) => {
+            return <Comment key={comment.id} comment={comment} />;
+          })
+        )}
+      </div>
+      <div className="w-[240px] h-[48px] ">
+        <Link href={"/board"}>
+          <Button className="rounded-[40px]" onClick={() => {}}>
+            목록으로 돌아가기
+            <Image src={Previous} width={24} height={24} alt="이전" />
+          </Button>
+        </Link>
+      </div>
+    </div>
+  );
+}
diff --git a/pages/boards.tsx b/pages/board/index.tsx
similarity index 87%
rename from pages/boards.tsx
rename to pages/board/index.tsx
index 89402092..394f05c9 100644
--- a/pages/boards.tsx
+++ b/pages/board/index.tsx
@@ -7,8 +7,8 @@ import useWindowSize from "@/hooks/useWindowSize";
 import { getArticles, getBestArticles } from "@/lib/Articles";
 import { GetServerSideProps } from "next";
 import { useRouter } from "next/router";
+import Link from "next/link";
 
-//
 const BEST_PAGE_SIZE: number = 3;
 const PAGE_SIZE: number = 10;
 interface Props {
@@ -16,6 +16,7 @@ interface Props {
   sortedArticles: Article[];
   decodedKeyword: string;
 }
+
 export const getServerSideProps: GetServerSideProps = async (context) => {
   const { orderBy = "recent", keyword } = context.query as {
     orderBy: string;
@@ -33,7 +34,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
     props: { bestArticles, sortedArticles, decodedKeyword },
   };
 };
-//
+
 export default function Board({
   bestArticles,
   sortedArticles,
@@ -45,8 +46,6 @@ export default function Board({
   const device: string = useWindowSize();
   const router = useRouter();
 
-  const handleClick = () => {};
-
   const handleSortChange = (option: string) => {
     const newOrderBy = option === "최신순" ? "recent" : "like";
     router.replace(
@@ -84,8 +83,8 @@ export default function Board({
     }
   }, [device, sortedArticles]);
 
-  const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
-    const searchValue = e.target.value.toLowerCase();
+  const handleSearchChange = (value: string) => {
+    const searchValue = value.toLowerCase();
     const newQuery = { ...router.query };
     if (!searchValue) {
       delete newQuery.keyword;
@@ -112,7 +111,9 @@ export default function Board({
         <div className="flex gap-6 mx-auto">
           {best.length > 0 &&
             best.map((article) => (
-              <BestArticle key={article.id} article={article} />
+              <Link key={article.id} href={`board/${article.id}`}>
+                <BestArticle article={article} />
+              </Link>
             ))}
         </div>
       </div>
@@ -122,21 +123,25 @@ export default function Board({
             게시글
           </div>
           <div className="w-[88px]">
-            <Button onClick={handleClick}>글쓰기</Button>
+            <Link href={"/addboard"}>
+              <Button>글쓰기</Button>
+            </Link>
           </div>
         </div>
         <div className="flex w-full gap-4">
           <SearchInput
             placeholder="검색할 상품을 입력해주세요"
             name="search"
-            onChange={handleSearchChange}
+            onChange={(value) => handleSearchChange(value)}
           />
           <SortSelect onChange={handleSortChange} />
         </div>
         <div className="flex flex-col w-full gap-6">
           {articles.length > 0 &&
             articles.map((article) => (
-              <Articles key={article.id} article={article} />
+              <Link href={`board/${article.id}`}>
+                <Articles key={article.id} article={article} />
+              </Link>
             ))}
         </div>
       </div>
diff --git a/public/assets/icons/previous.icon.svg b/public/assets/icons/previous.icon.svg
new file mode 100644
index 00000000..bbdaa5fa
--- /dev/null
+++ b/public/assets/icons/previous.icon.svg
@@ -0,0 +1,4 @@
+<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.5332 0.6C3.03615 0.6 2.6332 1.00294 2.6332 1.5C2.6332 1.99706 3.03615 2.4 3.5332 2.4V0.6ZM3.5332 2.4H13.6665V0.6H3.5332V2.4ZM18.0999 6.83333V7.9H19.8999V6.83333H18.0999ZM13.6665 12.3333H3.5332V14.1333H13.6665V12.3333ZM18.0999 7.9C18.0999 10.3485 16.115 12.3333 13.6665 12.3333V14.1333C17.1091 14.1333 19.8999 11.3426 19.8999 7.9H18.0999ZM13.6665 2.4C16.115 2.4 18.0999 4.38487 18.0999 6.83333H19.8999C19.8999 3.39076 17.1091 0.6 13.6665 0.6V2.4Z" fill="white"/>
+<path d="M-1.86502e-07 13.2333L7.2 9.53829L7.2 16.9284L-1.86502e-07 13.2333Z" fill="white"/>
+</svg>
diff --git a/public/assets/noComment.svg b/public/assets/noComment.svg
new file mode 100644
index 00000000..dbac5014
--- /dev/null
+++ b/public/assets/noComment.svg
@@ -0,0 +1,10 @@
+<svg width="140" height="140" viewBox="0 0 140 140" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M44 20C30.7452 20 20 30.7452 20 44V45C20 56.9621 28.7514 66.8802 40.201 68.701L47.5 76L54.5 69H96C109.255 69 120 58.2548 120 45V44C120 30.7452 109.255 20 96 20H44Z" fill="#F3F4F6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.5 74C64.2827 74 56 82.2827 56 92.5C56 102.717 64.2827 111 74.5 111H86L94 119L102.007 110.993C111.99 110.725 120 102.548 120 92.5C120 82.2827 111.717 74 101.5 74H74.5Z" fill="#F3F4F6"/>
+<circle cx="58" cy="45" r="3" fill="#E5E7EB"/>
+<circle cx="70" cy="45" r="3" fill="#E5E7EB"/>
+<circle cx="82" cy="45" r="3" fill="#E5E7EB"/>
+<circle cx="76" cy="93" r="3" fill="#E5E7EB"/>
+<circle cx="88" cy="93" r="3" fill="#E5E7EB"/>
+<circle cx="100" cy="93" r="3" fill="#E5E7EB"/>
+</svg>
diff --git a/types/global.d.ts b/types/global.d.ts
index 22b0ab9f..f68bc494 100644
--- a/types/global.d.ts
+++ b/types/global.d.ts
@@ -45,4 +45,10 @@ declare global {
     id: number;
   }
   type onClick = (e: MouseEvent<HTMLButtonElement>) => void;
+
+  interface ArticleData {
+    image: string | null;
+    content: string;
+    title: string;
+  }
 }


Reviews:
user: login: kiJu2
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-08T09:15:57Z
user: login: kiJu2
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T09:20:19Z

Review comments:
diff_hunk: @@ -1,16 +1,28 @@
+import clsx from "clsx";
 import { ReactNode } from "react";
 //
 interface Props {
-  onClick: onClick;
+  onClick?: onClick;
path: components/Button.tsx
user: login: kiJu2
body: ## (선택) 타입은 보통 파스칼 케이스로 사용하는게 일반적이예요 !
```suggestion
  onClick?: OnClick;
```
`global.d.ts`에서 위와 같이 명칭을 바꾸는건 어떨까요?
created_at: 2025-04-08T07:47:59Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,28 @@
+import clsx from "clsx";
 import { ReactNode } from "react";
 //
 interface Props {
-  onClick: onClick;
+  onClick?: onClick;
path: components/Button.tsx
user: login: kiJu2
body: ## 또한, 해당 타입은 필요 없을 수도 있겠네요 !
```suggestion
```
(이어서)
created_at: 2025-04-08T07:48:44Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,28 @@
+import clsx from "clsx";
 import { ReactNode } from "react";
 //
 interface Props {
-  onClick: onClick;
+  onClick?: onClick;
   children: ReactNode;
   disabled?: boolean;
+  className?: string;
 }
-export default function Button({ onClick, children, ...props }: Props) {
+export default function Button({
+  onClick,
+  children,
+  className,
+  disabled,
+  ...props
+}: Props) {
   return (
     <>
       <button
         onClick={onClick}
-        className="flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer"
+        className={clsx(
+          "flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer",
+          disabled ? "bg-gray-400" : "",
+          className
+        )}
path: components/Button.tsx
user: login: kiJu2
body: ## (이어서) `onClick`을 지우고 다음과 같이 작성해볼 수 있겠어요
```suggestion
export default function Button({
  children,
  className,
  disabled,
  ...props
}: Props) {
  return (
    <>
      <button
        className={clsx(
          "flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer",
          disabled ? "bg-gray-400" : "",
          className
        )}
```
created_at: 2025-04-08T07:49:42Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,28 @@
+import clsx from "clsx";
 import { ReactNode } from "react";
 //
 interface Props {
-  onClick: onClick;
+  onClick?: onClick;
   children: ReactNode;
   disabled?: boolean;
+  className?: string;
 }
-export default function Button({ onClick, children, ...props }: Props) {
+export default function Button({
+  onClick,
+  children,
+  className,
+  disabled,
+  ...props
+}: Props) {
   return (
     <>
       <button
         onClick={onClick}
-        className="flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer"
+        className={clsx(
+          "flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer",
+          disabled ? "bg-gray-400" : "",
+          className
+        )}
path: components/Button.tsx
user: login: kiJu2
body: ## 추가로 `disabled`도 지워볼 수 있을 것 같네요 !:
```tsx
export default function Button({
  children,
  className,
  ...props
}: Props) {
  return (
    <>
      <button
        disabled={disabled}
        className={clsx(
          "flex  bg-blue-500 justify-center items-center w-full h-full rounded-[8px] border-none font-Pretendard text-H5Bold text-gray-100 bg-light-blue cursor-pointer disabled:bg-gray-400", className
        )}
```
`disabled`를 버튼의 속성으로 넣게 되면 덩달아 접근성도 좋아지겠어요 !
또한, `disabled`를 상태 선택자를 통해서 스타일도 입힐 수 있구요 😊😊
created_at: 2025-04-08T07:52:05Z
author_association: COLLABORATOR
diff_hunk: @@ -37,78 +48,89 @@ export function Input({ onChange, type = "text", ...props }: Props) {
           $edit && "text-H7Regular h-[80px]"
         }relative w-full h-[56px] bg-gray-100 font-Pretendard text-H6Regular border-none rounded-xl text-gray-800 px-4 py-6 placeholder-gray-400 placeholder:text-H5Regular placeholder:absolute focus:outline-none`}
         type={type}
-        onChange={onChange}
+        onChange={(e) => {
+          if (e.nativeEvent.composed) return onChange(e.target.value);
+        }}
         onKeyUp={onKeyUp || undefined}
+        placeholder={placeholder}
       />
     </div>
   );
 }
+
 //
 interface ImgProps extends Omit<Props, "onChange"> {
-  onChange: (value: File | string) => void;
+  onChange: (value: null | string) => void;
 }
-export function ImgInput({ onChange, ...props }: ImgProps) {
+export function ImgInput({ onChange, label, ...props }: ImgProps) {
   const imgRef = useRef<HTMLInputElement>(null);
   const [imgPreview, setImgPreview] = useState<string>("");
 
   const handlePreviewImg = () => {
     if (!imgRef.current || !imgRef.current.files?.length) return;
     const file = imgRef.current.files[0];
+
+    const renamedFile = renameFile(file);
+
     const reader = new FileReader();
-    reader.readAsDataURL(file);
-    reader.onloadend = () => {
+    reader.readAsDataURL(renamedFile);
+
+    reader.onloadend = async () => {
       if (typeof reader.result === "string") {
         setImgPreview(reader.result);
-        onChange(file);
+        const formattedImage = await uploadImage(renamedFile);
+        onChange(formattedImage);
       }
     };
   };
-  //
+
   const handleClickImgDelete = () => {
     setImgPreview("");
-    onChange("");
+    onChange(null);
   };
+
   return (
-    <div className="flex gap-6 mobile:gap-[10px] ">
-      <div className="mobile:w-[168px]  w-[282px] h-[282px] bg-gray-100 rounded-xl  border-none text-gray-800">
-        <input
-          className="relative w-full h-full bg-gray-100 font-Pretendard text-H6Regular border-none rounded-xl text-gray-800 px-4 py-6 
+    <div className="flex flex-col gap-6 mobile:gap-[10px] ">
+      <p className="text-[18px] font-Pretendard text-H4Bold">{label}</p>
path: components/Input.tsx
user: login: kiJu2
body: # `<p>` 태그는 단락을 나타냅니다 !
**HTML `<p>` 요소**는 하나의 문단을 나타냅니다. 시각적인 매체에서, 문단은 보통 인접 블록과의 여백과 첫 줄의 들여쓰기로 구분하지만, HTML에서 문단은 이미지나 입력 폼 등 서로 관련있는 콘텐츠 무엇이나 될 수 있습니다.

예를 들어 다음과 같은 값이 `<p>`태그에 적절할 수 있어요 😊:
```
Geckos are a group of usually small, usually nocturnal lizards. They are found on every continent except Antarctica.
```

## ✨ 대체 제안: `<label>` 태그로 변경해보세요!
```suggestion
    <label htmlFor="fileUpload" className="text-[18px] font-Pretendard text-H4Bold">
      {label}
    </label>
```
혹은 이 텍스트가 진짜 단순한 스타일용 텍스트라면 <span>도 괜찮아요. 하지만 입력 폼의 설명이나 타이틀 역할을 하고 있다면 `<label>`을 쓰는 걸 추천드려요!
created_at: 2025-04-08T08:12:01Z
author_association: COLLABORATOR
diff_hunk: @@ -3,9 +3,22 @@ import pandaLogo from "@/public/assets/Logo/pandaLogo.svg";
 import textLogo from "@/public/assets/Logo/textLogo.svg";
 import myLogo from "@/public/assets/icons/default.profile.icon.svg";
 import Image from "next/image";
-import useWindowSize from "@/hooks/useWindowSize";
+import { useEffect, useState } from "react";
+import Button from "./Button";
+import { signIn } from "@/lib/auth";
+
 export default function Nav() {
-  const device: string = useWindowSize();
+  const [isLogin, setIsLogin] = useState(false);
+  useEffect(() => {
+    const hasAccessToken = Boolean(localStorage.getItem("accessToken"));
+    setIsLogin(hasAccessToken);
+  }, []);
+
+  const handleClickLogin = async () => {
+    await signIn();
+    setIsLogin(true);
+  };
path: components/Nav.tsx
user: login: kiJu2
body: ## `isLogin`이라는 상태는 `nav` 뿐 아니라 여러 곳에서 사용될 수 있겠네요 !
이럴 때, Context API와 훅을 하나 만들어두는건 어떨까요? 추가로 `isLoggin` 보다 `userData` 상태를 가지고 있어도 되겠어요. 😊
```tsx
const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    const token = localStorage.getItem("accessToken");
    if (token) {
      const userData = await getMy(); // 예시 입니다 !
      setUserData({ name: userData.name, email: userData.email }); // 예시 입니다 !
    }
  }, []);

  const signIn = async () => {
    const token = await login(); // 기존 코드에 `signIn` 함수와 같음
    localStorage.setItem("accessToken", token);
    const userData = await getMy(); // 예시 입니다 !
    setUserData({ name: userData.name, email: userData.email }); // 예시 입니다 !
  };

  // 추가로 signOut 함수도 필요하겠어요 !

  return (
    <AuthContext.Provider value={{ userData, signIn }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => useContext(AuthContext);
```
created_at: 2025-04-08T08:56:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,26 @@
+import Image from "next/image";
+import ProfileImg from "@/public/assets/icons/default.profile.icon.svg";
+import { useFormatDate, useFormatUpDate } from "@/hooks/useFormatting";
+import clsx from "clsx";
+interface Props {
+  value: {
+    writer: { nickname: string };
+    createdAt: string;
+  };
+  isComment?: boolean;
+}
+export default function Profile({ value, isComment }: Props) {
+  const formattedDate = isComment
+    ? useFormatUpDate(value.createdAt)
+    : useFormatDate(value.createdAt);
path: components/common/Profile.tsx
user: login: kiJu2
body: ## 두 함수는 리액트의 훅이라고 보기는 어려운 것 같아요.
유틸 함수로 사용하시는건 어떠세요?:
```suggestion
    ? formatUpDate(value.createdAt)
    : formatDate(value.createdAt);
```

created_at: 2025-04-08T09:11:51Z
author_association: COLLABORATOR
diff_hunk: @@ -1,12 +1,33 @@
-import axios from "axios";
+import axios, { AxiosResponse, InternalAxiosRequestConfig } from "axios";
 
 const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;
 
 const instance = axios.create({
   baseURL: BASE_URL,
   headers: {
     "Content-Type": "application/json",
+    withCredentials: true,
   },
 });
 
 export default instance;
+
+instance.interceptors.request.use(
+  (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
+    if (typeof window !== "undefined") {
+      const accessToken = localStorage.getItem("accessToken");
+      if (!accessToken) return config;
+      config.headers.set("Authorization", `Bearer ${accessToken}`);
+    }
+    return config;
+  }
+);
+
+instance.interceptors.response.use((response: AxiosResponse) => {
+  const accessToken = response.data.accessToken;
+  if (typeof window !== "undefined" && accessToken) {
+    localStorage.setItem("accessToken", accessToken);
+    localStorage.setItem("refreshToken", response.data.refreshToken);
+  }
+  return response;
+});
path: lib/api.tsx
user: login: kiJu2
body: ## 크으 ~ 인터셉터를 적절히 사용하셨군요 👍👍👍
created_at: 2025-04-08T09:12:29Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,120 @@
+import Button from "@/components/Button";
+import { ImgInput, Input } from "@/components/Input";
+import instance from "@/lib/api";
+import { createArticle } from "@/lib/Articles";
+import { GetServerSideProps } from "next";
+import { parseCookies, setCookie } from "nookies";
+import { useState } from "react";
+
+const InputAttributes = [
+  {
+    name: "title",
+    value: "",
+    placeholder: "제목을 입력해주세요",
+    label: "제목",
+    validation: "",
+    key: 1,
+  },
+  {
+    name: "content",
+    value: "",
+    placeholder: "내용을 입력해주세요",
+    label: "내용",
+    validation: "",
+    key: 2,
+  },
+];
+
+export const getServerSideProps: GetServerSideProps = async (ctx) => {
+  const cookies = parseCookies(ctx);
+  const accessToken = cookies.accessToken;
+  const refreshToken = cookies.refreshToken;
+  if (!accessToken) {
+    return {
+      redirect: {
+        destination: "/",
+      },
+      props: {},
+    };
+  }
+  try {
+    await instance.get(`/user/me`);
+  } catch (err: any) {
+    if (err.response?.status === 401) {
+      try {
+        const refreshRes = await instance.post(
+          `/auth/refresh-token`,
+          {},
+          { headers: { Cookie: `refreshToken=${refreshToken}` } }
+        );
+        const newAccessToken = refreshRes.data.accessToken;
+        setCookie(ctx, "accessToken", newAccessToken, {
+          path: "/",
+          httpOnly: true,
+          sameSite: "lax",
+        });
+      } catch {
+        return {
+          redirect: { destination: "/" },
+          props: {},
+        };
+      }
+    }
+  }
+  return { props: {} };
+};
path: pages/addboard.tsx
user: login: kiJu2
body: ## 서버사이드에서 인가처리도 훌륭히 하셨네요 ! 👍
NextJs에서 서버 자원을 사용하는 방법도 훌륭히 익히신 것 같아요 😆😆
created_at: 2025-04-08T09:20:08Z
author_association: COLLABORATOR

PR comments:
user: login: kiJu2
created_at: 2025-04-08T07:16:45Z
body: 스프리트 미션 하시느라 수고 많으셨어요.
학습에 도움 되실 수 있게 꼼꼼히 리뷰 하도록 해보겠습니다. 😊
author_association: COLLABORATOR
user: login: kiJu2
created_at: 2025-04-08T09:16:58Z
body: 선향님 ~! 너무 수고 많으셨습니다.
놀랐어요 ㅋㅋㅋ 월요일 되자마자 바로 푸시하시다니.. 주말 푸욱 쉬시고 바로 제출하셨나보군요 !
프로젝트가 끝났음에도 불구하고 지치지 않고 꾸준히 노력하시는 모습이 너무 보기 좋습니다. 
이번 미션 정말 수고 많으셨습니다 😊😊
author_association: COLLABORATOR