diff --git a/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt b/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt index b180a8b..0d50e14 100644 --- a/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt +++ b/src/main/kotlin/me/wypark/blogbackend/BlogBackendApplication.kt @@ -5,19 +5,35 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.data.web.config.EnableSpringDataWebSupport +/** + * [블로그 백엔드 애플리케이션 진입점] + * + * @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO): + * Spring Data Web의 페이징 직렬화 방식을 설정합니다. + * 최신 Spring Boot 버전에서는 PagedModel(구조체) 반환이 기본값이지만, + * 기존 프론트엔드와의 호환성 및 명시적인 DTO 변환을 선호하여 'VIA_DTO' 모드를 채택했습니다. + * 이는 내부 엔티티 구조가 외부 API 스펙에 직접 노출되는 것을 방지합니다. + */ @SpringBootApplication @EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) class BlogBackendApplication fun main(args: Array) { - // 1. .env 파일 로드 + /** + * [환경 변수 로드 전략: Twelve-Factor App] + * + * 로컬 개발 환경의 편의성을 위해 '.env' 파일을 지원하지만, + * 실제 운영(Production) 환경에서는 CI/CD 파이프라인을 통해 주입된 시스템 환경 변수를 우선합니다. + * + * 'ignoreIfMissing()' 옵션을 사용하여 배포 환경에서 .env 파일이 없더라도 + * 애플리케이션이 중단되지 않고 시스템 환경 변수로 fallback 되도록 구성했습니다. + */ val dotenv = Dotenv.configure().ignoreIfMissing().load() - // 2. 로드한 내용을 시스템 프로퍼티에 설정 (그래야 application.yml에서 ${}로 읽음) + // 로드된 환경 변수를 Spring Boot가 인식할 수 있도록 시스템 프로퍼티로 이관(Migration) dotenv.entries().forEach { entry -> System.setProperty(entry.key, entry.value) } - // 3. 스프링 실행 runApplication(*args) -} +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt b/src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt index 0c75650..b079824 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt @@ -1,15 +1,38 @@ package me.wypark.blogbackend.api.common +/** + * [API 공통 응답 규격] + * + * 클라이언트(Frontend)와 서버 간의 통신 프로토콜을 통일하기 위한 Wrapper 클래스입니다. + * 모든 REST API 응답은 이 클래스로 감싸서 반환되며, 이를 통해 예외 발생 시에도 + * 일관된 JSON 구조를 보장하여 클라이언트의 에러 핸들링 복잡도를 낮춥니다. + * + * @param T 실제 응답 데이터의 타입 (Generic) + */ data class ApiResponse( + // 비즈니스 로직 처리 결과 코드 (HTTP Status와는 별개로 세부적인 에러 코드를 정의하여 사용 가능) val code: String = "SUCCESS", + + // 클라이언트에게 노출할 알림 메시지 (Toast UI 등에서 활용) val message: String = "요청이 성공했습니다.", + + // 실제 전송할 데이터 Payload (실패 시 null) val data: T? = null ) { companion object { + /** + * 성공 응답을 생성하는 정적 팩토리 메서드입니다. + * 데이터가 없는 경우(예: 삭제/수정 완료)에도 일관된 형식을 유지하기 위해 기본 메시지를 제공합니다. + */ fun success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse { return ApiResponse("SUCCESS", message, data) } + /** + * 실패 응답을 생성하는 정적 팩토리 메서드입니다. + * 에러 상황에서는 data 필드가 불필요하므로, 타입을 사용하여 + * 타입 안정성(Type Safety)을 확보하고 불필요한 객체 생성을 방지합니다. + */ fun error(message: String, code: String = "ERROR"): ApiResponse { return ApiResponse(code, message, null) } diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt index d11014c..ecd754c 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt @@ -9,41 +9,77 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.userdetails.User import org.springframework.web.bind.annotation.* +/** + * [인증/인가 컨트롤러] + * + * JWT(Json Web Token) 기반의 Stateless 인증 처리를 담당하는 엔드포인트 집합입니다. + * 표준적인 Access/Refresh Token 패턴을 사용하며, 보안 강화를 위해 + * Refresh Token Rotation(RTR) 전략을 적용하여 탈취된 토큰의 재사용을 방지합니다. + */ @RestController @RequestMapping("/api/auth") class AuthController( private val authService: AuthService ) { + /** + * 신규 회원 가입을 요청합니다. + * + * 봇(Bot)이나 무분별한 가입을 방지하기 위해, 가입 요청 즉시 인증 메일을 발송합니다. + * 이메일 인증이 완료(`isVerified = true`)되기 전까지는 로그인이 제한됩니다. + */ @PostMapping("/signup") fun signup(@RequestBody @Valid request: SignupRequest): ResponseEntity> { authService.signup(request) return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요.")) } + /** + * 이메일 인증 코드를 검증하여 계정을 활성화합니다. + * Redis에 TTL(Time-To-Live)로 저장된 임시 코드와 사용자의 입력을 대조합니다. + */ @PostMapping("/verify") fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity> { authService.verifyEmail(request.email, request.code) return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다.")) } + /** + * 사용자 자격 증명(Email/Password)을 검증하고 토큰 쌍을 발급합니다. + * 인증 성공 시 Access Token과 Refresh Token이 모두 반환됩니다. + */ @PostMapping("/login") fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity> { val tokenDto = authService.login(request) return ResponseEntity.ok(ApiResponse.success(tokenDto)) } + /** + * Access Token 만료 시, Refresh Token을 사용하여 토큰을 갱신합니다 (Silent Refresh). + * + * [보안 전략: Refresh Token Rotation] + * 토큰 갱신 시 기존 Refresh Token은 폐기되고 새로운 Refresh Token이 발급됩니다. + * 만약 이미 폐기된 토큰으로 재요청이 들어올 경우, 탈취된 것으로 간주하여 해당 유저의 모든 토큰을 무효화합니다. + */ @PostMapping("/reissue") fun reissue(@RequestBody request: ReissueRequest): ResponseEntity> { val tokenDto = authService.reissue(request.accessToken, request.refreshToken) return ResponseEntity.ok(ApiResponse.success(tokenDto)) } + /** + * 로그아웃 처리를 수행합니다. + * + * JWT 특성상 클라이언트가 토큰을 삭제하는 것이 기본이지만, + * 서버 측에서도 Redis에 저장된 Refresh Token을 즉시 삭제(Evict)하여 + * 더 이상 해당 토큰으로 액세스 토큰을 재발급받지 못하도록 차단합니다. + */ @PostMapping("/logout") fun logout(@AuthenticationPrincipal user: User): ResponseEntity> { - authService.logout(user.username) // user.username은 email입니다. + authService.logout(user.username) // user.username은 SecurityContext에 저장된 email입니다. return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다.")) } } +// DTO가 다른 곳에서 재사용 않아 응집도를 위해 같은 파일 내에 정의 data class ReissueRequest(val accessToken: String, val refreshToken: String) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt index 391b390..198e414 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/CategoryController.kt @@ -8,12 +8,24 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +/** + * [카테고리 조회 API] + * + * 일반 사용자(Public)에게 노출되는 카테고리 관련 엔드포인트입니다. + * 블로그의 탐색(Navigation) 기능을 담당하며, 데이터 변경이 없는 읽기 전용(Read-Only) 작업만을 수행합니다. + */ @RestController @RequestMapping("/api/categories") class CategoryController( private val categoryService: CategoryService ) { + /** + * 카테고리 전체 계층 구조를 조회합니다. + * + * 프론트엔드 사이드바나 헤더 메뉴 렌더링을 위해 설계되었으며, + * 불필요한 네트워크 왕복(Round Trip)을 줄이기 위해 한 번의 요청으로 중첩된(Nested) 트리 형태의 전체 데이터를 반환합니다. + */ @GetMapping fun getCategoryTree(): ResponseEntity>> { return ResponseEntity.ok(ApiResponse.success(categoryService.getCategoryTree())) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt index 556ba46..ea7ea98 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/CommentController.kt @@ -10,34 +10,58 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.userdetails.User import org.springframework.web.bind.annotation.* +/** + * [일반 사용자용 댓글 API] + * + * 게시글에 대한 사용자 참여(Social Interaction)를 담당하는 컨트롤러입니다. + * 사용자 경험(UX)을 고려하여, 회원가입 없이도 자유롭게 소통할 수 있도록 + * 회원(Member)과 비회원(Guest)의 접근을 동시에 허용하는 하이브리드 로직을 수행합니다. + */ @RestController @RequestMapping("/api/comments") class CommentController( private val commentService: CommentService ) { - // 댓글 목록 조회 + /** + * 특정 게시글의 전체 댓글 목록을 조회합니다. + * + * 단순 리스트가 아닌, 대댓글(Nested Comments) 구조를 유지한 상태로 반환하여 + * 클라이언트가 별도의 재귀 로직 구현 없이 트리 형태로 즉시 렌더링할 수 있도록 지원합니다. + */ @GetMapping fun getComments(@RequestParam postSlug: String): ResponseEntity>> { return ResponseEntity.ok(ApiResponse.success(commentService.getComments(postSlug))) } - // 댓글 작성 (회원 or 비회원) + /** + * 댓글을 작성합니다 (회원/비회원 공용). + * + * 참여 장벽을 낮추기 위해 로그인 여부를 강제하지 않습니다. + * Security Context의 User 객체가 null일 경우 비회원으로 간주하며, + * 이 경우 RequestBody에 포함된 닉네임과 비밀번호를 사용하여 임시 신원을 생성합니다. + */ @PostMapping fun createComment( @RequestBody request: CommentSaveRequest, - @AuthenticationPrincipal user: User? // 비회원이면 null이 들어옴 + @AuthenticationPrincipal user: User? // 비회원 접근 시 null (Optional Principal) ): ResponseEntity> { - val email = user?.username // null이면 비회원 + val email = user?.username // 인증된 사용자라면 email 추출 val commentId = commentService.createComment(request, email) return ResponseEntity.ok(ApiResponse.success(commentId, "댓글이 등록되었습니다.")) } - // 댓글 삭제 + /** + * 댓글을 삭제합니다. + * + * 작성자 유형(회원/비회원)에 따라 검증 전략(Strategy)이 달라집니다. + * - 회원: 현재 로그인한 사용자의 ID와 댓글 작성자 ID의 일치 여부를 검증 + * - 비회원: 댓글 작성 시 설정한 비밀번호(Guest Password)의 일치 여부를 검증 + */ @DeleteMapping("/{id}") fun deleteComment( @PathVariable id: Long, - @RequestBody(required = false) request: CommentDeleteRequest?, // 비회원용 비밀번호 바디 + @RequestBody(required = false) request: CommentDeleteRequest?, // 비회원일 경우에만 바디가 필요함 @AuthenticationPrincipal user: User? ): ResponseEntity> { val email = user?.username diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt index a75212e..b874f04 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/PostController.kt @@ -11,31 +11,54 @@ import org.springframework.data.web.PageableDefault import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +/** + * [일반 사용자용 게시글 조회 API] + * + * 블로그의 핵심 콘텐츠인 게시글(Post) 데이터를 제공하는 Public 컨트롤러입니다. + * 방문자의 조회 요청을 처리하며, 검색 엔진 최적화(SEO)를 고려하여 + * 내부 식별자(ID)가 아닌 의미 있는 문자열(Slug) 기반의 URL 설계를 채택했습니다. + */ @RestController @RequestMapping("/api/posts") class PostController( private val postService: PostService ) { + /** + * 게시글 목록을 조회하거나 조건에 맞춰 검색합니다. + * + * [통합 검색 엔드포인트] + * 단순 목록 조회와 필터링(검색) 로직을 하나의 API로 통합하여 프론트엔드 구현을 단순화했습니다. + * 필터 조건(keyword, category, tag) 유무에 따라 동적 쿼리(QueryDSL) 또는 기본 페이징 쿼리로 분기 처리됩니다. + * + * @param keyword 제목 또는 본문 검색어 (Optional) + * @param category 카테고리 이름 (Optional, 프론트엔드 파라미터명: category) + * @param tag 태그 이름 (Optional) + */ @GetMapping fun getPosts( @RequestParam(required = false) keyword: String?, - @RequestParam(required = false) category: String?, // 👈 프론트는 'category'로 보냄 + @RequestParam(required = false) category: String?, @RequestParam(required = false) tag: String?, @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable ): ResponseEntity>> { - // 검색 조건이 하나라도 있으면 searchPosts 호출 (검색 + 카테고리 필터링) + // 필터 조건이 하나라도 존재하면 동적 쿼리(Search) 실행, 없으면 기본 목록 조회(List) 수행 return if (keyword != null || category != null || tag != null) { val posts = postService.searchPosts(keyword, category, tag, pageable) ResponseEntity.ok(ApiResponse.success(posts)) } else { - // 조건이 없으면 전체 목록 조회 val posts = postService.getPosts(pageable) ResponseEntity.ok(ApiResponse.success(posts)) } } + /** + * 게시글 상세 정보를 조회합니다. + * + * URL에 ID(숫자) 대신 제목 기반의 Slug를 사용하여 가독성과 SEO 점수를 높입니다. + * 상세 조회 성공 시, 서비스 레이어에서 조회수(View Count) 증가 트랜잭션이 함께 수행됩니다. + */ @GetMapping("/{slug}") fun getPost(@PathVariable slug: String): ResponseEntity> { val post = postService.getPostBySlug(slug) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/ProfileController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/ProfileController.kt index 1bc1532..14f9092 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/ProfileController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/ProfileController.kt @@ -8,12 +8,26 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +/** + * [일반 사용자용 프로필 조회 API] + * + * 블로그 방문자들에게 운영자(Owner)의 정보를 제공하는 Public 컨트롤러입니다. + * 수정 권한이 필요한 관리자 영역(AdminProfileController)과 분리하여, + * 불필요한 인증 로직 없이 누구나 빠르게 조회할 수 있도록 설계된 읽기 전용(Read-Only) 엔드포인트입니다. + */ @RestController @RequestMapping("/api/profile") class ProfileController( private val blogProfileService: BlogProfileService ) { + /** + * 블로그 운영자의 프로필 정보를 조회합니다. + * + * 단일 사용자 블로그(Single User Blog) 특성상 별도의 사용자 ID 파라미터 없이 + * 시스템에 설정된 유일한 프로필 데이터를 반환합니다. + * (주로 메인 화면의 사이드바나 About 페이지 렌더링에 사용됩니다.) + */ @GetMapping fun getProfile(): ResponseEntity> { return ResponseEntity.ok(ApiResponse.success(blogProfileService.getProfile())) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt index 30ed70f..05c8f9f 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCategoryController.kt @@ -7,19 +7,37 @@ import me.wypark.blogbackend.domain.category.CategoryService import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +/** + * [관리자용 카테고리 관리 API] + * + * 블로그의 카테고리 계층 구조(Tree Structure)를 조작하는 컨트롤러입니다. + * 단순한 CRUD 외에도 부모-자식 관계 설정 및 구조 변경(이동) 로직을 포함하고 있습니다. + * + * Note: + * - 데이터 무결성을 위해 모든 변경 작업은 트랜잭션 범위 안에서 유효성 검증(순환 참조 방지 등) 후 수행됩니다. + */ @RestController @RequestMapping("/api/admin/categories") class AdminCategoryController( private val categoryService: CategoryService ) { + /** + * 신규 카테고리를 생성합니다. + * parentId가 없을 경우 최상위(Root) 카테고리로 생성되며, 있을 경우 해당 노드의 자식으로 연결됩니다. + */ @PostMapping fun createCategory(@RequestBody request: CategoryCreateRequest): ResponseEntity> { val id = categoryService.createCategory(request) return ResponseEntity.ok(ApiResponse.success(id, "카테고리가 생성되었습니다.")) } - // 👈 [추가] 카테고리 수정 (이름, 위치 이동) + /** + * 카테고리 정보(이름 및 계층 위치)를 수정합니다. + * + * 단순 이름 변경뿐만 아니라, 부모 카테고리를 변경하여 트리 구조 내에서 위치를 이동시키는 기능도 수행합니다. + * 위치 이동 시 순환 참조(Cycle)가 발생하지 않도록 서비스 레이어에서 검증 로직이 수행됩니다. + */ @PutMapping("/{id}") fun updateCategory( @PathVariable id: Long, @@ -29,6 +47,13 @@ class AdminCategoryController( return ResponseEntity.ok(ApiResponse.success(message = "카테고리가 수정되었습니다.")) } + /** + * 카테고리를 삭제합니다. + * + * [삭제 정책] + * - 하위 카테고리(Children)는 재귀적으로 함께 삭제됩니다 (Cascade). + * - 해당 카테고리에 속해있던 게시글(Post)들은 삭제되지 않고 '미분류(Category = NULL)' 상태로 변경되어 보존됩니다. + */ @DeleteMapping("/{id}") fun deleteCategory(@PathVariable id: Long): ResponseEntity> { categoryService.deleteCategory(id) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt index 4059a53..c547ada 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminCommentController.kt @@ -11,19 +11,36 @@ import org.springframework.data.web.PageableDefault import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +/** + * [관리자용 댓글 관리 API] + * + * 블로그 내 모든 댓글 활동을 모니터링하고 중재(Moderation)하는 컨트롤러입니다. + * 개별 게시글 단위로 조회하는 일반 API와 달리, 시스템 전체의 댓글 흐름을 파악하는 데 초점이 맞춰져 있습니다. + */ @RestController @RequestMapping("/api/admin/comments") class AdminCommentController( private val commentService: CommentService ) { - // 관리자 권한으로 댓글 삭제 + /** + * 부적절한 댓글을 강제로 삭제합니다 (Moderation). + * + * 일반 사용자의 삭제 요청과 달리 작성자 본인 확인 절차(Ownership Check)를 건너뛰고, + * 관리자 권한으로 즉시 데이터를 제거합니다. + */ @DeleteMapping("/{id}") fun deleteComment(@PathVariable id: Long): ResponseEntity> { commentService.deleteCommentByAdmin(id) return ResponseEntity.ok(ApiResponse.success(message = "관리자 권한으로 댓글을 삭제했습니다.")) } + /** + * 관리자 대시보드용 전체 댓글 목록을 조회합니다. + * + * 특정 게시글에 종속되지 않고, 최근 작성된 순서대로 모든 댓글을 페이징하여 반환합니다. + * 이를 통해 관리자는 스팸이나 악성 댓글 발생 여부를 한눈에 파악할 수 있습니다. + */ @GetMapping fun getAllComments( @PageableDefault(size = 20, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt index 13c34d9..d05d1b4 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminImageController.kt @@ -10,12 +10,30 @@ import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +/** + * [관리자용 이미지 업로드 API] + * + * 게시글 본문(Markdown) 삽입용 이미지나 프로필 사진 등, 블로그 운영에 필요한 + * 정적 리소스(Static Resources)를 처리하는 컨트롤러입니다. + * + * 스토리지 저장소(S3/MinIO)와의 직접적인 통신은 Service Layer에 위임하며, + * 클라이언트에게는 업로드된 리소스의 접근 가능한 URL을 반환하여 즉시 렌더링 가능하도록 합니다. + */ @RestController @RequestMapping("/api/admin/images") class AdminImageController( private val imageService: ImageService ) { + /** + * 이미지를 업로드하고 접근 가능한 URL을 반환합니다. + * + * 주로 에디터(Toast UI 등)에서 이미지 첨부 이벤트가 발생했을 때 비동기로 호출되며, + * 업로드 성공 시 반환된 URL은 클라이언트 측에서 즉시 Markdown 문법(![alt](url))으로 변환되어 본문에 삽입됩니다. + * + * @param image 클라이언트가 전송한 바이너리 파일 (MultipartFile) + * @return CDN 또는 스토리지의 접근 가능한 절대 경로 (URL) + */ @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun uploadImage( @RequestPart("image") image: MultipartFile diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt index 945e197..a24ab02 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminPostController.kt @@ -9,12 +9,25 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.userdetails.User import org.springframework.web.bind.annotation.* +/** + * [관리자용 게시글 관리 API] + * + * 블로그 콘텐츠(Post)의 전체 수명 주기(Lifecycle)를 관리하는 컨트롤러입니다. + * 게시글의 작성, 수정, 삭제 기능을 제공하며, 이 과정에서 입력값 검증(@Valid)과 + * 데이터 무결성 유지를 위한 다양한 비즈니스 로직(Slug 생성, 태그 처리 등)을 조율합니다. + */ @RestController @RequestMapping("/api/admin/posts") class AdminPostController( private val postService: PostService ) { + /** + * 신규 게시글을 작성 및 발행합니다. + * + * Security Context에서 현재 로그인한 관리자 정보를 추출하여 작성자(Author)로 매핑함으로써, + * 클라이언트가 임의로 작성자를 변조하는 것을 방지합니다. + */ @PostMapping fun createPost( @RequestBody @Valid request: PostSaveRequest, @@ -24,7 +37,12 @@ class AdminPostController( return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 작성되었습니다.")) } - // 👈 [추가] 게시글 수정 엔드포인트 + /** + * 기존 게시글을 수정합니다. + * + * 제목이나 본문 변경뿐만 아니라, 카테고리 이동이나 태그 재설정과 같은 메타데이터 변경도 함께 처리합니다. + * 수정 시 사용되지 않게 된 이미지를 정리하거나, URL(Slug) 변경에 따른 리다이렉트 고려 등이 서비스 레이어에서 처리됩니다. + */ @PutMapping("/{id}") fun updatePost( @PathVariable id: Long, @@ -34,6 +52,14 @@ class AdminPostController( return ResponseEntity.ok(ApiResponse.success(postId, "게시글이 수정되었습니다.")) } + /** + * 게시글을 영구 삭제합니다. + * + * [리소스 정리 전략] + * 단순히 DB 레코드(Row)만 삭제하는 것이 아니라, 해당 게시글 본문에 포함되었던 + * S3 업로드 이미지 파일들을 추적하여 함께 삭제(Cleanup)합니다. + * 이를 통해 스토리지에 불필요한 고아 파일(Orphaned Files)이 누적되는 것을 방지하여 비용을 최적화합니다. + */ @DeleteMapping("/{id}") fun deletePost(@PathVariable id: Long): ResponseEntity> { postService.deletePost(id) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminProfileController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminProfileController.kt index aa400bf..ee15ea6 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminProfileController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/admin/AdminProfileController.kt @@ -9,12 +9,26 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +/** + * [관리자용 프로필 설정 API] + * + * 블로그 운영자의 소개(Bio), 프로필 사진, 소셜 링크 등을 관리하는 컨트롤러입니다. + * 개인 블로그 특성상 단일 사용자(Owner)에 대한 정보만 존재하므로, + * 별도의 ID 파라미터 없이 싱글톤(Singleton) 리소스처럼 관리됩니다. + */ @RestController @RequestMapping("/api/admin/profile") class AdminProfileController( private val blogProfileService: BlogProfileService ) { + /** + * 블로그 프로필 정보를 수정합니다. + * + * 단순 텍스트 정보(이름, 소개) 수정뿐만 아니라, + * 변경된 프로필 이미지 URL을 반영하고 기존 이미지를 정리하는 로직이 서비스 레이어에 포함되어 있습니다. + * 초기 데이터가 없을 경우(First Run), 수정 요청 시 기본 프로필이 생성(Upsert)됩니다. + */ @PutMapping fun updateProfile(@RequestBody request: ProfileUpdateRequest): ResponseEntity> { blogProfileService.updateProfile(request) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt index ac4b2df..47827c3 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt @@ -4,12 +4,26 @@ import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size -// 회원가입 요청 +/** + * [회원가입 요청 DTO] + * + * 사용자 등록을 위한 데이터 전송 객체입니다. + * Controller 진입 시점(@Valid)에서 입력값의 형식 검증을 수행하여, + * 비즈니스 로직(Service) 단계에서의 불필요한 연산을 방지합니다 (Fail-Fast 전략). + */ data class SignupRequest( @field:NotBlank(message = "이메일은 필수입니다.") @field:Email(message = "올바른 이메일 형식이 아닙니다.") val email: String, + /** + * 비밀번호 복잡도 정책: 최소 8자 ~ 최대 20자 + * + * Note: + * 이 필드는 클라이언트로부터 평문(Plain Text)으로 전달되므로, + * 전송 구간 암호화(HTTPS/TLS)가 보장된 환경에서만 사용되어야 합니다. + * DB 저장 시에는 반드시 단방향 해시 함수(BCrypt 등)를 통해 암호화됩니다. + */ @field:NotBlank(message = "비밀번호는 필수입니다.") @field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.") val password: String, @@ -19,7 +33,11 @@ data class SignupRequest( val nickname: String ) -// 로그인 요청 +/** + * [로그인 요청 DTO] + * + * JWT 토큰 발급을 위한 사용자 자격 증명(Credentials)을 전달받는 객체입니다. + */ data class LoginRequest( @field:NotBlank(message = "이메일을 입력해주세요.") val email: String, @@ -28,6 +46,12 @@ data class LoginRequest( val password: String ) +/** + * [이메일 인증 확인 DTO] + * + * 회원가입 직후 발송된 OTP(One Time Password) 코드를 검증하기 위한 요청 객체입니다. + * 이메일 소유권 확인(Proof of Ownership)을 위해 사용됩니다. + */ data class VerifyEmailRequest( @field:NotBlank(message = "이메일을 입력해주세요") val email: String, diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt index 998ac82..47ca2c9 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/CategoryDtos.kt @@ -2,44 +2,73 @@ package me.wypark.blogbackend.api.dto import me.wypark.blogbackend.domain.category.Category -// [요청] 카테고리 생성 +/** + * [카테고리 생성 요청 DTO] + * + * 새로운 카테고리 노드(Node)를 생성하기 위한 요청 객체입니다. + * 계층형 게시판 구조를 지원하기 위해 부모 카테고리 ID(parentId)를 선택적으로 받습니다. + * + * @property parentId null일 경우 최상위(Root) 레벨에 생성되며, 값이 있을 경우 해당 카테고리의 하위(Child)로 연결됩니다. + */ data class CategoryCreateRequest( val name: String, - val parentId: Long? = null // null이면 최상위(Root) 카테고리 + val parentId: Long? = null ) -// [요청] 카테고리 수정 (이름 + 부모 이동) +/** + * [카테고리 수정 요청 DTO] + * + * 카테고리의 속성 변경(Rename)과 구조 변경(Move)을 동시에 처리하는 객체입니다. + * + * Note: + * 트리 구조 내에서의 노드 이동(Move)은 데이터베이스 부하가 발생할 수 있고 + * 순환 참조(Cycle) 위험이 있으므로, 서비스 레이어에서 별도의 정합성 검증 로직을 거칩니다. + */ data class CategoryUpdateRequest( 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( val id: Long, val name: String, - val children: List // 자식들 + val children: List // 자식 노드 리스트 (Recursive) ) { companion object { - // Entity -> DTO 변환 (재귀 호출) + // 엔티티 그래프를 순회하며 DTO 트리로 변환 fun from(category: Category): CategoryResponse { return CategoryResponse( id = category.id!!, name = category.name, - // 자식들을 DTO로 변환하여 리스트에 담음 + // 자식 카테고리들을 재귀적으로 DTO 변환하여 리스트에 매핑 children = category.children.map { from(it) } ) } } } -// [Admin용 응답] 관리자 대시보드 목록용 +/** + * [관리자용 댓글 모니터링 DTO - Flat List] + * + * 관리자 대시보드에서 최근 댓글 흐름을 파악하기 위한 객체입니다. + * 계층형 구조(Nested)가 필요한 일반 사용자 뷰와 달리, 관리 목적상 시간순 나열이 중요하므로 + * 모든 댓글을 평탄화(Flatten)하여 게시글 정보와 함께 제공합니다. + */ data class AdminCommentResponse( val id: Long, val content: String, val author: String, - val postTitle: String, // 어떤 글인지 식별 - val postSlug: String, // 클릭 시 해당 글로 이동용 + val postTitle: String, // 문맥 파악을 위한 원본 게시글 제목 + val postSlug: String, // 클릭 시 해당 게시글로 바로 이동(Deep Link)하기 위한 식별자 val createdAt: java.time.LocalDateTime ) { companion object { diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt index dccee85..c557ea7 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/CommentDtos.kt @@ -3,20 +3,32 @@ package me.wypark.blogbackend.api.dto import me.wypark.blogbackend.domain.comment.Comment import java.time.LocalDateTime -// [응답] 댓글 (계층형 구조) +/** + * [댓글 응답 DTO - Hierarchical] + * + * 게시글 상세 화면에서 댓글 목록을 렌더링하기 위한 데이터 객체입니다. + * 대댓글(Nested Comment)을 포함하는 재귀적 구조를 가지며, 프론트엔드에서의 추가 가공 없이 + * 즉시 트리 형태로 렌더링할 수 있도록 설계되었습니다. + */ data class CommentResponse( val id: Long, val content: String, val author: String, + + // UI에서 게시글 작성자의 댓글을 강조(Highlight)하기 위한 플래그 val isPostAuthor: Boolean, + + // 회원일 경우 프로필 링크 연결 등을 위해 ID 제공 (비회원은 null) val memberId: Long?, + val createdAt: LocalDateTime, + + // 자식 댓글 리스트 (Recursive) val children: List ) { companion object { fun from(comment: Comment): CommentResponse { - // 게시글 작성자 ID와 댓글 작성자(회원) ID가 같은지 비교 - // comment.member는 비회원일 경우 null이므로 안전하게 처리됨 + // 게시글 작성자 본인이 쓴 댓글인지 확인 (비회원은 member가 null이므로 항상 false) val isAuthor = comment.member?.id == comment.post.member.id return CommentResponse( @@ -26,23 +38,37 @@ data class CommentResponse( isPostAuthor = isAuthor, memberId = comment.member?.id, 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( val postSlug: String, val content: String, - val parentId: Long? = null, // 대댓글일 경우 부모 ID + val parentId: Long? = null, // 대댓글(Reply)일 경우 상위 댓글 ID - // 비회원 전용 필드 (회원은 null 가능) + // --- 비회원 전용 필드 (Anonymous User) --- val guestNickname: String? = null, - val guestPassword: String? = null + val guestPassword: String? = null // 수정/삭제 권한 인증용 비밀번호 (DB 저장 시 암호화됨) ) -// [요청] 댓글 삭제 (비회원용 비밀번호 전달) +/** + * [댓글 삭제 요청 DTO] + * + * 비회원이 본인의 댓글을 삭제할 때 비밀번호 검증을 위해 사용됩니다. + * 회원의 경우 JWT 토큰으로 본인 확인이 가능하므로 이 DTO의 필드는 사용되지 않습니다. + */ data class CommentDeleteRequest( val guestPassword: String? = null ) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt index 4fbec54..ffc3ea2 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/PostDtos.kt @@ -3,7 +3,13 @@ package me.wypark.blogbackend.api.dto import me.wypark.blogbackend.domain.post.Post import java.time.LocalDateTime -// [응답] 인접 게시글 정보 (이전글/다음글) +/** + * [인접 게시글 응답 DTO] + * + * 게시글 상세 화면 하단에 위치할 '이전 글 / 다음 글' 네비게이션 링크를 위한 객체입니다. + * 전체 데이터를 로딩하는 대신, 링크 생성에 필요한 최소한의 식별자(Slug)와 제목(Title)만 포함하여 + * 페이로드 크기를 최적화했습니다. + */ data class PostNeighborResponse( val slug: String, val title: String @@ -18,7 +24,15 @@ data class PostNeighborResponse( } } -// [응답] 게시글 상세 정보 +/** + * [게시글 상세 응답 DTO] + * + * 단일 게시글의 모든 정보(Full Content)를 클라이언트에게 전달하는 객체입니다. + * + * [설계 의도] + * - SEO: ID 대신 Slug를 사용하여 검색 엔진 친화적인 URL 구조 지원 + * - UX: 별도의 추가 요청 없이 이전/다음 글 정보를 함께 반환하여 페이지 이동성(Navigability) 향상 + */ data class PostResponse( val id: Long, val title: String, @@ -27,11 +41,11 @@ data class PostResponse( val categoryName: String?, val viewCount: Long, val createdAt: LocalDateTime, - // 👈 [추가] 이전/다음 게시글 정보 + + // 현재 글을 기준으로 앞/뒤 글 정보 (없으면 null) val prevPost: PostNeighborResponse?, val nextPost: PostNeighborResponse? ) { - // Entity -> DTO 변환 편의 메서드 companion object { fun from(post: Post, prevPost: Post? = null, nextPost: Post? = null): PostResponse { return PostResponse( @@ -49,7 +63,15 @@ data class PostResponse( } } -// [응답] 게시글 목록용 (본문 제외, 가볍게) +/** + * [게시글 목록 응답 DTO] + * + * 메인 화면이나 카테고리 목록에서 사용되는 경량화(Lightweight) 객체입니다. + * + * [최적화 전략] + * 다수의 아이템을 렌더링해야 하므로, 데이터 전송량(Network Overhead)을 줄이기 위해 + * 무거운 본문(content)은 제외하거나 미리보기용으로 일부만 포함하도록 설계되었습니다. + */ data class PostSummaryResponse( val id: Long, val title: String, @@ -58,7 +80,7 @@ data class PostSummaryResponse( val viewCount: Long, val createdAt: LocalDateTime, val updatedAt: LocalDateTime, - val content: String? + val content: String? // 목록에서는 본문 미리보기 용도로 사용 (혹은 null) ) { companion object { fun from(post: Post): PostSummaryResponse { @@ -76,11 +98,18 @@ data class PostSummaryResponse( } } -// [요청] 게시글 작성/수정 +/** + * [게시글 작성/수정 요청 DTO] + * + * 게시글의 생명주기(생성/수정)를 담당하는 통합 커맨드 객체입니다. + * + * - Slug: 클라이언트가 직접 지정하지 않으면(null), 서버에서 제목을 기반으로 자동 생성(Generate)합니다. + * - Content: Markdown 포맷의 원문 텍스트를 저장합니다. + */ data class PostSaveRequest( val title: String, - val content: String, // 마크다운 원문 + val content: String, val slug: String? = null, val categoryId: Long? = null, - val tags: List = emptyList() // 태그는 나중에 구현 + val tags: List = emptyList() // 태그는 서비스 레이어에서 별도 로직으로 매핑(Many-to-Many) 처리 ) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt index 6c268bb..46d9e24 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/ProfileDtos.kt @@ -2,6 +2,15 @@ package me.wypark.blogbackend.api.dto import me.wypark.blogbackend.domain.profile.BlogProfile +/** + * [프로필 응답 DTO] + * + * 블로그 운영자(Owner)의 공개 정보를 렌더링하기 위한 View Object입니다. + * + * [설계 의도] + * 데이터베이스 엔티티(Entity)를 직접 반환하지 않고 DTO로 변환하여, + * 내부 구현의 변경이 클라이언트(View)에 영향을 미치지 않도록 결합도(Coupling)를 낮췄습니다. + */ data class ProfileResponse( val name: String, val bio: String, @@ -10,6 +19,7 @@ data class ProfileResponse( val email: String? ) { companion object { + // Entity -> DTO 변환 (Static Factory Method) fun from(profile: BlogProfile): ProfileResponse { return ProfileResponse( name = profile.name, @@ -22,6 +32,15 @@ data class ProfileResponse( } } +/** + * [프로필 수정 요청 DTO] + * + * 관리자 대시보드에서 블로그 설정(운영자 정보)을 변경하기 위한 요청 객체입니다. + * + * [유효성 정책] + * - 이름(name)과 소개(bio)는 블로그의 정체성을 나타내는 필수 항목입니다. + * - 프로필 이미지나 소셜 링크 등은 선택적(Optional)으로 입력할 수 있도록 Nullable로 설계되었습니다. + */ data class ProfileUpdateRequest( val name: String, val bio: String, diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/TokenDto.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/TokenDto.kt index fb33351..b6b680c 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/dto/TokenDto.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/TokenDto.kt @@ -1,5 +1,17 @@ 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( val grantType: String = "Bearer", val accessToken: String, diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt index ce9e176..9dc32a6 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/CorsConfig.kt @@ -6,24 +6,44 @@ import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.UrlBasedCorsConfigurationSource import org.springframework.web.filter.CorsFilter +/** + * [CORS(Cross-Origin Resource Sharing) 설정] + * + * 프론트엔드(React/Next.js)와 백엔드(Spring Boot)의 도메인이 다를 경우 발생하는 + * 브라우저의 보안 제약(SOP)을 해결하기 위한 설정입니다. + * + * 단순한 와일드카드(*) 허용이 아닌, 신뢰할 수 있는 특정 도메인(Origin)에 대해서만 + * 리소스 접근 권한을 명시적으로 부여하여 보안성을 확보했습니다. + */ @Configuration class CorsConfig { + @Bean fun corsFilter(): CorsFilter { val source = UrlBasedCorsConfigurationSource() val config = CorsConfiguration() + // 1. 인증 정보(Cookie, Authorization Header) 포함 허용 + // 이 옵션을 true로 설정하면, 보안상 addAllowedOrigin에 와일드카드(*)를 사용할 수 없습니다. 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("*") - // [중요] 클라이언트가 응답 헤더에서 'Authorization'이나 커스텀 토큰 헤더를 읽을 수 있게 허용 + // 4. [중요] 응답 헤더 노출 설정 (Expose Headers) + // 브라우저는 기본적으로 보안상 CORS 요청에 대한 응답 헤더 중 일부(Cache-Control, Content-Type 등)만 JavaScript에서 접근하도록 제한합니다. + // 따라서, 클라이언트가 로그인 후 발급된 JWT 토큰(Authorization)을 읽을 수 있도록 명시적으로 노출시켜야 합니다. config.addExposedHeader("Authorization") - config.addExposedHeader("Refresh-Token") // 리프레시 토큰도 헤더로 준다면 추가 + config.addExposedHeader("Refresh-Token") source.registerCorsConfiguration("/api/**", config) return CorsFilter(source) } -} - +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/JpaConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/JpaConfig.kt index 4a8474e..e0cf647 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/JpaConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/JpaConfig.kt @@ -4,5 +4,5 @@ import org.springframework.context.annotation.Configuration import org.springframework.data.jpa.repository.config.EnableJpaAuditing @Configuration -@EnableJpaAuditing // 엔티티의 생성일/수정일 자동 주입 활성화 +@EnableJpaAuditing class JpaConfig \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt index 4e5b9f3..660b256 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt @@ -7,6 +7,13 @@ import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.JavaMailSenderImpl import java.util.* +/** + * [메일 발송 설정] + * + * 회원가입 인증 코드 발송 등을 위한 SMTP(Simple Mail Transfer Protocol) 서버 설정입니다. + * Google Gmail SMTP 등을 사용하여 외부 메일 서버와 연동하며, + * 네트워크 지연이나 연결 실패 시 스레드가 차단(Blocking)되는 것을 방지하기 위한 타임아웃 설정이 포함되어 있습니다. + */ @Configuration class MailConfig( @Value("\${spring.mail.host}") private val host: String, @@ -21,26 +28,38 @@ class MailConfig( @Value("\${spring.mail.properties.mail.smtp.writetimeout}") private val writeTimeout: Int ) { + /** + * JavaMailSender 빈 등록 + * + * Spring Mail 라이브러리의 핵심 인터페이스 구현체를 생성합니다. + * application.yml에서 주입받은 환경 변수들을 기반으로 SMTP 연결을 초기화합니다. + */ @Bean fun javaMailSender(): JavaMailSender { val mailSender = JavaMailSenderImpl() - // 기본 설정 + // 기본 SMTP 서버 정보 설정 mailSender.host = host mailSender.port = port mailSender.username = username mailSender.password = password mailSender.defaultEncoding = "UTF-8" - // 세부 프로퍼티 설정 + // JavaMail 세부 속성 설정 val props: Properties = mailSender.javaMailProperties props["mail.transport.protocol"] = "smtp" props["mail.smtp.auth"] = auth props["mail.smtp.starttls.enable"] = starttlsEnable props["mail.smtp.starttls.required"] = starttlsRequired - 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.timeout"] = timeout props["mail.smtp.writetimeout"] = writeTimeout diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt index fa37fd6..aa821b0 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt @@ -11,32 +11,61 @@ import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.web.filter.CorsFilter +/** + * [Spring Security 설정] + * + * 애플리케이션의 보안 인가(Authorization) 및 인증(Authentication) 전략을 정의합니다. + * 전통적인 세션(Session) 기반 인증 대신, REST API 환경에 적합한 JWT(Token) 기반의 + * 무상태(Stateless) 아키텍처를 구현했습니다. + */ @Configuration @EnableWebSecurity class SecurityConfig( private val corsFilter: CorsFilter, - private val jwtAuthenticationFilter: JwtAuthenticationFilter // 주입 추가 + private val jwtAuthenticationFilter: JwtAuthenticationFilter ) { @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { http + // 1. 기본 보안 설정 비활성화 (REST API 환경) + // CSRF(Cross-Site Request Forgery): 쿠키 기반의 세션 인증을 사용하지 않으므로 비활성화 (Header에 토큰을 담아 보냄) .csrf { it.disable() } + // HttpBasic / FormLogin: UI 기반의 인증 창을 사용하지 않으므로 비활성화 .httpBasic { it.disable() } .formLogin { it.disable() } + + // 2. 커스텀 필터 등록 + // CorsFilter: 브라우저의 SOP(Same-Origin Policy) 우회를 위한 설정 적용 .addFilter(corsFilter) + + // 3. 세션 정책 설정 (Stateless) + // 서버가 클라이언트의 상태(Session)를 보존하지 않음 -> 서버 확장성(Scale-out) 유리 .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + + // 4. URL별 접근 권한 관리 (인가) + // Principle of Least Privilege(최소 권한의 원칙)에 따라, 명시적으로 허용된 경로 외에는 모두 인증을 요구 .authorizeHttpRequests { auth -> + // 인증 관련(로그인, 회원가입) 및 정적 리소스는 누구나 접근 가능 auth.requestMatchers("/api/auth/**").permitAll() + + // 조회(Read) 작업은 비회원에게도 허용 (GET 메서드 한정) auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll() - auth.requestMatchers("/api/comments/**").permitAll() // 비회원 댓글 허용 auth.requestMatchers(HttpMethod.GET, "/api/profile").permitAll() + + // 댓글 API: 비회원 작성/삭제도 지원하므로 전체 허용 (내부 로직에서 비밀번호 검증) + auth.requestMatchers("/api/comments/**").permitAll() + + // 관리자 영역: ROLE_ADMIN 권한을 가진 토큰 소유자만 접근 가능 auth.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") + + // 그 외 모든 요청은 인증 필요 auth.anyRequest().authenticated() } - // 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행 + // 5. JWT 인증 필터 삽입 + // UsernamePasswordAuthenticationFilter(기본 로그인 처리)보다 먼저 실행되어야 함 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) return http.build() diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtAuthenticationFilter.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtAuthenticationFilter.kt index 3814793..e152519 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtAuthenticationFilter.kt @@ -4,39 +4,56 @@ import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component import org.springframework.util.StringUtils import org.springframework.web.filter.OncePerRequestFilter -import org.springframework.stereotype.Component +/** + * [JWT 인증 필터] + * + * 모든 HTTP 요청의 헤더를 가로채어 JWT 토큰의 유효성을 검증하는 커스텀 필터입니다. + * Spring Security의 FilterChain 앞단에 배치되어, 인증된 사용자일 경우 + * SecurityContext에 Authentication 객체를 주입(Populate)하는 역할을 수행합니다. + */ @Component class JwtAuthenticationFilter( private val jwtProvider: JwtProvider ) : OncePerRequestFilter() { + /** + * 필터링 로직 수행 + * + * [흐름 제어 전략] + * 토큰이 없거나 유효하지 않더라도 이 필터에서 즉시 예외를 발생시키거나 요청을 차단하지 않습니다. + * 검증에 실패하면 SecurityContext가 비어있는 상태로 다음 필터(Chain)로 넘어가며, + * 최종적으로 FilterSecurityInterceptor(SecurityConfig) 단계에서 접근 권한을 판단하게 됩니다. + * (예: 인증되지 않은 사용자가 /api/public 접근 시 -> 허용, /api/admin 접근 시 -> 403 Forbidden) + */ override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain ) { - // 1. Request Header에서 토큰 추출 val token = resolveToken(request) - // 2. 토큰 유효성 검사 - // 토큰이 존재하고 유효하다면 인증 정보를 가져와 Context에 저장 + // 토큰 유효성 검증 및 SecurityContext 설정 + // (Stateless 아키텍처이므로 세션이 아닌 Context에 매 요청마다 인증 정보를 주입합니다) if (StringUtils.hasText(token) && jwtProvider.validateToken(token!!)) { val authentication = jwtProvider.getAuthentication(token) SecurityContextHolder.getContext().authentication = authentication } - // 3. 다음 필터로 진행 + // 다음 필터로 진행 filterChain.doFilter(request, response) } - // Request Header에서 토큰 정보 추출 + /** + * Request Header에서 표준 Bearer 스키마(RFC 6750)를 준수하는 토큰 문자열을 파싱합니다. + */ private fun resolveToken(request: HttpServletRequest): String? { val bearerToken = request.getHeader(AUTHORIZATION_HEADER) if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { - return bearerToken.substring(7) // "Bearer " 이후의 문자열만 반환 + return bearerToken.substring(7) // "Bearer " 접두어 제거 } return null } diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt index c79f182..26df401 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/jwt/JwtProvider.kt @@ -4,7 +4,7 @@ import io.jsonwebtoken.* import io.jsonwebtoken.io.Decoders import io.jsonwebtoken.security.Keys 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.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication @@ -15,6 +15,13 @@ import org.springframework.stereotype.Component import java.util.* import javax.crypto.SecretKey +/** + * [JWT 토큰 관리자] + * + * JWT(Json Web Token)의 생성, 파싱, 서명 검증을 담당하는 핵심 컴포넌트입니다. + * 'jjwt' 라이브러리를 사용하여 표준 규격(RFC 7519)에 맞는 토큰을 발급하며, + * 대칭키 암호화 알고리즘(HMAC-SHA)을 사용하여 서명의 무결성을 보장합니다. + */ @Component class JwtProvider( @Value("\${jwt.secret}") secretKey: String, @@ -23,28 +30,36 @@ class JwtProvider( ) { 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 { val authorities = authentication.authorities.joinToString(",") { it.authority } val now = Date().time - // 👇 [수정] Principal을 CustomUserDetails로 캐스팅하여 정보 추출 + // 인증 객체에서 비즈니스 도메인 정보 추출 (CustomUserDetails 활용) val principal = authentication.principal as CustomUserDetails val memberId = principal.memberId val nickname = principal.nickname - // Access Token 생성 + // 1. Access Token 생성 (Stateless 인증용, 짧은 유효기간) val accessTokenExpiresIn = Date(now + accessTokenValidity) val accessToken = Jwts.builder() - .subject(authentication.name) // email - .claim("auth", authorities) // 권한 정보 (ROLE_USER 등) - .claim("memberId", memberId) // 👈 [추가] 프론트엔드 식별용 ID - .claim("nickname", nickname) // 👈 [추가] 프론트엔드 표기용 닉네임 + .subject(authentication.name) // 표준 sub claim (Email) + .claim("auth", authorities) // 사용자 권한 (ROLE_USER 등) + .claim("memberId", memberId) // 프론트엔드 식별 편의성 제공 + .claim("nickname", nickname) // 프론트엔드 표기 편의성 제공 .expiration(accessTokenExpiresIn) .signWith(key) .compact() - // Refresh Token 생성 (권한 정보 등은 제외하고 만료일만 설정) + // 2. Refresh Token 생성 (토큰 갱신용, 긴 유효기간) + // 불필요한 정보 노출을 최소화하기 위해 식별자(sub)와 만료일만 포함 val refreshToken = Jwts.builder() .subject(authentication.name) .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 { val claims = parseClaims(accessToken) @@ -71,25 +89,31 @@ class JwtProvider( .split(",") .map { SimpleGrantedAuthority(it) } + // UserDetails 객체를 생성하여 Authentication에 담음 (비밀번호는 불필요하므로 빈 문자열) val principal = User(claims.subject, "", authorities) return UsernamePasswordAuthenticationToken(principal, "", authorities) } - // 3. 토큰 검증 (만료 여부, 위변조 여부 확인) + /** + * 토큰의 유효성을 검증합니다. + * + * 서명 위조, 만료, 형식 오류 등 다양한 예외 케이스를 정교하게 catch하여 처리합니다. + * 필터 레벨에서 호출되므로 false 반환 시 해당 요청은 인증 실패로 간주됩니다. + */ fun validateToken(token: String): Boolean { try { Jwts.parser().verifyWith(key).build().parseSignedClaims(token) return true } catch (e: SecurityException) { - // log.info("잘못된 JWT 서명입니다.") + // log.warn("잘못된 JWT 서명입니다.") } catch (e: MalformedJwtException) { - // log.info("잘못된 JWT 서명입니다.") + // log.warn("손상된 JWT 토큰입니다.") } catch (e: ExpiredJwtException) { - // log.info("만료된 JWT 토큰입니다.") + // log.warn("만료된 JWT 토큰입니다.") } catch (e: UnsupportedJwtException) { - // log.info("지원되지 않는 JWT 토큰입니다.") + // log.warn("지원되지 않는 JWT 토큰입니다.") } catch (e: IllegalArgumentException) { - // log.info("JWT 토큰이 잘못되었습니다.") + // log.warn("JWT 토큰이 비어있거나 잘못되었습니다.") } return false } @@ -98,6 +122,7 @@ class JwtProvider( return try { Jwts.parser().verifyWith(key).build().parseSignedClaims(accessToken).payload } catch (e: ExpiredJwtException) { + // 만료된 토큰이더라도 Claims 정보(사용자 ID 등)가 필요할 수 있으므로 예외에서 꺼내 반환 e.claims } } diff --git a/src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt b/src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt index 21a4fb7..9e41ddc 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt @@ -7,10 +7,23 @@ import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice +/** + * [전역 예외 처리 핸들러] + * + * 애플리케이션 전반에서 발생하는 예외(Exception)를 중앙에서 캡처하여 + * 클라이언트에게 일관된 포맷(ApiResponse)의 에러 응답을 반환합니다. + * @RestControllerAdvice를 사용하여 모든 컨트롤러에 AOP(Aspect Oriented Programming) 방식으로 적용됩니다. + */ @RestControllerAdvice class GlobalExceptionHandler { - // 1. 비즈니스 로직 에러 (의도적인 throw) + /** + * [비즈니스 로직 예외 처리] + * + * 서비스 계층(Service Layer)에서 검증 로직 수행 중 의도적으로 발생시킨 예외를 처리합니다. + * 예: 중복된 이메일, 존재하지 않는 게시글 조회 등 + * 이는 클라이언트의 잘못된 요청(Bad Request)으로 간주하여 400 상태 코드를 반환합니다. + */ @ExceptionHandler(IllegalArgumentException::class, IllegalStateException::class) fun handleBusinessException(e: RuntimeException): ResponseEntity> { return ResponseEntity @@ -18,7 +31,13 @@ class GlobalExceptionHandler { .body(ApiResponse.error(e.message ?: "잘못된 요청입니다.")) } - // 2. @Valid 검증 실패 (DTO 유효성 체크) + /** + * [입력값 유효성 검증 실패 처리] + * + * @Valid 어노테이션에 의해 DTO 검증 실패 시 발생하는 예외(MethodArgumentNotValidException)를 처리합니다. + * 여러 필드에서 에러가 발생할 수 있으나, 클라이언트가 즉시 인지하고 수정할 수 있도록 + * 첫 번째 에러 메시지만 추출하여 간결하게 반환합니다. + */ @ExceptionHandler(MethodArgumentNotValidException::class) fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity> { val message = e.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "입력값이 올바르지 않습니다." @@ -27,10 +46,19 @@ class GlobalExceptionHandler { .body(ApiResponse.error(message)) } - // 3. 나머지 알 수 없는 에러 + /** + * [시스템 예외 처리 (Fallback)] + * + * 명시적으로 처리되지 않은 모든 예외를 잡아내는 최후의 방어선입니다. + * NullPointerException이나 DB 연결 실패 등 예측하지 못한 서버 내부 오류가 이에 해당합니다. + * + * [보안 전략] + * 내부 로직이 노출될 수 있는 스택 트레이스(Stack Trace)는 클라이언트에게 절대 반환하지 않고 서버 로그로만 남기며, + * 사용자에게는 일반적인 500 에러 메시지만 전달합니다. + */ @ExceptionHandler(Exception::class) fun handleException(e: Exception): ResponseEntity> { - e.printStackTrace() // 로그 남기기 + e.printStackTrace() // 실제 운영 환경에서는 SLF4J 등의 로거를 사용하여 파일/ELK로 수집해야 함 return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponse.error("서버 내부 오류가 발생했습니다.")) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt index 6a40ac2..5a97e66 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt @@ -14,6 +14,13 @@ import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * [인증 비즈니스 로직 서비스] + * + * 회원가입, 로그인, 토큰 재발급 등 계정 보안과 관련된 핵심 로직을 담당합니다. + * DB(Member), Redis(RefreshToken), Email(Verification) 등 여러 인프라 자원을 오케스트레이션하여 + * 안전하고 무결한 인증 프로세스를 보장합니다. + */ @Service @Transactional(readOnly = true) class AuthService( @@ -23,12 +30,15 @@ class AuthService( private val jwtProvider: JwtProvider, private val refreshTokenRepository: RefreshTokenRepository, private val emailService: EmailService, - // 👇 추가: DB에서 유저 정보를 다시 로드하기 위해 필요 private val userDetailsService: UserDetailsService ) { /** - * 회원가입 + * 신규 회원을 등록합니다. + * + * [스팸 방지 전략] + * 무분별한 가입을 막기 위해 가입 즉시 활성화(Active)하지 않고, + * `isVerified = false` 상태로 저장한 뒤 이메일 인증을 강제합니다. */ @Transactional fun signup(request: SignupRequest) { @@ -44,98 +54,115 @@ class AuthService( password = passwordEncoder.encode(request.password), nickname = request.nickname, role = Role.ROLE_USER, - isVerified = false + isVerified = false // 초기 상태는 미인증 ) memberRepository.save(member) + // 비동기 처리를 고려할 수 있으나, 가입 직후 메일 수신이 중요하므로 동기 호출 emailService.sendVerificationCode(request.email) } /** - * 로그인 + * 사용자 자격 증명을 검증하고 초기 토큰을 발급합니다. + * + * 단순 ID/PW 검사뿐만 아니라, 이메일 인증 여부(Business Rule)를 체크하여 + * 미인증 계정의 접근을 원천 차단합니다. */ @Transactional fun login(request: LoginRequest): TokenDto { val member = memberRepository.findByEmail(request.email) ?: throw IllegalArgumentException("가입되지 않은 이메일입니다.") - // 비밀번호 체크 + // 비밀번호 체크 (Bcrypt) if (!passwordEncoder.matches(request.password, member.password)) { throw IllegalArgumentException("비밀번호가 일치하지 않습니다.") } - // 이메일 인증 여부 체크 + // 계정 활성화 여부 체크 if (!member.isVerified) { throw IllegalStateException("이메일 인증이 필요합니다.") } - // 1. ID/PW 기반의 인증 토큰 생성 + // 1. Spring Security 인증 토큰 생성 val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password) - // 2. 실제 검증 (사용자 비밀번호 체크) + // 2. 실제 검증 수행 (CustomUserDetailsService 호출됨) val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken) - // 3. 인증 정보를 기반으로 JWT 토큰 생성 + // 3. 인증 정보를 기반으로 JWT(Access + Refresh) 생성 val tokenDto = jwtProvider.generateTokenDto(authentication) - // 4. RefreshToken Redis 저장 (RTR: 기존 토큰 덮어쓰기) + // 4. Refresh Token을 Redis에 저장 (RTR 전략의 기준점) refreshTokenRepository.save(authentication.name, tokenDto.refreshToken) return tokenDto } /** - * 토큰 재발급 (RTR 적용) + * Access Token 만료 시 토큰을 갱신합니다. + * + * [핵심 보안 전략: Refresh Token Rotation (RTR)] + * 보안성을 높이기 위해 Refresh Token을 일회용으로 사용합니다. + * 토큰 재발급 요청 시 기존 Refresh Token을 폐기하고, 새로운 Refresh Token을 발급합니다. + * + * [토큰 탈취 감지] + * 만약 이미 사용된(폐기된) Refresh Token으로 요청이 들어온다면, 이는 토큰이 탈취된 것으로 간주하여 + * 해당 사용자의 저장된 모든 토큰을 삭제하고 강제 로그아웃 처리합니다. */ @Transactional fun reissue(accessToken: String, refreshToken: String): TokenDto { - // 1. 리프레시 토큰 검증 (만료 여부, 위변조 여부) + // 1. 토큰 자체의 유효성 검증 (위변조 여부) if (!jwtProvider.validateToken(refreshToken)) { throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.") } - // 2. 액세스 토큰에서 User ID(Email) 가져오기 - // (주의: 여기서 authentication.principal은 CustomUserDetails가 아닐 수 있음) + // 2. Access Token에서 사용자 정보 추출 val tempAuthentication = jwtProvider.getAuthentication(accessToken) - // 3. Redis에서 저장된 Refresh Token 가져오기 + // 3. Redis에 저장된 최신 Refresh Token 조회 val savedRefreshToken = refreshTokenRepository.findByEmail(tempAuthentication.name) ?: throw IllegalArgumentException("로그아웃 된 사용자입니다.") - // 4. 토큰 일치 여부 확인 (재사용 방지) + // 4. [RTR 핵심] 토큰 불일치 감지 (재사용 시도 -> 탈취 의심) if (savedRefreshToken != refreshToken) { - refreshTokenRepository.delete(tempAuthentication.name) - throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.") + refreshTokenRepository.delete(tempAuthentication.name) // 보안 조치: 세션 전체 파기 + throw IllegalArgumentException("토큰 정보가 일치하지 않습니다. (재사용 감지됨)") } - // ✨ 5. [수정됨] DB에서 유저 정보(CustomUserDetails) 다시 로드 - // JwtProvider.generateTokenDto()가 CustomUserDetails를 필요로 하므로 필수 + // 5. DB에서 최신 유저 정보 다시 로드 + // (토큰 갱신 시점의 권한 변경이나 닉네임 변경 등을 반영하기 위함) val userDetails = userDetailsService.loadUserByUsername(tempAuthentication.name) - // ✨ 로드한 userDetails로 새로운 Authentication 생성 + // 6. 새로운 Authentication 객체 생성 val newAuthentication = UsernamePasswordAuthenticationToken( userDetails, null, userDetails.authorities ) - // 6. 새 토큰 생성 (Rotation) + // 7. 새 토큰 쌍 발급 (Rotate) val newTokenDto = jwtProvider.generateTokenDto(newAuthentication) - // 7. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장) + // 8. Redis 업데이트 (기존 토큰 덮어쓰기) refreshTokenRepository.save(newAuthentication.name, newTokenDto.refreshToken) return newTokenDto } /** - * 로그아웃 + * 로그아웃 처리 + * + * 서버 측에서 Refresh Token을 삭제함으로써, Access Token이 만료되는 즉시 + * 더 이상 갱신할 수 없도록 세션을 종료시킵니다. */ @Transactional fun logout(email: String) { refreshTokenRepository.delete(email) } - // 3. 이메일 인증 확인 + /** + * 이메일 인증 코드를 검증하고 계정 상태를 활성화(Verify)합니다. + * 상태 변경(update)이 발생하므로 트랜잭션 내에서 처리됩니다. + */ @Transactional fun verifyEmail(email: String, code: String) { val member = memberRepository.findByEmail(email) @@ -145,12 +172,12 @@ class AuthService( throw IllegalArgumentException("이미 인증된 회원입니다.") } - // 코드 검증 + // Redis에 저장된 코드와 대조 if (!emailService.verifyCode(email, code)) { throw IllegalArgumentException("인증 코드가 올바르지 않거나 만료되었습니다.") } - // 인증 상태 업데이트 + // 인증 성공 시 회원 상태 변경 member.verify() } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetails.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetails.kt index 7da1c12..41ac911 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetails.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetails.kt @@ -3,9 +3,25 @@ package me.wypark.blogbackend.domain.auth import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.userdetails.User +/** + * [Spring Security 사용자 정보 확장 구현체] + * + * Spring Security의 표준 UserDetails(User) 클래스를 상속받아 + * 비즈니스 로직에 필요한 추가 식별자들을 포함하도록 확장한 클래스입니다. + * + * [설계 의도] + * 기본 User 객체는 username(email)과 password, 권한 정보만 가지고 있습니다. + * 하지만 실제 서비스 로직이나 JWT 토큰 생성 시에는 사용자의 DB PK(id)나 닉네임이 자주 필요합니다. + * 매 요청마다 DB를 다시 조회하는 오버헤드를 줄이기 위해, 인증 객체(Authentication) 내부에 + * 이 정보들을 함께 캐싱(Caching)하여 운반하도록 설계했습니다. + */ class CustomUserDetails( + // DB의 Primary Key (비즈니스 로직에서 조인이나 조회 시 사용) val memberId: Long, + + // UI 표시용 닉네임 (매번 회원 정보를 조회하지 않기 위함) val nickname: String, + username: String, password: String, authorities: Collection diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt index 7c047ce..f644545 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt @@ -8,22 +8,43 @@ import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.stereotype.Service +/** + * [Spring Security 사용자 로드 서비스] + * + * Spring Security의 인증 매니저(AuthenticationManager)가 실제 DB에 저장된 사용자 정보를 + * 조회할 수 있도록 지원하는 핵심 인터페이스(UserDetailsService)의 구현체입니다. + * + * 도메인 영역의 [Member] 엔티티를 시큐리티 영역의 [UserDetails] 객체로 변환(Adapt)하는 역할을 수행합니다. + */ @Service class CustomUserDetailsService( private val memberRepository: MemberRepository ) : UserDetailsService { + /** + * 사용자의 식별자(여기서는 이메일)로 DB에서 사용자 정보를 조회합니다. + * 로그인 요청 시 내부적으로 호출되며, 조회 실패 시 시큐리티 규격에 맞는 예외를 던집니다. + */ override fun loadUserByUsername(username: String): UserDetails { return memberRepository.findByEmail(username) ?.let { createUserDetails(it) } ?: throw UsernameNotFoundException("해당 유저를 찾을 수 없습니다: $username") } + /** + * [UserDetails 변환 로직] + * + * 조회된 Member 엔티티를 기반으로 인증 객체(CustomUserDetails)를 생성합니다. + * + * [최적화 전략] + * Spring Security가 제공하는 기본 User 객체 대신, 직접 정의한 CustomUserDetails를 반환함으로써 + * 추후 컨트롤러나 서비스 계층에서 @AuthenticationPrincipal을 통해 + * DB 추가 조회 없이도 사용자 식별자(ID)와 닉네임에 즉시 접근할 수 있도록 설계했습니다. + */ private fun createUserDetails(member: Member): UserDetails { - // [수정] 표준 User 객체 대신, ID와 닉네임을 포함하는 CustomUserDetails 반환 return CustomUserDetails( - memberId = member.id!!, // 토큰에 넣을 ID - nickname = member.nickname, // 토큰에 넣을 닉네임 + memberId = member.id!!, // 비즈니스 로직용 PK 캐싱 + nickname = member.nickname, // UI 렌더링용 닉네임 캐싱 username = member.email, password = member.password, authorities = listOf(SimpleGrantedAuthority(member.role.name)) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt index 5b743a4..41d8c4d 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt @@ -7,17 +7,32 @@ import org.springframework.stereotype.Service import java.util.concurrent.TimeUnit import kotlin.random.Random +/** + * [이메일 인증 서비스] + * + * 회원가입 시 본인 확인을 위한 OTP(One Time Password) 발송 및 검증 로직을 담당합니다. + * + * [아키텍처 설계] + * 인증 코드의 상태(State) 관리를 위해 인메모리 DB인 Redis를 사용합니다. + * RDB를 사용하지 않음으로써 만료된 코드의 정리(Cleanup) 비용을 없애고, 빠른 액세스 속도를 보장합니다. + */ @Service class EmailService( private val javaMailSender: JavaMailSender, private val redisTemplate: RedisTemplate ) { - // 인증 코드 전송 + /** + * 인증 코드를 생성하고 이메일로 발송합니다. + * + * 생성된 코드는 Redis에 저장되며, 보안을 위해 짧은 유효시간(TTL)을 가집니다. + * 이메일 발송은 외부 SMTP 서버를 이용하므로, 트래픽 급증 시 비동기 큐(RabbitMQ/Kafka) 도입을 고려할 수 있습니다. + */ fun sendVerificationCode(email: String) { val code = createVerificationCode() - // 1. Redis에 저장 (Key: "Verify:이메일", Value: 코드, 유효시간: 5분) + // Redis 저장 전략: Key에 Prefix("Verify:")를 붙여 네임스페이스를 구분하고, + // 5분의 TTL(Time-To-Live)을 설정하여 별도의 삭제 로직 없이 자동 만료되도록 처리함. redisTemplate.opsForValue().set( "Verify:$email", code, @@ -25,20 +40,30 @@ class EmailService( TimeUnit.MINUTES ) - // 2. 메일 발송 sendMail(email, code) } - // 인증 코드 검증 + /** + * 사용자가 입력한 코드와 Redis에 저장된 원본 코드를 대조합니다. + * 코드가 만료되었거나 일치하지 않을 경우 false를 반환합니다. + */ fun verifyCode(email: String, code: String): Boolean { val savedCode = redisTemplate.opsForValue().get("Verify:$email") return savedCode != null && savedCode == code } + /** + * 6자리 숫자(100000 ~ 999999)로 구성된 난수를 생성합니다. + * 보안성과 사용자 입력 편의성(Usability) 사이의 균형을 맞춘 길이입니다. + */ private fun createVerificationCode(): String { - return Random.nextInt(100000, 999999).toString() // 6자리 난수 + return Random.nextInt(100000, 999999).toString() } + /** + * HTML 템플릿을 사용하여 인증 메일을 발송합니다. + * 단순 텍스트보다 신뢰감을 주고 브랜드 아이덴티티를 전달하기 위해 인라인 스타일(CSS)을 적용했습니다. + */ private fun sendMail(email: String, code: String) { val mimeMessage = javaMailSender.createMimeMessage() val helper = MimeMessageHelper(mimeMessage, "utf-8") @@ -46,6 +71,7 @@ class EmailService( helper.setTo(email) helper.setSubject("[Blog] 회원가입 인증 코드입니다.") + // HTML 본문 구성 (이메일 클라이언트 호환성을 위해 Inline CSS 사용 권장) val htmlContent = """

@@ -71,7 +97,7 @@ class EmailService(

""".trimIndent() - helper.setText(htmlContent, true) // true: HTML 모드 켜기 + helper.setText(htmlContent, true) // true: HTML 모드 활성화 javaMailSender.send(mimeMessage) } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/RefreshTokenRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/RefreshTokenRepository.kt index 39b6b02..559a5ad 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/auth/RefreshTokenRepository.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/RefreshTokenRepository.kt @@ -5,12 +5,31 @@ import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Repository 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 class RefreshTokenRepository( private val redisTemplate: RedisTemplate, @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) { redisTemplate.opsForValue().set( "RT:$email", @@ -20,12 +39,22 @@ class RefreshTokenRepository( ) } - // 조회 + /** + * 사용자의 이메일로 저장된 Refresh Token을 조회합니다. + * + * 토큰 재발급(Reissue) 요청 시 클라이언트가 보낸 토큰과 서버에 저장된 토큰의 일치 여부를 + * 검증하기 위해 사용됩니다. (Refresh Token Rotation 전략의 핵심) + */ fun findByEmail(email: String): String? { return redisTemplate.opsForValue().get("RT:$email") } - // 삭제 (로그아웃 시) + /** + * Refresh Token을 삭제합니다. + * + * 사용자가 로그아웃하거나, 보안상의 이유로 토큰을 무효화해야 할 때 호출됩니다. + * Redis에서 즉시 제거(Evict)하므로, 이후 해당 토큰으로는 액세스 토큰을 재발급받을 수 없습니다. + */ fun delete(email: String) { redisTemplate.delete("RT:$email") } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt index b8fb135..71cb1f0 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/Category.kt @@ -2,35 +2,64 @@ package me.wypark.blogbackend.domain.category import jakarta.persistence.* +/** + * [카테고리 엔티티] + * + * 게시글 분류를 위한 계층형 구조(Hierarchical Structure)를 정의합니다. + * 자기 자신을 참조하는 Self-Referencing 방식을 사용하여 무한 깊이의 트리 구조를 구현했습니다. + */ @Entity class Category( @Column(nullable = false) var name: String, + /** + * [부모 카테고리] + * 루트(Root) 카테고리의 경우 null을 허용합니다. + * N+1 문제를 방지하기 위해 기본 Fetch 전략을 LAZY로 설정했습니다. + */ @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") - var parent: Category? = null, // 부모 카테고리 (없으면 최상위) + var parent: Category? = null, + /** + * [자식 카테고리 목록] + * + * [Cascade 설정] + * 부모 카테고리가 삭제될 경우, 데이터 무결성을 위해 하위 카테고리들도 함께 삭제(CascadeType.ALL)되도록 설정했습니다. + * (실무 정책에 따라 삭제 대신 '미분류'로 이동시키거나 삭제를 막을 수도 있습니다.) + */ @OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL]) - val children: MutableList = mutableListOf() // 자식 카테고리들 + val children: MutableList = mutableListOf() ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null - // 연관관계 편의 메서드 (부모-자식 연결) + /** + * [연관관계 편의 메서드] + * + * 양방향 관계인 Category 엔티티에서 부모와 자식 간의 참조를 원자적(Atomic)으로 설정합니다. + * 객체 관점에서 부모의 children 리스트에도 추가하고, 자식의 parent 필드도 설정해주어야 + * 영속성 컨텍스트(Persistence Context) 내에서 데이터 정합성이 유지됩니다. + */ fun addChild(child: Category) { this.children.add(child) child.parent = this } - // 이름 변경 fun updateName(name: String) { this.name = name } - // 부모 변경 (계층 이동) + /** + * [부모 카테고리 변경 (이동)] + * + * 카테고리의 위치를 트리 구조 내에서 이동시킵니다. + * 기존 부모와의 관계를 명시적으로 끊고 새로운 부모와 연결함으로써, + * JPA 1차 캐시 상의 데이터 불일치를 방지합니다. + */ fun changeParent(newParent: Category?) { - // 1. 기존 부모와의 관계 끊기 + // 1. 기존 부모와의 관계 끊기 (메모리 상의 리스트 정리) this.parent?.children?.remove(this) // 2. 새 부모 설정 diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt index 0a5f477..d9ccdfe 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryRepository.kt @@ -3,15 +3,39 @@ package me.wypark.blogbackend.domain.category import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query +/** + * [카테고리 데이터 접근 계층] + * + * 카테고리 엔티티의 영속성(Persistence)을 관리합니다. + * 계층형 구조(Hierarchy)의 특성을 고려하여 N+1 문제를 방지하기 위한 + * 최적화된 Fetch Join 쿼리를 포함하고 있습니다. + */ interface CategoryRepository : JpaRepository { - // 부모가 없는 최상위 카테고리들만 조회 (이걸 가져오면 자식들은 줄줄이 딸려옵니다) + /** + * 최상위(Root) 카테고리 목록을 조회합니다. + * + * [성능 최적화: Fetch Join] + * 카테고리 트리를 구성할 때, 지연 로딩(Lazy Loading)으로 인한 N+1 문제를 방지하기 위해 + * `LEFT JOIN FETCH`를 사용하여 자식 카테고리(children)까지 한 번의 쿼리로 즉시 로딩합니다. + * 이를 통해 애플리케이션 레벨에서 재귀적으로 트리를 구성할 때 DB 접근 횟수를 최소화합니다. + */ @Query("SELECT DISTINCT c FROM Category c LEFT JOIN FETCH c.children WHERE c.parent IS NULL") fun findAllRoots(): List - // 카테고리 이름 중복 검사 (같은 레벨에서 중복 방지용) + /** + * 카테고리 이름의 중복 여부를 검사합니다. + * + * 동일한 레벨 내에서 같은 이름의 카테고리가 생성되는 것을 방지하여 + * 사용자의 혼란을 막고 데이터의 유니크성을 보장하기 위해 사용됩니다. + */ fun existsByName(name: String): Boolean - // 이름으로 찾기 (게시글 작성 시 필요) + /** + * 이름으로 카테고리를 조회합니다. + * + * 게시글 작성 시 카테고리 이름 문자열을 엔티티로 매핑하거나, + * URL 경로(Path Variable)를 통해 카테고리를 찾을 때 활용됩니다. + */ fun findByName(name: String): Category? } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt index b6a0966..edb4a5a 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/category/CategoryService.kt @@ -8,6 +8,12 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * [카테고리 비즈니스 로직] + * + * 카테고리의 생성, 수정, 삭제 및 계층 구조(Tree) 관리를 담당합니다. + * 단순한 데이터 조작을 넘어, 순환 참조(Cycle) 방지와 같은 구조적 무결성 검증 로직이 포함되어 있습니다. + */ @Service @Transactional(readOnly = true) class CategoryService( @@ -15,24 +21,36 @@ class CategoryService( private val postRepository: PostRepository ) { - // 예약어 검증 메서드 + /** + * 시스템 예약어 사용을 방지합니다. + * '미분류(uncategorized)' 등 시스템 내부 로직에서 특별하게 취급하는 이름은 + * 사용자가 임의로 생성하거나 수정할 수 없도록 제한합니다. + */ private fun validateReservedName(name: String) { if (name.equals("uncategorized", ignoreCase = true)) { throw IllegalArgumentException("'uncategorized'는 시스템 예약어이므로 사용할 수 없습니다.") } } + /** + * 전체 카테고리를 계층형 트리 구조로 반환합니다. + * Root 노드만 조회하면, 엔티티 내의 연관관계와 Fetch Join을 통해 하위 노드들이 재귀적으로 매핑됩니다. + */ fun getCategoryTree(): List { val roots = categoryRepository.findAllRoots() return roots.map { CategoryResponse.from(it) } } + /** + * 신규 카테고리를 생성합니다. + * 데이터 정합성을 위해 이름 중복 검사와 부모 카테고리의 존재 여부를 엄격하게 검증(Strict Validation)합니다. + */ @Transactional fun createCategory(request: CategoryCreateRequest): Long { // 1. 예약어 검증 validateReservedName(request.name) - // 2. 중복 체크 + // 2. 중복 체크 (Unique Constraint) if (categoryRepository.existsByName(request.name)) { throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.") } @@ -52,14 +70,21 @@ class CategoryService( return categoryRepository.save(category).id!! } + /** + * 카테고리 정보를 수정합니다. (이름 변경 및 트리 구조 이동) + * + * [구조 변경 시 주의사항] + * 부모 카테고리를 변경하는 경우, 트리 구조가 깨지거나 순환 참조(Cycle)가 발생할 위험이 있습니다. + * 따라서 이동 전에 `validateHierarchy`를 통해 구조적 유효성을 반드시 확인해야 합니다. + */ @Transactional fun updateCategory(id: Long, request: CategoryUpdateRequest) { val category = categoryRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.") - // 1. 이름 변경 (변경 시에만 검증) + // 1. 이름 변경 (실제 변경이 있을 때만 검증 수행) if (request.name != null && category.name != request.name) { - validateReservedName(request.name) // 예약어 검증 + validateReservedName(request.name) if (categoryRepository.existsByName(request.name)) { throw IllegalArgumentException("이미 존재하는 카테고리 이름입니다.") @@ -67,28 +92,42 @@ class CategoryService( category.updateName(request.name) } - // 2. 부모 변경 + // 2. 부모 변경 (구조 이동) val currentParentId = category.parent?.id val newParentId = request.parentId if (currentParentId != newParentId) { if (newParentId == null) { + // Root로 이동 category.changeParent(null) } else { + // 다른 하위 노드로 이동 val newParent = categoryRepository.findByIdOrNull(newParentId) ?: throw IllegalArgumentException("이동하려는 부모 카테고리가 존재하지 않습니다.") + // 순환 참조 검증 validateHierarchy(category, newParent) category.changeParent(newParent) } } } + /** + * [순환 참조 방지 로직] + * + * 카테고리 이동 시, 대상(Target)이 자신의 하위 카테고리로 들어가는 것을 방지합니다. + * 만약 허용할 경우, A -> B -> A 형태의 무한 루프가 발생하여 트리 조회가 불가능해집니다. + * + * @param target 이동하려는 카테고리 + * @param newParent 이동할 목적지(새 부모) + */ private fun validateHierarchy(target: Category, newParent: Category) { + // 1. 자기 자신을 부모로 설정하는 경우 if (target.id == newParent.id) { throw IllegalArgumentException("자기 자신을 부모로 설정할 수 없습니다.") } + // 2. 자신의 자손(Descendant)을 부모로 설정하는 경우 var parent = newParent.parent while (parent != null) { if (parent.id == target.id) { @@ -98,19 +137,33 @@ class CategoryService( } } + /** + * 카테고리를 삭제합니다. + * + * [삭제 정책: Safe Deletion] + * 카테고리가 삭제되더라도, 해당 카테고리에 속한 게시글(Post)은 삭제되지 않아야 합니다. + * 따라서 삭제 대상 카테고리 및 그 하위 카테고리들에 속한 모든 게시글의 category_id를 + * NULL(미분류)로 업데이트한 후, 카테고리만 물리적으로 제거합니다. + */ @Transactional fun deleteCategory(id: Long) { val category = categoryRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("존재하지 않는 카테고리입니다.") + // 삭제할 카테고리와 그 자손들을 모두 수집 (Flattening) val categoriesToDelete = mutableListOf() collectAllCategories(category, categoriesToDelete) + // 연관된 게시글들의 카테고리 연결 해제 (Bulk Update로 성능 최적화) postRepository.bulkUpdateCategoryToNull(categoriesToDelete) + // 카테고리 삭제 (Cascade 설정에 의해 하위 카테고리도 DB에서 삭제됨) categoryRepository.delete(category) } + /** + * 재귀적으로 하위 카테고리를 모두 순회하여 리스트에 담습니다. + */ private fun collectAllCategories(category: Category, list: MutableList) { list.add(category) category.children.forEach { collectAllCategories(it, list) } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt b/src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt index cf72c01..beaf2f8 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/comment/Comment.kt @@ -5,6 +5,16 @@ import me.wypark.blogbackend.domain.common.BaseTimeEntity import me.wypark.blogbackend.domain.post.Post import me.wypark.blogbackend.domain.user.Member +/** + * [댓글 엔티티] + * + * 게시글에 대한 사용자 반응(Interaction)을 저장하는 도메인 모델입니다. + * + * [핵심 설계 전략] + * 1. 계층형 구조(Hierarchy): 대댓글 기능을 지원하기 위해 자기 자신을 참조(Self-Referencing)하는 구조를 가집니다. + * 2. 하이브리드 인증 지원: 참여율을 높이기 위해 회원(Member)뿐만 아니라 비회원(Guest)의 작성도 허용하며, + * 이에 따라 작성자 정보를 조건부로 저장하는 유연한 스키마를 채택했습니다. + */ @Entity class Comment( @Column(nullable = false, columnDefinition = "TEXT") @@ -14,36 +24,61 @@ class Comment( @JoinColumn(name = "post_id") val post: Post, + /** + * [계층형 구조 - 부모 댓글] + * 최상위 댓글일 경우 null이며, 대댓글(Reply)일 경우 상위 댓글을 참조합니다. + */ @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") - var parent: Comment? = null, // 대댓글용 부모 댓글 + var parent: Comment? = null, + /** + * [계층형 구조 - 자식 댓글] + * + * [삭제 정책: Cascade & OrphanRemoval] + * 부모 댓글이 삭제되면 그에 딸린 대댓글들도 논리적으로 존재 가치를 잃게 되므로, + * 영속성 전이(Cascade)를 통해 DB에서 함께 삭제되도록 설정하여 데이터 정합성을 유지합니다. + */ @OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true) val children: MutableList = mutableListOf(), - // --- 1. 회원일 경우 --- + // ================================================================================= + // [작성자 정보 관리 전략 (Hybrid)] + // 회원은 Member 연관관계를 사용하고, 비회원은 별도의 컬럼(guest_*)을 사용합니다. + // ================================================================================= + + // 1. 회원일 경우 (FK) @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") val member: Member? = null, - // --- 2. 비회원일 경우 --- + // 2. 비회원일 경우 (임시 식별 정보) @Column var guestNickname: String? = null, + /** + * 비회원용 수정/삭제 비밀번호 + * Note: 보안을 위해 실제 운영 환경에서는 평문 저장이 아닌 단방향 암호화(Hash) 후 저장해야 합니다. + */ @Column - var guestPassword: String? = null // 암호화해서 저장 권장 + var guestPassword: String? = null ) : BaseTimeEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null - // 댓글 작성자 이름 가져오기 (회원이면 닉네임, 비회원이면 입력한 이름) + /** + * 뷰 렌더링을 위한 작성자 이름 반환 로직입니다. + * 회원 여부에 따라 닉네임 소스(Source)가 달라지므로, 이를 캡슐화하여 클라이언트에 일관된 값을 제공합니다. + */ fun getAuthorName(): String { return member?.nickname ?: guestNickname ?: "알 수 없음" } - // 비회원 비밀번호 검증 + /** + * 비회원 댓글 삭제 요청 시 권한 검증을 수행합니다. + */ fun matchGuestPassword(password: String): Boolean { return this.guestPassword == password } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt index 0aef124..4fef9d6 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentRepository.kt @@ -3,12 +3,31 @@ package me.wypark.blogbackend.domain.comment import me.wypark.blogbackend.domain.post.Post import org.springframework.data.jpa.repository.JpaRepository +/** + * [댓글 데이터 접근 계층] + * + * 댓글 엔티티의 영속성 관리를 담당하는 리포지토리입니다. + * 계층형 댓글 구조(Root-Child)를 효율적으로 조회하고, + * 게시글 생명주기에 따른 종속적인 데이터 정리(Cleanup) 기능을 제공합니다. + */ interface CommentRepository : JpaRepository { - // 특정 게시글의 모든 댓글 조회 (최상위 부모 댓글 기준 + 작성순) - // 자식 댓글은 Entity의 children 필드를 통해 가져오거나, BatchSize로 최적화합니다. + /** + * 특정 게시글의 최상위(Root) 댓글 목록을 작성순으로 조회합니다. + * + * [계층형 데이터 조회 전략] + * 대댓글(Child)까지 모두 Eager Fetch로 가져올 경우 데이터 중복(Cartesian Product) 및 애플리케이션 메모리 부하가 발생할 수 있습니다. + * 따라서 Root 댓글만 우선 조회하고, 하위 댓글 컬렉션은 지연 로딩(Lazy Loading) 발생 시 + * 엔티티에 설정된 @BatchSize를 통해 IN 쿼리로 묶어서 가져오는 방식으로 N+1 문제를 최적화합니다. + */ fun findAllByPostAndParentIsNullOrderByCreatedAtAsc(post: Post): List - // 게시글 삭제 시 관련 댓글 전체 삭제용 + /** + * 게시글 삭제 시, 해당 게시글에 종속된 모든 댓글을 삭제합니다. + * + * [데이터 무결성 관리] + * 게시글(Post)이 사라지면 댓글(Comment)은 고아 데이터(Orphaned Data)가 되므로 + * 스토리지 낭비를 막고 참조 무결성을 유지하기 위해 함께 정리되어야 합니다. + */ fun deleteAllByPost(post: Post) } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt index 361cc15..ef3134e 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/comment/CommentService.kt @@ -12,46 +12,64 @@ import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * [댓글 비즈니스 로직] + * + * 게시글에 대한 사용자 반응(Interaction)을 처리하는 서비스입니다. + * + * [핵심 아키텍처: Hybrid Authentication] + * 사용자 참여율을 높이기 위해 로그인한 '회원'뿐만 아니라 '비회원(Guest)'의 활동도 허용합니다. + * 이에 따라 작성자 식별 및 권한 검증 로직이 이원화되어 처리됩니다. + */ @Service @Transactional(readOnly = true) class CommentService( private val commentRepository: CommentRepository, private val postRepository: PostRepository, private val memberRepository: MemberRepository, - private val passwordEncoder: PasswordEncoder // 비밀번호 암호화용 + private val passwordEncoder: PasswordEncoder ) { /** - * [Public] 특정 게시글의 댓글 목록 조회 (계층형) + * 특정 게시글의 댓글 목록을 계층형(Tree) 구조로 조회합니다. + * + * [조회 최적화 전략] + * DB에서 모든 댓글을 가져와 애플리케이션 메모리에서 트리를 구성하는 대신, + * 최상위(Root) 댓글만 조회하고 자식 댓글(Children)은 JPA의 관계 매핑과 @BatchSize를 통해 + * 필요 시점에 효율적으로 로딩(Lazy Loading)하는 방식을 택했습니다. */ fun getComments(postSlug: String): List { val post = postRepository.findBySlug(postSlug) ?: throw IllegalArgumentException("존재하지 않는 게시글입니다.") - // 최상위(부모가 null) 댓글만 가져오면, Entity 설정에 의해 자식들은 자동으로 딸려옴 + // Root 댓글 조회 (자식들은 DTO 변환 과정에서 재귀적으로 호출됨) val roots = commentRepository.findAllByPostAndParentIsNullOrderByCreatedAtAsc(post) return roots.map { CommentResponse.from(it) } } /** - * [Hybrid] 댓글 작성 (회원/비회원 공용) + * 댓글을 작성합니다. (회원/비회원 통합 처리) + * + * 인증 정보(userEmail) 유무에 따라 도메인 로직이 분기됩니다. + * - 회원: Member 엔티티와 연관관계를 맺어 영구적인 식별을 보장합니다. + * - 비회원: 닉네임과 비밀번호를 별도 컬럼에 저장하여 최소한의 식별 및 제어 권한을 부여합니다. */ @Transactional fun createComment(request: CommentSaveRequest, userEmail: String?): Long { - // 1. 게시글 조회 + // 1. 게시글 존재 확인 val post = postRepository.findBySlug(request.postSlug) ?: throw IllegalArgumentException("존재하지 않는 게시글입니다.") - // 2. 부모 댓글 조회 (대댓글인 경우) + // 2. 부모 댓글 조회 (대댓글인 경우 검증) val parent = request.parentId?.let { commentRepository.findByIdOrNull(it) ?: throw IllegalArgumentException("부모 댓글이 존재하지 않습니다.") } - // 3. 회원/비회원 구분 로직 + // 3. 작성자 유형별 엔티티 생성 (Factory Logic) val comment = if (userEmail != null) { - // [회원] DB에서 회원 정보 조회 후 연결 + // Case A: 회원 작성 val member = memberRepository.findByEmail(userEmail) ?: throw IllegalArgumentException("회원 정보를 찾을 수 없습니다.") @@ -59,10 +77,10 @@ class CommentService( content = request.content, post = post, parent = parent, - member = member // 회원 연결 + member = member ) } else { - // [비회원] 닉네임/비밀번호 필수 체크 + // Case B: 비회원 작성 (익명성 보장하되, 제어권 확보를 위해 비밀번호 필수) if (request.guestNickname.isNullOrBlank() || request.guestPassword.isNullOrBlank()) { throw IllegalArgumentException("비회원은 닉네임과 비밀번호가 필수입니다.") } @@ -72,39 +90,53 @@ class CommentService( post = post, parent = parent, guestNickname = request.guestNickname, - guestPassword = passwordEncoder.encode(request.guestPassword) + guestPassword = passwordEncoder.encode(request.guestPassword) // 보안상 단방향 암호화 저장 ) } - // 4. 부모가 있다면 연결 (양방향 편의) + // 4. 연관관계 편의 메서드 (객체 그래프 정합성 유지) parent?.children?.add(comment) return commentRepository.save(comment).id!! } + /** + * 댓글을 삭제합니다. + * + * [권한 검증 전략: Ownership Verification] + * 삭제 요청자가 실제 댓글 작성자인지 확인하는 로직입니다. + * 회원이라면 로그인 세션 정보를, 비회원이라면 작성 시 입력한 비밀번호를 검증 수단으로 사용합니다. + */ @Transactional fun deleteComment(commentId: Long, userEmail: String?, guestPassword: String?) { val comment = commentRepository.findByIdOrNull(commentId) ?: throw IllegalArgumentException("존재하지 않는 댓글입니다.") - // 권한 검증 + // 권한 검증 분기 if (userEmail != null) { - // [회원] 본인 댓글인지 확인 (이메일 비교) + // Case A: 회원 (이메일 불일치 시 예외) if (comment.member?.email != userEmail) { throw IllegalArgumentException("본인의 댓글만 삭제할 수 있습니다.") } } else { - // [비회원] 비밀번호 일치 확인 + // Case B: 비회원 (비밀번호 검증) + // DB에 저장된 해시값과 입력된 평문 비밀번호를 대조 if (comment.guestPassword == null || guestPassword == null || !passwordEncoder.matches(guestPassword, comment.guestPassword)) { throw IllegalArgumentException("비밀번호가 일치하지 않습니다.") } } - // 삭제 진행 + // 검증 통과 시 삭제 수행 commentRepository.delete(comment) } + /** + * [관리자 전용] 댓글 강제 삭제 + * + * 악성 댓글이나 스팸 처리를 위해, 작성자 확인 절차(Ownership Check)를 건너뛰고 + * 관리자 권한으로 즉시 데이터를 제거합니다. + */ @Transactional fun deleteCommentByAdmin(commentId: Long) { val comment = commentRepository.findByIdOrNull(commentId) @@ -113,6 +145,10 @@ class CommentService( commentRepository.delete(comment) } + /** + * 관리자 대시보드용 전체 댓글 조회 + * 계층 구조와 무관하게 시간순으로 페이징하여 모니터링 편의성을 제공합니다. + */ fun getAllComments(pageable: Pageable): Page { return commentRepository.findAll(pageable) .map { AdminCommentResponse.from(it) } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt b/src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt index 5d73e9f..3e1a7e2 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt @@ -8,14 +8,31 @@ import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.LocalDateTime -@MappedSuperclass // 상속받은 엔티티가 이 클래스의 필드(컬럼)를 인식하도록 함 -@EntityListeners(AuditingEntityListener::class) // JPA Auditing 기능 활성화 +/** + * [공통 시간 정보 엔티티] + * + * 모든 엔티티가 공통적으로 가져야 할 '생성 시간'과 '수정 시간'을 관리하는 상위 클래스입니다. + * + * [설계 의도] + * 반복적인 감사(Audit) 로직을 중복 구현하는 것을 방지하기 위해 JPA Auditing 기능을 적용했습니다. + * 이를 상속받는 엔티티들은 별도의 코드 작성 없이 데이터의 생명주기를 자동으로 추적할 수 있습니다. + */ +@MappedSuperclass // 테이블로 매핑되지 않고, 자식 클래스의 엔티티에 컬럼 정보만 제공함 (상속 관계 매핑 X) +@EntityListeners(AuditingEntityListener::class) // 엔티티의 변경 이벤트를 감지하여 시간 값을 자동으로 주입(Inject) abstract class BaseTimeEntity { + /** + * 최초 생성 시각 (Immutable) + * 데이터의 이력을 추적하는 기준이 되므로, 생성 이후에는 절대 변경되지 않도록 updatable = false를 설정하여 무결성을 보장합니다. + */ @CreatedDate - @Column(nullable = false, updatable = false) // 생성일은 수정 불가 + @Column(nullable = false, updatable = false) var createdAt: LocalDateTime = LocalDateTime.now() + /** + * 최종 수정 시각 + * 비즈니스 로직에 의해 데이터가 변경될 때마다 JPA가 자동으로 현재 시간을 갱신합니다. + */ @LastModifiedDate @Column(nullable = false) var updatedAt: LocalDateTime = LocalDateTime.now() diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt index b4ff4e3..14c2e51 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/image/ImageService.kt @@ -9,24 +9,42 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest import software.amazon.awssdk.services.s3.model.PutObjectRequest import java.util.* +/** + * [이미지 처리 서비스] + * + * AWS S3 또는 호환 가능한 Object Storage(MinIO 등)와의 통신을 전담하는 서비스입니다. + * 비즈니스 로직(게시글 작성 등)에서 파일 저장에 대한 세부 구현을 몰라도 되도록 + * 업로드 및 삭제 기능을 추상화하여 제공합니다. + */ @Service class ImageService( private val s3Client: S3Client, @Value("\${spring.cloud.aws.s3.endpoint:http://minio:9000}") private val endpoint: String ) { - private val bucketName = "blog-images" // 버킷 이름 + private val bucketName = "blog-images" + /** + * 서비스 초기화 시점에 버킷 존재 여부를 확인합니다. + * 로컬 개발 환경이나 초기 배포 시, 수동으로 스토리지를 세팅하는 번거로움을 줄이기 위해 + * 애플리케이션 레벨에서 인프라(Bucket & Policy)를 자동 프로비저닝(Auto-Provisioning)합니다. + */ init { createBucketIfNotExists() } + /** + * 이미지를 스토리지에 업로드하고 접근 가능한 URL을 반환합니다. + * + * [파일명 생성 전략] + * 사용자가 업로드한 원본 파일명은 중복될 가능성이 높으므로, + * UUID(Universally Unique Identifier)를 사용하여 고유한 식별자를 생성함으로써 덮어쓰기(Overwrite)를 방지합니다. + */ fun uploadImage(file: MultipartFile): String { - // 1. 파일명 중복 방지 (UUID 사용) val originalName = file.originalFilename ?: "image.jpg" val ext = originalName.substringAfterLast(".", "jpg") val fileName = "${UUID.randomUUID()}.$ext" - // 2. S3(MinIO)로 업로드 + // 메타데이터(ContentType)를 명시하여 브라우저에서 올바르게 렌더링되도록 설정 val putObjectRequest = PutObjectRequest.builder() .bucket(bucketName) .key(fileName) @@ -35,11 +53,17 @@ class ImageService( s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.inputStream, file.size)) - // 3. 접속 가능한 URL 반환 + // 클라이언트가 즉시 접근할 수 있는 절대 경로(URL) 반환 return "$endpoint/$bucketName/$fileName" } - // 👈 [추가] 이미지 삭제 로직 + /** + * 스토리지에서 이미지를 삭제합니다. + * + * [Fail-Safe 전략] + * 이미지 삭제 실패가 비즈니스 트랜잭션(예: 게시글 삭제)의 실패로 이어지지 않도록 예외를 내부에서 소비(Swallow)합니다. + * 고아 객체(Orphaned Object)가 남더라도 메인 데이터의 정합성을 우선시하는 설계입니다. + */ fun deleteImage(fileName: String) { try { val deleteObjectRequest = DeleteObjectRequest.builder() @@ -49,20 +73,25 @@ class ImageService( s3Client.deleteObject(deleteObjectRequest) } catch (e: Exception) { - // 이미지가 이미 없거나 삭제 실패 시 로그만 남기고 진행 (게시글 삭제 자체를 막지 않기 위해) - e.printStackTrace() + e.printStackTrace() // 실제 운영 시에는 Error Log 레벨로 기록하여 추후 배치 작업 등으로 정리 필요 } } + /** + * [버킷 초기화 로직] + * 버킷이 없을 경우 생성하고, 웹에서 이미지를 조회할 수 있도록 'Public Read' 권한 정책을 주입합니다. + */ private fun createBucketIfNotExists() { try { - // 버킷 존재 여부 확인 (없으면 에러 발생하므로 try-catch) + // 버킷 존재 여부 확인 (Head Bucket) s3Client.headBucket { it.bucket(bucketName) } } catch (e: Exception) { // 버킷 생성 s3Client.createBucket { it.bucket(bucketName) } - // ⭐ 버킷을 Public(공개)으로 설정 (이미지 조회를 위해 필수) + // [접근 제어 정책 설정] + // 외부 사용자가 URL을 통해 이미지(Object)를 조회(GetObject)할 수 있도록 + // 버킷 정책(Bucket Policy)을 JSON 형태로 정의하여 적용합니다. val policy = """ { "Version": "2012-10-17", diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt index eef8b75..bf48916 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/Post.kt @@ -6,6 +6,16 @@ import me.wypark.blogbackend.domain.common.BaseTimeEntity import me.wypark.blogbackend.domain.tag.PostTag import me.wypark.blogbackend.domain.user.Member +/** + * [게시글 엔티티] + * + * 블로그의 핵심 콘텐츠인 게시글(Post) 데이터를 정의하는 도메인 모델입니다. + * + * [설계 의도] + * - Setter 사용을 지양하고, 비즈니스 의미가 명확한 편의 메서드(update, addTags 등)를 통해 상태를 변경하도록 설계하여 + * 객체의 일관성(Consistency)과 코드의 응집도(Cohesion)를 높였습니다. + * - 조회수(viewCount)와 같은 동시성 처리가 필요한 필드는 별도의 증가 메서드로 관리합니다. + */ @Entity class Post( @Column(nullable = false) @@ -28,6 +38,12 @@ class Post( @JoinColumn(name = "category_id") var category: Category? = null, + /** + * [태그 매핑 전략] + * PostTag 엔티티와의 일대다 관계를 통해 태그 정보를 관리합니다. + * 게시글이 삭제되거나 수정될 때 태그 연결 정보도 함께 정리되어야 하므로 + * CascadeType.ALL과 orphanRemoval=true 옵션을 사용하여 생명주기를 동기화했습니다. + */ @OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true) val tags: MutableList = mutableListOf() ) : BaseTimeEntity() { @@ -35,6 +51,11 @@ class Post( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null + /** + * 조회수를 1 증가시킵니다. + * Note: 높은 트래픽 환경에서는 DB Lock 경합이 발생할 수 있으므로, + * Redis HyperLogLog 등을 활용한 캐싱 후 배치 업데이트(Write-Back) 전략을 고려할 수 있습니다. + */ fun increaseViewCount() { this.viewCount++ } @@ -43,7 +64,12 @@ class Post( this.tags.addAll(postTags) } - // 👈 [추가] 게시글 수정 메서드 + /** + * [게시글 수정 편의 메서드] + * + * 제목, 본문, 슬러그, 카테고리 등 주요 필드를 한 번에 업데이트합니다. + * JPA의 변경 감지(Dirty Checking) 기능에 의해 트랜잭션 종료 시점에 자동으로 Update 쿼리가 실행됩니다. + */ fun update(title: String, content: String, slug: String, category: Category?) { this.title = title this.content = content @@ -51,9 +77,15 @@ class Post( this.category = category } - // 👈 [추가] 태그 전체 교체 편의 메서드 + /** + * [태그 전체 교체 로직] + * + * 기존 태그 목록을 모두 비우고(clear) 새로운 태그들로 대체합니다. + * orphanRemoval = true 설정에 의해, 컬렉션에서 제거된 기존 PostTag 엔티티들은 + * DB에서도 자동으로 삭제(DELETE) 처리됩니다. + */ fun updateTags(newTags: List) { - this.tags.clear() // orphanRemoval = true 덕분에 기존 태그 매핑이 삭제됨 + this.tags.clear() this.tags.addAll(newTags) } } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt index c22cac3..adc9047 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepository.kt @@ -8,28 +8,63 @@ import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param +/** + * [게시글 데이터 접근 계층] + * + * 게시글(Post) 엔티티의 영속성을 관리하며, + * 검색(Search), 필터링(Filter), 대량 수정(Bulk Update) 등의 다양한 DB 조작을 수행합니다. + * 복잡한 동적 쿼리는 PostRepositoryCustom(QueryDSL)을 통해 처리합니다. + */ interface PostRepository : JpaRepository, PostRepositoryCustom { - // 1. Slug로 상세 조회 (URL이 깔끔해짐) + /** + * URL 친화적인 식별자(Slug)로 게시글을 단건 조회합니다. + * 숫자 ID 대신 의미 있는 문자열을 사용하여 검색 엔진 최적화(SEO)와 사용자 경험(UX)을 향상시킵니다. + */ fun findBySlug(slug: String): Post? - // 2. Slug 중복 검사 (글 작성/수정 시 필수) + /** + * Slug의 유일성(Uniqueness)을 검증합니다. + * 게시글 작성/수정 시 중복된 Slug가 발생하지 않도록 사전에 확인하는 용도입니다. + */ fun existsBySlug(slug: String): Boolean - // 3. 페이징된 목록 조회 (최신순 등은 Pageable로 해결) + /** + * 기본 페이징 조회 메서드를 오버라이드합니다. + * 최신순, 조회순 등 다양한 정렬 기준은 Pageable 객체에 담겨 전달됩니다. + */ override fun findAll(pageable: Pageable): Page - // 4. 특정 카테고리의 글 목록 조회 + /** + * 특정 카테고리에 속한 게시글 목록을 페이징하여 조회합니다. + */ fun findAllByCategory(category: Category, pageable: Pageable): Page - // 5. 카테고리 삭제 시 해당 카테고리(및 하위)에 속한 글들의 카테고리를 null로 변경 (미분류 처리) + /** + * [벌크 연산 최적화] + * + * 카테고리 삭제 시, 해당 카테고리에 속했던 게시글들을 일일이 조회하여 수정(Dirty Checking)하는 것은 비효율적입니다. + * 따라서 단 한 번의 UPDATE 쿼리로 '미분류(NULL)' 처리를 수행하여 성능을 극대화합니다. + * + * @Modifying(clearAutomatically = true): + * 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날리므로, + * 실행 후 1차 캐시와 DB의 데이터 불일치를 막기 위해 자동으로 캐시를 비웁니다. + */ @Modifying(clearAutomatically = true) @Query("UPDATE Post p SET p.category = null WHERE p.category IN :categories") fun bulkUpdateCategoryToNull(@Param("categories") categories: List) - // 6. [추가] 이전 글 조회 (현재 ID보다 작은 것 중 가장 큰 ID = 바로 이전 과거 글) + /** + * [이전 글 조회] + * 현재 글(ID)보다 작으면서(Less Than) 가장 큰 ID를 가진 레코드를 찾습니다. + * (즉, 바로 직전에 작성된 글) + */ fun findFirstByIdLessThanOrderByIdDesc(id: Long): Post? - // 7. [추가] 다음 글 조회 (현재 ID보다 큰 것 중 가장 작은 ID = 바로 다음 최신 글) + /** + * [다음 글 조회] + * 현재 글(ID)보다 크면서(Greater Than) 가장 작은 ID를 가진 레코드를 찾습니다. + * (즉, 바로 직후에 작성된 글) + */ fun findFirstByIdGreaterThanOrderByIdAsc(id: Long): Post? } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt index 6849394..75e9012 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryCustom.kt @@ -4,7 +4,32 @@ import me.wypark.blogbackend.api.dto.PostSummaryResponse import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +/** + * [게시글 동적 쿼리(Dynamic Query) 인터페이스] + * + * QueryDSL을 기반으로 복잡한 검색 및 필터링 로직을 수행하기 위한 커스텀 리포지토리 인터페이스입니다. + * 정적 메서드(Method Name Query)만으로는 처리하기 힘든 다중 조건 조합과 + * DTO 프로젝션(Projection)을 담당합니다. + */ interface PostRepositoryCustom { - // categoryName(String) -> categoryNames(List) 변경 - fun search(keyword: String?, categoryNames: List?, tagName: String?, pageable: Pageable): Page + + /** + * 게시글을 다양한 조건으로 검색하고 페이징 처리된 요약 정보를 반환합니다. + * + * [검색 필터 전략] + * - Keyword: 제목(Title)과 본문(Content)에 대한 통합 검색을 수행합니다. + * - Categories: 단일 카테고리가 아닌 다중 카테고리 필터링(IN절)을 지원하여, + * 사용자가 원하는 주제들을 한 번에 모아볼 수 있는 유연성을 제공합니다. + * - Tag: 특정 태그가 포함된 게시글을 필터링합니다. + * + * [성능 최적화: Projection] + * 엔티티 전체를 조회하는 대신, 목록 화면에 필요한 필드만 선별하여 DTO로 즉시 변환합니다. + * 이는 불필요한 데이터 전송(Network I/O)을 줄이고 영속성 컨텍스트의 부하를 최소화합니다. + */ + fun search( + keyword: String?, + categoryNames: List?, // 다중 선택 지원 (IN Clause) + tagName: String?, + pageable: Pageable + ): Page } \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt index be51cbf..ac33c97 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostRepositoryImpl.kt @@ -10,21 +10,37 @@ import me.wypark.blogbackend.api.dto.PostSummaryResponse import me.wypark.blogbackend.domain.post.QPost.post import me.wypark.blogbackend.domain.tag.QPostTag.postTag import me.wypark.blogbackend.domain.tag.QTag.tag -import me.wypark.blogbackend.domain.category.QCategory.category // 👈 QCategory import 추가 +import me.wypark.blogbackend.domain.category.QCategory.category import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable +/** + * [QueryDSL 리포지토리 구현체] + * + * PostRepositoryCustom 인터페이스를 구현하여 복잡한 동적 쿼리를 처리합니다. + * 컴파일 타임에 문법 오류를 잡을 수 있는 QueryDSL을 사용하여, + * 다중 필터링 조건과 조인(Join) 로직을 안전하고 직관적으로 작성했습니다. + */ class PostRepositoryImpl( private val queryFactory: JPAQueryFactory ) : PostRepositoryCustom { + + /** + * 동적 검색 및 목록 조회 + * + * [성능 최적화: Projections] + * 엔티티를 통째로 조회하면 불필요한 컬럼(LOB 데이터 등)까지 로딩되어 메모리 낭비가 발생합니다. + * 따라서 목록 화면 렌더링에 필요한 필드만 선별하여 DTO로 즉시 매핑(Projection)했습니다. + * + * [조회 정합성 보장] + * - Left Join: 카테고리나 태그가 없는 게시글도 누락 없이 조회되도록 Inner Join 대신 Left Join을 사용했습니다. + * - Distinct: 1:N 관계인 태그 테이블과 조인 시 게시글 데이터가 뻥튀기(Duplication)되는 문제를 해결합니다. + */ override fun search(keyword: String?, categoryNames: List?, tagName: String?, pageable: Pageable): Page { val builder = BooleanBuilder() builder.and(containsKeyword(keyword)) - - // "uncategorized" (또는 "미분류") 요청 시 post.category.isNull 조건으로 변환하여 처리 builder.and(inCategoryNames(categoryNames)) - builder.and(eqTagName(tagName)) val query = queryFactory @@ -34,32 +50,36 @@ class PostRepositoryImpl( post.id, post.title, post.slug, - category.name, // 👈 post.category.name 대신 alias 사용 (NULL 안전) + category.name, // QCategory Alias 사용으로 Null-Safe 처리 post.viewCount, post.createdAt, post.updatedAt, - post.content // 👈 [추가] 미리보기를 위해 본문 내용도 조회합니다. + post.content // 본문 미리보기용 데이터 ) ) .from(post) - .leftJoin(post.category, category) // 👈 명시적 Left Join 추가 (카테고리 없어도 글 조회 가능하게 함) - .leftJoin(post.tags, postTag) + .leftJoin(post.category, category) // 카테고리 미지정 글 포함 + .leftJoin(post.tags, postTag) // 태그 미지정 글 포함 .leftJoin(postTag.tag, tag) .where(builder) .distinct() .offset(pageable.offset) .limit(pageable.pageSize.toLong()) + // 동적 정렬 적용 for (order in getOrderSpecifiers(pageable)) { query.orderBy(order) } val content = query.fetch() + // [Count 쿼리 분리] + // 페이징을 위한 전체 개수 조회 시, 데이터 조회 쿼리보다 단순화할 수 있는 여지가 있다면 + // 별도의 쿼리로 분리하여 성능을 최적화하는 것이 좋습니다. val total = queryFactory .select(post.countDistinct()) .from(post) - .leftJoin(post.category, category) // 👈 Count 쿼리에도 Left Join 추가 + .leftJoin(post.category, category) .leftJoin(post.tags, postTag) .leftJoin(postTag.tag, tag) .where(builder) @@ -71,42 +91,49 @@ class PostRepositoryImpl( private fun containsKeyword(keyword: String?): BooleanBuilder { val builder = BooleanBuilder() if (!keyword.isNullOrBlank()) { + // 제목 또는 본문에 키워드가 포함되는지 검사 (OR 조건) builder.or(post.title.containsIgnoreCase(keyword)) builder.or(post.content.containsIgnoreCase(keyword)) } return builder } + /** + * 카테고리 다중 필터링 조건 생성 + * + * [미분류(Uncategorized) 처리 전략] + * 클라이언트로부터 "uncategorized" 요청이 오면 DB상의 NULL 값과 매핑해야 합니다. + * 일반 카테고리(IN 절)와 미분류(IS NULL) 조건이 혼재될 경우, 이를 유연하게 OR 연산으로 묶어 처리합니다. + */ private fun inCategoryNames(categoryNames: List?): BooleanExpression? { if (categoryNames.isNullOrEmpty()) return null - // 1. 요청에 "uncategorized" 또는 "미분류"가 포함되어 있는지 확인 - // (프론트엔드에서 한글로 "미분류"를 보내는 경우가 많으므로 둘 다 체크) + // 1. 특수 키워드 체크 ("uncategorized", "미분류") val hasUncategorized = categoryNames.any { it.equals("uncategorized", ignoreCase = true) || it.equals("미분류", ignoreCase = true) } - // 2. 그 외 일반 카테고리 이름들만 따로 추림 + // 2. 일반 카테고리명 추출 val normalNames = categoryNames.filter { !it.equals("uncategorized", ignoreCase = true) && !it.equals("미분류", ignoreCase = true) } var expression: BooleanExpression? = null - // A. 일반 카테고리 이름 조건 (IN 절) + // A. 일반 카테고리 조건 (IN Clause) if (normalNames.isNotEmpty()) { - expression = category.name.`in`(normalNames) // 👈 alias 사용 + expression = category.name.`in`(normalNames) } - // B. uncategorized 조건 (IS NULL) 추가 + // B. 미분류 조건 (IS NULL) 결합 if (hasUncategorized) { - val isNullExpr = post.category.isNull // FK가 NULL인지 확인 + val isNullExpr = post.category.isNull expression = if (expression != null) { - // (일반 카테고리들) OR (카테고리 없음) -> 둘 중 하나라도 만족하면 조회 + // (일반 카테고리들) OR (미분류) -> 둘 중 하나라도 만족하면 조회 expression.or(isNullExpr) } else { - // (카테고리 없음) -> 카테고리 없는 글만 모아서 조회 + // 오직 미분류 글만 조회 isNullExpr } } @@ -119,6 +146,10 @@ class PostRepositoryImpl( return tag.name.eq(tagName) } + /** + * Pageable의 Sort 객체를 QueryDSL의 OrderSpecifier로 변환 + * 문자열 필드명을 실제 Q-Type 필드로 매핑하여 런타임 에러를 방지합니다. + */ private fun getOrderSpecifiers(pageable: Pageable): List> { val orders = mutableListOf>() @@ -129,7 +160,7 @@ class PostRepositoryImpl( "viewCount" -> orders.add(OrderSpecifier(direction, post.viewCount)) "createdAt" -> orders.add(OrderSpecifier(direction, post.createdAt)) "id" -> orders.add(OrderSpecifier(direction, post.id)) - else -> orders.add(OrderSpecifier(Order.DESC, post.id)) + else -> orders.add(OrderSpecifier(Order.DESC, post.id)) // 기본 정렬 } } } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt index 9b8ba10..011ddd8 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/post/PostService.kt @@ -16,6 +16,16 @@ 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( @@ -26,11 +36,22 @@ class PostService( private val imageService: ImageService ) { + /** + * 전체 게시글 목록을 조회합니다. + * 목록 뷰에서는 본문 전체가 필요 없으므로, 경량화된 DTO(Summary)로 변환하여 트래픽을 절감합니다. + */ fun getPosts(pageable: Pageable): Page { return postRepository.findAll(pageable) .map { PostSummaryResponse.from(it) } } + /** + * 게시글 상세 정보를 조회합니다. + * + * [부가 로직] + * 1. 조회수 증가: 상세 조회 시 조회수 카운트를 원자적(Atomic)으로 증가시킵니다. + * 2. 인접 게시글 탐색: 사용자의 탐색 연속성(UX)을 위해 현재 글을 기준으로 이전/다음 글의 메타데이터를 함께 반환합니다. + */ @Transactional fun getPostBySlug(slug: String): PostResponse { val post = postRepository.findBySlug(slug) @@ -38,15 +59,21 @@ class PostService( post.increaseViewCount() - // 👈 [추가] 이전/다음 게시글 조회 - // prevPost: 현재 글보다 ID가 작으면서 가장 가까운 글 (과거 글) + // 인접 게시글 조회 (Prev/Next Navigation) + // ID를 기준으로 정렬하여 바로 앞/뒤의 게시글을 1건씩 조회합니다. val prevPost = postRepository.findFirstByIdLessThanOrderByIdDesc(post.id!!) - // nextPost: 현재 글보다 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) @@ -54,7 +81,7 @@ class PostService( val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) } - // Slug 생성 로직 + // SEO Friendly URL 생성을 위한 Slug 중복 검사 및 생성 val uniqueSlug = generateUniqueSlug(request.slug, request.title) val post = Post( @@ -65,49 +92,64 @@ class PostService( 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. 이미지 정리: (기존 본문 이미지) - (새 본문 이미지) = 삭제 대상 + // 1. 고아 이미지 정리: (수정 전 이미지 목록 - 수정 후 이미지 목록) val oldImages = extractImageNamesFromContent(post.content) val newImages = extractImageNamesFromContent(request.content) val removedImages = oldImages - newImages.toSet() removedImages.forEach { imageService.deleteImage(it) } - // 2. 카테고리 조회 + // 2. 카테고리 정보 갱신 val category = request.categoryId?.let { categoryRepository.findByIdOrNull(it) } - // 3. Slug 갱신 (변경 요청이 있고, 기존과 다를 경우에만) + // 3. Slug 갱신 (변경 요청 시에만 수행하여 불필요한 URL 변경 방지) var newSlug = post.slug if (!request.slug.isNullOrBlank() && request.slug != post.slug) { newSlug = generateUniqueSlug(request.slug, request.title) } - // 4. 정보 업데이트 + // 4. 게시글 메타데이터 업데이트 (Dirty Checking) post.update(request.title, request.content, newSlug, category) - // 5. 태그 업데이트 + // 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) @@ -116,6 +158,13 @@ class PostService( postRepository.delete(post) } + /** + * 복합 조건 검색을 수행합니다. + * + * [계층형 카테고리 검색] + * 상위 카테고리로 검색 시, 해당 카테고리에 속한 하위 카테고리(Descendants)의 게시글들도 + * 모두 결과에 포함되도록 검색 조건을 확장(Expand)합니다. + */ fun searchPosts(keyword: String?, categoryName: String?, tagName: String?, pageable: Pageable): Page { val categoryNames = if (categoryName != null) { getCategoryAndDescendants(categoryName) @@ -128,20 +177,26 @@ class PostService( // --- Helper Methods --- - // Slug 중복 처리 로직 분리 + /** + * 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++ @@ -149,7 +204,10 @@ class PostService( return uniqueSlug } - // 태그 이름 -> PostTag 변환 로직 분리 + /** + * 태그 문자열 리스트를 PostTag 엔티티 리스트로 변환합니다. + * DB에 존재하지 않는 태그는 즉시 생성(Save)하여 매핑합니다. + */ private fun resolveTags(tagNames: List, post: Post): List { return tagNames.map { tagName -> val tag = tagRepository.findByName(tagName) @@ -158,6 +216,10 @@ class PostService( } } + /** + * 정규표현식을 사용하여 Markdown 본문에서 이미지 URL(파일명)을 추출합니다. + * 패턴: ![alt](url) + */ private fun extractImageNamesFromContent(content: String): List { val regex = Regex("!\\[.*?\\]\\((.*?)\\)") return regex.findAll(content) @@ -166,6 +228,10 @@ class PostService( .toList() } + /** + * 특정 카테고리의 모든 자손 카테고리 이름을 재귀적으로 수집합니다. + * "Parent" 검색 시 "Parent > Child"의 글도 나오게 하기 위함입니다. + */ private fun getCategoryAndDescendants(categoryName: String): List { if (categoryName.equals("uncategorized", ignoreCase = true)) { return listOf("uncategorized") diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfile.kt b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfile.kt index b904ca4..89d945d 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfile.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfile.kt @@ -3,15 +3,28 @@ package me.wypark.blogbackend.domain.profile import jakarta.persistence.* import me.wypark.blogbackend.domain.common.BaseTimeEntity +/** + * [블로그 프로필 엔티티] + * + * 블로그 운영자(Owner)의 공개적인 신원 정보(Identity)를 관리하는 도메인 모델입니다. + * + * [설계 의도: 관심사의 분리 (Separation of Concerns)] + * 인증/인가를 담당하는 Member 엔티티와 의도적으로 분리하여 설계했습니다. + * - Member: 시스템 접속 및 보안을 위한 계정 정보 (Email, Password, Role) -> 보안 중요, 변경 빈도 낮음 + * - BlogProfile: 방문자에게 보여지는 소개 정보 (Bio, Social Links) -> 공개 데이터, 변경 빈도 높음 + * 이렇게 책임을 분리함으로써, 프로필 정보 수정 로직이 핵심 인증 데이터에 영향을 주지 않도록 격리했습니다. + */ @Entity @Table(name = "blog_profile") class BlogProfile( @Column(nullable = false) var name: String, + // 사용자의 긴 자기소개를 수용하기 위해 대용량 텍스트(CLOB) 타입으로 매핑 @Column(columnDefinition = "TEXT") var bio: String, + // S3/MinIO 등에 업로드된 이미지 리소스의 절대 경로(URL) @Column var imageUrl: String? = null, @@ -26,6 +39,12 @@ class BlogProfile( @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null + /** + * 프로필 정보를 갱신합니다. + * + * 단순 Setter 나열을 지양하고, 의미 있는 비즈니스 메서드(Update)를 정의하여 + * 한 번의 트랜잭션 내에서 관련된 모든 정보가 원자적(Atomic)으로 변경됨을 명시합니다. + */ fun update(name: String, bio: String, imageUrl: String?, githubUrl: String?, email: String?) { this.name = name this.bio = bio diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileService.kt index 2379c29..77079ea 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/profile/BlogProfileService.kt @@ -6,14 +6,30 @@ import me.wypark.blogbackend.domain.image.ImageService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * [프로필 비즈니스 로직] + * + * 블로그 운영자의 정보 관리 및 관련 리소스(이미지) 처리를 담당합니다. + * + * [단일 리소스 정책] + * 이 블로그 시스템은 단일 운영자(Single User)를 가정하므로, + * 프로필 데이터는 테이블 내에 항상 1개의 레코드(Singleton)만 존재하도록 관리됩니다. + */ @Service @Transactional(readOnly = true) class BlogProfileService( private val blogProfileRepository: BlogProfileRepository, - private val imageService: ImageService // 👈 이미지 서비스 주입 + private val imageService: ImageService ) { - // 프로필 조회 (없으면 기본값 생성 후 반환) + /** + * 현재 설정된 프로필 정보를 조회합니다. + * + * [초기화 전략: Get-Or-Create] + * 앱 초기 구동 시 프로필 데이터가 없을 경우(Cold Start), + * 사용자에게 빈 화면이나 에러를 보여주는 대신 기본값(Default)으로 레코드를 생성하여 반환합니다. + * 이를 통해 별도의 초기화 스크립트 없이도 즉시 서비스를 사용할 수 있습니다. + */ @Transactional fun getProfile(): ProfileResponse { val profile = blogProfileRepository.findAll().firstOrNull() @@ -29,9 +45,16 @@ class BlogProfileService( return ProfileResponse.from(profile) } - // 프로필 수정 + /** + * 프로필 정보를 수정합니다. + * + * [리소스 최적화: Image Garbage Collection] + * 프로필 이미지가 변경되거나 삭제될 경우, 더 이상 사용되지 않는 기존 이미지 파일(Dangling File)을 + * 스토리지(S3)에서 즉시 삭제하여 스토리지 비용 낭비를 방지합니다. + */ @Transactional fun updateProfile(request: ProfileUpdateRequest) { + // 데이터가 없으면 생성(Upsert) val profile = blogProfileRepository.findAll().firstOrNull() ?: blogProfileRepository.save( BlogProfile( @@ -43,17 +66,17 @@ class BlogProfileService( ) ) - // 1. 이미지 변경 감지 로직 추가 - // 요청된 URL이 기존 URL과 다르면 (새 이미지로 교체 or 삭제됨) + // [이미지 변경 감지] + // 요청된 이미지 URL이 기존과 다를 경우 (교체 또는 삭제) if (profile.imageUrl != request.imageUrl) { - // 기존에 설정된 이미지가 있었다면 S3에서 삭제 + // 기존 이미지가 존재했다면 정리 대상이므로 삭제 처리 if (!profile.imageUrl.isNullOrBlank()) { val oldFileName = profile.imageUrl!!.substringAfterLast("/") imageService.deleteImage(oldFileName) } } - // 2. 정보 업데이트 + // 엔티티 상태 업데이트 (Dirty Checking에 의해 트랜잭션 종료 시 반영) profile.update( name = request.name, bio = request.bio, diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 776639a..8064685 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,36 +1,44 @@ spring: - application: - name: blog-api - + # [파일 업로드 제한 설정] + # 고해상도 이미지나 대용량 미디어를 처리하기 위해 서블릿의 멀티파트 제약 조건을 완화합니다. + # Nginx 등의 리버스 프록시를 앞단에 둘 경우, 프록시 설정(client_max_body_size)도 함께 조정해야 합니다. servlet: multipart: - max-file-size: 100MB # 파일 하나당 최대 크기 (기본 1MB -> 10MB로 증량) - max-request-size: 100MB # 요청 전체 최대 크기 (여러 파일 합산) + max-file-size: 100MB # 단일 파일 허용 크기 + max-request-size: 100MB # 요청 전체 허용 크기 (여러 파일 합산) - # 1. 데이터베이스 설정 + # [데이터베이스 설정] + # Docker Compose 환경에서의 컨테이너 간 통신을 지원하기 위해 호스트 등을 환경변수로 동적 주입받습니다. datasource: - # Docker 내부 통신용 url: jdbc:postgresql://${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD} driver-class-name: org.postgresql.Driver - # 2. JPA 설정 + # [JPA / Hibernate 설정] jpa: hibernate: + # 운영 환경 데이터 보존을 위해 스키마 자동 생성(create/update) 대신 검증(validate) 모드를 사용합니다. + # 스키마 불일치 시 애플리케이션 실행을 중단하여 잠재적 오류를 방지합니다. ddl-auto: validate properties: hibernate: - format_sql: false # 쿼리 줄바꿈 - show_sql: false # 쿼리 출력 - highlight_sql: false # 쿼리 색상 강조 (가독성 UP) - open-in-view: false # OSIV 종료 (DB 커넥션 최적화) + format_sql: false # 운영 로그 가독성을 위해 SQL 포맷팅 비활성화 (디버깅 시 true 권장) + show_sql: false # 성능 저하 방지를 위해 콘솔 출력 비활성화 (로거로 대체 권장) + highlight_sql: false + # [OSIV(Open Session In View) 비활성화] + # 영속성 컨텍스트의 생존 범위를 트랜잭션 범위로 한정합니다. + # View 렌더링 시점까지 DB 커넥션을 점유하는 것을 방지하여, 트래픽 급증 시 커넥션 풀 고갈 리스크를 최소화합니다. + open-in-view: false + + # [SMTP 메일 설정] + # 회원가입 인증 코드(OTP) 발송을 위한 Gmail SMTP 설정입니다. mail: host: smtp.gmail.com port: 587 - username: ${MAIL_USERNAME} # 환경변수 처리 추천 - password: ${MAIL_PASSWORD} # 앱 비밀번호 (16자리) + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} # Google App Password (2단계 인증 앱 비밀번호) properties: mail: smtp: @@ -38,22 +46,27 @@ spring: starttls: enable: true required: true + # 네트워크 지연 시 스레드 차단을 막기 위한 타임아웃 설정 (Fail-Fast) connectiontimeout: 5000 timeout: 5000 writetimeout: 5000 - # 3. Redis 설정 + # [Redis 설정] + # Refresh Token의 저장(TTL) 및 캐싱(Caching) 처리를 위한 인메모리 데이터 저장소입니다. data: redis: host: ${REDIS_HOST:192.168.0.36} port: 6379 + # [AWS S3 / MinIO 설정] + # 정적 리소스(이미지) 저장을 위한 오브젝트 스토리지 설정입니다. + # 로컬 개발 시에는 MinIO를, 운영 시에는 실제 AWS S3를 바라보도록 환경변수로 제어합니다. cloud: aws: s3: bucket: blog-bucket - endpoint: https://s3.wypark.me - path-style-access-enabled: true + endpoint: https://s3.wypark.me # MinIO 엔드포인트 또는 AWS S3 리전 엔드포인트 + path-style-access-enabled: true # MinIO 호환성을 위해 Path Style 접근 허용 credentials: access-key: ${S3_ACCESS_KEY} secret-key: ${S3_SECRET_KEY} @@ -62,9 +75,11 @@ spring: stack: auto: false -# 5. JWT 설정 +# [JWT(Json Web Token) 정책 설정] jwt: - # 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요. + # [보안 경고] + # Secret Key는 서명 위조 방지를 위한 핵심 키이므로, 절대 소스코드에 평문으로 노출하지 않고 + # CI/CD 파이프라인 변수(${JWT_SECRET})를 통해 주입받습니다. secret: ${JWT_SECRET} - access-token-validity: 600000 - refresh-token-validity: 604800000 \ No newline at end of file + access-token-validity: 600000 # 10분 (짧은 만료 시간으로 탈취 시 피해 최소화) + refresh-token-validity: 604800000 # 7일 (RTR 적용으로 장기 유효기간 허용) \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 334fa41..c3cb4ed 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,5 @@ spring: application: - name: blog-backend + name: blog-api profiles: default: prod \ No newline at end of file