diff --git a/backend/src/main/java/hanglog/community/service/CommunityService.java b/backend/src/main/java/hanglog/community/service/CommunityService.java index cd7454a97..c23bbc041 100644 --- a/backend/src/main/java/hanglog/community/service/CommunityService.java +++ b/backend/src/main/java/hanglog/community/service/CommunityService.java @@ -14,9 +14,14 @@ import hanglog.community.dto.response.CommunityTripResponse; import hanglog.community.dto.response.RecommendTripListResponse; import hanglog.global.exception.BadRequestException; +import hanglog.like.domain.LikeCount; import hanglog.like.domain.LikeInfo; +import hanglog.like.domain.MemberLike; +import hanglog.like.dto.LikeElement; import hanglog.like.dto.LikeElements; -import hanglog.like.repository.LikeRepository; +import hanglog.like.domain.repository.LikeCountRepository; +import hanglog.like.domain.repository.LikeRepository; +import hanglog.like.domain.repository.MemberLikeRepository; import hanglog.trip.domain.Trip; import hanglog.trip.domain.repository.TripCityRepository; import hanglog.trip.domain.repository.TripRepository; @@ -25,6 +30,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; @@ -45,6 +51,8 @@ public class CommunityService { private final CityRepository cityRepository; private final RecommendStrategies recommendStrategies; private final PublishedTripRepository publishedTripRepository; + private final LikeCountRepository likeCountRepository; + private final MemberLikeRepository memberLikeRepository; @Transactional(readOnly = true) public CommunityTripListResponse getCommunityTripsByPage(final Accessor accessor, final Pageable pageable) { @@ -119,20 +127,31 @@ public TripDetailResponse getTripDetail(final Accessor accessor, final Long trip final LocalDateTime publishedDate = publishedTripRepository.findByTripId(tripId) .orElseThrow(() -> new BadRequestException(NOT_FOUND_TRIP_ID)) .getCreatedAt(); - final LikeElements likeElements = new LikeElements(likeRepository.findLikeCountAndIsLikeByTripIds( - accessor.getMemberId(), - List.of(tripId) - )); - final Map likeInfoByTrip = likeElements.toLikeMap(); + + final LikeElement likeElement = getLikeElement(accessor.getMemberId(), tripId); final Boolean isWriter = trip.isWriter(accessor.getMemberId()); return TripDetailResponse.publishedTrip( trip, cities, isWriter, - isLike(likeInfoByTrip, tripId), - getLikeCount(likeInfoByTrip, tripId), + likeElement.isLike(), + likeElement.getLikeCount(), publishedDate ); } + + private LikeElement getLikeElement(final Long memberId, final Long tripId) { + final Optional likeCount = likeCountRepository.findById(tripId); + final Optional memberLike = memberLikeRepository.findById(memberId); + if (likeCount.isPresent() && memberLike.isPresent()) { + final Map tripLikeStatusMap = memberLike.get().getLikeStatusForTrip(); + if (tripLikeStatusMap.containsKey(tripId)) { + return new LikeElement(tripId, likeCount.get().getCount(), tripLikeStatusMap.get(tripId)); + } + return new LikeElement(tripId, likeCount.get().getCount(), false); + } + return likeRepository.findLikeCountAndIsLikeByTripId(memberId, tripId) + .orElseGet(() -> new LikeElement(tripId, 0, false)); + } } diff --git a/backend/src/main/java/hanglog/like/domain/LikeCount.java b/backend/src/main/java/hanglog/like/domain/LikeCount.java new file mode 100644 index 000000000..62730449f --- /dev/null +++ b/backend/src/main/java/hanglog/like/domain/LikeCount.java @@ -0,0 +1,17 @@ +package hanglog.like.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@AllArgsConstructor +@RedisHash(value = "likeCount") +public class LikeCount { + + @Id + private Long tripId; + + private Long count; +} diff --git a/backend/src/main/java/hanglog/like/domain/MemberLike.java b/backend/src/main/java/hanglog/like/domain/MemberLike.java new file mode 100644 index 000000000..50101bee5 --- /dev/null +++ b/backend/src/main/java/hanglog/like/domain/MemberLike.java @@ -0,0 +1,18 @@ +package hanglog.like.domain; + +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@AllArgsConstructor +@RedisHash(value = "memberLike", timeToLive = 5400) +public class MemberLike { + + @Id + private Long memberId; + + private Map likeStatusForTrip; +} diff --git a/backend/src/main/java/hanglog/like/domain/repository/CustomLikeRepository.java b/backend/src/main/java/hanglog/like/domain/repository/CustomLikeRepository.java new file mode 100644 index 000000000..32e06b818 --- /dev/null +++ b/backend/src/main/java/hanglog/like/domain/repository/CustomLikeRepository.java @@ -0,0 +1,9 @@ +package hanglog.like.domain.repository; + +import hanglog.like.domain.Likes; +import java.util.List; + +public interface CustomLikeRepository { + + void saveAll(final List likes); +} diff --git a/backend/src/main/java/hanglog/like/domain/repository/LikeCountRepository.java b/backend/src/main/java/hanglog/like/domain/repository/LikeCountRepository.java new file mode 100644 index 000000000..de64fea80 --- /dev/null +++ b/backend/src/main/java/hanglog/like/domain/repository/LikeCountRepository.java @@ -0,0 +1,10 @@ +package hanglog.like.domain.repository; + +import hanglog.like.domain.LikeCount; +import java.util.List; +import org.springframework.data.repository.CrudRepository; + +public interface LikeCountRepository extends CrudRepository { + + List findLikeCountsByTripIdIn(final List tripIds); +} diff --git a/backend/src/main/java/hanglog/like/repository/LikeRepository.java b/backend/src/main/java/hanglog/like/domain/repository/LikeRepository.java similarity index 50% rename from backend/src/main/java/hanglog/like/repository/LikeRepository.java rename to backend/src/main/java/hanglog/like/domain/repository/LikeRepository.java index f2762a0e7..1cdbe97e4 100644 --- a/backend/src/main/java/hanglog/like/repository/LikeRepository.java +++ b/backend/src/main/java/hanglog/like/domain/repository/LikeRepository.java @@ -1,18 +1,16 @@ -package hanglog.like.repository; +package hanglog.like.domain.repository; import hanglog.like.domain.Likes; import hanglog.like.dto.LikeElement; +import hanglog.like.dto.TripLikeCount; import java.util.List; +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; public interface LikeRepository extends JpaRepository { - boolean existsByMemberIdAndTripId(final Long memberId, final Long tripId); - - void deleteByMemberIdAndTripId(final Long memberId, final Long tripId); - @Query(""" SELECT new hanglog.like.dto.LikeElement (l.tripId, COUNT(l.memberId), EXISTS(SELECT 1 FROM Likes l_1 WHERE l_1.memberId = :memberId AND l_1.tripId = l.tripId)) @@ -22,4 +20,21 @@ public interface LikeRepository extends JpaRepository { """) List findLikeCountAndIsLikeByTripIds(@Param("memberId") final Long memberId, @Param("tripIds") final List tripIds); + + @Query(""" + SELECT new hanglog.like.dto.LikeElement + (l.tripId, COUNT(l.memberId), EXISTS(SELECT 1 FROM Likes l_1 WHERE l_1.memberId = :memberId AND l_1.tripId = l.tripId)) + FROM Likes l + WHERE l.tripId = :tripId + GROUP BY l.tripId + """) + Optional findLikeCountAndIsLikeByTripId(@Param("memberId") final Long memberId, + @Param("tripId") final Long tripId); + + @Query(""" + SELECT new hanglog.like.dto.TripLikeCount(l.tripId, COUNT(l.memberId)) + FROM Likes l + GROUP BY l.tripId + """) + List findCountByAllTrips(); } diff --git a/backend/src/main/java/hanglog/like/domain/repository/MemberLikeRepository.java b/backend/src/main/java/hanglog/like/domain/repository/MemberLikeRepository.java new file mode 100644 index 000000000..f338c37cd --- /dev/null +++ b/backend/src/main/java/hanglog/like/domain/repository/MemberLikeRepository.java @@ -0,0 +1,7 @@ +package hanglog.like.domain.repository; + +import hanglog.like.domain.MemberLike; +import org.springframework.data.repository.CrudRepository; + +public interface MemberLikeRepository extends CrudRepository { +} diff --git a/backend/src/main/java/hanglog/like/dto/TripLikeCount.java b/backend/src/main/java/hanglog/like/dto/TripLikeCount.java new file mode 100644 index 000000000..5f43296c9 --- /dev/null +++ b/backend/src/main/java/hanglog/like/dto/TripLikeCount.java @@ -0,0 +1,12 @@ +package hanglog.like.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class TripLikeCount { + + private final long tripId; + private final long count; +} diff --git a/backend/src/main/java/hanglog/like/infrastrcutrue/CustomLikeRepositoryImpl.java b/backend/src/main/java/hanglog/like/infrastrcutrue/CustomLikeRepositoryImpl.java new file mode 100644 index 000000000..98e983850 --- /dev/null +++ b/backend/src/main/java/hanglog/like/infrastrcutrue/CustomLikeRepositoryImpl.java @@ -0,0 +1,37 @@ +package hanglog.like.infrastrcutrue; + +import hanglog.like.domain.Likes; +import hanglog.like.domain.repository.CustomLikeRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class CustomLikeRepositoryImpl implements CustomLikeRepository { + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + @Override + public void saveAll(final List likes) { + final String sql = """ + INSERT INTO likes (trip_id, member_id) + VALUES (:tripId, :memberId) + """; + namedParameterJdbcTemplate.batchUpdate(sql, getLikesToSqlParameterSources(likes)); + } + + private MapSqlParameterSource[] getLikesToSqlParameterSources(final List likes) { + return likes.stream() + .map(this::getLikeToSqlParameterSource) + .toArray(MapSqlParameterSource[]::new); + } + + private MapSqlParameterSource getLikeToSqlParameterSource(final Likes likes) { + return new MapSqlParameterSource() + .addValue("tripId", likes.getTripId()) + .addValue("memberId", likes.getMemberId()); + } +} diff --git a/backend/src/main/java/hanglog/like/service/LikeService.java b/backend/src/main/java/hanglog/like/service/LikeService.java index bfd76c50a..fc4799265 100644 --- a/backend/src/main/java/hanglog/like/service/LikeService.java +++ b/backend/src/main/java/hanglog/like/service/LikeService.java @@ -1,9 +1,22 @@ package hanglog.like.service; +import hanglog.like.domain.LikeCount; import hanglog.like.domain.Likes; +import hanglog.like.domain.MemberLike; +import hanglog.like.dto.TripLikeCount; import hanglog.like.dto.request.LikeRequest; -import hanglog.like.repository.LikeRepository; +import hanglog.like.domain.repository.CustomLikeRepository; +import hanglog.like.domain.repository.LikeCountRepository; +import hanglog.like.domain.repository.LikeRepository; +import hanglog.like.domain.repository.MemberLikeRepository; +import hanglog.member.domain.repository.MemberRepository; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,15 +26,49 @@ public class LikeService { private final LikeRepository likeRepository; + private final MemberLikeRepository memberLikeRepository; + private final LikeCountRepository likeCountRepository; + private final MemberRepository memberRepository; + private final CustomLikeRepository customLikeRepository; public void update(final Long memberId, final Long tripId, final LikeRequest likeRequest) { - final boolean requestStatus = likeRequest.getIsLike(); + Map tripLikeStatusMap = new HashMap<>(); + final Optional memberLike = memberLikeRepository.findById(memberId); + if (memberLike.isPresent()) { + tripLikeStatusMap = memberLike.get().getLikeStatusForTrip(); + } + tripLikeStatusMap.put(tripId, likeRequest.getIsLike()); + memberLikeRepository.save(new MemberLike(memberId, tripLikeStatusMap)); + updateLikeCountCache(tripId, likeRequest); + } - if (requestStatus && !likeRepository.existsByMemberIdAndTripId(memberId, tripId)) { - likeRepository.save(new Likes(tripId, memberId)); + private void updateLikeCountCache(final Long tripId, final LikeRequest likeRequest) { + final Optional likeCount = likeCountRepository.findById(tripId); + if (Boolean.TRUE.equals(likeRequest.getIsLike())) { + likeCount.ifPresent(count -> likeCountRepository.save(new LikeCount(tripId, count.getCount() + 1))); + return; } - if (!requestStatus) { - likeRepository.deleteByMemberIdAndTripId(memberId, tripId); + likeCount.ifPresent(count -> likeCountRepository.save(new LikeCount(tripId, count.getCount() - 1))); + } + + @Scheduled(cron = "0 0 * * * *") + public void writeBackMemberLikeCache() { + final List likes = memberRepository.findAll().stream() + .flatMap(member -> memberLikeRepository.findById(member.getId()) + .map(memberLike -> memberLike.getLikeStatusForTrip() + .entrySet().stream() + .filter(Map.Entry::getValue) + .map(entry -> new Likes(entry.getKey(), member.getId()))) + .orElseGet(Stream::empty)) + .toList(); + customLikeRepository.saveAll(likes); + } + + @Scheduled(cron = "0 0 0 * * *") + public void cacheLikeCount() { + final List tripLikeCounts = likeRepository.findCountByAllTrips(); + for (final TripLikeCount tripLikeCount : tripLikeCounts) { + likeCountRepository.save(new LikeCount(tripLikeCount.getTripId(), tripLikeCount.getCount())); } } } diff --git a/backend/src/test/java/hanglog/integration/service/CommunityServiceIntegrationTest.java b/backend/src/test/java/hanglog/integration/service/CommunityServiceIntegrationTest.java index 70fbc81d6..f3f706bb3 100644 --- a/backend/src/test/java/hanglog/integration/service/CommunityServiceIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/service/CommunityServiceIntegrationTest.java @@ -26,16 +26,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -@Import({ - TripService.class, - CommunityService.class, - LedgerService.class, - RecommendStrategies.class, - CustomDayLogRepositoryImpl.class, - CustomTripCityRepositoryImpl.class, - EventListenerTestConfig.class -}) -class CommunityServiceIntegrationTest extends ServiceIntegrationTest { +class CommunityServiceIntegrationTest extends RedisServiceIntegrationTest { @Autowired private TripService tripService; diff --git a/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java b/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java index 1d5b71f59..843d661e9 100644 --- a/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java @@ -4,23 +4,14 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import hanglog.like.dto.request.LikeRequest; -import hanglog.like.repository.LikeRepository; +import hanglog.like.domain.repository.MemberLikeRepository; import hanglog.like.service.LikeService; -import hanglog.trip.infrastructure.CustomDayLogRepositoryImpl; -import hanglog.trip.infrastructure.CustomTripCityRepositoryImpl; import hanglog.trip.service.TripService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Import; -@Import({ - TripService.class, - LikeService.class, - CustomDayLogRepositoryImpl.class, - CustomTripCityRepositoryImpl.class -}) -class LikeServiceIntegrationTest extends ServiceIntegrationTest { +class LikeServiceIntegrationTest extends RedisServiceIntegrationTest { @Autowired private TripService tripService; @@ -29,7 +20,7 @@ class LikeServiceIntegrationTest extends ServiceIntegrationTest { private LikeService likeService; @Autowired - private LikeRepository likeRepository; + private MemberLikeRepository memberLikeRepository; @DisplayName("해당 게시물의 좋아요 여부를 변경할 수 있다.") @Test @@ -42,11 +33,21 @@ void update() { // when & then assertSoftly(softly -> { - softly.assertThat(likeRepository.existsByMemberIdAndTripId(member.getId(), tripId)).isFalse(); + softly.assertThat(memberLikeRepository.findById(member.getId())).isEmpty(); + likeService.update(member.getId(), tripId, likeTrueRequest); - softly.assertThat(likeRepository.existsByMemberIdAndTripId(member.getId(), tripId)).isTrue(); + softly.assertThat(memberLikeRepository.findById(member.getId())).isPresent(); + softly.assertThat(memberLikeRepository.findById(member.getId()) + .get() + .getLikeStatusForTrip() + .get(tripId)).isTrue(); + likeService.update(member.getId(), tripId, likeFalseRequest); - softly.assertThat(likeRepository.existsByMemberIdAndTripId(member.getId(), tripId)).isFalse(); + softly.assertThat(memberLikeRepository.findById(member.getId())).isPresent(); + softly.assertThat(memberLikeRepository.findById(member.getId()) + .get() + .getLikeStatusForTrip() + .get(tripId)).isFalse(); }); } } diff --git a/backend/src/test/java/hanglog/integration/service/RedisServiceIntegrationTest.java b/backend/src/test/java/hanglog/integration/service/RedisServiceIntegrationTest.java new file mode 100644 index 000000000..2c70b69f7 --- /dev/null +++ b/backend/src/test/java/hanglog/integration/service/RedisServiceIntegrationTest.java @@ -0,0 +1,35 @@ +package hanglog.integration.service; + +import hanglog.global.config.RedisTestConfig; +import hanglog.member.domain.Member; +import hanglog.member.domain.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +@SpringBootTest +@Sql(value = { + "classpath:data/truncate.sql", + "classpath:data/currency.sql", + "classpath:data/cities.sql", + "classpath:data/categories.sql" +}) +@Import(RedisTestConfig.class) +public class RedisServiceIntegrationTest { + + @Autowired + protected MemberRepository memberRepository; + + public Member member; + + @BeforeEach + void setMember() { + this.member = memberRepository.save(new Member( + "socialLoginId", + "name", + "https://hanglog.com/img/imageName.png" + )); + } +}