Jelajahi Sumber

first commit

KIM-JINO5 3 bulan lalu
melakukan
00db81ae91
100 mengubah file dengan 9882 tambahan dan 0 penghapusan
  1. 43 0
      .gitignore
  2. 41 0
      .vscode/launch.json
  3. 10 0
      .vscode/settings.json
  4. 36 0
      README.md
  5. 6 0
      app/(account)/change-approve/loading.tsx
  6. 157 0
      app/(account)/change-approve/page.tsx
  7. 89 0
      app/(account)/change-approve/style.scss
  8. 6 0
      app/(account)/change-email/loading.tsx
  9. 128 0
      app/(account)/change-email/page.tsx
  10. 92 0
      app/(account)/change-email/style.scss
  11. 6 0
      app/(account)/change-intro/loading.tsx
  12. 126 0
      app/(account)/change-intro/page.tsx
  13. 94 0
      app/(account)/change-intro/style.scss
  14. 6 0
      app/(account)/change-name/loading.tsx
  15. 139 0
      app/(account)/change-name/page.tsx
  16. 104 0
      app/(account)/change-name/style.scss
  17. 6 0
      app/(account)/change-password/loading.tsx
  18. 146 0
      app/(account)/change-password/page.tsx
  19. 92 0
      app/(account)/change-password/style.scss
  20. 6 0
      app/(account)/change-photo/loading.tsx
  21. 135 0
      app/(account)/change-photo/page.tsx
  22. 73 0
      app/(account)/change-photo/style.scss
  23. 6 0
      app/(account)/change-summary/loading.tsx
  24. 139 0
      app/(account)/change-summary/page.tsx
  25. 104 0
      app/(account)/change-summary/style.scss
  26. 10 0
      app/(account)/layout.tsx
  27. 138 0
      app/(account)/login-log/page.tsx
  28. 69 0
      app/(account)/login-log/style.scss
  29. 30 0
      app/(account)/navTabs.tsx
  30. 184 0
      app/(account)/profile/page.tsx
  31. 62 0
      app/(account)/profile/style.scss
  32. 36 0
      app/(account)/style.scss
  33. 92 0
      app/(account)/valid-email/page.tsx
  34. 12 0
      app/(account)/valid-email/style.scss
  35. 132 0
      app/(account)/withdraw/page.tsx
  36. 67 0
      app/(account)/withdraw/style.scss
  37. 218 0
      app/(auth)/approval/page.tsx
  38. 93 0
      app/(auth)/approval/style.scss
  39. 92 0
      app/(auth)/forgot-password/page.tsx
  40. 57 0
      app/(auth)/forgot-password/style.scss
  41. 13 0
      app/(auth)/layout.tsx
  42. 6 0
      app/(auth)/login/loading.tsx
  43. 15 0
      app/(auth)/login/page.tsx
  44. 88 0
      app/(auth)/login/style.scss
  45. 111 0
      app/(auth)/login/view.tsx
  46. 6 0
      app/(auth)/register/loading.tsx
  47. 15 0
      app/(auth)/register/page.tsx
  48. 108 0
      app/(auth)/register/style.scss
  49. 183 0
      app/(auth)/register/view.tsx
  50. 122 0
      app/(auth)/reset-password/page.tsx
  51. 63 0
      app/(auth)/reset-password/style.scss
  52. 16 0
      app/(auth)/style.scss
  53. 50 0
      app/(auth)/welcome/page.tsx
  54. 32 0
      app/(auth)/welcome/style.scss
  55. 86 0
      app/(forum)/board/[code]/page.tsx
  56. 89 0
      app/(forum)/board/[code]/style.scss
  57. 259 0
      app/(forum)/board/[code]/view.tsx
  58. 132 0
      app/(forum)/board/_component/AlbumListLayout.tsx
  59. 133 0
      app/(forum)/board/_component/DefaultListLayout.tsx
  60. 19 0
      app/(forum)/board/_component/FooterContent.tsx
  61. 19 0
      app/(forum)/board/_component/HeaderContent.tsx
  62. 98 0
      app/(forum)/board/_component/NoticeListLayout.tsx
  63. 42 0
      app/(forum)/board/_component/PermissionDenied.tsx
  64. 33 0
      app/(forum)/board/_component/PostWriteButton.tsx
  65. 143 0
      app/(forum)/board/_component/QnAListLayout.tsx
  66. 573 0
      app/(forum)/board/_component/style.scss
  67. 222 0
      app/(forum)/comment/_component/EditForm.tsx
  68. 153 0
      app/(forum)/comment/_component/Item.tsx
  69. 94 0
      app/(forum)/comment/_component/List.tsx
  70. 245 0
      app/(forum)/comment/_component/WriteForm.tsx
  71. 19 0
      app/(forum)/comment/page.tsx
  72. 231 0
      app/(forum)/comment/style.scss
  73. 114 0
      app/(forum)/comment/view.tsx
  74. 9 0
      app/(forum)/layout.tsx
  75. 46 0
      app/(forum)/post/[id]/page.tsx
  76. 273 0
      app/(forum)/post/[id]/style.scss
  77. 390 0
      app/(forum)/post/[id]/view.tsx
  78. 43 0
      app/(forum)/post/_component/Content.tsx
  79. 64 0
      app/(forum)/post/_component/Copied.tsx
  80. 144 0
      app/(forum)/post/_component/Editor.tsx
  81. 19 0
      app/(forum)/post/_component/FooterContent.tsx
  82. 19 0
      app/(forum)/post/_component/HeaderContent.tsx
  83. 372 0
      app/(forum)/post/_component/LatestPosts.tsx
  84. 74 0
      app/(forum)/post/_component/PostTagInput.tsx
  85. 75 0
      app/(forum)/post/_component/QRCode.tsx
  86. 139 0
      app/(forum)/post/_component/Report.tsx
  87. 69 0
      app/(forum)/post/_component/SnsShare.tsx
  88. 38 0
      app/(forum)/post/_component/style.scss
  89. 67 0
      app/(forum)/post/edit/[id]/page.tsx
  90. 156 0
      app/(forum)/post/edit/[id]/style.scss
  91. 354 0
      app/(forum)/post/edit/[id]/view.tsx
  92. 17 0
      app/(forum)/post/write/page.tsx
  93. 156 0
      app/(forum)/post/write/style.scss
  94. 377 0
      app/(forum)/post/write/view.tsx
  95. 70 0
      app/api/auth/[slug]/route.ts
  96. 10 0
      app/api/ping/route.ts
  97. 18 0
      app/component/AuthStatus.tsx
  98. 84 0
      app/component/Editor.tsx
  99. 81 0
      app/component/EmojiPicker.tsx
  100. 104 0
      app/component/Layout.tsx

+ 43 - 0
.gitignore

@@ -0,0 +1,43 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+certificates

+ 41 - 0
.vscode/launch.json

@@ -0,0 +1,41 @@
+{
+	// Use IntelliSense to learn about possible attributes.
+	// Hover to view descriptions of existing attributes.
+	// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+	"version": "0.2.0",
+	"configurations": [
+		{
+			"name": "Next.js: debug server-side",
+			"type": "node-terminal",
+			"request": "launch",
+			"command": "npm run dev"
+		},
+		{
+			"name": "Next.js: debug client-side",
+			"type": "msedge",
+			"request": "launch",
+			"url": "https://localhost:3000",
+			"runtimeExecutable": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
+			"sourceMaps": true,
+			"sourceMapPathOverrides": {
+				"webpack:///./*": "${webRoot}/*"
+			}
+		},
+		{
+			"name": "Full Stack Debug",
+			"type": "node",
+			"request": "launch",
+			"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
+			"args": ["dev", "--experimental-https"],
+			"runtimeArgs": ["--inspect"],
+			"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
+			"serverReadyAction": {
+				"action": "debugWithEdge",
+				"killOnServerStop": true,
+				"pattern": "- Local:.+(https://localhost:3000)",
+				"uriFormat": "%s",
+				"webRoot": "${workspaceFolder}"
+			}
+		}
+	]
+}

+ 10 - 0
.vscode/settings.json

@@ -0,0 +1,10 @@
+{
+	"files.trimTrailingWhitespace": true,
+	"terminal.integrated.profiles": {
+        "PowerShell (Admin)": {
+            "path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
+            "args": ["-NoExit", "-Command", "Start-Process powershell -Verb RunAs"]
+        }
+    },
+    "terminal.integrated.defaultProfile.windows": "PowerShell (Admin)"
+}

+ 36 - 0
README.md

@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

+ 6 - 0
app/(account)/change-approve/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 157 - 0
app/(account)/change-approve/page.tsx

@@ -0,0 +1,157 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useState, useEffect } from 'react';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { ChangeApproveRequest } from '@/dtos/request/account';
+import { fetchChangeApprove } from '@/lib/api/account';
+import { throwError } from '@/lib/utils/client';
+
+export default function ChangeApprove()
+{
+	const { member, setMember } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [notifications, setNotifications] = useState({
+		isReceiveSMS: member?.memberApprove.isReceiveSMS || false,
+		isReceiveEmail: member?.memberApprove.isReceiveEmail || false,
+		isReceiveNote: member?.memberApprove.isReceiveNote || false
+	});
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+		if (member) {
+
+			const approve = member.memberApprove;
+			setNotifications({
+				isReceiveSMS: approve?.isReceiveSMS ?? false,
+				isReceiveEmail: approve?.isReceiveEmail ?? false,
+				isReceiveNote: approve?.isReceiveNote ?? false
+			});
+		}
+	}, [member]);
+
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		fetchChangeApprove({
+			IsReceiveSMS: notifications.isReceiveSMS,
+			IsReceiveEmail: notifications.isReceiveEmail,
+			IsReceiveNote: notifications.isReceiveNote
+		} as ChangeApproveRequest
+		).then(res => {
+			throwError(res);
+
+			member.memberApprove.isReceiveSMS = notifications.isReceiveSMS;
+			member.memberApprove.isReceiveEmail = notifications.isReceiveEmail;
+			member.memberApprove.isReceiveNote = notifications.isReceiveNote;
+			setMember(member);
+			localStorage.setItem('member', JSON.stringify(member));
+			alert("수신 설정이 변경되었습니다.");
+
+		}).catch(err => {
+			setError(err.message);
+		});
+	}
+
+	const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+		const { name, checked } = e.target;
+
+		setNotifications((prev) => ({
+			...prev,
+			[name]: checked
+		}));
+	};
+
+	return (
+		<div id="changeApprove">
+			<h1>알림 수신 설정</h1>
+			<form id="fChangeApprove" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+				<table className="table-auto max-xl:w-full lg:w-[600px]">
+					<caption>
+						보다 편리한 서비스 이용을 위해 원하는 알림 수신 여부를 설정할 수 있습니다.
+					</caption>
+					<colgroup>
+						<col width="30%"/>
+						<col width="60%"/>
+					</colgroup>
+					<tbody>
+						<tr>
+							<th>SMS 수신</th>
+							<td>
+								<label>
+									<input
+										type="checkbox"
+										name="isReceiveSMS"
+										checked={notifications.isReceiveSMS}
+										onChange={handleChange}
+									/>
+									수신합니다.
+								</label>
+							</td>
+						</tr>
+						<tr>
+							<th>이메일 수신</th>
+							<td>
+								<label>
+									<input
+										type="checkbox"
+										name="isReceiveEmail"
+										checked={notifications.isReceiveEmail}
+										onChange={handleChange}
+									/>
+									수신합니다.
+								</label>
+							</td>
+						</tr>
+						<tr>
+							<th>쪽지 수신</th>
+							<td>
+								<label>
+									<input
+										type="checkbox"
+										name="isReceiveNote"
+										checked={notifications.isReceiveNote}
+										onChange={handleChange}
+									/>
+									수신합니다.
+								</label>
+							</td>
+						</tr>
+					</tbody>
+					<tfoot>
+						<tr>
+							<td colSpan={2}>
+								<div className="flex justify-center gap-2">
+									<button type="submit" className="btn btn-submit">확인</button>
+									<Link href="/profile" className="btn btn-default">취소</Link>
+								</div>
+							</td>
+						</tr>
+					</tfoot>
+				</table>
+			</form>
+
+			<br />
+			<dl className="max-xl:w-full lg:w-[600px]">
+				<dt hidden>&nbsp;</dt>
+				<dd>
+					<ol>
+						<li>수신을 거부하더라도 계정 보안 및 필수 서비스 공지는 계속 받을 수 있습니다.</li>
+						<li>프로모션 및 이벤트 소식을 받고 싶다면 수신을 활성화하세요.</li>
+					</ol>
+				</dd>
+			</dl>
+		</div>
+	);
+}

+ 89 - 0
app/(account)/change-approve/style.scss

@@ -0,0 +1,89 @@
+#changeApprove {
+	padding: 25px 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 12px 13px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+				}
+
+				td {
+					border-bottom: 1px solid #ccc;
+
+					input[type="checkbox"] {
+						transform: scale(1.3);
+						margin-right: 8px;
+					}
+
+					label {
+						cursor: pointer;
+					}
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+				}
+			}
+		}
+	}
+
+	dl {
+		background-color: #f1f1f1;
+		border: 1px solid #ccc;
+		line-height: 1.5;
+		padding: 15px 16px 12px 16px;
+
+		dt, dd {
+			font-size: 14px;
+		}
+
+		dt {
+			font-weight: bold;
+			margin-bottom: 7px;
+		}
+
+		dd {
+			ol {
+				list-style: disc;
+				padding: 0 20px;
+			}
+		}
+	}
+
+	blockquote {
+		line-height: 1.9;
+	}
+}

+ 6 - 0
app/(account)/change-email/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 128 - 0
app/(account)/change-email/page.tsx

@@ -0,0 +1,128 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { useConfigContext } from '@/contexts/configProvider';
+import { ChangeEmailRequest } from '@/dtos/request/account';
+import { fetchChangeEmail } from '@/lib/api/account';
+import { throwError } from '@/lib/utils/client';
+
+export default function ChangeEmail()
+{
+	const config = useConfigContext();
+	const { member } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [isComplete, setComplete] = useState<boolean>(false);
+	const [newEmail, setNewEmail] = useState<string>('');
+	const newEmailRef = useRef<HTMLInputElement>(null);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	// 이메일 변경 요청
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		if (!newEmail) {
+			newEmailRef.current?.focus();
+			return setError('변경하실 이메일을 입력하세요.');
+		}
+
+		fetchChangeEmail({ Email: newEmail } as ChangeEmailRequest).then((res) => {
+			throwError(res);
+
+			setComplete(true);
+		}).catch(err => {
+			setError(err.message);
+		});
+	}
+
+	const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+		setNewEmail(e.target.value.trim());
+	}, []);
+
+	const refresh = () => location.reload();
+
+	return (
+		<>
+		<div id="changeEmail">
+			{!isComplete ?
+			<>
+				<h1>이메일 변경</h1>
+				<form id="fChangeEmail" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+					<table className="table-auto max-xl:w-full lg:w-[600px]">
+						<caption>
+							새 이메일 주소를 입력하고 &quot;확인&quot; 버튼을 누릅니다.<br />
+							인증 메일이 도착하시면 내용을 확인하신 후 본문에 있는 링크를 클릭해주세요.
+						</caption>
+						<colgroup>
+							<col width="30%"/>
+							<col width="60%"/>
+							<col width="10%"/>
+						</colgroup>
+						<tbody>
+							<tr>
+								<th>현재 이메일</th>
+								<td>{member?.email}</td>
+								<td>&nbsp;</td>
+							</tr>
+							<tr>
+								<th>새 이메일</th>
+								<td>
+									<input type="email" name="new_email" id="newEmail" ref={newEmailRef} value={newEmail} placeholder="변경할 이메일 주소" maxLength={60} autoFocus autoComplete="off" onChange={handleChange} />
+								</td>
+								<td>&nbsp;</td>
+							</tr>
+						</tbody>
+						<tfoot>
+							<tr>
+								<td colSpan={3}>
+									<div className="flex justify-center gap-2">
+										<button type="submit" className="btn btn-submit">확인</button>
+										<Link href="/profile" className="btn btn-default">취소</Link>
+									</div>
+								</td>
+							</tr>
+						</tfoot>
+					</table>
+				</form>
+				<br />
+				<dl className="max-xl:w-full lg:w-[600px]">
+					<dt>등록할 수 없는 이메일 주소</dt>
+					<dd>
+						<ol>
+							<li>전 세계 인터넷 통신 표준 RFC(Request for Comments)를 준수하지 않는 전자 메일 주소</li>
+							<li>회사에서 지정한 사용할 수 없는 문자(공백 및 더블바이트 문자)</li>
+							<li>전자 메일 계정 부분에서 반자 영숫자, 반자 기호 _(밑줄), . (점) 및 -(하이픈) 이외의 문자가 사용됩니다.</li>
+							{config.account.changeEmailDay > 0 && <li>이메일 변경 주기는 {config.account.changeEmailDay}일입니다.</li>}
+						</ol>
+					</dd>
+				</dl>
+			</>
+			:
+			<>
+				<h1>인증 이메일 발송</h1>
+				<blockquote>
+					<strong>{newEmail} 으로 인증 이메일이 발송되었습니다.</strong><br />
+					메일이 도착하면 내용을 확인하신 후 본문에 있는 링크를 클릭해 주세요.<br />
+					몇 분 이내에 메일이 도착하지 않는 경우 등록 된 메일 주소 및 수신 거부 설정을 확인한 후
+					처음부터 다시 시도해 주십시오.
+				</blockquote>
+				<br />
+				<button className="btn btn-default" onClick={refresh}>다시 시도하기</button>
+			</>
+			}
+		</div>
+		</>
+	);
+}

+ 92 - 0
app/(account)/change-email/style.scss

@@ -0,0 +1,92 @@
+#changeEmail {
+	padding: 25px 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 12px 13px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+				}
+
+				td {
+					border-bottom: 1px solid #ccc;
+
+					a {
+						padding: 3px 16px;
+					}
+
+					&:nth-of-type(2) {
+						text-align: right;
+					}
+
+					input {
+						width: 100%;
+					}
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+				}
+			}
+		}
+	}
+
+	dl {
+		background-color: #f1f1f1;
+		border: 1px solid #ccc;
+		line-height: 1.5;
+		padding: 15px 16px 12px 16px;
+
+		dt, dd {
+			font-size: 14px;
+		}
+
+		dt {
+			font-weight: bold;
+			margin-bottom: 7px;
+		}
+
+		dd {
+			ol {
+				list-style: disc;
+				padding: 0 20px;
+			}
+		}
+	}
+
+	blockquote {
+		line-height: 1.9;
+	}
+}

+ 6 - 0
app/(account)/change-intro/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 126 - 0
app/(account)/change-intro/page.tsx

@@ -0,0 +1,126 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useState, useEffect } from 'react';
+import { useConfigContext } from '@/contexts/configProvider';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { ChangeIntroRequest } from '@/dtos/request/account';
+import { fetchChangeIntro, fetchRemoveIntro } from '@/lib/api/account';
+import { throwError } from '@/lib/utils/client';
+import Editor from '@/app/component/Editor';
+
+export default function ChangeIntro()
+{
+	const config = useConfigContext();
+	const { member, setMember } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [newIntro, setNewIntro] = useState<string|null>('');
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+		if (member?.intro && !newIntro) {
+			const intro = member.intro.replace(/<img\b[^>]*?\bsrc=[''](\/[^'']*)['']/gi, (match, srcPath) => {
+				return match.replace(srcPath, process.env.API_URL + srcPath);
+			});
+
+			setNewIntro(intro);
+		}
+	}, [member, newIntro]);
+
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		fetchChangeIntro({ Intro: newIntro } as ChangeIntroRequest).then((res) => {
+			throwError(res);
+
+			member.intro = newIntro;
+			setMember(member);
+			setNewIntro(newIntro);
+			localStorage.setItem('member', JSON.stringify(member));
+
+			alert('자기소개가 변경되었습니다.');
+		}).catch(err => {
+			setError(err.message);
+			setNewIntro(member.intro);
+		});
+	}
+
+	const handleDelete = async () => {
+		if (confirm("자기소개를 삭제하시겠습니까?")) {
+			if (!member || !member.intro) {
+				return;
+			}
+
+			fetchRemoveIntro().then((res) => {
+				throwError(res);
+
+				member.intro = null;
+				setMember(member);
+				localStorage.setItem('member', JSON.stringify(member));
+
+				alert("자기소개가 삭제되었습니다.");
+			}).catch(err => {
+				setError(err.message);
+			}).finally(() => {
+				setNewIntro('');
+			});
+		}
+	};
+
+	return (
+		<>
+		<div id='changeIntro'>
+			<h1>자기소개 변경</h1>
+			<form id='fChangeIntro' method='post' acceptCharset='utf-8' autoComplete='off' onSubmit={handleSubmit}>
+				<table className='table-auto max-xl:w-full xl:w-[800px]'>
+					<caption>
+						커뮤니티 프로필에 표시되는 자기소개를 설정할 수 있습니다.
+					</caption>
+					<tbody>
+						<tr>
+							<td>
+								<Editor data={newIntro ?? ''} onChange={setNewIntro} />
+							</td>
+						</tr>
+					</tbody>
+					<tfoot>
+						<tr>
+							<td colSpan={3}>
+								<div className='flex justify-center gap-2'>
+									<button type='submit' className='btn btn-submit'>확인</button>
+									{ member?.intro && (
+										<button type="button" className="btn btn-delete" onClick={handleDelete}>삭제</button>
+									)}
+									<Link href='/profile' className='btn btn-default'>취소</Link>
+								</div>
+							</td>
+						</tr>
+					</tfoot>
+				</table>
+			</form>
+			<br />
+			<dl className='max-xl:w-full xl:w-[800px]'>
+				<dt hidden>&nbsp;</dt>
+				<dd>
+					<ol>
+						<li>자기소개는 최대 1000자 이내로 입력 가능합니다.</li>
+						<li>부적절한 내용은 별도의 고지 없이 삭제될 수 있습니다.</li>
+						{config.account.changeIntroDay > 0 && <li>한마디 변경 주기는 {config.account.changeIntroDay}일입니다.</li>}
+					</ol>
+				</dd>
+			</dl>
+		</div>
+		</>
+	);
+}

+ 94 - 0
app/(account)/change-intro/style.scss

@@ -0,0 +1,94 @@
+#changeIntro {
+	position: relative;
+	padding: 25px 32px 32px 32px;
+	min-height: 100%;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 12px 13px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+				}
+
+				td {
+					border-bottom: 1px solid #ccc;
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+
+					.btn-delete {
+						background-color: #e6f4fa;
+						color: #333;
+						border: 1px solid #b3b3b3;
+						-webkit-box-shadow: inset 0 -1px 0 0 #b3b3b3;
+						box-shadow: inset 0 -1px 0 0 #b3b3b3;
+
+						&:hover {
+							background-color: #c6e7f3;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	dl {
+		background-color: #f1f1f1;
+		border: 1px solid #ccc;
+		line-height: 1.5;
+		padding: 15px 16px 12px 16px;
+
+		dt, dd {
+			font-size: 14px;
+		}
+
+		dt {
+			font-weight: bold;
+			margin-bottom: 7px;
+		}
+
+		dd {
+			ol {
+				list-style: disc;
+				padding: 0 20px;
+			}
+		}
+	}
+
+	blockquote {
+		line-height: 1.9;
+	}
+}

+ 6 - 0
app/(account)/change-name/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 139 - 0
app/(account)/change-name/page.tsx

@@ -0,0 +1,139 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useConfigContext } from '@/contexts/configProvider';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { ChangeNameRequest } from '@/dtos/request/account';
+import { fetchChangeName, fetchRemoveName } from '@/lib/api/account';
+import { throwError } from '@/lib/utils/client';
+
+export default function ChangeName()
+{
+	const config = useConfigContext();
+	const { member, setMember } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [newName, setNewName] = useState<string>('');
+	const newNameRef = useRef<HTMLInputElement>(null);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		if (!newName) {
+			newNameRef.current?.focus();
+			return setError('별명을 입력하세요.');
+		}
+
+		fetchChangeName({ Name: newName } as ChangeNameRequest).then((res) => {
+			throwError(res);
+
+			member.name = newName;
+			setMember(member);
+			localStorage.setItem('member', JSON.stringify(member));
+
+			alert("별명이 변경되었습니다.");
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setNewName('');
+		});
+	}
+
+	const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+		setNewName(e.target.value.trim());
+	}, []);
+
+	const handleDelete = async () => {
+		if (confirm("별명을 삭제하시겠습니까?")) {
+			if (!member || !member.name) {
+				return;
+			}
+
+			fetchRemoveName().then((res) => {
+				throwError(res);
+
+				member.name = null;
+				setMember(member);
+				localStorage.setItem('member', JSON.stringify(member));
+
+				alert("별명이 삭제되었습니다.");
+			}).catch(err => {
+				setError(err.message);
+			}).finally(() => {
+				setNewName('');
+			});
+		}
+	};
+
+	return (
+		<>
+		<div id="changeName">
+			<h1>별명 변경</h1>
+			<form id="fChangeName" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+				<table className="table-auto max-xl:w-full lg:w-[600px]">
+					<caption>
+						개성을 담아 별명을 지어보세요!<br />
+						일 간격으로 별명을 변경할 수 있습니다.
+					</caption>
+					<colgroup>
+						<col width="30%"/>
+						<col width="60%"/>
+						<col width="10%"/>
+					</colgroup>
+					<tbody>
+						<tr>
+							<th>현재 별명</th>
+							<td>{member?.name ?? '-'}</td>
+							<td>&nbsp;</td>
+						</tr>
+						<tr>
+							<th>새 별명</th>
+							<td>
+								<input type="text" name="new_name" id="newName" ref={newNameRef} value={newName} placeholder="변경할 별명" maxLength={20} autoFocus autoComplete="off" onChange={handleChange} />
+							</td>
+							<td>&nbsp;</td>
+						</tr>
+					</tbody>
+					<tfoot>
+						<tr>
+							<td colSpan={3}>
+								<div className="flex justify-center gap-2">
+									<button type="submit" className="btn btn-submit">확인</button>
+									{ member?.name && (
+										<button type="button" className="btn btn-delete" onClick={handleDelete}>삭제</button>
+									)}
+									<Link href="/profile" className="btn btn-default">취소</Link>
+								</div>
+							</td>
+						</tr>
+					</tfoot>
+				</table>
+			</form>
+			<br />
+			<dl className="max-xl:w-full lg:w-[600px]">
+				<dt>등록할 수 없는 별명</dt>
+				<dd>
+					<ol>
+						<li>별명은 최대 20자 이내로 입력 가능합니다.</li>
+						<li>욕설 및 비속어는 별명으로 만들 수 없습니다.</li>
+						<li>부적절한 별명은 별도의 고지 없이 변경될 수 있습니다.</li>
+						{config.account.changeNameDay > 0 && <li>별명 변경 주기는 {config.account.changeNameDay}일입니다.</li>}
+					</ol>
+				</dd>
+			</dl>
+		</div>
+		</>
+	);
+}

+ 104 - 0
app/(account)/change-name/style.scss

@@ -0,0 +1,104 @@
+#changeName {
+	padding: 25px 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 12px 13px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+				}
+
+				td {
+					border-bottom: 1px solid #ccc;
+
+					a {
+						padding: 3px 16px;
+					}
+
+					&:nth-of-type(2) {
+						text-align: right;
+					}
+
+					input {
+						width: 100%;
+					}
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+
+					.btn-delete {
+						background-color: #e6f4fa;
+						color: #333;
+						border: 1px solid #b3b3b3;
+						-webkit-box-shadow: inset 0 -1px 0 0 #b3b3b3;
+						box-shadow: inset 0 -1px 0 0 #b3b3b3;
+
+						&:hover {
+							background-color: #c6e7f3;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	dl {
+		background-color: #f1f1f1;
+		border: 1px solid #ccc;
+		line-height: 1.5;
+		padding: 15px 16px 12px 16px;
+
+		dt, dd {
+			font-size: 14px;
+		}
+
+		dt {
+			font-weight: bold;
+			margin-bottom: 7px;
+		}
+
+		dd {
+			ol {
+				list-style: disc;
+				padding: 0 20px;
+			}
+		}
+	}
+
+	blockquote {
+		line-height: 1.9;
+	}
+}

+ 6 - 0
app/(account)/change-password/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 146 - 0
app/(account)/change-password/page.tsx

@@ -0,0 +1,146 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useState, useEffect, useCallback } from 'react';
+import { useConfigContext } from '@/contexts/configProvider';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { ChangePasswordRequest } from '@/dtos/request/account';
+import { fetchChangePassword } from '@/lib/api/account';
+import { getPasswordPolicyMessage } from '@/lib/utils/client';
+import NavTabs from '../navTabs';
+
+export default function ChangePassword()
+{
+	const config = useConfigContext();
+	const { member } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [isComplete, setComplete] = useState<boolean>(false);
+	const [formData, setFormData] = useState({
+		currentPassword: '',
+		newPassword: '',
+		confirmPassword: ''
+	});
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		if (formData.newPassword !== formData.confirmPassword) {
+			setError("새 비밀번호가 일치하지 않습니다.");
+			return;
+		}
+
+		if (formData.currentPassword === formData.newPassword) {
+			setError("새 비밀번호는 현재 비밀번호와 달라야 합니다.");
+			return;
+		}
+
+		fetchChangePassword({
+			ID: member.id,
+			CurrentPassword: formData.currentPassword,
+			NewPassword: formData.newPassword,
+			ConfirmPassword: formData.confirmPassword
+		} as ChangePasswordRequest).then((res) => {
+			if (!res.ok) {
+				throw new Error(res.message!);
+			}
+
+			setComplete(true);
+		}).catch(err => {
+			setError(err.message);
+		});
+	}
+
+	const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+		const { id, value } = e.target;
+		setFormData(prev => ({ ...prev, [id]: value}));
+	}, []);
+
+	useEffect(() => {
+		if (isComplete)
+		{
+			// 입력 초기화
+			setFormData({
+				currentPassword: '',
+				newPassword: '',
+				confirmPassword: ''
+			});
+
+			setComplete(false);
+			alert("비밀번호가 변경되었습니다.");
+		}
+	}, [isComplete]);
+
+	const txtPasswordGuide = getPasswordPolicyMessage({
+		passwordUppercaseLength: config.account.passwordUppercaseLength,
+		passwordNumbersLength: config.account.passwordNumbersLength,
+		passwordSpecialcharsLength: config.account.passwordSpecialcharsLength
+	});
+
+	return (
+		<>
+		<NavTabs />
+
+		<div id="changePassword">
+			<h1>비밀번호 변경</h1>
+			<form id="fChangePassword" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+				<table className="table-auto max-xl:w-full lg:w-[600px]">
+					<caption>
+						비밀번호는 { config.account.passwordMinLength }자 이상 20자 이하로 설정 가능합니다.<br />
+						{ txtPasswordGuide }
+					</caption>
+					<colgroup>
+						<col width="30%"/>
+						<col width="60%"/>
+						<col width="10%"/>
+					</colgroup>
+					<tbody>
+						<tr>
+							<th>현재 비밀번호</th>
+							<td>
+								<input type="password" id="currentPassword" value={formData.currentPassword} onChange={handleChange} placeholder="현재 비밀번호" required />
+							</td>
+							<td>&nbsp;</td>
+						</tr>
+						<tr>
+							<th>새 비밀번호</th>
+							<td>
+								<input type="password" id="newPassword" value={formData.newPassword} onChange={handleChange} placeholder="새 비밀번호" required />
+							</td>
+							<td>&nbsp;</td>
+						</tr>
+						<tr>
+							<th>새 비밀번호 확인</th>
+							<td>
+								<input type="password" id="confirmPassword" value={formData.confirmPassword} onChange={handleChange} placeholder="새 비밀번호 확인" required />
+							</td>
+							<td>&nbsp;</td>
+						</tr>
+					</tbody>
+					<tfoot>
+						<tr>
+							<td colSpan={3}>
+								<div className="flex justify-center gap-2">
+									<button type="submit" className="btn btn-submit">확인</button>
+									<Link href="/profile" className="btn btn-default">취소</Link>
+								</div>
+							</td>
+						</tr>
+					</tfoot>
+				</table>
+			</form>
+		</div>
+		</>
+	);
+}

+ 92 - 0
app/(account)/change-password/style.scss

@@ -0,0 +1,92 @@
+#changePassword {
+	padding: 0 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 12px 13px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+				}
+
+				td {
+					border-bottom: 1px solid #ccc;
+
+					a {
+						padding: 3px 16px;
+					}
+
+					&:nth-of-type(2) {
+						text-align: right;
+					}
+
+					input {
+						width: 100%;
+					}
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+				}
+			}
+		}
+	}
+
+	dl {
+		background-color: #f1f1f1;
+		border: 1px solid #ccc;
+		line-height: 1.5;
+		padding: 15px 16px 12px 16px;
+
+		dt, dd {
+			font-size: 14px;
+		}
+
+		dt {
+			font-weight: bold;
+			margin-bottom: 7px;
+		}
+
+		dd {
+			ol {
+				list-style: disc;
+				padding: 0 20px;
+			}
+		}
+	}
+
+	blockquote {
+		line-height: 1.9;
+	}
+}

+ 6 - 0
app/(account)/change-photo/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 135 - 0
app/(account)/change-photo/page.tsx

@@ -0,0 +1,135 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import Image from 'next/image';
+import { useState, useEffect } from 'react';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { fetchChangePhoto } from '@/lib/api/account';
+
+export default function ChangePhoto()
+{
+	const { member, setMember } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [newPhoto, setNewPhoto] = useState<File|null>(null);
+	const [preview, setPreview] = useState<string|null>(null);
+	const [remove, setRemove] = useState<boolean>(false);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		if (!newPhoto && !remove) {
+			return setError('사진을 선택하세요.');
+		}
+
+		fetchChangePhoto(newPhoto).then((res) => {
+			if (!res.ok) {
+				throw new Error(res.message!);
+			}
+
+			member.photo = (res.data?.photoURL || null);
+			setMember(member);
+			localStorage.setItem('member', JSON.stringify(member));
+
+			alert("사진이 변경되었습니다.");
+		}).catch(err => {
+			handleRemove();
+			setError(err.message);
+		}).finally(() => {
+			setRemove(false);
+			setNewPhoto(null);
+			setPreview(null);
+		});
+	}
+
+	const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+		if (e.target.files && e.target.files[0]) {
+			const selectedFile = e.target.files[0];
+			setNewPhoto(selectedFile);
+			setRemove(false);
+
+			// 이미지 미리보기
+			const reader = new FileReader();
+            reader.onloadend = () => setPreview(reader.result as string);
+            reader.readAsDataURL(selectedFile);
+		}
+	};
+
+	const handleRemove = () => {
+		setNewPhoto(null);
+		setPreview(null);
+		setRemove(true);
+
+		const fileInput = document.getElementById("photo") as HTMLInputElement;
+		if (fileInput) {
+			fileInput.value = ""; // 파일 선택 초기화
+		}
+	};
+
+	return (
+		<div id="changePhoto">
+			<h1>회원 사진</h1>
+			<form id="fChangePhoto" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+				<table className="table-auto max-xl:w-full lg:w-[600px]">
+					<caption>
+						커뮤니티 프로필에 대표 사진을 설정할 수 있습니다.
+					</caption>
+					<colgroup>
+						<col width="140px"/>
+						<col width="*"/>
+					</colgroup>
+					<tbody>
+						<tr>
+							<th>
+								<figure>
+									{preview ?
+										<Image src={preview} alt="미리보기" width={140} height={140} layout="responsive" style={{objectFit: "cover"}} />
+										:
+										(member.photo && !remove) ?
+											<Image src={member.photo} alt={member.email} width={140} height={140} style={{objectFit: "cover"}} unoptimized={true} />
+											:
+											<Image src="/resources/thumb.gif" alt="기본 사진" width={140} height={140} style={{objectFit: "cover"}} />
+									}
+								</figure>
+							</th>
+							<td>
+								<div className="flex justify-start gap-2">
+									<label htmlFor="photo" className="btn btn-default">사진 변경</label>
+									<input type="file" id="photo" accept="image/jpg,image/jpeg,image/png,image/gif" hidden onChange={handleChange} />
+
+									{(preview || member?.photo) &&
+										<button type="button" className="btn btn-default" onClick={handleRemove}>삭제</button>
+									}
+								</div>
+								<p className="mt-3">
+									98x98 크기 이상, 4MB 이하의 사진이 권장됩니다. 이미지 파일을 사용하세요. 사진이 {process.env.NEXT_PUBLIC_SITE_NAME} 커뮤니티 가이드를 준수해야 합니다.
+								</p>
+							</td>
+						</tr>
+					</tbody>
+					<tfoot>
+						<tr>
+							<td colSpan={2}>
+								<div className="flex justify-center gap-2">
+									<button type="submit" className="btn btn-submit">확인</button>
+									<Link href="/profile" className="btn btn-default">취소</Link>
+								</div>
+							</td>
+						</tr>
+					</tfoot>
+				</table>
+			</form>
+		</div>
+	);
+}

+ 73 - 0
app/(account)/change-photo/style.scss

@@ -0,0 +1,73 @@
+#changePhoto {
+	padding: 25px 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 0;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+					padding: 2px;
+
+					figure {
+						position: relative;
+						min-width: 140px;
+						min-height: 140px;
+						overflow: hidden;;
+
+						img {
+							position: absolute;
+							top: 50%;
+							left: 50%;
+							transform: translate(-50%, -50%);
+						}
+					}
+				}
+
+				td {
+					padding: 12px 13px;
+					border-bottom: 1px solid #ccc;
+
+					label, button {
+						padding: 5px 15px;
+					}
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+				}
+			}
+		}
+	}
+}

+ 6 - 0
app/(account)/change-summary/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 139 - 0
app/(account)/change-summary/page.tsx

@@ -0,0 +1,139 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useConfigContext } from '@/contexts/configProvider';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { ChangeSummaryRequest } from '@/dtos/request/account';
+import { fetchChangeSummary, fetchRemoveSummary } from '@/lib/api/account';
+import { throwError } from '@/lib/utils/client';
+
+export default function ChangeSummary()
+{
+	const config = useConfigContext();
+	const { member, setMember } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [newSummary, setNewSummary] = useState<string|null>('');
+	const newSummaryRef = useRef<HTMLInputElement>(null);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		if (!newSummary) {
+			newSummaryRef.current?.focus();
+			return setError('한마디를 입력하세요.');
+		}
+
+		setLoading(true);
+
+		fetchChangeSummary({ Summary: newSummary } as ChangeSummaryRequest).then((res) => {
+			throwError(res);
+
+			member.summary = newSummary;
+			setMember(member);
+			localStorage.setItem('member', JSON.stringify(member));
+			alert('한마디가 변경되었습니다.');
+
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setNewSummary('');
+		});
+	}
+
+	const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+		setNewSummary(e.target.value.trim());
+	}, []);
+
+	const handleDelete = async () => {
+		if (confirm("한마디를 삭제하시겠습니까?")) {
+			if (!member || !member.summary) {
+				return;
+			}
+
+			fetchRemoveSummary().then((res) => {
+				throwError(res);
+
+				member.summary = null;
+				setMember(member);
+				localStorage.setItem('member', JSON.stringify(member));
+
+				alert("한마디를 삭제되었습니다.");
+			}).catch(err => {
+				setError(err.message);
+			}).finally(() => {
+				setNewSummary('');
+			});
+		}
+	};
+
+	return (
+		<>
+		<div id='changeSummary'>
+			<h1>한마디 변경</h1>
+			<form id='fChangeSummary' method='post' acceptCharset='utf-8' autoComplete='off' onSubmit={handleSubmit}>
+				<table className='table-auto max-xl:w-full lg:w-[600px]'>
+					<caption>
+						커뮤니티 프로필에 표시되는 한마디를 설정할 수 있습니다.
+					</caption>
+					<colgroup>
+						<col width='30%'/>
+						<col width='60%'/>
+						<col width='10%'/>
+					</colgroup>
+					<tbody>
+						<tr>
+							<th>현재 한마디</th>
+							<td>{member?.summary || '-'}</td>
+							<td>&nbsp;</td>
+						</tr>
+						<tr>
+							<th>새 한마디</th>
+							<td>
+								<input type='text' name='new_summary' id='newSummary' ref={newSummaryRef} value={newSummary ?? ''} placeholder='변경할 한마디' maxLength={50} autoFocus autoComplete='off' onChange={handleChange} />
+							</td>
+							<td>&nbsp;</td>
+						</tr>
+					</tbody>
+					<tfoot>
+						<tr>
+							<td colSpan={3}>
+								<div className='flex justify-center gap-2'>
+									<button type='submit' className='btn btn-submit'>확인</button>
+									{ member?.summary && (
+										<button type='button' className='btn btn-delete' onClick={handleDelete}>삭제</button>
+									)}
+									<Link href='/profile' className='btn btn-default'>취소</Link>
+								</div>
+							</td>
+						</tr>
+					</tfoot>
+				</table>
+			</form>
+			<br />
+			<dl className='max-xl:w-full lg:w-[600px]'>
+				<dt>등록할 수 없는 한마디</dt>
+				<dd>
+					<ol>
+						<li>한마디는 최대 50자 이내로 입력 가능합니다.</li>
+						<li>부적절한 내용은 별도의 고지 없이 변경될 수 있습니다.</li>
+						{config.account.changeSummaryDay > 0 && <li>자기소개개 변경 주기는 {config.account.changeSummaryDay}일입니다.</li>}
+					</ol>
+				</dd>
+			</dl>
+		</div>
+		</>
+	);
+}

+ 104 - 0
app/(account)/change-summary/style.scss

@@ -0,0 +1,104 @@
+#changeSummary {
+	padding: 25px 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 12px 13px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+				}
+
+				td {
+					border-bottom: 1px solid #ccc;
+
+					a {
+						padding: 3px 16px;
+					}
+
+					&:nth-of-type(2) {
+						text-align: right;
+					}
+
+					input {
+						width: 100%;
+					}
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+
+					.btn-delete {
+						background-color: #e6f4fa;
+						color: #333;
+						border: 1px solid #b3b3b3;
+						-webkit-box-shadow: inset 0 -1px 0 0 #b3b3b3;
+						box-shadow: inset 0 -1px 0 0 #b3b3b3;
+
+						&:hover {
+							background-color: #c6e7f3;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	dl {
+		background-color: #f1f1f1;
+		border: 1px solid #ccc;
+		line-height: 1.5;
+		padding: 15px 16px 12px 16px;
+
+		dt, dd {
+			font-size: 14px;
+		}
+
+		dt {
+			font-weight: bold;
+			margin-bottom: 7px;
+		}
+
+		dd {
+			ol {
+				list-style: disc;
+				padding: 0 20px;
+			}
+		}
+	}
+
+	blockquote {
+		line-height: 1.9;
+	}
+}

+ 10 - 0
app/(account)/layout.tsx

@@ -0,0 +1,10 @@
+'use client';
+
+import './style.scss';
+import Layout from "@/app/component/Layout";
+
+export default function AccountLayout({ children }: { children: React.ReactNode }) {
+    return (
+		<Layout>{children}</Layout>
+	);
+}

+ 138 - 0
app/(account)/login-log/page.tsx

@@ -0,0 +1,138 @@
+'use client';
+
+import './style.scss';
+import { useState, useEffect } from 'react';
+import { UAParser } from "ua-parser-js";
+import { LoginLogType } from '@/constants/common';
+import { fetchLoginLog } from '@/lib/api/account';
+import type { LoginLog } from '@/types/account/loginLog';
+import Loading from '@/app/component/Loading';
+import Pagination from '@/app/component/Pagination';
+import NavTabs from '../navTabs';
+
+export default function LoginLog()
+{
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(true);
+	const [page, setPage] = useState<number>(1);
+	const [type, setType] = useState<LoginLogType>(LoginLogType.Today);
+	const [total, setTotal] = useState<number>(0);
+	const [logs, setLogs] = useState<LoginLog[]>([]);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+		setLoading(true);
+		fetchLoginLog(type, page).then((res) => {
+			if (!res.ok) {
+				throw new Error(res.message!);
+			}
+
+			setTotal(res.data.total);
+			setLogs(res.data.list);
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+	}, [type, page]);
+
+	useEffect(() => {
+		setPage(1);
+	}, [type]);
+
+	const tabItems = [
+		{ label: "오늘", value: LoginLogType.Today },
+		{ label: "1주일", value: LoginLogType.Week },
+		{ label: "1개월", value: LoginLogType.Month },
+		{ label: "3개월", value: LoginLogType.QuarterYear },
+		{ label: "6개월", value: LoginLogType.HalfYear }
+	];
+
+	return (
+		<>
+		<NavTabs />
+
+		<div id="loginLog" >
+			{ loading && <Loading /> }
+
+			<h1>로그인 기록</h1>
+			<table className="table-auto max-2xl:w-full 2xl:w-[1000px]">
+				<caption>
+					<div className="grid grid-cols-[auto,1fr] gap-4 items-center">
+						<div>
+							합계: {total}
+						</div>
+						<div id="loginTypeTab" className="justify-self-end">
+							{tabItems.map((item, i) => (
+								<button key={i} className={`flex-1 py-2 px-4 text-center text-sm font-medium
+									${type === item.value ? "border-b-2 border-blue-500 text-blue-500" : "text-gray-500"}
+								`} onClick={() => setType(item.value)}>{item.label}</button>
+							))}
+						</div>
+					</div>
+				</caption>
+				<colgroup>
+					<col width="20%"/>
+					<col width="*"/>
+					<col width="15%"/>
+					<col width="15%"/>
+					<col width="15%"/>
+				</colgroup>
+				<thead>
+					<tr>
+						<th>일시</th>
+						<th>접속 IP</th>
+						<th>접속 기기</th>
+						<th>브라우저</th>
+						<th>결과</th>
+					</tr>
+				</thead>
+				<tbody>
+					{logs.length > 0 ? (
+						logs.map((log) => {
+							const parser = new UAParser();
+							parser.setUA(log.userAgent);
+							const browser = `${parser.getBrowser().name ?? '알 수 없음'}`;
+							const device = `${parser.getOS().name ?? '알 수 없음'}`;
+
+							return (
+								<tr key={log.id} className="hover:bg-gray-100">
+									<td>{log.createdAt}</td>
+									<td>{log.ipAddress}</td>
+									<td>{device}</td>
+									<td>{browser}</td>
+									<td className={`border p-2 font-semibold ${
+											log.success ? "text-green-500" : "text-red-500"
+										}`}
+										>
+										{log.success ? "성공" : "실패"}
+									</td>
+								</tr>
+							);
+						})
+						) : (
+							<tr>
+								<td colSpan={5} className="border p-2 text-center text-gray-500">
+									기록이 없습니다.
+								</td>
+							</tr>
+						)}
+				</tbody>
+				<tfoot>
+					<tr>
+						<td colSpan={5}>
+							<Pagination total={total} page={page} perPage={20} onChange={setPage} />
+						</td>
+					</tr>
+				</tfoot>
+			</table>
+		</div>
+		</>
+	);
+}

+ 69 - 0
app/(account)/login-log/style.scss

@@ -0,0 +1,69 @@
+#loginLog {
+	padding: 0 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+
+			#loginTypeTab {
+				button {
+					&:hover {
+						color: #e47911;
+						font-weight: bold;
+						border-bottom: 2px solid #e47911;
+					}
+				}
+			}
+		}
+
+		tr {
+			th, td {
+				font-size: 14px;
+				text-align: center;
+				border: 1px solid #ccc;
+				padding: 6px 13px;
+			}
+		}
+
+		thead {
+			tr {
+				th {
+					background: #f9f9f9;
+					font-weight: bold;
+				}
+			}
+		}
+
+		tbody {
+			tr {
+				td {
+					font-weight: normal;
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+				}
+			}
+		}
+	}
+}

+ 30 - 0
app/(account)/navTabs.tsx

@@ -0,0 +1,30 @@
+'use client';
+
+import './style.scss';
+import React from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+export default function NavTabs() {
+	const pathname = usePathname();
+
+    return (
+		<div id="tabs">
+			{[
+				{ href: "/profile", label: "내 정보" },
+				{ href: "/change-password", label: "비밀번호 변경" },
+				{ href: "/my-posts", label: "작성 게시글" },
+				{ href: "/my-comments", label: "작성 댓글" },
+				{ href: "/exp-logs", label: "경험치 내역" },
+				{ href: "/login-log", label: "로그인 기록" },
+				{ href: "/withdraw", label: "회원탈퇴" },
+				{ href: "/cash", label:"캐시 충전"}
+			].map(({ href, label }, index, array) => (
+				<React.Fragment key={href}>
+					<Link href={href} className={pathname === href ? 'active' : ''}>{label}</Link>
+					{index !== array.length - 1 && <span>&nbsp;</span>}
+				</React.Fragment>
+			))}
+		</div>
+	);
+}

+ 184 - 0
app/(account)/profile/page.tsx

@@ -0,0 +1,184 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import Image from 'next/image';
+import { useState, useEffect } from 'react';
+import useAuth from '@/hooks/useAuth';
+import { formatDate, stripHtmlTags } from '@/lib/utils/client';
+import NavTabs from '../navTabs';
+import Loading from '@/app/component/Loading';
+
+export default function Profile()
+{
+	const { member } = useAuth();
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(true);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+		if (member) {
+			setLoading(false);
+		}
+	}, [member]);
+
+	if (!member) {
+		return <Loading />;
+	}
+
+	return (
+		<>
+		<NavTabs />
+
+		<div id="profile">
+			{ loading && <Loading /> }
+
+			<h1>내 정보</h1>
+			<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
+				<table className="table-auto">
+					<caption>등록 정보</caption>
+					<colgroup>
+						<col style={{width: "100%", minWidth: "80px", maxWidth: "30%"}}/>
+						<col/>
+						<col/>
+					</colgroup>
+					<tbody>
+						<tr>
+							<th>사용자 ID</th>
+							<td colSpan={2}>{member?.sid}</td>
+						</tr>
+						<tr>
+							<th>이메일</th>
+							<td>{member?.email}</td>
+							<td>
+								<Link href="/change-email" className="btn btn-default">이메일 변경</Link>
+							</td>
+						</tr>
+						<tr>
+							<th>별명</th>
+							<td>{member?.name || '-'}</td>
+							<td>
+								<Link href="/change-name" className="btn btn-default">별명 변경</Link>
+							</td>
+						</tr>
+						<tr>
+							<th>본인인증</th>
+							<td>{member?.isAuthCertified ? '인증 완료' : '-'}</td>
+							<td></td>
+						</tr>
+						<tr>
+							<th>회원가입 일시</th>
+							<td colSpan={2}>{formatDate(member.createdAt)}</td>
+						</tr>
+						<tr>
+							<th>마지막 로그인 일시</th>
+							<td colSpan={2}>{formatDate(member.lastLoginAt)}</td>
+						</tr>
+						<tr>
+							<th>알림 수신</th>
+							<td>
+								<div className="flex items-center gap-6">
+									<div className="flex items-center gap-4">
+									<label className="flex items-center gap-2 text-gray-700">
+										<input type="checkbox" checked={member.memberApprove.isReceiveSMS} readOnly disabled className="h-5 w-5 accent-gray-500 cursor-not-allowed" />
+										SMS
+									</label>
+
+									<label className="flex items-center gap-2 text-gray-700">
+										<input type="checkbox" checked={member.memberApprove.isReceiveEmail} readOnly disabled className="h-5 w-5 accent-gray-500 cursor-not-allowed" />
+										메일
+									</label>
+
+									<label className="flex items-center gap-2 text-gray-700">
+										<input type="checkbox" checked={member.memberApprove.isReceiveNote} readOnly disabled className="h-5 w-5 accent-gray-500 cursor-not-allowed" />
+										쪽지
+									</label>
+									</div>
+								</div>
+							</td>
+							<td>
+								<Link href="/change-approve" className="btn btn-default">수신 설정</Link>
+							</td>
+						</tr>
+					</tbody>
+				</table>
+
+				<table className="mt-3 xl:mt-0 table-auto">
+					<caption>활동 정보</caption>
+					<colgroup>
+						<col style={{width: "100%", minWidth: "80px", maxWidth: "30%"}}/>
+						<col width="*"/>
+						<col width="*"/>
+					</colgroup>
+					<tbody>
+						<tr>
+							<th>회원 사진</th>
+							<td>
+								{/* {member?.photo ? <Image src={member?.photo} alt="회원 사진" width={98} height={98} style={{objectFit: "cover"}} unoptimized={true} /> : '-'} */}
+							</td>
+							<td>
+								<Link href="/change-photo" className="btn btn-default">사진 변경</Link>
+							</td>
+						</tr>
+						<tr>
+							<th>회원 등급</th>
+							<td></td>
+							<td></td>
+						</tr>
+						<tr>
+							<th>한마디</th>
+							<td colSpan={2}>
+								<div className="grid grid-cols-[1fr_auto] gap-2 items-center">
+									<span className="truncate">{member?.summary || '-'}</span>
+									<Link href="/change-summary" className="btn btn-default">한마디 변경</Link>
+								</div>
+							</td>
+						</tr>
+						<tr>
+							<th>자기소개</th>
+							<td colSpan={2}>
+								<div className="grid grid-cols-[1fr_auto] gap-2 items-center">
+									<span className="truncate">{stripHtmlTags(member.intro) || '-'}</span>
+									<Link href="/change-intro" className="btn btn-default">자기소개 변경</Link>
+								</div>
+							</td>
+						</tr>
+						<tr>
+							<th>경험치</th>
+							<td colSpan={2}>{member?.exp}</td>
+						</tr>
+						<tr>
+							<th>작성 게시글</th>
+							<td colSpan={2}>{member?.posts}</td>
+						</tr>
+						<tr>
+							<th>작성 댓글</th>
+							<td colSpan={2}>{member?.comments}</td>
+						</tr>
+						<tr>
+							<th>구독자</th>
+							<td colSpan={2}>{member?.following}</td>
+						</tr>
+					</tbody>
+				</table>
+			</div>
+
+			<br />
+
+			<dl>
+				<dt>회원님의 개인정보는 안전하게 보호됩니다.</dt>
+				<dd>※ 의심스러운 활동이 감지되면 즉시 비밀번호를 변경하세요.</dd>
+				<dd>※ 보안을 위해 비밀번호를 주기적으로 변경하는 것이 좋습니다.</dd>
+				<dd>※ 자동 로그인 기능을 사용하면, 로그인 상태가 유지됩니다.</dd>
+				<dd>※ 공용 컴퓨터에서는 자동 로그인을 사용하지 않는 것이 좋습니다.  </dd>
+			</dl>
+		</div>
+		</>
+	);
+}

+ 62 - 0
app/(account)/profile/style.scss

@@ -0,0 +1,62 @@
+#profile {
+	padding: 0 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 10px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 8px 9px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+				}
+
+				td {
+					border-bottom: 1px solid #ccc;
+
+					a {
+						height: min-content;
+						padding: 3px 16px;
+					}
+
+					&:nth-of-type(2) {
+						text-align: right;
+					}
+				}
+			}
+		}
+	}
+
+	dl {
+		dt, dd {
+			font-size: 14px;
+		}
+
+		dt {
+			font-weight: bold;
+			margin-bottom: 7px;
+		}
+
+		dd {
+			margin-bottom: 2px;
+		}
+	}
+}

+ 36 - 0
app/(account)/style.scss

@@ -0,0 +1,36 @@
+#tabs {
+	position: relative;
+	padding: 5px 32px 0 32px;
+	margin-bottom: 24px;
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	justify-content: start;
+
+	a {
+		color: #0D6295;
+		padding: 10px 15px;
+		transition: color 0.3s;
+
+		&:first-child {
+			padding-left: 0;
+		}
+
+		&:hover {
+			color: #e47911;
+			text-decoration: underline;
+		}
+
+		&.active {
+			color: #e47911;
+			font-weight: bold;
+			text-decoration: underline;
+		}
+	}
+
+	span {
+		width: 1px;
+		height: 11px;
+		background-color: #ccc;
+	}
+}

+ 92 - 0
app/(account)/valid-email/page.tsx

@@ -0,0 +1,92 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import { useState, useEffect } from 'react';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { fetchValidEmail } from '@/lib/api/account';
+import Loading from '@/app/component/Loading';
+
+export default function VerifyEmail()
+{
+	const searchParams = useSearchParams();
+	const { member, setMember } = useMemberContext();
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
+	const [isComplete, setComplete] = useState<boolean>(false);
+	const [newEmail, setNewEmail] = useState<string>('');
+	const [token] = useState(searchParams.get('token'));
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+
+		if (!member) {
+			return;
+		}
+
+		setLoading(true);
+
+		if (!token) {
+			setError('잘못된 접근입니다.');
+			setLoading(false);
+			setComplete(false);
+			return;
+		}
+
+		fetchValidEmail(token).then((res) => {
+			if (!res.ok) {
+				throw new Error(res.message!);
+			}
+
+			member.email = res.data;
+			setComplete(true);
+			setMember(member);
+			setNewEmail(member.email);
+			localStorage.setItem('member', JSON.stringify(member));
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+
+	}, [token, member, setMember]);
+
+	if (loading) {
+		return <Loading />;
+	}
+
+	return (
+		<>
+		<div id="verifyEmail">
+			{isComplete ?
+			<>
+				<h1>이메일 변경이 완료되었습니다.</h1>
+				<blockquote>
+					<strong>{newEmail} 주소의 인증이 확인되었습니다. </strong><br />
+					다시 로그인 후 변경된 이메일로 서비스 이용이 가능합니다.<br />
+				</blockquote>
+				<br />
+				<Link href="/profile" className="btn btn-default">확인</Link>
+			</>
+			:
+			<>
+				<h1>이메일 변경이 거부되었습니다.</h1>
+				<blockquote>
+					인증 시간이 만료되었거나, 이미 인증되었을 수 있습니다.<br />
+					처음부터 다시 시도해 주십시오.
+				</blockquote>
+				<br />
+				<Link href="/change-email" className="btn btn-default">다시 시도하기</Link>
+			</>
+			}
+		</div>
+		</>
+	);
+}

+ 12 - 0
app/(account)/valid-email/style.scss

@@ -0,0 +1,12 @@
+#verifyEmail {
+	padding: 25px 32px 0 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	blockquote {
+		line-height: 1.9;
+	}
+}

+ 132 - 0
app/(account)/withdraw/page.tsx

@@ -0,0 +1,132 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useState, useEffect } from 'react';
+import { fetchWithdraw } from '@/lib/api/account';
+import useAuth from '@/hooks/useAuth';
+import Loading from '@/app/component/Loading';
+import NavTabs from '../navTabs';
+
+export default function Withdraw()
+{
+	const { member } = useAuth();
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(true);
+	const [isComplete, setComplete] = useState<boolean>(false);
+	const [agree, setAgree] = useState<boolean>(false);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+        if (member) {
+            setLoading(false);
+        }
+    }, [member]);
+
+	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+		e.preventDefault();
+
+		if (!member) {
+			return;
+		}
+
+		if (agree === false) {
+			alert('탈퇴에 동의해주세요.');
+			return;
+		}
+
+		if (confirm('정말 탈퇴하시겠습니까?')) {
+			setLoading(true);
+
+			fetchWithdraw().then((res) => {
+				if (!res.ok) {
+					throw new Error(res.message!);
+				}
+
+				setComplete(true);
+				localStorage.removeItem('member');
+			}).catch(err => {
+				setError(err.message);
+			}).finally(() => {
+				setLoading(false);
+			});
+		}
+	}
+
+	const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+		setAgree(e.target.checked);
+	};
+
+	useEffect(() => {
+		if (isComplete) {
+			alert('탈퇴가 완료되었습니다.');
+			setComplete(false);
+			location.replace('/');
+		}
+	}, [isComplete]);
+
+	if (!member) {
+        return <Loading />;
+    }
+
+	return (
+		<>
+		<NavTabs />
+
+		<div id="withdraw">
+			{ loading && <Loading /> }
+
+			<h1>회원탈퇴</h1>
+			<form id="fWithdraw" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+				<table className="table-auto max-xl:w-full lg:w-[600px]">
+					<tbody>
+						<tr>
+							<th>
+								<blockquote>
+									사용하고 계신 계정({member.email})은 탈퇴할 경우 재사용 및 복구가 불가능합니다.
+									<ins>탈퇴한 계정은 본인과 타인 모두 재사용 및 복구가 불가하오니 신중하게 선택하시기 바랍니다.</ins>
+									<br/>
+									<br/>
+									추가 회원가입은 탈퇴일로부터 90일 후에 가능합니다.
+									탈퇴 후 회원정보와 주요 서비스 이용기록은 모두 삭제되며, 삭제된 데이터는 복구되지 않습니다.
+									<br/>
+									<br/>
+									삭제되는 내용을 확인하시고 필요한 데이터는 미리 백업을 해주세요.
+									탈퇴 후 게시판, 댓글은은 등록한 게시물은 삭제되지 않고 유지됩니다.
+									<br/>
+									<br/>
+									탈퇴하기 전에 이메일 인증이 필요합니다.
+								</blockquote>
+							</th>
+						</tr>
+						<tr>
+							<td>
+								<label>
+									<input type="checkbox" name="agree" onChange={handleChange} required />
+									위 내용으로 탈퇴에 동의합니다.
+								</label>
+							</td>
+						</tr>
+					</tbody>
+					<tfoot>
+						<tr>
+							<td>
+								<div className="flex justify-center gap-2">
+									<button type="submit" className="btn btn-submit">확인</button>
+									<Link href="/profile" className="btn btn-default">취소</Link>
+								</div>
+							</td>
+						</tr>
+					</tfoot>
+				</table>
+			</form>
+		</div>
+	</>
+	);
+}

+ 67 - 0
app/(account)/withdraw/style.scss

@@ -0,0 +1,67 @@
+#withdraw {
+	padding: 0 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	table {
+		border: 1px solid #ccc;
+
+		caption {
+			text-align: left;
+			font-size: 16px;
+			margin-bottom: 10px;
+		}
+
+		tbody {
+			tr {
+				th, td {
+					text-align: left;
+					font-size: 14px;
+					font-weight: normal;
+					padding: 12px 13px;
+				}
+
+				th {
+					background: #f9f9f9;
+					border: 1px solid #ccc;
+
+					hr {
+						margin: 10px 0;
+					}
+				}
+
+				td {
+					text-align: center;
+					border-bottom: 1px solid #ccc;
+
+					input[type="checkbox"] {
+						transform: scale(1.3);
+						margin-right: 8px;
+					}
+
+					label {
+						cursor: pointer;
+					}
+				}
+			}
+		}
+
+		tfoot {
+			background: #fbfbfb;
+
+			tr {
+				td {
+					text-align: center;
+					padding: 8px;
+
+					button, a {
+						padding: 6px 20px;
+					}
+				}
+			}
+		}
+	}
+}

+ 218 - 0
app/(auth)/approval/page.tsx

@@ -0,0 +1,218 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useState, useEffect, useRef } from 'react';
+import { VerificationType } from '@/constants/common';
+import { fetchVerifyEmail, fetchResendEmail } from '@/lib/api/auth';
+import { throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
+
+export default function Approval()
+{
+	const router = useRouter();
+	const [loading, setLoading] = useState<boolean>(false);
+    const [error, setError] = useState<string>('');
+	const [verifyCode, setVerifyCode] = useState<string>('');
+    const verifyCodeRef = useRef<HTMLInputElement>(null);
+
+    const type: string|null = sessionStorage.getItem('type');
+    const email: string = (sessionStorage.getItem('email') || '');
+    const expiration: string|null = sessionStorage.getItem('expiration');
+    const callbackURL: string = (sessionStorage.getItem('callbackURL') || '/');
+
+    // 인증번호 유지 시간 조회
+    const getResendApprovalSecond = (): number => {
+        let remainResendApprovalSecond: string | null = sessionStorage.getItem('remainResendApprovalSecond');
+        if (!remainResendApprovalSecond) {
+            remainResendApprovalSecond = '120'; // 2분 후 만료
+            sessionStorage.setItem('remainResendApprovalSecond', remainResendApprovalSecond);
+        }
+        return Number(remainResendApprovalSecond);
+    };
+
+    const [resendApprovalSecond, setResendApprovalSecond] = useState(
+        getResendApprovalSecond()
+    );
+
+    // 다시 보내기 여부
+    const [canResend, setCanResend] = useState(false);
+
+    useEffect(() => {
+        if (error) {
+			alert(error);
+			setError('');
+        }
+    }, [error]);
+
+    // 페이지 벗어날 때 세션 정리
+    useEffect(() => {
+		if (!email || email == '') {
+			alert('잘못된 접근입니다.');
+		    location.replace('/');
+			return;
+		}
+
+        // 인증 시간 만료 확인
+        if (type == null || type == "") {
+            alert('다시 시도해주세요.');
+			router.push('/');
+            return;
+        }
+
+        if (!expiration || Date.now() > Number(expiration)) {
+            alert('인증 시간이 만료되었습니다. 다시 시도해주세요.');
+            router.push(callbackURL);
+            return;
+        }
+
+        const handleUnload = (e: BeforeUnloadEvent) => {
+            const isReload = (e.type === 'beforeunload' || (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming)?.type === 'reload' || performance.navigation.type === 1);
+            if (isReload) {
+                e.preventDefault();
+                return;
+            }
+
+            sessionStorage.clear();
+        }
+        window.addEventListener('beforeunload', handleUnload);
+
+        return () => window.removeEventListener('beforeunload', handleUnload);
+    }, [expiration, router]);
+
+    // 다시 보내기 타이머 설정
+    useEffect(() => {
+        if (resendApprovalSecond <= 0) {
+            setCanResend(true);
+            sessionStorage.setItem('expiration', '0');
+            sessionStorage.removeItem('remainResendApprovalSecond');
+            return;
+        }
+
+        const timer = setInterval(() => {
+            setResendApprovalSecond((prev) => {
+                if (prev <= 1) {
+                    clearInterval(timer);
+                    setCanResend(true);
+                    return 0;
+                }
+                return prev - 1;
+            });
+        }, 1000);
+
+        sessionStorage.setItem('remainResendApprovalSecond', resendApprovalSecond.toString());
+
+        return () => clearInterval(timer);
+    }, [resendApprovalSecond]);
+
+    // 인증번호 검증
+    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+        e.preventDefault();
+        setLoading(true);
+
+        try {
+
+            if (!verifyCode) {
+                verifyCodeRef.current?.focus();
+                throw new Error('인증번호를 입력하세요.');
+            }
+
+            await new Promise(resolve => setTimeout(resolve, 500));
+
+			const res = await fetchVerifyEmail({
+				Email: email,
+				Code: verifyCode,
+				Type: Number(type)
+			});
+
+            if (!res.ok) {
+                throwError(res);
+            }
+
+        	sessionStorage.setItem('email', email);
+
+			switch (type) {
+				// 회원가입 완료
+				case VerificationType.Registration.toString():
+					router.push('/welcome');
+					break;
+
+				// 비밀번호 변경 페이지 이동
+				case VerificationType.ForgotPassword.toString():
+					router.push('/reset-password');
+					break;
+			}
+
+        } catch (err: any) {
+            setError(err.message);
+        } finally {
+            setLoading(false);
+        }
+    };
+
+    // 인증번호 다시 보내기
+    const handleResend = async () => {
+        try {
+
+			await fetchResendEmail({
+				Email: email,
+				Type: Number(type)
+			});
+
+            setCanResend(false);
+            setResendApprovalSecond(getResendApprovalSecond());
+            setVerifyCode("");
+        } catch (err: any) {
+            setError(err.message);
+        }
+    };
+
+    return (
+		<>
+			{loading && <Loading />}
+
+			<div id="approvalForm" className="row-start-2 flex flex-row flex-wrap gap-2">
+				<fieldset className="grow">
+					<legend>인증번호 확인</legend>
+
+					<form id="fApproval" method="post" acceptCharset="utf-8" autoComplete="off" className="grid p-4" onSubmit={handleSubmit}>
+						<p>보안을 위해 인증번호를 발송했습니다. <br />수신된 인증번호를 입력해주세요.</p>
+						<br />
+
+						<label htmlFor="email">수신 이메일</label>
+						<input type="email" name="email" id="email" value={email} disabled />
+
+						<div className="flex flex-row flex-1">
+							<label htmlFor="verifyCode" className="flex-auto">인증번호</label>
+							<label className="text-right">
+								{canResend ? (
+									<button type="button" className="text-blue-600 no-underline hover:underline" onClick={handleResend}>
+										다시 받기
+									</button>
+								) : (
+									<><span className="text-green-700">유효시간:(<em>{resendApprovalSecond}</em>초)</span></>
+								)}
+							</label>
+						</div>
+						<input type="number" name="verify_code" id="verifyCode" ref={verifyCodeRef} minLength={6} maxLength={10} onChange={e => setVerifyCode(e.target.value)} autoComplete="off" />
+
+						<div className="grid grid-cols-2 gap-2">
+							<button type="submit" className="btn btn-submit" disabled={loading}>
+								{loading ? "확인 중..." : "확인"}
+							</button>
+							<button type="button" className="btn btn-default" onClick={() => router.push(callbackURL)}>취소</button>
+						</div>
+
+						<hr />
+
+						<dl>
+							<dt>도움이 필요하신가요?</dt>
+							<dd>인증번호를 받을 수 없거나 오류가 발생한 경우 <Link href="/support/qna">운영자에게 문의</Link>하세요.</dd>
+						</dl>
+					</form>
+				</fieldset>
+			</div>
+		</>
+    );
+}

+ 93 - 0
app/(auth)/approval/style.scss

@@ -0,0 +1,93 @@
+#approvalForm {
+    width: 100%;
+
+    fieldset {
+        position: relative;
+        border: 1px solid #cecece;
+        border-radius: 3px;
+        padding: 20px 26px;
+        align-content: center;
+        max-width: 430px;
+        margin: 0 auto;
+
+        > legend {
+            position: absolute;
+            top: -0.625rem;
+            left: 1.25rem;
+            padding: 0 5px;
+            background: white;
+            font-size: 1rem;
+            font-weight: bold;
+        }
+
+        // 로그인 창
+        > form {
+            label {
+                cursor: pointer;
+                margin-bottom: 7px;
+            }
+
+            span em {
+                font-style: normal;
+            }
+
+            // 수신된 이메일
+            input[type="email"] {
+                margin-bottom: 15px;
+            }
+
+            // 인증번호
+            input[type="number"] {
+                letter-spacing: 4px;
+                -moz-appearance: textfield;
+
+                &::-webkit-inner-spin-button,
+                &::-webkit-outer-spin-button {
+                    -webkit-appearance: none;
+                    margin: 0;
+                }
+
+				margin-bottom: 10px;
+            }
+
+            hr {
+                margin: 0.938rem 0;
+            }
+
+            // 우측 안내문구
+            dl {
+                dt {
+                    font-weight: bold;
+                }
+
+                dd {
+                    font-size: 0.938rem;
+
+                    a {
+                        display: inline-block;
+                        padding: 0.438rem 0;
+                        color: #0060a9;
+                        text-decoration: none;
+
+                        &:hover {
+                            text-decoration: underline;
+                            color: #c7511f;
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@media (max-width: 640px) {
+    #approvalForm {
+        fieldset {
+            width: 100%;
+
+            > form {
+                padding: 1rem 0;
+            }
+        }
+    }
+}

+ 92 - 0
app/(auth)/forgot-password/page.tsx

@@ -0,0 +1,92 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useState, useEffect, useRef } from 'react';
+import { ForgotPasswordRequest } from '@/dtos/request/auth';
+import { fetchForgotPassword } from '@/lib/api/auth';
+import { throwError } from '@/lib/utils/client';
+import { VerificationType } from '@/constants/common';
+import Loading from '@/app/component/Loading';
+
+export default function ForgotPassword()
+{
+    const router = useRouter();
+
+    const [loading, setLoading] = useState<boolean>(false);
+    const [error, setError] = useState<string>('');
+    const [email, setEmail] = useState<string>('');
+	const emailRef = useRef<HTMLInputElement>(null);
+
+    useEffect(() => {
+        if (error) {
+            alert(error);
+			setError('');
+        }
+    }, [error]);
+
+    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+        e.preventDefault();
+
+        try {
+
+			setLoading(true);
+        	setError('');
+
+            if (!email) {
+				emailRef.current?.focus();
+                throw new Error('이메일을 입력해주세요.');
+            }
+
+            await new Promise(resolve => setTimeout(resolve, 500));
+
+            const res = await fetchForgotPassword({
+				Email: email
+			} as ForgotPasswordRequest);
+
+			if (!res.ok) {
+				throwError(res);
+			}
+
+			// 시간 제한 생성
+			const expiration: string = (Date.now() + 10 * 60 * 1000).toString();
+			const callbackURL: string = location.pathname;
+			sessionStorage.setItem("type", VerificationType.ForgotPassword.toString());
+			sessionStorage.setItem("expiration", expiration);
+			sessionStorage.setItem("callbackURL", callbackURL);
+			sessionStorage.setItem("email", email);
+			router.push("/approval");
+
+        } catch (err: any) {
+            setError(err.message);
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+		<>
+			{loading && <Loading />}
+
+			<div id="forgotPasswordForm" className="row-start-2">
+				<fieldset>
+					<legend>비밀번호 재설정</legend>
+					<form method="post" acceptCharset="utf-8" autoComplete="off" className="grid pt-4 pl-4 pr-4 pb-1" onSubmit={handleSubmit}>
+						<p>{process.env.SITE_NAME} 계정과 연결된 이메일 주소를 입력해주세요.</p>
+						<p>해당 이메일로 인증번호가 발송되며 아래 입력란에 인증번호를 확인하면 비밀번호 재설정이 가능합니다.</p>
+
+						<br />
+						<label htmlFor="email">이메일</label>
+						<input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" />
+						<button type="submit" className="btn btn-submit" disabled={loading}>
+							{loading ? "조회 중..." : "다음 단계로"}
+						</button>
+						<hr />
+						<Link href="/login">취소하기</Link>
+					</form>
+				</fieldset>
+			</div>
+		</>
+    );
+}

+ 57 - 0
app/(auth)/forgot-password/style.scss

@@ -0,0 +1,57 @@
+#forgotPasswordForm {
+    width: 100%;
+
+    fieldset {
+        position: relative;
+        border: 1px solid #cecece;
+        border-radius: 3px;
+        padding: 20px 26px;
+        align-content: center;
+        max-width: 460px;
+        margin: 0 auto;
+
+        > legend {
+            position: absolute;
+            top: -0.625rem;
+            left: 1.25rem;
+            padding: 0 5px;
+            background: white;
+            font-size: 1rem;
+            font-weight: bold;
+        }
+
+        // 이메일 입력 창
+        form {
+
+            // 상단 안내문구
+            p {
+                font-size: 0.938rem;
+            }
+
+            label {
+                cursor: pointer;
+                margin-bottom: 7px;
+            }
+
+            hr {
+                margin: 20px 0;
+            }
+
+			button {
+				margin-top: 10px;
+			}
+
+            a {
+                display: block;
+                color: #0060a9;
+                text-decoration: none;
+                text-align: center;
+
+                &:hover {
+                    text-decoration: underline;
+                    color: #c7511f;
+                }
+            }
+        }
+    }
+}

+ 13 - 0
app/(auth)/layout.tsx

@@ -0,0 +1,13 @@
+import "./style.scss";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+    return (
+        <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 sm:p-20 font-[family-name:var(--font-geist-sans)]">
+            {children}
+            <address>
+                <hr />
+                <small>© 2025 PLAYR. All rights reserved.</small>
+            </address>
+        </div>
+    );
+}

+ 6 - 0
app/(auth)/login/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 15 - 0
app/(auth)/login/page.tsx

@@ -0,0 +1,15 @@
+'use server';
+
+import { redirect } from 'next/navigation';
+import { isAuthenticated } from '@/lib/api/auth';
+import View from './view';
+
+export default async function Page()
+{
+	// 로그인 여부 확인
+	if (await isAuthenticated()) {
+		redirect('/');
+	}
+
+	return <View />;
+}

+ 88 - 0
app/(auth)/login/style.scss

@@ -0,0 +1,88 @@
+#loginForm {
+    fieldset {
+        position: relative;
+        border: 1px solid #cecece;
+        border-radius: 3px;
+        padding: 20px 26px;
+        align-content: center;
+
+        > legend {
+            position: absolute;
+            top: -0.625rem;
+            left: 1.25rem;
+            padding: 0 5px;
+            background: white;
+            font-size: 1rem;
+            font-weight: bold;
+        }
+
+        // 로그인 창
+        > form {
+            label {
+                cursor: pointer;
+                margin-bottom: 7px;
+
+                &:last-child {
+                    margin-bottom: 0;
+                }
+            }
+
+            input[type="email"],
+            input[type="password"] {
+                &:nth-of-type(1) {
+                    margin-bottom: 15px;
+                }
+
+                &:nth-of-type(2) {
+                    letter-spacing: 1px;
+					margin-bottom: 10px;
+                }
+            }
+        }
+
+        // 우측 안내문구
+        dl {
+            dt {
+                font-weight: bold;
+            }
+
+            dd {
+                font-size: 0.938rem;
+
+                a {
+                    display: block;
+                    padding: 0.438rem 0;
+                    color: #0060a9;
+                    text-decoration: none;
+
+                    &:hover {
+                        text-decoration: underline;
+                        color: #c7511f;
+                    }
+                }
+            }
+
+            &:last-child {
+                dd > a {
+                    padding-bottom: 0;
+                }
+            }
+        }
+
+        hr {
+            margin: 0.938rem 0;
+        }
+    }
+}
+
+@media (max-width: 640px) {
+    #loginForm {
+        fieldset {
+            width: 100%;
+
+            > form {
+                padding: 1rem 0;
+            }
+        }
+    }
+}

+ 111 - 0
app/(auth)/login/view.tsx

@@ -0,0 +1,111 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { Checkbox } from '@/components/ui/checkbox';
+import { useState, useEffect, useRef } from 'react';
+import { fetchLogin } from '@/lib/api/auth';
+import { throwError } from '@/lib/utils/client';
+import { LoginRequest } from '@/dtos/request/auth';
+import useAuth from '@/hooks/useAuth';
+
+ export default function View()
+ {
+    const { login } = useAuth();
+    const [error, setError] = useState<string>('');
+    const [loading, setLoading] = useState<boolean>(false);
+    const [email, setEmail] = useState<string>('');
+    const [password, setPassword] = useState<string>('');
+    const [rememberMe, setRememberMe] = useState<boolean>(false);
+    const emailRef = useRef<HTMLInputElement>(null);
+    const passwordRef = useRef<HTMLInputElement>(null);
+
+    useEffect(() => {
+        if (error) {
+            alert(error);
+            setError('');
+        }
+    }, [error]);
+
+    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+        e.preventDefault();
+
+        try {
+
+            if (email.length < 1) {
+                emailRef.current?.focus();
+                throw new Error('이메일을 입력하세요.');
+            }
+
+            if (password.length < 1) {
+                passwordRef.current?.focus();
+                throw new Error('비밀번호를 입력하세요.');
+            }
+
+            const res = await fetchLogin({
+                Email: email,
+                Password: password
+            } as LoginRequest);
+
+            if (!res.ok) {
+                throwError(res);
+            }
+
+            login(rememberMe);
+
+        } catch (err) {
+            if (err instanceof Error) {
+                setError(err.message);
+            }
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+		<>
+			<div id="loginForm" className="row-start-2 flex flex-row flex-wrap gap-2">
+				<fieldset className="grow">
+					<legend>로그인</legend>
+					<form id="fLogin" method="post" acceptCharset="utf-8" autoComplete="off" className="grid p-4" onSubmit={handleSubmit}>
+						<label htmlFor="email">이메일</label>
+						<input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" />
+
+						<label htmlFor="password">비밀번호</label>
+						<input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
+
+						<button type="submit" className="btn btn-submit" disabled={loading}>
+							{loading ? "로그인 중..." : "로그인"}
+						</button>
+
+						<section className="mt-3 text-center">
+							<Checkbox name="remember_me" id="rememberMe" checked={rememberMe} onCheckedChange={(checked) => setRememberMe(checked === true)} />
+							<label htmlFor="rememberMe">로그인 상태 유지</label>
+						</section>
+					</form>
+				</fieldset>
+				<fieldset className="grow basis-1/2">
+					<dl>
+						<dt>아직 회원이 아니신가요?</dt>
+						<dd>회원가입 한번으로 커뮤니티에 참여하세요!</dd>
+						<dd>
+							<Link href="/register">
+								<small>></small> 회원가입
+							</Link>
+						</dd>
+					</dl>
+					<hr />
+					<dl>
+						<dt>비밀번호를 잊으셨나요?</dt>
+						<dd>비밀번호를 깜박했다면 다시 설정할 수 있어요!</dd>
+						<dd>
+							<Link href="/forgot-password">
+								<small>></small> 비밀번호 재설정
+							</Link>
+						</dd>
+					</dl>
+				</fieldset>
+			</div>
+		</>
+    );
+}

+ 6 - 0
app/(auth)/register/loading.tsx

@@ -0,0 +1,6 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading()
+{
+    return <LoadHtml />;
+}

+ 15 - 0
app/(auth)/register/page.tsx

@@ -0,0 +1,15 @@
+'use server';
+
+import { redirect } from 'next/navigation';
+import { isAuthenticated } from '@/lib/api/auth';
+import View from './view';
+
+export default async function Page()
+{
+	// 로그인 여부 확인
+	if (await isAuthenticated()) {
+		redirect('/');
+	}
+
+	return <View />;
+}

+ 108 - 0
app/(auth)/register/style.scss

@@ -0,0 +1,108 @@
+#registForm {
+    width: 100%;
+
+    fieldset {
+        position: relative;
+        border: 1px solid #cecece;
+        border-radius: 3px;
+        padding: 20px 26px;
+        align-content: center;
+        max-width: 800px;
+        margin: 0 auto;
+
+        > legend {
+            position: absolute;
+            top: -0.625rem;
+            left: 1.25rem;
+            padding: 0 5px;
+            background: white;
+            font-size: 1rem;
+            font-weight: bold;
+        }
+
+        // 좌측 안내문구
+        dl {
+            dt {
+                font-weight: bold;
+                padding-bottom: 5px;
+            }
+
+            dd {
+                font-size: 0.938rem;
+            }
+        }
+
+        // 회원가입 창
+        form {
+            label {
+                cursor: pointer;
+                margin-bottom: 7px;
+            }
+
+            input[type="email"],
+            input[type="password"] {
+                margin-bottom: 15px;
+            }
+
+            input[type="password"] {
+                letter-spacing: 1px;
+
+                &:last-child {
+                    margin-bottom: 0;
+                }
+            }
+
+            p {
+                padding-bottom: 5px;
+            }
+
+            section {
+                &:nth-of-type(2n+1) {
+                    flex-basis: 57%;
+                }
+
+                &:nth-of-type(2n) {
+                    flex-basis: 43%;
+
+                    label {
+                        small {
+                            padding-right: 5px;
+                        }
+
+                        button {
+                            color: #0060a9;
+
+                            &:hover {
+                                text-decoration: underline;
+                                color: #c7511f;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        hr {
+            margin: 20px 0;
+        }
+    }
+}
+
+@media (max-width: 660px) {
+    #registForm {
+        fieldset {
+            form {
+                section {
+
+                    &:nth-of-type(2n+1) {
+                        flex-basis: 100%;
+                    }
+    
+                    &:nth-of-type(2n) {
+                        flex-basis: 100%;
+                    }
+                }
+            }
+        }
+    }
+}

+ 183 - 0
app/(auth)/register/view.tsx

@@ -0,0 +1,183 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useState, useEffect, useRef } from 'react';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogTrigger } from '@/components/ui/dialog';
+import TermsDialog from '@/app/component/TermsDialog';
+import { VerificationType } from '@/constants/common';
+import { fetchRegister } from '@/lib/api/auth';
+import { throwError } from '@/lib/utils/client';
+import { RegisterRequest } from '@/dtos/request/auth';
+
+export default function View()
+{
+    const router = useRouter();
+    const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
+    const [email, setEmail] = useState<string>('');
+    const [password, setPassword] = useState<string>('');
+    const [rePassword, setRePassword] = useState<string>('');
+    const [agree1, setAgree1] = useState<boolean>(false);
+    const [agree2, setAgree2] = useState<boolean>(false);
+	const emailRef = useRef<HTMLInputElement>(null);
+	const passwordRef = useRef<HTMLInputElement>(null);
+	const rePasswordRef = useRef<HTMLInputElement>(null);
+
+    useEffect(() => {
+        if (error) {
+            alert(error);
+			setError('');
+        }
+    }, [error]);
+
+    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+        e.preventDefault();
+
+        try {
+
+            if (email.length < 1) {
+				emailRef.current?.focus();
+                throw new Error('이메일을 입력해주세요.');
+            }
+
+			if (password.length < 1) {
+				passwordRef.current?.focus();
+                throw new Error('비밀번호를 입력해주세요.');
+            }
+
+			if (rePassword.length < 1) {
+				rePasswordRef.current?.focus();
+                throw new Error('비밀번호 확인을 입력해주세요.');
+            }
+
+			if (password !== rePassword) {
+                throw new Error('비밀번호가 서로 일치하지 않습니다.');
+            }
+
+            if (!agree1 || !agree2) {
+                throw new Error('모든 필수 약관에 동의해주세요.');
+            }
+
+            await new Promise(resolve => setTimeout(resolve, 500));
+
+            const res = await fetchRegister({
+				Email : email,
+                Password : password,
+                IsPolicyAgree : agree1,
+                IsPrivacyAgree : agree2
+			} as RegisterRequest);
+
+			if (!res.ok) {
+				throwError(res);
+			}
+
+			// 시간 제한 생성
+			const expiration: string = (Date.now() + 10 * 60 * 1000).toString();
+			const callbackURL: string = location.pathname;
+
+			sessionStorage.setItem('type', VerificationType.Registration.toString());
+			sessionStorage.setItem('expiration', expiration);
+			sessionStorage.setItem('callbackURL', callbackURL);
+			sessionStorage.setItem('email', email);
+
+			if (res.data.isRegisterEmailAuth) {
+				// 이메일 인증 필요
+				router.push('/approval');
+			} else {
+				// 회원가입 완료
+				router.push('/welcome');
+			}
+
+        } catch (err) {
+            if (err instanceof Error) {
+                setError(err.message);
+            }
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+		<>
+			<div id="registForm" className="row-start-2">
+				<fieldset>
+					<legend>회원가입</legend>
+					<form id="fRegist" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+						<div className="flex flex-row flex-wrap">
+							<section className="grow sm:pt-4 sm:pb-4">
+								<dl>
+									<dt hidden>&nbsp;</dt>
+									<dd>{ process.env.NEXT_PUBLIC_SITE_NAME } 에 오신 것을 환영합니다.</dd>
+									<dd>빠르고 간단하게 회원가입을 진행하세요.</dd>
+								</dl>
+								<br/>
+								<dl>
+									<dt hidden>&nbsp;</dt>
+									<dd>가입 확인을 위해 유효한 이메일을 입력해주세요.</dd>
+									<dd>비밀번호는 최소 8자 이상 입력해주세요.</dd>
+								</dl>
+							</section>
+							<section className="grow sm:pt-4">
+								<article className="grid">
+									<label htmlFor="email">이메일</label>
+									<input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" />
+
+									<label htmlFor="password">비밀번호</label>
+									<input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
+
+									<label htmlFor="rePassword">비밀번호 확인</label>
+									<input type="password" name="re_password" id="rePassword" ref={rePasswordRef} maxLength={20} onChange={e => setRePassword(e.target.value)} />
+								</article>
+							</section>
+						</div>
+
+						<hr />
+
+						<div className="flex flex-row flex-wrap">
+							<section className="grow">
+								<dl>
+									<dt>회원가입 약관</dt>
+									<dd>원활한 서비스 이용을 위해 약관 동의가 필요합니다.</dd>
+									<dd>약관 내용을 자세히 확인하신 후 동의해주세요.</dd>
+								</dl>
+							</section>
+							<section className="grow pt-4 sm:pt-0">
+								<p>
+									<Checkbox name="agree_1" id="agree_1" onCheckedChange={(checked) => setAgree1(Boolean(checked))} />
+									<label htmlFor="agree_1">
+										<small>(필수)</small>
+										<Dialog>
+											<DialogTrigger>이용약관</DialogTrigger>
+											<TermsDialog subject="이용약관" description="" />
+										</Dialog>
+										에 동의합니다.
+									</label>
+								</p>
+								<p>
+									<Checkbox name="agree_2" id="agree_2" onCheckedChange={(checked) => setAgree2(Boolean(checked))} />
+									<label htmlFor="agree_2">
+										<small>(필수)</small>
+										<Dialog>
+											<DialogTrigger>개인정보처리방침</DialogTrigger>
+											<TermsDialog subject="개인정보처리방침" description="" />
+										</Dialog>
+										에 동의합니다.
+									</label>
+								</p>
+								<div className="grid grid-cols-2 gap-2 mt-3">
+									<button type="submit" className="btn btn-submit" disabled={loading}>
+										{loading ? "회원가입 중..." : "회원가입"}
+									</button>
+									<Link href="/login" className="btn btn-default">취소</Link>
+								</div>
+							</section>
+						</div>
+					</form>
+				</fieldset>
+			</div>
+		</>
+    );
+}

+ 122 - 0
app/(auth)/reset-password/page.tsx

@@ -0,0 +1,122 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useState, useEffect, useRef } from 'react';
+import { fetchResetPassword } from '@/lib/api/auth';
+import { throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
+
+export default function ResetPassword()
+{
+	const router = useRouter();
+
+	const [loading, setLoading] = useState<boolean>(false);
+    const [error, setError] = useState<string>('');
+	const [password, setPassword] = useState<string>('');
+    const [rePassword, setRePassword] = useState<string>('');
+    const [email, setEmail] = useState<string|null>(null);
+    const passwordRef = useRef<HTMLInputElement>(null);
+    const rePasswordRef = useRef<HTMLInputElement>(null);
+
+    useEffect(() => {
+        if (error) {
+            alert(error);
+			setError('');
+        }
+    }, [error]);
+
+    useEffect(() => {
+        const storedEmail = sessionStorage.getItem('email');
+
+        if (!storedEmail) {
+            setError('잘못된 접근입니다.');
+            router.push('/login');
+            return;
+        }
+
+        setEmail(storedEmail);
+    }, [router]);
+
+    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+        e.preventDefault();
+
+        try {
+
+            setLoading(true);
+            setError('');
+
+            if (!email) {
+                throw new Error("이메일을 입력하세요.");
+            }
+
+            if (!password) {
+                passwordRef.current?.focus();
+                throw new Error("비밀번호를 입력하세요.");
+            }
+
+            if (!rePassword) {
+                rePasswordRef.current?.focus();
+                throw new Error("비밀번호를 입력하세요.");
+            }
+
+            if (password !== rePassword) {
+                throw new Error("비밀번호가 일치하지 않습니다.");
+            }
+
+            await new Promise(resolve => setTimeout(resolve, 500));
+
+            const res = await fetchResetPassword({
+				Email: email,
+				Password: password,
+				RePassword: rePassword
+			});
+
+			if (!res.ok) {
+				throwError(res);
+			}
+
+			sessionStorage.clear();
+			alert(res.message);
+			router.push('/login');
+
+        } catch (err: any) {
+            setError(err.message);
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+		<>
+		{loading && <Loading />}
+        <div id="resetPasswordForm" className="row-start-2">
+            <fieldset>
+                <legend>비밀번호 변경</legend>
+                <form id="fResetPassword" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+                    <section className="grow sm:pt-4">
+                        <article className="grid">
+                            <label htmlFor="email">이메일</label>
+                            <input type="email" name="email" id="email" value={email || ''} disabled />
+
+                            <label htmlFor="password">비밀번호</label>
+                            <input type="password" name="password" id="password" ref={passwordRef} maxLength={20} value={password} onChange={e => setPassword(e.target.value)} />
+
+                            <label htmlFor="rePassword">비밀번호 확인</label>
+                            <input type="password" name="re_password" id="rePassword" ref={rePasswordRef} maxLength={20} value={rePassword} onChange={e => setRePassword(e.target.value)} />
+                        </article>
+                    </section>
+                    <hr />
+                    <div className="grid grid-cols-2 gap-2">
+                        <button type="submit" className="btn btn-submit" disabled={loading}>
+                            {loading ? "변경 중..." : "변경하기"}
+                        </button>
+                        <Link href="/login" className="btn btn-default">취소</Link>
+                    </div>
+                </form>
+            </fieldset>
+        </div>
+		</>
+    );
+}

+ 63 - 0
app/(auth)/reset-password/style.scss

@@ -0,0 +1,63 @@
+#resetPasswordForm {
+    width: 100%;
+
+    fieldset {
+        position: relative;
+        border: 1px solid #cecece;
+        border-radius: 3px;
+        padding: 20px 26px;
+        align-content: center;
+        max-width: 400px;
+        margin: 0 auto;
+
+        > legend {
+            position: absolute;
+            top: -0.625rem;
+            left: 1.25rem;
+            padding: 0 5px;
+            background: white;
+            font-size: 1rem;
+            font-weight: bold;
+        }
+
+        // 회원가입 창
+        form {
+            label {
+                cursor: pointer;
+                margin-bottom: 7px;
+            }
+
+            input[type="email"],
+            input[type="password"] {
+                margin-bottom: 15px;
+            }
+
+            input[type="password"] {
+                letter-spacing: 1px;
+
+                &:last-child {
+                    margin-bottom: 0;
+                }
+            }
+
+            label {
+                small {
+                    padding-right: 5px;
+                }
+
+                button {
+                    color: #0060a9;
+
+                    &:hover {
+                        text-decoration: underline;
+                        color: #c7511f;
+                    }
+                }
+            }
+        }
+
+        hr {
+            margin: 20px 0;
+        }
+    }
+}

+ 16 - 0
app/(auth)/style.scss

@@ -0,0 +1,16 @@
+html, body {
+    min-width: initial;
+    min-height: initial;
+}
+
+// 하단 저작권
+address {
+    width: 100%;
+    grid-row-start: 4;
+    text-align: center;
+
+    small {
+        display: block;
+        padding-top: 10px;
+    }
+}

+ 50 - 0
app/(auth)/welcome/page.tsx

@@ -0,0 +1,50 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useState, useEffect } from 'react';
+import { Registration } from '@/lib/api/auth';
+
+export default function Welcome()
+{
+	const router = useRouter();
+    const [error, setError] = useState<string>('');
+
+    useEffect(() => {
+        if (error) {
+            alert(error);
+			router.push('/register');
+        }
+    }, [error, router]);
+
+    useEffect(() => {
+        const email: string|null = sessionStorage.getItem('email');
+
+        if (!email) {
+            setError("잘못된 접근입니다.");
+        }
+
+		Registration(email).then().catch(err => {
+			setError(err.message);
+		}).finally(() => {
+            sessionStorage.removeItem('email');
+        });
+    }, []);
+
+    return (
+        <div id="welcomeForm" className="row-start-2 flex flex-row flex-wrap gap-2">
+            <fieldset className="grow">
+                <legend>회원가입을 환영합니다.</legend>
+                <svg xmlns="http://www.w3.org/2000/svg" width="15%" height="15%" fill="currentColor" className="pt-2 pb-3" viewBox="0 0 16 16">
+                    <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0"/>
+                </svg>
+                <p>회원가입 절차가 완료되었습니다.<br/>로그인 후 서비스를 이용하실 수 있습니다.</p>
+                <hr/>
+                <div className="text-center">
+                    <Link href="/login" className="btn btn-submit w-full sm:w-auto">로그인 하기</Link>
+                </div>
+            </fieldset>
+        </div>
+    );
+}

+ 32 - 0
app/(auth)/welcome/style.scss

@@ -0,0 +1,32 @@
+#welcomeForm {
+    width: 100%;
+
+    fieldset {
+        position: relative;
+        border: 1px solid #cecece;
+        border-radius: 3px;
+        padding: 20px 26px;
+        align-content: center;
+        max-width: 500px;
+        margin: 0 auto;
+
+        > legend {
+            position: absolute;
+            top: -0.625rem;
+            left: 1.25rem;
+            padding: 0 5px;
+            background: white;
+            font-size: 1rem;
+            font-weight: bold;
+        }
+
+        > svg {
+            display: block;
+            margin: 0 auto;
+        }
+
+        hr {
+            margin: 20px 0;
+        }
+    }
+}

+ 86 - 0
app/(forum)/board/[code]/page.tsx

@@ -0,0 +1,86 @@
+'use server';
+
+import './style.scss';
+import View from './view';
+import { notFound, forbidden, redirect } from 'next/navigation';
+import { BoardLayout, BoardSort, PostSearchType } from '@/constants/forum';
+import BoardPostsRequest from '@/dtos/request/forum/board/postListRequest';
+import { isAuthenticated } from '@/lib/api/auth';
+import { fetchBoard, fetchPostList } from '@/lib/api/forum/board';
+import { throwError, checkPermission } from '@/lib/utils/server';
+import PermissionDenied from '../_component/PermissionDenied';
+
+type Props = {
+	params: Promise<{
+		code: string;
+	}>;
+	searchParams: Promise<{
+		page: number;
+		perPage: number;
+		prefix?: number;
+		sort?: BoardSort;
+		search: PostSearchType;
+		keyword?: string;
+	}>;
+}
+
+export default async function Board({ params, searchParams }: Props)
+{
+	const { code } = await params;
+
+	if (!code) {
+		return notFound();
+	}
+
+	const query = await searchParams;
+
+	// 게시판 조회
+	const board = await fetchBoard(code);
+
+	if (!board || !board.data) {
+		return notFound();
+	}
+
+	if (!board.data.isActive) {
+		return forbidden();
+	}
+
+	const boardMeta = board.data.boardMeta;
+
+	// 1:1 게시판은 로그인한 사용자만 접근 가능
+	if (boardMeta.list.layout === BoardLayout.QnA && !await isAuthenticated()) {
+		redirect('/login');
+	}
+
+	// 게시판 접근 권한 확인
+	if (!await checkPermission(boardMeta.permission.boardAccess, board.data.boardManager)) {
+		return <PermissionDenied _board={board.data} />;
+	}
+
+	query.page = Math.max(Number(query.page) || 1) as number;
+	query.perPage = Math.max(Number(query.perPage) || (boardMeta.list.perPage || 10)) as number;
+	query.prefix = (Number(query.prefix) || null) as number|undefined;
+	query.sort = (Number(query.sort) || boardMeta.list.sort) as BoardSort|undefined;
+	query.search = (Number(query.search) || PostSearchType.Subject) as PostSearchType;
+	query.keyword = (query.keyword || '') as string|undefined;
+
+	// 게시글 조회
+	const boardPosts = await fetchPostList({
+		boardID: board.data.id as number,
+		boardCode: board.data.code as string,
+		page: query.page as number,
+		perPage: query.perPage as number,
+		boardPrefixID: query.prefix as number|null|undefined,
+		sort: query.sort as BoardSort|null|undefined,
+		search: query.search as PostSearchType,
+		keyword: query.keyword as string|null|undefined
+	} as BoardPostsRequest);
+
+	if (!boardPosts.ok) {
+		throwError(boardPosts);
+	}
+
+	return (
+		<View _query={query} _board={board.data} _postList={boardPosts.data!} />
+	);
+}

+ 89 - 0
app/(forum)/board/[code]/style.scss

@@ -0,0 +1,89 @@
+#board {
+	padding: 25px 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	// 검색 조건
+	.list-header, .list-footer {
+		select, input[type="search"], input[type="radio"] {
+			height: 34px;
+		}
+
+		input[type="radio"] {
+			position: absolute;
+			opacity: 0;
+			pointer-events: none;
+		}
+
+		a, button {
+			line-height: 34px;
+			padding: 0 15px;
+		}
+	}
+
+	// 상단 제어 버튼
+	.list-header {
+		display: grid;
+		grid-template-columns: 1fr auto auto;
+		gap: 7px;
+		align-items: end;
+
+		section:first-child {
+			flex: 1 1 auto;
+			min-width: 0;
+		}
+
+		article {
+			overflow: hidden;       	// 넘치는 애들은 숨김
+			white-space: nowrap;    	// 줄바꿈 방지
+			text-overflow: ellipsis;	// 필요 시 말줄임
+			display: flex;
+			gap: 0.5rem;
+
+			ul {
+				display: flex;
+			  	gap: 0.5rem;
+
+			  	li {
+					flex-shrink: 0;
+
+					label {
+						display: block;
+						line-height: inherit;
+						padding: 0.25rem 0.75rem;
+						background: #f1f1f1;
+						border-radius: 6px;
+						white-space: nowrap;
+						cursor: pointer;
+
+						&:hover, &:focus {
+							color: #c7511f;
+							background: #e1e1e1;
+							text-decoration: underline;
+						}
+
+						&.active {
+							background: #000;
+							color: #f1f1f1;
+						}
+					}
+			  	}
+			}
+		}
+	}
+
+	// 하단 제어 버튼
+	.list-footer {
+		display: grid;
+		grid-template-columns: 1fr auto;
+
+		> section:first-child > form {
+			display: flex;
+			align-items: center;
+			gap: 7px;
+		}
+	}
+}

+ 259 - 0
app/(forum)/board/[code]/view.tsx

@@ -0,0 +1,259 @@
+'use client';
+
+import './style.scss';
+import { usePathname, useSearchParams } from 'next/navigation';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { BoardLayout, BoardSort, PostSearchType } from '@/constants/forum';
+import { fetchPostList } from '@/lib/api/forum/board';
+import Post from '@/types/forum/post';
+import Loading from '@/app/component/Loading';
+import Pagination from '@/app/component/Pagination';
+import PostListRequest from '@/dtos/request/forum/board/postListRequest';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import PostListResponse from '@/dtos/response/forum/board/postListResponse';
+import PostWriteButtonfrom from '../_component/PostWriteButton';
+import HeaderContent from '../_component/HeaderContent';
+import FooterContent from '../_component/FooterContent';
+import DefaultListLayout from '../_component/DefaultListLayout';
+import AlbumListLayout from '../_component/AlbumListLayout';
+import QnAListLayout from '../_component/QnAListLayout';
+
+type ViewProps = {
+	_query: {
+		page: number;
+		perPage: number;
+		prefix?: number;
+		sort?: BoardSort;
+		search: PostSearchType;
+		keyword?: string;
+	},
+	_board: BoardResponse,
+	_postList: PostListResponse
+};
+
+export default function View({ _query, _board, _postList }: ViewProps)
+{
+	const pathname = usePathname();
+	const searchParams = useSearchParams();
+
+	const [error, setError] = useState<string|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+	const [total, setTotal] = useState<number>(_postList.total);
+	const [speaker, setSpeaker] = useState<Post[]>(_postList.speaker);
+	const [notice, setNotice] = useState<Post[]>(_postList.notice);
+	const [list, setList] = useState<Post[]>(_postList.list);
+	const [page, setPage] = useState<number>(_query.page);
+	const [perPage, setPerPage] = useState<number>(_query.perPage);
+	const [startIndex, setStartIndex] = useState<number>(0);
+	const [boardPrefixID, setBoardPrefixID] = useState<number|undefined>(_query.prefix);
+	const [sort, setSort] = useState<BoardSort|undefined>(_query.sort);
+	const [search, setSearch] = useState<PostSearchType>(_query.search);
+	const [keyword, setKeyword] = useState<string|undefined>(_query.keyword);
+	const [params, setParams] = useState<Record<string, string>>({});
+	const isMounted = useRef(false);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError(null);
+		}
+	}, [error]);
+
+	// 상태 => URL 동기화
+	useEffect(() => {
+		// 기존 URL 파라미터
+		const alreadyParams = new URLSearchParams(searchParams.toString());
+
+		// URL 파라미터 덮어쓰기 및 삭제
+		Object.entries(params).forEach(([k, v]) => {
+			if (v) {
+				alreadyParams.set(k, v);
+			} else {
+				alreadyParams.delete(k);
+			}
+		});
+
+		const queryString = `?${alreadyParams.toString()}`;
+		if (window.location.search !== queryString) {
+			window.history.replaceState(null, '', `${pathname}${queryString}`);
+		}
+
+	}, [page, perPage, boardPrefixID, sort, search, keyword]);
+
+	const handleFetchPosts = useCallback(async () => {
+		try {
+			setLoading(true);
+
+			const res = await fetchPostList({
+				boardID: _board.id,
+				boardCode: _board.code,
+				boardPrefixID: boardPrefixID,
+				page,
+				perPage,
+				sort,
+				search,
+				keyword
+			} as PostListRequest);
+
+			if (!res.data) {
+				setError('게시글을 불러올 수 없습니다.');
+			} else {
+				setTotal(res.data.total);
+				setSpeaker(res.data.speaker);
+				setNotice(res.data.notice);
+				setList(res.data.list);
+			}
+		} catch (err: any) {
+			setError(err.message || '알 수 없는 오류가 발생했습니다.');
+		} finally {
+			setLoading(false);
+		}
+	}, [_board.id, _board.code, boardPrefixID, page, perPage, sort, search, keyword]);
+
+	const handleChange = (e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement>) => {
+		const { name, value } = e.target;
+
+		let key = '';
+		switch (name) {
+			case 'boardPrefixID':
+				setBoardPrefixID((Number(value) || undefined) as number);
+				key = 'prefix';
+				break;
+			case 'sort':
+				setSort(Number(value) as BoardSort);
+				break;
+			case 'perPage':
+				setPerPage(Number(value));
+				break;
+			case 'search':
+				setSearch(Number(value) as PostSearchType);
+				break;
+			case 'keyword':
+				setKeyword(value);
+				break;
+		}
+
+		if (['sort', 'perPage', 'search', 'keyword'].includes(name)) {
+			key = name;
+		}
+
+		if (['boardPrefixID', 'perPage', 'search', 'keyword'].includes(name)) {
+			handlePageChange(1);
+		}
+
+		setParams((prev) => ({ ...prev, [key]: value }));
+	};
+
+	const handleSearch = async (e: React.FormEvent) => {
+		e.preventDefault();
+		handleFetchPosts();
+	};
+
+	const handlePageChange = (page: number) => {
+		setPage(page);
+		setParams((prev) => ({ ...prev, page: String(page) }));
+	};
+
+	useEffect(() => {
+		if (!isMounted.current) {
+			isMounted.current = true;
+			return;
+		}
+
+		handleFetchPosts();
+	}, [page, perPage, boardPrefixID, sort]);
+
+	useEffect(() => {
+		setStartIndex(total - ((page - 1) * perPage));
+	}, [total, list, page, perPage]);
+
+	return (
+		<div id='board'>
+			{loading && <Loading />}
+
+			<HeaderContent isEnabled={_board.boardMeta.list.showHeader} content={_board.boardMeta.list.headerContent } />
+
+			<div className='list-header'>
+				{/* 말머리 */}
+				<section aria-label='말머리 선택'>
+					<h1>{ _board.name }</h1>
+					<article>
+						<ul>
+							<li>
+								<label {...(!boardPrefixID ? { className: 'active' } : {})}>
+									<input type='radio' name='boardPrefixID' value='' checked={boardPrefixID === null} onChange={handleChange} /> 전체
+								</label>
+							</li>
+							{_board.boardPrefix.map((row, i) => (
+								<li key={i}>
+									<label {...(boardPrefixID === row.id ? { className: 'active' } : {})}
+										style={row.color && row.color != '#000000' ? { background: row.color, color: '#f1f1f1' } : undefined}
+									>
+										<input type='radio' name='boardPrefixID' value={row.id} checked={boardPrefixID === row.id} onChange={handleChange}/>
+										{row.name}
+									</label>
+								</li>
+							))}
+						</ul>
+					</article>
+				</section>
+
+				{/* 정렬 */}
+				<section aria-label='게시글 정렬'>
+					<select name='sort' value={sort ?? ''} title='게시글 정렬' onChange={handleChange}>
+						<option value={BoardSort.CreatedAt}>최신순</option>
+						<option value={BoardSort.Views}>조회순</option>
+						<option value={BoardSort.Comments}>댓글순</option>
+						<option value={BoardSort.Likes}>공감순</option>
+					</select>
+				</section>
+
+				{/* 출력 수 */}
+				<section aria-label='게시글 출력 수'>
+					<select name='perPage' value={perPage} title='출력 수' onChange={handleChange}>
+						<option value='10'>10개씩</option>
+						<option value='20'>20개씩</option>
+						<option value='30'>30개씩</option>
+						<option value='50'>50개씩</option>
+						<option value='100'>100개씩</option>
+					</select>
+				</section>
+			</div>
+
+			{/* 게시글 목록 */}
+			{(() => {
+				switch (_board.boardMeta.list.layout) {
+					case BoardLayout.Media:
+						return <AlbumListLayout boardListMeta={_board.boardMeta.list} speaker={speaker} notice={notice} list={list} startIndex={startIndex} onChange={setBoardPrefixID} />;
+					case BoardLayout.QnA:
+						return <QnAListLayout boardListMeta={_board.boardMeta.list} notice={notice} list={list} startIndex={startIndex} onChange={setBoardPrefixID} />;
+					default:
+						return <DefaultListLayout boardListMeta={_board.boardMeta.list} speaker={speaker} notice={notice} list={list} startIndex={startIndex} onChange={setBoardPrefixID} />;
+				}
+            })()}
+
+			{/* 검색 */}
+			<div className='list-footer'>
+				<section aria-label='게시글 검색'>
+					<form onSubmit={handleSearch} autoComplete='off'>
+						<select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange}>
+							<option value={PostSearchType.Subject}>제목</option>
+							<option value={PostSearchType.Content}>내용</option>
+							<option value={PostSearchType.Author}>작성자</option>
+							<option value={PostSearchType.Comment}>댓글</option>
+						</select>
+						<input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} />
+						<button type='submit' className='btn btn-default'>검색</button>
+					</form>
+				</section>
+
+				{/* 글쓰기 버튼 */}
+				<PostWriteButtonfrom alwaysShowButton={_board.boardMeta.list.alwaysShowWriteButton} boardCode={_board.code} />
+			</div>
+
+			<Pagination total={total} page={page} perPage={perPage} onChange={handlePageChange} />
+
+			<FooterContent isEnabled={_board.boardMeta.list.showFooter} content={_board.boardMeta.list.footerContent } />
+		</div>
+	);
+}

+ 132 - 0
app/(forum)/board/_component/AlbumListLayout.tsx

@@ -0,0 +1,132 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import Image, { ImageProps } from 'next/image';
+import { useState } from 'react';
+import Post from '@/types/forum/post';
+import { BoardListMeta } from '@/types/forum/boardMeta';
+import { formatDate, isHotPost, isNewPost } from '@/lib/utils/client';
+import { faThumbsUp, faEye, faClock } from '@fortawesome/free-regular-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import NoticeListLayout from './NoticeListLayout';
+
+interface Props {
+	boardListMeta: BoardListMeta;
+	speaker: Post[];
+	notice: Post[];
+	list: Post[];
+	startIndex: number;
+	onChange: (_: number|undefined) => void;
+}
+
+function ImageWithFallback({
+    src,
+    fallbackSrc = '/resources/no-image.png',
+    alt,
+    ...option
+}: ImageProps & { fallbackSrc?: string }) {
+    const [imgSrc, setImgSrc] = useState(src);
+
+    return (
+        <Image
+            {...option}
+            src={imgSrc}
+            alt={alt}
+            onError={() => setImgSrc(fallbackSrc)}
+        />
+    );
+}
+
+export default function AlbumListLayout({boardListMeta, speaker, notice, list, onChange}: Props)
+{
+	const searchParams = useSearchParams();
+
+	return (
+		<>
+			{!boardListMeta.exceptSpeaker && !boardListMeta.exceptNotice ? (
+
+				<section className='notice-list-layout' aria-label='공지사항'>
+					<article>
+						<ul>
+							<li>번호</li>
+							<li>제목</li>
+							<li>작성자</li>
+							<li>작성일</li>
+							<li>조회수</li>
+						</ul>
+					</article>
+					<article>
+						{/* 전체 공지 */}
+						<NoticeListLayout isEnabled={!boardListMeta.exceptSpeaker} list={speaker} layout={boardListMeta.layout} />
+
+						{/* 일반 공지 */}
+						<NoticeListLayout isEnabled={!boardListMeta.exceptNotice} list={notice} layout={boardListMeta.layout} />
+					</article>
+				</section>
+
+			) : (
+				<br hidden/>
+			)}
+
+			<div className={list.length > 0 ? 'album-list-layout' : 'grid p-10 text-center border-b mb-3'} aria-label='사진/영산 게시판'>
+				{list.length > 0 ? (
+					list.map(row => {
+						const query = Object.fromEntries(searchParams.entries());
+						const href = `/post/${row.id}${window.location.search}`;
+						const isNew = isNewPost(boardListMeta.isNewIcon, row);
+						const isHot = isHotPost(boardListMeta.isHotIcon, row);
+						const createdAt = formatDate(row.createdAt);
+
+						return (
+							<div key={row.id}>
+								<figure>
+									<article>
+										<Link href={href}>
+											{row.thumbnail ? (
+												<ImageWithFallback src={row.thumbnail} alt={row.subject} fill style={{ objectFit: 'cover' }} loading="lazy" />
+											) : (
+												<Image src='/resources/no-image.png' alt={row.subject} loading='lazy' fill />
+											)}
+										</Link>
+									</article>
+									<dl>
+										<dt>
+											{row.boardPrefix && row.boardPrefixID && (
+												<button type="button" onClick={() => onChange(row.boardPrefixID || undefined)}>
+													[{row.boardPrefix.name}]
+												</button>
+											)}
+											<Link href={{
+													pathname: '/post/' + row.id,
+													query: {
+														...query
+													}
+												}}
+											>
+												<em>{row.subject} {row.comments > 0 && (<span>[{row.comments}]</span>)}</em>
+												{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+												{isHot && <span><img src='/resources/hot.gif' alt='HOT'/></span>}
+											</Link>
+										</dt>
+										<dd>{row.name}</dd>
+									</dl>
+									<figcaption>
+										<ul>
+											<li><FontAwesomeIcon icon={faThumbsUp} /> {row.likes}</li>
+											<li><FontAwesomeIcon icon={faEye} /> {row.views}</li>
+											<li><FontAwesomeIcon icon={faClock} /> {createdAt}</li>
+										</ul>
+									</figcaption>
+								</figure>
+							</div>
+						);
+					})
+				) : (
+					<p>등록된 게시글이 없습니다.</p>
+				)}
+			</div>
+		</>
+	);
+}

+ 133 - 0
app/(forum)/board/_component/DefaultListLayout.tsx

@@ -0,0 +1,133 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import Post from '@/types/forum/post';
+import { BoardListMeta } from '@/types/forum/boardMeta';
+import { formatDate, isHotPost, isNewPost } from '@/lib/utils/client';
+import NoticeListLayout from './NoticeListLayout';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faComment, faThumbsUp, faEye } from '@fortawesome/free-regular-svg-icons';
+
+interface Props {
+	boardListMeta: BoardListMeta;
+	speaker: Post[];
+	notice: Post[];
+	list: Post[];
+	startIndex: number;
+	onChange: (_: number|undefined) => void;
+}
+
+export default function DefaultListLayout({boardListMeta, speaker, notice, list, startIndex, onChange}: Props)
+{
+	const searchParams = useSearchParams();
+
+	return (
+		<>
+			<section className="default-list-layout" aria-label='일반 게시판'>
+				<article>
+					<ul>
+						<li>번호</li>
+						<li>제목</li>
+						<li>작성자</li>
+						<li>작성일</li>
+						<li>조회수</li>
+						<li>좋아요</li>
+					</ul>
+				</article>
+				<article>
+					{/* 전체 공지 */}
+					<NoticeListLayout isEnabled={!boardListMeta.exceptSpeaker} list={speaker} layout={boardListMeta.layout} />
+
+					{/* 일반 공지 */}
+					<NoticeListLayout isEnabled={!boardListMeta.exceptNotice} list={notice} layout={boardListMeta.layout} />
+
+					{/* 일반 글 */}
+					{list.length > 0 && (
+						list.map((row, i) => {
+							const query = Object.fromEntries(searchParams.entries());
+							const isNew = isNewPost(boardListMeta.isNewIcon, row);
+							const isHot = isHotPost(boardListMeta.isHotIcon, row);
+							const createdAt = formatDate(row.createdAt);
+
+							return (
+								<section key={row.id}>
+									{/* PC */}
+									<ol>
+										<li>
+											<small>{startIndex - i}</small>
+										</li>
+										<li>
+											{row.boardPrefix && row.boardPrefixID && (
+												<button type="button" onClick={() => onChange(row.boardPrefixID || undefined)}>
+													[{row.boardPrefix.name}]
+												</button>
+											)}
+
+											<Link href={{
+													pathname: '/post/' + row.id,
+													query: {
+														...query
+													}
+												}}
+											>
+												<em>{row.subject}</em>
+												{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+												{isHot && <span><img src='/resources/hot.gif' alt='HOT'/></span>}
+											</Link>
+										</li>
+										<li>{row.name || row.sid}</li>
+										<li>{createdAt}</li>
+										<li>{row.views}</li>
+										<li>{row.likes}</li>
+									</ol>
+
+									{/* Mobile */}
+									<dl hidden>
+										<dt>
+											{row.boardPrefix && row.boardPrefixID && (
+												<button type="button" onClick={() => onChange(row.boardPrefixID || undefined)}>
+													[{row.boardPrefix.name}]
+												</button>
+											)}
+
+											<Link href={{
+													pathname: '/post/' + row.id,
+													query: {
+														...query
+													}
+												}}
+											>
+												<em>{row.subject}</em>
+												{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+												{isHot && <span><img src='/resources/hot.gif' alt='HOT'/></span>}
+											</Link>
+										</dt>
+										<dd>
+											<ul>
+												<li>{row.name || row.sid}</li>
+												<li><FontAwesomeIcon icon={faComment} /> {row.comments}</li>
+												<li><FontAwesomeIcon icon={faEye} /> {row.views}</li>
+												<li><FontAwesomeIcon icon={faThumbsUp} /> {row.likes}</li>
+												<li>{createdAt}</li>
+											</ul>
+										</dd>
+									</dl>
+								</section>
+							);
+						})
+					)}
+
+					{list.length <= 0 && (
+						<section>
+							<p className="text-center p-10">
+								등록된 글이 없습니다.
+							</p>
+						</section>
+					)}
+				</article>
+			</section>
+		</>
+	);
+}

+ 19 - 0
app/(forum)/board/_component/FooterContent.tsx

@@ -0,0 +1,19 @@
+'use client';
+
+import './style.scss';
+
+export default function FooterContent({ isEnabled, content }: { isEnabled: boolean, content: string|null }) {
+	if (!isEnabled) {
+		return;
+	}
+
+	return (
+		<>
+			<article
+				id='listFooterContent'
+				aria-label='게시판 하단 내용'
+			 	dangerouslySetInnerHTML={{ __html: content ?? '' }}
+			/>
+		</>
+	);
+}

+ 19 - 0
app/(forum)/board/_component/HeaderContent.tsx

@@ -0,0 +1,19 @@
+'use client';
+
+import './style.scss';
+
+export default function HeaderContent({ isEnabled, content }: { isEnabled: boolean, content: string|null }) {
+	if (!isEnabled) {
+		return;
+	}
+
+	return (
+		<>
+			<blockquote
+				id='listHeaderContent'
+				aria-label='게시판 상단 내용'
+			 	dangerouslySetInnerHTML={{ __html: content ?? '' }}
+			/>
+		</>
+	);
+}

+ 98 - 0
app/(forum)/board/_component/NoticeListLayout.tsx

@@ -0,0 +1,98 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import Post from '@/types/forum/post';
+import { formatDate, isNewPost } from '@/lib/utils/client';
+import { BoardLayout } from '@/constants/forum';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faComment, faThumbsUp, faEye } from '@fortawesome/free-regular-svg-icons';
+
+type Props = {
+	isEnabled: boolean;
+	list: Post[];
+	layout?: BoardLayout;
+}
+
+// 공지사항 목록
+export default function NoticeListLayout({isEnabled, list, layout}: Props)
+{
+	if (!isEnabled) {
+		return;
+	}
+
+	if (!list || list.length <= 0) {
+		return;
+	}
+
+	return (
+		<>
+			{list.map(row => {
+				const isNew = isNewPost(true, row);
+				const createdAt = formatDate(row.createdAt);
+
+				return (
+					<section key={row.id}>
+						{/* PC */}
+						<ol>
+							<li>
+								<img src='/resources/notice.gif' alt='공지사항' className='mx-auto'/>
+							</li>
+							<li>
+								<Link href={`/post/${row.id}${window.location.search}`}>
+									<em>{row.subject} {row.comments > 0 && (<span>[{row.comments}]</span>)}</em>
+									{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+								</Link>
+							</li>
+
+
+							{layout === BoardLayout.Default && (
+							<>
+								<li>{row.name || row.sid}</li>
+								<li>{createdAt}</li>
+								<li>{row.views}</li>
+								<li>{row.likes}</li>
+							</>
+							)}
+
+							{layout === BoardLayout.Media && (
+							<>
+								<li>{row.name || row.sid}</li>
+								<li>{createdAt}</li>
+								<li>{row.views}</li>
+							</>
+							)}
+
+							{layout === BoardLayout.QnA && (
+							<>
+								<li>{row.name || row.sid}</li>
+								<li>-</li>
+								<li>{createdAt}</li>
+							</>
+							)}
+						</ol>
+
+						{/* Mobile */}
+						<dl hidden>
+							<dt>
+								<Link href={`/post/${row.id}${window.location.search}`}>
+									<em>{row.subject}</em>
+									{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+								</Link>
+							</dt>
+							<dd>
+								<ul>
+									<li>{row.name || row.sid}</li>
+									<li><FontAwesomeIcon icon={faComment} /> {row.comments}</li>
+									<li><FontAwesomeIcon icon={faEye} /> {row.views}</li>
+									<li><FontAwesomeIcon icon={faThumbsUp} /> {row.likes}</li>
+									<li>{createdAt}</li>
+								</ul>
+							</dd>
+						</dl>
+					</section>
+				);
+			})}
+		</>
+	);
+}

+ 42 - 0
app/(forum)/board/_component/PermissionDenied.tsx

@@ -0,0 +1,42 @@
+'use client';
+
+import './style.scss';
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse'
+
+type Props = {
+	_board: BoardResponse
+};
+
+export default function PermissionDenied({ _board } : Props) {
+    const router = useRouter();
+
+    useEffect(() => {
+        document.title = '권한이 부족합니다';
+    }, []);
+
+    return (
+        <div className="permission-denied">
+            <h1>⚠️ 권한이 부족합니다</h1>
+            <p>이 페이지에 접근하려면 더 높은 회원 등급이 필요합니다.</p>
+
+            <table className="grade-table">
+                <thead>
+                    <tr>
+                        <th>등급명</th>
+                        <th>등급 순서</th>
+                        <th>설명</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr><td>일반회원</td><td>1</td><td>기본 접근 권한</td></tr>
+                    <tr><td>우수회원</td><td>2</td><td>일부 게시판 열람</td></tr>
+                    <tr><td>운영자</td><td>9</td><td>모든 접근 가능</td></tr>
+                </tbody>
+            </table>
+
+            <button type='button' onClick={() => router.back()}>돌아가기</button>
+        </div>
+    );
+}

+ 33 - 0
app/(forum)/board/_component/PostWriteButton.tsx

@@ -0,0 +1,33 @@
+'use client';
+
+import '../[code]/style.scss';
+import { useRouter } from 'next/navigation';
+import { useCallback } from 'react';
+import useAuth from '@/hooks/useAuth';
+
+export default function PostWriteButton({ alwaysShowButton, boardCode }: { alwaysShowButton: boolean, boardCode: string }) {
+	const router = useRouter();
+	const { isAuthenticated, isLogined } = useAuth();
+
+	const handleClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
+		const redirectUrl = e.currentTarget.value;
+
+		if (!await isLogined()) {
+			return;
+		}
+
+		router.push(redirectUrl);
+	}, []);
+
+	if (!alwaysShowButton && !isAuthenticated) {
+		return;
+	}
+
+	return (
+		<>
+			<section aria-label='글쓰기 버튼'>
+				<button value={`/post/write#${boardCode}`} className='btn btn-submit' onClick={handleClick}>글쓰기</button>
+			</section>
+		</>
+	);
+}

+ 143 - 0
app/(forum)/board/_component/QnAListLayout.tsx

@@ -0,0 +1,143 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import Post from '@/types/forum/post';
+import { BoardListMeta } from '@/types/forum/boardMeta';
+import { formatDate, isNewPost } from '@/lib/utils/client';
+import NoticeListLayout from './NoticeListLayout';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faComment } from '@fortawesome/free-regular-svg-icons';
+
+interface Props {
+	boardListMeta: BoardListMeta;
+	notice: Post[];
+	list: Post[];
+	startIndex: number;
+	onChange: (_: number|undefined) => void;
+}
+
+export default function QnAListLayout({boardListMeta, notice, list, startIndex, onChange}: Props)
+{
+	const searchParams = useSearchParams();
+
+	return (
+		<>
+			<section className='qna-list-layout' aria-label='1:1문의 게시판'>
+				<article>
+					<ul>
+						<li>번호</li>
+						<li>제목</li>
+						<li>작성자</li>
+						<li>답변 여부</li>
+						<li>작성일</li>
+					</ul>
+				</article>
+				<article>
+					{/* 일반 공지 */}
+					<NoticeListLayout isEnabled={!boardListMeta.exceptNotice} list={notice} layout={boardListMeta.layout} />
+
+					{/* 일반 글 */}
+					{list.length > 0 && (
+						list.map((row, i) => {
+							const query = Object.fromEntries(searchParams.entries());
+							const isNew = isNewPost(boardListMeta.isNewIcon, row);
+							const createdAt = formatDate(row.createdAt);
+
+							return (
+								<section key={row.id}>
+									{/* PC */}
+									<ol>
+										<li>
+											<small>{startIndex - i}</small>
+										</li>
+										<li>
+											{row.boardPrefix && row.boardPrefixID && (
+												<button type="button" onClick={() => onChange(row.boardPrefixID || undefined)}>
+													[{row.boardPrefix.name}]
+												</button>
+											)}
+
+											<Link href={{
+													pathname: '/post/' + row.id,
+													query: {
+														...query
+													}
+												}}
+											>
+												<em>{row.subject} {row.comments > 0 && (<span>[{row.comments}]</span>)}</em>
+												{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+											</Link>
+										</li>
+										<li>{row.name || row.sid}</li>
+										<li>
+											{!row.isReply ? (
+												<span className='inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-600/20 ring-inset'>
+													대기
+												</span>
+											) : (
+												<span className='inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset'>
+													완료
+												</span>
+											)}
+										</li>
+										<li>{createdAt}</li>
+									</ol>
+
+									{/* Mobile */}
+									<dl hidden>
+										<dt>
+											{row.boardPrefix && row.boardPrefixID && (
+												<button type="button" onClick={() => onChange(row.boardPrefixID || undefined)}>
+													[{row.boardPrefix.name}]
+												</button>
+											)}
+
+											<Link href={{
+													pathname: '/post/' + row.id,
+													query: {
+														...query
+													}
+												}}
+											>
+												<em>{row.subject}</em>
+												{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+											</Link>
+										</dt>
+										<dd>
+											<ul>
+												<li>{row.name || row.sid}</li>
+												<li><FontAwesomeIcon icon={faComment} /> {row.comments}</li>
+												<li>
+													{!row.isReply ? (
+														<span className='inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-600/20 ring-inset'>
+															대기
+														</span>
+													) : (
+														<span className='inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset'>
+															완료
+														</span>
+													)}
+												</li>
+												<li>{createdAt}</li>
+											</ul>
+										</dd>
+									</dl>
+								</section>
+							);
+						})
+					)}
+
+					{list.length <= 0 && (
+						<section>
+							<p className="text-center p-10">
+								1:1문의 글이 없습니다.
+							</p>
+						</section>
+					)}
+				</article>
+			</section>
+		</>
+	);
+}

+ 573 - 0
app/(forum)/board/_component/style.scss

@@ -0,0 +1,573 @@
+#listHeaderContent {
+	margin-bottom: 10px;
+}
+
+#listFooterContent {
+	margin-top: 10px;
+}
+
+// 일반 게시판
+section.default-list-layout {
+	border-top: 1px solid #eaeaea;
+	margin: 0.75rem 0;
+
+	article:nth-of-type(1) {
+		background-color: #f9fafb;
+		border-bottom: 1px solid #eaeaea;
+		padding: 0.5rem 0;
+
+		ul {
+			display: grid;
+			grid-template-columns:
+				clamp(50px, 5%, 80px)
+				1fr
+				clamp(100px, 14%, 190px)
+				clamp(80px, 10%, 100px)
+				clamp(50px, 9%, 100px)
+				clamp(50px, 9%, 100px);
+			column-gap: 0.75rem;
+
+			li {
+				text-align: center;
+
+				&:nth-child(2),
+				&:nth-child(3) {
+					text-align: left;
+				}
+			}
+		}
+
+		@media (max-width: 1024px) {
+			display: none;
+		}
+	}
+
+	article:nth-of-type(2) {
+		box-sizing: border-box;
+
+		section {
+			padding: 0.5rem 0;
+			box-sizing: inherit;
+			border-bottom: 1px solid #eaeaea;
+
+			&.active,
+			&:hover {
+				background-color: #faffd1;
+			}
+
+			button {
+				color: #333;
+				padding-right: 0.5rem;
+
+				&:hover {
+					text-decoration: underline;
+					color: #0060a9;
+				}
+			}
+
+			a {
+				color: #0060a9;
+				text-decoration: none;
+
+				&:hover {
+					text-decoration: underline;
+					color: #c7511f;
+				}
+
+				> em {
+					display: inherit;
+					font-style: normal;
+					padding-right: 0.5rem;
+
+					> span {
+						color: #d13232;
+					}
+				}
+
+				> span {
+					display: inline-block;
+					vertical-align: middle;
+				}
+			}
+
+			// PC
+			ol {
+				display: grid;
+				grid-template-columns:
+					clamp(50px, 5%, 80px) // 번호
+					1fr // 제목
+					clamp(100px, 14%, 190px) // 작성자
+					clamp(80px, 10%, 100px) // 작성일
+					clamp(50px, 9%, 100px) // 조회수
+					clamp(50px, 9%, 100px); // 추천수
+				column-gap: 0.75rem;
+				align-items: center;
+
+				li {
+					text-align: center;
+
+					&:nth-child(2) {
+						min-width: 0;
+						text-align: left;
+						word-break: keep-all;
+						overflow-wrap: break-word;
+						text-overflow: ellipsis;
+					}
+
+					&:nth-child(3) {
+						text-align: left;
+					}
+				}
+			}
+
+			// Mobile
+			dl {
+				dt {
+					font-size: 1.063rem;
+					word-break: keep-all;
+					overflow-wrap: break-word;
+					text-overflow: ellipsis;
+				}
+
+				dd {
+					ul {
+						display: flex;
+						flex-direction: row;
+						flex-wrap: nowrap;
+						justify-content: start;
+						padding-top: 0.5rem;
+						column-gap: 1.063rem;
+
+						li {
+							font-size: 0.875rem;
+
+							&:last-child {
+								flex-grow: 1;
+								text-align: right;
+							}
+						}
+					}
+				}
+			}
+
+			@media (max-width: 1024px) {
+				ol {
+					display: none;
+				}
+				dl {
+					display: block;
+				}
+			}
+		}
+	}
+}
+
+// 1:1 문의 게시판
+section.qna-list-layout {
+	border-top: 1px solid #eaeaea;
+	margin: 0.75rem 0;
+
+	article:nth-of-type(1) {
+		background-color: #f9fafb;
+		border-bottom: 1px solid #eaeaea;
+		padding: 0.5rem 0;
+
+		ul {
+			display: grid;
+			grid-template-columns:
+				clamp(50px, 5%, 80px) // 번호
+				1fr // 제목
+				clamp(100px, 14%, 190px) // 작성자
+				clamp(100px, 10%, 100px) // 답변 여부
+				clamp(80px, 10%, 100px); // 작성일
+			column-gap: 0.75rem;
+
+			li {
+				text-align: center;
+
+				&:nth-child(2),
+				&:nth-child(3) {
+					text-align: left;
+				}
+			}
+		}
+
+		@media (max-width: 1024px) {
+			display: none;
+		}
+	}
+
+	article:nth-of-type(2) {
+		box-sizing: border-box;
+
+		section {
+			padding: 0.5rem 0;
+			box-sizing: inherit;
+			border-bottom: 1px solid #eaeaea;
+
+			&.active,
+			&:hover {
+				background-color: #faffd1;
+			}
+
+			button {
+				color: #333;
+				padding-right: 0.5rem;
+
+				&:hover {
+					text-decoration: underline;
+					color: #0060a9;
+				}
+			}
+
+			a {
+				color: #0060a9;
+				text-decoration: none;
+
+				&:hover {
+					text-decoration: underline;
+					color: #c7511f;
+				}
+
+				> em {
+					display: inherit;
+					font-style: normal;
+					padding-right: 0.5rem;
+
+					> span {
+						color: #d13232;
+					}
+				}
+
+				> span {
+					display: inline-block;
+					vertical-align: middle;
+				}
+			}
+
+			// PC
+			ol {
+				display: grid;
+				grid-template-columns:
+					clamp(50px, 5%, 80px) // 번호
+					1fr // 제목
+					clamp(100px, 14%, 190px) // 작성자
+					clamp(100px, 10%, 100px) // 답변 여부
+					clamp(80px, 10%, 100px); // 작성일
+				column-gap: 0.75rem;
+				align-items: center;
+
+				li {
+					text-align: center;
+
+					&:nth-child(2) {
+						min-width: 0;
+						text-align: left;
+						word-break: keep-all;
+						overflow-wrap: break-word;
+						text-overflow: ellipsis;
+					}
+
+					&:nth-child(3) {
+						text-align: left;
+					}
+				}
+			}
+
+			// Mobile
+			dl {
+				dt {
+					font-size: 1.063rem;
+					word-break: keep-all;
+					overflow-wrap: break-word;
+					text-overflow: ellipsis;
+				}
+
+				dd {
+					ul {
+						display: flex;
+						flex-direction: row;
+						flex-wrap: nowrap;
+						justify-content: start;
+						padding-top: 0.5rem;
+						column-gap: 1.063rem;
+
+						li {
+							font-size: 0.875rem;
+
+							&:last-child {
+								flex-grow: 1;
+								text-align: right;
+							}
+						}
+					}
+				}
+			}
+
+			@media (max-width: 1024px) {
+				ol {
+					display: none;
+				}
+				dl {
+					display: block;
+				}
+			}
+		}
+	}
+}
+
+// 공지사항
+section.notice-list-layout {
+	border-top: 1px solid #eaeaea;
+	margin: 0.75rem 0;
+
+	article:nth-of-type(1) {
+		background-color: #f9fafb;
+		border-bottom: 1px solid #eaeaea;
+		padding: 0.5rem 0;
+
+		ul {
+			display: grid;
+			grid-template-columns:
+				clamp(50px, 5%, 80px) // 번호
+				1fr // 제목
+				clamp(100px, 14%, 190px) // 작성자
+				clamp(80px, 10%, 100px) // 작성일
+				clamp(50px, 9%, 100px); // 조회수
+			column-gap: 0.75rem;
+
+			li {
+				text-align: center;
+
+				&:nth-child(2),
+				&:nth-child(3) {
+					text-align: left;
+				}
+			}
+		}
+
+		@media (max-width: 1024px) {
+			display: none;
+		}
+	}
+
+	article:nth-of-type(2) {
+		box-sizing: border-box;
+
+		section {
+			padding: 0.5rem 0;
+			box-sizing: inherit;
+			border-bottom: 1px solid #eaeaea;
+
+			&.active,
+			&:hover {
+				background-color: #faffd1;
+			}
+
+			button {
+				color: #333;
+				padding-right: 0.5rem;
+
+				&:hover {
+					text-decoration: underline;
+					color: #0060a9;
+				}
+			}
+
+			a {
+				color: #0060a9;
+				text-decoration: none;
+
+				&:hover {
+					text-decoration: underline;
+					color: #c7511f;
+				}
+
+				> em {
+					display: inherit;
+					font-style: normal;
+					padding-right: 0.5rem;
+
+					> span {
+						color: #d13232;
+					}
+				}
+
+				> span {
+					display: inline-block;
+					vertical-align: middle;
+				}
+			}
+
+			// PC
+			ol {
+				display: grid;
+				grid-template-columns:
+					clamp(50px, 5%, 80px) // 번호
+					1fr // 제목
+					clamp(100px, 14%, 190px) // 작성자
+					clamp(80px, 10%, 100px) // 작성일
+					clamp(50px, 9%, 100px); // 조회수
+				column-gap: 0.75rem;
+				align-items: center;
+
+				li {
+					text-align: center;
+
+					&:nth-child(2) {
+						min-width: 0;
+						text-align: left;
+						word-break: keep-all;
+						overflow-wrap: break-word;
+						text-overflow: ellipsis;
+					}
+
+					&:nth-child(3) {
+						text-align: left;
+					}
+				}
+			}
+
+			// Mobile
+			dl {
+				dt {
+					font-size: 1.063rem;
+					word-break: keep-all;
+					overflow-wrap: break-word;
+					text-overflow: ellipsis;
+				}
+
+				dd {
+					ul {
+						display: flex;
+						flex-direction: row;
+						flex-wrap: nowrap;
+						justify-content: start;
+						padding-top: 0.5rem;
+						column-gap: 1.063rem;
+
+						li {
+							font-size: 0.875rem;
+
+							&:last-child {
+								flex-grow: 1;
+								text-align: right;
+							}
+						}
+					}
+				}
+			}
+
+			@media (max-width: 1024px) {
+				ol {
+					display: none;
+				}
+				dl {
+					display: block;
+				}
+			}
+		}
+	}
+}
+
+// 사진/영상 게시판
+.album-list-layout {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
+	gap: 12px;
+	border-bottom: 1px solid #eaeaea;
+	padding-bottom: 0.75rem;
+	justify-items: center;
+	align-items: start;
+	margin-bottom: 0.75rem;
+
+	> div {
+		width: 100%;
+
+		figure {
+
+			// 대표 이미지
+			article {
+				a {
+					display: block;
+					position: relative;
+					width: 100%;
+					aspect-ratio: 16 / 9;
+					overflow: hidden;
+
+					img:hover, img:focus {
+						border: 1px solid #eb7441;
+					}
+				}
+			}
+
+			dl {
+
+				// 제목
+				dt {
+
+					// 말머리
+					button {
+						color: #333;
+						padding-right: 0.5rem;
+
+						&:hover {
+							text-decoration: underline;
+							color: #0060a9;
+						}
+					}
+
+					a {
+						display: inline-block;
+						color: #0060a9;
+						text-decoration: none;
+						padding-top: 7px;
+
+						&:hover {
+							text-decoration: underline;
+							color: #c7511f;
+						}
+
+						> em {
+							font-style: normal;
+							padding-right: 0.25rem;
+							overflow-wrap: anywhere;
+						}
+
+						> span {
+							display: inline-block;
+							vertical-align: middle;
+						}
+					}
+				}
+
+				// 작성자
+				dd {
+					text-align: right;
+				}
+			}
+
+			// 추천 수, 조회 수, 작성일
+			figcaption {
+				ul {
+					display: flex;
+					flex-direction: row;
+					flex-wrap: nowrap;
+					justify-content: end;
+					padding-top: 0.5rem;
+					column-gap: 1.063rem;
+
+					li {
+						font-size: 0.875rem;
+					}
+				}
+			}
+		}
+	}
+
+	> p {
+		padding: 6.25rem 0;
+		text-align: center;
+		border-bottom: 1px solid #eaeaea;
+	}
+}

+ 222 - 0
app/(forum)/comment/_component/EditForm.tsx

@@ -0,0 +1,222 @@
+'use client';
+
+import '../style.scss';
+import { useState, useRef, useEffect } from 'react';
+import { useAuthContext } from '@/contexts/authProvider';
+import { fetchCommentUpdate } from '@/lib/api/forum/comment';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import PostResponse from '@/dtos/response/forum/post/postResponse';
+import CommentUpdateRequest from '@/dtos/request/forum/comment/commentUpdateRequest';
+import { type CommentItem } from '@/types/forum/comment';
+import EmojiPicker from '@/app/component/EmojiPicker';
+import { BoardLayout, CommentConst } from '@/constants/forum';
+import { throwError } from '@/lib/utils/server';
+import Loading from '@/app/component/Loading';
+
+type Props = {
+	board: BoardResponse,
+	post: PostResponse,
+	comment: CommentItem,
+	onSuccess: () => void;
+	onCancel?: () => void;
+};
+
+export default function EditForm({ board, post, comment, onSuccess, onCancel }: Props)
+{
+	const { isAuthenticated } = useAuthContext();
+	const boardMeta = board.boardMeta;
+
+	const [error, setError] = useState<string|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+
+	const [form, setForm] = useState<CommentUpdateRequest>({
+		postID: post.id,
+		commentID: comment.id,
+		mention: comment.mention?.rawHandle ?? '',
+		content: comment.content,
+		isSecret: false
+	});
+
+	const textareaRef = useRef<HTMLTextAreaElement>(null);
+	const contentRef = useRef<HTMLTextAreaElement>(null);
+	const editorRef = useRef<any>(null);
+
+	// 입력 창 높이 조절
+	const resizeTextarea = () => {
+		const textarea = textareaRef.current;
+		if (textarea) {
+			textarea.style.height = 'auto'; // 초기화
+			textarea.style.height = `${textarea.scrollHeight}px`; // 높이 조정
+		}
+	};
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError(null);
+		}
+	}, [error]);
+
+	useEffect(() => {
+		resizeTextarea();
+	}, [form.content]);
+
+	// 입력 내용 저장
+	const handleChange = (key: keyof CommentUpdateRequest, value: any) => {
+		setForm(prev => ({ ...prev, [key]: value }));
+	};
+
+	// 이모지 선택
+	const handleEmoji = (emoji: string) => {
+		handleChange("content", (form.content + emoji));
+	};
+
+	// Ctrl + Enter 최종 제출
+	const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+		if (e.ctrlKey && e.key === 'Enter') {
+			e.preventDefault();
+			handleSubmit();
+		}
+	};
+
+	// 댓글+답글 수정
+	const handleSubmit = async () => {
+		if (!isAuthenticated) {
+			return setError('로그인 후 이용해주세요.');
+		}
+
+		if (!form.content.trim()) {
+			return;
+		}
+
+		try {
+
+			if (!form.content) {
+				if (boardMeta.list.layout === BoardLayout.QnA) {
+					editorRef.current!.editorInstance?.editing.view.focus();
+				} else {
+					contentRef.current!.focus();
+				}
+
+				throw new Error('내용을 입력해주세요.');
+			} else if (!boardMeta.comment.enableEditor && !contentRef.current) {
+				// 기본 textarea 사용 시 글자 수 검사
+				if (form.content.length > CommentConst.MaxAllowedContentLength) {
+					contentRef.current!.focus();
+					throw new Error(`내용은 ${CommentConst.MaxAllowedContentLength}자 이내로 작성해주세요.`);
+				}
+			}
+
+			const formData = new FormData();
+			formData.append('postID', String(form.postID));
+			formData.append('commentID', String(form.commentID));
+			formData.append('isSecret', String(form.isSecret));
+
+			if (form.content) {
+				const doc = new DOMParser().parseFromString(form.content, 'text/html');
+				doc.querySelectorAll('img[src]').forEach(img => {
+					img.setAttribute('src', 'data:image/');
+				});
+
+				formData.append('content', doc.body.innerHTML);
+			}
+
+			// 이미지 정보
+			if (boardMeta.list.layout === BoardLayout.QnA) {
+				editorRef.current?.getImageStore().forEach((i: any) => {
+					if (i.image?.size > 0 && i.name) {
+						formData.append('images', i.image, i.name);
+					}
+				});
+
+				// 미디어 정보
+				editorRef.current!.getMediaStore().forEach((m: any) => {
+					if (m.url) {
+						formData.append('medias', m.url);
+					}
+				});
+
+				// 첨부 파일
+				editorRef.current!.getFileStore().forEach((f: any) => {
+					if (f?.size > 0 && f.name) {
+						formData.append('files', f.file, f.name);
+					}
+				});
+			}
+
+			const res = await fetchCommentUpdate(formData);
+			await throwError(res);
+			onSuccess();
+
+		} catch (err: any) {
+			setError(err.message);
+		} finally {
+			setLoading(false);
+			resetForm();
+		}
+	};
+
+	// 입력 내용 초기화
+	const resetForm = () => {
+		setForm({
+			postID: post.id,
+			commentID: comment.id,
+			mention: '',
+			content: '',
+			isSecret: false
+		});
+
+		if (contentRef.current) {
+			contentRef.current.value = '';
+		}
+
+		if (textareaRef.current) {
+			textareaRef.current.value = '';
+		}
+
+		resizeTextarea();
+	};
+
+	// 취소
+	const handleCancel = () => {
+		if (typeof onCancel === 'function') {
+			onCancel();
+		} else {
+			resetForm();
+		}
+	};
+
+	return (
+		<>
+			{loading ? (
+				<Loading type={2} />
+			) : (
+
+				<article className='edit-form'>
+					<div>
+						<textarea
+							name='comment'
+							rows={1}
+							value={form.content}
+							title='댓글 입력 창'
+							placeholder=''
+							ref={textareaRef}
+							onChange={(e) => handleChange("content", e.target.value)}
+							onKeyDown={handleKeyDown}
+							disabled={loading}
+						/>
+					</div>
+					<div>
+						{/* 비밀글, 이모지 */}
+						<EmojiPicker onEmojiSelect={handleEmoji} />
+					</div>
+					<div>
+						<button type='button' className='btn btn-default' onClick={handleCancel}>취소</button>
+						<button type='button' className='btn btn-submit' onClick={handleSubmit}>확인</button>
+					</div>
+				</article>
+
+			)}
+		</>
+	)
+};

+ 153 - 0
app/(forum)/comment/_component/Item.tsx

@@ -0,0 +1,153 @@
+'use client';
+
+import '../style.scss';
+import Image from 'next/image';
+import { useState, useEffect } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faThumbsUp as nThumbsUp, faThumbsDown as nThumbsDown, faFlag as nFlag, faPenToSquare, faTrashCan } from '@fortawesome/free-regular-svg-icons';
+import { faThumbsUp as yThumbsUp, faFlag as yFlag, faArrowRotateRight, faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
+import { throwError, loginCheck, formatDate } from '@/lib/utils/client';
+import { type CommentItem } from '@/types/forum/comment';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import PostResponse from '@/dtos/response/forum/post/postResponse';
+import CommentDeleteRequest from '@/dtos/request/forum/comment/commentDeleteRequest';
+import EditForm from './EditForm';
+import { fetchCommentDelete } from '@/lib/api/forum/comment';
+
+type Props = {
+	board: BoardResponse; // 게시판 정보
+    post: PostResponse; // 게시글 정보
+	comment: CommentItem; // 댓글 정보
+    isReplying?: boolean; // 답글 버튼 클릭 여부
+	onReply: () => void;  // 답글 버튼 클릭 시
+	onDelete: () => void; // 삭제 후
+    onSuccess: () => void; // 수정/삭제 후
+}
+
+export default function Item({ comment, isReplying, board, post, onReply, onSuccess, onDelete } : Props)
+{
+	const [error, setError] = useState<string|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+	const [isEditing, setIsEditing] = useState<boolean>(false);
+
+    useEffect(() => {
+        if (!comment) {
+            setIsEditing(false);
+        }
+    }, [comment]);
+
+    const handleStartEdit = () => {
+		if (!loginCheck()) {
+			return;
+		}
+        setIsEditing(true);
+    };
+
+    const handleCancelEdit = () => {
+        setIsEditing(false);
+    };
+
+    const handleEditSuccess = () => {
+        setIsEditing(false);
+        if (typeof onSuccess === 'function') {
+			onSuccess();
+		}
+    };
+
+	const handleDelete = () => {
+		setIsEditing(false);
+
+		if (confirm("댓글을 삭제하시겠습니까?")) {
+			setLoading(true);
+
+			// 댓글 삭제 호출
+			fetchCommentDelete({ commentID: comment.id } as CommentDeleteRequest).then((res) => {
+				throwError(res);
+
+				// 삭제 성공 시 해당 댓글 영역 삭제
+				onDelete();
+			}).catch(err => {
+				setError(err.message);
+			}).finally(() => {
+				setLoading(false);
+			});
+		}
+	};
+
+	const writerThumb = (comment.writer.thumbnail ?? '/resources/thumb.gif');
+	const writerName = (comment.writer.name || comment.writer.sid);
+	const createdAt = formatDate(comment.createdAt);
+
+	return (
+		<li className={comment.isSecret ? 'is-secret' : ''}>
+			<div>
+				<Image src={writerThumb} alt={writerName} width={72} height={0} />
+			</div>
+			<div>
+				<ul>
+					<li>{writerName}</li>
+					<li>{createdAt}</li>
+				</ul>
+			</div>
+			<div>
+				<DropdownMenu>
+					<DropdownMenuTrigger>
+						<FontAwesomeIcon icon={faEllipsisVertical}/>
+					</DropdownMenuTrigger>
+					<DropdownMenuContent>
+						{/*
+						<DropdownMenuItem><FontAwesomeIcon icon={nFlag} className="mr-2"/> 신고</DropdownMenuItem>
+						*/}
+						<DropdownMenuItem onClick={handleStartEdit}>
+							<FontAwesomeIcon icon={faPenToSquare} className="mr-2"/> 수정
+						</DropdownMenuItem>
+						<DropdownMenuItem onClick={handleDelete}>
+							<FontAwesomeIcon icon={faTrashCan} className="mr-2"/> 삭제
+						</DropdownMenuItem>
+					</DropdownMenuContent>
+				</DropdownMenu>
+			</div>
+			<div>
+				<ul>
+					<li>
+						{isEditing ? (
+							<EditForm
+								board={board}
+								post={post}
+								comment={comment}
+								onSuccess={handleEditSuccess}
+								onCancel={handleCancelEdit}
+							/>
+						) : (
+							<ul>
+								<li>
+									{comment.mention && (
+										<span className="mention">@{comment.mention.rawHandle} </span>
+									)}
+									{comment.content}
+								</li>
+							</ul>
+						)}
+					</li>
+				</ul>
+			</div>
+
+			{!isEditing && (
+			<div>
+				{/*
+				<button type="button" title="좋아요"><FontAwesomeIcon icon={nThumbsUp} /></button>
+				<button type="button" title="싫어요"><FontAwesomeIcon icon={nThumbsDown} /></button>
+				*/}
+				<button
+                    type="button"
+                    title="답글"
+                    onClick={onReply}
+                >
+                    {isReplying ? '답글 접기' : '답글'}
+                </button>
+			</div>
+			)}
+		</li>
+	);
+}

+ 94 - 0
app/(forum)/comment/_component/List.tsx

@@ -0,0 +1,94 @@
+'use client';
+
+import '../style.scss';
+import React from 'react';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import PostResponse from '@/dtos/response/forum/post/postResponse';
+import CommentListResponse from '@/dtos/response/forum/comment/commentListResponse';
+import WriteForm from './WriteForm';
+import Item from './Item';
+import Loading from '@/app/component/Loading';
+
+type Props = {
+    board: BoardResponse,
+    post: PostResponse,
+    data: CommentListResponse;
+    loading: boolean;
+    replyTargetID: number|null;
+    onReply: (commentID: number) => void;
+    onDelete: () => void;
+    onSuccess: () => void;
+}
+
+export default function List({ board, post, data, loading, replyTargetID, onReply, onSuccess, onDelete } : Props)
+{
+    return (
+        <>
+            {loading && <Loading type={2} />}
+
+            {data && data.total > 0 && (
+                <article className="comment-list">
+                    <ol>
+                        {data.list.map((row, i) => {
+                            return (
+                                <React.Fragment key={i}>
+                                    <Item key={i}
+                                        board={board}
+                                        post={post}
+                                        comment={row}
+                                        isReplying={replyTargetID == row.id}
+                                        onReply={() => onReply(row.id)}
+                                        onDelete={onDelete}
+                                        onSuccess={onSuccess}
+                                    />
+
+                                    {/* row에 대한 답글 폼(토글) */}
+                                    {replyTargetID === row.id && (
+                                        <div className="pl-[81px] mb-2">
+                                            <WriteForm
+                                                board={board}
+                                                post={post}
+                                                comment={row}
+                                                onSuccess={onSuccess}
+                                                onCancel={() => onReply(row.id)}
+                                            />
+                                        </div>
+                                    )}
+
+                                    {Array.isArray(row.children) && row.children.length > 0 && (
+                                        <ol className="pl-[81px]">
+                                            {row.children.map((ch, j) => (
+                                                <React.Fragment key={j}>
+                                                    <Item
+                                                        board={board}
+                                                        post={post}
+                                                        comment={ch}
+                                                        onReply={() => onReply(row.id)}
+                                                        onDelete={onDelete}
+                                                        onSuccess={onSuccess}
+                                                    />
+
+                                                    {replyTargetID != null && replyTargetID === ch.id && (
+                                                        <div className="pl-[81px] mb-2">
+                                                            <WriteForm
+                                                                board={board}
+                                                                post={post}
+                                                                comment={ch}
+                                                                onSuccess={onSuccess}
+                                                                onCancel={() => onReply(ch.id)}
+                                                            />
+                                                        </div>
+                                                    )}
+                                                </React.Fragment>
+                                            ))}
+                                        </ol>
+                                    )}
+                                </React.Fragment>
+                            )
+                        })}
+                    </ol>
+                </article>
+            )}
+        </>
+    );
+}

+ 245 - 0
app/(forum)/comment/_component/WriteForm.tsx

@@ -0,0 +1,245 @@
+'use client';
+
+import '../style.scss';
+import Image from 'next/image';
+import { useState, useRef, useEffect } from 'react';
+import { useMemberContext } from '@/contexts/memberProvider';
+import { fetchCommentCreate } from '@/lib/api/forum/comment';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import PostResponse from '@/dtos/response/forum/post/postResponse';
+import CommentCreateRequest from '@/dtos/request/forum/comment/commentCreateRequest';
+import EmojiPicker from '@/app/component/EmojiPicker';
+import Loading from '@/app/component/Loading';
+import { BoardLayout, CommentConst } from '@/constants/forum';
+import { throwError } from '@/lib/utils/server';
+import { CommentItem } from '@/types/forum/comment';
+
+type Props = {
+	board: BoardResponse,
+	post: PostResponse,
+	comment?: CommentItem; // null이면 새 댓글, 값이 있으면 답글
+	onSuccess: () => void;
+	onCancel?: () => void;
+};
+
+export default function WriteForm({ board, post, comment, onSuccess, onCancel }: Props)
+{
+	const { member } = useMemberContext();
+	const boardMeta = board.boardMeta;
+
+	const [error, setError] = useState<string|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+
+	const [form, setForm] = useState<CommentCreateRequest>({
+		postID: post.id,
+		parentID: comment?.parentID ?? undefined,
+		mention: '',
+		content: '',
+		isSecret: false
+	});
+
+	const textareaRef = useRef<HTMLTextAreaElement>(null);
+	const contentRef = useRef<HTMLTextAreaElement>(null);
+	const editorRef = useRef<any>(null);
+
+	const initialContent = () => {
+		if (comment && comment.parentID && member?.id != comment.writer.id) {
+			const rawHandle = comment.mention?.rawHandle ?? `@${comment.writer.name ?? comment.writer.sid}`;
+            const mentionText = rawHandle ? (rawHandle.endsWith(' ') ? rawHandle : `${rawHandle} `) : '';
+            setForm(prev => ({ ...prev, parentID: comment.id ?? undefined, mention: mentionText, content: mentionText + ' ' }));
+            setTimeout(() => textareaRef.current?.focus(), 0);
+		} else {
+            // 새 글일 때는 mention 초기화
+            setForm(prev => ({ ...prev, parentID: undefined, mention: '' }));
+        }
+	};
+
+	useEffect(() => {
+		initialContent();
+	}, [comment]);
+
+	// 입력 창 높이 조절
+	const resizeTextarea = () => {
+		const textarea = textareaRef.current;
+		if (textarea) {
+			textarea.style.height = 'auto'; // 초기화
+			textarea.style.height = `${textarea.scrollHeight}px`; // 높이 조정
+		}
+	};
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError(null);
+		}
+	}, [error]);
+
+	useEffect(() => {
+		resizeTextarea();
+	}, [form.content]);
+
+	// 입력 내용 저장
+	const handleChange = (key: keyof CommentCreateRequest, value: any) => {
+		setForm(prev => ({ ...prev, [key]: value }));
+	};
+
+	// 이모지 선택
+	const handleEmoji = (emoji: string) => {
+		handleChange("content", (form.content + emoji));
+	};
+
+	// Ctrl + Enter 최종 제출
+	const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+		if (e.ctrlKey && e.key === 'Enter') {
+			e.preventDefault();
+			handleSubmit();
+		}
+	};
+
+	// 댓글+답글 등록
+	const handleSubmit = async () => {
+		if (!form.content.trim()) {
+			return;
+		}
+
+		try {
+
+			if (!form.content) {
+				if (boardMeta.list.layout === BoardLayout.QnA) {
+					editorRef.current!.editorInstance?.editing.view.focus();
+				} else {
+					contentRef.current!.focus();
+				}
+
+				throw new Error('내용을 입력해주세요.');
+			} else if (!boardMeta.comment.enableEditor && !contentRef.current) {
+				// 기본 textarea 사용 시 글자 수 검사
+				if (form.content.length > CommentConst.MaxAllowedContentLength) {
+					contentRef.current!.focus();
+					throw new Error(`내용은 ${CommentConst.MaxAllowedContentLength}자 이내로 작성해주세요.`);
+				}
+			}
+
+			const formData = new FormData();
+			formData.append('postID', String(form.postID));
+			formData.append('isSecret', String(form.isSecret));
+
+			if (form.parentID) {
+				formData.append('parentID', String(form.parentID));
+			}
+
+			// content에서 자동으로 붙은 @회원을 제거한다.
+			const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+			if (form.mention) {
+				form.content = form.content.replace(
+					new RegExp('^\\s*' + escapeRegExp(form.mention) + '\\s*', 'i')
+				, '');
+			}
+
+			// 이미지 정보
+			if (boardMeta.list.layout === BoardLayout.QnA) {
+				if (form.content) {
+					const doc = new DOMParser().parseFromString(form.content, 'text/html');
+					doc.querySelectorAll('img[src]').forEach(img => {
+						img.setAttribute('src', 'data:image/');
+					});
+
+					form.content = doc.body.innerHTML;
+				}
+
+				editorRef.current?.getImageStore().forEach((i: any) => {
+					if (i.image?.size > 0 && i.name) {
+						formData.append('images', i.image, i.name);
+					}
+				});
+
+				// 미디어 정보
+				editorRef.current!.getMediaStore().forEach((m: any) => {
+					if (m.url) {
+						formData.append('medias', m.url);
+					}
+				});
+
+				// 첨부 파일
+				editorRef.current!.getFileStore().forEach((f: any) => {
+					if (f?.size > 0 && f.name) {
+						formData.append('files', f.file, f.name);
+					}
+				});
+			}
+
+			formData.append('content', form.content);
+
+			const res = await fetchCommentCreate(formData);
+			await throwError(res);
+			onSuccess();
+
+		} catch (err: any) {
+			setError(err.message);
+		} finally {
+			setLoading(false);
+			resetForm();
+		}
+	};
+
+	// 입력 내용 초기화
+	const resetForm = () => {
+		initialContent();
+
+		if (contentRef.current) {
+			contentRef.current.value = '';
+		}
+
+		if (textareaRef.current) {
+			textareaRef.current.value = '';
+		}
+
+		resizeTextarea();
+	};
+
+	// 취소
+	const handleCancel = () => {
+		if (typeof onCancel === 'function') {
+			onCancel();
+		} else {
+			resetForm();
+		}
+	};
+
+	return (
+		<>
+			{loading ? (
+				<Loading type={2} />
+			) : (
+
+				<article className='write-form'>
+					<div>
+						<Image src={member?.photo ?? '/resources/thumb.gif'} alt={member?.name ?? member?.sid ?? ''} width={72} height={0} />
+					</div>
+					<div>
+						<textarea
+							name='comment'
+							rows={1}
+							value={form.content}
+							title='댓글 입력 창'
+							placeholder=''
+							ref={textareaRef}
+							onChange={(e) => handleChange("content", e.target.value)}
+							onKeyDown={handleKeyDown}
+							disabled={loading}
+						/>
+					</div>
+					<div>
+						{/* 비밀글, 이모지 */}
+						<EmojiPicker onEmojiSelect={handleEmoji} />
+					</div>
+					<div>
+						<button type='button' className='btn btn-default' onClick={handleCancel}>취소</button>
+						<button type='button' className='btn btn-submit' onClick={handleSubmit}>확인</button>
+					</div>
+				</article>
+
+			)}
+		</>
+	)
+};

+ 19 - 0
app/(forum)/comment/page.tsx

@@ -0,0 +1,19 @@
+'use server';
+
+import { notFound } from 'next/navigation';
+import View from './view';
+
+export default async function CommentsView({ params }: { params: { id: string } }) {
+	const postID = params.id;
+
+	if (!/^\d+$/.test(postID)) {
+		return notFound();
+	}
+
+	// 여기서 댓글 목록을 호출
+	
+
+	return (
+		<View />
+	);
+}

+ 231 - 0
app/(forum)/comment/style.scss

@@ -0,0 +1,231 @@
+// 댓글
+#comments {
+	.comment-header {
+		display: grid;
+		grid-template-columns: 1fr auto auto;
+		align-items: center;
+		margin-bottom: 10px;
+		gap: 10px;
+
+		article {
+			&:nth-of-type(1) {
+				font-weight: bold;
+
+				> em {
+					font-style: normal;
+					color: #d51b28;
+				}
+			}
+
+			&:nth-of-type(2) {
+				height: 100%;
+
+				select {
+					height: inherit;
+				}
+			}
+		}
+	}
+
+	.write-form {
+		display: grid;
+		grid-template-rows: 1fr auto;
+		grid-template-columns: auto 1fr auto;
+		grid-template-areas: "thumb textarea textarea"
+							 "thumb controls buttons";
+		row-gap: 7px;
+		column-gap: 10px;
+
+		div {
+			&:nth-of-type(1) {
+				grid-area: thumb;
+			}
+
+			&:nth-of-type(2) {
+				grid-area: textarea;
+
+				> textarea {
+					width: 100%;
+					resize: none;
+					overflow-y: hidden;
+				}
+			}
+
+			&:nth-of-type(3) {
+				grid-area: controls;
+				display: flex;
+				flex-direction: row;
+				flex-wrap: nowrap;
+				gap: 7px;
+			}
+
+			&:nth-of-type(4) {
+				grid-area: buttons;
+				display: flex;
+				flex-direction: row;
+				flex-wrap: nowrap;
+				justify-content: flex-end;
+				gap: 10px;
+			}
+		}
+	}
+
+	.edit-form {
+		display: grid;
+		grid-template-rows: 1fr auto;
+		grid-template-columns: 1fr auto;
+		grid-template-areas: "textarea textarea"
+							 "controls buttons";
+		row-gap: 7px;
+		column-gap: 10px;
+
+		div {
+			&:nth-of-type(1) {
+				grid-area: textarea !important;
+
+				> textarea {
+					width: 100%;
+					resize: none;
+					overflow-y: hidden;
+				}
+			}
+
+			&:nth-of-type(2) {
+				grid-area: controls !important	;
+				display: flex;
+				flex-direction: row;
+				flex-wrap: nowrap;
+				gap: 7px;
+			}
+
+			&:nth-of-type(3) {
+				grid-area: buttons !important;
+				display: flex;
+				flex-direction: row;
+				flex-wrap: nowrap;
+				justify-content: flex-end;
+				gap: 10px;
+			}
+		}
+	}
+
+	.comment-list {
+		position: relative;
+
+		ol {
+			list-style: none;
+			display: flex;
+			flex-direction: column;
+			margin: 0;
+			gap: 10px;
+
+			> li {
+				display: grid;
+				grid-template-rows: auto 1fr auto;
+				grid-template-columns: auto 1fr auto;
+				grid-template-areas: "thumb header controls"
+									 "thumb content content"
+									 "_ footer footer";
+				row-gap: 7px;
+				column-gap: 10px;
+				margin-bottom: 10px;
+				border-bottom: 1px solid #eee;
+
+				div {
+					// 댓글 작성자 사진
+					&:nth-of-type(1) {
+						grid-area: thumb;
+					}
+
+					// 댓글 작성자, 작성일시
+					&:nth-of-type(2) {
+						grid-area: header;
+
+						> ul {
+							display: flex;
+							flex-wrap: wrap;
+							column-gap: 7px;
+							row-gap: 0;
+							list-style: none;
+
+							li {
+								position: relative;
+
+								// 가운데 점 추가
+								&::after {
+									content: '·';
+									margin-left: 7px;
+									color: #999;
+								}
+
+								// 마지막 li는 점 숨김
+								&:last-child::after {
+									content: '';
+									margin: 0;
+								}
+							}
+						}
+					}
+
+					// 댓글 관리
+					&:nth-of-type(3) {
+						grid-area: controls;
+
+						> button {
+							padding: 0 15px;
+
+							&:hover, &:focus, &:active {
+								background-color: #f0f0f0;
+								outline: 1px solid #dedede;
+								border-radius: 3px;
+							}
+						}
+					}
+
+					// 댓글 내용
+					&:nth-of-type(4) {
+						grid-area: content;
+					}
+
+					// 좋아요, 싫어요, 답글
+					&:nth-of-type(5) {
+						grid-area: footer;
+						display: flex;
+						flex-direction: row;
+						flex-wrap: nowrap;
+						row-gap: 0;
+						position: relative;
+						top: -15px;
+
+						> button {
+							color: #666;
+
+							/*
+							// 좋아요, 싫어요 버튼 스타일
+							&:nth-of-type(1),
+							&:nth-of-type(2) {
+								padding: 0 15px;
+								outline: 1px solid #dedede;
+								border-radius: 3px;
+
+								&:hover, &:focus, &:active {
+									background-color: #f0f0f0;
+								}
+							}
+							*/
+
+							&:nth-of-type(1) {
+								color: #0D6295;
+
+								&:hover {
+									color: #e47911;
+									text-decoration: underline;
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+}

+ 114 - 0
app/(forum)/comment/view.tsx

@@ -0,0 +1,114 @@
+'use client';
+
+import './style.scss';
+import { useState, useEffect } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import PostResponse from '@/dtos/response/forum/post/postResponse';
+import CommentListRequest from '@/dtos/request/forum/comment/commentListRequest';
+import CommentListResponse from '@/dtos/response/forum/comment/commentListResponse';
+import { fetchCommentList } from '@/lib/api/forum/comment';
+import { throwError, loginCheck } from '@/lib/utils/client';
+import WriteForm from './_component/WriteForm';
+import List from './_component/List';
+import { type CommentSort, CommentConst } from '@/constants/forum';
+import Pagination from '@/app/component/Pagination';
+
+type Props = {
+	board: BoardResponse;
+	post: PostResponse;
+}
+
+export default function View({ board, post } : Props)
+{
+	const [error, setError] = useState<string|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+	const [page, setPage] = useState<number>(1);
+	const [sort, setSort] = useState<CommentSort>(CommentConst.Sort.CreatedAt);
+	const [data, setData] = useState<CommentListResponse>({ total: 0, list: [] });
+	const [replyTargetID, setReplyTargetID] = useState<number|null>(null);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError(null);
+		}
+	}, [error]);
+
+	useEffect(() => {
+		loadComments();
+	}, [page, sort]);
+
+	const loadComments = async () => {
+		setLoading(true);
+
+		// 댓글 목록 호출
+		fetchCommentList({
+			postID: post.id,
+			page: page,
+			sort: sort,
+			perPage: board.boardMeta.comment?.perPage ?? 20
+		} as CommentListRequest).then((res) => {
+			throwError(res);
+
+			if (res.data != null) {
+				setData(res.data);
+			}
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+	};
+
+	const handleReply = (commentID: number) => {
+		if (!loginCheck()) {
+			return;
+		}
+		setReplyTargetID((prev) => (prev === commentID ? null : commentID)); // toggle
+	};
+
+	const handleSuccess = () => {
+		setReplyTargetID(null);
+		loadComments();
+	};
+
+	const handleDelete = () => {
+		setReplyTargetID(null);
+		loadComments();
+	};
+
+	return (
+		<>
+			{/* 댓글, 답글 */}
+			<section id="comments">
+				<div className='comment-header'>
+					<article>댓글 <em>{data.total}개</em></article>
+					<article>
+						<select name="sort" title="정렬 기준" onChange={e => setSort(Number(e.target.value) as CommentSort)} value={sort}>
+							<option value="0">최신순</option>
+							<option value="1">인기순</option>
+						</select>
+					</article>
+					<article>
+						<button className="btn btn-default" title="새로고침" onClick={loadComments} disabled={loading}>
+							<FontAwesomeIcon icon={faArrowRotateRight}/>
+						</button>
+					</article>
+				</div>
+
+				{/* 댓글 작성란 */}
+				<WriteForm board={board} post={post} onSuccess={handleSuccess} />
+
+				<hr />
+
+				{/* 댓글 목록 */}
+				<List board={board} post={post} data={data} loading={loading} replyTargetID={replyTargetID} onReply={handleReply} onSuccess={handleSuccess} onDelete={handleDelete} />
+
+ 				{/* 페이징 */}
+				<Pagination total={data.total} page={page} onChange={setPage} />
+			</section>
+		</>
+	);
+}

+ 9 - 0
app/(forum)/layout.tsx

@@ -0,0 +1,9 @@
+'use client';
+
+import Layout from "@/app/component/Layout";
+
+export default function BoardLayout({ children }: { children: React.ReactNode }) {
+    return (
+		<Layout>{children}</Layout>
+	);
+}

+ 46 - 0
app/(forum)/post/[id]/page.tsx

@@ -0,0 +1,46 @@
+'use server';
+
+import View from './view';
+import { notFound, forbidden, redirect } from 'next/navigation';
+import { BoardLayout } from '@/constants/forum';
+import { isAuthenticated } from '@/lib/api/auth';
+import { fetchBoard } from '@/lib/api/forum/board';
+import { fetchPostData } from '@/lib/api/forum/post';
+
+export default async function PostView({ params }: { params: Promise<{ id: string }> })
+{
+	const { id } = await params;
+
+	if (!/^\d+$/.test(id)) {
+		return forbidden();
+	}
+
+	// 게시글 정보 조회
+	const post = await fetchPostData(Number(id));
+
+	if (!post.ok || !post.data) {
+		return notFound();
+	}
+
+	// 게시판 조회
+	const board = await fetchBoard(post.data.boardCode);
+
+	if (!board || !board.data) {
+		return notFound();
+	}
+
+	if (!board.data.isActive) {
+		return forbidden();
+	}
+
+	const boardMeta = board.data.boardMeta;
+
+	// 1:1 게시판은 로그인한 사용자만 접근 가능
+	if (boardMeta.list.layout === BoardLayout.QnA && !await isAuthenticated()) {
+		redirect('/login');
+	}
+
+    return (
+		<View _board={board.data} _post={post.data} />
+	);
+}

+ 273 - 0
app/(forum)/post/[id]/style.scss

@@ -0,0 +1,273 @@
+#postView {
+	padding: 25px 32px 32px 32px;
+	min-width: 410px;
+	max-width: 1920px;
+	margin: 0 auto;
+
+	section {
+		// 제목
+		&.subject {
+			font-size: 1.5rem;
+			font-weight: 600;
+			margin-bottom: 10px;
+		}
+
+		// 상단
+		&.attribution {
+			display: flex;
+			flex-direction: row;
+			flex-wrap: nowrap;
+			align-items: center;
+
+			div:first-child {
+				flex-grow: 0;
+				padding-right: 20px;
+			}
+
+			div:last-child {
+				display: grid;
+				grid-template-columns: auto auto;
+				grid-template-rows: repeat(2, 1fr);
+				grid-template-areas: "writer-info post-date"
+									 "post-info functions";
+				align-items: center;
+				flex-grow: 1;
+				column-gap: 0;
+				row-gap: 10px;
+
+				article {
+					&.writer-thumb {
+						grid-area: writer-thumb;
+
+						> img {
+							width: 100%;
+							max-width: 92px;
+							object-fit: cover;
+						}
+					}
+
+					&.writer-info {
+						grid-area: writer-info;
+
+						> ul {
+							li {
+								&:nth-of-type(2) {
+									align-self: center;
+									font-size: 0.875rem;
+								}
+							}
+						}
+					}
+
+					&.post-info {
+						grid-area: post-info;
+					}
+
+					&.post-date {
+						grid-area: post-date;
+						text-align: right;
+					}
+
+					&.functions {
+						grid-area: functions;
+						justify-self: flex-end;
+					}
+
+					> ul {
+						display: flex;
+						flex-wrap: wrap;
+						column-gap: 10px;
+						row-gap: 0;
+						list-style: none;
+
+						li {
+							position: relative;
+
+							// 가운데 점 추가
+							&::after {
+								content: '·';
+								margin-left: 10px;
+								color: #999;
+							}
+
+							// 마지막 li는 점 숨김
+							&:last-child::after {
+								content: '';
+								margin: 0;
+							}
+
+							a, button {
+								color: #0D6295;
+								text-decoration: none;
+
+								&:hover {
+									text-decoration: underline;
+									color: #c7511f;
+								}
+							}
+						}
+					}
+				}
+			}
+
+			@media (max-width: 1140px) {
+				div:last-child {
+					grid-template-columns: auto 1fr;
+					grid-template-rows: auto auto auto auto;
+					grid-template-areas: "writer-thumb writer-info"
+										 "writer-thumb post-info"
+										 "writer-thumb post-date"
+										 "writer-thumb functions";
+					align-items: center;
+					grid-auto-rows: auto;
+					row-gap: 2px;
+					column-gap: 0;
+
+					article {
+						&.post-date {
+							text-align: left;
+						}
+
+						&.functions {
+							justify-self: start;
+						}
+					}
+				}
+			}
+		}
+
+		&.content {
+			min-height: 300px;
+			display: flex;
+			flex-direction: column;
+
+			article {
+				font-size: 1.063rem;
+				line-height: 1.25;
+
+				// 본문 내용
+				&:first-of-type {
+					flex-grow: 1;
+
+					// 첨부 파일
+					section.file-embed {
+						display: inline-block;
+						width: max-content;
+						max-width: 100%;
+						padding: 5px 40px;
+						border: 1px solid #ccc;
+						border-radius: 5px;
+						background: #f9f9f9;
+						margin: 5px 0;
+						box-sizing: border-box;
+
+						.file-icon svg {
+							width: 19px;
+							height: 19px;
+							display: inline-block;
+							vertical-align: sub;
+							padding-right: 5px;
+						}
+
+						&.image-style-align-left {
+							margin-left: 0;
+							margin-right: auto;
+						}
+
+						&.image-style-align-center {
+							display: block;
+							margin-left: auto;
+							margin-right: auto;
+						}
+
+						&.image-style-align-right {
+							margin-left: auto;
+							margin-right: 0;
+						}
+
+						&:hover {
+							background-color: #f0f0f0;
+							border-color: #c7511f;
+							cursor: pointer;
+						}
+					}
+
+					p a {
+						color: #0D6295;
+						text-decoration: none;
+
+						&:hover,
+						&:focus {
+							text-decoration: underline;
+							color: #c7511f;
+						}
+					}
+				}
+
+				// 태그
+				&:last-of-type {
+					span {
+						padding-right: 6px;
+
+						a {
+							color: #0D6295;
+							text-decoration: none;
+
+							&:hover {
+								text-decoration: underline;
+								color: #c7511f;
+							}
+						}
+					}
+				}
+			}
+		}
+
+		&.controls {
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+			gap: 0;
+
+			article {
+				display: flex;
+				flex-direction: row;
+				flex-wrap: nowrap;
+				gap: 7px;
+			}
+		}
+	}
+
+	hr {
+		margin: 14px 0;
+	}
+}
+
+// 인쇄 설정
+@media print {
+	body {
+		/* 인쇄할 요소만 보이게 */
+		#main, #container {
+			visibility: visible;
+			display: block;
+			all: unset;
+			width: 100%;
+			height: auto;
+			position: static;
+			padding: 0;
+			margin: 0 auto;
+			overflow: visible;
+		}
+
+		#header, #ticker, #footer, #aside {
+			display: none;
+			visibility: hidden;
+		}
+
+		html, body {
+			width: 100%;
+			height: auto;
+			overflow: visible;
+		}
+    }
+}

+ 390 - 0
app/(forum)/post/[id]/view.tsx

@@ -0,0 +1,390 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import Image from 'next/image';
+import { redirect, useRouter } from 'next/navigation';
+import { useState, useEffect, useCallback, MouseEvent } from 'react';
+import { Menu } from 'lucide-react';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faBookmark as nBookmark, faThumbsUp as nThumbsUp, faThumbsDown as nThumbsDown, faFlag as nFlag, faPenToSquare, faTrashCan } from '@fortawesome/free-regular-svg-icons';
+import { faQrcode, faPrint, faLink, faShareNodes, faBookmark as yBookmark, faThumbsUp as yThumbsUp, faThumbsDown as yThumbsDown, faFlag as yFlag } from '@fortawesome/free-solid-svg-icons';
+import Loading from '@/app/component/Loading';
+import Comment from '@/app/(forum)/comment/view';
+import LatestList from '@/app/(forum)/post/_component/LatestPosts';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import PostResponse from '@/dtos/response/forum/post/postResponse';
+import PostReactionRequest from '@/dtos/request/forum/post/postReactionRequest';
+import PostBookmarkRequest from '@/dtos/request/forum/post/postReactionRequest';
+import Content from '../_component/Content';
+import QRCode from '../_component/QRCode';
+import Copied from '../_component/Copied';
+import SnsShare from '../_component/SnsShare';
+import Report from '../_component/Report';
+import { Reaction } from '@/constants/forum';
+import { fetchPostReaction, fetchPostBookmark, fetchPostDelete } from '@/lib/api/forum/post';
+import { getDateTime, throwError, formatDate, isDateOverdue } from '@/lib/utils/client';
+import useAuth from '@/hooks/useAuth';
+
+type Props = {
+	_board: BoardResponse,
+	_post: PostResponse
+};
+
+export default function View({ _board, _post }: Props)
+{
+	useEffect(() => {
+
+		// 신고 횟수 초과 게시글은 접근 불가
+		if (_post.reports > _board.boardMeta.view.blameHideCount && _board.boardMeta.view.blameHideCount > 0) {
+			alert('비공개 게시글입니다.');
+			redirect(`/board/${_post.boardCode}${window.location.search}`);
+		}
+
+	}, []);
+
+	const router = useRouter();
+	const { member, isLogined } = useAuth();
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
+	const [qrCode, setQrCode] = useState<boolean>(false);
+	const [copied, setCopied] = useState<boolean>(false);
+	const [snsShare, setSnsShare] = useState<boolean>(false);
+	const [report, setReport] = useState<boolean>(false);
+	const [hasLike, setHasLike] = useState<boolean>(_post.hasLike);
+	const [hasDisLike, setHasDisLike] = useState<boolean>(_post.hasDislike);
+	const [hasBookmark, setHasBookmark] = useState<boolean>(_post.hasBookmark);
+	const [hasReport, setHasReport] = useState<boolean>(_post.hasReport);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	const toggleQRCode = useCallback((e: MouseEvent<HTMLAnchorElement>) => {
+		e.preventDefault();
+		setQrCode((prev) => !prev);
+	}, []);
+
+	const handlePrint = useCallback(() => {
+		window.print();
+	}, []);
+
+	const toggleCopied = useCallback((e: MouseEvent<HTMLAnchorElement>) => {
+		e.preventDefault();
+		setCopied((prev) => !prev);
+	}, []);
+
+	const toggleSnsShare = useCallback((e: MouseEvent<HTMLAnchorElement>) => {
+		e.preventDefault();
+		setSnsShare((prev) => !prev);
+	}, []);
+
+	// 좋아요/싫어요
+	const handleReaction = useCallback(async (e: MouseEvent<HTMLButtonElement>) => {
+		const reaction = Number(e.currentTarget.value);
+
+		if (!await isLogined()) {
+			return;
+		}
+
+		fetchPostReaction({ postID: _post.id, reaction: reaction } as PostReactionRequest).then(res => {
+			if (res.ok) {
+				switch (reaction) {
+					case Reaction.Like:
+						setHasLike(!hasLike);
+						setHasDisLike(false);
+						break;
+					case Reaction.Dislike:
+						setHasDisLike(!hasDisLike);
+						setHasLike(false);
+						break;
+				}
+			} else {
+				throwError(res);
+			}
+		}).catch((err) => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+
+	}, [member, hasLike, hasDisLike]);
+
+	// 즐겨찾기
+	const handleBookmark = useCallback(async () => {
+		if (!await isLogined()) {
+			return;
+		}
+
+		fetchPostBookmark({ postID: _post.id } as PostBookmarkRequest).then(res => {
+			if (res.ok) {
+				setHasBookmark(!hasBookmark);
+			} else {
+				throwError(res);
+			}
+		}).catch((err) => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+
+	}, [member, hasBookmark]);
+
+	// 신고하기 시작
+	const handleReport = useCallback(async () => {
+		if (hasReport) {
+			alert('이미 신고하셨습니다.');
+			return;
+		}
+
+		if (!await isLogined()) {
+			return;
+		}
+
+		setReport((prev) => !prev);
+	}, [member, hasReport]);
+
+	// 수정하기
+	const handleEdit = useCallback(async () => {
+		if (!await isLogined()) {
+			return;
+		}
+
+		// 게시글 삭제 보호 확인
+		if (_board.boardMeta.general.allowUpdateProtection && !member?.isAdmin) {
+			if (isDateOverdue(_post.createdAt, _board.boardMeta.general.updateProtectionDays)) {
+				return alert(`게시글 작성 후 ${_board.boardMeta.general.updateProtectionDays}일이 지나 수정이 불가능합니다.`);
+			}
+		}
+
+		router.push(`/post/edit/${_post.id}`);
+	}, [member]);
+
+	// 게시글 삭제
+	const handleDelete = useCallback(async () => {
+		if (!await isLogined()) {
+			return;
+		}
+
+		// 게시글 삭제 보호 확인
+		if (_board.boardMeta.general.allowDeleteProtection && !member?.isAdmin) {
+			if (isDateOverdue(_post.createdAt, _board.boardMeta.general.deleteProtectionDays)) {
+				return alert(`게시글 작성 후 ${_board.boardMeta.general.deleteProtectionDays}일이 지나 삭제가 불가능합니다.`);
+			}
+		}
+
+		if (confirm('정말 삭제하시겠습니까?')) {
+			fetchPostDelete(_post.id).then(res => {
+				if (res.ok) {
+					alert('게시글이 삭제되었습니다.');
+					router.push(`/board/${_post.boardCode}`);
+				} else {
+					throwError(res);
+				}
+			}).catch((err) => {
+				setError(err.message);
+			}).finally(() => {
+				setLoading(false);
+			});
+		}
+	}, [member]);
+
+	return (
+		<div id='postView'>
+			{loading && <Loading />}
+
+			<QRCode isEnable={true} open={qrCode} onChange={setQrCode} />
+			<Copied isEnable={true} open={copied} onChange={setCopied} />
+			<SnsShare isEnable={true} open={snsShare} onChange={setSnsShare} />
+			<Report isEnable={true} open={report} onChange={setReport} onComplete={setHasReport} postID={_post.id} memberID={member?.id} />
+
+			{/* 글 제목 */}
+			<section className='subject whitespace-normal break-words'>
+				{_post.boardPrefixID && ("[" + _post.boardPrefix.name + "]")} {_post.subject}
+			</section>
+
+			<hr />
+
+			{/* 글 작성자/작성일시/부가기능들 */}
+			<section className='attribution'>
+				{_board.boardMeta.view.showMemberPhoto && (
+				<div>
+					<article className='writer-thumb'>
+						<Image src='/resources/thumb.gif' alt='회원 사진' width={84} height={0} />
+					</article>
+				</div>
+				)}
+				<div>
+					<article className='writer-info'>
+						<ul>
+							<li>※ {_post.writer.name}</li>
+							{_board.boardMeta.view.showMemberRegDate && <li>{formatDate(_post.writer.createdAt)} 가입</li>}
+							{_board.boardMeta.view.showMemberSummary && <li>{_post.writer.summary}</li>}
+						</ul>
+					</article>
+
+					<article className='post-info'>
+						<ul>
+							<li>조회: {_post.views}</li>
+							<li>댓글: {_post.comments}</li>
+							{_board.boardMeta.view.allowLike && (
+								<li>좋아요: {_post.likes}</li>
+							)}
+							{_board.boardMeta.view.allowDislike && (
+								<li>싫어요: {_post.dislikes}</li>
+							)}
+							<li>IP: {_post.ipAddress}</li>
+						</ul>
+					</article>
+
+					<article className='post-date'>
+						작성일시 : {getDateTime(_post.createdAt)}
+					</article>
+
+					<article className='functions'>
+						<ul>
+							{_board.boardMeta.view.allowPostUrlQrCode && (
+							<li>
+								<a href='#' rel='noreferrer' onClick={toggleQRCode}><FontAwesomeIcon icon={faQrcode} /> QR</a>
+							</li>
+							)}
+							{_board.boardMeta.view.allowPrint && (
+							<li>
+								<a href='#' rel='noreferrer' onClick={handlePrint}><FontAwesomeIcon icon={faPrint} /> 인쇄</a>
+							</li>
+							)}
+							{_board.boardMeta.view.allowPostUrlCopy && (
+							<li>
+								<a href='#' rel='noreferrer' onClick={toggleCopied}><FontAwesomeIcon icon={faLink} /> 주소</a>
+							</li>
+							)}
+							{_board.boardMeta.view.allowSnsShare && (
+							<li>
+								<a href='#' rel='noreferrer' onClick={toggleSnsShare}><FontAwesomeIcon icon={faShareNodes} /> 공유</a>
+							</li>
+							)}
+						</ul>
+					</article>
+				</div>
+			</section>
+
+			<hr />
+
+			{/* 글 내용 */}
+			<section className='content'>
+				<Content boardMeta={_board.boardMeta} content={_post.content}></Content>
+
+				{_post.tagList.length > 0 && (
+					<article>
+						{/* 태그 표시 */}
+						{_post.tagList.map((row, i) => (
+							<span key={i}>
+								<Link href={`/tag/${row.slug}`}>#{row.slug}</Link>
+							</span>
+						))}
+					</article>
+				)}
+			</section>
+
+			<hr />
+
+			{/* 제어 버튼들 */}
+			<section className='controls'>
+
+				<article>
+					<Link href={`/board/${_post.boardCode}${window.location.search}`} className='btn btn-default'>목록</Link>
+
+					{_board.boardMeta.view.allowPrevNextBotton && (
+						<>
+						{!!_post.prevID && (
+							<Link href={`/post/${_post.prevID}`} className='btn btn-default'>이전</Link>
+						)}
+						{!!_post.nextID && (
+							<Link href={`/post/${_post.nextID}`} className='btn btn-default'>다음</Link>
+						)}
+						</>
+					)}
+				</article>
+
+				<article className='functions'>
+					{_board.boardMeta.view.allowLike && (
+					<div className='hidden sm:block'>
+						<button className='btn btn-default' title='좋아요' value={Reaction.Like} onClick={handleReaction}>
+							<FontAwesomeIcon icon={!hasLike ? nThumbsUp : yThumbsUp } />
+						</button>
+					</div>
+					)}
+					{_board.boardMeta.view.allowDislike && (
+					<div className='hidden sm:block'>
+						<button className='btn btn-default' title='싫어요' value={Reaction.Dislike} onClick={handleReaction}>
+							<FontAwesomeIcon icon={!hasDisLike ? nThumbsDown : yThumbsDown } />
+						</button>
+					</div>
+					)}
+					{_board.boardMeta.view.allowBookmark && (
+					<div className='hidden md:block'>
+						<button className='btn btn-default' onClick={handleBookmark}>
+							<FontAwesomeIcon icon={!hasBookmark ? nBookmark : yBookmark } /> 즐겨찾기
+						</button>
+					</div>
+					)}
+					{_board.boardMeta.view.allowBlame && (
+					<div className='hidden xlm:block'>
+						<button className='btn btn-default' onClick={handleReport}>
+							<FontAwesomeIcon icon={!hasReport ? nFlag : yFlag } /> 신고
+						</button>
+					</div>
+					)}
+					<div className='hidden xl:block'>
+						<button className='btn btn-default' onClick={handleEdit}>수정</button>
+					</div>
+					<div className='hidden xl:block'>
+						<button className='btn btn-default' onClick={handleDelete}>삭제</button>
+					</div>
+
+					<div className='block xl:hidden'>
+						<DropdownMenu>
+						<DropdownMenuTrigger asChild>
+							<button className='btn btn-default' title='더보기'>
+								<Menu className='w-5 h-5' />
+							</button>
+						</DropdownMenuTrigger>
+						<DropdownMenuContent align='end'>
+							{_board.boardMeta.view.allowLike && (
+							<DropdownMenuItem className='block sm:hidden'>
+								<button type="button" value={Reaction.Like} onClick={handleReaction}><FontAwesomeIcon icon={!hasLike ? nThumbsUp : yThumbsUp } /> 좋아요</button>
+							</DropdownMenuItem>
+							)}
+							{_board.boardMeta.view.allowDislike && (
+							<DropdownMenuItem className='block sm:hidden'>
+								<button type="button" value={Reaction.Dislike} onClick={handleReaction}><FontAwesomeIcon icon={!hasDisLike ? nThumbsDown : yThumbsDown } /> 좋아요</button>
+							</DropdownMenuItem>
+							)}
+							{_board.boardMeta.view.allowBookmark && (
+							<DropdownMenuItem className='block md:hidden' onClick={handleBookmark}><FontAwesomeIcon icon={!hasBookmark ? nBookmark : yBookmark } /> 즐겨찾기</DropdownMenuItem>
+							)}
+							{_board.boardMeta.view.allowBlame && (
+							<DropdownMenuItem className='block xlm:hidden' onClick={handleReport}><FontAwesomeIcon icon={!hasReport ? nFlag : yFlag } /> 신고</DropdownMenuItem>
+							)}
+							<DropdownMenuItem className='block xl:hidden' onClick={handleEdit}><FontAwesomeIcon icon={faPenToSquare} /> 수정</DropdownMenuItem>
+							<DropdownMenuItem className='block xl:hidden' onClick={handleDelete}><FontAwesomeIcon icon={faTrashCan} /> 삭제</DropdownMenuItem>
+						</DropdownMenuContent>
+						</DropdownMenu>
+					</div>
+				</article>
+			</section>
+
+			<br/>
+
+			{/* 댓글 */}
+			<Comment board={_board} post={_post} />
+
+			{/* 게시판 최근 글 */}
+			<LatestList boardListMeta={_board.boardMeta.list} boardID={_board.id} boardCode={_board.code} postID={_post.id} />
+		</div>
+	);
+}

+ 43 - 0
app/(forum)/post/_component/Content.tsx

@@ -0,0 +1,43 @@
+'use client';
+
+import './style.scss';
+import { useEffect } from 'react';
+import boardMeta from '@/types/forum/boardMeta';
+
+type Props = {
+	boardMeta: boardMeta;
+	content: string;
+}
+
+export default function Content({ boardMeta, content }: Props)
+{
+	useEffect(() => {
+		const handler = async (e: MouseEvent) => {
+			const target = e.target as HTMLElement;
+
+			// 파일 다운로드
+			const file = target.closest('section.file-embed') as HTMLElement;
+			if (file && file.dataset.uuid) {
+				window.location.href = process.env.NEXT_PUBLIC_API_URL + `/api/forum/post/file/${file.dataset.uuid}`;
+			}
+
+			// 링크 클릭
+			const anchor = target.closest('a[data-uuid]') as HTMLAnchorElement;
+			if (anchor && anchor.dataset.uuid) {
+				e.preventDefault();
+
+				// 무조건 URL 새창 열림이라면 _blank 속성 강제 적용
+				window.open((process.env.NEXT_PUBLIC_API_URL + `/api/forum/post/link/${anchor.dataset.uuid}`), boardMeta.view.allowContentLinkTargetBlank ? '_blank' : '_self');
+			}
+		}
+
+		document.addEventListener('click', handler);
+		return () => {
+			document.removeEventListener('click', handler);
+		}
+	}, []);
+
+	return (
+		<article dangerouslySetInnerHTML={{ __html: content }} className='whitespace-normal break-words'></article>
+	);
+}

+ 64 - 0
app/(forum)/post/_component/Copied.tsx

@@ -0,0 +1,64 @@
+'use client';
+
+import './style.scss';
+import { useEffect, useState } from 'react';
+import { Copy } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+type Props = {
+	isEnable: boolean;
+	open: boolean;
+	onChange: (open: boolean) => void;
+}
+
+export default function Copied({ isEnable, open, onChange }: Props) {
+	const [url, setUrl] = useState<string>('');
+
+	useEffect(() => {
+		if (typeof window !== 'undefined') {
+			setUrl(window.location.href);
+		}
+	}, []);
+
+	if (!isEnable) {
+		return;
+	}
+
+	const handleCopy = async () => {
+		try {
+			await navigator.clipboard.writeText(url);
+			alert("주소가 복사되었습니다.");
+			onChange(false);
+		} catch (err) {
+			console.error("복사 실패:", err);
+		}
+	};
+
+	return (
+		<Dialog open={open} onOpenChange={onChange}>
+			<DialogContent className='sm:max-w-md'>
+				<DialogHeader>
+					<DialogTitle>주소 복사</DialogTitle>
+					<DialogDescription>
+						이 주소를 복사하여 다른 곳에 붙여넣기 하세요.
+					</DialogDescription>
+				</DialogHeader>
+				<div className='flex items-center space-x-2'>
+					<div className='grid flex-1 gap-2'>
+						<Label htmlFor='link' className='sr-only'>
+							Link
+						</Label>
+						<Input id='link' defaultValue={url} readOnly />
+					</div>
+					<Button type='submit' size='sm' className='px-3' onClick={handleCopy}>
+						<span className='sr-only'>Copy</span>
+						<Copy />
+					</Button>
+				</div>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 144 - 0
app/(forum)/post/_component/Editor.tsx

@@ -0,0 +1,144 @@
+'use client';
+
+/**
+ * @author: Kim Jino
+ * @since: 2025.03.31
+ * @description: CKEditor component for Next.js using Script component for loading CKEditor script
+ */
+
+// @/app/(forum)/post/_component/Editor.tsx
+
+import '@/styles/editor.scss';
+import Script from 'next/script';
+import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle } from 'react';
+import { CKEditor } from '@ckeditor/ckeditor5-react';
+import type { Editor as CKEditorInstance } from '@ckeditor/ckeditor5-core';
+import Loading from '@/app/component/Loading';
+import BoardMeta from '@/types/forum/boardMeta';
+import { PostConst } from '@/constants/forum';
+
+export interface Handle {
+	editorInstance: CKEditorInstance|null;
+	getFileStore(): UploadedFile[];
+	getImageStore(): UploadedImage[];
+	getMediaStore(): UploadedMedia[];
+}
+
+interface Props {
+	key: string;
+	data: string;
+	onChange: (data: string) => void;
+	boardMeta?: BoardMeta|null;
+}
+
+const Editor = forwardRef<Handle, Props>(({ key = '', data = '', onChange, boardMeta }: Props, ref) =>
+{
+	const [ready, setReady] = useState<boolean>(false); // CKEditor가 준비되었는지 여부
+	const [instanceLoaded, setInstanceLoaded] = useState<boolean>(false);
+	const editorRef = useRef<typeof window.Editor|null>(null); // CKEditor 객체
+	const editorInstanceRef = useRef<CKEditorInstance|null>(null);
+
+	useEffect(() => {
+		if (typeof window !== 'undefined' && window.Editor) {
+			editorRef.current = window.Editor;
+			setReady(true);
+		}
+	}, [key]); // 게시판 주소가 바뀌면 다시 ready 확인
+
+	const handleScriptLoad = () => {
+		if (typeof window !== 'undefined' && window.Editor) {
+			editorRef.current = window.Editor;
+			setReady(true);
+		} else {
+			console.error('CKEditor script not loaded properly.');
+		}
+	};
+
+	// 외부에 접근 가능한 명령어
+	useImperativeHandle(ref, () => ({
+		editorInstance: editorInstanceRef.current,
+		getFileStore: () => editorInstanceRef.current?._fileStore || [],
+		getImageStore: () => editorInstanceRef.current?._imageStore || [],
+		getMediaStore: () => editorInstanceRef.current?._mediaStore || []
+	}), [instanceLoaded]);
+
+	return (
+		<>
+			<Script src="/editor/editor.min.js" strategy="afterInteractive" onLoad={handleScriptLoad}/>
+
+			{ready && editorRef.current ? (
+				<CKEditor
+					editor={editorRef.current}
+					data={data}
+					config={{
+						placeholder: '내용을 입력하세요.',
+						maxWordCount: PostConst.maxAllowedContentLength, // 최대 본문 길이
+
+						// 이미지 설정
+						allowImage: boardMeta?.write.allowImage ?? false,
+						imageUploadLimit: boardMeta?.write.imageUploadLimit,
+						imageUploadMaxSize: boardMeta?.write.imageUploadMaxSize,
+
+						// 동영상 설정
+						allowMedia: boardMeta?.write.allowMedia ?? false,
+						mediaUploadLimit: boardMeta?.write.mediaUploadLimit,
+
+						// 파일 설정
+						allowFile: boardMeta?.write.allowFile ?? false,
+						fileUploadLimit: boardMeta?.write.fileUploadLimit,
+						fileUploadMaxSize: boardMeta?.write.fileUploadMaxSize,
+						fileUploadExtension: boardMeta?.write.fileUploadExtension
+
+						/*
+						// 글자수 세기
+						wordCount: {
+							onUpdate: (stats: { characters: number; words: number }) => {
+								document.getElementById('editorTxtLength')!.innerText = stats.characters.toString();
+							}
+						}
+						*/
+					}}
+					onReady={(editor) => {
+						console.log('Editor was initialized');
+
+						// 최소 높이 설정
+						editor.editing.view.change(writer => {
+							const root = editor.editing.view.document.getRoot();
+							if (root) {
+								writer.setStyle('min-height', '300px', root);
+							}
+						});
+
+						editorInstanceRef.current = editor;
+						setInstanceLoaded(true);
+
+						// 외부 내용을 복사 붙여넣기 가능하도록 처리
+						editor.editing.view.document.on('paste', (_, data) => {
+							const html = data.dataTransfer.getData('text/html');
+							const text = data.dataTransfer.getData('text/plain');
+							const content = (html || text);
+
+							if (content) {
+								const viewFragment = editor.data.processor.toView(content);
+								const modelFragment = editor.data.toModel(viewFragment);
+
+								editor.model.change(() => {
+									editor.model.insertContent(modelFragment);
+								});
+							}
+						});
+					}}
+					onChange={(_, editor) => {
+						onChange?.(editor.getData());
+					}}
+				/>
+			) : (
+				<Loading/>
+			)}
+		</>
+	);
+});
+
+// 컴포넌트 이름 지정
+Editor.displayName = 'Editor';
+export default Editor;

+ 19 - 0
app/(forum)/post/_component/FooterContent.tsx

@@ -0,0 +1,19 @@
+'use client';
+
+import './style.scss';
+
+export default function FooterContent({ isEnabled, content }: { isEnabled?: boolean, content?: string|null }) {
+	if (!isEnabled) {
+		return;
+	}
+
+	return (
+		<>
+			<article
+				id='writeFooterContent'
+				aria-label='게시글 작성 하단 내용'
+			 	dangerouslySetInnerHTML={{ __html: content ?? '' }}
+			/>
+		</>
+	);
+}

+ 19 - 0
app/(forum)/post/_component/HeaderContent.tsx

@@ -0,0 +1,19 @@
+'use client';
+
+import './style.scss';
+
+export default function HeaderContent({ isEnabled, content }: { isEnabled?: boolean, content?: string|null }) {
+	if (!isEnabled) {
+		return;
+	}
+
+	return (
+		<>
+			<blockquote
+				id='writeHeaderContent'
+				aria-label='게시글 작성 상단 내용'
+			 	dangerouslySetInnerHTML={{ __html: content ?? '' }}
+			/>
+		</>
+	);
+}

+ 372 - 0
app/(forum)/post/_component/LatestPosts.tsx

@@ -0,0 +1,372 @@
+'use client';
+
+import './style.scss';
+import '../../board/_component/style.scss';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import Image, { ImageProps } from 'next/image';
+import { useState, useEffect } from 'react';
+import { clsx } from 'clsx';
+import { BoardListMeta } from '@/types/forum/boardMeta';
+import { fetchLatestPosts } from '@/lib/api/forum/board';
+import LatestPostsRequest from '@/dtos/request/forum/board/latestPostsRequest'
+import LatestPostsResponse from '@/dtos/response/forum/board/latestPostsResponse'
+import { throwError, isNewPost, isHotPost, formatDate } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faComment, faThumbsUp, faEye, faClock } from '@fortawesome/free-regular-svg-icons';
+import { BoardLayout } from '@/constants/forum';
+
+type Props = {
+	boardListMeta: BoardListMeta;
+	boardID: number;
+	boardCode: string;
+	postID?: number|null;
+}
+
+function ImageWithFallback({
+	src,
+	fallbackSrc = '/resources/no-image.png',
+	alt,
+	...option
+}: ImageProps & { fallbackSrc?: string }) {
+	const [imgSrc, setImgSrc] = useState(src);
+
+	return (
+		<Image
+			{...option}
+			src={imgSrc}
+			alt={alt}
+			onError={() => setImgSrc(fallbackSrc)}
+		/>
+	);
+}
+
+export default function LatestPosts(params : Props)
+{
+	const searchParams = useSearchParams();
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
+	const [latestPosts, setLatestPosts] = useState<LatestPostsResponse>({
+		list: []
+	});
+	const query = Object.fromEntries(searchParams.entries());
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+		(async () => {
+
+			setLoading(true);
+
+			const page = searchParams.get('page');
+			const perPage = searchParams.get('perPage');
+			const boardPrefixID = searchParams.get('boardPrefixID');
+
+			fetchLatestPosts({
+				boardID: params.boardID,
+				boardCode: params.boardCode,
+				postID: params.postID,
+				page: page ? Number(page) : null,
+				perPage: perPage ? Number(perPage) : null,
+				boardPrefixID: boardPrefixID ? Number(boardPrefixID) : null,
+				sort: searchParams.get('sort'),
+				search: searchParams.get('search'),
+				keyword: searchParams.get('keyword'),
+			} as LatestPostsRequest).then((res) => {
+				throwError(res);
+				setLatestPosts(res.data as LatestPostsResponse);
+			}).catch((err) => {
+				setError(err.message);
+			}).finally(() => {
+				setLoading(false);
+			});
+
+		})();
+	}, []);
+
+	const handleClick = (boardPrefixID: number|null) => {
+		const querys = new URLSearchParams(searchParams.toString());
+		if (boardPrefixID) {
+			querys.set('prefix', String(boardPrefixID));
+		}
+		window.location.href = `/board/${params.boardCode}?${querys.toString()}`;
+	}
+
+	if (latestPosts.list.length <= 0) {
+		return;
+	}
+
+	return (
+		<>
+		<div id="latestPosts">
+			{loading && <Loading />}
+
+			<article>
+
+				{params.boardListMeta.layout == BoardLayout.Default && (
+				<section className="default-list-layout" aria-label='일반 게시판'>
+					<article>
+						<ul>
+							<li>번호</li>
+							<li>제목</li>
+							<li>작성자</li>
+							<li>작성일</li>
+							<li>조회수</li>
+							<li>좋아요</li>
+						</ul>
+					</article>
+					<article>
+						{/* 일반 글 */}
+						{(latestPosts.list.map((row) => {
+								const query = Object.fromEntries(searchParams.entries());
+								const isNew = isNewPost(params.boardListMeta.isNewIcon, row);
+								const isHot = isHotPost(params.boardListMeta.isHotIcon, row);
+								const createdAt = formatDate(row.createdAt);
+
+								return (
+									<section key={row.id} className={clsx({ active: row.isActive })}>
+										{/* PC */}
+										<ol>
+											<li>
+												<small>{row.no}</small>
+											</li>
+											<li>
+												{row.boardPrefixID && (
+													<button type="button" onClick={() => handleClick(row.boardPrefixID)}>
+														[{row.boardPrefixName}]
+													</button>
+												)}
+
+												<Link href={{
+														pathname: '/post/' + row.id,
+														query: {
+															...query
+														}
+													}}
+												>
+													<em>{row.subject}</em>
+													{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+													{isHot && <span><img src='/resources/hot.gif' alt='HOT'/></span>}
+												</Link>
+											</li>
+											<li>{row.name || row.sid}</li>
+											<li>{createdAt}</li>
+											<li>{row.views}</li>
+											<li>{row.likes}</li>
+										</ol>
+
+										{/* Mobile */}
+										<dl hidden>
+											<dt>
+												<Link href={{
+														pathname: '/post/' + row.id,
+														query: {
+															...query
+														}
+													}}
+												>
+													<em>{row.subject}</em>
+													{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+													{isHot && <span><img src='/resources/hot.gif' alt='HOT'/></span>}
+												</Link>
+											</dt>
+											<dd>
+												<ul>
+													<li>{row.name || row.sid}</li>
+													<li><FontAwesomeIcon icon={faComment} /> {row.comments}</li>
+													<li><FontAwesomeIcon icon={faEye} /> {row.views}</li>
+													<li><FontAwesomeIcon icon={faThumbsUp} /> {row.likes}</li>
+													<li>{createdAt}</li>
+												</ul>
+											</dd>
+										</dl>
+									</section>
+								);
+							})
+						)}
+					</article>
+				</section>
+				)}
+
+				{/* 1:1문의 */}
+				{params.boardListMeta.layout == BoardLayout.QnA && (
+				<section className="qna-list-layout" aria-label='일반 게시판'>
+					<article>
+						<ul>
+							<li>번호</li>
+							<li>제목</li>
+							<li>작성자</li>
+							<li>답변 여부</li>
+							<li>작성일</li>
+						</ul>
+					</article>
+					<article>
+
+						{(latestPosts.list.map((row) => {
+								const query = Object.fromEntries(searchParams.entries());
+								const isNew = isNewPost(params.boardListMeta.isNewIcon, row);
+								const createdAt = formatDate(row.createdAt);
+
+								return (
+									<section key={row.id} className={clsx({ active: row.isActive })}>
+										{/* PC */}
+										<ol>
+											<li>
+												<small>{row.no}</small>
+											</li>
+											<li>
+												{row.boardPrefixID && (
+													<button type="button" onClick={() => handleClick(row.boardPrefixID)}>
+														[{row.boardPrefixName}]
+													</button>
+												)}
+
+												<Link href={{
+														pathname: '/post/' + row.id,
+														query: {
+															...query
+														}
+													}}
+												>
+													<em>{row.subject} {row.comments > 0 && (<span>[{row.comments}]</span>)}</em>
+													{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+												</Link>
+											</li>
+											<li>{row.name || row.sid}</li>
+											<li>
+												{!row.isReply ? (
+													<span className='inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-600/20 ring-inset'>
+														대기
+													</span>
+												) : (
+													<span className='inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset'>
+														완료
+													</span>
+												)}
+											</li>
+											<li>{createdAt}</li>
+										</ol>
+
+										{/* Mobile */}
+										<dl hidden>
+											<dt>
+												<Link href={{
+														pathname: '/post/' + row.id,
+														query: {
+															...query
+														}
+													}}
+												>
+													<em>{row.subject}</em>
+													{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+												</Link>
+											</dt>
+											<dd>
+												<ul>
+													<li>{row.name || row.sid}</li>
+													<li><FontAwesomeIcon icon={faComment} /> {row.comments}</li>
+													<li>
+														{!row.isReply ? (
+															<span className='inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-600/20 ring-inset'>
+																대기
+															</span>
+														) : (
+															<span className='inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset'>
+																완료
+															</span>
+														)}
+													</li>
+													<li>{createdAt}</li>
+												</ul>
+											</dd>
+										</dl>
+									</section>
+								);
+							})
+						)}
+					</article>
+				</section>
+				)}
+
+				{/* 사진/동영상 글 */}
+				{params.boardListMeta.layout == BoardLayout.Media && (
+				<section aria-label='사진/동영상 게시판'>
+					<article>
+						<div className="album-list-layout">
+						{(latestPosts.list.map((row) => {
+								const query = Object.fromEntries(searchParams.entries());
+								const isNew = isNewPost(params.boardListMeta.isNewIcon, row);
+								const isHot = isHotPost(params.boardListMeta.isHotIcon, row);
+								const createdAt = formatDate(row.createdAt);
+
+								// 사진/동영상 게시판
+								const href = `/post/${row.id}${window.location.search}`;
+								return (
+									<div key={row.id} className={clsx({ active: row.isActive })}>
+										<figure>
+											<article>
+												<Link href={href}>
+													{row.thumbnail ? (
+														<ImageWithFallback src={row.thumbnail} alt={row.subject} fill style={{ objectFit: 'cover' }} loading="lazy" />
+													) : (
+														<Image src='/resources/no-image.png' alt={row.subject} loading='lazy' fill />
+													)}
+												</Link>
+											</article>
+											<dl>
+												<dt>
+													{row.boardPrefixID && (
+														<button type="button" onClick={() => handleClick(row.boardPrefixID)}>
+															[{row.boardPrefixName}]
+														</button>
+													)}
+													<Link href={{
+															pathname: '/post/' + row.id,
+															query: {
+																...query
+															}
+														}}
+													>
+														<em>{row.subject} {row.comments > 0 && (<span>[{row.comments}]</span>)}</em>
+														{isNew && <span><img src='/resources/new.gif' alt='NEW'/></span>}
+														{isHot && <span><img src='/resources/hot.gif' alt='HOT'/></span>}
+													</Link>
+												</dt>
+												<dd>{row.name}</dd>
+											</dl>
+											<figcaption>
+												<ul>
+													<li><FontAwesomeIcon icon={faThumbsUp} /> {row.likes}</li>
+													<li><FontAwesomeIcon icon={faEye} /> {row.views}</li>
+													<li><FontAwesomeIcon icon={faClock} /> {createdAt}</li>
+												</ul>
+											</figcaption>
+										</figure>
+									</div>
+								);
+							})
+						)}
+						</div>
+					</article>
+				</section>
+				)}
+			</article>
+
+			<article>
+				<Link href={{
+					pathname: '/board/' + params.boardCode,
+					query: {
+						...query
+					}
+				}} className='btn btn-default'>목록으로</Link>
+			</article>
+		</div>
+		</>
+	);
+}

+ 74 - 0
app/(forum)/post/_component/PostTagInput.tsx

@@ -0,0 +1,74 @@
+'use client';
+
+// @/app/(forum)/post/_component/PostTagInput.tsx
+import React, { useState, KeyboardEvent } from 'react';
+import { filterSpecialCharacters } from '@/lib/utils/client';
+
+type Props = {
+	value: string[];
+	onChange: (tags: string[]) => void;
+	maxTags?: number;
+};
+
+export default function PostTagInput({ value, onChange, maxTags = 10 }: Props)
+{
+	const [inputValue, setInputValue] = useState<string>('');
+
+	const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
+		if (e.key === 'Enter' && inputValue.trim() !== '') {
+			e.preventDefault();
+
+			// 중복 방지
+			const newTag = inputValue.trim();
+			const isDuplicate = value.some(tag => tag.toLowerCase() === newTag.toLowerCase());
+			if (isDuplicate) {
+				setInputValue('');
+				return;
+			}
+
+			if (maxTags <= 0 || (value.length < maxTags)) {
+				onChange([...value, inputValue.trim()]);
+				setInputValue('');
+			}
+
+		} else if (e.key === 'Backspace' && inputValue === '') { // 삭제
+			onChange(value.slice(0, -1));
+		}
+	};
+
+	const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+		const value = filterSpecialCharacters(e.target.value); // 특수문자는 입력 제외
+		if (value.length > 20) {
+			return;
+		}
+
+		setInputValue(value);
+	};
+
+	return (
+		<div className="tag-input">
+
+			{/* 태그 표시 */}
+			{value.map((tag, index) => (
+				<span key={index} className="tag">
+					<small>#{tag}</small>
+					<button type="button" onClick={() => onChange(value.filter((_, i) => i !== index))}>x</button>
+				</span>
+			))}
+
+			{/* 최대 태그 수에 도달하지 않았을 때만 입력 표시 */}
+			{(maxTags <= 0 || (value.length < maxTags)) && (
+				<input
+					type="text"
+					value={inputValue}
+					onChange={handleChange}
+					onKeyDown={handleKeyDown}
+					maxLength={20}
+					placeholder={`# 태그를 입력해주세요. (최대 ${maxTags}개)`}
+					autoComplete="off"
+				/>
+			)}
+
+		</div>
+	);
+}

+ 75 - 0
app/(forum)/post/_component/QRCode.tsx

@@ -0,0 +1,75 @@
+'use client';
+
+import './style.scss';
+import { useEffect, useState } from 'react';
+import { QRCodeSVG } from 'qrcode.react';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { getDateFilename } from '@/lib/utils/client';
+
+type Props = {
+	isEnable: boolean;
+	open: boolean;
+	onChange: (open: boolean) => void;
+}
+
+export default function QRCode({ isEnable, open, onChange }: Props) {
+	const [url, setUrl] = useState<string>('');
+
+	useEffect(() => {
+		if (typeof window !== 'undefined') {
+			setUrl(window.location.href);
+		}
+	}, []);
+
+	if (!isEnable) {
+		return;
+	}
+
+	const handleDownload = () => {
+		const svgData = new XMLSerializer().serializeToString(
+			document.getElementById('qrCode')!
+		);
+
+		const canvas = document.createElement('canvas');
+		const ctx = canvas.getContext('2d')!;
+		const img = new Image();
+
+		img.onload = () => {
+			canvas.width = img.width;
+			canvas.height = img.height;
+			ctx.drawImage(img, 0, 0);
+			const pngFile = canvas.toDataURL('image/png');
+
+			const link = document.createElement('a');
+			link.download = getDateFilename('qr');
+			link.href = pngFile;
+			link.click();
+		};
+
+		img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
+	};
+
+	return (
+		<Dialog open={open} onOpenChange={onChange}>
+			<DialogContent className='sm:max-w-md'>
+				<DialogHeader>
+					<DialogTitle>게시글 확인 QR</DialogTitle>
+					<DialogDescription>
+						아래 QR을 스캔하여 게시글을 확인할 수 있습니다.
+					</DialogDescription>
+				</DialogHeader>
+				<div className='flex items-center justify-center space-x-2'>
+					<QRCodeSVG id='qrCode' value={url} />
+				</div>
+				<DialogFooter className='sm:justify-center'>
+					<DialogClose asChild>
+						<Button type='button' variant='outline' className='hover:bg-blue-500 hover:text-white sm:w-24' onClick={handleDownload}>
+							저장
+						</Button>
+					</DialogClose>
+				</DialogFooter>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 139 - 0
app/(forum)/post/_component/Report.tsx

@@ -0,0 +1,139 @@
+'use client';
+
+import './style.scss';
+import { useEffect, useState, useCallback, useRef } from 'react';
+import Loading from '@/app/component/Loading';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { fetchReport } from '@/lib/api/forum/post';
+import { throwError } from '@/lib/utils/client';
+import { ReportType } from '@/constants/forum';
+import PostReportRequest from '@/dtos/request/forum/post/postReportRequest';
+
+type Props = {
+	isEnable: boolean;
+	open: boolean;
+	onChange: (_: boolean) => void;
+	onComplete: (_: boolean) => void;
+	postID: number;
+	memberID?: number;
+}
+
+export default function Report({ isEnable, open, onChange, onComplete, postID, memberID }: Props)
+{
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
+	const [form, setForm] = useState<{
+		type: string;
+		reason: string;
+	}>({
+		type: '',
+		reason: ''
+	});
+	const typeRef = useRef<HTMLSelectElement>(null);
+	const reasonRef = useRef<HTMLTextAreaElement>(null);
+
+	const reportTypeLabels: Record<ReportType, string> = {
+		[ReportType.None]: '신고 유형을 선택하세요.',
+		[ReportType.Abuse]: '욕설',
+		[ReportType.Obscene]: '음란',
+		[ReportType.Illegal]: '불법',
+		[ReportType.Impersonation]: '신분 사칭',
+		[ReportType.CashTrade]: '현금거래 유도',
+		[ReportType.SpamAd]: '스팸/광고',
+		[ReportType.Flood]: '도배',
+		[ReportType.PersonalLeak]: '개인정보 노출',
+		[ReportType.Other]: '기타'
+	};
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	const handleChange = (e: React.ChangeEvent<HTMLSelectElement|HTMLTextAreaElement>) => {
+        const { name, value } = e.target;
+
+        setForm((prev) => ({
+            ...prev,
+            [name]: value
+        }));
+    };
+
+	const handleSubmit = useCallback(async () => {
+		if (!form.type) {
+			alert('신고 유형을 선택하세요.');
+			typeRef.current?.focus();
+			return;
+		}
+
+		if (!form.reason) {
+			alert('신고 내용을 입력해주세요.');
+			reasonRef.current?.focus();
+			return;
+		}
+
+        if (!memberID) {
+            alert('로그인 후 이용해주세요.');
+            return;
+        }
+
+		setLoading(true);
+		onChange(false);
+
+		try {
+
+            const res = await fetchReport({
+				postID,
+				type: Number(form.type) as ReportType,
+				reason: form.reason
+			} as PostReportRequest);
+
+			if (res.ok) {
+				alert('신고가 접수되었습니다.');
+				setForm({ type: '', reason: '' });
+				onComplete(true);
+			} else {
+				throwError(res);
+			}
+
+        } catch (err: any) {
+            alert(err.message);
+        } finally {
+            setLoading(false);
+        }
+    }, [form, postID, memberID, onChange]);
+
+	if (!isEnable) {
+		return null;
+	}
+
+	return (
+		<>
+			{loading && <Loading />}
+
+			<Dialog open={open} onOpenChange={onChange}>
+				<DialogContent className='w-3xs sm:max-w-md'>
+					<DialogHeader>
+						<DialogTitle>게시글 신고</DialogTitle>
+					</DialogHeader>
+					<div id='report' className='flex flex-col items-center gap-4'>
+						<select ref={typeRef} name='type' value={form.type} title='신고 유형' onChange={handleChange}>
+							{Object.entries(reportTypeLabels).map(([key, label]) => (
+								<option key={key} value={key}>
+									{label}
+								</option>
+							))}
+						</select>
+						<textarea ref={reasonRef} name='reason' rows={4} value={form.reason} title='신고 내용' onChange={handleChange} maxLength={500} placeholder='신고 내용을 구체적으로 입력해주세요.(500자 이하)'></textarea>
+						<Button type='button' variant='outline' className='hover:bg-blue-500 hover:text-white sm:w-24' onClick={handleSubmit}>
+							신고하기
+						</Button>
+					</div>
+				</DialogContent>
+			</Dialog>
+		</>
+	);
+}

+ 69 - 0
app/(forum)/post/_component/SnsShare.tsx

@@ -0,0 +1,69 @@
+'use client';
+
+import './style.scss';
+import { useCallback } from 'react';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faNairaSign } from '@fortawesome/free-solid-svg-icons';
+import { faFacebook, faTwitter, faReddit } from '@fortawesome/free-brands-svg-icons';
+
+type Props = {
+	isEnable: boolean;
+	open: boolean;
+	onChange: (open: boolean) => void;
+}
+
+export default function SnsShare({ isEnable, open, onChange }: Props)
+{
+	const handleSnsShare = useCallback((type: string) => {
+		if (type) {
+			let url = encodeURIComponent(window.location.href);
+
+			switch (type) {
+				case 'facebook':
+					url = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
+					break;
+				case 'twitter':
+					url = `https://twitter.com/intent/tweet?url=${url}`;
+					break;
+				case 'reddit':
+					url = `https://www.reddit.com/submit?url=${url}`;
+					break;
+				case 'band':
+					url = `https://band.us/plugin/share?body=${url}`;
+					break;
+				default:
+					return;
+			}
+
+			const width = 600;
+			const height = 400;
+			const left = (window.screenX + (window.outerWidth - width) / 2);
+			const top = (window.screenY + (window.outerHeight - height) / 2);
+			window.open(url, '_blank', `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
+		}
+
+		onChange(false);
+	}, []);
+
+	if (!isEnable) {
+		return;
+	}
+
+	return (
+		<Dialog open={open} onOpenChange={onChange}>
+			<DialogContent className='sm:max-w-md'>
+				<DialogHeader>
+					<DialogTitle>SNS 공유</DialogTitle>
+				</DialogHeader>
+				<div className='flex flex-col gap-2'>
+					<Button variant='outline' onClick={() => handleSnsShare("facebook")}><FontAwesomeIcon icon={faFacebook} /> Facebook</Button>
+					<Button variant='outline' onClick={() => handleSnsShare("twitter")}><FontAwesomeIcon icon={faTwitter} /> Twitter</Button>
+					<Button variant='outline' onClick={() => handleSnsShare("reddit")}><FontAwesomeIcon icon={faReddit} /> Reddit</Button>
+					<Button variant='outline' onClick={() => handleSnsShare("band")}><FontAwesomeIcon icon={faNairaSign} /> Band</Button>
+				</div>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 38 - 0
app/(forum)/post/_component/style.scss

@@ -0,0 +1,38 @@
+#writeHeaderContent {
+	margin-bottom: 0.625rem;
+}
+
+#writeFooterContent {
+	margin-top: 0.625rem;
+}
+
+// 최근 게시글 목록
+#latestPosts {
+	> article:first-of-type {
+		overflow: hidden;
+
+		table {
+			tr.active {
+				background-color: #f4fcff;
+			}
+		}
+	}
+
+	// 목록으로
+	> article:last-of-type {
+		text-align: center;
+		padding: 0.938rem 0 1.25rem 0;
+
+		> a {
+			padding-left: 30px;
+    		padding-right: 30px;
+		}
+	}
+}
+
+#report {
+	select, textarea {
+		width: 100%;
+		padding: 0.5rem;
+	}
+}

+ 67 - 0
app/(forum)/post/edit/[id]/page.tsx

@@ -0,0 +1,67 @@
+'use server';
+
+import View from './view';
+import { notFound, forbidden, unauthorized } from 'next/navigation';
+import { isAuthenticated } from '@/lib/api/auth';
+import { fetchBoard, fetchBoardList } from '@/lib/api/forum/board';
+import { fetchPostData } from '@/lib/api/forum/post';
+import { getTokenData } from '@/lib/utils/server';
+import { TokenData } from '@/dtos/response/common';
+
+export default async function PostEdit({ params }: { params: Promise<{ id: string }> })
+{
+	const { id } = await params;
+
+	if (!/^\d+$/.test(id)) {
+		return forbidden();
+	}
+
+	let tokenData: TokenData|null;
+
+	try {
+
+		if (!await isAuthenticated()) {
+			throw Error;
+		}
+
+		// 회원 정보 조회
+		tokenData = await getTokenData();
+		if (!tokenData) {
+			throw Error;
+		}
+
+		console.log(tokenData);
+
+	} catch {
+		return unauthorized();
+	}
+
+	try {
+
+		// 게시글 정보 조회
+		const post = await fetchPostData(Number(id));
+
+		if (!post.ok || !post.data) {
+			throw Error;
+		}
+
+		// 게시판 상세 조회
+		const board = await fetchBoard(post.data.boardCode);
+		if (!board.ok || !board.data) {
+			throw Error;
+		}
+
+		// 게시판 목록 조회
+		const boardList = await fetchBoardList(board.data.boardGroup.code);
+
+		if (!boardList.ok || !boardList.data) {
+			return notFound();
+		}
+
+		return (
+			<View _boardList={boardList.data} _board={board.data} _post={post.data} />
+		);
+	} catch {
+		return notFound();
+	}
+}

+ 156 - 0
app/(forum)/post/edit/[id]/style.scss

@@ -0,0 +1,156 @@
+#postEdit {
+	padding: 1.5625rem 2rem 2rem 2rem;
+
+	h1 {
+		font-size: 1.375rem;
+		margin-bottom: 1.25rem;
+	}
+
+	min-width: 25.75rem;
+	max-width: 80rem;
+	margin: 0 auto;
+
+	fieldset {
+		display: flex;
+		flex-direction: column;
+		gap: 0;
+		min-width: 0;
+
+		section {
+			margin-bottom: 0.4375rem;
+
+			select, input, textarea {
+				padding: 0.625rem;
+				border-color: #ccced1;
+				border-width: 0.0625rem;
+
+				&:focus {
+					border: 0.0625rem solid hsl(218, 81.8%, 56.9%);
+					box-shadow: 0.125rem 0.125rem 0.1875rem rgba(0, 0, 0, .1) inset, 0 0;
+					outline: none;
+				}
+			}
+
+			// 분류, 말머리, 비밀글, 공지, 전체 공지
+			&:nth-of-type(1) {
+				display: grid;
+				grid-template-columns: repeat(auto-fit, minmax(0, max-content));
+				justify-content: flex-start;
+				align-items: center;
+				gap: 0.4375rem;
+
+				article {
+					&:last-of-type {
+						margin-left: 0.625rem;
+					}
+
+					label {
+						padding-left: 0.625rem;
+						padding-right: 0.75rem;
+						cursor: pointer;
+					}
+				}
+			}
+
+			// 제목
+			&:nth-of-type(2) {
+				input {
+					width: 100%;
+				}
+			}
+
+			// CKEditor
+			&:nth-of-type(3) {
+				overflow: visible;
+				box-sizing: border-box;
+				margin-bottom: 0;
+
+				> textarea {
+					display: block;
+					width: 100%;
+					min-height: 18.75rem;
+					border-bottom-left-radius: 0;
+					border-bottom-right-radius: 0;
+				}
+			}
+
+			// 태그
+			&#postTag {
+				margin-bottom: 0;
+				padding: 0.5rem 0.625rem 0.4375rem 0.625rem;
+				border: 0.0625rem solid #ccced1;
+				border-width: 0 0.0625rem 0.0625rem 0.0625rem;
+				position: relative;
+				bottom: 0.0625rem;
+
+				> .tag-input {
+					display: flex;
+					flex-direction: row;
+					flex-wrap: wrap;
+					justify-content: flex-start;
+					align-items: center;
+					gap: 0.625rem;
+
+					> .tag {
+						border: 0.0625rem solid #ccced1;
+						border-radius: 0.125rem;
+						padding: 0.0625rem 0.625rem;
+						background-color: #f9fafb;
+
+						> small {
+							font-weight: normal;
+							color: #333;
+
+							&:hover {
+								text-decoration: underline;
+							}
+						}
+
+						> button {
+							display: inline-block;
+							vertical-align: text-bottom;
+							margin-left: 0.4375rem;
+							color: rgb(173, 0, 0);
+
+							&:hover {
+								color: red;
+							}
+						}
+					}
+
+					> input {
+						border: transparent;
+						flex-grow: 1;
+						flex-basis: 0;
+
+						&:hover,
+						&:focus {
+							border: transparent;
+							box-shadow: none;
+							outline: none;
+						}
+					}
+				}
+			}
+
+			// 비회원 글쓰기
+			&#anonymous {
+				display: grid;
+				grid-template-columns: repeat(auto-fit, minmax(0, max-content));
+				justify-content: flex-start;
+				align-items: center;
+				gap: 0.4375rem;
+				margin-top: 0.4375rem;
+			}
+
+			// 확인, 취소
+			&:last-of-type {
+				display: grid;
+				grid-template-columns: auto auto;
+				justify-content: center;
+				align-items: center;
+				gap: 0.4375rem;
+			}
+		}
+	}
+}

+ 354 - 0
app/(forum)/post/edit/[id]/view.tsx

@@ -0,0 +1,354 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useState, useEffect, useCallback, useRef, FormEvent } from 'react';
+import Loading from '@/app/component/Loading';
+import { BoardLayout, PostConst } from '@/constants/forum';
+import { fetchBoard } from '@/lib/api/forum/board';
+import { fetchPostUpdate } from '@/lib/api/forum/post';
+import { throwError } from '@/lib/utils/client';
+import BoardResponse from '@/dtos/response/forum/board/boardResponse';
+import BoardListResponse from '@/dtos/response/forum/board/boardListResponse';
+import PostResponse from '@/dtos/response/forum/post/postResponse';
+import Editor, { Handle } from '../../_component/Editor';
+import HeaderContent from '../../_component/HeaderContent';
+import FooterContent from '../../_component/FooterContent';
+import PostTagInput from '../../_component/PostTagInput';
+
+type Props = {
+	_boardList: BoardListResponse[],
+	_board: BoardResponse,
+	_post: PostResponse
+};
+
+export default function View({ _boardList, _board, _post }: Props)
+{
+	const router = useRouter();
+	const editorRef = useRef<Handle>(null);
+	const [error, setError] = useState<string|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+	const [isChanged, setIsChanged] = useState<boolean>(false);
+	const [board, setBoard] = useState<BoardResponse|null>(_board);
+	const [boardCode, setBoardCode] = useState<string>(_post.boardCode);
+	const [boardPrefixID, setBoardPrefixID] = useState<string>(_post.boardPrefixID?.toString() ?? '');
+	const [subject, setSubject] = useState<string>(_post.subject);
+	const [content, setContent] = useState<string>(_post.content);
+	const [isSecret, setIsSecret] = useState<boolean>(_post.isSecret);
+	const [isNotice, setIsNotice] = useState<boolean>(_post.isNotice);
+	const [isSpeaker, setIsSpeaker] = useState<boolean>(_post.isSpeaker);
+	const [tags, setTags] = useState<string[]>(_post.tagList.map((tag) => tag.slug));
+
+	const boardCodeRef = useRef<HTMLSelectElement>(null);
+	const boardPrefixIDRef = useRef<HTMLSelectElement>(null);
+	const subjectRef = useRef<HTMLInputElement>(null);
+	const contentRef = useRef<HTMLTextAreaElement>(null);
+	const redirectUrl = `/post/${_post.id}`;
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError(null);
+		}
+	}, [error]);
+
+	// 게시글 초기화
+	const resetForm = () => {
+		setError('');
+		setIsChanged(false);
+		setBoardPrefixID('');
+		setSubject(board?.boardMeta.write.defaultSubject || '');
+		setContent(board?.boardMeta.write.defaultContent || '');
+		setIsSecret(false);
+		setIsNotice(false);
+		setIsSpeaker(false);
+		setTags([]);
+
+		// Editor 초기화
+		if (editorRef.current?.editorInstance) {
+			editorRef.current.editorInstance.setData(content);
+		}
+	};
+
+	// 게시판 선택 시
+	const handleBoardChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+		const code = e.target.value;
+		if (code) {
+			return;
+		}
+
+		if (isChanged) {
+			if (!confirm('작성 중인 내용이 사라질 수 있습니다. 게시판을 변경하시겠습니까?')) {
+				return
+			}
+		}
+
+		setLoading(true);
+
+		fetchBoard(boardCode).then((res) => {
+			if (res.ok) {
+				setBoardCode(code);
+				setBoard(res.data);
+				setIsChanged(false);
+				resetForm();
+			} else {
+				throw new Error('게시판을 조회할 수 없습니다.');
+			}
+		}).catch((err) => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+	};
+
+	// 제목, 내용, 말머리 변경 시
+	const handleChange = (e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement>) => {
+		const { name, value } = e.target as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement;
+		const checked = (e.target as HTMLInputElement).checked;
+
+		switch (name) {
+			case 'boardPrefixID':
+				setBoardPrefixID(value);
+				break;
+			case 'isSecret':
+				setIsSecret(checked);
+				break;
+			case 'isNotice':
+				setIsNotice(checked);
+				setIsSpeaker(false);
+				break;
+			case 'isSpeaker':
+				setIsSpeaker(checked);
+				setIsNotice(false);
+				break;
+			case 'subject':
+				setSubject(value);
+				break;
+			case 'content':
+				setContent(value);
+				break;
+		}
+
+		setIsChanged(true);
+	};
+
+	// CKEditor에서 내용 변경 시
+	const handleEditorChange = useCallback((data: string) => {
+		setContent(data);
+		setIsChanged(true);
+	}, []);
+
+	const validate = () => {
+		if (!boardCode || !board) {
+			boardCodeRef.current!.focus();
+			throw new Error('게시판을 선택해주세요.');
+		}
+
+		if (board.boardMeta.write.allowPrefix && board.boardMeta.write.requiredPrefix && !boardPrefixID) {
+			boardPrefixIDRef.current!.focus();
+			throw new Error((board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + '를 선택해주세요.');
+		}
+
+		if (!subject) {
+			subjectRef.current!.focus();
+			throw new Error('제목을 입력해주세요.');
+		} else if (subject.length > PostConst.maxAllowedSubjectLength) {
+			subjectRef.current!.focus();
+			throw new Error(`제목은 ${PostConst.maxAllowedSubjectLength}자 이내로 작성해주세요.`);
+		}
+
+		if (!content) {
+			if (board.boardMeta.write.allowEditor) {
+				editorRef.current!.editorInstance?.editing.view.focus();
+			} else {
+				contentRef.current!.focus();
+			}
+
+			throw new Error('내용을 입력해주세요.');
+		} else if (!board.boardMeta.write.allowEditor) {
+			// 기본 textarea 사용 시 글자 수 검사
+			if (content.length > PostConst.maxAllowedContentLength) {
+				contentRef.current!.focus();
+				throw new Error(`내용은 ${PostConst.maxAllowedContentLength}자 이내로 작성해주세요.`);
+			}
+		}
+
+		if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
+			throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
+		}
+	};
+
+	// 게시글 등록 처리
+	const handleSubmit = useCallback(async (e: FormEvent) => {
+		e.preventDefault();
+
+		try {
+
+			validate();
+			setLoading(true);
+
+			if (!board) {
+				throw new Error('게시판을 선택해 주세요.');
+			}
+
+			const formData = new FormData();
+			formData.append('postID', _post.id.toString());
+			formData.append('boardID', board.id.toString());
+			formData.append('boardCode', boardCode);
+			formData.append('boardPrefixID', boardPrefixID);
+			formData.append('isSecret', isSecret.toString());
+			formData.append('isNotice', isNotice.toString());
+			formData.append('isSpeaker', isSpeaker.toString());
+			formData.append('subject', subject);
+
+			if (content) {
+				const doc = new DOMParser().parseFromString(content, 'text/html');
+				doc.querySelectorAll('img[src]').forEach(img => {
+					const src = img.getAttribute('src');
+					if (src && src.startsWith('data:image/')) {
+						img.setAttribute('src', 'data:image/');
+					}
+				});
+
+				formData.append('content', doc.body.innerHTML);
+			}
+
+			// 태그
+			if (board.boardMeta.write.allowTag) {
+				tags.forEach(tag => formData.append('tags', tag));
+			}
+
+			// 이미지 정보
+			if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowImage) {
+				editorRef.current?.getImageStore().forEach(i => {
+					if (i.image?.size > 0 && i.name) {
+						formData.append('images', i.image, i.name);
+					}
+				});
+			}
+
+			// 미디어 정보
+			if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowMedia) {
+				editorRef.current!.getMediaStore().forEach((m) => {
+					if (m.url) {
+						formData.append('medias', m.url);
+					}
+				});
+			}
+
+			// 첨부 파일
+			if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowFile) {
+				editorRef.current!.getFileStore().forEach(f => {
+					if (f?.size > 0 && f.name) {
+						formData.append('files', f.file, f.name);
+					}
+				});
+			}
+
+			const res = await fetchPostUpdate(formData);
+
+			if (res.ok) {
+				resetForm();
+				router.push(redirectUrl);
+			} else {
+				throwError(res);
+			}
+
+		} catch (err: any) {
+			setError(err.message);
+		} finally {
+			setLoading(false);
+		}
+	}, [boardCode, board, boardPrefixID, subject, content, isSecret, isNotice, isSpeaker, tags]);
+
+	return (
+		<form id='postEdit' onSubmit={handleSubmit}>
+			{loading && <Loading />}
+
+			<fieldset>
+				<legend><h1>{board?.name} 글 수정</h1></legend>
+
+				{/* 상단 안내 */}
+				{<HeaderContent isEnabled={board?.boardMeta.write.showHeader} content={board?.boardMeta.write.headerContent}/>}
+
+				{/* 게시판 선택, 말머리, 비밀글, 공지, 전체 공지 */}
+				<section>
+
+					{/* 게시판 선택 */}
+					<article>
+						<select name='boardCode' ref={boardCodeRef} value={boardCode} onChange={handleBoardChange} title='게시판 선택'>
+							{_boardList.map((board) => (
+								<option key={board.code} value={board.code}>{board.name}</option>
+							))}
+						</select>
+					</article>
+
+					{/* 말머리 */}
+					{board?.boardMeta.write.allowPrefix && (
+						<article>
+							<select name='boardPrefixID' ref={boardPrefixIDRef} value={boardPrefixID} onChange={handleChange} title='말머리 선택'>
+								<option value=''>{(board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + ' 선택'}</option>
+								{board.boardPrefix.map((row) => (
+									<option key={row.id} value={row.id}>{row.name}</option>
+								))}
+							</select>
+						</article>
+					)}
+
+					<article>
+						{/* 비밀글 */}
+						{board?.boardMeta.write.allowSecret && (
+							<>
+								<input type='checkbox' name='isSecret' id='isSecret' checked={isSecret} onChange={handleChange} />
+								<label htmlFor='isSecret'>비밀글</label>
+							</>
+						)}
+
+						{/* 해당 게시판 공지 */}
+						<input type='checkbox' name='isNotice' id='isNotice' checked={isNotice} onChange={handleChange} />
+						<label htmlFor='isNotice'>공지</label>
+
+						{/* 게시판 전체 공지 */}
+						<input type='checkbox' name='isSpeaker' id='isSpeaker' checked={isSpeaker} onChange={handleChange} />
+						<label htmlFor='isSpeaker'>전체 공지</label>
+					</article>
+				</section>
+
+				{/* 제목 */}
+				<section>
+					<input type='text' name='subject' ref={subjectRef} value={subject} onChange={handleChange} placeholder='글 제목을 입력해주세요.' autoFocus maxLength={PostConst.maxAllowedSubjectLength} />
+				</section>
+
+				{/* 내용 */}
+				<section>
+					{board?.boardMeta.write.allowEditor ?
+					(
+						<Editor ref={editorRef} key={boardCode} data={content} onChange={handleEditorChange} boardMeta={board?.boardMeta} />
+					) : (
+						<textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.maxAllowedContentLength}></textarea>
+					)}
+				</section>
+
+				{/* 태그 */}
+				{board?.boardMeta.write.allowTag && (
+					<section id='postTag'>
+						<PostTagInput value={tags} onChange={setTags} maxTags={board.boardMeta.write.tagLimit} />
+					</section>
+				)}
+
+				{/* 하단 안내 */}
+				{<FooterContent isEnabled={board?.boardMeta.write.showFooter} content={board?.boardMeta.write.footerContent}/>}
+
+				<br/>
+
+				<section>
+					<button type='submit' className='btn btn-submit' disabled={loading}>
+						{ loading ? '수정 중…' : '확인' }
+					</button>
+					<Link href={redirectUrl} className='btn btn-default'>취소</Link>
+				</section>
+			</fieldset>
+		</form>
+	);
+}

+ 17 - 0
app/(forum)/post/write/page.tsx

@@ -0,0 +1,17 @@
+'use server';
+
+import View from './view';
+import { forbidden } from 'next/navigation';
+import { isAuthenticated } from '@/lib/api/auth';
+
+export default async function PostWrite()
+{
+	// 로그인 필요
+	if (!await isAuthenticated()) {
+		return forbidden();
+	}
+
+    return (
+		<View />
+	);
+}

+ 156 - 0
app/(forum)/post/write/style.scss

@@ -0,0 +1,156 @@
+#postWrite {
+	padding: 1.5625rem 2rem 2rem 2rem;
+
+	h1 {
+		font-size: 1.375rem;
+		margin-bottom: 1.25rem;
+	}
+
+	min-width: 25.75rem;
+	max-width: 80rem;
+	margin: 0 auto;
+
+	fieldset {
+		display: flex;
+		flex-direction: column;
+		gap: 0;
+		min-width: 0;
+
+		section {
+			margin-bottom: 0.4375rem;
+
+			select, input, textarea {
+				padding: 0.625rem;
+				border-color: #ccced1;
+				border-width: 0.0625rem;
+
+				&:focus {
+					border: 0.0625rem solid hsl(218, 81.8%, 56.9%);
+					box-shadow: 0.125rem 0.125rem 0.1875rem rgba(0, 0, 0, .1) inset, 0 0;
+					outline: none;
+				}
+			}
+
+			// 분류, 말머리, 비밀글, 공지, 전체 공지
+			&:nth-of-type(1) {
+				display: grid;
+				grid-template-columns: repeat(auto-fit, minmax(0, max-content));
+				justify-content: flex-start;
+				align-items: center;
+				gap: 0.4375rem;
+
+				article {
+					&:last-of-type {
+						margin-left: 0.625rem;
+					}
+
+					label {
+						padding-left: 0.625rem;
+						padding-right: 0.75rem;
+						cursor: pointer;
+					}
+				}
+			}
+
+			// 제목
+			&:nth-of-type(2) {
+				input {
+					width: 100%;
+				}
+			}
+
+			// CKEditor
+			&:nth-of-type(3) {
+				overflow: visible;
+				box-sizing: border-box;
+				margin-bottom: 0;
+
+				> textarea {
+					display: block;
+					width: 100%;
+					min-height: 18.75rem;
+					border-bottom-left-radius: 0;
+					border-bottom-right-radius: 0;
+				}
+			}
+
+			// 태그
+			&#postTag {
+				margin-bottom: 0;
+				padding: 0.5rem 0.625rem 0.4375rem 0.625rem;
+				border: 0.0625rem solid #ccced1;
+				border-width: 0 0.0625rem 0.0625rem 0.0625rem;
+				position: relative;
+				bottom: 0.0625rem;
+
+				> .tag-input {
+					display: flex;
+					flex-direction: row;
+					flex-wrap: wrap;
+					justify-content: flex-start;
+					align-items: center;
+					gap: 0.625rem;
+
+					> .tag {
+						border: 0.0625rem solid #ccced1;
+						border-radius: 0.125rem;
+						padding: 0.0625rem 0.625rem;
+						background-color: #f9fafb;
+
+						> small {
+							font-weight: normal;
+							color: #333;
+
+							&:hover {
+								text-decoration: underline;
+							}
+						}
+
+						> button {
+							display: inline-block;
+							vertical-align: text-bottom;
+							margin-left: 0.4375rem;
+							color: rgb(173, 0, 0);
+
+							&:hover {
+								color: red;
+							}
+						}
+					}
+
+					> input {
+						border: transparent;
+						flex-grow: 1;
+						flex-basis: 0;
+
+						&:hover,
+						&:focus {
+							border: transparent;
+							box-shadow: none;
+							outline: none;
+						}
+					}
+				}
+			}
+
+			// 비회원 글쓰기
+			&#anonymous {
+				display: grid;
+				grid-template-columns: repeat(auto-fit, minmax(0, max-content));
+				justify-content: flex-start;
+				align-items: center;
+				gap: 0.4375rem;
+				margin-top: 0.4375rem;
+			}
+
+			// 확인, 취소
+			&:last-of-type {
+				display: grid;
+				grid-template-columns: auto auto;
+				justify-content: center;
+				align-items: center;
+				gap: 0.4375rem;
+			}
+		}
+	}
+}

+ 377 - 0
app/(forum)/post/write/view.tsx

@@ -0,0 +1,377 @@
+'use client';
+
+import './style.scss';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { useState, useEffect, useCallback, useRef, FormEvent } from 'react';
+import Loading from '@/app/component/Loading';
+import { BoardLayout, PostConst } from '@/constants/forum';
+import { fetchBoard, fetchBoardList } from '@/lib/api/forum/board';
+import { fetchPostCreate } from '@/lib/api/forum/post';
+import { throwError } from '@/lib/utils/client';
+import boardResponse from '@/dtos/response/forum/board/boardResponse';
+import BoardListResponse from '@/dtos/response/forum/board/boardListResponse';
+import Editor, { Handle } from '../_component/Editor';
+import HeaderContent from '../_component/HeaderContent';
+import FooterContent from '../_component/FooterContent';
+import PostTagInput from '../_component/PostTagInput';
+
+export default function View()
+{
+	const router = useRouter();
+	const editorRef = useRef<Handle>(null);
+	const [error, setError] = useState<string|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+	const [isChanged, setIsChanged] = useState<boolean>(false);
+	const [board, setBoard] = useState<boardResponse|null>(null);
+	const [boardList, setBoardList] = useState<BoardListResponse[]>([]);
+	const [boardCode, setBoardCode] = useState<string>('');
+	const [boardPrefix, setBoardPrefix] = useState<string>('');
+	const [subject, setSubject] = useState<string>('');
+	const [content, setContent] = useState<string>('');
+	const [isSecret, setIsSecret] = useState<boolean>(false);
+	const [isNotice, setIsNotice] = useState<boolean>(false);
+	const [isSpeaker, setIsSpeaker] = useState<boolean>(false);
+	const [tags, setTags] = useState<string[]>([]);
+
+	const boardCodeRef = useRef<HTMLSelectElement>(null);
+	const boardPrefixRef = useRef<HTMLSelectElement>(null);
+	const subjectRef = useRef<HTMLInputElement>(null);
+	const contentRef = useRef<HTMLTextAreaElement>(null);
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError(null);
+		}
+	}, [error]);
+
+	useEffect(() => {
+		const hash = window.location.hash;
+		if (hash) {
+			setBoardCode(hash.substring(1));
+		}
+	}, []);
+
+	useEffect(() => {
+		if (boardCode) {
+			setLoading(true);
+
+			// 게시판 상세 정보 호출
+			fetchBoard(boardCode).then((res) => {
+				if (res.ok) {
+					setBoard(res.data);
+				} else {
+					throw new Error('게시판을 조회할 수 없습니다.');
+				}
+			}).catch((err) => {
+				setError(err.message);
+			}).finally(() => {
+				setLoading(false);
+			});
+		}
+	}, [boardCode]);
+
+	// 게시판 변경 시
+	useEffect(() => {
+		if (board) {
+			resetForm();
+
+			// 게시판 목록 호출
+			if (boardList.length <= 0) {
+				fetchBoardList(board.boardGroup.code).then((res) => {
+					if (res.ok) {
+						if (res.data && res.data.length >= 0) {
+							setBoardList(res.data);
+						}
+					} else {
+						throw new Error('게시판을 조회할 수 없습니다.');
+					}
+				}).catch((err) => {
+					setError(err.message);
+				}).finally(() => {
+					setLoading(false);
+				});
+			}
+		}
+	}, [board]);
+
+	// 게시글 초기화
+	const resetForm = () => {
+		setError('');
+		setIsChanged(false);
+		setBoardPrefix('');
+		setSubject(board?.boardMeta.write.defaultSubject || '');
+		setContent(board?.boardMeta.write.defaultContent || '');
+		setIsSecret(false);
+		setIsNotice(false);
+		setIsSpeaker(false);
+		setTags([]);
+
+		// Editor 초기화
+		if (editorRef.current?.editorInstance) {
+			editorRef.current.editorInstance.setData(content);
+		}
+	};
+
+	// 게시판 선택 시
+	const handleBoardChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+		const code = e.target.value;
+
+		if (isChanged) {
+			if (!confirm('작성 중인 내용이 사라질 수 있습니다. 게시판을 변경하시겠습니까?')) {
+				return
+			}
+		}
+
+		setBoardCode(code);
+		setIsChanged(false);
+	};
+
+	// 제목, 내용, 말머리 변경 시
+	const handleChange = (e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement>) => {
+		const { name, value } = e.target as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement;
+		const checked = (e.target as HTMLInputElement).checked;
+
+		switch (name) {
+			case 'boardPrefix':
+				setBoardPrefix(value);
+				break;
+			case 'isSecret':
+				setIsSecret(checked);
+				break;
+			case 'isNotice':
+				setIsNotice(checked);
+				setIsSpeaker(false);
+				break;
+			case 'isSpeaker':
+				setIsSpeaker(checked);
+				setIsNotice(false);
+				break;
+			case 'subject':
+				setSubject(value);
+				break;
+			case 'content':
+				setContent(value);
+				break;
+		}
+
+		setIsChanged(true);
+	};
+
+	// CKEditor에서 내용 변경 시
+	const handleEditorChange = useCallback((data: string) => {
+		setContent(data);
+		setIsChanged(true);
+	}, []);
+
+	const validate = () => {
+		if (!boardCode || !board) {
+			boardCodeRef.current!.focus();
+			throw new Error('게시판을 선택해주세요.');
+		}
+
+		if (board.boardMeta.write.allowPrefix && board.boardMeta.write.requiredPrefix && !boardPrefix) {
+			boardPrefixRef.current!.focus();
+			throw new Error((board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + '를 선택해주세요.');
+		}
+
+		if (!subject) {
+			subjectRef.current!.focus();
+			throw new Error('제목을 입력해주세요.');
+		} else if (subject.length > PostConst.MaxAllowedSubjectLength) {
+			subjectRef.current!.focus();
+			throw new Error(`제목은 ${PostConst.MaxAllowedSubjectLength}자 이내로 작성해주세요.`);
+		}
+
+		if (!content) {
+			if (board.boardMeta.write.allowEditor) {
+				editorRef.current!.editorInstance?.editing.view.focus();
+			} else {
+				contentRef.current!.focus();
+			}
+
+			throw new Error('내용을 입력해주세요.');
+		} else if (!board.boardMeta.write.allowEditor) {
+			// 기본 textarea 사용 시 글자 수 검사
+			if (content.length > PostConst.MaxAllowedContentLength) {
+				contentRef.current!.focus();
+				throw new Error(`내용은 ${PostConst.MaxAllowedContentLength}자 이내로 작성해주세요.`);
+			}
+		}
+
+		if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
+			throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
+		}
+	};
+
+	// 게시글 등록 처리
+	const handleSubmit = useCallback(async (e: FormEvent) => {
+		e.preventDefault();
+
+		try {
+
+			validate();
+			setLoading(true);
+
+			if (!board) {
+				throw new Error('게시판을 선택해 주세요.');
+			}
+
+			const formData = new FormData();
+			formData.append('boardID', String(board.id));
+			formData.append('boardCode', boardCode);
+			formData.append('boardPrefixID', boardPrefix);
+			formData.append('isSecret', String(isSecret));
+			formData.append('isNotice', String(isNotice));
+			formData.append('isSpeaker', String(isSpeaker));
+			formData.append('subject', subject);
+
+			if (content) {
+				const doc = new DOMParser().parseFromString(content, 'text/html');
+				doc.querySelectorAll('img[src]').forEach(img => {
+					img.setAttribute('src', 'data:image/');
+				});
+
+				formData.append('content', doc.body.innerHTML);
+			}
+
+			// 태그
+			if (board.boardMeta.write.allowTag) {
+				tags.forEach(tag => formData.append('tags', tag));
+			}
+
+			// 이미지 정보
+			if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowImage) {
+				editorRef.current?.getImageStore().forEach(i => {
+					if (i.image?.size > 0 && i.name) {
+						formData.append('images', i.image, i.name);
+					}
+				});
+			}
+
+			// 미디어 정보
+			if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowMedia) {
+				editorRef.current!.getMediaStore().forEach((m) => {
+					if (m.url) {
+						formData.append('medias', m.url);
+					}
+				});
+			}
+
+			// 첨부 파일
+			if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowFile) {
+				editorRef.current!.getFileStore().forEach(f => {
+					if (f?.size > 0 && f.name) {
+						formData.append('files', f.file, f.name);
+					}
+				});
+			}
+
+			const res = await fetchPostCreate(formData);
+
+			if (res.ok) {
+				resetForm();
+				router.push(`/post/${res.data!.id}`);
+			} else {
+				throwError(res);
+			}
+
+		} catch (err: any) {
+			setError(err.message);
+		} finally {
+			setLoading(false);
+		}
+	}, [boardCode, board, boardPrefix, subject, content, isSecret, isNotice, isSpeaker, tags]);
+
+	return (
+		<form id='postWrite' onSubmit={handleSubmit}>
+			{loading && <Loading />}
+
+			<fieldset>
+				<legend><h1>{board?.name} 글쓰기</h1></legend>
+
+				{/* 상단 안내 */}
+				{<HeaderContent isEnabled={board?.boardMeta.write.showHeader} content={board?.boardMeta.write.headerContent}/>}
+
+				{/* 게시판 선택, 말머리, 비밀글, 공지, 전체 공지 */}
+				<section>
+
+					{/* 게시판 선택 */}
+					<article>
+						<select name='boardCode' ref={boardCodeRef} value={boardCode} onChange={handleBoardChange} title='게시판 선택'>
+							<option value=''>게시판 선택</option>
+							{boardList.map((board) => (
+								<option key={board.code} value={board.code}>{board.name}</option>
+							))}
+						</select>
+					</article>
+
+					{/* 말머리 */}
+					{board?.boardMeta.write.allowPrefix && (
+						<article>
+							<select name='boardPrefix' ref={boardPrefixRef} value={boardPrefix} onChange={handleChange} title='말머리 선택'>
+								<option value=''>{(board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + ' 선택'}</option>
+								{board?.boardPrefix.map((row) => (
+									<option key={row.id} value={row.id}>{row.name}</option>
+								))}
+							</select>
+						</article>
+					)}
+
+					<article>
+						{/* 비밀글 */}
+						{board?.boardMeta.write.allowSecret && (
+							<>
+								<input type='checkbox' name='isSecret' id='isSecret' checked={isSecret} onChange={handleChange} />
+								<label htmlFor='isSecret'>비밀글</label>
+							</>
+						)}
+
+						{/* 해당 게시판 공지 */}
+						<input type='checkbox' name='isNotice' id='isNotice' checked={isNotice} onChange={handleChange} />
+						<label htmlFor='isNotice'>공지</label>
+
+						{/* 게시판 전체 공지 */}
+						<input type='checkbox' name='isSpeaker' id='isSpeaker' checked={isSpeaker} onChange={handleChange} />
+						<label htmlFor='isSpeaker'>전체 공지</label>
+					</article>
+				</section>
+
+				{/* 제목 */}
+				<section>
+					<input type='text' name='subject' ref={subjectRef} value={subject} onChange={handleChange} placeholder='글 제목을 입력해주세요.' autoFocus maxLength={PostConst.MaxAllowedSubjectLength} />
+				</section>
+
+				{/* 내용 */}
+				<section>
+					{board?.boardMeta.write.allowEditor ?
+					(
+						<Editor ref={editorRef} key={boardCode} data={content} onChange={handleEditorChange} boardMeta={board?.boardMeta} />
+					) : (
+						<textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.MaxAllowedContentLength}></textarea>
+					)}
+				</section>
+
+				{/* 태그 */}
+				{board?.boardMeta.write?.allowTag && (
+					<section id='postTag'>
+						<PostTagInput value={tags} onChange={setTags} maxTags={board.boardMeta.write.tagLimit} />
+					</section>
+				)}
+
+				{/* 하단 안내 */}
+				{<FooterContent isEnabled={board?.boardMeta.write.showFooter} content={board?.boardMeta.write.footerContent}/>}
+
+				<br/>
+
+				<section>
+					<button type='submit' className='btn btn-submit' disabled={loading}>
+						{ loading ? '등록 중…' : '확인' }
+					</button>
+					<Link href={`/board/${boardCode || 'latest'}`} className='btn btn-default'>취소</Link>
+				</section>
+			</fieldset>
+		</form>
+	);
+}

+ 70 - 0
app/api/auth/[slug]/route.ts

@@ -0,0 +1,70 @@
+
+import { type NextRequest, NextResponse } from 'next/server';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: { slug: string } }) {
+	const { slug } = await params;
+
+	switch (slug) {
+		default:
+            return new Response(JSON.stringify({ message: '잘못된 요청입니다.' }), { status: 400 });
+
+		// accessToken 검증
+		case 'verify-token': {
+			const res = await fetchJson('/api/auth/verify-token', {
+				method: 'GET',
+				headers: {'Content-Type': 'application/json'},
+				data: request,
+				withCredentials: true
+			});
+
+			return new NextResponse(JSON.stringify(res), {
+				status: 200
+			});
+		}
+
+		// refreshToken 검증
+		case 'refresh-token': {
+			const res = await fetchJson('/api/auth/refresh-token', {
+				method: 'GET',
+				headers: {'Content-Type': 'application/json'},
+				data: request,
+				withCredentials: true
+			});
+
+			return new NextResponse(JSON.stringify(res), {
+				status: 200
+			});
+		}
+	}
+}
+
+export async function POST(request: NextRequest, { params }: { params: { slug: string } }) {
+	const { slug } = await params;
+
+	switch (slug) {
+		default:
+            return new Response(JSON.stringify({ message: '잘못된 요청입니다.' }), { status: 400 });
+
+		// 로그아웃
+		case 'logout': {
+			const res = await fetchJson('/api/auth/logout', {
+				method: 'GET',
+				headers: {'Content-Type': 'application/json'},
+				withCredentials: true
+			});
+
+			const headers = new Headers();
+
+			if (res.ok) {
+				headers.append('Set-Cookie', 'accessToken=; HttpOnly; Path=/; Max-Age=0;');
+				headers.append('Set-Cookie', 'refreshToken=; HttpOnly; Path=/; Max-Age=0;');
+			}
+
+			return new NextResponse(null, {
+				status: 200,
+				headers: headers
+			});
+		}
+	}
+}

+ 10 - 0
app/api/ping/route.ts

@@ -0,0 +1,10 @@
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET() {
+
+	const res = await fetch('https://localhost:4000/api/ping', {
+		method: 'GET',
+	}).then(r => r.json());
+
+	return new Response(res.message);
+}

+ 18 - 0
app/component/AuthStatus.tsx

@@ -0,0 +1,18 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import useAuth from "@/hooks/useAuth";
+
+export default function AuthStatus()
+{
+	const router = useRouter();
+    const { isAuthenticated } = useAuth();
+
+    if (isAuthenticated) {
+        return router.push('/');
+    }
+
+    return (
+        <></>
+    );
+}

+ 84 - 0
app/component/Editor.tsx

@@ -0,0 +1,84 @@
+'use client';
+
+/**
+ * @author: Kim Jino
+ * @since: 2025.03.31
+ * @description: CKEditor component for Next.js using Script component for loading CKEditor script
+ */
+
+// @/app/component/editor.tsx
+
+import '@/styles/editor.scss';
+import Script from 'next/script';
+import React, { useRef, useState, useEffect } from 'react';
+import { CKEditor } from '@ckeditor/ckeditor5-react';
+import Loading from '@/app/component/Loading';
+
+interface EditorProps {
+	data?: string;
+	onChange?: (data: string) => void;
+}
+
+export default function Editor({ data = '', onChange }: EditorProps)
+{
+	const editorRef = useRef<typeof window.Editor|null>(null);
+	const [ready, setReady] = useState(false);
+
+	useEffect(() => {
+		if (typeof window !== 'undefined' && window.Editor) {
+			editorRef.current = window.Editor;
+			setReady(true);
+		}
+	}, []);
+
+	const handleScriptLoad = () => {
+		if (typeof window !== 'undefined' && window.Editor) {
+			editorRef.current = window.Editor;
+			setReady(true);
+		} else {
+			console.error('CKEditor script not loaded properly.');
+		}
+	};
+
+	return (
+		<>
+			<Script src="/editor/editor.min.js" strategy="afterInteractive" onLoad={handleScriptLoad}/>
+
+			{ready && editorRef.current ? (
+				<CKEditor
+					editor={editorRef.current}
+					data={data}
+					config={{
+						placeholder: '내용을 입력하세요.',
+						maxWordCount: 3000,
+
+						// 이미지 설정
+						allowImage: true,
+						imageUploadLimit: 1,
+						imageUploadMaxSize: 5120,
+
+						// 미디어어 설정
+						allowMedia: true,
+						mediaUploadLimit: 1
+					}}
+					onReady={(editor) => {
+						console.log('Editor was initialized');
+
+						// 최소 높이 설정
+						editor.editing.view.change(writer => {
+							const root = editor.editing.view.document.getRoot();
+							if (root) {
+								writer.setStyle('min-height', '300px', root);
+							}
+						});
+					}}
+					onChange={(_, editor) => {
+						onChange?.(editor.getData());
+					}}
+				/>
+			) : (
+				<Loading/>
+			)}
+		</>
+	);
+}

+ 81 - 0
app/component/EmojiPicker.tsx

@@ -0,0 +1,81 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import { useState, useRef, useEffect } from 'react';
+import { EmojiClickData, EmojiStyle, Categories } from 'emoji-picker-react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faFaceSmile } from '@fortawesome/free-regular-svg-icons';
+import '@/styles/emoji-picker.scss';
+
+const Picker = dynamic(
+	() => {
+		return import('emoji-picker-react');
+	},
+	{ ssr: false }
+);
+
+type Props = {
+	onEmojiSelect: (emoji: string) => void;
+};
+
+export default function EmojiPicker({ onEmojiSelect }: Props) {
+	const [showPicker, setShowPicker] = useState(false);
+	const wrapperRef = useRef<HTMLDivElement>(null);
+
+	const handleEmojiClick = (emojiData: EmojiClickData) => {
+		onEmojiSelect(emojiData.emoji);
+		setShowPicker(false); // 선택 후 자동으로 닫기
+	};
+
+	useEffect(() => {
+		const handleClickOutside = (event: MouseEvent) => {
+			if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
+				setShowPicker(false);
+			}
+		};
+
+		if (showPicker) {
+			document.addEventListener('mousedown', handleClickOutside);
+		} else {
+			document.removeEventListener('mousedown', handleClickOutside);
+		}
+
+		return () => {
+			document.removeEventListener('mousedown', handleClickOutside);
+		};
+	}, [showPicker]);
+
+	return (
+		<>
+			<div id='emojiPicker' ref={wrapperRef}>
+				<button type="button" onClick={() => setShowPicker(prev => !prev)} title='이모지 선택'>
+					<FontAwesomeIcon icon={faFaceSmile} />
+				</button>
+
+				{showPicker && (
+					<Picker
+						searchPlaceholder="그림 이모니콘 검색"
+						onEmojiClick={handleEmojiClick}
+						emojiStyle={EmojiStyle.NATIVE}
+						previewConfig={{ 
+							showPreview: false 
+						}}
+						lazyLoadEmojis={true}
+						width="30rem"
+						height="20rem" 
+						categories={[
+							{ category: Categories.SMILEYS_PEOPLE, name: '인물' },
+							{ category: Categories.ANIMALS_NATURE, name: '자연' },
+							{ category: Categories.FOOD_DRINK, name: '음식' },
+							{ category: Categories.TRAVEL_PLACES, name: '여행' },
+							{ category: Categories.ACTIVITIES, name: '활동' },
+							{ category: Categories.OBJECTS, name: '사물' },
+							{ category: Categories.SYMBOLS, name: '기호' },
+							{ category: Categories.FLAGS, name: '국기' }
+						]}
+					/>
+				)}
+			</div>
+		</>
+	);
+}

+ 104 - 0
app/component/Layout.tsx

@@ -0,0 +1,104 @@
+'use client';
+
+import Link from 'next/link';
+import Styles from '../styles/common.module.scss';
+import useAuth from '@/hooks/useAuth';
+
+export default function Layout({ children }: { children: React.ReactNode })
+{
+	const { isAuthenticated, isLoading, logout } = useAuth();
+
+	if (isLoading) {
+		return <></>;
+	}
+
+    return (
+        <>
+            <div id='container' className={Styles.container}>
+
+                {/* 상단 내용 */}
+                <header id='header' className={`${Styles.header} flex items-center justify-between w-full py-2 px-4`}>
+                    <Link href='/' className='font-bold text-xl'>
+                        Bitforum
+                    </Link>
+                    <nav className='flex grow items-center justify-between'>
+                        <ul className='flex gap-4'>
+                            <li>
+                                <Link href='/'>
+                                    가상화폐
+                                </Link>
+                            </li>
+                            <li>
+                                <Link href='/board/latest'>
+                                    토론실
+                                </Link>
+                            </li>
+                            <li>
+                                <Link href='/news'>
+                                    새소식
+                                </Link>
+                            </li>
+                            <li>
+                                <Link href='/support/notice'>
+                                    고객지원
+                                </Link>
+                            </li>
+                        </ul>
+						{!isAuthenticated ? (
+							<ul className='flex gap-4'>
+								<li>
+									<a href='/login'>
+										로그인
+									</a>
+								</li>
+								<li>
+									<a href='/register'>
+										회원가입
+									</a>
+								</li>
+							</ul>
+						) : (
+							<ul className='flex gap-4'>
+								<li>
+									<Link href='/profile'>
+										내 정보
+									</Link>
+								</li>
+								<li>
+									<button type='button' onClick={logout}>
+										로그아웃
+									</button>
+								</li>
+							</ul>
+						)}
+                    </nav>
+                </header>
+
+                {/* 전광판 */}
+                <p id='ticker' className={Styles.panel}></p>
+
+                {/* 좌측 내용 */}
+                <main id='main' className={`${Styles.main} relative`}>
+                    {children}
+                </main>
+
+                {/* 우측 내용 */}
+                <aside id='aside' className={Styles.aside}></aside>
+
+                {/* 하단 내용 */}
+                <footer id='footer' className={`${Styles.footer} px-4`}>
+                    <ol>
+                        {/* 최신글 표시 ▼ */}
+                        <li>111111</li>
+
+                        {/* 공지사항 표시 ▲ */}
+                        <li>222222</li>
+
+                        {/* 저작권 표시 */}
+                        <li>© 2025 PLAYR. All rights reserved.</li>
+                    </ol>
+                </footer>
+            </div>
+        </>
+    );
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini