주석 수정
This commit is contained in:
@@ -5,19 +5,35 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
|
|||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
import org.springframework.data.web.config.EnableSpringDataWebSupport
|
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)
|
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
|
||||||
class BlogBackendApplication
|
class BlogBackendApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
// 1. .env 파일 로드
|
/**
|
||||||
|
* [환경 변수 로드 전략: Twelve-Factor App]
|
||||||
|
*
|
||||||
|
* 로컬 개발 환경의 편의성을 위해 '.env' 파일을 지원하지만,
|
||||||
|
* 실제 운영(Production) 환경에서는 CI/CD 파이프라인을 통해 주입된 시스템 환경 변수를 우선합니다.
|
||||||
|
*
|
||||||
|
* 'ignoreIfMissing()' 옵션을 사용하여 배포 환경에서 .env 파일이 없더라도
|
||||||
|
* 애플리케이션이 중단되지 않고 시스템 환경 변수로 fallback 되도록 구성했습니다.
|
||||||
|
*/
|
||||||
val dotenv = Dotenv.configure().ignoreIfMissing().load()
|
val dotenv = Dotenv.configure().ignoreIfMissing().load()
|
||||||
|
|
||||||
// 2. 로드한 내용을 시스템 프로퍼티에 설정 (그래야 application.yml에서 ${}로 읽음)
|
// 로드된 환경 변수를 Spring Boot가 인식할 수 있도록 시스템 프로퍼티로 이관(Migration)
|
||||||
dotenv.entries().forEach { entry ->
|
dotenv.entries().forEach { entry ->
|
||||||
System.setProperty(entry.key, entry.value)
|
System.setProperty(entry.key, entry.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 스프링 실행
|
|
||||||
runApplication<BlogBackendApplication>(*args)
|
runApplication<BlogBackendApplication>(*args)
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,38 @@
|
|||||||
package me.wypark.blogbackend.api.common
|
package me.wypark.blogbackend.api.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [API 공통 응답 규격]
|
||||||
|
*
|
||||||
|
* 클라이언트(Frontend)와 서버 간의 통신 프로토콜을 통일하기 위한 Wrapper 클래스입니다.
|
||||||
|
* 모든 REST API 응답은 이 클래스로 감싸서 반환되며, 이를 통해 예외 발생 시에도
|
||||||
|
* 일관된 JSON 구조를 보장하여 클라이언트의 에러 핸들링 복잡도를 낮춥니다.
|
||||||
|
*
|
||||||
|
* @param T 실제 응답 데이터의 타입 (Generic)
|
||||||
|
*/
|
||||||
data class ApiResponse<T>(
|
data class ApiResponse<T>(
|
||||||
|
// 비즈니스 로직 처리 결과 코드 (HTTP Status와는 별개로 세부적인 에러 코드를 정의하여 사용 가능)
|
||||||
val code: String = "SUCCESS",
|
val code: String = "SUCCESS",
|
||||||
|
|
||||||
|
// 클라이언트에게 노출할 알림 메시지 (Toast UI 등에서 활용)
|
||||||
val message: String = "요청이 성공했습니다.",
|
val message: String = "요청이 성공했습니다.",
|
||||||
|
|
||||||
|
// 실제 전송할 데이터 Payload (실패 시 null)
|
||||||
val data: T? = null
|
val data: T? = null
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* 성공 응답을 생성하는 정적 팩토리 메서드입니다.
|
||||||
|
* 데이터가 없는 경우(예: 삭제/수정 완료)에도 일관된 형식을 유지하기 위해 기본 메시지를 제공합니다.
|
||||||
|
*/
|
||||||
fun <T> success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse<T> {
|
fun <T> success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse<T> {
|
||||||
return ApiResponse("SUCCESS", message, data)
|
return ApiResponse("SUCCESS", message, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 응답을 생성하는 정적 팩토리 메서드입니다.
|
||||||
|
* 에러 상황에서는 data 필드가 불필요하므로, <Nothing> 타입을 사용하여
|
||||||
|
* 타입 안정성(Type Safety)을 확보하고 불필요한 객체 생성을 방지합니다.
|
||||||
|
*/
|
||||||
fun error(message: String, code: String = "ERROR"): ApiResponse<Nothing> {
|
fun error(message: String, code: String = "ERROR"): ApiResponse<Nothing> {
|
||||||
return ApiResponse(code, message, null)
|
return ApiResponse(code, message, null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,41 +9,77 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|||||||
import org.springframework.security.core.userdetails.User
|
import org.springframework.security.core.userdetails.User
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [인증/인가 컨트롤러]
|
||||||
|
*
|
||||||
|
* JWT(Json Web Token) 기반의 Stateless 인증 처리를 담당하는 엔드포인트 집합입니다.
|
||||||
|
* 표준적인 Access/Refresh Token 패턴을 사용하며, 보안 강화를 위해
|
||||||
|
* Refresh Token Rotation(RTR) 전략을 적용하여 탈취된 토큰의 재사용을 방지합니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
class AuthController(
|
class AuthController(
|
||||||
private val authService: AuthService
|
private val authService: AuthService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 회원 가입을 요청합니다.
|
||||||
|
*
|
||||||
|
* 봇(Bot)이나 무분별한 가입을 방지하기 위해, 가입 요청 즉시 인증 메일을 발송합니다.
|
||||||
|
* 이메일 인증이 완료(`isVerified = true`)되기 전까지는 로그인이 제한됩니다.
|
||||||
|
*/
|
||||||
@PostMapping("/signup")
|
@PostMapping("/signup")
|
||||||
fun signup(@RequestBody @Valid request: SignupRequest): ResponseEntity<ApiResponse<Nothing>> {
|
fun signup(@RequestBody @Valid request: SignupRequest): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
authService.signup(request)
|
authService.signup(request)
|
||||||
return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요."))
|
return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 인증 코드를 검증하여 계정을 활성화합니다.
|
||||||
|
* Redis에 TTL(Time-To-Live)로 저장된 임시 코드와 사용자의 입력을 대조합니다.
|
||||||
|
*/
|
||||||
@PostMapping("/verify")
|
@PostMapping("/verify")
|
||||||
fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity<ApiResponse<Nothing>> {
|
fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
authService.verifyEmail(request.email, request.code)
|
authService.verifyEmail(request.email, request.code)
|
||||||
return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다."))
|
return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 자격 증명(Email/Password)을 검증하고 토큰 쌍을 발급합니다.
|
||||||
|
* 인증 성공 시 Access Token과 Refresh Token이 모두 반환됩니다.
|
||||||
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity<ApiResponse<TokenDto>> {
|
fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity<ApiResponse<TokenDto>> {
|
||||||
val tokenDto = authService.login(request)
|
val tokenDto = authService.login(request)
|
||||||
return ResponseEntity.ok(ApiResponse.success(tokenDto))
|
return ResponseEntity.ok(ApiResponse.success(tokenDto))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access Token 만료 시, Refresh Token을 사용하여 토큰을 갱신합니다 (Silent Refresh).
|
||||||
|
*
|
||||||
|
* [보안 전략: Refresh Token Rotation]
|
||||||
|
* 토큰 갱신 시 기존 Refresh Token은 폐기되고 새로운 Refresh Token이 발급됩니다.
|
||||||
|
* 만약 이미 폐기된 토큰으로 재요청이 들어올 경우, 탈취된 것으로 간주하여 해당 유저의 모든 토큰을 무효화합니다.
|
||||||
|
*/
|
||||||
@PostMapping("/reissue")
|
@PostMapping("/reissue")
|
||||||
fun reissue(@RequestBody request: ReissueRequest): ResponseEntity<ApiResponse<TokenDto>> {
|
fun reissue(@RequestBody request: ReissueRequest): ResponseEntity<ApiResponse<TokenDto>> {
|
||||||
val tokenDto = authService.reissue(request.accessToken, request.refreshToken)
|
val tokenDto = authService.reissue(request.accessToken, request.refreshToken)
|
||||||
return ResponseEntity.ok(ApiResponse.success(tokenDto))
|
return ResponseEntity.ok(ApiResponse.success(tokenDto))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃 처리를 수행합니다.
|
||||||
|
*
|
||||||
|
* JWT 특성상 클라이언트가 토큰을 삭제하는 것이 기본이지만,
|
||||||
|
* 서버 측에서도 Redis에 저장된 Refresh Token을 즉시 삭제(Evict)하여
|
||||||
|
* 더 이상 해당 토큰으로 액세스 토큰을 재발급받지 못하도록 차단합니다.
|
||||||
|
*/
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
fun logout(@AuthenticationPrincipal user: User): ResponseEntity<ApiResponse<Nothing>> {
|
fun logout(@AuthenticationPrincipal user: User): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
authService.logout(user.username) // user.username은 email입니다.
|
authService.logout(user.username) // user.username은 SecurityContext에 저장된 email입니다.
|
||||||
return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다."))
|
return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DTO가 다른 곳에서 재사용 않아 응집도를 위해 같은 파일 내에 정의
|
||||||
data class ReissueRequest(val accessToken: String, val refreshToken: String)
|
data class ReissueRequest(val accessToken: String, val refreshToken: String)
|
||||||
@@ -8,12 +8,24 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [카테고리 조회 API]
|
||||||
|
*
|
||||||
|
* 일반 사용자(Public)에게 노출되는 카테고리 관련 엔드포인트입니다.
|
||||||
|
* 블로그의 탐색(Navigation) 기능을 담당하며, 데이터 변경이 없는 읽기 전용(Read-Only) 작업만을 수행합니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/categories")
|
@RequestMapping("/api/categories")
|
||||||
class CategoryController(
|
class CategoryController(
|
||||||
private val categoryService: CategoryService
|
private val categoryService: CategoryService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 전체 계층 구조를 조회합니다.
|
||||||
|
*
|
||||||
|
* 프론트엔드 사이드바나 헤더 메뉴 렌더링을 위해 설계되었으며,
|
||||||
|
* 불필요한 네트워크 왕복(Round Trip)을 줄이기 위해 한 번의 요청으로 중첩된(Nested) 트리 형태의 전체 데이터를 반환합니다.
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getCategoryTree(): ResponseEntity<ApiResponse<List<CategoryResponse>>> {
|
fun getCategoryTree(): ResponseEntity<ApiResponse<List<CategoryResponse>>> {
|
||||||
return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree()))
|
return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree()))
|
||||||
|
|||||||
@@ -10,34 +10,58 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|||||||
import org.springframework.security.core.userdetails.User
|
import org.springframework.security.core.userdetails.User
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [일반 사용자용 댓글 API]
|
||||||
|
*
|
||||||
|
* 게시글에 대한 사용자 참여(Social Interaction)를 담당하는 컨트롤러입니다.
|
||||||
|
* 사용자 경험(UX)을 고려하여, 회원가입 없이도 자유롭게 소통할 수 있도록
|
||||||
|
* 회원(Member)과 비회원(Guest)의 접근을 동시에 허용하는 하이브리드 로직을 수행합니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/comments")
|
@RequestMapping("/api/comments")
|
||||||
class CommentController(
|
class CommentController(
|
||||||
private val commentService: CommentService
|
private val commentService: CommentService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// 댓글 목록 조회
|
/**
|
||||||
|
* 특정 게시글의 전체 댓글 목록을 조회합니다.
|
||||||
|
*
|
||||||
|
* 단순 리스트가 아닌, 대댓글(Nested Comments) 구조를 유지한 상태로 반환하여
|
||||||
|
* 클라이언트가 별도의 재귀 로직 구현 없이 트리 형태로 즉시 렌더링할 수 있도록 지원합니다.
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getComments(@RequestParam postSlug: String): ResponseEntity<ApiResponse<List<CommentResponse>>> {
|
fun getComments(@RequestParam postSlug: String): ResponseEntity<ApiResponse<List<CommentResponse>>> {
|
||||||
return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug)))
|
return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 작성 (회원 or 비회원)
|
/**
|
||||||
|
* 댓글을 작성합니다 (회원/비회원 공용).
|
||||||
|
*
|
||||||
|
* 참여 장벽을 낮추기 위해 로그인 여부를 강제하지 않습니다.
|
||||||
|
* Security Context의 User 객체가 null일 경우 비회원으로 간주하며,
|
||||||
|
* 이 경우 RequestBody에 포함된 닉네임과 비밀번호를 사용하여 임시 신원을 생성합니다.
|
||||||
|
*/
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun createComment(
|
fun createComment(
|
||||||
@RequestBody request: CommentSaveRequest,
|
@RequestBody request: CommentSaveRequest,
|
||||||
@AuthenticationPrincipal user: User? // 비회원이면 null이 들어옴
|
@AuthenticationPrincipal user: User? // 비회원 접근 시 null (Optional Principal)
|
||||||
): ResponseEntity<ApiResponse<Long>> {
|
): ResponseEntity<ApiResponse<Long>> {
|
||||||
val email = user?.username // null이면 비회원
|
val email = user?.username // 인증된 사용자라면 email 추출
|
||||||
val commentId = commentService.createComment(request, email)
|
val commentId = commentService.createComment(request, email)
|
||||||
return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다."))
|
return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 삭제
|
/**
|
||||||
|
* 댓글을 삭제합니다.
|
||||||
|
*
|
||||||
|
* 작성자 유형(회원/비회원)에 따라 검증 전략(Strategy)이 달라집니다.
|
||||||
|
* - 회원: 현재 로그인한 사용자의 ID와 댓글 작성자 ID의 일치 여부를 검증
|
||||||
|
* - 비회원: 댓글 작성 시 설정한 비밀번호(Guest Password)의 일치 여부를 검증
|
||||||
|
*/
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
fun deleteComment(
|
fun deleteComment(
|
||||||
@PathVariable id: Long,
|
@PathVariable id: Long,
|
||||||
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원용 비밀번호 바디
|
@RequestBody(required = false) request: CommentDeleteRequest?, // 비회원일 경우에만 바디가 필요함
|
||||||
@AuthenticationPrincipal user: User?
|
@AuthenticationPrincipal user: User?
|
||||||
): ResponseEntity<ApiResponse<Nothing>> {
|
): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
val email = user?.username
|
val email = user?.username
|
||||||
|
|||||||
@@ -11,31 +11,54 @@ import org.springframework.data.web.PageableDefault
|
|||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [일반 사용자용 게시글 조회 API]
|
||||||
|
*
|
||||||
|
* 블로그의 핵심 콘텐츠인 게시글(Post) 데이터를 제공하는 Public 컨트롤러입니다.
|
||||||
|
* 방문자의 조회 요청을 처리하며, 검색 엔진 최적화(SEO)를 고려하여
|
||||||
|
* 내부 식별자(ID)가 아닌 의미 있는 문자열(Slug) 기반의 URL 설계를 채택했습니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/posts")
|
@RequestMapping("/api/posts")
|
||||||
class PostController(
|
class PostController(
|
||||||
private val postService: PostService
|
private val postService: PostService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글 목록을 조회하거나 조건에 맞춰 검색합니다.
|
||||||
|
*
|
||||||
|
* [통합 검색 엔드포인트]
|
||||||
|
* 단순 목록 조회와 필터링(검색) 로직을 하나의 API로 통합하여 프론트엔드 구현을 단순화했습니다.
|
||||||
|
* 필터 조건(keyword, category, tag) 유무에 따라 동적 쿼리(QueryDSL) 또는 기본 페이징 쿼리로 분기 처리됩니다.
|
||||||
|
*
|
||||||
|
* @param keyword 제목 또는 본문 검색어 (Optional)
|
||||||
|
* @param category 카테고리 이름 (Optional, 프론트엔드 파라미터명: category)
|
||||||
|
* @param tag 태그 이름 (Optional)
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getPosts(
|
fun getPosts(
|
||||||
@RequestParam(required = false) keyword: String?,
|
@RequestParam(required = false) keyword: String?,
|
||||||
@RequestParam(required = false) category: String?, // 👈 프론트는 'category'로 보냄
|
@RequestParam(required = false) category: String?,
|
||||||
@RequestParam(required = false) tag: String?,
|
@RequestParam(required = false) tag: String?,
|
||||||
@PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable
|
@PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||||
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
|
): ResponseEntity<ApiResponse<Page<PostSummaryResponse>>> {
|
||||||
|
|
||||||
// 검색 조건이 하나라도 있으면 searchPosts 호출 (검색 + 카테고리 필터링)
|
// 필터 조건이 하나라도 존재하면 동적 쿼리(Search) 실행, 없으면 기본 목록 조회(List) 수행
|
||||||
return if (keyword != null || category != null || tag != null) {
|
return if (keyword != null || category != null || tag != null) {
|
||||||
val posts = postService.searchPosts(keyword, category, tag, pageable)
|
val posts = postService.searchPosts(keyword, category, tag, pageable)
|
||||||
ResponseEntity.ok(ApiResponse.success(posts))
|
ResponseEntity.ok(ApiResponse.success(posts))
|
||||||
} else {
|
} else {
|
||||||
// 조건이 없으면 전체 목록 조회
|
|
||||||
val posts = postService.getPosts(pageable)
|
val posts = postService.getPosts(pageable)
|
||||||
ResponseEntity.ok(ApiResponse.success(posts))
|
ResponseEntity.ok(ApiResponse.success(posts))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글 상세 정보를 조회합니다.
|
||||||
|
*
|
||||||
|
* URL에 ID(숫자) 대신 제목 기반의 Slug를 사용하여 가독성과 SEO 점수를 높입니다.
|
||||||
|
* 상세 조회 성공 시, 서비스 레이어에서 조회수(View Count) 증가 트랜잭션이 함께 수행됩니다.
|
||||||
|
*/
|
||||||
@GetMapping("/{slug}")
|
@GetMapping("/{slug}")
|
||||||
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
|
fun getPost(@PathVariable slug: String): ResponseEntity<ApiResponse<PostResponse>> {
|
||||||
val post = postService.getPostBySlug(slug)
|
val post = postService.getPostBySlug(slug)
|
||||||
|
|||||||
@@ -8,12 +8,26 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [일반 사용자용 프로필 조회 API]
|
||||||
|
*
|
||||||
|
* 블로그 방문자들에게 운영자(Owner)의 정보를 제공하는 Public 컨트롤러입니다.
|
||||||
|
* 수정 권한이 필요한 관리자 영역(AdminProfileController)과 분리하여,
|
||||||
|
* 불필요한 인증 로직 없이 누구나 빠르게 조회할 수 있도록 설계된 읽기 전용(Read-Only) 엔드포인트입니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/profile")
|
@RequestMapping("/api/profile")
|
||||||
class ProfileController(
|
class ProfileController(
|
||||||
private val blogProfileService: BlogProfileService
|
private val blogProfileService: BlogProfileService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블로그 운영자의 프로필 정보를 조회합니다.
|
||||||
|
*
|
||||||
|
* 단일 사용자 블로그(Single User Blog) 특성상 별도의 사용자 ID 파라미터 없이
|
||||||
|
* 시스템에 설정된 유일한 프로필 데이터를 반환합니다.
|
||||||
|
* (주로 메인 화면의 사이드바나 About 페이지 렌더링에 사용됩니다.)
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getProfile(): ResponseEntity<ApiResponse<ProfileResponse>> {
|
fun getProfile(): ResponseEntity<ApiResponse<ProfileResponse>> {
|
||||||
return ResponseEntity.ok(ApiResponse.success(blogProfileService.getProfile()))
|
return ResponseEntity.ok(ApiResponse.success(blogProfileService.getProfile()))
|
||||||
|
|||||||
@@ -7,19 +7,37 @@ import me.wypark.blogbackend.domain.category.CategoryService
|
|||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [관리자용 카테고리 관리 API]
|
||||||
|
*
|
||||||
|
* 블로그의 카테고리 계층 구조(Tree Structure)를 조작하는 컨트롤러입니다.
|
||||||
|
* 단순한 CRUD 외에도 부모-자식 관계 설정 및 구조 변경(이동) 로직을 포함하고 있습니다.
|
||||||
|
*
|
||||||
|
* Note:
|
||||||
|
* - 데이터 무결성을 위해 모든 변경 작업은 트랜잭션 범위 안에서 유효성 검증(순환 참조 방지 등) 후 수행됩니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/categories")
|
@RequestMapping("/api/admin/categories")
|
||||||
class AdminCategoryController(
|
class AdminCategoryController(
|
||||||
private val categoryService: CategoryService
|
private val categoryService: CategoryService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 카테고리를 생성합니다.
|
||||||
|
* parentId가 없을 경우 최상위(Root) 카테고리로 생성되며, 있을 경우 해당 노드의 자식으로 연결됩니다.
|
||||||
|
*/
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity<ApiResponse<Long>> {
|
fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity<ApiResponse<Long>> {
|
||||||
val id = categoryService.createCategory(request)
|
val id = categoryService.createCategory(request)
|
||||||
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
|
return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 👈 [추가] 카테고리 수정 (이름, 위치 이동)
|
/**
|
||||||
|
* 카테고리 정보(이름 및 계층 위치)를 수정합니다.
|
||||||
|
*
|
||||||
|
* 단순 이름 변경뿐만 아니라, 부모 카테고리를 변경하여 트리 구조 내에서 위치를 이동시키는 기능도 수행합니다.
|
||||||
|
* 위치 이동 시 순환 참조(Cycle)가 발생하지 않도록 서비스 레이어에서 검증 로직이 수행됩니다.
|
||||||
|
*/
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
fun updateCategory(
|
fun updateCategory(
|
||||||
@PathVariable id: Long,
|
@PathVariable id: Long,
|
||||||
@@ -29,6 +47,13 @@ class AdminCategoryController(
|
|||||||
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 수정되었습니다."))
|
return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 수정되었습니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리를 삭제합니다.
|
||||||
|
*
|
||||||
|
* [삭제 정책]
|
||||||
|
* - 하위 카테고리(Children)는 재귀적으로 함께 삭제됩니다 (Cascade).
|
||||||
|
* - 해당 카테고리에 속해있던 게시글(Post)들은 삭제되지 않고 '미분류(Category = NULL)' 상태로 변경되어 보존됩니다.
|
||||||
|
*/
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
fun deleteCategory(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
categoryService.deleteCategory(id)
|
categoryService.deleteCategory(id)
|
||||||
|
|||||||
@@ -11,19 +11,36 @@ import org.springframework.data.web.PageableDefault
|
|||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [관리자용 댓글 관리 API]
|
||||||
|
*
|
||||||
|
* 블로그 내 모든 댓글 활동을 모니터링하고 중재(Moderation)하는 컨트롤러입니다.
|
||||||
|
* 개별 게시글 단위로 조회하는 일반 API와 달리, 시스템 전체의 댓글 흐름을 파악하는 데 초점이 맞춰져 있습니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/comments")
|
@RequestMapping("/api/admin/comments")
|
||||||
class AdminCommentController(
|
class AdminCommentController(
|
||||||
private val commentService: CommentService
|
private val commentService: CommentService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// 관리자 권한으로 댓글 삭제
|
/**
|
||||||
|
* 부적절한 댓글을 강제로 삭제합니다 (Moderation).
|
||||||
|
*
|
||||||
|
* 일반 사용자의 삭제 요청과 달리 작성자 본인 확인 절차(Ownership Check)를 건너뛰고,
|
||||||
|
* 관리자 권한으로 즉시 데이터를 제거합니다.
|
||||||
|
*/
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
fun deleteComment(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
fun deleteComment(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
commentService.deleteCommentByAdmin(id)
|
commentService.deleteCommentByAdmin(id)
|
||||||
return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다."))
|
return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 대시보드용 전체 댓글 목록을 조회합니다.
|
||||||
|
*
|
||||||
|
* 특정 게시글에 종속되지 않고, 최근 작성된 순서대로 모든 댓글을 페이징하여 반환합니다.
|
||||||
|
* 이를 통해 관리자는 스팸이나 악성 댓글 발생 여부를 한눈에 파악할 수 있습니다.
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getAllComments(
|
fun getAllComments(
|
||||||
@PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
|
@PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
|
||||||
|
|||||||
@@ -10,12 +10,30 @@ import org.springframework.web.bind.annotation.RequestPart
|
|||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [관리자용 이미지 업로드 API]
|
||||||
|
*
|
||||||
|
* 게시글 본문(Markdown) 삽입용 이미지나 프로필 사진 등, 블로그 운영에 필요한
|
||||||
|
* 정적 리소스(Static Resources)를 처리하는 컨트롤러입니다.
|
||||||
|
*
|
||||||
|
* 스토리지 저장소(S3/MinIO)와의 직접적인 통신은 Service Layer에 위임하며,
|
||||||
|
* 클라이언트에게는 업로드된 리소스의 접근 가능한 URL을 반환하여 즉시 렌더링 가능하도록 합니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/images")
|
@RequestMapping("/api/admin/images")
|
||||||
class AdminImageController(
|
class AdminImageController(
|
||||||
private val imageService: ImageService
|
private val imageService: ImageService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지를 업로드하고 접근 가능한 URL을 반환합니다.
|
||||||
|
*
|
||||||
|
* 주로 에디터(Toast UI 등)에서 이미지 첨부 이벤트가 발생했을 때 비동기로 호출되며,
|
||||||
|
* 업로드 성공 시 반환된 URL은 클라이언트 측에서 즉시 Markdown 문법()으로 변환되어 본문에 삽입됩니다.
|
||||||
|
*
|
||||||
|
* @param image 클라이언트가 전송한 바이너리 파일 (MultipartFile)
|
||||||
|
* @return CDN 또는 스토리지의 접근 가능한 절대 경로 (URL)
|
||||||
|
*/
|
||||||
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
fun uploadImage(
|
fun uploadImage(
|
||||||
@RequestPart("image") image: MultipartFile
|
@RequestPart("image") image: MultipartFile
|
||||||
|
|||||||
@@ -9,12 +9,25 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|||||||
import org.springframework.security.core.userdetails.User
|
import org.springframework.security.core.userdetails.User
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [관리자용 게시글 관리 API]
|
||||||
|
*
|
||||||
|
* 블로그 콘텐츠(Post)의 전체 수명 주기(Lifecycle)를 관리하는 컨트롤러입니다.
|
||||||
|
* 게시글의 작성, 수정, 삭제 기능을 제공하며, 이 과정에서 입력값 검증(@Valid)과
|
||||||
|
* 데이터 무결성 유지를 위한 다양한 비즈니스 로직(Slug 생성, 태그 처리 등)을 조율합니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/posts")
|
@RequestMapping("/api/admin/posts")
|
||||||
class AdminPostController(
|
class AdminPostController(
|
||||||
private val postService: PostService
|
private val postService: PostService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 게시글을 작성 및 발행합니다.
|
||||||
|
*
|
||||||
|
* Security Context에서 현재 로그인한 관리자 정보를 추출하여 작성자(Author)로 매핑함으로써,
|
||||||
|
* 클라이언트가 임의로 작성자를 변조하는 것을 방지합니다.
|
||||||
|
*/
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun createPost(
|
fun createPost(
|
||||||
@RequestBody @Valid request: PostSaveRequest,
|
@RequestBody @Valid request: PostSaveRequest,
|
||||||
@@ -24,7 +37,12 @@ class AdminPostController(
|
|||||||
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다."))
|
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 👈 [추가] 게시글 수정 엔드포인트
|
/**
|
||||||
|
* 기존 게시글을 수정합니다.
|
||||||
|
*
|
||||||
|
* 제목이나 본문 변경뿐만 아니라, 카테고리 이동이나 태그 재설정과 같은 메타데이터 변경도 함께 처리합니다.
|
||||||
|
* 수정 시 사용되지 않게 된 이미지를 정리하거나, URL(Slug) 변경에 따른 리다이렉트 고려 등이 서비스 레이어에서 처리됩니다.
|
||||||
|
*/
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
fun updatePost(
|
fun updatePost(
|
||||||
@PathVariable id: Long,
|
@PathVariable id: Long,
|
||||||
@@ -34,6 +52,14 @@ class AdminPostController(
|
|||||||
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 수정되었습니다."))
|
return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 수정되었습니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글을 영구 삭제합니다.
|
||||||
|
*
|
||||||
|
* [리소스 정리 전략]
|
||||||
|
* 단순히 DB 레코드(Row)만 삭제하는 것이 아니라, 해당 게시글 본문에 포함되었던
|
||||||
|
* S3 업로드 이미지 파일들을 추적하여 함께 삭제(Cleanup)합니다.
|
||||||
|
* 이를 통해 스토리지에 불필요한 고아 파일(Orphaned Files)이 누적되는 것을 방지하여 비용을 최적화합니다.
|
||||||
|
*/
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
fun deletePost(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
fun deletePost(@PathVariable id: Long): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
postService.deletePost(id)
|
postService.deletePost(id)
|
||||||
|
|||||||
@@ -9,12 +9,26 @@ import org.springframework.web.bind.annotation.RequestBody
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [관리자용 프로필 설정 API]
|
||||||
|
*
|
||||||
|
* 블로그 운영자의 소개(Bio), 프로필 사진, 소셜 링크 등을 관리하는 컨트롤러입니다.
|
||||||
|
* 개인 블로그 특성상 단일 사용자(Owner)에 대한 정보만 존재하므로,
|
||||||
|
* 별도의 ID 파라미터 없이 싱글톤(Singleton) 리소스처럼 관리됩니다.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/profile")
|
@RequestMapping("/api/admin/profile")
|
||||||
class AdminProfileController(
|
class AdminProfileController(
|
||||||
private val blogProfileService: BlogProfileService
|
private val blogProfileService: BlogProfileService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블로그 프로필 정보를 수정합니다.
|
||||||
|
*
|
||||||
|
* 단순 텍스트 정보(이름, 소개) 수정뿐만 아니라,
|
||||||
|
* 변경된 프로필 이미지 URL을 반영하고 기존 이미지를 정리하는 로직이 서비스 레이어에 포함되어 있습니다.
|
||||||
|
* 초기 데이터가 없을 경우(First Run), 수정 요청 시 기본 프로필이 생성(Upsert)됩니다.
|
||||||
|
*/
|
||||||
@PutMapping
|
@PutMapping
|
||||||
fun updateProfile(@RequestBody request: ProfileUpdateRequest): ResponseEntity<ApiResponse<Nothing>> {
|
fun updateProfile(@RequestBody request: ProfileUpdateRequest): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
blogProfileService.updateProfile(request)
|
blogProfileService.updateProfile(request)
|
||||||
|
|||||||
@@ -4,12 +4,26 @@ import jakarta.validation.constraints.Email
|
|||||||
import jakarta.validation.constraints.NotBlank
|
import jakarta.validation.constraints.NotBlank
|
||||||
import jakarta.validation.constraints.Size
|
import jakarta.validation.constraints.Size
|
||||||
|
|
||||||
// 회원가입 요청
|
/**
|
||||||
|
* [회원가입 요청 DTO]
|
||||||
|
*
|
||||||
|
* 사용자 등록을 위한 데이터 전송 객체입니다.
|
||||||
|
* Controller 진입 시점(@Valid)에서 입력값의 형식 검증을 수행하여,
|
||||||
|
* 비즈니스 로직(Service) 단계에서의 불필요한 연산을 방지합니다 (Fail-Fast 전략).
|
||||||
|
*/
|
||||||
data class SignupRequest(
|
data class SignupRequest(
|
||||||
@field:NotBlank(message = "이메일은 필수입니다.")
|
@field:NotBlank(message = "이메일은 필수입니다.")
|
||||||
@field:Email(message = "올바른 이메일 형식이 아닙니다.")
|
@field:Email(message = "올바른 이메일 형식이 아닙니다.")
|
||||||
val email: String,
|
val email: String,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 복잡도 정책: 최소 8자 ~ 최대 20자
|
||||||
|
*
|
||||||
|
* Note:
|
||||||
|
* 이 필드는 클라이언트로부터 평문(Plain Text)으로 전달되므로,
|
||||||
|
* 전송 구간 암호화(HTTPS/TLS)가 보장된 환경에서만 사용되어야 합니다.
|
||||||
|
* DB 저장 시에는 반드시 단방향 해시 함수(BCrypt 등)를 통해 암호화됩니다.
|
||||||
|
*/
|
||||||
@field:NotBlank(message = "비밀번호는 필수입니다.")
|
@field:NotBlank(message = "비밀번호는 필수입니다.")
|
||||||
@field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.")
|
@field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.")
|
||||||
val password: String,
|
val password: String,
|
||||||
@@ -19,7 +33,11 @@ data class SignupRequest(
|
|||||||
val nickname: String
|
val nickname: String
|
||||||
)
|
)
|
||||||
|
|
||||||
// 로그인 요청
|
/**
|
||||||
|
* [로그인 요청 DTO]
|
||||||
|
*
|
||||||
|
* JWT 토큰 발급을 위한 사용자 자격 증명(Credentials)을 전달받는 객체입니다.
|
||||||
|
*/
|
||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
@field:NotBlank(message = "이메일을 입력해주세요.")
|
@field:NotBlank(message = "이메일을 입력해주세요.")
|
||||||
val email: String,
|
val email: String,
|
||||||
@@ -28,6 +46,12 @@ data class LoginRequest(
|
|||||||
val password: String
|
val password: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [이메일 인증 확인 DTO]
|
||||||
|
*
|
||||||
|
* 회원가입 직후 발송된 OTP(One Time Password) 코드를 검증하기 위한 요청 객체입니다.
|
||||||
|
* 이메일 소유권 확인(Proof of Ownership)을 위해 사용됩니다.
|
||||||
|
*/
|
||||||
data class VerifyEmailRequest(
|
data class VerifyEmailRequest(
|
||||||
@field:NotBlank(message = "이메일을 입력해주세요")
|
@field:NotBlank(message = "이메일을 입력해주세요")
|
||||||
val email: String,
|
val email: String,
|
||||||
|
|||||||
@@ -2,44 +2,73 @@ package me.wypark.blogbackend.api.dto
|
|||||||
|
|
||||||
import me.wypark.blogbackend.domain.category.Category
|
import me.wypark.blogbackend.domain.category.Category
|
||||||
|
|
||||||
// [요청] 카테고리 생성
|
/**
|
||||||
|
* [카테고리 생성 요청 DTO]
|
||||||
|
*
|
||||||
|
* 새로운 카테고리 노드(Node)를 생성하기 위한 요청 객체입니다.
|
||||||
|
* 계층형 게시판 구조를 지원하기 위해 부모 카테고리 ID(parentId)를 선택적으로 받습니다.
|
||||||
|
*
|
||||||
|
* @property parentId null일 경우 최상위(Root) 레벨에 생성되며, 값이 있을 경우 해당 카테고리의 하위(Child)로 연결됩니다.
|
||||||
|
*/
|
||||||
data class CategoryCreateRequest(
|
data class CategoryCreateRequest(
|
||||||
val name: String,
|
val name: String,
|
||||||
val parentId: Long? = null // null이면 최상위(Root) 카테고리
|
val parentId: Long? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// [요청] 카테고리 수정 (이름 + 부모 이동)
|
/**
|
||||||
|
* [카테고리 수정 요청 DTO]
|
||||||
|
*
|
||||||
|
* 카테고리의 속성 변경(Rename)과 구조 변경(Move)을 동시에 처리하는 객체입니다.
|
||||||
|
*
|
||||||
|
* Note:
|
||||||
|
* 트리 구조 내에서의 노드 이동(Move)은 데이터베이스 부하가 발생할 수 있고
|
||||||
|
* 순환 참조(Cycle) 위험이 있으므로, 서비스 레이어에서 별도의 정합성 검증 로직을 거칩니다.
|
||||||
|
*/
|
||||||
data class CategoryUpdateRequest(
|
data class CategoryUpdateRequest(
|
||||||
val name: String,
|
val name: String,
|
||||||
val parentId: Long? // null이면 최상위(Root)로 이동
|
val parentId: Long? // 변경할 부모 ID (null이면 최상위로 이동)
|
||||||
)
|
)
|
||||||
|
|
||||||
// [응답] 카테고리 트리 구조 (재귀)
|
/**
|
||||||
|
* [카테고리 응답 DTO - Tree Structure]
|
||||||
|
*
|
||||||
|
* 프론트엔드 네비게이션 바(Sidebar) 등에서 계층형 메뉴를 렌더링하기 위한 재귀적 구조의 객체입니다.
|
||||||
|
*
|
||||||
|
* [성능 고려사항]
|
||||||
|
* N+1 문제를 방지하기 위해, 엔티티 조회 시점에는 Fetch Join을 사용하거나
|
||||||
|
* Batch Size를 설정하여 쿼리를 최적화한 후, 메모리 상에서 이 DTO 구조로 변환하여 반환합니다.
|
||||||
|
*/
|
||||||
data class CategoryResponse(
|
data class CategoryResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val children: List<CategoryResponse> // 자식들
|
val children: List<CategoryResponse> // 자식 노드 리스트 (Recursive)
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
// Entity -> DTO 변환 (재귀 호출)
|
// 엔티티 그래프를 순회하며 DTO 트리로 변환
|
||||||
fun from(category: Category): CategoryResponse {
|
fun from(category: Category): CategoryResponse {
|
||||||
return CategoryResponse(
|
return CategoryResponse(
|
||||||
id = category.id!!,
|
id = category.id!!,
|
||||||
name = category.name,
|
name = category.name,
|
||||||
// 자식들을 DTO로 변환하여 리스트에 담음
|
// 자식 카테고리들을 재귀적으로 DTO 변환하여 리스트에 매핑
|
||||||
children = category.children.map { from(it) }
|
children = category.children.map { from(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Admin용 응답] 관리자 대시보드 목록용
|
/**
|
||||||
|
* [관리자용 댓글 모니터링 DTO - Flat List]
|
||||||
|
*
|
||||||
|
* 관리자 대시보드에서 최근 댓글 흐름을 파악하기 위한 객체입니다.
|
||||||
|
* 계층형 구조(Nested)가 필요한 일반 사용자 뷰와 달리, 관리 목적상 시간순 나열이 중요하므로
|
||||||
|
* 모든 댓글을 평탄화(Flatten)하여 게시글 정보와 함께 제공합니다.
|
||||||
|
*/
|
||||||
data class AdminCommentResponse(
|
data class AdminCommentResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val content: String,
|
val content: String,
|
||||||
val author: String,
|
val author: String,
|
||||||
val postTitle: String, // 어떤 글인지 식별
|
val postTitle: String, // 문맥 파악을 위한 원본 게시글 제목
|
||||||
val postSlug: String, // 클릭 시 해당 글로 이동용
|
val postSlug: String, // 클릭 시 해당 게시글로 바로 이동(Deep Link)하기 위한 식별자
|
||||||
val createdAt: java.time.LocalDateTime
|
val createdAt: java.time.LocalDateTime
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -3,20 +3,32 @@ package me.wypark.blogbackend.api.dto
|
|||||||
import me.wypark.blogbackend.domain.comment.Comment
|
import me.wypark.blogbackend.domain.comment.Comment
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
// [응답] 댓글 (계층형 구조)
|
/**
|
||||||
|
* [댓글 응답 DTO - Hierarchical]
|
||||||
|
*
|
||||||
|
* 게시글 상세 화면에서 댓글 목록을 렌더링하기 위한 데이터 객체입니다.
|
||||||
|
* 대댓글(Nested Comment)을 포함하는 재귀적 구조를 가지며, 프론트엔드에서의 추가 가공 없이
|
||||||
|
* 즉시 트리 형태로 렌더링할 수 있도록 설계되었습니다.
|
||||||
|
*/
|
||||||
data class CommentResponse(
|
data class CommentResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val content: String,
|
val content: String,
|
||||||
val author: String,
|
val author: String,
|
||||||
|
|
||||||
|
// UI에서 게시글 작성자의 댓글을 강조(Highlight)하기 위한 플래그
|
||||||
val isPostAuthor: Boolean,
|
val isPostAuthor: Boolean,
|
||||||
|
|
||||||
|
// 회원일 경우 프로필 링크 연결 등을 위해 ID 제공 (비회원은 null)
|
||||||
val memberId: Long?,
|
val memberId: Long?,
|
||||||
|
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
|
|
||||||
|
// 자식 댓글 리스트 (Recursive)
|
||||||
val children: List<CommentResponse>
|
val children: List<CommentResponse>
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(comment: Comment): CommentResponse {
|
fun from(comment: Comment): CommentResponse {
|
||||||
// 게시글 작성자 ID와 댓글 작성자(회원) ID가 같은지 비교
|
// 게시글 작성자 본인이 쓴 댓글인지 확인 (비회원은 member가 null이므로 항상 false)
|
||||||
// comment.member는 비회원일 경우 null이므로 안전하게 처리됨
|
|
||||||
val isAuthor = comment.member?.id == comment.post.member.id
|
val isAuthor = comment.member?.id == comment.post.member.id
|
||||||
|
|
||||||
return CommentResponse(
|
return CommentResponse(
|
||||||
@@ -26,23 +38,37 @@ data class CommentResponse(
|
|||||||
isPostAuthor = isAuthor,
|
isPostAuthor = isAuthor,
|
||||||
memberId = comment.member?.id,
|
memberId = comment.member?.id,
|
||||||
createdAt = comment.createdAt,
|
createdAt = comment.createdAt,
|
||||||
children = comment.children.map { from(it) }
|
children = comment.children.map { from(it) } // 재귀 호출로 트리 구성
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [요청] 댓글 작성
|
|
||||||
|
/**
|
||||||
|
* [댓글 작성 요청 DTO]
|
||||||
|
*
|
||||||
|
* 회원과 비회원(Guest) 모두가 사용하는 통합 요청 객체입니다.
|
||||||
|
*
|
||||||
|
* [검증 로직]
|
||||||
|
* - 회원: Security Context에서 유저 정보를 가져오므로 guest 필드는 무시됩니다.
|
||||||
|
* - 비회원: guestNickname과 guestPassword가 필수값으로 요구됩니다.
|
||||||
|
*/
|
||||||
data class CommentSaveRequest(
|
data class CommentSaveRequest(
|
||||||
val postSlug: String,
|
val postSlug: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val parentId: Long? = null, // 대댓글일 경우 부모 ID
|
val parentId: Long? = null, // 대댓글(Reply)일 경우 상위 댓글 ID
|
||||||
|
|
||||||
// 비회원 전용 필드 (회원은 null 가능)
|
// --- 비회원 전용 필드 (Anonymous User) ---
|
||||||
val guestNickname: String? = null,
|
val guestNickname: String? = null,
|
||||||
val guestPassword: String? = null
|
val guestPassword: String? = null // 수정/삭제 권한 인증용 비밀번호 (DB 저장 시 암호화됨)
|
||||||
)
|
)
|
||||||
|
|
||||||
// [요청] 댓글 삭제 (비회원용 비밀번호 전달)
|
/**
|
||||||
|
* [댓글 삭제 요청 DTO]
|
||||||
|
*
|
||||||
|
* 비회원이 본인의 댓글을 삭제할 때 비밀번호 검증을 위해 사용됩니다.
|
||||||
|
* 회원의 경우 JWT 토큰으로 본인 확인이 가능하므로 이 DTO의 필드는 사용되지 않습니다.
|
||||||
|
*/
|
||||||
data class CommentDeleteRequest(
|
data class CommentDeleteRequest(
|
||||||
val guestPassword: String? = null
|
val guestPassword: String? = null
|
||||||
)
|
)
|
||||||
@@ -3,7 +3,13 @@ package me.wypark.blogbackend.api.dto
|
|||||||
import me.wypark.blogbackend.domain.post.Post
|
import me.wypark.blogbackend.domain.post.Post
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
// [응답] 인접 게시글 정보 (이전글/다음글)
|
/**
|
||||||
|
* [인접 게시글 응답 DTO]
|
||||||
|
*
|
||||||
|
* 게시글 상세 화면 하단에 위치할 '이전 글 / 다음 글' 네비게이션 링크를 위한 객체입니다.
|
||||||
|
* 전체 데이터를 로딩하는 대신, 링크 생성에 필요한 최소한의 식별자(Slug)와 제목(Title)만 포함하여
|
||||||
|
* 페이로드 크기를 최적화했습니다.
|
||||||
|
*/
|
||||||
data class PostNeighborResponse(
|
data class PostNeighborResponse(
|
||||||
val slug: String,
|
val slug: String,
|
||||||
val title: String
|
val title: String
|
||||||
@@ -18,7 +24,15 @@ data class PostNeighborResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [응답] 게시글 상세 정보
|
/**
|
||||||
|
* [게시글 상세 응답 DTO]
|
||||||
|
*
|
||||||
|
* 단일 게시글의 모든 정보(Full Content)를 클라이언트에게 전달하는 객체입니다.
|
||||||
|
*
|
||||||
|
* [설계 의도]
|
||||||
|
* - SEO: ID 대신 Slug를 사용하여 검색 엔진 친화적인 URL 구조 지원
|
||||||
|
* - UX: 별도의 추가 요청 없이 이전/다음 글 정보를 함께 반환하여 페이지 이동성(Navigability) 향상
|
||||||
|
*/
|
||||||
data class PostResponse(
|
data class PostResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -27,11 +41,11 @@ data class PostResponse(
|
|||||||
val categoryName: String?,
|
val categoryName: String?,
|
||||||
val viewCount: Long,
|
val viewCount: Long,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
// 👈 [추가] 이전/다음 게시글 정보
|
|
||||||
|
// 현재 글을 기준으로 앞/뒤 글 정보 (없으면 null)
|
||||||
val prevPost: PostNeighborResponse?,
|
val prevPost: PostNeighborResponse?,
|
||||||
val nextPost: PostNeighborResponse?
|
val nextPost: PostNeighborResponse?
|
||||||
) {
|
) {
|
||||||
// Entity -> DTO 변환 편의 메서드
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(post: Post, prevPost: Post? = null, nextPost: Post? = null): PostResponse {
|
fun from(post: Post, prevPost: Post? = null, nextPost: Post? = null): PostResponse {
|
||||||
return PostResponse(
|
return PostResponse(
|
||||||
@@ -49,7 +63,15 @@ data class PostResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [응답] 게시글 목록용 (본문 제외, 가볍게)
|
/**
|
||||||
|
* [게시글 목록 응답 DTO]
|
||||||
|
*
|
||||||
|
* 메인 화면이나 카테고리 목록에서 사용되는 경량화(Lightweight) 객체입니다.
|
||||||
|
*
|
||||||
|
* [최적화 전략]
|
||||||
|
* 다수의 아이템을 렌더링해야 하므로, 데이터 전송량(Network Overhead)을 줄이기 위해
|
||||||
|
* 무거운 본문(content)은 제외하거나 미리보기용으로 일부만 포함하도록 설계되었습니다.
|
||||||
|
*/
|
||||||
data class PostSummaryResponse(
|
data class PostSummaryResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -58,7 +80,7 @@ data class PostSummaryResponse(
|
|||||||
val viewCount: Long,
|
val viewCount: Long,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
val updatedAt: LocalDateTime,
|
val updatedAt: LocalDateTime,
|
||||||
val content: String?
|
val content: String? // 목록에서는 본문 미리보기 용도로 사용 (혹은 null)
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(post: Post): PostSummaryResponse {
|
fun from(post: Post): PostSummaryResponse {
|
||||||
@@ -76,11 +98,18 @@ data class PostSummaryResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [요청] 게시글 작성/수정
|
/**
|
||||||
|
* [게시글 작성/수정 요청 DTO]
|
||||||
|
*
|
||||||
|
* 게시글의 생명주기(생성/수정)를 담당하는 통합 커맨드 객체입니다.
|
||||||
|
*
|
||||||
|
* - Slug: 클라이언트가 직접 지정하지 않으면(null), 서버에서 제목을 기반으로 자동 생성(Generate)합니다.
|
||||||
|
* - Content: Markdown 포맷의 원문 텍스트를 저장합니다.
|
||||||
|
*/
|
||||||
data class PostSaveRequest(
|
data class PostSaveRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val content: String, // 마크다운 원문
|
val content: String,
|
||||||
val slug: String? = null,
|
val slug: String? = null,
|
||||||
val categoryId: Long? = null,
|
val categoryId: Long? = null,
|
||||||
val tags: List<String> = emptyList() // 태그는 나중에 구현
|
val tags: List<String> = emptyList() // 태그는 서비스 레이어에서 별도 로직으로 매핑(Many-to-Many) 처리
|
||||||
)
|
)
|
||||||
@@ -2,6 +2,15 @@ package me.wypark.blogbackend.api.dto
|
|||||||
|
|
||||||
import me.wypark.blogbackend.domain.profile.BlogProfile
|
import me.wypark.blogbackend.domain.profile.BlogProfile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [프로필 응답 DTO]
|
||||||
|
*
|
||||||
|
* 블로그 운영자(Owner)의 공개 정보를 렌더링하기 위한 View Object입니다.
|
||||||
|
*
|
||||||
|
* [설계 의도]
|
||||||
|
* 데이터베이스 엔티티(Entity)를 직접 반환하지 않고 DTO로 변환하여,
|
||||||
|
* 내부 구현의 변경이 클라이언트(View)에 영향을 미치지 않도록 결합도(Coupling)를 낮췄습니다.
|
||||||
|
*/
|
||||||
data class ProfileResponse(
|
data class ProfileResponse(
|
||||||
val name: String,
|
val name: String,
|
||||||
val bio: String,
|
val bio: String,
|
||||||
@@ -10,6 +19,7 @@ data class ProfileResponse(
|
|||||||
val email: String?
|
val email: String?
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
// Entity -> DTO 변환 (Static Factory Method)
|
||||||
fun from(profile: BlogProfile): ProfileResponse {
|
fun from(profile: BlogProfile): ProfileResponse {
|
||||||
return ProfileResponse(
|
return ProfileResponse(
|
||||||
name = profile.name,
|
name = profile.name,
|
||||||
@@ -22,6 +32,15 @@ data class ProfileResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [프로필 수정 요청 DTO]
|
||||||
|
*
|
||||||
|
* 관리자 대시보드에서 블로그 설정(운영자 정보)을 변경하기 위한 요청 객체입니다.
|
||||||
|
*
|
||||||
|
* [유효성 정책]
|
||||||
|
* - 이름(name)과 소개(bio)는 블로그의 정체성을 나타내는 필수 항목입니다.
|
||||||
|
* - 프로필 이미지나 소셜 링크 등은 선택적(Optional)으로 입력할 수 있도록 Nullable로 설계되었습니다.
|
||||||
|
*/
|
||||||
data class ProfileUpdateRequest(
|
data class ProfileUpdateRequest(
|
||||||
val name: String,
|
val name: String,
|
||||||
val bio: String,
|
val bio: String,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -6,24 +6,44 @@ 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()
|
||||||
|
|
||||||
|
// 1. 인증 정보(Cookie, Authorization Header) 포함 허용
|
||||||
|
// 이 옵션을 true로 설정하면, 보안상 addAllowedOrigin에 와일드카드(*)를 사용할 수 없습니다.
|
||||||
config.allowCredentials = true
|
config.allowCredentials = true
|
||||||
config.addAllowedOrigin("https://blog.wypark.me") // 프론트 도메인
|
|
||||||
config.addAllowedHeader("*") // 클라이언트가 보내는 모든 헤더 허용 (Authorization 포함)
|
// 2. 신뢰할 수 있는 출처(Origin) 명시
|
||||||
|
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
|
||||||
|
config.addAllowedOrigin("https://blog.wypark.me")
|
||||||
|
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
|
||||||
|
|
||||||
|
// 3. 허용할 HTTP 메서드 및 헤더
|
||||||
|
// REST API의 유연성을 위해 모든 표준 메서드와 헤더를 허용합니다.
|
||||||
|
config.addAllowedHeader("*")
|
||||||
config.addAllowedMethod("*")
|
config.addAllowedMethod("*")
|
||||||
|
|
||||||
// [중요] 클라이언트가 응답 헤더에서 'Authorization'이나 커스텀 토큰 헤더를 읽을 수 있게 허용
|
// 4. [중요] 응답 헤더 노출 설정 (Expose Headers)
|
||||||
|
// 브라우저는 기본적으로 보안상 CORS 요청에 대한 응답 헤더 중 일부(Cache-Control, Content-Type 등)만 JavaScript에서 접근하도록 제한합니다.
|
||||||
|
// 따라서, 클라이언트가 로그인 후 발급된 JWT 토큰(Authorization)을 읽을 수 있도록 명시적으로 노출시켜야 합니다.
|
||||||
config.addExposedHeader("Authorization")
|
config.addExposedHeader("Authorization")
|
||||||
config.addExposedHeader("Refresh-Token") // 리프레시 토큰도 헤더로 준다면 추가
|
config.addExposedHeader("Refresh-Token")
|
||||||
|
|
||||||
source.registerCorsConfiguration("/api/**", config)
|
source.registerCorsConfiguration("/api/**", config)
|
||||||
return CorsFilter(source)
|
return CorsFilter(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,6 +7,13 @@ import org.springframework.mail.javamail.JavaMailSender
|
|||||||
import org.springframework.mail.javamail.JavaMailSenderImpl
|
import org.springframework.mail.javamail.JavaMailSenderImpl
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [메일 발송 설정]
|
||||||
|
*
|
||||||
|
* 회원가입 인증 코드 발송 등을 위한 SMTP(Simple Mail Transfer Protocol) 서버 설정입니다.
|
||||||
|
* Google Gmail SMTP 등을 사용하여 외부 메일 서버와 연동하며,
|
||||||
|
* 네트워크 지연이나 연결 실패 시 스레드가 차단(Blocking)되는 것을 방지하기 위한 타임아웃 설정이 포함되어 있습니다.
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
class MailConfig(
|
class MailConfig(
|
||||||
@Value("\${spring.mail.host}") private val host: String,
|
@Value("\${spring.mail.host}") private val host: String,
|
||||||
@@ -21,26 +28,38 @@ class MailConfig(
|
|||||||
@Value("\${spring.mail.properties.mail.smtp.writetimeout}") private val writeTimeout: Int
|
@Value("\${spring.mail.properties.mail.smtp.writetimeout}") private val writeTimeout: Int
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaMailSender 빈 등록
|
||||||
|
*
|
||||||
|
* Spring Mail 라이브러리의 핵심 인터페이스 구현체를 생성합니다.
|
||||||
|
* application.yml에서 주입받은 환경 변수들을 기반으로 SMTP 연결을 초기화합니다.
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
fun javaMailSender(): JavaMailSender {
|
fun javaMailSender(): JavaMailSender {
|
||||||
val mailSender = JavaMailSenderImpl()
|
val mailSender = JavaMailSenderImpl()
|
||||||
|
|
||||||
// 기본 설정
|
// 기본 SMTP 서버 정보 설정
|
||||||
mailSender.host = host
|
mailSender.host = host
|
||||||
mailSender.port = port
|
mailSender.port = port
|
||||||
mailSender.username = username
|
mailSender.username = username
|
||||||
mailSender.password = password
|
mailSender.password = password
|
||||||
mailSender.defaultEncoding = "UTF-8"
|
mailSender.defaultEncoding = "UTF-8"
|
||||||
|
|
||||||
// 세부 프로퍼티 설정
|
// JavaMail 세부 속성 설정
|
||||||
val props: Properties = mailSender.javaMailProperties
|
val props: Properties = mailSender.javaMailProperties
|
||||||
props["mail.transport.protocol"] = "smtp"
|
props["mail.transport.protocol"] = "smtp"
|
||||||
props["mail.smtp.auth"] = auth
|
props["mail.smtp.auth"] = auth
|
||||||
props["mail.smtp.starttls.enable"] = starttlsEnable
|
props["mail.smtp.starttls.enable"] = starttlsEnable
|
||||||
props["mail.smtp.starttls.required"] = starttlsRequired
|
props["mail.smtp.starttls.required"] = starttlsRequired
|
||||||
props["mail.debug"] = "true" // 디버깅용 로그 출력 (배포 시 false로 변경 추천)
|
|
||||||
|
|
||||||
// 타임아웃 설정 (서버 응답 없을 때 무한 대기 방지)
|
// [디버깅 설정]
|
||||||
|
// 개발 환경에서는 메일 발송 로그를 상세히 확인하기 위해 true로 설정합니다.
|
||||||
|
// 운영(Prod) 환경에서는 로그 양이 많아질 수 있으므로 false로 변경하거나 로그 레벨을 조정해야 합니다.
|
||||||
|
props["mail.debug"] = "true"
|
||||||
|
|
||||||
|
// [안정성 설정: 타임아웃]
|
||||||
|
// 외부 SMTP 서버 응답이 지연될 경우, 애플리케이션 스레드가 무한 대기(Hang) 상태에 빠지는 것을 방지합니다.
|
||||||
|
// 각각 연결(Connection), 읽기(Read), 쓰기(Write) 타임아웃을 명시하여 Fail-Fast를 유도합니다.
|
||||||
props["mail.smtp.connectiontimeout"] = connectionTimeout
|
props["mail.smtp.connectiontimeout"] = connectionTimeout
|
||||||
props["mail.smtp.timeout"] = timeout
|
props["mail.smtp.timeout"] = timeout
|
||||||
props["mail.smtp.writetimeout"] = writeTimeout
|
props["mail.smtp.writetimeout"] = writeTimeout
|
||||||
|
|||||||
@@ -11,32 +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("/api/comments/**").permitAll() // 비회원 댓글 허용
|
|
||||||
auth.requestMatchers(HttpMethod.GET, "/api/profile").permitAll()
|
auth.requestMatchers(HttpMethod.GET, "/api/profile").permitAll()
|
||||||
|
|
||||||
|
// 댓글 API: 비회원 작성/삭제도 지원하므로 전체 허용 (내부 로직에서 비밀번호 검증)
|
||||||
|
auth.requestMatchers("/api/comments/**").permitAll()
|
||||||
|
|
||||||
|
// 관리자 영역: ROLE_ADMIN 권한을 가진 토큰 소유자만 접근 가능
|
||||||
auth.requestMatchers("/api/admin/**").hasAuthority("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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +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 추가
|
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
|
||||||
@@ -15,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,
|
||||||
@@ -23,28 +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
|
||||||
|
|
||||||
// 👇 [수정] Principal을 CustomUserDetails로 캐스팅하여 정보 추출
|
// 인증 객체에서 비즈니스 도메인 정보 추출 (CustomUserDetails 활용)
|
||||||
val principal = authentication.principal as CustomUserDetails
|
val principal = authentication.principal as CustomUserDetails
|
||||||
val memberId = principal.memberId
|
val memberId = principal.memberId
|
||||||
val nickname = principal.nickname
|
val nickname = principal.nickname
|
||||||
|
|
||||||
// Access Token 생성
|
// 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
|
.subject(authentication.name) // 표준 sub claim (Email)
|
||||||
.claim("auth", authorities) // 권한 정보 (ROLE_USER 등)
|
.claim("auth", authorities) // 사용자 권한 (ROLE_USER 등)
|
||||||
.claim("memberId", memberId) // 👈 [추가] 프론트엔드 식별용 ID
|
.claim("memberId", memberId) // 프론트엔드 식별 편의성 제공
|
||||||
.claim("nickname", nickname) // 👈 [추가] 프론트엔드 표기용 닉네임
|
.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))
|
||||||
@@ -58,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)
|
||||||
|
|
||||||
@@ -71,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
|
||||||
}
|
}
|
||||||
@@ -98,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,23 @@ import org.springframework.web.bind.MethodArgumentNotValidException
|
|||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [전역 예외 처리 핸들러]
|
||||||
|
*
|
||||||
|
* 애플리케이션 전반에서 발생하는 예외(Exception)를 중앙에서 캡처하여
|
||||||
|
* 클라이언트에게 일관된 포맷(ApiResponse)의 에러 응답을 반환합니다.
|
||||||
|
* @RestControllerAdvice를 사용하여 모든 컨트롤러에 AOP(Aspect Oriented Programming) 방식으로 적용됩니다.
|
||||||
|
*/
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class GlobalExceptionHandler {
|
class GlobalExceptionHandler {
|
||||||
|
|
||||||
// 1. 비즈니스 로직 에러 (의도적인 throw)
|
/**
|
||||||
|
* [비즈니스 로직 예외 처리]
|
||||||
|
*
|
||||||
|
* 서비스 계층(Service Layer)에서 검증 로직 수행 중 의도적으로 발생시킨 예외를 처리합니다.
|
||||||
|
* 예: 중복된 이메일, 존재하지 않는 게시글 조회 등
|
||||||
|
* 이는 클라이언트의 잘못된 요청(Bad Request)으로 간주하여 400 상태 코드를 반환합니다.
|
||||||
|
*/
|
||||||
@ExceptionHandler(IllegalArgumentException::class, IllegalStateException::class)
|
@ExceptionHandler(IllegalArgumentException::class, IllegalStateException::class)
|
||||||
fun handleBusinessException(e: RuntimeException): ResponseEntity<ApiResponse<Nothing>> {
|
fun handleBusinessException(e: RuntimeException): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
@@ -18,7 +31,13 @@ class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.error(e.message ?: "잘못된 요청입니다."))
|
.body(ApiResponse.error(e.message ?: "잘못된 요청입니다."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. @Valid 검증 실패 (DTO 유효성 체크)
|
/**
|
||||||
|
* [입력값 유효성 검증 실패 처리]
|
||||||
|
*
|
||||||
|
* @Valid 어노테이션에 의해 DTO 검증 실패 시 발생하는 예외(MethodArgumentNotValidException)를 처리합니다.
|
||||||
|
* 여러 필드에서 에러가 발생할 수 있으나, 클라이언트가 즉시 인지하고 수정할 수 있도록
|
||||||
|
* 첫 번째 에러 메시지만 추출하여 간결하게 반환합니다.
|
||||||
|
*/
|
||||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||||
fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Nothing>> {
|
fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
val message = e.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "입력값이 올바르지 않습니다."
|
val message = e.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "입력값이 올바르지 않습니다."
|
||||||
@@ -27,10 +46,19 @@ class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.error(message))
|
.body(ApiResponse.error(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 나머지 알 수 없는 에러
|
/**
|
||||||
|
* [시스템 예외 처리 (Fallback)]
|
||||||
|
*
|
||||||
|
* 명시적으로 처리되지 않은 모든 예외를 잡아내는 최후의 방어선입니다.
|
||||||
|
* NullPointerException이나 DB 연결 실패 등 예측하지 못한 서버 내부 오류가 이에 해당합니다.
|
||||||
|
*
|
||||||
|
* [보안 전략]
|
||||||
|
* 내부 로직이 노출될 수 있는 스택 트레이스(Stack Trace)는 클라이언트에게 절대 반환하지 않고 서버 로그로만 남기며,
|
||||||
|
* 사용자에게는 일반적인 500 에러 메시지만 전달합니다.
|
||||||
|
*/
|
||||||
@ExceptionHandler(Exception::class)
|
@ExceptionHandler(Exception::class)
|
||||||
fun handleException(e: Exception): ResponseEntity<ApiResponse<Nothing>> {
|
fun handleException(e: Exception): ResponseEntity<ApiResponse<Nothing>> {
|
||||||
e.printStackTrace() // 로그 남기기
|
e.printStackTrace() // 실제 운영 환경에서는 SLF4J 등의 로거를 사용하여 파일/ELK로 수집해야 함
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
.body(ApiResponse.error("서버 내부 오류가 발생했습니다."))
|
.body(ApiResponse.error("서버 내부 오류가 발생했습니다."))
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [인증 비즈니스 로직 서비스]
|
||||||
|
*
|
||||||
|
* 회원가입, 로그인, 토큰 재발급 등 계정 보안과 관련된 핵심 로직을 담당합니다.
|
||||||
|
* DB(Member), Redis(RefreshToken), Email(Verification) 등 여러 인프라 자원을 오케스트레이션하여
|
||||||
|
* 안전하고 무결한 인증 프로세스를 보장합니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class AuthService(
|
class AuthService(
|
||||||
@@ -23,12 +30,15 @@ class AuthService(
|
|||||||
private val jwtProvider: JwtProvider,
|
private val jwtProvider: JwtProvider,
|
||||||
private val refreshTokenRepository: RefreshTokenRepository,
|
private val refreshTokenRepository: RefreshTokenRepository,
|
||||||
private val emailService: EmailService,
|
private val emailService: EmailService,
|
||||||
// 👇 추가: DB에서 유저 정보를 다시 로드하기 위해 필요
|
|
||||||
private val userDetailsService: UserDetailsService
|
private val userDetailsService: UserDetailsService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원가입
|
* 신규 회원을 등록합니다.
|
||||||
|
*
|
||||||
|
* [스팸 방지 전략]
|
||||||
|
* 무분별한 가입을 막기 위해 가입 즉시 활성화(Active)하지 않고,
|
||||||
|
* `isVerified = false` 상태로 저장한 뒤 이메일 인증을 강제합니다.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun signup(request: SignupRequest) {
|
fun signup(request: SignupRequest) {
|
||||||
@@ -44,98 +54,115 @@ class AuthService(
|
|||||||
password = passwordEncoder.encode(request.password),
|
password = passwordEncoder.encode(request.password),
|
||||||
nickname = request.nickname,
|
nickname = request.nickname,
|
||||||
role = Role.ROLE_USER,
|
role = Role.ROLE_USER,
|
||||||
isVerified = false
|
isVerified = false // 초기 상태는 미인증
|
||||||
)
|
)
|
||||||
|
|
||||||
memberRepository.save(member)
|
memberRepository.save(member)
|
||||||
|
|
||||||
|
// 비동기 처리를 고려할 수 있으나, 가입 직후 메일 수신이 중요하므로 동기 호출
|
||||||
emailService.sendVerificationCode(request.email)
|
emailService.sendVerificationCode(request.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인
|
* 사용자 자격 증명을 검증하고 초기 토큰을 발급합니다.
|
||||||
|
*
|
||||||
|
* 단순 ID/PW 검사뿐만 아니라, 이메일 인증 여부(Business Rule)를 체크하여
|
||||||
|
* 미인증 계정의 접근을 원천 차단합니다.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun login(request: LoginRequest): TokenDto {
|
fun login(request: LoginRequest): TokenDto {
|
||||||
val member = memberRepository.findByEmail(request.email)
|
val member = memberRepository.findByEmail(request.email)
|
||||||
?: throw IllegalArgumentException("가입되지 않은 이메일입니다.")
|
?: throw IllegalArgumentException("가입되지 않은 이메일입니다.")
|
||||||
|
|
||||||
// 비밀번호 체크
|
// 비밀번호 체크 (Bcrypt)
|
||||||
if (!passwordEncoder.matches(request.password, member.password)) {
|
if (!passwordEncoder.matches(request.password, member.password)) {
|
||||||
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
|
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이메일 인증 여부 체크
|
// 계정 활성화 여부 체크
|
||||||
if (!member.isVerified) {
|
if (!member.isVerified) {
|
||||||
throw IllegalStateException("이메일 인증이 필요합니다.")
|
throw IllegalStateException("이메일 인증이 필요합니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. ID/PW 기반의 인증 토큰 생성
|
// 1. Spring Security 인증 토큰 생성
|
||||||
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
|
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
|
||||||
|
|
||||||
// 2. 실제 검증 (사용자 비밀번호 체크)
|
// 2. 실제 검증 수행 (CustomUserDetailsService 호출됨)
|
||||||
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
|
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
|
||||||
|
|
||||||
// 3. 인증 정보를 기반으로 JWT 토큰 생성
|
// 3. 인증 정보를 기반으로 JWT(Access + Refresh) 생성
|
||||||
val tokenDto = jwtProvider.generateTokenDto(authentication)
|
val tokenDto = jwtProvider.generateTokenDto(authentication)
|
||||||
|
|
||||||
// 4. RefreshToken Redis 저장 (RTR: 기존 토큰 덮어쓰기)
|
// 4. Refresh Token을 Redis에 저장 (RTR 전략의 기준점)
|
||||||
refreshTokenRepository.save(authentication.name, tokenDto.refreshToken)
|
refreshTokenRepository.save(authentication.name, tokenDto.refreshToken)
|
||||||
|
|
||||||
return tokenDto
|
return tokenDto
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰 재발급 (RTR 적용)
|
* Access Token 만료 시 토큰을 갱신합니다.
|
||||||
|
*
|
||||||
|
* [핵심 보안 전략: Refresh Token Rotation (RTR)]
|
||||||
|
* 보안성을 높이기 위해 Refresh Token을 일회용으로 사용합니다.
|
||||||
|
* 토큰 재발급 요청 시 기존 Refresh Token을 폐기하고, 새로운 Refresh Token을 발급합니다.
|
||||||
|
*
|
||||||
|
* [토큰 탈취 감지]
|
||||||
|
* 만약 이미 사용된(폐기된) Refresh Token으로 요청이 들어온다면, 이는 토큰이 탈취된 것으로 간주하여
|
||||||
|
* 해당 사용자의 저장된 모든 토큰을 삭제하고 강제 로그아웃 처리합니다.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun reissue(accessToken: String, refreshToken: String): TokenDto {
|
fun reissue(accessToken: String, refreshToken: String): TokenDto {
|
||||||
// 1. 리프레시 토큰 검증 (만료 여부, 위변조 여부)
|
// 1. 토큰 자체의 유효성 검증 (위변조 여부)
|
||||||
if (!jwtProvider.validateToken(refreshToken)) {
|
if (!jwtProvider.validateToken(refreshToken)) {
|
||||||
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
|
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 액세스 토큰에서 User ID(Email) 가져오기
|
// 2. Access Token에서 사용자 정보 추출
|
||||||
// (주의: 여기서 authentication.principal은 CustomUserDetails가 아닐 수 있음)
|
|
||||||
val tempAuthentication = jwtProvider.getAuthentication(accessToken)
|
val tempAuthentication = jwtProvider.getAuthentication(accessToken)
|
||||||
|
|
||||||
// 3. Redis에서 저장된 Refresh Token 가져오기
|
// 3. Redis에 저장된 최신 Refresh Token 조회
|
||||||
val savedRefreshToken = refreshTokenRepository.findByEmail(tempAuthentication.name)
|
val savedRefreshToken = refreshTokenRepository.findByEmail(tempAuthentication.name)
|
||||||
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
|
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
|
||||||
|
|
||||||
// 4. 토큰 일치 여부 확인 (재사용 방지)
|
// 4. [RTR 핵심] 토큰 불일치 감지 (재사용 시도 -> 탈취 의심)
|
||||||
if (savedRefreshToken != refreshToken) {
|
if (savedRefreshToken != refreshToken) {
|
||||||
refreshTokenRepository.delete(tempAuthentication.name)
|
refreshTokenRepository.delete(tempAuthentication.name) // 보안 조치: 세션 전체 파기
|
||||||
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.")
|
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다. (재사용 감지됨)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✨ 5. [수정됨] DB에서 유저 정보(CustomUserDetails) 다시 로드
|
// 5. DB에서 최신 유저 정보 다시 로드
|
||||||
// JwtProvider.generateTokenDto()가 CustomUserDetails를 필요로 하므로 필수
|
// (토큰 갱신 시점의 권한 변경이나 닉네임 변경 등을 반영하기 위함)
|
||||||
val userDetails = userDetailsService.loadUserByUsername(tempAuthentication.name)
|
val userDetails = userDetailsService.loadUserByUsername(tempAuthentication.name)
|
||||||
|
|
||||||
// ✨ 로드한 userDetails로 새로운 Authentication 생성
|
// 6. 새로운 Authentication 객체 생성
|
||||||
val newAuthentication = UsernamePasswordAuthenticationToken(
|
val newAuthentication = UsernamePasswordAuthenticationToken(
|
||||||
userDetails, null, userDetails.authorities
|
userDetails, null, userDetails.authorities
|
||||||
)
|
)
|
||||||
|
|
||||||
// 6. 새 토큰 생성 (Rotation)
|
// 7. 새 토큰 쌍 발급 (Rotate)
|
||||||
val newTokenDto = jwtProvider.generateTokenDto(newAuthentication)
|
val newTokenDto = jwtProvider.generateTokenDto(newAuthentication)
|
||||||
|
|
||||||
// 7. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
|
// 8. Redis 업데이트 (기존 토큰 덮어쓰기)
|
||||||
refreshTokenRepository.save(newAuthentication.name, newTokenDto.refreshToken)
|
refreshTokenRepository.save(newAuthentication.name, newTokenDto.refreshToken)
|
||||||
|
|
||||||
return newTokenDto
|
return newTokenDto
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그아웃
|
* 로그아웃 처리
|
||||||
|
*
|
||||||
|
* 서버 측에서 Refresh Token을 삭제함으로써, Access Token이 만료되는 즉시
|
||||||
|
* 더 이상 갱신할 수 없도록 세션을 종료시킵니다.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun logout(email: String) {
|
fun logout(email: String) {
|
||||||
refreshTokenRepository.delete(email)
|
refreshTokenRepository.delete(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 이메일 인증 확인
|
/**
|
||||||
|
* 이메일 인증 코드를 검증하고 계정 상태를 활성화(Verify)합니다.
|
||||||
|
* 상태 변경(update)이 발생하므로 트랜잭션 내에서 처리됩니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun verifyEmail(email: String, code: String) {
|
fun verifyEmail(email: String, code: String) {
|
||||||
val member = memberRepository.findByEmail(email)
|
val member = memberRepository.findByEmail(email)
|
||||||
@@ -145,12 +172,12 @@ class AuthService(
|
|||||||
throw IllegalArgumentException("이미 인증된 회원입니다.")
|
throw IllegalArgumentException("이미 인증된 회원입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 코드 검증
|
// Redis에 저장된 코드와 대조
|
||||||
if (!emailService.verifyCode(email, code)) {
|
if (!emailService.verifyCode(email, code)) {
|
||||||
throw IllegalArgumentException("인증 코드가 올바르지 않거나 만료되었습니다.")
|
throw IllegalArgumentException("인증 코드가 올바르지 않거나 만료되었습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 상태 업데이트
|
// 인증 성공 시 회원 상태 변경
|
||||||
member.verify()
|
member.verify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,25 @@ package me.wypark.blogbackend.domain.auth
|
|||||||
import org.springframework.security.core.GrantedAuthority
|
import org.springframework.security.core.GrantedAuthority
|
||||||
import org.springframework.security.core.userdetails.User
|
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(
|
class CustomUserDetails(
|
||||||
|
// DB의 Primary Key (비즈니스 로직에서 조인이나 조회 시 사용)
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
|
|
||||||
|
// UI 표시용 닉네임 (매번 회원 정보를 조회하지 않기 위함)
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
|
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
authorities: Collection<GrantedAuthority>
|
authorities: Collection<GrantedAuthority>
|
||||||
|
|||||||
@@ -8,22 +8,43 @@ import org.springframework.security.core.userdetails.UserDetailsService
|
|||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Spring Security 사용자 로드 서비스]
|
||||||
|
*
|
||||||
|
* Spring Security의 인증 매니저(AuthenticationManager)가 실제 DB에 저장된 사용자 정보를
|
||||||
|
* 조회할 수 있도록 지원하는 핵심 인터페이스(UserDetailsService)의 구현체입니다.
|
||||||
|
*
|
||||||
|
* 도메인 영역의 [Member] 엔티티를 시큐리티 영역의 [UserDetails] 객체로 변환(Adapt)하는 역할을 수행합니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
class CustomUserDetailsService(
|
class CustomUserDetailsService(
|
||||||
private val memberRepository: MemberRepository
|
private val memberRepository: MemberRepository
|
||||||
) : UserDetailsService {
|
) : UserDetailsService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 식별자(여기서는 이메일)로 DB에서 사용자 정보를 조회합니다.
|
||||||
|
* 로그인 요청 시 내부적으로 호출되며, 조회 실패 시 시큐리티 규격에 맞는 예외를 던집니다.
|
||||||
|
*/
|
||||||
override fun loadUserByUsername(username: String): UserDetails {
|
override fun loadUserByUsername(username: String): UserDetails {
|
||||||
return memberRepository.findByEmail(username)
|
return memberRepository.findByEmail(username)
|
||||||
?.let { createUserDetails(it) }
|
?.let { createUserDetails(it) }
|
||||||
?: throw UsernameNotFoundException("해당 유저를 찾을 수 없습니다: $username")
|
?: throw UsernameNotFoundException("해당 유저를 찾을 수 없습니다: $username")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [UserDetails 변환 로직]
|
||||||
|
*
|
||||||
|
* 조회된 Member 엔티티를 기반으로 인증 객체(CustomUserDetails)를 생성합니다.
|
||||||
|
*
|
||||||
|
* [최적화 전략]
|
||||||
|
* Spring Security가 제공하는 기본 User 객체 대신, 직접 정의한 CustomUserDetails를 반환함으로써
|
||||||
|
* 추후 컨트롤러나 서비스 계층에서 @AuthenticationPrincipal을 통해
|
||||||
|
* DB 추가 조회 없이도 사용자 식별자(ID)와 닉네임에 즉시 접근할 수 있도록 설계했습니다.
|
||||||
|
*/
|
||||||
private fun createUserDetails(member: Member): UserDetails {
|
private fun createUserDetails(member: Member): UserDetails {
|
||||||
// [수정] 표준 User 객체 대신, ID와 닉네임을 포함하는 CustomUserDetails 반환
|
|
||||||
return CustomUserDetails(
|
return CustomUserDetails(
|
||||||
memberId = member.id!!, // 토큰에 넣을 ID
|
memberId = member.id!!, // 비즈니스 로직용 PK 캐싱
|
||||||
nickname = member.nickname, // 토큰에 넣을 닉네임
|
nickname = member.nickname, // UI 렌더링용 닉네임 캐싱
|
||||||
username = member.email,
|
username = member.email,
|
||||||
password = member.password,
|
password = member.password,
|
||||||
authorities = listOf(SimpleGrantedAuthority(member.role.name))
|
authorities = listOf(SimpleGrantedAuthority(member.role.name))
|
||||||
|
|||||||
@@ -7,17 +7,32 @@ import org.springframework.stereotype.Service
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [이메일 인증 서비스]
|
||||||
|
*
|
||||||
|
* 회원가입 시 본인 확인을 위한 OTP(One Time Password) 발송 및 검증 로직을 담당합니다.
|
||||||
|
*
|
||||||
|
* [아키텍처 설계]
|
||||||
|
* 인증 코드의 상태(State) 관리를 위해 인메모리 DB인 Redis를 사용합니다.
|
||||||
|
* RDB를 사용하지 않음으로써 만료된 코드의 정리(Cleanup) 비용을 없애고, 빠른 액세스 속도를 보장합니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
class EmailService(
|
class EmailService(
|
||||||
private val javaMailSender: JavaMailSender,
|
private val javaMailSender: JavaMailSender,
|
||||||
private val redisTemplate: RedisTemplate<String, String>
|
private val redisTemplate: RedisTemplate<String, String>
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// 인증 코드 전송
|
/**
|
||||||
|
* 인증 코드를 생성하고 이메일로 발송합니다.
|
||||||
|
*
|
||||||
|
* 생성된 코드는 Redis에 저장되며, 보안을 위해 짧은 유효시간(TTL)을 가집니다.
|
||||||
|
* 이메일 발송은 외부 SMTP 서버를 이용하므로, 트래픽 급증 시 비동기 큐(RabbitMQ/Kafka) 도입을 고려할 수 있습니다.
|
||||||
|
*/
|
||||||
fun sendVerificationCode(email: String) {
|
fun sendVerificationCode(email: String) {
|
||||||
val code = createVerificationCode()
|
val code = createVerificationCode()
|
||||||
|
|
||||||
// 1. Redis에 저장 (Key: "Verify:이메일", Value: 코드, 유효시간: 5분)
|
// Redis 저장 전략: Key에 Prefix("Verify:")를 붙여 네임스페이스를 구분하고,
|
||||||
|
// 5분의 TTL(Time-To-Live)을 설정하여 별도의 삭제 로직 없이 자동 만료되도록 처리함.
|
||||||
redisTemplate.opsForValue().set(
|
redisTemplate.opsForValue().set(
|
||||||
"Verify:$email",
|
"Verify:$email",
|
||||||
code,
|
code,
|
||||||
@@ -25,20 +40,30 @@ class EmailService(
|
|||||||
TimeUnit.MINUTES
|
TimeUnit.MINUTES
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. 메일 발송
|
|
||||||
sendMail(email, code)
|
sendMail(email, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 코드 검증
|
/**
|
||||||
|
* 사용자가 입력한 코드와 Redis에 저장된 원본 코드를 대조합니다.
|
||||||
|
* 코드가 만료되었거나 일치하지 않을 경우 false를 반환합니다.
|
||||||
|
*/
|
||||||
fun verifyCode(email: String, code: String): Boolean {
|
fun verifyCode(email: String, code: String): Boolean {
|
||||||
val savedCode = redisTemplate.opsForValue().get("Verify:$email")
|
val savedCode = redisTemplate.opsForValue().get("Verify:$email")
|
||||||
return savedCode != null && savedCode == code
|
return savedCode != null && savedCode == code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 6자리 숫자(100000 ~ 999999)로 구성된 난수를 생성합니다.
|
||||||
|
* 보안성과 사용자 입력 편의성(Usability) 사이의 균형을 맞춘 길이입니다.
|
||||||
|
*/
|
||||||
private fun createVerificationCode(): String {
|
private fun createVerificationCode(): String {
|
||||||
return Random.nextInt(100000, 999999).toString() // 6자리 난수
|
return Random.nextInt(100000, 999999).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 템플릿을 사용하여 인증 메일을 발송합니다.
|
||||||
|
* 단순 텍스트보다 신뢰감을 주고 브랜드 아이덴티티를 전달하기 위해 인라인 스타일(CSS)을 적용했습니다.
|
||||||
|
*/
|
||||||
private fun sendMail(email: String, code: String) {
|
private fun sendMail(email: String, code: String) {
|
||||||
val mimeMessage = javaMailSender.createMimeMessage()
|
val mimeMessage = javaMailSender.createMimeMessage()
|
||||||
val helper = MimeMessageHelper(mimeMessage, "utf-8")
|
val helper = MimeMessageHelper(mimeMessage, "utf-8")
|
||||||
@@ -46,6 +71,7 @@ class EmailService(
|
|||||||
helper.setTo(email)
|
helper.setTo(email)
|
||||||
helper.setSubject("[Blog] 회원가입 인증 코드입니다.")
|
helper.setSubject("[Blog] 회원가입 인증 코드입니다.")
|
||||||
|
|
||||||
|
// HTML 본문 구성 (이메일 클라이언트 호환성을 위해 Inline CSS 사용 권장)
|
||||||
val htmlContent = """
|
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;">
|
<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;">
|
<h1 style="margin: 0; padding: 0 5px; font-size: 28px; font-weight: 400;">
|
||||||
@@ -71,7 +97,7 @@ class EmailService(
|
|||||||
</div>
|
</div>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
helper.setText(htmlContent, true) // true: HTML 모드 켜기
|
helper.setText(htmlContent, true) // true: HTML 모드 활성화
|
||||||
javaMailSender.send(mimeMessage)
|
javaMailSender.send(mimeMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,35 +2,64 @@ package me.wypark.blogbackend.domain.category
|
|||||||
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [카테고리 엔티티]
|
||||||
|
*
|
||||||
|
* 게시글 분류를 위한 계층형 구조(Hierarchical Structure)를 정의합니다.
|
||||||
|
* 자기 자신을 참조하는 Self-Referencing 방식을 사용하여 무한 깊이의 트리 구조를 구현했습니다.
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
class Category(
|
class Category(
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var name: String,
|
var name: String,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [부모 카테고리]
|
||||||
|
* 루트(Root) 카테고리의 경우 null을 허용합니다.
|
||||||
|
* N+1 문제를 방지하기 위해 기본 Fetch 전략을 LAZY로 설정했습니다.
|
||||||
|
*/
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "parent_id")
|
@JoinColumn(name = "parent_id")
|
||||||
var parent: Category? = null, // 부모 카테고리 (없으면 최상위)
|
var parent: Category? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [자식 카테고리 목록]
|
||||||
|
*
|
||||||
|
* [Cascade 설정]
|
||||||
|
* 부모 카테고리가 삭제될 경우, 데이터 무결성을 위해 하위 카테고리들도 함께 삭제(CascadeType.ALL)되도록 설정했습니다.
|
||||||
|
* (실무 정책에 따라 삭제 대신 '미분류'로 이동시키거나 삭제를 막을 수도 있습니다.)
|
||||||
|
*/
|
||||||
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL])
|
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL])
|
||||||
val children: MutableList<Category> = mutableListOf() // 자식 카테고리들
|
val children: MutableList<Category> = mutableListOf()
|
||||||
) {
|
) {
|
||||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
val id: Long? = null
|
val id: Long? = null
|
||||||
|
|
||||||
// 연관관계 편의 메서드 (부모-자식 연결)
|
/**
|
||||||
|
* [연관관계 편의 메서드]
|
||||||
|
*
|
||||||
|
* 양방향 관계인 Category 엔티티에서 부모와 자식 간의 참조를 원자적(Atomic)으로 설정합니다.
|
||||||
|
* 객체 관점에서 부모의 children 리스트에도 추가하고, 자식의 parent 필드도 설정해주어야
|
||||||
|
* 영속성 컨텍스트(Persistence Context) 내에서 데이터 정합성이 유지됩니다.
|
||||||
|
*/
|
||||||
fun addChild(child: Category) {
|
fun addChild(child: Category) {
|
||||||
this.children.add(child)
|
this.children.add(child)
|
||||||
child.parent = this
|
child.parent = this
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이름 변경
|
|
||||||
fun updateName(name: String) {
|
fun updateName(name: String) {
|
||||||
this.name = name
|
this.name = name
|
||||||
}
|
}
|
||||||
|
|
||||||
// 부모 변경 (계층 이동)
|
/**
|
||||||
|
* [부모 카테고리 변경 (이동)]
|
||||||
|
*
|
||||||
|
* 카테고리의 위치를 트리 구조 내에서 이동시킵니다.
|
||||||
|
* 기존 부모와의 관계를 명시적으로 끊고 새로운 부모와 연결함으로써,
|
||||||
|
* JPA 1차 캐시 상의 데이터 불일치를 방지합니다.
|
||||||
|
*/
|
||||||
fun changeParent(newParent: Category?) {
|
fun changeParent(newParent: Category?) {
|
||||||
// 1. 기존 부모와의 관계 끊기
|
// 1. 기존 부모와의 관계 끊기 (메모리 상의 리스트 정리)
|
||||||
this.parent?.children?.remove(this)
|
this.parent?.children?.remove(this)
|
||||||
|
|
||||||
// 2. 새 부모 설정
|
// 2. 새 부모 설정
|
||||||
|
|||||||
@@ -3,15 +3,39 @@ package me.wypark.blogbackend.domain.category
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [카테고리 데이터 접근 계층]
|
||||||
|
*
|
||||||
|
* 카테고리 엔티티의 영속성(Persistence)을 관리합니다.
|
||||||
|
* 계층형 구조(Hierarchy)의 특성을 고려하여 N+1 문제를 방지하기 위한
|
||||||
|
* 최적화된 Fetch Join 쿼리를 포함하고 있습니다.
|
||||||
|
*/
|
||||||
interface CategoryRepository : JpaRepository<Category, Long> {
|
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")
|
@Query("SELECT DISTINCT c FROM Category c LEFT JOIN FETCH c.children WHERE c.parent IS NULL")
|
||||||
fun findAllRoots(): List<Category>
|
fun findAllRoots(): List<Category>
|
||||||
|
|
||||||
// 카테고리 이름 중복 검사 (같은 레벨에서 중복 방지용)
|
/**
|
||||||
|
* 카테고리 이름의 중복 여부를 검사합니다.
|
||||||
|
*
|
||||||
|
* 동일한 레벨 내에서 같은 이름의 카테고리가 생성되는 것을 방지하여
|
||||||
|
* 사용자의 혼란을 막고 데이터의 유니크성을 보장하기 위해 사용됩니다.
|
||||||
|
*/
|
||||||
fun existsByName(name: String): Boolean
|
fun existsByName(name: String): Boolean
|
||||||
|
|
||||||
// 이름으로 찾기 (게시글 작성 시 필요)
|
/**
|
||||||
|
* 이름으로 카테고리를 조회합니다.
|
||||||
|
*
|
||||||
|
* 게시글 작성 시 카테고리 이름 문자열을 엔티티로 매핑하거나,
|
||||||
|
* URL 경로(Path Variable)를 통해 카테고리를 찾을 때 활용됩니다.
|
||||||
|
*/
|
||||||
fun findByName(name: String): Category?
|
fun findByName(name: String): Category?
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,12 @@ import org.springframework.data.repository.findByIdOrNull
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [카테고리 비즈니스 로직]
|
||||||
|
*
|
||||||
|
* 카테고리의 생성, 수정, 삭제 및 계층 구조(Tree) 관리를 담당합니다.
|
||||||
|
* 단순한 데이터 조작을 넘어, 순환 참조(Cycle) 방지와 같은 구조적 무결성 검증 로직이 포함되어 있습니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class CategoryService(
|
class CategoryService(
|
||||||
@@ -15,24 +21,36 @@ class CategoryService(
|
|||||||
private val postRepository: PostRepository
|
private val postRepository: PostRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// 예약어 검증 메서드
|
/**
|
||||||
|
* 시스템 예약어 사용을 방지합니다.
|
||||||
|
* '미분류(uncategorized)' 등 시스템 내부 로직에서 특별하게 취급하는 이름은
|
||||||
|
* 사용자가 임의로 생성하거나 수정할 수 없도록 제한합니다.
|
||||||
|
*/
|
||||||
private fun validateReservedName(name: String) {
|
private fun validateReservedName(name: String) {
|
||||||
if (name.equals("uncategorized", ignoreCase = true)) {
|
if (name.equals("uncategorized", ignoreCase = true)) {
|
||||||
throw IllegalArgumentException("'uncategorized'는 시스템 예약어이므로 사용할 수 없습니다.")
|
throw IllegalArgumentException("'uncategorized'는 시스템 예약어이므로 사용할 수 없습니다.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 카테고리를 계층형 트리 구조로 반환합니다.
|
||||||
|
* Root 노드만 조회하면, 엔티티 내의 연관관계와 Fetch Join을 통해 하위 노드들이 재귀적으로 매핑됩니다.
|
||||||
|
*/
|
||||||
fun getCategoryTree(): List<CategoryResponse> {
|
fun getCategoryTree(): List<CategoryResponse> {
|
||||||
val roots = categoryRepository.findAllRoots()
|
val roots = categoryRepository.findAllRoots()
|
||||||
return roots.map { CategoryResponse.from(it) }
|
return roots.map { CategoryResponse.from(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 카테고리를 생성합니다.
|
||||||
|
* 데이터 정합성을 위해 이름 중복 검사와 부모 카테고리의 존재 여부를 엄격하게 검증(Strict Validation)합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCategory(request: CategoryCreateRequest): Long {
|
fun createCategory(request: CategoryCreateRequest): Long {
|
||||||
// 1. 예약어 검증
|
// 1. 예약어 검증
|
||||||
validateReservedName(request.name)
|
validateReservedName(request.name)
|
||||||
|
|
||||||
// 2. 중복 체크
|
// 2. 중복 체크 (Unique Constraint)
|
||||||
if (categoryRepository.existsByName(request.name)) {
|
if (categoryRepository.existsByName(request.name)) {
|
||||||
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
|
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
|
||||||
}
|
}
|
||||||
@@ -52,14 +70,21 @@ class CategoryService(
|
|||||||
return categoryRepository.save(category).id!!
|
return categoryRepository.save(category).id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 정보를 수정합니다. (이름 변경 및 트리 구조 이동)
|
||||||
|
*
|
||||||
|
* [구조 변경 시 주의사항]
|
||||||
|
* 부모 카테고리를 변경하는 경우, 트리 구조가 깨지거나 순환 참조(Cycle)가 발생할 위험이 있습니다.
|
||||||
|
* 따라서 이동 전에 `validateHierarchy`를 통해 구조적 유효성을 반드시 확인해야 합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateCategory(id: Long, request: CategoryUpdateRequest) {
|
fun updateCategory(id: Long, request: CategoryUpdateRequest) {
|
||||||
val category = categoryRepository.findByIdOrNull(id)
|
val category = categoryRepository.findByIdOrNull(id)
|
||||||
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
|
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
|
||||||
|
|
||||||
// 1. 이름 변경 (변경 시에만 검증)
|
// 1. 이름 변경 (실제 변경이 있을 때만 검증 수행)
|
||||||
if (request.name != null && category.name != request.name) {
|
if (request.name != null && category.name != request.name) {
|
||||||
validateReservedName(request.name) // 예약어 검증
|
validateReservedName(request.name)
|
||||||
|
|
||||||
if (categoryRepository.existsByName(request.name)) {
|
if (categoryRepository.existsByName(request.name)) {
|
||||||
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
|
throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.")
|
||||||
@@ -67,28 +92,42 @@ class CategoryService(
|
|||||||
category.updateName(request.name)
|
category.updateName(request.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 부모 변경
|
// 2. 부모 변경 (구조 이동)
|
||||||
val currentParentId = category.parent?.id
|
val currentParentId = category.parent?.id
|
||||||
val newParentId = request.parentId
|
val newParentId = request.parentId
|
||||||
|
|
||||||
if (currentParentId != newParentId) {
|
if (currentParentId != newParentId) {
|
||||||
if (newParentId == null) {
|
if (newParentId == null) {
|
||||||
|
// Root로 이동
|
||||||
category.changeParent(null)
|
category.changeParent(null)
|
||||||
} else {
|
} else {
|
||||||
|
// 다른 하위 노드로 이동
|
||||||
val newParent = categoryRepository.findByIdOrNull(newParentId)
|
val newParent = categoryRepository.findByIdOrNull(newParentId)
|
||||||
?: throw IllegalArgumentException("이동하려는 부모 카테고리가 존재하지 않습니다.")
|
?: throw IllegalArgumentException("이동하려는 부모 카테고리가 존재하지 않습니다.")
|
||||||
|
|
||||||
|
// 순환 참조 검증
|
||||||
validateHierarchy(category, newParent)
|
validateHierarchy(category, newParent)
|
||||||
category.changeParent(newParent)
|
category.changeParent(newParent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [순환 참조 방지 로직]
|
||||||
|
*
|
||||||
|
* 카테고리 이동 시, 대상(Target)이 자신의 하위 카테고리로 들어가는 것을 방지합니다.
|
||||||
|
* 만약 허용할 경우, A -> B -> A 형태의 무한 루프가 발생하여 트리 조회가 불가능해집니다.
|
||||||
|
*
|
||||||
|
* @param target 이동하려는 카테고리
|
||||||
|
* @param newParent 이동할 목적지(새 부모)
|
||||||
|
*/
|
||||||
private fun validateHierarchy(target: Category, newParent: Category) {
|
private fun validateHierarchy(target: Category, newParent: Category) {
|
||||||
|
// 1. 자기 자신을 부모로 설정하는 경우
|
||||||
if (target.id == newParent.id) {
|
if (target.id == newParent.id) {
|
||||||
throw IllegalArgumentException("자기 자신을 부모로 설정할 수 없습니다.")
|
throw IllegalArgumentException("자기 자신을 부모로 설정할 수 없습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 자신의 자손(Descendant)을 부모로 설정하는 경우
|
||||||
var parent = newParent.parent
|
var parent = newParent.parent
|
||||||
while (parent != null) {
|
while (parent != null) {
|
||||||
if (parent.id == target.id) {
|
if (parent.id == target.id) {
|
||||||
@@ -98,19 +137,33 @@ class CategoryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리를 삭제합니다.
|
||||||
|
*
|
||||||
|
* [삭제 정책: Safe Deletion]
|
||||||
|
* 카테고리가 삭제되더라도, 해당 카테고리에 속한 게시글(Post)은 삭제되지 않아야 합니다.
|
||||||
|
* 따라서 삭제 대상 카테고리 및 그 하위 카테고리들에 속한 모든 게시글의 category_id를
|
||||||
|
* NULL(미분류)로 업데이트한 후, 카테고리만 물리적으로 제거합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteCategory(id: Long) {
|
fun deleteCategory(id: Long) {
|
||||||
val category = categoryRepository.findByIdOrNull(id)
|
val category = categoryRepository.findByIdOrNull(id)
|
||||||
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
|
?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.")
|
||||||
|
|
||||||
|
// 삭제할 카테고리와 그 자손들을 모두 수집 (Flattening)
|
||||||
val categoriesToDelete = mutableListOf<Category>()
|
val categoriesToDelete = mutableListOf<Category>()
|
||||||
collectAllCategories(category, categoriesToDelete)
|
collectAllCategories(category, categoriesToDelete)
|
||||||
|
|
||||||
|
// 연관된 게시글들의 카테고리 연결 해제 (Bulk Update로 성능 최적화)
|
||||||
postRepository.bulkUpdateCategoryToNull(categoriesToDelete)
|
postRepository.bulkUpdateCategoryToNull(categoriesToDelete)
|
||||||
|
|
||||||
|
// 카테고리 삭제 (Cascade 설정에 의해 하위 카테고리도 DB에서 삭제됨)
|
||||||
categoryRepository.delete(category)
|
categoryRepository.delete(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재귀적으로 하위 카테고리를 모두 순회하여 리스트에 담습니다.
|
||||||
|
*/
|
||||||
private fun collectAllCategories(category: Category, list: MutableList<Category>) {
|
private fun collectAllCategories(category: Category, list: MutableList<Category>) {
|
||||||
list.add(category)
|
list.add(category)
|
||||||
category.children.forEach { collectAllCategories(it, list) }
|
category.children.forEach { collectAllCategories(it, list) }
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
|||||||
import me.wypark.blogbackend.domain.post.Post
|
import me.wypark.blogbackend.domain.post.Post
|
||||||
import me.wypark.blogbackend.domain.user.Member
|
import me.wypark.blogbackend.domain.user.Member
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [댓글 엔티티]
|
||||||
|
*
|
||||||
|
* 게시글에 대한 사용자 반응(Interaction)을 저장하는 도메인 모델입니다.
|
||||||
|
*
|
||||||
|
* [핵심 설계 전략]
|
||||||
|
* 1. 계층형 구조(Hierarchy): 대댓글 기능을 지원하기 위해 자기 자신을 참조(Self-Referencing)하는 구조를 가집니다.
|
||||||
|
* 2. 하이브리드 인증 지원: 참여율을 높이기 위해 회원(Member)뿐만 아니라 비회원(Guest)의 작성도 허용하며,
|
||||||
|
* 이에 따라 작성자 정보를 조건부로 저장하는 유연한 스키마를 채택했습니다.
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
class Comment(
|
class Comment(
|
||||||
@Column(nullable = false, columnDefinition = "TEXT")
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
@@ -14,36 +24,61 @@ class Comment(
|
|||||||
@JoinColumn(name = "post_id")
|
@JoinColumn(name = "post_id")
|
||||||
val post: Post,
|
val post: Post,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [계층형 구조 - 부모 댓글]
|
||||||
|
* 최상위 댓글일 경우 null이며, 대댓글(Reply)일 경우 상위 댓글을 참조합니다.
|
||||||
|
*/
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "parent_id")
|
@JoinColumn(name = "parent_id")
|
||||||
var parent: Comment? = null, // 대댓글용 부모 댓글
|
var parent: Comment? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [계층형 구조 - 자식 댓글]
|
||||||
|
*
|
||||||
|
* [삭제 정책: Cascade & OrphanRemoval]
|
||||||
|
* 부모 댓글이 삭제되면 그에 딸린 대댓글들도 논리적으로 존재 가치를 잃게 되므로,
|
||||||
|
* 영속성 전이(Cascade)를 통해 DB에서 함께 삭제되도록 설정하여 데이터 정합성을 유지합니다.
|
||||||
|
*/
|
||||||
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true)
|
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
val children: MutableList<Comment> = mutableListOf(),
|
val children: MutableList<Comment> = mutableListOf(),
|
||||||
|
|
||||||
// --- 1. 회원일 경우 ---
|
// =================================================================================
|
||||||
|
// [작성자 정보 관리 전략 (Hybrid)]
|
||||||
|
// 회원은 Member 연관관계를 사용하고, 비회원은 별도의 컬럼(guest_*)을 사용합니다.
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
// 1. 회원일 경우 (FK)
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id")
|
@JoinColumn(name = "member_id")
|
||||||
val member: Member? = null,
|
val member: Member? = null,
|
||||||
|
|
||||||
// --- 2. 비회원일 경우 ---
|
// 2. 비회원일 경우 (임시 식별 정보)
|
||||||
@Column
|
@Column
|
||||||
var guestNickname: String? = null,
|
var guestNickname: String? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비회원용 수정/삭제 비밀번호
|
||||||
|
* Note: 보안을 위해 실제 운영 환경에서는 평문 저장이 아닌 단방향 암호화(Hash) 후 저장해야 합니다.
|
||||||
|
*/
|
||||||
@Column
|
@Column
|
||||||
var guestPassword: String? = null // 암호화해서 저장 권장
|
var guestPassword: String? = null
|
||||||
|
|
||||||
) : BaseTimeEntity() {
|
) : BaseTimeEntity() {
|
||||||
|
|
||||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
val id: Long? = null
|
val id: Long? = null
|
||||||
|
|
||||||
// 댓글 작성자 이름 가져오기 (회원이면 닉네임, 비회원이면 입력한 이름)
|
/**
|
||||||
|
* 뷰 렌더링을 위한 작성자 이름 반환 로직입니다.
|
||||||
|
* 회원 여부에 따라 닉네임 소스(Source)가 달라지므로, 이를 캡슐화하여 클라이언트에 일관된 값을 제공합니다.
|
||||||
|
*/
|
||||||
fun getAuthorName(): String {
|
fun getAuthorName(): String {
|
||||||
return member?.nickname ?: guestNickname ?: "알 수 없음"
|
return member?.nickname ?: guestNickname ?: "알 수 없음"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비회원 비밀번호 검증
|
/**
|
||||||
|
* 비회원 댓글 삭제 요청 시 권한 검증을 수행합니다.
|
||||||
|
*/
|
||||||
fun matchGuestPassword(password: String): Boolean {
|
fun matchGuestPassword(password: String): Boolean {
|
||||||
return this.guestPassword == password
|
return this.guestPassword == password
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,31 @@ package me.wypark.blogbackend.domain.comment
|
|||||||
import me.wypark.blogbackend.domain.post.Post
|
import me.wypark.blogbackend.domain.post.Post
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [댓글 데이터 접근 계층]
|
||||||
|
*
|
||||||
|
* 댓글 엔티티의 영속성 관리를 담당하는 리포지토리입니다.
|
||||||
|
* 계층형 댓글 구조(Root-Child)를 효율적으로 조회하고,
|
||||||
|
* 게시글 생명주기에 따른 종속적인 데이터 정리(Cleanup) 기능을 제공합니다.
|
||||||
|
*/
|
||||||
interface CommentRepository : JpaRepository<Comment, Long> {
|
interface CommentRepository : JpaRepository<Comment, Long> {
|
||||||
|
|
||||||
// 특정 게시글의 모든 댓글 조회 (최상위 부모 댓글 기준 + 작성순)
|
/**
|
||||||
// 자식 댓글은 Entity의 children 필드를 통해 가져오거나, BatchSize로 최적화합니다.
|
* 특정 게시글의 최상위(Root) 댓글 목록을 작성순으로 조회합니다.
|
||||||
|
*
|
||||||
|
* [계층형 데이터 조회 전략]
|
||||||
|
* 대댓글(Child)까지 모두 Eager Fetch로 가져올 경우 데이터 중복(Cartesian Product) 및 애플리케이션 메모리 부하가 발생할 수 있습니다.
|
||||||
|
* 따라서 Root 댓글만 우선 조회하고, 하위 댓글 컬렉션은 지연 로딩(Lazy Loading) 발생 시
|
||||||
|
* 엔티티에 설정된 @BatchSize를 통해 IN 쿼리로 묶어서 가져오는 방식으로 N+1 문제를 최적화합니다.
|
||||||
|
*/
|
||||||
fun findAllByPostAndParentIsNullOrderByCreatedAtAsc(post: Post): List<Comment>
|
fun findAllByPostAndParentIsNullOrderByCreatedAtAsc(post: Post): List<Comment>
|
||||||
|
|
||||||
// 게시글 삭제 시 관련 댓글 전체 삭제용
|
/**
|
||||||
|
* 게시글 삭제 시, 해당 게시글에 종속된 모든 댓글을 삭제합니다.
|
||||||
|
*
|
||||||
|
* [데이터 무결성 관리]
|
||||||
|
* 게시글(Post)이 사라지면 댓글(Comment)은 고아 데이터(Orphaned Data)가 되므로
|
||||||
|
* 스토리지 낭비를 막고 참조 무결성을 유지하기 위해 함께 정리되어야 합니다.
|
||||||
|
*/
|
||||||
fun deleteAllByPost(post: Post)
|
fun deleteAllByPost(post: Post)
|
||||||
}
|
}
|
||||||
@@ -12,46 +12,64 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [댓글 비즈니스 로직]
|
||||||
|
*
|
||||||
|
* 게시글에 대한 사용자 반응(Interaction)을 처리하는 서비스입니다.
|
||||||
|
*
|
||||||
|
* [핵심 아키텍처: Hybrid Authentication]
|
||||||
|
* 사용자 참여율을 높이기 위해 로그인한 '회원'뿐만 아니라 '비회원(Guest)'의 활동도 허용합니다.
|
||||||
|
* 이에 따라 작성자 식별 및 권한 검증 로직이 이원화되어 처리됩니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class CommentService(
|
class CommentService(
|
||||||
private val commentRepository: CommentRepository,
|
private val commentRepository: CommentRepository,
|
||||||
private val postRepository: PostRepository,
|
private val postRepository: PostRepository,
|
||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val passwordEncoder: PasswordEncoder // 비밀번호 암호화용
|
private val passwordEncoder: PasswordEncoder
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Public] 특정 게시글의 댓글 목록 조회 (계층형)
|
* 특정 게시글의 댓글 목록을 계층형(Tree) 구조로 조회합니다.
|
||||||
|
*
|
||||||
|
* [조회 최적화 전략]
|
||||||
|
* DB에서 모든 댓글을 가져와 애플리케이션 메모리에서 트리를 구성하는 대신,
|
||||||
|
* 최상위(Root) 댓글만 조회하고 자식 댓글(Children)은 JPA의 관계 매핑과 @BatchSize를 통해
|
||||||
|
* 필요 시점에 효율적으로 로딩(Lazy Loading)하는 방식을 택했습니다.
|
||||||
*/
|
*/
|
||||||
fun getComments(postSlug: String): List<CommentResponse> {
|
fun getComments(postSlug: String): List<CommentResponse> {
|
||||||
val post = postRepository.findBySlug(postSlug)
|
val post = postRepository.findBySlug(postSlug)
|
||||||
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
||||||
|
|
||||||
// 최상위(부모가 null) 댓글만 가져오면, Entity 설정에 의해 자식들은 자동으로 딸려옴
|
// Root 댓글 조회 (자식들은 DTO 변환 과정에서 재귀적으로 호출됨)
|
||||||
val roots = commentRepository.findAllByPostAndParentIsNullOrderByCreatedAtAsc(post)
|
val roots = commentRepository.findAllByPostAndParentIsNullOrderByCreatedAtAsc(post)
|
||||||
|
|
||||||
return roots.map { CommentResponse.from(it) }
|
return roots.map { CommentResponse.from(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Hybrid] 댓글 작성 (회원/비회원 공용)
|
* 댓글을 작성합니다. (회원/비회원 통합 처리)
|
||||||
|
*
|
||||||
|
* 인증 정보(userEmail) 유무에 따라 도메인 로직이 분기됩니다.
|
||||||
|
* - 회원: Member 엔티티와 연관관계를 맺어 영구적인 식별을 보장합니다.
|
||||||
|
* - 비회원: 닉네임과 비밀번호를 별도 컬럼에 저장하여 최소한의 식별 및 제어 권한을 부여합니다.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createComment(request: CommentSaveRequest, userEmail: String?): Long {
|
fun createComment(request: CommentSaveRequest, userEmail: String?): Long {
|
||||||
// 1. 게시글 조회
|
// 1. 게시글 존재 확인
|
||||||
val post = postRepository.findBySlug(request.postSlug)
|
val post = postRepository.findBySlug(request.postSlug)
|
||||||
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
||||||
|
|
||||||
// 2. 부모 댓글 조회 (대댓글인 경우)
|
// 2. 부모 댓글 조회 (대댓글인 경우 검증)
|
||||||
val parent = request.parentId?.let {
|
val parent = request.parentId?.let {
|
||||||
commentRepository.findByIdOrNull(it)
|
commentRepository.findByIdOrNull(it)
|
||||||
?: throw IllegalArgumentException("부모 댓글이 존재하지 않습니다.")
|
?: throw IllegalArgumentException("부모 댓글이 존재하지 않습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 회원/비회원 구분 로직
|
// 3. 작성자 유형별 엔티티 생성 (Factory Logic)
|
||||||
val comment = if (userEmail != null) {
|
val comment = if (userEmail != null) {
|
||||||
// [회원] DB에서 회원 정보 조회 후 연결
|
// Case A: 회원 작성
|
||||||
val member = memberRepository.findByEmail(userEmail)
|
val member = memberRepository.findByEmail(userEmail)
|
||||||
?: throw IllegalArgumentException("회원 정보를 찾을 수 없습니다.")
|
?: throw IllegalArgumentException("회원 정보를 찾을 수 없습니다.")
|
||||||
|
|
||||||
@@ -59,10 +77,10 @@ class CommentService(
|
|||||||
content = request.content,
|
content = request.content,
|
||||||
post = post,
|
post = post,
|
||||||
parent = parent,
|
parent = parent,
|
||||||
member = member // 회원 연결
|
member = member
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// [비회원] 닉네임/비밀번호 필수 체크
|
// Case B: 비회원 작성 (익명성 보장하되, 제어권 확보를 위해 비밀번호 필수)
|
||||||
if (request.guestNickname.isNullOrBlank() || request.guestPassword.isNullOrBlank()) {
|
if (request.guestNickname.isNullOrBlank() || request.guestPassword.isNullOrBlank()) {
|
||||||
throw IllegalArgumentException("비회원은 닉네임과 비밀번호가 필수입니다.")
|
throw IllegalArgumentException("비회원은 닉네임과 비밀번호가 필수입니다.")
|
||||||
}
|
}
|
||||||
@@ -72,39 +90,53 @@ class CommentService(
|
|||||||
post = post,
|
post = post,
|
||||||
parent = parent,
|
parent = parent,
|
||||||
guestNickname = request.guestNickname,
|
guestNickname = request.guestNickname,
|
||||||
guestPassword = passwordEncoder.encode(request.guestPassword)
|
guestPassword = passwordEncoder.encode(request.guestPassword) // 보안상 단방향 암호화 저장
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 부모가 있다면 연결 (양방향 편의)
|
// 4. 연관관계 편의 메서드 (객체 그래프 정합성 유지)
|
||||||
parent?.children?.add(comment)
|
parent?.children?.add(comment)
|
||||||
|
|
||||||
return commentRepository.save(comment).id!!
|
return commentRepository.save(comment).id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글을 삭제합니다.
|
||||||
|
*
|
||||||
|
* [권한 검증 전략: Ownership Verification]
|
||||||
|
* 삭제 요청자가 실제 댓글 작성자인지 확인하는 로직입니다.
|
||||||
|
* 회원이라면 로그인 세션 정보를, 비회원이라면 작성 시 입력한 비밀번호를 검증 수단으로 사용합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteComment(commentId: Long, userEmail: String?, guestPassword: String?) {
|
fun deleteComment(commentId: Long, userEmail: String?, guestPassword: String?) {
|
||||||
val comment = commentRepository.findByIdOrNull(commentId)
|
val comment = commentRepository.findByIdOrNull(commentId)
|
||||||
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
|
?: throw IllegalArgumentException("존재하지 않는 댓글입니다.")
|
||||||
|
|
||||||
// 권한 검증
|
// 권한 검증 분기
|
||||||
if (userEmail != null) {
|
if (userEmail != null) {
|
||||||
// [회원] 본인 댓글인지 확인 (이메일 비교)
|
// Case A: 회원 (이메일 불일치 시 예외)
|
||||||
if (comment.member?.email != userEmail) {
|
if (comment.member?.email != userEmail) {
|
||||||
throw IllegalArgumentException("본인의 댓글만 삭제할 수 있습니다.")
|
throw IllegalArgumentException("본인의 댓글만 삭제할 수 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// [비회원] 비밀번호 일치 확인
|
// Case B: 비회원 (비밀번호 검증)
|
||||||
|
// DB에 저장된 해시값과 입력된 평문 비밀번호를 대조
|
||||||
if (comment.guestPassword == null || guestPassword == null ||
|
if (comment.guestPassword == null || guestPassword == null ||
|
||||||
!passwordEncoder.matches(guestPassword, comment.guestPassword)) {
|
!passwordEncoder.matches(guestPassword, comment.guestPassword)) {
|
||||||
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
|
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 삭제 진행
|
// 검증 통과 시 삭제 수행
|
||||||
commentRepository.delete(comment)
|
commentRepository.delete(comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [관리자 전용] 댓글 강제 삭제
|
||||||
|
*
|
||||||
|
* 악성 댓글이나 스팸 처리를 위해, 작성자 확인 절차(Ownership Check)를 건너뛰고
|
||||||
|
* 관리자 권한으로 즉시 데이터를 제거합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteCommentByAdmin(commentId: Long) {
|
fun deleteCommentByAdmin(commentId: Long) {
|
||||||
val comment = commentRepository.findByIdOrNull(commentId)
|
val comment = commentRepository.findByIdOrNull(commentId)
|
||||||
@@ -113,6 +145,10 @@ class CommentService(
|
|||||||
commentRepository.delete(comment)
|
commentRepository.delete(comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 대시보드용 전체 댓글 조회
|
||||||
|
* 계층 구조와 무관하게 시간순으로 페이징하여 모니터링 편의성을 제공합니다.
|
||||||
|
*/
|
||||||
fun getAllComments(pageable: Pageable): Page<AdminCommentResponse> {
|
fun getAllComments(pageable: Pageable): Page<AdminCommentResponse> {
|
||||||
return commentRepository.findAll(pageable)
|
return commentRepository.findAll(pageable)
|
||||||
.map { AdminCommentResponse.from(it) }
|
.map { AdminCommentResponse.from(it) }
|
||||||
|
|||||||
@@ -8,14 +8,31 @@ import org.springframework.data.annotation.LastModifiedDate
|
|||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@MappedSuperclass // 상속받은 엔티티가 이 클래스의 필드(컬럼)를 인식하도록 함
|
/**
|
||||||
@EntityListeners(AuditingEntityListener::class) // JPA Auditing 기능 활성화
|
* [공통 시간 정보 엔티티]
|
||||||
|
*
|
||||||
|
* 모든 엔티티가 공통적으로 가져야 할 '생성 시간'과 '수정 시간'을 관리하는 상위 클래스입니다.
|
||||||
|
*
|
||||||
|
* [설계 의도]
|
||||||
|
* 반복적인 감사(Audit) 로직을 중복 구현하는 것을 방지하기 위해 JPA Auditing 기능을 적용했습니다.
|
||||||
|
* 이를 상속받는 엔티티들은 별도의 코드 작성 없이 데이터의 생명주기를 자동으로 추적할 수 있습니다.
|
||||||
|
*/
|
||||||
|
@MappedSuperclass // 테이블로 매핑되지 않고, 자식 클래스의 엔티티에 컬럼 정보만 제공함 (상속 관계 매핑 X)
|
||||||
|
@EntityListeners(AuditingEntityListener::class) // 엔티티의 변경 이벤트를 감지하여 시간 값을 자동으로 주입(Inject)
|
||||||
abstract class BaseTimeEntity {
|
abstract class BaseTimeEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최초 생성 시각 (Immutable)
|
||||||
|
* 데이터의 이력을 추적하는 기준이 되므로, 생성 이후에는 절대 변경되지 않도록 updatable = false를 설정하여 무결성을 보장합니다.
|
||||||
|
*/
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(nullable = false, updatable = false) // 생성일은 수정 불가
|
@Column(nullable = false, updatable = false)
|
||||||
var createdAt: LocalDateTime = LocalDateTime.now()
|
var createdAt: LocalDateTime = LocalDateTime.now()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최종 수정 시각
|
||||||
|
* 비즈니스 로직에 의해 데이터가 변경될 때마다 JPA가 자동으로 현재 시간을 갱신합니다.
|
||||||
|
*/
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||||
|
|||||||
@@ -9,24 +9,42 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest
|
|||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [이미지 처리 서비스]
|
||||||
|
*
|
||||||
|
* AWS S3 또는 호환 가능한 Object Storage(MinIO 등)와의 통신을 전담하는 서비스입니다.
|
||||||
|
* 비즈니스 로직(게시글 작성 등)에서 파일 저장에 대한 세부 구현을 몰라도 되도록
|
||||||
|
* 업로드 및 삭제 기능을 추상화하여 제공합니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
class ImageService(
|
class ImageService(
|
||||||
private val s3Client: S3Client,
|
private val s3Client: S3Client,
|
||||||
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
|
@Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String
|
||||||
) {
|
) {
|
||||||
private val bucketName = "blog-images" // 버킷 이름
|
private val bucketName = "blog-images"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 초기화 시점에 버킷 존재 여부를 확인합니다.
|
||||||
|
* 로컬 개발 환경이나 초기 배포 시, 수동으로 스토리지를 세팅하는 번거로움을 줄이기 위해
|
||||||
|
* 애플리케이션 레벨에서 인프라(Bucket & Policy)를 자동 프로비저닝(Auto-Provisioning)합니다.
|
||||||
|
*/
|
||||||
init {
|
init {
|
||||||
createBucketIfNotExists()
|
createBucketIfNotExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지를 스토리지에 업로드하고 접근 가능한 URL을 반환합니다.
|
||||||
|
*
|
||||||
|
* [파일명 생성 전략]
|
||||||
|
* 사용자가 업로드한 원본 파일명은 중복될 가능성이 높으므로,
|
||||||
|
* UUID(Universally Unique Identifier)를 사용하여 고유한 식별자를 생성함으로써 덮어쓰기(Overwrite)를 방지합니다.
|
||||||
|
*/
|
||||||
fun uploadImage(file: MultipartFile): String {
|
fun uploadImage(file: MultipartFile): String {
|
||||||
// 1. 파일명 중복 방지 (UUID 사용)
|
|
||||||
val originalName = file.originalFilename ?: "image.jpg"
|
val originalName = file.originalFilename ?: "image.jpg"
|
||||||
val ext = originalName.substringAfterLast(".", "jpg")
|
val ext = originalName.substringAfterLast(".", "jpg")
|
||||||
val fileName = "${UUID.randomUUID()}.$ext"
|
val fileName = "${UUID.randomUUID()}.$ext"
|
||||||
|
|
||||||
// 2. S3(MinIO)로 업로드
|
// 메타데이터(ContentType)를 명시하여 브라우저에서 올바르게 렌더링되도록 설정
|
||||||
val putObjectRequest = PutObjectRequest.builder()
|
val putObjectRequest = PutObjectRequest.builder()
|
||||||
.bucket(bucketName)
|
.bucket(bucketName)
|
||||||
.key(fileName)
|
.key(fileName)
|
||||||
@@ -35,11 +53,17 @@ class ImageService(
|
|||||||
|
|
||||||
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size))
|
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size))
|
||||||
|
|
||||||
// 3. 접속 가능한 URL 반환
|
// 클라이언트가 즉시 접근할 수 있는 절대 경로(URL) 반환
|
||||||
return "$endpoint/$bucketName/$fileName"
|
return "$endpoint/$bucketName/$fileName"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 👈 [추가] 이미지 삭제 로직
|
/**
|
||||||
|
* 스토리지에서 이미지를 삭제합니다.
|
||||||
|
*
|
||||||
|
* [Fail-Safe 전략]
|
||||||
|
* 이미지 삭제 실패가 비즈니스 트랜잭션(예: 게시글 삭제)의 실패로 이어지지 않도록 예외를 내부에서 소비(Swallow)합니다.
|
||||||
|
* 고아 객체(Orphaned Object)가 남더라도 메인 데이터의 정합성을 우선시하는 설계입니다.
|
||||||
|
*/
|
||||||
fun deleteImage(fileName: String) {
|
fun deleteImage(fileName: String) {
|
||||||
try {
|
try {
|
||||||
val deleteObjectRequest = DeleteObjectRequest.builder()
|
val deleteObjectRequest = DeleteObjectRequest.builder()
|
||||||
@@ -49,20 +73,25 @@ class ImageService(
|
|||||||
|
|
||||||
s3Client.deleteObject(deleteObjectRequest)
|
s3Client.deleteObject(deleteObjectRequest)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 이미지가 이미 없거나 삭제 실패 시 로그만 남기고 진행 (게시글 삭제 자체를 막지 않기 위해)
|
e.printStackTrace() // 실제 운영 시에는 Error Log 레벨로 기록하여 추후 배치 작업 등으로 정리 필요
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [버킷 초기화 로직]
|
||||||
|
* 버킷이 없을 경우 생성하고, 웹에서 이미지를 조회할 수 있도록 'Public Read' 권한 정책을 주입합니다.
|
||||||
|
*/
|
||||||
private fun createBucketIfNotExists() {
|
private fun createBucketIfNotExists() {
|
||||||
try {
|
try {
|
||||||
// 버킷 존재 여부 확인 (없으면 에러 발생하므로 try-catch)
|
// 버킷 존재 여부 확인 (Head Bucket)
|
||||||
s3Client.headBucket { it.bucket(bucketName) }
|
s3Client.headBucket { it.bucket(bucketName) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 버킷 생성
|
// 버킷 생성
|
||||||
s3Client.createBucket { it.bucket(bucketName) }
|
s3Client.createBucket { it.bucket(bucketName) }
|
||||||
|
|
||||||
// ⭐ 버킷을 Public(공개)으로 설정 (이미지 조회를 위해 필수)
|
// [접근 제어 정책 설정]
|
||||||
|
// 외부 사용자가 URL을 통해 이미지(Object)를 조회(GetObject)할 수 있도록
|
||||||
|
// 버킷 정책(Bucket Policy)을 JSON 형태로 정의하여 적용합니다.
|
||||||
val policy = """
|
val policy = """
|
||||||
{
|
{
|
||||||
"Version": "2012-10-17",
|
"Version": "2012-10-17",
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
|||||||
import me.wypark.blogbackend.domain.tag.PostTag
|
import me.wypark.blogbackend.domain.tag.PostTag
|
||||||
import me.wypark.blogbackend.domain.user.Member
|
import me.wypark.blogbackend.domain.user.Member
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [게시글 엔티티]
|
||||||
|
*
|
||||||
|
* 블로그의 핵심 콘텐츠인 게시글(Post) 데이터를 정의하는 도메인 모델입니다.
|
||||||
|
*
|
||||||
|
* [설계 의도]
|
||||||
|
* - Setter 사용을 지양하고, 비즈니스 의미가 명확한 편의 메서드(update, addTags 등)를 통해 상태를 변경하도록 설계하여
|
||||||
|
* 객체의 일관성(Consistency)과 코드의 응집도(Cohesion)를 높였습니다.
|
||||||
|
* - 조회수(viewCount)와 같은 동시성 처리가 필요한 필드는 별도의 증가 메서드로 관리합니다.
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
class Post(
|
class Post(
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@@ -28,6 +38,12 @@ class Post(
|
|||||||
@JoinColumn(name = "category_id")
|
@JoinColumn(name = "category_id")
|
||||||
var category: Category? = null,
|
var category: Category? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [태그 매핑 전략]
|
||||||
|
* PostTag 엔티티와의 일대다 관계를 통해 태그 정보를 관리합니다.
|
||||||
|
* 게시글이 삭제되거나 수정될 때 태그 연결 정보도 함께 정리되어야 하므로
|
||||||
|
* CascadeType.ALL과 orphanRemoval=true 옵션을 사용하여 생명주기를 동기화했습니다.
|
||||||
|
*/
|
||||||
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
|
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||||
val tags: MutableList<PostTag> = mutableListOf()
|
val tags: MutableList<PostTag> = mutableListOf()
|
||||||
) : BaseTimeEntity() {
|
) : BaseTimeEntity() {
|
||||||
@@ -35,6 +51,11 @@ class Post(
|
|||||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
val id: Long? = null
|
val id: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회수를 1 증가시킵니다.
|
||||||
|
* Note: 높은 트래픽 환경에서는 DB Lock 경합이 발생할 수 있으므로,
|
||||||
|
* Redis HyperLogLog 등을 활용한 캐싱 후 배치 업데이트(Write-Back) 전략을 고려할 수 있습니다.
|
||||||
|
*/
|
||||||
fun increaseViewCount() {
|
fun increaseViewCount() {
|
||||||
this.viewCount++
|
this.viewCount++
|
||||||
}
|
}
|
||||||
@@ -43,7 +64,12 @@ class Post(
|
|||||||
this.tags.addAll(postTags)
|
this.tags.addAll(postTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 👈 [추가] 게시글 수정 메서드
|
/**
|
||||||
|
* [게시글 수정 편의 메서드]
|
||||||
|
*
|
||||||
|
* 제목, 본문, 슬러그, 카테고리 등 주요 필드를 한 번에 업데이트합니다.
|
||||||
|
* JPA의 변경 감지(Dirty Checking) 기능에 의해 트랜잭션 종료 시점에 자동으로 Update 쿼리가 실행됩니다.
|
||||||
|
*/
|
||||||
fun update(title: String, content: String, slug: String, category: Category?) {
|
fun update(title: String, content: String, slug: String, category: Category?) {
|
||||||
this.title = title
|
this.title = title
|
||||||
this.content = content
|
this.content = content
|
||||||
@@ -51,9 +77,15 @@ class Post(
|
|||||||
this.category = category
|
this.category = category
|
||||||
}
|
}
|
||||||
|
|
||||||
// 👈 [추가] 태그 전체 교체 편의 메서드
|
/**
|
||||||
|
* [태그 전체 교체 로직]
|
||||||
|
*
|
||||||
|
* 기존 태그 목록을 모두 비우고(clear) 새로운 태그들로 대체합니다.
|
||||||
|
* orphanRemoval = true 설정에 의해, 컬렉션에서 제거된 기존 PostTag 엔티티들은
|
||||||
|
* DB에서도 자동으로 삭제(DELETE) 처리됩니다.
|
||||||
|
*/
|
||||||
fun updateTags(newTags: List<PostTag>) {
|
fun updateTags(newTags: List<PostTag>) {
|
||||||
this.tags.clear() // orphanRemoval = true 덕분에 기존 태그 매핑이 삭제됨
|
this.tags.clear()
|
||||||
this.tags.addAll(newTags)
|
this.tags.addAll(newTags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,28 +8,63 @@ import org.springframework.data.jpa.repository.Modifying
|
|||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
import org.springframework.data.repository.query.Param
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [게시글 데이터 접근 계층]
|
||||||
|
*
|
||||||
|
* 게시글(Post) 엔티티의 영속성을 관리하며,
|
||||||
|
* 검색(Search), 필터링(Filter), 대량 수정(Bulk Update) 등의 다양한 DB 조작을 수행합니다.
|
||||||
|
* 복잡한 동적 쿼리는 PostRepositoryCustom(QueryDSL)을 통해 처리합니다.
|
||||||
|
*/
|
||||||
interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom {
|
interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom {
|
||||||
|
|
||||||
// 1. Slug로 상세 조회 (URL이 깔끔해짐)
|
/**
|
||||||
|
* URL 친화적인 식별자(Slug)로 게시글을 단건 조회합니다.
|
||||||
|
* 숫자 ID 대신 의미 있는 문자열을 사용하여 검색 엔진 최적화(SEO)와 사용자 경험(UX)을 향상시킵니다.
|
||||||
|
*/
|
||||||
fun findBySlug(slug: String): Post?
|
fun findBySlug(slug: String): Post?
|
||||||
|
|
||||||
// 2. Slug 중복 검사 (글 작성/수정 시 필수)
|
/**
|
||||||
|
* Slug의 유일성(Uniqueness)을 검증합니다.
|
||||||
|
* 게시글 작성/수정 시 중복된 Slug가 발생하지 않도록 사전에 확인하는 용도입니다.
|
||||||
|
*/
|
||||||
fun existsBySlug(slug: String): Boolean
|
fun existsBySlug(slug: String): Boolean
|
||||||
|
|
||||||
// 3. 페이징된 목록 조회 (최신순 등은 Pageable로 해결)
|
/**
|
||||||
|
* 기본 페이징 조회 메서드를 오버라이드합니다.
|
||||||
|
* 최신순, 조회순 등 다양한 정렬 기준은 Pageable 객체에 담겨 전달됩니다.
|
||||||
|
*/
|
||||||
override fun findAll(pageable: Pageable): Page<Post>
|
override fun findAll(pageable: Pageable): Page<Post>
|
||||||
|
|
||||||
// 4. 특정 카테고리의 글 목록 조회
|
/**
|
||||||
|
* 특정 카테고리에 속한 게시글 목록을 페이징하여 조회합니다.
|
||||||
|
*/
|
||||||
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
|
fun findAllByCategory(category: Category, pageable: Pageable): Page<Post>
|
||||||
|
|
||||||
// 5. 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리)
|
/**
|
||||||
|
* [벌크 연산 최적화]
|
||||||
|
*
|
||||||
|
* 카테고리 삭제 시, 해당 카테고리에 속했던 게시글들을 일일이 조회하여 수정(Dirty Checking)하는 것은 비효율적입니다.
|
||||||
|
* 따라서 단 한 번의 UPDATE 쿼리로 '미분류(NULL)' 처리를 수행하여 성능을 극대화합니다.
|
||||||
|
*
|
||||||
|
* @Modifying(clearAutomatically = true):
|
||||||
|
* 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날리므로,
|
||||||
|
* 실행 후 1차 캐시와 DB의 데이터 불일치를 막기 위해 자동으로 캐시를 비웁니다.
|
||||||
|
*/
|
||||||
@Modifying(clearAutomatically = true)
|
@Modifying(clearAutomatically = true)
|
||||||
@Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories")
|
@Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories")
|
||||||
fun bulkUpdateCategoryToNull(@Param("categories") categories: List<Category>)
|
fun bulkUpdateCategoryToNull(@Param("categories") categories: List<Category>)
|
||||||
|
|
||||||
// 6. [추가] 이전 글 조회 (현재 ID보다 작은 것 중 가장 큰 ID = 바로 이전 과거 글)
|
/**
|
||||||
|
* [이전 글 조회]
|
||||||
|
* 현재 글(ID)보다 작으면서(Less Than) 가장 큰 ID를 가진 레코드를 찾습니다.
|
||||||
|
* (즉, 바로 직전에 작성된 글)
|
||||||
|
*/
|
||||||
fun findFirstByIdLessThanOrderByIdDesc(id: Long): Post?
|
fun findFirstByIdLessThanOrderByIdDesc(id: Long): Post?
|
||||||
|
|
||||||
// 7. [추가] 다음 글 조회 (현재 ID보다 큰 것 중 가장 작은 ID = 바로 다음 최신 글)
|
/**
|
||||||
|
* [다음 글 조회]
|
||||||
|
* 현재 글(ID)보다 크면서(Greater Than) 가장 작은 ID를 가진 레코드를 찾습니다.
|
||||||
|
* (즉, 바로 직후에 작성된 글)
|
||||||
|
*/
|
||||||
fun findFirstByIdGreaterThanOrderByIdAsc(id: Long): Post?
|
fun findFirstByIdGreaterThanOrderByIdAsc(id: Long): Post?
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,32 @@ import me.wypark.blogbackend.api.dto.PostSummaryResponse
|
|||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [게시글 동적 쿼리(Dynamic Query) 인터페이스]
|
||||||
|
*
|
||||||
|
* QueryDSL을 기반으로 복잡한 검색 및 필터링 로직을 수행하기 위한 커스텀 리포지토리 인터페이스입니다.
|
||||||
|
* 정적 메서드(Method Name Query)만으로는 처리하기 힘든 다중 조건 조합과
|
||||||
|
* DTO 프로젝션(Projection)을 담당합니다.
|
||||||
|
*/
|
||||||
interface PostRepositoryCustom {
|
interface PostRepositoryCustom {
|
||||||
// categoryName(String) -> categoryNames(List<String>) 변경
|
|
||||||
fun search(keyword: String?, categoryNames: List<String>?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse>
|
/**
|
||||||
|
* 게시글을 다양한 조건으로 검색하고 페이징 처리된 요약 정보를 반환합니다.
|
||||||
|
*
|
||||||
|
* [검색 필터 전략]
|
||||||
|
* - 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>
|
||||||
}
|
}
|
||||||
@@ -10,21 +10,37 @@ import me.wypark.blogbackend.api.dto.PostSummaryResponse
|
|||||||
import me.wypark.blogbackend.domain.post.QPost.post
|
import me.wypark.blogbackend.domain.post.QPost.post
|
||||||
import me.wypark.blogbackend.domain.tag.QPostTag.postTag
|
import me.wypark.blogbackend.domain.tag.QPostTag.postTag
|
||||||
import me.wypark.blogbackend.domain.tag.QTag.tag
|
import me.wypark.blogbackend.domain.tag.QTag.tag
|
||||||
import me.wypark.blogbackend.domain.category.QCategory.category // 👈 QCategory import 추가
|
import me.wypark.blogbackend.domain.category.QCategory.category
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.PageImpl
|
import org.springframework.data.domain.PageImpl
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [QueryDSL 리포지토리 구현체]
|
||||||
|
*
|
||||||
|
* PostRepositoryCustom 인터페이스를 구현하여 복잡한 동적 쿼리를 처리합니다.
|
||||||
|
* 컴파일 타임에 문법 오류를 잡을 수 있는 QueryDSL을 사용하여,
|
||||||
|
* 다중 필터링 조건과 조인(Join) 로직을 안전하고 직관적으로 작성했습니다.
|
||||||
|
*/
|
||||||
class PostRepositoryImpl(
|
class PostRepositoryImpl(
|
||||||
private val queryFactory: JPAQueryFactory
|
private val queryFactory: JPAQueryFactory
|
||||||
) : PostRepositoryCustom {
|
) : 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> {
|
override fun search(keyword: String?, categoryNames: List<String>?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
|
||||||
val builder = BooleanBuilder()
|
val builder = BooleanBuilder()
|
||||||
builder.and(containsKeyword(keyword))
|
builder.and(containsKeyword(keyword))
|
||||||
|
|
||||||
// "uncategorized" (또는 "미분류") 요청 시 post.category.isNull 조건으로 변환하여 처리
|
|
||||||
builder.and(inCategoryNames(categoryNames))
|
builder.and(inCategoryNames(categoryNames))
|
||||||
|
|
||||||
builder.and(eqTagName(tagName))
|
builder.and(eqTagName(tagName))
|
||||||
|
|
||||||
val query = queryFactory
|
val query = queryFactory
|
||||||
@@ -34,32 +50,36 @@ class PostRepositoryImpl(
|
|||||||
post.id,
|
post.id,
|
||||||
post.title,
|
post.title,
|
||||||
post.slug,
|
post.slug,
|
||||||
category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전)
|
category.name, // QCategory Alias 사용으로 Null-Safe 처리
|
||||||
post.viewCount,
|
post.viewCount,
|
||||||
post.createdAt,
|
post.createdAt,
|
||||||
post.updatedAt,
|
post.updatedAt,
|
||||||
post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다.
|
post.content // 본문 미리보기용 데이터
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(post)
|
.from(post)
|
||||||
.leftJoin(post.category, category) // 👈 명시적 Left Join 추가 (카테고리 없어도 글 조회 가능하게 함)
|
.leftJoin(post.category, category) // 카테고리 미지정 글 포함
|
||||||
.leftJoin(post.tags, postTag)
|
.leftJoin(post.tags, postTag) // 태그 미지정 글 포함
|
||||||
.leftJoin(postTag.tag, tag)
|
.leftJoin(postTag.tag, tag)
|
||||||
.where(builder)
|
.where(builder)
|
||||||
.distinct()
|
.distinct()
|
||||||
.offset(pageable.offset)
|
.offset(pageable.offset)
|
||||||
.limit(pageable.pageSize.toLong())
|
.limit(pageable.pageSize.toLong())
|
||||||
|
|
||||||
|
// 동적 정렬 적용
|
||||||
for (order in getOrderSpecifiers(pageable)) {
|
for (order in getOrderSpecifiers(pageable)) {
|
||||||
query.orderBy(order)
|
query.orderBy(order)
|
||||||
}
|
}
|
||||||
|
|
||||||
val content = query.fetch()
|
val content = query.fetch()
|
||||||
|
|
||||||
|
// [Count 쿼리 분리]
|
||||||
|
// 페이징을 위한 전체 개수 조회 시, 데이터 조회 쿼리보다 단순화할 수 있는 여지가 있다면
|
||||||
|
// 별도의 쿼리로 분리하여 성능을 최적화하는 것이 좋습니다.
|
||||||
val total = queryFactory
|
val total = queryFactory
|
||||||
.select(post.countDistinct())
|
.select(post.countDistinct())
|
||||||
.from(post)
|
.from(post)
|
||||||
.leftJoin(post.category, category) // 👈 Count 쿼리에도 Left Join 추가
|
.leftJoin(post.category, category)
|
||||||
.leftJoin(post.tags, postTag)
|
.leftJoin(post.tags, postTag)
|
||||||
.leftJoin(postTag.tag, tag)
|
.leftJoin(postTag.tag, tag)
|
||||||
.where(builder)
|
.where(builder)
|
||||||
@@ -71,42 +91,49 @@ class PostRepositoryImpl(
|
|||||||
private fun containsKeyword(keyword: String?): BooleanBuilder {
|
private fun containsKeyword(keyword: String?): BooleanBuilder {
|
||||||
val builder = BooleanBuilder()
|
val builder = BooleanBuilder()
|
||||||
if (!keyword.isNullOrBlank()) {
|
if (!keyword.isNullOrBlank()) {
|
||||||
|
// 제목 또는 본문에 키워드가 포함되는지 검사 (OR 조건)
|
||||||
builder.or(post.title.containsIgnoreCase(keyword))
|
builder.or(post.title.containsIgnoreCase(keyword))
|
||||||
builder.or(post.content.containsIgnoreCase(keyword))
|
builder.or(post.content.containsIgnoreCase(keyword))
|
||||||
}
|
}
|
||||||
return builder
|
return builder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 다중 필터링 조건 생성
|
||||||
|
*
|
||||||
|
* [미분류(Uncategorized) 처리 전략]
|
||||||
|
* 클라이언트로부터 "uncategorized" 요청이 오면 DB상의 NULL 값과 매핑해야 합니다.
|
||||||
|
* 일반 카테고리(IN 절)와 미분류(IS NULL) 조건이 혼재될 경우, 이를 유연하게 OR 연산으로 묶어 처리합니다.
|
||||||
|
*/
|
||||||
private fun inCategoryNames(categoryNames: List<String>?): BooleanExpression? {
|
private fun inCategoryNames(categoryNames: List<String>?): BooleanExpression? {
|
||||||
if (categoryNames.isNullOrEmpty()) return null
|
if (categoryNames.isNullOrEmpty()) return null
|
||||||
|
|
||||||
// 1. 요청에 "uncategorized" 또는 "미분류"가 포함되어 있는지 확인
|
// 1. 특수 키워드 체크 ("uncategorized", "미분류")
|
||||||
// (프론트엔드에서 한글로 "미분류"를 보내는 경우가 많으므로 둘 다 체크)
|
|
||||||
val hasUncategorized = categoryNames.any {
|
val hasUncategorized = categoryNames.any {
|
||||||
it.equals("uncategorized", ignoreCase = true) || it.equals("미분류", ignoreCase = true)
|
it.equals("uncategorized", ignoreCase = true) || it.equals("미분류", ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 그 외 일반 카테고리 이름들만 따로 추림
|
// 2. 일반 카테고리명 추출
|
||||||
val normalNames = categoryNames.filter {
|
val normalNames = categoryNames.filter {
|
||||||
!it.equals("uncategorized", ignoreCase = true) && !it.equals("미분류", ignoreCase = true)
|
!it.equals("uncategorized", ignoreCase = true) && !it.equals("미분류", ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
var expression: BooleanExpression? = null
|
var expression: BooleanExpression? = null
|
||||||
|
|
||||||
// A. 일반 카테고리 이름 조건 (IN 절)
|
// A. 일반 카테고리 조건 (IN Clause)
|
||||||
if (normalNames.isNotEmpty()) {
|
if (normalNames.isNotEmpty()) {
|
||||||
expression = category.name.`in`(normalNames) // 👈 alias 사용
|
expression = category.name.`in`(normalNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
// B. uncategorized 조건 (IS NULL) 추가
|
// B. 미분류 조건 (IS NULL) 결합
|
||||||
if (hasUncategorized) {
|
if (hasUncategorized) {
|
||||||
val isNullExpr = post.category.isNull // FK가 NULL인지 확인
|
val isNullExpr = post.category.isNull
|
||||||
|
|
||||||
expression = if (expression != null) {
|
expression = if (expression != null) {
|
||||||
// (일반 카테고리들) OR (카테고리 없음) -> 둘 중 하나라도 만족하면 조회
|
// (일반 카테고리들) OR (미분류) -> 둘 중 하나라도 만족하면 조회
|
||||||
expression.or(isNullExpr)
|
expression.or(isNullExpr)
|
||||||
} else {
|
} else {
|
||||||
// (카테고리 없음) -> 카테고리 없는 글만 모아서 조회
|
// 오직 미분류 글만 조회
|
||||||
isNullExpr
|
isNullExpr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +146,10 @@ class PostRepositoryImpl(
|
|||||||
return tag.name.eq(tagName)
|
return tag.name.eq(tagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pageable의 Sort 객체를 QueryDSL의 OrderSpecifier로 변환
|
||||||
|
* 문자열 필드명을 실제 Q-Type 필드로 매핑하여 런타임 에러를 방지합니다.
|
||||||
|
*/
|
||||||
private fun getOrderSpecifiers(pageable: Pageable): List<OrderSpecifier<*>> {
|
private fun getOrderSpecifiers(pageable: Pageable): List<OrderSpecifier<*>> {
|
||||||
val orders = mutableListOf<OrderSpecifier<*>>()
|
val orders = mutableListOf<OrderSpecifier<*>>()
|
||||||
|
|
||||||
@@ -129,7 +160,7 @@ class PostRepositoryImpl(
|
|||||||
"viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount))
|
"viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount))
|
||||||
"createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt))
|
"createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt))
|
||||||
"id" -> orders.add(OrderSpecifier(direction, post.id))
|
"id" -> orders.add(OrderSpecifier(direction, post.id))
|
||||||
else -> orders.add(OrderSpecifier(Order.DESC, post.id))
|
else -> orders.add(OrderSpecifier(Order.DESC, post.id)) // 기본 정렬
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ import org.springframework.data.repository.findByIdOrNull
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [게시글 비즈니스 로직]
|
||||||
|
*
|
||||||
|
* 게시글(Post)의 생명주기(Lifecycle) 전반을 관리하는 서비스입니다.
|
||||||
|
* 단순 CRUD 외에도 다음과 같은 중요한 정책들을 수행합니다.
|
||||||
|
*
|
||||||
|
* 1. 리소스 정리: 게시글 수정/삭제 시 본문에서 제외된 이미지를 S3에서 물리적으로 삭제하여 스토리지 비용을 최적화합니다.
|
||||||
|
* 2. URL 전략: 검색 엔진 최적화(SEO)를 위해 중복되지 않는 고유한 Slug를 생성하고 관리합니다.
|
||||||
|
* 3. 검색 확장: 카테고리 검색 시 하위 카테고리의 글까지 포함하여 조회하는 재귀적 검색 로직을 제공합니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class PostService(
|
class PostService(
|
||||||
@@ -26,11 +36,22 @@ class PostService(
|
|||||||
private val imageService: ImageService
|
private val imageService: ImageService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 게시글 목록을 조회합니다.
|
||||||
|
* 목록 뷰에서는 본문 전체가 필요 없으므로, 경량화된 DTO(Summary)로 변환하여 트래픽을 절감합니다.
|
||||||
|
*/
|
||||||
fun getPosts(pageable: Pageable): Page<PostSummaryResponse> {
|
fun getPosts(pageable: Pageable): Page<PostSummaryResponse> {
|
||||||
return postRepository.findAll(pageable)
|
return postRepository.findAll(pageable)
|
||||||
.map { PostSummaryResponse.from(it) }
|
.map { PostSummaryResponse.from(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글 상세 정보를 조회합니다.
|
||||||
|
*
|
||||||
|
* [부가 로직]
|
||||||
|
* 1. 조회수 증가: 상세 조회 시 조회수 카운트를 원자적(Atomic)으로 증가시킵니다.
|
||||||
|
* 2. 인접 게시글 탐색: 사용자의 탐색 연속성(UX)을 위해 현재 글을 기준으로 이전/다음 글의 메타데이터를 함께 반환합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun getPostBySlug(slug: String): PostResponse {
|
fun getPostBySlug(slug: String): PostResponse {
|
||||||
val post = postRepository.findBySlug(slug)
|
val post = postRepository.findBySlug(slug)
|
||||||
@@ -38,15 +59,21 @@ class PostService(
|
|||||||
|
|
||||||
post.increaseViewCount()
|
post.increaseViewCount()
|
||||||
|
|
||||||
// 👈 [추가] 이전/다음 게시글 조회
|
// 인접 게시글 조회 (Prev/Next Navigation)
|
||||||
// prevPost: 현재 글보다 ID가 작으면서 가장 가까운 글 (과거 글)
|
// ID를 기준으로 정렬하여 바로 앞/뒤의 게시글을 1건씩 조회합니다.
|
||||||
val prevPost = postRepository.findFirstByIdLessThanOrderByIdDesc(post.id!!)
|
val prevPost = postRepository.findFirstByIdLessThanOrderByIdDesc(post.id!!)
|
||||||
// nextPost: 현재 글보다 ID가 크면서 가장 가까운 글 (최신 글)
|
|
||||||
val nextPost = postRepository.findFirstByIdGreaterThanOrderByIdAsc(post.id!!)
|
val nextPost = postRepository.findFirstByIdGreaterThanOrderByIdAsc(post.id!!)
|
||||||
|
|
||||||
return PostResponse.from(post, prevPost, nextPost)
|
return PostResponse.from(post, prevPost, nextPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 게시글을 생성합니다.
|
||||||
|
*
|
||||||
|
* [Slug 생성 전략]
|
||||||
|
* 사용자가 Slug를 직접 입력하지 않은 경우 제목을 기반으로 생성하며,
|
||||||
|
* 중복 발생 시 숫자를 붙여(suffix) 유일성을 보장하는 재귀적/반복적 로직을 수행합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createPost(request: PostSaveRequest, email: String): Long {
|
fun createPost(request: PostSaveRequest, email: String): Long {
|
||||||
val member = memberRepository.findByEmail(email)
|
val member = memberRepository.findByEmail(email)
|
||||||
@@ -54,7 +81,7 @@ class PostService(
|
|||||||
|
|
||||||
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
|
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
|
||||||
|
|
||||||
// Slug 생성 로직
|
// SEO Friendly URL 생성을 위한 Slug 중복 검사 및 생성
|
||||||
val uniqueSlug = generateUniqueSlug(request.slug, request.title)
|
val uniqueSlug = generateUniqueSlug(request.slug, request.title)
|
||||||
|
|
||||||
val post = Post(
|
val post = Post(
|
||||||
@@ -65,49 +92,64 @@ class PostService(
|
|||||||
category = category
|
category = category
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 태그 처리: 기존 태그는 재사용, 없는 태그는 신규 생성 (Find or Create)
|
||||||
val postTags = resolveTags(request.tags, post)
|
val postTags = resolveTags(request.tags, post)
|
||||||
post.addTags(postTags)
|
post.addTags(postTags)
|
||||||
|
|
||||||
return postRepository.save(post).id!!
|
return postRepository.save(post).id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
// 게시글 수정
|
/**
|
||||||
|
* 게시글 정보를 수정합니다.
|
||||||
|
*
|
||||||
|
* [이미지 가비지 컬렉션 (GC)]
|
||||||
|
* 본문 수정 과정에서 삭제된 이미지 태그를 감지하여, 실제 스토리지(S3)에서도 파일을 삭제합니다.
|
||||||
|
* 이를 통해 DB와 스토리지 간의 데이터 불일치를 방지하고 불필요한 비용 발생을 억제합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updatePost(id: Long, request: PostSaveRequest): Long {
|
fun updatePost(id: Long, request: PostSaveRequest): Long {
|
||||||
val post = postRepository.findByIdOrNull(id)
|
val post = postRepository.findByIdOrNull(id)
|
||||||
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
||||||
|
|
||||||
// 1. 이미지 정리: (기존 본문 이미지) - (새 본문 이미지) = 삭제 대상
|
// 1. 고아 이미지 정리: (수정 전 이미지 목록 - 수정 후 이미지 목록)
|
||||||
val oldImages = extractImageNamesFromContent(post.content)
|
val oldImages = extractImageNamesFromContent(post.content)
|
||||||
val newImages = extractImageNamesFromContent(request.content)
|
val newImages = extractImageNamesFromContent(request.content)
|
||||||
val removedImages = oldImages - newImages.toSet()
|
val removedImages = oldImages - newImages.toSet()
|
||||||
|
|
||||||
removedImages.forEach { imageService.deleteImage(it) }
|
removedImages.forEach { imageService.deleteImage(it) }
|
||||||
|
|
||||||
// 2. 카테고리 조회
|
// 2. 카테고리 정보 갱신
|
||||||
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
|
val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) }
|
||||||
|
|
||||||
// 3. Slug 갱신 (변경 요청이 있고, 기존과 다를 경우에만)
|
// 3. Slug 갱신 (변경 요청 시에만 수행하여 불필요한 URL 변경 방지)
|
||||||
var newSlug = post.slug
|
var newSlug = post.slug
|
||||||
if (!request.slug.isNullOrBlank() && request.slug != post.slug) {
|
if (!request.slug.isNullOrBlank() && request.slug != post.slug) {
|
||||||
newSlug = generateUniqueSlug(request.slug, request.title)
|
newSlug = generateUniqueSlug(request.slug, request.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 정보 업데이트
|
// 4. 게시글 메타데이터 업데이트 (Dirty Checking)
|
||||||
post.update(request.title, request.content, newSlug, category)
|
post.update(request.title, request.content, newSlug, category)
|
||||||
|
|
||||||
// 5. 태그 업데이트
|
// 5. 태그 매핑 재설정
|
||||||
val newPostTags = resolveTags(request.tags, post)
|
val newPostTags = resolveTags(request.tags, post)
|
||||||
post.updateTags(newPostTags)
|
post.updateTags(newPostTags)
|
||||||
|
|
||||||
return post.id!!
|
return post.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글을 삭제합니다.
|
||||||
|
*
|
||||||
|
* [Cascading Deletion]
|
||||||
|
* 게시글 엔티티뿐만 아니라, 본문에 포함된 모든 이미지 파일도 스토리지에서 제거합니다.
|
||||||
|
* 태그 매핑 정보 등은 JPA Cascade 설정에 의해 자동으로 정리됩니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deletePost(id: Long) {
|
fun deletePost(id: Long) {
|
||||||
val post = postRepository.findByIdOrNull(id)
|
val post = postRepository.findByIdOrNull(id)
|
||||||
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
?: throw IllegalArgumentException("존재하지 않는 게시글입니다.")
|
||||||
|
|
||||||
|
// 본문에 포함된 이미지 추출 및 삭제
|
||||||
val imageNames = extractImageNamesFromContent(post.content)
|
val imageNames = extractImageNamesFromContent(post.content)
|
||||||
imageNames.forEach { fileName ->
|
imageNames.forEach { fileName ->
|
||||||
imageService.deleteImage(fileName)
|
imageService.deleteImage(fileName)
|
||||||
@@ -116,6 +158,13 @@ class PostService(
|
|||||||
postRepository.delete(post)
|
postRepository.delete(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 복합 조건 검색을 수행합니다.
|
||||||
|
*
|
||||||
|
* [계층형 카테고리 검색]
|
||||||
|
* 상위 카테고리로 검색 시, 해당 카테고리에 속한 하위 카테고리(Descendants)의 게시글들도
|
||||||
|
* 모두 결과에 포함되도록 검색 조건을 확장(Expand)합니다.
|
||||||
|
*/
|
||||||
fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
|
fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page<PostSummaryResponse> {
|
||||||
val categoryNames = if (categoryName != null) {
|
val categoryNames = if (categoryName != null) {
|
||||||
getCategoryAndDescendants(categoryName)
|
getCategoryAndDescendants(categoryName)
|
||||||
@@ -128,20 +177,26 @@ class PostService(
|
|||||||
|
|
||||||
// --- Helper Methods ---
|
// --- Helper Methods ---
|
||||||
|
|
||||||
// Slug 중복 처리 로직 분리
|
/**
|
||||||
|
* Slug 중복 발생 시, 카운팅 숫자를 접미사(Suffix)로 붙여 유일한 값을 생성합니다.
|
||||||
|
* 예: "hello-world" -> "hello-world-1" -> "hello-world-2"
|
||||||
|
*/
|
||||||
private fun generateUniqueSlug(inputSlug: String?, title: String): String {
|
private fun generateUniqueSlug(inputSlug: String?, title: String): String {
|
||||||
val rawSlug = if (!inputSlug.isNullOrBlank()) {
|
val rawSlug = if (!inputSlug.isNullOrBlank()) {
|
||||||
inputSlug
|
inputSlug
|
||||||
} else {
|
} else {
|
||||||
|
// URL에 안전하지 않은 문자 제거 및 공백 치환
|
||||||
title.trim().replace("\\s+".toRegex(), "-").lowercase()
|
title.trim().replace("\\s+".toRegex(), "-").lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
var uniqueSlug = rawSlug
|
var uniqueSlug = rawSlug
|
||||||
var count = 1
|
var count = 1
|
||||||
|
|
||||||
|
// 특수문자 정제
|
||||||
uniqueSlug = uniqueSlug.replace("?", "")
|
uniqueSlug = uniqueSlug.replace("?", "")
|
||||||
uniqueSlug = uniqueSlug.replace(";", "")
|
uniqueSlug = uniqueSlug.replace(";", "")
|
||||||
|
|
||||||
|
// 중복 체크 루프
|
||||||
while (postRepository.existsBySlug(uniqueSlug)) {
|
while (postRepository.existsBySlug(uniqueSlug)) {
|
||||||
uniqueSlug = "$rawSlug-$count"
|
uniqueSlug = "$rawSlug-$count"
|
||||||
count++
|
count++
|
||||||
@@ -149,7 +204,10 @@ class PostService(
|
|||||||
return uniqueSlug
|
return uniqueSlug
|
||||||
}
|
}
|
||||||
|
|
||||||
// 태그 이름 -> PostTag 변환 로직 분리
|
/**
|
||||||
|
* 태그 문자열 리스트를 PostTag 엔티티 리스트로 변환합니다.
|
||||||
|
* DB에 존재하지 않는 태그는 즉시 생성(Save)하여 매핑합니다.
|
||||||
|
*/
|
||||||
private fun resolveTags(tagNames: List<String>, post: Post): List<PostTag> {
|
private fun resolveTags(tagNames: List<String>, post: Post): List<PostTag> {
|
||||||
return tagNames.map { tagName ->
|
return tagNames.map { tagName ->
|
||||||
val tag = tagRepository.findByName(tagName)
|
val tag = tagRepository.findByName(tagName)
|
||||||
@@ -158,6 +216,10 @@ class PostService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정규표현식을 사용하여 Markdown 본문에서 이미지 URL(파일명)을 추출합니다.
|
||||||
|
* 패턴: 
|
||||||
|
*/
|
||||||
private fun extractImageNamesFromContent(content: String): List<String> {
|
private fun extractImageNamesFromContent(content: String): List<String> {
|
||||||
val regex = Regex("!\\[.*?\\]\\((.*?)\\)")
|
val regex = Regex("!\\[.*?\\]\\((.*?)\\)")
|
||||||
return regex.findAll(content)
|
return regex.findAll(content)
|
||||||
@@ -166,6 +228,10 @@ class PostService(
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 카테고리의 모든 자손 카테고리 이름을 재귀적으로 수집합니다.
|
||||||
|
* "Parent" 검색 시 "Parent > Child"의 글도 나오게 하기 위함입니다.
|
||||||
|
*/
|
||||||
private fun getCategoryAndDescendants(categoryName: String): List<String> {
|
private fun getCategoryAndDescendants(categoryName: String): List<String> {
|
||||||
if (categoryName.equals("uncategorized", ignoreCase = true)) {
|
if (categoryName.equals("uncategorized", ignoreCase = true)) {
|
||||||
return listOf("uncategorized")
|
return listOf("uncategorized")
|
||||||
|
|||||||
@@ -3,15 +3,28 @@ package me.wypark.blogbackend.domain.profile
|
|||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [블로그 프로필 엔티티]
|
||||||
|
*
|
||||||
|
* 블로그 운영자(Owner)의 공개적인 신원 정보(Identity)를 관리하는 도메인 모델입니다.
|
||||||
|
*
|
||||||
|
* [설계 의도: 관심사의 분리 (Separation of Concerns)]
|
||||||
|
* 인증/인가를 담당하는 Member 엔티티와 의도적으로 분리하여 설계했습니다.
|
||||||
|
* - Member: 시스템 접속 및 보안을 위한 계정 정보 (Email, Password, Role) -> 보안 중요, 변경 빈도 낮음
|
||||||
|
* - BlogProfile: 방문자에게 보여지는 소개 정보 (Bio, Social Links) -> 공개 데이터, 변경 빈도 높음
|
||||||
|
* 이렇게 책임을 분리함으로써, 프로필 정보 수정 로직이 핵심 인증 데이터에 영향을 주지 않도록 격리했습니다.
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "blog_profile")
|
@Table(name = "blog_profile")
|
||||||
class BlogProfile(
|
class BlogProfile(
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var name: String,
|
var name: String,
|
||||||
|
|
||||||
|
// 사용자의 긴 자기소개를 수용하기 위해 대용량 텍스트(CLOB) 타입으로 매핑
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
var bio: String,
|
var bio: String,
|
||||||
|
|
||||||
|
// S3/MinIO 등에 업로드된 이미지 리소스의 절대 경로(URL)
|
||||||
@Column
|
@Column
|
||||||
var imageUrl: String? = null,
|
var imageUrl: String? = null,
|
||||||
|
|
||||||
@@ -26,6 +39,12 @@ class BlogProfile(
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
val id: Long? = null
|
val id: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 정보를 갱신합니다.
|
||||||
|
*
|
||||||
|
* 단순 Setter 나열을 지양하고, 의미 있는 비즈니스 메서드(Update)를 정의하여
|
||||||
|
* 한 번의 트랜잭션 내에서 관련된 모든 정보가 원자적(Atomic)으로 변경됨을 명시합니다.
|
||||||
|
*/
|
||||||
fun update(name: String, bio: String, imageUrl: String?, githubUrl: String?, email: String?) {
|
fun update(name: String, bio: String, imageUrl: String?, githubUrl: String?, email: String?) {
|
||||||
this.name = name
|
this.name = name
|
||||||
this.bio = bio
|
this.bio = bio
|
||||||
|
|||||||
@@ -6,14 +6,30 @@ import me.wypark.blogbackend.domain.image.ImageService
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [프로필 비즈니스 로직]
|
||||||
|
*
|
||||||
|
* 블로그 운영자의 정보 관리 및 관련 리소스(이미지) 처리를 담당합니다.
|
||||||
|
*
|
||||||
|
* [단일 리소스 정책]
|
||||||
|
* 이 블로그 시스템은 단일 운영자(Single User)를 가정하므로,
|
||||||
|
* 프로필 데이터는 테이블 내에 항상 1개의 레코드(Singleton)만 존재하도록 관리됩니다.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class BlogProfileService(
|
class BlogProfileService(
|
||||||
private val blogProfileRepository: BlogProfileRepository,
|
private val blogProfileRepository: BlogProfileRepository,
|
||||||
private val imageService: ImageService // 👈 이미지 서비스 주입
|
private val imageService: ImageService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// 프로필 조회 (없으면 기본값 생성 후 반환)
|
/**
|
||||||
|
* 현재 설정된 프로필 정보를 조회합니다.
|
||||||
|
*
|
||||||
|
* [초기화 전략: Get-Or-Create]
|
||||||
|
* 앱 초기 구동 시 프로필 데이터가 없을 경우(Cold Start),
|
||||||
|
* 사용자에게 빈 화면이나 에러를 보여주는 대신 기본값(Default)으로 레코드를 생성하여 반환합니다.
|
||||||
|
* 이를 통해 별도의 초기화 스크립트 없이도 즉시 서비스를 사용할 수 있습니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun getProfile(): ProfileResponse {
|
fun getProfile(): ProfileResponse {
|
||||||
val profile = blogProfileRepository.findAll().firstOrNull()
|
val profile = blogProfileRepository.findAll().firstOrNull()
|
||||||
@@ -29,9 +45,16 @@ class BlogProfileService(
|
|||||||
return ProfileResponse.from(profile)
|
return ProfileResponse.from(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프로필 수정
|
/**
|
||||||
|
* 프로필 정보를 수정합니다.
|
||||||
|
*
|
||||||
|
* [리소스 최적화: Image Garbage Collection]
|
||||||
|
* 프로필 이미지가 변경되거나 삭제될 경우, 더 이상 사용되지 않는 기존 이미지 파일(Dangling File)을
|
||||||
|
* 스토리지(S3)에서 즉시 삭제하여 스토리지 비용 낭비를 방지합니다.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateProfile(request: ProfileUpdateRequest) {
|
fun updateProfile(request: ProfileUpdateRequest) {
|
||||||
|
// 데이터가 없으면 생성(Upsert)
|
||||||
val profile = blogProfileRepository.findAll().firstOrNull()
|
val profile = blogProfileRepository.findAll().firstOrNull()
|
||||||
?: blogProfileRepository.save(
|
?: blogProfileRepository.save(
|
||||||
BlogProfile(
|
BlogProfile(
|
||||||
@@ -43,17 +66,17 @@ class BlogProfileService(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 1. 이미지 변경 감지 로직 추가
|
// [이미지 변경 감지]
|
||||||
// 요청된 URL이 기존 URL과 다르면 (새 이미지로 교체 or 삭제됨)
|
// 요청된 이미지 URL이 기존과 다를 경우 (교체 또는 삭제)
|
||||||
if (profile.imageUrl != request.imageUrl) {
|
if (profile.imageUrl != request.imageUrl) {
|
||||||
// 기존에 설정된 이미지가 있었다면 S3에서 삭제
|
// 기존 이미지가 존재했다면 정리 대상이므로 삭제 처리
|
||||||
if (!profile.imageUrl.isNullOrBlank()) {
|
if (!profile.imageUrl.isNullOrBlank()) {
|
||||||
val oldFileName = profile.imageUrl!!.substringAfterLast("/")
|
val oldFileName = profile.imageUrl!!.substringAfterLast("/")
|
||||||
imageService.deleteImage(oldFileName)
|
imageService.deleteImage(oldFileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 정보 업데이트
|
// 엔티티 상태 업데이트 (Dirty Checking에 의해 트랜잭션 종료 시 반영)
|
||||||
profile.update(
|
profile.update(
|
||||||
name = request.name,
|
name = request.name,
|
||||||
bio = request.bio,
|
bio = request.bio,
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
# [파일 업로드 제한 설정]
|
||||||
name: blog-api
|
# 고해상도 이미지나 대용량 미디어를 처리하기 위해 서블릿의 멀티파트 제약 조건을 완화합니다.
|
||||||
|
# Nginx 등의 리버스 프록시를 앞단에 둘 경우, 프록시 설정(client_max_body_size)도 함께 조정해야 합니다.
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 100MB # 파일 하나당 최대 크기 (기본 1MB -> 10MB로 증량)
|
max-file-size: 100MB # 단일 파일 허용 크기
|
||||||
max-request-size: 100MB # 요청 전체 최대 크기 (여러 파일 합산)
|
max-request-size: 100MB # 요청 전체 허용 크기 (여러 파일 합산)
|
||||||
|
|
||||||
# 1. 데이터베이스 설정
|
# [데이터베이스 설정]
|
||||||
|
# Docker Compose 환경에서의 컨테이너 간 통신을 지원하기 위해 호스트 등을 환경변수로 동적 주입받습니다.
|
||||||
datasource:
|
datasource:
|
||||||
# Docker 내부 통신용
|
|
||||||
url: jdbc:postgresql://${SPRING_DATASOURCE_URL}
|
url: jdbc:postgresql://${SPRING_DATASOURCE_URL}
|
||||||
username: ${SPRING_DATASOURCE_USERNAME}
|
username: ${SPRING_DATASOURCE_USERNAME}
|
||||||
password: ${SPRING_DATASOURCE_PASSWORD}
|
password: ${SPRING_DATASOURCE_PASSWORD}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
|
|
||||||
# 2. JPA 설정
|
# [JPA / Hibernate 설정]
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
|
# 운영 환경 데이터 보존을 위해 스키마 자동 생성(create/update) 대신 검증(validate) 모드를 사용합니다.
|
||||||
|
# 스키마 불일치 시 애플리케이션 실행을 중단하여 잠재적 오류를 방지합니다.
|
||||||
ddl-auto: validate
|
ddl-auto: validate
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: false # 쿼리 줄바꿈
|
format_sql: false # 운영 로그 가독성을 위해 SQL 포맷팅 비활성화 (디버깅 시 true 권장)
|
||||||
show_sql: false # 쿼리 출력
|
show_sql: false # 성능 저하 방지를 위해 콘솔 출력 비활성화 (로거로 대체 권장)
|
||||||
highlight_sql: false # 쿼리 색상 강조 (가독성 UP)
|
highlight_sql: false
|
||||||
open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
|
|
||||||
|
|
||||||
|
# [OSIV(Open Session In View) 비활성화]
|
||||||
|
# 영속성 컨텍스트의 생존 범위를 트랜잭션 범위로 한정합니다.
|
||||||
|
# View 렌더링 시점까지 DB 커넥션을 점유하는 것을 방지하여, 트래픽 급증 시 커넥션 풀 고갈 리스크를 최소화합니다.
|
||||||
|
open-in-view: false
|
||||||
|
|
||||||
|
# [SMTP 메일 설정]
|
||||||
|
# 회원가입 인증 코드(OTP) 발송을 위한 Gmail SMTP 설정입니다.
|
||||||
mail:
|
mail:
|
||||||
host: smtp.gmail.com
|
host: smtp.gmail.com
|
||||||
port: 587
|
port: 587
|
||||||
username: ${MAIL_USERNAME} # 환경변수 처리 추천
|
username: ${MAIL_USERNAME}
|
||||||
password: ${MAIL_PASSWORD} # 앱 비밀번호 (16자리)
|
password: ${MAIL_PASSWORD} # Google App Password (2단계 인증 앱 비밀번호)
|
||||||
properties:
|
properties:
|
||||||
mail:
|
mail:
|
||||||
smtp:
|
smtp:
|
||||||
@@ -38,22 +46,27 @@ spring:
|
|||||||
starttls:
|
starttls:
|
||||||
enable: true
|
enable: true
|
||||||
required: true
|
required: true
|
||||||
|
# 네트워크 지연 시 스레드 차단을 막기 위한 타임아웃 설정 (Fail-Fast)
|
||||||
connectiontimeout: 5000
|
connectiontimeout: 5000
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
writetimeout: 5000
|
writetimeout: 5000
|
||||||
|
|
||||||
# 3. Redis 설정
|
# [Redis 설정]
|
||||||
|
# Refresh Token의 저장(TTL) 및 캐싱(Caching) 처리를 위한 인메모리 데이터 저장소입니다.
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:192.168.0.36}
|
host: ${REDIS_HOST:192.168.0.36}
|
||||||
port: 6379
|
port: 6379
|
||||||
|
|
||||||
|
# [AWS S3 / MinIO 설정]
|
||||||
|
# 정적 리소스(이미지) 저장을 위한 오브젝트 스토리지 설정입니다.
|
||||||
|
# 로컬 개발 시에는 MinIO를, 운영 시에는 실제 AWS S3를 바라보도록 환경변수로 제어합니다.
|
||||||
cloud:
|
cloud:
|
||||||
aws:
|
aws:
|
||||||
s3:
|
s3:
|
||||||
bucket: blog-bucket
|
bucket: blog-bucket
|
||||||
endpoint: https://s3.wypark.me
|
endpoint: https://s3.wypark.me # MinIO 엔드포인트 또는 AWS S3 리전 엔드포인트
|
||||||
path-style-access-enabled: true
|
path-style-access-enabled: true # MinIO 호환성을 위해 Path Style 접근 허용
|
||||||
credentials:
|
credentials:
|
||||||
access-key: ${S3_ACCESS_KEY}
|
access-key: ${S3_ACCESS_KEY}
|
||||||
secret-key: ${S3_SECRET_KEY}
|
secret-key: ${S3_SECRET_KEY}
|
||||||
@@ -62,9 +75,11 @@ spring:
|
|||||||
stack:
|
stack:
|
||||||
auto: false
|
auto: false
|
||||||
|
|
||||||
# 5. JWT 설정
|
# [JWT(Json Web Token) 정책 설정]
|
||||||
jwt:
|
jwt:
|
||||||
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
|
# [보안 경고]
|
||||||
|
# Secret Key는 서명 위조 방지를 위한 핵심 키이므로, 절대 소스코드에 평문으로 노출하지 않고
|
||||||
|
# CI/CD 파이프라인 변수(${JWT_SECRET})를 통해 주입받습니다.
|
||||||
secret: ${JWT_SECRET}
|
secret: ${JWT_SECRET}
|
||||||
access-token-validity: 600000
|
access-token-validity: 600000 # 10분 (짧은 만료 시간으로 탈취 시 피해 최소화)
|
||||||
refresh-token-validity: 604800000
|
refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: blog-backend
|
name: blog-api
|
||||||
profiles:
|
profiles:
|
||||||
default: prod
|
default: prod
|
||||||
Reference in New Issue
Block a user