Files
blog-backend/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt
2026-02-03 15:05:28 +09:00

252 lines
10 KiB
Kotlin

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