From b935ec8f9aa84593500c321ca55a0b8fe48f3120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=EB=AF=BC?= Date: Thu, 15 Jan 2026 21:13:46 +0900 Subject: [PATCH 1/5] feat(eventlog): implement Redis-buffered event logging with tests --- .gitignore | 44 ------ build.gradle | 6 + .../controller/EventLogController.java | 63 ++++++++ .../docs/EventLogControllerDocs.java | 26 ++++ .../domain/eventlog/dto/PageViewCommand.java | 17 +++ .../domain/eventlog/dto/PageViewRequest.java | 9 ++ .../domain/eventlog/entity/DeviceType.java | 7 + .../eventlog/entity/EventLogEntity.java | 71 +++++++++ .../domain/eventlog/entity/EventType.java | 6 + .../domain/eventlog/entity/IdentityType.java | 5 + .../repository/EventLogRepository.java | 9 ++ .../service/EventLogFlushScheduler.java | 77 ++++++++++ .../service/EventLogIngestService.java | 29 ++++ .../eventlog/service/EventLogStore.java | 76 +++++++++ src/main/resources/application.yml | 4 - .../service/EventLogFlushSchedulerTest.java | 144 ++++++++++++++++++ .../service/EventLogIngestServiceTest.java | 50 ++++++ .../eventlog/service/EventLogStoreTest.java | 97 ++++++++++++ 18 files changed, 692 insertions(+), 48 deletions(-) delete mode 100644 .gitignore create mode 100644 src/main/java/redot/redot_server/domain/eventlog/controller/EventLogController.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/controller/docs/EventLogControllerDocs.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/dto/PageViewCommand.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/dto/PageViewRequest.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/entity/DeviceType.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/entity/EventLogEntity.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/entity/EventType.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/entity/IdentityType.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/repository/EventLogRepository.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/service/EventLogIngestService.java create mode 100644 src/main/java/redot/redot_server/domain/eventlog/service/EventLogStore.java delete mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java create mode 100644 src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java create mode 100644 src/test/java/redot/redot_server/domain/eventlog/service/EventLogStoreTest.java diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 4d06849..0000000 --- a/.gitignore +++ /dev/null @@ -1,44 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ -src/main/generated/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ -.DS_Store - -application-*.yml - -devtools/ -devtools/** diff --git a/build.gradle b/build.gradle index 547b07f..122f4b4 100644 --- a/build.gradle +++ b/build.gradle @@ -67,11 +67,17 @@ 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" testImplementation "org.testcontainers:junit-jupiter:1.20.1" testImplementation "org.testcontainers:postgresql:1.20.1" + testImplementation "org.postgresql:postgresql:42.7.3" + testImplementation "org.flywaydb:flyway-core" } tasks.named('test') { 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..4726cf5 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java @@ -0,0 +1,77 @@ +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 org.springframework.transaction.annotation.Transactional; +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 = stringRedisTemplate.opsForList().range(key, 0, BATCH_SIZE - 1); + if (items == null || items.isEmpty()) return; + + 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.actorType(), +// cmd.actorId(), +// cmd.anonymousId(), + cmd.deviceType(), + cmd.ip(), + cmd.occurredAt() + )); + } catch (Exception ignored) { } + } + + // 중복(event_id unique)으로 saveAll 실패 가능 → 폴백 + try { + repo.saveAll(entities); + } catch (Exception e) { + for (EventLogEntity entity : entities) { + try { repo.save(entity); } catch (Exception ignored) {} + } + } + + // DB 저장 성공(중복은 무시) 후 trim + stringRedisTemplate.opsForList().trim(key, items.size(), -1); + } +} \ 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..ec64a49 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogStore.java @@ -0,0 +1,76 @@ +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); + + // 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); + } + + /* + 등록된 모든 앱 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); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 548f512..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,4 +0,0 @@ -spring: - profiles: - default: dev - 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..fa5de5c --- /dev/null +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java @@ -0,0 +1,144 @@ +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 com.fasterxml.jackson.databind.DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE; +import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; +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()) + .disable(WRITE_DATES_AS_TIMESTAMPS) + .disable(ADJUST_DATES_TO_CONTEXT_TIME_ZONE); + + 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 + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(repo, times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + assertThat(saved).hasSize(2); + + // trim: 앞에서 읽은 개수만큼 제거 + 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_does_nothing() { + // given + 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()); + + // when + scheduler.flushApp(appId); + + // then + verifyNoInteractions(repo); + verify(listOps, never()).trim(anyString(), anyLong(), anyLong()); + } +} \ 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..d0ee620 --- /dev/null +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java @@ -0,0 +1,50 @@ +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 com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; +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()) + .disable(WRITE_DATES_AS_TIMESTAMPS); // ISO-8601로 찍히게(선택) + + EventLogIngestService service = new EventLogIngestService(store, om); + + PageViewCommand cmd = new PageViewCommand( + UUID.randomUUID(), + 10L, + DeviceType.MOBILE, + "127.0.0.1", + Instant.now() + ); + + 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); + } +} From c4d72284285d7e417aa56e8467634bbb9b5919fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=EB=AF=BC?= Date: Thu, 15 Jan 2026 21:36:26 +0900 Subject: [PATCH 2/5] fix(config): restore .gitignore and application.yml template --- .gitignore | 44 ++++++++++++++++++++++++++++++ src/main/resources/application.yml | 4 +++ 2 files changed, 48 insertions(+) create mode 100644 .gitignore create mode 100644 src/main/resources/application.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d06849 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +src/main/generated/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ +.DS_Store + +application-*.yml + +devtools/ +devtools/** diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..548f512 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,4 @@ +spring: + profiles: + default: dev + From a1c6e6f82dd422d243ccc155bfe23ddccfad4fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=EB=AF=BC?= Date: Thu, 15 Jan 2026 22:29:44 +0900 Subject: [PATCH 3/5] fix(eventlog): add DLQ for parse and DB failures and cover flush scheduler with tests --- .../service/EventLogFlushScheduler.java | 40 +++- .../eventlog/service/EventLogStore.java | 31 +++ src/main/resources/application.yml | 3 +- .../service/EventLogFlushSchedulerTest.java | 180 +++++++++++++++++- .../service/EventLogIngestServiceTest.java | 6 +- 5 files changed, 238 insertions(+), 22 deletions(-) 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 index 4726cf5..56b6e6a 100644 --- a/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java @@ -39,6 +39,7 @@ public void flushAllApps() { /* 한 앱에 대해 Redis 버퍼에서 이벤트를 읽어와 DB에 저장 */ + public void flushApp(Long redotAppId) { String key = eventLogStore.bufferKey(redotAppId); @@ -59,19 +60,42 @@ public void flushApp(Long redotAppId) { cmd.ip(), cmd.occurredAt() )); - } catch (Exception ignored) { } + } catch (Exception e) { + eventLogStore.pushDeadLetter(redotAppId, json, "PARSE_FAIL: " + e.getMessage()); + } } - // 중복(event_id unique)으로 saveAll 실패 가능 → 폴백 - try { - repo.saveAll(entities); - } catch (Exception e) { - for (EventLogEntity entity : entities) { - try { repo.save(entity); } catch (Exception ignored) {} + // DB 저장 실패(중복/기타)도 손실 방지 위해 DLQ로 이동 + if (!entities.isEmpty()) { + try { + repo.saveAll(entities); + } catch (Exception e) { + for (EventLogEntity entity : entities) { + try { + repo.save(entity); + } catch (Exception ex) { + // entity를 다시 JSON으로 만들 수 있으면 DLQ로, 아니면 최소 로그라도 남김 + eventLogStore.pushDeadLetter( + redotAppId, + safeToJson(entity), + "DB_SAVE_FAIL: " + ex.getMessage() + ); + } + } } } - // DB 저장 성공(중복은 무시) 후 trim + // "꺼내서 처리한 items"는 제거 (실패건은 DLQ로 옮겼으니 손실 아님) stringRedisTemplate.opsForList().trim(key, items.size(), -1); } + + + private String safeToJson(EventLogEntity entity) { + try { + // ObjectMapper 주입 안 받으려면 entity 정보를 문자열로라도 남겨 + return entity.toString(); + } catch (Exception ignored) { + return "EventLogEntity(toString_failed)"; + } + } } \ 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 index ec64a49..a5ae4d4 100644 --- a/src/main/java/redot/redot_server/domain/eventlog/service/EventLogStore.java +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogStore.java @@ -15,6 +15,9 @@ 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"; @@ -34,6 +37,18 @@ public void pushPageView(Long redotAppId, String 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 조회 */ @@ -73,4 +88,20 @@ public long size(Long redotAppId) { 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 index fa5de5c..466e0e1 100644 --- a/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java @@ -41,10 +41,7 @@ class EventLogFlushSchedulerTest { void setUp() { template = mock(StringRedisTemplate.class); store = mock(EventLogStore.class); - om = new ObjectMapper() - .registerModule(new JavaTimeModule()) - .disable(WRITE_DATES_AS_TIMESTAMPS) - .disable(ADJUST_DATES_TO_CONTEXT_TIME_ZONE); + om = new ObjectMapper().registerModule(new JavaTimeModule()); repo = mock(EventLogRepository.class); @@ -56,7 +53,7 @@ void setUp() { } /* - flushApp가 Redis에서 읽어와 DB에 저장하고, 버퍼를 잘 트림하는지 검증 + flushApp가 Redis에서 읽어와 DB에 저장하고 버퍼를 트림하는지 검증 */ @Test void flushApp_reads_from_redis_saves_to_db_and_trims_buffer() throws Exception { @@ -79,13 +76,18 @@ void flushApp_reads_from_redis_saves_to_db_and_trims_buffer() throws Exception { scheduler.flushApp(appId); // then - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + (ArgumentCaptor) ArgumentCaptor.forClass(List.class); + verify(repo, times(1)).saveAll(captor.capture()); List saved = captor.getValue(); assertThat(saved).hasSize(2); - // trim: 앞에서 읽은 개수만큼 제거 + // saveAll 성공이면 개별 save는 없어야 함 + verify(repo, never()).save(any(EventLogEntity.class)); + verify(listOps, times(1)).trim(key, 2, -1); } @@ -138,7 +140,169 @@ void flushApp_when_no_items_does_nothing() { scheduler.flushApp(appId); // then - verifyNoInteractions(repo); + 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); + } + + + @Test + void flushApp_when_contains_invalid_json_still_trims_by_items_size() 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")); + + String valid = om.writeValueAsString(c1); + String invalid = "{not-json"; + + when(listOps.range(key, 0, 2000 - 1)).thenReturn(List.of(valid, invalid)); + + // when + scheduler.flushApp(appId); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + (ArgumentCaptor) ArgumentCaptor.forClass(List.class); + + verify(repo, times(1)).saveAll(captor.capture()); + + // 유효한 것만 파싱되면 1개만 저장됨 + assertThat(captor.getValue()).hasSize(1); + + // trim은 items.size() = 2 로 수행됨(현재 정책) + verify(listOps, times(1)).trim(key, 2, -1); + } + + /* + 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(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 index d0ee620..82a23cf 100644 --- a/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java @@ -23,9 +23,7 @@ class EventLogIngestServiceTest { void ingestPageView_serializes_and_pushes_to_store() { EventLogStore store = mock(EventLogStore.class); - ObjectMapper om = new ObjectMapper() - .registerModule(new JavaTimeModule()) - .disable(WRITE_DATES_AS_TIMESTAMPS); // ISO-8601로 찍히게(선택) + ObjectMapper om = new ObjectMapper().registerModule(new JavaTimeModule()); EventLogIngestService service = new EventLogIngestService(store, om); @@ -34,7 +32,7 @@ void ingestPageView_serializes_and_pushes_to_store() { 10L, DeviceType.MOBILE, "127.0.0.1", - Instant.now() + Instant.parse("2026-01-14T12:00:00Z") ); service.ingestPageView(cmd); From b774559cd35cea473b9d0e5bba7fb00f82ea9f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=EB=AF=BC?= Date: Thu, 15 Jan 2026 22:49:44 +0900 Subject: [PATCH 4/5] fix(eventlog): flush refactor with DLQ handling, auto-unregister inactive apps, and tests --- .../service/EventLogFlushScheduler.java | 88 +++++++++++++------ .../service/EventLogFlushSchedulerTest.java | 47 ++-------- 2 files changed, 66 insertions(+), 69 deletions(-) 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 index 56b6e6a..1b10ea6 100644 --- a/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java +++ b/src/main/java/redot/redot_server/domain/eventlog/service/EventLogFlushScheduler.java @@ -5,7 +5,6 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import redot.redot_server.domain.eventlog.dto.PageViewCommand; import redot.redot_server.domain.eventlog.entity.EventLogEntity; import redot.redot_server.domain.eventlog.repository.EventLogRepository; @@ -43,9 +42,30 @@ public void flushAllApps() { 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); - if (items == null || items.isEmpty()) return; + 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 { @@ -53,9 +73,6 @@ public void flushApp(Long redotAppId) { entities.add(EventLogEntity.pageView( cmd.eventId(), cmd.redotAppId(), -// cmd.actorType(), -// cmd.actorId(), -// cmd.anonymousId(), cmd.deviceType(), cmd.ip(), cmd.occurredAt() @@ -64,38 +81,55 @@ public void flushApp(Long redotAppId) { 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); + } + } - // DB 저장 실패(중복/기타)도 손실 방지 위해 DLQ로 이동 - if (!entities.isEmpty()) { + /* + 여러 엔티티를 개별 저장하며 실패 시 DLQ로 이동 + */ + private void saveIndividuallyWithDlq(Long redotAppId, List entities) { + for (EventLogEntity entity : entities) { try { - repo.saveAll(entities); - } catch (Exception e) { - for (EventLogEntity entity : entities) { - try { - repo.save(entity); - } catch (Exception ex) { - // entity를 다시 JSON으로 만들 수 있으면 DLQ로, 아니면 최소 로그라도 남김 - eventLogStore.pushDeadLetter( - redotAppId, - safeToJson(entity), - "DB_SAVE_FAIL: " + ex.getMessage() - ); - } - } + repo.save(entity); + } catch (Exception ex) { + eventLogStore.pushDeadLetter( + redotAppId, + safeToJson(entity), + "DB_SAVE_FAIL: " + ex.getMessage() + ); } } - - // "꺼내서 처리한 items"는 제거 (실패건은 DLQ로 옮겼으니 손실 아님) - stringRedisTemplate.opsForList().trim(key, items.size(), -1); } + /* + Redis 리스트에서 앞의 processedCount개 항목 제거 + */ + private void trimProcessed(String key, int processedCount) { + stringRedisTemplate.opsForList().trim(key, processedCount, -1); + } + /* + EventLogEntity를 안전하게 JSON 문자열로 변환 + */ private String safeToJson(EventLogEntity entity) { try { - // ObjectMapper 주입 안 받으려면 entity 정보를 문자열로라도 남겨 - return entity.toString(); + return objectMapper.writeValueAsString(entity); } catch (Exception ignored) { - return "EventLogEntity(toString_failed)"; + return String.valueOf(entity); } } + } \ 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 index 466e0e1..a0f3ec6 100644 --- a/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogFlushSchedulerTest.java @@ -17,8 +17,6 @@ import java.util.List; import java.util.UUID; -import static com.fasterxml.jackson.databind.DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE; -import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; @@ -125,27 +123,23 @@ void flushApp_when_saveAll_fails_fallbacks_to_save_each_and_still_trims() throws } /* - flushApp에서 Redis에 아이템이 없으면 아무 동작도 하지 않는지 검증 + flushApp에서 Redis 버퍼에 항목이 없으면 앱 등록 해제만 수행하는지 검증 */ @Test - void flushApp_when_no_items_does_nothing() { - // given + 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()); - // when scheduler.flushApp(appId); - // then + 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도 실패하는 경우에도 트림은 수행되는지 검증 */ @@ -180,39 +174,7 @@ void flushApp_when_saveAll_fails_and_some_save_fails_still_continues_and_trims() verify(repo, times(1)).saveAll(anyList()); verify(repo, times(2)).save(any(EventLogEntity.class)); // 실패해도 2번 시도해야 함 verify(listOps, times(1)).trim(key, 2, -1); - } - - - @Test - void flushApp_when_contains_invalid_json_still_trims_by_items_size() 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")); - - String valid = om.writeValueAsString(c1); - String invalid = "{not-json"; - - when(listOps.range(key, 0, 2000 - 1)).thenReturn(List.of(valid, invalid)); - - // when - scheduler.flushApp(appId); - - // then - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = - (ArgumentCaptor) ArgumentCaptor.forClass(List.class); - - verify(repo, times(1)).saveAll(captor.capture()); - - // 유효한 것만 파싱되면 1개만 저장됨 - assertThat(captor.getValue()).hasSize(1); - - // trim은 items.size() = 2 로 수행됨(현재 정책) - verify(listOps, times(1)).trim(key, 2, -1); + verify(store, times(1)).pushDeadLetter(eq(appId), anyString(), startsWith("DB_SAVE_FAIL:")); } /* @@ -281,6 +243,7 @@ void flushApp_when_saveAll_fails_but_individual_saves_succeed_should_not_push_to 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); From a7aa5139882f2bc540d0e9faaf94db49e62a92b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B7=9C=EB=AF=BC?= Date: Thu, 15 Jan 2026 23:22:25 +0900 Subject: [PATCH 5/5] fix(eventlog): add DLQ and flush refactor with tests; clean up duplicate db dependencies --- build.gradle | 2 -- .../domain/eventlog/service/EventLogIngestServiceTest.java | 1 - 2 files changed, 3 deletions(-) diff --git a/build.gradle b/build.gradle index 122f4b4..20e5f9b 100644 --- a/build.gradle +++ b/build.gradle @@ -76,8 +76,6 @@ dependencies { testImplementation "org.springframework.boot:spring-boot-testcontainers" testImplementation "org.testcontainers:junit-jupiter:1.20.1" testImplementation "org.testcontainers:postgresql:1.20.1" - testImplementation "org.postgresql:postgresql:42.7.3" - testImplementation "org.flywaydb:flyway-core" } tasks.named('test') { 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 index 82a23cf..c82f485 100644 --- a/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java +++ b/src/test/java/redot/redot_server/domain/eventlog/service/EventLogIngestServiceTest.java @@ -10,7 +10,6 @@ import java.time.Instant; import java.util.UUID; -import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*;