diff --git a/.gitignore b/.gitignore
index c2065bc..54b6590 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,16 @@ out/
### VS Code ###
.vscode/
+
+### db ###
+*.mv.db
+*.trace.db
+*.sql
+
+### env ###
+/backend/.env.properties
+# env files (all environments)
+.env
+backend/.env.properties
+*.env
+.env.*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9b90498
--- /dev/null
+++ b/README.md
@@ -0,0 +1,308 @@
+# ๐งถ Knitly - Online Knitting Pattern & Community Platform
+
+> ์ทจ๋ฏธ๋ก ๋จ๊ฐ์ง์ ์ฆ๊ธฐ๋ ์ฌ๋๋ค์ด ์์ ๋กญ๊ฒ ๋์์ ์ ์ํ๊ณ ํ๋งคํ๋ฉฐ,
+> ์๋ก์ ์ฐฝ์๋ฌผ์ ๊ณต์ ํ ์ ์๋ ์ปค๋ฎค๋ํฐ ๊ธฐ๋ฐ ํ๋ซํผ์
๋๋ค.
+> ๋จ์ํ ๋์ ํ๋งค๋ฅผ ๋์ด, ์ ์์์ ์๋น์๊ฐ ํจ๊ป ์ฑ์ฅํ๋ ์ฐฝ์ ์ํ๊ณ๋ฅผ ์งํฅํฉ๋๋ค.
+---
+## โจ ํ๋ก์ ํธ ๊ฐ์
+
+* **ํ๋ก์ ํธ๋ช
:** Knitly
+* **์ฃผ์ :** ๋จ๊ฐ์ง ๋์ ํ๋งค ๋ฐ ์ปค๋ฎค๋ํฐ ์๋น์ค
+* **์ฃผ์ ์ ์ ์ด์ :**
+ โ์ทจ๋ฏธ๋ก ๋จ๊ฐ์ง์ ํ๋ ์ฌ๋๋ค์ด ์์ ๋กญ๊ฒ ๋์์ ์ ์ํ๊ณ ๊ณต์ ํ ์ ์๋ ํ๋ซํผ์ด ์์ผ๋ฉด ์ข๊ฒ ๋ค๋ ์์ด๋์ด์์ ์ถ๋ฐํ์์ต๋๋ค.โ
+* **๊ฐ๋ฐ ๋ชฉํ:**
+
+ * ์บ์ฑ๊ณผ ๋ฝ์ ํตํ **์ฑ๋ฅ ํฅ์ ๋ฐ ๋ฐ์ดํฐ ์ผ๊ด์ฑ ํ๋ณด**
+ * ์๋น์ค ๊ฐ **๋ฐ์ดํฐ ํ๋ฆ ๋ฐ ๋น๋๊ธฐ ์ฒ๋ฆฌ ๊ตฌํ**
+ * **Redis ๊ธฐ๋ฐ**์ ์ธ๊ธฐ์ ์กฐํ, ๋์์ฑ ์ ์ด, ์ฐ ๊ธฐ๋ฅ ๊ด๋ฆฌ
+ * **์ธ๋ถ API ์ฐ๋** (ํ ์คํ์ด๋จผ์ธ , OAuth 2.0, PDF ๋ณํ ๋ฑ)
+ * **์์ธ ์ฒ๋ฆฌ ๊ฐํ ๋ฐ ํ
์คํธ ์ฝ๋ ๊ฒ์ฆ**
+ * ์ ์ฒด ์๋น์ค์ **์์ ์ฑ ๋ฐ ์ ์ง๋ณด์์ฑ ํ๋ณด**
+
+---
+
+## ๐ป ๊ธฐ์ ์ ํน์ง
+
+| ๋ถ๋ฅ | ๊ธฐ์ ์คํ |
+| ------------------ | ------------------------------------------------------- |
+| **Backend** | Spring Boot 3.5.x, Java 17, JPA(Hibernate), MySQL |
+| **Infra** | AWS EC2, Docker, Redis, Nginx |
+| **API** | OAuth 2.0 (Google Login), Toss Payments API, Swagger UI |
+| **DevOps** | GitHub Actions (CI/CD), Docker Compose |
+| **Test & Monitor** | JUnit5, MockMvc, Prometheus, Jmeter, NGrinder |
+| **Tooling** | IntelliJ, Postman, DBeaver, Slack, Notion |
+
+
+
+---
+
+## ๐งฉ ์ฃผ์ ๊ธฐ๋ฅ ์์ฝ
+
+| ๊ตฌ๋ถ | ๊ธฐ๋ฅ |
+| -------------- | -------------------------------------------- |
+| **ํ์๊ฐ์
/ ๋ก๊ทธ์ธ** | ๊ตฌ๊ธ OAuth 2.0 ๋ก๊ทธ์ธ, JWT ๊ธฐ๋ฐ ์ธ์ฆ/์ธ๊ฐ |
+| **๋์ ์ ์** | 10x10 ๊ฒฉ์(Grid) ๊ธฐ๋ฐ UI์์ ๋์ ์ ์ โ PDF ๋ณํ ์ ์ฅ |
+| **์ํ ํ๋งค** | ์ ์ํ ๋์ ๋๋ PDF ์
๋ก๋ / ๋ฌด๋ฃยทํ์ ํ๋งค ์ง์ |
+| **์ํ ๊ตฌ๋งค** | Queue ๊ธฐ๋ฐ ์ด๋ฉ์ผ ์๋ ๋ฐ์ก / Redis ๋ฝ์ผ๋ก ์ฌ๊ณ ๊ด๋ฆฌ |
+| **์ํ ์กฐํ** | Redis ZSet์ผ๋ก ์ธ๊ธฐ์ ์ ๋ ฌ / ์นดํ
๊ณ ๋ฆฌ๋ณยท๊ฐ๊ฒฉ์ยท์ต์ ์ |
+| **์ฐ ๋ฐ ๋ฆฌ๋ทฐ** | Redis ์ฐ ์นด์ดํธ / Rabbit Queue ๊ธฐ๋ฐ DB ๋๊ธฐํ / ๋ฆฌ๋ทฐ ์์ฑ |
+| **์ปค๋ฎค๋ํฐ** | ๊ฒ์๊ธ ๋ฐ ๋๊ธ CRUD / ์ํํธ ๋๋ฆฌํธ ๊ธฐ๋ฐ ๊ด๋ฆฌ |
+| **๋ง์ดํ์ด์ง** | ๊ตฌ๋งค๋ด์ญ, ์ฐ๋ชฉ๋ก, ์ด๋ฒคํธ ์ฐธ์ฌ๋ด์ญ, ๊ตฌ๋
๊ด๋ฆฌ |
+| **๊ฒฐ์ ** | Toss Payments API ์ฐ๋ / ๋ชจ์๊ฒฐ์ ์ง์ |
+| **์ธ์ฆ/์ธ๊ฐ** | JWT ๊ธฐ๋ฐ ์ธ์ฆ ํํฐ / ์กฐํ ์ธ ๋ชจ๋ API ํ ํฐ ๊ฒ์ฆ |
+---
+
+# ํ์
+|[๊น์์ง](https://github.com/dpwls8984)|[๊น์ํ](https://github.com/SiHejt)|[๋์
์ฒ ](https://github.com/No-366)|[๋ถ์ข
์ฐ](https://github.com/Boojw)|[์ ํ์ฐ](https://github.com/hznnoy)|
+|:-:|:-:|:-:|:-:|:-:|
+|
|
|
|
|
|
+|BE, FE|BE, FE|BE, FE|BE, FE|BE, FE|
+
+
+
+## ๐ ๏ธ ์ญํ ๋ถ๋ด
+
+| ์ด๋ฆ | ๋ด๋น ๊ธฐ๋ฅ |
+|--------|-----------|
+| **๊น์์ง** | - ์ํ ๊ตฌ๋งค(Redis, ๋ถ์ฐ ๋ฝ)
- ์ํ ํ๋งค |
+| **๊น์ํ** | - ์ํ ๋ฆฌ๋ทฐ
- ์ํ ์ฐ(Redis & RabbitMQ) |
+| **๋์
์ฒ ** | - Google ์์
๋ก๊ทธ์ธ
- JWT ์ธ์ฆ
- ํ๋งค์ ํ์ด์ง |
+| **๋ถ์ข
์ฐ** | - ์ปค๋ฎค๋ํฐ ๊ธ
- ์ปค๋ฎค๋ํฐ ๋๊ธ
- ๋ง์ดํ์ด์ง ์กฐํ(์์ฑ ๊ธ/๋๊ธ) |
+| **์ ํ์ฐ** | - ๋์ ์์ฑ + PDF ์ ์ฅ
- ๋์ ์กฐํ
- ์ํ ์กฐํ(Redis ZSet)
- ์ํ ๊ฒฐ์ (ํ ์ค ํ์ด๋จผ์ธ ์ฐ๋)|
+---
+
+# ๐งฉ ๊ธฐ๋ฅ ์ ์์ (Feature Definition)
+
+## ๐งโ๐ป ํ์๊ฐ์
/ ๋ก๊ทธ์ธ
+
+- **์์
๋ก๊ทธ์ธ ์ง์**
+ - ๊ตฌ๊ธ OAuth 2.0 ๊ธฐ๋ฐ ๋ก๊ทธ์ธ
+ - OAuth ์ธ์ฆ ์๋ฃ ์ ํ๋ก์ ํธ ๋ด๋ก ๋ฆฌ๋ค์ด๋ ํธ
+- **ํ์ ์ ๋ณด ์์ **
+ - ๋๋ค์ / ํ๋กํ ์ด๋ฏธ์ง ์์ ๊ธฐ๋ฅ โ (๋นํ์ฑ)
+- **ํ์ ํํด / ์ฌ๊ฐ์
**
+ - ํ์์ ๋ณด ํ
์ด๋ธ์ `status` ์นผ๋ผ ์ถ๊ฐํ์ฌ ์ํ ๊ด๋ฆฌ
+ - ํํด ์ ์์
๊ณ์ ์ฐ๊ฒฐ ๋๊ธฐ ๋ก์ง ํฌํจ
+
+---
+
+## ๐จ ๋์ ์ ์
+
+- 10X10 ๊ฒฉ์์ ๊ธฐํธ๋ฅผ ์ฝ์
ํด ๋์์ ๋ง๋ค๊ณ pdf ํ์ผ๋ก ์ ์ฅ
+- **ํ๋ก ํธ์๋**
+ - ๊ฒฉ์(Grid) UI ๊ธฐ๋ฐ ๋์ ์ ์ ํ๋ฉด
+ - ์ฌ์ฉ์์ ์
๋ ฅ์ `gridData(JSON)` ํํ๋ก ๋ฐฑ์๋์ ์ ์ก
+- **๋ฐฑ์๋**
+ - ์์ ํ `gridData`๋ฅผ ๊ธฐ๋ฐ์ผ๋ก PDF ํ์ผ ์์ฑ ๋ฐ ์ ์ฅ
+
+---
+
+## ๐๏ธ ์ํ ํ๋งค
+
+- **ํ๋งค ๋์**
+ - ์ฌ์ฉ์๊ฐ ์ง์ ์ ์ํ ๋์ ๋๋ ์ผ๋ฐ PDF ์
๋ก๋ ๊ฐ๋ฅ
+- **์ํ ํํ**
+ - ์ ๋ฃ/๋ฌด๋ฃ ๋์ + ์์/ํ์ ์๋ ์ ํํด ๋์ ์ํ ํ๋งค ์ง์
+- **์นดํ
๊ณ ๋ฆฌ ๋ถ๋ฅ**
+ - ์์ / ํ์ / ์์ฐํฐ / ๊ฐ๋ฐฉ / ๊ธฐํ / ๋ฌด๋ฃ / ํ์
+
+---
+
+## ๐ธ ์ํ ๊ตฌ๋งค
+
+- **๊ตฌ๋งค ํ๋ก์ธ์ค**
+ - ์ฌ์ฉ์๋ ๋์์ ๊ตฌ๋งคํ๋ฉด ์ด๋ฉ์ผ๋ก PDF ์๋ ๋ฐ์ก
+ - ์ด๋ฉ์ผ์ ์์
๋ก๊ทธ์ธ ๊ณ์ ์ผ๋ก ๋ฐ์ก๋จ
+- **๋น๋๊ธฐ ์ฒ๋ฆฌ**
+ - Kafka ๋์ **Queue ๊ตฌ์กฐ** ์ฌ์ฉ
+ - ํ์ `(์ฌ์ฉ์ ๊ณ์ , ๋์ PDF)` ์ ๋ณด ์ ์ฅ ํ ์๋ ์ด๋ฉ์ผ ์ ์ก
+- **์ฌ๊ณ ๊ด๋ฆฌ**
+ - ํ์ ์๋ ๋์์ **Redis Lock + Queue** ๊ธฐ๋ฐ ๋์์ฑ ์ ์ด
+
+---
+
+## ๐ ์ํ ์กฐํ
+
+- **์ ๋ ฌ ๊ธฐ์ค**
+ - ์ธ๊ธฐ์ (Redis ZSet์ผ๋ก ๊ตฌ๋งค์ ๊ธฐ๋ฐ ์ ๋ ฌ)
+ - ์ต์ ์, ๊ฐ๊ฒฉ์ ์ ๋ ฌ ์ง์
+ - ๋ฉ์ธํ์ด์ง์์ ์ธ๊ธฐ top5 ์ํ ์กฐํ
+- **ํ์ด์ง ์ฒ๋ฆฌ**
+ - Spring ์๋ฒ์ฌ์ด๋ ๋ ๋๋ง ๊ธฐ๋ฐ `Paging` ์ฒ๋ฆฌ
+- **ํํฐ๋ง**
+ - ์นดํ
๊ณ ๋ฆฌ๋ณ / ๋ฌด๋ฃ / ํ์ ๋์ ์กฐํ ๊ฐ๋ฅ
+
+---
+
+## ๐คย **์ํ ์ฐ & ๋ฆฌ๋ทฐ**
+
+- Redis๋ฅผ ์ด์ฉํ ์ค์๊ฐ ์ฐ ์นด์ดํธ
+- Rabbit ํ๋ฅผ ํ์ฉํ์ฌ 10๋ถ๋ง๋ค DB์ ๋๊ธฐํ
+- ์ํ๋ณ ๋ฆฌ๋ทฐ ์์ฑ ๋ฐ ํ์ธ ๊ฐ๋ฅ
+
+---
+
+## ๐ฌ ์ปค๋ฎค๋ํฐ
+
+- **๊ฒ์ํ**
+ - ๋จ์ผ ๊ฒ์ํ ๊ตฌ์กฐ
+- **๊ฒ์๊ธ ๊ธฐ๋ฅ**
+ - ๊ธ ๋ฑ๋ก / ์์ / ์ญ์
+- **๋๊ธ ๊ธฐ๋ฅ**
+ - ๋๊ธ ๋ฑ๋ก / ์ญ์
+
+---
+
+## ๐ ๋ง์ดํ์ด์ง
+
+- ์ฐ, ๊ตฌ๋งค๋ด์ญ, ์ด๋ฒคํธ ์ฐธ์ฌ๋ด์ญ, ๊ตฌ๋
๊ด๋ฆฌ
+- ๋ง์ดํ์ด์ง ์์ฑ๊ธ/๋๊ธ ํ์ธ
+
+---
+
+## ๐งต ํ๋งค์ ๊ฐ์ธ ํ์ด์ง
+
+- **ํ๋งค์ ์ ์ฉ ํ์ด์ง**
+ - ํ๋งค์ค์ธ ๋์ ๋ชฉ๋ก ๋ฐ ํ์ ๋ฌธ๊ตฌ ํ์
+- **๊ตฌ๋
์ฐ๊ฒฐ**
+ - ํ๋งค์์ ๊ตฌ๋
๊ธฐ๋ฅ ์ฐ๊ณ (ํ๋งค์๋ณ ๊ตฌ๋
๊ด๋ฆฌ)
+
+---
+
+## ๐ ์ ์ฐฉ์ ์ด๋ฒคํธ (์ ํ์ฌํญ)
+
+- **์ด๋ฒคํธ ์์ฑ**
+ - ์ด์์ง์ด ์ง์ ์์ฑ (ํ์ ์๋ / ๋ฌด๋ฃ ๋ฑ)
+- **์ฌ๊ณ ๊ด๋ฆฌ**
+ - ํ์ ์๋ ์ด๋ฒคํธ๋ Redis Lock ์ฌ์ฉ
+ - ํ์ ์ Queue ๋ณํ ์ฌ์ฉ
+
+---
+
+## ๐ฌ ๊ตฌ๋
๊ธฐ๋ฅ (์ ํ์ฌํญ)
+
+- ํ๋งค์๋ฅผ ๊ตฌ๋
ํ ์ฌ์ฉ์๋ ํ ๋ฌ๊ฐ ํด๋น ํ๋งค์์ ๋ชจ๋ ๋์ ๋ค์ด๋ก๋ ๊ฐ๋ฅ
+
+---
+
+## ๐ณ ๊ฒฐ์ ๊ธฐ๋ฅ
+
+- ํ ์ค ํ์ด๋จผ์ธ API์ ์ฐ๋ํ์ฌ ๊ฒฐ์ ๊ฐ๋ฅ
+
+---
+
+## ๐ ์ธ์ฆ / ์ธ๊ฐ
+
+- **JWT ๊ธฐ๋ฐ ์ธ์ฆ ์ ์ฉ**
+ - ์กฐํ(READ) API๋ฅผ ์ ์ธํ ๋ชจ๋ Controller์ ํ ํฐ ๊ฒ์ฆ ๋ก์ง ํ์
+
+---
+
+โ
**์ ๋ฆฌ ์์ฝ**
+
+- ๊ธฐ์ ํค์๋: `Spring Boot`, `Redis`, `JWT`, `Queue`, `H2`, `MySQL`, `Swagger`, `OAuth 2.0`, `Docker`
+- ์ฃผ์ ๋น๋๊ธฐ ์ฒ๋ฆฌ: **Queue (Kafka ๋์ฒด)**
+- ์ฃผ์ ๋์์ฑ ์ ์ด: **Redis Lock (Lettuce ๊ธฐ๋ฐ)**
+- ๋ฐ์ดํฐ ์ ๋ฌ ํ์: **JSON (gridData, API ์๋ต)**
+
+```mermaid
+flowchart LR
+ A[๋นํ์]
+ B1[์์
๋ก๊ทธ์ธ - ๊ตฌ๊ธ OAuth 2.0]
+ B2[ํ์๊ฐ์
]
+ B3[ํํด ๋ฐ ์ฌ๊ฐ์
]
+
+ A --> B1
+ B1 --> B2
+ B1 --> B3
+
+ D[๋์ ์ ์]
+ D3[PDF ํ์ผ ์์ฑยท์ ์ฅ]
+
+ B2 --> D
+ D --> D3
+
+ E[์ํ]
+ E1[์ํ ํ๋งค - ์ ๋ฃยท๋ฌด๋ฃ / ์์ยทํ์ ]
+ E2[์ํ ๊ตฌ๋งค - ์ด๋ฉ์ผ ๋ฐ์ก / ์ฌ๊ณ ๊ด๋ฆฌ]
+ E3[์ํ ์กฐํ - ์ ๋ ฌ]
+ E4[์ํ ์ฐยท๋ฆฌ๋ทฐ]
+
+ B2 --> E
+ E --> E1
+ E --> E2
+ E --> E3
+ E --> E4
+
+ F[์ปค๋ฎค๋ํฐ]
+ F1[๊ธ ์์ฑ/๋๊ธ ์์ฑ]
+
+ B2 --> F
+ F --> F1
+
+ G[๋ง์ดํ์ด์ง]
+ G1[๊ตฌ๋งค๋ด์ญ ํ์ธ]
+ G2[๋ฆฌ๋ทฐ/์ฐ ํ์ธ]
+ G3[ํ๋งค์ ์คํ ์ด]
+ G4[๋ด ๋์ ๋ชฉ๋ก]
+
+ B2 --> G
+ G --> G1
+ G --> G2
+ G --> G3
+ G --> G4
+
+ K[๊ฒฐ์ ๊ธฐ๋ฅ]
+ K1[ํ ์ค ํ์ด๋จผ์ธ API ์ฐ๋]
+ K2[๊ฒฐ์ ์๋ฃ ํ ์ด๋ฉ์ผ ๋ฐ์ก]
+
+ B2 --> K
+ K --> K1
+ K1 --> K2
+```
+---
+
+## ๐ ์ปค๋ฐ ์ปจ๋ฒค์
& ํ์
๊ท์น
+### GitHub Flow(main/feature + develop)
+> ์ด์ ์์ฑ โ ๋ธ๋์น ์์ฑ โ ๊ตฌํ โ Commit & Push โ PR ์์ฑ โ ์ฝ๋ ๋ฆฌ๋ทฐ โ develop์ Merge
+
+- `main`: ๋ฐฐํฌ์ฉ ์์ ๋ธ๋์น
+- `dev`: ๊ธฐ๋ฅ ํตํฉ ๋ธ๋์น
+- `feature/{domain}`: ๊ธฐ๋ฅ ๋จ์ ์์
๋ธ๋์น
+- `hotfix`: ์ค๋ฅ ํด๊ฒฐ ๋ธ๋์น
+- `publishing`: AWS ๋ฐฐํฌ์ฉ ๋ธ๋์น
+
+### ์ปค๋ฐ ์ปจ๋ฒค์
+
+|์ ํ | ์ค๋ช
|
+|---|---|
+|init|์ด๊ธฐ์ค์ |
+|feat| ์๋ก์ด ๊ธฐ๋ฅ|
+|fix| ๋ฒ๊ทธ ์์ |
+|docs|๋ฌธ์ ๋ณ๊ฒฝ(README ๋ฑ)|
+|style| ํฌ๋งท/์คํ์ผ(๊ธฐ๋ฅ ๋ณ๊ฒฝ ์์)|
+|refactor| ๋ฆฌํฉํ ๋ง(๋์ ๋ณ๊ฒฝ ์์)|
+|test| ํ
์คํธ|
+|chore| ๋น๋/์ค์ /์์กด์ฑ|
+|remove| ํ์ผ/ํด๋ ์ญ์ |
+|rename| ํ์ผ/ํด๋๋ช
๋ณ๊ฒฝ|
+
+### ์ปค๋ฐ ๊ณ ์ ๋ฒํธ
+- ์์
๋ก๊ทธ์ธ 100
+- ์ปค๋ฎค๋ํฐ 200
+- ์ํ 300
+ - ์ฃผ๋ฌธ 301
+ - ํ๋งค 302
+ - ๋ฆฌ๋ทฐ 303
+ - ์ฐ ๋ฑ๋ก/์ทจ์ 304
+ - ๋ชฉ๋ก ์กฐํ 305
+ - ๊ฒฐ์ 306
+- ๋ง์ดํ์ด์ง 400
+ - ํ๋งค์ ํ์ด์ง 401
+ - ์กฐํ 402
+ - ์ฐ ์กฐํ 403
+- ๋์ 500
+ - ์์ฑ 501
+ - ์กฐํ / ์ญ์ 502
+- ์ด๋ฒคํธ 600
+
diff --git a/build.gradle.kts b/backend/build.gradle.kts
similarity index 52%
rename from build.gradle.kts
rename to backend/build.gradle.kts
index 979e961..f60ccd7 100644
--- a/build.gradle.kts
+++ b/backend/build.gradle.kts
@@ -26,9 +26,13 @@ repositories {
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
+ implementation ("org.redisson:redisson-spring-boot-starter:3.27.2") // Redisson ๋ผ์ด๋ธ๋ฌ๋ฆฌ
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
+ implementation("org.springframework.boot:spring-boot-starter-data-jpa")
+ implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
+ implementation("org.springframework.boot:spring-boot-starter-amqp")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
@@ -37,6 +41,24 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ implementation("org.apache.pdfbox:pdfbox:2.0.29") // PDF ๋ณํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
+ implementation("commons-codec:commons-codec:1.16.0")
+ implementation("org.springframework.boot:spring-boot-starter-mail") // Spring Email
+
+ // JWT ๋ผ์ด๋ธ๋ฌ๋ฆฌ (ํ์)
+ implementation("io.jsonwebtoken:jjwt-api:0.12.5")
+ runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
+ runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")
+
+ implementation("org.apache.pdfbox:pdfbox:2.0.29") // PDF ๋ณํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
+ implementation("commons-codec:commons-codec:1.16.0")
+
+ // Swagger/OpenAPI
+ // SpringDoc OpenAPI (Swagger 3) - WebMVC ๋ฐ Swagger UI ํฌํจ
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.11")
+
+ implementation("com.fasterxml.jackson.core:jackson-databind")
+
}
tasks.withType {
diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml
new file mode 100644
index 0000000..cce1629
--- /dev/null
+++ b/backend/docker-compose.yml
@@ -0,0 +1,37 @@
+services:
+ mysql:
+ image: mysql:8.0
+ container_name: knitly_db-mysql
+ environment:
+ MYSQL_ROOT_PASSWORD: ${DB_PW}
+ MYSQL_DATABASE: knitly_db
+ ports:
+ - "3306:3306"
+ command: --default-authentication-plugin=mysql_native_password
+ volumes:
+ - mysql_data:/var/lib/mysql
+
+ redis:
+ image: redis:latest
+ container_name: knitly_redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+
+ rabbitmq:
+ image: rabbitmq:3-management
+ container_name: knitly_rabbitmq
+ ports:
+ - "5672:5672"
+ - "15672:15672"
+ environment:
+ RABBITMQ_DEFAULT_USER: guest
+ RABBITMQ_DEFAULT_PASS: guest
+ volumes:
+ - rabbitmq_data:/var/lib/rabbitmq
+
+volumes:
+ mysql_data:
+ redis_data:
+ rabbitmq_data:
diff --git a/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar
similarity index 100%
rename from gradle/wrapper/gradle-wrapper.jar
rename to backend/gradle/wrapper/gradle-wrapper.jar
diff --git a/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties
similarity index 100%
rename from gradle/wrapper/gradle-wrapper.properties
rename to backend/gradle/wrapper/gradle-wrapper.properties
diff --git a/gradlew b/backend/gradlew
similarity index 100%
rename from gradlew
rename to backend/gradlew
diff --git a/gradlew.bat b/backend/gradlew.bat
similarity index 100%
rename from gradlew.bat
rename to backend/gradlew.bat
diff --git a/settings.gradle.kts b/backend/settings.gradle.kts
similarity index 100%
rename from settings.gradle.kts
rename to backend/settings.gradle.kts
diff --git a/backend/src/main/java/com/mysite/knitly/KnitlyApplication.java b/backend/src/main/java/com/mysite/knitly/KnitlyApplication.java
new file mode 100644
index 0000000..1336e86
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/KnitlyApplication.java
@@ -0,0 +1,20 @@
+package com.mysite.knitly;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@SpringBootApplication
+@EnableJpaAuditing
+// @ConfigurationPropertiesScan ์ญํ : @ConfigurationProperties๊ฐ ๋ถ์ ๋ชจ๋ ํด๋์ค๋ฅผ ์๋์ผ๋ก ์ค์บํ์ฌ
+// Spring IoC ์ปจํ
์ด๋์ ๋น(Bean)์ผ๋ก ๋ฑ๋กํ๊ณ ,
+// ์ธ๋ถ ์ค์ ํ์ผ(properties ๋๋ YAML)์ ๊ฐ๊ณผ ๋ฐ์ธ๋ฉํ๋๋ก ํ์ฑํ
+@ConfigurationPropertiesScan
+public class KnitlyApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(KnitlyApplication.class, args);
+ }
+
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/controller/CommentController.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/controller/CommentController.java
new file mode 100644
index 0000000..0bcf58a
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/controller/CommentController.java
@@ -0,0 +1,85 @@
+package com.mysite.knitly.domain.community.comment.controller;
+
+import com.mysite.knitly.domain.community.comment.dto.CommentCreateRequest;
+import com.mysite.knitly.domain.community.comment.dto.CommentResponse;
+import com.mysite.knitly.domain.community.comment.dto.CommentTreeResponse;
+import com.mysite.knitly.domain.community.comment.dto.CommentUpdateRequest;
+import com.mysite.knitly.domain.community.comment.service.CommentService;
+import com.mysite.knitly.domain.mypage.dto.PageResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import com.mysite.knitly.domain.user.entity.User;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.access.prepost.PreAuthorize;
+import java.net.URI;
+
+@RestController
+@RequestMapping("/community")
+@RequiredArgsConstructor
+public class CommentController {
+
+ private final CommentService commentService;
+
+ // ๋๊ธ ๋ชฉ๋ก
+ @GetMapping("/posts/{postId}/comments")
+ public ResponseEntity> getComments(
+ @PathVariable Long postId,
+ @RequestParam(defaultValue = "asc") String sort,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size,
+ @AuthenticationPrincipal User user
+ ) {
+ Page result = commentService.getComments(postId, sort, page, size, user);
+ return ResponseEntity.ok(PageResponse.of(result));
+ }
+
+ // ๋๊ธ ๊ฐ์
+ @GetMapping("/posts/{postId}/comments/count")
+ public ResponseEntity count(@PathVariable Long postId) {
+ return ResponseEntity.ok(commentService.count(postId));
+ }
+
+ // ๋๊ธ ์์ฑ (parentId ์์ผ๋ฉด ๋๋๊ธ)
+ @PostMapping("/posts/{postId}/comments")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity create(
+ @PathVariable Long postId,
+ @AuthenticationPrincipal User user,
+ @Valid @RequestBody CommentCreateRequest request
+ ) {
+ if (!postId.equals(request.postId())) {
+ return ResponseEntity.badRequest().build();
+ }
+ CommentResponse resp = commentService.create(request, user);
+ // ๋ฆฌ์์ค ๊ตฌ์กฐ ์ผ๊ด์ฑ: /community/posts/{postId}/comments/{commentId}
+ return ResponseEntity
+ .created(URI.create(String.format("/community/posts/%d/comments/%d", postId, resp.id())))
+ .body(resp);
+ }
+
+ // ๋๊ธ ์์
+ @PatchMapping("/comments/{commentId}")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity update(
+ @PathVariable Long commentId,
+ @Valid @RequestBody CommentUpdateRequest request,
+ @AuthenticationPrincipal User user
+ ) {
+ commentService.update(commentId, request, user);
+ return ResponseEntity.noContent().build();
+ }
+
+ // ๋๊ธ ์ญ์
+ @DeleteMapping("/comments/{commentId}")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity delete(
+ @PathVariable Long commentId,
+ @AuthenticationPrincipal User user
+ ) {
+ commentService.delete(commentId, user);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentCreateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentCreateRequest.java
new file mode 100644
index 0000000..628e300
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentCreateRequest.java
@@ -0,0 +1,19 @@
+package com.mysite.knitly.domain.community.comment.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import jakarta.validation.constraints.Positive;
+
+public record CommentCreateRequest(
+
+ @NotNull(message = "๊ฒ์๊ธ ID๋ ํ์์
๋๋ค.")
+ Long postId,
+
+ @Positive(message = "parentId๋ 1 ์ด์์ ๊ฐ์ด์ด์ผ ํฉ๋๋ค.")
+ Long parentId,
+
+ @NotBlank(message = "๋๊ธ ๋ด์ฉ์ ํ์์
๋๋ค.")
+ @Size(min = 1, max = 300, message = "๋๊ธ์ 1์ ์ด์ 300์ ์ดํ๋ก ์
๋ ฅํด ์ฃผ์ธ์.")
+ String content
+) {}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentResponse.java
new file mode 100644
index 0000000..2c97d25
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentResponse.java
@@ -0,0 +1,12 @@
+package com.mysite.knitly.domain.community.comment.dto;
+
+import java.time.LocalDateTime;
+
+public record CommentResponse(
+ Long id,
+ String content,
+ Long authorId,
+ String authorDisplay,
+ LocalDateTime createdAt,
+ boolean mine
+) {}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentTreeResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentTreeResponse.java
new file mode 100644
index 0000000..bafcea1
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentTreeResponse.java
@@ -0,0 +1,16 @@
+package com.mysite.knitly.domain.community.comment.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+ // ํธ๋ฆฌ ์๋ต ๊ตฌ์กฐ
+ public record CommentTreeResponse(
+ Long id,
+ String content,
+ Long authorId,
+ String authorDisplay,
+ LocalDateTime createdAt,
+ boolean mine,
+ Long parentId,
+ List children
+) {}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentUpdateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentUpdateRequest.java
new file mode 100644
index 0000000..d4bfe59
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/dto/CommentUpdateRequest.java
@@ -0,0 +1,11 @@
+package com.mysite.knitly.domain.community.comment.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record CommentUpdateRequest(
+
+ @NotBlank(message = "๋๊ธ ๋ด์ฉ์ ํ์์
๋๋ค.")
+ @Size(min = 1, max = 300, message = "๋๊ธ์ 1์ ์ด์ 300์ ์ดํ๋ก ์
๋ ฅํด ์ฃผ์ธ์.")
+ String content
+) {}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/entity/Comment.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/entity/Comment.java
new file mode 100644
index 0000000..febf396
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/entity/Comment.java
@@ -0,0 +1,63 @@
+package com.mysite.knitly.domain.community.comment.entity;
+
+import com.mysite.knitly.domain.community.post.entity.Post;
+import com.mysite.knitly.domain.user.entity.User;
+import com.mysite.knitly.global.jpa.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.Where;
+import java.util.ArrayList;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+@Entity
+@Table(name = "comments")
+@Where(clause = "is_deleted = false")
+public class Comment extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "comment_id")
+ private Long id;
+
+ @Lob
+ @Column(nullable = false)
+ private String content;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User author;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ // ๋๋๊ธ, ์๊ธฐ์ฐธ์กฐ
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "parent_id")
+ private Comment parent;
+
+ @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("createdAt ASC")
+ @Builder.Default
+ private List children = new ArrayList<>();
+
+ @Builder.Default
+ @Column(name = "is_deleted", nullable = false)
+ private boolean deleted = false;
+
+ public void setPost(Post post) { this.post = post; }
+ public void setParent(Comment parent) { this.parent = parent; }
+ public boolean isRoot() { return this.parent == null; }
+
+ public void softDelete() { this.deleted = true; }
+
+ public void update(String newContent) { this.content = newContent; }
+
+ public boolean isAuthor(User user) {
+ return user != null && author != null && author.getUserId().equals(user.getUserId());
+ }
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/repository/CommentRepository.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/repository/CommentRepository.java
new file mode 100644
index 0000000..393f1f8
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/repository/CommentRepository.java
@@ -0,0 +1,53 @@
+package com.mysite.knitly.domain.community.comment.repository;
+
+import com.mysite.knitly.domain.community.comment.entity.Comment;
+import com.mysite.knitly.domain.community.post.entity.Post;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.*;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Collection;
+
+
+public interface CommentRepository extends JpaRepository {
+
+ List findByPost(Post post);
+
+ // ์ ๋ ฌ๏ผ๋ฑ๋ก์,์ต์ ์)
+ @EntityGraph(attributePaths = "author")
+ Page findByPostAndDeletedFalseOrderByCreatedAtAsc(Post post, Pageable pageable);
+ @EntityGraph(attributePaths = "author")
+ Page findByPostAndDeletedFalseOrderByCreatedAtDesc(Post post, Pageable pageable);
+
+ // ๋๊ธ ์
+ long countByPostIdAndDeletedFalse(Long postId);
+
+ // ๋ฃจํธ ๋๊ธ
+ @EntityGraph(attributePaths = "author")
+ Page findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtAsc(Post post, Pageable pageable);
+ @EntityGraph(attributePaths = "author")
+ Page findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtDesc(Post post, Pageable pageable);
+
+ // ์์ ๋๋๊ธ
+ @EntityGraph(attributePaths = "author")
+ List findByParentIdAndDeletedFalseOrderByCreatedAtAsc(Long parentId);
+
+ // ์์ ๋๋๊ธ ๋ฐฐ์น ์กฐํ (N+1 ์ ๊ฑฐ)
+ @EntityGraph(attributePaths = "author")
+ List findByParentIdInAndDeletedFalseOrderByCreatedAtAsc(Collection parentIds);
+
+ // ๋ง์ดํ์ด์ง ๋๊ธ ์กฐํ ์
+ Page findByAuthor_UserIdAndDeletedFalseAndContentContainingIgnoreCaseOrderByCreatedAtDesc(
+ Long userId, String content, Pageable pageable
+ );
+
+ @Query("SELECT c.author.userId " +
+ "FROM Comment c " +
+ "WHERE c.deleted = false AND c.post.id = :postId " +
+ "GROUP BY c.author.userId " +
+ "ORDER BY MIN(c.createdAt) ASC")
+
+ List findAuthorOrderForPost(@Param("postId") Long postId);
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/comment/service/CommentService.java b/backend/src/main/java/com/mysite/knitly/domain/community/comment/service/CommentService.java
new file mode 100644
index 0000000..8faac14
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/comment/service/CommentService.java
@@ -0,0 +1,217 @@
+package com.mysite.knitly.domain.community.comment.service;
+
+import com.mysite.knitly.domain.community.comment.dto.CommentCreateRequest;
+import com.mysite.knitly.domain.community.comment.dto.CommentResponse;
+import com.mysite.knitly.domain.community.comment.dto.CommentTreeResponse;
+import com.mysite.knitly.domain.community.comment.dto.CommentUpdateRequest;
+import com.mysite.knitly.domain.community.comment.entity.Comment;
+import com.mysite.knitly.domain.community.comment.repository.CommentRepository;
+import com.mysite.knitly.domain.community.post.entity.Post;
+import com.mysite.knitly.domain.community.post.repository.PostRepository;
+import com.mysite.knitly.domain.user.entity.User;
+import com.mysite.knitly.global.exception.ErrorCode;
+import com.mysite.knitly.global.exception.ServiceException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class CommentService {
+
+ private final CommentRepository commentRepository;
+ private final PostRepository postRepository;
+
+ // ๋๊ธ ๋ชฉ๋ก
+ public Page getComments(Long postId, String sort, int page, int size, User currentUser) {
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new ServiceException(ErrorCode.POST_NOT_FOUND));
+
+ Pageable pageable = PageRequest.of(page, size);
+
+ Page roots = ("desc".equalsIgnoreCase(sort))
+ ? commentRepository.findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtDesc(post, pageable)
+ : commentRepository.findByPostAndParentIsNullAndDeletedFalseOrderByCreatedAtAsc(post, pageable);
+
+ Map authorNoMap = buildAuthorNoMap(postId);
+ // N+1 ์ ๊ฑฐ
+ List parentIds = roots.getContent().stream()
+ .map(Comment::getId)
+ .toList();
+ Map> childrenMap = parentIds.isEmpty()
+ ? Map.of()
+ : commentRepository.findByParentIdInAndDeletedFalseOrderByCreatedAtAsc(parentIds)
+ .stream()
+ .collect(Collectors.groupingBy(c -> c.getParent().getId()));
+
+ return roots.map(root ->
+ toTreeResponseWithGroupedChildren(root, currentUser, authorNoMap, childrenMap)
+ );
+
+ }
+
+ // ๋๊ธ ๊ฐ์
+ public long count(Long postId) {
+ return commentRepository.countByPostIdAndDeletedFalse(postId);
+ }
+
+ // ๋๊ธ ์์ฑ
+ @Transactional
+ public CommentResponse create(CommentCreateRequest req, User currentUser) {
+ if (currentUser == null) {
+ throw new ServiceException(ErrorCode.COMMENT_UNAUTHORIZED);
+ }
+ Post post = postRepository.findById(req.postId())
+ .orElseThrow(() -> new ServiceException(ErrorCode.POST_NOT_FOUND));
+ // ์ธ์ฆ ์ฌ์ฉ์ ๊ทธ๋๋ก ์ฌ์ฉ
+ User author = currentUser;
+
+ // parentId๊ฐ ์์ผ๋ฉด ๋์ผ ๊ฒ์๊ธ ์์์ธ์ง ๊ฒ์ฆ
+ Comment parent = null;
+ if (req.parentId() != null) {
+ parent = commentRepository.findById(req.parentId())
+ .orElseThrow(() -> new ServiceException(ErrorCode.COMMENT_NOT_FOUND));
+ if (!parent.getPost().getId().equals(req.postId())) {
+ throw new ServiceException(ErrorCode.BAD_REQUEST);
+ }
+ }
+
+ // content trim & ๊ณต๋ฐฑ๋ง ์
๋ ฅ ๊ธ์ง
+ String trimmed = req.content() == null ? null : req.content().trim();
+ if (trimmed == null || trimmed.isBlank()) {
+ throw new ServiceException(ErrorCode.BAD_REQUEST);
+ }
+
+ Comment saved = commentRepository.save(
+ Comment.builder()
+ .post(post)
+ .author(author)
+ .content(trimmed)
+ .parent(parent)
+ .build()
+ );
+
+ Map authorNoMap = buildAuthorNoMap(req.postId());
+ return toFlatResponse(saved, currentUser, authorNoMap);
+ }
+
+ // ๋๊ธ ์์
+ @Transactional
+ public void update(Long commentId, CommentUpdateRequest req, User currentUser) {
+ if (currentUser == null) {
+ throw new ServiceException(ErrorCode.COMMENT_UNAUTHORIZED);
+ }
+ Comment c = commentRepository.findById(commentId)
+ .orElseThrow(() -> new ServiceException(ErrorCode.COMMENT_NOT_FOUND));
+
+ if (c.isDeleted()) {
+ throw new ServiceException(ErrorCode.COMMENT_ALREADY_DELETED);
+ }
+ if (!c.isAuthor(currentUser)) {
+ throw new ServiceException(ErrorCode.COMMENT_UPDATE_FORBIDDEN);
+ }
+
+ // content trim & ๊ณต๋ฐฑ๋ง ์
๋ ฅ ๊ธ์ง
+ String trimmed = req.content() == null ? null : req.content().trim();
+ if (trimmed == null || trimmed.isBlank()) {
+ throw new ServiceException(ErrorCode.BAD_REQUEST);
+ }
+ c.update(trimmed);
+ }
+
+ // ๋๊ธ ์ญ์
+ @Transactional
+ public void delete(Long commentId, User currentUser) {
+ if (currentUser == null) {
+ throw new ServiceException(ErrorCode.COMMENT_UNAUTHORIZED);
+ }
+ Comment c = commentRepository.findById(commentId)
+ .orElseThrow(() -> new ServiceException(ErrorCode.COMMENT_NOT_FOUND));
+
+ if (c.isDeleted()) {
+ throw new ServiceException(ErrorCode.COMMENT_ALREADY_DELETED);
+ }
+ if (!c.isAuthor(currentUser)) {
+ throw new ServiceException(ErrorCode.COMMENT_DELETE_FORBIDDEN);
+ }
+ c.softDelete();
+ }
+
+ // ์์ฑ์ ์ฒซ ๋๊ธ ์๊ฐ ๊ธฐ์ค์ผ๋ก
+ private Map buildAuthorNoMap(Long postId) {
+ List order = commentRepository.findAuthorOrderForPost(postId);
+ Map map = new HashMap<>();
+ int n = 1;
+ for (Long uid : order) {
+ map.put(uid, n++);
+ }
+ return map;
+ }
+
+ // create ์๋ต
+ private CommentResponse toFlatResponse(Comment c, User currentUser, Map authorNoMap) {
+ Long uid = (c.getAuthor() == null) ? null : c.getAuthor().getUserId();
+ int no = (uid != null && authorNoMap.containsKey(uid)) ? authorNoMap.get(uid) : 0;
+ String display = (no > 0) ? "์ต๋ช
์ ํธ์ค " + no : "์ต๋ช
์ ํธ์ค";
+
+ boolean mine = c.isAuthor(currentUser);
+
+ return new CommentResponse(
+ c.getId(),
+ c.getContent(),
+ uid,
+ display,
+ c.getCreatedAt(),
+ mine
+ );
+ }
+ // ํธ๋ฆฌ ์๋ต ๋ณํ
+ private CommentTreeResponse toTreeResponseWithGroupedChildren(
+ Comment root,
+ User currentUser,
+ Map authorNoMap,
+ Map> childrenMap
+ ) {
+ List children = childrenMap.getOrDefault(root.getId(), List.of());
+ return new CommentTreeResponse(
+ root.getId(),
+ root.getContent(),
+ root.getAuthor() == null ? null : root.getAuthor().getUserId(),
+ displayName(root, authorNoMap),
+ root.getCreatedAt(),
+ isMine(root, currentUser),
+ root.getParent() == null ? null : root.getParent().getId(),
+ children.stream()
+ .map(ch -> new CommentTreeResponse(
+ ch.getId(),
+ ch.getContent(),
+ ch.getAuthor() == null ? null : ch.getAuthor().getUserId(),
+ displayName(ch, authorNoMap),
+ ch.getCreatedAt(),
+ isMine(ch, currentUser),
+ ch.getParent() == null ? null : ch.getParent().getId(),
+ List.of()
+ ))
+ .collect(Collectors.toList())
+ );
+ }
+
+ private boolean isMine(Comment c, User currentUser) {
+ return c.isAuthor(currentUser);
+ }
+
+ private String displayName(Comment c, Map authorNoMap) {
+ Long uid = (c.getAuthor() == null) ? null : c.getAuthor().getUserId();
+ int no = (uid != null && authorNoMap.containsKey(uid)) ? authorNoMap.get(uid) : 0;
+ return (no > 0) ? "์ต๋ช
์ ํธ์ค " + no : "์ต๋ช
์ ํธ์ค";
+ }
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/controller/PostController.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/controller/PostController.java
new file mode 100644
index 0000000..53fd7ee
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/controller/PostController.java
@@ -0,0 +1,73 @@
+package com.mysite.knitly.domain.community.post.controller;
+
+import com.mysite.knitly.domain.community.post.dto.PostCreateRequest;
+import com.mysite.knitly.domain.community.post.dto.PostListItemResponse;
+import com.mysite.knitly.domain.community.post.dto.PostResponse;
+import com.mysite.knitly.domain.community.post.dto.PostUpdateRequest;
+import com.mysite.knitly.domain.community.post.entity.PostCategory;
+import com.mysite.knitly.domain.community.post.service.PostService;
+import com.mysite.knitly.domain.user.entity.User;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/community/posts")
+@RequiredArgsConstructor
+public class PostController {
+
+ private final PostService postService;
+
+ @GetMapping
+ public ResponseEntity> getPosts(
+ @RequestParam(required = false) PostCategory category,
+ @RequestParam(required = false) String query,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size
+ ) {
+ return ResponseEntity.ok(postService.getPostList(category, query, page, size));
+ }
+
+ @GetMapping("/{postId}")
+ public ResponseEntity getPost(
+ @AuthenticationPrincipal User user,
+ @PathVariable("postId") Long postId
+ ) {
+ return ResponseEntity.ok(postService.getPost(postId, user));
+ }
+
+ @PostMapping
+ @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()")
+ public ResponseEntity create(
+ @Valid @RequestBody PostCreateRequest request
+ , @AuthenticationPrincipal User user
+
+ ) {
+ PostResponse res = postService.create(request, user);
+ return ResponseEntity.status(HttpStatus.CREATED).body(res);
+ }
+
+ @PutMapping("/{postId}")
+ @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()")
+ public ResponseEntity update(
+ @PathVariable("postId") Long postId,
+ @Valid @RequestBody PostUpdateRequest request,
+ @AuthenticationPrincipal User user
+ ) {
+ return ResponseEntity.ok(postService.update(postId, request, user));
+ }
+
+ @DeleteMapping("/{postId}")
+ @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()")
+ public ResponseEntity delete(
+ @PathVariable Long postId,
+ @AuthenticationPrincipal User user
+ ) {
+ postService.delete(postId, user);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostCreateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostCreateRequest.java
new file mode 100644
index 0000000..d64d83b
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostCreateRequest.java
@@ -0,0 +1,21 @@
+package com.mysite.knitly.domain.community.post.dto;
+
+import com.mysite.knitly.domain.community.post.entity.PostCategory;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+
+public record PostCreateRequest(
+
+ @NotNull(message = "์นดํ
๊ณ ๋ฆฌ๋ ํ์์
๋๋ค.")
+ PostCategory category,
+
+ @NotBlank(message = "์ ๋ชฉ์ ํ์์
๋๋ค.")
+ String title,
+
+ @NotBlank(message = "๋ด์ฉ์ ํ์์
๋๋ค.")
+ String content,
+
+ List imageUrls
+) {}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListItemResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListItemResponse.java
new file mode 100644
index 0000000..8a00e86
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListItemResponse.java
@@ -0,0 +1,16 @@
+package com.mysite.knitly.domain.community.post.dto;
+
+import com.mysite.knitly.domain.community.post.entity.PostCategory;
+import java.time.LocalDateTime;
+
+public record PostListItemResponse(
+
+ Long id,
+ PostCategory category,
+ String title,
+ String excerpt,
+ String authorDisplay,
+ LocalDateTime createdAt,
+ Long commentCount,
+ String thumbnailUrl
+) {}
\ No newline at end of file
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListRowResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListRowResponse.java
new file mode 100644
index 0000000..6527d39
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostListRowResponse.java
@@ -0,0 +1,15 @@
+package com.mysite.knitly.domain.community.post.dto;
+
+import com.mysite.knitly.domain.community.post.entity.PostCategory;
+import java.time.LocalDateTime;
+
+public record PostListRowResponse(
+ Long id,
+ PostCategory category,
+ String title,
+ String excerpt,
+ Long authorId,
+ LocalDateTime createdAt,
+ Long commentCount,
+ String thumbnailUrl
+) {}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostResponse.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostResponse.java
new file mode 100644
index 0000000..b0ba1ee
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostResponse.java
@@ -0,0 +1,20 @@
+package com.mysite.knitly.domain.community.post.dto;
+
+import com.mysite.knitly.domain.community.post.entity.PostCategory;
+import java.time.LocalDateTime;
+import java.util.List;
+
+// ๊ฒ์๊ธ ๋จ๊ฑด ์๋ต DTO
+public record PostResponse(
+ Long id,
+ PostCategory category,
+ String title,
+ String content,
+ List imageUrls,
+ Long authorId,
+ String authorDisplay,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt,
+ Long commentCount,
+ boolean mine
+) {}
\ No newline at end of file
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostUpdateRequest.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostUpdateRequest.java
new file mode 100644
index 0000000..02a0436
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/dto/PostUpdateRequest.java
@@ -0,0 +1,22 @@
+package com.mysite.knitly.domain.community.post.dto;
+
+import com.mysite.knitly.domain.community.post.entity.PostCategory;
+import jakarta.validation.constraints.*;
+
+import java.util.List;
+
+public record PostUpdateRequest(
+
+ @NotNull(message = "์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ ํํด ์ฃผ์ธ์.")
+ PostCategory category,
+
+ @NotBlank(message = "์ ๋ชฉ์ ํ์์
๋๋ค.")
+ @Size(max = 100, message = "์ ๋ชฉ์ 100์ ์ดํ๋ก ์
๋ ฅํด ์ฃผ์ธ์.")
+ String title,
+
+ @NotBlank(message = "๋ด์ฉ์ ํ์์
๋๋ค.")
+ String content,
+
+ @Size(max = 5, message = "์ด๋ฏธ์ง๋ ์ต๋ 5๊ฐ๊น์ง ์
๋ก๋ํ ์ ์์ต๋๋ค.")
+ List imageUrls
+) {}
\ No newline at end of file
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/Post.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/Post.java
new file mode 100644
index 0000000..a602788
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/Post.java
@@ -0,0 +1,82 @@
+package com.mysite.knitly.domain.community.post.entity;
+
+import com.mysite.knitly.domain.community.comment.entity.Comment;
+import com.mysite.knitly.domain.user.entity.User;
+import com.mysite.knitly.global.jpa.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.Where;
+import java.util.ArrayList;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+@Entity
+@Table(name = "posts")
+@Where(clause = "is_deleted = false")
+public class Post extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "post_id")
+ private Long id;
+
+ @Column(nullable = false, length = 100)
+ private String title;
+
+ @Lob
+ @Column(nullable = false)
+ private String content;
+
+ // ๋ค์ค ์ด๋ฏธ์ง URL
+ @Builder.Default
+ @ElementCollection
+ @CollectionTable(name = "post_images", joinColumns = @JoinColumn(name = "post_id"))
+ @Column(name = "url", nullable = false, length = 512)
+ @OrderColumn(name = "sort_order")
+ private List imageUrls = new ArrayList<>();
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "post_category", nullable = false, columnDefinition = "ENUM('FREE','QUESTION','TIP')")
+ private PostCategory category;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User author;
+
+ @Builder.Default
+ @Column(name = "is_deleted", nullable = false)
+ private boolean deleted = false;
+
+ @Builder.Default
+ @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List comments = new ArrayList<>();
+
+ public void addComment(Comment comment) {
+ comments.add(comment);
+ comment.setPost(this);
+ }
+
+ public void softDelete() { this.deleted = true; }
+
+ // ์ด๋ฏธ์ง ์์
+ public void update(String title, String content, PostCategory category) {
+ this.title = title;
+ this.content = content;
+ this.category = category;
+ }
+
+ // ์ด๋ฏธ์ง ๊ต์ฒด
+ public void replaceImages(List newUrls) {
+ this.imageUrls.clear();
+ if (newUrls != null) {
+ this.imageUrls.addAll(newUrls);
+ }
+ }
+
+ public boolean isAuthor(User user) {
+ return user != null && author != null && author.getUserId().equals(user.getUserId());
+ }
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/PostCategory.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/PostCategory.java
new file mode 100644
index 0000000..37cb15d
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/entity/PostCategory.java
@@ -0,0 +1,6 @@
+package com.mysite.knitly.domain.community.post.entity;
+
+// ์นดํ
๊ณ ๋ฆฌ ๋๋กญ๋ค์ด ์์ , ์ง๋ฌธ, ํ
+public enum PostCategory {
+ FREE, QUESTION, TIP
+}
diff --git a/backend/src/main/java/com/mysite/knitly/domain/community/post/repository/PostRepository.java b/backend/src/main/java/com/mysite/knitly/domain/community/post/repository/PostRepository.java
new file mode 100644
index 0000000..d4d9de4
--- /dev/null
+++ b/backend/src/main/java/com/mysite/knitly/domain/community/post/repository/PostRepository.java
@@ -0,0 +1,59 @@
+package com.mysite.knitly.domain.community.post.repository;
+
+import com.mysite.knitly.domain.community.post.dto.PostListRowResponse;
+import com.mysite.knitly.domain.community.post.entity.Post;
+import com.mysite.knitly.domain.community.post.entity.PostCategory;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.*;
+import org.springframework.data.repository.query.Param;
+import java.util.Collection;
+import java.util.List;
+
+public interface PostRepository extends JpaRepository {
+
+ @EntityGraph(attributePaths = "author")
+ @Query(
+ "SELECT p " +
+ "FROM Post p " +
+ "WHERE (:category IS NULL OR p.category = :category) " +
+ " AND ( :query IS NULL OR :query = '' " +
+ " OR LOWER(p.title) LIKE LOWER(CONCAT('%', :query, '%')) " +
+ " OR p.content LIKE CONCAT('%', :query, '%') ) " +
+ "ORDER BY p.createdAt DESC"
+ )
+ Page findListRows(@Param("category") PostCategory category,
+ @Param("query") String query,
+ Pageable pageable);
+
+ @Query("SELECT COUNT(c.id) FROM Comment c WHERE c.post.id = :postId AND c.deleted = false")
+ long countCommentsByPostId(@Param("postId") Long postId);
+
+ // ๋๊ธ ์ ์ผ๊ด ์กฐํ (N+1 ๋ฌธ์ ์ ๊ฑฐ)
+ @Query("""
+ SELECT c.post.id, COUNT(c.id)
+ FROM Comment c
+ WHERE c.post.id IN :postIds AND c.deleted = false
+ GROUP BY c.post.id
+ """)
+ List