diff --git a/src/main/java/com/example/solidconnection/community/post/domain/ViewCountEvent.java b/src/main/java/com/example/solidconnection/community/post/domain/ViewCountEvent.java new file mode 100644 index 000000000..5aaee4e24 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/domain/ViewCountEvent.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.community.post.domain; + +public record ViewCountEvent( + String streamId, + String flushId, + long postId, + long delta +) { + + public ViewCountEvent { + if (flushId == null || flushId.isBlank() || flushId.length() > 100 || postId <= 0 || delta <= 0) { + throw new IllegalArgumentException("Invalid view count event"); + } + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/domain/ViewCountFlush.java b/src/main/java/com/example/solidconnection/community/post/domain/ViewCountFlush.java new file mode 100644 index 000000000..848b4174b --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/domain/ViewCountFlush.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.community.post.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "view_count_flush", uniqueConstraints = @UniqueConstraint(name = "uk_view_count_flush_id", columnNames = "flush_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ViewCountFlush { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "flush_id", nullable = false, length = 100) + private String flushId; + + // Receipts survive physical post deletion and late redelivery. + @Column(name = "post_id", nullable = false) + private long postId; + + @Column(name = "delta", nullable = false) + private long delta; +} diff --git a/src/main/java/com/example/solidconnection/community/post/repository/ViewCountFlushRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/ViewCountFlushRepository.java new file mode 100644 index 000000000..cdbef5729 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/repository/ViewCountFlushRepository.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.community.post.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ViewCountFlushRepository { + + private final JdbcTemplate jdbcTemplate; + + public boolean recordIfNew( + String flushId, + long postId, + long delta + ) { + try { + jdbcTemplate.update("INSERT INTO view_count_flush (flush_id, post_id, delta) VALUES (?, ?, ?)", + flushId, postId, delta); + return true; + } catch (DuplicateKeyException exception) { + // A JDBC duplicate does not mark the JPA transaction rollback-only. + return false; + } + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/repository/ViewCountStreamRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/ViewCountStreamRepository.java new file mode 100644 index 000000000..973e65e2d --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/repository/ViewCountStreamRepository.java @@ -0,0 +1,111 @@ +package com.example.solidconnection.community.post.repository; + +import com.example.solidconnection.community.post.domain.ViewCountEvent; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Repository; + +@Repository +@Slf4j +public class ViewCountStreamRepository { + + public static final String ACTIVE_KEY = "post:view:{views}:active"; + public static final String STREAM_KEY = "post:view:{views}:events"; + public static final String GROUP = "view-count-db"; + private static final int BATCH_SIZE = 50; + private static final RedisScript FLUSH = RedisScript.of(new ClassPathResource("scripts/flushViewCounts.lua"), Long.class); + private static final RedisScript CLAIM = RedisScript.of(new ClassPathResource("scripts/claimViewCounts.lua"), List.class); + private static final RedisScript ACK = RedisScript.of(new ClassPathResource("scripts/ackViewCount.lua"), Long.class); + private static final RedisScript INIT_GROUP = RedisScript.of(""" + local result = redis.pcall('XGROUP', 'CREATE', KEYS[1], ARGV[1], '0', 'MKSTREAM') + if type(result) == 'table' and result.err and not string.find(result.err, 'BUSYGROUP', 1, true) then + return redis.error_reply(result.err) + end + return 1 + """, Long.class); + + private final StringRedisTemplate redis; + private String claimCursor = "0-0"; + + public ViewCountStreamRepository(StringRedisTemplate redis) { + this.redis = redis; + } + + public void increment(long postId) { + redis.opsForHash().increment(ACTIVE_KEY, Long.toString(postId), 1); + } + + public void deleteActive(long postId) { + redis.opsForHash().delete(ACTIVE_KEY, Long.toString(postId)); + } + + public long flush() { + Long count = redis.execute(FLUSH, List.of(ACTIVE_KEY, STREAM_KEY), UUID.randomUUID().toString(), Integer.toString(BATCH_SIZE)); + return count == null ? 0 : count; + } + + public void ensureGroup() { + redis.execute(INIT_GROUP, List.of(STREAM_KEY), GROUP); + } + + public List readNew(String consumer) { + List> records = redis.opsForStream().read( + Consumer.from(GROUP, consumer), StreamReadOptions.empty().count(BATCH_SIZE), + StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())); + if (records == null) { + return List.of(); + } + return records.stream().flatMap(record -> { + Map fields = record.getValue(); + return parseEvent(record.getId().getValue(), Objects.toString(fields.get("flushId"), ""), + Objects.toString(fields.get("postId"), ""), Objects.toString(fields.get("delta"), "")).stream(); + }).toList(); + } + + public synchronized List claim(String consumer, Duration minIdle) { + List response = redis.execute(CLAIM, List.of(STREAM_KEY), GROUP, consumer, + Long.toString(minIdle.toMillis()), claimCursor, Integer.toString(BATCH_SIZE)); + if (response == null || response.isEmpty()) { + return List.of(); + } + claimCursor = response.get(0).toString(); + List events = new ArrayList<>(); + for (int i = 1; i < response.size(); i += 4) { + parseEvent(response.get(i).toString(), response.get(i + 1).toString(), + response.get(i + 2).toString(), response.get(i + 3).toString()).ifPresent(events::add); + } + return events; + } + + private Optional parseEvent( + String streamId, + String flushId, + String postId, + String delta + ) { + try { + return Optional.of(new ViewCountEvent(streamId, flushId, Long.parseLong(postId), Long.parseLong(delta))); + } catch (IllegalArgumentException exception) { + log.warn("Invalid view count event remains pending: streamId={}", streamId, exception); + return Optional.empty(); + } + } + + public void acknowledge(ViewCountEvent event) { + redis.execute(ACK, List.of(STREAM_KEY), GROUP, event.streamId()); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostRedisManager.java b/src/main/java/com/example/solidconnection/community/post/service/PostRedisManager.java index e074b45f4..1c39910e8 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostRedisManager.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostRedisManager.java @@ -4,8 +4,8 @@ import static com.example.solidconnection.redis.RedisConstants.VALIDATE_POST_CREATE_TTL; import static com.example.solidconnection.redis.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX; import static com.example.solidconnection.redis.RedisConstants.VALIDATE_VIEW_COUNT_TTL; -import static com.example.solidconnection.redis.RedisConstants.VIEW_COUNT_KEY_PREFIX; +import com.example.solidconnection.community.post.repository.ViewCountStreamRepository; import com.example.solidconnection.redis.RedisService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -15,18 +15,10 @@ public class PostRedisManager { private final RedisService redisService; - - public Long getPostIdFromPostViewCountRedisKey(String key) { - return Long.parseLong(key.substring(VIEW_COUNT_KEY_PREFIX.getValue().length())); - } - - public Long getAndDeleteViewCount(String key) { - return redisService.getAndDelete(key); - } + private final ViewCountStreamRepository streamRepository; public void deleteViewCountCache(Long postId) { - String key = getPostViewCountRedisKey(postId); - redisService.deleteKey(key); + streamRepository.deleteActive(postId); } public void incrementViewCountIfFirstAccess(long siteUserId, Long postId) { @@ -34,15 +26,10 @@ public void incrementViewCountIfFirstAccess(long siteUserId, Long postId) { boolean isFirstAccess = redisService.isPresent(validateKey, VALIDATE_VIEW_COUNT_TTL.getValue()); if (isFirstAccess) { - String viewCountKey = getPostViewCountRedisKey(postId); - redisService.increaseViewCount(viewCountKey); + streamRepository.increment(postId); } } - public String getPostViewCountRedisKey(Long postId) { - return VIEW_COUNT_KEY_PREFIX.getValue() + postId; - } - public String getValidatePostViewCountRedisKey(long siteUserId, Long postId) { return VALIDATE_VIEW_COUNT_KEY_PREFIX.getValue() + postId + ":" + siteUserId; } diff --git a/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java index 792177306..4778fa63b 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java @@ -1,30 +1,24 @@ package com.example.solidconnection.community.post.service; +import com.example.solidconnection.community.post.domain.ViewCountEvent; import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.ViewCountFlushRepository; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service -@EnableAsync -@Slf4j public class UpdateViewCountService { private final PostRepository postRepository; - private final PostRedisManager postRedisManager; + private final ViewCountFlushRepository flushRepository; @Transactional - @Async - public void updateViewCount(String key) { - Long postId = postRedisManager.getPostIdFromPostViewCountRedisKey(key); - Long viewCount = postRedisManager.getAndDeleteViewCount(key); - if (viewCount == null) { - return; + public void apply(ViewCountEvent event) { + if (flushRepository.recordIfNew(event.flushId(), event.postId(), event.delta())) { + // Deleted posts are a completed no-op; retain the receipt against replay. + postRepository.increaseViewCount(event.postId(), event.delta()); } - postRepository.increaseViewCount(postId, viewCount); } } diff --git a/src/main/java/com/example/solidconnection/community/post/service/ViewCountConsumer.java b/src/main/java/com/example/solidconnection/community/post/service/ViewCountConsumer.java new file mode 100644 index 000000000..05f3aaa29 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/ViewCountConsumer.java @@ -0,0 +1,49 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.post.domain.ViewCountEvent; +import com.example.solidconnection.community.post.repository.ViewCountStreamRepository; +import java.time.Duration; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class ViewCountConsumer { + + private final ViewCountStreamRepository streamRepository; + private final UpdateViewCountService updateService; + private final Duration minIdle; + private final String consumer = UUID.randomUUID().toString(); + + public ViewCountConsumer( + ViewCountStreamRepository streamRepository, + UpdateViewCountService updateService, + @Value("${view.count.claim-min-idle:60s}") Duration minIdle + ) { + this.streamRepository = streamRepository; + this.updateService = updateService; + this.minIdle = minIdle; + } + + public void consume() { + streamRepository.ensureGroup(); + streamRepository.claim(consumer, minIdle).forEach(this::process); + streamRepository.readNew(consumer).forEach(this::process); + } + + public void flushActiveCounters() { + streamRepository.flush(); + } + + public void process(ViewCountEvent event) { + try { + updateService.apply(event); + // The transactional proxy has committed before apply returns. + streamRepository.acknowledge(event); + } catch (RuntimeException exception) { + log.warn("View count event remains pending: flushId={}", event.flushId(), exception); + } + } +} diff --git a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java index 202d8f05c..82a431ced 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java @@ -1,36 +1,25 @@ package com.example.solidconnection.scheduler; -import static com.example.solidconnection.redis.RedisConstants.VIEW_COUNT_KEY_PATTERN; - -import com.example.solidconnection.community.post.service.UpdateViewCountService; -import com.example.solidconnection.util.RedisUtils; -import java.util.List; +import com.example.solidconnection.community.post.service.ViewCountConsumer; import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; @RequiredArgsConstructor @Component -@EnableScheduling -@EnableAsync +@ConditionalOnProperty(name = "view.count.scheduling.enabled", havingValue = "true", matchIfMissing = true) public class UpdateViewCountScheduler { - private final RedisUtils redisUtils; - private final ThreadPoolTaskExecutor asyncExecutor; - private final UpdateViewCountService updateViewCountService; + private final ViewCountConsumer consumer; - @Async @Scheduled(fixedDelayString = "${view.count.scheduling.delay}") public void updateViewCount() { - - List itemViewCountKeys = redisUtils.getKeysOrderByExpiration(VIEW_COUNT_KEY_PATTERN.getValue()); - - itemViewCountKeys.forEach(key -> asyncExecutor.submit(() -> { - updateViewCountService.updateViewCount(key); - })); + try { + consumer.flushActiveCounters(); + } finally { + // A failed flush must not prevent recovery of already persisted events. + consumer.consume(); + } } } diff --git a/src/main/resources/db/migration/V63__create_view_count_flush.sql b/src/main/resources/db/migration/V63__create_view_count_flush.sql new file mode 100644 index 000000000..bca65140c --- /dev/null +++ b/src/main/resources/db/migration/V63__create_view_count_flush.sql @@ -0,0 +1,8 @@ +CREATE TABLE view_count_flush ( + id BIGINT NOT NULL AUTO_INCREMENT, + flush_id VARCHAR(100) NOT NULL, + post_id BIGINT NOT NULL, + delta BIGINT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_view_count_flush_id UNIQUE (flush_id) +); diff --git a/src/main/resources/scripts/ackViewCount.lua b/src/main/resources/scripts/ackViewCount.lua new file mode 100644 index 000000000..b6ba74857 --- /dev/null +++ b/src/main/resources/scripts/ackViewCount.lua @@ -0,0 +1,6 @@ +-- Only one consumer group owns this stream. +local acknowledged = redis.call('XACK', KEYS[1], ARGV[1], ARGV[2]) +if acknowledged == 1 then + redis.call('XDEL', KEYS[1], ARGV[2]) +end +return acknowledged diff --git a/src/main/resources/scripts/claimViewCounts.lua b/src/main/resources/scripts/claimViewCounts.lua new file mode 100644 index 000000000..eaa8cd394 --- /dev/null +++ b/src/main/resources/scripts/claimViewCounts.lua @@ -0,0 +1,14 @@ +local response = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', ARGV[5]) +local result = {response[1]} +for _, entry in ipairs(response[2]) do + local fields = {} + for i = 1, #entry[2], 2 do + fields[entry[2][i]] = entry[2][i + 1] + end + table.insert(result, entry[1]) + -- Keep the response shape even for malformed entries. Leave them pending for inspection. + table.insert(result, fields['flushId'] or '') + table.insert(result, fields['postId'] or '') + table.insert(result, fields['delta'] or '') +end +return result diff --git a/src/main/resources/scripts/flushViewCounts.lua b/src/main/resources/scripts/flushViewCounts.lua new file mode 100644 index 000000000..6ac855d4f --- /dev/null +++ b/src/main/resources/scripts/flushViewCounts.lua @@ -0,0 +1,9 @@ +-- Persist each event before removing its counter. Bound work per invocation. +local posts = redis.call('HRANDFIELD', KEYS[1], tonumber(ARGV[2])) +for _, postId in ipairs(posts) do + local delta = redis.call('HGET', KEYS[1], postId) + redis.call('XADD', KEYS[2], '*', 'flushId', ARGV[1] .. ':' .. postId, + 'postId', postId, 'delta', delta) + redis.call('HDEL', KEYS[1], postId) +end +return #posts diff --git a/src/test/java/com/example/solidconnection/community/post/service/LegacyViewCountFailureTest.java b/src/test/java/com/example/solidconnection/community/post/service/LegacyViewCountFailureTest.java new file mode 100644 index 000000000..ff37f9e5c --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/LegacyViewCountFailureTest.java @@ -0,0 +1,106 @@ +package com.example.solidconnection.community.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.fixture.BoardFixture; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.redis.RedisService; +import com.example.solidconnection.scheduler.UpdateViewCountScheduler; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; + +@TestContainerSpringBootTest +@DisplayName("기존 조회수 집계 유실 재현 테스트") +class LegacyViewCountFailureTest { + + @Autowired + private PostFixture postFixture; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private PostRepository postRepository; + + @Autowired + private RedisService redisService; + + @Autowired + private TransactionTemplate transactionTemplate; + + @MockitoBean + private UpdateViewCountScheduler scheduler; + + @Nested + @DisplayName("기존 조회수 집계의 유실 재현") + class 기존_구현의_장애_재현 { + + @Test + void 기존_구현에서_DB_실패_5건의_조회수_500회가_유실된다() { + // given: 게시글 150개 중 50개에 각각 100회 조회 + Board board = boardFixture.자유게시판(); + SiteUser user = siteUserFixture.사용자(); + List posts = new ArrayList<>(); + for (int i = 0; i < 150; i++) { + posts.add(postFixture.게시글("title", "content", false, PostCategory.자유, board, user)); + } + for (int i = 0; i < 50; i++) { + for (int count = 0; count < 100; count++) { + redisService.increaseViewCount("post:view:" + posts.get(i).getId()); + } + } + PostRepository failingRepository = mock(PostRepository.class); + doThrow(new IllegalStateException("injected DB failure")) + .when(failingRepository).increaseViewCount(anyLong(), anyLong()); + // Frozen copy of the pre-change GETDEL -> DB behavior, measured before implementation. + + // when: 첫 5개 DB 실패 후 같은 작업 재시도 + for (int i = 0; i < 50; i++) { + String key = "post:view:" + posts.get(i).getId(); + if (i < 5) { + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> legacyFlush(key, failingRepository))) + .isInstanceOf(IllegalStateException.class); + } + transactionTemplate.executeWithoutResult(status -> legacyFlush(key, postRepository)); + } + + // then: 총합과 게시글별 결과 모두 검증 + long total = 0; + for (int i = 0; i < 150; i++) { + long actual = postRepository.getById(posts.get(i).getId()).getViewCount(); + assertThat(actual).isEqualTo(i >= 5 && i < 50 ? 100 : 0); + total += actual; + } + assertThat(total).isEqualTo(4500); + System.out.println("BASELINE input=5000 persisted=" + total + " lost=" + (5000 - total)); + } + + private void legacyFlush(String key, PostRepository repository) { + long postId = Long.parseLong(key.substring("post:view:".length())); + Long delta = redisService.getAndDelete(key); + if (delta != null) { + repository.increaseViewCount(postId, delta); + } + } + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java index 309797032..b3f65aa04 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java @@ -25,6 +25,7 @@ import com.example.solidconnection.community.post.fixture.PostFixture; import com.example.solidconnection.community.post.fixture.PostImageFixture; import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.ViewCountStreamRepository; import com.example.solidconnection.redis.RedisService; import com.example.solidconnection.s3.domain.UploadPath; import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; @@ -39,6 +40,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.web.multipart.MultipartFile; @@ -47,6 +49,9 @@ @DisplayName("게시글 생성/수정/삭제 서비스 테스트") class PostCommandServiceTest { + @Autowired + private StringRedisTemplate redisTemplate; + @Autowired private PostCommandService postCommandService; @@ -304,8 +309,7 @@ class 게시글_삭제_테스트 { // given String originImageUrl = "origin-image-url"; postImageFixture.게시글_이미지(originImageUrl, post); - String viewCountKey = postRedisManager.getPostViewCountRedisKey(post.getId()); - redisService.increaseViewCount(viewCountKey); + postRedisManager.incrementViewCountIfFirstAccess(user1.getId(), post.getId()); // when PostDeleteResponse response = postCommandService.deletePostById(user1.getId(), post.getId()); @@ -314,7 +318,9 @@ class 게시글_삭제_테스트 { assertAll( () -> assertThat(response.id()).isEqualTo(post.getId()), () -> assertThat(postRepository.findById(post.getId())).isEmpty(), - () -> assertThat(redisService.isKeyExists(viewCountKey)).isFalse() + () -> assertThat(redisTemplate.opsForHash().hasKey( + ViewCountStreamRepository.ACTIVE_KEY, + post.getId().toString())).isFalse() ); then(s3Service).should().deletePostImage(originImageUrl); } diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java index 6d8dd67f1..eb9a421d1 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java @@ -17,6 +17,7 @@ import com.example.solidconnection.community.post.dto.PostListResponse; import com.example.solidconnection.community.post.fixture.PostFixture; import com.example.solidconnection.community.post.fixture.PostImageFixture; +import com.example.solidconnection.community.post.repository.ViewCountStreamRepository; import com.example.solidconnection.redis.RedisService; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; @@ -28,11 +29,15 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; @TestContainerSpringBootTest @DisplayName("게시글 조회 서비스 테스트") class PostQueryServiceTest { + @Autowired + private StringRedisTemplate redisTemplate; + @Autowired private PostQueryService postQueryService; @@ -227,7 +232,6 @@ void setUp() { List comments = List.of(comment1, comment2); String validateKey = postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId()); - String viewCountKey = postRedisManager.getPostViewCountRedisKey(post.getId()); // when PostFindResponse response = postQueryService.findPostById(user.getId(), post.getId()); @@ -239,7 +243,9 @@ void setUp() { () -> assertThat(response.postFindSiteUserResponse().id()).isEqualTo(user.getId()), () -> assertThat(response.postFindPostImageResponses()).hasSize(imageUrls.size()), () -> assertThat(response.postFindCommentResponses()).hasSize(comments.size()), - () -> assertThat(redisService.isKeyExists(viewCountKey)).isTrue(), + () -> assertThat(redisTemplate.opsForHash().hasKey( + ViewCountStreamRepository.ACTIVE_KEY, + post.getId().toString())).isTrue(), () -> assertThat(redisService.isKeyExists(validateKey)).isTrue() ); } diff --git a/src/test/java/com/example/solidconnection/community/post/service/UpdateViewCountServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/UpdateViewCountServiceTest.java index 651b15abf..5578e611f 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/UpdateViewCountServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/UpdateViewCountServiceTest.java @@ -3,8 +3,11 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import com.example.solidconnection.community.post.domain.ViewCountEvent; import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.ViewCountFlushRepository; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -22,33 +25,36 @@ class UpdateViewCountServiceTest { private PostRepository postRepository; @Mock - private PostRedisManager postRedisManager; + private ViewCountFlushRepository flushRepository; - @Test - void Redis_조회수_키가_이미_소비되었으면_DB_업데이트를_하지_않는다() { - // given - String key = "post:view:1"; - given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L); - given(postRedisManager.getAndDeleteViewCount(key)).willReturn(null); + @Nested + @DisplayName("처리 이력에 따른 조회수 반영") + class 이벤트_반영 { - // when - updateViewCountService.updateViewCount(key); + @Test + void 처리_이력이_있으면_DB_업데이트를_하지_않는다() { + // given + ViewCountEvent event = new ViewCountEvent("1-0", "flush-1", 1L, 2L); + given(flushRepository.recordIfNew("flush-1", 1L, 2L)).willReturn(false); - // then - then(postRepository).shouldHaveNoInteractions(); - } + // when + updateViewCountService.apply(event); + + // then + then(postRepository).shouldHaveNoInteractions(); + } - @Test - void Redis_조회수가_있으면_DB_조회수를_증가시킨다() { - // given - String key = "post:view:1"; - given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L); - given(postRedisManager.getAndDeleteViewCount(key)).willReturn(2L); + @Test + void 신규_처리_이력이면_DB_조회수를_증가시킨다() { + // given + ViewCountEvent event = new ViewCountEvent("1-0", "flush-1", 1L, 2L); + given(flushRepository.recordIfNew("flush-1", 1L, 2L)).willReturn(true); - // when - updateViewCountService.updateViewCount(key); + // when + updateViewCountService.apply(event); - // then - then(postRepository).should().increaseViewCount(1L, 2L); + // then + then(postRepository).should().increaseViewCount(1L, 2L); + } } } diff --git a/src/test/java/com/example/solidconnection/community/post/service/ViewCountRecoveryTest.java b/src/test/java/com/example/solidconnection/community/post/service/ViewCountRecoveryTest.java new file mode 100644 index 000000000..297f38373 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/ViewCountRecoveryTest.java @@ -0,0 +1,389 @@ +package com.example.solidconnection.community.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.fixture.BoardFixture; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.domain.ViewCountEvent; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.ViewCountStreamRepository; +import com.example.solidconnection.scheduler.UpdateViewCountScheduler; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor; +import org.springframework.scheduling.config.ScheduledTask; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@TestContainerSpringBootTest +@DisplayName("조회수 이벤트 복구 테스트") +class ViewCountRecoveryTest { + + @Autowired + private PostFixture postFixture; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private PostRepository postRepository; + + @Autowired + private ViewCountStreamRepository stream; + + @Autowired + private UpdateViewCountService updateService; + + @Autowired + private ViewCountConsumer consumer; + + @Autowired + private StringRedisTemplate redis; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private ScheduledAnnotationBeanPostProcessor scheduledTasks; + + @MockitoBean + private UpdateViewCountScheduler scheduler; + + private List posts; + + @BeforeEach + void setUp() { + Board board = boardFixture.자유게시판(); + SiteUser user = siteUserFixture.사용자(); + posts = new ArrayList<>(); + for (int i = 0; i < 150; i++) { + posts.add(postFixture.게시글("title", "content", false, PostCategory.자유, board, user)); + } + stream.ensureGroup(); + } + + @Nested + @DisplayName("DB 실패 및 완료 확인 실패 복구") + class 장애_복구 { + + @Test + void DB_업데이트_5건이_실패해도_재처리하면_5000회가_모두_반영된다() { + // given: 선택한 게시글 5개의 조회수 UPDATE만 실패시키는 임시 제약조건 + List events = prepareEvents(); + String failingIds = events.subList(0, 5).stream().map(event -> Long.toString(event.postId())) + .collect(Collectors.joining(",")); + jdbc.execute("UPDATE post SET title = 'injected-view-failure' WHERE id IN (" + failingIds + ")"); + jdbc.execute("ALTER TABLE post ADD CONSTRAINT chk_view_count_failure " + + "CHECK (title <> 'injected-view-failure' OR view_count = 0)"); + try { + // when: 5건은 처리 이력까지 롤백되고 PEL에 남아야 한다. + events.forEach(consumer::process); + assertThat(receipts()).isEqualTo(45); + assertThat(streamPending()).isEqualTo(5); + assertThat(jdbc.queryForObject("SELECT SUM(view_count) FROM post", Long.class)).isEqualTo(4500); + } finally { + jdbc.execute("ALTER TABLE post DROP CHECK chk_view_count_failure"); + } + List recovered = stream.claim("after-crash", Duration.ZERO); + assertThat(recovered).hasSize(5); + recovered.forEach(consumer::process); + + // then: 총합, 게시글별 값, DB 이력 및 PEL 검증 + assertCounts(5000); + assertThat(receipts()).isEqualTo(50); + assertThat(streamPending()).isZero(); + assertThat(redis.opsForStream().size(ViewCountStreamRepository.STREAM_KEY)).isZero(); + System.out.println("RECOVERY input=5000 persisted=5000 lost=0 failedEvents=5"); + } + + @Test + void 커밋_후_ACK_전_중단된_50건을_재인계해도_중복_반영하지_않는다() { + // given: 실제 DB 커밋 후 ACK를 생략하여 종료 시점의 상태 재현 + List events = prepareEvents(); + events.forEach(updateService::apply); + assertCounts(5000); + assertThat(streamPending()).isEqualTo(50); + + // when: 다른 consumer가 같은 flushId로 회수하고 반복 처리 + List recovered = stream.claim("replacement", Duration.ZERO); + assertThat(recovered).extracting(ViewCountEvent::flushId) + .containsExactlyInAnyOrderElementsOf(events.stream().map(ViewCountEvent::flushId).toList()); + recovered.forEach(consumer::process); + recovered.forEach(consumer::process); + + // then + assertCounts(5000); + assertThat(receipts()).isEqualTo(50); + assertThat(streamPending()).isZero(); + System.out.println("REPLAY committed=5000 afterReplay=5000 duplicated=0 events=50"); + } + + @Test + void 커밋_후_ACK가_실패해도_재처리에서_중복_반영하지_않는다() { + // given + List events = prepareEvents(); + ViewCountStreamRepository failedAck = mock(ViewCountStreamRepository.class); + doThrow(new IllegalStateException("injected ACK failure")).when(failedAck).acknowledge(events.getFirst()); + ViewCountConsumer failedConsumer = new ViewCountConsumer(failedAck, updateService, Duration.ZERO); + // when: DB 커밋 성공 이후 ACK 호출만 실패 + failedConsumer.process(events.getFirst()); + assertThat(receipts()).isEqualTo(1); + assertThat(streamPending()).isEqualTo(50); + stream.claim("replacement", Duration.ZERO).forEach(consumer::process); + // then + assertCounts(5000); + assertThat(receipts()).isEqualTo(50); + assertThat(streamPending()).isZero(); + } + } + + private List prepareEvents() { + for (int i = 0; i < 50; i++) { + for (int count = 0; count < 100; count++) { + stream.increment(posts.get(i).getId()); + } + } + assertThat(redis.getExpire(ViewCountStreamRepository.ACTIVE_KEY)).isEqualTo(-1); + assertThat(stream.flush()).isEqualTo(50); + assertThat(stream.flush()).isZero(); + List events = stream.readNew("before-crash"); + assertThat(events).hasSize(50); + return events; + } + + private void assertCounts(long total) { + long actualTotal = 0; + for (int i = 0; i < 150; i++) { + long actual = postRepository.getById(posts.get(i).getId()).getViewCount(); + assertThat(actual).as("post %s", i).isEqualTo(i < 50 ? 100 : 0); + actualTotal += actual; + } + assertThat(actualTotal).isEqualTo(total); + } + + @Nested + @DisplayName("동일 이벤트 처리 및 증가와 집계의 동시성") + class 동시성 { + + @Test + void 동일_이벤트를_동시에_처리해도_한_번만_반영된다() throws Exception { + // given + long postId = posts.getFirst().getId(); + ViewCountEvent event = new ViewCountEvent("1-0", "concurrent-flush", postId, 100); + try (var executor = Executors.newFixedThreadPool(8)) { + List> tasks = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + tasks.add(() -> { + updateService.apply(event); + return null; + }); + } + // when: Future.get으로 스레드 예외도 검증 + for (var future : executor.invokeAll(tasks)) { + future.get(); + } + } + // then + assertThat(postRepository.getById(postId).getViewCount()).isEqualTo(100); + assertThat(receipts()).isEqualTo(1); + } + + @Test + void 증가와_flush를_동시에_실행해도_5000회가_보존된다() throws Exception { + // given + long postId = posts.getFirst().getId(); + try (var executor = Executors.newFixedThreadPool(6)) { + List> tasks = new ArrayList<>(); + for (int thread = 0; thread < 5; thread++) { + tasks.add(() -> { + for (int i = 0; i < 1000; i++) { + stream.increment(postId); + } + return null; + }); + } + tasks.add(() -> { + for (int i = 0; i < 100; i++) { + stream.flush(); + } + return null; + }); + // when + for (var future : executor.invokeAll(tasks)) { + future.get(); + } + } + stream.flush(); + List events; + while (!(events = stream.readNew("concurrent-consumer")).isEmpty()) { + events.forEach(consumer::process); + } + // then + assertThat(postRepository.getById(postId).getViewCount()).isEqualTo(5000); + assertThat(streamPending()).isZero(); + assertThat(redis.opsForHash().size(ViewCountStreamRepository.ACTIVE_KEY)).isZero(); + } + } + + @Nested + @DisplayName("이벤트 저장 실패 시 활성 카운터 보존") + class 이벤트_발행 { + + @Test + void XADD가_실패하면_활성_카운터가_삭제되지_않는다() { + // given: Stream 키 타입 오류 + redis.delete(ViewCountStreamRepository.STREAM_KEY); + redis.opsForValue().set(ViewCountStreamRepository.STREAM_KEY, "wrong-type"); + stream.increment(posts.getFirst().getId()); + // when + assertThatThrownBy(stream::flush).isInstanceOf(RuntimeException.class); + // then + assertThat(redis.opsForHash().get(ViewCountStreamRepository.ACTIVE_KEY, + posts.getFirst().getId().toString())).isEqualTo("1"); + } + } + + @Nested + @DisplayName("오류 이벤트 격리와 미확인 이벤트 순회") + class 이벤트_소비와_재인계 { + + @Test + void 잘못된_신규_이벤트가_있어도_같은_배치의_정상_이벤트는_처리한다() { + // given + redis.opsForStream().add(ViewCountStreamRepository.STREAM_KEY, Map.of("delta", "invalid")); + long postId = posts.getFirst().getId(); + stream.increment(postId); + stream.flush(); + // when + consumer.consume(); + // then: 잘못된 이벤트는 유실시키지 않고 pending으로 보존 + assertThat(postRepository.getById(postId).getViewCount()).isEqualTo(1); + assertThat(streamPending()).isEqualTo(1); + } + + @Test + void 잘못된_미확인_이벤트가_있어도_정상_이벤트를_재인계한다() { + // given: 첫 read에서 정상 이벤트도 pending 상태가 된다. + redis.opsForStream().add(ViewCountStreamRepository.STREAM_KEY, Map.of("delta", "invalid")); + long postId = posts.getFirst().getId(); + stream.increment(postId); + stream.flush(); + assertThat(stream.readNew("previous-consumer")).hasSize(1); + // when + stream.claim("replacement", Duration.ZERO).forEach(consumer::process); + // then + assertThat(postRepository.getById(postId).getViewCount()).isEqualTo(1); + assertThat(streamPending()).isEqualTo(1); + } + + @Test + void 미확인_이벤트_150건을_커서로_끝까지_재인계한다() { + // given + posts.forEach(post -> stream.increment(post.getId())); + for (int i = 0; i < 3; i++) { + assertThat(stream.flush()).isEqualTo(50); + assertThat(stream.readNew("previous-consumer")).hasSize(50); + } + assertThat(streamPending()).isEqualTo(150); + // when: 다음 커서가 가리키는 위치부터 이어서 회수 + for (int i = 0; i < 3; i++) { + List recovered = stream.claim("replacement", Duration.ZERO); + assertThat(recovered).hasSize(50); + recovered.forEach(consumer::process); + } + // then + for (Post post : posts) { + assertThat(postRepository.getById(post.getId()).getViewCount()).isEqualTo(1); + } + assertThat(receipts()).isEqualTo(150); + assertThat(streamPending()).isZero(); + } + } + + @Nested + @DisplayName("마이그레이션과 삭제된 게시글 처리") + class DB_반영 { + + @Test + void 마이그레이션으로_생성한_테이블에서도_중복_flushId는_한번만_반영된다() { + // given: Hibernate 생성 테이블을 실제 배포용 SQL로 교체 + jdbc.execute("DROP TABLE view_count_flush"); + new ResourceDatabasePopulator(new ClassPathResource("db/migration/V63__create_view_count_flush.sql")) + .execute(jdbc.getDataSource()); + ViewCountEvent event = new ViewCountEvent("1-0", "migration-flush", posts.getFirst().getId(), 100); + // when + updateService.apply(event); + updateService.apply(event); + // then + assertThat(receipts()).isEqualTo(1); + assertThat(postRepository.getById(event.postId()).getViewCount()).isEqualTo(100); + } + + @Test + void 게시글이_삭제된_이벤트도_완료하고_재처리하지_않는다() { + // given + long postId = posts.getFirst().getId(); + stream.increment(postId); + stream.flush(); + postRepository.deleteById(postId); + // when + consumer.consume(); + // then + assertThat(receipts()).isEqualTo(1); + assertThat(streamPending()).isZero(); + assertThat(postRepository.findById(postId)).isEmpty(); + } + } + + private long receipts() { + return jdbc.queryForObject("SELECT COUNT(*) FROM view_count_flush", Long.class); + } + + private long streamPending() { + return redis.opsForStream().pending(ViewCountStreamRepository.STREAM_KEY, ViewCountStreamRepository.GROUP) + .getTotalPendingMessages(); + } + + @Nested + @DisplayName("조회수 스케줄러 설정의 독립성") + class 스케줄러_설정 { + + @Test + void 조회수_스케줄러를_꺼도_다른_스케줄러는_등록된다() { + // given + String expectedTask = "com.example.solidconnection.scheduler.UserRemovalScheduler.scheduledUserRemoval"; + + // when + Set registeredTasks = scheduledTasks.getScheduledTasks(); + + // then + assertThat(registeredTasks).extracting(Object::toString).contains(expectedTask); + } + } +} diff --git a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java index a3529a0ab..551127c13 100644 --- a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java @@ -8,6 +8,7 @@ import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.ViewCountStreamRepository; import com.example.solidconnection.community.post.service.PostRedisManager; import com.example.solidconnection.redis.RedisService; import com.example.solidconnection.siteuser.domain.SiteUser; @@ -22,8 +23,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; @TestContainerSpringBootTest +@TestPropertySource(properties = "view.count.scheduling.enabled=true") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @DisplayName("게시글 조회수 동시성 테스트") class PostViewCountConcurrencyTest { @@ -39,6 +44,9 @@ class PostViewCountConcurrencyTest { @Autowired private PostRedisManager postRedisManager; + @Autowired + private ViewCountStreamRepository viewCountStreamRepository; + @Autowired private SiteUserFixture siteUserFixture; @@ -92,7 +100,7 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - redisService.increaseViewCount(postRedisManager.getPostViewCountRedisKey(post.getId())); + viewCountStreamRepository.increment(post.getId()); } finally { doneSignal.countDown(); } @@ -124,7 +132,7 @@ private Post createPost(Board board, SiteUser siteUser) { try { boolean isFirstTime = redisService.isPresent(postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId()), VALIDATE_VIEW_COUNT_TTL.getValue()); if (isFirstTime) { - redisService.increaseViewCount(postRedisManager.getPostViewCountRedisKey(post.getId())); + viewCountStreamRepository.increment(post.getId()); } } finally { doneSignal.countDown(); @@ -137,7 +145,7 @@ private Post createPost(Board board, SiteUser siteUser) { try { boolean isFirstTime = redisService.isPresent(postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId()), VALIDATE_VIEW_COUNT_TTL.getValue()); if (isFirstTime) { - redisService.increaseViewCount(postRedisManager.getPostViewCountRedisKey(post.getId())); + viewCountStreamRepository.increment(post.getId()); } } finally { doneSignal.countDown(); diff --git a/src/test/java/com/example/solidconnection/scheduler/UpdateViewCountSchedulerTest.java b/src/test/java/com/example/solidconnection/scheduler/UpdateViewCountSchedulerTest.java new file mode 100644 index 000000000..c403d0948 --- /dev/null +++ b/src/test/java/com/example/solidconnection/scheduler/UpdateViewCountSchedulerTest.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.scheduler; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; + +import com.example.solidconnection.community.post.service.ViewCountConsumer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("조회수 집계 스케줄러 테스트") +class UpdateViewCountSchedulerTest { + + @Mock + private ViewCountConsumer consumer; + + @InjectMocks + private UpdateViewCountScheduler scheduler; + + @Nested + @DisplayName("조회수 집계의 실행 순서와 장애 복구") + class 조회수_집계 { + + @Test + void 활성_카운터를_이벤트로_옮긴_뒤_같은_주기에_소비한다() { + // given + InOrder order = inOrder(consumer); + + // when + scheduler.updateViewCount(); + // then + order.verify(consumer).flushActiveCounters(); + order.verify(consumer).consume(); + } + + @Test + void flush가_실패해도_기존_이벤트의_소비와_복구를_시도한다() { + // given + doThrow(new IllegalStateException("injected flush failure")).when(consumer).flushActiveCounters(); + // when + assertThatThrownBy(scheduler::updateViewCount).isInstanceOf(IllegalStateException.class); + // then + verify(consumer).consume(); + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 55441f122..b5e44befc 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -47,6 +47,7 @@ cloud: view: count: scheduling: + enabled: false delay: 3000 websocket: thread-pool: