diff --git a/build.gradle b/build.gradle index 547b07f..20e5f9b 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,10 @@ dependencies { annotationProcessor "jakarta.persistence:jakarta.persistence-api" annotationProcessor "jakarta.annotation:jakarta.annotation-api" + // Jackson JSR310 (Java 8 Date and Time API) 지원 + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" + testImplementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation "org.springframework.boot:spring-boot-testcontainers" diff --git a/src/main/java/redot/redot_server/domain/eventlog/controller/EventLogController.java b/src/main/java/redot/redot_server/domain/eventlog/controller/EventLogController.java new file mode 100644 index 0000000..43dfea9 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/controller/EventLogController.java @@ -0,0 +1,63 @@ +package redot.redot_server.domain.eventlog.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import redot.redot_server.domain.eventlog.controller.docs.EventLogControllerDocs; +import redot.redot_server.domain.eventlog.dto.PageViewCommand; +import redot.redot_server.domain.eventlog.dto.PageViewRequest; +import redot.redot_server.domain.eventlog.service.EventLogIngestService; +import redot.redot_server.global.redotapp.resolver.annotation.CurrentRedotApp; +import redot.redot_server.global.security.principal.JwtPrincipal; + +import java.time.Instant; +import java.util.UUID; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v1/event-logs") +public class EventLogController implements EventLogControllerDocs { + + private final EventLogIngestService ingestService; + + /* + 페이지 뷰 이벤트 수집 엔드포인트 + */ + @PostMapping("/page-view") + public ResponseEntity pageView( + @CurrentRedotApp Long redotAppId, + @Valid @RequestBody PageViewRequest req, + HttpServletRequest servletRequest, + @AuthenticationPrincipal JwtPrincipal jwtPrincipal // 없을 수도 있음(비회원) + ) { + String ip = extractClientIp(servletRequest); + + PageViewCommand cmd = new PageViewCommand( + UUID.randomUUID(), + redotAppId, + req.deviceType(), + ip, + Instant.now() + ); + + ingestService.ingestPageView(cmd); + return ResponseEntity.accepted().build(); + } + + /* + 클라이언트 IP 추출 + */ + private String extractClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isBlank()) return xff.split(",")[0].trim(); + String xri = request.getHeader("X-Real-IP"); + if (xri != null && !xri.isBlank()) return xri.trim(); + return request.getRemoteAddr(); + } +} \ No newline at end of file diff --git a/src/main/java/redot/redot_server/domain/eventlog/controller/docs/EventLogControllerDocs.java b/src/main/java/redot/redot_server/domain/eventlog/controller/docs/EventLogControllerDocs.java new file mode 100644 index 0000000..b60974a --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/controller/docs/EventLogControllerDocs.java @@ -0,0 +1,26 @@ +package redot.redot_server.domain.eventlog.controller.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import redot.redot_server.domain.eventlog.dto.PageViewRequest; +import redot.redot_server.global.security.principal.JwtPrincipal; + +@Tag(name = "EventLogs", description = "이벤트 로그 관리 API") +public interface EventLogControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 Redot 앱의 서브도메인") + @Operation(summary = "페이지 뷰 이벤트 수집", + description = "클라이언트로부터 페이지 뷰 이벤트를 수집합니다.") + @ApiResponse(responseCode = "202", description = "이벤트 수집 요청 접수 성공") + ResponseEntity pageView( + @Parameter(hidden = true) Long redotAppId, + @Parameter(hidden = true) PageViewRequest req, + @Parameter(hidden = true) HttpServletRequest servletRequest, + @Parameter(hidden = true) JwtPrincipal jwtPrincipal + ); +} diff --git a/src/main/java/redot/redot_server/domain/eventlog/dto/PageViewCommand.java b/src/main/java/redot/redot_server/domain/eventlog/dto/PageViewCommand.java new file mode 100644 index 0000000..a12af42 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/dto/PageViewCommand.java @@ -0,0 +1,17 @@ +package redot.redot_server.domain.eventlog.dto; + +import redot.redot_server.domain.eventlog.entity.DeviceType; + +import java.time.Instant; +import java.util.UUID; + +public record PageViewCommand( + UUID eventId, + Long redotAppId, +// IdentityType actorType, +// Long actorId, // nullable +// String anonymousId, // nullable + DeviceType deviceType, + String ip, + Instant occurredAt +) {} diff --git a/src/main/java/redot/redot_server/domain/eventlog/dto/PageViewRequest.java b/src/main/java/redot/redot_server/domain/eventlog/dto/PageViewRequest.java new file mode 100644 index 0000000..984c943 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/dto/PageViewRequest.java @@ -0,0 +1,9 @@ +package redot.redot_server.domain.eventlog.dto; + +import jakarta.validation.constraints.NotNull; +import redot.redot_server.domain.eventlog.entity.DeviceType; + +public record PageViewRequest( + @NotNull DeviceType deviceType + //String anonymousId // 비회원이면 프론트에서 UUID 만들어서 넣어주면 좋음 +) {} diff --git a/src/main/java/redot/redot_server/domain/eventlog/entity/DeviceType.java b/src/main/java/redot/redot_server/domain/eventlog/entity/DeviceType.java new file mode 100644 index 0000000..2180849 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/entity/DeviceType.java @@ -0,0 +1,7 @@ +package redot.redot_server.domain.eventlog.entity; + +public enum DeviceType { + MOBILE, + DESKTOP, + TABLET +} diff --git a/src/main/java/redot/redot_server/domain/eventlog/entity/EventLogEntity.java b/src/main/java/redot/redot_server/domain/eventlog/entity/EventLogEntity.java new file mode 100644 index 0000000..c44759d --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/entity/EventLogEntity.java @@ -0,0 +1,71 @@ +package redot.redot_server.domain.eventlog.entity; + +import jakarta.persistence.*; +import redot.redot_server.global.common.entity.BaseTimeEntity; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "event_logs", + indexes = { + @Index(name = "idx_event_logs_app_time", columnList = "redot_app_id, occurred_at"), + @Index(name = "idx_event_logs_type_time", columnList = "type, occurred_at") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_event_logs_event_id", columnNames = "event_id") + } +) +public class EventLogEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // PK + + // UUID를 varchar로 저장 (중복 방지 위함) + @Column(name = "event_id", nullable = false, updatable = false, length = 36) + private String eventId; // 이벤트 고유 ID + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private EventType type; // 이벤트 유형 + + @Column(name = "redot_app_id", nullable = false) + private Long redotAppId; // 리닷 앱 ID + +// @Enumerated(EnumType.STRING) +// @Column(name = "actor_type", nullable = false, length = 32) +// private IdentityType actorType; +// +// @Column(name = "actor_id") +// private Long actorId; +// +// @Column(name = "anonymous_id", length = 64) +// private String anonymousId; + + @Enumerated(EnumType.STRING) + @Column(name = "device_type", nullable = false, length = 16) + private DeviceType deviceType; // 디바이스 유형 + + @Column(nullable = false, length = 64) + private String ip; // IP 주소 + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; // 이벤트 발생 시각 + + protected EventLogEntity() {} + + public static EventLogEntity pageView(UUID eventId, Long redotAppId, DeviceType deviceType, String ip, Instant occurredAt) { + EventLogEntity e = new EventLogEntity(); + e.eventId = eventId.toString(); + e.type = EventType.PAGE_VIEW; + e.redotAppId = redotAppId; +// e.actorType = actorType; +// e.actorId = actorId; +// e.anonymousId = anonymousId; + e.deviceType = deviceType; + e.ip = ip; + e.occurredAt = occurredAt; + return e; + } +} diff --git a/src/main/java/redot/redot_server/domain/eventlog/entity/EventType.java b/src/main/java/redot/redot_server/domain/eventlog/entity/EventType.java new file mode 100644 index 0000000..aff89aa --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/entity/EventType.java @@ -0,0 +1,6 @@ +package redot.redot_server.domain.eventlog.entity; + +public enum EventType { + PAGE_VIEW, CTA_CLICK, PURCHASE + // 조회, 클릭, 구매 +} \ No newline at end of file diff --git a/src/main/java/redot/redot_server/domain/eventlog/entity/IdentityType.java b/src/main/java/redot/redot_server/domain/eventlog/entity/IdentityType.java new file mode 100644 index 0000000..bf0cf2b --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/entity/IdentityType.java @@ -0,0 +1,5 @@ +package redot.redot_server.domain.eventlog.entity; + +public enum IdentityType { + MEMBER, GUEST +} diff --git a/src/main/java/redot/redot_server/domain/eventlog/repository/EventLogRepository.java b/src/main/java/redot/redot_server/domain/eventlog/repository/EventLogRepository.java new file mode 100644 index 0000000..9099b2e --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/repository/EventLogRepository.java @@ -0,0 +1,9 @@ +package redot.redot_server.domain.eventlog.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import redot.redot_server.domain.eventlog.entity.EventLogEntity; + +@Repository +public interface EventLogRepository extends JpaRepository { +} diff --git a/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java new file mode 100644 index 0000000..1b10ea6 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java @@ -0,0 +1,135 @@ +package redot.redot_server.domain.eventlog.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import redot.redot_server.domain.eventlog.dto.PageViewCommand; +import redot.redot_server.domain.eventlog.entity.EventLogEntity; +import redot.redot_server.domain.eventlog.repository.EventLogRepository; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class EventLogFlushScheduler { + + private final StringRedisTemplate stringRedisTemplate; + private final EventLogStore eventLogStore; + private final ObjectMapper objectMapper; + private final EventLogRepository repo; + + // 한번에 최대 몇 개 flush할지 + private static final int BATCH_SIZE = 2000; + + /* + 등록된 모든 redotAppId에 대해 Redis 버퍼에서 이벤트를 읽어와 DB에 저장 + */ + @Scheduled(cron = "0 */10 * * * *") + public void flushAllApps() { + List apps = eventLogStore.getRegisteredApps(); + for (Long appId : apps) { + flushApp(appId); + } + } + + /* + 한 앱에 대해 Redis 버퍼에서 이벤트를 읽어와 DB에 저장 + */ + + public void flushApp(Long redotAppId) { + String key = eventLogStore.bufferKey(redotAppId); + + List items = readBatch(key); + if (items.isEmpty()) { + eventLogStore.unregisterApp(redotAppId); + return; + } + + List entities = parseToEntities(redotAppId, items); + saveWithDlqFallback(redotAppId, entities); + + trimProcessed(key, items.size()); + } + + /* + Redis에서 한 번에 BATCH_SIZE만큼 항목 읽기 + */ + private List readBatch(String key) { + List items = stringRedisTemplate.opsForList().range(key, 0, BATCH_SIZE - 1); + return (items == null) ? List.of() : items; + } + + /* + JSON 문자열 목록을 EventLogEntity 목록으로 변환 + */ + private List parseToEntities(Long redotAppId, List items) { + List entities = new ArrayList<>(items.size()); + for (String json : items) { + try { + PageViewCommand cmd = objectMapper.readValue(json, PageViewCommand.class); + entities.add(EventLogEntity.pageView( + cmd.eventId(), + cmd.redotAppId(), + cmd.deviceType(), + cmd.ip(), + cmd.occurredAt() + )); + } catch (Exception e) { + eventLogStore.pushDeadLetter(redotAppId, json, "PARSE_FAIL: " + e.getMessage()); + } + } + return entities; + } + + /* + 여러 엔티티를 개별 저장하며 실패 시 DLQ로 이동 + */ + private void saveWithDlqFallback(Long redotAppId, List entities) { + if (entities.isEmpty()) return; + + try { + repo.saveAll(entities); + } catch (Exception e) { + saveIndividuallyWithDlq(redotAppId, entities); + } + } + + /* + 여러 엔티티를 개별 저장하며 실패 시 DLQ로 이동 + */ + private void saveIndividuallyWithDlq(Long redotAppId, List entities) { + for (EventLogEntity entity : entities) { + try { + repo.save(entity); + } catch (Exception ex) { + eventLogStore.pushDeadLetter( + redotAppId, + safeToJson(entity), + "DB_SAVE_FAIL: " + ex.getMessage() + ); + } + } + } + + /* + Redis 리스트에서 앞의 processedCount개 항목 제거 + */ + private void trimProcessed(String key, int processedCount) { + stringRedisTemplate.opsForList().trim(key, processedCount, -1); + } + + /* + EventLogEntity를 안전하게 JSON 문자열로 변환 + */ + private String safeToJson(EventLogEntity entity) { + try { + return objectMapper.writeValueAsString(entity); + } catch (Exception ignored) { + return String.valueOf(entity); + } + } + +} \ No newline at end of file diff --git a/src/main/java/redot/redot_server/domain/eventlog/service/EventLogIngestService.java b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogIngestService.java new file mode 100644 index 0000000..e73dffb --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogIngestService.java @@ -0,0 +1,29 @@ +package redot.redot_server.domain.eventlog.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import redot.redot_server.domain.eventlog.dto.PageViewCommand; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EventLogIngestService { + + private final EventLogStore eventLogStore; + private final ObjectMapper objectMapper; + + /* + 페이지 뷰 이벤트 수집 + */ + public void ingestPageView(PageViewCommand cmd) { + try { + String json = objectMapper.writeValueAsString(cmd); + eventLogStore.pushPageView(cmd.redotAppId(), json); + } catch (JsonProcessingException e) { + log.error("Failed to serialize page view event. cmd={}", cmd, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/redot/redot_server/domain/eventlog/service/EventLogStore.java b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogStore.java new file mode 100644 index 0000000..a5ae4d4 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogStore.java @@ -0,0 +1,107 @@ +package redot.redot_server.domain.eventlog.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class EventLogStore { + + // 10분 버퍼 (스케줄러가 10분마다 비우는 구조) + private static final Duration BUFFER_TTL = Duration.ofMinutes(20); + + // DLQ 키: event-log:dlq:{appId} + private static final String DLQ_KEY = "event-log:dlq:%s"; + + // app 단위 버퍼 키: event-log:buffer:{appId} + private static final String BUFFER_KEY = "event-log:buffer:%s"; + + // flush 대상 앱 목록을 보관하는 set + private static final String APPS_KEY = "event-log:apps"; + + private final StringRedisTemplate stringRedisTemplate; + + /* + 페이지 뷰 이벤트를 Redis 버퍼에 저장 + */ + public void pushPageView(Long redotAppId, String payloadJson) { + registerApp(redotAppId); + + String key = bufferKey(redotAppId); + stringRedisTemplate.opsForList().rightPush(key, payloadJson); + stringRedisTemplate.expire(key, BUFFER_TTL); + } + + /* + DLQ에 실패한 이벤트 저장 + */ + public void pushDeadLetter(Long redotAppId, String rawJson, String reason) { + String key = DLQ_KEY.formatted(redotAppId); + String payload = "{\"reason\":\"" + escape(reason) + "\",\"raw\":" + quote(rawJson) + "}"; + stringRedisTemplate.opsForList().rightPush(key, payload); + // DLQ는 길게 보관(예: 7일) 혹은 영구 보관 정책 + stringRedisTemplate.expire(key, Duration.ofDays(7)); + } + + + /* + 등록된 모든 앱 ID 조회 + */ + public List getRegisteredApps() { + Set members = stringRedisTemplate.opsForSet().members(APPS_KEY); + if (members == null || members.isEmpty()) return List.of(); + return members.stream().map(Long::valueOf).toList(); + } + + /* + 앱 ID 등록/해제 + */ + public void registerApp(Long redotAppId) { + stringRedisTemplate.opsForSet().add(APPS_KEY, String.valueOf(redotAppId)); + } + + /* + 앱 ID 등록 해제 + */ + public void unregisterApp(Long redotAppId) { + stringRedisTemplate.opsForSet().remove(APPS_KEY, String.valueOf(redotAppId)); + // 버퍼도 같이 정리하고 싶으면: + // stringRedisTemplate.delete(bufferKey(redotAppId)); + } + + /* + 특정 앱의 버퍼에 쌓여있는 이벤트 수 조회 + */ + public long size(Long redotAppId) { + Long size = stringRedisTemplate.opsForList().size(bufferKey(redotAppId)); + return size == null ? 0 : size; + } + + /* + 특정 앱의 버퍼 키 생성 + */ + public String bufferKey(Long redotAppId) { + return BUFFER_KEY.formatted(redotAppId); + } + + + /* + 문자열 내 따옴표 이스케이프 처리 + */ + private String escape(String s) { + return s == null ? "" : s.replace("\"", "\\\""); + } + + /* + 문자열을 JSON 문자열로 감싸기 + */ + private String quote(String s) { + if (s == null) return "null"; + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 548f512..543df73 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,3 @@ spring: profiles: - default: dev - + default: dev \ No newline at end of file diff --git a/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java new file mode 100644 index 0000000..a0f3ec6 --- /dev/null +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java @@ -0,0 +1,271 @@ +package redot.redot_server.domain.eventlog.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import redot.redot_server.domain.eventlog.dto.PageViewCommand; +import redot.redot_server.domain.eventlog.entity.DeviceType; +import redot.redot_server.domain.eventlog.entity.EventLogEntity; +import redot.redot_server.domain.eventlog.repository.EventLogRepository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class EventLogFlushSchedulerTest { + + private StringRedisTemplate template; + private EventLogStore store; + private ObjectMapper om; + private EventLogRepository repo; + + @SuppressWarnings("unchecked") + private ListOperations listOps; + + private EventLogFlushScheduler scheduler; + + /* + 테스트마다 목 객체 초기화 및 스케줄러 인스턴스 생성 + */ + @BeforeEach + void setUp() { + template = mock(StringRedisTemplate.class); + store = mock(EventLogStore.class); + om = new ObjectMapper().registerModule(new JavaTimeModule()); + + repo = mock(EventLogRepository.class); + + listOps = mock(ListOperations.class); + + when(template.opsForList()).thenReturn(listOps); + + scheduler = new EventLogFlushScheduler(template, store, om, repo); + } + + /* + flushApp가 Redis에서 읽어와 DB에 저장하고 버퍼를 트림하는지 검증 + */ + @Test + void flushApp_reads_from_redis_saves_to_db_and_trims_buffer() throws Exception { + // given + Long appId = 10L; + String key = "event-log:buffer:10"; + when(store.bufferKey(appId)).thenReturn(key); + + PageViewCommand c1 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.MOBILE, "127.0.0.1", + Instant.parse("2026-01-14T12:00:00Z")); + PageViewCommand c2 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.DESKTOP, "127.0.0.2", + Instant.parse("2026-01-14T12:01:00Z")); + + String j1 = om.writeValueAsString(c1); + String j2 = om.writeValueAsString(c2); + + when(listOps.range(key, 0, 2000 - 1)).thenReturn(List.of(j1, j2)); + + // when + scheduler.flushApp(appId); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + (ArgumentCaptor) ArgumentCaptor.forClass(List.class); + + verify(repo, times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + assertThat(saved).hasSize(2); + + // saveAll 성공이면 개별 save는 없어야 함 + verify(repo, never()).save(any(EventLogEntity.class)); + + verify(listOps, times(1)).trim(key, 2, -1); + } + + /* + flushApp에서 saveAll이 실패하면 save 각각 호출하는 폴백 로직 검증 + */ + @Test + void flushApp_when_saveAll_fails_fallbacks_to_save_each_and_still_trims() throws Exception { + // given + Long appId = 10L; + String key = "event-log:buffer:10"; + when(store.bufferKey(appId)).thenReturn(key); + + PageViewCommand c1 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.MOBILE, "127.0.0.1", + Instant.parse("2026-01-14T12:00:00Z")); + PageViewCommand c2 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.DESKTOP, "127.0.0.2", + Instant.parse("2026-01-14T12:01:00Z")); + + String j1 = om.writeValueAsString(c1); + String j2 = om.writeValueAsString(c2); + + when(listOps.range(key, 0, 2000 - 1)).thenReturn(List.of(j1, j2)); + + doThrow(new DataIntegrityViolationException("dup")) + .when(repo).saveAll(anyList()); + + // when + scheduler.flushApp(appId); + + // then + verify(repo, times(1)).saveAll(anyList()); + verify(repo, times(2)).save(any(EventLogEntity.class)); + + verify(listOps, times(1)).trim(key, 2, -1); + } + + /* + flushApp에서 Redis 버퍼에 항목이 없으면 앱 등록 해제만 수행하는지 검증 + */ + @Test + void flushApp_when_no_items_unregisters_app_and_does_nothing_else() { + Long appId = 10L; + String key = "event-log:buffer:10"; + when(store.bufferKey(appId)).thenReturn(key); + when(listOps.range(key, 0, 2000 - 1)).thenReturn(List.of()); + + scheduler.flushApp(appId); + + verify(store, times(1)).unregisterApp(appId); + verify(repo, never()).saveAll(anyList()); + verify(repo, never()).save(any(EventLogEntity.class)); + verify(listOps, never()).trim(anyString(), anyLong(), anyLong()); + } + + /* + flushApp에서 saveAll이 실패하고 일부 개별 save도 실패하는 경우에도 트림은 수행되는지 검증 + */ + @Test + void flushApp_when_saveAll_fails_and_some_save_fails_still_continues_and_trims() throws Exception { + // given + Long appId = 10L; + String key = "event-log:buffer:10"; + when(store.bufferKey(appId)).thenReturn(key); + + PageViewCommand c1 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.MOBILE, "127.0.0.1", + Instant.parse("2026-01-14T12:00:00Z")); + PageViewCommand c2 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.DESKTOP, "127.0.0.2", + Instant.parse("2026-01-14T12:01:00Z")); + + when(listOps.range(key, 0, 2000 - 1)) + .thenReturn(List.of(om.writeValueAsString(c1), om.writeValueAsString(c2))); + + doThrow(new DataIntegrityViolationException("dup")) + .when(repo).saveAll(anyList()); + + // 첫 번째 개별 save만 실패, 두 번째는 성공(그냥 인자를 그대로 반환) + when(repo.save(any(EventLogEntity.class))) + .thenThrow(new DataIntegrityViolationException("dup-one")) + .thenAnswer(invocation -> invocation.getArgument(0)); + + + // when + scheduler.flushApp(appId); + + // then + verify(repo, times(1)).saveAll(anyList()); + verify(repo, times(2)).save(any(EventLogEntity.class)); // 실패해도 2번 시도해야 함 + verify(listOps, times(1)).trim(key, 2, -1); + verify(store, times(1)).pushDeadLetter(eq(appId), anyString(), startsWith("DB_SAVE_FAIL:")); + } + + /* + flushApp에서 saveAll이 실패하고 모든 개별 save도 실패하는 경우 DLQ로 이동하는지 검증 + */ + @Test + void flushApp_when_saveAll_fails_and_individual_save_fails_should_push_to_dlq() throws Exception { + // given + Long appId = 10L; + String key = "event-log:buffer:10"; + when(store.bufferKey(appId)).thenReturn(key); + + PageViewCommand c1 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.MOBILE, "127.0.0.1", + Instant.parse("2026-01-14T12:00:00Z")); + PageViewCommand c2 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.DESKTOP, "127.0.0.2", + Instant.parse("2026-01-14T12:01:00Z")); + + when(listOps.range(key, 0, 2000 - 1)) + .thenReturn(List.of(om.writeValueAsString(c1), om.writeValueAsString(c2))); + + doThrow(new DataIntegrityViolationException("dup")) + .when(repo).saveAll(anyList()); + + // 개별 save 둘 다 실패시키기 (DLQ가 2번 호출되어야 함) + when(repo.save(any(EventLogEntity.class))) + .thenThrow(new DataIntegrityViolationException("dup-one")) + .thenThrow(new DataIntegrityViolationException("dup-two")); + + // when + scheduler.flushApp(appId); + + // then + verify(repo, times(1)).saveAll(anyList()); + verify(repo, times(2)).save(any(EventLogEntity.class)); + + // ✅ DLQ로 2건 이동 + verify(store, times(2)).pushDeadLetter(eq(appId), anyString(), startsWith("DB_SAVE_FAIL:")); + + // trim은 수행 + verify(listOps, times(1)).trim(key, 2, -1); + } + + @Test + void flushApp_when_saveAll_fails_but_individual_saves_succeed_should_not_push_to_dlq() throws Exception { + // given + Long appId = 10L; + String key = "event-log:buffer:10"; + when(store.bufferKey(appId)).thenReturn(key); + + PageViewCommand c1 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.MOBILE, "127.0.0.1", + Instant.parse("2026-01-14T12:00:00Z")); + PageViewCommand c2 = new PageViewCommand(UUID.randomUUID(), appId, DeviceType.DESKTOP, "127.0.0.2", + Instant.parse("2026-01-14T12:01:00Z")); + + when(listOps.range(key, 0, 2000 - 1)) + .thenReturn(List.of(om.writeValueAsString(c1), om.writeValueAsString(c2))); + + doThrow(new DataIntegrityViolationException("dup")) + .when(repo).saveAll(anyList()); + + // 개별 save는 성공(엔티티 반환) + when(repo.save(any(EventLogEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + scheduler.flushApp(appId); + + // then + verify(repo, times(1)).saveAll(anyList()); + verify(repo, times(2)).save(any(EventLogEntity.class)); + verify(store, never()).pushDeadLetter(anyLong(), anyString(), anyString()); + verify(listOps, times(1)).trim(key, 2, -1); + } + + /* + flushApp에서 파싱 실패 시 DLQ로 이동하는지 검증 + */ + @Test + void flushApp_when_parse_fails_should_push_to_dlq() throws Exception { + Long appId = 10L; + String key = "event-log:buffer:10"; + when(store.bufferKey(appId)).thenReturn(key); + + String invalid = "{not-json"; + when(listOps.range(key, 0, 2000 - 1)).thenReturn(List.of(invalid)); + + scheduler.flushApp(appId); + + verify(store, times(1)).pushDeadLetter(eq(appId), eq(invalid), startsWith("PARSE_FAIL:")); + verify(listOps, times(1)).trim(key, 1, -1); + } + + +} \ No newline at end of file diff --git a/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java new file mode 100644 index 0000000..c82f485 --- /dev/null +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java @@ -0,0 +1,47 @@ +package redot.redot_server.domain.eventlog.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import redot.redot_server.domain.eventlog.dto.PageViewCommand; +import redot.redot_server.domain.eventlog.entity.DeviceType; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class EventLogIngestServiceTest { + + /* + 페이지 뷰 이벤트를 직렬화하여 스토어에 푸시하는지 검증 + */ + @Test + void ingestPageView_serializes_and_pushes_to_store() { + EventLogStore store = mock(EventLogStore.class); + + ObjectMapper om = new ObjectMapper().registerModule(new JavaTimeModule()); + + EventLogIngestService service = new EventLogIngestService(store, om); + + PageViewCommand cmd = new PageViewCommand( + UUID.randomUUID(), + 10L, + DeviceType.MOBILE, + "127.0.0.1", + Instant.parse("2026-01-14T12:00:00Z") + ); + + service.ingestPageView(cmd); + + ArgumentCaptor jsonCaptor = ArgumentCaptor.forClass(String.class); + verify(store, times(1)).pushPageView(eq(10L), jsonCaptor.capture()); + + String json = jsonCaptor.getValue(); + assertThat(json).contains("\"redotAppId\":10"); + assertThat(json).contains("\"deviceType\":\"MOBILE\""); + assertThat(json).contains("\"ip\":\"127.0.0.1\""); + } +} \ No newline at end of file diff --git a/src/test/java/redot/redot_server/domain/eventlog/service/EventLogStoreTest.java b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogStoreTest.java new file mode 100644 index 0000000..5e722c7 --- /dev/null +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogStoreTest.java @@ -0,0 +1,97 @@ +package redot.redot_server.domain.eventlog.service; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.Duration; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class EventLogStoreTest { + + /* + pushPageView가 앱 등록, 페이로드 푸시, TTL 설정을 제대로 하는지 검증 + */ + @Test + void pushPageView_registers_app_and_pushes_payload_and_sets_ttl() { + // given + StringRedisTemplate template = mock(StringRedisTemplate.class); + + @SuppressWarnings("unchecked") + SetOperations setOps = mock(SetOperations.class); + @SuppressWarnings("unchecked") + ListOperations listOps = mock(ListOperations.class); + + when(template.opsForSet()).thenReturn(setOps); + when(template.opsForList()).thenReturn(listOps); + + EventLogStore store = new EventLogStore(template); + + Long appId = 10L; + String payload = "{\"hello\":\"world\"}"; + + // when + store.pushPageView(appId, payload); + + // then + verify(setOps, times(1)).add("event-log:apps", "10"); + verify(listOps, times(1)).rightPush("event-log:buffer:10", payload); + + // TTL은 코드에서 Duration.ofMinutes(20) + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Duration.class); + verify(template, times(1)).expire(eq("event-log:buffer:10"), ttlCaptor.capture()); + assertThat(ttlCaptor.getValue()).isEqualTo(Duration.ofMinutes(20)); + } + + /* + getRegisteredApps가 Redis에서 앱 ID 목록을 제대로 조회하는지 검증 + */ + @Test + void getRegisteredApps_returns_long_list() { + // given + StringRedisTemplate template = mock(StringRedisTemplate.class); + + @SuppressWarnings("unchecked") + SetOperations setOps = mock(SetOperations.class); + + when(template.opsForSet()).thenReturn(setOps); + when(setOps.members("event-log:apps")).thenReturn(Set.of("10", "20")); + + EventLogStore store = new EventLogStore(template); + + // when + List apps = store.getRegisteredApps(); + + // then + assertThat(apps).containsExactlyInAnyOrder(10L, 20L); + } + + /* + size가 Redis에서 조회한 값을 제대로 반환하는지 검증 + */ + @Test + void size_returns_0_when_null() { + // given + StringRedisTemplate template = mock(StringRedisTemplate.class); + + @SuppressWarnings("unchecked") + ListOperations listOps = mock(ListOperations.class); + + when(template.opsForList()).thenReturn(listOps); + when(listOps.size("event-log:buffer:10")).thenReturn(null); + + EventLogStore store = new EventLogStore(template); + + // when + long size = store.size(10L); + + // then + assertThat(size).isEqualTo(0); + } +}