diff --git a/back/build.gradle.kts b/back/build.gradle.kts index 2125f6f..258e9c4 100644 --- a/back/build.gradle.kts +++ b/back/build.gradle.kts @@ -78,6 +78,13 @@ dependencies { // AI Services - WebFlux for non-blocking HTTP clients implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + // macOS Netty 네이티브 DNS 리졸버 (WebFlux 필요) + val isMacOS: Boolean = System.getProperty("os.name").startsWith("Mac OS X") + val architecture = System.getProperty("os.arch").lowercase() + if (isMacOS && architecture == "aarch64") { + developmentOnly("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64") + } } tasks.withType { diff --git a/back/src/main/java/com/back/domain/comment/controller/CommentController.java b/back/src/main/java/com/back/domain/comment/controller/CommentController.java index 2c26854..c179ac5 100644 --- a/back/src/main/java/com/back/domain/comment/controller/CommentController.java +++ b/back/src/main/java/com/back/domain/comment/controller/CommentController.java @@ -4,6 +4,7 @@ import com.back.domain.comment.dto.CommentResponse; import com.back.domain.comment.enums.CommentSortType; import com.back.domain.comment.service.CommentService; +import com.back.domain.user.entity.User; import com.back.global.common.PageResponse; import com.back.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -20,72 +21,62 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Comment", description = "댓글 관련 API") @RestController @RequestMapping("/api/v1/posts/{postId}/comments") @RequiredArgsConstructor +@Tag(name = "Comment", description = "댓글 관련 API") public class CommentController { private final CommentService commentService; - // 댓글 생성 @PostMapping @Operation(summary = "댓글 생성", description = "새 댓글을 생성합니다.") - public ResponseEntity createPost( - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "생성할 댓글 정보", - required = true - ) + public ResponseEntity createComment( @RequestBody @Valid CommentRequest request, - @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId, + @PathVariable("postId") Long postId, @AuthenticationPrincipal CustomUserDetails cs ) { - CommentResponse response = commentService.createComment(cs.getUser().getId(), postId, request); + User user = cs.getUser(); + CommentResponse response = commentService.createComment(user, postId, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @GetMapping @Operation(summary = "댓글 목록 조회", description = "게시글 목록을 조회합니다.") - public ResponseEntity> getPosts( - @Parameter(description = "페이지 정보") Pageable pageable, - @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId, - @Parameter(description = "정렬 조건 LATEST or LIKES") @RequestParam(defaultValue = "LATEST") CommentSortType sortType, + public ResponseEntity> getComments( + Pageable pageable, + @PathVariable("postId") Long postId, + @RequestParam(defaultValue = "LATEST") CommentSortType sortType, @AuthenticationPrincipal CustomUserDetails cs) { - Sort sort = Sort.by(Sort.Direction.DESC, sortType.getProperty()); - - Pageable sortedPageable = PageRequest.of( - pageable.getPageNumber(), - pageable.getPageSize(), - sort - ); + User user = cs != null ? cs.getUser() : null; - Long userId = (cs != null && cs.getUser() != null) ? cs.getUser().getId() : null; + Sort sort = Sort.by(Sort.Direction.DESC, sortType.getProperty()); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); - Page responses = commentService.getComments(userId, postId, sortedPageable); + Page responses = commentService.getComments(user, postId, sortedPageable); return ResponseEntity.ok(PageResponse.of(responses)); } - @PutMapping("/{commentId}") @Operation(summary = "댓글 수정", description = "자신의 댓글을 수정합니다.") public ResponseEntity updateComment( - @Parameter(description = "수정할 댓글 ID", required = true) @PathVariable Long commentId, - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "수정할 댓글 정보", - required = true - ) + @PathVariable Long commentId, @RequestBody @Valid CommentRequest request, @AuthenticationPrincipal CustomUserDetails cs) { - return ResponseEntity.ok(commentService.updateComment(cs.getUser().getId(), commentId, request)); + + User user = cs.getUser(); + return ResponseEntity.ok(commentService.updateComment(user, commentId, request)); } @DeleteMapping("/{commentId}") @Operation(summary = "댓글 삭제", description = "자신의 댓글을 삭제합니다.") - public ResponseEntity deletePost( - @Parameter(description = "삭제할 댓글 ID", required = true) @PathVariable Long commentId, + public ResponseEntity deleteComment( + @PathVariable Long commentId, @AuthenticationPrincipal CustomUserDetails cs) { - commentService.deleteComment(cs.getUser().getId(), commentId); - return ResponseEntity.ok(null); + + User user = cs.getUser(); + commentService.deleteComment(user, commentId); + return ResponseEntity.ok().build(); } -} \ No newline at end of file +} diff --git a/back/src/main/java/com/back/domain/comment/entity/Comment.java b/back/src/main/java/com/back/domain/comment/entity/Comment.java index 85b2ec6..99cc768 100644 --- a/back/src/main/java/com/back/domain/comment/entity/Comment.java +++ b/back/src/main/java/com/back/domain/comment/entity/Comment.java @@ -50,8 +50,8 @@ public class Comment extends BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; - public void checkUser(Long userId) { - if (!user.getId().equals(userId)) + public void checkUser(Long targetUserId) { + if (!targetUserId.equals(user.getId())) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); } diff --git a/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java b/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java index b3562f4..d151c94 100644 --- a/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java +++ b/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java @@ -24,9 +24,9 @@ public interface CommentRepository extends JpaRepository { int countByUserId(Long userId); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT c FROM Comment c WHERE c.id = :commentId") - Optional findByIdWithLock(@Param("commentId") Long commentId); +// @Lock(LockModeType.PESSIMISTIC_WRITE) +// @Query("SELECT c FROM Comment c WHERE c.id = :commentId") +// Optional findByIdWithLock(@Param("commentId") Long commentId); @EntityGraph(attributePaths = {"post"}) Page findByUserIdOrderByCreatedDateDesc(Long userId, Pageable pageable); diff --git a/back/src/main/java/com/back/domain/comment/service/CommentService.java b/back/src/main/java/com/back/domain/comment/service/CommentService.java index f7ae1e0..88377c9 100644 --- a/back/src/main/java/com/back/domain/comment/service/CommentService.java +++ b/back/src/main/java/com/back/domain/comment/service/CommentService.java @@ -31,33 +31,27 @@ @Transactional(readOnly = true) public class CommentService { - private final UserRepository userRepository; private final PostRepository postRepository; private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; - public CommentResponse createComment(Long userId, Long postId, CommentRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - + public CommentResponse createComment(User user, Long postId, CommentRequest request) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + CommentMappers.CommentCtxMapper ctxMapper = new CommentMappers.CommentCtxMapper(user, post); Comment savedComment = commentRepository.save(ctxMapper.toEntity(request)); return ctxMapper.toResponse(savedComment); } - public Page getComments(Long userId, Long postId, Pageable pageable) { - User user = userId != null - ? userRepository.findById(userId).orElse(null) - : null; - + public Page getComments(User user, Long postId, Pageable pageable) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + Page commentsPage = commentRepository.findCommentsByPostId(postId, pageable); - Set userLikedComments = userId != null - ? getUserLikedComments(userId, commentsPage) + Set userLikedComments = user != null + ? getUserLikedComments(user, commentsPage) : Collections.emptySet(); return commentsPage.map(comment -> CommentMappers.toCommentResponse( @@ -68,29 +62,30 @@ public Page getComments(Long userId, Long postId, Pageable page } @Transactional - public Long updateComment(Long userId, Long commentId, CommentRequest request) { + public Long updateComment(User user, Long commentId, CommentRequest request) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - comment.checkUser(userId); + + comment.checkUser(user.getId()); comment.updateContent(request.content()); return comment.getId(); } @Transactional - public void deleteComment(Long userId, Long commentId) { + public void deleteComment(User user, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - comment.checkUser(userId); + + comment.checkUser(user.getId()); commentRepository.delete(comment); } - // 특정 사용자가 한 게시글 내 댓글에서 좋아요를 누른 댓글 ID 집합 조회 - private Set getUserLikedComments(Long userId, Page comments) { + private Set getUserLikedComments(User user, Page comments) { Set commentIds = comments.getContent() .stream() .map(Comment::getId) .collect(Collectors.toSet()); - return commentLikeRepository.findLikedCommentsIdsByUserAndCommentIds(userId, commentIds); + return commentLikeRepository.findLikedCommentsIdsByUserAndCommentIds(user.getId(), commentIds); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/like/controller/LikeController.java b/back/src/main/java/com/back/domain/like/controller/LikeController.java index e5d1416..f94f081 100644 --- a/back/src/main/java/com/back/domain/like/controller/LikeController.java +++ b/back/src/main/java/com/back/domain/like/controller/LikeController.java @@ -15,33 +15,34 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/posts") public class LikeController { + private final LikeService likeService; @PostMapping("/{postId}/likes") public ResponseEntity addLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.addLike(cs.getUser().getId(), postId); - return ResponseEntity.status(HttpStatus.CREATED).body(null); + likeService.addLike(cs.getUser(), postId); + return ResponseEntity.status(HttpStatus.CREATED).build(); } @DeleteMapping("/{postId}/likes") public ResponseEntity removeLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.removeLike(postId, cs.getUser().getId()); - return ResponseEntity.ok(null); + likeService.removeLike(cs.getUser(), postId); + return ResponseEntity.ok().build(); } @PostMapping("/{postId}/comments/{commentId}/likes") public ResponseEntity addCommentLike(@PathVariable Long postId, @PathVariable Long commentId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.addCommentLike(cs.getUser().getId(), postId, commentId); - return ResponseEntity.status(HttpStatus.CREATED).body(null); + likeService.addCommentLike(cs.getUser(), postId, commentId); + return ResponseEntity.status(HttpStatus.CREATED).build(); } @DeleteMapping("/{postId}/comments/{commentId}/likes") public ResponseEntity removeCommentLike(@PathVariable Long postId, @PathVariable Long commentId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.removeCommentLike(cs.getUser().getId(), postId, commentId); - return ResponseEntity.ok(null); + likeService.removeCommentLike(cs.getUser(), postId, commentId); + return ResponseEntity.ok().build(); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/like/service/LikeService.java b/back/src/main/java/com/back/domain/like/service/LikeService.java index 74b117e..e897599 100644 --- a/back/src/main/java/com/back/domain/like/service/LikeService.java +++ b/back/src/main/java/com/back/domain/like/service/LikeService.java @@ -10,11 +10,15 @@ import com.back.domain.post.repository.PostRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; +import com.back.global.common.WithLock; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.core.Ordered; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; +import org.springframework.transaction.interceptor.TransactionInterceptor; /** * 좋아요 관련 비즈니스 로직을 처리하는 서비스. @@ -28,29 +32,33 @@ public class LikeService { private final CommentLikeRepository commentLikeRepository; private final CommentRepository commentRepository; private final PostRepository postRepository; - private final UserRepository userRepository; @Transactional - public void addLike(Long userId, Long postId) { - Post post = postRepository.findByIdWithLock(postId) + @WithLock(key = "'post:' + #postId") + public void addLike(User user, Long postId) { + Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { + if (postLikeRepository.existsByPostIdAndUserId(postId, user.getId())) { throw new ApiException(ErrorCode.POST_ALREADY_LIKED); } - PostLike postLike = createPostLike(post, userId); + PostLike postLike = PostLike.builder() + .post(post) + .user(user) + .build(); postLikeRepository.save(postLike); post.incrementLikeCount(); } @Transactional - public void removeLike(Long postId, Long userId) { - Post post = postRepository.findByIdWithLock(postId) + @WithLock(key = "'post:' + #postId") + public void removeLike(User user, Long postId) { + Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, userId) > 0; + boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, user.getId()) > 0; if (!deleted) { throw new ApiException(ErrorCode.LIKE_NOT_FOUND); @@ -60,26 +68,31 @@ public void removeLike(Long postId, Long userId) { } @Transactional - public void addCommentLike(Long userId, Long postId, Long commentId) { - Comment comment = commentRepository.findByIdWithLock(commentId) + @WithLock(key = "'comment:' + #commentId") + public void addCommentLike(User user, Long postId, Long commentId) { + Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - if (commentLikeRepository.existsByCommentIdAndUserId(commentId, userId)) { + if (commentLikeRepository.existsByCommentIdAndUserId(commentId, user.getId())) { throw new ApiException(ErrorCode.COMMENT_ALREADY_LIKED); } - CommentLike commentLike = createCommentLike(comment, userId); + CommentLike commentLike = CommentLike.builder() + .comment(comment) + .user(user) + .build(); commentLikeRepository.save(commentLike); comment.incrementLikeCount(); } @Transactional - public void removeCommentLike(Long userId, Long postId, Long commentId) { - Comment comment = commentRepository.findByIdWithLock(commentId) + @WithLock(key = "'comment:' + #commentId") + public void removeCommentLike(User user, Long postId, Long commentId) { + Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - boolean deleted = commentLikeRepository.deleteByCommentIdAndUserId(commentId, userId) > 0; + boolean deleted = commentLikeRepository.deleteByCommentIdAndUserId(commentId, user.getId()) > 0; if (!deleted) { throw new ApiException(ErrorCode.LIKE_NOT_FOUND); @@ -87,20 +100,4 @@ public void removeCommentLike(Long userId, Long postId, Long commentId) { comment.decrementLikeCount(); } - - private PostLike createPostLike(Post post, Long userId) { - User userReference = userRepository.getReferenceById(userId); - return PostLike.builder() - .post(post) - .user(userReference) - .build(); - } - - private CommentLike createCommentLike(Comment comment, Long userId) { - User userReference = userRepository.getReferenceById(userId); - return CommentLike.builder() - .comment(comment) - .user(userReference) - .build(); - } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java b/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java index 75bd6b9..0305316 100644 --- a/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java +++ b/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java @@ -21,13 +21,13 @@ public class PollVoteController { private final PollVoteService pollVoteService; @PostMapping - public ResponseEntity vote( + public ResponseEntity vote( @PathVariable Long postId, @RequestBody @Valid VoteRequest request, @AuthenticationPrincipal CustomUserDetails cs) { - pollVoteService.vote(cs.getUser(), postId, request); - return ResponseEntity.ok().build(); + PollResponse response = pollVoteService.vote(cs.getUser(), postId, request); + return ResponseEntity.ok().body(response); } @GetMapping diff --git a/back/src/main/java/com/back/domain/poll/converter/PollConverter.java b/back/src/main/java/com/back/domain/poll/converter/PollConverter.java index 4eaae62..eeac836 100644 --- a/back/src/main/java/com/back/domain/poll/converter/PollConverter.java +++ b/back/src/main/java/com/back/domain/poll/converter/PollConverter.java @@ -77,13 +77,4 @@ public PollOptionResponse fromPollOptionJson(String voteContent) { throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT); } } - - public PollOptionResponse.VoteOption fromPollOptionInVoteOptionJson(String voteContent) { - if (voteContent == null) return null; - try { - return objectMapper.readValue(voteContent, PollOptionResponse.VoteOption.class); - } catch (JsonProcessingException e) { - throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT); - } - } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java b/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java index 2517b62..8aa59e8 100644 --- a/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java +++ b/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java @@ -11,6 +11,7 @@ public record PollOptionResponse( ) { public record VoteOption( int index, - String text + String text, + Integer voteCount ) {} } diff --git a/back/src/main/java/com/back/domain/poll/service/PollVoteService.java b/back/src/main/java/com/back/domain/poll/service/PollVoteService.java index a613a08..54ecab7 100644 --- a/back/src/main/java/com/back/domain/poll/service/PollVoteService.java +++ b/back/src/main/java/com/back/domain/poll/service/PollVoteService.java @@ -33,7 +33,7 @@ public class PollVoteService { private final PollConverter pollConverter; @Transactional - public void vote(User user, Long postId, @Valid VoteRequest request) { + public PollResponse vote(User user, Long postId, @Valid VoteRequest request) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); @@ -54,6 +54,8 @@ public void vote(User user, Long postId, @Valid VoteRequest request) { .build(); pollVoteRepository.save(pollVote); + + return getVote(postId); } public PollResponse getVote(Long postId) { diff --git a/back/src/main/java/com/back/domain/post/controller/PostController.java b/back/src/main/java/com/back/domain/post/controller/PostController.java index fda2cf1..d7ca00b 100644 --- a/back/src/main/java/com/back/domain/post/controller/PostController.java +++ b/back/src/main/java/com/back/domain/post/controller/PostController.java @@ -5,6 +5,7 @@ import com.back.domain.post.dto.PostSearchCondition; import com.back.domain.post.dto.PostSummaryResponse; import com.back.domain.post.service.PostService; +import com.back.domain.user.entity.User; import com.back.global.common.PageResponse; import com.back.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -39,9 +40,9 @@ public ResponseEntity createPost( required = true ) @RequestBody @Valid PostRequest request, - @AuthenticationPrincipal CustomUserDetails cs - ) { - PostDetailResponse response = postService.createPost(cs.getUser().getId(), request); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = userDetails.getUser(); + PostDetailResponse response = postService.createPost(user, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -51,9 +52,9 @@ public ResponseEntity createPost( public ResponseEntity> getPosts( @Parameter(description = "검색 조건") @ModelAttribute PostSearchCondition condition, @Parameter(description = "페이지 정보") Pageable pageable, - @AuthenticationPrincipal CustomUserDetails cs) { - Long userId = (cs != null && cs.getUser() != null) ? cs.getUser().getId() : null; - Page responses = postService.getPosts(userId, condition, pageable); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = (userDetails != null) ? userDetails.getUser() : null; + Page responses = postService.getPosts(user, condition, pageable); return ResponseEntity.ok(PageResponse.of(responses)); } @@ -62,9 +63,9 @@ public ResponseEntity> getPosts( @Operation(summary = "게시글 상세 조회", description = "게시글 ID로 게시글을 조회합니다.") public ResponseEntity getPost( @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable Long postId, - @AuthenticationPrincipal CustomUserDetails cs) { - Long userId = (cs != null && cs.getUser() != null) ? cs.getUser().getId() : null; - return ResponseEntity.ok(postService.getPost(userId, postId)); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = (userDetails != null) ? userDetails.getUser() : null; + return ResponseEntity.ok(postService.getPost(user, postId)); } @PutMapping("/{postId}") @@ -76,16 +77,18 @@ public ResponseEntity updatePost( required = true ) @RequestBody @Valid PostRequest request, - @AuthenticationPrincipal CustomUserDetails cs) { - return ResponseEntity.ok(postService.updatePost(cs.getUser().getId(), postId, request)); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = userDetails.getUser(); + return ResponseEntity.ok(postService.updatePost(user, postId, request)); } @DeleteMapping("/{postId}") @Operation(summary = "게시글 삭제", description = "게시글 ID로 게시글을 삭제합니다.") public ResponseEntity deletePost( @Parameter(description = "삭제할 게시글 ID", required = true) @PathVariable Long postId, - @AuthenticationPrincipal CustomUserDetails cs) { - postService.deletePost(cs.getUser().getId(), postId); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = userDetails.getUser(); + postService.deletePost(user, postId); return ResponseEntity.ok(null); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java b/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java index 66a10bf..b51aa81 100644 --- a/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java +++ b/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java @@ -2,6 +2,7 @@ import com.back.domain.poll.dto.PollOptionResponse; import com.back.domain.post.enums.PostCategory; +import com.back.domain.scenario.dto.ScenarioDetailResponse; import com.back.global.common.DateFormat; import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; @@ -35,8 +36,13 @@ public record PostDetailResponse( @DateFormat LocalDateTime createdDate, - @Schema(description = "투표 정보, 투표가 없는 게시글인 경우 null") + @Schema(description = "투표 정보, 투표 게시글이 아닌 경우 반환 되지 않는다") @JsonInclude(JsonInclude.Include.NON_NULL) - PollOptionResponse polls -) {} + PollOptionResponse polls, + + @Schema(description = "게시글에 첨부된 시나리오 정보, 시나리오 게시글이 아닌 경우 반환되지 않는다.") + @JsonInclude(JsonInclude.Include.NON_NULL) + ScenarioDetailResponse scenario +) { +} diff --git a/back/src/main/java/com/back/domain/post/dto/PostRequest.java b/back/src/main/java/com/back/domain/post/dto/PostRequest.java index 4143e28..ba38dfa 100644 --- a/back/src/main/java/com/back/domain/post/dto/PostRequest.java +++ b/back/src/main/java/com/back/domain/post/dto/PostRequest.java @@ -19,5 +19,7 @@ public record PostRequest( Boolean hide, - PollRequest poll + PollRequest poll, + + Long scenarioId ) { } diff --git a/back/src/main/java/com/back/domain/post/entity/Post.java b/back/src/main/java/com/back/domain/post/entity/Post.java index 8ac3e84..7c046d8 100644 --- a/back/src/main/java/com/back/domain/post/entity/Post.java +++ b/back/src/main/java/com/back/domain/post/entity/Post.java @@ -10,6 +10,7 @@ import com.back.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.*; +import lombok.extern.slf4j.Slf4j; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.service.spi.ServiceException; @@ -20,6 +21,7 @@ import java.util.ArrayList; import java.util.List; +@Slf4j @Entity @Getter @Table(name = "post", @@ -80,8 +82,8 @@ public void updatePost(String title, String content, PostCategory category) { this.category = category; } - public void checkUser(User targetUser) { - if (!user.equals(targetUser)) + public void checkUser(Long targetUserId) { + if (!user.getId().equals(targetUserId)) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); } diff --git a/back/src/main/java/com/back/domain/post/mapper/PostMappers.java b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java index b19c4de..e4b8b7b 100644 --- a/back/src/main/java/com/back/domain/post/mapper/PostMappers.java +++ b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java @@ -7,12 +7,15 @@ import com.back.domain.post.dto.PostSummaryResponse; import com.back.domain.post.entity.Post; import com.back.domain.post.enums.PostCategory; +import com.back.domain.scenario.dto.ScenarioDetailResponse; +import com.back.domain.scenario.entity.Scenario; import com.back.domain.user.entity.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.UUID; +import java.util.function.Function; /** * PostMapper @@ -25,7 +28,7 @@ public class PostMappers { private final PollConverter pollConverter; - public Post toEntity(PostRequest request, User user) { + public Post toEntity(PostRequest request, User user, Scenario scenario) { String voteContent = null; if (request.category() == PostCategory.POLL && request.poll() != null) { UUID pollUid = UUID.randomUUID(); @@ -39,10 +42,32 @@ public Post toEntity(PostRequest request, User user) { .user(user) .hide(request.hide() != null ? request.hide() : false) .voteContent(voteContent) + .scenario(scenario) .likeCount(0) .build(); } + public PostDetailResponse toDetailByCategory( + Post post, + boolean isLiked, + Function pollInfoProvider, + Function scenarioProvider + ) { + return switch (post.getCategory()) { + case CHAT -> toDetailResponse(post, isLiked); + + case POLL -> { + PollOptionResponse pollResponse = pollInfoProvider.apply(post); + yield toDetailWithPollsResponse(post, isLiked, pollResponse); + } + + case SCENARIO -> { + ScenarioDetailResponse scenarioResponse = scenarioProvider.apply(post); + yield toDetailWithScenarioResponse(post, isLiked, scenarioResponse); + } + }; + } + public PostDetailResponse toDetailResponse(Post post, Boolean isLiked) { return new PostDetailResponse( post.getId(), @@ -53,7 +78,8 @@ public PostDetailResponse toDetailResponse(Post post, Boolean isLiked) { post.getLikeCount(), isLiked, post.getCreatedDate(), - pollConverter.fromPollOptionJson(post.getVoteContent()) + null, + null ); } @@ -80,7 +106,23 @@ public PostDetailResponse toDetailWithPollsResponse(Post post, Boolean isLiked, post.getLikeCount(), isLiked, post.getCreatedDate(), - pollResponse + pollResponse, + null + ); + } + + public PostDetailResponse toDetailWithScenarioResponse(Post post, Boolean isLiked, ScenarioDetailResponse scenarioResponse) { + return new PostDetailResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.isHide() ? "익명" : post.getUser().getNickname(), + post.getCategory(), + post.getLikeCount(), + isLiked, + post.getCreatedDate(), + null, + scenarioResponse ); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/repository/PostRepository.java b/back/src/main/java/com/back/domain/post/repository/PostRepository.java index 496c971..c0bb309 100644 --- a/back/src/main/java/com/back/domain/post/repository/PostRepository.java +++ b/back/src/main/java/com/back/domain/post/repository/PostRepository.java @@ -17,9 +17,9 @@ */ @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM Post p WHERE p.id = :postId") - Optional findByIdWithLock(@Param("postId") Long postId); +// @Lock(LockModeType.PESSIMISTIC_WRITE) +// @Query("SELECT p FROM Post p WHERE p.id = :postId") +// Optional findByIdWithLock(@Param("postId") Long postId); int countByUserId(Long userId); diff --git a/back/src/main/java/com/back/domain/post/service/PostService.java b/back/src/main/java/com/back/domain/post/service/PostService.java index e10898e..97755f1 100644 --- a/back/src/main/java/com/back/domain/post/service/PostService.java +++ b/back/src/main/java/com/back/domain/post/service/PostService.java @@ -12,11 +12,16 @@ import com.back.domain.post.enums.PostCategory; import com.back.domain.post.mapper.PostMappers; import com.back.domain.post.repository.PostRepository; +import com.back.domain.scenario.dto.ScenarioDetailResponse; +import com.back.domain.scenario.entity.Scenario; +import com.back.domain.scenario.entity.SceneType; +import com.back.domain.scenario.repository.ScenarioRepository; +import com.back.domain.scenario.repository.SceneTypeRepository; import com.back.domain.user.entity.User; -import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -24,64 +29,92 @@ import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; /** * 게시글 관련 비즈니스 로직을 처리하는 서비스. */ +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class PostService { - private final UserRepository userRepository; private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PollVoteRepository pollVoteRepository; + private final ScenarioRepository scenarioRepository; + private final SceneTypeRepository sceneTypeRepository; private final PostMappers postMappers; private final PollConverter pollConverter; @Transactional - public PostDetailResponse createPost(Long userId, PostRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - Post post = postMappers.toEntity(request, user); + public PostDetailResponse createPost(User user, PostRequest request) { + Scenario scenario = null; + if (request.category() == PostCategory.SCENARIO) { { + scenario = scenarioRepository.findById(request.scenarioId()) + .orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); + }} + + Post post = postMappers.toEntity(request, user, scenario); Post savedPost = postRepository.save(post); - return postMappers.toDetailResponse(savedPost, false); + return postMappers.toDetailByCategory( + savedPost, + false, + this::getPollInfoForCreate, + this::getScenarioInfoForCreate + ); } - public PostDetailResponse getPost(Long userId, Long postId) { + public PostDetailResponse getPost(User user, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - boolean isLiked = userId != null && - postLikeRepository.existsByPostIdAndUserId(postId, userId); + boolean isLiked = user != null && user.getId() != null && + postLikeRepository.existsByPostIdAndUserId(postId, user.getId()); - if (post.getCategory() == PostCategory.CHAT) { - return postMappers.toDetailResponse(post, isLiked); - } + return postMappers.toDetailByCategory( + post, + isLiked, + p -> getPollInfo(user, postId, p), + this::getScenarioInfoForCreate + ); + } - List options = - pollConverter.fromPollOptionJson(post.getVoteContent()).options(); + private PollOptionResponse getPollInfo(User user, Long postId, Post post) { + // 전체 투표 결과 카운트 + Map countMap = pollVoteRepository.findByPostId(postId).stream() + .flatMap(pv -> pollConverter.fromChoiceJson(pv.getChoiceJson()).stream()) + .collect(Collectors.groupingBy(i -> i, Collectors.counting())); - List selected = userId != null - ? pollVoteRepository.findByPostIdAndUserId(postId, userId) + // 옵션별 매핑 + 각 옵션에 voteCount 채우기 + List options = + pollConverter.fromPollOptionJson(post.getVoteContent()).options().stream() + .map(opt -> new PollOptionResponse.VoteOption( + opt.index(), + opt.text(), + countMap.getOrDefault(opt.index(), 0L).intValue() + )) + .toList(); + + // 현재 유저 선택값 + List selected = user != null && user.getId() != null + ? pollVoteRepository.findByPostIdAndUserId(postId, user.getId()) .map(vote -> pollConverter.fromChoiceJson(vote.getChoiceJson())) .orElse(Collections.emptyList()) : Collections.emptyList(); - PollOptionResponse pollResponse = new PollOptionResponse(selected, options); - - return postMappers.toDetailWithPollsResponse(post, isLiked, pollResponse); + return new PollOptionResponse(selected, options); } - public Page getPosts(Long userId, PostSearchCondition condition, Pageable pageable) { + public Page getPosts(User user, PostSearchCondition condition, Pageable pageable) { Page posts = postRepository.searchPosts(condition, pageable); - Set likedPostIds = userId != null - ? getUserLikedPostIds(userId, posts) + Set likedPostIds = user != null && user.getId() != null + ? getUserLikedPostIds(user.getId(), posts) : Collections.emptySet(); return posts.map(post -> postMappers.toSummaryResponse( @@ -91,31 +124,33 @@ public Page getPosts(Long userId, PostSearchCondition condi } @Transactional - public Long updatePost(Long userId, Long postId, PostRequest request) { - if (userId == null) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + public Long updatePost(User user, Long postId, PostRequest request) { + if (user == null || user.getId() == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + } - Post post = validatePostOwnership(userId, postId); + Post post = validatePostOwnership(user, postId); post.updatePost(request.title(), request.content(), request.category()); return postId; } @Transactional - public void deletePost(Long userId, Long postId) { - if (userId == null) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + public void deletePost(User user, Long postId) { + if (user == null || user.getId() == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + } - Post post = validatePostOwnership(userId, postId); + Post post = validatePostOwnership(user, postId); postRepository.delete(post); } - private Post validatePostOwnership(Long userId, Long postId) { + private Post validatePostOwnership(User requestUser, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - User requestUser = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - - post.checkUser(requestUser); + log.info("Validating post ownership for user ID: {} and post ID: {}", requestUser.getId(), post.getUser().getId()); + post.checkUser(requestUser.getId()); return post; } @@ -129,4 +164,25 @@ private Set getUserLikedPostIds(Long userId, Page posts) { return postLikeRepository.findLikedPostIdsByUserAndPostIds(userId, postIds); } -} + + private PollOptionResponse getPollInfoForCreate(Post post) { + List options = + pollConverter.fromPollOptionJson(post.getVoteContent()).options().stream() + .map(opt -> new PollOptionResponse.VoteOption( + opt.index(), + opt.text(), + 0 + )) + .toList(); + + return new PollOptionResponse(Collections.emptyList(), options); + } + + private ScenarioDetailResponse getScenarioInfoForCreate(Post post) { + List sceneTypes = + sceneTypeRepository.findByScenarioIdOrderByTypeAsc(post.getScenario().getId()); + + return ScenarioDetailResponse.from(post.getScenario(), sceneTypes); + } + +} \ No newline at end of file diff --git a/back/src/main/java/com/back/global/common/WithLock.java b/back/src/main/java/com/back/global/common/WithLock.java new file mode 100644 index 0000000..d8df22a --- /dev/null +++ b/back/src/main/java/com/back/global/common/WithLock.java @@ -0,0 +1,13 @@ +package com.back.global.common; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface WithLock { + String key(); + long waitTime() default 5000; // 락 획득 대기 시간 (ms) +} diff --git a/back/src/main/java/com/back/global/exception/ErrorCode.java b/back/src/main/java/com/back/global/exception/ErrorCode.java index 4c4f264..27df4e4 100644 --- a/back/src/main/java/com/back/global/exception/ErrorCode.java +++ b/back/src/main/java/com/back/global/exception/ErrorCode.java @@ -73,8 +73,10 @@ public enum ErrorCode { // Poll Errors POLL_VOTE_NOT_FOUND(HttpStatus.NOT_FOUND, "PV001", "Poll Vote Not Found"), POLL_VOTE_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "PV002", "투표 형식이 올바르지 않습니다."), - POLL_VOTE_INVALID_OPTION(HttpStatus.BAD_REQUEST, "PV003", "존재하지 않는 투표 항목입니다." ); + POLL_VOTE_INVALID_OPTION(HttpStatus.BAD_REQUEST, "PV003", "존재하지 않는 투표 항목입니다." ), + // Lock Errors + LOCK_ACQUISITION_FAILED(HttpStatus.CONFLICT, "L001", "다른 요청이 처리 중입니다. 잠시 후 다시 시도해주세요."); private final HttpStatus status; private final String code; diff --git a/back/src/main/java/com/back/global/lock/LockAspect.java b/back/src/main/java/com/back/global/lock/LockAspect.java new file mode 100644 index 0000000..6c4d4c9 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/LockAspect.java @@ -0,0 +1,60 @@ +package com.back.global.lock; + +import com.back.global.common.WithLock; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +@Aspect +@Component +@Order(Ordered.LOWEST_PRECEDENCE - 1) +public class LockAspect { + private final LockManager lockManager; + private final ExpressionParser parser = new SpelExpressionParser(); + + public LockAspect(LockManager lockManager) { + this.lockManager = lockManager; + } + + @Around("@annotation(withLock)") + public Object applyLock(ProceedingJoinPoint joinPoint, WithLock withLock) throws Throwable { + String lockKey = generateLockKey(joinPoint, withLock.key()); + ReentrantLock lock = lockManager.getLock(lockKey); + + lock.lock(); + try { + return joinPoint.proceed(); + } finally { + lock.unlock(); + log.debug("Lock released: {}", lockKey); + lockManager.releaseLock(lockKey); + } + } + + private String generateLockKey(ProceedingJoinPoint joinPoint, String keyExpression) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + StandardEvaluationContext context = new StandardEvaluationContext(); + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + Expression expression = parser.parseExpression(keyExpression); + return expression.getValue(context, String.class); + } +} + diff --git a/back/src/main/java/com/back/global/lock/LockManager.java b/back/src/main/java/com/back/global/lock/LockManager.java new file mode 100644 index 0000000..d8d01f6 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/LockManager.java @@ -0,0 +1,8 @@ +package com.back.global.lock; + +import java.util.concurrent.locks.ReentrantLock; + +public interface LockManager { + ReentrantLock getLock(String key); + void releaseLock(String key); +} diff --git a/back/src/main/java/com/back/global/lock/MemoryLockManager.java b/back/src/main/java/com/back/global/lock/MemoryLockManager.java new file mode 100644 index 0000000..5467e21 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/MemoryLockManager.java @@ -0,0 +1,45 @@ +package com.back.global.lock; + +import lombok.Getter; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; + +@Component +@Primary +public class MemoryLockManager implements LockManager { + private final ConcurrentHashMap lockMap = new ConcurrentHashMap<>(); + + @Override + public ReentrantLock getLock(String key) { + LockHolder holder = lockMap.computeIfAbsent(key, k -> new LockHolder()); + holder.incrementRef(); + return holder.getLock(); + } + + @Override + public void releaseLock(String key) { + LockHolder holder = lockMap.get(key); + if (holder != null && holder.decrementRef() == 0) { + lockMap.remove(key, holder); + } + } + + @Getter + private static class LockHolder { + private final ReentrantLock lock = new ReentrantLock(true); + private final AtomicInteger refCount = new AtomicInteger(0); + + public int incrementRef() { + return refCount.incrementAndGet(); + } + + public int decrementRef() { + return refCount.decrementAndGet(); + } + } +} + diff --git a/back/src/main/java/com/back/global/lock/RedisLockManager.java b/back/src/main/java/com/back/global/lock/RedisLockManager.java new file mode 100644 index 0000000..4f783a1 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/RedisLockManager.java @@ -0,0 +1,27 @@ +package com.back.global.lock; + +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Component; + +import java.util.concurrent.locks.ReentrantLock; + +@Component +public class RedisLockManager implements LockManager { + + private final RedisConnectionFactory factory; + + public RedisLockManager(RedisConnectionFactory factory) { + this.factory = factory; + } + + @Override + public ReentrantLock getLock(String key) { + return new RedisLuaLock(key, factory); + } + + @Override + public void releaseLock(String key) {} +} + + diff --git a/back/src/main/java/com/back/global/lock/RedisLuaLock.java b/back/src/main/java/com/back/global/lock/RedisLuaLock.java new file mode 100644 index 0000000..46bf429 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/RedisLuaLock.java @@ -0,0 +1,71 @@ +package com.back.global.lock; + +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import java.util.Collections; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +public class RedisLuaLock extends ReentrantLock { + + private final StringRedisTemplate redisTemplate; + private final String key; + private static final long TTL = 5000; + private static final int RETRIES = 50; + private static final long RETRY_DELAY_MS = 50; + + private static final String LUA_LOCK = """ + if redis.call('SETNX', KEYS[1], 'locked') == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + return 1 + else + return 0 + end + """; + + public RedisLuaLock(String key, RedisConnectionFactory factory) { + super(true); + this.key = "lock:" + key; + this.redisTemplate = new StringRedisTemplate(factory); + } + + @Override + public void lock() { + int retries = RETRIES; + boolean acquired = false; + while (!acquired && retries-- > 0) { + acquired = tryLockLua(); + if (!acquired) { + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Lock interrupted", e); + } + } + } + if (!acquired) { + throw new ApiException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + } + + private boolean tryLockLua() { + Long result = redisTemplate.execute( + new DefaultRedisScript<>(LUA_LOCK, Long.class), + Collections.singletonList(key), + String.valueOf(TTL) + ); + return Long.valueOf(1).equals(result); + } + + @Override + public void unlock() { + redisTemplate.delete(key); + } +} + diff --git a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java index 520ddd3..0d89be4 100644 --- a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java +++ b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java @@ -113,7 +113,7 @@ class CreatePost { @Test @DisplayName("성공 - 정상 요청") void success() throws Exception { - PostRequest request = new PostRequest("테스트 게시글", "테스트 내용입니다.", PostCategory.CHAT, false, null); + PostRequest request = new PostRequest("테스트 게시글", "테스트 내용입니다.", PostCategory.CHAT, false, null, null); mockMvc.perform(post("/api/v1/posts") .contentType(MediaType.APPLICATION_JSON) @@ -127,7 +127,7 @@ void success() throws Exception { @Test @DisplayName("실패 - 유효성 검사 실패 (빈 제목)") void failEmptyTitle() throws Exception { - PostRequest request = new PostRequest("", "내용", PostCategory.SCENARIO, false, null); + PostRequest request = new PostRequest("", "내용", PostCategory.CHAT, false, null, null); mockMvc.perform(post("/api/v1/posts") .contentType(MediaType.APPLICATION_JSON) @@ -310,7 +310,7 @@ void createPost() { @Test @DisplayName("성공 - 본인 게시글 수정") void success() throws Exception { - PostRequest updateRequest = new PostRequest("수정된 제목", "수정된 내용", PostCategory.CHAT, false, null); + PostRequest updateRequest = new PostRequest("수정된 제목", "수정된 내용", PostCategory.CHAT, false, null, null); mockMvc.perform(put("/api/v1/posts/{postId}", savedPost.getId()) .contentType(MediaType.APPLICATION_JSON) @@ -330,7 +330,7 @@ void failUnauthorizedUser() throws Exception { .build(); postRepository.save(otherPost); - PostRequest updateRequest = new PostRequest("수정 시도", "수정 시도 내용", PostCategory.CHAT, false, null); + PostRequest updateRequest = new PostRequest("수정 시도", "수정 시도 내용", PostCategory.CHAT, false, null, null); mockMvc.perform(put("/api/v1/posts/{postId}", otherPost.getId()) .contentType(MediaType.APPLICATION_JSON)