์ ๊ตญ ์คํค์ฅ ์ ๋ณด์ ๋ผ์ด๋ฉ ๊ฒฝํ์ ๋๋๊ณ , ํจ๊ป ํ ์ ์ ๋ฅผ ์ฐพ๋ ์ํฐ์คํฌ์ธ ์ ์ฉ ์ปค๋ฎค๋ํฐ ํ๋ซํผ
- ์๋น์ค๋ช : Snowthing (๋ํ )
- ๋ชฉ์ : ๊ธฐ์กด ๋ ธํํ๋ ์ํฐ์คํฌ์ธ ์ปค๋ฎค๋ํฐ์ ๋ถํธํจ(http ์ ์, ๋ณด์ ์ทจ์ฝ, ์ด๋ฏธ์ง ์ฒจ๋ถ ์ค๋ฅ ๋ฑ)์ ๊ทน๋ณตํ๊ณ , ์ค๋ ธ๋ณด๋/์คํค์ด๋ฅผ ์ํ ์ปค๋ฎค๋ํฐ ํ๋ซํผ์ ์ ๊ณตํฉ๋๋ค.
- 1์ฐจ MVP ํต์ฌ ํ๊ฒ:
- ์๋ จ ์ด์ฉ์ (์์ฆ ๋ณด๋): ์ค์๊ฐ ์ค์ง/ํ์ฅ ์ ๋ณด ๊ณต์ , ํ๋กํ N:M ์ค๊ณ(๋ฒ ์ด์ค ์คํค์ฅ/๋ผ์ด๋ฉ ์ฑํฅ) ๋ฑ๋ก
- ์ด๋ณด ์ด์ฉ์ (์ ๋ฌธ ๋ณด๋): ๋ถ๋ด ์๋ ๋นํ์ ์ต๋ช ์์ฑ ๋ฐ ์ ์ ๋ Q&A ์ง์ ํ์
- Core: Next.js 16 (App Router), TypeScript, React 19
- State & Fetching: React State, Fetch API (
credentials: 'include') - Styling: Vanilla CSS, UI Component System
- Core: Java 21 (LTS), Spring Boot 3.3.x, Spring Data JPA
- Security & Auth: Spring Security 6, Spring Session (Servlet Memory / Redis Ready), BCrypt
- Build & Test: Gradle, JUnit5, Mockito, MockMvc
- RDBMS: H2 (In-Memory Dev), MySQL 8.0 (Production)
- In-Memory Cache: Spring Session Redis (Scale-out Ready)
Snowthing์ ์ฌ์ฉ์ ๊ฒฝํ(UX) ์ต์ฐ์ ๊ณผ ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ ๋ฐ ๋ณด์์ฑ์ ์ํธํ๊ธฐ ์ํด ๋ค์๊ณผ ๊ฐ์ ํต์ฌ ์ํคํ ์ฒ ์ค๊ณ๋ฅผ ์ ํํ์์ต๋๋ค.
sequenceDiagram
autonumber
actor User as ๐ค ์ฌ์ฉ์ (Browser)
participant Client as ๐ป Next.js (Port 3000)
participant Server as โ๏ธ Spring Boot (Port 8080)
participant Session as ๐ง Tomcat Session Manager
Note over User, Server: 1. ๋นํ์ ์ ์ & ํ์๊ฐ์
/๋ก๊ทธ์ธ ์๋
User->>Client: ํผ ์
๋ ฅ (์ด๋ฉ์ผ, ๋น๋ฐ๋ฒํธ, N:M ์ฒดํฌ๋ฐ์ค)
Client->>Server: POST /api/auth/login (with credentials: 'include')
Note over Server: 2. ๋น๋ฐ๋ฒํธ ์ ๋ฐ ์ ๊ท์ & BCrypt ํด์ ๊ฒ์ฆ
Server->>Server: passwordEncoder.matches(raw, encoded)
Note over Server, Session: 3. ์ธ์
๊ณ ์ ๋ฐฉ์ด (Session Fixation Defense)
Server->>Session: request.changeSessionId() ํธ์ถ! (์ธ์
์๋ณ์ ๊ต์ฒด)
Session-->>Server: ์ ๊ท 32์๋ฆฌ JSESSIONID ๋ฐ๊ธ (๊ธฐ์กด ์คํค์ฅ/์ฑํฅ ๊ฒ์ ํํฐ ์ธ์
๋ฐ์ดํฐ ์ ์ง)
Server-->>Client: Set-Cookie: JSESSIONID=A1B2...; Path=/; HttpOnly; SameSite=Lax
Client-->>User: ๋ก๊ทธ์ธ ์ฑ๊ณต (๋ฉ์ธ ํ๋กํ ๋์๋ณด๋ ์ ํ)
Note over User, Server: 4. ์ธ์ฆ๋ API ์์ฒญ (ํ๋กํ ์กฐํ/์์ )
User->>Client: ํ๋กํ ์์ ํด๋ฆญ (PUT /api/members/me)
Client->>Server: PUT /api/members/me (Cookie: JSESSIONID=A1B2...)
Server->>Session: JSESSIONID ์ธ์
๊ฒ์ฆ & SecurityContext ๋ณต์
Server-->>Client: 200 OK (์์ ๋ ํ๋กํ & N:M ํ๊ทธ ์นฉ ๋ฐํ)
Note over User, Server: 5. ์ธ์
๋ก๊ทธ์์ (3๋จ๊ณ ์๋ฉธ ํ๋ก์ธ์ค)
User->>Client: ๋ก๊ทธ์์ ๋ฒํผ ํด๋ฆญ
Client->>Server: POST /api/auth/logout
Server->>Server: 1) ThreadLocal.clearContext() ์ฒญ์
Server->>Session: 2) session.invalidate() ํฐ์บฃ ์ธ์
ํ๊ธฐ
Server-->>Client: 3) Set-Cookie: JSESSIONID=; Max-Age=0 (์ฟ ํค ์ฆ์ ๋ง๋ฃ)
createdAt, updatedAt ์ปฌ๋ผ์ ํ์๊ฐ์
, ๊ฒ์๊ธ, ๋๊ธ ๋ฑ ์์คํ
์ ์ฒด ๋๋ฉ์ธ์์ ๊ณตํต์ผ๋ก ์ฌ์ฉ๋ฉ๋๋ค. ๊ธฐ์กด์๋ ์ ํ๋ฆฌ์ผ์ด์
๋ ๋ฒจ์์ ์ง์ set์ ํธ์ถํ๊ฑฐ๋ DB ๋ ๋ฒจ์ sysdate๋ฅผ ์ด์ฉํ๋ ค ํ์ผ๋ ์ฌ๋ฌ ํธ๋ ์ด๋์คํ๊ฐ ์กด์ฌํ์ต๋๋ค.
- ์ ํ๋ฆฌ์ผ์ด์
์๋ ์ฃผ์
๋ฐฉ์: ์ํฐํฐ๋ง๋ค ์๊ฐ ์์ฑ ์ฝ๋๊ฐ ์ค๋ณต๋๊ณ , ๊ฐ๋ฐ์์ ์ค์๋ก
null์ด ๋ค์ด๊ฐ ์ํ์ด ์์ต๋๋ค. - DB ๋ ๋ฒจ
sysdate์ฃผ์ ๋ฐฉ์: JPA 1์ฐจ ์บ์ ๋ฉ๋ชจ๋ฆฌ์ ์ฌ๋ผ๊ฐ ์ํฐํฐ ๊ฐ์ฒด๋ ๋ ํ๋๋ฅผnull๋ก ๊ฐ์ง๊ณ ์์ด, ํ์๊ฐ์ ์งํ ์๋ต DTO๋ฅผ ๋ฐํํ ๋ ์๊ฐ์ดnull๋ก ์ถ๋ ฅ๋๋ 1์ฐจ ์บ์ ๋ฉ๋ชจ๋ฆฌ โ DB ๊ฐ ๋ฐ์ดํฐ ๋ถ์ผ์น๊ฐ ๋ฐ์ํฉ๋๋ค.
- ํด๊ฒฐ ๋ฐฉ์:
BaseTimeEntity๊ณตํต ์ถ์ ์ํฐํฐ๋ฅผ ์์ฑํ๊ณ JPA ์์์ฑ ๋ผ์ดํ์ฌ์ดํด ์ด๋ฒคํธ๋ฅผ ๊ฐ์ํ๋AuditingEntityListener๋ฅผ ์ ์ฉํ์ต๋๋ค.
SQL ํจํท์ด DB๋ก ๋์ด๊ฐ๊ธฐ ๋ฐ๋ก ์ ์๋ฐ ๋ฉ๋ชจ๋ฆฌ ๊ฐ์ฒด์ ๋ ํ๋์ ํ์ฌ ์๊ฐ์ ๋จผ์ ์ฑ์ ๋ฃ๋๋ก ์ฒ๋ฆฌํ์ฌ ์ฝ๋ ์ค๋ณต๊ณผ 1์ฐจ ์บ์ ๋ฐ์ดํฐ ๋ถ์ผ์น๋ฅผ ๋์์ ํด๊ฒฐํ์ต๋๋ค.
์ด๊ธฐ์๋ ์จ๋ผ์ธ ๊ฒ์์ด๋ ์ํ ์ฑ์ฒ๋ผ ๊ณ์ ๋์ฉ ๋ฐฉ์ง๋ฅผ ์ํด ํ๋์ ์ธ์ ๋ง ํ์ฉํ๋ ๋จ์ผ ์ธ์ ์ ๊ตฌ์ํ์ผ๋, ๋ณธ ์ปค๋ฎค๋ํฐ์ ํน์ฑ์ ๋ง์ถฐ ์ ์ ์ ์ดํ๋ฅ ์ ๋ฎ์ถ๊ณ ์ฅ์๊ฐ ์ฒด๋ฅํ๋๋ก ๋ง๋๋ ๊ฒ์ด ์๋น์ค์ ํต์ฌ ๋ชฉํ์์ต๋๋ค.
- ์ ์ฑ
๋น๊ต ๋ฐ ์ ํ:
- ๋จ์ผ ์ธ์ + ์งง์ ๋ง๋ฃ์๊ฐ (์ด๊ธฐ์): ๋ชจ๋ฐ์ผ/์น ๋์ ์ ์์ด ๋ถ๊ฐ๋ฅํ์ฌ ์ ์ ๊ฒฝํ(UX)์ด ์ ํ๋๊ณ ์ฌ๋ก๊ทธ์ธ ์คํธ๋ ์ค๊ฐ ์ฆ๊ฐํจ.
- ๋ฌด์ ํ ์ธ์ (๋์): ๋ก๊ทธ์ธ ์ ์ ๊ฐ ๋์ด๋ ์๋ก ์๋ฒ ๋ฉ๋ชจ๋ฆฌ(RAM)์ ์ธ์ ๊ฐ์ฒด๊ฐ ๋์ ๋์ด OutOfMemory(OOM)๋ก ์ธํ ์๋ฒ ๋ค์ด ์ํ ๋ฐ์.
- [์ ํ] ๋ค์ค ์ธ์
์ง์ + ์ฌ๋ผ์ด๋ฉ ์ธ์
+ Remember-Me (30์ผ):
- ๊ธฐ๋ณธ ์ธ์ 1์๊ฐ: ํ๋ ์ค์ธ ์ ์ ๋ ์ธ์ ๋ง๋ฃ์๊ฐ์ด ์ง์์ ์ผ๋ก ์ฐ์ฅ(Sliding Session)๋์ด ๋๊น ์๋ ์ฐ์์ ์ธ ๊ฒฝํ ์ ๊ณต.
- Remember-Me (30์ผ): ๋ก๊ทธ์ธ ์ ์ฒดํฌ๋ฐ์ค๋ฅผ ์ ํํ ์ ์ ์๊ฒ 30์ผ ์ฅ๊ธฐ ์ธ์ ์ ๋ถ์ฌํ์ฌ ์ด์ฉ ํธ๋ฆฌ์ฑ ํ๋ณด.
- ๋ฉ๋ชจ๋ฆฌ ๋ณดํธ: 30์ผ ๋์ ํ๋์ด ์ ํ ์๋ ๋ง๋ฃ๋ ์ธ์ ์ ์๋ธ๋ฆฟ ์ปจํ ์ด๋๊ฐ ๋ฉ๋ชจ๋ฆฌ์์ ์ฆ์ ํ๊ธฐ(Garbage Collection)ํ๋๋ก ๊ตฌ์ฑํ์ฌ ๋ฉ๋ชจ๋ฆฌ ๊ณ ๊ฐ ๋ฐฉ์ง.
๋ก๊ทธ์ธ ์ฑ๊ณต ์ ๋จ์ ๊ธฐ๋ณธ ์ธ์ ๋ฐ๊ธ๋ง ์ํํ๋ฉด, ๋ก๊ทธ์ธ ์ ํด์ปค๊ฐ ์ ์ ๋ธ๋ผ์ฐ์ ์ ๋ฏธ๋ฆฌ ์ฌ์ด๋ ๋นํ์ ์ธ์ ID๊ฐ ๋ก๊ทธ์ธ ์ ์ ์ ์ ๊ถํ ์ธ์ ์ผ๋ก ๊ทธ๋๋ก ์น๊ฒฉ๋์ด ๊ณต๊ฒฉ์๊ฐ ์ ์ ์ ๋ก๊ทธ์ธ ๊ถํ์ ํ์ณ๋ด๋ ์ธ์ ํ์ทจ(Session Hijacking) ์ํ์ ๋ฌด๋ฐฉ๋น๋ก ๋ ธ์ถ๋ฉ๋๋ค.
- ํด๊ฒฐ ๋ฐฉ์: ๋ก๊ทธ์ธ ์ฑ๊ณต ์งํ Spring Security์
request.changeSessionId()๋ฅผ ๋ช ์์ ์ผ๋ก ํธ์ถํฉ๋๋ค.
๊ธฐ์กด ์ธ์ ์ ๋ณด๊ด๋์ด ์๋ ์์ฑ ๋ฐ์ดํฐ(์ฅ๋ฐ๊ตฌ๋ ๋ฑ)๋ ๋ฉ๋ชจ๋ฆฌ ์์์ 100% ๋ณด์กดํ๋ฉด์, ํด๋ผ์ด์ธํธ ๋ธ๋ผ์ฐ์ ๋ก ๋ด๋ ค์ฃผ๋ ์ธ์ ์๋ณ์ ํค(JSESSIONID)๋ง์ 32์๋ฆฌ ์ ๋์๋ก ์ฆ์ ์ฌ๋ฐ๊ธํ์ฌ ๊ณต๊ฒฉ์์ ์ด์ ์ธ์ ID๋ฅผ ๋ฌผ๋ฆฌ์ ์ผ๋ก ์์ ํ ๋ฌดํจํ์์ผฐ์ต๋๋ค.
4. ๊ฒ์ํ(Post) ๋๋ฉ์ธ ์ค๊ณ & ํต์ฌ ๊ธฐ์ ์ ์์ฌ๊ฒฐ์ (Board Architecture & Decisions)
๊ฒ์ํ์ Snowthing์์ ๊ฐ์ฅ ์์ฃผ ์ฝํ๋ ๋๋ฉ์ธ์ด๋ค. ๊ทธ๋์ ๋จ์ CRUD๋ก๋ง ๋ง๋ค์ง ์๊ณ , ๋ชฉ๋ก ์กฐํ ๋น์ฉ, ์ต๋ช ๊ธ ๊ถํ, ์ญ์ ์ ์ฑ , ์ด๋ฏธ์ง ์ฒจ๋ถ ์ํ๊น์ง ๊ฐ์ด ๋ง์ถฐ์ ์ค๊ณํ๋ค.
๊ฒ์๊ธ์ ์ค์ฌ ์ํฐํฐ๋ Post๋ค. Post ํ๋๊ฐ ์ ๋ชฉ, ๋ณธ๋ฌธ, ์์ฑ IP, ์กฐํ์, ๋๊ธ ์, ์ถ์ฒ/๋น์ถ์ฒ ์, ์ญ์ ์ฌ๋ถ, ์ํ๊ฐ์ ๊ฐ์ง๋ค.
์ฃผ๋ณ ์ํฐํฐ๋ ์ญํ ์ ๋๋ ์ ๋ถ์๋ค.
PostCategory: ์์ ๊ฒ์ํ, ์ต๋ช ๊ฒ์ํ, ์ฅ๋น Q&A, ๋ฆฌ์กฐํธ ๋ง์ง ๊ฐ์ ๊ฒ์ํ ์ข ๋ฅ๋ฅผ ๋ด๋นํ๋ค.PostImage: ๊ฒ์๊ธ ์ฒจ๋ถ ์ด๋ฏธ์ง๋ฅผ 1:N์ผ๋ก ์ ์ฅํ๋ค. ์ด๋ฏธ์ง๋ ์ฌ๋ฌ ์ฅ์ด ๊ฐ๋ฅํ๊ณsortOrder๋ก ํ์ ์์๋ฅผ ๊ฐ์ง๋ค.PostReaction: ์ถ์ฒ/๋น์ถ์ฒ ์ด๋ ฅ์ ์ ์ฅํ๋ค. ๋ก๊ทธ์ธ ์ฌ์ฉ์์ ์ถ์ฒ/๋น์ถ์ฒ๊ณผ ๋น๋ก๊ทธ์ธ ์ฌ์ฉ์์ ์ต๋ช ์ถ์ฒ/๋น์ถ์ฒ์ ๋ ๋ค ์ฒ๋ฆฌํ๊ธฐ ์ํด ํ์ ID ๋๋anonymous_voter_id์ฟ ํค ๊ฐ์ ๊ธฐ์ค์ผ๋ก ๊ตฌ๋ถํ๋ค.Comment: ๊ฒ์๊ธ ํ์ ๋๊ธ/๋๋๊ธ์ ๋ด๋นํ๋ค.Member: ๋ก๊ทธ์ธ ํ์์ด ์์ฑํ ๊ฒ์๊ธ์ ์์ฑ์ ์ ๋ณด๋ค.
์ธ๋ถ API์์๋ DB PK์ธ post_id๋ฅผ ๊ทธ๋๋ก ๋
ธ์ถํ์ง ์๊ณ publicId๋ฅผ ์ฌ์ฉํ๋ค. ๋ด๋ถ PK๋ ์กฐ์ธ๊ณผ ์ธ๋ฑ์ค์ ์ฐ๊ณ , ์ธ๋ถ ์๋ณ์๋ URL๊ณผ API ์๋ต์ ์ฐ๋ ์์ผ๋ก ์ฑ
์์ ๋๋ด๋ค. ์ด๋ ๊ฒ ํด๋๋ฉด ์ฌ์ฉ์๊ฐ URL ์ซ์๋ฅผ ๋ฐ๊ฟ๊ฐ๋ฉฐ ๋ด๋ถ ๋ฐ์ดํฐ ๊ฐ์๋ฅผ ์ถ์ธกํ๋ ๋ฌธ์ ๋ ์ค์ผ ์ ์๋ค.
ํ์๊ณผ ๊ฒ์๊ธ์ Member 1 : N Post ๊ด๊ณ๋ค. ํ ๋ช
์ ํ์์ ์ฌ๋ฌ ๊ธ์ ์ธ ์ ์๊ณ , ํ๋์ ํ์ ๊ธ์ ํ ๋ช
์ ์์ฑ์๋ฅผ ๊ฐ์ง๋ค.
์ฝ๋์์๋ Post.member๋ฅผ @ManyToOne(fetch = FetchType.LAZY)๋ก ๋๋ค. ๊ฒ์๊ธ ๋ชฉ๋ก์ ๋ณผ ๋๋ง๋ค ํ์ ์ํฐํฐ ์ ์ฒด๊ฐ ํญ์ ํ์ํ ๊ฒ์ ์๋๊ธฐ ๋๋ฌธ์ ๊ธฐ๋ณธ์ LAZY๋ก ๋ ๊ฒ์ด๋ค. ํ์ํ ํ๋ฉด์์๋ fetch join์ด๋ DTO projection์ผ๋ก ํ์ํ ์์ฑ์ ์ ๋ณด๋ง ๊ฐ์ ธ์จ๋ค.
๋ค๋ง member_id๋ nullable์ด๋ค. ์ด์ ๋ ์ต๋ช
๊ฒ์ํ ๋๋ฌธ์ด๋ค.
- ๋ก๊ทธ์ธ ํ์ ๊ธ:
member_id๊ฐ ์๋ค. - ๋ก๊ทธ์ธ ํ์์ด ์ต๋ช
์ผ๋ก ์ด ๊ธ:
member_id๋ฅผ ๋จ๊ธธ ์ ์๊ณ , ํ๋ฉด์์๋ ์ต๋ช ์ผ๋ก ๋ง์คํนํ๋ค. - ๋น๋ก๊ทธ์ธ ์ต๋ช
๊ธ:
member_id๊ฐ ์๋ค. ๋์ ์์ฑ IP์ ์ต๋ช ๋น๋ฐ๋ฒํธ ํด์๋ก ์์ /์ญ์ ๊ถํ์ ๊ฒ์ฆํ๋ค.
์ด ๊ตฌ์กฐ์ ๋๊ฐ๋ ๊ถํ ๊ฒ์ฆ์ด ์กฐ๊ธ ๋ณต์กํด์ง๋ค๋ ์ ์ด๋ค. ๊ทธ๋์ ์์ /์ญ์ ์ ๋จ์ํ member_id๋ง ๋ณด๋ ๊ฒ์ด ์๋๋ผ, ํ์ ๊ธ์ธ์ง ์ต๋ช
๊ธ์ธ์ง ๋จผ์ ๋๋ ๋ค ๊ฐ๊ฐ ๋ค๋ฅธ ๊ฒ์ฆ ๋ก์ง์ ํ๋ค.
๊ฒ์๊ธ ์ํ๋ PostStatus๋ก ๊ด๋ฆฌํ๋ค.
NORMAL: ์ ์ ๊ธ์ด๋ค. ๋ชฉ๋ก๊ณผ ์์ธ ์กฐํ ๋์์ด๋ค.DELETED: ์์ฑ์ ๋๋ ๊ด๋ฆฌ์์ ์ํด ์ญ์ ๋ ๊ธ์ด๋ค. ํ์ฌ๋ Soft Delete ๊ฒฐ๊ณผ ์ํ๋ก ์ฌ์ฉํ๋ค.BLOCKED: ๊ด๋ฆฌ์๊ฐ ๋ถ์ ์ ํ ๊ธ์ ์ฐจ๋จํ ์ํ๋ค.HIDDEN: ๋น๊ณต๊ฐ๋ ์จ๊น ์ฒ๋ฆฌ๊ฐ ํ์ํ ์ํ๋ฅผ ์ํด ๋ถ๋ฆฌํด๋ ๊ฐ์ด๋ค.DRAFT: ์์ ์ ์ฅ์ ๊ณ ๋ คํ ์ํ๊ฐ์ด๋ค.
๊ฒ์๊ธ ์ ํ์ ์ํ๊ฐ ์๋๋ผ ์นดํ
๊ณ ๋ฆฌ๋ค. PostCategory.code๋ก ๊ตฌ๋ถํ๋ค.
FREE: ์์ ๊ฒ์ํANONYMOUS: ์ต๋ช ๊ฒ์ํQNA: ์ฅ๋น Q&AFOOD: ๋ฆฌ์กฐํธ ๋ง์ง
์ํ์ ์ ํ์ ์์ง ์์ ์ด์ ๋ ์ญํ ์ด ๋ค๋ฅด๊ธฐ ๋๋ฌธ์ด๋ค. NORMAL, DELETED๋ ๊ธ์ ํ์ฌ ์ฒ๋ฆฌ ์ํ์ด๊ณ , FREE, ANONYMOUS๋ ๊ธ์ด ์ํ ๊ฒ์ํ์ด๋ค. ์ด ๋์ ๊ฐ์ enum์ผ๋ก ํฉ์น๋ฉด โ์ต๋ช
๊ฒ์ํ์ ์๋ ์ญ์ ๋ ๊ธโ ๊ฐ์ ์ํ๋ฅผ ํํํ๊ธฐ๊ฐ ์ง์ ๋ถํด์ง๋ค.
๋ชฉ๋ก ์๋ต๊ณผ ์์ธ ์๋ต์ ์ผ๋ถ๋ฌ ๋๋ด๋ค.
๋ชฉ๋ก์์๋ ์ฌ์ฉ์๊ฐ ๊ธ์ ๊ณ ๋ฅด๋ ๋ฐ ํ์ํ ์ ๋ณด๋ง ์์ผ๋ฉด ๋๋ค. ๊ทธ๋์ PostListResponse์๋ publicId, categoryName, title, writerNickname, thumbnailImageUrl, hasImage, viewCount, commentCount, likeCount, status, createdAt ๊ฐ์ ๋ชฉ๋ก์ฉ ํ๋๋ง ๋๋ค.
์์ธ์์๋ ๋ณธ๋ฌธ๊ณผ ์ด๋ฏธ์ง ์ ์ฒด๊ฐ ํ์ํ๋ค. ๊ทธ๋์ PostDetailResponse์๋ content, images, writer, isAnonymous ๊ฐ์ ์์ธ ํ๋ฉด์ฉ ํ๋๋ฅผ ํฌํจํ๋ค.
์ด๋ ๊ฒ ๋๋ ์ด์ ๋ ๊ฐ๋จํ๋ค. ๋ชฉ๋ก 20๊ฐ๋ฅผ ๋ณด์ฌ์ฃผ๋๋ฐ ๋ณธ๋ฌธ ์ ์ฒด์ ์ด๋ฏธ์ง ๋ฐฐ์ด๊น์ง ๊ฐ์ด ๊ฐ์ ธ์ค๋ฉด, ์ฌ์ฉ์๊ฐ ํด๋ฆญํ์ง๋ ์์ ๊ธ์ ๋ฐ์ดํฐ๋ฅผ ๋งค๋ฒ ์ฝ๊ณ ๋ด๋ ค์ฃผ๊ฒ ๋๋ค. ๊ฒ์๊ธ ์๊ฐ ๋์๋ก DB I/O์ ์๋ต ํฌ๊ธฐ๊ฐ ๊ฐ์ด ์ปค์ง๋ค.
๊ทธ๋์ ๋ชฉ๋ก์ ๊ฐ๋ณ๊ฒ, ์์ธ๋ ํ์ํ ๋งํผ๋ง ๊ฐ์ ธ์ค๊ฒ ํ๋ค. ์ต๊ทผ ์ด๋ฏธ์ง ๋ฑ์ง๋ ๊ฐ์ ํ๋ฆ์ด๋ค. ๋ชฉ๋ก์์ ์ด๋ฏธ์ง๊ฐ ์๋์ง ํ์ํ๋ ค๊ณ ๋งค๋ฒ post_image๋ฅผ ์กฐ์ธํ์ง ์๊ณ , Post.hasImage์ ์ฒจ๋ถ ์ฌ๋ถ๋ฅผ ์ญ์ ๊ทํํด๋๋ค. ๋์ ์์ฑ/์์ ์ Post.replaceImages()์์ ์ด๋ฏธ์ง ๋ชฉ๋ก๊ณผ hasImage๋ฅผ ๊ฐ์ด ๋ง์ถฐ ๋ฐ์ดํฐ๊ฐ ํ์ด์ง์ง ์๊ฒ ํ๋ค.
ํ์ฌ ๊ฒ์๊ธ ๋ชฉ๋ก์ ์น ํ๋ฉด ๊ธฐ์ค์ผ๋ก offset pagination์ ์ง์ํ๋ค. page, size๋ฅผ ๋ฐ์์ ์ํ๋ ํ์ด์ง๋ฅผ ์กฐํํ๋ ๋ฐฉ์์ด๋ค.
์ ๋ ฌ ๊ธฐ์ค์ createdAt DESC, id DESC๋ค.
createdAt๋ง ์ฐ์ง ์๊ณ id๋ฅผ ๊ฐ์ด ๋ ์ด์ ๋ ๊ฐ์ ์๊ฐ์ ์ฌ๋ฌ ๊ธ์ด ๋ค์ด์ฌ ์ ์๊ธฐ ๋๋ฌธ์ด๋ค. ์ ๋ ฌ ๊ธฐ์ค์ด ํ๋๋ฟ์ด๋ฉด ๊ฐ์ createdAt ๊ฐ์ ๊ฐ์ง ๊ธ๋ค์ ์์๊ฐ DB ์คํ ๊ณํ์ ๋ฐ๋ผ ํ๋ค๋ฆด ์ ์๋ค. ๊ทธ๋์ ์ต์ ์์ฑ์ผ์ ๋จผ์ ๋ณด๊ณ , ๊ฐ์ ์๊ฐ์ด๋ฉด ๋ ๋์ค์ ์ ์ฅ๋ id๋ฅผ ํ ๋ฒ ๋ ๊ธฐ์ค์ผ๋ก ๋๋ค.
offset ๋ฐฉ์์ ์น ํ์ด์ง ๋ฒํธ UI์ ์ ๋ง์ง๋ง, ํ์ด์ง๊ฐ ๋๋ฌด ๊น์ด์ง๋ฉด DB๊ฐ ์์ชฝ ๋ฐ์ดํฐ๋ฅผ ๋ง์ด ๊ฑด๋๋ฐ์ด์ผ ํ๋ค. ๊ทธ๋์ MAX_OFFSET_PAGE ์ ํ์ ๋๋ค.
๋ชจ๋ฐ์ผ ๋ฌดํ์คํฌ๋กค์ด๋ ๊น์ ํ์ด์ง ํ์์ cursor pagination์ด ๋ ๋ง๋ค. ํ์ฌ๋ cursor ํ๋ผ๋ฏธํฐ๊ฐ ์์ผ๋ฉด cursor ๊ธฐ๋ฐ ์กฐํ๋ก ๋ถ๋ฆฌ๋์ด ์๊ณ , ๋ค์ ์ปค์๋ฅผ ์๋ต์ ๋ด๋ ค์ฃผ๋ ๊ตฌ์กฐ๋ฅผ ์ค๋นํด๋๋ค.
๊ฒ์๊ธ ์์ ์ ์์ฑ์ ๋ณธ์ธ๋ง ๊ฐ๋ฅํ๊ฒ ์ก์๋ค. ๊ด๋ฆฌ์๋ ํ์ธ์ ๊ธ์ ์์ ํ์ง ์๋๋ค. ๊ด๋ฆฌ์๊ฐ ๋ณธ๋ฌธ์ ์ง์ ๋ฐ๊พธ๋ฉด ์ ์์ฑ์์ ์๋์ ์ฑ ์ ์์ฌ๊ฐ ์์ด๊ธฐ ๋๋ฌธ์ด๋ค. ๊ด๋ฆฌ์๋ ์ญ์ ๋ ์ฐจ๋จ ๊ฐ์ ์ด์ ์กฐ์น๋ง ํ๋ ์ชฝ์ด ๋ ๋ช ํํ๋ค.
๊ฒ์ฆ ํ๋ฆ์ ๊ธ ์ ํ์ ๋ฐ๋ผ ๋๋๋ค.
- ํ์ ๊ธ: ๋ก๊ทธ์ธํ ์ฌ์ฉ์์
publicId์ ๊ฒ์๊ธ ์์ฑ์์publicId๊ฐ ๊ฐ์์ผ ํ๋ค. - ๋ก๊ทธ์ธ ํ์์ด ์์ฑํ ์ต๋ช ๊ธ: ํ๋ฉด์๋ ์ต๋ช ์ผ๋ก ๋ณด์ด์ง๋ง ์๋ฒ์๋ ์์ฑ์ ํ์ ์ ๋ณด๊ฐ ์์ผ๋ฏ๋ก, ๊ฐ์ ํ์์ด๋ฉด ๋น๋ฐ๋ฒํธ ์์ด ์์ ํ ์ ์๋ค.
- ๋น๋ก๊ทธ์ธ ์ต๋ช
๊ธ: ์์ฑ ๋น์ ์
๋ ฅํ
anonymousPassword๋ฅผ BCrypt๋ก ๋น๊ตํ๋ค.
์ด ๊ฒ์ฆ์ ํ๋ก ํธ์์ ๋ฒํผ์ ์จ๊ธฐ๋ ๊ฒ๋ง์ผ๋ก ๋๋ด์ง ์๋๋ค. ํ๋ก ํธ ๊ฐ๋๋ UX์ฉ์ด๊ณ , ์ค์ ๋ณด์์ PostService.validateEditPermission()์์ ํ ๋ฒ ๋ ๋ง๋๋ค. URL์ ์ง์ ์
๋ ฅํ๊ฑฐ๋ API๋ฅผ ์ง์ ํธ์ถํด๋ ์๋ฒ์์ 403์ผ๋ก ๋งํ์ผ ํ๊ธฐ ๋๋ฌธ์ด๋ค.
Hard Delete๋ DB row๋ฅผ ์ค์ ๋ก ์ญ์ ํ๋ ๋ฐฉ์์ด๋ค.
DELETE FROM post WHERE post_id = ?์ฅ์ ์ ํ ์ด๋ธ์ ๋ฐ์ดํฐ๊ฐ ๋จ์ง ์์ ๊ตฌ์กฐ๊ฐ ๋จ์ํ๋ค๋ ์ ์ด๋ค. ํ์ง๋ง ํ ๋ฒ ์ง์ฐ๋ฉด ๋ณต๊ตฌ๊ฐ ์ด๋ ต๊ณ , ๋๊ธ/์ถ์ฒ/์ด๋ฏธ์ง ๊ฐ์ ์ฐ๊ด ๋ฐ์ดํฐ๋ ๊ฐ์ด ์ด๋ป๊ฒ ์ฒ๋ฆฌํ ์ง ๊ฒฐ์ ํด์ผ ํ๋ค. ์ ๊ณ ๋ ๋ถ์์ด ์๊ฒผ์ ๋ โ๋ฌด์จ ๊ธ์ด ์์๋์งโ ํ์ธํ๊ธฐ๋ ์ด๋ ต๋ค.
Soft Delete๋ row๋ฅผ ์ง์ฐ์ง ์๊ณ ์ญ์ ํ์๋ง ๋จ๊ธฐ๋ ๋ฐฉ์์ด๋ค.
UPDATE post
SET is_deleted = true,
status = 'DELETED',
deleted_at = NOW()
WHERE post_id = ?์ฅ์ ์ ์ด๋ ฅ์ด ๋จ๋๋ค๋ ์ ์ด๋ค. ๋์ ๋ชจ๋ ์กฐํ์์ ์ญ์ ๋ ๊ธ์ ์ ์ธํด์ผ ํ๊ณ , unique ์ ์ฝ์ด๋ ์ธ๋ฑ์ค ์ค๊ณ๋ ์ญ์ ๋ฐ์ดํฐ๋ฅผ ๊ณ ๋ คํด์ผ ํ๋ค.
ํ์ฌ๋ Soft Delete๋ฅผ ์ ํํ๋ค. ์ด์ ๋ ๊ฒ์ํ ์๋น์ค์์ ์ญ์ ๊ฐ ๋จ์ํ ์ ์ฅ๊ณต๊ฐ ์ ๋ฆฌ ๋ฌธ์ ๊ฐ ์๋๊ธฐ ๋๋ฌธ์ด๋ค.
์ฒซ ๋ฒ์งธ ์ด์ ๋ ์ด์ ์ด๋ ฅ์ด๋ค. ์ฌ์ฉ์๊ฐ ๋ฌธ์ ์๋ ๊ธ์ ์ฌ๋ฆฐ ๋ค ๋ฐ๋ก ์ญ์ ํด๋ฒ๋ฆฌ๋ฉด, ๊ด๋ฆฌ์ ์ ์ฅ์์๋ ์ด๋ค ์ผ์ด ์์๋์ง ํ์ธํ๊ธฐ ์ด๋ ต๋ค. Soft Delete๋ ๊ธ์ ์ผ๋ฐ ์ฌ์ฉ์์๊ฒ๋ ์จ๊ธฐ๋ฉด์๋ ์๋ฒ์๋ ์ด๋ ฅ์ ๋จ๊ธธ ์ ์๋ค.
๋ ๋ฒ์งธ ์ด์ ๋ ๋๊ธ๊ณผ ์ถ์ฒ ๊ฐ์ ์ฃผ๋ณ ๋ฐ์ดํฐ ๋๋ฌธ์ด๋ค. ๊ฒ์๊ธ์ Hard Deleteํ๋ฉด ๋๊ธ, ์ด๋ฏธ์ง, ์ถ์ฒ ๋ฐ์ดํฐ๋ ๊ฐ์ด ์ญ์ ํ ์ง ๋จ๊ธธ์ง ๊ฒฐ์ ํด์ผ ํ๋ค. ์ง๊ธ ๋จ๊ณ์์๋ ์ค์ row๋ฅผ ์ ๊ฑฐํ๊ธฐ๋ณด๋ค isDeleted, status, deletedAt์ผ๋ก ์ํ๋ฅผ ๋ฐ๊พธ๋ ์ชฝ์ด ๋ ์์ ํ๋ค.
์ธ ๋ฒ์งธ ์ด์ ๋ UX๋ค. ๋ชฉ๋ก์์ ๋ฐฉ๊ธ ๋ณด์ด๋ ๊ธ์ด ์ญ์ ๋์์ ๋ ์์ ํ ์ฌ๋ผ์ง๋ ๊ฒ๋ณด๋ค, ํ์ํ๋ฉด โ์ญ์ ๋ ๊ฒ์๊ธ์ ๋๋คโ์ฒ๋ผ ์ํ๋ฅผ ํํํ๋ ์ชฝ์ด ํ๋ฆ์ ์ค๋ช ํ๊ธฐ ์ฝ๋ค.
ํ์ฌ ๊ตฌํ์ @SQLDelete์ @SQLRestriction("is_deleted = false")๋ฅผ ์ฌ์ฉํ๋ค. ๊ทธ๋์ ์ญ์ ํธ์ถ์ update๋ก ๋ฐ๋๊ณ , ๊ธฐ๋ณธ ์กฐํ์์๋ ์ญ์ ๋ ๊ธ์ด ๋น ์ง๋ค.
์์ธ ์กฐํ๋ PostRepository.findWithMemberAndCategoryByPublicId()๋ฅผ ์ฌ์ฉํ๋ค.
@Query("SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.publicId = :publicId")
Optional<Post> findWithMemberAndCategoryByPublicId(@Param("publicId") String publicId);member๋ ๋น๋ก๊ทธ์ธ ์ต๋ช
๊ธ์์ ์์ ์ ์์ผ๋ฏ๋ก LEFT JOIN FETCH๋ฅผ ์ด๋ค. category๋ ๊ฒ์๊ธ์ ๋ฐ๋์ ์์ด์ผ ํ๋ฏ๋ก JOIN FETCH๋ฅผ ์ด๋ค.
์ด ์ฟผ๋ฆฌ๋ก ์์ธ ์กฐํ ์ ๊ฒ์๊ธ, ์์ฑ์, ์นดํ
๊ณ ๋ฆฌ๋ฅผ ํ ๋ฒ์ ๊ฐ์ ธ์จ๋ค. ๊ทธ๋ ์ง ์์ผ๋ฉด ์๋น์ค๋ DTO ๋ณํ ๊ณผ์ ์์ post.getMember(), post.getCategory()๋ฅผ ์ ๊ทผํ ๋ ์ถ๊ฐ select๊ฐ ๋ฐ์ํ ์ ์๋ค.
๋ชฉ๋ก ์กฐํ๋ ์์ฑ์์ ์นดํ ๊ณ ๋ฆฌ๋ฅผ ๊ฐ์ด ๋ณด์ฌ์ค์ผ ํ๋ฏ๋ก fetch join์ ์ ์ฉํ๋ค.
@Query("SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.category.code = :categoryCode")
Page<Post> findByCategoryCodeWithMemberAndCategory(@Param("categoryCode") String categoryCode, Pageable pageable);์ด๋ฏธ์ง๋ ๋ชฉ๋ก ์กฐํ์์ ์กฐ์ธํ์ง ์๋๋ค. ๋ชฉ๋ก์๋ ์ ์ฒด ์ด๋ฏธ์ง ๋ฐฐ์ด์ด ํ์ํ์ง ์๊ณ , ์ด๋ฏธ์ง ์กด์ฌ ์ฌ๋ถ๋ง ํ์ํ๋ค. ๊ทธ๋์ hasImage๋ฅผ ์ฌ์ฉํ๋ค. ์ด ์ ํ์ ์ฝ๊ธฐ ์ฑ๋ฅ์ ์ป๋ ๋์ , ์์ฑ/์์ ์ hasImage๋ฅผ ๋ฐ๋์ ๊ฐ์ด ๊ฐฑ์ ํด์ผ ํ๋ค๋ ๋๊ฐ๊ฐ ์๋ค. ๊ทธ ๋ถ๋ถ์ Post.replaceImages()๋ก ๋ฌถ์ด์ ์ฒ๋ฆฌํ๋ค.
๊ฒ์๊ธ ๋๋ฉ์ธ์์ ์ฃผ๋ก ๋ด๋ ค๊ฐ๋ ์์ธ๋ ๋ค์๊ณผ ๊ฐ๋ค.
POST_NOT_FOUND (404): ์กด์ฌํ์ง ์๋ ๊ธ, ์ญ์ ๋ ๊ธ, ์ผ๋ฐ ์ฌ์ฉ์๊ฐ ๋ณผ ์ ์๋ ๊ธ์ ์ ๊ทผํ ๊ฒฝ์ฐPOST_CATEGORY_NOT_FOUND (404): ์์ฒญํcategoryCode์ ํด๋นํ๋ ๊ฒ์ํ์ด ์๋ ๊ฒฝ์ฐACCESS_DENIED (403): ์์ฑ์๊ฐ ์๋ ์ฌ์ฉ์๊ฐ ์์ /์ญ์ ๋ฅผ ์๋ํ ๊ฒฝ์ฐINVALID_ANON_PASSWORD (403): ์ต๋ช ๊ธ ์์ /์ญ์ ๋น๋ฐ๋ฒํธ๊ฐ ํ๋ฆฐ ๊ฒฝ์ฐINVALID_CREDENTIALS (401): ๋ก๊ทธ์ธ์ด ํ์ํ ์ผ๋ฐ ๊ฒ์๊ธ ์์ฑ์์ ์ธ์ฆ ์ ๋ณด๊ฐ ์๋ ๊ฒฝ์ฐINVALID_INPUT (400): ์ต๋ช ๊ธ ์์ฑ/์ถ์ฒยท๋น์ถ์ฒ ๋ฑ์์ ํ์ํ ๊ฐ์ด ๋น ์ง ๊ฒฝ์ฐINVALID_PAGE_SIZE (400): ํ์ฉ ๋ฒ์๋ฅผ ๋ฒ์ด๋size์์ฒญINVALID_PAGE_LIMIT (400): offset page ์ ํ์ ๋๊ธด ์์ฒญ
๊ฒ์๊ธ ์์ฑ, ์์ , ์ญ์ ๊ฐ์ CUD ์์ฒญ์ CSRF ๊ณต๊ฒฉ ํ์ ์ด ๋๊ธฐ ์ฝ๋ค.
Spring Security์ CookieCsrfTokenRepository.withHttpOnlyFalse()๋ฅผ ์ ์ฉํ๋ค.
์ด ๋ฐฉ์์ Double Submit Cookie ํจํด์ผ๋ก ๋์ํ๋ค. ๋ฐฑ์๋๊ฐ XSRF-TOKEN ์ฟ ํค๋ฅผ ๋ฐ๊ธํ๋ฉด, ํ๋ก ํธ์๋๊ฐ ์์ ๋ณ๊ฒฝ ์์ฒญ(POST, PUT, DELETE)์ ๋ณด๋ผ ๋ ์ฟ ํค ๊ฐ์ ์ฝ์ด X-XSRF-TOKEN HTTP ํค๋์ ๋ด์์ ๋ณด๋ธ๋ค. ์๋ฒ์ CsrfFilter๋ ์ฟ ํค์ ํ ํฐ ๊ฐ๊ณผ ํค๋์ ํ ํฐ ๊ฐ์ด ์ผ์นํ๋์ง ๋น๊ตํ์ฌ ๊ฒ์ฆํ๋ค.
์ธ๋ถ ํดํน ์ฌ์ดํธ๋ ๋์ผ ์ถ์ฒ ์ ์ฑ
(SOP) ์ ์ฝ์ผ๋ก ์ธํด ์ฌ์ฉ์์ XSRF-TOKEN ์ฟ ํค๋ฅผ ์๋ฐ์คํฌ๋ฆฝํธ๋ก ์ฝ์ ์ ์์ด X-XSRF-TOKEN ํค๋๋ฅผ ์์ฑํ์ง ๋ชปํ๋ฏ๋ก ์์กฐ๋ ์์ฒญ์ 403 Forbidden์ผ๋ก ์ฐจ๋จ๋๋ค.
snowthing/ (ํ๋ก์ ํธ ์ต์์ ๋ฃจํธ)
โโโ backend/ # Spring Boot ๋ฐฑ์๋ ์ ํ๋ฆฌ์ผ์ด์
โ โโโ src/main/java/com/ikae/snowthing/
โ โ โโโ domain/auth/ # ์ธ์
๋ก๊ทธ์ธ/๋ก๊ทธ์์ ์ปจํธ๋กค๋ฌ & ์๋น์ค
โ โ โโโ domain/member/ # ํ์, N:M ์คํค์ฅ/์ฑํฅ ์ํฐํฐ & ๋ ํฌ์งํ ๋ฆฌ
โ โ โโโ global/config/ # SecurityConfig, CorsConfig, DataInitializer
โ โโโ src/test/java/ # 25๊ฐ ๋ฐฑ์๋ ๋จ์/ํตํฉ/๋ณด์ ํ
์คํธ ์ํธ
โโโ frontend/ # Next.js ํ๋ก ํธ์๋ ์ ํ๋ฆฌ์ผ์ด์
โ โโโ app/signup/page.tsx # ์ด๋ฉ์ผ/๋น๋ฐ๋ฒํธ ์ ๊ท์ & N:M ์ฒดํฌ๋ฐ์ค ํ์๊ฐ์
UI
โ โโโ app/login/page.tsx # credentials: 'include' ์ธ์
๋ก๊ทธ์ธ UI
โ โโโ app/page.tsx # ๋ฉ์ธ ํ๋กํ ๋์๋ณด๋ & ํ๋กํ ์์ UI (PUT)
โโโ docs/ # ๊ธฐํ, ์ํคํ
์ฒ, ERD, API ๋ช
์ธ ๋ฌธ์
โ โโโ conception/ # ๊ธฐํ ๋ฐ ์ํคํ
์ฒ ๋ช
์ธ์ (sprint01/)
โ โโโ project/ # work.md ์์
์ด๋ ฅ ๋ฐ sql_logs ๋ฆฌํฌํธ
โโโ README.md
cd backend
.\gradlew.bat test # ์ ์ฒด 25๊ฐ ํตํฉ/๋จ์ ํ
์คํธ ์คํ
.\gradlew.bat bootRun # 8080 ํฌํธ ๋ฐฑ์๋ ์๋ฒ ๊ธฐ๋cd frontend
npm run lint # ์ฝ๋ ์คํ์ผ ๊ฒ์ฆ
npm run build # ํ๋ก ํธ์๋ ํ๋ก๋์
๋น๋
npm run dev # 3000 ํฌํธ ๊ฐ๋ฐ ์๋ฒ ๊ธฐ๋