Compare commits

..

9 Commits

Author SHA1 Message Date
5869b8fe14 주석 수정 2026-02-03 15:05:28 +09:00
ParkWonYeop
0c72a603b3 . 2025-12-29 14:13:51 +09:00
ParkWonYeop
46a8a43163 . 2025-12-27 17:52:40 +09:00
pwy3282040@msecure.co
884853586d feat: 댓글 구조 변경
작성자 표시를 위해 CommentResponse에 isPostAuthor 필드 추가
2025-12-27 00:14:57 +09:00
pwy3282040@msecure.co
ef6ffa5670 feat: 댓글 구조 변경
작성자 표시를 위해 CommentResponse에 isPostAuthor 필드 추가
2025-12-26 15:07:35 +09:00
pwy3282040@msecure.co
2f6cb41764 docs: README.md 수정
관련 문서 링크 변경
2025-12-26 15:02:57 +09:00
pwy3282040@msecure.co
60d645f47b feat: 블로그 핵심 기능 구현 (댓글, 태그, 카테고리, 이미지)
[댓글 시스템]
- 계층형(대댓글) 구조 및 회원/비회원 하이브리드 작성 로직 구현
- 비회원 댓글용 비밀번호 암호화 저장 (PasswordEncoder 적용)
- 관리자용 댓글 강제 삭제 및 전체 댓글 모니터링(Dashboard) API 구현

[태그 & 카테고리]
- N:M 태그 시스템(PostTag) 엔티티 설계 및 게시글 작성 시 자동 저장 로직
- 계층형(Tree) 카테고리 구조 구현 및 관리자 생성/삭제 API
- QueryDSL 검색 조건에 태그 및 카테고리 필터링 추가

[이미지 업로드]
- AWS S3 (MinIO) 연동 및 Bucket 자동 생성/Public 정책 설정
- 마크다운 에디터용 이미지 업로드 API 구현
2025-12-26 14:47:48 +09:00
pwy3282040@msecure.co
6fbfcaf90b feat: JWT 기반 인증 시스템 및 이메일 가입 구현
[인프라]
- Docker Compose 구성 (DB, Redis, MinIO)
- Spring Boot 3.5.9 + Kotlin + Gradle 설정

[인증/보안]
- Spring Security 및 JWT 필터 설정
- RTR(Refresh Token Rotation) 방식의 토큰 재발급 로직 구현
- Redis를 활용한 Refresh Token 및 이메일 인증 코드 관리

[기능 구현]
- 회원가입 (이메일 인증 포함)
- 로그인/로그아웃/토큰재발급 API 구현
- 공통 응답(ApiResponse) 및 전역 예외 처리(GlobalExceptionHandler) 적용
2025-12-26 13:07:14 +09:00
pwy3282040@msecure.co
49d435079f feat: JWT 기반 인증 시스템 및 이메일 가입 구현
[인프라]
- Docker Compose 구성 (DB, Redis, MinIO)
- Spring Boot 3.5.9 + Kotlin + Gradle 설정

[인증/보안]
- Spring Security 및 JWT 필터 설정
- RTR(Refresh Token Rotation) 방식의 토큰 재발급 로직 구현
- Redis를 활용한 Refresh Token 및 이메일 인증 코드 관리

[기능 구현]
- 회원가입 (이메일 인증 포함)
- 로그인/로그아웃/토큰재발급 API 구현
- 공통 응답(ApiResponse) 및 전역 예외 처리(GlobalExceptionHandler) 적용
2025-12-26 12:58:51 +09:00
60 changed files with 3427 additions and 77 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
HELP.md HELP.md
.gradle .gradle
.env
build/ build/
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/ !**/src/main/**/build/
@@ -38,4 +39,6 @@ out/
### Kotlin ### ### Kotlin ###
.kotlin .kotlin
### docker ###
postgres_data postgres_data

View File

@@ -80,5 +80,5 @@ $ docker-compose logs -f blog-api
--- ---
## 📝 관련 문서 ## 📝 관련 문서
- [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/p13joh_beKNJ6R-I9KrFW) - [**ENDPOINT**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/_csY9ZzOnSbt4bTnIA_9w)
- [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/7fRNXK9utxG4mcekWivGK) - [**API 명세서**](https://affine.wypark.me/workspace/f85df0c4-a315-4166-94a8-6558cdafff1d/9axPww7llEcSH3nOZzi-m)

View File

@@ -30,6 +30,8 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("io.github.cdimascio:dotenv-java:3.0.0")
// 2. Kotlin Modules // 2. Kotlin Modules
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

View File

@@ -9,16 +9,28 @@ services:
environment: environment:
# Database # Database
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db
- SPRING_DATASOURCE_USERNAME=wypark - SPRING_DATASOURCE_USERNAME=${DB_USER}
- SPRING_DATASOURCE_PASSWORD=your_password - SPRING_DATASOURCE_PASSWORD=${DB_PASS}
# Redis # Redis
- SPRING_DATA_REDIS_HOST=redis - SPRING_DATA_REDIS_HOST=redis
- SPRING_DATA_REDIS_PORT=6379 - SPRING_DATA_REDIS_PORT=6379
# AWS S3 / MinIO (Docker 내부 통신용) # AWS S3 / MinIO (Docker 내부 통신용)
- CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin - SPRING_CLOUD_AWS_CREDENTIALS_ACCESS_KEY=admin
- CLOUD_AWS_CREDENTIALS_SECRET_KEY=password - SPRING_CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
- CLOUD_AWS_REGION_STATIC=ap-northeast-2 - SPRING_CLOUD_AWS_REGION_STATIC=ap-northeast-2
- CLOUD_AWS_S3_ENDPOINT=http://minio:9000 - SPRING_CLOUD_AWS_S3_ENDPOINT=http://minio:9000
# SMTP 메일 설정
- SPRING_MAIL_HOST=smtp.gmail.com
- SPRING_MAIL_PORT=587
- SPRING_MAIL_USERNAME=${MAIL_USER}
- SPRING_MAIL_PASSWORD=${MAIL_PASS}
# SMTP 세부 설정
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED=true
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT=5000
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT=5000
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT=5000
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -35,13 +47,13 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
POSTGRES_USER: wypark POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: your_password POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: blog_db POSTGRES_DB: blog_db
volumes: volumes:
- ./postgres_data:/var/lib/postgresql/data - ./postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U wypark -d blog_db"] test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d blog_db" ]
interval: 5s interval: 5s
retries: 5 retries: 5
networks: networks:
@@ -53,7 +65,7 @@ services:
container_name: blog-redis container_name: blog-redis
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: [ "CMD", "redis-cli", "ping" ]
interval: 5s interval: 5s
retries: 5 retries: 5
networks: networks:

View File

@@ -1,11 +1,39 @@
package me.wypark.blogbackend package me.wypark.blogbackend
import io.github.cdimascio.dotenv.Dotenv
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.data.web.config.EnableSpringDataWebSupport
/**
* [블로그 백엔드 애플리케이션 진입점]
*
* @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO):
* Spring Data Web의 페이징 직렬화 방식을 설정합니다.
* 최신 Spring Boot 버전에서는 PagedModel(구조체) 반환이 기본값이지만,
* 기존 프론트엔드와의 호환성 및 명시적인 DTO 변환을 선호하여 'VIA_DTO' 모드를 채택했습니다.
* 이는 내부 엔티티 구조가 외부 API 스펙에 직접 노출되는 것을 방지합니다.
*/
@SpringBootApplication @SpringBootApplication
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
class BlogBackendApplication class BlogBackendApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {
/**
* [환경 변수 로드 전략: Twelve-Factor App]
*
* 로컬 개발 환경의 편의성을 위해 '.env' 파일을 지원하지만,
* 실제 운영(Production) 환경에서는 CI/CD 파이프라인을 통해 주입된 시스템 환경 변수를 우선합니다.
*
* 'ignoreIfMissing()' 옵션을 사용하여 배포 환경에서 .env 파일이 없더라도
* 애플리케이션이 중단되지 않고 시스템 환경 변수로 fallback 되도록 구성했습니다.
*/
val dotenv = Dotenv.configure().ignoreIfMissing().load()
// 로드된 환경 변수를 Spring Boot가 인식할 수 있도록 시스템 프로퍼티로 이관(Migration)
dotenv.entries().forEach { entry ->
System.setProperty(entry.key, entry.value)
}
runApplication<BlogBackendApplication>(*args) runApplication<BlogBackendApplication>(*args)
} }

View File

@@ -0,0 +1,40 @@
package me.wypark.blogbackend.api.common
/**
* [API 공통 응답 규격]
*
* 클라이언트(Frontend)와 서버 간의 통신 프로토콜을 통일하기 위한 Wrapper 클래스입니다.
* 모든 REST API 응답은 이 클래스로 감싸서 반환되며, 이를 통해 예외 발생 시에도
* 일관된 JSON 구조를 보장하여 클라이언트의 에러 핸들링 복잡도를 낮춥니다.
*
* @param T 실제 응답 데이터의 타입 (Generic)
*/
data class ApiResponse<T>(
// 비즈니스 로직 처리 결과 코드 (HTTP Status와는 별개로 세부적인 에러 코드를 정의하여 사용 가능)
val code: String = "SUCCESS",
// 클라이언트에게 노출할 알림 메시지 (Toast UI 등에서 활용)
val message: String = "요청이 성공했습니다.",
// 실제 전송할 데이터 Payload (실패 시 null)
val data: T? = null
) {
companion object {
/**
* 성공 응답을 생성하는 정적 팩토리 메서드입니다.
* 데이터가 없는 경우(예: 삭제/수정 완료)에도 일관된 형식을 유지하기 위해 기본 메시지를 제공합니다.
*/
fun <T> success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse<T> {
return ApiResponse("SUCCESS", message, data)
}
/**
* 실패 응답을 생성하는 정적 팩토리 메서드입니다.
* 에러 상황에서는 data 필드가 불필요하므로, <Nothing> 타입을 사용하여
* 타입 안정성(Type Safety)을 확보하고 불필요한 객체 생성을 방지합니다.
*/
fun error(message: String, code: String = "ERROR"): ApiResponse<Nothing> {
return ApiResponse(code, message, null)
}
}
}

View File

@@ -0,0 +1,85 @@
package me.wypark.blogbackend.api.controller
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.*
import me.wypark.blogbackend.domain.auth.AuthService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.*
/**
* [인증/인가 컨트롤러]
*
* JWT(Json Web Token) 기반의 Stateless 인증 처리를 담당하는 엔드포인트 집합입니다.
* 표준적인 Access/Refresh Token 패턴을 사용하며, 보안 강화를 위해
* Refresh Token Rotation(RTR) 전략을 적용하여 탈취된 토큰의 재사용을 방지합니다.
*/
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService
) {
/**
* 신규 회원 가입을 요청합니다.
*
* 봇(Bot)이나 무분별한 가입을 방지하기 위해, 가입 요청 즉시 인증 메일을 발송합니다.
* 이메일 인증이 완료(`isVerified = true`)되기 전까지는 로그인이 제한됩니다.
*/
@PostMapping("/signup")
fun signup(@RequestBody @Valid request: SignupRequest): ResponseEntity<ApiResponse<Nothing>> {
authService.signup(request)
return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요."))
}
/**
* 이메일 인증 코드를 검증하여 계정을 활성화합니다.
* Redis에 TTL(Time-To-Live)로 저장된 임시 코드와 사용자의 입력을 대조합니다.
*/
@PostMapping("/verify")
fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity<ApiResponse<Nothing>> {
authService.verifyEmail(request.email, request.code)
return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다."))
}
/**
* 사용자 자격 증명(Email/Password)을 검증하고 토큰 쌍을 발급합니다.
* 인증 성공 시 Access Token과 Refresh Token이 모두 반환됩니다.
*/
@PostMapping("/login")
fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity<ApiResponse<TokenDto>> {
val tokenDto = authService.login(request)
return ResponseEntity.ok(ApiResponse.success(tokenDto))
}
/**
* Access Token 만료 시, Refresh Token을 사용하여 토큰을 갱신합니다 (Silent Refresh).
*
* [보안 전략: Refresh Token Rotation]
* 토큰 갱신 시 기존 Refresh Token은 폐기되고 새로운 Refresh Token이 발급됩니다.
* 만약 이미 폐기된 토큰으로 재요청이 들어올 경우, 탈취된 것으로 간주하여 해당 유저의 모든 토큰을 무효화합니다.
*/
@PostMapping("/reissue")
fun reissue(@RequestBody request: ReissueRequest): ResponseEntity<ApiResponse<TokenDto>> {
val tokenDto = authService.reissue(request.accessToken, request.refreshToken)
return ResponseEntity.ok(ApiResponse.success(tokenDto))
}
/**
* 로그아웃 처리를 수행합니다.
*
* JWT 특성상 클라이언트가 토큰을 삭제하는 것이 기본이지만,
* 서버 측에서도 Redis에 저장된 Refresh Token을 즉시 삭제(Evict)하여
* 더 이상 해당 토큰으로 액세스 토큰을 재발급받지 못하도록 차단합니다.
*/
@PostMapping("/logout")
fun logout(@AuthenticationPrincipal user: User): ResponseEntity<ApiResponse<Nothing>> {
authService.logout(user.username) // user.username은 SecurityContext에 저장된 email입니다.
return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다."))
}
}
// DTO가 다른 곳에서 재사용 않아 응집도를 위해 같은 파일 내에 정의
data class ReissueRequest(val accessToken: String, val refreshToken: String)

View File

@@ -0,0 +1,33 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.CategoryResponse
import me.wypark.blogbackend.domain.category.CategoryService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* [카테고리 조회 API]
*
* 일반 사용자(Public)에게 노출되는 카테고리 관련 엔드포인트입니다.
* 블로그의 탐색(Navigation) 기능을 담당하며, 데이터 변경이 없는 읽기 전용(Read-Only) 작업만을 수행합니다.
*/
@RestController
@RequestMapping("/api/categories")
class CategoryController(
private val categoryService: CategoryService
) {
/**
* 카테고리 전체 계층 구조를 조회합니다.
*
* 프론트엔드 사이드바나 헤더 메뉴 렌더링을 위해 설계되었으며,
* 불필요한 네트워크 왕복(Round Trip)을 줄이기 위해 한 번의 요청으로 중첩된(Nested) 트리 형태의 전체 데이터를 반환합니다.
*/
@GetMapping
fun getCategoryTree(): ResponseEntity<ApiResponse<List<CategoryResponse>>> {
return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree()))
}
}

View File

@@ -0,0 +1,73 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.CommentDeleteRequest
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.api.dto.CommentSaveRequest
import me.wypark.blogbackend.domain.comment.CommentService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.*
/**
* [일반 사용자용 댓글 API]
*
* 게시글에 대한 사용자 참여(Social Interaction)를 담당하는 컨트롤러입니다.
* 사용자 경험(UX)을 고려하여, 회원가입 없이도 자유롭게 소통할 수 있도록
* 회원(Member)과 비회원(Guest)의 접근을 동시에 허용하는 하이브리드 로직을 수행합니다.
*/
@RestController
@RequestMapping("/api/comments")
class CommentController(
private val commentService: CommentService
) {
/**
* 특정 게시글의 전체 댓글 목록을 조회합니다.
*
* 단순 리스트가 아닌, 대댓글(Nested Comments) 구조를 유지한 상태로 반환하여
* 클라이언트가 별도의 재귀 로직 구현 없이 트리 형태로 즉시 렌더링할 수 있도록 지원합니다.
*/
@GetMapping
fun getComments(@RequestParam postSlug: String): ResponseEntity<ApiResponse<List<CommentResponse>>> {
return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug)))
}
/**
* 댓글을 작성합니다 (회원/비회원 공용).
*
* 참여 장벽을 낮추기 위해 로그인 여부를 강제하지 않습니다.
* Security Context의 User 객체가 null일 경우 비회원으로 간주하며,
* 이 경우 RequestBody에 포함된 닉네임과 비밀번호를 사용하여 임시 신원을 생성합니다.
*/
@PostMapping
fun createComment(
@RequestBody request: CommentSaveRequest,
@AuthenticationPrincipal user: User? // 비회원 접근 시 null (Optional Principal)
): ResponseEntity<ApiResponse<Long>> {
val email = user?.username // 인증된 사용자라면 email 추출
val commentId = commentService.createComment(request, email)
return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다."))
}
/**
* 댓글을 삭제합니다.
*
* 작성자 유형(회원/비회원)에 따라 검증 전략(Strategy)이 달라집니다.
* - 회원: 현재 로그인한 사용자의 ID와 댓글 작성자 ID의 일치 여부를 검증
* - 비회원: 댓글 작성 시 설정한 비밀번호(Guest Password)의 일치 여부를 검증
*/
@DeleteMapping("/{id}")
fun deleteComment(
@PathVariable id: Long,
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원일 경우에만 바디가 필요함
@AuthenticationPrincipal user: User?
): ResponseEntity<ApiResponse<Nothing>> {
val email = user?.username
val password = request?.guestPassword
commentService.deleteComment(id, email, password)
return ResponseEntity.ok(ApiResponse.success(message = "댓글이 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,67 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.PostResponse
import me.wypark.blogbackend.api.dto.PostSummaryResponse
import me.wypark.blogbackend.domain.post.PostService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* [일반 사용자용 게시글 조회 API]
*
* 블로그의 핵심 콘텐츠인 게시글(Post) 데이터를 제공하는 Public 컨트롤러입니다.
* 방문자의 조회 요청을 처리하며, 검색 엔진 최적화(SEO)를 고려하여
* 내부 식별자(ID)가 아닌 의미 있는 문자열(Slug) 기반의 URL 설계를 채택했습니다.
*/
@RestController
@RequestMapping("/api/posts")
class PostController(
private val postService: PostService
) {
/**
* 게시글 목록을 조회하거나 조건에 맞춰 검색합니다.
*
* [통합 검색 엔드포인트]
* 단순 목록 조회와 필터링(검색) 로직을 하나의 API로 통합하여 프론트엔드 구현을 단순화했습니다.
* 필터 조건(keyword, category, tag) 유무에 따라 동적 쿼리(QueryDSL) 또는 기본 페이징 쿼리로 분기 처리됩니다.
*
* @param keyword 제목 또는 본문 검색어 (Optional)
* @param category 카테고리 이름 (Optional, 프론트엔드 파라미터명: category)
* @param tag 태그 이름 (Optional)
*/
@GetMapping
fun getPosts(
@RequestParam(required = false) keyword: String?,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) tag: String?,
@PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
// 필터 조건이 하나라도 존재하면 동적 쿼리(Search) 실행, 없으면 기본 목록 조회(List) 수행
return if (keyword != null || category != null || tag != null) {
val posts = postService.searchPosts(keyword, category, tag, pageable)
ResponseEntity.ok(ApiResponse.success(posts))
} else {
val posts = postService.getPosts(pageable)
ResponseEntity.ok(ApiResponse.success(posts))
}
}
/**
* 게시글 상세 정보를 조회합니다.
*
* URL에 ID(숫자) 대신 제목 기반의 Slug를 사용하여 가독성과 SEO 점수를 높입니다.
* 상세 조회 성공 시, 서비스 레이어에서 조회수(View Count) 증가 트랜잭션이 함께 수행됩니다.
*/
@GetMapping("/{slug}")
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
val post = postService.getPostBySlug(slug)
return ResponseEntity.ok(ApiResponse.success(post))
}
}

View File

@@ -0,0 +1,35 @@
package me.wypark.blogbackend.api.controller
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.ProfileResponse
import me.wypark.blogbackend.domain.profile.BlogProfileService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* [일반 사용자용 프로필 조회 API]
*
* 블로그 방문자들에게 운영자(Owner)의 정보를 제공하는 Public 컨트롤러입니다.
* 수정 권한이 필요한 관리자 영역(AdminProfileController)과 분리하여,
* 불필요한 인증 로직 없이 누구나 빠르게 조회할 수 있도록 설계된 읽기 전용(Read-Only) 엔드포인트입니다.
*/
@RestController
@RequestMapping("/api/profile")
class ProfileController(
private val blogProfileService: BlogProfileService
) {
/**
* 블로그 운영자의 프로필 정보를 조회합니다.
*
* 단일 사용자 블로그(Single User Blog) 특성상 별도의 사용자 ID 파라미터 없이
* 시스템에 설정된 유일한 프로필 데이터를 반환합니다.
* (주로 메인 화면의 사이드바나 About 페이지 렌더링에 사용됩니다.)
*/
@GetMapping
fun getProfile(): ResponseEntity<ApiResponse<ProfileResponse>> {
return ResponseEntity.ok(ApiResponse.success(blogProfileService.getProfile()))
}
}

View File

@@ -0,0 +1,62 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.CategoryCreateRequest
import me.wypark.blogbackend.api.dto.CategoryUpdateRequest
import me.wypark.blogbackend.domain.category.CategoryService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* [관리자용 카테고리 관리 API]
*
* 블로그의 카테고리 계층 구조(Tree Structure)를 조작하는 컨트롤러입니다.
* 단순한 CRUD 외에도 부모-자식 관계 설정 및 구조 변경(이동) 로직을 포함하고 있습니다.
*
* Note:
* - 데이터 무결성을 위해 모든 변경 작업은 트랜잭션 범위 안에서 유효성 검증(순환 참조 방지 등) 후 수행됩니다.
*/
@RestController
@RequestMapping("/api/admin/categories")
class AdminCategoryController(
private val categoryService: CategoryService
) {
/**
* 신규 카테고리를 생성합니다.
* parentId가 없을 경우 최상위(Root) 카테고리로 생성되며, 있을 경우 해당 노드의 자식으로 연결됩니다.
*/
@PostMapping
fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity<ApiResponse<Long>> {
val id = categoryService.createCategory(request)
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
}
/**
* 카테고리 정보(이름 및 계층 위치)를 수정합니다.
*
* 단순 이름 변경뿐만 아니라, 부모 카테고리를 변경하여 트리 구조 내에서 위치를 이동시키는 기능도 수행합니다.
* 위치 이동 시 순환 참조(Cycle)가 발생하지 않도록 서비스 레이어에서 검증 로직이 수행됩니다.
*/
@PutMapping("/{id}")
fun updateCategory(
@PathVariable id: Long,
@RequestBody request: CategoryUpdateRequest
): ResponseEntity<ApiResponse<Nothing>> {
categoryService.updateCategory(id, request)
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 수정되었습니다."))
}
/**
* 카테고리를 삭제합니다.
*
* [삭제 정책]
* - 하위 카테고리(Children)는 재귀적으로 함께 삭제됩니다 (Cascade).
* - 해당 카테고리에 속해있던 게시글(Post)들은 삭제되지 않고 '미분류(Category = NULL)' 상태로 변경되어 보존됩니다.
*/
@DeleteMapping("/{id}")
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
categoryService.deleteCategory(id)
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,50 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.AdminCommentResponse
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.domain.comment.CommentService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* [관리자용 댓글 관리 API]
*
* 블로그 내 모든 댓글 활동을 모니터링하고 중재(Moderation)하는 컨트롤러입니다.
* 개별 게시글 단위로 조회하는 일반 API와 달리, 시스템 전체의 댓글 흐름을 파악하는 데 초점이 맞춰져 있습니다.
*/
@RestController
@RequestMapping("/api/admin/comments")
class AdminCommentController(
private val commentService: CommentService
) {
/**
* 부적절한 댓글을 강제로 삭제합니다 (Moderation).
*
* 일반 사용자의 삭제 요청과 달리 작성자 본인 확인 절차(Ownership Check)를 건너뛰고,
* 관리자 권한으로 즉시 데이터를 제거합니다.
*/
@DeleteMapping("/{id}")
fun deleteComment(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
commentService.deleteCommentByAdmin(id)
return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다."))
}
/**
* 관리자 대시보드용 전체 댓글 목록을 조회합니다.
*
* 특정 게시글에 종속되지 않고, 최근 작성된 순서대로 모든 댓글을 페이징하여 반환합니다.
* 이를 통해 관리자는 스팸이나 악성 댓글 발생 여부를 한눈에 파악할 수 있습니다.
*/
@GetMapping
fun getAllComments(
@PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<AdminCommentResponse>>> {
return ResponseEntity.ok(ApiResponse.success(commentService.getAllComments(pageable)))
}
}

View File

@@ -0,0 +1,44 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.domain.image.ImageService
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
/**
* [관리자용 이미지 업로드 API]
*
* 게시글 본문(Markdown) 삽입용 이미지나 프로필 사진 등, 블로그 운영에 필요한
* 정적 리소스(Static Resources)를 처리하는 컨트롤러입니다.
*
* 스토리지 저장소(S3/MinIO)와의 직접적인 통신은 Service Layer에 위임하며,
* 클라이언트에게는 업로드된 리소스의 접근 가능한 URL을 반환하여 즉시 렌더링 가능하도록 합니다.
*/
@RestController
@RequestMapping("/api/admin/images")
class AdminImageController(
private val imageService: ImageService
) {
/**
* 이미지를 업로드하고 접근 가능한 URL을 반환합니다.
*
* 주로 에디터(Toast UI 등)에서 이미지 첨부 이벤트가 발생했을 때 비동기로 호출되며,
* 업로드 성공 시 반환된 URL은 클라이언트 측에서 즉시 Markdown 문법(![alt](url))으로 변환되어 본문에 삽입됩니다.
*
* @param image 클라이언트가 전송한 바이너리 파일 (MultipartFile)
* @return CDN 또는 스토리지의 접근 가능한 절대 경로 (URL)
*/
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun uploadImage(
@RequestPart("image") image: MultipartFile
): ResponseEntity<ApiResponse<String>> {
val imageUrl = imageService.uploadImage(image)
return ResponseEntity.ok(ApiResponse.success(imageUrl, "이미지 업로드 성공"))
}
}

View File

@@ -0,0 +1,68 @@
package me.wypark.blogbackend.api.controller.admin
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.PostSaveRequest
import me.wypark.blogbackend.domain.post.PostService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.*
/**
* [관리자용 게시글 관리 API]
*
* 블로그 콘텐츠(Post)의 전체 수명 주기(Lifecycle)를 관리하는 컨트롤러입니다.
* 게시글의 작성, 수정, 삭제 기능을 제공하며, 이 과정에서 입력값 검증(@Valid)과
* 데이터 무결성 유지를 위한 다양한 비즈니스 로직(Slug 생성, 태그 처리 등)을 조율합니다.
*/
@RestController
@RequestMapping("/api/admin/posts")
class AdminPostController(
private val postService: PostService
) {
/**
* 신규 게시글을 작성 및 발행합니다.
*
* Security Context에서 현재 로그인한 관리자 정보를 추출하여 작성자(Author)로 매핑함으로써,
* 클라이언트가 임의로 작성자를 변조하는 것을 방지합니다.
*/
@PostMapping
fun createPost(
@RequestBody @Valid request: PostSaveRequest,
@AuthenticationPrincipal user: User
): ResponseEntity<ApiResponse<Long>> {
val postId = postService.createPost(request, user.username)
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다."))
}
/**
* 기존 게시글을 수정합니다.
*
* 제목이나 본문 변경뿐만 아니라, 카테고리 이동이나 태그 재설정과 같은 메타데이터 변경도 함께 처리합니다.
* 수정 시 사용되지 않게 된 이미지를 정리하거나, URL(Slug) 변경에 따른 리다이렉트 고려 등이 서비스 레이어에서 처리됩니다.
*/
@PutMapping("/{id}")
fun updatePost(
@PathVariable id: Long,
@RequestBody @Valid request: PostSaveRequest
): ResponseEntity<ApiResponse<Long>> {
val postId = postService.updatePost(id, request)
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 수정되었습니다."))
}
/**
* 게시글을 영구 삭제합니다.
*
* [리소스 정리 전략]
* 단순히 DB 레코드(Row)만 삭제하는 것이 아니라, 해당 게시글 본문에 포함되었던
* S3 업로드 이미지 파일들을 추적하여 함께 삭제(Cleanup)합니다.
* 이를 통해 스토리지에 불필요한 고아 파일(Orphaned Files)이 누적되는 것을 방지하여 비용을 최적화합니다.
*/
@DeleteMapping("/{id}")
fun deletePost(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
postService.deletePost(id)
return ResponseEntity.ok(ApiResponse.success(message = "게시글과 포함된 이미지가 삭제되었습니다."))
}
}

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.api.controller.admin
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.ProfileUpdateRequest
import me.wypark.blogbackend.domain.profile.BlogProfileService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* [관리자용 프로필 설정 API]
*
* 블로그 운영자의 소개(Bio), 프로필 사진, 소셜 링크 등을 관리하는 컨트롤러입니다.
* 개인 블로그 특성상 단일 사용자(Owner)에 대한 정보만 존재하므로,
* 별도의 ID 파라미터 없이 싱글톤(Singleton) 리소스처럼 관리됩니다.
*/
@RestController
@RequestMapping("/api/admin/profile")
class AdminProfileController(
private val blogProfileService: BlogProfileService
) {
/**
* 블로그 프로필 정보를 수정합니다.
*
* 단순 텍스트 정보(이름, 소개) 수정뿐만 아니라,
* 변경된 프로필 이미지 URL을 반영하고 기존 이미지를 정리하는 로직이 서비스 레이어에 포함되어 있습니다.
* 초기 데이터가 없을 경우(First Run), 수정 요청 시 기본 프로필이 생성(Upsert)됩니다.
*/
@PutMapping
fun updateProfile(@RequestBody request: ProfileUpdateRequest): ResponseEntity<ApiResponse<Nothing>> {
blogProfileService.updateProfile(request)
return ResponseEntity.ok(ApiResponse.success(message = "프로필이 수정되었습니다."))
}
}

View File

@@ -0,0 +1,61 @@
package me.wypark.blogbackend.api.dto
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
/**
* [회원가입 요청 DTO]
*
* 사용자 등록을 위한 데이터 전송 객체입니다.
* Controller 진입 시점(@Valid)에서 입력값의 형식 검증을 수행하여,
* 비즈니스 로직(Service) 단계에서의 불필요한 연산을 방지합니다 (Fail-Fast 전략).
*/
data class SignupRequest(
@field:NotBlank(message = "이메일은 필수입니다.")
@field:Email(message = "올바른 이메일 형식이 아닙니다.")
val email: String,
/**
* 비밀번호 복잡도 정책: 최소 8자 ~ 최대 20자
*
* Note:
* 이 필드는 클라이언트로부터 평문(Plain Text)으로 전달되므로,
* 전송 구간 암호화(HTTPS/TLS)가 보장된 환경에서만 사용되어야 합니다.
* DB 저장 시에는 반드시 단방향 해시 함수(BCrypt 등)를 통해 암호화됩니다.
*/
@field:NotBlank(message = "비밀번호는 필수입니다.")
@field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.")
val password: String,
@field:NotBlank(message = "닉네임은 필수입니다.")
@field:Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하로 입력해주세요.")
val nickname: String
)
/**
* [로그인 요청 DTO]
*
* JWT 토큰 발급을 위한 사용자 자격 증명(Credentials)을 전달받는 객체입니다.
*/
data class LoginRequest(
@field:NotBlank(message = "이메일을 입력해주세요.")
val email: String,
@field:NotBlank(message = "비밀번호를 입력해주세요.")
val password: String
)
/**
* [이메일 인증 확인 DTO]
*
* 회원가입 직후 발송된 OTP(One Time Password) 코드를 검증하기 위한 요청 객체입니다.
* 이메일 소유권 확인(Proof of Ownership)을 위해 사용됩니다.
*/
data class VerifyEmailRequest(
@field:NotBlank(message = "이메일을 입력해주세요")
val email: String,
@field:NotBlank(message = "인증 코드를 입력해주세요")
val code: String
)

View File

@@ -0,0 +1,86 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.category.Category
/**
* [카테고리 생성 요청 DTO]
*
* 새로운 카테고리 노드(Node)를 생성하기 위한 요청 객체입니다.
* 계층형 게시판 구조를 지원하기 위해 부모 카테고리 ID(parentId)를 선택적으로 받습니다.
*
* @property parentId null일 경우 최상위(Root) 레벨에 생성되며, 값이 있을 경우 해당 카테고리의 하위(Child)로 연결됩니다.
*/
data class CategoryCreateRequest(
val name: String,
val parentId: Long? = null
)
/**
* [카테고리 수정 요청 DTO]
*
* 카테고리의 속성 변경(Rename)과 구조 변경(Move)을 동시에 처리하는 객체입니다.
*
* Note:
* 트리 구조 내에서의 노드 이동(Move)은 데이터베이스 부하가 발생할 수 있고
* 순환 참조(Cycle) 위험이 있으므로, 서비스 레이어에서 별도의 정합성 검증 로직을 거칩니다.
*/
data class CategoryUpdateRequest(
val name: String,
val parentId: Long? // 변경할 부모 ID (null이면 최상위로 이동)
)
/**
* [카테고리 응답 DTO - Tree Structure]
*
* 프론트엔드 네비게이션 바(Sidebar) 등에서 계층형 메뉴를 렌더링하기 위한 재귀적 구조의 객체입니다.
*
* [성능 고려사항]
* N+1 문제를 방지하기 위해, 엔티티 조회 시점에는 Fetch Join을 사용하거나
* Batch Size를 설정하여 쿼리를 최적화한 후, 메모리 상에서 이 DTO 구조로 변환하여 반환합니다.
*/
data class CategoryResponse(
val id: Long,
val name: String,
val children: List<CategoryResponse> // 자식 노드 리스트 (Recursive)
) {
companion object {
// 엔티티 그래프를 순회하며 DTO 트리로 변환
fun from(category: Category): CategoryResponse {
return CategoryResponse(
id = category.id!!,
name = category.name,
// 자식 카테고리들을 재귀적으로 DTO 변환하여 리스트에 매핑
children = category.children.map { from(it) }
)
}
}
}
/**
* [관리자용 댓글 모니터링 DTO - Flat List]
*
* 관리자 대시보드에서 최근 댓글 흐름을 파악하기 위한 객체입니다.
* 계층형 구조(Nested)가 필요한 일반 사용자 뷰와 달리, 관리 목적상 시간순 나열이 중요하므로
* 모든 댓글을 평탄화(Flatten)하여 게시글 정보와 함께 제공합니다.
*/
data class AdminCommentResponse(
val id: Long,
val content: String,
val author: String,
val postTitle: String, // 문맥 파악을 위한 원본 게시글 제목
val postSlug: String, // 클릭 시 해당 게시글로 바로 이동(Deep Link)하기 위한 식별자
val createdAt: java.time.LocalDateTime
) {
companion object {
fun from(comment: me.wypark.blogbackend.domain.comment.Comment): AdminCommentResponse {
return AdminCommentResponse(
id = comment.id!!,
content = comment.content,
author = comment.getAuthorName(),
postTitle = comment.post.title,
postSlug = comment.post.slug,
createdAt = comment.createdAt
)
}
}
}

View File

@@ -0,0 +1,74 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.comment.Comment
import java.time.LocalDateTime
/**
* [댓글 응답 DTO - Hierarchical]
*
* 게시글 상세 화면에서 댓글 목록을 렌더링하기 위한 데이터 객체입니다.
* 대댓글(Nested Comment)을 포함하는 재귀적 구조를 가지며, 프론트엔드에서의 추가 가공 없이
* 즉시 트리 형태로 렌더링할 수 있도록 설계되었습니다.
*/
data class CommentResponse(
val id: Long,
val content: String,
val author: String,
// UI에서 게시글 작성자의 댓글을 강조(Highlight)하기 위한 플래그
val isPostAuthor: Boolean,
// 회원일 경우 프로필 링크 연결 등을 위해 ID 제공 (비회원은 null)
val memberId: Long?,
val createdAt: LocalDateTime,
// 자식 댓글 리스트 (Recursive)
val children: List<CommentResponse>
) {
companion object {
fun from(comment: Comment): CommentResponse {
// 게시글 작성자 본인이 쓴 댓글인지 확인 (비회원은 member가 null이므로 항상 false)
val isAuthor = comment.member?.id == comment.post.member.id
return CommentResponse(
id = comment.id!!,
content = comment.content,
author = comment.getAuthorName(),
isPostAuthor = isAuthor,
memberId = comment.member?.id,
createdAt = comment.createdAt,
children = comment.children.map { from(it) } // 재귀 호출로 트리 구성
)
}
}
}
/**
* [댓글 작성 요청 DTO]
*
* 회원과 비회원(Guest) 모두가 사용하는 통합 요청 객체입니다.
*
* [검증 로직]
* - 회원: Security Context에서 유저 정보를 가져오므로 guest 필드는 무시됩니다.
* - 비회원: guestNickname과 guestPassword가 필수값으로 요구됩니다.
*/
data class CommentSaveRequest(
val postSlug: String,
val content: String,
val parentId: Long? = null, // 대댓글(Reply)일 경우 상위 댓글 ID
// --- 비회원 전용 필드 (Anonymous User) ---
val guestNickname: String? = null,
val guestPassword: String? = null // 수정/삭제 권한 인증용 비밀번호 (DB 저장 시 암호화됨)
)
/**
* [댓글 삭제 요청 DTO]
*
* 비회원이 본인의 댓글을 삭제할 때 비밀번호 검증을 위해 사용됩니다.
* 회원의 경우 JWT 토큰으로 본인 확인이 가능하므로 이 DTO의 필드는 사용되지 않습니다.
*/
data class CommentDeleteRequest(
val guestPassword: String? = null
)

View File

@@ -0,0 +1,115 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.post.Post
import java.time.LocalDateTime
/**
* [인접 게시글 응답 DTO]
*
* 게시글 상세 화면 하단에 위치할 '이전 글 / 다음 글' 네비게이션 링크를 위한 객체입니다.
* 전체 데이터를 로딩하는 대신, 링크 생성에 필요한 최소한의 식별자(Slug)와 제목(Title)만 포함하여
* 페이로드 크기를 최적화했습니다.
*/
data class PostNeighborResponse(
val slug: String,
val title: String
) {
companion object {
fun from(post: Post): PostNeighborResponse {
return PostNeighborResponse(
slug = post.slug,
title = post.title
)
}
}
}
/**
* [게시글 상세 응답 DTO]
*
* 단일 게시글의 모든 정보(Full Content)를 클라이언트에게 전달하는 객체입니다.
*
* [설계 의도]
* - SEO: ID 대신 Slug를 사용하여 검색 엔진 친화적인 URL 구조 지원
* - UX: 별도의 추가 요청 없이 이전/다음 글 정보를 함께 반환하여 페이지 이동성(Navigability) 향상
*/
data class PostResponse(
val id: Long,
val title: String,
val content: String,
val slug: String,
val categoryName: String?,
val viewCount: Long,
val createdAt: LocalDateTime,
// 현재 글을 기준으로 앞/뒤 글 정보 (없으면 null)
val prevPost: PostNeighborResponse?,
val nextPost: PostNeighborResponse?
) {
companion object {
fun from(post: Post, prevPost: Post? = null, nextPost: Post? = null): PostResponse {
return PostResponse(
id = post.id!!,
title = post.title,
content = post.content,
slug = post.slug,
categoryName = post.category?.name,
viewCount = post.viewCount,
createdAt = post.createdAt,
prevPost = prevPost?.let { PostNeighborResponse.from(it) },
nextPost = nextPost?.let { PostNeighborResponse.from(it) }
)
}
}
}
/**
* [게시글 목록 응답 DTO]
*
* 메인 화면이나 카테고리 목록에서 사용되는 경량화(Lightweight) 객체입니다.
*
* [최적화 전략]
* 다수의 아이템을 렌더링해야 하므로, 데이터 전송량(Network Overhead)을 줄이기 위해
* 무거운 본문(content)은 제외하거나 미리보기용으로 일부만 포함하도록 설계되었습니다.
*/
data class PostSummaryResponse(
val id: Long,
val title: String,
val slug: String,
val categoryName: String?,
val viewCount: Long,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
val content: String? // 목록에서는 본문 미리보기 용도로 사용 (혹은 null)
) {
companion object {
fun from(post: Post): PostSummaryResponse {
return PostSummaryResponse(
id = post.id!!,
title = post.title,
slug = post.slug,
categoryName = post.category?.name,
viewCount = post.viewCount,
createdAt = post.createdAt,
updatedAt = post.updatedAt,
content = post.content
)
}
}
}
/**
* [게시글 작성/수정 요청 DTO]
*
* 게시글의 생명주기(생성/수정)를 담당하는 통합 커맨드 객체입니다.
*
* - Slug: 클라이언트가 직접 지정하지 않으면(null), 서버에서 제목을 기반으로 자동 생성(Generate)합니다.
* - Content: Markdown 포맷의 원문 텍스트를 저장합니다.
*/
data class PostSaveRequest(
val title: String,
val content: String,
val slug: String? = null,
val categoryId: Long? = null,
val tags: List<String> = emptyList() // 태그는 서비스 레이어에서 별도 로직으로 매핑(Many-to-Many) 처리
)

View File

@@ -0,0 +1,50 @@
package me.wypark.blogbackend.api.dto
import me.wypark.blogbackend.domain.profile.BlogProfile
/**
* [프로필 응답 DTO]
*
* 블로그 운영자(Owner)의 공개 정보를 렌더링하기 위한 View Object입니다.
*
* [설계 의도]
* 데이터베이스 엔티티(Entity)를 직접 반환하지 않고 DTO로 변환하여,
* 내부 구현의 변경이 클라이언트(View)에 영향을 미치지 않도록 결합도(Coupling)를 낮췄습니다.
*/
data class ProfileResponse(
val name: String,
val bio: String,
val imageUrl: String?,
val githubUrl: String?,
val email: String?
) {
companion object {
// Entity -> DTO 변환 (Static Factory Method)
fun from(profile: BlogProfile): ProfileResponse {
return ProfileResponse(
name = profile.name,
bio = profile.bio,
imageUrl = profile.imageUrl,
githubUrl = profile.githubUrl,
email = profile.email
)
}
}
}
/**
* [프로필 수정 요청 DTO]
*
* 관리자 대시보드에서 블로그 설정(운영자 정보)을 변경하기 위한 요청 객체입니다.
*
* [유효성 정책]
* - 이름(name)과 소개(bio)는 블로그의 정체성을 나타내는 필수 항목입니다.
* - 프로필 이미지나 소셜 링크 등은 선택적(Optional)으로 입력할 수 있도록 Nullable로 설계되었습니다.
*/
data class ProfileUpdateRequest(
val name: String,
val bio: String,
val imageUrl: String?,
val githubUrl: String?,
val email: String?
)

View File

@@ -1,5 +1,17 @@
package me.wypark.blogbackend.api.dto package me.wypark.blogbackend.api.dto
/**
* [JWT 토큰 응답 DTO]
*
* 로그인 또는 토큰 재발급 성공 시 클라이언트에게 반환되는 인증 정보 객체입니다.
* RFC 6750 (Bearer Token Usage) 표준을 따르며, 클라이언트가 인증 헤더(Authorization)를
* 올바르게 구성할 수 있도록 필요한 메타데이터를 함께 제공합니다.
*
* @property grantType 인증 타입 (Default: "Bearer")
* @property accessToken 리소스 접근을 위한 단기 유효 토큰 (Stateless)
* @property refreshToken Access Token 갱신을 위한 장기 유효 토큰 (Rotation 전략 적용)
* @property accessTokenExpiresIn Access Token의 유효 기간(ms). 클라이언트가 만료 시점을 예측하여 미리 갱신 요청을 보낼 수 있도록 함.
*/
data class TokenDto( data class TokenDto(
val grantType: String = "Bearer", val grantType: String = "Bearer",
val accessToken: String, val accessToken: String,

View File

@@ -6,17 +6,42 @@ import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import org.springframework.web.filter.CorsFilter import org.springframework.web.filter.CorsFilter
/**
* [CORS(Cross-Origin Resource Sharing) 설정]
*
* 프론트엔드(React/Next.js)와 백엔드(Spring Boot)의 도메인이 다를 경우 발생하는
* 브라우저의 보안 제약(SOP)을 해결하기 위한 설정입니다.
*
* 단순한 와일드카드(*) 허용이 아닌, 신뢰할 수 있는 특정 도메인(Origin)에 대해서만
* 리소스 접근 권한을 명시적으로 부여하여 보안성을 확보했습니다.
*/
@Configuration @Configuration
class CorsConfig { class CorsConfig {
@Bean @Bean
fun corsFilter(): CorsFilter { fun corsFilter(): CorsFilter {
val source = UrlBasedCorsConfigurationSource() val source = UrlBasedCorsConfigurationSource()
val config = CorsConfiguration() val config = CorsConfiguration()
config.allowCredentials = true // 쿠키/토큰 허용 // 1. 인증 정보(Cookie, Authorization Header) 포함 허용
config.addAllowedOriginPattern("*") // 개발용 (배포 시 프론트 도메인으로 변경 추천) // 이 옵션을 true로 설정하면, 보안상 addAllowedOrigin에 와일드카드(*)를 사용할 수 없습니다.
config.allowCredentials = true
// 2. 신뢰할 수 있는 출처(Origin) 명시
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
config.addAllowedOrigin("https://blog.wypark.me")
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
// 3. 허용할 HTTP 메서드 및 헤더
// REST API의 유연성을 위해 모든 표준 메서드와 헤더를 허용합니다.
config.addAllowedHeader("*") config.addAllowedHeader("*")
config.addAllowedMethod("*") // GET, POST, PUT, DELETE 등 모두 허용 config.addAllowedMethod("*")
// 4. [중요] 응답 헤더 노출 설정 (Expose Headers)
// 브라우저는 기본적으로 보안상 CORS 요청에 대한 응답 헤더 중 일부(Cache-Control, Content-Type 등)만 JavaScript에서 접근하도록 제한합니다.
// 따라서, 클라이언트가 로그인 후 발급된 JWT 토큰(Authorization)을 읽을 수 있도록 명시적으로 노출시켜야 합니다.
config.addExposedHeader("Authorization")
config.addExposedHeader("Refresh-Token")
source.registerCorsConfiguration("/api/**", config) source.registerCorsConfiguration("/api/**", config)
return CorsFilter(source) return CorsFilter(source)

View File

@@ -4,5 +4,5 @@ import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.data.jpa.repository.config.EnableJpaAuditing
@Configuration @Configuration
@EnableJpaAuditing // 엔티티의 생성일/수정일 자동 주입 활성화 @EnableJpaAuditing
class JpaConfig class JpaConfig

View File

@@ -0,0 +1,69 @@
package me.wypark.blogbackend.core.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.JavaMailSenderImpl
import java.util.*
/**
* [메일 발송 설정]
*
* 회원가입 인증 코드 발송 등을 위한 SMTP(Simple Mail Transfer Protocol) 서버 설정입니다.
* Google Gmail SMTP 등을 사용하여 외부 메일 서버와 연동하며,
* 네트워크 지연이나 연결 실패 시 스레드가 차단(Blocking)되는 것을 방지하기 위한 타임아웃 설정이 포함되어 있습니다.
*/
@Configuration
class MailConfig(
@Value("\${spring.mail.host}") private val host: String,
@Value("\${spring.mail.port}") private val port: Int,
@Value("\${spring.mail.username}") private val username: String,
@Value("\${spring.mail.password}") private val password: String,
@Value("\${spring.mail.properties.mail.smtp.auth}") private val auth: String,
@Value("\${spring.mail.properties.mail.smtp.starttls.enable}") private val starttlsEnable: String,
@Value("\${spring.mail.properties.mail.smtp.starttls.required}") private val starttlsRequired: String,
@Value("\${spring.mail.properties.mail.smtp.connectiontimeout}") private val connectionTimeout: Int,
@Value("\${spring.mail.properties.mail.smtp.timeout}") private val timeout: Int,
@Value("\${spring.mail.properties.mail.smtp.writetimeout}") private val writeTimeout: Int
) {
/**
* JavaMailSender 빈 등록
*
* Spring Mail 라이브러리의 핵심 인터페이스 구현체를 생성합니다.
* application.yml에서 주입받은 환경 변수들을 기반으로 SMTP 연결을 초기화합니다.
*/
@Bean
fun javaMailSender(): JavaMailSender {
val mailSender = JavaMailSenderImpl()
// 기본 SMTP 서버 정보 설정
mailSender.host = host
mailSender.port = port
mailSender.username = username
mailSender.password = password
mailSender.defaultEncoding = "UTF-8"
// JavaMail 세부 속성 설정
val props: Properties = mailSender.javaMailProperties
props["mail.transport.protocol"] = "smtp"
props["mail.smtp.auth"] = auth
props["mail.smtp.starttls.enable"] = starttlsEnable
props["mail.smtp.starttls.required"] = starttlsRequired
// [디버깅 설정]
// 개발 환경에서는 메일 발송 로그를 상세히 확인하기 위해 true로 설정합니다.
// 운영(Prod) 환경에서는 로그 양이 많아질 수 있으므로 false로 변경하거나 로그 레벨을 조정해야 합니다.
props["mail.debug"] = "true"
// [안정성 설정: 타임아웃]
// 외부 SMTP 서버 응답이 지연될 경우, 애플리케이션 스레드가 무한 대기(Hang) 상태에 빠지는 것을 방지합니다.
// 각각 연결(Connection), 읽기(Read), 쓰기(Write) 타임아웃을 명시하여 Fail-Fast를 유도합니다.
props["mail.smtp.connectiontimeout"] = connectionTimeout
props["mail.smtp.timeout"] = timeout
props["mail.smtp.writetimeout"] = writeTimeout
return mailSender
}
}

View File

@@ -0,0 +1,18 @@
package me.wypark.blogbackend.core.config
import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class QueryDslConfig(
@PersistenceContext
private val entityManager: EntityManager
) {
@Bean
fun jpaQueryFactory(): JPAQueryFactory {
return JPAQueryFactory(entityManager)
}
}

View File

@@ -0,0 +1,31 @@
package me.wypark.blogbackend.core.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client
import java.net.URI
@Configuration
class S3Config(
@Value("\${spring.cloud.aws.credentials.access-key:admin}") private val accessKey: String,
@Value("\${spring.cloud.aws.credentials.secret-key:password}") private val secretKey: String,
@Value("\${spring.cloud.aws.region.static:ap-northeast-2}") private val regionStr: String, // 변수명 regionStr 확인
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
) {
@Bean
fun s3Client(): S3Client {
return S3Client.builder()
.region(Region.of(regionStr))
.credentialsProvider(
StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))
)
.endpointOverride(URI.create(endpoint)) // MinIO 주소
.forcePathStyle(true) // MinIO 필수 설정
.build()
}
}

View File

@@ -11,31 +11,61 @@ import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.filter.CorsFilter import org.springframework.web.filter.CorsFilter
/**
* [Spring Security 설정]
*
* 애플리케이션의 보안 인가(Authorization) 및 인증(Authentication) 전략을 정의합니다.
* 전통적인 세션(Session) 기반 인증 대신, REST API 환경에 적합한 JWT(Token) 기반의
* 무상태(Stateless) 아키텍처를 구현했습니다.
*/
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
class SecurityConfig( class SecurityConfig(
private val corsFilter: CorsFilter, private val corsFilter: CorsFilter,
private val jwtAuthenticationFilter: JwtAuthenticationFilter // 주입 추가 private val jwtAuthenticationFilter: JwtAuthenticationFilter
) { ) {
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun filterChain(http: HttpSecurity): SecurityFilterChain {
http http
// 1. 기본 보안 설정 비활성화 (REST API 환경)
// CSRF(Cross-Site Request Forgery): 쿠키 기반의 세션 인증을 사용하지 않으므로 비활성화 (Header에 토큰을 담아 보냄)
.csrf { it.disable() } .csrf { it.disable() }
// HttpBasic / FormLogin: UI 기반의 인증 창을 사용하지 않으므로 비활성화
.httpBasic { it.disable() } .httpBasic { it.disable() }
.formLogin { it.disable() } .formLogin { it.disable() }
// 2. 커스텀 필터 등록
// CorsFilter: 브라우저의 SOP(Same-Origin Policy) 우회를 위한 설정 적용
.addFilter(corsFilter) .addFilter(corsFilter)
// 3. 세션 정책 설정 (Stateless)
// 서버가 클라이언트의 상태(Session)를 보존하지 않음 -> 서버 확장성(Scale-out) 유리
.sessionManagement { .sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
} }
// 4. URL별 접근 권한 관리 (인가)
// Principle of Least Privilege(최소 권한의 원칙)에 따라, 명시적으로 허용된 경로 외에는 모두 인증을 요구
.authorizeHttpRequests { auth -> .authorizeHttpRequests { auth ->
// 인증 관련(로그인, 회원가입) 및 정적 리소스는 누구나 접근 가능
auth.requestMatchers("/api/auth/**").permitAll() auth.requestMatchers("/api/auth/**").permitAll()
// 조회(Read) 작업은 비회원에게도 허용 (GET 메서드 한정)
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll() auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
auth.requestMatchers(HttpMethod.POST, "/api/comments/**").permitAll() // 비회원 댓글 허용 auth.requestMatchers(HttpMethod.GET, "/api/profile").permitAll()
auth.requestMatchers("/api/admin/**").hasRole("ADMIN")
// 댓글 API: 비회원 작성/삭제도 지원하므로 전체 허용 (내부 로직에서 비밀번호 검증)
auth.requestMatchers("/api/comments/**").permitAll()
// 관리자 영역: ROLE_ADMIN 권한을 가진 토큰 소유자만 접근 가능
auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
// 그 외 모든 요청은 인증 필요
auth.anyRequest().authenticated() auth.anyRequest().authenticated()
} }
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행 // 5. JWT 인증 필터 삽입
// UsernamePasswordAuthenticationFilter(기본 로그인 처리)보다 먼저 실행되어야 함
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build() return http.build()

View File

@@ -4,39 +4,56 @@ import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.util.StringUtils import org.springframework.util.StringUtils
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.stereotype.Component
/**
* [JWT 인증 필터]
*
* 모든 HTTP 요청의 헤더를 가로채어 JWT 토큰의 유효성을 검증하는 커스텀 필터입니다.
* Spring Security의 FilterChain 앞단에 배치되어, 인증된 사용자일 경우
* SecurityContext에 Authentication 객체를 주입(Populate)하는 역할을 수행합니다.
*/
@Component @Component
class JwtAuthenticationFilter( class JwtAuthenticationFilter(
private val jwtProvider: JwtProvider private val jwtProvider: JwtProvider
) : OncePerRequestFilter() { ) : OncePerRequestFilter() {
/**
* 필터링 로직 수행
*
* [흐름 제어 전략]
* 토큰이 없거나 유효하지 않더라도 이 필터에서 즉시 예외를 발생시키거나 요청을 차단하지 않습니다.
* 검증에 실패하면 SecurityContext가 비어있는 상태로 다음 필터(Chain)로 넘어가며,
* 최종적으로 FilterSecurityInterceptor(SecurityConfig) 단계에서 접근 권한을 판단하게 됩니다.
* (예: 인증되지 않은 사용자가 /api/public 접근 시 -> 허용, /api/admin 접근 시 -> 403 Forbidden)
*/
override fun doFilterInternal( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,
filterChain: FilterChain filterChain: FilterChain
) { ) {
// 1. Request Header에서 토큰 추출
val token = resolveToken(request) val token = resolveToken(request)
// 2. 토큰 유효성 검 // 토큰 유효성 검증 및 SecurityContext 설정
// 토큰이 존재하고 유효하다면 인증 정보를 가져와 Context에 저장 // (Stateless 아키텍처이므로 세션이 아닌 Context에 매 요청마다 인증 정보를 주입합니다)
if (StringUtils.hasText(token) && jwtProvider.validateToken(token!!)) { if (StringUtils.hasText(token) && jwtProvider.validateToken(token!!)) {
val authentication = jwtProvider.getAuthentication(token) val authentication = jwtProvider.getAuthentication(token)
SecurityContextHolder.getContext().authentication = authentication SecurityContextHolder.getContext().authentication = authentication
} }
// 3. 다음 필터로 진행 // 다음 필터로 진행
filterChain.doFilter(request, response) filterChain.doFilter(request, response)
} }
// Request Header에서 토큰 정보 추출 /**
* Request Header에서 표준 Bearer 스키마(RFC 6750)를 준수하는 토큰 문자열을 파싱합니다.
*/
private fun resolveToken(request: HttpServletRequest): String? { private fun resolveToken(request: HttpServletRequest): String? {
val bearerToken = request.getHeader(AUTHORIZATION_HEADER) val bearerToken = request.getHeader(AUTHORIZATION_HEADER)
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7) // "Bearer " 이후의 문자열만 반환 return bearerToken.substring(7) // "Bearer " 접두어 제거
} }
return null return null
} }

View File

@@ -4,6 +4,7 @@ import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
import me.wypark.blogbackend.api.dto.TokenDto import me.wypark.blogbackend.api.dto.TokenDto
import me.wypark.blogbackend.domain.auth.CustomUserDetails
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
@@ -14,6 +15,13 @@ import org.springframework.stereotype.Component
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey
/**
* [JWT 토큰 관리자]
*
* JWT(Json Web Token)의 생성, 파싱, 서명 검증을 담당하는 핵심 컴포넌트입니다.
* 'jjwt' 라이브러리를 사용하여 표준 규격(RFC 7519)에 맞는 토큰을 발급하며,
* 대칭키 암호화 알고리즘(HMAC-SHA)을 사용하여 서명의 무결성을 보장합니다.
*/
@Component @Component
class JwtProvider( class JwtProvider(
@Value("\${jwt.secret}") secretKey: String, @Value("\${jwt.secret}") secretKey: String,
@@ -22,21 +30,36 @@ class JwtProvider(
) { ) {
private val key: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)) private val key: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))
// 1. 토큰 생성 (Access + Refresh 동시 발급) /**
* 인증된 사용자 정보를 기반으로 Access Token과 Refresh Token 쌍을 생성합니다.
*
* [Payload 설계 전략]
* Access Token의 Payload(Claims)에는 'memberId'와 'nickname'을 포함시킵니다.
* 이는 프론트엔드에서 사용자 정보를 표시할 때 매번 별도의 API(예: /me)를 호출하지 않고,
* 토큰 디코딩만으로 즉시 UI를 렌더링할 수 있게 하여 네트워크 비용을 절감하기 위함입니다.
*/
fun generateTokenDto(authentication: Authentication): TokenDto { fun generateTokenDto(authentication: Authentication): TokenDto {
val authorities = authentication.authorities.joinToString(",") { it.authority } val authorities = authentication.authorities.joinToString(",") { it.authority }
val now = Date().time val now = Date().time
// Access Token 생성 // 인증 객체에서 비즈니스 도메인 정보 추출 (CustomUserDetails 활용)
val principal = authentication.principal as CustomUserDetails
val memberId = principal.memberId
val nickname = principal.nickname
// 1. Access Token 생성 (Stateless 인증용, 짧은 유효기간)
val accessTokenExpiresIn = Date(now + accessTokenValidity) val accessTokenExpiresIn = Date(now + accessTokenValidity)
val accessToken = Jwts.builder() val accessToken = Jwts.builder()
.subject(authentication.name) // email 또는 id .subject(authentication.name) // 표준 sub claim (Email)
.claim("auth", authorities) // 권한 정보 (ROLE_USER 등) .claim("auth", authorities) // 사용자 권한 (ROLE_USER 등)
.claim("memberId", memberId) // 프론트엔드 식별 편의성 제공
.claim("nickname", nickname) // 프론트엔드 표기 편의성 제공
.expiration(accessTokenExpiresIn) .expiration(accessTokenExpiresIn)
.signWith(key) .signWith(key)
.compact() .compact()
// Refresh Token 생성 (권한 정보 등은 제외하고 만료일만 설정) // 2. Refresh Token 생성 (토큰 갱신용, 긴 유효기간)
// 불필요한 정보 노출을 최소화하기 위해 식별자(sub)와 만료일만 포함
val refreshToken = Jwts.builder() val refreshToken = Jwts.builder()
.subject(authentication.name) .subject(authentication.name)
.expiration(Date(now + refreshTokenValidity)) .expiration(Date(now + refreshTokenValidity))
@@ -50,7 +73,10 @@ class JwtProvider(
) )
} }
// 2. 토큰에서 인증 정보(Authentication) 추출 /**
* Access Token을 복호화하여 Spring Security가 이해할 수 있는 Authentication 객체로 변환합니다.
* 요청 당 1회 수행되므로 성능을 고려하여 DB 조회 없이 토큰의 Claims만으로 객체를 구성합니다.
*/
fun getAuthentication(accessToken: String): Authentication { fun getAuthentication(accessToken: String): Authentication {
val claims = parseClaims(accessToken) val claims = parseClaims(accessToken)
@@ -63,25 +89,31 @@ class JwtProvider(
.split(",") .split(",")
.map { SimpleGrantedAuthority(it) } .map { SimpleGrantedAuthority(it) }
// UserDetails 객체를 생성하여 Authentication에 담음 (비밀번호는 불필요하므로 빈 문자열)
val principal = User(claims.subject, "", authorities) val principal = User(claims.subject, "", authorities)
return UsernamePasswordAuthenticationToken(principal, "", authorities) return UsernamePasswordAuthenticationToken(principal, "", authorities)
} }
// 3. 토큰 검증 (만료 여부, 위변조 여부 확인) /**
* 토큰의 유효성을 검증합니다.
*
* 서명 위조, 만료, 형식 오류 등 다양한 예외 케이스를 정교하게 catch하여 처리합니다.
* 필터 레벨에서 호출되므로 false 반환 시 해당 요청은 인증 실패로 간주됩니다.
*/
fun validateToken(token: String): Boolean { fun validateToken(token: String): Boolean {
try { try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token) Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
return true return true
} catch (e: SecurityException) { } catch (e: SecurityException) {
// log.info("잘못된 JWT 서명입니다.") // log.warn("잘못된 JWT 서명입니다.")
} catch (e: MalformedJwtException) { } catch (e: MalformedJwtException) {
// log.info("잘못된 JWT 서명입니다.") // log.warn("손상된 JWT 토큰입니다.")
} catch (e: ExpiredJwtException) { } catch (e: ExpiredJwtException) {
// log.info("만료된 JWT 토큰입니다.") // log.warn("만료된 JWT 토큰입니다.")
} catch (e: UnsupportedJwtException) { } catch (e: UnsupportedJwtException) {
// log.info("지원되지 않는 JWT 토큰입니다.") // log.warn("지원되지 않는 JWT 토큰입니다.")
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// log.info("JWT 토큰이 잘못되었습니다.") // log.warn("JWT 토큰이 비어있거나 잘못되었습니다.")
} }
return false return false
} }
@@ -90,6 +122,7 @@ class JwtProvider(
return try { return try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(accessToken).payload Jwts.parser().verifyWith(key).build().parseSignedClaims(accessToken).payload
} catch (e: ExpiredJwtException) { } catch (e: ExpiredJwtException) {
// 만료된 토큰이더라도 Claims 정보(사용자 ID 등)가 필요할 수 있으므로 예외에서 꺼내 반환
e.claims e.claims
} }
} }

View File

@@ -0,0 +1,66 @@
package me.wypark.blogbackend.core.error
import me.wypark.blogbackend.api.common.ApiResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
/**
* [전역 예외 처리 핸들러]
*
* 애플리케이션 전반에서 발생하는 예외(Exception)를 중앙에서 캡처하여
* 클라이언트에게 일관된 포맷(ApiResponse)의 에러 응답을 반환합니다.
* @RestControllerAdvice를 사용하여 모든 컨트롤러에 AOP(Aspect Oriented Programming) 방식으로 적용됩니다.
*/
@RestControllerAdvice
class GlobalExceptionHandler {
/**
* [비즈니스 로직 예외 처리]
*
* 서비스 계층(Service Layer)에서 검증 로직 수행 중 의도적으로 발생시킨 예외를 처리합니다.
* 예: 중복된 이메일, 존재하지 않는 게시글 조회 등
* 이는 클라이언트의 잘못된 요청(Bad Request)으로 간주하여 400 상태 코드를 반환합니다.
*/
@ExceptionHandler(IllegalArgumentException::class, IllegalStateException::class)
fun handleBusinessException(e: RuntimeException): ResponseEntity<ApiResponse<Nothing>> {
return ResponseEntity
.badRequest()
.body(ApiResponse.error(e.message ?: "잘못된 요청입니다."))
}
/**
* [입력값 유효성 검증 실패 처리]
*
* @Valid 어노테이션에 의해 DTO 검증 실패 시 발생하는 예외(MethodArgumentNotValidException)를 처리합니다.
* 여러 필드에서 에러가 발생할 수 있으나, 클라이언트가 즉시 인지하고 수정할 수 있도록
* 첫 번째 에러 메시지만 추출하여 간결하게 반환합니다.
*/
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Nothing>> {
val message = e.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "입력값이 올바르지 않습니다."
return ResponseEntity
.badRequest()
.body(ApiResponse.error(message))
}
/**
* [시스템 예외 처리 (Fallback)]
*
* 명시적으로 처리되지 않은 모든 예외를 잡아내는 최후의 방어선입니다.
* NullPointerException이나 DB 연결 실패 등 예측하지 못한 서버 내부 오류가 이에 해당합니다.
*
* [보안 전략]
* 내부 로직이 노출될 수 있는 스택 트레이스(Stack Trace)는 클라이언트에게 절대 반환하지 않고 서버 로그로만 남기며,
* 사용자에게는 일반적인 500 에러 메시지만 전달합니다.
*/
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ApiResponse<Nothing>> {
e.printStackTrace() // 실제 운영 환경에서는 SLF4J 등의 로거를 사용하여 파일/ELK로 수집해야 함
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 내부 오류가 발생했습니다."))
}
}

View File

@@ -0,0 +1,183 @@
package me.wypark.blogbackend.domain.auth
import me.wypark.blogbackend.api.dto.LoginRequest
import me.wypark.blogbackend.api.dto.SignupRequest
import me.wypark.blogbackend.api.dto.TokenDto
import me.wypark.blogbackend.core.config.jwt.JwtProvider
import me.wypark.blogbackend.domain.user.Member
import me.wypark.blogbackend.domain.user.MemberRepository
import me.wypark.blogbackend.domain.user.Role
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* [인증 비즈니스 로직 서비스]
*
* 회원가입, 로그인, 토큰 재발급 등 계정 보안과 관련된 핵심 로직을 담당합니다.
* DB(Member), Redis(RefreshToken), Email(Verification) 등 여러 인프라 자원을 오케스트레이션하여
* 안전하고 무결한 인증 프로세스를 보장합니다.
*/
@Service
@Transactional(readOnly = true)
class AuthService(
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder,
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
private val jwtProvider: JwtProvider,
private val refreshTokenRepository: RefreshTokenRepository,
private val emailService: EmailService,
private val userDetailsService: UserDetailsService
) {
/**
* 신규 회원을 등록합니다.
*
* [스팸 방지 전략]
* 무분별한 가입을 막기 위해 가입 즉시 활성화(Active)하지 않고,
* `isVerified = false` 상태로 저장한 뒤 이메일 인증을 강제합니다.
*/
@Transactional
fun signup(request: SignupRequest) {
if (memberRepository.existsByEmail(request.email)) {
throw IllegalArgumentException("이미 가입된 이메일입니다.")
}
if (memberRepository.existsByNickname(request.nickname)) {
throw IllegalArgumentException("이미 사용 중인 닉네임입니다.")
}
val member = Member(
email = request.email,
password = passwordEncoder.encode(request.password),
nickname = request.nickname,
role = Role.ROLE_USER,
isVerified = false // 초기 상태는 미인증
)
memberRepository.save(member)
// 비동기 처리를 고려할 수 있으나, 가입 직후 메일 수신이 중요하므로 동기 호출
emailService.sendVerificationCode(request.email)
}
/**
* 사용자 자격 증명을 검증하고 초기 토큰을 발급합니다.
*
* 단순 ID/PW 검사뿐만 아니라, 이메일 인증 여부(Business Rule)를 체크하여
* 미인증 계정의 접근을 원천 차단합니다.
*/
@Transactional
fun login(request: LoginRequest): TokenDto {
val member = memberRepository.findByEmail(request.email)
?: throw IllegalArgumentException("가입되지 않은 이메일입니다.")
// 비밀번호 체크 (Bcrypt)
if (!passwordEncoder.matches(request.password, member.password)) {
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
}
// 계정 활성화 여부 체크
if (!member.isVerified) {
throw IllegalStateException("이메일 인증이 필요합니다.")
}
// 1. Spring Security 인증 토큰 생성
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
// 2. 실제 검증 수행 (CustomUserDetailsService 호출됨)
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
// 3. 인증 정보를 기반으로 JWT(Access + Refresh) 생성
val tokenDto = jwtProvider.generateTokenDto(authentication)
// 4. Refresh Token을 Redis에 저장 (RTR 전략의 기준점)
refreshTokenRepository.save(authentication.name, tokenDto.refreshToken)
return tokenDto
}
/**
* Access Token 만료 시 토큰을 갱신합니다.
*
* [핵심 보안 전략: Refresh Token Rotation (RTR)]
* 보안성을 높이기 위해 Refresh Token을 일회용으로 사용합니다.
* 토큰 재발급 요청 시 기존 Refresh Token을 폐기하고, 새로운 Refresh Token을 발급합니다.
*
* [토큰 탈취 감지]
* 만약 이미 사용된(폐기된) Refresh Token으로 요청이 들어온다면, 이는 토큰이 탈취된 것으로 간주하여
* 해당 사용자의 저장된 모든 토큰을 삭제하고 강제 로그아웃 처리합니다.
*/
@Transactional
fun reissue(accessToken: String, refreshToken: String): TokenDto {
// 1. 토큰 자체의 유효성 검증 (위변조 여부)
if (!jwtProvider.validateToken(refreshToken)) {
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
}
// 2. Access Token에서 사용자 정보 추출
val tempAuthentication = jwtProvider.getAuthentication(accessToken)
// 3. Redis에 저장된 최신 Refresh Token 조회
val savedRefreshToken = refreshTokenRepository.findByEmail(tempAuthentication.name)
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
// 4. [RTR 핵심] 토큰 불일치 감지 (재사용 시도 -> 탈취 의심)
if (savedRefreshToken != refreshToken) {
refreshTokenRepository.delete(tempAuthentication.name) // 보안 조치: 세션 전체 파기
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다. (재사용 감지됨)")
}
// 5. DB에서 최신 유저 정보 다시 로드
// (토큰 갱신 시점의 권한 변경이나 닉네임 변경 등을 반영하기 위함)
val userDetails = userDetailsService.loadUserByUsername(tempAuthentication.name)
// 6. 새로운 Authentication 객체 생성
val newAuthentication = UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.authorities
)
// 7. 새 토큰 쌍 발급 (Rotate)
val newTokenDto = jwtProvider.generateTokenDto(newAuthentication)
// 8. Redis 업데이트 (기존 토큰 덮어쓰기)
refreshTokenRepository.save(newAuthentication.name, newTokenDto.refreshToken)
return newTokenDto
}
/**
* 로그아웃 처리
*
* 서버 측에서 Refresh Token을 삭제함으로써, Access Token이 만료되는 즉시
* 더 이상 갱신할 수 없도록 세션을 종료시킵니다.
*/
@Transactional
fun logout(email: String) {
refreshTokenRepository.delete(email)
}
/**
* 이메일 인증 코드를 검증하고 계정 상태를 활성화(Verify)합니다.
* 상태 변경(update)이 발생하므로 트랜잭션 내에서 처리됩니다.
*/
@Transactional
fun verifyEmail(email: String, code: String) {
val member = memberRepository.findByEmail(email)
?: throw IllegalArgumentException("존재하지 않는 회원입니다.")
if (member.isVerified) {
throw IllegalArgumentException("이미 인증된 회원입니다.")
}
// Redis에 저장된 코드와 대조
if (!emailService.verifyCode(email, code)) {
throw IllegalArgumentException("인증 코드가 올바르지 않거나 만료되었습니다.")
}
// 인증 성공 시 회원 상태 변경
member.verify()
}
}

View File

@@ -0,0 +1,28 @@
package me.wypark.blogbackend.domain.auth
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.User
/**
* [Spring Security 사용자 정보 확장 구현체]
*
* Spring Security의 표준 UserDetails(User) 클래스를 상속받아
* 비즈니스 로직에 필요한 추가 식별자들을 포함하도록 확장한 클래스입니다.
*
* [설계 의도]
* 기본 User 객체는 username(email)과 password, 권한 정보만 가지고 있습니다.
* 하지만 실제 서비스 로직이나 JWT 토큰 생성 시에는 사용자의 DB PK(id)나 닉네임이 자주 필요합니다.
* 매 요청마다 DB를 다시 조회하는 오버헤드를 줄이기 위해, 인증 객체(Authentication) 내부에
* 이 정보들을 함께 캐싱(Caching)하여 운반하도록 설계했습니다.
*/
class CustomUserDetails(
// DB의 Primary Key (비즈니스 로직에서 조인이나 조회 시 사용)
val memberId: Long,
// UI 표시용 닉네임 (매번 회원 정보를 조회하지 않기 위함)
val nickname: String,
username: String,
password: String,
authorities: Collection<GrantedAuthority>
) : User(username, password, authorities)

View File

@@ -0,0 +1,53 @@
package me.wypark.blogbackend.domain.auth
import me.wypark.blogbackend.domain.user.Member
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
/**
* [Spring Security 사용자 로드 서비스]
*
* Spring Security의 인증 매니저(AuthenticationManager)가 실제 DB에 저장된 사용자 정보를
* 조회할 수 있도록 지원하는 핵심 인터페이스(UserDetailsService)의 구현체입니다.
*
* 도메인 영역의 [Member] 엔티티를 시큐리티 영역의 [UserDetails] 객체로 변환(Adapt)하는 역할을 수행합니다.
*/
@Service
class CustomUserDetailsService(
private val memberRepository: MemberRepository
) : UserDetailsService {
/**
* 사용자의 식별자(여기서는 이메일)로 DB에서 사용자 정보를 조회합니다.
* 로그인 요청 시 내부적으로 호출되며, 조회 실패 시 시큐리티 규격에 맞는 예외를 던집니다.
*/
override fun loadUserByUsername(username: String): UserDetails {
return memberRepository.findByEmail(username)
?.let { createUserDetails(it) }
?: throw UsernameNotFoundException("해당 유저를 찾을 수 없습니다: $username")
}
/**
* [UserDetails 변환 로직]
*
* 조회된 Member 엔티티를 기반으로 인증 객체(CustomUserDetails)를 생성합니다.
*
* [최적화 전략]
* Spring Security가 제공하는 기본 User 객체 대신, 직접 정의한 CustomUserDetails를 반환함으로써
* 추후 컨트롤러나 서비스 계층에서 @AuthenticationPrincipal을 통해
* DB 추가 조회 없이도 사용자 식별자(ID)와 닉네임에 즉시 접근할 수 있도록 설계했습니다.
*/
private fun createUserDetails(member: Member): UserDetails {
return CustomUserDetails(
memberId = member.id!!, // 비즈니스 로직용 PK 캐싱
nickname = member.nickname, // UI 렌더링용 닉네임 캐싱
username = member.email,
password = member.password,
authorities = listOf(SimpleGrantedAuthority(member.role.name))
)
}
}

View File

@@ -0,0 +1,103 @@
package me.wypark.blogbackend.domain.auth
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
import kotlin.random.Random
/**
* [이메일 인증 서비스]
*
* 회원가입 시 본인 확인을 위한 OTP(One Time Password) 발송 및 검증 로직을 담당합니다.
*
* [아키텍처 설계]
* 인증 코드의 상태(State) 관리를 위해 인메모리 DB인 Redis를 사용합니다.
* RDB를 사용하지 않음으로써 만료된 코드의 정리(Cleanup) 비용을 없애고, 빠른 액세스 속도를 보장합니다.
*/
@Service
class EmailService(
private val javaMailSender: JavaMailSender,
private val redisTemplate: RedisTemplate<String, String>
) {
/**
* 인증 코드를 생성하고 이메일로 발송합니다.
*
* 생성된 코드는 Redis에 저장되며, 보안을 위해 짧은 유효시간(TTL)을 가집니다.
* 이메일 발송은 외부 SMTP 서버를 이용하므로, 트래픽 급증 시 비동기 큐(RabbitMQ/Kafka) 도입을 고려할 수 있습니다.
*/
fun sendVerificationCode(email: String) {
val code = createVerificationCode()
// Redis 저장 전략: Key에 Prefix("Verify:")를 붙여 네임스페이스를 구분하고,
// 5분의 TTL(Time-To-Live)을 설정하여 별도의 삭제 로직 없이 자동 만료되도록 처리함.
redisTemplate.opsForValue().set(
"Verify:$email",
code,
5,
TimeUnit.MINUTES
)
sendMail(email, code)
}
/**
* 사용자가 입력한 코드와 Redis에 저장된 원본 코드를 대조합니다.
* 코드가 만료되었거나 일치하지 않을 경우 false를 반환합니다.
*/
fun verifyCode(email: String, code: String): Boolean {
val savedCode = redisTemplate.opsForValue().get("Verify:$email")
return savedCode != null && savedCode == code
}
/**
* 6자리 숫자(100000 ~ 999999)로 구성된 난수를 생성합니다.
* 보안성과 사용자 입력 편의성(Usability) 사이의 균형을 맞춘 길이입니다.
*/
private fun createVerificationCode(): String {
return Random.nextInt(100000, 999999).toString()
}
/**
* HTML 템플릿을 사용하여 인증 메일을 발송합니다.
* 단순 텍스트보다 신뢰감을 주고 브랜드 아이덴티티를 전달하기 위해 인라인 스타일(CSS)을 적용했습니다.
*/
private fun sendMail(email: String, code: String) {
val mimeMessage = javaMailSender.createMimeMessage()
val helper = MimeMessageHelper(mimeMessage, "utf-8")
helper.setTo(email)
helper.setSubject("[Blog] 회원가입 인증 코드입니다.")
// HTML 본문 구성 (이메일 클라이언트 호환성을 위해 Inline CSS 사용 권장)
val htmlContent = """
<div style="font-family: 'Apple SD Gothic Neo', 'sans-serif' !important; width: 540px; height: 600px; border-top: 4px solid #00C73C; margin: 100px auto; padding: 30px 0; box-sizing: border-box;">
<h1 style="margin: 0; padding: 0 5px; font-size: 28px; font-weight: 400;">
<span style="font-size: 15px; margin: 0 0 10px 3px;">Wypark Blog</span><br />
<span style="color: #00C73C;">메일인증</span> 안내입니다.
</h1>
<p style="font-size: 16px; line-height: 26px; margin-top: 50px; padding: 0 5px;">
안녕하세요.<br />
Wypark Blog에 가입해 주셔서 진심으로 감사드립니다.<br />
아래 <b style="color: #00C73C;">'인증 코드'</b>를 입력하여 회원가입을 완료해 주세요.<br />
감사합니다.
</p>
<div style="margin-top: 50px; border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; padding: 25px; background-color: #F9F9F9; text-align: center;">
<div style="font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #333;">
$code
</div>
</div>
<div style="margin-top: 30px; text-align: center;">
<p style="font-size: 14px; color: #888;">이 코드는 5분간 유효합니다.</p>
</div>
</div>
""".trimIndent()
helper.setText(htmlContent, true) // true: HTML 모드 활성화
javaMailSender.send(mimeMessage)
}
}

View File

@@ -5,12 +5,31 @@ import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* [Refresh Token 저장소]
*
* JWT 인증 방식의 핵심인 Refresh Token의 생명주기(저장, 조회, 삭제)를 관리하는 리포지토리입니다.
*
* [기술적 의사결정: Redis]
* RDB 대신 In-Memory DB인 Redis를 선택한 이유는 다음과 같습니다.
* 1. TTL(Time-To-Live): 토큰 만료 시 별도의 배치 작업 없이 자동으로 데이터를 삭제하여 스토리지 공간을 효율적으로 관리할 수 있습니다.
* 2. Performance: 잦은 I/O가 발생하는 토큰 검증 과정에서 디스크 기반 DB보다 훨씬 빠른 응답 속도를 보장합니다.
*/
@Repository @Repository
class RefreshTokenRepository( class RefreshTokenRepository(
private val redisTemplate: RedisTemplate<String, String>, private val redisTemplate: RedisTemplate<String, String>,
@Value("\${jwt.refresh-token-validity}") private val refreshTokenValidity: Long @Value("\${jwt.refresh-token-validity}") private val refreshTokenValidity: Long
) { ) {
// 저장 (Key: Email, Value: RefreshToken)
/**
* Refresh Token을 저장합니다.
*
* @param email 사용자 식별자 (Key)
* @param refreshToken 발급된 토큰 (Value)
*
* Key에는 "RT:" 접두어(Prefix)를 붙여 Redis 내의 다른 데이터와 네임스페이스를 분리합니다.
* 유효 기간(refreshTokenValidity)을 설정하여 해당 시간이 지나면 Redis에서 자동 소멸되도록 합니다.
*/
fun save(email: String, refreshToken: String) { fun save(email: String, refreshToken: String) {
redisTemplate.opsForValue().set( redisTemplate.opsForValue().set(
"RT:$email", "RT:$email",
@@ -20,12 +39,22 @@ class RefreshTokenRepository(
) )
} }
// 조회 /**
* 사용자의 이메일로 저장된 Refresh Token을 조회합니다.
*
* 토큰 재발급(Reissue) 요청 시 클라이언트가 보낸 토큰과 서버에 저장된 토큰의 일치 여부를
* 검증하기 위해 사용됩니다. (Refresh Token Rotation 전략의 핵심)
*/
fun findByEmail(email: String): String? { fun findByEmail(email: String): String? {
return redisTemplate.opsForValue().get("RT:$email") return redisTemplate.opsForValue().get("RT:$email")
} }
// 삭제 (로그아웃 시) /**
* Refresh Token을 삭제합니다.
*
* 사용자가 로그아웃하거나, 보안상의 이유로 토큰을 무효화해야 할 때 호출됩니다.
* Redis에서 즉시 제거(Evict)하므로, 이후 해당 토큰으로는 액세스 토큰을 재발급받을 수 없습니다.
*/
fun delete(email: String) { fun delete(email: String) {
redisTemplate.delete("RT:$email") redisTemplate.delete("RT:$email")
} }

View File

@@ -0,0 +1,71 @@
package me.wypark.blogbackend.domain.category
import jakarta.persistence.*
/**
* [카테고리 엔티티]
*
* 게시글 분류를 위한 계층형 구조(Hierarchical Structure)를 정의합니다.
* 자기 자신을 참조하는 Self-Referencing 방식을 사용하여 무한 깊이의 트리 구조를 구현했습니다.
*/
@Entity
class Category(
@Column(nullable = false)
var name: String,
/**
* [부모 카테고리]
* 루트(Root) 카테고리의 경우 null을 허용합니다.
* N+1 문제를 방지하기 위해 기본 Fetch 전략을 LAZY로 설정했습니다.
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
var parent: Category? = null,
/**
* [자식 카테고리 목록]
*
* [Cascade 설정]
* 부모 카테고리가 삭제될 경우, 데이터 무결성을 위해 하위 카테고리들도 함께 삭제(CascadeType.ALL)되도록 설정했습니다.
* (실무 정책에 따라 삭제 대신 '미분류'로 이동시키거나 삭제를 막을 수도 있습니다.)
*/
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL])
val children: MutableList<Category> = mutableListOf()
) {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
/**
* [연관관계 편의 메서드]
*
* 양방향 관계인 Category 엔티티에서 부모와 자식 간의 참조를 원자적(Atomic)으로 설정합니다.
* 객체 관점에서 부모의 children 리스트에도 추가하고, 자식의 parent 필드도 설정해주어야
* 영속성 컨텍스트(Persistence Context) 내에서 데이터 정합성이 유지됩니다.
*/
fun addChild(child: Category) {
this.children.add(child)
child.parent = this
}
fun updateName(name: String) {
this.name = name
}
/**
* [부모 카테고리 변경 (이동)]
*
* 카테고리의 위치를 트리 구조 내에서 이동시킵니다.
* 기존 부모와의 관계를 명시적으로 끊고 새로운 부모와 연결함으로써,
* JPA 1차 캐시 상의 데이터 불일치를 방지합니다.
*/
fun changeParent(newParent: Category?) {
// 1. 기존 부모와의 관계 끊기 (메모리 상의 리스트 정리)
this.parent?.children?.remove(this)
// 2. 새 부모 설정
this.parent = newParent
// 3. 새 부모의 자식 목록에 추가 (null이 아니면)
newParent?.children?.add(this)
}
}

View File

@@ -0,0 +1,41 @@
package me.wypark.blogbackend.domain.category
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
/**
* [카테고리 데이터 접근 계층]
*
* 카테고리 엔티티의 영속성(Persistence)을 관리합니다.
* 계층형 구조(Hierarchy)의 특성을 고려하여 N+1 문제를 방지하기 위한
* 최적화된 Fetch Join 쿼리를 포함하고 있습니다.
*/
interface CategoryRepository : JpaRepository<Category, Long> {
/**
* 최상위(Root) 카테고리 목록을 조회합니다.
*
* [성능 최적화: Fetch Join]
* 카테고리 트리를 구성할 때, 지연 로딩(Lazy Loading)으로 인한 N+1 문제를 방지하기 위해
* `LEFT JOIN FETCH`를 사용하여 자식 카테고리(children)까지 한 번의 쿼리로 즉시 로딩합니다.
* 이를 통해 애플리케이션 레벨에서 재귀적으로 트리를 구성할 때 DB 접근 횟수를 최소화합니다.
*/
@Query("SELECT DISTINCT c FROM Category c LEFT JOIN FETCH c.children WHERE c.parent IS NULL")
fun findAllRoots(): List<Category>
/**
* 카테고리 이름의 중복 여부를 검사합니다.
*
* 동일한 레벨 내에서 같은 이름의 카테고리가 생성되는 것을 방지하여
* 사용자의 혼란을 막고 데이터의 유니크성을 보장하기 위해 사용됩니다.
*/
fun existsByName(name: String): Boolean
/**
* 이름으로 카테고리를 조회합니다.
*
* 게시글 작성 시 카테고리 이름 문자열을 엔티티로 매핑하거나,
* URL 경로(Path Variable)를 통해 카테고리를 찾을 때 활용됩니다.
*/
fun findByName(name: String): Category?
}

View File

@@ -0,0 +1,171 @@
package me.wypark.blogbackend.domain.category
import me.wypark.blogbackend.api.dto.CategoryCreateRequest
import me.wypark.blogbackend.api.dto.CategoryResponse
import me.wypark.blogbackend.api.dto.CategoryUpdateRequest
import me.wypark.blogbackend.domain.post.PostRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* [카테고리 비즈니스 로직]
*
* 카테고리의 생성, 수정, 삭제 및 계층 구조(Tree) 관리를 담당합니다.
* 단순한 데이터 조작을 넘어, 순환 참조(Cycle) 방지와 같은 구조적 무결성 검증 로직이 포함되어 있습니다.
*/
@Service
@Transactional(readOnly = true)
class CategoryService(
private val categoryRepository: CategoryRepository,
private val postRepository: PostRepository
) {
/**
* 시스템 예약어 사용을 방지합니다.
* '미분류(uncategorized)' 등 시스템 내부 로직에서 특별하게 취급하는 이름은
* 사용자가 임의로 생성하거나 수정할 수 없도록 제한합니다.
*/
private fun validateReservedName(name: String) {
if (name.equals("uncategorized", ignoreCase = true)) {
throw IllegalArgumentException("'uncategorized'는 시스템 예약어이므로 사용할 수 없습니다.")
}
}
/**
* 전체 카테고리를 계층형 트리 구조로 반환합니다.
* Root 노드만 조회하면, 엔티티 내의 연관관계와 Fetch Join을 통해 하위 노드들이 재귀적으로 매핑됩니다.
*/
fun getCategoryTree(): List<CategoryResponse> {
val roots = categoryRepository.findAllRoots()
return roots.map { CategoryResponse.from(it) }
}
/**
* 신규 카테고리를 생성합니다.
* 데이터 정합성을 위해 이름 중복 검사와 부모 카테고리의 존재 여부를 엄격하게 검증(Strict Validation)합니다.
*/
@Transactional
fun createCategory(request: CategoryCreateRequest): Long {
// 1. 예약어 검증
validateReservedName(request.name)
// 2. 중복 체크 (Unique Constraint)
if (categoryRepository.existsByName(request.name)) {
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
}
val parent = request.parentId?.let {
categoryRepository.findByIdOrNull(it)
?: throw IllegalArgumentException("부모 카테고리가 존재하지 않습니다.")
}
val category = Category(
name = request.name,
parent = parent
)
parent?.addChild(category)
return categoryRepository.save(category).id!!
}
/**
* 카테고리 정보를 수정합니다. (이름 변경 및 트리 구조 이동)
*
* [구조 변경 시 주의사항]
* 부모 카테고리를 변경하는 경우, 트리 구조가 깨지거나 순환 참조(Cycle)가 발생할 위험이 있습니다.
* 따라서 이동 전에 `validateHierarchy`를 통해 구조적 유효성을 반드시 확인해야 합니다.
*/
@Transactional
fun updateCategory(id: Long, request: CategoryUpdateRequest) {
val category = categoryRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
// 1. 이름 변경 (실제 변경이 있을 때만 검증 수행)
if (request.name != null && category.name != request.name) {
validateReservedName(request.name)
if (categoryRepository.existsByName(request.name)) {
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
}
category.updateName(request.name)
}
// 2. 부모 변경 (구조 이동)
val currentParentId = category.parent?.id
val newParentId = request.parentId
if (currentParentId != newParentId) {
if (newParentId == null) {
// Root로 이동
category.changeParent(null)
} else {
// 다른 하위 노드로 이동
val newParent = categoryRepository.findByIdOrNull(newParentId)
?: throw IllegalArgumentException("이동하려는 부모 카테고리가 존재하지 않습니다.")
// 순환 참조 검증
validateHierarchy(category, newParent)
category.changeParent(newParent)
}
}
}
/**
* [순환 참조 방지 로직]
*
* 카테고리 이동 시, 대상(Target)이 자신의 하위 카테고리로 들어가는 것을 방지합니다.
* 만약 허용할 경우, A -> B -> A 형태의 무한 루프가 발생하여 트리 조회가 불가능해집니다.
*
* @param target 이동하려는 카테고리
* @param newParent 이동할 목적지(새 부모)
*/
private fun validateHierarchy(target: Category, newParent: Category) {
// 1. 자기 자신을 부모로 설정하는 경우
if (target.id == newParent.id) {
throw IllegalArgumentException("자기 자신을 부모로 설정할 수 없습니다.")
}
// 2. 자신의 자손(Descendant)을 부모로 설정하는 경우
var parent = newParent.parent
while (parent != null) {
if (parent.id == target.id) {
throw IllegalArgumentException("자신의 하위 카테고리 밑으로 이동할 수 없습니다.")
}
parent = parent.parent
}
}
/**
* 카테고리를 삭제합니다.
*
* [삭제 정책: Safe Deletion]
* 카테고리가 삭제되더라도, 해당 카테고리에 속한 게시글(Post)은 삭제되지 않아야 합니다.
* 따라서 삭제 대상 카테고리 및 그 하위 카테고리들에 속한 모든 게시글의 category_id를
* NULL(미분류)로 업데이트한 후, 카테고리만 물리적으로 제거합니다.
*/
@Transactional
fun deleteCategory(id: Long) {
val category = categoryRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
// 삭제할 카테고리와 그 자손들을 모두 수집 (Flattening)
val categoriesToDelete = mutableListOf<Category>()
collectAllCategories(category, categoriesToDelete)
// 연관된 게시글들의 카테고리 연결 해제 (Bulk Update로 성능 최적화)
postRepository.bulkUpdateCategoryToNull(categoriesToDelete)
// 카테고리 삭제 (Cascade 설정에 의해 하위 카테고리도 DB에서 삭제됨)
categoryRepository.delete(category)
}
/**
* 재귀적으로 하위 카테고리를 모두 순회하여 리스트에 담습니다.
*/
private fun collectAllCategories(category: Category, list: MutableList<Category>) {
list.add(category)
category.children.forEach { collectAllCategories(it, list) }
}
}

View File

@@ -0,0 +1,85 @@
package me.wypark.blogbackend.domain.comment
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
import me.wypark.blogbackend.domain.post.Post
import me.wypark.blogbackend.domain.user.Member
/**
* [댓글 엔티티]
*
* 게시글에 대한 사용자 반응(Interaction)을 저장하는 도메인 모델입니다.
*
* [핵심 설계 전략]
* 1. 계층형 구조(Hierarchy): 대댓글 기능을 지원하기 위해 자기 자신을 참조(Self-Referencing)하는 구조를 가집니다.
* 2. 하이브리드 인증 지원: 참여율을 높이기 위해 회원(Member)뿐만 아니라 비회원(Guest)의 작성도 허용하며,
* 이에 따라 작성자 정보를 조건부로 저장하는 유연한 스키마를 채택했습니다.
*/
@Entity
class Comment(
@Column(nullable = false, columnDefinition = "TEXT")
var content: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
val post: Post,
/**
* [계층형 구조 - 부모 댓글]
* 최상위 댓글일 경우 null이며, 대댓글(Reply)일 경우 상위 댓글을 참조합니다.
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
var parent: Comment? = null,
/**
* [계층형 구조 - 자식 댓글]
*
* [삭제 정책: Cascade & OrphanRemoval]
* 부모 댓글이 삭제되면 그에 딸린 대댓글들도 논리적으로 존재 가치를 잃게 되므로,
* 영속성 전이(Cascade)를 통해 DB에서 함께 삭제되도록 설정하여 데이터 정합성을 유지합니다.
*/
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true)
val children: MutableList<Comment> = mutableListOf(),
// =================================================================================
// [작성자 정보 관리 전략 (Hybrid)]
// 회원은 Member 연관관계를 사용하고, 비회원은 별도의 컬럼(guest_*)을 사용합니다.
// =================================================================================
// 1. 회원일 경우 (FK)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
val member: Member? = null,
// 2. 비회원일 경우 (임시 식별 정보)
@Column
var guestNickname: String? = null,
/**
* 비회원용 수정/삭제 비밀번호
* Note: 보안을 위해 실제 운영 환경에서는 평문 저장이 아닌 단방향 암호화(Hash) 후 저장해야 합니다.
*/
@Column
var guestPassword: String? = null
) : BaseTimeEntity() {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
/**
* 뷰 렌더링을 위한 작성자 이름 반환 로직입니다.
* 회원 여부에 따라 닉네임 소스(Source)가 달라지므로, 이를 캡슐화하여 클라이언트에 일관된 값을 제공합니다.
*/
fun getAuthorName(): String {
return member?.nickname ?: guestNickname ?: "알 수 없음"
}
/**
* 비회원 댓글 삭제 요청 시 권한 검증을 수행합니다.
*/
fun matchGuestPassword(password: String): Boolean {
return this.guestPassword == password
}
}

View File

@@ -0,0 +1,33 @@
package me.wypark.blogbackend.domain.comment
import me.wypark.blogbackend.domain.post.Post
import org.springframework.data.jpa.repository.JpaRepository
/**
* [댓글 데이터 접근 계층]
*
* 댓글 엔티티의 영속성 관리를 담당하는 리포지토리입니다.
* 계층형 댓글 구조(Root-Child)를 효율적으로 조회하고,
* 게시글 생명주기에 따른 종속적인 데이터 정리(Cleanup) 기능을 제공합니다.
*/
interface CommentRepository : JpaRepository<Comment, Long> {
/**
* 특정 게시글의 최상위(Root) 댓글 목록을 작성순으로 조회합니다.
*
* [계층형 데이터 조회 전략]
* 대댓글(Child)까지 모두 Eager Fetch로 가져올 경우 데이터 중복(Cartesian Product) 및 애플리케이션 메모리 부하가 발생할 수 있습니다.
* 따라서 Root 댓글만 우선 조회하고, 하위 댓글 컬렉션은 지연 로딩(Lazy Loading) 발생 시
* 엔티티에 설정된 @BatchSize를 통해 IN 쿼리로 묶어서 가져오는 방식으로 N+1 문제를 최적화합니다.
*/
fun findAllByPostAndParentIsNullOrderByCreatedAtAsc(post: Post): List<Comment>
/**
* 게시글 삭제 시, 해당 게시글에 종속된 모든 댓글을 삭제합니다.
*
* [데이터 무결성 관리]
* 게시글(Post)이 사라지면 댓글(Comment)은 고아 데이터(Orphaned Data)가 되므로
* 스토리지 낭비를 막고 참조 무결성을 유지하기 위해 함께 정리되어야 합니다.
*/
fun deleteAllByPost(post: Post)
}

View File

@@ -0,0 +1,156 @@
package me.wypark.blogbackend.domain.comment
import me.wypark.blogbackend.api.dto.AdminCommentResponse
import me.wypark.blogbackend.api.dto.CommentResponse
import me.wypark.blogbackend.api.dto.CommentSaveRequest
import me.wypark.blogbackend.domain.post.PostRepository
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* [댓글 비즈니스 로직]
*
* 게시글에 대한 사용자 반응(Interaction)을 처리하는 서비스입니다.
*
* [핵심 아키텍처: Hybrid Authentication]
* 사용자 참여율을 높이기 위해 로그인한 '회원'뿐만 아니라 '비회원(Guest)'의 활동도 허용합니다.
* 이에 따라 작성자 식별 및 권한 검증 로직이 이원화되어 처리됩니다.
*/
@Service
@Transactional(readOnly = true)
class CommentService(
private val commentRepository: CommentRepository,
private val postRepository: PostRepository,
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder
) {
/**
* 특정 게시글의 댓글 목록을 계층형(Tree) 구조로 조회합니다.
*
* [조회 최적화 전략]
* DB에서 모든 댓글을 가져와 애플리케이션 메모리에서 트리를 구성하는 대신,
* 최상위(Root) 댓글만 조회하고 자식 댓글(Children)은 JPA의 관계 매핑과 @BatchSize를 통해
* 필요 시점에 효율적으로 로딩(Lazy Loading)하는 방식을 택했습니다.
*/
fun getComments(postSlug: String): List<CommentResponse> {
val post = postRepository.findBySlug(postSlug)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
// Root 댓글 조회 (자식들은 DTO 변환 과정에서 재귀적으로 호출됨)
val roots = commentRepository.findAllByPostAndParentIsNullOrderByCreatedAtAsc(post)
return roots.map { CommentResponse.from(it) }
}
/**
* 댓글을 작성합니다. (회원/비회원 통합 처리)
*
* 인증 정보(userEmail) 유무에 따라 도메인 로직이 분기됩니다.
* - 회원: Member 엔티티와 연관관계를 맺어 영구적인 식별을 보장합니다.
* - 비회원: 닉네임과 비밀번호를 별도 컬럼에 저장하여 최소한의 식별 및 제어 권한을 부여합니다.
*/
@Transactional
fun createComment(request: CommentSaveRequest, userEmail: String?): Long {
// 1. 게시글 존재 확인
val post = postRepository.findBySlug(request.postSlug)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
// 2. 부모 댓글 조회 (대댓글인 경우 검증)
val parent = request.parentId?.let {
commentRepository.findByIdOrNull(it)
?: throw IllegalArgumentException("부모 댓글이 존재하지 않습니다.")
}
// 3. 작성자 유형별 엔티티 생성 (Factory Logic)
val comment = if (userEmail != null) {
// Case A: 회원 작성
val member = memberRepository.findByEmail(userEmail)
?: throw IllegalArgumentException("회원 정보를 찾을 수 없습니다.")
Comment(
content = request.content,
post = post,
parent = parent,
member = member
)
} else {
// Case B: 비회원 작성 (익명성 보장하되, 제어권 확보를 위해 비밀번호 필수)
if (request.guestNickname.isNullOrBlank() || request.guestPassword.isNullOrBlank()) {
throw IllegalArgumentException("비회원은 닉네임과 비밀번호가 필수입니다.")
}
Comment(
content = request.content,
post = post,
parent = parent,
guestNickname = request.guestNickname,
guestPassword = passwordEncoder.encode(request.guestPassword) // 보안상 단방향 암호화 저장
)
}
// 4. 연관관계 편의 메서드 (객체 그래프 정합성 유지)
parent?.children?.add(comment)
return commentRepository.save(comment).id!!
}
/**
* 댓글을 삭제합니다.
*
* [권한 검증 전략: Ownership Verification]
* 삭제 요청자가 실제 댓글 작성자인지 확인하는 로직입니다.
* 회원이라면 로그인 세션 정보를, 비회원이라면 작성 시 입력한 비밀번호를 검증 수단으로 사용합니다.
*/
@Transactional
fun deleteComment(commentId: Long, userEmail: String?, guestPassword: String?) {
val comment = commentRepository.findByIdOrNull(commentId)
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
// 권한 검증 분기
if (userEmail != null) {
// Case A: 회원 (이메일 불일치 시 예외)
if (comment.member?.email != userEmail) {
throw IllegalArgumentException("본인의 댓글만 삭제할 수 있습니다.")
}
} else {
// Case B: 비회원 (비밀번호 검증)
// DB에 저장된 해시값과 입력된 평문 비밀번호를 대조
if (comment.guestPassword == null || guestPassword == null ||
!passwordEncoder.matches(guestPassword, comment.guestPassword)) {
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
}
}
// 검증 통과 시 삭제 수행
commentRepository.delete(comment)
}
/**
* [관리자 전용] 댓글 강제 삭제
*
* 악성 댓글이나 스팸 처리를 위해, 작성자 확인 절차(Ownership Check)를 건너뛰고
* 관리자 권한으로 즉시 데이터를 제거합니다.
*/
@Transactional
fun deleteCommentByAdmin(commentId: Long) {
val comment = commentRepository.findByIdOrNull(commentId)
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
commentRepository.delete(comment)
}
/**
* 관리자 대시보드용 전체 댓글 조회
* 계층 구조와 무관하게 시간순으로 페이징하여 모니터링 편의성을 제공합니다.
*/
fun getAllComments(pageable: Pageable): Page<AdminCommentResponse> {
return commentRepository.findAll(pageable)
.map { AdminCommentResponse.from(it) }
}
}

View File

@@ -0,0 +1,39 @@
package me.wypark.blogbackend.domain.common
import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
/**
* [공통 시간 정보 엔티티]
*
* 모든 엔티티가 공통적으로 가져야 할 '생성 시간'과 '수정 시간'을 관리하는 상위 클래스입니다.
*
* [설계 의도]
* 반복적인 감사(Audit) 로직을 중복 구현하는 것을 방지하기 위해 JPA Auditing 기능을 적용했습니다.
* 이를 상속받는 엔티티들은 별도의 코드 작성 없이 데이터의 생명주기를 자동으로 추적할 수 있습니다.
*/
@MappedSuperclass // 테이블로 매핑되지 않고, 자식 클래스의 엔티티에 컬럼 정보만 제공함 (상속 관계 매핑 X)
@EntityListeners(AuditingEntityListener::class) // 엔티티의 변경 이벤트를 감지하여 시간 값을 자동으로 주입(Inject)
abstract class BaseTimeEntity {
/**
* 최초 생성 시각 (Immutable)
* 데이터의 이력을 추적하는 기준이 되므로, 생성 이후에는 절대 변경되지 않도록 updatable = false를 설정하여 무결성을 보장합니다.
*/
@CreatedDate
@Column(nullable = false, updatable = false)
var createdAt: LocalDateTime = LocalDateTime.now()
/**
* 최종 수정 시각
* 비즈니스 로직에 의해 데이터가 변경될 때마다 JPA가 자동으로 현재 시간을 갱신합니다.
*/
@LastModifiedDate
@Column(nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
}

View File

@@ -0,0 +1,114 @@
package me.wypark.blogbackend.domain.image
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.util.*
/**
* [이미지 처리 서비스]
*
* AWS S3 또는 호환 가능한 Object Storage(MinIO 등)와의 통신을 전담하는 서비스입니다.
* 비즈니스 로직(게시글 작성 등)에서 파일 저장에 대한 세부 구현을 몰라도 되도록
* 업로드 및 삭제 기능을 추상화하여 제공합니다.
*/
@Service
class ImageService(
private val s3Client: S3Client,
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
) {
private val bucketName = "blog-images"
/**
* 서비스 초기화 시점에 버킷 존재 여부를 확인합니다.
* 로컬 개발 환경이나 초기 배포 시, 수동으로 스토리지를 세팅하는 번거로움을 줄이기 위해
* 애플리케이션 레벨에서 인프라(Bucket & Policy)를 자동 프로비저닝(Auto-Provisioning)합니다.
*/
init {
createBucketIfNotExists()
}
/**
* 이미지를 스토리지에 업로드하고 접근 가능한 URL을 반환합니다.
*
* [파일명 생성 전략]
* 사용자가 업로드한 원본 파일명은 중복될 가능성이 높으므로,
* UUID(Universally Unique Identifier)를 사용하여 고유한 식별자를 생성함으로써 덮어쓰기(Overwrite)를 방지합니다.
*/
fun uploadImage(file: MultipartFile): String {
val originalName = file.originalFilename ?: "image.jpg"
val ext = originalName.substringAfterLast(".", "jpg")
val fileName = "${UUID.randomUUID()}.$ext"
// 메타데이터(ContentType)를 명시하여 브라우저에서 올바르게 렌더링되도록 설정
val putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.contentType(file.contentType)
.build()
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size))
// 클라이언트가 즉시 접근할 수 있는 절대 경로(URL) 반환
return "$endpoint/$bucketName/$fileName"
}
/**
* 스토리지에서 이미지를 삭제합니다.
*
* [Fail-Safe 전략]
* 이미지 삭제 실패가 비즈니스 트랜잭션(예: 게시글 삭제)의 실패로 이어지지 않도록 예외를 내부에서 소비(Swallow)합니다.
* 고아 객체(Orphaned Object)가 남더라도 메인 데이터의 정합성을 우선시하는 설계입니다.
*/
fun deleteImage(fileName: String) {
try {
val deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.build()
s3Client.deleteObject(deleteObjectRequest)
} catch (e: Exception) {
e.printStackTrace() // 실제 운영 시에는 Error Log 레벨로 기록하여 추후 배치 작업 등으로 정리 필요
}
}
/**
* [버킷 초기화 로직]
* 버킷이 없을 경우 생성하고, 웹에서 이미지를 조회할 수 있도록 'Public Read' 권한 정책을 주입합니다.
*/
private fun createBucketIfNotExists() {
try {
// 버킷 존재 여부 확인 (Head Bucket)
s3Client.headBucket { it.bucket(bucketName) }
} catch (e: Exception) {
// 버킷 생성
s3Client.createBucket { it.bucket(bucketName) }
// [접근 제어 정책 설정]
// 외부 사용자가 URL을 통해 이미지(Object)를 조회(GetObject)할 수 있도록
// 버킷 정책(Bucket Policy)을 JSON 형태로 정의하여 적용합니다.
val policy = """
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::$bucketName/*"]
}
]
}
""".trimIndent()
s3Client.putBucketPolicy {
it.bucket(bucketName).policy(policy)
}
}
}
}

View File

@@ -0,0 +1,91 @@
package me.wypark.blogbackend.domain.post
import jakarta.persistence.*
import me.wypark.blogbackend.domain.category.Category
import me.wypark.blogbackend.domain.common.BaseTimeEntity
import me.wypark.blogbackend.domain.tag.PostTag
import me.wypark.blogbackend.domain.user.Member
/**
* [게시글 엔티티]
*
* 블로그의 핵심 콘텐츠인 게시글(Post) 데이터를 정의하는 도메인 모델입니다.
*
* [설계 의도]
* - Setter 사용을 지양하고, 비즈니스 의미가 명확한 편의 메서드(update, addTags 등)를 통해 상태를 변경하도록 설계하여
* 객체의 일관성(Consistency)과 코드의 응집도(Cohesion)를 높였습니다.
* - 조회수(viewCount)와 같은 동시성 처리가 필요한 필드는 별도의 증가 메서드로 관리합니다.
*/
@Entity
class Post(
@Column(nullable = false)
var title: String,
@Column(columnDefinition = "TEXT", nullable = false)
var content: String,
@Column(nullable = false, unique = true)
var slug: String,
@Column(nullable = false)
var viewCount: Long = 0,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
val member: Member,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
var category: Category? = null,
/**
* [태그 매핑 전략]
* PostTag 엔티티와의 일대다 관계를 통해 태그 정보를 관리합니다.
* 게시글이 삭제되거나 수정될 때 태그 연결 정보도 함께 정리되어야 하므로
* CascadeType.ALL과 orphanRemoval=true 옵션을 사용하여 생명주기를 동기화했습니다.
*/
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
val tags: MutableList<PostTag> = mutableListOf()
) : BaseTimeEntity() {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
/**
* 조회수를 1 증가시킵니다.
* Note: 높은 트래픽 환경에서는 DB Lock 경합이 발생할 수 있으므로,
* Redis HyperLogLog 등을 활용한 캐싱 후 배치 업데이트(Write-Back) 전략을 고려할 수 있습니다.
*/
fun increaseViewCount() {
this.viewCount++
}
fun addTags(postTags: List<PostTag>) {
this.tags.addAll(postTags)
}
/**
* [게시글 수정 편의 메서드]
*
* 제목, 본문, 슬러그, 카테고리 등 주요 필드를 한 번에 업데이트합니다.
* JPA의 변경 감지(Dirty Checking) 기능에 의해 트랜잭션 종료 시점에 자동으로 Update 쿼리가 실행됩니다.
*/
fun update(title: String, content: String, slug: String, category: Category?) {
this.title = title
this.content = content
this.slug = slug
this.category = category
}
/**
* [태그 전체 교체 로직]
*
* 기존 태그 목록을 모두 비우고(clear) 새로운 태그들로 대체합니다.
* orphanRemoval = true 설정에 의해, 컬렉션에서 제거된 기존 PostTag 엔티티들은
* DB에서도 자동으로 삭제(DELETE) 처리됩니다.
*/
fun updateTags(newTags: List<PostTag>) {
this.tags.clear()
this.tags.addAll(newTags)
}
}

View File

@@ -0,0 +1,70 @@
package me.wypark.blogbackend.domain.post
import me.wypark.blogbackend.domain.category.Category
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
/**
* [게시글 데이터 접근 계층]
*
* 게시글(Post) 엔티티의 영속성을 관리하며,
* 검색(Search), 필터링(Filter), 대량 수정(Bulk Update) 등의 다양한 DB 조작을 수행합니다.
* 복잡한 동적 쿼리는 PostRepositoryCustom(QueryDSL)을 통해 처리합니다.
*/
interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom {
/**
* URL 친화적인 식별자(Slug)로 게시글을 단건 조회합니다.
* 숫자 ID 대신 의미 있는 문자열을 사용하여 검색 엔진 최적화(SEO)와 사용자 경험(UX)을 향상시킵니다.
*/
fun findBySlug(slug: String): Post?
/**
* Slug의 유일성(Uniqueness)을 검증합니다.
* 게시글 작성/수정 시 중복된 Slug가 발생하지 않도록 사전에 확인하는 용도입니다.
*/
fun existsBySlug(slug: String): Boolean
/**
* 기본 페이징 조회 메서드를 오버라이드합니다.
* 최신순, 조회순 등 다양한 정렬 기준은 Pageable 객체에 담겨 전달됩니다.
*/
override fun findAll(pageable: Pageable): Page<Post>
/**
* 특정 카테고리에 속한 게시글 목록을 페이징하여 조회합니다.
*/
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
/**
* [벌크 연산 최적화]
*
* 카테고리 삭제 시, 해당 카테고리에 속했던 게시글들을 일일이 조회하여 수정(Dirty Checking)하는 것은 비효율적입니다.
* 따라서 단 한 번의 UPDATE 쿼리로 '미분류(NULL)' 처리를 수행하여 성능을 극대화합니다.
*
* @Modifying(clearAutomatically = true):
* 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날리므로,
* 실행 후 1차 캐시와 DB의 데이터 불일치를 막기 위해 자동으로 캐시를 비웁니다.
*/
@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories")
fun bulkUpdateCategoryToNull(@Param("categories") categories: List<Category>)
/**
* [이전 글 조회]
* 현재 글(ID)보다 작으면서(Less Than) 가장 큰 ID를 가진 레코드를 찾습니다.
* (즉, 바로 직전에 작성된 글)
*/
fun findFirstByIdLessThanOrderByIdDesc(id: Long): Post?
/**
* [다음 글 조회]
* 현재 글(ID)보다 크면서(Greater Than) 가장 작은 ID를 가진 레코드를 찾습니다.
* (즉, 바로 직후에 작성된 글)
*/
fun findFirstByIdGreaterThanOrderByIdAsc(id: Long): Post?
}

View File

@@ -0,0 +1,35 @@
package me.wypark.blogbackend.domain.post
import me.wypark.blogbackend.api.dto.PostSummaryResponse
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
/**
* [게시글 동적 쿼리(Dynamic Query) 인터페이스]
*
* QueryDSL을 기반으로 복잡한 검색 및 필터링 로직을 수행하기 위한 커스텀 리포지토리 인터페이스입니다.
* 정적 메서드(Method Name Query)만으로는 처리하기 힘든 다중 조건 조합과
* DTO 프로젝션(Projection)을 담당합니다.
*/
interface PostRepositoryCustom {
/**
* 게시글을 다양한 조건으로 검색하고 페이징 처리된 요약 정보를 반환합니다.
*
* [검색 필터 전략]
* - Keyword: 제목(Title)과 본문(Content)에 대한 통합 검색을 수행합니다.
* - Categories: 단일 카테고리가 아닌 다중 카테고리 필터링(IN절)을 지원하여,
* 사용자가 원하는 주제들을 한 번에 모아볼 수 있는 유연성을 제공합니다.
* - Tag: 특정 태그가 포함된 게시글을 필터링합니다.
*
* [성능 최적화: Projection]
* 엔티티 전체를 조회하는 대신, 목록 화면에 필요한 필드만 선별하여 DTO로 즉시 변환합니다.
* 이는 불필요한 데이터 전송(Network I/O)을 줄이고 영속성 컨텍스트의 부하를 최소화합니다.
*/
fun search(
keyword: String?,
categoryNames: List<String>?, // 다중 선택 지원 (IN Clause)
tagName: String?,
pageable: Pageable
): Page<PostSummaryResponse>
}

View File

@@ -0,0 +1,169 @@
package me.wypark.blogbackend.domain.post
import com.querydsl.core.BooleanBuilder
import com.querydsl.core.types.Order
import com.querydsl.core.types.OrderSpecifier
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import me.wypark.blogbackend.api.dto.PostSummaryResponse
import me.wypark.blogbackend.domain.post.QPost.post
import me.wypark.blogbackend.domain.tag.QPostTag.postTag
import me.wypark.blogbackend.domain.tag.QTag.tag
import me.wypark.blogbackend.domain.category.QCategory.category
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
/**
* [QueryDSL 리포지토리 구현체]
*
* PostRepositoryCustom 인터페이스를 구현하여 복잡한 동적 쿼리를 처리합니다.
* 컴파일 타임에 문법 오류를 잡을 수 있는 QueryDSL을 사용하여,
* 다중 필터링 조건과 조인(Join) 로직을 안전하고 직관적으로 작성했습니다.
*/
class PostRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PostRepositoryCustom {
/**
* 동적 검색 및 목록 조회
*
* [성능 최적화: Projections]
* 엔티티를 통째로 조회하면 불필요한 컬럼(LOB 데이터 등)까지 로딩되어 메모리 낭비가 발생합니다.
* 따라서 목록 화면 렌더링에 필요한 필드만 선별하여 DTO로 즉시 매핑(Projection)했습니다.
*
* [조회 정합성 보장]
* - Left Join: 카테고리나 태그가 없는 게시글도 누락 없이 조회되도록 Inner Join 대신 Left Join을 사용했습니다.
* - Distinct: 1:N 관계인 태그 테이블과 조인 시 게시글 데이터가 뻥튀기(Duplication)되는 문제를 해결합니다.
*/
override fun search(keyword: String?, categoryNames: List<String>?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
val builder = BooleanBuilder()
builder.and(containsKeyword(keyword))
builder.and(inCategoryNames(categoryNames))
builder.and(eqTagName(tagName))
val query = queryFactory
.select(
Projections.constructor(
PostSummaryResponse::class.java,
post.id,
post.title,
post.slug,
category.name, // QCategory Alias 사용으로 Null-Safe 처리
post.viewCount,
post.createdAt,
post.updatedAt,
post.content // 본문 미리보기용 데이터
)
)
.from(post)
.leftJoin(post.category, category) // 카테고리 미지정 글 포함
.leftJoin(post.tags, postTag) // 태그 미지정 글 포함
.leftJoin(postTag.tag, tag)
.where(builder)
.distinct()
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
// 동적 정렬 적용
for (order in getOrderSpecifiers(pageable)) {
query.orderBy(order)
}
val content = query.fetch()
// [Count 쿼리 분리]
// 페이징을 위한 전체 개수 조회 시, 데이터 조회 쿼리보다 단순화할 수 있는 여지가 있다면
// 별도의 쿼리로 분리하여 성능을 최적화하는 것이 좋습니다.
val total = queryFactory
.select(post.countDistinct())
.from(post)
.leftJoin(post.category, category)
.leftJoin(post.tags, postTag)
.leftJoin(postTag.tag, tag)
.where(builder)
.fetchOne() ?: 0L
return PageImpl(content, pageable, total)
}
private fun containsKeyword(keyword: String?): BooleanBuilder {
val builder = BooleanBuilder()
if (!keyword.isNullOrBlank()) {
// 제목 또는 본문에 키워드가 포함되는지 검사 (OR 조건)
builder.or(post.title.containsIgnoreCase(keyword))
builder.or(post.content.containsIgnoreCase(keyword))
}
return builder
}
/**
* 카테고리 다중 필터링 조건 생성
*
* [미분류(Uncategorized) 처리 전략]
* 클라이언트로부터 "uncategorized" 요청이 오면 DB상의 NULL 값과 매핑해야 합니다.
* 일반 카테고리(IN 절)와 미분류(IS NULL) 조건이 혼재될 경우, 이를 유연하게 OR 연산으로 묶어 처리합니다.
*/
private fun inCategoryNames(categoryNames: List<String>?): BooleanExpression? {
if (categoryNames.isNullOrEmpty()) return null
// 1. 특수 키워드 체크 ("uncategorized", "미분류")
val hasUncategorized = categoryNames.any {
it.equals("uncategorized", ignoreCase = true) || it.equals("미분류", ignoreCase = true)
}
// 2. 일반 카테고리명 추출
val normalNames = categoryNames.filter {
!it.equals("uncategorized", ignoreCase = true) && !it.equals("미분류", ignoreCase = true)
}
var expression: BooleanExpression? = null
// A. 일반 카테고리 조건 (IN Clause)
if (normalNames.isNotEmpty()) {
expression = category.name.`in`(normalNames)
}
// B. 미분류 조건 (IS NULL) 결합
if (hasUncategorized) {
val isNullExpr = post.category.isNull
expression = if (expression != null) {
// (일반 카테고리들) OR (미분류) -> 둘 중 하나라도 만족하면 조회
expression.or(isNullExpr)
} else {
// 오직 미분류 글만 조회
isNullExpr
}
}
return expression
}
private fun eqTagName(tagName: String?): BooleanExpression? {
if (tagName.isNullOrBlank()) return null
return tag.name.eq(tagName)
}
/**
* Pageable의 Sort 객체를 QueryDSL의 OrderSpecifier로 변환
* 문자열 필드명을 실제 Q-Type 필드로 매핑하여 런타임 에러를 방지합니다.
*/
private fun getOrderSpecifiers(pageable: Pageable): List<OrderSpecifier<*>> {
val orders = mutableListOf<OrderSpecifier<*>>()
if (!pageable.sort.isEmpty) {
for (order in pageable.sort) {
val direction = if (order.direction.isAscending) Order.ASC else Order.DESC
when (order.property) {
"viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount))
"createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt))
"id" -> orders.add(OrderSpecifier(direction, post.id))
else -> orders.add(OrderSpecifier(Order.DESC, post.id)) // 기본 정렬
}
}
}
return orders
}
}

View File

@@ -0,0 +1,252 @@
package me.wypark.blogbackend.domain.post
import me.wypark.blogbackend.api.dto.PostResponse
import me.wypark.blogbackend.api.dto.PostSaveRequest
import me.wypark.blogbackend.api.dto.PostSummaryResponse
import me.wypark.blogbackend.domain.category.Category
import me.wypark.blogbackend.domain.category.CategoryRepository
import me.wypark.blogbackend.domain.image.ImageService
import me.wypark.blogbackend.domain.tag.PostTag
import me.wypark.blogbackend.domain.tag.Tag
import me.wypark.blogbackend.domain.tag.TagRepository
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* [게시글 비즈니스 로직]
*
* 게시글(Post)의 생명주기(Lifecycle) 전반을 관리하는 서비스입니다.
* 단순 CRUD 외에도 다음과 같은 중요한 정책들을 수행합니다.
*
* 1. 리소스 정리: 게시글 수정/삭제 시 본문에서 제외된 이미지를 S3에서 물리적으로 삭제하여 스토리지 비용을 최적화합니다.
* 2. URL 전략: 검색 엔진 최적화(SEO)를 위해 중복되지 않는 고유한 Slug를 생성하고 관리합니다.
* 3. 검색 확장: 카테고리 검색 시 하위 카테고리의 글까지 포함하여 조회하는 재귀적 검색 로직을 제공합니다.
*/
@Service
@Transactional(readOnly = true)
class PostService(
private val postRepository: PostRepository,
private val categoryRepository: CategoryRepository,
private val memberRepository: MemberRepository,
private val tagRepository: TagRepository,
private val imageService: ImageService
) {
/**
* 전체 게시글 목록을 조회합니다.
* 목록 뷰에서는 본문 전체가 필요 없으므로, 경량화된 DTO(Summary)로 변환하여 트래픽을 절감합니다.
*/
fun getPosts(pageable: Pageable): Page<PostSummaryResponse> {
return postRepository.findAll(pageable)
.map { PostSummaryResponse.from(it) }
}
/**
* 게시글 상세 정보를 조회합니다.
*
* [부가 로직]
* 1. 조회수 증가: 상세 조회 시 조회수 카운트를 원자적(Atomic)으로 증가시킵니다.
* 2. 인접 게시글 탐색: 사용자의 탐색 연속성(UX)을 위해 현재 글을 기준으로 이전/다음 글의 메타데이터를 함께 반환합니다.
*/
@Transactional
fun getPostBySlug(slug: String): PostResponse {
val post = postRepository.findBySlug(slug)
?: throw IllegalArgumentException("해당 게시글을 찾을 수 없습니다: $slug")
post.increaseViewCount()
// 인접 게시글 조회 (Prev/Next Navigation)
// ID를 기준으로 정렬하여 바로 앞/뒤의 게시글을 1건씩 조회합니다.
val prevPost = postRepository.findFirstByIdLessThanOrderByIdDesc(post.id!!)
val nextPost = postRepository.findFirstByIdGreaterThanOrderByIdAsc(post.id!!)
return PostResponse.from(post, prevPost, nextPost)
}
/**
* 신규 게시글을 생성합니다.
*
* [Slug 생성 전략]
* 사용자가 Slug를 직접 입력하지 않은 경우 제목을 기반으로 생성하며,
* 중복 발생 시 숫자를 붙여(suffix) 유일성을 보장하는 재귀적/반복적 로직을 수행합니다.
*/
@Transactional
fun createPost(request: PostSaveRequest, email: String): Long {
val member = memberRepository.findByEmail(email)
?: throw IllegalArgumentException("회원 없음")
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
// SEO Friendly URL 생성을 위한 Slug 중복 검사 및 생성
val uniqueSlug = generateUniqueSlug(request.slug, request.title)
val post = Post(
title = request.title,
content = request.content,
slug = uniqueSlug,
member = member,
category = category
)
// 태그 처리: 기존 태그는 재사용, 없는 태그는 신규 생성 (Find or Create)
val postTags = resolveTags(request.tags, post)
post.addTags(postTags)
return postRepository.save(post).id!!
}
/**
* 게시글 정보를 수정합니다.
*
* [이미지 가비지 컬렉션 (GC)]
* 본문 수정 과정에서 삭제된 이미지 태그를 감지하여, 실제 스토리지(S3)에서도 파일을 삭제합니다.
* 이를 통해 DB와 스토리지 간의 데이터 불일치를 방지하고 불필요한 비용 발생을 억제합니다.
*/
@Transactional
fun updatePost(id: Long, request: PostSaveRequest): Long {
val post = postRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
// 1. 고아 이미지 정리: (수정 전 이미지 목록 - 수정 후 이미지 목록)
val oldImages = extractImageNamesFromContent(post.content)
val newImages = extractImageNamesFromContent(request.content)
val removedImages = oldImages - newImages.toSet()
removedImages.forEach { imageService.deleteImage(it) }
// 2. 카테고리 정보 갱신
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
// 3. Slug 갱신 (변경 요청 시에만 수행하여 불필요한 URL 변경 방지)
var newSlug = post.slug
if (!request.slug.isNullOrBlank() && request.slug != post.slug) {
newSlug = generateUniqueSlug(request.slug, request.title)
}
// 4. 게시글 메타데이터 업데이트 (Dirty Checking)
post.update(request.title, request.content, newSlug, category)
// 5. 태그 매핑 재설정
val newPostTags = resolveTags(request.tags, post)
post.updateTags(newPostTags)
return post.id!!
}
/**
* 게시글을 삭제합니다.
*
* [Cascading Deletion]
* 게시글 엔티티뿐만 아니라, 본문에 포함된 모든 이미지 파일도 스토리지에서 제거합니다.
* 태그 매핑 정보 등은 JPA Cascade 설정에 의해 자동으로 정리됩니다.
*/
@Transactional
fun deletePost(id: Long) {
val post = postRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
// 본문에 포함된 이미지 추출 및 삭제
val imageNames = extractImageNamesFromContent(post.content)
imageNames.forEach { fileName ->
imageService.deleteImage(fileName)
}
postRepository.delete(post)
}
/**
* 복합 조건 검색을 수행합니다.
*
* [계층형 카테고리 검색]
* 상위 카테고리로 검색 시, 해당 카테고리에 속한 하위 카테고리(Descendants)의 게시글들도
* 모두 결과에 포함되도록 검색 조건을 확장(Expand)합니다.
*/
fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
val categoryNames = if (categoryName != null) {
getCategoryAndDescendants(categoryName)
} else {
null
}
return postRepository.search(keyword, categoryNames, tagName, pageable)
}
// --- Helper Methods ---
/**
* Slug 중복 발생 시, 카운팅 숫자를 접미사(Suffix)로 붙여 유일한 값을 생성합니다.
* 예: "hello-world" -> "hello-world-1" -> "hello-world-2"
*/
private fun generateUniqueSlug(inputSlug: String?, title: String): String {
val rawSlug = if (!inputSlug.isNullOrBlank()) {
inputSlug
} else {
// URL에 안전하지 않은 문자 제거 및 공백 치환
title.trim().replace("\\s+".toRegex(), "-").lowercase()
}
var uniqueSlug = rawSlug
var count = 1
// 특수문자 정제
uniqueSlug = uniqueSlug.replace("?", "")
uniqueSlug = uniqueSlug.replace(";", "")
// 중복 체크 루프
while (postRepository.existsBySlug(uniqueSlug)) {
uniqueSlug = "$rawSlug-$count"
count++
}
return uniqueSlug
}
/**
* 태그 문자열 리스트를 PostTag 엔티티 리스트로 변환합니다.
* DB에 존재하지 않는 태그는 즉시 생성(Save)하여 매핑합니다.
*/
private fun resolveTags(tagNames: List<String>, post: Post): List<PostTag> {
return tagNames.map { tagName ->
val tag = tagRepository.findByName(tagName)
?: tagRepository.save(Tag(name = tagName))
PostTag(post = post, tag = tag)
}
}
/**
* 정규표현식을 사용하여 Markdown 본문에서 이미지 URL(파일명)을 추출합니다.
* 패턴: ![alt](url)
*/
private fun extractImageNamesFromContent(content: String): List<String> {
val regex = Regex("!\\[.*?\\]\\((.*?)\\)")
return regex.findAll(content)
.map { it.groupValues[1] }
.map { it.substringAfterLast("/") }
.toList()
}
/**
* 특정 카테고리의 모든 자손 카테고리 이름을 재귀적으로 수집합니다.
* "Parent" 검색 시 "Parent > Child"의 글도 나오게 하기 위함입니다.
*/
private fun getCategoryAndDescendants(categoryName: String): List<String> {
if (categoryName.equals("uncategorized", ignoreCase = true)) {
return listOf("uncategorized")
}
val category = categoryRepository.findByName(categoryName)
if (category == null) return listOf(categoryName)
val names = mutableListOf<String>()
collectCategoryNames(category, names)
return names
}
private fun collectCategoryNames(category: Category, names: MutableList<String>) {
names.add(category.name)
category.children.forEach { collectCategoryNames(it, names) }
}
}

View File

@@ -0,0 +1,55 @@
package me.wypark.blogbackend.domain.profile
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
/**
* [블로그 프로필 엔티티]
*
* 블로그 운영자(Owner)의 공개적인 신원 정보(Identity)를 관리하는 도메인 모델입니다.
*
* [설계 의도: 관심사의 분리 (Separation of Concerns)]
* 인증/인가를 담당하는 Member 엔티티와 의도적으로 분리하여 설계했습니다.
* - Member: 시스템 접속 및 보안을 위한 계정 정보 (Email, Password, Role) -> 보안 중요, 변경 빈도 낮음
* - BlogProfile: 방문자에게 보여지는 소개 정보 (Bio, Social Links) -> 공개 데이터, 변경 빈도 높음
* 이렇게 책임을 분리함으로써, 프로필 정보 수정 로직이 핵심 인증 데이터에 영향을 주지 않도록 격리했습니다.
*/
@Entity
@Table(name = "blog_profile")
class BlogProfile(
@Column(nullable = false)
var name: String,
// 사용자의 긴 자기소개를 수용하기 위해 대용량 텍스트(CLOB) 타입으로 매핑
@Column(columnDefinition = "TEXT")
var bio: String,
// S3/MinIO 등에 업로드된 이미지 리소스의 절대 경로(URL)
@Column
var imageUrl: String? = null,
@Column
var githubUrl: String? = null,
@Column
var email: String? = null
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
/**
* 프로필 정보를 갱신합니다.
*
* 단순 Setter 나열을 지양하고, 의미 있는 비즈니스 메서드(Update)를 정의하여
* 한 번의 트랜잭션 내에서 관련된 모든 정보가 원자적(Atomic)으로 변경됨을 명시합니다.
*/
fun update(name: String, bio: String, imageUrl: String?, githubUrl: String?, email: String?) {
this.name = name
this.bio = bio
this.imageUrl = imageUrl
this.githubUrl = githubUrl
this.email = email
}
}

View File

@@ -0,0 +1,6 @@
package me.wypark.blogbackend.domain.profile
import org.springframework.data.jpa.repository.JpaRepository
interface BlogProfileRepository : JpaRepository<BlogProfile, Long> {
}

View File

@@ -0,0 +1,88 @@
package me.wypark.blogbackend.domain.profile
import me.wypark.blogbackend.api.dto.ProfileResponse
import me.wypark.blogbackend.api.dto.ProfileUpdateRequest
import me.wypark.blogbackend.domain.image.ImageService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* [프로필 비즈니스 로직]
*
* 블로그 운영자의 정보 관리 및 관련 리소스(이미지) 처리를 담당합니다.
*
* [단일 리소스 정책]
* 이 블로그 시스템은 단일 운영자(Single User)를 가정하므로,
* 프로필 데이터는 테이블 내에 항상 1개의 레코드(Singleton)만 존재하도록 관리됩니다.
*/
@Service
@Transactional(readOnly = true)
class BlogProfileService(
private val blogProfileRepository: BlogProfileRepository,
private val imageService: ImageService
) {
/**
* 현재 설정된 프로필 정보를 조회합니다.
*
* [초기화 전략: Get-Or-Create]
* 앱 초기 구동 시 프로필 데이터가 없을 경우(Cold Start),
* 사용자에게 빈 화면이나 에러를 보여주는 대신 기본값(Default)으로 레코드를 생성하여 반환합니다.
* 이를 통해 별도의 초기화 스크립트 없이도 즉시 서비스를 사용할 수 있습니다.
*/
@Transactional
fun getProfile(): ProfileResponse {
val profile = blogProfileRepository.findAll().firstOrNull()
?: blogProfileRepository.save(
BlogProfile(
name = "Blog User",
bio = "안녕하세요. 블로그에 오신 것을 환영합니다.",
imageUrl = null,
githubUrl = null,
email = null
)
)
return ProfileResponse.from(profile)
}
/**
* 프로필 정보를 수정합니다.
*
* [리소스 최적화: Image Garbage Collection]
* 프로필 이미지가 변경되거나 삭제될 경우, 더 이상 사용되지 않는 기존 이미지 파일(Dangling File)을
* 스토리지(S3)에서 즉시 삭제하여 스토리지 비용 낭비를 방지합니다.
*/
@Transactional
fun updateProfile(request: ProfileUpdateRequest) {
// 데이터가 없으면 생성(Upsert)
val profile = blogProfileRepository.findAll().firstOrNull()
?: blogProfileRepository.save(
BlogProfile(
name = request.name,
bio = request.bio,
imageUrl = request.imageUrl,
githubUrl = request.githubUrl,
email = request.email
)
)
// [이미지 변경 감지]
// 요청된 이미지 URL이 기존과 다를 경우 (교체 또는 삭제)
if (profile.imageUrl != request.imageUrl) {
// 기존 이미지가 존재했다면 정리 대상이므로 삭제 처리
if (!profile.imageUrl.isNullOrBlank()) {
val oldFileName = profile.imageUrl!!.substringAfterLast("/")
imageService.deleteImage(oldFileName)
}
}
// 엔티티 상태 업데이트 (Dirty Checking에 의해 트랜잭션 종료 시 반영)
profile.update(
name = request.name,
bio = request.bio,
imageUrl = request.imageUrl,
githubUrl = request.githubUrl,
email = request.email
)
}
}

View File

@@ -0,0 +1,19 @@
package me.wypark.blogbackend.domain.tag
import jakarta.persistence.*
import me.wypark.blogbackend.domain.post.Post
@Entity
@Table(name = "post_tag")
class PostTag(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
val post: Post,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
val tag: Tag
) {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}

View File

@@ -0,0 +1,13 @@
package me.wypark.blogbackend.domain.tag
import jakarta.persistence.*
@Entity
@Table(name = "tag")
class Tag(
@Column(nullable = false, unique = true)
val name: String
) {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}

View File

@@ -0,0 +1,7 @@
package me.wypark.blogbackend.domain.tag
import org.springframework.data.jpa.repository.JpaRepository
interface TagRepository : JpaRepository<Tag, Long> {
fun findByName(name: String): Tag?
}

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.domain.user
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
@Entity
@Table(name = "member")
class Member(
@Column(nullable = false, unique = true)
val email: String,
@Column(nullable = false)
var password: String,
@Column(nullable = false)
var nickname: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val role: Role,
@Column(nullable = false)
var isVerified: Boolean = false
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
fun verify() {
this.isVerified = true
}
}
enum class Role {
ROLE_USER, ROLE_ADMIN
}

View File

@@ -0,0 +1,10 @@
package me.wypark.blogbackend.domain.user
import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository : JpaRepository<Member, Long> {
// 로그인 및 중복 가입 방지를 위한 핵심 메소드들입니다.
fun findByEmail(email: String): Member?
fun existsByEmail(email: String): Boolean
fun existsByNickname(nickname: String): Boolean
}

View File

@@ -1,49 +1,85 @@
spring: spring:
application: # [파일 업로드 제한 설정]
name: blog-api # 고해상도 이미지나 대용량 미디어를 처리하기 위해 서블릿의 멀티파트 제약 조건을 완화합니다.
# Nginx 등의 리버스 프록시를 앞단에 둘 경우, 프록시 설정(client_max_body_size)도 함께 조정해야 합니다.
servlet:
multipart:
max-file-size: 100MB # 단일 파일 허용 크기
max-request-size: 100MB # 요청 전체 허용 크기 (여러 파일 합산)
# 1. 데이터베이스 설정 # [데이터베이스 설정]
# Docker Compose 환경에서의 컨테이너 간 통신을 지원하기 위해 호스트 등을 환경변수로 동적 주입받습니다.
datasource: datasource:
# Docker 내부 통신용 url: jdbc:postgresql://${SPRING_DATASOURCE_URL}
url: jdbc:postgresql://db:5432/blog_db username: ${SPRING_DATASOURCE_USERNAME}
username: ${SPRING_DATASOURCE_USERNAME:wypark} password: ${SPRING_DATASOURCE_PASSWORD}
password: ${SPRING_DATASOURCE_PASSWORD:your_password}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
# 2. JPA 설정 # [JPA / Hibernate 설정]
jpa: jpa:
hibernate: hibernate:
ddl-auto: update # 운영 환경 데이터 보존을 위해 스키마 자동 생성(create/update) 대신 검증(validate) 모드를 사용합니다.
# 스키마 불일치 시 애플리케이션 실행을 중단하여 잠재적 오류를 방지합니다.
ddl-auto: validate
properties: properties:
hibernate: hibernate:
format_sql: true # 쿼리 줄바꿈 format_sql: false # 운영 로그 가독성을 위해 SQL 포맷팅 비활성화 (디버깅 시 true 권장)
show_sql: true # 쿼리 출력 show_sql: false # 성능 저하 방지를 위해 콘솔 출력 비활성화 (로거로 대체 권장)
highlight_sql: true # 쿼리 색상 강조 (가독성 UP) highlight_sql: false
open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
# 3. Redis 설정 # [OSIV(Open Session In View) 비활성화]
# 영속성 컨텍스트의 생존 범위를 트랜잭션 범위로 한정합니다.
# View 렌더링 시점까지 DB 커넥션을 점유하는 것을 방지하여, 트래픽 급증 시 커넥션 풀 고갈 리스크를 최소화합니다.
open-in-view: false
# [SMTP 메일 설정]
# 회원가입 인증 코드(OTP) 발송을 위한 Gmail SMTP 설정입니다.
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD} # Google App Password (2단계 인증 앱 비밀번호)
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
# 네트워크 지연 시 스레드 차단을 막기 위한 타임아웃 설정 (Fail-Fast)
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
# [Redis 설정]
# Refresh Token의 저장(TTL) 및 캐싱(Caching) 처리를 위한 인메모리 데이터 저장소입니다.
data: data:
redis: redis:
host: redis # Docker 서비스명 host: ${REDIS_HOST:192.168.0.36}
port: 6379 port: 6379
# 4. AWS S3 / MinIO 설정 (아까 이야기한 부분) # [AWS S3 / MinIO 설정]
cloud: # 정적 리소스(이미지) 저장을 위한 오브젝트 스토리지 설정입니다.
aws: # 로컬 개발 시에는 MinIO를, 운영 시에는 실제 AWS S3를 바라보도록 환경변수로 제어합니다.
s3: cloud:
bucket: my-blog-bucket # MinIO 콘솔에서 미리 생성해야 함 aws:
endpoint: http://minio:9000 # Docker 내부 통신용 s3:
credentials: bucket: blog-bucket
access-key: admin endpoint: https://s3.wypark.me # MinIO 엔드포인트 또는 AWS S3 리전 엔드포인트
secret-key: password path-style-access-enabled: true # MinIO 호환성을 위해 Path Style 접근 허용
region: credentials:
static: ap-northeast-2 access-key: ${S3_ACCESS_KEY}
stack: secret-key: ${S3_SECRET_KEY}
auto: false region:
static: ap-northeast-2
stack:
auto: false
# 5. JWT 설정 # [JWT(Json Web Token) 정책 설정]
jwt: jwt:
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요. # [보안 경고]
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!} # Secret Key는 서명 위조 방지를 위한 핵심 키이므로, 절대 소스코드에 평문으로 노출하지 않고
access-token-validity: 1800000 # 30분 # CI/CD 파이프라인 변수(${JWT_SECRET})를 통해 주입받습니다.
refresh-token-validity: 604800000 # 7일 secret: ${JWT_SECRET}
access-token-validity: 600000 # 10분 (짧은 만료 시간으로 탈취 시 피해 최소화)
refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용)

View File

@@ -1,5 +1,5 @@
spring: spring:
application: application:
name: blog-backend name: blog-api
profiles: profiles:
default: prod default: prod

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATH" value="/mnt/data/blog-server/logs/"/>
<property name="LOG_FILE_NAME" value="blog-server"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) --- [%thread] %cyan(%logger{35}) : %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${LOG_FILE_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%thread] %logger{35} : %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root>
</configuration>