diff --git a/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java b/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java index d464a68..e93d9fe 100644 --- a/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java +++ b/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java @@ -1,5 +1,8 @@ package com.matzip.admin.event; +import com.matzip.common.analytics.AnalyticsRecorder; +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.analytics.domain.TargetType; import com.matzip.common.exception.BusinessException; import com.matzip.common.exception.code.ErrorCode; import com.matzip.lottery.service.LotteryEventService; @@ -17,10 +20,12 @@ public class RequestReviewEventListener { private final LotteryEventService lotteryEventService; private final PlaceRepository placeRepository; + private final AnalyticsRecorder analyticsRecorder; - public RequestReviewEventListener(LotteryEventService lotteryEventService, PlaceRepository placeRepository) { + public RequestReviewEventListener(LotteryEventService lotteryEventService, PlaceRepository placeRepository, AnalyticsRecorder analyticsRecorder) { this.lotteryEventService = lotteryEventService; this.placeRepository = placeRepository; + this.analyticsRecorder = analyticsRecorder; } @Async @@ -36,6 +41,7 @@ public void enterCurrentEventOnApproval(RequestReviewEvent event) { boolean entered = lotteryEventService.enterCurrentEventOnPlaceApproval(registrant.getId(), place.getId()); if (entered) { log.info("[자동 응모 완료] userId: {}, placeId: {}", registrant.getId(), place.getId()); + analyticsRecorder.record(EventType.EVENT_ENTERED, registrant.getId(), TargetType.PLACE, place.getId()); return; } log.info("[자동 응모 스킵] 진행 중 이벤트 없음 또는 이미 응모됨. userId: {}, placeId: {}", registrant.getId(), place.getId()); diff --git a/src/main/java/com/matzip/admin/service/AdminPlaceRegisterRequestService.java b/src/main/java/com/matzip/admin/service/AdminPlaceRegisterRequestService.java index e32c24a..a141246 100644 --- a/src/main/java/com/matzip/admin/service/AdminPlaceRegisterRequestService.java +++ b/src/main/java/com/matzip/admin/service/AdminPlaceRegisterRequestService.java @@ -4,6 +4,9 @@ import com.matzip.admin.domain.RequestReviewStatus; import com.matzip.admin.event.RequestReviewEvent; import com.matzip.admin.repository.RequestReviewRepository; +import com.matzip.common.analytics.AnalyticsRecorder; +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.analytics.domain.TargetType; import com.matzip.common.exception.BusinessException; import com.matzip.common.exception.code.ErrorCode; import com.matzip.place.domain.PlaceStatus; @@ -23,11 +26,13 @@ public class AdminPlaceRegisterRequestService { private final PlaceRepository placeRepository; private final RequestReviewRepository requestReviewRepository; private final ApplicationEventPublisher applicationEventPublisher; + private final AnalyticsRecorder analyticsRecorder; - public AdminPlaceRegisterRequestService(PlaceRepository placeRepository, RequestReviewRepository requestReviewRepository, ApplicationEventPublisher applicationEventPublisher) { + public AdminPlaceRegisterRequestService(PlaceRepository placeRepository, RequestReviewRepository requestReviewRepository, ApplicationEventPublisher applicationEventPublisher, AnalyticsRecorder analyticsRecorder) { this.placeRepository = placeRepository; this.requestReviewRepository = requestReviewRepository; this.applicationEventPublisher = applicationEventPublisher; + this.analyticsRecorder = analyticsRecorder; } @Transactional @@ -44,6 +49,11 @@ public void review(Long placeId, RequestReviewStatus status, String rejectedReas } applicationEventPublisher.publishEvent(new RequestReviewEvent(placeId, status)); + + EventType eventType = status == RequestReviewStatus.APPROVED + ? EventType.PLACE_APPROVED + : EventType.PLACE_REJECTED; + analyticsRecorder.record(eventType, null, TargetType.PLACE, placeId); } private void approve(Place place/*, Long adminId*/) { diff --git a/src/main/java/com/matzip/auth/service/AuthService.java b/src/main/java/com/matzip/auth/service/AuthService.java index 16f40ab..fb63c39 100644 --- a/src/main/java/com/matzip/auth/service/AuthService.java +++ b/src/main/java/com/matzip/auth/service/AuthService.java @@ -8,6 +8,8 @@ import com.matzip.auth.client.kakao.KakaoLoginApiClient; import com.matzip.auth.client.kakao.dto.KakaoTokenResponseDto; import com.matzip.auth.client.kakao.dto.KakaoUserResponseDto; +import com.matzip.common.analytics.AnalyticsRecorder; +import com.matzip.common.analytics.domain.EventType; import com.matzip.common.config.ImageProperties; import com.matzip.common.config.JwtProperties; import com.matzip.common.exception.BusinessException; @@ -36,6 +38,7 @@ public class AuthService { private final NickNameGenerator nickNameGenerator; private final ProfileAssignment profileAssignment; + private final AnalyticsRecorder analyticsRecorder; private long accessTokenTtlMs() { return jwtProps.getExpirationTime(); @@ -94,6 +97,12 @@ public LoginResponseDto login(KakaoLoginRequestDto loginRequest) { saved.updateToken(refreshToken); // 변경 감지에 의해 업데이트 } + // 5) 분석 이벤트 기록 (GA가 못 보는 회원 가입/로그인 신호) + if (firstLogin) { + analyticsRecorder.record(EventType.SIGNUP, user.getId()); + } + analyticsRecorder.record(EventType.LOGIN, user.getId()); + return LoginResponseDto.builder() .tokenType("Bearer") .accessToken(accessToken) diff --git a/src/main/java/com/matzip/common/analytics/AnalyticsEvent.java b/src/main/java/com/matzip/common/analytics/AnalyticsEvent.java new file mode 100644 index 0000000..a4ab922 --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/AnalyticsEvent.java @@ -0,0 +1,30 @@ +package com.matzip.common.analytics; + +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.analytics.domain.TargetType; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 도메인 서비스가 발행하는 분석 이벤트 페이로드 + * 도메인은 이 record만 발행하고, 적재 책임은 AnalyticsEventListener가 진다 + */ +public record AnalyticsEvent( + EventType eventType, + Long userId, + String anonymousId, + TargetType targetType, + Long targetId, + Map properties, + LocalDateTime occurredAt +) { + + public static AnalyticsEvent of(EventType eventType, Long userId) { + return new AnalyticsEvent(eventType, userId, null, null, null, null, LocalDateTime.now()); + } + + public static AnalyticsEvent of(EventType eventType, Long userId, TargetType targetType, Long targetId) { + return new AnalyticsEvent(eventType, userId, null, targetType, targetId, null, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/matzip/common/analytics/AnalyticsEventListener.java b/src/main/java/com/matzip/common/analytics/AnalyticsEventListener.java new file mode 100644 index 0000000..c5c3664 --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/AnalyticsEventListener.java @@ -0,0 +1,37 @@ +package com.matzip.common.analytics; + +import com.matzip.common.analytics.domain.entity.EventLog; +import com.matzip.common.analytics.repository.EventLogRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 분석 이벤트를 event_log에 적재한다. + * - AFTER_COMMIT: 커밋된 액션만 기록(롤백되면 남기지 않음) + * - fallbackExecution: 트랜잭션 밖에서 발행된 이벤트(예: 자동 응모 후)도 놓치지 않음 + * - @Async + try-catch: 적재 실패·지연이 비즈니스 흐름에 영향을 주지 않도록 격리(fire-and-forget) + */ +@Slf4j +@Component +public class AnalyticsEventListener { + + private final EventLogRepository eventLogRepository; + + public AnalyticsEventListener(EventLogRepository eventLogRepository) { + this.eventLogRepository = eventLogRepository; + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void on(AnalyticsEvent event) { + try { + eventLogRepository.save(EventLog.from(event)); + } catch (Exception e) { + log.warn("[분석 이벤트 적재 실패] type={}, userId={}, targetId={}", + event.eventType(), event.userId(), event.targetId(), e); + } + } +} diff --git a/src/main/java/com/matzip/common/analytics/AnalyticsRecorder.java b/src/main/java/com/matzip/common/analytics/AnalyticsRecorder.java new file mode 100644 index 0000000..e6f5ee5 --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/AnalyticsRecorder.java @@ -0,0 +1,32 @@ +package com.matzip.common.analytics; + +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.analytics.domain.TargetType; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * 도메인 서비스가 한 줄로 분석 이벤트를 발행하도록 돕는 얇은 헬퍼 + * 실제 적재는 AnalyticsEventListener가 커밋 이후 비동기로 수행한다. + */ +@Component +public class AnalyticsRecorder { + + private final ApplicationEventPublisher applicationEventPublisher; + + public AnalyticsRecorder(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + public void record(AnalyticsEvent event) { + applicationEventPublisher.publishEvent(event); + } + + public void record(EventType eventType, Long userId) { + record(AnalyticsEvent.of(eventType, userId)); + } + + public void record(EventType eventType, Long userId, TargetType targetType, Long targetId) { + record(AnalyticsEvent.of(eventType, userId, targetType, targetId)); + } +} diff --git a/src/main/java/com/matzip/common/analytics/JsonMapConverter.java b/src/main/java/com/matzip/common/analytics/JsonMapConverter.java new file mode 100644 index 0000000..ac2954c --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/JsonMapConverter.java @@ -0,0 +1,44 @@ +package com.matzip.common.analytics; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Map; + +/** + * event_log.properties(부가 속성)를 Map ↔ JSON 문자열로 자동 변환한다. + * 값이 없으면 null로 저장해 컬럼을 비워둔다(MVP 단계에선 대부분 비어 있음) + */ +@Converter +public class JsonMapConverter implements AttributeConverter, String> { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null || attribute.isEmpty()) { + return null; + } + try { + return OBJECT_MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + throw new IllegalArgumentException("이벤트 properties 직렬화 실패", e); + } + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return null; + } + try { + return OBJECT_MAPPER.readValue(dbData, MAP_TYPE); + } catch (Exception e) { + throw new IllegalArgumentException("이벤트 properties 역직렬화 실패", e); + } + } +} diff --git a/src/main/java/com/matzip/common/analytics/domain/EventType.java b/src/main/java/com/matzip/common/analytics/domain/EventType.java new file mode 100644 index 0000000..a32c253 --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/domain/EventType.java @@ -0,0 +1,14 @@ +package com.matzip.common.analytics.domain; + +/** + * 서버사이드에서 적재하는 비즈니스 이벤트 종류 + * GA4가 보지 못하거나 DB 조인이 필요한 이벤트만 남긴다. + */ +public enum EventType { + SIGNUP, + LOGIN, + PLACE_REGISTER_REQUESTED, + PLACE_APPROVED, + PLACE_REJECTED, + EVENT_ENTERED +} diff --git a/src/main/java/com/matzip/common/analytics/domain/TargetType.java b/src/main/java/com/matzip/common/analytics/domain/TargetType.java new file mode 100644 index 0000000..cb418f4 --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/domain/TargetType.java @@ -0,0 +1,9 @@ +package com.matzip.common.analytics.domain; + +/** + * 이벤트가 가리키는 대상 종류. target_id와 함께 저장해 비즈니스 테이블과 조인할 때 쓴다. + */ +public enum TargetType { + PLACE, + LOTTERY_EVENT +} diff --git a/src/main/java/com/matzip/common/analytics/domain/entity/EventLog.java b/src/main/java/com/matzip/common/analytics/domain/entity/EventLog.java new file mode 100644 index 0000000..d9b05ad --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/domain/entity/EventLog.java @@ -0,0 +1,80 @@ +package com.matzip.common.analytics.domain.entity; + +import com.matzip.common.analytics.AnalyticsEvent; +import com.matzip.common.analytics.JsonMapConverter; +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Map; + +@Getter +@Entity +@Table( + name = "event_log", + indexes = { + @Index(name = "idx_event_log_type_occurred", columnList = "event_type, occurred_at"), + @Index(name = "idx_event_log_user_occurred", columnList = "user_id, occurred_at") + } +) +public class EventLog extends BaseEntity { + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 50) + private EventType eventType; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "anonymous_id", length = 100) + private String anonymousId; + + @Column(name = "target_type", length = 50) + private String targetType; + + @Column(name = "target_id") + private Long targetId; + + @Convert(converter = JsonMapConverter.class) + @Column(name = "properties", length = 4000) + private Map properties; + + @Column(name = "occurred_at", nullable = false) + private LocalDateTime occurredAt; + + protected EventLog() { + } + + @Builder + private EventLog(EventType eventType, Long userId, String anonymousId, String targetType, + Long targetId, Map properties, LocalDateTime occurredAt) { + this.eventType = eventType; + this.userId = userId; + this.anonymousId = anonymousId; + this.targetType = targetType; + this.targetId = targetId; + this.properties = properties; + this.occurredAt = occurredAt; + } + + public static EventLog from(AnalyticsEvent event) { + return EventLog.builder() + .eventType(event.eventType()) + .userId(event.userId()) + .anonymousId(event.anonymousId()) + .targetType(event.targetType() == null ? null : event.targetType().name()) + .targetId(event.targetId()) + .properties(event.properties()) + .occurredAt(event.occurredAt() == null ? LocalDateTime.now() : event.occurredAt()) + .build(); + } +} diff --git a/src/main/java/com/matzip/common/analytics/repository/EventLogRepository.java b/src/main/java/com/matzip/common/analytics/repository/EventLogRepository.java new file mode 100644 index 0000000..da90b21 --- /dev/null +++ b/src/main/java/com/matzip/common/analytics/repository/EventLogRepository.java @@ -0,0 +1,7 @@ +package com.matzip.common.analytics.repository; + +import com.matzip.common.analytics.domain.entity.EventLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/matzip/place/service/PlaceService.java b/src/main/java/com/matzip/place/service/PlaceService.java index 2e58186..81d1299 100644 --- a/src/main/java/com/matzip/place/service/PlaceService.java +++ b/src/main/java/com/matzip/place/service/PlaceService.java @@ -28,6 +28,9 @@ import com.matzip.user.domain.User; import com.matzip.user.repository.UserRepository; import com.matzip.common.infra.discord.DiscordWebhookSender; +import com.matzip.common.analytics.AnalyticsRecorder; +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.analytics.domain.TargetType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -58,6 +61,7 @@ public class PlaceService { private final UserRepository userRepository; private final PlaceTempStoreMemory placeTempStore; private final DiscordWebhookSender discordWebhookSender; + private final AnalyticsRecorder analyticsRecorder; public PlaceCheckResponseDto preview(PlaceCheckRequestDto req) { final String kakaoPlaceId = req.getKakaoPlaceId(); @@ -150,6 +154,12 @@ public PlaceRegisterResponseDto register(PlaceRequestDto req) { .build(); placeRepository.save(place); + analyticsRecorder.record( + EventType.PLACE_REGISTER_REQUESTED, + registeredBy == null ? null : registeredBy.getId(), + TargetType.PLACE, + place.getId()); + List categoryIds = req.getCategoryIds(); if (categoryIds.size() != new LinkedHashSet<>(categoryIds).size()) { throw new IllegalArgumentException("categoryId는 중복될 수 없습니다."); diff --git a/src/main/resources/sql/event_log.sql b/src/main/resources/sql/event_log.sql new file mode 100644 index 0000000..950df51 --- /dev/null +++ b/src/main/resources/sql/event_log.sql @@ -0,0 +1,75 @@ +-- 비즈니스 이벤트 로깅(event_log) 참고 스크립트 +-- GA4(클라이언트 자동수집)가 못 보거나 DB 조인이 필요한 비즈니스 이벤트를 서버에서 적재한다. +-- 적재 경로: common/analytics 의 AnalyticsRecorder(발행) -> AnalyticsEventListener(비동기 AFTER_COMMIT 적재) + +-- ===================================================================== +-- [필수] prod 배포 전 수동 DDL +-- prod 는 ddl-auto: validate 이므로 이 테이블을 먼저 생성하지 않으면 기동에 실패한다. +-- (local/test 는 자동 생성) 컬럼 정의는 엔티티 매핑과 일치시켜 validate 를 통과시킨다. +-- 배포 후 기동 로그에 스키마 validate 에러가 없는지 확인할 것. +-- ===================================================================== +CREATE TABLE event_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + event_type VARCHAR(50) NOT NULL, + user_id BIGINT NULL, + anonymous_id VARCHAR(100) NULL, + target_type VARCHAR(50) NULL, + target_id BIGINT NULL, + properties VARCHAR(4000) NULL, + occurred_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + KEY idx_event_log_type_occurred (event_type, occurred_at), + KEY idx_event_log_user_occurred (user_id, occurred_at) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; + +-- 적재 이벤트(MVP): SIGNUP, LOGIN, PLACE_REGISTER_REQUESTED, +-- PLACE_APPROVED, PLACE_REJECTED, EVENT_ENTERED + +-- ===================================================================== +-- 검증용 집계 쿼리 +-- ===================================================================== + +-- 1) 일별 신규 가입 추이 (획득) +SELECT DATE(occurred_at) AS d, COUNT(*) AS signups +FROM event_log +WHERE event_type = 'SIGNUP' +GROUP BY DATE(occurred_at) +ORDER BY d; + +-- 2) 일별 DAU (로그인 기준, 회원 리텐션의 기반) +SELECT DATE(occurred_at) AS d, COUNT(DISTINCT user_id) AS dau +FROM event_log +WHERE event_type = 'LOGIN' +GROUP BY DATE(occurred_at) +ORDER BY d; + +-- 3) 가입 주차 대비 7일 이후 재로그인 (주간 코호트 리텐션 간이 버전) +WITH signup AS ( + SELECT user_id, MIN(occurred_at) AS signup_at + FROM event_log WHERE event_type = 'SIGNUP' GROUP BY user_id +) +SELECT + YEARWEEK(s.signup_at, 3) AS signup_week, + COUNT(DISTINCT s.user_id) AS signups, + COUNT(DISTINCT CASE WHEN l.occurred_at >= s.signup_at + INTERVAL 7 DAY + THEN l.user_id END) AS relogin_after_7d +FROM signup s +LEFT JOIN event_log l ON l.user_id = s.user_id AND l.event_type = 'LOGIN' +GROUP BY YEARWEEK(s.signup_at, 3) +ORDER BY signup_week; + +-- 4) 등록 -> 승인/반려 -> 응모 퍼널 +SELECT event_type, COUNT(*) AS cnt +FROM event_log +WHERE event_type IN ('PLACE_REGISTER_REQUESTED', 'PLACE_APPROVED', 'PLACE_REJECTED', 'EVENT_ENTERED') +GROUP BY event_type; + +-- 5) 이벤트 응모를 캠퍼스별로 (GA 가 못 하는 비즈니스 조인 예시) +SELECT p.campus, COUNT(*) AS entries +FROM event_log e +JOIN place p ON e.target_type = 'PLACE' AND e.target_id = p.id +WHERE e.event_type = 'EVENT_ENTERED' +GROUP BY p.campus; diff --git a/src/test/java/com/matzip/common/analytics/AnalyticsEventListenerTest.java b/src/test/java/com/matzip/common/analytics/AnalyticsEventListenerTest.java new file mode 100644 index 0000000..2bdb852 --- /dev/null +++ b/src/test/java/com/matzip/common/analytics/AnalyticsEventListenerTest.java @@ -0,0 +1,60 @@ +package com.matzip.common.analytics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.matzip.AbstractMatzipApplicationTest; +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.analytics.domain.TargetType; +import com.matzip.common.analytics.domain.entity.EventLog; +import com.matzip.common.analytics.repository.EventLogRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class AnalyticsEventListenerTest extends AbstractMatzipApplicationTest { + + @Autowired + private EventLogRepository eventLogRepository; + + // @Async 프록시를 우회해 매핑·적재 로직을 동기적으로 검증하기 위해 직접 생성한다. + private AnalyticsEventListener analyticsEventListener; + + @BeforeEach + void clearEventLog() { + eventLogRepository.deleteAll(); + analyticsEventListener = new AnalyticsEventListener(eventLogRepository); + } + + @Test + void 이벤트를_event_log에_필드까지_그대로_적재한다() { + AnalyticsEvent event = AnalyticsEvent.of(EventType.PLACE_REGISTER_REQUESTED, 42L, TargetType.PLACE, 7L); + + analyticsEventListener.on(event); + + List logs = eventLogRepository.findAll(); + assertThat(logs).hasSize(1); + EventLog saved = logs.get(0); + assertThat(saved.getEventType()).isEqualTo(EventType.PLACE_REGISTER_REQUESTED); + assertThat(saved.getUserId()).isEqualTo(42L); + assertThat(saved.getTargetType()).isEqualTo("PLACE"); + assertThat(saved.getTargetId()).isEqualTo(7L); + assertThat(saved.getOccurredAt()).isNotNull(); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + void properties가_JSON으로_왕복된다() { + AnalyticsEvent event = new AnalyticsEvent( + EventType.PLACE_REJECTED, null, null, TargetType.PLACE, 3L, + Map.of("reason", "중복 등록"), LocalDateTime.now()); + + analyticsEventListener.on(event); + + EventLog saved = eventLogRepository.findAll().get(0); + assertThat(saved.getUserId()).isNull(); + assertThat(saved.getProperties()).containsEntry("reason", "중복 등록"); + } +} diff --git a/src/test/java/com/matzip/common/analytics/AnalyticsEventLoggingIntegrationTest.java b/src/test/java/com/matzip/common/analytics/AnalyticsEventLoggingIntegrationTest.java new file mode 100644 index 0000000..7f7fccc --- /dev/null +++ b/src/test/java/com/matzip/common/analytics/AnalyticsEventLoggingIntegrationTest.java @@ -0,0 +1,94 @@ +package com.matzip.common.analytics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.matzip.AbstractMatzipApplicationTest; +import com.matzip.common.analytics.domain.EventType; +import com.matzip.common.analytics.repository.EventLogRepository; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 발행(AnalyticsRecorder) → 트랜잭션 커밋 → 비동기 리스너 적재까지의 통합 동작 검증. + * AFTER_COMMIT 의미상, 커밋된 트랜잭션의 이벤트만 적재되고 롤백되면 남지 않아야 한다. + */ +class AnalyticsEventLoggingIntegrationTest extends AbstractMatzipApplicationTest { + + @Autowired + private AnalyticsRecorder analyticsRecorder; + @Autowired + private EventLogRepository eventLogRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUpTransactionTemplate() { + transactionTemplate = new TransactionTemplate(transactionManager); + eventLogRepository.deleteAll(); + } + + @Test + void 커밋된_트랜잭션에서_발행한_이벤트는_비동기로_적재된다() { + transactionTemplate.executeWithoutResult(status -> + analyticsRecorder.record(EventType.LOGIN, 100L)); + + // 비동기 AFTER_COMMIT 리스너가 적재할 때까지 대기 + boolean saved = waitUntil(() -> eventLogRepository.count() == 1, 2000); + + assertThat(saved).isTrue(); + assertThat(eventLogRepository.findAll().get(0).getEventType()).isEqualTo(EventType.LOGIN); + } + + @Test + void 롤백된_트랜잭션에서_발행한_이벤트는_적재되지_않는다() { + try { + transactionTemplate.executeWithoutResult(status -> { + analyticsRecorder.record(EventType.LOGIN, 200L); + throw new RuntimeException("의도적 롤백"); + }); + } catch (RuntimeException ignored) { + // 롤백 유도용 예외 + } + + // AFTER_COMMIT은 롤백 시 실행되지 않으므로, 잠시 대기해도 적재되지 않아야 한다 + boolean neverSaved = stayFalse(() -> eventLogRepository.count() > 0, 500); + assertThat(neverSaved).isTrue(); + } + + private boolean waitUntil(BooleanSupplier condition, long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + sleep(); + } + return condition.getAsBoolean(); + } + + private boolean stayFalse(BooleanSupplier condition, long durationMs) { + long deadline = System.currentTimeMillis() + durationMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return false; + } + sleep(); + } + return !condition.getAsBoolean(); + } + + private void sleep() { + try { + TimeUnit.MILLISECONDS.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/com/matzip/common/analytics/JsonMapConverterTest.java b/src/test/java/com/matzip/common/analytics/JsonMapConverterTest.java new file mode 100644 index 0000000..1cf2048 --- /dev/null +++ b/src/test/java/com/matzip/common/analytics/JsonMapConverterTest.java @@ -0,0 +1,36 @@ +package com.matzip.common.analytics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JsonMapConverterTest { + + private final JsonMapConverter converter = new JsonMapConverter(); + + @Test + void Map를_JSON_문자열로_직렬화하고_다시_Map으로_역직렬화한다() { + Map properties = Map.of("query", "돈까스", "resultCount", 12); + + String json = converter.convertToDatabaseColumn(properties); + Map restored = converter.convertToEntityAttribute(json); + + assertThat(json).contains("돈까스"); + assertThat(restored).containsEntry("query", "돈까스"); + assertThat(restored).containsEntry("resultCount", 12); + } + + @Test + void null이나_빈_Map은_null로_저장한다() { + assertThat(converter.convertToDatabaseColumn(null)).isNull(); + assertThat(converter.convertToDatabaseColumn(Map.of())).isNull(); + } + + @Test + void null이나_빈_문자열은_null로_복원한다() { + assertThat(converter.convertToEntityAttribute(null)).isNull(); + assertThat(converter.convertToEntityAttribute("")).isNull(); + assertThat(converter.convertToEntityAttribute(" ")).isNull(); + } +} diff --git a/src/test/java/com/matzip/place/application/PlaceServiceCachingTest.java b/src/test/java/com/matzip/place/application/PlaceServiceCachingTest.java index 042dc9b..8a4c617 100644 --- a/src/test/java/com/matzip/place/application/PlaceServiceCachingTest.java +++ b/src/test/java/com/matzip/place/application/PlaceServiceCachingTest.java @@ -1,5 +1,6 @@ package com.matzip.place.application; +import com.matzip.common.analytics.AnalyticsRecorder; import com.matzip.common.infra.discord.DiscordWebhookSender; import com.matzip.place.client.kakao.dto.KakaoMenuDto; import com.matzip.place.dto.PhotoDto; @@ -56,6 +57,7 @@ class PlaceServiceCachingTest { @Mock private PlaceCategoryRepository placeCategoryRepository; @Mock private PlaceTagRepository placeTagRepository; @Mock private DiscordWebhookSender discordWebhookSender; + @Mock private AnalyticsRecorder analyticsRecorder; private static final String TEST_KAKAO_PLACE_ID = "1852074823"; diff --git a/src/test/java/com/matzip/place/application/PlaceServiceTest.java b/src/test/java/com/matzip/place/application/PlaceServiceTest.java index ad956cd..4f9ac10 100644 --- a/src/test/java/com/matzip/place/application/PlaceServiceTest.java +++ b/src/test/java/com/matzip/place/application/PlaceServiceTest.java @@ -1,5 +1,6 @@ package com.matzip.place.application; +import com.matzip.common.analytics.AnalyticsRecorder; import com.matzip.common.infra.discord.DiscordWebhookSender; import com.matzip.place.dto.request.PlaceRequestDto; import com.matzip.place.client.PlaceTempStoreMemory; @@ -55,6 +56,7 @@ class PlaceServiceTest { @Mock private PlaceCategoryRepository placeCategoryRepository; @Mock private PlaceTagRepository placeTagRepository; @Mock private DiscordWebhookSender discordWebhookSender; + @Mock private AnalyticsRecorder analyticsRecorder; @Test @DisplayName("카테고리는 요청 categoryIds 순서대로 저장된다")