diff --git a/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java b/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java index f1539ea..4744eee 100644 --- a/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java +++ b/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java @@ -51,9 +51,9 @@ public void handleCommentCreated(CommentCreatedEvent event) { public void handleLikeCreated(LikeCreatedEvent event) { try { handleNotification( - event.postWriterId(), - event.likeWriterName() + "왹이 외계 수집물에 푸 딩을 달았다!", - "/posts/" + event.postId(), + event.receiverId(), + event.senderName() + "왹이 외계 수집물에 푸 딩을 달았다!", + "/posts/" + event.targetId(), NotificationType.LIKE ); } catch (Exception e) { diff --git a/src/main/java/com/jiwon/mylog/domain/event/UserStatsEventListener.java b/src/main/java/com/jiwon/mylog/domain/event/UserStatsEventListener.java index eaadeb3..a1089ad 100644 --- a/src/main/java/com/jiwon/mylog/domain/event/UserStatsEventListener.java +++ b/src/main/java/com/jiwon/mylog/domain/event/UserStatsEventListener.java @@ -55,7 +55,7 @@ public void handleLikeCreated(LikeCreatedEvent event) { if (!validateIsToday(event.createdAt())) return; try { - userStatsService.updateReceivedLikes(event.postWriterId(), 1); + userStatsService.updateReceivedLikes(event.receiverId(), 1); } catch (Exception e) { log.warn("좋아요 생성 이벤트 통계 처리 실패: {}", event, e); } @@ -66,7 +66,7 @@ public void handleLikeDeleted(LikeDeletedEvent event) { if (!validateIsToday(event.createdAt())) return; try { - userStatsService.updateReceivedLikes(event.postWriterId(), -1); + userStatsService.updateReceivedLikes(event.receiverId(), -1); } catch (Exception e) { log.warn("좋아요 삭제 이벤트 통계 처리 실패: {}", event, e); } diff --git a/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeCreatedEvent.java b/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeCreatedEvent.java index 92a816a..194da6e 100644 --- a/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeCreatedEvent.java +++ b/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeCreatedEvent.java @@ -3,6 +3,9 @@ import java.time.LocalDateTime; public record LikeCreatedEvent( - Long postId, Long postWriterId, Long likeWriterId, String likeWriterName, + Long targetId, + Long receiverId, + Long senderId, + String senderName, LocalDateTime createdAt) { } diff --git a/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeDeletedEvent.java b/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeDeletedEvent.java index 27c0f2c..eb60d5d 100644 --- a/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeDeletedEvent.java +++ b/src/main/java/com/jiwon/mylog/domain/event/dto/like/LikeDeletedEvent.java @@ -3,5 +3,8 @@ import java.time.LocalDateTime; public record LikeDeletedEvent( - Long postId, Long postWriterId, Long likeWriterId, LocalDateTime createdAt) { + Long targetId, + Long receiverId, + Long senderId, + LocalDateTime createdAt) { } diff --git a/src/main/java/com/jiwon/mylog/domain/like/LikeRepository.java b/src/main/java/com/jiwon/mylog/domain/like/LikeRepository.java deleted file mode 100644 index 2b0d5cf..0000000 --- a/src/main/java/com/jiwon/mylog/domain/like/LikeRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.jiwon.mylog.domain.like; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.Objects; - -@Repository -public interface LikeRepository extends JpaRepository { - boolean existsByUserIdAndPostId(Long userId, Long postId); - - @Query(value = "SELECT p.user_id, l.created_at " + - "FROM likes l " + - "INNER JOIN posts p ON l.post_id = p.id " + - "WHERE l.user_id = :userId AND l.post_id = :postId", - nativeQuery = true) - Object[] findLikeDetails(@Param("userId") Long userId, @Param("postId") Long postId); - - @Modifying - @Query(value = "insert ignore into likes(user_id, post_id) values(:userId, :postId)", nativeQuery = true) - void saveLike(@Param("userId") Long userId, @Param("postId") Long postId); - - @Modifying - @Query(value = "delete from likes where user_id = :userId and post_id = :postId", nativeQuery = true) - void deleteLike(@Param("userId") Long userId, @Param("postId") Long postId); - - @Query(value = "select count(*) from likes where post_id = :postId", nativeQuery = true) - long countByPostId(@Param("postId") Long postId); -} diff --git a/src/main/java/com/jiwon/mylog/domain/like/LikeController.java b/src/main/java/com/jiwon/mylog/domain/like/controller/LikeController.java similarity index 66% rename from src/main/java/com/jiwon/mylog/domain/like/LikeController.java rename to src/main/java/com/jiwon/mylog/domain/like/controller/LikeController.java index 79f580e..4b1ced7 100644 --- a/src/main/java/com/jiwon/mylog/domain/like/LikeController.java +++ b/src/main/java/com/jiwon/mylog/domain/like/controller/LikeController.java @@ -1,7 +1,7 @@ -package com.jiwon.mylog.domain.like; +package com.jiwon.mylog.domain.like.controller; +import com.jiwon.mylog.domain.like.service.PostLikeService; import com.jiwon.mylog.global.common.entity.PageResponse; -import com.jiwon.mylog.global.common.entity.SliceResponse; import com.jiwon.mylog.global.security.auth.annotation.LoginUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -21,29 +21,29 @@ @RestController public class LikeController { - private final LikeService likeService; + private final PostLikeService postLikeService; @PostMapping("/posts/{postId}/likes") - public ResponseEntity createLike(@LoginUser Long userId, @PathVariable Long postId) { - likeService.createLike(userId, postId); + public ResponseEntity likePost(@LoginUser Long userId, @PathVariable Long postId) { + postLikeService.like(userId, postId); return new ResponseEntity<>(HttpStatus.CREATED); } @DeleteMapping("/posts/{postId}/likes") - public ResponseEntity deleteLike(@LoginUser Long userId, @PathVariable Long postId) { - likeService.deleteLike(userId, postId); + public ResponseEntity unlikePost(@LoginUser Long userId, @PathVariable Long postId) { + postLikeService.unlike(userId, postId); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @GetMapping("/posts/{postId}/likes/count") - public ResponseEntity getLikeCount(@PathVariable Long postId) { - long likeCount = likeService.getLikeCount(postId); + public ResponseEntity countPostLike(@PathVariable Long postId) { + long likeCount = postLikeService.countLikes(postId); return ResponseEntity.ok(likeCount); } @GetMapping("/posts/{postId}/likes") - public ResponseEntity getLikeStatus(@LoginUser Long userId, @PathVariable Long postId) { - boolean likeStatus = likeService.getLikeStatus(userId, postId); + public ResponseEntity isLikedPost(@LoginUser Long userId, @PathVariable Long postId) { + boolean likeStatus = postLikeService.isLiked(userId, postId); return ResponseEntity.ok(likeStatus); } @@ -51,7 +51,7 @@ public ResponseEntity getLikeStatus(@LoginUser Long userId, @PathVariab public ResponseEntity getUserLikes( @PathVariable Long userId, @PageableDefault(size = 10, sort="createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - PageResponse response = likeService.getUserLikes(userId, pageable); + PageResponse response = postLikeService.getUserLikes(userId, pageable); return ResponseEntity.ok(response); } } diff --git a/src/main/java/com/jiwon/mylog/domain/like/entity/BaseLike.java b/src/main/java/com/jiwon/mylog/domain/like/entity/BaseLike.java new file mode 100644 index 0000000..3301093 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/like/entity/BaseLike.java @@ -0,0 +1,34 @@ +package com.jiwon.mylog.domain.like.entity; + +import com.jiwon.mylog.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseLike { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + BaseLike(User user) { + this.user = user; + } +} diff --git a/src/main/java/com/jiwon/mylog/domain/like/Like.java b/src/main/java/com/jiwon/mylog/domain/like/entity/PostLike.java similarity index 50% rename from src/main/java/com/jiwon/mylog/domain/like/Like.java rename to src/main/java/com/jiwon/mylog/domain/like/entity/PostLike.java index 4d10f53..26466da 100644 --- a/src/main/java/com/jiwon/mylog/domain/like/Like.java +++ b/src/main/java/com/jiwon/mylog/domain/like/entity/PostLike.java @@ -1,10 +1,8 @@ -package com.jiwon.mylog.domain.like; +package com.jiwon.mylog.domain.like.entity; import com.jiwon.mylog.domain.post.entity.Post; import com.jiwon.mylog.domain.user.entity.User; -import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -13,47 +11,35 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; @Getter -@EntityListeners(AuditingEntityListener.class) -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Table( - name = "likes", + name = "post_like", uniqueConstraints = @UniqueConstraint( - name = "like_uk", + name = "post_like_uk", columnNames = {"user_id", "post_id"} ) ) -public class Like { - +public class PostLike extends BaseLike { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id", nullable = false) private Post post; - @CreatedDate - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; + PostLike(User user, Post post) { + super(user); + this.post = post; + } - public static Like toLike(User user, Post post) { - Like like = new Like(); - like.user = user; - like.post = post; - return like; + public static PostLike toPostLike(User user, Post post) { + return new PostLike(user, post); } -} +} \ No newline at end of file diff --git a/src/main/java/com/jiwon/mylog/domain/like/repository/PostLikeRepository.java b/src/main/java/com/jiwon/mylog/domain/like/repository/PostLikeRepository.java new file mode 100644 index 0000000..6d253ad --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/like/repository/PostLikeRepository.java @@ -0,0 +1,23 @@ +package com.jiwon.mylog.domain.like.repository; + +import com.jiwon.mylog.domain.like.entity.PostLike; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostLikeRepository extends JpaRepository { + + @Query("select pl from PostLike pl " + + "join fetch pl.user " + + "join fetch pl.post " + + "where pl.user.id = :userId and pl.post.id = :postId") + Optional findWithDetails(@Param("userId") Long userId, @Param("postId") Long postId); + + boolean existsByUserIdAndPostId(Long userId, Long postId); + + @Query(value = "select count(*) from post_like where post_id = :postId", nativeQuery = true) + long countByPostId(@Param("postId") Long postId); +} diff --git a/src/main/java/com/jiwon/mylog/domain/like/service/LikeService.java b/src/main/java/com/jiwon/mylog/domain/like/service/LikeService.java new file mode 100644 index 0000000..bee41b4 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/like/service/LikeService.java @@ -0,0 +1,12 @@ +package com.jiwon.mylog.domain.like.service; + +public interface LikeService { + + boolean isLiked(Long userId, Long targetId); + + void like(Long userId, Long targetId); + + void unlike(Long userId, Long targetId); + + Long countLikes(Long targetId); +} diff --git a/src/main/java/com/jiwon/mylog/domain/like/LikeService.java b/src/main/java/com/jiwon/mylog/domain/like/service/PostLikeService.java similarity index 54% rename from src/main/java/com/jiwon/mylog/domain/like/LikeService.java rename to src/main/java/com/jiwon/mylog/domain/like/service/PostLikeService.java index 6bf20a8..0ce5c0b 100644 --- a/src/main/java/com/jiwon/mylog/domain/like/LikeService.java +++ b/src/main/java/com/jiwon/mylog/domain/like/service/PostLikeService.java @@ -1,7 +1,9 @@ -package com.jiwon.mylog.domain.like; +package com.jiwon.mylog.domain.like.service; import com.jiwon.mylog.domain.event.dto.like.LikeCreatedEvent; import com.jiwon.mylog.domain.event.dto.like.LikeDeletedEvent; +import com.jiwon.mylog.domain.like.repository.PostLikeRepository; +import com.jiwon.mylog.domain.like.entity.PostLike; import com.jiwon.mylog.domain.post.dto.response.PostSummaryResponse; import com.jiwon.mylog.domain.post.entity.Post; import com.jiwon.mylog.domain.post.repository.PostRepository; @@ -11,95 +13,84 @@ import com.jiwon.mylog.global.common.error.ErrorCode; import com.jiwon.mylog.global.common.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.Objects; - @RequiredArgsConstructor @Service -public class LikeService { +public class PostLikeService implements LikeService { private final ApplicationEventPublisher eventPublisher; - private final LikeRepository likeRepository; + private final PostLikeRepository postLikeRepository; private final UserRepository userRepository; private final PostRepository postRepository; - @CacheEvict(value = "like::count", key = "'postId:' + #postId", condition = "#postId != null") + @Override + @Transactional(readOnly = true) + public boolean isLiked(Long userId, Long postId) { + return postLikeRepository.existsByUserIdAndPostId(userId, postId); + } + + @Override + @Transactional(readOnly = true) + public Long countLikes(Long postId) { + return postLikeRepository.countByPostId(postId); + } + + @Override @Transactional - public void createLike(Long userId, Long postId) { + public void like(Long userId, Long postId) { User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_USER)); - Post post = postRepository.findById(postId) + Post post = postRepository.findByIdWithUser(postId) .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_POST)); - Long receiverId = post.getUser().getId(); - Like like = Like.toLike(user, post); - Like savedLike = likeRepository.save(like); + PostLike savedPostLike = postLikeRepository.save(PostLike.toPostLike(user, post)); + + Long receiverId = post.getUser().getId(); if (!receiverId.equals(userId)) { eventPublisher.publishEvent( new LikeCreatedEvent( postId, receiverId, - userId, + user.getId(), user.getUsername(), - savedLike.getCreatedAt() + savedPostLike.getCreatedAt() ) ); } } - @CacheEvict(value = "like::count", key = "'postId:' + #postId", condition = "#postId != null") + @Override @Transactional - public void deleteLike(Long userId, Long postId) { - validateUserExists(userId); - validatePostExists(postId); - - Object[] likeDetails = likeRepository.findLikeDetails(userId, postId); - Long receiverId = ((Number) likeDetails[0]).longValue(); - LocalDateTime createdAt = (LocalDateTime) likeDetails[1]; + public void unlike(Long userId, Long postId) { + PostLike postLike = postLikeRepository.findWithDetails(userId, postId) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND)); + postLikeRepository.delete(postLike); - likeRepository.deleteLike(userId, postId); + Long receiverId = postLike.getUser().getId(); + Long targetId = postLike.getPost().getId(); if (!receiverId.equals(userId)) { eventPublisher.publishEvent( new LikeDeletedEvent( - postId, + targetId, receiverId, userId, - createdAt + postLike.getCreatedAt() ) ); } } - @Cacheable(value = "like::count", key = "'postId:' + #postId", condition = "#postId != null") - @Transactional(readOnly = true) - public long getLikeCount(Long postId) { - validatePostExists(postId); - return likeRepository.countByPostId(postId); - } - - @Transactional(readOnly = true) - public boolean getLikeStatus(Long userId, Long postId) { - validateUserExists(userId); - validatePostExists(postId); - return likeRepository.existsByUserIdAndPostId(userId, postId); - } - @Transactional(readOnly = true) public PageResponse getUserLikes(Long userId, Pageable pageable) { - validateUserExists(userId); Page postPage = postRepository.findLikedPosts(userId, pageable); - return PageResponse.from( postPage.getContent(), postPage.getNumber(), @@ -108,16 +99,4 @@ public PageResponse getUserLikes(Long userId, Pageable pageable) { postPage.getTotalElements() ); } - - private void validateUserExists(Long userId) { - if (!userRepository.existsById(userId)) { - throw new NotFoundException(ErrorCode.NOT_FOUND_USER); - } - } - - private void validatePostExists(Long postId) { - if (!postRepository.existsById(postId)) { - throw new NotFoundException(ErrorCode.NOT_FOUND_POST); - } - } } diff --git a/src/main/java/com/jiwon/mylog/domain/post/entity/Post.java b/src/main/java/com/jiwon/mylog/domain/post/entity/Post.java index 0454f4c..e4b3029 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/entity/Post.java +++ b/src/main/java/com/jiwon/mylog/domain/post/entity/Post.java @@ -79,14 +79,14 @@ public class Post extends BaseEntity { private List images = new ArrayList<>(); @Builder.Default - @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "post") private List postTags = new ArrayList<>(); @Builder.Default @OneToMany(mappedBy = "post") private List comments = new ArrayList<>(); - public static Post create(PostRequest request, User user, Category category, List tags) { + public static Post create(PostRequest request, User user, Category category) { Post post = Post.builder() .title(request.getTitle()) .content(request.getContent()) @@ -98,7 +98,6 @@ public static Post create(PostRequest request, User user, Category category, Lis .pinned(request.isPinned()) .type(PostType.fromString(request.getType())) .build(); - post.setTags(tags); return post; } @@ -112,12 +111,6 @@ public void update(PostRequest request, Category category, List tags) { updateTags(tags); } - private void setTags(List tags) { - this.postTags = tags.stream() - .map(tag -> PostTag.createPostTag(this, tag)) - .toList(); - } - private void updateTags(List tags) { this.postTags.clear(); this.postTags.addAll( diff --git a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java index f414d49..f6cfbe2 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java +++ b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java @@ -53,4 +53,7 @@ public interface PostRepository extends JpaRepository, PostRepositor "from Post p " + "where p.user.id = :userId and p.pinned = true and p.deletedAt is null") List findPinnedPostsByUserId(@Param("userId") Long userId); + + @Query("select p from Post p join fetch p.user where p.id = :postId") + Optional findByIdWithUser(@Param("postId") Long postId); } diff --git a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java index 50f7f4d..91d64a4 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java @@ -5,7 +5,7 @@ import com.jiwon.mylog.domain.comment.dto.response.CommentResponse; import com.jiwon.mylog.domain.comment.entity.QComment; import com.jiwon.mylog.domain.image.entity.QProfileImage; -import com.jiwon.mylog.domain.like.QLike; +import com.jiwon.mylog.domain.like.entity.QPostLike; import com.jiwon.mylog.domain.post.dto.response.PostDetailResponse; import com.jiwon.mylog.domain.post.dto.response.PostNavigationResponse; import com.jiwon.mylog.domain.post.dto.response.PostSummaryResponse; @@ -56,7 +56,7 @@ public class PostRepositoryImpl implements PostRepositoryCustom { private static final QPostTag POST_TAG = QPostTag.postTag; private static final QTag TAG = QTag.tag; private static final QComment COMMENT = QComment.comment; - private static final QLike LIKE = QLike.like; + private static final QPostLike POST_LIKE = QPostLike.postLike; @Override public Page findLikedPosts(Long userId, Pageable pageable) { @@ -79,8 +79,8 @@ public Page findLikedPosts(Long userId, Pageable pageable) POST.createdAt ) ) - .from(LIKE) - .join(LIKE.post, POST) + .from(POST_LIKE) + .join(POST_LIKE.post, POST) .join(POST.user, USER) .leftJoin(POST.category, CATEGORY) .leftJoin(USER.profileImage, PROFILE_IMAGE) @@ -89,13 +89,13 @@ public Page findLikedPosts(Long userId, Pageable pageable) ) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) - .orderBy(LIKE.createdAt.desc()) + .orderBy(POST_LIKE.createdAt.desc()) .fetch(); Long total = jpaQueryFactory - .select(LIKE.count()) - .from(LIKE) - .join(LIKE.post, POST) + .select(POST_LIKE.count()) + .from(POST_LIKE) + .join(POST_LIKE.post, POST) .where(likeUserIdEq(userId), postDeletedAtIsNull()) .fetchOne(); @@ -458,7 +458,7 @@ private BooleanExpression postUserIdEq(Long userId) { } private BooleanExpression likeUserIdEq(Long userId) { - return userId != null ? LIKE.user.id.eq(userId) : null; + return userId != null ? POST_LIKE.user.id.eq(userId) : null; } private BooleanExpression categoryIdEq(Long categoryId) { diff --git a/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java b/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java index fb637cc..8b98d7f 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java +++ b/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java @@ -10,6 +10,8 @@ import com.jiwon.mylog.domain.post.dto.response.PostDetailResponse; import com.jiwon.mylog.domain.post.dto.response.PostNavigationResponse; import com.jiwon.mylog.domain.post.dto.response.RelatedPostResponse; +import com.jiwon.mylog.domain.tag.entity.PostTag; +import com.jiwon.mylog.domain.tag.repository.posttag.PostTagJdbcRepository; import com.jiwon.mylog.global.common.entity.PageResponse; import com.jiwon.mylog.domain.post.dto.response.PostSummaryResponse; import com.jiwon.mylog.domain.post.entity.Post; @@ -22,7 +24,8 @@ import com.jiwon.mylog.domain.category.repository.CategoryRepository; import com.jiwon.mylog.domain.user.repository.UserRepository; -import java.time.LocalDateTime; +import jakarta.persistence.EntityManager; +import java.util.Comparator; import java.util.List; import com.jiwon.mylog.domain.tag.service.TagService; @@ -43,8 +46,10 @@ @Service public class PostService { + private final EntityManager em; private final ApplicationEventPublisher eventPublisher; private final TagService tagService; + private final PostTagJdbcRepository postTagJdbcRepository; private final PostRepository postRepository; private final UserRepository userRepository; private final CategoryRepository categoryRepository; @@ -63,15 +68,21 @@ public class PostService { public PostDetailResponse createPost(Long userId, PostRequest postRequest) { User user = getUserById(userId); Category category = getCategoryById(userId, postRequest.getCategoryId()); - List tags = tagService.getTagsById(user, postRequest.getTagRequests()); - - Post post = Post.create(postRequest, user, category, tags); - Post savedPost = postRepository.save(post); + List tags = tagService.getOrCreateTags(user, postRequest.getTagRequests()); + Post savedPost = postRepository.save(Post.create(postRequest, user, category)); + + if (tags != null && !tags.isEmpty()) { + List postTags = tags.stream() + .map(tag -> PostTag.createPostTag(savedPost, tag)) + .toList(); + postTagJdbcRepository.saveAll(postTags); + } increaseRelatedPostInfo(category, tags); eventPublisher.publishEvent(new PostCreatedEvent(userId, savedPost.getId(), savedPost.getCreatedAt())); - return PostDetailResponse.fromPost(savedPost); + return postRepository.findPostDetail(savedPost.getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_POST)); } @Caching( @@ -91,11 +102,14 @@ public PostDetailResponse updatePost(Long userId, Long postId, PostRequest postR decreaseRelatedPostInfo(post); Category category = getCategoryById(userId, postRequest.getCategoryId()); - List tags = tagService.getTagsById(post.getUser(), postRequest.getTagRequests()); + List tags = tagService.getOrCreateTags(post.getUser(), postRequest.getTagRequests()); increaseRelatedPostInfo(category, tags); post.update(postRequest, category, tags); - return PostDetailResponse.fromPost(post); + em.flush(); + + return postRepository.findPostDetail(post.getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_POST)); } @Caching(evict = { @@ -127,7 +141,11 @@ private void increaseRelatedPostInfo(Category category, List tags) { if (category != null) { category.incrementUsage(); } - tags.forEach(Tag::incrementUsage); + + List sortedTags = tags.stream() + .sorted(Comparator.comparing(Tag::getId)) + .toList(); + sortedTags.forEach(Tag::incrementUsage); } /** diff --git a/src/main/java/com/jiwon/mylog/domain/tag/entity/Tag.java b/src/main/java/com/jiwon/mylog/domain/tag/entity/Tag.java index d8b057d..90fbdd7 100644 --- a/src/main/java/com/jiwon/mylog/domain/tag/entity/Tag.java +++ b/src/main/java/com/jiwon/mylog/domain/tag/entity/Tag.java @@ -1,7 +1,6 @@ package com.jiwon.mylog.domain.tag.entity; import com.jiwon.mylog.domain.user.entity.User; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -10,7 +9,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; @@ -18,9 +16,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.ArrayList; -import java.util.List; - @AllArgsConstructor @NoArgsConstructor @Builder @@ -48,10 +43,6 @@ public class Tag { @JoinColumn(name = "user_id", nullable = false) private User user; - @Builder.Default - @OneToMany(mappedBy = "tag", cascade = CascadeType.ALL, orphanRemoval = true) - private List postTags = new ArrayList<>(); - public void incrementUsage() { this.usageCount++; } @@ -59,4 +50,12 @@ public void incrementUsage() { public void decrementUsage() { this.usageCount = Math.max(0, this.usageCount - 1); } + + public static Tag create(User user, String name) { + return Tag.builder() + .user(user) + .name(name) + .usageCount(0L) + .build(); + } } diff --git a/src/main/java/com/jiwon/mylog/domain/tag/repository/posttag/PostTagJdbcRepository.java b/src/main/java/com/jiwon/mylog/domain/tag/repository/posttag/PostTagJdbcRepository.java new file mode 100644 index 0000000..2ea7d3e --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/tag/repository/posttag/PostTagJdbcRepository.java @@ -0,0 +1,37 @@ +package com.jiwon.mylog.domain.tag.repository.posttag; + +import com.jiwon.mylog.domain.tag.entity.PostTag; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Repository +public class PostTagJdbcRepository { + + private final JdbcTemplate jdbcTemplate; + + @Transactional + public void saveAll(List postTags) { + jdbcTemplate.batchUpdate("insert into post_tag (post_id, tag_id) values (?, ?)", + new BatchPreparedStatementSetter() { + + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PostTag postTag = postTags.get(i); + ps.setLong(1, postTag.getPost().getId()); + ps.setLong(2, postTag.getTag().getId()); + } + + @Override + public int getBatchSize() { + return postTags.size(); + } + }); + } +} diff --git a/src/main/java/com/jiwon/mylog/domain/tag/repository/PostTagRepository.java b/src/main/java/com/jiwon/mylog/domain/tag/repository/posttag/PostTagRepository.java similarity index 82% rename from src/main/java/com/jiwon/mylog/domain/tag/repository/PostTagRepository.java rename to src/main/java/com/jiwon/mylog/domain/tag/repository/posttag/PostTagRepository.java index dbee9f3..c1d0dd0 100644 --- a/src/main/java/com/jiwon/mylog/domain/tag/repository/PostTagRepository.java +++ b/src/main/java/com/jiwon/mylog/domain/tag/repository/posttag/PostTagRepository.java @@ -1,4 +1,4 @@ -package com.jiwon.mylog.domain.tag.repository; +package com.jiwon.mylog.domain.tag.repository.posttag; import com.jiwon.mylog.domain.tag.entity.PostTag; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagJdbcRepository.java b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagJdbcRepository.java new file mode 100644 index 0000000..97944c6 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagJdbcRepository.java @@ -0,0 +1,38 @@ +package com.jiwon.mylog.domain.tag.repository.tag; + +import com.jiwon.mylog.domain.tag.entity.Tag; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Repository +public class TagJdbcRepository { + + private final JdbcTemplate jdbcTemplate; + + @Transactional + public void upsert(List tags) { + jdbcTemplate.batchUpdate("insert into tag (name, usage_count, user_id) values (?, ?, ?) on duplicate key update id = LAST_INSERT_ID(id)", + new BatchPreparedStatementSetter() { + + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + Tag tag = tags.get(i); + ps.setString(1, tag.getName()); + ps.setLong(2, tag.getUsageCount()); + ps.setLong(3, tag.getUser().getId()); + } + + @Override + public int getBatchSize() { + return tags.size(); + } + }); + } +} diff --git a/src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepository.java b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepository.java similarity index 89% rename from src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepository.java rename to src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepository.java index 4aed817..4f8e4b4 100644 --- a/src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepository.java +++ b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepository.java @@ -1,6 +1,5 @@ -package com.jiwon.mylog.domain.tag.repository; +package com.jiwon.mylog.domain.tag.repository.tag; -import com.jiwon.mylog.domain.category.entity.Category; import com.jiwon.mylog.domain.tag.entity.Tag; import com.jiwon.mylog.domain.user.entity.User; import java.util.List; @@ -18,6 +17,8 @@ public interface TagRepository extends JpaRepository, TagRepositoryCu @Query("select t from Tag t where t.user.id = :userId") List findAllByUserId(@Param("userId") Long userId); + Optional findByUserAndName(User user, String name); + @Modifying(clearAutomatically = true) @Query("update Tag t set t.usageCount = (select coalesce(count(pt.id), 0) from PostTag pt where pt.tag.id = t.id and pt.post.deletedAt is null)") void updateAllPostCounts(); diff --git a/src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepositoryCustom.java b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepositoryCustom.java similarity index 81% rename from src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepositoryCustom.java rename to src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepositoryCustom.java index 5ea17b1..08e16a7 100644 --- a/src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepositoryCustom.java +++ b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepositoryCustom.java @@ -1,4 +1,4 @@ -package com.jiwon.mylog.domain.tag.repository; +package com.jiwon.mylog.domain.tag.repository.tag; import com.jiwon.mylog.global.common.entity.PageResponse; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepositoryImpl.java b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepositoryImpl.java similarity index 97% rename from src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepositoryImpl.java rename to src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepositoryImpl.java index dcd4845..37e5988 100644 --- a/src/main/java/com/jiwon/mylog/domain/tag/repository/TagRepositoryImpl.java +++ b/src/main/java/com/jiwon/mylog/domain/tag/repository/tag/TagRepositoryImpl.java @@ -1,4 +1,4 @@ -package com.jiwon.mylog.domain.tag.repository; +package com.jiwon.mylog.domain.tag.repository.tag; import com.jiwon.mylog.global.common.entity.PageResponse; import com.jiwon.mylog.domain.tag.dto.response.TagCountResponse; diff --git a/src/main/java/com/jiwon/mylog/domain/tag/service/TagService.java b/src/main/java/com/jiwon/mylog/domain/tag/service/TagService.java index 1b3411f..b19524a 100644 --- a/src/main/java/com/jiwon/mylog/domain/tag/service/TagService.java +++ b/src/main/java/com/jiwon/mylog/domain/tag/service/TagService.java @@ -1,16 +1,15 @@ package com.jiwon.mylog.domain.tag.service; +import com.jiwon.mylog.domain.tag.repository.tag.TagJdbcRepository; import com.jiwon.mylog.global.common.entity.PageResponse; import com.jiwon.mylog.domain.tag.dto.request.TagRequest; import com.jiwon.mylog.domain.tag.dto.response.TagResponse; import com.jiwon.mylog.domain.tag.entity.Tag; -import com.jiwon.mylog.domain.tag.repository.TagRepository; +import com.jiwon.mylog.domain.tag.repository.tag.TagRepository; import com.jiwon.mylog.domain.user.entity.User; -import java.util.Collection; import java.util.List; import java.util.stream.Collectors; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -20,6 +19,7 @@ @Service public class TagService { + private final TagJdbcRepository tagJdbcRepository; private final TagRepository tagRepository; @Transactional(readOnly = true) @@ -36,39 +36,18 @@ public PageResponse getAllTagsWithCount(Long userId, Pageable pageable) { } @Transactional - public List getTagsById(User user, List tagRequests) { - + public List getOrCreateTags(User user, List tagRequests) { List names = tagRequests.stream() .map(TagRequest::getName) .distinct() - .collect(Collectors.toList()); + .toList(); - // 이미 존재하는 태그 - List existingTags = tagRepository.findAllByUserAndNameIn(user, names); + List tags = names.stream() + .map(name -> Tag.create(user, name)) + .toList(); - List existNames = existingTags.stream() - .map(Tag::getName) - .collect(Collectors.toList()); - - // 새로운 태그 - List createdTags = names.stream() - .filter(name -> !existNames.contains(name)) - .map(name -> createTag(user, name)) - .collect(Collectors.toList()); - - tagRepository.saveAll(createdTags); - - return Stream.of(existingTags, createdTags) - .flatMap(Collection::stream) - .collect(Collectors.toList()); - } + tagJdbcRepository.upsert(tags); - private Tag createTag(User user, String tagName) { - Tag tag = Tag.builder() - .name(tagName) - .usageCount(0L) - .user(user) - .build(); - return tag; + return tagRepository.findAllByUserAndNameIn(user, names); } } diff --git a/src/main/java/com/jiwon/mylog/global/common/config/JDBCTemplateConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/JDBCTemplateConfig.java new file mode 100644 index 0000000..a1507a5 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/global/common/config/JDBCTemplateConfig.java @@ -0,0 +1,15 @@ +package com.jiwon.mylog.global.common.config; + +import javax.sql.DataSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class JDBCTemplateConfig { + + @Bean + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } +} diff --git a/src/main/java/com/jiwon/mylog/global/schedular/CountScheduler.java b/src/main/java/com/jiwon/mylog/global/schedular/CountScheduler.java index df32e0e..d164d61 100644 --- a/src/main/java/com/jiwon/mylog/global/schedular/CountScheduler.java +++ b/src/main/java/com/jiwon/mylog/global/schedular/CountScheduler.java @@ -1,7 +1,7 @@ package com.jiwon.mylog.global.schedular; import com.jiwon.mylog.domain.category.repository.CategoryRepository; -import com.jiwon.mylog.domain.tag.repository.TagRepository; +import com.jiwon.mylog.domain.tag.repository.tag.TagRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; diff --git a/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceTest.java b/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceTest.java index 2b1c6be..89ceec0 100644 --- a/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceTest.java +++ b/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceTest.java @@ -12,7 +12,7 @@ import com.jiwon.mylog.domain.tag.dto.request.TagRequest; import com.jiwon.mylog.domain.tag.entity.PostTag; import com.jiwon.mylog.domain.tag.entity.Tag; -import com.jiwon.mylog.domain.tag.repository.TagRepository; +import com.jiwon.mylog.domain.tag.repository.tag.TagRepository; import com.jiwon.mylog.domain.tag.service.TagService; import com.jiwon.mylog.domain.user.entity.User; import com.jiwon.mylog.domain.user.repository.UserRepository; diff --git a/src/test/java/com/jiwon/mylog/domain/tag/repository/PostTagBatchTest.java b/src/test/java/com/jiwon/mylog/domain/tag/repository/PostTagBatchTest.java new file mode 100644 index 0000000..d9b9b59 --- /dev/null +++ b/src/test/java/com/jiwon/mylog/domain/tag/repository/PostTagBatchTest.java @@ -0,0 +1,109 @@ +package com.jiwon.mylog.domain.tag.repository; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jiwon.mylog.config.EmbeddedRedisConfig; +import com.jiwon.mylog.domain.post.entity.Post; +import com.jiwon.mylog.domain.tag.entity.PostTag; +import com.jiwon.mylog.domain.tag.entity.Tag; +import com.jiwon.mylog.domain.tag.repository.posttag.PostTagJdbcRepository; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionTemplate; + +@Import(EmbeddedRedisConfig.class) +@ActiveProfiles("test") +@SpringBootTest +class PostTagBatchTest { + + @Autowired + TransactionTemplate tx; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Autowired + PostTagJdbcRepository postTagJdbcRepository; + + private static final Long[] TAGS = {1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L}; + + @BeforeEach + void clean() { + jdbcTemplate.update("TRUNCATE TABLE post_tag"); + } + + private List buildPostTags(Long postId, int tagCount) { + List list = new ArrayList<>(tagCount); + for (int i = 0; i < tagCount; i++) { + Post post = Post.builder().id(postId).build(); + Tag tag = Tag.builder().id(TAGS[i]).build(); + + PostTag pt = new PostTag(); + pt.setPost(post); + pt.setTag(tag); + list.add(pt); + } + return list; + } + + private long measureMillis(Runnable r) { + long start = System.nanoTime(); + r.run(); + return (System.nanoTime() - start) / 1_000_000; + } + + private void insertIndividually(List postTags) { + final String sql = "INSERT INTO post_tag (post_id, tag_id) VALUES (?, ?)"; + for (PostTag pt : postTags) { + jdbcTemplate.update(sql, pt.getPost().getId(), pt.getTag().getId()); + } + } + + private void insertBatch(List postTags) { + postTagJdbcRepository.saveAll(postTags); + } + + @Test + void compare_single_post_with_max_tags() { + final int TAG_COUNT = 10; + final Long POST_ID = 1L; + + List postTags = buildPostTags(POST_ID, TAG_COUNT); + long ms1 = measureMillis(() -> tx.executeWithoutResult(s -> insertIndividually(postTags))); + + jdbcTemplate.update("DELETE FROM post_tag WHERE post_id = ?", POST_ID); + + List postTags2 = buildPostTags(POST_ID, TAG_COUNT); + long ms2 = measureMillis(() -> tx.executeWithoutResult(s -> insertBatch(postTags2))); + + System.out.println("단일 포스트 10개 태그"); + System.out.printf("개별 INSERT: %d ms%n", ms1); + System.out.printf("배치 INSERT: %d ms%n", ms2); + + assertTrue(ms1 > 0 && ms2 > 0); + } + + @ParameterizedTest + @ValueSource(ints = {1, 3, 5, 7, 10}) + void test_different_tag_counts(int tagCount) { + List postTags = buildPostTags(1L, tagCount); + + long ms1 = measureMillis(() -> tx.executeWithoutResult(s -> insertIndividually(postTags))); + jdbcTemplate.update("DELETE FROM post_tag WHERE post_id = 1"); + + List postTags2 = buildPostTags(1L, tagCount); + long ms2 = measureMillis(() -> tx.executeWithoutResult(s -> insertBatch(postTags2))); + jdbcTemplate.update("DELETE FROM post_tag WHERE post_id = 1"); + + System.out.printf("태그 %d개: 개별=%dms, 배치=%dms%n", tagCount, ms1, ms2); + } +} \ No newline at end of file